pi-openai-toolkit 0.14.5 → 0.14.7

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 +279 -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
@@ -1,133 +1,133 @@
1
- import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
- import {
3
- boundReviewText,
4
- MAX_TRANSCRIPT_CHARS,
5
- MAX_TRANSCRIPT_ENTRY_CHARS,
6
- MAX_TRANSCRIPT_RECENT_ENTRIES,
7
- MAX_TRANSCRIPT_TOOL_CHARS,
8
- TRUNCATION_MARKER,
9
- } from "./types";
10
-
11
- /**
12
- * A transcript line already reduced to text, with the role kept so the reviewer can
13
- * tell whose words these are. `[user]` is the only role that carries authorization.
14
- */
15
- export type TranscriptLine = {
16
- role: "user" | "assistant" | "tool";
17
- text: string;
18
- };
19
-
20
- function textOfContent(content: unknown): string {
21
- if (typeof content === "string") return content;
22
- if (!Array.isArray(content)) return "";
23
- return content
24
- .map((block) => {
25
- if (!block || typeof block !== "object") return "";
26
- const record = block as Record<string, unknown>;
27
- if (record.type === "text" && typeof record.text === "string") return record.text;
28
- if (record.type === "image") return "[image omitted]";
29
- if (record.type === "toolCall" && typeof record.name === "string") {
30
- const args = record.arguments;
31
- return `${record.name} ${typeof args === "object" && args ? JSON.stringify(args) : ""}`.trim();
32
- }
33
- return "";
34
- })
35
- .filter(Boolean)
36
- .join("\n")
37
- .trim();
38
- }
39
-
40
- /**
41
- * Flatten session entries into reviewer lines. Non-message entries are skipped,
42
- * except a compaction summary, which is kept as a `[user]`-adjacent context marker
43
- * because it is the only surviving view of the older conversation.
44
- */
45
- export function sessionEntriesToLines(entries: readonly SessionEntry[]): TranscriptLine[] {
46
- const lines: TranscriptLine[] = [];
47
- for (const entry of entries) {
48
- if (entry.type === "compaction" && typeof entry.summary === "string") {
49
- const summary = boundReviewText(entry.summary, MAX_TRANSCRIPT_ENTRY_CHARS);
50
- if (summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${summary}` });
51
- continue;
52
- }
53
- if (entry.type !== "message") continue;
54
- const message = entry.message as { role?: unknown; content?: unknown };
55
- const text = textOfContent(message.content);
56
- if (!text) continue;
57
- if (message.role === "user") lines.push({ role: "user", text });
58
- else if (message.role === "assistant") lines.push({ role: "assistant", text });
59
- else if (message.role === "toolResult") lines.push({ role: "tool", text });
60
- }
61
- return lines;
62
- }
63
-
64
- function renderLine(index: number, line: TranscriptLine): string {
65
- const body = boundReviewText(line.text, MAX_TRANSCRIPT_ENTRY_CHARS);
66
- return `[${index}] [${line.role}]: ${body}`;
67
- }
68
-
69
- /**
70
- * Build a bounded transcript the way Codex's guardian does: user turns are the
71
- * anchors and are protected, tool and assistant evidence gets its own smaller
72
- * budget so verbose command output cannot crowd out the human conversation that
73
- * actually establishes authorization.
74
- *
75
- * Selection order is newest-first, then rendered in chronological order.
76
- */
77
- export function buildReviewTranscript(
78
- lines: readonly TranscriptLine[],
79
- budgets: {
80
- maxTotalChars?: number;
81
- maxToolChars?: number;
82
- maxRecentEntries?: number;
83
- } = {},
84
- ): { text: string; omitted: boolean } {
85
- const maxTotalChars = budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS;
86
- const maxToolChars = budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS;
87
- const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
88
-
89
- const recent = lines.slice(-Math.max(1, maxRecentEntries));
90
- const omitted = recent.length < lines.length;
91
-
92
- const userLines: number[] = [];
93
- const otherLines: number[] = [];
94
- recent.forEach((line, index) => {
95
- if (line.role === "user") userLines.push(index);
96
- else otherLines.push(index);
97
- });
98
-
99
- const selected = new Set<number>();
100
- let userChars = 0;
101
- // The first user turn carries the task; the latest carries the immediate ask.
102
- for (const index of [userLines[0]!, userLines[userLines.length - 1]!, ...userLines.slice(1, -1).reverse()]) {
103
- if (index === undefined || selected.has(index)) continue;
104
- const cost = recent[index]!.text.length;
105
- if (userChars + cost > maxTotalChars) break;
106
- selected.add(index);
107
- userChars += cost;
108
- }
109
-
110
- let toolChars = 0;
111
- for (const index of [...otherLines].reverse()) {
112
- const cost = recent[index]!.text.length;
113
- if (toolChars + cost > maxToolChars) continue;
114
- if (userChars + toolChars + cost > maxTotalChars) continue;
115
- selected.add(index);
116
- toolChars += cost;
117
- }
118
-
119
- const ordered = [...selected].sort((a, b) => a - b);
120
- const rendered = ordered.map((index) => renderLine(index + 1, recent[index]!));
121
- if (omitted) rendered.unshift(`${TRUNCATION_MARKER} earlier conversation entries were omitted.`);
122
- return { text: rendered.join("\n"), omitted };
123
- }
124
-
125
- /**
126
- * Build the reviewer transcript straight from session entries.
127
- */
128
- export function transcriptFromEntries(
129
- entries: readonly SessionEntry[],
130
- budgets?: Parameters<typeof buildReviewTranscript>[1],
131
- ): { text: string; omitted: boolean } {
132
- return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
133
- }
1
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ boundReviewText,
4
+ MAX_TRANSCRIPT_CHARS,
5
+ MAX_TRANSCRIPT_ENTRY_CHARS,
6
+ MAX_TRANSCRIPT_RECENT_ENTRIES,
7
+ MAX_TRANSCRIPT_TOOL_CHARS,
8
+ TRUNCATION_MARKER,
9
+ } from "./types";
10
+
11
+ /**
12
+ * A transcript line already reduced to text, with the role kept so the reviewer can
13
+ * tell whose words these are. `[user]` is the only role that carries authorization.
14
+ */
15
+ export type TranscriptLine = {
16
+ role: "user" | "assistant" | "tool";
17
+ text: string;
18
+ };
19
+
20
+ function textOfContent(content: unknown): string {
21
+ if (typeof content === "string") return content;
22
+ if (!Array.isArray(content)) return "";
23
+ return content
24
+ .map((block) => {
25
+ if (!block || typeof block !== "object") return "";
26
+ const record = block as Record<string, unknown>;
27
+ if (record.type === "text" && typeof record.text === "string") return record.text;
28
+ if (record.type === "image") return "[image omitted]";
29
+ if (record.type === "toolCall" && typeof record.name === "string") {
30
+ const args = record.arguments;
31
+ return `${record.name} ${typeof args === "object" && args ? JSON.stringify(args) : ""}`.trim();
32
+ }
33
+ return "";
34
+ })
35
+ .filter(Boolean)
36
+ .join("\n")
37
+ .trim();
38
+ }
39
+
40
+ /**
41
+ * Flatten session entries into reviewer lines. Non-message entries are skipped,
42
+ * except a compaction summary, which is kept as a `[user]`-adjacent context marker
43
+ * because it is the only surviving view of the older conversation.
44
+ */
45
+ export function sessionEntriesToLines(entries: readonly SessionEntry[]): TranscriptLine[] {
46
+ const lines: TranscriptLine[] = [];
47
+ for (const entry of entries) {
48
+ if (entry.type === "compaction" && typeof entry.summary === "string") {
49
+ const summary = boundReviewText(entry.summary, MAX_TRANSCRIPT_ENTRY_CHARS);
50
+ if (summary) lines.push({ role: "assistant", text: `[earlier conversation summary] ${summary}` });
51
+ continue;
52
+ }
53
+ if (entry.type !== "message") continue;
54
+ const message = entry.message as { role?: unknown; content?: unknown };
55
+ const text = textOfContent(message.content);
56
+ if (!text) continue;
57
+ if (message.role === "user") lines.push({ role: "user", text });
58
+ else if (message.role === "assistant") lines.push({ role: "assistant", text });
59
+ else if (message.role === "toolResult") lines.push({ role: "tool", text });
60
+ }
61
+ return lines;
62
+ }
63
+
64
+ function renderLine(index: number, line: TranscriptLine): string {
65
+ const body = boundReviewText(line.text, MAX_TRANSCRIPT_ENTRY_CHARS);
66
+ return `[${index}] [${line.role}]: ${body}`;
67
+ }
68
+
69
+ /**
70
+ * Build a bounded transcript the way Codex's guardian does: user turns are the
71
+ * anchors and are protected, tool and assistant evidence gets its own smaller
72
+ * budget so verbose command output cannot crowd out the human conversation that
73
+ * actually establishes authorization.
74
+ *
75
+ * Selection order is newest-first, then rendered in chronological order.
76
+ */
77
+ export function buildReviewTranscript(
78
+ lines: readonly TranscriptLine[],
79
+ budgets: {
80
+ maxTotalChars?: number;
81
+ maxToolChars?: number;
82
+ maxRecentEntries?: number;
83
+ } = {},
84
+ ): { text: string; omitted: boolean } {
85
+ const maxTotalChars = budgets.maxTotalChars ?? MAX_TRANSCRIPT_CHARS;
86
+ const maxToolChars = budgets.maxToolChars ?? MAX_TRANSCRIPT_TOOL_CHARS;
87
+ const maxRecentEntries = budgets.maxRecentEntries ?? MAX_TRANSCRIPT_RECENT_ENTRIES;
88
+
89
+ const recent = lines.slice(-Math.max(1, maxRecentEntries));
90
+ const omitted = recent.length < lines.length;
91
+
92
+ const userLines: number[] = [];
93
+ const otherLines: number[] = [];
94
+ recent.forEach((line, index) => {
95
+ if (line.role === "user") userLines.push(index);
96
+ else otherLines.push(index);
97
+ });
98
+
99
+ const selected = new Set<number>();
100
+ let userChars = 0;
101
+ // The first user turn carries the task; the latest carries the immediate ask.
102
+ for (const index of [userLines[0]!, userLines[userLines.length - 1]!, ...userLines.slice(1, -1).reverse()]) {
103
+ if (index === undefined || selected.has(index)) continue;
104
+ const cost = recent[index]!.text.length;
105
+ if (userChars + cost > maxTotalChars) break;
106
+ selected.add(index);
107
+ userChars += cost;
108
+ }
109
+
110
+ let toolChars = 0;
111
+ for (const index of [...otherLines].reverse()) {
112
+ const cost = recent[index]!.text.length;
113
+ if (toolChars + cost > maxToolChars) continue;
114
+ if (userChars + toolChars + cost > maxTotalChars) continue;
115
+ selected.add(index);
116
+ toolChars += cost;
117
+ }
118
+
119
+ const ordered = [...selected].sort((a, b) => a - b);
120
+ const rendered = ordered.map((index) => renderLine(index + 1, recent[index]!));
121
+ if (omitted) rendered.unshift(`${TRUNCATION_MARKER} earlier conversation entries were omitted.`);
122
+ return { text: rendered.join("\n"), omitted };
123
+ }
124
+
125
+ /**
126
+ * Build the reviewer transcript straight from session entries.
127
+ */
128
+ export function transcriptFromEntries(
129
+ entries: readonly SessionEntry[],
130
+ budgets?: Parameters<typeof buildReviewTranscript>[1],
131
+ ): { text: string; omitted: boolean } {
132
+ return buildReviewTranscript(sessionEntriesToLines(entries), budgets);
133
+ }
@@ -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
+ }