@oh-my-pi/pi-coding-agent 17.2.11 → 17.2.12

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 (152) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/{CHANGELOG-d9xenpn9.md → CHANGELOG-k9ghy5sn.md} +13 -0
  3. package/dist/cli.js +10795 -10791
  4. package/dist/types/advisor/runtime.d.ts +1 -1
  5. package/dist/types/config/custom-models.d.ts +31 -0
  6. package/dist/types/config/model-config-values.d.ts +19 -0
  7. package/dist/types/config/model-patch.d.ts +93 -0
  8. package/dist/types/config/model-provider-discovery.d.ts +51 -0
  9. package/dist/types/config/model-registry.d.ts +5 -52
  10. package/dist/types/config/settings.d.ts +5 -3
  11. package/dist/types/debug/raw-sse-buffer.d.ts +9 -0
  12. package/dist/types/discovery/agent-plugin-format.d.ts +6 -13
  13. package/dist/types/discovery/contained-path.d.ts +2 -3
  14. package/dist/types/eval/executor-base.d.ts +8 -2
  15. package/dist/types/eval/kernel-session-registry.d.ts +60 -0
  16. package/dist/types/export/share.d.ts +1 -1
  17. package/dist/types/lsp/diagnostics.d.ts +96 -0
  18. package/dist/types/lsp/index.d.ts +7 -128
  19. package/dist/types/lsp/servers.d.ts +72 -0
  20. package/dist/types/lsp/tool.d.ts +42 -0
  21. package/dist/types/lsp/workspace-diagnostics.d.ts +12 -0
  22. package/dist/types/lsp/writethrough.d.ts +33 -0
  23. package/dist/types/mcp/transports/header-policy.d.ts +2 -1
  24. package/dist/types/modes/components/agent-hub-projection.d.ts +2 -7
  25. package/dist/types/modes/components/agent-hub-renderer.d.ts +0 -7
  26. package/dist/types/modes/theme/color.d.ts +19 -0
  27. package/dist/types/modes/theme/loader.d.ts +19 -0
  28. package/dist/types/modes/theme/schema.d.ts +175 -0
  29. package/dist/types/modes/theme/symbols.d.ts +28 -0
  30. package/dist/types/modes/theme/theme-class.d.ts +244 -0
  31. package/dist/types/modes/theme/theme.d.ts +9 -280
  32. package/dist/types/modes/theme/tui-adapters.d.ts +13 -0
  33. package/dist/types/registry/agent-registry.d.ts +1 -3
  34. package/dist/types/secrets/index.d.ts +3 -1
  35. package/dist/types/secrets/message-transform.d.ts +40 -0
  36. package/dist/types/secrets/obfuscator.d.ts +0 -108
  37. package/dist/types/secrets/placeholder-scan.d.ts +95 -0
  38. package/dist/types/secrets/placeholder.d.ts +94 -0
  39. package/dist/types/secrets/replacement.d.ts +82 -0
  40. package/dist/types/session/agent-session-types.d.ts +6 -0
  41. package/dist/types/session/agent-session.d.ts +1 -1
  42. package/dist/types/session/session-handoff.d.ts +3 -3
  43. package/dist/types/session/session-manager.d.ts +32 -0
  44. package/dist/types/session/session-provider-boundary.d.ts +1 -1
  45. package/dist/types/session/turn-recovery.d.ts +2 -2
  46. package/dist/types/slash-commands/builtin-collaboration.d.ts +2 -0
  47. package/dist/types/slash-commands/builtin-completions.d.ts +36 -0
  48. package/dist/types/slash-commands/builtin-control.d.ts +2 -0
  49. package/dist/types/slash-commands/builtin-lifecycle.d.ts +3 -0
  50. package/dist/types/slash-commands/builtin-marketplace.d.ts +11 -0
  51. package/dist/types/slash-commands/builtin-modes.d.ts +5 -0
  52. package/dist/types/slash-commands/builtin-registry.d.ts +1 -1
  53. package/dist/types/slash-commands/builtin-session.d.ts +2 -0
  54. package/dist/types/tools/gh-common.d.ts +69 -0
  55. package/dist/types/tools/gh-pr-checkout.d.ts +84 -0
  56. package/dist/types/tools/gh-pr-diff.d.ts +102 -0
  57. package/dist/types/tools/gh-run-watch.d.ts +78 -0
  58. package/dist/types/tools/gh-search.d.ts +61 -0
  59. package/dist/types/tools/gh-types.d.ts +340 -0
  60. package/dist/types/tools/gh-view.d.ts +88 -0
  61. package/dist/types/tools/gh.d.ts +4 -201
  62. package/dist/types/tools/read-archive.d.ts +16 -0
  63. package/dist/types/tools/read-format.d.ts +104 -0
  64. package/dist/types/tools/read-path-resolution.d.ts +19 -0
  65. package/dist/types/tools/read-pdf-images.d.ts +12 -0
  66. package/dist/types/tools/read-renderer.d.ts +24 -0
  67. package/dist/types/tools/read-selector.d.ts +27 -0
  68. package/dist/types/tools/read-sqlite.d.ts +16 -0
  69. package/dist/types/tools/read-summary.d.ts +19 -0
  70. package/dist/types/tools/read.d.ts +1 -23
  71. package/dist/types/tools/shell-tokenize.d.ts +2 -1
  72. package/dist/types/utils/changelog.d.ts +0 -5
  73. package/package.json +13 -13
  74. package/src/advisor/runtime.ts +12 -7
  75. package/src/config/custom-models.ts +188 -0
  76. package/src/config/model-config-values.ts +128 -0
  77. package/src/config/model-patch.ts +252 -0
  78. package/src/config/model-provider-discovery.ts +132 -0
  79. package/src/config/model-registry.ts +64 -704
  80. package/src/config/settings.ts +7 -3
  81. package/src/debug/raw-sse-buffer.ts +20 -0
  82. package/src/discovery/agent-plugin-format.ts +7 -7
  83. package/src/discovery/contained-path.ts +2 -5
  84. package/src/edit/hashline/diff.ts +5 -1
  85. package/src/eval/executor-base.ts +39 -6
  86. package/src/eval/jl/executor.ts +82 -371
  87. package/src/eval/kernel-session-registry.ts +398 -0
  88. package/src/eval/py/executor.ts +53 -261
  89. package/src/eval/rb/executor.ts +36 -279
  90. package/src/export/share.ts +2 -1
  91. package/src/lsp/diagnostics.ts +516 -0
  92. package/src/lsp/index.ts +20 -2819
  93. package/src/lsp/servers.ts +296 -0
  94. package/src/lsp/tool.ts +1352 -0
  95. package/src/lsp/workspace-diagnostics.ts +170 -0
  96. package/src/lsp/writethrough.ts +561 -0
  97. package/src/mcp/transports/header-policy.ts +1 -1
  98. package/src/modes/components/agent-hub-projection.ts +2 -2
  99. package/src/modes/components/agent-hub-renderer.ts +3 -7
  100. package/src/modes/controllers/command-controller.ts +4 -1
  101. package/src/modes/theme/color.ts +133 -0
  102. package/src/modes/theme/loader.ts +178 -0
  103. package/src/modes/theme/schema.ts +263 -0
  104. package/src/modes/theme/symbols.ts +1000 -0
  105. package/src/modes/theme/theme-class.ts +611 -0
  106. package/src/modes/theme/theme.ts +27 -2453
  107. package/src/modes/theme/tui-adapters.ts +279 -0
  108. package/src/registry/agent-registry.ts +2 -2
  109. package/src/secrets/index.ts +6 -12
  110. package/src/secrets/message-transform.ts +287 -0
  111. package/src/secrets/obfuscator.ts +39 -1303
  112. package/src/secrets/placeholder-scan.ts +506 -0
  113. package/src/secrets/placeholder.ts +309 -0
  114. package/src/secrets/replacement.ts +216 -0
  115. package/src/session/agent-session-types.ts +6 -0
  116. package/src/session/agent-session.ts +113 -7
  117. package/src/session/indexed-session-storage.ts +7 -2
  118. package/src/session/session-advisors.ts +32 -34
  119. package/src/session/session-handoff.ts +39 -20
  120. package/src/session/session-manager.ts +67 -3
  121. package/src/session/session-provider-boundary.ts +3 -6
  122. package/src/session/turn-recovery.ts +21 -12
  123. package/src/slash-commands/builtin-collaboration.ts +504 -0
  124. package/src/slash-commands/builtin-completions.ts +291 -0
  125. package/src/slash-commands/builtin-control.ts +78 -0
  126. package/src/slash-commands/builtin-lifecycle.ts +506 -0
  127. package/src/slash-commands/builtin-marketplace.ts +567 -0
  128. package/src/slash-commands/builtin-modes.ts +518 -0
  129. package/src/slash-commands/builtin-registry.ts +21 -3000
  130. package/src/slash-commands/builtin-session.ts +592 -0
  131. package/src/task/executor.ts +17 -14
  132. package/src/tools/gh-common.ts +288 -0
  133. package/src/tools/gh-pr-checkout.ts +679 -0
  134. package/src/tools/gh-pr-diff.ts +473 -0
  135. package/src/tools/gh-run-watch.ts +1015 -0
  136. package/src/tools/gh-search.ts +500 -0
  137. package/src/tools/gh-types.ts +379 -0
  138. package/src/tools/gh-view.ts +612 -0
  139. package/src/tools/gh.ts +109 -3821
  140. package/src/tools/read-archive.ts +209 -0
  141. package/src/tools/read-format.ts +593 -0
  142. package/src/tools/read-path-resolution.ts +36 -0
  143. package/src/tools/read-pdf-images.ts +250 -0
  144. package/src/tools/read-renderer.ts +289 -0
  145. package/src/tools/read-selector.ts +84 -0
  146. package/src/tools/read-sqlite.ts +215 -0
  147. package/src/tools/read-summary.ts +199 -0
  148. package/src/tools/read.ts +77 -1820
  149. package/src/tools/shell-tokenize.ts +1 -1
  150. package/src/utils/changelog.ts +1 -1
  151. package/src/utils/title-generator.ts +3 -1
  152. package/src/web/search/providers/zai.ts +12 -3
