pi-blackhole 0.3.3 → 0.3.5

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 (41) hide show
  1. package/README.md +13 -13
  2. package/example-config.json +19 -27
  3. package/package.json +1 -1
  4. package/src/commands/pi-vcc.ts +3 -9
  5. package/src/core/brief.ts +18 -13
  6. package/src/core/build-sections.ts +1 -2
  7. package/src/core/content.ts +8 -5
  8. package/src/core/drill-down.ts +2 -9
  9. package/src/core/filter-noise.ts +7 -10
  10. package/src/core/format.ts +25 -25
  11. package/src/core/load-messages.ts +71 -2
  12. package/src/core/normalize.ts +10 -4
  13. package/src/core/render-entries.ts +10 -2
  14. package/src/core/search-entries.ts +9 -1
  15. package/src/core/settings.ts +3 -34
  16. package/src/core/summarize.ts +33 -13
  17. package/src/core/tool-args.ts +4 -1
  18. package/src/core/unified-config.ts +33 -26
  19. package/src/extract/commits.ts +3 -2
  20. package/src/extract/files.ts +6 -4
  21. package/src/hooks/before-compact.ts +7 -3
  22. package/src/om/agents/dropper/agent.ts +2 -7
  23. package/src/om/agents/observer/agent.ts +11 -13
  24. package/src/om/agents/reflector/agent.ts +2 -7
  25. package/src/om/compaction-trigger.ts +1 -8
  26. package/src/om/configure-overlay.ts +20 -14
  27. package/src/om/consolidation.ts +57 -18
  28. package/src/om/cooldown.ts +24 -11
  29. package/src/om/debug-log.ts +79 -11
  30. package/src/om/key-matcher.ts +6 -66
  31. package/src/om/ledger/fold.ts +8 -0
  32. package/src/om/ledger/render-summary.ts +16 -15
  33. package/src/om/ledger/types.ts +1 -1
  34. package/src/om/pending.ts +38 -5
  35. package/src/om/provider-stream.ts +17 -0
  36. package/src/om/retryable-error.ts +26 -0
  37. package/src/om/reverse-recall.ts +9 -1
  38. package/src/om/runtime.ts +69 -18
  39. package/src/om/status-overlay.ts +6 -5
  40. package/src/sections.ts +0 -3
  41. package/src/tools/recall.ts +7 -2
@@ -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",
@@ -187,6 +192,11 @@ function positiveInt(v: unknown): number | undefined {
187
192
  return Number.isInteger(v) && typeof v === "number" && v > 0 ? v : undefined;
188
193
  }
189
194
 
195
+ /** Like positiveInt but allows 0. Used for cooldownHours where 0 means "disabled". */
196
+ function nonNegativeInt(v: unknown): number | undefined {
197
+ return Number.isInteger(v) && typeof v === "number" && v >= 0 ? v : undefined;
198
+ }
199
+
190
200
  function parseModel(v: unknown): OmModelConfig | undefined {
191
201
  if (!isRecord(v)) return undefined;
192
202
  const provider = nonEmptyString(v.provider);
@@ -194,7 +204,7 @@ function parseModel(v: unknown): OmModelConfig | undefined {
194
204
  if (!provider || !id) return undefined;
195
205
  const model: OmModelConfig = { provider, id };
196
206
  if (isThinkingLevel(v.thinking)) model.thinking = v.thinking;
197
- const cooldown = positiveInt(v.cooldownHours);
207
+ const cooldown = nonNegativeInt(v.cooldownHours);
198
208
  if (cooldown !== undefined) model.cooldownHours = cooldown;
199
209
  const ctxWindow = positiveInt(v.contextWindow);
200
210
  if (ctxWindow !== undefined) model.contextWindow = ctxWindow;
@@ -220,15 +230,18 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
220
230
  if (typeof raw.debug === "boolean") c.debug = raw.debug;
221
231
 
222
232
  // Booleans — om
233
+ if (typeof raw.sessionFallback === "boolean") c.sessionFallback = raw.sessionFallback;
223
234
  if (typeof raw.noAutoCompact === "boolean") c.noAutoCompact = raw.noAutoCompact;
224
235
  if (typeof raw.passive === "boolean") c.passive = raw.passive;
225
236
  if (typeof raw.memory === "boolean") c.memory = raw.memory;
226
237
  if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
227
238
 
228
- // Positive integers
239
+ // Numeric fields — use nonNegativeInt for observerPreambleMaxTokens (0 = auto)
229
240
  const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "observationsPoolTargetTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
230
241
  for (const k of numKeys) {
231
- 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]);
232
245
  if (v !== undefined) (c as Record<string, unknown>)[k] = v;
233
246
  }
