pi-blackhole 0.3.4 → 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 (40) 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 +27 -25
  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 +5 -1
  27. package/src/om/consolidation.ts +35 -13
  28. package/src/om/cooldown.ts +15 -12
  29. package/src/om/debug-log.ts +79 -11
  30. package/src/om/key-matcher.ts +6 -32
  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 +39 -18
  39. package/src/sections.ts +0 -3
  40. 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",
@@ -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,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) => {
@@ -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
  }));
@@ -15,7 +15,7 @@ import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
15
15
  import type { ConfiguredModel } from "./config.js";
16
16
  import { debugLog, withDebugLogContext } from "./debug-log.js";
17
17
  import { type ResolveResult, type Runtime } from "./runtime.js";
18
- import { isRetryableError } from "./cooldown.js";
18
+ import { isRetryableError } from "./retryable-error.js";
19
19
  import { effectiveContextWindow } from "./model-budget.js";
20
20
  import { serializeSourceAddressedBranchEntries } from "./serialize.js";
21
21
 
@@ -27,6 +27,7 @@ import {
27
27
  savePendingReflection,
28
28
  savePendingDropped,
29
29
  isObservationChunkPending,
30
+ PendingOMState,
30
31
  } from "./pending.js";
31
32
  import {
32
33
  OM_OBSERVATIONS_DROPPED,
@@ -98,6 +99,8 @@ function capSourceEntriesToTokens(entries: Entry[], maxTokens: number): Entry[]
98
99
  for (let i = entries.length - 1; i >= 0; i--) {
99
100
  const entry = entries[i];
100
101
  let chars = 0;
102
+ // Tokenize all entry types, not just "message": custom_message and
103
+ // branch_summary entries also consume observer context window.
101
104
  if (entry.type === "message" && entry.message) {
102
105
  const msg = entry.message as any;
103
106
  if (typeof msg.content === "string") chars = msg.content.length;
@@ -106,9 +109,25 @@ function capSourceEntriesToTokens(entries: Entry[], maxTokens: number): Entry[]
106
109
  if (block.text) chars += block.text.length;
107
110
  }
108
111
  }
112
+ } else if (entry.type === "custom" && (entry.customType === OM_OBSERVATIONS_RECORDED || entry.customType === OM_REFLECTIONS_RECORDED || entry.customType === OM_OBSERVATIONS_DROPPED)) {
113
+ // Custom entries carry structured data — estimate from JSON serialization
114
+ chars = String(JSON.stringify(entry.data ?? {})).length;
115
+ } else if (entry.summary) {
116
+ chars = String(entry.summary).length;
109
117
  }
110
118
  const estTokens = Math.ceil(chars / 4);
111
119
  if (totalTokens + estTokens > maxTokens && kept.length > 0) break;
120
+ // Remove the `kept.length > 0` guard? No — keep the guard but allow
121
+ // the first entry to be dropped only if it exceeds maxTokens alone.
122
+ // (The guard against empty kept list prevents dropping the first entry
123
+ // when later entries are small; but a single oversized entry should
124
+ // still be included to avoid losing the newest data entirely.)
125
+ if (totalTokens + estTokens > maxTokens && kept.length === 0) {
126
+ // First (newest) entry exceeds maxTokens alone — include it anyway
127
+ // to avoid data loss, but don't add more.
128
+ kept.unshift(entry);
129
+ break;
130
+ }
112
131
  kept.unshift(entry);
113
132
  totalTokens += estTokens;
114
133
  }
@@ -140,7 +159,7 @@ function mergeReflections(existing: Reflection[], additional: Reflection[]): Ref
140
159
  * should still be served as "new" on subsequent runs.
141
160
  */
142
161
  function pendingObservationsCreatedAfter(
143
- pending: any,
162
+ pending: PendingOMState,
144
163
  entries: Entry[],
145
164
  afterCoversUpToId: string | undefined,
146
165
  ): Observation[] {
@@ -315,8 +334,6 @@ async function runObserverStage(
315
334
  // of processing everything including the compaction summary.
316
335
  const effectiveStart = lastCoverageIdx >= 0 ? lastCoverageIdx : findLastCompactionIndex(entries);
317
336
  let chunkEntries = sourceEntriesAfter(entries, effectiveStart);
318
- const coversUpToId = chunkEntries.at(-1)?.id;
319
- if (!coversUpToId) return "continue";
320
337
 
321
338
  // Cap observer input to observerChunkMaxTokens (newest-to-oldest)
322
339
  const maxChunkTokens = runtime.config.observerChunkMaxTokens;
@@ -324,6 +341,10 @@ async function runObserverStage(
324
341
  chunkEntries = capSourceEntriesToTokens(chunkEntries, maxChunkTokens);
325
342
  }
326
343
 
344
+ // coversUpToId must point to the LAST entry AFTER capping, not before
345
+ const coversUpToId = chunkEntries.at(-1)?.id;
346
+ if (!coversUpToId) return "continue";
347
+
327
348
  const { text: chunk, sourceEntryIds } = serializeSourceAddressedBranchEntries(chunkEntries);
328
349
  if (!chunk.trim() || sourceEntryIds.length === 0) return "continue";
329
350
  const chunkTokens = Math.ceil(chunk.length / 4);
@@ -390,8 +411,9 @@ async function runObserverStage(
390
411
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "observer"), stageFallbacks: stageFallbackModels(runtime, "observer") });
391
412
 
