@aeondave/pi-skill-optimizer 3.0.0

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.
package/src/output.ts ADDED
@@ -0,0 +1,629 @@
1
+ /**
2
+ * Pure, dependency-free tool-output reduction.
3
+ *
4
+ * The deterministic reducer keeps bounded context around protected evidence. A
5
+ * model-assisted extraction is never trusted directly: it must contain only
6
+ * verbatim source lines, retain every protected line, and save enough UTF-8
7
+ * bytes to justify replacing the original. Rejected extraction falls back to
8
+ * the deterministic reducer, then to the original text.
9
+ */
10
+
11
+ /** Evidence that may not be discarded by either reduction strategy. */
12
+ export const DEFAULT_OUTPUT_PATTERNS: readonly RegExp[] = [
13
+ /\b(error|errors|errno|failed|failure|fatal|panic|exception|traceback|stack\s?trace|denied|refused|cannot|unable|timed?\s?out|segfault|core dumped|assert(ion)?)\b/i,
14
+ /\b(warning|warn|deprecat)\b/i,
15
+ /^\s*at\s+\S/, // JavaScript and similar stack frames
16
+ /^\s*(?:Caused by:|npm\s+ERR!|error\[E\d+\])/i,
17
+ /\b(?:E\d{2,}|ENOENT|EACCES|EPERM|ECONNRESET|ECONNREFUSED|ETIMEDOUT)\b/,
18
+ /(^|\s)(FAIL|FAILED|not ok|✗|×|✖)(\s|$|:)/i,
19
+ /\b(exit(?:ed)?\s+(?:code|status)|non-zero)\b/i,
20
+ /^\s*(?:(?:tests?|suites?)\s*:\s*.*\bfailed\b|failures?\s*:\s*[1-9]\d*|(?:expected|actual|received)\s*:)/i,
21
+ /(?:^|[\s("'`])(?:[A-Za-z]:[\\/]|\/|\.{0,2}[\\/])?[^\s:()[\]{}"'`]+\.[A-Za-z0-9]{1,12}:\d+(?::\d+)?\b/,
22
+ /^\s*File\s+["'][^"']+["'],\s+line\s+\d+/i,
23
+ /(?:^\s*(?:[-*]\s*)?(?:constraint|requirement|required|must(?:\s+not)?|do\s+not|never|shall(?:\s+not)?)\b|\b(?:must(?:\s+not)?|shall(?:\s+not)?|is required to)\b)/i,
24
+ ];
25
+
26
+ export interface OutputReduceOptions {
27
+ /** Only reduce when the text exceeds this many lines or UTF-8 bytes. */
28
+ maxLines: number;
29
+ maxBytes: number;
30
+ /** Lines kept from the start and end. */
31
+ headLines: number;
32
+ tailLines: number;
33
+ /** UTF-8 byte cap for ordinary kept lines. Protected lines stay verbatim. */
34
+ maxLineBytes: number;
35
+ /** Additional project-specific evidence patterns. Core evidence always applies. */
36
+ patterns: readonly RegExp[];
37
+ /** Ordinary lines retained on each side of protected evidence. */
38
+ contextLines?: number;
39
+ /** A reduction must satisfy both minimums. Set either to zero to disable it. */
40
+ minSavingsBytes?: number;
41
+ minSavingsRatio?: number;
42
+ }
43
+
44
+ export interface OutputReduceResult {
45
+ text: string;
46
+ reduced: boolean;
47
+ fromLines: number;
48
+ toLines: number;
49
+ fromBytes: number;
50
+ toBytes: number;
51
+ }
52
+
53
+ export const DEFAULT_OUTPUT_OPTIONS: Required<OutputReduceOptions> = {
54
+ maxLines: 200,
55
+ maxBytes: 16_000,
56
+ headLines: 40,
57
+ tailLines: 60,
58
+ maxLineBytes: 2_000,
59
+ patterns: DEFAULT_OUTPUT_PATTERNS,
60
+ contextLines: 1,
61
+ minSavingsBytes: 512,
62
+ minSavingsRatio: 0.1,
63
+ };
64
+
65
+ export type ExtractStrategy = "extract" | "smart" | "original";
66
+ export type ExtractRejectionReason =
67
+ | "empty-extraction"
68
+ | "non-verbatim-line"
69
+ | "out-of-order-line"
70
+ | "missing-protected-evidence"
71
+ | "insufficient-benefit";
72
+
73
+ export interface ExtractValidationOptions {
74
+ /** Options for the deterministic fallback. */
75
+ smartOptions?: Partial<OutputReduceOptions>;
76
+ /** Additional lines that both strategies must preserve verbatim. */
77
+ protectedPatterns?: readonly RegExp[];
78
+ /** Extraction and smart fallback must satisfy both minimums. */
79
+ minSavingsBytes?: number;
80
+ minSavingsRatio?: number;
81
+ }
82
+
83
+ export interface ExtractValidationResult extends OutputReduceResult {
84
+ strategy: ExtractStrategy;
85
+ /** Present when model output was rejected, including when smart recovered. */
86
+ rejectionReason?: ExtractRejectionReason;
87
+ }
88
+
89
+ interface ResolvedOutputReduceOptions {
90
+ maxLines: number;
91
+ maxBytes: number;
92
+ headLines: number;
93
+ tailLines: number;
94
+ maxLineBytes: number;
95
+ patterns: readonly RegExp[];
96
+ contextLines: number;
97
+ minSavingsBytes: number;
98
+ minSavingsRatio: number;
99
+ }
100
+
101
+ /** Exact UTF-8 size used by every byte-oriented limit and metric in this module. */
102
+ export function utf8ByteLength(text: string): number {
103
+ return Buffer.byteLength(text, "utf8");
104
+ }
105
+
106
+ /** Return the longest prefix within `maxBytes`, never splitting a code point. */
107
+ export function truncateUtf8Bytes(text: string, maxBytes: number): string {
108
+ if (maxBytes === Number.POSITIVE_INFINITY) return text;
109
+ const limit = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : 0;
110
+ if (limit === 0) return "";
111
+ if (utf8ByteLength(text) <= limit) return text;
112
+
113
+ let used = 0;
114
+ let end = 0;
115
+ for (const codePoint of text) {
116
+ const bytes = utf8ByteLength(codePoint);
117
+ if (used + bytes > limit) break;
118
+ used += bytes;
119
+ end += codePoint.length;
120
+ }
121
+ return text.slice(0, end);
122
+ }
123
+
124
+ function utf8Suffix(text: string, maxBytes: number): string {
125
+ const limit = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : 0;
126
+ if (limit === 0) return "";
127
+ if (utf8ByteLength(text) <= limit) return text;
128
+
129
+ let used = 0;
130
+ let start = text.length;
131
+ while (start > 0) {
132
+ let next = start - 1;
133
+ const unit = text.charCodeAt(next);
134
+ if (unit >= 0xdc00 && unit <= 0xdfff && next > 0) {
135
+ const previous = text.charCodeAt(next - 1);
136
+ if (previous >= 0xd800 && previous <= 0xdbff) next--;
137
+ }
138
+ const codePoint = text.slice(next, start);
139
+ const bytes = utf8ByteLength(codePoint);
140
+ if (used + bytes > limit) break;
141
+ used += bytes;
142
+ start = next;
143
+ }
144
+ return text.slice(start);
145
+ }
146
+
147
+ function clipUtf8HeadTail(text: string, maxBytes: number): string {
148
+ if (maxBytes === Number.POSITIVE_INFINITY || utf8ByteLength(text) <= maxBytes) return text;
149
+ const limit = Number.isFinite(maxBytes) ? Math.max(0, Math.floor(maxBytes)) : 0;
150
+ if (limit === 0) return "";
151
+ const marker = "\n...[middle truncated for extraction]...\n";
152
+ const markerBytes = utf8ByteLength(marker);
153
+ if (markerBytes >= limit) return truncateUtf8Bytes(text, limit);
154
+
155
+ const contentBudget = limit - markerBytes;
156
+ const headBudget = Math.ceil(contentBudget * 0.6);
157
+ const tailBudget = contentBudget - headBudget;
158
+ return `${truncateUtf8Bytes(text, headBudget)}${marker}${utf8Suffix(text, tailBudget)}`;
159
+ }
160
+
161
+ function nonNegative(value: number | undefined, fallback: number): number {
162
+ if (value === Number.POSITIVE_INFINITY) return value;
163
+ return Number.isFinite(value) ? Math.max(0, value as number) : fallback;
164
+ }
165
+
166
+ function resolveOptions(options: Partial<OutputReduceOptions>): ResolvedOutputReduceOptions {
167
+ return {
168
+ maxLines: Math.floor(nonNegative(options.maxLines, DEFAULT_OUTPUT_OPTIONS.maxLines)),
169
+ maxBytes: Math.floor(nonNegative(options.maxBytes, DEFAULT_OUTPUT_OPTIONS.maxBytes)),
170
+ headLines: Math.floor(nonNegative(options.headLines, DEFAULT_OUTPUT_OPTIONS.headLines)),
171
+ tailLines: Math.floor(nonNegative(options.tailLines, DEFAULT_OUTPUT_OPTIONS.tailLines)),
172
+ maxLineBytes: Math.floor(nonNegative(options.maxLineBytes, DEFAULT_OUTPUT_OPTIONS.maxLineBytes)),
173
+ patterns: options.patterns ?? DEFAULT_OUTPUT_OPTIONS.patterns,
174
+ contextLines: Math.floor(nonNegative(options.contextLines, DEFAULT_OUTPUT_OPTIONS.contextLines)),
175
+ minSavingsBytes: Math.floor(nonNegative(options.minSavingsBytes, DEFAULT_OUTPUT_OPTIONS.minSavingsBytes)),
176
+ minSavingsRatio: Math.min(1, nonNegative(options.minSavingsRatio, DEFAULT_OUTPUT_OPTIONS.minSavingsRatio)),
177
+ };
178
+ }
179
+
180
+ function testPattern(pattern: RegExp, line: string): boolean {
181
+ pattern.lastIndex = 0;
182
+ const matched = pattern.test(line);
183
+ pattern.lastIndex = 0;
184
+ return matched;
185
+ }
186
+
187
+ function matchesAny(line: string, patterns: readonly RegExp[]): boolean {
188
+ return patterns.some((pattern) => testPattern(pattern, line));
189
+ }
190
+
191
+ function combinedEvidencePatterns(additional: readonly RegExp[] | undefined): readonly RegExp[] {
192
+ if (!additional || additional === DEFAULT_OUTPUT_PATTERNS || additional.length === 0) return DEFAULT_OUTPUT_PATTERNS;
193
+ return [...DEFAULT_OUTPUT_PATTERNS, ...additional];
194
+ }
195
+
196
+ /** True only when replacing `fromBytes` satisfies both configured savings floors. */
197
+ export function hasMinimumSavings(fromBytes: number, toBytes: number, minBytes: number, minRatio: number): boolean {
198
+ if (fromBytes <= 0 || toBytes >= fromBytes) return false;
199
+ const saved = fromBytes - toBytes;
200
+ return saved >= Math.max(0, minBytes) && saved / fromBytes >= Math.max(0, Math.min(1, minRatio));
201
+ }
202
+
203
+ /** True when a registered command looks like it comes from an RTK extension. */
204
+ export function isRtkSource(name: string, path: string, source: string): boolean {
205
+ return /rtk/i.test(path) || /rtk/i.test(source) || /\brtk\b/i.test(name);
206
+ }
207
+
208
+ /** Best-effort program name of a shell command. */
209
+ export function commandProgram(command: string): string {
210
+ let s = command.trim();
211
+ s = s.replace(/^(?:\s*[A-Za-z_][A-Za-z0-9_]*=(?:"[^"]*"|'[^']*'|\S*)\s+)+/, "");
212
+ const tokens = s.split(/\s+/).filter(Boolean);
213
+ let prog = tokens[0] ?? "";
214
+ if (["sudo", "env", "command", "time", "nice", "nohup"].includes(prog) && tokens[1]) prog = tokens[1];
215
+ return prog.replace(/^.*[\\/]/, "").toLowerCase().replace(/\.exe$/, "");
216
+ }
217
+
218
+ /** True when the command's program is in the extract-exclusion list. */
219
+ export function isExcludedCommand(command: string, exclude: readonly string[]): boolean {
220
+ if (!command || exclude.length === 0) return false;
221
+ const prog = commandProgram(command);
222
+ return exclude.some((entry) => entry.toLowerCase() === prog);
223
+ }
224
+
225
+ /** Collect protected evidence verbatim, including repeated occurrences. */
226
+ export function protectedEvidenceLines(
227
+ text: string,
228
+ additionalPatterns: readonly RegExp[] = [],
229
+ max = Number.POSITIVE_INFINITY,
230
+ ): string[] {
231
+ const patterns = combinedEvidencePatterns(additionalPatterns);
232
+ const limit = Number.isFinite(max) ? Math.max(0, Math.floor(max)) : Number.POSITIVE_INFINITY;
233
+ if (limit === 0) return [];
234
+ const out: string[] = [];
235
+ for (const line of text.split(/\r?\n/)) {
236
+ if (matchesAny(line, patterns)) {
237
+ out.push(line);
238
+ if (out.length >= limit) break;
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+
244
+ /**
245
+ * Build the prompt for a model-assisted verbatim selection pass. The command
246
+ * output is clipped by real UTF-8 bytes and retains both head and tail.
247
+ */
248
+ export function buildExtractPrompt(request: string, command: string, output: string, maxBytes = 64_000): { system: string; user: string } {
249
+ const clipped = clipUtf8HeadTail(output, maxBytes);
250
+ const system = [
251
+ "Select the exact COMMAND OUTPUT lines needed for the REQUEST. Do not summarize, rephrase, infer, or invent.",
252
+ "Return only complete lines copied character-for-character, including leading whitespace, in their original order and without duplicates.",
253
+ "No prose, commentary, markdown, code fences, or headers. Never return a line absent from COMMAND OUTPUT.",
254
+ "Always retain errors, warnings, stack frames, exit failures, failed tests and assertions, path-and-line diagnostics, and explicit constraints.",
255
+ "Keep the result minimal. If relevance is unclear, select a representative head and tail plus all protected evidence.",
256
+ "Do not return the synthetic middle-truncation marker.",
257
+ ].join("\n");
258
+ const user = `REQUEST:\n${request || "(no explicit request)"}\n\nCOMMAND:\n${command || "(unknown)"}\n\nCOMMAND OUTPUT:\n${clipped}`;
259
+ return { system, user };
260
+ }
261
+
262
+ function truncateLine(line: string, maxLineBytes: number): string {
263
+ const totalBytes = utf8ByteLength(line);
264
+ if (totalBytes <= maxLineBytes) return line;
265
+ const largestMarker = `... (+${totalBytes} bytes)`;
266
+ const markerBudget = utf8ByteLength(largestMarker);
267
+ if (markerBudget >= maxLineBytes) return truncateUtf8Bytes(line, maxLineBytes);
268
+ const prefix = truncateUtf8Bytes(line, maxLineBytes - markerBudget);
269
+ const marker = `... (+${totalBytes - utf8ByteLength(prefix)} bytes)`;
270
+ return `${prefix}${marker}`;
271
+ }
272
+
273
+ function unchangedResult(text: string, lines: number, bytes: number): OutputReduceResult {
274
+ return { text, reduced: false, fromLines: lines, toLines: lines, fromBytes: bytes, toBytes: bytes };
275
+ }
276
+
277
+ /**
278
+ * Deterministically keep head, tail, protected evidence, and bounded evidence
279
+ * context. The original is returned if the result is not materially smaller.
280
+ */
281
+ export function reduceOutput(text: string, options: Partial<OutputReduceOptions> = {}): OutputReduceResult {
282
+ const opts = resolveOptions(options);
283
+ const fromBytes = utf8ByteLength(text);
284
+ const lines = text.split(/\r?\n/);
285
+ const fromLines = lines.length;
286
+
287
+ if (fromLines <= opts.maxLines && fromBytes <= opts.maxBytes) return unchangedResult(text, fromLines, fromBytes);
288
+
289
+ const evidencePatterns = combinedEvidencePatterns(opts.patterns);
290
+ const evidence = new Set<number>();
291
+ const keep = new Set<number>();
292
+ for (let i = 0; i < Math.min(opts.headLines, fromLines); i++) keep.add(i);
293
+ for (let i = Math.max(0, fromLines - opts.tailLines); i < fromLines; i++) keep.add(i);
294
+ for (let i = 0; i < fromLines; i++) {
295
+ if (!matchesAny(lines[i], evidencePatterns)) continue;
296
+ evidence.add(i);
297
+ for (let j = Math.max(0, i - opts.contextLines); j <= Math.min(fromLines - 1, i + opts.contextLines); j++) keep.add(j);
298
+ }
299
+
300
+ const sorted = [...keep].sort((a, b) => a - b);
301
+ const out: string[] = [];
302
+ let cursor = 0;
303
+ let changed = false;
304
+ for (const index of sorted) {
305
+ if (index > cursor) {
306
+ out.push(`... (${index - cursor} lines omitted) ...`);
307
+ changed = true;
308
+ }
309
+ const line = evidence.has(index) ? lines[index] : truncateLine(lines[index], opts.maxLineBytes);
310
+ if (line !== lines[index]) changed = true;
311
+ out.push(line);
312
+ cursor = index + 1;
313
+ }
314
+ if (cursor < fromLines) {
315
+ out.push(`... (${fromLines - cursor} lines omitted) ...`);
316
+ changed = true;
317
+ }
318
+
319
+ const next = out.join("\n");
320
+ const toBytes = utf8ByteLength(next);
321
+ if (!changed || !hasMinimumSavings(fromBytes, toBytes, opts.minSavingsBytes, opts.minSavingsRatio)) {
322
+ return unchangedResult(text, fromLines, fromBytes);
323
+ }
324
+ return { text: next, reduced: true, fromLines, toLines: out.length, fromBytes, toBytes };
325
+ }
326
+
327
+ function lineCounts(lines: readonly string[]): Map<string, number> {
328
+ const counts = new Map<string, number>();
329
+ for (const line of lines) counts.set(line, (counts.get(line) ?? 0) + 1);
330
+ return counts;
331
+ }
332
+
333
+ function stripOuterEmptyLines(text: string): string[] {
334
+ const lines = text.split(/\r?\n/);
335
+ while (lines[0] === "") lines.shift();
336
+ while (lines.at(-1) === "") lines.pop();
337
+ return lines;
338
+ }
339
+
340
+ function extractionIsVerbatim(originalLines: readonly string[], extractedLines: readonly string[]): boolean {
341
+ const remaining = lineCounts(originalLines);
342
+ for (const line of extractedLines) {
343
+ const count = remaining.get(line) ?? 0;
344
+ if (count === 0) return false;
345
+ remaining.set(line, count - 1);
346
+ }
347
+ return true;
348
+ }
349
+
350
+ function extractionPreservesOrder(originalLines: readonly string[], extractedLines: readonly string[]): boolean {
351
+ let cursor = 0;
352
+ for (const line of extractedLines) {
353
+ while (cursor < originalLines.length && originalLines[cursor] !== line) cursor++;
354
+ if (cursor >= originalLines.length) return false;
355
+ cursor++;
356
+ }
357
+ return true;
358
+ }
359
+
360
+ function extractionHasEvidence(extractedLines: readonly string[], evidenceLines: readonly string[]): boolean {
361
+ const remaining = lineCounts(extractedLines);
362
+ for (const line of evidenceLines) {
363
+ const count = remaining.get(line) ?? 0;
364
+ if (count === 0) return false;
365
+ remaining.set(line, count - 1);
366
+ }
367
+ return true;
368
+ }
369
+
370
+ function fallbackExtraction(
371
+ original: string,
372
+ reason: ExtractRejectionReason,
373
+ options: ExtractValidationOptions,
374
+ ): ExtractValidationResult {
375
+ const additional = [
376
+ ...(options.smartOptions?.patterns ?? []),
377
+ ...(options.protectedPatterns ?? []),
378
+ ];
379
+ const smart = reduceOutput(original, {
380
+ ...options.smartOptions,
381
+ patterns: additional,
382
+ minSavingsBytes: options.minSavingsBytes ?? options.smartOptions?.minSavingsBytes,
383
+ minSavingsRatio: options.minSavingsRatio ?? options.smartOptions?.minSavingsRatio,
384
+ });
385
+ return {
386
+ ...smart,
387
+ strategy: smart.reduced ? "smart" : "original",
388
+ rejectionReason: reason,
389
+ };
390
+ }
391
+
392
+ /**
393
+ * Validate untrusted model extraction and choose extract, deterministic smart,
394
+ * or original. The caller retains the original for its existing recovery path.
395
+ */
396
+ export function validateExtractedOutput(
397
+ original: string,
398
+ extracted: string,
399
+ options: ExtractValidationOptions = {},
400
+ ): ExtractValidationResult {
401
+ const originalLines = original.split(/\r?\n/);
402
+ const fromBytes = utf8ByteLength(original);
403
+ const extractedLines = stripOuterEmptyLines(extracted);
404
+ if (extractedLines.length === 0) return fallbackExtraction(original, "empty-extraction", options);
405
+ if (!extractionIsVerbatim(originalLines, extractedLines)) return fallbackExtraction(original, "non-verbatim-line", options);
406
+ if (!extractionPreservesOrder(originalLines, extractedLines)) return fallbackExtraction(original, "out-of-order-line", options);
407
+
408
+ const evidence = protectedEvidenceLines(original, options.protectedPatterns);
409
+ if (!extractionHasEvidence(extractedLines, evidence)) {
410
+ return fallbackExtraction(original, "missing-protected-evidence", options);
411
+ }
412
+
413
+ const text = extractedLines.join("\n");
414
+ const toBytes = utf8ByteLength(text);
415
+ const minBytes = nonNegative(
416
+ options.minSavingsBytes ?? options.smartOptions?.minSavingsBytes,
417
+ DEFAULT_OUTPUT_OPTIONS.minSavingsBytes,
418
+ );
419
+ const minRatio = Math.min(1, nonNegative(
420
+ options.minSavingsRatio ?? options.smartOptions?.minSavingsRatio,
421
+ DEFAULT_OUTPUT_OPTIONS.minSavingsRatio,
422
+ ));
423
+ if (extractedLines.length >= originalLines.length || !hasMinimumSavings(fromBytes, toBytes, minBytes, minRatio)) {
424
+ return fallbackExtraction(original, "insufficient-benefit", options);
425
+ }
426
+
427
+ return {
428
+ text,
429
+ reduced: true,
430
+ strategy: "extract",
431
+ fromLines: originalLines.length,
432
+ toLines: extractedLines.length,
433
+ fromBytes,
434
+ toBytes,
435
+ };
436
+ }
437
+
438
+ export interface ColumnarJsonOptions {
439
+ minSavingsBytes?: number;
440
+ minSavingsRatio?: number;
441
+ protectedPatterns?: readonly RegExp[];
442
+ }
443
+
444
+ export interface ColumnarJsonResult {
445
+ text: string;
446
+ reduced: boolean;
447
+ strategy: "columnar-json" | "original";
448
+ fromBytes: number;
449
+ toBytes: number;
450
+ rows: number;
451
+ columns: number;
452
+ }
453
+
454
+ interface ColumnarJsonV1 {
455
+ $format: "columnar-json-v1";
456
+ rows: number;
457
+ keys: string[];
458
+ columns: unknown[][];
459
+ }
460
+
461
+ const MAX_EMPTY_COLUMNAR_ROWS = 10_000;
462
+ const JSON_NUMBER_TOKEN = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
463
+
464
+ /**
465
+ * Reject number spellings that JavaScript cannot round-trip exactly through
466
+ * JSON.parse/JSON.stringify. The source text is what the model sees, so even
467
+ * safe but non-canonical spellings conservatively stay in their original form.
468
+ */
469
+ function hasNonRoundTripJsonNumber(text: string): boolean {
470
+ let inString = false;
471
+ let escaped = false;
472
+ for (let index = 0; index < text.length; index++) {
473
+ const character = text[index];
474
+ if (inString) {
475
+ if (escaped) escaped = false;
476
+ else if (character === "\\") escaped = true;
477
+ else if (character === '"') inString = false;
478
+ continue;
479
+ }
480
+ if (character === '"') {
481
+ inString = true;
482
+ continue;
483
+ }
484
+ if (character !== "-" && !(character >= "0" && character <= "9")) continue;
485
+
486
+ JSON_NUMBER_TOKEN.lastIndex = index;
487
+ const match = JSON_NUMBER_TOKEN.exec(text);
488
+ if (!match) return true;
489
+ const token = match[0];
490
+ const value = Number(token);
491
+ if (!Number.isFinite(value)
492
+ || Object.is(value, -0)
493
+ || (Number.isInteger(value) && !Number.isSafeInteger(value))
494
+ || String(value) !== token) return true;
495
+ index += token.length - 1;
496
+ }
497
+ return false;
498
+ }
499
+
500
+ function matchesOutputPattern(text: string, pattern: RegExp): boolean {
501
+ pattern.lastIndex = 0;
502
+ const matched = pattern.test(text);
503
+ pattern.lastIndex = 0;
504
+ return matched;
505
+ }
506
+
507
+ /** Decode only the self-describing lossless columnar format emitted below. */
508
+ export function decodeColumnarJson(text: string): unknown[] | undefined {
509
+ let value: unknown;
510
+ try {
511
+ value = JSON.parse(text);
512
+ } catch {
513
+ return undefined;
514
+ }
515
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
516
+ const encoded = value as Partial<ColumnarJsonV1>;
517
+ const rowCount = encoded.rows;
518
+ if (encoded.$format !== "columnar-json-v1"
519
+ || typeof rowCount !== "number"
520
+ || !Number.isInteger(rowCount)
521
+ || rowCount < 0
522
+ || !Array.isArray(encoded.keys)
523
+ || (encoded.keys.length === 0 && rowCount > MAX_EMPTY_COLUMNAR_ROWS)
524
+ || !encoded.keys.every((key) => typeof key === "string")
525
+ || new Set(encoded.keys).size !== encoded.keys.length
526
+ || !Array.isArray(encoded.columns)
527
+ || encoded.columns.length !== encoded.keys.length
528
+ || !encoded.columns.every((column) => Array.isArray(column) && column.length === rowCount)) {
529
+ return undefined;
530
+ }
531
+ const rows: unknown[] = [];
532
+ for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) {
533
+ rows.push(Object.fromEntries(encoded.keys.map((key, columnIndex) => [key, encoded.columns![columnIndex][rowIndex]])));
534
+ }
535
+ return rows;
536
+ }
537
+
538
+ /**
539
+ * Losslessly encode a homogeneous JSON object array as columns. The transform is
540
+ * accepted only after an internal round trip and only when the complete encoded
541
+ * result is materially smaller. Protected evidence always forces identity.
542
+ */
543
+ export function reduceJsonArrayColumnar(text: string, options: ColumnarJsonOptions = {}): ColumnarJsonResult {
544
+ const fromBytes = utf8ByteLength(text);
545
+ const original = (): ColumnarJsonResult => ({
546
+ text,
547
+ reduced: false,
548
+ strategy: "original",
549
+ fromBytes,
550
+ toBytes: fromBytes,
551
+ rows: 0,
552
+ columns: 0,
553
+ });
554
+ if (protectedEvidenceLines(text).length > 0
555
+ || (options.protectedPatterns ?? []).some((pattern) => matchesOutputPattern(text, pattern))) return original();
556
+
557
+ let parsed: unknown;
558
+ try {
559
+ parsed = JSON.parse(text);
560
+ } catch {
561
+ return original();
562
+ }
563
+ if (!Array.isArray(parsed) || parsed.length < 2) return original();
564
+ if (hasNonRoundTripJsonNumber(text)) return original();
565
+ if (parsed.some((row) => !row || typeof row !== "object" || Array.isArray(row))) return original();
566
+ const records = parsed as Array<Record<string, unknown>>;
567
+ const keys = Object.keys(records[0]);
568
+ for (let i = 1; i < records.length; i++) {
569
+ const rowKeys = Object.keys(records[i]);
570
+ if (rowKeys.length !== keys.length || rowKeys.some((key, index) => key !== keys[index])) return original();
571
+ }
572
+ const encoded: ColumnarJsonV1 = {
573
+ $format: "columnar-json-v1",
574
+ rows: records.length,
575
+ keys,
576
+ columns: keys.map((key) => records.map((record) => record[key])),
577
+ };
578
+ const candidate = JSON.stringify(encoded);
579
+ const decoded = decodeColumnarJson(candidate);
580
+ if (!decoded || JSON.stringify(decoded) !== JSON.stringify(parsed)) return original();
581
+ const toBytes = utf8ByteLength(candidate);
582
+ const minBytes = Math.max(0, Math.floor(options.minSavingsBytes ?? 256));
583
+ const minRatio = Math.min(1, Math.max(0, options.minSavingsRatio ?? 0.05));
584
+ if (!hasMinimumSavings(fromBytes, toBytes, minBytes, minRatio)) return original();
585
+ return {
586
+ text: candidate,
587
+ reduced: true,
588
+ strategy: "columnar-json",
589
+ fromBytes,
590
+ toBytes,
591
+ rows: records.length,
592
+ columns: keys.length,
593
+ };
594
+ }
595
+
596
+ export interface ToolResultReductionDecision {
597
+ toolName: string;
598
+ outputTools: readonly string[];
599
+ rtkHandled?: boolean;
600
+ alreadyReduced?: boolean;
601
+ text?: string;
602
+ }
603
+
604
+ function wildcardMatch(value: string, pattern: string): boolean {
605
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
606
+ return new RegExp(`^${escaped}$`, "i").test(value);
607
+ }
608
+
609
+ /** Case-insensitive exact/glob matching for configured output tool names. */
610
+ export function matchesOutputTool(toolName: string, outputTools: readonly string[]): boolean {
611
+ return outputTools.some((pattern) => pattern.length > 0 && wildcardMatch(toolName, pattern));
612
+ }
613
+
614
+ /** Recognize outputs already replaced by this extension, history dedup, or RTK. */
615
+ export function isKnownReducedOutput(text: string): boolean {
616
+ return text.includes("[skill-optimizer:")
617
+ || /^\[duplicate tool result; same-as=htr:[A-Za-z0-9_-]{43}\]$/m.test(text)
618
+ || /^\[rtk(?::|\])/im.test(text);
619
+ }
620
+
621
+ /**
622
+ * Per-result coexistence decision. Mere RTK presence is not ownership evidence:
623
+ * only an explicit handled flag or a recognizable reduction suppresses AUTO.
624
+ */
625
+ export function shouldReduceToolResult(options: ToolResultReductionDecision): boolean {
626
+ if (!matchesOutputTool(options.toolName, options.outputTools)) return false;
627
+ if (options.alreadyReduced || options.rtkHandled || (options.text && isKnownReducedOutput(options.text))) return false;
628
+ return true;
629
+ }