@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,313 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { buildSections } from "../src/core/build-sections";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("outstanding context: deep error extraction", () => {
|
|
6
|
+
it("captures bash non-zero exit codes", () => {
|
|
7
|
+
const blocks: NormalizedBlock[] = [
|
|
8
|
+
{ kind: "bash", command: "npm test", output: "1 failed", exitCode: 1 },
|
|
9
|
+
];
|
|
10
|
+
const r = buildSections({ blocks });
|
|
11
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
12
|
+
expect(r.outstandingContext[0]).toContain("bash:exit 1");
|
|
13
|
+
expect(r.outstandingContext[0]).toContain("npm test");
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("captures bash non-zero exit code with output context", () => {
|
|
17
|
+
const blocks: NormalizedBlock[] = [
|
|
18
|
+
{ kind: "bash", command: "cargo build", output: "error: could not compile", exitCode: 101 },
|
|
19
|
+
];
|
|
20
|
+
const r = buildSections({ blocks });
|
|
21
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
22
|
+
expect(r.outstandingContext[0]).toContain("bash:exit 101");
|
|
23
|
+
expect(r.outstandingContext[0]).toContain("cargo build");
|
|
24
|
+
expect(r.outstandingContext[0]).toContain("could not compile");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("skips bash with exit code 0", () => {
|
|
28
|
+
const blocks: NormalizedBlock[] = [
|
|
29
|
+
{ kind: "bash", command: "echo ok", output: "ok", exitCode: 0 },
|
|
30
|
+
];
|
|
31
|
+
const r = buildSections({ blocks });
|
|
32
|
+
expect(r.outstandingContext).toEqual([]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("skips bash with undefined exit code", () => {
|
|
36
|
+
const blocks: NormalizedBlock[] = [
|
|
37
|
+
{ kind: "bash", command: "echo ok", output: "ok", exitCode: undefined },
|
|
38
|
+
];
|
|
39
|
+
const r = buildSections({ blocks });
|
|
40
|
+
expect(r.outstandingContext).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("captures TypeScript compiler errors", () => {
|
|
44
|
+
const blocks: NormalizedBlock[] = [
|
|
45
|
+
{ kind: "bash", command: "tsc --noEmit", output: "src/auth.ts(12,5): error TS2322: Type 'string' is not assignable to type 'number'.", exitCode: 0 },
|
|
46
|
+
];
|
|
47
|
+
const r = buildSections({ blocks });
|
|
48
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
49
|
+
expect(r.outstandingContext.some(c => c.includes("[tsc]"))).toBe(true);
|
|
50
|
+
expect(r.outstandingContext.some(c => c.includes("TS2322"))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("captures test failures in bash output", () => {
|
|
54
|
+
const blocks: NormalizedBlock[] = [
|
|
55
|
+
{ kind: "bash", command: "bun test", output: "2 failed\nFAIL auth.test.ts", exitCode: 1 },
|
|
56
|
+
];
|
|
57
|
+
const r = buildSections({ blocks });
|
|
58
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
59
|
+
// Should capture both the exit code AND the test failure signal
|
|
60
|
+
const hasExitCode = r.outstandingContext.some(c => c.includes("bash:exit 1"));
|
|
61
|
+
const hasTests = r.outstandingContext.some(c => c.includes("[tests]"));
|
|
62
|
+
expect(hasExitCode || hasTests).toBe(true);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("captures empty grep results", () => {
|
|
66
|
+
const blocks: NormalizedBlock[] = [
|
|
67
|
+
{ kind: "tool_call", name: "grep", args: { pattern: "verifyToken", path: "src/" } },
|
|
68
|
+
{ kind: "tool_result", name: "grep", text: "No matches found", isError: false },
|
|
69
|
+
];
|
|
70
|
+
const r = buildSections({ blocks });
|
|
71
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
72
|
+
expect(r.outstandingContext[0]).toContain("[no matches]");
|
|
73
|
+
expect(r.outstandingContext[0]).toContain("grep");
|
|
74
|
+
expect(r.outstandingContext[0]).toContain("verifyToken");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("captures empty glob results", () => {
|
|
78
|
+
const blocks: NormalizedBlock[] = [
|
|
79
|
+
{ kind: "tool_call", name: "glob", args: { pattern: "**/*.proto" } },
|
|
80
|
+
{ kind: "tool_result", name: "glob", text: "No files matched.", isError: false },
|
|
81
|
+
];
|
|
82
|
+
const r = buildSections({ blocks });
|
|
83
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
84
|
+
expect(r.outstandingContext[0]).toContain("[no matches]");
|
|
85
|
+
expect(r.outstandingContext[0]).toContain("glob");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("captures tsc errors from tool_result with bash error", () => {
|
|
89
|
+
const blocks: NormalizedBlock[] = [
|
|
90
|
+
{ kind: "tool_result", name: "bash", text: "src/utils.ts(5,1): error TS2305: Module has no exported member 'foo'.", isError: true },
|
|
91
|
+
];
|
|
92
|
+
const r = buildSections({ blocks });
|
|
93
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
94
|
+
expect(r.outstandingContext.some(c => c.includes("[tsc]"))).toBe(true);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("still captures BLOCKER_RE text from assistant", () => {
|
|
98
|
+
const blocks: NormalizedBlock[] = [
|
|
99
|
+
{ kind: "assistant", text: "The deployment is still failing because of the auth configuration." },
|
|
100
|
+
];
|
|
101
|
+
const r = buildSections({ blocks });
|
|
102
|
+
expect(r.outstandingContext.length).toBeGreaterThan(0);
|
|
103
|
+
expect(r.outstandingContext[0]).toContain("still failing");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("deduplicates error items", () => {
|
|
107
|
+
const blocks: NormalizedBlock[] = [
|
|
108
|
+
{ kind: "tool_result", name: "bash", text: "Command not found", isError: true },
|
|
109
|
+
{ kind: "tool_result", name: "bash", text: "Command not found", isError: true },
|
|
110
|
+
];
|
|
111
|
+
const r = buildSections({ blocks });
|
|
112
|
+
const matchCount = r.outstandingContext.filter(c => c.includes("Command not found")).length;
|
|
113
|
+
expect(matchCount).toBeLessThanOrEqual(1);
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("files and changes: symbol annotations", () => {
|
|
118
|
+
it("annotates modified files with exported symbol names from Edit newText", () => {
|
|
119
|
+
const blocks: NormalizedBlock[] = [
|
|
120
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts", newText: "export function login() {}\nexport function verifyToken() {}\nconst _internal = 1;" } },
|
|
121
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
122
|
+
];
|
|
123
|
+
const r = buildSections({ blocks });
|
|
124
|
+
expect(r.filesAndChanges.length).toBeGreaterThan(0);
|
|
125
|
+
expect(r.filesAndChanges[0]).toContain("Modified");
|
|
126
|
+
expect(r.filesAndChanges[0]).toContain("login");
|
|
127
|
+
expect(r.filesAndChanges[0]).toContain("verifyToken");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("annotates read files with exported symbol names from Read result", () => {
|
|
131
|
+
const blocks: NormalizedBlock[] = [
|
|
132
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "types.ts" } },
|
|
133
|
+
{ kind: "tool_result", name: "Read", text: "export interface User { name: string; }\nexport type AuthPayload = { email: string; }", isError: false },
|
|
134
|
+
];
|
|
135
|
+
const r = buildSections({ blocks });
|
|
136
|
+
expect(r.filesAndChanges.length).toBeGreaterThan(0);
|
|
137
|
+
expect(r.filesAndChanges[0]).toContain("Read");
|
|
138
|
+
expect(r.filesAndChanges[0]).toContain("User");
|
|
139
|
+
expect(r.filesAndChanges[0]).toContain("AuthPayload");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("does not annotate files when no symbols are found", () => {
|
|
143
|
+
const blocks: NormalizedBlock[] = [
|
|
144
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "config.json" } },
|
|
145
|
+
{ kind: "tool_result", name: "Read", text: '{ "name": "test" }', isError: false },
|
|
146
|
+
];
|
|
147
|
+
const r = buildSections({ blocks });
|
|
148
|
+
expect(r.filesAndChanges[0]).toContain("config.json");
|
|
149
|
+
expect(r.filesAndChanges[0]).not.toContain("(");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("handles Mixed Edit+Read with combined symbols", () => {
|
|
153
|
+
const blocks: NormalizedBlock[] = [
|
|
154
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "utils.ts" } },
|
|
155
|
+
{ kind: "tool_result", name: "Read", text: "export function helper() {}\nexport class Util {}", isError: false },
|
|
156
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "utils.ts", newText: "export function newHelper() {}" } },
|
|
157
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
158
|
+
];
|
|
159
|
+
const r = buildSections({ blocks });
|
|
160
|
+
expect(r.filesAndChanges[0]).toContain("Modified");
|
|
161
|
+
expect(r.filesAndChanges[0]).toContain("utils.ts");
|
|
162
|
+
// Should contain symbols from both Read result and Edit newText
|
|
163
|
+
expect(r.filesAndChanges[0]).toContain("newHelper");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("annotates Python def and class exports", () => {
|
|
167
|
+
const blocks: NormalizedBlock[] = [
|
|
168
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "auth.py" } },
|
|
169
|
+
{ kind: "tool_result", name: "Read", text: "def login(email, pwd):\n pass\n\nclass AuthProvider:\n pass", isError: false },
|
|
170
|
+
];
|
|
171
|
+
const r = buildSections({ blocks });
|
|
172
|
+
expect(r.filesAndChanges[0]).toContain("login");
|
|
173
|
+
expect(r.filesAndChanges[0]).toContain("AuthProvider");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("annotates Go exported functions", () => {
|
|
177
|
+
const blocks: NormalizedBlock[] = [
|
|
178
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "handler.go" } },
|
|
179
|
+
{ kind: "tool_result", name: "Read", text: "func HandleRequest(w http.ResponseWriter, r *http.Request) {\n\t// ...\n}\n\nfunc internalHelper() {}", isError: false },
|
|
180
|
+
];
|
|
181
|
+
const r = buildSections({ blocks });
|
|
182
|
+
expect(r.filesAndChanges[0]).toContain("HandleRequest");
|
|
183
|
+
// unexported Go functions (lowercase) should not be included
|
|
184
|
+
expect(r.filesAndChanges[0]).not.toContain("internalHelper");
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
describe("type catalog", () => {
|
|
189
|
+
it("extracts exported signatures from modified files", () => {
|
|
190
|
+
const blocks: NormalizedBlock[] = [
|
|
191
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts", newText: "export function login(email: string, pwd: string): Promise<Session> {\n // impl\n}" } },
|
|
192
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
193
|
+
];
|
|
194
|
+
const r = buildSections({ blocks });
|
|
195
|
+
expect(r.typeCatalog.length).toBeGreaterThan(0);
|
|
196
|
+
expect(r.typeCatalog[0]).toContain("auth.ts");
|
|
197
|
+
expect(r.typeCatalog.some(l => l.includes("login"))).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("extracts exported signatures from Read results", () => {
|
|
201
|
+
const blocks: NormalizedBlock[] = [
|
|
202
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "types.ts" } },
|
|
203
|
+
{ kind: "tool_result", name: "Read", text: "export interface User {\n name: string;\n email: string;\n}\n\nexport type Session = {\n token: string;\n user: User;\n}", isError: false },
|
|
204
|
+
];
|
|
205
|
+
const r = buildSections({ blocks });
|
|
206
|
+
expect(r.typeCatalog.length).toBeGreaterThan(0);
|
|
207
|
+
expect(r.typeCatalog.some(l => l.includes("interface User"))).toBe(true);
|
|
208
|
+
expect(r.typeCatalog.some(l => l.includes("type Session"))).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("prioritizes modified files over read files", () => {
|
|
212
|
+
const blocks: NormalizedBlock[] = [
|
|
213
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "read_only.ts" } },
|
|
214
|
+
{ kind: "tool_result", name: "Read", text: "export function readOnly() {}", isError: false },
|
|
215
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "modified.ts", newText: "export function modified() {}" } },
|
|
216
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
217
|
+
];
|
|
218
|
+
const r = buildSections({ blocks });
|
|
219
|
+
expect(r.typeCatalog.length).toBeGreaterThan(0);
|
|
220
|
+
// Modified file should appear first
|
|
221
|
+
expect(r.typeCatalog[0]).toContain("modified.ts");
|
|
222
|
+
expect(r.typeCatalog[0]).toContain("[modified]");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("returns empty for files with no exportable signatures", () => {
|
|
226
|
+
const blocks: NormalizedBlock[] = [
|
|
227
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "data.json" } },
|
|
228
|
+
{ kind: "tool_result", name: "Read", text: '{ "key": "value" }', isError: false },
|
|
229
|
+
];
|
|
230
|
+
const r = buildSections({ blocks });
|
|
231
|
+
expect(r.typeCatalog).toEqual([]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("caps signatures per file to 8", () => {
|
|
235
|
+
const sigs = Array.from({ length: 15 }, (_, i) => `export function fn${i}(): void {}`).join("\n");
|
|
236
|
+
const blocks: NormalizedBlock[] = [
|
|
237
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "big.ts" } },
|
|
238
|
+
{ kind: "tool_result", name: "Read", text: sigs, isError: false },
|
|
239
|
+
];
|
|
240
|
+
const r = buildSections({ blocks });
|
|
241
|
+
// The catalog should have the file but max 8 signatures
|
|
242
|
+
const sigLines = r.typeCatalog.filter(l => l.startsWith(" "));
|
|
243
|
+
expect(sigLines.length).toBeLessThanOrEqual(8);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("symbol changes", () => {
|
|
248
|
+
it("extracts symbol refs from Edit/Write tool calls", () => {
|
|
249
|
+
const blocks: NormalizedBlock[] = [
|
|
250
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts", newText: "export function login() {}\nexport function verifyToken() {}" } },
|
|
251
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
252
|
+
];
|
|
253
|
+
const r = buildSections({ blocks });
|
|
254
|
+
expect(r.symbolChanges.length).toBeGreaterThan(0);
|
|
255
|
+
expect(r.symbolChanges.some(s => s.name === "login" && s.file === "auth.ts")).toBe(true);
|
|
256
|
+
expect(r.symbolChanges.some(s => s.name === "verifyToken" && s.kind === "function")).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("marks read vs modified access correctly", () => {
|
|
260
|
+
const blocks: NormalizedBlock[] = [
|
|
261
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "types.ts" } },
|
|
262
|
+
{ kind: "tool_result", name: "Read", text: "export interface User {}", isError: false },
|
|
263
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts", newText: "export function login() {}" } },
|
|
264
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
265
|
+
];
|
|
266
|
+
const r = buildSections({ blocks });
|
|
267
|
+
const loginSym = r.symbolChanges.find(s => s.name === "login");
|
|
268
|
+
const userSym = r.symbolChanges.find(s => s.name === "User");
|
|
269
|
+
expect(loginSym?.access).toBe("modified");
|
|
270
|
+
expect(userSym?.access).toBe("read");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it("deduplicates symbols by name+file", () => {
|
|
274
|
+
const blocks: NormalizedBlock[] = [
|
|
275
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "auth.ts" } },
|
|
276
|
+
{ kind: "tool_result", name: "Read", text: "export function login() {}", isError: false },
|
|
277
|
+
{ kind: "tool_call", name: "Edit", args: { file_path: "auth.ts", newText: "export function login() {}" } },
|
|
278
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
279
|
+
];
|
|
280
|
+
const r = buildSections({ blocks });
|
|
281
|
+
const loginSymCount = r.symbolChanges.filter(s => s.name === "login" && s.file === "auth.ts").length;
|
|
282
|
+
expect(loginSymCount).toBe(1);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("format output integration", () => {
|
|
287
|
+
it("includes Type Catalog section in formatted output between Files and Commits", () => {
|
|
288
|
+
const blocks: NormalizedBlock[] = [
|
|
289
|
+
{ kind: "tool_call", name: "Read", args: { file_path: "types.ts" } },
|
|
290
|
+
{ kind: "tool_result", name: "Read", text: "export interface User { name: string; }", isError: false },
|
|
291
|
+
];
|
|
292
|
+
const r = buildSections({ blocks });
|
|
293
|
+
const { formatSummary } = require("../src/core/format");
|
|
294
|
+
const output = formatSummary(r);
|
|
295
|
+
expect(output).toContain("[Type Catalog]");
|
|
296
|
+
// Should appear after Files And Changes and before Commits
|
|
297
|
+
const filesIdx = output.indexOf("[Files And Changes]");
|
|
298
|
+
const typeIdx = output.indexOf("[Type Catalog]");
|
|
299
|
+
const commitsIdx = output.indexOf("[Commits]");
|
|
300
|
+
if (filesIdx >= 0 && typeIdx >= 0) expect(filesIdx).toBeLessThan(typeIdx);
|
|
301
|
+
// Commits may or may not be present
|
|
302
|
+
if (commitsIdx >= 0 && typeIdx >= 0) expect(typeIdx).toBeLessThan(commitsIdx);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
it("Type Catalog is volatile (fresh only) on merge", () => {
|
|
306
|
+
const { compile } = require("../src/core/summarize");
|
|
307
|
+
const prev = "[Session Goal]\n- goal\n\n[Type Catalog]\n- old-file.ts [read]:\n export function old()";
|
|
308
|
+
const fresh = compile({ messages: [], previousSummary: prev });
|
|
309
|
+
// Type Catalog should be fresh-only, not merged from previous
|
|
310
|
+
expect(fresh).not.toContain("old-file.ts");
|
|
311
|
+
expect(fresh).not.toContain("old()");
|
|
312
|
+
});
|
|
313
|
+
});
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { extractGoals } from "../src/extract/goals";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("extractGoals", () => {
|
|
6
|
+
it("returns empty for no blocks", () => {
|
|
7
|
+
expect(extractGoals([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("returns empty when no user blocks", () => {
|
|
11
|
+
const blocks: NormalizedBlock[] = [
|
|
12
|
+
{ kind: "assistant", text: "hello" },
|
|
13
|
+
];
|
|
14
|
+
expect(extractGoals(blocks)).toEqual([]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts first user message lines as goals", () => {
|
|
18
|
+
const blocks: NormalizedBlock[] = [
|
|
19
|
+
{ kind: "user", text: "Fix login bug\nCheck auth flow" },
|
|
20
|
+
];
|
|
21
|
+
const goals = extractGoals(blocks);
|
|
22
|
+
expect(goals).toEqual(["Fix login bug", "Check auth flow"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("takes up to 6 lines from first user block", () => {
|
|
26
|
+
const blocks: NormalizedBlock[] = [
|
|
27
|
+
{ kind: "user", text: "fix the login bug\ncheck auth flow\nupdate the tests\nrefactor utils\nclean up" },
|
|
28
|
+
];
|
|
29
|
+
expect(extractGoals(blocks)).toHaveLength(5);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("ignores subsequent user blocks", () => {
|
|
33
|
+
const blocks: NormalizedBlock[] = [
|
|
34
|
+
{ kind: "user", text: "first goal" },
|
|
35
|
+
{ kind: "assistant", text: "ok" },
|
|
36
|
+
{ kind: "user", text: "second request" },
|
|
37
|
+
];
|
|
38
|
+
expect(extractGoals(blocks)).toEqual(["first goal"]);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("detects scope change with explicit pivot keywords", () => {
|
|
42
|
+
const blocks: NormalizedBlock[] = [
|
|
43
|
+
{ kind: "user", text: "Fix login bug" },
|
|
44
|
+
{ kind: "assistant", text: "ok" },
|
|
45
|
+
{ kind: "user", text: "Actually, instead let's refactor the auth module" },
|
|
46
|
+
];
|
|
47
|
+
const goals = extractGoals(blocks);
|
|
48
|
+
expect(goals).toContain("Fix login bug");
|
|
49
|
+
expect(goals).toContain("[Scope change]");
|
|
50
|
+
expect(goals.some((g) => g.includes("refactor"))).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("detects scope change from new task statements", () => {
|
|
54
|
+
const blocks: NormalizedBlock[] = [
|
|
55
|
+
{ kind: "user", text: "Fix login bug" },
|
|
56
|
+
{ kind: "assistant", text: "done" },
|
|
57
|
+
{ kind: "user", text: "Now implement the user registration flow" },
|
|
58
|
+
];
|
|
59
|
+
const goals = extractGoals(blocks);
|
|
60
|
+
expect(goals).toContain("[Scope change]");
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("keeps latest scope change only", () => {
|
|
64
|
+
const blocks: NormalizedBlock[] = [
|
|
65
|
+
{ kind: "user", text: "Fix login bug" },
|
|
66
|
+
{ kind: "assistant", text: "done" },
|
|
67
|
+
{ kind: "user", text: "Actually, fix the signup page instead" },
|
|
68
|
+
{ kind: "assistant", text: "ok" },
|
|
69
|
+
{ kind: "user", text: "Change of plan, implement password reset" },
|
|
70
|
+
];
|
|
71
|
+
const goals = extractGoals(blocks);
|
|
72
|
+
const scopeIdx = goals.indexOf("[Scope change]");
|
|
73
|
+
expect(goals[scopeIdx + 1]).toContain("password reset");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("skips noise short user messages as goals", () => {
|
|
77
|
+
const blocks: NormalizedBlock[] = [
|
|
78
|
+
{ kind: "user", text: "ok" },
|
|
79
|
+
{ kind: "assistant", text: "hello" },
|
|
80
|
+
{ kind: "user", text: "Fix the authentication module" },
|
|
81
|
+
];
|
|
82
|
+
const goals = extractGoals(blocks);
|
|
83
|
+
expect(goals[0]).toContain("Fix the authentication");
|
|
84
|
+
expect(goals.some((g) => g === "ok")).toBe(false);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { extractPreferences } from "../src/extract/preferences";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("extractPreferences", () => {
|
|
6
|
+
it("returns empty for no blocks", () => {
|
|
7
|
+
expect(extractPreferences([])).toEqual([]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("captures preference patterns from user", () => {
|
|
11
|
+
const blocks: NormalizedBlock[] = [
|
|
12
|
+
{ kind: "user", text: "I prefer TypeScript over JavaScript" },
|
|
13
|
+
];
|
|
14
|
+
expect(extractPreferences(blocks).length).toBe(1);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("ignores assistant blocks", () => {
|
|
18
|
+
const blocks: NormalizedBlock[] = [
|
|
19
|
+
{ kind: "assistant", text: "I always use best practices" },
|
|
20
|
+
];
|
|
21
|
+
expect(extractPreferences(blocks)).toEqual([]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("captures please use pattern", () => {
|
|
25
|
+
const blocks: NormalizedBlock[] = [
|
|
26
|
+
{ kind: "user", text: "please use bun instead of node" },
|
|
27
|
+
];
|
|
28
|
+
expect(extractPreferences(blocks).length).toBe(1);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { filterNoise } from "../src/core/filter-noise";
|
|
3
|
+
import type { NormalizedBlock } from "../src/types";
|
|
4
|
+
|
|
5
|
+
describe("filterNoise", () => {
|
|
6
|
+
it("removes thinking blocks", () => {
|
|
7
|
+
const blocks: NormalizedBlock[] = [
|
|
8
|
+
{ kind: "thinking", text: "hmm", redacted: false },
|
|
9
|
+
{ kind: "assistant", text: "hello" },
|
|
10
|
+
];
|
|
11
|
+
expect(filterNoise(blocks)).toEqual([{ kind: "assistant", text: "hello" }]);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("removes noise tool calls and results", () => {
|
|
15
|
+
const blocks: NormalizedBlock[] = [
|
|
16
|
+
{ kind: "tool_call", name: "TodoWrite", args: {} },
|
|
17
|
+
{ kind: "tool_result", name: "TodoWrite", text: "ok", isError: false },
|
|
18
|
+
{ kind: "tool_call", name: "Read", args: { path: "x.ts" } },
|
|
19
|
+
];
|
|
20
|
+
const result = filterNoise(blocks);
|
|
21
|
+
expect(result).toHaveLength(1);
|
|
22
|
+
expect(result[0]).toEqual({ kind: "tool_call", name: "Read", args: { path: "x.ts" } });
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("removes user blocks that are pure XML wrappers", () => {
|
|
26
|
+
const blocks: NormalizedBlock[] = [
|
|
27
|
+
{ kind: "user", text: "<system-reminder>some noise</system-reminder>" },
|
|
28
|
+
{ kind: "user", text: "Fix the bug" },
|
|
29
|
+
];
|
|
30
|
+
const result = filterNoise(blocks);
|
|
31
|
+
expect(result).toHaveLength(1);
|
|
32
|
+
expect(result[0]).toEqual({ kind: "user", text: "Fix the bug" });
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("cleans XML wrappers from user text but keeps real content", () => {
|
|
36
|
+
const blocks: NormalizedBlock[] = [
|
|
37
|
+
{ kind: "user", text: "<system-reminder>noise</system-reminder>\nFix the login" },
|
|
38
|
+
];
|
|
39
|
+
const result = filterNoise(blocks);
|
|
40
|
+
expect(result).toHaveLength(1);
|
|
41
|
+
expect((result[0] as any).text).toBe("Fix the login");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("removes known noise strings", () => {
|
|
45
|
+
const blocks: NormalizedBlock[] = [
|
|
46
|
+
{ kind: "user", text: "Continue from where you left off." },
|
|
47
|
+
{ kind: "user", text: "real task" },
|
|
48
|
+
];
|
|
49
|
+
const result = filterNoise(blocks);
|
|
50
|
+
expect(result).toHaveLength(1);
|
|
51
|
+
expect((result[0] as any).text).toBe("real task");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("preserves non-noise tool calls", () => {
|
|
55
|
+
const blocks: NormalizedBlock[] = [
|
|
56
|
+
{ kind: "tool_call", name: "Edit", args: { path: "a.ts" } },
|
|
57
|
+
{ kind: "tool_result", name: "Edit", text: "ok", isError: false },
|
|
58
|
+
];
|
|
59
|
+
expect(filterNoise(blocks)).toHaveLength(2);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Message } from "@earendil-works/pi-ai";
|
|
2
|
+
|
|
3
|
+
const ts = Date.now();
|
|
4
|
+
const assistBase = {
|
|
5
|
+
api: "messages" as any,
|
|
6
|
+
provider: "anthropic" as any,
|
|
7
|
+
model: "test",
|
|
8
|
+
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
9
|
+
timestamp: ts,
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const userMsg = (text: string): Message => ({
|
|
13
|
+
role: "user",
|
|
14
|
+
content: text,
|
|
15
|
+
timestamp: ts,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export const assistantText = (text: string): Message => ({
|
|
19
|
+
role: "assistant",
|
|
20
|
+
content: [{ type: "text", text }],
|
|
21
|
+
...assistBase,
|
|
22
|
+
stopReason: "stop",
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
export const assistantWithThinking = (
|
|
26
|
+
text: string,
|
|
27
|
+
thinking: string,
|
|
28
|
+
): Message => ({
|
|
29
|
+
role: "assistant",
|
|
30
|
+
content: [
|
|
31
|
+
{ type: "thinking", thinking },
|
|
32
|
+
{ type: "text", text },
|
|
33
|
+
],
|
|
34
|
+
...assistBase,
|
|
35
|
+
stopReason: "stop",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const assistantWithToolCall = (
|
|
39
|
+
name: string,
|
|
40
|
+
args: Record<string, unknown>,
|
|
41
|
+
): Message => ({
|
|
42
|
+
role: "assistant",
|
|
43
|
+
content: [{ type: "toolCall", id: "tc_1", name, arguments: args }],
|
|
44
|
+
...assistBase,
|
|
45
|
+
stopReason: "toolUse",
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
export const toolResult = (
|
|
49
|
+
name: string,
|
|
50
|
+
text: string,
|
|
51
|
+
isError = false,
|
|
52
|
+
): Message => ({
|
|
53
|
+
role: "toolResult",
|
|
54
|
+
toolCallId: "tc_1",
|
|
55
|
+
toolName: name,
|
|
56
|
+
content: [{ type: "text", text }],
|
|
57
|
+
isError,
|
|
58
|
+
timestamp: ts,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { formatRecallOutput } from "../src/core/format-recall";
|
|
3
|
+
import type { SearchHit } from "../src/core/search-entries";
|
|
4
|
+
|
|
5
|
+
describe("formatRecallOutput", () => {
|
|
6
|
+
it("shows no-match message with query", () => {
|
|
7
|
+
const r = formatRecallOutput([], "xyz");
|
|
8
|
+
expect(r).toContain('No matches for "xyz"');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("shows no-entries message without query", () => {
|
|
12
|
+
expect(formatRecallOutput([])).toContain("No entries");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
describe("browse mode (no query)", () => {
|
|
16
|
+
it("formats entries with index and role", () => {
|
|
17
|
+
const entries: SearchHit[] = [
|
|
18
|
+
{ index: 0, role: "user", summary: "hello" },
|
|
19
|
+
];
|
|
20
|
+
const r = formatRecallOutput(entries);
|
|
21
|
+
expect(r).toContain("#0 [user] hello");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("shows session history header", () => {
|
|
25
|
+
const entries: SearchHit[] = [
|
|
26
|
+
{ index: 0, role: "user", summary: "a" },
|
|
27
|
+
{ index: 1, role: "assistant", summary: "b" },
|
|
28
|
+
];
|
|
29
|
+
const r = formatRecallOutput(entries);
|
|
30
|
+
expect(r).toContain("Session history (2 entries)");
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("search mode (with query)", () => {
|
|
35
|
+
it("shows match count with query", () => {
|
|
36
|
+
const entries: SearchHit[] = [
|
|
37
|
+
{ index: 2, role: "assistant", summary: "done", snippet: "done" },
|
|
38
|
+
];
|
|
39
|
+
const r = formatRecallOutput(entries, "done");
|
|
40
|
+
expect(r).toContain('Found 1 matches for "done"');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("groups entries into segments by turn", () => {
|
|
44
|
+
// Segments are split on user/bash boundaries
|
|
45
|
+
const entries: SearchHit[] = [
|
|
46
|
+
// Segment 1: user + assistant + tool_result (only user matches)
|
|
47
|
+
{ index: 0, role: "user", summary: "fix auth", snippet: "fix auth" },
|
|
48
|
+
{ index: 1, role: "assistant", summary: "checking...", snippet: undefined },
|
|
49
|
+
{ index: 2, role: "tool_result", summary: "file not found", snippet: undefined },
|
|
50
|
+
// Segment 2: second user turn (assistant matches)
|
|
51
|
+
{ index: 3, role: "user", summary: "check the logs", snippet: undefined },
|
|
52
|
+
{ index: 4, role: "assistant", summary: "found the auth bug", snippet: "auth bug" },
|
|
53
|
+
];
|
|
54
|
+
const r = formatRecallOutput(entries, "auth");
|
|
55
|
+
expect(r).toContain("--- #0-#2 (1/3 entries match) ---");
|
|
56
|
+
expect(r).toContain("> #0 [user] fix auth");
|
|
57
|
+
expect(r).toContain(" #1 [assistant] checking...");
|
|
58
|
+
expect(r).toContain("--- #3-#4 (1/2 entries match) ---");
|
|
59
|
+
expect(r).toContain("> #4 [assistant] auth bug");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("shows segment context for adjacent turns", () => {
|
|
63
|
+
// Single match — adjacent segments shown as context
|
|
64
|
+
const entries: SearchHit[] = [
|
|
65
|
+
{ index: 0, role: "user", summary: "hello", snippet: undefined },
|
|
66
|
+
{ index: 1, role: "assistant", summary: "hi", snippet: undefined },
|
|
67
|
+
{ index: 2, role: "user", summary: "fix auth", snippet: "fix auth" },
|
|
68
|
+
{ index: 3, role: "assistant", summary: "done", snippet: undefined },
|
|
69
|
+
{ index: 4, role: "user", summary: "thanks", snippet: undefined },
|
|
70
|
+
];
|
|
71
|
+
const r = formatRecallOutput(entries, "auth");
|
|
72
|
+
expect(r).toMatch(/---/);
|
|
73
|
+
expect(r).toContain("fix auth");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("handles entries without snippet (assume all matched)", () => {
|
|
77
|
+
const entries: SearchHit[] = [
|
|
78
|
+
{ index: 0, role: "user", summary: "hello" },
|
|
79
|
+
{ index: 1, role: "assistant", summary: "world" },
|
|
80
|
+
];
|
|
81
|
+
const r = formatRecallOutput(entries, "search term");
|
|
82
|
+
expect(r).toContain('Found 2 matches for "search term"');
|
|
83
|
+
expect(r).toContain("> #0 [user] hello");
|
|
84
|
+
expect(r).toContain("> #1 [assistant] world");
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
});
|