@monotykamary/pi-vcc 0.3.16

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 (76) hide show
  1. package/README.md +688 -0
  2. package/__tests__/helpers.ts +135 -0
  3. package/__tests__/root-causes.test.ts +858 -0
  4. package/demo.gif +0 -0
  5. package/index.ts +21 -0
  6. package/knip.json +4 -0
  7. package/npm-shrinkwrap.json +3001 -0
  8. package/package.json +36 -0
  9. package/scripts/audit-sessions.ts +88 -0
  10. package/scripts/benchmark-real-sessions.ts +25 -0
  11. package/scripts/compare-before-after.ts +36 -0
  12. package/scripts/dump-branch-output.ts +20 -0
  13. package/src/commands/pi-vcc.ts +36 -0
  14. package/src/commands/vcc-recall.ts +65 -0
  15. package/src/core/brief.ts +497 -0
  16. package/src/core/build-sections.ts +431 -0
  17. package/src/core/content.ts +71 -0
  18. package/src/core/filter-noise.ts +42 -0
  19. package/src/core/format-recall.ts +177 -0
  20. package/src/core/format.ts +98 -0
  21. package/src/core/invisible-continue.ts +172 -0
  22. package/src/core/lineage.ts +26 -0
  23. package/src/core/load-messages.ts +40 -0
  24. package/src/core/normalize.ts +73 -0
  25. package/src/core/recall-scope.ts +49 -0
  26. package/src/core/render-entries.ts +58 -0
  27. package/src/core/report.ts +237 -0
  28. package/src/core/sanitize.ts +5 -0
  29. package/src/core/search-entries.ts +375 -0
  30. package/src/core/settings.ts +190 -0
  31. package/src/core/skill-collapse.ts +35 -0
  32. package/src/core/summarize.ts +179 -0
  33. package/src/core/tool-args.ts +14 -0
  34. package/src/details.ts +19 -0
  35. package/src/extract/commits.ts +69 -0
  36. package/src/extract/goals.ts +79 -0
  37. package/src/extract/preferences.ts +55 -0
  38. package/src/extract/shared-symbols.ts +357 -0
  39. package/src/hooks/before-compact.ts +564 -0
  40. package/src/hooks/proactive-threshold.ts +131 -0
  41. package/src/sections.ts +23 -0
  42. package/src/tools/recall.ts +204 -0
  43. package/src/types.ts +20 -0
  44. package/tests/australian-government.test.ts +479 -0
  45. package/tests/before-compact-hook.test.ts +185 -0
  46. package/tests/before-compact.test.ts +227 -0
  47. package/tests/brief.test.ts +215 -0
  48. package/tests/build-sections.test.ts +59 -0
  49. package/tests/compile.test.ts +89 -0
  50. package/tests/content.test.ts +66 -0
  51. package/tests/deep-extraction.test.ts +313 -0
  52. package/tests/extract-goals.test.ts +86 -0
  53. package/tests/extract-preferences.test.ts +30 -0
  54. package/tests/filter-noise.test.ts +61 -0
  55. package/tests/fixtures.ts +61 -0
  56. package/tests/format-recall.test.ts +87 -0
  57. package/tests/format.test.ts +73 -0
  58. package/tests/government-domain.test.ts +542 -0
  59. package/tests/lineage.test.ts +33 -0
  60. package/tests/load-messages.test.ts +49 -0
  61. package/tests/model-threshold-hook.test.ts +377 -0
  62. package/tests/model-threshold.test.ts +223 -0
  63. package/tests/normalize.test.ts +104 -0
  64. package/tests/proactive-threshold.test.ts +490 -0
  65. package/tests/real-sessions.test.ts +38 -0
  66. package/tests/recall-expand.test.ts +15 -0
  67. package/tests/recall-scope.test.ts +32 -0
  68. package/tests/recall-tool-scope.test.ts +67 -0
  69. package/tests/render-entries.test.ts +92 -0
  70. package/tests/report.test.ts +58 -0
  71. package/tests/sanitize.test.ts +24 -0
  72. package/tests/search-entries.test.ts +233 -0
  73. package/tests/support/load-session.ts +23 -0
  74. package/tests/support/real-sessions.ts +51 -0
  75. package/tests/threshold-integration.test.ts +359 -0
  76. package/tsconfig.json +20 -0
