pi-blackhole 0.2.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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +373 -0
  3. package/example-config.json +115 -0
  4. package/index.ts +39 -0
  5. package/package.json +55 -0
  6. package/src/commands/memory.ts +191 -0
  7. package/src/commands/pi-vcc.ts +94 -0
  8. package/src/commands/vcc-recall.ts +112 -0
  9. package/src/core/brief.ts +390 -0
  10. package/src/core/build-sections.ts +85 -0
  11. package/src/core/content.ts +60 -0
  12. package/src/core/filter-noise.ts +42 -0
  13. package/src/core/format-recall.ts +27 -0
  14. package/src/core/format.ts +76 -0
  15. package/src/core/lineage.ts +26 -0
  16. package/src/core/load-messages.ts +41 -0
  17. package/src/core/normalize.ts +79 -0
  18. package/src/core/recall-scope.ts +14 -0
  19. package/src/core/render-entries.ts +56 -0
  20. package/src/core/report.ts +237 -0
  21. package/src/core/sanitize.ts +5 -0
  22. package/src/core/search-entries.ts +227 -0
  23. package/src/core/settings.ts +34 -0
  24. package/src/core/skill-collapse.ts +35 -0
  25. package/src/core/summarize.ts +213 -0
  26. package/src/core/tool-args.ts +14 -0
  27. package/src/core/unified-config.ts +285 -0
  28. package/src/details.ts +13 -0
  29. package/src/extract/commits.ts +69 -0
  30. package/src/extract/files.ts +80 -0
  31. package/src/extract/goals.ts +79 -0
  32. package/src/extract/preferences.ts +55 -0
  33. package/src/hooks/before-compact.ts +345 -0
  34. package/src/om/agents/dropper/agent.ts +204 -0
  35. package/src/om/agents/dropper/prompts.ts +48 -0
  36. package/src/om/agents/observer/agent.ts +256 -0
  37. package/src/om/agents/observer/prompts.ts +119 -0
  38. package/src/om/agents/reflector/agent.ts +161 -0
  39. package/src/om/agents/reflector/prompts.ts +77 -0
  40. package/src/om/clipboard.ts +63 -0
  41. package/src/om/compaction-hook.ts +63 -0
  42. package/src/om/compaction-trigger.ts +92 -0
  43. package/src/om/config.ts +22 -0
  44. package/src/om/consolidation.ts +514 -0
  45. package/src/om/cooldown.ts +130 -0
  46. package/src/om/debug-log.ts +55 -0
  47. package/src/om/ids.ts +5 -0
  48. package/src/om/ledger/fold.ts +106 -0
  49. package/src/om/ledger/index.ts +6 -0
  50. package/src/om/ledger/progress.ts +225 -0
  51. package/src/om/ledger/projection.ts +237 -0
  52. package/src/om/ledger/recall.ts +243 -0
  53. package/src/om/ledger/render-summary.ts +44 -0
  54. package/src/om/ledger/types.ts +206 -0
  55. package/src/om/model-budget.ts +9 -0
  56. package/src/om/pending.ts +225 -0
  57. package/src/om/reverse-recall.ts +130 -0
  58. package/src/om/runtime.ts +241 -0
  59. package/src/om/serialize.ts +224 -0
  60. package/src/om/tokens.ts +33 -0
  61. package/src/sections.ts +18 -0
  62. package/src/tools/recall.ts +212 -0
  63. package/src/types.ts +19 -0
  64. package/vitest.config.ts +41 -0
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Serialize branch entries and render source-addressed chunks.
3
+ *
4
+ * Upstream: https://github.com/elpapi42/pi-observational-memory (src/serialize.ts)
5
+ * Unmodified.
6
+ */
7
+ import type { Message, TextContent, ToolResultMessage } from "@earendil-works/pi-ai";
8
+
9
+ function pad(n: number): string {
10
+ return n.toString().padStart(2, "0");
11
+ }
12
+
13
+ function fmtLocal(d: Date): string {
14
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
15
+ }
16
+
17
+ function formatTimestamp(v: number | string | undefined): string {
18
+ if (v === undefined) return "????-??-?? ??:??";
19
+ const d = new Date(v);
20
+ return Number.isNaN(d.getTime()) ? "????-??-?? ??:??" : fmtLocal(d);
21
+ }
22
+
23
+ function formatRecallTimestamp(...values: Array<number | string | undefined>): string {
24
+ for (const v of values) {
25
+ if (v === undefined) continue;
26
+ const d = new Date(v);
27
+ if (!Number.isNaN(d.getTime())) return fmtLocal(d);
28
+ }
29
+ return "Unknown time";
30
+ }
31
+
32
+ function textAndPlaceholders(
33
+ content: unknown,
34
+ options: { omitRedactedThinking?: boolean; includeThinking?: boolean } = {},
35
+ ): string {
36
+ if (typeof content === "string") return content;
37
+ if (!Array.isArray(content)) return "[non-text content omitted]";
38
+
39
+ const parts: string[] = [];
40
+ for (const block of content as Array<Record<string, unknown>>) {
41
+ if (!block || typeof block !== "object") {
42
+ parts.push("[non-text content omitted]");
43
+ continue;
44
+ }
45
+ if (block.type === "text" && typeof block.text === "string") {
46
+ parts.push(block.text);
47
+ continue;
48
+ }
49
+ if (block.type === "thinking") {
50
+ if (options.omitRedactedThinking && block.redacted === true) continue;
51
+ if (options.includeThinking && typeof block.thinking === "string") {
52
+ parts.push(`[thinking: ${block.thinking}]`);
53
+ continue;
54
+ }
55
+ parts.push("[non-text content omitted]");
56
+ continue;
57
+ }
58
+ if (block.type === "toolCall" && typeof block.name === "string") {
59
+ parts.push(`[${block.name}(${JSON.stringify(block.arguments ?? {})})]`);
60
+ continue;
61
+ }
62
+ parts.push("[non-text content omitted]");
63
+ }
64
+ return parts.join("\n");
65
+ }
66
+
67
+ function textOnly(content: unknown): string {
68
+ if (content == null) return "";
69
+ if (typeof content === "string") return content;
70
+ if (!Array.isArray(content)) return "";
71
+ return content
72
+ .filter((b): b is TextContent => b?.type === "text" && typeof b.text === "string")
73
+ .map((b) => b.text)
74
+ .join("\n");
75
+ }
76
+
77
+ export function serializeConversation(messages: Message[]): string {
78
+ return messages
79
+ .map((msg): string | null => {
80
+ const time = formatTimestamp(msg.timestamp);
81
+ if (msg.role === "user") {
82
+ const text = textOnly(msg.content);
83
+ return `[User @ ${time}]: ${text}`;
84
+ }
85
+ if (msg.role === "assistant") {
86
+ const body = textAndPlaceholders(msg.content, {
87
+ includeThinking: true,
88
+ omitRedactedThinking: true,
89
+ })
90
+ .split("\n")
91
+ .filter(Boolean)
92
+ .join("\n");
93
+ if (!body) return null;
94
+ return `[Assistant @ ${time}]: ${body}`;
95
+ }
96
+ const text = textOnly(msg.content);
97
+ return `[Tool result for ${(msg as ToolResultMessage).toolName} @ ${time}]: ${text}`;
98
+ })
99
+ .filter((line): line is string => line !== null)
100
+ .join("\n\n");
101
+ }
102
+
103
+ export function nowTimestamp(): string {
104
+ return fmtLocal(new Date());
105
+ }
106
+
107
+ export const MAX_RECORD_CONTENT_CHARS = 10_000;
108
+
109
+ export function truncateRecordContent(content: string): string {
110
+ if (content.length <= MAX_RECORD_CONTENT_CHARS) return content;
111
+ const head = content.slice(0, MAX_RECORD_CONTENT_CHARS);
112
+ const dropped = content.length - MAX_RECORD_CONTENT_CHARS;
113
+ return `${head} … [truncated ${dropped} chars]`;
114
+ }
115
+
116
+ export type RenderableEntry = {
117
+ type: string;
118
+ id?: string;
119
+ timestamp?: string;
120
+ message?: unknown;
121
+ customType?: string;
122
+ content?: unknown;
123
+ summary?: unknown;
124
+ };
125
+
126
+ function renderCustomMessage(entry: RenderableEntry, options: { recallFormat: boolean }): string {
127
+ const time = options.recallFormat ? formatRecallTimestamp(entry.timestamp) : formatTimestamp(entry.timestamp);
128
+ const text = options.recallFormat
129
+ ? textAndPlaceholders(entry.content)
130
+ : typeof entry.content === "string"
131
+ ? entry.content
132
+ : Array.isArray(entry.content)
133
+ ? (entry.content as Array<{ type?: string; text?: string }>)
134
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
135
+ .map((b) => b.text as string)
136
+ .join("\n")
137
+ : "";
138
+ if (options.recallFormat) {
139
+ const origin = entry.customType ? `Custom message (${entry.customType})` : "Custom message";
140
+ return `[${origin} @ ${time}]: ${text}`;
141
+ }
142
+ const tag = entry.customType ? `Custom (${entry.customType})` : "Custom";
143
+ return `[${tag} @ ${time}]: ${text}`;
144
+ }
145
+
146
+ export function serializeBranchEntries(entries: RenderableEntry[]): string {
147
+ const blocks: string[] = [];
148
+ for (const entry of entries) {
149
+ if (entry.type === "message" && entry.message) {
150
+ const part = serializeConversation([entry.message as Message]);
151
+ if (part) blocks.push(part);
152
+ continue;
153
+ }
154
+ if (entry.type === "custom_message") {
155
+ blocks.push(renderCustomMessage(entry, { recallFormat: false }));
156
+ continue;
157
+ }
158
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
159
+ const time = formatTimestamp(entry.timestamp);
160
+ blocks.push(`[Branch summary @ ${time}]: ${entry.summary}`);
161
+ }
162
+ }
163
+ return blocks.join("\n\n");
164
+ }
165
+
166
+ export type SourceAddressedSerialization = {
167
+ text: string;
168
+ sourceEntryIds: string[];
169
+ };
170
+
171
+ function isSourceRenderableEntry(entry: RenderableEntry): boolean {
172
+ return entry.type === "message" || entry.type === "custom_message" || entry.type === "branch_summary";
173
+ }
174
+
175
+ export function serializeSourceAddressedBranchEntries(entries: RenderableEntry[]): SourceAddressedSerialization {
176
+ const blocks: string[] = [];
177
+ const sourceEntryIds: string[] = [];
178
+ for (const entry of entries) {
179
+ if (!entry.id || !isSourceRenderableEntry(entry)) continue;
180
+ const rendered = serializeBranchEntries([entry]);
181
+ if (!rendered.trim()) continue;
182
+ sourceEntryIds.push(entry.id);
183
+ blocks.push(`[Source entry id: ${entry.id}]\n${rendered}`);
184
+ }
185
+ return { text: blocks.join("\n\n"), sourceEntryIds };
186
+ }
187
+
188
+ function renderRecallMessage(entry: RenderableEntry): string | null {
189
+ if (!entry.message || typeof entry.message !== "object") return null;
190
+ const msg = entry.message as Message;
191
+ const time = formatRecallTimestamp(msg.timestamp, entry.timestamp);
192
+ if (msg.role === "user") {
193
+ return `[User @ ${time}]: ${textAndPlaceholders(msg.content)}`;
194
+ }
195
+ if (msg.role === "assistant") {
196
+ const body = textAndPlaceholders(msg.content, {
197
+ includeThinking: true,
198
+ omitRedactedThinking: true,
199
+ })
200
+ .split("\n")
201
+ .filter(Boolean)
202
+ .join("\n");
203
+ if (!body) return null;
204
+ return `[Assistant @ ${time}]: ${body}`;
205
+ }
206
+ return `[Tool result: ${(msg as ToolResultMessage).toolName} @ ${time}]: ${textAndPlaceholders(msg.content)}`;
207
+ }
208
+
209
+ export function renderRecallSourceEntry(entry: RenderableEntry): string | null {
210
+ if (entry.type === "message") return renderRecallMessage(entry);
211
+ if (entry.type === "custom_message") return renderCustomMessage(entry, { recallFormat: true });
212
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
213
+ const time = formatRecallTimestamp(entry.timestamp);
214
+ return `[Branch summary @ ${time}]: ${entry.summary}`;
215
+ }
216
+ return null;
217
+ }
218
+
219
+ export function renderRecallSourceEntries(entries: RenderableEntry[]): string {
220
+ return entries
221
+ .map(renderRecallSourceEntry)
222
+ .filter((block): block is string => block !== null && block.trim().length > 0)
223
+ .join("\n\n");
224
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Token estimation for serialized entries.
3
+ *
4
+ * Upstream: https://github.com/elpapi42/pi-observational-memory (src/tokens.ts)
5
+ * Unmodified.
6
+ */
7
+ import { estimateTokens as estimateMessageTokens } from "@earendil-works/pi-coding-agent";
8
+
9
+ export function estimateStringTokens(text: string): number {
10
+ return Math.ceil(text.length / 4);
11
+ }
12
+
13
+ export function estimateEntryTokens(entry: { type: string; message?: unknown; content?: unknown; summary?: unknown }): number {
14
+ if (entry.type === "message" && entry.message) {
15
+ return estimateMessageTokens(entry.message as Parameters<typeof estimateMessageTokens>[0]);
16
+ }
17
+ if (entry.type === "custom_message" && entry.content) {
18
+ const content = entry.content;
19
+ if (typeof content === "string") return estimateStringTokens(content);
20
+ if (Array.isArray(content)) {
21
+ let total = 0;
22
+ for (const block of content) {
23
+ if (block.type === "text" && block.text) total += estimateStringTokens(block.text);
24
+ }
25
+ return total;
26
+ }
27
+ }
28
+ if (entry.type === "branch_summary" && typeof entry.summary === "string") {
29
+ return estimateStringTokens(entry.summary);
30
+ }
31
+ return 0;
32
+ }
33
+
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Pi-vcc section types.
3
+ *
4
+ * Upstream: https://github.com/sting8k/pi-vcc (src/sections.ts)
5
+ * Unmodified.
6
+ */
7
+ import type { TranscriptEntry } from "./core/brief";
8
+
9
+ export interface SectionData {
10
+ sessionGoal: string[];
11
+ outstandingContext: string[];
12
+ filesAndChanges: string[];
13
+ commits: string[];
14
+ userPreferences: string[];
15
+ briefTranscript: string;
16
+ /** Structured transcript entries (verbose object format) */
17
+ transcriptEntries: TranscriptEntry[];
18
+ }
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Unified recall tool — handles #N transcript indices, 12-char hex om memory ids,
3
+ * and free-text search (BM25 + regex).
4
+ *
5
+ * Created by pi-vcc-om. Replaces pi-vcc's vcc_recall and OM's standalone recall-observation.
6
+ */
7
+ import { Type } from "@earendil-works/pi-ai";
8
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
+ import { loadAllMessages } from "../core/load-messages";
10
+ import { searchEntries } from "../core/search-entries";
11
+ import { formatRecallOutput } from "../core/format-recall";
12
+ import { getActiveLineageEntryIds } from "../core/lineage";
13
+ import { normalizeRecallScope } from "../core/recall-scope";
14
+ import {
15
+ recallMemorySources,
16
+ type Entry,
17
+ } from "../om/ledger/recall.js";
18
+ import { renderRecallSourceEntries } from "../om/serialize.js";
19
+ import {
20
+ findObservationsForEntryIds,
21
+ findReflectionsForEntryIds,
22
+ formatRelatedObservations,
23
+ buildIndexMap,
24
+ formatEntryIndexAnnotation,
25
+ } from "../om/reverse-recall.js";
26
+
27
+ // ── Pi-vcc recall logic ──────────────────────────────────────────────────
28
+
29
+ const DEFAULT_RECENT = 25;
30
+ const PAGE_SIZE = 5;
31
+
32
+ const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
33
+ requested.filter((i) => !Number.isInteger(i) || !available.has(i));
34
+
35
+ async function vccRecall(params: { query?: string; expand?: number[]; page?: number; scope?: "lineage" | "all" }, ctx: any) {
36
+ const sessionFile = ctx.sessionManager.getSessionFile();
37
+ if (!sessionFile) {
38
+ return { content: [{ type: "text" as const, text: "No session file available." }], details: undefined };
39
+ }
40
+ const scope = normalizeRecallScope(params.scope);
41
+ const lineageEntryIds = scope === "lineage" ? getActiveLineageEntryIds(ctx.sessionManager) : undefined;
42
+ const expandSet = new Set(params.expand ?? []);
43
+ const hasExpand = expandSet.size > 0;
44
+
45
+ if (hasExpand && !params.query) {
46
+ const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
47
+ const requested = [...expandSet];
48
+ const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
49
+ const invalid = invalidExpandIndices(requested, new Set(byIndex.keys()));
50
+ if (invalid.length > 0) {
51
+ return { content: [{ type: "text" as const, text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }], details: undefined };
52
+ }
53
+ const expanded = requested.map((i) => byIndex.get(i)).filter((m): m is NonNullable<typeof m> => Boolean(m));
54
+ let output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(expanded);
55
+
56
+ // Coupling: look up related OM observations
57
+ const expandedIds = expanded.map((e) => e.id).filter(Boolean);
58
+ if (expandedIds.length > 0) {
59
+ try {
60
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
61
+ const obs = findObservationsForEntryIds(branchEntries, expandedIds);
62
+ const refs = findReflectionsForEntryIds(branchEntries, expandedIds);
63
+ if (obs.length > 0 || refs.length > 0) {
64
+ output += "\n\n" + formatRelatedObservations(obs, refs);
65
+ }
66
+ } catch { /* branch may not be available */ }
67
+ }
68
+
69
+ return { content: [{ type: "text" as const, text: output }], details: undefined };
70
+ }
71
+
72
+ const { rendered: msgs, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
73
+ const allResults = params.query?.trim()
74
+ ? searchEntries(msgs, rawMessages, params.query)
75
+ : msgs.slice(-DEFAULT_RECENT);
76
+
77
+ if (params.query?.trim()) {
78
+ const page = Math.max(1, params.page ?? 1);
79
+ const start = (page - 1) * PAGE_SIZE;
80
+ const pageResults = allResults.slice(start, start + PAGE_SIZE);
81
+ const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
82
+ const scopeSuffix = scope === "all" ? " (scope: all)" : "";
83
+ const header = totalPages > 1
84
+ ? `Page ${page}/${totalPages} (${allResults.length} total matches${scopeSuffix})`
85
+ : `${allResults.length} matches${scopeSuffix}`;
86
+ const footer = page < totalPages
87
+ ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
88
+ : "";
89
+ let output = formatRecallOutput(pageResults, params.query, header) + footer;
90
+
91
+ // Coupling: augment search results with related observations
92
+ const pageResultIds = pageResults.map((r) => r.id).filter(Boolean);
93
+ if (pageResultIds.length > 0) {
94
+ try {
95
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
96
+ const obs = findObservationsForEntryIds(branchEntries, pageResultIds);
97
+ const refs = findReflectionsForEntryIds(branchEntries, pageResultIds);
98
+ if (obs.length > 0 || refs.length > 0) {
99
+ output += "\n\n" + formatRelatedObservations(obs, refs);
100
+ }
101
+ } catch { /* branch may not be available */ }
102
+ }
103
+
104
+ return { content: [{ type: "text" as const, text: output }], details: undefined };
105
+ }
106
+
107
+ const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(allResults, params.query);
108
+ return { content: [{ type: "text" as const, text: output }], details: undefined };
109
+ }
110
+
111
+ // ── Observational-memory recall logic ─────────────────────────────────────
112
+
113
+ const MEMORY_ID_PATTERN = /^[a-f0-9]{12}$/;
114
+ const VCC_ENTRY_PATTERN = /^#(\d+)$/;
115
+
116
+ async function omRecall(memoryId: string, ctx: any) {
117
+ if (!MEMORY_ID_PATTERN.test(memoryId)) {
118
+ return { content: [{ type: "text" as const, text: `Memory id must be 12 lowercase hex characters. Received: ${memoryId}` }], details: undefined };
119
+ }
120
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
121
+ const result = recallMemorySources(branchEntries, memoryId);
122
+ if (result.status === "not_found") {
123
+ return { content: [{ type: "text" as const, text: `No observation or reflection with id ${memoryId} was found on the current branch.` }], details: undefined };
124
+ }
125
+ const lines: string[] = [];
126
+ if (result.collision) lines.push(`ID ${result.memoryId} matched multiple items.`);
127
+ for (const ref of result.reflections) {
128
+ lines.push(`[${ref.reflection.id}] ${ref.reflection.content}`);
129
+ }
130
+ for (const obs of result.observations) {
131
+ const dropped = obs.status === "dropped" ? " [dropped]" : "";
132
+ lines.push(`[${obs.observation.id}]${dropped} ${obs.observation.timestamp} [${obs.observation.relevance}] ${obs.observation.content}`);
133
+ }
134
+ if (result.sourceEntries.length > 0) {
135
+ lines.push("");
136
+ lines.push("Sources:");
137
+ // Cross-format nav: annotate source entries with #N indices
138
+ try {
139
+ const sessionFile = ctx.sessionManager.getSessionFile();
140
+ if (sessionFile) {
141
+ const { rendered } = await Promise.resolve(loadAllMessages(sessionFile, false));
142
+ const idToIndex = buildIndexMap(rendered);
143
+ const indexAnnotation = formatEntryIndexAnnotation(
144
+ result.observations.flatMap((o) => o.sourceEntryIds),
145
+ idToIndex,
146
+ );
147
+ if (indexAnnotation) lines.push(indexAnnotation);
148
+ }
149
+ } catch { /* ignore errors from index mapping */ }
150
+ lines.push(renderRecallSourceEntries(result.sourceEntries));
151
+ }
152
+ const text = lines.join("\n") || `Memory ${memoryId} found, but no evidence rendered.`;
153
+ return { content: [{ type: "text" as const, text }], details: undefined };
154
+ }
155
+
156
+ // ── Unified recall tool ──────────────────────────────────────────────────
157
+
158
+ export function registerRecallTool(pi: ExtensionAPI): void {
159
+ pi.registerTool({
160
+ name: "recall",
161
+ label: "Recall",
162
+ description:
163
+ "Recall session history or memory evidence. This is text/pattern matching, NOT semantic search. Accepts:\n" +
164
+ "- A 12-char hex id [a1b2c3d4e5f6] to recover observation/reflection source evidence.\n" +
165
+ "- A #N entry index to expand a specific transcript entry from compacted output.\n" +
166
+ "- A text/regex query to search conversation content. Multi-word queries use BM25 ranking with stopword filtering.\n" +
167
+ "Search tips: use unique concrete words (file names, function names, exact phrases), not conceptual questions. Regex metacharacters (|, *, .) trigger regex mode. Default scope is active lineage; use scope:'all' for off-lineage branches.",
168
+ promptSnippet:
169
+ "recall: Text/regex search (not semantic). Also: 12-char hex ids recover obs/reflection sources; #N indices expand transcript entries. Use concrete words for search. scope:'all' for off-lineage.",
170
+ promptGuidelines: [
171
+ "Use recall with a 12-char hex id before making an important decision that depends on a compacted observation or reflection whose details are unclear.",
172
+ "Use recall with a search query when you need to find specific conversation content. Use unique concrete terms (file paths, function names, error messages, exact phrases), not conceptual questions. Example: 'merge_design.md' not 'what did we decide about merging'.",
173
+ "Use recall with #N to expand a transcript entry reference from compacted output.",
174
+ "After compaction, the summary includes observation/reflection ids in brackets. Use recall with those ids to recover full source evidence.",
175
+ "If you get no results, try fewer terms, use a distinctive single word, or use a regex pattern (e.g. 'fork.*pi-vcc').",
176
+ ],
177
+ parameters: Type.Object({
178
+ query: Type.Optional(
179
+ Type.String({ description: "12-char hex id, #N entry index, or text/regex search terms. NOT semantic — use concrete words (file names, quoted phrases, identifiers). Regex metacharacters trigger regex mode. Multi-word = BM25-ranked OR." }),
180
+ ),
181
+ expand: Type.Optional(
182
+ Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for (blackhole format only)." }),
183
+ ),
184
+ page: Type.Optional(
185
+ Type.Number({ description: "Page number (1-based) for paginated search results. Default: 1." }),
186
+ ),
187
+ scope: Type.Optional(
188
+ Type.Union([
189
+ Type.Literal("lineage"),
190
+ Type.Literal("all"),
191
+ ], { description: "Search scope. Default: lineage; all includes off-lineage branches." }),
192
+ ),
193
+ }),
194
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
195
+ const q = params.query?.trim();
196
+ // Dispatch by format
197
+ if (q && VCC_ENTRY_PATTERN.test(q)) {
198
+ // #N → expand entry indices
199
+ const match = q.match(VCC_ENTRY_PATTERN);
200
+ const index = match ? parseInt(match[1], 10) : NaN;
201
+ if (!Number.isNaN(index)) {
202
+ return vccRecall({ query: "", expand: [index] }, ctx);
203
+ }
204
+ }
205
+ if (q && MEMORY_ID_PATTERN.test(q)) {
206
+ return omRecall(q, ctx);
207
+ }
208
+ // Default: pi-vcc search
209
+ return vccRecall(params, ctx);
210
+ },
211
+ });
212
+ }
package/src/types.ts ADDED
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Normalized block types shared across pi-vcc pipeline.
3
+ *
4
+ * Upstream: https://github.com/sting8k/pi-vcc (src/types.ts)
5
+ * Unmodified.
6
+ */
7
+ export interface FileOps {
8
+ readFiles?: string[];
9
+ modifiedFiles?: string[];
10
+ createdFiles?: string[];
11
+ }
12
+
13
+ export type NormalizedBlock =
14
+ | { kind: "user"; text: string; sourceIndex?: number }
15
+ | { kind: "assistant"; text: string; sourceIndex?: number }
16
+ | { kind: "tool_call"; name: string; args: Record<string, unknown>; sourceIndex?: number }
17
+ | { kind: "tool_result"; name: string; text: string; isError: boolean; sourceIndex?: number }
18
+ | { kind: "bash"; command: string; output: string; exitCode: number | undefined; sourceIndex?: number }
19
+ | { kind: "thinking"; text: string; redacted: boolean; sourceIndex?: number };
@@ -0,0 +1,41 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ // ── pnpm global store paths for @earendil-works packages ──────────────────
4
+
5
+ const GLOBAL_PNPM =
6
+ "/home/kovalik/.local/share/pnpm/global/5/.pnpm";
7
+
8
+ const PKGS: Record<string, string> = {
9
+ "@earendil-works/pi-ai": `${GLOBAL_PNPM}/@earendil-works+pi-ai@0.75.4_ws@8.20.1_zod@4.4.3/node_modules/@earendil-works/pi-ai`,
10
+ "@earendil-works/pi-ai/oauth": `${GLOBAL_PNPM}/@earendil-works+pi-ai@0.75.4_ws@8.20.1_zod@4.4.3/node_modules/@earendil-works/pi-ai/oauth`,
11
+ "@earendil-works/pi-agent-core": `${GLOBAL_PNPM}/@earendil-works+pi-agent-core@0.75.4_ws@8.20.1_zod@4.4.3/node_modules/@earendil-works/pi-agent-core`,
12
+ "@earendil-works/pi-coding-agent": `${GLOBAL_PNPM}/@earendil-works+pi-coding-agent@0.75.4_ws@8.20.1_zod@4.4.3/node_modules/@earendil-works/pi-coding-agent`,
13
+ "@earendil-works/pi-tui": `${GLOBAL_PNPM}/@earendil-works+pi-tui@0.75.4/node_modules/@earendil-works/pi-tui`,
14
+ };
15
+
16
+ export default defineConfig({
17
+ test: {
18
+ globals: true,
19
+ environment: "node",
20
+ testTimeout: 10000,
21
+ include: ["tests/**/*.test.ts"],
22
+ },
23
+ resolve: {
24
+ alias: [
25
+ // Map @earendil-works/* to global pnpm store
26
+ ...Object.entries(PKGS).map(([name, path]) => ({
27
+ find: new RegExp(`^${escapeRegex(name)}$`),
28
+ replacement: path,
29
+ })),
30
+ // Resolve .js → extension-less for our source files
31
+ {
32
+ find: /\.js$/,
33
+ replacement: "",
34
+ },
35
+ ],
36
+ },
37
+ });
38
+
39
+ function escapeRegex(s: string): string {
40
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+ }