opencode-blackhole 0.0.0-stage → 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 (50) hide show
  1. package/LICENSE +35 -0
  2. package/LICENSE.vendor +21 -0
  3. package/README.md +377 -2
  4. package/dist/server.js +5025 -0
  5. package/index.ts +1 -0
  6. package/package.json +57 -4
  7. package/src/adapter/opencode-messages.ts +270 -0
  8. package/src/compaction/build.ts +264 -0
  9. package/src/compaction/handoff.ts +421 -0
  10. package/src/config.ts +316 -0
  11. package/src/index.ts +714 -0
  12. package/src/memory/ledger.ts +254 -0
  13. package/src/memory/manager.ts +659 -0
  14. package/src/memory/models.ts +172 -0
  15. package/src/memory/prompts.ts +145 -0
  16. package/src/recall/recall.ts +327 -0
  17. package/src/state/paths.ts +42 -0
  18. package/src/store/capture.ts +84 -0
  19. package/src/store/transcript.ts +279 -0
  20. package/src/vcc/README.md +51 -0
  21. package/src/vcc/core/brief.ts +407 -0
  22. package/src/vcc/core/build-sections.ts +81 -0
  23. package/src/vcc/core/compact-args.ts +45 -0
  24. package/src/vcc/core/content.ts +156 -0
  25. package/src/vcc/core/drill-down.ts +302 -0
  26. package/src/vcc/core/filter-noise.ts +41 -0
  27. package/src/vcc/core/format-recall.ts +100 -0
  28. package/src/vcc/core/format.ts +104 -0
  29. package/src/vcc/core/lineage.ts +26 -0
  30. package/src/vcc/core/normalize.ts +76 -0
  31. package/src/vcc/core/rank.ts +283 -0
  32. package/src/vcc/core/recall-request.ts +160 -0
  33. package/src/vcc/core/recall-scope.ts +30 -0
  34. package/src/vcc/core/render-entries.ts +55 -0
  35. package/src/vcc/core/sanitize.ts +5 -0
  36. package/src/vcc/core/search-entries.ts +594 -0
  37. package/src/vcc/core/skill-collapse.ts +35 -0
  38. package/src/vcc/core/summarize.ts +289 -0
  39. package/src/vcc/core/token-estimate.ts +100 -0
  40. package/src/vcc/core/tool-args.ts +16 -0
  41. package/src/vcc/details.ts +11 -0
  42. package/src/vcc/extract/commits.ts +96 -0
  43. package/src/vcc/extract/files.ts +87 -0
  44. package/src/vcc/extract/goals.ts +79 -0
  45. package/src/vcc/extract/preferences.ts +55 -0
  46. package/src/vcc/extract/tracked-commands.ts +232 -0
  47. package/src/vcc/load-messages.ts +42 -0
  48. package/src/vcc/pi-types.ts +53 -0
  49. package/src/vcc/sections.ts +12 -0
  50. package/src/vcc/types.ts +16 -0
