@monotykamary/pi-vcc 0.3.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +688 -0
  2. package/__tests__/helpers.ts +135 -0
  3. package/__tests__/root-causes.test.ts +858 -0
  4. package/demo.gif +0 -0
  5. package/index.ts +21 -0
  6. package/knip.json +4 -0
  7. package/npm-shrinkwrap.json +3001 -0
  8. package/package.json +36 -0
  9. package/scripts/audit-sessions.ts +88 -0
  10. package/scripts/benchmark-real-sessions.ts +25 -0
  11. package/scripts/compare-before-after.ts +36 -0
  12. package/scripts/dump-branch-output.ts +20 -0
  13. package/src/commands/pi-vcc.ts +36 -0
  14. package/src/commands/vcc-recall.ts +65 -0
  15. package/src/core/brief.ts +497 -0
  16. package/src/core/build-sections.ts +431 -0
  17. package/src/core/content.ts +71 -0
  18. package/src/core/filter-noise.ts +42 -0
  19. package/src/core/format-recall.ts +177 -0
  20. package/src/core/format.ts +98 -0
  21. package/src/core/invisible-continue.ts +172 -0
  22. package/src/core/lineage.ts +26 -0
  23. package/src/core/load-messages.ts +40 -0
  24. package/src/core/normalize.ts +73 -0
  25. package/src/core/recall-scope.ts +49 -0
  26. package/src/core/render-entries.ts +58 -0
  27. package/src/core/report.ts +237 -0
  28. package/src/core/sanitize.ts +5 -0
  29. package/src/core/search-entries.ts +375 -0
  30. package/src/core/settings.ts +190 -0
  31. package/src/core/skill-collapse.ts +35 -0
  32. package/src/core/summarize.ts +179 -0
  33. package/src/core/tool-args.ts +14 -0
  34. package/src/details.ts +19 -0
  35. package/src/extract/commits.ts +69 -0
  36. package/src/extract/goals.ts +79 -0
  37. package/src/extract/preferences.ts +55 -0
  38. package/src/extract/shared-symbols.ts +357 -0
  39. package/src/hooks/before-compact.ts +564 -0
  40. package/src/hooks/proactive-threshold.ts +131 -0
  41. package/src/sections.ts +23 -0
  42. package/src/tools/recall.ts +204 -0
  43. package/src/types.ts +20 -0
  44. package/tests/australian-government.test.ts +479 -0
  45. package/tests/before-compact-hook.test.ts +185 -0
  46. package/tests/before-compact.test.ts +227 -0
  47. package/tests/brief.test.ts +215 -0
  48. package/tests/build-sections.test.ts +59 -0
  49. package/tests/compile.test.ts +89 -0
  50. package/tests/content.test.ts +66 -0
  51. package/tests/deep-extraction.test.ts +313 -0
  52. package/tests/extract-goals.test.ts +86 -0
  53. package/tests/extract-preferences.test.ts +30 -0
  54. package/tests/filter-noise.test.ts +61 -0
  55. package/tests/fixtures.ts +61 -0
  56. package/tests/format-recall.test.ts +87 -0
  57. package/tests/format.test.ts +73 -0
  58. package/tests/government-domain.test.ts +542 -0
  59. package/tests/lineage.test.ts +33 -0
  60. package/tests/load-messages.test.ts +49 -0
  61. package/tests/model-threshold-hook.test.ts +377 -0
  62. package/tests/model-threshold.test.ts +223 -0
  63. package/tests/normalize.test.ts +104 -0
  64. package/tests/proactive-threshold.test.ts +490 -0
  65. package/tests/real-sessions.test.ts +38 -0
  66. package/tests/recall-expand.test.ts +15 -0
  67. package/tests/recall-scope.test.ts +32 -0
  68. package/tests/recall-tool-scope.test.ts +67 -0
  69. package/tests/render-entries.test.ts +92 -0
  70. package/tests/report.test.ts +58 -0
  71. package/tests/sanitize.test.ts +24 -0
  72. package/tests/search-entries.test.ts +233 -0
  73. package/tests/support/load-session.ts +23 -0
  74. package/tests/support/real-sessions.ts +51 -0
  75. package/tests/threshold-integration.test.ts +359 -0
  76. package/tsconfig.json +20 -0