234
247
 
@@ -257,8 +270,9 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
257
270
 
258
271
  /**
259
272
  * Migrate legacy config knobs to new unified surface.
260
- * Runs once at load time; old keys are removed from the parsed object.
261
- * 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.
262
276
  */
263
277
  function migrateOldKnobs(parsed: Record<string, unknown>): void {
264
278
  // Only run if new keys are absent AND old keys are present
@@ -356,13 +370,23 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
356
370
 
357
371
  // Env override — new compaction surface
358
372
  const envCompaction = process.env.PI_BLACKHOLE_COMPACTION;
359
- if (envCompaction !== undefined && isCompaction(envCompaction.trim().toLowerCase())) {
360
- 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
+ }
361
380
  }
362
381
 
363
382
  const envCompactionEngine = process.env.PI_BLACKHOLE_COMPACTION_ENGINE;
364
- if (envCompactionEngine !== undefined && isCompactionEngine(envCompactionEngine.trim().toLowerCase())) {
365
- 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
+ }
366
390
  }
367
391
 
368
392
  // Merge defaults then override
@@ -440,24 +464,7 @@ export function scaffoldConfig(): void {
440
464
  }
441
465
  }
442
466
 
443
- // ── Toggle helpers ───────────────────────────────────────────────────────────
444
467
 
445
- /** Cycle compaction: auto → manual → off → auto */
446
- export function toggleCompaction(current: "auto" | "manual" | "off"): "auto" | "manual" | "off" {
447
- const cycle: Array<"auto" | "manual" | "off"> = ["auto", "manual", "off"];
448
- const idx = cycle.indexOf(current);
449
- return cycle[(idx + 1) % cycle.length];
450
- }
451
-
452
- /** Toggle compactionEngine: blackhole ↔ pi-default */
453
- export function toggleCompactionEngine(current: "blackhole" | "pi-default"): "blackhole" | "pi-default" {
454
- return current === "blackhole" ? "pi-default" : "blackhole";
455
- }
456
-
457
- /** Toggle tailBehavior: pi-default ↔ minimal */
458
- export function toggleTailBehavior(current: "pi-default" | "minimal"): "pi-default" | "minimal" {
459
- return current === "pi-default" ? "minimal" : "pi-default";
460
- }
461
468
 
462
469
  // ── Migration detection ───────────────────────────────────────────────────────
463
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,14 +2,7 @@ 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
-
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;
5
+ import { RETRYABLE_ERROR_RE } from "./retryable-error.js";
13
6
 
14
7
  export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
