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,26 @@
1
+ export interface LineageEntryLike {
2
+ id?: string;
3
+ }
4
+
5
+ export interface LineageSessionManagerLike {
6
+ getBranch: () => LineageEntryLike[];
7
+ getEntries?: () => LineageEntryLike[];
8
+ }
9
+
10
+ export const getActiveLineageEntryIds = (sessionManager: LineageSessionManagerLike): Set<string> => {
11
+ try {
12
+ const branch = sessionManager.getBranch() ?? [];
13
+ if (branch.length > 0) {
14
+ return new Set(branch.map((e) => e.id).filter((id): id is string => Boolean(id)));
15
+ }
16
+ } catch {
17
+ // fall through to defensive fallback
18
+ }
19
+
20
+ try {
21
+ const all = sessionManager.getEntries?.() ?? [];
22
+ return new Set(all.map((e) => e.id).filter((id): id is string => Boolean(id)));
23
+ } catch {
24
+ return new Set();
25
+ }
26
+ };
@@ -0,0 +1,41 @@
1
+ import { readFileSync } from "fs";
2
+ import type { Message } from "@earendil-works/pi-ai";
3
+ import { renderMessage, type RenderedEntry } from "./render-entries";
4
+
5
+ export interface LoadedMessages {
6
+ rendered: RenderedEntry[];
7
+ rawMessages: Message[];
8
+ entryIds: string[];
9
+ }
10
+
11
+ export const loadAllMessages = (
12
+ sessionFile: string,
13
+ full: boolean,
14
+ allowedEntryIds?: Set<string>,
15
+ ): LoadedMessages => {
16
+ const content = readFileSync(sessionFile, "utf-8");
17
+ const entries: any[] = [];
18
+ for (const line of content.split("\n")) {
19
+ if (!line.trim()) continue;
20
+ try { entries.push(JSON.parse(line)); } catch {}
21
+ }
22
+ const rendered: RenderedEntry[] = [];
23
+ const rawMessages: Message[] = [];
24
+ const entryIds: string[] = [];
25
+
26
+ let messageIndex = 0;
27
+ for (const e of entries) {
28
+ const isMessage = e.type === "message" && e.message;
29
+ if (!isMessage) continue;
30
+
31
+ const allowed = !allowedEntryIds || allowedEntryIds.has(e.id);
32
+ if (allowed) {
33
+ rendered.push(renderMessage(e.message, messageIndex, String(e.id), full));
34
+ rawMessages.push(e.message);
35
+ entryIds.push(String(e.id));
36
+ }
37
+ messageIndex++;
38
+ }
39
+
40
+ return { rendered, rawMessages, entryIds };
41
+ };
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Message normalization — converts raw messages to NormalizedBlock array.
3
+ *
4
+ * Upstream: https://github.com/sting8k/pi-vcc (src/core/normalize.ts)
5
+ * Unmodified.
6
+ */
7
+ import type { Message } from "@earendil-works/pi-ai";
8
+ import type { NormalizedBlock } from "../types";
9
+ import { textOf } from "./content";
10
+ import { sanitize } from "./sanitize";
11
+
12
+ const normalizeOne = (msg: Message, msgIndex: number): NormalizedBlock[] => {
13
+ if (msg.role === "user") {
14
+ const blocks: NormalizedBlock[] = [];
15
+ const text = sanitize(textOf(msg.content));
16
+ if (text) blocks.push({ kind: "user", text, sourceIndex: msgIndex });
17
+ if (msg.content && typeof msg.content !== "string") {
18
+ for (const part of msg.content) {
19
+ if (part.type === "image") {
20
+ blocks.push({ kind: "user", text: `[image: ${part.mimeType}]`, sourceIndex: msgIndex });
21
+ }
22
+ }
23
+ }
24
+ return blocks.length > 0 ? blocks : [{ kind: "user", text: "", sourceIndex: msgIndex }];
25
+ }
26
+
27
+ if ((msg as any).role === "bashExecution") {
28
+ const cmd = (msg as any).command ?? "";
29
+ const out = (msg as any).output ?? "";
30
+ const exit = (msg as any).exitCode;
31
+ return [{ kind: "bash", command: cmd, output: out, exitCode: exit, sourceIndex: msgIndex }];
32
+ }
33
+
34
+ if (msg.role === "toolResult") {
35
+ return [{
36
+ kind: "tool_result",
37
+ name: msg.toolName,
38
+ text: sanitize(textOf(msg.content)),
39
+ isError: msg.isError,
40
+ sourceIndex: msgIndex,
41
+ }];
42
+ }
43
+
44
+ if (msg.role === "assistant") {
45
+ if (!msg.content) return [];
46
+ if (typeof msg.content === "string") {
47
+ return [{ kind: "assistant", text: sanitize(msg.content), sourceIndex: msgIndex }];
48
+ }
49
+
50
+ const blocks: NormalizedBlock[] = [];
51
+ for (const part of msg.content) {
52
+ if (part.type === "text") {
53
+ blocks.push({ kind: "assistant", text: sanitize(part.text), sourceIndex: msgIndex });
54
+ } else if (part.type === "thinking") {
55
+ blocks.push({
56
+ kind: "thinking",
57
+ text: sanitize(part.thinking),
58
+ redacted: part.redacted ?? false,
59
+ sourceIndex: msgIndex,
60
+ });
61
+ } else if (part.type === "toolCall") {
62
+ blocks.push({
63
+ kind: "tool_call",
64
+ name: part.name,
65
+ args: part.arguments,
66
+ sourceIndex: msgIndex,
67
+ });
68
+ }
69
+ }
70
+ return blocks;
71
+ }
72
+
73
+ return [];
74
+ };
75
+
76
+ export const normalize = (messages: Message[]): NormalizedBlock[] =>
77
+ messages.flatMap((msg, i) => normalizeOne(msg, i));
78
+
79
+
@@ -0,0 +1,14 @@
1
+ export type RecallScope = "lineage" | "all";
2
+
3
+ const SCOPE_RE = /\bscope:(lineage|all)\b/i;
4
+
5
+ export const normalizeRecallScope = (scope?: unknown): RecallScope =>
6
+ typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
7
+
8
+ export const parseRecallScope = (text: string): { scope: RecallScope; text: string } => {
9
+ const match = text.match(SCOPE_RE);
10
+ return {
11
+ scope: normalizeRecallScope(match?.[1]),
12
+ text: text.replace(SCOPE_RE, "").replace(/\s+/g, " ").trim(),
13
+ };
14
+ };
@@ -0,0 +1,56 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
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
+ id: string;
9
+ role: string;
10
+ summary: string;
11
+ files?: string[];
12
+ }
13
+
14
+ const toolCalls = (content: Message["content"]): string => {
15
+ if (!content || typeof content === "string") return "";
16
+ return content
17
+ .filter((c) => c.type === "toolCall")
18
+ .map((c) => `${c.name}(${summarizeToolArgs(c.arguments)})`)
19
+ .join(", ");
20
+ };
21
+
22
+ const extractFilesFromContent = (content: Message["content"]): string[] => {
23
+ if (!content || typeof content === "string") return [];
24
+ return content
25
+ .filter((c) => c.type === "toolCall")
26
+ .map((c) => extractPath(c.arguments))
27
+ .filter((p): p is string => p !== null);
28
+ };
29
+
30
+ export const renderMessage = (msg: Message, index: number, id: string, full = false): RenderedEntry => {
31
+ if (msg.role === "user") {
32
+ return { index, id, role: "user", summary: full ? textOf(msg.content) : clip(textOf(msg.content), 300) };
33
+ }
34
+ if (msg.role === "toolResult") {
35
+ const prefix = msg.isError ? "ERROR " : "";
36
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 200);
37
+ return {
38
+ index, id, role: "tool_result",
39
+ summary: `${prefix}[${msg.toolName}] ${text}`,
40
+ };
41
+ }
42
+ // bashExecution has command+output instead of content
43
+ if ((msg as any).role === "bashExecution") {
44
+ const cmd = (msg as any).command ?? "";
45
+ const out = (msg as any).output ?? "";
46
+ const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
47
+ return { index, id, role: "bash", summary: text };
48
+ }
49
+ const text = full ? textOf(msg.content) : clip(textOf(msg.content), 300);
50
+ const tools = toolCalls(msg.content);
51
+ const files = extractFilesFromContent(msg.content);
52
+ const summary = tools ? `${tools}\n${text}` : text;
53
+ return { index, id, role: "assistant", summary, ...(files.length > 0 && { files }) };
54
+ };
55
+
56
+
@@ -0,0 +1,237 @@
1
+ import type { Message } from "@earendil-works/pi-ai";
2
+ import { buildSections } from "./build-sections";
3
+ import { clip } from "./content";
4
+ import { normalize } from "./normalize";
5
+ import { renderMessage } from "./render-entries";
6
+ import { searchEntries } from "./search-entries";
7
+ import { type CompileInput, compile } from "./summarize";
8
+
9
+ const SECTION_HEADERS = ["Session Goal", "Files And Changes", "Commits", "Outstanding Context"];
10
+
11
+ interface RoleCounts {
12
+ user: number;
13
+ assistant: number;
14
+ toolResult: number;
15
+ }
16
+
17
+ interface BlockCounts {
18
+ user: number;
19
+ assistant: number;
20
+ toolCalls: number;
21
+ toolResults: number;
22
+ thinking: number;
23
+ }
24
+
25
+ export interface RecallProbe {
26
+ label: string;
27
+ sourceText: string;
28
+ query: string;
29
+ summaryMentioned: boolean;
30
+ recallHits: number;
31
+ }
32
+
33
+ export interface CompactReport {
34
+ summary: string;
35
+ before: {
36
+ messageCount: number;
37
+ roleCounts: RoleCounts;
38
+ blockCounts: BlockCounts;
39
+ inputChars: number;
40
+ estimatedTokens: number;
41
+ topFiles: string[];
42
+ preview: string;
43
+ };
44
+ after: {
45
+ summaryLength: number;
46
+ estimatedTokens: number;
47
+ sectionCount: number;
48
+ summaryPreview: string;
49
+ goalsCount: number;
50
+ blockersCount: number;
51
+ briefTranscriptLines: number;
52
+ };
53
+ compression: {
54
+ charsBefore: number;
55
+ charsAfter: number;
56
+ ratio: number;
57
+ messagesBefore: number;
58
+ };
59
+ recall: {
60
+ probes: RecallProbe[];
61
+ };
62
+ }
63
+
64
+ const estimateTokensFromChars = (chars: number): number =>
65
+ Math.ceil(chars / 4);
66
+
67
+ const countRoles = (messages: Message[]): RoleCounts => {
68
+ const counts: RoleCounts = { user: 0, assistant: 0, toolResult: 0 };
69
+ for (const msg of messages) {
70
+ if (msg.role === "user") counts.user += 1;
71
+ else if (msg.role === "assistant") counts.assistant += 1;
72
+ else if (msg.role === "toolResult") counts.toolResult += 1;
73
+ }
74
+ return counts;
75
+ };
76
+
77
+ const countBlocks = (messages: Message[]): BlockCounts => {
78
+ const counts: BlockCounts = {
79
+ user: 0,
80
+ assistant: 0,
81
+ toolCalls: 0,
82
+ toolResults: 0,
83
+ thinking: 0,
84
+ };
85
+
86
+ for (const block of normalize(messages)) {
87
+ if (block.kind === "user") counts.user += 1;
88
+ else if (block.kind === "assistant") counts.assistant += 1;
89
+ else if (block.kind === "tool_call") counts.toolCalls += 1;
90
+ else if (block.kind === "tool_result") counts.toolResults += 1;
91
+ else if (block.kind === "thinking") counts.thinking += 1;
92
+ }
93
+
94
+ return counts;
95
+ };
96
+
97
+ const inputCharsOf = (messages: Message[]): number =>
98
+ messages
99
+ .map((msg, index) => renderMessage(msg, index, "", true).summary.length)
100
+ .reduce((sum, len) => sum + len, 0);
101
+
102
+ const topFilesOf = (messages: Message[]): string[] => {
103
+ const files = new Set<string>();
104
+ for (const block of normalize(messages)) {
105
+ if (block.kind === "tool_call") {
106
+ for (const key of ["path", "file_path", "filePath", "file"]) {
107
+ const val = block.args[key];
108
+ if (typeof val === "string") { files.add(val); break; }
109
+ }
110
+ }
111
+ }
112
+ return [...files].slice(0, 10);
113
+ };
114
+
115
+ const previewOf = (messages: Message[], edgeCount = 3): string => {
116
+ const rendered = messages.map((msg, index) => renderMessage(msg, index, ""));
117
+ if (rendered.length === 0) return "(empty)";
118
+ if (rendered.length <= edgeCount * 2) {
119
+ return rendered
120
+ .map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`)
121
+ .join("\n");
122
+ }
123
+
124
+ const first = rendered.slice(0, edgeCount);
125
+ const last = rendered.slice(-edgeCount);
126
+ return [
127
+ ...first.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
128
+ "...",
129
+ ...last.map((entry) => `#${entry.index} [${entry.role}] ${clip(entry.summary, 220)}`),
130
+ ].join("\n");
131
+ };
132
+
133
+ const sectionCountOf = (summary: string): number =>
134
+ SECTION_HEADERS.filter((header) => summary.includes(`[${header}]`)).length;
135
+
136
+ const briefLineCountOf = (summary: string): number => {
137
+ const sep = "\n\n---\n\n";
138
+ const idx = summary.indexOf(sep);
139
+ if (idx < 0) return 0;
140
+ return summary.slice(idx + sep.length).split("\n").length;
141
+ };
142
+
143
+ const queryTermsOf = (text: string): string[] =>
144
+ (text.match(/[\p{L}\p{N}_./-]{3,}/gu) ?? [])
145
+ .map((part) => part.trim())
146
+ .filter(Boolean);
147
+
148
+ const queryOf = (text: string): string => {
149
+ const terms = queryTermsOf(text);
150
+ return terms.slice(0, 6).join(" ");
151
+ };
152
+
153
+ const matchesQuery = (text: string, query: string): boolean => {
154
+ const hay = text.toLowerCase();
155
+ return query
156
+ .toLowerCase()
157
+ .split(/\s+/)
158
+ .filter(Boolean)
159
+ .every((term) => hay.includes(term));
160
+ };
161
+
162
+ const probesOf = (messages: Message[], summary: string): RecallProbe[] => {
163
+ const blocks = normalize(messages);
164
+ const data = buildSections({ blocks });
165
+
166
+ // Find first file from tool calls
167
+ let firstFile = "";
168
+ for (const b of blocks) {
169
+ if (b.kind === "tool_call") {
170
+ for (const key of ["path", "file_path", "filePath", "file"]) {
171
+ if (typeof b.args[key] === "string") { firstFile = b.args[key] as string; break; }
172
+ }
173
+ if (firstFile) break;
174
+ }
175
+ }
176
+
177
+ const rawProbes = [
178
+ { label: "goal", text: data.sessionGoal[0] ?? "" },
179
+ { label: "file", text: firstFile },
180
+ { label: "problem", text: data.outstandingContext[0] ?? "" },
181
+ ];
182
+
183
+ const rendered = messages.map((msg, index) => renderMessage(msg, index, ""));
184
+
185
+ return rawProbes
186
+ .map(({ label, text }) => {
187
+ const sourceText = text.trim();
188
+ const query = queryOf(sourceText);
189
+ if (!query) return null;
190
+ return {
191
+ label,
192
+ sourceText,
193
+ query,
194
+ summaryMentioned: matchesQuery(summary, query),
195
+ recallHits: searchEntries(rendered, messages, query).length,
196
+ };
197
+ })
198
+ .filter((probe): probe is RecallProbe => probe !== null);
199
+ };
200
+
201
+ export const buildCompactReport = (input: CompileInput): CompactReport => {
202
+ const summary = compile(input);
203
+ const data = buildSections({ blocks: normalize(input.messages) });
204
+ const inputChars = inputCharsOf(input.messages);
205
+ const topFiles = topFilesOf(input.messages);
206
+
207
+ return {
208
+ summary,
209
+ before: {
210
+ messageCount: input.messages.length,
211
+ roleCounts: countRoles(input.messages),
212
+ blockCounts: countBlocks(input.messages),
213
+ inputChars,
214
+ estimatedTokens: estimateTokensFromChars(inputChars),
215
+ topFiles,
216
+ preview: previewOf(input.messages),
217
+ },
218
+ after: {
219
+ summaryLength: summary.length,
220
+ estimatedTokens: estimateTokensFromChars(summary.length),
221
+ sectionCount: sectionCountOf(summary),
222
+ summaryPreview: summary,
223
+ goalsCount: data.sessionGoal.length,
224
+ blockersCount: data.outstandingContext.length,
225
+ briefTranscriptLines: briefLineCountOf(summary),
226
+ },
227
+ compression: {
228
+ charsBefore: inputChars,
229
+ charsAfter: summary.length,
230
+ ratio: summary.length === 0 ? 0 : Number((inputChars / summary.length).toFixed(2)),
231
+ messagesBefore: input.messages.length,
232
+ },
233
+ recall: {
234
+ probes: probesOf(input.messages, summary),
235
+ },
236
+ };
237
+ };
@@ -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, "");
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Search entries — BM25 + regex search over session history.
3
+ *
4
+ * Upstream: https://github.com/sting8k/pi-vcc (src/core/search-entries.ts)
5
+ * Unmodified.
6
+ */
7
+ import type { Message } from "@earendil-works/pi-ai";
8
+ import type { RenderedEntry } from "./render-entries";
9
+ import { textOf } from "./content";
10
+
11
+ export interface SearchHit extends RenderedEntry {
12
+ /** Context snippet around the first matched term (only when query provided) */
13
+ snippet?: string;
14
+ /** Number of query terms matched (for ranking) */
15
+ matchCount?: number;
16
+ }
17
+
18
+ const escapeRegex = (s: string): string =>
19
+ s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+
21
+ /** Try to compile as regex; fall back to escaped literal. */
22
+ const safeRegex = (pattern: string): RegExp => {
23
+ try {
24
+ return new RegExp(pattern, "i");
25
+ } catch {
26
+ return new RegExp(escapeRegex(pattern), "i");
27
+ }
28
+ };
29
+
30
+ /** Detect if the query looks like a single regex pattern (contains regex metacharacters). */
31
+ const looksLikeRegex = (query: string): boolean =>
32
+ /[|*+?{}()[\]\\^$.]/.test(query);
33
+
34
+ /** Build a regex for snippet highlighting — matches first available term. */
35
+ const snippetRegex = (terms: string[]): RegExp => {
36
+ const alts = terms.map((t) => {
37
+ try {
38
+ // Validate that it's a valid regex
39
+ new RegExp(t, "i");
40
+ return t;
41
+ } catch {
42
+ return escapeRegex(t);
43
+ }
44
+ });
45
+ return new RegExp(alts.join("|"), "i");
46
+ };
47
+
48
+ // ── Stopwords for natural language queries ──
49
+ const STOPWORDS = new Set([
50
+ // English
51
+ "the", "a", "an", "is", "are", "was", "were", "be", "been", "being",
52
+ "have", "has", "had", "do", "does", "did", "will", "would", "could",
53
+ "should", "may", "might", "can", "shall", "of", "in", "to", "for",
54
+ "with", "on", "at", "from", "by", "as", "into", "through", "during",
55
+ "before", "after", "above", "below", "between", "out", "off", "over",
56
+ "under", "again", "further", "then", "once", "here", "there", "when",
57
+ "where", "why", "how", "all", "both", "each", "few", "more", "most",
58
+ "other", "some", "such", "no", "nor", "not", "only", "own", "same",
59
+ "so", "than", "too", "very", "just", "about", "it", "its", "that",
60
+ "this", "what", "which", "who", "whom", "these", "those",
61
+ ]);
62
+
63
+ /** Remove stopwords, keep meaningful terms. */
64
+ const filterStopwords = (terms: string[]): string[] => {
65
+ const meaningful = terms.filter((t) => !STOPWORDS.has(t.toLowerCase()) && t.length > 1);
66
+ // If all terms were stopwords, return original (don't lose everything)
67
+ return meaningful.length > 0 ? meaningful : terms;
68
+ };
69
+
70
+ /** Count how many distinct terms match the haystack. */
71
+ const countMatches = (hay: string, terms: string[]): number => {
72
+ let count = 0;
73
+ for (const t of terms) {
74
+ if (safeRegex(t).test(hay)) count++;
75
+ }
76
+ return count;
77
+ };
78
+
79
+ // ── BM25-lite scoring ──
80
+ const BM25_K = 1.2;
81
+ const BM25_B = 0.75;
82
+
83
+ /** Count occurrences of a regex pattern in text. */
84
+ const termFreq = (text: string, pattern: RegExp): number => {
85
+ const matches = text.match(new RegExp(pattern.source, "gi"));
86
+ return matches ? matches.length : 0;
87
+ };
88
+
89
+ interface BM25Context {
90
+ n: number; // total docs
91
+ avgDl: number; // average doc length (words)
92
+ df: Map<string, number>; // term -> number of docs containing it
93
+ }
94
+
95
+ /** Precompute IDF and avgDl across all docs. */
96
+ const buildBM25Context = (docs: string[], terms: string[]): BM25Context => {
97
+ const n = docs.length;
98
+ const df = new Map<string, number>();
99
+ let totalLen = 0;
100
+
101
+ for (const doc of docs) {
102
+ totalLen += doc.split(/\s+/).length;
103
+ for (const t of terms) {
104
+ if (safeRegex(t).test(doc)) {
105
+ df.set(t, (df.get(t) ?? 0) + 1);
106
+ }
107
+ }
108
+ }
109
+
110
+ return { n, avgDl: totalLen / Math.max(n, 1), df };
111
+ };
112
+
113
+ /** BM25 score for a single doc against query terms. */
114
+ const bm25Score = (doc: string, terms: string[], ctx: BM25Context): number => {
115
+ const dl = doc.split(/\s+/).length;
116
+ let score = 0;
117
+
118
+ for (const t of terms) {
119
+ const tf = termFreq(doc, safeRegex(t));
120
+ if (tf === 0) continue;
121
+
122
+ const docFreq = ctx.df.get(t) ?? 0;
123
+ // IDF: log((N - df + 0.5) / (df + 0.5) + 1)
124
+ const idf = Math.log((ctx.n - docFreq + 0.5) / (docFreq + 0.5) + 1);
125
+ // TF saturation with length normalization
126
+ const tfNorm = (tf * (BM25_K + 1)) / (tf + BM25_K * (1 - BM25_B + BM25_B * dl / ctx.avgDl));
127
+ score += idf * tfNorm;
128
+ }
129
+
130
+ return score;
131
+ };
132
+
133
+ /** Line-based snippet: ±contextLines around first regex match. */
134
+ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | undefined => {
135
+ const lines = text.split("\n");
136
+ let matchIdx = -1;
137
+ for (let i = 0; i < lines.length; i++) {
138
+ if (regex.test(lines[i])) {
139
+ matchIdx = i;
140
+ break;
141
+ }
142
+ }
143
+ if (matchIdx === -1) return undefined;
144
+
145
+ const start = Math.max(0, matchIdx - contextLines);
146
+ const end = Math.min(lines.length, matchIdx + contextLines + 1);
147
+ const slice = lines.slice(start, end);
148
+
149
+ const parts: string[] = [];
150
+ if (start > 0) parts.push(`...(${start} lines above)`);
151
+ parts.push(...slice);
152
+ if (end < lines.length) parts.push(`...(${lines.length - end} lines below)`);
153
+ return parts.join("\n");
154
+ };
155
+
156
+ /** Build full searchable text for a message. */
157
+ const fullText = (msg: Message): string => {
158
+ if ((msg as any).role === "bashExecution") {
159
+ return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
160
+ }
161
+ return textOf(msg.content);
162
+ };
163
+
164
+ export const searchEntries = (
165
+ entries: RenderedEntry[],
166
+ messages: Message[],
167
+ query?: string,
168
+ ): SearchHit[] => {
169
+ if (!query?.trim()) return entries;
170
+
171
+ const rawQuery = query.trim();
172
+
173
+ // If query looks like a single regex pattern (contains metacharacters),
174
+ // treat the whole thing as one pattern — don't split into terms
175
+ if (looksLikeRegex(rawQuery)) {
176
+ const regex = safeRegex(rawQuery);
177
+ const hits: SearchHit[] = [];
178
+ for (let i = 0; i < entries.length; i++) {
179
+ const e = entries[i];
180
+ const msg = messages[i];
181
+ const text = msg ? fullText(msg) : e.summary;
182
+ const filePart = e.files?.join(" ") ?? "";
183
+ const hay = `${e.role} ${text} ${filePart}`;
184
+ if (regex.test(hay)) {
185
+ const snip = lineSnippet(text, regex);
186
+ hits.push({ ...e, snippet: snip, matchCount: 1 });
187
+ }
188
+ }
189
+ return hits;
190
+ }
191
+
192
+ // Natural language / multi-word query: BM25 scoring
193
+ const rawTerms = rawQuery.split(/\s+/);
194
+ const terms = filterStopwords(rawTerms);
195
+ const snipRe = snippetRegex(terms);
196
+
197
+ // Build all docs for BM25 context
198
+ const docs: string[] = [];
199
+ for (let i = 0; i < entries.length; i++) {
200
+ const e = entries[i];
201
+ const msg = messages[i];
202
+ const text = msg ? fullText(msg) : e.summary;
203
+ const filePart = e.files?.join(" ") ?? "";
204
+ docs.push(`${e.role} ${text} ${filePart}`);
205
+ }
206
+
207
+ const ctx = buildBM25Context(docs, terms);
208
+
209
+ const scored: Array<{ hit: SearchHit; score: number }> = [];
210
+ for (let i = 0; i < entries.length; i++) {
211
+ const e = entries[i];
212
+ const hay = docs[i];
213
+ const mc = countMatches(hay, terms);
214
+ if (mc === 0) continue;
215
+ const score = bm25Score(hay, terms, ctx);
216
+ const text = messages[i] ? fullText(messages[i]) : e.summary;
217
+ const snip = lineSnippet(text, snipRe);
218
+ scored.push({
219
+ hit: { ...e, snippet: snip, matchCount: mc },
220
+ score,
221
+ });
222
+ }
223
+
224
+ // Sort by BM25 score desc
225
+ scored.sort((a, b) => b.score - a.score);
226
+ return scored.map((s) => s.hit);
227
+ };