pi-openai-toolkit 0.14.7 → 0.14.9

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 (47) hide show
  1. package/README.md +37 -8
  2. package/README.zh.md +37 -8
  3. package/docs/internals.md +27 -1
  4. package/extensions/auto-mode.ts +1 -1
  5. package/extensions/codex-astra.ts +1 -1
  6. package/extensions/compaction.ts +1 -1
  7. package/extensions/image-generation.ts +1 -1
  8. package/extensions/web-search.ts +1 -1
  9. package/package.json +6 -2
  10. package/src/auto-mode/classifier.ts +165 -165
  11. package/src/auto-mode/extension.ts +129 -20
  12. package/src/auto-mode/policy.ts +146 -146
  13. package/src/auto-mode/prompt.ts +279 -279
  14. package/src/auto-mode/reviewer.ts +325 -325
  15. package/src/auto-mode/tool-review-tui.ts +211 -0
  16. package/src/auto-mode/transcript.ts +133 -133
  17. package/src/auto-mode/types.ts +137 -137
  18. package/src/codex-astra/effort-planner.ts +175 -175
  19. package/src/codex-astra/extension.ts +122 -122
  20. package/src/compaction-projection.ts +31 -0
  21. package/src/config.ts +100 -1
  22. package/src/context-management/messages.ts +112 -112
  23. package/src/context-management/namespace-tools.ts +426 -426
  24. package/src/context-management/tool-contract.ts +23 -23
  25. package/src/context-management/tools.ts +291 -279
  26. package/src/context-management/window-budget.ts +83 -83
  27. package/src/context-management/window-manager.ts +110 -28
  28. package/src/context-management/window-request.ts +125 -125
  29. package/src/extension-runtime.ts +263 -34
  30. package/src/image-generation/artifacts.ts +237 -237
  31. package/src/image-generation/client.ts +248 -248
  32. package/src/image-generation/eligibility.ts +18 -18
  33. package/src/image-generation/references.ts +194 -194
  34. package/src/image-generation/render.ts +252 -252
  35. package/src/model-scope.ts +17 -17
  36. package/src/payload-rewrite.ts +55 -10
  37. package/src/pi-context-hook.ts +116 -0
  38. package/src/provider-headers.ts +23 -23
  39. package/src/request-context-cache.ts +16 -1
  40. package/src/runtime.ts +52 -0
  41. package/src/types.ts +46 -0
  42. package/src/web-search/alpha-client.ts +875 -0
  43. package/src/web-search/extension.ts +523 -80
  44. package/src/web-search/payload.ts +295 -96
  45. package/src/web-search/prompt.ts +59 -20
  46. package/src/web-search/types.ts +144 -45
  47. package/tsconfig.check.json +2 -0
