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.
- package/README.md +13 -13
- package/example-config.json +19 -27
- package/package.json +1 -1
- package/src/commands/pi-vcc.ts +3 -9
- package/src/core/brief.ts +18 -13
- package/src/core/build-sections.ts +1 -2
- package/src/core/content.ts +8 -5
- package/src/core/drill-down.ts +2 -9
- package/src/core/filter-noise.ts +7 -10
- package/src/core/format.ts +25 -25
- package/src/core/load-messages.ts +71 -2
- package/src/core/normalize.ts +10 -4
- package/src/core/render-entries.ts +10 -2
- package/src/core/search-entries.ts +9 -1
- package/src/core/settings.ts +3 -34
- package/src/core/summarize.ts +33 -13
- package/src/core/tool-args.ts +4 -1
- package/src/core/unified-config.ts +27 -25
- package/src/extract/commits.ts +3 -2
- package/src/extract/files.ts +6 -4
- package/src/hooks/before-compact.ts +7 -3
- package/src/om/agents/dropper/agent.ts +2 -7
- package/src/om/agents/observer/agent.ts +11 -13
- package/src/om/agents/reflector/agent.ts +2 -7
- package/src/om/compaction-trigger.ts +1 -8
- package/src/om/configure-overlay.ts +5 -1
- package/src/om/consolidation.ts +35 -13
- package/src/om/cooldown.ts +15 -12
- package/src/om/debug-log.ts +79 -11
- package/src/om/key-matcher.ts +6 -32
- package/src/om/ledger/fold.ts +8 -0
- package/src/om/ledger/render-summary.ts +16 -15
- package/src/om/ledger/types.ts +1 -1
- package/src/om/pending.ts +38 -5
- package/src/om/provider-stream.ts +17 -0
- package/src/om/retryable-error.ts +26 -0
- package/src/om/reverse-recall.ts +9 -1
- package/src/om/runtime.ts +39 -18
- package/src/sections.ts +0 -3
- package/src/tools/recall.ts +7 -2
package/src/core/settings.ts
CHANGED
|
@@ -1,40 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Scaffold the pi-blackhole config file on disk.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
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 {
|
|
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();
|
package/src/core/summarize.ts
CHANGED
|
@@ -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
|
|
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) =>
|
|
34
|
-
|
|
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
|
|
172
|
-
*
|
|
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
|
-
*
|
|
175
|
-
*
|
|
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
|
-
|
|
183
|
-
const
|
|
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;
|
package/src/core/tool-args.ts
CHANGED
|
@@ -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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
|
365
|
-
|
|
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
|
|
370
|
-
|
|
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
|
|
package/src/extract/commits.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
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] ?? "";
|
package/src/extract/files.ts
CHANGED
|
@@ -16,16 +16,18 @@ const FILE_WRITE_TOOLS = new Set([
|
|
|
16
16
|
"MultiEdit",
|
|
17
17
|
]);
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
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")
|
|
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:
|
|
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
|
|
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))
|
|
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 (
|
|
94
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
}));
|
package/src/om/consolidation.ts
CHANGED
|
@@ -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 "./
|
|
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:
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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");
|