@nghyane/arcane 0.1.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 +3 -0
- package/README.md +12 -0
- package/examples/README.md +21 -0
- package/examples/custom-tools/README.md +109 -0
- package/examples/custom-tools/hello/index.ts +20 -0
- package/examples/custom-tools/todo/index.ts +206 -0
- package/examples/extensions/README.md +143 -0
- package/examples/extensions/api-demo.ts +89 -0
- package/examples/extensions/chalk-logger.ts +25 -0
- package/examples/extensions/hello.ts +32 -0
- package/examples/extensions/pirate.ts +43 -0
- package/examples/extensions/plan-mode.ts +550 -0
- package/examples/extensions/reload-runtime.ts +37 -0
- package/examples/extensions/todo.ts +296 -0
- package/examples/extensions/tools.ts +144 -0
- package/examples/extensions/with-deps/index.ts +35 -0
- package/examples/extensions/with-deps/package-lock.json +31 -0
- package/examples/extensions/with-deps/package.json +16 -0
- package/examples/hooks/README.md +56 -0
- package/examples/hooks/auto-commit-on-exit.ts +48 -0
- package/examples/hooks/confirm-destructive.ts +58 -0
- package/examples/hooks/custom-compaction.ts +116 -0
- package/examples/hooks/dirty-repo-guard.ts +51 -0
- package/examples/hooks/file-trigger.ts +40 -0
- package/examples/hooks/git-checkpoint.ts +52 -0
- package/examples/hooks/handoff.ts +150 -0
- package/examples/hooks/permission-gate.ts +33 -0
- package/examples/hooks/protected-paths.ts +29 -0
- package/examples/hooks/qna.ts +119 -0
- package/examples/hooks/status-line.ts +39 -0
- package/examples/sdk/01-minimal.ts +21 -0
- package/examples/sdk/02-custom-model.ts +49 -0
- package/examples/sdk/03-custom-prompt.ts +43 -0
- package/examples/sdk/04-skills.ts +43 -0
- package/examples/sdk/06-extensions.ts +80 -0
- package/examples/sdk/06-hooks.ts +61 -0
- package/examples/sdk/07-context-files.ts +35 -0
- package/examples/sdk/08-prompt-templates.ts +36 -0
- package/examples/sdk/08-slash-commands.ts +41 -0
- package/examples/sdk/09-api-keys-and-oauth.ts +54 -0
- package/examples/sdk/11-sessions.ts +47 -0
- package/examples/sdk/README.md +150 -0
- package/package.json +464 -0
- package/scripts/format-prompts.ts +184 -0
- package/scripts/generate-docs-index.ts +40 -0
- package/scripts/generate-template.ts +32 -0
- package/src/bun-imports.d.ts +22 -0
- package/src/capability/context-file.ts +39 -0
- package/src/capability/extension-module.ts +33 -0
- package/src/capability/extension.ts +47 -0
- package/src/capability/fs.ts +89 -0
- package/src/capability/hook.ts +39 -0
- package/src/capability/index.ts +432 -0
- package/src/capability/instruction.ts +36 -0
- package/src/capability/mcp.ts +60 -0
- package/src/capability/prompt.ts +34 -0
- package/src/capability/rule.ts +223 -0
- package/src/capability/settings.ts +34 -0
- package/src/capability/skill.ts +48 -0
- package/src/capability/slash-command.ts +39 -0
- package/src/capability/ssh.ts +41 -0
- package/src/capability/system-prompt.ts +34 -0
- package/src/capability/tool.ts +37 -0
- package/src/capability/types.ts +156 -0
- package/src/cli/args.ts +259 -0
- package/src/cli/config-cli.ts +357 -0
- package/src/cli/file-processor.ts +124 -0
- package/src/cli/grep-cli.ts +152 -0
- package/src/cli/jupyter-cli.ts +106 -0
- package/src/cli/list-models.ts +103 -0
- package/src/cli/plugin-cli.ts +661 -0
- package/src/cli/session-picker.ts +42 -0
- package/src/cli/setup-cli.ts +376 -0
- package/src/cli/shell-cli.ts +174 -0
- package/src/cli/ssh-cli.ts +179 -0
- package/src/cli/stats-cli.ts +197 -0
- package/src/cli/update-cli.ts +286 -0
- package/src/cli/web-search-cli.ts +143 -0
- package/src/cli.ts +65 -0
- package/src/commands/commit.ts +36 -0
- package/src/commands/config.ts +51 -0
- package/src/commands/grep.ts +41 -0
- package/src/commands/jupyter.ts +32 -0
- package/src/commands/launch.ts +139 -0
- package/src/commands/plugin.ts +70 -0
- package/src/commands/setup.ts +42 -0
- package/src/commands/shell.ts +29 -0
- package/src/commands/ssh.ts +60 -0
- package/src/commands/stats.ts +29 -0
- package/src/commands/update.ts +21 -0
- package/src/commands/web-search.ts +42 -0
- package/src/commit/agentic/agent.ts +311 -0
- package/src/commit/agentic/fallback.ts +96 -0
- package/src/commit/agentic/index.ts +359 -0
- package/src/commit/agentic/prompts/analyze-file.md +22 -0
- package/src/commit/agentic/prompts/session-user.md +25 -0
- package/src/commit/agentic/prompts/split-confirm.md +1 -0
- package/src/commit/agentic/prompts/system.md +38 -0
- package/src/commit/agentic/state.ts +69 -0
- package/src/commit/agentic/tools/analyze-file.ts +118 -0
- package/src/commit/agentic/tools/git-file-diff.ts +194 -0
- package/src/commit/agentic/tools/git-hunk.ts +50 -0
- package/src/commit/agentic/tools/git-overview.ts +84 -0
- package/src/commit/agentic/tools/index.ts +56 -0
- package/src/commit/agentic/tools/propose-changelog.ts +128 -0
- package/src/commit/agentic/tools/propose-commit.ts +154 -0
- package/src/commit/agentic/tools/recent-commits.ts +81 -0
- package/src/commit/agentic/tools/split-commit.ts +280 -0
- package/src/commit/agentic/topo-sort.ts +44 -0
- package/src/commit/agentic/trivial.ts +51 -0
- package/src/commit/agentic/validation.ts +200 -0
- package/src/commit/analysis/conventional.ts +165 -0
- package/src/commit/analysis/index.ts +4 -0
- package/src/commit/analysis/scope.ts +242 -0
- package/src/commit/analysis/summary.ts +112 -0
- package/src/commit/analysis/validation.ts +66 -0
- package/src/commit/changelog/detect.ts +37 -0
- package/src/commit/changelog/generate.ts +110 -0
- package/src/commit/changelog/index.ts +234 -0
- package/src/commit/changelog/parse.ts +44 -0
- package/src/commit/cli.ts +93 -0
- package/src/commit/git/diff.ts +148 -0
- package/src/commit/git/errors.ts +9 -0
- package/src/commit/git/index.ts +211 -0
- package/src/commit/git/operations.ts +54 -0
- package/src/commit/index.ts +5 -0
- package/src/commit/map-reduce/index.ts +64 -0
- package/src/commit/map-reduce/map-phase.ts +178 -0
- package/src/commit/map-reduce/reduce-phase.ts +145 -0
- package/src/commit/map-reduce/utils.ts +9 -0
- package/src/commit/message.ts +11 -0
- package/src/commit/model-selection.ts +69 -0
- package/src/commit/pipeline.ts +243 -0
- package/src/commit/prompts/analysis-system.md +148 -0
- package/src/commit/prompts/analysis-user.md +38 -0
- package/src/commit/prompts/changelog-system.md +50 -0
- package/src/commit/prompts/changelog-user.md +18 -0
- package/src/commit/prompts/file-observer-system.md +24 -0
- package/src/commit/prompts/file-observer-user.md +8 -0
- package/src/commit/prompts/reduce-system.md +50 -0
- package/src/commit/prompts/reduce-user.md +17 -0
- package/src/commit/prompts/summary-retry.md +3 -0
- package/src/commit/prompts/summary-system.md +38 -0
- package/src/commit/prompts/summary-user.md +13 -0
- package/src/commit/prompts/types-description.md +2 -0
- package/src/commit/types.ts +109 -0
- package/src/commit/utils/exclusions.ts +42 -0
- package/src/config/file-lock.ts +121 -0
- package/src/config/keybindings.ts +280 -0
- package/src/config/model-registry.ts +1140 -0
- package/src/config/model-resolver.ts +812 -0
- package/src/config/prompt-templates.ts +526 -0
- package/src/config/resolve-config-value.ts +92 -0
- package/src/config/settings-schema.ts +1236 -0
- package/src/config/settings.ts +706 -0
- package/src/config.ts +414 -0
- package/src/cursor.ts +239 -0
- package/src/debug/index.ts +431 -0
- package/src/debug/log-formatting.ts +60 -0
- package/src/debug/log-viewer.ts +903 -0
- package/src/debug/profiler.ts +158 -0
- package/src/debug/report-bundle.ts +366 -0
- package/src/debug/system-info.ts +112 -0
- package/src/discovery/agents-md.ts +68 -0
- package/src/discovery/agents.ts +199 -0
- package/src/discovery/builtin.ts +815 -0
- package/src/discovery/claude-plugins.ts +205 -0
- package/src/discovery/claude.ts +506 -0
- package/src/discovery/cline.ts +83 -0
- package/src/discovery/codex.ts +532 -0
- package/src/discovery/cursor.ts +218 -0
- package/src/discovery/gemini.ts +395 -0
- package/src/discovery/github.ts +117 -0
- package/src/discovery/helpers.ts +698 -0
- package/src/discovery/index.ts +89 -0
- package/src/discovery/mcp-json.ts +156 -0
- package/src/discovery/opencode.ts +394 -0
- package/src/discovery/ssh.ts +160 -0
- package/src/discovery/vscode.ts +103 -0
- package/src/discovery/windsurf.ts +145 -0
- package/src/exa/company.ts +57 -0
- package/src/exa/index.ts +62 -0
- package/src/exa/linkedin.ts +57 -0
- package/src/exa/mcp-client.ts +289 -0
- package/src/exa/render.ts +244 -0
- package/src/exa/researcher.ts +89 -0
- package/src/exa/search.ts +330 -0
- package/src/exa/types.ts +166 -0
- package/src/exa/websets.ts +247 -0
- package/src/exec/bash-executor.ts +184 -0
- package/src/exec/exec.ts +53 -0
- package/src/export/custom-share.ts +65 -0
- package/src/export/html/index.ts +162 -0
- package/src/export/html/template.css +889 -0
- package/src/export/html/template.generated.ts +2 -0
- package/src/export/html/template.html +45 -0
- package/src/export/html/template.js +1329 -0
- package/src/export/html/template.macro.ts +24 -0
- package/src/export/html/vendor/highlight.min.js +1213 -0
- package/src/export/html/vendor/marked.min.js +6 -0
- package/src/export/ttsr.ts +434 -0
- package/src/extensibility/custom-commands/bundled/review/index.ts +433 -0
- package/src/extensibility/custom-commands/index.ts +15 -0
- package/src/extensibility/custom-commands/loader.ts +231 -0
- package/src/extensibility/custom-commands/types.ts +111 -0
- package/src/extensibility/custom-tools/index.ts +22 -0
- package/src/extensibility/custom-tools/loader.ts +235 -0
- package/src/extensibility/custom-tools/types.ts +226 -0
- package/src/extensibility/custom-tools/wrapper.ts +45 -0
- package/src/extensibility/extensions/index.ts +136 -0
- package/src/extensibility/extensions/loader.ts +520 -0
- package/src/extensibility/extensions/runner.ts +774 -0
- package/src/extensibility/extensions/types.ts +1293 -0
- package/src/extensibility/extensions/wrapper.ts +188 -0
- package/src/extensibility/hooks/index.ts +16 -0
- package/src/extensibility/hooks/loader.ts +273 -0
- package/src/extensibility/hooks/runner.ts +441 -0
- package/src/extensibility/hooks/tool-wrapper.ts +106 -0
- package/src/extensibility/hooks/types.ts +817 -0
- package/src/extensibility/plugins/doctor.ts +65 -0
- package/src/extensibility/plugins/git-url.ts +281 -0
- package/src/extensibility/plugins/index.ts +33 -0
- package/src/extensibility/plugins/installer.ts +192 -0
- package/src/extensibility/plugins/loader.ts +338 -0
- package/src/extensibility/plugins/manager.ts +716 -0
- package/src/extensibility/plugins/parser.ts +105 -0
- package/src/extensibility/plugins/types.ts +190 -0
- package/src/extensibility/skills.ts +385 -0
- package/src/extensibility/slash-commands.ts +287 -0
- package/src/extensibility/tool-proxy.ts +25 -0
- package/src/index.ts +275 -0
- package/src/internal-urls/agent-protocol.ts +136 -0
- package/src/internal-urls/artifact-protocol.ts +97 -0
- package/src/internal-urls/docs-index.generated.ts +54 -0
- package/src/internal-urls/docs-protocol.ts +84 -0
- package/src/internal-urls/index.ts +31 -0
- package/src/internal-urls/json-query.ts +126 -0
- package/src/internal-urls/memory-protocol.ts +133 -0
- package/src/internal-urls/router.ts +70 -0
- package/src/internal-urls/rule-protocol.ts +55 -0
- package/src/internal-urls/skill-protocol.ts +111 -0
- package/src/internal-urls/types.ts +52 -0
- package/src/ipy/executor.ts +556 -0
- package/src/ipy/gateway-coordinator.ts +426 -0
- package/src/ipy/kernel.ts +892 -0
- package/src/ipy/modules.ts +109 -0
- package/src/ipy/prelude.py +831 -0
- package/src/ipy/prelude.ts +3 -0
- package/src/ipy/runtime.ts +222 -0
- package/src/lsp/client.ts +867 -0
- package/src/lsp/clients/biome-client.ts +202 -0
- package/src/lsp/clients/index.ts +50 -0
- package/src/lsp/clients/lsp-linter-client.ts +93 -0
- package/src/lsp/clients/swiftlint-client.ts +120 -0
- package/src/lsp/config.ts +397 -0
- package/src/lsp/defaults.json +464 -0
- package/src/lsp/edits.ts +109 -0
- package/src/lsp/index.ts +1268 -0
- package/src/lsp/lspmux.ts +250 -0
- package/src/lsp/render.ts +689 -0
- package/src/lsp/types.ts +414 -0
- package/src/lsp/utils.ts +549 -0
- package/src/main.ts +773 -0
- package/src/mcp/client.ts +239 -0
- package/src/mcp/config-writer.ts +215 -0
- package/src/mcp/config.ts +363 -0
- package/src/mcp/index.ts +55 -0
- package/src/mcp/json-rpc.ts +84 -0
- package/src/mcp/loader.ts +124 -0
- package/src/mcp/manager.ts +490 -0
- package/src/mcp/oauth-discovery.ts +274 -0
- package/src/mcp/oauth-flow.ts +229 -0
- package/src/mcp/render.ts +123 -0
- package/src/mcp/tool-bridge.ts +372 -0
- package/src/mcp/tool-cache.ts +121 -0
- package/src/mcp/transports/http.ts +332 -0
- package/src/mcp/transports/index.ts +6 -0
- package/src/mcp/transports/stdio.ts +281 -0
- package/src/mcp/types.ts +248 -0
- package/src/memories/index.ts +1099 -0
- package/src/memories/storage.ts +563 -0
- package/src/modes/components/agent-dashboard.ts +1130 -0
- package/src/modes/components/assistant-message.ts +144 -0
- package/src/modes/components/bash-execution.ts +218 -0
- package/src/modes/components/bordered-loader.ts +41 -0
- package/src/modes/components/branch-summary-message.ts +45 -0
- package/src/modes/components/codemode-group.ts +369 -0
- package/src/modes/components/compaction-summary-message.ts +51 -0
- package/src/modes/components/countdown-timer.ts +46 -0
- package/src/modes/components/custom-editor.ts +181 -0
- package/src/modes/components/custom-message.ts +91 -0
- package/src/modes/components/diff.ts +186 -0
- package/src/modes/components/dynamic-border.ts +25 -0
- package/src/modes/components/extensions/extension-dashboard.ts +325 -0
- package/src/modes/components/extensions/extension-list.ts +484 -0
- package/src/modes/components/extensions/index.ts +9 -0
- package/src/modes/components/extensions/inspector-panel.ts +321 -0
- package/src/modes/components/extensions/state-manager.ts +586 -0
- package/src/modes/components/extensions/types.ts +191 -0
- package/src/modes/components/footer.ts +315 -0
- package/src/modes/components/history-search.ts +157 -0
- package/src/modes/components/hook-editor.ts +101 -0
- package/src/modes/components/hook-input.ts +72 -0
- package/src/modes/components/hook-message.ts +100 -0
- package/src/modes/components/hook-selector.ts +155 -0
- package/src/modes/components/index.ts +41 -0
- package/src/modes/components/keybinding-hints.ts +65 -0
- package/src/modes/components/login-dialog.ts +164 -0
- package/src/modes/components/mcp-add-wizard.ts +1295 -0
- package/src/modes/components/model-selector.ts +625 -0
- package/src/modes/components/oauth-selector.ts +210 -0
- package/src/modes/components/plugin-settings.ts +477 -0
- package/src/modes/components/python-execution.ts +196 -0
- package/src/modes/components/queue-mode-selector.ts +56 -0
- package/src/modes/components/read-tool-group.ts +119 -0
- package/src/modes/components/session-selector.ts +242 -0
- package/src/modes/components/settings-defs.ts +340 -0
- package/src/modes/components/settings-selector.ts +529 -0
- package/src/modes/components/show-images-selector.ts +45 -0
- package/src/modes/components/skill-message.ts +90 -0
- package/src/modes/components/status-line/index.ts +4 -0
- package/src/modes/components/status-line/presets.ts +94 -0
- package/src/modes/components/status-line/segments.ts +352 -0
- package/src/modes/components/status-line/separators.ts +55 -0
- package/src/modes/components/status-line/types.ts +75 -0
- package/src/modes/components/status-line-segment-editor.ts +354 -0
- package/src/modes/components/status-line.ts +421 -0
- package/src/modes/components/theme-selector.ts +63 -0
- package/src/modes/components/thinking-selector.ts +64 -0
- package/src/modes/components/todo-display.ts +115 -0
- package/src/modes/components/todo-reminder.ts +40 -0
- package/src/modes/components/tool-execution.ts +703 -0
- package/src/modes/components/tree-selector.ts +904 -0
- package/src/modes/components/ttsr-notification.ts +80 -0
- package/src/modes/components/user-message-selector.ts +146 -0
- package/src/modes/components/user-message.ts +22 -0
- package/src/modes/components/visual-truncate.ts +63 -0
- package/src/modes/components/welcome.ts +247 -0
- package/src/modes/controllers/command-controller.ts +1120 -0
- package/src/modes/controllers/event-controller.ts +479 -0
- package/src/modes/controllers/extension-ui-controller.ts +778 -0
- package/src/modes/controllers/input-controller.ts +671 -0
- package/src/modes/controllers/mcp-command-controller.ts +1315 -0
- package/src/modes/controllers/selector-controller.ts +712 -0
- package/src/modes/controllers/ssh-command-controller.ts +452 -0
- package/src/modes/index.ts +15 -0
- package/src/modes/interactive-mode.ts +1027 -0
- package/src/modes/print-mode.ts +191 -0
- package/src/modes/rpc/rpc-client.ts +583 -0
- package/src/modes/rpc/rpc-mode.ts +700 -0
- package/src/modes/rpc/rpc-types.ts +236 -0
- package/src/modes/theme/dark.json +95 -0
- package/src/modes/theme/defaults/alabaster.json +93 -0
- package/src/modes/theme/defaults/amethyst.json +96 -0
- package/src/modes/theme/defaults/anthracite.json +93 -0
- package/src/modes/theme/defaults/basalt.json +91 -0
- package/src/modes/theme/defaults/birch.json +95 -0
- package/src/modes/theme/defaults/dark-abyss.json +91 -0
- package/src/modes/theme/defaults/dark-arctic.json +104 -0
- package/src/modes/theme/defaults/dark-aurora.json +95 -0
- package/src/modes/theme/defaults/dark-catppuccin.json +107 -0
- package/src/modes/theme/defaults/dark-cavern.json +91 -0
- package/src/modes/theme/defaults/dark-copper.json +95 -0
- package/src/modes/theme/defaults/dark-cosmos.json +90 -0
- package/src/modes/theme/defaults/dark-cyberpunk.json +102 -0
- package/src/modes/theme/defaults/dark-dracula.json +98 -0
- package/src/modes/theme/defaults/dark-eclipse.json +91 -0
- package/src/modes/theme/defaults/dark-ember.json +95 -0
- package/src/modes/theme/defaults/dark-equinox.json +90 -0
- package/src/modes/theme/defaults/dark-forest.json +96 -0
- package/src/modes/theme/defaults/dark-github.json +105 -0
- package/src/modes/theme/defaults/dark-gruvbox.json +112 -0
- package/src/modes/theme/defaults/dark-lavender.json +95 -0
- package/src/modes/theme/defaults/dark-lunar.json +89 -0
- package/src/modes/theme/defaults/dark-midnight.json +95 -0
- package/src/modes/theme/defaults/dark-monochrome.json +94 -0
- package/src/modes/theme/defaults/dark-monokai.json +98 -0
- package/src/modes/theme/defaults/dark-nebula.json +90 -0
- package/src/modes/theme/defaults/dark-nord.json +97 -0
- package/src/modes/theme/defaults/dark-ocean.json +101 -0
- package/src/modes/theme/defaults/dark-one.json +100 -0
- package/src/modes/theme/defaults/dark-rainforest.json +91 -0
- package/src/modes/theme/defaults/dark-reef.json +91 -0
- package/src/modes/theme/defaults/dark-retro.json +92 -0
- package/src/modes/theme/defaults/dark-rose-pine.json +96 -0
- package/src/modes/theme/defaults/dark-sakura.json +95 -0
- package/src/modes/theme/defaults/dark-slate.json +95 -0
- package/src/modes/theme/defaults/dark-solarized.json +97 -0
- package/src/modes/theme/defaults/dark-solstice.json +90 -0
- package/src/modes/theme/defaults/dark-starfall.json +91 -0
- package/src/modes/theme/defaults/dark-sunset.json +99 -0
- package/src/modes/theme/defaults/dark-swamp.json +90 -0
- package/src/modes/theme/defaults/dark-synthwave.json +103 -0
- package/src/modes/theme/defaults/dark-taiga.json +91 -0
- package/src/modes/theme/defaults/dark-terminal.json +95 -0
- package/src/modes/theme/defaults/dark-tokyo-night.json +101 -0
- package/src/modes/theme/defaults/dark-tundra.json +91 -0
- package/src/modes/theme/defaults/dark-twilight.json +91 -0
- package/src/modes/theme/defaults/dark-volcanic.json +91 -0
- package/src/modes/theme/defaults/graphite.json +92 -0
- package/src/modes/theme/defaults/index.ts +195 -0
- package/src/modes/theme/defaults/light-arctic.json +107 -0
- package/src/modes/theme/defaults/light-aurora-day.json +91 -0
- package/src/modes/theme/defaults/light-canyon.json +91 -0
- package/src/modes/theme/defaults/light-catppuccin.json +106 -0
- package/src/modes/theme/defaults/light-cirrus.json +90 -0
- package/src/modes/theme/defaults/light-coral.json +95 -0
- package/src/modes/theme/defaults/light-cyberpunk.json +96 -0
- package/src/modes/theme/defaults/light-dawn.json +90 -0
- package/src/modes/theme/defaults/light-dunes.json +91 -0
- package/src/modes/theme/defaults/light-eucalyptus.json +95 -0
- package/src/modes/theme/defaults/light-forest.json +100 -0
- package/src/modes/theme/defaults/light-frost.json +95 -0
- package/src/modes/theme/defaults/light-github.json +115 -0
- package/src/modes/theme/defaults/light-glacier.json +91 -0
- package/src/modes/theme/defaults/light-gruvbox.json +108 -0
- package/src/modes/theme/defaults/light-haze.json +90 -0
- package/src/modes/theme/defaults/light-honeycomb.json +95 -0
- package/src/modes/theme/defaults/light-lagoon.json +91 -0
- package/src/modes/theme/defaults/light-lavender.json +95 -0
- package/src/modes/theme/defaults/light-meadow.json +91 -0
- package/src/modes/theme/defaults/light-mint.json +95 -0
- package/src/modes/theme/defaults/light-monochrome.json +101 -0
- package/src/modes/theme/defaults/light-ocean.json +99 -0
- package/src/modes/theme/defaults/light-one.json +99 -0
- package/src/modes/theme/defaults/light-opal.json +91 -0
- package/src/modes/theme/defaults/light-orchard.json +91 -0
- package/src/modes/theme/defaults/light-paper.json +95 -0
- package/src/modes/theme/defaults/light-prism.json +90 -0
- package/src/modes/theme/defaults/light-retro.json +98 -0
- package/src/modes/theme/defaults/light-sand.json +95 -0
- package/src/modes/theme/defaults/light-savanna.json +91 -0
- package/src/modes/theme/defaults/light-solarized.json +102 -0
- package/src/modes/theme/defaults/light-soleil.json +90 -0
- package/src/modes/theme/defaults/light-sunset.json +99 -0
- package/src/modes/theme/defaults/light-synthwave.json +98 -0
- package/src/modes/theme/defaults/light-tokyo-night.json +111 -0
- package/src/modes/theme/defaults/light-wetland.json +91 -0
- package/src/modes/theme/defaults/light-zenith.json +89 -0
- package/src/modes/theme/defaults/limestone.json +94 -0
- package/src/modes/theme/defaults/mahogany.json +97 -0
- package/src/modes/theme/defaults/marble.json +93 -0
- package/src/modes/theme/defaults/obsidian.json +91 -0
- package/src/modes/theme/defaults/onyx.json +91 -0
- package/src/modes/theme/defaults/pearl.json +93 -0
- package/src/modes/theme/defaults/porcelain.json +91 -0
- package/src/modes/theme/defaults/quartz.json +96 -0
- package/src/modes/theme/defaults/sandstone.json +95 -0
- package/src/modes/theme/defaults/titanium.json +90 -0
- package/src/modes/theme/light.json +93 -0
- package/src/modes/theme/mermaid-cache.ts +111 -0
- package/src/modes/theme/theme-schema.json +429 -0
- package/src/modes/theme/theme.ts +2333 -0
- package/src/modes/types.ts +216 -0
- package/src/modes/utils/ui-helpers.ts +529 -0
- package/src/patch/applicator.ts +1482 -0
- package/src/patch/diff.ts +425 -0
- package/src/patch/fuzzy.ts +784 -0
- package/src/patch/hashline.ts +972 -0
- package/src/patch/index.ts +964 -0
- package/src/patch/normalize.ts +397 -0
- package/src/patch/normative.ts +72 -0
- package/src/patch/parser.ts +532 -0
- package/src/patch/shared.ts +400 -0
- package/src/patch/types.ts +292 -0
- package/src/priority.json +35 -0
- package/src/prompts/agents/explore.md +48 -0
- package/src/prompts/agents/frontmatter.md +9 -0
- package/src/prompts/agents/init.md +36 -0
- package/src/prompts/agents/librarian.md +53 -0
- package/src/prompts/agents/oracle.md +51 -0
- package/src/prompts/agents/reviewer.md +70 -0
- package/src/prompts/agents/task.md +14 -0
- package/src/prompts/compaction/branch-summary-context.md +5 -0
- package/src/prompts/compaction/branch-summary-preamble.md +2 -0
- package/src/prompts/compaction/branch-summary.md +30 -0
- package/src/prompts/compaction/compaction-short-summary.md +9 -0
- package/src/prompts/compaction/compaction-summary-context.md +5 -0
- package/src/prompts/compaction/compaction-summary.md +38 -0
- package/src/prompts/compaction/compaction-turn-prefix.md +17 -0
- package/src/prompts/compaction/compaction-update-summary.md +45 -0
- package/src/prompts/memories/consolidation.md +30 -0
- package/src/prompts/memories/read_path.md +11 -0
- package/src/prompts/memories/stage_one_input.md +6 -0
- package/src/prompts/memories/stage_one_system.md +21 -0
- package/src/prompts/review-request.md +64 -0
- package/src/prompts/system/agent-creation-architect.md +65 -0
- package/src/prompts/system/agent-creation-user.md +6 -0
- package/src/prompts/system/custom-system-prompt.md +68 -0
- package/src/prompts/system/file-operations.md +10 -0
- package/src/prompts/system/subagent-submit-reminder.md +11 -0
- package/src/prompts/system/subagent-system-prompt.md +31 -0
- package/src/prompts/system/subagent-user-prompt.md +8 -0
- package/src/prompts/system/summarization-system.md +3 -0
- package/src/prompts/system/system-prompt.md +300 -0
- package/src/prompts/system/title-system.md +2 -0
- package/src/prompts/system/ttsr-interrupt.md +7 -0
- package/src/prompts/system/web-search.md +28 -0
- package/src/prompts/tools/ask.md +44 -0
- package/src/prompts/tools/bash.md +24 -0
- package/src/prompts/tools/browser.md +33 -0
- package/src/prompts/tools/calculator.md +12 -0
- package/src/prompts/tools/explore.md +29 -0
- package/src/prompts/tools/fetch.md +16 -0
- package/src/prompts/tools/find.md +18 -0
- package/src/prompts/tools/gemini-image.md +23 -0
- package/src/prompts/tools/grep.md +28 -0
- package/src/prompts/tools/hashline.md +232 -0
- package/src/prompts/tools/librarian.md +24 -0
- package/src/prompts/tools/lsp.md +28 -0
- package/src/prompts/tools/oracle.md +26 -0
- package/src/prompts/tools/patch.md +74 -0
- package/src/prompts/tools/python.md +66 -0
- package/src/prompts/tools/read.md +36 -0
- package/src/prompts/tools/replace.md +38 -0
- package/src/prompts/tools/reviewer.md +41 -0
- package/src/prompts/tools/ssh.md +51 -0
- package/src/prompts/tools/task-summary.md +28 -0
- package/src/prompts/tools/task.md +275 -0
- package/src/prompts/tools/todo-write.md +65 -0
- package/src/prompts/tools/undo-edit.md +7 -0
- package/src/prompts/tools/web-search.md +19 -0
- package/src/prompts/tools/write.md +18 -0
- package/src/sdk.ts +1287 -0
- package/src/secrets/index.ts +116 -0
- package/src/secrets/obfuscator.ts +269 -0
- package/src/secrets/regex.ts +21 -0
- package/src/session/agent-session.ts +4669 -0
- package/src/session/agent-storage.ts +621 -0
- package/src/session/artifacts.ts +132 -0
- package/src/session/auth-storage.ts +1433 -0
- package/src/session/blob-store.ts +103 -0
- package/src/session/compaction/branch-summarization.ts +315 -0
- package/src/session/compaction/compaction.ts +864 -0
- package/src/session/compaction/index.ts +7 -0
- package/src/session/compaction/pruning.ts +91 -0
- package/src/session/compaction/utils.ts +171 -0
- package/src/session/history-storage.ts +170 -0
- package/src/session/messages.ts +317 -0
- package/src/session/session-manager.ts +2276 -0
- package/src/session/session-storage.ts +342 -0
- package/src/session/streaming-output.ts +565 -0
- package/src/slash-commands/builtin-registry.ts +439 -0
- package/src/ssh/config-writer.ts +183 -0
- package/src/ssh/connection-manager.ts +444 -0
- package/src/ssh/ssh-executor.ts +127 -0
- package/src/ssh/sshfs-mount.ts +135 -0
- package/src/stt/downloader.ts +71 -0
- package/src/stt/index.ts +3 -0
- package/src/stt/recorder.ts +351 -0
- package/src/stt/setup.ts +52 -0
- package/src/stt/stt-controller.ts +160 -0
- package/src/stt/transcribe.py +70 -0
- package/src/stt/transcriber.ts +91 -0
- package/src/system-prompt.ts +685 -0
- package/src/task/agents.ts +155 -0
- package/src/task/batch.ts +102 -0
- package/src/task/commands.ts +134 -0
- package/src/task/discovery.ts +126 -0
- package/src/task/executor.ts +908 -0
- package/src/task/index.ts +223 -0
- package/src/task/output-manager.ts +107 -0
- package/src/task/parallel.ts +84 -0
- package/src/task/render.ts +326 -0
- package/src/task/subprocess-tool-registry.ts +88 -0
- package/src/task/template.ts +32 -0
- package/src/task/types.ts +144 -0
- package/src/tools/ask.ts +523 -0
- package/src/tools/bash-interactive.ts +419 -0
- package/src/tools/bash-interceptor.ts +105 -0
- package/src/tools/bash-normalize.ts +107 -0
- package/src/tools/bash-skill-urls.ts +177 -0
- package/src/tools/bash.ts +347 -0
- package/src/tools/browser.ts +1374 -0
- package/src/tools/calculator.ts +537 -0
- package/src/tools/context.ts +39 -0
- package/src/tools/explore.ts +23 -0
- package/src/tools/fetch.ts +1091 -0
- package/src/tools/find.ts +540 -0
- package/src/tools/fs-cache-invalidation.ts +28 -0
- package/src/tools/gemini-image.ts +907 -0
- package/src/tools/grep.ts +489 -0
- package/src/tools/index.ts +337 -0
- package/src/tools/json-tree.ts +231 -0
- package/src/tools/jtd-to-json-schema.ts +247 -0
- package/src/tools/jtd-to-typescript.ts +198 -0
- package/src/tools/librarian.ts +33 -0
- package/src/tools/list-limit.ts +40 -0
- package/src/tools/notebook.ts +287 -0
- package/src/tools/oracle.ts +40 -0
- package/src/tools/output-meta.ts +459 -0
- package/src/tools/output-utils.ts +63 -0
- package/src/tools/path-utils.ts +116 -0
- package/src/tools/puppeteer/00_stealth_tampering.txt +63 -0
- package/src/tools/puppeteer/01_stealth_activity.txt +20 -0
- package/src/tools/puppeteer/02_stealth_hairline.txt +11 -0
- package/src/tools/puppeteer/03_stealth_botd.txt +384 -0
- package/src/tools/puppeteer/04_stealth_iframe.txt +81 -0
- package/src/tools/puppeteer/05_stealth_webgl.txt +75 -0
- package/src/tools/puppeteer/06_stealth_screen.txt +72 -0
- package/src/tools/puppeteer/07_stealth_fonts.txt +97 -0
- package/src/tools/puppeteer/08_stealth_audio.txt +51 -0
- package/src/tools/puppeteer/09_stealth_locale.txt +46 -0
- package/src/tools/puppeteer/10_stealth_plugins.txt +206 -0
- package/src/tools/puppeteer/11_stealth_hardware.txt +8 -0
- package/src/tools/puppeteer/12_stealth_codecs.txt +40 -0
- package/src/tools/puppeteer/13_stealth_worker.txt +74 -0
- package/src/tools/python.ts +1118 -0
- package/src/tools/read.ts +1193 -0
- package/src/tools/render-utils.ts +680 -0
- package/src/tools/renderers.ts +60 -0
- package/src/tools/reviewer-tool.ts +41 -0
- package/src/tools/ssh.ts +326 -0
- package/src/tools/subagent-tool.ts +169 -0
- package/src/tools/submit-result.ts +152 -0
- package/src/tools/todo-write.ts +255 -0
- package/src/tools/tool-errors.ts +92 -0
- package/src/tools/tool-result.ts +86 -0
- package/src/tools/undo-edit.ts +145 -0
- package/src/tools/undo-history.ts +22 -0
- package/src/tools/write.ts +274 -0
- package/src/tui/code-cell.ts +108 -0
- package/src/tui/file-list.ts +47 -0
- package/src/tui/index.ts +11 -0
- package/src/tui/output-block.ts +144 -0
- package/src/tui/status-line.ts +39 -0
- package/src/tui/tree-list.ts +53 -0
- package/src/tui/types.ts +16 -0
- package/src/tui/utils.ts +116 -0
- package/src/utils/changelog.ts +98 -0
- package/src/utils/event-bus.ts +33 -0
- package/src/utils/external-editor.ts +59 -0
- package/src/utils/file-display-mode.ts +36 -0
- package/src/utils/file-mentions.ts +384 -0
- package/src/utils/frontmatter.ts +101 -0
- package/src/utils/fuzzy.ts +108 -0
- package/src/utils/ignore-files.ts +119 -0
- package/src/utils/image-convert.ts +27 -0
- package/src/utils/image-resize.ts +236 -0
- package/src/utils/mime.ts +30 -0
- package/src/utils/open.ts +20 -0
- package/src/utils/shell-snapshot.ts +199 -0
- package/src/utils/timings.ts +26 -0
- package/src/utils/title-generator.ts +167 -0
- package/src/utils/tools-manager.ts +362 -0
- package/src/web/scrapers/artifacthub.ts +215 -0
- package/src/web/scrapers/arxiv.ts +88 -0
- package/src/web/scrapers/aur.ts +175 -0
- package/src/web/scrapers/biorxiv.ts +141 -0
- package/src/web/scrapers/bluesky.ts +284 -0
- package/src/web/scrapers/brew.ts +177 -0
- package/src/web/scrapers/cheatsh.ts +78 -0
- package/src/web/scrapers/chocolatey.ts +158 -0
- package/src/web/scrapers/choosealicense.ts +110 -0
- package/src/web/scrapers/cisa-kev.ts +100 -0
- package/src/web/scrapers/clojars.ts +180 -0
- package/src/web/scrapers/coingecko.ts +184 -0
- package/src/web/scrapers/crates-io.ts +128 -0
- package/src/web/scrapers/crossref.ts +149 -0
- package/src/web/scrapers/devto.ts +177 -0
- package/src/web/scrapers/discogs.ts +307 -0
- package/src/web/scrapers/discourse.ts +221 -0
- package/src/web/scrapers/dockerhub.ts +160 -0
- package/src/web/scrapers/fdroid.ts +158 -0
- package/src/web/scrapers/firefox-addons.ts +214 -0
- package/src/web/scrapers/flathub.ts +239 -0
- package/src/web/scrapers/github-gist.ts +68 -0
- package/src/web/scrapers/github.ts +490 -0
- package/src/web/scrapers/gitlab.ts +456 -0
- package/src/web/scrapers/go-pkg.ts +275 -0
- package/src/web/scrapers/hackage.ts +94 -0
- package/src/web/scrapers/hackernews.ts +208 -0
- package/src/web/scrapers/hex.ts +121 -0
- package/src/web/scrapers/huggingface.ts +385 -0
- package/src/web/scrapers/iacr.ts +86 -0
- package/src/web/scrapers/index.ts +249 -0
- package/src/web/scrapers/jetbrains-marketplace.ts +169 -0
- package/src/web/scrapers/lemmy.ts +220 -0
- package/src/web/scrapers/lobsters.ts +186 -0
- package/src/web/scrapers/mastodon.ts +310 -0
- package/src/web/scrapers/maven.ts +152 -0
- package/src/web/scrapers/mdn.ts +172 -0
- package/src/web/scrapers/metacpan.ts +253 -0
- package/src/web/scrapers/musicbrainz.ts +272 -0
- package/src/web/scrapers/npm.ts +114 -0
- package/src/web/scrapers/nuget.ts +205 -0
- package/src/web/scrapers/nvd.ts +243 -0
- package/src/web/scrapers/ollama.ts +265 -0
- package/src/web/scrapers/open-vsx.ts +119 -0
- package/src/web/scrapers/opencorporates.ts +275 -0
- package/src/web/scrapers/openlibrary.ts +319 -0
- package/src/web/scrapers/orcid.ts +298 -0
- package/src/web/scrapers/osv.ts +192 -0
- package/src/web/scrapers/packagist.ts +174 -0
- package/src/web/scrapers/pub-dev.ts +185 -0
- package/src/web/scrapers/pubmed.ts +177 -0
- package/src/web/scrapers/pypi.ts +129 -0
- package/src/web/scrapers/rawg.ts +124 -0
- package/src/web/scrapers/readthedocs.ts +125 -0
- package/src/web/scrapers/reddit.ts +104 -0
- package/src/web/scrapers/repology.ts +262 -0
- package/src/web/scrapers/rfc.ts +209 -0
- package/src/web/scrapers/rubygems.ts +117 -0
- package/src/web/scrapers/searchcode.ts +217 -0
- package/src/web/scrapers/sec-edgar.ts +274 -0
- package/src/web/scrapers/semantic-scholar.ts +190 -0
- package/src/web/scrapers/snapcraft.ts +200 -0
- package/src/web/scrapers/sourcegraph.ts +373 -0
- package/src/web/scrapers/spdx.ts +121 -0
- package/src/web/scrapers/spotify.ts +217 -0
- package/src/web/scrapers/stackoverflow.ts +124 -0
- package/src/web/scrapers/terraform.ts +304 -0
- package/src/web/scrapers/tldr.ts +51 -0
- package/src/web/scrapers/twitter.ts +97 -0
- package/src/web/scrapers/types.ts +200 -0
- package/src/web/scrapers/utils.ts +142 -0
- package/src/web/scrapers/vimeo.ts +152 -0
- package/src/web/scrapers/vscode-marketplace.ts +195 -0
- package/src/web/scrapers/w3c.ts +163 -0
- package/src/web/scrapers/wikidata.ts +357 -0
- package/src/web/scrapers/wikipedia.ts +95 -0
- package/src/web/scrapers/youtube.ts +312 -0
- package/src/web/search/auth.ts +178 -0
- package/src/web/search/index.ts +598 -0
- package/src/web/search/provider.ts +77 -0
- package/src/web/search/providers/anthropic.ts +284 -0
- package/src/web/search/providers/base.ts +22 -0
- package/src/web/search/providers/brave.ts +165 -0
- package/src/web/search/providers/codex.ts +377 -0
- package/src/web/search/providers/exa.ts +158 -0
- package/src/web/search/providers/gemini.ts +437 -0
- package/src/web/search/providers/jina.ts +99 -0
- package/src/web/search/providers/kimi.ts +196 -0
- package/src/web/search/providers/perplexity.ts +546 -0
- package/src/web/search/providers/synthetic.ts +136 -0
- package/src/web/search/providers/zai.ts +352 -0
- package/src/web/search/render.ts +299 -0
- package/src/web/search/types.ts +437 -0
|
@@ -0,0 +1,2276 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as os from "node:os";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import type { AgentMessage } from "@nghyane/arcane-agent";
|
|
5
|
+
import type { ImageContent, Message, TextContent, Usage } from "@nghyane/arcane-ai";
|
|
6
|
+
import { getTerminalId } from "@nghyane/arcane-tui";
|
|
7
|
+
import { isEnoent, logger, parseJsonlLenient, Snowflake } from "@nghyane/arcane-utils";
|
|
8
|
+
import { getBlobsDir, getAgentDir as getDefaultAgentDir, getProjectDir } from "@nghyane/arcane-utils/dirs";
|
|
9
|
+
import { type BlobPutResult, BlobStore, externalizeImageData, isBlobRef, resolveImageData } from "./blob-store";
|
|
10
|
+
import {
|
|
11
|
+
type BashExecutionMessage,
|
|
12
|
+
type CustomMessage,
|
|
13
|
+
createBranchSummaryMessage,
|
|
14
|
+
createCompactionSummaryMessage,
|
|
15
|
+
createCustomMessage,
|
|
16
|
+
type FileMentionMessage,
|
|
17
|
+
type HookMessage,
|
|
18
|
+
type PythonExecutionMessage,
|
|
19
|
+
} from "./messages";
|
|
20
|
+
import type { SessionStorage, SessionStorageWriter } from "./session-storage";
|
|
21
|
+
import { FileSessionStorage, MemorySessionStorage } from "./session-storage";
|
|
22
|
+
|
|
23
|
+
export const CURRENT_SESSION_VERSION = 3;
|
|
24
|
+
|
|
25
|
+
export interface SessionHeader {
|
|
26
|
+
type: "session";
|
|
27
|
+
version?: number; // v1 sessions don't have this
|
|
28
|
+
id: string;
|
|
29
|
+
title?: string; // Auto-generated title from first message
|
|
30
|
+
timestamp: string;
|
|
31
|
+
cwd: string;
|
|
32
|
+
parentSession?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface NewSessionOptions {
|
|
36
|
+
parentSession?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionEntryBase {
|
|
40
|
+
type: string;
|
|
41
|
+
id: string;
|
|
42
|
+
parentId: string | null;
|
|
43
|
+
timestamp: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SessionMessageEntry extends SessionEntryBase {
|
|
47
|
+
type: "message";
|
|
48
|
+
message: AgentMessage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ThinkingLevelChangeEntry extends SessionEntryBase {
|
|
52
|
+
type: "thinking_level_change";
|
|
53
|
+
thinkingLevel: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface ModelChangeEntry extends SessionEntryBase {
|
|
57
|
+
type: "model_change";
|
|
58
|
+
/** Model in "provider/modelId" format */
|
|
59
|
+
model: string;
|
|
60
|
+
/** Role: "default", "fast", "oracle", etc. Undefined treated as "default" */
|
|
61
|
+
role?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CompactionEntry<T = unknown> extends SessionEntryBase {
|
|
65
|
+
type: "compaction";
|
|
66
|
+
summary: string;
|
|
67
|
+
shortSummary?: string;
|
|
68
|
+
firstKeptEntryId: string;
|
|
69
|
+
tokensBefore: number;
|
|
70
|
+
/** Extension-specific data (e.g., ArtifactIndex, version markers for structured compaction) */
|
|
71
|
+
details?: T;
|
|
72
|
+
/** Hook-provided data to persist across compaction */
|
|
73
|
+
preserveData?: Record<string, unknown>;
|
|
74
|
+
/** True if generated by an extension, undefined/false if pi-generated (backward compatible) */
|
|
75
|
+
fromExtension?: boolean;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface BranchSummaryEntry<T = unknown> extends SessionEntryBase {
|
|
79
|
+
type: "branch_summary";
|
|
80
|
+
fromId: string;
|
|
81
|
+
summary: string;
|
|
82
|
+
/** Extension-specific data (not sent to LLM) */
|
|
83
|
+
details?: T;
|
|
84
|
+
/** True if generated by an extension, false if pi-generated */
|
|
85
|
+
fromExtension?: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Custom entry for extensions to store extension-specific data in the session.
|
|
90
|
+
* Use customType to identify your extension's entries.
|
|
91
|
+
*
|
|
92
|
+
* Purpose: Persist extension state across session reloads. On reload, extensions can
|
|
93
|
+
* scan entries for their customType and reconstruct internal state.
|
|
94
|
+
*
|
|
95
|
+
* Does NOT participate in LLM context (ignored by buildSessionContext).
|
|
96
|
+
* For injecting content into context, see CustomMessageEntry.
|
|
97
|
+
*/
|
|
98
|
+
export interface CustomEntry<T = unknown> extends SessionEntryBase {
|
|
99
|
+
type: "custom";
|
|
100
|
+
customType: string;
|
|
101
|
+
data?: T;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Label entry for user-defined bookmarks/markers on entries. */
|
|
105
|
+
export interface LabelEntry extends SessionEntryBase {
|
|
106
|
+
type: "label";
|
|
107
|
+
targetId: string;
|
|
108
|
+
label: string | undefined;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** TTSR injection entry - tracks which time-traveling rules have been injected this session. */
|
|
112
|
+
export interface TtsrInjectionEntry extends SessionEntryBase {
|
|
113
|
+
type: "ttsr_injection";
|
|
114
|
+
/** Names of rules that were injected */
|
|
115
|
+
injectedRules: string[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Session init entry - captures initial context for subagent sessions (debugging/replay). */
|
|
119
|
+
export interface SessionInitEntry extends SessionEntryBase {
|
|
120
|
+
type: "session_init";
|
|
121
|
+
/** Full system prompt sent to the model */
|
|
122
|
+
systemPrompt: string;
|
|
123
|
+
/** Initial task/user message */
|
|
124
|
+
task: string;
|
|
125
|
+
/** Tools available to the agent */
|
|
126
|
+
tools: string[];
|
|
127
|
+
/** Output schema if structured output was requested */
|
|
128
|
+
outputSchema?: unknown;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Mode change entry - tracks agent mode transitions. */
|
|
132
|
+
export interface ModeChangeEntry extends SessionEntryBase {
|
|
133
|
+
type: "mode_change";
|
|
134
|
+
/** Current mode name, or "none" when exiting a mode */
|
|
135
|
+
mode: string;
|
|
136
|
+
/** Optional mode-specific data (e.g. plan file path) */
|
|
137
|
+
data?: Record<string, unknown>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Custom message entry for extensions to inject messages into LLM context.
|
|
142
|
+
* Use customType to identify your extension's entries.
|
|
143
|
+
*
|
|
144
|
+
* Unlike CustomEntry, this DOES participate in LLM context.
|
|
145
|
+
* The content is converted to a user message in buildSessionContext().
|
|
146
|
+
* Use details for extension-specific metadata (not sent to LLM).
|
|
147
|
+
*
|
|
148
|
+
* display controls TUI rendering:
|
|
149
|
+
* - false: hidden entirely
|
|
150
|
+
* - true: rendered with distinct styling (different from user messages)
|
|
151
|
+
*/
|
|
152
|
+
export interface CustomMessageEntry<T = unknown> extends SessionEntryBase {
|
|
153
|
+
type: "custom_message";
|
|
154
|
+
customType: string;
|
|
155
|
+
content: string | (TextContent | ImageContent)[];
|
|
156
|
+
details?: T;
|
|
157
|
+
display: boolean;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Session entry - has id/parentId for tree structure (returned by "read" methods in SessionManager) */
|
|
161
|
+
export type SessionEntry =
|
|
162
|
+
| SessionMessageEntry
|
|
163
|
+
| ThinkingLevelChangeEntry
|
|
164
|
+
| ModelChangeEntry
|
|
165
|
+
| CompactionEntry
|
|
166
|
+
| BranchSummaryEntry
|
|
167
|
+
| CustomEntry
|
|
168
|
+
| CustomMessageEntry
|
|
169
|
+
| LabelEntry
|
|
170
|
+
| TtsrInjectionEntry
|
|
171
|
+
| SessionInitEntry
|
|
172
|
+
| ModeChangeEntry;
|
|
173
|
+
|
|
174
|
+
/** Raw file entry (includes header) */
|
|
175
|
+
export type FileEntry = SessionHeader | SessionEntry;
|
|
176
|
+
|
|
177
|
+
/** Tree node for getTree() - defensive copy of session structure */
|
|
178
|
+
export interface SessionTreeNode {
|
|
179
|
+
entry: SessionEntry;
|
|
180
|
+
children: SessionTreeNode[];
|
|
181
|
+
/** Resolved label for this entry, if any */
|
|
182
|
+
label?: string;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface SessionContext {
|
|
186
|
+
messages: AgentMessage[];
|
|
187
|
+
thinkingLevel: string;
|
|
188
|
+
/** Model roles: { default: "provider/modelId", small: "provider/modelId", ... } */
|
|
189
|
+
models: Record<string, string>;
|
|
190
|
+
/** Names of TTSR rules that have been injected this session */
|
|
191
|
+
injectedTtsrRules: string[];
|
|
192
|
+
/** Active mode (e.g. "plan") or "none" if no special mode is active */
|
|
193
|
+
mode: string;
|
|
194
|
+
/** Mode-specific data from the last mode_change entry */
|
|
195
|
+
modeData?: Record<string, unknown>;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export interface SessionInfo {
|
|
199
|
+
path: string;
|
|
200
|
+
id: string;
|
|
201
|
+
/** Working directory where the session was started. Empty string for old sessions. */
|
|
202
|
+
cwd: string;
|
|
203
|
+
title?: string;
|
|
204
|
+
/** Path to the parent session (if this session was forked). */
|
|
205
|
+
parentSessionPath?: string;
|
|
206
|
+
created: Date;
|
|
207
|
+
modified: Date;
|
|
208
|
+
messageCount: number;
|
|
209
|
+
firstMessage: string;
|
|
210
|
+
allMessagesText: string;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export type ReadonlySessionManager = Pick<
|
|
214
|
+
SessionManager,
|
|
215
|
+
| "getCwd"
|
|
216
|
+
| "getSessionDir"
|
|
217
|
+
| "getSessionId"
|
|
218
|
+
| "getSessionFile"
|
|
219
|
+
| "getLeafId"
|
|
220
|
+
| "getLeafEntry"
|
|
221
|
+
| "getEntry"
|
|
222
|
+
| "getLabel"
|
|
223
|
+
| "getBranch"
|
|
224
|
+
| "getHeader"
|
|
225
|
+
| "getEntries"
|
|
226
|
+
| "getTree"
|
|
227
|
+
| "getUsageStatistics"
|
|
228
|
+
| "putBlob"
|
|
229
|
+
>;
|
|
230
|
+
|
|
231
|
+
/** Generate a unique short ID (8 hex chars, collision-checked) */
|
|
232
|
+
function generateId(byId: { has(id: string): boolean }): string {
|
|
233
|
+
for (let i = 0; i < 100; i++) {
|
|
234
|
+
const id = crypto.randomUUID().slice(-8);
|
|
235
|
+
if (!byId.has(id)) return id;
|
|
236
|
+
}
|
|
237
|
+
return Snowflake.next(); // fallback to full snowflake id
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Migrate v1 → v2: add id/parentId tree structure. Mutates in place. */
|
|
241
|
+
function migrateV1ToV2(entries: FileEntry[]): void {
|
|
242
|
+
const ids = new Set<string>();
|
|
243
|
+
let prevId: string | null = null;
|
|
244
|
+
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
if (entry.type === "session") {
|
|
247
|
+
entry.version = 2;
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
entry.id = generateId(ids);
|
|
252
|
+
entry.parentId = prevId;
|
|
253
|
+
prevId = entry.id;
|
|
254
|
+
|
|
255
|
+
// Convert firstKeptEntryIndex to firstKeptEntryId for compaction
|
|
256
|
+
if (entry.type === "compaction") {
|
|
257
|
+
const comp = entry as CompactionEntry & { firstKeptEntryIndex?: number };
|
|
258
|
+
if (typeof comp.firstKeptEntryIndex === "number") {
|
|
259
|
+
const targetEntry = entries[comp.firstKeptEntryIndex];
|
|
260
|
+
if (targetEntry && targetEntry.type !== "session") {
|
|
261
|
+
comp.firstKeptEntryId = targetEntry.id;
|
|
262
|
+
}
|
|
263
|
+
delete comp.firstKeptEntryIndex;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Migrate v2 → v3: rename hookMessage role to custom. Mutates in place. */
|
|
270
|
+
function migrateV2ToV3(entries: FileEntry[]): void {
|
|
271
|
+
for (const entry of entries) {
|
|
272
|
+
if (entry.type === "session") {
|
|
273
|
+
entry.version = 3;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (entry.type === "message") {
|
|
278
|
+
const msg = entry.message as { role?: string };
|
|
279
|
+
if (msg.role === "hookMessage") {
|
|
280
|
+
(entry.message as { role: string }).role = "custom";
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Run all necessary migrations to bring entries to current version.
|
|
288
|
+
* Mutates entries in place. Returns true if any migration was applied.
|
|
289
|
+
*/
|
|
290
|
+
function migrateToCurrentVersion(entries: FileEntry[]): boolean {
|
|
291
|
+
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
292
|
+
const version = header?.version ?? 1;
|
|
293
|
+
|
|
294
|
+
if (version >= CURRENT_SESSION_VERSION) return false;
|
|
295
|
+
|
|
296
|
+
if (version < 2) migrateV1ToV2(entries);
|
|
297
|
+
if (version < 3) migrateV2ToV3(entries);
|
|
298
|
+
|
|
299
|
+
return true;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Exported for testing */
|
|
303
|
+
export function migrateSessionEntries(entries: FileEntry[]): void {
|
|
304
|
+
migrateToCurrentVersion(entries);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
let sessionDirsMigrated = false;
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Migrate old `--<home-encoded>-*--` session dirs to the new `-*` format.
|
|
311
|
+
* Runs once on first access, best-effort.
|
|
312
|
+
*/
|
|
313
|
+
function migrateHomeSessionDirs(): void {
|
|
314
|
+
if (sessionDirsMigrated) return;
|
|
315
|
+
sessionDirsMigrated = true;
|
|
316
|
+
|
|
317
|
+
const home = os.homedir();
|
|
318
|
+
const homeEncoded = home.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-");
|
|
319
|
+
const oldPrefix = `--${homeEncoded}-`;
|
|
320
|
+
const oldExact = `--${homeEncoded}--`;
|
|
321
|
+
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
322
|
+
|
|
323
|
+
let entries: string[];
|
|
324
|
+
try {
|
|
325
|
+
entries = fs.readdirSync(sessionsRoot);
|
|
326
|
+
} catch {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (const entry of entries) {
|
|
331
|
+
let remainder: string;
|
|
332
|
+
if (entry === oldExact) {
|
|
333
|
+
remainder = "";
|
|
334
|
+
} else if (entry.startsWith(oldPrefix) && entry.endsWith("--")) {
|
|
335
|
+
remainder = entry.slice(oldPrefix.length, -2);
|
|
336
|
+
} else {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const newName = `-${remainder}`;
|
|
341
|
+
const oldPath = path.join(sessionsRoot, entry);
|
|
342
|
+
const newPath = path.join(sessionsRoot, newName);
|
|
343
|
+
|
|
344
|
+
try {
|
|
345
|
+
const existing = fs.statSync(newPath, { throwIfNoEntry: false });
|
|
346
|
+
if (existing?.isDirectory()) {
|
|
347
|
+
// Merge files from old dir into existing new dir
|
|
348
|
+
for (const file of fs.readdirSync(oldPath)) {
|
|
349
|
+
const src = path.join(oldPath, file);
|
|
350
|
+
const dst = path.join(newPath, file);
|
|
351
|
+
if (!fs.existsSync(dst)) {
|
|
352
|
+
fs.renameSync(src, dst);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
fs.rmSync(oldPath, { recursive: true, force: true });
|
|
356
|
+
} else {
|
|
357
|
+
if (existing) {
|
|
358
|
+
fs.rmSync(newPath, { recursive: true, force: true });
|
|
359
|
+
}
|
|
360
|
+
fs.renameSync(oldPath, newPath);
|
|
361
|
+
}
|
|
362
|
+
} catch {
|
|
363
|
+
// Best effort
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Exported for compaction.test.ts */
|
|
369
|
+
export function parseSessionEntries(content: string): FileEntry[] {
|
|
370
|
+
return parseJsonlLenient<FileEntry>(content);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export function getLatestCompactionEntry(entries: SessionEntry[]): CompactionEntry | null {
|
|
374
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
375
|
+
if (entries[i].type === "compaction") {
|
|
376
|
+
return entries[i] as CompactionEntry;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
return null;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function toError(value: unknown): Error {
|
|
383
|
+
return value instanceof Error ? value : new Error(String(value));
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Build the session context from entries using tree traversal.
|
|
388
|
+
* If leafId is provided, walks from that entry to root.
|
|
389
|
+
* Handles compaction and branch summaries along the path.
|
|
390
|
+
*/
|
|
391
|
+
export function buildSessionContext(
|
|
392
|
+
entries: SessionEntry[],
|
|
393
|
+
leafId?: string | null,
|
|
394
|
+
byId?: Map<string, SessionEntry>,
|
|
395
|
+
): SessionContext {
|
|
396
|
+
// Build uuid index if not available
|
|
397
|
+
if (!byId) {
|
|
398
|
+
byId = new Map<string, SessionEntry>();
|
|
399
|
+
for (const entry of entries) {
|
|
400
|
+
byId.set(entry.id, entry);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Find leaf
|
|
405
|
+
let leaf: SessionEntry | undefined;
|
|
406
|
+
if (leafId === null) {
|
|
407
|
+
// Explicitly null - return no messages (navigated to before first entry)
|
|
408
|
+
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [], mode: "none" };
|
|
409
|
+
}
|
|
410
|
+
if (leafId) {
|
|
411
|
+
leaf = byId.get(leafId);
|
|
412
|
+
}
|
|
413
|
+
if (!leaf) {
|
|
414
|
+
// Fallback to last entry (when leafId is undefined)
|
|
415
|
+
leaf = entries[entries.length - 1];
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (!leaf) {
|
|
419
|
+
return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [], mode: "none" };
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Walk from leaf to root, collecting path
|
|
423
|
+
const path: SessionEntry[] = [];
|
|
424
|
+
let current: SessionEntry | undefined = leaf;
|
|
425
|
+
while (current) {
|
|
426
|
+
path.unshift(current);
|
|
427
|
+
current = current.parentId ? byId.get(current.parentId) : undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Extract settings and find compaction
|
|
431
|
+
let thinkingLevel = "off";
|
|
432
|
+
const models: Record<string, string> = {};
|
|
433
|
+
let compaction: CompactionEntry | null = null;
|
|
434
|
+
const injectedTtsrRulesSet = new Set<string>();
|
|
435
|
+
let mode = "none";
|
|
436
|
+
let modeData: Record<string, unknown> | undefined;
|
|
437
|
+
|
|
438
|
+
for (const entry of path) {
|
|
439
|
+
if (entry.type === "thinking_level_change") {
|
|
440
|
+
thinkingLevel = entry.thinkingLevel;
|
|
441
|
+
} else if (entry.type === "model_change") {
|
|
442
|
+
// New format: { model: "provider/id", role?: string }
|
|
443
|
+
if (entry.model) {
|
|
444
|
+
const role = entry.role ?? "default";
|
|
445
|
+
models[role] = entry.model;
|
|
446
|
+
}
|
|
447
|
+
} else if (entry.type === "message" && entry.message.role === "assistant") {
|
|
448
|
+
// Infer default model from assistant messages
|
|
449
|
+
models.default = `${entry.message.provider}/${entry.message.model}`;
|
|
450
|
+
} else if (entry.type === "compaction") {
|
|
451
|
+
compaction = entry;
|
|
452
|
+
} else if (entry.type === "ttsr_injection") {
|
|
453
|
+
// Collect injected TTSR rule names
|
|
454
|
+
for (const ruleName of entry.injectedRules) {
|
|
455
|
+
injectedTtsrRulesSet.add(ruleName);
|
|
456
|
+
}
|
|
457
|
+
} else if (entry.type === "mode_change") {
|
|
458
|
+
mode = entry.mode;
|
|
459
|
+
modeData = entry.data;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const injectedTtsrRules = Array.from(injectedTtsrRulesSet);
|
|
464
|
+
|
|
465
|
+
// Build messages and collect corresponding entries
|
|
466
|
+
// When there's a compaction, we need to:
|
|
467
|
+
// 1. Emit summary first (entry = compaction)
|
|
468
|
+
// 2. Emit kept messages (from firstKeptEntryId up to compaction)
|
|
469
|
+
// 3. Emit messages after compaction
|
|
470
|
+
const messages: AgentMessage[] = [];
|
|
471
|
+
|
|
472
|
+
const appendMessage = (entry: SessionEntry) => {
|
|
473
|
+
if (entry.type === "message") {
|
|
474
|
+
messages.push(entry.message);
|
|
475
|
+
} else if (entry.type === "custom_message") {
|
|
476
|
+
messages.push(
|
|
477
|
+
createCustomMessage(entry.customType, entry.content, entry.display, entry.details, entry.timestamp),
|
|
478
|
+
);
|
|
479
|
+
} else if (entry.type === "branch_summary" && entry.summary) {
|
|
480
|
+
messages.push(createBranchSummaryMessage(entry.summary, entry.fromId, entry.timestamp));
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
if (compaction) {
|
|
485
|
+
// Emit summary first
|
|
486
|
+
messages.push(
|
|
487
|
+
createCompactionSummaryMessage(
|
|
488
|
+
compaction.summary,
|
|
489
|
+
compaction.tokensBefore,
|
|
490
|
+
compaction.timestamp,
|
|
491
|
+
compaction.shortSummary,
|
|
492
|
+
),
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
// Find compaction index in path
|
|
496
|
+
const compactionIdx = path.findIndex(e => e.type === "compaction" && e.id === compaction.id);
|
|
497
|
+
|
|
498
|
+
// Emit kept messages (before compaction, starting from firstKeptEntryId)
|
|
499
|
+
let foundFirstKept = false;
|
|
500
|
+
for (let i = 0; i < compactionIdx; i++) {
|
|
501
|
+
const entry = path[i];
|
|
502
|
+
if (entry.id === compaction.firstKeptEntryId) {
|
|
503
|
+
foundFirstKept = true;
|
|
504
|
+
}
|
|
505
|
+
if (foundFirstKept) {
|
|
506
|
+
appendMessage(entry);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Emit messages after compaction
|
|
511
|
+
for (let i = compactionIdx + 1; i < path.length; i++) {
|
|
512
|
+
const entry = path[i];
|
|
513
|
+
appendMessage(entry);
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
// No compaction - emit all messages, handle branch summaries and custom messages
|
|
517
|
+
for (const entry of path) {
|
|
518
|
+
appendMessage(entry);
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return { messages, thinkingLevel, models, injectedTtsrRules, mode, modeData };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Encode a cwd into a safe directory name for session storage.
|
|
527
|
+
* Home-relative paths use single-dash format: `/Users/x/Projects/pi` → `-Projects-pi`
|
|
528
|
+
* Absolute paths use double-dash format: `/tmp/foo` → `--tmp-foo--`
|
|
529
|
+
*/
|
|
530
|
+
function encodeSessionDirName(cwd: string): string {
|
|
531
|
+
const home = os.homedir();
|
|
532
|
+
if (cwd === home || cwd.startsWith(`${home}/`) || cwd.startsWith(`${home}\\`)) {
|
|
533
|
+
const relative = cwd.slice(home.length).replace(/^[/\\]/, "");
|
|
534
|
+
return `-${relative.replace(/[/\\:]/g, "-")}`;
|
|
535
|
+
}
|
|
536
|
+
return `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
|
|
537
|
+
}
|
|
538
|
+
/**
|
|
539
|
+
* Compute the default session directory for a cwd.
|
|
540
|
+
* Encodes cwd into a safe directory name under ~/.arcane/agent/sessions/.
|
|
541
|
+
*/
|
|
542
|
+
function getDefaultSessionDir(cwd: string, storage: SessionStorage): string {
|
|
543
|
+
migrateHomeSessionDirs();
|
|
544
|
+
const dirName = encodeSessionDirName(cwd);
|
|
545
|
+
const sessionDir = path.join(getDefaultAgentDir(), "sessions", dirName);
|
|
546
|
+
storage.ensureDirSync(sessionDir);
|
|
547
|
+
return sessionDir;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// =============================================================================
|
|
551
|
+
// Terminal breadcrumbs: maps terminal (TTY) -> last session file for --continue
|
|
552
|
+
// =============================================================================
|
|
553
|
+
|
|
554
|
+
const TERMINAL_SESSIONS_DIR = "terminal-sessions";
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Write a breadcrumb linking the current terminal to a session file.
|
|
558
|
+
* The breadcrumb contains the cwd and session path so --continue can
|
|
559
|
+
* find "this terminal's last session" even when running concurrent instances.
|
|
560
|
+
*/
|
|
561
|
+
function writeTerminalBreadcrumb(cwd: string, sessionFile: string): void {
|
|
562
|
+
const terminalId = getTerminalId();
|
|
563
|
+
if (!terminalId) return;
|
|
564
|
+
|
|
565
|
+
try {
|
|
566
|
+
const breadcrumbDir = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR);
|
|
567
|
+
const breadcrumbFile = path.join(breadcrumbDir, terminalId);
|
|
568
|
+
const content = `${cwd}\n${sessionFile}\n`;
|
|
569
|
+
// Bun.write auto-creates parent dirs
|
|
570
|
+
void Bun.write(breadcrumbFile, content);
|
|
571
|
+
} catch {
|
|
572
|
+
// Best-effort — don't break session creation if breadcrumb fails
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Read the terminal breadcrumb for the current terminal, scoped to a cwd.
|
|
578
|
+
* Returns the session file path if it exists and matches the cwd, null otherwise.
|
|
579
|
+
*/
|
|
580
|
+
async function readTerminalBreadcrumb(cwd: string): Promise<string | null> {
|
|
581
|
+
const terminalId = getTerminalId();
|
|
582
|
+
if (!terminalId) return null;
|
|
583
|
+
|
|
584
|
+
try {
|
|
585
|
+
const breadcrumbFile = path.join(getDefaultAgentDir(), TERMINAL_SESSIONS_DIR, terminalId);
|
|
586
|
+
const content = await Bun.file(breadcrumbFile).text();
|
|
587
|
+
const lines = content.trim().split("\n");
|
|
588
|
+
if (lines.length < 2) return null;
|
|
589
|
+
|
|
590
|
+
const breadcrumbCwd = lines[0];
|
|
591
|
+
const sessionFile = lines[1];
|
|
592
|
+
|
|
593
|
+
// Only return if cwd matches (user might have cd'd)
|
|
594
|
+
if (path.resolve(breadcrumbCwd) !== path.resolve(cwd)) return null;
|
|
595
|
+
|
|
596
|
+
// Verify the session file still exists
|
|
597
|
+
const stat = fs.statSync(sessionFile, { throwIfNoEntry: false });
|
|
598
|
+
if (stat?.isFile()) return sessionFile;
|
|
599
|
+
} catch (err) {
|
|
600
|
+
if (!isEnoent(err)) logger.debug("Terminal breadcrumb read failed", { err });
|
|
601
|
+
// Breadcrumb doesn't exist or is corrupt — fall through
|
|
602
|
+
}
|
|
603
|
+
return null;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/** Exported for testing */
|
|
607
|
+
export async function loadEntriesFromFile(
|
|
608
|
+
filePath: string,
|
|
609
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
610
|
+
): Promise<FileEntry[]> {
|
|
611
|
+
let content: string;
|
|
612
|
+
try {
|
|
613
|
+
content = await storage.readText(filePath);
|
|
614
|
+
} catch (err) {
|
|
615
|
+
if (isEnoent(err)) return [];
|
|
616
|
+
throw err;
|
|
617
|
+
}
|
|
618
|
+
const entries = parseJsonlLenient<FileEntry>(content);
|
|
619
|
+
|
|
620
|
+
// Validate session header
|
|
621
|
+
if (entries.length === 0) return entries;
|
|
622
|
+
const header = entries[0] as SessionHeader;
|
|
623
|
+
if (header.type !== "session" || typeof header.id !== "string") {
|
|
624
|
+
return [];
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
return entries;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/**
|
|
631
|
+
* Resolve blob references in loaded entries, replacing `blob:sha256:<hash>` data fields
|
|
632
|
+
* with the actual base64 content from the blob store. Mutates entries in place.
|
|
633
|
+
*/
|
|
634
|
+
async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise<void> {
|
|
635
|
+
const promises: Promise<void>[] = [];
|
|
636
|
+
|
|
637
|
+
for (const entry of entries) {
|
|
638
|
+
if (entry.type === "session") continue;
|
|
639
|
+
|
|
640
|
+
// Resolve image blocks in message content arrays
|
|
641
|
+
let contentArray: unknown[] | undefined;
|
|
642
|
+
if (entry.type === "message") {
|
|
643
|
+
const content = (entry.message as { content?: unknown }).content;
|
|
644
|
+
if (Array.isArray(content)) contentArray = content;
|
|
645
|
+
} else if (entry.type === "custom_message" && Array.isArray(entry.content)) {
|
|
646
|
+
contentArray = entry.content;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
if (!contentArray) continue;
|
|
650
|
+
|
|
651
|
+
for (const block of contentArray) {
|
|
652
|
+
if (isImageBlock(block) && isBlobRef(block.data)) {
|
|
653
|
+
promises.push(
|
|
654
|
+
resolveImageData(blobStore, block.data).then(resolved => {
|
|
655
|
+
(block as { data: string }).data = resolved;
|
|
656
|
+
}),
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await Promise.all(promises);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Lightweight metadata for a session file, used in session picker UI.
|
|
667
|
+
* Uses lazy getters to defer string formatting until actually displayed.
|
|
668
|
+
*/
|
|
669
|
+
function sanitizeSessionName(value: string | undefined): string | undefined {
|
|
670
|
+
if (!value) return undefined;
|
|
671
|
+
const firstLine = value.split(/\r?\n/)[0] ?? "";
|
|
672
|
+
const stripped = firstLine.replace(/[\x00-\x1F\x7F]/g, "");
|
|
673
|
+
const trimmed = stripped.trim();
|
|
674
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
class RecentSessionInfo {
|
|
678
|
+
#fullName: string | undefined;
|
|
679
|
+
#name: string | undefined;
|
|
680
|
+
#timeAgo: string | undefined;
|
|
681
|
+
|
|
682
|
+
constructor(
|
|
683
|
+
readonly path: string,
|
|
684
|
+
readonly mtime: number,
|
|
685
|
+
header: Record<string, unknown>,
|
|
686
|
+
firstPrompt?: string,
|
|
687
|
+
) {
|
|
688
|
+
// Extract title from session header, falling back to first user prompt, then id
|
|
689
|
+
const trystr = (v: unknown) => (typeof v === "string" ? v : undefined);
|
|
690
|
+
this.#fullName =
|
|
691
|
+
sanitizeSessionName(trystr(header.title)) ??
|
|
692
|
+
sanitizeSessionName(firstPrompt) ??
|
|
693
|
+
sanitizeSessionName(trystr(header.id));
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** Full session name from header, or filename without extension as fallback */
|
|
697
|
+
get fullName(): string {
|
|
698
|
+
if (this.#fullName) return this.#fullName;
|
|
699
|
+
this.#fullName = this.path.split("/").pop()?.replace(".jsonl", "") ?? "Unknown";
|
|
700
|
+
return this.#fullName;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/** Truncated name for display (max 40 chars) */
|
|
704
|
+
get name(): string {
|
|
705
|
+
if (this.#name) return this.#name;
|
|
706
|
+
const fullName = this.fullName;
|
|
707
|
+
this.#name = fullName.length <= 40 ? fullName : `${fullName.slice(0, 39)}…`;
|
|
708
|
+
return this.#name;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Human-readable relative time (e.g., "2 hours ago") */
|
|
712
|
+
get timeAgo(): string {
|
|
713
|
+
if (this.#timeAgo) return this.#timeAgo;
|
|
714
|
+
this.#timeAgo = formatTimeAgo(new Date(this.mtime));
|
|
715
|
+
return this.#timeAgo;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* Extracts the text content from a user message entry.
|
|
721
|
+
* Returns undefined if the entry is not a user message or has no text.
|
|
722
|
+
*/
|
|
723
|
+
function extractFirstUserPrompt(entries: Array<Record<string, unknown>>): string | undefined {
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
if (entry.type !== "message") continue;
|
|
726
|
+
const message = entry.message as Record<string, unknown> | undefined;
|
|
727
|
+
if (message?.role !== "user") continue;
|
|
728
|
+
const content = message.content;
|
|
729
|
+
if (typeof content === "string") return content;
|
|
730
|
+
if (Array.isArray(content)) {
|
|
731
|
+
for (const block of content) {
|
|
732
|
+
if (typeof block === "object" && block !== null && "text" in block) {
|
|
733
|
+
const text = (block as { text: unknown }).text;
|
|
734
|
+
if (typeof text === "string") return text;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
return undefined;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Reads all session files from the directory and returns them sorted by mtime (newest first).
|
|
744
|
+
* Uses low-level file I/O to efficiently read only the first 4KB of each file
|
|
745
|
+
* to extract the JSON header and first user message without loading entire session logs into memory.
|
|
746
|
+
*/
|
|
747
|
+
async function getSortedSessions(sessionDir: string, storage: SessionStorage): Promise<RecentSessionInfo[]> {
|
|
748
|
+
try {
|
|
749
|
+
const files: string[] = storage.listFilesSync(sessionDir, "*.jsonl");
|
|
750
|
+
const sessions: RecentSessionInfo[] = [];
|
|
751
|
+
await Promise.all(
|
|
752
|
+
files.map(async (path: string) => {
|
|
753
|
+
try {
|
|
754
|
+
const content = await storage.readTextPrefix(path, 4096);
|
|
755
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
756
|
+
if (entries.length === 0) return;
|
|
757
|
+
const header = entries[0] as Record<string, unknown>;
|
|
758
|
+
if (header.type !== "session" || typeof header.id !== "string") return;
|
|
759
|
+
const mtime = storage.statSync(path).mtimeMs;
|
|
760
|
+
const firstPrompt = header.title ? undefined : extractFirstUserPrompt(entries);
|
|
761
|
+
sessions.push(new RecentSessionInfo(path, mtime, header, firstPrompt));
|
|
762
|
+
} catch {}
|
|
763
|
+
}),
|
|
764
|
+
);
|
|
765
|
+
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
766
|
+
} catch {
|
|
767
|
+
return [];
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/** Exported for testing */
|
|
772
|
+
export async function findMostRecentSession(
|
|
773
|
+
sessionDir: string,
|
|
774
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
775
|
+
): Promise<string | null> {
|
|
776
|
+
const sessions = await getSortedSessions(sessionDir, storage);
|
|
777
|
+
return sessions[0]?.path || null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** Format a time difference as a human-readable string */
|
|
781
|
+
function formatTimeAgo(date: Date): string {
|
|
782
|
+
const now = Date.now();
|
|
783
|
+
const diffMs = now - date.getTime();
|
|
784
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
785
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
786
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
787
|
+
|
|
788
|
+
if (diffMins < 1) return "just now";
|
|
789
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
790
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
791
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
792
|
+
return date.toLocaleDateString();
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
const MAX_PERSIST_CHARS = 500_000;
|
|
796
|
+
const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]";
|
|
797
|
+
/** Minimum base64 length to externalize to blob store (skip tiny inline images) */
|
|
798
|
+
const BLOB_EXTERNALIZE_THRESHOLD = 1024;
|
|
799
|
+
const TEXT_CONTENT_KEY = "content";
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* Recursively truncate large strings in an object for session persistence.
|
|
803
|
+
* - Truncates any oversized string fields (key-agnostic)
|
|
804
|
+
* - Replaces oversized image blocks with text notices
|
|
805
|
+
* - Updates lineCount when content is truncated
|
|
806
|
+
* - Returns original object if no changes needed (structural sharing)
|
|
807
|
+
*/
|
|
808
|
+
function truncateString(value: string, maxLength: number): string {
|
|
809
|
+
if (value.length <= maxLength) return value;
|
|
810
|
+
let truncated = value.slice(0, maxLength);
|
|
811
|
+
if (truncated.length > 0) {
|
|
812
|
+
const last = truncated.charCodeAt(truncated.length - 1);
|
|
813
|
+
if (last >= 0xd800 && last <= 0xdbff) {
|
|
814
|
+
truncated = truncated.slice(0, -1);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
return truncated;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } {
|
|
821
|
+
return (
|
|
822
|
+
typeof value === "object" &&
|
|
823
|
+
value !== null &&
|
|
824
|
+
"type" in value &&
|
|
825
|
+
(value as { type?: string }).type === "image" &&
|
|
826
|
+
"data" in value &&
|
|
827
|
+
typeof (value as { data?: string }).data === "string"
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
async function truncateForPersistence<T>(obj: T, blobStore: BlobStore, key?: string): Promise<T> {
|
|
832
|
+
if (obj === null || obj === undefined) return obj;
|
|
833
|
+
|
|
834
|
+
if (typeof obj === "string") {
|
|
835
|
+
if (obj.length > MAX_PERSIST_CHARS) {
|
|
836
|
+
const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length);
|
|
837
|
+
return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}` as T;
|
|
838
|
+
}
|
|
839
|
+
return obj;
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (Array.isArray(obj)) {
|
|
843
|
+
let changed = false;
|
|
844
|
+
const result = await Promise.all(
|
|
845
|
+
obj.map(async item => {
|
|
846
|
+
// Special handling: compress oversized images while preserving shape
|
|
847
|
+
if (key === TEXT_CONTENT_KEY && isImageBlock(item)) {
|
|
848
|
+
if (!isBlobRef(item.data) && item.data.length >= BLOB_EXTERNALIZE_THRESHOLD) {
|
|
849
|
+
changed = true;
|
|
850
|
+
const blobRef = await externalizeImageData(blobStore, item.data);
|
|
851
|
+
return { ...item, data: blobRef };
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const newItem = await truncateForPersistence(item, blobStore, key);
|
|
855
|
+
if (newItem !== item) changed = true;
|
|
856
|
+
return newItem;
|
|
857
|
+
}),
|
|
858
|
+
);
|
|
859
|
+
return changed ? (result as T) : obj;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (typeof obj === "object") {
|
|
863
|
+
let changed = false;
|
|
864
|
+
const result: Record<string, unknown> = {};
|
|
865
|
+
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
|
|
866
|
+
// Strip transient/redundant properties that shouldn't be persisted
|
|
867
|
+
// - partialJson: streaming accumulator for tool call JSON parsing
|
|
868
|
+
// - jsonlEvents: raw subprocess streaming events (already saved to artifact files)
|
|
869
|
+
if (k === "partialJson" || k === "jsonlEvents") {
|
|
870
|
+
changed = true;
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
const newV = await truncateForPersistence(v, blobStore, k);
|
|
874
|
+
result[k] = newV;
|
|
875
|
+
if (newV !== v) changed = true;
|
|
876
|
+
}
|
|
877
|
+
// Update lineCount if content was truncated (for FileMentionFile)
|
|
878
|
+
if (changed && "lineCount" in result && "content" in result && typeof result.content === "string") {
|
|
879
|
+
result.lineCount = result.content.split("\n").length;
|
|
880
|
+
}
|
|
881
|
+
return changed ? (result as T) : obj;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
return obj;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): Promise<FileEntry> {
|
|
888
|
+
return truncateForPersistence(entry, blobStore);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
class NdjsonFileWriter {
|
|
892
|
+
#writer: SessionStorageWriter;
|
|
893
|
+
#closed = false;
|
|
894
|
+
#closing = false;
|
|
895
|
+
#error: Error | undefined;
|
|
896
|
+
#pendingWrites: Promise<void> = Promise.resolve();
|
|
897
|
+
#onError: ((err: Error) => void) | undefined;
|
|
898
|
+
|
|
899
|
+
constructor(storage: SessionStorage, path: string, options?: { flags?: "a" | "w"; onError?: (err: Error) => void }) {
|
|
900
|
+
this.#onError = options?.onError;
|
|
901
|
+
this.#writer = storage.openWriter(path, {
|
|
902
|
+
flags: options?.flags ?? "a",
|
|
903
|
+
onError: (err: Error) => this.#recordError(err),
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
#recordError(err: unknown): Error {
|
|
908
|
+
const writeErr = toError(err);
|
|
909
|
+
if (!this.#error) this.#error = writeErr;
|
|
910
|
+
this.#onError?.(writeErr);
|
|
911
|
+
return writeErr;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
#enqueue(task: () => Promise<void>): Promise<void> {
|
|
915
|
+
const run = async () => {
|
|
916
|
+
if (this.#error) throw this.#error;
|
|
917
|
+
await task();
|
|
918
|
+
};
|
|
919
|
+
const next = this.#pendingWrites.then(run);
|
|
920
|
+
void next.catch((err: unknown) => {
|
|
921
|
+
if (!this.#error) this.#error = toError(err);
|
|
922
|
+
});
|
|
923
|
+
this.#pendingWrites = next;
|
|
924
|
+
return next;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
async #writeLine(line: string): Promise<void> {
|
|
928
|
+
if (this.#error) throw this.#error;
|
|
929
|
+
try {
|
|
930
|
+
await this.#writer.writeLine(line);
|
|
931
|
+
} catch (err) {
|
|
932
|
+
throw this.#recordError(err);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
/** Queue a write. Returns a promise so callers can await if needed. */
|
|
937
|
+
write(entry: FileEntry): Promise<void> {
|
|
938
|
+
if (this.#closed || this.#closing) throw new Error("Writer closed");
|
|
939
|
+
if (this.#error) throw this.#error;
|
|
940
|
+
const line = `${JSON.stringify(entry)}\n`;
|
|
941
|
+
return this.#enqueue(() => this.#writeLine(line));
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/** Flush all buffered data to disk. Waits for all queued writes. */
|
|
945
|
+
async flush(): Promise<void> {
|
|
946
|
+
if (this.#closed) return;
|
|
947
|
+
if (this.#error) throw this.#error;
|
|
948
|
+
|
|
949
|
+
await this.#enqueue(async () => {});
|
|
950
|
+
|
|
951
|
+
if (this.#error) throw this.#error;
|
|
952
|
+
|
|
953
|
+
try {
|
|
954
|
+
await this.#writer.flush();
|
|
955
|
+
} catch (err) {
|
|
956
|
+
throw this.#recordError(err);
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** Sync data to persistent storage. */
|
|
961
|
+
async fsync(): Promise<void> {
|
|
962
|
+
if (this.#closed) return;
|
|
963
|
+
if (this.#error) throw this.#error;
|
|
964
|
+
try {
|
|
965
|
+
await this.#writer.fsync();
|
|
966
|
+
} catch (err) {
|
|
967
|
+
throw this.#recordError(err);
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/** Close the writer, flushing all data. */
|
|
972
|
+
async close(): Promise<void> {
|
|
973
|
+
if (this.#closed || this.#closing) return;
|
|
974
|
+
this.#closing = true;
|
|
975
|
+
|
|
976
|
+
let closeError: Error | undefined;
|
|
977
|
+
try {
|
|
978
|
+
await this.flush();
|
|
979
|
+
} catch (err) {
|
|
980
|
+
closeError = toError(err);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
try {
|
|
984
|
+
await this.#pendingWrites;
|
|
985
|
+
} catch (err) {
|
|
986
|
+
if (!closeError) closeError = toError(err);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
try {
|
|
990
|
+
await this.#writer.close();
|
|
991
|
+
} catch (err) {
|
|
992
|
+
const endErr = this.#recordError(err);
|
|
993
|
+
if (!closeError) closeError = endErr;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
this.#closed = true;
|
|
997
|
+
|
|
998
|
+
if (!closeError && this.#error) closeError = this.#error;
|
|
999
|
+
if (closeError) throw closeError;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Check if there's a stored error. */
|
|
1003
|
+
getError(): Error | undefined {
|
|
1004
|
+
return this.#error;
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
/** Get recent sessions for display in welcome screen */
|
|
1009
|
+
export async function getRecentSessions(
|
|
1010
|
+
sessionDir: string,
|
|
1011
|
+
limit = 3,
|
|
1012
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
1013
|
+
): Promise<RecentSessionInfo[]> {
|
|
1014
|
+
const sessions = await getSortedSessions(sessionDir, storage);
|
|
1015
|
+
return sessions.slice(0, limit);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* Manages conversation sessions as append-only trees stored in JSONL files.
|
|
1020
|
+
*
|
|
1021
|
+
* Each session entry has an id and parentId forming a tree structure. The "leaf"
|
|
1022
|
+
* pointer tracks the current position. Appending creates a child of the current leaf.
|
|
1023
|
+
* Branching moves the leaf to an earlier entry, allowing new branches without
|
|
1024
|
+
* modifying history.
|
|
1025
|
+
*
|
|
1026
|
+
* Use buildSessionContext() to get the resolved message list for the LLM, which
|
|
1027
|
+
* handles compaction summaries and follows the path from root to current leaf.
|
|
1028
|
+
*/
|
|
1029
|
+
export interface UsageStatistics {
|
|
1030
|
+
input: number;
|
|
1031
|
+
output: number;
|
|
1032
|
+
cacheRead: number;
|
|
1033
|
+
cacheWrite: number;
|
|
1034
|
+
cost: number;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function getTaskToolUsage(details: unknown): Usage | undefined {
|
|
1038
|
+
if (!details || typeof details !== "object") return undefined;
|
|
1039
|
+
const record = details as Record<string, unknown>;
|
|
1040
|
+
const usage = record.usage;
|
|
1041
|
+
if (!usage || typeof usage !== "object") return undefined;
|
|
1042
|
+
return usage as Usage;
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function extractTextFromContent(content: Message["content"]): string {
|
|
1046
|
+
if (typeof content === "string") return content;
|
|
1047
|
+
return content
|
|
1048
|
+
.filter((block): block is TextContent => block.type === "text")
|
|
1049
|
+
.map(block => block.text)
|
|
1050
|
+
.join(" ");
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
async function collectSessionsFromFiles(files: string[], storage: SessionStorage): Promise<SessionInfo[]> {
|
|
1054
|
+
const sessions: SessionInfo[] = [];
|
|
1055
|
+
|
|
1056
|
+
// Collect session info for all files in parallel
|
|
1057
|
+
await Promise.all(
|
|
1058
|
+
files.map(async file => {
|
|
1059
|
+
try {
|
|
1060
|
+
const content = await storage.readText(file);
|
|
1061
|
+
const entries = parseJsonlLenient<Record<string, unknown>>(content);
|
|
1062
|
+
if (entries.length === 0) return;
|
|
1063
|
+
|
|
1064
|
+
// Check first entry for valid session header
|
|
1065
|
+
type SessionHeaderShape = { type: string; id: string; cwd?: string; title?: string; timestamp: string };
|
|
1066
|
+
const header = entries[0] as SessionHeaderShape;
|
|
1067
|
+
if (header.type !== "session" || !header.id) return;
|
|
1068
|
+
|
|
1069
|
+
let messageCount = 0;
|
|
1070
|
+
let firstMessage = "";
|
|
1071
|
+
const allMessages: string[] = [];
|
|
1072
|
+
let shortSummary: string | undefined;
|
|
1073
|
+
|
|
1074
|
+
for (let i = 1; i < entries.length; i++) {
|
|
1075
|
+
const entry = entries[i] as { type?: string; message?: Message; shortSummary?: string };
|
|
1076
|
+
|
|
1077
|
+
if (entry.type === "compaction" && typeof entry.shortSummary === "string") {
|
|
1078
|
+
shortSummary = entry.shortSummary;
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
if (entry.type === "message" && entry.message) {
|
|
1082
|
+
messageCount++;
|
|
1083
|
+
|
|
1084
|
+
if (entry.message.role === "user" || entry.message.role === "assistant") {
|
|
1085
|
+
const textContent = extractTextFromContent(entry.message.content);
|
|
1086
|
+
|
|
1087
|
+
if (textContent) {
|
|
1088
|
+
allMessages.push(textContent);
|
|
1089
|
+
|
|
1090
|
+
if (!firstMessage && entry.message.role === "user") {
|
|
1091
|
+
firstMessage = textContent;
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (messageCount) {
|
|
1099
|
+
const stats = storage.statSync(file);
|
|
1100
|
+
sessions.push({
|
|
1101
|
+
path: file,
|
|
1102
|
+
id: header.id,
|
|
1103
|
+
cwd: typeof header.cwd === "string" ? header.cwd : "",
|
|
1104
|
+
title: header.title ?? shortSummary,
|
|
1105
|
+
parentSessionPath: (header as SessionHeader).parentSession,
|
|
1106
|
+
created: new Date(header.timestamp),
|
|
1107
|
+
modified: stats.mtime,
|
|
1108
|
+
messageCount,
|
|
1109
|
+
firstMessage: firstMessage || "(no messages)",
|
|
1110
|
+
allMessagesText: allMessages.join(" "),
|
|
1111
|
+
});
|
|
1112
|
+
}
|
|
1113
|
+
} catch {}
|
|
1114
|
+
}),
|
|
1115
|
+
);
|
|
1116
|
+
|
|
1117
|
+
sessions.sort((a, b) => b.modified.getTime() - a.modified.getTime());
|
|
1118
|
+
return sessions;
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
export class SessionManager {
|
|
1122
|
+
#sessionId: string = "";
|
|
1123
|
+
#sessionName: string | undefined;
|
|
1124
|
+
#sessionFile: string | undefined;
|
|
1125
|
+
#flushed: boolean = false;
|
|
1126
|
+
#fileEntries: FileEntry[] = [];
|
|
1127
|
+
#byId: Map<string, SessionEntry> = new Map();
|
|
1128
|
+
#labelsById: Map<string, string> = new Map();
|
|
1129
|
+
#leafId: string | null = null;
|
|
1130
|
+
#usageStatistics: UsageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1131
|
+
#persistWriter: NdjsonFileWriter | undefined;
|
|
1132
|
+
#persistWriterPath: string | undefined;
|
|
1133
|
+
#persistChain: Promise<void> = Promise.resolve();
|
|
1134
|
+
#persistError: Error | undefined;
|
|
1135
|
+
#persistErrorReported = false;
|
|
1136
|
+
readonly #blobStore: BlobStore;
|
|
1137
|
+
|
|
1138
|
+
private constructor(
|
|
1139
|
+
private readonly cwd: string,
|
|
1140
|
+
private readonly sessionDir: string,
|
|
1141
|
+
private readonly persist: boolean,
|
|
1142
|
+
private readonly storage: SessionStorage,
|
|
1143
|
+
) {
|
|
1144
|
+
this.#blobStore = new BlobStore(getBlobsDir());
|
|
1145
|
+
if (persist && sessionDir) {
|
|
1146
|
+
this.storage.ensureDirSync(sessionDir);
|
|
1147
|
+
}
|
|
1148
|
+
// Note: call _initSession() or _initSessionFile() after construction
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
/** Puts a binary blob into the blob store and returns the blob reference */
|
|
1152
|
+
async putBlob(data: Buffer): Promise<BlobPutResult> {
|
|
1153
|
+
return this.#blobStore.put(data);
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
/** Initialize with a specific session file (used by factory methods) */
|
|
1157
|
+
async #initSessionFile(sessionFile: string): Promise<void> {
|
|
1158
|
+
await this.setSessionFile(sessionFile);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
/** Initialize with a new session (used by factory methods) */
|
|
1162
|
+
#initNewSession(): void {
|
|
1163
|
+
this.#newSessionSync();
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/** Switch to a different session file (used for resume and branching) */
|
|
1167
|
+
async setSessionFile(sessionFile: string): Promise<void> {
|
|
1168
|
+
await this.#closePersistWriter();
|
|
1169
|
+
this.#persistError = undefined;
|
|
1170
|
+
this.#persistErrorReported = false;
|
|
1171
|
+
this.#sessionFile = path.resolve(sessionFile);
|
|
1172
|
+
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
1173
|
+
this.#fileEntries = await loadEntriesFromFile(this.#sessionFile, this.storage);
|
|
1174
|
+
if (this.#fileEntries.length > 0) {
|
|
1175
|
+
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1176
|
+
this.#sessionId = header?.id ?? Snowflake.next();
|
|
1177
|
+
this.#sessionName = header?.title;
|
|
1178
|
+
|
|
1179
|
+
if (migrateToCurrentVersion(this.#fileEntries)) {
|
|
1180
|
+
await this.#rewriteFile();
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
await resolveBlobRefsInEntries(this.#fileEntries, this.#blobStore);
|
|
1184
|
+
|
|
1185
|
+
this.#buildIndex();
|
|
1186
|
+
this.#flushed = true;
|
|
1187
|
+
} else {
|
|
1188
|
+
const explicitPath = this.#sessionFile;
|
|
1189
|
+
this.#newSessionSync();
|
|
1190
|
+
this.#sessionFile = explicitPath; // preserve explicit path from --session flag
|
|
1191
|
+
await this.#rewriteFile();
|
|
1192
|
+
this.#flushed = true;
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
/** Start a new session. Closes any existing writer first. */
|
|
1198
|
+
async newSession(options?: NewSessionOptions): Promise<string | undefined> {
|
|
1199
|
+
await this.#closePersistWriter();
|
|
1200
|
+
return this.#newSessionSync(options);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Fork the current session, creating a new session file with the same entries.
|
|
1205
|
+
* Returns both the old and new session file paths for artifact copying.
|
|
1206
|
+
* @returns { oldSessionFile, newSessionFile } or undefined if not persisting
|
|
1207
|
+
*/
|
|
1208
|
+
async fork(): Promise<{ oldSessionFile: string; newSessionFile: string } | undefined> {
|
|
1209
|
+
if (!this.persist || !this.#sessionFile) {
|
|
1210
|
+
return undefined;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const oldSessionFile = this.#sessionFile;
|
|
1214
|
+
const oldSessionId = this.#sessionId;
|
|
1215
|
+
|
|
1216
|
+
// Close the current writer
|
|
1217
|
+
await this.#closePersistWriter();
|
|
1218
|
+
this.#persistChain = Promise.resolve();
|
|
1219
|
+
this.#persistError = undefined;
|
|
1220
|
+
this.#persistErrorReported = false;
|
|
1221
|
+
|
|
1222
|
+
// Create new session ID and header
|
|
1223
|
+
this.#sessionId = Snowflake.next();
|
|
1224
|
+
const timestamp = new Date().toISOString();
|
|
1225
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
1226
|
+
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
1227
|
+
|
|
1228
|
+
// Update the header with new ID but keep all entries
|
|
1229
|
+
const oldHeader = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1230
|
+
const newHeader: SessionHeader = {
|
|
1231
|
+
type: "session",
|
|
1232
|
+
version: CURRENT_SESSION_VERSION,
|
|
1233
|
+
id: this.#sessionId,
|
|
1234
|
+
title: oldHeader?.title ?? this.#sessionName,
|
|
1235
|
+
timestamp,
|
|
1236
|
+
cwd: this.cwd,
|
|
1237
|
+
parentSession: oldSessionId,
|
|
1238
|
+
};
|
|
1239
|
+
this.#sessionName = newHeader.title;
|
|
1240
|
+
|
|
1241
|
+
// Replace the header in fileEntries
|
|
1242
|
+
const entries = this.#fileEntries.filter(e => e.type !== "session") as SessionEntry[];
|
|
1243
|
+
this.#fileEntries = [newHeader, ...entries];
|
|
1244
|
+
|
|
1245
|
+
// Write the new session file
|
|
1246
|
+
this.#flushed = false;
|
|
1247
|
+
await this.#rewriteFile();
|
|
1248
|
+
|
|
1249
|
+
return { oldSessionFile, newSessionFile: this.#sessionFile };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Move the session to a new working directory.
|
|
1254
|
+
* Moves session files and artifacts on disk, updates all internal references,
|
|
1255
|
+
* and rewrites the session header with the new cwd.
|
|
1256
|
+
*/
|
|
1257
|
+
async moveTo(newCwd: string): Promise<void> {
|
|
1258
|
+
const resolvedCwd = path.resolve(newCwd);
|
|
1259
|
+
if (resolvedCwd === this.cwd) return;
|
|
1260
|
+
|
|
1261
|
+
const newSessionDir = getDefaultSessionDir(resolvedCwd, this.storage);
|
|
1262
|
+
|
|
1263
|
+
if (this.persist && this.#sessionFile) {
|
|
1264
|
+
// Close the persist writer before moving files
|
|
1265
|
+
await this.#closePersistWriter();
|
|
1266
|
+
this.#persistChain = Promise.resolve();
|
|
1267
|
+
this.#persistError = undefined;
|
|
1268
|
+
this.#persistErrorReported = false;
|
|
1269
|
+
|
|
1270
|
+
const oldSessionFile = this.#sessionFile;
|
|
1271
|
+
const newSessionFile = path.join(newSessionDir, path.basename(oldSessionFile));
|
|
1272
|
+
const oldArtifactDir = oldSessionFile.slice(0, -6); // strip .jsonl
|
|
1273
|
+
const newArtifactDir = newSessionFile.slice(0, -6);
|
|
1274
|
+
let movedSessionFile = false;
|
|
1275
|
+
let movedArtifactDir = false;
|
|
1276
|
+
|
|
1277
|
+
try {
|
|
1278
|
+
await fs.promises.rename(oldSessionFile, newSessionFile);
|
|
1279
|
+
movedSessionFile = true;
|
|
1280
|
+
|
|
1281
|
+
try {
|
|
1282
|
+
const stat = await fs.promises.stat(oldArtifactDir);
|
|
1283
|
+
if (stat.isDirectory()) {
|
|
1284
|
+
await fs.promises.rename(oldArtifactDir, newArtifactDir);
|
|
1285
|
+
movedArtifactDir = true;
|
|
1286
|
+
}
|
|
1287
|
+
} catch (err) {
|
|
1288
|
+
if (!isEnoent(err)) throw err;
|
|
1289
|
+
}
|
|
1290
|
+
} catch (err) {
|
|
1291
|
+
if (movedArtifactDir) {
|
|
1292
|
+
try {
|
|
1293
|
+
await fs.promises.rename(newArtifactDir, oldArtifactDir);
|
|
1294
|
+
} catch (rollbackErr) {
|
|
1295
|
+
throw new Error(
|
|
1296
|
+
`Failed to move artifacts and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
if (movedSessionFile) {
|
|
1301
|
+
try {
|
|
1302
|
+
await fs.promises.rename(newSessionFile, oldSessionFile);
|
|
1303
|
+
} catch (rollbackErr) {
|
|
1304
|
+
throw new Error(
|
|
1305
|
+
`Failed to move session file and rollback: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`,
|
|
1306
|
+
);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
throw err;
|
|
1310
|
+
}
|
|
1311
|
+
this.#sessionFile = newSessionFile;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
// Update cwd and sessionDir (controlled mutation of readonly fields)
|
|
1315
|
+
(this as unknown as { cwd: string }).cwd = resolvedCwd;
|
|
1316
|
+
(this as unknown as { sessionDir: string }).sessionDir = newSessionDir;
|
|
1317
|
+
|
|
1318
|
+
// Update the session header in fileEntries
|
|
1319
|
+
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1320
|
+
if (header) {
|
|
1321
|
+
header.cwd = resolvedCwd;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// Rewrite the session file at its new location with updated header
|
|
1325
|
+
if (this.persist && this.#sessionFile) {
|
|
1326
|
+
await this.#rewriteFile();
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
// Update terminal breadcrumb
|
|
1330
|
+
if (this.#sessionFile) {
|
|
1331
|
+
writeTerminalBreadcrumb(resolvedCwd, this.#sessionFile);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
/** Sync version for initial creation (no existing writer to close) */
|
|
1336
|
+
#newSessionSync(options?: NewSessionOptions): string | undefined {
|
|
1337
|
+
this.#persistChain = Promise.resolve();
|
|
1338
|
+
this.#persistError = undefined;
|
|
1339
|
+
this.#persistErrorReported = false;
|
|
1340
|
+
this.#sessionId = Snowflake.next();
|
|
1341
|
+
this.#sessionName = undefined;
|
|
1342
|
+
const timestamp = new Date().toISOString();
|
|
1343
|
+
const header: SessionHeader = {
|
|
1344
|
+
type: "session",
|
|
1345
|
+
version: CURRENT_SESSION_VERSION,
|
|
1346
|
+
id: this.#sessionId,
|
|
1347
|
+
timestamp,
|
|
1348
|
+
cwd: this.cwd,
|
|
1349
|
+
parentSession: options?.parentSession,
|
|
1350
|
+
};
|
|
1351
|
+
this.#fileEntries = [header];
|
|
1352
|
+
this.#byId.clear();
|
|
1353
|
+
this.#labelsById.clear();
|
|
1354
|
+
this.#leafId = null;
|
|
1355
|
+
this.#flushed = false;
|
|
1356
|
+
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1357
|
+
|
|
1358
|
+
if (this.persist) {
|
|
1359
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
1360
|
+
this.#sessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${this.#sessionId}.jsonl`);
|
|
1361
|
+
writeTerminalBreadcrumb(this.cwd, this.#sessionFile);
|
|
1362
|
+
}
|
|
1363
|
+
return this.#sessionFile;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
#buildIndex(): void {
|
|
1367
|
+
this.#byId.clear();
|
|
1368
|
+
this.#labelsById.clear();
|
|
1369
|
+
this.#leafId = null;
|
|
1370
|
+
this.#usageStatistics = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0 };
|
|
1371
|
+
for (const entry of this.#fileEntries) {
|
|
1372
|
+
if (entry.type === "session") continue;
|
|
1373
|
+
this.#byId.set(entry.id, entry);
|
|
1374
|
+
this.#leafId = entry.id;
|
|
1375
|
+
if (entry.type === "label") {
|
|
1376
|
+
if (entry.label) {
|
|
1377
|
+
this.#labelsById.set(entry.targetId, entry.label);
|
|
1378
|
+
} else {
|
|
1379
|
+
this.#labelsById.delete(entry.targetId);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
1383
|
+
const usage = entry.message.usage;
|
|
1384
|
+
this.#usageStatistics.input += usage.input;
|
|
1385
|
+
this.#usageStatistics.output += usage.output;
|
|
1386
|
+
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
1387
|
+
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
1388
|
+
this.#usageStatistics.cost += usage.cost.total;
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
1392
|
+
const usage = getTaskToolUsage(entry.message.details);
|
|
1393
|
+
if (usage) {
|
|
1394
|
+
this.#usageStatistics.input += usage.input;
|
|
1395
|
+
this.#usageStatistics.output += usage.output;
|
|
1396
|
+
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
1397
|
+
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
1398
|
+
this.#usageStatistics.cost += usage.cost.total;
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
#recordPersistError(err: unknown): Error {
|
|
1405
|
+
const normalized = toError(err);
|
|
1406
|
+
if (!this.#persistError) this.#persistError = normalized;
|
|
1407
|
+
if (!this.#persistErrorReported) {
|
|
1408
|
+
this.#persistErrorReported = true;
|
|
1409
|
+
logger.error("Session persistence error.", {
|
|
1410
|
+
sessionFile: this.#sessionFile,
|
|
1411
|
+
error: normalized.message,
|
|
1412
|
+
stack: normalized.stack,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
return normalized;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
#queuePersistTask(task: () => Promise<void>, options?: { ignoreError?: boolean }): Promise<void> {
|
|
1419
|
+
const next = this.#persistChain.then(async () => {
|
|
1420
|
+
if (this.#persistError && !options?.ignoreError) throw this.#persistError;
|
|
1421
|
+
await task();
|
|
1422
|
+
});
|
|
1423
|
+
this.#persistChain = next.catch(err => {
|
|
1424
|
+
this.#recordPersistError(err);
|
|
1425
|
+
});
|
|
1426
|
+
return next;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
#ensurePersistWriter(): NdjsonFileWriter | undefined {
|
|
1430
|
+
if (!this.persist || !this.#sessionFile) return undefined;
|
|
1431
|
+
if (this.#persistError) throw this.#persistError;
|
|
1432
|
+
if (this.#persistWriter && this.#persistWriterPath === this.#sessionFile) return this.#persistWriter;
|
|
1433
|
+
// Note: caller must await _closePersistWriter() before calling this if switching files
|
|
1434
|
+
this.#persistWriter = new NdjsonFileWriter(this.storage, this.#sessionFile, {
|
|
1435
|
+
onError: err => {
|
|
1436
|
+
this.#recordPersistError(err);
|
|
1437
|
+
},
|
|
1438
|
+
});
|
|
1439
|
+
this.#persistWriterPath = this.#sessionFile;
|
|
1440
|
+
return this.#persistWriter;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async #closePersistWriterInternal(): Promise<void> {
|
|
1444
|
+
if (this.#persistWriter) {
|
|
1445
|
+
await this.#persistWriter.close();
|
|
1446
|
+
this.#persistWriter = undefined;
|
|
1447
|
+
}
|
|
1448
|
+
this.#persistWriterPath = undefined;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
async #closePersistWriter(): Promise<void> {
|
|
1452
|
+
await this.#queuePersistTask(
|
|
1453
|
+
async () => {
|
|
1454
|
+
await this.#closePersistWriterInternal();
|
|
1455
|
+
},
|
|
1456
|
+
{ ignoreError: true },
|
|
1457
|
+
);
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
async #writeEntriesAtomically(entries: FileEntry[]): Promise<void> {
|
|
1461
|
+
if (!this.#sessionFile) return;
|
|
1462
|
+
const dir = path.resolve(this.#sessionFile, "..");
|
|
1463
|
+
const tempPath = path.join(dir, `.${path.basename(this.#sessionFile)}.${Snowflake.next()}.tmp`);
|
|
1464
|
+
const writer = new NdjsonFileWriter(this.storage, tempPath, { flags: "w" });
|
|
1465
|
+
try {
|
|
1466
|
+
for (const entry of entries) {
|
|
1467
|
+
await writer.write(entry);
|
|
1468
|
+
}
|
|
1469
|
+
await writer.flush();
|
|
1470
|
+
await writer.fsync();
|
|
1471
|
+
await writer.close();
|
|
1472
|
+
await this.storage.rename(tempPath, this.#sessionFile);
|
|
1473
|
+
} catch (err) {
|
|
1474
|
+
try {
|
|
1475
|
+
await writer.close();
|
|
1476
|
+
} catch {
|
|
1477
|
+
// Ignore cleanup errors
|
|
1478
|
+
}
|
|
1479
|
+
try {
|
|
1480
|
+
await this.storage.unlink(tempPath);
|
|
1481
|
+
} catch {
|
|
1482
|
+
// Ignore cleanup errors
|
|
1483
|
+
}
|
|
1484
|
+
throw toError(err);
|
|
1485
|
+
}
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
async #rewriteFile(): Promise<void> {
|
|
1489
|
+
if (!this.persist || !this.#sessionFile) return;
|
|
1490
|
+
await this.#queuePersistTask(async () => {
|
|
1491
|
+
await this.#closePersistWriterInternal();
|
|
1492
|
+
const entries = await Promise.all(
|
|
1493
|
+
this.#fileEntries.map(entry => prepareEntryForPersistence(entry, this.#blobStore)),
|
|
1494
|
+
);
|
|
1495
|
+
await this.#writeEntriesAtomically(entries);
|
|
1496
|
+
this.#flushed = true;
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
isPersisted(): boolean {
|
|
1501
|
+
return this.persist;
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
/** Flush pending writes to disk. Call before switching sessions or on shutdown. */
|
|
1505
|
+
async flush(): Promise<void> {
|
|
1506
|
+
if (!this.#persistWriter) return;
|
|
1507
|
+
await this.#queuePersistTask(async () => {
|
|
1508
|
+
if (this.#persistWriter) {
|
|
1509
|
+
await this.#persistWriter.flush();
|
|
1510
|
+
await this.#persistWriter.fsync();
|
|
1511
|
+
}
|
|
1512
|
+
});
|
|
1513
|
+
if (this.#persistError) throw this.#persistError;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
getCwd(): string {
|
|
1517
|
+
return this.cwd;
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/** Get usage statistics across all assistant messages in the session. */
|
|
1521
|
+
getUsageStatistics(): UsageStatistics {
|
|
1522
|
+
return this.#usageStatistics;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
getSessionDir(): string {
|
|
1526
|
+
return this.sessionDir;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
getSessionId(): string {
|
|
1530
|
+
return this.#sessionId;
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
getSessionFile(): string | undefined {
|
|
1534
|
+
return this.#sessionFile;
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
getSessionName(): string | undefined {
|
|
1538
|
+
return this.#sessionName;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
async setSessionName(name: string): Promise<void> {
|
|
1542
|
+
this.#sessionName = name;
|
|
1543
|
+
|
|
1544
|
+
// Update the in-memory header (so first flush includes title)
|
|
1545
|
+
const header = this.#fileEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
1546
|
+
if (header) {
|
|
1547
|
+
header.title = name;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
// Update the session file header with the title (if already flushed)
|
|
1551
|
+
const sessionFile = this.#sessionFile;
|
|
1552
|
+
if (this.persist && sessionFile && this.storage.existsSync(sessionFile)) {
|
|
1553
|
+
await this.#rewriteFile();
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
_persist(entry: SessionEntry): void {
|
|
1558
|
+
if (!this.persist || !this.#sessionFile) return;
|
|
1559
|
+
if (this.#persistError) throw this.#persistError;
|
|
1560
|
+
|
|
1561
|
+
const hasAssistant = this.#fileEntries.some(e => e.type === "message" && e.message.role === "assistant");
|
|
1562
|
+
if (!hasAssistant) {
|
|
1563
|
+
// Mark as not flushed so when assistant arrives, all entries get written
|
|
1564
|
+
this.#flushed = false;
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
if (!this.#flushed) {
|
|
1569
|
+
this.#flushed = true;
|
|
1570
|
+
void this.#queuePersistTask(async () => {
|
|
1571
|
+
const writer = this.#ensurePersistWriter();
|
|
1572
|
+
if (!writer) return;
|
|
1573
|
+
const entries = await Promise.all(
|
|
1574
|
+
this.#fileEntries.map(e => prepareEntryForPersistence(e, this.#blobStore)),
|
|
1575
|
+
);
|
|
1576
|
+
for (const persistedEntry of entries) {
|
|
1577
|
+
await writer.write(persistedEntry);
|
|
1578
|
+
}
|
|
1579
|
+
});
|
|
1580
|
+
} else {
|
|
1581
|
+
void this.#queuePersistTask(async () => {
|
|
1582
|
+
const writer = this.#ensurePersistWriter();
|
|
1583
|
+
if (!writer) return;
|
|
1584
|
+
const persistedEntry = await prepareEntryForPersistence(entry, this.#blobStore);
|
|
1585
|
+
await writer.write(persistedEntry);
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
#appendEntry(entry: SessionEntry): void {
|
|
1591
|
+
this.#fileEntries.push(entry);
|
|
1592
|
+
this.#byId.set(entry.id, entry);
|
|
1593
|
+
this.#leafId = entry.id;
|
|
1594
|
+
this._persist(entry);
|
|
1595
|
+
if (entry.type === "message" && entry.message.role === "assistant") {
|
|
1596
|
+
const usage = entry.message.usage;
|
|
1597
|
+
this.#usageStatistics.input += usage.input;
|
|
1598
|
+
this.#usageStatistics.output += usage.output;
|
|
1599
|
+
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
1600
|
+
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
1601
|
+
this.#usageStatistics.cost += usage.cost.total;
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
if (entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolName === "task") {
|
|
1605
|
+
const usage = getTaskToolUsage(entry.message.details);
|
|
1606
|
+
if (usage) {
|
|
1607
|
+
this.#usageStatistics.input += usage.input;
|
|
1608
|
+
this.#usageStatistics.output += usage.output;
|
|
1609
|
+
this.#usageStatistics.cacheRead += usage.cacheRead;
|
|
1610
|
+
this.#usageStatistics.cacheWrite += usage.cacheWrite;
|
|
1611
|
+
this.#usageStatistics.cost += usage.cost.total;
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
/** Append a message as child of current leaf, then advance leaf. Returns entry id.
|
|
1617
|
+
* Does not allow writing CompactionSummaryMessage and BranchSummaryMessage directly.
|
|
1618
|
+
* Reason: we want these to be top-level entries in the session, not message session entries,
|
|
1619
|
+
* so it is easier to find them.
|
|
1620
|
+
* These need to be appended via appendCompaction() and appendBranchSummary() methods.
|
|
1621
|
+
*/
|
|
1622
|
+
appendMessage(
|
|
1623
|
+
message:
|
|
1624
|
+
| Message
|
|
1625
|
+
| CustomMessage
|
|
1626
|
+
| HookMessage
|
|
1627
|
+
| BashExecutionMessage
|
|
1628
|
+
| PythonExecutionMessage
|
|
1629
|
+
| FileMentionMessage,
|
|
1630
|
+
): string {
|
|
1631
|
+
const entry: SessionMessageEntry = {
|
|
1632
|
+
type: "message",
|
|
1633
|
+
id: generateId(this.#byId),
|
|
1634
|
+
parentId: this.#leafId,
|
|
1635
|
+
timestamp: new Date().toISOString(),
|
|
1636
|
+
message,
|
|
1637
|
+
};
|
|
1638
|
+
this.#appendEntry(entry);
|
|
1639
|
+
return entry.id;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
/** Append a thinking level change as child of current leaf, then advance leaf. Returns entry id. */
|
|
1643
|
+
appendThinkingLevelChange(thinkingLevel: string): string {
|
|
1644
|
+
const entry: ThinkingLevelChangeEntry = {
|
|
1645
|
+
type: "thinking_level_change",
|
|
1646
|
+
id: generateId(this.#byId),
|
|
1647
|
+
parentId: this.#leafId,
|
|
1648
|
+
timestamp: new Date().toISOString(),
|
|
1649
|
+
thinkingLevel,
|
|
1650
|
+
};
|
|
1651
|
+
this.#appendEntry(entry);
|
|
1652
|
+
return entry.id;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
/** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */
|
|
1656
|
+
appendModeChange(mode: string, data?: Record<string, unknown>): string {
|
|
1657
|
+
const entry: ModeChangeEntry = {
|
|
1658
|
+
type: "mode_change",
|
|
1659
|
+
id: generateId(this.#byId),
|
|
1660
|
+
parentId: this.#leafId,
|
|
1661
|
+
timestamp: new Date().toISOString(),
|
|
1662
|
+
mode,
|
|
1663
|
+
data,
|
|
1664
|
+
};
|
|
1665
|
+
this.#appendEntry(entry);
|
|
1666
|
+
return entry.id;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
/**
|
|
1670
|
+
* Append a model change as child of current leaf, then advance leaf. Returns entry id.
|
|
1671
|
+
* @param model Model in "provider/modelId" format
|
|
1672
|
+
* @param role Optional role (default: "default")
|
|
1673
|
+
*/
|
|
1674
|
+
appendModelChange(model: string, role?: string): string {
|
|
1675
|
+
const entry: ModelChangeEntry = {
|
|
1676
|
+
type: "model_change",
|
|
1677
|
+
id: generateId(this.#byId),
|
|
1678
|
+
parentId: this.#leafId,
|
|
1679
|
+
timestamp: new Date().toISOString(),
|
|
1680
|
+
model,
|
|
1681
|
+
role,
|
|
1682
|
+
};
|
|
1683
|
+
this.#appendEntry(entry);
|
|
1684
|
+
return entry.id;
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/** Append session init metadata (for subagent debugging/replay). Returns entry id. */
|
|
1688
|
+
appendSessionInit(init: { systemPrompt: string; task: string; tools: string[]; outputSchema?: unknown }): string {
|
|
1689
|
+
const entry: SessionInitEntry = {
|
|
1690
|
+
type: "session_init",
|
|
1691
|
+
id: generateId(this.#byId),
|
|
1692
|
+
parentId: this.#leafId,
|
|
1693
|
+
timestamp: new Date().toISOString(),
|
|
1694
|
+
...init,
|
|
1695
|
+
};
|
|
1696
|
+
this.#appendEntry(entry);
|
|
1697
|
+
return entry.id;
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
/** Append a compaction summary as child of current leaf, then advance leaf. Returns entry id. */
|
|
1701
|
+
appendCompaction<T = unknown>(
|
|
1702
|
+
summary: string,
|
|
1703
|
+
shortSummary: string | undefined,
|
|
1704
|
+
firstKeptEntryId: string,
|
|
1705
|
+
tokensBefore: number,
|
|
1706
|
+
details?: T,
|
|
1707
|
+
fromExtension?: boolean,
|
|
1708
|
+
preserveData?: Record<string, unknown>,
|
|
1709
|
+
): string {
|
|
1710
|
+
const entry: CompactionEntry<T> = {
|
|
1711
|
+
type: "compaction",
|
|
1712
|
+
id: generateId(this.#byId),
|
|
1713
|
+
parentId: this.#leafId,
|
|
1714
|
+
timestamp: new Date().toISOString(),
|
|
1715
|
+
summary,
|
|
1716
|
+
shortSummary,
|
|
1717
|
+
firstKeptEntryId,
|
|
1718
|
+
tokensBefore,
|
|
1719
|
+
details,
|
|
1720
|
+
fromExtension,
|
|
1721
|
+
preserveData,
|
|
1722
|
+
};
|
|
1723
|
+
this.#appendEntry(entry);
|
|
1724
|
+
return entry.id;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** Append a custom entry (for extensions) as child of current leaf, then advance leaf. Returns entry id. */
|
|
1728
|
+
appendCustomEntry(customType: string, data?: unknown): string {
|
|
1729
|
+
const entry: CustomEntry = {
|
|
1730
|
+
type: "custom",
|
|
1731
|
+
customType,
|
|
1732
|
+
data,
|
|
1733
|
+
id: generateId(this.#byId),
|
|
1734
|
+
parentId: this.#leafId,
|
|
1735
|
+
timestamp: new Date().toISOString(),
|
|
1736
|
+
};
|
|
1737
|
+
this.#appendEntry(entry);
|
|
1738
|
+
return entry.id;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
/**
|
|
1742
|
+
* Rewrite the session file after in-place entry updates.
|
|
1743
|
+
* Use sparingly (e.g., pruning old tool outputs).
|
|
1744
|
+
*/
|
|
1745
|
+
async rewriteEntries(): Promise<void> {
|
|
1746
|
+
if (!this.persist || !this.#sessionFile) return;
|
|
1747
|
+
await this.#rewriteFile();
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1750
|
+
/**
|
|
1751
|
+
* Rewrite tool call arguments in the most recent assistant message containing the toolCallId.
|
|
1752
|
+
* Returns true if a tool call was updated.
|
|
1753
|
+
*/
|
|
1754
|
+
async rewriteAssistantToolCallArgs(toolCallId: string, args: Record<string, unknown>): Promise<boolean> {
|
|
1755
|
+
let updated = false;
|
|
1756
|
+
for (let i = this.#fileEntries.length - 1; i >= 0; i--) {
|
|
1757
|
+
const entry = this.#fileEntries[i];
|
|
1758
|
+
if (entry.type !== "message" || entry.message.role !== "assistant") continue;
|
|
1759
|
+
const message = entry.message as { content?: unknown };
|
|
1760
|
+
if (!Array.isArray(message.content)) continue;
|
|
1761
|
+
for (const block of message.content) {
|
|
1762
|
+
if (typeof block !== "object" || block === null) continue;
|
|
1763
|
+
if (!("type" in block) || (block as { type?: string }).type !== "toolCall") continue;
|
|
1764
|
+
const toolCall = block as { id?: string; arguments?: Record<string, unknown> };
|
|
1765
|
+
if (toolCall.id === toolCallId) {
|
|
1766
|
+
toolCall.arguments = args;
|
|
1767
|
+
updated = true;
|
|
1768
|
+
break;
|
|
1769
|
+
}
|
|
1770
|
+
}
|
|
1771
|
+
if (updated) break;
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
if (updated && this.persist && this.#sessionFile) {
|
|
1775
|
+
await this.#rewriteFile();
|
|
1776
|
+
}
|
|
1777
|
+
return updated;
|
|
1778
|
+
}
|
|
1779
|
+
|
|
1780
|
+
/**
|
|
1781
|
+
* Append a custom message entry (for extensions) that participates in LLM context.
|
|
1782
|
+
* @param customType Hook identifier for filtering on reload
|
|
1783
|
+
* @param content Message content (string or TextContent/ImageContent array)
|
|
1784
|
+
* @param display Whether to show in TUI (true = styled display, false = hidden)
|
|
1785
|
+
* @param details Optional extension-specific metadata (not sent to LLM)
|
|
1786
|
+
* @returns Entry id
|
|
1787
|
+
*/
|
|
1788
|
+
appendCustomMessageEntry<T = unknown>(
|
|
1789
|
+
customType: string,
|
|
1790
|
+
content: string | (TextContent | ImageContent)[],
|
|
1791
|
+
display: boolean,
|
|
1792
|
+
details?: T,
|
|
1793
|
+
): string {
|
|
1794
|
+
const entry: CustomMessageEntry<T> = {
|
|
1795
|
+
type: "custom_message",
|
|
1796
|
+
customType,
|
|
1797
|
+
content,
|
|
1798
|
+
display,
|
|
1799
|
+
details,
|
|
1800
|
+
id: generateId(this.#byId),
|
|
1801
|
+
parentId: this.#leafId,
|
|
1802
|
+
timestamp: new Date().toISOString(),
|
|
1803
|
+
};
|
|
1804
|
+
this.#appendEntry(entry);
|
|
1805
|
+
return entry.id;
|
|
1806
|
+
}
|
|
1807
|
+
|
|
1808
|
+
// =========================================================================
|
|
1809
|
+
// TTSR (Time Traveling Stream Rules)
|
|
1810
|
+
// =========================================================================
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* Append a TTSR injection entry recording which rules were injected.
|
|
1814
|
+
* @param ruleNames Names of rules that were injected
|
|
1815
|
+
* @returns Entry id
|
|
1816
|
+
*/
|
|
1817
|
+
appendTtsrInjection(ruleNames: string[]): string {
|
|
1818
|
+
const entry: TtsrInjectionEntry = {
|
|
1819
|
+
type: "ttsr_injection",
|
|
1820
|
+
id: generateId(this.#byId),
|
|
1821
|
+
parentId: this.#leafId,
|
|
1822
|
+
timestamp: new Date().toISOString(),
|
|
1823
|
+
injectedRules: ruleNames,
|
|
1824
|
+
};
|
|
1825
|
+
this.#appendEntry(entry);
|
|
1826
|
+
return entry.id;
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
/**
|
|
1830
|
+
* Get all unique TTSR rule names that have been injected in the current branch.
|
|
1831
|
+
* Scans from root to current leaf for ttsr_injection entries.
|
|
1832
|
+
*/
|
|
1833
|
+
getInjectedTtsrRules(): string[] {
|
|
1834
|
+
const path = this.getBranch();
|
|
1835
|
+
const ruleNames = new Set<string>();
|
|
1836
|
+
for (const entry of path) {
|
|
1837
|
+
if (entry.type === "ttsr_injection") {
|
|
1838
|
+
for (const name of entry.injectedRules) {
|
|
1839
|
+
ruleNames.add(name);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
}
|
|
1843
|
+
return Array.from(ruleNames);
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// =========================================================================
|
|
1847
|
+
// Tree Traversal
|
|
1848
|
+
// =========================================================================
|
|
1849
|
+
|
|
1850
|
+
getLeafId(): string | null {
|
|
1851
|
+
return this.#leafId;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
getLeafEntry(): SessionEntry | undefined {
|
|
1855
|
+
return this.#leafId ? this.#byId.get(this.#leafId) : undefined;
|
|
1856
|
+
}
|
|
1857
|
+
|
|
1858
|
+
/**
|
|
1859
|
+
* Get the most recent model role from the current session path.
|
|
1860
|
+
* Returns undefined if no model change has been recorded.
|
|
1861
|
+
*/
|
|
1862
|
+
getLastModelChangeRole(): string | undefined {
|
|
1863
|
+
let current = this.getLeafEntry();
|
|
1864
|
+
while (current) {
|
|
1865
|
+
if (current.type === "model_change") {
|
|
1866
|
+
return current.role ?? "default";
|
|
1867
|
+
}
|
|
1868
|
+
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
1869
|
+
}
|
|
1870
|
+
return undefined;
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
getEntry(id: string): SessionEntry | undefined {
|
|
1874
|
+
return this.#byId.get(id);
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
/**
|
|
1878
|
+
* Get all direct children of an entry.
|
|
1879
|
+
*/
|
|
1880
|
+
getChildren(parentId: string): SessionEntry[] {
|
|
1881
|
+
const children: SessionEntry[] = [];
|
|
1882
|
+
for (const entry of this.#byId.values()) {
|
|
1883
|
+
if (entry.parentId === parentId) {
|
|
1884
|
+
children.push(entry);
|
|
1885
|
+
}
|
|
1886
|
+
}
|
|
1887
|
+
return children;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
/**
|
|
1891
|
+
* Get the label for an entry, if any.
|
|
1892
|
+
*/
|
|
1893
|
+
getLabel(id: string): string | undefined {
|
|
1894
|
+
return this.#labelsById.get(id);
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
/**
|
|
1898
|
+
* Set or clear a label on an entry.
|
|
1899
|
+
* Labels are user-defined markers for bookmarking/navigation.
|
|
1900
|
+
* Pass undefined or empty string to clear the label.
|
|
1901
|
+
*/
|
|
1902
|
+
appendLabelChange(targetId: string, label: string | undefined): string {
|
|
1903
|
+
if (!this.#byId.has(targetId)) {
|
|
1904
|
+
throw new Error(`Entry ${targetId} not found`);
|
|
1905
|
+
}
|
|
1906
|
+
const entry: LabelEntry = {
|
|
1907
|
+
type: "label",
|
|
1908
|
+
id: generateId(this.#byId),
|
|
1909
|
+
parentId: this.#leafId,
|
|
1910
|
+
timestamp: new Date().toISOString(),
|
|
1911
|
+
targetId,
|
|
1912
|
+
label,
|
|
1913
|
+
};
|
|
1914
|
+
this.#appendEntry(entry);
|
|
1915
|
+
if (label) {
|
|
1916
|
+
this.#labelsById.set(targetId, label);
|
|
1917
|
+
} else {
|
|
1918
|
+
this.#labelsById.delete(targetId);
|
|
1919
|
+
}
|
|
1920
|
+
return entry.id;
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* Walk from entry to root, returning all entries in path order.
|
|
1925
|
+
* Includes all entry types (messages, compaction, model changes, etc.).
|
|
1926
|
+
* Use buildSessionContext() to get the resolved messages for the LLM.
|
|
1927
|
+
*/
|
|
1928
|
+
getBranch(fromId?: string): SessionEntry[] {
|
|
1929
|
+
const path: SessionEntry[] = [];
|
|
1930
|
+
const startId = fromId ?? this.#leafId;
|
|
1931
|
+
let current = startId ? this.#byId.get(startId) : undefined;
|
|
1932
|
+
while (current) {
|
|
1933
|
+
path.unshift(current);
|
|
1934
|
+
current = current.parentId ? this.#byId.get(current.parentId) : undefined;
|
|
1935
|
+
}
|
|
1936
|
+
return path;
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
/**
|
|
1940
|
+
* Build the session context (what gets sent to the LLM).
|
|
1941
|
+
* Uses tree traversal from current leaf.
|
|
1942
|
+
*/
|
|
1943
|
+
buildSessionContext(): SessionContext {
|
|
1944
|
+
return buildSessionContext(this.getEntries(), this.#leafId, this.#byId);
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
/**
|
|
1948
|
+
* Get session header.
|
|
1949
|
+
*/
|
|
1950
|
+
getHeader(): SessionHeader | null {
|
|
1951
|
+
const h = this.#fileEntries.find(e => e.type === "session");
|
|
1952
|
+
return h ? (h as SessionHeader) : null;
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
/**
|
|
1956
|
+
* Get all session entries (excludes header). Returns a shallow copy.
|
|
1957
|
+
* The session is append-only: use appendXXX() to add entries, branch() to
|
|
1958
|
+
* change the leaf pointer. Entries cannot be modified or deleted.
|
|
1959
|
+
*/
|
|
1960
|
+
getEntries(): SessionEntry[] {
|
|
1961
|
+
return this.#fileEntries.filter((e): e is SessionEntry => e.type !== "session");
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
/**
|
|
1965
|
+
* Get the session as a tree structure. Returns a shallow defensive copy of all entries.
|
|
1966
|
+
* A well-formed session has exactly one root (first entry with parentId === null).
|
|
1967
|
+
* Orphaned entries (broken parent chain) are also returned as roots.
|
|
1968
|
+
*/
|
|
1969
|
+
getTree(): SessionTreeNode[] {
|
|
1970
|
+
const entries = this.getEntries();
|
|
1971
|
+
const nodeMap = new Map<string, SessionTreeNode>();
|
|
1972
|
+
const roots: SessionTreeNode[] = [];
|
|
1973
|
+
|
|
1974
|
+
// Create nodes with resolved labels
|
|
1975
|
+
for (const entry of entries) {
|
|
1976
|
+
const label = this.#labelsById.get(entry.id);
|
|
1977
|
+
nodeMap.set(entry.id, { entry, children: [], label });
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// Build tree
|
|
1981
|
+
for (const entry of entries) {
|
|
1982
|
+
const node = nodeMap.get(entry.id)!;
|
|
1983
|
+
if (entry.parentId === null || entry.parentId === entry.id) {
|
|
1984
|
+
roots.push(node);
|
|
1985
|
+
} else {
|
|
1986
|
+
const parent = nodeMap.get(entry.parentId);
|
|
1987
|
+
if (parent) {
|
|
1988
|
+
parent.children.push(node);
|
|
1989
|
+
} else {
|
|
1990
|
+
// Orphan - treat as root
|
|
1991
|
+
roots.push(node);
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
// Sort children by timestamp (oldest first, newest at bottom)
|
|
1997
|
+
// Use iterative approach to avoid stack overflow on deep trees
|
|
1998
|
+
const stack: SessionTreeNode[] = [...roots];
|
|
1999
|
+
while (stack.length > 0) {
|
|
2000
|
+
const node = stack.pop()!;
|
|
2001
|
+
node.children.sort((a, b) => new Date(a.entry.timestamp).getTime() - new Date(b.entry.timestamp).getTime());
|
|
2002
|
+
stack.push(...node.children);
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
return roots;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
// =========================================================================
|
|
2009
|
+
// Branching
|
|
2010
|
+
// =========================================================================
|
|
2011
|
+
|
|
2012
|
+
/**
|
|
2013
|
+
* Start a new branch from an earlier entry.
|
|
2014
|
+
* Moves the leaf pointer to the specified entry. The next appendXXX() call
|
|
2015
|
+
* will create a child of that entry, forming a new branch. Existing entries
|
|
2016
|
+
* are not modified or deleted.
|
|
2017
|
+
*/
|
|
2018
|
+
branch(branchFromId: string): void {
|
|
2019
|
+
if (!this.#byId.has(branchFromId)) {
|
|
2020
|
+
throw new Error(`Entry ${branchFromId} not found`);
|
|
2021
|
+
}
|
|
2022
|
+
this.#leafId = branchFromId;
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* Reset the leaf pointer to null (before any entries).
|
|
2027
|
+
* The next appendXXX() call will create a new root entry (parentId = null).
|
|
2028
|
+
* Use this when navigating to re-edit the first user message.
|
|
2029
|
+
*/
|
|
2030
|
+
resetLeaf(): void {
|
|
2031
|
+
this.#leafId = null;
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/**
|
|
2035
|
+
* Start a new branch with a summary of the abandoned path.
|
|
2036
|
+
* Same as branch(), but also appends a branch_summary entry that captures
|
|
2037
|
+
* context from the abandoned conversation path.
|
|
2038
|
+
*/
|
|
2039
|
+
branchWithSummary(branchFromId: string | null, summary: string, details?: unknown, fromExtension?: boolean): string {
|
|
2040
|
+
if (branchFromId !== null && !this.#byId.has(branchFromId)) {
|
|
2041
|
+
throw new Error(`Entry ${branchFromId} not found`);
|
|
2042
|
+
}
|
|
2043
|
+
this.#leafId = branchFromId;
|
|
2044
|
+
const entry: BranchSummaryEntry = {
|
|
2045
|
+
type: "branch_summary",
|
|
2046
|
+
id: generateId(this.#byId),
|
|
2047
|
+
parentId: branchFromId,
|
|
2048
|
+
timestamp: new Date().toISOString(),
|
|
2049
|
+
fromId: branchFromId ?? "root",
|
|
2050
|
+
summary,
|
|
2051
|
+
details,
|
|
2052
|
+
fromExtension,
|
|
2053
|
+
};
|
|
2054
|
+
this.#appendEntry(entry);
|
|
2055
|
+
return entry.id;
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
/**
|
|
2059
|
+
* Create a new session file containing only the path from root to the specified leaf.
|
|
2060
|
+
* Useful for extracting a single conversation path from a branched session.
|
|
2061
|
+
* Returns the new session file path, or undefined if not persisting.
|
|
2062
|
+
*/
|
|
2063
|
+
createBranchedSession(leafId: string): string | undefined {
|
|
2064
|
+
const previousSessionFile = this.#sessionFile;
|
|
2065
|
+
const branchPath = this.getBranch(leafId);
|
|
2066
|
+
if (branchPath.length === 0) {
|
|
2067
|
+
throw new Error(`Entry ${leafId} not found`);
|
|
2068
|
+
}
|
|
2069
|
+
|
|
2070
|
+
// Filter out LabelEntry from path - we'll recreate them from the resolved map
|
|
2071
|
+
const pathWithoutLabels = branchPath.filter(e => e.type !== "label");
|
|
2072
|
+
|
|
2073
|
+
const newSessionId = Snowflake.next();
|
|
2074
|
+
const timestamp = new Date().toISOString();
|
|
2075
|
+
const fileTimestamp = timestamp.replace(/[:.]/g, "-");
|
|
2076
|
+
const newSessionFile = path.join(this.getSessionDir(), `${fileTimestamp}_${newSessionId}.jsonl`);
|
|
2077
|
+
|
|
2078
|
+
const header: SessionHeader = {
|
|
2079
|
+
type: "session",
|
|
2080
|
+
version: CURRENT_SESSION_VERSION,
|
|
2081
|
+
id: newSessionId,
|
|
2082
|
+
timestamp,
|
|
2083
|
+
cwd: this.cwd,
|
|
2084
|
+
parentSession: this.persist ? previousSessionFile : undefined,
|
|
2085
|
+
};
|
|
2086
|
+
|
|
2087
|
+
// Collect labels for entries in the path
|
|
2088
|
+
const pathEntryIds = new Set(pathWithoutLabels.map(e => e.id));
|
|
2089
|
+
const labelsToWrite: Array<{ targetId: string; label: string }> = [];
|
|
2090
|
+
for (const [targetId, label] of this.#labelsById) {
|
|
2091
|
+
if (pathEntryIds.has(targetId)) {
|
|
2092
|
+
labelsToWrite.push({ targetId, label });
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
if (this.persist) {
|
|
2097
|
+
const lines: string[] = [];
|
|
2098
|
+
lines.push(JSON.stringify(header));
|
|
2099
|
+
for (const entry of pathWithoutLabels) {
|
|
2100
|
+
lines.push(JSON.stringify(entry));
|
|
2101
|
+
}
|
|
2102
|
+
// Write fresh label entries at the end
|
|
2103
|
+
const lastEntryId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
2104
|
+
let parentId = lastEntryId;
|
|
2105
|
+
const labelEntries: LabelEntry[] = [];
|
|
2106
|
+
for (const { targetId, label } of labelsToWrite) {
|
|
2107
|
+
const labelEntry: LabelEntry = {
|
|
2108
|
+
type: "label",
|
|
2109
|
+
id: generateId(new Set(pathEntryIds)),
|
|
2110
|
+
parentId,
|
|
2111
|
+
timestamp: new Date().toISOString(),
|
|
2112
|
+
targetId,
|
|
2113
|
+
label,
|
|
2114
|
+
};
|
|
2115
|
+
lines.push(JSON.stringify(labelEntry));
|
|
2116
|
+
pathEntryIds.add(labelEntry.id);
|
|
2117
|
+
labelEntries.push(labelEntry);
|
|
2118
|
+
parentId = labelEntry.id;
|
|
2119
|
+
}
|
|
2120
|
+
this.storage.writeTextSync(newSessionFile, `${lines.join("\n")}\n`);
|
|
2121
|
+
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
2122
|
+
this.#sessionId = newSessionId;
|
|
2123
|
+
this.#sessionFile = newSessionFile;
|
|
2124
|
+
this.#flushed = true;
|
|
2125
|
+
this.#buildIndex();
|
|
2126
|
+
return newSessionFile;
|
|
2127
|
+
}
|
|
2128
|
+
|
|
2129
|
+
// In-memory mode: replace current session with the path + labels
|
|
2130
|
+
const labelEntries: LabelEntry[] = [];
|
|
2131
|
+
let parentId = pathWithoutLabels[pathWithoutLabels.length - 1]?.id || null;
|
|
2132
|
+
for (const { targetId, label } of labelsToWrite) {
|
|
2133
|
+
const labelEntry: LabelEntry = {
|
|
2134
|
+
type: "label",
|
|
2135
|
+
id: generateId(new Set([...pathEntryIds, ...labelEntries.map(e => e.id)])),
|
|
2136
|
+
parentId,
|
|
2137
|
+
timestamp: new Date().toISOString(),
|
|
2138
|
+
targetId,
|
|
2139
|
+
label,
|
|
2140
|
+
};
|
|
2141
|
+
labelEntries.push(labelEntry);
|
|
2142
|
+
parentId = labelEntry.id;
|
|
2143
|
+
}
|
|
2144
|
+
this.#fileEntries = [header, ...pathWithoutLabels, ...labelEntries];
|
|
2145
|
+
this.#sessionId = newSessionId;
|
|
2146
|
+
this.#buildIndex();
|
|
2147
|
+
return undefined;
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* Create a new session.
|
|
2152
|
+
* @param cwd Working directory (stored in session header)
|
|
2153
|
+
* @param sessionDir Optional session directory. If omitted, uses default (~/.arcane/agent/sessions/<encoded-cwd>/).
|
|
2154
|
+
*/
|
|
2155
|
+
static create(cwd: string, sessionDir?: string, storage: SessionStorage = new FileSessionStorage()): SessionManager {
|
|
2156
|
+
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
|
2157
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
2158
|
+
manager.#initNewSession();
|
|
2159
|
+
return manager;
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
/**
|
|
2163
|
+
* Fork a session into the current project directory.
|
|
2164
|
+
* Copies history from another session file while creating a new session file in the current sessionDir.
|
|
2165
|
+
*/
|
|
2166
|
+
static async forkFrom(
|
|
2167
|
+
sourcePath: string,
|
|
2168
|
+
cwd: string,
|
|
2169
|
+
sessionDir?: string,
|
|
2170
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
2171
|
+
): Promise<SessionManager> {
|
|
2172
|
+
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
|
2173
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
2174
|
+
const forkEntries = structuredClone(await loadEntriesFromFile(sourcePath, storage)) as FileEntry[];
|
|
2175
|
+
migrateToCurrentVersion(forkEntries);
|
|
2176
|
+
await resolveBlobRefsInEntries(forkEntries, manager.#blobStore);
|
|
2177
|
+
const sourceHeader = forkEntries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2178
|
+
const historyEntries = forkEntries.filter(entry => entry.type !== "session") as SessionEntry[];
|
|
2179
|
+
manager.#newSessionSync({ parentSession: sourceHeader?.id });
|
|
2180
|
+
const newHeader = manager.#fileEntries[0] as SessionHeader;
|
|
2181
|
+
newHeader.title = sourceHeader?.title;
|
|
2182
|
+
manager.#fileEntries = [newHeader, ...historyEntries];
|
|
2183
|
+
manager.#sessionName = newHeader.title;
|
|
2184
|
+
manager.#buildIndex();
|
|
2185
|
+
await manager.#rewriteFile();
|
|
2186
|
+
return manager;
|
|
2187
|
+
}
|
|
2188
|
+
|
|
2189
|
+
/**
|
|
2190
|
+
* Open a specific session file.
|
|
2191
|
+
* @param path Path to session file
|
|
2192
|
+
* @param sessionDir Optional session directory for /new or /branch. If omitted, derives from file's parent.
|
|
2193
|
+
*/
|
|
2194
|
+
static async open(
|
|
2195
|
+
filePath: string,
|
|
2196
|
+
sessionDir?: string,
|
|
2197
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
2198
|
+
): Promise<SessionManager> {
|
|
2199
|
+
// Extract cwd from session header if possible, otherwise use getProjectDir()
|
|
2200
|
+
const entries = await loadEntriesFromFile(filePath, storage);
|
|
2201
|
+
const header = entries.find(e => e.type === "session") as SessionHeader | undefined;
|
|
2202
|
+
const cwd = header?.cwd ?? getProjectDir();
|
|
2203
|
+
// If no sessionDir provided, derive from file's parent directory
|
|
2204
|
+
const dir = sessionDir ?? path.resolve(filePath, "..");
|
|
2205
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
2206
|
+
await manager.#initSessionFile(filePath);
|
|
2207
|
+
return manager;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
/**
|
|
2211
|
+
* Continue the most recent session, or create new if none.
|
|
2212
|
+
* @param cwd Working directory
|
|
2213
|
+
* @param sessionDir Optional session directory. If omitted, uses default (~/.arcane/agent/sessions/<encoded-cwd>/).
|
|
2214
|
+
*/
|
|
2215
|
+
static async continueRecent(
|
|
2216
|
+
cwd: string,
|
|
2217
|
+
sessionDir?: string,
|
|
2218
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
2219
|
+
): Promise<SessionManager> {
|
|
2220
|
+
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
|
2221
|
+
// Prefer terminal-scoped breadcrumb (handles concurrent sessions correctly)
|
|
2222
|
+
const terminalSession = await readTerminalBreadcrumb(cwd);
|
|
2223
|
+
const mostRecent = terminalSession ?? (await findMostRecentSession(dir, storage));
|
|
2224
|
+
const manager = new SessionManager(cwd, dir, true, storage);
|
|
2225
|
+
if (mostRecent) {
|
|
2226
|
+
await manager.#initSessionFile(mostRecent);
|
|
2227
|
+
} else {
|
|
2228
|
+
manager.#initNewSession();
|
|
2229
|
+
}
|
|
2230
|
+
return manager;
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
/** Create an in-memory session (no file persistence) */
|
|
2234
|
+
static inMemory(
|
|
2235
|
+
cwd: string = getProjectDir(),
|
|
2236
|
+
storage: SessionStorage = new MemorySessionStorage(),
|
|
2237
|
+
): SessionManager {
|
|
2238
|
+
const manager = new SessionManager(cwd, "", false, storage);
|
|
2239
|
+
manager.#initNewSession();
|
|
2240
|
+
return manager;
|
|
2241
|
+
}
|
|
2242
|
+
|
|
2243
|
+
/**
|
|
2244
|
+
* List all sessions.
|
|
2245
|
+
* @param cwd Working directory (used to compute default session directory)
|
|
2246
|
+
* @param sessionDir Optional session directory. If omitted, uses default (~/.arcane/agent/sessions/<encoded-cwd>/).
|
|
2247
|
+
*/
|
|
2248
|
+
static async list(
|
|
2249
|
+
cwd: string,
|
|
2250
|
+
sessionDir?: string,
|
|
2251
|
+
storage: SessionStorage = new FileSessionStorage(),
|
|
2252
|
+
): Promise<SessionInfo[]> {
|
|
2253
|
+
const dir = sessionDir ?? getDefaultSessionDir(cwd, storage);
|
|
2254
|
+
try {
|
|
2255
|
+
const files = storage.listFilesSync(dir, "*.jsonl");
|
|
2256
|
+
return await collectSessionsFromFiles(files, storage);
|
|
2257
|
+
} catch {
|
|
2258
|
+
return [];
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
/**
|
|
2263
|
+
* List all sessions across all project directories.
|
|
2264
|
+
*/
|
|
2265
|
+
static async listAll(storage: SessionStorage = new FileSessionStorage()): Promise<SessionInfo[]> {
|
|
2266
|
+
const sessionsRoot = path.join(getDefaultAgentDir(), "sessions");
|
|
2267
|
+
try {
|
|
2268
|
+
const files = Array.from(new Bun.Glob("**/*.jsonl").scanSync(sessionsRoot)).map(name =>
|
|
2269
|
+
path.join(sessionsRoot, name),
|
|
2270
|
+
);
|
|
2271
|
+
return await collectSessionsFromFiles(files, storage);
|
|
2272
|
+
} catch {
|
|
2273
|
+
return [];
|
|
2274
|
+
}
|
|
2275
|
+
}
|
|
2276
|
+
}
|