@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.
- package/README.md +688 -0
- package/__tests__/helpers.ts +135 -0
- package/__tests__/root-causes.test.ts +858 -0
- package/demo.gif +0 -0
- package/index.ts +21 -0
- package/knip.json +4 -0
- package/npm-shrinkwrap.json +3001 -0
- package/package.json +36 -0
- package/scripts/audit-sessions.ts +88 -0
- package/scripts/benchmark-real-sessions.ts +25 -0
- package/scripts/compare-before-after.ts +36 -0
- package/scripts/dump-branch-output.ts +20 -0
- package/src/commands/pi-vcc.ts +36 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +497 -0
- package/src/core/build-sections.ts +431 -0
- package/src/core/content.ts +71 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +177 -0
- package/src/core/format.ts +98 -0
- package/src/core/invisible-continue.ts +172 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +40 -0
- package/src/core/normalize.ts +73 -0
- package/src/core/recall-scope.ts +49 -0
- package/src/core/render-entries.ts +58 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +375 -0
- package/src/core/settings.ts +190 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +179 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +19 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/shared-symbols.ts +357 -0
- package/src/hooks/before-compact.ts +564 -0
- package/src/hooks/proactive-threshold.ts +131 -0
- package/src/sections.ts +23 -0
- package/src/tools/recall.ts +204 -0
- package/src/types.ts +20 -0
- package/tests/australian-government.test.ts +479 -0
- package/tests/before-compact-hook.test.ts +185 -0
- package/tests/before-compact.test.ts +227 -0
- package/tests/brief.test.ts +215 -0
- package/tests/build-sections.test.ts +59 -0
- package/tests/compile.test.ts +89 -0
- package/tests/content.test.ts +66 -0
- package/tests/deep-extraction.test.ts +313 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +87 -0
- package/tests/format.test.ts +73 -0
- package/tests/government-domain.test.ts +542 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +49 -0
- package/tests/model-threshold-hook.test.ts +377 -0
- package/tests/model-threshold.test.ts +223 -0
- package/tests/normalize.test.ts +104 -0
- package/tests/proactive-threshold.test.ts +490 -0
- package/tests/real-sessions.test.ts +38 -0
- package/tests/recall-expand.test.ts +15 -0
- package/tests/recall-scope.test.ts +32 -0
- package/tests/recall-tool-scope.test.ts +67 -0
- package/tests/render-entries.test.ts +92 -0
- package/tests/report.test.ts +58 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +233 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tests/threshold-integration.test.ts +359 -0
- 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
|
+
};
|