ultimate-pi 0.4.0 → 0.5.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/.agents/skills/harness-decisions/SKILL.md +15 -0
- package/.agents/skills/scrapling-web/SKILL.md +45 -40
- package/.agents/skills/wiki-autoresearch/SKILL.md +3 -3
- package/.pi/PACKAGING.md +3 -2
- package/.pi/SYSTEM.md +12 -13
- package/.pi/agents/pi-pi/agent-expert.md +3 -3
- package/.pi/extensions/harness-web-guard.ts +95 -0
- package/.pi/extensions/harness-web-tools.ts +209 -0
- package/.pi/extensions/lib/harness-vcc-settings.ts +50 -0
- package/.pi/extensions/lib/harness-web/run-cli.ts +92 -0
- package/.pi/extensions/ultimate-pi-vcc.ts +17 -0
- package/.pi/harness/docs/adrs/0030-inhouse-vcc-compaction.md +40 -0
- package/.pi/harness/docs/adrs/README.md +1 -0
- package/.pi/harness/env.harness.template +3 -1
- package/.pi/prompts/harness-setup.md +48 -2
- package/.pi/scripts/harness-cli-verify.sh +12 -3
- package/.pi/scripts/harness-searxng-bootstrap.mjs +270 -0
- package/.pi/scripts/harness-web-search.md +24 -5
- package/.pi/scripts/harness-web.py +24 -7
- package/.pi/scripts/harness_web/config.py +37 -3
- package/.pi/scripts/harness_web/output.py +8 -2
- package/.pi/scripts/harness_web/search.py +22 -0
- package/.pi/scripts/harness_web/search_ddg.py +1 -5
- package/.pi/scripts/harness_web/search_searxng.py +100 -0
- package/.pi/scripts/vendor-pi-vcc-settings.stub.ts +8 -0
- package/.pi/scripts/vendor-sync-pi-vcc.sh +40 -0
- package/.pi/settings.example.json +1 -6
- package/CHANGELOG.md +20 -6
- package/THIRD_PARTY_NOTICES.md +8 -22
- package/package.json +7 -6
- package/vendor/pi-vcc/README.md +215 -0
- package/vendor/pi-vcc/UPSTREAM_PIN.md +12 -0
- package/vendor/pi-vcc/demo.gif +0 -0
- package/vendor/pi-vcc/index.ts +12 -0
- package/vendor/pi-vcc/package.json +26 -0
- package/vendor/pi-vcc/scripts/audit-sessions.ts +88 -0
- package/vendor/pi-vcc/scripts/benchmark-real-sessions.ts +25 -0
- package/vendor/pi-vcc/scripts/compare-before-after.ts +36 -0
- package/vendor/pi-vcc/scripts/dump-branch-output.ts +20 -0
- package/vendor/pi-vcc/src/commands/pi-vcc.ts +36 -0
- package/vendor/pi-vcc/src/commands/vcc-recall.ts +65 -0
- package/vendor/pi-vcc/src/core/brief.ts +381 -0
- package/vendor/pi-vcc/src/core/build-sections.ts +79 -0
- package/vendor/pi-vcc/src/core/content.ts +60 -0
- package/vendor/pi-vcc/src/core/filter-noise.ts +42 -0
- package/vendor/pi-vcc/src/core/format-recall.ts +27 -0
- package/vendor/pi-vcc/src/core/format.ts +49 -0
- package/vendor/pi-vcc/src/core/lineage.ts +26 -0
- package/vendor/pi-vcc/src/core/load-messages.ts +41 -0
- package/vendor/pi-vcc/src/core/normalize.ts +66 -0
- package/vendor/pi-vcc/src/core/recall-scope.ts +14 -0
- package/vendor/pi-vcc/src/core/render-entries.ts +55 -0
- package/vendor/pi-vcc/src/core/report.ts +237 -0
- package/vendor/pi-vcc/src/core/sanitize.ts +5 -0
- package/vendor/pi-vcc/src/core/search-entries.ts +221 -0
- package/vendor/pi-vcc/src/core/settings.ts +8 -0
- package/vendor/pi-vcc/src/core/skill-collapse.ts +35 -0
- package/vendor/pi-vcc/src/core/summarize.ts +157 -0
- package/vendor/pi-vcc/src/core/tool-args.ts +14 -0
- package/vendor/pi-vcc/src/details.ts +7 -0
- package/vendor/pi-vcc/src/extract/commits.ts +69 -0
- package/vendor/pi-vcc/src/extract/files.ts +80 -0
- package/vendor/pi-vcc/src/extract/goals.ts +79 -0
- package/vendor/pi-vcc/src/extract/preferences.ts +55 -0
- package/vendor/pi-vcc/src/hooks/before-compact.ts +314 -0
- package/vendor/pi-vcc/src/sections.ts +12 -0
- package/vendor/pi-vcc/src/tools/recall.ts +109 -0
- package/vendor/pi-vcc/src/types.ts +14 -0
- package/vendor/pi-vcc/tests/before-compact-hook.test.ts +204 -0
- package/vendor/pi-vcc/tests/before-compact.test.ts +145 -0
- package/vendor/pi-vcc/tests/brief.test.ts +206 -0
- package/vendor/pi-vcc/tests/build-sections.test.ts +59 -0
- package/vendor/pi-vcc/tests/compile.test.ts +80 -0
- package/vendor/pi-vcc/tests/content.test.ts +31 -0
- package/vendor/pi-vcc/tests/extract-goals.test.ts +86 -0
- package/vendor/pi-vcc/tests/extract-preferences.test.ts +30 -0
- package/vendor/pi-vcc/tests/filter-noise.test.ts +61 -0
- package/vendor/pi-vcc/tests/fixtures.ts +61 -0
- package/vendor/pi-vcc/tests/format-recall.test.ts +30 -0
- package/vendor/pi-vcc/tests/format.test.ts +62 -0
- package/vendor/pi-vcc/tests/lineage.test.ts +33 -0
- package/vendor/pi-vcc/tests/load-messages.test.ts +51 -0
- package/vendor/pi-vcc/tests/normalize.test.ts +97 -0
- package/vendor/pi-vcc/tests/real-sessions.test.ts +38 -0
- package/vendor/pi-vcc/tests/recall-expand.test.ts +15 -0
- package/vendor/pi-vcc/tests/recall-scope.test.ts +32 -0
- package/vendor/pi-vcc/tests/recall-tool-scope.test.ts +67 -0
- package/vendor/pi-vcc/tests/render-entries.test.ts +62 -0
- package/vendor/pi-vcc/tests/report.test.ts +44 -0
- package/vendor/pi-vcc/tests/sanitize.test.ts +24 -0
- package/vendor/pi-vcc/tests/search-entries.test.ts +144 -0
- package/vendor/pi-vcc/tests/support/load-session.ts +23 -0
- package/vendor/pi-vcc/tests/support/real-sessions.ts +51 -0
- package/.pi/pi-vcc-config.json +0 -4
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { convertToLlm } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { writeFileSync } from "fs";
|
|
4
|
+
import { compile } from "../core/summarize";
|
|
5
|
+
import { loadSettings, type PiVccSettings } from "../core/settings";
|
|
6
|
+
import type { PiVccCompactionDetails } from "../details";
|
|
7
|
+
|
|
8
|
+
export const PI_VCC_COMPACT_INSTRUCTION = "__pi_vcc__";
|
|
9
|
+
|
|
10
|
+
export interface CompactionStats {
|
|
11
|
+
summarized: number;
|
|
12
|
+
kept: number;
|
|
13
|
+
keptTokensEst: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let lastStats: CompactionStats | null = null;
|
|
17
|
+
let lastCompactWasPiVcc = false;
|
|
18
|
+
export const getLastCompactionStats = () => lastStats;
|
|
19
|
+
|
|
20
|
+
const formatTokens = (n: number): string => {
|
|
21
|
+
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
|
22
|
+
return String(n);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const dbg = (settings: PiVccSettings, data: Record<string, unknown>) => {
|
|
26
|
+
if (!settings.debug) return;
|
|
27
|
+
try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const previewContent = (content: unknown): string => {
|
|
31
|
+
if (typeof content === "string") return content.slice(0, 300);
|
|
32
|
+
if (Array.isArray(content)) {
|
|
33
|
+
return content
|
|
34
|
+
.map((c: any) => {
|
|
35
|
+
if (c?.type === "text") return c.text ?? "";
|
|
36
|
+
if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
|
|
37
|
+
if (c?.type === "thinking") return `[thinking]`;
|
|
38
|
+
if (c?.type === "image") return `[image:${c.mimeType}]`;
|
|
39
|
+
return `[${c?.type ?? "unknown"}]`;
|
|
40
|
+
})
|
|
41
|
+
.join("\n")
|
|
42
|
+
.slice(0, 300);
|
|
43
|
+
}
|
|
44
|
+
return "";
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface EntryWithMessage {
|
|
48
|
+
entry: { id: string; type: string };
|
|
49
|
+
message: { role: string; content: unknown };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type OwnCutCancelReason =
|
|
53
|
+
| "no_live_messages"
|
|
54
|
+
| "too_few_live_messages";
|
|
55
|
+
|
|
56
|
+
export type OwnCutResult =
|
|
57
|
+
| { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
|
|
58
|
+
| { ok: false; reason: OwnCutCancelReason };
|
|
59
|
+
|
|
60
|
+
export function buildOwnCut(branchEntries: any[]): OwnCutResult {
|
|
61
|
+
// Find the last compaction entry and its firstKeptEntryId
|
|
62
|
+
let lastCompactionIdx = -1;
|
|
63
|
+
let lastKeptId: string | undefined;
|
|
64
|
+
for (let i = branchEntries.length - 1; i >= 0; i--) {
|
|
65
|
+
if (branchEntries[i].type === "compaction") {
|
|
66
|
+
lastCompactionIdx = i;
|
|
67
|
+
lastKeptId = branchEntries[i].firstKeptEntryId;
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
|
|
73
|
+
// compact-all) OR set to an id that no longer exists in the branch. In both cases,
|
|
74
|
+
// start collecting from right after the last compaction entry.
|
|
75
|
+
const hasPriorCompaction = lastCompactionIdx >= 0;
|
|
76
|
+
const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
|
|
77
|
+
const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
|
|
78
|
+
|
|
79
|
+
// Collect live messages
|
|
80
|
+
const liveMessages: EntryWithMessage[] = [];
|
|
81
|
+
if (orphanRecovery) {
|
|
82
|
+
for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
|
|
83
|
+
const e = branchEntries[i];
|
|
84
|
+
if (e.type === "compaction") continue;
|
|
85
|
+
if (e.type === "message" && e.message) {
|
|
86
|
+
liveMessages.push({ entry: e, message: e.message });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
|
|
91
|
+
for (const e of branchEntries) {
|
|
92
|
+
if (!foundKept && e.id === lastKeptId) foundKept = true;
|
|
93
|
+
if (!foundKept) continue;
|
|
94
|
+
if (e.type === "compaction") continue;
|
|
95
|
+
if (e.type === "message" && e.message) {
|
|
96
|
+
liveMessages.push({ entry: e, message: e.message });
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (liveMessages.length === 0) return { ok: false, reason: "no_live_messages" };
|
|
102
|
+
if (liveMessages.length <= 2) return { ok: false, reason: "too_few_live_messages" };
|
|
103
|
+
|
|
104
|
+
// Summarize all messages, keep only the last user message as context
|
|
105
|
+
let cutIdx = liveMessages.length - 1;
|
|
106
|
+
while (cutIdx > 0 && liveMessages[cutIdx].message.role !== "user") {
|
|
107
|
+
cutIdx--;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (cutIdx <= 0) {
|
|
111
|
+
// Single user prompt scenario (or no user at all).
|
|
112
|
+
// Compact EVERYTHING and keep no tail. This handles both:
|
|
113
|
+
// - Single user prompt at index 0: compact all, fresh start after summary
|
|
114
|
+
// - No user message at all (e.g., long assistant/tool chain): still compact
|
|
115
|
+
// to recover from context overflow rather than cancelling and leaving
|
|
116
|
+
// the session unrecoverable.
|
|
117
|
+
// firstKeptEntryId="" is a sentinel: pi-core's buildSessionContext won't match it
|
|
118
|
+
// (so 0 kept from pre-compaction), and next buildOwnCut triggers orphan recovery.
|
|
119
|
+
return {
|
|
120
|
+
ok: true,
|
|
121
|
+
messages: liveMessages.map((e) => e.message),
|
|
122
|
+
firstKeptEntryId: "",
|
|
123
|
+
compactAll: true,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
messages: liveMessages.slice(0, cutIdx).map((e) => e.message),
|
|
130
|
+
firstKeptEntryId: liveMessages[cutIdx].entry.id,
|
|
131
|
+
compactAll: false,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const REASON_MESSAGES: Record<OwnCutCancelReason, string> = {
|
|
136
|
+
no_live_messages: "pi-vcc: Nothing to compact (no live messages)",
|
|
137
|
+
too_few_live_messages: "pi-vcc: Too few messages to compact",
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
|
|
141
|
+
pi.on("session_before_compact", (event, ctx) => {
|
|
142
|
+
const { preparation, branchEntries, customInstructions } = event;
|
|
143
|
+
const settings = loadSettings();
|
|
144
|
+
|
|
145
|
+
// Always handle explicit /pi-vcc marker.
|
|
146
|
+
// Otherwise, only handle when user opted in via settings.
|
|
147
|
+
const isPiVcc = customInstructions === PI_VCC_COMPACT_INSTRUCTION;
|
|
148
|
+
if (!isPiVcc && !settings.overrideDefaultCompaction) return;
|
|
149
|
+
|
|
150
|
+
const ownCut = buildOwnCut(branchEntries as any[]);
|
|
151
|
+
if (!ownCut.ok) {
|
|
152
|
+
const lastComp = [...branchEntries].reverse().find((e: any) => e.type === "compaction");
|
|
153
|
+
const lastCompIdx = lastComp ? (branchEntries as any[]).indexOf(lastComp) : -1;
|
|
154
|
+
|
|
155
|
+
// Recompute liveMessages view (same logic as buildOwnCut) for diagnostic
|
|
156
|
+
const lastKeptId: string | undefined = lastComp?.firstKeptEntryId;
|
|
157
|
+
const hasPriorCompaction = lastCompIdx >= 0;
|
|
158
|
+
const hasValidKeptId = !!lastKeptId && (branchEntries as any[]).some((e: any) => e.id === lastKeptId);
|
|
159
|
+
const diagOrphan = hasPriorCompaction && !hasValidKeptId;
|
|
160
|
+
const liveRoles: string[] = [];
|
|
161
|
+
if (diagOrphan) {
|
|
162
|
+
for (let i = lastCompIdx + 1; i < branchEntries.length; i++) {
|
|
163
|
+
const e = (branchEntries as any[])[i];
|
|
164
|
+
if (e.type === "compaction") continue;
|
|
165
|
+
if (e.type === "message" && e.message) liveRoles.push(e.message.role);
|
|
166
|
+
}
|
|
167
|
+
} else {
|
|
168
|
+
let foundKept = !lastKeptId;
|
|
169
|
+
for (const e of branchEntries as any[]) {
|
|
170
|
+
if (!foundKept && e.id === lastKeptId) foundKept = true;
|
|
171
|
+
if (!foundKept) continue;
|
|
172
|
+
if (e.type === "compaction") continue;
|
|
173
|
+
if (e.type === "message" && e.message) liveRoles.push(e.message.role);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
const userIndices = liveRoles.reduce<number[]>((acc, r, i) => (r === "user" ? (acc.push(i), acc) : acc), []);
|
|
177
|
+
|
|
178
|
+
dbg(settings, {
|
|
179
|
+
cancelled: true,
|
|
180
|
+
reason: ownCut.reason,
|
|
181
|
+
isPiVcc,
|
|
182
|
+
counts: {
|
|
183
|
+
total: branchEntries.length,
|
|
184
|
+
messages: (branchEntries as any[]).filter((e: any) => e.type === "message").length,
|
|
185
|
+
compactions: (branchEntries as any[]).filter((e: any) => e.type === "compaction").length,
|
|
186
|
+
entriesAfterLastCompaction: lastCompIdx >= 0 ? branchEntries.length - lastCompIdx - 1 : null,
|
|
187
|
+
},
|
|
188
|
+
liveMessages: {
|
|
189
|
+
count: liveRoles.length,
|
|
190
|
+
userCount: userIndices.length,
|
|
191
|
+
firstUserIdx: userIndices[0] ?? null,
|
|
192
|
+
lastUserIdx: userIndices[userIndices.length - 1] ?? null,
|
|
193
|
+
roleSequence: liveRoles.length <= 30
|
|
194
|
+
? liveRoles
|
|
195
|
+
: [...liveRoles.slice(0, 10), "...", ...liveRoles.slice(-10)],
|
|
196
|
+
},
|
|
197
|
+
lastCompaction: lastComp ? {
|
|
198
|
+
hasFirstKeptEntryId: !!lastComp.firstKeptEntryId,
|
|
199
|
+
foundInBranch: lastComp.firstKeptEntryId
|
|
200
|
+
? (branchEntries as any[]).some((e: any) => e.id === lastComp.firstKeptEntryId)
|
|
201
|
+
: null,
|
|
202
|
+
} : null,
|
|
203
|
+
tail: (branchEntries as any[]).slice(-5).map((e: any) => ({
|
|
204
|
+
type: e.type,
|
|
205
|
+
role: e.type === "message" ? e.message?.role : undefined,
|
|
206
|
+
hasContent: e.type === "message" ? e.message?.content != null : undefined,
|
|
207
|
+
})),
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
|
|
212
|
+
} catch {}
|
|
213
|
+
return { cancel: true };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const agentMessages = ownCut.messages;
|
|
217
|
+
const firstKeptEntryId = ownCut.firstKeptEntryId;
|
|
218
|
+
const messages = convertToLlm(agentMessages);
|
|
219
|
+
|
|
220
|
+
// Count kept messages and estimate tokens
|
|
221
|
+
const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
|
|
222
|
+
const keptEntries = keptIdx >= 0
|
|
223
|
+
? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
|
|
224
|
+
: [];
|
|
225
|
+
const keptChars = keptEntries.reduce((sum: number, e: any) => {
|
|
226
|
+
const c = e.message?.content;
|
|
227
|
+
if (typeof c === "string") return sum + c.length;
|
|
228
|
+
if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => {
|
|
229
|
+
if (p.text) return s + p.text.length;
|
|
230
|
+
if (p.type === "toolCall") return s + (p.name?.length ?? 0) + (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length);
|
|
231
|
+
if (p.type === "toolResult") return s + (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length);
|
|
232
|
+
return s;
|
|
233
|
+
}, 0);
|
|
234
|
+
return sum;
|
|
235
|
+
}, 0);
|
|
236
|
+
lastStats = {
|
|
237
|
+
summarized: agentMessages.length,
|
|
238
|
+
kept: keptEntries.length,
|
|
239
|
+
keptTokensEst: Math.round(keptChars / 4),
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const config = settings;
|
|
243
|
+
|
|
244
|
+
const summary = compile({
|
|
245
|
+
messages,
|
|
246
|
+
previousSummary: preparation.previousSummary,
|
|
247
|
+
fileOps: {
|
|
248
|
+
readFiles: [...preparation.fileOps.read],
|
|
249
|
+
modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const branchIds = branchEntries.map((e: any) => e.id);
|
|
254
|
+
const cutIdx = branchIds.indexOf(firstKeptEntryId);
|
|
255
|
+
const cutWindow = cutIdx >= 0
|
|
256
|
+
? branchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e: any) => ({
|
|
257
|
+
id: e.id,
|
|
258
|
+
type: e.type,
|
|
259
|
+
role: e.type === "message" ? e.message?.role : undefined,
|
|
260
|
+
preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
|
|
261
|
+
}))
|
|
262
|
+
: [];
|
|
263
|
+
|
|
264
|
+
dbg(config, {
|
|
265
|
+
usedOwnCut: true,
|
|
266
|
+
messagesToSummarize: agentMessages.length,
|
|
267
|
+
messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
|
|
268
|
+
messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
|
|
269
|
+
convertedMessages: messages.length,
|
|
270
|
+
firstKeptEntryId,
|
|
271
|
+
cutWindow,
|
|
272
|
+
tokensBefore: preparation.tokensBefore,
|
|
273
|
+
summaryLength: summary.length,
|
|
274
|
+
summaryPreview: summary.slice(0, 500),
|
|
275
|
+
sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
const details: PiVccCompactionDetails = {
|
|
279
|
+
compactor: "ultimate-pi-vcc",
|
|
280
|
+
version: 1,
|
|
281
|
+
sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
|
|
282
|
+
sourceMessageCount: agentMessages.length,
|
|
283
|
+
previousSummaryUsed: Boolean(preparation.previousSummary),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
lastCompactWasPiVcc = isPiVcc;
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
compaction: {
|
|
290
|
+
summary,
|
|
291
|
+
details,
|
|
292
|
+
tokensBefore: preparation.tokensBefore,
|
|
293
|
+
firstKeptEntryId,
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// Fire success toast for /compact path only (delayed to let UI settle).
|
|
299
|
+
// /pi-vcc path uses its own onComplete callback in the command handler.
|
|
300
|
+
pi.on("session_compact", (event, ctx) => {
|
|
301
|
+
if (!event.fromExtension) return;
|
|
302
|
+
if (lastCompactWasPiVcc) return; // /pi-vcc handles its own toast via onComplete
|
|
303
|
+
const stats = lastStats;
|
|
304
|
+
if (!stats) return;
|
|
305
|
+
setTimeout(() => {
|
|
306
|
+
try {
|
|
307
|
+
ctx?.ui?.notify?.(
|
|
308
|
+
`pi-vcc: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
|
|
309
|
+
"info",
|
|
310
|
+
);
|
|
311
|
+
} catch {}
|
|
312
|
+
}, 500);
|
|
313
|
+
});
|
|
314
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { TranscriptEntry } from "./core/brief";
|
|
2
|
+
|
|
3
|
+
export interface SectionData {
|
|
4
|
+
sessionGoal: string[];
|
|
5
|
+
outstandingContext: string[];
|
|
6
|
+
filesAndChanges: string[];
|
|
7
|
+
commits: string[];
|
|
8
|
+
userPreferences: string[];
|
|
9
|
+
briefTranscript: string;
|
|
10
|
+
/** Structured transcript entries (verbose object format) */
|
|
11
|
+
transcriptEntries: TranscriptEntry[];
|
|
12
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox";
|
|
2
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
3
|
+
import { loadAllMessages } from "../core/load-messages";
|
|
4
|
+
import { searchEntries } from "../core/search-entries";
|
|
5
|
+
import { formatRecallOutput } from "../core/format-recall";
|
|
6
|
+
import { getActiveLineageEntryIds } from "../core/lineage";
|
|
7
|
+
import { normalizeRecallScope } from "../core/recall-scope";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_RECENT = 25;
|
|
10
|
+
const PAGE_SIZE = 5;
|
|
11
|
+
|
|
12
|
+
export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
|
|
13
|
+
requested.filter((i) => !Number.isInteger(i) || !available.has(i));
|
|
14
|
+
|
|
15
|
+
export const registerRecallTool = (pi: ExtensionAPI) => {
|
|
16
|
+
pi.registerTool({
|
|
17
|
+
name: "vcc_recall",
|
|
18
|
+
label: "VCC Recall",
|
|
19
|
+
description:
|
|
20
|
+
"Search session history. Defaults to active lineage; use scope:'all' to include off-lineage branches." +
|
|
21
|
+
" Supports regex queries, paging, and expand indices.",
|
|
22
|
+
promptSnippet:
|
|
23
|
+
"vcc_recall: Search history; default scope is active lineage. Use scope:'all' for off-lineage branches.",
|
|
24
|
+
parameters: Type.Object({
|
|
25
|
+
query: Type.Optional(
|
|
26
|
+
Type.String({ description: "Search terms or regex pattern (e.g. 'hook|inject', 'fail.*build'). Multi-word = OR ranked by relevance." }),
|
|
27
|
+
),
|
|
28
|
+
expand: Type.Optional(
|
|
29
|
+
Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for" }),
|
|
30
|
+
),
|
|
31
|
+
page: Type.Optional(
|
|
32
|
+
Type.Number({ description: "Page number (1-based) for paginated search results. Default: 1." }),
|
|
33
|
+
),
|
|
34
|
+
scope: Type.Optional(
|
|
35
|
+
Type.Union([
|
|
36
|
+
Type.Literal("lineage"),
|
|
37
|
+
Type.Literal("all"),
|
|
38
|
+
], { description: "Search scope. Default: lineage; all includes off-lineage branches." }),
|
|
39
|
+
),
|
|
40
|
+
}),
|
|
41
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
42
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
43
|
+
if (!sessionFile) {
|
|
44
|
+
return {
|
|
45
|
+
content: [{ type: "text", text: "No session file available." }],
|
|
46
|
+
details: undefined,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const scope = normalizeRecallScope(params.scope);
|
|
51
|
+
const lineageEntryIds = scope === "lineage"
|
|
52
|
+
? getActiveLineageEntryIds(ctx.sessionManager)
|
|
53
|
+
: undefined;
|
|
54
|
+
const expandSet = new Set(params.expand ?? []);
|
|
55
|
+
const hasExpand = expandSet.size > 0;
|
|
56
|
+
|
|
57
|
+
if (hasExpand && !params.query) {
|
|
58
|
+
const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
|
|
59
|
+
const requested = [...expandSet];
|
|
60
|
+
const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
|
|
61
|
+
const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
|
|
62
|
+
if (invalid.length > 0) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{ type: "text", text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }],
|
|
65
|
+
details: undefined,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const expanded = requested.map((i) => byIndex.get(i)).filter((m): m is NonNullable<typeof m> => Boolean(m));
|
|
70
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(expanded);
|
|
71
|
+
return {
|
|
72
|
+
content: [{ type: "text", text: output }],
|
|
73
|
+
details: undefined,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const { rendered: msgs, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
|
|
78
|
+
const allResults = params.query?.trim()
|
|
79
|
+
? searchEntries(msgs, rawMessages, params.query)
|
|
80
|
+
: msgs.slice(-DEFAULT_RECENT);
|
|
81
|
+
|
|
82
|
+
if (params.query?.trim()) {
|
|
83
|
+
const page = Math.max(1, params.page ?? 1);
|
|
84
|
+
const start = (page - 1) * PAGE_SIZE;
|
|
85
|
+
const pageResults = allResults.slice(start, start + PAGE_SIZE);
|
|
86
|
+
const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
|
|
87
|
+
const scopeSuffix = scope === "all" ? " (scope: all)" : "";
|
|
88
|
+
const header = totalPages > 1
|
|
89
|
+
? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix})`
|
|
90
|
+
: `${allResults.length} matches${scopeSuffix}`;
|
|
91
|
+
const footer = page < totalPages
|
|
92
|
+
? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
|
|
93
|
+
: "";
|
|
94
|
+
const output = formatRecallOutput(pageResults, params.query, header) + footer;
|
|
95
|
+
return {
|
|
96
|
+
content: [{ type: "text", text: output }],
|
|
97
|
+
details: undefined,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(allResults, params.query);
|
|
102
|
+
return {
|
|
103
|
+
content: [{ type: "text", text: output }],
|
|
104
|
+
details: undefined,
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
};
|
|
109
|
+
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
2
|
+
|
|
3
|
+
export interface FileOps {
|
|
4
|
+
readFiles?: string[];
|
|
5
|
+
modifiedFiles?: string[];
|
|
6
|
+
createdFiles?: string[];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type NormalizedBlock =
|
|
10
|
+
| { kind: "user"; text: string; sourceIndex?: number }
|
|
11
|
+
| { kind: "assistant"; text: string; sourceIndex?: number }
|
|
12
|
+
| { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
|
|
13
|
+
| { kind: "tool_result"; name: string; text: string; isError: boolean; sourceIndex?: number }
|
|
14
|
+
| { kind: "thinking"; text: string; redacted: boolean; sourceIndex?: number };
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from "bun:test";
|
|
2
|
+
import { existsSync, unlinkSync, readFileSync } from "fs";
|
|
3
|
+
import { registerBeforeCompactHook, PI_VCC_COMPACT_INSTRUCTION } from "../src/hooks/before-compact";
|
|
4
|
+
|
|
5
|
+
const DEBUG_PATH = "/tmp/pi-vcc-debug.json";
|
|
6
|
+
|
|
7
|
+
let compactionEnvBackup: string | undefined;
|
|
8
|
+
let debugEnvBackup: string | undefined;
|
|
9
|
+
|
|
10
|
+
beforeAll(() => {
|
|
11
|
+
compactionEnvBackup = process.env.HARNESS_VCC_COMPACTION;
|
|
12
|
+
debugEnvBackup = process.env.HARNESS_VCC_DEBUG;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterAll(() => {
|
|
16
|
+
if (compactionEnvBackup === undefined) {
|
|
17
|
+
delete process.env.HARNESS_VCC_COMPACTION;
|
|
18
|
+
} else {
|
|
19
|
+
process.env.HARNESS_VCC_COMPACTION = compactionEnvBackup;
|
|
20
|
+
}
|
|
21
|
+
if (debugEnvBackup === undefined) {
|
|
22
|
+
delete process.env.HARNESS_VCC_DEBUG;
|
|
23
|
+
} else {
|
|
24
|
+
process.env.HARNESS_VCC_DEBUG = debugEnvBackup;
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
function setHarnessEnv(opts: {
|
|
29
|
+
overrideDefaultCompaction: boolean;
|
|
30
|
+
debug: boolean;
|
|
31
|
+
}) {
|
|
32
|
+
process.env.HARNESS_VCC_COMPACTION = opts.overrideDefaultCompaction
|
|
33
|
+
? "true"
|
|
34
|
+
: "false";
|
|
35
|
+
process.env.HARNESS_VCC_DEBUG = opts.debug ? "true" : "false";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Minimal ExtensionAPI stub: capture handler + provide ctx with mocked ui.notify
|
|
39
|
+
function createMockPi() {
|
|
40
|
+
let handler: ((event: any, ctx: any) => any) | undefined;
|
|
41
|
+
const notifyCalls: Array<{ msg: string; level: string }> = [];
|
|
42
|
+
const ctx = {
|
|
43
|
+
hasUI: true,
|
|
44
|
+
ui: {
|
|
45
|
+
notify: (msg: string, level: string) => {
|
|
46
|
+
notifyCalls.push({ msg, level });
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
pi: {
|
|
52
|
+
on: (eventName: string, h: (e: any, c: any) => any) => {
|
|
53
|
+
if (eventName === "session_before_compact") handler = h;
|
|
54
|
+
},
|
|
55
|
+
} as any,
|
|
56
|
+
invoke: (event: any) => handler!(event, ctx),
|
|
57
|
+
notifyCalls,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function makeEvent(branchEntries: any[], customInstructions?: string) {
|
|
62
|
+
return {
|
|
63
|
+
type: "session_before_compact",
|
|
64
|
+
customInstructions,
|
|
65
|
+
branchEntries,
|
|
66
|
+
preparation: {
|
|
67
|
+
previousSummary: undefined,
|
|
68
|
+
fileOps: { read: [], written: [], edited: [] },
|
|
69
|
+
tokensBefore: 1000,
|
|
70
|
+
},
|
|
71
|
+
signal: new AbortController().signal,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const msg = (id: string, role: "user" | "assistant" | "toolResult", content = "x") => ({
|
|
76
|
+
id,
|
|
77
|
+
type: "message",
|
|
78
|
+
message: { role, content },
|
|
79
|
+
});
|
|
80
|
+
const comp = (id: string, firstKeptEntryId?: string) => ({
|
|
81
|
+
id,
|
|
82
|
+
type: "compaction",
|
|
83
|
+
firstKeptEntryId,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe("registerBeforeCompactHook: cancel paths", () => {
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
89
|
+
});
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("/pi-vcc with too few live messages cancels and notifies warning", () => {
|
|
95
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
96
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
97
|
+
registerBeforeCompactHook(pi);
|
|
98
|
+
|
|
99
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
100
|
+
expect(invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION))).toEqual({
|
|
101
|
+
cancel: true,
|
|
102
|
+
});
|
|
103
|
+
expect(notifyCalls).toHaveLength(1);
|
|
104
|
+
expect(notifyCalls[0].level).toBe("warning");
|
|
105
|
+
expect(notifyCalls[0].msg).toContain("Too few messages");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("/pi-vcc with no user message compacts all instead of cancelling", () => {
|
|
109
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
110
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
111
|
+
registerBeforeCompactHook(pi);
|
|
112
|
+
|
|
113
|
+
const entries = [
|
|
114
|
+
msg("m1", "assistant"),
|
|
115
|
+
msg("m2", "assistant"),
|
|
116
|
+
msg("m3", "assistant"),
|
|
117
|
+
];
|
|
118
|
+
const result = invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION));
|
|
119
|
+
expect(result.cancel).toBeUndefined();
|
|
120
|
+
expect(result.compaction).toBeDefined();
|
|
121
|
+
expect(result.compaction.firstKeptEntryId).toBe("");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("/compact with override=true cancels and notifies (NEW: was silent before)", () => {
|
|
125
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: true });
|
|
126
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
127
|
+
registerBeforeCompactHook(pi);
|
|
128
|
+
|
|
129
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
130
|
+
expect(invoke(makeEvent(entries, undefined))).toEqual({ cancel: true });
|
|
131
|
+
expect(notifyCalls).toHaveLength(1);
|
|
132
|
+
expect(notifyCalls[0].level).toBe("warning");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("/compact with override=false short-circuits (no notify, returns undefined)", () => {
|
|
136
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
137
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
138
|
+
registerBeforeCompactHook(pi);
|
|
139
|
+
|
|
140
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
141
|
+
expect(invoke(makeEvent(entries, undefined))).toBeUndefined();
|
|
142
|
+
expect(notifyCalls).toHaveLength(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("debug:true writes metrics-only snapshot on cancel with no content leakage", () => {
|
|
146
|
+
setHarnessEnv({ debug: true, overrideDefaultCompaction: false });
|
|
147
|
+
const { pi, invoke } = createMockPi();
|
|
148
|
+
registerBeforeCompactHook(pi);
|
|
149
|
+
|
|
150
|
+
const entries = [
|
|
151
|
+
msg("m1", "user", "SECRET_TOKEN_abc123"),
|
|
152
|
+
msg("m2", "assistant", "sensitive response"),
|
|
153
|
+
];
|
|
154
|
+
expect(invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION))).toEqual({
|
|
155
|
+
cancel: true,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
expect(existsSync(DEBUG_PATH)).toBe(true);
|
|
159
|
+
const snapshot = JSON.parse(readFileSync(DEBUG_PATH, "utf-8"));
|
|
160
|
+
expect(snapshot.cancelled).toBe(true);
|
|
161
|
+
expect(snapshot.reason).toBe("too_few_live_messages");
|
|
162
|
+
|
|
163
|
+
const serialized = JSON.stringify(snapshot);
|
|
164
|
+
expect(serialized).not.toContain("SECRET_TOKEN_abc123");
|
|
165
|
+
expect(serialized).not.toContain("sensitive response");
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("debug:false does NOT write snapshot", () => {
|
|
169
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
170
|
+
const { pi, invoke } = createMockPi();
|
|
171
|
+
registerBeforeCompactHook(pi);
|
|
172
|
+
const entries = [msg("m1", "user"), msg("m2", "assistant")];
|
|
173
|
+
expect(invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION))).toEqual({
|
|
174
|
+
cancel: true,
|
|
175
|
+
});
|
|
176
|
+
expect(existsSync(DEBUG_PATH)).toBe(false);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe("registerBeforeCompactHook: compact-all path", () => {
|
|
181
|
+
beforeEach(() => {
|
|
182
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
183
|
+
});
|
|
184
|
+
afterEach(() => {
|
|
185
|
+
if (existsSync(DEBUG_PATH)) unlinkSync(DEBUG_PATH);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("single-user + autonomous tail → returns compaction with empty firstKeptEntryId", () => {
|
|
189
|
+
setHarnessEnv({ debug: false, overrideDefaultCompaction: false });
|
|
190
|
+
const { pi, invoke, notifyCalls } = createMockPi();
|
|
191
|
+
registerBeforeCompactHook(pi);
|
|
192
|
+
|
|
193
|
+
const entries = [
|
|
194
|
+
msg("m1", "user", "go"),
|
|
195
|
+
msg("m2", "assistant", "calling tool"),
|
|
196
|
+
msg("m3", "toolResult", "result"),
|
|
197
|
+
msg("m4", "assistant", "done"),
|
|
198
|
+
];
|
|
199
|
+
const result = invoke(makeEvent(entries, PI_VCC_COMPACT_INSTRUCTION));
|
|
200
|
+
expect(result.compaction).toBeDefined();
|
|
201
|
+
expect(result.compaction.firstKeptEntryId).toBe("");
|
|
202
|
+
expect(notifyCalls).toHaveLength(0);
|
|
203
|
+
});
|
|
204
|
+
});
|