15
8
  pi.on("agent_end", (event: any, ctx: any) => {
@@ -6,7 +6,9 @@
6
6
  * Navigation: ↑↓ Edit: Enter Save: Ctrl+S Cancel: Esc
7
7
  */
8
8
 
9
- import { matchKey, visibleWidth } from "./key-matcher.js";
9
+ import { visibleWidth } from "./key-matcher.js";
10
+ import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
11
+ import { DEFAULTS } from "../core/unified-config.js";
10
12
  import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
11
13
  import { dirname } from "node:path";
12
14
 
@@ -48,6 +50,8 @@ const FIELDS: FieldDef[] = [
48
50
  // ── Observational Memory ──
49
51
  { key: "memory", label: "Observational memory", type: "boolean", section: "Observational Memory",
50
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" },
51
55
  { key: "observeAfterTokens", label: "Observer threshold", type: "number", section: "Observational Memory",
52
56
  helpText: "Tokens accumulated since last observer run before triggering next observe" },
53
57
  { key: "reflectAfterTokens", label: "Reflect + dropper threshold", type: "number", section: "Observational Memory",
@@ -138,9 +142,10 @@ export function createConfigureOverlay(
138
142
  raw = {};
139
143
  }
140
144
 
145
+ const defaults = DEFAULTS as unknown as Record<string, unknown>;
141
146
  const fields: FieldState[] = FIELDS.map((def) => ({
142
147
  def,
143
- value: formatValue(def, raw[def.key]),
148
+ value: formatValue(def, def.key in raw ? raw[def.key] : defaults[def.key]),
144
149
  editing: false,
145
150
  cursor: 0,
146
151
  }));
@@ -191,18 +196,18 @@ export function createConfigureOverlay(
191
196
 
192
197
  // While editing a number field
193
198
  if (cur.editing) {
194
- if (matchKey(data, "escape")) {
199
+ if (matchesKey(data, "escape")) {
195
200
  cur.value = formatValue(cur.def, raw[cur.def.key]);
196
201
  cur.editing = false;
197
202
  invalidate();
198
203
  return;
199
204
  }
200
- if (matchKey(data, "enter") || matchKey(data, "tab")) {
205
+ if (matchesKey(data, "enter") || matchesKey(data, "tab")) {
201
206
  cur.editing = false;
202
207
  invalidate();
203
208
  return;
204
209
  }
205
- if (matchKey(data, "backspace")) {
210
+ if (matchesKey(data, "backspace")) {
206
211
  if (cur.cursor > 0) {
207
212
  cur.value = cur.value.slice(0, cur.cursor - 1) + cur.value.slice(cur.cursor);
208
213
  cur.cursor--;
@@ -210,18 +215,19 @@ export function createConfigureOverlay(
210
215
  }
211
216
  return;
212
217
  }
213
- if (matchKey(data, "left")) {
218
+ if (matchesKey(data, "left")) {
214
219
  cur.cursor = Math.max(0, cur.cursor - 1);
215
220
  invalidate();
216
221
  return;
217
222
  }
218
- if (matchKey(data, "right")) {
223
+ if (matchesKey(data, "right")) {
219
224
  cur.cursor = Math.min(cur.value.length, cur.cursor + 1);
220
225
  invalidate();
221
226
  return;
222
227
  }
223
- if (data.length === 1 && data >= "0" && data <= "9") {
224
- cur.value = cur.value.slice(0, cur.cursor) + data + cur.value.slice(cur.cursor);
228
+ const digit = decodeKittyPrintable(data) ?? data;
229
+ if (digit.length === 1 && digit >= "0" && digit <= "9") {
230
+ cur.value = cur.value.slice(0, cur.cursor) + digit + cur.value.slice(cur.cursor);
225
231
  cur.cursor++;
226
232
  invalidate();
227
233
  }
@@ -231,20 +237,20 @@ export function createConfigureOverlay(
231
237
  // Not editing — global navigation
232
238
 
233
239
  // Ctrl+S → save and close
234
- if (matchKey(data, "ctrl+s")) {
240
+ if (matchesKey(data, "ctrl+s")) {
235
241
  const saved = save();
236
242
  done({ saved, path: configPath });
237
243
  return;
238
244
  }
239
245
 
240
246
  // Esc → close without saving
241
- if (matchKey(data, "escape")) {
247
+ if (matchesKey(data, "escape")) {
242
248
  done(undefined);
243
249
  return;
244
250
  }
245
251
 
246
252
  // Enter/space → edit/toggle
247
- if (matchKey(data, "enter") || matchKey(data, "space")) {
253
+ if (matchesKey(data, "enter") || matchesKey(data, "space")) {
248
254
  switch (cur.def.type) {
249
255
  case "boolean":
250
256
  cur.value = cur.value === "on" ? "off" : "on";
@@ -268,12 +274,12 @@ export function createConfigureOverlay(
268
274
  }
269
275
 
270
276
  // ↑↓ navigation
271
- if (matchKey(data, "up")) {
277
+ if (matchesKey(data, "up")) {
272
278
  selectedIndex = Math.max(0, selectedIndex - 1);
273
279
  invalidate();
274
280
  return;
275
281
  }
276
- if (matchKey(data, "down")) {
282
+ if (matchesKey(data, "down")) {
277
283
  selectedIndex = Math.min(fields.length - 1, selectedIndex + 1);
278
284
  invalidate();
279
285
  return;