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.
Files changed (87) hide show
  1. package/README.md +15 -0
  2. package/dist/index.js +11660 -0
  3. package/dist/index.js.map +1 -0
  4. package/example-config.json +1 -1
  5. package/index.ts +37 -63
  6. package/package.json +21 -9
  7. package/src/commands/cleanup.ts +279 -240
  8. package/src/commands/memory.ts +236 -184
  9. package/src/commands/pi-vcc.ts +202 -152
  10. package/src/commands/vcc-recall.ts +126 -95
  11. package/src/core/brief.ts +167 -33
  12. package/src/core/build-sections.ts +8 -2
  13. package/src/core/config-env.ts +117 -0
  14. package/src/core/content.ts +31 -7
  15. package/src/core/drill-down.ts +41 -11
  16. package/src/core/filter-noise.ts +9 -3
  17. package/src/core/format-recall.ts +15 -6
  18. package/src/core/format.ts +14 -4
  19. package/src/core/lineage.ts +9 -3
  20. package/src/core/load-messages.ts +24 -5
  21. package/src/core/normalize.ts +38 -14
  22. package/src/core/recall-scope.ts +11 -3
  23. package/src/core/render-entries.ts +22 -6
  24. package/src/core/sanitize.ts +5 -1
  25. package/src/core/search-entries.ts +111 -19
  26. package/src/core/settings.ts +1 -3
  27. package/src/core/summarize.ts +42 -21
  28. package/src/core/unified-config.ts +549 -411
  29. package/src/extract/commits.ts +4 -2
  30. package/src/extract/files.ts +10 -5
  31. package/src/extract/goals.ts +7 -2
  32. package/src/hooks/before-compact.ts +210 -88
  33. package/src/om/agents/dropper/agent.ts +380 -265
  34. package/src/om/agents/dropper/coverage.ts +102 -82
  35. package/src/om/agents/observer/agent.ts +242 -206
  36. package/src/om/agents/reflector/agent.ts +212 -153
  37. package/src/om/cleanup.ts +239 -218
  38. package/src/om/clipboard.ts +59 -51
  39. package/src/om/compaction-trigger.ts +448 -333
  40. package/src/om/config.ts +13 -6
  41. package/src/om/configure-overlay.ts +518 -355
  42. package/src/om/consolidation.ts +1460 -953
  43. package/src/om/cooldown.ts +75 -65
  44. package/src/om/debug-log.ts +86 -68
  45. package/src/om/ids.ts +1 -1
  46. package/src/om/ledger/fold.ts +89 -78
  47. package/src/om/ledger/progress.ts +181 -153
  48. package/src/om/ledger/projection.ts +248 -185
  49. package/src/om/ledger/recall.ts +247 -196
  50. package/src/om/ledger/render-summary.ts +79 -50
  51. package/src/om/ledger/types.ts +146 -117
  52. package/src/om/model-budget.ts +23 -13
  53. package/src/om/pending.ts +243 -179
  54. package/src/om/provider-stream.ts +52 -7
  55. package/src/om/retryable-error.ts +12 -16
  56. package/src/om/reverse-recall.ts +97 -91
  57. package/src/om/runtime.ts +474 -375
  58. package/src/om/serialize.ts +190 -166
  59. package/src/om/status-overlay.ts +246 -195
  60. package/src/om/tokens.ts +28 -21
  61. package/src/pi-base/blackhole-settings.ts +437 -0
  62. package/src/pi-base/config-manager.ts +440 -0
  63. package/src/pi-base/config.ts +469 -0
  64. package/src/pi-base/env.ts +43 -0
  65. package/src/pi-base/paths.ts +47 -0
  66. package/src/pi-base/settings/body.ts +1648 -0
  67. package/src/pi-base/settings/fields/action.ts +43 -0
  68. package/src/pi-base/settings/fields/boolean.ts +47 -0
  69. package/src/pi-base/settings/fields/custom.ts +72 -0
  70. package/src/pi-base/settings/fields/enum.ts +310 -0
  71. package/src/pi-base/settings/fields/index.ts +46 -0
  72. package/src/pi-base/settings/fields/model.ts +452 -0
  73. package/src/pi-base/settings/fields/string.ts +527 -0
  74. package/src/pi-base/settings/fields/text.ts +115 -0
  75. package/src/pi-base/settings/frame.ts +197 -0
  76. package/src/pi-base/settings/index.ts +77 -0
  77. package/src/pi-base/settings/inline-edit.ts +313 -0
  78. package/src/pi-base/settings/modal.ts +152 -0
  79. package/src/pi-base/settings/types.ts +500 -0
  80. package/src/pi-base/settings/validate-field.ts +113 -0
  81. package/src/pi-base/shell.ts +117 -0
  82. package/src/pi-base/types.ts +6 -0
  83. package/src/pi-base/ui.ts +32 -0
  84. package/src/tools/recall.ts +347 -225
  85. package/src/types.ts +20 -3
  86. package/tsup.config.ts +23 -0
  87. 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 = (text: string): Array<{ segment: string; index: number; isWordLike?: boolean }> => {
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<{ segment: string; index: number; isWordLike?: boolean }> = [];
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<{ segment: string; index: number; isWordLike?: boolean }> = [];
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", "an", "the", "is", "are", "was", "were", "be", "been", "being",
62
- "have", "has", "had", "do", "does", "did", "will", "would", "could",
63
- "should", "may", "might", "shall", "can", "need", "must",
64
- "to", "of", "in", "for", "on", "with", "at", "by", "from", "as",
65
- "into", "through", "during", "before", "after", "above", "below",
66
- "between", "under", "over",
67
- "and", "but", "or", "nor", "not", "so", "yet", "both", "either",
68
- "neither", "each", "every", "all", "any", "few", "more", "most",
69
- "other", "some", "such", "no",
70
- "that", "this", "these", "those", "it", "its",
71
- "i", "me", "my", "we", "our", "you", "your", "he", "him", "his",
72
- "she", "her", "they", "them", "their", "who", "which", "what",
73
- "if", "then", "than", "when", "where", "how", "just", "also",
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 = /\s*\|\s*(?:head|tail|sort|wc|column|tr|cut|uniq)(?:\s[^|]*)?$/;
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.split("\n").map(l => l.trim()).filter(Boolean).join("; ");
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", Edit: "file_path", Write: "file_path",
126
- read: "path", edit: "path", write: "path",
127
- Glob: "pattern", Grep: "pattern",
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("* ")) { out.push(line); continue; }
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] = `${base} (${m[2]}, #${ref}) x${parseInt(m[3]) + 1}`;
255
- } else if (last.match(/\(#\d+\)$/) && last.replace(/\s*\(#\d+\)$/, "") === base) {
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 (prev && prevMatch && prevMatch[1] === tool && prev.lines.length === 1 && prev.lines[0] === body) {
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 = (prev.header === "[assistant]" && prev.lines.every((l) => l.startsWith("* "))) ||
450
+ const prevIsToolLike =
451
+ (prev.header === "[assistant]" &&
452
+ prev.lines.every((l) => l.startsWith("* "))) ||
333
453
  prev.header.startsWith("[tool_error]");
334
- const curIsToolLike = (sec.header === "[assistant]" && sec.lines.every((l) => l.startsWith("* "))) ||
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 = (line: string): { tool: string; cmd?: string; ref?: string; count?: number } | null => {
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(/^\* (\S+)\s*(?:"([^"]*)")?\s*(?:\((#[\d, #]+)\))?\s*(?:x(\d+))?$/);
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 = (sections: BriefLine[]): TranscriptEntry[] => {
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({ role: "assistant", text: clean, ...(ref && { ref }) });
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(/^\[tool_error\]\s+(\S+)\s*(?:\(#(\d+)\))?/);
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 { extractPreferences, dedupPreferencesAgainstGoals } from "../extract/preferences";
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 = b.kind === "user" ? `[user] ${clipSentence(line, 150)}` : clipSentence(line, 150);
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
+ };
@@ -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.split("\n").map((line) => line.trim()).filter(Boolean);
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 (Array.isArray(args.edits) && args.edits.length > 0 && args.edits.every((e) => typeof e === "object" && e !== null)) return true;
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 (typeof args.oldText === "string" && args.oldText.length > 0 && args.edits === undefined) return true;
70
- if (typeof args.newText === "string" && args.newText.length > 0 && args.edits === undefined) return true;
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 += edit.oldText.slice(0, Math.floor(maxBytesPerCall / 2)) + "\n";
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 += edit.newText.slice(0, Math.floor(maxBytesPerCall / 2)) + "\n";
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 = (text: string, term: string, radius = 60): string | null => {
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);
@@ -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((e: unknown): e is { oldText?: string; newText?: string } => e !== null && typeof e === "object");
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)) entry.oldText = args.oldText;
52
- if (typeof args.newText === "string" && !Array.isArray(args.edits)) entry.newText = args.newText;
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((e, i) => `--- edit ${i + 1} ---\n${e.oldText ?? ""}\n--- becomes ---\n${e.newText ?? ""}`)
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
- query: string,
174
- ): { index: number; pathPattern: string; full: boolean; offset?: number; limit?: number } | null {
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 { index, pathPattern, full: true, offset: undefined, limit: undefined };
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 { index, pathPattern, full: false, offset: undefined, limit: undefined };
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, { full, offset, limit });
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((tc) => ` [#${entryIndex}:${tc.path}] ${tc.name}(${tc.path})`);
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
 
@@ -1,8 +1,13 @@
1
1
  import type { NormalizedBlock } from "../types";
2
2
 
3
3
  const NOISE_TOOLS = new Set([
4
- "TodoWrite", "TodoRead", "ToolSearch", "WebSearch",
5
- "AskUser", "ExitSpecMode", "GenerateDroid",
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 = /<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
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(fm: FileMatch, index: number, isQuery: boolean): string {
30
+ function formatFileMatch(
31
+ fm: FileMatch,
32
+ index: number,
33
+ isQuery: boolean,
34
+ ): string {
31
35
  const label = isQuery
32
- ? (fm.lineCount === 1 ? "match" : "matches")
33
- : (fm.lineCount === 1 ? "line" : "lines");
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 = totalPages > 1
65
- ? `Page ${currentPage}/${totalPages} (${touched.length} total files)`
66
- : `${touched.length} files touched`;
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);