pi-blackhole 0.4.2 → 0.4.3
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 +15 -0
- package/dist/index.js +11660 -0
- package/dist/index.js.map +1 -0
- package/example-config.json +1 -1
- package/index.ts +37 -63
- package/package.json +21 -9
- package/src/commands/cleanup.ts +279 -240
- package/src/commands/memory.ts +236 -184
- package/src/commands/pi-vcc.ts +202 -152
- package/src/commands/vcc-recall.ts +126 -95
- package/src/core/brief.ts +167 -33
- package/src/core/build-sections.ts +8 -2
- package/src/core/config-env.ts +117 -0
- package/src/core/content.ts +31 -7
- package/src/core/drill-down.ts +41 -11
- package/src/core/filter-noise.ts +9 -3
- package/src/core/format-recall.ts +15 -6
- package/src/core/format.ts +14 -4
- package/src/core/lineage.ts +9 -3
- package/src/core/load-messages.ts +24 -5
- package/src/core/normalize.ts +38 -14
- package/src/core/recall-scope.ts +11 -3
- package/src/core/render-entries.ts +22 -6
- package/src/core/sanitize.ts +5 -1
- package/src/core/search-entries.ts +111 -19
- package/src/core/settings.ts +1 -3
- package/src/core/summarize.ts +42 -21
- package/src/core/unified-config.ts +549 -411
- package/src/extract/commits.ts +4 -2
- package/src/extract/files.ts +10 -5
- package/src/extract/goals.ts +7 -2
- package/src/hooks/before-compact.ts +210 -88
- package/src/om/agents/dropper/agent.ts +380 -265
- package/src/om/agents/dropper/coverage.ts +102 -82
- package/src/om/agents/observer/agent.ts +242 -206
- package/src/om/agents/reflector/agent.ts +212 -153
- package/src/om/cleanup.ts +239 -218
- package/src/om/clipboard.ts +59 -51
- package/src/om/compaction-trigger.ts +448 -333
- package/src/om/config.ts +13 -6
- package/src/om/configure-overlay.ts +518 -355
- package/src/om/consolidation.ts +1460 -953
- package/src/om/cooldown.ts +75 -65
- package/src/om/debug-log.ts +86 -68
- package/src/om/ids.ts +1 -1
- package/src/om/ledger/fold.ts +89 -78
- package/src/om/ledger/progress.ts +181 -153
- package/src/om/ledger/projection.ts +248 -185
- package/src/om/ledger/recall.ts +247 -196
- package/src/om/ledger/render-summary.ts +79 -50
- package/src/om/ledger/types.ts +146 -117
- package/src/om/model-budget.ts +23 -13
- package/src/om/pending.ts +243 -179
- package/src/om/provider-stream.ts +52 -7
- package/src/om/retryable-error.ts +12 -16
- package/src/om/reverse-recall.ts +97 -91
- package/src/om/runtime.ts +474 -375
- package/src/om/serialize.ts +190 -166
- package/src/om/status-overlay.ts +246 -195
- package/src/om/tokens.ts +28 -21
- package/src/pi-base/blackhole-settings.ts +437 -0
- package/src/pi-base/config-manager.ts +440 -0
- package/src/pi-base/config.ts +469 -0
- package/src/pi-base/env.ts +43 -0
- package/src/pi-base/paths.ts +47 -0
- package/src/pi-base/settings/body.ts +1648 -0
- package/src/pi-base/settings/fields/action.ts +43 -0
- package/src/pi-base/settings/fields/boolean.ts +47 -0
- package/src/pi-base/settings/fields/custom.ts +72 -0
- package/src/pi-base/settings/fields/enum.ts +310 -0
- package/src/pi-base/settings/fields/index.ts +46 -0
- package/src/pi-base/settings/fields/model.ts +452 -0
- package/src/pi-base/settings/fields/string.ts +527 -0
- package/src/pi-base/settings/fields/text.ts +115 -0
- package/src/pi-base/settings/frame.ts +197 -0
- package/src/pi-base/settings/index.ts +77 -0
- package/src/pi-base/settings/inline-edit.ts +313 -0
- package/src/pi-base/settings/modal.ts +152 -0
- package/src/pi-base/settings/types.ts +500 -0
- package/src/pi-base/settings/validate-field.ts +113 -0
- package/src/pi-base/shell.ts +117 -0
- package/src/pi-base/types.ts +6 -0
- package/src/pi-base/ui.ts +32 -0
- package/src/tools/recall.ts +347 -225
- package/src/types.ts +20 -3
- package/tsup.config.ts +23 -0
- package/vitest.config.ts +15 -15
package/src/core/brief.ts
CHANGED
|
@@ -21,12 +21,18 @@ const isNoiseUser = (text: string): boolean => {
|
|
|
21
21
|
|
|
22
22
|
// Unicode-aware word segmentation via Intl.Segmenter with lazy init & fallback
|
|
23
23
|
let _segmenter: Intl.Segmenter | null | undefined = undefined;
|
|
24
|
-
const wordSegments = (
|
|
24
|
+
const wordSegments = (
|
|
25
|
+
text: string,
|
|
26
|
+
): Array<{ segment: string; index: number; isWordLike?: boolean }> => {
|
|
25
27
|
// Available: fast path
|
|
26
28
|
if (_segmenter) return Array.from(_segmenter.segment(text));
|
|
27
29
|
// Fallback already established: don't retry the constructor
|
|
28
30
|
if (_segmenter === null) {
|
|
29
|
-
const parts: Array<{
|
|
31
|
+
const parts: Array<{
|
|
32
|
+
segment: string;
|
|
33
|
+
index: number;
|
|
34
|
+
isWordLike?: boolean;
|
|
35
|
+
}> = [];
|
|
30
36
|
let idx = 0;
|
|
31
37
|
for (const part of text.split(/(\s+)/)) {
|
|
32
38
|
if (!part) continue;
|
|
@@ -41,7 +47,11 @@ const wordSegments = (text: string): Array<{ segment: string; index: number; isW
|
|
|
41
47
|
return Array.from(_segmenter.segment(text));
|
|
42
48
|
} catch {
|
|
43
49
|
_segmenter = null; // permanently fallback
|
|
44
|
-
const parts: Array<{
|
|
50
|
+
const parts: Array<{
|
|
51
|
+
segment: string;
|
|
52
|
+
index: number;
|
|
53
|
+
isWordLike?: boolean;
|
|
54
|
+
}> = [];
|
|
45
55
|
let idx = 0;
|
|
46
56
|
for (const part of text.split(/(\s+)/)) {
|
|
47
57
|
if (!part) continue;
|
|
@@ -58,19 +68,105 @@ const isWord = (seg: { segment: string; isWordLike?: boolean }): boolean =>
|
|
|
58
68
|
|
|
59
69
|
// Common stop words — don't count toward budget
|
|
60
70
|
const STOP_WORDS = new Set([
|
|
61
|
-
"a",
|
|
62
|
-
"
|
|
63
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
"
|
|
70
|
-
"
|
|
71
|
-
"
|
|
72
|
-
"
|
|
73
|
-
"
|
|
71
|
+
"a",
|
|
72
|
+
"an",
|
|
73
|
+
"the",
|
|
74
|
+
"is",
|
|
75
|
+
"are",
|
|
76
|
+
"was",
|
|
77
|
+
"were",
|
|
78
|
+
"be",
|
|
79
|
+
"been",
|
|
80
|
+
"being",
|
|
81
|
+
"have",
|
|
82
|
+
"has",
|
|
83
|
+
"had",
|
|
84
|
+
"do",
|
|
85
|
+
"does",
|
|
86
|
+
"did",
|
|
87
|
+
"will",
|
|
88
|
+
"would",
|
|
89
|
+
"could",
|
|
90
|
+
"should",
|
|
91
|
+
"may",
|
|
92
|
+
"might",
|
|
93
|
+
"shall",
|
|
94
|
+
"can",
|
|
95
|
+
"need",
|
|
96
|
+
"must",
|
|
97
|
+
"to",
|
|
98
|
+
"of",
|
|
99
|
+
"in",
|
|
100
|
+
"for",
|
|
101
|
+
"on",
|
|
102
|
+
"with",
|
|
103
|
+
"at",
|
|
104
|
+
"by",
|
|
105
|
+
"from",
|
|
106
|
+
"as",
|
|
107
|
+
"into",
|
|
108
|
+
"through",
|
|
109
|
+
"during",
|
|
110
|
+
"before",
|
|
111
|
+
"after",
|
|
112
|
+
"above",
|
|
113
|
+
"below",
|
|
114
|
+
"between",
|
|
115
|
+
"under",
|
|
116
|
+
"over",
|
|
117
|
+
"and",
|
|
118
|
+
"but",
|
|
119
|
+
"or",
|
|
120
|
+
"nor",
|
|
121
|
+
"not",
|
|
122
|
+
"so",
|
|
123
|
+
"yet",
|
|
124
|
+
"both",
|
|
125
|
+
"either",
|
|
126
|
+
"neither",
|
|
127
|
+
"each",
|
|
128
|
+
"every",
|
|
129
|
+
"all",
|
|
130
|
+
"any",
|
|
131
|
+
"few",
|
|
132
|
+
"more",
|
|
133
|
+
"most",
|
|
134
|
+
"other",
|
|
135
|
+
"some",
|
|
136
|
+
"such",
|
|
137
|
+
"no",
|
|
138
|
+
"that",
|
|
139
|
+
"this",
|
|
140
|
+
"these",
|
|
141
|
+
"those",
|
|
142
|
+
"it",
|
|
143
|
+
"its",
|
|
144
|
+
"i",
|
|
145
|
+
"me",
|
|
146
|
+
"my",
|
|
147
|
+
"we",
|
|
148
|
+
"our",
|
|
149
|
+
"you",
|
|
150
|
+
"your",
|
|
151
|
+
"he",
|
|
152
|
+
"him",
|
|
153
|
+
"his",
|
|
154
|
+
"she",
|
|
155
|
+
"her",
|
|
156
|
+
"they",
|
|
157
|
+
"them",
|
|
158
|
+
"their",
|
|
159
|
+
"who",
|
|
160
|
+
"which",
|
|
161
|
+
"what",
|
|
162
|
+
"if",
|
|
163
|
+
"then",
|
|
164
|
+
"than",
|
|
165
|
+
"when",
|
|
166
|
+
"where",
|
|
167
|
+
"how",
|
|
168
|
+
"just",
|
|
169
|
+
"also",
|
|
74
170
|
]);
|
|
75
171
|
|
|
76
172
|
const truncateTokens = (text: string, limit: number): string => {
|
|
@@ -96,12 +192,17 @@ const truncateTokens = (text: string, limit: number): string => {
|
|
|
96
192
|
const BASH_CAP = 120;
|
|
97
193
|
// Only strip pure-formatting pipe tails (head/tail for truncation, sort/wc/column for display).
|
|
98
194
|
// awk/python3/node/bun are data-processing commands — their output carries semantic meaning.
|
|
99
|
-
const PIPE_TAIL_RE =
|
|
195
|
+
const PIPE_TAIL_RE =
|
|
196
|
+
/\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|uniq)(?:\s[^|]*)?$/;
|
|
100
197
|
|
|
101
198
|
/** Semantic compression: strip cd prefix, pipe tail formatting, cap length */
|
|
102
199
|
const compressBash = (raw: string): string => {
|
|
103
200
|
// Flatten multi-line: join lines with semicolons
|
|
104
|
-
let cmd = raw
|
|
201
|
+
let cmd = raw
|
|
202
|
+
.split("\n")
|
|
203
|
+
.map((l) => l.trim())
|
|
204
|
+
.filter(Boolean)
|
|
205
|
+
.join("; ");
|
|
105
206
|
// Strip cd <path> && prefix
|
|
106
207
|
cmd = cmd.replace(/^cd\s+\S+\s*&&\s*/, "");
|
|
107
208
|
// Strip pipe tail formatting commands (up to 10 iterations, stop when stable)
|
|
@@ -122,9 +223,14 @@ const compressBash = (raw: string): string => {
|
|
|
122
223
|
// ── tool summary ──
|
|
123
224
|
|
|
124
225
|
const TOOL_SUMMARY_FIELDS: Record<string, string> = {
|
|
125
|
-
Read: "file_path",
|
|
126
|
-
|
|
127
|
-
|
|
226
|
+
Read: "file_path",
|
|
227
|
+
Edit: "file_path",
|
|
228
|
+
Write: "file_path",
|
|
229
|
+
read: "path",
|
|
230
|
+
edit: "path",
|
|
231
|
+
write: "path",
|
|
232
|
+
Glob: "pattern",
|
|
233
|
+
Grep: "pattern",
|
|
128
234
|
};
|
|
129
235
|
|
|
130
236
|
const toolOneLiner = (name: string, args: Record<string, unknown>): string => {
|
|
@@ -245,14 +351,21 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
245
351
|
if (sec.header !== "[assistant]") continue;
|
|
246
352
|
const out: string[] = [];
|
|
247
353
|
for (const line of sec.lines) {
|
|
248
|
-
if (!line.startsWith("* ")) {
|
|
354
|
+
if (!line.startsWith("* ")) {
|
|
355
|
+
out.push(line);
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
249
358
|
const ref = line.match(/\(#(\d+)\)$/)?.[1] ?? "";
|
|
250
359
|
const base = ref ? line.slice(0, -(ref.length + 3)).trimEnd() : line;
|
|
251
360
|
const last = out.length > 0 ? out[out.length - 1] : "";
|
|
252
361
|
const m = last.match(/^(.*) \((#[\d, #]+)\) x(\d+)$/);
|
|
253
362
|
if (m && m[1] === base) {
|
|
254
|
-
out[out.length - 1] =
|
|
255
|
-
|
|
363
|
+
out[out.length - 1] =
|
|
364
|
+
`${base} (${m[2]}, #${ref}) x${parseInt(m[3]) + 1}`;
|
|
365
|
+
} else if (
|
|
366
|
+
last.match(/\(#\d+\)$/) &&
|
|
367
|
+
last.replace(/\s*\(#\d+\)$/, "") === base
|
|
368
|
+
) {
|
|
256
369
|
const prevRef = last.match(/\(#(\d+)\)$/)?.[1];
|
|
257
370
|
out[out.length - 1] = `${base} (#${prevRef}, #${ref}) x2`;
|
|
258
371
|
} else {
|
|
@@ -304,7 +417,13 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
304
417
|
const prevMatch = prev?.header.match(
|
|
305
418
|
/^\[tool_error\]\s+(\S+?)\s*\(((?:#\d+(?:,\s*)?)+)\)(?:\s*x(\d+))?$/,
|
|
306
419
|
);
|
|
307
|
-
if (
|
|
420
|
+
if (
|
|
421
|
+
prev &&
|
|
422
|
+
prevMatch &&
|
|
423
|
+
prevMatch[1] === tool &&
|
|
424
|
+
prev.lines.length === 1 &&
|
|
425
|
+
prev.lines[0] === body
|
|
426
|
+
) {
|
|
308
427
|
const refs = prevMatch[2] + (ref ? `, #${ref}` : "");
|
|
309
428
|
const count = prevMatch[3] ? parseInt(prevMatch[3]) + 1 : 2;
|
|
310
429
|
prev.header = `[tool_error] ${tool} (${refs}) x${count}`;
|
|
@@ -322,16 +441,19 @@ export const buildBriefSections = (blocks: NormalizedBlock[]): BriefLine[] => {
|
|
|
322
441
|
* Stringify BriefLine sections into text format.
|
|
323
442
|
*/
|
|
324
443
|
export const stringifyBrief = (sections: BriefLine[]): string => {
|
|
325
|
-
|
|
326
444
|
// Emit sections -- suppress blank lines between consecutive tool/error summaries
|
|
327
445
|
const out: string[] = [];
|
|
328
446
|
for (let i = 0; i < sections.length; i++) {
|
|
329
447
|
const sec = sections[i];
|
|
330
448
|
if (i > 0) {
|
|
331
449
|
const prev = sections[i - 1];
|
|
332
|
-
const prevIsToolLike =
|
|
450
|
+
const prevIsToolLike =
|
|
451
|
+
(prev.header === "[assistant]" &&
|
|
452
|
+
prev.lines.every((l) => l.startsWith("* "))) ||
|
|
333
453
|
prev.header.startsWith("[tool_error]");
|
|
334
|
-
const curIsToolLike =
|
|
454
|
+
const curIsToolLike =
|
|
455
|
+
(sec.header === "[assistant]" &&
|
|
456
|
+
sec.lines.every((l) => l.startsWith("* "))) ||
|
|
335
457
|
sec.header.startsWith("[tool_error]");
|
|
336
458
|
if (!(prevIsToolLike && curIsToolLike)) {
|
|
337
459
|
out.push("");
|
|
@@ -347,11 +469,15 @@ export const stringifyBrief = (sections: BriefLine[]): string => {
|
|
|
347
469
|
};
|
|
348
470
|
|
|
349
471
|
/** Parse a text line into a structured TranscriptEntry */
|
|
350
|
-
const parseToolLine = (
|
|
472
|
+
const parseToolLine = (
|
|
473
|
+
line: string,
|
|
474
|
+
): { tool: string; cmd?: string; ref?: string; count?: number } | null => {
|
|
351
475
|
// * bash "cmd" (#5)
|
|
352
476
|
// * bash "cmd" (#1, #3) x2
|
|
353
477
|
// * tilth "query" (#7)
|
|
354
|
-
const m = line.match(
|
|
478
|
+
const m = line.match(
|
|
479
|
+
/^\* (\S+)\s*(?:"([^"]*)")?\s*(?:\((#[\d, #]+)\))?\s*(?:x(\d+))?$/,
|
|
480
|
+
);
|
|
355
481
|
if (!m) return null;
|
|
356
482
|
return {
|
|
357
483
|
tool: m[1],
|
|
@@ -370,7 +496,9 @@ const extractRef = (text: string): { clean: string; ref?: string } => {
|
|
|
370
496
|
/**
|
|
371
497
|
* Convert BriefLine sections to structured TranscriptEntry array for JSON output.
|
|
372
498
|
*/
|
|
373
|
-
export const sectionsToTranscript = (
|
|
499
|
+
export const sectionsToTranscript = (
|
|
500
|
+
sections: BriefLine[],
|
|
501
|
+
): TranscriptEntry[] => {
|
|
374
502
|
const entries: TranscriptEntry[] = [];
|
|
375
503
|
|
|
376
504
|
for (const sec of sections) {
|
|
@@ -394,7 +522,11 @@ export const sectionsToTranscript = (sections: BriefLine[]): TranscriptEntry[] =
|
|
|
394
522
|
} else {
|
|
395
523
|
// Fallback: unparseable tool line
|
|
396
524
|
const { clean, ref } = extractRef(line.slice(2));
|
|
397
|
-
entries.push({
|
|
525
|
+
entries.push({
|
|
526
|
+
role: "assistant",
|
|
527
|
+
text: clean,
|
|
528
|
+
...(ref && { ref }),
|
|
529
|
+
});
|
|
398
530
|
}
|
|
399
531
|
} else {
|
|
400
532
|
const { clean, ref } = extractRef(line);
|
|
@@ -403,7 +535,9 @@ export const sectionsToTranscript = (sections: BriefLine[]): TranscriptEntry[] =
|
|
|
403
535
|
}
|
|
404
536
|
} else if (sec.header.startsWith("[tool_error]")) {
|
|
405
537
|
// [tool_error] bash (#5)
|
|
406
|
-
const headerMatch = sec.header.match(
|
|
538
|
+
const headerMatch = sec.header.match(
|
|
539
|
+
/^\[tool_error\]\s+(\S+)\s*(?:\(#(\d+)\))?/,
|
|
540
|
+
);
|
|
407
541
|
const tool = headerMatch?.[1] ?? "unknown";
|
|
408
542
|
const ref = headerMatch?.[2] ? `#${headerMatch[2]}` : undefined;
|
|
409
543
|
for (const line of sec.lines) {
|
|
@@ -9,7 +9,10 @@ import { clipSentence, firstLine, nonEmptyLines } from "./content";
|
|
|
9
9
|
import type { SectionData } from "../sections";
|
|
10
10
|
import { extractGoals } from "../extract/goals";
|
|
11
11
|
import { extractFiles } from "../extract/files";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
extractPreferences,
|
|
14
|
+
dedupPreferencesAgainstGoals,
|
|
15
|
+
} from "../extract/preferences";
|
|
13
16
|
import { extractCommits, formatCommits } from "../extract/commits";
|
|
14
17
|
import { buildBriefSections, stringifyBrief } from "./brief";
|
|
15
18
|
|
|
@@ -39,7 +42,10 @@ const extractOutstandingContext = (blocks: NormalizedBlock[]): string[] => {
|
|
|
39
42
|
if (/^\s*\(/.test(line)) continue;
|
|
40
43
|
// Require sentence-like start: capital letter, code identifier, or quote
|
|
41
44
|
if (!/^\s*["'`*_]?[A-Z`]/.test(line)) continue;
|
|
42
|
-
const clipped =
|
|
45
|
+
const clipped =
|
|
46
|
+
b.kind === "user"
|
|
47
|
+
? `[user] ${clipSentence(line, 150)}`
|
|
48
|
+
: clipSentence(line, 150);
|
|
43
49
|
if (!items.includes(clipped)) items.push(clipped);
|
|
44
50
|
break;
|
|
45
51
|
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Declarative PI_BLACKHOLE_* environment overrides for the unified config.
|
|
3
|
+
*
|
|
4
|
+
* Single source of truth for the env-var surface, shared by:
|
|
5
|
+
* - ConfigManager (pi-base/config-manager.ts) — modal + om-off/om-on path
|
|
6
|
+
* - loadUnifiedConfig (core/unified-config.ts) — the runtime path
|
|
7
|
+
*
|
|
8
|
+
* This guarantees env overrides behave identically everywhere. (Previously
|
|
9
|
+
* the runtime ignored the declarative map and only applied the passive
|
|
10
|
+
* trio + compaction/compaction-engine vars.)
|
|
11
|
+
*/
|
|
12
|
+
import { readBooleanEnv, readPositiveIntEnv } from "../pi-base/config.js";
|
|
13
|
+
|
|
14
|
+
export interface EnvParser {
|
|
15
|
+
/** Env var name */
|
|
16
|
+
var: string;
|
|
17
|
+
/** Custom parse function (receives raw string, returns parsed value) */
|
|
18
|
+
parse: (raw: string, current: unknown) => unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type EnvOverride = string | EnvParser;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply env overrides onto a config object. Field types are inferred from
|
|
25
|
+
* the defaults (boolean → truthy parsing, positive integer → int parsing,
|
|
26
|
+
* anything else → float). Returns a NEW object; input is not mutated.
|
|
27
|
+
*/
|
|
28
|
+
export function applyEnvOverrides<T extends object>(
|
|
29
|
+
config: T,
|
|
30
|
+
env: Record<string, EnvOverride>,
|
|
31
|
+
defaults: Record<string, unknown>,
|
|
32
|
+
): T {
|
|
33
|
+
const result: Record<string, unknown> = {
|
|
34
|
+
...(config as Record<string, unknown>),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
for (const [key, value] of Object.entries(env)) {
|
|
38
|
+
if (!value) continue;
|
|
39
|
+
|
|
40
|
+
const defaultValue = defaults[key];
|
|
41
|
+
|
|
42
|
+
if (typeof value === "string") {
|
|
43
|
+
if (typeof defaultValue === "boolean") {
|
|
44
|
+
result[key] = readBooleanEnv(
|
|
45
|
+
value,
|
|
46
|
+
(result[key] as boolean) ?? (defaultValue as boolean),
|
|
47
|
+
);
|
|
48
|
+
} else if (typeof defaultValue === "number") {
|
|
49
|
+
if (Number.isInteger(defaultValue) && defaultValue > 0) {
|
|
50
|
+
result[key] = readPositiveIntEnv(
|
|
51
|
+
value,
|
|
52
|
+
(result[key] as number) ?? (defaultValue as number),
|
|
53
|
+
);
|
|
54
|
+
} else {
|
|
55
|
+
// Float — parse and preserve
|
|
56
|
+
const raw = process.env[value]?.trim();
|
|
57
|
+
if (raw) {
|
|
58
|
+
const parsed = Number.parseFloat(raw);
|
|
59
|
+
if (Number.isFinite(parsed)) {
|
|
60
|
+
result[key] = parsed;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
} else {
|
|
66
|
+
const raw = process.env[value.var]?.trim();
|
|
67
|
+
if (raw) {
|
|
68
|
+
const parsed = value.parse(raw, result[key]);
|
|
69
|
+
if (parsed !== undefined) {
|
|
70
|
+
result[key] = parsed;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result as T;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The declarative PI_BLACKHOLE_* env-var map. Keys are UnifiedConfig field
|
|
81
|
+
* names; values are the env var names (or parsers for special types).
|
|
82
|
+
*/
|
|
83
|
+
export const DECLARATIVE_ENV_OVERRIDES: Record<string, EnvOverride> = {
|
|
84
|
+
// Booleans
|
|
85
|
+
memory: "PI_BLACKHOLE_MEMORY",
|
|
86
|
+
debug: "PI_BLACKHOLE_DEBUG",
|
|
87
|
+
debugLog: "PI_BLACKHOLE_DEBUG_LOG",
|
|
88
|
+
sessionFallback: "PI_BLACKHOLE_SESSION_FALLBACK",
|
|
89
|
+
fullFoldAlways: "PI_BLACKHOLE_FULL_FOLD_ALWAYS",
|
|
90
|
+
// Positive integers
|
|
91
|
+
compactAfterTokens: "PI_BLACKHOLE_COMPACT_AFTER_TOKENS",
|
|
92
|
+
observeAfterTokens: "PI_BLACKHOLE_OBSERVE_AFTER_TOKENS",
|
|
93
|
+
reflectAfterTokens: "PI_BLACKHOLE_REFLECT_AFTER_TOKENS",
|
|
94
|
+
observationsPoolMaxTokens: "PI_BLACKHOLE_OBSERVATIONS_POOL_MAX_TOKENS",
|
|
95
|
+
observationsPoolTargetTokens: "PI_BLACKHOLE_OBSERVATIONS_POOL_TARGET_TOKENS",
|
|
96
|
+
reflectorInputMaxTokens: "PI_BLACKHOLE_REFLECTOR_INPUT_MAX_TOKENS",
|
|
97
|
+
dropperInputMaxTokens: "PI_BLACKHOLE_DROPPER_INPUT_MAX_TOKENS",
|
|
98
|
+
observerChunkMaxTokens: "PI_BLACKHOLE_OBSERVER_CHUNK_MAX_TOKENS",
|
|
99
|
+
observerPreambleMaxTokens: "PI_BLACKHOLE_OBSERVER_PREAMBLE_MAX_TOKENS",
|
|
100
|
+
agentMaxTurns: "PI_BLACKHOLE_AGENT_MAX_TURNS",
|
|
101
|
+
// Float in (0, 1]
|
|
102
|
+
dropperPressureThreshold: {
|
|
103
|
+
var: "PI_BLACKHOLE_DROPPER_PRESSURE_THRESHOLD",
|
|
104
|
+
parse: (raw: string) => {
|
|
105
|
+
const n = Number.parseFloat(raw);
|
|
106
|
+
return Number.isFinite(n) && n > 0 && n <= 1 ? n : undefined;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
// Float in (0, 1]
|
|
110
|
+
dropperPoolFullnessThreshold: {
|
|
111
|
+
var: "PI_BLACKHOLE_DROPPER_POOL_FULLNESS_THRESHOLD",
|
|
112
|
+
parse: (raw: string) => {
|
|
113
|
+
const n = Number.parseFloat(raw);
|
|
114
|
+
return Number.isFinite(n) && n > 0 && n <= 1 ? n : undefined;
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
};
|
package/src/core/content.ts
CHANGED
|
@@ -33,7 +33,10 @@ export const clipSentence = (text: string, max = 200): string => {
|
|
|
33
33
|
};
|
|
34
34
|
|
|
35
35
|
export const nonEmptyLines = (text: string): string[] =>
|
|
36
|
-
text
|
|
36
|
+
text
|
|
37
|
+
.split("\n")
|
|
38
|
+
.map((line) => line.trim())
|
|
39
|
+
.filter(Boolean);
|
|
37
40
|
|
|
38
41
|
export const firstLine = (text: string, max = 200): string =>
|
|
39
42
|
clip(text.split("\n")[0] ?? "", max);
|
|
@@ -64,10 +67,25 @@ export const isContentBearing = (args: Record<string, unknown>): boolean => {
|
|
|
64
67
|
// Must have at least one content-bearing field
|
|
65
68
|
if (typeof args.content === "string" && args.content.length > 0) return true;
|
|
66
69
|
// edits must be a non-empty array of objects (each with oldText/newText)
|
|
67
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
Array.isArray(args.edits) &&
|
|
72
|
+
args.edits.length > 0 &&
|
|
73
|
+
args.edits.every((e) => typeof e === "object" && e !== null)
|
|
74
|
+
)
|
|
75
|
+
return true;
|
|
68
76
|
// oldText/newText without edits are content-bearing
|
|
69
|
-
if (
|
|
70
|
-
|
|
77
|
+
if (
|
|
78
|
+
typeof args.oldText === "string" &&
|
|
79
|
+
args.oldText.length > 0 &&
|
|
80
|
+
args.edits === undefined
|
|
81
|
+
)
|
|
82
|
+
return true;
|
|
83
|
+
if (
|
|
84
|
+
typeof args.newText === "string" &&
|
|
85
|
+
args.newText.length > 0 &&
|
|
86
|
+
args.edits === undefined
|
|
87
|
+
)
|
|
88
|
+
return true;
|
|
71
89
|
return false;
|
|
72
90
|
};
|
|
73
91
|
|
|
@@ -98,11 +116,13 @@ export const toolCallArgsText = (
|
|
|
98
116
|
if (extracted.length >= maxBytesPerCall) break;
|
|
99
117
|
if (edit && typeof edit === "object") {
|
|
100
118
|
if (typeof edit.oldText === "string") {
|
|
101
|
-
extracted +=
|
|
119
|
+
extracted +=
|
|
120
|
+
edit.oldText.slice(0, Math.floor(maxBytesPerCall / 2)) + "\n";
|
|
102
121
|
}
|
|
103
122
|
if (extracted.length >= maxBytesPerCall) break;
|
|
104
123
|
if (typeof edit.newText === "string") {
|
|
105
|
-
extracted +=
|
|
124
|
+
extracted +=
|
|
125
|
+
edit.newText.slice(0, Math.floor(maxBytesPerCall / 2)) + "\n";
|
|
106
126
|
}
|
|
107
127
|
}
|
|
108
128
|
}
|
|
@@ -122,7 +142,11 @@ export const toolCallArgsText = (
|
|
|
122
142
|
};
|
|
123
143
|
|
|
124
144
|
/** Extract a snippet of ~`radius` chars around the first match of `term` in `text`. */
|
|
125
|
-
export const snippet = (
|
|
145
|
+
export const snippet = (
|
|
146
|
+
text: string,
|
|
147
|
+
term: string,
|
|
148
|
+
radius = 60,
|
|
149
|
+
): string | null => {
|
|
126
150
|
const idx = text.toLowerCase().indexOf(term.toLowerCase());
|
|
127
151
|
if (idx === -1) return null;
|
|
128
152
|
const start = Math.max(0, idx - radius);
|
package/src/core/drill-down.ts
CHANGED
|
@@ -46,10 +46,15 @@ function findContentBearingCalls(content: unknown[]): ContentBearingCall[] {
|
|
|
46
46
|
const entry: ContentBearingCall = { name: (part as any).name ?? "", path };
|
|
47
47
|
if (typeof args.content === "string") entry.content = args.content;
|
|
48
48
|
if (Array.isArray(args.edits)) {
|
|
49
|
-
entry.edits = args.edits.filter(
|
|
49
|
+
entry.edits = args.edits.filter(
|
|
50
|
+
(e: unknown): e is { oldText?: string; newText?: string } =>
|
|
51
|
+
e !== null && typeof e === "object",
|
|
52
|
+
);
|
|
50
53
|
}
|
|
51
|
-
if (typeof args.oldText === "string" && !Array.isArray(args.edits))
|
|
52
|
-
|
|
54
|
+
if (typeof args.oldText === "string" && !Array.isArray(args.edits))
|
|
55
|
+
entry.oldText = args.oldText;
|
|
56
|
+
if (typeof args.newText === "string" && !Array.isArray(args.edits))
|
|
57
|
+
entry.newText = args.newText;
|
|
53
58
|
results.push(entry);
|
|
54
59
|
}
|
|
55
60
|
return results;
|
|
@@ -71,7 +76,10 @@ function formatToolCallContent(
|
|
|
71
76
|
body = tc.content;
|
|
72
77
|
} else if (tc.edits) {
|
|
73
78
|
body = tc.edits
|
|
74
|
-
.map(
|
|
79
|
+
.map(
|
|
80
|
+
(e, i) =>
|
|
81
|
+
`--- edit ${i + 1} ---\n${e.oldText ?? ""}\n--- becomes ---\n${e.newText ?? ""}`,
|
|
82
|
+
)
|
|
75
83
|
.join("\n\n");
|
|
76
84
|
} else if (tc.oldText && tc.newText) {
|
|
77
85
|
body = `--- old ---\n${tc.oldText}\n--- new ---\n${tc.newText}`;
|
|
@@ -169,9 +177,13 @@ const DRILLDOWN_PATTERN = /^#(\d+):(.+?)(?::(full|\d+(?::\d+)?))?$/;
|
|
|
169
177
|
* :30:20 → offset 30 lines, limit 20 lines
|
|
170
178
|
* (none) → preview first 30 lines
|
|
171
179
|
*/
|
|
172
|
-
export function parseDrillDown(
|
|
173
|
-
|
|
174
|
-
|
|
180
|
+
export function parseDrillDown(query: string): {
|
|
181
|
+
index: number;
|
|
182
|
+
pathPattern: string;
|
|
183
|
+
full: boolean;
|
|
184
|
+
offset?: number;
|
|
185
|
+
limit?: number;
|
|
186
|
+
} | null {
|
|
175
187
|
const match = query.match(DRILLDOWN_PATTERN);
|
|
176
188
|
if (!match) return null;
|
|
177
189
|
const index = parseInt(match[1], 10);
|
|
@@ -179,7 +191,13 @@ export function parseDrillDown(
|
|
|
179
191
|
const suffix = match[3];
|
|
180
192
|
|
|
181
193
|
if (suffix === "full") {
|
|
182
|
-
return {
|
|
194
|
+
return {
|
|
195
|
+
index,
|
|
196
|
+
pathPattern,
|
|
197
|
+
full: true,
|
|
198
|
+
offset: undefined,
|
|
199
|
+
limit: undefined,
|
|
200
|
+
};
|
|
183
201
|
}
|
|
184
202
|
|
|
185
203
|
if (suffix !== undefined) {
|
|
@@ -192,7 +210,13 @@ export function parseDrillDown(
|
|
|
192
210
|
}
|
|
193
211
|
}
|
|
194
212
|
|
|
195
|
-
return {
|
|
213
|
+
return {
|
|
214
|
+
index,
|
|
215
|
+
pathPattern,
|
|
216
|
+
full: false,
|
|
217
|
+
offset: undefined,
|
|
218
|
+
limit: undefined,
|
|
219
|
+
};
|
|
196
220
|
}
|
|
197
221
|
|
|
198
222
|
// ── Main export ───────────────────────────────────────────────────────────
|
|
@@ -238,10 +262,16 @@ export function expandEntryFile(
|
|
|
238
262
|
return `No file content found in entry #${entryIndex}.`;
|
|
239
263
|
}
|
|
240
264
|
if (calls.length === 1) {
|
|
241
|
-
return formatToolCallContent(calls[0], entryIndex, {
|
|
265
|
+
return formatToolCallContent(calls[0], entryIndex, {
|
|
266
|
+
full,
|
|
267
|
+
offset,
|
|
268
|
+
limit,
|
|
269
|
+
});
|
|
242
270
|
}
|
|
243
271
|
// Multiple content-bearing calls — list them
|
|
244
|
-
const items = calls.map(
|
|
272
|
+
const items = calls.map(
|
|
273
|
+
(tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`,
|
|
274
|
+
);
|
|
245
275
|
return `Entry #${entryIndex} has ${calls.length} file operations:\n${items.join("\n")}\n\nUse #${entryIndex}:path to drill into a specific file.`;
|
|
246
276
|
}
|
|
247
277
|
|
package/src/core/filter-noise.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import type { NormalizedBlock } from "../types";
|
|
2
2
|
|
|
3
3
|
const NOISE_TOOLS = new Set([
|
|
4
|
-
"TodoWrite",
|
|
5
|
-
"
|
|
4
|
+
"TodoWrite",
|
|
5
|
+
"TodoRead",
|
|
6
|
+
"ToolSearch",
|
|
7
|
+
"WebSearch",
|
|
8
|
+
"AskUser",
|
|
9
|
+
"ExitSpecMode",
|
|
10
|
+
"GenerateDroid",
|
|
6
11
|
]);
|
|
7
12
|
|
|
8
13
|
const NOISE_STRINGS = [
|
|
@@ -11,7 +16,8 @@ const NOISE_STRINGS = [
|
|
|
11
16
|
"IMPORTANT: TodoWrite was not called yet.",
|
|
12
17
|
];
|
|
13
18
|
|
|
14
|
-
const XML_WRAPPER_RE =
|
|
19
|
+
const XML_WRAPPER_RE =
|
|
20
|
+
/<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
|
|
15
21
|
|
|
16
22
|
/** Return cleaned user text, or null if the block is noise. Cleans once. */
|
|
17
23
|
const cleanOrNull = (text: string): string | null => {
|
|
@@ -27,10 +27,18 @@ export function shortPath(fullPath: string): string {
|
|
|
27
27
|
// ── File indicator formatting ─────────────────────────────────────────────
|
|
28
28
|
|
|
29
29
|
/** Render one file indicator line with shortened path. */
|
|
30
|
-
function formatFileMatch(
|
|
30
|
+
function formatFileMatch(
|
|
31
|
+
fm: FileMatch,
|
|
32
|
+
index: number,
|
|
33
|
+
isQuery: boolean,
|
|
34
|
+
): string {
|
|
31
35
|
const label = isQuery
|
|
32
|
-
?
|
|
33
|
-
|
|
36
|
+
? fm.lineCount === 1
|
|
37
|
+
? "match"
|
|
38
|
+
: "matches"
|
|
39
|
+
: fm.lineCount === 1
|
|
40
|
+
? "line"
|
|
41
|
+
: "lines";
|
|
34
42
|
const displayPath = shortPath(fm.path);
|
|
35
43
|
let line = ` [${fm.toolName}] ${displayPath} — ${fm.lineCount} ${label} use #${index}:${fm.path}`;
|
|
36
44
|
if (fm.snippet) {
|
|
@@ -61,9 +69,10 @@ export function formatTouchedOutput(
|
|
|
61
69
|
const start = (currentPage - 1) * ps;
|
|
62
70
|
const pageFiles = touched.slice(start, start + ps);
|
|
63
71
|
|
|
64
|
-
const header =
|
|
65
|
-
|
|
66
|
-
|
|
72
|
+
const header =
|
|
73
|
+
totalPages > 1
|
|
74
|
+
? `Page ${currentPage}/${totalPages} (${touched.length} total files)`
|
|
75
|
+
: `${touched.length} files touched`;
|
|
67
76
|
|
|
68
77
|
const lines = pageFiles.map((tf) => {
|
|
69
78
|
const displayPath = shortPath(tf.path);
|