aicodeman 1.45.0 → 1.46.0
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/README.md +26 -6
- package/dist/agent-codex-identity.d.ts +26 -0
- package/dist/agent-codex-identity.d.ts.map +1 -0
- package/dist/agent-codex-identity.js +253 -0
- package/dist/agent-codex-identity.js.map +1 -0
- package/dist/agent-inspection.d.ts +32 -0
- package/dist/agent-inspection.d.ts.map +1 -0
- package/dist/agent-inspection.js +196 -0
- package/dist/agent-inspection.js.map +1 -0
- package/dist/agent-staging.d.ts +130 -0
- package/dist/agent-staging.d.ts.map +1 -0
- package/dist/agent-staging.js +312 -0
- package/dist/agent-staging.js.map +1 -0
- package/dist/cli-agent.d.ts +31 -2
- package/dist/cli-agent.d.ts.map +1 -1
- package/dist/cli-agent.js +279 -34
- package/dist/cli-agent.js.map +1 -1
- package/dist/cli-history.d.ts +20 -0
- package/dist/cli-history.d.ts.map +1 -0
- package/dist/cli-history.js +61 -0
- package/dist/cli-history.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +6 -1
- package/dist/cli.js.map +1 -1
- package/dist/codex-rollout-format.d.ts +66 -0
- package/dist/codex-rollout-format.d.ts.map +1 -0
- package/dist/codex-rollout-format.js +111 -0
- package/dist/codex-rollout-format.js.map +1 -0
- package/dist/codex-transcript.d.ts +3 -0
- package/dist/codex-transcript.d.ts.map +1 -1
- package/dist/codex-transcript.js +13 -41
- package/dist/codex-transcript.js.map +1 -1
- package/dist/config/cli-registry/schema.d.ts +12 -7
- package/dist/config/cli-registry/schema.d.ts.map +1 -1
- package/dist/config/cli-registry/schema.js +26 -0
- package/dist/config/cli-registry/schema.js.map +1 -1
- package/dist/config/cli-registry/stock.d.ts.map +1 -1
- package/dist/config/cli-registry/stock.js +40 -1
- package/dist/config/cli-registry/stock.js.map +1 -1
- package/dist/config/cli-registry/types.d.ts +57 -0
- package/dist/config/cli-registry/types.d.ts.map +1 -1
- package/dist/config/server-timing.d.ts +6 -0
- package/dist/config/server-timing.d.ts.map +1 -1
- package/dist/config/server-timing.js +9 -0
- package/dist/config/server-timing.js.map +1 -1
- package/dist/daemon-control.d.ts +1 -0
- package/dist/daemon-control.d.ts.map +1 -1
- package/dist/daemon-control.js +2 -0
- package/dist/daemon-control.js.map +1 -1
- package/dist/deepseek-transcript.d.ts +9 -7
- package/dist/deepseek-transcript.d.ts.map +1 -1
- package/dist/deepseek-transcript.js +9 -7
- package/dist/deepseek-transcript.js.map +1 -1
- package/dist/docker-hosts.d.ts +8 -0
- package/dist/docker-hosts.d.ts.map +1 -1
- package/dist/docker-hosts.js +29 -0
- package/dist/docker-hosts.js.map +1 -1
- package/dist/git-fetch-scheduler.d.ts +124 -0
- package/dist/git-fetch-scheduler.d.ts.map +1 -0
- package/dist/git-fetch-scheduler.js +249 -0
- package/dist/git-fetch-scheduler.js.map +1 -0
- package/dist/git-fetch.d.ts +241 -0
- package/dist/git-fetch.d.ts.map +1 -0
- package/dist/git-fetch.js +392 -0
- package/dist/git-fetch.js.map +1 -0
- package/dist/git-workspace-status.d.ts +36 -1
- package/dist/git-workspace-status.d.ts.map +1 -1
- package/dist/git-workspace-status.js +42 -5
- package/dist/git-workspace-status.js.map +1 -1
- package/dist/hooks-config.d.ts +14 -2
- package/dist/hooks-config.d.ts.map +1 -1
- package/dist/hooks-config.js +39 -3
- package/dist/hooks-config.js.map +1 -1
- package/dist/local-history.d.ts +71 -0
- package/dist/local-history.d.ts.map +1 -0
- package/dist/local-history.js +750 -0
- package/dist/local-history.js.map +1 -0
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +2 -2
- package/dist/mcp-server.js.map +1 -1
- package/dist/mux-interface.d.ts +5 -2
- package/dist/mux-interface.d.ts.map +1 -1
- package/dist/prompt-cache-keepwarm.d.ts.map +1 -1
- package/dist/prompt-cache-keepwarm.js +5 -3
- package/dist/prompt-cache-keepwarm.js.map +1 -1
- package/dist/session.d.ts +113 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +341 -13
- package/dist/session.js.map +1 -1
- package/dist/terminal-input-classifier.d.ts +8 -0
- package/dist/terminal-input-classifier.d.ts.map +1 -0
- package/dist/terminal-input-classifier.js +60 -0
- package/dist/terminal-input-classifier.js.map +1 -0
- package/dist/tmux-manager.d.ts +23 -1
- package/dist/tmux-manager.d.ts.map +1 -1
- package/dist/tmux-manager.js +54 -6
- package/dist/tmux-manager.js.map +1 -1
- package/dist/types/session.d.ts +15 -0
- package/dist/types/session.d.ts.map +1 -1
- package/dist/types/session.js.map +1 -1
- package/dist/web/network-auth-policy.d.ts +13 -3
- package/dist/web/network-auth-policy.d.ts.map +1 -1
- package/dist/web/network-auth-policy.js +15 -3
- package/dist/web/network-auth-policy.js.map +1 -1
- package/dist/web/ports/infra-port.d.ts +2 -0
- package/dist/web/ports/infra-port.d.ts.map +1 -1
- package/dist/web/public/admin-ui.js.gz +0 -0
- package/dist/web/public/api-client.c9b1cddc.js.gz +0 -0
- package/dist/web/public/app.3093527f.js +43 -0
- package/dist/web/public/app.3093527f.js.br +0 -0
- package/dist/web/public/app.3093527f.js.gz +0 -0
- package/dist/web/public/approvals-ui.js.gz +0 -0
- package/dist/web/public/{constants.0b3cdde9.js → constants.affcdde6.js} +611 -12
- package/dist/web/public/constants.affcdde6.js.br +0 -0
- package/dist/web/public/constants.affcdde6.js.gz +0 -0
- package/dist/web/public/cron-ui.js.gz +0 -0
- package/dist/web/public/entrance-animations.js.gz +0 -0
- package/dist/web/public/git-status-ui.js +88 -5
- package/dist/web/public/git-status-ui.js.br +0 -0
- package/dist/web/public/git-status-ui.js.gz +0 -0
- package/dist/web/public/header-divider.eb2d2463.js.gz +0 -0
- package/dist/web/public/home-sessions.js.gz +0 -0
- package/dist/web/public/host-wake-ui.js.gz +0 -0
- package/dist/web/public/i18n.70265257.js +2 -0
- package/dist/web/public/i18n.70265257.js.br +0 -0
- package/dist/web/public/i18n.70265257.js.gz +0 -0
- package/dist/web/public/image-input.941386e7.js.gz +0 -0
- package/dist/web/public/index.html +107 -16
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/input-cjk.8bc46081.js.gz +0 -0
- package/dist/web/public/keyboard-accessory.4e8b20ac.js.gz +0 -0
- package/dist/web/public/{mobile-handlers.1c0cbcd8.js → mobile-handlers.f7c3f692.js} +22 -0
- package/dist/web/public/mobile-handlers.f7c3f692.js.br +0 -0
- package/dist/web/public/mobile-handlers.f7c3f692.js.gz +0 -0
- package/dist/web/public/mobile-ime-preview.2dbaf262.js.gz +0 -0
- package/dist/web/public/mobile-overview.js.gz +0 -0
- package/dist/web/public/mobile.1bbd2d67.css +1 -0
- package/dist/web/public/mobile.1bbd2d67.css.br +0 -0
- package/dist/web/public/mobile.1bbd2d67.css.gz +0 -0
- package/dist/web/public/notification-manager.feb82c04.js.gz +0 -0
- package/dist/web/public/orchestrator-panel.js.gz +0 -0
- package/dist/web/public/{panels-ui.78c3defb.js → panels-ui.4a56ee63.js} +1 -1
- package/dist/web/public/panels-ui.4a56ee63.js.br +0 -0
- package/dist/web/public/panels-ui.4a56ee63.js.gz +0 -0
- package/dist/web/public/ralph-panel.b350e2ef.js.gz +0 -0
- package/dist/web/public/ralph-wizard.b6c0dd19.js.gz +0 -0
- package/dist/web/public/readmymind-ui.js.gz +0 -0
- package/dist/web/public/reboot-restore-ui.js.gz +0 -0
- package/dist/web/public/respawn-ui.d7adcf6a.js.gz +0 -0
- package/dist/web/public/sanitize-html.e7147e76.js.gz +0 -0
- package/dist/web/public/session-lineage.js.gz +0 -0
- package/dist/web/public/{session-ui.ea6b1994.js → session-ui.186f9419.js} +8 -8
- package/dist/web/public/session-ui.186f9419.js.br +0 -0
- package/dist/web/public/session-ui.186f9419.js.gz +0 -0
- package/dist/web/public/{settings-ui.085dd202.js → settings-ui.a191154e.js} +11 -11
- package/dist/web/public/settings-ui.a191154e.js.br +0 -0
- package/dist/web/public/settings-ui.a191154e.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview-worker.js.gz +0 -0
- package/dist/web/public/spreadsheet-preview.75c751de.js.gz +0 -0
- package/dist/web/public/spreadsheet-xlsx-core.js.gz +0 -0
- package/dist/web/public/styles.7238eae8.css +1 -0
- package/dist/web/public/styles.7238eae8.css.br +0 -0
- package/dist/web/public/styles.7238eae8.css.gz +0 -0
- package/dist/web/public/subagent-windows.07277a58.js.gz +0 -0
- package/dist/web/public/sw.js +2 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/public/tab-layout-browser.2d6189f7.js.gz +0 -0
- package/dist/web/public/{tab-rail-resize.7157e0bd.js → tab-rail-resize.b5cdceaf.js} +1 -1
- package/dist/web/public/tab-rail-resize.b5cdceaf.js.br +0 -0
- package/dist/web/public/tab-rail-resize.b5cdceaf.js.gz +0 -0
- package/dist/web/public/terminal-keycode229-recovery.af09f99a.js.gz +0 -0
- package/dist/web/public/terminal-split.js.gz +0 -0
- package/dist/web/public/terminal-tile.js +2 -0
- package/dist/web/public/terminal-tile.js.br +0 -0
- package/dist/web/public/terminal-tile.js.gz +0 -0
- package/dist/web/public/terminal-ui.7e787829.js +4 -0
- package/dist/web/public/terminal-ui.7e787829.js.br +0 -0
- package/dist/web/public/terminal-ui.7e787829.js.gz +0 -0
- package/dist/web/public/tile-grid.js.gz +0 -0
- package/dist/web/public/ultracode-panel.js.gz +0 -0
- package/dist/web/public/ultracode-windows.js.gz +0 -0
- package/dist/web/public/vendor/dompurify.min.js.gz +0 -0
- package/dist/web/public/vendor/exceljs.min.js.gz +0 -0
- package/dist/web/public/vendor/fflate.min.js.gz +0 -0
- package/dist/web/public/vendor/marked.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-fit.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-serialize.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-unicode11.min.js.gz +0 -0
- package/dist/web/public/vendor/xterm-addon-webgl.min.js.gz +0 -0
- package/dist/web/public/vendor/{xterm-predictive-echo.ae5e3d5d.js → xterm-predictive-echo.276b11d9.js} +1 -1
- package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.br +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.276b11d9.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js +5 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.1be42307.js.gz +0 -0
- package/dist/web/public/vendor/xterm.css.gz +0 -0
- package/dist/web/public/vendor/xterm.min.js.gz +0 -0
- package/dist/web/public/voice-input.2342c37b.js.gz +0 -0
- package/dist/web/public/voice-pcm-worklet.js.gz +0 -0
- package/dist/web/public/webview-tabs.js.gz +0 -0
- package/dist/web/public/whats-new.json +9 -9
- package/dist/web/routes/agent-session-routes.d.ts +20 -0
- package/dist/web/routes/agent-session-routes.d.ts.map +1 -0
- package/dist/web/routes/agent-session-routes.js +160 -0
- package/dist/web/routes/agent-session-routes.js.map +1 -0
- package/dist/web/routes/case-routes.d.ts.map +1 -1
- package/dist/web/routes/case-routes.js +32 -26
- package/dist/web/routes/case-routes.js.map +1 -1
- package/dist/web/routes/config-backup-routes.js +2 -2
- package/dist/web/routes/config-backup-routes.js.map +1 -1
- package/dist/web/routes/git-status-routes.d.ts +21 -7
- package/dist/web/routes/git-status-routes.d.ts.map +1 -1
- package/dist/web/routes/git-status-routes.js +78 -12
- package/dist/web/routes/git-status-routes.js.map +1 -1
- package/dist/web/routes/index.d.ts +1 -0
- package/dist/web/routes/index.d.ts.map +1 -1
- package/dist/web/routes/index.js +1 -0
- package/dist/web/routes/index.js.map +1 -1
- package/dist/web/routes/ralph-routes.d.ts.map +1 -1
- package/dist/web/routes/ralph-routes.js +7 -16
- package/dist/web/routes/ralph-routes.js.map +1 -1
- package/dist/web/routes/session-routes.d.ts.map +1 -1
- package/dist/web/routes/session-routes.js +96 -28
- package/dist/web/routes/session-routes.js.map +1 -1
- package/dist/web/routes/system-routes.d.ts.map +1 -1
- package/dist/web/routes/system-routes.js +84 -57
- package/dist/web/routes/system-routes.js.map +1 -1
- package/dist/web/routes/ws-routes.d.ts +5 -0
- package/dist/web/routes/ws-routes.d.ts.map +1 -1
- package/dist/web/routes/ws-routes.js +84 -47
- package/dist/web/routes/ws-routes.js.map +1 -1
- package/dist/web/schemas.d.ts +30 -7
- package/dist/web/schemas.d.ts.map +1 -1
- package/dist/web/schemas.js +40 -1
- package/dist/web/schemas.js.map +1 -1
- package/dist/web/server.d.ts +5 -2
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +84 -14
- package/dist/web/server.js.map +1 -1
- package/dist/web/settings-writer.d.ts +47 -0
- package/dist/web/settings-writer.d.ts.map +1 -0
- package/dist/web/settings-writer.js +110 -0
- package/dist/web/settings-writer.js.map +1 -0
- package/package.json +1 -1
- package/skills/codeman/SKILL.md +25 -6
- package/skills/codeman/reference/history.md +84 -0
- package/skills/codeman/reference/inspection-and-staging.md +152 -0
- package/skills/codeman/reference/messaging.md +1 -1
- package/skills/codeman/reference/verbs.md +18 -3
- package/dist/web/public/app.42d0abae.js +0 -43
- package/dist/web/public/app.42d0abae.js.br +0 -0
- package/dist/web/public/app.42d0abae.js.gz +0 -0
- package/dist/web/public/constants.0b3cdde9.js.br +0 -0
- package/dist/web/public/constants.0b3cdde9.js.gz +0 -0
- package/dist/web/public/i18n.1e68220d.js +0 -2
- package/dist/web/public/i18n.1e68220d.js.br +0 -0
- package/dist/web/public/i18n.1e68220d.js.gz +0 -0
- package/dist/web/public/mobile-handlers.1c0cbcd8.js.br +0 -0
- package/dist/web/public/mobile-handlers.1c0cbcd8.js.gz +0 -0
- package/dist/web/public/mobile.12fb18de.css +0 -1
- package/dist/web/public/mobile.12fb18de.css.br +0 -0
- package/dist/web/public/mobile.12fb18de.css.gz +0 -0
- package/dist/web/public/panels-ui.78c3defb.js.br +0 -0
- package/dist/web/public/panels-ui.78c3defb.js.gz +0 -0
- package/dist/web/public/session-ui.ea6b1994.js.br +0 -0
- package/dist/web/public/session-ui.ea6b1994.js.gz +0 -0
- package/dist/web/public/settings-ui.085dd202.js.br +0 -0
- package/dist/web/public/settings-ui.085dd202.js.gz +0 -0
- package/dist/web/public/styles.3b59b41d.css +0 -1
- package/dist/web/public/styles.3b59b41d.css.br +0 -0
- package/dist/web/public/styles.3b59b41d.css.gz +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.br +0 -0
- package/dist/web/public/tab-rail-resize.7157e0bd.js.gz +0 -0
- package/dist/web/public/terminal-ui.fb2a27f5.js +0 -2
- package/dist/web/public/terminal-ui.fb2a27f5.js.br +0 -0
- package/dist/web/public/terminal-ui.fb2a27f5.js.gz +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.br +0 -0
- package/dist/web/public/vendor/xterm-predictive-echo.ae5e3d5d.js.gz +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js +0 -4
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.br +0 -0
- package/dist/web/public/vendor/xterm-zerolag-input.0b69623c.js.gz +0 -0
|
@@ -0,0 +1,750 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Offline reader for saved Codex conversations, behind
|
|
3
|
+
* `codeman history read` (`cli-history.ts`). It reads rollout JSONL files from
|
|
4
|
+
* disk directly: no HTTP route, no running server and no managed session.
|
|
5
|
+
*
|
|
6
|
+
* ## Selecting conversations
|
|
7
|
+
*
|
|
8
|
+
* Exactly one selector: `file` (one rollout, no store search), `sessionId` (a
|
|
9
|
+
* native thread id or unique prefix of at least 8 characters) or
|
|
10
|
+
* `rootSessionId` (a full id plus every transitive descendant). Identity always
|
|
11
|
+
* comes from each file's `session_meta`, never from its filename.
|
|
12
|
+
*
|
|
13
|
+
* ## Budgets
|
|
14
|
+
*
|
|
15
|
+
* Three separate bounds, each raisable from the CLI up to a fixed maximum, and
|
|
16
|
+
* each failing closed with an error rather than returning a partial answer:
|
|
17
|
+
*
|
|
18
|
+
* - `metadataBytes`: the head reads that attribute every rollout in the store.
|
|
19
|
+
* A `session_meta` line embeds Codex's base instructions (about 20 KiB on
|
|
20
|
+
* every rollout measured), so this scales with the store, not the query,
|
|
21
|
+
* and is kept apart from the transcript budget: a lookup on a store of
|
|
22
|
+
* thousands of rollouts must not spend the budget meant for the transcripts
|
|
23
|
+
* it returns. Each file's head read is also capped at 128 KiB.
|
|
24
|
+
* - `scanBytes`: the selected transcripts, read whole.
|
|
25
|
+
* - `maxEntries`: directory entries visited.
|
|
26
|
+
*
|
|
27
|
+
* ## Forks and sub-agents
|
|
28
|
+
*
|
|
29
|
+
* A thread Codex forks or spawns as a sub-agent can carry a copy of its
|
|
30
|
+
* parent's history in its own file (codex-rs `ForkPersistence::Copied`, written
|
|
31
|
+
* by `record_initial_history` in `core/src/session/mod.rs`; the copy keeps the
|
|
32
|
+
* parent's `session_meta`, see `keep_forked_rollout_item` in
|
|
33
|
+
* `core/src/agent/control/spawn.rs`). Such a file starts with its own
|
|
34
|
+
* `session_meta`, then the parent's, then the copied records, then one
|
|
35
|
+
* `thread_settings_applied` event, then the child's own records. The copy
|
|
36
|
+
* already lives in the parent's own file, so it is dropped here:
|
|
37
|
+
*
|
|
38
|
+
* - Paginated history (`history_mode: "paginated"`) numbers every record with
|
|
39
|
+
* an `ordinal` and declares `subagent_history_start_ordinal`; records below
|
|
40
|
+
* it are inherited, exactly as Codex's own projection treats them
|
|
41
|
+
* (`thread-store/src/local/thread_history_materialization.rs`). A paginated
|
|
42
|
+
* thread with a `history_base` references its prefix in another file and has
|
|
43
|
+
* nothing inherited to drop.
|
|
44
|
+
* - Legacy history has no ordinals. The prefix runs from the inherited
|
|
45
|
+
* `session_meta` to the `thread_settings_applied` event Codex appends after
|
|
46
|
+
* the copy. A legacy copy keeps the parent's own `thread_settings_applied`
|
|
47
|
+
* events too, so when more than one candidate follows, the boundary is the
|
|
48
|
+
* last one whose preceding messages are still a prefix of the parent's own
|
|
49
|
+
* messages, read from the parent's file (once: a family read that selects
|
|
50
|
+
* the parent reuses that same read, so no file is charged to `scanBytes`
|
|
51
|
+
* twice). Without a readable parent file that cannot be proven: the cut then
|
|
52
|
+
* falls at the LAST candidate, so parent text is never attributed to the
|
|
53
|
+
* child, and the result says so (`unverifiedPrefixes`, `complete: false`).
|
|
54
|
+
* Any failure of the parent's read other than a budget leads here, rather
|
|
55
|
+
* than failing the child whose own file is intact.
|
|
56
|
+
*
|
|
57
|
+
* A later `session_meta` must be the file's own id or a declared ancestor
|
|
58
|
+
* (`parent_thread_id`, `forked_from_id`, or the shared root `session_id`, and
|
|
59
|
+
* transitively the ancestors those declare); any other id still refuses the
|
|
60
|
+
* whole file rather than attribute one conversation's answer to another.
|
|
61
|
+
*
|
|
62
|
+
* ## Compressed rollouts
|
|
63
|
+
*
|
|
64
|
+
* Codex's `local_thread_store_compression` feature rewrites cold rollouts to
|
|
65
|
+
* `*.jsonl.zst` in place. This reader does not decompress them: a store scan
|
|
66
|
+
* counts each one in `compressedFiles` (so `complete` is false and a not-found
|
|
67
|
+
* error names them), skipping one whose plain `.jsonl` sibling exists, as
|
|
68
|
+
* Codex's own discovery does. A Zstandard file given to `file` is refused by
|
|
69
|
+
* its magic number, whatever its name.
|
|
70
|
+
*
|
|
71
|
+
* ## Live rollouts
|
|
72
|
+
*
|
|
73
|
+
* A running Codex appends to its rollout. Reads take a snapshot: the file's
|
|
74
|
+
* identity (device and inode) must match the attributed one and its size may
|
|
75
|
+
* only have grown; everything up to the last complete line is read, and an
|
|
76
|
+
* unterminated tail is reported as a partial record.
|
|
77
|
+
*
|
|
78
|
+
* ## Format
|
|
79
|
+
*
|
|
80
|
+
* The line shapes and the injected-context rule are shared with the Past
|
|
81
|
+
* Sessions scanner through `codex-rollout-format.ts`.
|
|
82
|
+
*
|
|
83
|
+
* @module local-history
|
|
84
|
+
*/
|
|
85
|
+
import { constants } from 'node:fs';
|
|
86
|
+
import { isUtf8 } from 'node:buffer';
|
|
87
|
+
import { lstat, open, opendir, stat } from 'node:fs/promises';
|
|
88
|
+
import { basename, join, resolve } from 'node:path';
|
|
89
|
+
import { codexMessageFromLine, codexSessionsRoot, isCodexInjectedContext } from './codex-rollout-format.js';
|
|
90
|
+
const KIB = 1024;
|
|
91
|
+
const MIB = 1024 * KIB;
|
|
92
|
+
const GIB = 1024 * MIB;
|
|
93
|
+
export const HISTORY_DEFAULT_SCAN_BYTES = 16 * MIB;
|
|
94
|
+
export const HISTORY_MAX_SCAN_BYTES = 256 * MIB;
|
|
95
|
+
export const HISTORY_DEFAULT_METADATA_BYTES = 1 * GIB;
|
|
96
|
+
export const HISTORY_MAX_METADATA_BYTES = 16 * GIB;
|
|
97
|
+
export const HISTORY_DEFAULT_MAX_ENTRIES = 65536;
|
|
98
|
+
export const HISTORY_MAX_ENTRIES = 1048576;
|
|
99
|
+
export const HISTORY_DEFAULT_OUTPUT_BYTES = 8192;
|
|
100
|
+
export const HISTORY_MAX_OUTPUT_BYTES = 1 * MIB;
|
|
101
|
+
/** Per-file head window for `session_meta`. The line itself runs to about 20 KiB. */
|
|
102
|
+
const METADATA_HEAD_BYTES = 128 * KIB;
|
|
103
|
+
/** Read the head in steps, so a typical rollout costs one step past its session_meta line. */
|
|
104
|
+
const METADATA_CHUNK_BYTES = 32 * KIB;
|
|
105
|
+
/**
|
|
106
|
+
* Codex's compressed rollout suffix (codex-rs `rollout/src/compression.rs`:
|
|
107
|
+
* `is_compressed_rollout_path` matches a name ending in `.jsonl.zst`).
|
|
108
|
+
*/
|
|
109
|
+
const COMPRESSED_ROLLOUT_SUFFIX = '.jsonl.zst';
|
|
110
|
+
/** The Zstandard frame magic number, little-endian on disk. */
|
|
111
|
+
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd]);
|
|
112
|
+
/** Codex writes `sessions/YYYY/MM/DD`; this leaves five levels of headroom. */
|
|
113
|
+
const MAX_DIRECTORY_DEPTH = 8;
|
|
114
|
+
const ID = /^[0-9a-f]{8}(?:[0-9a-f-]{0,28})$/i;
|
|
115
|
+
const FULL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
116
|
+
/** A budget ran out: always fatal, never counted. */
|
|
117
|
+
class HistoryBudgetError extends Error {
|
|
118
|
+
}
|
|
119
|
+
/** One store file could not be attributed: fatal for `--file`, counted for a store scan. */
|
|
120
|
+
class UnattributableFileError extends Error {
|
|
121
|
+
}
|
|
122
|
+
function object(value) {
|
|
123
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
|
124
|
+
}
|
|
125
|
+
function string(value) {
|
|
126
|
+
return typeof value === 'string' ? value : undefined;
|
|
127
|
+
}
|
|
128
|
+
function fullId(value) {
|
|
129
|
+
const id = string(value);
|
|
130
|
+
return id && FULL_ID.test(id) ? id.toLowerCase() : undefined;
|
|
131
|
+
}
|
|
132
|
+
function fileIdentity(value) {
|
|
133
|
+
return `${value.dev}:${value.ino}`;
|
|
134
|
+
}
|
|
135
|
+
function validDate(value) {
|
|
136
|
+
return (value === undefined ||
|
|
137
|
+
(/^\d{4}-\d{2}-\d{2}$/.test(value) &&
|
|
138
|
+
Number.isFinite(Date.parse(value)) &&
|
|
139
|
+
new Date(value).toISOString().slice(0, 10) === value));
|
|
140
|
+
}
|
|
141
|
+
/** The UTC calendar date of a native timestamp, or undefined when it does not parse. */
|
|
142
|
+
function utcDate(timestamp) {
|
|
143
|
+
const ms = Date.parse(timestamp);
|
|
144
|
+
return Number.isFinite(ms) ? new Date(ms).toISOString().slice(0, 10) : undefined;
|
|
145
|
+
}
|
|
146
|
+
function checkBudget(value, max, flag) {
|
|
147
|
+
if (!Number.isSafeInteger(value) || value < 1 || value > max)
|
|
148
|
+
throw new Error(`Invalid ${flag} (1-${max})`);
|
|
149
|
+
}
|
|
150
|
+
function validate(options) {
|
|
151
|
+
if ([options.file, options.sessionId, options.rootSessionId].filter((v) => v !== undefined).length !== 1)
|
|
152
|
+
throw new Error('Select exactly one of --file, --session-id, or --root-session-id');
|
|
153
|
+
if (options.file !== undefined && !options.file)
|
|
154
|
+
throw new Error('A nonempty file is required');
|
|
155
|
+
if (options.sessionId !== undefined && !ID.test(options.sessionId))
|
|
156
|
+
throw new Error('Invalid native session id');
|
|
157
|
+
if (options.rootSessionId !== undefined && !FULL_ID.test(options.rootSessionId))
|
|
158
|
+
throw new Error('Invalid root session id');
|
|
159
|
+
if (!validDate(options.after) ||
|
|
160
|
+
!validDate(options.before) ||
|
|
161
|
+
(options.after && options.before && options.after > options.before))
|
|
162
|
+
throw new Error('Invalid date or date range (use YYYY-MM-DD, UTC)');
|
|
163
|
+
if (options.topic !== undefined && !options.topic.trim())
|
|
164
|
+
throw new Error('Invalid empty topic');
|
|
165
|
+
checkBudget(options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES, HISTORY_MAX_SCAN_BYTES, '--scan-bytes');
|
|
166
|
+
checkBudget(options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES, HISTORY_MAX_METADATA_BYTES, '--metadata-bytes');
|
|
167
|
+
checkBudget(options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES, HISTORY_MAX_ENTRIES, '--max-entries');
|
|
168
|
+
}
|
|
169
|
+
async function regularFile(path) {
|
|
170
|
+
// O_NOFOLLOW prevents a final-component link swap between lstat and open. fstat
|
|
171
|
+
// validates the opened descriptor, not a previously inspected pathname.
|
|
172
|
+
if (!(await lstat(path)).isFile())
|
|
173
|
+
throw new Error('History source must be a regular file, not a symlink');
|
|
174
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
175
|
+
if (!(await handle.stat()).isFile()) {
|
|
176
|
+
await handle.close();
|
|
177
|
+
throw new Error('History source must be a regular file');
|
|
178
|
+
}
|
|
179
|
+
return handle;
|
|
180
|
+
}
|
|
181
|
+
async function readAt(handle, position, size) {
|
|
182
|
+
const buffer = Buffer.alloc(size);
|
|
183
|
+
let filled = 0;
|
|
184
|
+
while (filled < size) {
|
|
185
|
+
const { bytesRead } = await handle.read(buffer, filled, size - filled, position + filled);
|
|
186
|
+
if (!bytesRead)
|
|
187
|
+
break;
|
|
188
|
+
filled += bytesRead;
|
|
189
|
+
}
|
|
190
|
+
return buffer.subarray(0, filled);
|
|
191
|
+
}
|
|
192
|
+
async function readMetadataChunk(handle, position, size, budget) {
|
|
193
|
+
if (size > budget.metadataRemaining)
|
|
194
|
+
throw new HistoryBudgetError('History metadata byte budget exhausted; raise --metadata-bytes');
|
|
195
|
+
const chunk = await readAt(handle, position, size);
|
|
196
|
+
budget.metadataRemaining -= chunk.length;
|
|
197
|
+
budget.scan.metadataBytesRead += chunk.length;
|
|
198
|
+
return chunk;
|
|
199
|
+
}
|
|
200
|
+
async function readTranscript(handle, size, budget) {
|
|
201
|
+
if (size > budget.transcriptRemaining)
|
|
202
|
+
throw new HistoryBudgetError('History scan byte budget exhausted; narrow the selection or raise --scan-bytes');
|
|
203
|
+
const buffer = await readAt(handle, 0, size);
|
|
204
|
+
budget.transcriptRemaining -= buffer.length;
|
|
205
|
+
budget.scan.bytesRead += buffer.length;
|
|
206
|
+
return buffer;
|
|
207
|
+
}
|
|
208
|
+
function metadata(value, path) {
|
|
209
|
+
const entry = object(value);
|
|
210
|
+
if (entry.type !== 'session_meta')
|
|
211
|
+
return undefined;
|
|
212
|
+
const p = object(entry.payload);
|
|
213
|
+
const sessionId = fullId(p.id) ?? fullId(p.session_id);
|
|
214
|
+
if (!sessionId)
|
|
215
|
+
return undefined;
|
|
216
|
+
const spawn = object(object(object(p.source).subagent).thread_spawn);
|
|
217
|
+
const forkedFromId = fullId(p.forked_from_id);
|
|
218
|
+
// A fork with no spawn parent (a `/fork` in the TUI) still descends from its source thread.
|
|
219
|
+
const parentId = fullId(p.parent_thread_id) ?? fullId(p.parent_session_id) ?? fullId(spawn.parent_thread_id) ?? forkedFromId;
|
|
220
|
+
// Codex: "session_id is equal to the root thread's ID".
|
|
221
|
+
const declaredRoot = fullId(p.root_session_id) ?? fullId(p.session_id);
|
|
222
|
+
const start = p.subagent_history_start_ordinal;
|
|
223
|
+
return {
|
|
224
|
+
path,
|
|
225
|
+
sessionId,
|
|
226
|
+
parentId,
|
|
227
|
+
forkedFromId,
|
|
228
|
+
rootId: declaredRoot && declaredRoot !== sessionId ? declaredRoot : undefined,
|
|
229
|
+
historyStart: Number.isSafeInteger(start) && start >= 0 ? start : undefined,
|
|
230
|
+
timestamp: string(p.timestamp) ?? string(entry.timestamp) ?? '',
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function sameIdentity(a, b) {
|
|
234
|
+
return (a.sessionId === b.sessionId &&
|
|
235
|
+
a.parentId === b.parentId &&
|
|
236
|
+
a.forkedFromId === b.forkedFromId &&
|
|
237
|
+
a.rootId === b.rootId &&
|
|
238
|
+
a.historyStart === b.historyStart);
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* The first `session_meta` within the file's head. Problems with the file
|
|
242
|
+
* itself throw {@link UnattributableFileError}; a budget throws
|
|
243
|
+
* {@link HistoryBudgetError}; a short file with no metadata yet returns undefined.
|
|
244
|
+
*/
|
|
245
|
+
async function readMetadata(path, budget) {
|
|
246
|
+
let handle;
|
|
247
|
+
try {
|
|
248
|
+
handle = await regularFile(path);
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
throw new UnattributableFileError(error instanceof Error ? error.message : String(error));
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
const before = await handle.stat();
|
|
255
|
+
const limit = Math.min(before.size, METADATA_HEAD_BYTES);
|
|
256
|
+
let buffer = Buffer.alloc(0);
|
|
257
|
+
let parsed = 0;
|
|
258
|
+
while (buffer.length < limit) {
|
|
259
|
+
const chunk = await readMetadataChunk(handle, buffer.length, Math.min(METADATA_CHUNK_BYTES, limit - buffer.length), budget);
|
|
260
|
+
if (!chunk.length)
|
|
261
|
+
break;
|
|
262
|
+
if (!buffer.length && chunk.subarray(0, ZSTD_MAGIC.length).equals(ZSTD_MAGIC))
|
|
263
|
+
throw new UnattributableFileError('History source is Zstandard-compressed (a Codex .jsonl.zst rollout); only plain .jsonl is read');
|
|
264
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
265
|
+
let end = buffer.indexOf(10, parsed);
|
|
266
|
+
while (end >= 0) {
|
|
267
|
+
const line = buffer.subarray(parsed, end);
|
|
268
|
+
parsed = end + 1;
|
|
269
|
+
end = buffer.indexOf(10, parsed);
|
|
270
|
+
if (!isUtf8(line))
|
|
271
|
+
throw new UnattributableFileError('History metadata contains invalid UTF8');
|
|
272
|
+
let value;
|
|
273
|
+
try {
|
|
274
|
+
value = JSON.parse(line.toString('utf8'));
|
|
275
|
+
}
|
|
276
|
+
catch {
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const found = metadata(value, path);
|
|
280
|
+
if (!found)
|
|
281
|
+
continue;
|
|
282
|
+
// session_meta is written once; a growing file is a live rollout, not a change.
|
|
283
|
+
const after = await handle.stat();
|
|
284
|
+
if (fileIdentity(after) !== fileIdentity(before) || after.size < before.size)
|
|
285
|
+
throw new UnattributableFileError('History source changed during metadata scan');
|
|
286
|
+
return { ...found, identity: fileIdentity(before), size: before.size };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
if (buffer.length < limit)
|
|
290
|
+
throw new UnattributableFileError('History source changed during metadata scan');
|
|
291
|
+
if (before.size > METADATA_HEAD_BYTES)
|
|
292
|
+
throw new UnattributableFileError('No session metadata within the first 128 KiB');
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
await handle.close();
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
async function findSources(root, budget) {
|
|
300
|
+
// The root may itself be a symlink, as the Past Sessions scanner allows
|
|
301
|
+
// (`codex-transcript.ts` reads it with readdir, which follows it); links
|
|
302
|
+
// BELOW the root are never followed by either reader.
|
|
303
|
+
let rootStat;
|
|
304
|
+
try {
|
|
305
|
+
rootStat = await stat(root);
|
|
306
|
+
}
|
|
307
|
+
catch {
|
|
308
|
+
throw new Error(`Sessions root not found: ${root}`);
|
|
309
|
+
}
|
|
310
|
+
if (!rootStat.isDirectory())
|
|
311
|
+
throw new Error('Sessions root must be a directory');
|
|
312
|
+
const sources = [];
|
|
313
|
+
const visit = async (dir, depth) => {
|
|
314
|
+
if (depth > MAX_DIRECTORY_DEPTH)
|
|
315
|
+
throw new HistoryBudgetError('History directory scan depth budget exhausted');
|
|
316
|
+
let entries;
|
|
317
|
+
try {
|
|
318
|
+
entries = await opendir(dir);
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
budget.scan.unreadableDirectories++;
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
for await (const entry of entries) {
|
|
325
|
+
if (--budget.entriesRemaining < 0)
|
|
326
|
+
throw new HistoryBudgetError('History directory scan entry budget exhausted; raise --max-entries');
|
|
327
|
+
budget.scan.entries++;
|
|
328
|
+
const path = join(dir, entry.name);
|
|
329
|
+
if (entry.isSymbolicLink()) {
|
|
330
|
+
budget.scan.skippedSymlinks++;
|
|
331
|
+
continue;
|
|
332
|
+
}
|
|
333
|
+
if (entry.isDirectory()) {
|
|
334
|
+
// Recheck immediately before descending; never intentionally follow links.
|
|
335
|
+
const now = await lstat(path).catch(() => undefined);
|
|
336
|
+
if (!now?.isDirectory()) {
|
|
337
|
+
budget.scan.unreadableDirectories++;
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
await visit(path, depth + 1);
|
|
341
|
+
}
|
|
342
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
343
|
+
let source;
|
|
344
|
+
try {
|
|
345
|
+
source = await readMetadata(path, budget);
|
|
346
|
+
}
|
|
347
|
+
catch (error) {
|
|
348
|
+
if (!(error instanceof UnattributableFileError))
|
|
349
|
+
throw error;
|
|
350
|
+
}
|
|
351
|
+
if (source)
|
|
352
|
+
sources.push(source);
|
|
353
|
+
else
|
|
354
|
+
budget.scan.unattributedFiles++;
|
|
355
|
+
}
|
|
356
|
+
else if (entry.isFile() && entry.name.endsWith(COMPRESSED_ROLLOUT_SUFFIX)) {
|
|
357
|
+
// Codex reads the plain sibling instead when both exist (`should_skip_compressed_sibling`),
|
|
358
|
+
// and that sibling is visited (or counted) on its own.
|
|
359
|
+
const plain = await lstat(path.slice(0, -'.zst'.length)).catch(() => undefined);
|
|
360
|
+
if (plain)
|
|
361
|
+
continue;
|
|
362
|
+
budget.scan.compressedFiles++;
|
|
363
|
+
budget.compressedPaths.push(path);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
};
|
|
367
|
+
await visit(root, 0);
|
|
368
|
+
return sources;
|
|
369
|
+
}
|
|
370
|
+
function message(value) {
|
|
371
|
+
const entry = object(value);
|
|
372
|
+
const p = object(entry.payload);
|
|
373
|
+
if (entry.isSidechain || p.isSidechain)
|
|
374
|
+
return undefined;
|
|
375
|
+
const found = codexMessageFromLine(value);
|
|
376
|
+
if (!found)
|
|
377
|
+
return undefined;
|
|
378
|
+
// Codex context is not a human turn. Real prompts also arrive as events, which are never filtered.
|
|
379
|
+
if (found.injectionProne && isCodexInjectedContext(found.text))
|
|
380
|
+
return undefined;
|
|
381
|
+
return {
|
|
382
|
+
role: found.role,
|
|
383
|
+
message: found.text,
|
|
384
|
+
// Codex records a phase on assistant messages only; a user turn has none.
|
|
385
|
+
phase: found.role === 'assistant' ? (found.phase ?? null) : null,
|
|
386
|
+
representation: found.shape === 'item' ? 'modern' : found.shape,
|
|
387
|
+
timestamp: string(entry.timestamp) ?? string(p.timestamp) ?? '',
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
/** Native events and response_item commonly duplicate one accepted message. */
|
|
391
|
+
function collapse(items) {
|
|
392
|
+
const records = [];
|
|
393
|
+
let representations = new Set();
|
|
394
|
+
for (const raw of items) {
|
|
395
|
+
const item = { ...raw };
|
|
396
|
+
const previous = records.at(-1);
|
|
397
|
+
if (previous?.role === item.role &&
|
|
398
|
+
previous.message === item.message &&
|
|
399
|
+
!representations.has(item.representation)) {
|
|
400
|
+
representations.add(item.representation);
|
|
401
|
+
if (!previous.timestamp)
|
|
402
|
+
previous.timestamp = item.timestamp;
|
|
403
|
+
if (item.phase === 'final_answer' || previous.phase === null)
|
|
404
|
+
previous.phase = item.phase ?? previous.phase;
|
|
405
|
+
continue;
|
|
406
|
+
}
|
|
407
|
+
records.push(item);
|
|
408
|
+
representations = new Set([item.representation]);
|
|
409
|
+
}
|
|
410
|
+
return records;
|
|
411
|
+
}
|
|
412
|
+
function isPrefixOf(prefix, whole) {
|
|
413
|
+
return (prefix.length <= whole.length &&
|
|
414
|
+
prefix.every((item, i) => item.role === whole[i].role && item.message === whole[i].message));
|
|
415
|
+
}
|
|
416
|
+
async function parseSource(source, budget) {
|
|
417
|
+
const handle = await regularFile(source.path);
|
|
418
|
+
let buffer;
|
|
419
|
+
try {
|
|
420
|
+
const now = await handle.stat();
|
|
421
|
+
// A live rollout only grows. A different file or a shorter one is a rewrite.
|
|
422
|
+
if (fileIdentity(now) !== source.identity || now.size < source.size)
|
|
423
|
+
throw new Error('History source changed after attribution; retry explicitly');
|
|
424
|
+
buffer = await readTranscript(handle, now.size, budget);
|
|
425
|
+
if (buffer.length !== now.size)
|
|
426
|
+
throw new Error('History source shrank while reading; retry explicitly');
|
|
427
|
+
}
|
|
428
|
+
finally {
|
|
429
|
+
await handle.close();
|
|
430
|
+
}
|
|
431
|
+
// Snapshot up to the last complete line: a running Codex may be mid-append.
|
|
432
|
+
const end = buffer.lastIndexOf(10) + 1;
|
|
433
|
+
const complete = buffer.subarray(0, end);
|
|
434
|
+
if (buffer.subarray(end).toString('latin1').trim())
|
|
435
|
+
budget.scan.partialRecords++;
|
|
436
|
+
if (!isUtf8(complete))
|
|
437
|
+
throw new Error('History source contains invalid UTF8');
|
|
438
|
+
const ancestors = new Set([source.parentId, source.forkedFromId, source.rootId].filter((id) => !!id));
|
|
439
|
+
const raw = [];
|
|
440
|
+
const own = [];
|
|
441
|
+
let legacy;
|
|
442
|
+
let lastOrdinal = -1;
|
|
443
|
+
for (const line of complete.toString('utf8').split('\n')) {
|
|
444
|
+
if (!line.trim())
|
|
445
|
+
continue;
|
|
446
|
+
let parsed;
|
|
447
|
+
try {
|
|
448
|
+
parsed = JSON.parse(line);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
budget.scan.malformedRecords++;
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
const entry = object(parsed);
|
|
455
|
+
const ordinal = typeof entry.ordinal === 'number' ? entry.ordinal : undefined;
|
|
456
|
+
if (ordinal !== undefined)
|
|
457
|
+
lastOrdinal = Math.max(lastOrdinal, ordinal);
|
|
458
|
+
if (source.historyStart !== undefined && ordinal === undefined) {
|
|
459
|
+
// Codex's projection skips a paginated record without an ordinal; so does this, visibly.
|
|
460
|
+
budget.scan.malformedRecords++;
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
const inherited = source.historyStart !== undefined && ordinal < source.historyStart;
|
|
464
|
+
if (entry.type === 'session_meta') {
|
|
465
|
+
const declared = metadata(parsed, source.path);
|
|
466
|
+
if (declared && declared.sessionId === source.sessionId && sameIdentity(declared, source))
|
|
467
|
+
continue;
|
|
468
|
+
if (!declared || declared.sessionId === source.sessionId || !ancestors.has(declared.sessionId))
|
|
469
|
+
throw new Error('History source contains conflicting native metadata; attribution refused');
|
|
470
|
+
// An ancestor that was itself a fork carries its own ancestors' metadata in the copy.
|
|
471
|
+
for (const id of [declared.parentId, declared.forkedFromId, declared.rootId])
|
|
472
|
+
if (id)
|
|
473
|
+
ancestors.add(id);
|
|
474
|
+
if (inherited || legacy)
|
|
475
|
+
continue;
|
|
476
|
+
budget.scan.inheritedPrefixes++;
|
|
477
|
+
legacy = { ancestorId: declared.sessionId, pieces: [] };
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (legacy && entry.type === 'event_msg' && object(entry.payload).type === 'thread_settings_applied') {
|
|
481
|
+
if (!inherited)
|
|
482
|
+
legacy.pieces.push({ boundary: true });
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const item = message(parsed);
|
|
486
|
+
if (!item)
|
|
487
|
+
continue;
|
|
488
|
+
raw.push(item);
|
|
489
|
+
if (inherited)
|
|
490
|
+
continue;
|
|
491
|
+
if (legacy)
|
|
492
|
+
legacy.pieces.push(item);
|
|
493
|
+
else
|
|
494
|
+
own.push(item);
|
|
495
|
+
}
|
|
496
|
+
if (source.historyStart !== undefined && source.historyStart > 0) {
|
|
497
|
+
budget.scan.inheritedPrefixes++;
|
|
498
|
+
// Initialization died while copying the prefix: Codex refuses to resume such a file.
|
|
499
|
+
if (lastOrdinal < source.historyStart - 1)
|
|
500
|
+
budget.scan.incompletePrefixes++;
|
|
501
|
+
}
|
|
502
|
+
return { raw: collapse(raw), own, legacy };
|
|
503
|
+
}
|
|
504
|
+
/** The thread's own messages: its inherited prefix dropped, proven against the parent where one is needed. */
|
|
505
|
+
async function readMessages(parsed, budget, context) {
|
|
506
|
+
const own = [...parsed.own];
|
|
507
|
+
if (parsed.legacy)
|
|
508
|
+
own.push(...(await afterLegacyPrefix(parsed.legacy, budget, context)));
|
|
509
|
+
return collapse(own);
|
|
510
|
+
}
|
|
511
|
+
/** The child's own messages after a legacy inherited prefix. */
|
|
512
|
+
async function afterLegacyPrefix(prefix, budget, context) {
|
|
513
|
+
const boundaries = prefix.pieces.flatMap((piece, i) => ('boundary' in piece ? [i] : []));
|
|
514
|
+
if (!boundaries.length) {
|
|
515
|
+
budget.scan.incompletePrefixes++;
|
|
516
|
+
return [];
|
|
517
|
+
}
|
|
518
|
+
const messagesBefore = (index) => collapse(prefix.pieces.slice(0, index).filter((piece) => !('boundary' in piece)));
|
|
519
|
+
let cut = boundaries[0];
|
|
520
|
+
if (boundaries.length > 1) {
|
|
521
|
+
// The last candidate whose preceding messages the parent's own file also holds.
|
|
522
|
+
const parent = await context.ancestorMessages(prefix.ancestorId);
|
|
523
|
+
const proven = parent ? boundaries.filter((candidate) => isPrefixOf(messagesBefore(candidate), parent)) : [];
|
|
524
|
+
if (proven.length)
|
|
525
|
+
cut = proven.at(-1);
|
|
526
|
+
else {
|
|
527
|
+
// Unprovable: drop up to the last candidate, so parent text is never attributed to the child.
|
|
528
|
+
budget.scan.unverifiedPrefixes++;
|
|
529
|
+
cut = boundaries.at(-1);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return prefix.pieces.slice(cut + 1).filter((piece) => !('boundary' in piece));
|
|
533
|
+
}
|
|
534
|
+
function selectedTurns(messages, options) {
|
|
535
|
+
const turns = [];
|
|
536
|
+
for (const item of messages) {
|
|
537
|
+
if (item.role === 'user' || !turns.length)
|
|
538
|
+
turns.push([]);
|
|
539
|
+
turns.at(-1).push(item);
|
|
540
|
+
}
|
|
541
|
+
const needle = options.topic?.trim().toLocaleLowerCase();
|
|
542
|
+
return turns
|
|
543
|
+
.filter((turn) => {
|
|
544
|
+
const dateMatches = (!options.after && !options.before) ||
|
|
545
|
+
turn.some((item) => {
|
|
546
|
+
const date = utcDate(item.timestamp);
|
|
547
|
+
return (date !== undefined &&
|
|
548
|
+
(!options.after || date >= options.after) &&
|
|
549
|
+
(!options.before || date <= options.before));
|
|
550
|
+
});
|
|
551
|
+
return dateMatches && (!needle || turn.some((item) => item.message.toLocaleLowerCase().includes(needle)));
|
|
552
|
+
})
|
|
553
|
+
.flat();
|
|
554
|
+
}
|
|
555
|
+
/** Native ancestry is metadata evidence, not the conversation selected by a query. */
|
|
556
|
+
function nativeRoot(source, sources) {
|
|
557
|
+
const seen = new Set();
|
|
558
|
+
let current = source;
|
|
559
|
+
while (true) {
|
|
560
|
+
if (seen.has(current.sessionId))
|
|
561
|
+
throw new Error('Native history contains cyclic ancestry');
|
|
562
|
+
seen.add(current.sessionId);
|
|
563
|
+
if (current.rootId)
|
|
564
|
+
return current.rootId;
|
|
565
|
+
if (!current.parentId)
|
|
566
|
+
return current.sessionId;
|
|
567
|
+
const parents = sources.filter((candidate) => candidate.sessionId === current.parentId);
|
|
568
|
+
if (parents.length === 0)
|
|
569
|
+
return null;
|
|
570
|
+
if (parents.length > 1)
|
|
571
|
+
throw new Error('Native ancestor id is ambiguous');
|
|
572
|
+
current = parents[0];
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/**
|
|
576
|
+
* The not-found error, naming what the store held but could not attribute.
|
|
577
|
+
* Codex names a rollout `rollout-<timestamp>-<thread id>.jsonl[.zst]`, so a
|
|
578
|
+
* compressed file whose name carries the wanted id is named outright; the name
|
|
579
|
+
* is only a hint here, never identity.
|
|
580
|
+
*/
|
|
581
|
+
function missingSessionMessage(wanted, budget) {
|
|
582
|
+
const { scan } = budget;
|
|
583
|
+
const notes = [];
|
|
584
|
+
if (scan.compressedFiles) {
|
|
585
|
+
// `rollout-` (8) + `YYYY-MM-DDTHH-MM-SS` (19) + `-` (1), then the thread id.
|
|
586
|
+
const named = budget.compressedPaths.filter((path) => {
|
|
587
|
+
const name = basename(path).toLowerCase();
|
|
588
|
+
return name.startsWith('rollout-') && name.slice(28).startsWith(wanted);
|
|
589
|
+
});
|
|
590
|
+
const shown = named.slice(0, 3).join(', ') + (named.length > 3 ? ` and ${named.length - 3} more` : '');
|
|
591
|
+
notes.push(named.length
|
|
592
|
+
? `it may be in compressed rollout ${shown}, which this reader cannot open; read a decompressed copy with --file (zstd -dc <path> > copy.jsonl)`
|
|
593
|
+
: `${scan.compressedFiles} compressed .jsonl.zst rollout(s) could not be read`);
|
|
594
|
+
}
|
|
595
|
+
if (scan.unattributedFiles)
|
|
596
|
+
notes.push(`${scan.unattributedFiles} file(s) could not be attributed`);
|
|
597
|
+
return `Native session missing from history store${notes.length ? ` (${notes.join('; ')})` : ''}`;
|
|
598
|
+
}
|
|
599
|
+
export async function readLocalHistory(options) {
|
|
600
|
+
validate(options);
|
|
601
|
+
const scan = {
|
|
602
|
+
bytesRead: 0,
|
|
603
|
+
metadataBytesRead: 0,
|
|
604
|
+
entries: 0,
|
|
605
|
+
malformedRecords: 0,
|
|
606
|
+
partialRecords: 0,
|
|
607
|
+
skippedSymlinks: 0,
|
|
608
|
+
unattributedFiles: 0,
|
|
609
|
+
compressedFiles: 0,
|
|
610
|
+
unreadableDirectories: 0,
|
|
611
|
+
inheritedPrefixes: 0,
|
|
612
|
+
incompletePrefixes: 0,
|
|
613
|
+
unverifiedPrefixes: 0,
|
|
614
|
+
};
|
|
615
|
+
const budget = {
|
|
616
|
+
transcriptRemaining: options.scanBytes ?? HISTORY_DEFAULT_SCAN_BYTES,
|
|
617
|
+
metadataRemaining: options.metadataBytes ?? HISTORY_DEFAULT_METADATA_BYTES,
|
|
618
|
+
entriesRemaining: options.maxEntries ?? HISTORY_DEFAULT_MAX_ENTRIES,
|
|
619
|
+
scan,
|
|
620
|
+
compressedPaths: [],
|
|
621
|
+
};
|
|
622
|
+
let sources;
|
|
623
|
+
let attributionSources;
|
|
624
|
+
if (options.file !== undefined) {
|
|
625
|
+
let source;
|
|
626
|
+
try {
|
|
627
|
+
source = await readMetadata(resolve(options.file), budget);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
throw error instanceof UnattributableFileError ? new Error(error.message) : error;
|
|
631
|
+
}
|
|
632
|
+
if (!source)
|
|
633
|
+
throw new Error('History source is missing valid native session metadata');
|
|
634
|
+
sources = [source];
|
|
635
|
+
attributionSources = sources;
|
|
636
|
+
}
|
|
637
|
+
else {
|
|
638
|
+
const root = options.sessionsRoot ?? codexSessionsRoot();
|
|
639
|
+
const all = await findSources(resolve(root), budget);
|
|
640
|
+
const wanted = (options.sessionId ?? options.rootSessionId).toLowerCase();
|
|
641
|
+
const matches = all.filter((source) => source.sessionId.startsWith(wanted));
|
|
642
|
+
if (!matches.length)
|
|
643
|
+
throw new Error(missingSessionMessage(wanted, budget));
|
|
644
|
+
if (matches.length !== 1)
|
|
645
|
+
throw new Error('Native session id is ambiguous');
|
|
646
|
+
const target = matches[0];
|
|
647
|
+
attributionSources = all;
|
|
648
|
+
const queryRootId = target.sessionId;
|
|
649
|
+
sources = [target];
|
|
650
|
+
if (options.rootSessionId) {
|
|
651
|
+
const selected = new Set([target.sessionId]);
|
|
652
|
+
let changed = true;
|
|
653
|
+
while (changed) {
|
|
654
|
+
changed = false;
|
|
655
|
+
for (const source of all) {
|
|
656
|
+
if (!selected.has(source.sessionId) &&
|
|
657
|
+
(source.rootId === queryRootId || (source.parentId && selected.has(source.parentId)))) {
|
|
658
|
+
selected.add(source.sessionId);
|
|
659
|
+
sources.push(source);
|
|
660
|
+
changed = true;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
for (const source of sources) {
|
|
665
|
+
if (all.filter((other) => other.sessionId === source.sessionId).length !== 1)
|
|
666
|
+
throw new Error('Native family contains ambiguous duplicated session ids');
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
sources.sort((a, b) => a.timestamp.localeCompare(b.timestamp) || a.sessionId.localeCompare(b.sessionId));
|
|
671
|
+
const roots = new Map(sources.map((source) => [source.sessionId, nativeRoot(source, attributionSources)]));
|
|
672
|
+
// Each selected file is read once, whether the main loop or a child's boundary proof asks first.
|
|
673
|
+
const parsedCache = new Map();
|
|
674
|
+
const parse = (source) => {
|
|
675
|
+
let parsed = parsedCache.get(source);
|
|
676
|
+
if (!parsed) {
|
|
677
|
+
parsed = parseSource(source, budget);
|
|
678
|
+
parsedCache.set(source, parsed);
|
|
679
|
+
}
|
|
680
|
+
return parsed;
|
|
681
|
+
};
|
|
682
|
+
const context = {
|
|
683
|
+
// Raw messages need no boundary proof of their own, so this never recurses.
|
|
684
|
+
async ancestorMessages(id) {
|
|
685
|
+
const candidates = attributionSources.filter((source) => source.sessionId === id);
|
|
686
|
+
if (candidates.length !== 1)
|
|
687
|
+
return undefined;
|
|
688
|
+
try {
|
|
689
|
+
return (await parse(candidates[0])).raw;
|
|
690
|
+
}
|
|
691
|
+
catch (error) {
|
|
692
|
+
// A budget always fails the read; any other failure only leaves the boundary unproven.
|
|
693
|
+
if (error instanceof HistoryBudgetError)
|
|
694
|
+
throw error;
|
|
695
|
+
return undefined;
|
|
696
|
+
}
|
|
697
|
+
},
|
|
698
|
+
};
|
|
699
|
+
const records = [];
|
|
700
|
+
for (const source of sources) {
|
|
701
|
+
records.push(...selectedTurns(await readMessages(await parse(source), budget, context), options).map((item) => ({
|
|
702
|
+
rootSessionId: roots.get(source.sessionId) ?? null,
|
|
703
|
+
sessionId: source.sessionId,
|
|
704
|
+
role: item.role,
|
|
705
|
+
message: item.message,
|
|
706
|
+
phase: item.phase,
|
|
707
|
+
timestamp: item.timestamp,
|
|
708
|
+
})));
|
|
709
|
+
}
|
|
710
|
+
if (!records.length)
|
|
711
|
+
throw new Error('No matching user/assistant evidence found');
|
|
712
|
+
return {
|
|
713
|
+
source: 'codex-jsonl',
|
|
714
|
+
complete: scan.malformedRecords === 0 &&
|
|
715
|
+
scan.partialRecords === 0 &&
|
|
716
|
+
scan.skippedSymlinks === 0 &&
|
|
717
|
+
scan.unattributedFiles === 0 &&
|
|
718
|
+
scan.compressedFiles === 0 &&
|
|
719
|
+
scan.unreadableDirectories === 0 &&
|
|
720
|
+
scan.incompletePrefixes === 0 &&
|
|
721
|
+
scan.unverifiedPrefixes === 0 &&
|
|
722
|
+
[...roots.values()].every((root) => root !== null),
|
|
723
|
+
sessions: sources.map((source) => ({
|
|
724
|
+
sessionId: source.sessionId,
|
|
725
|
+
rootSessionId: roots.get(source.sessionId) ?? null,
|
|
726
|
+
...(source.parentId ? { parentSessionId: source.parentId } : {}),
|
|
727
|
+
path: source.path,
|
|
728
|
+
})),
|
|
729
|
+
records,
|
|
730
|
+
scan,
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
/** The `--max-bytes` check, exported so the CLI can make it before any file is read. */
|
|
734
|
+
export function validateOutputBytes(maxBytes) {
|
|
735
|
+
checkBudget(maxBytes, HISTORY_MAX_OUTPUT_BYTES, '--max-bytes');
|
|
736
|
+
}
|
|
737
|
+
/** Output never splits a JSON document, UTF8 character, record, or selected turn. */
|
|
738
|
+
export function formatLocalHistory(result, format = 'json', maxBytes = HISTORY_DEFAULT_OUTPUT_BYTES) {
|
|
739
|
+
validateOutputBytes(maxBytes);
|
|
740
|
+
const body = format === 'json'
|
|
741
|
+
? JSON.stringify(result)
|
|
742
|
+
: [
|
|
743
|
+
`source=${result.source} complete=${result.complete} scannedBytes=${result.scan.bytesRead} metadataBytes=${result.scan.metadataBytesRead}`,
|
|
744
|
+
...result.records.map((row) => `[root=${row.rootSessionId} session=${row.sessionId} ${row.timestamp} ${row.role}${row.phase ? ` ${row.phase}` : ''}]\n${row.message}`),
|
|
745
|
+
].join('\n\n');
|
|
746
|
+
if (Buffer.byteLength(body) + 1 > maxBytes)
|
|
747
|
+
throw new Error('History output byte budget exceeded; narrow filters or raise --max-bytes');
|
|
748
|
+
return body;
|
|
749
|
+
}
|
|
750
|
+
//# sourceMappingURL=local-history.js.map
|