@monotykamary/pi-vcc 0.3.16 → 0.4.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/README.md +5 -4
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/src/core/build-sections.ts +12 -131
- package/src/core/format.ts +4 -14
- package/src/core/summarize.ts +98 -22
- package/src/hooks/before-compact.ts +16 -28
- package/src/sections.ts +0 -4
- package/stress-debug.ts +202 -0
- package/stress-degradation-curve.ts +251 -0
- package/tests/breadcrumbs.test.ts +312 -0
- package/tests/deep-extraction.test.ts +28 -19
- package/tests/model-threshold-hook.test.ts +60 -126
- package/tests/stress-100-compactions.test.ts +373 -0
- package/tests/threshold-integration.test.ts +26 -50
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ pi-vcc is one of four compaction approaches in the AI coding-agent ecosystem. He
|
|
|
210
210
|
- **Error resolution detection** — tsc errors in `[Outstanding Context]` are tagged `[RESOLVED]` when the file they reference was subsequently edited, letting the model skip stale errors.
|
|
211
211
|
- **Task-boundary-aware cut** — compaction splits at complete conversational turns, not mid-tool-call. If the assistant's response is in-flight (unmatched tool calls), the cut pushes back to keep the whole turn in the tail.
|
|
212
212
|
- **Structured anchors** — `[Anchors]` section lists commit hashes, error IDs, and key file paths for zero-tool-call recall. The model can find references at a glance instead of calling `vcc_recall`.
|
|
213
|
-
- **Per-model and global compaction thresholds** — configure different `reserveTokens` or `compactPercent` per model and globally, so models with different context windows compact at the right time.
|
|
213
|
+
- **Per-model and global compaction thresholds** — configure different `reserveTokens` or `compactPercent` per model and globally, so models with different context windows compact at the right time. Proactive triggering on `agent_end` and `model_select` events compacts earlier for small-context models. Applies to both pi-vcc and pi-core compaction.
|
|
214
214
|
|
|
215
215
|
## Install
|
|
216
216
|
|
|
@@ -579,14 +579,15 @@ Config lives at `~/.pi/agent/pi-vcc-config.json` (auto-scaffolded on first load
|
|
|
579
579
|
|
|
580
580
|
Pi-core's auto-compaction triggers when `contextTokens > contextWindow − reserveTokens`. The global `reserveTokens` (default 16384) is one-size-fits-all — but different models have very different context windows and cost profiles.
|
|
581
581
|
|
|
582
|
-
Pi-vcc's thresholds
|
|
582
|
+
Pi-vcc's thresholds provide proactive compaction at both the per-model and global level:
|
|
583
583
|
|
|
584
584
|
| Direction | How it works |
|
|
585
585
|
|---|---|
|
|
586
|
-
| **Compact later** (model can handle more context) | `session_before_compact` cancels compaction when context hasn't crossed the model's threshold. The global threshold might trigger compaction prematurely for a model with a large context window. |
|
|
587
586
|
| **Compact earlier** (model needs compaction sooner) | `agent_end` and `model_select` proactively trigger compaction when context exceeds the model's threshold but hasn't hit the global threshold yet. The `globalThreshold` also proactively triggers for unmatched models. |
|
|
588
587
|
|
|
589
|
-
|
|
588
|
+
Previously, a "compact later" direction was implemented by cancelling compaction in `session_before_compact` when context was below the per-model threshold. This guard was removed because `session_before_compact` carries no reason field — manual `/compact` and auto-compaction are indistinguishable (both have `customInstructions: undefined`), so the guard was blocking explicit user compaction requests.
|
|
589
|
+
|
|
590
|
+
The proactive trigger handles the "compact earlier" direction. If pi-core's global threshold fires before the per-model threshold is crossed, the compaction proceeds — slightly premature from the per-model threshold's perspective, but preferable to blocking an explicit user action.
|
|
590
591
|
|
|
591
592
|
Key matching order: exact `"provider/modelId"` → `"modelId"` → `globalThreshold` → pi-core's global setting.
|
|
592
593
|
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@monotykamary/pi-vcc",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.17",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@monotykamary/pi-vcc",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.17",
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"knip": "6.14.1",
|
|
12
12
|
"typescript": "6.0.3"
|
package/package.json
CHANGED
|
@@ -224,34 +224,16 @@ const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
|
|
|
224
224
|
|
|
225
225
|
const formatFileActivityFromUnified = (data: import("../extract/shared-symbols").UnifiedExtractResult): string[] => {
|
|
226
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
227
|
const formatCategory = (label: string, set: Set<string>): string | null => {
|
|
236
228
|
if (set.size === 0) return null;
|
|
237
229
|
const arr = [...set];
|
|
238
|
-
const
|
|
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
|
-
}
|
|
230
|
+
const kept = arr.slice(0, 10);
|
|
250
231
|
|
|
251
232
|
if (arr.length > 10) {
|
|
252
|
-
|
|
233
|
+
const omitted = arr.slice(10);
|
|
234
|
+
return `${label}: ${kept.join(", ")}, +recall: ${omitted.join(", ")}`;
|
|
253
235
|
}
|
|
254
|
-
return `${label}: ${
|
|
236
|
+
return `${label}: ${kept.join(", ")}`;
|
|
255
237
|
};
|
|
256
238
|
|
|
257
239
|
const lines: string[] = [];
|
|
@@ -271,118 +253,22 @@ const formatTypeCatalogFromUnified = (data: import("../extract/shared-symbols").
|
|
|
271
253
|
let totalSigs = 0;
|
|
272
254
|
const MAX_TOTAL_SIGS = 30;
|
|
273
255
|
|
|
274
|
-
|
|
256
|
+
const omittedFiles: string[] = [];
|
|
257
|
+
for (let i = 0; i < catalog.length; i++) {
|
|
258
|
+
const entry = catalog[i];
|
|
275
259
|
if (totalSigs >= MAX_TOTAL_SIGS) {
|
|
276
|
-
|
|
277
|
-
|
|
260
|
+
omittedFiles.push(entry.file);
|
|
261
|
+
continue;
|
|
278
262
|
}
|
|
279
|
-
|
|
280
|
-
lines.push(`${entry.file} ${tag}:`);
|
|
263
|
+
lines.push(`${entry.file}:`);
|
|
281
264
|
for (const sig of entry.signatures) {
|
|
282
265
|
if (totalSigs >= MAX_TOTAL_SIGS) break;
|
|
283
266
|
lines.push(` ${sig}`);
|
|
284
267
|
totalSigs++;
|
|
285
268
|
}
|
|
286
269
|
}
|
|
287
|
-
|
|
288
|
-
|
|
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}`);
|
|
270
|
+
if (omittedFiles.length > 0) {
|
|
271
|
+
lines.push(`(${omittedFiles.length} more files with signatures omitted)`);
|
|
386
272
|
}
|
|
387
273
|
|
|
388
274
|
return lines;
|
|
@@ -417,15 +303,10 @@ export const buildSections = (input: BuildSectionsInput): SectionData => {
|
|
|
417
303
|
userPreferences,
|
|
418
304
|
typeCatalog: formatTypeCatalogFromUnified(fileAndSymbols),
|
|
419
305
|
symbolChanges: fileAndSymbols.symbolChanges,
|
|
420
|
-
currentStatus: extractCurrentStatus(blocks),
|
|
421
306
|
turnSummaries,
|
|
422
|
-
anchors: [], // populated after initial section data is built
|
|
423
307
|
briefTranscript: stringifyBrief(briefSections),
|
|
424
308
|
transcriptEntries: sectionsToTranscript(briefSections),
|
|
425
309
|
};
|
|
426
310
|
|
|
427
|
-
// Anchors depend on other sections, so extract after building them
|
|
428
|
-
result.anchors = extractAnchors(result);
|
|
429
|
-
|
|
430
311
|
return result;
|
|
431
312
|
};
|
package/src/core/format.ts
CHANGED
|
@@ -43,20 +43,15 @@ export const capBrief = (text: string): string => {
|
|
|
43
43
|
// Find first section header to avoid cutting mid-section
|
|
44
44
|
const firstHeader = kept.findIndex((l) => /^\[.+\]/.test(l));
|
|
45
45
|
const clean = firstHeader > 0 ? kept.slice(firstHeader) : kept;
|
|
46
|
-
|
|
46
|
+
const crumbLine = `...(${omitted} earlier lines omitted)`;
|
|
47
|
+
return `${crumbLine}\n\n${clean.join("\n")}`;
|
|
47
48
|
};
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
"Use `vcc_recall` to search for prior work, decisions, and context from before this summary. " +
|
|
51
|
-
"Do not redo work already completed.";
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Format the summary with cache-friendly section ordering.
|
|
50
|
+
/** Format the summary with cache-friendly section ordering.
|
|
55
51
|
*
|
|
56
52
|
* Stable (merged/accumulated) sections come first so the prompt prefix
|
|
57
53
|
* stays cacheable across compactions. Volatile (always-fresh) sections
|
|
58
|
-
* come last.
|
|
59
|
-
*/
|
|
54
|
+
* come last. */
|
|
60
55
|
export const formatSummary = (
|
|
61
56
|
data: SectionData,
|
|
62
57
|
): string => {
|
|
@@ -66,14 +61,12 @@ export const formatSummary = (
|
|
|
66
61
|
section("User Preferences", data.userPreferences),
|
|
67
62
|
section("Files And Changes", data.filesAndChanges),
|
|
68
63
|
section("Commits", data.commits),
|
|
69
|
-
section("Anchors", data.anchors),
|
|
70
64
|
].filter(Boolean);
|
|
71
65
|
|
|
72
66
|
const volatileSections = [
|
|
73
67
|
section("Type Catalog", data.typeCatalog),
|
|
74
68
|
section("Outstanding Context", data.outstandingContext),
|
|
75
69
|
section("Earlier Turns", data.turnSummaries),
|
|
76
|
-
section("Current Status", data.currentStatus),
|
|
77
70
|
].filter(Boolean);
|
|
78
71
|
|
|
79
72
|
// All header sections (stable + volatile) form the header block
|
|
@@ -91,8 +84,5 @@ export const formatSummary = (
|
|
|
91
84
|
|
|
92
85
|
let result = wrapLongLines(parts.join("\n\n---\n\n"));
|
|
93
86
|
|
|
94
|
-
// NOTE: RECALL_NOTE is intentionally NOT appended here.
|
|
95
|
-
// It is appended once by `compile()` at the very end, after merge-with-previous,
|
|
96
|
-
// to avoid the note compounding inside the brief transcript across compactions.
|
|
97
87
|
return result;
|
|
98
88
|
};
|
package/src/core/summarize.ts
CHANGED
|
@@ -3,7 +3,7 @@ import type { FileOps } from "../types";
|
|
|
3
3
|
import { normalize } from "./normalize";
|
|
4
4
|
import { filterNoise } from "./filter-noise";
|
|
5
5
|
import { buildSections } from "./build-sections";
|
|
6
|
-
import { formatSummary, capBrief,
|
|
6
|
+
import { formatSummary, capBrief, wrapLongLines } from "./format";
|
|
7
7
|
|
|
8
8
|
export interface CompileInput {
|
|
9
9
|
messages: Message[];
|
|
@@ -17,11 +17,9 @@ const HEADER_NAMES = [
|
|
|
17
17
|
"User Preferences",
|
|
18
18
|
"Files And Changes",
|
|
19
19
|
"Commits",
|
|
20
|
-
"Anchors",
|
|
21
20
|
"Type Catalog",
|
|
22
21
|
"Outstanding Context",
|
|
23
22
|
"Earlier Turns",
|
|
24
|
-
"Current Status",
|
|
25
23
|
];
|
|
26
24
|
|
|
27
25
|
const SEPARATOR = "\n\n---\n\n";
|
|
@@ -31,7 +29,8 @@ const HANDFOFF_PREAMBLE =
|
|
|
31
29
|
"This summary captures work done before the most recent messages in this session. " +
|
|
32
30
|
"Read it to pick up context — this is work already in progress. " +
|
|
33
31
|
"Do not recap what was done, do not ask what to do next. " +
|
|
34
|
-
"Continue directly where you left off."
|
|
32
|
+
"Continue directly where you left off. " +
|
|
33
|
+
"Use `vcc_recall` to search for prior work, decisions, and context from before this summary.";
|
|
35
34
|
|
|
36
35
|
/** Extract a named section from summary text */
|
|
37
36
|
const sectionOf = (text: string, header: string): string => {
|
|
@@ -57,27 +56,67 @@ const briefOf = (text: string): string => {
|
|
|
57
56
|
return text.slice(idx + SEPARATOR.length).trim();
|
|
58
57
|
};
|
|
59
58
|
|
|
59
|
+
/**
|
|
60
|
+
* Extract searchable keywords from a section line for breadcrumb trails.
|
|
61
|
+
*
|
|
62
|
+
* Deterministic: same input always produces the same breadcrumb.
|
|
63
|
+
* Idempotent: the breadcrumb is a pure function of the line text.
|
|
64
|
+
*/
|
|
65
|
+
const extractBreadcrumb = (line: string): string => {
|
|
66
|
+
const text = line.replace(/^\s*-\s*/, "").trim();
|
|
67
|
+
if (!text) return "";
|
|
68
|
+
// "edited auth.ts" → "auth.ts"
|
|
69
|
+
const fileMatch = text.match(/(?:edited |read |wrote |created |deleted )?(\S+\.\w{1,12})/);
|
|
70
|
+
if (fileMatch) return fileMatch[1];
|
|
71
|
+
// "Fix login bug → ..." → first few words before →
|
|
72
|
+
const beforeArrow = text.split("\u2192")[0].trim();
|
|
73
|
+
const words = beforeArrow.split(/\s+/).filter(w => w.length > 2).slice(0, 3);
|
|
74
|
+
if (words.length > 0) return words.join(" ");
|
|
75
|
+
// fallback: first content word
|
|
76
|
+
const first = text.split(/\s+/).find(w => w.length > 2);
|
|
77
|
+
return first ?? "";
|
|
78
|
+
};
|
|
79
|
+
|
|
60
80
|
/** Merge a header section */
|
|
61
81
|
const mergeHeaderSection = (header: string, prev: string, fresh: string): string => {
|
|
62
|
-
// Outstanding Context, Type Catalog
|
|
63
|
-
if (header === "Outstanding Context" || header === "Type Catalog"
|
|
64
|
-
if (!prev) return
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
// Files And Changes: merge by category (Modified/Created/Read), dedup paths
|
|
82
|
+
// Outstanding Context, Type Catalog are volatile -- always use fresh only
|
|
83
|
+
if (header === "Outstanding Context" || header === "Type Catalog") return fresh;
|
|
84
|
+
if (!prev && !fresh) return "";
|
|
85
|
+
// Files And Changes: always run through merge (even when only prev
|
|
86
|
+
// has content) so the cap +recall breadcrumbs are applied.
|
|
68
87
|
if (header === "Files And Changes") {
|
|
69
88
|
return mergeFileLines(prev, fresh);
|
|
70
89
|
}
|
|
71
90
|
|
|
72
|
-
// Session Goal, User Preferences: line-level dedup, cap
|
|
91
|
+
// Session Goal, User Preferences, Commits, Earlier Turns: line-level dedup, cap
|
|
73
92
|
const isClean = (l: string) => l.startsWith("- ") && !l.includes("<skill") && !l.includes("</skill");
|
|
93
|
+
// ...recall: breadcrumb lines from prior compactions are preserved as-is,
|
|
94
|
+
// not re-processed or capped. They survive across compactions.
|
|
95
|
+
const isRecallBreadcrumb = (l: string) => l.startsWith("- ...recall:");
|
|
74
96
|
const prevLines = prev.split("\n").filter(isClean);
|
|
75
97
|
const freshLines = fresh.split("\n").filter(isClean);
|
|
76
|
-
const
|
|
98
|
+
const prevBreadcrumbs = prev.split("\n").filter(isRecallBreadcrumb);
|
|
99
|
+
const freshBreadcrumbs = fresh.split("\n").filter(isRecallBreadcrumb);
|
|
100
|
+
const allBreadcrumbs = [...new Set([...prevBreadcrumbs, ...freshBreadcrumbs])];
|
|
101
|
+
const contentLines = [...new Set([...prevLines.filter(l => !isRecallBreadcrumb(l)), ...freshLines.filter(l => !isRecallBreadcrumb(l))])];
|
|
77
102
|
const CAP = header === "Session Goal" ? 8 : header === "Commits" ? 8 : header === "Earlier Turns" ? 15 : 15;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
103
|
+
if (contentLines.length > CAP) {
|
|
104
|
+
const kept = contentLines.slice(-CAP);
|
|
105
|
+
const dropped = contentLines.slice(0, contentLines.length - CAP);
|
|
106
|
+
const crumbs = dropped.map(extractBreadcrumb).filter(Boolean);
|
|
107
|
+
const headerLine = `[${header}]`;
|
|
108
|
+
// Merge new crumbs with existing breadcrumbs (dedup by extracting terms)
|
|
109
|
+
const allCrumbs = crumbs.length > 0 ? [...allBreadcrumbs, `- ...recall: ${crumbs.join(", ")}`] : allBreadcrumbs;
|
|
110
|
+
if (allCrumbs.length > 0) {
|
|
111
|
+
return `${headerLine}\n${allCrumbs.join("\n")}\n${kept.join("\n")}`;
|
|
112
|
+
}
|
|
113
|
+
return `${headerLine}\n${kept.join("\n")}`;
|
|
114
|
+
}
|
|
115
|
+
if (contentLines.length === 0 && allBreadcrumbs.length === 0) return "";
|
|
116
|
+
const parts: string[] = [];
|
|
117
|
+
if (allBreadcrumbs.length > 0) parts.push(...allBreadcrumbs);
|
|
118
|
+
if (contentLines.length > 0) parts.push(...contentLines);
|
|
119
|
+
return `[${header}]\n${parts.join("\n")}`;
|
|
81
120
|
};
|
|
82
121
|
|
|
83
122
|
/** Merge Files And Changes by category, dedup paths across compactions */
|
|
@@ -88,7 +127,9 @@ const mergeFileLines = (prev: string, fresh: string): string => {
|
|
|
88
127
|
|
|
89
128
|
// Parse "- Modified: a, b, c (+N more)" lines from both prev and fresh
|
|
90
129
|
// Also handle symbol-annotated format: "- Modified: a (fn1, fn2), b"
|
|
130
|
+
// Also handle breadcrumb format: "path1, +recall: path2, path3"
|
|
91
131
|
for (const text of [prev, fresh]) {
|
|
132
|
+
if (!text) continue;
|
|
92
133
|
for (const line of text.split("\n")) {
|
|
93
134
|
for (const cat of categories) {
|
|
94
135
|
const prefix = `- ${cat}: `;
|
|
@@ -98,8 +139,12 @@ const mergeFileLines = (prev: string, fresh: string): string => {
|
|
|
98
139
|
rest = rest.replace(/\s*\([^)]*\)/g, "");
|
|
99
140
|
// Strip "(+N more)" suffix
|
|
100
141
|
rest = rest.replace(/\s*\(\+\d+ more\)\s*$/, "");
|
|
142
|
+
// Strip breadcrumb marker so paths after it are parsed
|
|
143
|
+
rest = rest.replace(/,\s*\+recall:\s*/, ", ");
|
|
101
144
|
for (const p of rest.split(",")) {
|
|
102
145
|
const trimmed = p.trim();
|
|
146
|
+
// Skip breadcrumb markers that survived the replace
|
|
147
|
+
if (trimmed.startsWith("+recall:")) continue;
|
|
103
148
|
if (trimmed) merged[cat].add(trimmed);
|
|
104
149
|
}
|
|
105
150
|
}
|
|
@@ -112,7 +157,9 @@ const mergeFileLines = (prev: string, fresh: string): string => {
|
|
|
112
157
|
const cap = (set: Set<string>, limit: number) => {
|
|
113
158
|
const arr = [...set];
|
|
114
159
|
if (arr.length <= limit) return arr.join(", ");
|
|
115
|
-
|
|
160
|
+
const kept = arr.slice(0, limit);
|
|
161
|
+
const omitted = arr.slice(limit);
|
|
162
|
+
return kept.join(", ") + `, +recall: ${omitted.join(", ")}`;
|
|
116
163
|
};
|
|
117
164
|
|
|
118
165
|
const lines: string[] = [];
|
|
@@ -166,14 +213,43 @@ export const compile = (input: CompileInput): string => {
|
|
|
166
213
|
: undefined;
|
|
167
214
|
const merged = prev ? mergePrevious(prev, fresh) : fresh;
|
|
168
215
|
if (!merged) return "";
|
|
169
|
-
const body = merged
|
|
216
|
+
const body = merged;
|
|
170
217
|
return wrapLongLines(HANDFOFF_PREAMBLE + "\n\n" + body);
|
|
171
218
|
};
|
|
172
219
|
|
|
173
220
|
const stripRecallNote = (text: string): string => {
|
|
174
|
-
//
|
|
175
|
-
//
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
221
|
+
// Strip leading preamble (HANDFOFF_PREAMBLE) that compile() prepends to every output.
|
|
222
|
+
// When the output from one compaction becomes the previousSummary for the next,
|
|
223
|
+
// the preamble must be removed so mergePrevious only sees the actual sections.
|
|
224
|
+
//
|
|
225
|
+
// Also handles legacy format where RECALL_NOTE was a trailing --- block
|
|
226
|
+
// (before the preamble merge). Both shapes can appear in the same text
|
|
227
|
+
// (old format had a leading preamble AND a trailing RECALL_NOTE). Both must
|
|
228
|
+
// be stripped so the merge only sees clean section data.
|
|
229
|
+
|
|
230
|
+
let result = text;
|
|
231
|
+
|
|
232
|
+
// 1. Leading preamble (current format): "This summary captures..."
|
|
233
|
+
if (result.startsWith("This summary captures work done before")) {
|
|
234
|
+
// Find the end of the preamble paragraph (first section header or double newline)
|
|
235
|
+
const headerStart = result.indexOf("[");
|
|
236
|
+
if (headerStart > 0) {
|
|
237
|
+
result = result.slice(headerStart).trim();
|
|
238
|
+
} else {
|
|
239
|
+
const doubleNL = result.indexOf("\n\n");
|
|
240
|
+
if (doubleNL > 0) result = result.slice(doubleNL + 2).trim();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// 2. Trailing legacy RECALL_NOTE (pre-preamble-merge format)
|
|
245
|
+
// Old format appended RECALL_NOTE as a separate --- block at the end.
|
|
246
|
+
// After step 1 strips the leading preamble, the trailing block survives.
|
|
247
|
+
// Must be stripped so it doesn't get merged into the brief transcript.
|
|
248
|
+
const legacyRecall = "Use `vcc_recall` to search for prior work, decisions, and context from before this summary.";
|
|
249
|
+
const legacyIdx = result.lastIndexOf(legacyRecall);
|
|
250
|
+
if (legacyIdx > 0) {
|
|
251
|
+
result = result.slice(0, legacyIdx).replace(/\s*(?:\n\n---\n\n)?\s*$/, "").trimEnd();
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return result;
|
|
179
255
|
};
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { writeFileSync } from "fs";
|
|
3
3
|
import { compile, type CompileInput } from "../core/summarize";
|
|
4
|
-
import { loadSettings,
|
|
5
|
-
import { isProactiveTriggerActive } from "./proactive-threshold";
|
|
4
|
+
import { loadSettings, type PiVccSettings } from "../core/settings";
|
|
6
5
|
import { triggerInvisibleContinue } from "../core/invisible-continue";
|
|
7
6
|
import type { PiVccCompactionDetails } from "../details";
|
|
8
7
|
|
|
@@ -279,34 +278,23 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
|
279
278
|
const { preparation, branchEntries, customInstructions } = event;
|
|
280
279
|
const settings = loadSettings();
|
|
281
280
|
|
|
282
|
-
// Per-model threshold
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
281
|
+
// Per-model threshold guard was previously applied here, cancelling
|
|
282
|
+
// compaction when context was below the per-model threshold. This
|
|
283
|
+
// blocked manual /compact from working because session_before_compact
|
|
284
|
+
// carries no "reason" field — manual and auto compactions are
|
|
285
|
+
// indistinguishable (both have customInstructions: undefined).
|
|
286
286
|
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
287
|
+
// The per-model threshold is now served entirely by the proactive
|
|
288
|
+
// trigger (in proactive-threshold.ts), which fires on agent_end /
|
|
289
|
+
// model_select when the per-model threshold is crossed. This correctly
|
|
290
|
+
// compacts earlier than pi-core's global threshold without needing to
|
|
291
|
+
// cancel any compaction here.
|
|
292
|
+
//
|
|
293
|
+
// If pi-core's global threshold fires before the per-model threshold
|
|
294
|
+
// is crossed, the compaction proceeds — slightly premature from the
|
|
295
|
+
// per-model threshold's perspective, but this is preferable to blocking
|
|
296
|
+
// an explicit user action (/compact).
|
|
289
297
|
const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
|
|
290
|
-
if (!isPiVcc && !isProactiveTriggerActive()) {
|
|
291
|
-
const threshold = getModelThreshold(settings, ctx.model);
|
|
292
|
-
if (threshold) {
|
|
293
|
-
const contextWindow = ctx.model?.contextWindow ?? 0;
|
|
294
|
-
const reserve = resolveReserveTokens(threshold, contextWindow);
|
|
295
|
-
if (reserve != null && contextWindow > 0) {
|
|
296
|
-
const effectiveThreshold = contextWindow - reserve;
|
|
297
|
-
if (preparation.tokensBefore <= effectiveThreshold) {
|
|
298
|
-
try {
|
|
299
|
-
const pct = Math.round((preparation.tokensBefore / contextWindow) * 100);
|
|
300
|
-
ctx?.ui?.notify?.(
|
|
301
|
-
`pi-vcc: Skipped compaction — ${pct}% of context window used (threshold: ${formatTokens(effectiveThreshold)} tok)`,
|
|
302
|
-
"info",
|
|
303
|
-
);
|
|
304
|
-
} catch {}
|
|
305
|
-
return { cancel: true };
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
298
|
|
|
311
299
|
// Always handle explicit /pi-vcc marker.
|
|
312
300
|
// Otherwise, only handle when user opted in via settings.
|
package/src/sections.ts
CHANGED
|
@@ -11,12 +11,8 @@ export interface SectionData {
|
|
|
11
11
|
typeCatalog: string[];
|
|
12
12
|
/** Symbol-level changes (function/type/class names per file) */
|
|
13
13
|
symbolChanges: SymbolRef[];
|
|
14
|
-
/** Current working context: what's being worked on, last action, next steps */
|
|
15
|
-
currentStatus: string[];
|
|
16
14
|
/** Per-turn one-liner summaries for the HCA zone (heaviest compression, oldest turns) */
|
|
17
15
|
turnSummaries: string[];
|
|
18
|
-
/** Structured reference anchors for zero-tool-call recall */
|
|
19
|
-
anchors: string[];
|
|
20
16
|
briefTranscript: string;
|
|
21
17
|
/** Structured transcript entries (verbose object format) */
|
|
22
18
|
transcriptEntries: TranscriptEntry[];
|