pi-better-subagents 0.1.5 → 0.1.6

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/cleanup.ts ADDED
@@ -0,0 +1,403 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ readdirSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { join, relative } from "node:path";
11
+ import {
12
+ baseDir,
13
+ listMetas,
14
+ ownedByThisParent,
15
+ readMeta,
16
+ runDir,
17
+ sessionsDir,
18
+ type RunMeta,
19
+ type RunStatus,
20
+ } from "./registry.ts";
21
+ import type { SubagentConfig } from "./config.ts";
22
+
23
+ export const DEFAULT_CLEANUP_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
24
+
25
+ /**
26
+ * Total registry budget. Age alone cannot bound this directory: 63 GB
27
+ * accumulated inside five days, so a seven-day window never saw it. A
28
+ * `message_update` event re-serialises the whole accumulated message, so one
29
+ * long-running subagent can write gigabytes in an afternoon.
30
+ *
31
+ * Deliberately tight. A single run's log has been observed past 3 GB, so a
32
+ * budget generous enough to hold several of those defeats the purpose: what a
33
+ * developer wants back is the disk, and what they lose is a log nothing reads
34
+ * once its result has been delivered.
35
+ */
36
+ export const DEFAULT_MAX_REGISTRY_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
37
+
38
+ /**
39
+ * How often the size bound may be enforced. Independent of the daily marker:
40
+ * the point of a size cap is to react inside a day, and it only stats run
41
+ * directories rather than walking the session tree.
42
+ */
43
+ export const SIZE_SWEEP_INTERVAL_MS = 10 * 60 * 1000; // 10min
44
+
45
+ interface CleanupState {
46
+ lastLocalDate?: string;
47
+ lastRunAt?: number;
48
+ lastSizeSweepAt?: number;
49
+ }
50
+
51
+ export interface DailyCleanupResult {
52
+ ran: boolean;
53
+ dateKey: string;
54
+ removedRunDirs: number;
55
+ removedSessionPaths: number;
56
+ errors: string[];
57
+ }
58
+
59
+ export interface DailyCleanupOptions {
60
+ now?: number;
61
+ config?: Pick<SubagentConfig, "cleanupTerminalRunRetentionMs" | "cleanupSessionRetentionMs">;
62
+ terminalRunRetentionMs?: number;
63
+ sessionRetentionMs?: number;
64
+ }
65
+
66
+ const TERMINAL_CLEANUP_STATUSES = new Set<RunStatus>(["completed", "failed", "killed", "lost"]);
67
+
68
+ export interface SizeCapEntry {
69
+ id: string;
70
+ status?: RunStatus;
71
+ /** End of the run, when recorded; oldest-first ordering uses it. */
72
+ endedAt?: number;
73
+ dirMtimeMs: number;
74
+ bytes: number;
75
+ }
76
+
77
+ export interface SizeCapPlan {
78
+ remove: string[];
79
+ reclaimedBytes: number;
80
+ keptBytes: number;
81
+ /** The cap could not be met without deleting live or protected runs. */
82
+ overBudget: boolean;
83
+ }
84
+
85
+ export interface SizeCapLimits {
86
+ maxBytes: number;
87
+ /**
88
+ * Runs the calling pi owns. Never removed: unlike the age bound, a size
89
+ * sweep goes after the NEWEST large runs once a cap is exceeded, which is
90
+ * exactly the live session's own work — the session can still deliver their
91
+ * callbacks and answer `subagent_result` for them.
92
+ */
93
+ protectedIds?: ReadonlySet<string>;
94
+ }
95
+
96
+ /**
97
+ * Choose run directories to retire so the registry fits `maxBytes`,
98
+ * oldest-terminal-first. Pure; the caller supplies the entries.
99
+ */
100
+ export function planRegistrySizeCap(
101
+ entries: readonly SizeCapEntry[],
102
+ limits: SizeCapLimits,
103
+ ): SizeCapPlan {
104
+ let kept = entries.reduce((sum, entry) => sum + entry.bytes, 0);
105
+ const remove: string[] = [];
106
+ if (kept <= limits.maxBytes) {
107
+ return { remove, reclaimedBytes: 0, keptBytes: kept, overBudget: false };
108
+ }
109
+
110
+ const candidates = entries
111
+ .filter((entry) => entry.status !== undefined
112
+ && TERMINAL_CLEANUP_STATUSES.has(entry.status)
113
+ && !limits.protectedIds?.has(entry.id))
114
+ .sort((a, b) => (a.endedAt ?? a.dirMtimeMs) - (b.endedAt ?? b.dirMtimeMs));
115
+
116
+ let reclaimed = 0;
117
+ for (const candidate of candidates) {
118
+ if (kept <= limits.maxBytes) break;
119
+ remove.push(candidate.id);
120
+ kept -= candidate.bytes;
121
+ reclaimed += candidate.bytes;
122
+ }
123
+ return { remove, reclaimedBytes: reclaimed, keptBytes: kept, overBudget: kept > limits.maxBytes };
124
+ }
125
+
126
+ export function cleanupStatePath(): string {
127
+ return join(baseDir(), "cleanup-state.json");
128
+ }
129
+
130
+ export function localDateKey(now: number = Date.now()): string {
131
+ const d = new Date(now);
132
+ const year = d.getFullYear();
133
+ const month = String(d.getMonth() + 1).padStart(2, "0");
134
+ const day = String(d.getDate()).padStart(2, "0");
135
+ return `${year}-${month}-${day}`;
136
+ }
137
+
138
+ function readCleanupState(): CleanupState {
139
+ try {
140
+ return JSON.parse(readFileSync(cleanupStatePath(), "utf8")) as CleanupState;
141
+ } catch {
142
+ return {};
143
+ }
144
+ }
145
+
146
+ function writeCleanupState(state: CleanupState): void {
147
+ mkdirSync(baseDir(), { recursive: true });
148
+ writeFileSync(cleanupStatePath(), JSON.stringify(state, null, 2));
149
+ }
150
+
151
+ function retentionMs(explicit: number | undefined, configured: number | null | undefined): number {
152
+ const raw = explicit ?? configured;
153
+ return Number.isFinite(raw) && raw! >= 0 ? raw! : DEFAULT_CLEANUP_RETENTION_MS;
154
+ }
155
+
156
+ function terminalTimestamp(meta: RunMeta): number {
157
+ return meta.endedAt ?? meta.lostAt ?? meta.startedAt;
158
+ }
159
+
160
+ function safeStatMtime(path: string): number | undefined {
161
+ try {
162
+ return statSync(path).mtimeMs;
163
+ } catch {
164
+ return undefined;
165
+ }
166
+ }
167
+
168
+ function removePath(path: string, errors: string[]): boolean {
169
+ try {
170
+ rmSync(path, { recursive: true, force: true });
171
+ return true;
172
+ } catch (err) {
173
+ errors.push(`${path}: ${err instanceof Error ? err.message : String(err)}`);
174
+ return false;
175
+ }
176
+ }
177
+
178
+ function cleanRunDirs(cutoff: number, errors: string[]): number {
179
+ let removed = 0;
180
+ let ids: string[];
181
+ try {
182
+ ids = readdirSync(join(baseDir(), "runs"));
183
+ } catch {
184
+ return 0;
185
+ }
186
+
187
+ for (const id of ids) {
188
+ const dir = runDir(id);
189
+ const meta = readMeta(id);
190
+ if (meta) {
191
+ if (!TERMINAL_CLEANUP_STATUSES.has(meta.status)) continue;
192
+ if (terminalTimestamp(meta) >= cutoff) continue;
193
+ if (removePath(dir, errors)) removed += 1;
194
+ continue;
195
+ }
196
+
197
+ const mtimeMs = safeStatMtime(dir);
198
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(dir, errors)) removed += 1;
199
+ }
200
+ return removed;
201
+ }
202
+
203
+ function activeSessionIds(metas: RunMeta[]): Set<string> {
204
+ const active = new Set<string>();
205
+ for (const meta of metas) {
206
+ if (meta.status === "running" || meta.status === "orphaned") active.add(meta.sessionId);
207
+ }
208
+ return active;
209
+ }
210
+
211
+ function pathMentionsActiveSession(path: string, active: Set<string>): boolean {
212
+ if (active.size === 0) return false;
213
+ const rel = relative(sessionsDir(), path);
214
+ for (const sessionId of active) {
215
+ if (sessionId && rel.includes(sessionId)) return true;
216
+ }
217
+ return false;
218
+ }
219
+
220
+ function cleanSessionTree(cutoff: number, active: Set<string>, errors: string[]): number {
221
+ const root = sessionsDir();
222
+ if (!existsSync(root)) return 0;
223
+ let removed = 0;
224
+
225
+ const visit = (dir: string): boolean => {
226
+ let entries;
227
+ try {
228
+ entries = readdirSync(dir, { withFileTypes: true });
229
+ } catch (err) {
230
+ errors.push(`${dir}: ${err instanceof Error ? err.message : String(err)}`);
231
+ return false;
232
+ }
233
+
234
+ let empty = true;
235
+ for (const entry of entries) {
236
+ const path = join(dir, entry.name);
237
+ if (entry.isDirectory()) {
238
+ const childEmpty = visit(path);
239
+ if (childEmpty && !pathMentionsActiveSession(path, active)) {
240
+ const mtimeMs = safeStatMtime(path);
241
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(path, errors)) {
242
+ removed += 1;
243
+ continue;
244
+ }
245
+ }
246
+ empty = false;
247
+ continue;
248
+ }
249
+
250
+ if (pathMentionsActiveSession(path, active)) {
251
+ empty = false;
252
+ continue;
253
+ }
254
+ const mtimeMs = safeStatMtime(path);
255
+ if (mtimeMs !== undefined && mtimeMs < cutoff && removePath(path, errors)) {
256
+ removed += 1;
257
+ continue;
258
+ }
259
+ empty = false;
260
+ }
261
+ return empty;
262
+ };
263
+
264
+ visit(root);
265
+ return removed;
266
+ }
267
+
268
+ export function runDailyCleanupOnce(options: DailyCleanupOptions = {}): DailyCleanupResult {
269
+ const now = options.now ?? Date.now();
270
+ const dateKey = localDateKey(now);
271
+ const state = readCleanupState();
272
+ if (state.lastLocalDate === dateKey) {
273
+ return { ran: false, dateKey, removedRunDirs: 0, removedSessionPaths: 0, errors: [] };
274
+ }
275
+
276
+ const errors: string[] = [];
277
+ const terminalRetention = retentionMs(options.terminalRunRetentionMs, options.config?.cleanupTerminalRunRetentionMs);
278
+ const sessionRetention = retentionMs(options.sessionRetentionMs, options.config?.cleanupSessionRetentionMs);
279
+ const metas = listMetas();
280
+ const removedRunDirs = cleanRunDirs(now - terminalRetention, errors);
281
+ const removedSessionPaths = cleanSessionTree(now - sessionRetention, activeSessionIds(metas), errors);
282
+
283
+ try {
284
+ writeCleanupState({ lastLocalDate: dateKey, lastRunAt: now });
285
+ } catch (err) {
286
+ errors.push(`${cleanupStatePath()}: ${err instanceof Error ? err.message : String(err)}`);
287
+ }
288
+
289
+ return { ran: true, dateKey, removedRunDirs, removedSessionPaths, errors };
290
+ }
291
+
292
+ /** Bytes held under one run directory. Best-effort; unreadable entries count 0. */
293
+ function dirBytes(dir: string): number {
294
+ let bytes = 0;
295
+ let entries;
296
+ try {
297
+ entries = readdirSync(dir, { withFileTypes: true });
298
+ } catch {
299
+ return 0;
300
+ }
301
+ for (const entry of entries) {
302
+ const path = join(dir, entry.name);
303
+ try {
304
+ bytes += entry.isDirectory() ? dirBytes(path) : statSync(path).size;
305
+ } catch { /* vanished mid-scan */ }
306
+ }
307
+ return bytes;
308
+ }
309
+
310
+ /** Read the registry into size-cap entries. */
311
+ export function collectSizeCapEntries(): SizeCapEntry[] {
312
+ let ids: string[];
313
+ try {
314
+ ids = readdirSync(join(baseDir(), "runs"));
315
+ } catch {
316
+ return [];
317
+ }
318
+ const entries: SizeCapEntry[] = [];
319
+ for (const id of ids) {
320
+ const dir = runDir(id);
321
+ const meta = readMeta(id);
322
+ entries.push({
323
+ id,
324
+ status: meta?.status,
325
+ endedAt: meta ? terminalTimestamp(meta) : undefined,
326
+ dirMtimeMs: safeStatMtime(dir) ?? 0,
327
+ bytes: dirBytes(dir),
328
+ });
329
+ }
330
+ return entries;
331
+ }
332
+
333
+ export interface SizeCapResult extends SizeCapPlan {
334
+ ran: boolean;
335
+ removed: string[];
336
+ errors: string[];
337
+ }
338
+
339
+ export interface SizeCapOptions {
340
+ now?: number;
341
+ config?: Pick<SubagentConfig, "maxRegistryBytes">;
342
+ maxBytes?: number;
343
+ /** Bypass the interval marker (tests, explicit operator sweeps). */
344
+ force?: boolean;
345
+ protectedIds?: ReadonlySet<string>;
346
+ }
347
+
348
+ /**
349
+ * Enforce the registry byte budget, at most once per SIZE_SWEEP_INTERVAL_MS.
350
+ * Complements the daily age sweep: age retires history, this bounds the peak a
351
+ * single busy day can reach. Best-effort; never throws into a caller.
352
+ */
353
+ export function enforceRegistrySizeCapOnce(options: SizeCapOptions = {}): SizeCapResult {
354
+ const now = options.now ?? Date.now();
355
+ const idle: SizeCapResult = {
356
+ ran: false,
357
+ remove: [],
358
+ removed: [],
359
+ reclaimedBytes: 0,
360
+ keptBytes: 0,
361
+ overBudget: false,
362
+ errors: [],
363
+ };
364
+
365
+ const state = readCleanupState();
366
+ if (!options.force
367
+ && typeof state.lastSizeSweepAt === "number"
368
+ && now - state.lastSizeSweepAt >= 0
369
+ && now - state.lastSizeSweepAt < SIZE_SWEEP_INTERVAL_MS) {
370
+ return idle;
371
+ }
372
+
373
+ const configured = options.config?.maxRegistryBytes;
374
+ const maxBytes = options.maxBytes
375
+ ?? (Number.isFinite(configured) && configured! > 0 ? configured! : DEFAULT_MAX_REGISTRY_BYTES);
376
+
377
+ const errors: string[] = [];
378
+ const protectedIds = options.protectedIds ?? ownRunIds();
379
+ const plan = planRegistrySizeCap(collectSizeCapEntries(), { maxBytes, protectedIds });
380
+ const removed: string[] = [];
381
+ for (const id of plan.remove) {
382
+ if (removePath(runDir(id), errors)) removed.push(id);
383
+ }
384
+
385
+ try {
386
+ writeCleanupState({ ...state, lastSizeSweepAt: now });
387
+ } catch (err) {
388
+ errors.push(`${cleanupStatePath()}: ${err instanceof Error ? err.message : String(err)}`);
389
+ }
390
+
391
+ return { ...plan, ran: true, removed, errors };
392
+ }
393
+
394
+ /** Run ids owned by this pi process, which a size sweep must never remove. */
395
+ function ownRunIds(): Set<string> {
396
+ const own = new Set<string>();
397
+ try {
398
+ for (const meta of listMetas()) {
399
+ if (ownedByThisParent(meta)) own.add(meta.id);
400
+ }
401
+ } catch { /* registry unreadable: protect nothing, remove nothing new */ }
402
+ return own;
403
+ }
package/config.ts CHANGED
@@ -45,6 +45,16 @@ export interface SubagentConfig {
45
45
  healthStaleMs?: number | null;
46
46
  healthLongToolMs?: number | null;
47
47
  healthLongCompactionMs?: number | null;
48
+ /** Retention for terminal run directories during once-daily cleanup. Default: 7 days. */
49
+ cleanupTerminalRunRetentionMs?: number | null;
50
+ /** Retention for child pi session files during once-daily cleanup. Default: 7 days. */
51
+ cleanupSessionRetentionMs?: number | null;
52
+ /**
53
+ * Total registry byte budget. Enforced between daily sweeps, oldest terminal
54
+ * run first — age alone cannot bound a directory that can grow by gigabytes
55
+ * in an afternoon. Default: 2 GiB. See cleanup.ts.
56
+ */
57
+ maxRegistryBytes?: number | null;
48
58
  }