@@ -1,137 +1,137 @@
1
- import type { AutoModeConfig } from "../types";
2
-
3
- export const AUTO_MODE_ENTRY_TYPE = "pi-openai-toolkit.auto-mode-decision.v1";
4
- export const AUTO_MODE_STATUS_KEY = "pi-openai-toolkit:auto-mode";
5
- export const AUTO_MODE_FLAG = "auto";
6
- export const AUTO_MODE_COMMAND = "auto";
7
-
8
- /**
9
- * Built-in tools that change state outside the conversation. Everything else is
10
- * read-only by default and only reviewed when the user opts in through
11
- * `autoMode.extraTools` or `gate: "all"`.
12
- */
13
- export const SIDE_EFFECT_TOOLS = ["bash", "write", "edit"] as const;
14
-
15
- export const MAX_REVIEW_INPUT_CHARS = 8_000;
16
- export const MAX_REVIEW_INTENT_CHARS = 2_000;
17
- export const MAX_REVIEW_REASON_CHARS = 600;
18
- export const MAX_REVIEW_MODEL_KEY_CHARS = 256;
19
-
20
- /** Budgets for the compact transcript handed to the reviewer. */
21
- export const MAX_TRANSCRIPT_CHARS = 24_000;
22
- export const MAX_TRANSCRIPT_TOOL_CHARS = 12_000;
23
- export const MAX_TRANSCRIPT_ENTRY_CHARS = 2_000;
24
- export const MAX_TRANSCRIPT_RECENT_ENTRIES = 40;
25
- export const TRUNCATION_MARKER = "<truncated />";
26
-
27
- /** Risk of the planned action itself, as scored by the reviewer. */
28
- export type RiskLevel = "low" | "medium" | "high" | "critical";
29
-
30
- /** How far the observed conversation actually authorizes that action. */
31
- export type UserAuthorization = "unknown" | "low" | "medium" | "high";
32
-
33
- /**
34
- * Why a review could not be completed. Kept separate from `deny` on purpose: an
35
- * infrastructure failure must never be reported to the agent as a safety verdict.
36
- */
37
- export type ReviewFailureCause =
38
- | "not-configured"
39
- | "timeout"
40
- | "cancelled"
41
- | "provider-error"
42
- | "invalid-output";
43
-
44
- /**
45
- * The reviewer's full verdict. Only `outcome` is required from the model; the
46
- * remaining fields are back-filled by `parseReviewVerdict` so that a terse
47
- * `{"outcome":"allow"}` answer is still a usable, recordable decision.
48
- */
49
- export type GuardianVerdict = {
50
- outcome: "allow" | "deny";
51
- riskLevel: RiskLevel;
52
- userAuthorization: UserAuthorization;
53
- rationale: string;
54
- };
55
-
56
- /**
57
- * Result of asking the reviewer model about one pending tool call.
58
- * `unavailable` means the review could not be completed at all; it is never
59
- * treated as approval.
60
- */
61
- export type ReviewOutcome =
62
- | {
63
- kind: "allow";
64
- verdict: GuardianVerdict;
65
- reviewerModel: string;
66
- evidenceRounds: number;
67
- }
68
- | {
69
- kind: "deny";
70
- verdict: GuardianVerdict;
71
- reviewerModel: string;
72
- evidenceRounds: number;
73
- }
74
- | { kind: "unavailable"; reason: string; cause: ReviewFailureCause };
75
-
76
- /** Non-blocking pre-score produced by the trajectory classifier. */
77
- export type ClassifierRisk = "low" | "high";
78
-
79
- /**
80
- * Why the classifier's cached score could or could not satisfy a gated call.
81
- * Mirrors the deferral reasons the blocking reviewer exists to cover: anything
82
- * other than `low_risk` falls through to the synchronous review.
83
- */
84
- export type FastDecisionReason =
85
- | "low_risk"
86
- | "elevated_risk"
87
- | "stale_score"
88
- | "scoring_failure"
89
- | "missing_score"
90
- | "authorization_changed"
91
- | "requires_synchronous_review";
92
-
93
- export type AutoModeDecisionSource =
94
- | "reviewer"
95
- | "classifier"
96
- | "human"
97
- | "policy"
98
- | "circuit-breaker";
99
-
100
- export type AutoModeDecisionRecord = {
101
- timestamp: number;
102
- toolName: string;
103
- toolCallId: string;
104
- decision:
105
- | "allow"
106
- | "deny"
107
- | "unavailable-allowed"
108
- | "unavailable-blocked"
109
- | "turn-interrupted";
110
- reason: string;
111
- reviewerModel?: string;
112
- source: AutoModeDecisionSource;
113
- /** Present on reviewer verdicts; omitted for classifier fast paths. */
114
- riskLevel?: RiskLevel;
115
- userAuthorization?: UserAuthorization;
116
- evidenceRounds?: number;
117
- /** Only present when `source` is `classifier`. */
118
- fastDecision?: FastDecisionReason;
119
- };
120
-
121
- export type AutoModeModelRef = {
122
- provider?: string;
123
- id?: string;
124
- };
125
-
126
- export type AutoModeGateSource = AutoModeConfig["gate"];
127
-
128
- /**
129
- * Truncate to a bounded single-line-safe diagnostic. Reviewer reasons and tool
130
- * arguments both originate from untrusted text, so every persisted copy is capped.
131
- */
132
- export function boundReviewText(value: unknown, maxChars: number): string {
133
- const text = typeof value === "string" ? value : String(value ?? "");
134
- const collapsed = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
135
- if (collapsed.length <= maxChars) return collapsed;
136
- return `${collapsed.slice(0, Math.max(0, maxChars - 3))}...`;
137
- }
1
+ import type { AutoModeConfig } from "../types";
2
+
3
+ export const AUTO_MODE_ENTRY_TYPE = "pi-openai-toolkit.auto-mode-decision.v1";
4
+ export const AUTO_MODE_STATUS_KEY = "pi-openai-toolkit:auto-mode";
5
+ export const AUTO_MODE_FLAG = "auto";
6
+ export const AUTO_MODE_COMMAND = "auto";
7
+
8
+ /**
9
+ * Built-in tools that change state outside the conversation. Everything else is
10
+ * read-only by default and only reviewed when the user opts in through
11
+ * `autoMode.extraTools` or `gate: "all"`.
12
+ */
13
+ export const SIDE_EFFECT_TOOLS = ["bash", "write", "edit"] as const;
14
+
15
+ export const MAX_REVIEW_INPUT_CHARS = 8_000;
16
+ export const MAX_REVIEW_INTENT_CHARS = 2_000;
17
+ export const MAX_REVIEW_REASON_CHARS = 600;
18
+ export const MAX_REVIEW_MODEL_KEY_CHARS = 256;
19
+
20
+ /** Budgets for the compact transcript handed to the reviewer. */
21
+ export const MAX_TRANSCRIPT_CHARS = 24_000;
22
+ export const MAX_TRANSCRIPT_TOOL_CHARS = 12_000;
23
+ export const MAX_TRANSCRIPT_ENTRY_CHARS = 2_000;
24
+ export const MAX_TRANSCRIPT_RECENT_ENTRIES = 40;
25
+ export const TRUNCATION_MARKER = "<truncated />";
26
+
27
+ /** Risk of the planned action itself, as scored by the reviewer. */
28
+ export type RiskLevel = "low" | "medium" | "high" | "critical";
29
+
30
+ /** How far the observed conversation actually authorizes that action. */
31
+ export type UserAuthorization = "unknown" | "low" | "medium" | "high";
32
+
33
+ /**
34
+ * Why a review could not be completed. Kept separate from `deny` on purpose: an
35
+ * infrastructure failure must never be reported to the agent as a safety verdict.
36
+ */
37
+ export type ReviewFailureCause =
38
+ | "not-configured"
39
+ | "timeout"
40
+ | "cancelled"
41
+ | "provider-error"
42
+ | "invalid-output";
43
+
44
+ /**
45
+ * The reviewer's full verdict. Only `outcome` is required from the model; the
46
+ * remaining fields are back-filled by `parseReviewVerdict` so that a terse
47
+ * `{"outcome":"allow"}` answer is still a usable, recordable decision.
48
+ */
49
+ export type GuardianVerdict = {
50
+ outcome: "allow" | "deny";
51
+ riskLevel: RiskLevel;
52
+ userAuthorization: UserAuthorization;
53
+ rationale: string;
54
+ };
55
+
56
+ /**
57
+ * Result of asking the reviewer model about one pending tool call.
58
+ * `unavailable` means the review could not be completed at all; it is never
59
+ * treated as approval.
60
+ */
61
+ export type ReviewOutcome =
62
+ | {
63
+ kind: "allow";
64
+ verdict: GuardianVerdict;
65
+ reviewerModel: string;
66
+ evidenceRounds: number;
67
+ }
68
+ | {
69
+ kind: "deny";
70
+ verdict: GuardianVerdict;
71
+ reviewerModel: string;
72
+ evidenceRounds: number;
73
+ }
74
+ | { kind: "unavailable"; reason: string; cause: ReviewFailureCause };
75
+
76
+ /** Non-blocking pre-score produced by the trajectory classifier. */
77
+ export type ClassifierRisk = "low" | "high";
78
+
79
+ /**
80
+ * Why the classifier's cached score could or could not satisfy a gated call.
81
+ * Mirrors the deferral reasons the blocking reviewer exists to cover: anything
82
+ * other than `low_risk` falls through to the synchronous review.
83
+ */
84
+ export type FastDecisionReason =
85
+ | "low_risk"
86
+ | "elevated_risk"
87
+ | "stale_score"
88
+ | "scoring_failure"
89
+ | "missing_score"
90
+ | "authorization_changed"
91
+ | "requires_synchronous_review";
92
+
93
+ export type AutoModeDecisionSource =
94
+ | "reviewer"
95
+ | "classifier"
96
+ | "human"
97
+ | "policy"
98
+ | "circuit-breaker";
99
+
100
+ export type AutoModeDecisionRecord = {
101
+ timestamp: number;
102
+ toolName: string;
103
+ toolCallId: string;
104
+ decision:
105
+ | "allow"
106
+ | "deny"
107
+ | "unavailable-allowed"
108
+ | "unavailable-blocked"
109
+ | "turn-interrupted";
110
+ reason: string;
111
+ reviewerModel?: string;
112
+ source: AutoModeDecisionSource;
113
+ /** Present on reviewer verdicts; omitted for classifier fast paths. */
114
+ riskLevel?: RiskLevel;
115
+ userAuthorization?: UserAuthorization;
116
+ evidenceRounds?: number;
117
+ /** Only present when `source` is `classifier`. */
118
+ fastDecision?: FastDecisionReason;
119
+ };
120
+
121
+ export type AutoModeModelRef = {
122
+ provider?: string;
123
+ id?: string;
124
+ };
125
+
126
+ export type AutoModeGateSource = AutoModeConfig["gate"];
127
+
128
+ /**
129
+ * Truncate to a bounded single-line-safe diagnostic. Reviewer reasons and tool
130
+ * arguments both originate from untrusted text, so every persisted copy is capped.
131
+ */
132
+ export function boundReviewText(value: unknown, maxChars: number): string {
133
+ const text = typeof value === "string" ? value : String(value ?? "");
134
+ const collapsed = text.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, "").trim();
135
+ if (collapsed.length <= maxChars) return collapsed;
136
+ return `${collapsed.slice(0, Math.max(0, maxChars - 3))}...`;
137
+ }
@@ -1,175 +1,175 @@
1
- import { createHash } from "node:crypto";
2
-
3
- /**
4
- * Stable request-level reasoning effort via `configuration_update` input items
5
- * (GPT-6 Astra), ported from Oh My Pi (`@oh-my-pi/pi-ai`, MIT licensed,
6
- * Copyright (c) 2025-2026 Can Bölük / Stencil Labs) to an extension-side
7
- * payload rewrite.
8
- *
9
- * The request-level `reasoning.effort` is pinned to the value of the session's
10
- * first request so the cached prompt prefix survives an effort change. Each
11
- * later change is carried as a `configuration_update` item inserted at the
12
- * tail of the transcript — before the user message it takes effect on, or
13
- * after the latest tool result when the level changes inside a tool loop — and
14
- * replayed at that position on every subsequent request until another update
15
- * overrides it.
16
- *
17
- * Wire constraints (verified upstream against the Codex backend): only
18
- * `gpt-6-astra` accepts the item type, consecutive updates are rejected, and
19
- * `/responses/compact` rejects histories containing them — compaction
20
- * requests are built outside this planner and never carry the items.
21
- */
22
-
23
- /** `configuration_update` input item; only `reasoning.effort` is updatable. */
24
- export type ConfigurationUpdateItem = {
25
- type: "configuration_update";
26
- reasoning: { effort: string };
27
- };
28
-
29
- export type PlannableInputItem = {
30
- type?: string | null;
31
- role?: string;
32
- id?: string | null;
33
- status?: string | null;
34
- [key: string]: unknown;
35
- };
36
-
37
- type EffortTransition = {
38
- /** Input-array position the item is spliced into (before `input[index]`). */
39
- index: number;
40
- /** Fingerprint of `input[index - 1]` at record time; a mismatch means the history was rewritten. */
41
- anchor: string;
42
- effort: string;
43
- };
44
-
45
- /** Per-conversation effort baseline and recorded transitions. */
46
- export type EffortControlState = {
47
- baseEffort?: string;
48
- currentEffort?: string;
49
- transitions: EffortTransition[];
50
- };
51
-
52
- export type EffortPlanResult = {
53
- /** The request-level effort that must accompany the returned input. */
54
- effort: string;
55
- /** Number of `configuration_update` items spliced into this request. */
56
- spliced: number;
57
- };
58
-
59
- export function createEffortControlState(): EffortControlState {
60
- return { transitions: [] };
61
- }
62
-
63
- const MAX_EFFORT_CONTROL_STATES = 16;
64
-
65
- /**
66
- * Fetch (or create) the control state for one conversation from a bounded
67
- * LRU map, refreshing its slot.
68
- */
69
- export function getEffortControlState(states: Map<string, EffortControlState>, key: string): EffortControlState {
70
- const existing = states.get(key);
71
- if (existing) {
72
- states.delete(key);
73
- states.set(key, existing);
74
- return existing;
75
- }
76
- const created = createEffortControlState();
77
- states.set(key, created);
78
- if (states.size > MAX_EFFORT_CONTROL_STATES) {
79
- const oldest = states.keys().next().value;
80
- if (oldest !== undefined) states.delete(oldest);
81
- }
82
- return created;
83
- }
84
-
85
- /**
86
- * Fingerprint of the item a transition sits after. Output-only lifecycle
87
- * fields are excluded: a live response item carries `id`/`status` that the
88
- * sanitized replay of the same item drops.
89
- */
90
- function transitionAnchor(input: readonly PlannableInputItem[], index: number): string {
91
- if (index === 0) return "";
92
- const item = input[index - 1];
93
- if (!item) return "";
94
- const { id: _id, status: _status, ...stable } = item;
95
- return createHash("sha1").update(JSON.stringify(stable)).digest("hex");
96
- }
97
-
98
- function resetEffortControlState(state: EffortControlState): void {
99
- state.baseEffort = undefined;
100
- state.currentEffort = undefined;
101
- state.transitions = [];
102
- }
103
-
104
- /**
105
- * Discard the baseline when the request no longer continues the conversation
106
- * it was captured for: a wire history that shrank or was rewritten under a
107
- * recorded transition (compaction, branch switch, `/clear`). The next request
108
- * re-baselines from its own effort, which is what the API asks for after
109
- * compaction anyway.
110
- */
111
- function syncEffortControlState(state: EffortControlState, input: readonly PlannableInputItem[]): void {
112
- for (const transition of state.transitions) {
113
- if (transition.index > input.length || transition.anchor !== transitionAnchor(input, transition.index)) {
114
- resetEffortControlState(state);
115
- return;
116
- }
117
- }
118
- }
119
-
120
- /**
121
- * Pin the request-level effort to the session baseline and splice pending
122
- * `configuration_update` items into a copy of `input`.
123
- *
124
- * `input` is the freshly built transcript for this request, without any
125
- * `configuration_update` items. `requested` is the wire effort the caller
126
- * would otherwise send at the request level.
127
- */
128
- export function planStableEffort(
129
- state: EffortControlState,
130
- input: readonly PlannableInputItem[],
131
- requested: string,
132
- ): { input: Array<PlannableInputItem | ConfigurationUpdateItem>; result: EffortPlanResult } {
133
- const next: Array<PlannableInputItem | ConfigurationUpdateItem> = [...input];
134
- syncEffortControlState(state, next);
135
- if (state.baseEffort === undefined) {
136
- state.baseEffort = requested;
137
- state.currentEffort = requested;
138
- return { input: next, result: { effort: requested, spliced: 0 } };
139
- }
140
- if (state.currentEffort !== requested) {
141
- const last = next[next.length - 1];
142
- const index = last && last.role === "user" ? next.length - 1 : next.length;
143
- const existing = state.transitions.find((transition) => transition.index === index);
144
- if (existing) {
145
- existing.effort = requested;
146
- } else {
147
- state.transitions.push({ index, anchor: transitionAnchor(next, index), effort: requested });
148
- }
149
- // A change back to the effort already in force at that position is a
150
- // no-op on the wire; drop it rather than send a redundant item.
151
- let preceding = state.baseEffort;
152
- let precedingIndex = -1;
153
- for (const transition of state.transitions) {
154
- if (transition.index < index && transition.index > precedingIndex) {
155
- preceding = transition.effort;
156
- precedingIndex = transition.index;
157
- }
158
- }
159
- if (requested === preceding) {
160
- state.transitions = state.transitions.filter((transition) => transition.index !== index);
161
- }
162
- state.currentEffort = requested;
163
- }
164
- // Splice in ascending order so each insertion offsets only the ones after it.
165
- state.transitions.sort((a, b) => a.index - b.index);
166
- let offset = 0;
167
- for (const transition of state.transitions) {
168
- next.splice(transition.index + offset, 0, {
169
- type: "configuration_update",
170
- reasoning: { effort: transition.effort },
171
- });
172
- offset++;
173
- }
174
- return { input: next, result: { effort: state.baseEffort, spliced: state.transitions.length } };
175
- }
1
+ import { createHash } from "node:crypto";
2
+
3
+ /**
4
+ * Stable request-level reasoning effort via `configuration_update` input items
5
+ * (GPT-6 Astra), ported from Oh My Pi (`@oh-my-pi/pi-ai`, MIT licensed,
6
+ * Copyright (c) 2025-2026 Can Bölük / Stencil Labs) to an extension-side
7
+ * payload rewrite.
8
+ *
9
+ * The request-level `reasoning.effort` is pinned to the value of the session's
10
+ * first request so the cached prompt prefix survives an effort change. Each
11
+ * later change is carried as a `configuration_update` item inserted at the
12
+ * tail of the transcript — before the user message it takes effect on, or
13
+ * after the latest tool result when the level changes inside a tool loop — and
14
+ * replayed at that position on every subsequent request until another update
15
+ * overrides it.
16
+ *
17
+ * Wire constraints (verified upstream against the Codex backend): only
18
+ * `gpt-6-astra` accepts the item type, consecutive updates are rejected, and
19
+ * `/responses/compact` rejects histories containing them — compaction
20
+ * requests are built outside this planner and never carry the items.
21
+ */
22
+
23
+ /** `configuration_update` input item; only `reasoning.effort` is updatable. */
24
+ export type ConfigurationUpdateItem = {
25
+ type: "configuration_update";
26
+ reasoning: { effort: string };
27
+ };
28
+
29
+ export type PlannableInputItem = {
30
+ type?: string | null;
31
+ role?: string;
32
+ id?: string | null;
33
+ status?: string | null;
34
+ [key: string]: unknown;
35
+ };
36
+
37
+ type EffortTransition = {
38
+ /** Input-array position the item is spliced into (before `input[index]`). */
39
+ index: number;
40
+ /** Fingerprint of `input[index - 1]` at record time; a mismatch means the history was rewritten. */
41
+ anchor: string;
42
+ effort: string;
43
+ };
44
+
45
+ /** Per-conversation effort baseline and recorded transitions. */
46
+ export type EffortControlState = {
47
+ baseEffort?: string;
48
+ currentEffort?: string;
49
+ transitions: EffortTransition[];
50
+ };
51
+
52
+ export type EffortPlanResult = {
53
+ /** The request-level effort that must accompany the returned input. */
54
+ effort: string;
55
+ /** Number of `configuration_update` items spliced into this request. */
56
+ spliced: number;
57
+ };
58
+
59
+ export function createEffortControlState(): EffortControlState {
60
+ return { transitions: [] };
61
+ }
62
+
63
+ const MAX_EFFORT_CONTROL_STATES = 16;
64
+
65
+ /**
66
+ * Fetch (or create) the control state for one conversation from a bounded
67
+ * LRU map, refreshing its slot.
68
+ */
69
+ export function getEffortControlState(states: Map<string, EffortControlState>, key: string): EffortControlState {
70
+ const existing = states.get(key);
71
+ if (existing) {
72
+ states.delete(key);
73
+ states.set(key, existing);
74
+ return existing;
75
+ }
76
+ const created = createEffortControlState();
77
+ states.set(key, created);
78
+ if (states.size > MAX_EFFORT_CONTROL_STATES) {
79
+ const oldest = states.keys().next().value;
80
+ if (oldest !== undefined) states.delete(oldest);
81
+ }
82
+ return created;
83
+ }
84
+
85
+ /**
86
+ * Fingerprint of the item a transition sits after. Output-only lifecycle
87
+ * fields are excluded: a live response item carries `id`/`status` that the
88
+ * sanitized replay of the same item drops.
89
+ */
90
+ function transitionAnchor(input: readonly PlannableInputItem[], index: number): string {
91
+ if (index === 0) return "";
92
+ const item = input[index - 1];
93
+ if (!item) return "";
94
+ const { id: _id, status: _status, ...stable } = item;
95
+ return createHash("sha1").update(JSON.stringify(stable)).digest("hex");
96
+ }
97
+
98
+ function resetEffortControlState(state: EffortControlState): void {
99
+ state.baseEffort = undefined;
100
+ state.currentEffort = undefined;
101
+ state.transitions = [];
102
+ }
103
+
104
+ /**
105
+ * Discard the baseline when the request no longer continues the conversation
106
+ * it was captured for: a wire history that shrank or was rewritten under a
107
+ * recorded transition (compaction, branch switch, `/clear`). The next request
108
+ * re-baselines from its own effort, which is what the API asks for after
109
+ * compaction anyway.
110
+ */
111
+ function syncEffortControlState(state: EffortControlState, input: readonly PlannableInputItem[]): void {
112
+ for (const transition of state.transitions) {
113
+ if (transition.index > input.length || transition.anchor !== transitionAnchor(input, transition.index)) {
114
+ resetEffortControlState(state);
115
+ return;
116
+ }
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Pin the request-level effort to the session baseline and splice pending
122
+ * `configuration_update` items into a copy of `input`.
123
+ *
124
+ * `input` is the freshly built transcript for this request, without any
125
+ * `configuration_update` items. `requested` is the wire effort the caller
126
+ * would otherwise send at the request level.
127
+ */
128
+ export function planStableEffort(
129
+ state: EffortControlState,
130
+ input: readonly PlannableInputItem[],
131
+ requested: string,
132
+ ): { input: Array<PlannableInputItem | ConfigurationUpdateItem>; result: EffortPlanResult } {
133
+ const next: Array<PlannableInputItem | ConfigurationUpdateItem> = [...input];
134
+ syncEffortControlState(state, next);
135
+ if (state.baseEffort === undefined) {
136
+ state.baseEffort = requested;
137
+ state.currentEffort = requested;
138
+ return { input: next, result: { effort: requested, spliced: 0 } };
139
+ }
140
+ if (state.currentEffort !== requested) {
141
+ const last = next[next.length - 1];
142
+ const index = last && last.role === "user" ? next.length - 1 : next.length;
143
+ const existing = state.transitions.find((transition) => transition.index === index);
144
+ if (existing) {
145
+ existing.effort = requested;
146
+ } else {
147
+ state.transitions.push({ index, anchor: transitionAnchor(next, index), effort: requested });
148
+ }
149
+ // A change back to the effort already in force at that position is a
150
+ // no-op on the wire; drop it rather than send a redundant item.
151
+ let preceding = state.baseEffort;
152
+ let precedingIndex = -1;
153
+ for (const transition of state.transitions) {
154
+ if (transition.index < index && transition.index > precedingIndex) {
155
+ preceding = transition.effort;
156
+ precedingIndex = transition.index;
157
+ }
158
+ }
159
+ if (requested === preceding) {
160
+ state.transitions = state.transitions.filter((transition) => transition.index !== index);
161
+ }
162
+ state.currentEffort = requested;
163
+ }
164
+ // Splice in ascending order so each insertion offsets only the ones after it.
165
+ state.transitions.sort((a, b) => a.index - b.index);
166
+ let offset = 0;
167
+ for (const transition of state.transitions) {
168
+ next.splice(transition.index + offset, 0, {
169
+ type: "configuration_update",
170
+ reasoning: { effort: transition.effort },
171
+ });
172
+ offset++;
173
+ }
174
+ return { input: next, result: { effort: state.baseEffort, spliced: state.transitions.length } };
175
+ }