@@ -0,0 +1,506 @@
1
+ import type { JsonRecord, JsonValue, SecretObfuscator } from "./obfuscator";
2
+ import {
3
+ lookupFriendlyPlaceholderAlias,
4
+ PLACEHOLDER_RE,
5
+ type RegexScanSegment,
6
+ type ReplaceRegexScan,
7
+ resumePlaceholderScanAfterRejectedCandidate,
8
+ } from "./placeholder";
9
+
10
+ // ═══════════════════════════════════════════════════════════════════════════
11
+ // Helpers
12
+ // ═══════════════════════════════════════════════════════════════════════════
13
+
14
+ // Like the untracked walk, but threads a parallel `origin` tag string through:
15
+ // preserved placeholder spans keep their existing origin tag (so a
16
+ // same-call-fresh "F" placeholder is never relabeled prior-call "I", and vice
17
+ // versa), while `transform`'s output — always freshly generated or redacted
18
+ // content in both callers below — is tagged "I" (it must not be re-matched as
19
+ // though it arrived in the input, mirroring plain-secret replacement tagging).
20
+ export function transformOutsidePlaceholdersTracked(
21
+ text: string,
22
+ origin: string,
23
+ shouldSkipPlaceholder: (placeholder: string) => boolean,
24
+ transform: (chunk: string) => string,
25
+ preservePlaceholder?: (placeholder: string) => string,
26
+ ): { text: string; origin: string } {
27
+ PLACEHOLDER_RE.lastIndex = 0;
28
+ let result = "";
29
+ let resultOrigin = "";
30
+ let pendingIndex = 0;
31
+ for (;;) {
32
+ const match = PLACEHOLDER_RE.exec(text);
33
+ if (match === null) break;
34
+ if (!shouldSkipPlaceholder(match[0])) {
35
+ resumePlaceholderScanAfterRejectedCandidate(match);
36
+ continue;
37
+ }
38
+ const transformed = transform(text.slice(pendingIndex, match.index));
39
+ result += transformed;
40
+ resultOrigin += "I".repeat(transformed.length);
41
+ const preserved = preservePlaceholder ? preservePlaceholder(match[0]) : match[0];
42
+ result += preserved;
43
+ resultOrigin += origin.slice(match.index, match.index + match[0].length);
44
+ pendingIndex = match.index + match[0].length;
45
+ }
46
+ const trailing = transform(text.slice(pendingIndex));
47
+ result += trailing;
48
+ resultOrigin += "I".repeat(trailing.length);
49
+ return { text: result, origin: resultOrigin };
50
+ }
51
+
52
+ export function trailingOutsidePreservedPlaceholderChunk(
53
+ text: string,
54
+ shouldPreservePlaceholder: (placeholder: string) => boolean,
55
+ ): string {
56
+ PLACEHOLDER_RE.lastIndex = 0;
57
+ let pendingIndex = 0;
58
+ let sawPlaceholder = false;
59
+ for (;;) {
60
+ const match = PLACEHOLDER_RE.exec(text);
61
+ if (match === null) break;
62
+ if (!shouldPreservePlaceholder(match[0])) {
63
+ resumePlaceholderScanAfterRejectedCandidate(match);
64
+ continue;
65
+ }
66
+ sawPlaceholder = true;
67
+ pendingIndex = match.index + match[0].length;
68
+ }
69
+ return sawPlaceholder ? text.slice(pendingIndex) : "";
70
+ }
71
+
72
+ export function buildReplaceRegexScan(
73
+ text: string,
74
+ ranges: ReadonlyArray<{ start: number; end: number }>,
75
+ deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
76
+ ): ReplaceRegexScan {
77
+ let scanText = "";
78
+ let cursor = 0;
79
+ const segments: RegexScanSegment[] = [];
80
+ const appendSegment = (
81
+ value: string,
82
+ textStart: number,
83
+ textEnd: number,
84
+ generatedPlaceholder: boolean,
85
+ recursive: boolean,
86
+ ) => {
87
+ if (value.length === 0) return;
88
+ const scanStart = scanText.length;
89
+ scanText += value;
90
+ segments.push({
91
+ scanStart,
92
+ scanEnd: scanStart + value.length,
93
+ textStart,
94
+ textEnd,
95
+ generatedPlaceholder,
96
+ recursive,
97
+ });
98
+ };
99
+
100
+ for (const range of ranges) {
101
+ appendSegment(text.slice(cursor, range.start), cursor, range.start, false, false);
102
+ const placeholder = text.slice(range.start, range.end);
103
+ const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
104
+ appendSegment(mapping?.secret ?? placeholder, range.start, range.end, true, mapping?.recursive ?? false);
105
+ cursor = range.end;
106
+ }
107
+ appendSegment(text.slice(cursor), cursor, text.length, false, false);
108
+
109
+ return { text: scanText, segments };
110
+ }
111
+
112
+ export function mapReplaceRegexMatch(
113
+ segments: ReadonlyArray<RegexScanSegment>,
114
+ scanStart: number,
115
+ scanEnd: number,
116
+ ): {
117
+ start: number;
118
+ end: number;
119
+ recursive: boolean;
120
+ preserveGeneratedPlaceholders: boolean;
121
+ partialPlaceholderCut: boolean;
122
+ cutResumeIndex: number;
123
+ firstPlaceholderScanStart: number;
124
+ } {
125
+ const startSegment = findScanSegment(segments, scanStart);
126
+ const endSegment = findScanSegment(segments, scanEnd - 1);
127
+ const start = startSegment.generatedPlaceholder
128
+ ? startSegment.textStart
129
+ : startSegment.textStart + (scanStart - startSegment.scanStart);
130
+ const end = endSegment.generatedPlaceholder
131
+ ? endSegment.textEnd
132
+ : endSegment.textStart + (scanEnd - endSegment.scanStart);
133
+ // A match boundary that falls strictly inside a generated placeholder's
134
+ // expanded value cuts the underlying secret: the snap above pulls the span out
135
+ // to the whole `#…#` token, so the obfuscate path can leave it alone instead of
136
+ // consuming a partial placeholder expansion.
137
+ const partialPlaceholderCut =
138
+ (startSegment.generatedPlaceholder && scanStart > startSegment.scanStart) ||
139
+ (endSegment.generatedPlaceholder && scanEnd < endSegment.scanEnd);
140
+ let recursive = false;
141
+ let preserveGeneratedPlaceholders = false;
142
+ // When the match straddles a placeholder, resume scanning just past the last
143
+ // overlapping placeholder so trailing wholly-outside content (e.g. an 8-char
144
+ // run after the secret) still gets matched instead of being consumed by the
145
+ // straddling span. `firstPlaceholderScanStart` marks where the leading
146
+ // wholly-outside prefix ends, so a prefix that independently matches can be
147
+ // redacted on its own rather than skipped along with the cut span.
148
+ let cutResumeIndex = scanStart;
149
+ let firstPlaceholderScanStart = -1;
150
+ for (const segment of segments) {
151
+ if (segment.scanStart >= scanEnd || segment.scanEnd <= scanStart) continue;
152
+ recursive ||= segment.recursive;
153
+ preserveGeneratedPlaceholders ||= segment.generatedPlaceholder;
154
+ if (segment.generatedPlaceholder) {
155
+ if (firstPlaceholderScanStart === -1) firstPlaceholderScanStart = segment.scanStart;
156
+ if (segment.scanEnd > cutResumeIndex) cutResumeIndex = segment.scanEnd;
157
+ }
158
+ }
159
+ return {
160
+ start,
161
+ end,
162
+ recursive,
163
+ preserveGeneratedPlaceholders,
164
+ partialPlaceholderCut,
165
+ cutResumeIndex,
166
+ firstPlaceholderScanStart,
167
+ };
168
+ }
169
+
170
+ function findScanSegment(segments: ReadonlyArray<RegexScanSegment>, scanIndex: number): RegexScanSegment {
171
+ for (const segment of segments) {
172
+ if (scanIndex >= segment.scanStart && scanIndex < segment.scanEnd) return segment;
173
+ }
174
+ throw new Error("regex match did not map to source text");
175
+ }
176
+
177
+ /**
178
+ * Extend a scan-space resume position past a consecutive run of generated
179
+ * placeholder segments starting exactly at it, with no raw gap in between. A
180
+ * cut-resolution resume point that happens to land precisely on the START of
181
+ * ANOTHER placeholder must not stop there and hand it to a fresh `regex.exec`
182
+ * attempt — the same content, scanned as an opaque adjacent placeholder run,
183
+ * must resolve identically whether the run's LEADING member is still raw text
184
+ * (this call is about to placeholder it) or is ALREADY a placeholder from a
185
+ * prior call or an earlier pass of this same call. Without this, a bounded
186
+ * regex whose reach spans two adjacent secrets plus trailing spillover bytes
187
+ * (e.g. `[A-Z]{9}` over `ABCDEFGH` + `SECRETUV` + `A`) resolves the leading
188
+ * secret as its own independent redaction on the FIRST obfuscate() call (a
189
+ * genuinely raw prefix gets its own match, then the discard for the rest
190
+ * resumes right after it), but on a LATER call — once that prefix is itself a
191
+ * placeholder — the very first match attempt starts already inside the
192
+ * placeholder run, cannot be prefix-narrowed at all, and its discard resume
193
+ * point lands mid-run instead of past it, exposing a shorter tail (`SECRETUV`
194
+ * + `A`) to a clean, un-cut match the first call never attempted. Chaining the
195
+ * resume point through every immediately-adjacent placeholder makes both
196
+ * calls land on the exact same next scan position.
197
+ */
198
+ export function extendPastAdjacentPlaceholders(segments: ReadonlyArray<RegexScanSegment>, index: number): number {
199
+ let cursor = index;
200
+ for (;;) {
201
+ const segment = segments.find(candidate => candidate.scanStart === cursor && candidate.generatedPlaceholder);
202
+ if (!segment) return cursor;
203
+ cursor = segment.scanEnd;
204
+ }
205
+ }
206
+
207
+ // Apply a fixed custom replacement across a matched span while preserving any
208
+ // inner generated placeholders. Usually the replacement is the user's single
209
+ // redaction marker for the whole match, so emit it for the first non-empty
210
+ // surrounding chunk and drop later chunks. But bounded regexes can cut through
211
+ // an already-emitted marker on the trailing side (`X#…#RED` from
212
+ // `XSECRETUVREDACTED`), where dropping the later prefix would leave raw bytes
213
+ // (`ACTED`) to be consumed on the next pass. Promote later chunks that are a
214
+ // prefix of the replacement to the FULL marker so the first pass is already a
215
+ // fixed point. The reversible placeholder stays intact in its relative
216
+ // position.
217
+ export function redactWithFixedReplacementOutsidePlaceholders(
218
+ text: string,
219
+ origin: string,
220
+ replacement: string,
221
+ shouldPreservePlaceholder: (placeholder: string) => boolean,
222
+ ): { text: string; origin: string } {
223
+ let emitted = false;
224
+ return transformOutsidePlaceholdersTracked(
225
+ text,
226
+ origin,
227
+ shouldPreservePlaceholder,
228
+ chunk => {
229
+ if (chunk.length === 0) return "";
230
+ if (!emitted) {
231
+ emitted = true;
232
+ return replacement;
233
+ }
234
+ return replacement.startsWith(chunk) ? replacement : "";
235
+ },
236
+ placeholder => placeholder,
237
+ );
238
+ }
239
+
240
+ export function deobfuscateGeneratedPlaceholderRanges(
241
+ text: string,
242
+ start: number,
243
+ end: number,
244
+ ranges: ReadonlyArray<{ start: number; end: number }>,
245
+ deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
246
+ ): { text: string; recursive: boolean } {
247
+ let result = "";
248
+ let cursor = start;
249
+ let recursive = false;
250
+ for (const range of ranges) {
251
+ if (range.end <= start || range.start >= end) continue;
252
+ const overlapStart = Math.max(range.start, start);
253
+ const overlapEnd = Math.min(range.end, end);
254
+ result += text.slice(cursor, overlapStart);
255
+ const placeholder = text.slice(overlapStart, overlapEnd);
256
+ const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
257
+ result += mapping?.secret ?? placeholder;
258
+ recursive ||= mapping?.recursive ?? false;
259
+ cursor = overlapEnd;
260
+ }
261
+ result += text.slice(cursor, end);
262
+ return { text: result, recursive };
263
+ }
264
+
265
+ // Concatenate ONLY the deobfuscated placeholder ranges within [start, end),
266
+ // dropping the bytes that lie outside them. Used to test whether a regex match
267
+ // that straddles a prior-call placeholder would still match on the placeholder's
268
+ // own (expanded) secret value alone — i.e. the surrounding raw bytes are greedy
269
+ // spillover the match does not need, rather than content the match depends on.
270
+ export function placeholderInnerText(
271
+ text: string,
272
+ start: number,
273
+ end: number,
274
+ ranges: ReadonlyArray<{ start: number; end: number }>,
275
+ deobfuscateMap: ReadonlyMap<string, { secret: string; recursive: boolean }>,
276
+ ): string {
277
+ let result = "";
278
+ for (const range of ranges) {
279
+ if (range.end <= start || range.start >= end) continue;
280
+ const overlapStart = Math.max(range.start, start);
281
+ const overlapEnd = Math.min(range.end, end);
282
+ const placeholder = text.slice(overlapStart, overlapEnd);
283
+ const mapping = lookupFriendlyPlaceholderAlias(deobfuscateMap, placeholder);
284
+ result += mapping?.secret ?? placeholder;
285
+ }
286
+ return result;
287
+ }
288
+
289
+ // Concatenate the bytes of [start, end) that lie OUTSIDE the given (ascending,
290
+ // non-overlapping) placeholder ranges. Used to test whether a regex match that
291
+ // straddles a prior-call placeholder would still match on its surrounding bytes
292
+ // alone — i.e. those bytes are genuinely new content to redact rather than a
293
+ // match that only exists because the deobfuscated placeholder bridges them.
294
+ export function textOutsidePlaceholderRanges(
295
+ text: string,
296
+ start: number,
297
+ end: number,
298
+ ranges: ReadonlyArray<{ start: number; end: number }>,
299
+ ): string {
300
+ let result = "";
301
+ let cursor = start;
302
+ for (const range of ranges) {
303
+ if (range.end <= start || range.start >= end) continue;
304
+ const overlapStart = Math.max(range.start, start);
305
+ const overlapEnd = Math.min(range.end, end);
306
+ result += text.slice(cursor, overlapStart);
307
+ cursor = overlapEnd;
308
+ }
309
+ result += text.slice(cursor, end);
310
+ return result;
311
+ }
312
+
313
+ // Like `textOutsidePlaceholderRanges`, but tests each outside chunk against
314
+ // `regex` in its REAL context instead of on an isolated slice — tried in BOTH
315
+ // the literal `#…#` placeholder-token text AND the EXPANDED scan context
316
+ // (placeholder resolved to its secret value), since either can be the reason a
317
+ // chunk independently requires redaction:
318
+ // - Literal-token context matters when the placeholder TOKEN's own non-word
319
+ // boundary is what completes a boundary-sensitive pattern, e.g. a prefix
320
+ // "ABCDEFGH" next to a placeholder token matches `\b[A-Z]{8}\b` because the
321
+ // token's leading `#` is a non-word byte — but that boundary disappears
322
+ // once the placeholder expands into more `[A-Z]` bytes with no separator.
323
+ // - Expanded scan context matters when a lookbehind/lookahead only resolves
324
+ // once the neighboring placeholder is expanded, e.g. a prior plain
325
+ // placeholder for `ABCDEFGH` next to raw `SECRET`, matched by
326
+ // `(?<=ABCDEFGH)SECRET`: the literal placeholder token before `SECRET`
327
+ // never satisfies the lookbehind, so literal-context alone wrongly reports
328
+ // no independent match.
329
+ // A match only counts when it lies ENTIRELY within one outside chunk (in
330
+ // whichever context it was tested); a match that reaches into the
331
+ // placeholder itself is not evidence the outside chunk independently
332
+ // requires redaction.
333
+ export function outsidePlaceholderRangesAnyIndependentlyMatch(
334
+ text: string,
335
+ scanText: string,
336
+ segments: ReadonlyArray<RegexScanSegment>,
337
+ start: number,
338
+ end: number,
339
+ ranges: ReadonlyArray<{ start: number; end: number }>,
340
+ regex: RegExp,
341
+ ): boolean {
342
+ // A text-space outside chunk lies entirely within one non-placeholder scan
343
+ // segment (placeholder ranges are exactly the gaps between such segments),
344
+ // so its scan-space span is a fixed offset from its text-space span.
345
+ const toScanSpace = (chunkStart: number, chunkEnd: number): [number, number] | undefined => {
346
+ for (const segment of segments) {
347
+ if (segment.generatedPlaceholder || segment.textStart > chunkStart || segment.textEnd < chunkEnd) continue;
348
+ const offset = segment.scanStart - segment.textStart;
349
+ return [chunkStart + offset, chunkEnd + offset];
350
+ }
351
+ return undefined;
352
+ };
353
+ const chunkIndependentlyMatches = (chunkStart: number, chunkEnd: number): boolean => {
354
+ if (chunkMatchesInSourceContext(text, chunkStart, chunkEnd, regex)) return true;
355
+ const scanSpan = toScanSpace(chunkStart, chunkEnd);
356
+ return scanSpan !== undefined && chunkMatchesInSourceContext(scanText, scanSpan[0], scanSpan[1], regex);
357
+ };
358
+ let cursor = start;
359
+ for (const range of ranges) {
360
+ if (range.end <= start || range.start >= end) continue;
361
+ const overlapStart = Math.max(range.start, start);
362
+ const overlapEnd = Math.min(range.end, end);
363
+ if (cursor < overlapStart && chunkIndependentlyMatches(cursor, overlapStart)) return true;
364
+ cursor = overlapEnd;
365
+ }
366
+ return cursor < end && chunkIndependentlyMatches(cursor, end);
367
+ }
368
+
369
+ // Whether `regex` (global) has a match fully contained in [chunkStart, chunkEnd)
370
+ // when run against the full `text` — so lookbehind/lookahead see the actual
371
+ // surrounding bytes rather than an isolated slice's edges.
372
+ function chunkMatchesInSourceContext(text: string, chunkStart: number, chunkEnd: number, regex: RegExp): boolean {
373
+ regex.lastIndex = chunkStart;
374
+ for (;;) {
375
+ const found = regex.exec(text);
376
+ if (found === null || found.index >= chunkEnd) return false;
377
+ const matchEnd = found.index + found[0].length;
378
+ if (matchEnd <= chunkEnd) return true;
379
+ regex.lastIndex = found[0].length === 0 ? found.index + 1 : matchEnd;
380
+ }
381
+ }
382
+
383
+ export function firstOutsidePlaceholderRange(
384
+ start: number,
385
+ end: number,
386
+ ranges: ReadonlyArray<{ start: number; end: number }>,
387
+ ): { start: number; end: number } | undefined {
388
+ let cursor = start;
389
+ for (const range of ranges) {
390
+ if (range.end <= start || range.start >= end) continue;
391
+ const overlapStart = Math.max(range.start, start);
392
+ const overlapEnd = Math.min(range.end, end);
393
+ if (cursor < overlapStart) return { start: cursor, end: overlapStart };
394
+ cursor = overlapEnd;
395
+ }
396
+ return cursor < end ? { start: cursor, end } : undefined;
397
+ }
398
+
399
+ export function countOutsidePlaceholderRanges(
400
+ start: number,
401
+ end: number,
402
+ ranges: ReadonlyArray<{ start: number; end: number }>,
403
+ ): number {
404
+ let count = 0;
405
+ let cursor = start;
406
+ for (const range of ranges) {
407
+ if (range.end <= start || range.start >= end) continue;
408
+ const overlapStart = Math.max(range.start, start);
409
+ const overlapEnd = Math.min(range.end, end);
410
+ if (cursor < overlapStart) count++;
411
+ cursor = overlapEnd;
412
+ }
413
+ if (cursor < end) count++;
414
+ return count;
415
+ }
416
+
417
+ export function replaceRange(text: string, start: number, end: number, replacement: string): string {
418
+ return text.slice(0, start) + replacement + text.slice(end);
419
+ }
420
+
421
+ /** Deep-walk an object, transforming all string values. */
422
+ export function deepWalkStrings<T>(obj: T, transform: (s: string) => string): T {
423
+ if (typeof obj === "string") {
424
+ return transform(obj) as unknown as T;
425
+ }
426
+ if (Array.isArray(obj)) {
427
+ let changed = false;
428
+ const result = obj.map(item => {
429
+ const transformed = deepWalkStrings(item, transform);
430
+ if (transformed !== item) changed = true;
431
+ return transformed;
432
+ });
433
+ return (changed ? result : obj) as unknown as T;
434
+ }
435
+ if (obj !== null && typeof obj === "object" && isPlainRecord(obj)) {
436
+ let changed = false;
437
+ const result: Record<string, unknown> = {};
438
+ for (const key of Object.keys(obj)) {
439
+ const value = (obj as Record<string, unknown>)[key];
440
+ const transformed = deepWalkStrings(value, transform);
441
+ if (transformed !== value) changed = true;
442
+ result[key] = transformed;
443
+ }
444
+ return (changed ? result : obj) as T;
445
+ }
446
+ return obj;
447
+ }
448
+
449
+ function isPlainRecord(obj: object): obj is Record<string, unknown> {
450
+ const prototype = Object.getPrototypeOf(obj);
451
+ return prototype === Object.prototype || prototype === null;
452
+ }
453
+
454
+ export function collectJsonRegexSecretValues(obfuscator: SecretObfuscator, value: JsonValue): Set<string> {
455
+ const values = new Set<string>();
456
+ const collect = (item: JsonValue): void => {
457
+ if (typeof item === "string") {
458
+ for (const secretValue of obfuscator.collectRegexSecretValuesForObfuscation(item)) {
459
+ values.add(secretValue);
460
+ }
461
+ return;
462
+ }
463
+ if (Array.isArray(item)) {
464
+ for (const child of item) collect(child);
465
+ return;
466
+ }
467
+ if (item !== null && typeof item === "object") {
468
+ for (const child of Object.values(item)) {
469
+ if (child !== undefined) collect(child);
470
+ }
471
+ }
472
+ };
473
+ collect(value);
474
+ return values;
475
+ }
476
+
477
+ /**
478
+ * Map every string in arbitrary JSON. Used ONLY for tool-call arguments, whose
479
+ * shape is model-authored and not known ahead of time. No other caller may walk
480
+ * untyped data: every message/content path is handled by a typed transformer.
481
+ */
482
+ export function mapJsonStrings(value: JsonValue, fn: (s: string) => string): JsonValue {
483
+ if (typeof value === "string") return fn(value);
484
+ if (Array.isArray(value)) {
485
+ let changed = false;
486
+ const out = value.map(item => {
487
+ const next = mapJsonStrings(item, fn);
488
+ if (next !== item) changed = true;
489
+ return next;
490
+ });
491
+ return changed ? out : value;
492
+ }
493
+ if (value !== null && typeof value === "object") {
494
+ let changed = false;
495
+ const out: JsonRecord = {};
496
+ for (const key of Object.keys(value)) {
497
+ const item = value[key];
498
+ if (item === undefined) continue;
499
+ const next = mapJsonStrings(item, fn);
500
+ if (next !== item) changed = true;
501
+ out[key] = next;
502
+ }
503
+ return changed ? out : value;
504
+ }
505
+ return value;
506
+ }