@phnx-labs/agents-cli 1.22.83 → 1.22.84
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/CHANGELOG.md +39 -0
- package/dist/commands/accounts.js +1 -1
- package/dist/commands/sessions-trace.d.ts +11 -0
- package/dist/commands/sessions-trace.js +71 -0
- package/dist/lib/accounts/connect.d.ts +6 -0
- package/dist/lib/accounts/connect.js +70 -0
- package/dist/lib/daemon-ticks.js +23 -2
- package/dist/lib/feed/activity-stream.d.ts +60 -0
- package/dist/lib/feed/activity-stream.js +271 -0
- package/dist/lib/feed/activity.d.ts +7 -0
- package/dist/lib/feed/activity.js +10 -4
- package/dist/lib/feed/watch.d.ts +9 -3
- package/dist/lib/feed/watch.js +72 -36
- package/dist/lib/fleet-shared-state.d.ts +6 -0
- package/dist/lib/session/active.d.ts +38 -4
- package/dist/lib/session/active.js +36 -4
- package/dist/lib/session/bash-command.js +10 -0
- package/dist/lib/session/db.d.ts +47 -2
- package/dist/lib/session/db.js +131 -4
- package/dist/lib/session/mirror.d.ts +5 -0
- package/dist/lib/session/mirror.js +166 -0
- package/dist/lib/session/parse.d.ts +49 -0
- package/dist/lib/session/parse.js +324 -30
- package/dist/lib/session/prompt.d.ts +93 -2
- package/dist/lib/session/prompt.js +266 -15
- package/dist/lib/session/remote/peer-stream.d.ts +47 -0
- package/dist/lib/session/remote/peer-stream.js +142 -0
- package/dist/lib/session/remote/watch.d.ts +1 -1
- package/dist/lib/session/remote/watch.js +41 -42
- package/dist/lib/session/session-cache.d.ts +9 -0
- package/dist/lib/session/session-cache.js +40 -2
- package/dist/lib/session/timeline-pass.d.ts +129 -0
- package/dist/lib/session/timeline-pass.js +323 -0
- package/dist/lib/session/timeline.d.ts +182 -0
- package/dist/lib/session/timeline.js +636 -0
- package/dist/lib/session/types.d.ts +155 -1
- package/dist/lib/summarizer/pass.d.ts +2 -0
- package/dist/lib/summarizer/pass.js +14 -1
- package/dist/lib/summarizer/summarize.d.ts +7 -0
- package/dist/lib/summarizer/summarize.js +3 -0
- package/package.json +1 -1
|
@@ -27,8 +27,8 @@ import * as fs from 'fs';
|
|
|
27
27
|
import * as path from 'path';
|
|
28
28
|
import { createMemoryCache } from '../memory-cache.js';
|
|
29
29
|
import { getCacheDir } from '../state.js';
|
|
30
|
-
import { backfillActiveRowsFromIndex, sessionProcessIsLocal } from './active.js';
|
|
31
|
-
import { readSessionSummaryAny } from './db.js';
|
|
30
|
+
import { backfillActiveRowsFromIndex, foldRecap, sessionProcessIsLocal } from './active.js';
|
|
31
|
+
import { readSessionSummaryAny, readSessionTimelineAny } from './db.js';
|
|
32
32
|
import { isSummarizerReady } from '../summarizer/config.js';
|
|
33
33
|
/** Snapshot file under `getCacheDir()` (regenerable, gitignored). */
|
|
34
34
|
const SNAPSHOT_FILE = '.active-sessions.json';
|
|
@@ -468,10 +468,48 @@ export function resolveStreamSummaryState(current, nowMs = Date.now()) {
|
|
|
468
468
|
return current;
|
|
469
469
|
return isSummarizerReady(nowMs) ? 'pending' : 'skipped';
|
|
470
470
|
}
|
|
471
|
+
/**
|
|
472
|
+
* Merge the daemon-folded timeline (PHNX-3939) onto a live row from the
|
|
473
|
+
* transcript-keyed `session_timelines` cache. Sibling of
|
|
474
|
+
* {@link mergeSessionSummary} and the same contract: one indexed by-id read of
|
|
475
|
+
* the BOUNDED projection (never the fold's resume state), never a parse, never
|
|
476
|
+
* a model call. `runTimelinePass` in the daemon tick is the only producer.
|
|
477
|
+
* Best-effort — a DB error leaves the row untouched.
|
|
478
|
+
*/
|
|
479
|
+
export function mergeSessionTimeline(s) {
|
|
480
|
+
if (!s.sessionId)
|
|
481
|
+
return s;
|
|
482
|
+
try {
|
|
483
|
+
const stored = readSessionTimelineAny(s.sessionId);
|
|
484
|
+
if (!stored)
|
|
485
|
+
return s;
|
|
486
|
+
if (s.timeline === undefined)
|
|
487
|
+
s.timeline = stored.timeline;
|
|
488
|
+
if (s.files === undefined && stored.files !== undefined)
|
|
489
|
+
s.files = stored.files;
|
|
490
|
+
if (stored.request) {
|
|
491
|
+
// `request` is the one field where the FOLD wins over what the gather
|
|
492
|
+
// already put there. `foldRecap` tidies whatever turn the INDEX had for the
|
|
493
|
+
// row, which lags a live session by up to a scan; the daemon fold read the
|
|
494
|
+
// transcript itself this tick and counted the turns. Re-deriving the recap
|
|
495
|
+
// afterwards keeps the row's title and `userPromptClean` agreeing with the
|
|
496
|
+
// request they are supposed to describe.
|
|
497
|
+
const changed = s.request?.headline !== stored.request.headline || s.request?.turns !== stored.request.turns;
|
|
498
|
+
s.request = stored.request;
|
|
499
|
+
if (changed)
|
|
500
|
+
foldRecap([s]);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
// best-effort: never let the timeline merge break the gather path.
|
|
505
|
+
}
|
|
506
|
+
return s;
|
|
507
|
+
}
|
|
471
508
|
export function applyImmutableMemo(s) {
|
|
472
509
|
if (!s.sessionId)
|
|
473
510
|
return s;
|
|
474
511
|
mergeSessionSummary(s);
|
|
512
|
+
mergeSessionTimeline(s);
|
|
475
513
|
const mtime = transcriptMtimeMs(s);
|
|
476
514
|
if (mtime === null)
|
|
477
515
|
return s;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's incremental timeline pass (PHNX-3939).
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the existing `SessionStateService` tick, which is already
|
|
5
|
+
* reader-gated — no watcher, no work — and already visits every live session.
|
|
6
|
+
* This adds the one thing the tick did not do: fold each live transcript's
|
|
7
|
+
* NEW BYTES into its cached {@link TimelineState} and store the bounded
|
|
8
|
+
* projection a session row merges.
|
|
9
|
+
*
|
|
10
|
+
* Three properties keep it off the request path and off the CPU:
|
|
11
|
+
* - **Reader-gated and budgeted.** Nothing runs unless a `sessions watch`
|
|
12
|
+
* consumer is attached, and at most `budget` sessions are folded per tick.
|
|
13
|
+
* - **Appended bytes only.** For the resumable harnesses (Claude and Codex,
|
|
14
|
+
* line-delimited JSON) a fold reads `size - offset` bytes, not the file.
|
|
15
|
+
* The same resume rule the tool index already uses (`tool-index.ts`).
|
|
16
|
+
* - **Complete records only.** Only newline-terminated lines are folded and
|
|
17
|
+
* the offset advances past those alone, so the 973,963-byte record in one
|
|
18
|
+
* live transcript on this fleet cannot be folded half-written; the next tick
|
|
19
|
+
* picks it up whole.
|
|
20
|
+
*/
|
|
21
|
+
import type { ActiveSession } from './active.js';
|
|
22
|
+
import { type SessionTimelineCacheRow, type SessionTimelineEntry } from './db.js';
|
|
23
|
+
import type { SessionAgentId, SessionEvent } from './types.js';
|
|
24
|
+
/** Sessions folded per tick. The tick's own deadline is 30 s; this stays well inside it. */
|
|
25
|
+
export declare const TIMELINE_PASS_MAX_PER_TICK = 8;
|
|
26
|
+
/** Bytes one session may consume in one tick. A bigger backlog catches up over ticks. */
|
|
27
|
+
export declare const TIMELINE_PASS_MAX_BYTES_PER_SESSION: number;
|
|
28
|
+
/**
|
|
29
|
+
* Bytes the whole pass may consume in one tick, across every session.
|
|
30
|
+
*
|
|
31
|
+
* The per-session cap alone is not a bound on the TICK: on a cold cache eight
|
|
32
|
+
* live sessions each fold from offset 0, and eight multi-megabyte transcripts
|
|
33
|
+
* parsed back to back is what blew the 30 s `session-state` deadline and parked
|
|
34
|
+
* the service on this fleet (observed 2026-09-06). A tick budget makes a cold
|
|
35
|
+
* start catch up over a few ticks instead of stalling one.
|
|
36
|
+
*/
|
|
37
|
+
export declare const TIMELINE_PASS_MAX_BYTES_PER_TICK: number;
|
|
38
|
+
/**
|
|
39
|
+
* Ceiling on a whole-file re-parse for a harness with no resumable reader.
|
|
40
|
+
* Beyond it the timeline is reported `partial` rather than wedging the tick —
|
|
41
|
+
* the same honesty rule the tool index applies at its own in-memory limit.
|
|
42
|
+
*/
|
|
43
|
+
export declare const TIMELINE_PASS_MAX_WHOLE_FILE_BYTES: number;
|
|
44
|
+
/** Events folded from one non-resumable whole-file parse before reporting `partial`. */
|
|
45
|
+
export declare const TIMELINE_PASS_MAX_EVENTS = 20000;
|
|
46
|
+
/**
|
|
47
|
+
* How long a non-resumable harness's timeline may go stale before the pass
|
|
48
|
+
* re-parses its transcript whole.
|
|
49
|
+
*
|
|
50
|
+
* Kimi and Grok expose no byte offset to resume from, so their only option is a
|
|
51
|
+
* full parse — and a full parse of a large, actively-appended transcript wedges
|
|
52
|
+
* the Node event loop for seconds (the PHNX-3411 incident, which is why the tool
|
|
53
|
+
* index deferred those two harnesses to their own budgeted pass). Re-parsing
|
|
54
|
+
* every 15 s would reproduce it. One re-parse per minute keeps such a session's
|
|
55
|
+
* timeline current enough for a card that moves about once a minute, at a
|
|
56
|
+
* quarter of the cost.
|
|
57
|
+
*/
|
|
58
|
+
export declare const TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS = 60000;
|
|
59
|
+
export interface TimelinePassResult {
|
|
60
|
+
/** Sessions whose timeline was folded and written this tick. */
|
|
61
|
+
computed: number;
|
|
62
|
+
/** Sessions whose cached timeline already matched the transcript bytes. */
|
|
63
|
+
reused: number;
|
|
64
|
+
/** Sessions skipped: unreadable transcript, no id/file, or no parseable transcript. */
|
|
65
|
+
skipped: number;
|
|
66
|
+
}
|
|
67
|
+
export interface TimelinePassOptions {
|
|
68
|
+
/** Live rows to fold. Defaults to the local active-sessions cache. */
|
|
69
|
+
sessions?: ActiveSession[];
|
|
70
|
+
budget?: number;
|
|
71
|
+
/** Bytes the whole pass may read this tick, across every session. */
|
|
72
|
+
maxBytes?: number;
|
|
73
|
+
/** Set false only in tests: the pass is reader-gated exactly like the warm tick. */
|
|
74
|
+
requireReader?: boolean;
|
|
75
|
+
nowMs?: number;
|
|
76
|
+
signal?: AbortSignal;
|
|
77
|
+
statFile?: (path: string) => {
|
|
78
|
+
mtimeMs: number;
|
|
79
|
+
size: number;
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Every event of one transcript, read the way the timeline fold needs them.
|
|
84
|
+
*
|
|
85
|
+
* The single source of truth for "which reader does this harness's timeline
|
|
86
|
+
* use", shared by the daemon pass's non-resumable branch and
|
|
87
|
+
* `agents sessions trace --steps` — so the CLI door and the cached row can never
|
|
88
|
+
* fold two different event streams for the same session.
|
|
89
|
+
*/
|
|
90
|
+
export declare function parseTimelineEvents(filePath: string, agent: SessionAgentId): SessionEvent[];
|
|
91
|
+
/** One session's fold: the entry to cache, and what it actually cost to produce. */
|
|
92
|
+
export interface SessionTimelineFold {
|
|
93
|
+
entry: SessionTimelineEntry;
|
|
94
|
+
/**
|
|
95
|
+
* Bytes this fold READ off disk — what the tick's byte budget is debited by.
|
|
96
|
+
*
|
|
97
|
+
* Not the transcript's growth delta: a non-resumable harness re-parses the
|
|
98
|
+
* WHOLE file every time, so eight 3 MiB sessions grown by 10 KB each read
|
|
99
|
+
* 24 MiB, not 80 KB. Charging the delta is what let a tick overrun its budget
|
|
100
|
+
* by an order of magnitude.
|
|
101
|
+
*/
|
|
102
|
+
bytesRead: number;
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Fold one session's new transcript bytes and return the entry to cache.
|
|
106
|
+
* Exported for tests: this is where the resume decision and the whole-file
|
|
107
|
+
* fallback live, and both are worth pinning without a daemon.
|
|
108
|
+
*
|
|
109
|
+
* `byteBudget` is the TICK's remaining allowance. Each branch derives its own
|
|
110
|
+
* ceiling from it — the resumable chunk read is additionally capped per session,
|
|
111
|
+
* while a whole-file re-parse is gated on a budget that can actually reach a
|
|
112
|
+
* real transcript. A session that genuinely does not fit gets a `partial` row
|
|
113
|
+
* stating why, never silence.
|
|
114
|
+
*/
|
|
115
|
+
export declare function foldSessionTimeline(session: ActiveSession, filePath: string, fileSize: number, prior: SessionTimelineCacheRow | undefined, nowMs?: number, byteBudget?: number): SessionTimelineFold | undefined;
|
|
116
|
+
/**
|
|
117
|
+
* Fold the timelines of the live local sessions, bounded, once per tick.
|
|
118
|
+
* Returns what it did so the daemon tick can log it like every other pass.
|
|
119
|
+
*
|
|
120
|
+
* Async only because resolving the session list needs the session-cache module,
|
|
121
|
+
* which is loaded lazily to keep this off the CLI's startup graph. When the
|
|
122
|
+
* caller already HAS the rows (the daemon tick does — it just gathered them),
|
|
123
|
+
* {@link runTimelinePassSync} is the same work with no dynamic import.
|
|
124
|
+
*/
|
|
125
|
+
export declare function runTimelinePass(opts?: TimelinePassOptions): Promise<TimelinePassResult>;
|
|
126
|
+
/** {@link runTimelinePass} over an explicit row list. Pure of the session cache. */
|
|
127
|
+
export declare function runTimelinePassSync(opts: TimelinePassOptions & {
|
|
128
|
+
sessions: ActiveSession[];
|
|
129
|
+
}): TimelinePassResult;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The daemon's incremental timeline pass (PHNX-3939).
|
|
3
|
+
*
|
|
4
|
+
* Runs inside the existing `SessionStateService` tick, which is already
|
|
5
|
+
* reader-gated — no watcher, no work — and already visits every live session.
|
|
6
|
+
* This adds the one thing the tick did not do: fold each live transcript's
|
|
7
|
+
* NEW BYTES into its cached {@link TimelineState} and store the bounded
|
|
8
|
+
* projection a session row merges.
|
|
9
|
+
*
|
|
10
|
+
* Three properties keep it off the request path and off the CPU:
|
|
11
|
+
* - **Reader-gated and budgeted.** Nothing runs unless a `sessions watch`
|
|
12
|
+
* consumer is attached, and at most `budget` sessions are folded per tick.
|
|
13
|
+
* - **Appended bytes only.** For the resumable harnesses (Claude and Codex,
|
|
14
|
+
* line-delimited JSON) a fold reads `size - offset` bytes, not the file.
|
|
15
|
+
* The same resume rule the tool index already uses (`tool-index.ts`).
|
|
16
|
+
* - **Complete records only.** Only newline-terminated lines are folded and
|
|
17
|
+
* the offset advances past those alone, so the 973,963-byte record in one
|
|
18
|
+
* live transcript on this fleet cannot be folded half-written; the next tick
|
|
19
|
+
* picks it up whole.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import { readSessionTimelineEntry, writeSessionTimeline } from './db.js';
|
|
23
|
+
import { parseClaudeContent, parseCodexItemsContent, parseSession } from './parse.js';
|
|
24
|
+
import { toolEvidenceSourcePath } from './tool-store.js';
|
|
25
|
+
import { compactTimelineState, emptyTimelineState, foldTimeline, projectSessionFiles, projectTimeline, unavailableTimeline, TIMELINE_EXTRACTOR_VERSION, } from './timeline.js';
|
|
26
|
+
/** Sessions folded per tick. The tick's own deadline is 30 s; this stays well inside it. */
|
|
27
|
+
export const TIMELINE_PASS_MAX_PER_TICK = 8;
|
|
28
|
+
/** Bytes one session may consume in one tick. A bigger backlog catches up over ticks. */
|
|
29
|
+
export const TIMELINE_PASS_MAX_BYTES_PER_SESSION = 4 * 1024 * 1024;
|
|
30
|
+
/**
|
|
31
|
+
* Bytes the whole pass may consume in one tick, across every session.
|
|
32
|
+
*
|
|
33
|
+
* The per-session cap alone is not a bound on the TICK: on a cold cache eight
|
|
34
|
+
* live sessions each fold from offset 0, and eight multi-megabyte transcripts
|
|
35
|
+
* parsed back to back is what blew the 30 s `session-state` deadline and parked
|
|
36
|
+
* the service on this fleet (observed 2026-09-06). A tick budget makes a cold
|
|
37
|
+
* start catch up over a few ticks instead of stalling one.
|
|
38
|
+
*/
|
|
39
|
+
export const TIMELINE_PASS_MAX_BYTES_PER_TICK = 8 * 1024 * 1024;
|
|
40
|
+
/**
|
|
41
|
+
* Ceiling on a whole-file re-parse for a harness with no resumable reader.
|
|
42
|
+
* Beyond it the timeline is reported `partial` rather than wedging the tick —
|
|
43
|
+
* the same honesty rule the tool index applies at its own in-memory limit.
|
|
44
|
+
*/
|
|
45
|
+
export const TIMELINE_PASS_MAX_WHOLE_FILE_BYTES = 16 * 1024 * 1024;
|
|
46
|
+
/** Events folded from one non-resumable whole-file parse before reporting `partial`. */
|
|
47
|
+
export const TIMELINE_PASS_MAX_EVENTS = 20_000;
|
|
48
|
+
/**
|
|
49
|
+
* How long a non-resumable harness's timeline may go stale before the pass
|
|
50
|
+
* re-parses its transcript whole.
|
|
51
|
+
*
|
|
52
|
+
* Kimi and Grok expose no byte offset to resume from, so their only option is a
|
|
53
|
+
* full parse — and a full parse of a large, actively-appended transcript wedges
|
|
54
|
+
* the Node event loop for seconds (the PHNX-3411 incident, which is why the tool
|
|
55
|
+
* index deferred those two harnesses to their own budgeted pass). Re-parsing
|
|
56
|
+
* every 15 s would reproduce it. One re-parse per minute keeps such a session's
|
|
57
|
+
* timeline current enough for a card that moves about once a minute, at a
|
|
58
|
+
* quarter of the cost.
|
|
59
|
+
*/
|
|
60
|
+
export const TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS = 60_000;
|
|
61
|
+
/**
|
|
62
|
+
* Harnesses whose transcript is append-only line-delimited JSON that the fold
|
|
63
|
+
* can resume from a byte offset. Everything else is re-parsed whole (bounded)
|
|
64
|
+
* because its parser exposes no offset — the same split as
|
|
65
|
+
* `isResumableToolSource` in `tool-index.ts`.
|
|
66
|
+
*/
|
|
67
|
+
function isResumableTimelineSource(agent) {
|
|
68
|
+
return agent === 'claude' || agent === 'codex';
|
|
69
|
+
}
|
|
70
|
+
/** Harnesses that write no parseable transcript at all — stated, never faked as empty. */
|
|
71
|
+
const NO_TRANSCRIPT_REASON = {
|
|
72
|
+
openclaw: 'OpenClaw writes no parseable transcript, so there are no steps to fold',
|
|
73
|
+
};
|
|
74
|
+
/** Read `[start, end)` of a file, keeping only complete newline-terminated lines. */
|
|
75
|
+
function readCompleteLines(filePath, start, end, maxBytes) {
|
|
76
|
+
const stop = Math.min(end, start + maxBytes);
|
|
77
|
+
if (stop <= start)
|
|
78
|
+
return { text: '', offset: start };
|
|
79
|
+
const fd = fs.openSync(filePath, 'r');
|
|
80
|
+
try {
|
|
81
|
+
const buffer = Buffer.alloc(stop - start);
|
|
82
|
+
const read = fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
83
|
+
const lastNewline = buffer.lastIndexOf(0x0a, read - 1);
|
|
84
|
+
// Nothing complete in this window: leave the offset where it was so the
|
|
85
|
+
// record is folded whole once its closing newline lands.
|
|
86
|
+
if (lastNewline < 0)
|
|
87
|
+
return { text: '', offset: start };
|
|
88
|
+
return {
|
|
89
|
+
text: buffer.toString('utf8', 0, lastNewline + 1),
|
|
90
|
+
offset: start + lastNewline + 1,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
fs.closeSync(fd);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Normalize one transcript chunk into the events the fold consumes.
|
|
99
|
+
*
|
|
100
|
+
* Interrupts are user steps in the timeline and the harness file ledger is the
|
|
101
|
+
* Files card, so both are opted in — the published stream every OTHER consumer
|
|
102
|
+
* reads stays unchanged. Codex reads its typed `item_completed` stream, NOT the
|
|
103
|
+
* `response_item` records `parseCodexContent` reads: the two describe the same
|
|
104
|
+
* turn, so folding both would double-count every call (see
|
|
105
|
+
* `parseCodexItemsContent`).
|
|
106
|
+
*/
|
|
107
|
+
function eventsForChunk(agent, text) {
|
|
108
|
+
if (agent === 'claude') {
|
|
109
|
+
return parseClaudeContent(text, { includeInterrupts: true, includeFileHistory: true });
|
|
110
|
+
}
|
|
111
|
+
return parseCodexItemsContent(text);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Every event of one transcript, read the way the timeline fold needs them.
|
|
115
|
+
*
|
|
116
|
+
* The single source of truth for "which reader does this harness's timeline
|
|
117
|
+
* use", shared by the daemon pass's non-resumable branch and
|
|
118
|
+
* `agents sessions trace --steps` — so the CLI door and the cached row can never
|
|
119
|
+
* fold two different event streams for the same session.
|
|
120
|
+
*/
|
|
121
|
+
export function parseTimelineEvents(filePath, agent) {
|
|
122
|
+
if (isResumableTimelineSource(agent)) {
|
|
123
|
+
return eventsForChunk(agent, fs.readFileSync(filePath, 'utf8'));
|
|
124
|
+
}
|
|
125
|
+
return parseSession(filePath, agent, { includeInterrupts: true, includeFileHistory: true });
|
|
126
|
+
}
|
|
127
|
+
/** MiB, for the human-readable reasons a row carries. */
|
|
128
|
+
function mib(bytes) {
|
|
129
|
+
return Math.round((bytes / (1024 * 1024)) * 10) / 10;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Fold one session's new transcript bytes and return the entry to cache.
|
|
133
|
+
* Exported for tests: this is where the resume decision and the whole-file
|
|
134
|
+
* fallback live, and both are worth pinning without a daemon.
|
|
135
|
+
*
|
|
136
|
+
* `byteBudget` is the TICK's remaining allowance. Each branch derives its own
|
|
137
|
+
* ceiling from it — the resumable chunk read is additionally capped per session,
|
|
138
|
+
* while a whole-file re-parse is gated on a budget that can actually reach a
|
|
139
|
+
* real transcript. A session that genuinely does not fit gets a `partial` row
|
|
140
|
+
* stating why, never silence.
|
|
141
|
+
*/
|
|
142
|
+
export function foldSessionTimeline(session, filePath, fileSize, prior, nowMs = Date.now(), byteBudget = TIMELINE_PASS_MAX_BYTES_PER_TICK) {
|
|
143
|
+
const agent = (session.kind ?? 'claude');
|
|
144
|
+
// A settled state stamped at the file's current size, so an unavailable or
|
|
145
|
+
// over-limit session is recognized as up to date next tick instead of being
|
|
146
|
+
// re-decided every 15 s.
|
|
147
|
+
const settledAt = () => ({ ...emptyTimelineState(), offset: fileSize });
|
|
148
|
+
const unavailable = NO_TRANSCRIPT_REASON[agent];
|
|
149
|
+
if (unavailable) {
|
|
150
|
+
return { entry: { timeline: unavailableTimeline(unavailable), state: settledAt() }, bytesRead: 0 };
|
|
151
|
+
}
|
|
152
|
+
let state;
|
|
153
|
+
let events;
|
|
154
|
+
let offset;
|
|
155
|
+
let bytesRead;
|
|
156
|
+
if (isResumableTimelineSource(agent)) {
|
|
157
|
+
const resumable = prior?.state
|
|
158
|
+
&& prior.state.version === TIMELINE_EXTRACTOR_VERSION
|
|
159
|
+
&& prior.state.offset <= fileSize;
|
|
160
|
+
state = resumable ? prior.state : emptyTimelineState();
|
|
161
|
+
const start = resumable ? state.offset : 0;
|
|
162
|
+
const maxBytes = Math.min(byteBudget, TIMELINE_PASS_MAX_BYTES_PER_SESSION);
|
|
163
|
+
const chunk = readCompleteLines(filePath, start, fileSize, maxBytes);
|
|
164
|
+
// Nothing COMPLETE in the window this tick could read — a record still being
|
|
165
|
+
// written, or a byte budget too small to reach the next newline. Leave the
|
|
166
|
+
// cached row and its offset alone; writing here would stamp an empty fold
|
|
167
|
+
// over a real one and move the resume point past bytes never folded.
|
|
168
|
+
if (!chunk.text && chunk.offset === start)
|
|
169
|
+
return undefined;
|
|
170
|
+
events = eventsForChunk(agent, chunk.text);
|
|
171
|
+
offset = chunk.offset;
|
|
172
|
+
bytesRead = chunk.offset - start;
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
// No resumable reader for this harness: re-parse whole, bounded, from a
|
|
176
|
+
// FRESH state — a partial re-fold onto a prior state would double-count.
|
|
177
|
+
// Rate-limited so an actively-appended large transcript is not re-parsed on
|
|
178
|
+
// every 15 s tick (see TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS).
|
|
179
|
+
if (prior && nowMs - prior.computedAt < TIMELINE_PASS_NON_RESUMABLE_MIN_INTERVAL_MS)
|
|
180
|
+
return undefined;
|
|
181
|
+
if (fileSize > TIMELINE_PASS_MAX_WHOLE_FILE_BYTES) {
|
|
182
|
+
return {
|
|
183
|
+
entry: {
|
|
184
|
+
timeline: unavailableTimeline(`transcript is larger than the ${Math.round(TIMELINE_PASS_MAX_WHOLE_FILE_BYTES / (1024 * 1024))} MiB whole-file fold limit for ${agent}`),
|
|
185
|
+
state: settledAt(),
|
|
186
|
+
},
|
|
187
|
+
bytesRead: 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
// Eligibility is the smaller of what the tick has left and what a whole-file
|
|
191
|
+
// fold is allowed to cost — NOT the per-session allowance, which is capped
|
|
192
|
+
// at 4 MiB and so could never reach a transcript in (4 MiB, 16 MiB] however
|
|
193
|
+
// idle the tick was. That gap left every such session on kimi/grok/gemini/
|
|
194
|
+
// cursor/opencode/droid/warp/forge/copilot/goose/antigravity with no row at
|
|
195
|
+
// all, silently counted `reused`.
|
|
196
|
+
const wholeFileAllowance = Math.min(byteBudget, TIMELINE_PASS_MAX_WHOLE_FILE_BYTES);
|
|
197
|
+
if (fileSize > wholeFileAllowance) {
|
|
198
|
+
// It does not fit THIS tick. Say so on the row rather than write nothing:
|
|
199
|
+
// the state is left at offset 0, so a tick with more budget upgrades it to
|
|
200
|
+
// a real fold instead of the session staying invisible forever.
|
|
201
|
+
return {
|
|
202
|
+
entry: {
|
|
203
|
+
timeline: {
|
|
204
|
+
...unavailableTimeline(`a ${mib(fileSize)} MiB ${agent} transcript needs a whole-file fold, and this tick had ${mib(wholeFileAllowance)} MiB of budget left`),
|
|
205
|
+
state: 'partial',
|
|
206
|
+
},
|
|
207
|
+
state: emptyTimelineState(),
|
|
208
|
+
},
|
|
209
|
+
bytesRead: 0,
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
state = emptyTimelineState();
|
|
213
|
+
events = parseTimelineEvents(filePath, agent);
|
|
214
|
+
offset = fileSize;
|
|
215
|
+
// The whole file was read, not the delta — charge the tick for all of it.
|
|
216
|
+
bytesRead = fileSize;
|
|
217
|
+
}
|
|
218
|
+
const folded = compactTimelineState(foldTimeline(events, state, {
|
|
219
|
+
attachments: session.attachments,
|
|
220
|
+
maxEvents: isResumableTimelineSource(agent) ? undefined : TIMELINE_PASS_MAX_EVENTS,
|
|
221
|
+
offset,
|
|
222
|
+
}));
|
|
223
|
+
const files = projectSessionFiles(folded);
|
|
224
|
+
return {
|
|
225
|
+
entry: {
|
|
226
|
+
timeline: projectTimeline(folded, session.activity),
|
|
227
|
+
...(folded.request ? { request: folded.request } : {}),
|
|
228
|
+
...(files ? { files } : {}),
|
|
229
|
+
state: folded,
|
|
230
|
+
},
|
|
231
|
+
bytesRead,
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Fold the timelines of the live local sessions, bounded, once per tick.
|
|
236
|
+
* Returns what it did so the daemon tick can log it like every other pass.
|
|
237
|
+
*
|
|
238
|
+
* Async only because resolving the session list needs the session-cache module,
|
|
239
|
+
* which is loaded lazily to keep this off the CLI's startup graph. When the
|
|
240
|
+
* caller already HAS the rows (the daemon tick does — it just gathered them),
|
|
241
|
+
* {@link runTimelinePassSync} is the same work with no dynamic import.
|
|
242
|
+
*/
|
|
243
|
+
export async function runTimelinePass(opts = {}) {
|
|
244
|
+
const { readActiveSessionsCache, isActiveSessionsJournalReaderRecent } = await import('./session-cache.js');
|
|
245
|
+
const now = opts.nowMs ?? Date.now();
|
|
246
|
+
let sessions = opts.sessions;
|
|
247
|
+
if (!sessions) {
|
|
248
|
+
if (opts.requireReader !== false && !isActiveSessionsJournalReaderRecent(now)) {
|
|
249
|
+
return { computed: 0, reused: 0, skipped: 0 };
|
|
250
|
+
}
|
|
251
|
+
sessions = readActiveSessionsCache('local')?.sessions ?? [];
|
|
252
|
+
}
|
|
253
|
+
return runTimelinePassSync({ ...opts, sessions });
|
|
254
|
+
}
|
|
255
|
+
/** {@link runTimelinePass} over an explicit row list. Pure of the session cache. */
|
|
256
|
+
export function runTimelinePassSync(opts) {
|
|
257
|
+
const result = { computed: 0, reused: 0, skipped: 0 };
|
|
258
|
+
const sessions = opts.sessions;
|
|
259
|
+
const now = opts.nowMs ?? Date.now();
|
|
260
|
+
const stat = opts.statFile ?? ((p) => {
|
|
261
|
+
const s = fs.statSync(p);
|
|
262
|
+
return { mtimeMs: s.mtimeMs, size: s.size };
|
|
263
|
+
});
|
|
264
|
+
let budget = opts.budget ?? TIMELINE_PASS_MAX_PER_TICK;
|
|
265
|
+
let byteBudget = opts.maxBytes ?? TIMELINE_PASS_MAX_BYTES_PER_TICK;
|
|
266
|
+
for (const session of sessions) {
|
|
267
|
+
if (opts.signal?.aborted)
|
|
268
|
+
break;
|
|
269
|
+
if (budget <= 0 || byteBudget <= 0)
|
|
270
|
+
break;
|
|
271
|
+
const id = session.sessionId;
|
|
272
|
+
const file = session.sessionFile;
|
|
273
|
+
if (!id || !file)
|
|
274
|
+
continue;
|
|
275
|
+
// Stamp on the file the PARSER reads, not the row's `sessionFile`: Kimi's row
|
|
276
|
+
// points at `state.json` while its content is in a sibling `wire.jsonl`, and
|
|
277
|
+
// Grok's at `summary.json` beside `chat_history.jsonl`. Stamping the wrong
|
|
278
|
+
// file makes an actively-growing session look unchanged forever. Same helper
|
|
279
|
+
// the tool index uses, so the two agree on what a session's bytes are.
|
|
280
|
+
const source = toolEvidenceSourcePath(file, (session.kind ?? 'claude'));
|
|
281
|
+
let stamp;
|
|
282
|
+
try {
|
|
283
|
+
const st = stat(source);
|
|
284
|
+
stamp = { fileMtimeMs: Math.round(st.mtimeMs), fileSize: st.size };
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
result.skipped++;
|
|
288
|
+
continue; // transcript unreadable this tick — try again next time
|
|
289
|
+
}
|
|
290
|
+
const prior = readSessionTimelineEntry(id);
|
|
291
|
+
// Cached against these exact bytes: nothing was appended, nothing to fold.
|
|
292
|
+
if (prior && prior.state.offset === stamp.fileSize) {
|
|
293
|
+
result.reused++;
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
budget--;
|
|
297
|
+
let fold;
|
|
298
|
+
try {
|
|
299
|
+
fold = foldSessionTimeline(session, file, stamp.fileSize, prior, now, byteBudget);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
// A novel record shape, or a transcript rotated between the stat and the
|
|
303
|
+
// read. That is a real failure, not "nothing new" — say so and count it a
|
|
304
|
+
// skip, or it retries forever behind a healthy-looking log line.
|
|
305
|
+
console.log(`timeline pass: fold failed for ${id} (${session.kind ?? 'claude'}): ${err.message}`);
|
|
306
|
+
result.skipped++;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (!fold) {
|
|
310
|
+
// Nothing complete to fold yet, or a non-resumable harness inside its
|
|
311
|
+
// re-parse interval — the cached row stays current, so this is not a skip.
|
|
312
|
+
result.reused++;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
// Debited by the bytes actually read, so the tick's bound is real: a
|
|
316
|
+
// whole-file re-parse charges the whole file, and a session that read
|
|
317
|
+
// nothing (over budget, unavailable harness) charges nothing.
|
|
318
|
+
byteBudget -= fold.bytesRead;
|
|
319
|
+
writeSessionTimeline({ id, ...stamp, timeline: fold.entry, computedAtMs: now });
|
|
320
|
+
result.computed++;
|
|
321
|
+
}
|
|
322
|
+
return result;
|
|
323
|
+
}
|