@@ -0,0 +1,67 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { registerRecallTool } from "../src/tools/recall";
6
+
7
+ const makeSession = () => {
8
+ const dir = mkdtempSync(join(tmpdir(), "pi-vcc-recall-scope-"));
9
+ const file = join(dir, "session.jsonl");
10
+ const lines = [
11
+ JSON.stringify({ type: "message", id: "m1", message: { role: "user", content: "active lineage token" } }),
12
+ JSON.stringify({ type: "message", id: "m2", message: { role: "user", content: "off lineage secret" } }),
13
+ ];
14
+ writeFileSync(file, lines.join("\n") + "\n", "utf8");
15
+ return { dir, file };
16
+ };
17
+
18
+ const register = () => {
19
+ let tool: any;
20
+ registerRecallTool({ registerTool: (t: any) => { tool = t; } } as any);
21
+ return tool;
22
+ };
23
+
24
+ const invoke = async (tool: any, file: string, params: Record<string, unknown>) => {
25
+ const result = await tool.execute("tool-call", params, undefined, undefined, {
26
+ sessionManager: {
27
+ getSessionFile: () => file,
28
+ getBranch: () => [{ id: "m1" }],
29
+ getEntries: () => [{ id: "m1" }, { id: "m2" }],
30
+ },
31
+ });
32
+ return result.content[0].text as string;
33
+ };
34
+
35
+ describe("vcc_recall scope", () => {
36
+ it("defaults to active lineage and opts into all-session search explicitly", async () => {
37
+ const { dir, file } = makeSession();
38
+ try {
39
+ const tool = register();
40
+
41
+ const lineage = await invoke(tool, file, { query: "secret" });
42
+ expect(lineage).toContain("No matches");
43
+
44
+ const all = await invoke(tool, file, { query: "secret", scope: "all" });
45
+ expect(all).toContain("scope: all");
46
+ expect(all).toContain("off lineage secret");
47
+ } finally {
48
+ rmSync(dir, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ it("keeps expand strict by default but allows off-lineage expand with scope all", async () => {
53
+ const { dir, file } = makeSession();
54
+ try {
55
+ const tool = register();
56
+
57
+ const lineage = await invoke(tool, file, { expand: [1] });
58
+ expect(lineage).toContain("Cannot expand indices outside active lineage: 1");
59
+
60
+ const all = await invoke(tool, file, { expand: [1], scope: "all" });
61
+ expect(all).toContain("Scope: all");
62
+ expect(all).toContain("#1 [user] off lineage secret");
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
67
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { renderMessage } from "../src/core/render-entries";
3
+ import type { Message } from "@earendil-works/pi-ai";
4
+ import { userMsg, assistantText, assistantWithToolCall, assistantWithThinking, toolResult } from "./fixtures";
5
+
6
+ describe("renderMessage", () => {
7
+ it("renders user message", () => {
8
+ const r = renderMessage(userMsg("hello"), 0);
9
+ expect(r).toEqual({ index: 0, role: "user", summary: "hello" });
10
+ });
11
+
12
+ it("renders assistant text", () => {
13
+ const r = renderMessage(assistantText("done"), 1);
14
+ expect(r.role).toBe("assistant");
15
+ expect(r.summary).toBe("done");
16
+ });
17
+
18
+ it("renders tool result", () => {
19
+ const r = renderMessage(toolResult("Read", "file contents"), 2);
20
+ expect(r.role).toBe("tool_result");
21
+ expect(r.summary).toContain("[Read]");
22
+ });
23
+
24
+ it("renders tool call arguments with values", () => {
25
+ const r = renderMessage(assistantWithToolCall("Read", { path: "a.ts" }), 2);
26
+ expect(r.summary).toContain("Read(path=a.ts)");
27
+ });
28
+
29
+ it("renders error tool result with prefix", () => {
30
+ const r = renderMessage(toolResult("bash", "not found", true), 3);
31
+ expect(r.summary).toStartWith("ERROR");
32
+ });
33
+
34
+ it("truncates long user text", () => {
35
+ const long = "x".repeat(500);
36
+ const r = renderMessage(userMsg(long), 0);
37
+ expect(r.summary.length).toBeLessThanOrEqual(300);
38
+ });
39
+
40
+ it("renders bashExecution message", () => {
41
+ const msg = { role: "bashExecution", command: "ls -la", output: "total 0\n" } as any;
42
+ const r = renderMessage(msg, 5);
43
+ expect(r.role).toBe("bash");
44
+ expect(r.summary).toContain("$ ls -la");
45
+ expect(r.summary).toContain("total 0");
46
+ });
47
+
48
+ it("renders bashExecution with missing output", () => {
49
+ const msg = { role: "bashExecution", command: "exit 1" } as any;
50
+ const r = renderMessage(msg, 6);
51
+ expect(r.role).toBe("bash");
52
+ expect(r.summary).toContain("$ exit 1");
53
+ });
54
+
55
+ it("renders assistant with thinking in recall output", () => {
56
+ const r = renderMessage(assistantWithThinking("Checking auth flow", "The bug is probably in the middleware"), 1);
57
+ expect(r.role).toBe("assistant");
58
+ expect(r.summary).toContain("[thinking]");
59
+ expect(r.summary).toContain("The bug is probably in the middleware");
60
+ expect(r.summary).toContain("Checking auth flow");
61
+ });
62
+
63
+ it("clips thinking in non-full mode", () => {
64
+ const longThinking = "x".repeat(400);
65
+ const r = renderMessage(assistantWithThinking("short", longThinking), 1, false);
66
+ expect(r.summary).toContain("[thinking]");
67
+ // Thinking should be clipped to ~150 chars
68
+ expect(r.summary.length).toBeLessThan(longThinking.length);
69
+ });
70
+
71
+ it("renders full thinking in full mode", () => {
72
+ const longThinking = "x".repeat(400);
73
+ const r = renderMessage(assistantWithThinking("short", longThinking), 1, true);
74
+ expect(r.summary).toContain("[thinking]");
75
+ expect(r.summary).toContain(longThinking);
76
+ });
77
+
78
+ it("renders assistant without thinking normally", () => {
79
+ const r = renderMessage(assistantText("done"), 1);
80
+ expect(r.role).toBe("assistant");
81
+ expect(r.summary).toBe("done");
82
+ expect(r.summary).not.toContain("[thinking]");
83
+ });
84
+
85
+ it("handles message with undefined content", () => {
86
+ const msg = { role: "assistant", content: undefined } as any;
87
+ const r = renderMessage(msg, 3);
88
+ expect(r.role).toBe("assistant");
89
+ expect(r.summary).toBe("");
90
+ });
91
+ });
92
+
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { buildCompactReport } from "../src/core/report";
3
+ import {
4
+ userMsg,
5
+ assistantText,
6
+ assistantWithToolCall,
7
+ toolResult,
8
+ } from "./fixtures";
9
+
10
+ describe("buildCompactReport", () => {
11
+ it("includes before and after compact metrics", () => {
12
+ const report = buildCompactReport({
13
+ messages: [
14
+ userMsg("Fix login bug in auth.ts"),
15
+ assistantWithToolCall("Read", { path: "auth.ts" }),
16
+ toolResult("Read", "const x = 1;"),
17
+ assistantText("Found the root cause in auth.ts.\n1. Fix validation\n2. Run tests"),
18
+ ],
19
+ });
20
+ expect(report.before.messageCount).toBe(4);
21
+ expect(report.before.roleCounts.user).toBe(1);
22
+ expect(report.before.topFiles).toContain("auth.ts");
23
+ expect(report.before.preview).toContain("Fix login bug in auth.ts");
24
+ expect(report.after.sectionCount).toBeGreaterThan(0);
25
+ expect(report.after.summaryPreview).toContain("[Session Goal]");
26
+ expect(report.after.summaryPreview).toContain('* Read "auth.ts"');
27
+ expect(report.after.briefTranscriptLines).toBeGreaterThan(0);
28
+ expect(report.compression.ratio).toBeGreaterThan(0);
29
+ });
30
+
31
+ it("marks recall probe coverage for goal and file queries", () => {
32
+ const report = buildCompactReport({
33
+ messages: [
34
+ userMsg("Fix login bug in auth.ts"),
35
+ assistantWithToolCall("Read", { path: "auth.ts" }),
36
+ toolResult("Read", "code content"),
37
+ assistantText("Found the root cause"),
38
+ ],
39
+ });
40
+ expect(report.recall.probes.length).toBeGreaterThanOrEqual(1);
41
+ const goalProbe = report.recall.probes.find((p) => p.label === "goal");
42
+ expect(goalProbe?.summaryMentioned).toBe(true);
43
+ });
44
+
45
+ it("counts recall hits with the probe query", () => {
46
+ const report = buildCompactReport({
47
+ messages: [
48
+ userMsg("Handle unrelated setup"),
49
+ assistantWithToolCall("Read", { path: "unique-auth-file.ts" }),
50
+ toolResult("Read", "code content"),
51
+ assistantText("Done"),
52
+ ],
53
+ });
54
+ const fileProbe = report.recall.probes.find((p) => p.label === "file");
55
+ expect(fileProbe?.query).toBe("unique-auth-file.ts");
56
+ expect(fileProbe?.recallHits).toBe(1);
57
+ });
58
+ });
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { sanitize } from "../src/core/sanitize";
3
+
4
+ describe("sanitize", () => {
5
+ it("strips ANSI escape codes", () => {
6
+ expect(sanitize("\x1b[31mred\x1b[0m")).toBe("red");
7
+ });
8
+
9
+ it("normalizes CRLF to LF", () => {
10
+ expect(sanitize("a\r\nb\r\n")).toBe("a\nb\n");
11
+ });
12
+
13
+ it("strips bare CR", () => {
14
+ expect(sanitize("a\rb")).toBe("a\nb");
15
+ });
16
+
17
+ it("strips control characters but preserves newlines and tabs", () => {
18
+ expect(sanitize("a\x00b\tc\nd")).toBe("ab\tc\nd");
19
+ });
20
+
21
+ it("passes clean text unchanged", () => {
22
+ expect(sanitize("hello world")).toBe("hello world");
23
+ });
24
+ });
@@ -0,0 +1,233 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { searchEntries } from "../src/core/search-entries";
3
+ import type { RenderedEntry } from "../src/core/render-entries";
4
+ import type { Message } from "@earendil-works/pi-ai";
5
+
6
+ const entries: RenderedEntry[] = [
7
+ { index: 0, role: "user", summary: "Fix login bug" },
8
+ { index: 1, role: "assistant", summary: "Reading auth.ts" },
9
+ { index: 2, role: "tool_result", summary: "[Read] code here" },
10
+ { index: 3, role: "assistant", summary: "Found the root cause in auth module" },
11
+ ];
12
+
13
+ const messages: Message[] = [
14
+ { role: "user", content: "Fix login bug" } as any,
15
+ { role: "assistant", content: [{ type: "text", text: "Reading auth.ts" }] } as any,
16
+ { role: "toolResult", content: [{ type: "text", text: "[Read] code here" }] } as any,
17
+ { role: "assistant", content: [{ type: "text", text: "Found the root cause in auth module" }] } as any,
18
+ ];
19
+
20
+ describe("searchEntries", () => {
21
+ it("returns all for empty query", () => {
22
+ expect(searchEntries(entries, messages)).toEqual(entries);
23
+ expect(searchEntries(entries, messages, "")).toEqual(entries);
24
+ });
25
+
26
+ it("filters by single term", () => {
27
+ const r = searchEntries(entries, messages, "login");
28
+ expect(r).toHaveLength(1);
29
+ expect(r[0].index).toBe(0);
30
+ });
31
+
32
+ it("returns empty for no match", () => {
33
+ expect(searchEntries(entries, messages, "xyz123")).toEqual([]);
34
+ });
35
+
36
+ it("finds keyword beyond clip boundary in full content", () => {
37
+ const longText = "A".repeat(400) + " hidden_keyword here";
38
+ const longEntries: RenderedEntry[] = [
39
+ { index: 0, role: "user", summary: "A".repeat(300) },
40
+ ];
41
+ const longMsgs: Message[] = [
42
+ { role: "user", content: longText } as any,
43
+ ];
44
+ const r = searchEntries(longEntries, longMsgs, "hidden_keyword");
45
+ expect(r).toHaveLength(1);
46
+ expect(r[0].snippet).toContain("hidden_keyword");
47
+ });
48
+
49
+ it("returns snippet around matched term", () => {
50
+ const r = searchEntries(entries, messages, "root");
51
+ expect(r).toHaveLength(1);
52
+ expect(r[0].snippet).toBeDefined();
53
+ expect(r[0].snippet).toContain("root");
54
+ });
55
+
56
+ // ── regex support ──
57
+
58
+ it("supports regex pattern: alternation", () => {
59
+ const r = searchEntries(entries, messages, "login|auth");
60
+ expect(r).toHaveLength(3); // "login bug", "auth.ts", "auth module"
61
+ expect(r.map((h) => h.index).sort()).toEqual([0, 1, 3]);
62
+ });
63
+
64
+ it("supports regex pattern: wildcard", () => {
65
+ const r = searchEntries(entries, messages, "Read.*auth");
66
+ expect(r).toHaveLength(1);
67
+ expect(r[0].index).toBe(1);
68
+ });
69
+
70
+ it("falls back to escaped literal for invalid regex", () => {
71
+ const extraEntries: RenderedEntry[] = [
72
+ { index: 0, role: "user", summary: "test (foo" },
73
+ { index: 1, role: "assistant", summary: "no match here" },
74
+ ];
75
+ const extraMsgs: Message[] = [
76
+ { role: "user", content: "error with (foo pattern" } as any,
77
+ { role: "assistant", content: [{ type: "text", text: "no match here" }] } as any,
78
+ ];
79
+ const r = searchEntries(extraEntries, extraMsgs, "(foo");
80
+ expect(r).toHaveLength(1);
81
+ expect(r[0].index).toBe(0);
82
+ });
83
+
84
+ it("regex is case-insensitive", () => {
85
+ const r = searchEntries(entries, messages, "FIX|ROOT");
86
+ expect(r).toHaveLength(2);
87
+ });
88
+
89
+ // ── natural language queries (OR logic + ranking) ──
90
+
91
+ it("natural language query uses OR logic with multi-term minimum", () => {
92
+ // "root cause auth" -- 3 meaningful terms → requires ≥2 matches
93
+ // #3 has all 3 (root, cause, auth); #1 has only auth (1 term, below threshold)
94
+ const r = searchEntries(entries, messages, "root cause auth");
95
+ expect(r.length).toBe(1);
96
+ expect(r[0].index).toBe(3); // "Found the root cause in auth module" matches all 3
97
+ });
98
+
99
+ it("2-term OR query matches on single term (no multi-term floor)", () => {
100
+ // 2 meaningful terms → no floor, matches entries with ANY term
101
+ const r = searchEntries(entries, messages, "login auth");
102
+ expect(r.length).toBeGreaterThanOrEqual(2); // #0 has login, #1 + #3 have auth
103
+ });
104
+
105
+ it("natural language ranks by BM25 score", () => {
106
+ const r = searchEntries(entries, messages, "root cause auth");
107
+ // Top result has more terms matched = higher BM25 score
108
+ expect(r[0].matchCount!).toBeGreaterThanOrEqual(r[r.length - 1].matchCount!);
109
+ });
110
+
111
+ it("filters stopwords from queries", () => {
112
+ // "the root cause of it" → stopwords: the, of, it → meaningful: root, cause
113
+ const r = searchEntries(entries, messages, "the root cause of it");
114
+ expect(r).toHaveLength(1);
115
+ expect(r[0].index).toBe(3);
116
+ });
117
+
118
+ it("keeps all terms if all are stopwords", () => {
119
+ // When all terms are stopwords, keep them (don't drop everything)
120
+ // "the" appears in "Found the root cause" so it matches
121
+ const r = searchEntries(entries, messages, "the");
122
+ expect(r.length).toBeGreaterThan(0);
123
+ });
124
+
125
+ // ── line-based snippet ──
126
+
127
+ it("snippet shows context lines around match", () => {
128
+ const multiline = "line 0\nline 1\nline 2 TARGET\nline 3\nline 4\nline 5";
129
+ const e: RenderedEntry[] = [{ index: 0, role: "user", summary: "test" }];
130
+ const m: Message[] = [{ role: "user", content: multiline } as any];
131
+ const r = searchEntries(e, m, "TARGET");
132
+ expect(r).toHaveLength(1);
133
+ const snip = r[0].snippet!;
134
+ expect(snip).toContain("line 2 TARGET");
135
+ expect(snip).toContain("line 0");
136
+ expect(snip).toContain("line 4");
137
+ expect(snip).not.toContain("line 5");
138
+ });
139
+
140
+ it("snippet handles match at beginning", () => {
141
+ const multiline = "TARGET here\nline 1\nline 2\nline 3";
142
+ const e: RenderedEntry[] = [{ index: 0, role: "user", summary: "test" }];
143
+ const m: Message[] = [{ role: "user", content: multiline } as any];
144
+ const r = searchEntries(e, m, "TARGET");
145
+ const snip = r[0].snippet!;
146
+ expect(snip).toContain("TARGET here");
147
+ expect(snip).toContain("line 2");
148
+ expect(snip).not.toContain("line 3");
149
+ });
150
+
151
+ // ── thinking content searchability ──
152
+
153
+ it("finds terms in thinking content", () => {
154
+ const e: RenderedEntry[] = [
155
+ { index: 0, role: "user", summary: "Fix the parser" },
156
+ { index: 1, role: "assistant", summary: "Looking at parser" },
157
+ ];
158
+ const m: Message[] = [
159
+ { role: "user", content: "Fix the parser" } as any,
160
+ { role: "assistant", content: [
161
+ { type: "thinking", thinking: "The race condition is in the event emitter" },
162
+ { type: "text", text: "Looking at parser" },
163
+ ] } as any,
164
+ ];
165
+ const r = searchEntries(e, m, "race condition");
166
+ expect(r).toHaveLength(1);
167
+ expect(r[0].index).toBe(1);
168
+ expect(r[0].snippet).toContain("race condition");
169
+ });
170
+
171
+ // ── search result grounding (prevents "half the session" returns) ──
172
+
173
+ it("filters low-relevance BM25 hits by score ratio threshold", () => {
174
+ // Build a session where one entry is highly relevant and many are barely relevant
175
+ const govEntries: RenderedEntry[] = [
176
+ { index: 0, role: "user", summary: "Review the policy document" },
177
+ { index: 1, role: "assistant", summary: "I found the policy document. It covers the review process." },
178
+ { index: 2, role: "user", summary: "Check the deployment pipeline" },
179
+ { index: 3, role: "assistant", summary: "The pipeline has a step for document generation" }, // weak match
180
+ { index: 4, role: "user", summary: "Fix the login bug" },
181
+ { index: 5, role: "assistant", summary: "Updated the document in README" }, // very weak
182
+ ];
183
+ const govMsgs: Message[] = [
184
+ { role: "user", content: "Review the policy document" } as any,
185
+ { role: "assistant", content: [{ type: "text", text: "I found the policy document. It covers the review process." }] } as any,
186
+ { role: "user", content: "Check the deployment pipeline" } as any,
187
+ { role: "assistant", content: [{ type: "text", text: "The pipeline has a step for document generation" }] } as any,
188
+ { role: "user", content: "Fix the login bug" } as any,
189
+ { role: "assistant", content: [{ type: "text", text: "Updated the document in README" }] } as any,
190
+ ];
191
+ // 2-term query: policy + review → no multi-term floor
192
+ // Should return both strong matches but may filter very weak ones
193
+ const r = searchEntries(govEntries, govMsgs, "policy review");
194
+ // #0 and #1 are strong matches; #3 and #5 only have "document" which isn't queried
195
+ expect(r.length).toBeLessThanOrEqual(2);
196
+ expect(r[0].index).toBe(0); // highest BM25: matches both terms
197
+ });
198
+
199
+ it("requires 2+ term matches for 3+ term queries", () => {
200
+ // Simulates a government session with common domain vocabulary
201
+ const govEntries: RenderedEntry[] = [
202
+ { index: 0, role: "user", summary: "Review the policy document" },
203
+ { index: 1, role: "assistant", summary: "Checking policy" }, // only 1/3 terms
204
+ { index: 2, role: "user", summary: "Fix login bug" }, // 0/3 terms
205
+ { index: 3, role: "assistant", summary: "Review policy and update document" }, // 2/3 terms
206
+ ];
207
+ const govMsgs: Message[] = [
208
+ { role: "user", content: "Review the policy document" } as any,
209
+ { role: "assistant", content: [{ type: "text", text: "Checking policy" }] } as any,
210
+ { role: "user", content: "Fix login bug" } as any,
211
+ { role: "assistant", content: [{ type: "text", text: "Review policy and update document" }] } as any,
212
+ ];
213
+ // "review policy document" = 3 terms → requires ≥2 matches
214
+ // #0 matches all 3, #1 matches only "policy" (1 term → filtered), #3 matches "review" + "document" (2 terms → kept)
215
+ const r = searchEntries(govEntries, govMsgs, "review policy document");
216
+ expect(r.length).toBe(2);
217
+ const indices = r.map((h) => h.index).sort();
218
+ expect(indices).toContain(0);
219
+ expect(indices).toContain(3);
220
+ });
221
+
222
+ it("hard caps regex search results", () => {
223
+ // Build many entries that all match the same regex
224
+ const manyEntries: RenderedEntry[] = Array.from({ length: 100 }, (_, i) => ({
225
+ index: i, role: "user" as const, summary: `document ${i} review`,
226
+ }));
227
+ const manyMsgs: Message[] = Array.from({ length: 100 }, (_, i) => ({
228
+ role: "user" as const, content: `document ${i} review`,
229
+ } as any));
230
+ const r = searchEntries(manyEntries, manyMsgs, "document.*review");
231
+ expect(r.length).toBeLessThanOrEqual(50); // MAX_SEARCH_RESULTS
232
+ });
233
+ });
@@ -0,0 +1,23 @@
1
+ import { buildSessionContext, loadEntriesFromFile } from "../../node_modules/@earendil-works/pi-coding-agent/dist/core/session-manager.js";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
+
4
+ export interface LoadedSession {
5
+ messageCount: number;
6
+ skippedCount: number;
7
+ messages: Message[];
8
+ }
9
+
10
+ export const loadSessionMessages = (file: string): LoadedSession => {
11
+ const entries = loadEntriesFromFile(file);
12
+ const sessionEntries = entries.filter((entry) => entry.type !== "header");
13
+ const context = buildSessionContext(sessionEntries as any);
14
+ const messages = (context.messages as any[]).filter(
15
+ (msg): msg is Message =>
16
+ msg && typeof msg.role === "string" && "content" in msg,
17
+ );
18
+ return {
19
+ messageCount: messages.length,
20
+ skippedCount: context.messages.length - messages.length,
21
+ messages,
22
+ };
23
+ };
@@ -0,0 +1,51 @@
1
+ import { mkdir, mkdtemp, copyFile, chmod, readdir, stat } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join, basename } from "node:path";
4
+
5
+ const SESSION_ROOT = join(process.env.HOME ?? "", ".pi/agent/sessions");
6
+
7
+ export interface SessionSample {
8
+ source: string;
9
+ copy: string;
10
+ size: number;
11
+ mtimeMs: number;
12
+ }
13
+
14
+ const walk = async (dir: string): Promise<string[]> => {
15
+ const names = await readdir(dir, { withFileTypes: true });
16
+ const out: string[] = [];
17
+ for (const name of names) {
18
+ const path = join(dir, name.name);
19
+ if (name.isDirectory()) out.push(...await walk(path));
20
+ else if (name.isFile() && path.endsWith(".jsonl")) out.push(path);
21
+ }
22
+ return out;
23
+ };
24
+
25
+ const pickLargest = async (limit: number): Promise<string[]> => {
26
+ const files = await walk(SESSION_ROOT);
27
+ const sized = await Promise.all(
28
+ files.map(async (file) => ({ file, size: (await stat(file)).size })),
29
+ );
30
+ return sized.sort((a, b) => b.size - a.size).slice(0, limit).map((x) => x.file);
31
+ };
32
+
33
+ export const prepareSessionSamples = async (limit = 2): Promise<SessionSample[]> => {
34
+ const selected = await pickLargest(limit);
35
+ const dir = await mkdtemp(join(tmpdir(), "pi-vcc-sessions-"));
36
+ await mkdir(dir, { recursive: true });
37
+ const samples: SessionSample[] = [];
38
+ for (const source of selected) {
39
+ const srcStat = await stat(source);
40
+ const copy = join(dir, basename(source));
41
+ await copyFile(source, copy);
42
+ await chmod(copy, 0o444);
43
+ samples.push({ source, copy, size: srcStat.size, mtimeMs: srcStat.mtimeMs });
44
+ }
45
+ return samples;
46
+ };
47
+
48
+ export const readSourceStat = async (sample: SessionSample) => {
49
+ const s = await stat(sample.source);
50
+ return { size: s.size, mtimeMs: s.mtimeMs };
51
+ };