pi-vcc 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +120 -0
- package/demo.gif +0 -0
- package/flow/plans/20260515-1300/plan.md +206 -0
- package/index.ts +14 -0
- package/package.json +36 -0
- package/pi-vcc-config.schema.json +131 -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 +33 -0
- package/src/commands/vcc-recall.ts +65 -0
- package/src/core/brief.ts +381 -0
- package/src/core/build-sections.ts +87 -0
- package/src/core/content.ts +60 -0
- package/src/core/filter-noise.ts +42 -0
- package/src/core/format-recall.ts +27 -0
- package/src/core/format.ts +56 -0
- package/src/core/lineage.ts +26 -0
- package/src/core/load-messages.ts +63 -0
- package/src/core/normalize.ts +66 -0
- package/src/core/recall-scope.ts +14 -0
- package/src/core/render-entries.ts +68 -0
- package/src/core/report.ts +237 -0
- package/src/core/sanitize.ts +5 -0
- package/src/core/search-entries.ts +230 -0
- package/src/core/settings.ts +215 -0
- package/src/core/skill-collapse.ts +35 -0
- package/src/core/summarize.ts +159 -0
- package/src/core/tool-args.ts +14 -0
- package/src/details.ts +7 -0
- package/src/extract/commits.ts +69 -0
- package/src/extract/files.ts +80 -0
- package/src/extract/goals.ts +79 -0
- package/src/extract/preferences.ts +55 -0
- package/src/extract/references.ts +214 -0
- package/src/extract/signals.ts +145 -0
- package/src/hooks/before-compact.ts +405 -0
- package/src/sections.ts +14 -0
- package/src/tools/recall.ts +109 -0
- package/src/types.ts +14 -0
- package/tests/before-compact-hook.test.ts +181 -0
- package/tests/before-compact.test.ts +140 -0
- package/tests/brief.test.ts +206 -0
- package/tests/build-sections.test.ts +90 -0
- package/tests/compile.test.ts +110 -0
- package/tests/config-integration.test.ts +107 -0
- package/tests/content.test.ts +31 -0
- package/tests/edge-cases.test.ts +368 -0
- package/tests/extract-goals.test.ts +86 -0
- package/tests/extract-preferences.test.ts +30 -0
- package/tests/extract-references.test.ts +475 -0
- package/tests/extract-signals.test.ts +561 -0
- package/tests/filter-noise.test.ts +61 -0
- package/tests/fixtures.ts +61 -0
- package/tests/format-recall.test.ts +30 -0
- package/tests/format.test.ts +91 -0
- package/tests/lineage.test.ts +33 -0
- package/tests/load-messages.test.ts +51 -0
- package/tests/normalize.test.ts +97 -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 +62 -0
- package/tests/report.test.ts +44 -0
- package/tests/sanitize.test.ts +24 -0
- package/tests/search-entries.test.ts +144 -0
- package/tests/settings-scaffold.test.ts +120 -0
- package/tests/settings.test.ts +32 -0
- package/tests/support/load-session.ts +23 -0
- package/tests/support/real-sessions.ts +51 -0
- package/tsconfig.json +14 -0
- package/vitest.config.ts +7 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatRecallOutput } from "../src/core/format-recall";
|
|
3
|
+
import type { RenderedEntry } from "../src/core/render-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
|
+
it("formats entries with index and role", () => {
|
|
16
|
+
const entries: RenderedEntry[] = [
|
|
17
|
+
{ index: 0, role: "user", summary: "hello" },
|
|
18
|
+
];
|
|
19
|
+
const r = formatRecallOutput(entries);
|
|
20
|
+
expect(r).toContain("#0 [user] hello");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("shows match count with query", () => {
|
|
24
|
+
const entries: RenderedEntry[] = [
|
|
25
|
+
{ index: 2, role: "assistant", summary: "done" },
|
|
26
|
+
];
|
|
27
|
+
const r = formatRecallOutput(entries, "done");
|
|
28
|
+
expect(r).toContain('Found 1 matches for "done"');
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { formatSummary } from "../src/core/format";
|
|
3
|
+
import type { SectionData } from "../src/sections";
|
|
4
|
+
|
|
5
|
+
const empty: SectionData = {
|
|
6
|
+
sessionGoal: [],
|
|
7
|
+
outstandingContext: [],
|
|
8
|
+
filesAndChanges: [],
|
|
9
|
+
commits: [],
|
|
10
|
+
references: [],
|
|
11
|
+
keySignals: [],
|
|
12
|
+
userPreferences: [],
|
|
13
|
+
briefTranscript: "",
|
|
14
|
+
transcriptEntries: [],
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
describe("formatSummary", () => {
|
|
18
|
+
it("returns empty string for all-empty sections", () => {
|
|
19
|
+
expect(formatSummary(empty)).toBe("");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("formats a single header section", () => {
|
|
23
|
+
const data = {
|
|
24
|
+
...empty,
|
|
25
|
+
sessionGoal: ["fix auth bug"],
|
|
26
|
+
};
|
|
27
|
+
const r = formatSummary(data);
|
|
28
|
+
expect(r).toContain("[Session Goal]");
|
|
29
|
+
expect(r).toContain("- fix auth bug");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("separates header and brief transcript with ---", () => {
|
|
33
|
+
const data = {
|
|
34
|
+
...empty,
|
|
35
|
+
sessionGoal: ["goal"],
|
|
36
|
+
briefTranscript: "[user]\ndo something",
|
|
37
|
+
};
|
|
38
|
+
const r = formatSummary(data);
|
|
39
|
+
expect(r).toContain("[Session Goal]");
|
|
40
|
+
expect(r).toContain("---");
|
|
41
|
+
expect(r).toContain("[user]\ndo something");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("renders brief transcript alone when no header sections", () => {
|
|
45
|
+
const data = {
|
|
46
|
+
...empty,
|
|
47
|
+
briefTranscript: "[user]\nhi\n\n[assistant]\nhello",
|
|
48
|
+
};
|
|
49
|
+
const r = formatSummary(data);
|
|
50
|
+
expect(r).toContain("[user]\nhi\n\n[assistant]\nhello");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("joins multiple header sections with blank line", () => {
|
|
54
|
+
const data = {
|
|
55
|
+
...empty,
|
|
56
|
+
sessionGoal: ["goal"],
|
|
57
|
+
outstandingContext: ["blocker"],
|
|
58
|
+
};
|
|
59
|
+
const r = formatSummary(data);
|
|
60
|
+
expect(r).toContain("[Session Goal]");
|
|
61
|
+
expect(r).toContain("[Outstanding Context]");
|
|
62
|
+
expect(r).toContain("\n\n");
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("renders References section after Commits", () => {
|
|
66
|
+
const data = {
|
|
67
|
+
...empty,
|
|
68
|
+
commits: ["abc1234: fix bug"],
|
|
69
|
+
references: ["URL: https://example.com", "GitHub: #42"],
|
|
70
|
+
};
|
|
71
|
+
const r = formatSummary(data);
|
|
72
|
+
expect(r).toContain("[Commits]");
|
|
73
|
+
expect(r).toContain("[References]");
|
|
74
|
+
expect(r).toContain("- URL: https://example.com");
|
|
75
|
+
expect(r).toContain("- GitHub: #42");
|
|
76
|
+
// References should appear after Commits in output
|
|
77
|
+
const commitsIdx = r.indexOf("[Commits]");
|
|
78
|
+
const refsIdx = r.indexOf("[References]");
|
|
79
|
+
expect(refsIdx).toBeGreaterThan(commitsIdx);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("skips References section when empty", () => {
|
|
83
|
+
const data = {
|
|
84
|
+
...empty,
|
|
85
|
+
sessionGoal: ["goal"],
|
|
86
|
+
references: [],
|
|
87
|
+
};
|
|
88
|
+
const r = formatSummary(data);
|
|
89
|
+
expect(r).not.toContain("[References]");
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { getActiveLineageEntryIds } from "../src/core/lineage";
|
|
3
|
+
|
|
4
|
+
describe("getActiveLineageEntryIds", () => {
|
|
5
|
+
it("returns IDs from active branch", () => {
|
|
6
|
+
const ids = getActiveLineageEntryIds({
|
|
7
|
+
getBranch: () => [{ id: "a" }, { id: "b" }, { id: "c" }],
|
|
8
|
+
});
|
|
9
|
+
expect([...ids]).toEqual(["a", "b", "c"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("falls back to getEntries when getBranch throws", () => {
|
|
13
|
+
const ids = getActiveLineageEntryIds({
|
|
14
|
+
getBranch: () => {
|
|
15
|
+
throw new Error("boom");
|
|
16
|
+
},
|
|
17
|
+
getEntries: () => [{ id: "x" }, { id: "y" }],
|
|
18
|
+
});
|
|
19
|
+
expect([...ids]).toEqual(["x", "y"]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns empty set when both branch and entries are unavailable", () => {
|
|
23
|
+
const ids = getActiveLineageEntryIds({
|
|
24
|
+
getBranch: () => {
|
|
25
|
+
throw new Error("boom");
|
|
26
|
+
},
|
|
27
|
+
getEntries: () => {
|
|
28
|
+
throw new Error("boom2");
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
expect(ids.size).toBe(0);
|
|
32
|
+
});
|
|
33
|
+
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { loadAllMessages } from "../src/core/load-messages";
|
|
6
|
+
|
|
7
|
+
describe("loadAllMessages", () => {
|
|
8
|
+
it("loads all message entries when no lineage filter is provided", async () => {
|
|
9
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-vcc-load-all-"));
|
|
10
|
+
const file = join(dir, "session.jsonl");
|
|
11
|
+
try {
|
|
12
|
+
const lines = [
|
|
13
|
+
JSON.stringify({ type: "session", id: "s1" }),
|
|
14
|
+
JSON.stringify({ type: "message", id: "m1", message: { role: "user", content: "u1" } }),
|
|
15
|
+
JSON.stringify({ type: "custom", id: "c1", customType: "x", data: {} }),
|
|
16
|
+
JSON.stringify({ type: "message", id: "m2", message: { role: "assistant", content: [{ type: "text", text: "a1" }] } }),
|
|
17
|
+
JSON.stringify({ type: "message", id: "m3", message: { role: "toolResult", toolName: "read", content: [{ type: "text", text: "ok" }] } }),
|
|
18
|
+
];
|
|
19
|
+
writeFileSync(file, lines.join("\n") + "\n", "utf8");
|
|
20
|
+
|
|
21
|
+
const loaded = await loadAllMessages(file, false);
|
|
22
|
+
expect(loaded.rendered).toHaveLength(3);
|
|
23
|
+
expect(loaded.rawMessages).toHaveLength(3);
|
|
24
|
+
expect(loaded.entryIds).toEqual(["m1", "m2", "m3"]);
|
|
25
|
+
expect(loaded.rendered.map((e) => e.index)).toEqual([0, 1, 2]);
|
|
26
|
+
} finally {
|
|
27
|
+
rmSync(dir, { recursive: true, force: true });
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("filters messages by allowed lineage entry IDs and preserves original message index", async () => {
|
|
32
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-vcc-load-filter-"));
|
|
33
|
+
const file = join(dir, "session.jsonl");
|
|
34
|
+
try {
|
|
35
|
+
const lines = [
|
|
36
|
+
JSON.stringify({ type: "message", id: "m1", message: { role: "user", content: "u1" } }),
|
|
37
|
+
JSON.stringify({ type: "message", id: "m2", message: { role: "assistant", content: [{ type: "text", text: "a1" }] } }),
|
|
38
|
+
JSON.stringify({ type: "message", id: "m3", message: { role: "user", content: "u2" } }),
|
|
39
|
+
];
|
|
40
|
+
writeFileSync(file, lines.join("\n") + "\n", "utf8");
|
|
41
|
+
|
|
42
|
+
const loaded = await loadAllMessages(file, false, new Set(["m2"]));
|
|
43
|
+
expect(loaded.rendered).toHaveLength(1);
|
|
44
|
+
expect(loaded.rawMessages).toHaveLength(1);
|
|
45
|
+
expect(loaded.entryIds).toEqual(["m2"]);
|
|
46
|
+
expect(loaded.rendered[0].index).toBe(1);
|
|
47
|
+
} finally {
|
|
48
|
+
rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalize } from "../src/core/normalize";
|
|
3
|
+
import {
|
|
4
|
+
userMsg,
|
|
5
|
+
assistantText,
|
|
6
|
+
assistantWithThinking,
|
|
7
|
+
assistantWithToolCall,
|
|
8
|
+
toolResult,
|
|
9
|
+
} from "./fixtures";
|
|
10
|
+
|
|
11
|
+
describe("normalize", () => {
|
|
12
|
+
it("returns empty for empty input", () => {
|
|
13
|
+
expect(normalize([])).toEqual([]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("normalizes user message (string content)", () => {
|
|
17
|
+
const blocks = normalize([userMsg("fix the bug")]);
|
|
18
|
+
expect(blocks).toEqual([{ kind: "user", text: "fix the bug", sourceIndex: 0 }]);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("normalizes assistant text message", () => {
|
|
22
|
+
const blocks = normalize([assistantText("done")]);
|
|
23
|
+
expect(blocks).toEqual([{ kind: "assistant", text: "done", sourceIndex: 0 }]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("normalizes assistant string content", () => {
|
|
27
|
+
const msg = { ...assistantText("done"), content: "plain text" } as any;
|
|
28
|
+
expect(normalize([msg])).toEqual([{ kind: "assistant", text: "plain text", sourceIndex: 0 }]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("splits assistant thinking + text", () => {
|
|
32
|
+
const blocks = normalize([assistantWithThinking("result", "hmm")]);
|
|
33
|
+
expect(blocks).toHaveLength(2);
|
|
34
|
+
expect(blocks[0]).toEqual({
|
|
35
|
+
kind: "thinking", text: "hmm", redacted: false, sourceIndex: 0,
|
|
36
|
+
});
|
|
37
|
+
expect(blocks[1]).toEqual({ kind: "assistant", text: "result", sourceIndex: 0 });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("normalizes tool call", () => {
|
|
41
|
+
const blocks = normalize([assistantWithToolCall("Read", { path: "a.ts" })]);
|
|
42
|
+
expect(blocks).toEqual([{
|
|
43
|
+
kind: "tool_call", name: "Read", args: { path: "a.ts" }, sourceIndex: 0,
|
|
44
|
+
}]);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("normalizes tool result", () => {
|
|
48
|
+
const blocks = normalize([toolResult("Read", "file contents")]);
|
|
49
|
+
expect(blocks).toEqual([{
|
|
50
|
+
kind: "tool_result", name: "Read",
|
|
51
|
+
text: "file contents", isError: false, sourceIndex: 0,
|
|
52
|
+
}]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("normalizes error tool result", () => {
|
|
56
|
+
const blocks = normalize([toolResult("Edit", "not found", true)]);
|
|
57
|
+
expect(blocks[0]).toMatchObject({
|
|
58
|
+
kind: "tool_result", isError: true,
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("handles mixed message sequence", () => {
|
|
63
|
+
const blocks = normalize([
|
|
64
|
+
userMsg("fix it"),
|
|
65
|
+
assistantWithToolCall("Read", { path: "x.ts" }),
|
|
66
|
+
toolResult("Read", "code"),
|
|
67
|
+
assistantText("done"),
|
|
68
|
+
]);
|
|
69
|
+
expect(blocks).toHaveLength(4);
|
|
70
|
+
expect(blocks.map((b) => b.kind)).toEqual([
|
|
71
|
+
"user", "tool_call", "tool_result", "assistant",
|
|
72
|
+
]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("produces image placeholder for user image content", () => {
|
|
76
|
+
const msg = {
|
|
77
|
+
role: "user" as const,
|
|
78
|
+
content: [
|
|
79
|
+
{ type: "text" as const, text: "look at this" },
|
|
80
|
+
{ type: "image" as const, data: "abc", mimeType: "image/png" },
|
|
81
|
+
],
|
|
82
|
+
timestamp: Date.now(),
|
|
83
|
+
};
|
|
84
|
+
const blocks = normalize([msg]);
|
|
85
|
+
expect(blocks).toHaveLength(2);
|
|
86
|
+
expect(blocks[0]).toEqual({ kind: "user", text: "look at this", sourceIndex: 0 });
|
|
87
|
+
expect(blocks[1]).toEqual({ kind: "user", text: "[image: image/png]", sourceIndex: 0 });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("skips unknown message roles gracefully", () => {
|
|
91
|
+
const weird = { role: "bashExecution", command: "ls", output: "files", exitCode: 0 } as any;
|
|
92
|
+
const blocks = normalize([weird]);
|
|
93
|
+
expect(blocks).toEqual([]);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { beforeAll, describe, expect, it } from "vitest";
|
|
2
|
+
import { buildCompactReport } from "../src/core/report";
|
|
3
|
+
import { prepareSessionSamples, readSourceStat, type SessionSample } from "./support/real-sessions";
|
|
4
|
+
import { loadSessionMessages } from "./support/load-session";
|
|
5
|
+
|
|
6
|
+
let samples: SessionSample[] = [];
|
|
7
|
+
|
|
8
|
+
beforeAll(async () => {
|
|
9
|
+
samples = await prepareSessionSamples(2);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe("real session integration", () => {
|
|
13
|
+
it("compiles copied large sessions without mutating originals", async () => {
|
|
14
|
+
for (const sample of samples) {
|
|
15
|
+
const before = await readSourceStat(sample);
|
|
16
|
+
const loaded = loadSessionMessages(sample.copy);
|
|
17
|
+
const report = await buildCompactReport({ messages: loaded.messages });
|
|
18
|
+
const after = await readSourceStat(sample);
|
|
19
|
+
|
|
20
|
+
expect(loaded.messageCount).toBeGreaterThan(0);
|
|
21
|
+
expect(loaded.skippedCount).toBeGreaterThanOrEqual(0);
|
|
22
|
+
expect(report.summary.length).toBeGreaterThan(0);
|
|
23
|
+
expect(report.summary).toContain("[");
|
|
24
|
+
expect(report.before.preview.length).toBeGreaterThan(0);
|
|
25
|
+
expect(report.after.summaryPreview.length).toBeGreaterThan(0);
|
|
26
|
+
expect(report.compression.charsBefore).toBeGreaterThan(0);
|
|
27
|
+
expect(report.recall.probes.length).toBeGreaterThan(0);
|
|
28
|
+
expect(after).toEqual(before);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("uses read-only copied fixtures", () => {
|
|
33
|
+
for (const sample of samples) {
|
|
34
|
+
expect(sample.copy).not.toBe(sample.source);
|
|
35
|
+
expect(sample.copy.includes("pi-vcc-sessions-")).toBe(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { invalidExpandIndices } from "../src/tools/recall";
|
|
3
|
+
|
|
4
|
+
describe("invalidExpandIndices", () => {
|
|
5
|
+
it("returns indices that are not in available lineage index set", () => {
|
|
6
|
+
const available = new Set([0, 2, 5]);
|
|
7
|
+
expect(invalidExpandIndices([0, 2], available)).toEqual([]);
|
|
8
|
+
expect(invalidExpandIndices([1, 2, 7], available)).toEqual([1, 7]);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("rejects non-integer indices", () => {
|
|
12
|
+
const available = new Set([0, 1, 2]);
|
|
13
|
+
expect(invalidExpandIndices([1.5, 2], available)).toEqual([1.5]);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { normalizeRecallScope, parseRecallScope } from "../src/core/recall-scope";
|
|
3
|
+
|
|
4
|
+
describe("normalizeRecallScope", () => {
|
|
5
|
+
it("defaults to active lineage", () => {
|
|
6
|
+
expect(normalizeRecallScope()).toBe("lineage");
|
|
7
|
+
expect(normalizeRecallScope("lineage")).toBe("lineage");
|
|
8
|
+
expect(normalizeRecallScope("unknown")).toBe("lineage");
|
|
9
|
+
expect(normalizeRecallScope(123)).toBe("lineage");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("accepts all scope", () => {
|
|
13
|
+
expect(normalizeRecallScope("all")).toBe("all");
|
|
14
|
+
expect(normalizeRecallScope("ALL")).toBe("all");
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
describe("parseRecallScope", () => {
|
|
19
|
+
it("removes scope token from command text", () => {
|
|
20
|
+
expect(parseRecallScope("license scope:all page:2")).toEqual({
|
|
21
|
+
scope: "all",
|
|
22
|
+
text: "license page:2",
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("defaults to lineage when no scope token is present", () => {
|
|
27
|
+
expect(parseRecallScope("license page:2")).toEqual({
|
|
28
|
+
scope: "lineage",
|
|
29
|
+
text: "license page:2",
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
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,62 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { renderMessage } from "../src/core/render-entries";
|
|
3
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
4
|
+
import { userMsg, assistantText, assistantWithToolCall, 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.startsWith("ERROR")).toBe(true);
|
|
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("handles message with undefined content", () => {
|
|
56
|
+
const msg = { role: "assistant", content: undefined } as any;
|
|
57
|
+
const r = renderMessage(msg, 3);
|
|
58
|
+
expect(r.role).toBe("assistant");
|
|
59
|
+
expect(r.summary).toBe("");
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
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", async () => {
|
|
12
|
+
const report = await 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", async () => {
|
|
32
|
+
const report = await 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
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
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
|
+
});
|