pi-blackhole 0.3.4 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -16
- package/example-config.json +19 -27
- package/package.json +1 -1
- package/src/commands/memory.ts +1 -1
- package/src/commands/pi-vcc.ts +3 -9
- package/src/commands/vcc-recall.ts +16 -3
- 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/recall-scope.ts +3 -3
- package/src/core/render-entries.ts +10 -2
- package/src/core/search-entries.ts +12 -7
- 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 +54 -18
- 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 +11 -9
|
@@ -3,6 +3,13 @@ import { clip, textOf } from "./content";
|
|
|
3
3
|
import { summarizeToolArgs } from "./tool-args";
|
|
4
4
|
import { extractPath } from "./tool-args";
|
|
5
5
|
|
|
6
|
+
// Mirrors @earendil-works/pi-coding-agent's BashExecutionMessage (not re-exported from index)
|
|
7
|
+
interface LocalBashExec {
|
|
8
|
+
role: "bashExecution";
|
|
9
|
+
command: string;
|
|
10
|
+
output: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
export interface RenderedEntry {
|
|
7
14
|
index: number;
|
|
8
15
|
id: string;
|
|
@@ -41,8 +48,9 @@ export const renderMessage = (msg: Message, index: number, id: string, full = fa
|
|
|
41
48
|
}
|
|
42
49
|
// bashExecution has command+output instead of content
|
|
43
50
|
if ((msg as any).role === "bashExecution") {
|
|
44
|
-
const
|
|
45
|
-
const
|
|
51
|
+
const bashMsg = msg as unknown as LocalBashExec;
|
|
52
|
+
const cmd = bashMsg.command ?? "";
|
|
53
|
+
const out = bashMsg.output ?? "";
|
|
46
54
|
const text = full ? `$ ${cmd}\n${out}` : clip(`$ ${cmd}\n${out}`, 300);
|
|
47
55
|
return { index, id, role: "bash", summary: text };
|
|
48
56
|
}
|
|
@@ -9,6 +9,13 @@ import type { RenderedEntry } from "./render-entries";
|
|
|
9
9
|
import { textOf, toolCallArgsText, isContentBearing } from "./content";
|
|
10
10
|
import type { RecallMode } from "./recall-scope";
|
|
11
11
|
|
|
12
|
+
// Mirrors @earendil-works/pi-coding-agent's BashExecutionMessage (not re-exported from index)
|
|
13
|
+
interface LocalBashExec {
|
|
14
|
+
role: "bashExecution";
|
|
15
|
+
command: string;
|
|
16
|
+
output: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
12
19
|
export interface FileMatch {
|
|
13
20
|
/** Name of the tool (write, edit, hex_edit) */
|
|
14
21
|
toolName: string;
|
|
@@ -183,15 +190,13 @@ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | un
|
|
|
183
190
|
const fullText = (msg: Message, mode?: RecallMode): string => {
|
|
184
191
|
if ((msg as any).role === "bashExecution") {
|
|
185
192
|
if (mode === "file") return ""; // bash is not file content
|
|
186
|
-
|
|
193
|
+
const bashMsg = msg as unknown as LocalBashExec;
|
|
194
|
+
return `${bashMsg.command ?? ""} ${bashMsg.output ?? ""}`;
|
|
187
195
|
}
|
|
188
196
|
if (mode === "file") {
|
|
189
197
|
return toolCallArgsText(msg.content);
|
|
190
198
|
}
|
|
191
|
-
|
|
192
|
-
return textOf(msg.content);
|
|
193
|
-
}
|
|
194
|
-
// hybrid (default): both
|
|
199
|
+
// hybrid (default): both transcript text + tool call args
|
|
195
200
|
const text = textOf(msg.content);
|
|
196
201
|
const toolArgs = toolCallArgsText(msg.content);
|
|
197
202
|
return toolArgs ? `${text}\n${toolArgs}` : text;
|
|
@@ -322,7 +327,7 @@ export const searchEntries = (
|
|
|
322
327
|
const hay = `${e.role} ${text} ${filePart}`;
|
|
323
328
|
if (regex.test(hay)) {
|
|
324
329
|
const snip = lineSnippet(text, regex);
|
|
325
|
-
const fileMatches =
|
|
330
|
+
const fileMatches = computeFileMatches(msg, rawQuery);
|
|
326
331
|
const extra = fileMatches.length > 0 ? { fileMatches } : {};
|
|
327
332
|
hits.push({ ...e, snippet: snip, matchCount: 1, ...extra });
|
|
328
333
|
}
|
|
@@ -358,7 +363,7 @@ export const searchEntries = (
|
|
|
358
363
|
const score = bm25Score(hay, terms, ctx);
|
|
359
364
|
const text = fullTextCache[i];
|
|
360
365
|
const snip = lineSnippet(text, snipRe);
|
|
361
|
-
const fileMatches =
|
|
366
|
+
const fileMatches = computeFileMatches(messages[i], rawQuery);
|
|
362
367
|
const extra = fileMatches.length > 0 ? { fileMatches } : {};
|
|
363
368
|
scored.push({
|
|
364
369
|
hit: { ...e, snippet: snip, matchCount: mc, ...extra },
|
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,18 +2,43 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
2
2
|
import { rawTokensSinceLastCompaction, type Entry } from "./ledger/index.js";
|
|
3
3
|
import type { Runtime } from "./runtime.js";
|
|
4
4
|
import { debugLog } from "./debug-log.js";
|
|
5
|
+
import { RETRYABLE_ERROR_RE } from "./retryable-error.js";
|
|
6
|
+
|
|
7
|
+
function getErrorMessage(error: unknown): string {
|
|
8
|
+
if (error instanceof Error) return error.message;
|
|
9
|
+
if (error && typeof error === "object" && "message" in error) {
|
|
10
|
+
return String((error as { message: unknown }).message);
|
|
11
|
+
}
|
|
12
|
+
return String(error);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function isStaleExtensionContextError(error: unknown): boolean {
|
|
16
|
+
const message = getErrorMessage(error);
|
|
17
|
+
return message.includes("extension ctx is stale") || message.includes("ctx is stale");
|
|
18
|
+
}
|
|
5
19
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
function notifySafely(hasUI: boolean, ui: any, message: string, level: "info" | "warning" | "error"): void {
|
|
21
|
+
if (!hasUI) return;
|
|
22
|
+
try {
|
|
23
|
+
ui?.notify(message, level);
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (!isStaleExtensionContextError(error)) throw error;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
13
28
|
|
|
14
29
|
export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): void {
|
|
15
30
|
pi.on("agent_end", (event: any, ctx: any) => {
|
|
16
|
-
|
|
31
|
+
try {
|
|
32
|
+
handleAgentEnd(event, ctx, runtime);
|
|
33
|
+
} catch (error) {
|
|
34
|
+
if (isStaleExtensionContextError(error)) return;
|
|
35
|
+
throw error;
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function handleAgentEnd(event: any, ctx: any, runtime: Runtime): void {
|
|
41
|
+
runtime.ensureConfig(ctx.cwd);
|
|
17
42
|
|
|
18
43
|
// Pass the config flag explicitly — this handler runs outside ALS context
|
|
19
44
|
// (agent_end events don't flow through consolidation's withDebugLogContext),
|
|
@@ -100,7 +125,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
100
125
|
|
|
101
126
|
dbg("compaction_trigger.threshold_reached", { tokens, sessionId, hasUI });
|
|
102
127
|
|
|
103
|
-
|
|
128
|
+
notifySafely(
|
|
129
|
+
hasUI,
|
|
130
|
+
ui,
|
|
104
131
|
`Observational memory: compaction threshold reached (~${tokens.toLocaleString()} tokens); triggering compaction`,
|
|
105
132
|
"info",
|
|
106
133
|
);
|
|
@@ -117,7 +144,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
117
144
|
if (currentSessionId !== sessionId) {
|
|
118
145
|
runtime.compactInFlight = false;
|
|
119
146
|
dbg("compaction_trigger.microtask.bail", { reason: "session_changed" });
|
|
120
|
-
|
|
147
|
+
notifySafely(
|
|
148
|
+
hasUI,
|
|
149
|
+
ui,
|
|
121
150
|
"Observational memory: compaction cancelled — session changed before compaction",
|
|
122
151
|
"info",
|
|
123
152
|
);
|
|
@@ -129,7 +158,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
129
158
|
if (!isIdle) {
|
|
130
159
|
runtime.compactInFlight = false;
|
|
131
160
|
dbg("compaction_trigger.microtask.bail", { reason: "not_idle" });
|
|
132
|
-
|
|
161
|
+
notifySafely(
|
|
162
|
+
hasUI,
|
|
163
|
+
ui,
|
|
133
164
|
"Observational memory: compaction deferred — agent became busy before compaction",
|
|
134
165
|
"info",
|
|
135
166
|
);
|
|
@@ -141,7 +172,9 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
141
172
|
if (currentTokens < runtime.config.compactAfterTokens) {
|
|
142
173
|
runtime.compactInFlight = false;
|
|
143
174
|
dbg("compaction_trigger.microtask.bail", { reason: "pressure_relieved", currentTokens, threshold: runtime.config.compactAfterTokens });
|
|
144
|
-
|
|
175
|
+
notifySafely(
|
|
176
|
+
hasUI,
|
|
177
|
+
ui,
|
|
145
178
|
"Observational memory: compaction skipped — another compaction already ran before deferred compaction",
|
|
146
179
|
"info",
|
|
147
180
|
);
|
|
@@ -153,7 +186,7 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
153
186
|
onComplete: (result: any) => {
|
|
154
187
|
runtime.compactInFlight = false;
|
|
155
188
|
dbg("compaction_trigger.onComplete", { result: !!result });
|
|
156
|
-
|
|
189
|
+
notifySafely(hasUI, ui, "Observational memory: compaction complete", "info");
|
|
157
190
|
},
|
|
158
191
|
onError: (error: { message: string }) => {
|
|
159
192
|
runtime.compactInFlight = false;
|
|
@@ -162,15 +195,18 @@ export function registerCompactionTrigger(pi: ExtensionAPI, runtime: Runtime): v
|
|
|
162
195
|
// We already notified the user with the real reason before returning { cancel: true }.
|
|
163
196
|
return;
|
|
164
197
|
}
|
|
165
|
-
|
|
198
|
+
notifySafely(hasUI, ui, `Observational memory: ${error.message}`, "error");
|
|
166
199
|
},
|
|
167
200
|
});
|
|
168
201
|
} catch (error) {
|
|
169
202
|
runtime.compactInFlight = false;
|
|
170
|
-
const msg =
|
|
203
|
+
const msg = getErrorMessage(error);
|
|
204
|
+
if (isStaleExtensionContextError(error)) {
|
|
205
|
+
dbg("compaction_trigger.microtask.bail", { reason: "stale_ctx", message: msg });
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
171
208
|
dbg("compaction_trigger.microtask.error", { message: msg });
|
|
172
|
-
|
|
209
|
+
notifySafely(hasUI, ui, `Observational memory: compact threw: ${msg}`, "error");
|
|
173
210
|
}
|
|
174
|
-
|
|
175
|
-
});
|
|
211
|
+
}, 0);
|
|
176
212
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { visibleWidth } from "./key-matcher.js";
|
|
10
10
|
import { matchesKey, decodeKittyPrintable } from "@earendil-works/pi-tui";
|
|
11
|
+
import { DEFAULTS } from "../core/unified-config.js";
|
|
11
12
|
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
12
13
|
import { dirname } from "node:path";
|
|
13
14
|
|
|
@@ -49,6 +50,8 @@ const FIELDS: FieldDef[] = [
|
|
|
49
50
|
// ── Observational Memory ──
|
|
50
51
|
{ key: "memory", label: "Observational memory", type: "boolean", section: "Observational Memory",
|
|
51
52
|
helpText: "Enable OM workers (observer, reflector, dropper) and content injection" },
|
|
53
|
+
{ key: "sessionFallback", label: "Session model fallback", type: "boolean", section: "Observational Memory",
|
|
54
|
+
helpText: "off=skip stage when all OM models fail, instead of falling back to the main coding model" },
|
|
52
55
|
{ key: "observeAfterTokens", label: "Observer threshold", type: "number", section: "Observational Memory",
|
|
53
56
|
helpText: "Tokens accumulated since last observer run before triggering next observe" },
|
|
54
57
|
{ key: "reflectAfterTokens", label: "Reflect + dropper threshold", type: "number", section: "Observational Memory",
|
|
@@ -139,9 +142,10 @@ export function createConfigureOverlay(
|
|
|
139
142
|
raw = {};
|
|
140
143
|
}
|
|
141
144
|
|
|
145
|
+
const defaults = DEFAULTS as unknown as Record<string, unknown>;
|
|
142
146
|
const fields: FieldState[] = FIELDS.map((def) => ({
|
|
143
147
|
def,
|
|
144
|
-
value: formatValue(def, raw[def.key]),
|
|
148
|
+
value: formatValue(def, def.key in raw ? raw[def.key] : defaults[def.key]),
|
|
145
149
|
editing: false,
|
|
146
150
|
cursor: 0,
|
|
147
151
|
}));
|