392
413
  // Check if estimated input fits in model's context window
414
+ // Use actual chunk tokens (already computed) instead of the configured cap
393
415
  const effectiveObsCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
394
- const observerEstimatedInput = runtime.config.observerChunkMaxTokens + AGENT_LOOP_RESERVE;
416
+ const observerEstimatedInput = chunkTokens + AGENT_LOOP_RESERVE;
395
417
  if (observerEstimatedInput > effectiveObsCtx) {
396
418
  debugLog("observer.context_window_exceeded", { estimatedInput: observerEstimatedInput, effectiveCtx: effectiveObsCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
397
419
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveObsCtx} too small for estimated input ${observerEstimatedInput}`), "observer");
@@ -474,7 +496,7 @@ async function runReflectorStage(
474
496
  ): Promise<ReflectorStageResult> {
475
497
  const sessionId = ctx.sessionManager.getSessionId();
476
498
  const entries = ctx.sessionManager.getBranch() as Entry[];
477
- let reflectionTokens: number;
499
+ let reflectionTokens = 0;
478
500
  let observationCoverageId: string | undefined;
479
501
  if (runtime.config.noAutoCompact) {
480
502
  const pending = readPendingState(sessionId);
@@ -523,8 +545,7 @@ async function runReflectorStage(
523
545
  // Adjust accumulated for pending coverage in noAutoCompact mode
524
546
  let effectiveReflectionTokens = reflectionTokens;
525
547
  if (runtime.config.noAutoCompact) {
526
- const pending = readPendingState(sessionId);
527
- if (pending.reflection?.coversUpToId) {
548
+ if (pending?.reflection?.coversUpToId) {
528
549
  const idx = entryIndexForId(entries, pending.reflection.coversUpToId);
529
550
  if (idx >= 0) effectiveReflectionTokens = rawTokensAfterIndex(entries, idx);
530
551
  }
@@ -535,8 +556,9 @@ async function runReflectorStage(
535
556
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "reflector"), stageFallbacks: stageFallbackModels(runtime, "reflector") });
536
557
 
537
558
  // Check if estimated input fits in model's context window
559
+ // Use actual computed input size (new items + summary budget) instead of cap
538
560
  const effectiveRefCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
539
- const reflectorEstimatedInput = runtime.config.reflectorInputMaxTokens + AGENT_LOOP_RESERVE;
561
+ const reflectorEstimatedInput = reflectorInputTokens + AGENT_LOOP_RESERVE;
540
562
  if (reflectorEstimatedInput > effectiveRefCtx) {
541
563
  debugLog("reflector.context_window_exceeded", { estimatedInput: reflectorEstimatedInput, effectiveCtx: effectiveRefCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
542
564
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveRefCtx} too small for estimated input ${reflectorEstimatedInput}`), "reflector");
@@ -614,7 +636,7 @@ async function runDropperStage(
614
636
  ): Promise<StageOutcome> {
615
637
  const sessionId = ctx.sessionManager.getSessionId();
616
638
  const entries = ctx.sessionManager.getBranch() as Entry[];
617
- let dropTokens: number;
639
+ let dropTokens = 0;
618
640
  let observationCoverageId: string | undefined;
619
641
  if (runtime.config.noAutoCompact) {
620
642
  const pending = readPendingState(sessionId);
@@ -661,8 +683,7 @@ async function runDropperStage(
661
683
  // Adjust accumulated for pending coverage in noAutoCompact mode
662
684
  let effectiveDropTokens = dropTokens;
663
685
  if (runtime.config.noAutoCompact) {
664
- const pending = readPendingState(sessionId);
665
- if (pending.dropped?.coversUpToId) {
686
+ if (pending?.dropped?.coversUpToId) {
666
687
  const idx = entryIndexForId(entries, pending.dropped.coversUpToId);
667
688
  if (idx >= 0) effectiveDropTokens = rawTokensAfterIndex(entries, idx);
668
689
  }
@@ -692,8 +713,9 @@ async function runDropperStage(
692
713
  const stageModelForThinking = runtime.findCandidateConfig(resolved.model, { model: ctx.model, modelRegistry: ctx.modelRegistry, hasUI: ctx.hasUI, ui: ctx.ui, stageModel: stageModelConfig(runtime, "dropper"), stageFallbacks: stageFallbackModels(runtime, "dropper") });
693
714
 
694
715
  // Check if estimated input fits in model's context window
716
+ // Use actual computed input size (new observations + summary budget) instead of cap
695
717
  const effectiveDropCtx = effectiveContextWindow(resolved.model as any, stageModelForThinking);
696
- const dropperEstimatedInput = runtime.config.dropperInputMaxTokens + AGENT_LOOP_RESERVE;
718
+ const dropperEstimatedInput = dropperInputTokens + AGENT_LOOP_RESERVE;
697
719
  if (dropperEstimatedInput > effectiveDropCtx) {
698
720
  debugLog("dropper.context_window_exceeded", { estimatedInput: dropperEstimatedInput, effectiveCtx: effectiveDropCtx, model: `${(resolved.model as any).provider}/${(resolved.model as any).id}` });
699
721
  runtime.recordRetryableError(stageModelForThinking, new Error(`context window ${effectiveDropCtx} too small for estimated input ${dropperEstimatedInput}`), "dropper");