pi-blackhole 0.2.2 → 0.3.0
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 +23 -8
- package/index.ts +45 -0
- package/package.json +12 -5
- package/src/commands/pi-vcc.ts +18 -8
- package/src/commands/vcc-recall.ts +3 -2
- package/src/core/build-sections.ts +1 -1
- package/src/core/content.ts +70 -0
- package/src/core/drill-down.ts +273 -0
- package/src/core/format-recall.ts +107 -3
- package/src/core/load-messages.ts +3 -2
- package/src/core/recall-scope.ts +15 -4
- package/src/core/search-entries.ts +155 -10
- package/src/core/unified-config.ts +48 -17
- package/src/om/agents/dropper/agent.ts +144 -12
- package/src/om/agents/dropper/coverage.ts +128 -0
- package/src/om/agents/dropper/prompts.ts +12 -12
- package/src/om/agents/observer/agent.ts +22 -2
- package/src/om/agents/observer/prompts.ts +1 -1
- package/src/om/agents/reflector/agent.ts +51 -2
- package/src/om/agents/reflector/prompts.ts +8 -4
- package/src/om/consolidation.ts +1 -47
- package/src/om/cooldown.ts +11 -4
- package/src/om/ledger/projection.ts +13 -1
- package/src/om/ledger/render-summary.ts +51 -0
- package/src/om/pending.ts +8 -4
- package/src/tools/recall.ts +120 -44
- package/vitest.config.ts +1 -23
|
@@ -1,4 +1,86 @@
|
|
|
1
|
-
import type { SearchHit } from "./search-entries";
|
|
1
|
+
import type { SearchHit, FileMatch, TouchedFile } from "./search-entries";
|
|
2
|
+
|
|
3
|
+
// ── Path shortening ───────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const CWD = process.cwd();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Shorten an absolute file path for display:
|
|
9
|
+
* - If within cwd, return `./relative/path`
|
|
10
|
+
* - Otherwise, show last 3 path components with `.../` prefix
|
|
11
|
+
* - Short paths (≤3 components) returned as-is
|
|
12
|
+
*/
|
|
13
|
+
export function shortPath(fullPath: string): string {
|
|
14
|
+
// Normalize backslashes to forward slashes for cross-platform path handling
|
|
15
|
+
const normalized = fullPath.replace(/\\/g, "/");
|
|
16
|
+
const cwdNormalized = CWD.replace(/\\/g, "/");
|
|
17
|
+
if (normalized.startsWith(cwdNormalized + "/")) {
|
|
18
|
+
return "." + normalized.slice(cwdNormalized.length);
|
|
19
|
+
}
|
|
20
|
+
const parts = normalized.split("/");
|
|
21
|
+
if (parts.length > 3) {
|
|
22
|
+
return ".../" + parts.slice(-3).join("/");
|
|
23
|
+
}
|
|
24
|
+
return normalized;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// ── File indicator formatting ─────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/** Render one file indicator line with shortened path. */
|
|
30
|
+
function formatFileMatch(fm: FileMatch, index: number, isQuery: boolean): string {
|
|
31
|
+
const label = isQuery
|
|
32
|
+
? (fm.lineCount === 1 ? "match" : "matches")
|
|
33
|
+
: (fm.lineCount === 1 ? "line" : "lines");
|
|
34
|
+
const displayPath = shortPath(fm.path);
|
|
35
|
+
let line = ` [${fm.toolName}] ${displayPath} — ${fm.lineCount} ${label} use #${index}:${fm.path}`;
|
|
36
|
+
if (fm.snippet) {
|
|
37
|
+
line += `\n | ${fm.snippet}`;
|
|
38
|
+
}
|
|
39
|
+
return line;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Touched file output ───────────────────────────────────────────────────
|
|
43
|
+
|
|
44
|
+
const TOUCHED_PAGE_SIZE = 5;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Format aggregated "files touched" output.
|
|
48
|
+
*/
|
|
49
|
+
export function formatTouchedOutput(
|
|
50
|
+
touched: TouchedFile[],
|
|
51
|
+
page?: number,
|
|
52
|
+
pageSize?: number,
|
|
53
|
+
): string {
|
|
54
|
+
if (touched.length === 0) {
|
|
55
|
+
return "No file operations found in session history.";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ps = pageSize ?? TOUCHED_PAGE_SIZE;
|
|
59
|
+
const totalPages = Math.ceil(touched.length / ps);
|
|
60
|
+
const currentPage = Math.max(1, page ?? 1);
|
|
61
|
+
const start = (currentPage - 1) * ps;
|
|
62
|
+
const pageFiles = touched.slice(start, start + ps);
|
|
63
|
+
|
|
64
|
+
const header = totalPages > 1
|
|
65
|
+
? `Page ${currentPage}/${totalPages} (${touched.length} total files)`
|
|
66
|
+
: `${touched.length} files touched`;
|
|
67
|
+
|
|
68
|
+
const lines = pageFiles.map((tf) => {
|
|
69
|
+
const displayPath = shortPath(tf.path);
|
|
70
|
+
const indices = tf.entries
|
|
71
|
+
.map((e) => `#${e.index} (${e.toolName})`)
|
|
72
|
+
.join(", ");
|
|
73
|
+
return ` ${displayPath} ${indices}`;
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
let result = `${header}:\n\n${lines.join("\n")}`;
|
|
77
|
+
|
|
78
|
+
if (currentPage < totalPages) {
|
|
79
|
+
result += `\n\n--- Use page:${currentPage + 1} for more results ---`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return result;
|
|
83
|
+
}
|
|
2
84
|
|
|
3
85
|
export const formatRecallOutput = (
|
|
4
86
|
entries: SearchHit[],
|
|
@@ -18,9 +100,31 @@ export const formatRecallOutput = (
|
|
|
18
100
|
: `Session history (${entries.length} entries):`;
|
|
19
101
|
|
|
20
102
|
const lines = entries.map((e) => {
|
|
21
|
-
const fileSuffix = e.files?.length ? ` files:[${e.files.join(", ")}]` : "";
|
|
22
103
|
const body = query && e.snippet ? e.snippet : e.summary;
|
|
23
|
-
|
|
104
|
+
let line = `#${e.index} [${e.role}]`;
|
|
105
|
+
|
|
106
|
+
// Entry text on next line if it's long or has fileMatches
|
|
107
|
+
if (e.fileMatches?.length) {
|
|
108
|
+
line += `\n ${body}`;
|
|
109
|
+
// Only show top 3 file matches
|
|
110
|
+
const isQuery = Boolean(query);
|
|
111
|
+
const topFileMatches = e.fileMatches.slice(0, 3);
|
|
112
|
+
for (const fm of topFileMatches) {
|
|
113
|
+
line += `\n${formatFileMatch(fm, e.index, isQuery)}`;
|
|
114
|
+
}
|
|
115
|
+
if (e.fileMatches.length > 3) {
|
|
116
|
+
line += `\n ...(${e.fileMatches.length - 3} more file matches)`;
|
|
117
|
+
}
|
|
118
|
+
} else if (e.files?.length) {
|
|
119
|
+
// Fallback: entries without fileMatches (e.g. expand-only path) still have
|
|
120
|
+
// the legacy .files array from renderMessage — show it in old format.
|
|
121
|
+
const fileSuffix = ` files:[${e.files.join(", ")}]`;
|
|
122
|
+
line += `${fileSuffix} ${body}`;
|
|
123
|
+
} else {
|
|
124
|
+
line += ` ${body}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return line;
|
|
24
128
|
});
|
|
25
129
|
|
|
26
130
|
return `${header}\n\n${lines.join("\n\n")}`;
|
|
@@ -34,9 +34,10 @@ export const loadAllMessages = (
|
|
|
34
34
|
|
|
35
35
|
const allowed = !allowedEntryIds || allowedEntryIds.has(e.id);
|
|
36
36
|
if (allowed) {
|
|
37
|
-
|
|
37
|
+
const entryId = e.id != null ? String(e.id) : "";
|
|
38
|
+
rendered.push(renderMessage(e.message, messageIndex, entryId, full));
|
|
38
39
|
rawMessages.push(e.message);
|
|
39
|
-
entryIds.push(
|
|
40
|
+
entryIds.push(entryId);
|
|
40
41
|
}
|
|
41
42
|
messageIndex++;
|
|
42
43
|
}
|
package/src/core/recall-scope.ts
CHANGED
|
@@ -1,14 +1,25 @@
|
|
|
1
1
|
export type RecallScope = "lineage" | "all";
|
|
2
|
+
export type RecallMode = "hybrid" | "file" | "transcript" | "touched";
|
|
2
3
|
|
|
3
4
|
const SCOPE_RE = /\bscope:(lineage|all)\b/i;
|
|
5
|
+
const MODE_RE = /\bmode:(hybrid|file|transcript|touched)\b/i;
|
|
6
|
+
|
|
7
|
+
const VALID_MODES = new Set(["hybrid", "file", "transcript", "touched"]);
|
|
4
8
|
|
|
5
9
|
export const normalizeRecallScope = (scope?: unknown): RecallScope =>
|
|
6
10
|
typeof scope === "string" && scope.toLowerCase() === "all" ? "all" : "lineage";
|
|
7
11
|
|
|
8
|
-
export const
|
|
9
|
-
|
|
12
|
+
export const normalizeRecallMode = (mode?: unknown): RecallMode =>
|
|
13
|
+
typeof mode === "string" && VALID_MODES.has(mode.toLowerCase())
|
|
14
|
+
? (mode.toLowerCase() as RecallMode)
|
|
15
|
+
: "hybrid";
|
|
16
|
+
|
|
17
|
+
export const parseRecallScope = (text: string): { scope: RecallScope; mode: RecallMode; text: string } => {
|
|
18
|
+
const scopeMatch = text.match(SCOPE_RE);
|
|
19
|
+
const modeMatch = text.match(MODE_RE);
|
|
10
20
|
return {
|
|
11
|
-
scope: normalizeRecallScope(
|
|
12
|
-
|
|
21
|
+
scope: normalizeRecallScope(scopeMatch?.[1]),
|
|
22
|
+
mode: normalizeRecallMode(modeMatch?.[1]),
|
|
23
|
+
text: text.replace(SCOPE_RE, "").replace(MODE_RE, "").replace(/\s+/g, " ").trim(),
|
|
13
24
|
};
|
|
14
25
|
};
|
|
@@ -6,13 +6,39 @@
|
|
|
6
6
|
*/
|
|
7
7
|
import type { Message } from "@earendil-works/pi-ai";
|
|
8
8
|
import type { RenderedEntry } from "./render-entries";
|
|
9
|
-
import { textOf } from "./content";
|
|
9
|
+
import { textOf, toolCallArgsText, isContentBearing } from "./content";
|
|
10
|
+
import type { RecallMode } from "./recall-scope";
|
|
11
|
+
|
|
12
|
+
export interface FileMatch {
|
|
13
|
+
/** Name of the tool (write, edit, hex_edit) */
|
|
14
|
+
toolName: string;
|
|
15
|
+
/** File path from the tool call arguments */
|
|
16
|
+
path: string;
|
|
17
|
+
/** Number of lines in the content that matched the query */
|
|
18
|
+
lineCount: number;
|
|
19
|
+
/** First matching line snippet (only populated for top matches) */
|
|
20
|
+
snippet?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** A file touched in one entry — used by mode:touched aggregation. */
|
|
24
|
+
export interface FileTouch {
|
|
25
|
+
index: number;
|
|
26
|
+
toolName: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Aggregated view of a file touched across multiple entries. */
|
|
30
|
+
export interface TouchedFile {
|
|
31
|
+
path: string;
|
|
32
|
+
entries: FileTouch[];
|
|
33
|
+
}
|
|
10
34
|
|
|
11
35
|
export interface SearchHit extends RenderedEntry {
|
|
12
36
|
/** Context snippet around the first matched term (only when query provided) */
|
|
13
37
|
snippet?: string;
|
|
14
38
|
/** Number of query terms matched (for ranking) */
|
|
15
39
|
matchCount?: number;
|
|
40
|
+
/** Per-file match indicators from content-bearing tool calls */
|
|
41
|
+
fileMatches?: FileMatch[];
|
|
16
42
|
}
|
|
17
43
|
|
|
18
44
|
const escapeRegex = (s: string): string =>
|
|
@@ -153,18 +179,131 @@ const lineSnippet = (text: string, regex: RegExp, contextLines = 2): string | un
|
|
|
153
179
|
return parts.join("\n");
|
|
154
180
|
};
|
|
155
181
|
|
|
156
|
-
/** Build full searchable text for a message. */
|
|
157
|
-
const fullText = (msg: Message): string => {
|
|
182
|
+
/** Build full searchable text for a message, optionally filtered by mode. */
|
|
183
|
+
const fullText = (msg: Message, mode?: RecallMode): string => {
|
|
158
184
|
if ((msg as any).role === "bashExecution") {
|
|
185
|
+
if (mode === "file") return ""; // bash is not file content
|
|
159
186
|
return `${(msg as any).command ?? ""} ${(msg as any).output ?? ""}`;
|
|
160
187
|
}
|
|
161
|
-
|
|
188
|
+
if (mode === "file") {
|
|
189
|
+
return toolCallArgsText(msg.content);
|
|
190
|
+
}
|
|
191
|
+
if (mode === "transcript") {
|
|
192
|
+
return textOf(msg.content);
|
|
193
|
+
}
|
|
194
|
+
// hybrid (default): both
|
|
195
|
+
const text = textOf(msg.content);
|
|
196
|
+
const toolArgs = toolCallArgsText(msg.content);
|
|
197
|
+
return toolArgs ? `${text}\n${toolArgs}` : text;
|
|
162
198
|
};
|
|
163
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Extract searchable text from tool call arguments (content, edits, oldText, newText).
|
|
202
|
+
*/
|
|
203
|
+
function extractToolCallText(args: Record<string, unknown>): string {
|
|
204
|
+
let text = "";
|
|
205
|
+
if (typeof args.content === "string") text += args.content + "\n";
|
|
206
|
+
if (Array.isArray(args.edits)) {
|
|
207
|
+
for (const edit of args.edits) {
|
|
208
|
+
if (edit && typeof edit === "object") {
|
|
209
|
+
if (typeof edit.oldText === "string") text += edit.oldText + "\n";
|
|
210
|
+
if (typeof edit.newText === "string") text += edit.newText + "\n";
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
if (typeof args.oldText === "string" && !Array.isArray(args.edits)) text += args.oldText + "\n";
|
|
215
|
+
if (typeof args.newText === "string" && !Array.isArray(args.edits)) text += args.newText + "\n";
|
|
216
|
+
return text;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Compute file indicators from a message (no query — counts total lines per file).
|
|
221
|
+
*/
|
|
222
|
+
export function getFileIndicators(msg: Message): FileMatch[] {
|
|
223
|
+
if (!msg?.content || typeof msg.content === "string") return [];
|
|
224
|
+
const fileMatches: FileMatch[] = [];
|
|
225
|
+
for (const part of msg.content) {
|
|
226
|
+
if (!part || typeof part !== "object" || part.type !== "toolCall") continue;
|
|
227
|
+
const args = part.arguments as Record<string, unknown>;
|
|
228
|
+
if (!isContentBearing(args)) continue;
|
|
229
|
+
|
|
230
|
+
const path = ["path", "filePath", "file_path", "file"]
|
|
231
|
+
.map((k) => args[k])
|
|
232
|
+
.find((v): v is string => typeof v === "string")!;
|
|
233
|
+
|
|
234
|
+
const totalText = extractToolCallText(args);
|
|
235
|
+
const nonEmpty = totalText.split("\n").filter((l) => l.trim().length > 0);
|
|
236
|
+
fileMatches.push({
|
|
237
|
+
toolName: part.name || "",
|
|
238
|
+
path,
|
|
239
|
+
lineCount: nonEmpty.length,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return fileMatches;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function computeFileMatches(msg: Message | undefined, query: string): FileMatch[] {
|
|
246
|
+
if (!msg?.content || typeof msg.content === "string") return [];
|
|
247
|
+
const rawQuery = query.trim();
|
|
248
|
+
const hasQuery = rawQuery.length > 0;
|
|
249
|
+
if (!hasQuery) return getFileIndicators(msg as Message);
|
|
250
|
+
const regex = looksLikeRegex(rawQuery) ? safeRegex(rawQuery) : snippetRegex(rawQuery.split(/\s+/));
|
|
251
|
+
const fileMatches: FileMatch[] = [];
|
|
252
|
+
|
|
253
|
+
for (const part of msg.content) {
|
|
254
|
+
if (!part || typeof part !== "object" || part.type !== "toolCall") continue;
|
|
255
|
+
const args = part.arguments as Record<string, unknown>;
|
|
256
|
+
if (!isContentBearing(args)) continue;
|
|
257
|
+
|
|
258
|
+
// Path is guaranteed by isContentBearing check
|
|
259
|
+
const path = ["path", "filePath", "file_path", "file"]
|
|
260
|
+
.map((k) => args[k])
|
|
261
|
+
.find((v): v is string => typeof v === "string")!;
|
|
262
|
+
|
|
263
|
+
const searchText = extractToolCallText(args);
|
|
264
|
+
if (!searchText) continue;
|
|
265
|
+
|
|
266
|
+
const lines = searchText.split("\n");
|
|
267
|
+
const matchingLines = lines.filter((line) => regex.test(line));
|
|
268
|
+
if (matchingLines.length > 0) {
|
|
269
|
+
fileMatches.push({
|
|
270
|
+
toolName: part.name || "",
|
|
271
|
+
path,
|
|
272
|
+
lineCount: matchingLines.length,
|
|
273
|
+
snippet: matchingLines[0],
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return fileMatches;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** Aggregate file operations across all entries for mode:touched. */
|
|
282
|
+
export function getTouchedFiles(
|
|
283
|
+
messages: Message[],
|
|
284
|
+
rendered: RenderedEntry[],
|
|
285
|
+
): TouchedFile[] {
|
|
286
|
+
const map = new Map<string, TouchedFile>();
|
|
287
|
+
for (let i = 0; i < messages.length; i++) {
|
|
288
|
+
const msg = messages[i];
|
|
289
|
+
const indicators = getFileIndicators(msg);
|
|
290
|
+
for (const fm of indicators) {
|
|
291
|
+
const index = rendered[i]?.index ?? i;
|
|
292
|
+
if (!map.has(fm.path)) {
|
|
293
|
+
map.set(fm.path, { path: fm.path, entries: [] });
|
|
294
|
+
}
|
|
295
|
+
map.get(fm.path)!.entries.push({ index, toolName: fm.toolName });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return Array.from(map.values());
|
|
299
|
+
}
|
|
300
|
+
|
|
164
301
|
export const searchEntries = (
|
|
165
302
|
entries: RenderedEntry[],
|
|
166
303
|
messages: Message[],
|
|
167
304
|
query?: string,
|
|
305
|
+
_page?: number,
|
|
306
|
+
mode?: RecallMode,
|
|
168
307
|
): SearchHit[] => {
|
|
169
308
|
if (!query?.trim()) return entries;
|
|
170
309
|
|
|
@@ -178,12 +317,14 @@ export const searchEntries = (
|
|
|
178
317
|
for (let i = 0; i < entries.length; i++) {
|
|
179
318
|
const e = entries[i];
|
|
180
319
|
const msg = messages[i];
|
|
181
|
-
const text = msg ? fullText(msg) : e.summary;
|
|
320
|
+
const text = msg ? fullText(msg, mode) : e.summary;
|
|
182
321
|
const filePart = e.files?.join(" ") ?? "";
|
|
183
322
|
const hay = `${e.role} ${text} ${filePart}`;
|
|
184
323
|
if (regex.test(hay)) {
|
|
185
324
|
const snip = lineSnippet(text, regex);
|
|
186
|
-
|
|
325
|
+
const fileMatches = mode === "transcript" ? [] : computeFileMatches(msg, rawQuery);
|
|
326
|
+
const extra = fileMatches.length > 0 ? { fileMatches } : {};
|
|
327
|
+
hits.push({ ...e, snippet: snip, matchCount: 1, ...extra });
|
|
187
328
|
}
|
|
188
329
|
}
|
|
189
330
|
return hits;
|
|
@@ -194,12 +335,14 @@ export const searchEntries = (
|
|
|
194
335
|
const terms = filterStopwords(rawTerms);
|
|
195
336
|
const snipRe = snippetRegex(terms);
|
|
196
337
|
|
|
197
|
-
// Build all docs for BM25 context
|
|
338
|
+
// Build all docs for BM25 context (cache fullText to avoid recomputing)
|
|
198
339
|
const docs: string[] = [];
|
|
340
|
+
const fullTextCache: string[] = [];
|
|
199
341
|
for (let i = 0; i < entries.length; i++) {
|
|
200
342
|
const e = entries[i];
|
|
201
343
|
const msg = messages[i];
|
|
202
|
-
const text = msg ? fullText(msg) : e.summary;
|
|
344
|
+
const text = msg ? fullText(msg, mode) : e.summary;
|
|
345
|
+
fullTextCache.push(text);
|
|
203
346
|
const filePart = e.files?.join(" ") ?? "";
|
|
204
347
|
docs.push(`${e.role} ${text} ${filePart}`);
|
|
205
348
|
}
|
|
@@ -213,10 +356,12 @@ export const searchEntries = (
|
|
|
213
356
|
const mc = countMatches(hay, terms);
|
|
214
357
|
if (mc === 0) continue;
|
|
215
358
|
const score = bm25Score(hay, terms, ctx);
|
|
216
|
-
const text =
|
|
359
|
+
const text = fullTextCache[i];
|
|
217
360
|
const snip = lineSnippet(text, snipRe);
|
|
361
|
+
const fileMatches = mode === "transcript" ? [] : computeFileMatches(messages[i], rawQuery);
|
|
362
|
+
const extra = fileMatches.length > 0 ? { fileMatches } : {};
|
|
218
363
|
scored.push({
|
|
219
|
-
hit: { ...e, snippet: snip, matchCount: mc },
|
|
364
|
+
hit: { ...e, snippet: snip, matchCount: mc, ...extra },
|
|
220
365
|
score,
|
|
221
366
|
});
|
|
222
367
|
}
|
|
@@ -44,6 +44,16 @@ export interface UnifiedConfig {
|
|
|
44
44
|
compactAfterTokens: number;
|
|
45
45
|
/** Observation pool token pressure for full fold. */
|
|
46
46
|
observationsPoolMaxTokens: number;
|
|
47
|
+
/** Target token budget for the observation pool (dropper aims here).
|
|
48
|
+
* Optional; defaults to half of observationsPoolMaxTokens when unset.
|
|
49
|
+
* Must be less than observationsPoolMaxTokens.
|
|
50
|
+
*
|
|
51
|
+
* NOTE: Ported from upstream as forward-compat (no-op in our pool algorithm).
|
|
52
|
+
* Upstream renamed budgetTokens→targetTokens (52b5844) and uses this
|
|
53
|
+
* for their tokensOverTarget / avgTokensPerObservation drop calculation.
|
|
54
|
+
* We keep our ratio-based urgency algorithm; this knob exists so future
|
|
55
|
+
* lockstep iterations don't diverge on the config shape. */
|
|
56
|
+
observationsPoolTargetTokens: number;
|
|
47
57
|
/** Max prompt tokens for reflector model input (rolling window cap). */
|
|
48
58
|
reflectorInputMaxTokens: number;
|
|
49
59
|
/** Max prompt tokens for dropper model input (rolling window cap). */
|
|
@@ -96,6 +106,7 @@ export const DEFAULTS: UnifiedConfig = {
|
|
|
96
106
|
reflectAfterTokens: 25_000,
|
|
97
107
|
compactAfterTokens: 81_000,
|
|
98
108
|
observationsPoolMaxTokens: 20_000,
|
|
109
|
+
observationsPoolTargetTokens: 10_000,
|
|
99
110
|
reflectorInputMaxTokens: 80_000,
|
|
100
111
|
dropperInputMaxTokens: 80_000,
|
|
101
112
|
observerChunkMaxTokens: 40_000,
|
|
@@ -160,7 +171,7 @@ function parseConfig(raw: Record<string, unknown>): Partial<UnifiedConfig> {
|
|
|
160
171
|
if (typeof raw.debugLog === "boolean") c.debugLog = raw.debugLog;
|
|
161
172
|
|
|
162
173
|
// Positive integers
|
|
163
|
-
const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
|
|
174
|
+
const numKeys = ["observeAfterTokens", "reflectAfterTokens", "compactAfterTokens", "observationsPoolMaxTokens", "observationsPoolTargetTokens", "reflectorInputMaxTokens", "dropperInputMaxTokens", "observerChunkMaxTokens", "observerPreambleMaxTokens", "agentMaxTurns"] as const;
|
|
164
175
|
for (const k of numKeys) {
|
|
165
176
|
const v = positiveInt(raw[k]);
|
|
166
177
|
if (v !== undefined) (c as Record<string, unknown>)[k] = v;
|
|
@@ -238,7 +249,37 @@ export function loadUnifiedConfig(cwd: string): UnifiedConfig {
|
|
|
238
249
|
else if (["0", "false", "no", "off"].includes(v)) parsed.passive = false;
|
|
239
250
|
}
|
|
240
251
|
|
|
241
|
-
|
|
252
|
+
// Merge defaults then override
|
|
253
|
+
const merged = { ...DEFAULTS, ...parsed };
|
|
254
|
+
|
|
255
|
+
// ── Validate all numeric fields ──
|
|
256
|
+
// Prevents NaN/undefined from leaking into runtime math.
|
|
257
|
+
const NUMERIC_KEYS: ReadonlyArray<keyof UnifiedConfig> = [
|
|
258
|
+
"observeAfterTokens", "reflectAfterTokens", "compactAfterTokens",
|
|
259
|
+
"observationsPoolMaxTokens", "observationsPoolTargetTokens",
|
|
260
|
+
"reflectorInputMaxTokens", "dropperInputMaxTokens",
|
|
261
|
+
"observerChunkMaxTokens", "observerPreambleMaxTokens",
|
|
262
|
+
"agentMaxTurns",
|
|
263
|
+
];
|
|
264
|
+
for (const k of NUMERIC_KEYS) {
|
|
265
|
+
const v = (merged as Record<string, unknown>)[k];
|
|
266
|
+
// observerPreambleMaxTokens=0 means "auto-compute from observerChunkMaxTokens (30%)"
|
|
267
|
+
// so 0 is valid for that field. All other numeric fields must be strictly positive.
|
|
268
|
+
const minVal = k === "observerPreambleMaxTokens" ? 0 : 1;
|
|
269
|
+
if (typeof v !== "number" || !Number.isFinite(v) || v < minVal) {
|
|
270
|
+
(merged as Record<string, unknown>)[k] = DEFAULTS[k];
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Derive observationsPoolTargetTokens if still unset or invalid (must be < max)
|
|
275
|
+
if (
|
|
276
|
+
merged.observationsPoolTargetTokens === undefined ||
|
|
277
|
+
merged.observationsPoolTargetTokens >= merged.observationsPoolMaxTokens
|
|
278
|
+
) {
|
|
279
|
+
merged.observationsPoolTargetTokens = Math.floor(merged.observationsPoolMaxTokens / 2);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return merged;
|
|
242
283
|
}
|
|
243
284
|
|
|
244
285
|
/**
|
|
@@ -260,7 +301,11 @@ export function saveUnifiedConfig(settings: Partial<UnifiedConfig>): boolean {
|
|
|
260
301
|
|
|
261
302
|
/**
|
|
262
303
|
* Ensure ~/.pi/agent/pi-blackhole/pi-blackhole-config.json exists with defaults.
|
|
263
|
-
*
|
|
304
|
+
*
|
|
305
|
+
* Only creates the file if it doesn't exist. Missing keys are filled at read
|
|
306
|
+
* time by loadUnifiedConfig() via { ...DEFAULTS, ...parsed } merge, so there
|
|
307
|
+
* is no need to keep the on-disk file "complete". This avoids a crash on
|
|
308
|
+
* read-only filesystems where the config is managed externally (e.g., Nix).
|
|
264
309
|
*/
|
|
265
310
|
export function scaffoldConfig(): void {
|
|
266
311
|
try {
|
|
@@ -270,21 +315,7 @@ export function scaffoldConfig(): void {
|
|
|
270
315
|
|
|
271
316
|
if (!existsSync(path)) {
|
|
272
317
|
writeFileSync(path, `${JSON.stringify(DEFAULTS, null, 2)}\n`);
|
|
273
|
-
return;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
const parsed = readJson(path);
|
|
277
|
-
if (!parsed || typeof parsed !== "object") return;
|
|
278
|
-
|
|
279
|
-
let changed = false;
|
|
280
|
-
const next: Record<string, unknown> = { ...parsed };
|
|
281
|
-
for (const [key, value] of Object.entries(DEFAULTS)) {
|
|
282
|
-
if (!(key in next)) {
|
|
283
|
-
next[key] = value;
|
|
284
|
-
changed = true;
|
|
285
|
-
}
|
|
286
318
|
}
|
|
287
|
-
if (changed) writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`);
|
|
288
319
|
} catch (e) {
|
|
289
320
|
console.error("blackhole: config scaffold failed", e);
|
|
290
321
|
}
|