pi-openai-toolkit 0.14.5 → 0.14.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/docs/internals.md +42 -21
  2. package/package.json +1 -1
  3. package/src/auto-mode/classifier.ts +165 -165
  4. package/src/auto-mode/policy.ts +146 -146
  5. package/src/auto-mode/prompt.ts +279 -279
  6. package/src/auto-mode/reviewer.ts +325 -325
  7. package/src/auto-mode/transcript.ts +133 -133
  8. package/src/auto-mode/types.ts +137 -137
  9. package/src/codex-astra/effort-planner.ts +175 -175
  10. package/src/codex-astra/extension.ts +122 -122
  11. package/src/context-management/codex-provider.ts +20 -20
  12. package/src/context-management/history-notes.ts +32 -0
  13. package/src/context-management/messages.ts +112 -106
  14. package/src/context-management/namespace-tools.ts +426 -426
  15. package/src/context-management/tool-contract.ts +23 -23
  16. package/src/context-management/tools.ts +306 -311
  17. package/src/context-management/types.ts +7 -0
  18. package/src/context-management/window-budget.ts +83 -83
  19. package/src/context-management/window-manager.ts +60 -51
  20. package/src/context-management/window-request.ts +125 -125
  21. package/src/debug.ts +1 -1
  22. package/src/extension-runtime.ts +34 -9
  23. package/src/image-generation/artifacts.ts +237 -237
  24. package/src/image-generation/client.ts +248 -248
  25. package/src/image-generation/eligibility.ts +18 -18
  26. package/src/image-generation/references.ts +194 -194
  27. package/src/image-generation/render.ts +252 -252
  28. package/src/model-scope.ts +17 -17
  29. package/src/provider-headers.ts +23 -23
  30. package/src/responses-headers.ts +76 -11
  31. package/src/runtime.ts +37 -1
  32. package/src/web-search/extension.ts +80 -80
  33. package/src/web-search/payload.ts +96 -96
  34. package/src/web-search/prompt.ts +20 -20
  35. package/src/web-search/types.ts +45 -45
package/docs/internals.md CHANGED
@@ -1,8 +1,10 @@
1
1
  # Toolkit Internals
2
2
 
3
- Developer-facing reference for [pi-openai-toolkit](../README.md). This is the protocol and implementation detail that the README deliberately keeps out of the user's way. If you are only installing the toolkit, you never need this page; if you are changing wire behavior, fixtures, or the compaction strategy code, start here.
3
+ Developer-facing reference for [pi-openai-toolkit](../README.md). The README keeps this protocol and implementation detail out of the installation path. If you are changing wire behavior, fixtures, or the compaction strategy code, start here.
4
4
 
5
- ## Table of Contents
5
+ ---
6
+
7
+ ### Table of Contents
6
8
 
