diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1690498 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,20 @@ +name: ci + +on: + push: + branches: [main, master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "22" + - name: Install dev dependencies + run: npm install --no-fund --no-audit + - name: Unit tests + run: npm test diff --git a/.gitignore b/.gitignore index 1e24ff7..c0fcd16 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ node_modules/ .no-mistakes/ *.log .DS_Store +*.tsbuildinfo +.pi/ +dist/ +coverage/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1df05e1 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,38 @@ +# AGENTS.md — firstmate-crew-knowledge + +Pi-Package (`pi-package`), das Firstmate bei Crew-Routing und Quota-Balance unterstützt. + +## Was dieses Repo ist + +- Installierbare Extension: `package.json` → `"pi": { "extensions": ["./index.ts"] }` +- Tools: `crew_route`, `crew_balance`, `crew_apply_dispatch`, `crew_suggest_primary` +- Knowledge: `knowledge/*.json` (Task-Klassen, Provider, Profile) +- Logik in `src/` + +## Was es nicht ist + +- Kein Ersatz für Firstmate-Core (`bin/fm-spawn.sh`, Intake-Urteil, `quota-array-dispatch`) +- Kein Auto-Spawn, kein Credential-Store, kein Billing + +## Firstmate-Verträge (Pointer) + +Autoritative Firstmate-Doku liegt im Firstmate-Home (nicht hier kopieren): + +- Dispatch-Schema / `config/crew-dispatch.json` → Firstmate `docs/configuration.md` (Crew dispatch profiles) und `docs/examples/crew-dispatch.json` +- Array-Auflösung → Firstmate-Skill `quota-array-dispatch` +- Spawn-Flags → `bin/fm-spawn.sh --harness/--model/--effort` +- Quota-Daten → `quota-axi --json` (Skill `quota-axi`) + +## Lokale Konventionen + +- User-facing Docs: Deutsch; kurze English-Section in README ok +- Commits/PR: Deutsch, ohne AI-Co-Author-Meta +- Writes nur unter dem aufgelösten Home/config (siehe README) und nur bei explizitem Tool-Call (`dryRun=false`) +- Tests: `npm test` (Node built-in test runner, Fixtures, kein Netz) + +## Maintaining this file + +Keep this file for knowledge useful to almost every future agent session in this project. +Do not repeat what the codebase already shows; point to the authoritative file or command instead. +Prefer rewriting or pruning existing entries over appending new ones. +When updating this file, preserve this bar for all agents and keep entries concise. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e80024a --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 thelad-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b949c9d..b6b7945 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,86 @@ # firstmate-crew-knowledge -Pi-Extension für Firstmate: modell- und harness-Routing pro Task plus ausgewogene Quota über Cursor, Claude, Codex und Grok. +Pi-Extension für [Firstmate](https://github.com/thelad-dev): empfiehlt pro Task-Klasse konkrete `{harness, model, effort}`-Profile und balanciert die Nutzung über die Captain-Subscriptions **Cursor**, **Claude**, **ChatGPT/Codex** und **Grok (xAI)**. -Status: MVP in Arbeit. Installation und Nutzung folgen nach dem ersten Release. +> **Wichtig:** Dieses Paket **ersetzt weder Firstmate-Urteil noch `fm-spawn`**. Firstmate bleibt Owner von Intake, `quota-array-dispatch` und dem finalen Spawn. Die Tools liefern nur Empfehlungen, Spawn-Flags und optional eine `crew-dispatch.json`. + +## Install + +```bash +# npm (sobald published) +pi install npm:firstmate-crew-knowledge + +# GitHub +pi install git:github.com/thelad-dev/firstmate-crew-knowledge + +# lokaler Checkout +pi install /pfad/zu/firstmate-crew-knowledge +``` + +Voraussetzung: Node ≥ 22.6, Pi Coding Agent. Für Live-Quota: [`quota-axi`](https://www.npmjs.com/package/quota-axi) auf `PATH` (`quota-axi --json`). + +## English (short) + +Installable Pi package (`pi-package`) that classifies crew work, recommends `--harness/--model/--effort` for Firstmate’s `fm-spawn`, and ranks providers from live `quota-axi --json`. It can dry-run or write `config/crew-dispatch.json` under the resolved Firstmate home. It does **not** auto-spawn and does **not** replace Firstmate judgment. + +## Firstmate-Anbindung + +| Oberfläche | Zweck | +| --- | --- | +| Tool `crew_route` | Task-Text → Klasse + Spawn-Flags + Tradeoff/Quota-Evidence | +| Tool `crew_balance` | Live-Quota sortiert (Headroom/Runway), degraded wenn `quota-axi` fehlt | +| Tool `crew_apply_dispatch` | Erzeugt Firstmate-`crew-dispatch.json` (Default: **dry-run**; `merge` hängt fehlende `when`-Regeln an und verweigert unlesbare/ungültige Bestandsdateien) | +| Tool `crew_suggest_primary` | Listet/sucht authentifizierte Pi-Session-Modelle (`modelRegistry` / `scopedModels`) und empfiehlt ein Primary-Switch-Ziel nur darunter (kein Model-Switch, keine Crew-Panes) | +| Command `/crew-route …` | Slash-Einstieg: vollständige `crew_route`-Ausgabe via Widget/Notify/Status (ohne Truncation, ohne Editor) | + +### Empfohlener Ablauf im Primary + +1. Task skizzieren → `crew_route` (oder `/crew-route …`). +2. Output lesen: Zeile `spawn: --harness … --model … --effort …` plus Rationale. +3. Optional `crew_balance` für den Gesamtblick über Subscriptions. +4. Optional `crew_apply_dispatch` dry-run; bei Bedarf mit `dryRun=false` nach `$FM_HOME/config/crew-dispatch.json` schreiben. +5. **Firstmate** wählt final (ggf. Array via `quota-array-dispatch`) und ruft auf: + +```bash +bin/fm-spawn.sh --mode … --yolo … \ + --harness … --model … --effort … +``` + +Schema der Dispatch-Datei entspricht Firstmate `docs/examples/crew-dispatch.json` (`rules[].when/use/why`, `default`). + +### Umgebungsvariablen + +| Variable | Bedeutung | +| --- | --- | +| `FM_HOME` | Operational Home; Schreibziel `config/crew-dispatch.json` | +| `FM_ROOT_OVERRIDE` | Fallback, wenn `FM_HOME` unset | + +Ohne aufgelöstes Home (`FM_HOME` oder Fallback `FM_ROOT_OVERRIDE`) nur Dry-Run/Anzeige — **keine** Writes. Writes gehen ausschließlich nach `/config/…` (explizites Tool mit `dryRun=false`). + +## Knowledge-Pack + +Datengetrieben unter [`knowledge/`](knowledge/): + +- `task-classes.json` — trivial_fix, standard_ship, hard_multi_file, research_web_live, scout_audit +- `providers.json` — Rollen/Stärken Claude · Codex · Cursor · Grok, Pi-Model-Muster (`provider/model`) +- `profiles.json` — konkrete Profile + Dispatch-Template + +Tabellen aktualisieren, ohne Scorer-Code anzufassen. + +## Fail-closed / Degraded + +- `quota-axi` fehlt oder schlägt fehl → klare Meldung, Offline-Heuristik aus Knowledge, Label **`degraded`**. Abbruch der Tool-Ausführung ist Cancellation, nicht Degraded. +- Unbekannte Quota (einschließlich stale Snapshots) wird **nicht** als 0 behandelt; erschöpfte Provider werden gemieden, wenn Alternativen existieren. +- Keine Secrets, keine Billing-APIs außer dem lokalen `quota-axi`. + +## Entwicklung + +```bash +npm test +``` + +Tests laufen ohne Netzwerk gegen Fixture-JSON unter `tests/fixtures/`. + +## Lizenz + +MIT diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..04dd2b1 --- /dev/null +++ b/index.ts @@ -0,0 +1,254 @@ +/** + * firstmate-crew-knowledge — Pi extension + * + * Tools for the primary Firstmate session: + * crew_route — task class → spawn flags + balance rationale + * crew_balance — live quota-axi ranking snapshot + * crew_apply_dispatch — generate/update config/crew-dispatch.json (dry-run default) + * crew_suggest_primary — optional mid-session Pi model suggestion (no pane hijack) + * + * Does not replace Firstmate judgment or auto-spawn crewmates. + */ + +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; +import { Type } from "typebox"; +import { applyDispatch } from "./src/dispatch.ts"; +import { + formatBalanceResult, + formatDispatchResult, + formatRouteResult, + routeResultJson, +} from "./src/format.ts"; +import { loadKnowledge } from "./src/knowledge.ts"; +import { readLiveQuota } from "./src/quota.ts"; +import { balanceProviders, routeTask } from "./src/scorer.ts"; +import { + formatSuggestPrimaryResult, + suggestPrimary, +} from "./src/suggest-primary.ts"; + +const knowledge = loadKnowledge(); + +const ROUTE_PARAMS = Type.Object({ + task: Type.String({ + description: + "Task description or brief excerpt used to classify work and recommend harness/model/effort.", + }), + taskClass: Type.Optional( + Type.String({ + description: + "Optional explicit class id: trivial_fix | standard_ship | hard_multi_file | research_web_live | scout_audit", + }), + ), + format: Type.Optional( + Type.Union([Type.Literal("text"), Type.Literal("json")], { + description: "Response format (default text)", + }), + ), +}); + +const BALANCE_PARAMS = Type.Object({ + format: Type.Optional( + Type.Union([Type.Literal("text"), Type.Literal("json")], { + description: "Response format (default text)", + }), + ), +}); + +const APPLY_PARAMS = Type.Object({ + dryRun: Type.Optional( + Type.Boolean({ + description: + "Default true. When false, writes only /config/crew-dispatch.json (FM_HOME, else FM_ROOT_OVERRIDE).", + }), + ), + merge: Type.Optional( + Type.Boolean({ + description: + "When true, append generated rules whose `when` text is not already present. Unreadable or invalid existing files refuse the write (no overwrite).", + }), + ), +}); + +const SUGGEST_PRIMARY_PARAMS = Type.Object({ + goal: Type.Optional( + Type.String({ + description: + "What the primary Firstmate session needs (e.g. long refactor, cheap triage).", + }), + ), + search: Type.Optional( + Type.String({ + description: + "Optional filter over authenticated session models (provider, id, or name substring).", + }), + ), + format: Type.Optional( + Type.Union([Type.Literal("text"), Type.Literal("json")], { + description: "Response format (default text)", + }), + ), +}); + +function textResult(text: string, details?: unknown) { + return { + content: [{ type: "text" as const, text }], + details, + }; +} + +function suggestPrimaryJson(result: ReturnType) { + return { + goal: result.goal, + search: result.search, + taskClassId: result.taskClassId, + mode: result.mode, + degradedReason: result.degradedReason, + currentModel: result.currentModel, + availableCount: result.availableCount, + listedSource: result.listedSource, + recommendation: result.recommendation, + matches: result.matches, + searchHits: result.searchHits, + notes: result.notes, + disclaimer: result.disclaimer, + }; +} + +/** Emit full route text via real ExtensionUIContext APIs (no truncation, no editor clobber). */ +function presentRouteText( + ui: ExtensionContext["ui"], + text: string, + spawnFlags: string, +): void { + const lines = text.split("\n"); + if (typeof ui.setWidget === "function") { + ui.setWidget("crew-route", lines); + } + if (typeof ui.notify === "function") { + ui.notify(text, "info"); + } + if (typeof ui.setStatus === "function") { + ui.setStatus("crew-route", spawnFlags); + } +} + +export default function firstmateCrewKnowledge(pi: ExtensionAPI): void { + pi.registerTool({ + name: "crew_route", + label: "Crew Route", + description: + "Classify a crew task and recommend concrete --harness/--model/--effort spawn flags, balancing quality vs live quota-axi headroom. Does not spawn.", + promptSnippet: + "Recommend Firstmate crew spawn profile (harness/model/effort) from task text and quota-axi", + promptGuidelines: [ + "Use crew_route before fm-spawn when choosing among Claude/Codex/Cursor/Grok profiles.", + "Treat output as advice; Firstmate still decides and calls fm-spawn with concrete flags.", + "If mode is degraded, prefer knowledge tables and disclose uncertainty.", + ], + parameters: ROUTE_PARAMS, + async execute(_id, params, signal) { + const quota = await readLiveQuota({ signal }); + const result = routeTask(knowledge, quota, params.task, params.taskClass); + if (params.format === "json") { + const payload = routeResultJson(result); + return textResult(JSON.stringify(payload, null, 2), payload); + } + return textResult(formatRouteResult(result), routeResultJson(result)); + }, + }); + + pi.registerTool({ + name: "crew_balance", + label: "Crew Balance", + description: + "Read live quota-axi --json and summarize provider headroom/runway for balanced multi-provider burn. Data only plus transparent sort — no spawn.", + promptSnippet: "Show quota-aware provider balance for the captain fleet", + promptGuidelines: [ + "Call crew_balance when comparing remaining headroom across Claude, Codex, Cursor, Grok.", + "Do not invent credentials; missing quota-axi yields labeled degraded output.", + ], + parameters: BALANCE_PARAMS, + async execute(_id, params, signal) { + const quota = await readLiveQuota({ signal }); + const result = balanceProviders(knowledge, quota); + if (params.format === "json") { + return textResult(JSON.stringify(result, null, 2), result); + } + return textResult(formatBalanceResult(result), result); + }, + }); + + pi.registerTool({ + name: "crew_apply_dispatch", + label: "Crew Apply Dispatch", + description: + "Generate a Firstmate config/crew-dispatch.json from the knowledge pack. Dry-run by default; writes only under the resolved home/config when dryRun=false.", + promptSnippet: + "Build or dry-run write Firstmate crew-dispatch.json from curated profiles", + promptGuidelines: [ + "Default dryRun=true — show JSON without writing.", + "Only set dryRun=false when the captain/Firstmate explicitly wants config/crew-dispatch.json updated under the resolved Firstmate home.", + "Never write outside that home's config/ directory.", + ], + parameters: APPLY_PARAMS, + async execute(_id, params) { + const result = applyDispatch(knowledge, { + dryRun: params.dryRun !== false, + merge: Boolean(params.merge), + }); + return textResult(formatDispatchResult(result), result); + }, + }); + + pi.registerTool({ + name: "crew_suggest_primary", + label: "Crew Suggest Primary", + description: + "List/search authenticated Pi session models and recommend a primary-session switch target among models actually available now. Advisory only — does not switch models or touch crew panes. pi-model-switch not required.", + promptSnippet: + "Suggest a primary Pi session model from authenticated session catalogue without switching", + promptGuidelines: [ + "Advisory only — do not call model switch APIs unless the captain asks separately.", + "Never hijack crewmate panes.", + "Only recommend models visible via ExtensionContext modelRegistry/scopedModels.", + ], + parameters: SUGGEST_PRIMARY_PARAMS, + async execute(_id, params, signal, _onUpdate, ctx) { + const quota = await readLiveQuota({ signal }); + const result = suggestPrimary(knowledge, quota, { + goal: params.goal, + search: params.search, + session: { + modelRegistry: ctx?.modelRegistry, + scopedModels: ctx?.scopedModels, + model: ctx?.model, + }, + }); + const payload = suggestPrimaryJson(result); + if (params.format === "json") { + return textResult(JSON.stringify(payload, null, 2), payload); + } + return textResult(formatSuggestPrimaryResult(result), payload); + }, + }); + + pi.registerCommand("crew-route", { + description: + "Classify task text and print spawn flags: /crew-route ", + handler: async (args, ctx) => { + const task = (args ?? "").trim(); + if (!task) { + ctx.ui.notify( + "Usage: /crew-route — or call tool crew_route", + "warning", + ); + return; + } + const quota = await readLiveQuota(); + const result = routeTask(knowledge, quota, task); + const text = formatRouteResult(result); + presentRouteText(ctx.ui, text, result.recommendation.spawnFlags); + }, + }); +} diff --git a/knowledge/README.md b/knowledge/README.md new file mode 100644 index 0000000..bd9d736 --- /dev/null +++ b/knowledge/README.md @@ -0,0 +1,11 @@ +# Knowledge pack + +Datengetriebene Tabellen für `firstmate-crew-knowledge`. Tool-Code liest diese Dateien; Routing-Updates gehören hierher, nicht in den Scorer. + +| Datei | Inhalt | +| --- | --- | +| `task-classes.json` | Task-Klassen (trivial → scout), Keywords, bevorzugte Profile | +| `providers.json` | Provider-Rollen (Claude, Codex, Cursor, Grok), Harness- und Model-Muster | +| `profiles.json` | Konkrete `{harness, model, effort}`-Profile + Dispatch-Template | + +Firstmate bleibt urteilsfähig: diese Tabellen sind Vorschläge, kein Ersatz für `quota-array-dispatch` oder `fm-spawn`. diff --git a/knowledge/profiles.json b/knowledge/profiles.json new file mode 100644 index 0000000..079d5c1 --- /dev/null +++ b/knowledge/profiles.json @@ -0,0 +1,201 @@ +{ + "version": 1, + "description": "Concrete {harness, model, effort} profiles referenced by task-classes. Update here without touching scorer code.", + "profiles": [ + { + "id": "cheap-fast-claude", + "provider": "claude", + "harness": "claude", + "model": "haiku", + "effort": "low", + "quality": "low", + "reasoningClass": "fast", + "why": "Cheapest fast Claude profile for narrow mechanical work." + }, + { + "id": "cheap-fast-codex", + "provider": "codex", + "harness": "codex", + "model": "gpt-5.5", + "effort": "low", + "quality": "low", + "reasoningClass": "fast", + "why": "Low-effort Codex for trivial mechanical edits." + }, + { + "id": "cursor-auto", + "provider": "cursor", + "harness": "cursor", + "model": "auto", + "effort": "low", + "quality": "low", + "reasoningClass": "fast", + "why": "Cursor auto path when Cursor headroom is the right burn target." + }, + { + "id": "standard-codex", + "provider": "codex", + "harness": "codex", + "model": "gpt-5.5", + "effort": "medium", + "quality": "medium", + "reasoningClass": "standard", + "why": "Default steady ship profile on Codex." + }, + { + "id": "standard-claude", + "provider": "claude", + "harness": "claude", + "model": "sonnet", + "effort": "medium", + "quality": "medium", + "reasoningClass": "standard", + "why": "Standard Claude Sonnet ship profile." + }, + { + "id": "standard-pi-sonnet", + "provider": "claude", + "harness": "pi", + "model": "anthropic/claude-sonnet-4", + "effort": "medium", + "quality": "medium", + "reasoningClass": "standard", + "why": "Pi-hosted Sonnet when the primary fleet prefers the Pi harness." + }, + { + "id": "cursor-composer", + "provider": "cursor", + "harness": "cursor", + "model": "composer", + "effort": "medium", + "quality": "medium", + "reasoningClass": "standard", + "why": "Cursor composer for IDE-centric standard ships." + }, + { + "id": "strong-claude", + "provider": "claude", + "harness": "claude", + "model": "claude-sonnet-4", + "effort": "high", + "quality": "high", + "reasoningClass": "strong", + "why": "Strong Claude profile for ambiguous multi-file work." + }, + { + "id": "strong-codex", + "provider": "codex", + "harness": "codex", + "model": "gpt-5.5", + "effort": "high", + "quality": "high", + "reasoningClass": "strong", + "why": "High-effort Codex for hard multi-file features." + }, + { + "id": "strong-pi-opus", + "provider": "claude", + "harness": "pi", + "model": "anthropic/claude-opus-4", + "effort": "high", + "quality": "high", + "reasoningClass": "strong", + "why": "Pi-hosted Opus when maximum judgment load is required on Pi." + }, + { + "id": "cursor-composer-high", + "provider": "cursor", + "harness": "cursor", + "model": "composer", + "effort": "high", + "quality": "high", + "reasoningClass": "strong", + "why": "High-effort Cursor composer alternative for hard work." + }, + { + "id": "grok-research", + "provider": "grok", + "harness": "grok", + "model": "latest", + "effort": "medium", + "quality": "medium", + "reasoningClass": "research", + "why": "Native Grok when live/public web context is central." + }, + { + "id": "pi-grok-research", + "provider": "grok", + "harness": "pi", + "model": "xai/grok-4", + "effort": "medium", + "quality": "medium", + "reasoningClass": "research", + "why": "Pi-hosted Grok/xAI when the home prefers Pi over the Grok CLI." + }, + { + "id": "scout-codex", + "provider": "codex", + "harness": "codex", + "model": "gpt-5.5", + "effort": "medium", + "quality": "medium", + "reasoningClass": "scout", + "why": "Read-oriented Codex scout profile." + }, + { + "id": "scout-claude", + "provider": "claude", + "harness": "claude", + "model": "sonnet", + "effort": "medium", + "quality": "medium", + "reasoningClass": "scout", + "why": "Read-oriented Claude scout profile." + }, + { + "id": "scout-pi", + "provider": "claude", + "harness": "pi", + "model": "anthropic/claude-sonnet-4", + "effort": "low", + "quality": "medium", + "reasoningClass": "scout", + "why": "Low-effort Pi scout to limit burn on surveys." + }, + { + "id": "cursor-scout", + "provider": "cursor", + "harness": "cursor", + "model": "auto", + "effort": "low", + "quality": "medium", + "reasoningClass": "scout", + "why": "Cursor auto for lightweight scouting when Cursor is the burn target." + } + ], + "dispatchTemplate": { + "rules": [ + { + "when": "The task depends on fresh news, current events, live public facts, or recent market and product changes.", + "profileIds": ["grok-research", "pi-grok-research"], + "why": "Grok is preferred when current web-connected context is central; resolve alternatives through quota-aware choice." + }, + { + "when": "The task is a trivial mechanical edit such as a rote rename, formatting sweep, targeted typo fix, or simple file gathering.", + "profileIds": ["cheap-fast-claude", "cheap-fast-codex"], + "why": "Use the cheapest fast profile when the task is narrow and low ambiguity." + }, + { + "when": "The task is a big or ambiguous multi-file feature, a risky refactor, or work that requires holding many moving parts in mind.", + "profileIds": ["strong-claude", "strong-codex"], + "why": "Use a strong coding profile for big, ambiguous work; resolve the alternatives through quota-array-dispatch." + }, + { + "when": "The task is a read-only scout, audit, inventory, or risk scan without shipping code.", + "profileIds": ["scout-codex", "scout-claude"], + "why": "Prefer medium read-oriented profiles for scouts; keep burn modest." + } + ], + "defaultProfileIds": ["standard-codex", "standard-pi-sonnet", "standard-claude"] + } +} diff --git a/knowledge/providers.json b/knowledge/providers.json new file mode 100644 index 0000000..bc70e1e --- /dev/null +++ b/knowledge/providers.json @@ -0,0 +1,87 @@ +{ + "version": 1, + "description": "Provider families for the captain fleet. Maps quota-axi provider ids to harnesses and model-id patterns. Not a credential store.", + "providers": [ + { + "id": "claude", + "label": "Claude", + "quotaProvider": "claude", + "harnesses": ["claude", "pi"], + "strengths": [ + "long-context multi-file reasoning", + "careful refactors", + "instruction following" + ], + "weaknesses": [ + "subscription headroom can tighten mid-cycle", + "no native live-web preference over Grok" + ], + "piModelPatterns": ["anthropic/claude-*", "anthropic/*"], + "nativeModelHints": ["haiku", "sonnet", "opus", "claude-sonnet-4", "claude-opus-4"], + "defaultHarness": "claude" + }, + { + "id": "codex", + "label": "ChatGPT / Codex", + "quotaProvider": "codex", + "harnesses": ["codex", "pi"], + "strengths": [ + "steady coding throughput", + "repo-wide edits", + "good default ship profile" + ], + "weaknesses": [ + "model windows can be tighter than account windows", + "live-web research is not the primary strength" + ], + "piModelPatterns": ["openai-codex/*", "openai/*"], + "nativeModelHints": ["gpt-5.5", "gpt-5.4", "o3", "o4-mini"], + "defaultHarness": "codex" + }, + { + "id": "cursor", + "label": "Cursor", + "quotaProvider": "cursor", + "harnesses": ["cursor"], + "strengths": [ + "IDE-native multi-file edits", + "composer-style implementation", + "good when Cursor plan headroom is healthy" + ], + "weaknesses": [ + "quota-axi often cannot compute effective remaining for Cursor", + "not a Firstmate primary harness for fleet spawn in all homes" + ], + "piModelPatterns": [], + "nativeModelHints": ["auto", "composer", "default"], + "defaultHarness": "cursor", + "notes": "Cursor quotaSemantics are frequently unknown; treat headroom as disclosed uncertainty, not zero." + }, + { + "id": "grok", + "label": "Grok (xAI)", + "quotaProvider": "grok", + "harnesses": ["grok", "pi"], + "strengths": [ + "live/public web-connected research", + "current-events style questions", + "fast exploratory answers" + ], + "weaknesses": [ + "consumer credit windows may be unavailable while auth is usable", + "not the default for deep multi-file refactors" + ], + "piModelPatterns": ["xai/grok-*", "xai/*"], + "nativeModelHints": ["grok-4", "grok-3", "latest"], + "defaultHarness": "grok", + "notes": "authStatus usable with empty windows is uncertainty, not logout." + } + ], + "harnessNotes": { + "pi": "Pi coding-agent; model ids are provider/model. Can host anthropic, openai-codex, xai families when authenticated.", + "claude": "Claude Code / Claude CLI native harness.", + "codex": "OpenAI Codex CLI harness.", + "grok": "Grok CLI harness.", + "cursor": "Cursor agent/composer path when the home supports it as a spawn harness." + } +} diff --git a/knowledge/task-classes.json b/knowledge/task-classes.json new file mode 100644 index 0000000..b6e24fb --- /dev/null +++ b/knowledge/task-classes.json @@ -0,0 +1,109 @@ +{ + "version": 1, + "description": "Task classes for crew routing. Keywords are lowercase match hints; Firstmate still judges fit.", + "classes": [ + { + "id": "trivial_fix", + "label": "Trivial fix", + "description": "Rote rename, formatting, typo, single-line patch, simple file gathering.", + "keywords": [ + "typo", + "rename", + "format", + "prettier", + "lint fix", + "whitespace", + "comment only", + "trivial", + "one-liner", + "simple fix" + ], + "qualityFloor": "low", + "preferredProfileIds": ["cheap-fast-claude", "cheap-fast-codex", "cursor-auto"], + "fallbackProfileIds": ["standard-codex", "standard-pi-sonnet"] + }, + { + "id": "standard_ship", + "label": "Standard ship", + "description": "Ordinary feature or bugfix with clear scope, a few files, normal review bar.", + "keywords": [ + "feature", + "bugfix", + "implement", + "ship", + "ship it", + "pr", + "pull request", + "open a pr", + "merge request", + "add endpoint", + "wire up", + "standard" + ], + "qualityFloor": "medium", + "preferredProfileIds": ["standard-codex", "standard-pi-sonnet", "standard-claude", "cursor-composer"], + "fallbackProfileIds": ["strong-claude", "strong-codex"] + }, + { + "id": "hard_multi_file", + "label": "Hard multi-file", + "description": "Ambiguous multi-file feature, risky refactor, many moving parts, high judgment load.", + "keywords": [ + "refactor", + "multi-file", + "architecture", + "ambiguous", + "migration", + "redesign", + "complex", + "risky", + "cross-cutting" + ], + "qualityFloor": "high", + "preferredProfileIds": ["strong-claude", "strong-codex", "strong-pi-opus", "cursor-composer-high"], + "fallbackProfileIds": ["standard-codex", "standard-claude"] + }, + { + "id": "research_web_live", + "label": "Research / web-live", + "description": "Needs fresh news, live public facts, current market/product changes, or web-connected research.", + "keywords": [ + "web research", + "research online", + "live web", + "search the web", + "web-connected", + "current events", + "current market news", + "latest news", + "breaking news", + "fresh news", + "news today", + "news as of today", + "market news", + "product news" + ], + "qualityFloor": "medium", + "preferredProfileIds": ["grok-research", "pi-grok-research"], + "fallbackProfileIds": ["standard-pi-sonnet", "standard-codex"] + }, + { + "id": "scout_audit", + "label": "Scout / audit", + "description": "Read-only survey, codebase scout, audit, inventory, or risk scan without shipping code.", + "keywords": [ + "scout", + "audit", + "inventory", + "survey", + "explore", + "map codebase", + "read-only", + "risk scan" + ], + "qualityFloor": "medium", + "preferredProfileIds": ["scout-codex", "scout-claude", "scout-pi", "cursor-scout"], + "fallbackProfileIds": ["standard-codex", "cheap-fast-claude"] + } + ] +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..17d062b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,2021 @@ +{ + "name": "firstmate-crew-knowledge", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "firstmate-crew-knowledge", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + }, + "engines": { + "node": ">=22.6" + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.91.1", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.91.1.tgz", + "integrity": "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw==", + "license": "MIT", + "peer": true, + "dependencies": { + "json-schema-to-ts": "^3.1.1" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.7.tgz", + "integrity": "sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@aws-sdk/xml-builder": "^3.972.38", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.31.1", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.68.tgz", + "integrity": "sha512-2a20A/IdNOwUvaDq91iqqS7BA0XlNMfW3iLGZGZLJv0EbUqhSxB0PIx4rQQqssvWj1uXImb3/UCCdHz/+1dOiA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.70.tgz", + "integrity": "sha512-0yRem2Fs52r/Nn6UAqIlpjexfaYj8ziEozOe9tamtAVT/5bzFLKx8O2r7MaRqgS3hGKHIa1Jij9nKHSsNnb04A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.13.tgz", + "integrity": "sha512-2M39DE02XpYYaSWYk/4AsImXYUU/1L2xmTMLUpMMWq7DfLv191/vCRy3baKtdr45AkJQyVgSjmuVOLm15SwrRQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-login": "^3.972.75", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.75", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.75.tgz", + "integrity": "sha512-jaTESuJlQsoUZ44f/i2puyPt8VlF/dMMJ9HM3cStYtk7eKX4N9UWi83OLixUkoOJH3BwWlPLCq9YIK9nfWhVBg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.79", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.79.tgz", + "integrity": "sha512-RIw5dof1EHkWubrZzPC941CDtnFG1iAXsxbFgLkhdYZXHc4icU13c/uxSMI0J5eUx9bxa7LjfpdjfClBB1QsDA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.68", + "@aws-sdk/credential-provider-http": "^3.972.70", + "@aws-sdk/credential-provider-ini": "^3.973.13", + "@aws-sdk/credential-provider-process": "^3.972.68", + "@aws-sdk/credential-provider-sso": "^3.973.12", + "@aws-sdk/credential-provider-web-identity": "^3.972.74", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.68", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.68.tgz", + "integrity": "sha512-nLP3Pda2MQTFJ25hKBMmUuB9Uv+bTZQNlufbeCwklP549Vwnkd8bRLJoCKp5k6xjmdyptrPrOfGOhN0mKuca8A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.12.tgz", + "integrity": "sha512-EmgyyHn+f9WCcelp3L/vci+LGbX8GigWaVphRArjVo5Pktkr9YnLy/mQ6VDkDyBD72dtfRNTgHmD2ts4rTDXKQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/token-providers": "3.1108.0", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1108.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1108.0.tgz", + "integrity": "sha512-rI80zxDxGJ6904eC/YbjkdjY6JdaZvQ01kOmrMvw7cFQGIHo27fhnIVbMSVDS4T6foQImjxYSRoOu/uSJscXDw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.74", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.74.tgz", + "integrity": "sha512-0YfczxGXF3RjGj8z7QG/Ho2HnLGKDHfPSHiTs47UU1U/+mmwISDN+rvGKt2zh+3FX8NdT4xd95LGBGyhQw2dgQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/nested-clients": "^3.997.42", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.32", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.32.tgz", + "integrity": "sha512-rlbmsMG7ZNgrVhWSqqXpq6y9hfiREyzCg3CNTk9UK+AoP7+65kOkqpWmqwLfV1UrRSHATdLnZF2rt9ZTUxYQJA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.27", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.27.tgz", + "integrity": "sha512-M7Ay1VpBpf/YFfic9kkjwE3wyCh4G0gEM4RypRXYm7aPjyfqi+D8FEYMR2E3IqbvN+qi2rEFYAiwWL0XHtQYdQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.50", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.50.tgz", + "integrity": "sha512-gdcWRbmIf1dWA/prf44Bnnzgqj+AbsXX2yfhZhOQLwSm7NfKIYPmkRlPqP0CTepHzjxMIBdWBDdtQB+Y/dFUeg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.42", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.42.tgz", + "integrity": "sha512-XWRyon2MTHXD/zMoo0Mbge6Vwf+iE0qQaM/RyGO6NfZ9WukCFiQL27nQVZjYy2JwSIg+iXZxKOX95OBXqlSM4w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.977.7", + "@aws-sdk/signature-v4-multi-region": "^3.996.44", + "@aws-sdk/types": "^3.974.3", + "@smithy/core": "^3.31.1", + "@smithy/fetch-http-handler": "^5.6.13", + "@smithy/node-http-handler": "^4.9.13", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.10.0.tgz", + "integrity": "sha512-nrh7VxqzPQS/ip1hS293aI/OAWDWARQvjUxCfuKhyrfHa2gTdk28066RNeWLI1uuoHXaKAkOF8IcSAHuOp0+SA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.44.tgz", + "integrity": "sha512-ZSfQ35Qn4MhSY+A0Whyr+KBx+wJKZUyBsOrjB2pSHOafRzbFe47T8XcXM8hZqUAC69qnqIy0C9ArxTuud0CC2w==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/types": "^3.974.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.3", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.3.tgz", + "integrity": "sha512-ECAqfpNsef+7MO8qtR0h9KcFIBAygaE7Cm6UOiQl+ft+uVap+1G7bNEjs4mdJE2OnA4m6k7i8peH8uGIAsOMGw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.9.tgz", + "integrity": "sha512-wB/ho7pTJKqWz3WYDt2ZWDWI8bxQpN/xwf+5ZQ1zWaj+HDY9B8Fn434i6qZ6j6ZG3aCiIJtZaQVqwajx5xYsQA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.38", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.38.tgz", + "integrity": "sha512-grf7mzfVxBS5AlsuTvBN7uDpzqohFww9fRPCO+EBSUdvtsYMcPSKdz54h/7XiscqNcUM1Ae1MF7JLHmiYYuzbQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.16.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.84.1.tgz", + "integrity": "sha512-evyzXYWCLQGmcaBYHlmSku02r8qoN4SGI60GZABo6iV+H+nqX+P9ud8fEZ4GmRq9mUSREvvfX+w9dA9ThF9C6w==", + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-telemetry": "^0.84.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.84.1.tgz", + "integrity": "sha512-wMsAdJMxuNri08vLqTyYVI201DQQezGhPSTkzYsHdw5dYX3rCNwEmSvpaAwhi7ELKI/2tE/CEgSWg/6iRxSgdQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@anthropic-ai/sdk": "0.91.1", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.84.1", + "@google/genai": "1.52.0", + "@mistralai/mistralai": "2.2.6", + "@opentelemetry/api": "1.9.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.26.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-client": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-client/-/pi-client-0.84.1.tgz", + "integrity": "sha512-/V5hGHE4Zq+jG0GtwIB9PyBUOGd6gBLZ7lkQYFKchKnxYHeH3rmWC5xw4kpnZKKBuBuFTdLVbU9vEjlAGMMb2A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-protocol": "^0.84.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.84.1.tgz", + "integrity": "sha512-ncAqFrG+iybuPGOhMiZoEHkEzTpJgz3guYD32pD+M7ucc0WeHmauP6wa7qwP8V/KWvsZDVNa5XGsdZ7fkC7w7A==", + "license": "MIT", + "peer": true, + "dependencies": { + "@earendil-works/pi-agent-core": "^0.84.1", + "@earendil-works/pi-ai": "^0.84.1", + "@earendil-works/pi-client": "^0.84.1", + "@earendil-works/pi-protocol": "^0.84.1", + "@earendil-works/pi-tui": "^0.84.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "glob": "13.0.6", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-coding-agent/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-protocol": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-protocol/-/pi-protocol-0.84.1.tgz", + "integrity": "sha512-Ox1pciyeSPGEEUcxvR0/dJcrY7C6hrEGA8y71rOsvSIUlXN1Cbp/be/eoL71OGDBk5O97TeQPfWN6Ju/2Ehjww==", + "license": "MIT", + "peer": true, + "dependencies": { + "typebox": "1.3.7" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-protocol/node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT", + "peer": true + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.84.1.tgz", + "integrity": "sha512-180/xGJtsq7IoR3p9EKWjRd0e9M4DkxInhlo9xyD7prDC7Qrhqq+nhvwrW0lFjPfXcEI2FSHmGCSyvSJE9GsaQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.84.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.84.1.tgz", + "integrity": "sha512-udeXFbgEhJ6JiB0uguwNVNkDy2FENfmtQwPcY+/iJ8GWeq18wkal1tKqa5YyeH0IqtX1vG0cGh8zfSYzyzVuLA==", + "license": "MIT", + "peer": true, + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "peer": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "peer": true, + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mistralai/mistralai": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@mistralai/mistralai/-/mistralai-2.2.6.tgz", + "integrity": "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.40.0", + "ws": "^8.18.0", + "zod": "^3.25.0 || ^4.0.0", + "zod-to-json-schema": "^3.25.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.9.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + } + } + }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/@opentelemetry/semantic-conventions": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz", + "integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/@smithy/core": { + "version": "3.32.0", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.32.0.tgz", + "integrity": "sha512-NAiCSC78fzbNIEWoheoF74Ob5ZorLijCHpMY26Fqvqg/+9LuyIqMfHDg2p8Yk1rqOyowtiL3y7WX0AW+teL6zw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.0.tgz", + "integrity": "sha512-2jsPi+7Zv2hSzD9IXR9D7DTqSn7mv4XalzRm+bESh53jiaUS3NKEUbpQFTJP0HhQy9qzZvluxQ3yS24zdRrqsA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.7.0.tgz", + "integrity": "sha512-W/exA8T0LEzCQtJ02w4IzaEQPIspgarqZprb7W8FwnYiDowgCrjl2fTQ6FvuSSUnJORuepBF81abmBJwqh+0XQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.0.tgz", + "integrity": "sha512-hCynhm22wMJ8wTF9crcwu8mxggtUrSLLJgDcGUvYFBqpofxycYJCGKOMYg4xtPPFtgNiDJSYmhsWLTrcU/g59Q==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/core": "^3.32.0", + "@smithy/types": "^4.17.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.17.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.17.0.tgz", + "integrity": "sha512-Aw4joiM0ZdErpo39lCj8phT2lxoiKZV+KZzBxnnQhWVtU2Is/WffQSL04uUWRcXUse9Ln8vXZK6V/FwqRVnQpg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT", + "peer": true + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 14" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT", + "peer": true + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "peer": true, + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause", + "peer": true + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "peer": true, + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "peer": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT", + "peer": true + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "peer": true, + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz", + "integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC", + "peer": true + }, + "node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "license": "Apache-2.0", + "peer": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "peer": true, + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "peer": true, + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "peer": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC", + "peer": true + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "peer": true, + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "peer": true, + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "peer": true, + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0", + "peer": true + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "license": "BlueOak-1.0.0", + "peer": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT", + "peer": true + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.26.0.tgz", + "integrity": "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA==", + "license": "Apache-2.0", + "peer": true, + "bin": { + "openai": "bin/cli" + }, + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT", + "peer": true + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", + "integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==", + "license": "BlueOak-1.0.0", + "peer": true, + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "peer": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.5", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.5.tgz", + "integrity": "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "peer": true, + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "peer": true + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "peer": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "peer": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC", + "peer": true + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT", + "peer": true + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "peer": true + }, + "node_modules/typebox": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.13.tgz", + "integrity": "sha512-rzv/3uBDgWGb5b4+Rqb/WoDFCc+5N0iRC/7kgQyk/mJJeumQjHFFAZ+X+Zs+CNxnNnJOkhIMrQc2irTzfEbKjw==", + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "peer": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "peer": true, + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peer": true, + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ab3fc30 --- /dev/null +++ b/package.json @@ -0,0 +1,51 @@ +{ + "name": "firstmate-crew-knowledge", + "version": "0.1.0", + "description": "Pi-Extension für Firstmate: modell-/harness-Routing pro Task-Klasse und quota-bewusste Kandidatenbalance über Cursor, Claude, Codex und Grok.", + "keywords": [ + "pi-package", + "pi-extension", + "firstmate", + "crew", + "quota", + "dispatch", + "harness" + ], + "author": "thelad-dev", + "license": "MIT", + "type": "module", + "engines": { + "node": ">=22.6" + }, + "scripts": { + "test": "node --test --experimental-strip-types --no-warnings --test-force-exit 'tests/*.test.ts'", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "files": [ + "index.ts", + "src/", + "knowledge/", + "README.md", + "LICENSE", + "AGENTS.md" + ], + "pi": { + "extensions": ["./index.ts"] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "typebox": "*" + }, + "devDependencies": { + "@types/node": "^22.15.0", + "typescript": "^5.8.0" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/thelad-dev/firstmate-crew-knowledge.git" + }, + "bugs": { + "url": "https://github.com/thelad-dev/firstmate-crew-knowledge/issues" + }, + "homepage": "https://github.com/thelad-dev/firstmate-crew-knowledge#readme" +} diff --git a/src/dispatch.ts b/src/dispatch.ts new file mode 100644 index 0000000..0b799a0 --- /dev/null +++ b/src/dispatch.ts @@ -0,0 +1,281 @@ +import { readFileSync } from "node:fs"; +import { resolveProfiles, type KnowledgePack } from "./knowledge.ts"; +import { + crewDispatchPath, + resolveFmHome, + writeUnderFmConfig, +} from "./fm-home.ts"; +import type { + CrewDispatchFile, + DispatchBuildResult, + KnowledgeProfile, + SpawnProfile, +} from "./types.ts"; + +const EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]); + +export function profileToSpawn(p: KnowledgeProfile): SpawnProfile { + const out: SpawnProfile = { harness: p.harness }; + if (p.model) out.model = p.model; + if (p.effort) out.effort = p.effort; + return out; +} + +/** Build a crew-dispatch.json document from the knowledge pack template. */ +export function buildDispatchFromKnowledge(pack: KnowledgePack): CrewDispatchFile { + const rules = pack.dispatchTemplate.rules.map((rule) => { + const profiles = resolveProfiles(pack, rule.profileIds).map(profileToSpawn); + if (!profiles.length) { + throw new Error(`Dispatch rule has no resolvable profiles: ${rule.when}`); + } + return { + when: rule.when, + use: profiles.length === 1 ? profiles[0]! : profiles, + why: rule.why, + }; + }); + + const defaults = resolveProfiles(pack, pack.dispatchTemplate.defaultProfileIds).map( + profileToSpawn, + ); + if (!defaults.length) { + throw new Error("dispatchTemplate.defaultProfileIds resolved empty"); + } + + return { + rules, + default: defaults.length === 1 ? defaults[0]! : defaults, + }; +} + +/** + * Lightweight schema check matching Firstmate docs/examples/crew-dispatch.json. + * Does not verify harness binaries — Firstmate bootstrap owns that. + */ +export function validateDispatch(doc: unknown): { ok: boolean; errors: string[] } { + const errors: string[] = []; + if (!doc || typeof doc !== "object") { + return { ok: false, errors: ["root must be an object"] }; + } + const root = doc as Record; + if (!Array.isArray(root.rules)) { + errors.push("rules must be an array"); + } else { + if (root.rules.length === 0) errors.push("rules must be non-empty"); + root.rules.forEach((rule, i) => validateRule(rule, `rules[${i}]`, errors)); + } + if (root.default !== undefined) { + validateUse(root.default, "default", errors); + } + return { ok: errors.length === 0, errors }; +} + +function validateRule(rule: unknown, path: string, errors: string[]): void { + if (!rule || typeof rule !== "object") { + errors.push(`${path} must be an object`); + return; + } + const r = rule as Record; + if (typeof r.when !== "string" || !r.when.trim()) { + errors.push(`${path}.when must be a non-empty string`); + } + if (r.use === undefined) { + errors.push(`${path}.use is required`); + } else { + validateUse(r.use, `${path}.use`, errors); + } + if (r.why !== undefined && typeof r.why !== "string") { + errors.push(`${path}.why must be a string when present`); + } +} + +function validateUse(use: unknown, path: string, errors: string[]): void { + if (Array.isArray(use)) { + if (use.length === 0) errors.push(`${path} array must be non-empty`); + use.forEach((p, i) => validateProfile(p, `${path}[${i}]`, errors)); + return; + } + validateProfile(use, path, errors); +} + +function validateProfile(profile: unknown, path: string, errors: string[]): void { + if (!profile || typeof profile !== "object") { + errors.push(`${path} must be a profile object`); + return; + } + const p = profile as Record; + if (typeof p.harness !== "string" || !p.harness.trim()) { + errors.push(`${path}.harness is required`); + } + if (p.model !== undefined && typeof p.model !== "string") { + errors.push(`${path}.model must be a string when present`); + } + if (p.effort !== undefined) { + if (typeof p.effort !== "string" || !EFFORTS.has(p.effort)) { + errors.push( + `${path}.effort must be one of low|medium|high|xhigh|max when present`, + ); + } + } +} + +export interface ApplyDispatchOptions { + /** When true (default), do not write — only return content. */ + dryRun?: boolean; + /** Append generated rules whose `when` text is absent. Unreadable/invalid existing files refuse the write. */ + merge?: boolean; + /** Override FM_HOME resolution (tests). */ + fmHome?: string | null; + env?: NodeJS.ProcessEnv; +} + +/** + * Generate (and optionally write) config/crew-dispatch.json under the resolved Firstmate home. + * Writes are refused unless dryRun is explicitly false. + * Never writes outside /config. + * merge refuses unreadable or invalid existing files instead of overwriting them. + */ +export function applyDispatch( + pack: KnowledgePack, + options: ApplyDispatchOptions = {}, +): DispatchBuildResult { + const dryRun = options.dryRun !== false; + const env = options.env ?? process.env; + + let home = options.fmHome; + let homeSource = "option"; + if (home === undefined) { + const resolved = resolveFmHome(env); + home = resolved.home; + homeSource = resolved.source; + } + + let dispatch = buildDispatchFromKnowledge(pack); + + if (!home) { + const validation = validateDispatch(dispatch); + return { + dispatch, + path: "(FM_HOME unset — no write target)", + dryRun: true, + wrote: false, + validation, + summary: `FM_HOME unset (${homeSource}); dry-run only. Set FM_HOME to enable writes to config/crew-dispatch.json.`, + }; + } + + const path = crewDispatchPath(home); + + if (options.merge) { + const existing = loadExistingForMerge(path); + if (existing.kind === "unmergeable") { + return { + dispatch, + path, + dryRun: true, + wrote: false, + validation: { ok: false, errors: [existing.error] }, + summary: `Merge refused; not writing. ${existing.error}`, + }; + } + if (existing.kind === "ok") { + dispatch = mergeDispatch(existing.doc, dispatch); + } + } + + const validation = validateDispatch(dispatch); + if (!validation.ok) { + return { + dispatch, + path, + dryRun: true, + wrote: false, + validation, + summary: `Validation failed; not writing. Errors: ${validation.errors.join("; ")}`, + }; + } + + if (dryRun) { + return { + dispatch, + path, + dryRun: true, + wrote: false, + validation, + summary: `Dry-run OK. Would write ${path}. Pass dryRun=false for explicit write under FM_HOME/config.`, + }; + } + + const body = `${JSON.stringify(dispatch, null, 2)}\n`; + const written = writeUnderFmConfig(home, "crew-dispatch.json", body); + return { + dispatch, + path: written, + dryRun: false, + wrote: true, + validation, + summary: `Wrote ${written} (${dispatch.rules.length} rules). Firstmate still chooses the matching rule and may resolve arrays via quota-array-dispatch before fm-spawn.`, + }; +} + +function nodeErrorCode(err: unknown): unknown { + if (err && typeof err === "object" && "code" in err) { + return (err as { code: unknown }).code; + } + return undefined; +} + +type ExistingDispatch = + | { kind: "missing" } + | { kind: "ok"; doc: CrewDispatchFile } + | { kind: "unmergeable"; error: string }; + +function loadExistingForMerge(path: string): ExistingDispatch { + let raw: string; + try { + raw = readFileSync(path, "utf8"); + } catch (err) { + if (nodeErrorCode(err) === "ENOENT") return { kind: "missing" }; + const message = err instanceof Error ? err.message : String(err); + return { kind: "unmergeable", error: `Cannot read existing ${path}: ${message}` }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return { + kind: "unmergeable", + error: `Existing ${path} is not valid JSON: ${message}`, + }; + } + + if (!parsed || typeof parsed !== "object" || !Array.isArray((parsed as { rules?: unknown }).rules)) { + return { + kind: "unmergeable", + error: `Existing ${path} cannot be merged: rules must be an array`, + }; + } + + return { kind: "ok", doc: parsed as CrewDispatchFile }; +} + +/** Append generated rules that are not already present by exact `when` text. */ +export function mergeDispatch( + existing: CrewDispatchFile, + generated: CrewDispatchFile, +): CrewDispatchFile { + if (!Array.isArray(existing.rules)) { + throw new Error("existing.rules must be an array"); + } + const whens = new Set(existing.rules.map((r) => r.when)); + const rules = [...existing.rules]; + for (const rule of generated.rules) { + if (!whens.has(rule.when)) rules.push(rule); + } + return { + rules, + default: existing.default ?? generated.default, + }; +} diff --git a/src/fm-home.ts b/src/fm-home.ts new file mode 100644 index 0000000..000c75c --- /dev/null +++ b/src/fm-home.ts @@ -0,0 +1,61 @@ +import { accessSync, constants, mkdirSync, renameSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, resolve } from "node:path"; + +/** + * Resolve Firstmate operational home. + * Prefer FM_HOME, then FM_ROOT_OVERRIDE, else null (caller must not invent a write root). + */ +export function resolveFmHome( + env: NodeJS.ProcessEnv = process.env, +): { home: string | null; source: string } { + const fmHome = env.FM_HOME?.trim(); + if (fmHome) { + return { home: resolveHomePath(fmHome), source: "FM_HOME" }; + } + const rootOverride = env.FM_ROOT_OVERRIDE?.trim(); + if (rootOverride) { + return { home: resolveHomePath(rootOverride), source: "FM_ROOT_OVERRIDE" }; + } + return { home: null, source: "unset" }; +} + +function resolveHomePath(raw: string): string { + if (isAbsolute(raw)) return resolve(raw); + return resolve(process.cwd(), raw); +} + +export function crewDispatchPath(fmHome: string): string { + return join(fmHome, "config", "crew-dispatch.json"); +} + +/** + * Atomic write restricted to paths under fmHome/config. + * Throws if the target escapes that directory. + */ +export function writeUnderFmConfig( + fmHome: string, + relativeUnderConfig: string, + contents: string, +): string { + const configRoot = resolve(fmHome, "config"); + const target = resolve(configRoot, relativeUnderConfig); + if (target !== configRoot && !target.startsWith(configRoot + "/") && !target.startsWith(configRoot + "\\")) { + throw new Error( + `Refusing write outside FM_HOME/config: ${target} (config root ${configRoot})`, + ); + } + mkdirSync(dirname(target), { recursive: true }); + const tmp = `${target}.${process.pid}.${Date.now()}.tmp`; + writeFileSync(tmp, contents, "utf8"); + renameSync(tmp, target); + return target; +} + +export function canReadPath(path: string): boolean { + try { + accessSync(path, constants.R_OK); + return true; + } catch { + return false; + } +} diff --git a/src/format.ts b/src/format.ts new file mode 100644 index 0000000..a74b285 --- /dev/null +++ b/src/format.ts @@ -0,0 +1,124 @@ +import type { + BalanceResult, + DispatchBuildResult, + RankedCandidate, + RouteResult, +} from "./types.ts"; + +export function formatRouteResult(result: RouteResult): string { + const rec = result.recommendation; + const lines: string[] = [ + "# crew_route", + "", + `Task-Klasse: ${result.taskClass.id} (${result.taskClass.label})`, + `Klassifikation: ${result.classificationConfidence} — ${result.classificationNotes.join(" ")}`, + `Modus: ${result.mode}${result.degradedReason ? ` — ${result.degradedReason}` : ""}`, + `Quota-Kurz: ${result.quotaSummary}`, + "", + "## Empfehlung", + `spawn: ${rec.spawnFlags}`, + `profile: ${rec.profile.id}`, + `tradeoff: ${rec.tradeoff}`, + `why: ${rec.profile.why}`, + "evidence:", + ...rec.evidence.map((e) => ` - ${e}`), + "", + ]; + + if (result.alternatives.length) { + lines.push("## Alternativen"); + for (const alt of result.alternatives) { + lines.push( + `- ${alt.spawnFlags} [${alt.profile.id}] score=${alt.rankScore.toFixed(1)} — ${alt.tradeoff}`, + ); + } + lines.push(""); + } + + lines.push(`## Hinweis`, result.disclaimer); + return lines.join("\n"); +} + +export function formatBalanceResult(result: BalanceResult): string { + const lines: string[] = [ + "# crew_balance", + "", + `Modus: ${result.mode}${result.degradedReason ? ` — ${result.degradedReason}` : ""}`, + result.generatedAt ? `Snapshot: ${result.generatedAt}` : "Snapshot: n/a", + "", + "## Provider (sortiert)", + ]; + for (const p of result.providers) { + const head = + p.effectiveRemaining !== null ? `${p.effectiveRemaining}%` : "unknown"; + const runway = + p.usableRunwaySeconds !== null + ? `${Math.round(p.usableRunwaySeconds / 3600)}h` + : p.runwayStatus; + lines.push( + `- ${p.provider}: headroom=${head} runway=${runway} exhausted=${p.exhausted} state=${p.stateStatus}`, + ); + lines.push(` evidence: ${p.evidence}`); + } + lines.push("", "## Notes", ...result.rankingNotes.map((n) => `- ${n}`)); + lines.push("", result.disclaimer); + return lines.join("\n"); +} + +export function formatDispatchResult(result: DispatchBuildResult): string { + const lines: string[] = [ + "# crew_apply_dispatch", + "", + result.summary, + `path: ${result.path}`, + `dryRun: ${result.dryRun}`, + `wrote: ${result.wrote}`, + `validation: ${result.validation.ok ? "ok" : "FAILED"}`, + ]; + if (!result.validation.ok) { + for (const e of result.validation.errors) lines.push(` - ${e}`); + } + lines.push("", "## crew-dispatch.json", "```json", JSON.stringify(result.dispatch, null, 2), "```"); + lines.push( + "", + "Firstmate liest diese Datei bei Intake, wählt die passende Regel und löst Arrays über quota-array-dispatch auf, bevor fm-spawn konkrete Flags bekommt.", + ); + return lines.join("\n"); +} + +export function routeResultJson(result: RouteResult): unknown { + return { + taskClass: result.taskClass.id, + classificationConfidence: result.classificationConfidence, + mode: result.mode, + degradedReason: result.degradedReason, + quotaSummary: result.quotaSummary, + spawnFlags: result.recommendation.spawnFlags, + recommendation: candidateJson(result.recommendation), + alternatives: result.alternatives.map(candidateJson), + disclaimer: result.disclaimer, + }; +} + +function candidateJson(c: RankedCandidate) { + return { + profileId: c.profile.id, + harness: c.profile.harness, + model: c.profile.model, + effort: c.profile.effort, + provider: c.profile.provider, + spawnFlags: c.spawnFlags, + rankScore: Number(c.rankScore.toFixed(3)), + qualityFit: c.qualityFit, + tradeoff: c.tradeoff, + evidence: c.evidence, + quota: c.providerView + ? { + effectiveRemaining: c.providerView.effectiveRemaining, + usableRunwaySeconds: c.providerView.usableRunwaySeconds, + exhausted: c.providerView.exhausted, + evidence: c.providerView.evidence, + } + : null, + }; +} diff --git a/src/knowledge.ts b/src/knowledge.ts new file mode 100644 index 0000000..630bef9 --- /dev/null +++ b/src/knowledge.ts @@ -0,0 +1,81 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import type { KnowledgeProfile, ProviderInfo, TaskClass } from "./types.ts"; + +export interface DispatchTemplateRule { + when: string; + profileIds: string[]; + why?: string; +} + +export interface KnowledgePack { + taskClasses: TaskClass[]; + providers: ProviderInfo[]; + profiles: Map; + profileList: KnowledgeProfile[]; + dispatchTemplate: { + rules: DispatchTemplateRule[]; + defaultProfileIds: string[]; + }; + rootDir: string; +} + +interface TaskClassesFile { + classes: TaskClass[]; +} + +interface ProvidersFile { + providers: ProviderInfo[]; +} + +interface ProfilesFile { + profiles: KnowledgeProfile[]; + dispatchTemplate: { + rules: DispatchTemplateRule[]; + defaultProfileIds: string[]; + }; +} + +function packageRootFrom(moduleUrl: string): string { + // src/knowledge.ts → package root + return join(dirname(fileURLToPath(moduleUrl)), ".."); +} + +function readJson(path: string): T { + return JSON.parse(readFileSync(path, "utf8")) as T; +} + +/** Load curated knowledge tables from the package knowledge/ directory. */ +export function loadKnowledge(rootDir: string = packageRootFrom(import.meta.url)): KnowledgePack { + const knowledgeDir = join(rootDir, "knowledge"); + const taskFile = readJson(join(knowledgeDir, "task-classes.json")); + const providerFile = readJson(join(knowledgeDir, "providers.json")); + const profileFile = readJson(join(knowledgeDir, "profiles.json")); + + const profiles = new Map(); + for (const p of profileFile.profiles) { + profiles.set(p.id, p); + } + + return { + taskClasses: taskFile.classes, + providers: providerFile.providers, + profiles, + profileList: profileFile.profiles, + dispatchTemplate: profileFile.dispatchTemplate, + rootDir, + }; +} + +export function resolveProfiles( + pack: KnowledgePack, + ids: string[], +): KnowledgeProfile[] { + const out: KnowledgeProfile[] = []; + for (const id of ids) { + const p = pack.profiles.get(id); + if (p) out.push(p); + } + return out; +} diff --git a/src/quota.ts b/src/quota.ts new file mode 100644 index 0000000..6a094e3 --- /dev/null +++ b/src/quota.ts @@ -0,0 +1,285 @@ +import { execFile, type ChildProcess } from "node:child_process"; +import type { + EffectiveAvailability, + ProviderQuotaView, + QuotaProviderSnapshot, + QuotaSnapshot, +} from "./types.ts"; + +export interface QuotaReadResult { + mode: "live" | "degraded"; + snapshot?: QuotaSnapshot; + degradedReason?: string; + providers: ProviderQuotaView[]; +} + +export type QuotaExec = ( + file: string, + args: readonly string[], + options: { + encoding: "utf8"; + timeout: number; + maxBuffer: number; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + }, +) => Promise; + +export interface ReadLiveQuotaOptions { + signal?: AbortSignal; + exec?: QuotaExec; +} + +const QUOTA_TIMEOUT_MS = 20_000; + +function defaultQuotaExec( + file: string, + args: readonly string[], + options: { + encoding: "utf8"; + timeout: number; + maxBuffer: number; + env: NodeJS.ProcessEnv; + signal?: AbortSignal; + }, +): Promise { + return new Promise((resolve, reject) => { + let settled = false; + let child: ChildProcess | undefined; + const settle = (fn: () => void) => { + if (settled) return; + settled = true; + options.signal?.removeEventListener("abort", onAbort); + fn(); + }; + const failAborted = () => { + settle(() => reject(abortError(options.signal?.reason))); + }; + const onAbort = () => { + child?.kill("SIGTERM"); + failAborted(); + }; + + if (options.signal?.aborted) { + failAborted(); + return; + } + + child = execFile( + file, + [...args], + { + encoding: "utf8", + timeout: options.timeout, + maxBuffer: options.maxBuffer, + env: options.env, + signal: options.signal, + }, + (err, stdout) => { + if (err) { + settle(() => reject(err)); + return; + } + settle(() => resolve(stdout)); + }, + ); + + options.signal?.addEventListener("abort", onAbort, { once: true }); + }); +} + +function nodeErrorCode(err: unknown): unknown { + if (err && typeof err === "object" && "code" in err) { + return (err as { code: unknown }).code; + } + return undefined; +} + +function abortError(reason?: unknown): Error { + if (reason instanceof Error) return reason; + return Object.assign(new Error("The operation was aborted"), { + code: "ABORT_ERR", + name: "AbortError", + }); +} + +function isAbortError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + if (nodeErrorCode(err) === "ABORT_ERR") return true; + return "name" in err && (err as { name: unknown }).name === "AbortError"; +} + +function quotaFailureReason(err: unknown): string { + const message = err instanceof Error ? err.message : String(err); + const missing = + nodeErrorCode(err) === "ENOENT" + ? "quota-axi not found on PATH" + : `quota-axi failed: ${message.split("\n")[0]}`; + return `${missing}. Using offline knowledge heuristics (labeled degraded).`; +} + +function degraded(reason: string): QuotaReadResult { + return { + mode: "degraded", + degradedReason: reason, + providers: [], + }; +} + +/** + * Run quota-axi --json from PATH. Never invents credentials. + * Missing/failed quota-axi is labeled degraded; AbortSignal cancellation is rethrown, not degraded. + */ +export async function readLiveQuota( + options: ReadLiveQuotaOptions = {}, +): Promise { + const exec = options.exec ?? defaultQuotaExec; + const signal = options.signal; + if (signal?.aborted) { + throw abortError(signal.reason); + } + try { + const stdout = await exec("quota-axi", ["--json"], { + encoding: "utf8", + timeout: QUOTA_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + env: process.env, + signal, + }); + const snapshot = JSON.parse(stdout) as QuotaSnapshot; + if (!snapshot || !Array.isArray(snapshot.providers)) { + return { + mode: "degraded", + degradedReason: "quota-axi --json returned unexpected shape (missing providers[])", + providers: [], + }; + } + return { + mode: "live", + snapshot, + providers: snapshot.providers.map(summarizeProvider), + }; + } catch (err) { + if (isAbortError(err)) throw err; + return degraded(quotaFailureReason(err)); + } +} + +/** Pure parse path for fixtures/tests. */ +export function summarizeSnapshot(snapshot: QuotaSnapshot): ProviderQuotaView[] { + return (snapshot.providers ?? []).map(summarizeProvider); +} + +/** + * Collapse a quota-axi provider snapshot to ranking inputs. + * Stale snapshots keep remaining/runway unknown so routing never trusts expired quota. + */ +export function summarizeProvider(p: QuotaProviderSnapshot): ProviderQuotaView { + const stateStatus = p.state?.status ?? "unknown"; + const stale = Boolean(p.state?.stale); + const semanticsStatus = p.quotaSemantics?.status ?? "unknown"; + const avail = pickApplicableAvailability(p.quotaSemantics?.effectiveAvailability ?? []); + + let effectiveRemaining: number | null = null; + let usableRunwaySeconds: number | null = null; + let runwayStatus = "unknown"; + let scopeUsed: string | undefined; + + if (avail && !stale) { + scopeUsed = avail.scope; + if ( + avail.status === "known" && + typeof avail.effectivePercentRemaining === "number" && + Number.isFinite(avail.effectivePercentRemaining) + ) { + effectiveRemaining = avail.effectivePercentRemaining; + } + const runway = avail.runway; + if (runway) { + runwayStatus = runway.status ?? "unknown"; + if ( + runway.status === "projected_exhaustion" || + runway.status === "through_reset" || + runway.status === "exhausted_now" + ) { + if (typeof runway.usableRunwaySeconds === "number" && Number.isFinite(runway.usableRunwaySeconds)) { + usableRunwaySeconds = runway.usableRunwaySeconds; + } else if (runway.status === "exhausted_now") { + usableRunwaySeconds = 0; + } + } + } + } + + // Fallback diagnostic: single window percentRemaining when semantics unknown + // but a clear 0-remaining window exists — still label as window-diagnostic. + let exhausted = effectiveRemaining === 0 || usableRunwaySeconds === 0; + if ( + !stale && + effectiveRemaining === null && + Array.isArray(p.windows) && + p.windows.length > 0 + ) { + const remainings = p.windows + .map((w) => w.percentRemaining) + .filter((n): n is number => typeof n === "number" && Number.isFinite(n)); + if (remainings.length > 0 && remainings.every((n) => n <= 0)) { + exhausted = true; + } + } + + const evidenceParts: string[] = [ + `state=${stateStatus}`, + stale ? "stale" : "fresh-or-n/a", + `semantics=${semanticsStatus}`, + ]; + if (scopeUsed) evidenceParts.push(`scope=${scopeUsed}`); + if (effectiveRemaining !== null) evidenceParts.push(`effectiveRemaining=${effectiveRemaining}%`); + else evidenceParts.push("effectiveRemaining=unknown"); + if (usableRunwaySeconds !== null) evidenceParts.push(`runwaySeconds=${usableRunwaySeconds}`); + else evidenceParts.push(`runway=${runwayStatus}`); + if (p.state?.authStatus) evidenceParts.push(`authStatus=${p.state.authStatus}`); + if (p.state?.error) evidenceParts.push(`error=${p.state.error}`); + + return { + provider: p.provider, + label: p.label ?? p.provider, + stateStatus, + stale, + authStatus: p.state?.authStatus, + error: p.state?.error, + semanticsStatus, + effectiveRemaining, + usableRunwaySeconds, + runwayStatus, + scopeUsed, + exhausted, + evidence: evidenceParts.join("; "), + }; +} + +/** + * Prefer broad account scopes (all_models / all_products) over model-specific + * scopes when summarizing provider-level headroom. + */ +export function pickApplicableAvailability( + items: EffectiveAvailability[], +): EffectiveAvailability | undefined { + if (!items.length) return undefined; + const broad = items.find((i) => { + const s = (i.scope ?? "").toLowerCase(); + return s === "all_models" || s === "all_products" || s === "account"; + }); + if (broad) return broad; + // Prefer known status entries + const known = items.find((i) => i.status === "known"); + return known ?? items[0]; +} + +export function indexProvidersById( + views: ProviderQuotaView[], +): Map { + const m = new Map(); + for (const v of views) m.set(v.provider, v); + return m; +} diff --git a/src/scorer.ts b/src/scorer.ts new file mode 100644 index 0000000..ba7ced8 --- /dev/null +++ b/src/scorer.ts @@ -0,0 +1,400 @@ +import { indexProvidersById, type QuotaReadResult } from "./quota.ts"; +import { resolveProfiles, type KnowledgePack } from "./knowledge.ts"; +import type { + BalanceResult, + KnowledgeProfile, + ProviderQuotaView, + QualityFloor, + RankedCandidate, + RouteResult, + TaskClass, +} from "./types.ts"; + +const QUALITY_RANK: Record = { + low: 1, + medium: 2, + high: 3, +}; + +const DISCLAIMER = + "Empfehlung nur — ersetzt weder Firstmate-Urteil noch quota-array-dispatch. Firstmate übergibt konkrete Flags an fm-spawn."; + +export interface ClassifyResult { + taskClass: TaskClass; + confidence: "high" | "medium" | "low"; + notes: string[]; +} + +/** Keyword/heuristic classification; optional explicit class id wins. */ +export function classifyTask( + pack: KnowledgePack, + taskText: string, + explicitClassId?: string, +): ClassifyResult { + if (explicitClassId) { + const hit = pack.taskClasses.find((c) => c.id === explicitClassId); + if (hit) { + return { + taskClass: hit, + confidence: "high", + notes: [`Explizite Klasse: ${hit.id}`], + }; + } + return { + taskClass: pack.taskClasses.find((c) => c.id === "standard_ship") ?? pack.taskClasses[0], + confidence: "low", + notes: [ + `Unbekannte Klasse "${explicitClassId}", Fallback standard_ship`, + ], + }; + } + + const text = taskText.toLowerCase(); + const scores: Array<{ cls: TaskClass; score: number; hits: string[] }> = []; + for (const cls of pack.taskClasses) { + const hits = cls.keywords.filter((k) => keywordMatches(text, k)); + scores.push({ cls, score: hits.length, hits }); + } + scores.sort((a, b) => b.score - a.score); + const best = scores[0]; + if (!best || best.score === 0) { + const standard = + pack.taskClasses.find((c) => c.id === "standard_ship") ?? pack.taskClasses[0]; + return { + taskClass: standard, + confidence: "low", + notes: [ + "Keine Keyword-Treffer; Fallback standard_ship. Bei Bedarf taskClass explizit setzen.", + ], + }; + } + const second = scores[1]; + const confidence = + best.score >= 2 && (!second || best.score > second.score) + ? "high" + : best.score >= 1 + ? "medium" + : "low"; + return { + taskClass: best.cls, + confidence, + notes: [ + `Keyword-Treffer (${best.score}): ${best.hits.slice(0, 6).join(", ")}`, + ], + }; +} + +export function buildSpawnFlags(profile: KnowledgeProfile): string { + const parts = [`--harness ${shellQuote(profile.harness)}`]; + if (profile.model) parts.push(`--model ${shellQuote(profile.model)}`); + if (profile.effort) parts.push(`--effort ${shellQuote(String(profile.effort))}`); + return parts.join(" "); +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@+-]+$/.test(value)) return value; + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +/** + * Match keywords on token/phrase boundaries so short tokens like "pr" or "ship" + * do not false-fire inside ordinary English (improve, relationship, …). + */ +export function keywordMatches(haystackLower: string, keyword: string): boolean { + const k = keyword.toLowerCase().trim(); + if (!k) return false; + const escaped = k.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\s+/g, "\\s+"); + const re = new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:$|[^a-z0-9_])`, "i"); + return re.test(haystackLower); +} + +/** + * Transparent ranking: + * 1. Quality floor must be met (hard filter for preferred list; soft for fallback). + * 2. Prefer non-exhausted providers when alternatives exist. + * 3. Prefer higher known effectiveRemaining. + * 4. Prefer longer known usable runway. + * 5. Unknown quota stays eligible with uncertainty disclosed (never treated as 0). + * 6. Preferred-list order is a weak tie-break only after quota comparisons. + * + * Rank score is diagnostic/transparent, not a hidden composite that replaces judgment. + */ +export interface RankOptions { + /** Number of leading profiles that are class-preferred (rest are fallback). */ + preferredCount?: number; +} + +export function rankCandidates( + profiles: KnowledgeProfile[], + providerIndex: Map, + qualityFloor: QualityFloor, + mode: "live" | "degraded", + options: RankOptions = {}, +): RankedCandidate[] { + const floor = QUALITY_RANK[qualityFloor] ?? 2; + const preferredCount = options.preferredCount ?? profiles.length; + const anyKnownHeadroom = [...providerIndex.values()].some( + (p) => p.effectiveRemaining !== null && !p.exhausted, + ); + + const ranked: RankedCandidate[] = profiles.map((profile, index) => { + const view = providerIndex.get(profile.provider) ?? null; + const qualityFit = QUALITY_RANK[profile.quality] >= floor; + const isPreferred = index < preferredCount; + const evidence: string[] = [ + `profile=${profile.id}`, + `provider=${profile.provider}`, + `quality=${profile.quality} (floor ${qualityFloor})`, + isPreferred ? "list=preferred" : "list=fallback", + profile.why, + ]; + + let rankScore = 0; + // Task-class fit dominates quota: preferred shortlist beats healthy fallbacks + // unless preferred is exhausted while a fallback has real headroom. + if (isPreferred) { + rankScore += 50; + rankScore += Math.max(0, preferredCount - index) * 0.5; + } else { + rankScore += Math.max(0, 5 - (index - preferredCount)) * 0.1; + } + + if (qualityFit) rankScore += 100; + else rankScore -= 50; + + if (mode === "degraded" || !view) { + evidence.push( + mode === "degraded" + ? "quota=degraded/offline heuristic" + : "quota=no provider row", + ); + rankScore += 5; // neutral-ish offline + return { + profile, + providerView: view, + qualityFit, + rankScore, + tradeoff: qualityFit + ? "Qualität ok; Quota offline/unbekannt — Knowledge-Reihenfolge." + : "Unter Quality-Floor; nur Fallback.", + evidence, + spawnFlags: buildSpawnFlags(profile), + }; + } + + evidence.push(view.evidence); + + if (view.exhausted && anyKnownHeadroom) { + rankScore -= 80; + evidence.push("penalty=exhausted_while_alternatives_exist"); + } else if (view.exhausted) { + rankScore -= 20; + evidence.push("note=exhausted_but_no_known_alternative_headroom"); + } + + if (view.effectiveRemaining !== null) { + // 0..40 points from headroom + rankScore += Math.min(40, Math.max(0, view.effectiveRemaining) * 0.4); + evidence.push(`headroom_term=${(view.effectiveRemaining * 0.4).toFixed(1)}`); + } else { + // Unknown stays eligible; slight preference for known sustainable evidence elsewhere + rankScore += 8; + evidence.push("headroom_term=unknown_eligible(+8)"); + } + + if (view.usableRunwaySeconds !== null) { + // Cap runway contribution (~1 day) + const hours = view.usableRunwaySeconds / 3600; + const runwayPts = Math.min(25, hours * 0.5); + rankScore += runwayPts; + evidence.push(`runway_term=${runwayPts.toFixed(1)} (${hours.toFixed(1)}h)`); + } else { + evidence.push("runway_term=unknown"); + } + + // Concrete auth failure is uncertainty for most providers; only soft penalty + if (view.stateStatus === "auth_required" || view.authStatus === "unusable") { + rankScore -= 15; + evidence.push("soft_penalty=auth_uncertain"); + } + + const tradeoff = describeTradeoff(profile, view, qualityFit); + return { + profile, + providerView: view, + qualityFit, + rankScore, + tradeoff, + evidence, + spawnFlags: buildSpawnFlags(profile), + }; + }); + + ranked.sort((a, b) => b.rankScore - a.rankScore); + return ranked; +} + +function describeTradeoff( + profile: KnowledgeProfile, + view: ProviderQuotaView, + qualityFit: boolean, +): string { + const parts: string[] = []; + if (!qualityFit) parts.push("unter Quality-Floor"); + else parts.push(`Qualität ${profile.quality}/${profile.reasoningClass}`); + + if (view.exhausted) parts.push("Quota erschöpft/0"); + else if (view.effectiveRemaining !== null) + parts.push(`Headroom ${view.effectiveRemaining}% (${view.scopeUsed ?? "scope?"})`); + else parts.push("Headroom unbekannt (eligible)"); + + if (view.usableRunwaySeconds !== null) + parts.push(`Runway ~${Math.round(view.usableRunwaySeconds / 3600)}h`); + else if (view.runwayStatus && view.runwayStatus !== "unknown") + parts.push(`Runway-Status ${view.runwayStatus}`); + + return parts.join("; "); +} + +export function routeTask( + pack: KnowledgePack, + quota: QuotaReadResult, + taskText: string, + explicitClassId?: string, +): RouteResult { + const classification = classifyTask(pack, taskText, explicitClassId); + const preferred = resolveProfiles(pack, classification.taskClass.preferredProfileIds); + const fallback = resolveProfiles(pack, classification.taskClass.fallbackProfileIds); + // Deduplicate by profile id, preferred first + const seen = new Set(); + const combined: KnowledgeProfile[] = []; + let preferredCount = 0; + for (const p of preferred) { + if (seen.has(p.id)) continue; + seen.add(p.id); + combined.push(p); + preferredCount += 1; + } + for (const p of fallback) { + if (seen.has(p.id)) continue; + seen.add(p.id); + combined.push(p); + } + + const providerIndex = indexProvidersById(quota.providers); + const ranked = rankCandidates( + combined, + providerIndex, + classification.taskClass.qualityFloor, + quota.mode, + { preferredCount }, + ); + + if (!ranked.length) { + // Absolute fallback: first standard profile + const emergency = + pack.profileList.find((p) => p.id === "standard-codex") ?? pack.profileList[0]; + const emergencyRanked: RankedCandidate = { + profile: emergency, + providerView: providerIndex.get(emergency.provider) ?? null, + qualityFit: true, + rankScore: 0, + tradeoff: "Emergency fallback — no class profiles resolved", + evidence: ["no profiles for class"], + spawnFlags: buildSpawnFlags(emergency), + }; + return { + taskClass: classification.taskClass, + classificationConfidence: classification.confidence, + classificationNotes: classification.notes, + mode: quota.mode, + degradedReason: quota.degradedReason, + recommendation: emergencyRanked, + alternatives: [], + quotaSummary: summarizeQuota(quota), + disclaimer: DISCLAIMER, + }; + } + + const [recommendation, ...alternatives] = ranked; + return { + taskClass: classification.taskClass, + classificationConfidence: classification.confidence, + classificationNotes: classification.notes, + mode: quota.mode, + degradedReason: quota.degradedReason, + recommendation, + alternatives: alternatives.slice(0, 5), + quotaSummary: summarizeQuota(quota), + disclaimer: DISCLAIMER, + }; +} + +export function balanceProviders( + pack: KnowledgePack, + quota: QuotaReadResult, +): BalanceResult { + const views = + quota.providers.length > 0 + ? [...quota.providers] + : pack.providers.map( + (p): ProviderQuotaView => ({ + provider: p.quotaProvider, + label: p.label, + stateStatus: "unknown", + stale: false, + semanticsStatus: "unknown", + effectiveRemaining: null, + usableRunwaySeconds: null, + runwayStatus: "unknown", + exhausted: false, + evidence: "offline: no quota-axi data", + }), + ); + + views.sort((a, b) => { + // Non-exhausted known headroom first, then unknown, then exhausted + const score = (v: ProviderQuotaView) => { + if (v.exhausted) return -1000; + if (v.effectiveRemaining !== null) return v.effectiveRemaining; + return -1; // unknown above exhausted, below known positive + }; + const d = score(b) - score(a); + if (d !== 0) return d; + const ra = a.usableRunwaySeconds ?? -1; + const rb = b.usableRunwaySeconds ?? -1; + return rb - ra; + }); + + const notes = [ + "Sortierung: bekanntes Headroom desc, dann Runway; exhausted zuletzt; unknown bleibt eligible.", + "Prozentwerte sind provider-intern und nicht cross-provider vergleichbar — nur relative Preferenz.", + ]; + if (quota.mode === "degraded") { + notes.push(`Degraded: ${quota.degradedReason}`); + } + + return { + mode: quota.mode, + degradedReason: quota.degradedReason, + generatedAt: quota.snapshot?.generatedAt, + providers: views, + rankingNotes: notes, + disclaimer: DISCLAIMER, + }; +} + +function summarizeQuota(quota: QuotaReadResult): string { + if (quota.mode === "degraded") { + return `degraded: ${quota.degradedReason ?? "unknown"}`; + } + if (!quota.providers.length) return "live: no providers in snapshot"; + return quota.providers + .map((p) => { + const head = + p.effectiveRemaining !== null ? `${p.effectiveRemaining}%` : "unk"; + const ex = p.exhausted ? "!exhausted" : "ok"; + return `${p.provider}:${head}/${ex}`; + }) + .join(", "); +} diff --git a/src/session-models.ts b/src/session-models.ts new file mode 100644 index 0000000..9a7e855 --- /dev/null +++ b/src/session-models.ts @@ -0,0 +1,148 @@ +/** List/search authenticated Pi session models without switching. */ + +export interface SessionModelRef { + provider: string; + id: string; + name?: string; + /** Canonical `provider/id` reference used by Pi `/model`. */ + ref: string; +} + +/** Minimal shape of Pi ExtensionContext model surfaces used here. */ +export interface SessionModelSource { + modelRegistry?: { + getAvailable?(): Array<{ provider: string; id: string; name?: string }>; + getAll?(): Array<{ provider: string; id: string; name?: string }>; + // Method syntax keeps parameter checking bivariant so ModelRegistry assigns. + hasConfiguredAuth?(model: { + provider: string; + id: string; + name?: string; + }): boolean; + }; + scopedModels?: ReadonlyArray<{ + model: { provider: string; id: string; name?: string }; + }>; + model?: { provider: string; id: string; name?: string } | null; +} + +export interface ListedSessionModels { + models: SessionModelRef[]; + current: SessionModelRef | null; + source: "scoped" | "available" | "empty"; + notes: string[]; +} + +export function toSessionModelRef(model: { + provider: string; + id: string; + name?: string; +}): SessionModelRef { + return { + provider: model.provider, + id: model.id, + name: model.name, + ref: `${model.provider}/${model.id}`, + }; +} + +function dedupeModels(models: SessionModelRef[]): SessionModelRef[] { + const seen = new Set(); + const out: SessionModelRef[] = []; + for (const m of models) { + const key = m.ref.toLowerCase(); + if (seen.has(key)) continue; + seen.add(key); + out.push(m); + } + return out; +} + +/** + * Prefer session-scoped models when configured; otherwise authenticated + * catalogue via modelRegistry.getAvailable() (auth-filtered when possible). + */ +export function listAuthenticatedSessionModels( + source: SessionModelSource, +): ListedSessionModels { + const notes: string[] = []; + const current = source.model ? toSessionModelRef(source.model) : null; + + if (source.scopedModels && source.scopedModels.length > 0) { + const models = dedupeModels( + source.scopedModels.map((entry) => toSessionModelRef(entry.model)), + ); + notes.push( + `Using ${models.length} session-scoped model(s) (same set as /scoped-models).`, + ); + return { models, current, source: "scoped", notes }; + } + + const registry = source.modelRegistry; + const raw = + typeof registry?.getAvailable === "function" ? registry.getAvailable() : []; + + if (!raw.length) { + notes.push( + "No authenticated/available Pi models visible in this session context.", + ); + return { models: [], current, source: "empty", notes }; + } + + let filtered = raw; + if (typeof registry?.hasConfiguredAuth === "function") { + const authed = raw.filter((m) => { + try { + return registry.hasConfiguredAuth!(m); + } catch { + return false; + } + }); + if (authed.length === 0) { + notes.push( + "getAvailable() returned models but none passed hasConfiguredAuth.", + ); + return { models: [], current, source: "empty", notes }; + } + filtered = authed; + notes.push("Filtered to models with configured auth."); + } else { + notes.push( + "Auth filter unavailable; listing getAvailable() catalogue as session-available.", + ); + } + + const models = dedupeModels(filtered.map(toSessionModelRef)); + if (!models.length) { + notes.push( + "No authenticated/available Pi models visible in this session context.", + ); + return { models, current, source: "empty", notes }; + } + return { models, current, source: "available", notes }; +} + +/** Case-insensitive substring search over provider, id, name, and ref. */ +export function searchSessionModels( + models: SessionModelRef[], + query: string, +): SessionModelRef[] { + const q = query.trim().toLowerCase(); + if (!q) return models; + return models.filter((m) => { + if (m.ref.toLowerCase().includes(q)) return true; + if (m.id.toLowerCase().includes(q)) return true; + if (m.provider.toLowerCase().includes(q)) return true; + if (m.name && m.name.toLowerCase().includes(q)) return true; + return false; + }); +} + +/** Simple glob match (`*` only) against a full string. */ +export function matchModelPattern(pattern: string, value: string): boolean { + const escaped = pattern + .toLowerCase() + .replace(/[.+^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + return new RegExp(`^${escaped}$`, "i").test(value); +} diff --git a/src/suggest-primary.ts b/src/suggest-primary.ts new file mode 100644 index 0000000..e22a319 --- /dev/null +++ b/src/suggest-primary.ts @@ -0,0 +1,362 @@ +import { indexProvidersById, type QuotaReadResult } from "./quota.ts"; +import type { KnowledgePack } from "./knowledge.ts"; +import { + listAuthenticatedSessionModels, + matchModelPattern, + searchSessionModels, + type SessionModelRef, + type SessionModelSource, +} from "./session-models.ts"; +import { routeTask } from "./scorer.ts"; +import type { KnowledgeProfile, ProviderQuotaView } from "./types.ts"; + +const DISCLAIMER = + "Advisory only — schaltet kein Modell und berührt keine Crew-Panes. Manuell /model oder optionales pi-model-switch nutzen."; + +export interface PrimaryModelMatch { + model: SessionModelRef; + profileId?: string; + score: number; + reason: string; + tradeoff: string; +} + +export interface SuggestPrimaryResult { + goal: string; + search?: string; + taskClassId: string; + mode: "live" | "degraded"; + degradedReason?: string; + currentModel: SessionModelRef | null; + availableCount: number; + listedSource: "scoped" | "available" | "empty"; + matches: PrimaryModelMatch[]; + recommendation: PrimaryModelMatch | null; + searchHits: SessionModelRef[]; + notes: string[]; + disclaimer: string; +} + +export interface SuggestPrimaryOptions { + goal?: string; + /** Optional filter over authenticated session models (provider/id/name). */ + search?: string; + session: SessionModelSource; +} + +function profileModelTokens(profile: KnowledgeProfile): string[] { + if (!profile.model) return []; + const m = profile.model.toLowerCase(); + const tokens = [m]; + if (m.includes("/")) { + const bare = m.slice(m.lastIndexOf("/") + 1); + if (bare) tokens.push(bare); + } + return tokens; +} + +export function modelMatchesProfile( + model: SessionModelRef, + profile: KnowledgeProfile, +): boolean { + const ref = model.ref.toLowerCase(); + const id = model.id.toLowerCase(); + for (const token of profileModelTokens(profile)) { + if (ref === token || id === token) return true; + if (ref.endsWith(`/${token}`)) return true; + // Allow dated ids (claude-sonnet-4-20250514) against bare hints. + if (id === token || id.startsWith(`${token}-`) || id.startsWith(`${token}@`)) { + return true; + } + } + return false; +} + +export function modelMatchesProviderPatterns( + model: SessionModelRef, + patterns: string[], +): boolean { + const ref = model.ref; + const id = model.id; + for (const pat of patterns) { + if (matchModelPattern(pat, ref) || matchModelPattern(pat, id)) return true; + // Patterns like anthropic/* against provider-only when id wildcard. + if (pat.endsWith("/*") && matchModelPattern(pat.slice(0, -1) + model.id, ref)) { + return true; + } + } + return false; +} + +function providerViewForModel( + model: SessionModelRef, + pack: KnowledgePack, + providerIndex: Map, +): ProviderQuotaView | null { + for (const info of pack.providers) { + if (modelMatchesProviderPatterns(model, info.piModelPatterns)) { + return providerIndex.get(info.quotaProvider) ?? null; + } + } + return null; +} + +function describeModelTradeoff( + model: SessionModelRef, + view: ProviderQuotaView | null, + profileId?: string, +): string { + const parts = [`session model ${model.ref}`]; + if (profileId) parts.push(`profile ${profileId}`); + if (!view) parts.push("quota unbekannt/n.a."); + else if (view.exhausted) parts.push("Quota erschöpft/0"); + else if (view.effectiveRemaining !== null) + parts.push(`Headroom ${view.effectiveRemaining}%`); + else parts.push("Headroom unbekannt (eligible)"); + return parts.join("; "); +} + +/** + * Recommend a primary-session Pi model only among models actually available + * in the current ExtensionContext (scopedModels / modelRegistry). + */ +export function suggestPrimary( + pack: KnowledgePack, + quota: QuotaReadResult, + options: SuggestPrimaryOptions, +): SuggestPrimaryResult { + const goal = options.goal?.trim() || "standard primary orchestration"; + const search = options.search?.trim() || undefined; + const listed = listAuthenticatedSessionModels(options.session); + const pool = search + ? searchSessionModels(listed.models, search) + : listed.models; + + const routed = routeTask(pack, quota, goal); + const providerIndex = indexProvidersById(quota.providers); + const shortlist = [routed.recommendation, ...routed.alternatives]; + const notes = [ + ...listed.notes, + `Task-Klasse (advisory): ${routed.taskClass.id} (${routed.classificationConfidence})`, + ...routed.classificationNotes, + ]; + if (search) { + notes.push( + `Search "${search}": ${pool.length} Treffer von ${listed.models.length} verfügbaren Session-Modellen.`, + ); + } + + if (!listed.models.length) { + notes.push( + "Keine Empfehlung möglich — Session hat keine sichtbaren authentifizierten Pi-Modelle.", + ); + return { + goal, + search, + taskClassId: routed.taskClass.id, + mode: routed.mode, + degradedReason: routed.degradedReason, + currentModel: listed.current, + availableCount: 0, + listedSource: listed.source, + matches: [], + recommendation: null, + searchHits: [], + notes, + disclaimer: DISCLAIMER, + }; + } + + if (!pool.length) { + notes.push( + `Search ohne Treffer unter ${listed.models.length} verfügbaren Session-Modellen.`, + ); + return { + goal, + search, + taskClassId: routed.taskClass.id, + mode: routed.mode, + degradedReason: routed.degradedReason, + currentModel: listed.current, + availableCount: listed.models.length, + listedSource: listed.source, + matches: [], + recommendation: null, + searchHits: [], + notes, + disclaimer: DISCLAIMER, + }; + } + + const matches: PrimaryModelMatch[] = pool.map((model) => { + let score = 0; + const reasons: string[] = []; + let profileId: string | undefined; + const view = providerViewForModel(model, pack, providerIndex); + + for (let i = 0; i < shortlist.length; i++) { + const cand = shortlist[i]; + if (modelMatchesProfile(model, cand.profile)) { + const boost = Math.max(10, 120 - i * 12); + score += boost; + profileId = profileId ?? cand.profile.id; + reasons.push(`profile-match ${cand.profile.id} (+${boost})`); + break; + } + } + + if (!profileId) { + for (let i = 0; i < shortlist.length; i++) { + const cand = shortlist[i]; + const info = pack.providers.find((p) => p.id === cand.profile.provider); + if ( + info && + modelMatchesProviderPatterns(model, info.piModelPatterns) + ) { + const boost = Math.max(5, 60 - i * 8); + score += boost; + profileId = cand.profile.id; + reasons.push(`provider-family ${info.id} via ${cand.profile.id} (+${boost})`); + break; + } + } + } + + if (view) { + if (view.exhausted) { + score -= 40; + reasons.push("quota exhausted penalty"); + } else if (view.effectiveRemaining !== null) { + const pts = Math.min(25, view.effectiveRemaining * 0.25); + score += pts; + reasons.push(`headroom +${pts.toFixed(1)}`); + } else { + score += 5; + reasons.push("quota unknown eligible +5"); + } + } else { + score += 2; + reasons.push("no quota row +2"); + } + + if ( + listed.current && + listed.current.ref.toLowerCase() === model.ref.toLowerCase() + ) { + score += 1; + reasons.push("currently active"); + } + + return { + model, + profileId, + score, + reason: reasons.join("; ") || "available in session", + tradeoff: describeModelTradeoff(model, view, profileId), + }; + }); + + matches.sort((a, b) => b.score - a.score); + + // Prefer models that actually tied to the route shortlist; if none scored + // a family/profile match, still surface the best available session model. + const fitted = matches.filter( + (m) => m.profileId !== undefined || m.score >= 5, + ); + const ordered = fitted.length ? fitted : matches; + const recommendation = ordered[0] ?? null; + + if (recommendation && recommendation.profileId === undefined) { + notes.push( + "Kein Knowledge-Profil traf exakt; beste verfügbare Session-Modell-Heuristik.", + ); + } + if ( + recommendation && + !shortlist.some( + (c) => + modelMatchesProfile(recommendation.model, c.profile) || + modelMatchesProviderPatterns( + recommendation.model, + pack.providers.find((p) => p.id === c.profile.provider) + ?.piModelPatterns ?? [], + ), + ) + ) { + notes.push( + "Empfehlung liegt außerhalb der route-preferred Provider-Familien — nur Session-Verfügbarkeit.", + ); + } + + notes.push( + "Empfehlung nur unter in dieser Session sichtbaren/authentifizierten Pi-Modellen.", + ); + + return { + goal, + search, + taskClassId: routed.taskClass.id, + mode: routed.mode, + degradedReason: routed.degradedReason, + currentModel: listed.current, + availableCount: listed.models.length, + listedSource: listed.source, + matches: ordered.slice(0, 8), + recommendation, + searchHits: search ? pool : [], + notes, + disclaimer: DISCLAIMER, + }; +} + +export function formatSuggestPrimaryResult(result: SuggestPrimaryResult): string { + const lines: string[] = [ + "# crew_suggest_primary", + "", + `Goal: ${result.goal}`, + result.search ? `Search: ${result.search}` : "Search: (none)", + `Suggested class: ${result.taskClassId}`, + `Mode: ${result.mode}${result.degradedReason ? ` — ${result.degradedReason}` : ""}`, + `Session models: ${result.availableCount} (${result.listedSource})`, + result.currentModel + ? `Current: ${result.currentModel.ref}` + : "Current: (none)", + "", + ]; + + if (!result.recommendation) { + lines.push("No primary switch target among authenticated session models."); + } else { + const rec = result.recommendation; + lines.push("## Recommendation (advisory)"); + lines.push(`switch target: ${rec.model.ref}`); + if (rec.model.name) lines.push(`name: ${rec.model.name}`); + if (rec.profileId) lines.push(`aligned profile: ${rec.profileId}`); + lines.push(`tradeoff: ${rec.tradeoff}`); + lines.push(`why: ${rec.reason}`); + lines.push(""); + } + + if (result.matches.length > 1) { + lines.push("## Alternatives in session"); + for (const m of result.matches.slice(1, 6)) { + lines.push( + `- ${m.model.ref}${m.profileId ? ` [${m.profileId}]` : ""} score=${m.score.toFixed(1)} — ${m.reason}`, + ); + } + lines.push(""); + } + + if (result.search && result.searchHits.length) { + lines.push("## Search hits"); + for (const hit of result.searchHits.slice(0, 12)) { + lines.push(`- ${hit.ref}${hit.name ? ` (${hit.name})` : ""}`); + } + lines.push(""); + } + + lines.push("## Notes"); + for (const n of result.notes) lines.push(`- ${n}`); + lines.push("", result.disclaimer); + return lines.join("\n"); +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..3fc7972 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,166 @@ +/** Shared types for firstmate-crew-knowledge. */ + +export type Effort = "low" | "medium" | "high" | "xhigh" | "max"; + +export type QualityFloor = "low" | "medium" | "high"; + +export interface SpawnProfile { + harness: string; + model?: string; + effort?: Effort | string; +} + +export interface DispatchRule { + when: string; + use: SpawnProfile | SpawnProfile[]; + why?: string; +} + +export interface CrewDispatchFile { + rules: DispatchRule[]; + default?: SpawnProfile | SpawnProfile[]; +} + +export interface KnowledgeProfile extends SpawnProfile { + id: string; + provider: string; + quality: QualityFloor; + reasoningClass: string; + why: string; +} + +export interface TaskClass { + id: string; + label: string; + description: string; + keywords: string[]; + qualityFloor: QualityFloor; + preferredProfileIds: string[]; + fallbackProfileIds: string[]; +} + +export interface ProviderInfo { + id: string; + label: string; + quotaProvider: string; + harnesses: string[]; + strengths: string[]; + weaknesses: string[]; + piModelPatterns: string[]; + nativeModelHints: string[]; + defaultHarness: string; + notes?: string; +} + +export interface QuotaRunway { + status?: string; + usableRunwaySeconds?: number | null; + projectedExhaustedAt?: string | null; + limitingWindowId?: string | null; + projectionConfidence?: string | null; + projectionBasis?: string | null; + unmeasurableWindowIds?: string[]; +} + +export interface EffectiveAvailability { + scope: string; + status?: string; + effectivePercentRemaining?: number | null; + boundedBy?: string[]; + runway?: QuotaRunway; + pace?: { status?: string; [k: string]: unknown }; + limitingWindowIds?: string[]; +} + +export interface QuotaProviderSnapshot { + provider: string; + label?: string; + source?: string; + plan?: string; + windows?: Array<{ + id?: string; + label?: string; + kind?: string; + percentUsed?: number | null; + percentRemaining?: number | null; + resetsAt?: string | null; + }>; + state?: { + status?: string; + stale?: boolean; + error?: string; + authStatus?: string; + refreshedAt?: string; + sourcesTried?: string[]; + }; + quotaSemantics?: { + status?: string; + description?: string; + effectiveAvailability?: EffectiveAvailability[]; + unresolvedWindowIds?: string[]; + }; +} + +export interface QuotaSnapshot { + generatedAt?: string; + schemaVersion?: number; + providers: QuotaProviderSnapshot[]; +} + +export interface ProviderQuotaView { + provider: string; + label: string; + stateStatus: string; + stale: boolean; + authStatus?: string; + error?: string; + semanticsStatus: string; + /** Known effective remaining % from the broadest applicable scope, or null if unknown. */ + effectiveRemaining: number | null; + /** Known usable runway seconds, or null if unknown/unmeasurable. */ + usableRunwaySeconds: number | null; + runwayStatus: string; + scopeUsed?: string; + exhausted: boolean; + evidence: string; +} + +export interface RankedCandidate { + profile: KnowledgeProfile; + providerView: ProviderQuotaView | null; + qualityFit: boolean; + rankScore: number; + tradeoff: string; + evidence: string[]; + spawnFlags: string; +} + +export interface RouteResult { + taskClass: TaskClass; + classificationConfidence: "high" | "medium" | "low"; + classificationNotes: string[]; + mode: "live" | "degraded"; + degradedReason?: string; + recommendation: RankedCandidate; + alternatives: RankedCandidate[]; + quotaSummary: string; + disclaimer: string; +} + +export interface BalanceResult { + mode: "live" | "degraded"; + degradedReason?: string; + generatedAt?: string; + providers: ProviderQuotaView[]; + rankingNotes: string[]; + disclaimer: string; +} + +export interface DispatchBuildResult { + dispatch: CrewDispatchFile; + path: string; + dryRun: boolean; + wrote: boolean; + validation: { ok: boolean; errors: string[] }; + summary: string; +} diff --git a/tests/dispatch.test.ts b/tests/dispatch.test.ts new file mode 100644 index 0000000..bc6917b --- /dev/null +++ b/tests/dispatch.test.ts @@ -0,0 +1,196 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + applyDispatch, + buildDispatchFromKnowledge, + mergeDispatch, + validateDispatch, +} from "../src/dispatch.ts"; +import { loadKnowledge } from "../src/knowledge.ts"; +import type { CrewDispatchFile } from "../src/types.ts"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const pack = loadKnowledge(root); + +describe("buildDispatchFromKnowledge", () => { + it("matches Firstmate schema shape", () => { + const doc = buildDispatchFromKnowledge(pack); + const v = validateDispatch(doc); + assert.equal(v.ok, true, v.errors.join("; ")); + assert.ok(Array.isArray(doc.rules) && doc.rules.length >= 3); + assert.ok(doc.default); + for (const rule of doc.rules) { + assert.equal(typeof rule.when, "string"); + assert.ok(rule.use); + } + }); + + it("rejects empty rules", () => { + const v = validateDispatch({ rules: [] }); + assert.equal(v.ok, false); + }); + + it("rejects profile without harness", () => { + const v = validateDispatch({ + rules: [{ when: "x", use: { model: "y" } }], + }); + assert.equal(v.ok, false); + }); +}); + +describe("applyDispatch", () => { + it("dry-run by default does not write", () => { + const home = mkdtempSync(join(tmpdir(), "fck-dry-")); + try { + const result = applyDispatch(pack, { fmHome: home, dryRun: true }); + assert.equal(result.wrote, false); + assert.equal(result.dryRun, true); + assert.equal(result.validation.ok, true); + assert.ok(result.path.endsWith("config/crew-dispatch.json")); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("writes only under FM_HOME/config when dryRun=false", () => { + const home = mkdtempSync(join(tmpdir(), "fck-write-")); + try { + const result = applyDispatch(pack, { fmHome: home, dryRun: false }); + assert.equal(result.wrote, true); + const raw = readFileSync(result.path, "utf8"); + const parsed = JSON.parse(raw) as CrewDispatchFile; + assert.equal(validateDispatch(parsed).ok, true); + assert.ok(result.path.includes(join(home, "config"))); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("refuses write when FM_HOME unset without option", () => { + const result = applyDispatch(pack, { + fmHome: null, + dryRun: false, + env: {}, + }); + assert.equal(result.wrote, false); + assert.match(result.summary, /FM_HOME unset/); + }); + + it("merge keeps existing when text", () => { + const home = mkdtempSync(join(tmpdir(), "fck-merge-")); + try { + mkdirSync(join(home, "config"), { recursive: true }); + const existing: CrewDispatchFile = { + rules: [ + { + when: "Custom captain rule about widgets.", + use: { harness: "codex", model: "gpt-5.5", effort: "medium" }, + why: "captain", + }, + ], + default: { harness: "codex" }, + }; + writeFileSync( + join(home, "config", "crew-dispatch.json"), + JSON.stringify(existing), + ); + const result = applyDispatch(pack, { + fmHome: home, + dryRun: false, + merge: true, + }); + assert.equal(result.wrote, true); + assert.ok( + result.dispatch.rules.some((r) => r.when.includes("Custom captain")), + ); + assert.ok(result.dispatch.rules.length > 1); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("merge writes generated file when existing dispatch is missing", () => { + const home = mkdtempSync(join(tmpdir(), "fck-merge-miss-")); + try { + const result = applyDispatch(pack, { + fmHome: home, + dryRun: false, + merge: true, + }); + assert.equal(result.wrote, true); + assert.equal(result.validation.ok, true); + const parsed = JSON.parse(readFileSync(result.path, "utf8")) as CrewDispatchFile; + assert.ok(Array.isArray(parsed.rules) && parsed.rules.length >= 1); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("merge refuses overwrite when existing file is invalid JSON", () => { + const home = mkdtempSync(join(tmpdir(), "fck-merge-badjson-")); + try { + mkdirSync(join(home, "config"), { recursive: true }); + const path = join(home, "config", "crew-dispatch.json"); + const original = "{ not json"; + writeFileSync(path, original); + const result = applyDispatch(pack, { + fmHome: home, + dryRun: false, + merge: true, + }); + assert.equal(result.wrote, false); + assert.equal(result.validation.ok, false); + assert.match(result.summary, /Merge refused/); + assert.match(result.validation.errors.join(" "), /not valid JSON/); + assert.equal(readFileSync(path, "utf8"), original); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("merge refuses overwrite when existing file has no rules array", () => { + const home = mkdtempSync(join(tmpdir(), "fck-merge-shape-")); + try { + mkdirSync(join(home, "config"), { recursive: true }); + const path = join(home, "config", "crew-dispatch.json"); + const original = JSON.stringify({ default: { harness: "codex" } }); + writeFileSync(path, original); + const result = applyDispatch(pack, { + fmHome: home, + dryRun: false, + merge: true, + }); + assert.equal(result.wrote, false); + assert.equal(result.validation.ok, false); + assert.match(result.summary, /Merge refused/); + assert.match(result.validation.errors.join(" "), /rules must be an array/); + assert.equal(readFileSync(path, "utf8"), original); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); + +describe("mergeDispatch", () => { + it("does not duplicate identical when", () => { + const a: CrewDispatchFile = { + rules: [{ when: "same", use: { harness: "a" } }], + }; + const b: CrewDispatchFile = { + rules: [ + { when: "same", use: { harness: "b" } }, + { when: "other", use: { harness: "c" } }, + ], + default: { harness: "c" }, + }; + const m = mergeDispatch(a, b); + assert.equal(m.rules.length, 2); + const firstUse = m.rules[0]!.use; + assert.ok(!Array.isArray(firstUse)); + assert.equal(firstUse.harness, "a"); + }); +}); diff --git a/tests/fixtures/quota-balanced.json b/tests/fixtures/quota-balanced.json new file mode 100644 index 0000000..abe30c1 --- /dev/null +++ b/tests/fixtures/quota-balanced.json @@ -0,0 +1,96 @@ +{ + "generatedAt": "2026-08-12T12:00:00.000Z", + "schemaVersion": 3, + "providers": [ + { + "provider": "claude", + "label": "Claude", + "source": "oauth-file", + "state": { "status": "fresh", "stale": false }, + "quotaSemantics": { + "status": "known", + "description": "Claude account windows bound every model.", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 12, + "boundedBy": ["weekly"], + "runway": { + "status": "projected_exhaustion", + "usableRunwaySeconds": 7200, + "projectionConfidence": "low" + } + } + ] + } + }, + { + "provider": "codex", + "label": "Codex", + "source": "oauth", + "state": { "status": "fresh", "stale": false }, + "quotaSemantics": { + "status": "known", + "description": "Codex base account windows bound every model.", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 70, + "boundedBy": ["weekly"], + "runway": { + "status": "projected_exhaustion", + "usableRunwaySeconds": 86400, + "projectionConfidence": "medium" + } + } + ] + } + }, + { + "provider": "cursor", + "label": "Cursor", + "source": "api", + "state": { "status": "fresh", "stale": false }, + "windows": [ + { + "id": "included_usage", + "percentRemaining": 40, + "percentUsed": 60 + } + ], + "quotaSemantics": { + "status": "unknown", + "description": "Cursor effective remaining unknown.", + "effectiveAvailability": [] + } + }, + { + "provider": "grok", + "label": "Grok", + "source": "auth-json", + "state": { + "status": "fresh", + "stale": false, + "authStatus": "usable" + }, + "quotaSemantics": { + "status": "known", + "description": "Grok credits as percent.", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 55, + "boundedBy": ["monthly"], + "runway": { + "status": "through_reset", + "usableRunwaySeconds": null + } + } + ] + } + } + ] +} diff --git a/tests/fixtures/quota-claude-exhausted.json b/tests/fixtures/quota-claude-exhausted.json new file mode 100644 index 0000000..e0cba82 --- /dev/null +++ b/tests/fixtures/quota-claude-exhausted.json @@ -0,0 +1,79 @@ +{ + "generatedAt": "2026-08-12T12:00:00.000Z", + "schemaVersion": 3, + "providers": [ + { + "provider": "claude", + "label": "Claude", + "source": "oauth-file", + "state": { "status": "fresh", "stale": false }, + "quotaSemantics": { + "status": "known", + "description": "Claude exhausted.", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 0, + "boundedBy": ["weekly"], + "runway": { + "status": "exhausted_now", + "usableRunwaySeconds": 0 + } + } + ] + } + }, + { + "provider": "codex", + "label": "Codex", + "source": "oauth", + "state": { "status": "fresh", "stale": false }, + "quotaSemantics": { + "status": "known", + "description": "Codex healthy.", + "effectiveAvailability": [ + { + "scope": "all_models", + "status": "known", + "effectivePercentRemaining": 80, + "boundedBy": ["weekly"], + "runway": { + "status": "projected_exhaustion", + "usableRunwaySeconds": 172800 + } + } + ] + } + }, + { + "provider": "grok", + "label": "Grok", + "source": "unavailable", + "state": { + "status": "unavailable", + "stale": false, + "authStatus": "usable", + "error": "Grok consumer quota unavailable" + }, + "quotaSemantics": { + "status": "unknown", + "description": "No windows.", + "effectiveAvailability": [] + } + }, + { + "provider": "cursor", + "label": "Cursor", + "source": "api", + "state": { "status": "fresh", "stale": false }, + "windows": [ + { "id": "included_usage", "percentRemaining": 0, "percentUsed": 100 } + ], + "quotaSemantics": { + "status": "unknown", + "effectiveAvailability": [] + } + } + ] +} diff --git a/tests/fm-home.test.ts b/tests/fm-home.test.ts new file mode 100644 index 0000000..b379baa --- /dev/null +++ b/tests/fm-home.test.ts @@ -0,0 +1,56 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, it } from "node:test"; +import { + crewDispatchPath, + resolveFmHome, + writeUnderFmConfig, +} from "../src/fm-home.ts"; + +describe("resolveFmHome", () => { + it("prefers FM_HOME", () => { + const r = resolveFmHome({ + FM_HOME: "/tmp/fm-home-test", + FM_ROOT_OVERRIDE: "/tmp/other", + }); + assert.equal(r.source, "FM_HOME"); + assert.ok(r.home?.endsWith("fm-home-test")); + }); + + it("falls back to FM_ROOT_OVERRIDE", () => { + const r = resolveFmHome({ FM_ROOT_OVERRIDE: "/tmp/root-only" }); + assert.equal(r.source, "FM_ROOT_OVERRIDE"); + }); + + it("returns null when unset", () => { + const r = resolveFmHome({}); + assert.equal(r.home, null); + }); +}); + +describe("writeUnderFmConfig", () => { + it("writes crew-dispatch.json under config", () => { + const home = mkdtempSync(join(tmpdir(), "fck-home-")); + try { + const path = writeUnderFmConfig(home, "crew-dispatch.json", '{"rules":[]}\n'); + assert.equal(path, crewDispatchPath(home)); + assert.equal(readFileSync(path, "utf8"), '{"rules":[]}\n'); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); + + it("refuses path escape", () => { + const home = mkdtempSync(join(tmpdir(), "fck-esc-")); + try { + assert.throws( + () => writeUnderFmConfig(home, "../secrets.txt", "nope"), + /outside FM_HOME\/config/, + ); + } finally { + rmSync(home, { recursive: true, force: true }); + } + }); +}); diff --git a/tests/quota.test.ts b/tests/quota.test.ts new file mode 100644 index 0000000..b696192 --- /dev/null +++ b/tests/quota.test.ts @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { + pickApplicableAvailability, + readLiveQuota, + summarizeProvider, +} from "../src/quota.ts"; +import type { QuotaProviderSnapshot } from "../src/types.ts"; + +describe("pickApplicableAvailability", () => { + it("prefers all_models over model scope", () => { + const picked = pickApplicableAvailability([ + { + scope: "model:fable", + status: "known", + effectivePercentRemaining: 4, + }, + { + scope: "all_models", + status: "known", + effectivePercentRemaining: 10, + }, + ]); + assert.equal(picked?.scope, "all_models"); + assert.equal(picked?.effectivePercentRemaining, 10); + }); +}); + +describe("summarizeProvider", () => { + it("marks exhausted_now as exhausted", () => { + const snap: QuotaProviderSnapshot = { + provider: "claude", + state: { status: "fresh", stale: false }, + quotaSemantics: { + status: "known", + effectiveAvailability: [ + { + scope: "all_models", + status: "known", + effectivePercentRemaining: 0, + runway: { status: "exhausted_now", usableRunwaySeconds: 0 }, + }, + ], + }, + }; + const v = summarizeProvider(snap); + assert.equal(v.exhausted, true); + assert.equal(v.effectiveRemaining, 0); + }); + + it("leaves unknown headroom null (not zero)", () => { + const snap: QuotaProviderSnapshot = { + provider: "cursor", + state: { status: "fresh", stale: false }, + windows: [{ id: "included_usage", percentRemaining: 25 }], + quotaSemantics: { + status: "unknown", + effectiveAvailability: [], + }, + }; + const v = summarizeProvider(snap); + assert.equal(v.effectiveRemaining, null); + assert.equal(v.exhausted, false); + }); + + it("treats all window remaining 0 as exhausted diagnostic", () => { + const snap: QuotaProviderSnapshot = { + provider: "cursor", + state: { status: "fresh", stale: false }, + windows: [ + { id: "a", percentRemaining: 0 }, + { id: "b", percentRemaining: 0 }, + ], + quotaSemantics: { status: "unknown", effectiveAvailability: [] }, + }; + const v = summarizeProvider(snap); + assert.equal(v.exhausted, true); + }); + + it("ignores stale effective percentages for routing headroom", () => { + const snap: QuotaProviderSnapshot = { + provider: "codex", + state: { status: "fresh", stale: true }, + quotaSemantics: { + status: "known", + effectiveAvailability: [ + { + scope: "all_models", + status: "known", + effectivePercentRemaining: 90, + }, + ], + }, + }; + const v = summarizeProvider(snap); + assert.equal(v.stale, true); + assert.equal(v.effectiveRemaining, null); + }); + + it("does not mark stale zero windows as exhausted", () => { + const snap: QuotaProviderSnapshot = { + provider: "cursor", + state: { status: "fresh", stale: true }, + windows: [ + { id: "a", percentRemaining: 0 }, + { id: "b", percentRemaining: 0 }, + ], + quotaSemantics: { status: "unknown", effectiveAvailability: [] }, + }; + const v = summarizeProvider(snap); + assert.equal(v.stale, true); + assert.equal(v.effectiveRemaining, null); + assert.equal(v.exhausted, false); + }); +}); + +function failExec(code: string, message: string): () => Promise { + return async () => { + const err = new Error(message) as NodeJS.ErrnoException; + err.code = code; + throw err; + }; +} + +describe("readLiveQuota", () => { + it("labels missing binary only when error.code is ENOENT", async () => { + const result = await readLiveQuota({ + exec: failExec("ENOENT", "spawn quota-axi ENOENT"), + }); + assert.equal(result.mode, "degraded"); + assert.match(result.degradedReason ?? "", /quota-axi not found on PATH/); + }); + + it("does not label timeout or permission errors as missing binary", async () => { + const timeout = await readLiveQuota({ + exec: failExec("ETIMEDOUT", "spawnSync quota-axi ETIMEDOUT"), + }); + assert.equal(timeout.mode, "degraded"); + assert.match(timeout.degradedReason ?? "", /quota-axi failed/); + assert.doesNotMatch(timeout.degradedReason ?? "", /not found on PATH/); + + const denied = await readLiveQuota({ + exec: failExec("EACCES", "spawn quota-axi EACCES"), + }); + assert.equal(denied.mode, "degraded"); + assert.match(denied.degradedReason ?? "", /quota-axi failed/); + assert.doesNotMatch(denied.degradedReason ?? "", /not found on PATH/); + }); + + it("rejects when AbortSignal aborts an in-flight quota-axi run", async () => { + const ac = new AbortController(); + const resultPromise = readLiveQuota({ + signal: ac.signal, + exec: (_file, _args, options) => + new Promise((_, reject) => { + const onAbort = () => { + const err = Object.assign(new Error("The operation was aborted"), { + code: "ABORT_ERR", + name: "AbortError", + }); + reject(err); + }; + if (options.signal?.aborted) { + onAbort(); + return; + } + options.signal?.addEventListener("abort", onAbort, { once: true }); + }), + }); + ac.abort(); + await assert.rejects(resultPromise, (err: unknown) => { + assert.ok(isAbortish(err)); + return true; + }); + }); + + it("rejects without spawning when AbortSignal is already aborted", async () => { + const ac = new AbortController(); + ac.abort(); + let spawned = false; + await assert.rejects( + () => + readLiveQuota({ + signal: ac.signal, + exec: async () => { + spawned = true; + return "{}"; + }, + }), + (err: unknown) => { + assert.equal(spawned, false); + assert.ok(isAbortish(err)); + return true; + }, + ); + }); +}); + +function isAbortish(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const code = "code" in err ? (err as { code: unknown }).code : undefined; + const name = "name" in err ? (err as { name: unknown }).name : undefined; + return code === "ABORT_ERR" || name === "AbortError"; +} diff --git a/tests/scorer.test.ts b/tests/scorer.test.ts new file mode 100644 index 0000000..3f93c00 --- /dev/null +++ b/tests/scorer.test.ts @@ -0,0 +1,241 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { loadKnowledge } from "../src/knowledge.ts"; +import { summarizeSnapshot, type QuotaReadResult } from "../src/quota.ts"; +import { + balanceProviders, + buildSpawnFlags, + classifyTask, + rankCandidates, + routeTask, +} from "../src/scorer.ts"; +import type { QuotaSnapshot } from "../src/types.ts"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtures = join(root, "tests", "fixtures"); +const pack = loadKnowledge(root); + +function loadFixture(name: string): QuotaReadResult { + const snapshot = JSON.parse( + readFileSync(join(fixtures, name), "utf8"), + ) as QuotaSnapshot; + return { + mode: "live", + snapshot, + providers: summarizeSnapshot(snapshot), + }; +} + +describe("classifyTask", () => { + it("classifies research by keywords", () => { + const r = classifyTask( + pack, + "Need live web research on current market news and latest news today", + ); + assert.equal(r.taskClass.id, "research_web_live"); + assert.notEqual(r.confidence, "low"); + }); + + it("does not classify ordinary coding as research_web_live", () => { + const currentBug = classifyTask( + pack, + "fix the current bug in the login form", + ); + assert.notEqual(currentBug.taskClass.id, "research_web_live"); + + const latestCommit = classifyTask( + pack, + "update the latest commit message", + ); + assert.notEqual(latestCommit.taskClass.id, "research_web_live"); + + const freshBranch = classifyTask( + pack, + "rebase onto a fresh branch today", + ); + assert.notEqual(freshBranch.taskClass.id, "research_web_live"); + + const productChanges = classifyTask( + pack, + "need product changes in the checkout flow", + ); + assert.notEqual(productChanges.taskClass.id, "research_web_live"); + + const codebaseResearch = classifyTask( + pack, + "research why the login test fails", + ); + assert.notEqual(codebaseResearch.taskClass.id, "research_web_live"); + + const webSearchFeature = classifyTask( + pack, + "add web search to the settings page", + ); + assert.notEqual(webSearchFeature.taskClass.id, "research_web_live"); + + const currentMarketPrice = classifyTask( + pack, + "update the current market price in checkout", + ); + assert.notEqual(currentMarketPrice.taskClass.id, "research_web_live"); + + const asOfTodayStatus = classifyTask( + pack, + "as of today the login is broken", + ); + assert.notEqual(asOfTodayStatus.taskClass.id, "research_web_live"); + }); + + it("still classifies lookup-only research phrases as research_web_live", () => { + const searchTheWeb = classifyTask( + pack, + "please search the web for competitor pricing", + ); + assert.equal(searchTheWeb.taskClass.id, "research_web_live"); + + const marketNews = classifyTask( + pack, + "need current market news on the vendor", + ); + assert.equal(marketNews.taskClass.id, "research_web_live"); + + const newsAsOfToday = classifyTask( + pack, + "what is the news as of today on the outage", + ); + assert.equal(newsAsOfToday.taskClass.id, "research_web_live"); + }); + + it("honors explicit class id", () => { + const r = classifyTask(pack, "anything", "hard_multi_file"); + assert.equal(r.taskClass.id, "hard_multi_file"); + assert.equal(r.confidence, "high"); + }); + + it("falls back to standard_ship", () => { + const r = classifyTask(pack, "xyzzy unrelated blob"); + assert.equal(r.taskClass.id, "standard_ship"); + assert.equal(r.confidence, "low"); + }); + + it("does not false-fire short keywords inside ordinary English", () => { + // "pr" must not match improve/approach/provider/primary; "ship" not relationship/ownership + const r = classifyTask( + pack, + "improve the approach for the provider primary relationship ownership", + ); + assert.equal(r.taskClass.id, "standard_ship"); + assert.equal(r.confidence, "low"); + assert.match(r.notes.join(" "), /Keine Keyword-Treffer/); + }); + + it("still matches real pull-request / ship phrasing", () => { + const pr = classifyTask(pack, "please open a pr for this feature"); + assert.equal(pr.taskClass.id, "standard_ship"); + assert.notEqual(pr.confidence, "low"); + + const ship = classifyTask(pack, "ready to ship it after review"); + assert.equal(ship.taskClass.id, "standard_ship"); + assert.notEqual(ship.confidence, "low"); + }); +}); + +describe("rankCandidates / routeTask with fixtures", () => { + it("prefers codex when claude is low and codex has headroom", () => { + const quota = loadFixture("quota-balanced.json"); + const result = routeTask( + pack, + quota, + "implement a standard feature and open a PR", + "standard_ship", + ); + assert.equal(result.mode, "live"); + assert.equal(result.recommendation.profile.provider, "codex"); + assert.match(result.recommendation.spawnFlags, /--harness codex/); + assert.match(result.recommendation.spawnFlags, /--model /); + assert.match(result.recommendation.spawnFlags, /--effort /); + }); + + it("avoids exhausted claude when codex alternative exists", () => { + const quota = loadFixture("quota-claude-exhausted.json"); + const result = routeTask( + pack, + quota, + "big ambiguous multi-file refactor across architecture", + "hard_multi_file", + ); + assert.notEqual(result.recommendation.profile.provider, "claude"); + assert.equal(result.recommendation.profile.provider, "codex"); + const claudeAlt = [result.recommendation, ...result.alternatives].find( + (c) => c.profile.provider === "claude", + ); + assert.ok(claudeAlt); + assert.ok( + claudeAlt!.rankScore < result.recommendation.rankScore, + "exhausted claude should rank below codex", + ); + }); + + it("keeps unknown-quota providers eligible (not treated as zero)", () => { + const quota = loadFixture("quota-balanced.json"); + const profiles = pack.profileList.filter((p) => + ["standard-codex", "cursor-composer"].includes(p.id), + ); + const providerIndex = new Map( + quota.providers.map((p) => [p.provider, p] as const), + ); + const ranked = rankCandidates(profiles, providerIndex, "medium", "live"); + const cursor = ranked.find((r) => r.profile.provider === "cursor"); + assert.ok(cursor); + assert.equal(cursor!.providerView?.effectiveRemaining, null); + // Should not be crushed to bottom solely for unknown + assert.ok(cursor!.rankScore > 0); + }); + + it("routes research to grok when headroom exists", () => { + const quota = loadFixture("quota-balanced.json"); + const result = routeTask( + pack, + quota, + "research latest news and current product changes", + "research_web_live", + ); + assert.equal(result.recommendation.profile.provider, "grok"); + assert.match(result.recommendation.spawnFlags, /--harness (grok|pi)/); + }); + + it("degraded mode still returns a profile", () => { + const degraded: QuotaReadResult = { + mode: "degraded", + degradedReason: "quota-axi not found on PATH", + providers: [], + }; + const result = routeTask(pack, degraded, "fix a typo", "trivial_fix"); + assert.equal(result.mode, "degraded"); + assert.ok(result.recommendation.spawnFlags.includes("--harness")); + assert.match(result.degradedReason ?? "", /quota-axi/); + }); +}); + +describe("balanceProviders", () => { + it("sorts known headroom above exhausted", () => { + const quota = loadFixture("quota-claude-exhausted.json"); + const bal = balanceProviders(pack, quota); + const ids = bal.providers.map((p) => p.provider); + assert.ok(ids.indexOf("codex") < ids.indexOf("claude")); + }); +}); + +describe("buildSpawnFlags", () => { + it("emits harness model effort", () => { + const p = pack.profiles.get("standard-codex"); + assert.ok(p); + assert.equal( + buildSpawnFlags(p!), + "--harness codex --model gpt-5.5 --effort medium", + ); + }); +}); diff --git a/tests/suggest-primary.test.ts b/tests/suggest-primary.test.ts new file mode 100644 index 0000000..2f2ef7a --- /dev/null +++ b/tests/suggest-primary.test.ts @@ -0,0 +1,233 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { loadKnowledge } from "../src/knowledge.ts"; +import { summarizeSnapshot, type QuotaReadResult } from "../src/quota.ts"; +import { + listAuthenticatedSessionModels, + searchSessionModels, + type SessionModelSource, +} from "../src/session-models.ts"; +import { + modelMatchesProfile, + suggestPrimary, +} from "../src/suggest-primary.ts"; +import type { QuotaSnapshot } from "../src/types.ts"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const fixtures = join(root, "tests", "fixtures"); +const pack = loadKnowledge(root); + +function loadFixture(name: string): QuotaReadResult { + const snapshot = JSON.parse( + readFileSync(join(fixtures, name), "utf8"), + ) as QuotaSnapshot; + return { + mode: "live", + snapshot, + providers: summarizeSnapshot(snapshot), + }; +} + +function sessionWith( + models: Array<{ provider: string; id: string; name?: string }>, + opts: { + current?: { provider: string; id: string; name?: string }; + scoped?: boolean; + authed?: Set; + } = {}, +): SessionModelSource { + const authed = opts.authed; + if (opts.scoped) { + return { + scopedModels: models.map((m) => ({ model: m })), + model: opts.current ?? null, + modelRegistry: { + getAvailable: () => [], + }, + }; + } + return { + model: opts.current ?? null, + modelRegistry: { + getAvailable: () => models, + hasConfiguredAuth: (m) => + authed ? authed.has(`${m.provider}/${m.id}`) : true, + }, + }; +} + +describe("listAuthenticatedSessionModels", () => { + it("prefers scopedModels over catalogue", () => { + const listed = listAuthenticatedSessionModels({ + scopedModels: [ + { model: { provider: "anthropic", id: "claude-sonnet-4", name: "Sonnet" } }, + ], + modelRegistry: { + getAvailable: () => [ + { provider: "openai-codex", id: "gpt-5.5" }, + { provider: "anthropic", id: "claude-sonnet-4" }, + ], + }, + }); + assert.equal(listed.source, "scoped"); + assert.equal(listed.models.length, 1); + assert.equal(listed.models[0].ref, "anthropic/claude-sonnet-4"); + }); + + it("filters getAvailable by hasConfiguredAuth", () => { + const listed = listAuthenticatedSessionModels( + sessionWith( + [ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "xai", id: "grok-4" }, + ], + { authed: new Set(["xai/grok-4"]) }, + ), + ); + assert.equal(listed.source, "available"); + assert.deepEqual( + listed.models.map((m) => m.ref), + ["xai/grok-4"], + ); + }); + + it("returns empty when getAvailable is empty (no getAll fallback)", () => { + const listed = listAuthenticatedSessionModels({ + modelRegistry: { + getAvailable: () => [], + getAll: () => [{ provider: "anthropic", id: "claude-sonnet-4" }], + }, + }); + assert.equal(listed.source, "empty"); + assert.equal(listed.models.length, 0); + }); + + it("returns empty when hasConfiguredAuth filters to zero", () => { + const listed = listAuthenticatedSessionModels( + sessionWith( + [ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "xai", id: "grok-4" }, + ], + { authed: new Set() }, + ), + ); + assert.equal(listed.source, "empty"); + assert.equal(listed.models.length, 0); + }); +}); + +describe("searchSessionModels", () => { + it("filters by provider/id substring", () => { + const all = listAuthenticatedSessionModels( + sessionWith([ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "anthropic", id: "claude-opus-4" }, + { provider: "xai", id: "grok-4" }, + ]), + ).models; + const hits = searchSessionModels(all, "opus"); + assert.equal(hits.length, 1); + assert.equal(hits[0].id, "claude-opus-4"); + }); +}); + +describe("suggestPrimary", () => { + it("only recommends models present in the session", () => { + const quota = loadFixture("quota-balanced.json"); + const session = sessionWith([ + { provider: "openai-codex", id: "gpt-5.5", name: "GPT" }, + { provider: "xai", id: "grok-4" }, + ]); + const result = suggestPrimary(pack, quota, { + goal: "implement a standard feature", + session, + }); + assert.ok(result.recommendation); + assert.ok( + ["openai-codex/gpt-5.5", "xai/grok-4"].includes( + result.recommendation!.model.ref, + ), + ); + assert.equal(result.availableCount, 2); + for (const m of result.matches) { + assert.ok( + ["openai-codex/gpt-5.5", "xai/grok-4"].includes(m.model.ref), + `unexpected model ${m.model.ref}`, + ); + } + }); + + it("does not invent models outside the session catalogue", () => { + const quota = loadFixture("quota-balanced.json"); + const session = sessionWith([{ provider: "xai", id: "grok-4" }]); + const result = suggestPrimary(pack, quota, { + goal: "hard multi-file architecture refactor", + session, + }); + assert.ok(result.recommendation); + assert.equal(result.recommendation!.model.ref, "xai/grok-4"); + assert.ok( + !result.matches.some((m) => m.model.provider === "anthropic"), + "must not surface anthropic when not in session", + ); + }); + + it("returns null recommendation when session has no models", () => { + const quota = loadFixture("quota-balanced.json"); + const result = suggestPrimary(pack, quota, { + goal: "anything", + session: { modelRegistry: { getAvailable: () => [] } }, + }); + assert.equal(result.recommendation, null); + assert.equal(result.availableCount, 0); + assert.match(result.notes.join(" "), /Keine Empfehlung/); + }); + + it("search restricts candidates", () => { + const quota = loadFixture("quota-balanced.json"); + const session = sessionWith([ + { provider: "anthropic", id: "claude-sonnet-4" }, + { provider: "anthropic", id: "claude-opus-4" }, + { provider: "openai-codex", id: "gpt-5.5" }, + ]); + const result = suggestPrimary(pack, quota, { + goal: "standard ship", + search: "opus", + session, + }); + assert.ok(result.recommendation); + assert.equal(result.recommendation!.model.id, "claude-opus-4"); + assert.equal(result.searchHits.length, 1); + }); + + it("matches knowledge profile model ids against session models", () => { + const profile = pack.profiles.get("standard-pi-sonnet"); + assert.ok(profile); + assert.equal( + modelMatchesProfile( + { + provider: "anthropic", + id: "claude-sonnet-4", + ref: "anthropic/claude-sonnet-4", + }, + profile!, + ), + true, + ); + assert.equal( + modelMatchesProfile( + { + provider: "anthropic", + id: "claude-sonnet-4-20250514", + ref: "anthropic/claude-sonnet-4-20250514", + }, + profile!, + ), + true, + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..23aea0b --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "types": ["node"], + "lib": ["ES2022"] + }, + "include": ["index.ts", "src/**/*.ts", "tests/**/*.ts"] +}