@phnx-labs/agents-cli 1.20.74 → 1.20.77
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 +1055 -0
- package/README.md +36 -10
- package/dist/bin/agents +0 -0
- package/dist/commands/apply.js +21 -3
- package/dist/commands/attach.d.ts +10 -0
- package/dist/commands/attach.js +41 -0
- package/dist/commands/computer.js +3 -3
- package/dist/commands/detach-core.d.ts +46 -0
- package/dist/commands/detach-core.js +61 -0
- package/dist/commands/detach.d.ts +5 -0
- package/dist/commands/detach.js +171 -0
- package/dist/commands/doctor.js +18 -0
- package/dist/commands/exec.js +83 -13
- package/dist/commands/feed.d.ts +7 -1
- package/dist/commands/feed.js +47 -4
- package/dist/commands/go.d.ts +9 -2
- package/dist/commands/go.js +16 -6
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +145 -0
- package/dist/commands/import.js +38 -8
- package/dist/commands/inspect.js +5 -2
- package/dist/commands/profiles.d.ts +20 -0
- package/dist/commands/profiles.js +68 -21
- package/dist/commands/repo.js +14 -0
- package/dist/commands/routines.js +286 -15
- package/dist/commands/secrets.js +102 -37
- package/dist/commands/sessions-browser.js +12 -2
- package/dist/commands/sessions-export.js +19 -5
- package/dist/commands/sessions-migrate.d.ts +29 -0
- package/dist/commands/sessions-migrate.js +596 -0
- package/dist/commands/sessions-picker.d.ts +14 -1
- package/dist/commands/sessions-picker.js +184 -16
- package/dist/commands/sessions.d.ts +56 -14
- package/dist/commands/sessions.js +322 -62
- package/dist/commands/setup-computer.js +2 -2
- package/dist/commands/ssh.d.ts +13 -0
- package/dist/commands/ssh.js +110 -37
- package/dist/commands/status.js +10 -2
- package/dist/commands/versions.js +15 -6
- package/dist/commands/view.d.ts +5 -0
- package/dist/commands/view.js +24 -4
- package/dist/commands/watchdog.d.ts +7 -2
- package/dist/commands/watchdog.js +73 -57
- package/dist/index.js +59 -7
- package/dist/lib/activity.d.ts +17 -6
- package/dist/lib/activity.js +466 -40
- package/dist/lib/actor.d.ts +50 -0
- package/dist/lib/actor.js +166 -0
- package/dist/lib/agent-spec/provider.js +2 -1
- package/dist/lib/agent-spec/resolve.js +19 -5
- package/dist/lib/agent-spec/types.d.ts +9 -1
- package/dist/lib/agents.d.ts +21 -0
- package/dist/lib/agents.js +70 -6
- package/dist/lib/cloud/codex.d.ts +2 -0
- package/dist/lib/cloud/codex.js +14 -3
- package/dist/lib/cloud/session-index.d.ts +32 -0
- package/dist/lib/cloud/session-index.js +58 -0
- package/dist/lib/cloud/store.d.ts +7 -0
- package/dist/lib/cloud/store.js +25 -0
- package/dist/lib/config-transfer.js +4 -0
- package/dist/lib/daemon.d.ts +10 -1
- package/dist/lib/daemon.js +114 -11
- package/dist/lib/devices/connect.d.ts +15 -1
- package/dist/lib/devices/connect.js +15 -1
- package/dist/lib/devices/fleet.d.ts +37 -1
- package/dist/lib/devices/fleet.js +36 -2
- package/dist/lib/devices/health-report.d.ts +38 -0
- package/dist/lib/devices/health-report.js +214 -0
- package/dist/lib/devices/health.js +4 -1
- package/dist/lib/devices/reachability.d.ts +31 -0
- package/dist/lib/devices/reachability.js +40 -0
- package/dist/lib/devices/registry.d.ts +33 -0
- package/dist/lib/devices/registry.js +37 -0
- package/dist/lib/devices/resolve-target.d.ts +15 -27
- package/dist/lib/devices/resolve-target.js +63 -102
- package/dist/lib/devices/sync.d.ts +18 -0
- package/dist/lib/devices/sync.js +23 -1
- package/dist/lib/devices/tailscale.d.ts +3 -0
- package/dist/lib/devices/tailscale.js +1 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/exec.d.ts +82 -0
- package/dist/lib/exec.js +218 -6
- package/dist/lib/feed-post.d.ts +63 -0
- package/dist/lib/feed-post.js +204 -0
- package/dist/lib/feed.d.ts +7 -1
- package/dist/lib/feed.js +96 -6
- package/dist/lib/fleet/apply.d.ts +32 -2
- package/dist/lib/fleet/apply.js +97 -10
- package/dist/lib/fleet/types.d.ts +11 -0
- package/dist/lib/fs-walk.d.ts +13 -0
- package/dist/lib/fs-walk.js +16 -7
- package/dist/lib/heal.d.ts +7 -4
- package/dist/lib/heal.js +10 -22
- package/dist/lib/hooks.d.ts +1 -0
- package/dist/lib/hooks.js +156 -0
- package/dist/lib/hosts/dispatch.d.ts +16 -0
- package/dist/lib/hosts/dispatch.js +26 -4
- package/dist/lib/hosts/passthrough.js +9 -2
- package/dist/lib/hosts/reconnect.d.ts +78 -0
- package/dist/lib/hosts/reconnect.js +127 -0
- package/dist/lib/hosts/registry.d.ts +75 -14
- package/dist/lib/hosts/registry.js +205 -30
- package/dist/lib/hosts/remote-cmd.d.ts +11 -0
- package/dist/lib/hosts/remote-cmd.js +32 -8
- package/dist/lib/hosts/remote-session-id.d.ts +45 -0
- package/dist/lib/hosts/remote-session-id.js +84 -0
- package/dist/lib/hosts/run-target.d.ts +17 -5
- package/dist/lib/hosts/run-target.js +30 -10
- package/dist/lib/hosts/session-index.d.ts +18 -1
- package/dist/lib/hosts/session-index.js +40 -5
- package/dist/lib/hosts/session-marker.d.ts +33 -0
- package/dist/lib/hosts/session-marker.js +51 -0
- package/dist/lib/hosts/tasks.d.ts +8 -4
- package/dist/lib/import.d.ts +2 -0
- package/dist/lib/import.js +35 -2
- package/dist/lib/menubar/install-menubar.d.ts +29 -0
- package/dist/lib/menubar/install-menubar.js +59 -1
- package/dist/lib/menubar/notify-desktop.d.ts +44 -0
- package/dist/lib/menubar/notify-desktop.js +78 -0
- package/dist/lib/migrate.d.ts +16 -0
- package/dist/lib/migrate.js +36 -0
- package/dist/lib/models.d.ts +27 -0
- package/dist/lib/models.js +54 -1
- package/dist/lib/overdue.d.ts +6 -2
- package/dist/lib/overdue.js +10 -36
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/process.d.ts +17 -0
- package/dist/lib/platform/process.js +70 -0
- package/dist/lib/profiles-presets.js +9 -7
- package/dist/lib/profiles.d.ts +31 -0
- package/dist/lib/profiles.js +70 -0
- package/dist/lib/pty-server.d.ts +2 -10
- package/dist/lib/pty-server.js +4 -38
- package/dist/lib/registry.d.ts +1 -1
- package/dist/lib/registry.js +48 -8
- package/dist/lib/rotate.d.ts +18 -0
- package/dist/lib/rotate.js +28 -0
- package/dist/lib/routine-notify.d.ts +76 -0
- package/dist/lib/routine-notify.js +190 -0
- package/dist/lib/routines-placement.d.ts +38 -0
- package/dist/lib/routines-placement.js +79 -0
- package/dist/lib/routines-project.d.ts +97 -0
- package/dist/lib/routines-project.js +349 -0
- package/dist/lib/routines.d.ts +94 -0
- package/dist/lib/routines.js +93 -9
- package/dist/lib/runner.d.ts +12 -2
- package/dist/lib/runner.js +242 -20
- package/dist/lib/sandbox.js +10 -0
- package/dist/lib/secrets/account-token.d.ts +20 -0
- package/dist/lib/secrets/account-token.js +64 -0
- package/dist/lib/secrets/agent.d.ts +74 -4
- package/dist/lib/secrets/agent.js +181 -107
- package/dist/lib/secrets/bundles.d.ts +54 -6
- package/dist/lib/secrets/bundles.js +230 -34
- package/dist/lib/secrets/index.d.ts +26 -3
- package/dist/lib/secrets/index.js +42 -12
- package/dist/lib/secrets/rc-hygiene.d.ts +55 -0
- package/dist/lib/secrets/rc-hygiene.js +154 -0
- package/dist/lib/secrets/remote.d.ts +7 -4
- package/dist/lib/secrets/remote.js +7 -4
- package/dist/lib/secrets/session-store.d.ts +6 -2
- package/dist/lib/secrets/session-store.js +65 -15
- package/dist/lib/secrets/sync-commands.d.ts +21 -0
- package/dist/lib/secrets/sync-commands.js +21 -0
- package/dist/lib/secrets/unlock-hints.d.ts +27 -0
- package/dist/lib/secrets/unlock-hints.js +36 -0
- package/dist/lib/self-update.d.ts +23 -2
- package/dist/lib/self-update.js +86 -5
- package/dist/lib/session/active.d.ts +124 -7
- package/dist/lib/session/active.js +390 -56
- package/dist/lib/session/cloud.js +2 -1
- package/dist/lib/session/db.d.ts +54 -0
- package/dist/lib/session/db.js +320 -19
- package/dist/lib/session/detached.d.ts +30 -0
- package/dist/lib/session/detached.js +92 -0
- package/dist/lib/session/discover.d.ts +478 -3
- package/dist/lib/session/discover.js +1740 -472
- package/dist/lib/session/fork.js +2 -1
- package/dist/lib/session/hook-sessions.d.ts +43 -0
- package/dist/lib/session/hook-sessions.js +135 -0
- package/dist/lib/session/linear.d.ts +6 -0
- package/dist/lib/session/linear.js +55 -0
- package/dist/lib/session/migrate-targets.d.ts +65 -0
- package/dist/lib/session/migrate-targets.js +94 -0
- package/dist/lib/session/migrations.d.ts +37 -0
- package/dist/lib/session/migrations.js +60 -0
- package/dist/lib/session/parse.d.ts +18 -0
- package/dist/lib/session/parse.js +141 -32
- package/dist/lib/session/pid-registry.d.ts +34 -2
- package/dist/lib/session/pid-registry.js +49 -2
- package/dist/lib/session/prompt.d.ts +7 -0
- package/dist/lib/session/prompt.js +37 -0
- package/dist/lib/session/provenance.d.ts +7 -0
- package/dist/lib/session/render.d.ts +7 -2
- package/dist/lib/session/render.js +40 -26
- package/dist/lib/session/short-id.d.ts +17 -0
- package/dist/lib/session/short-id.js +20 -0
- package/dist/lib/session/state.d.ts +4 -0
- package/dist/lib/session/state.js +99 -13
- package/dist/lib/session/types.d.ts +25 -1
- package/dist/lib/session/types.js +8 -0
- package/dist/lib/shims.d.ts +1 -1
- package/dist/lib/shims.js +20 -6
- package/dist/lib/sqlite.d.ts +3 -2
- package/dist/lib/sqlite.js +27 -4
- package/dist/lib/staleness/detectors/commands.js +1 -1
- package/dist/lib/staleness/detectors/workflows.js +13 -2
- package/dist/lib/staleness/writers/commands.js +7 -7
- package/dist/lib/staleness/writers/workflows.d.ts +4 -2
- package/dist/lib/startup/command-registry.d.ts +1 -0
- package/dist/lib/startup/command-registry.js +3 -0
- package/dist/lib/state.d.ts +8 -4
- package/dist/lib/state.js +21 -37
- package/dist/lib/teams/agents.d.ts +19 -10
- package/dist/lib/teams/agents.js +40 -39
- package/dist/lib/types.d.ts +47 -2
- package/dist/lib/usage.d.ts +33 -1
- package/dist/lib/usage.js +172 -12
- package/dist/lib/versions.d.ts +9 -2
- package/dist/lib/versions.js +37 -8
- package/dist/lib/watchdog/log.d.ts +43 -0
- package/dist/lib/watchdog/log.js +69 -0
- package/dist/lib/watchdog/routine.d.ts +44 -0
- package/dist/lib/watchdog/routine.js +69 -0
- package/dist/lib/watchdog/runner.d.ts +51 -7
- package/dist/lib/watchdog/runner.js +239 -64
- package/dist/lib/watchdog/watchdog.d.ts +1 -1
- package/dist/lib/watchdog/watchdog.js +31 -16
- package/dist/lib/workflows.d.ts +16 -0
- package/dist/lib/workflows.js +110 -1
- package/package.json +1 -1
|
@@ -14,20 +14,23 @@ import * as readline from 'readline';
|
|
|
14
14
|
import { execFile } from 'child_process';
|
|
15
15
|
import { promisify } from 'util';
|
|
16
16
|
import Database from '../sqlite.js';
|
|
17
|
-
import { getAgentsDir, getHistoryDir, getRunsDir } from '../state.js';
|
|
17
|
+
import { getAgentsDir, getUserAgentsDir, getHistoryDir, getRunsDir } from '../state.js';
|
|
18
|
+
import { shortCodexHome } from '../codex-home.js';
|
|
18
19
|
const execFileAsync = promisify(execFile);
|
|
19
20
|
import { AGENTS, agentConfigDirName, getCliVersion } from '../agents.js';
|
|
20
|
-
import {
|
|
21
|
+
import { walkForFilesWithStat } from '../fs-walk.js';
|
|
21
22
|
import { getConfigSymlinkVersion } from '../shims.js';
|
|
22
23
|
import { SESSION_AGENTS } from './types.js';
|
|
24
|
+
import { deriveShortId } from './short-id.js';
|
|
23
25
|
import { extractSessionTopic } from './prompt.js';
|
|
24
26
|
import { parseAntigravity } from './parse.js';
|
|
25
|
-
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket } from './state.js';
|
|
27
|
+
import { extractPrUrl, detectWorktree, detectTicket, isPrCreateCommand, detectSpawnedTeam, isTicketCreateTool, extractCreatedTicket, extractRecentDirectoriesTouched, extractTodoProgressFromEvents } from './state.js';
|
|
26
28
|
import { costOfUsage } from '../pricing/index.js';
|
|
27
29
|
import { machineId } from './sync/config.js';
|
|
28
30
|
import { mapBounded } from '../concurrency.js';
|
|
29
|
-
import { getDB, getScanStampByPath, getScanStampsForPaths, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, } from './db.js';
|
|
31
|
+
import { getDB, getScanStampByPath, getScanStampsForPaths, getParserStatesForPaths, getDirLedgerForPaths, recordDirScans, recordScans, syncLabels, seedLabelsFromNames, syncTopics, upsertSessionsBatch, querySessions, countSessions, ftsSearch, tryClaimScan, releaseScan, cacheLinearProject, } from './db.js';
|
|
30
32
|
import { buildRunNameMap } from './run-names.js';
|
|
33
|
+
import { resolveLinearApiKey } from '../auto-dispatch-linear.js';
|
|
31
34
|
const HOME = os.homedir();
|
|
32
35
|
// Versions can live under either repo: the user repo (current canonical
|
|
33
36
|
// location, ~/.agents/.history/versions/) or the system repo (legacy / npm-shipped,
|
|
@@ -41,6 +44,26 @@ const HERMES_SESSIONS_DIR = path.join(HOME, '.hermes', 'sessions');
|
|
|
41
44
|
/** How long OpenClaw channel/cron snapshots stay valid before we re-shell-out. */
|
|
42
45
|
const OPENCLAW_TTL_MS = 60_000;
|
|
43
46
|
const ACTIVE_APPEND_RESCAN_DEBOUNCE_MS = 5_000;
|
|
47
|
+
/**
|
|
48
|
+
* How recently a file must have been scanned to be treated as "hot" — a
|
|
49
|
+
* candidate for an in-place append even when its parent dir's mtime hasn't
|
|
50
|
+
* moved. A dir-ledger match lets us skip the per-file stat of everything in a
|
|
51
|
+
* leaf dir EXCEPT its hot set; a file is hot if it lives under the agent's live
|
|
52
|
+
* `~/.<agent>` root (the only tree an agent appends to live) or was scanned
|
|
53
|
+
* within this window. 10 minutes comfortably covers a session that paused
|
|
54
|
+
* between `agents sessions` calls but is still being written to.
|
|
55
|
+
*/
|
|
56
|
+
const HOT_FILE_WINDOW_MS = 600_000;
|
|
57
|
+
/**
|
|
58
|
+
* Kill-switch: set `AGENTS_SESSIONS_NO_DIR_LEDGER=1` to force the old full-walk
|
|
59
|
+
* path (readdir + per-file stat every dir, every run — the pre-A-2 behavior),
|
|
60
|
+
* skipping the dir_ledger short-circuit entirely. One env var reverts a field
|
|
61
|
+
* regression to today's behavior.
|
|
62
|
+
*/
|
|
63
|
+
function dirLedgerDisabled() {
|
|
64
|
+
const v = process.env.AGENTS_SESSIONS_NO_DIR_LEDGER;
|
|
65
|
+
return v === '1' || v === 'true';
|
|
66
|
+
}
|
|
44
67
|
let cachedOpenClawWorkspaces = null;
|
|
45
68
|
const cachedAgentVersions = new Map();
|
|
46
69
|
/**
|
|
@@ -77,9 +100,97 @@ export async function discoverSessions(options) {
|
|
|
77
100
|
}
|
|
78
101
|
}
|
|
79
102
|
const sessions = querySessions(buildQueryOptions(options, agents, { includeLimit: true }));
|
|
103
|
+
await resolveLinearProjects(sessions);
|
|
80
104
|
for (const s of sessions)
|
|
81
105
|
s.machine = machineForSessionFile(s.filePath, s.agent);
|
|
82
|
-
return sessions;
|
|
106
|
+
return scopeToManaged(sessions, agents, options);
|
|
107
|
+
}
|
|
108
|
+
const linearProjectCache = new Map();
|
|
109
|
+
async function resolveLinearProjects(sessions) {
|
|
110
|
+
const apiKey = resolveLinearApiKey();
|
|
111
|
+
if (!apiKey)
|
|
112
|
+
return;
|
|
113
|
+
await Promise.all(sessions.map(async (session) => {
|
|
114
|
+
if (!session.ticketId || session.linearProject)
|
|
115
|
+
return;
|
|
116
|
+
let project = linearProjectCache.get(session.ticketId);
|
|
117
|
+
if (project === undefined) {
|
|
118
|
+
try {
|
|
119
|
+
const response = await fetch('https://api.linear.app/graphql', {
|
|
120
|
+
method: 'POST',
|
|
121
|
+
signal: AbortSignal.timeout(3_000),
|
|
122
|
+
headers: { Authorization: apiKey, 'Content-Type': 'application/json' },
|
|
123
|
+
body: JSON.stringify({
|
|
124
|
+
query: `query($id:String!){ issue(id:$id){ project{ name url } } }`,
|
|
125
|
+
variables: { id: session.ticketId },
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
if (!response.ok)
|
|
129
|
+
throw new Error(String(response.status));
|
|
130
|
+
const body = await response.json();
|
|
131
|
+
const node = body.data?.issue?.project;
|
|
132
|
+
project = node?.name && node?.url ? { name: node.name, url: node.url } : null;
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
project = null;
|
|
136
|
+
}
|
|
137
|
+
linearProjectCache.set(session.ticketId, project);
|
|
138
|
+
}
|
|
139
|
+
if (!project)
|
|
140
|
+
return;
|
|
141
|
+
session.linearProject = project.name;
|
|
142
|
+
session.linearProjectUrl = project.url;
|
|
143
|
+
cacheLinearProject(session.id, project.name, project.url);
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Drop unmanaged rows for agents that HAVE managed versions.
|
|
148
|
+
*
|
|
149
|
+
* Scoping happens here, at query time, rather than by narrowing the scan: the index
|
|
150
|
+
* stays complete, so `--unmanaged` needs no re-scan and every other consumer of the
|
|
151
|
+
* DB (watchdog, the Factory watcher, `--roots`) is unaffected.
|
|
152
|
+
*
|
|
153
|
+
* An agent with no managed versions is left alone entirely — someone who has never
|
|
154
|
+
* run `agents add` sees exactly what they saw before.
|
|
155
|
+
*/
|
|
156
|
+
function scopeToManaged(sessions, agents, options) {
|
|
157
|
+
if (options?.includeUnmanaged)
|
|
158
|
+
return sessions;
|
|
159
|
+
if (!anyManagedVersions())
|
|
160
|
+
return sessions;
|
|
161
|
+
const kept = sessions.filter((s) => isManagedSessionFile(s.filePath));
|
|
162
|
+
const hidden = sessions.length - kept.length;
|
|
163
|
+
if (hidden > 0)
|
|
164
|
+
options?.onHiddenUnmanaged?.(hidden);
|
|
165
|
+
return kept;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* True once agents-cli manages ANY agent version. Until then it manages nothing, so
|
|
169
|
+
* scoping to "managed only" would leave the listing empty for a user who has never
|
|
170
|
+
* run `agents add` — the browser is most of the tool's value before you install
|
|
171
|
+
* anything through it.
|
|
172
|
+
*/
|
|
173
|
+
function anyManagedVersions() {
|
|
174
|
+
for (const root of VERSIONS_ROOTS) {
|
|
175
|
+
const base = path.join(root, 'versions');
|
|
176
|
+
let agentDirs;
|
|
177
|
+
try {
|
|
178
|
+
agentDirs = fs.readdirSync(base, { withFileTypes: true });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
for (const a of agentDirs) {
|
|
184
|
+
if (!a.isDirectory())
|
|
185
|
+
continue;
|
|
186
|
+
try {
|
|
187
|
+
if (fs.readdirSync(path.join(base, a.name), { withFileTypes: true }).some((e) => e.isDirectory()))
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
catch { /* unreadable */ }
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return false;
|
|
83
194
|
}
|
|
84
195
|
/**
|
|
85
196
|
* How many agents' dotfile dirs we scan at once, and the minimum spacing between
|
|
@@ -108,6 +219,7 @@ function dispatchAgentScan(agent, onProgress) {
|
|
|
108
219
|
case 'hermes': return scanHermesIncremental(onProgress);
|
|
109
220
|
case 'kimi': return scanKimiIncremental(onProgress);
|
|
110
221
|
case 'droid': return scanDroidIncremental(onProgress);
|
|
222
|
+
case 'grok': return scanGrokIncremental(onProgress);
|
|
111
223
|
default: return Promise.resolve();
|
|
112
224
|
}
|
|
113
225
|
}
|
|
@@ -119,6 +231,45 @@ let _localMachineId;
|
|
|
119
231
|
* when the path sits under the agent's backups root, the first segment below it
|
|
120
232
|
* is the origin machine id; otherwise it's the local machine.
|
|
121
233
|
*/
|
|
234
|
+
/**
|
|
235
|
+
* True when this transcript belongs to a version agents-cli manages — i.e. it lives
|
|
236
|
+
* under a version home (or a backup mirror of one) rather than in the user's own
|
|
237
|
+
* `~/.<agent>`.
|
|
238
|
+
*
|
|
239
|
+
* `agents sessions` scans both, which is right for indexing: the DB stays a complete
|
|
240
|
+
* picture and `--unmanaged` can surface everything without a re-scan. But listing
|
|
241
|
+
* *by default* is a different question. Once you have managed versions, an unmanaged
|
|
242
|
+
* install's history is not really agents-cli's to show — most visibly after
|
|
243
|
+
* `agents add --isolated`, where the whole point was to keep the two apart.
|
|
244
|
+
*/
|
|
245
|
+
export function isManagedSessionFile(filePath) {
|
|
246
|
+
// Synthetic rows (OpenClaw workspace sessions, cloud/remote entries) have no local
|
|
247
|
+
// transcript to classify. They are produced BY agents-cli rather than read out of
|
|
248
|
+
// someone's dotfile dir, so scoping must not silently swallow them.
|
|
249
|
+
if (!filePath || !path.isAbsolute(filePath))
|
|
250
|
+
return true;
|
|
251
|
+
const roots = [
|
|
252
|
+
...VERSIONS_ROOTS.map((root) => path.join(root, 'versions')),
|
|
253
|
+
path.join(getHistoryDir(), 'backups'),
|
|
254
|
+
// Codex's managed home is not always under versions/. On macOS the versioned path
|
|
255
|
+
// overflows SUN_LEN for codex's control socket, so the shim relocates it to
|
|
256
|
+
// `<agentsUserDir>/.codex-homes/<version>/` (lib/codex-home.ts).
|
|
257
|
+
path.join(getUserAgentsDir(), '.codex-homes'),
|
|
258
|
+
// Routine archives are agents-cli's OWN run output — managed by definition.
|
|
259
|
+
getRunsDir(),
|
|
260
|
+
];
|
|
261
|
+
// Compare realpaths as well as the literal roots. A transcript's stored path is
|
|
262
|
+
// resolved, so on macOS (`/var` -> `/private/var`) a temp-dir HOME yields
|
|
263
|
+
// `/private/var/...` for the file and `/var/...` for the root, and a plain prefix
|
|
264
|
+
// test silently classifies every managed session as the user's own.
|
|
265
|
+
const real = safeRealpathSync(filePath) || filePath;
|
|
266
|
+
return roots.some((root) => {
|
|
267
|
+
if (filePath.startsWith(root + path.sep))
|
|
268
|
+
return true;
|
|
269
|
+
const realRoot = safeRealpathSync(root);
|
|
270
|
+
return !!realRoot && real.startsWith(realRoot + path.sep);
|
|
271
|
+
});
|
|
272
|
+
}
|
|
122
273
|
export function machineForSessionFile(filePath, agent) {
|
|
123
274
|
const base = path.join(getHistoryDir(), 'backups', agent) + path.sep;
|
|
124
275
|
if (filePath.startsWith(base)) {
|
|
@@ -168,12 +319,74 @@ function buildQueryOptions(options, agents, opts) {
|
|
|
168
319
|
sortBy: options?.sortBy,
|
|
169
320
|
};
|
|
170
321
|
}
|
|
171
|
-
/**
|
|
322
|
+
/**
|
|
323
|
+
* Canonicalize a working directory path (follows symlinks when it is local).
|
|
324
|
+
*
|
|
325
|
+
* Most callers pass a cwd RECORDED in a transcript, which may name a directory
|
|
326
|
+
* on another machine — a POSIX path read on a Windows host, say. `path.resolve()`
|
|
327
|
+
* rebases such a path onto the current drive (`/Users/me` -> `D:\Users\me`),
|
|
328
|
+
* inventing a location that never existed. So an absolute path is normalized but
|
|
329
|
+
* never rebased; only a genuinely relative one resolves against the process cwd.
|
|
330
|
+
*
|
|
331
|
+
* `path.normalize()` still runs on every branch: it collapses `.`, `..`, and
|
|
332
|
+
* duplicate separators, and folds separators on Windows. Both sides of the cwd
|
|
333
|
+
* filter in `db.ts` (`cwd = ?` and `cwd LIKE ? || path.sep || '%'`) come through
|
|
334
|
+
* here, so dropping that would leave a trailing slash or a `..` segment in one
|
|
335
|
+
* side and match nothing.
|
|
336
|
+
*
|
|
337
|
+
* Realpath is attempted only for a path that is absolute in THIS platform's
|
|
338
|
+
* terms. A POSIX-rooted path on Windows is drive-relative to `fs.realpathSync`,
|
|
339
|
+
* which would resolve `/Users/me` against the current drive and reintroduce the
|
|
340
|
+
* graft for any path that happens to exist locally.
|
|
341
|
+
*/
|
|
342
|
+
export function _normalizeCwdForTest(cwd) {
|
|
343
|
+
return normalizeCwd(cwd);
|
|
344
|
+
}
|
|
172
345
|
function normalizeCwd(cwd) {
|
|
173
346
|
if (!cwd)
|
|
174
347
|
return '';
|
|
175
|
-
|
|
176
|
-
|
|
348
|
+
// A POSIX-rooted path on Windows belongs to another machine. Normalize it with
|
|
349
|
+
// POSIX rules so its separators survive — path.win32.normalize would fold them
|
|
350
|
+
// to backslashes, mangling the very path we are trying to preserve — and never
|
|
351
|
+
// realpath it, since fs.realpathSync would resolve it against the current drive.
|
|
352
|
+
if (process.platform === 'win32' && /^\//.test(cwd) && !/^[a-zA-Z]:/.test(cwd)) {
|
|
353
|
+
return stripTrailingSep(path.posix.normalize(cwd));
|
|
354
|
+
}
|
|
355
|
+
const normalized = path.isAbsolute(cwd) ? stripTrailingSep(path.normalize(cwd)) : path.resolve(cwd);
|
|
356
|
+
return safeRealpathSync(normalized) || normalized;
|
|
357
|
+
}
|
|
358
|
+
/** Drop a trailing separator so `cwd = ?` and the `cwd LIKE ? + sep` subdir
|
|
359
|
+
* wildcard agree; a root path (`/`, `C:\`) keeps its separator. */
|
|
360
|
+
function stripTrailingSep(p) {
|
|
361
|
+
const stripped = p.replace(/[\\/]+$/, '');
|
|
362
|
+
return stripped.length > 0 && !/^[a-zA-Z]:$/.test(stripped) ? stripped : p;
|
|
363
|
+
}
|
|
364
|
+
/** Canonical 8-4-4-4-12 hex UUID (covers both v4 and the v7 ids newer harnesses mint). */
|
|
365
|
+
const UUID_36 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
366
|
+
/** kimi and rush mint `session_` + a UUID. */
|
|
367
|
+
const SESSION_UUID_PREFIX = /^session_/;
|
|
368
|
+
/** opencode mints `ses_` + a 26-char ULID — NOT a UUID, so it needs its own shape. */
|
|
369
|
+
const SES_ULID = /^ses_[0-9a-z]{26}$/;
|
|
370
|
+
/**
|
|
371
|
+
* Whether a query is a session id in full, rather than an id prefix or a search
|
|
372
|
+
* phrase.
|
|
373
|
+
*
|
|
374
|
+
* Callers use this to decide that an id lookup is the ONLY admissible
|
|
375
|
+
* interpretation: a complete id is unique, so when it misses there is nothing
|
|
376
|
+
* left to widen to. Without the check, `sessions <uuid>` fell through to the
|
|
377
|
+
* FTS content search, which tokenizes the UUID and matches every transcript that
|
|
378
|
+
* merely mentions it — surfacing unrelated sessions as if they were id matches.
|
|
379
|
+
*
|
|
380
|
+
* The accepted shapes are the ones the index actually holds, measured over a
|
|
381
|
+
* 12,507-row index: a bare UUID (11,116 rows), `session_` + UUID (1,360 — kimi
|
|
382
|
+
* and rush), and `ses_` + ULID (15 — opencode). Deliberately NOT covered, so a
|
|
383
|
+
* miss keeps today's search behavior rather than gaining a wrong error: routine
|
|
384
|
+
* run ids (ISO timestamps, matched via `routineRunId` below) and cloud execution
|
|
385
|
+
* ids, whose charset is too permissive to distinguish from a search phrase.
|
|
386
|
+
*/
|
|
387
|
+
export function isCompleteSessionId(query) {
|
|
388
|
+
const q = query.trim().toLowerCase();
|
|
389
|
+
return UUID_36.test(q.replace(SESSION_UUID_PREFIX, '')) || SES_ULID.test(q);
|
|
177
390
|
}
|
|
178
391
|
/**
|
|
179
392
|
* Resolve a session by full or short ID. Accepts a pre-loaded session list
|
|
@@ -230,26 +443,39 @@ export function searchContentIndex(sessions, query) {
|
|
|
230
443
|
* growing transcript over and over. The cached row is good enough for a few
|
|
231
444
|
* seconds; once writes settle or the debounce expires, the file is parsed once.
|
|
232
445
|
*/
|
|
233
|
-
function filterChangedFiles(filePaths) {
|
|
234
|
-
const
|
|
235
|
-
const out = [];
|
|
236
|
-
const now = Date.now();
|
|
446
|
+
export function filterChangedFiles(filePaths) {
|
|
447
|
+
const entries = [];
|
|
237
448
|
for (const filePath of filePaths) {
|
|
238
449
|
const stat = safeStatSync(filePath);
|
|
239
450
|
if (!stat)
|
|
240
451
|
continue;
|
|
452
|
+
entries.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
|
|
453
|
+
}
|
|
454
|
+
return filterChangedEntries(entries);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Ledger-compare pre-stat'd entries (from the walk's own stat) without a second
|
|
458
|
+
* stat. Same debounce and change-detection as filterChangedFiles; the raw
|
|
459
|
+
* mtime is floored here so warm files match the ledger exactly as the stat path
|
|
460
|
+
* does (Math.floor(stat.mtimeMs)).
|
|
461
|
+
*/
|
|
462
|
+
export function filterChangedEntries(entries) {
|
|
463
|
+
const ledger = getScanStampsForPaths(entries.map(e => e.filePath));
|
|
464
|
+
const out = [];
|
|
465
|
+
const now = Date.now();
|
|
466
|
+
for (const entry of entries) {
|
|
241
467
|
const scan = {
|
|
242
|
-
fileMtimeMs: Math.floor(
|
|
243
|
-
fileSize:
|
|
468
|
+
fileMtimeMs: Math.floor(entry.fileMtimeMs),
|
|
469
|
+
fileSize: entry.fileSize,
|
|
244
470
|
};
|
|
245
|
-
const prev = ledger.get(filePath);
|
|
471
|
+
const prev = ledger.get(entry.filePath);
|
|
246
472
|
if (prev && prev.fileMtimeMs === scan.fileMtimeMs && prev.fileSize === scan.fileSize) {
|
|
247
473
|
continue;
|
|
248
474
|
}
|
|
249
475
|
if (prev && shouldDeferRecentAppend(prev, scan, now)) {
|
|
250
476
|
continue;
|
|
251
477
|
}
|
|
252
|
-
out.push({ filePath, scan });
|
|
478
|
+
out.push({ filePath: entry.filePath, scan });
|
|
253
479
|
}
|
|
254
480
|
return out;
|
|
255
481
|
}
|
|
@@ -262,6 +488,96 @@ export function shouldDeferRecentAppend(prev, current, nowMs, debounceMs = ACTIV
|
|
|
262
488
|
return false;
|
|
263
489
|
return nowMs - prev.scannedAt < debounceMs;
|
|
264
490
|
}
|
|
491
|
+
/**
|
|
492
|
+
* Walk a set of leaf transcript directories and return the files that changed,
|
|
493
|
+
* skipping the per-file `stat` of directories whose (mtime, entry_count) matches
|
|
494
|
+
* the dir_ledger.
|
|
495
|
+
*
|
|
496
|
+
* Per leaf dir:
|
|
497
|
+
* - `stat` the dir once and `readdir` it (one cheap syscall) to get the entry
|
|
498
|
+
* count and the file list.
|
|
499
|
+
* - If the dir matches the dir_ledger (floored mtime AND entry_count), no file
|
|
500
|
+
* was created / deleted / renamed since we last walked it. We then stat ONLY
|
|
501
|
+
* the hot files (live-root files, or files scanned within HOT_FILE_WINDOW_MS)
|
|
502
|
+
* and run just those through the ledger compare — so an in-place append to a
|
|
503
|
+
* still-live session is still caught, while immutable backup/version dirs
|
|
504
|
+
* collapse to a single dir stat and zero per-file stats.
|
|
505
|
+
* - Else (changed dir, or no ledger row) we stat every file (today's full
|
|
506
|
+
* walk) and record the fresh dir stamp so the next run can short-circuit.
|
|
507
|
+
*
|
|
508
|
+
* The kill-switch (`AGENTS_SESSIONS_NO_DIR_LEDGER=1`) forces the full-walk branch
|
|
509
|
+
* for every dir and never consults or records the dir_ledger.
|
|
510
|
+
*/
|
|
511
|
+
export function collectChangedFilesInLeafDirs(leafDirs, ext) {
|
|
512
|
+
const disabled = dirLedgerDisabled();
|
|
513
|
+
const dirStamps = disabled ? new Map() : getDirLedgerForPaths(leafDirs.map(d => d.dirPath));
|
|
514
|
+
const now = Date.now();
|
|
515
|
+
// Files whose per-file stat we still need to ledger-compare this run.
|
|
516
|
+
const toCompare = [];
|
|
517
|
+
const allFiles = [];
|
|
518
|
+
const dirScansToRecord = [];
|
|
519
|
+
for (const { dirPath, isLiveRoot } of leafDirs) {
|
|
520
|
+
const dirStat = safeStatSync(dirPath);
|
|
521
|
+
if (!dirStat?.isDirectory())
|
|
522
|
+
continue;
|
|
523
|
+
let names;
|
|
524
|
+
try {
|
|
525
|
+
names = fs.readdirSync(dirPath).filter(f => f.endsWith(ext));
|
|
526
|
+
}
|
|
527
|
+
catch {
|
|
528
|
+
continue;
|
|
529
|
+
}
|
|
530
|
+
const files = names.map(f => path.join(dirPath, f));
|
|
531
|
+
for (const filePath of files)
|
|
532
|
+
allFiles.push({ filePath, isLiveRoot });
|
|
533
|
+
const dirMtimeMs = Math.floor(dirStat.mtimeMs);
|
|
534
|
+
const entryCount = names.length;
|
|
535
|
+
const prevDir = dirStamps.get(dirPath);
|
|
536
|
+
const dirUnchanged = !disabled && prevDir !== undefined && prevDir.dirMtimeMs === dirMtimeMs && prevDir.entryCount === entryCount;
|
|
537
|
+
if (dirUnchanged) {
|
|
538
|
+
// Contents did not change (no create/delete/rename). Stat only the hot
|
|
539
|
+
// files; the rest are served from the DB with no stat. An immutable backup
|
|
540
|
+
// dir (not a live root, nothing recently scanned) does zero per-file stats.
|
|
541
|
+
//
|
|
542
|
+
// A live-root dir treats every file as hot — that is the tree an agent
|
|
543
|
+
// appends to live, and an append does NOT bump the parent-dir mtime, so
|
|
544
|
+
// without this a growing session would be silently skipped. A non-live
|
|
545
|
+
// file is hot only if it was scanned within HOT_FILE_WINDOW_MS (bulk ledger
|
|
546
|
+
// lookup), covering a session under a version/backup path that is somehow
|
|
547
|
+
// still being written.
|
|
548
|
+
const stamps = isLiveRoot ? null : getScanStampsForPaths(files);
|
|
549
|
+
for (const filePath of files) {
|
|
550
|
+
let hot = isLiveRoot;
|
|
551
|
+
if (!hot && stamps) {
|
|
552
|
+
const s = stamps.get(filePath);
|
|
553
|
+
hot = s?.scannedAt !== undefined && now - s.scannedAt <= HOT_FILE_WINDOW_MS;
|
|
554
|
+
}
|
|
555
|
+
if (!hot)
|
|
556
|
+
continue;
|
|
557
|
+
const stat = safeStatSync(filePath);
|
|
558
|
+
if (!stat)
|
|
559
|
+
continue;
|
|
560
|
+
toCompare.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
|
|
561
|
+
}
|
|
562
|
+
// No dir stamp to record — nothing about the dir changed.
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
// Changed dir (or cold ledger): full per-file stat, exactly as today.
|
|
566
|
+
for (const filePath of files) {
|
|
567
|
+
const stat = safeStatSync(filePath);
|
|
568
|
+
if (!stat)
|
|
569
|
+
continue;
|
|
570
|
+
toCompare.push({ filePath, fileMtimeMs: stat.mtimeMs, fileSize: stat.size });
|
|
571
|
+
}
|
|
572
|
+
if (!disabled)
|
|
573
|
+
dirScansToRecord.push({ dirPath, dirMtimeMs, entryCount });
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const changed = filterChangedEntries(toCompare);
|
|
577
|
+
if (dirScansToRecord.length > 0)
|
|
578
|
+
recordDirScans(dirScansToRecord);
|
|
579
|
+
return { changed, allFiles };
|
|
580
|
+
}
|
|
265
581
|
// ---------------------------------------------------------------------------
|
|
266
582
|
// Multi-version directory scanning
|
|
267
583
|
// ---------------------------------------------------------------------------
|
|
@@ -294,6 +610,17 @@ export function getAgentSessionDirs(agent, subdir) {
|
|
|
294
610
|
try {
|
|
295
611
|
for (const version of fs.readdirSync(versionsBase)) {
|
|
296
612
|
addDir(path.join(versionsBase, version, 'home', configDirName, subdir));
|
|
613
|
+
// Codex's managed home is not always where the version layout says. On macOS
|
|
614
|
+
// the versioned path overflows SUN_LEN (104 bytes) for codex's control
|
|
615
|
+
// socket, so the shim relocates the home to
|
|
616
|
+
// `<agentsUserDir>/.codex-homes/<version>/.codex` (lib/codex-home.ts). Every
|
|
617
|
+
// transcript an isolated codex writes lands there, and nothing scanned it —
|
|
618
|
+
// `agents sessions --roots` listed only the user's own ~/.codex, so a managed
|
|
619
|
+
// copy's own history was invisible. addDir skips what does not exist, so this
|
|
620
|
+
// is inert on Linux and for versions that never needed relocating.
|
|
621
|
+
if (agent === 'codex') {
|
|
622
|
+
addDir(path.join(shortCodexHome(getUserAgentsDir(), version), subdir));
|
|
623
|
+
}
|
|
297
624
|
}
|
|
298
625
|
}
|
|
299
626
|
catch { /* dir unreadable */ }
|
|
@@ -324,6 +651,7 @@ const SESSION_ROOT_SPECS = [
|
|
|
324
651
|
{ agent: 'antigravity', subdir: 'conversations' },
|
|
325
652
|
{ agent: 'droid', subdir: 'sessions' },
|
|
326
653
|
{ agent: 'kimi', subdir: 'sessions' },
|
|
654
|
+
{ agent: 'grok', subdir: 'sessions' },
|
|
327
655
|
];
|
|
328
656
|
function sessionRootSubdir(agent) {
|
|
329
657
|
return SESSION_ROOT_SPECS.find((spec) => spec.agent === agent)?.subdir ?? null;
|
|
@@ -402,7 +730,14 @@ async function readRoutineArchiveMeta(agent, filePath) {
|
|
|
402
730
|
return null;
|
|
403
731
|
if (agent === 'claude') {
|
|
404
732
|
const sessionId = path.basename(filePath).replace(/\.jsonl$/, '');
|
|
405
|
-
|
|
733
|
+
// Routine archives are finalized, immutable transcripts — no live append, so
|
|
734
|
+
// no continuation to resume. A FULL parse (undefined prior) is correct here;
|
|
735
|
+
// the returned continuation is unused by this archive path.
|
|
736
|
+
const stat = safeStatSync(filePath);
|
|
737
|
+
if (!stat)
|
|
738
|
+
return null;
|
|
739
|
+
const scanStamp = { fileMtimeMs: Math.floor(stat.mtimeMs), fileSize: stat.size };
|
|
740
|
+
const result = await readClaudeMeta(filePath, sessionId, scanStamp, undefined);
|
|
406
741
|
return result ? { ...result, meta: decorateRoutineSession(result.meta, info) } : null;
|
|
407
742
|
}
|
|
408
743
|
if (agent === 'codex') {
|
|
@@ -416,12 +751,13 @@ async function scanRoutineArchivesIncremental(agent, onProgress) {
|
|
|
416
751
|
if (!subdir)
|
|
417
752
|
return;
|
|
418
753
|
const ext = agent === 'gemini' ? '.json' : '.jsonl';
|
|
419
|
-
const
|
|
754
|
+
const prestat = [];
|
|
420
755
|
for (const sessionsDir of getRoutineArchiveSessionDirs(agent, subdir)) {
|
|
421
|
-
for (const
|
|
422
|
-
|
|
756
|
+
for (const f of walkForFilesWithStat(sessionsDir, ext, 100_000)) {
|
|
757
|
+
prestat.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
|
|
758
|
+
}
|
|
423
759
|
}
|
|
424
|
-
const changed =
|
|
760
|
+
const changed = filterChangedEntries(prestat);
|
|
425
761
|
if (changed.length === 0)
|
|
426
762
|
return;
|
|
427
763
|
onProgress?.({ agent, parsed: 0, total: changed.length });
|
|
@@ -533,40 +869,64 @@ export function buildClaudeLabelMap() {
|
|
|
533
869
|
async function scanClaudeIncremental(onProgress) {
|
|
534
870
|
const account = getClaudeAccount();
|
|
535
871
|
const labelMap = buildClaudeLabelMap();
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
872
|
+
// Enumerate every leaf project dir across all Claude roots. The FIRST root
|
|
873
|
+
// returned by getAgentSessionDirs is the agent's live `~/.claude/projects` —
|
|
874
|
+
// the only tree Claude appends to in place, so its project dirs are live roots
|
|
875
|
+
// (every file hot). Version-home + backup roots are immutable: their dirs
|
|
876
|
+
// short-circuit to a single dir stat when unchanged.
|
|
877
|
+
const roots = getAgentSessionDirs('claude', 'projects');
|
|
878
|
+
const leafDirs = [];
|
|
879
|
+
const seenLeaf = new Set();
|
|
880
|
+
roots.forEach((projectsDir, rootIdx) => {
|
|
881
|
+
const isLiveRoot = rootIdx === 0;
|
|
539
882
|
let projectDirs;
|
|
540
883
|
try {
|
|
541
884
|
projectDirs = fs.readdirSync(projectsDir);
|
|
542
885
|
}
|
|
543
886
|
catch {
|
|
544
|
-
|
|
887
|
+
return;
|
|
545
888
|
}
|
|
546
889
|
for (const dirName of projectDirs) {
|
|
547
890
|
const dirPath = path.join(projectsDir, dirName);
|
|
548
|
-
const
|
|
549
|
-
if (
|
|
550
|
-
continue;
|
|
551
|
-
let files;
|
|
552
|
-
try {
|
|
553
|
-
files = fs.readdirSync(dirPath).filter(f => f.endsWith('.jsonl'));
|
|
554
|
-
}
|
|
555
|
-
catch {
|
|
891
|
+
const key = safeRealpathSync(dirPath) || dirPath;
|
|
892
|
+
if (seenLeaf.has(key))
|
|
556
893
|
continue;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
const sessionId = file.replace('.jsonl', '');
|
|
560
|
-
if (seen.has(sessionId))
|
|
561
|
-
continue;
|
|
562
|
-
seen.add(sessionId);
|
|
563
|
-
filePaths.push(path.join(dirPath, file));
|
|
564
|
-
}
|
|
894
|
+
seenLeaf.add(key);
|
|
895
|
+
leafDirs.push({ dirPath, isLiveRoot });
|
|
565
896
|
}
|
|
566
|
-
}
|
|
567
|
-
const changed =
|
|
897
|
+
});
|
|
898
|
+
const { changed: changedAll, allFiles } = collectChangedFilesInLeafDirs(leafDirs, '.jsonl');
|
|
899
|
+
// Restore the pre-A-2 cross-root precedence: a session id present in multiple
|
|
900
|
+
// roots is ALWAYS served from its live path, never a frozen backup/version
|
|
901
|
+
// copy. Pre-A-2, dedup happened at enumeration time via a live-first `seen`
|
|
902
|
+
// set, so a non-live copy was never even stat'd when a live copy existed. This
|
|
903
|
+
// PR must not regress that to "whichever copy changed this run wins" — a cold
|
|
904
|
+
// (unchanged) live copy paired with a freshly-written backup snapshot would
|
|
905
|
+
// otherwise flip the row's file_path to the backup path.
|
|
906
|
+
//
|
|
907
|
+
// allFiles is every transcript file across all roots in live-first order, so
|
|
908
|
+
// the FIRST occurrence of each session id is its live (or highest-precedence)
|
|
909
|
+
// path — the durable winner, independent of which copy was flagged changed.
|
|
910
|
+
const sessionIdOf = (fp) => path.basename(fp).replace('.jsonl', '');
|
|
911
|
+
const winnerBySession = new Map();
|
|
912
|
+
for (const { filePath } of allFiles) {
|
|
913
|
+
const id = sessionIdOf(filePath);
|
|
914
|
+
if (!winnerBySession.has(id))
|
|
915
|
+
winnerBySession.set(id, filePath);
|
|
916
|
+
}
|
|
917
|
+
// Keep a changed entry only if it is its session's winner. A changed non-live
|
|
918
|
+
// copy is dropped whenever a live copy exists anywhere; the winning path is
|
|
919
|
+
// parsed only when it itself changed (a cold winner needs no re-parse — its DB
|
|
920
|
+
// row already points at the live path).
|
|
921
|
+
const changed = changedAll.filter(e => winnerBySession.get(sessionIdOf(e.filePath)) === e.filePath);
|
|
568
922
|
if (changed.length > 0) {
|
|
569
923
|
onProgress?.({ agent: 'claude', parsed: 0, total: changed.length });
|
|
924
|
+
// Bulk-fetch each changed file's prior resumable continuation. A file with a
|
|
925
|
+
// usable prior state + growth goes incremental (re-parse only the appended
|
|
926
|
+
// bytes); everything else does a FULL from-offset-0 parse. The decision + the
|
|
927
|
+
// parse both live in scanClaudeSessionResumable so full and incremental share
|
|
928
|
+
// one reducer and produce identical rows.
|
|
929
|
+
const priorStates = getParserStatesForPaths(changed.map(c => c.filePath));
|
|
570
930
|
const entries = [];
|
|
571
931
|
const touched = [];
|
|
572
932
|
let parsed = 0;
|
|
@@ -574,9 +934,16 @@ async function scanClaudeIncremental(onProgress) {
|
|
|
574
934
|
try {
|
|
575
935
|
const sessionId = path.basename(filePath).replace('.jsonl', '');
|
|
576
936
|
const label = labelMap.get(sessionId) ?? undefined;
|
|
577
|
-
const
|
|
937
|
+
const priorRow = priorStates.get(filePath);
|
|
938
|
+
const result = await readClaudeMeta(filePath, sessionId, scan, priorRow, account, label);
|
|
578
939
|
if (result) {
|
|
579
|
-
entries.push({
|
|
940
|
+
entries.push({
|
|
941
|
+
meta: result.meta,
|
|
942
|
+
content: result.content,
|
|
943
|
+
scan,
|
|
944
|
+
parserState: result.parserState,
|
|
945
|
+
contentText: result.contentText,
|
|
946
|
+
});
|
|
580
947
|
}
|
|
581
948
|
else {
|
|
582
949
|
touched.push({ filePath, scan });
|
|
@@ -596,16 +963,27 @@ async function scanClaudeIncremental(onProgress) {
|
|
|
596
963
|
if (labelMap.size > 0)
|
|
597
964
|
syncLabels(labelMap);
|
|
598
965
|
}
|
|
599
|
-
/**
|
|
600
|
-
|
|
601
|
-
|
|
966
|
+
/**
|
|
967
|
+
* Stream-parse a single Claude JSONL file to extract session metadata, resuming
|
|
968
|
+
* from the persisted continuation when the file merely grew (see
|
|
969
|
+
* {@link scanClaudeSessionResumable}). Returns the row's meta + FTS content plus
|
|
970
|
+
* the serialized continuation (parser_state + content_text) to persist for the
|
|
971
|
+
* next scan.
|
|
972
|
+
*/
|
|
973
|
+
async function readClaudeMeta(filePath, sessionId, scanStamp, priorRow, account, label) {
|
|
974
|
+
const prior = parsePriorClaudeState(priorRow);
|
|
975
|
+
const { scan, newState, mode } = await scanClaudeSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
|
|
976
|
+
if (mode === 'incremental')
|
|
977
|
+
claudeIncrementalScanCount++;
|
|
978
|
+
else
|
|
979
|
+
claudeFullScanCount++;
|
|
602
980
|
const isTeamOrigin = scan.entrypoint === 'sdk-cli';
|
|
603
981
|
let meta;
|
|
604
982
|
if (scan.timestamp) {
|
|
605
983
|
const cwd = normalizeCwd(scan.cwd || '');
|
|
606
984
|
meta = {
|
|
607
985
|
id: sessionId,
|
|
608
|
-
shortId: sessionId
|
|
986
|
+
shortId: deriveShortId(sessionId),
|
|
609
987
|
agent: 'claude',
|
|
610
988
|
timestamp: scan.timestamp,
|
|
611
989
|
lastActivity: scan.lastActivity,
|
|
@@ -630,13 +1008,15 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
630
1008
|
createdTickets: scan.createdTickets,
|
|
631
1009
|
spawnedTeam: scan.spawnedTeam,
|
|
632
1010
|
plan: scan.plan,
|
|
1011
|
+
todos: scan.todos,
|
|
1012
|
+
recentDirectoriesTouched: scan.recentDirectoriesTouched,
|
|
633
1013
|
};
|
|
634
1014
|
}
|
|
635
1015
|
else {
|
|
636
1016
|
const stat = safeStatSync(filePath);
|
|
637
1017
|
meta = {
|
|
638
1018
|
id: sessionId,
|
|
639
|
-
shortId: sessionId
|
|
1019
|
+
shortId: deriveShortId(sessionId),
|
|
640
1020
|
agent: 'claude',
|
|
641
1021
|
timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
|
|
642
1022
|
lastActivity: scan.lastActivity,
|
|
@@ -657,9 +1037,19 @@ async function readClaudeMeta(filePath, sessionId, account, label) {
|
|
|
657
1037
|
createdTickets: scan.createdTickets,
|
|
658
1038
|
spawnedTeam: scan.spawnedTeam,
|
|
659
1039
|
plan: scan.plan,
|
|
1040
|
+
todos: scan.todos,
|
|
1041
|
+
recentDirectoriesTouched: scan.recentDirectoriesTouched,
|
|
660
1042
|
};
|
|
661
1043
|
}
|
|
662
|
-
return {
|
|
1044
|
+
return {
|
|
1045
|
+
meta,
|
|
1046
|
+
content: scan.contentText || '',
|
|
1047
|
+
// Persist the continuation so the next scan of this file can resume from the
|
|
1048
|
+
// offset instead of a full reparse. content_text is the same accumulated user
|
|
1049
|
+
// doc, cached so the resume can hydrate userTexts without re-reading the file.
|
|
1050
|
+
parserState: JSON.stringify(newState),
|
|
1051
|
+
contentText: newState.contentText,
|
|
1052
|
+
};
|
|
663
1053
|
}
|
|
664
1054
|
// ---------------------------------------------------------------------------
|
|
665
1055
|
// Codex account info
|
|
@@ -745,37 +1135,58 @@ async function scanCodexIncremental(onProgress) {
|
|
|
745
1135
|
// readCodexMeta when a changed session actually needs it — never eagerly here,
|
|
746
1136
|
// so a no-op scan (changed.length === 0) never touches the credential file.
|
|
747
1137
|
const currentVersion = await getCurrentAgentVersion('codex');
|
|
748
|
-
const
|
|
1138
|
+
const prestat = [];
|
|
749
1139
|
for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
|
|
750
|
-
// High limit:
|
|
751
|
-
|
|
752
|
-
|
|
1140
|
+
// High limit: the walk stats each file once here; parsing is gated by the
|
|
1141
|
+
// ledger match below, which reuses that stat instead of re-stat'ing.
|
|
1142
|
+
for (const f of walkForFilesWithStat(sessionsDir, '.jsonl', 100_000)) {
|
|
1143
|
+
prestat.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
|
|
753
1144
|
}
|
|
754
1145
|
}
|
|
755
|
-
const changed =
|
|
1146
|
+
const changed = filterChangedEntries(prestat);
|
|
756
1147
|
// Codex keeps human-readable titles (`thread_name`) in `session_index.jsonl`,
|
|
757
|
-
// which updates independently of the rollout files
|
|
758
|
-
//
|
|
1148
|
+
// which updates independently of the rollout files. Stat each index against the
|
|
1149
|
+
// ledger *without reading it*; only read + re-apply titles when the index (or a
|
|
1150
|
+
// rollout) actually changed. On a fully unchanged scan this collapses to a
|
|
1151
|
+
// couple of stat() calls instead of a full read + a `syncTopics` DB pass.
|
|
1152
|
+
const titleIndex = diffCodexTitleIndexes();
|
|
1153
|
+
if (changed.length === 0 && !titleIndex.changed)
|
|
1154
|
+
return;
|
|
759
1155
|
const titles = readCodexThreadNames();
|
|
760
1156
|
if (changed.length === 0) {
|
|
1157
|
+
// No rollouts changed, but the title index did — apply the new titles.
|
|
761
1158
|
syncTopics(titles);
|
|
1159
|
+
recordScans(titleIndex.stamps);
|
|
762
1160
|
return;
|
|
763
1161
|
}
|
|
764
1162
|
onProgress?.({ agent: 'codex', parsed: 0, total: changed.length });
|
|
1163
|
+
// Bulk-fetch each changed rollout's prior resumable continuation. A file with
|
|
1164
|
+
// a usable prior state + growth goes incremental (re-parse only the appended
|
|
1165
|
+
// bytes); everything else does a FULL from-offset-0 parse. The decision + the
|
|
1166
|
+
// parse both live in scanCodexSessionResumable so full and incremental share
|
|
1167
|
+
// one reducer and produce identical rows.
|
|
1168
|
+
const priorStates = getParserStatesForPaths(changed.map(c => c.filePath));
|
|
765
1169
|
const entries = [];
|
|
766
1170
|
const touched = [];
|
|
767
1171
|
const seen = new Set();
|
|
768
1172
|
let parsed = 0;
|
|
769
1173
|
for (const { filePath, scan } of changed) {
|
|
770
1174
|
try {
|
|
771
|
-
const
|
|
1175
|
+
const priorRow = priorStates.get(filePath);
|
|
1176
|
+
const result = await readCodexMeta(filePath, getCodexAccount, currentVersion, scan, priorRow);
|
|
772
1177
|
if (result && !seen.has(result.meta.id)) {
|
|
773
1178
|
seen.add(result.meta.id);
|
|
774
1179
|
// Prefer the Codex-generated title over the first-prompt fallback.
|
|
775
1180
|
const title = titles.get(result.meta.id);
|
|
776
1181
|
if (title)
|
|
777
1182
|
result.meta.topic = title;
|
|
778
|
-
entries.push({
|
|
1183
|
+
entries.push({
|
|
1184
|
+
meta: result.meta,
|
|
1185
|
+
content: result.content,
|
|
1186
|
+
scan,
|
|
1187
|
+
parserState: result.parserState,
|
|
1188
|
+
contentText: result.contentText,
|
|
1189
|
+
});
|
|
779
1190
|
}
|
|
780
1191
|
else {
|
|
781
1192
|
touched.push({ filePath, scan });
|
|
@@ -789,9 +1200,12 @@ async function scanCodexIncremental(onProgress) {
|
|
|
789
1200
|
}
|
|
790
1201
|
upsertSessionsBatch(entries);
|
|
791
1202
|
recordScans(touched);
|
|
792
|
-
//
|
|
793
|
-
// last scan
|
|
794
|
-
|
|
1203
|
+
// Only when the title index changed can an *unchanged* rollout have gained a
|
|
1204
|
+
// title since the last scan; the inline titles applied above already cover
|
|
1205
|
+
// every changed session, so skip the extra sync when the index is untouched.
|
|
1206
|
+
if (titleIndex.changed)
|
|
1207
|
+
syncTopics(titles);
|
|
1208
|
+
recordScans(titleIndex.stamps);
|
|
795
1209
|
}
|
|
796
1210
|
/** Parse the lines of a Codex `session_index.jsonl` into a session id -> title map. */
|
|
797
1211
|
export function parseCodexThreadNameIndex(raw) {
|
|
@@ -812,6 +1226,32 @@ export function parseCodexThreadNameIndex(raw) {
|
|
|
812
1226
|
}
|
|
813
1227
|
return titles;
|
|
814
1228
|
}
|
|
1229
|
+
/**
|
|
1230
|
+
* Stat every Codex `session_index.jsonl` and diff it against the scan ledger
|
|
1231
|
+
* *without reading it*. Returns the fresh stamps (persisted only after a
|
|
1232
|
+
* successful title sync) and whether any index changed since the last scan — the
|
|
1233
|
+
* signal that lets a no-op scan skip the file read + `syncTopics` entirely.
|
|
1234
|
+
*
|
|
1235
|
+
* The index path is a sibling of `sessions/` (never inside it), so it is never
|
|
1236
|
+
* walked as a rollout and its ledger row can't collide with a transcript's.
|
|
1237
|
+
*/
|
|
1238
|
+
function diffCodexTitleIndexes() {
|
|
1239
|
+
const stamps = [];
|
|
1240
|
+
let changed = false;
|
|
1241
|
+
for (const sessionsDir of getAgentSessionDirs('codex', 'sessions')) {
|
|
1242
|
+
const indexPath = path.join(path.dirname(sessionsDir), 'session_index.jsonl');
|
|
1243
|
+
const stat = safeStatSync(indexPath);
|
|
1244
|
+
if (!stat)
|
|
1245
|
+
continue; // no index in this home
|
|
1246
|
+
const scan = { fileMtimeMs: Math.floor(stat.mtimeMs), fileSize: stat.size };
|
|
1247
|
+
const prev = getScanStampByPath(indexPath);
|
|
1248
|
+
if (!prev || prev.fileMtimeMs !== scan.fileMtimeMs || prev.fileSize !== scan.fileSize) {
|
|
1249
|
+
changed = true;
|
|
1250
|
+
}
|
|
1251
|
+
stamps.push({ filePath: indexPath, scan });
|
|
1252
|
+
}
|
|
1253
|
+
return { stamps, changed };
|
|
1254
|
+
}
|
|
815
1255
|
/**
|
|
816
1256
|
* Read Codex session titles across every Codex home (live + versioned). The
|
|
817
1257
|
* `session_index.jsonl` file sits beside each `sessions/` rollout tree.
|
|
@@ -839,15 +1279,36 @@ function readCodexThreadNames() {
|
|
|
839
1279
|
* performs is deferred until we know this file is a real session worth building
|
|
840
1280
|
* metadata for, and only then — never during the file walk / stat phase.
|
|
841
1281
|
*/
|
|
842
|
-
export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
|
|
843
|
-
|
|
1282
|
+
export async function readCodexMeta(filePath, resolveAccount, currentVersion, scanStamp, priorRow) {
|
|
1283
|
+
// Resume from the persisted continuation when the file merely grew; otherwise
|
|
1284
|
+
// full-parse from byte 0. Both branches share one reducer, so an append yields
|
|
1285
|
+
// a row identical to a from-scratch reparse. When no stamp is supplied (a
|
|
1286
|
+
// caller outside the live scan path), fall back to a plain full parse with no
|
|
1287
|
+
// continuation to persist.
|
|
1288
|
+
let scan;
|
|
1289
|
+
let newState;
|
|
1290
|
+
let newOffset = 0;
|
|
1291
|
+
if (scanStamp) {
|
|
1292
|
+
const prior = parsePriorCodexState(priorRow);
|
|
1293
|
+
const result = await scanCodexSessionResumable(filePath, prior, scanStamp.fileMtimeMs, scanStamp.fileSize, priorRow?.fileMtimeMs);
|
|
1294
|
+
if (result.mode === 'incremental')
|
|
1295
|
+
codexIncrementalScanCount++;
|
|
1296
|
+
else
|
|
1297
|
+
codexFullScanCount++;
|
|
1298
|
+
scan = result.scan;
|
|
1299
|
+
newState = result.newState;
|
|
1300
|
+
newOffset = result.newOffset;
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
scan = await scanCodexSession(filePath);
|
|
1304
|
+
}
|
|
844
1305
|
const sessionId = scan.sessionId || '';
|
|
845
1306
|
if (!sessionId)
|
|
846
1307
|
return null;
|
|
847
1308
|
const cwd = normalizeCwd(scan.cwd || '');
|
|
848
1309
|
const meta = {
|
|
849
1310
|
id: sessionId,
|
|
850
|
-
shortId: sessionId
|
|
1311
|
+
shortId: deriveShortId(sessionId),
|
|
851
1312
|
agent: 'codex',
|
|
852
1313
|
// Codex `session_meta` only carries the start time; use file mtime when
|
|
853
1314
|
// it's newer so long-running sessions register as recently active.
|
|
@@ -871,8 +1332,18 @@ export async function readCodexMeta(filePath, resolveAccount, currentVersion) {
|
|
|
871
1332
|
ticketId: scan.ticketId,
|
|
872
1333
|
createdTickets: scan.createdTickets,
|
|
873
1334
|
spawnedTeam: scan.spawnedTeam,
|
|
1335
|
+
todos: scan.todos,
|
|
1336
|
+
recentDirectoriesTouched: scan.recentDirectoriesTouched,
|
|
1337
|
+
};
|
|
1338
|
+
return {
|
|
1339
|
+
meta,
|
|
1340
|
+
content: scan.contentText || '',
|
|
1341
|
+
// Persist the continuation so the next scan of this rollout resumes from the
|
|
1342
|
+
// offset instead of a full reparse; content_text caches the accumulated user
|
|
1343
|
+
// doc for the resume's hydrate. Absent when no stamp was supplied.
|
|
1344
|
+
parserState: newState ? JSON.stringify(newState) : undefined,
|
|
1345
|
+
contentText: newState?.contentText,
|
|
874
1346
|
};
|
|
875
|
-
return { meta, content: scan.contentText || '' };
|
|
876
1347
|
}
|
|
877
1348
|
/**
|
|
878
1349
|
* Codex writes `session_meta` (with the start timestamp) on the first line of a
|
|
@@ -901,33 +1372,34 @@ function pickLatestCodexTimestamp(metaTimestamp, filePath) {
|
|
|
901
1372
|
async function scanGeminiIncremental(onProgress) {
|
|
902
1373
|
const currentVersion = await getCurrentAgentVersion('gemini');
|
|
903
1374
|
const projectMap = buildGeminiProjectMap();
|
|
904
|
-
|
|
905
|
-
|
|
1375
|
+
// Each `<tmpDir>/<hashDir>/chats` is a leaf dir of Gemini transcripts. The
|
|
1376
|
+
// FIRST tmp root is the live `~/.gemini/tmp` — its chats dirs are live roots;
|
|
1377
|
+
// version-home + backup roots are immutable and short-circuit when unchanged.
|
|
1378
|
+
const tmpRoots = getAgentSessionDirs('gemini', 'tmp');
|
|
1379
|
+
const leafDirs = [];
|
|
1380
|
+
const seenLeaf = new Set();
|
|
1381
|
+
tmpRoots.forEach((tmpDir, rootIdx) => {
|
|
1382
|
+
const isLiveRoot = rootIdx === 0;
|
|
906
1383
|
let hashDirs;
|
|
907
1384
|
try {
|
|
908
1385
|
hashDirs = fs.readdirSync(tmpDir);
|
|
909
1386
|
}
|
|
910
1387
|
catch {
|
|
911
|
-
|
|
1388
|
+
return;
|
|
912
1389
|
}
|
|
913
1390
|
for (const hashDir of hashDirs) {
|
|
914
1391
|
const chatsDir = path.join(tmpDir, hashDir, 'chats');
|
|
915
1392
|
if (!fs.existsSync(chatsDir))
|
|
916
1393
|
continue;
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
chatFiles = fs.readdirSync(chatsDir).filter(f => f.endsWith('.json'));
|
|
920
|
-
}
|
|
921
|
-
catch {
|
|
1394
|
+
const key = safeRealpathSync(chatsDir) || chatsDir;
|
|
1395
|
+
if (seenLeaf.has(key))
|
|
922
1396
|
continue;
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
filePaths.push({ filePath: path.join(chatsDir, file), hashDir });
|
|
926
|
-
}
|
|
1397
|
+
seenLeaf.add(key);
|
|
1398
|
+
leafDirs.push({ dirPath: chatsDir, isLiveRoot });
|
|
927
1399
|
}
|
|
928
|
-
}
|
|
929
|
-
const
|
|
930
|
-
const changedByPath = new Map(
|
|
1400
|
+
});
|
|
1401
|
+
const { changed } = collectChangedFilesInLeafDirs(leafDirs, '.json');
|
|
1402
|
+
const changedByPath = new Map(changed.map(c => [c.filePath, c.scan]));
|
|
931
1403
|
if (changedByPath.size === 0)
|
|
932
1404
|
return;
|
|
933
1405
|
onProgress?.({ agent: 'gemini', parsed: 0, total: changedByPath.size });
|
|
@@ -935,10 +1407,9 @@ async function scanGeminiIncremental(onProgress) {
|
|
|
935
1407
|
const touched = [];
|
|
936
1408
|
const seen = new Set();
|
|
937
1409
|
let parsed = 0;
|
|
938
|
-
for (const { filePath,
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
continue;
|
|
1410
|
+
for (const { filePath, scan } of changed) {
|
|
1411
|
+
// The hashDir is the directory two levels up: <hashDir>/chats/<file>.json.
|
|
1412
|
+
const hashDir = path.basename(path.dirname(path.dirname(filePath)));
|
|
942
1413
|
try {
|
|
943
1414
|
const result = readGeminiMeta(filePath, hashDir, projectMap, currentVersion);
|
|
944
1415
|
if (result && !seen.has(result.meta.id)) {
|
|
@@ -1058,7 +1529,7 @@ function readGeminiMeta(filePath, hashDir, projectMap, currentVersion) {
|
|
|
1058
1529
|
: undefined;
|
|
1059
1530
|
const meta = {
|
|
1060
1531
|
id: sessionId,
|
|
1061
|
-
shortId: sessionId
|
|
1532
|
+
shortId: deriveShortId(sessionId),
|
|
1062
1533
|
agent: 'gemini',
|
|
1063
1534
|
timestamp: startTime || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
|
|
1064
1535
|
lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
|
|
@@ -1204,7 +1675,7 @@ function readAntigravityMeta(filePath, currentVersion) {
|
|
|
1204
1675
|
const stat = safeStatSync(filePath);
|
|
1205
1676
|
const meta = {
|
|
1206
1677
|
id: sessionId,
|
|
1207
|
-
shortId: sessionId
|
|
1678
|
+
shortId: deriveShortId(sessionId),
|
|
1208
1679
|
agent: 'antigravity',
|
|
1209
1680
|
timestamp: stat ? stat.mtime.toISOString() : new Date().toISOString(),
|
|
1210
1681
|
project: normalizedCwd ? path.basename(normalizedCwd) : undefined,
|
|
@@ -1332,7 +1803,7 @@ async function scanOpenCodeIncremental() {
|
|
|
1332
1803
|
const topic = title || undefined;
|
|
1333
1804
|
const meta = {
|
|
1334
1805
|
id,
|
|
1335
|
-
shortId: id
|
|
1806
|
+
shortId: deriveShortId(id, /^ses_/),
|
|
1336
1807
|
agent: 'opencode',
|
|
1337
1808
|
timestamp,
|
|
1338
1809
|
lastActivity,
|
|
@@ -1402,7 +1873,7 @@ async function scanOpenClawIncremental() {
|
|
|
1402
1873
|
entries.push({
|
|
1403
1874
|
meta: {
|
|
1404
1875
|
id: `openclaw-${agentId}`,
|
|
1405
|
-
shortId: agentId
|
|
1876
|
+
shortId: deriveShortId(agentId),
|
|
1406
1877
|
agent: 'openclaw',
|
|
1407
1878
|
timestamp: new Date().toISOString(),
|
|
1408
1879
|
project: name,
|
|
@@ -1438,7 +1909,7 @@ async function scanOpenClawIncremental() {
|
|
|
1438
1909
|
entries.push({
|
|
1439
1910
|
meta: {
|
|
1440
1911
|
id: `openclaw-cron-${jobId}`,
|
|
1441
|
-
shortId: jobId
|
|
1912
|
+
shortId: deriveShortId(jobId),
|
|
1442
1913
|
agent: 'openclaw',
|
|
1443
1914
|
timestamp: new Date().toISOString(),
|
|
1444
1915
|
project: `${jobName} (${agentId || 'unknown'})`,
|
|
@@ -1512,7 +1983,7 @@ async function readRushMeta(filePath, sessionId) {
|
|
|
1512
1983
|
const stat = safeStatSync(filePath);
|
|
1513
1984
|
const timestamp = scan.timestamp
|
|
1514
1985
|
|| (stat ? stat.mtime.toISOString() : new Date().toISOString());
|
|
1515
|
-
const shortId = sessionId
|
|
1986
|
+
const shortId = deriveShortId(sessionId, /^session_/);
|
|
1516
1987
|
const meta = {
|
|
1517
1988
|
id: sessionId,
|
|
1518
1989
|
shortId,
|
|
@@ -1672,7 +2143,7 @@ function readHermesMeta(filePath) {
|
|
|
1672
2143
|
: typeof session.session_start === 'string'
|
|
1673
2144
|
? session.session_start
|
|
1674
2145
|
: stat ? stat.mtime.toISOString() : new Date().toISOString();
|
|
1675
|
-
const shortId = sessionId
|
|
2146
|
+
const shortId = deriveShortId(sessionId, /^api-/);
|
|
1676
2147
|
const model = typeof session.model === 'string' ? session.model : undefined;
|
|
1677
2148
|
const platform = typeof session.platform === 'string' ? session.platform : undefined;
|
|
1678
2149
|
const meta = {
|
|
@@ -1713,14 +2184,15 @@ function extractHermesMessageText(content) {
|
|
|
1713
2184
|
*/
|
|
1714
2185
|
async function scanDroidIncremental(onProgress) {
|
|
1715
2186
|
const currentVersion = await getCurrentAgentVersion('droid');
|
|
1716
|
-
const
|
|
2187
|
+
const prestat = [];
|
|
1717
2188
|
for (const sessionsDir of getAgentSessionDirs('droid', 'sessions')) {
|
|
1718
|
-
// High limit:
|
|
1719
|
-
|
|
1720
|
-
|
|
2189
|
+
// High limit: the walk stats each file once here; parsing is gated by the
|
|
2190
|
+
// ledger match below, which reuses that stat instead of re-stat'ing.
|
|
2191
|
+
for (const f of walkForFilesWithStat(sessionsDir, '.jsonl', 100_000)) {
|
|
2192
|
+
prestat.push({ filePath: f.path, fileMtimeMs: f.mtimeMs, fileSize: f.size });
|
|
1721
2193
|
}
|
|
1722
2194
|
}
|
|
1723
|
-
const changed =
|
|
2195
|
+
const changed = filterChangedEntries(prestat);
|
|
1724
2196
|
if (changed.length === 0)
|
|
1725
2197
|
return;
|
|
1726
2198
|
onProgress?.({ agent: 'droid', parsed: 0, total: changed.length });
|
|
@@ -1772,7 +2244,7 @@ async function readDroidMeta(filePath, currentVersion) {
|
|
|
1772
2244
|
const cwd = normalizeCwd(scan.cwd || '');
|
|
1773
2245
|
const meta = {
|
|
1774
2246
|
id: sessionId,
|
|
1775
|
-
shortId: sessionId
|
|
2247
|
+
shortId: deriveShortId(sessionId),
|
|
1776
2248
|
agent: 'droid',
|
|
1777
2249
|
timestamp: scan.timestamp || (stat ? stat.mtime.toISOString() : new Date().toISOString()),
|
|
1778
2250
|
lastActivity: scan.lastActivity,
|
|
@@ -1916,46 +2388,254 @@ function extractDroidMessageText(content) {
|
|
|
1916
2388
|
.join('\n')
|
|
1917
2389
|
.trim();
|
|
1918
2390
|
}
|
|
2391
|
+
/** Zero-value accumulator for a fresh (from-byte-0) Claude parse. */
|
|
2392
|
+
export function initClaudeParseState() {
|
|
2393
|
+
return {
|
|
2394
|
+
timestamp: undefined,
|
|
2395
|
+
cwd: undefined,
|
|
2396
|
+
gitBranch: undefined,
|
|
2397
|
+
version: undefined,
|
|
2398
|
+
topic: undefined,
|
|
2399
|
+
customTitle: undefined,
|
|
2400
|
+
aiTitle: undefined,
|
|
2401
|
+
entrypoint: undefined,
|
|
2402
|
+
messageCount: 0,
|
|
2403
|
+
tokenCount: 0,
|
|
2404
|
+
outputTokens: 0,
|
|
2405
|
+
sawTokenCount: false,
|
|
2406
|
+
costUsd: 0,
|
|
2407
|
+
sawCost: false,
|
|
2408
|
+
firstTsMs: undefined,
|
|
2409
|
+
lastTsMs: undefined,
|
|
2410
|
+
seenAssistantIds: new Set(),
|
|
2411
|
+
userTexts: [],
|
|
2412
|
+
sawPrCreate: false,
|
|
2413
|
+
prUrl: undefined,
|
|
2414
|
+
prNumber: undefined,
|
|
2415
|
+
createdTickets: new Set(),
|
|
2416
|
+
pendingTicketTools: new Set(),
|
|
2417
|
+
spawnedTeam: undefined,
|
|
2418
|
+
plan: undefined,
|
|
2419
|
+
checklistEvents: [],
|
|
2420
|
+
recentDirectoriesTouched: [],
|
|
2421
|
+
};
|
|
2422
|
+
}
|
|
2423
|
+
const CHECKLIST_TOOLS = new Set(['TodoWrite', 'todo_write', 'update_plan', 'TaskCreate', 'TaskUpdate']);
|
|
2424
|
+
const DIRECTORY_TOOLS = new Set(['Edit', 'Write', 'edit_file', 'write_file', 'create_file', 'edit', 'write', 'Bash', 'exec_command', 'run_shell_command', 'shell', 'Execute']);
|
|
2425
|
+
function foldDerivedToolState(state, event) {
|
|
2426
|
+
if (CHECKLIST_TOOLS.has(event.tool ?? ''))
|
|
2427
|
+
state.checklistEvents.push(event);
|
|
2428
|
+
if (!DIRECTORY_TOOLS.has(event.tool ?? ''))
|
|
2429
|
+
return;
|
|
2430
|
+
const next = extractRecentDirectoriesTouched([event], state.cwd);
|
|
2431
|
+
for (const dir of next ?? []) {
|
|
2432
|
+
const old = state.recentDirectoriesTouched.indexOf(dir);
|
|
2433
|
+
if (old >= 0)
|
|
2434
|
+
state.recentDirectoriesTouched.splice(old, 1);
|
|
2435
|
+
state.recentDirectoriesTouched.push(dir);
|
|
2436
|
+
}
|
|
2437
|
+
if (state.recentDirectoriesTouched.length > 10)
|
|
2438
|
+
state.recentDirectoriesTouched.splice(0, state.recentDirectoriesTouched.length - 10);
|
|
2439
|
+
}
|
|
2440
|
+
/**
|
|
2441
|
+
* Fold one parsed transcript line into the accumulator. This is the exact loop
|
|
2442
|
+
* body {@link scanClaudeSession} used to run inline — extracted verbatim,
|
|
2443
|
+
* mutating `state.*` in place. `parsed` is the already-`JSON.parse`d line (the
|
|
2444
|
+
* malformed-line skip happens in the caller, as before).
|
|
2445
|
+
*/
|
|
2446
|
+
export function applyClaudeLine(state, parsed) {
|
|
2447
|
+
// entrypoint ships on the first envelope event (attachment/user/assistant)
|
|
2448
|
+
// and is the clean structural signal for "was this a team spawn?"
|
|
2449
|
+
if (!state.entrypoint && typeof parsed.entrypoint === 'string') {
|
|
2450
|
+
state.entrypoint = parsed.entrypoint;
|
|
2451
|
+
}
|
|
2452
|
+
// Produced-artifact signals, structurally (independent of the PR gate below):
|
|
2453
|
+
// - a Bash `agents teams create/add` command → the team it spawned
|
|
2454
|
+
// - a Linear create_issue / `gh issue create` tool_use → its result carries
|
|
2455
|
+
// the new ticket ref, read from the matching tool_result.
|
|
2456
|
+
if (parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
2457
|
+
for (const b of parsed.message.content) {
|
|
2458
|
+
if (b?.type !== 'tool_use')
|
|
2459
|
+
continue;
|
|
2460
|
+
if (!state.spawnedTeam && typeof b?.input?.command === 'string') {
|
|
2461
|
+
const team = detectSpawnedTeam(b.input.command);
|
|
2462
|
+
if (team)
|
|
2463
|
+
state.spawnedTeam = team;
|
|
2464
|
+
}
|
|
2465
|
+
if (typeof b?.id === 'string' && isTicketCreateTool(b?.name, b?.input?.command)) {
|
|
2466
|
+
state.pendingTicketTools.add(b.id);
|
|
2467
|
+
}
|
|
2468
|
+
// ExitPlanMode plan markdown — last one wins so a re-planned session
|
|
2469
|
+
// reports its most recent plan.
|
|
2470
|
+
if (b?.name === 'ExitPlanMode' && typeof b?.input?.plan === 'string') {
|
|
2471
|
+
const p = b.input.plan.trim();
|
|
2472
|
+
if (p)
|
|
2473
|
+
state.plan = b.input.plan;
|
|
2474
|
+
}
|
|
2475
|
+
foldDerivedToolState(state, {
|
|
2476
|
+
type: 'tool_use', agent: 'claude', timestamp: parsed.timestamp || '', tool: b?.name, args: b?.input || {},
|
|
2477
|
+
path: b?.input?.file_path || b?.input?.path, command: b?.input?.command,
|
|
2478
|
+
});
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
if (state.pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
|
|
2482
|
+
for (const b of parsed.message.content) {
|
|
2483
|
+
if (b?.type !== 'tool_result' || typeof b?.tool_use_id !== 'string')
|
|
2484
|
+
continue;
|
|
2485
|
+
if (!state.pendingTicketTools.has(b.tool_use_id))
|
|
2486
|
+
continue;
|
|
2487
|
+
state.pendingTicketTools.delete(b.tool_use_id);
|
|
2488
|
+
const text = typeof b.content === 'string'
|
|
2489
|
+
? b.content
|
|
2490
|
+
: Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
|
|
2491
|
+
const t = extractCreatedTicket(text);
|
|
2492
|
+
if (t)
|
|
2493
|
+
state.createdTickets.add(t);
|
|
2494
|
+
}
|
|
2495
|
+
}
|
|
2496
|
+
// PR signal, structurally: a Bash tool_use whose command is `gh pr create`
|
|
2497
|
+
// marks intent; the pull URL is then read from a tool_result's output.
|
|
2498
|
+
if (!state.prUrl) {
|
|
2499
|
+
if (!state.sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
2500
|
+
for (const b of parsed.message.content) {
|
|
2501
|
+
if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
|
|
2502
|
+
state.sawPrCreate = true;
|
|
2503
|
+
}
|
|
2504
|
+
}
|
|
2505
|
+
}
|
|
2506
|
+
if (state.sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
|
|
2507
|
+
for (const b of parsed.message.content) {
|
|
2508
|
+
if (b?.type !== 'tool_result')
|
|
2509
|
+
continue;
|
|
2510
|
+
const text = typeof b.content === 'string'
|
|
2511
|
+
? b.content
|
|
2512
|
+
: Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
|
|
2513
|
+
const pr = extractPrUrl(text);
|
|
2514
|
+
if (pr) {
|
|
2515
|
+
state.prUrl = pr.url;
|
|
2516
|
+
state.prNumber = pr.number;
|
|
2517
|
+
}
|
|
2518
|
+
}
|
|
2519
|
+
}
|
|
2520
|
+
}
|
|
2521
|
+
// Track duration across every timestamped event, not just the first.
|
|
2522
|
+
if (typeof parsed.timestamp === 'string') {
|
|
2523
|
+
const ms = new Date(parsed.timestamp).getTime();
|
|
2524
|
+
if (!Number.isNaN(ms)) {
|
|
2525
|
+
if (state.firstTsMs === undefined || ms < state.firstTsMs)
|
|
2526
|
+
state.firstTsMs = ms;
|
|
2527
|
+
if (state.lastTsMs === undefined || ms > state.lastTsMs)
|
|
2528
|
+
state.lastTsMs = ms;
|
|
2529
|
+
}
|
|
2530
|
+
}
|
|
2531
|
+
if (!state.timestamp && (parsed.type === 'user' || parsed.type === 'assistant') && parsed.timestamp) {
|
|
2532
|
+
state.timestamp = parsed.timestamp;
|
|
2533
|
+
state.cwd = parsed.cwd || '';
|
|
2534
|
+
state.gitBranch = parsed.gitBranch || undefined;
|
|
2535
|
+
state.version = parsed.version || undefined;
|
|
2536
|
+
}
|
|
2537
|
+
if (parsed.type === 'custom-title') {
|
|
2538
|
+
const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
|
|
2539
|
+
if (t)
|
|
2540
|
+
state.customTitle = t;
|
|
2541
|
+
return;
|
|
2542
|
+
}
|
|
2543
|
+
if (parsed.type === 'ai-title') {
|
|
2544
|
+
const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
|
|
2545
|
+
if (t)
|
|
2546
|
+
state.aiTitle = t;
|
|
2547
|
+
return;
|
|
2548
|
+
}
|
|
2549
|
+
if (parsed.type === 'user') {
|
|
2550
|
+
const text = extractClaudeUserText(parsed);
|
|
2551
|
+
if (text) {
|
|
2552
|
+
state.messageCount++;
|
|
2553
|
+
state.userTexts.push(text);
|
|
2554
|
+
if (!state.topic)
|
|
2555
|
+
state.topic = extractSessionTopic(text);
|
|
2556
|
+
}
|
|
2557
|
+
return;
|
|
2558
|
+
}
|
|
2559
|
+
if (parsed.type !== 'assistant')
|
|
2560
|
+
return;
|
|
2561
|
+
const assistantId = typeof parsed.message?.id === 'string'
|
|
2562
|
+
? parsed.message.id
|
|
2563
|
+
: typeof parsed.uuid === 'string'
|
|
2564
|
+
? parsed.uuid
|
|
2565
|
+
: undefined;
|
|
2566
|
+
const logicalId = assistantId || `${parsed.timestamp || ''}:${state.seenAssistantIds.size}`;
|
|
2567
|
+
if (state.seenAssistantIds.has(logicalId))
|
|
2568
|
+
return;
|
|
2569
|
+
state.seenAssistantIds.add(logicalId);
|
|
2570
|
+
state.messageCount++;
|
|
2571
|
+
const usageObj = parsed.message?.usage || parsed.usage;
|
|
2572
|
+
const usage = getClaudeUsageTotal(usageObj);
|
|
2573
|
+
if (usage !== null) {
|
|
2574
|
+
state.tokenCount += usage;
|
|
2575
|
+
state.sawTokenCount = true;
|
|
2576
|
+
}
|
|
2577
|
+
if (typeof usageObj?.output_tokens === 'number')
|
|
2578
|
+
state.outputTokens += usageObj.output_tokens;
|
|
2579
|
+
// Per-assistant-message cost: each event carries its own model, so we
|
|
2580
|
+
// multiply that event's raw token directions by that model's price.
|
|
2581
|
+
const model = parsed.message?.model;
|
|
2582
|
+
if (model && usageObj && typeof usageObj === 'object') {
|
|
2583
|
+
const eventCost = costOfUsage({
|
|
2584
|
+
model,
|
|
2585
|
+
inputTokens: usageObj.input_tokens,
|
|
2586
|
+
outputTokens: usageObj.output_tokens,
|
|
2587
|
+
cacheReadTokens: usageObj.cache_read_input_tokens,
|
|
2588
|
+
cacheCreationTokens: usageObj.cache_creation_input_tokens,
|
|
2589
|
+
});
|
|
2590
|
+
if (eventCost > 0) {
|
|
2591
|
+
state.costUsd += eventCost;
|
|
2592
|
+
state.sawCost = true;
|
|
2593
|
+
}
|
|
2594
|
+
}
|
|
2595
|
+
}
|
|
2596
|
+
/**
|
|
2597
|
+
* Build the {@link ClaudeSessionScan} return object from an accumulator. This is
|
|
2598
|
+
* the exact return-building {@link scanClaudeSession} used to run inline.
|
|
2599
|
+
*/
|
|
2600
|
+
export function finalizeClaudeScan(state) {
|
|
2601
|
+
const durationMs = state.firstTsMs !== undefined && state.lastTsMs !== undefined && state.lastTsMs > state.firstTsMs
|
|
2602
|
+
? state.lastTsMs - state.firstTsMs
|
|
2603
|
+
: undefined;
|
|
2604
|
+
// Prefer an explicit session title (user `/rename` > Claude auto-title) over
|
|
2605
|
+
// the first-prompt topic.
|
|
2606
|
+
const resolvedTopic = state.customTitle || state.aiTitle || state.topic;
|
|
2607
|
+
const worktree = detectWorktree(state.cwd, state.gitBranch);
|
|
2608
|
+
const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
|
|
2609
|
+
return {
|
|
2610
|
+
timestamp: state.timestamp,
|
|
2611
|
+
cwd: state.cwd,
|
|
2612
|
+
gitBranch: state.gitBranch,
|
|
2613
|
+
version: state.version,
|
|
2614
|
+
topic: resolvedTopic,
|
|
2615
|
+
entrypoint: state.entrypoint,
|
|
2616
|
+
messageCount: state.messageCount,
|
|
2617
|
+
tokenCount: state.sawTokenCount ? state.tokenCount : undefined,
|
|
2618
|
+
outputTokens: state.sawTokenCount ? state.outputTokens : undefined,
|
|
2619
|
+
costUsd: state.sawCost ? state.costUsd : undefined,
|
|
2620
|
+
durationMs,
|
|
2621
|
+
lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
|
|
2622
|
+
contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
|
|
2623
|
+
prUrl: state.prUrl,
|
|
2624
|
+
prNumber: state.prNumber,
|
|
2625
|
+
worktreeSlug: worktree?.slug,
|
|
2626
|
+
ticketId: ticket?.id,
|
|
2627
|
+
createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
|
|
2628
|
+
spawnedTeam: state.spawnedTeam,
|
|
2629
|
+
plan: state.plan,
|
|
2630
|
+
todos: extractTodoProgressFromEvents(state.checklistEvents),
|
|
2631
|
+
recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
|
|
2632
|
+
};
|
|
2633
|
+
}
|
|
1919
2634
|
/** Stream a Claude JSONL file and extract scan-level metadata (timestamp, cwd, topic, tokens). */
|
|
1920
2635
|
export async function scanClaudeSession(filePath) {
|
|
1921
2636
|
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
1922
2637
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
1923
|
-
|
|
1924
|
-
let cwd;
|
|
1925
|
-
let gitBranch;
|
|
1926
|
-
let version;
|
|
1927
|
-
let topic;
|
|
1928
|
-
// Explicit session titles: `/rename` writes a `custom-title` event; Claude
|
|
1929
|
-
// auto-generates an `ai-title`. Both can repeat across the file — last wins.
|
|
1930
|
-
let customTitle;
|
|
1931
|
-
let aiTitle;
|
|
1932
|
-
let entrypoint;
|
|
1933
|
-
let messageCount = 0;
|
|
1934
|
-
let tokenCount = 0;
|
|
1935
|
-
let outputTokens = 0;
|
|
1936
|
-
let sawTokenCount = false;
|
|
1937
|
-
let costUsd = 0;
|
|
1938
|
-
let sawCost = false;
|
|
1939
|
-
// Track the first and last timestamped event to derive wall-clock duration.
|
|
1940
|
-
let firstTsMs;
|
|
1941
|
-
let lastTsMs;
|
|
1942
|
-
const seenAssistantIds = new Set();
|
|
1943
|
-
const userTexts = [];
|
|
1944
|
-
// Durable PR signal: set only when an actual `gh pr create` Bash *command*
|
|
1945
|
-
// runs (structural — the command field, not any prose mentioning it), then
|
|
1946
|
-
// capture the pull URL from a later tool_result's output.
|
|
1947
|
-
let sawPrCreate = false;
|
|
1948
|
-
let prUrl;
|
|
1949
|
-
let prNumber;
|
|
1950
|
-
// Artifacts the session PRODUCED: tracker refs it created and any team it spawned.
|
|
1951
|
-
// Ticket creation spans two events — a create_issue tool_use, then the tool_result
|
|
1952
|
-
// carrying the new id — so we hold the pending tool_use ids until their result lands.
|
|
1953
|
-
const createdTickets = new Set();
|
|
1954
|
-
const pendingTicketTools = new Set();
|
|
1955
|
-
let spawnedTeam;
|
|
1956
|
-
// The LAST ExitPlanMode plan wins so a re-planned session surfaces its most
|
|
1957
|
-
// recent plan, matching the semantic the extension's re-parser relied on.
|
|
1958
|
-
let plan;
|
|
2638
|
+
const state = initClaudeParseState();
|
|
1959
2639
|
try {
|
|
1960
2640
|
for await (const line of rl) {
|
|
1961
2641
|
if (!line.trim())
|
|
@@ -1967,213 +2647,163 @@ export async function scanClaudeSession(filePath) {
|
|
|
1967
2647
|
catch {
|
|
1968
2648
|
continue;
|
|
1969
2649
|
}
|
|
1970
|
-
|
|
1971
|
-
// and is the clean structural signal for "was this a team spawn?"
|
|
1972
|
-
if (!entrypoint && typeof parsed.entrypoint === 'string') {
|
|
1973
|
-
entrypoint = parsed.entrypoint;
|
|
1974
|
-
}
|
|
1975
|
-
// Produced-artifact signals, structurally (independent of the PR gate below):
|
|
1976
|
-
// - a Bash `agents teams create/add` command → the team it spawned
|
|
1977
|
-
// - a Linear create_issue / `gh issue create` tool_use → its result carries
|
|
1978
|
-
// the new ticket ref, read from the matching tool_result.
|
|
1979
|
-
if (parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
1980
|
-
for (const b of parsed.message.content) {
|
|
1981
|
-
if (b?.type !== 'tool_use')
|
|
1982
|
-
continue;
|
|
1983
|
-
if (!spawnedTeam && typeof b?.input?.command === 'string') {
|
|
1984
|
-
const team = detectSpawnedTeam(b.input.command);
|
|
1985
|
-
if (team)
|
|
1986
|
-
spawnedTeam = team;
|
|
1987
|
-
}
|
|
1988
|
-
if (typeof b?.id === 'string' && isTicketCreateTool(b?.name, b?.input?.command)) {
|
|
1989
|
-
pendingTicketTools.add(b.id);
|
|
1990
|
-
}
|
|
1991
|
-
// ExitPlanMode plan markdown — last one wins so a re-planned session
|
|
1992
|
-
// reports its most recent plan.
|
|
1993
|
-
if (b?.name === 'ExitPlanMode' && typeof b?.input?.plan === 'string') {
|
|
1994
|
-
const p = b.input.plan.trim();
|
|
1995
|
-
if (p)
|
|
1996
|
-
plan = b.input.plan;
|
|
1997
|
-
}
|
|
1998
|
-
}
|
|
1999
|
-
}
|
|
2000
|
-
if (pendingTicketTools.size > 0 && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
|
|
2001
|
-
for (const b of parsed.message.content) {
|
|
2002
|
-
if (b?.type !== 'tool_result' || typeof b?.tool_use_id !== 'string')
|
|
2003
|
-
continue;
|
|
2004
|
-
if (!pendingTicketTools.has(b.tool_use_id))
|
|
2005
|
-
continue;
|
|
2006
|
-
pendingTicketTools.delete(b.tool_use_id);
|
|
2007
|
-
const text = typeof b.content === 'string'
|
|
2008
|
-
? b.content
|
|
2009
|
-
: Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
|
|
2010
|
-
const t = extractCreatedTicket(text);
|
|
2011
|
-
if (t)
|
|
2012
|
-
createdTickets.add(t);
|
|
2013
|
-
}
|
|
2014
|
-
}
|
|
2015
|
-
// PR signal, structurally: a Bash tool_use whose command is `gh pr create`
|
|
2016
|
-
// marks intent; the pull URL is then read from a tool_result's output.
|
|
2017
|
-
if (!prUrl) {
|
|
2018
|
-
if (!sawPrCreate && parsed.type === 'assistant' && Array.isArray(parsed.message?.content)) {
|
|
2019
|
-
for (const b of parsed.message.content) {
|
|
2020
|
-
if (b?.type === 'tool_use' && typeof b?.input?.command === 'string' && isPrCreateCommand(b.input.command)) {
|
|
2021
|
-
sawPrCreate = true;
|
|
2022
|
-
}
|
|
2023
|
-
}
|
|
2024
|
-
}
|
|
2025
|
-
if (sawPrCreate && parsed.type === 'user' && Array.isArray(parsed.message?.content)) {
|
|
2026
|
-
for (const b of parsed.message.content) {
|
|
2027
|
-
if (b?.type !== 'tool_result')
|
|
2028
|
-
continue;
|
|
2029
|
-
const text = typeof b.content === 'string'
|
|
2030
|
-
? b.content
|
|
2031
|
-
: Array.isArray(b.content) ? b.content.map((c) => c?.text || '').join('\n') : '';
|
|
2032
|
-
const pr = extractPrUrl(text);
|
|
2033
|
-
if (pr) {
|
|
2034
|
-
prUrl = pr.url;
|
|
2035
|
-
prNumber = pr.number;
|
|
2036
|
-
}
|
|
2037
|
-
}
|
|
2038
|
-
}
|
|
2039
|
-
}
|
|
2040
|
-
// Track duration across every timestamped event, not just the first.
|
|
2041
|
-
if (typeof parsed.timestamp === 'string') {
|
|
2042
|
-
const ms = new Date(parsed.timestamp).getTime();
|
|
2043
|
-
if (!Number.isNaN(ms)) {
|
|
2044
|
-
if (firstTsMs === undefined || ms < firstTsMs)
|
|
2045
|
-
firstTsMs = ms;
|
|
2046
|
-
if (lastTsMs === undefined || ms > lastTsMs)
|
|
2047
|
-
lastTsMs = ms;
|
|
2048
|
-
}
|
|
2049
|
-
}
|
|
2050
|
-
if (!timestamp && (parsed.type === 'user' || parsed.type === 'assistant') && parsed.timestamp) {
|
|
2051
|
-
timestamp = parsed.timestamp;
|
|
2052
|
-
cwd = parsed.cwd || '';
|
|
2053
|
-
gitBranch = parsed.gitBranch || undefined;
|
|
2054
|
-
version = parsed.version || undefined;
|
|
2055
|
-
}
|
|
2056
|
-
if (parsed.type === 'custom-title') {
|
|
2057
|
-
const t = typeof parsed.customTitle === 'string' ? parsed.customTitle.trim() : '';
|
|
2058
|
-
if (t)
|
|
2059
|
-
customTitle = t;
|
|
2060
|
-
continue;
|
|
2061
|
-
}
|
|
2062
|
-
if (parsed.type === 'ai-title') {
|
|
2063
|
-
const t = typeof parsed.aiTitle === 'string' ? parsed.aiTitle.trim() : '';
|
|
2064
|
-
if (t)
|
|
2065
|
-
aiTitle = t;
|
|
2066
|
-
continue;
|
|
2067
|
-
}
|
|
2068
|
-
if (parsed.type === 'user') {
|
|
2069
|
-
const text = extractClaudeUserText(parsed);
|
|
2070
|
-
if (text) {
|
|
2071
|
-
messageCount++;
|
|
2072
|
-
userTexts.push(text);
|
|
2073
|
-
if (!topic)
|
|
2074
|
-
topic = extractSessionTopic(text);
|
|
2075
|
-
}
|
|
2076
|
-
continue;
|
|
2077
|
-
}
|
|
2078
|
-
if (parsed.type !== 'assistant')
|
|
2079
|
-
continue;
|
|
2080
|
-
const assistantId = typeof parsed.message?.id === 'string'
|
|
2081
|
-
? parsed.message.id
|
|
2082
|
-
: typeof parsed.uuid === 'string'
|
|
2083
|
-
? parsed.uuid
|
|
2084
|
-
: undefined;
|
|
2085
|
-
const logicalId = assistantId || `${parsed.timestamp || ''}:${seenAssistantIds.size}`;
|
|
2086
|
-
if (seenAssistantIds.has(logicalId))
|
|
2087
|
-
continue;
|
|
2088
|
-
seenAssistantIds.add(logicalId);
|
|
2089
|
-
messageCount++;
|
|
2090
|
-
const usageObj = parsed.message?.usage || parsed.usage;
|
|
2091
|
-
const usage = getClaudeUsageTotal(usageObj);
|
|
2092
|
-
if (usage !== null) {
|
|
2093
|
-
tokenCount += usage;
|
|
2094
|
-
sawTokenCount = true;
|
|
2095
|
-
}
|
|
2096
|
-
if (typeof usageObj?.output_tokens === 'number')
|
|
2097
|
-
outputTokens += usageObj.output_tokens;
|
|
2098
|
-
// Per-assistant-message cost: each event carries its own model, so we
|
|
2099
|
-
// multiply that event's raw token directions by that model's price.
|
|
2100
|
-
const model = parsed.message?.model;
|
|
2101
|
-
if (model && usageObj && typeof usageObj === 'object') {
|
|
2102
|
-
const eventCost = costOfUsage({
|
|
2103
|
-
model,
|
|
2104
|
-
inputTokens: usageObj.input_tokens,
|
|
2105
|
-
outputTokens: usageObj.output_tokens,
|
|
2106
|
-
cacheReadTokens: usageObj.cache_read_input_tokens,
|
|
2107
|
-
cacheCreationTokens: usageObj.cache_creation_input_tokens,
|
|
2108
|
-
});
|
|
2109
|
-
if (eventCost > 0) {
|
|
2110
|
-
costUsd += eventCost;
|
|
2111
|
-
sawCost = true;
|
|
2112
|
-
}
|
|
2113
|
-
}
|
|
2650
|
+
applyClaudeLine(state, parsed);
|
|
2114
2651
|
}
|
|
2115
2652
|
}
|
|
2116
2653
|
finally {
|
|
2117
2654
|
rl.close();
|
|
2118
2655
|
stream.destroy();
|
|
2119
2656
|
}
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2657
|
+
return finalizeClaudeScan(state);
|
|
2658
|
+
}
|
|
2659
|
+
/** Cap on the FIFO window of recent assistant ids persisted in the continuation. */
|
|
2660
|
+
const SEEN_IDS_RECENT_CAP = 256;
|
|
2661
|
+
/**
|
|
2662
|
+
* Snapshot a live {@link ClaudeParseState} into its serializable form at
|
|
2663
|
+
* `offset` bytes consumed. Round-trips through {@link hydrateClaudeParseState}
|
|
2664
|
+
* so incremental replay equals a full parse.
|
|
2665
|
+
*/
|
|
2666
|
+
export function serializeClaudeParserState(state, offset) {
|
|
2667
|
+
const allIds = [...state.seenAssistantIds];
|
|
2668
|
+
const seenIdsRecent = allIds.length > SEEN_IDS_RECENT_CAP
|
|
2669
|
+
? allIds.slice(allIds.length - SEEN_IDS_RECENT_CAP)
|
|
2670
|
+
: allIds;
|
|
2671
|
+
const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
|
|
2128
2672
|
return {
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2673
|
+
v: 1,
|
|
2674
|
+
offset,
|
|
2675
|
+
timestamp: state.timestamp,
|
|
2676
|
+
cwd: state.cwd,
|
|
2677
|
+
gitBranch: state.gitBranch,
|
|
2678
|
+
version: state.version,
|
|
2679
|
+
entrypoint: state.entrypoint,
|
|
2680
|
+
firstTsMs: state.firstTsMs,
|
|
2681
|
+
topic: state.topic,
|
|
2682
|
+
customTitle: state.customTitle,
|
|
2683
|
+
aiTitle: state.aiTitle,
|
|
2684
|
+
plan: state.plan,
|
|
2685
|
+
lastTsMs: state.lastTsMs,
|
|
2686
|
+
messageCount: state.messageCount,
|
|
2687
|
+
tokenCount: state.tokenCount,
|
|
2688
|
+
outputTokens: state.outputTokens,
|
|
2689
|
+
sawTokenCount: state.sawTokenCount,
|
|
2690
|
+
sawCost: state.sawCost,
|
|
2691
|
+
costUsd: state.costUsd,
|
|
2692
|
+
seenIdsSize: state.seenAssistantIds.size,
|
|
2693
|
+
seenIdsRecent,
|
|
2694
|
+
sawPrCreate: state.sawPrCreate,
|
|
2695
|
+
prUrl: state.prUrl,
|
|
2696
|
+
prNumber: state.prNumber,
|
|
2697
|
+
pendingTicketTools: [...state.pendingTicketTools],
|
|
2698
|
+
createdTickets: [...state.createdTickets],
|
|
2699
|
+
spawnedTeam: state.spawnedTeam,
|
|
2700
|
+
// ticketId is derived at finalize time; persist it (and content_text) so a
|
|
2701
|
+
// consumer (B-2) can rebuild the row + FTS doc on append without re-reading
|
|
2702
|
+
// the whole file. worktreeSlug is re-derived from cwd/gitBranch, so it need
|
|
2703
|
+
// not be persisted.
|
|
2145
2704
|
ticketId: ticket?.id,
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2705
|
+
contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
|
|
2706
|
+
checklistEvents: state.checklistEvents,
|
|
2707
|
+
recentDirectoriesTouched: state.recentDirectoriesTouched,
|
|
2149
2708
|
};
|
|
2150
2709
|
}
|
|
2151
|
-
/**
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2710
|
+
/**
|
|
2711
|
+
* Rebuild a live {@link ClaudeParseState} from a persisted continuation so that
|
|
2712
|
+
* applying the appended lines yields the same accumulator a full parse would.
|
|
2713
|
+
*
|
|
2714
|
+
* `seenAssistantIds` is rehydrated from the recent-id FIFO window, then padded
|
|
2715
|
+
* with unique sentinel entries so its `.size` matches the true prior count
|
|
2716
|
+
* (`seenIdsSize`) — the fallback id `${ts}:${size}` must line up with the full
|
|
2717
|
+
* parse even when the window dropped older ids. Padding sentinels can never
|
|
2718
|
+
* collide with a real logical id (real ids are message ids/uuids or
|
|
2719
|
+
* `${ts}:${n}`; the sentinel prefix is not JSON-line-derived).
|
|
2720
|
+
*
|
|
2721
|
+
* `userTexts` is rehydrated as a single joined blob from `contentText`: only
|
|
2722
|
+
* `userTexts.join('\n')` (detectTicket + contentText) and `userTexts.length > 0`
|
|
2723
|
+
* are ever read downstream, and both are preserved by a one-element array
|
|
2724
|
+
* holding the joined content.
|
|
2725
|
+
*/
|
|
2726
|
+
export function hydrateClaudeParseState(prior) {
|
|
2727
|
+
const seen = new Set(prior.seenIdsRecent);
|
|
2728
|
+
// Pad to the true prior size so `seenAssistantIds.size` (which feeds the
|
|
2729
|
+
// fallback logical id) is exact even when older ids fell out of the window.
|
|
2730
|
+
let pad = 0;
|
|
2731
|
+
while (seen.size < prior.seenIdsSize) {
|
|
2732
|
+
seen.add(`pad:${pad++}`);
|
|
2733
|
+
}
|
|
2734
|
+
return {
|
|
2735
|
+
timestamp: prior.timestamp,
|
|
2736
|
+
cwd: prior.cwd,
|
|
2737
|
+
gitBranch: prior.gitBranch,
|
|
2738
|
+
version: prior.version,
|
|
2739
|
+
topic: prior.topic,
|
|
2740
|
+
customTitle: prior.customTitle,
|
|
2741
|
+
aiTitle: prior.aiTitle,
|
|
2742
|
+
entrypoint: prior.entrypoint,
|
|
2743
|
+
messageCount: prior.messageCount,
|
|
2744
|
+
tokenCount: prior.tokenCount,
|
|
2745
|
+
outputTokens: prior.outputTokens,
|
|
2746
|
+
sawTokenCount: prior.sawTokenCount,
|
|
2747
|
+
costUsd: prior.costUsd,
|
|
2748
|
+
sawCost: prior.sawCost,
|
|
2749
|
+
firstTsMs: prior.firstTsMs,
|
|
2750
|
+
lastTsMs: prior.lastTsMs,
|
|
2751
|
+
seenAssistantIds: seen,
|
|
2752
|
+
userTexts: prior.contentText !== undefined && prior.contentText.length > 0 ? [prior.contentText] : [],
|
|
2753
|
+
sawPrCreate: prior.sawPrCreate,
|
|
2754
|
+
prUrl: prior.prUrl,
|
|
2755
|
+
prNumber: prior.prNumber,
|
|
2756
|
+
createdTickets: new Set(prior.createdTickets),
|
|
2757
|
+
pendingTicketTools: new Set(prior.pendingTicketTools),
|
|
2758
|
+
spawnedTeam: prior.spawnedTeam,
|
|
2759
|
+
plan: prior.plan,
|
|
2760
|
+
checklistEvents: prior.checklistEvents ?? [],
|
|
2761
|
+
recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
/**
|
|
2765
|
+
* Resume a Claude parse from `fromOffset` bytes into the file, folding only the
|
|
2766
|
+
* newly-appended lines into `prior`. Returns the finalized scan, the next
|
|
2767
|
+
* serialized continuation, and the byte offset to resume from next time —
|
|
2768
|
+
* `newOffset` stops at the last `'\n'` seen so a half-written trailing record is
|
|
2769
|
+
* re-read (not lost) on the next append.
|
|
2770
|
+
*
|
|
2771
|
+
* NOT wired into the live scan path yet (that is B-2); {@link scanClaudeSession}
|
|
2772
|
+
* remains the only caller-facing entry point. This exists so the parity harness
|
|
2773
|
+
* can prove full === hydrate(state@k) + apply(k+1..n).
|
|
2774
|
+
*/
|
|
2775
|
+
export async function scanClaudeSessionIncremental(filePath, fromOffset, prior) {
|
|
2776
|
+
const state = hydrateClaudeParseState(prior);
|
|
2777
|
+
// Read the appended byte range and apply ONLY newline-terminated lines. The
|
|
2778
|
+
// applied lines and `newOffset` MUST stay consistent: readline (like the full
|
|
2779
|
+
// parse) would emit a trailing UNTERMINATED final line at EOF, but `newOffset`
|
|
2780
|
+
// stops before it — so the next pass, after that record's '\n' is flushed,
|
|
2781
|
+
// would re-read and re-apply the same line and double-count it (user events
|
|
2782
|
+
// have no dedup, unlike assistant `seenAssistantIds`). A record written
|
|
2783
|
+
// non-atomically — bytes first, then '\n' in a second write — is exactly this
|
|
2784
|
+
// case. So we slice at the last '\n' ourselves: everything up to and including
|
|
2785
|
+
// it is a run of complete lines we apply and commit; any tail after it
|
|
2786
|
+
// (syntactically broken OR complete-but-not-yet-terminated) is a still-being-
|
|
2787
|
+
// written record we DEFER to the next pass, once its '\n' lands.
|
|
2788
|
+
const chunks = [];
|
|
2789
|
+
const stream = fs.createReadStream(filePath, { start: fromOffset });
|
|
2175
2790
|
try {
|
|
2176
|
-
for await (const
|
|
2791
|
+
for await (const chunk of stream) {
|
|
2792
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
|
|
2793
|
+
}
|
|
2794
|
+
}
|
|
2795
|
+
finally {
|
|
2796
|
+
stream.destroy();
|
|
2797
|
+
}
|
|
2798
|
+
const appended = Buffer.concat(chunks);
|
|
2799
|
+
// Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
|
|
2800
|
+
const lastNl = appended.lastIndexOf(0x0a);
|
|
2801
|
+
const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
|
|
2802
|
+
if (consumedBytes > 0) {
|
|
2803
|
+
// split('\n') on the committed run: the element after the final '\n' is ''
|
|
2804
|
+
// (skipped by the trim guard). A stray '\r' from CRLF is tolerated by the
|
|
2805
|
+
// JSON.parse below exactly as the full parse tolerates it.
|
|
2806
|
+
for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
|
|
2177
2807
|
if (!line.trim())
|
|
2178
2808
|
continue;
|
|
2179
2809
|
let parsed;
|
|
@@ -2183,140 +2813,558 @@ async function scanCodexSession(filePath) {
|
|
|
2183
2813
|
catch {
|
|
2184
2814
|
continue;
|
|
2185
2815
|
}
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2816
|
+
applyClaudeLine(state, parsed);
|
|
2817
|
+
}
|
|
2818
|
+
}
|
|
2819
|
+
const newOffset = fromOffset + consumedBytes;
|
|
2820
|
+
return {
|
|
2821
|
+
scan: finalizeClaudeScan(state),
|
|
2822
|
+
newState: serializeClaudeParserState(state, newOffset),
|
|
2823
|
+
newOffset,
|
|
2824
|
+
};
|
|
2825
|
+
}
|
|
2826
|
+
/** Serialized zero-value continuation: a fresh accumulator at offset 0, used to drive a FULL parse from the start through the same resumable path. */
|
|
2827
|
+
function freshClaudeParserState() {
|
|
2828
|
+
return serializeClaudeParserState(initClaudeParseState(), 0);
|
|
2829
|
+
}
|
|
2830
|
+
/**
|
|
2831
|
+
* Decide full-vs-incremental for one Claude file and parse it uniformly, always
|
|
2832
|
+
* returning a finalized scan plus the continuation to persist. Both branches run
|
|
2833
|
+
* through the SAME reducer (via {@link scanClaudeSessionIncremental}), so the row
|
|
2834
|
+
* an append produces is identical to a from-scratch full reparse by construction
|
|
2835
|
+
* (the B-1 parity harness proves this at the function level).
|
|
2836
|
+
*
|
|
2837
|
+
* INCREMENTAL when a prior continuation exists AND the file grew past the
|
|
2838
|
+
* persisted offset AND its mtime did not go backwards — an in-place append.
|
|
2839
|
+
* FULL (from byte 0, fresh state) otherwise: cold start (no prior), truncation /
|
|
2840
|
+
* rewrite (size shrank to at or below the offset), or a clock rewind / restore
|
|
2841
|
+
* (mtime older than the last parse). A FULL parse still produces a continuation,
|
|
2842
|
+
* so the file's very next append can go incremental.
|
|
2843
|
+
*
|
|
2844
|
+
* `mode` is returned so the caller (and tests) can confirm which branch ran.
|
|
2845
|
+
*/
|
|
2846
|
+
async function scanClaudeSessionResumable(filePath, prior, currentFileMtimeMs, currentFileSize, priorFileMtimeMs) {
|
|
2847
|
+
// File size + mtime cannot distinguish an APPEND from an in-place rewrite or a
|
|
2848
|
+
// restore that dropped DIFFERENT, larger content at the same path: both grow
|
|
2849
|
+
// the file and move mtime forward. Resuming from the stored offset across that
|
|
2850
|
+
// boundary would fold the new file's bytes into an accumulator hydrated from
|
|
2851
|
+
// the OLD session, so the persisted row silently diverges from a full reparse.
|
|
2852
|
+
// So the metadata gate below only makes a file ELIGIBLE; before trusting the
|
|
2853
|
+
// offset we re-read the transcript's first user/assistant timestamp and require
|
|
2854
|
+
// it to still match the prior continuation's. An append keeps that identity
|
|
2855
|
+
// byte-for-byte; a rewrite/restore of a different session changes it. A
|
|
2856
|
+
// mismatch — or an identity we cannot derive — falls back to a FULL parse,
|
|
2857
|
+
// which is always correct. (A shrink is already handled: currentFileSize is not
|
|
2858
|
+
// > prior.offset, so it takes the FULL branch.)
|
|
2859
|
+
let canIncrement = false;
|
|
2860
|
+
if (prior !== null &&
|
|
2861
|
+
currentFileSize > prior.offset &&
|
|
2862
|
+
(priorFileMtimeMs === undefined || currentFileMtimeMs >= priorFileMtimeMs) &&
|
|
2863
|
+
prior.timestamp !== undefined) {
|
|
2864
|
+
canIncrement = (await claudeSessionIdentityAt(filePath)) === prior.timestamp;
|
|
2865
|
+
}
|
|
2866
|
+
if (canIncrement && prior !== null) {
|
|
2867
|
+
const result = await scanClaudeSessionIncremental(filePath, prior.offset, prior);
|
|
2868
|
+
return { ...result, mode: 'incremental' };
|
|
2869
|
+
}
|
|
2870
|
+
const result = await scanClaudeSessionIncremental(filePath, 0, freshClaudeParserState());
|
|
2871
|
+
return { ...result, mode: 'full' };
|
|
2872
|
+
}
|
|
2873
|
+
/**
|
|
2874
|
+
* Cheaply derive a Claude transcript's session identity — the first
|
|
2875
|
+
* user/assistant event `timestamp` — by streaming only the START of the file
|
|
2876
|
+
* (at most `maxBytes`) and stopping at the first such event. Used by
|
|
2877
|
+
* {@link scanClaudeSessionResumable} to confirm a grown file is still the SAME
|
|
2878
|
+
* session before resuming from a stored parse offset. Returns undefined when no
|
|
2879
|
+
* user/assistant event appears within the budget, which forces a FULL parse.
|
|
2880
|
+
*/
|
|
2881
|
+
async function claudeSessionIdentityAt(filePath, maxBytes = 1_048_576) {
|
|
2882
|
+
const state = initClaudeParseState();
|
|
2883
|
+
const stream = fs.createReadStream(filePath, { start: 0, end: maxBytes - 1, encoding: 'utf-8' });
|
|
2884
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
2885
|
+
try {
|
|
2886
|
+
for await (const line of rl) {
|
|
2887
|
+
if (!line.trim())
|
|
2242
2888
|
continue;
|
|
2889
|
+
let parsed;
|
|
2890
|
+
try {
|
|
2891
|
+
parsed = JSON.parse(line);
|
|
2243
2892
|
}
|
|
2244
|
-
|
|
2245
|
-
const role = parsed.payload.role === 'user' || parsed.payload.role === 'developer'
|
|
2246
|
-
? 'user'
|
|
2247
|
-
: 'assistant';
|
|
2248
|
-
const text = extractCodexMessageText(parsed.payload.content, role);
|
|
2249
|
-
if (!text)
|
|
2250
|
-
continue;
|
|
2251
|
-
messageCount++;
|
|
2252
|
-
if (role === 'user') {
|
|
2253
|
-
userTexts.push(text);
|
|
2254
|
-
if (!topic)
|
|
2255
|
-
topic = extractSessionTopic(text);
|
|
2256
|
-
}
|
|
2893
|
+
catch {
|
|
2257
2894
|
continue;
|
|
2258
2895
|
}
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
if (total !== null)
|
|
2263
|
-
tokenCount = total;
|
|
2264
|
-
// token_count is cumulative — keep the latest snapshot and price it once
|
|
2265
|
-
// after the stream, so we don't double-count across intermediate events.
|
|
2266
|
-
if (totalUsage && typeof totalUsage === 'object')
|
|
2267
|
-
lastTotalTokenUsage = totalUsage;
|
|
2268
|
-
// Codex also stamps the model on the rate_limits/token_count payload on
|
|
2269
|
-
// some versions; prefer session_meta but fall back to it.
|
|
2270
|
-
if (!model && typeof parsed.payload.info?.model === 'string')
|
|
2271
|
-
model = parsed.payload.info.model;
|
|
2272
|
-
}
|
|
2896
|
+
applyClaudeLine(state, parsed);
|
|
2897
|
+
if (state.timestamp !== undefined)
|
|
2898
|
+
break;
|
|
2273
2899
|
}
|
|
2274
2900
|
}
|
|
2275
2901
|
finally {
|
|
2276
2902
|
rl.close();
|
|
2277
2903
|
stream.destroy();
|
|
2278
2904
|
}
|
|
2905
|
+
return state.timestamp;
|
|
2906
|
+
}
|
|
2907
|
+
/**
|
|
2908
|
+
* Parse the prior continuation blob for a changed file into a usable
|
|
2909
|
+
* {@link ClaudeParserState}, or null when there is none / it is unusable. A blob
|
|
2910
|
+
* from a different serialization version is treated as absent so the file falls
|
|
2911
|
+
* back to a clean FULL parse rather than resuming against a stale shape.
|
|
2912
|
+
*/
|
|
2913
|
+
function parsePriorClaudeState(row) {
|
|
2914
|
+
if (!row?.parserState)
|
|
2915
|
+
return null;
|
|
2916
|
+
try {
|
|
2917
|
+
const parsed = JSON.parse(row.parserState);
|
|
2918
|
+
if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
|
|
2919
|
+
return null;
|
|
2920
|
+
return parsed;
|
|
2921
|
+
}
|
|
2922
|
+
catch {
|
|
2923
|
+
return null;
|
|
2924
|
+
}
|
|
2925
|
+
}
|
|
2926
|
+
/** Test seam: how many times the incremental (append-resume) branch was taken since the last reset. */
|
|
2927
|
+
let claudeIncrementalScanCount = 0;
|
|
2928
|
+
/** Test seam: how many times a full (from-offset-0) Claude parse ran since the last reset. */
|
|
2929
|
+
let claudeFullScanCount = 0;
|
|
2930
|
+
/** Test seam: read the (incremental, full) Claude parse counters. */
|
|
2931
|
+
export function __claudeScanBranchCountsForTest() {
|
|
2932
|
+
return { incremental: claudeIncrementalScanCount, full: claudeFullScanCount };
|
|
2933
|
+
}
|
|
2934
|
+
/** Test seam: reset the Claude parse-branch counters to observe a scan from a clean slate. */
|
|
2935
|
+
export function __resetClaudeScanBranchCountsForTest() {
|
|
2936
|
+
claudeIncrementalScanCount = 0;
|
|
2937
|
+
claudeFullScanCount = 0;
|
|
2938
|
+
}
|
|
2939
|
+
/** Zero-value accumulator for a fresh (from-byte-0) Codex parse. */
|
|
2940
|
+
export function initCodexParseState() {
|
|
2941
|
+
return {
|
|
2942
|
+
sessionId: undefined,
|
|
2943
|
+
timestamp: undefined,
|
|
2944
|
+
cwd: undefined,
|
|
2945
|
+
gitBranch: undefined,
|
|
2946
|
+
version: undefined,
|
|
2947
|
+
model: undefined,
|
|
2948
|
+
topic: undefined,
|
|
2949
|
+
messageCount: 0,
|
|
2950
|
+
tokenCount: undefined,
|
|
2951
|
+
lastTotalTokenUsage: undefined,
|
|
2952
|
+
firstTsMs: undefined,
|
|
2953
|
+
lastTsMs: undefined,
|
|
2954
|
+
userTexts: [],
|
|
2955
|
+
sawPrCreate: false,
|
|
2956
|
+
prUrl: undefined,
|
|
2957
|
+
prNumber: undefined,
|
|
2958
|
+
createdTickets: new Set(),
|
|
2959
|
+
pendingTicketTools: new Set(),
|
|
2960
|
+
spawnedTeam: undefined,
|
|
2961
|
+
checklistEvents: [],
|
|
2962
|
+
recentDirectoriesTouched: [],
|
|
2963
|
+
};
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Fold one parsed Codex line into the accumulator — the exact loop body
|
|
2967
|
+
* {@link scanCodexSession} used to run inline, extracted verbatim and mutating
|
|
2968
|
+
* `state.*` in place. `parsed` is the already-`JSON.parse`d line (the
|
|
2969
|
+
* malformed-line skip happens in the caller, as before).
|
|
2970
|
+
*/
|
|
2971
|
+
export function applyCodexLine(state, parsed) {
|
|
2972
|
+
// PR signal, structurally: a Codex `function_call` whose command is
|
|
2973
|
+
// `gh pr create`, then the pull URL from a `function_call_output`.
|
|
2974
|
+
if (parsed.type === 'response_item') {
|
|
2975
|
+
const p = parsed.payload || {};
|
|
2976
|
+
if (p.type === 'function_call') {
|
|
2977
|
+
let cmd = '';
|
|
2978
|
+
let args = {};
|
|
2979
|
+
try {
|
|
2980
|
+
args = typeof p.arguments === 'string' ? JSON.parse(p.arguments) : (p.arguments || {});
|
|
2981
|
+
cmd = String(args.command || args.cmd || '');
|
|
2982
|
+
}
|
|
2983
|
+
catch { /* non-JSON args */ }
|
|
2984
|
+
foldDerivedToolState(state, {
|
|
2985
|
+
type: 'tool_use', agent: 'codex', timestamp: parsed.timestamp || '', tool: p.name, args,
|
|
2986
|
+
path: args.file_path || args.path, command: cmd || undefined,
|
|
2987
|
+
});
|
|
2988
|
+
if (!state.prUrl && !state.sawPrCreate && isPrCreateCommand(cmd))
|
|
2989
|
+
state.sawPrCreate = true;
|
|
2990
|
+
if (!state.spawnedTeam) {
|
|
2991
|
+
const team = detectSpawnedTeam(cmd);
|
|
2992
|
+
if (team)
|
|
2993
|
+
state.spawnedTeam = team;
|
|
2994
|
+
}
|
|
2995
|
+
if (typeof p.call_id === 'string' && isTicketCreateTool(p.name, cmd)) {
|
|
2996
|
+
state.pendingTicketTools.add(p.call_id);
|
|
2997
|
+
}
|
|
2998
|
+
}
|
|
2999
|
+
if (p.type === 'function_call_output') {
|
|
3000
|
+
if (!state.prUrl && state.sawPrCreate) {
|
|
3001
|
+
const pr = extractPrUrl(String(p.output || ''));
|
|
3002
|
+
if (pr) {
|
|
3003
|
+
state.prUrl = pr.url;
|
|
3004
|
+
state.prNumber = pr.number;
|
|
3005
|
+
}
|
|
3006
|
+
}
|
|
3007
|
+
if (typeof p.call_id === 'string' && state.pendingTicketTools.has(p.call_id)) {
|
|
3008
|
+
state.pendingTicketTools.delete(p.call_id);
|
|
3009
|
+
const t = extractCreatedTicket(String(p.output || ''));
|
|
3010
|
+
if (t)
|
|
3011
|
+
state.createdTickets.add(t);
|
|
3012
|
+
}
|
|
3013
|
+
}
|
|
3014
|
+
}
|
|
3015
|
+
// Track duration across every timestamped event.
|
|
3016
|
+
if (typeof parsed.timestamp === 'string') {
|
|
3017
|
+
const ms = new Date(parsed.timestamp).getTime();
|
|
3018
|
+
if (!Number.isNaN(ms)) {
|
|
3019
|
+
if (state.firstTsMs === undefined || ms < state.firstTsMs)
|
|
3020
|
+
state.firstTsMs = ms;
|
|
3021
|
+
if (state.lastTsMs === undefined || ms > state.lastTsMs)
|
|
3022
|
+
state.lastTsMs = ms;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
if (parsed.type === 'session_meta') {
|
|
3026
|
+
const payload = parsed.payload || {};
|
|
3027
|
+
state.sessionId = payload.id || state.sessionId;
|
|
3028
|
+
state.timestamp = payload.timestamp || parsed.timestamp || state.timestamp;
|
|
3029
|
+
state.cwd = payload.cwd || state.cwd;
|
|
3030
|
+
state.gitBranch = payload.git?.branch || state.gitBranch;
|
|
3031
|
+
state.version = payload.cli_version || payload.version || state.version;
|
|
3032
|
+
state.model = payload.model || state.model;
|
|
3033
|
+
return;
|
|
3034
|
+
}
|
|
3035
|
+
if (parsed.type === 'response_item' && parsed.payload?.type === 'message') {
|
|
3036
|
+
const role = parsed.payload.role === 'user' || parsed.payload.role === 'developer'
|
|
3037
|
+
? 'user'
|
|
3038
|
+
: 'assistant';
|
|
3039
|
+
const text = extractCodexMessageText(parsed.payload.content, role);
|
|
3040
|
+
if (!text)
|
|
3041
|
+
return;
|
|
3042
|
+
state.messageCount++;
|
|
3043
|
+
if (role === 'user') {
|
|
3044
|
+
state.userTexts.push(text);
|
|
3045
|
+
if (!state.topic)
|
|
3046
|
+
state.topic = extractSessionTopic(text);
|
|
3047
|
+
}
|
|
3048
|
+
return;
|
|
3049
|
+
}
|
|
3050
|
+
if (parsed.type === 'event_msg' && parsed.payload?.type === 'token_count') {
|
|
3051
|
+
const totalUsage = parsed.payload.info?.total_token_usage;
|
|
3052
|
+
const total = getCodexTokenCount(totalUsage);
|
|
3053
|
+
if (total !== null)
|
|
3054
|
+
state.tokenCount = total;
|
|
3055
|
+
// token_count is cumulative — keep the latest snapshot and price it once
|
|
3056
|
+
// after the stream, so we don't double-count across intermediate events.
|
|
3057
|
+
if (totalUsage && typeof totalUsage === 'object')
|
|
3058
|
+
state.lastTotalTokenUsage = totalUsage;
|
|
3059
|
+
// Codex also stamps the model on the rate_limits/token_count payload on
|
|
3060
|
+
// some versions; prefer session_meta but fall back to it.
|
|
3061
|
+
if (!state.model && typeof parsed.payload.info?.model === 'string')
|
|
3062
|
+
state.model = parsed.payload.info.model;
|
|
3063
|
+
}
|
|
3064
|
+
}
|
|
3065
|
+
/**
|
|
3066
|
+
* Build the {@link CodexSessionScan} return object from an accumulator — the
|
|
3067
|
+
* exact return-building {@link scanCodexSession} used to run inline.
|
|
3068
|
+
*/
|
|
3069
|
+
export function finalizeCodexScan(state) {
|
|
2279
3070
|
// Price the final cumulative token snapshot once, against the session model.
|
|
2280
3071
|
let costUsd;
|
|
2281
|
-
if (model && lastTotalTokenUsage) {
|
|
3072
|
+
if (state.model && state.lastTotalTokenUsage) {
|
|
2282
3073
|
const c = costOfUsage({
|
|
2283
|
-
model,
|
|
2284
|
-
inputTokens: lastTotalTokenUsage.input_tokens,
|
|
2285
|
-
outputTokens: (lastTotalTokenUsage.output_tokens ?? 0) + (lastTotalTokenUsage.reasoning_output_tokens ?? 0),
|
|
2286
|
-
cacheReadTokens: lastTotalTokenUsage.cached_input_tokens,
|
|
3074
|
+
model: state.model,
|
|
3075
|
+
inputTokens: state.lastTotalTokenUsage.input_tokens,
|
|
3076
|
+
outputTokens: (state.lastTotalTokenUsage.output_tokens ?? 0) + (state.lastTotalTokenUsage.reasoning_output_tokens ?? 0),
|
|
3077
|
+
cacheReadTokens: state.lastTotalTokenUsage.cached_input_tokens,
|
|
2287
3078
|
});
|
|
2288
3079
|
if (c > 0)
|
|
2289
3080
|
costUsd = c;
|
|
2290
3081
|
}
|
|
2291
|
-
const durationMs = firstTsMs !== undefined && lastTsMs !== undefined && lastTsMs > firstTsMs
|
|
2292
|
-
? lastTsMs - firstTsMs
|
|
3082
|
+
const durationMs = state.firstTsMs !== undefined && state.lastTsMs !== undefined && state.lastTsMs > state.firstTsMs
|
|
3083
|
+
? state.lastTsMs - state.firstTsMs
|
|
2293
3084
|
: undefined;
|
|
2294
|
-
const worktree = detectWorktree(cwd, gitBranch);
|
|
2295
|
-
const ticket = detectTicket(userTexts.join('\n') || undefined, gitBranch);
|
|
3085
|
+
const worktree = detectWorktree(state.cwd, state.gitBranch);
|
|
3086
|
+
const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
|
|
2296
3087
|
return {
|
|
2297
|
-
sessionId,
|
|
2298
|
-
timestamp,
|
|
2299
|
-
cwd,
|
|
2300
|
-
gitBranch,
|
|
2301
|
-
version,
|
|
2302
|
-
topic,
|
|
2303
|
-
messageCount,
|
|
2304
|
-
tokenCount,
|
|
2305
|
-
outputTokens: lastTotalTokenUsage
|
|
2306
|
-
? (lastTotalTokenUsage.output_tokens ?? 0) + (lastTotalTokenUsage.reasoning_output_tokens ?? 0)
|
|
3088
|
+
sessionId: state.sessionId,
|
|
3089
|
+
timestamp: state.timestamp,
|
|
3090
|
+
cwd: state.cwd,
|
|
3091
|
+
gitBranch: state.gitBranch,
|
|
3092
|
+
version: state.version,
|
|
3093
|
+
topic: state.topic,
|
|
3094
|
+
messageCount: state.messageCount,
|
|
3095
|
+
tokenCount: state.tokenCount,
|
|
3096
|
+
outputTokens: state.lastTotalTokenUsage
|
|
3097
|
+
? (state.lastTotalTokenUsage.output_tokens ?? 0) + (state.lastTotalTokenUsage.reasoning_output_tokens ?? 0)
|
|
2307
3098
|
: undefined,
|
|
2308
3099
|
costUsd,
|
|
2309
3100
|
durationMs,
|
|
2310
|
-
lastActivity: lastTsMs !== undefined ? new Date(lastTsMs).toISOString() : undefined,
|
|
2311
|
-
contentText: userTexts.length > 0 ? userTexts.join('\n') : undefined,
|
|
2312
|
-
prUrl,
|
|
2313
|
-
prNumber,
|
|
3101
|
+
lastActivity: state.lastTsMs !== undefined ? new Date(state.lastTsMs).toISOString() : undefined,
|
|
3102
|
+
contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
|
|
3103
|
+
prUrl: state.prUrl,
|
|
3104
|
+
prNumber: state.prNumber,
|
|
2314
3105
|
worktreeSlug: worktree?.slug,
|
|
2315
3106
|
ticketId: ticket?.id,
|
|
2316
|
-
createdTickets: createdTickets.size > 0 ? [...createdTickets] : undefined,
|
|
2317
|
-
spawnedTeam,
|
|
3107
|
+
createdTickets: state.createdTickets.size > 0 ? [...state.createdTickets] : undefined,
|
|
3108
|
+
spawnedTeam: state.spawnedTeam,
|
|
3109
|
+
todos: extractTodoProgressFromEvents(state.checklistEvents),
|
|
3110
|
+
recentDirectoriesTouched: state.recentDirectoriesTouched.length ? state.recentDirectoriesTouched : undefined,
|
|
3111
|
+
};
|
|
3112
|
+
}
|
|
3113
|
+
/** Stream a Codex JSONL file and extract scan-level metadata (session ID, cwd, topic, tokens). */
|
|
3114
|
+
async function scanCodexSession(filePath) {
|
|
3115
|
+
const stream = fs.createReadStream(filePath, { encoding: 'utf-8' });
|
|
3116
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
3117
|
+
const state = initCodexParseState();
|
|
3118
|
+
try {
|
|
3119
|
+
for await (const line of rl) {
|
|
3120
|
+
if (!line.trim())
|
|
3121
|
+
continue;
|
|
3122
|
+
let parsed;
|
|
3123
|
+
try {
|
|
3124
|
+
parsed = JSON.parse(line);
|
|
3125
|
+
}
|
|
3126
|
+
catch {
|
|
3127
|
+
continue;
|
|
3128
|
+
}
|
|
3129
|
+
applyCodexLine(state, parsed);
|
|
3130
|
+
}
|
|
3131
|
+
}
|
|
3132
|
+
finally {
|
|
3133
|
+
rl.close();
|
|
3134
|
+
stream.destroy();
|
|
3135
|
+
}
|
|
3136
|
+
return finalizeCodexScan(state);
|
|
3137
|
+
}
|
|
3138
|
+
/**
|
|
3139
|
+
* Snapshot a live {@link CodexParseState} into its serializable form at `offset`
|
|
3140
|
+
* bytes consumed. Round-trips through {@link hydrateCodexParseState} so
|
|
3141
|
+
* incremental replay equals a full parse.
|
|
3142
|
+
*/
|
|
3143
|
+
export function serializeCodexParserState(state, offset) {
|
|
3144
|
+
const ticket = detectTicket(state.userTexts.join('\n') || undefined, state.gitBranch);
|
|
3145
|
+
return {
|
|
3146
|
+
v: 1,
|
|
3147
|
+
offset,
|
|
3148
|
+
sessionId: state.sessionId,
|
|
3149
|
+
timestamp: state.timestamp,
|
|
3150
|
+
cwd: state.cwd,
|
|
3151
|
+
gitBranch: state.gitBranch,
|
|
3152
|
+
version: state.version,
|
|
3153
|
+
model: state.model,
|
|
3154
|
+
topic: state.topic,
|
|
3155
|
+
messageCount: state.messageCount,
|
|
3156
|
+
tokenCount: state.tokenCount,
|
|
3157
|
+
lastTotalTokenUsage: state.lastTotalTokenUsage,
|
|
3158
|
+
firstTsMs: state.firstTsMs,
|
|
3159
|
+
lastTsMs: state.lastTsMs,
|
|
3160
|
+
sawPrCreate: state.sawPrCreate,
|
|
3161
|
+
prUrl: state.prUrl,
|
|
3162
|
+
prNumber: state.prNumber,
|
|
3163
|
+
pendingTicketTools: [...state.pendingTicketTools],
|
|
3164
|
+
createdTickets: [...state.createdTickets],
|
|
3165
|
+
spawnedTeam: state.spawnedTeam,
|
|
3166
|
+
// ticketId is derived at finalize time; persist it (and content_text) so a
|
|
3167
|
+
// consumer can rebuild the row + FTS doc on append without re-reading the
|
|
3168
|
+
// whole file. worktreeSlug is re-derived from cwd/gitBranch, so it need not
|
|
3169
|
+
// be persisted.
|
|
3170
|
+
ticketId: ticket?.id,
|
|
3171
|
+
contentText: state.userTexts.length > 0 ? state.userTexts.join('\n') : undefined,
|
|
3172
|
+
checklistEvents: state.checklistEvents,
|
|
3173
|
+
recentDirectoriesTouched: state.recentDirectoriesTouched,
|
|
3174
|
+
};
|
|
3175
|
+
}
|
|
3176
|
+
/**
|
|
3177
|
+
* Rebuild a live {@link CodexParseState} from a persisted continuation so that
|
|
3178
|
+
* applying the appended lines yields the same accumulator a full parse would.
|
|
3179
|
+
*
|
|
3180
|
+
* `userTexts` is rehydrated as a single joined blob from `contentText`: only
|
|
3181
|
+
* `userTexts.join('\n')` (detectTicket + contentText) and `userTexts.length > 0`
|
|
3182
|
+
* are ever read downstream, and both are preserved by a one-element array
|
|
3183
|
+
* holding the joined content. Topic is first-wins and already persisted, so a
|
|
3184
|
+
* collapsed userTexts never changes it.
|
|
3185
|
+
*/
|
|
3186
|
+
export function hydrateCodexParseState(prior) {
|
|
3187
|
+
return {
|
|
3188
|
+
sessionId: prior.sessionId,
|
|
3189
|
+
timestamp: prior.timestamp,
|
|
3190
|
+
cwd: prior.cwd,
|
|
3191
|
+
gitBranch: prior.gitBranch,
|
|
3192
|
+
version: prior.version,
|
|
3193
|
+
model: prior.model,
|
|
3194
|
+
topic: prior.topic,
|
|
3195
|
+
messageCount: prior.messageCount,
|
|
3196
|
+
tokenCount: prior.tokenCount,
|
|
3197
|
+
lastTotalTokenUsage: prior.lastTotalTokenUsage,
|
|
3198
|
+
firstTsMs: prior.firstTsMs,
|
|
3199
|
+
lastTsMs: prior.lastTsMs,
|
|
3200
|
+
userTexts: prior.contentText !== undefined && prior.contentText.length > 0 ? [prior.contentText] : [],
|
|
3201
|
+
sawPrCreate: prior.sawPrCreate,
|
|
3202
|
+
prUrl: prior.prUrl,
|
|
3203
|
+
prNumber: prior.prNumber,
|
|
3204
|
+
createdTickets: new Set(prior.createdTickets),
|
|
3205
|
+
pendingTicketTools: new Set(prior.pendingTicketTools),
|
|
3206
|
+
spawnedTeam: prior.spawnedTeam,
|
|
3207
|
+
checklistEvents: prior.checklistEvents ?? [],
|
|
3208
|
+
recentDirectoriesTouched: prior.recentDirectoriesTouched ?? [],
|
|
3209
|
+
};
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Resume a Codex parse from `fromOffset` bytes into the file, folding only the
|
|
3213
|
+
* newly-appended lines into `prior`. Returns the finalized scan, the next
|
|
3214
|
+
* serialized continuation, and the byte offset to resume from next time.
|
|
3215
|
+
*
|
|
3216
|
+
* Same trailing-line discipline as {@link scanClaudeSessionIncremental}: apply
|
|
3217
|
+
* ONLY the run of newline-terminated lines (slice at the last `'\n'`), and set
|
|
3218
|
+
* `newOffset = fromOffset + consumedBytes`. Any tail after the last `'\n'` —
|
|
3219
|
+
* syntactically broken OR a complete-but-not-yet-terminated record — is DEFERRED
|
|
3220
|
+
* to the next pass once its `'\n'` lands. Codex `messageCount` is additive with
|
|
3221
|
+
* NO dedup, so re-reading a still-unterminated complete line would double-count
|
|
3222
|
+
* it; deferring prevents that (the bug class prix-cloud caught for Claude).
|
|
3223
|
+
*/
|
|
3224
|
+
export async function scanCodexSessionIncremental(filePath, fromOffset, prior) {
|
|
3225
|
+
const state = hydrateCodexParseState(prior);
|
|
3226
|
+
const chunks = [];
|
|
3227
|
+
const stream = fs.createReadStream(filePath, { start: fromOffset });
|
|
3228
|
+
try {
|
|
3229
|
+
for await (const chunk of stream) {
|
|
3230
|
+
chunks.push(typeof chunk === 'string' ? Buffer.from(chunk, 'utf-8') : chunk);
|
|
3231
|
+
}
|
|
3232
|
+
}
|
|
3233
|
+
finally {
|
|
3234
|
+
stream.destroy();
|
|
3235
|
+
}
|
|
3236
|
+
const appended = Buffer.concat(chunks);
|
|
3237
|
+
// Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
|
|
3238
|
+
const lastNl = appended.lastIndexOf(0x0a);
|
|
3239
|
+
const consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
|
|
3240
|
+
if (consumedBytes > 0) {
|
|
3241
|
+
for (const line of appended.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
|
|
3242
|
+
if (!line.trim())
|
|
3243
|
+
continue;
|
|
3244
|
+
let parsed;
|
|
3245
|
+
try {
|
|
3246
|
+
parsed = JSON.parse(line);
|
|
3247
|
+
}
|
|
3248
|
+
catch {
|
|
3249
|
+
continue;
|
|
3250
|
+
}
|
|
3251
|
+
applyCodexLine(state, parsed);
|
|
3252
|
+
}
|
|
3253
|
+
}
|
|
3254
|
+
const newOffset = fromOffset + consumedBytes;
|
|
3255
|
+
return {
|
|
3256
|
+
scan: finalizeCodexScan(state),
|
|
3257
|
+
newState: serializeCodexParserState(state, newOffset),
|
|
3258
|
+
newOffset,
|
|
2318
3259
|
};
|
|
2319
3260
|
}
|
|
3261
|
+
/** Serialized zero-value continuation: a fresh accumulator at offset 0, used to drive a FULL parse from the start through the same resumable path. */
|
|
3262
|
+
function freshCodexParserState() {
|
|
3263
|
+
return serializeCodexParserState(initCodexParseState(), 0);
|
|
3264
|
+
}
|
|
3265
|
+
/**
|
|
3266
|
+
* Cheaply derive a Codex rollout's session identity — the `session_meta` id — by
|
|
3267
|
+
* streaming only the START of the file (at most `maxBytes`) and stopping once the
|
|
3268
|
+
* id is known. Mirrors {@link claudeSessionIdentityAt}: used by
|
|
3269
|
+
* {@link scanCodexSessionResumable} to confirm a grown file is still the SAME
|
|
3270
|
+
* session before resuming from a stored parse offset. Codex writes `session_meta`
|
|
3271
|
+
* (carrying the durable session UUID) on the first line of every rollout, so the
|
|
3272
|
+
* id is reached almost immediately. Returns undefined when no id appears within
|
|
3273
|
+
* the budget, which forces a FULL parse.
|
|
3274
|
+
*/
|
|
3275
|
+
async function codexSessionIdentityAt(filePath, maxBytes = 1_048_576) {
|
|
3276
|
+
const state = initCodexParseState();
|
|
3277
|
+
const stream = fs.createReadStream(filePath, { start: 0, end: maxBytes - 1, encoding: 'utf-8' });
|
|
3278
|
+
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
3279
|
+
try {
|
|
3280
|
+
for await (const line of rl) {
|
|
3281
|
+
if (!line.trim())
|
|
3282
|
+
continue;
|
|
3283
|
+
let parsed;
|
|
3284
|
+
try {
|
|
3285
|
+
parsed = JSON.parse(line);
|
|
3286
|
+
}
|
|
3287
|
+
catch {
|
|
3288
|
+
continue;
|
|
3289
|
+
}
|
|
3290
|
+
applyCodexLine(state, parsed);
|
|
3291
|
+
if (state.sessionId !== undefined)
|
|
3292
|
+
break;
|
|
3293
|
+
}
|
|
3294
|
+
}
|
|
3295
|
+
finally {
|
|
3296
|
+
rl.close();
|
|
3297
|
+
stream.destroy();
|
|
3298
|
+
}
|
|
3299
|
+
return state.sessionId;
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* Decide full-vs-incremental for one Codex rollout and parse it uniformly,
|
|
3303
|
+
* always returning a finalized scan plus the continuation to persist. Both
|
|
3304
|
+
* branches run through the SAME reducer (via {@link scanCodexSessionIncremental}),
|
|
3305
|
+
* so an append produces a row identical to a from-scratch full reparse by
|
|
3306
|
+
* construction. INCREMENTAL when a prior continuation exists AND the file grew
|
|
3307
|
+
* past the persisted offset AND its mtime did not go backwards; FULL (from byte
|
|
3308
|
+
* 0, fresh state) otherwise (cold start, truncation/rewrite, clock rewind).
|
|
3309
|
+
*/
|
|
3310
|
+
async function scanCodexSessionResumable(filePath, prior, currentFileMtimeMs, currentFileSize, priorFileMtimeMs) {
|
|
3311
|
+
// File size + mtime cannot distinguish an APPEND from an in-place rewrite or a
|
|
3312
|
+
// restore that dropped a DIFFERENT, larger rollout at the same path: both grow
|
|
3313
|
+
// the file and move mtime forward. Resuming from the stored offset across that
|
|
3314
|
+
// boundary would fold the new session's bytes into an accumulator hydrated from
|
|
3315
|
+
// the OLD session, so the persisted row silently diverges from a full reparse.
|
|
3316
|
+
// So the metadata gate below only makes a file ELIGIBLE; before trusting the
|
|
3317
|
+
// offset we re-read the rollout's `session_meta` id and require it to still
|
|
3318
|
+
// match the prior continuation's. An append keeps that id; a rewrite/restore of
|
|
3319
|
+
// a different session changes it. A mismatch — or an id we cannot derive —
|
|
3320
|
+
// falls back to a FULL parse, which is always correct. (A shrink is already
|
|
3321
|
+
// handled: currentFileSize is not > prior.offset, so it takes the FULL branch.)
|
|
3322
|
+
let canIncrement = false;
|
|
3323
|
+
if (prior !== null &&
|
|
3324
|
+
currentFileSize > prior.offset &&
|
|
3325
|
+
(priorFileMtimeMs === undefined || currentFileMtimeMs >= priorFileMtimeMs) &&
|
|
3326
|
+
prior.sessionId !== undefined) {
|
|
3327
|
+
canIncrement = (await codexSessionIdentityAt(filePath)) === prior.sessionId;
|
|
3328
|
+
}
|
|
3329
|
+
if (canIncrement && prior !== null) {
|
|
3330
|
+
const result = await scanCodexSessionIncremental(filePath, prior.offset, prior);
|
|
3331
|
+
return { ...result, mode: 'incremental' };
|
|
3332
|
+
}
|
|
3333
|
+
const result = await scanCodexSessionIncremental(filePath, 0, freshCodexParserState());
|
|
3334
|
+
return { ...result, mode: 'full' };
|
|
3335
|
+
}
|
|
3336
|
+
/**
|
|
3337
|
+
* Parse the prior continuation blob for a changed Codex file into a usable
|
|
3338
|
+
* {@link CodexParserState}, or null when there is none / it is unusable. A blob
|
|
3339
|
+
* from a different serialization version is treated as absent so the file falls
|
|
3340
|
+
* back to a clean FULL parse rather than resuming against a stale shape.
|
|
3341
|
+
*/
|
|
3342
|
+
function parsePriorCodexState(row) {
|
|
3343
|
+
if (!row?.parserState)
|
|
3344
|
+
return null;
|
|
3345
|
+
try {
|
|
3346
|
+
const parsed = JSON.parse(row.parserState);
|
|
3347
|
+
if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
|
|
3348
|
+
return null;
|
|
3349
|
+
return parsed;
|
|
3350
|
+
}
|
|
3351
|
+
catch {
|
|
3352
|
+
return null;
|
|
3353
|
+
}
|
|
3354
|
+
}
|
|
3355
|
+
/** Test seam: how many times the incremental (append-resume) branch was taken since the last reset. */
|
|
3356
|
+
let codexIncrementalScanCount = 0;
|
|
3357
|
+
/** Test seam: how many times a full (from-offset-0) Codex parse ran since the last reset. */
|
|
3358
|
+
let codexFullScanCount = 0;
|
|
3359
|
+
/** Test seam: read the (incremental, full) Codex parse counters. */
|
|
3360
|
+
export function __codexScanBranchCountsForTest() {
|
|
3361
|
+
return { incremental: codexIncrementalScanCount, full: codexFullScanCount };
|
|
3362
|
+
}
|
|
3363
|
+
/** Test seam: reset the Codex parse-branch counters to observe a scan from a clean slate. */
|
|
3364
|
+
export function __resetCodexScanBranchCountsForTest() {
|
|
3365
|
+
codexIncrementalScanCount = 0;
|
|
3366
|
+
codexFullScanCount = 0;
|
|
3367
|
+
}
|
|
2320
3368
|
/** Resolve the working directory for an OpenClaw agent from its workspace config. */
|
|
2321
3369
|
function getOpenClawSessionCwd(agentId) {
|
|
2322
3370
|
const workspace = agentId ? getOpenClawWorkspaceMap().get(agentId) : undefined;
|
|
@@ -2576,16 +3624,20 @@ async function scanKimiIncremental(onProgress) {
|
|
|
2576
3624
|
if (changed.length === 0)
|
|
2577
3625
|
return;
|
|
2578
3626
|
onProgress?.({ agent: 'kimi', parsed: 0, total: changed.length });
|
|
3627
|
+
// Bulk-fetch each changed session's prior wire-parse continuation (offset +
|
|
3628
|
+
// counter bases). A session whose wire.jsonl grew resumes from the offset;
|
|
3629
|
+
// everything else (cold start, truncation) full-parses from byte 0.
|
|
3630
|
+
const priorStates = getParserStatesForPaths(changed.map(c => c.filePath));
|
|
2579
3631
|
const scanEntries = [];
|
|
2580
3632
|
const touched = [];
|
|
2581
3633
|
const seen = new Set();
|
|
2582
3634
|
let parsed = 0;
|
|
2583
3635
|
for (const { filePath, scan } of changed) {
|
|
2584
3636
|
try {
|
|
2585
|
-
const result = readKimiMeta(filePath);
|
|
3637
|
+
const result = readKimiMeta(filePath, priorStates.get(filePath));
|
|
2586
3638
|
if (result && !seen.has(result.meta.id)) {
|
|
2587
3639
|
seen.add(result.meta.id);
|
|
2588
|
-
scanEntries.push({ meta: result.meta, content: result.content, scan });
|
|
3640
|
+
scanEntries.push({ meta: result.meta, content: result.content, scan, parserState: result.parserState });
|
|
2589
3641
|
}
|
|
2590
3642
|
else {
|
|
2591
3643
|
touched.push({ filePath, scan });
|
|
@@ -2601,7 +3653,7 @@ async function scanKimiIncremental(onProgress) {
|
|
|
2601
3653
|
recordScans(touched);
|
|
2602
3654
|
}
|
|
2603
3655
|
/** Parse a single Kimi session state.json file to extract session metadata. */
|
|
2604
|
-
export function readKimiMeta(filePath) {
|
|
3656
|
+
export function readKimiMeta(filePath, priorRow) {
|
|
2605
3657
|
let state;
|
|
2606
3658
|
try {
|
|
2607
3659
|
state = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
@@ -2626,7 +3678,7 @@ export function readKimiMeta(filePath) {
|
|
|
2626
3678
|
const stat = safeStatSync(filePath);
|
|
2627
3679
|
const timestamp = updatedAt || createdAt
|
|
2628
3680
|
|| (stat ? stat.mtime.toISOString() : new Date().toISOString());
|
|
2629
|
-
const shortId = sessionId
|
|
3681
|
+
const shortId = deriveShortId(sessionId, /^session_/);
|
|
2630
3682
|
// Try to infer project from session directory path
|
|
2631
3683
|
// ~/.kimi-code/sessions/<workdir_hash>/session_<uuid>/
|
|
2632
3684
|
const workDirName = path.basename(path.dirname(sessionDir));
|
|
@@ -2637,8 +3689,11 @@ export function readKimiMeta(filePath) {
|
|
|
2637
3689
|
project = parts.slice(0, -1).join('/');
|
|
2638
3690
|
}
|
|
2639
3691
|
}
|
|
2640
|
-
// Parse wire.jsonl
|
|
2641
|
-
|
|
3692
|
+
// Parse wire.jsonl incrementally: resume from the persisted offset + counter
|
|
3693
|
+
// bases when the wire grew, else full-parse from byte 0. The continuation is
|
|
3694
|
+
// persisted on this session's state.json ledger row.
|
|
3695
|
+
const prior = parsePriorKimiState(priorRow);
|
|
3696
|
+
const { messageCount, tokenCount, outputTokens, newState } = parseKimiWireMetricsIncremental(sessionDir, prior);
|
|
2642
3697
|
const meta = {
|
|
2643
3698
|
id: sessionId,
|
|
2644
3699
|
shortId,
|
|
@@ -2651,46 +3706,259 @@ export function readKimiMeta(filePath) {
|
|
|
2651
3706
|
tokenCount: tokenCount > 0 ? tokenCount : undefined,
|
|
2652
3707
|
outputTokens: outputTokens > 0 ? outputTokens : undefined,
|
|
2653
3708
|
};
|
|
2654
|
-
return { meta, content: lastPrompt || '' };
|
|
3709
|
+
return { meta, content: lastPrompt || '', parserState: JSON.stringify(newState) };
|
|
2655
3710
|
}
|
|
2656
|
-
/**
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
|
|
2660
|
-
|
|
3711
|
+
/** Fold one parsed Kimi wire event into the additive counters, in place. */
|
|
3712
|
+
function applyKimiWireEvent(acc, event) {
|
|
3713
|
+
if (event.type === 'context.append_message') {
|
|
3714
|
+
acc.messageCount++;
|
|
3715
|
+
}
|
|
3716
|
+
else if (event.type === 'usage.record' && event.usage) {
|
|
3717
|
+
// Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
|
|
3718
|
+
const u = event.usage;
|
|
3719
|
+
acc.tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
|
|
3720
|
+
acc.outputTokens += (u.output || 0);
|
|
3721
|
+
}
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* Incrementally parse Kimi's wire.jsonl for message-count and token counters,
|
|
3725
|
+
* resuming from a persisted continuation instead of re-reading from byte 0 every
|
|
3726
|
+
* scan. Returns the finalized counters and the next {@link KimiParserState} to
|
|
3727
|
+
* persist (offset + the three counter bases).
|
|
3728
|
+
*
|
|
3729
|
+
* Same trailing-line discipline as {@link scanClaudeSessionIncremental}: read
|
|
3730
|
+
* only the appended byte range from `prior.offset`, apply ONLY the run of
|
|
3731
|
+
* newline-terminated lines (slice at the last `'\n'`), and advance the offset to
|
|
3732
|
+
* `prior.offset + consumedBytes`. A complete-but-not-yet-terminated last record
|
|
3733
|
+
* is DEFERRED to the next pass; because these counters are additive with no
|
|
3734
|
+
* dedup, re-reading such a line would double-count it. FULL parse from byte 0
|
|
3735
|
+
* (fresh counters) when there is no prior OR the file shrank below the stored
|
|
3736
|
+
* offset (truncation/rewrite).
|
|
3737
|
+
*/
|
|
3738
|
+
export function parseKimiWireMetricsIncremental(sessionDir, prior) {
|
|
2661
3739
|
const wirePath = path.join(sessionDir, 'agents', 'main', 'wire.jsonl');
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
return { messageCount: 0, tokenCount: 0, outputTokens: 0 };
|
|
3740
|
+
const stat = safeStatSync(wirePath);
|
|
3741
|
+
if (!stat) {
|
|
3742
|
+
// No wire.jsonl (yet): zero counters, offset 0 so a later append is a clean
|
|
3743
|
+
// full parse.
|
|
3744
|
+
return { messageCount: 0, tokenCount: 0, outputTokens: 0, newState: { v: 1, offset: 0, messageCount: 0, tokenCount: 0, outputTokens: 0 } };
|
|
3745
|
+
}
|
|
3746
|
+
// INCREMENTAL only when a usable prior exists AND the file grew past its
|
|
3747
|
+
// offset; otherwise FULL from byte 0 with fresh counters (cold start OR the
|
|
3748
|
+
// file shrank to/below the offset — a truncation/rewrite).
|
|
3749
|
+
//
|
|
3750
|
+
// No session-identity re-check is needed here (unlike Claude's
|
|
3751
|
+
// claudeSessionIdentityAt / Codex's codexSessionIdentityAt, which guard against
|
|
3752
|
+
// an in-place rewrite dropping a DIFFERENT session at the same path). A Kimi
|
|
3753
|
+
// wire.jsonl is uniquely keyed by its session dir — `.../session_<uuid>/agents/
|
|
3754
|
+
// main/wire.jsonl` (see readKimiMeta: sessionId is `session_<uuid>` and must
|
|
3755
|
+
// start with `session_`) — and Kimi only ever APPENDS to that per-session log.
|
|
3756
|
+
// The path therefore cannot host a different session's transcript, so a
|
|
3757
|
+
// size-grew wire.jsonl is always the same session's append. (A truncation/
|
|
3758
|
+
// rewrite — the only way its bytes could diverge — already shrinks it to/below
|
|
3759
|
+
// the offset and takes the FULL branch above.)
|
|
3760
|
+
const canIncrement = prior !== null && stat.size > prior.offset;
|
|
3761
|
+
const fromOffset = canIncrement ? prior.offset : 0;
|
|
3762
|
+
const acc = canIncrement
|
|
3763
|
+
? { messageCount: prior.messageCount, tokenCount: prior.tokenCount, outputTokens: prior.outputTokens }
|
|
3764
|
+
: { messageCount: 0, tokenCount: 0, outputTokens: 0 };
|
|
3765
|
+
let consumedBytes = 0;
|
|
3766
|
+
let fd;
|
|
3767
|
+
try {
|
|
3768
|
+
// Read ONLY the appended byte range [fromOffset, stat.size) — not the whole
|
|
3769
|
+
// file. readSync from an explicit position keeps this function synchronous
|
|
3770
|
+
// (its callers are sync) while making the disk read + allocation scale with
|
|
3771
|
+
// the appended delta, not total file size, matching scanCodexSessionIncremental
|
|
3772
|
+
// / scanClaudeSessionIncremental. Bytes past the stat'd size are a concurrent
|
|
3773
|
+
// append and are deferred to the next scan.
|
|
3774
|
+
const bytesToRead = Math.max(0, stat.size - fromOffset);
|
|
3775
|
+
const appended = Buffer.allocUnsafe(bytesToRead);
|
|
3776
|
+
if (bytesToRead > 0) {
|
|
3777
|
+
fd = fs.openSync(wirePath, 'r');
|
|
3778
|
+
let read = 0;
|
|
3779
|
+
while (read < bytesToRead) {
|
|
3780
|
+
const n = fs.readSync(fd, appended, read, bytesToRead - read, fromOffset + read);
|
|
3781
|
+
if (n <= 0)
|
|
3782
|
+
break;
|
|
3783
|
+
read += n;
|
|
3784
|
+
}
|
|
3785
|
+
const chunk = read === bytesToRead ? appended : appended.subarray(0, read);
|
|
3786
|
+
// Bytes up to AND INCLUDING the last '\n' are the committed, complete-line run.
|
|
3787
|
+
const lastNl = chunk.lastIndexOf(0x0a);
|
|
3788
|
+
consumedBytes = lastNl === -1 ? 0 : lastNl + 1;
|
|
3789
|
+
if (consumedBytes > 0) {
|
|
3790
|
+
for (const line of chunk.subarray(0, consumedBytes).toString('utf-8').split('\n')) {
|
|
3791
|
+
if (!line.trim())
|
|
3792
|
+
continue;
|
|
3793
|
+
try {
|
|
3794
|
+
applyKimiWireEvent(acc, JSON.parse(line));
|
|
3795
|
+
}
|
|
3796
|
+
catch {
|
|
3797
|
+
// Malformed line, skip
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
}
|
|
2667
3802
|
}
|
|
3803
|
+
catch {
|
|
3804
|
+
// If wire.jsonl can't be read, keep the accumulated counters (0s on a cold
|
|
3805
|
+
// parse) — graceful degradation, matching the pre-incremental behavior.
|
|
3806
|
+
}
|
|
3807
|
+
finally {
|
|
3808
|
+
if (fd !== undefined) {
|
|
3809
|
+
try {
|
|
3810
|
+
fs.closeSync(fd);
|
|
3811
|
+
}
|
|
3812
|
+
catch { /* already closed / gone */ }
|
|
3813
|
+
}
|
|
3814
|
+
}
|
|
3815
|
+
return {
|
|
3816
|
+
messageCount: acc.messageCount,
|
|
3817
|
+
tokenCount: acc.tokenCount,
|
|
3818
|
+
outputTokens: acc.outputTokens,
|
|
3819
|
+
newState: { v: 1, offset: fromOffset + consumedBytes, messageCount: acc.messageCount, tokenCount: acc.tokenCount, outputTokens: acc.outputTokens },
|
|
3820
|
+
};
|
|
3821
|
+
}
|
|
3822
|
+
/**
|
|
3823
|
+
* Parse the prior continuation blob for a changed Kimi session into a usable
|
|
3824
|
+
* {@link KimiParserState}, or null when there is none / it is unusable. A blob
|
|
3825
|
+
* from a different serialization version is treated as absent so the wire parse
|
|
3826
|
+
* falls back to a clean FULL parse rather than resuming against a stale shape.
|
|
3827
|
+
*/
|
|
3828
|
+
function parsePriorKimiState(row) {
|
|
3829
|
+
if (!row?.parserState)
|
|
3830
|
+
return null;
|
|
2668
3831
|
try {
|
|
2669
|
-
const
|
|
2670
|
-
|
|
2671
|
-
|
|
3832
|
+
const parsed = JSON.parse(row.parserState);
|
|
3833
|
+
if (parsed?.v !== 1 || typeof parsed.offset !== 'number')
|
|
3834
|
+
return null;
|
|
3835
|
+
return parsed;
|
|
3836
|
+
}
|
|
3837
|
+
catch {
|
|
3838
|
+
return null;
|
|
3839
|
+
}
|
|
3840
|
+
}
|
|
3841
|
+
/**
|
|
3842
|
+
* Scan Grok sessions. Grok stores one directory per session under
|
|
3843
|
+
* ~/.grok/sessions/<url-encoded-cwd>/<uuid>/, each holding a summary.json with
|
|
3844
|
+
* structured metadata (id, cwd, title, timestamps, message count). Same
|
|
3845
|
+
* dir-per-session (L3) shape as Kimi, so it walks two levels and gates the
|
|
3846
|
+
* summary.json read through the scan ledger. Before this, Grok had a type slot
|
|
3847
|
+
* and a placeholder parser but no scanner, so `agents sessions` never indexed it.
|
|
3848
|
+
*/
|
|
3849
|
+
async function scanGrokIncremental(onProgress) {
|
|
3850
|
+
const currentVersion = await getCurrentAgentVersion('grok');
|
|
3851
|
+
const filePaths = [];
|
|
3852
|
+
for (const sessionsDir of getAgentSessionDirs('grok', 'sessions')) {
|
|
3853
|
+
if (!fs.existsSync(sessionsDir))
|
|
3854
|
+
continue;
|
|
3855
|
+
let cwdDirNames;
|
|
3856
|
+
try {
|
|
3857
|
+
cwdDirNames = fs.readdirSync(sessionsDir);
|
|
3858
|
+
}
|
|
3859
|
+
catch {
|
|
3860
|
+
continue;
|
|
3861
|
+
}
|
|
3862
|
+
for (const cwdDirName of cwdDirNames) {
|
|
3863
|
+
const cwdDir = path.join(sessionsDir, cwdDirName);
|
|
3864
|
+
const stat = safeStatSync(cwdDir);
|
|
3865
|
+
if (!stat?.isDirectory())
|
|
2672
3866
|
continue;
|
|
3867
|
+
let sessionNames;
|
|
2673
3868
|
try {
|
|
2674
|
-
|
|
2675
|
-
if (event.type === 'context.append_message') {
|
|
2676
|
-
messageCount++;
|
|
2677
|
-
}
|
|
2678
|
-
else if (event.type === 'usage.record' && event.usage) {
|
|
2679
|
-
// Kimi usage structure: inputOther + output + inputCacheRead + inputCacheCreation
|
|
2680
|
-
const u = event.usage;
|
|
2681
|
-
tokenCount += (u.inputOther || 0) + (u.output || 0) + (u.inputCacheRead || 0) + (u.inputCacheCreation || 0);
|
|
2682
|
-
outputTokens += (u.output || 0);
|
|
2683
|
-
}
|
|
3869
|
+
sessionNames = fs.readdirSync(cwdDir);
|
|
2684
3870
|
}
|
|
2685
3871
|
catch {
|
|
2686
|
-
|
|
3872
|
+
continue;
|
|
3873
|
+
}
|
|
3874
|
+
for (const sessionName of sessionNames) {
|
|
3875
|
+
const summaryPath = path.join(cwdDir, sessionName, 'summary.json');
|
|
3876
|
+
if (!fs.existsSync(summaryPath))
|
|
3877
|
+
continue;
|
|
3878
|
+
filePaths.push(summaryPath);
|
|
3879
|
+
}
|
|
3880
|
+
}
|
|
3881
|
+
}
|
|
3882
|
+
const changed = filterChangedFiles(filePaths);
|
|
3883
|
+
if (changed.length === 0)
|
|
3884
|
+
return;
|
|
3885
|
+
onProgress?.({ agent: 'grok', parsed: 0, total: changed.length });
|
|
3886
|
+
const scanEntries = [];
|
|
3887
|
+
const touched = [];
|
|
3888
|
+
const seen = new Set();
|
|
3889
|
+
let parsed = 0;
|
|
3890
|
+
for (const { filePath, scan } of changed) {
|
|
3891
|
+
try {
|
|
3892
|
+
const result = readGrokMeta(filePath, currentVersion);
|
|
3893
|
+
if (result && !seen.has(result.meta.id)) {
|
|
3894
|
+
seen.add(result.meta.id);
|
|
3895
|
+
scanEntries.push({ meta: result.meta, content: result.content, scan });
|
|
3896
|
+
}
|
|
3897
|
+
else {
|
|
3898
|
+
touched.push({ filePath, scan });
|
|
2687
3899
|
}
|
|
2688
3900
|
}
|
|
3901
|
+
catch {
|
|
3902
|
+
touched.push({ filePath, scan });
|
|
3903
|
+
}
|
|
3904
|
+
parsed++;
|
|
3905
|
+
onProgress?.({ agent: 'grok', parsed, total: changed.length });
|
|
3906
|
+
}
|
|
3907
|
+
upsertSessionsBatch(scanEntries);
|
|
3908
|
+
recordScans(touched);
|
|
3909
|
+
}
|
|
3910
|
+
/** Parse a single Grok session summary.json into session metadata. */
|
|
3911
|
+
export function readGrokMeta(filePath, currentVersion) {
|
|
3912
|
+
let summary;
|
|
3913
|
+
try {
|
|
3914
|
+
summary = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
2689
3915
|
}
|
|
2690
3916
|
catch {
|
|
2691
|
-
|
|
3917
|
+
return null;
|
|
3918
|
+
}
|
|
3919
|
+
const sessionDir = path.dirname(filePath);
|
|
3920
|
+
// The uuid directory name is the canonical id; summary.info.id mirrors it.
|
|
3921
|
+
const sessionId = (typeof summary?.info?.id === 'string' && summary.info.id) || path.basename(sessionDir);
|
|
3922
|
+
if (!sessionId)
|
|
3923
|
+
return null;
|
|
3924
|
+
const cwd = normalizeCwd(typeof summary?.info?.cwd === 'string' ? summary.info.cwd : '');
|
|
3925
|
+
const topic = (typeof summary?.generated_title === 'string' && summary.generated_title.trim()) ||
|
|
3926
|
+
(typeof summary?.session_summary === 'string' && summary.session_summary.trim()) ||
|
|
3927
|
+
undefined;
|
|
3928
|
+
// created_at is the session start; last_active_at/updated_at is the latest
|
|
3929
|
+
// activity. Coerce timestamp to never-null (NOT NULL column) via the file mtime,
|
|
3930
|
+
// matching how the other dir-per-session parsers (Kimi) fall back.
|
|
3931
|
+
const createdAt = typeof summary?.created_at === 'string' ? summary.created_at : undefined;
|
|
3932
|
+
const lastActivity = (typeof summary?.last_active_at === 'string' && summary.last_active_at) ||
|
|
3933
|
+
(typeof summary?.updated_at === 'string' && summary.updated_at) ||
|
|
3934
|
+
undefined;
|
|
3935
|
+
const stat = safeStatSync(filePath);
|
|
3936
|
+
const timestamp = createdAt || lastActivity || (stat ? stat.mtime.toISOString() : new Date().toISOString());
|
|
3937
|
+
const messageCount = typeof summary?.num_chat_messages === 'number'
|
|
3938
|
+
? summary.num_chat_messages
|
|
3939
|
+
: typeof summary?.num_messages === 'number'
|
|
3940
|
+
? summary.num_messages
|
|
3941
|
+
: undefined;
|
|
3942
|
+
// Grok records its managed home in summary.grok_home
|
|
3943
|
+
// (…/versions/grok/<version>/home/.grok) — recover the version from it.
|
|
3944
|
+
let embeddedVersion;
|
|
3945
|
+
if (typeof summary?.grok_home === 'string') {
|
|
3946
|
+
embeddedVersion = summary.grok_home.match(/versions\/grok\/([^/]+)\//)?.[1];
|
|
2692
3947
|
}
|
|
2693
|
-
|
|
3948
|
+
const meta = {
|
|
3949
|
+
id: sessionId,
|
|
3950
|
+
shortId: deriveShortId(sessionId),
|
|
3951
|
+
agent: 'grok',
|
|
3952
|
+
timestamp,
|
|
3953
|
+
lastActivity,
|
|
3954
|
+
project: cwd ? path.basename(cwd) : undefined,
|
|
3955
|
+
cwd: cwd || undefined,
|
|
3956
|
+
filePath,
|
|
3957
|
+
version: resolveSessionVersion('grok', filePath, embeddedVersion, currentVersion),
|
|
3958
|
+
topic,
|
|
3959
|
+
messageCount,
|
|
3960
|
+
};
|
|
3961
|
+
return { meta, content: topic || '' };
|
|
2694
3962
|
}
|
|
2695
3963
|
/** Parse a time filter string (relative like '7d' or ISO timestamp) into epoch milliseconds. */
|
|
2696
3964
|
export function parseTimeFilter(input) {
|