7
9
  - [Remote Context protocol](#remote-context-protocol)
8
10
  - [Rollover lifecycle](#rollover-lifecycle)
@@ -10,28 +12,34 @@ Developer-facing reference for [pi-openai-toolkit](../README.md). This is the pr
10
12
  - [Artifacts and debugging](#artifacts-and-debugging)
11
13
  - [Provenance](#provenance)
12
14
 
13
- ## Remote Context protocol
15
+ ---
16
+
17
+ ### Remote Context protocol
14
18
 
15
19
  Internal alpha endpoints, adapted from `@howaboua/pi-codex-conversion`:
16
20
 
17
21
  ```text
18
22
  POST {base}/alpha/history/v2/list_windows | list_items | read_item | search_contents
19
23
  POST {base}/alpha/notes/v2/list_files_by_prefix | read_file | search_contents | append_to_file | write_file
20
- GET {base}/alpha/notes/v2/thread_hint
24
+ POST {base}/alpha/notes/v2/thread_hint
21
25
  ```
22
26
 
23
- Native route uses `{base} = <backend-api>/codex` with `Authorization: Bearer <oauth token>`, `ChatGPT-Account-ID`, and Codex CLI headers. Gateway route uses the configured `/v1` base with the data-plane API key, `originator: codex_cli_rs`, `X-Codex-Affinity-Scope`, and `X-Codex-Model` carrying the bare model id, never local credentials. Both set `Session-Id` / `X-Client-Request-Id` to the bounded Pi session id, `x-openai-tool-output-truncation-policy`, and `x-openai-encrypted-tool-arguments` for endpoints with encrypted parameters; encrypted results come back as `encrypted_output` and are re-encoded into later requests. These are alpha contracts: the upstream backend may change them at any time.
27
+ The native route uses `{base} = <backend-api>/codex` with `Authorization: Bearer <oauth token>`, `ChatGPT-Account-ID`, and Codex CLI headers. The gateway route uses the configured `/v1` base with the API key configured for that provider, `originator: codex_cli_rs`, `X-Codex-Affinity-Scope`, and `X-Codex-Model` carrying the bare model ID. Inherited OAuth headers, cookies, and account IDs are stripped before the request leaves the process. Both routes set `Session-Id` and `X-Client-Request-Id` to the bounded Pi session ID, plus `x-openai-tool-output-truncation-policy` and `x-openai-encrypted-tool-arguments` for endpoints with encrypted parameters. Encrypted results come back as `encrypted_output` and are re-encoded into later requests. These are alpha contracts, so the upstream backend may change them at any time.
24
28
 
25
29
  Search text and note bodies are sent under the encrypted-argument policy. Live model requests carry `x-codex-window-id` and `x-codex-turn-metadata` request headers derived from the active window identity.
26
30
 
27
- Coverage is never inferred from model names. A native session is `provider: "openai-codex"` on api `openai-codex-responses` (any model id). A gateway session must be on api `openai-responses` and carry an exact `provider/model` key listed in `compaction.gatewayContextModels`; the list ships empty and there is no built-in gateway SKU ([src/context-management/codex-provider.ts](../src/context-management/codex-provider.ts)). Unlisted gateway models, listed or not, keep running remote compaction v2 when Remote Context declines them. The toolkit registers no provider or model with Pi; it resolves credentials and base URLs through Pi's `ModelRegistry`.
31
+ Coverage is never inferred from model names. A native session uses provider `openai-codex` with API `openai-codex-responses` for any model ID. A gateway session uses API `openai-responses` and must carry an exact `provider/model` key listed in `compaction.gatewayContextModels`; the list ships empty and there is no built-in gateway SKU. See [src/context-management/codex-provider.ts](../src/context-management/codex-provider.ts). The toolkit registers no provider or model with Pi. It resolves credentials and base URLs through Pi's `ModelRegistry`.
28
32
 
29
- ## Rollover lifecycle
33
+ ---
34
+
35
+ ### Rollover lifecycle
30
36
 
31
37
  The `new_context` happy path, end to end:
32
38
 
33
39
  ```mermaid
34
40
  sequenceDiagram
41
+ accTitle: Remote Context rollover
42
+ accDescr: A Pi model checkpoints notes, requests a local new_context boundary, and sends the next request with the new window identity.
35
43
  participant Model
36
44
  participant Toolkit
37
45
  participant Pi
@@ -41,6 +49,7 @@ sequenceDiagram
41
49
  Backend-->>Toolkit: encrypted_output
42
50
  Model->>Toolkit: new_context
43
51
  Toolkit->>Toolkit: verify successful notes write in this window
52
+ Toolkit-->>Model: checkpoint receipt with the successful note path
44
53
  Toolkit-->>Pi: window marker appended, previous window trim scheduled
45
54
  Model->>Backend: next request with the new x-codex-window-id
46
55
  Pi->>Toolkit: session_before_compact
@@ -49,25 +58,37 @@ sequenceDiagram
49
58
 
50
59
  State invariants the lifecycle code must keep honest:
51
60
 
52
- - The checkpoint gate verifies a *successful* `notes` `append_to_file` / `write_file` against the persisted session branch after the latest window boundary, so it survives restarts and forks.
53
- - Budget checks are skipped until the current window has produced its own assistant usage; acting on the previous window's usage anchor burns the once-per-window reminder on a false alarm.
54
- - The scheduled trim is consumed exactly once, synchronously, by the first compaction attempt whose boundary window id matches. Every other compaction path (threshold without a scheduled rollover, manual `/compact`, overflow) is cancelled.
55
- - A window boundary is persisted as a `codex-context-window` custom message; `session_start` replays boundaries from the branch and rebuilds identity after forks.
61
+ - The checkpoint gate verifies a successful `notes` `append_to_file` or `write_file` against the persisted session branch after the latest window boundary, so it survives restarts and forks. A response with `ok: false` or `success: false` is not successful, even when HTTP returns 200.
62
+ - Every rollover carries the most recent successful checkpoint path in its handoff message. The optional `thread_hint` is supplemental; a hint failure must not erase the local receipt.
63
+ - `new_context` has no force escape hatch: a successful notes checkpoint is always required before rollover.
64
+ - Budget checks are skipped until the current window has produced its own assistant usage. Acting on the previous window's usage anchor would burn the once-per-window reminder on a false alarm.
65
+ - The scheduled trim is consumed exactly once, synchronously, by the first compaction attempt whose boundary window ID matches. Every other compaction path, including a threshold without a scheduled rollover, manual `/compact`, and overflow, is cancelled.
66
+ - A window boundary is persisted as a `codex-context-window` custom message. `session_start` replays boundaries from the branch and rebuilds identity after forks.
67
+
68
+ ---
69
+
70
+ ### Remote Compaction v2 wire contract
71
+
72
+ v2 is what an uncovered session gets. Any model that Remote Context declines, such as a gateway model absent from `gatewayContextModels`, can still compact through v2 when its API appears in `responsesApis`. The two strategies never compete for one session.
73
+
74
+ A `compaction_trigger` item appended to the live streaming request yields one output item of `type: "compaction"` with non-empty `encrypted_content`, stored in `CompactionEntry.details.compactedWindow`. On later requests the opaque checkpoint is replayed ahead of live turns, with no text summary. Replay fails closed: if the summary anchor cannot be located, the request is aborted with a notification and a content-free failure artifact. The sentinel-only payload is never sent.
75
+
76
+ A v2 response with a missing or empty checkpoint is never stored, and the `nativeFallback` tier is skipped. Pi's own threshold drives the next attempt, which may use `remoteCompactModel` when configured. `remoteCompactModel` must resolve to the same effective base URL as the active model.
56
77
 
57
- ## Remote Compaction v2 wire contract
78
+ ---
58
79
 
59
- A `compaction_trigger` item appended to the live streaming request yields one output item of `type: "compaction"` with non-empty `encrypted_content`, stored in `CompactionEntry.details.compactedWindow`. On later requests the opaque checkpoint is replayed ahead of live turns: zero-loss, no text summary. Replay fails closed: if the summary anchor cannot be located, the request is aborted with a notification and a content-free failure artifact; the sentinel-only payload is never sent. A v2 response with a missing or empty checkpoint is never stored, and the `nativeFallback` tier is skipped: Pi's own threshold drives the next attempt, which may use `remoteCompactModel` when configured. `remoteCompactModel` must resolve to the same effective base URL as the active model.
80
+ ### Artifacts and debugging
60
81
 
61
- ## Artifacts and debugging
82
+ With `debug: true`, the toolkit writes lifecycle and compaction artifacts under `artifactRoot` (default `~/.pi/agent/artifacts/pi-openai-toolkit/compaction`):
62
83
 
63
- With `debug: true` the toolkit writes lifecycle and compaction artifacts under `artifactRoot` (default `~/.pi/agent/artifacts/pi-openai-toolkit/compaction`):
84
+ - Each `session_start` writes a lifecycle artifact whose `activation` field records `active` or the exact inactive reason, such as `unsupported-model`, `unsupported-api`, `missing-api-key`, `auth-resolution-failed`, or `tool-name-conflict`.
85
+ - `logProviderPayloads` additionally writes provider request metadata and payloads, while `logCompactResponses` writes compact status and structural summaries with complete response and body fields critically redacted. Keep both off unless inspecting a specific request.
86
+ - Artifacts always redact Authorization credentials, API keys and tokens, Codex account IDs, and opaque `encrypted_content` or `encrypted_output`, even when `redactSensitiveData` is `false`.
64
87
 
65
- - each `session_start` writes a lifecycle artifact whose `activation` field records `active`, or the exact inactive reason (`tool-name-conflict`, `missing-api-key`, `auth-resolution-failed`, `model-not-covered`, ...);
66
- - `logProviderPayloads` additionally writes raw provider request payloads, `logCompactResponses` the compact SSE bodies; keep both off unless inspecting a specific request;
67
- - artifacts always redact Authorization credentials, API keys/tokens, Codex account ids, and opaque `encrypted_content` / `encrypted_output`, even with `redactSensitiveData: false`.
88
+ ---
68
89
 
69
- ## Provenance
90
+ ### Provenance
70
91
 
71
- The Remote Context protocol was reverse-engineered from `@howaboua/pi-codex-conversion@3.0.29` (source commit `7021ae48e8efe36a3becc5830d529696ff798e5e`). The Astra compatibility layer is adapted from Oh My Pi 18.1.8 (MIT). Web Search behavior was adapted from [`pi-openai-web-search`](https://github.com/code-yeongyu/pi-openai-web-search) (commit `3964338`). Attribution details are in [NOTICE](../NOTICE).
92
+ The Remote Context protocol was reverse-engineered from `@howaboua/pi-codex-conversion@3.0.29` (source commit `7021ae48e8efe36a3becc5830d529696ff798e5e`). The Astra compatibility layer is adapted from Oh My Pi 18.1.8 (MIT). Web Search behavior was adapted from [pi-openai-web-search](https://github.com/code-yeongyu/pi-openai-web-search) (commit `3964338`). Attribution details are in [NOTICE](../NOTICE).
72
93
 
73
- Protocol fixtures live in [src/context-management/](../src/context-management/) and must be updated together with any upstream alpha endpoint change; fixture-based tests are required for new wire behavior.
94
+ Protocol fixtures live in [src/context-management/](../src/context-management/) and must be updated together with any upstream alpha endpoint change. New wire behavior requires fixture-based tests.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.14.5",
3
+ "version": "0.14.6",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -1,165 +1,165 @@
1
- import type { Api, Model } from "@earendil-works/pi-ai";
2
- import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
- import { parseModelSpec } from "../runtime";
4
- import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
5
- import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
6
-
7
- /** One completed classification of the trajectory, tagged to a tool-call position. */
8
- export type ScoreRecord = {
9
- risk: ClassifierRisk;
10
- /** Tool-call counter value when the sample was taken. */
11
- scoredAtCall: number;
12
- /** Fingerprint of the user turns the score was based on. */
13
- authorizationVersion: string;
14
- sampledAt: number;
15
- };
16
-
17
- /**
18
- * Mutable per-session tracker for the non-blocking pre-scorer.
19
- *
20
- * The counters reproduce Codex's staleness rule: the agent keeps running while a
21
- * classification is in flight, so a score is only trustworthy if it is recent
22
- * enough and nothing the user said has changed underneath it.
23
- */
24
- export type ScoreTracker = {
25
- latestCallIndex: number;
26
- latestScoredIndex: number;
27
- latestFailedIndex: number;
28
- score?: ScoreRecord;
29
- };
30
-
31
- export function createScoreTracker(): ScoreTracker {
32
- return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
33
- }
34
-
35
- /** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
36
- export function resetScoreTracker(tracker: ScoreTracker): void {
37
- tracker.latestCallIndex = 0;
38
- tracker.latestScoredIndex = 0;
39
- tracker.latestFailedIndex = 0;
40
- tracker.score = undefined;
41
- }
42
-
43
- /**
44
- * Fingerprint of the user-authored conversation. Any new or edited user turn makes
45
- * an existing score stale, because the score's authorization premise changed.
46
- */
47
- export function authorizationVersion(userText: string): string {
48
- const bounded = boundReviewText(userText, 8_000);
49
- let hash = 2166136261;
50
- for (let index = 0; index < bounded.length; index += 1) {
51
- hash ^= bounded.charCodeAt(index);
52
- hash = Math.imul(hash, 16777619);
53
- }
54
- return `${bounded.length}:${(hash >>> 0).toString(36)}`;
55
- }
56
-
57
- export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
58
- tracker.score = score;
59
- tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
60
- }
61
-
62
- export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
63
- tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
64
- }
65
-
66
- export type FastDecisionInput = {
67
- tracker: ScoreTracker;
68
- /** Tool-call counter value for the call being gated. */
69
- currentCallIndex: number;
70
- authorizationVersion: string;
71
- maxLag: number;
72
- /** Retries after a denial and widened gates must not take the fast path. */
73
- requiresSynchronousReview?: boolean;
74
- };
75
-
76
- /**
77
- * Decide whether a cached classification can satisfy this gated call without
78
- * running the blocking reviewer. Anything other than `low_risk` defers to the
79
- * synchronous review; the classifier never denies an action on its own.
80
- */
81
- export function fastApprovalEligible(input: FastDecisionInput): {
82
- eligible: boolean;
83
- reason: FastDecisionReason;
84
- } {
85
- const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
86
-
87
- if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
88
- if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
89
-
90
- const score = input.tracker.score;
91
- if (!score) return defer("missing_score");
92
-
93
- const lag = input.currentCallIndex - score.scoredAtCall;
94
- if (lag > input.maxLag) return defer("stale_score");
95
- if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
96
- if (score.risk === "low") return { eligible: true, reason: "low_risk" };
97
- return defer("elevated_risk");
98
- }
99
-
100
- export type ClassificationResult =
101
- | { kind: ClassifierRisk; model: string }
102
- | { kind: "failed"; reason: string };
103
-
104
- /**
105
- * Run one non-blocking classification. Output is a single token; anything else is a
106
- * failed sample rather than a verdict, so a confused classifier costs a review and
107
- * never an approval or a denial.
108
- */
109
- export async function classifyTrajectory(params: {
110
- registry: Pick<ModelRegistry, "find" | "complete">;
111
- modelSpec: string;
112
- prompt: string;
113
- timeoutMs: number;
114
- signal?: AbortSignal;
115
- }): Promise<ClassificationResult> {
116
- const parsed = parseModelSpec(params.modelSpec);
117
- if (!parsed) {
118
- return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
119
- }
120
- const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
121
- if (!model) {
122
- return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
123
- }
124
- const label = boundReviewText(`${model.provider}/${model.id}`, 256);
125
-
126
- const controller = new AbortController();
127
- const abortFromCaller = () => controller.abort();
128
- if (params.signal?.aborted) controller.abort();
129
- else params.signal?.addEventListener("abort", abortFromCaller);
130
- const timer = setTimeout(() => controller.abort(), params.timeoutMs);
131
-
132
- try {
133
- const response = await params.registry.complete(
134
- model,
135
- {
136
- systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
137
- messages: [
138
- {
139
- role: "user",
140
- content: [{ type: "text", text: params.prompt }],
141
- timestamp: Date.now(),
142
- },
143
- ],
144
- },
145
- { signal: controller.signal, cacheRetention: "none" },
146
- );
147
- if (response.stopReason === "error" || response.stopReason === "aborted") {
148
- return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
149
- }
150
- const text = response.content
151
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
152
- .map((block) => block.text)
153
- .join("")
154
- .trim()
155
- .toLowerCase();
156
- if (/^high\b/.test(text)) return { kind: "high", model: label };
157
- if (/^low\b/.test(text)) return { kind: "low", model: label };
158
- return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
159
- } catch (error) {
160
- return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
161
- } finally {
162
- clearTimeout(timer);
163
- params.signal?.removeEventListener("abort", abortFromCaller);
164
- }
165
- }
1
+ import type { Api, Model } from "@earendil-works/pi-ai";
2
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
3
+ import { parseModelSpec } from "../runtime";
4
+ import { CLASSIFIER_SYSTEM_PROMPT } from "./prompt";
5
+ import { boundReviewText, type ClassifierRisk, type FastDecisionReason } from "./types";
6
+
7
+ /** One completed classification of the trajectory, tagged to a tool-call position. */
8
+ export type ScoreRecord = {
9
+ risk: ClassifierRisk;
10
+ /** Tool-call counter value when the sample was taken. */
11
+ scoredAtCall: number;
12
+ /** Fingerprint of the user turns the score was based on. */
13
+ authorizationVersion: string;
14
+ sampledAt: number;
15
+ };
16
+
17
+ /**
18
+ * Mutable per-session tracker for the non-blocking pre-scorer.
19
+ *
20
+ * The counters reproduce Codex's staleness rule: the agent keeps running while a
21
+ * classification is in flight, so a score is only trustworthy if it is recent
22
+ * enough and nothing the user said has changed underneath it.
23
+ */
24
+ export type ScoreTracker = {
25
+ latestCallIndex: number;
26
+ latestScoredIndex: number;
27
+ latestFailedIndex: number;
28
+ score?: ScoreRecord;
29
+ };
30
+
31
+ export function createScoreTracker(): ScoreTracker {
32
+ return { latestCallIndex: 0, latestScoredIndex: 0, latestFailedIndex: 0 };
33
+ }
34
+
35
+ /** Reset everything: a new turn or a disengaged mode invalidates prior scoring. */
36
+ export function resetScoreTracker(tracker: ScoreTracker): void {
37
+ tracker.latestCallIndex = 0;
38
+ tracker.latestScoredIndex = 0;
39
+ tracker.latestFailedIndex = 0;
40
+ tracker.score = undefined;
41
+ }
42
+
43
+ /**
44
+ * Fingerprint of the user-authored conversation. Any new or edited user turn makes
45
+ * an existing score stale, because the score's authorization premise changed.
46
+ */
47
+ export function authorizationVersion(userText: string): string {
48
+ const bounded = boundReviewText(userText, 8_000);
49
+ let hash = 2166136261;
50
+ for (let index = 0; index < bounded.length; index += 1) {
51
+ hash ^= bounded.charCodeAt(index);
52
+ hash = Math.imul(hash, 16777619);
53
+ }
54
+ return `${bounded.length}:${(hash >>> 0).toString(36)}`;
55
+ }
56
+
57
+ export function recordScoredCall(tracker: ScoreTracker, score: ScoreRecord): void {
58
+ tracker.score = score;
59
+ tracker.latestScoredIndex = Math.max(tracker.latestScoredIndex, score.scoredAtCall);
60
+ }
61
+
62
+ export function recordFailedCall(tracker: ScoreTracker, scoredAtCall: number): void {
63
+ tracker.latestFailedIndex = Math.max(tracker.latestFailedIndex, scoredAtCall);
64
+ }
65
+
66
+ export type FastDecisionInput = {
67
+ tracker: ScoreTracker;
68
+ /** Tool-call counter value for the call being gated. */
69
+ currentCallIndex: number;
70
+ authorizationVersion: string;
71
+ maxLag: number;
72
+ /** Retries after a denial and widened gates must not take the fast path. */
73
+ requiresSynchronousReview?: boolean;
74
+ };
75
+
76
+ /**
77
+ * Decide whether a cached classification can satisfy this gated call without
78
+ * running the blocking reviewer. Anything other than `low_risk` defers to the
79
+ * synchronous review; the classifier never denies an action on its own.
80
+ */
81
+ export function fastApprovalEligible(input: FastDecisionInput): {
82
+ eligible: boolean;
83
+ reason: FastDecisionReason;
84
+ } {
85
+ const defer = (reason: FastDecisionReason) => ({ eligible: false, reason });
86
+
87
+ if (input.requiresSynchronousReview) return defer("requires_synchronous_review");
88
+ if (input.tracker.latestFailedIndex > input.tracker.latestScoredIndex) return defer("scoring_failure");
89
+
90
+ const score = input.tracker.score;
91
+ if (!score) return defer("missing_score");
92
+
93
+ const lag = input.currentCallIndex - score.scoredAtCall;
94
+ if (lag > input.maxLag) return defer("stale_score");
95
+ if (score.authorizationVersion !== input.authorizationVersion) return defer("authorization_changed");
96
+ if (score.risk === "low") return { eligible: true, reason: "low_risk" };
97
+ return defer("elevated_risk");
98
+ }
99
+
100
+ export type ClassificationResult =
101
+ | { kind: ClassifierRisk; model: string }
102
+ | { kind: "failed"; reason: string };
103
+
104
+ /**
105
+ * Run one non-blocking classification. Output is a single token; anything else is a
106
+ * failed sample rather than a verdict, so a confused classifier costs a review and
107
+ * never an approval or a denial.
108
+ */
109
+ export async function classifyTrajectory(params: {
110
+ registry: Pick<ModelRegistry, "find" | "complete">;
111
+ modelSpec: string;
112
+ prompt: string;
113
+ timeoutMs: number;
114
+ signal?: AbortSignal;
115
+ }): Promise<ClassificationResult> {
116
+ const parsed = parseModelSpec(params.modelSpec);
117
+ if (!parsed) {
118
+ return { kind: "failed", reason: `autoMode.classifier.model "${params.modelSpec}" is not provider/model-id.` };
119
+ }
120
+ const model = params.registry.find(parsed.provider, parsed.modelId) as Model<Api> | undefined;
121
+ if (!model) {
122
+ return { kind: "failed", reason: `Classifier model ${params.modelSpec} is not available.` };
123
+ }
124
+ const label = boundReviewText(`${model.provider}/${model.id}`, 256);
125
+
126
+ const controller = new AbortController();
127
+ const abortFromCaller = () => controller.abort();
128
+ if (params.signal?.aborted) controller.abort();
129
+ else params.signal?.addEventListener("abort", abortFromCaller);
130
+ const timer = setTimeout(() => controller.abort(), params.timeoutMs);
131
+
132
+ try {
133
+ const response = await params.registry.complete(
134
+ model,
135
+ {
136
+ systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
137
+ messages: [
138
+ {
139
+ role: "user",
140
+ content: [{ type: "text", text: params.prompt }],
141
+ timestamp: Date.now(),
142
+ },
143
+ ],
144
+ },
145
+ { signal: controller.signal, cacheRetention: "none" },
146
+ );
147
+ if (response.stopReason === "error" || response.stopReason === "aborted") {
148
+ return { kind: "failed", reason: response.errorMessage ?? `Classifier stopped: ${response.stopReason}` };
149
+ }
150
+ const text = response.content
151
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
152
+ .map((block) => block.text)
153
+ .join("")
154
+ .trim()
155
+ .toLowerCase();
156
+ if (/^high\b/.test(text)) return { kind: "high", model: label };
157
+ if (/^low\b/.test(text)) return { kind: "low", model: label };
158
+ return { kind: "failed", reason: `Classifier returned unreadable output: ${boundReviewText(text, 80)}` };
159
+ } catch (error) {
160
+ return { kind: "failed", reason: error instanceof Error ? error.message : String(error) };
161
+ } finally {
162
+ clearTimeout(timer);
163
+ params.signal?.removeEventListener("abort", abortFromCaller);
164
+ }
165
+ }