@@ -0,0 +1,283 @@
1
+ import type { NormalizedBlock, FileOps } from "../types";
2
+ import { extractPath } from "./tool-args";
3
+ import { compileBrief, heredocCloseIndex } from "./brief";
4
+
5
+ export interface BriefRankingOptions {
6
+ /** Maximum normalized blocks used to build the brief transcript. */
7
+ maxBlocks?: number;
8
+ /** Always keep this many latest blocks to preserve local continuity. */
9
+ preserveRecentBlocks?: number;
10
+ /** Hook-provided file activity, used as structural signal instead of prose guessing. */
11
+ fileOps?: FileOps;
12
+ /**
13
+ * Optional size budget (in characters of rendered brief) for the selected
14
+ * blocks. When set, this is the PRIMARY limit: blocks are added by score
15
+ * until the budget is reached, and lower-value blocks are skipped rather
16
+ * than truncating the tail. maxBlocks still applies as a safety upper bound.
17
+ * Callers derive this from a token budget via charsPerToken.
18
+ * When maxBriefCharsCeiling + briefCharsPerBlock are also set, this acts as
19
+ * the FLOOR of a size-relative budget (see below).
20
+ */
21
+ maxBriefChars?: number;
22
+ /**
23
+ * Optional upper bound for a size-relative char budget. When set together
24
+ * with maxBriefChars (floor) and briefCharsPerBlock (slope), the effective
25
+ * budget becomes clamp(briefCharsPerBlock * blockCount, maxBriefChars,
26
+ * maxBriefCharsCeiling): larger transcripts (which carry more high-value
27
+ * long-tail -- edits, commands, tests) get more brief budget, while small
28
+ * sessions stay at the floor and this hard ceiling prevents unbounded growth.
29
+ */
30
+ maxBriefCharsCeiling?: number;
31
+ /** Per-block slope (chars) for the size-relative budget. Requires the ceiling. */
32
+ briefCharsPerBlock?: number;
33
+ }
34
+
35
+ export interface RankedBlock {
36
+ block: NormalizedBlock;
37
+ index: number;
38
+ score: number;
39
+ reasons: string[];
40
+ }
41
+
42
+ const DEFAULT_MAX_BLOCKS = 80;
43
+ const DEFAULT_RECENT_BLOCKS = 16;
44
+
45
+ const EDIT_TOOL_RE = /^(edit|write|multiedit|quick_edit|target_edit|apply_patch)$/i;
46
+ const READ_TOOL_RE = /^(read|glob|grep|ls|find|semantic_query|semantic_grep|semantic_show)$/i;
47
+ const TEST_COMMAND_RE = /\b(?:bun|npm|pnpm|yarn|node|pytest|cargo|go|mvn|gradle)\b[^\n]*(?:test|spec|check|lint|build|tsc)/i;
48
+ const GH_PR_POLL_RE = /(?:^|\s)gh\s+pr\s+(?:view|checks)\s+(\d+)\b/i;
49
+ // Durable workflow facts: which PR/issue was acted on, and git state changes.
50
+ // Structural (command shape), not prose — same spirit as TEST_COMMAND_RE.
51
+ const WORKFLOW_COMMAND_RE =
52
+ /(?:^|\s)(?:gh\s+(?:pr|issue)\s+[a-z-]+|git\s+(?:commit|push|merge|rebase|revert|cherry-pick|tag|reset|checkout|branch)\b)/i;
53
+ const MIN_SEGMENT_CLOSING_ASSISTANT_CHARS = 120;
54
+
55
+ // A bash block whose every meaningful line is pure scaffolding (set -e, cd,
56
+ // export, ls, echo, sleep, pwd, comments, heredoc bodies) carries no durable
57
+ // fact. Such blocks get a score penalty so the size-relative budget never
58
+ // pulls them in ahead of real edits/commands when spare chars appear.
59
+ const TRIVIAL_BASH_LINE_RE =
60
+ /^(?:set\s+[-+]|cd(?:\s+\S+)?$|export\s+\w+=|(?:source|\.)\s+\S+|pwd$|true$|:$|#|ls(?:\s|$)|echo\b|clear$|sleep\b)/;
61
+ const TRIVIAL_BASH_PENALTY = 16;
62
+
63
+ const isTrivialOnlyBash = (raw: string): boolean => {
64
+ const lines = raw.split("\n");
65
+ const kept: string[] = [];
66
+ for (let i = 0; i < lines.length; i++) {
67
+ kept.push(lines[i]);
68
+ // Skip heredoc bodies (they are content, not scaffolding) using the same
69
+ // hardened detection as brief.ts: only skip when the terminator exists
70
+ // downstream, so a stray `<<` never swallows a later real command.
71
+ const close = heredocCloseIndex(lines, i);
72
+ if (close !== -1) i = close;
73
+ }
74
+ const meaningful = kept.map((l) => l.trim()).filter(Boolean).filter((l) => !TRIVIAL_BASH_LINE_RE.test(l));
75
+ return meaningful.length === 0;
76
+ };
77
+
78
+ const asPathSet = (paths?: string[]): Set<string> => new Set((paths ?? []).filter(Boolean));
79
+
80
+ const bashCommandFromBlock = (block: NormalizedBlock): string | undefined => {
81
+ if (block.kind === "bash") return block.command;
82
+ if (block.kind === "tool_call" && /^bash$/i.test(block.name) && typeof block.args.command === "string") {
83
+ return block.args.command;
84
+ }
85
+ return undefined;
86
+ };
87
+
88
+ const pathFromBlock = (block: NormalizedBlock): string | undefined => {
89
+ if (block.kind === "tool_call") return extractPath(block.args) ?? undefined;
90
+ if (block.kind === "bash") {
91
+ const match = block.command.match(/(?:^|\s)([\w./-]+\.[\w-]+)(?:\s|$)/);
92
+ return match?.[1];
93
+ }
94
+ return undefined;
95
+ };
96
+
97
+ const add = (ranked: RankedBlock, points: number, reason: string) => {
98
+ ranked.score += points;
99
+ ranked.reasons.push(reason);
100
+ };
101
+
102
+ const scoreBlock = (
103
+ block: NormalizedBlock,
104
+ index: number,
105
+ total: number,
106
+ modifiedFiles: Set<string>,
107
+ readFiles: Set<string>,
108
+ ): RankedBlock => {
109
+ const ranked: RankedBlock = { block, index, score: 0, reasons: [] };
110
+ const recency = total <= 1 ? 0 : Math.round((index / (total - 1)) * 12);
111
+ add(ranked, recency, "recency");
112
+
113
+ if (block.kind === "user") add(ranked, 18, "user-turn");
114
+ if (block.kind === "assistant") add(ranked, 10, "assistant-context");
115
+ if (block.kind === "tool_result") add(ranked, 1, "tool-result-low-value");
116
+
117
+ if (block.kind === "tool_call") {
118
+ const command = bashCommandFromBlock(block);
119
+ if (EDIT_TOOL_RE.test(block.name)) add(ranked, 34, "edit-tool");
120
+ else if (command && TEST_COMMAND_RE.test(command)) add(ranked, 26, "test-command");
121
+ else if (READ_TOOL_RE.test(block.name)) add(ranked, 6, "read-tool");
122
+ else add(ranked, 12, "tool-call");
123
+ if (command && WORKFLOW_COMMAND_RE.test(command)) add(ranked, 14, "workflow-command");
124
+ if (command && isTrivialOnlyBash(command)) add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
125
+ }
126
+
127
+ if (block.kind === "bash") {
128
+ add(ranked, 8, "bash");
129
+ if (block.exitCode != null && block.exitCode !== 0) add(ranked, 24, "nonzero-exit");
130
+ if (TEST_COMMAND_RE.test(block.command)) add(ranked, 22, "test-command");
131
+ if (WORKFLOW_COMMAND_RE.test(block.command)) add(ranked, 14, "workflow-command");
132
+ // Penalize scaffolding-only commands, but not ones that failed (a failed
133
+ // `ls`/`cd` still records a real state fact via nonzero-exit).
134
+ if (isTrivialOnlyBash(block.command) && !(block.exitCode != null && block.exitCode !== 0)) {
135
+ add(ranked, -TRIVIAL_BASH_PENALTY, "trivial-bash");
136
+ }
137
+ }
138
+
139
+ const path = pathFromBlock(block);
140
+ if (path) {
141
+ if (modifiedFiles.has(path)) add(ranked, 18, "hook-modified-file");
142
+ if (readFiles.has(path)) add(ranked, 6, "hook-read-file");
143
+ }
144
+
145
+ if (block.kind === "tool_result" && block.text.length > 1000) add(ranked, -8, "long-tool-result");
146
+ return ranked;
147
+ };
148
+
149
+ const boostAdjacency = (ranked: RankedBlock[]) => {
150
+ const important = ranked
151
+ .filter((r) => r.score >= 34 || r.reasons.includes("edit-tool") || r.reasons.includes("test-command") || r.reasons.includes("nonzero-exit"))
152
+ .map((r) => r.index);
153
+
154
+ for (const idx of important) {
155
+ for (let i = idx - 1; i >= Math.max(0, idx - 8); i--) {
156
+ if (ranked[i].block.kind === "user") {
157
+ add(ranked[i], 10, "near-important-event");
158
+ break;
159
+ }
160
+ }
161
+ for (let i = idx - 1; i >= Math.max(0, idx - 4); i--) {
162
+ if (ranked[i].block.kind === "assistant") {
163
+ add(ranked[i], 7, "near-important-event");
164
+ break;
165
+ }
166
+ }
167
+ for (let i = idx + 1; i <= Math.min(ranked.length - 1, idx + 4); i++) {
168
+ if (ranked[i].block.kind === "assistant" || ranked[i].block.kind === "bash") {
169
+ add(ranked[i], 5, "after-important-event");
170
+ break;
171
+ }
172
+ }
173
+ }
174
+ };
175
+
176
+ const nextNonToolResult = (ranked: RankedBlock[], index: number): NormalizedBlock | undefined => {
177
+ for (let i = index + 1; i < ranked.length; i++) {
178
+ if (ranked[i].block.kind !== "tool_result") return ranked[i].block;
179
+ }
180
+ return undefined;
181
+ };
182
+
183
+ const boostSegmentClosingAssistants = (ranked: RankedBlock[]) => {
184
+ for (let i = 0; i < ranked.length; i++) {
185
+ const current = ranked[i];
186
+ if (current.block.kind !== "assistant") continue;
187
+ if (current.block.text.trim().length < MIN_SEGMENT_CLOSING_ASSISTANT_CHARS) continue;
188
+ const next = nextNonToolResult(ranked, i);
189
+ if (!next || next.kind === "user") {
190
+ add(current, 14, "segment-closing-assistant");
191
+ }
192
+ }
193
+ };
194
+
195
+ const dedupKey = (block: NormalizedBlock): string | undefined => {
196
+ const command = bashCommandFromBlock(block);
197
+ const ghPrPoll = command?.match(GH_PR_POLL_RE);
198
+ if (ghPrPoll) return `gh-pr-poll:${ghPrPoll[1]}`;
199
+ if (command) {
200
+ const normalized = command.replace(/\s+/g, " ").trim();
201
+ return normalized ? `bash:${normalized}` : undefined;
202
+ }
203
+ if (block.kind === "tool_call") {
204
+ const path = pathFromBlock(block);
205
+ return path ? `tool:${block.name.toLowerCase()}:${path}` : undefined;
206
+ }
207
+ return undefined;
208
+ };
209
+
210
+ export const rankBriefBlocks = (blocks: NormalizedBlock[], options: BriefRankingOptions = {}): RankedBlock[] => {
211
+ const modifiedFiles = asPathSet(options.fileOps?.modifiedFiles);
212
+ const readFiles = asPathSet(options.fileOps?.readFiles);
213
+ const ranked = blocks.map((block, index) => scoreBlock(block, index, blocks.length, modifiedFiles, readFiles));
214
+ boostAdjacency(ranked);
215
+ boostSegmentClosingAssistants(ranked);
216
+ return ranked;
217
+ };
218
+
219
+ export const selectRankedBriefBlocks = (
220
+ blocks: NormalizedBlock[],
221
+ options: BriefRankingOptions = {},
222
+ ): NormalizedBlock[] => {
223
+ const maxBlocks = options.maxBlocks ?? DEFAULT_MAX_BLOCKS;
224
+ // Size-relative budget: when a ceiling + slope are provided, the effective
225
+ // char budget scales with transcript length (block count) between the floor
226
+ // (maxBriefChars) and the ceiling. Larger transcripts carry more high-value
227
+ // long-tail, so they earn more brief budget; small sessions stay at the floor.
228
+ const maxBriefChars =
229
+ options.maxBriefChars != null && options.maxBriefCharsCeiling != null && options.briefCharsPerBlock != null
230
+ ? Math.round(
231
+ Math.min(
232
+ options.maxBriefCharsCeiling,
233
+ Math.max(options.maxBriefChars, options.briefCharsPerBlock * blocks.length),
234
+ ),
235
+ )
236
+ : options.maxBriefChars;
237
+ // Fast path: nothing to trim by count and no char budget to enforce.
238
+ if (blocks.length <= maxBlocks && maxBriefChars == null) return blocks;
239
+
240
+ const preserveRecentBlocks = Math.min(options.preserveRecentBlocks ?? DEFAULT_RECENT_BLOCKS, maxBlocks);
241
+ const ranked = rankBriefBlocks(blocks, options);
242
+ const selected = new Set<number>();
243
+ const seenKeys = new Set<string>();
244
+
245
+ // Per-block rendered size, only computed when a char budget is active.
246
+ const costs = maxBriefChars == null
247
+ ? null
248
+ : blocks.map((b) => (b.kind === "tool_result" ? 0 : compileBrief([b]).length + 1));
249
+ let usedChars = 0;
250
+
251
+ // Keep the latest blocks to preserve local continuity, iterating NEWEST first
252
+ // so the most recent context is guaranteed. When a char budget is active these
253
+ // are charged against it too and over-budget blocks are skipped -- otherwise a
254
+ // run of large recent blocks could blow past maxBriefChars (the old bug on very
255
+ // long transcripts, where preserve-recent alone exceeded the budget).
256
+ for (let i = blocks.length - 1; i >= Math.max(0, blocks.length - preserveRecentBlocks); i--) {
257
+ if (blocks[i].kind === "tool_result") continue;
258
+ if (selected.has(i)) continue;
259
+ if (costs && usedChars + costs[i] > maxBriefChars!) continue;
260
+ selected.add(i);
261
+ if (costs) usedChars += costs[i];
262
+ const key = dedupKey(blocks[i]);
263
+ if (key) seenKeys.add(key);
264
+ }
265
+
266
+ const ordered = [...ranked].sort((a, b) => b.score - a.score || b.index - a.index);
267
+ for (const item of ordered) {
268
+ if (selected.size >= maxBlocks) break;
269
+ if (selected.has(item.index)) continue;
270
+ if (item.block.kind === "tool_result") continue;
271
+ const key = dedupKey(item.block);
272
+ if (key && seenKeys.has(key)) continue;
273
+ if (costs) {
274
+ // Skip (not break) so smaller high-value blocks can still fit the budget.
275
+ if (usedChars + costs[item.index] > maxBriefChars!) continue;
276
+ usedChars += costs[item.index];
277
+ }
278
+ selected.add(item.index);
279
+ if (key) seenKeys.add(key);
280
+ }
281
+
282
+ return [...selected].sort((a, b) => a - b).map((i) => blocks[i]);
283
+ };
@@ -0,0 +1,160 @@
1
+ import { parseDrillDown } from "./drill-down";
2
+ import { normalizeRecallMode, normalizeRecallScope, parseRecallScope, type RecallScope } from "./recall-scope";
3
+
4
+ export type DrillTarget = NonNullable<ReturnType<typeof parseDrillDown>>;
5
+
6
+ /** The one thing a recall call does. Exactly one action runs per call. */
7
+ export type RecallAction =
8
+ | { kind: "drill"; target: DrillTarget }
9
+ | { kind: "touched"; page?: number }
10
+ | { kind: "expand"; indices: number[] }
11
+ | { kind: "range"; range: unknown[]; page: number }
12
+ | { kind: "search"; query: string; page: number }
13
+ | { kind: "recent"; query?: string };
14
+
15
+ export interface RecallRequest {
16
+ scope: RecallScope;
17
+ action: RecallAction;
18
+ /** Params the caller set that this action does not use. Defaults (page:1, expand:[]) are not listed. */
19
+ ignored?: string[];
20
+ /** Values normalizeToolParams could not repair and removed. */
21
+ dropped?: string[];
22
+ }
23
+
24
+ export interface RecallToolParams {
25
+ query?: string;
26
+ expand?: number[];
27
+ range?: unknown[];
28
+ page?: number;
29
+ scope?: string;
30
+ mode?: string;
31
+ }
32
+
33
+ /** Where normalizeToolParams records values it had to drop; read by parseToolRequest. */
34
+ export const DROPPED_KEY = "_vccDropped";
35
+
36
+ const KNOWN_PARAMS = new Set(["query", "range", "expand", "page", "scope", "mode"]);
37
+
38
+ /** 12, "12", "#12", " 12 " -> 12. Anything else -> undefined. */
39
+ const toIndex = (v: unknown): number | undefined => {
40
+ if (typeof v === "number") return Number.isInteger(v) ? v : undefined;
41
+ if (typeof v === "string") {
42
+ const m = v.trim().match(/^#?(\d+)$/);
43
+ return m ? Number(m[1]) : undefined;
44
+ }
45
+ return undefined;
46
+ };
47
+
48
+ /** "12", "#12, #15", "12 15" -> numbers; undefined unless the whole string is #N tokens. */
49
+ const indexList = (s: string): number[] | undefined => {
50
+ const t = s.trim().replace(/^\[|\]$/g, "");
51
+ if (!/^\s*#?\d+(\s*[,\s]\s*#?\d+)*\s*$/.test(t)) return undefined;
52
+ return (t.match(/\d+/g) ?? []).map(Number);
53
+ };
54
+
55
+ /**
56
+ * Repair argument mistakes whose meaning is certain, before pi validates the
57
+ * schema: numbers sent as strings or with a leading #, a single index where a
58
+ * list is expected, a range written as "10-20" or reversed, enum values in
59
+ * the wrong case, and a scope value put in mode. Values that cannot be
60
+ * repaired are removed and listed under DROPPED_KEY, so the output names them
61
+ * instead of pi rejecting the whole call or recall dropping them silently.
62
+ */
63
+ export const normalizeToolParams = (raw: unknown): Record<string, unknown> => {
64
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
65
+ const p: Record<string, unknown> = { ...(raw as Record<string, unknown>) };
66
+ const dropped: string[] = [];
67
+ const drop = (key: string, why: string) => { dropped.push(`${key} ${JSON.stringify(p[key])} (${why})`); delete p[key]; };
68
+
69
+ for (const key of Object.keys(p)) {
70
+ if (!KNOWN_PARAMS.has(key) && key !== DROPPED_KEY) drop(key, "not a vcc_recall param");
71
+ }
72
+
73
+ if (p.page !== undefined) {
74
+ const n = toIndex(p.page);
75
+ if (n !== undefined && n >= 1) p.page = n; else drop("page", "not a page number");
76
+ }
77
+
78
+ if (p.expand !== undefined) {
79
+ const list = Array.isArray(p.expand) ? p.expand.map(toIndex)
80
+ : typeof p.expand === "string" ? indexList(p.expand)
81
+ : [toIndex(p.expand)];
82
+ if (list && list.every((n) => n !== undefined)) p.expand = list; else drop("expand", "not #N indices");
83
+ }
84
+
85
+ if (p.range !== undefined) {
86
+ let pair: (number | undefined)[] | undefined;
87
+ if (Array.isArray(p.range)) pair = p.range.map(toIndex);
88
+ else if (typeof p.range === "string") {
89
+ const m = p.range.trim().match(/^\[?\s*#?(\d+)\s*(?:(?:-|\.\.|,|to)\s*#?(\d+)\s*)?\]?$/i);
90
+ pair = m ? [Number(m[1]), Number(m[2] ?? m[1])] : undefined;
91
+ } else pair = [toIndex(p.range)];
92
+ // A single index reads just that entry.
93
+ if (pair?.length === 1) pair = [pair[0], pair[0]];
94
+ if (pair && pair.length === 2 && pair.every((n) => n !== undefined)) {
95
+ const [a, b] = pair as number[];
96
+ p.range = a <= b ? [a, b] : [b, a];
97
+ } else drop("range", "not [from, to]");
98
+ }
99
+
100
+ for (const key of ["scope", "mode"] as const) {
101
+ if (typeof p[key] === "string") p[key] = (p[key] as string).trim().toLowerCase();
102
+ }
103
+ if ((p.mode === "all" || p.mode === "lineage") && p.scope === undefined) {
104
+ p.scope = p.mode;
105
+ delete p.mode;
106
+ }
107
+ if (p.scope !== undefined && p.scope !== "lineage" && p.scope !== "all") drop("scope", "use 'lineage' or 'all'");
108
+ if (p.mode !== undefined && p.mode !== "hybrid" && p.mode !== "touched") drop("mode", "use 'touched' or leave it out");
109
+ if (p.query !== undefined && typeof p.query !== "string") drop("query", "not text");
110
+
111
+ if (dropped.length > 0) p[DROPPED_KEY] = dropped;
112
+ return p;
113
+ };
114
+
115
+ /**
116
+ * Tool params -> request. The precedence lives here and only here:
117
+ * #N:path drill-down, then mode:'touched', then expand, then range, then
118
+ * query search, then the most recent entries.
119
+ */
120
+ export const parseToolRequest = (params: RecallToolParams): RecallRequest => {
121
+ const scope = normalizeRecallScope(params.scope);
122
+ const q = params.query?.trim();
123
+ const indices = [...new Set(params.expand ?? [])];
124
+ const hasRange = Array.isArray(params.range) && params.range.length > 0;
125
+ const set = {
126
+ query: Boolean(q),
127
+ expand: indices.length > 0,
128
+ range: hasRange,
129
+ page: (params.page ?? 1) > 1,
130
+ touched: normalizeRecallMode(params.mode) === "touched",
131
+ };
132
+ const droppedRaw = (params as Record<string, unknown>)[DROPPED_KEY];
133
+ const dropped = Array.isArray(droppedRaw) && droppedRaw.length > 0 ? droppedRaw.map(String) : undefined;
134
+ const req = (action: RecallAction, uses: (keyof typeof set)[]): RecallRequest => {
135
+ const ignored = (Object.keys(set) as (keyof typeof set)[])
136
+ .filter((k) => set[k] && !uses.includes(k))
137
+ .map((k) => (k === "touched" ? "mode" : k));
138
+ const base: RecallRequest = dropped ? { scope, action, dropped } : { scope, action };
139
+ return ignored.length > 0 ? { ...base, ignored } : base;
140
+ };
141
+
142
+ const target = q ? parseDrillDown(q) : null;
143
+ if (target) return req({ kind: "drill", target }, ["query"]);
144
+ if (set.touched) return req({ kind: "touched", page: params.page }, ["touched", "page"]);
145
+ if (set.expand) return req({ kind: "expand", indices }, ["expand"]);
146
+ if (hasRange) return req({ kind: "range", range: params.range!, page: Math.max(1, params.page ?? 1) }, ["range", "page"]);
147
+ if (q) return req({ kind: "search", query: params.query!, page: Math.max(1, params.page ?? 1) }, ["query", "page"]);
148
+ return req({ kind: "recent", query: params.query }, []);
149
+ };
150
+
151
+ /** /pi-vcc-recall args -> request. A person types a query, scope:all and page:N only. */
152
+ export const parseCommandRequest = (args: string): RecallRequest => {
153
+ const parsed = parseRecallScope(args.trim());
154
+ const scope = parsed.scope;
155
+ const pageMatch = parsed.text.match(/\bpage:(\d+)\b/i);
156
+ const page = pageMatch ? Math.max(1, parseInt(pageMatch[1], 10)) : 1;
157
+ const query = parsed.text.replace(/\bpage:\d+\b/i, "").trim();
158
+ if (!query) return { scope, action: { kind: "recent" } };
159
+ return { scope, action: { kind: "search", query, page } };
160
+ };
@@ -0,0 +1,30 @@
1
+ export type RecallScope = "lineage" | "all";
2
+ export type RecallMode = "hybrid" | "touched";
3
+
4
+ const SCOPE_RE = /\bscope:(lineage|all)\b/i;
5
+
6
+ const VALID_MODES = new Set(["hybrid", "touched"]);
7
+
8
+ export const normalizeRecallScope = (scope?: unknown): RecallScope =>
9
+ typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
10
+
11
+ /**
12
+ * Normalize a mode param to a supported recall mode. Without OM integration,
13
+ * only "touched" adds behavior beyond the default hybrid search — "file"-only
14
+ * search is not implemented in pi-vcc, so it is not exposed.
15
+ *
16
+ * Ported from pi-blackhole (https://github.com/k0valik/pi-blackhole, MIT) by
17
+ * k0valik — a pi-vcc derivative.
18
+ */
19
+ export const normalizeRecallMode = (mode?: unknown): RecallMode =>
20
+ typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
21
+ ? (mode.toLowerCase() as RecallMode)
22
+ : "hybrid";
23
+
24
+ export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
25
+ const match = text.match(SCOPE_RE);
26
+ return {
27
+ scope: normalizeRecallScope(match?.[1]),
28
+ text: text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim(),
29
+ };
30
+ };
@@ -0,0 +1,55 @@
1
+ import type { Message, ToolCall } from "../pi-types";
2
+ import { clip, textOf } from "./content";
3
+ import { summarizeToolArgs } from "./tool-args";
4
+ import { extractPath } from "./tool-args";
5
+
6
+ export interface RenderedEntry {
7
+ index: number;
8
+ role: string;
9
+ summary: string;
10
+ files?: string[];
11
+ }
12
+
13
+ const toolCallParts = (content: Message["content"]): ToolCall[] => {
14
+ if (!content || typeof content === "string") return [];
15
+ return (content as { type: string }[]).filter((c): c is ToolCall => c.type === "toolCall");
16
+ };
17
+
18
+ const toolCalls = (content: Message["content"]): string => {
19
+ return toolCallParts(content)
20
+ .map((c) => `${c.name}(${summarizeToolArgs(c.arguments)})`)
21
+ .join(", ");
22
+ };
23
+
24
+ const extractFilesFromContent = (content: Message["content"]): string[] => {
25
+ return toolCallParts(content)
26
+ .map((c) => extractPath(c.arguments))
27
+ .filter((p): p is string => p !== null);
28
+ };
29
+
30
+ export const renderMessage = (msg: Message, index: number, full = false): RenderedEntry => {
31
+ if (msg.role === "user") {
32
+ return { index, role: "user", summary: full ? textOf(msg.content) : clip(textOf(msg.content), 300) };
33
+ }
34
+ if (msg.role === "toolResult") {
35
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 200);
36
+ return {
37
+ index, role: "tool_result",
38
+ summary: `[${msg.toolName}] ${text}`,
39
+ };
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
+ }
48
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
49
+ const tools = toolCalls(msg.content);
50
+ const files = extractFilesFromContent(msg.content);
51
+ const summary = tools ? `${tools}\n${text}` : text;
52
+ return { index, role: "assistant", summary, ...(files.length > 0 && { files }) };
53
+ };
54
+
55
+
@@ -0,0 +1,5 @@
1
+ const ANSI_RE = /\x1b\[[0-9;]*[A-Za-z]/g;
2
+ const CTRL_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g;
3
+
4
+ export const sanitize = (text: string): string =>
5
+ text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(ANSI_RE, "").replace(CTRL_RE, "");