@sting8k/pi-vcc 0.1.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.
Files changed (49) hide show
  1. package/README.md +149 -0
  2. package/index.ts +10 -0
  3. package/package.json +36 -0
  4. package/scripts/audit-sessions.ts +88 -0
  5. package/scripts/benchmark-real-sessions.ts +25 -0
  6. package/scripts/compare-before-after.ts +36 -0
  7. package/src/commands/pi-vcc.ts +11 -0
  8. package/src/core/build-sections.ts +119 -0
  9. package/src/core/content.ts +20 -0
  10. package/src/core/filter-noise.ts +42 -0
  11. package/src/core/format-recall.ts +23 -0
  12. package/src/core/format.ts +34 -0
  13. package/src/core/normalize.ts +62 -0
  14. package/src/core/redact.ts +8 -0
  15. package/src/core/render-entries.ts +48 -0
  16. package/src/core/report.ts +225 -0
  17. package/src/core/sanitize.ts +5 -0
  18. package/src/core/search-entries.ts +14 -0
  19. package/src/core/summarize.ts +81 -0
  20. package/src/core/tool-args.ts +14 -0
  21. package/src/details.ts +7 -0
  22. package/src/extract/decisions.ts +32 -0
  23. package/src/extract/files.ts +46 -0
  24. package/src/extract/findings.ts +27 -0
  25. package/src/extract/goals.ts +41 -0
  26. package/src/extract/preferences.ts +30 -0
  27. package/src/hooks/before-compact.ts +141 -0
  28. package/src/sections.ts +11 -0
  29. package/src/tools/recall.ts +85 -0
  30. package/src/types.ts +14 -0
  31. package/tests/build-sections.test.ts +56 -0
  32. package/tests/compile.test.ts +50 -0
  33. package/tests/extract-decisions.test.ts +30 -0
  34. package/tests/extract-files.test.ts +62 -0
  35. package/tests/extract-findings.test.ts +39 -0
  36. package/tests/extract-goals.test.ts +86 -0
  37. package/tests/extract-preferences.test.ts +30 -0
  38. package/tests/filter-noise.test.ts +61 -0
  39. package/tests/fixtures.ts +61 -0
  40. package/tests/format-recall.test.ts +30 -0
  41. package/tests/format.test.ts +47 -0
  42. package/tests/normalize.test.ts +97 -0
  43. package/tests/real-sessions.test.ts +38 -0
  44. package/tests/render-entries.test.ts +40 -0
  45. package/tests/report.test.ts +54 -0
  46. package/tests/sanitize.test.ts +24 -0
  47. package/tests/search-entries.test.ts +33 -0
  48. package/tests/support/load-session.ts +23 -0
  49. package/tests/support/real-sessions.ts +51 -0
