@phnx-labs/agents-cli 1.22.82 → 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 +47 -0
- package/dist/commands/accounts.js +34 -6
- package/dist/commands/sessions-trace.d.ts +11 -0
- package/dist/commands/sessions-trace.js +71 -0
- package/dist/commands/view.js +3 -6
- package/dist/lib/account-registry.d.ts +40 -3
- package/dist/lib/account-registry.js +89 -18
- package/dist/lib/accounts/connect.d.ts +6 -0
- package/dist/lib/accounts/connect.js +70 -0
- package/dist/lib/claude-statusline.d.ts +13 -5
- package/dist/lib/claude-statusline.js +19 -6
- package/dist/lib/daemon-ticks.js +23 -2
- package/dist/lib/devices/fleet-inventory.js +3 -4
- package/dist/lib/devices/harness-inventory.js +4 -5
- 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 +103 -13
- 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
package/dist/lib/session/db.js
CHANGED
|
@@ -22,13 +22,14 @@ import { extractBackgroundShells, extractSkills, extractSlashCommands, harnessTr
|
|
|
22
22
|
import { resolveResource } from '../resources.js';
|
|
23
23
|
import { discoverPlugins } from '../plugins/plugins.js';
|
|
24
24
|
import { machineId } from '../machine-id.js';
|
|
25
|
-
import { firstUserMessageFromEvents } from './prompt.js';
|
|
25
|
+
import { firstUserMessageFromEvents, lastUserMessageFromEvents } from './prompt.js';
|
|
26
|
+
import { emptyTimelineState, TIMELINE_EXTRACTOR_VERSION } from './timeline.js';
|
|
26
27
|
const SESSIONS_DIR = getSessionsDir();
|
|
27
28
|
const DB_PATH = getSessionsDbPath();
|
|
28
29
|
/** Current schema version; bumped when migrations are added. Exported so tests
|
|
29
30
|
* assert against the constant instead of hardcoding a number that every bump
|
|
30
31
|
* then has to chase (docs/sessions.md calls the constant the source of truth). */
|
|
31
|
-
export const SCHEMA_VERSION =
|
|
32
|
+
export const SCHEMA_VERSION = 48;
|
|
32
33
|
/**
|
|
33
34
|
* Bump to force the content extractor (assistant-answer text, alongside the
|
|
34
35
|
* user-prompt text every harness already accumulates) to re-derive on every
|
|
@@ -103,6 +104,7 @@ CREATE TABLE IF NOT EXISTS sessions (
|
|
|
103
104
|
git_branch TEXT,
|
|
104
105
|
topic TEXT,
|
|
105
106
|
first_user_message TEXT,
|
|
107
|
+
last_user_message TEXT,
|
|
106
108
|
label TEXT,
|
|
107
109
|
message_count INTEGER,
|
|
108
110
|
token_count INTEGER,
|
|
@@ -420,6 +422,27 @@ CREATE TABLE IF NOT EXISTS session_summaries (
|
|
|
420
422
|
summary_json TEXT NOT NULL
|
|
421
423
|
);
|
|
422
424
|
|
|
425
|
+
-- Daemon-computed narration-anchored timeline (PHNX-3939). Same lazy,
|
|
426
|
+
-- stamp-validated cache shape as session_summaries, with one addition: the fold
|
|
427
|
+
-- is INCREMENTAL, so the row carries both the bounded projection the display
|
|
428
|
+
-- path reads and the resume state the pass folds the next appended bytes onto.
|
|
429
|
+
--
|
|
430
|
+
-- Two columns rather than one on purpose. projection_json is small and bounded
|
|
431
|
+
-- (8 steps + counters + the tidied request + 8 file rows) and is what a session
|
|
432
|
+
-- row merges on the read path; state_json holds the whole TimelineState (byte
|
|
433
|
+
-- offset, open step, pending call ids, per-path file ledger) and is read ONLY by
|
|
434
|
+
-- the daemon pass. Folding one blob would make every row merge parse the resume
|
|
435
|
+
-- state it has no use for.
|
|
436
|
+
CREATE TABLE IF NOT EXISTS session_timelines (
|
|
437
|
+
session_id TEXT PRIMARY KEY,
|
|
438
|
+
file_mtime_ms INTEGER,
|
|
439
|
+
file_size INTEGER,
|
|
440
|
+
extractor_version INTEGER NOT NULL,
|
|
441
|
+
computed_at INTEGER NOT NULL,
|
|
442
|
+
projection_json TEXT NOT NULL,
|
|
443
|
+
state_json TEXT NOT NULL
|
|
444
|
+
);
|
|
445
|
+
|
|
423
446
|
-- Durable metadata for one browser task (RUSH-2549). The browser daemon's
|
|
424
447
|
-- tasks.json is LIVE state: saveTaskState writes the in-memory task map, so
|
|
425
448
|
-- stopping a task drops its entry and a daemon restart empties the file. That is
|
|
@@ -1286,6 +1309,25 @@ function migrateSchema(db, fromVersion) {
|
|
|
1286
1309
|
// The idx_sessions_mirror_synced index is created unconditionally after this
|
|
1287
1310
|
// block (fresh DBs skip migrations), alongside idx_sessions_last_activity.
|
|
1288
1311
|
}
|
|
1312
|
+
if (fromVersion < 48) {
|
|
1313
|
+
// v47 -> v48: retain the LATEST genuine user turn beside the first
|
|
1314
|
+
// (PHNX-3939). For a /continue, a redirect, or an interrupted-and-restated
|
|
1315
|
+
// session the operative request is the last thing the user said, and the row
|
|
1316
|
+
// title / sidebar Request card show that.
|
|
1317
|
+
//
|
|
1318
|
+
// Deliberately WITHOUT a CONTENT_INDEX_VERSION bump, unlike the v45
|
|
1319
|
+
// first_user_message column. That bump forces a re-parse of every indexed
|
|
1320
|
+
// transcript on the next scan; measured on this fleet it pinned the daemon
|
|
1321
|
+
// long enough to blow the session-index and session-state tick deadlines and
|
|
1322
|
+
// park both services. It also buys nothing here: the value can only CHANGE
|
|
1323
|
+
// when the transcript changes, so the ordinary incremental scan fills it in
|
|
1324
|
+
// for exactly the sessions where it matters, and the sidebar's request comes
|
|
1325
|
+
// from the daemon-folded `session_timelines` projection rather than this
|
|
1326
|
+
// column. An old row simply keeps NULL until its next write.
|
|
1327
|
+
const cols = new Set(db.prepare(`PRAGMA table_info(sessions)`).all().map((c) => c.name));
|
|
1328
|
+
if (!cols.has('last_user_message'))
|
|
1329
|
+
db.exec(`ALTER TABLE sessions ADD COLUMN last_user_message TEXT`);
|
|
1330
|
+
}
|
|
1289
1331
|
if (fromVersion < 47) {
|
|
1290
1332
|
// v46 -> v47: carry the actor's Phoenix id (PHNX-3798). Additive, write-once
|
|
1291
1333
|
// launch metadata joined from the actor sidecar — never transcript-derived,
|
|
@@ -1842,7 +1884,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1842
1884
|
INSERT INTO sessions (
|
|
1843
1885
|
id, short_id, agent, harness, origin, routine_name, routine_run_id,
|
|
1844
1886
|
version, account, account_key, account_org, mode, timestamp, last_activity,
|
|
1845
|
-
project, cwd, git_branch, topic, first_user_message, label, message_count, token_count,
|
|
1887
|
+
project, cwd, git_branch, topic, first_user_message, last_user_message, label, message_count, token_count,
|
|
1846
1888
|
output_tokens, input_tokens, cache_read_tokens, cache_write_tokens,
|
|
1847
1889
|
cost_usd, cost_usd_nocache, duration_ms, model, tool_call_count,
|
|
1848
1890
|
file_path, file_mtime_ms, file_size, scanned_at, is_team_origin,
|
|
@@ -1853,7 +1895,7 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1853
1895
|
) VALUES (
|
|
1854
1896
|
@id, @short_id, @agent, @harness, @origin, @routine_name, @routine_run_id,
|
|
1855
1897
|
@version, @account, @account_key, @account_org, @mode, @timestamp, @last_activity,
|
|
1856
|
-
@project, @cwd, @git_branch, @topic, @first_user_message, @label, @message_count, @token_count,
|
|
1898
|
+
@project, @cwd, @git_branch, @topic, @first_user_message, @last_user_message, @label, @message_count, @token_count,
|
|
1857
1899
|
@output_tokens, @input_tokens, @cache_read_tokens, @cache_write_tokens,
|
|
1858
1900
|
@cost_usd, @cost_usd_nocache, @duration_ms, @model, @tool_call_count,
|
|
1859
1901
|
@file_path, @file_mtime_ms, @file_size, @scanned_at, @is_team_origin,
|
|
@@ -1891,6 +1933,10 @@ const upsertSessionStmt = (db) => db.prepare(`
|
|
|
1891
1933
|
git_branch = excluded.git_branch,
|
|
1892
1934
|
topic = excluded.topic,
|
|
1893
1935
|
first_user_message = COALESCE(excluded.first_user_message, sessions.first_user_message),
|
|
1936
|
+
-- Unlike the first turn, the LATEST one moves: a new turn must replace the
|
|
1937
|
+
-- stored value, so this is excluded-wins with a COALESCE only to keep a
|
|
1938
|
+
-- known value when a rescan cannot re-derive one.
|
|
1939
|
+
last_user_message = COALESCE(excluded.last_user_message, sessions.last_user_message),
|
|
1894
1940
|
-- Never let an empty/placeholder incoming label clobber a good stored one.
|
|
1895
1941
|
-- A real incoming label (non-empty after trim) still wins; a blank one keeps
|
|
1896
1942
|
-- the label seeded by --name (seedLabelsFromNames) or refined by an agent
|
|
@@ -2110,6 +2156,7 @@ function enrichMetaFromEvents(meta, events) {
|
|
|
2110
2156
|
return {
|
|
2111
2157
|
...meta,
|
|
2112
2158
|
firstUserMessage: meta.firstUserMessage ?? firstUserMessageFromEvents(events),
|
|
2159
|
+
lastUserMessage: meta.lastUserMessage ?? lastUserMessageFromEvents(events),
|
|
2113
2160
|
todos: extractTodoProgressFromEvents(events),
|
|
2114
2161
|
recentDirectoriesTouched: extractRecentDirectoriesTouched(events, meta.cwd),
|
|
2115
2162
|
...fanOutCounts(events, meta.agent),
|
|
@@ -2201,6 +2248,7 @@ export function upsertSession(meta, content, scan, assistantContent = '') {
|
|
|
2201
2248
|
git_branch: meta.gitBranch ?? null,
|
|
2202
2249
|
topic: meta.topic ?? null,
|
|
2203
2250
|
first_user_message: meta.firstUserMessage ?? null,
|
|
2251
|
+
last_user_message: meta.lastUserMessage ?? null,
|
|
2204
2252
|
label: meta.label ?? null,
|
|
2205
2253
|
message_count: meta.messageCount ?? null,
|
|
2206
2254
|
token_count: meta.tokenCount ?? null,
|
|
@@ -2441,6 +2489,7 @@ export function upsertSessionsBatch(entries) {
|
|
|
2441
2489
|
git_branch: meta.gitBranch ?? null,
|
|
2442
2490
|
topic: meta.topic ?? null,
|
|
2443
2491
|
first_user_message: meta.firstUserMessage ?? null,
|
|
2492
|
+
last_user_message: meta.lastUserMessage ?? null,
|
|
2444
2493
|
label: meta.label ?? null,
|
|
2445
2494
|
message_count: meta.messageCount ?? null,
|
|
2446
2495
|
token_count: meta.tokenCount ?? null,
|
|
@@ -2690,6 +2739,7 @@ function rowToMeta(row) {
|
|
|
2690
2739
|
mode: isSessionRunMode(row.mode) ? row.mode : undefined,
|
|
2691
2740
|
topic: row.topic ?? undefined,
|
|
2692
2741
|
firstUserMessage: row.first_user_message ?? undefined,
|
|
2742
|
+
lastUserMessage: row.last_user_message ?? undefined,
|
|
2693
2743
|
label: row.label ?? undefined,
|
|
2694
2744
|
isTeamOrigin: row.is_team_origin === 1,
|
|
2695
2745
|
prUrl: row.pr_url ?? undefined,
|
|
@@ -3417,6 +3467,68 @@ export function writeSessionSummary(entry) {
|
|
|
3417
3467
|
summary_json = excluded.summary_json
|
|
3418
3468
|
`).run(entry.id, entry.fileMtimeMs, entry.fileSize, SESSION_SUMMARY_EXTRACTOR_VERSION, Date.now(), JSON.stringify(entry.summary));
|
|
3419
3469
|
}
|
|
3470
|
+
function parseTimelineProjection(json) {
|
|
3471
|
+
try {
|
|
3472
|
+
return JSON.parse(json);
|
|
3473
|
+
}
|
|
3474
|
+
catch {
|
|
3475
|
+
return undefined;
|
|
3476
|
+
}
|
|
3477
|
+
}
|
|
3478
|
+
/**
|
|
3479
|
+
* Read the projection AND the resume state for one session, whatever the
|
|
3480
|
+
* transcript stamp — the daemon pass's read, which needs the state to continue
|
|
3481
|
+
* from and re-stats the file itself.
|
|
3482
|
+
*/
|
|
3483
|
+
export function readSessionTimelineEntry(id) {
|
|
3484
|
+
const row = getDB().prepare(`
|
|
3485
|
+
SELECT projection_json AS projectionJson, state_json AS stateJson, computed_at AS computedAt
|
|
3486
|
+
FROM session_timelines
|
|
3487
|
+
WHERE session_id = ? AND extractor_version = ?
|
|
3488
|
+
`).get(id, TIMELINE_EXTRACTOR_VERSION);
|
|
3489
|
+
if (!row)
|
|
3490
|
+
return undefined;
|
|
3491
|
+
const projection = parseTimelineProjection(row.projectionJson);
|
|
3492
|
+
if (!projection)
|
|
3493
|
+
return undefined;
|
|
3494
|
+
try {
|
|
3495
|
+
return { ...projection, state: JSON.parse(row.stateJson), computedAt: row.computedAt };
|
|
3496
|
+
}
|
|
3497
|
+
catch {
|
|
3498
|
+
return undefined;
|
|
3499
|
+
}
|
|
3500
|
+
}
|
|
3501
|
+
/**
|
|
3502
|
+
* Read only the bounded projection for one session — the display merge, on the
|
|
3503
|
+
* read path. Deliberately does NOT parse the resume state: a live row needs the
|
|
3504
|
+
* 8 steps and the request, never the fold's bookkeeping.
|
|
3505
|
+
*/
|
|
3506
|
+
export function readSessionTimelineAny(id) {
|
|
3507
|
+
const row = getDB().prepare(`
|
|
3508
|
+
SELECT projection_json AS projectionJson
|
|
3509
|
+
FROM session_timelines
|
|
3510
|
+
WHERE session_id = ? AND extractor_version = ?
|
|
3511
|
+
`).get(id, TIMELINE_EXTRACTOR_VERSION);
|
|
3512
|
+
if (!row)
|
|
3513
|
+
return undefined;
|
|
3514
|
+
return parseTimelineProjection(row.projectionJson);
|
|
3515
|
+
}
|
|
3516
|
+
/** Persist a folded timeline against the exact transcript bytes it was folded to. */
|
|
3517
|
+
export function writeSessionTimeline(entry) {
|
|
3518
|
+
const { state, ...projection } = entry.timeline;
|
|
3519
|
+
getDB().prepare(`
|
|
3520
|
+
INSERT INTO session_timelines
|
|
3521
|
+
(session_id, file_mtime_ms, file_size, extractor_version, computed_at, projection_json, state_json)
|
|
3522
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
3523
|
+
ON CONFLICT(session_id) DO UPDATE SET
|
|
3524
|
+
file_mtime_ms = excluded.file_mtime_ms,
|
|
3525
|
+
file_size = excluded.file_size,
|
|
3526
|
+
extractor_version = excluded.extractor_version,
|
|
3527
|
+
computed_at = excluded.computed_at,
|
|
3528
|
+
projection_json = excluded.projection_json,
|
|
3529
|
+
state_json = excluded.state_json
|
|
3530
|
+
`).run(entry.id, entry.fileMtimeMs, entry.fileSize, TIMELINE_EXTRACTOR_VERSION, entry.computedAtMs ?? Date.now(), JSON.stringify(projection), JSON.stringify(state));
|
|
3531
|
+
}
|
|
3420
3532
|
/**
|
|
3421
3533
|
* The most-recently-active sessions whose transcript is genuinely local to this
|
|
3422
3534
|
* box — a real `file_path`, not a peer mirror — for publishing to the fleet
|
|
@@ -3444,6 +3556,7 @@ export function queryLocalOriginSessionsForMirror(self, limit) {
|
|
|
3444
3556
|
// Ride the daemon-computed summary alongside the digest so a peer renders it
|
|
3445
3557
|
// without a transcript (PHNX-3939); only a summarized session carries one.
|
|
3446
3558
|
summary: readSessionSummaryAny(r.id) ?? null,
|
|
3559
|
+
timeline: readSessionTimelineAny(r.id) ?? null,
|
|
3447
3560
|
}));
|
|
3448
3561
|
}
|
|
3449
3562
|
/**
|
|
@@ -3525,6 +3638,18 @@ export function upsertMirrorSession(row, source, syncedAt) {
|
|
|
3525
3638
|
if (row.summary) {
|
|
3526
3639
|
writeSessionSummary({ id: row.id, fileMtimeMs: null, fileSize: null, summary: row.summary });
|
|
3527
3640
|
}
|
|
3641
|
+
// Same for the peer's folded timeline (PHNX-3939): the projection is what a row
|
|
3642
|
+
// renders, and the resume state is deliberately EMPTY — this box never folds a
|
|
3643
|
+
// peer's transcript (it does not have one), so storing a foreign byte offset
|
|
3644
|
+
// would be a resume point into a file that is not here.
|
|
3645
|
+
if (row.timeline) {
|
|
3646
|
+
writeSessionTimeline({
|
|
3647
|
+
id: row.id,
|
|
3648
|
+
fileMtimeMs: null,
|
|
3649
|
+
fileSize: null,
|
|
3650
|
+
timeline: { ...row.timeline, state: emptyTimelineState() },
|
|
3651
|
+
});
|
|
3652
|
+
}
|
|
3528
3653
|
return true;
|
|
3529
3654
|
}
|
|
3530
3655
|
/**
|
|
@@ -3542,12 +3667,14 @@ export function pruneMirrorSessions(cutoffMs) {
|
|
|
3542
3667
|
const delText = db.prepare(`DELETE FROM session_text WHERE session_id = ?`);
|
|
3543
3668
|
const delPreview = db.prepare(`DELETE FROM session_preview_cache WHERE session_id = ?`);
|
|
3544
3669
|
const delSummary = db.prepare(`DELETE FROM session_summaries WHERE session_id = ?`);
|
|
3670
|
+
const delTimeline = db.prepare(`DELETE FROM session_timelines WHERE session_id = ?`);
|
|
3545
3671
|
const txn = db.transaction(() => {
|
|
3546
3672
|
for (const { id } of stale) {
|
|
3547
3673
|
delRow.run(id);
|
|
3548
3674
|
delText.run(id);
|
|
3549
3675
|
delPreview.run(id);
|
|
3550
3676
|
delSummary.run(id);
|
|
3677
|
+
delTimeline.run(id);
|
|
3551
3678
|
}
|
|
3552
3679
|
});
|
|
3553
3680
|
txn();
|
|
@@ -26,6 +26,11 @@ export declare const SESSION_MIRROR_MAX_ROWS = 200;
|
|
|
26
26
|
export declare const SESSION_MIRROR_SNIPPET_MAX = 280;
|
|
27
27
|
/** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
|
|
28
28
|
export declare const SESSION_MIRROR_MAX_AGE_MS: number;
|
|
29
|
+
/** Caps for the timeline block on a published mirror row. */
|
|
30
|
+
export declare const SESSION_MIRROR_MAX_STEPS = 8;
|
|
31
|
+
export declare const SESSION_MIRROR_STEP_TEXT_MAX = 160;
|
|
32
|
+
export declare const SESSION_MIRROR_REQUEST_MAX = 2000;
|
|
33
|
+
export declare const SESSION_MIRROR_MAX_FILES = 8;
|
|
29
34
|
export interface PublishSessionMirrorOptions {
|
|
30
35
|
userAgentsDir?: string;
|
|
31
36
|
device?: string;
|
|
@@ -30,6 +30,43 @@ export const SESSION_MIRROR_MAX_ROWS = 200;
|
|
|
30
30
|
export const SESSION_MIRROR_SNIPPET_MAX = 280;
|
|
31
31
|
/** Mirror rows older than this since their last sync are pruned (staleness/size ceiling). */
|
|
32
32
|
export const SESSION_MIRROR_MAX_AGE_MS = 14 * 24 * 60 * 60_000;
|
|
33
|
+
/** Caps for the timeline block on a published mirror row. */
|
|
34
|
+
export const SESSION_MIRROR_MAX_STEPS = 8;
|
|
35
|
+
export const SESSION_MIRROR_STEP_TEXT_MAX = 160;
|
|
36
|
+
export const SESSION_MIRROR_REQUEST_MAX = 2_000;
|
|
37
|
+
export const SESSION_MIRROR_MAX_FILES = 8;
|
|
38
|
+
function cap(value, max) {
|
|
39
|
+
return value.length > max ? value.slice(0, max) : value;
|
|
40
|
+
}
|
|
41
|
+
/** Bound the tidied request: prose capped, chips capped, never re-derived. */
|
|
42
|
+
function boundedRequest(request) {
|
|
43
|
+
return {
|
|
44
|
+
...request,
|
|
45
|
+
text: cap(request.text, SESSION_MIRROR_REQUEST_MAX),
|
|
46
|
+
headline: cap(request.headline, SESSION_MIRROR_STEP_TEXT_MAX),
|
|
47
|
+
...(request.command ? { command: cap(request.command, 120) } : {}),
|
|
48
|
+
attachments: request.attachments.slice(0, SESSION_MIRROR_MAX_FILES)
|
|
49
|
+
.map((a) => ({ kind: a.kind, name: cap(a.name, 120) })),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Bound the step list. Counters are kept exact — only text is truncated. */
|
|
53
|
+
function boundedTimeline(timeline) {
|
|
54
|
+
const steps = timeline.steps.slice(-SESSION_MIRROR_MAX_STEPS).map((step) => ({
|
|
55
|
+
...step,
|
|
56
|
+
text: cap(step.text, SESSION_MIRROR_STEP_TEXT_MAX),
|
|
57
|
+
...(step.now ? { now: cap(step.now, SESSION_MIRROR_STEP_TEXT_MAX) } : {}),
|
|
58
|
+
...(step.marks ? { marks: step.marks.slice(0, 4).map((mark) => cap(mark, 40)) } : {}),
|
|
59
|
+
}));
|
|
60
|
+
return { ...timeline, steps, ...(timeline.reason ? { reason: cap(timeline.reason, 200) } : {}) };
|
|
61
|
+
}
|
|
62
|
+
/** Bound the file rows; `total` still reports the real count. */
|
|
63
|
+
function boundedFiles(files) {
|
|
64
|
+
return {
|
|
65
|
+
...files,
|
|
66
|
+
changes: files.changes.slice(0, SESSION_MIRROR_MAX_FILES)
|
|
67
|
+
.map((change) => ({ ...change, path: cap(change.path, 400) })),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
33
70
|
function snippet(value, max = SESSION_MIRROR_SNIPPET_MAX) {
|
|
34
71
|
if (!value)
|
|
35
72
|
return undefined;
|
|
@@ -83,6 +120,12 @@ export async function publishSessionMirrorToSharedStore(options = {}) {
|
|
|
83
120
|
}
|
|
84
121
|
: {}),
|
|
85
122
|
...(s.summary?.summaryState ? { summaryState: s.summary.summaryState } : {}),
|
|
123
|
+
// Same bound-on-publish discipline as the summary above: the fleet state file
|
|
124
|
+
// is git-synced, so the request text, the step list, and the file rows are
|
|
125
|
+
// capped here rather than shipped raw (PHNX-3939).
|
|
126
|
+
...(s.timeline?.request ? { request: boundedRequest(s.timeline.request) } : {}),
|
|
127
|
+
...(s.timeline?.timeline ? { timeline: boundedTimeline(s.timeline.timeline) } : {}),
|
|
128
|
+
...(s.timeline?.files ? { files: boundedFiles(s.timeline.files) } : {}),
|
|
86
129
|
capturedAt,
|
|
87
130
|
}));
|
|
88
131
|
try {
|
|
@@ -133,8 +176,131 @@ function toUpsert(raw) {
|
|
|
133
176
|
ticketId: cap(r.ticketId, 64),
|
|
134
177
|
prUrl: cap(r.prUrl, 512),
|
|
135
178
|
summary: toMirrorSummary(r),
|
|
179
|
+
timeline: toMirrorTimeline(r),
|
|
136
180
|
};
|
|
137
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Validate an untrusted peer's published request/timeline/files into the bounded
|
|
184
|
+
* projection the local cache stores (PHNX-3939). Bounds mirror the publish side,
|
|
185
|
+
* so a hostile or oversized peer cannot bloat this box's cache. Only a row that
|
|
186
|
+
* carries a well-formed timeline is kept; a malformed one is dropped whole
|
|
187
|
+
* rather than partially trusted.
|
|
188
|
+
*/
|
|
189
|
+
function toMirrorTimeline(r) {
|
|
190
|
+
const timeline = toMirrorTimelineBlock(r.timeline);
|
|
191
|
+
if (!timeline)
|
|
192
|
+
return undefined;
|
|
193
|
+
return {
|
|
194
|
+
timeline,
|
|
195
|
+
...(toMirrorRequest(r.request) ? { request: toMirrorRequest(r.request) } : {}),
|
|
196
|
+
...(toMirrorFiles(r.files) ? { files: toMirrorFiles(r.files) } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
function isRecord(v) {
|
|
200
|
+
return Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
201
|
+
}
|
|
202
|
+
function count(v) {
|
|
203
|
+
return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? Math.floor(v) : 0;
|
|
204
|
+
}
|
|
205
|
+
/** The verb keys a peer's `mix` may carry — anything else is dropped, not trusted. */
|
|
206
|
+
const MIRROR_VERB_CLASSES = [
|
|
207
|
+
'read', 'edit', 'run', 'git', 'test', 'browser', 'agent', 'other',
|
|
208
|
+
];
|
|
209
|
+
/** A peer's per-verb breakdown, whitelisted by key and clamped by value. */
|
|
210
|
+
function toMirrorMix(raw) {
|
|
211
|
+
if (!isRecord(raw))
|
|
212
|
+
return undefined;
|
|
213
|
+
const mix = {};
|
|
214
|
+
for (const verb of MIRROR_VERB_CLASSES) {
|
|
215
|
+
const n = count(raw[verb]);
|
|
216
|
+
if (n > 0)
|
|
217
|
+
mix[verb] = n;
|
|
218
|
+
}
|
|
219
|
+
return Object.keys(mix).length ? mix : undefined;
|
|
220
|
+
}
|
|
221
|
+
function toMirrorTimelineBlock(raw) {
|
|
222
|
+
if (!isRecord(raw))
|
|
223
|
+
return undefined;
|
|
224
|
+
const state = raw.state;
|
|
225
|
+
if (state !== 'ready' && state !== 'partial' && state !== 'unavailable')
|
|
226
|
+
return undefined;
|
|
227
|
+
const steps = (Array.isArray(raw.steps) ? raw.steps : [])
|
|
228
|
+
.filter(isRecord)
|
|
229
|
+
.filter((step) => isString(step.text) && isString(step.at))
|
|
230
|
+
.slice(0, SESSION_MIRROR_MAX_STEPS)
|
|
231
|
+
.map((step) => ({
|
|
232
|
+
text: String(step.text).slice(0, SESSION_MIRROR_STEP_TEXT_MAX),
|
|
233
|
+
at: String(step.at).slice(0, 40),
|
|
234
|
+
...(isString(step.endedAt) ? { endedAt: step.endedAt.slice(0, 40) } : {}),
|
|
235
|
+
source: (step.source === 'thinking' || step.source === 'derived' || step.source === 'user'
|
|
236
|
+
? step.source : 'narration'),
|
|
237
|
+
tools: count(step.tools),
|
|
238
|
+
failed: count(step.failed),
|
|
239
|
+
blocked: count(step.blocked),
|
|
240
|
+
...(isString(step.now) ? { now: step.now.slice(0, SESSION_MIRROR_STEP_TEXT_MAX) } : {}),
|
|
241
|
+
// `mix` and `live` are what the sidebar renders a step WITH ("6 run ·
|
|
242
|
+
// 2 blocked", the amber now-line). Dropping them here made a remote row
|
|
243
|
+
// disagree with the local one about what a live step even is, while `now`
|
|
244
|
+
// survived — so they ride through, whitelisted like every other field.
|
|
245
|
+
...(step.live === true ? { live: true } : {}),
|
|
246
|
+
...(() => {
|
|
247
|
+
const mix = toMirrorMix(step.mix);
|
|
248
|
+
return mix ? { mix } : {};
|
|
249
|
+
})(),
|
|
250
|
+
...(Array.isArray(step.marks)
|
|
251
|
+
? { marks: step.marks.filter(isString).slice(0, 4).map((mark) => mark.slice(0, 40)) }
|
|
252
|
+
: {}),
|
|
253
|
+
}));
|
|
254
|
+
const earlier = isRecord(raw.earlier) ? raw.earlier : {};
|
|
255
|
+
return {
|
|
256
|
+
steps,
|
|
257
|
+
earlier: { steps: count(earlier.steps), tools: count(earlier.tools), failed: count(earlier.failed) },
|
|
258
|
+
tools: count(raw.tools),
|
|
259
|
+
failed: count(raw.failed),
|
|
260
|
+
blocked: count(raw.blocked),
|
|
261
|
+
spanMs: count(raw.spanMs),
|
|
262
|
+
state,
|
|
263
|
+
...(isString(raw.reason) ? { reason: raw.reason.slice(0, 200) } : {}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
function toMirrorRequest(raw) {
|
|
267
|
+
if (!isRecord(raw) || !isString(raw.headline))
|
|
268
|
+
return undefined;
|
|
269
|
+
const kind = raw.kind;
|
|
270
|
+
return {
|
|
271
|
+
text: isString(raw.text) ? raw.text.slice(0, SESSION_MIRROR_REQUEST_MAX) : '',
|
|
272
|
+
headline: raw.headline.slice(0, SESSION_MIRROR_STEP_TEXT_MAX),
|
|
273
|
+
kind: kind === 'image' || kind === 'command' || kind === 'skill' ? kind : 'text',
|
|
274
|
+
...(isString(raw.command) ? { command: raw.command.slice(0, 120) } : {}),
|
|
275
|
+
attachments: (Array.isArray(raw.attachments) ? raw.attachments : [])
|
|
276
|
+
.filter(isRecord)
|
|
277
|
+
.filter((a) => isString(a.name))
|
|
278
|
+
.slice(0, SESSION_MIRROR_MAX_FILES)
|
|
279
|
+
.map((a) => ({
|
|
280
|
+
kind: a.kind === 'image' || a.kind === 'dir' ? a.kind : 'file',
|
|
281
|
+
name: String(a.name).slice(0, 120),
|
|
282
|
+
})),
|
|
283
|
+
pastedLines: count(raw.pastedLines),
|
|
284
|
+
...(typeof raw.turns === 'number' ? { turns: count(raw.turns) } : {}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function toMirrorFiles(raw) {
|
|
288
|
+
if (!isRecord(raw))
|
|
289
|
+
return undefined;
|
|
290
|
+
const changes = (Array.isArray(raw.changes) ? raw.changes : [])
|
|
291
|
+
.filter(isRecord)
|
|
292
|
+
.filter((change) => isString(change.path))
|
|
293
|
+
.slice(0, SESSION_MIRROR_MAX_FILES)
|
|
294
|
+
.map((change) => ({
|
|
295
|
+
path: String(change.path).slice(0, 400),
|
|
296
|
+
op: (change.op === 'created' || change.op === 'deleted' ? change.op : 'modified'),
|
|
297
|
+
edits: count(change.edits),
|
|
298
|
+
at: isString(change.at) ? change.at.slice(0, 40) : '',
|
|
299
|
+
}));
|
|
300
|
+
if (!changes.length)
|
|
301
|
+
return undefined;
|
|
302
|
+
return { changes, total: count(raw.total) || changes.length, source: raw.source === 'harness' ? 'harness' : 'tools' };
|
|
303
|
+
}
|
|
138
304
|
/** One published summary state, validated back into the union or undefined. */
|
|
139
305
|
function toSummaryState(v) {
|
|
140
306
|
return v === 'pending' || v === 'ready' || v === 'skipped' ? v : undefined;
|
|
@@ -33,6 +33,14 @@ export interface ParseSessionOptions {
|
|
|
33
33
|
maxToolOutputChars?: number;
|
|
34
34
|
/** Opt-in because interrupts are not user messages and would change the published event stream. */
|
|
35
35
|
includeInterrupts?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* Opt-in `file_change` events from the harness's own file ledger (Claude
|
|
38
|
+
* `file-history-delta`). Opt-in for the same reason as
|
|
39
|
+
* {@link includeInterrupts}: they are not tool calls, and emitting them by
|
|
40
|
+
* default would change every published event stream (message counts, digests,
|
|
41
|
+
* trajectories) for a signal only the timeline fold consumes.
|
|
42
|
+
*/
|
|
43
|
+
includeFileHistory?: boolean;
|
|
36
44
|
}
|
|
37
45
|
export declare function parseSession(filePath: string, agent?: SessionAgentId, opts?: ParseSessionOptions): SessionEvent[];
|
|
38
46
|
/** Infer the agent type from a session file path using known directory conventions. */
|
|
@@ -75,6 +83,28 @@ export declare function applyPatchTargets(input: string): Array<{
|
|
|
75
83
|
op: 'Add' | 'Update' | 'Delete';
|
|
76
84
|
}>;
|
|
77
85
|
export declare function applyPatchTargetPaths(input: string): string[];
|
|
86
|
+
/**
|
|
87
|
+
* Codex writes its turn TWICE in one rollout: the raw `response_item` records
|
|
88
|
+
* the model exchanged, and a parallel `event_msg` / `item_completed` stream of
|
|
89
|
+
* typed, already-classified items. The two overlap — on a 6,244-line rollout on
|
|
90
|
+
* zion (2026-09-06) the same turn appears as 648 `custom_tool_call` +
|
|
91
|
+
* 60 `function_call` records AND as 1,038 `CommandExecution` items — so the two
|
|
92
|
+
* MUST NOT be merged into one event list: every consumer that counts tool calls
|
|
93
|
+
* (digest, trajectory, insights, the tool index) would double-count.
|
|
94
|
+
*
|
|
95
|
+
* {@link parseCodexContent} therefore keeps reading `response_item` and this is
|
|
96
|
+
* a SEPARATE reader over the item stream, for consumers that want the harness's
|
|
97
|
+
* own classification instead of re-deriving it: the command as Codex parsed it
|
|
98
|
+
* (`parsed_cmd.type`), its `exit_code`/`status`, the per-path `FileChange`
|
|
99
|
+
* ledger, `AgentMessage.phase` (`commentary` is the narration between calls),
|
|
100
|
+
* web searches, sub-agent activity and compactions. The timeline fold is the
|
|
101
|
+
* consumer; `parseSession` is untouched.
|
|
102
|
+
*
|
|
103
|
+
* Version-tolerant by construction: an item type this does not know folds to a
|
|
104
|
+
* generic `other` tool_use rather than throwing, so a Codex release that adds an
|
|
105
|
+
* item type degrades to a counted step instead of an empty timeline.
|
|
106
|
+
*/
|
|
107
|
+
export declare function parseCodexItemsContent(content: string): SessionEvent[];
|
|
78
108
|
/**
|
|
79
109
|
* Parse Codex JSONL *content* (already read into a string) into normalized
|
|
80
110
|
* events. Split from `parseCodex` so the tail reader can parse just the last
|
|
@@ -142,6 +172,25 @@ export declare function sessionFilePathContainer(filePath: string): string;
|
|
|
142
172
|
* timestamps aren't stored, so every event carries the session's `created_at`
|
|
143
173
|
* (from summary.json), falling back to the transcript's mtime.
|
|
144
174
|
*/
|
|
175
|
+
/**
|
|
176
|
+
* Counters Grok writes beside its transcript in `signals.json` — the only place
|
|
177
|
+
* a Grok session records milestones and failures, since `chat_history.jsonl`
|
|
178
|
+
* carries no timestamps and no exit codes. Read by the timeline fold to mark a
|
|
179
|
+
* session that committed / opened / merged a PR and to report a tool-failure
|
|
180
|
+
* count the event stream cannot supply.
|
|
181
|
+
*/
|
|
182
|
+
export interface GrokSessionSignals {
|
|
183
|
+
gitCommitCount: number;
|
|
184
|
+
prCreatedCount: number;
|
|
185
|
+
prMergedCount: number;
|
|
186
|
+
toolFailureCount: number;
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Read `signals.json` next to a Grok transcript. Returns `undefined` when the
|
|
190
|
+
* file is absent or unreadable — a Grok session simply has no signals then, and
|
|
191
|
+
* the fold reports what it does have rather than inventing zeros.
|
|
192
|
+
*/
|
|
193
|
+
export declare function readGrokSignals(filePath: string): GrokSessionSignals | undefined;
|
|
145
194
|
export declare function parseGrok(filePath: string): SessionEvent[];
|
|
146
195
|
/**
|
|
147
196
|
* The transcript query {@link parseOpenCode} runs, exported so a test can assert
|