49
59
 
50
60
  /** Concurrency cap when config.json sets none. */
@@ -10,7 +10,8 @@
10
10
  * and must not collapse into stale. Raw log mtime/size is diagnostic only.
11
11
  */
12
12
 
13
- import { readFileSync, statSync } from "node:fs";
13
+ import { statSync } from "node:fs";
14
+ import { DEFAULT_MAX_READ_BYTES, readAppendedLines, type LogCursor } from "./log-cursor.ts";
14
15
  import { logPathFor } from "./registry.ts";
15
16
  import type { RunStatus } from "./registry.ts";
16
17
  import { loadConfig, type SubagentConfig } from "./config.ts";
@@ -119,6 +120,14 @@ export interface RawLogDiagnostic {
119
120
  mtimeMs?: number;
120
121
  sizeBytes?: number;
121
122
  error?: string;
123
+ /**
124
+ * Bytes of the log were never read: a bounded cold start on an already-huge
125
+ * log, or a log that outran the per-read budget. Diagnostic only — facts
126
+ * from a partial window are still facts, they are just not the whole run.
127
+ */
128
+ windowTruncated?: boolean;
129
+ /** Retained fact-relevant events were dropped to stay inside the memory budget. */
130
+ eventsDropped?: boolean;
122
131
  }