@@ -0,0 +1,141 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+ import { convertToLlm } from "@mariozechner/pi-coding-agent";
3
+ import { readFileSync, writeFileSync } from "fs";
4
+ import { join } from "path";
5
+ import { homedir } from "os";
6
+ import { compile } from "../core/summarize";
7
+ import type { PiVccCompactionDetails } from "../details";
8
+
9
+ const CONFIG_PATH = join(homedir(), ".pi", "agent", "pi-vcc-config.json");
10
+
11
+ const loadConfig = (): { debug?: boolean } => {
12
+ try { return JSON.parse(readFileSync(CONFIG_PATH, "utf-8")); } catch { return {}; }
13
+ };
14
+
15
+ const dbg = (data: Record<string, unknown>) => {
16
+ if (!loadConfig().debug) return;
17
+ try { writeFileSync("/tmp/pi-vcc-debug.json", JSON.stringify(data, null, 2)); } catch {}
18
+ };
19
+
20
+ const previewContent = (content: unknown): string => {
21
+ if (typeof content === "string") return content.slice(0, 300);
22
+ if (Array.isArray(content)) {
23
+ return content
24
+ .map((c: any) => {
25
+ if (c?.type === "text") return c.text ?? "";
26
+ if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
27
+ if (c?.type === "thinking") return `[thinking]`;
28
+ if (c?.type === "image") return `[image:${c.mimeType}]`;
29
+ return `[${c?.type ?? "unknown"}]`;
30
+ })
31
+ .join("\n")
32
+ .slice(0, 300);
33
+ }
34
+ return "";
35
+ };
36
+
37
+ interface EntryWithMessage {
38
+ entry: { id: string; type: string };
39
+ message: { role: string; content: unknown };
40
+ }
41
+
42
+ function buildOwnCut(branchEntries: any[]): { messages: any[]; firstKeptEntryId: string } | null {
43
+ const postCompaction: EntryWithMessage[] = [];
44
+ for (const e of branchEntries) {
45
+ if (e.type === "compaction") {
46
+ postCompaction.length = 0;
47
+ continue;
48
+ }
49
+ if (e.type === "message" && e.message) {
50
+ postCompaction.push({ entry: e, message: e.message });
51
+ }
52
+ }
53
+
54
+ if (postCompaction.length <= 2) return null;
55
+
56
+ // Summarize all messages, keep only the last user message as context
57
+ let cutIdx = postCompaction.length - 1;
58
+
59
+ // Align to last user message boundary
60
+ while (cutIdx > 0 && postCompaction[cutIdx].message.role !== "user") {
61
+ cutIdx--;
62
+ }
63
+
64
+ if (cutIdx <= 0) return null;
65
+
66
+ return {
67
+ messages: postCompaction.slice(0, cutIdx).map((e) => e.message),
68
+ firstKeptEntryId: postCompaction[cutIdx].entry.id,
69
+ };
70
+ }
71
+
72
+ export const registerBeforeCompactHook = (pi: ExtensionAPI) => {
73
+ pi.on("session_before_compact", (event) => {
74
+ const { preparation, branchEntries } = event;
75
+
76
+ let agentMessages = preparation.messagesToSummarize;
77
+ let firstKeptEntryId = preparation.firstKeptEntryId;
78
+
79
+ // If pi-core's preparation has nothing to summarize, build our own cut
80
+ if (agentMessages.length === 0) {
81
+ const ownCut = buildOwnCut(branchEntries as any[]);
82
+ if (ownCut) {
83
+ agentMessages = ownCut.messages;
84
+ firstKeptEntryId = ownCut.firstKeptEntryId;
85
+ }
86
+ }
87
+
88
+ const messages = convertToLlm(agentMessages);
89
+
90
+ const summary = compile({
91
+ messages,
92
+ previousSummary: preparation.previousSummary,
93
+ fileOps: {
94
+ readFiles: [...preparation.fileOps.read],
95
+ modifiedFiles: [...preparation.fileOps.written, ...preparation.fileOps.edited],
96
+ },
97
+ });
98
+
99
+ const branchIds = branchEntries.map((e: any) => e.id);
100
+ const cutIdx = branchIds.indexOf(firstKeptEntryId);
101
+ const cutWindow = cutIdx >= 0
102
+ ? branchEntries.slice(Math.max(0, cutIdx - 3), Math.min(branchEntries.length, cutIdx + 3)).map((e: any) => ({
103
+ id: e.id,
104
+ type: e.type,
105
+ role: e.type === "message" ? e.message?.role : undefined,
106
+ preview: e.type === "message" ? previewContent(e.message?.content) : undefined,
107
+ }))
108
+ : [];
109
+
110
+ dbg({
111
+ usedOwnCut: agentMessages !== preparation.messagesToSummarize,
112
+ messagesToSummarize: agentMessages.length,
113
+ messagesPreviewHead: agentMessages.slice(0, 3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
114
+ messagesPreviewTail: agentMessages.slice(-3).map((m: any) => ({ role: m.role, preview: previewContent(m.content) })),
115
+ convertedMessages: messages.length,
116
+ firstKeptEntryId,
117
+ cutWindow,
118
+ tokensBefore: preparation.tokensBefore,
119
+ summaryLength: summary.length,
120
+ summaryPreview: summary.slice(0, 500),
121
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
122
+ });
123
+
124
+ const details: PiVccCompactionDetails = {
125
+ compactor: "pi-vcc",
126
+ version: 1,
127
+ sections: [...summary.matchAll(/^\[(.+?)\]/gm)].map((m) => m[1]),
128
+ sourceMessageCount: agentMessages.length,
129
+ previousSummaryUsed: Boolean(preparation.previousSummary),
130
+ };
131
+
132
+ return {
133
+ compaction: {
134
+ summary,
135
+ details,
136
+ tokensBefore: preparation.tokensBefore,
137
+ firstKeptEntryId,
138
+ },
139
+ };
140
+ });
141
+ };
@@ -0,0 +1,11 @@
1
+ export interface SectionData {
2
+ sessionGoal: string[];
3
+ keyConversationTurns: string[];
4
+ actionsTaken: string[];
5
+ importantEvidence: string[];
6
+ filesRead: string[];
7
+ filesModified: string[];
8
+ filesCreated: string[];
9
+ outstandingContext: string[];
10
+ userPreferences: string[];
11
+ }
@@ -0,0 +1,85 @@
1
+ import { Type } from "@sinclair/typebox";
2
+ import { readFileSync } from "fs";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+ import { renderMessage } from "../core/render-entries";
5
+ import { searchEntries } from "../core/search-entries";
6
+ import { formatRecallOutput } from "../core/format-recall";
7
+
8
+ const DEFAULT_RECENT = 25;
9
+ const MAX_RESULTS = 50;
10
+
11
+ const loadAllMessages = (sessionFile: string, full: boolean) => {
12
+ const content = readFileSync(sessionFile, "utf-8");
13
+ const entries: any[] = [];
14
+ for (const line of content.split("\n")) {
15
+ if (!line.trim()) continue;
16
+ try {
17
+ entries.push(JSON.parse(line));
18
+ } catch {}
19
+ }
20
+ return entries
21
+ .filter((e) => e.type === "message" && e.message)
22
+ .map((e, i) => renderMessage(e.message, i, full));
23
+ };
24
+
25
+ export const registerRecallTool = (pi: ExtensionAPI) => {
26
+ pi.registerTool({
27
+ name: "vcc_recall",
28
+ label: "VCC Recall",
29
+ description:
30
+ "Search full conversation history in this session, including before compaction." +
31
+ " Use without query to see recent brief history." +
32
+ " Use with query to search all history." +
33
+ " Use expand with entry indices to get full content (note: some tool results may already be truncated by Pi core before saving).",
34
+ promptSnippet:
35
+ "vcc_recall: Search full conversation history including compacted parts. Use expand:[indices] for full content.",
36
+ parameters: Type.Object({
37
+ query: Type.Optional(
38
+ Type.String({ description: "Search terms to filter history" }),
39
+ ),
40
+ expand: Type.Optional(
41
+ Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for" }),
42
+ ),
43
+ }),
44
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
45
+ const sessionFile = ctx.sessionManager.getSessionFile();
46
+ if (!sessionFile) {
47
+ return {
48
+ content: [{ type: "text", text: "No session file available." }],
49
+ details: undefined,
50
+ };
51
+ }
52
+
53
+ const expandSet = new Set(params.expand ?? []);
54
+ const hasExpand = expandSet.size > 0;
55
+
56
+ if (hasExpand && !params.query) {
57
+ const fullMsgs = loadAllMessages(sessionFile, true);
58
+ const expanded = fullMsgs.filter((m) => expandSet.has(m.index));
59
+ if (expanded.length === 0) {
60
+ return {
61
+ content: [{ type: "text", text: `No entries found for indices: ${[...expandSet].join(", ")}` }],
62
+ details: undefined,
63
+ };
64
+ }
65
+ const output = formatRecallOutput(expanded);
66
+ return {
67
+ content: [{ type: "text", text: output }],
68
+ details: undefined,
69
+ };
70
+ }
71
+
72
+ const msgs = loadAllMessages(sessionFile, false);
73
+ const results = params.query?.trim()
74
+ ? searchEntries(msgs, params.query).slice(0, MAX_RESULTS)
75
+ : msgs.slice(-DEFAULT_RECENT);
76
+ const output = formatRecallOutput(results, params.query);
77
+
78
+ return {
79
+ content: [{ type: "text", text: output }],
80
+ details: undefined,
81
+ };
82
+ },
83
+ });
84
+ };
85
+
package/src/types.ts ADDED
@@ -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 }
11
+ | { kind: "assistant"; text: string }
12
+ | { kind: "tool_call"; name: string; args: Record<string, unknown> }
13
+ | { kind: "tool_result"; name: string; text: string; isError: boolean }
14
+ | { kind: "thinking"; text: string; redacted: boolean };
@@ -0,0 +1,56 @@
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("buildSections", () => {
6
+ it("returns all-empty for no blocks", () => {
7
+ const r = buildSections({ blocks: [] });
8
+ expect(r.sessionGoal).toEqual([]);
9
+ expect(r.actionsTaken).toEqual([]);
10
+ expect(r.filesRead).toEqual([]);
11
+ });
12
+
13
+ it("populates sections from realistic blocks", () => {
14
+ const blocks: NormalizedBlock[] = [
15
+ { kind: "user", text: "Fix the auth bug" },
16
+ { kind: "tool_call", name: "Read", args: { path: "auth.ts" } },
17
+ { kind: "tool_result", name: "Read", text: "export function auth() { return checkToken(req.headers.authorization); }", isError: false },
18
+ { kind: "assistant", text: "The root cause is a null check" },
19
+ { kind: "tool_call", name: "Edit", args: { path: "auth.ts" } },
20
+ { kind: "tool_result", name: "Edit", text: "ok", isError: false },
21
+ { kind: "assistant", text: "- run tests next" },
22
+ ];
23
+ const r = buildSections({ blocks });
24
+ expect(r.sessionGoal).toContain("Fix the auth bug");
25
+ expect(r.filesRead).toContain("auth.ts");
26
+ expect(r.filesModified).toContain("auth.ts");
27
+ expect(r.actionsTaken.length).toBeGreaterThan(0);
28
+ expect(r.actionsTaken[0]).toContain("auth.ts");
29
+ expect(r.importantEvidence.length).toBeGreaterThan(0);
30
+ expect(r.importantEvidence[0]).toContain("[Read]");
31
+ expect(r.keyConversationTurns.length).toBeGreaterThan(0);
32
+ expect(r.keyConversationTurns.some((t) => t.startsWith("[user]"))).toBe(true);
33
+ expect(r.keyConversationTurns.some((t) => t.startsWith("[assistant]"))).toBe(true);
34
+ });
35
+
36
+ it("uses fileOps to seed file lists", () => {
37
+ const r = buildSections({
38
+ blocks: [],
39
+ fileOps: { readFiles: ["x.ts"], modifiedFiles: ["y.ts"] },
40
+ });
41
+ expect(r.filesRead).toContain("x.ts");
42
+ expect(r.filesModified).toContain("y.ts");
43
+ });
44
+
45
+ it("collapses repeated tool calls", () => {
46
+ const blocks: NormalizedBlock[] = [
47
+ { kind: "tool_call", name: "Read", args: { path: "a.ts" } },
48
+ { kind: "tool_call", name: "Read", args: { path: "a.ts" } },
49
+ { kind: "tool_call", name: "Read", args: { path: "a.ts" } },
50
+ ];
51
+ const r = buildSections({ blocks });
52
+ expect(r.actionsTaken.length).toBe(1);
53
+ expect(r.actionsTaken[0]).toContain("x3");
54
+ });
55
+ });
56
+
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { compile } from "../src/core/summarize";
3
+ import {
4
+ userMsg,
5
+ assistantText,
6
+ assistantWithToolCall,
7
+ toolResult,
8
+ } from "./fixtures";
9
+
10
+ describe("compile", () => {
11
+ it("returns empty string for no messages", () => {
12
+ expect(compile({ messages: [] })).toBe("");
13
+ });
14
+
15
+ it("produces structured output from a conversation", () => {
16
+ const r = compile({
17
+ messages: [
18
+ userMsg("Fix login bug"),
19
+ assistantWithToolCall("Read", { path: "auth.ts" }),
20
+ toolResult("Read", "function login() {}"),
21
+ assistantText("Found the issue.\n1. Fix validation"),
22
+ ],
23
+ });
24
+ expect(r).toContain("[Session Goal]");
25
+ expect(r).toContain("Fix login bug");
26
+ expect(r).toContain("[Actions Taken]");
27
+ expect(r).toContain("[Files And Changes]");
28
+ expect(r).toContain("auth.ts");
29
+ });
30
+
31
+ it("merges by section instead of appending delta blocks", () => {
32
+ const r = compile({
33
+ messages: [assistantText("Current state")],
34
+ previousSummary: "[Session Goal]\n- Original goal",
35
+ });
36
+ expect(r).toContain("[Session Goal]\n- Original goal");
37
+ expect(r).toContain("[Key Conversation Turns]");
38
+ expect(r).not.toContain("[Delta Since Last Compaction]");
39
+ });
40
+
41
+ it("passes fileOps through to sections", () => {
42
+ const r = compile({
43
+ messages: [userMsg("check")],
44
+ fileOps: { readFiles: ["config.ts"] },
45
+ });
46
+ expect(r).toContain("config.ts");
47
+ });
48
+ });
49
+
50
+
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { extractDecisions } from "../src/extract/decisions";
3
+ import type { NormalizedBlock } from "../src/types";
4
+
5
+ describe("extractDecisions", () => {
6
+ it("returns empty for no blocks", () => {
7
+ expect(extractDecisions([])).toEqual([]);
8
+ });
9
+
10
+ it("captures decision patterns from assistant", () => {
11
+ const blocks: NormalizedBlock[] = [
12
+ { kind: "assistant", text: "I decided to use React for the frontend" },
13
+ ];
14
+ expect(extractDecisions(blocks).length).toBe(1);
15
+ });
16
+
17
+ it("captures from user blocks too", () => {
18
+ const blocks: NormalizedBlock[] = [
19
+ { kind: "user", text: "We must use PostgreSQL for the database" },
20
+ ];
21
+ expect(extractDecisions(blocks).length).toBe(1);
22
+ });
23
+
24
+ it("ignores tool_call blocks", () => {
25
+ const blocks: NormalizedBlock[] = [
26
+ { kind: "tool_call", name: "Read", args: { path: "a.ts" } },
27
+ ];
28
+ expect(extractDecisions(blocks)).toEqual([]);
29
+ });
30
+ });
@@ -0,0 +1,62 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { extractFiles } from "../src/extract/files";
3
+ import type { NormalizedBlock } from "../src/types";
4
+
5
+ describe("extractFiles", () => {
6
+ it("returns empty sets for no blocks", () => {
7
+ const r = extractFiles([]);
8
+ expect(r.read.size).toBe(0);
9
+ expect(r.modified.size).toBe(0);
10
+ expect(r.created.size).toBe(0);
11
+ });
12
+
13
+ it("seeds from fileOps", () => {
14
+ const r = extractFiles([], {
15
+ readFiles: ["a.ts"],
16
+ modifiedFiles: ["b.ts"],
17
+ createdFiles: ["c.ts"],
18
+ });
19
+ expect(r.read.has("a.ts")).toBe(true);
20
+ expect(r.modified.has("b.ts")).toBe(true);
21
+ expect(r.created.has("c.ts")).toBe(true);
22
+ });
23
+
24
+ it("detects Read tool", () => {
25
+ const blocks: NormalizedBlock[] = [
26
+ { kind: "tool_call", name: "Read", args: { path: "x.ts" } },
27
+ ];
28
+ expect(extractFiles(blocks).read.has("x.ts")).toBe(true);
29
+ });
30
+
31
+ it("detects Edit tool as modified", () => {
32
+ const blocks: NormalizedBlock[] = [
33
+ { kind: "tool_call", name: "Edit", args: { path: "y.ts" } },
34
+ ];
35
+ expect(extractFiles(blocks).modified.has("y.ts")).toBe(true);
36
+ });
37
+
38
+ it("detects Write tool as both modified and created", () => {
39
+ const blocks: NormalizedBlock[] = [
40
+ { kind: "tool_call", name: "Write", args: { path: "new.ts" } },
41
+ ];
42
+ const r = extractFiles(blocks);
43
+ expect(r.modified.has("new.ts")).toBe(true);
44
+ expect(r.created.has("new.ts")).toBe(true);
45
+ });
46
+
47
+ it("skips tool_call without path arg", () => {
48
+ const blocks: NormalizedBlock[] = [
49
+ { kind: "tool_call", name: "Read", args: { query: "foo" } },
50
+ ];
51
+ expect(extractFiles(blocks).read.size).toBe(0);
52
+ });
53
+
54
+ it("supports file_path arg key", () => {
55
+ const blocks: NormalizedBlock[] = [
56
+ { kind: "tool_call", name: "read_file", args: { file_path: "z.ts" } },
57
+ ];
58
+ expect(extractFiles(blocks).read.has("z.ts")).toBe(true);
59
+ });
60
+ });
61
+
62
+
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { extractFindings } from "../src/extract/findings";
3
+ import type { NormalizedBlock } from "../src/types";
4
+
5
+ describe("extractFindings", () => {
6
+ it("returns empty for no blocks", () => {
7
+ expect(extractFindings([])).toEqual([]);
8
+ });
9
+
10
+ it("ignores raw tool errors", () => {
11
+ const blocks: NormalizedBlock[] = [
12
+ { kind: "tool_result", name: "Edit", text: "File not found", isError: true },
13
+ ];
14
+ expect(extractFindings(blocks)).toEqual([]);
15
+ });
16
+
17
+ it("captures assistant lines matching error patterns", () => {
18
+ const blocks: NormalizedBlock[] = [
19
+ { kind: "assistant", text: "The root cause is a null pointer" },
20
+ ];
21
+ expect(extractFindings(blocks).length).toBe(1);
22
+ });
23
+
24
+ it("ignores short lines", () => {
25
+ const blocks: NormalizedBlock[] = [
26
+ { kind: "assistant", text: "error" },
27
+ ];
28
+ expect(extractFindings(blocks)).toEqual([]);
29
+ });
30
+
31
+ it("deduplicates findings", () => {
32
+ const blocks: NormalizedBlock[] = [
33
+ { kind: "assistant", text: "found that X is broken" },
34
+ { kind: "assistant", text: "found that X is broken" },
35
+ ];
36
+ expect(extractFindings(blocks).length).toBe(1);
37
+ });
38
+ });
39
+
@@ -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 max 3 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(3);
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
+ });