@oh-my-pi/pi-coding-agent 15.12.3 → 15.13.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/CHANGELOG.md +347 -7
- package/dist/cli.js +1615 -1231
- package/dist/types/async/job-manager.d.ts +15 -0
- package/dist/types/autolearn/controller.d.ts +25 -0
- package/dist/types/autolearn/managed-skills.d.ts +45 -0
- package/dist/types/autoresearch/state.d.ts +1 -1
- package/dist/types/autoresearch/tools/init-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/log-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/run-experiment.d.ts +1 -1
- package/dist/types/autoresearch/tools/update-notes.d.ts +1 -1
- package/dist/types/autoresearch/types.d.ts +1 -1
- package/dist/types/cli/args.d.ts +19 -2
- package/dist/types/cli/models-cli.d.ts +49 -0
- package/dist/types/cli/session-picker.d.ts +1 -1
- package/dist/types/cli/setup-cli.d.ts +1 -1
- package/dist/types/cli/setup-model-picker.d.ts +14 -0
- package/dist/types/collab/protocol.d.ts +1 -1
- package/dist/types/commands/launch.d.ts +0 -3
- package/dist/types/commands/models.d.ts +33 -0
- package/dist/types/commands/say.d.ts +24 -0
- package/dist/types/commands/token.d.ts +25 -0
- package/dist/types/commit/agentic/tools/analyze-file.d.ts +1 -1
- package/dist/types/commit/agentic/tools/git-file-diff.d.ts +1 -1
- package/dist/types/commit/agentic/tools/git-hunk.d.ts +1 -1
- package/dist/types/commit/agentic/tools/git-overview.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-changelog.d.ts +1 -1
- package/dist/types/commit/agentic/tools/propose-commit.d.ts +1 -1
- package/dist/types/commit/agentic/tools/recent-commits.d.ts +1 -1
- package/dist/types/commit/agentic/tools/schemas.d.ts +1 -1
- package/dist/types/commit/agentic/tools/split-commit.d.ts +1 -1
- package/dist/types/commit/changelog/generate.d.ts +1 -1
- package/dist/types/commit/shared-llm.d.ts +1 -1
- package/dist/types/config/keybindings.d.ts +3 -3
- package/dist/types/config/model-registry.d.ts +17 -0
- package/dist/types/config/models-config-schema.d.ts +13 -1
- package/dist/types/config/models-config.d.ts +8 -2
- package/dist/types/config/settings-schema.d.ts +281 -58
- package/dist/types/edit/hashline/params.d.ts +1 -1
- package/dist/types/edit/modes/apply-patch.d.ts +1 -1
- package/dist/types/edit/modes/patch.d.ts +1 -1
- package/dist/types/edit/modes/replace.d.ts +1 -1
- package/dist/types/export/html/index.d.ts +2 -1
- package/dist/types/extensibility/custom-commands/types.d.ts +2 -2
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
- package/dist/types/extensibility/extensions/model-api.d.ts +17 -0
- package/dist/types/extensibility/extensions/runner.d.ts +3 -1
- package/dist/types/extensibility/extensions/types.d.ts +49 -3
- package/dist/types/extensibility/hooks/index.d.ts +2 -1
- package/dist/types/extensibility/hooks/types.d.ts +2 -2
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +9 -0
- package/dist/types/extensibility/plugins/loader.d.ts +11 -0
- package/dist/types/extensibility/shared-events.d.ts +1 -1
- package/dist/types/extensibility/skills.d.ts +10 -0
- package/dist/types/goals/guided-setup.d.ts +18 -0
- package/dist/types/goals/state.d.ts +1 -1
- package/dist/types/goals/tools/goal-tool.d.ts +1 -1
- package/dist/types/hindsight/transcript.d.ts +1 -1
- package/dist/types/index.d.ts +5 -0
- package/dist/types/internal-urls/local-protocol.d.ts +4 -2
- package/dist/types/lsp/types.d.ts +1 -1
- package/dist/types/main.d.ts +4 -3
- package/dist/types/mcp/manager.d.ts +8 -0
- package/dist/types/mcp/startup-events.d.ts +11 -0
- package/dist/types/memories/index.d.ts +7 -0
- package/dist/types/memory-backend/local-backend.d.ts +4 -3
- package/dist/types/mnemopi/config.d.ts +28 -0
- package/dist/types/modes/acp/acp-agent.d.ts +1 -2
- package/dist/types/modes/components/agent-hub.d.ts +6 -0
- package/dist/types/modes/components/assistant-message.d.ts +1 -2
- package/dist/types/modes/components/compaction-summary-message.d.ts +15 -1
- package/dist/types/modes/components/custom-editor.d.ts +39 -1
- package/dist/types/modes/components/custom-editor.test.d.ts +1 -0
- package/dist/types/modes/components/index.d.ts +1 -0
- package/dist/types/modes/components/logout-account-selector.d.ts +8 -0
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/components/status-line/component.d.ts +9 -5
- package/dist/types/modes/components/status-line/types.d.ts +2 -1
- package/dist/types/modes/components/tool-execution.d.ts +26 -16
- package/dist/types/modes/components/transcript-container.d.ts +23 -2
- package/dist/types/modes/components/tree-selector.d.ts +1 -1
- package/dist/types/modes/components/usage-row.d.ts +3 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -2
- package/dist/types/modes/controllers/event-controller.d.ts +0 -17
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
- package/dist/types/modes/gradient-highlight.d.ts +9 -4
- package/dist/types/modes/image-references.d.ts +6 -0
- package/dist/types/modes/interactive-mode.d.ts +27 -6
- package/dist/types/modes/magic-keywords.d.ts +13 -1
- package/dist/types/modes/rpc/rpc-mode.d.ts +35 -1
- package/dist/types/modes/rpc/rpc-types.d.ts +9 -1
- package/dist/types/modes/runtime-init.d.ts +4 -0
- package/dist/types/modes/theme/theme.d.ts +13 -2
- package/dist/types/modes/types.d.ts +8 -7
- package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
- package/dist/types/registry/agent-registry.d.ts +17 -0
- package/dist/types/secrets/obfuscator.d.ts +1 -1
- package/dist/types/session/agent-session.d.ts +28 -35
- package/dist/types/session/agent-storage.d.ts +2 -1
- package/dist/types/session/indexed-session-storage.d.ts +3 -3
- package/dist/types/session/messages.d.ts +8 -10
- package/dist/types/session/session-context.d.ts +39 -0
- package/dist/types/session/session-entries.d.ts +159 -0
- package/dist/types/session/session-listing.d.ts +69 -0
- package/dist/types/session/session-loader.d.ts +16 -0
- package/dist/types/session/session-manager.d.ts +85 -462
- package/dist/types/session/session-migrations.d.ts +12 -0
- package/dist/types/session/session-paths.d.ts +25 -0
- package/dist/types/session/session-persistence.d.ts +8 -0
- package/dist/types/session/session-storage.d.ts +11 -7
- package/dist/types/session/snapcompact-inline.d.ts +12 -1
- package/dist/types/session/snapcompact-savings-journal.d.ts +46 -0
- package/dist/types/session/tool-choice-queue.d.ts +6 -6
- package/dist/types/slash-commands/helpers/logout.d.ts +15 -0
- package/dist/types/stt/asr-client.d.ts +90 -0
- package/dist/types/stt/asr-protocol.d.ts +97 -0
- package/dist/types/stt/asr-worker.d.ts +2 -0
- package/dist/types/stt/downloader.d.ts +38 -0
- package/dist/types/stt/endpointer.d.ts +59 -0
- package/dist/types/stt/index.d.ts +5 -1
- package/dist/types/stt/models.d.ts +120 -0
- package/dist/types/stt/recorder.d.ts +17 -0
- package/dist/types/stt/stt-controller.d.ts +6 -0
- package/dist/types/stt/transcriber.d.ts +5 -7
- package/dist/types/stt/wav.d.ts +29 -0
- package/dist/types/system-prompt.d.ts +4 -0
- package/dist/types/task/executor.d.ts +2 -0
- package/dist/types/task/index.d.ts +9 -1
- package/dist/types/task/types.d.ts +37 -1
- package/dist/types/tools/ask.d.ts +1 -1
- package/dist/types/tools/ast-edit.d.ts +1 -1
- package/dist/types/tools/ast-grep.d.ts +1 -1
- package/dist/types/tools/bash.d.ts +3 -3
- package/dist/types/tools/browser/cmux/cmux-tab.d.ts +202 -0
- package/dist/types/tools/browser/cmux/rpc.d.ts +70 -0
- package/dist/types/tools/browser/cmux/socket-client.d.ts +19 -0
- package/dist/types/tools/browser/registry.d.ts +16 -3
- package/dist/types/tools/browser/render.d.ts +2 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +2 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +16 -4
- package/dist/types/tools/browser.d.ts +3 -1
- package/dist/types/tools/checkpoint.d.ts +1 -1
- package/dist/types/tools/debug.d.ts +1 -1
- package/dist/types/tools/eval-render.d.ts +1 -1
- package/dist/types/tools/eval.d.ts +1 -1
- package/dist/types/tools/find.d.ts +1 -1
- package/dist/types/tools/gh.d.ts +1 -1
- package/dist/types/tools/image-gen.d.ts +1 -1
- package/dist/types/tools/index.d.ts +14 -2
- package/dist/types/tools/inspect-image.d.ts +1 -1
- package/dist/types/tools/irc.d.ts +2 -1
- package/dist/types/tools/job.d.ts +1 -1
- package/dist/types/tools/learn.d.ts +51 -0
- package/dist/types/tools/manage-skill.d.ts +40 -0
- package/dist/types/tools/memory-edit.d.ts +1 -1
- package/dist/types/tools/memory-recall.d.ts +1 -1
- package/dist/types/tools/memory-reflect.d.ts +1 -1
- package/dist/types/tools/memory-retain.d.ts +1 -1
- package/dist/types/tools/plan-mode-guard.d.ts +10 -0
- package/dist/types/tools/read.d.ts +1 -1
- package/dist/types/tools/render-mermaid.d.ts +1 -1
- package/dist/types/tools/renderers.d.ts +7 -11
- package/dist/types/tools/resolve.d.ts +1 -1
- package/dist/types/tools/review.d.ts +1 -1
- package/dist/types/tools/search-tool-bm25.d.ts +1 -1
- package/dist/types/tools/search.d.ts +1 -1
- package/dist/types/tools/ssh.d.ts +2 -2
- package/dist/types/tools/todo.d.ts +2 -2
- package/dist/types/tools/tts.d.ts +26 -1
- package/dist/types/tools/write.d.ts +2 -2
- package/dist/types/tts/downloader.d.ts +20 -0
- package/dist/types/tts/index.d.ts +8 -0
- package/dist/types/tts/models.d.ts +82 -0
- package/dist/types/tts/player.d.ts +32 -0
- package/dist/types/tts/runtime.d.ts +6 -0
- package/dist/types/tts/streaming-player.d.ts +41 -0
- package/dist/types/tts/tts-client.d.ts +93 -0
- package/dist/types/tts/tts-protocol.d.ts +95 -0
- package/dist/types/tts/tts-worker.d.ts +2 -0
- package/dist/types/tts/vocalizer.d.ts +41 -0
- package/dist/types/tts/wav.d.ts +8 -0
- package/dist/types/utils/clipboard.d.ts +4 -3
- package/dist/types/utils/image-loading.d.ts +18 -1
- package/dist/types/utils/thinking-display.d.ts +17 -0
- package/dist/types/utils/tool-choice.d.ts +8 -0
- package/dist/types/utils/tools-manager.d.ts +2 -1
- package/dist/types/utils/tools-manager.test.d.ts +1 -0
- package/dist/types/web/scrapers/github.d.ts +1 -1
- package/dist/types/web/search/index.d.ts +1 -1
- package/package.json +17 -16
- package/src/async/job-manager.ts +49 -0
- package/src/autolearn/controller.ts +139 -0
- package/src/autolearn/managed-skills.ts +257 -0
- package/src/autoresearch/state.ts +1 -1
- package/src/autoresearch/storage.ts +2 -1
- package/src/autoresearch/tools/init-experiment.ts +1 -1
- package/src/autoresearch/tools/log-experiment.ts +1 -1
- package/src/autoresearch/tools/run-experiment.ts +1 -1
- package/src/autoresearch/tools/update-notes.ts +1 -1
- package/src/autoresearch/types.ts +1 -1
- package/src/cli/args.ts +56 -10
- package/src/cli/auth-gateway-cli.ts +1 -1
- package/src/cli/bench-cli.ts +1 -1
- package/src/cli/dry-balance-cli.ts +1 -1
- package/src/cli/models-cli.ts +427 -0
- package/src/cli/session-picker.ts +2 -1
- package/src/cli/setup-cli.ts +148 -47
- package/src/cli/setup-model-picker.ts +43 -0
- package/src/cli-commands.ts +3 -0
- package/src/cli.ts +45 -13
- package/src/collab/host.ts +10 -13
- package/src/collab/protocol.ts +1 -1
- package/src/commands/launch.ts +0 -3
- package/src/commands/models.ts +61 -0
- package/src/commands/say.ts +102 -0
- package/src/commands/setup.ts +1 -1
- package/src/commands/token.ts +89 -0
- package/src/commit/agentic/tools/analyze-file.ts +4 -1
- package/src/commit/agentic/tools/git-file-diff.ts +1 -1
- package/src/commit/agentic/tools/git-hunk.ts +1 -1
- package/src/commit/agentic/tools/git-overview.ts +1 -1
- package/src/commit/agentic/tools/propose-changelog.ts +1 -1
- package/src/commit/agentic/tools/propose-commit.ts +1 -1
- package/src/commit/agentic/tools/recent-commits.ts +1 -1
- package/src/commit/agentic/tools/schemas.ts +1 -1
- package/src/commit/agentic/tools/split-commit.ts +1 -1
- package/src/commit/analysis/summary.ts +1 -1
- package/src/commit/changelog/generate.ts +1 -1
- package/src/commit/shared-llm.ts +1 -1
- package/src/config/keybindings.ts +2 -2
- package/src/config/model-discovery.ts +11 -5
- package/src/config/model-registry.ts +79 -21
- package/src/config/model-resolver.ts +2 -2
- package/src/config/models-config-schema.ts +5 -2
- package/src/config/models-config.ts +2 -1
- package/src/config/settings-schema.ts +266 -32
- package/src/config/settings.ts +10 -0
- package/src/discovery/builtin.ts +23 -1
- package/src/discovery/claude-plugins.ts +44 -5
- package/src/discovery/helpers.ts +41 -1
- package/src/edit/hashline/params.ts +1 -1
- package/src/edit/modes/apply-patch.ts +1 -1
- package/src/edit/modes/patch.ts +1 -1
- package/src/edit/modes/replace.ts +1 -1
- package/src/eval/__tests__/budget-bridge.test.ts +1 -1
- package/src/eval/agent-bridge.ts +1 -1
- package/src/eval/completion-bridge.ts +1 -1
- package/src/eval/js/shared/prelude.txt +69 -17
- package/src/export/html/index.ts +3 -6
- package/src/export/html/template.js +24 -2
- package/src/export/html/tool-views.generated.js +2 -2
- package/src/extensibility/custom-commands/loader.ts +1 -1
- package/src/extensibility/custom-commands/types.ts +2 -2
- package/src/extensibility/custom-tools/loader.ts +1 -1
- package/src/extensibility/custom-tools/types.ts +2 -2
- package/src/extensibility/extensions/loader.ts +2 -2
- package/src/extensibility/extensions/model-api.ts +41 -0
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +54 -3
- package/src/extensibility/extensions/wrapper.ts +41 -5
- package/src/extensibility/hooks/index.ts +2 -1
- package/src/extensibility/hooks/loader.ts +1 -1
- package/src/extensibility/hooks/types.ts +2 -2
- package/src/extensibility/plugins/legacy-pi-compat.ts +43 -13
- package/src/extensibility/plugins/loader.ts +30 -19
- package/src/extensibility/plugins/manager.ts +221 -90
- package/src/extensibility/shared-events.ts +1 -1
- package/src/extensibility/skills.ts +101 -5
- package/src/goals/guided-setup.ts +133 -0
- package/src/goals/state.ts +1 -1
- package/src/goals/tools/goal-tool.ts +1 -1
- package/src/hindsight/transcript.ts +1 -1
- package/src/index.ts +5 -0
- package/src/internal-urls/docs-index.generated.ts +13 -10
- package/src/internal-urls/history-protocol.ts +1 -1
- package/src/internal-urls/local-protocol.ts +29 -7
- package/src/lsp/types.ts +1 -1
- package/src/main.ts +27 -32
- package/src/mcp/config-writer.ts +7 -3
- package/src/mcp/manager.ts +11 -0
- package/src/mcp/startup-events.ts +21 -0
- package/src/mcp/transports/stdio.ts +2 -1
- package/src/memories/index.ts +149 -12
- package/src/memories/storage.ts +2 -1
- package/src/memory-backend/local-backend.ts +11 -5
- package/src/mnemopi/backend.ts +1 -0
- package/src/mnemopi/config.ts +112 -12
- package/src/modes/acp/acp-agent.ts +8 -53
- package/src/modes/acp/acp-event-mapper.ts +5 -1
- package/src/modes/components/agent-hub.ts +51 -5
- package/src/modes/components/assistant-message.ts +12 -44
- package/src/modes/components/compaction-summary-message.ts +125 -26
- package/src/modes/components/custom-editor.test.ts +96 -0
- package/src/modes/components/custom-editor.ts +164 -8
- package/src/modes/components/index.ts +1 -0
- package/src/modes/components/logout-account-selector.ts +130 -0
- package/src/modes/components/mcp-add-wizard.ts +1 -1
- package/src/modes/components/model-selector.ts +2 -2
- package/src/modes/components/session-selector.ts +1 -1
- package/src/modes/components/settings-defs.ts +7 -0
- package/src/modes/components/status-line/component.ts +54 -157
- package/src/modes/components/status-line/segments.ts +1 -1
- package/src/modes/components/status-line/types.ts +2 -1
- package/src/modes/components/tool-execution.ts +82 -43
- package/src/modes/components/transcript-container.ts +70 -1
- package/src/modes/components/tree-selector.ts +1 -1
- package/src/modes/components/usage-row.ts +18 -0
- package/src/modes/components/user-message.ts +4 -2
- package/src/modes/controllers/command-controller.ts +14 -16
- package/src/modes/controllers/event-controller.ts +101 -73
- package/src/modes/controllers/extension-ui-controller.ts +6 -0
- package/src/modes/controllers/input-controller.ts +311 -57
- package/src/modes/controllers/mcp-command-controller.ts +44 -3
- package/src/modes/controllers/selector-controller.ts +68 -12
- package/src/modes/controllers/streaming-reveal.ts +4 -3
- package/src/modes/gradient-highlight.ts +21 -9
- package/src/modes/image-references.ts +20 -0
- package/src/modes/interactive-mode.ts +288 -48
- package/src/modes/magic-keywords.ts +27 -5
- package/src/modes/rpc/rpc-mode.ts +146 -14
- package/src/modes/rpc/rpc-subagents.ts +2 -2
- package/src/modes/rpc/rpc-types.ts +8 -2
- package/src/modes/runtime-init.ts +28 -3
- package/src/modes/theme/theme.ts +99 -51
- package/src/modes/types.ts +6 -7
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/modes/utils/ui-helpers.ts +36 -7
- package/src/priority.json +5 -1
- package/src/prompts/agents/task.md +1 -0
- package/src/prompts/goals/guided-goal-interview.md +8 -0
- package/src/prompts/goals/guided-goal-system.md +12 -0
- package/src/prompts/memories/read-path.md +6 -0
- package/src/prompts/system/autolearn-guidance-learn.md +1 -0
- package/src/prompts/system/autolearn-guidance.md +7 -0
- package/src/prompts/system/autolearn-nudge.md +3 -0
- package/src/prompts/system/eager-task.md +7 -0
- package/src/prompts/system/eager-todo.md +11 -6
- package/src/prompts/system/empty-stop-retry.md +4 -6
- package/src/prompts/system/subagent-system-prompt.md +4 -0
- package/src/prompts/system/system-prompt.md +10 -5
- package/src/prompts/system/title-marker-instruction.md +1 -0
- package/src/prompts/system/title-system-marker.md +16 -0
- package/src/prompts/tools/job.md +1 -0
- package/src/prompts/tools/learn.md +7 -0
- package/src/prompts/tools/manage-skill.md +9 -0
- package/src/prompts/tools/task.md +3 -0
- package/src/registry/agent-registry.ts +30 -0
- package/src/sdk.ts +103 -43
- package/src/secrets/obfuscator.ts +1 -1
- package/src/session/agent-session.ts +331 -318
- package/src/session/agent-storage.ts +18 -9
- package/src/session/history-storage.ts +3 -2
- package/src/session/indexed-session-storage.ts +7 -10
- package/src/session/messages.ts +9 -11
- package/src/session/session-context.ts +352 -0
- package/src/session/session-dump-format.ts +4 -2
- package/src/session/session-entries.ts +194 -0
- package/src/session/session-listing.ts +588 -0
- package/src/session/session-loader.ts +106 -0
- package/src/session/session-manager.ts +968 -3064
- package/src/session/session-migrations.ts +78 -0
- package/src/session/session-paths.ts +193 -0
- package/src/session/session-persistence.ts +131 -0
- package/src/session/session-storage.ts +91 -30
- package/src/session/snapcompact-inline.ts +21 -1
- package/src/session/snapcompact-savings-journal.ts +113 -0
- package/src/session/tool-choice-queue.ts +23 -11
- package/src/slash-commands/builtin-registry.ts +40 -4
- package/src/slash-commands/helpers/logout.ts +88 -0
- package/src/stt/asr-client.ts +520 -0
- package/src/stt/asr-protocol.ts +65 -0
- package/src/stt/asr-worker.ts +790 -0
- package/src/stt/downloader.ts +107 -47
- package/src/stt/endpointer.ts +259 -0
- package/src/stt/index.ts +5 -1
- package/src/stt/models.ts +150 -0
- package/src/stt/recorder.ts +247 -60
- package/src/stt/stt-controller.ts +201 -22
- package/src/stt/transcriber.ts +37 -68
- package/src/stt/wav.ts +173 -0
- package/src/system-prompt.ts +8 -0
- package/src/task/agents.ts +1 -2
- package/src/task/executor.ts +49 -15
- package/src/task/index.ts +60 -6
- package/src/task/render.ts +83 -8
- package/src/task/types.ts +54 -1
- package/src/tools/ask.ts +9 -1
- package/src/tools/ast-edit.ts +1 -1
- package/src/tools/ast-grep.ts +1 -1
- package/src/tools/bash.ts +5 -4
- package/src/tools/browser/cmux/cmux-tab.ts +1264 -0
- package/src/tools/browser/cmux/rpc.ts +156 -0
- package/src/tools/browser/cmux/socket-client.ts +309 -0
- package/src/tools/browser/registry.ts +37 -3
- package/src/tools/browser/render.ts +6 -1
- package/src/tools/browser/tab-protocol.ts +2 -0
- package/src/tools/browser/tab-supervisor.ts +189 -18
- package/src/tools/browser/tab-worker.ts +1 -1
- package/src/tools/browser.ts +16 -1
- package/src/tools/checkpoint.ts +1 -1
- package/src/tools/debug.ts +1 -1
- package/src/tools/eval-render.ts +4 -3
- package/src/tools/eval.ts +11 -6
- package/src/tools/fetch.ts +13 -2
- package/src/tools/find.ts +1 -1
- package/src/tools/gh.ts +1 -1
- package/src/tools/github-cache.ts +2 -1
- package/src/tools/image-gen.ts +1 -1
- package/src/tools/index.ts +43 -5
- package/src/tools/inspect-image.ts +3 -1
- package/src/tools/irc.ts +11 -3
- package/src/tools/job.ts +15 -3
- package/src/tools/learn.ts +144 -0
- package/src/tools/manage-skill.ts +104 -0
- package/src/tools/memory-edit.ts +1 -1
- package/src/tools/memory-recall.ts +1 -1
- package/src/tools/memory-reflect.ts +1 -1
- package/src/tools/memory-retain.ts +1 -1
- package/src/tools/plan-mode-guard.ts +53 -19
- package/src/tools/read.ts +8 -2
- package/src/tools/render-mermaid.ts +1 -1
- package/src/tools/renderers.ts +7 -11
- package/src/tools/report-tool-issue.ts +3 -2
- package/src/tools/resolve.ts +1 -1
- package/src/tools/review.ts +1 -1
- package/src/tools/search-tool-bm25.ts +1 -1
- package/src/tools/search.ts +1 -1
- package/src/tools/ssh.ts +5 -4
- package/src/tools/todo.ts +2 -2
- package/src/tools/tts.ts +204 -93
- package/src/tools/write.ts +19 -3
- package/src/tts/downloader.ts +64 -0
- package/src/tts/index.ts +8 -0
- package/src/tts/models.ts +137 -0
- package/src/tts/player.ts +137 -0
- package/src/tts/runtime.ts +21 -0
- package/src/tts/streaming-player.ts +266 -0
- package/src/tts/tts-client.ts +647 -0
- package/src/tts/tts-protocol.ts +60 -0
- package/src/tts/tts-worker.ts +497 -0
- package/src/tts/vocalizer.ts +162 -0
- package/src/tts/wav.ts +58 -0
- package/src/utils/clipboard.ts +35 -18
- package/src/utils/image-loading.ts +35 -4
- package/src/utils/thinking-display.ts +37 -0
- package/src/utils/title-generator.ts +48 -5
- package/src/utils/tool-choice.ts +16 -0
- package/src/utils/tools-manager.test.ts +25 -0
- package/src/utils/tools-manager.ts +19 -1
- package/src/web/scrapers/github.ts +96 -0
- package/src/web/search/index.ts +14 -1
- package/src/web/search/providers/searxng.ts +13 -1
- package/dist/types/cli/list-models.d.ts +0 -30
- package/dist/types/stt/setup.d.ts +0 -18
- package/src/cli/list-models.ts +0 -194
- package/src/stt/setup.ts +0 -52
- package/src/stt/transcribe.py +0 -70
|
@@ -1,319 +1,242 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import * as os from "node:os";
|
|
3
2
|
import * as path from "node:path";
|
|
4
|
-
import type {
|
|
5
|
-
import
|
|
6
|
-
ImageContent,
|
|
7
|
-
Message,
|
|
8
|
-
MessageAttribution,
|
|
9
|
-
ProviderPayload,
|
|
10
|
-
ServiceTier,
|
|
11
|
-
TextContent,
|
|
12
|
-
Usage,
|
|
13
|
-
} from "@oh-my-pi/pi-ai";
|
|
14
|
-
import { getTerminalId } from "@oh-my-pi/pi-tui";
|
|
15
|
-
import {
|
|
16
|
-
getBlobsDir,
|
|
17
|
-
getAgentDir as getDefaultAgentDir,
|
|
18
|
-
getProjectDir,
|
|
19
|
-
getSessionsDir,
|
|
20
|
-
getTerminalSessionsDir,
|
|
21
|
-
hasFsCode,
|
|
22
|
-
isEnoent,
|
|
23
|
-
logger,
|
|
24
|
-
parseJsonlLenient,
|
|
25
|
-
pathIsWithin,
|
|
26
|
-
resolveEquivalentPath,
|
|
27
|
-
Snowflake,
|
|
28
|
-
toError,
|
|
29
|
-
} from "@oh-my-pi/pi-utils";
|
|
30
|
-
import * as snapcompact from "@oh-my-pi/snapcompact";
|
|
3
|
+
import type { ImageContent, Message, MessageAttribution, ServiceTier, TextContent, Usage } from "@oh-my-pi/pi-ai";
|
|
4
|
+
import { getBlobsDir, getProjectDir, getSessionsDir, isEnoent, logger, toError } from "@oh-my-pi/pi-utils";
|
|
31
5
|
import { ArtifactManager } from "./artifacts";
|
|
32
|
-
import {
|
|
33
|
-
type BlobPutOptions,
|
|
34
|
-
type BlobPutResult,
|
|
35
|
-
BlobStore,
|
|
36
|
-
externalizeImageData,
|
|
37
|
-
externalizeImageDataSync,
|
|
38
|
-
externalizeImageDataUrl,
|
|
39
|
-
externalizeImageDataUrlSync,
|
|
40
|
-
isBlobRef,
|
|
41
|
-
isImageDataUrl,
|
|
42
|
-
resolveImageData,
|
|
43
|
-
resolveImageDataUrl,
|
|
44
|
-
} from "./blob-store";
|
|
6
|
+
import { type BlobPutOptions, type BlobPutResult, BlobStore } from "./blob-store";
|
|
45
7
|
import {
|
|
46
8
|
type BashExecutionMessage,
|
|
47
9
|
type CustomMessage,
|
|
48
|
-
createBranchSummaryMessage,
|
|
49
|
-
createCompactionSummaryMessage,
|
|
50
|
-
createCustomMessage,
|
|
51
10
|
type FileMentionMessage,
|
|
52
11
|
type HookMessage,
|
|
53
12
|
type PythonExecutionMessage,
|
|
54
13
|
sanitizeRehydratedOpenAIResponsesAssistantMessage,
|
|
55
14
|
stripInternalDetailsFields,
|
|
56
15
|
} from "./messages";
|
|
57
|
-
import type
|
|
58
|
-
import {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
type
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
16
|
+
import { type BuildSessionContextOptions, buildSessionContext, type SessionContext } from "./session-context";
|
|
17
|
+
import {
|
|
18
|
+
type BranchSummaryEntry,
|
|
19
|
+
type CompactionEntry,
|
|
20
|
+
CURRENT_SESSION_VERSION,
|
|
21
|
+
type CustomEntry,
|
|
22
|
+
type CustomMessageEntry,
|
|
23
|
+
type FileEntry,
|
|
24
|
+
type LabelEntry,
|
|
25
|
+
type MCPToolSelectionEntry,
|
|
26
|
+
type ModeChangeEntry,
|
|
27
|
+
type ModelChangeEntry,
|
|
28
|
+
type NewSessionOptions,
|
|
29
|
+
type ServiceTierChangeEntry,
|
|
30
|
+
type SessionEntry,
|
|
31
|
+
type SessionHeader,
|
|
32
|
+
type SessionInitEntry,
|
|
33
|
+
type SessionMessageEntry,
|
|
34
|
+
type SessionTreeNode,
|
|
35
|
+
type ThinkingLevelChangeEntry,
|
|
36
|
+
type TtsrInjectionEntry,
|
|
37
|
+
type UsageStatistics,
|
|
38
|
+
} from "./session-entries";
|
|
39
|
+
import { findMostRecentSession, listAllSessions, listSessions, type SessionInfo } from "./session-listing";
|
|
40
|
+
import { loadEntriesFromFile, resolveBlobRefsInEntries } from "./session-loader";
|
|
41
|
+
import { generateId, migrateToCurrentVersion } from "./session-migrations";
|
|
42
|
+
import {
|
|
43
|
+
computeDefaultSessionDir,
|
|
44
|
+
readTerminalBreadcrumbEntry,
|
|
45
|
+
resolveManagedSessionRoot,
|
|
46
|
+
writeTerminalBreadcrumb,
|
|
47
|
+
} from "./session-paths";
|
|
48
|
+
import { prepareEntryForPersistence } from "./session-persistence";
|
|
49
|
+
import {
|
|
50
|
+
FileSessionStorage,
|
|
51
|
+
MemorySessionStorage,
|
|
52
|
+
type SessionStorage,
|
|
53
|
+
type SessionStorageWriter,
|
|
54
|
+
} from "./session-storage";
|
|
72
55
|
|
|
73
|
-
|
|
74
|
-
parentSession?: string;
|
|
75
|
-
/** Skip flushing the current session and delete it instead of saving. */
|
|
76
|
-
drop?: boolean;
|
|
77
|
-
}
|
|
56
|
+
const JSONL_SUFFIX_LENGTH = ".jsonl".length;
|
|
78
57
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
id: string;
|
|
82
|
-
parentId: string | null;
|
|
83
|
-
timestamp: string;
|
|
58
|
+
function mintSessionId(): string {
|
|
59
|
+
return Bun.randomUUIDv7();
|
|
84
60
|
}
|
|
85
61
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
message: AgentMessage;
|
|
62
|
+
function nowIso(): string {
|
|
63
|
+
return new Date().toISOString();
|
|
89
64
|
}
|
|
90
65
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
thinkingLevel?: string | null;
|
|
66
|
+
function fileSafeTimestamp(iso: string): string {
|
|
67
|
+
return iso.replace(/[:.]/g, "-");
|
|
94
68
|
}
|
|
95
69
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
/** Model in "provider/modelId" format */
|
|
99
|
-
model: string;
|
|
100
|
-
/** Role: "default", "smol", "slow", etc. Undefined treated as "default" */
|
|
101
|
-
role?: string;
|
|
70
|
+
function artifactsDirectoryFor(sessionFile: string | undefined): string | null {
|
|
71
|
+
return sessionFile ? sessionFile.slice(0, -JSONL_SUFFIX_LENGTH) : null;
|
|
102
72
|
}
|
|
103
73
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
serviceTier: ServiceTier | null;
|
|
74
|
+
function emptyUsageStatistics(): UsageStatistics {
|
|
75
|
+
return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
107
76
|
}
|
|
108
77
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
firstKeptEntryId: string;
|
|
114
|
-
tokensBefore: number;
|
|
115
|
-
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
116
|
-
details?: T;
|
|
117
|
-
/** Hook-provided data to persist across compaction */
|
|
118
|
-
preserveData?: Record<string, unknown>;
|
|
119
|
-
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
|
|
120
|
-
fromExtension?: boolean;
|
|
78
|
+
function taskUsageFrom(details: unknown): Usage | undefined {
|
|
79
|
+
if (details === null || typeof details !== "object") return undefined;
|
|
80
|
+
const maybeUsage = (details as Record<string, unknown>).usage;
|
|
81
|
+
return maybeUsage !== null && typeof maybeUsage === "object" ? (maybeUsage as Usage) : undefined;
|
|
121
82
|
}
|
|
122
83
|
|
|
123
|
-
|
|
124
|
-
type
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
/** True if generated by an extension, false if pi-generated */
|
|
130
|
-
fromExtension?: boolean;
|
|
84
|
+
function entryUsage(entry: SessionEntry): Usage | undefined {
|
|
85
|
+
if (entry.type !== "message") return undefined;
|
|
86
|
+
const message = entry.message;
|
|
87
|
+
if (message.role === "assistant") return message.usage;
|
|
88
|
+
if (message.role === "toolResult" && message.toolName === "task") return taskUsageFrom(message.details);
|
|
89
|
+
return undefined;
|
|
131
90
|
}
|
|
132
91
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
* For injecting content into context, see CustomMessageEntry.
|
|
142
|
-
*/
|
|
143
|
-
export interface CustomEntry<T = unknown> extends SessionEntryBase {
|
|
144
|
-
type: "custom";
|
|
145
|
-
customType: string;
|
|
146
|
-
data?: T;
|
|
92
|
+
function addUsage(target: UsageStatistics, usage: Usage | undefined): void {
|
|
93
|
+
if (!usage) return;
|
|
94
|
+
target.input += usage.input;
|
|
95
|
+
target.output += usage.output;
|
|
96
|
+
target.cacheRead += usage.cacheRead;
|
|
97
|
+
target.cacheWrite += usage.cacheWrite;
|
|
98
|
+
target.premiumRequests += usage.premiumRequests ?? 0;
|
|
99
|
+
target.cost += usage.cost.total;
|
|
147
100
|
}
|
|
148
101
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
type: "label";
|
|
152
|
-
targetId: string;
|
|
153
|
-
label: string | undefined;
|
|
102
|
+
function isAssistantEntry(entry: SessionEntry): boolean {
|
|
103
|
+
return entry.type === "message" && entry.message.role === "assistant";
|
|
154
104
|
}
|
|
155
105
|
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
type: "ttsr_injection";
|
|
159
|
-
/** Names of rules that were injected */
|
|
160
|
-
injectedRules: string[];
|
|
106
|
+
function orderedByTimestamp(a: SessionTreeNode, b: SessionTreeNode): number {
|
|
107
|
+
return new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime();
|
|
161
108
|
}
|
|
162
109
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
110
|
+
/**
|
|
111
|
+
* Maintains the derived views over a session's entry list: id lookup, the
|
|
112
|
+
* parent→children adjacency, the resolved label map, the active leaf, and the
|
|
113
|
+
* running usage totals. Kept in lockstep with the manager's `#entries` so reads
|
|
114
|
+
* stay O(1)/O(children) instead of rescanning the whole journal.
|
|
115
|
+
*/
|
|
116
|
+
class SessionEntryIndex {
|
|
117
|
+
#entriesById = new Map<string, SessionEntry>();
|
|
118
|
+
#children = new Map<string | null, SessionEntry[]>();
|
|
119
|
+
#labels = new Map<string, string>();
|
|
120
|
+
#leaf: string | null = null;
|
|
121
|
+
#usage = emptyUsageStatistics();
|
|
169
122
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
/** Tools available to the agent */
|
|
178
|
-
tools: string[];
|
|
179
|
-
/** Output schema if structured output was requested */
|
|
180
|
-
outputSchema?: unknown;
|
|
181
|
-
}
|
|
123
|
+
clear(): void {
|
|
124
|
+
this.#entriesById.clear();
|
|
125
|
+
this.#children.clear();
|
|
126
|
+
this.#labels.clear();
|
|
127
|
+
this.#leaf = null;
|
|
128
|
+
this.#usage = emptyUsageStatistics();
|
|
129
|
+
}
|
|
182
130
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
mode: string;
|
|
188
|
-
/** Optional mode-specific data (e.g. plan file path) */
|
|
189
|
-
data?: Record<string, unknown>;
|
|
190
|
-
}
|
|
131
|
+
rebuild(entries: readonly SessionEntry[]): void {
|
|
132
|
+
this.clear();
|
|
133
|
+
for (const entry of entries) this.insert(entry);
|
|
134
|
+
}
|
|
191
135
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
*
|
|
196
|
-
* Unlike CustomEntry, this DOES participate in LLM context.
|
|
197
|
-
* The content participates in LLM context through convertToLlm().
|
|
198
|
-
* Use details for extension-specific metadata (not sent to LLM).
|
|
199
|
-
*
|
|
200
|
-
* display controls TUI rendering:
|
|
201
|
-
* - false: hidden entirely
|
|
202
|
-
* - true: rendered with distinct styling (different from user messages)
|
|
203
|
-
*/
|
|
204
|
-
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
205
|
-
type: "custom_message";
|
|
206
|
-
customType: string;
|
|
207
|
-
content: string | (TextContent | ImageContent)[];
|
|
208
|
-
details?: T;
|
|
209
|
-
display: boolean;
|
|
210
|
-
/** Who initiated this message for billing/attribution semantics. */
|
|
211
|
-
attribution?: MessageAttribution;
|
|
212
|
-
}
|
|
136
|
+
insert(entry: SessionEntry): void {
|
|
137
|
+
this.#entriesById.set(entry.id, entry);
|
|
138
|
+
this.#leaf = entry.id;
|
|
213
139
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
| ThinkingLevelChangeEntry
|
|
218
|
-
| ModelChangeEntry
|
|
219
|
-
| ServiceTierChangeEntry
|
|
220
|
-
| CompactionEntry
|
|
221
|
-
| BranchSummaryEntry
|
|
222
|
-
| CustomEntry
|
|
223
|
-
| CustomMessageEntry
|
|
224
|
-
| LabelEntry
|
|
225
|
-
| TtsrInjectionEntry
|
|
226
|
-
| MCPToolSelectionEntry
|
|
227
|
-
| SessionInitEntry
|
|
228
|
-
| ModeChangeEntry;
|
|
229
|
-
|
|
230
|
-
/** Raw file entry (includes header) */
|
|
231
|
-
export type FileEntry = SessionHeader | SessionEntry;
|
|
232
|
-
|
|
233
|
-
/** Tree node for getTree() - defensive copy of session structure */
|
|
234
|
-
export interface SessionTreeNode {
|
|
235
|
-
entry: SessionEntry;
|
|
236
|
-
children: SessionTreeNode[];
|
|
237
|
-
/** Resolved label for this entry, if any */
|
|
238
|
-
label?: string;
|
|
239
|
-
}
|
|
140
|
+
const bucket = this.#children.get(entry.parentId);
|
|
141
|
+
if (bucket) bucket.push(entry);
|
|
142
|
+
else this.#children.set(entry.parentId, [entry]);
|
|
240
143
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
246
|
-
models: Record<string, string>;
|
|
247
|
-
/** Names of TTSR rules that have been injected this session */
|
|
248
|
-
injectedTtsrRules: string[];
|
|
249
|
-
/** MCP tool names selected through discovery for this session branch. */
|
|
250
|
-
selectedMCPToolNames: string[];
|
|
251
|
-
/** Whether this branch contains an explicit persisted MCP selection entry. */
|
|
252
|
-
hasPersistedMCPToolSelection: boolean;
|
|
253
|
-
/** Active mode (e.g. "plan") or "none" if no special mode is active */
|
|
254
|
-
mode: string;
|
|
255
|
-
/** Mode-specific data from the last mode_change entry */
|
|
256
|
-
modeData?: Record<string, unknown>;
|
|
257
|
-
}
|
|
144
|
+
if (entry.type === "label") {
|
|
145
|
+
if (entry.label) this.#labels.set(entry.targetId, entry.label);
|
|
146
|
+
else this.#labels.delete(entry.targetId);
|
|
147
|
+
}
|
|
258
148
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
/** Lists session model strings to try when restoring, in fallback order. */
|
|
262
|
-
export function getRestorableSessionModels(
|
|
263
|
-
models: Readonly<Record<string, string>>,
|
|
264
|
-
lastModelChangeRole: string | undefined,
|
|
265
|
-
): string[] {
|
|
266
|
-
const defaultModel = models.default;
|
|
267
|
-
if (
|
|
268
|
-
!lastModelChangeRole ||
|
|
269
|
-
lastModelChangeRole === "default" ||
|
|
270
|
-
lastModelChangeRole === EPHEMERAL_MODEL_CHANGE_ROLE
|
|
271
|
-
) {
|
|
272
|
-
return defaultModel ? [defaultModel] : [];
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
const roleModel = models[lastModelChangeRole];
|
|
276
|
-
if (!roleModel) return defaultModel ? [defaultModel] : [];
|
|
277
|
-
if (!defaultModel || roleModel === defaultModel) return [roleModel];
|
|
278
|
-
return [roleModel, defaultModel];
|
|
279
|
-
}
|
|
149
|
+
addUsage(this.#usage, entryUsage(entry));
|
|
150
|
+
}
|
|
280
151
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
* a length-truncated turn.
|
|
289
|
-
* - `aborted` — the last assistant turn was cancelled by the user.
|
|
290
|
-
* - `error` — the last assistant turn ended in an error.
|
|
291
|
-
* - `pending` — a trailing user message with no assistant reply persisted after it.
|
|
292
|
-
* - `unknown` — status could not be determined (empty/header-only session, or the
|
|
293
|
-
* final message was larger than the tail window that was read).
|
|
294
|
-
*/
|
|
295
|
-
export type SessionStatus = "complete" | "interrupted" | "aborted" | "error" | "pending" | "unknown";
|
|
152
|
+
has(id: string): boolean {
|
|
153
|
+
return this.#entriesById.has(id);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
get(id: string): SessionEntry | undefined {
|
|
157
|
+
return this.#entriesById.get(id);
|
|
158
|
+
}
|
|
296
159
|
|
|
297
|
-
export interface SessionInfo {
|
|
298
|
-
path: string;
|
|
299
|
-
id: string;
|
|
300
|
-
/** Working directory where the session was started. Empty string for old sessions. */
|
|
301
|
-
cwd: string;
|
|
302
|
-
title?: string;
|
|
303
|
-
/** Path to the parent session (if this session was forked). */
|
|
304
|
-
parentSessionPath?: string;
|
|
305
|
-
created: Date;
|
|
306
|
-
modified: Date;
|
|
307
|
-
messageCount: number;
|
|
308
|
-
/** File size in bytes on disk; used for compact list rendering. */
|
|
309
|
-
size: number;
|
|
310
|
-
firstMessage: string;
|
|
311
|
-
allMessagesText: string;
|
|
312
160
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
161
|
+
* The live id→entry map. Read-only for callers (lookups + `generateId`
|
|
162
|
+
* collision checks); never mutate it directly — go through `insert`/`rebuild`.
|
|
315
163
|
*/
|
|
316
|
-
|
|
164
|
+
entriesById(): Map<string, SessionEntry> {
|
|
165
|
+
return this.#entriesById;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
leafId(): string | null {
|
|
169
|
+
return this.#leaf;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
leafEntry(): SessionEntry | undefined {
|
|
173
|
+
return this.#leaf ? this.#entriesById.get(this.#leaf) : undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
setLeaf(id: string | null): void {
|
|
177
|
+
this.#leaf = id;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
childrenOf(parentId: string): SessionEntry[] {
|
|
181
|
+
return [...(this.#children.get(parentId) ?? [])];
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
labelFor(id: string): string | undefined {
|
|
185
|
+
return this.#labels.get(id);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
labelsInEffect(): IterableIterator<[string, string]> {
|
|
189
|
+
return this.#labels.entries();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
usageSnapshot(): UsageStatistics {
|
|
193
|
+
return { ...this.#usage };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
pathTo(id: string | null | undefined = this.#leaf): SessionEntry[] {
|
|
197
|
+
const branch: SessionEntry[] = [];
|
|
198
|
+
const seen = new Set<string>();
|
|
199
|
+
let cursor = id ? this.#entriesById.get(id) : undefined;
|
|
200
|
+
|
|
201
|
+
while (cursor && !seen.has(cursor.id)) {
|
|
202
|
+
seen.add(cursor.id);
|
|
203
|
+
branch.unshift(cursor);
|
|
204
|
+
cursor = cursor.parentId ? this.#entriesById.get(cursor.parentId) : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return branch;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
tree(entries: readonly SessionEntry[]): SessionTreeNode[] {
|
|
211
|
+
const nodes = new Map<string, SessionTreeNode>();
|
|
212
|
+
const roots: SessionTreeNode[] = [];
|
|
213
|
+
|
|
214
|
+
for (const entry of entries) {
|
|
215
|
+
nodes.set(entry.id, { entry, children: [], label: this.#labels.get(entry.id) });
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const entry of entries) {
|
|
219
|
+
const node = nodes.get(entry.id)!;
|
|
220
|
+
const parentId = entry.parentId;
|
|
221
|
+
if (parentId === null || parentId === entry.id) {
|
|
222
|
+
roots.push(node);
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const parent = nodes.get(parentId);
|
|
227
|
+
if (parent) parent.children.push(node);
|
|
228
|
+
else roots.push(node);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const stack = [...roots];
|
|
232
|
+
while (stack.length > 0) {
|
|
233
|
+
const node = stack.pop()!;
|
|
234
|
+
node.children.sort(orderedByTimestamp);
|
|
235
|
+
stack.push(...node.children);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return roots;
|
|
239
|
+
}
|
|
317
240
|
}
|
|
318
241
|
|
|
319
242
|
export type ReadonlySessionManager = Pick<
|
|
@@ -341,2304 +264,709 @@ export type ReadonlySessionManager = Pick<
|
|
|
341
264
|
| "putBlobSync"
|
|
342
265
|
>;
|
|
343
266
|
|
|
344
|
-
|
|
345
|
-
|
|
267
|
+
interface SessionManagerStateSnapshot {
|
|
268
|
+
cwd: string;
|
|
269
|
+
sessionDir: string;
|
|
270
|
+
sessionId: string;
|
|
271
|
+
sessionName: string | undefined;
|
|
272
|
+
titleSource: "auto" | "user" | undefined;
|
|
273
|
+
sessionFile: string | undefined;
|
|
274
|
+
onDisk: boolean;
|
|
275
|
+
needsRewrite: boolean;
|
|
276
|
+
header: SessionHeader;
|
|
277
|
+
entries: SessionEntry[];
|
|
346
278
|
}
|
|
347
279
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
if (!byId.has(id)) return id;
|
|
353
|
-
}
|
|
354
|
-
return Snowflake.next(); // fallback to full snowflake id
|
|
280
|
+
interface DiskQueueOptions {
|
|
281
|
+
ignorePriorError?: boolean;
|
|
282
|
+
ignoreEpoch?: boolean;
|
|
283
|
+
epoch?: number;
|
|
355
284
|
}
|
|
356
285
|
|
|
357
|
-
/**
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
286
|
+
/**
|
|
287
|
+
* Stores and navigates an append-only conversation journal.
|
|
288
|
+
*
|
|
289
|
+
* A session is a JSONL file: one header line followed by entries. Entries form a
|
|
290
|
+
* tree by `(id, parentId)`, and the mutable leaf pointer selects which path is
|
|
291
|
+
* active for future appends and for LLM context construction.
|
|
292
|
+
*
|
|
293
|
+
* Durability is software-crash safe but not power-loss safe: appends are handed
|
|
294
|
+
* to the OS synchronously in-body (so an entry survives an OOM/SIGKILL the
|
|
295
|
+
* instant `appendMessage` returns) but never `fsync`'d. Full-file rewrites go
|
|
296
|
+
* through the storage layer's atomic temp-write+rename so a crash mid-rewrite
|
|
297
|
+
* cannot truncate the prior good file.
|
|
298
|
+
*/
|
|
299
|
+
export class SessionManager {
|
|
300
|
+
#cwd: string;
|
|
301
|
+
#sessionDir: string;
|
|
302
|
+
readonly #persist: boolean;
|
|
303
|
+
readonly #storage: SessionStorage;
|
|
304
|
+
readonly #blobs: BlobStore;
|
|
367
305
|
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
if (typeof comp.firstKeptEntryIndex === "number") {
|
|
376
|
-
const targetEntry = entries[comp.firstKeptEntryIndex];
|
|
377
|
-
if (targetEntry && targetEntry.type !== "session") {
|
|
378
|
-
comp.firstKeptEntryId = targetEntry.id;
|
|
379
|
-
}
|
|
380
|
-
delete comp.firstKeptEntryIndex;
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
}
|
|
384
|
-
}
|
|
306
|
+
#sessionId = "";
|
|
307
|
+
#sessionName: string | undefined;
|
|
308
|
+
#titleSource: "auto" | "user" | undefined;
|
|
309
|
+
#sessionFile: string | undefined;
|
|
310
|
+
#header!: SessionHeader;
|
|
311
|
+
#entries: SessionEntry[] = [];
|
|
312
|
+
#index = new SessionEntryIndex();
|
|
385
313
|
|
|
386
|
-
/**
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
}
|
|
314
|
+
/** File reflects all current entries; appends can go incrementally. */
|
|
315
|
+
#fileIsCurrent = false;
|
|
316
|
+
/** In-memory entries diverged from disk (load-migration/sanitize) → next persist must full-rewrite. */
|
|
317
|
+
#rewriteRequired = false;
|
|
318
|
+
/** Lazy gate crossed (ensureOnDisk / loaded file): every entry must persist from now on. */
|
|
319
|
+
#forceFileCreation = false;
|
|
393
320
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
}
|
|
321
|
+
/**
|
|
322
|
+
* Collab replication tap: invoked for every appended entry with the
|
|
323
|
+
* in-memory (pre-blob-externalization) entry, so inline images survive.
|
|
324
|
+
*/
|
|
325
|
+
onEntryAppended?: (entry: SessionEntry) => void;
|
|
402
326
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|
408
|
-
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
409
|
-
const version = header?.version ?? 1;
|
|
327
|
+
#turnBudgetTotal: number | null = null;
|
|
328
|
+
#turnBudgetHard = false;
|
|
329
|
+
#turnOutputBaseline = 0;
|
|
330
|
+
#turnEvalOutput = 0;
|
|
410
331
|
|
|
411
|
-
|
|
332
|
+
/** The single open append writer; the manager only ever writes one file at a time. */
|
|
333
|
+
#writer: SessionStorageWriter | undefined;
|
|
334
|
+
/** Serializes async disk work (flush/close/atomic rewrite). Appends are synchronous and bypass it. */
|
|
335
|
+
#diskTail: Promise<void> = Promise.resolve();
|
|
336
|
+
#diskFailure: Error | undefined;
|
|
337
|
+
#diskFailureLogged = false;
|
|
338
|
+
/** Bumped on every sync rewrite / chain reset so stale queued tasks become no-ops. */
|
|
339
|
+
#diskEpoch = 0;
|
|
412
340
|
|
|
413
|
-
|
|
414
|
-
|
|
341
|
+
#artifactManager: ArtifactManager | null = null;
|
|
342
|
+
#artifactManagerSessionFile: string | null = null;
|
|
343
|
+
#adoptedArtifactManager: ArtifactManager | null = null;
|
|
344
|
+
#inMemoryArtifacts: Map<string, string> | null = null;
|
|
345
|
+
#inMemoryArtifactCounter = 0;
|
|
415
346
|
|
|
416
|
-
|
|
417
|
-
|
|
347
|
+
#suppressBreadcrumb = false;
|
|
348
|
+
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
418
349
|
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
350
|
+
private constructor(cwd: string, sessionDir: string, persist: boolean, storage: SessionStorage) {
|
|
351
|
+
this.#cwd = cwd;
|
|
352
|
+
this.#sessionDir = sessionDir;
|
|
353
|
+
this.#persist = persist;
|
|
354
|
+
this.#storage = storage;
|
|
355
|
+
this.#blobs = new BlobStore(getBlobsDir());
|
|
423
356
|
|
|
424
|
-
|
|
357
|
+
if (persist && sessionDir) this.#storage.ensureDirSync(sessionDir);
|
|
358
|
+
}
|
|
425
359
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
* Best effort: callers decide whether migration failures should surface.
|
|
429
|
-
*/
|
|
430
|
-
function migrateSessionDirPath(oldPath: string, newPath: string): void {
|
|
431
|
-
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
|
|
432
|
-
if (existing?.isDirectory()) {
|
|
433
|
-
for (const file of fs.readdirSync(oldPath)) {
|
|
434
|
-
const src = path.join(oldPath, file);
|
|
435
|
-
const dst = path.join(newPath, file);
|
|
436
|
-
if (!fs.existsSync(dst)) {
|
|
437
|
-
fs.renameSync(src, dst);
|
|
438
|
-
}
|
|
439
|
-
}
|
|
440
|
-
fs.rmSync(oldPath, { recursive: true, force: true });
|
|
441
|
-
return;
|
|
360
|
+
#rememberBreadcrumb(cwd: string, sessionFile: string): void {
|
|
361
|
+
if (!this.#suppressBreadcrumb) writeTerminalBreadcrumb(cwd, sessionFile);
|
|
442
362
|
}
|
|
443
|
-
|
|
444
|
-
|
|
363
|
+
|
|
364
|
+
#clearDiskError(): void {
|
|
365
|
+
this.#diskFailure = undefined;
|
|
366
|
+
this.#diskFailureLogged = false;
|
|
445
367
|
}
|
|
446
|
-
fs.renameSync(oldPath, newPath);
|
|
447
|
-
}
|
|
448
368
|
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
}
|
|
369
|
+
#noteDiskFailure(errorLike: unknown): Error {
|
|
370
|
+
const error = toError(errorLike);
|
|
371
|
+
if (!this.#diskFailure) this.#diskFailure = error;
|
|
453
372
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
373
|
+
if (!this.#diskFailureLogged) {
|
|
374
|
+
this.#diskFailureLogged = true;
|
|
375
|
+
logger.error("Session persistence error.", {
|
|
376
|
+
sessionFile: this.#sessionFile,
|
|
377
|
+
error: error.message,
|
|
378
|
+
stack: error.stack,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
458
381
|
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
const canonicalCwd = resolveEquivalentPath(resolvedCwd);
|
|
462
|
-
const home = resolveEquivalentPath(os.homedir());
|
|
463
|
-
const tempRoot = resolveEquivalentPath(os.tmpdir());
|
|
464
|
-
const encodedDirName = pathIsWithin(home, canonicalCwd)
|
|
465
|
-
? encodeRelativeSessionDirName("-", home, canonicalCwd)
|
|
466
|
-
: pathIsWithin(tempRoot, canonicalCwd)
|
|
467
|
-
? encodeRelativeSessionDirName("-tmp", tempRoot, canonicalCwd)
|
|
468
|
-
: encodeLegacyAbsoluteSessionDirName(canonicalCwd);
|
|
469
|
-
return { encodedDirName, resolvedCwd };
|
|
470
|
-
}
|
|
382
|
+
return this.#diskFailure;
|
|
383
|
+
}
|
|
471
384
|
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
|
|
482
|
-
const oldPrefix = `--${homeEncoded}-`;
|
|
483
|
-
const oldExact = `--${homeEncoded}--`;
|
|
484
|
-
|
|
485
|
-
let entries: string[];
|
|
486
|
-
try {
|
|
487
|
-
entries = fs.readdirSync(sessionsRoot);
|
|
488
|
-
} catch {
|
|
489
|
-
return;
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
for (const entry of entries) {
|
|
493
|
-
let remainder: string;
|
|
494
|
-
if (entry === oldExact) {
|
|
495
|
-
remainder = "";
|
|
496
|
-
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
|
|
497
|
-
remainder = entry.slice(oldPrefix.length, -2);
|
|
498
|
-
} else {
|
|
499
|
-
continue;
|
|
500
|
-
}
|
|
385
|
+
#scheduleDiskWork(work: () => Promise<void>, options: DiskQueueOptions = {}): Promise<void> {
|
|
386
|
+
const epoch = options.epoch ?? this.#diskEpoch;
|
|
387
|
+
const scheduled = this.#diskTail
|
|
388
|
+
.catch(() => undefined)
|
|
389
|
+
.then(async () => {
|
|
390
|
+
if (!options.ignoreEpoch && epoch !== this.#diskEpoch) return;
|
|
391
|
+
if (this.#diskFailure && !options.ignorePriorError) throw this.#diskFailure;
|
|
392
|
+
await work();
|
|
393
|
+
});
|
|
501
394
|
|
|
502
|
-
const
|
|
503
|
-
|
|
504
|
-
|
|
395
|
+
const reported = scheduled.catch(err => {
|
|
396
|
+
throw this.#noteDiskFailure(err);
|
|
397
|
+
});
|
|
398
|
+
this.#diskTail = reported.catch(() => undefined);
|
|
399
|
+
return reported;
|
|
400
|
+
}
|
|
505
401
|
|
|
402
|
+
async #drainAndCloseWriter(): Promise<void> {
|
|
506
403
|
try {
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
404
|
+
await this.#scheduleDiskWork(
|
|
405
|
+
async () => {
|
|
406
|
+
await this.#closeWriterHandle();
|
|
407
|
+
},
|
|
408
|
+
{ ignorePriorError: true, ignoreEpoch: true },
|
|
409
|
+
);
|
|
410
|
+
} finally {
|
|
411
|
+
this.#writer = undefined;
|
|
412
|
+
this.#diskTail = Promise.resolve();
|
|
510
413
|
}
|
|
511
414
|
}
|
|
512
|
-
}
|
|
513
415
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
try {
|
|
519
|
-
migrateSessionDirPath(legacyDir, sessionDir);
|
|
520
|
-
} catch {
|
|
521
|
-
// Best effort
|
|
416
|
+
#closeWriterEventually(): void {
|
|
417
|
+
const writer = this.#writer;
|
|
418
|
+
this.#writer = undefined;
|
|
419
|
+
if (writer) void writer.close().catch(() => undefined);
|
|
522
420
|
}
|
|
523
|
-
}
|
|
524
421
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
422
|
+
async #closeWriterHandle(): Promise<void> {
|
|
423
|
+
const writer = this.#writer;
|
|
424
|
+
if (!writer) return;
|
|
425
|
+
this.#writer = undefined;
|
|
426
|
+
await writer.close();
|
|
530
427
|
}
|
|
531
|
-
return path.dirname(sessionDir);
|
|
532
|
-
}
|
|
533
428
|
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
return parseJsonlLenient<FileEntry>(content);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
|
540
|
-
for (let i = entries.length - 1; i >= 0; i--) {
|
|
541
|
-
if (entries[i].type === "compaction") {
|
|
542
|
-
return entries[i] as CompactionEntry;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
return null;
|
|
546
|
-
}
|
|
429
|
+
#appendWriter(): SessionStorageWriter {
|
|
430
|
+
if (!this.#sessionFile) throw new Error("Cannot open a session writer before a session file exists");
|
|
547
431
|
|
|
548
|
-
|
|
549
|
-
/**
|
|
550
|
-
* Build the full-history display transcript instead of the LLM context:
|
|
551
|
-
* every path entry in chronological order, with each compaction emitted
|
|
552
|
-
* inline as a `compactionSummary` message at the position it fired rather
|
|
553
|
-
* than replacing the history before it. Display-only — never send the
|
|
554
|
-
* result to a provider.
|
|
555
|
-
*/
|
|
556
|
-
transcript?: boolean;
|
|
557
|
-
}
|
|
432
|
+
if (this.#writer?.isOpen()) return this.#writer;
|
|
558
433
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
export function buildSessionContext(
|
|
565
|
-
entries: SessionEntry[],
|
|
566
|
-
leafId?: string | null,
|
|
567
|
-
byId?: Map<string, SessionEntry>,
|
|
568
|
-
options?: BuildSessionContextOptions,
|
|
569
|
-
): SessionContext {
|
|
570
|
-
// Build uuid index if not available
|
|
571
|
-
if (!byId) {
|
|
572
|
-
byId = new Map<string, SessionEntry>();
|
|
573
|
-
for (const entry of entries) {
|
|
574
|
-
byId.set(entry.id, entry);
|
|
575
|
-
}
|
|
434
|
+
this.#writer = this.#storage.openWriter(this.#sessionFile, {
|
|
435
|
+
flags: "a",
|
|
436
|
+
onError: err => this.#noteDiskFailure(err),
|
|
437
|
+
});
|
|
438
|
+
return this.#writer;
|
|
576
439
|
}
|
|
577
440
|
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
if (leafId === null) {
|
|
581
|
-
// Explicitly null - return no messages (navigated to before first entry)
|
|
582
|
-
return {
|
|
583
|
-
messages: [],
|
|
584
|
-
thinkingLevel: "off",
|
|
585
|
-
serviceTier: undefined,
|
|
586
|
-
models: {},
|
|
587
|
-
injectedTtsrRules: [],
|
|
588
|
-
selectedMCPToolNames: [],
|
|
589
|
-
hasPersistedMCPToolSelection: false,
|
|
590
|
-
mode: "none",
|
|
591
|
-
};
|
|
441
|
+
#lineFor(entry: FileEntry): string {
|
|
442
|
+
return `${JSON.stringify(prepareEntryForPersistence(entry, this.#blobs))}\n`;
|
|
592
443
|
}
|
|
593
|
-
|
|
594
|
-
|
|
444
|
+
|
|
445
|
+
#fileBody(): string {
|
|
446
|
+
let body = this.#lineFor(this.#header);
|
|
447
|
+
for (const entry of this.#entries) body += this.#lineFor(entry);
|
|
448
|
+
return body;
|
|
595
449
|
}
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
450
|
+
|
|
451
|
+
#historyContainsAssistantMessage(): boolean {
|
|
452
|
+
return this.#entries.some(isAssistantEntry);
|
|
599
453
|
}
|
|
600
454
|
|
|
601
|
-
|
|
602
|
-
return
|
|
603
|
-
messages: [],
|
|
604
|
-
thinkingLevel: "off",
|
|
605
|
-
serviceTier: undefined,
|
|
606
|
-
models: {},
|
|
607
|
-
injectedTtsrRules: [],
|
|
608
|
-
selectedMCPToolNames: [],
|
|
609
|
-
hasPersistedMCPToolSelection: false,
|
|
610
|
-
mode: "none",
|
|
611
|
-
};
|
|
455
|
+
#shouldHaveSessionFile(): boolean {
|
|
456
|
+
return this.#forceFileCreation || this.#fileIsCurrent || this.#historyContainsAssistantMessage();
|
|
612
457
|
}
|
|
613
458
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
// Track whether an explicit `model_change` with role="default" has been
|
|
633
|
-
// seen on this path. Once a user (or the agent itself) records an
|
|
634
|
-
// explicit default, later assistant-message inference must NOT overwrite
|
|
635
|
-
// it: temporary fallbacks (retry fallback, context promotion) and
|
|
636
|
-
// server-side model downgrades both produce assistant messages tagged
|
|
637
|
-
// with the wrong model id, which previously clobbered the user's pick on
|
|
638
|
-
// resume (issue #849).
|
|
639
|
-
let hasExplicitDefaultModel = false;
|
|
640
|
-
|
|
641
|
-
for (const entry of path) {
|
|
642
|
-
if (entry.type === "thinking_level_change") {
|
|
643
|
-
thinkingLevel = entry.thinkingLevel ?? "off";
|
|
644
|
-
} else if (entry.type === "model_change") {
|
|
645
|
-
// New format: { model: "provider/id", role?: string }
|
|
646
|
-
if (entry.model) {
|
|
647
|
-
const role = entry.role ?? "default";
|
|
648
|
-
models[role] = entry.model;
|
|
649
|
-
if (role === "default") {
|
|
650
|
-
hasExplicitDefaultModel = true;
|
|
651
|
-
}
|
|
652
|
-
}
|
|
653
|
-
} else if (entry.type === "service_tier_change") {
|
|
654
|
-
serviceTier = entry.serviceTier ?? undefined;
|
|
655
|
-
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
656
|
-
// Legacy fallback: infer default model from assistant messages only
|
|
657
|
-
// when no explicit `model_change` (role=default) entry has been
|
|
658
|
-
// recorded yet. Newer sessions always record an explicit default
|
|
659
|
-
// model_change at the start of the conversation, so this branch is
|
|
660
|
-
// only used to keep pre-model_change sessions working.
|
|
661
|
-
if (!hasExplicitDefaultModel) {
|
|
662
|
-
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
663
|
-
}
|
|
664
|
-
} else if (entry.type === "compaction") {
|
|
665
|
-
compaction = entry;
|
|
666
|
-
} else if (entry.type === "ttsr_injection") {
|
|
667
|
-
// Collect injected TTSR rule names
|
|
668
|
-
for (const ruleName of entry.injectedRules) {
|
|
669
|
-
injectedTtsrRulesSet.add(ruleName);
|
|
670
|
-
}
|
|
671
|
-
} else if (entry.type === "mcp_tool_selection") {
|
|
672
|
-
selectedMCPToolNames = [...entry.selectedToolNames];
|
|
673
|
-
hasPersistedMCPToolSelection = true;
|
|
674
|
-
} else if (entry.type === "mode_change") {
|
|
675
|
-
mode = entry.mode;
|
|
676
|
-
modeData = entry.data;
|
|
459
|
+
/**
|
|
460
|
+
* Synchronously rewrite the whole file (header + entries) and keep no open
|
|
461
|
+
* writer; the next append re-opens one. `writeTextSync` returns with the
|
|
462
|
+
* bytes in the kernel page cache, so the file is software-crash durable.
|
|
463
|
+
*/
|
|
464
|
+
#rewriteSynchronously(): void {
|
|
465
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
const body = this.#fileBody();
|
|
469
|
+
this.#diskEpoch++;
|
|
470
|
+
this.#diskTail = Promise.resolve();
|
|
471
|
+
this.#closeWriterEventually();
|
|
472
|
+
this.#storage.writeTextSync(this.#sessionFile, body);
|
|
473
|
+
this.#fileIsCurrent = true;
|
|
474
|
+
this.#rewriteRequired = false;
|
|
475
|
+
} catch (err) {
|
|
476
|
+
this.#noteDiskFailure(err);
|
|
677
477
|
}
|
|
678
478
|
}
|
|
679
479
|
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
entry.timestamp,
|
|
700
|
-
entry.attribution,
|
|
701
|
-
),
|
|
702
|
-
);
|
|
703
|
-
} else if (entry.type === "branch_summary" && entry.summary) {
|
|
704
|
-
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
705
|
-
}
|
|
706
|
-
};
|
|
707
|
-
|
|
708
|
-
if (options?.transcript) {
|
|
709
|
-
// Display transcript: every entry in chronological order. Compactions do
|
|
710
|
-
// not erase prior history here — each renders inline (as a divider in the
|
|
711
|
-
// TUI) at the point it fired, with any snapcompact frames re-attached so
|
|
712
|
-
// the component can report them.
|
|
713
|
-
for (const entry of path) {
|
|
714
|
-
if (entry.type === "compaction") {
|
|
715
|
-
const snapcompactArchive = snapcompact.getPreservedArchive(entry.preserveData);
|
|
716
|
-
messages.push(
|
|
717
|
-
createCompactionSummaryMessage(
|
|
718
|
-
entry.summary,
|
|
719
|
-
entry.tokensBefore,
|
|
720
|
-
entry.timestamp,
|
|
721
|
-
entry.shortSummary,
|
|
722
|
-
undefined,
|
|
723
|
-
snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
|
|
724
|
-
),
|
|
725
|
-
);
|
|
726
|
-
} else {
|
|
727
|
-
appendMessage(entry);
|
|
728
|
-
}
|
|
729
|
-
}
|
|
730
|
-
} else if (compaction) {
|
|
731
|
-
const providerPayload: ProviderPayload | undefined = (() => {
|
|
732
|
-
const candidate = compaction.preserveData?.openaiRemoteCompaction;
|
|
733
|
-
if (!candidate || typeof candidate !== "object") return undefined;
|
|
734
|
-
const remote = candidate as { provider?: unknown; replacementHistory?: unknown };
|
|
735
|
-
if (typeof remote.provider !== "string" || remote.provider.length === 0) return undefined;
|
|
736
|
-
if (!Array.isArray(remote.replacementHistory)) return undefined;
|
|
737
|
-
return {
|
|
738
|
-
type: "openaiResponsesHistory",
|
|
739
|
-
provider: remote.provider,
|
|
740
|
-
items: remote.replacementHistory as Array<Record<string, unknown>>,
|
|
741
|
-
};
|
|
742
|
-
})();
|
|
743
|
-
const remoteReplacementHistory = providerPayload?.items;
|
|
744
|
-
|
|
745
|
-
// Emit summary first; re-attach any archived snapcompact frames so the
|
|
746
|
-
// model can keep reading the archived history after every context rebuild.
|
|
747
|
-
const snapcompactArchive = snapcompact.getPreservedArchive(compaction.preserveData);
|
|
748
|
-
messages.push(
|
|
749
|
-
createCompactionSummaryMessage(
|
|
750
|
-
compaction.summary,
|
|
751
|
-
compaction.tokensBefore,
|
|
752
|
-
compaction.timestamp,
|
|
753
|
-
compaction.shortSummary,
|
|
754
|
-
providerPayload,
|
|
755
|
-
snapcompactArchive ? snapcompact.images(snapcompactArchive) : undefined,
|
|
756
|
-
),
|
|
480
|
+
/**
|
|
481
|
+
* Rewrite the whole file atomically (temp-write + rename, EPERM-safe) on the
|
|
482
|
+
* disk chain. The body is serialized inside the task — after the writer is
|
|
483
|
+
* closed — so entries appended before the task runs are included.
|
|
484
|
+
*/
|
|
485
|
+
async #rewriteAtomically(): Promise<void> {
|
|
486
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
487
|
+
|
|
488
|
+
const epoch = this.#diskEpoch;
|
|
489
|
+
await this.#scheduleDiskWork(
|
|
490
|
+
async () => {
|
|
491
|
+
await this.#closeWriterHandle();
|
|
492
|
+
const sessionFile = this.#sessionFile;
|
|
493
|
+
if (!sessionFile) return;
|
|
494
|
+
await this.#storage.writeTextAtomic(sessionFile, this.#fileBody());
|
|
495
|
+
this.#fileIsCurrent = true;
|
|
496
|
+
this.#rewriteRequired = false;
|
|
497
|
+
},
|
|
498
|
+
{ epoch },
|
|
757
499
|
);
|
|
500
|
+
}
|
|
758
501
|
|
|
759
|
-
|
|
760
|
-
|
|
502
|
+
#appendToSessionFile(entry: SessionEntry): void {
|
|
503
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
504
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
761
505
|
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
foundFirstKept = true;
|
|
769
|
-
}
|
|
770
|
-
if (foundFirstKept) {
|
|
771
|
-
appendMessage(entry);
|
|
772
|
-
}
|
|
773
|
-
}
|
|
506
|
+
// Lazy gate: a brand-new session is not written until it has an assistant
|
|
507
|
+
// message (or someone forced creation), so sessions that never produce
|
|
508
|
+
// output never create a file.
|
|
509
|
+
if (!this.#shouldHaveSessionFile()) {
|
|
510
|
+
this.#fileIsCurrent = false;
|
|
511
|
+
return;
|
|
774
512
|
}
|
|
775
513
|
|
|
776
|
-
//
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
} else {
|
|
782
|
-
// No compaction - emit all messages, handle branch summaries and custom messages
|
|
783
|
-
for (const entry of path) {
|
|
784
|
-
appendMessage(entry);
|
|
514
|
+
// Cold/divergent: not on disk yet, or in-memory entries diverged from the
|
|
515
|
+
// file → rewrite the whole file synchronously and keep going.
|
|
516
|
+
if (!this.#fileIsCurrent || this.#rewriteRequired) {
|
|
517
|
+
this.#rewriteSynchronously();
|
|
518
|
+
return;
|
|
785
519
|
}
|
|
786
|
-
}
|
|
787
520
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
// carries signed `thinking`/`redacted_thinking` is rejected by Anthropic — "thinking
|
|
800
|
-
// blocks in the latest assistant message cannot be modified", and signed thinking
|
|
801
|
-
// replayed out of its original turn shape can also fail signature validation (this
|
|
802
|
-
// bites the handoff/branch-summary request). So when we rewrite a turn we also
|
|
803
|
-
// neutralize its protected reasoning: drop `redactedThinking` (encrypted, no
|
|
804
|
-
// plaintext to keep) and clear `thinking` signatures so the provider encoder
|
|
805
|
-
// downgrades them to plain text (verified accepted by the live API), preserving the
|
|
806
|
-
// visible reasoning while removing the immutability/invalid-signature hazard. Drop a
|
|
807
|
-
// turn left with no content. (Live turns never qualify: their results are persisted
|
|
808
|
-
// on the same path before any context rebuild.)
|
|
809
|
-
const pairedToolResultIds = new Set<string>();
|
|
810
|
-
for (const message of messages) {
|
|
811
|
-
if (message.role === "toolResult") pairedToolResultIds.add(message.toolCallId);
|
|
812
|
-
}
|
|
813
|
-
for (let i = messages.length - 1; i >= 0; i--) {
|
|
814
|
-
const message = messages[i];
|
|
815
|
-
if (message.role !== "assistant") continue;
|
|
816
|
-
const hasDangling = message.content.some(
|
|
817
|
-
block => block.type === "toolCall" && !pairedToolResultIds.has(block.id),
|
|
818
|
-
);
|
|
819
|
-
if (!hasDangling) continue;
|
|
820
|
-
const normalized = message.content
|
|
821
|
-
.filter(
|
|
822
|
-
block =>
|
|
823
|
-
!(block.type === "toolCall" && !pairedToolResultIds.has(block.id)) && block.type !== "redactedThinking",
|
|
824
|
-
)
|
|
825
|
-
.map(block =>
|
|
826
|
-
block.type === "thinking" && block.thinkingSignature ? { ...block, thinkingSignature: undefined } : block,
|
|
827
|
-
);
|
|
828
|
-
if (normalized.length === 0) {
|
|
829
|
-
messages.splice(i, 1);
|
|
830
|
-
} else {
|
|
831
|
-
messages[i] = { ...message, content: normalized };
|
|
521
|
+
// Hot path: append synchronously so the entry is durable the instant this
|
|
522
|
+
// returns (file/memory writers perform the write in-body). Never routed
|
|
523
|
+
// through the async disk chain — durability must hold without a flush().
|
|
524
|
+
// A mid-close writer leaves `#writer` undefined, so `#appendWriter` simply
|
|
525
|
+
// opens a fresh append handle and the entry still lands.
|
|
526
|
+
try {
|
|
527
|
+
void this.#appendWriter()
|
|
528
|
+
.append(this.#lineFor(entry))
|
|
529
|
+
.catch(err => this.#noteDiskFailure(err));
|
|
530
|
+
} catch (err) {
|
|
531
|
+
this.#noteDiskFailure(err);
|
|
832
532
|
}
|
|
833
533
|
}
|
|
834
534
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
selectedMCPToolNames,
|
|
842
|
-
hasPersistedMCPToolSelection,
|
|
843
|
-
mode,
|
|
844
|
-
modeData,
|
|
845
|
-
};
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
/**
|
|
849
|
-
* Compute the default session directory for a cwd.
|
|
850
|
-
* Classifies cwd by canonical location so symlink/alias paths resolve to the
|
|
851
|
-
* same home-relative or temp-root directory names as their real targets.
|
|
852
|
-
*/
|
|
853
|
-
function computeDefaultSessionDir(
|
|
854
|
-
cwd: string,
|
|
855
|
-
storage: SessionStorage,
|
|
856
|
-
sessionsRoot: string = getSessionsDir(),
|
|
857
|
-
): string {
|
|
858
|
-
const { encodedDirName, resolvedCwd } = getDefaultSessionDirName(cwd);
|
|
859
|
-
migrateHomeSessionDirs(sessionsRoot);
|
|
860
|
-
const sessionDir = path.join(sessionsRoot, encodedDirName);
|
|
861
|
-
migrateLegacyAbsoluteSessionDir(resolvedCwd, sessionDir, sessionsRoot);
|
|
862
|
-
storage.ensureDirSync(sessionDir);
|
|
863
|
-
return sessionDir;
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
// =============================================================================
|
|
867
|
-
// Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
|
|
868
|
-
// =============================================================================
|
|
535
|
+
#resetToNewSession(options?: NewSessionOptions, forcedSessionFile?: string): string | undefined {
|
|
536
|
+
this.#diskTail = Promise.resolve();
|
|
537
|
+
this.#clearDiskError();
|
|
538
|
+
this.#sessionId = mintSessionId();
|
|
539
|
+
this.#sessionName = undefined;
|
|
540
|
+
this.#titleSource = undefined;
|
|
869
541
|
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
const breadcrumbDir = getTerminalSessionsDir();
|
|
880
|
-
const breadcrumbFile = path.join(breadcrumbDir, terminalId);
|
|
881
|
-
const content = `${cwd}\n${sessionFile}\n`;
|
|
882
|
-
// Best-effort — don't break session creation if breadcrumb fails
|
|
883
|
-
Bun.write(breadcrumbFile, content).catch(() => {});
|
|
884
|
-
}
|
|
542
|
+
const timestamp = nowIso();
|
|
543
|
+
this.#header = {
|
|
544
|
+
type: "session",
|
|
545
|
+
version: CURRENT_SESSION_VERSION,
|
|
546
|
+
id: this.#sessionId,
|
|
547
|
+
timestamp,
|
|
548
|
+
cwd: this.#cwd,
|
|
549
|
+
parentSession: options?.parentSession,
|
|
550
|
+
};
|
|
885
551
|
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
552
|
+
this.#entries = [];
|
|
553
|
+
this.#index.clear();
|
|
554
|
+
this.#fileIsCurrent = false;
|
|
555
|
+
this.#rewriteRequired = false;
|
|
556
|
+
this.#forceFileCreation = false;
|
|
557
|
+
this.#turnBudgetTotal = null;
|
|
558
|
+
this.#turnBudgetHard = false;
|
|
559
|
+
this.#turnOutputBaseline = 0;
|
|
560
|
+
this.#turnEvalOutput = 0;
|
|
561
|
+
this.#artifactManager = null;
|
|
562
|
+
this.#artifactManagerSessionFile = null;
|
|
563
|
+
this.#adoptedArtifactManager = null;
|
|
564
|
+
this.#inMemoryArtifacts = null;
|
|
565
|
+
this.#inMemoryArtifactCounter = 0;
|
|
890
566
|
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
if (!terminalId) return null;
|
|
900
|
-
|
|
901
|
-
try {
|
|
902
|
-
const breadcrumbFile = path.join(getTerminalSessionsDir(), terminalId);
|
|
903
|
-
const content = await Bun.file(breadcrumbFile).text();
|
|
904
|
-
const lines = content.trim().split("\n");
|
|
905
|
-
if (lines.length < 2) return null;
|
|
906
|
-
|
|
907
|
-
const breadcrumbCwd = lines[0];
|
|
908
|
-
const sessionFile = lines[1];
|
|
909
|
-
|
|
910
|
-
// Verify the session file still exists
|
|
911
|
-
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
|
912
|
-
if (stat?.isFile()) return { cwd: breadcrumbCwd, sessionFile };
|
|
913
|
-
} catch (err) {
|
|
914
|
-
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
915
|
-
// Breadcrumb doesn't exist or is corrupt — fall through
|
|
916
|
-
}
|
|
917
|
-
return null;
|
|
918
|
-
}
|
|
567
|
+
if (this.#persist) {
|
|
568
|
+
this.#sessionFile =
|
|
569
|
+
forcedSessionFile ??
|
|
570
|
+
path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
571
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
572
|
+
} else {
|
|
573
|
+
this.#sessionFile = undefined;
|
|
574
|
+
}
|
|
919
575
|
|
|
920
|
-
|
|
921
|
-
export async function loadEntriesFromFile(
|
|
922
|
-
filePath: string,
|
|
923
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
924
|
-
): Promise<FileEntry[]> {
|
|
925
|
-
let content: string;
|
|
926
|
-
try {
|
|
927
|
-
content = await storage.readText(filePath);
|
|
928
|
-
} catch (err) {
|
|
929
|
-
if (isEnoent(err)) return [];
|
|
930
|
-
throw err;
|
|
576
|
+
return this.#sessionFile;
|
|
931
577
|
}
|
|
932
|
-
const entries = parseJsonlLenient<FileEntry>(content);
|
|
933
578
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
579
|
+
#applyEntries(header: SessionHeader, entries: SessionEntry[]): void {
|
|
580
|
+
this.#header = header;
|
|
581
|
+
this.#entries = entries;
|
|
582
|
+
this.#sessionId = header.id;
|
|
583
|
+
this.#sessionName = header.title;
|
|
584
|
+
this.#titleSource = header.titleSource;
|
|
585
|
+
this.#index.rebuild(entries);
|
|
939
586
|
}
|
|
940
587
|
|
|
941
|
-
|
|
942
|
-
|
|
588
|
+
#freshEntryFields(): { id: string; parentId: string | null; timestamp: string } {
|
|
589
|
+
return {
|
|
590
|
+
id: generateId(this.#index),
|
|
591
|
+
parentId: this.#index.leafId(),
|
|
592
|
+
timestamp: nowIso(),
|
|
593
|
+
};
|
|
594
|
+
}
|
|
943
595
|
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
function hasImageUrl(value: unknown): value is { image_url: string } {
|
|
949
|
-
return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string";
|
|
950
|
-
}
|
|
596
|
+
#recordEntry(entry: SessionEntry): void {
|
|
597
|
+
this.#entries.push(entry);
|
|
598
|
+
this.#index.insert(entry);
|
|
599
|
+
this.#appendToSessionFile(entry);
|
|
951
600
|
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
601
|
+
const callback = this.onEntryAppended;
|
|
602
|
+
if (callback) {
|
|
603
|
+
try {
|
|
604
|
+
callback(entry);
|
|
605
|
+
} catch (err) {
|
|
606
|
+
logger.warn("collab entry hook failed", { error: String(err) });
|
|
607
|
+
}
|
|
608
|
+
}
|
|
956
609
|
}
|
|
957
610
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
value.image_url = await resolveImageDataUrl(blobStore, value.image_url);
|
|
611
|
+
#draftPath(): string | null {
|
|
612
|
+
const artifactsDir = this.getArtifactsDir();
|
|
613
|
+
return artifactsDir ? path.join(artifactsDir, "draft.txt") : null;
|
|
962
614
|
}
|
|
963
615
|
|
|
964
|
-
|
|
965
|
-
|
|
616
|
+
#artifactManagerForSession(): ArtifactManager | null {
|
|
617
|
+
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
|
|
966
618
|
|
|
967
|
-
|
|
968
|
-
|
|
619
|
+
const sessionFile = this.#sessionFile;
|
|
620
|
+
if (!sessionFile) {
|
|
621
|
+
this.#artifactManager = null;
|
|
622
|
+
this.#artifactManagerSessionFile = null;
|
|
623
|
+
return null;
|
|
624
|
+
}
|
|
969
625
|
|
|
970
|
-
|
|
971
|
-
if (entry.type === "session") continue;
|
|
626
|
+
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) return this.#artifactManager;
|
|
972
627
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
} else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
|
|
977
|
-
contentArray = entry.content;
|
|
978
|
-
}
|
|
979
|
-
|
|
980
|
-
if (contentArray) {
|
|
981
|
-
for (const block of contentArray) {
|
|
982
|
-
if (isImageBlock(block) && isBlobRef(block.data)) {
|
|
983
|
-
promises.push(
|
|
984
|
-
resolveImageData(blobStore, block.data).then(resolved => {
|
|
985
|
-
block.data = resolved;
|
|
986
|
-
}),
|
|
987
|
-
);
|
|
988
|
-
}
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
promises.push(resolvePersistedImageUrlRefs(entry, blobStore));
|
|
993
|
-
}
|
|
994
|
-
|
|
995
|
-
await Promise.all(promises);
|
|
996
|
-
}
|
|
997
|
-
|
|
998
|
-
/**
|
|
999
|
-
* Read-only message view of a session file: load entries, migrate to the
|
|
1000
|
-
* current version, resolve blob refs, and build the context along the
|
|
1001
|
-
* persisted leaf path (last entry). Does NOT create a writer or take the
|
|
1002
|
-
* session lock — safe to call against a file another session is writing.
|
|
1003
|
-
*/
|
|
1004
|
-
export async function loadSessionMessagesReadOnly(filePath: string): Promise<AgentMessage[]> {
|
|
1005
|
-
const entries = await loadEntriesFromFile(filePath);
|
|
1006
|
-
if (entries.length === 0) return [];
|
|
1007
|
-
migrateToCurrentVersion(entries);
|
|
1008
|
-
await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir()));
|
|
1009
|
-
const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session");
|
|
1010
|
-
return buildSessionContext(sessionEntries).messages;
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
/**
|
|
1014
|
-
* Lightweight metadata for a session file, used in session picker UI.
|
|
1015
|
-
* Uses lazy getters to defer string formatting until actually displayed.
|
|
1016
|
-
*/
|
|
1017
|
-
function sanitizeSessionName(value: string | undefined): string | undefined {
|
|
1018
|
-
if (!value) return undefined;
|
|
1019
|
-
const firstLine = value.split(/\r?\n/)[0] ?? "";
|
|
1020
|
-
const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
|
|
1021
|
-
const trimmed = stripped.trim();
|
|
1022
|
-
return trimmed.length > 0 ? trimmed : undefined;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
class RecentSessionInfo {
|
|
1026
|
-
#fullName: string | undefined;
|
|
1027
|
-
#timeAgo: string | undefined;
|
|
1028
|
-
readonly #headerTimestamp: string | undefined;
|
|
1029
|
-
|
|
1030
|
-
constructor(
|
|
1031
|
-
readonly path: string,
|
|
1032
|
-
readonly mtime: number,
|
|
1033
|
-
header: Record<string, unknown>,
|
|
1034
|
-
firstPrompt?: string,
|
|
1035
|
-
) {
|
|
1036
|
-
// Prefer an explicit title, then the first user prompt. The raw UUID `id` is
|
|
1037
|
-
// intentionally not used as a fallback: showing it as a "name" is unfriendly and
|
|
1038
|
-
// indistinguishable from neighboring sessions in the UI. The friendly fallback is
|
|
1039
|
-
// derived lazily in `fullName` from the session timestamp.
|
|
1040
|
-
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
1041
|
-
this.#fullName = sanitizeSessionName(trystr(header.title)) ?? sanitizeSessionName(firstPrompt);
|
|
1042
|
-
this.#headerTimestamp = trystr(header.timestamp);
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
|
-
/** Display name. Falls back to a timestamp-based label, never the raw UUID. */
|
|
1046
|
-
get fullName(): string {
|
|
1047
|
-
if (this.#fullName) return this.#fullName;
|
|
1048
|
-
const ts = this.#headerTimestamp ? Date.parse(this.#headerTimestamp) : Number.NaN;
|
|
1049
|
-
const date = new Date(Number.isFinite(ts) ? ts : this.mtime);
|
|
1050
|
-
const time = date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
1051
|
-
this.#fullName = `Untitled · ${time}`;
|
|
1052
|
-
return this.#fullName;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
/**
|
|
1056
|
-
* Display name without an arbitrary length cap. The renderer is responsible for
|
|
1057
|
-
* width-aware truncation so adjacent fields (e.g. the relative time) stay visible.
|
|
1058
|
-
*/
|
|
1059
|
-
get name(): string {
|
|
1060
|
-
return this.fullName;
|
|
1061
|
-
}
|
|
1062
|
-
|
|
1063
|
-
/** Human-readable relative time (e.g., "2 hours ago") */
|
|
1064
|
-
get timeAgo(): string {
|
|
1065
|
-
if (this.#timeAgo) return this.#timeAgo;
|
|
1066
|
-
this.#timeAgo = formatTimeAgo(new Date(this.mtime));
|
|
1067
|
-
return this.#timeAgo;
|
|
1068
|
-
}
|
|
1069
|
-
}
|
|
1070
|
-
|
|
1071
|
-
/**
|
|
1072
|
-
* Extracts the text content from a user message entry.
|
|
1073
|
-
* Returns undefined if the entry is not a user message or has no text.
|
|
1074
|
-
*/
|
|
1075
|
-
function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string | undefined {
|
|
1076
|
-
for (const entry of entries) {
|
|
1077
|
-
if (entry.type !== "message") continue;
|
|
1078
|
-
const message = entry.message as Record<string, unknown> | undefined;
|
|
1079
|
-
if (message?.role !== "user") continue;
|
|
1080
|
-
const content = message.content;
|
|
1081
|
-
if (typeof content === "string") return content;
|
|
1082
|
-
if (Array.isArray(content)) {
|
|
1083
|
-
for (const block of content) {
|
|
1084
|
-
if (typeof block === "object" && block !== null && "text" in block) {
|
|
1085
|
-
const text = (block as { text: unknown }).text;
|
|
1086
|
-
if (typeof text === "string") return text;
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
}
|
|
1090
|
-
}
|
|
1091
|
-
return undefined;
|
|
1092
|
-
}
|
|
1093
|
-
|
|
1094
|
-
/**
|
|
1095
|
-
* Promote orphaned `<basename>.jsonl.<snowflake>.bak` backups created by
|
|
1096
|
-
* `#replaceSessionFileAfterEperm` back to their primary path when the primary
|
|
1097
|
-
* is missing. This runs once per session-dir scan, before the main `*.jsonl`
|
|
1098
|
-
* glob, so a crash between the two renames in the EPERM-rewrite path does not
|
|
1099
|
-
* leave the user's last good state stranded outside the loader's view.
|
|
1100
|
-
*
|
|
1101
|
-
* Exported for testing.
|
|
1102
|
-
*/
|
|
1103
|
-
export async function recoverOrphanedBackups(sessionDir: string, storage: SessionStorage): Promise<void> {
|
|
1104
|
-
let backups: string[];
|
|
1105
|
-
try {
|
|
1106
|
-
backups = storage.listFilesSync(sessionDir, "*.bak");
|
|
1107
|
-
} catch {
|
|
1108
|
-
return;
|
|
1109
|
-
}
|
|
1110
|
-
if (backups.length === 0) return;
|
|
1111
|
-
// For each primary path, pick the newest backup (highest mtime) as the recovery source.
|
|
1112
|
-
const candidates = new Map<string, { backup: string; mtimeMs: number }>();
|
|
1113
|
-
for (const backup of backups) {
|
|
1114
|
-
const name = path.basename(backup);
|
|
1115
|
-
// Expect "<primary>.<snowflake>.bak" where <primary> ends in ".jsonl".
|
|
1116
|
-
if (!name.endsWith(".bak")) continue;
|
|
1117
|
-
const trimmed = name.slice(0, -".bak".length);
|
|
1118
|
-
const dotIdx = trimmed.lastIndexOf(".");
|
|
1119
|
-
if (dotIdx <= 0) continue;
|
|
1120
|
-
const primaryName = trimmed.slice(0, dotIdx);
|
|
1121
|
-
if (!primaryName.endsWith(".jsonl")) continue;
|
|
1122
|
-
const primaryPath = path.join(sessionDir, primaryName);
|
|
1123
|
-
let mtimeMs = 0;
|
|
1124
|
-
try {
|
|
1125
|
-
mtimeMs = storage.statSync(backup).mtimeMs;
|
|
1126
|
-
} catch {
|
|
1127
|
-
continue;
|
|
1128
|
-
}
|
|
1129
|
-
const existing = candidates.get(primaryPath);
|
|
1130
|
-
if (!existing || mtimeMs > existing.mtimeMs) {
|
|
1131
|
-
candidates.set(primaryPath, { backup, mtimeMs });
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
for (const [primaryPath, { backup }] of candidates) {
|
|
1135
|
-
if (storage.existsSync(primaryPath)) continue;
|
|
1136
|
-
try {
|
|
1137
|
-
await storage.rename(backup, primaryPath);
|
|
1138
|
-
logger.warn("Recovered orphaned session backup", {
|
|
1139
|
-
sessionFile: primaryPath,
|
|
1140
|
-
backupPath: backup,
|
|
1141
|
-
});
|
|
1142
|
-
} catch (err) {
|
|
1143
|
-
logger.warn("Failed to recover orphaned session backup", {
|
|
1144
|
-
sessionFile: primaryPath,
|
|
1145
|
-
backupPath: backup,
|
|
1146
|
-
error: toError(err).message,
|
|
1147
|
-
});
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
}
|
|
1151
|
-
|
|
1152
|
-
/**
|
|
1153
|
-
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
1154
|
-
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
1155
|
-
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
1156
|
-
*/
|
|
1157
|
-
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
|
|
1158
|
-
await recoverOrphanedBackups(sessionDir, storage);
|
|
1159
|
-
try {
|
|
1160
|
-
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
1161
|
-
const sessions: RecentSessionInfo[] = [];
|
|
1162
|
-
await Promise.all(
|
|
1163
|
-
files.map(async (path: string) => {
|
|
1164
|
-
try {
|
|
1165
|
-
const [content] = await storage.readTextSlices(path, 4096, 0);
|
|
1166
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1167
|
-
if (entries.length === 0) return;
|
|
1168
|
-
const header = entries[0] as Record<string, unknown>;
|
|
1169
|
-
if (header.type !== "session" || typeof header.id !== "string") return;
|
|
1170
|
-
const mtime = storage.statSync(path).mtimeMs;
|
|
1171
|
-
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
1172
|
-
sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
|
|
1173
|
-
} catch {}
|
|
1174
|
-
}),
|
|
1175
|
-
);
|
|
1176
|
-
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
1177
|
-
} catch {
|
|
1178
|
-
return [];
|
|
1179
|
-
}
|
|
1180
|
-
}
|
|
1181
|
-
|
|
1182
|
-
/** Exported for testing */
|
|
1183
|
-
export async function findMostRecentSession(
|
|
1184
|
-
sessionDir: string,
|
|
1185
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1186
|
-
): Promise<string | null> {
|
|
1187
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
1188
|
-
return sessions[0]?.path || null;
|
|
1189
|
-
}
|
|
1190
|
-
|
|
1191
|
-
/** Format a time difference as a human-readable string */
|
|
1192
|
-
function formatTimeAgo(date: Date): string {
|
|
1193
|
-
const now = Date.now();
|
|
1194
|
-
const diffMs = now - date.getTime();
|
|
1195
|
-
const diffMins = Math.floor(diffMs / 60000);
|
|
1196
|
-
const diffHours = Math.floor(diffMs / 3600000);
|
|
1197
|
-
const diffDays = Math.floor(diffMs / 86400000);
|
|
1198
|
-
|
|
1199
|
-
if (diffMins < 1) return "just now";
|
|
1200
|
-
if (diffMins < 60) return `${diffMins}m ago`;
|
|
1201
|
-
if (diffHours < 24) return `${diffHours}h ago`;
|
|
1202
|
-
if (diffDays < 7) return `${diffDays}d ago`;
|
|
1203
|
-
return date.toLocaleDateString();
|
|
1204
|
-
}
|
|
1205
|
-
|
|
1206
|
-
const MAX_PERSIST_CHARS = 500_000;
|
|
1207
|
-
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
|
|
1208
|
-
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
|
|
1209
|
-
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
|
|
1210
|
-
const TEXT_CONTENT_KEY = "content";
|
|
1211
|
-
|
|
1212
|
-
/**
|
|
1213
|
-
* Recursively truncate large strings in an object for session persistence.
|
|
1214
|
-
* - Truncates any oversized string fields (key-agnostic)
|
|
1215
|
-
* - Replaces oversized image blocks with text notices
|
|
1216
|
-
* - Updates lineCount when content is truncated
|
|
1217
|
-
* - Returns original object if no changes needed (structural sharing)
|
|
1218
|
-
*/
|
|
1219
|
-
function truncateString(value: string, maxLength: number): string {
|
|
1220
|
-
if (value.length <= maxLength) return value;
|
|
1221
|
-
let truncated = value.slice(0, maxLength);
|
|
1222
|
-
if (truncated.length > 0) {
|
|
1223
|
-
const last = truncated.charCodeAt(truncated.length - 1);
|
|
1224
|
-
if (last >= 0xd800 && last <= 0xdbff) {
|
|
1225
|
-
truncated = truncated.slice(0, -1);
|
|
1226
|
-
}
|
|
1227
|
-
}
|
|
1228
|
-
return truncated;
|
|
1229
|
-
}
|
|
1230
|
-
|
|
1231
|
-
function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
|
|
1232
|
-
return (
|
|
1233
|
-
typeof value === "object" &&
|
|
1234
|
-
value !== null &&
|
|
1235
|
-
"type" in value &&
|
|
1236
|
-
(value as { type?: string }).type === "image" &&
|
|
1237
|
-
"data" in value &&
|
|
1238
|
-
typeof (value as { data?: string }).data === "string"
|
|
1239
|
-
);
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
async function truncateForPersistence(obj: FileEntry, blobStore: BlobStore, key?: string): Promise<FileEntry>;
|
|
1243
|
-
async function truncateForPersistence(obj: string, blobStore: BlobStore, key?: string): Promise<string>;
|
|
1244
|
-
async function truncateForPersistence(obj: unknown[], blobStore: BlobStore, key?: string): Promise<unknown[]>;
|
|
1245
|
-
async function truncateForPersistence(obj: object, blobStore: BlobStore, key?: string): Promise<object>;
|
|
1246
|
-
async function truncateForPersistence(
|
|
1247
|
-
obj: null | undefined,
|
|
1248
|
-
blobStore: BlobStore,
|
|
1249
|
-
key?: string,
|
|
1250
|
-
): Promise<null | undefined>;
|
|
1251
|
-
async function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): Promise<unknown> {
|
|
1252
|
-
if (obj === null || obj === undefined) return obj;
|
|
1253
|
-
|
|
1254
|
-
if (typeof obj === "string") {
|
|
1255
|
-
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
1256
|
-
return externalizeImageDataUrl(blobStore, obj);
|
|
1257
|
-
}
|
|
1258
|
-
|
|
1259
|
-
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1260
|
-
// Cryptographic signatures must be preserved exactly or cleared entirely — never truncated.
|
|
1261
|
-
// Truncation would produce an invalid signature that the API rejects.
|
|
1262
|
-
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1263
|
-
return "";
|
|
1264
|
-
}
|
|
1265
|
-
|
|
1266
|
-
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1267
|
-
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1268
|
-
}
|
|
1269
|
-
|
|
1270
|
-
return obj;
|
|
1271
|
-
}
|
|
1272
|
-
|
|
1273
|
-
if (Array.isArray(obj)) {
|
|
1274
|
-
let changed = false;
|
|
1275
|
-
const result = await Promise.all(
|
|
1276
|
-
obj.map(async item => {
|
|
1277
|
-
// Special handling: compress oversized images while preserving shape
|
|
1278
|
-
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
|
|
1279
|
-
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
|
|
1280
|
-
changed = true;
|
|
1281
|
-
const blobRef = await externalizeImageData(blobStore, item.data, item.mimeType);
|
|
1282
|
-
return { ...item, data: blobRef };
|
|
1283
|
-
}
|
|
1284
|
-
}
|
|
1285
|
-
|
|
1286
|
-
const newItem = await truncateForPersistence(item, blobStore, key);
|
|
1287
|
-
if (newItem !== item) changed = true;
|
|
1288
|
-
return newItem;
|
|
1289
|
-
}),
|
|
1290
|
-
);
|
|
1291
|
-
return changed ? result : obj;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
if (typeof obj === "object") {
|
|
1295
|
-
let changed = false;
|
|
1296
|
-
const entries: Array<readonly [string, unknown]> = await Promise.all(
|
|
1297
|
-
Object.entries(obj).flatMap(([childKey, value]) => {
|
|
1298
|
-
// Strip transient/redundant properties that shouldn't be persisted.
|
|
1299
|
-
// - partialJson: streaming accumulator for tool call JSON parsing
|
|
1300
|
-
// - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
|
|
1301
|
-
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1302
|
-
changed = true;
|
|
1303
|
-
return [];
|
|
1304
|
-
}
|
|
1305
|
-
|
|
1306
|
-
return [
|
|
1307
|
-
(async () => {
|
|
1308
|
-
const newValue = await truncateForPersistence(value, blobStore, childKey);
|
|
1309
|
-
if (newValue !== value) changed = true;
|
|
1310
|
-
return [childKey, newValue] as const;
|
|
1311
|
-
})(),
|
|
1312
|
-
];
|
|
1313
|
-
}),
|
|
1314
|
-
);
|
|
1315
|
-
|
|
1316
|
-
if (!changed) return obj;
|
|
1317
|
-
|
|
1318
|
-
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1319
|
-
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1320
|
-
if (
|
|
1321
|
-
contentEntry &&
|
|
1322
|
-
typeof contentEntry[1] === "string" &&
|
|
1323
|
-
lineCountEntry &&
|
|
1324
|
-
typeof lineCountEntry[1] === "number"
|
|
1325
|
-
) {
|
|
1326
|
-
const content = contentEntry[1];
|
|
1327
|
-
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1328
|
-
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1329
|
-
);
|
|
1330
|
-
return Object.fromEntries(updatedEntries);
|
|
1331
|
-
}
|
|
1332
|
-
return Object.fromEntries(entries);
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
return obj;
|
|
1336
|
-
}
|
|
1337
|
-
|
|
1338
|
-
async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise<FileEntry> {
|
|
1339
|
-
return truncateForPersistence(entry, blobStore);
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
/**
|
|
1343
|
-
* Synchronous variant of {@link truncateForPersistence}.
|
|
1344
|
-
*
|
|
1345
|
-
* The async version's overhead — `Promise.all` over `Object.entries`/`Array.prototype.map`,
|
|
1346
|
-
* one microtask hop per nested node — is pure waste for entries without image blobs
|
|
1347
|
-
* (the vast majority). The fast path runs in one synchronous tick so an OOM/SIGKILL
|
|
1348
|
-
* landing right after `_persist` returns cannot lose the entry. Image externalization
|
|
1349
|
-
* still happens, but via the synchronous blob-store path (`fs.writeFileSync`), so the
|
|
1350
|
-
* blob bytes are in the kernel page cache before the JSONL line referencing them is
|
|
1351
|
-
* written.
|
|
1352
|
-
*/
|
|
1353
|
-
function truncateForPersistenceSync(obj: unknown, blobStore: BlobStore, key?: string): unknown {
|
|
1354
|
-
if (obj === null || obj === undefined) return obj;
|
|
1355
|
-
|
|
1356
|
-
if (typeof obj === "string") {
|
|
1357
|
-
if (key === "image_url" && isImageDataUrl(obj)) {
|
|
1358
|
-
return externalizeImageDataUrlSync(blobStore, obj);
|
|
1359
|
-
}
|
|
1360
|
-
if (obj.length > MAX_PERSIST_CHARS) {
|
|
1361
|
-
if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") {
|
|
1362
|
-
return "";
|
|
1363
|
-
}
|
|
1364
|
-
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
1365
|
-
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`;
|
|
1366
|
-
}
|
|
1367
|
-
return obj;
|
|
1368
|
-
}
|
|
1369
|
-
|
|
1370
|
-
if (Array.isArray(obj)) {
|
|
1371
|
-
let changed = false;
|
|
1372
|
-
const result: unknown[] = new Array(obj.length);
|
|
1373
|
-
for (let i = 0; i < obj.length; i++) {
|
|
1374
|
-
const item = obj[i];
|
|
1375
|
-
if (
|
|
1376
|
-
key === TEXT_CONTENT_KEY &&
|
|
1377
|
-
isImageBlock(item) &&
|
|
1378
|
-
!isBlobRef(item.data) &&
|
|
1379
|
-
item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
|
|
1380
|
-
) {
|
|
1381
|
-
changed = true;
|
|
1382
|
-
result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
|
|
1383
|
-
continue;
|
|
1384
|
-
}
|
|
1385
|
-
const newItem = truncateForPersistenceSync(item, blobStore, key);
|
|
1386
|
-
if (newItem !== item) changed = true;
|
|
1387
|
-
result[i] = newItem;
|
|
1388
|
-
}
|
|
1389
|
-
return changed ? result : obj;
|
|
1390
|
-
}
|
|
1391
|
-
|
|
1392
|
-
if (typeof obj === "object") {
|
|
1393
|
-
let changed = false;
|
|
1394
|
-
const entries: Array<readonly [string, unknown]> = [];
|
|
1395
|
-
for (const [childKey, value] of Object.entries(obj)) {
|
|
1396
|
-
if (childKey === "partialJson" || childKey === "jsonlEvents") {
|
|
1397
|
-
changed = true;
|
|
1398
|
-
continue;
|
|
1399
|
-
}
|
|
1400
|
-
const newValue = truncateForPersistenceSync(value, blobStore, childKey);
|
|
1401
|
-
if (newValue !== value) changed = true;
|
|
1402
|
-
entries.push([childKey, newValue]);
|
|
1403
|
-
}
|
|
1404
|
-
if (!changed) return obj;
|
|
1405
|
-
|
|
1406
|
-
const contentEntry = entries.find(([childKey]) => childKey === "content");
|
|
1407
|
-
const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount");
|
|
1408
|
-
if (
|
|
1409
|
-
contentEntry &&
|
|
1410
|
-
typeof contentEntry[1] === "string" &&
|
|
1411
|
-
lineCountEntry &&
|
|
1412
|
-
typeof lineCountEntry[1] === "number"
|
|
1413
|
-
) {
|
|
1414
|
-
const content = contentEntry[1];
|
|
1415
|
-
const updatedEntries = entries.map(([childKey, value]) =>
|
|
1416
|
-
childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const),
|
|
1417
|
-
);
|
|
1418
|
-
return Object.fromEntries(updatedEntries);
|
|
1419
|
-
}
|
|
1420
|
-
return Object.fromEntries(entries);
|
|
1421
|
-
}
|
|
1422
|
-
|
|
1423
|
-
return obj;
|
|
1424
|
-
}
|
|
1425
|
-
|
|
1426
|
-
function prepareEntryForPersistenceSync(entry: FileEntry, blobStore: BlobStore): FileEntry {
|
|
1427
|
-
return truncateForPersistenceSync(entry, blobStore) as FileEntry;
|
|
1428
|
-
}
|
|
1429
|
-
|
|
1430
|
-
class NdjsonFileWriter {
|
|
1431
|
-
#writer: SessionStorageWriter;
|
|
1432
|
-
#closed = false;
|
|
1433
|
-
#closing = false;
|
|
1434
|
-
#error: Error | undefined;
|
|
1435
|
-
#pendingWrites: Promise<void> = Promise.resolve();
|
|
1436
|
-
#onError: ((err: Error) => void) | undefined;
|
|
1437
|
-
|
|
1438
|
-
constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
|
|
1439
|
-
this.#onError = options?.onError;
|
|
1440
|
-
this.#writer = storage.openWriter(path, {
|
|
1441
|
-
flags: options?.flags ?? "a",
|
|
1442
|
-
onError: (err: Error) => this.#recordError(err),
|
|
1443
|
-
});
|
|
1444
|
-
}
|
|
1445
|
-
|
|
1446
|
-
#recordError(err: unknown): Error {
|
|
1447
|
-
const writeErr = toError(err);
|
|
1448
|
-
if (!this.#error) this.#error = writeErr;
|
|
1449
|
-
this.#onError?.(writeErr);
|
|
1450
|
-
return writeErr;
|
|
1451
|
-
}
|
|
1452
|
-
|
|
1453
|
-
#enqueue(task: () => Promise<void>): Promise<void> {
|
|
1454
|
-
const run = async () => {
|
|
1455
|
-
if (this.#error) throw this.#error;
|
|
1456
|
-
await task();
|
|
1457
|
-
};
|
|
1458
|
-
const next = this.#pendingWrites.then(run);
|
|
1459
|
-
void next.catch((err: unknown) => {
|
|
1460
|
-
if (!this.#error) this.#error = toError(err);
|
|
1461
|
-
});
|
|
1462
|
-
this.#pendingWrites = next;
|
|
1463
|
-
return next;
|
|
1464
|
-
}
|
|
1465
|
-
|
|
1466
|
-
async #writeLine(line: string): Promise<void> {
|
|
1467
|
-
if (this.#error) throw this.#error;
|
|
1468
|
-
try {
|
|
1469
|
-
await this.#writer.writeLine(line);
|
|
1470
|
-
} catch (err) {
|
|
1471
|
-
throw this.#recordError(err);
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
|
|
1475
|
-
/** Queue a write. Returns a promise so callers can await if needed. */
|
|
1476
|
-
write(entry: FileEntry): Promise<void> {
|
|
1477
|
-
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
1478
|
-
if (this.#error) throw this.#error;
|
|
1479
|
-
const line = `${JSON.stringify(entry)}\n`;
|
|
1480
|
-
return this.#enqueue(() => this.#writeLine(line));
|
|
1481
|
-
}
|
|
1482
|
-
|
|
1483
|
-
/**
|
|
1484
|
-
* Synchronously serialize and append the entry. Returns once `fs.writeSync` has handed
|
|
1485
|
-
* the bytes to the kernel page cache — durable across OOM/SIGKILL even before fsync.
|
|
1486
|
-
*
|
|
1487
|
-
* Callers MUST NOT mix this with pending async `write()` calls on the same writer:
|
|
1488
|
-
* the async path is queued through `#pendingWrites`, but this method bypasses the
|
|
1489
|
-
* queue. Use only when no concurrent async write is in flight (the session-manager
|
|
1490
|
-
* persist path enforces this via `#flushed`/`#needsFullRewriteOnNextPersist`).
|
|
1491
|
-
*/
|
|
1492
|
-
writeSync(entry: FileEntry): void {
|
|
1493
|
-
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
1494
|
-
if (this.#error) throw this.#error;
|
|
1495
|
-
const line = `${JSON.stringify(entry)}\n`;
|
|
1496
|
-
try {
|
|
1497
|
-
this.#writer.writeLineSync(line);
|
|
1498
|
-
} catch (err) {
|
|
1499
|
-
throw this.#recordError(err);
|
|
1500
|
-
}
|
|
1501
|
-
}
|
|
1502
|
-
|
|
1503
|
-
/** Flush all buffered data to disk. Waits for all queued writes. */
|
|
1504
|
-
async flush(): Promise<void> {
|
|
1505
|
-
if (this.#closed) return;
|
|
1506
|
-
if (this.#error) throw this.#error;
|
|
1507
|
-
|
|
1508
|
-
await this.#enqueue(async () => {});
|
|
1509
|
-
|
|
1510
|
-
if (this.#error) throw this.#error;
|
|
1511
|
-
|
|
1512
|
-
try {
|
|
1513
|
-
await this.#writer.flush();
|
|
1514
|
-
} catch (err) {
|
|
1515
|
-
throw this.#recordError(err);
|
|
1516
|
-
}
|
|
1517
|
-
}
|
|
1518
|
-
|
|
1519
|
-
/** Sync data to persistent storage. */
|
|
1520
|
-
async fsync(): Promise<void> {
|
|
1521
|
-
if (this.#closed) return;
|
|
1522
|
-
if (this.#error) throw this.#error;
|
|
1523
|
-
try {
|
|
1524
|
-
await this.#writer.fsync();
|
|
1525
|
-
} catch (err) {
|
|
1526
|
-
throw this.#recordError(err);
|
|
1527
|
-
}
|
|
1528
|
-
}
|
|
1529
|
-
|
|
1530
|
-
/** Close the writer, flushing all data. */
|
|
1531
|
-
async close(): Promise<void> {
|
|
1532
|
-
if (this.#closed || this.#closing) return;
|
|
1533
|
-
this.#closing = true;
|
|
1534
|
-
|
|
1535
|
-
let closeError: Error | undefined;
|
|
1536
|
-
try {
|
|
1537
|
-
await this.flush();
|
|
1538
|
-
} catch (err) {
|
|
1539
|
-
closeError = toError(err);
|
|
1540
|
-
}
|
|
1541
|
-
|
|
1542
|
-
try {
|
|
1543
|
-
await this.#pendingWrites;
|
|
1544
|
-
} catch (err) {
|
|
1545
|
-
if (!closeError) closeError = toError(err);
|
|
1546
|
-
}
|
|
1547
|
-
|
|
1548
|
-
try {
|
|
1549
|
-
await this.#writer.close();
|
|
1550
|
-
} catch (err) {
|
|
1551
|
-
const endErr = this.#recordError(err);
|
|
1552
|
-
if (!closeError) closeError = endErr;
|
|
1553
|
-
}
|
|
1554
|
-
|
|
1555
|
-
this.#closed = true;
|
|
1556
|
-
|
|
1557
|
-
if (!closeError && this.#error) closeError = this.#error;
|
|
1558
|
-
if (closeError) throw closeError;
|
|
1559
|
-
}
|
|
1560
|
-
|
|
1561
|
-
/** Check if there's a stored error. */
|
|
1562
|
-
getError(): Error | undefined {
|
|
1563
|
-
return this.#error;
|
|
1564
|
-
}
|
|
1565
|
-
|
|
1566
|
-
/** True while the writer accepts new writes (not closing or closed). */
|
|
1567
|
-
isOpen(): boolean {
|
|
1568
|
-
return !this.#closed && !this.#closing;
|
|
1569
|
-
}
|
|
1570
|
-
}
|
|
1571
|
-
|
|
1572
|
-
/** Get recent sessions for display in welcome screen (which reserves WELCOME_SESSION_SLOTS rows) */
|
|
1573
|
-
export async function getRecentSessions(
|
|
1574
|
-
sessionDir: string,
|
|
1575
|
-
limit = 4,
|
|
1576
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1577
|
-
): Promise<RecentSessionInfo[]> {
|
|
1578
|
-
const sessions = await getSortedSessions(sessionDir, storage);
|
|
1579
|
-
return sessions.slice(0, limit);
|
|
1580
|
-
}
|
|
1581
|
-
|
|
1582
|
-
/**
|
|
1583
|
-
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
1584
|
-
*
|
|
1585
|
-
* Each session entry has an id and parentId forming a tree structure. The "leaf"
|
|
1586
|
-
* pointer tracks the current position. Appending creates a child of the current leaf.
|
|
1587
|
-
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
1588
|
-
* modifying history.
|
|
1589
|
-
*
|
|
1590
|
-
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
1591
|
-
* handles compaction summaries and follows the path from root to current leaf.
|
|
1592
|
-
*/
|
|
1593
|
-
export interface UsageStatistics {
|
|
1594
|
-
input: number;
|
|
1595
|
-
output: number;
|
|
1596
|
-
cacheRead: number;
|
|
1597
|
-
cacheWrite: number;
|
|
1598
|
-
premiumRequests: number;
|
|
1599
|
-
cost: number;
|
|
1600
|
-
}
|
|
1601
|
-
|
|
1602
|
-
function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
1603
|
-
if (!details || typeof details !== "object") return undefined;
|
|
1604
|
-
const record = details as Record<string, unknown>;
|
|
1605
|
-
const usage = record.usage;
|
|
1606
|
-
if (!usage || typeof usage !== "object") return undefined;
|
|
1607
|
-
return usage as Usage;
|
|
1608
|
-
}
|
|
1609
|
-
|
|
1610
|
-
function extractTextFromContent(content: Message["content"]): string {
|
|
1611
|
-
if (typeof content === "string") return content;
|
|
1612
|
-
return content
|
|
1613
|
-
.filter((block): block is TextContent => block.type === "text")
|
|
1614
|
-
.map(block => block.text)
|
|
1615
|
-
.join(" ");
|
|
1616
|
-
}
|
|
1617
|
-
|
|
1618
|
-
const SESSION_LIST_PREFIX_BYTES = 4096;
|
|
1619
|
-
/**
|
|
1620
|
-
* Tail window read to derive {@link SessionStatus}. Large enough to capture a
|
|
1621
|
-
* typical final assistant turn (thinking + text); when the final message exceeds
|
|
1622
|
-
* it the status falls back to `unknown` rather than misreporting.
|
|
1623
|
-
*/
|
|
1624
|
-
const SESSION_LIST_SUFFIX_BYTES = 32_768;
|
|
1625
|
-
const SESSION_LIST_PARALLEL_THRESHOLD = 64;
|
|
1626
|
-
const SESSION_LIST_MAX_WORKERS = 16;
|
|
1627
|
-
|
|
1628
|
-
/**
|
|
1629
|
-
* Derive a {@link SessionStatus} from a tail window of a session file. Entries are
|
|
1630
|
-
* newline-terminated on write, so within the window only the first line can be a
|
|
1631
|
-
* partial fragment — it simply fails to parse and is skipped. We walk backwards to
|
|
1632
|
-
* the last `message` entry and classify by its role / stop reason.
|
|
1633
|
-
*/
|
|
1634
|
-
function deriveSessionStatus(suffix: string): SessionStatus {
|
|
1635
|
-
if (!suffix) return "unknown";
|
|
1636
|
-
const lines = suffix.split("\n");
|
|
1637
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
1638
|
-
const line = lines[i];
|
|
1639
|
-
// Every persisted entry is `JSON.stringify(obj)` → starts with `{`. This
|
|
1640
|
-
// cheaply rejects blank lines and the leading partial fragment without
|
|
1641
|
-
// attempting to parse a multi-KB tail of a truncated line.
|
|
1642
|
-
if (line.charCodeAt(0) !== 123) continue;
|
|
1643
|
-
let entry: { type?: string; message?: TailMessage };
|
|
1644
|
-
try {
|
|
1645
|
-
entry = JSON.parse(line);
|
|
1646
|
-
} catch {
|
|
1647
|
-
continue;
|
|
1648
|
-
}
|
|
1649
|
-
if (entry.type === "message" && entry.message) {
|
|
1650
|
-
return statusFromTailMessage(entry.message);
|
|
1651
|
-
}
|
|
1652
|
-
}
|
|
1653
|
-
return "unknown";
|
|
1654
|
-
}
|
|
1655
|
-
|
|
1656
|
-
interface TailMessage {
|
|
1657
|
-
role?: string;
|
|
1658
|
-
stopReason?: string;
|
|
1659
|
-
content?: unknown;
|
|
1660
|
-
}
|
|
1661
|
-
|
|
1662
|
-
function isToolCallBlock(block: unknown): boolean {
|
|
1663
|
-
return typeof block === "object" && block !== null && (block as { type?: unknown }).type === "toolCall";
|
|
1664
|
-
}
|
|
1665
|
-
|
|
1666
|
-
function statusFromTailMessage(message: TailMessage): SessionStatus {
|
|
1667
|
-
switch (message.role) {
|
|
1668
|
-
case "assistant": {
|
|
1669
|
-
switch (message.stopReason) {
|
|
1670
|
-
case "error":
|
|
1671
|
-
return "error";
|
|
1672
|
-
case "aborted":
|
|
1673
|
-
return "aborted";
|
|
1674
|
-
case "length":
|
|
1675
|
-
return "interrupted";
|
|
1676
|
-
}
|
|
1677
|
-
// A turn that ends without unanswered tool calls means the agent yielded
|
|
1678
|
-
// control back to the user — complete. Trailing tool calls (no tool
|
|
1679
|
-
// results after) mean the loop was cut off before running them.
|
|
1680
|
-
const content = message.content;
|
|
1681
|
-
if (Array.isArray(content) && content.some(isToolCallBlock)) return "interrupted";
|
|
1682
|
-
return "complete";
|
|
1683
|
-
}
|
|
1684
|
-
case "toolResult":
|
|
1685
|
-
// Tools ran but the agent never produced the following assistant turn.
|
|
1686
|
-
return "interrupted";
|
|
1687
|
-
case "user":
|
|
1688
|
-
// User message with no assistant reply persisted after it.
|
|
1689
|
-
return "pending";
|
|
1690
|
-
default:
|
|
1691
|
-
return "unknown";
|
|
1692
|
-
}
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
function decodeJsonStringFragment(value: string): string {
|
|
1696
|
-
const safeValue = value.endsWith("\\") ? value.slice(0, -1) : value;
|
|
1697
|
-
try {
|
|
1698
|
-
return JSON.parse(`"${safeValue}"`) as string;
|
|
1699
|
-
} catch {
|
|
1700
|
-
return safeValue
|
|
1701
|
-
.replace(/\\n/g, "\n")
|
|
1702
|
-
.replace(/\\r/g, "\r")
|
|
1703
|
-
.replace(/\\t/g, "\t")
|
|
1704
|
-
.replace(/\\"/g, '"')
|
|
1705
|
-
.replace(/\\\\/g, "\\");
|
|
1706
|
-
}
|
|
1707
|
-
}
|
|
1708
|
-
|
|
1709
|
-
function extractStringProperty(source: string, name: string, startIndex = 0): string | undefined {
|
|
1710
|
-
const propertyIndex = source.indexOf(`"${name}"`, startIndex);
|
|
1711
|
-
if (propertyIndex === -1) return undefined;
|
|
1712
|
-
|
|
1713
|
-
const colonIndex = source.indexOf(":", propertyIndex + name.length + 2);
|
|
1714
|
-
if (colonIndex === -1) return undefined;
|
|
1715
|
-
|
|
1716
|
-
let valueIndex = colonIndex + 1;
|
|
1717
|
-
while (valueIndex < source.length) {
|
|
1718
|
-
const char = source.charCodeAt(valueIndex);
|
|
1719
|
-
if (char !== 32 && char !== 9 && char !== 10 && char !== 13) break;
|
|
1720
|
-
valueIndex++;
|
|
1721
|
-
}
|
|
1722
|
-
if (source.charCodeAt(valueIndex) !== 34) return undefined;
|
|
1723
|
-
|
|
1724
|
-
const valueStart = valueIndex + 1;
|
|
1725
|
-
let escaped = false;
|
|
1726
|
-
for (let i = valueStart; i < source.length; i++) {
|
|
1727
|
-
const char = source.charCodeAt(i);
|
|
1728
|
-
if (escaped) {
|
|
1729
|
-
escaped = false;
|
|
1730
|
-
continue;
|
|
1731
|
-
}
|
|
1732
|
-
if (char === 92) {
|
|
1733
|
-
escaped = true;
|
|
1734
|
-
continue;
|
|
1735
|
-
}
|
|
1736
|
-
if (char === 34) {
|
|
1737
|
-
return decodeJsonStringFragment(source.slice(valueStart, i));
|
|
1738
|
-
}
|
|
1739
|
-
}
|
|
1740
|
-
|
|
1741
|
-
return decodeJsonStringFragment(source.slice(valueStart));
|
|
1742
|
-
}
|
|
1743
|
-
|
|
1744
|
-
function countMessageMarkers(content: string): number {
|
|
1745
|
-
let count = 0;
|
|
1746
|
-
let index = 0;
|
|
1747
|
-
while (index < content.length) {
|
|
1748
|
-
const typeIndex = content.indexOf('"type"', index);
|
|
1749
|
-
if (typeIndex === -1) break;
|
|
1750
|
-
const colonIndex = content.indexOf(":", typeIndex + 6);
|
|
1751
|
-
if (colonIndex === -1) break;
|
|
1752
|
-
const type = extractStringProperty(content, "type", typeIndex);
|
|
1753
|
-
if (type === "message") count++;
|
|
1754
|
-
index = colonIndex + 1;
|
|
1755
|
-
}
|
|
1756
|
-
return count;
|
|
1757
|
-
}
|
|
1758
|
-
|
|
1759
|
-
function extractFirstUserMessageFromPrefix(content: string): string | undefined {
|
|
1760
|
-
const roleIndex = content.indexOf('"role"');
|
|
1761
|
-
if (roleIndex === -1) return undefined;
|
|
1762
|
-
|
|
1763
|
-
let index = roleIndex;
|
|
1764
|
-
while (index !== -1) {
|
|
1765
|
-
const role = extractStringProperty(content, "role", index);
|
|
1766
|
-
if (role === "user") {
|
|
1767
|
-
return extractStringProperty(content, "content", index) ?? extractStringProperty(content, "text", index);
|
|
1768
|
-
}
|
|
1769
|
-
index = content.indexOf('"role"', index + 6);
|
|
1770
|
-
}
|
|
1771
|
-
|
|
1772
|
-
return undefined;
|
|
1773
|
-
}
|
|
1774
|
-
|
|
1775
|
-
interface SessionListHeader {
|
|
1776
|
-
type: "session";
|
|
1777
|
-
id: string;
|
|
1778
|
-
cwd?: string;
|
|
1779
|
-
title?: string;
|
|
1780
|
-
parentSession?: string;
|
|
1781
|
-
timestamp?: string;
|
|
1782
|
-
}
|
|
1783
|
-
|
|
1784
|
-
function parseSessionListHeader(
|
|
1785
|
-
content: string,
|
|
1786
|
-
entries: Array<Record<string, unknown>>,
|
|
1787
|
-
): SessionListHeader | undefined {
|
|
1788
|
-
const parsedHeader = entries[0];
|
|
1789
|
-
if (parsedHeader?.type === "session" && typeof parsedHeader.id === "string") {
|
|
1790
|
-
return {
|
|
1791
|
-
type: "session",
|
|
1792
|
-
id: parsedHeader.id,
|
|
1793
|
-
cwd: typeof parsedHeader.cwd === "string" ? parsedHeader.cwd : undefined,
|
|
1794
|
-
title: typeof parsedHeader.title === "string" ? parsedHeader.title : undefined,
|
|
1795
|
-
parentSession: typeof parsedHeader.parentSession === "string" ? parsedHeader.parentSession : undefined,
|
|
1796
|
-
timestamp: typeof parsedHeader.timestamp === "string" ? parsedHeader.timestamp : undefined,
|
|
1797
|
-
};
|
|
628
|
+
this.#artifactManager = new ArtifactManager(sessionFile.slice(0, -JSONL_SUFFIX_LENGTH));
|
|
629
|
+
this.#artifactManagerSessionFile = sessionFile;
|
|
630
|
+
return this.#artifactManager;
|
|
1798
631
|
}
|
|
1799
632
|
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
return {
|
|
1808
|
-
type: "session",
|
|
1809
|
-
id,
|
|
1810
|
-
cwd: extractStringProperty(firstLine, "cwd"),
|
|
1811
|
-
title: extractStringProperty(firstLine, "title"),
|
|
1812
|
-
parentSession: extractStringProperty(firstLine, "parentSession"),
|
|
1813
|
-
timestamp: extractStringProperty(firstLine, "timestamp"),
|
|
1814
|
-
};
|
|
1815
|
-
}
|
|
1816
|
-
|
|
1817
|
-
function getSessionListWorkerCount(fileCount: number): number {
|
|
1818
|
-
if (fileCount <= SESSION_LIST_PARALLEL_THRESHOLD) return 1;
|
|
1819
|
-
return Math.min(
|
|
1820
|
-
SESSION_LIST_MAX_WORKERS,
|
|
1821
|
-
os.availableParallelism(),
|
|
1822
|
-
Math.ceil(fileCount / SESSION_LIST_PARALLEL_THRESHOLD),
|
|
1823
|
-
);
|
|
1824
|
-
}
|
|
1825
|
-
|
|
1826
|
-
async function collectSessionFromFile(file: string, storage: SessionStorage): Promise<SessionInfo | undefined> {
|
|
1827
|
-
try {
|
|
1828
|
-
const stat = storage.statSync(file);
|
|
1829
|
-
const [content, suffix] = await storage.readTextSlices(
|
|
1830
|
-
file,
|
|
1831
|
-
SESSION_LIST_PREFIX_BYTES,
|
|
1832
|
-
SESSION_LIST_SUFFIX_BYTES,
|
|
1833
|
-
);
|
|
1834
|
-
const { size, mtime } = stat;
|
|
1835
|
-
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1836
|
-
const header = parseSessionListHeader(content, entries);
|
|
1837
|
-
if (!header) return undefined;
|
|
1838
|
-
|
|
1839
|
-
let parsedMessageCount = 0;
|
|
1840
|
-
let firstMessage = "";
|
|
1841
|
-
const allMessages: string[] = [];
|
|
1842
|
-
let shortSummary: string | undefined;
|
|
1843
|
-
|
|
1844
|
-
for (let i = 1; i < entries.length; i++) {
|
|
1845
|
-
const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
|
|
1846
|
-
|
|
1847
|
-
if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
|
|
1848
|
-
shortSummary = entry.shortSummary;
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
if (entry.type === "message" && entry.message) {
|
|
1852
|
-
parsedMessageCount++;
|
|
1853
|
-
|
|
1854
|
-
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
1855
|
-
const textContent = extractTextFromContent(entry.message.content);
|
|
1856
|
-
|
|
1857
|
-
if (textContent) {
|
|
1858
|
-
allMessages.push(textContent);
|
|
1859
|
-
|
|
1860
|
-
if (!firstMessage && entry.message.role === "user") {
|
|
1861
|
-
firstMessage = textContent;
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
633
|
+
#notifySessionNameListeners(): void {
|
|
634
|
+
for (const callback of [...this.#sessionNameChangedCallbacks]) {
|
|
635
|
+
try {
|
|
636
|
+
callback();
|
|
637
|
+
} catch (err) {
|
|
638
|
+
logger.warn("SessionManager: session name change hook failed", { error: String(err) });
|
|
1865
639
|
}
|
|
1866
640
|
}
|
|
1867
|
-
|
|
1868
|
-
firstMessage ||= extractFirstUserMessageFromPrefix(content) ?? "";
|
|
1869
|
-
const messageCount = Math.max(parsedMessageCount, countMessageMarkers(content));
|
|
1870
|
-
return {
|
|
1871
|
-
path: file,
|
|
1872
|
-
id: header.id,
|
|
1873
|
-
cwd: header.cwd ?? "",
|
|
1874
|
-
title: header.title ?? shortSummary,
|
|
1875
|
-
parentSessionPath: header.parentSession,
|
|
1876
|
-
created: new Date(header.timestamp ?? ""),
|
|
1877
|
-
modified: mtime,
|
|
1878
|
-
messageCount,
|
|
1879
|
-
size,
|
|
1880
|
-
firstMessage: firstMessage || "(no messages)",
|
|
1881
|
-
allMessagesText: allMessages.length > 0 ? allMessages.join(" ") : firstMessage,
|
|
1882
|
-
status: deriveSessionStatus(suffix),
|
|
1883
|
-
};
|
|
1884
|
-
} catch {
|
|
1885
|
-
return undefined;
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
async function collectSessionsFromFileStride(
|
|
1890
|
-
files: string[],
|
|
1891
|
-
storage: SessionStorage,
|
|
1892
|
-
startIndex: number,
|
|
1893
|
-
stride: number,
|
|
1894
|
-
): Promise<SessionInfo[]> {
|
|
1895
|
-
const sessions: SessionInfo[] = [];
|
|
1896
|
-
|
|
1897
|
-
for (let i = startIndex; i < files.length; i += stride) {
|
|
1898
|
-
const session = await collectSessionFromFile(files[i], storage);
|
|
1899
|
-
if (session) sessions.push(session);
|
|
1900
|
-
}
|
|
1901
|
-
|
|
1902
|
-
return sessions;
|
|
1903
|
-
}
|
|
1904
|
-
|
|
1905
|
-
async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
|
|
1906
|
-
const workerCount = getSessionListWorkerCount(files.length);
|
|
1907
|
-
const sessions =
|
|
1908
|
-
workerCount === 1
|
|
1909
|
-
? await collectSessionsFromFileStride(files, storage, 0, 1)
|
|
1910
|
-
: (
|
|
1911
|
-
await Promise.all(
|
|
1912
|
-
Array.from({ length: workerCount }, (_, workerIndex) =>
|
|
1913
|
-
collectSessionsFromFileStride(files, storage, workerIndex, workerCount),
|
|
1914
|
-
),
|
|
1915
|
-
)
|
|
1916
|
-
).flat();
|
|
1917
|
-
|
|
1918
|
-
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1919
|
-
return sessions;
|
|
1920
|
-
}
|
|
1921
|
-
|
|
1922
|
-
export interface ResolvedSessionMatch {
|
|
1923
|
-
session: SessionInfo;
|
|
1924
|
-
scope: "local" | "global";
|
|
1925
|
-
}
|
|
1926
|
-
|
|
1927
|
-
function sessionMatchesResumeArg(session: SessionInfo, sessionArg: string): boolean {
|
|
1928
|
-
const normalizedArg = sessionArg.toLowerCase();
|
|
1929
|
-
const normalizedId = session.id.toLowerCase();
|
|
1930
|
-
if (normalizedId.startsWith(normalizedArg)) {
|
|
1931
|
-
return true;
|
|
1932
|
-
}
|
|
1933
|
-
|
|
1934
|
-
const fileName = path.basename(session.path, ".jsonl").toLowerCase();
|
|
1935
|
-
if (fileName.startsWith(normalizedArg)) {
|
|
1936
|
-
return true;
|
|
1937
|
-
}
|
|
1938
|
-
|
|
1939
|
-
const separator = fileName.lastIndexOf("_");
|
|
1940
|
-
if (separator < 0) {
|
|
1941
|
-
return false;
|
|
1942
|
-
}
|
|
1943
|
-
|
|
1944
|
-
const fileSessionId = fileName.slice(separator + 1);
|
|
1945
|
-
return fileSessionId.startsWith(normalizedArg);
|
|
1946
|
-
}
|
|
1947
|
-
|
|
1948
|
-
export async function resolveResumableSession(
|
|
1949
|
-
sessionArg: string,
|
|
1950
|
-
cwd: string,
|
|
1951
|
-
sessionDir?: string,
|
|
1952
|
-
storage: SessionStorage = new FileSessionStorage(),
|
|
1953
|
-
): Promise<ResolvedSessionMatch | undefined> {
|
|
1954
|
-
const localSessionDir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
1955
|
-
const localSessions = await SessionManager.list(cwd, localSessionDir, storage);
|
|
1956
|
-
const localMatch = localSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
1957
|
-
if (localMatch) {
|
|
1958
|
-
return { session: localMatch, scope: "local" };
|
|
1959
|
-
}
|
|
1960
|
-
|
|
1961
|
-
if (sessionDir) {
|
|
1962
|
-
return undefined;
|
|
1963
|
-
}
|
|
1964
|
-
|
|
1965
|
-
const globalSessions = await SessionManager.listAll(storage);
|
|
1966
|
-
const globalMatch = globalSessions.find(session => sessionMatchesResumeArg(session, sessionArg));
|
|
1967
|
-
if (!globalMatch) {
|
|
1968
|
-
return undefined;
|
|
1969
|
-
}
|
|
1970
|
-
|
|
1971
|
-
return { session: globalMatch, scope: "global" };
|
|
1972
|
-
}
|
|
1973
|
-
interface SessionManagerStateSnapshot {
|
|
1974
|
-
cwd: string;
|
|
1975
|
-
sessionDir: string;
|
|
1976
|
-
sessionId: string;
|
|
1977
|
-
sessionName: string | undefined;
|
|
1978
|
-
titleSource: "auto" | "user" | undefined;
|
|
1979
|
-
sessionFile: string | undefined;
|
|
1980
|
-
flushed: boolean;
|
|
1981
|
-
needsFullRewriteOnNextPersist: boolean;
|
|
1982
|
-
fileEntries: FileEntry[];
|
|
1983
|
-
}
|
|
1984
|
-
|
|
1985
|
-
export class SessionManager {
|
|
1986
|
-
#sessionId: string = "";
|
|
1987
|
-
#sessionName: string | undefined;
|
|
1988
|
-
#titleSource: "auto" | "user" | undefined;
|
|
1989
|
-
#sessionFile: string | undefined;
|
|
1990
|
-
#flushed: boolean = false;
|
|
1991
|
-
#needsFullRewriteOnNextPersist: boolean = false;
|
|
1992
|
-
#ensuredOnDisk: boolean = false;
|
|
1993
|
-
#fileEntries: FileEntry[] = [];
|
|
1994
|
-
#byId: Map<string, SessionEntry> = new Map();
|
|
1995
|
-
#labelsById: Map<string, string> = new Map();
|
|
1996
|
-
#leafId: string | null = null;
|
|
1997
|
-
/**
|
|
1998
|
-
* Collab replication tap: invoked for every appended entry with the
|
|
1999
|
-
* in-memory (pre-blob-externalization) entry, so inline images survive.
|
|
2000
|
-
* Failures are swallowed — a broadcast error must never break persistence.
|
|
2001
|
-
*/
|
|
2002
|
-
onEntryAppended?: (entry: SessionEntry) => void;
|
|
2003
|
-
#usageStatistics = {
|
|
2004
|
-
input: 0,
|
|
2005
|
-
output: 0,
|
|
2006
|
-
cacheRead: 0,
|
|
2007
|
-
cacheWrite: 0,
|
|
2008
|
-
premiumRequests: 0,
|
|
2009
|
-
cost: 0,
|
|
2010
|
-
} satisfies UsageStatistics;
|
|
2011
|
-
/** Per-turn output-token budget set by a `+Nk` directive (total null when none this turn). */
|
|
2012
|
-
#turnBudget: { total: number | null; hard: boolean } = { total: null, hard: false };
|
|
2013
|
-
/** Cumulative `output` snapshot captured when the current turn budget window opened. */
|
|
2014
|
-
#turnBaselineOutput = 0;
|
|
2015
|
-
/** Output tokens consumed by eval-spawned subagents in the current turn window. */
|
|
2016
|
-
#turnEvalOutput = 0;
|
|
2017
|
-
#persistWriter: NdjsonFileWriter | undefined;
|
|
2018
|
-
#persistWriterPath: string | undefined;
|
|
2019
|
-
#persistChain: Promise<void> = Promise.resolve();
|
|
2020
|
-
#persistError: Error | undefined;
|
|
2021
|
-
#persistErrorReported = false;
|
|
2022
|
-
#artifactManager: ArtifactManager | null = null;
|
|
2023
|
-
#artifactManagerSessionFile: string | null = null;
|
|
2024
|
-
// When set, take precedence over the lazily-derived per-session manager.
|
|
2025
|
-
// Subagents adopt the parent's manager so artifact IDs are unique across the
|
|
2026
|
-
// whole agent tree and all files land in the parent's artifacts dir.
|
|
2027
|
-
#adoptedArtifactManager: ArtifactManager | null = null;
|
|
2028
|
-
// In-memory artifact fallback for non-persistent sessions (persist=false).
|
|
2029
|
-
// Keyed by sequential numeric ID string; mirrors the file-based ArtifactManager ID scheme.
|
|
2030
|
-
#inMemoryArtifacts: Map<string, string> | null = null;
|
|
2031
|
-
#inMemoryArtifactCounter = 0;
|
|
2032
|
-
readonly #blobStore: BlobStore;
|
|
2033
|
-
#suppressBreadcrumb = false;
|
|
2034
|
-
#sessionNameChangedCallbacks = new Set<() => void>();
|
|
2035
|
-
|
|
2036
|
-
private constructor(
|
|
2037
|
-
private cwd: string,
|
|
2038
|
-
private sessionDir: string,
|
|
2039
|
-
private readonly persist: boolean,
|
|
2040
|
-
private readonly storage: SessionStorage,
|
|
2041
|
-
) {
|
|
2042
|
-
this.#blobStore = new BlobStore(getBlobsDir());
|
|
2043
|
-
if (persist && sessionDir) {
|
|
2044
|
-
this.storage.ensureDirSync(sessionDir);
|
|
2045
|
-
}
|
|
2046
|
-
// Note: call _initSession() or _initSessionFile() after construction
|
|
2047
641
|
}
|
|
2048
642
|
|
|
2049
|
-
#
|
|
2050
|
-
|
|
2051
|
-
|
|
643
|
+
static #cleanTitle(raw: string): string {
|
|
644
|
+
return raw
|
|
645
|
+
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
646
|
+
.replace(/ +/g, " ")
|
|
647
|
+
.trim();
|
|
2052
648
|
}
|
|
2053
649
|
|
|
2054
|
-
/** Puts a binary blob into the blob store and returns the blob reference */
|
|
650
|
+
/** Puts a binary blob into the blob store and returns the blob reference. */
|
|
2055
651
|
async putBlob(data: Buffer, options?: BlobPutOptions): Promise<BlobPutResult> {
|
|
2056
|
-
return this.#
|
|
652
|
+
return this.#blobs.put(data, options);
|
|
2057
653
|
}
|
|
2058
654
|
|
|
2059
655
|
/** Synchronous variant of {@link putBlob} for rebuild-only render paths. */
|
|
2060
656
|
putBlobSync(data: Buffer, options?: BlobPutOptions): BlobPutResult {
|
|
2061
|
-
return this.#
|
|
657
|
+
return this.#blobs.putSync(data, options);
|
|
2062
658
|
}
|
|
2063
659
|
|
|
2064
660
|
captureState(): SessionManagerStateSnapshot {
|
|
2065
661
|
return {
|
|
2066
|
-
cwd: this
|
|
2067
|
-
sessionDir: this
|
|
662
|
+
cwd: this.#cwd,
|
|
663
|
+
sessionDir: this.#sessionDir,
|
|
2068
664
|
sessionId: this.#sessionId,
|
|
2069
665
|
sessionName: this.#sessionName,
|
|
2070
666
|
titleSource: this.#titleSource,
|
|
2071
667
|
sessionFile: this.#sessionFile,
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
// Snapshot
|
|
2075
|
-
//
|
|
2076
|
-
|
|
668
|
+
onDisk: this.#fileIsCurrent,
|
|
669
|
+
needsRewrite: this.#rewriteRequired,
|
|
670
|
+
// Snapshot header + entries by reference: switch/reload replaces the
|
|
671
|
+
// active header/array wholesale, so rollback needs no deep clone.
|
|
672
|
+
header: this.#header,
|
|
673
|
+
entries: [...this.#entries],
|
|
2077
674
|
};
|
|
2078
675
|
}
|
|
2079
676
|
|
|
2080
677
|
restoreState(snapshot: SessionManagerStateSnapshot): void {
|
|
2081
|
-
this
|
|
2082
|
-
this
|
|
2083
|
-
this.#
|
|
678
|
+
this.#closeWriterEventually();
|
|
679
|
+
this.#diskTail = Promise.resolve();
|
|
680
|
+
this.#clearDiskError();
|
|
681
|
+
|
|
682
|
+
this.#cwd = snapshot.cwd;
|
|
683
|
+
this.#sessionDir = snapshot.sessionDir;
|
|
684
|
+
this.#sessionFile = snapshot.sessionFile;
|
|
685
|
+
this.#fileIsCurrent = snapshot.onDisk;
|
|
686
|
+
this.#rewriteRequired = snapshot.needsRewrite;
|
|
687
|
+
this.#forceFileCreation = snapshot.onDisk;
|
|
688
|
+
this.#applyEntries(snapshot.header, [...snapshot.entries]);
|
|
2084
689
|
this.#sessionName = snapshot.sessionName;
|
|
2085
690
|
this.#titleSource = snapshot.titleSource;
|
|
2086
|
-
this.#sessionFile = snapshot.sessionFile;
|
|
2087
|
-
this.#flushed = snapshot.flushed;
|
|
2088
|
-
this.#needsFullRewriteOnNextPersist = snapshot.needsFullRewriteOnNextPersist;
|
|
2089
|
-
this.#fileEntries = [...snapshot.fileEntries];
|
|
2090
|
-
this.#persistWriter = undefined;
|
|
2091
|
-
this.#persistWriterPath = undefined;
|
|
2092
|
-
this.#persistChain = Promise.resolve();
|
|
2093
|
-
this.#persistError = undefined;
|
|
2094
|
-
this.#persistErrorReported = false;
|
|
2095
691
|
this.#artifactManager = null;
|
|
2096
692
|
this.#artifactManagerSessionFile = null;
|
|
2097
693
|
this.#adoptedArtifactManager = null;
|
|
2098
|
-
this.#buildIndex();
|
|
2099
|
-
if (this.#sessionFile) {
|
|
2100
|
-
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2103
694
|
|
|
2104
|
-
|
|
2105
|
-
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
2106
|
-
await this.setSessionFile(sessionFile);
|
|
695
|
+
if (this.#sessionFile) this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
2107
696
|
}
|
|
2108
697
|
|
|
2109
|
-
/**
|
|
2110
|
-
#initNewSession(): void {
|
|
2111
|
-
this.#newSessionSync();
|
|
2112
|
-
}
|
|
2113
|
-
|
|
2114
|
-
/** Switch to a different session file (used for resume and branching) */
|
|
698
|
+
/** Switch to a different session file (resume / branch). */
|
|
2115
699
|
async setSessionFile(sessionFile: string): Promise<void> {
|
|
2116
|
-
await this.#
|
|
2117
|
-
this.#
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
this.#
|
|
2121
|
-
this.#
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
const headerCwd = header?.cwd ? path.resolve(header.cwd) : undefined;
|
|
2134
|
-
if (headerCwd && headerCwd !== this.cwd) {
|
|
2135
|
-
this.cwd = headerCwd;
|
|
2136
|
-
this.sessionDir = path.resolve(this.#sessionFile, "..");
|
|
2137
|
-
this.#maybeWriteBreadcrumb(this.cwd, this.#sessionFile);
|
|
2138
|
-
}
|
|
2139
|
-
|
|
2140
|
-
this.#needsFullRewriteOnNextPersist = migrateToCurrentVersion(this.#fileEntries);
|
|
700
|
+
await this.#drainAndCloseWriter();
|
|
701
|
+
this.#clearDiskError();
|
|
702
|
+
|
|
703
|
+
const resolvedSessionFile = path.resolve(sessionFile);
|
|
704
|
+
this.#sessionFile = resolvedSessionFile;
|
|
705
|
+
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
706
|
+
|
|
707
|
+
const fileEntries = await loadEntriesFromFile(resolvedSessionFile, this.#storage);
|
|
708
|
+
if (fileEntries.length === 0) {
|
|
709
|
+
// Explicit but empty/missing path (e.g. --session flag): start fresh but
|
|
710
|
+
// keep the requested path and materialize the header immediately.
|
|
711
|
+
this.#resetToNewSession(undefined, resolvedSessionFile);
|
|
712
|
+
this.#forceFileCreation = true;
|
|
713
|
+
await this.#rewriteAtomically();
|
|
714
|
+
this.#fileIsCurrent = true;
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
2141
717
|
|
|
2142
|
-
|
|
2143
|
-
|
|
718
|
+
const migrated = migrateToCurrentVersion(fileEntries);
|
|
719
|
+
await resolveBlobRefsInEntries(fileEntries, this.#blobs);
|
|
720
|
+
// loadEntriesFromFile guarantees entries[0] is a valid session header.
|
|
721
|
+
const header = fileEntries[0] as SessionHeader;
|
|
2144
722
|
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
this.#
|
|
2151
|
-
this.#
|
|
2152
|
-
|
|
2153
|
-
this.#flushed = true;
|
|
2154
|
-
this.#ensuredOnDisk = true;
|
|
2155
|
-
return;
|
|
723
|
+
// Adopt the loaded session's working directory. Sessions live in a dir
|
|
724
|
+
// keyed by their cwd, so resuming a session from another project must
|
|
725
|
+
// re-point cwd/sessionDir at that project.
|
|
726
|
+
const headerCwd = header.cwd ? path.resolve(header.cwd) : undefined;
|
|
727
|
+
if (headerCwd && headerCwd !== path.resolve(this.#cwd)) {
|
|
728
|
+
this.#cwd = headerCwd;
|
|
729
|
+
this.#sessionDir = path.dirname(resolvedSessionFile);
|
|
730
|
+
this.#rememberBreadcrumb(this.#cwd, resolvedSessionFile);
|
|
2156
731
|
}
|
|
732
|
+
|
|
733
|
+
this.#applyEntries(header, fileEntries.slice(1) as SessionEntry[]);
|
|
734
|
+
this.#fileIsCurrent = true;
|
|
735
|
+
this.#rewriteRequired = migrated;
|
|
736
|
+
this.#forceFileCreation = true;
|
|
737
|
+
this.#artifactManager = null;
|
|
738
|
+
this.#artifactManagerSessionFile = null;
|
|
739
|
+
|
|
740
|
+
if (this.sanitizeLoadedOpenAIResponsesReplayMetadata()) this.#rewriteRequired = true;
|
|
2157
741
|
}
|
|
2158
742
|
|
|
2159
|
-
/** Start a new session.
|
|
743
|
+
/** Start a new session. Drains and closes any existing writer first. */
|
|
2160
744
|
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
2161
|
-
await this.#
|
|
2162
|
-
return this.#
|
|
745
|
+
await this.#drainAndCloseWriter();
|
|
746
|
+
return this.#resetToNewSession(options);
|
|
2163
747
|
}
|
|
2164
748
|
|
|
2165
|
-
/** Delete a session file and its
|
|
749
|
+
/** Delete a session file and its artifact directory. ENOENT is treated as success. */
|
|
2166
750
|
async dropSession(sessionPath: string): Promise<void> {
|
|
2167
|
-
await this.#
|
|
751
|
+
await this.#drainAndCloseWriter();
|
|
2168
752
|
try {
|
|
2169
|
-
await this
|
|
753
|
+
await this.#storage.deleteSessionWithArtifacts(sessionPath);
|
|
2170
754
|
} catch (err) {
|
|
2171
|
-
if (isEnoent(err))
|
|
2172
|
-
throw err;
|
|
755
|
+
if (!isEnoent(err)) throw err;
|
|
2173
756
|
}
|
|
2174
757
|
}
|
|
2175
758
|
|
|
2176
759
|
/**
|
|
2177
|
-
* Fork the current session
|
|
2178
|
-
*
|
|
2179
|
-
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
|
|
760
|
+
* Fork the current session into a new file with the same entries.
|
|
761
|
+
* @returns the old and new session file paths, or undefined when not persisting.
|
|
2180
762
|
*/
|
|
2181
763
|
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
|
|
2182
|
-
if (!this
|
|
2183
|
-
return undefined;
|
|
2184
|
-
}
|
|
764
|
+
if (!this.#persist || !this.#sessionFile) return undefined;
|
|
2185
765
|
|
|
2186
766
|
const oldSessionFile = this.#sessionFile;
|
|
2187
|
-
const
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
this.#
|
|
2193
|
-
this.#
|
|
2194
|
-
|
|
2195
|
-
// Create new session ID and header
|
|
2196
|
-
this.#sessionId = createSessionId();
|
|
2197
|
-
const timestamp = new Date().toISOString();
|
|
2198
|
-
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
2199
|
-
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
2200
|
-
|
|
2201
|
-
// Update the header with new ID but keep all entries
|
|
2202
|
-
const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2203
|
-
const newHeader: SessionHeader = {
|
|
767
|
+
const parentSessionId = this.#sessionId;
|
|
768
|
+
await this.#drainAndCloseWriter();
|
|
769
|
+
this.#clearDiskError();
|
|
770
|
+
|
|
771
|
+
const timestamp = nowIso();
|
|
772
|
+
this.#sessionId = mintSessionId();
|
|
773
|
+
this.#sessionFile = path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${this.#sessionId}.jsonl`);
|
|
774
|
+
this.#header = {
|
|
2204
775
|
type: "session",
|
|
2205
776
|
version: CURRENT_SESSION_VERSION,
|
|
2206
777
|
id: this.#sessionId,
|
|
2207
|
-
title:
|
|
2208
|
-
titleSource:
|
|
778
|
+
title: this.#header.title ?? this.#sessionName,
|
|
779
|
+
titleSource: this.#header.titleSource ?? this.#titleSource,
|
|
2209
780
|
timestamp,
|
|
2210
|
-
cwd: this
|
|
2211
|
-
parentSession:
|
|
781
|
+
cwd: this.#cwd,
|
|
782
|
+
parentSession: parentSessionId,
|
|
2212
783
|
};
|
|
2213
|
-
this.#sessionName =
|
|
2214
|
-
this.#titleSource =
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
this.#
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
this.#flushed = false;
|
|
2222
|
-
await this.#rewriteFile();
|
|
784
|
+
this.#sessionName = this.#header.title;
|
|
785
|
+
this.#titleSource = this.#header.titleSource;
|
|
786
|
+
this.#fileIsCurrent = false;
|
|
787
|
+
this.#rewriteRequired = false;
|
|
788
|
+
this.#forceFileCreation = true;
|
|
789
|
+
this.#artifactManager = null;
|
|
790
|
+
this.#artifactManagerSessionFile = null;
|
|
791
|
+
this.#rememberBreadcrumb(this.#cwd, this.#sessionFile);
|
|
2223
792
|
|
|
793
|
+
await this.#rewriteAtomically();
|
|
2224
794
|
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
2225
795
|
}
|
|
2226
796
|
|
|
2227
797
|
/**
|
|
2228
|
-
* Move the session to a new working directory
|
|
2229
|
-
*
|
|
2230
|
-
* and rewrites the session header with the new cwd. When provided,
|
|
2231
|
-
* `targetSessionDir` is used instead of deriving the default directory for
|
|
2232
|
-
* the new cwd (for `--continue --session-dir` / `--resume --session-dir`).
|
|
798
|
+
* Move the session to a new working directory: relocate the session file and
|
|
799
|
+
* artifacts on disk, update internal references, and rewrite the header cwd.
|
|
2233
800
|
*/
|
|
2234
801
|
async moveTo(newCwd: string, targetSessionDir?: string): Promise<void> {
|
|
2235
802
|
const resolvedCwd = path.resolve(newCwd);
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
? computeDefaultSessionDir(resolvedCwd, this.storage, managedSessionsRoot)
|
|
2243
|
-
: computeDefaultSessionDir(resolvedCwd, this.storage);
|
|
2244
|
-
let hadSessionFile = false;
|
|
2245
|
-
|
|
2246
|
-
if (this.persist && this.#sessionFile) {
|
|
2247
|
-
this.storage.ensureDirSync(newSessionDir);
|
|
2248
|
-
// Close the persist writer before moving files
|
|
2249
|
-
await this.#closePersistWriter();
|
|
2250
|
-
this.#persistChain = Promise.resolve();
|
|
2251
|
-
this.#persistError = undefined;
|
|
2252
|
-
this.#persistErrorReported = false;
|
|
2253
|
-
|
|
2254
|
-
const oldSessionFile = this.#sessionFile;
|
|
2255
|
-
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
2256
|
-
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
2257
|
-
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
2258
|
-
const sameSessionFile = path.resolve(oldSessionFile) === path.resolve(newSessionFile);
|
|
2259
|
-
const sameArtifactDir = path.resolve(oldArtifactDir) === path.resolve(newArtifactDir);
|
|
2260
|
-
hadSessionFile = this.storage.existsSync(oldSessionFile);
|
|
2261
|
-
let movedSessionFile = false;
|
|
2262
|
-
let movedArtifactDir = false;
|
|
2263
|
-
|
|
2264
|
-
try {
|
|
2265
|
-
// Guard: session file may not exist yet (no assistant messages persisted)
|
|
2266
|
-
if (hadSessionFile && !sameSessionFile) {
|
|
2267
|
-
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
2268
|
-
movedSessionFile = true;
|
|
2269
|
-
}
|
|
2270
|
-
|
|
2271
|
-
if (!sameArtifactDir) {
|
|
2272
|
-
try {
|
|
2273
|
-
const stat = await fs.promises.stat(oldArtifactDir);
|
|
2274
|
-
if (stat.isDirectory()) {
|
|
2275
|
-
await fs.promises.rename(oldArtifactDir, newArtifactDir);
|
|
2276
|
-
movedArtifactDir = true;
|
|
2277
|
-
}
|
|
2278
|
-
} catch (err) {
|
|
2279
|
-
if (!isEnoent(err)) throw err;
|
|
2280
|
-
}
|
|
2281
|
-
}
|
|
2282
|
-
} catch (err) {
|
|
2283
|
-
if (movedArtifactDir) {
|
|
2284
|
-
try {
|
|
2285
|
-
await fs.promises.rename(newArtifactDir, oldArtifactDir);
|
|
2286
|
-
} catch (rollbackErr) {
|
|
2287
|
-
throw new Error(
|
|
2288
|
-
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
2289
|
-
);
|
|
2290
|
-
}
|
|
2291
|
-
}
|
|
2292
|
-
if (movedSessionFile) {
|
|
2293
|
-
try {
|
|
2294
|
-
await fs.promises.rename(newSessionFile, oldSessionFile);
|
|
2295
|
-
} catch (rollbackErr) {
|
|
2296
|
-
throw new Error(
|
|
2297
|
-
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
2298
|
-
);
|
|
2299
|
-
}
|
|
2300
|
-
}
|
|
2301
|
-
throw err;
|
|
2302
|
-
}
|
|
2303
|
-
this.#sessionFile = newSessionFile;
|
|
2304
|
-
}
|
|
2305
|
-
|
|
2306
|
-
// Update cwd and sessionDir after the move succeeds.
|
|
2307
|
-
this.cwd = resolvedCwd;
|
|
2308
|
-
this.sessionDir = newSessionDir;
|
|
2309
|
-
|
|
2310
|
-
// Update the session header in fileEntries
|
|
2311
|
-
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2312
|
-
if (header) {
|
|
2313
|
-
header.cwd = resolvedCwd;
|
|
803
|
+
const resolvedTargetDir = targetSessionDir ? path.resolve(targetSessionDir) : undefined;
|
|
804
|
+
if (
|
|
805
|
+
resolvedCwd === path.resolve(this.#cwd) &&
|
|
806
|
+
(!resolvedTargetDir || resolvedTargetDir === path.resolve(this.#sessionDir))
|
|
807
|
+
) {
|
|
808
|
+
return;
|
|
2314
809
|
}
|
|
2315
810
|
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
await this.#rewriteFile();
|
|
2323
|
-
}
|
|
811
|
+
const managedRoot = resolveManagedSessionRoot(this.#sessionDir, this.#cwd);
|
|
812
|
+
const nextSessionDir =
|
|
813
|
+
resolvedTargetDir ??
|
|
814
|
+
(managedRoot
|
|
815
|
+
? computeDefaultSessionDir(resolvedCwd, this.#storage, managedRoot)
|
|
816
|
+
: computeDefaultSessionDir(resolvedCwd, this.#storage));
|
|
2324
817
|
|
|
2325
|
-
|
|
2326
|
-
if (this.#sessionFile) {
|
|
2327
|
-
this.#maybeWriteBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
2328
|
-
}
|
|
2329
|
-
}
|
|
818
|
+
let sessionFileExisted = false;
|
|
2330
819
|
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
2334
|
-
|
|
2335
|
-
this.#persistErrorReported = false;
|
|
2336
|
-
this.#sessionId = createSessionId();
|
|
2337
|
-
this.#sessionName = undefined;
|
|
2338
|
-
this.#titleSource = undefined;
|
|
2339
|
-
const timestamp = new Date().toISOString();
|
|
2340
|
-
const header: SessionHeader = {
|
|
2341
|
-
type: "session",
|
|
2342
|
-
version: CURRENT_SESSION_VERSION,
|
|
2343
|
-
id: this.#sessionId,
|
|
2344
|
-
timestamp,
|
|
2345
|
-
cwd: this.cwd,
|
|
2346
|
-
parentSession: options?.parentSession,
|
|
2347
|
-
};
|
|
2348
|
-
this.#fileEntries = [header];
|
|
2349
|
-
this.#byId.clear();
|
|
2350
|
-
this.#labelsById.clear();
|
|
2351
|
-
this.#leafId = null;
|
|
2352
|
-
this.#flushed = false;
|
|
2353
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
2354
|
-
this.#ensuredOnDisk = false;
|
|
2355
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
2356
|
-
this.#inMemoryArtifacts = null;
|
|
2357
|
-
this.#inMemoryArtifactCounter = 0;
|
|
820
|
+
if (this.#persist && this.#sessionFile) {
|
|
821
|
+
this.#storage.ensureDirSync(nextSessionDir);
|
|
822
|
+
await this.#drainAndCloseWriter();
|
|
823
|
+
this.#clearDiskError();
|
|
2358
824
|
|
|
2359
|
-
|
|
2360
|
-
const
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
825
|
+
const oldSessionFile = this.#sessionFile;
|
|
826
|
+
const newSessionFile = path.join(nextSessionDir, path.basename(oldSessionFile));
|
|
827
|
+
const oldArtifactsDir = artifactsDirectoryFor(oldSessionFile)!;
|
|
828
|
+
const newArtifactsDir = artifactsDirectoryFor(newSessionFile)!;
|
|
829
|
+
const sessionPathChanged = path.resolve(oldSessionFile) !== path.resolve(newSessionFile);
|
|
830
|
+
const artifactPathChanged = path.resolve(oldArtifactsDir) !== path.resolve(newArtifactsDir);
|
|
831
|
+
sessionFileExisted = this.#storage.existsSync(oldSessionFile);
|
|
2366
832
|
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
this.#labelsById.clear();
|
|
2370
|
-
this.#leafId = null;
|
|
2371
|
-
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, premiumRequests: 0, cost: 0 };
|
|
2372
|
-
for (const entry of this.#fileEntries) {
|
|
2373
|
-
if (entry.type === "session") continue;
|
|
2374
|
-
this.#byId.set(entry.id, entry);
|
|
2375
|
-
this.#leafId = entry.id;
|
|
2376
|
-
if (entry.type === "label") {
|
|
2377
|
-
if (entry.label) {
|
|
2378
|
-
this.#labelsById.set(entry.targetId, entry.label);
|
|
2379
|
-
} else {
|
|
2380
|
-
this.#labelsById.delete(entry.targetId);
|
|
2381
|
-
}
|
|
2382
|
-
}
|
|
2383
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
2384
|
-
const usage = entry.message.usage;
|
|
2385
|
-
this.#usageStatistics.input += usage.input;
|
|
2386
|
-
this.#usageStatistics.output += usage.output;
|
|
2387
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2388
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2389
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2390
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2391
|
-
}
|
|
833
|
+
let sessionMoved = false;
|
|
834
|
+
let artifactsMoved = false;
|
|
2392
835
|
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
this.#usageStatistics.output += usage.output;
|
|
2398
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2399
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2400
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2401
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
836
|
+
try {
|
|
837
|
+
if (sessionFileExisted && sessionPathChanged) {
|
|
838
|
+
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
839
|
+
sessionMoved = true;
|
|
2402
840
|
}
|
|
2403
|
-
}
|
|
2404
|
-
}
|
|
2405
|
-
}
|
|
2406
841
|
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2417
|
-
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2425
|
-
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
});
|
|
2429
|
-
return next;
|
|
2430
|
-
}
|
|
2431
|
-
|
|
2432
|
-
#ensurePersistWriter(): NdjsonFileWriter | undefined {
|
|
2433
|
-
if (!this.persist || !this.#sessionFile) return undefined;
|
|
2434
|
-
if (this.#persistError) throw this.#persistError;
|
|
2435
|
-
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) {
|
|
2436
|
-
if (this.#persistWriter.isOpen()) return this.#persistWriter;
|
|
2437
|
-
// Cached writer for the current file is mid-close (queued
|
|
2438
|
-
// `#closePersistWriterInternal` has flipped `#closing` but not yet
|
|
2439
|
-
// cleared `#persistWriter`). Returning it would make `writeSync`
|
|
2440
|
-
// throw "Writer closed". Defer to the caller — `_persist` routes
|
|
2441
|
-
// the entry through the async rewrite path so it still lands on disk.
|
|
2442
|
-
return undefined;
|
|
2443
|
-
}
|
|
2444
|
-
// Note: caller must await _closePersistWriter() before calling this if switching files
|
|
2445
|
-
this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
|
|
2446
|
-
onError: err => {
|
|
2447
|
-
this.#recordPersistError(err);
|
|
2448
|
-
},
|
|
2449
|
-
});
|
|
2450
|
-
this.#persistWriterPath = this.#sessionFile;
|
|
2451
|
-
return this.#persistWriter;
|
|
2452
|
-
}
|
|
2453
|
-
|
|
2454
|
-
async #closePersistWriterInternal(): Promise<void> {
|
|
2455
|
-
if (this.#persistWriter) {
|
|
2456
|
-
await this.#persistWriter.close();
|
|
2457
|
-
this.#persistWriter = undefined;
|
|
2458
|
-
}
|
|
2459
|
-
this.#persistWriterPath = undefined;
|
|
2460
|
-
}
|
|
842
|
+
if (artifactPathChanged) {
|
|
843
|
+
try {
|
|
844
|
+
const artifactStat = await fs.promises.stat(oldArtifactsDir);
|
|
845
|
+
if (artifactStat.isDirectory()) {
|
|
846
|
+
await fs.promises.rename(oldArtifactsDir, newArtifactsDir);
|
|
847
|
+
artifactsMoved = true;
|
|
848
|
+
}
|
|
849
|
+
} catch (err) {
|
|
850
|
+
if (!isEnoent(err)) throw err;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
} catch (err) {
|
|
854
|
+
if (artifactsMoved) {
|
|
855
|
+
try {
|
|
856
|
+
await fs.promises.rename(newArtifactsDir, oldArtifactsDir);
|
|
857
|
+
} catch (rollbackErr) {
|
|
858
|
+
throw new Error(
|
|
859
|
+
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
860
|
+
);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
2461
863
|
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
// Move the old session file aside first so a failed retry can roll back to the last good file.
|
|
2472
|
-
// The backup uses a plain `<basename>.<snowflake>.bak` name (no leading dot) so that if the
|
|
2473
|
-
// process crashes between the two renames, `recoverOrphanedBackups` can find it via the
|
|
2474
|
-
// shared `*.bak` glob on both real and in-memory storage backends and promote it back to
|
|
2475
|
-
// the primary on the next session-dir scan.
|
|
864
|
+
if (sessionMoved) {
|
|
865
|
+
try {
|
|
866
|
+
await fs.promises.rename(newSessionFile, oldSessionFile);
|
|
867
|
+
} catch (rollbackErr) {
|
|
868
|
+
throw new Error(
|
|
869
|
+
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
870
|
+
);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
2476
873
|
|
|
2477
|
-
|
|
2478
|
-
const dir = path.resolve(targetPath, "..");
|
|
2479
|
-
const backupPath = path.join(dir, `${path.basename(targetPath)}.${Snowflake.next()}.bak`);
|
|
2480
|
-
try {
|
|
2481
|
-
await this.storage.rename(targetPath, backupPath);
|
|
2482
|
-
} catch (err) {
|
|
2483
|
-
if (isEnoent(err)) {
|
|
2484
|
-
await this.storage.rename(tempPath, targetPath);
|
|
2485
|
-
return;
|
|
874
|
+
throw err;
|
|
2486
875
|
}
|
|
2487
|
-
throw toError(renameError);
|
|
2488
|
-
}
|
|
2489
876
|
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
const replaceError = toError(err);
|
|
2494
|
-
const originalError = toError(renameError);
|
|
2495
|
-
try {
|
|
2496
|
-
await this.storage.rename(backupPath, targetPath);
|
|
2497
|
-
} catch (rollbackErr) {
|
|
2498
|
-
const rollbackError = toError(rollbackErr);
|
|
2499
|
-
throw new Error(
|
|
2500
|
-
`Failed to replace session file after EPERM (original: ${originalError.message}; retry: ${replaceError.message}); rollback from ${backupPath} also failed: ${rollbackError.message}`,
|
|
2501
|
-
{ cause: originalError },
|
|
2502
|
-
);
|
|
2503
|
-
}
|
|
2504
|
-
throw replaceError;
|
|
877
|
+
this.#sessionFile = newSessionFile;
|
|
878
|
+
this.#artifactManager = null;
|
|
879
|
+
this.#artifactManagerSessionFile = null;
|
|
2505
880
|
}
|
|
2506
881
|
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
if (!isEnoent(err)) {
|
|
2511
|
-
logger.warn("Failed to remove session rewrite backup", {
|
|
2512
|
-
sessionFile: targetPath,
|
|
2513
|
-
backupPath,
|
|
2514
|
-
error: toError(err).message,
|
|
2515
|
-
});
|
|
2516
|
-
}
|
|
2517
|
-
}
|
|
2518
|
-
}
|
|
882
|
+
this.#cwd = resolvedCwd;
|
|
883
|
+
this.#sessionDir = nextSessionDir;
|
|
884
|
+
this.#header.cwd = resolvedCwd;
|
|
2519
885
|
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
await this.#
|
|
2526
|
-
}
|
|
2527
|
-
}
|
|
2528
|
-
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
2529
|
-
if (!this.#sessionFile) return;
|
|
2530
|
-
const dir = path.resolve(this.#sessionFile, "..");
|
|
2531
|
-
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
2532
|
-
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
2533
|
-
try {
|
|
2534
|
-
for (const entry of entries) {
|
|
2535
|
-
await writer.write(entry);
|
|
2536
|
-
}
|
|
2537
|
-
await writer.flush();
|
|
2538
|
-
await writer.fsync();
|
|
2539
|
-
await writer.close();
|
|
2540
|
-
await this.#replaceSessionFile(tempPath, this.#sessionFile);
|
|
2541
|
-
} catch (err) {
|
|
2542
|
-
try {
|
|
2543
|
-
await writer.close();
|
|
2544
|
-
} catch {
|
|
2545
|
-
// Ignore cleanup errors
|
|
2546
|
-
}
|
|
2547
|
-
try {
|
|
2548
|
-
await this.storage.unlink(tempPath);
|
|
2549
|
-
} catch {
|
|
2550
|
-
// Ignore cleanup errors
|
|
2551
|
-
}
|
|
2552
|
-
throw toError(err);
|
|
886
|
+
// Rewrite at the new location when the file already existed (update cwd) or
|
|
887
|
+
// there is in-memory output worth materializing; otherwise stay lazy.
|
|
888
|
+
const hasAssistant = this.#historyContainsAssistantMessage();
|
|
889
|
+
if (this.#persist && this.#sessionFile && (sessionFileExisted || hasAssistant)) {
|
|
890
|
+
this.#forceFileCreation = true;
|
|
891
|
+
await this.#rewriteAtomically();
|
|
2553
892
|
}
|
|
2554
|
-
}
|
|
2555
|
-
|
|
2556
|
-
async #rewriteFile(): Promise<void> {
|
|
2557
|
-
if (!this.persist || !this.#sessionFile) return;
|
|
2558
|
-
await this.#queuePersistTask(async () => {
|
|
2559
|
-
await this.#closePersistWriterInternal();
|
|
2560
|
-
const entries = await Promise.all(
|
|
2561
|
-
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
2562
|
-
);
|
|
2563
|
-
await this.#writeEntriesAtomically(entries);
|
|
2564
|
-
this.#needsFullRewriteOnNextPersist = false;
|
|
2565
|
-
this.#flushed = true;
|
|
2566
|
-
});
|
|
2567
|
-
}
|
|
2568
893
|
|
|
2569
|
-
|
|
2570
|
-
return this.persist;
|
|
894
|
+
if (this.#sessionFile) this.#rememberBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
2571
895
|
}
|
|
2572
896
|
|
|
2573
897
|
/**
|
|
2574
|
-
* Force
|
|
2575
|
-
*
|
|
898
|
+
* Force the session onto disk even with no assistant message yet (ACP
|
|
899
|
+
* session/new must create a discoverable file immediately).
|
|
2576
900
|
*/
|
|
2577
901
|
async ensureOnDisk(): Promise<void> {
|
|
2578
|
-
if (!this
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
this.#
|
|
902
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
903
|
+
this.#forceFileCreation = true;
|
|
904
|
+
if (this.#fileIsCurrent && !this.#rewriteRequired) return;
|
|
905
|
+
await this.#rewriteAtomically();
|
|
2582
906
|
}
|
|
2583
907
|
|
|
2584
|
-
/** Flush pending writes
|
|
908
|
+
/** Flush pending writes. Call before switching sessions or on shutdown. */
|
|
2585
909
|
async flush(): Promise<void> {
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
await this.#persistWriter.fsync();
|
|
2590
|
-
}
|
|
910
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
911
|
+
await this.#scheduleDiskWork(async () => {
|
|
912
|
+
if (this.#writer?.isOpen()) await this.#writer.flush();
|
|
2591
913
|
});
|
|
2592
|
-
if (this.#
|
|
914
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* Synchronously flush all in-memory entries to disk. Use when the process may
|
|
919
|
+
* exit before an async flush settles (Ctrl+C in the TUI). Software-crash
|
|
920
|
+
* durable; not atomic and not power-loss safe — a same-process crash never
|
|
921
|
+
* lands mid-`writeFileSync`.
|
|
922
|
+
*/
|
|
923
|
+
flushSync(): void {
|
|
924
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
925
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
926
|
+
this.#rewriteSynchronously();
|
|
927
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2593
928
|
}
|
|
2594
929
|
|
|
2595
|
-
/**
|
|
930
|
+
/** Flush, then close the append writer. */
|
|
2596
931
|
async close(): Promise<void> {
|
|
2597
|
-
if (!this.#
|
|
2598
|
-
await this.#
|
|
2599
|
-
await this.#
|
|
2600
|
-
this.#
|
|
932
|
+
if (!this.#persist) return;
|
|
933
|
+
await this.#scheduleDiskWork(async () => {
|
|
934
|
+
await this.#closeWriterHandle();
|
|
935
|
+
this.#fileIsCurrent = true;
|
|
2601
936
|
});
|
|
2602
|
-
if (this.#
|
|
937
|
+
if (this.#diskFailure) throw this.#diskFailure;
|
|
2603
938
|
}
|
|
2604
939
|
|
|
2605
940
|
getCwd(): string {
|
|
2606
|
-
return this
|
|
941
|
+
return this.#cwd;
|
|
2607
942
|
}
|
|
2608
943
|
|
|
2609
|
-
/** Get usage statistics across all assistant messages in the session. */
|
|
2610
944
|
getUsageStatistics(): UsageStatistics {
|
|
2611
|
-
return this.#
|
|
945
|
+
return this.#index.usageSnapshot();
|
|
2612
946
|
}
|
|
2613
947
|
|
|
2614
948
|
/**
|
|
2615
949
|
* Open a new per-turn budget window: snapshot the cumulative output baseline,
|
|
2616
|
-
* reset the eval-subagent counter, and set the (optional) ceiling.
|
|
2617
|
-
* per real user message; `total` is null when no `+Nk` directive was present.
|
|
950
|
+
* reset the eval-subagent counter, and set the (optional) ceiling.
|
|
2618
951
|
*/
|
|
2619
952
|
beginTurnBudget(total: number | null, hard: boolean): void {
|
|
2620
|
-
this.#
|
|
2621
|
-
this.#
|
|
953
|
+
this.#turnBudgetTotal = total;
|
|
954
|
+
this.#turnBudgetHard = hard;
|
|
955
|
+
this.#turnOutputBaseline = this.#index.usageSnapshot().output;
|
|
2622
956
|
this.#turnEvalOutput = 0;
|
|
2623
957
|
}
|
|
2624
958
|
|
|
2625
|
-
/** Record output tokens consumed by an eval-spawned subagent in the current turn. */
|
|
2626
959
|
recordEvalSubagentOutput(output: number): void {
|
|
2627
960
|
if (Number.isFinite(output) && output > 0) this.#turnEvalOutput += output;
|
|
2628
961
|
}
|
|
2629
962
|
|
|
2630
|
-
/**
|
|
2631
|
-
* Current turn budget for the eval `budget` helper: the ceiling (null = none),
|
|
2632
|
-
* output tokens spent this turn (main loop + eval-spawned subagents, no
|
|
2633
|
-
* double-count), and whether the ceiling is hard.
|
|
2634
|
-
*/
|
|
2635
963
|
getTurnBudget(): { total: number | null; spent: number; hard: boolean } {
|
|
2636
|
-
const
|
|
2637
|
-
return { total: this.#
|
|
964
|
+
const mainOutput = Math.max(0, this.#index.usageSnapshot().output - this.#turnOutputBaseline);
|
|
965
|
+
return { total: this.#turnBudgetTotal, spent: mainOutput + this.#turnEvalOutput, hard: this.#turnBudgetHard };
|
|
2638
966
|
}
|
|
2639
967
|
|
|
2640
968
|
getSessionDir(): string {
|
|
2641
|
-
return this
|
|
969
|
+
return this.#sessionDir;
|
|
2642
970
|
}
|
|
2643
971
|
|
|
2644
972
|
getSessionId(): string {
|
|
@@ -2649,152 +977,78 @@ export class SessionManager {
|
|
|
2649
977
|
return this.#sessionFile;
|
|
2650
978
|
}
|
|
2651
979
|
|
|
2652
|
-
/**
|
|
2653
|
-
* Returns the session artifacts directory path (session file path without .jsonl).
|
|
2654
|
-
* Returns null when the session is not persisted to a file.
|
|
2655
|
-
* When this session has adopted an external ArtifactManager (subagent case),
|
|
2656
|
-
* returns that manager's directory so reads/writes land in the shared parent
|
|
2657
|
-
* dir instead of a private (non-existent) subdir.
|
|
2658
|
-
*/
|
|
2659
980
|
getArtifactsDir(): string | null {
|
|
2660
981
|
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager.dir;
|
|
2661
|
-
|
|
2662
|
-
return sessionFile ? sessionFile.slice(0, -6) : null;
|
|
982
|
+
return artifactsDirectoryFor(this.#sessionFile);
|
|
2663
983
|
}
|
|
2664
984
|
|
|
2665
|
-
/**
|
|
2666
|
-
* Adopt an externally-owned ArtifactManager. Used by subagents to share
|
|
2667
|
-
* the parent session's artifact directory and ID counter.
|
|
2668
|
-
*/
|
|
2669
985
|
adoptArtifactManager(manager: ArtifactManager): void {
|
|
2670
986
|
this.#adoptedArtifactManager = manager;
|
|
2671
987
|
}
|
|
2672
988
|
|
|
2673
|
-
/**
|
|
2674
|
-
* Returns the ArtifactManager this session writes through. Lazily creates
|
|
2675
|
-
* one bound to the current session file unless an external manager was
|
|
2676
|
-
* adopted via `adoptArtifactManager`. Returns null only for non-persistent
|
|
2677
|
-
* sessions with no adopted manager.
|
|
2678
|
-
*/
|
|
2679
989
|
getArtifactManager(): ArtifactManager | null {
|
|
2680
|
-
return this.#
|
|
2681
|
-
}
|
|
2682
|
-
|
|
2683
|
-
/**
|
|
2684
|
-
* Returns an artifact manager bound to the current session file.
|
|
2685
|
-
* Recreates the manager when the active session file changes.
|
|
2686
|
-
*/
|
|
2687
|
-
#getOrCreateArtifactManager(): ArtifactManager | null {
|
|
2688
|
-
if (this.#adoptedArtifactManager) return this.#adoptedArtifactManager;
|
|
2689
|
-
const sessionFile = this.#sessionFile;
|
|
2690
|
-
if (!sessionFile) {
|
|
2691
|
-
this.#artifactManager = null;
|
|
2692
|
-
this.#artifactManagerSessionFile = null;
|
|
2693
|
-
return null;
|
|
2694
|
-
}
|
|
2695
|
-
|
|
2696
|
-
if (this.#artifactManager && this.#artifactManagerSessionFile === sessionFile) {
|
|
2697
|
-
return this.#artifactManager;
|
|
2698
|
-
}
|
|
2699
|
-
|
|
2700
|
-
const manager = new ArtifactManager(sessionFile.slice(0, -6));
|
|
2701
|
-
this.#artifactManager = manager;
|
|
2702
|
-
this.#artifactManagerSessionFile = sessionFile;
|
|
2703
|
-
return manager;
|
|
990
|
+
return this.#artifactManagerForSession();
|
|
2704
991
|
}
|
|
2705
992
|
|
|
2706
|
-
/**
|
|
2707
|
-
* Allocate a new artifact path and ID for the current session.
|
|
2708
|
-
* Returns an empty object when the session is not persisted.
|
|
2709
|
-
*/
|
|
2710
993
|
async allocateArtifactPath(toolType: string): Promise<{ id?: string; path?: string }> {
|
|
2711
|
-
|
|
2712
|
-
if (!manager) return {};
|
|
2713
|
-
return manager.allocatePath(toolType);
|
|
994
|
+
return (await this.#artifactManagerForSession()?.allocatePath(toolType)) ?? {};
|
|
2714
995
|
}
|
|
2715
996
|
|
|
2716
|
-
/**
|
|
2717
|
-
* Save artifact content under the current session and return artifact ID.
|
|
2718
|
-
* Returns an artifact ID for all sessions (file-backed for persistent, in-memory fallback otherwise).
|
|
2719
|
-
*/
|
|
2720
997
|
async saveArtifact(content: string, toolType: string): Promise<string | undefined> {
|
|
2721
|
-
const manager = this.#
|
|
998
|
+
const manager = this.#artifactManagerForSession();
|
|
2722
999
|
if (manager) return manager.save(content, toolType);
|
|
2723
|
-
|
|
2724
|
-
|
|
1000
|
+
|
|
1001
|
+
// Non-persistent session: keep an in-memory copy so spill truncation works.
|
|
1002
|
+
this.#inMemoryArtifacts ??= new Map();
|
|
2725
1003
|
const id = String(this.#inMemoryArtifactCounter++);
|
|
2726
1004
|
this.#inMemoryArtifacts.set(id, content);
|
|
2727
1005
|
return id;
|
|
2728
1006
|
}
|
|
2729
1007
|
|
|
2730
|
-
/**
|
|
2731
|
-
* Resolve an artifact ID to an on-disk path for the current session.
|
|
2732
|
-
* Returns null when missing or when the session is not persisted.
|
|
2733
|
-
*/
|
|
2734
1008
|
async getArtifactPath(id: string): Promise<string | null> {
|
|
2735
|
-
|
|
2736
|
-
if (!manager) return null;
|
|
2737
|
-
return manager.getPath(id);
|
|
2738
|
-
}
|
|
2739
|
-
|
|
2740
|
-
/**
|
|
2741
|
-
* Path to the unsent-input draft sidecar for the current session. Lives inside
|
|
2742
|
-
* the artifacts directory so it is removed together with the session on
|
|
2743
|
-
* `dropSession`. Returns null when the session has no on-disk identity.
|
|
2744
|
-
*/
|
|
2745
|
-
#getDraftPath(): string | null {
|
|
2746
|
-
const dir = this.getArtifactsDir();
|
|
2747
|
-
return dir ? path.join(dir, "draft.txt") : null;
|
|
1009
|
+
return (await this.#artifactManagerForSession()?.getPath(id)) ?? null;
|
|
2748
1010
|
}
|
|
2749
1011
|
|
|
2750
|
-
/**
|
|
2751
|
-
* Persist (or clear) the current editor draft so the next resume of this
|
|
2752
|
-
* session can restore it. Empty text deletes any stale draft. No-op when the
|
|
2753
|
-
* session is not persisted.
|
|
2754
|
-
*/
|
|
2755
1012
|
async saveDraft(text: string): Promise<void> {
|
|
2756
|
-
const draftPath = this.#
|
|
2757
|
-
if (!draftPath || !this
|
|
1013
|
+
const draftPath = this.#draftPath();
|
|
1014
|
+
if (!draftPath || !this.#persist) return;
|
|
1015
|
+
|
|
2758
1016
|
if (text.length === 0) {
|
|
2759
1017
|
try {
|
|
2760
|
-
await this
|
|
1018
|
+
await this.#storage.unlink(draftPath);
|
|
2761
1019
|
} catch (err) {
|
|
2762
1020
|
if (!isEnoent(err)) throw err;
|
|
2763
1021
|
}
|
|
2764
1022
|
return;
|
|
2765
1023
|
}
|
|
2766
|
-
|
|
2767
|
-
//
|
|
2768
|
-
// never produced an assistant reply would persist a draft next to a
|
|
2769
|
-
// session file that does not exist on disk.
|
|
1024
|
+
|
|
1025
|
+
// Force the header onto disk so resume can find the file this draft attaches to.
|
|
2770
1026
|
await this.ensureOnDisk();
|
|
2771
|
-
await this
|
|
1027
|
+
await this.#storage.writeText(draftPath, text);
|
|
2772
1028
|
}
|
|
2773
1029
|
|
|
2774
|
-
/**
|
|
2775
|
-
* Read and remove the saved draft. Returns the previously-saved text, or
|
|
2776
|
-
* null when no draft is pending. Single-shot: a successful read removes the
|
|
2777
|
-
* sidecar so a subsequent resume does not re-restore the same text.
|
|
2778
|
-
*/
|
|
2779
1030
|
async consumeDraft(): Promise<string | null> {
|
|
2780
|
-
const draftPath = this.#
|
|
1031
|
+
const draftPath = this.#draftPath();
|
|
2781
1032
|
if (!draftPath) return null;
|
|
2782
|
-
|
|
1033
|
+
|
|
1034
|
+
let draft: string;
|
|
2783
1035
|
try {
|
|
2784
|
-
|
|
1036
|
+
draft = await this.#storage.readText(draftPath);
|
|
2785
1037
|
} catch (err) {
|
|
2786
1038
|
if (isEnoent(err)) return null;
|
|
2787
1039
|
throw err;
|
|
2788
1040
|
}
|
|
1041
|
+
|
|
2789
1042
|
try {
|
|
2790
|
-
await this
|
|
1043
|
+
await this.#storage.unlink(draftPath);
|
|
2791
1044
|
} catch (err) {
|
|
2792
1045
|
if (!isEnoent(err)) throw err;
|
|
2793
1046
|
}
|
|
2794
|
-
|
|
1047
|
+
|
|
1048
|
+
return draft;
|
|
2795
1049
|
}
|
|
2796
1050
|
|
|
2797
|
-
/** The source that set the session name: "user" (manual
|
|
1051
|
+
/** The source that set the session name: "user" (manual/RPC) or "auto" (generated title). */
|
|
2798
1052
|
get titleSource(): "auto" | "user" | undefined {
|
|
2799
1053
|
return this.#titleSource;
|
|
2800
1054
|
}
|
|
@@ -2810,174 +1064,51 @@ export class SessionManager {
|
|
|
2810
1064
|
};
|
|
2811
1065
|
}
|
|
2812
1066
|
|
|
2813
|
-
#fireSessionNameChanged(): void {
|
|
2814
|
-
for (const cb of [...this.#sessionNameChangedCallbacks]) {
|
|
2815
|
-
try {
|
|
2816
|
-
cb();
|
|
2817
|
-
} catch (err) {
|
|
2818
|
-
logger.warn("SessionManager: session name change hook failed", { error: String(err) });
|
|
2819
|
-
}
|
|
2820
|
-
}
|
|
2821
|
-
}
|
|
2822
|
-
|
|
2823
|
-
/** Strip C0/C1 control characters (includes ESC, so removes ANSI sequences) and collapse whitespace. */
|
|
2824
|
-
static #sanitizeName(name: string): string {
|
|
2825
|
-
return name
|
|
2826
|
-
.replace(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
2827
|
-
.replace(/ +/g, " ")
|
|
2828
|
-
.trim();
|
|
2829
|
-
}
|
|
2830
|
-
|
|
2831
1067
|
/**
|
|
2832
1068
|
* Set the session display name.
|
|
2833
|
-
* @param source
|
|
2834
|
-
* Auto
|
|
1069
|
+
* @param source "user" for explicit renames; "auto" for generated titles.
|
|
1070
|
+
* Auto titles are ignored once the user has set a name.
|
|
2835
1071
|
*/
|
|
2836
1072
|
async setSessionName(name: string, source: "auto" | "user" = "auto"): Promise<boolean> {
|
|
2837
|
-
// User-set names take permanent precedence over auto-generated ones.
|
|
2838
1073
|
if (this.#titleSource === "user" && source === "auto") return false;
|
|
2839
1074
|
|
|
2840
|
-
const
|
|
2841
|
-
if (!
|
|
1075
|
+
const title = SessionManager.#cleanTitle(name);
|
|
1076
|
+
if (!title) return false;
|
|
2842
1077
|
|
|
2843
|
-
this.#sessionName =
|
|
1078
|
+
this.#sessionName = title;
|
|
2844
1079
|
this.#titleSource = source;
|
|
1080
|
+
this.#header.title = title;
|
|
1081
|
+
this.#header.titleSource = source;
|
|
2845
1082
|
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
if (header) {
|
|
2849
|
-
header.title = sanitized;
|
|
2850
|
-
header.titleSource = source;
|
|
1083
|
+
if (this.#persist && this.#sessionFile && this.#storage.existsSync(this.#sessionFile)) {
|
|
1084
|
+
await this.#rewriteAtomically();
|
|
2851
1085
|
}
|
|
2852
1086
|
|
|
2853
|
-
|
|
2854
|
-
const sessionFile = this.#sessionFile;
|
|
2855
|
-
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
2856
|
-
await this.#rewriteFile();
|
|
2857
|
-
}
|
|
2858
|
-
this.#fireSessionNameChanged();
|
|
1087
|
+
this.#notifySessionNameListeners();
|
|
2859
1088
|
return true;
|
|
2860
1089
|
}
|
|
2861
1090
|
|
|
2862
|
-
_persist(entry: SessionEntry): void {
|
|
2863
|
-
if (!this.persist || !this.#sessionFile) return;
|
|
2864
|
-
if (this.#persistError) throw this.#persistError;
|
|
2865
|
-
|
|
2866
|
-
// Normally we wait for the first assistant message before persisting to avoid
|
|
2867
|
-
// creating files for sessions that never produce output. Once ensureOnDisk() has
|
|
2868
|
-
// been called, the session is already on disk and every entry must be flushed.
|
|
2869
|
-
if (!this.#ensuredOnDisk) {
|
|
2870
|
-
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
2871
|
-
if (!hasAssistant) {
|
|
2872
|
-
// Mark as not flushed so when assistant arrives, all entries get written.
|
|
2873
|
-
this.#flushed = false;
|
|
2874
|
-
return;
|
|
2875
|
-
}
|
|
2876
|
-
}
|
|
2877
|
-
|
|
2878
|
-
if (this.#needsFullRewriteOnNextPersist || !this.#flushed) {
|
|
2879
|
-
// Cold path: rewrite the whole file atomically. Async — the writer is
|
|
2880
|
-
// closed/reopened and every entry is re-prepared. Errors flow through
|
|
2881
|
-
// `#persistChain` → `#recordPersistError`; we swallow the rejection
|
|
2882
|
-
// here to avoid an unhandled rejection when the persist dir races with
|
|
2883
|
-
// test-level tempDir cleanup.
|
|
2884
|
-
this.#rewriteFile().catch(() => {});
|
|
2885
|
-
return;
|
|
2886
|
-
}
|
|
2887
|
-
|
|
2888
|
-
// Hot path: synchronously truncate + append. `fs.writeSync` returns once the
|
|
2889
|
-
// bytes are in the kernel page cache, so the entry survives an OOM/SIGKILL
|
|
2890
|
-
// landing immediately after this call. Image externalization (rare) runs via
|
|
2891
|
-
// the synchronous blob-store path so blob bytes are durable before the JSONL
|
|
2892
|
-
// line referencing them is written.
|
|
2893
|
-
try {
|
|
2894
|
-
const writer = this.#ensurePersistWriter();
|
|
2895
|
-
if (!writer) {
|
|
2896
|
-
// `#ensurePersistWriter` returns undefined here only when the cached
|
|
2897
|
-
// writer is mid-close (the `!persist`/`!sessionFile` cases are
|
|
2898
|
-
// rejected above). Route through `#rewriteFile` so the entry — which
|
|
2899
|
-
// is already in `#fileEntries` — persists once the close drains.
|
|
2900
|
-
this.#rewriteFile().catch(() => {});
|
|
2901
|
-
return;
|
|
2902
|
-
}
|
|
2903
|
-
const persistedEntry = prepareEntryForPersistenceSync(entry, this.#blobStore);
|
|
2904
|
-
writer.writeSync(persistedEntry);
|
|
2905
|
-
} catch (err) {
|
|
2906
|
-
this.#recordPersistError(err);
|
|
2907
|
-
}
|
|
2908
|
-
}
|
|
2909
|
-
|
|
2910
|
-
#appendEntry(entry: SessionEntry): void {
|
|
2911
|
-
this.#fileEntries.push(entry);
|
|
2912
|
-
this.#byId.set(entry.id, entry);
|
|
2913
|
-
this.#leafId = entry.id;
|
|
2914
|
-
this._persist(entry);
|
|
2915
|
-
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
2916
|
-
const usage = entry.message.usage;
|
|
2917
|
-
this.#usageStatistics.input += usage.input;
|
|
2918
|
-
this.#usageStatistics.output += usage.output;
|
|
2919
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2920
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2921
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2922
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2923
|
-
}
|
|
2924
|
-
|
|
2925
|
-
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
2926
|
-
const usage = getTaskToolUsage(entry.message.details);
|
|
2927
|
-
if (usage) {
|
|
2928
|
-
this.#usageStatistics.input += usage.input;
|
|
2929
|
-
this.#usageStatistics.output += usage.output;
|
|
2930
|
-
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
2931
|
-
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
2932
|
-
this.#usageStatistics.premiumRequests += usage.premiumRequests ?? 0;
|
|
2933
|
-
this.#usageStatistics.cost += usage.cost.total;
|
|
2934
|
-
}
|
|
2935
|
-
}
|
|
2936
|
-
if (this.onEntryAppended) {
|
|
2937
|
-
try {
|
|
2938
|
-
this.onEntryAppended(entry);
|
|
2939
|
-
} catch (err) {
|
|
2940
|
-
logger.warn("collab entry hook failed", { error: String(err) });
|
|
2941
|
-
}
|
|
2942
|
-
}
|
|
2943
|
-
}
|
|
2944
|
-
|
|
2945
1091
|
/**
|
|
2946
1092
|
* Append a foreign (host-authored) entry verbatim, preserving its
|
|
2947
|
-
* `id`/`parentId
|
|
2948
|
-
* host session into the local replica file.
|
|
1093
|
+
* `id`/`parentId`. Used by collab guests to mirror the host session.
|
|
2949
1094
|
*/
|
|
2950
1095
|
ingestReplicatedEntry(entry: SessionEntry): void {
|
|
2951
|
-
this.#
|
|
1096
|
+
this.#recordEntry(entry);
|
|
2952
1097
|
}
|
|
2953
1098
|
|
|
2954
1099
|
/**
|
|
2955
1100
|
* Snapshot the session for collab replication: the live header plus a deep
|
|
2956
|
-
* copy of every entry (the host mutates entries in place on
|
|
2957
|
-
*
|
|
1101
|
+
* copy of every entry (the host mutates entries in place on rewrite paths, so
|
|
1102
|
+
* guests must not share references).
|
|
2958
1103
|
*/
|
|
2959
1104
|
snapshotForReplication(): { header: SessionHeader; entries: SessionEntry[] } {
|
|
2960
|
-
|
|
2961
|
-
|
|
2962
|
-
|
|
2963
|
-
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
title: this.#sessionName,
|
|
2968
|
-
titleSource: this.#titleSource,
|
|
2969
|
-
timestamp: new Date().toISOString(),
|
|
2970
|
-
cwd: this.cwd,
|
|
2971
|
-
};
|
|
2972
|
-
const entries = structuredClone(this.#fileEntries.filter(e => e.type !== "session")) as SessionEntry[];
|
|
2973
|
-
return { header, entries };
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
|
-
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
2977
|
-
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
2978
|
-
* Reason: we want these to be top-level entries in the session, not message session entries,
|
|
2979
|
-
* so it is easier to find them.
|
|
2980
|
-
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
1105
|
+
return { header: structuredClone(this.#header), entries: structuredClone(this.#entries) as SessionEntry[] };
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
/**
|
|
1109
|
+
* Append a message as a child of the current leaf, then advance the leaf.
|
|
1110
|
+
* CompactionSummaryMessage / BranchSummaryMessage are rejected here — they are
|
|
1111
|
+
* top-level entries via appendCompaction()/branchWithSummary().
|
|
2981
1112
|
*/
|
|
2982
1113
|
appendMessage(
|
|
2983
1114
|
message:
|
|
@@ -2988,88 +1119,50 @@ export class SessionManager {
|
|
|
2988
1119
|
| PythonExecutionMessage
|
|
2989
1120
|
| FileMentionMessage,
|
|
2990
1121
|
): string {
|
|
2991
|
-
const entry: SessionMessageEntry = {
|
|
2992
|
-
|
|
2993
|
-
id: generateId(this.#byId),
|
|
2994
|
-
parentId: this.#leafId,
|
|
2995
|
-
timestamp: new Date().toISOString(),
|
|
2996
|
-
message,
|
|
2997
|
-
};
|
|
2998
|
-
this.#appendEntry(entry);
|
|
1122
|
+
const entry: SessionMessageEntry = { type: "message", ...this.#freshEntryFields(), message };
|
|
1123
|
+
this.#recordEntry(entry);
|
|
2999
1124
|
return entry.id;
|
|
3000
1125
|
}
|
|
3001
1126
|
|
|
3002
|
-
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
3003
1127
|
appendThinkingLevelChange(thinkingLevel?: string): string {
|
|
3004
1128
|
const entry: ThinkingLevelChangeEntry = {
|
|
3005
1129
|
type: "thinking_level_change",
|
|
3006
|
-
|
|
3007
|
-
parentId: this.#leafId,
|
|
3008
|
-
timestamp: new Date().toISOString(),
|
|
1130
|
+
...this.#freshEntryFields(),
|
|
3009
1131
|
thinkingLevel: thinkingLevel ?? null,
|
|
3010
1132
|
};
|
|
3011
|
-
this.#
|
|
1133
|
+
this.#recordEntry(entry);
|
|
3012
1134
|
return entry.id;
|
|
3013
1135
|
}
|
|
3014
1136
|
|
|
3015
1137
|
appendServiceTierChange(serviceTier: ServiceTier | null): string {
|
|
3016
|
-
const entry: ServiceTierChangeEntry = {
|
|
3017
|
-
|
|
3018
|
-
id: generateId(this.#byId),
|
|
3019
|
-
parentId: this.#leafId,
|
|
3020
|
-
timestamp: new Date().toISOString(),
|
|
3021
|
-
serviceTier,
|
|
3022
|
-
};
|
|
3023
|
-
this.#appendEntry(entry);
|
|
1138
|
+
const entry: ServiceTierChangeEntry = { type: "service_tier_change", ...this.#freshEntryFields(), serviceTier };
|
|
1139
|
+
this.#recordEntry(entry);
|
|
3024
1140
|
return entry.id;
|
|
3025
1141
|
}
|
|
3026
1142
|
|
|
3027
|
-
/** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */
|
|
3028
1143
|
appendModeChange(mode: string, data?: Record<string, unknown>): string {
|
|
3029
|
-
const entry: ModeChangeEntry = {
|
|
3030
|
-
|
|
3031
|
-
id: generateId(this.#byId),
|
|
3032
|
-
parentId: this.#leafId,
|
|
3033
|
-
timestamp: new Date().toISOString(),
|
|
3034
|
-
mode,
|
|
3035
|
-
data,
|
|
3036
|
-
};
|
|
3037
|
-
this.#appendEntry(entry);
|
|
1144
|
+
const entry: ModeChangeEntry = { type: "mode_change", ...this.#freshEntryFields(), mode, data };
|
|
1145
|
+
this.#recordEntry(entry);
|
|
3038
1146
|
return entry.id;
|
|
3039
1147
|
}
|
|
3040
1148
|
|
|
3041
1149
|
/**
|
|
3042
|
-
* Append a model change as child of current leaf, then advance leaf.
|
|
1150
|
+
* Append a model change as a child of the current leaf, then advance the leaf.
|
|
3043
1151
|
* @param model Model in "provider/modelId" format
|
|
3044
1152
|
* @param role Optional role (default: "default")
|
|
3045
1153
|
*/
|
|
3046
1154
|
appendModelChange(model: string, role?: string): string {
|
|
3047
|
-
const entry: ModelChangeEntry = {
|
|
3048
|
-
|
|
3049
|
-
id: generateId(this.#byId),
|
|
3050
|
-
parentId: this.#leafId,
|
|
3051
|
-
timestamp: new Date().toISOString(),
|
|
3052
|
-
model,
|
|
3053
|
-
role,
|
|
3054
|
-
};
|
|
3055
|
-
this.#appendEntry(entry);
|
|
1155
|
+
const entry: ModelChangeEntry = { type: "model_change", ...this.#freshEntryFields(), model, role };
|
|
1156
|
+
this.#recordEntry(entry);
|
|
3056
1157
|
return entry.id;
|
|
3057
1158
|
}
|
|
3058
1159
|
|
|
3059
|
-
/** Append session init metadata (for subagent debugging/replay). Returns entry id. */
|
|
3060
1160
|
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
|
|
3061
|
-
const entry: SessionInitEntry = {
|
|
3062
|
-
|
|
3063
|
-
id: generateId(this.#byId),
|
|
3064
|
-
parentId: this.#leafId,
|
|
3065
|
-
timestamp: new Date().toISOString(),
|
|
3066
|
-
...init,
|
|
3067
|
-
};
|
|
3068
|
-
this.#appendEntry(entry);
|
|
1161
|
+
const entry: SessionInitEntry = { type: "session_init", ...this.#freshEntryFields(), ...init };
|
|
1162
|
+
this.#recordEntry(entry);
|
|
3069
1163
|
return entry.id;
|
|
3070
1164
|
}
|
|
3071
1165
|
|
|
3072
|
-
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
3073
1166
|
appendCompaction<T = unknown>(
|
|
3074
1167
|
summary: string,
|
|
3075
1168
|
shortSummary: string | undefined,
|
|
@@ -3081,9 +1174,7 @@ export class SessionManager {
|
|
|
3081
1174
|
): string {
|
|
3082
1175
|
const entry: CompactionEntry<T> = {
|
|
3083
1176
|
type: "compaction",
|
|
3084
|
-
|
|
3085
|
-
parentId: this.#leafId,
|
|
3086
|
-
timestamp: new Date().toISOString(),
|
|
1177
|
+
...this.#freshEntryFields(),
|
|
3087
1178
|
summary,
|
|
3088
1179
|
shortSummary,
|
|
3089
1180
|
firstKeptEntryId,
|
|
@@ -3092,31 +1183,23 @@ export class SessionManager {
|
|
|
3092
1183
|
fromExtension,
|
|
3093
1184
|
preserveData,
|
|
3094
1185
|
};
|
|
3095
|
-
this.#
|
|
1186
|
+
this.#recordEntry(entry);
|
|
3096
1187
|
return entry.id;
|
|
3097
1188
|
}
|
|
3098
1189
|
|
|
3099
|
-
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
|
|
3100
1190
|
appendCustomEntry(customType: string, data?: unknown): string {
|
|
3101
|
-
const entry: CustomEntry = {
|
|
3102
|
-
|
|
3103
|
-
customType,
|
|
3104
|
-
data,
|
|
3105
|
-
id: generateId(this.#byId),
|
|
3106
|
-
parentId: this.#leafId,
|
|
3107
|
-
timestamp: new Date().toISOString(),
|
|
3108
|
-
};
|
|
3109
|
-
this.#appendEntry(entry);
|
|
1191
|
+
const entry: CustomEntry = { type: "custom", customType, data, ...this.#freshEntryFields() };
|
|
1192
|
+
this.#recordEntry(entry);
|
|
3110
1193
|
return entry.id;
|
|
3111
1194
|
}
|
|
3112
1195
|
|
|
3113
1196
|
/**
|
|
3114
|
-
* Rewrite the session file after in-place entry updates.
|
|
3115
|
-
* Use sparingly
|
|
1197
|
+
* Rewrite the session file after in-place entry updates (e.g. pruning old tool
|
|
1198
|
+
* outputs). Use sparingly.
|
|
3116
1199
|
*/
|
|
3117
1200
|
async rewriteEntries(): Promise<void> {
|
|
3118
|
-
if (!this
|
|
3119
|
-
await this.#
|
|
1201
|
+
if (!this.#persist || !this.#sessionFile) return;
|
|
1202
|
+
await this.#rewriteAtomically();
|
|
3120
1203
|
}
|
|
3121
1204
|
|
|
3122
1205
|
/**
|
|
@@ -3126,7 +1209,6 @@ export class SessionManager {
|
|
|
3126
1209
|
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
3127
1210
|
* @param details Optional extension-specific metadata (not sent to LLM)
|
|
3128
1211
|
* @param attribution Who initiated this message for billing/attribution semantics
|
|
3129
|
-
* @returns Entry id
|
|
3130
1212
|
*/
|
|
3131
1213
|
appendCustomMessageEntry<T = unknown>(
|
|
3132
1214
|
customType: string,
|
|
@@ -3140,402 +1222,244 @@ export class SessionManager {
|
|
|
3140
1222
|
customType,
|
|
3141
1223
|
content,
|
|
3142
1224
|
display,
|
|
3143
|
-
// Drop AgentSession-internal transient fields
|
|
3144
|
-
// `INTERNAL_DETAILS_FIELDS`) before disk persistence. Single
|
|
3145
|
-
// chokepoint covers every CustomMessage write path.
|
|
1225
|
+
// Drop AgentSession-internal transient fields before disk persistence.
|
|
3146
1226
|
details: stripInternalDetailsFields(details),
|
|
3147
1227
|
attribution,
|
|
3148
|
-
|
|
3149
|
-
parentId: this.#leafId,
|
|
3150
|
-
timestamp: new Date().toISOString(),
|
|
1228
|
+
...this.#freshEntryFields(),
|
|
3151
1229
|
};
|
|
3152
|
-
this.#
|
|
1230
|
+
this.#recordEntry(entry);
|
|
3153
1231
|
return entry.id;
|
|
3154
1232
|
}
|
|
3155
1233
|
|
|
3156
|
-
// =========================================================================
|
|
3157
|
-
// TTSR (Time Traveling Stream Rules)
|
|
3158
|
-
// =========================================================================
|
|
3159
|
-
|
|
3160
1234
|
/**
|
|
3161
1235
|
* Append an MCP tool selection entry recording the discovery-selected MCP tools.
|
|
3162
|
-
* @param selectedToolNames MCP tool names selected for this branch
|
|
3163
|
-
* @returns Entry id
|
|
3164
1236
|
*/
|
|
3165
1237
|
appendMCPToolSelection(selectedToolNames: string[]): string {
|
|
3166
1238
|
const entry: MCPToolSelectionEntry = {
|
|
3167
1239
|
type: "mcp_tool_selection",
|
|
3168
|
-
|
|
3169
|
-
parentId: this.#leafId,
|
|
3170
|
-
timestamp: new Date().toISOString(),
|
|
1240
|
+
...this.#freshEntryFields(),
|
|
3171
1241
|
selectedToolNames: [...selectedToolNames],
|
|
3172
1242
|
};
|
|
3173
|
-
this.#
|
|
1243
|
+
this.#recordEntry(entry);
|
|
3174
1244
|
return entry.id;
|
|
3175
1245
|
}
|
|
3176
1246
|
|
|
3177
|
-
/**
|
|
3178
|
-
* Append a TTSR injection entry recording which rules were injected.
|
|
3179
|
-
* @param ruleNames Names of rules that were injected
|
|
3180
|
-
* @returns Entry id
|
|
3181
|
-
*/
|
|
1247
|
+
/** Append a TTSR injection entry recording which rules were injected. */
|
|
3182
1248
|
appendTtsrInjection(ruleNames: string[]): string {
|
|
3183
1249
|
const entry: TtsrInjectionEntry = {
|
|
3184
1250
|
type: "ttsr_injection",
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
timestamp: new Date().toISOString(),
|
|
3188
|
-
injectedRules: ruleNames,
|
|
1251
|
+
...this.#freshEntryFields(),
|
|
1252
|
+
injectedRules: [...ruleNames],
|
|
3189
1253
|
};
|
|
3190
|
-
this.#
|
|
1254
|
+
this.#recordEntry(entry);
|
|
3191
1255
|
return entry.id;
|
|
3192
1256
|
}
|
|
3193
1257
|
|
|
3194
|
-
/**
|
|
3195
|
-
* Get all unique TTSR rule names that have been injected in the current branch.
|
|
3196
|
-
* Scans from root to current leaf for ttsr_injection entries.
|
|
3197
|
-
*/
|
|
1258
|
+
/** All unique TTSR rule names injected on the current branch (root → leaf). */
|
|
3198
1259
|
getInjectedTtsrRules(): string[] {
|
|
3199
|
-
const
|
|
3200
|
-
const
|
|
3201
|
-
|
|
3202
|
-
|
|
3203
|
-
for (const name of entry.injectedRules) {
|
|
3204
|
-
ruleNames.add(name);
|
|
3205
|
-
}
|
|
3206
|
-
}
|
|
1260
|
+
const names = new Set<string>();
|
|
1261
|
+
for (const entry of this.getBranch()) {
|
|
1262
|
+
if (entry.type !== "ttsr_injection") continue;
|
|
1263
|
+
for (const name of entry.injectedRules) names.add(name);
|
|
3207
1264
|
}
|
|
3208
|
-
return
|
|
1265
|
+
return [...names];
|
|
3209
1266
|
}
|
|
3210
1267
|
|
|
3211
|
-
// =========================================================================
|
|
3212
|
-
// Tree Traversal
|
|
3213
|
-
// =========================================================================
|
|
3214
|
-
|
|
3215
1268
|
getLeafId(): string | null {
|
|
3216
|
-
return this.#leafId;
|
|
1269
|
+
return this.#index.leafId();
|
|
3217
1270
|
}
|
|
3218
1271
|
|
|
3219
1272
|
getLeafEntry(): SessionEntry | undefined {
|
|
3220
|
-
return this.#
|
|
1273
|
+
return this.#index.leafEntry();
|
|
3221
1274
|
}
|
|
3222
1275
|
|
|
3223
1276
|
/**
|
|
3224
|
-
*
|
|
3225
|
-
*
|
|
1277
|
+
* The most recent model role on the current branch, or undefined when no
|
|
1278
|
+
* model change has been recorded.
|
|
3226
1279
|
*/
|
|
3227
1280
|
getLastModelChangeRole(): string | undefined {
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
}
|
|
3233
|
-
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
1281
|
+
const branch = this.getBranch();
|
|
1282
|
+
for (let index = branch.length - 1; index >= 0; index--) {
|
|
1283
|
+
const entry = branch[index];
|
|
1284
|
+
if (entry.type === "model_change") return entry.role ?? "default";
|
|
3234
1285
|
}
|
|
3235
1286
|
return undefined;
|
|
3236
1287
|
}
|
|
3237
1288
|
|
|
3238
1289
|
getEntry(id: string): SessionEntry | undefined {
|
|
3239
|
-
return this.#
|
|
1290
|
+
return this.#index.get(id);
|
|
3240
1291
|
}
|
|
3241
1292
|
|
|
3242
|
-
/**
|
|
3243
|
-
* Get all direct children of an entry.
|
|
3244
|
-
*/
|
|
1293
|
+
/** All direct children of an entry. */
|
|
3245
1294
|
getChildren(parentId: string): SessionEntry[] {
|
|
3246
|
-
|
|
3247
|
-
for (const entry of this.#byId.values()) {
|
|
3248
|
-
if (entry.parentId === parentId) {
|
|
3249
|
-
children.push(entry);
|
|
3250
|
-
}
|
|
3251
|
-
}
|
|
3252
|
-
return children;
|
|
1295
|
+
return this.#index.childrenOf(parentId);
|
|
3253
1296
|
}
|
|
3254
1297
|
|
|
3255
|
-
/**
|
|
3256
|
-
* Get the label for an entry, if any.
|
|
3257
|
-
*/
|
|
3258
1298
|
getLabel(id: string): string | undefined {
|
|
3259
|
-
return this.#
|
|
1299
|
+
return this.#index.labelFor(id);
|
|
3260
1300
|
}
|
|
3261
1301
|
|
|
3262
1302
|
/**
|
|
3263
|
-
* Set or clear a label on an entry.
|
|
3264
|
-
* Labels are user-defined markers for bookmarking/navigation.
|
|
3265
|
-
* Pass undefined or empty string to clear the label.
|
|
1303
|
+
* Set or clear a label on an entry. Pass undefined/empty to clear.
|
|
3266
1304
|
*/
|
|
3267
1305
|
appendLabelChange(targetId: string, label: string | undefined): string {
|
|
3268
|
-
if (!this.#
|
|
3269
|
-
|
|
3270
|
-
}
|
|
3271
|
-
|
|
3272
|
-
type: "label",
|
|
3273
|
-
id: generateId(this.#byId),
|
|
3274
|
-
parentId: this.#leafId,
|
|
3275
|
-
timestamp: new Date().toISOString(),
|
|
3276
|
-
targetId,
|
|
3277
|
-
label,
|
|
3278
|
-
};
|
|
3279
|
-
this.#appendEntry(entry);
|
|
3280
|
-
if (label) {
|
|
3281
|
-
this.#labelsById.set(targetId, label);
|
|
3282
|
-
} else {
|
|
3283
|
-
this.#labelsById.delete(targetId);
|
|
3284
|
-
}
|
|
1306
|
+
if (!this.#index.has(targetId)) throw new Error(`Entry ${targetId} not found`);
|
|
1307
|
+
|
|
1308
|
+
const entry: LabelEntry = { type: "label", ...this.#freshEntryFields(), targetId, label };
|
|
1309
|
+
this.#recordEntry(entry);
|
|
3285
1310
|
return entry.id;
|
|
3286
1311
|
}
|
|
3287
1312
|
|
|
3288
1313
|
/**
|
|
3289
|
-
* Walk from entry to root, returning
|
|
3290
|
-
*
|
|
3291
|
-
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
1314
|
+
* Walk from an entry to root, returning entries in path order. Includes all
|
|
1315
|
+
* entry types; use buildSessionContext() for the resolved LLM messages.
|
|
3292
1316
|
*/
|
|
3293
1317
|
getBranch(fromId?: string): SessionEntry[] {
|
|
3294
|
-
|
|
3295
|
-
const startId = fromId ?? this.#leafId;
|
|
3296
|
-
let current = startId ? this.#byId.get(startId) : undefined;
|
|
3297
|
-
while (current) {
|
|
3298
|
-
path.unshift(current);
|
|
3299
|
-
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
3300
|
-
}
|
|
3301
|
-
return path;
|
|
1318
|
+
return this.#index.pathTo(fromId ?? this.#index.leafId());
|
|
3302
1319
|
}
|
|
3303
1320
|
|
|
3304
1321
|
/**
|
|
3305
|
-
* Build the session context (
|
|
3306
|
-
*
|
|
3307
|
-
* Uses tree traversal from current leaf.
|
|
1322
|
+
* Build the session context (LLM messages), or — with `{ transcript: true }` —
|
|
1323
|
+
* the full-history display transcript, from the current leaf path.
|
|
3308
1324
|
*/
|
|
3309
1325
|
buildSessionContext(options?: BuildSessionContextOptions): SessionContext {
|
|
3310
|
-
return buildSessionContext(this
|
|
1326
|
+
return buildSessionContext(this.#entries, this.#index.leafId(), this.#index.entriesById(), options);
|
|
3311
1327
|
}
|
|
3312
1328
|
|
|
3313
|
-
/** Strip stale OpenAI Responses assistant replay metadata from loaded
|
|
1329
|
+
/** Strip stale OpenAI Responses assistant replay metadata from loaded entries. */
|
|
3314
1330
|
sanitizeLoadedOpenAIResponsesReplayMetadata(): boolean {
|
|
3315
|
-
let
|
|
3316
|
-
for (const entry of this.#
|
|
3317
|
-
if (entry.type !== "message" || entry.message.role !== "assistant")
|
|
3318
|
-
continue;
|
|
3319
|
-
}
|
|
1331
|
+
let changed = false;
|
|
1332
|
+
for (const entry of this.#entries) {
|
|
1333
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
3320
1334
|
|
|
3321
|
-
const
|
|
3322
|
-
if (
|
|
3323
|
-
continue;
|
|
3324
|
-
}
|
|
1335
|
+
const sanitized = sanitizeRehydratedOpenAIResponsesAssistantMessage(entry.message);
|
|
1336
|
+
if (sanitized === entry.message) continue;
|
|
3325
1337
|
|
|
3326
|
-
entry.message =
|
|
3327
|
-
|
|
1338
|
+
entry.message = sanitized;
|
|
1339
|
+
changed = true;
|
|
3328
1340
|
}
|
|
3329
1341
|
|
|
3330
|
-
return
|
|
1342
|
+
return changed;
|
|
3331
1343
|
}
|
|
3332
1344
|
|
|
3333
|
-
/**
|
|
3334
|
-
* Get session header.
|
|
3335
|
-
*/
|
|
3336
1345
|
getHeader(): SessionHeader | null {
|
|
3337
|
-
|
|
3338
|
-
return h ? (h as SessionHeader) : null;
|
|
1346
|
+
return this.#header;
|
|
3339
1347
|
}
|
|
3340
1348
|
|
|
3341
|
-
/**
|
|
3342
|
-
* Get all session entries (excludes header). Returns a shallow copy.
|
|
3343
|
-
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
3344
|
-
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
3345
|
-
*/
|
|
1349
|
+
/** All session entries (excludes header). Returns a shallow copy. */
|
|
3346
1350
|
getEntries(): SessionEntry[] {
|
|
3347
|
-
return this.#
|
|
1351
|
+
return [...this.#entries];
|
|
3348
1352
|
}
|
|
3349
1353
|
|
|
3350
1354
|
/**
|
|
3351
|
-
*
|
|
3352
|
-
*
|
|
3353
|
-
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
1355
|
+
* The session as a tree. A well-formed session has exactly one root; orphaned
|
|
1356
|
+
* entries (broken parent chain) are returned as roots too.
|
|
3354
1357
|
*/
|
|
3355
1358
|
getTree(): SessionTreeNode[] {
|
|
3356
|
-
|
|
3357
|
-
const nodeMap = new Map<string, SessionTreeNode>();
|
|
3358
|
-
const roots: SessionTreeNode[] = [];
|
|
3359
|
-
|
|
3360
|
-
// Create nodes with resolved labels
|
|
3361
|
-
for (const entry of entries) {
|
|
3362
|
-
const label = this.#labelsById.get(entry.id);
|
|
3363
|
-
nodeMap.set(entry.id, { entry, children: [], label });
|
|
3364
|
-
}
|
|
3365
|
-
|
|
3366
|
-
// Build tree
|
|
3367
|
-
for (const entry of entries) {
|
|
3368
|
-
const node = nodeMap.get(entry.id)!;
|
|
3369
|
-
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
3370
|
-
roots.push(node);
|
|
3371
|
-
} else {
|
|
3372
|
-
const parent = nodeMap.get(entry.parentId);
|
|
3373
|
-
if (parent) {
|
|
3374
|
-
parent.children.push(node);
|
|
3375
|
-
} else {
|
|
3376
|
-
// Orphan - treat as root
|
|
3377
|
-
roots.push(node);
|
|
3378
|
-
}
|
|
3379
|
-
}
|
|
3380
|
-
}
|
|
3381
|
-
|
|
3382
|
-
// Sort children by timestamp (oldest first, newest at bottom)
|
|
3383
|
-
// Use iterative approach to avoid stack overflow on deep trees
|
|
3384
|
-
const stack: SessionTreeNode[] = [...roots];
|
|
3385
|
-
while (stack.length > 0) {
|
|
3386
|
-
const node = stack.pop()!;
|
|
3387
|
-
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
3388
|
-
stack.push(...node.children);
|
|
3389
|
-
}
|
|
3390
|
-
|
|
3391
|
-
return roots;
|
|
1359
|
+
return this.#index.tree(this.#entries);
|
|
3392
1360
|
}
|
|
3393
1361
|
|
|
3394
|
-
// =========================================================================
|
|
3395
|
-
// Branching
|
|
3396
|
-
// =========================================================================
|
|
3397
|
-
|
|
3398
1362
|
/**
|
|
3399
|
-
*
|
|
3400
|
-
*
|
|
3401
|
-
* will create a child of that entry, forming a new branch. Existing entries
|
|
3402
|
-
* are not modified or deleted.
|
|
1363
|
+
* Move the leaf to an earlier entry so the next append forms a new branch.
|
|
1364
|
+
* Existing entries are never modified or deleted.
|
|
3403
1365
|
*/
|
|
3404
1366
|
branch(branchFromId: string): void {
|
|
3405
|
-
if (!this.#
|
|
3406
|
-
|
|
3407
|
-
}
|
|
3408
|
-
this.#leafId = branchFromId;
|
|
1367
|
+
if (!this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
|
1368
|
+
this.#index.setLeaf(branchFromId);
|
|
3409
1369
|
}
|
|
3410
1370
|
|
|
3411
|
-
/**
|
|
3412
|
-
* Reset the leaf pointer to null (before any entries).
|
|
3413
|
-
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
3414
|
-
* Use this when navigating to re-edit the first user message.
|
|
3415
|
-
*/
|
|
1371
|
+
/** Reset the leaf to null so the next append creates a new root entry. */
|
|
3416
1372
|
resetLeaf(): void {
|
|
3417
|
-
this.#
|
|
1373
|
+
this.#index.setLeaf(null);
|
|
3418
1374
|
}
|
|
3419
1375
|
|
|
3420
|
-
/**
|
|
3421
|
-
* Start a new branch with a summary of the abandoned path.
|
|
3422
|
-
* Same as branch(), but also appends a branch_summary entry that captures
|
|
3423
|
-
* context from the abandoned conversation path.
|
|
3424
|
-
*/
|
|
1376
|
+
/** Like branch(), but also records a branch_summary of the abandoned path. */
|
|
3425
1377
|
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
3426
|
-
if (branchFromId !== null && !this.#
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
this.#leafId = branchFromId;
|
|
1378
|
+
if (branchFromId !== null && !this.#index.has(branchFromId)) throw new Error(`Entry ${branchFromId} not found`);
|
|
1379
|
+
|
|
1380
|
+
this.#index.setLeaf(branchFromId);
|
|
3430
1381
|
const entry: BranchSummaryEntry = {
|
|
3431
1382
|
type: "branch_summary",
|
|
3432
|
-
id: generateId(this.#
|
|
1383
|
+
id: generateId(this.#index),
|
|
3433
1384
|
parentId: branchFromId,
|
|
3434
|
-
timestamp:
|
|
1385
|
+
timestamp: nowIso(),
|
|
3435
1386
|
fromId: branchFromId ?? "root",
|
|
3436
1387
|
summary,
|
|
3437
1388
|
details,
|
|
3438
1389
|
fromExtension,
|
|
3439
1390
|
};
|
|
3440
|
-
this.#
|
|
1391
|
+
this.#recordEntry(entry);
|
|
3441
1392
|
return entry.id;
|
|
3442
1393
|
}
|
|
3443
1394
|
|
|
3444
1395
|
/**
|
|
3445
|
-
* Create a new session file containing only the path from root to
|
|
3446
|
-
*
|
|
3447
|
-
* Returns the new session file path, or undefined if not persisting.
|
|
1396
|
+
* Create a new session file containing only the path from root to `leafId`.
|
|
1397
|
+
* Returns the new file path, or undefined when not persisting.
|
|
3448
1398
|
*/
|
|
3449
1399
|
createBranchedSession(leafId: string): string | undefined {
|
|
3450
|
-
const
|
|
1400
|
+
const sourceSessionFile = this.#sessionFile;
|
|
3451
1401
|
const branchPath = this.getBranch(leafId);
|
|
3452
|
-
if (branchPath.length === 0) {
|
|
3453
|
-
throw new Error(`Entry ${leafId} not found`);
|
|
3454
|
-
}
|
|
1402
|
+
if (branchPath.length === 0) throw new Error(`Entry ${leafId} not found`);
|
|
3455
1403
|
|
|
3456
|
-
//
|
|
3457
|
-
const
|
|
3458
|
-
|
|
3459
|
-
const
|
|
3460
|
-
const
|
|
3461
|
-
|
|
3462
|
-
|
|
1404
|
+
// Drop label entries from the path; recreate them fresh from the resolved map.
|
|
1405
|
+
const entriesToKeep = branchPath.filter(entry => entry.type !== "label");
|
|
1406
|
+
const keptIds = new Set(entriesToKeep.map(entry => entry.id));
|
|
1407
|
+
const labelsToCarry: Array<{ targetId: string; label: string }> = [];
|
|
1408
|
+
for (const [targetId, label] of this.#index.labelsInEffect()) {
|
|
1409
|
+
if (keptIds.has(targetId)) labelsToCarry.push({ targetId, label });
|
|
1410
|
+
}
|
|
3463
1411
|
|
|
1412
|
+
const timestamp = nowIso();
|
|
1413
|
+
const newSessionId = mintSessionId();
|
|
1414
|
+
const newSessionFile = path.join(this.#sessionDir, `${fileSafeTimestamp(timestamp)}_${newSessionId}.jsonl`);
|
|
3464
1415
|
const header: SessionHeader = {
|
|
3465
1416
|
type: "session",
|
|
3466
1417
|
version: CURRENT_SESSION_VERSION,
|
|
3467
1418
|
id: newSessionId,
|
|
3468
1419
|
timestamp,
|
|
3469
|
-
cwd: this
|
|
3470
|
-
parentSession: this
|
|
1420
|
+
cwd: this.#cwd,
|
|
1421
|
+
parentSession: this.#persist ? sourceSessionFile : undefined,
|
|
3471
1422
|
};
|
|
3472
1423
|
|
|
3473
|
-
|
|
3474
|
-
|
|
3475
|
-
const
|
|
3476
|
-
for (const [targetId, label] of this.#labelsById) {
|
|
3477
|
-
if (pathEntryIds.has(targetId)) {
|
|
3478
|
-
labelsToWrite.push({ targetId, label });
|
|
3479
|
-
}
|
|
3480
|
-
}
|
|
3481
|
-
|
|
3482
|
-
if (this.persist) {
|
|
3483
|
-
const lines: string[] = [];
|
|
3484
|
-
lines.push(JSON.stringify(header));
|
|
3485
|
-
for (const entry of pathWithoutLabels) {
|
|
3486
|
-
lines.push(JSON.stringify(entry));
|
|
3487
|
-
}
|
|
3488
|
-
// Write fresh label entries at the end
|
|
3489
|
-
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
3490
|
-
let parentId = lastEntryId;
|
|
3491
|
-
const labelEntries: LabelEntry[] = [];
|
|
3492
|
-
for (const { targetId, label } of labelsToWrite) {
|
|
3493
|
-
const labelEntry: LabelEntry = {
|
|
3494
|
-
type: "label",
|
|
3495
|
-
id: generateId(new Set(pathEntryIds)),
|
|
3496
|
-
parentId,
|
|
3497
|
-
timestamp: new Date().toISOString(),
|
|
3498
|
-
targetId,
|
|
3499
|
-
label,
|
|
3500
|
-
};
|
|
3501
|
-
lines.push(JSON.stringify(labelEntry));
|
|
3502
|
-
pathEntryIds.add(labelEntry.id);
|
|
3503
|
-
labelEntries.push(labelEntry);
|
|
3504
|
-
parentId = labelEntry.id;
|
|
3505
|
-
}
|
|
3506
|
-
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
3507
|
-
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
3508
|
-
this.#sessionId = newSessionId;
|
|
3509
|
-
this.#sessionFile = newSessionFile;
|
|
3510
|
-
this.#flushed = true;
|
|
3511
|
-
this.#buildIndex();
|
|
3512
|
-
return newSessionFile;
|
|
3513
|
-
}
|
|
3514
|
-
|
|
3515
|
-
// In-memory mode: replace current session with the path + labels
|
|
3516
|
-
const labelEntries: LabelEntry[] = [];
|
|
3517
|
-
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
3518
|
-
for (const { targetId, label } of labelsToWrite) {
|
|
1424
|
+
const labels: LabelEntry[] = [];
|
|
1425
|
+
let parentId = entriesToKeep[entriesToKeep.length - 1]?.id ?? null;
|
|
1426
|
+
for (const carried of labelsToCarry) {
|
|
3519
1427
|
const labelEntry: LabelEntry = {
|
|
3520
1428
|
type: "label",
|
|
3521
|
-
id: generateId(new Set([...
|
|
1429
|
+
id: generateId(new Set([...keptIds, ...labels.map(entry => entry.id)])),
|
|
3522
1430
|
parentId,
|
|
3523
|
-
timestamp:
|
|
3524
|
-
targetId,
|
|
3525
|
-
label,
|
|
1431
|
+
timestamp: nowIso(),
|
|
1432
|
+
targetId: carried.targetId,
|
|
1433
|
+
label: carried.label,
|
|
3526
1434
|
};
|
|
3527
|
-
|
|
1435
|
+
labels.push(labelEntry);
|
|
3528
1436
|
parentId = labelEntry.id;
|
|
3529
1437
|
}
|
|
3530
|
-
|
|
1438
|
+
|
|
1439
|
+
this.#header = header;
|
|
1440
|
+
this.#entries = [...entriesToKeep, ...labels];
|
|
3531
1441
|
this.#sessionId = newSessionId;
|
|
3532
|
-
this.#
|
|
3533
|
-
|
|
1442
|
+
this.#sessionName = header.title;
|
|
1443
|
+
this.#titleSource = header.titleSource;
|
|
1444
|
+
this.#index.rebuild(this.#entries);
|
|
1445
|
+
this.#artifactManager = null;
|
|
1446
|
+
this.#artifactManagerSessionFile = null;
|
|
1447
|
+
this.#forceFileCreation = this.#persist;
|
|
1448
|
+
|
|
1449
|
+
if (!this.#persist) {
|
|
1450
|
+
this.#sessionFile = undefined;
|
|
1451
|
+
this.#fileIsCurrent = false;
|
|
1452
|
+
this.#rewriteRequired = false;
|
|
1453
|
+
return undefined;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
this.#sessionFile = newSessionFile;
|
|
1457
|
+
this.#rewriteSynchronously();
|
|
1458
|
+
this.#rememberBreadcrumb(this.#cwd, newSessionFile);
|
|
1459
|
+
return newSessionFile;
|
|
3534
1460
|
}
|
|
3535
1461
|
|
|
3536
|
-
/**
|
|
3537
|
-
* Resolve the canonical default session directory for a cwd.
|
|
3538
|
-
*/
|
|
1462
|
+
/** Resolve the canonical default session directory for a cwd. */
|
|
3539
1463
|
static getDefaultSessionDir(
|
|
3540
1464
|
cwd: string,
|
|
3541
1465
|
agentDir?: string,
|
|
@@ -3546,19 +1470,19 @@ export class SessionManager {
|
|
|
3546
1470
|
|
|
3547
1471
|
/**
|
|
3548
1472
|
* Create a new session.
|
|
3549
|
-
* @param cwd Working directory (stored in session header)
|
|
3550
|
-
* @param sessionDir Optional session directory
|
|
1473
|
+
* @param cwd Working directory (stored in the session header)
|
|
1474
|
+
* @param sessionDir Optional session directory; defaults to the cwd-derived dir.
|
|
3551
1475
|
*/
|
|
3552
1476
|
static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
|
|
3553
1477
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3554
1478
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3555
|
-
manager.#
|
|
1479
|
+
manager.#resetToNewSession();
|
|
3556
1480
|
return manager;
|
|
3557
1481
|
}
|
|
3558
1482
|
|
|
3559
1483
|
/**
|
|
3560
|
-
* Fork a session into the current project directory
|
|
3561
|
-
*
|
|
1484
|
+
* Fork a session into the current project directory: copy history from another
|
|
1485
|
+
* session file while creating a fresh session file in this sessionDir.
|
|
3562
1486
|
*/
|
|
3563
1487
|
static async forkFrom(
|
|
3564
1488
|
sourcePath: string,
|
|
@@ -3570,123 +1494,119 @@ export class SessionManager {
|
|
|
3570
1494
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3571
1495
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3572
1496
|
manager.#suppressBreadcrumb = options?.suppressBreadcrumb === true;
|
|
3573
|
-
|
|
3574
|
-
|
|
3575
|
-
|
|
3576
|
-
|
|
3577
|
-
|
|
3578
|
-
|
|
3579
|
-
const
|
|
3580
|
-
|
|
3581
|
-
|
|
3582
|
-
manager.#
|
|
3583
|
-
manager.#sessionName =
|
|
3584
|
-
manager.#titleSource =
|
|
1497
|
+
|
|
1498
|
+
const sourceEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
|
|
1499
|
+
migrateToCurrentVersion(sourceEntries);
|
|
1500
|
+
await resolveBlobRefsInEntries(sourceEntries, manager.#blobs);
|
|
1501
|
+
|
|
1502
|
+
const sourceHeader = sourceEntries.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
1503
|
+
const history = sourceEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
1504
|
+
manager.#resetToNewSession({ parentSession: sourceHeader?.id });
|
|
1505
|
+
manager.#header.title = sourceHeader?.title;
|
|
1506
|
+
manager.#header.titleSource = sourceHeader?.titleSource;
|
|
1507
|
+
manager.#sessionName = manager.#header.title;
|
|
1508
|
+
manager.#titleSource = manager.#header.titleSource;
|
|
1509
|
+
manager.#entries = history;
|
|
1510
|
+
manager.#index.rebuild(history);
|
|
3585
1511
|
manager.sanitizeLoadedOpenAIResponsesReplayMetadata();
|
|
3586
|
-
manager.#
|
|
3587
|
-
await manager.#
|
|
1512
|
+
manager.#forceFileCreation = true;
|
|
1513
|
+
await manager.#rewriteAtomically();
|
|
3588
1514
|
return manager;
|
|
3589
1515
|
}
|
|
3590
1516
|
|
|
3591
1517
|
/**
|
|
3592
1518
|
* Open a specific session file.
|
|
3593
|
-
* @param
|
|
3594
|
-
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
|
|
1519
|
+
* @param sessionDir Optional dir for /new or /branch; defaults to the file's parent.
|
|
3595
1520
|
*/
|
|
3596
1521
|
static async open(
|
|
3597
1522
|
filePath: string,
|
|
3598
1523
|
sessionDir?: string,
|
|
3599
1524
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3600
1525
|
): Promise<SessionManager> {
|
|
3601
|
-
|
|
3602
|
-
const
|
|
3603
|
-
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1526
|
+
const loaded = await loadEntriesFromFile(filePath, storage);
|
|
1527
|
+
const header = loaded.find(entry => entry.type === "session") as SessionHeader | undefined;
|
|
3604
1528
|
const cwd = header?.cwd ?? getProjectDir();
|
|
3605
|
-
|
|
3606
|
-
const dir = sessionDir ?? path.resolve(filePath, "..");
|
|
1529
|
+
const dir = sessionDir ?? path.dirname(path.resolve(filePath));
|
|
3607
1530
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3608
|
-
await manager
|
|
1531
|
+
await manager.setSessionFile(filePath);
|
|
3609
1532
|
return manager;
|
|
3610
1533
|
}
|
|
3611
1534
|
|
|
3612
|
-
/**
|
|
3613
|
-
* Continue the most recent session, or create new if none.
|
|
3614
|
-
* @param cwd Working directory
|
|
3615
|
-
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
|
|
3616
|
-
*/
|
|
1535
|
+
/** Continue the most recent session, or create a new one if none exists. */
|
|
3617
1536
|
static async continueRecent(
|
|
3618
1537
|
cwd: string,
|
|
3619
1538
|
sessionDir?: string,
|
|
3620
1539
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3621
1540
|
): Promise<SessionManager> {
|
|
3622
1541
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3623
|
-
// Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
|
|
3624
|
-
const breadcrumb = await readTerminalBreadcrumbEntry();
|
|
3625
|
-
const breadcrumbCwd = breadcrumb ? path.resolve(breadcrumb.cwd) : undefined;
|
|
3626
1542
|
const resolvedCwd = path.resolve(cwd);
|
|
3627
|
-
|
|
3628
|
-
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3642
|
-
|
|
3643
|
-
|
|
3644
|
-
|
|
3645
|
-
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
1543
|
+
const breadcrumb = await readTerminalBreadcrumbEntry();
|
|
1544
|
+
let chosenSession: string | null | undefined;
|
|
1545
|
+
|
|
1546
|
+
if (breadcrumb) {
|
|
1547
|
+
const breadcrumbCwd = path.resolve(breadcrumb.cwd);
|
|
1548
|
+
if (breadcrumbCwd === resolvedCwd) {
|
|
1549
|
+
chosenSession = breadcrumb.sessionFile;
|
|
1550
|
+
} else {
|
|
1551
|
+
// The terminal's last session started in a different cwd. If that cwd is
|
|
1552
|
+
// gone (worktree move/rename) and this location has no sessions of its
|
|
1553
|
+
// own, re-root the moved session here instead of starting fresh. When an
|
|
1554
|
+
// explicit sessionDir is reused across the move, the stale breadcrumb file
|
|
1555
|
+
// may be the newest entry there; prefer a genuine current-cwd session.
|
|
1556
|
+
let newestInTargetDir = await findMostRecentSession(dir, storage);
|
|
1557
|
+
const breadcrumbFile = path.resolve(breadcrumb.sessionFile);
|
|
1558
|
+
const breadcrumbCwdMissing = !fs.existsSync(breadcrumbCwd);
|
|
1559
|
+
const newestIsBreadcrumb = newestInTargetDir ? path.resolve(newestInTargetDir) === breadcrumbFile : false;
|
|
1560
|
+
let currentProjectAlreadyHasSession = false;
|
|
1561
|
+
|
|
1562
|
+
if (breadcrumbCwdMissing && newestIsBreadcrumb) {
|
|
1563
|
+
const localSession = (await SessionManager.list(cwd, dir, storage)).find(
|
|
1564
|
+
session =>
|
|
1565
|
+
path.resolve(session.path) !== breadcrumbFile &&
|
|
1566
|
+
session.cwd &&
|
|
1567
|
+
path.resolve(session.cwd) === resolvedCwd,
|
|
1568
|
+
);
|
|
1569
|
+
if (localSession) {
|
|
1570
|
+
newestInTargetDir = localSession.path;
|
|
1571
|
+
currentProjectAlreadyHasSession = true;
|
|
1572
|
+
}
|
|
3655
1573
|
}
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
1574
|
+
|
|
1575
|
+
const looksLikeMovedProject =
|
|
1576
|
+
breadcrumbCwdMissing &&
|
|
1577
|
+
(newestInTargetDir === null || (newestIsBreadcrumb && !currentProjectAlreadyHasSession));
|
|
1578
|
+
if (looksLikeMovedProject) {
|
|
1579
|
+
logger.info("Re-rooting moved session", { from: breadcrumbCwd, to: resolvedCwd });
|
|
1580
|
+
const manager = await SessionManager.open(breadcrumb.sessionFile, undefined, storage);
|
|
1581
|
+
await manager.moveTo(cwd, sessionDir);
|
|
1582
|
+
return manager;
|
|
1583
|
+
}
|
|
1584
|
+
|
|
1585
|
+
chosenSession = newestInTargetDir;
|
|
3663
1586
|
}
|
|
3664
1587
|
}
|
|
3665
|
-
|
|
3666
|
-
if (
|
|
1588
|
+
|
|
1589
|
+
if (chosenSession === undefined) chosenSession = await findMostRecentSession(dir, storage);
|
|
1590
|
+
|
|
3667
1591
|
const manager = new SessionManager(cwd, dir, true, storage);
|
|
3668
|
-
if (
|
|
3669
|
-
|
|
3670
|
-
} else {
|
|
3671
|
-
manager.#initNewSession();
|
|
3672
|
-
}
|
|
1592
|
+
if (chosenSession) await manager.setSessionFile(chosenSession);
|
|
1593
|
+
else manager.#resetToNewSession();
|
|
3673
1594
|
return manager;
|
|
3674
1595
|
}
|
|
3675
1596
|
|
|
3676
|
-
/** Create an in-memory session (no file persistence) */
|
|
1597
|
+
/** Create an in-memory session (no file persistence). */
|
|
3677
1598
|
static inMemory(
|
|
3678
1599
|
cwd: string = getProjectDir(),
|
|
3679
1600
|
storage: SessionStorage = new MemorySessionStorage(),
|
|
3680
1601
|
): SessionManager {
|
|
3681
1602
|
const manager = new SessionManager(cwd, "", false, storage);
|
|
3682
|
-
manager.#
|
|
1603
|
+
manager.#resetToNewSession();
|
|
3683
1604
|
return manager;
|
|
3684
1605
|
}
|
|
3685
1606
|
|
|
3686
1607
|
/**
|
|
3687
|
-
* List
|
|
3688
|
-
* @param
|
|
3689
|
-
* @param sessionDir Optional session directory. If omitted, uses default (~/.omp/agent/sessions/<encoded-cwd>/).
|
|
1608
|
+
* List sessions for a project directory.
|
|
1609
|
+
* @param sessionDir Optional dir; defaults to the cwd-derived dir.
|
|
3690
1610
|
*/
|
|
3691
1611
|
static async list(
|
|
3692
1612
|
cwd: string,
|
|
@@ -3694,27 +1614,11 @@ export class SessionManager {
|
|
|
3694
1614
|
storage: SessionStorage = new FileSessionStorage(),
|
|
3695
1615
|
): Promise<SessionInfo[]> {
|
|
3696
1616
|
const dir = sessionDir ?? SessionManager.getDefaultSessionDir(cwd, undefined, storage);
|
|
3697
|
-
|
|
3698
|
-
await recoverOrphanedBackups(dir, storage);
|
|
3699
|
-
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
3700
|
-
return await collectSessionsFromFiles(files, storage);
|
|
3701
|
-
} catch {
|
|
3702
|
-
return [];
|
|
3703
|
-
}
|
|
1617
|
+
return listSessions(dir, storage);
|
|
3704
1618
|
}
|
|
3705
1619
|
|
|
3706
|
-
/**
|
|
3707
|
-
|
|
3708
|
-
|
|
3709
|
-
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
3710
|
-
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
3711
|
-
try {
|
|
3712
|
-
const files = await Array.fromAsync(new Bun.Glob("*/*.jsonl").scan(sessionsRoot), name =>
|
|
3713
|
-
path.join(sessionsRoot, name),
|
|
3714
|
-
);
|
|
3715
|
-
return await collectSessionsFromFiles(files, storage);
|
|
3716
|
-
} catch {
|
|
3717
|
-
return [];
|
|
3718
|
-
}
|
|
1620
|
+
/** List all sessions across all project directories. */
|
|
1621
|
+
static listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
1622
|
+
return listAllSessions(storage);
|
|
3719
1623
|
}
|
|
3720
1624
|
}
|