@@ -0,0 +1,431 @@
1
+ import type { NormalizedBlock, ToolResultIndex } from "../types";
2
+ import { clip, clipSentence, firstLine, nonEmptyLines } from "./content";
3
+ import type { SectionData } from "../sections";
4
+ import { extractGoals } from "../extract/goals";
5
+ import { extractPath } from "./tool-args";
6
+ import { extractFileAndSymbolData } from "../extract/shared-symbols";
7
+ import { extractPreferences, dedupPreferencesAgainstGoals } from "../extract/preferences";
8
+ import { extractCommits, formatCommits } from "../extract/commits";
9
+ import { buildBriefSections, identifyTurns, sectionsToTranscript, stringifyBrief } from "./brief";
10
+
11
+ /**
12
+ * Build a one-time look-ahead index: for each tool_call block, find the
13
+ * nearest tool_result block that follows it (within +3 positions).
14
+ *
15
+ * Without this, files.ts / symbol-changes.ts / type-catalog.ts each scan
16
+ * forward independently — tripling the look-ahead cost and the regex parsing
17
+ * of tool results. The index collapses that to a single O(n) pre-scan.
18
+ */
19
+ const buildToolResultIndex = (blocks: NormalizedBlock[]): ToolResultIndex => {
20
+ const map = new Map<number, Extract<NormalizedBlock, { kind: "tool_result" }>>();
21
+ for (let i = 0; i < blocks.length; i++) {
22
+ if (blocks[i].kind !== "tool_call") continue;
23
+ for (let j = i + 1; j < Math.min(blocks.length, i + 4); j++) {
24
+ if (blocks[j].kind === "tool_result") {
25
+ map.set(i, blocks[j] as Extract<NormalizedBlock, { kind: "tool_result" }>);
26
+ break;
27
+ }
28
+ }
29
+ }
30
+ return {
31
+ get: (callIndex: number) => map.get(callIndex) ?? null,
32
+ };
33
+ };
34
+
35
+ interface BuildSectionsInput {
36
+ blocks: NormalizedBlock[];
37
+ /** Pre-built tool-call → tool-result look-ahead index. Built once, shared across extractors. */
38
+ toolResultIndex?: ToolResultIndex;
39
+ }
40
+
41
+ const BLOCKER_RE =
42
+ /\b(fail(ed|s|ure|ing)?|broken|cannot|can't|won't work|does not work|doesn't work|still (broken|failing|wrong)|blocked|blocker|not (fixed|resolved|working)|crash(es|ed|ing)?)\b/i;
43
+
44
+ // TypeScript compiler error pattern
45
+ const TSC_ERROR_RE = /error TS\d+:.+/;
46
+
47
+ // Test failure indicators
48
+ const TEST_FAIL_RE = /(?:FAIL|✗|✘|×)\s|(\d+)\s+(?:failed|failure|failing)/i;
49
+
50
+ // Empty grep/search result indicators
51
+ const EMPTY_RESULT_RE = /^(?:No matches? found\.?|No files? matched\.?|0 results?|No results?\.?)$/i;
52
+
53
+ // Maximum characters of bash output to scan for error patterns.
54
+ // Compiler/test errors almost always appear near the start of output;
55
+ // scanning the full output (potentially megabytes) is unnecessary.
56
+ const BASH_OUTPUT_SCAN_LIMIT = 8_000;
57
+
58
+ // Priority tags for outstanding context items
59
+ const PRIORITY_ERROR = "[ERROR]";
60
+ const PRIORITY_WARN = "[WARN]";
61
+ const PRIORITY_INFO = "[INFO]";
62
+
63
+ /** Prepend a priority tag based on the error type and exit code. */
64
+ const priorityTag = (item: string): string => {
65
+ if (/^\[tsc\]/.test(item)) return `${PRIORITY_ERROR} ${item}`;
66
+ if (/^\[bash:exit [1-9]\d*\]/.test(item)) return `${PRIORITY_ERROR} ${item}`;
67
+ if (/^\[tests\]/.test(item)) return `${PRIORITY_WARN} ${item}`;
68
+ if (/^\[no matches\]/.test(item)) return `${PRIORITY_INFO} ${item}`;
69
+ if (/^\[user\]/.test(item)) return `${PRIORITY_WARN} ${item}`;
70
+ // Generic tool errors
71
+ if (/^\[\w+\]/.test(item)) return `${PRIORITY_ERROR} ${item}`;
72
+ return `${PRIORITY_WARN} ${item}`;
73
+ };
74
+
75
+ // Write-tool names used for resolution detection
76
+ const FILE_EDIT_TOOLS = new Set([
77
+ "Edit", "Write", "edit", "write", "MultiEdit",
78
+ ]);
79
+
80
+ /** Extract file path from a [tsc] error line like "src/auth.ts(5,18): error TS2304: ..." */
81
+ const extractTscFile = (item: string): string | null => {
82
+ const m = item.match(/^\[tsc\]\s+(\S+)\(\d+,\d+\)/);
83
+ return m ? m[1] : null;
84
+ };
85
+
86
+ /** Check if a tsc error's file was edited at a position after the error. */
87
+ const isTscResolved = (file: string, tailIdx: number, editPositions: Map<number, Set<string>>): boolean => {
88
+ for (const [pos, files] of editPositions) {
89
+ if (pos > tailIdx && files.has(file)) return true;
90
+ }
91
+ return false;
92
+ };
93
+
94
+ const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
95
+ const items: string[] = [];
96
+ const itemTailIndices: number[] = [];
97
+ const seen = new Set<string>();
98
+ const tail = blocks.slice(-25);
99
+
100
+ const push = (item: string, tailIndex?: number) => {
101
+ if (!seen.has(item)) {
102
+ seen.add(item);
103
+ items.push(item);
104
+ itemTailIndices.push(tailIndex ?? -1);
105
+ }
106
+ };
107
+
108
+ for (let bi = 0; bi < tail.length; bi++) {
109
+ const b = tail[bi];
110
+
111
+ // 1. Bash non-zero exit codes (the exitCode field is already captured but was unused)
112
+ if (b.kind === "bash" && b.exitCode !== undefined && b.exitCode !== 0) {
113
+ const cmd = b.command.split("\n").map(l => l.trim()).filter(Boolean)[0] ?? b.command;
114
+ const cmdDisplay = cmd.length > 80 ? cmd.slice(0, 77) + "..." : cmd;
115
+ const outLine = firstLine(b.output, 120);
116
+ const errTag = `exit ${b.exitCode}`;
117
+ push(`[bash:${errTag}] ${cmdDisplay}${outLine && outLine !== cmdDisplay ? ` → ${outLine}` : ""}`);
118
+ continue;
119
+ }
120
+
121
+ // 2. TypeScript compiler errors in bash output
122
+ // Scan only the first BASH_OUTPUT_SCAN_LIMIT chars — errors appear at start of output
123
+ // Now includes file path (e.g., src/auth.ts(5,18): error TS2304:) for resolution detection
124
+ if (b.kind === "bash" && b.output) {
125
+ const outputHead = b.output.slice(0, BASH_OUTPUT_SCAN_LIMIT);
126
+ if (TSC_ERROR_RE.test(outputHead)) {
127
+ const tsLines = outputHead.split("\n")
128
+ .filter(l => TSC_ERROR_RE.test(l.trim()))
129
+ .slice(0, 3);
130
+ for (const line of tsLines) {
131
+ push(`[tsc] ${clip(line.trim(), 150)}`, bi);
132
+ }
133
+ continue;
134
+ }
135
+ }
136
+
137
+ // 3. Test failures in bash output
138
+ if (b.kind === "bash" && b.output && TEST_FAIL_RE.test(b.output.slice(0, BASH_OUTPUT_SCAN_LIMIT))) {
139
+ push(`[tests] ${firstLine(b.output, 150)}`);
140
+ continue;
141
+ }
142
+
143
+ // 4. Empty grep/search results (searched for something that wasn't found = signal)
144
+ if (b.kind === "tool_result" && (b.name === "grep" || b.name === "Grep" || b.name === "Glob" || b.name === "glob")) {
145
+ const trimmed = b.text.trim();
146
+ if (EMPTY_RESULT_RE.test(trimmed) || trimmed === "") {
147
+ const prevIdx = tail.slice(0, bi).findLastIndex(
148
+ (p) => p.kind === "tool_call" && (p.name === "grep" || p.name === "Grep" || p.name === "Glob" || p.name === "glob")
149
+ );
150
+ let pattern = "";
151
+ if (prevIdx >= 0) {
152
+ const pc = tail[prevIdx];
153
+ if (pc.kind === "tool_call") {
154
+ pattern = (pc.args.pattern ?? pc.args.query ?? pc.args.glob ?? "") as string;
155
+ if (pattern) pattern = ` "${clip(pattern, 60)}"`;
156
+ }
157
+ }
158
+ push(`[no matches] ${b.name}${pattern}`);
159
+ continue;
160
+ }
161
+ }
162
+
163
+ // 5. Tool errors — classify tsc/test failures before generic catch
164
+ if (b.kind === "tool_result" && b.isError) {
165
+ // Check for tsc errors in tool result text first
166
+ if (TSC_ERROR_RE.test(b.text)) {
167
+ const tsLines = b.text.split("\n")
168
+ .filter(l => TSC_ERROR_RE.test(l.trim()))
169
+ .slice(0, 3);
170
+ for (const line of tsLines) {
171
+ push(`[tsc] ${clip(line.trim(), 150)}`, bi);
172
+ }
173
+ continue;
174
+ }
175
+ // Check for test failures
176
+ if (TEST_FAIL_RE.test(b.text)) {
177
+ push(`[tests] ${firstLine(b.text, 150)}`);
178
+ continue;
179
+ }
180
+ // Generic error fallback
181
+ push(`[${b.name}] ${firstLine(b.text, 150)}`);
182
+ continue;
183
+ }
184
+
185
+ // 6. BLOCKER_RE text matching (user/assistant mentions of problems)
186
+ if (b.kind === "assistant" || b.kind === "user") {
187
+ for (const line of nonEmptyLines(b.text)) {
188
+ if (!BLOCKER_RE.test(line)) continue;
189
+ if (line.length < 15) continue;
190
+ if (/^\s*[-*+>]\s/.test(line)) continue;
191
+ if (/^\s*\(/.test(line)) continue;
192
+ if (!/^\s*["'`*_]?[A-Z`]/.test(line)) continue;
193
+ const clipped = b.kind === "user" ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
194
+ push(clipped);
195
+ break;
196
+ }
197
+ }
198
+ }
199
+
200
+ // Resolution detection: pre-compute edit positions in the tail so we can
201
+ // check whether tsc errors were subsequently fixed by an edit to the same file.
202
+ const editPositions = new Map<number, Set<string>>();
203
+ for (let i = 0; i < tail.length; i++) {
204
+ const b = tail[i];
205
+ if (b.kind === "tool_call" && FILE_EDIT_TOOLS.has(b.name)) {
206
+ const path = extractPath(b.args);
207
+ if (path) {
208
+ if (!editPositions.has(i)) editPositions.set(i, new Set());
209
+ editPositions.get(i)!.add(path);
210
+ }
211
+ }
212
+ }
213
+
214
+ // Apply priority tags, marking resolved tsc errors as [RESOLVED]
215
+ return items.slice(0, 8).map((item, idx) => {
216
+ const tailIdx = itemTailIndices[idx] ?? -1;
217
+ const file = extractTscFile(item);
218
+ const resolved = tailIdx >= 0 && file !== null && isTscResolved(file, tailIdx, editPositions);
219
+ if (!resolved) return priorityTag(item);
220
+ const tagged = priorityTag(item);
221
+ return tagged.replace(/^\[(ERROR|WARN)\]/, "[RESOLVED]");
222
+ });
223
+ };
224
+
225
+ const formatFileActivityFromUnified = (data: import("../extract/shared-symbols").UnifiedExtractResult): string[] => {
226
+ const act = data.fileActivity;
227
+ const maxSymbolsPerFile = 4;
228
+
229
+ const cap = (set: Set<string>, limit: number) => {
230
+ const arr = [...set];
231
+ if (arr.length <= limit) return arr.join(", ");
232
+ return arr.slice(0, limit).join(", ") + ` (+${arr.length - limit} more)`;
233
+ };
234
+
235
+ const formatCategory = (label: string, set: Set<string>): string | null => {
236
+ if (set.size === 0) return null;
237
+ const arr = [...set];
238
+ const annotated: string[] = [];
239
+
240
+ for (const p of arr.slice(0, 10)) {
241
+ const syms = act.symbols.get(p);
242
+ if (syms && syms.length > 0) {
243
+ const sigs = syms.slice(0, maxSymbolsPerFile).join(", ");
244
+ const suffix = syms.length > maxSymbolsPerFile ? `, +${syms.length - maxSymbolsPerFile} more` : "";
245
+ annotated.push(`${p} (${sigs}${suffix})`);
246
+ } else {
247
+ annotated.push(p);
248
+ }
249
+ }
250
+
251
+ if (arr.length > 10) {
252
+ return `${label}: ${annotated.join(", ")} (+${arr.length - 10} more)`;
253
+ }
254
+ return `${label}: ${annotated.join(", ")}`;
255
+ };
256
+
257
+ const lines: string[] = [];
258
+ const modLine = formatCategory("Modified", act.modified);
259
+ if (modLine) lines.push(modLine);
260
+ const createLine = formatCategory("Created", act.created);
261
+ if (createLine) lines.push(createLine);
262
+ const readLine = formatCategory("Read", act.read);
263
+ if (readLine) lines.push(readLine);
264
+ return lines;
265
+ };
266
+
267
+ const formatTypeCatalogFromUnified = (data: import("../extract/shared-symbols").UnifiedExtractResult): string[] => {
268
+ const catalog = data.typeCatalog;
269
+ if (catalog.length === 0) return [];
270
+ const lines: string[] = [];
271
+ let totalSigs = 0;
272
+ const MAX_TOTAL_SIGS = 30;
273
+
274
+ for (const entry of catalog) {
275
+ if (totalSigs >= MAX_TOTAL_SIGS) {
276
+ lines.push("(more signatures omitted)");
277
+ break;
278
+ }
279
+ const tag = entry.modified ? "[modified]" : "[read]";
280
+ lines.push(`${entry.file} ${tag}:`);
281
+ for (const sig of entry.signatures) {
282
+ if (totalSigs >= MAX_TOTAL_SIGS) break;
283
+ lines.push(` ${sig}`);
284
+ totalSigs++;
285
+ }
286
+ }
287
+
288
+ return lines;
289
+ };
290
+
291
+ // User messages that are purely confirmatory — skip these for "Working on"
292
+ const CONFIRMATORY_USER_RE =
293
+ /^(ok|okay|yes|yeah|yep|sure|great|thanks|thx|nice|looks? good|works?|perfect|done|thanks!*|got it|i see|lgtm|awesome)\b/i;
294
+
295
+ /**
296
+ * Extract current working status from the tail of the conversation.
297
+ * Returns up to 3 lines: current focus, last action, next steps.
298
+ */
299
+ const extractCurrentStatus = (blocks: NormalizedBlock[]): string[] => {
300
+ const items: string[] = [];
301
+ const tail = blocks.slice(-20);
302
+
303
+ // 1. Current focus: last substantive (non-confirmatory) user message
304
+ for (let i = tail.length - 1; i >= 0; i--) {
305
+ const b = tail[i];
306
+ if (b.kind !== "user") continue;
307
+ const text = b.text.trim();
308
+ if (text.length < 10 || CONFIRMATORY_USER_RE.test(text)) continue;
309
+ items.push(`Working on: ${clip(text, 120)}`);
310
+ break;
311
+ }
312
+
313
+ // 2. Last action: last tool call that modified/read a file
314
+ for (let i = tail.length - 1; i >= 0; i--) {
315
+ const b = tail[i];
316
+ if (b.kind === "tool_call") {
317
+ const path = extractPath(b.args);
318
+ if (path) {
319
+ const cmd = b.name.length > 80 ? `${b.name.slice(0, 77)}...` : b.name;
320
+ items.push(`Last action: ${cmd} "${clip(path, 80)}"`);
321
+ break;
322
+ }
323
+ }
324
+ }
325
+
326
+ // 3. Next steps: last agent text that mentions what to do next
327
+ for (let i = tail.length - 1; i >= 0; i--) {
328
+ const b = tail[i];
329
+ if (b.kind === "assistant" && b.text.trim().length > 20) {
330
+ const nextMatch = b.text.match(/(?:next|remaining|todo|still need|what.*left|following)/i);
331
+ if (nextMatch) {
332
+ items.push(`Next: ${clip(b.text.trim(), 120)}`);
333
+ break;
334
+ }
335
+ }
336
+ }
337
+
338
+ return items.slice(0, 3);
339
+ };
340
+
341
+ /**
342
+ * Extract structured reference anchors from already-built section data.
343
+ * These let the model self-serve common lookups without calling vcc_recall.
344
+ */
345
+ const extractAnchors = (data: SectionData): string[] => {
346
+ const lines: string[] = [];
347
+
348
+ // Commit hashes
349
+ const commitHashes: string[] = [];
350
+ for (const line of data.commits) {
351
+ const hashMatch = line.match(/^-\s*([a-f0-9]{7,40}):/);
352
+ if (hashMatch) commitHashes.push(hashMatch[1]);
353
+ }
354
+ if (commitHashes.length > 0) {
355
+ lines.push(`commits: ${commitHashes.join(", ")}`);
356
+ }
357
+
358
+ // Error IDs from outstanding context
359
+ const errorIds: string[] = [];
360
+ for (const line of data.outstandingContext) {
361
+ const tscMatch = line.match(/TS(\d{4,5})/);
362
+ if (tscMatch) errorIds.push(`TS${tscMatch[1]}`);
363
+ }
364
+ if (errorIds.length > 0) {
365
+ lines.push(`errors: ${[...new Set(errorIds)].join(", ")}`);
366
+ }
367
+
368
+ // Key file paths from Files And Changes
369
+ const filePaths: string[] = [];
370
+ for (const line of data.filesAndChanges) {
371
+ const categoryMatch = line.match(/^-\s*(?:Modified|Created|Read):\s*(.*)/);
372
+ if (!categoryMatch) continue;
373
+ const pathPart = categoryMatch[1]
374
+ .replace(/\s*\([^)]*\)/g, "")
375
+ .replace(/\s*\(\+\d+ more\)\s*$/, "");
376
+ for (const p of pathPart.split(",")) {
377
+ const trimmed = p.trim();
378
+ if (trimmed) filePaths.push(trimmed);
379
+ }
380
+ }
381
+ if (filePaths.length > 0) {
382
+ const display = filePaths.length <= 15
383
+ ? filePaths.join(", ")
384
+ : `${filePaths.slice(0, 12).join(", ")} (+${filePaths.length - 12} more)`;
385
+ lines.push(`files: ${display}`);
386
+ }
387
+
388
+ return lines;
389
+ };
390
+
391
+ export const buildSections = (input: BuildSectionsInput): SectionData => {
392
+ const { blocks } = input;
393
+ // Build tool-call → tool-result look-ahead index once, share across extractors.
394
+ const tri = input.toolResultIndex ?? buildToolResultIndex(blocks);
395
+
396
+ // Single-pass file and symbol extraction — replaces the triple-redundant
397
+ // scan that extractFiles / extractSymbolChanges / extractTypeCatalog each
398
+ // performed independently, each re-scanning the same tool results with
399
+ // overlapping regex patterns.
400
+ const fileAndSymbols = extractFileAndSymbolData(blocks, tri);
401
+
402
+ const briefSections = buildBriefSections(blocks);
403
+ const sessionGoal = extractGoals(blocks);
404
+ const userPreferences = dedupPreferencesAgainstGoals(
405
+ extractPreferences(blocks),
406
+ sessionGoal,
407
+ );
408
+
409
+ const turnSummaries = identifyTurns(blocks).map(t => t.summary);
410
+ const outstandingContext = extractOutstandingContext(blocks);
411
+
412
+ const result: SectionData = {
413
+ sessionGoal,
414
+ outstandingContext,
415
+ filesAndChanges: formatFileActivityFromUnified(fileAndSymbols),
416
+ commits: formatCommits(extractCommits(blocks)),
417
+ userPreferences,
418
+ typeCatalog: formatTypeCatalogFromUnified(fileAndSymbols),
419
+ symbolChanges: fileAndSymbols.symbolChanges,
420
+ currentStatus: extractCurrentStatus(blocks),
421
+ turnSummaries,
422
+ anchors: [], // populated after initial section data is built
423
+ briefTranscript: stringifyBrief(briefSections),
424
+ transcriptEntries: sectionsToTranscript(briefSections),
425
+ };
426
+
427
+ // Anchors depend on other sections, so extract after building them
428
+ result.anchors = extractAnchors(result);
429
+
430
+ return result;
431
+ };
@@ -0,0 +1,71 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+
3
+ export const clip = (text: string, max = 200): string => {
4
+ if (text.length <= max) return text;
5
+ // Try to cut at a word boundary
6
+ const cut = text.lastIndexOf(" ", max);
7
+ let end = cut > max * 0.6 ? cut : max;
8
+ // Avoid splitting a surrogate pair
9
+ if (end > 0 && end < text.length) {
10
+ const code = text.charCodeAt(end - 1);
11
+ if (code >= 0xd800 && code <= 0xdbff) end--;
12
+ }
13
+ return text.slice(0, end);
14
+ };
15
+
16
+ /**
17
+ * Clip text to last sentence boundary at or before `max` chars.
18
+ * Falls back to word boundary (clip()) if no sentence end is found in the
19
+ * acceptable range. Trailing whitespace stripped.
20
+ */
21
+ export const clipSentence = (text: string, max = 200): string => {
22
+ if (text.length <= max) return text;
23
+ // Look for sentence terminators followed by space/newline within [max*0.5, max]
24
+ const window = text.slice(0, max);
25
+ const matches = [...window.matchAll(/[.!?](?:\s|$)/g)];
26
+ if (matches.length > 0) {
27
+ const last = matches[matches.length - 1];
28
+ const end = (last.index ?? 0) + 1; // include the punctuation
29
+ if (end >= max * 0.5) return text.slice(0, end);
30
+ }
31
+ return clip(text, max);
32
+ };
33
+
34
+ export const nonEmptyLines = (text: string): string[] =>
35
+ text.split("\n").map((line) => line.trim()).filter(Boolean);
36
+
37
+ export const firstLine = (text: string, max = 200): string =>
38
+ clip(text.split("\n")[0] ?? "", max);
39
+
40
+ const textParts = (content: Message["content"]): string[] => {
41
+ if (!content) return [];
42
+ if (typeof content === "string") return [content];
43
+ return content
44
+ .filter((part) => part.type === "text")
45
+ .map((part) => part.text);
46
+ };
47
+
48
+ export const textOf = (content: Message["content"]): string =>
49
+ textParts(content).join("\n");
50
+
51
+ const thinkingParts = (content: Message["content"]): string[] => {
52
+ if (!content) return [];
53
+ if (typeof content === "string") return [];
54
+ return content
55
+ .filter((part) => part.type === "thinking")
56
+ .map((part) => part.thinking ?? "");
57
+ };
58
+
59
+ export const thinkingOf = (content: Message["content"]): string =>
60
+ thinkingParts(content).join("\n");
61
+
62
+ /** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
63
+ const snippet = (text: string, term: string, radius = 60): string | null => {
64
+ const idx = text.toLowerCase().indexOf(term.toLowerCase());
65
+ if (idx === -1) return null;
66
+ const start = Math.max(0, idx - radius);
67
+ const end = Math.min(text.length, idx + term.length + radius);
68
+ const prefix = start > 0 ? "..." : "";
69
+ const suffix = end < text.length ? "..." : "";
70
+ return `${prefix}${text.slice(start, end)}${suffix}`;
71
+ };
@@ -0,0 +1,42 @@
1
+ import type { NormalizedBlock } from "../types";
2
+
3
+ const NOISE_TOOLS = new Set([
4
+ "TodoWrite", "TodoRead", "ToolSearch", "WebSearch",
5
+ "AskUser", "ExitSpecMode", "GenerateDroid",
6
+ ]);
7
+
8
+ const NOISE_STRINGS = [
9
+ "Continue from where you left off.",
10
+ "No response requested.",
11
+ "IMPORTANT: TodoWrite was not called yet.",
12
+ ];
13
+
14
+ const XML_WRAPPER_RE = /<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
15
+
16
+ const isNoiseUserBlock = (text: string): boolean => {
17
+ const trimmed = text.trim();
18
+ if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return true;
19
+ const stripped = trimmed.replace(XML_WRAPPER_RE, "").trim();
20
+ return stripped.length === 0;
21
+ };
22
+
23
+ const cleanUserText = (text: string): string =>
24
+ text.replace(XML_WRAPPER_RE, "").trim();
25
+
26
+ export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
27
+ const out: NormalizedBlock[] = [];
28
+ for (const b of blocks) {
29
+ if (b.kind === "thinking") continue;
30
+ if (b.kind === "tool_call" && NOISE_TOOLS.has(b.name)) continue;
31
+ if (b.kind === "tool_result" && NOISE_TOOLS.has(b.name)) continue;
32
+ if (b.kind === "user") {
33
+ if (isNoiseUserBlock(b.text)) continue;
34
+ const cleaned = cleanUserText(b.text);
35
+ if (!cleaned) continue;
36
+ out.push({ kind: "user", text: cleaned });
37
+ continue;
38
+ }
39
+ out.push(b);
40
+ }
41
+ return out;
42
+ };