@sting8k/pi-vcc 0.1.2 → 0.1.4
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 +7 -5
- package/package.json +1 -1
- package/src/core/content.ts +12 -0
- package/src/core/format-recall.ts +4 -3
- package/src/core/normalize.ts +2 -1
- package/src/core/render-entries.ts +9 -2
- package/src/core/search-entries.ts +36 -5
- package/src/tools/recall.ts +7 -6
- package/tests/content.test.ts +31 -0
- package/tests/render-entries.test.ts +22 -0
- package/tests/search-entries.test.ts +34 -5
package/README.md
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# pi-vcc
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@sting8k/pi-vcc)
|
|
4
|
+
|
|
3
5
|
Algorithmic conversation compactor for [Pi](https://github.com/badlogic/pi-mono). No LLM calls -- produces structured, transcript-preserving summaries using pure extraction and formatting.
|
|
4
6
|
|
|
5
|
-
Inspired by [VCC](https://github.com/lllyasviel/VCC) (View-oriented Conversation Compiler)
|
|
7
|
+
Inspired by [VCC](https://github.com/lllyasviel/VCC) **(View-oriented Conversation Compiler)**.
|
|
6
8
|
|
|
7
9
|
## Why pi-vcc
|
|
8
10
|
|
|
@@ -95,25 +97,25 @@ Pi's default compaction discards old messages permanently. After compaction, the
|
|
|
95
97
|
|
|
96
98
|
`vcc_recall` bypasses this by reading the raw session JSONL file directly. It parses every message entry in the file, renders each one into a searchable `RenderedEntry` with a stable index (matching the message's position in the JSONL), role, truncated summary, and associated file paths. This means entry `#41` always refers to the same message regardless of how many compactions have happened.
|
|
97
99
|
|
|
98
|
-
**Search**
|
|
100
|
+
**Search** matches against the full content of every entry (not just the truncated summary). The query is split into terms and all must appear. Results show a snippet around the first match with surrounding context:
|
|
99
101
|
|
|
100
102
|
```
|
|
101
103
|
vcc_recall({ query: "auth token refresh" }) // all terms must match
|
|
102
104
|
```
|
|
103
105
|
|
|
104
|
-
**Browse** without a query returns the last 25 entries:
|
|
106
|
+
**Browse** without a query returns the last 25 entries as brief summaries:
|
|
105
107
|
|
|
106
108
|
```
|
|
107
109
|
vcc_recall()
|
|
108
110
|
```
|
|
109
111
|
|
|
110
|
-
**Expand**
|
|
112
|
+
**Expand** returns full untruncated content for specific indices found via search:
|
|
111
113
|
|
|
112
114
|
```
|
|
113
115
|
vcc_recall({ expand: [41, 42] }) // full content, no clipping
|
|
114
116
|
```
|
|
115
117
|
|
|
116
|
-
Typical workflow: search
|
|
118
|
+
Typical workflow: search -> find relevant entry indices from snippets -> expand those indices for full content.
|
|
117
119
|
|
|
118
120
|
> Some tool results are truncated by Pi core at save time. `expand` returns everything in the JSONL but can't recover what Pi already cut.
|
|
119
121
|
|
package/package.json
CHANGED
package/src/core/content.ts
CHANGED
|
@@ -10,6 +10,7 @@ export const firstLine = (text: string, max = 200): string =>
|
|
|
10
10
|
clip(text.split("\n")[0] ?? "", max);
|
|
11
11
|
|
|
12
12
|
export const textParts = (content: Message["content"]): string[] => {
|
|
13
|
+
if (!content) return [];
|
|
13
14
|
if (typeof content === "string") return [content];
|
|
14
15
|
return content
|
|
15
16
|
.filter((part) => part.type === "text")
|
|
@@ -18,3 +19,14 @@ export const textParts = (content: Message["content"]): string[] => {
|
|
|
18
19
|
|
|
19
20
|
export const textOf = (content: Message["content"]): string =>
|
|
20
21
|
textParts(content).join("\n");
|
|
22
|
+
|
|
23
|
+
/** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
|
|
24
|
+
export const snippet = (text: string, term: string, radius = 60): string | null => {
|
|
25
|
+
const idx = text.toLowerCase().indexOf(term.toLowerCase());
|
|
26
|
+
if (idx === -1) return null;
|
|
27
|
+
const start = Math.max(0, idx - radius);
|
|
28
|
+
const end = Math.min(text.length, idx + term.length + radius);
|
|
29
|
+
const prefix = start > 0 ? "..." : "";
|
|
30
|
+
const suffix = end < text.length ? "..." : "";
|
|
31
|
+
return `${prefix}${text.slice(start, end)}${suffix}`;
|
|
32
|
+
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { SearchHit } from "./search-entries";
|
|
2
2
|
|
|
3
3
|
export const formatRecallOutput = (
|
|
4
|
-
entries:
|
|
4
|
+
entries: SearchHit[],
|
|
5
5
|
query?: string,
|
|
6
6
|
): string => {
|
|
7
7
|
if (entries.length === 0) {
|
|
@@ -16,7 +16,8 @@ export const formatRecallOutput = (
|
|
|
16
16
|
|
|
17
17
|
const lines = entries.map((e) => {
|
|
18
18
|
const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
|
|
19
|
-
|
|
19
|
+
const body = query && e.snippet ? e.snippet : e.summary;
|
|
20
|
+
return `#${e.index} [${e.role}]${fileSuffix} ${body}`;
|
|
20
21
|
});
|
|
21
22
|
|
|
22
23
|
return `${header}\n\n${lines.join("\n\n")}`;
|
package/src/core/normalize.ts
CHANGED
|
@@ -8,7 +8,7 @@ const normalizeOne = (msg: Message): NormalizedBlock[] => {
|
|
|
8
8
|
const blocks: NormalizedBlock[] = [];
|
|
9
9
|
const text = sanitize(textOf(msg.content));
|
|
10
10
|
if (text) blocks.push({ kind: "user", text });
|
|
11
|
-
if (typeof msg.content !== "string") {
|
|
11
|
+
if (msg.content && typeof msg.content !== "string") {
|
|
12
12
|
for (const part of msg.content) {
|
|
13
13
|
if (part.type === "image") {
|
|
14
14
|
blocks.push({ kind: "user", text: `[image: ${part.mimeType}]` });
|
|
@@ -28,6 +28,7 @@ const normalizeOne = (msg: Message): NormalizedBlock[] => {
|
|
|
28
28
|
}
|
|
29
29
|
|
|
30
30
|
if (msg.role === "assistant") {
|
|
31
|
+
if (!msg.content) return [];
|
|
31
32
|
if (typeof msg.content === "string") {
|
|
32
33
|
return [{ kind: "assistant", text: sanitize(msg.content) }];
|
|
33
34
|
}
|
|
@@ -11,7 +11,7 @@ export interface RenderedEntry {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
const toolCalls = (content: Message["content"]): string => {
|
|
14
|
-
if (typeof content === "string") return "";
|
|
14
|
+
if (!content || typeof content === "string") return "";
|
|
15
15
|
return content
|
|
16
16
|
.filter((c) => c.type === "toolCall")
|
|
17
17
|
.map((c) => `${c.name}(${summarizeToolArgs(c.arguments)})`)
|
|
@@ -19,7 +19,7 @@ const toolCalls = (content: Message["content"]): string => {
|
|
|
19
19
|
};
|
|
20
20
|
|
|
21
21
|
const extractFilesFromContent = (content: Message["content"]): string[] => {
|
|
22
|
-
if (typeof content === "string") return [];
|
|
22
|
+
if (!content || typeof content === "string") return [];
|
|
23
23
|
return content
|
|
24
24
|
.filter((c) => c.type === "toolCall")
|
|
25
25
|
.map((c) => extractPath(c.arguments))
|
|
@@ -38,6 +38,13 @@ export const renderMessage = (msg: Message, index: number, full = false): Render
|
|
|
38
38
|
summary: `${prefix}[${msg.toolName}] ${text}`,
|
|
39
39
|
};
|
|
40
40
|
}
|
|
41
|
+
// bashExecution has command+output instead of content
|
|
42
|
+
if ((msg as any).role === "bashExecution") {
|
|
43
|
+
const cmd = (msg as any).command ?? "";
|
|
44
|
+
const out = (msg as any).output ?? "";
|
|
45
|
+
const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
|
|
46
|
+
return { index, role: "bash", summary: text };
|
|
47
|
+
}
|
|
41
48
|
const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
|
|
42
49
|
const tools = toolCalls(msg.content);
|
|
43
50
|
const files = extractFilesFromContent(msg.content);
|
|
@@ -1,14 +1,45 @@
|
|
|
1
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
1
2
|
import type { RenderedEntry } from "./render-entries";
|
|
3
|
+
import { textOf, snippet } from "./content";
|
|
4
|
+
|
|
5
|
+
export interface SearchHit extends RenderedEntry {
|
|
6
|
+
/** Context snippet around the first matched term (only when query provided) */
|
|
7
|
+
snippet?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Build full searchable text for a message. */
|
|
11
|
+
const fullText = (msg: Message): string => {
|
|
12
|
+
if ((msg as any).role === "bashExecution") {
|
|
13
|
+
return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
|
|
14
|
+
}
|
|
15
|
+
return textOf(msg.content);
|
|
16
|
+
};
|
|
2
17
|
|
|
3
18
|
export const searchEntries = (
|
|
4
19
|
entries: RenderedEntry[],
|
|
20
|
+
messages: Message[],
|
|
5
21
|
query?: string,
|
|
6
|
-
):
|
|
22
|
+
): SearchHit[] => {
|
|
7
23
|
if (!query?.trim()) return entries;
|
|
8
24
|
const terms = query.toLowerCase().split(/\s+/);
|
|
9
|
-
|
|
25
|
+
|
|
26
|
+
const hits: SearchHit[] = [];
|
|
27
|
+
for (let i = 0; i < entries.length; i++) {
|
|
28
|
+
const e = entries[i];
|
|
29
|
+
const msg = messages[i];
|
|
30
|
+
const text = msg ? fullText(msg) : e.summary;
|
|
10
31
|
const filePart = e.files?.join(" ") ?? "";
|
|
11
|
-
const hay = `${e.role} ${
|
|
12
|
-
|
|
13
|
-
|
|
32
|
+
const hay = `${e.role} ${text} ${filePart}`.toLowerCase();
|
|
33
|
+
|
|
34
|
+
if (terms.every((t) => hay.includes(t))) {
|
|
35
|
+
// Find snippet around the first matching term in the raw text
|
|
36
|
+
let snip: string | undefined;
|
|
37
|
+
for (const t of terms) {
|
|
38
|
+
const s = snippet(text, t);
|
|
39
|
+
if (s) { snip = s; break; }
|
|
40
|
+
}
|
|
41
|
+
hits.push({ ...e, snippet: snip });
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return hits;
|
|
14
45
|
};
|
package/src/tools/recall.ts
CHANGED
|
@@ -17,9 +17,10 @@ const loadAllMessages = (sessionFile: string, full: boolean) => {
|
|
|
17
17
|
entries.push(JSON.parse(line));
|
|
18
18
|
} catch {}
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
const messageEntries = entries.filter((e) => e.type === "message" && e.message);
|
|
21
|
+
const rendered = messageEntries.map((e, i) => renderMessage(e.message, i, full));
|
|
22
|
+
const rawMessages = messageEntries.map((e) => e.message);
|
|
23
|
+
return { rendered, rawMessages };
|
|
23
24
|
};
|
|
24
25
|
|
|
25
26
|
export const registerRecallTool = (pi: ExtensionAPI) => {
|
|
@@ -54,7 +55,7 @@ export const registerRecallTool = (pi: ExtensionAPI) => {
|
|
|
54
55
|
const hasExpand = expandSet.size > 0;
|
|
55
56
|
|
|
56
57
|
if (hasExpand && !params.query) {
|
|
57
|
-
const fullMsgs = loadAllMessages(sessionFile, true);
|
|
58
|
+
const { rendered: fullMsgs } = loadAllMessages(sessionFile, true);
|
|
58
59
|
const expanded = fullMsgs.filter((m) => expandSet.has(m.index));
|
|
59
60
|
if (expanded.length === 0) {
|
|
60
61
|
return {
|
|
@@ -69,9 +70,9 @@ export const registerRecallTool = (pi: ExtensionAPI) => {
|
|
|
69
70
|
};
|
|
70
71
|
}
|
|
71
72
|
|
|
72
|
-
const msgs = loadAllMessages(sessionFile, false);
|
|
73
|
+
const { rendered: msgs, rawMessages } = loadAllMessages(sessionFile, false);
|
|
73
74
|
const results = params.query?.trim()
|
|
74
|
-
? searchEntries(msgs, params.query).slice(0, MAX_RESULTS)
|
|
75
|
+
? searchEntries(msgs, rawMessages, params.query).slice(0, MAX_RESULTS)
|
|
75
76
|
: msgs.slice(-DEFAULT_RECENT);
|
|
76
77
|
const output = formatRecallOutput(results, params.query);
|
|
77
78
|
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { textParts, textOf, clip, firstLine } from "../src/core/content";
|
|
3
|
+
|
|
4
|
+
describe("textParts", () => {
|
|
5
|
+
it("returns [] for undefined content", () => {
|
|
6
|
+
expect(textParts(undefined as any)).toEqual([]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("returns [] for null content", () => {
|
|
10
|
+
expect(textParts(null as any)).toEqual([]);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("wraps string content", () => {
|
|
14
|
+
expect(textParts("hello")).toEqual(["hello"]);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("extracts text parts from array content", () => {
|
|
18
|
+
const content = [
|
|
19
|
+
{ type: "text" as const, text: "first" },
|
|
20
|
+
{ type: "toolCall" as const, name: "x", id: "1", arguments: {} },
|
|
21
|
+
{ type: "text" as const, text: "second" },
|
|
22
|
+
];
|
|
23
|
+
expect(textParts(content)).toEqual(["first", "second"]);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe("textOf", () => {
|
|
28
|
+
it("returns empty string for undefined content", () => {
|
|
29
|
+
expect(textOf(undefined as any)).toBe("");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -36,5 +36,27 @@ describe("renderMessage", () => {
|
|
|
36
36
|
const r = renderMessage(userMsg(long), 0);
|
|
37
37
|
expect(r.summary.length).toBeLessThanOrEqual(300);
|
|
38
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
|
+
});
|
|
39
61
|
});
|
|
40
62
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect } from "bun:test";
|
|
2
2
|
import { searchEntries } from "../src/core/search-entries";
|
|
3
3
|
import type { RenderedEntry } from "../src/core/render-entries";
|
|
4
|
+
import type { Message } from "@mariozechner/pi-ai";
|
|
4
5
|
|
|
5
6
|
const entries: RenderedEntry[] = [
|
|
6
7
|
{ index: 0, role: "user", summary: "Fix login bug" },
|
|
@@ -9,25 +10,53 @@ const entries: RenderedEntry[] = [
|
|
|
9
10
|
{ index: 3, role: "assistant", summary: "Found the root cause" },
|
|
10
11
|
];
|
|
11
12
|
|
|
13
|
+
// Matching raw messages for full-text search
|
|
14
|
+
const messages: Message[] = [
|
|
15
|
+
{ role: "user", content: "Fix login bug" } as any,
|
|
16
|
+
{ role: "assistant", content: [{ type: "text", text: "Reading auth.ts" }] } as any,
|
|
17
|
+
{ role: "toolResult", content: [{ type: "text", text: "[Read] code here" }] } as any,
|
|
18
|
+
{ role: "assistant", content: [{ type: "text", text: "Found the root cause" }] } as any,
|
|
19
|
+
];
|
|
20
|
+
|
|
12
21
|
describe("searchEntries", () => {
|
|
13
22
|
it("returns all for empty query", () => {
|
|
14
|
-
expect(searchEntries(entries)).toEqual(entries);
|
|
15
|
-
expect(searchEntries(entries, "")).toEqual(entries);
|
|
23
|
+
expect(searchEntries(entries, messages)).toEqual(entries);
|
|
24
|
+
expect(searchEntries(entries, messages, "")).toEqual(entries);
|
|
16
25
|
});
|
|
17
26
|
|
|
18
27
|
it("filters by single term", () => {
|
|
19
|
-
const r = searchEntries(entries, "login");
|
|
28
|
+
const r = searchEntries(entries, messages, "login");
|
|
20
29
|
expect(r).toHaveLength(1);
|
|
21
30
|
expect(r[0].index).toBe(0);
|
|
22
31
|
});
|
|
23
32
|
|
|
24
33
|
it("filters by multiple terms (AND)", () => {
|
|
25
|
-
const r = searchEntries(entries, "root cause");
|
|
34
|
+
const r = searchEntries(entries, messages, "root cause");
|
|
26
35
|
expect(r).toHaveLength(1);
|
|
27
36
|
expect(r[0].index).toBe(3);
|
|
28
37
|
});
|
|
29
38
|
|
|
30
39
|
it("returns empty for no match", () => {
|
|
31
|
-
expect(searchEntries(entries, "xyz123")).toEqual([]);
|
|
40
|
+
expect(searchEntries(entries, messages, "xyz123")).toEqual([]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("finds keyword beyond clip boundary in full content", () => {
|
|
44
|
+
const longText = "A".repeat(400) + " hidden_keyword here";
|
|
45
|
+
const longEntries: RenderedEntry[] = [
|
|
46
|
+
{ index: 0, role: "user", summary: "A".repeat(300) },
|
|
47
|
+
];
|
|
48
|
+
const longMsgs: Message[] = [
|
|
49
|
+
{ role: "user", content: longText } as any,
|
|
50
|
+
];
|
|
51
|
+
const r = searchEntries(longEntries, longMsgs, "hidden_keyword");
|
|
52
|
+
expect(r).toHaveLength(1);
|
|
53
|
+
expect(r[0].snippet).toContain("hidden_keyword");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("returns snippet around matched term", () => {
|
|
57
|
+
const r = searchEntries(entries, messages, "root");
|
|
58
|
+
expect(r).toHaveLength(1);
|
|
59
|
+
expect(r[0].snippet).toBeDefined();
|
|
60
|
+
expect(r[0].snippet).toContain("root");
|
|
32
61
|
});
|
|
33
62
|
});
|