pi-blackhole 0.3.4 → 0.3.7

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 (43) hide show
  1. package/README.md +13 -16
  2. package/example-config.json +19 -27
  3. package/package.json +1 -1
  4. package/src/commands/memory.ts +1 -1
  5. package/src/commands/pi-vcc.ts +3 -9
  6. package/src/commands/vcc-recall.ts +16 -3
  7. package/src/core/brief.ts +18 -13
  8. package/src/core/build-sections.ts +1 -2
  9. package/src/core/content.ts +8 -5
  10. package/src/core/drill-down.ts +2 -9
  11. package/src/core/filter-noise.ts +7 -10
  12. package/src/core/format.ts +25 -25
  13. package/src/core/load-messages.ts +71 -2
  14. package/src/core/normalize.ts +10 -4
  15. package/src/core/recall-scope.ts +3 -3
  16. package/src/core/render-entries.ts +10 -2
  17. package/src/core/search-entries.ts +12 -7
  18. package/src/core/settings.ts +3 -34
  19. package/src/core/summarize.ts +33 -13
  20. package/src/core/tool-args.ts +4 -1
  21. package/src/core/unified-config.ts +27 -25
  22. package/src/extract/commits.ts +3 -2
  23. package/src/extract/files.ts +6 -4
  24. package/src/hooks/before-compact.ts +7 -3
  25. package/src/om/agents/dropper/agent.ts +2 -7
  26. package/src/om/agents/observer/agent.ts +11 -13
  27. package/src/om/agents/reflector/agent.ts +2 -7
  28. package/src/om/compaction-trigger.ts +54 -18
  29. package/src/om/configure-overlay.ts +5 -1
  30. package/src/om/consolidation.ts +35 -13
  31. package/src/om/cooldown.ts +15 -12
  32. package/src/om/debug-log.ts +79 -11
  33. package/src/om/key-matcher.ts +6 -32
  34. package/src/om/ledger/fold.ts +8 -0
  35. package/src/om/ledger/render-summary.ts +16 -15
  36. package/src/om/ledger/types.ts +1 -1
  37. package/src/om/pending.ts +38 -5
  38. package/src/om/provider-stream.ts +17 -0
  39. package/src/om/retryable-error.ts +26 -0
  40. package/src/om/reverse-recall.ts +9 -1
  41. package/src/om/runtime.ts +39 -18
  42. package/src/sections.ts +0 -3
  43. package/src/tools/recall.ts +11 -9
@@ -3,6 +3,13 @@ import { clip, textOf } from "./content";
3
3
  import { summarizeToolArgs } from "./tool-args";
4
4
  import { extractPath } from "./tool-args";
5
5
 