123
132
 
124
133
  type LooseEvent = Record<string, unknown>;
@@ -184,6 +193,43 @@ function pushError(history: ModelErrorEntry[], entry: ModelErrorEntry, max = 8):
184
193
  while (history.length > max) history.shift();
185
194
  }
186
195
 
196
+ /**
197
+ * Every event type `extractChildEventFacts` below branches on. The fold has no
198
+ * default branch — an event of any other type (`message_update` and friends,
199
+ * which are 98% of a log by volume) cannot move a single fact — so incremental
200
+ * readers may drop unlisted types before parsing them without changing any
201
+ * observation. Keep in step with the fold: `health_observation.test.mjs` fails
202
+ * if the fold branches on a type this set does not name, or names one the fold
203
+ * no longer reads.
204
+ */
205
+ export const CHILD_EVENT_FACT_TYPES: ReadonlySet<string> = new Set([
206
+ "tool_execution_start",
207
+ "tool_execution_update",
208
+ "tool_execution_end",
209
+ "compaction_start",
210
+ "compaction_end",
211
+ "auto_retry_start",
212
+ "auto_retry_end",
213
+ "message_end",
214
+ "turn_end",
215
+ "agent_end",
216
+ "agent_settled",
217
+ ]);
218
+
219
+ /** Leading `{"type":"…"` of an NDJSON event line, read without parsing it. */
220
+ const LEADING_TYPE = /^\s*\{\s*"type"\s*:\s*"([A-Za-z0-9_.-]+)"/;
221
+
222
+ /**
223
+ * Whether a raw log line could move any fact. Lines whose type we can read
224
+ * cheaply and that the fold ignores are rejected without a `JSON.parse`; a line
225
+ * whose shape we cannot read cheaply is kept, so the fold stays authoritative.
226
+ */
227
+ export function mayAffectChildEventFacts(line: string): boolean {
228
+ const match = LEADING_TYPE.exec(line);
229
+ if (!match) return true;
230
+ return CHILD_EVENT_FACT_TYPES.has(match[1]!);
231
+ }
232
+
187
233
  /**
188
234
  * Extract health-relevant facts from an in-memory event list (tests / callers
189
235
  * that already parsed NDJSON). Skips non-objects; ignores raw non-JSON noise.
@@ -371,9 +417,99 @@ export function extractChildEventFacts(events: ReadonlyArray<unknown>): ChildEve
371
417
  };
372
418
  }
373
419
 
420
+ /** Parse the fact-relevant events out of raw NDJSON lines. */
421
+ function parseFactEvents(lines: Iterable<string>): { events: unknown[]; sizes: number[] } {
422
+ const events: unknown[] = [];
423
+ const sizes: number[] = [];
424
+ for (const line of lines) {
425
+ const s = line.trim();
426
+ if (!s || s[0] !== "{") continue;
427
+ if (!mayAffectChildEventFacts(s)) continue;
428
+ try {
429
+ events.push(JSON.parse(s));
430
+ sizes.push(s.length);
431
+ } catch {
432
+ // bad JSON — ignore (noise / partial line)
433
+ }
434
+ }
435
+ return { events, sizes };
436
+ }
437
+
438
+ // ---- incremental log reading ----------------------------------------------
439
+ //
440
+ // Logs are append-only and reach gigabytes, so facts are folded from an
441
+ // accumulated event list that grows by the bytes appended since the last read
442
+ // (see log-cursor.ts). Only the types the fold consumes are retained, which is
443
+ // ~2% of a log's lines; `message_update` alone is 98% of the volume and cannot
444
+ // move a fact. The retained list is capped, oldest-first, and a drop is
445
+ // reported as a diagnostic rather than silently changing an observation.
446
+
447
+ /** Memory budget for one run's retained fact-relevant events. */
448
+ const MAX_RETAINED_EVENT_BYTES = 8 * 1024 * 1024; // 8 MiB
449
+
450
+ function maxHealthReadBytes(): number {
451
+ const raw = process.env.PI_SUBAGENT_MAX_HEALTH_READ_BYTES;
452
+ if (!raw) return DEFAULT_MAX_READ_BYTES;
453
+ const n = Number(raw);
454
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_MAX_READ_BYTES;
455
+ }
456
+
457
+ /**
458
+ * Runs tracked at once. A backstop, not a policy: callers are expected to reset
459
+ * a dismissed run, and this bounds the leak if one forgets.
460
+ */
461
+ const MAX_TRACKED_RUNS = 64;
462
+
463
+ interface FactLogState {
464
+ cursor: LogCursor;
465
+ events: unknown[];
466
+ /** Retained source bytes per event, index-aligned with `events`. */
467
+ sizes: number[];
468
+ /** Sum of `sizes`, for the drop-oldest budget. */
469
+ bytes: number;
470
+ truncated: boolean;
471
+ dropped: boolean;
472
+ }
473
+
474
+ const factLogStates = new Map<string, FactLogState>();
475
+
476
+ /**
477
+ * Forget the incremental read position for a run (or all runs). Call after a run
478
+ * is dismissed, and in tests that rewrite a log under a reused id.
479
+ */
480
+ export function resetChildEventLogCursor(id?: string): void {
481
+ if (id === undefined) factLogStates.clear();
482
+ else factLogStates.delete(id);
483
+ }
484
+
485
+ function rememberState(id: string, state: FactLogState): void {
486
+ // Re-insert so the map orders least-recently-read first.
487
+ factLogStates.delete(id);
488
+ factLogStates.set(id, state);
489
+ while (factLogStates.size > MAX_TRACKED_RUNS) {
490
+ const oldest = factLogStates.keys().next();
491
+ if (oldest.done) break;
492
+ factLogStates.delete(oldest.value);
493
+ }
494
+ }
495
+
496
+ function trimToBudget(state: FactLogState): void {
497
+ while (state.bytes > MAX_RETAINED_EVENT_BYTES && state.events.length > 1) {
498
+ state.events.shift();
499
+ state.bytes -= state.sizes.shift() ?? 0;
500
+ state.dropped = true;
501
+ }
502
+ if (state.bytes < 0) state.bytes = 0;
503
+ }
504
+
374
505
  /**
375
506
  * Read a run log for event facts + raw mtime/size diagnostics.
376
507
  * Raw log write time never promotes activity health by itself.
508
+ *
509
+ * Reads incrementally: only bytes appended since the previous call for this id
510
+ * are parsed, so a hot-path caller never re-reads a whole multi-gigabyte log.
511
+ * Pass `logText` to fold a caller-supplied log instead, which bypasses the
512
+ * cursor entirely.
377
513
  */
378
514
  export function extractChildEventFactsFromLog(
379
515
  id: string,
@@ -389,33 +525,42 @@ export function extractChildEventFactsFromLog(
389
525
  rawLog.error = err instanceof Error ? err.message : String(err);
390
526
  }
391
527
 
392
- let text = opts.logText;
393
- if (text === undefined) {
394
- try {
395
- text = readFileSync(path, "utf-8");
396
- } catch (err) {
397
- rawLog.error = rawLog.error ?? (err instanceof Error ? err.message : String(err));
398
- return { facts: emptyFacts(), rawLog };
399
- }
400
- }
401
-
402
- const events: unknown[] = [];
403
- for (const line of text.split("\n")) {
404
- const s = line.trim();
405
- if (!s || s[0] !== "{") continue;
406
- try {
407
- events.push(JSON.parse(s));
408
- } catch {
409
- // bad JSON — ignore (noise / partial line)
410
- }
411
- }
412
-
413
528
  // Do not synthesise wall-clock timestamps from raw mtime / now. Untimestamped
414
529
  // events still contribute structural facts (open tools, compacting, model
415
530
  // phase); activity age only moves on parsed-event write provenance.
416
531
  // `opts.now` is accepted for API symmetry with callers but must not mint times.
417
532
  void opts.now;
418
- return { facts: extractChildEventFacts(events), rawLog };
533
+
534
+ if (opts.logText !== undefined) {
535
+ const { events } = parseFactEvents(opts.logText.split("\n"));
536
+ return { facts: extractChildEventFacts(events), rawLog };
537
+ }
538
+
539
+ const previous = factLogStates.get(id);
540
+ const read = readAppendedLines(path, previous?.cursor, maxHealthReadBytes());
541
+ if (read.error !== undefined) {
542
+ rawLog.error = rawLog.error ?? read.error;
543
+ if (!previous) return { facts: emptyFacts(), rawLog };
544
+ }
545
+
546
+ const state: FactLogState = read.restarted || !previous
547
+ ? { cursor: read.cursor, events: [], sizes: [], bytes: 0, truncated: false, dropped: false }
548
+ : previous;
549
+ state.cursor = read.cursor;
550
+ state.truncated = state.truncated || read.truncated;
551
+
552
+ const parsed = parseFactEvents(read.lines);
553
+ if (parsed.events.length > 0) {
554
+ state.events.push(...parsed.events);
555
+ state.sizes.push(...parsed.sizes);
556
+ for (const size of parsed.sizes) state.bytes += size;
557
+ trimToBudget(state);
558
+ }
559
+ rememberState(id, state);
560
+
561
+ if (state.truncated) rawLog.windowTruncated = true;
562
+ if (state.dropped) rawLog.eventsDropped = true;
563
+ return { facts: extractChildEventFacts(state.events), rawLog };
419
564
  }
420
565
 
421
566
  // ---- observation ----------------------------------------------------------