@sting8k/pi-vcc 0.1.3 → 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 CHANGED
@@ -1,8 +1,10 @@
1
1
  # pi-vcc
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@sting8k/pi-vcc)](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** uses multi-term matching -- the query is split into terms and all must appear in the entry's role + summary + file paths. This searches across the entire session including compacted regions:
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** switches to full mode -- entries are rendered without truncation, so you get the complete content for specific indices found via search:
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 brief -> find relevant entry indices -> expand those indices for full content.
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sting8k/pi-vcc",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Algorithmic conversation compactor for pi - transcript-preserving structured summaries, no LLM calls",
5
5
  "main": "index.ts",
6
6
  "keywords": [
@@ -19,3 +19,14 @@ export const textParts = (content: Message["content"]): string[] => {
19
19
 
20
20
  export const textOf = (content: Message["content"]): string =>
21
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 { RenderedEntry } from "./render-entries";
1
+ import type { SearchHit } from "./search-entries";
2
2
 
3
3
  export const formatRecallOutput = (
4
- entries: RenderedEntry[],
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
- return `#${e.index} [${e.role}]${fileSuffix} ${e.summary}`;
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")}`;
@@ -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
- ): RenderedEntry[] => {
22
+ ): SearchHit[] => {
7
23
  if (!query?.trim()) return entries;
8
24
  const terms = query.toLowerCase().split(/\s+/);
9
- return entries.filter((e) => {
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} ${e.summary} ${filePart}`.toLowerCase();
12
- return terms.every((t) => hay.includes(t));
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
  };
@@ -17,9 +17,10 @@ const loadAllMessages = (sessionFile: string, full: boolean) => {
17
17
  entries.push(JSON.parse(line));
18
18
  } catch {}
19
19
  }
20
- return entries
21
- .filter((e) => e.type === "message" && e.message)
22
- .map((e, i) => renderMessage(e.message, i, full));
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
 
@@ -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
  });