6
+ // Mirrors @earendil-works/pi-coding-agent's BashExecutionMessage (not re-exported from index)
7
+ interface LocalBashExec {
8
+ role: "bashExecution";
9
+ command: string;
10
+ output: string;
11
+ }
12
+
6
13
  export interface RenderedEntry {
7
14
  index: number;
8
15
  id: string;
@@ -41,8 +48,9 @@ export const renderMessage = (msg: Message, index: number, id: string, full = fa
41
48
  }
42
49
  // bashExecution has command+output instead of content
43
50
  if ((msg as any).role === "bashExecution") {
44
- const cmd = (msg as any).command ?? "";
45
- const out = (msg as any).output ?? "";
51
+ const bashMsg = msg as unknown as LocalBashExec;
52
+ const cmd = bashMsg.command ?? "";
53
+ const out = bashMsg.output ?? "";
46
54
  const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
47
55
  return { index, id, role: "bash", summary: text };
48
56
  }
@@ -9,6 +9,13 @@ import type { RenderedEntry } from "./render-entries";
9
9
  import { textOf, toolCallArgsText, isContentBearing } from "./content";
10
10
  import type { RecallMode } from "./recall-scope";
11
11
 
12
+ // Mirrors @earendil-works/pi-coding-agent's BashExecutionMessage (not re-exported from index)
13
+ interface LocalBashExec {
14
+ role: "bashExecution";
15
+ command: string;
16
+ output: string;
17
+ }
18
+
12
19
  export interface FileMatch {
13
20
  /** Name of the tool (write, edit, hex_edit) */
14
21
  toolName: string;
@@ -183,15 +190,13 @@ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | un
183
190
  const fullText = (msg: Message, mode?: RecallMode): string => {
184
191
  if ((msg as any).role === "bashExecution") {
185
192
  if (mode === "file") return ""; // bash is not file content
186
- return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
193
+ const bashMsg = msg as unknown as LocalBashExec;
194
+ return `${bashMsg.command ?? ""} ${bashMsg.output ?? ""}`;
187
195
  }
188
196
  if (mode === "file") {
189
197
  return toolCallArgsText(msg.content);
190
198
  }
191
- if (mode === "transcript") {
192
- return textOf(msg.content);
193
- }
194
- // hybrid (default): both
199
+ // hybrid (default): both transcript text + tool call args
195
200
  const text = textOf(msg.content);
196
201
  const toolArgs = toolCallArgsText(msg.content);
197
202
  return toolArgs ? `${text}\n${toolArgs}` : text;
@@ -322,7 +327,7 @@ export const searchEntries = (
322
327
  const hay = `${e.role} ${text} ${filePart}`;
323
328
  if (regex.test(hay)) {
324
329
  const snip = lineSnippet(text, regex);
325
- const fileMatches = mode === "transcript" ? [] : computeFileMatches(msg, rawQuery);
330
+ const fileMatches = computeFileMatches(msg, rawQuery);
326
331
  const extra = fileMatches.length > 0 ? { fileMatches } : {};
327
332
  hits.push({ ...e, snippet: snip, matchCount: 1, ...extra });
328
333
  }
@@ -358,7 +363,7 @@ export const searchEntries = (
358
363
  const score = bm25Score(hay, terms, ctx);
359
364
  const text = fullTextCache[i];
360
365
  const snip = lineSnippet(text, snipRe);
361
- const fileMatches = mode === "transcript" ? [] : computeFileMatches(messages[i], rawQuery);
366
+ const fileMatches = computeFileMatches(messages[i], rawQuery);
362
367
  const extra = fileMatches.length > 0 ? { fileMatches } : {};
363
368
  scored.push({
364
369
  hit: { ...e, snippet: snip, matchCount: mc, ...extra },
@@ -1,40 +1,9 @@
1
1
  /**
2
- * Pi-vcc settings reads from unified pi-blackhole/pi-blackhole-config.json.
2
+ * Scaffold the pi-blackhole config file on disk.
3
3
  *
4
- * Upstream: https://github.com/sting8k/pi-vcc (src/core/settings.ts)
5
- * Modified by pi-vcc-om: loadSettings wraps loadUnifiedConfig.
4
+ * Only holds scaffoldSettings(); config loading/parsing happens in unified-config.ts.
6
5
  */
7
- import { loadUnifiedConfig, scaffoldConfig } from "./unified-config.js";
8
-
9
- export interface PiVccSettings {
10
- /** @deprecated Use compactionEngine instead. */
11
- overrideDefaultCompaction: boolean;
12
- /** @deprecated Use compaction instead. */
13
- noAutoCompact: boolean;
14
- /** @deprecated Use compaction + memory instead. */
15
- passive: boolean;
16
- /** Write debug snapshots to /tmp/pi-blackhole-debug.json. */
17
- debug: boolean;
18
- /** Unified compaction control: "auto" | "manual" | "off". */
19
- compaction: "auto" | "manual" | "off";
20
- /** Compaction engine: "blackhole" | "pi-default". */
21
- compactionEngine: "blackhole" | "pi-default";
22
- /** Visible tail behavior: "pi-default" | "minimal". */
23
- tailBehavior: "pi-default" | "minimal";
24
- }
25
-
26
- export function loadSettings(): PiVccSettings {
27
- const config = loadUnifiedConfig(process.cwd());
28
- return {
29
- overrideDefaultCompaction: config.overrideDefaultCompaction ?? false,
30
- noAutoCompact: config.noAutoCompact ?? false,
31
- passive: config.passive ?? false,
32
- debug: config.debug,
33
- compaction: config.compaction,
34
- compactionEngine: config.compactionEngine,
35
- tailBehavior: config.tailBehavior,
36
- };
37
- }
6
+ import { scaffoldConfig } from "./unified-config.js";
38
7
 
39
8
  export function scaffoldSettings(): void {
40
9
  scaffoldConfig();
@@ -27,11 +27,19 @@ const sectionOf = (text: string, header: string): string => {
27
27
  const start = text.indexOf(tag);
28
28
  if (start < 0) return "";
29
29
  const after = text.slice(start);
30
- // Find next section header or separator
30
+ // Find next section header (must start at line boundary to avoid matching in content)
31
31
  const nextSection = HEADER_NAMES
32
32
  .filter((h) => h !== header)
33
- .map((h) => after.indexOf(`[${h}]`))
34
- .filter((n) => n > 0);
33
+ .map((h) => {
34
+ // Escape the header name for regex safety
35
+ const escaped = h.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
36
+ const re = new RegExp(`(?:^|\\n)\\[${escaped}\\]`);
37
+ const m = after.match(re);
38
+ if (!m) return -1;
39
+ // m.index points to \n (or 0); advance past it to the [
40
+ return m.index! + (m[0].startsWith("\n") ? 1 : 0);
41
+ })
42
+ .filter((n) => n >= 0);
35
43
  const nextSep = after.indexOf("\n\n---\n\n");
36
44
  const candidates = [...nextSection, ...(nextSep > 0 ? [nextSep] : [])].sort((a, b) => a - b);
37
45
  const end = candidates[0];
@@ -93,6 +101,8 @@ const mergeFileLines = (prev: string, fresh: string): string => {
93
101
 
94
102
  // Dedup: if already in Modified, drop from Created (file existed before)
95
103
  for (const p of merged.Modified) merged.Created.delete(p);
104
+ // Also remove Read entries that also appear in Modified (same file read+edited)
105
+ for (const p of merged.Modified) merged.Read.delete(p);
96
106
 
97
107
  const cap = (set: Set<string>, limit: number) => {
98
108
  const arr = [...set];
@@ -154,9 +164,7 @@ export const compile = (input: CompileInput): string => {
154
164
  prev = prev ? stripOMContent(prev) : undefined;
155
165
  const merged = prev ? mergePrevious(prev, fresh) : fresh;
156
166
  if (!merged) return "";
157
- return wrapLongLines(merged);
158
- // RECALL_NOTE is now part of OM_FOOTER in render-summary.ts, appended
159
- // by the before-compact hook after observations/reflections.
167
+ return wrapLongLines(merged + SEPARATOR + RECALL_NOTE);
160
168
  };
161
169
 
162
170
  const stripRecallNote = (text: string): string => {
@@ -168,19 +176,28 @@ const stripRecallNote = (text: string): string => {
168
176
  };
169
177
 
170
178
  /**
171
- * Strip OM content (## Reflections, ## Observations, and the
172
- * CONTEXT_USAGE_INSTRUCTIONS preamble) from a previous compaction summary.
179
+ * Strip OM content and recall-guidance footers from a previous compaction summary.
180
+ *
181
+ * OM content (## Reflections / ## Observations + instructions) is appended after
182
+ * compile() by the before-compact hook, so it must be stripped from the previous
183
+ * summary to prevent compounding across compactions. The fresh OM projection is
184
+ * re-rendered each time.
173
185
  *
174
- * OM content is appended after compile() by the before-compact hook, so it
175
- * must be stripped from the previous summary to prevent compounding across
176
- * compactions. The fresh OM projection is re-rendered each time.
186
+ * Also strips the basic recall-guidance footer that is always appended when OM
187
+ * is off or has no entries.
177
188
  */
178
189
  const stripOMContent = (text: string): string => {
179
190
  // Remove everything from "## Reflections" or "## Observations" onward,
180
191
  // plus the instructions preamble that precedes them.
181
192
  // The preamble starts with "These are condensed memories from earlier in this session."
182
- const reflIdx = text.indexOf("## Reflections");
183
- const obsIdx = text.indexOf("## Observations");
193
+ // Use line-start anchoring to avoid matching inside conversation content
194
+ const reflMatch = text.match(/^## Reflections/m);
195
+ const reflIdx = reflMatch ? reflMatch.index! : -1;
196
+ const obsMatch = text.match(/^## Observations/m);
197
+ const obsIdx = obsMatch ? obsMatch.index! : -1;
198
+
199
+ // Also detect the basic recall-guidance footer (no observation preamble)
200
+ const basicFooterIdx = text.indexOf("Use `recall` with an id to retrieve original context, or `#N:path` drill-down");
184
201
 
185
202
  // Find the start of OM content: either the instructions preamble or the first section header
186
203
  let stripFrom = -1;
@@ -197,6 +214,9 @@ const stripOMContent = (text: string): string => {
197
214
  } else if (minSectionIdx < Infinity) {
198
215
  stripFrom = minSectionIdx;
199
216
  }
217
+ } else if (basicFooterIdx >= 0) {
218
+ // Strip the basic recall-guidance footer (no observations/reflections present)
219
+ stripFrom = basicFooterIdx;
200
220
  }
201
221
 
202
222
  if (stripFrom < 0) return text;
@@ -1,5 +1,8 @@
1
+ /** Canonical list of path-like argument keys across all Pi tools. */
2
+ export const PATH_KEYS = ["path", "file_path", "filePath", "file"] as const;
3
+
1
4
  export const extractPath = (args: Record<string, unknown>): string | null => {
2
- for (const key of ["path", "file_path", "filePath", "file"]) {
5
+ for (const key of PATH_KEYS) {
3
6
  if (typeof args[key] === "string") return args[key] as string;
4
7
  }
5
8
  return null;
@@ -117,6 +117,10 @@ export interface UnifiedConfig {
117
117
  /** Fallback models for dropper, tried in order after primary model fails. */
118
118
  dropperFallbackModels?: OmModelConfig[];
119
119
 
120
+ /** When false, skip session model fallback when all OM model candidates are exhausted.
121
+ * Default true for backward compatibility. */
122
+ sessionFallback?: boolean;
123
+
120
124
  /** @deprecated Use compaction instead. */
121
125
  noAutoCompact?: boolean;
122
126
  /** @deprecated Use compaction + memory instead. */
@@ -131,6 +135,7 @@ export interface UnifiedConfig {
131
135
 
132
136
  export const DEFAULTS: UnifiedConfig = {
133
137
  debug: false,
138
+ sessionFallback: true,
134
139
 
135
140
  // New config surface
136
141
  compaction: "auto",
@@ -225,15 +230,18 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
225
230
  if (typeof raw.debug === "boolean") c.debug = raw.debug;
226
231
 
227
232
  // Booleans — om
233
+ if (typeof raw.sessionFallback === "boolean") c.sessionFallback = raw.sessionFallback;
228
234
  if (typeof raw.noAutoCompact === "boolean") c.noAutoCompact = raw.noAutoCompact;
229
235
  if (typeof raw.passive === "boolean") c.passive = raw.passive;
230
236
  if (typeof raw.memory === "boolean") c.memory = raw.memory;
231
237
  if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
232
238
 
233
- // Positive integers
239
+ // Numeric fields — use nonNegativeInt for observerPreambleMaxTokens (0 = auto)
234
240
  const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "observationsPoolTargetTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
235
241
  for (const k of numKeys) {
236
- const v = positiveInt(raw[k]);
242
+ // observerPreambleMaxTokens accepts 0 (auto-compute); everything else must be > 0
243
+ const validator = k === "observerPreambleMaxTokens" ? nonNegativeInt : positiveInt;
244
+ const v = validator(raw[k]);
237
245
  if (v !== undefined) (c as Record<string, unknown>)[k] = v;
238
246
  }
239
247
 
@@ -262,8 +270,9 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
262
270
 
263
271
  /**
264
272
  * Migrate legacy config knobs to new unified surface.
265
- * Runs once at load time; old keys are removed from the parsed object.
266
- * Does NOT mutate the on-disk config file.
273
+ * Operates on the in-memory parsed object each time loadUnifiedConfig() is called;
274
+ * old keys are removed from this copy. Does NOT mutate the on-disk config file.
275
+ * Idempotent — safe to call repeatedly.
267
276
  */
268
277
  function migrateOldKnobs(parsed: Record<string, unknown>): void {
269
278
  // Only run if new keys are absent AND old keys are present
@@ -361,13 +370,23 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
361
370
 
362
371
  // Env override — new compaction surface
363
372
  const envCompaction = process.env.PI_BLACKHOLE_COMPACTION;
364
- if (envCompaction !== undefined && isCompaction(envCompaction.trim().toLowerCase())) {
365
- parsed.compaction = envCompaction.trim().toLowerCase() as "auto" | "manual" | "off";
373
+ if (envCompaction !== undefined) {
374
+ const trimmed = envCompaction.trim().toLowerCase();
375
+ if (isCompaction(trimmed)) {
376
+ parsed.compaction = trimmed as "auto" | "manual" | "off";
377
+ } else {
378
+ console.warn(`blackhole: invalid PI_BLACKHOLE_COMPACTION value "${envCompaction}"; ignoring`);
379
+ }
366
380
  }
367
381
 
368
382
  const envCompactionEngine = process.env.PI_BLACKHOLE_COMPACTION_ENGINE;
369
- if (envCompactionEngine !== undefined && isCompactionEngine(envCompactionEngine.trim().toLowerCase())) {
370
- parsed.compactionEngine = envCompactionEngine.trim().toLowerCase() as "blackhole" | "pi-default";
383
+ if (envCompactionEngine !== undefined) {
384
+ const trimmed = envCompactionEngine.trim().toLowerCase();
385
+ if (isCompactionEngine(trimmed)) {
386
+ parsed.compactionEngine = trimmed as "blackhole" | "pi-default";
387
+ } else {
388
+ console.warn(`blackhole: invalid PI_BLACKHOLE_COMPACTION_ENGINE value "${envCompactionEngine}"; ignoring`);
389
+ }
371
390
  }
372
391
 
373
392
  // Merge defaults then override
@@ -445,24 +464,7 @@ export function scaffoldConfig(): void {
445
464
  }
446
465
  }
447
466
 
448
- // ── Toggle helpers ───────────────────────────────────────────────────────────
449
-
450
- /** Cycle compaction: auto → manual → off → auto */
451
- export function toggleCompaction(current: "auto" | "manual" | "off"): "auto" | "manual" | "off" {
452
- const cycle: Array<"auto" | "manual" | "off"> = ["auto", "manual", "off"];
453
- const idx = cycle.indexOf(current);
454
- return cycle[(idx + 1) % cycle.length];
455
- }
456
-
457
- /** Toggle compactionEngine: blackhole ↔ pi-default */
458
- export function toggleCompactionEngine(current: "blackhole" | "pi-default"): "blackhole" | "pi-default" {
459
- return current === "blackhole" ? "pi-default" : "blackhole";
460
- }
461
467
 
462
- /** Toggle tailBehavior: pi-default ↔ minimal */
463
- export function toggleTailBehavior(current: "pi-default" | "minimal"): "pi-default" | "minimal" {
464
- return current === "pi-default" ? "minimal" : "pi-default";
465
- }
466
468
 
467
469
  // ── Migration detection ───────────────────────────────────────────────────────
468
470
 
@@ -6,8 +6,9 @@ interface CommitInfo {
6
6
  }
7
7
 
8
8
  const COMMIT_MSG_RE = /git\s+commit[^\n]*?-m\s+(?:"((?:[^"\\]|\\.)*)"|'((?:[^'\\]|\\.)*)'|\$?'((?:[^'\\]|\\.)*)')/;
9
- // Match short hash from git output: "[branch hash]" or "main hash" or 7-12 hex
10
- const HASH_RE = /\b([0-9a-f]{7,12})\b/;
9
+ // Match short hash from git output only as fallback after bracket/range patterns fail.
10
+ // Requires 8+ hex chars to reduce false positives from random hex in tool output.
11
+ const HASH_RE = /\b([0-9a-f]{8,12})\b/;
11
12
 
12
13
  const firstLineOf = (text: string): string => {
13
14
  const line = text.split(/\\n|\n/)[0] ?? "";
@@ -16,16 +16,18 @@ const FILE_WRITE_TOOLS = new Set([
16
16
  "MultiEdit",
17
17
  ]);
18
18
 
19
- const FILE_CREATE_TOOLS = new Set([
20
- "Write", "write", "write_file",
21
- ]);
19
+ // Pi never exposes a "createdFiles" field — Write operations are tracked as modified.
20
+ // FILE_CREATE_TOOLS kept as empty set for forward-compat if Pi adds a creation signal.
21
+ const FILE_CREATE_TOOLS = new Set<string>();
22
22
 
23
23
  /**
24
24
  * Find the longest common directory prefix among absolute paths.
25
25
  * Returns "" if fewer than 2 absolute paths or no meaningful common prefix.
26
26
  */
27
27
  const longestCommonDirPrefix = (paths: string[]): string => {
28
- const abs = paths.filter((p) => p.startsWith("/"));
28
+ // Normalize backslashes (Windows) to forward slashes for uniform comparison
29
+ const normalized = paths.map((p) => p.replace(/\\/g, "/"));
30
+ const abs = normalized.filter((p) => p.startsWith("/") || /^[A-Za-z]:\//.test(p));
29
31
  if (abs.length < 2) return "";
30
32
  const split = abs.map((p) => p.split("/"));
31
33
  const min = Math.min(...split.map((s) => s.length));
@@ -289,8 +289,10 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
289
289
  }
290
290
 
291
291
  // Determine effective tail behavior for buildOwnCut
292
+ // Both /blackhole and auto-triggered default to "minimal" (aggressive cut);
293
+ // users can opt into "pi-default" (gentler) by setting tailBehavior in config.
292
294
  const effectiveTailBehavior = isPiVcc
293
- ? (omRuntime.config.tailBehavior ?? "minimal") // /blackhole: minimal by default
295
+ ? (omRuntime.config.tailBehavior ?? "minimal")
294
296
  : (omRuntime.config.tailBehavior ?? "minimal");
295
297
 
296
298
  trace("before_compact.tail_behavior", {
@@ -451,7 +453,7 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
451
453
  omRuntime.compactWasPiVcc = isPiVcc;
452
454
 
453
455
  // ── Inject observational-memory content ───────────────────────────
454
- let omContent = "";
456
+ let omContent: string;
455
457
  let omDetails: Record<string, unknown> | undefined;
456
458
  trace("before_compact.om_injection", { memoryEnabled: omRuntime.config.memory !== false });
457
459
  if (omRuntime.config.memory !== false) {
@@ -462,11 +464,13 @@ export const registerBeforeCompactHook = (pi: ExtensionAPI, omRuntime: Runtime)
462
464
  );
463
465
  omContent = renderSummary(projection.reflections, projection.observations);
464
466
  omDetails = projection.details;
467
+ } else {
468
+ omContent = renderSummary([], []);
465
469
  }
466
470
 
467
471
  return {
468
472
  compaction: {
469
- summary: omContent ? summary + "\n\n" + omContent : summary,
473
+ summary: summary + "\n\n" + omContent,
470
474
  details: { ...details, "om.folded": omDetails },
471
475
  tokensBefore: preparation.tokensBefore,
472
476
  firstKeptEntryId,
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
9
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
+ import { createBridgeStreamFn } from "../../provider-stream.js";
10
11
  import { streamSimple } from "@earendil-works/pi-ai";
11
12
  import { Type } from "typebox";
12
13
  import type { Static } from "typebox";
@@ -287,13 +288,7 @@ export async function runDropper(args: RunDropperArgs): Promise<string[] | undef
287
288
 
288
289
  const loop = args.agentLoop ?? agentLoop;
289
290
  // ── Bridge stream function ──
290
- const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
291
- const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
292
- const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
293
- if (!providerStreams) return streamSimple(model, ctx, opts);
294
- const customFn = model?.api ? providerStreams.get(model.api) : undefined;
295
- return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
296
- };
291
+ const bridgeStreamFn = createBridgeStreamFn(streamSimple);
297
292
  const streamFn = args.streamFn ?? bridgeStreamFn;
298
293
  const stream = loop(prompts, context, config, signal, streamFn);
299
294
  let agentError: string | undefined;
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
10
10
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
11
+ import { createBridgeStreamFn } from "../../provider-stream.js";
11
12
  import { streamSimple } from "@earendil-works/pi-ai";
12
13
  import { Type } from "typebox";
13
14
  import type { Static } from "typebox";
@@ -85,13 +86,19 @@ export function normalizeSourceEntryIds(
85
86
  const allowedOrder = new Map<string, number>();
86
87
  for (let i = 0; i < allowedSourceEntryIds.length; i++) allowedOrder.set(allowedSourceEntryIds[i], i);
87
88
 
89
+ // Filter out invalid/unknown IDs instead of rejecting the entire batch.
90
+ // Matches the dropper's normalizeDropObservationIds pattern: one hallucinated
91
+ // ID from the LLM should not discard valid observations.
88
92
  const seen = new Set<string>();
93
+ const valid: string[] = [];
89
94
  for (const id of sourceEntryIds) {
90
- if (!allowedOrder.has(id)) return undefined;
95
+ if (!allowedOrder.has(id)) continue;
96
+ if (seen.has(id)) continue;
91
97
  seen.add(id);
98
+ valid.push(id);
92
99
  }
93
- if (seen.size === 0) return undefined;
94
- return Array.from(seen).sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
100
+ if (valid.length === 0) return undefined;
101
+ return valid.sort((a, b) => (allowedOrder.get(a) ?? 0) - (allowedOrder.get(b) ?? 0));
95
102
  }
96
103
 
97
104
  /** Result returned by runObserver when no observations are recorded. */
@@ -226,16 +233,7 @@ ${conversation}`;
226
233
  // Consolidation agents run via jiti (moduleCache: false) which creates a separate
227
234
  // pi-ai instance whose apiProviderRegistry lacks custom providers registered by
228
235
  // other extensions (e.g., claude-bridge). The bridge looks up streamSimple functions
229
- // from a Symbol.for() global that index.ts populates during provider registration.
230
- // If no custom provider is found, it falls back to the jiti-loaded streamSimple
231
- // which handles all built-in providers correctly.
232
- const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
233
- const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
234
- const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
235
- if (!providerStreams) return streamSimple(model, ctx, opts);
236
- const customFn = model?.api ? providerStreams.get(model.api) : undefined;
237
- return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
238
- };
236
+ const bridgeStreamFn = createBridgeStreamFn(streamSimple);
239
237
  const streamFn = args.streamFn ?? bridgeStreamFn;
240
238
  const stream = loop(prompts, context, config, signal, streamFn);
241
239
  let agentError: string | undefined;
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import { agentLoop, type AgentContext, type AgentLoopConfig, type AgentTool } from "@earendil-works/pi-agent-core";
9
9
  import type { Message, Model, ModelThinkingLevel } from "@earendil-works/pi-ai";
10
+ import { createBridgeStreamFn } from "../../provider-stream.js";
10
11
  import { streamSimple } from "@earendil-works/pi-ai";
11
12
  import { Type } from "typebox";
12
13
  import type { Static } from "typebox";
@@ -150,13 +151,7 @@ export async function runReflector(args: RunReflectorArgs): Promise<Reflection[]
150
151
 
151
152
  const loop = args.agentLoop ?? agentLoop;
152
153
  // ── Bridge stream function ──
153
- const PROVIDER_STREAMS_KEY = Symbol.for("pi-blackhole:provider-streams");
154
- const bridgeStreamFn = (model: any, ctx: any, opts: any) => {
155
- const providerStreams: Map<string, Function> | undefined = (globalThis as any)[PROVIDER_STREAMS_KEY];
156
- if (!providerStreams) return streamSimple(model, ctx, opts);
157
- const customFn = model?.api ? providerStreams.get(model.api) : undefined;
158
- return customFn ? customFn(model, ctx, opts) : streamSimple(model, ctx, opts);
159
- };
154
+ const bridgeStreamFn = createBridgeStreamFn(streamSimple);
160
155
  const streamFn = args.streamFn ?? bridgeStreamFn;
161
156
  const stream = loop(prompts, context, config, signal, streamFn);
162
157
  let agentError: string | undefined;
@@ -2,18 +2,43 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { rawTokensSinceLastCompaction, type Entry } from "./ledger/index.js";
3
3
  import type { Runtime } from "./runtime.js";
4
4
  import { debugLog } from "./debug-log.js";
5
+ import { RETRYABLE_ERROR_RE } from "./retryable-error.js";
6
+
7
+ function getErrorMessage(error: unknown): string {
8
+ if (error instanceof Error) return error.message;
9
+ if (error && typeof error === "object" && "message" in error) {
10
+ return String((error as { message: unknown }).message);
11
+ }
12
+ return String(error);
13
+ }
14
+
15
+ function isStaleExtensionContextError(error: unknown): boolean {
16
+ const message = getErrorMessage(error);
17
+ return message.includes("extension ctx is stale") || message.includes("ctx is stale");
18
+ }
5
19
 
6
- /**
7
- * Regex matching Pi's internal retryable error detection.
8
- * When the last assistant message in agent_end has stopReason "error" matching this pattern,
9
- * Pi will auto-retry — we must not trigger compaction between attempts.
10
- */
11
- const RETRYABLE_ERROR_RE =
12
- /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
20
+ function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" | "warning" | "error"): void {
21
+ if (!hasUI) return;
22
+ try {
23
+ ui?.notify(message, level);
24
+ } catch (error) {
25
+ if (!isStaleExtensionContextError(error)) throw error;
26
+ }
27
+ }
13
28
 
14
29
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
15
30
  pi.on("agent_end", (event: any, ctx: any) => {
16
- runtime.ensureConfig(ctx.cwd);
31
+ try {
32
+ handleAgentEnd(event, ctx, runtime);
33
+ } catch (error) {
34
+ if (isStaleExtensionContextError(error)) return;
35
+ throw error;
36
+ }
37
+ });
38
+ }
39
+
40
+ function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
41
+ runtime.ensureConfig(ctx.cwd);
17
42
 
18
43
  // Pass the config flag explicitly — this handler runs outside ALS context
19
44
  // (agent_end events don't flow through consolidation's withDebugLogContext),
@@ -100,7 +125,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
100
125
 
101
126
  dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
102
127
 
103
- if (hasUI) ui?.notify(
128
+ notifySafely(
129
+ hasUI,
130
+ ui,
104
131
  `Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
105
132
  "info",
106
133
  );
@@ -117,7 +144,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
117
144
  if (currentSessionId !== sessionId) {
118
145
  runtime.compactInFlight = false;
119
146
  dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
120
- if (hasUI) ui?.notify(
147
+ notifySafely(
148
+ hasUI,
149
+ ui,
121
150
  "Observational memory: compaction cancelled — session changed before compaction",
122
151
  "info",
123
152
  );
@@ -129,7 +158,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
129
158
  if (!isIdle) {
130
159
  runtime.compactInFlight = false;
131
160
  dbg("compaction_trigger.microtask.bail", { reason: "not_idle" });
132
- if (hasUI) ui?.notify(
161
+ notifySafely(
162
+ hasUI,
163
+ ui,
133
164
  "Observational memory: compaction deferred — agent became busy before compaction",
134
165
  "info",
135
166
  );
@@ -141,7 +172,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
141
172
  if (currentTokens < runtime.config.compactAfterTokens) {
142
173
  runtime.compactInFlight = false;
143
174
  dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
144
- if (hasUI) ui?.notify(
175
+ notifySafely(
176
+ hasUI,
177
+ ui,
145
178
  "Observational memory: compaction skipped — another compaction already ran before deferred compaction",
146
179
  "info",
147
180
  );
@@ -153,7 +186,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
153
186
  onComplete: (result: any) => {
154
187
  runtime.compactInFlight = false;
155
188
  dbg("compaction_trigger.onComplete", { result: !!result });
156
- if (hasUI) ui?.notify("Observational memory: compaction complete", "info");
189
+ notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
157
190
  },
158
191
  onError: (error: { message: string }) => {
159
192
  runtime.compactInFlight = false;
@@ -162,15 +195,18 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
162
195
  // We already notified the user with the real reason before returning { cancel: true }.
163
196
  return;
164
197
  }
165
- if (hasUI) ui?.notify(`Observational memory: ${error.message}`, "error");
198
+ notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
166
199
  },
167
200
  });
168
201
  } catch (error) {
169
202
  runtime.compactInFlight = false;
170
- const msg = error instanceof Error ? error.message : String(error);
203
+ const msg = getErrorMessage(error);
204
+ if (isStaleExtensionContextError(error)) {
205
+ dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
206
+ return;
207
+ }
171
208
  dbg("compaction_trigger.microtask.error", { message: msg });
172
- if (hasUI) ui?.notify(`Observational memory: compact threw: ${msg}`, "error");
209
+ notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
173
210
  }
174
- }, 0);
175
- });
211
+ }, 0);
176
212
  }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { visibleWidth } from "./key-matcher.js";
10
10
  import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
11
+ import { DEFAULTS } from "../core/unified-config.js";
11
12
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
12
13
  import { dirname } from "node:path";
13
14
 
@@ -49,6 +50,8 @@ const FIELDS: FieldDef[] = [
49
50
  // ── Observational Memory ──
50
51
  { key: "memory", label: "Observational memory", type: "boolean", section: "Observational Memory",
51
52
  helpText: "Enable OM workers (observer, reflector, dropper) and content injection" },
53
+ { key: "sessionFallback", label: "Session model fallback", type: "boolean", section: "Observational Memory",
54
+ helpText: "off=skip stage when all OM models fail, instead of falling back to the main coding model" },
52
55
  { key: "observeAfterTokens", label: "Observer threshold", type: "number", section: "Observational Memory",
53
56
  helpText: "Tokens accumulated since last observer run before triggering next observe" },
54
57
  { key: "reflectAfterTokens", label: "Reflect + dropper threshold", type: "number", section: "Observational Memory",
@@ -139,9 +142,10 @@ export function createConfigureOverlay(
139
142
  raw = {};
140
143
  }
141
144
 
145
+ const defaults = DEFAULTS as unknown as Record<string, unknown>;
142
146
  const fields: FieldState[] = FIELDS.map((def) => ({
143
147
  def,
144
- value: formatValue(def, raw[def.key]),
148
+ value: formatValue(def, def.key in raw ? raw[def.key] : defaults[def.key]),
145
149
  editing: false,
146
150
  cursor: 0,
147
151
  }));