pi-blackhole 0.2.2 → 0.3.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.
@@ -57,10 +57,17 @@ function readCooldownMap(): CooldownMap {
57
57
  }
58
58
 
59
59
  function writeCooldownMap(map: CooldownMap): void {
60
- const path = cooldownPath();
61
- const dir = dirname(path);
62
- if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
63
- writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
60
+ try {
61
+ const path = cooldownPath();
62
+ const dir = dirname(path);
63
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
64
+ writeFileSync(path, `${JSON.stringify(map, null, 2)}\n`);
65
+ } catch {
66
+ // Best-effort: cooldowns are advisory. Losing them means a rate-limited
67
+ // model might be retried before its cooldown window expires — slightly
68
+ // more API traffic, no data loss. This also prevents a process crash
69
+ // on read-only filesystems.
70
+ }
64
71
  }
65
72
 
66
73
  // ── API ─────────────────────────────────────────────────────────────────────
@@ -4,6 +4,7 @@
4
4
  * Upstream: https://github.com/elpapi42/pi-observational-memory (src/session-ledger/projection.ts)
5
5
  * Unmodified.
6
6
  */
7
+ import { selectPriorObservations } from "./render-summary.js";
7
8
  import {
8
9
  OM_FOLDED,
9
10
  isMemoryDetails,
@@ -207,10 +208,21 @@ export function buildCompactionProjection(
207
208
  0,
208
209
  );
209
210
  const fullFold = observationTokens >= config.observationsPoolMaxTokens;
210
- const projection = fullFold
211
+ let projection = fullFold
211
212
  ? fullProjection(entries, firstKeptEntryId)
212
213
  : normalProjection;
213
214
 
215
+ // Cap observations to budget using relevance-tiered + recency scoring.
216
+ // Even if the dropper determined some old observations are worth keeping,
217
+ // this safety valve ensures the compaction output never exceeds the pool
218
+ // token budget. Observations survive in the branch regardless.
219
+ if (config.observationsPoolMaxTokens > 0 && observationTokens >= config.observationsPoolMaxTokens) {
220
+ projection = {
221
+ observations: selectPriorObservations(projection.observations, config.observationsPoolMaxTokens),
222
+ reflections: projection.reflections,
223
+ };
224
+ }
225
+
214
226
  const details: MemoryDetails = {
215
227
  type: OM_FOLDED,
216
228
  version: 1,
@@ -25,6 +25,57 @@ export function observationToSummaryLine(observation: Observation): string {
25
25
  return `[${observation.id}] ${observation.timestamp} [${observation.relevance}] ${observation.content}`;
26
26
  }
27
27
 
28
+ /** Score an observation for cap/trim selection.
29
+ * Relevance tier dominates: medium (5+) always outranks low (max 2).
30
+ * Recency is based on position in the flat-mapped array (0 = oldest, N-1 = newest),
31
+ * avoiding wall-clock dependency that punishes sessions spanning days or weeks. */
32
+ export function scoreObservation(obs: Observation, index: number, total: number): number {
33
+ const base = obs.relevance === "high" || obs.relevance === "critical" ? 10
34
+ : obs.relevance === "medium" ? 5 : 1;
35
+ const recency = total > 1 ? index / (total - 1) : 1;
36
+ return base + recency;
37
+ }
38
+
39
+ /** Select observations up to a token budget, keeping all high-relevance items
40
+ * unconditionally and filling the remaining budget with the best-scoring
41
+ * medium and low observations (relevance-tiered + recency).
42
+ *
43
+ * Reflections are never trimmed — they are inherently rare and always stay.
44
+ * Observations stay in the branch either way; this only caps what is rendered
45
+ * in the compaction summary output. */
46
+ export function selectPriorObservations(observations: Observation[], maxTokens: number): Observation[] {
47
+ // Track original indices so we can restore chronological order after scoring
48
+ const indexed = observations.map((obs, i) => ({ obs, originalIndex: i }));
49
+ const high = indexed.filter(item => item.obs.relevance === "high" || item.obs.relevance === "critical");
50
+ const rest = indexed.filter(item => item.obs.relevance !== "high" && item.obs.relevance !== "critical");
51
+
52
+ // High always kept — consume budget first
53
+ let budget = maxTokens;
54
+ const selected = new Set<{ obs: Observation; originalIndex: number }>();
55
+ for (const item of high) {
56
+ const lineTokens = Math.ceil(observationToSummaryLine(item.obs).length / 4);
57
+ selected.add(item);
58
+ budget -= lineTokens;
59
+ }
60
+
61
+ // Score medium + low and select best within remaining budget
62
+ if (rest.length > 0 && budget > 0) {
63
+ const scored = rest.map((item, i) => ({ item, score: scoreObservation(item.obs, i, rest.length) }));
64
+ scored.sort((a, b) => b.score - a.score); // highest score first
65
+ for (const { item } of scored) {
66
+ const lineTokens = Math.ceil(observationToSummaryLine(item.obs).length / 4);
67
+ if (budget - lineTokens < 0) break;
68
+ selected.add(item);
69
+ budget -= lineTokens;
70
+ }
71
+ }
72
+
73
+ // Restore original chronological order before returning
74
+ return Array.from(selected)
75
+ .sort((a, b) => a.originalIndex - b.originalIndex)
76
+ .map(item => item.obs);
77
+ }
78
+
28
79
  export function reflectionToSummaryLine(reflection: Reflection): string {
29
80
  return `[${reflection.id}] ${reflection.content}`;
30
81
  }
package/src/om/pending.ts CHANGED
@@ -14,10 +14,8 @@
14
14
  * concurrent pi sessions writing to a shared file.
15
15
  */
16
16
  import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
17
- import { dirname, join } from "node:path";
17
+ import { join } from "node:path";
18
18
  import { getAgentDir } from "@earendil-works/pi-coding-agent";
19
- import type { Observation, Reflection } from "./ledger/types.js";
20
-
21
19
  // ── Types ───────────────────────────────────────────────────────────────────
22
20
 
23
21
  export interface PendingObservation {
@@ -143,7 +141,13 @@ function writeSessionState(sessionId: string, state: PendingOMState): void {
143
141
  }
144
142
  } catch { /* best-effort — stale backup is optional */ }
145
143
 
146
- writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
144
+ try {
145
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
146
+ } catch {
147
+ // Best-effort: pending state loss means consolidation may re-process
148
+ // the same data on the next run — safe (idempotent by design).
149
+ // Prevents process crash on read-only filesystems.
150
+ }
147
151
  }
148
152
 
149
153
  /**
@@ -4,13 +4,17 @@
4
4
  *
5
5
  * Created by pi-vcc-om. Replaces pi-vcc's vcc_recall and OM's standalone recall-observation.
6
6
  */
7
- import { Type } from "@earendil-works/pi-ai";
7
+ import { Type } from "typebox";
8
+ import { StringEnum } from "@earendil-works/pi-ai";
8
9
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
9
10
  import { loadAllMessages } from "../core/load-messages";
10
- import { searchEntries } from "../core/search-entries";
11
- import { formatRecallOutput } from "../core/format-recall";
11
+ import { searchEntries, getFileIndicators, getTouchedFiles } from "../core/search-entries";
12
+ import type { RenderedEntry } from "../core/render-entries";
13
+ import type { SearchHit } from "../core/search-entries";
14
+ import { formatRecallOutput, formatTouchedOutput } from "../core/format-recall";
12
15
  import { getActiveLineageEntryIds } from "../core/lineage";
13
- import { normalizeRecallScope } from "../core/recall-scope";
16
+ import { normalizeRecallScope, normalizeRecallMode } from "../core/recall-scope";
17
+ import { parseDrillDown, expandEntryFile } from "../core/drill-down.js";
14
18
  import {
15
19
  recallMemorySources,
16
20
  type Entry,
@@ -29,20 +33,63 @@ import {
29
33
  const DEFAULT_RECENT = 25;
30
34
  const PAGE_SIZE = 5;
31
35
 
32
- const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
36
+ export const invalidExpandIndices = (requested: number[], available: Set<number>): number[] =>
33
37
  requested.filter((i) => !Number.isInteger(i) || !available.has(i));
34
38
 
35
- async function vccRecall(params: { query?: string; expand?: number[]; page?: number; scope?: "lineage" | "all" }, ctx: any) {
39
+ /**
40
+ * Merge expanded (full-content) entries into search results.
41
+ * Overlapping entries get their summary replaced with full content.
42
+ * Non-overlapping expanded entries are appended. Results are sorted by index.
43
+ */
44
+ export function mergeExpandedIntoSearchResults(
45
+ searchResults: SearchHit[],
46
+ expandedEntries: RenderedEntry[],
47
+ ): SearchHit[] {
48
+ if (expandedEntries.length === 0) return searchResults;
49
+
50
+ const expandedByIndex = new Map(expandedEntries.map((e) => [e.index, e]));
51
+
52
+ // Replace truncated summaries with full content for expanded indices
53
+ const merged = searchResults.map((r) => {
54
+ const full = expandedByIndex.get(r.index);
55
+ return full ? { ...r, summary: full.summary } : r;
56
+ });
57
+
58
+ // Append expand-only entries not already in search results
59
+ for (const fe of expandedEntries) {
60
+ if (!merged.some((r) => r.index === fe.index)) {
61
+ merged.push(fe as SearchHit);
62
+ }
63
+ }
64
+
65
+ // Maintain natural order by index
66
+ merged.sort((a, b) => a.index - b.index);
67
+ return merged;
68
+ }
69
+
70
+ async function vccRecall(params: { query?: string; expand?: number[]; page?: number; scope?: "lineage" | "all"; mode?: string }, ctx: any) {
36
71
  const sessionFile = ctx.sessionManager.getSessionFile();
37
72
  if (!sessionFile) {
38
73
  return { content: [{ type: "text" as const, text: "No session file available." }], details: undefined };
39
74
  }
40
75
  const scope = normalizeRecallScope(params.scope);
76
+ const mode = normalizeRecallMode(params.mode);
41
77
  const lineageEntryIds = scope === "lineage" ? getActiveLineageEntryIds(ctx.sessionManager) : undefined;
78
+
79
+ // ── "touched" mode: aggregate file operations ──
80
+ if (mode === "touched") {
81
+ const { rendered, rawMessages } = loadAllMessages(sessionFile, false, lineageEntryIds);
82
+ const touched = getTouchedFiles(rawMessages, rendered);
83
+ const text = formatTouchedOutput(touched, params.page);
84
+ return { content: [{ type: "text" as const, text }], details: undefined };
85
+ }
86
+
42
87
  const expandSet = new Set(params.expand ?? []);
43
88
  const hasExpand = expandSet.size > 0;
44
89
 
45
- if (hasExpand && !params.query) {
90
+ // ── Pre-load full messages if expand is requested ──
91
+ let expandedFullEntries: RenderedEntry[] | undefined;
92
+ if (hasExpand) {
46
93
  const { rendered: fullMsgs } = loadAllMessages(sessionFile, true, lineageEntryIds);
47
94
  const requested = [...expandSet];
48
95
  const byIndex = new Map(fullMsgs.map((m) => [m.index, m]));
@@ -50,34 +97,55 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
50
97
  if (invalid.length > 0) {
51
98
  return { content: [{ type: "text" as const, text: `Cannot expand indices outside ${scope === "all" ? "session history" : "active lineage"}: ${invalid.join(", ")}` }], details: undefined };
52
99
  }
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);
100
+ expandedFullEntries = requested.map((i) => byIndex.get(i)).filter((m): m is NonNullable<typeof m> => Boolean(m));
55
101
 
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
- }
102
+ // Expand-only path (no query): return expanded entries immediately
103
+ if (!params.query) {
104
+ let output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(expandedFullEntries);
68
105
 
69
- return { content: [{ type: "text" as const, text: output }], details: undefined };
106
+ // Coupling: look up related OM observations
107
+ const expandedIds = expandedFullEntries.map((e) => e.id).filter(Boolean);
108
+ if (expandedIds.length > 0) {
109
+ try {
110
+ const branchEntries = ctx.sessionManager.getBranch() as Entry[];
111
+ const obs = findObservationsForEntryIds(branchEntries, expandedIds);
112
+ const refs = findReflectionsForEntryIds(branchEntries, expandedIds);
113
+ if (obs.length > 0 || refs.length > 0) {
114
+ output += "\n\n" + formatRelatedObservations(obs, refs);
115
+ }
116
+ } catch { /* branch may not be available */ }
117
+ }
118
+
119
+ return { content: [{ type: "text" as const, text: output }], details: undefined };
120
+ }
121
+ // With query: fall through to search, then merge expanded entries into results
70
122
  }
71
123
 
72
124
  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);
125
+ let allResults: SearchHit[] = params.query?.trim()
126
+ ? searchEntries(msgs, rawMessages, params.query, undefined, mode)
127
+ : msgs.slice(-DEFAULT_RECENT).map((entry, i) => {
128
+ const msgIndex = Math.max(0, msgs.length - DEFAULT_RECENT) + i;
129
+ const msg = rawMessages[msgIndex];
130
+ if (msg) {
131
+ const indicators = getFileIndicators(msg);
132
+ if (indicators.length > 0) {
133
+ return { ...entry, fileMatches: indicators };
134
+ }
135
+ }
136
+ return entry;
137
+ });
138
+
139
+ // Merge expanded entries into full result set BEFORE pagination
140
+ // so pagination counts and positioning stay consistent
141
+ if (expandedFullEntries) {
142
+ allResults = mergeExpandedIntoSearchResults(allResults, expandedFullEntries);
143
+ }
76
144
 
77
145
  if (params.query?.trim()) {
78
146
  const page = Math.max(1, params.page ?? 1);
79
147
  const start = (page - 1) * PAGE_SIZE;
80
- const pageResults = allResults.slice(start, start + PAGE_SIZE);
148
+ const pageResults: SearchHit[] = allResults.slice(start, start + PAGE_SIZE);
81
149
  const totalPages = Math.ceil(allResults.length / PAGE_SIZE);
82
150
  const scopeSuffix = scope === "all" ? " (scope: all)" : "";
83
151
  const header = totalPages > 1
@@ -86,6 +154,7 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
86
154
  const footer = page < totalPages
87
155
  ? `\n--- Use page:${page + 1}${scope === "all" ? " with scope:'all'" : ""} for more results ---`
88
156
  : "";
157
+
89
158
  let output = formatRecallOutput(pageResults, params.query, header) + footer;
90
159
 
91
160
  // Coupling: augment search results with related observations
@@ -104,6 +173,7 @@ async function vccRecall(params: { query?: string; expand?: number[]; page?: num
104
173
  return { content: [{ type: "text" as const, text: output }], details: undefined };
105
174
  }
106
175
 
176
+ // No query: show recent entries (expand already merged above)
107
177
  const output = (scope === "all" ? "Scope: all\n\n" : "") + formatRecallOutput(allResults, params.query);
108
178
  return { content: [{ type: "text" as const, text: output }], details: undefined };
109
179
  }
@@ -160,40 +230,46 @@ export function registerRecallTool(pi: ExtensionAPI): void {
160
230
  name: "recall",
161
231
  label: "Recall",
162
232
  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.",
233
+ "Search session history and earlier lines omitted, file write/edit content by text/regex. " +
234
+ "Expand entries (#N), drill-down file content (#N:path) with paging, or aggregate touched files (mode:touched).",
168
235
  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.",
236
+ "recall: Search session history + file write/edit content by text/regex. #N expand, #N:path drill-down with offset/limit paging, mode:file/transcript/touched.",
170
237
  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').",
238
+ "Use recall search is literal text/regex matching, NOT semantic. If no results, try different terms or a regex pattern. Set scope:'all' to search the full session.",
239
+ "Use recall mode:file to search only write/edit file content, mode:transcript for conversation-only, mode:touched for aggregate view of all files written/edited across the session.",
240
+ "Use recall drill-down supports paging: #42:auth.ts shows first 30 lines, #42:auth.ts:30 shows next 30, #42:auth.ts:full shows everything. Note: edit diffs are not indexed for text search — drill-down reads them from raw JSONL.",
241
+ "Use recall when a drill-down path matches multiple files, options are listed. Narrow with a more specific path substring.",
242
+ "Use recall hex observation/reflection ids (12-char hex) link memory evidence to session entries with cross-references for navigation.",
176
243
  ],
177
244
  parameters: Type.Object({
178
245
  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." }),
246
+ Type.String({ description: "Text/regex search; #N expands entry; #N:path drills file (#N:file auto-selects); #N:path:full all lines; #N:path:offset:limit range; 12-char hex for observations. Only full-file writes indexed." }),
180
247
  ),
181
248
  expand: Type.Optional(
182
- Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for (blackhole format only)." }),
249
+ Type.Array(Type.Number(), { description: "Entry indices to return full untruncated content for. Standalone or with query." }),
183
250
  ),
184
251
  page: Type.Optional(
185
- Type.Number({ description: "Page number (1-based) for paginated search results. Default: 1." }),
252
+ Type.Number({ description: "Page number (1-based) for paginated results. Default: 1." }),
186
253
  ),
187
254
  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." }),
255
+ StringEnum(["lineage", "all"] as const, { description: "Search scope. lineage = active lineage (default), all = entire session." }),
256
+ ),
257
+ mode: Type.Optional(
258
+ StringEnum(["hybrid", "file", "transcript", "touched"] as const, { description: "What content to search. hybrid (default) = transcript + file indicators. file = write/edit file content only. transcript = conversation only. touched = aggregate files grouped by path (not per-entry search)." }),
192
259
  ),
193
260
  }),
194
261
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
195
262
  const q = params.query?.trim();
196
263
  // Dispatch by format
264
+ if (q && parseDrillDown(q)) {
265
+ const parsed = parseDrillDown(q)!;
266
+ const sessionFile = ctx.sessionManager.getSessionFile();
267
+ if (!sessionFile) {
268
+ return { content: [{ type: "text" as const, text: "No session file available." }], details: undefined };
269
+ }
270
+ const text = expandEntryFile(sessionFile, parsed.index, parsed.pathPattern, parsed.full, parsed.offset, parsed.limit);
271
+ return { content: [{ type: "text" as const, text }], details: undefined };
272
+ }
197
273
  if (q && VCC_ENTRY_PATTERN.test(q)) {
198
274
  // #N → expand entry indices
199
275
  const match = q.match(VCC_ENTRY_PATTERN);
package/vitest.config.ts CHANGED
@@ -1,18 +1,5 @@
1
1
  import { defineConfig } from "vitest/config";
2
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
3
  export default defineConfig({
17
4
  test: {
18
5
  globals: true,
@@ -22,12 +9,7 @@ export default defineConfig({
22
9
  },
23
10
  resolve: {
24
11
  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
12
+ // Resolve .js → extension-less for our TypeScript source files
31
13
  {
32
14
  find: /\.js$/,
33
15
  replacement: "",
@@ -35,7 +17,3 @@ export default defineConfig({
35
17
  ],
36
18
  },
37
19
  });
38
-
39
- function escapeRegex(s: string): string {
40
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
- }