@maudecode/talaria-web 1.17.2 → 1.19.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/_release.json +8 -8
- package/dist/api/auth-raw.d.ts +1 -1
- package/dist/api/auth-raw.js +20 -20
- package/dist/api/auth-raw.js.map +1 -1
- package/dist/api/automation-raw.d.ts +7 -1
- package/dist/api/automation-raw.js +74 -27
- package/dist/api/automation-raw.js.map +1 -1
- package/dist/api/automation-router.d.ts +128 -23
- package/dist/api/automation-router.js +29 -13
- package/dist/api/automation-router.js.map +1 -1
- package/dist/api/chat-router.d.ts +1845 -40
- package/dist/api/chat-router.js +196 -114
- package/dist/api/chat-router.js.map +1 -1
- package/dist/api/git-router.d.ts +37 -3
- package/dist/api/git-router.js +47 -48
- package/dist/api/git-router.js.map +1 -1
- package/dist/api/raw-routes.d.ts +9 -1
- package/dist/api/raw-routes.js +286 -73
- package/dist/api/raw-routes.js.map +1 -1
- package/dist/api/router.d.ts +16 -6
- package/dist/api/router.js +28 -26
- package/dist/api/router.js.map +1 -1
- package/dist/api/session-visibility.d.ts +4 -4
- package/dist/api/session-visibility.js +26 -16
- package/dist/api/session-visibility.js.map +1 -1
- package/dist/api/sessions-router.d.ts +3312 -131
- package/dist/api/sessions-router.js +287 -157
- package/dist/api/sessions-router.js.map +1 -1
- package/dist/api/settings-router.d.ts +457 -8
- package/dist/api/settings-router.js +54 -33
- package/dist/api/settings-router.js.map +1 -1
- package/dist/api/sse-routes.d.ts +7 -2
- package/dist/api/sse-routes.js +109 -62
- package/dist/api/sse-routes.js.map +1 -1
- package/dist/api/tools-raw.d.ts +5 -0
- package/dist/api/tools-raw.js +63 -55
- package/dist/api/tools-raw.js.map +1 -1
- package/dist/api/tools-router.d.ts +998 -85
- package/dist/api/tools-router.js +447 -61
- package/dist/api/tools-router.js.map +1 -1
- package/dist/api/transcribe-stream.d.ts +16 -0
- package/dist/api/transcribe-stream.js +172 -0
- package/dist/api/transcribe-stream.js.map +1 -0
- package/dist/app.d.ts +4 -1
- package/dist/app.js +98 -59
- package/dist/app.js.map +1 -1
- package/dist/auth/gate.js +15 -11
- package/dist/auth/gate.js.map +1 -1
- package/dist/auth/oidc.d.ts +5 -2
- package/dist/auth/oidc.js +8 -7
- package/dist/auth/oidc.js.map +1 -1
- package/dist/auth/passkeys.d.ts +15 -14
- package/dist/auth/passkeys.js +89 -91
- package/dist/auth/passkeys.js.map +1 -1
- package/dist/auth/store.d.ts +17 -7
- package/dist/auth/store.js +93 -52
- package/dist/auth/store.js.map +1 -1
- package/dist/bin/talaria-web.js +12 -81
- package/dist/bin/talaria-web.js.map +1 -1
- package/dist/cli/ctl.js +2 -1
- package/dist/cli/ctl.js.map +1 -1
- package/dist/cli/dotenv.d.ts +5 -0
- package/dist/cli/dotenv.js +15 -2
- package/dist/cli/dotenv.js.map +1 -1
- package/dist/cli/launcher.js +5 -5
- package/dist/cli/launcher.js.map +1 -1
- package/dist/cli/main.d.ts +1 -0
- package/dist/cli/main.js +85 -0
- package/dist/cli/main.js.map +1 -0
- package/dist/cli/supervise.d.ts +2 -0
- package/dist/cli/supervise.js +67 -4
- package/dist/cli/supervise.js.map +1 -1
- package/dist/config/agent-config.d.ts +34 -11
- package/dist/config/agent-config.js +116 -60
- package/dist/config/agent-config.js.map +1 -1
- package/dist/config.d.ts +4 -0
- package/dist/config.js +19 -6
- package/dist/config.js.map +1 -1
- package/dist/fs/atomic.d.ts +1 -0
- package/dist/fs/atomic.js +4 -1
- package/dist/fs/atomic.js.map +1 -1
- package/dist/fs/stat.d.ts +1 -0
- package/dist/fs/stat.js +11 -0
- package/dist/fs/stat.js.map +1 -0
- package/dist/http/context.d.ts +31 -12
- package/dist/http/context.js +60 -24
- package/dist/http/context.js.map +1 -1
- package/dist/http/csp.d.ts +2 -1
- package/dist/http/csp.js +3 -2
- package/dist/http/csp.js.map +1 -1
- package/dist/onboarding.d.ts +2 -2
- package/dist/onboarding.js +10 -8
- package/dist/onboarding.js.map +1 -1
- package/dist/profiles/profiles.d.ts +20 -4
- package/dist/profiles/profiles.js +115 -30
- package/dist/profiles/profiles.js.map +1 -1
- package/dist/projects.d.ts +15 -6
- package/dist/projects.js +55 -47
- package/dist/projects.js.map +1 -1
- package/dist/providers/catalog.d.ts +84 -14
- package/dist/providers/catalog.js +474 -151
- package/dist/providers/catalog.js.map +1 -1
- package/dist/providers/env-file.d.ts +10 -3
- package/dist/providers/env-file.js +24 -75
- package/dist/providers/env-file.js.map +1 -1
- package/dist/providers/runtime-env.d.ts +4 -2
- package/dist/providers/runtime-env.js +9 -6
- package/dist/providers/runtime-env.js.map +1 -1
- package/dist/providers/tables.d.ts +3 -1
- package/dist/providers/tables.js +4 -2
- package/dist/providers/tables.js.map +1 -1
- package/dist/raster-image.d.ts +7 -0
- package/dist/raster-image.js +267 -0
- package/dist/raster-image.js.map +1 -0
- package/dist/redact.js +342 -46
- package/dist/redact.js.map +1 -1
- package/dist/release.d.ts +7 -0
- package/dist/release.js +14 -0
- package/dist/release.js.map +1 -1
- package/dist/runtime.d.ts +5 -1
- package/dist/runtime.js +305 -96
- package/dist/runtime.js.map +1 -1
- package/dist/server.js +15 -0
- package/dist/server.js.map +1 -1
- package/dist/sessions/anchor.d.ts +9 -3
- package/dist/sessions/anchor.js +28 -36
- package/dist/sessions/anchor.js.map +1 -1
- package/dist/sessions/background-events.d.ts +2 -0
- package/dist/sessions/background-events.js +10 -0
- package/dist/sessions/background-events.js.map +1 -0
- package/dist/sessions/background-tasks.d.ts +26 -27
- package/dist/sessions/background-tasks.js +89 -97
- package/dist/sessions/background-tasks.js.map +1 -1
- package/dist/sessions/background-updates.d.ts +15 -8
- package/dist/sessions/background-updates.js +101 -25
- package/dist/sessions/background-updates.js.map +1 -1
- package/dist/sessions/cli-sessions.d.ts +27 -6
- package/dist/sessions/cli-sessions.js +152 -42
- package/dist/sessions/cli-sessions.js.map +1 -1
- package/dist/sessions/completions.d.ts +77 -9
- package/dist/sessions/completions.js +482 -45
- package/dist/sessions/completions.js.map +1 -1
- package/dist/sessions/compress.d.ts +8 -0
- package/dist/sessions/compress.js +2 -2
- package/dist/sessions/compress.js.map +1 -1
- package/dist/sessions/drafts.d.ts +14 -10
- package/dist/sessions/drafts.js +38 -43
- package/dist/sessions/drafts.js.map +1 -1
- package/dist/sessions/export.d.ts +5 -0
- package/dist/sessions/export.js +30 -0
- package/dist/sessions/export.js.map +1 -1
- package/dist/sessions/gateway-sessions.d.ts +46 -0
- package/dist/sessions/gateway-sessions.js +378 -0
- package/dist/sessions/gateway-sessions.js.map +1 -0
- package/dist/sessions/gateway-watcher.d.ts +7 -3
- package/dist/sessions/gateway-watcher.js +31 -19
- package/dist/sessions/gateway-watcher.js.map +1 -1
- package/dist/sessions/goals.d.ts +31 -0
- package/dist/sessions/goals.js +75 -0
- package/dist/sessions/goals.js.map +1 -0
- package/dist/sessions/handoff.d.ts +32 -0
- package/dist/sessions/handoff.js +114 -0
- package/dist/sessions/handoff.js.map +1 -0
- package/dist/sessions/journal.d.ts +5 -79
- package/dist/sessions/journal.js +15 -606
- package/dist/sessions/journal.js.map +1 -1
- package/dist/sessions/list.d.ts +31 -16
- package/dist/sessions/list.js +139 -129
- package/dist/sessions/list.js.map +1 -1
- package/dist/sessions/merge.d.ts +69 -13
- package/dist/sessions/merge.js +265 -64
- package/dist/sessions/merge.js.map +1 -1
- package/dist/sessions/notices.d.ts +22 -0
- package/dist/sessions/notices.js +72 -0
- package/dist/sessions/notices.js.map +1 -0
- package/dist/sessions/pending.d.ts +10 -4
- package/dist/sessions/pending.js +83 -7
- package/dist/sessions/pending.js.map +1 -1
- package/dist/sessions/prefix-index.d.ts +82 -0
- package/dist/sessions/prefix-index.js +232 -0
- package/dist/sessions/prefix-index.js.map +1 -0
- package/dist/sessions/recovery.d.ts +19 -0
- package/dist/sessions/recovery.js +322 -0
- package/dist/sessions/recovery.js.map +1 -0
- package/dist/sessions/relay.d.ts +38 -19
- package/dist/sessions/relay.js +302 -79
- package/dist/sessions/relay.js.map +1 -1
- package/dist/sessions/service.d.ts +260 -55
- package/dist/sessions/service.js +1448 -462
- package/dist/sessions/service.js.map +1 -1
- package/dist/sessions/session.d.ts +24 -2
- package/dist/sessions/session.js +69 -7
- package/dist/sessions/session.js.map +1 -1
- package/dist/sessions/shares.d.ts +7 -6
- package/dist/sessions/shares.js +22 -45
- package/dist/sessions/shares.js.map +1 -1
- package/dist/sessions/source-kind.js +1 -0
- package/dist/sessions/source-kind.js.map +1 -1
- package/dist/sessions/state-db-identity.d.ts +18 -0
- package/dist/sessions/state-db-identity.js +67 -0
- package/dist/sessions/state-db-identity.js.map +1 -0
- package/dist/sessions/state-db-worker.d.ts +1 -0
- package/dist/sessions/state-db-worker.js +35 -0
- package/dist/sessions/state-db-worker.js.map +1 -0
- package/dist/sessions/state-db.d.ts +113 -7
- package/dist/sessions/state-db.js +861 -44
- package/dist/sessions/state-db.js.map +1 -1
- package/dist/sessions/storage/backend.d.ts +162 -0
- package/dist/sessions/storage/backend.js +19 -0
- package/dist/sessions/storage/backend.js.map +1 -0
- package/dist/sessions/storage/conformance.d.ts +20 -0
- package/dist/sessions/storage/conformance.js +237 -0
- package/dist/sessions/storage/conformance.js.map +1 -0
- package/dist/sessions/storage/document.d.ts +16 -0
- package/dist/sessions/storage/document.js +44 -0
- package/dist/sessions/storage/document.js.map +1 -0
- package/dist/sessions/storage/json-documents.d.ts +23 -0
- package/dist/sessions/storage/json-documents.js +233 -0
- package/dist/sessions/storage/json-documents.js.map +1 -0
- package/dist/sessions/storage/json-files.d.ts +12 -0
- package/dist/sessions/storage/json-files.js +124 -0
- package/dist/sessions/storage/json-files.js.map +1 -0
- package/dist/sessions/storage/json-installation.d.ts +3 -0
- package/dist/sessions/storage/json-installation.js +31 -0
- package/dist/sessions/storage/json-installation.js.map +1 -0
- package/dist/sessions/storage/json-journal.d.ts +114 -0
- package/dist/sessions/storage/json-journal.js +686 -0
- package/dist/sessions/storage/json-journal.js.map +1 -0
- package/dist/sessions/storage/json-recovery.d.ts +4 -0
- package/dist/sessions/storage/json-recovery.js +195 -0
- package/dist/sessions/storage/json-recovery.js.map +1 -0
- package/dist/sessions/storage/json.d.ts +68 -0
- package/dist/sessions/storage/json.js +258 -0
- package/dist/sessions/storage/json.js.map +1 -0
- package/dist/sessions/storage/row.d.ts +6 -0
- package/dist/sessions/storage/row.js +49 -0
- package/dist/sessions/storage/row.js.map +1 -0
- package/dist/sessions/storage/search-text.d.ts +4 -0
- package/dist/sessions/storage/search-text.js +16 -0
- package/dist/sessions/storage/search-text.js.map +1 -0
- package/dist/sessions/store.d.ts +65 -68
- package/dist/sessions/store.js +305 -602
- package/dist/sessions/store.js.map +1 -1
- package/dist/sessions/streams.d.ts +4 -0
- package/dist/sessions/streams.js +9 -1
- package/dist/sessions/streams.js.map +1 -1
- package/dist/sessions/titles.d.ts +2 -0
- package/dist/sessions/titles.js +5 -1
- package/dist/sessions/titles.js.map +1 -1
- package/dist/sessions/todo.d.ts +5 -0
- package/dist/sessions/todo.js +28 -21
- package/dist/sessions/todo.js.map +1 -1
- package/dist/sessions/tool-display.d.ts +5 -4
- package/dist/sessions/tool-display.js +30 -50
- package/dist/sessions/tool-display.js.map +1 -1
- package/dist/sessions/turn-context.d.ts +11 -2
- package/dist/sessions/turn-context.js +18 -11
- package/dist/sessions/turn-context.js.map +1 -1
- package/dist/sessions/turn.d.ts +122 -17
- package/dist/sessions/turn.js +939 -358
- package/dist/sessions/turn.js.map +1 -1
- package/dist/sessions/wakeup-pause.d.ts +54 -0
- package/dist/sessions/wakeup-pause.js +163 -0
- package/dist/sessions/wakeup-pause.js.map +1 -0
- package/dist/sessions/window.js +13 -12
- package/dist/sessions/window.js.map +1 -1
- package/dist/settings.d.ts +19 -7
- package/dist/settings.js +178 -184
- package/dist/settings.js.map +1 -1
- package/dist/sidecar/client.d.ts +17 -3
- package/dist/sidecar/client.js +78 -8
- package/dist/sidecar/client.js.map +1 -1
- package/dist/sidecar/discover.d.ts +2 -1
- package/dist/sidecar/discover.js +93 -10
- package/dist/sidecar/discover.js.map +1 -1
- package/dist/sidecar/env.d.ts +4 -0
- package/dist/sidecar/env.js +3 -0
- package/dist/sidecar/env.js.map +1 -0
- package/dist/sidecar/fake.d.ts +2 -2
- package/dist/sidecar/fake.js +19 -2
- package/dist/sidecar/fake.js.map +1 -1
- package/dist/startup.d.ts +13 -5
- package/dist/startup.js +48 -0
- package/dist/startup.js.map +1 -1
- package/dist/test/env-file.d.ts +5 -0
- package/dist/test/env-file.js +88 -0
- package/dist/test/env-file.js.map +1 -0
- package/dist/test/harness.d.ts +1 -0
- package/dist/test/harness.js +1 -1
- package/dist/test/harness.js.map +1 -1
- package/dist/test/passkeys.d.ts +22 -0
- package/dist/test/passkeys.js +62 -0
- package/dist/test/passkeys.js.map +1 -0
- package/dist/test/storage.d.ts +15 -0
- package/dist/test/storage.js +26 -0
- package/dist/test/storage.js.map +1 -0
- package/dist/test/ts-worker-hooks.d.ts +1 -0
- package/dist/test/ts-worker-hooks.js +24 -0
- package/dist/test/ts-worker-hooks.js.map +1 -0
- package/dist/text/diff.d.ts +9 -14
- package/dist/text/diff.js +74 -108
- package/dist/text/diff.js.map +1 -1
- package/dist/tools/commands.d.ts +6 -1
- package/dist/tools/commands.js +8 -2
- package/dist/tools/commands.js.map +1 -1
- package/dist/tools/connectors.d.ts +33 -0
- package/dist/tools/connectors.js +121 -0
- package/dist/tools/connectors.js.map +1 -0
- package/dist/tools/crons.d.ts +38 -1
- package/dist/tools/crons.js +122 -18
- package/dist/tools/crons.js.map +1 -1
- package/dist/tools/dashboard-plugins.d.ts +53 -0
- package/dist/tools/dashboard-plugins.js +188 -0
- package/dist/tools/dashboard-plugins.js.map +1 -0
- package/dist/tools/extensions.d.ts +18 -5
- package/dist/tools/extensions.js +344 -266
- package/dist/tools/extensions.js.map +1 -1
- package/dist/tools/gateway-restart.d.ts +62 -0
- package/dist/tools/gateway-restart.js +142 -0
- package/dist/tools/gateway-restart.js.map +1 -0
- package/dist/tools/health.d.ts +44 -0
- package/dist/tools/health.js +201 -46
- package/dist/tools/health.js.map +1 -1
- package/dist/tools/hygiene.d.ts +4 -3
- package/dist/tools/hygiene.js +23 -9
- package/dist/tools/hygiene.js.map +1 -1
- package/dist/tools/kanban.d.ts +4 -2
- package/dist/tools/kanban.js +28 -19
- package/dist/tools/kanban.js.map +1 -1
- package/dist/tools/mcp.d.ts +13 -0
- package/dist/tools/mcp.js +88 -0
- package/dist/tools/mcp.js.map +1 -1
- package/dist/tools/memory-masks.d.ts +5 -0
- package/dist/tools/memory-masks.js +256 -0
- package/dist/tools/memory-masks.js.map +1 -0
- package/dist/tools/memory.d.ts +29 -3
- package/dist/tools/memory.js +69 -27
- package/dist/tools/memory.js.map +1 -1
- package/dist/tools/plugin-settings.d.ts +15 -0
- package/dist/tools/plugin-settings.js +90 -0
- package/dist/tools/plugin-settings.js.map +1 -0
- package/dist/tools/project-os.d.ts +11 -0
- package/dist/tools/project-os.js +267 -0
- package/dist/tools/project-os.js.map +1 -0
- package/dist/tools/skills.d.ts +14 -5
- package/dist/tools/skills.js +49 -16
- package/dist/tools/skills.js.map +1 -1
- package/dist/tools/update-notifications.d.ts +34 -15
- package/dist/tools/update-notifications.js +141 -57
- package/dist/tools/update-notifications.js.map +1 -1
- package/dist/tools/update-rollback.d.ts +11 -0
- package/dist/tools/update-rollback.js +28 -0
- package/dist/tools/update-rollback.js.map +1 -0
- package/dist/tools/updates.d.ts +110 -32
- package/dist/tools/updates.js +1112 -294
- package/dist/tools/updates.js.map +1 -1
- package/dist/tools/wiki.d.ts +68 -0
- package/dist/tools/wiki.js +413 -0
- package/dist/tools/wiki.js.map +1 -0
- package/dist/util.d.ts +2 -0
- package/dist/util.js +5 -0
- package/dist/util.js.map +1 -1
- package/dist/workspace/escape.d.ts +61 -0
- package/dist/workspace/escape.js +149 -0
- package/dist/workspace/escape.js.map +1 -0
- package/dist/workspace/extract.d.ts +22 -0
- package/dist/workspace/extract.js +308 -0
- package/dist/workspace/extract.js.map +1 -0
- package/dist/workspace/fs.d.ts +12 -0
- package/dist/workspace/fs.js +92 -23
- package/dist/workspace/fs.js.map +1 -1
- package/dist/workspace/git.d.ts +34 -1
- package/dist/workspace/git.js +328 -35
- package/dist/workspace/git.js.map +1 -1
- package/dist/workspace/media.d.ts +16 -13
- package/dist/workspace/media.js +43 -61
- package/dist/workspace/media.js.map +1 -1
- package/dist/workspace/rollback.d.ts +36 -22
- package/dist/workspace/rollback.js +178 -272
- package/dist/workspace/rollback.js.map +1 -1
- package/dist/workspace/unzip.js +4 -1
- package/dist/workspace/unzip.js.map +1 -1
- package/dist/workspace/upload.d.ts +7 -4
- package/dist/workspace/upload.js +5 -2
- package/dist/workspace/upload.js.map +1 -1
- package/dist/workspace/workspaces.d.ts +15 -19
- package/dist/workspace/workspaces.js +55 -127
- package/dist/workspace/workspaces.js.map +1 -1
- package/dist/workspace/worktrees.d.ts +2 -1
- package/dist/workspace/worktrees.js +179 -42
- package/dist/workspace/worktrees.js.map +1 -1
- package/package.json +7 -3
- package/sidecar/agent_dependency.json +4 -4
- package/sidecar/agent_support.json +26 -0
- package/sidecar/scripts/record-fixtures.py +12 -3
- package/sidecar/scripts/replay_sidecar.py +32 -0
- package/sidecar/scripts/suite_runner.py +302 -0
- package/sidecar/scripts/test.sh +12 -4
- package/sidecar/talaria_sidecar/__init__.py +6 -1
- package/sidecar/talaria_sidecar/__main__.py +4 -2
- package/sidecar/talaria_sidecar/agent_mutation.py +975 -0
- package/sidecar/talaria_sidecar/agent_services.py +1053 -0
- package/sidecar/talaria_sidecar/dependency_sync.py +85 -0
- package/sidecar/talaria_sidecar/home.py +56 -3
- package/sidecar/talaria_sidecar/methods/__init__.py +2 -2
- package/sidecar/talaria_sidecar/methods/aux.py +166 -24
- package/sidecar/talaria_sidecar/methods/chat.py +852 -135
- package/sidecar/talaria_sidecar/methods/checkpoints.py +89 -0
- package/sidecar/talaria_sidecar/methods/commands.py +294 -4
- package/sidecar/talaria_sidecar/methods/config.py +123 -4
- package/sidecar/talaria_sidecar/methods/connectors.py +343 -0
- package/sidecar/talaria_sidecar/methods/cron.py +395 -36
- package/sidecar/talaria_sidecar/methods/cron_execution.py +284 -0
- package/sidecar/talaria_sidecar/methods/gateway.py +212 -55
- package/sidecar/talaria_sidecar/methods/gateway_sessions.py +234 -0
- package/sidecar/talaria_sidecar/methods/goals.py +81 -41
- package/sidecar/talaria_sidecar/methods/kanban.py +108 -34
- package/sidecar/talaria_sidecar/methods/mcp.py +1 -1
- package/sidecar/talaria_sidecar/methods/memory.py +84 -0
- package/sidecar/talaria_sidecar/methods/oauth.py +5 -2
- package/sidecar/talaria_sidecar/methods/process.py +218 -15
- package/sidecar/talaria_sidecar/methods/profiles.py +121 -13
- package/sidecar/talaria_sidecar/methods/providers.py +217 -28
- package/sidecar/talaria_sidecar/methods/runtime.py +69 -1
- package/sidecar/talaria_sidecar/methods/skills.py +67 -4
- package/sidecar/talaria_sidecar/methods/state_db.py +316 -75
- package/sidecar/talaria_sidecar/methods/stt.py +159 -78
- package/sidecar/talaria_sidecar/methods/text.py +14 -1
- package/sidecar/talaria_sidecar/methods/toolsets.py +49 -0
- package/sidecar/talaria_sidecar/methods/usage.py +113 -14
- package/sidecar/talaria_sidecar/methods/worktree.py +26 -19
- package/sidecar/talaria_sidecar/methods/write_approval.py +181 -0
- package/sidecar/talaria_sidecar/rpc.py +45 -4
- package/sidecar/talaria_sidecar/runtime.py +98 -13
- package/sidecar/talaria_sidecar/speed.py +35 -0
- package/static/dist/FILES.txt +126 -126
- package/static/dist/assets/AppShell-cey33v9s.js +8 -0
- package/static/dist/assets/ChatPage-i8wnugr7.js +18 -0
- package/static/dist/assets/ConnectTargets-hg19553x.js +1 -0
- package/static/dist/assets/ExtensionRoute-cly4apm5.js +1 -0
- package/static/dist/assets/Field-j8uqoigz.js +9 -0
- package/static/dist/assets/HubRoute-e4bgz41j.js +2 -0
- package/static/dist/assets/Markdown-lahuichm.js +598 -0
- package/static/dist/assets/RightPanel-lbo4i0fn.js +1 -0
- package/static/dist/assets/Select-gqatm2e0.js +1 -0
- package/static/dist/assets/SessionListPanel-mfqayfjh.js +1 -0
- package/static/dist/assets/SettingsLayout-b8980u7k.js +1 -0
- package/static/dist/assets/States-e7dkgtxf.js +1 -0
- package/static/dist/assets/Toaster-bfqqy786.js +1 -0
- package/static/dist/assets/_app-n1n6pfx1.js +1 -0
- package/static/dist/assets/{_app.ext._extensionId-opyh9y0v.js → _app.ext._extensionId-buxjxav3.js} +1 -1
- package/static/dist/assets/_app.index-jt8zwiu2.js +1 -0
- package/static/dist/assets/_app.insights-cv76ml71.js +1 -0
- package/static/dist/assets/_app.kanban-h6ts53j7.js +1 -0
- package/static/dist/assets/_app.logs-dm873z90.js +1 -0
- package/static/dist/assets/_app.memory-glk5y6m8.js +1 -0
- package/static/dist/assets/_app.profiles-ka6zpstq.js +1 -0
- package/static/dist/assets/{_app.session._sessionId-bfd4h3id.js → _app.session._sessionId-g2vzghvc.js} +1 -1
- package/static/dist/assets/_app.settings-hgjdhzcd.js +1 -0
- package/static/dist/assets/_app.settings._section-jf2kmhae.js +3 -0
- package/static/dist/assets/_app.skills-cii852yq.js +1 -0
- package/static/dist/assets/_app.tasks-fp9q7atm.js +1 -0
- package/static/dist/assets/_app.todos-dw94qzue.js +1 -0
- package/static/dist/assets/_app.workspaces-inyygzze.js +1 -0
- package/static/dist/assets/{abnfDiagram-VCTEODGH-b2x1lqot.js → abnfDiagram-VCTEODGH-gjayq3ra.js} +1 -1
- package/static/dist/assets/architecture-7GRP2DOG-lp56972r.js +1 -0
- package/static/dist/assets/{architectureDiagram-5GKGNRK7-h38o5l7b.js → architectureDiagram-5GKGNRK7-nwb3xovn.js} +1 -1
- package/static/dist/assets/archive-b1j004vn.js +1 -0
- package/static/dist/assets/back-g74gkozu.js +1 -0
- package/static/dist/assets/{blockDiagram-I7D4REHJ-bqquxv2u.js → blockDiagram-I7D4REHJ-joedghi3.js} +1 -1
- package/static/dist/assets/{c4Diagram-7LVT6UL2-f3fb8w6z.js → c4Diagram-7LVT6UL2-hyrjrohe.js} +1 -1
- package/static/dist/assets/channel-es95o4y8.js +1 -0
- package/static/dist/assets/{chunk-4HAMMTFA-bzfyyzp3.js → chunk-4HAMMTFA-koouqbut.js} +1 -1
- package/static/dist/assets/{chunk-75Z2AOVW-ly99rfx5.js → chunk-75Z2AOVW-bl4bhdnt.js} +1 -1
- package/static/dist/assets/{chunk-DU6HZSFF-bg6ecfqf.js → chunk-DU6HZSFF-gpedttsp.js} +3 -3
- package/static/dist/assets/{chunk-F27PBJKO-e8k7mmqw.js → chunk-F27PBJKO-d8xxi0zi.js} +1 -1
- package/static/dist/assets/{chunk-GMAD6QVW-cmalnoaj.js → chunk-GMAD6QVW-jro0hl73.js} +1 -1
- package/static/dist/assets/{chunk-GVQU2GXP-gmif5o01.js → chunk-GVQU2GXP-djn6gbyu.js} +1 -1
- package/static/dist/assets/{chunk-IMKFNOWR-f1nwwh6u.js → chunk-IMKFNOWR-vw4p5p7o.js} +1 -1
- package/static/dist/assets/{chunk-L3NEJ4N5-oktfh5v7.js → chunk-L3NEJ4N5-n1hj77q8.js} +1 -1
- package/static/dist/assets/{chunk-OSK3NFVY-b1sdx96z.js → chunk-OSK3NFVY-oy0gwn2n.js} +1 -1
- package/static/dist/assets/{chunk-P2QGCYS3-fhu4nzxt.js → chunk-P2QGCYS3-onf1hz6c.js} +1 -1
- package/static/dist/assets/{chunk-POPQ4Y6H-em31rnef.js → chunk-POPQ4Y6H-m8sqn7cs.js} +1 -1
- package/static/dist/assets/{chunk-PWAF6VOD-bxhhyftw.js → chunk-PWAF6VOD-ek4smc8x.js} +1 -1
- package/static/dist/assets/{chunk-SHT3W25Y-fmrvwcav.js → chunk-SHT3W25Y-g9gfvefo.js} +3 -3
- package/static/dist/assets/{chunk-SVP7TREG-lymukl2j.js → chunk-SVP7TREG-f9nfv2cu.js} +1 -1
- package/static/dist/assets/{chunk-TICWLB2K-lmza6k23.js → chunk-TICWLB2K-bnk3m1mq.js} +1 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-kmihoqxh.js +1 -0
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-kmihoqxh.js +1 -0
- package/static/dist/assets/copied-bli9tbcx.js +1 -0
- package/static/dist/assets/{copy-fw5b1pc6.js → copy-jamtd1pf.js} +1 -1
- package/static/dist/assets/{cynefin-OW5HDTMX-czlhbyhs.js → cynefin-OW5HDTMX-f8xfdd3f.js} +1 -1
- package/static/dist/assets/{cynefinDiagram-5FMLGOSQ-gq7mvswb.js → cynefinDiagram-5FMLGOSQ-n9lb99h9.js} +1 -1
- package/static/dist/assets/{dagre-GXQ25YYZ-cqdbf7dr.js → dagre-GXQ25YYZ-dgailddi.js} +4 -4
- package/static/dist/assets/{diagram-S7CK7UJ4-o6arxa58.js → diagram-S7CK7UJ4-m6aeoe7c.js} +1 -1
- package/static/dist/assets/{diagram-UQ7AKVKN-nhnd68tz.js → diagram-UQ7AKVKN-jall3j0x.js} +1 -1
- package/static/dist/assets/{diagram-VSXAHHWV-dk2sclzn.js → diagram-VSXAHHWV-hbawi7hn.js} +1 -1
- package/static/dist/assets/{diagram-VX7I27RA-n3k5nyx3.js → diagram-VX7I27RA-hendl2tt.js} +1 -1
- package/static/dist/assets/{diagram-Z3DM3KII-m6q9cwdh.js → diagram-Z3DM3KII-igwc35p3.js} +1 -1
- package/static/dist/assets/{ebnfDiagram-PWID7BFC-lsa5641p.js → ebnfDiagram-PWID7BFC-c64knz08.js} +1 -1
- package/static/dist/assets/{erDiagram-RLTQ6QDP-fo2ckbe6.js → erDiagram-RLTQ6QDP-brdu9lcm.js} +1 -1
- package/static/dist/assets/eventmodeling-NTZA5JFV-njk1j8wl.js +1 -0
- package/static/dist/assets/flowDiagram-HODETNUW-nma6f0ga.js +1 -0
- package/static/dist/assets/folder-plus-lvt9kudb.js +1 -0
- package/static/dist/assets/{ganttDiagram-EL5Y4UJY-nzqz6x9g.js → ganttDiagram-EL5Y4UJY-ma6jxj5i.js} +1 -1
- package/static/dist/assets/{gitGraph-4MIJSDKK-gukvcxe6.js → gitGraph-4MIJSDKK-naa9w6xa.js} +1 -1
- package/static/dist/assets/{gitGraphDiagram-WWUBYQGX-lqhdp8f0.js → gitGraphDiagram-WWUBYQGX-hzltvsod.js} +1 -1
- package/static/dist/assets/{highlighted-body-KPVGNVTW-igw6cp39.js → highlighted-body-KPVGNVTW-ghoavj8d.js} +1 -1
- package/static/dist/assets/host-hzm8n85n.js +1 -0
- package/static/dist/assets/index-drdcaqeq.css +2 -0
- package/static/dist/assets/index-jzm844he.js +11 -0
- package/static/dist/assets/{info-A6RAGUB7-gvu7a93l.js → info-A6RAGUB7-mn2ut5ko.js} +1 -1
- package/static/dist/assets/{infoDiagram-27XIBGKW-m8t8g6w1.js → infoDiagram-27XIBGKW-h6kuqvsb.js} +1 -1
- package/static/dist/assets/{ishikawaDiagram-5VMMS53U-l07vkbgj.js → ishikawaDiagram-5VMMS53U-e7bnwq7h.js} +1 -1
- package/static/dist/assets/{journeyDiagram-3NMN7TZE-hybkgj8l.js → journeyDiagram-3NMN7TZE-etd1bi6p.js} +1 -1
- package/static/dist/assets/{kanban-definition-UXKFOSKX-by13uvtn.js → kanban-definition-UXKFOSKX-emb1g9dz.js} +4 -4
- package/static/dist/assets/lazyRouteComponent-o816ytzl.js +1 -0
- package/static/dist/assets/{line-osxq6fq7.js → line-h0ojzsvs.js} +1 -1
- package/static/dist/assets/link-k01ryaf9.js +1 -0
- package/static/dist/assets/login-bsae49ew.js +1 -0
- package/static/dist/assets/mermaid-HWGCJPDP-ihl34s74.js +1 -0
- package/static/dist/assets/{mermaid-parser.core-i91oxmou.js → mermaid-parser.core-bnre27u8.js} +3 -3
- package/static/dist/assets/{mindmap-definition-YA3MSWOX-m82mx8ww.js → mindmap-definition-YA3MSWOX-o1vathns.js} +1 -1
- package/static/dist/assets/onboarding-c65tysya.js +1 -0
- package/static/dist/assets/onboarding_model_label-gpn28uix.js +1 -0
- package/static/dist/assets/{packet-AYTQ26CC-nbtgge2o.js → packet-AYTQ26CC-hatq7m2e.js} +1 -1
- package/static/dist/assets/pcmTap.worklet-74EKjWAZ.js +1 -0
- package/static/dist/assets/{pegDiagram-XKGWAZYB-f1zxzga1.js → pegDiagram-XKGWAZYB-gwisz8jv.js} +1 -1
- package/static/dist/assets/{pie-WAS4IAKB-of8vuhxs.js → pie-WAS4IAKB-fpftf0mk.js} +1 -1
- package/static/dist/assets/{pieDiagram-E7YTZNPT-o7y8ftio.js → pieDiagram-E7YTZNPT-khm1o9a6.js} +1 -1
- package/static/dist/assets/{quadrantDiagram-AXDQQJYC-iqiwia1t.js → quadrantDiagram-AXDQQJYC-owqc4uqn.js} +1 -1
- package/static/dist/assets/{radar-RG4KPBEZ-delnsy2v.js → radar-RG4KPBEZ-cntfwjpq.js} +1 -1
- package/static/dist/assets/{railroad-74A4TZTK-nspnyq9u.js → railroad-74A4TZTK-a7t5eibp.js} +1 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-ol8cdktf.js +1 -0
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-i8vzd429.js +1 -0
- package/static/dist/assets/railroad-peg-WCYAUIDC-nw3ulewz.js +1 -0
- package/static/dist/assets/{railroadDiagram-O6MQD6OU-z41tqx5z.js → railroadDiagram-O6MQD6OU-etuh0qa0.js} +1 -1
- package/static/dist/assets/{refresh-cw-nt1dtsx4.js → refresh-cw-mqbkv2a3.js} +1 -1
- package/static/dist/assets/refresh-l29ljp76.js +1 -0
- package/static/dist/assets/{requirementDiagram-BXWQKSXE-bdvif194.js → requirementDiagram-BXWQKSXE-oq2ulk22.js} +1 -1
- package/static/dist/assets/{sankeyDiagram-P5KCCOFB-h73e0rol.js → sankeyDiagram-P5KCCOFB-iqc02emv.js} +1 -1
- package/static/dist/assets/saved-ndd4t2xb.js +1 -0
- package/static/dist/assets/{sequenceDiagram-WJ2MYXX4-iv95mb1z.js → sequenceDiagram-WJ2MYXX4-i0v116q0.js} +2 -2
- package/static/dist/assets/settings_section_extensions_title-jhfo6o8z.js +1 -0
- package/static/dist/assets/share._token-c1ri0hy1.js +2 -0
- package/static/dist/assets/sliders-horizontal-j3jjt9tv.js +1 -0
- package/static/dist/assets/{stateDiagram-D77RDMKH-k417ixmf.js → stateDiagram-D77RDMKH-dnpt1ato.js} +1 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-bxjacpk6.js +1 -0
- package/static/dist/assets/{swimlanes-42K2YHIH-esola70w.js → swimlanes-42K2YHIH-loqbdazb.js} +1 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-id62p9hz.js +8 -0
- package/static/dist/assets/{timeline-definition-24CTP7MA-getrz2y8.js → timeline-definition-24CTP7MA-ol2hnvma.js} +1 -1
- package/static/dist/assets/toast-i3h34v6o.js +64 -0
- package/static/dist/assets/{treeView-Q6P3EWNA-hc7ap1q7.js → treeView-Q6P3EWNA-idrj6iox.js} +1 -1
- package/static/dist/assets/{treemap-WGGIJYW6-b7k1syy5.js → treemap-WGGIJYW6-lzr49vv5.js} +1 -1
- package/static/dist/assets/untitled-oaoya4qj.js +1 -0
- package/static/dist/assets/useForm-nfaxgmsk.js +1 -0
- package/static/dist/assets/{useParams-lhg566ew.js → useParams-gjc70qru.js} +1 -1
- package/static/dist/assets/{useSearch-lbrh6ejn.js → useSearch-e6ae539u.js} +1 -1
- package/static/dist/assets/{vennDiagram-4TSXK5OY-n6pcy5qb.js → vennDiagram-4TSXK5OY-bvwr9o2z.js} +1 -1
- package/static/dist/assets/{wardley-WFR3VGLG-lpa18vz1.js → wardley-WFR3VGLG-31ui8tma.js} +1 -1
- package/static/dist/assets/{wardleyDiagram-VM6X3IG4-kpclwwg7.js → wardleyDiagram-VM6X3IG4-k5wqtx4b.js} +1 -1
- package/static/dist/assets/{xychartDiagram-S5SC5T6Z-paa2n8y0.js → xychartDiagram-S5SC5T6Z-aqbgvujg.js} +1 -1
- package/static/dist/index.html +1 -1
- package/static/dist/sw.js +1 -1
- package/static/dist/assets/AppShell-fb2td9zi.js +0 -1
- package/static/dist/assets/ChatPage-cj96kiy9.js +0 -25
- package/static/dist/assets/Dialog-kxcg7ard.js +0 -1
- package/static/dist/assets/ExtensionRoute-jf7h0754.js +0 -1
- package/static/dist/assets/HubRoute-gmanpd5y.js +0 -2
- package/static/dist/assets/Markdown-mdwlvj3w.js +0 -598
- package/static/dist/assets/RightPanel-ftvrjlcp.js +0 -1
- package/static/dist/assets/Select-bilaypej.js +0 -1
- package/static/dist/assets/SessionListPanel-og8n36qs.js +0 -1
- package/static/dist/assets/SettingsLayout-jonjefmj.js +0 -1
- package/static/dist/assets/States-nmejrwrm.js +0 -1
- package/static/dist/assets/Toaster-nnl8zkt9.js +0 -1
- package/static/dist/assets/_app-gdidzd94.js +0 -1
- package/static/dist/assets/_app.index-hbarzbjx.js +0 -1
- package/static/dist/assets/_app.insights-luywlarz.js +0 -1
- package/static/dist/assets/_app.kanban-kincnbr8.js +0 -1
- package/static/dist/assets/_app.logs-oi2gr3gs.js +0 -1
- package/static/dist/assets/_app.memory-b6jfivt6.js +0 -1
- package/static/dist/assets/_app.profiles-lpvixmqp.js +0 -1
- package/static/dist/assets/_app.settings-vowx3iq0.js +0 -1
- package/static/dist/assets/_app.settings._section-cnntg1tu.js +0 -3
- package/static/dist/assets/_app.skills-fb57lpau.js +0 -1
- package/static/dist/assets/_app.tasks-eszpx1q7.js +0 -2
- package/static/dist/assets/_app.todos-nr05ac6w.js +0 -1
- package/static/dist/assets/_app.workspaces-d2luomu6.js +0 -1
- package/static/dist/assets/architecture-7GRP2DOG-hd0o7bde.js +0 -1
- package/static/dist/assets/back-bemaer09.js +0 -1
- package/static/dist/assets/cancel-drd0g6d9.js +0 -1
- package/static/dist/assets/channel-cpdgf867.js +0 -1
- package/static/dist/assets/classDiagram-ZZMXUADV-ko09sguk.js +0 -1
- package/static/dist/assets/classDiagram-v2-VYDZK3BY-ko09sguk.js +0 -1
- package/static/dist/assets/copied-ima3pysb.js +0 -1
- package/static/dist/assets/endpoints-d33siokx.js +0 -64
- package/static/dist/assets/eventmodeling-NTZA5JFV-emctlo64.js +0 -1
- package/static/dist/assets/flowDiagram-HODETNUW-bfi3moqa.js +0 -1
- package/static/dist/assets/index-cxq58xb1.js +0 -11
- package/static/dist/assets/index-hgtx08n5.css +0 -2
- package/static/dist/assets/lazyRouteComponent-af0lvgjx.js +0 -1
- package/static/dist/assets/link-jyxicdhg.js +0 -1
- package/static/dist/assets/login-ec21t39x.js +0 -1
- package/static/dist/assets/mermaid-HWGCJPDP-gfl8z3ep.js +0 -1
- package/static/dist/assets/onboarding-btnltvyz.js +0 -1
- package/static/dist/assets/onboarding_model_label-dp7xffoh.js +0 -1
- package/static/dist/assets/railroad-abnf-HS5TGJTU-nst1qxyn.js +0 -1
- package/static/dist/assets/railroad-ebnf-LZEXJU2U-ci37x8qy.js +0 -1
- package/static/dist/assets/railroad-peg-WCYAUIDC-aimkk07z.js +0 -1
- package/static/dist/assets/refresh-b4uams1i.js +0 -1
- package/static/dist/assets/saved-kg52csy7.js +0 -1
- package/static/dist/assets/settings_section_extensions_title-ej0iapxe.js +0 -1
- package/static/dist/assets/share._token-f97e94r0.js +0 -2
- package/static/dist/assets/sliders-horizontal-fj18it47.js +0 -1
- package/static/dist/assets/sse-fwpqtzbg.js +0 -1
- package/static/dist/assets/stateDiagram-v2-MP3YSRHH-hwn4apct.js +0 -1
- package/static/dist/assets/swimlanesDiagram-VR7AAH4N-d1h97oyg.js +0 -8
- package/static/dist/assets/todoStore-eginf9h4.js +0 -1
- package/static/dist/assets/tts-lxdxnmjd.js +0 -1
- package/static/dist/assets/untitled-ovpfthg4.js +0 -1
- package/static/dist/assets/useForm-bcblv1rx.js +0 -1
- package/static/dist/assets/useLocale-fi5jb8ma.js +0 -1
package/dist/sessions/service.js
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
|
+
import { filterAsync } from '../util.js';
|
|
2
|
+
import { statSignature } from '../fs/stat.js';
|
|
3
|
+
import { SessionPrefixIndex } from './prefix-index.js';
|
|
1
4
|
import { str } from '../util.js';
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
5
|
+
import { createHmac, randomUUID } from 'node:crypto';
|
|
3
6
|
import { rmSync } from 'node:fs';
|
|
4
7
|
import { buildActiveTurnToken, copyJson, redactSessionData, redactValue, stripPublicInternalFields, withSceneToolDisplay } from '../redact.js';
|
|
5
8
|
import { DraftVersionConflict, normalizeDraftVersion } from './drafts.js';
|
|
6
|
-
import { allSessions, buildSessionListPayload, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches,
|
|
9
|
+
import { allSessions, buildSessionListPayload, reconcileLocalSessionRows, sessionLineageIds, CLI_IDENTITY_FIELDS, isClaimableCliSource, isMessagingSessionRecord, ownerLockCandidates, withOwnerLocks, withSessionWireFlags, lineageRootId, mergeCliSidebarMetadata, sessionListResponse, sessionSearchMatches, sessionSearchPreview, sessionSearchTerms } from './list.js';
|
|
7
10
|
import { anchorSceneIntOrNull, fullToolResult, hydrateAnchorActivityScenes, normalizeAnchorSceneMessageRef, readAnchorSceneRows, storeAnchorScene, withTurnIds } from './anchor.js';
|
|
8
|
-
import { isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom } from './session.js';
|
|
9
|
-
import { SessionBusy, SessionNotFound
|
|
10
|
-
import { UNSETTLED_TODO_KEY, attachTodoState } from './todo.js';
|
|
11
|
+
import { contextUsage, COMPRESSION_RECOVERY_ACTION_START_FOCUSED, compressionRecoveryPayload, forgetContextFigures, isSafeSessionId, lastMessageTimestamp, Session, sharePath, stripAttachedFilesMarker, titleFrom } from './session.js';
|
|
12
|
+
import { SessionBusy, SessionNotFound } from './store.js';
|
|
13
|
+
import { UNSETTLED_TODO_KEY, attachTodoSnapshot, attachTodoState } from './todo.js';
|
|
11
14
|
import { isClaudeCodeSessionId } from './claude-code.js';
|
|
12
|
-
import {
|
|
15
|
+
import { auditSessionRecovery, repairSafeSessionRecovery } from './recovery.js';
|
|
16
|
+
import { agentSessionRowsExisting, agentSessionRowsExistingAsync, CANONICAL_READ_ONLY_REASON, OWNERSHIP_UNAVAILABLE_REASON, CONVERSATION_ROUND_THRESHOLD, countConversationRounds, stateDbCompressionLineage, stateDbTailToolContent, stateDbLineageReport, stateDbLocalTarget, stateDbSessionMessages, stateDbSessionRead, stateDbSessionTailRead, stateDbSessionRow, stateDbSessionSources, stateDbSessionSourcesAsync, stateDbTimestampSeconds } from './state-db.js';
|
|
17
|
+
import { completionIncomplete, fallbackHandoffSummary, HANDOFF_SYSTEM_PROMPT, handoffMarker, handoffPayload, handoffTranscript, messageHandoffPayload, sameHandoff } from './handoff.js';
|
|
13
18
|
import { anchorMessageKey, anchorSummary, CompressionJobs, compressionReference, visibleMessagesForAnchor } from './compress.js';
|
|
14
|
-
import { SidecarError } from '../sidecar/client.js';
|
|
15
|
-
import { agentSteerText, attachedFilesPrompt, attachmentObjects, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext,
|
|
19
|
+
import { chatReadiness, runtimeFailureDetails, SidecarError } from '../sidecar/client.js';
|
|
20
|
+
import { agentSteerText, ApiMessageSummary, appendStateDbIdentityRows, attachedFilesPrompt, attachmentObjects, checkpointTurnStart, dedupeContext, isContextCompressionMarker, journalOutputRows, looksLikeCurrentUserTurn, stoppedTurnContext, mergeSessionMessagesAppendOnly, messageIdentity, pendingUserRow, reasoningFieldsText, sanitizeMessagesForApi, stateDbSeenId, stripWorkspacePrefix, withAttachmentObjects, newestSettledReply, withBodyExcerpts, withDisplayMedia, withMarkerKinds, withMirrorSources, withSceneRowMedia, withPendingUserTurn, withToolCallOutcomes, withoutRunningTurnOutput } from './merge.js';
|
|
16
21
|
import { withBackgroundUpdates } from './background-updates.js';
|
|
22
|
+
import { markedStateDbGeneration, stateDbIdentityKeys, stampStateDbGeneration, stateDbMessageRepresented } from './state-db-identity.js';
|
|
23
|
+
import { sourceKind } from './source-kind.js';
|
|
17
24
|
import { withBackgroundLinks } from './background-tasks.js';
|
|
18
|
-
import { messagesForLimitedPayload, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
|
|
25
|
+
import { messagesForLimitedPayload, messageCountsAsRenderable, messageWindowForDisplay, MAX_MSG_LIMIT, parseMsgLimit, toolCallsForMessageWindow } from './window.js';
|
|
19
26
|
import { redactText } from '../redact.js';
|
|
20
27
|
import { workspaceDisplayName } from '../workspace/workspaces.js';
|
|
21
28
|
import { buildShareSnapshot } from './shares.js';
|
|
22
|
-
import {
|
|
29
|
+
import { mediaAnchorRootResolved, resolveMediaAccess, mediaRefPath, mediaTarget } from '../workspace/media.js';
|
|
23
30
|
import { projectMediaRefs } from '../workspace/media-refs.js';
|
|
24
31
|
import { loadGatewaySessionIdentityMap } from './list.js';
|
|
25
32
|
import { join } from 'node:path';
|
|
@@ -48,8 +55,31 @@ export async function ensureAgentRuntimeCurrent(sidecar) {
|
|
|
48
55
|
throw staleRuntimeFailure(error);
|
|
49
56
|
}
|
|
50
57
|
}
|
|
58
|
+
/** The health probe is also the browser's server-reachability probe (30 s), so the readiness check answers well inside it. */
|
|
59
|
+
const CHAT_READINESS_CHECK_TIMEOUT_MS = 5_000;
|
|
60
|
+
/**
|
|
61
|
+
* TAL-951: whether a chat turn would be admitted now. Ready and source-stale incompatible sidecars both run turn
|
|
62
|
+
* admission's live check: recovery state can change after the handshake. A check
|
|
63
|
+
* that cannot answer fails closed as `sidecar_unavailable`.
|
|
64
|
+
*/
|
|
65
|
+
export async function currentChatReadiness(sidecar) {
|
|
66
|
+
const readiness = chatReadiness(sidecar);
|
|
67
|
+
if (!sidecar || (!readiness.available && readiness.condition !== 'agent_runtime_stale'))
|
|
68
|
+
return readiness;
|
|
69
|
+
try {
|
|
70
|
+
await sidecar.call('runtime.ensure_current', {}, { timeoutMs: CHAT_READINESS_CHECK_TIMEOUT_MS });
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof SidecarError && error.condition === 'agent_runtime_stale')
|
|
74
|
+
return { available: false, ...runtimeFailureDetails(error.condition, error.data), condition: 'agent_runtime_stale' };
|
|
75
|
+
return { available: false, condition: error instanceof SidecarError && error.condition === 'agent_incompatible' ? 'agent_incompatible' : 'sidecar_unavailable' };
|
|
76
|
+
}
|
|
77
|
+
// The live guard can clear after a rollback, but the client's incompatible admission gate still refuses chat.
|
|
78
|
+
return readiness.available ? readiness : { available: false, ...runtimeFailureDetails('agent_runtime_stale'), condition: 'agent_runtime_stale' };
|
|
79
|
+
}
|
|
51
80
|
function staleRuntimeFailure(error) {
|
|
52
|
-
|
|
81
|
+
const { message, ...details } = runtimeFailureDetails(error.condition, error.data);
|
|
82
|
+
return new HttpFailure(409, message ?? error.message, details);
|
|
53
83
|
}
|
|
54
84
|
/** TAL-536: the most of a dead run's journal stale-stream cleanup reads (Python's recovery window). */
|
|
55
85
|
const RECOVERY_JOURNAL_MAX_BYTES = 8 * 1024 * 1024;
|
|
@@ -57,29 +87,67 @@ const RECOVERY_JOURNAL_MAX_ROWS = 65536;
|
|
|
57
87
|
const isDict = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
|
|
58
88
|
export class SessionService {
|
|
59
89
|
deps;
|
|
90
|
+
owner = null;
|
|
60
91
|
/** TAL-255: one manual compression worker per session, shared by every compress route. */
|
|
61
92
|
compressionJobs;
|
|
93
|
+
/** TAL-1005: the turns now waiting on their session's compression job before they read its history. */
|
|
94
|
+
compactionWaiters = new Set();
|
|
62
95
|
constructor(deps) {
|
|
63
96
|
this.deps = deps;
|
|
64
97
|
this.compressionJobs = new CompressionJobs();
|
|
65
98
|
}
|
|
66
99
|
get store() { return this.deps.store; }
|
|
100
|
+
/**
|
|
101
|
+
* User-turn admission shared by `chat.start` and automatic turns through `TurnRunner.startServerTurn`.
|
|
102
|
+
* A requested workspace must be trusted; the session's own one, when its directory is gone, falls back to the last one.
|
|
103
|
+
*/
|
|
104
|
+
async turnWorkspace(s, requested) {
|
|
105
|
+
const ws = this.deps.workspaces;
|
|
106
|
+
if (requested !== undefined && requested !== null && requested !== '') {
|
|
107
|
+
try {
|
|
108
|
+
return (await ws.resolveTrusted(str(requested), s.profile));
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw new HttpFailure(400, error.message);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
try {
|
|
115
|
+
const [workspace, recovered] = (await ws.resolveImplicitWithRecovery(s.workspace, async (p) => (await ws.lastWorkspace(p)), s.profile));
|
|
116
|
+
if (recovered)
|
|
117
|
+
s.workspace = workspace;
|
|
118
|
+
return workspace;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
throw new HttpFailure(400, error.message);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** A `@provider:` id is split; any other model an explicit pick did not just choose is checked against the catalog (TAL-542). */
|
|
125
|
+
turnModel(s, body = {}) {
|
|
126
|
+
const requestedModel = str(body.model) || s.model;
|
|
127
|
+
const requestedProvider = 'model_provider' in body ? body.model_provider : s.model_provider;
|
|
128
|
+
const [model, provider] = this.deps.modelStateFromRequest(requestedModel, requestedProvider, s.model_provider);
|
|
129
|
+
if (model !== requestedModel || !model || body.explicit_model_pick === true)
|
|
130
|
+
return [model, provider, model !== requestedModel];
|
|
131
|
+
const repaired = this.deps.repairSessionModel?.(s.profile ?? null, model, provider);
|
|
132
|
+
return repaired ? [repaired[0], repaired[1], true] : [model, provider, false];
|
|
133
|
+
}
|
|
67
134
|
/**
|
|
68
135
|
* TAL-186: rewrites a session's media references to their URLs. A local path is rewritten only when `/api/media`
|
|
69
136
|
* serves it for this session, under the same resolution, so every rewritten reference loads.
|
|
70
137
|
*/
|
|
71
|
-
mediaProjector(s) {
|
|
138
|
+
async mediaProjector(s) {
|
|
72
139
|
const media = this.deps.media;
|
|
140
|
+
const access = media ? await resolveMediaAccess(media.access) : null;
|
|
73
141
|
const served = new Map();
|
|
74
142
|
const localUrl = (path) => {
|
|
75
|
-
if (!media?.localIo(s.profile))
|
|
143
|
+
if (!media?.localIo(s.profile) || !access)
|
|
76
144
|
return null;
|
|
77
145
|
if (served.has(path))
|
|
78
146
|
return served.get(path) ?? null;
|
|
79
147
|
let url = null;
|
|
80
148
|
try {
|
|
81
149
|
const target = mediaTarget(mediaRefPath(path));
|
|
82
|
-
if (
|
|
150
|
+
if (mediaAnchorRootResolved(target, s, access))
|
|
83
151
|
url = `./api/media?${new URLSearchParams({ path: target, session_id: s.session_id }).toString()}`;
|
|
84
152
|
}
|
|
85
153
|
catch { /* an invalid path stays text */ }
|
|
@@ -106,7 +174,7 @@ export class SessionService {
|
|
|
106
174
|
* not slip past the profile check into a handler that trims and trusts it. A non-empty id that is still unsafe after
|
|
107
175
|
* trimming can name no session and is refused here instead of falling through.
|
|
108
176
|
*/
|
|
109
|
-
sessionIdVisible(raw) {
|
|
177
|
+
async sessionIdVisible(raw) {
|
|
110
178
|
if (typeof raw !== 'string' || !raw.trim())
|
|
111
179
|
return true;
|
|
112
180
|
const sid = raw.trim();
|
|
@@ -114,70 +182,196 @@ export class SessionService {
|
|
|
114
182
|
return false;
|
|
115
183
|
let session;
|
|
116
184
|
try {
|
|
117
|
-
session = this.store.get(sid, { metadataOnly: true });
|
|
185
|
+
session = (await this.store.get(sid, { metadataOnly: true }));
|
|
118
186
|
}
|
|
119
187
|
catch {
|
|
120
188
|
return true;
|
|
121
189
|
}
|
|
122
190
|
return this.visibleToActiveProfile(session.profile);
|
|
123
191
|
}
|
|
192
|
+
localTargets = null;
|
|
193
|
+
/** One receipt-backed physical target for every operation on a local conversation alias. */
|
|
194
|
+
resolveSessionId(sid) {
|
|
195
|
+
try {
|
|
196
|
+
const path = this.stateDbPath(null);
|
|
197
|
+
const key = JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]);
|
|
198
|
+
if (this.localTargets?.key !== key)
|
|
199
|
+
this.localTargets = { key, ids: new Map() };
|
|
200
|
+
const cached = this.localTargets.ids.get(sid);
|
|
201
|
+
if (cached)
|
|
202
|
+
return cached;
|
|
203
|
+
return stateDbLocalTarget(path, sid, { onRead: (target) => {
|
|
204
|
+
// A failed or changing read cannot seed a routing cache.
|
|
205
|
+
if (key !== JSON.stringify([statSignature(path), statSignature(`${path}-wal`), path]))
|
|
206
|
+
return;
|
|
207
|
+
if (this.localTargets.ids.size >= 500)
|
|
208
|
+
this.localTargets.ids.clear();
|
|
209
|
+
this.localTargets.ids.set(sid, target);
|
|
210
|
+
} });
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return sid;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
124
216
|
/** Python `_lookup_cli_session_metadata(...).get('read_only')` plus the persisted sidecar flag and a not-claimable foreign owner. */
|
|
125
|
-
isReadOnlyImport(sid) {
|
|
217
|
+
async isReadOnlyImport(sid) {
|
|
126
218
|
try {
|
|
127
|
-
const s = this.store.get(sid, { metadataOnly: true });
|
|
128
|
-
if (this.isReadOnly(s))
|
|
219
|
+
const s = (await this.store.get(sid, { metadataOnly: true }));
|
|
220
|
+
if ((await this.isReadOnly(s)))
|
|
129
221
|
return true;
|
|
130
222
|
}
|
|
131
223
|
catch {
|
|
132
224
|
// No sidecar: a foreign state.db transcript whose owner refuses claiming is read-only too.
|
|
133
|
-
const { session, reason } = this.claimOrSynthesizeCliSession(sid);
|
|
225
|
+
const { session, reason } = (await this.claimOrSynthesizeCliSession(sid));
|
|
134
226
|
if (session && reason === 'not_claimable')
|
|
135
227
|
return true;
|
|
136
228
|
}
|
|
137
|
-
const meta = this.lookupCliMeta(sid);
|
|
229
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
138
230
|
return meta !== null && Boolean(meta.read_only);
|
|
139
231
|
}
|
|
140
232
|
/** Python `_session_is_subagent_view_only`: a delegated child by any signal — the persisted sidecar or the state.db row. */
|
|
141
|
-
isSubagentViewOnly(sid) {
|
|
233
|
+
async isSubagentViewOnly(sid) {
|
|
142
234
|
try {
|
|
143
|
-
const s = this.store.get(sid);
|
|
235
|
+
const s = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false }));
|
|
144
236
|
if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
|
|
145
237
|
return true;
|
|
146
238
|
}
|
|
147
239
|
catch { /* no sidecar */ }
|
|
148
|
-
const meta = this.lookupCliMeta(sid);
|
|
240
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
149
241
|
return meta !== null && str(meta.source_tag || meta.raw_source || meta.source).trim().toLowerCase() === 'subagent';
|
|
150
242
|
}
|
|
151
243
|
/** TAL-358: batched `sessions.source` owners from the active profile's state.db. */
|
|
152
|
-
stateDbSources = (ids
|
|
244
|
+
stateDbSources = (ids, profile = this.deps.activeProfile()) => {
|
|
245
|
+
try {
|
|
246
|
+
return stateDbSessionSources(this.stateDbPath(profile), ids);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
/**
|
|
253
|
+
* TAL-590 (Python #3238): the read-only CLI or API-server imports among `rows` whose active-profile state.db row is
|
|
254
|
+
* gone, so their source was deleted outside Web and no Web action could remove them. An unreadable state.db proves
|
|
255
|
+
* nothing (`agentSessionRowsExisting`); another profile's session or one with a run the runtime still holds is never
|
|
256
|
+
* an orphan, while a stream id a crash left behind does not count as one.
|
|
257
|
+
*/
|
|
258
|
+
async orphanedImports(rows, existing, owners) {
|
|
259
|
+
const candidates = (await this.orphanCandidates(rows));
|
|
260
|
+
if (!candidates.length)
|
|
261
|
+
return new Set();
|
|
262
|
+
existing ??= agentSessionRowsExisting(this.stateDbPath(null), candidates);
|
|
263
|
+
if (owners === undefined)
|
|
264
|
+
owners = this.stateDbSources(candidates);
|
|
265
|
+
return new Set(candidates.filter((sid) => !existing.has(sid) && owners !== null && typeof owners.get(sid) !== 'object'));
|
|
266
|
+
}
|
|
267
|
+
async orphanCandidates(rows) {
|
|
268
|
+
return (await filterAsync(rows, async (r) => r.read_only && ['cli', 'api'].includes(sourceKind(r)) && this.visibleToActiveProfile(str(r.profile) || null) && !(await this.activeRunBlocking(str(r.session_id)))))
|
|
269
|
+
.map((r) => str(r.session_id));
|
|
270
|
+
}
|
|
271
|
+
async isOrphanedImport(sid) {
|
|
272
|
+
try {
|
|
273
|
+
return (await this.orphanedImports([(await this.store.get(sid, { metadataOnly: true })).compact()])).size > 0;
|
|
274
|
+
}
|
|
275
|
+
catch {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* The list's owner locks and orphan prune (an orphan's sidecar goes, as nothing else ever removes it), with their
|
|
281
|
+
* state.db reads made on the listing worker up front so the synchronous build never opens state.db (TAL-920).
|
|
282
|
+
*/
|
|
283
|
+
async listStateDbLookups(rows, cliRowIds) {
|
|
284
|
+
const dbPath = this.stateDbPath(null);
|
|
285
|
+
const activeProfile = this.deps.activeProfile();
|
|
286
|
+
const byProfile = ownerLockCandidates(rows);
|
|
287
|
+
const activeIds = byProfile.get(activeProfile) ?? new Set();
|
|
288
|
+
for (const id of cliRowIds)
|
|
289
|
+
activeIds.add(id);
|
|
290
|
+
byProfile.set(activeProfile, activeIds);
|
|
291
|
+
const owners = new Map(await Promise.all([...byProfile].map(async ([profile, ids]) => [profile, ids.size ? await stateDbSessionSourcesAsync(this.stateDbPath(profile), ids) : new Map()])));
|
|
292
|
+
const checked = new Set((await this.orphanCandidates(rows)));
|
|
293
|
+
const existing = checked.size ? await agentSessionRowsExistingAsync(dbPath, checked) : new Set();
|
|
294
|
+
return {
|
|
295
|
+
stateDbSources: (_ids, profile = activeProfile) => owners.get(profile) ?? null,
|
|
296
|
+
pruneOrphanedImports: async (current) => {
|
|
297
|
+
// Only an id the worker checked can be an orphan; a row that became a candidate meanwhile waits for the next list.
|
|
298
|
+
const orphans = (await this.orphanedImports(current.filter((r) => checked.has(str(r.session_id))), existing, owners.get(activeProfile) ?? null));
|
|
299
|
+
for (const sid of orphans)
|
|
300
|
+
(await this.store.delete(sid));
|
|
301
|
+
return orphans;
|
|
302
|
+
},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
153
305
|
/** The persisted flag folded with the state.db owner lock (TAL-358): the one read-only rule for mutation gates and the wire. */
|
|
154
|
-
|
|
155
|
-
if (s
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
306
|
+
async readOnlyReason(s, profile) {
|
|
307
|
+
if (typeof s === 'string') {
|
|
308
|
+
try {
|
|
309
|
+
s = await this.store.get(s, { metadataOnly: true, cacheOnMiss: false, promote: false });
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
if (!(error instanceof SessionNotFound))
|
|
313
|
+
return OWNERSHIP_UNAVAILABLE_REASON;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return this.loadedReadOnlyReason(s, profile);
|
|
317
|
+
}
|
|
318
|
+
loadedReadOnlyReason(s, profile) {
|
|
319
|
+
let raw = typeof s === 'string' ? { session_id: s, profile: this.deps.activeProfile() }
|
|
320
|
+
: { session_id: s.session_id, profile: s.profile, source_tag: s.source_tag, raw_source: s.raw_source, session_source: s.session_source, read_only: s.read_only };
|
|
321
|
+
if (profile !== undefined)
|
|
322
|
+
raw = this.deps.profilesMatch(str(raw.profile) || null, profile) ? { ...raw, profile } : { session_id: raw.session_id, profile };
|
|
323
|
+
const row = withOwnerLocks([raw], this.stateDbSources)[0];
|
|
324
|
+
return row.read_only ? str(row.read_only_reason) || 'Read-only imported sessions cannot be modified from WebUI' : null;
|
|
325
|
+
}
|
|
326
|
+
assertWritable(session, profile) {
|
|
327
|
+
const check = (reason) => { if (reason)
|
|
328
|
+
throw new HttpFailure(403, reason); };
|
|
329
|
+
if (typeof session === 'string')
|
|
330
|
+
return this.readOnlyReason(session, profile).then(check);
|
|
331
|
+
check(this.loadedReadOnlyReason(session, profile));
|
|
332
|
+
}
|
|
333
|
+
/** Controls can exist without a local session file; use the same id the sidecar will address. */
|
|
334
|
+
async idForMutation(sid) {
|
|
335
|
+
sid = sid.trim();
|
|
336
|
+
if (!sid)
|
|
337
|
+
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
338
|
+
await this.assertWritable(sid);
|
|
339
|
+
return sid;
|
|
340
|
+
}
|
|
341
|
+
async isReadOnly(s) { return (await this.readOnlyReason(s)) !== null; }
|
|
342
|
+
/** Recheck after the mutex wait; legacy local metadata/handoff actions keep their existing read-only allowance. */
|
|
343
|
+
withMutationLock(sid, fn, opts = {}) {
|
|
344
|
+
return this.store.withLock(sid, async () => {
|
|
345
|
+
if (opts.allowReadOnly)
|
|
346
|
+
(await this.rejectViewOnly(sid, 'modified'));
|
|
347
|
+
else
|
|
348
|
+
(await this.assertWritable(sid));
|
|
349
|
+
return fn();
|
|
350
|
+
}, opts);
|
|
159
351
|
}
|
|
160
352
|
/** Full session for mutation, refusing read-only imports (Python `_get_or_materialize_session`). */
|
|
161
|
-
getForMutation(sid) {
|
|
162
|
-
let s = this.get404(sid);
|
|
163
|
-
s = this.store.ensureFull(sid, s);
|
|
164
|
-
|
|
165
|
-
throw new HttpFailure(403, 'Read-only imported sessions cannot be modified from WebUI');
|
|
353
|
+
async getForMutation(sid) {
|
|
354
|
+
let s = (await this.get404(sid));
|
|
355
|
+
s = (await this.store.ensureFull(sid, s));
|
|
356
|
+
this.assertWritable(s);
|
|
166
357
|
if (str(s.source_tag || s.raw_source).trim().toLowerCase() === 'subagent')
|
|
167
358
|
throw new HttpFailure(403, 'Read-only subagent child session');
|
|
168
359
|
return s;
|
|
169
360
|
}
|
|
170
|
-
get404(sid, opts = {}) {
|
|
361
|
+
async get404(sid, opts = {}) {
|
|
362
|
+
sid = this.resolveSessionId(sid);
|
|
171
363
|
try {
|
|
172
|
-
return this.store.get(sid, opts);
|
|
364
|
+
return (await this.store.get(sid, opts));
|
|
173
365
|
}
|
|
174
366
|
catch (error) {
|
|
175
367
|
if (error instanceof SessionNotFound) {
|
|
176
368
|
// Python `_get_or_materialize_session`: a claimable foreign (CLI/TUI/Desktop) session gains a WebUI sidecar
|
|
177
369
|
// on its first mutation; a read-only foreign source answers 403 from `getForMutation`, a missing one 404.
|
|
178
|
-
const synth = this.claimOrSynthesizeCliSession(sid);
|
|
370
|
+
const synth = (await this.claimOrSynthesizeCliSession(sid));
|
|
179
371
|
if (synth.session && synth.reason === 'materialized') {
|
|
180
|
-
this.store.save(synth.session);
|
|
372
|
+
await this.store.save(synth.session);
|
|
373
|
+
await this.store.touch(synth.session);
|
|
374
|
+
this.deps.titleClaimed?.(synth.session);
|
|
181
375
|
return synth.session;
|
|
182
376
|
}
|
|
183
377
|
if (synth.session)
|
|
@@ -194,11 +388,16 @@ export class SessionService {
|
|
|
194
388
|
/** `stateDbRows` and whether the read succeeded on a state.db whose messages carry ids (TAL-493). */
|
|
195
389
|
stateDbRead(s) {
|
|
196
390
|
if (str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() === 'subagent')
|
|
197
|
-
return { rows: [], idCapable: false, ok: false };
|
|
391
|
+
return { rows: [], idCapable: false, ok: false, reason: null, generation: null };
|
|
198
392
|
// TAL-529: after a compression rotation the Agent writes to the continuation, so the read follows the recorded lineage.
|
|
199
393
|
const lineage = s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null;
|
|
200
394
|
return stateDbSessionRead(this.stateDbPath(s.profile), s.session_id, { stitch: false, lineage });
|
|
201
395
|
}
|
|
396
|
+
/** Preserve existing transcript provenance before a turn adds Agent rows from its starting database read. */
|
|
397
|
+
beginStateDbTurn(s) {
|
|
398
|
+
stampStateDbGeneration([...s.messages, ...s.context_messages], markedStateDbGeneration(s));
|
|
399
|
+
return this.stateDbRead(s);
|
|
400
|
+
}
|
|
202
401
|
stateDbPath(profile) {
|
|
203
402
|
return join(this.deps.profileHome(profile ?? this.deps.activeProfile()), 'state.db');
|
|
204
403
|
}
|
|
@@ -210,7 +409,7 @@ export class SessionService {
|
|
|
210
409
|
mergedTranscript(s, local = s.messages, stateRows = this.stateDbRows(s)) {
|
|
211
410
|
if (!stateRows.length)
|
|
212
411
|
return local;
|
|
213
|
-
return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows) });
|
|
412
|
+
return mergeSessionMessagesAppendOnly(local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: currentStateDbSeenId(s, stateRows), stateDbGeneration: markedStateDbGeneration(s) });
|
|
214
413
|
}
|
|
215
414
|
/**
|
|
216
415
|
* TAL-493: record the highest state.db id this boundary or settled turn read, under the session lock in the same save,
|
|
@@ -218,6 +417,10 @@ export class SessionService {
|
|
|
218
417
|
* fields are set: the marker holds only while they and the row it names stay as recorded.
|
|
219
418
|
*/
|
|
220
419
|
markStateDbSeen(s, read = this.stateDbRead(s), opts = {}) {
|
|
420
|
+
// Freeze numeric provenance before replacing the marker, including after a database replacement.
|
|
421
|
+
const priorGeneration = markedStateDbGeneration(s);
|
|
422
|
+
if (priorGeneration !== null)
|
|
423
|
+
stampStateDbGeneration([...s.messages, ...s.context_messages], priorGeneration);
|
|
221
424
|
// A failed read says nothing. A settlement keeps the marker; a boundary drops it (fail closed): it may cut rows the
|
|
222
425
|
// marker still admits without moving a field, and the timestamp rules honour its watermark.
|
|
223
426
|
if (!read.ok) {
|
|
@@ -242,22 +445,38 @@ export class SessionService {
|
|
|
242
445
|
* turn started from but the transcript lacks (a CLI continuation read into its context) are kept before the turn.
|
|
243
446
|
*/
|
|
244
447
|
settleStateDb(s, turn) {
|
|
245
|
-
const read = this.stateDbRead(s);
|
|
246
|
-
const
|
|
247
|
-
const
|
|
448
|
+
const read = turn.read ?? this.stateDbRead(s);
|
|
449
|
+
const agentAdded = withoutRows(turn.agentRows ?? [], turn.previousContext);
|
|
450
|
+
const contextAdded = withoutRows(s.context_messages, turn.previousContext);
|
|
451
|
+
if (typeof turn.startGeneration === 'string') {
|
|
452
|
+
stampStateDbGeneration(s.messages.filter((m) => m._turn_id !== turn.turnId), markedStateDbGeneration(s));
|
|
453
|
+
stampStateDbGeneration([...s.messages.filter((m) => m._turn_id === turn.turnId), ...contextAdded, ...agentAdded], turn.startGeneration);
|
|
454
|
+
}
|
|
455
|
+
const startGeneration = turn.previousContext.findLast((m) => typeof m._state_db_generation === 'string')?._state_db_generation;
|
|
456
|
+
const generation = turn.startGeneration ?? markedStateDbGeneration(s) ?? (typeof startGeneration === 'string' ? startGeneration : null);
|
|
457
|
+
const readGeneration = read.generation;
|
|
458
|
+
const generationChanged = generation !== null && typeof readGeneration === 'string' && generation !== readGeneration;
|
|
459
|
+
const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
|
|
460
|
+
const identities = (m) => stateDbIdentityKeys(m, includeUids, generation);
|
|
461
|
+
const startedWith = new Set(turn.previousContext.flatMap((m) => identities(m)));
|
|
462
|
+
const wasInContext = (m) => stateDbMessageRepresented(m, startedWith, includeUids, generation);
|
|
463
|
+
const earlier = mergeSessionMessagesAppendOnly(s.messages, read.rows.filter(wasInContext), { stateDbSeenId: -1, stateDbGeneration: generation }).slice(s.messages.length);
|
|
248
464
|
const added = new Map();
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
465
|
+
const addedIds = new Set(agentAdded.flatMap((m) => identities(m)));
|
|
466
|
+
for (const m of agentAdded) {
|
|
467
|
+
if (!identities(m).length)
|
|
468
|
+
added.set(settledIdentity(m), (added.get(settledIdentity(m)) ?? 0) + 1);
|
|
469
|
+
}
|
|
470
|
+
const ownWorkAfter = turn.agentRows?.length || generationChanged ? null : turn.startId ?? null;
|
|
252
471
|
// Without a current marker, the turn's own starting read is the baseline: rows committed after it are judged here.
|
|
253
|
-
const seenId = currentStateDbSeenId(s, read.rows) ?? turn.startId ?? null;
|
|
254
|
-
const shown = read.rows.length ? mergeSessionMessagesAppendOnly(s.messages, read.rows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: seenId }) : s.messages;
|
|
472
|
+
const seenId = currentStateDbSeenId(s, read.rows) ?? (generationChanged ? null : turn.startId ?? null);
|
|
473
|
+
const shown = read.rows.length ? mergeSessionMessagesAppendOnly(s.messages, read.rows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: seenId, stateDbGeneration: generation }) : s.messages;
|
|
255
474
|
const fresh = new Set(shown.slice(s.messages.length));
|
|
256
475
|
// The turn's own rows the merge already matched to their local copies use up their share before any shown row can.
|
|
257
476
|
if (seenId !== null) {
|
|
258
477
|
for (const m of read.rows) {
|
|
259
478
|
const id = m._state_db_row_id;
|
|
260
|
-
if (typeof id !== 'number' || id <= seenId ||
|
|
479
|
+
if (typeof id !== 'number' || id <= seenId || wasInContext(m) || fresh.has(m))
|
|
261
480
|
continue;
|
|
262
481
|
const left = added.get(settledIdentity(m)) ?? 0;
|
|
263
482
|
if (left > 0)
|
|
@@ -266,7 +485,9 @@ export class SessionService {
|
|
|
266
485
|
}
|
|
267
486
|
const missed = [...fresh].filter((m) => {
|
|
268
487
|
const id = m._state_db_row_id;
|
|
269
|
-
if (
|
|
488
|
+
if (wasInContext(m))
|
|
489
|
+
return false;
|
|
490
|
+
if (stateDbMessageRepresented(m, addedIds, includeUids, generation))
|
|
270
491
|
return false;
|
|
271
492
|
const key = settledIdentity(m);
|
|
272
493
|
const left = added.get(key) ?? 0;
|
|
@@ -283,7 +504,7 @@ export class SessionService {
|
|
|
283
504
|
if (missed.length) {
|
|
284
505
|
s.messages.push(...copyJson(missed));
|
|
285
506
|
if (s.context_messages.length)
|
|
286
|
-
s.context_messages.
|
|
507
|
+
s.context_messages = mergeSessionMessagesAppendOnly(s.context_messages, copyJson(missed), { stateDbSeenId: -1, stateDbGeneration: generation });
|
|
287
508
|
}
|
|
288
509
|
this.markStateDbSeen(s, read);
|
|
289
510
|
// TAL-529: a compression rotation moved the Agent to a continuation, whose first rows restate the compressed context.
|
|
@@ -296,6 +517,30 @@ export class SessionService {
|
|
|
296
517
|
this.markStateDbSeen(s);
|
|
297
518
|
}
|
|
298
519
|
}
|
|
520
|
+
/**
|
|
521
|
+
* TAL-921: the model context a turn leaves when it ends without its settled result (an error, a Stop, a dead run), as a
|
|
522
|
+
* Stop's: the Agent's rows when they hold the turn's prompt, else its own rows among `stateRows` (the state.db rows
|
|
523
|
+
* committed after it started), then the prose that streamed past them. Null when it captured nothing the model can use.
|
|
524
|
+
*/
|
|
525
|
+
unsettledTurnContext(turn) {
|
|
526
|
+
const reported = turn.agentRows && checkpointTurnStart(turn.agentRows, turn.msgText, turn.historyLength) !== null ? turn.agentRows : null;
|
|
527
|
+
const own = reported ? null : runStateDbRows(turn.stateRows, turn.msgText);
|
|
528
|
+
const checkpoint = reported ?? (own && [...turn.previousContext, ...own]);
|
|
529
|
+
const context = stoppedTurnContext(turn.previousContext, checkpoint, turn.prompt, turn.msgText, turn.streamedText, turn.historyLength);
|
|
530
|
+
if (!context)
|
|
531
|
+
return null;
|
|
532
|
+
if (!own || !checkpoint || checkpointTurnStart(checkpoint, turn.msgText, turn.historyLength) === null)
|
|
533
|
+
return dedupeContext(context);
|
|
534
|
+
// The context holds the turn's own state.db rows from its prompt on. The Agent persisted the prompt's text only, so the
|
|
535
|
+
// prompt row carries what the turn sent (attached files, images). This turn's settlement covers the rows: without
|
|
536
|
+
// their ids, the next one never reads them as a CLI continuation the transcript lacks.
|
|
537
|
+
const at = turn.previousContext.length;
|
|
538
|
+
return dedupeContext(context.map((m, i) => {
|
|
539
|
+
if (i === at)
|
|
540
|
+
return { role: 'user', content: turn.prompt, ...(m.timestamp != null ? { timestamp: m.timestamp } : {}) };
|
|
541
|
+
return i > at && i < at + own.length ? Object.fromEntries(Object.entries(m).filter(([k]) => k !== '_state_db_row_id' && k !== '_state_db_generation')) : m;
|
|
542
|
+
}));
|
|
543
|
+
}
|
|
299
544
|
/**
|
|
300
545
|
* Python `reconciled_state_db_messages_for_session(prefer_context=True)`: the model history is the owner context
|
|
301
546
|
* extended append-only with the Agent's state.db rows (a CLI continuation of this session reaches the model), except
|
|
@@ -314,25 +559,37 @@ export class SessionService {
|
|
|
314
559
|
const pending = this.pendingTurn(s);
|
|
315
560
|
return pending ? withPendingUserTurn(context, pending) : context;
|
|
316
561
|
}
|
|
562
|
+
/** Python `_is_messaging_session_id`: from the WebUI metadata or the Agent's row. */
|
|
563
|
+
async isMessagingSession(sid) {
|
|
564
|
+
try {
|
|
565
|
+
if (isMessagingSessionRecord((await this.store.get(sid, { metadataOnly: true })).compact()))
|
|
566
|
+
return true;
|
|
567
|
+
}
|
|
568
|
+
catch { /* absent */ }
|
|
569
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
570
|
+
return meta !== null && isMessagingSessionRecord(meta);
|
|
571
|
+
}
|
|
317
572
|
/** Python `_lookup_cli_session_metadata`: the sidebar row for a state.db session in the active profile. */
|
|
318
|
-
lookupCliMeta(sid) {
|
|
573
|
+
async lookupCliMeta(sid) {
|
|
319
574
|
try {
|
|
320
575
|
if (isClaudeCodeSessionId(sid))
|
|
321
|
-
return this.claudeCodeRows().find((r) => str(r.session_id) === sid) ?? null;
|
|
322
|
-
|
|
576
|
+
return (await this.claudeCodeRows()).find((r) => str(r.session_id) === sid) ?? null;
|
|
577
|
+
const profile = this.deps.activeProfile();
|
|
578
|
+
return (await this.deps.cliSessionsSync(profile, { sourceFilter: null })).rows.find((r) => str(r.session_id) === sid)
|
|
579
|
+
?? (await this.deps.cliSessionsSync(profile, { sourceFilter: null, sessionIds: [sid] })).rows[0] ?? null;
|
|
323
580
|
}
|
|
324
581
|
catch {
|
|
325
582
|
return null;
|
|
326
583
|
}
|
|
327
584
|
}
|
|
328
|
-
claudeCodeRows() {
|
|
329
|
-
return this.deps.claudeCode?.rows(this.deps.workspaces.lastWorkspace(this.deps.activeProfile())) ?? [];
|
|
585
|
+
async claudeCodeRows() {
|
|
586
|
+
return this.deps.claudeCode?.rows((await this.deps.workspaces.lastWorkspace(this.deps.activeProfile()))) ?? [];
|
|
330
587
|
}
|
|
331
588
|
/** Python `_session_index_marks_was_webui`: an index row that once owned a WebUI sidecar (self-heal 404). */
|
|
332
|
-
indexMarksWasWebui(sid) {
|
|
589
|
+
async indexMarksWasWebui(sid) {
|
|
333
590
|
let entries;
|
|
334
591
|
try {
|
|
335
|
-
entries = this.store.readIndexEntries();
|
|
592
|
+
entries = (await this.store.readIndexEntries());
|
|
336
593
|
}
|
|
337
594
|
catch {
|
|
338
595
|
return false;
|
|
@@ -354,20 +611,23 @@ export class SessionService {
|
|
|
354
611
|
* a WebUI sidecar. `materialized` sessions are writeable (the caller persists the sidecar), `not_claimable` ones are
|
|
355
612
|
* read-only stubs, `was_webui`/`no_foreign_state`/`invalid_sid` answer 404.
|
|
356
613
|
*/
|
|
357
|
-
claimOrSynthesizeCliSession(sid, metaIn) {
|
|
614
|
+
async claimOrSynthesizeCliSession(sid, metaIn) {
|
|
358
615
|
if (!isSafeSessionId(sid))
|
|
359
616
|
return { session: null, reason: 'invalid_sid' };
|
|
360
617
|
const profile = this.deps.activeProfile();
|
|
361
618
|
const dbPath = join(this.deps.profileHome(profile), 'state.db');
|
|
619
|
+
sid = this.resolveSessionId(sid);
|
|
362
620
|
const row = stateDbSessionRow(dbPath, sid);
|
|
363
621
|
const stateDbSource = str(row?.source).trim().toLowerCase();
|
|
364
622
|
const subagentChild = stateDbSource === 'subagent';
|
|
365
|
-
|
|
623
|
+
const owners = this.stateDbSources([sid]);
|
|
624
|
+
const canonical = typeof owners?.get(sid) === 'object';
|
|
625
|
+
if (((await this.indexMarksWasWebui(sid)) || ((await this.store.wasDeleted(sid)) && ['', 'webui', 'fork'].includes(stateDbSource))) && !subagentChild && !canonical)
|
|
366
626
|
return { session: null, reason: 'was_webui' };
|
|
367
627
|
const msgs = isClaudeCodeSessionId(sid) ? this.deps.claudeCode?.messages(sid) ?? [] : stateDbSessionMessages(dbPath, sid, { stitch: true });
|
|
368
|
-
|
|
628
|
+
const meta = { ...(metaIn ?? (await this.lookupCliMeta(sid)) ?? {}) };
|
|
629
|
+
if (!msgs.length && !(meta._lineage_root_id && meta._lineage_tip_id))
|
|
369
630
|
return { session: null, reason: 'no_foreign_state' };
|
|
370
|
-
const meta = { ...(metaIn ?? this.lookupCliMeta(sid) ?? {}) };
|
|
371
631
|
if (row) {
|
|
372
632
|
if (!meta.source_tag && stateDbSource)
|
|
373
633
|
meta.source_tag = stateDbSource;
|
|
@@ -384,12 +644,16 @@ export class SessionService {
|
|
|
384
644
|
if (!meta.updated_at && (row.ended_at || row.started_at))
|
|
385
645
|
meta.updated_at = row.ended_at || row.started_at;
|
|
386
646
|
}
|
|
387
|
-
|
|
388
|
-
const
|
|
389
|
-
const
|
|
647
|
+
// An owner read that fails is an unknown owner, which never releases a claim (TAL-793).
|
|
648
|
+
const claimable = owners !== null && isClaimableCliSource(meta, owners.get(sid) || stateDbSource);
|
|
649
|
+
const workspace = str(meta.workspace || meta.cwd).trim() || (await this.deps.workspaces.lastWorkspace(profile));
|
|
650
|
+
const defaults = (await this.store.deps.defaults(profile));
|
|
651
|
+
const context = Array.isArray(meta._lineage_ids) ? stateDbSessionMessages(dbPath, sid, { stitch: false }) : null;
|
|
390
652
|
const session = new Session({
|
|
391
653
|
session_id: sid, title: str(meta.title) || 'CLI Session', workspace, model: str(meta.model) || 'unknown', model_provider: str(meta.model_provider) || null,
|
|
392
654
|
messages: msgs, created_at: Number(meta.created_at) || 0, updated_at: Number(meta.updated_at) || 0, profile: str(meta.profile) || null,
|
|
655
|
+
pinned: Boolean(meta.pinned), archived: Boolean(meta.archived),
|
|
656
|
+
...(context ? { context_messages: context } : {}),
|
|
393
657
|
is_cli_session: claimable ? true : !subagentChild, source_tag: str(meta.source_tag) || null, raw_source: str(meta.raw_source) || null,
|
|
394
658
|
session_source: str(meta.session_source) || null, source_label: str(meta.source_label) || null, read_only: !claimable,
|
|
395
659
|
// Python `import_cli_session`: the claimed sidecar keeps the row's lineage, background project and channel identity
|
|
@@ -399,9 +663,9 @@ export class SessionService {
|
|
|
399
663
|
}, defaults);
|
|
400
664
|
return { session, reason: claimable ? 'materialized' : 'not_claimable' };
|
|
401
665
|
}
|
|
402
|
-
mutationTarget(sid, verb) {
|
|
666
|
+
async mutationTarget(sid, verb) {
|
|
403
667
|
try {
|
|
404
|
-
return this.getForMutation(sid);
|
|
668
|
+
return (await this.getForMutation(sid));
|
|
405
669
|
}
|
|
406
670
|
catch (error) {
|
|
407
671
|
if (error instanceof SessionNotFound)
|
|
@@ -419,49 +683,61 @@ export class SessionService {
|
|
|
419
683
|
if (isClaudeCodeSessionId(sid))
|
|
420
684
|
throw new HttpFailure(400, `Claude Code sessions are view-only and cannot be ${verb} from WebUI`);
|
|
421
685
|
}
|
|
422
|
-
|
|
686
|
+
async rejectViewOnly(sid, verb) {
|
|
687
|
+
sid = sid.trim();
|
|
423
688
|
this.rejectClaudeCode(sid, verb);
|
|
424
|
-
|
|
689
|
+
let profile = this.deps.activeProfile();
|
|
690
|
+
try {
|
|
691
|
+
profile = (await this.store.get(sid, { metadataOnly: true, cacheOnMiss: false, promote: false })).profile || 'default';
|
|
692
|
+
}
|
|
693
|
+
catch { /* foreign row */ }
|
|
694
|
+
const owners = this.stateDbSources([sid], profile);
|
|
695
|
+
const owner = owners?.get(sid);
|
|
696
|
+
if (!owners || typeof owner === 'object')
|
|
697
|
+
throw new HttpFailure(403, typeof owner === 'object' ? owner.reason ?? CANONICAL_READ_ONLY_REASON : OWNERSHIP_UNAVAILABLE_REASON);
|
|
698
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
425
699
|
throw new HttpFailure(400, `Subagent sessions are view-only and cannot be ${verb} from WebUI`);
|
|
700
|
+
return sid;
|
|
701
|
+
}
|
|
702
|
+
/** All transcript transports use the same live-run and durable-pending evidence. */
|
|
703
|
+
async backgroundUpdates(s, messages, reviewRows) {
|
|
704
|
+
return withBackgroundUpdates(messages, s, { activeTurnId: this.deps.runtime.activeRunStream(s.session_id), pendingDelegations: (await this.deps.backgroundPending?.(s.session_id)) ?? new Set(), ...(reviewRows ? { reviewRows } : {}) });
|
|
426
705
|
}
|
|
427
706
|
/** TAL-372: each delegation row of a turn's scene carries the status of the work it started. */
|
|
428
|
-
backgroundLinked(s, messages) {
|
|
429
|
-
return withBackgroundLinks(messages, this.deps.backgroundReceipts?.(s.session_id) ?? []);
|
|
707
|
+
async backgroundLinked(s, messages) {
|
|
708
|
+
return withBackgroundLinks(messages, (await this.deps.backgroundReceipts?.(s.session_id)) ?? []);
|
|
430
709
|
}
|
|
431
710
|
/** `compact()` plus messages, redacted for the wire (Python `_public_session_projection`). */
|
|
432
|
-
publicSession(s, withMessages = true) {
|
|
433
|
-
const payload = this.wireRow(s);
|
|
711
|
+
async publicSession(s, withMessages = true, messages = s.messages) {
|
|
712
|
+
const payload = (await this.wireRow(s));
|
|
434
713
|
// Mutation replies replace a client's transcript, so they carry the same server-built scenes as the detail.
|
|
435
714
|
if (withMessages) {
|
|
436
|
-
payload.messages = this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(
|
|
715
|
+
payload.messages = (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(messages), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !(await this.journaledActiveTurn(s)) })));
|
|
437
716
|
payload.compression_reference = compressionReference(s, payload.messages);
|
|
438
717
|
}
|
|
439
718
|
return redactSessionData(payload, this.deps.redactEnabled());
|
|
440
719
|
}
|
|
441
720
|
/** `compact()` with the wire streaming/read-only flags (TAL-312), for replies that return the session row. */
|
|
442
|
-
wireRow(s) {
|
|
443
|
-
return withSessionWireFlags({ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
721
|
+
async wireRow(s) {
|
|
722
|
+
return withSessionWireFlags(withOwnerLocks([{ ...s.compact({ contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }), assistant_name: this.assistantName(s), workspace_name: (await this.workspaceNames([s]))(s) }], this.stateDbSources)[0], this.deps.runtime.activeStreamIds);
|
|
444
723
|
}
|
|
445
724
|
/**
|
|
446
725
|
* TAL-303: the label for a session's workspace, from its own profile's registry (a profileless row is the root's).
|
|
447
726
|
* One resolver serves one response, so each profile's registry is read once however many rows it names.
|
|
448
727
|
*/
|
|
449
|
-
workspaceNames() {
|
|
728
|
+
async workspaceNames(rows) {
|
|
450
729
|
const registries = new Map();
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
try {
|
|
456
|
-
entries = this.deps.workspaces.entries(profile);
|
|
457
|
-
}
|
|
458
|
-
catch {
|
|
459
|
-
entries = [];
|
|
460
|
-
}
|
|
461
|
-
registries.set(profile, entries);
|
|
730
|
+
for (const profile of new Set(rows.map((row) => str(row.profile) || 'default'))) {
|
|
731
|
+
let entries;
|
|
732
|
+
try {
|
|
733
|
+
entries = await this.deps.workspaces.entries(profile);
|
|
462
734
|
}
|
|
463
|
-
|
|
464
|
-
|
|
735
|
+
catch {
|
|
736
|
+
entries = [];
|
|
737
|
+
}
|
|
738
|
+
registries.set(profile, entries);
|
|
739
|
+
}
|
|
740
|
+
return (row) => workspaceDisplayName(str(row.workspace), registries.get(str(row.profile) || 'default') ?? []);
|
|
465
741
|
}
|
|
466
742
|
/** The agent's display name for a session's profile (`assistant_name`). */
|
|
467
743
|
assistantName(s) {
|
|
@@ -472,47 +748,219 @@ export class SessionService {
|
|
|
472
748
|
return redactSessionData(s.toDocument(), this.deps.redactEnabled());
|
|
473
749
|
}
|
|
474
750
|
// ── detail ──────────────────────────────────────────────────────────────
|
|
475
|
-
loadRevision(s) {
|
|
476
|
-
return
|
|
751
|
+
async loadRevision(s) {
|
|
752
|
+
return (await this.store.revision(s.session_id));
|
|
753
|
+
}
|
|
754
|
+
pageTails = new WeakMap();
|
|
755
|
+
pageContexts = new WeakMap();
|
|
756
|
+
prefixIndex(s, revision) {
|
|
757
|
+
const previous = s.prefixIndex;
|
|
758
|
+
if (previous?.rows === s.messages && previous.revision === revision && previous.length <= s.messages.length) {
|
|
759
|
+
previous.extend();
|
|
760
|
+
return previous;
|
|
761
|
+
}
|
|
762
|
+
return s.prefixIndex = new SessionPrefixIndex(s.messages, revision, markedStateDbGeneration(s), { toolCalls: s.tool_calls });
|
|
763
|
+
}
|
|
764
|
+
/** Existing projections, once per complete turn range, in absolute transcript coordinates. */
|
|
765
|
+
async deriveRange(s, rows, baseIndex, initialTurn, journaled, refs, calls, reviewRows) {
|
|
766
|
+
return (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(rows), baseIndex, initialTurn), s), reviewRows)), calls, s.active_stream_id, baseIndex), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: true, baseIndex, refCounts: refs })));
|
|
767
|
+
}
|
|
768
|
+
async limitedDetail(s, index, limit, before, pending, journaled) {
|
|
769
|
+
let prefixEnd = s.messages.length;
|
|
770
|
+
const turnId = pending?.turnId ?? journaled?.turnId;
|
|
771
|
+
if (turnId)
|
|
772
|
+
prefixEnd = Math.min(prefixEnd, index.turns.get(turnId)?.first ?? prefixEnd);
|
|
773
|
+
const prompt = pending ? index.prompts.get(pending.activeTurnToken) : undefined;
|
|
774
|
+
if (prompt !== undefined)
|
|
775
|
+
prefixEnd = Math.min(prefixEnd, prompt);
|
|
776
|
+
const dbPath = this.stateDbPath(s.profile);
|
|
777
|
+
const key = JSON.stringify([index.revision, index.length, statSignature(dbPath), statSignature(`${dbPath}-wal`), s.active_stream_id, pending, journaled, s.state_db_seen_id, s.state_db_seen_boundary, s.state_db_seen_stamp, s.updated_at, this.deps.activeProfile(), s.compression_anchor_summary, s.compression_anchor_message_key, s.compression_anchor_visible_idx, this.compactionWaiters.has(s.active_stream_id ?? '')]);
|
|
778
|
+
let cached = this.pageTails.get(index);
|
|
779
|
+
if (cached?.key !== key || cached.contextRows !== s.context_messages || cached.contextLength !== s.context_messages.length) {
|
|
780
|
+
const streaming = Boolean(s.active_stream_id && !this.compactionWaiters.has(s.active_stream_id));
|
|
781
|
+
const contextKey = JSON.stringify([index.revision, s.active_stream_id, index.length, streaming]);
|
|
782
|
+
let context = this.pageContexts.get(index);
|
|
783
|
+
if (context?.key !== contextKey || context.contextRows !== s.context_messages || context.contextLength !== s.context_messages.length) {
|
|
784
|
+
const summary = !s.context_messages.length && !s.active_stream_id ? index.model : new ApiMessageSummary(s.active_stream_id, !s.context_messages.length);
|
|
785
|
+
if (summary !== index.model && !streaming) {
|
|
786
|
+
for (const row of s.context_messages.length ? s.context_messages : s.messages) {
|
|
787
|
+
summary.add(row);
|
|
788
|
+
if (summary.enough)
|
|
789
|
+
break;
|
|
790
|
+
}
|
|
791
|
+
summary.finish();
|
|
792
|
+
}
|
|
793
|
+
context = { key: contextKey, local: summary.rows, enough: summary.enough, contextRows: s.context_messages, contextLength: s.context_messages.length, marker: summary.marker, unidentified: summary.enough ? [] : summary.unidentified };
|
|
794
|
+
this.pageContexts.set(index, context);
|
|
795
|
+
}
|
|
796
|
+
let stateRows;
|
|
797
|
+
let effectiveSeenId = null;
|
|
798
|
+
let cacheable = true;
|
|
799
|
+
let appended = null;
|
|
800
|
+
if (typeof s.state_db_seen_id === 'number' && str(s.source_tag || s.raw_source || s.session_source).trim().toLowerCase() !== 'subagent') {
|
|
801
|
+
const read = stateDbSessionTailRead(dbPath, s.session_id, s.state_db_seen_id, { lineage: s.state_db_lineage?.[0] === s.session_id ? s.state_db_lineage : null, unidentified: [...index.unidentified, ...context.unidentified] });
|
|
802
|
+
if (!read.ok)
|
|
803
|
+
cacheable = false;
|
|
804
|
+
const markerRows = read.anchor ? [read.anchor, ...read.rows] : read.rows;
|
|
805
|
+
if (read.ok && read.idCapable && currentStateDbSeenId(s, markerRows) !== null) {
|
|
806
|
+
// Covered rows consume unidentified local copies before a new duplicate. Any such match needs the full snapshot.
|
|
807
|
+
const ambiguous = read.requiresFullRead === true;
|
|
808
|
+
if (!ambiguous) {
|
|
809
|
+
const includeUids = read.rows.some((m) => stateDbIdentityKeys(m).some((id) => id.startsWith('uid:')));
|
|
810
|
+
const mode = includeUids ? 1 : 0;
|
|
811
|
+
appended = appendStateDbIdentityRows(read.rows, s.state_db_seen_id, index.identities[mode], index.unidentified[mode], index.generation, includeUids);
|
|
812
|
+
stateRows = read.rows;
|
|
813
|
+
effectiveSeenId = s.state_db_seen_id;
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
if (appended === null) {
|
|
818
|
+
stateRows = this.stateDbRows(s);
|
|
819
|
+
effectiveSeenId = currentStateDbSeenId(s, stateRows);
|
|
820
|
+
appended = this.mergedTranscript(s, s.messages, stateRows).slice(s.messages.length);
|
|
821
|
+
}
|
|
822
|
+
let tail = [...s.messages.slice(prefixEnd), ...appended];
|
|
823
|
+
if (pending)
|
|
824
|
+
tail = withPendingUserTurn(tail, { ...pending, localCount: s.messages.length - prefixEnd });
|
|
825
|
+
if (journaled)
|
|
826
|
+
tail = withoutRunningTurnOutput(tail, { ...journaled, localCount: s.messages.length - prefixEnd });
|
|
827
|
+
const tailIndex = new SessionPrefixIndex(tail, index.revision, index.generation, { toolCalls: index.toolCalls(prefixEnd, tail.length), baseIndex: prefixEnd, initialTurn: prefixEnd ? index.turnId(prefixEnd - 1) : 'legacy:start', timestampFloor: index.summary(prefixEnd).maxTimestamp, todoFloor: index.summary(prefixEnd).maxTodoTimestamp, parent: { index, end: prefixEnd } });
|
|
828
|
+
const enoughContext = !streaming && (context.enough || sanitizeMessagesForApi(context.marker ? context.local : mergeSessionMessagesAppendOnly(context.local, stateRows, { truncationWatermark: s.truncation_watermark, compressedWatermark: s.truncation_watermark_compressed, stateDbSeenId: effectiveSeenId, stateDbGeneration: index.generation }).filter((m) => !s.active_stream_id || m._turn_id !== s.active_stream_id), { maxMessages: 4 }).length >= 4);
|
|
829
|
+
const owners = new Map();
|
|
830
|
+
const sources = (ids, profile) => {
|
|
831
|
+
const ownerKey = JSON.stringify([ids, profile]);
|
|
832
|
+
const existing = owners.get(ownerKey);
|
|
833
|
+
if (existing)
|
|
834
|
+
return existing;
|
|
835
|
+
const result = this.stateDbSources(ids, profile);
|
|
836
|
+
if (result)
|
|
837
|
+
owners.set(ownerKey, result);
|
|
838
|
+
return result;
|
|
839
|
+
};
|
|
840
|
+
cached = { key, tail, index: tailIndex, enoughContext, sources, contextRows: s.context_messages, contextLength: s.context_messages.length,
|
|
841
|
+
summary: { total: prefixEnd + tail.length, last: Math.max(index.summary(prefixEnd).maxTimestamp, tailIndex.summary().maxTimestamp), todo: tailIndex.summary().todo ?? index.summary(prefixEnd).todo, compression: index.compressionReference(s, prefixEnd, tailIndex) } };
|
|
842
|
+
if (cacheable)
|
|
843
|
+
this.pageTails.set(index, cached);
|
|
844
|
+
}
|
|
845
|
+
const { tail, index: tailIndex } = cached;
|
|
846
|
+
const total = prefixEnd + tail.length;
|
|
847
|
+
const end = before === null ? total : Math.max(0, Math.min(before, total));
|
|
848
|
+
const readRange = (start, stop) => start >= prefixEnd ? tail.slice(start - prefixEnd, stop - prefixEnd) : [...s.messages.slice(start, Math.min(stop, prefixEnd)), ...(stop > prefixEnd ? tail.slice(0, stop - prefixEnd) : [])];
|
|
849
|
+
const idAt = (at) => at < prefixEnd ? index.turnId(at) : tailIndex.turnId(at);
|
|
850
|
+
const turnSpan = (id) => {
|
|
851
|
+
const prefix = index.turns.get(id), suffix = tailIndex.turns.get(id);
|
|
852
|
+
return { first: Math.min(prefix && prefix.first < prefixEnd ? prefix.first : total, suffix?.first ?? total), last: Math.max(prefix && prefix.first < prefixEnd ? Math.min(prefix.last, prefixEnd - 1) : -1, suffix?.last ?? -1) + 1 };
|
|
853
|
+
};
|
|
854
|
+
const spanAt = (at) => {
|
|
855
|
+
const id = idAt(at), own = turnSpan(id);
|
|
856
|
+
const dependencies = [index.dependencies.get(id), tailIndex.dependencies.get(id)];
|
|
857
|
+
const first = Math.min(...dependencies.map((span) => span?.first ?? total));
|
|
858
|
+
const last = Math.max(...dependencies.map((span) => span ? Math.min(span.last, total - 1) : -1));
|
|
859
|
+
return { first: Math.min(own.first, first), last: Math.max(own.last, last + 1) };
|
|
860
|
+
};
|
|
861
|
+
// A notification can refer to a parent turn outside this page. Read that indexed turn, never the whole prefix.
|
|
862
|
+
const reviewTurns = new Map();
|
|
863
|
+
const reviewRows = (id) => {
|
|
864
|
+
let rows = reviewTurns.get(id);
|
|
865
|
+
if (!rows) {
|
|
866
|
+
const span = turnSpan(id);
|
|
867
|
+
rows = span.first < span.last ? withTurnIds(readRange(span.first, span.last), span.first, span.first ? idAt(span.first - 1) : 'legacy:start').filter((row) => row._turn_id === id) : [];
|
|
868
|
+
reviewTurns.set(id, rows);
|
|
869
|
+
}
|
|
870
|
+
return rows;
|
|
871
|
+
};
|
|
872
|
+
const refs = { get: (ref) => index.refCountBefore(ref, prefixEnd) + (tailIndex.refCounts.get(ref) ?? 0) };
|
|
873
|
+
if (cached.newestReply === undefined) {
|
|
874
|
+
let newestReply = null;
|
|
875
|
+
const excluded = new Set(s.active_stream_id ? [s.active_stream_id] : []);
|
|
876
|
+
selection: for (const owner of [tailIndex, index])
|
|
877
|
+
for (const candidate of owner.replyTurns(owner === index ? prefixEnd : total, excluded)) {
|
|
878
|
+
const span = turnSpan(idAt(candidate)), rows = readRange(span.first, span.last);
|
|
879
|
+
const derived = await this.deriveRange(s, rows, span.first, span.first ? idAt(span.first - 1) : 'legacy:start', Boolean(journaled), refs, index.toolCalls(span.first, rows.length), reviewRows);
|
|
880
|
+
const newest = newestSettledReply(derived, s.active_stream_id, span.first);
|
|
881
|
+
if (newest !== null) {
|
|
882
|
+
newestReply = newest;
|
|
883
|
+
break selection;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
cached.newestReply = newestReply;
|
|
887
|
+
}
|
|
888
|
+
let start = end ? spanAt(Math.max(0, end - limit)).first : 0;
|
|
889
|
+
if (pending && before === null)
|
|
890
|
+
start = Math.min(start, prefixEnd);
|
|
891
|
+
let stop = end ? spanAt(end - 1).last : 0;
|
|
892
|
+
// Stamped ids can recur non-contiguously. Include every row of a selected turn, as the full derivation does.
|
|
893
|
+
let derived = [];
|
|
894
|
+
for (;;) {
|
|
895
|
+
for (let at = start; at < stop; at += 1) {
|
|
896
|
+
const span = spanAt(at);
|
|
897
|
+
if (span.first < start) {
|
|
898
|
+
start = span.first;
|
|
899
|
+
at = start - 1;
|
|
900
|
+
}
|
|
901
|
+
stop = Math.max(stop, span.last);
|
|
902
|
+
}
|
|
903
|
+
const rows = readRange(start, stop);
|
|
904
|
+
const calls = index.toolCalls(start, rows.length);
|
|
905
|
+
derived = (await this.deriveRange(s, rows, start, start ? idAt(start - 1) : 'legacy:start', Boolean(journaled), refs, calls, reviewRows));
|
|
906
|
+
const [page, relative] = messageWindowForDisplay(derived, limit, end - start);
|
|
907
|
+
if (relative > 0 || start === 0 || page.filter((m) => messageCountsAsRenderable(m)).length >= limit) {
|
|
908
|
+
let offset = start + relative;
|
|
909
|
+
let messages = page;
|
|
910
|
+
if (pending && before === null) {
|
|
911
|
+
const at = derived.findIndex((m) => m.role === 'user' && m._active_turn_token === pending.activeTurnToken);
|
|
912
|
+
if (at >= 0 && start + at < offset) {
|
|
913
|
+
messages = derived.slice(at, relative + messages.length);
|
|
914
|
+
offset = start + at;
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
return { messages, offset, newestReply: cached.newestReply, ...cached.summary, enoughContext: cached.enoughContext, sources: cached.sources };
|
|
918
|
+
}
|
|
919
|
+
start = spanAt(Math.max(0, start - Math.max(limit, stop - start))).first;
|
|
920
|
+
}
|
|
477
921
|
}
|
|
478
922
|
/** Python `_handle_session_get` on the WebUI sidecar (state.db merge pending). */
|
|
479
|
-
detail(sid, query) {
|
|
923
|
+
async detail(sid, query) {
|
|
480
924
|
if (!sid)
|
|
481
925
|
throw new HttpFailure(400, 'session_id is required');
|
|
926
|
+
sid = this.resolveSessionId(sid);
|
|
482
927
|
const loadMessages = (query.messages ?? '1') !== '0';
|
|
483
928
|
const msgLimit = parseMsgLimit(query.msg_limit ?? null);
|
|
484
929
|
const msgBeforeRaw = query.msg_before;
|
|
485
930
|
const msgBefore = msgBeforeRaw ? (Number.isInteger(Number(msgBeforeRaw)) ? Number(msgBeforeRaw) : null) : null;
|
|
486
931
|
let s;
|
|
487
932
|
try {
|
|
488
|
-
s = this.store.get(sid, { metadataOnly: !loadMessages });
|
|
933
|
+
s = (await this.store.get(sid, { metadataOnly: !loadMessages }));
|
|
489
934
|
}
|
|
490
935
|
catch {
|
|
491
|
-
return this.foreignSessionDetail(sid);
|
|
936
|
+
return (await this.foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore));
|
|
492
937
|
}
|
|
493
|
-
const revisionBefore = this.loadRevision(s);
|
|
938
|
+
const revisionBefore = (await this.loadRevision(s));
|
|
494
939
|
if (!this.visibleToActiveProfile(s.profile)) {
|
|
495
940
|
if (s.profile)
|
|
496
941
|
throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile: s.profile });
|
|
497
942
|
throw new HttpFailure(404, 'Session not found');
|
|
498
943
|
}
|
|
499
|
-
this.clearStaleStreamState(s);
|
|
500
|
-
const journaled = loadMessages ? this.journaledActiveTurn(s) : null;
|
|
501
|
-
let transcript = loadMessages ? this.mergedTranscript(s) : [];
|
|
944
|
+
await this.clearStaleStreamState(s);
|
|
945
|
+
const journaled = loadMessages ? (await this.journaledActiveTurn(s)) : null;
|
|
502
946
|
const pending = loadMessages ? this.pendingTurn(s) : null;
|
|
503
|
-
|
|
947
|
+
const prefix = loadMessages && msgLimit !== null ? this.prefixIndex(s, revisionBefore) : null;
|
|
948
|
+
const limited = prefix && msgLimit !== null ? (await this.limitedDetail(s, prefix, msgLimit, msgBefore, pending, journaled)) : null;
|
|
949
|
+
const stateRows = loadMessages && !limited ? this.stateDbRows(s) : [];
|
|
950
|
+
let transcript = loadMessages && !limited ? this.mergedTranscript(s, s.messages, stateRows) : [];
|
|
951
|
+
if (pending && !limited)
|
|
504
952
|
transcript = withPendingUserTurn(transcript, pending);
|
|
505
|
-
if (journaled)
|
|
953
|
+
if (journaled && !limited)
|
|
506
954
|
transcript = withoutRunningTurnOutput(transcript, { ...journaled, localCount: s.messages.length });
|
|
507
955
|
// Turn ids, tool outcomes and scenes are computed over the full transcript, so every window reports the same values.
|
|
508
|
-
const all = loadMessages ? this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes(
|
|
509
|
-
let truncated = [];
|
|
510
|
-
let offset = 0;
|
|
511
|
-
let summaryCount = null;
|
|
512
|
-
let summaryLast = null;
|
|
956
|
+
const all = loadMessages && !limited ? (await this.backgroundLinked(s, hydrateAnchorActivityScenes(withToolCallOutcomes((await this.backgroundUpdates(s, withMarkerKinds(withTurnIds(withAttachmentObjects(transcript)), s))), s.tool_calls, s.active_stream_id), s.anchor_activity_scenes, { activeTurnId: s.active_stream_id, runningScene: !journaled, clipToolResults: msgLimit !== null }))) : [];
|
|
957
|
+
let truncated = limited?.messages ?? [];
|
|
958
|
+
let offset = limited?.offset ?? 0;
|
|
959
|
+
let summaryCount = limited?.total ?? null;
|
|
960
|
+
let summaryLast = limited?.last ?? null;
|
|
513
961
|
if (loadMessages) {
|
|
514
|
-
|
|
515
|
-
|
|
962
|
+
if (!limited)
|
|
963
|
+
[truncated, offset] = messageWindowForDisplay(all, msgLimit, msgBefore);
|
|
516
964
|
// TAL-368: a tail window starts no later than the running turn's prompt, so a turn whose output stays in the
|
|
517
965
|
// transcript (no journal to replay it) keeps its prompt; older pages end before it, so it is never sent twice.
|
|
518
966
|
// ponytail: the window grows with that turn's persisted rows; clip the turn instead if degraded runs get long.
|
|
@@ -522,7 +970,7 @@ export class SessionService {
|
|
|
522
970
|
offset = prompt;
|
|
523
971
|
}
|
|
524
972
|
// Per-row display fields (TAL-186 media, TAL-456 excerpts cut from that display text) only for the rows sent.
|
|
525
|
-
truncated = withBodyExcerpts(withDisplayMedia(truncated, this.mediaProjector(s)), s.active_stream_id);
|
|
973
|
+
truncated = withBodyExcerpts(withDisplayMedia(truncated, (await this.mediaProjector(s))), s.active_stream_id, limited ? limited.newestReply : newestSettledReply(all, s.active_stream_id), offset);
|
|
526
974
|
if (msgLimit !== null)
|
|
527
975
|
truncated = messagesForLimitedPayload(truncated);
|
|
528
976
|
}
|
|
@@ -530,10 +978,10 @@ export class SessionService {
|
|
|
530
978
|
summaryCount = s.metadataMessageCount ?? s.messages.length;
|
|
531
979
|
summaryLast = lastMessageTimestamp(s.messages) ?? 0;
|
|
532
980
|
}
|
|
533
|
-
const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < all.length);
|
|
981
|
+
const windowed = loadMessages && msgLimit !== null && (msgBefore !== null || truncated.length < (limited?.total ?? all.length));
|
|
534
982
|
let toolCalls = loadMessages ? s.tool_calls : [];
|
|
535
983
|
if (windowed)
|
|
536
|
-
toolCalls = toolCallsForMessageWindow(toolCalls, offset, truncated.length);
|
|
984
|
+
toolCalls = prefix ? prefix.toolCalls(offset, truncated.length, true) : toolCallsForMessageWindow(toolCalls, offset, truncated.length);
|
|
537
985
|
const mergedCount = summaryCount ?? all.length;
|
|
538
986
|
let mergedLast = summaryLast ?? 0;
|
|
539
987
|
if (summaryLast === null && all.length) {
|
|
@@ -541,7 +989,7 @@ export class SessionService {
|
|
|
541
989
|
}
|
|
542
990
|
const activeStreamIds = this.deps.runtime.activeStreamIds;
|
|
543
991
|
const raw = {
|
|
544
|
-
...s.compact({ includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
992
|
+
...s.compact({ ...(prefix ? { transcriptSummary: prefix.summary() } : {}), includeRuntime: true, activeStreamIds, contextLengthFor: this.deps.contextLengthFor, modelOptionFor: this.deps.modelOptionFor }),
|
|
545
993
|
messages: truncated,
|
|
546
994
|
message_count: mergedCount,
|
|
547
995
|
tool_calls: toolCalls,
|
|
@@ -555,10 +1003,12 @@ export class SessionService {
|
|
|
555
1003
|
};
|
|
556
1004
|
if (loadMessages)
|
|
557
1005
|
raw.transcript_seq = journaled ? { stream_id: journaled.turnId, seq: 0 } : null;
|
|
558
|
-
if (
|
|
1006
|
+
if (limited)
|
|
1007
|
+
attachTodoSnapshot(raw, limited.todo, s.extra[UNSETTLED_TODO_KEY]);
|
|
1008
|
+
else if (loadMessages)
|
|
559
1009
|
attachTodoState(raw, all, s.extra[UNSETTLED_TODO_KEY]);
|
|
560
1010
|
if (loadMessages)
|
|
561
|
-
raw.compression_reference = compressionReference(s, all);
|
|
1011
|
+
raw.compression_reference = limited ? limited.compression : compressionReference(s, all);
|
|
562
1012
|
if (mergedLast) {
|
|
563
1013
|
raw.last_message_at = Math.max(Number(raw.last_message_at ?? 0) || 0, mergedLast);
|
|
564
1014
|
raw.updated_at = Math.max(Number(raw.updated_at ?? 0) || 0, mergedLast);
|
|
@@ -567,25 +1017,66 @@ export class SessionService {
|
|
|
567
1017
|
raw._messages_truncated = isTruncated;
|
|
568
1018
|
raw._messages_offset = offset;
|
|
569
1019
|
raw._msg_limit_max = MAX_MSG_LIMIT;
|
|
570
|
-
const revisionAfter = this.loadRevision(s);
|
|
1020
|
+
const revisionAfter = (await this.loadRevision(s));
|
|
571
1021
|
raw._load_revision = revisionBefore !== null && revisionBefore === revisionAfter ? hashRevision(revisionBefore) : `unstable-${randomUUID().replace(/-/g, '')}`;
|
|
572
|
-
raw
|
|
1022
|
+
Object.assign(raw, withOwnerLocks([raw], limited?.sources ?? this.stateDbSources)[0]);
|
|
573
1023
|
raw.assistant_name = this.assistantName(s);
|
|
574
|
-
raw.workspace_name = this.workspaceNames()(s);
|
|
1024
|
+
raw.workspace_name = (await this.workspaceNames([s]))(s);
|
|
1025
|
+
raw.branched_from = (await this.branchedFrom(s));
|
|
575
1026
|
withSessionWireFlags(raw, activeStreamIds);
|
|
1027
|
+
// A metadata-only read lacks the history the gate counts, so it carries no flag.
|
|
1028
|
+
if (loadMessages)
|
|
1029
|
+
raw.can_compress = limited ? raw.read_only !== true && !isClaudeCodeSessionId(s.session_id) && (!s.active_stream_id || this.compactionWaiters.has(s.active_stream_id)) && limited.enoughContext : this.canCompress(s, raw.read_only === true, stateRows);
|
|
1030
|
+
raw.confirms_deep_truncate = true;
|
|
576
1031
|
raw.pending_steers = raw.active_stream_id ? (this.deps.runtime.pendingSteers?.(str(raw.active_stream_id)) ?? []) : [];
|
|
577
|
-
|
|
1032
|
+
// TAL-1006: the compression a client shows on load: the running manual job's, else the live run's open span.
|
|
1033
|
+
const job = this.compressionJobs.get(s.session_id);
|
|
1034
|
+
raw.run_status = this.currentRunStatus(s.session_id, str(raw.active_stream_id) || null);
|
|
1035
|
+
raw.compression_started_at = job?.status === 'running' ? job.started_at : raw.active_stream_id ? (this.deps.runtime.compressionStartedAt?.(str(raw.active_stream_id)) ?? null) : null;
|
|
1036
|
+
raw.automatic_turns_held = (await this.deps.runtime.automaticTurnsHeld?.(s.session_id)) ?? false;
|
|
1037
|
+
raw.notices = this.deps.runtime.sessionNotices?.(s.session_id) ?? [];
|
|
1038
|
+
raw.goal = this.deps.runtime.sessionGoal?.(s.session_id) ?? null;
|
|
1039
|
+
const publicRow = redactSessionData(raw, this.deps.redactEnabled());
|
|
1040
|
+
return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
|
|
1041
|
+
}
|
|
1042
|
+
/**
|
|
1043
|
+
* TAL-454: the chat `/branch` copied, while it still loads (archived included). A compression continuation is also a
|
|
1044
|
+
* fork of its parent but not a branch, so it links nowhere.
|
|
1045
|
+
*/
|
|
1046
|
+
async branchedFrom(s) {
|
|
1047
|
+
const parentId = s.parent_session_id;
|
|
1048
|
+
if (!parentId || str(s.session_source).trim().toLowerCase() !== 'fork' || s.compression_recovery_source_session_id)
|
|
1049
|
+
return null;
|
|
1050
|
+
let title;
|
|
1051
|
+
try {
|
|
1052
|
+
const parent = (await this.store.get(parentId, { metadataOnly: true, promote: false, cacheOnMiss: false }));
|
|
1053
|
+
if (!this.visibleToActiveProfile(parent.profile))
|
|
1054
|
+
return null;
|
|
1055
|
+
title = parent.title;
|
|
1056
|
+
}
|
|
1057
|
+
catch {
|
|
1058
|
+
// A read-only foreign parent (a cron run) has no sidecar and opens from the active profile's state.db while it
|
|
1059
|
+
// has transcript rows and is not a deleted chat (`claimOrSynthesizeCliSession`'s `was_webui` and `no_foreign_state`).
|
|
1060
|
+
const dbPath = this.stateDbPath(null);
|
|
1061
|
+
const row = stateDbSessionRow(dbPath, parentId);
|
|
1062
|
+
if (!row || (await this.store.wasDeleted(parentId)) || (await this.indexMarksWasWebui(parentId)))
|
|
1063
|
+
return null;
|
|
1064
|
+
if (!stateDbSessionMessages(dbPath, parentId, { stitch: true }).length)
|
|
1065
|
+
return null;
|
|
1066
|
+
title = row.title;
|
|
1067
|
+
}
|
|
1068
|
+
return { session_id: parentId, title: str(redactText(str(title) || 'Untitled', this.deps.redactEnabled())) };
|
|
578
1069
|
}
|
|
579
1070
|
/**
|
|
580
1071
|
* TAL-316: the active run whose journal can replay its output from the start. The detail then leaves that output to the
|
|
581
1072
|
* replay; with no active run, no identifiable prompt, or no complete journal, it returns the persisted transcript unchanged.
|
|
582
1073
|
*/
|
|
583
|
-
journaledActiveTurn(s) {
|
|
1074
|
+
async journaledActiveTurn(s) {
|
|
584
1075
|
const turnId = str(s.active_stream_id).trim();
|
|
585
1076
|
const activeTurnToken = buildActiveTurnToken(turnId, s.pending_started_at);
|
|
586
1077
|
if (!turnId || !activeTurnToken || this.deps.runtime.journalDegraded(turnId))
|
|
587
1078
|
return null;
|
|
588
|
-
const summary = this.deps.journal?.findRunSummary(turnId);
|
|
1079
|
+
const summary = (await this.deps.journal?.findRunSummary(turnId));
|
|
589
1080
|
if (summary?.session_id !== s.session_id || summary.journal_pruned)
|
|
590
1081
|
return null;
|
|
591
1082
|
return { turnId, startedAt: Number(s.pending_started_at), activeTurnToken };
|
|
@@ -601,8 +1092,9 @@ export class SessionService {
|
|
|
601
1092
|
return { turnId, startedAt, activeTurnToken, localCount: s.messages.length, prompt: pendingUserRow(text, s.pending_attachments, startedAt, s.pending_user_source || 'webui', turnId) };
|
|
602
1093
|
}
|
|
603
1094
|
/** A session without a sidecar, synthesized from state.db for this profile; 404/409 like the detail. */
|
|
604
|
-
foreignSession(sid) {
|
|
605
|
-
|
|
1095
|
+
async foreignSession(sid) {
|
|
1096
|
+
sid = this.resolveSessionId(sid);
|
|
1097
|
+
const meta = (await this.lookupCliMeta(sid));
|
|
606
1098
|
const profile = str(meta?.profile) || null;
|
|
607
1099
|
const profileAgnostic = str(meta?.source_tag || meta?.raw_source).trim().toLowerCase() === 'claude_code';
|
|
608
1100
|
if (!profileAgnostic && !this.visibleToActiveProfile(profile)) {
|
|
@@ -610,16 +1102,19 @@ export class SessionService {
|
|
|
610
1102
|
throw new HttpFailure(409, 'Session belongs to a different profile', { code: 'session_profile_mismatch', session_id: sid, profile });
|
|
611
1103
|
throw new HttpFailure(404, 'Session not found');
|
|
612
1104
|
}
|
|
613
|
-
const { session: synth, reason } = this.claimOrSynthesizeCliSession(sid, meta);
|
|
1105
|
+
const { session: synth, reason } = (await this.claimOrSynthesizeCliSession(sid, meta));
|
|
614
1106
|
if (!synth || reason === 'was_webui')
|
|
615
1107
|
throw new HttpFailure(404, 'Session not found');
|
|
616
1108
|
return { synth, meta };
|
|
617
1109
|
}
|
|
618
1110
|
/** Python `_handle_session_get` without a sidecar: the state.db transcript as a (read-only or claimable) foreign stub. */
|
|
619
|
-
|
|
620
|
-
|
|
1111
|
+
foreignMessages(messages, session) {
|
|
1112
|
+
return hydrateAnchorActivityScenes(withToolCallOutcomes(withMarkerKinds(withTurnIds(messages), session), [], null), {});
|
|
1113
|
+
}
|
|
1114
|
+
async foreignSessionDetail(sid, loadMessages, msgLimit, msgBefore) {
|
|
1115
|
+
const { synth, meta } = (await this.foreignSession(sid));
|
|
621
1116
|
// The same turn projection as a WebUI session: turn ids, tool outcomes, then each completed turn's scene.
|
|
622
|
-
const msgs =
|
|
1117
|
+
const msgs = this.foreignMessages(synth.messages, synth);
|
|
623
1118
|
const lastTs = Number(msgs[msgs.length - 1]?.timestamp ?? 0) || 0;
|
|
624
1119
|
const sess = {
|
|
625
1120
|
session_id: synth.session_id, title: synth.title, workspace: synth.workspace, model: synth.model, message_count: msgs.length,
|
|
@@ -627,14 +1122,15 @@ export class SessionService {
|
|
|
627
1122
|
pinned: synth.pinned, archived: synth.archived, project_id: synth.project_id ?? null, profile: synth.profile,
|
|
628
1123
|
is_cli_session: synth.is_cli_session, source_tag: synth.source_tag, raw_source: synth.raw_source, session_source: synth.session_source,
|
|
629
1124
|
source_label: synth.source_label, read_only: synth.read_only, can_duplicate: false, messages: msgs, tool_calls: [], transcript_seq: null,
|
|
630
|
-
assistant_name: this.assistantName(synth), workspace_name: this.workspaceNames()(synth),
|
|
1125
|
+
assistant_name: this.assistantName(synth), workspace_name: (await this.workspaceNames([synth]))(synth), confirms_deep_truncate: true,
|
|
631
1126
|
};
|
|
632
1127
|
attachTodoState(sess, msgs);
|
|
633
|
-
const merged = withSessionWireFlags(meta ? mergeCliSidebarMetadata(sess, meta) : sess, this.deps.runtime.activeStreamIds);
|
|
634
|
-
|
|
1128
|
+
const merged = withSessionWireFlags(withOwnerLocks([meta ? mergeCliSidebarMetadata(sess, meta) : sess], this.stateDbSources)[0], this.deps.runtime.activeStreamIds);
|
|
1129
|
+
const publicRow = redactSessionData(merged, this.deps.redactEnabled());
|
|
1130
|
+
return this.owner ? await this.owner.project(publicRow, msgLimit, msgBefore, loadMessages) : publicRow;
|
|
635
1131
|
}
|
|
636
1132
|
/** Clear persisted streaming flags when no live stream backs them (Python `_clear_stale_stream_state`). */
|
|
637
|
-
clearStaleStreamState(session) {
|
|
1133
|
+
async clearStaleStreamState(session) {
|
|
638
1134
|
const streamId = session.active_stream_id;
|
|
639
1135
|
if (!streamId)
|
|
640
1136
|
return false;
|
|
@@ -645,9 +1141,12 @@ export class SessionService {
|
|
|
645
1141
|
const pendingAge = session.pending_started_at ? this.deps.now() - session.pending_started_at : null;
|
|
646
1142
|
if (session.hasPendingPrompt && pendingAge !== null && pendingAge < 30)
|
|
647
1143
|
return false;
|
|
1144
|
+
// Reading a display-only session must not settle the previous Talaria worker's transcript.
|
|
1145
|
+
if ((await this.isReadOnly(session)))
|
|
1146
|
+
return false;
|
|
648
1147
|
let target = session;
|
|
649
1148
|
if (session.loadedMetadataOnly) {
|
|
650
|
-
const full = this.store.load(session.session_id);
|
|
1149
|
+
const full = (await this.store.load(session.session_id));
|
|
651
1150
|
if (!full)
|
|
652
1151
|
return false;
|
|
653
1152
|
target = full;
|
|
@@ -665,7 +1164,7 @@ export class SessionService {
|
|
|
665
1164
|
// run that never finished with an answer, so a dead stream never silently drops what the user sent or what streamed.
|
|
666
1165
|
const turnId = str(target.active_stream_id);
|
|
667
1166
|
// ponytail: one bounded tail read (Python's recovery window); a journal past it recovers its latest output only.
|
|
668
|
-
const events = this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS)
|
|
1167
|
+
const events = (await this.deps.journal?.readRunEventTail(target.session_id, turnId, RECOVERY_JOURNAL_MAX_BYTES, RECOVERY_JOURNAL_MAX_ROWS))?.events ?? [];
|
|
669
1168
|
const { rows: output, answered } = journalOutputRows(events, turnId);
|
|
670
1169
|
const pendingText = str(target.pending_user_message);
|
|
671
1170
|
const attachments = [...target.pending_attachments];
|
|
@@ -674,7 +1173,8 @@ export class SessionService {
|
|
|
674
1173
|
const read = this.stateDbRead(target);
|
|
675
1174
|
const runStart = target.pending_started_at || events[0]?.created_at || this.deps.now();
|
|
676
1175
|
const before = read.rows.filter((m) => Number(m.timestamp) < runStart);
|
|
677
|
-
const
|
|
1176
|
+
const runRows = read.rows.filter((m) => Number(m.timestamp) >= runStart);
|
|
1177
|
+
const ownRows = target.hasPendingPrompt ? runStateDbRows(runRows, pendingText) : null;
|
|
678
1178
|
// Without this turn's rows: an eager save already put its prompt in the transcript a fresh context falls back to.
|
|
679
1179
|
const previousContext = this.modelContext(target, before).filter((m) => m._turn_id !== turnId);
|
|
680
1180
|
if (target.hasPendingPrompt) {
|
|
@@ -684,16 +1184,16 @@ export class SessionService {
|
|
|
684
1184
|
target.messages.push({ role: 'user', content: pendingText, timestamp: Math.trunc(startedAt), ...(attachments.length ? { attachments } : {}), _recovered: true, _source: target.pending_user_source ?? 'webui', _turn_id: turnId });
|
|
685
1185
|
// The model context settles as a Stop's does: the Agent's own rows when it committed the prompt, else the prompt it
|
|
686
1186
|
// was sent; then the prose that streamed past them.
|
|
687
|
-
const prompt = attachedFilesPrompt(
|
|
1187
|
+
const prompt = attachedFilesPrompt(pendingText, attachments);
|
|
688
1188
|
const streamed = output.map((m) => str(m.content)).filter(Boolean).join('\n\n');
|
|
689
|
-
target.context_messages =
|
|
1189
|
+
target.context_messages = this.unsettledTurnContext({ previousContext, prompt, msgText: pendingText, streamedText: streamed, historyLength: previousContext.length, stateRows: runRows }) ?? dedupeContext([...structuredClone(previousContext), { role: 'user', content: prompt }]);
|
|
690
1190
|
}
|
|
691
1191
|
if (target.hasPendingPrompt || output.length) {
|
|
692
1192
|
target.messages.push(...output);
|
|
693
1193
|
if (!answered)
|
|
694
|
-
target.messages.push({ role: 'assistant', content: '**Interrupted:** The reply was interrupted before it could be saved.', timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: turnId });
|
|
1194
|
+
target.messages.push({ role: 'assistant', content: '**Interrupted:** The reply was interrupted before it could be saved.', _terminal_state: 'interrupted', timestamp: Math.trunc(this.deps.now()), _error: true, _turn_id: turnId });
|
|
695
1195
|
// Settled like a turn: the Agent's own rows are covered by the recovered turn; without them, every row past the run's start is.
|
|
696
|
-
this.settleStateDb(target, { turnId, previousContext, agentRows: ownRows, startId: stateDbSeenId(before) ?? (read.idCapable ? 0 : null) });
|
|
1196
|
+
this.settleStateDb(target, { turnId, previousContext, agentRows: ownRows, startId: stateDbSeenId(before) ?? (read.idCapable ? 0 : null), read });
|
|
697
1197
|
}
|
|
698
1198
|
target.active_stream_id = null;
|
|
699
1199
|
target.pending_user_message = null;
|
|
@@ -701,7 +1201,7 @@ export class SessionService {
|
|
|
701
1201
|
target.pending_started_at = null;
|
|
702
1202
|
target.pending_user_source = null;
|
|
703
1203
|
try {
|
|
704
|
-
this.store.save(target, { touchUpdatedAt: false });
|
|
1204
|
+
(await this.store.save(target, { touchUpdatedAt: false }));
|
|
705
1205
|
}
|
|
706
1206
|
catch {
|
|
707
1207
|
return false;
|
|
@@ -712,31 +1212,52 @@ export class SessionService {
|
|
|
712
1212
|
session.pending_attachments = [];
|
|
713
1213
|
session.pending_started_at = null;
|
|
714
1214
|
session.pending_user_source = null;
|
|
715
|
-
this.store.touch(target);
|
|
1215
|
+
await this.store.touch(target);
|
|
716
1216
|
}
|
|
717
1217
|
return true;
|
|
718
1218
|
}
|
|
719
1219
|
// ── list / search ────────────────────────────────────────────────────────
|
|
720
|
-
|
|
1220
|
+
/** Expand saved aliases in one worker query independently of the bounded native sidebar window. */
|
|
1221
|
+
async persistedAliasMetadata(rows, existing = []) {
|
|
1222
|
+
const profile = this.deps.activeProfile();
|
|
1223
|
+
const represented = new Set(existing.flatMap((row) => [...sessionLineageIds(row)]));
|
|
1224
|
+
const ids = rows.filter((row) => this.deps.profilesMatch(str(row.profile) || null, profile) && !isClaudeCodeSessionId(str(row.session_id)) && !represented.has(str(row.session_id))).map((row) => str(row.session_id));
|
|
1225
|
+
if (!ids.length)
|
|
1226
|
+
return { rows: existing };
|
|
1227
|
+
const extra = await this.deps.cliSessions(profile, { sourceFilter: null, sessionIds: ids });
|
|
1228
|
+
return { rows: [...new Map([...existing, ...extra.rows].map((row) => [str(row.session_id), row])).values()], storage: extra.storage };
|
|
1229
|
+
}
|
|
1230
|
+
async list(params) {
|
|
721
1231
|
const activeProfile = this.deps.activeProfile();
|
|
1232
|
+
const webuiRows = (await allSessions(this.store, { sidebarMetadataOnly: true }));
|
|
722
1233
|
const wantState = params.showCliSessions || params.showCronSessions || params.showWebhookSessions || params.showKanbanSessions;
|
|
723
1234
|
// Python reads every profile's state.db under all_profiles; this port projects the active profile only.
|
|
724
|
-
const cliRead = wantState ? this.deps.cliSessions(activeProfile, { sourceFilter: params.sourceFilter ?? null }) : undefined;
|
|
725
|
-
const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? this.claudeCodeRows() : [];
|
|
726
|
-
|
|
1235
|
+
const cliRead = wantState ? await this.deps.cliSessions(activeProfile, { sourceFilter: params.sourceFilter ?? null }) : undefined;
|
|
1236
|
+
const claudeCodeRows = params.showCliSessions && params.showClaudeCodeSessions ? (await this.claudeCodeRows()) : [];
|
|
1237
|
+
let cliRows = cliRead ? [...cliRead.rows, ...claudeCodeRows] : undefined;
|
|
727
1238
|
const gatewayIdentity = loadGatewaySessionIdentityMap(join(this.deps.profileHome(activeProfile), 'sessions', 'sessions.json'));
|
|
728
1239
|
const truncatedSources = cliRead?.truncated;
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1240
|
+
let agentStorage = cliRead?.storage;
|
|
1241
|
+
if (cliRows) {
|
|
1242
|
+
const expanded = await this.persistedAliasMetadata(webuiRows, cliRows);
|
|
1243
|
+
cliRows = expanded.rows;
|
|
1244
|
+
if (expanded.storage && (expanded.storage.state === 'corrupt' || agentStorage?.state === 'ok'))
|
|
1245
|
+
agentStorage = expanded.storage;
|
|
1246
|
+
}
|
|
1247
|
+
const lookups = await this.listStateDbLookups(webuiRows, (cliRows ?? []).map((r) => str(r.session_id)));
|
|
1248
|
+
const payload = (await buildSessionListPayload(this.store, { ...params, ...(cliRows ? { cliRows } : {}), ...(truncatedSources ? { truncatedSources } : {}), ...(agentStorage ? { agentStorage } : {}), gatewayIdentity, ...lookups, webuiRows, activeProfile, isolatedProfileMode: this.deps.isolatedProfileMode(), profilesMatch: this.deps.profilesMatch }));
|
|
1249
|
+
return sessionListResponse(payload, this.deps.runtime, this.deps.redactEnabled(), this.deps.now(), await this.workspaceNames([...payload.sessions, ...payload.sidebar_reference_sessions]));
|
|
1250
|
+
}
|
|
1251
|
+
async search(q, opts) {
|
|
733
1252
|
const activeProfile = this.deps.activeProfile();
|
|
734
|
-
let sessions = allSessions(this.store);
|
|
1253
|
+
let sessions = (await allSessions(this.store));
|
|
735
1254
|
if (!opts.allProfiles)
|
|
736
1255
|
sessions = sessions.filter((r) => this.deps.profilesMatch(str(r.profile) || null, activeProfile));
|
|
1256
|
+
const metadata = await this.persistedAliasMetadata(sessions);
|
|
1257
|
+
sessions = reconcileLocalSessionRows(sessions, metadata.rows, this.deps.profilesMatch);
|
|
737
1258
|
sessions = withOwnerLocks(sessions, this.stateDbSources);
|
|
738
1259
|
const redact = this.deps.redactEnabled();
|
|
739
|
-
const workspaceName = this.workspaceNames();
|
|
1260
|
+
const workspaceName = await this.workspaceNames(sessions);
|
|
740
1261
|
const redactRow = (item) => {
|
|
741
1262
|
withSessionWireFlags(item, this.deps.runtime.activeStreamIds);
|
|
742
1263
|
item.workspace_name = workspaceName(item);
|
|
@@ -751,17 +1272,16 @@ export class SessionService {
|
|
|
751
1272
|
if (!query)
|
|
752
1273
|
return { sessions: sessions.map((s) => redactRow({ ...s })), all_profiles: opts.allProfiles, active_profile: activeProfile };
|
|
753
1274
|
const terms = sessionSearchTerms(query);
|
|
1275
|
+
const matches = opts.content ? await this.store.searchMessages(sessions.filter((s) => !sessionSearchMatches(str(s.title), terms)).map((s) => str(s.session_id)), terms, opts.depth) : new Map();
|
|
754
1276
|
const results = [];
|
|
755
1277
|
for (const s of sessions) {
|
|
756
1278
|
if (sessionSearchMatches(str(s.title), terms)) {
|
|
757
1279
|
results.push(redactRow({ ...s, match_type: 'title' }));
|
|
758
1280
|
continue;
|
|
759
1281
|
}
|
|
760
|
-
|
|
761
|
-
continue;
|
|
762
|
-
const hit = this.contentMatch(str(s.session_id), terms, opts.depth);
|
|
1282
|
+
const hit = matches.get(str(s.session_id));
|
|
763
1283
|
if (hit)
|
|
764
|
-
results.push(redactRow({ ...s, ...hit }));
|
|
1284
|
+
results.push(redactRow({ ...s, ...this.contentMatch(hit.text, terms) }));
|
|
765
1285
|
}
|
|
766
1286
|
return { sessions: results, query, count: results.length, all_profiles: opts.allProfiles, active_profile: activeProfile };
|
|
767
1287
|
}
|
|
@@ -770,8 +1290,8 @@ export class SessionService {
|
|
|
770
1290
|
* archived, projection and order), narrowed to `projectId` (`none`: rows without a project), then matched on the
|
|
771
1291
|
* title, then the metadata the row shows (workspace path and name, model, provider, profile, source label), then message content.
|
|
772
1292
|
*/
|
|
773
|
-
sidebarSearch(q, params, opts) {
|
|
774
|
-
let rows = this.list(params).body.sessions;
|
|
1293
|
+
async sidebarSearch(q, params, opts) {
|
|
1294
|
+
let rows = (await this.list(params)).body.sessions;
|
|
775
1295
|
if (opts.projectId === 'none')
|
|
776
1296
|
rows = rows.filter((r) => !r.project_id);
|
|
777
1297
|
else if (opts.projectId)
|
|
@@ -781,46 +1301,35 @@ export class SessionService {
|
|
|
781
1301
|
if (!query)
|
|
782
1302
|
return { ...base, sessions: rows };
|
|
783
1303
|
const terms = sessionSearchTerms(query);
|
|
1304
|
+
const metadataMatch = (row) => ['workspace', 'workspace_name', 'model', 'model_provider', 'profile', 'source_label'].some((k) => sessionSearchMatches(str(row[k]), terms));
|
|
1305
|
+
const matches = opts.content ? await this.store.searchMessages(rows.filter((row) => !sessionSearchMatches(str(row.title), terms) && !metadataMatch(row)).map((row) => str(row.session_id)), terms, opts.depth) : new Map();
|
|
784
1306
|
const results = [];
|
|
785
1307
|
for (const row of rows) {
|
|
786
1308
|
if (sessionSearchMatches(str(row.title), terms))
|
|
787
1309
|
results.push({ ...row, match_type: 'title' });
|
|
788
|
-
else if (
|
|
1310
|
+
else if (metadataMatch(row))
|
|
789
1311
|
results.push({ ...row, match_type: 'metadata' });
|
|
790
|
-
else
|
|
791
|
-
const hit =
|
|
1312
|
+
else {
|
|
1313
|
+
const hit = matches.get(str(row.session_id));
|
|
792
1314
|
if (hit)
|
|
793
|
-
results.push({ ...row, ...hit });
|
|
1315
|
+
results.push({ ...row, ...this.contentMatch(hit.text, terms) });
|
|
794
1316
|
}
|
|
795
1317
|
}
|
|
796
1318
|
return { ...base, sessions: results, query, count: results.length };
|
|
797
1319
|
}
|
|
798
1320
|
/** The first of a stored session's first `depth` messages (0: all) containing every term, with its redacted excerpt. */
|
|
799
|
-
contentMatch(
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
return null;
|
|
806
|
-
}
|
|
807
|
-
for (const m of depth ? sess.messages.slice(0, depth) : sess.messages) {
|
|
808
|
-
const c = sessionSearchMessageText(m);
|
|
809
|
-
if (!sessionSearchMatches(c, terms))
|
|
810
|
-
continue;
|
|
811
|
-
const item = { match_type: 'content' };
|
|
812
|
-
const preview = sessionSearchPreview(c, terms);
|
|
813
|
-
if (preview)
|
|
814
|
-
item.match_preview = redactText(preview, this.deps.redactEnabled());
|
|
815
|
-
return item;
|
|
816
|
-
}
|
|
817
|
-
return null;
|
|
1321
|
+
contentMatch(text, terms) {
|
|
1322
|
+
const item = { match_type: 'content' };
|
|
1323
|
+
const preview = sessionSearchPreview(text, terms);
|
|
1324
|
+
if (preview)
|
|
1325
|
+
item.match_preview = redactText(preview, this.deps.redactEnabled());
|
|
1326
|
+
return item;
|
|
818
1327
|
}
|
|
819
1328
|
// ── status / usage ───────────────────────────────────────────────────────
|
|
820
|
-
status(sid) {
|
|
821
|
-
const s = this.get404(sid, { metadataOnly: true });
|
|
822
|
-
this.clearStaleStreamState(s);
|
|
823
|
-
const full = this.get404(sid);
|
|
1329
|
+
async status(sid) {
|
|
1330
|
+
const s = (await this.get404(sid, { metadataOnly: true }));
|
|
1331
|
+
await this.clearStaleStreamState(s);
|
|
1332
|
+
const full = (await this.get404(sid));
|
|
824
1333
|
const inp = Math.trunc(full.input_tokens) || 0;
|
|
825
1334
|
const out = Math.trunc(full.output_tokens) || 0;
|
|
826
1335
|
const profile = full.profile || 'default';
|
|
@@ -831,37 +1340,37 @@ export class SessionService {
|
|
|
831
1340
|
catch {
|
|
832
1341
|
hermesHome = '';
|
|
833
1342
|
}
|
|
834
|
-
const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, active_turn_origin: origin } = this.wireRow(full);
|
|
1343
|
+
const { is_streaming: streaming, active_stream_id: live, read_only: readOnly, read_only_reason: readOnlyReason, active_turn_origin: origin } = (await this.wireRow(full));
|
|
835
1344
|
return {
|
|
836
1345
|
session_id: full.session_id, title: full.title, model: full.model, profile, hermes_home: hermesHome, workspace: full.workspace, personality: full.personality,
|
|
837
|
-
message_count: full.messages.length, created_at: full.created_at, updated_at: full.updated_at, agent_running: streaming, is_streaming: streaming, active_stream_id: live, active_turn_origin: origin, read_only: readOnly,
|
|
1346
|
+
message_count: full.messages.length, created_at: full.created_at, updated_at: full.updated_at, agent_running: streaming, is_streaming: streaming, active_stream_id: live, active_turn_origin: origin, read_only: readOnly, read_only_reason: readOnlyReason,
|
|
838
1347
|
input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: full.estimated_cost,
|
|
839
1348
|
};
|
|
840
1349
|
}
|
|
841
|
-
usage(sid) {
|
|
842
|
-
const s = this.get404(sid);
|
|
1350
|
+
async usage(sid) {
|
|
1351
|
+
const s = (await this.get404(sid));
|
|
843
1352
|
const inp = Math.trunc(s.input_tokens) || 0;
|
|
844
1353
|
const out = Math.trunc(s.output_tokens) || 0;
|
|
845
1354
|
return { input_tokens: inp, output_tokens: out, total_tokens: inp + out, estimated_cost: s.estimated_cost, model: s.model };
|
|
846
1355
|
}
|
|
847
1356
|
// ── creation ─────────────────────────────────────────────────────────────
|
|
848
|
-
resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
|
|
1357
|
+
async resolveNewSessionWorkspace(body, visiblePrevSessionId, profile) {
|
|
849
1358
|
const candidate = body.workspace;
|
|
850
1359
|
if (!candidate)
|
|
851
1360
|
return null;
|
|
852
1361
|
const value = str(candidate);
|
|
853
1362
|
if (body.workspace_inherited_from_prev_session !== true || !visiblePrevSessionId)
|
|
854
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1363
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
855
1364
|
let previous;
|
|
856
1365
|
try {
|
|
857
|
-
previous = this.store.get(visiblePrevSessionId, { metadataOnly: true });
|
|
1366
|
+
previous = (await this.store.get(visiblePrevSessionId, { metadataOnly: true }));
|
|
858
1367
|
}
|
|
859
1368
|
catch {
|
|
860
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
1369
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
861
1370
|
}
|
|
862
1371
|
if (previous.workspace !== value)
|
|
863
|
-
return this.deps.workspaces.resolveTrusted(value, profile);
|
|
864
|
-
const [workspace] = this.deps.workspaces.resolveImplicitWithRecovery(value, (p) => this.deps.workspaces.profileDefaultWorkspaceForBoot(p), profile);
|
|
1372
|
+
return (await this.deps.workspaces.resolveTrusted(value, profile));
|
|
1373
|
+
const [workspace] = (await this.deps.workspaces.resolveImplicitWithRecovery(value, async (p) => (await this.deps.workspaces.profileDefaultWorkspaceForBoot(p)), profile));
|
|
865
1374
|
return workspace;
|
|
866
1375
|
}
|
|
867
1376
|
/** The server owns the names' normalization (TAL-631): trimmed, blanks dropped, and nothing left is the profile's defaults. */
|
|
@@ -875,25 +1384,25 @@ export class SessionService {
|
|
|
875
1384
|
const names = toolsets.map((t) => t.trim()).filter(Boolean);
|
|
876
1385
|
return names.length ? names : null;
|
|
877
1386
|
}
|
|
878
|
-
create(body, opts = {}) {
|
|
1387
|
+
async create(body, opts = {}) {
|
|
879
1388
|
// Python `new_session`: a body without a profile takes the request's active profile (cookie first).
|
|
880
1389
|
const profile = (typeof body.profile === 'string' && body.profile) || this.deps.activeProfile();
|
|
881
1390
|
let prevSessionId = typeof body.prev_session_id === 'string' && body.prev_session_id ? body.prev_session_id : null;
|
|
882
|
-
if (prevSessionId && !this.sessionIdVisible(prevSessionId))
|
|
1391
|
+
if (prevSessionId && !(await this.sessionIdVisible(prevSessionId)))
|
|
883
1392
|
prevSessionId = null;
|
|
884
1393
|
// Python: leaving a session for a new one flushes the previous session's memory in the background (W6).
|
|
885
1394
|
if (prevSessionId)
|
|
886
|
-
this.deps.commitSessionMemory?.(prevSessionId);
|
|
1395
|
+
await this.deps.commitSessionMemory?.(prevSessionId);
|
|
887
1396
|
let workspace;
|
|
888
1397
|
try {
|
|
889
|
-
workspace = this.resolveNewSessionWorkspace(body, prevSessionId, profile);
|
|
1398
|
+
workspace = (await this.resolveNewSessionWorkspace(body, prevSessionId, profile));
|
|
890
1399
|
}
|
|
891
1400
|
catch (error) {
|
|
892
1401
|
throw new HttpFailure(400, error.message);
|
|
893
1402
|
}
|
|
894
1403
|
const [model, provider] = this.deps.modelStateFromRequest(body.model, body.model_provider, null);
|
|
895
1404
|
const toolsets = this.validateToolsetsShape(body.enabled_toolsets);
|
|
896
|
-
const s = this.store.newSession({ workspace, model, modelProvider: provider, profile, projectId: typeof body.project_id === 'string' && body.project_id ? body.project_id : null, worktree: opts.worktree ?? null, enabledToolsets: toolsets });
|
|
1405
|
+
const s = (await this.store.newSession({ workspace, model, modelProvider: provider, profile, projectId: typeof body.project_id === 'string' && body.project_id ? body.project_id : null, worktree: opts.worktree ?? null, enabledToolsets: toolsets }));
|
|
897
1406
|
if (opts.worktree)
|
|
898
1407
|
this.publish('session_new', s.profile, s.session_id);
|
|
899
1408
|
return s;
|
|
@@ -904,46 +1413,47 @@ export class SessionService {
|
|
|
904
1413
|
const title = nextTitle.trim().slice(0, 80) || 'Untitled';
|
|
905
1414
|
let current;
|
|
906
1415
|
try {
|
|
907
|
-
current = this.store.get(sid);
|
|
1416
|
+
current = (await this.store.get(sid));
|
|
908
1417
|
}
|
|
909
1418
|
catch {
|
|
910
1419
|
throw new HttpFailure(404, 'Session not found');
|
|
911
1420
|
}
|
|
912
|
-
if (this.isReadOnly(current))
|
|
1421
|
+
if ((await this.isReadOnly(current)))
|
|
913
1422
|
throw new HttpFailure(403, `Session ${sid} is read-only`);
|
|
914
|
-
await this.
|
|
1423
|
+
await this.withMutationLock(sid, async () => {
|
|
915
1424
|
current.title = title;
|
|
916
1425
|
markSessionTitleGenerated(current);
|
|
917
|
-
this.store.save(current, { touchUpdatedAt: false });
|
|
1426
|
+
await this.store.save(current, { touchUpdatedAt: false });
|
|
918
1427
|
});
|
|
919
|
-
|
|
1428
|
+
// An explicit regenerate is user-requested, so state.db takes it over a manual name.
|
|
1429
|
+
await this.deps.syncTitle(current, true);
|
|
920
1430
|
this.publish(eventReason, current.profile, current.session_id);
|
|
921
1431
|
return current;
|
|
922
1432
|
}
|
|
923
1433
|
async rename(sid, rawTitle) {
|
|
924
|
-
const s = this.mutationTarget(sid, 'renamed');
|
|
925
|
-
await this.
|
|
1434
|
+
const s = (await this.mutationTarget(sid, 'renamed'));
|
|
1435
|
+
await this.withMutationLock(sid, async () => {
|
|
926
1436
|
applySessionTitleRename(s, rawTitle);
|
|
927
|
-
this.store.save(s);
|
|
1437
|
+
await this.store.save(s);
|
|
928
1438
|
});
|
|
929
|
-
await this.deps.syncTitle(s);
|
|
1439
|
+
await this.deps.syncTitle(s, true);
|
|
930
1440
|
this.publish('session_rename', s.profile, s.session_id);
|
|
931
|
-
return { session: this.wireRow(s) };
|
|
1441
|
+
return { session: (await this.wireRow(s)) };
|
|
932
1442
|
}
|
|
933
1443
|
// The pin quota is counted across sessions, so the count-and-save transaction runs on one process-wide chain;
|
|
934
1444
|
// per-session locks alone would let two pins of different sessions both see the last free slot.
|
|
935
1445
|
pinChain = Promise.resolve();
|
|
936
1446
|
pin(sid, pinRequested) {
|
|
937
|
-
const run = this.pinChain.then(() => this.pinUnserialized(sid, pinRequested));
|
|
1447
|
+
const run = this.pinChain.then(async () => (await this.pinUnserialized(sid, pinRequested)));
|
|
938
1448
|
this.pinChain = run.catch(() => undefined);
|
|
939
1449
|
return run;
|
|
940
1450
|
}
|
|
941
1451
|
async pinUnserialized(sid, pinRequested) {
|
|
942
|
-
this.
|
|
943
|
-
let s = this.get404(sid);
|
|
944
|
-
s = this.store.ensureFull(sid, s);
|
|
1452
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1453
|
+
let s = (await this.get404(sid));
|
|
1454
|
+
s = (await this.store.ensureFull(sid, s));
|
|
945
1455
|
if (pinRequested && !s.pinned) {
|
|
946
|
-
const rows = allSessions(this.store);
|
|
1456
|
+
const rows = (await allSessions(this.store));
|
|
947
1457
|
const byId = new Map(rows.filter((r) => r.session_id).map((r) => [str(r.session_id), r]));
|
|
948
1458
|
byId.set(s.session_id, s.compact());
|
|
949
1459
|
const targetLineage = lineageRootId(s.compact(), byId);
|
|
@@ -953,38 +1463,38 @@ export class SessionService {
|
|
|
953
1463
|
if (pinnedLineages.size >= limit)
|
|
954
1464
|
throw new HttpFailure(400, `Up to ${limit} sessions can be pinned. Unpin one before pinning another.`);
|
|
955
1465
|
}
|
|
956
|
-
await this.
|
|
1466
|
+
await this.withMutationLock(sid, async () => {
|
|
957
1467
|
s.pinned = pinRequested;
|
|
958
|
-
this.store.save(s);
|
|
959
|
-
});
|
|
1468
|
+
await this.store.save(s);
|
|
1469
|
+
}, { allowReadOnly: true });
|
|
960
1470
|
this.publish('session_pin', s.profile, s.session_id);
|
|
961
|
-
return { ok: true, session: this.wireRow(s) };
|
|
1471
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
962
1472
|
}
|
|
963
1473
|
async archive(sid, archived) {
|
|
964
|
-
this.
|
|
965
|
-
let s = this.get404(sid);
|
|
966
|
-
s = this.store.ensureFull(sid, s);
|
|
967
|
-
await this.
|
|
1474
|
+
await this.rejectViewOnly(sid, 'archived');
|
|
1475
|
+
let s = (await this.get404(sid));
|
|
1476
|
+
s = (await this.store.ensureFull(sid, s));
|
|
1477
|
+
await this.withMutationLock(sid, async () => {
|
|
968
1478
|
s.archived = archived;
|
|
969
1479
|
if (archived)
|
|
970
1480
|
s.pinned = false;
|
|
971
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
972
|
-
});
|
|
1481
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1482
|
+
}, { allowReadOnly: true });
|
|
973
1483
|
this.publish('session_archive', s.profile, s.session_id);
|
|
974
|
-
return { ok: true, session: this.wireRow(s), ...worktreeRetainedPayload(s) };
|
|
1484
|
+
return { ok: true, session: (await this.wireRow(s)), ...worktreeRetainedPayload(s) };
|
|
975
1485
|
}
|
|
976
1486
|
async move(sid, targetProjectId) {
|
|
977
|
-
const s = this.mutationTarget(sid, 'moved');
|
|
1487
|
+
const s = (await this.mutationTarget(sid, 'moved'));
|
|
978
1488
|
if (targetProjectId) {
|
|
979
1489
|
const sessionProfile = s.profile || this.deps.activeProfile();
|
|
980
|
-
const target = this.deps.projects.load().find((p) => p.project_id === targetProjectId);
|
|
1490
|
+
const target = (await this.deps.projects.load()).find((p) => p.project_id === targetProjectId);
|
|
981
1491
|
if (!target || !this.deps.profilesMatch(target.profile ?? null, sessionProfile))
|
|
982
1492
|
throw new HttpFailure(404, 'Project not found');
|
|
983
1493
|
}
|
|
984
1494
|
try {
|
|
985
|
-
await this.
|
|
1495
|
+
await this.withMutationLock(sid, async () => {
|
|
986
1496
|
s.project_id = targetProjectId;
|
|
987
|
-
this.store.save(s);
|
|
1497
|
+
await this.store.save(s);
|
|
988
1498
|
}, { timeoutMs: 5000 });
|
|
989
1499
|
}
|
|
990
1500
|
catch (error) {
|
|
@@ -993,67 +1503,108 @@ export class SessionService {
|
|
|
993
1503
|
throw error;
|
|
994
1504
|
}
|
|
995
1505
|
this.publish('session_move', s.profile, s.session_id);
|
|
996
|
-
return { ok: true, session: this.wireRow(s) };
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
const
|
|
1001
|
-
const
|
|
1002
|
-
const
|
|
1003
|
-
|
|
1506
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1507
|
+
}
|
|
1508
|
+
modelConfirmationKey = randomUUID();
|
|
1509
|
+
async confirmModelSwitch(s, model, provider, token, retry = false) {
|
|
1510
|
+
const profile = s.profile ?? this.deps.activeProfile();
|
|
1511
|
+
const home = this.deps.profileHome(profile);
|
|
1512
|
+
const context = contextUsage(s).context_used_tokens;
|
|
1513
|
+
const messages = this.modelContext(s);
|
|
1514
|
+
const before = JSON.stringify([s.model, s.model_provider, context, s.context_length, messages]);
|
|
1515
|
+
let decision;
|
|
1004
1516
|
try {
|
|
1005
|
-
|
|
1517
|
+
const sidecar = this.deps.sidecar?.();
|
|
1518
|
+
if (!sidecar)
|
|
1519
|
+
throw new Error('Agent sidecar is unavailable');
|
|
1520
|
+
decision = await sidecar.call('models.selection_warnings', { profile_home: home, session_id: s.session_id, model, provider, current_model: s.model, current_provider: s.model_provider, context_tokens: context, messages });
|
|
1006
1521
|
}
|
|
1007
1522
|
catch (error) {
|
|
1008
|
-
throw new HttpFailure(
|
|
1523
|
+
throw new HttpFailure(503, `Cannot check model selection warnings: ${error.message}`);
|
|
1009
1524
|
}
|
|
1010
|
-
|
|
1525
|
+
if (before !== JSON.stringify([s.model, s.model_provider, contextUsage(s).context_used_tokens, s.context_length, this.modelContext(s)])) {
|
|
1526
|
+
if (retry)
|
|
1527
|
+
throw new HttpFailure(503, 'The session changed while checking model warnings. Try the selection again.');
|
|
1528
|
+
return this.confirmModelSwitch(s, model, provider, null, true);
|
|
1529
|
+
}
|
|
1530
|
+
if (!decision.warnings.length)
|
|
1531
|
+
return;
|
|
1532
|
+
const signature = JSON.stringify([profile, home, s.session_id, s.model, s.model_provider, model, provider, context, s.context_length, messages, decision]);
|
|
1533
|
+
const confirmToken = createHmac('sha256', this.modelConfirmationKey).update(signature).digest('hex');
|
|
1534
|
+
if (token !== confirmToken)
|
|
1535
|
+
throw new HttpFailure(409, decision.warnings.map((w) => w.message).join('\n\n'), { type: 'model_confirmation_required', warnings: decision.warnings, confirm_token: confirmToken });
|
|
1536
|
+
}
|
|
1537
|
+
async update(sid, body) {
|
|
1538
|
+
let s = (await this.mutationTarget(sid, 'updated'));
|
|
1539
|
+
let oldWs = s.workspace;
|
|
1540
|
+
let newWs = oldWs;
|
|
1541
|
+
await this.withMutationLock(sid, async () => {
|
|
1542
|
+
s = await this.mutationTarget(sid, 'updated');
|
|
1543
|
+
oldWs = s.workspace;
|
|
1544
|
+
try {
|
|
1545
|
+
newWs = await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : s.workspace, s.profile);
|
|
1546
|
+
}
|
|
1547
|
+
catch (error) {
|
|
1548
|
+
throw new HttpFailure(400, error.message);
|
|
1549
|
+
}
|
|
1550
|
+
const oldModel = s.model;
|
|
1551
|
+
const oldProvider = s.model_provider;
|
|
1552
|
+
const hasModel = 'model' in body || 'model_provider' in body;
|
|
1553
|
+
const [model, provider] = hasModel ? this.deps.modelStateFromRequest('model' in body ? body.model : s.model, 'model_provider' in body ? body.model_provider : undefined, s.model_provider) : [s.model, s.model_provider];
|
|
1554
|
+
const nextModel = model ?? s.model;
|
|
1555
|
+
const changed = hasModel && (str(oldModel) !== str(nextModel) || str(oldProvider) !== str(provider));
|
|
1556
|
+
const contextLength = changed ? (this.deps.resolveContextLength ? await this.deps.resolveContextLength(nextModel, provider, s.profile) : this.deps.contextLengthFor(nextModel, provider)) : s.context_length;
|
|
1557
|
+
if (changed)
|
|
1558
|
+
await this.confirmModelSwitch(s, nextModel ?? '', provider, body.confirm_token);
|
|
1559
|
+
this.assertWritable(s);
|
|
1011
1560
|
s.workspace = newWs;
|
|
1012
|
-
if (
|
|
1013
|
-
|
|
1014
|
-
if (model !== null)
|
|
1015
|
-
s.model = model;
|
|
1561
|
+
if (hasModel) {
|
|
1562
|
+
s.model = nextModel;
|
|
1016
1563
|
s.model_provider = provider;
|
|
1017
|
-
if (
|
|
1018
|
-
s.context_length =
|
|
1019
|
-
s
|
|
1020
|
-
|
|
1021
|
-
this.deps.runtime.evictAgent(sid);
|
|
1564
|
+
if (changed) {
|
|
1565
|
+
s.context_length = contextLength;
|
|
1566
|
+
forgetContextFigures(s);
|
|
1567
|
+
await this.deps.runtime.evictAgent(sid);
|
|
1022
1568
|
}
|
|
1023
1569
|
}
|
|
1024
|
-
this.store.save(s);
|
|
1570
|
+
await this.store.save(s);
|
|
1025
1571
|
});
|
|
1026
1572
|
if (str(oldWs) !== str(newWs))
|
|
1027
1573
|
this.deps.runtime.closeTerminal(sid);
|
|
1028
|
-
this.deps.workspaces.setLastWorkspace(newWs, s.profile);
|
|
1029
|
-
return { session: this.publicSession(s) };
|
|
1574
|
+
await this.deps.workspaces.setLastWorkspace(newWs, s.profile);
|
|
1575
|
+
return { session: (await this.publicSession(s)) };
|
|
1030
1576
|
}
|
|
1031
1577
|
async setToolsets(sid, toolsets) {
|
|
1032
|
-
this.
|
|
1578
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1033
1579
|
const cleaned = this.validateToolsetsShape(toolsets);
|
|
1034
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1035
|
-
await this.
|
|
1580
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1581
|
+
await this.withMutationLock(sid, async () => {
|
|
1036
1582
|
s.enabled_toolsets = cleaned;
|
|
1037
|
-
this.store.save(s);
|
|
1583
|
+
await this.store.save(s);
|
|
1038
1584
|
});
|
|
1039
1585
|
return { ok: true, enabled_toolsets: s.enabled_toolsets };
|
|
1040
1586
|
}
|
|
1041
1587
|
/** Python `/api/personality/set` persistence: the name only; the prompt is resolved by the caller. */
|
|
1042
1588
|
async setPersonality(sid, name) {
|
|
1043
|
-
this.
|
|
1044
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1045
|
-
await this.
|
|
1589
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1590
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1591
|
+
await this.withMutationLock(sid, async () => {
|
|
1046
1592
|
s.personality = name;
|
|
1047
|
-
this.store.save(s);
|
|
1593
|
+
await this.store.save(s);
|
|
1048
1594
|
});
|
|
1049
1595
|
return typeof s.personality === 'string' ? s.personality : null;
|
|
1050
1596
|
}
|
|
1051
1597
|
// ── transcript mutations ─────────────────────────────────────────────────
|
|
1052
|
-
|
|
1053
|
-
|
|
1598
|
+
/**
|
|
1599
|
+
* TAL-929: under a `hold`, a cut that discards more than one user turn changes nothing and answers with its counts,
|
|
1600
|
+
* unless `confirmToken` is the token of the rows it would remove now: a transcript that changed since asks again.
|
|
1601
|
+
* Without one (a released App, which asked on the device) the cut proceeds as before.
|
|
1602
|
+
*/
|
|
1603
|
+
async truncate(sid, keepRaw, hold = null) {
|
|
1604
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
1054
1605
|
if (keepRaw === null || keepRaw === undefined)
|
|
1055
1606
|
throw new HttpFailure(400, 'Missing required field(s): keep_count');
|
|
1056
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1607
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1057
1608
|
// Python `int(body["keep_count"])`: a float truncates, a non-integer string is rejected.
|
|
1058
1609
|
let keep;
|
|
1059
1610
|
if (typeof keepRaw === 'number')
|
|
@@ -1066,26 +1617,61 @@ export class SessionService {
|
|
|
1066
1617
|
throw new HttpFailure(400, 'keep_count must be an integer');
|
|
1067
1618
|
if (keep < 0)
|
|
1068
1619
|
throw new HttpFailure(400, 'keep_count must be non-negative');
|
|
1069
|
-
await this.
|
|
1620
|
+
const refused = await this.withMutationLock(sid, async () => {
|
|
1621
|
+
// One state.db read decides and marks the boundary, so a row committed meanwhile is neither counted nor hidden.
|
|
1622
|
+
const read = this.stateDbRead(s);
|
|
1623
|
+
if (hold) {
|
|
1624
|
+
// A failed read cannot say whether a CLI continuation would go too: fail closed. The Agent keeps a state.db for every
|
|
1625
|
+
// profile it has run in, so a missing one is never evidence of a session without state.db rows.
|
|
1626
|
+
if (!read.ok)
|
|
1627
|
+
throw new HttpFailure(503, read.reason ?? 'The session history could not be read. Please try again in a moment.');
|
|
1628
|
+
const held = (await this.heldCut(s, keep, read.rows));
|
|
1629
|
+
if (held && held.confirm_token !== hold.confirmToken)
|
|
1630
|
+
return held;
|
|
1631
|
+
}
|
|
1632
|
+
// A cut inside the state.db continuation keeps the continuation's rows before it: the boundary below hides every
|
|
1633
|
+
// row this read saw, so those rows join the stored transcript and model history first.
|
|
1634
|
+
if (keep > s.messages.length) {
|
|
1635
|
+
if (s.context_messages.length)
|
|
1636
|
+
s.context_messages = this.modelContext(s, read.rows);
|
|
1637
|
+
s.messages = this.mergedTranscript(s, s.messages, read.rows);
|
|
1638
|
+
}
|
|
1070
1639
|
truncateSessionAtKeep(s, keep);
|
|
1071
|
-
this.markStateDbSeen(s,
|
|
1072
|
-
this.store.save(s);
|
|
1640
|
+
this.markStateDbSeen(s, read, { boundary: true });
|
|
1641
|
+
await this.store.save(s);
|
|
1642
|
+
return null;
|
|
1073
1643
|
});
|
|
1074
|
-
|
|
1075
|
-
|
|
1644
|
+
if (refused)
|
|
1645
|
+
return refused;
|
|
1646
|
+
await this.deps.runtime.evictAgent(sid);
|
|
1647
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1648
|
+
}
|
|
1649
|
+
/**
|
|
1650
|
+
* TAL-929: the confirmation a cut at `keep` needs, or null for one that removes at most one user turn. Counted on the rows
|
|
1651
|
+
* the detail shows, state.db continuation included, since `keep` indexes them: the user turns the cut removes, and the
|
|
1652
|
+
* messages after the row it starts at.
|
|
1653
|
+
*/
|
|
1654
|
+
async heldCut(s, keep, stateRows) {
|
|
1655
|
+
const transcript = this.mergedTranscript(s, s.messages, stateRows);
|
|
1656
|
+
const cut = ((await this.publicSession(s, true, transcript)).messages ?? []).map((m, i) => ({ m, i })).filter(({ m, i }) => i >= keep && m._display === 'row');
|
|
1657
|
+
const userTurns = cut.filter(({ m }) => m.role === 'user').length;
|
|
1658
|
+
if (userTurns <= 1)
|
|
1659
|
+
return null;
|
|
1660
|
+
const token = createHash('sha256').update(JSON.stringify(transcript.slice(keep))).digest('hex').slice(0, 32);
|
|
1661
|
+
return { ok: false, confirm_required: true, discard_user_turns: userTurns, discard_messages: cut.filter(({ i }) => i > keep).length, confirm_token: token };
|
|
1076
1662
|
}
|
|
1077
1663
|
// ── manual compression (TAL-255) ────────────────────────────────────────
|
|
1078
1664
|
/**
|
|
1079
1665
|
* Python `_handle_session_compress_start`: validate, then join the session's running job or start a fresh one (a
|
|
1080
1666
|
* finished job is replaced). The stale-runtime refusal comes before a job exists; a running job is joined without it.
|
|
1081
1667
|
*/
|
|
1082
|
-
async startCompression(sid, focusRaw) {
|
|
1668
|
+
async startCompression(sid, focusRaw, opts = {}) {
|
|
1083
1669
|
const running = () => { const job = this.compressionJobs.get(sid); return job?.status === 'running' ? job : undefined; };
|
|
1084
1670
|
// A running job is joined first: once it has installed its short context, the guards below would refuse a new one.
|
|
1085
1671
|
const existing = running();
|
|
1086
1672
|
if (existing)
|
|
1087
1673
|
return existing;
|
|
1088
|
-
const { s } = this.compressionTarget(sid);
|
|
1674
|
+
const { s } = (await this.compressionTarget(sid));
|
|
1089
1675
|
const focusTopic = str(focusRaw).trim().slice(0, 500) || null;
|
|
1090
1676
|
await ensureAgentRuntimeCurrent(this.deps.sidecar?.() ?? null);
|
|
1091
1677
|
// Another start may have admitted a job while the runtime check awaited.
|
|
@@ -1098,8 +1684,8 @@ export class SessionService {
|
|
|
1098
1684
|
throw new HttpFailure(409, `Profile '${str(profile)}' is being deleted.`);
|
|
1099
1685
|
const release = this.deps.profileActivity?.(profile);
|
|
1100
1686
|
const now = this.deps.now();
|
|
1101
|
-
const job = { session_id: sid, focus_topic: focusTopic, status: 'running', started_at: now, updated_at: now, done: Promise.resolve() };
|
|
1102
|
-
job.done = this.compressSession(
|
|
1687
|
+
const job = { session_id: sid, focus_topic: focusTopic, status: 'running', started_at: now, updated_at: now, turns: new Set(), automatic: opts.automatic === true, done: Promise.resolve() };
|
|
1688
|
+
job.done = this.compressSession(job).finally(() => { release?.(); this.compressionJobs.expireLater(job); }).then((result) => { Object.assign(job, { status: 'done', result, updated_at: this.deps.now() }); }, (error) => {
|
|
1103
1689
|
const known = error instanceof HttpFailure;
|
|
1104
1690
|
if (!known)
|
|
1105
1691
|
this.deps.log(`[webui] Manual compression worker failed for session ${sid}: ${error.message}`);
|
|
@@ -1108,22 +1694,257 @@ export class SessionService {
|
|
|
1108
1694
|
this.compressionJobs.set(job);
|
|
1109
1695
|
return job;
|
|
1110
1696
|
}
|
|
1697
|
+
/**
|
|
1698
|
+
* TAL-1005: compact the session in its joinable compression job before the next turn reads its history. A running job
|
|
1699
|
+
* is always joined; a new one starts when the session is `due` (`compactionDue`, read from the store when omitted).
|
|
1700
|
+
* `turn` is the waiting turn's stream: the job leaves its rows alone. `onWait` runs once a job is being waited on; an abort of `signal` (the turn's Stop) ends the wait but not the
|
|
1701
|
+
* job. True when the job installed a compressed context. Never throws: a failed job leaves the Agent's own preflight.
|
|
1702
|
+
*/
|
|
1703
|
+
async ensureCompacted(sid, opts = {}) {
|
|
1704
|
+
const { turn, signal } = opts;
|
|
1705
|
+
if (turn)
|
|
1706
|
+
this.compactionWaiters.add(turn);
|
|
1707
|
+
try {
|
|
1708
|
+
let job = this.compressionJobs.get(sid);
|
|
1709
|
+
if (job?.status !== 'running')
|
|
1710
|
+
job = (opts.due ?? (await this.storedCompactionDue(sid))) ? await this.startCompression(sid, null, { automatic: true }) : undefined;
|
|
1711
|
+
if (!job || signal?.aborted)
|
|
1712
|
+
return false;
|
|
1713
|
+
if (turn)
|
|
1714
|
+
job.turns.add(turn);
|
|
1715
|
+
await opts.onWait?.();
|
|
1716
|
+
const aborted = new Promise((resolve) => { signal?.addEventListener('abort', () => { resolve(); }, { once: true }); });
|
|
1717
|
+
await Promise.race([job.done, aborted]);
|
|
1718
|
+
return !signal?.aborted && job.status === 'done';
|
|
1719
|
+
}
|
|
1720
|
+
catch (error) {
|
|
1721
|
+
this.deps.log(`[webui] compaction before the next turn of ${sid} did not run: ${error.message}`);
|
|
1722
|
+
return false;
|
|
1723
|
+
}
|
|
1724
|
+
finally {
|
|
1725
|
+
if (turn)
|
|
1726
|
+
this.compactionWaiters.delete(turn);
|
|
1727
|
+
}
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* TAL-1005: measure the model context again when it holds a post-compression estimate the rows after it may have
|
|
1731
|
+
* outgrown (a waiting turn's prompt, a stopped or failed turn's work). Skipped while a turn owns the session, which
|
|
1732
|
+
* settles its own figures, or once the estimate changed meanwhile. True when it stored a new estimate.
|
|
1733
|
+
*/
|
|
1734
|
+
async remeasureContext(sid) {
|
|
1735
|
+
const sidecar = this.deps.sidecar?.();
|
|
1736
|
+
let s;
|
|
1737
|
+
try {
|
|
1738
|
+
s = (await this.store.get(sid));
|
|
1739
|
+
}
|
|
1740
|
+
catch {
|
|
1741
|
+
return false;
|
|
1742
|
+
}
|
|
1743
|
+
const before = s.post_compression_context_tokens_estimate;
|
|
1744
|
+
if (before === null || s.active_stream_id || !sidecar)
|
|
1745
|
+
return false;
|
|
1746
|
+
let tokens;
|
|
1747
|
+
try {
|
|
1748
|
+
tokens = (await sidecar.call('models.estimate_tokens', { messages: this.modelContext(s) })).tokens;
|
|
1749
|
+
}
|
|
1750
|
+
catch {
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
return this.store.withLock(sid, async () => {
|
|
1754
|
+
let live;
|
|
1755
|
+
try {
|
|
1756
|
+
live = (await this.store.get(sid));
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
return false;
|
|
1760
|
+
}
|
|
1761
|
+
if (live.active_stream_id || live.post_compression_context_tokens_estimate !== before || (await this.isReadOnly(live)))
|
|
1762
|
+
return false;
|
|
1763
|
+
live.post_compression_context_tokens_estimate = Math.max(1, tokens);
|
|
1764
|
+
await this.store.save(live, { touchUpdatedAt: false });
|
|
1765
|
+
return true;
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
async storedCompactionDue(sid) {
|
|
1769
|
+
try {
|
|
1770
|
+
return this.compactionDue((await this.store.get(sid, { metadataOnly: true })));
|
|
1771
|
+
}
|
|
1772
|
+
catch {
|
|
1773
|
+
return false;
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
/** Whether the session's context (the post-compression estimate, else the last prompt) reached the next turn's threshold. */
|
|
1777
|
+
compactionDue(s) {
|
|
1778
|
+
const positive = (v) => { const n = Math.trunc(Number(v ?? 0)); return Number.isFinite(n) && n > 0 ? n : null; };
|
|
1779
|
+
const used = positive(s.post_compression_context_tokens_estimate) ?? positive(s.last_prompt_tokens);
|
|
1780
|
+
const threshold = positive(s.next_turn_threshold_tokens) ?? positive(s.threshold_tokens);
|
|
1781
|
+
return used !== null && threshold !== null && used >= threshold;
|
|
1782
|
+
}
|
|
1111
1783
|
/** The session a compression may run on now, and the model history it compresses: the context a turn would send, so a
|
|
1112
|
-
* repeat compression builds on the previous summary instead of re-reading the whole display transcript.
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1784
|
+
* repeat compression builds on the previous summary instead of re-reading the whole display transcript. `context` is
|
|
1785
|
+
* that history unsanitized and `stateRows` the state.db read it came from. */
|
|
1786
|
+
async compressionTarget(sid) {
|
|
1787
|
+
await this.rejectViewOnly(sid, 'compressed');
|
|
1788
|
+
const s = (await this.mutationTarget(sid, 'compressed'));
|
|
1789
|
+
const stateRows = this.stateDbRows(s);
|
|
1790
|
+
const context = this.compressionContext(s, stateRows);
|
|
1791
|
+
return { s, history: sanitizeMessagesForApi(context), context, stateRows };
|
|
1792
|
+
}
|
|
1793
|
+
/**
|
|
1794
|
+
* The loaded session's own compression guards: no running turn and enough model history. TAL-1005: a turn waiting on
|
|
1795
|
+
* the compression has not read its history yet, so it does not block; the context leaves out its rows, as it does.
|
|
1796
|
+
*/
|
|
1797
|
+
compressionContext(s, stateRows) {
|
|
1798
|
+
const turn = s.active_stream_id;
|
|
1799
|
+
if (turn && !this.compactionWaiters.has(turn))
|
|
1117
1800
|
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1118
|
-
const
|
|
1119
|
-
if (
|
|
1801
|
+
const context = this.modelContext(s, stateRows).filter((m) => !turn || m._turn_id !== turn);
|
|
1802
|
+
if (sanitizeMessagesForApi(context).length < 4)
|
|
1120
1803
|
throw new HttpFailure(400, 'Not enough conversation to compress (need at least 4 messages).');
|
|
1121
|
-
return
|
|
1804
|
+
return context;
|
|
1805
|
+
}
|
|
1806
|
+
/** TAL-614: whether a manual compression would start now: `compressionTarget`'s gates, with `readOnly` the wire flag. */
|
|
1807
|
+
canCompress(s, readOnly, stateRows) {
|
|
1808
|
+
if (readOnly || isClaudeCodeSessionId(s.session_id))
|
|
1809
|
+
return false;
|
|
1810
|
+
try {
|
|
1811
|
+
this.compressionContext(s, stateRows);
|
|
1812
|
+
return true;
|
|
1813
|
+
}
|
|
1814
|
+
catch (error) {
|
|
1815
|
+
if (error instanceof HttpFailure)
|
|
1816
|
+
return false;
|
|
1817
|
+
throw error;
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/** TAL-258: the session's WebUI record (absent for a CLI or gateway session) and the profile whose state.db holds its rows. */
|
|
1821
|
+
async handoffTarget(sid) {
|
|
1822
|
+
sid = sid.trim();
|
|
1823
|
+
let local = null;
|
|
1824
|
+
try {
|
|
1825
|
+
local = (await this.store.get(sid));
|
|
1826
|
+
}
|
|
1827
|
+
catch { /* a foreign session without a WebUI record */ }
|
|
1828
|
+
const profile = local?.profile ?? this.deps.activeProfile();
|
|
1829
|
+
return { local, profile, dbPath: this.stateDbPath(profile) };
|
|
1830
|
+
}
|
|
1831
|
+
/** Python `_handle_conversation_rounds` (TAL-258): the session's rounds and whether the handoff dock is due. */
|
|
1832
|
+
async conversationRounds(sid, since) {
|
|
1833
|
+
const rounds = countConversationRounds((await this.handoffTarget(sid)).dbPath, sid, since);
|
|
1834
|
+
return { ok: true, rounds, threshold: CONVERSATION_ROUND_THRESHOLD, should_show: rounds >= CONVERSATION_ROUND_THRESHOLD };
|
|
1835
|
+
}
|
|
1836
|
+
/**
|
|
1837
|
+
* Python `_handle_handoff_summary` (TAL-258): the session's main model summarizes its last 50 state.db messages after
|
|
1838
|
+
* `since`, and the summary is appended as a display-only `handoff_summary` tool row. A failed or cut-off answer falls
|
|
1839
|
+
* back to a deterministic local summary rather than an error.
|
|
1840
|
+
*/
|
|
1841
|
+
async handoffSummary(sid, since) {
|
|
1842
|
+
sid = (await this.rejectViewOnly(sid, 'summarized'));
|
|
1843
|
+
const { local, profile, dbPath } = (await this.handoffTarget(sid));
|
|
1844
|
+
const rounds = countConversationRounds(dbPath, sid, since);
|
|
1845
|
+
if (rounds < CONVERSATION_ROUND_THRESHOLD)
|
|
1846
|
+
throw new HttpFailure(400, 'Not enough conversation rounds to generate a summary.');
|
|
1847
|
+
// A running turn settles the transcript it started from, which would drop a card appended meanwhile.
|
|
1848
|
+
if (local?.active_stream_id)
|
|
1849
|
+
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1850
|
+
const all = stateDbSessionMessages(dbPath, sid, { stitch: true });
|
|
1851
|
+
const messages = (since === null ? all : all.filter((m) => { const ts = stateDbTimestampSeconds(m.timestamp); return ts !== null && ts > since; })).slice(-50);
|
|
1852
|
+
if (messages.length < 2)
|
|
1853
|
+
throw new HttpFailure(400, 'Not enough messages to summarize.');
|
|
1854
|
+
const sidecar = this.deps.sidecar?.() ?? null;
|
|
1855
|
+
await ensureAgentRuntimeCurrent(sidecar);
|
|
1856
|
+
const meta = local ? null : (await this.lookupCliMeta(sid));
|
|
1857
|
+
const generated = await this.generateHandoffSummary(sidecar, profile, local, meta, messages);
|
|
1858
|
+
const pick = (row, ...keys) => { for (const key of keys) {
|
|
1859
|
+
const v = str(row?.[key]).trim();
|
|
1860
|
+
if (v)
|
|
1861
|
+
return v;
|
|
1862
|
+
} return ''; };
|
|
1863
|
+
// Python `get_cli_sessions` never listed WebUI's own state.db rows, so they name no channel.
|
|
1864
|
+
const cliRow = meta ?? (await this.lookupCliMeta(sid));
|
|
1865
|
+
const channel = pick(local, 'source_label', 'raw_source', 'source_tag', 'session_source') || (str(cliRow?.raw_source || cliRow?.source).trim().toLowerCase() === 'webui' ? '' : pick(cliRow, 'source_label', 'raw_source', 'source_tag', 'source')) || null;
|
|
1866
|
+
await this.persistHandoffMarker(sid, profile, handoffMarker(sid, generated.summary, channel, rounds, generated.fallback, this.deps.now()));
|
|
1867
|
+
return { ok: true, summary: generated.summary, message_count: messages.length, rounds, fallback: generated.fallback, ...(generated.warning ? { warning: generated.warning } : {}) };
|
|
1868
|
+
}
|
|
1869
|
+
/** The main model's summary at 700 tokens, once more at 1400 when it was cut off, else the local fallback. */
|
|
1870
|
+
async generateHandoffSummary(sidecar, profile, local, meta, messages) {
|
|
1871
|
+
const fallback = (warning) => ({ summary: fallbackHandoffSummary(messages), fallback: true, ...(warning ? { warning: `Summary generation used local fallback: ${warning}` } : {}) });
|
|
1872
|
+
if (!sidecar)
|
|
1873
|
+
return fallback('the Agent sidecar is not running');
|
|
1874
|
+
const [model, provider] = local ? this.deps.modelStateFromRequest(local.model, local.model_provider, local.model_provider) : [str(meta?.model).trim() || null, null];
|
|
1875
|
+
const prompt = [{ role: 'system', content: HANDOFF_SYSTEM_PROMPT }, { role: 'user', content: `Conversation transcript:\n${handoffTranscript(messages)}` }];
|
|
1876
|
+
const ask = (maxTokens) => sidecar.call('aux.complete', { profile_home: this.deps.profileHome(profile), task: 'handoff_summary', messages: prompt, model: model ?? '', provider, max_tokens: maxTokens, temperature: 0.2 }, { timeoutMs: 60_000 });
|
|
1877
|
+
try {
|
|
1878
|
+
let result = await ask(700);
|
|
1879
|
+
if (completionIncomplete(result))
|
|
1880
|
+
result = await ask(1400);
|
|
1881
|
+
return completionIncomplete(result) ? fallback() : { summary: result.text, fallback: false };
|
|
1882
|
+
}
|
|
1883
|
+
catch (error) {
|
|
1884
|
+
if (error instanceof SidecarError && error.condition === 'agent_runtime_stale')
|
|
1885
|
+
throw staleRuntimeFailure(error);
|
|
1886
|
+
// No credential is not a failure: the predecessor answered with the local summary and no warning.
|
|
1887
|
+
if (error instanceof SidecarError && error.condition === 'credential_missing')
|
|
1888
|
+
return fallback();
|
|
1889
|
+
return fallback(sanitizePaths(error));
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
/**
|
|
1893
|
+
* Python `_persist_handoff_summary`: a messaging session gets the marker in state.db and its WebUI record; any other
|
|
1894
|
+
* session in its WebUI record, or in state.db when it has none. Each store skips a marker its tail already holds, and
|
|
1895
|
+
* the session lock keeps two summaries from both passing that check. A card neither store saved answers 503.
|
|
1896
|
+
*/
|
|
1897
|
+
async persistHandoffMarker(sid, profile, marker) {
|
|
1898
|
+
const card = messageHandoffPayload(marker);
|
|
1899
|
+
const toStateDb = async () => {
|
|
1900
|
+
await this.rejectViewOnly(sid, 'summarized');
|
|
1901
|
+
if (sameHandoff(handoffPayload(stateDbTailToolContent(this.stateDbPath(profile), sid)), card))
|
|
1902
|
+
return true;
|
|
1903
|
+
const sidecar = this.deps.sidecar?.();
|
|
1904
|
+
if (!sidecar)
|
|
1905
|
+
return false;
|
|
1906
|
+
try {
|
|
1907
|
+
return (await sidecar.call('state_db.append_message', { profile_home: this.deps.profileHome(profile), session_id: sid, role: 'tool', content: str(marker.content), tool_name: 'handoff_summary', timestamp: Number(marker.timestamp) })).ok;
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
return false;
|
|
1911
|
+
}
|
|
1912
|
+
};
|
|
1913
|
+
await this.withMutationLock(sid, async () => {
|
|
1914
|
+
let live = null;
|
|
1915
|
+
try {
|
|
1916
|
+
live = (await this.store.get(sid));
|
|
1917
|
+
}
|
|
1918
|
+
catch { /* no WebUI record */ }
|
|
1919
|
+
// A turn that started while the summary was generated would drop the card when it settles.
|
|
1920
|
+
if (live?.active_stream_id)
|
|
1921
|
+
throw new HttpFailure(409, 'Session is still streaming; wait for the current turn to finish.');
|
|
1922
|
+
const toLocal = async () => {
|
|
1923
|
+
await this.rejectViewOnly(sid, 'summarized');
|
|
1924
|
+
if (!live)
|
|
1925
|
+
return false;
|
|
1926
|
+
if (sameHandoff(messageHandoffPayload(live.messages.at(-1)), card))
|
|
1927
|
+
return true;
|
|
1928
|
+
live.messages.push(marker);
|
|
1929
|
+
await this.store.save(live);
|
|
1930
|
+
return true;
|
|
1931
|
+
};
|
|
1932
|
+
const messaging = (await this.isMessagingSession(sid));
|
|
1933
|
+
const saved = messaging ? [await toStateDb(), (await toLocal())].some(Boolean) : (await toLocal()) || await toStateDb();
|
|
1934
|
+
// The caller may switch away on success, so a card saved nowhere is an error, not a summary.
|
|
1935
|
+
if (!saved)
|
|
1936
|
+
throw new HttpFailure(503, 'The handoff summary could not be saved; please retry.');
|
|
1937
|
+
}, { allowReadOnly: true });
|
|
1938
|
+
}
|
|
1939
|
+
/** The same live presentation for session detail and journal snapshot recovery. */
|
|
1940
|
+
currentRunStatus(sid, streamId) {
|
|
1941
|
+
const job = this.compressionJobs.get(sid);
|
|
1942
|
+
return job?.status === 'running' ? { phase: 'compressing' } : streamId ? (this.deps.runtime.runStatus?.(streamId) ?? null) : null;
|
|
1122
1943
|
}
|
|
1123
1944
|
/** The session's compression job; a deleted session's finished result is dropped rather than served. */
|
|
1124
|
-
compressionJob(sid) {
|
|
1945
|
+
async compressionJob(sid) {
|
|
1125
1946
|
try {
|
|
1126
|
-
this.store.get(sid, { metadataOnly: true });
|
|
1947
|
+
(await this.store.get(sid, { metadataOnly: true }));
|
|
1127
1948
|
}
|
|
1128
1949
|
catch {
|
|
1129
1950
|
this.compressionJobs.delete(sid);
|
|
@@ -1133,18 +1954,22 @@ export class SessionService {
|
|
|
1133
1954
|
}
|
|
1134
1955
|
/**
|
|
1135
1956
|
* Python `_handle_session_compress`: compress the sanitized transcript in the sidecar outside the lock, then under the
|
|
1136
|
-
* session lock refuse a result the session moved past (stream
|
|
1957
|
+
* session lock refuse a result the session moved past (another stream, or a transcript changed) and install it as the
|
|
1137
1958
|
* model context with the manual anchor and the #4836 boundary that keeps state.db from replaying compressed rows.
|
|
1138
|
-
* The guards run again first: a stream may have started while the caller awaited the runtime check.
|
|
1959
|
+
* The guards run again first: a stream may have started while the caller awaited the runtime check. TAL-1005: rows
|
|
1960
|
+
* the job's waiting turns added meanwhile (a prompt, a Stop) stay after the compressed context.
|
|
1139
1961
|
*/
|
|
1140
|
-
async compressSession(
|
|
1141
|
-
const {
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1962
|
+
async compressSession(job) {
|
|
1963
|
+
const { session_id: sid, focus_topic: focusTopic } = job;
|
|
1964
|
+
const { s, history, context, stateRows: seenRows } = (await this.compressionTarget(sid));
|
|
1965
|
+
const ownRows = (m) => !s.active_stream_id || m._turn_id !== s.active_stream_id;
|
|
1966
|
+
// The display transcript and the model context must both still start where the compression found them, followed
|
|
1967
|
+
// only by the waiting turns' rows; the context is the exact history sent, so a state.db row that lands after this
|
|
1968
|
+
// read fails the commit instead of being dropped.
|
|
1969
|
+
// A turn still waiting to join (it started its own start meanwhile) joins this job, so it counts as one of its turns.
|
|
1970
|
+
const waited = (id) => job.turns.has(str(id)) || this.compactionWaiters.has(str(id));
|
|
1971
|
+
const sameRows = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
1972
|
+
const turnTail = (rows, base) => (sameRows(rows.slice(0, base.length), base) && rows.slice(base.length).every((m) => waited(m._turn_id)) ? rows.slice(base.length) : null);
|
|
1148
1973
|
const sidecar = this.deps.sidecar?.();
|
|
1149
1974
|
if (!sidecar)
|
|
1150
1975
|
throw new HttpFailure(400, 'Compression failed: the Agent sidecar is not running');
|
|
@@ -1168,44 +1993,51 @@ export class SessionService {
|
|
|
1168
1993
|
throw new HttpFailure(result.status === 'lock_skipped' ? 409 : 400, result.message ?? 'Nothing to compress yet.');
|
|
1169
1994
|
const summary = result.summary ?? {};
|
|
1170
1995
|
let committed = false;
|
|
1996
|
+
let keptTurnRows = false;
|
|
1171
1997
|
try {
|
|
1172
|
-
const current = await this.
|
|
1998
|
+
const current = await this.withMutationLock(sid, async () => {
|
|
1173
1999
|
let live;
|
|
1174
2000
|
try {
|
|
1175
|
-
live = this.store.get(sid);
|
|
2001
|
+
live = (await this.store.get(sid));
|
|
1176
2002
|
}
|
|
1177
2003
|
catch {
|
|
1178
2004
|
throw new HttpFailure(404, 'Session not found');
|
|
1179
2005
|
}
|
|
1180
|
-
|
|
2006
|
+
const turn = live.active_stream_id;
|
|
2007
|
+
if (turn && !waited(turn))
|
|
1181
2008
|
throw new HttpFailure(409, 'Session stream state changed during compression; please retry.');
|
|
1182
2009
|
// One state.db read serves the check, the kept display rows, and the boundary.
|
|
1183
2010
|
const read = this.stateDbRead(live);
|
|
1184
2011
|
const stateRows = read.rows;
|
|
1185
|
-
|
|
2012
|
+
const tail = turnTail(this.modelContext(live, stateRows).filter((m) => !turn || m._turn_id !== turn), context);
|
|
2013
|
+
if (!tail || !turnTail(live.messages, s.messages))
|
|
1186
2014
|
throw new HttpFailure(409, 'Session was modified during compression; please retry.');
|
|
1187
2015
|
// Rows the sidecar returns without a timestamp take the newest one this compression saw, never the clock: the
|
|
1188
2016
|
// boundary then hides no state.db row the append-only merge would still show (it already drops rows at or
|
|
1189
2017
|
// before the newest local row), so a CLI row committed after the read follows the compressed context.
|
|
1190
|
-
const seen = [...
|
|
2018
|
+
const seen = [...s.messages, ...s.context_messages, ...seenRows].filter(ownRows).map((m) => Number(m.timestamp)).filter(Number.isFinite);
|
|
1191
2019
|
const stamp = seen.length ? Math.max(...seen) : this.deps.now();
|
|
1192
2020
|
const compressed = copyJson(result.messages);
|
|
1193
2021
|
for (const m of compressed)
|
|
1194
2022
|
m.timestamp ??= stamp;
|
|
1195
|
-
live.context_messages = compressed;
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
2023
|
+
live.context_messages = [...compressed, ...copyJson(tail)];
|
|
2024
|
+
keptTurnRows = tail.length > 0;
|
|
2025
|
+
if (!turn) {
|
|
2026
|
+
live.pending_user_message = null;
|
|
2027
|
+
live.pending_attachments = [];
|
|
2028
|
+
live.pending_started_at = null;
|
|
2029
|
+
live.pending_user_source = null;
|
|
2030
|
+
}
|
|
1201
2031
|
// The compressed history included any state.db-only continuation; the new boundary would hide those rows from
|
|
1202
2032
|
// the display, so the transcript keeps them before it is applied (and the anchor counts them).
|
|
1203
2033
|
const display = this.mergedTranscript(live, live.messages, stateRows);
|
|
1204
2034
|
if (display.length > live.messages.length)
|
|
1205
2035
|
live.messages = copyJson(display);
|
|
2036
|
+
// The anchor is the newest row the compression covered: never a waiting turn's own.
|
|
1206
2037
|
const visible = visibleMessagesForAnchor(live.messages);
|
|
1207
|
-
|
|
1208
|
-
live.
|
|
2038
|
+
const anchor = visible.findLast((m) => !waited(m._turn_id));
|
|
2039
|
+
live.compression_anchor_visible_idx = anchor ? visible.lastIndexOf(anchor) : null;
|
|
2040
|
+
live.compression_anchor_message_key = anchorMessageKey(anchor);
|
|
1209
2041
|
live.compression_anchor_summary = anchorSummary(summary, compressed);
|
|
1210
2042
|
live.compression_anchor_mode = 'manual';
|
|
1211
2043
|
// #4836: an intentional-shrink boundary, so append-only state.db reconciliation does not replay compressed rows.
|
|
@@ -1215,15 +2047,22 @@ export class SessionService {
|
|
|
1215
2047
|
this.markStateDbSeen(live, read, { boundary: true });
|
|
1216
2048
|
live.last_prompt_tokens = result.after_tokens;
|
|
1217
2049
|
live.post_compression_context_tokens_estimate = result.after_tokens;
|
|
1218
|
-
|
|
2050
|
+
// An automatic job is maintenance, not activity: it leaves the session's place in the list alone.
|
|
2051
|
+
await this.store.save(live, { touchUpdatedAt: !job.automatic });
|
|
1219
2052
|
// A backup from before the compression would restore the uncompressed context.
|
|
1220
2053
|
try {
|
|
1221
|
-
|
|
2054
|
+
await this.store.shrinkBackup.discard(sid);
|
|
1222
2055
|
}
|
|
1223
2056
|
catch { /* ignore */ }
|
|
1224
2057
|
return live;
|
|
1225
2058
|
});
|
|
1226
2059
|
committed = true;
|
|
2060
|
+
// The estimate covers the compressed rows only; a stopped turn's kept prompt is measured with them (a waiting turn
|
|
2061
|
+
// settles its own figures instead).
|
|
2062
|
+
if (keptTurnRows)
|
|
2063
|
+
await this.remeasureContext(sid);
|
|
2064
|
+
// Open chats refetch the session for its new context figures; the list order stays (no recency change).
|
|
2065
|
+
this.publish('session_compress', current.profile, sid);
|
|
1227
2066
|
// The cached turn agent still carries the uncompressed state; the next turn builds a fresh one (gateway parity).
|
|
1228
2067
|
// Awaited, so a turn sent right after the reply cannot reuse the old agent.
|
|
1229
2068
|
try {
|
|
@@ -1232,31 +2071,57 @@ export class SessionService {
|
|
|
1232
2071
|
catch (error) {
|
|
1233
2072
|
this.deps.log(`[webui] agent eviction after compression of ${sid} failed: ${error.message}`);
|
|
1234
2073
|
}
|
|
1235
|
-
return { ok: true, session: this.publicSession(current), summary, focus_topic: focusTopic };
|
|
2074
|
+
return { ok: true, session: (await this.publicSession(current)), summary, focus_topic: focusTopic };
|
|
1236
2075
|
}
|
|
1237
2076
|
finally {
|
|
1238
2077
|
// Second phase: the Agent's context-engine notification fires only for a result the session now holds.
|
|
1239
2078
|
if (result.commit_token) {
|
|
2079
|
+
const readOnly = committed ? (await this.readOnlyReason(s)) : null;
|
|
2080
|
+
if (readOnly)
|
|
2081
|
+
committed = false;
|
|
1240
2082
|
try {
|
|
1241
2083
|
await sidecar.call('chat.compress_finalize', { commit_token: result.commit_token, committed });
|
|
1242
2084
|
}
|
|
1243
2085
|
catch (error) {
|
|
1244
2086
|
this.deps.log(`[webui] compression finalize for ${sid} failed: ${error.message}`);
|
|
1245
2087
|
}
|
|
2088
|
+
if (readOnly)
|
|
2089
|
+
throw new HttpFailure(403, readOnly);
|
|
1246
2090
|
}
|
|
1247
2091
|
}
|
|
1248
2092
|
}
|
|
2093
|
+
/** Clear and delete share the profile-scoped, best-effort Agent session boundary. */
|
|
2094
|
+
async finalizeSession(sid, profile, reason) {
|
|
2095
|
+
const sidecar = this.deps.sidecar?.();
|
|
2096
|
+
if (!sidecar || this.deps.profileDeleting?.(profile))
|
|
2097
|
+
return [];
|
|
2098
|
+
const release = this.deps.profileActivity?.(profile);
|
|
2099
|
+
try {
|
|
2100
|
+
return (await sidecar.call('chat.finalize_session', {
|
|
2101
|
+
session_id: sid, profile_home: this.deps.profileHome(profile ?? this.deps.activeProfile()), reason,
|
|
2102
|
+
}, { timeoutMs: 12_000 })).messages;
|
|
2103
|
+
}
|
|
2104
|
+
catch (error) {
|
|
2105
|
+
this.deps.log(`[webui] session finalize (${reason}) failed: ${error.message}`);
|
|
2106
|
+
return [];
|
|
2107
|
+
}
|
|
2108
|
+
finally {
|
|
2109
|
+
release?.();
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
1249
2112
|
async clear(sid) {
|
|
1250
|
-
this.
|
|
1251
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
1252
|
-
await this.
|
|
2113
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2114
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
2115
|
+
await this.withMutationLock(sid, async () => {
|
|
2116
|
+
const messages = await this.finalizeSession(sid, s.profile, 'clear');
|
|
1253
2117
|
const hadMessages = s.messages.length > 0;
|
|
1254
2118
|
truncateSessionAtKeep(s, 0);
|
|
2119
|
+
s.messages = messages.map((content) => ({ role: 'local_notice', content, _notice_kind: 'session_finalize', timestamp: this.deps.now() }));
|
|
1255
2120
|
s.tool_calls = [];
|
|
1256
2121
|
if (s.parent_session_id) {
|
|
1257
2122
|
let parentIsSnapshot = false;
|
|
1258
2123
|
try {
|
|
1259
|
-
parentIsSnapshot = this.store.get(s.parent_session_id, { metadataOnly: true }).pre_compression_snapshot;
|
|
2124
|
+
parentIsSnapshot = (await this.store.get(s.parent_session_id, { metadataOnly: true })).pre_compression_snapshot;
|
|
1260
2125
|
}
|
|
1261
2126
|
catch {
|
|
1262
2127
|
parentIsSnapshot = false;
|
|
@@ -1275,21 +2140,21 @@ export class SessionService {
|
|
|
1275
2140
|
s.clear_generation = hadMessages ? randomUUID().replace(/-/g, '') : null;
|
|
1276
2141
|
applySessionTitleRename(s, 'Untitled');
|
|
1277
2142
|
this.markStateDbSeen(s, undefined, { boundary: true });
|
|
1278
|
-
this.store.save(s);
|
|
2143
|
+
await this.store.save(s);
|
|
1279
2144
|
if (hadMessages) {
|
|
1280
2145
|
try {
|
|
1281
|
-
|
|
2146
|
+
await this.store.shrinkBackup.discard(sid);
|
|
1282
2147
|
}
|
|
1283
2148
|
catch { /* ignore */ }
|
|
1284
2149
|
}
|
|
1285
2150
|
});
|
|
1286
|
-
this.deps.runtime.evictAgent(sid, true);
|
|
1287
|
-
return { ok: true, session: this.wireRow(s) };
|
|
2151
|
+
await this.deps.runtime.evictAgent(sid, true);
|
|
2152
|
+
return { ok: true, session: (await this.wireRow(s)) };
|
|
1288
2153
|
}
|
|
1289
2154
|
async retry(sid) {
|
|
1290
|
-
this.
|
|
1291
|
-
return this.
|
|
1292
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
2155
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2156
|
+
return this.withMutationLock(sid, async () => {
|
|
2157
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1293
2158
|
const history = s.messages;
|
|
1294
2159
|
const lastUser = findLastUserIndex(history);
|
|
1295
2160
|
if (lastUser === null)
|
|
@@ -1306,14 +2171,14 @@ export class SessionService {
|
|
|
1306
2171
|
const removed = history.length - lastUser;
|
|
1307
2172
|
shrinkTo(s, lastUser);
|
|
1308
2173
|
this.markStateDbSeen(s, undefined, { boundary: true });
|
|
1309
|
-
this.store.save(s);
|
|
2174
|
+
await this.store.save(s);
|
|
1310
2175
|
return { ok: true, last_user_text: lastUserText, last_user_prompt: lastUserPrompt, last_user_attachments: lastUserAttachments, removed_count: removed };
|
|
1311
2176
|
});
|
|
1312
2177
|
}
|
|
1313
2178
|
async undo(sid) {
|
|
1314
|
-
this.
|
|
1315
|
-
return this.
|
|
1316
|
-
const s = this.mutationTarget(sid, 'modified');
|
|
2179
|
+
await this.rejectViewOnly(sid, 'modified');
|
|
2180
|
+
return this.withMutationLock(sid, async () => {
|
|
2181
|
+
const s = (await this.mutationTarget(sid, 'modified'));
|
|
1317
2182
|
const history = s.messages;
|
|
1318
2183
|
const lastUser = findLastUserIndex(history);
|
|
1319
2184
|
if (lastUser === null)
|
|
@@ -1322,14 +2187,14 @@ export class SessionService {
|
|
|
1322
2187
|
const removed = history.length - lastUser;
|
|
1323
2188
|
shrinkTo(s, lastUser);
|
|
1324
2189
|
this.markStateDbSeen(s, undefined, { boundary: true });
|
|
1325
|
-
this.store.save(s);
|
|
2190
|
+
await this.store.save(s);
|
|
1326
2191
|
const preview = removedText.length > 40 ? `${removedText.slice(0, 40)}...` : removedText;
|
|
1327
2192
|
return { ok: true, removed_count: removed, removed_preview: preview };
|
|
1328
2193
|
});
|
|
1329
2194
|
}
|
|
1330
|
-
duplicate(sid) {
|
|
1331
|
-
this.
|
|
1332
|
-
const session = this.store.load(sid);
|
|
2195
|
+
async duplicate(sid) {
|
|
2196
|
+
await this.rejectViewOnly(sid, 'duplicated');
|
|
2197
|
+
const session = (await this.store.load(sid));
|
|
1333
2198
|
if (!session)
|
|
1334
2199
|
throw new HttpFailure(404, 'Session not found');
|
|
1335
2200
|
const now = this.deps.now();
|
|
@@ -1337,7 +2202,7 @@ export class SessionService {
|
|
|
1337
2202
|
const watermark = session.truncation_watermark_compressed ? { truncation_watermark: session.truncation_watermark, truncation_boundary: session.truncation_boundary, truncation_watermark_compressed: true } : {};
|
|
1338
2203
|
const copied = new Session({
|
|
1339
2204
|
title: `${session.title || 'Untitled'} (copy)`, workspace: session.workspace, model: session.model, model_provider: session.model_provider,
|
|
1340
|
-
messages: copyJson(session.messages), tool_calls: copyJson(session.tool_calls), pinned: false, archived: false, project_id: session.project_id, profile: session.profile,
|
|
2205
|
+
messages: withMirrorSources(copyJson(session.messages), session), tool_calls: copyJson(session.tool_calls), pinned: false, archived: false, project_id: session.project_id, profile: session.profile,
|
|
1341
2206
|
input_tokens: session.input_tokens, output_tokens: session.output_tokens, estimated_cost: session.estimated_cost, cache_read_tokens: session.cache_read_tokens, cache_write_tokens: session.cache_write_tokens,
|
|
1342
2207
|
personality: session.personality, enabled_toolsets: session.enabled_toolsets, context_length: session.context_length, threshold_tokens: session.threshold_tokens,
|
|
1343
2208
|
...watermark, context_messages: copyJson(session.context_messages),
|
|
@@ -1345,16 +2210,15 @@ export class SessionService {
|
|
|
1345
2210
|
manual_title: session.manual_title, composer_draft: copyJson(session.composer_draft), context_engine: session.context_engine, context_engine_state: copyJson(session.context_engine_state),
|
|
1346
2211
|
created_at: now, updated_at: now,
|
|
1347
2212
|
}, { workspace: session.workspace, model: session.model });
|
|
1348
|
-
this.store.touch(copied);
|
|
1349
|
-
this.store.save(copied);
|
|
2213
|
+
await this.store.touch(copied);
|
|
2214
|
+
await this.store.save(copied);
|
|
1350
2215
|
this.publish('session_duplicate', copied.profile, copied.session_id);
|
|
1351
|
-
return { session: this.publicSession(copied) };
|
|
2216
|
+
return { session: (await this.publicSession(copied)) };
|
|
1352
2217
|
}
|
|
1353
|
-
branch(sid, body) {
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
if (this.isReadOnly(source)) {
|
|
2218
|
+
async branch(sid, body) {
|
|
2219
|
+
await this.rejectViewOnly(sid, 'branched');
|
|
2220
|
+
const source = (await this.get404(sid));
|
|
2221
|
+
if ((await this.isReadOnly(source))) {
|
|
1358
2222
|
if (str(source.source_tag || source.raw_source).trim().toLowerCase() !== 'cron')
|
|
1359
2223
|
throw new HttpFailure(403, 'Read-only sessions cannot be branched from WebUI');
|
|
1360
2224
|
source.branchSourceReadonly = true;
|
|
@@ -1370,7 +2234,7 @@ export class SessionService {
|
|
|
1370
2234
|
const customTitle = body.title ? str(body.title).trim().slice(0, 80) || null : null;
|
|
1371
2235
|
if (!source.branchSourceReadonly) {
|
|
1372
2236
|
try {
|
|
1373
|
-
this.store.save(source);
|
|
2237
|
+
(await this.store.save(source));
|
|
1374
2238
|
}
|
|
1375
2239
|
catch { /* ignore */ }
|
|
1376
2240
|
}
|
|
@@ -1385,25 +2249,106 @@ export class SessionService {
|
|
|
1385
2249
|
const sourceContext = stateRows.length ? this.mergedTranscript(source, source.context_messages.length ? source.context_messages : source.messages) : source.context_messages;
|
|
1386
2250
|
const forkedContext = copyJson(truncateContextForDisplayKeep(sourceContext, sourceMessages, forkKeep));
|
|
1387
2251
|
const branch = new Session({
|
|
1388
|
-
workspace: source.workspace, model: source.model, model_provider: source.model_provider, profile: source.profile, title, messages: forked, project_id: source.project_id,
|
|
2252
|
+
workspace: source.workspace, model: source.model, model_provider: source.model_provider, profile: source.profile, title, messages: withMirrorSources(forked, source), project_id: source.project_id,
|
|
1389
2253
|
personality: source.personality, enabled_toolsets: source.enabled_toolsets, context_length: source.context_length, threshold_tokens: source.threshold_tokens,
|
|
1390
2254
|
context_messages: forkedContext, gateway_routing: copyJson(source.gateway_routing), context_engine: source.context_engine, context_engine_state: copyJson(source.context_engine_state),
|
|
1391
2255
|
parent_session_id: source.session_id, session_source: 'fork',
|
|
1392
2256
|
}, { workspace: source.workspace, model: source.model });
|
|
1393
|
-
this.store.touch(branch);
|
|
2257
|
+
await this.store.touch(branch);
|
|
1394
2258
|
if (forked.length) {
|
|
1395
|
-
this.store.save(branch);
|
|
2259
|
+
await this.store.save(branch);
|
|
1396
2260
|
this.publish('session_branch', branch.profile, branch.session_id);
|
|
1397
2261
|
}
|
|
1398
2262
|
return { session_id: branch.session_id, title, parent_session_id: source.session_id };
|
|
1399
2263
|
}
|
|
2264
|
+
/** Python `read_session_lineage_report` on the active profile's state.db. */
|
|
2265
|
+
lineageReport(sid) {
|
|
2266
|
+
return stateDbLineageReport(this.stateDbPath(null), sid);
|
|
2267
|
+
}
|
|
2268
|
+
/** TAL-259: recovery reads the active profile's state.db, and a recovered sidecar joins that profile. */
|
|
2269
|
+
async recoveryDeps() {
|
|
2270
|
+
return { store: this.store, stateDbPath: this.stateDbPath(null), profile: this.deps.activeProfile(), stampingSince: await this.deps.recoveryStampingSince(), isReadOnly: (sid, profile) => Boolean(withOwnerLocks([{ session_id: sid, profile }], this.stateDbSources)[0]?.read_only), log: this.deps.log };
|
|
2271
|
+
}
|
|
2272
|
+
async recoveryAudit() {
|
|
2273
|
+
return (await auditSessionRecovery(await this.recoveryDeps()));
|
|
2274
|
+
}
|
|
2275
|
+
async recoveryRepairSafe() {
|
|
2276
|
+
return repairSafeSessionRecovery(await this.recoveryDeps());
|
|
2277
|
+
}
|
|
2278
|
+
/**
|
|
2279
|
+
* Python `_handle_session_compression_recovery_start`: open a compression-exhausted session's focused continuation, an
|
|
2280
|
+
* empty fork keeping its workspace, model, profile, toolsets and worktree. A retry opens the oldest continuation
|
|
2281
|
+
* already started from it in the same profile; the source's lock makes concurrent starts converge on one child.
|
|
2282
|
+
*/
|
|
2283
|
+
async compressionRecoveryStart(sid) {
|
|
2284
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
2285
|
+
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot start compression recovery from WebUI');
|
|
2286
|
+
const action = COMPRESSION_RECOVERY_ACTION_START_FOCUSED;
|
|
2287
|
+
let created = false;
|
|
2288
|
+
const child = await this.withMutationLock(sid, async () => {
|
|
2289
|
+
// Validated under the lock: a delete that won it leaves no source to fork.
|
|
2290
|
+
const source = (await this.store.get(sid));
|
|
2291
|
+
if (!this.visibleToActiveProfile(source.profile))
|
|
2292
|
+
throw new HttpFailure(404, 'Session not found');
|
|
2293
|
+
if (!compressionRecoveryPayload(source))
|
|
2294
|
+
throw new HttpFailure(409, 'Session does not have a compression recovery action.');
|
|
2295
|
+
const existing = (await this.compressionRecoveryChild(sid, action, source.profile));
|
|
2296
|
+
if (existing)
|
|
2297
|
+
return existing;
|
|
2298
|
+
const base = (source.title || 'Untitled').trim() || 'Untitled';
|
|
2299
|
+
const fresh = new Session({
|
|
2300
|
+
title: base.endsWith(' (focused continuation)') ? base : `${base} (focused continuation)`, workspace: source.workspace, model: source.model, model_provider: source.model_provider,
|
|
2301
|
+
project_id: source.project_id, profile: source.profile, session_source: 'fork', personality: source.personality, enabled_toolsets: copyJson(source.enabled_toolsets),
|
|
2302
|
+
context_length: source.context_length, threshold_tokens: source.threshold_tokens, gateway_routing: copyJson(source.gateway_routing), gateway_routing_history: copyJson(source.gateway_routing_history),
|
|
2303
|
+
parent_session_id: source.session_id, worktree_path: source.worktree_path, worktree_branch: source.worktree_branch, worktree_repo_root: source.worktree_repo_root, worktree_created_at: source.worktree_created_at,
|
|
2304
|
+
compression_recovery_source_session_id: sid, compression_recovery_action: action,
|
|
2305
|
+
// An empty model-facing transcript: the focused follow-up must not replay the exhausted context.
|
|
2306
|
+
messages: [], context_messages: [], composer_draft: { text: '', files: [] },
|
|
2307
|
+
}, { workspace: source.workspace, model: source.model });
|
|
2308
|
+
try {
|
|
2309
|
+
await this.store.save(fresh);
|
|
2310
|
+
}
|
|
2311
|
+
catch (error) {
|
|
2312
|
+
throw new HttpFailure(500, `Failed to start compression recovery: ${sanitizePaths(error)}`);
|
|
2313
|
+
}
|
|
2314
|
+
await this.store.touch(fresh);
|
|
2315
|
+
created = true;
|
|
2316
|
+
return fresh;
|
|
2317
|
+
}, { allowReadOnly: true });
|
|
2318
|
+
if (created)
|
|
2319
|
+
this.publish('session_compression_recovery', child.profile, child.session_id);
|
|
2320
|
+
return {
|
|
2321
|
+
ok: true, session: (await this.publicSession(child)), source_session_id: sid, recommended_recovery_action: action,
|
|
2322
|
+
message: created ? 'Started a focused continuation. Describe the next narrow task to continue.' : 'Opened the existing focused continuation for this exhausted session.',
|
|
2323
|
+
};
|
|
2324
|
+
}
|
|
2325
|
+
/** Python `find_compression_recovery_session`: the oldest same-profile continuation started from `sid`, cached or persisted. */
|
|
2326
|
+
async compressionRecoveryChild(sid, action, profile) {
|
|
2327
|
+
const matches = (s) => s.compression_recovery_source_session_id === sid && s.compression_recovery_action === action && this.deps.profilesMatch(s.profile, profile);
|
|
2328
|
+
const found = new Map();
|
|
2329
|
+
for (const s of this.store.sessions.values())
|
|
2330
|
+
if (matches(s))
|
|
2331
|
+
found.set(s.session_id, s);
|
|
2332
|
+
for (const id of (await this.store.persistedIds())) {
|
|
2333
|
+
if (found.has(id) || this.store.sessions.has(id))
|
|
2334
|
+
continue;
|
|
2335
|
+
try {
|
|
2336
|
+
const meta = (await this.store.get(id, { metadataOnly: true, promote: false, cacheOnMiss: false }));
|
|
2337
|
+
if (matches(meta))
|
|
2338
|
+
found.set(id, meta);
|
|
2339
|
+
}
|
|
2340
|
+
catch { /* unreadable: not a match */ }
|
|
2341
|
+
}
|
|
2342
|
+
const [oldest] = [...found.values()].sort((a, b) => (a.created_at || 0) - (b.created_at || 0) || (a.updated_at || 0) - (b.updated_at || 0) || (a.session_id < b.session_id ? -1 : 1));
|
|
2343
|
+
return oldest ? (await this.store.get(oldest.session_id)) : null;
|
|
2344
|
+
}
|
|
1400
2345
|
/** The run that keeps `delete` answering 409, if any; stream status reports it so a draining client can wait on it (TAL-622). */
|
|
1401
|
-
activeRunBlocking(sid) {
|
|
2346
|
+
async activeRunBlocking(sid) {
|
|
1402
2347
|
const live = this.deps.runtime.activeRunStream(sid);
|
|
1403
2348
|
if (live)
|
|
1404
2349
|
return live;
|
|
1405
2350
|
try {
|
|
1406
|
-
const candidate = str(this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false }).active_stream_id).trim();
|
|
2351
|
+
const candidate = str((await this.store.get(sid, { metadataOnly: true, promote: false, cacheOnMiss: false })).active_stream_id).trim();
|
|
1407
2352
|
if (candidate && this.deps.runtime.activeStreamIds.has(candidate))
|
|
1408
2353
|
return candidate;
|
|
1409
2354
|
}
|
|
@@ -1415,112 +2360,141 @@ export class SessionService {
|
|
|
1415
2360
|
throw new HttpFailure(400, 'session_id is required');
|
|
1416
2361
|
if (!isSafeSessionId(sid))
|
|
1417
2362
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
2363
|
+
await this.rejectViewOnly(sid, 'deleted');
|
|
1418
2364
|
// Python: a read-only import (persisted sidecar flag, sidebar metadata, or a foreign state.db owner such as a
|
|
1419
|
-
// Claude Code session) is never deleted — that would erase the owner's authoritative transcript.
|
|
1420
|
-
|
|
2365
|
+
// Claude Code session) is never deleted — that would erase the owner's authoritative transcript. An orphan has none.
|
|
2366
|
+
const orphaned = (await this.isOrphanedImport(sid));
|
|
2367
|
+
if (!orphaned && (await this.isReadOnlyImport(sid)))
|
|
1421
2368
|
throw new HttpFailure(400, 'Read-only imported sessions cannot be deleted from WebUI');
|
|
1422
|
-
if (this.isSubagentViewOnly(sid))
|
|
2369
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1423
2370
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot be deleted from WebUI');
|
|
1424
|
-
const retained = (() => { try {
|
|
1425
|
-
return worktreeRetainedPayload(this.store.get(sid, { metadataOnly: true }));
|
|
2371
|
+
const retained = (await (async () => { try {
|
|
2372
|
+
return worktreeRetainedPayload((await this.store.get(sid, { metadataOnly: true })));
|
|
1426
2373
|
}
|
|
1427
2374
|
catch {
|
|
1428
2375
|
return {};
|
|
1429
|
-
} })();
|
|
2376
|
+
} })());
|
|
1430
2377
|
let eventProfile = null;
|
|
1431
|
-
let hadSidecar = true;
|
|
1432
2378
|
try {
|
|
1433
|
-
eventProfile = this.store.get(sid, { metadataOnly: true }).profile;
|
|
2379
|
+
eventProfile = (await this.store.get(sid, { metadataOnly: true })).profile;
|
|
1434
2380
|
}
|
|
1435
2381
|
catch {
|
|
1436
2382
|
eventProfile = null;
|
|
1437
|
-
hadSidecar = false;
|
|
1438
2383
|
}
|
|
1439
2384
|
// Python `_is_messaging_session_id`: decided before the JSON is gone, from WebUI metadata or the Agent's row.
|
|
1440
|
-
const isMessaging = (()
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
2385
|
+
const isMessaging = (await this.isMessagingSession(sid));
|
|
2386
|
+
const knownIds = !isMessaging && !orphaned ? stateDbCompressionLineage(this.stateDbPath(eventProfile), sid) : [sid];
|
|
2387
|
+
const assertIdle = async () => { for (const id of knownIds)
|
|
2388
|
+
if (await this.activeRunBlocking(id))
|
|
2389
|
+
throw new HttpFailure(409, 'Session has an active run; stop it before deleting'); };
|
|
2390
|
+
await assertIdle();
|
|
2391
|
+
let stateDbCleanupFailed = false;
|
|
2392
|
+
let removedIds = [sid];
|
|
1447
2393
|
try {
|
|
1448
|
-
await this.
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
//
|
|
1452
|
-
|
|
1453
|
-
|
|
2394
|
+
await this.withMutationLock(sid, async () => {
|
|
2395
|
+
await assertIdle();
|
|
2396
|
+
// TAL-792: state.db goes first, so another Agent process running or compressing the session refuses the delete
|
|
2397
|
+
// before anything of Talaria's is removed. Python: the Agent/CLI transcript goes too (else a claimable CLI row
|
|
2398
|
+
// resurfaces in the sidebar), but a messaging channel's memory is never erased from the WebUI.
|
|
2399
|
+
if (!isMessaging && !orphaned) {
|
|
2400
|
+
const result = await this.deleteStateDbRows(eventProfile, sid);
|
|
2401
|
+
stateDbCleanupFailed = !result.ok;
|
|
2402
|
+
removedIds = [...new Set([sid, ...(result.deleted_ids ?? (result.ok ? knownIds : []))])];
|
|
1454
2403
|
}
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
2404
|
+
if (removedIds.some((id) => !isSafeSessionId(id)))
|
|
2405
|
+
throw new HttpFailure(503, 'Session cleanup could not finish because the Agent returned invalid session IDs.');
|
|
2406
|
+
removedIds = (await filterAsync(removedIds, async (id) => {
|
|
2407
|
+
try {
|
|
2408
|
+
return this.deps.profilesMatch((await this.store.get(id, { metadataOnly: true })).profile, eventProfile ?? this.deps.activeProfile());
|
|
2409
|
+
}
|
|
2410
|
+
catch {
|
|
2411
|
+
return true;
|
|
2412
|
+
}
|
|
2413
|
+
}));
|
|
2414
|
+
for (const id of removedIds) {
|
|
2415
|
+
// Revoke before removing each persisted alias; UI cleanup follows the Agent's committed target set.
|
|
2416
|
+
try {
|
|
2417
|
+
const session = (await this.store.get(id, { metadataOnly: true }));
|
|
2418
|
+
if (!Object.keys(retained).length)
|
|
2419
|
+
Object.assign(retained, worktreeRetainedPayload(session));
|
|
2420
|
+
await this.deps.shares.revoke(session);
|
|
2421
|
+
await this.deps.clearRelayCompletions?.(id, session.profile);
|
|
2422
|
+
}
|
|
2423
|
+
catch (error) {
|
|
2424
|
+
if (!(error instanceof SessionNotFound))
|
|
2425
|
+
throw error;
|
|
2426
|
+
}
|
|
2427
|
+
const finalize = id === sid && !(await this.store.wasDeleted(id));
|
|
2428
|
+
if (!(await this.store.delete(id)))
|
|
2429
|
+
throw new HttpFailure(500, 'Failed to delete session data');
|
|
2430
|
+
if (finalize)
|
|
2431
|
+
await this.finalizeSession(sid, eventProfile, 'delete');
|
|
1458
2432
|
}
|
|
1459
|
-
|
|
1460
|
-
throw new HttpFailure(500, 'Failed to delete session data');
|
|
1461
|
-
}, { timeoutMs: 5000 });
|
|
2433
|
+
}, { timeoutMs: 5000, allowReadOnly: orphaned });
|
|
1462
2434
|
}
|
|
1463
2435
|
catch (error) {
|
|
1464
2436
|
if (error instanceof SessionBusy)
|
|
1465
2437
|
throw new HttpFailure(503, 'Session busy, try again');
|
|
1466
2438
|
throw error;
|
|
1467
2439
|
}
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
catch { /* ignore */ }
|
|
1473
|
-
// Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
|
|
1474
|
-
try {
|
|
1475
|
-
this.deps.journal?.deleteSession(sid);
|
|
1476
|
-
}
|
|
1477
|
-
catch { /* ignore */ }
|
|
1478
|
-
this.deps.runtime.closeTerminal(sid);
|
|
1479
|
-
// Python: the Agent/CLI transcript in state.db goes too (else a claimable CLI row resurfaces in the sidebar), but a
|
|
1480
|
-
// messaging channel's memory is never erased from the WebUI; the actual outcome is reported.
|
|
1481
|
-
let stateDbCleanupFailed = false;
|
|
1482
|
-
if (!isMessaging) {
|
|
1483
|
-
// TAL-529: the compression continuations go too, tip first; the Agent's delete keeps and detaches them otherwise.
|
|
1484
|
-
for (const id of stateDbCompressionLineage(this.stateDbPath(eventProfile), sid).reverse()) {
|
|
1485
|
-
try {
|
|
1486
|
-
if (!(await this.deps.runtime.deleteCliSession(eventProfile, id)))
|
|
1487
|
-
stateDbCleanupFailed = true;
|
|
1488
|
-
}
|
|
1489
|
-
catch {
|
|
1490
|
-
stateDbCleanupFailed = true;
|
|
1491
|
-
}
|
|
2440
|
+
for (const id of removedIds) {
|
|
2441
|
+
await this.deps.runtime.evictAgent(id, true);
|
|
2442
|
+
try {
|
|
2443
|
+
rmSync(this.deps.attachmentDir(id), { recursive: true, force: true });
|
|
1492
2444
|
}
|
|
2445
|
+
catch { /* ignore */ }
|
|
2446
|
+
// Python `delete_run_journal` (#3802): a deleted session leaves no replayable run journal behind.
|
|
2447
|
+
try {
|
|
2448
|
+
(await this.deps.journal?.deleteSession(id));
|
|
2449
|
+
}
|
|
2450
|
+
catch { /* ignore */ }
|
|
2451
|
+
this.deps.runtime.closeTerminal(id);
|
|
1493
2452
|
}
|
|
1494
2453
|
this.publish('session_delete', eventProfile);
|
|
1495
|
-
// Only a sidecar session is ever published to the relay.
|
|
1496
|
-
if (hadSidecar)
|
|
1497
|
-
this.deps.clearRelayCompletions?.(sid, eventProfile);
|
|
1498
2454
|
return { ok: true, state_db_cleanup_failed: stateDbCleanupFailed, ...retained };
|
|
1499
2455
|
}
|
|
1500
|
-
cleanup
|
|
2456
|
+
/** The committed Agent deletion targets and cleanup outcome; throws 409 `refused` while an Agent process holds a row. */
|
|
2457
|
+
async deleteStateDbRows(profile, sid) {
|
|
2458
|
+
// TAL-529: the compression continuations go too; the Agent's delete keeps and detaches them otherwise. TAL-792: the
|
|
2459
|
+
// sidecar resolves them inside its delete transaction, so a guard on any of them refuses before anything is removed.
|
|
2460
|
+
let result;
|
|
2461
|
+
try {
|
|
2462
|
+
result = await this.deps.runtime.deleteCliSession(profile, sid);
|
|
2463
|
+
}
|
|
2464
|
+
catch {
|
|
2465
|
+
return { ok: false };
|
|
2466
|
+
}
|
|
2467
|
+
if (result.refused) {
|
|
2468
|
+
const message = result.refused === 'runtime_history_unsupported'
|
|
2469
|
+
? 'This Agent cannot retire the session\'s retained runtime history. Use an Agent that supports retiring this history, or explicitly restore a pre-upgrade backup before deleting it.'
|
|
2470
|
+
: 'In use by another Agent process. Try again later.';
|
|
2471
|
+
throw new HttpFailure(409, message, { refused: result.refused });
|
|
2472
|
+
}
|
|
2473
|
+
return result;
|
|
2474
|
+
}
|
|
2475
|
+
async cleanup(zeroOnly) {
|
|
1501
2476
|
let cleaned = 0;
|
|
1502
2477
|
const removed = new Set();
|
|
1503
|
-
for (const s of this.store.scanAll()) {
|
|
2478
|
+
for (const s of (await this.store.scanAll())) {
|
|
1504
2479
|
const shouldDelete = zeroOnly ? s.messages.length === 0 : s.title === 'Untitled' && s.messages.length === 0;
|
|
1505
|
-
if (!shouldDelete)
|
|
2480
|
+
if (!shouldDelete || (await this.isReadOnly(s)))
|
|
1506
2481
|
continue;
|
|
1507
2482
|
// A cleared session keeps its share; keep the session (and its revoke route) if the share cannot be revoked.
|
|
1508
2483
|
try {
|
|
1509
|
-
this.deps.shares.revoke(s);
|
|
2484
|
+
(await this.deps.shares.revoke(s));
|
|
1510
2485
|
}
|
|
1511
2486
|
catch {
|
|
1512
2487
|
continue;
|
|
1513
2488
|
}
|
|
1514
2489
|
this.store.sessions.delete(s.session_id);
|
|
1515
|
-
|
|
2490
|
+
await this.store.delete(s.session_id, { tombstone: false });
|
|
1516
2491
|
cleaned += 1;
|
|
1517
2492
|
removed.add(s.session_id);
|
|
1518
2493
|
}
|
|
1519
|
-
this.store.invalidatePersistedIds();
|
|
1520
2494
|
let rewroteIndex = false;
|
|
1521
2495
|
try {
|
|
1522
|
-
const rows = this.store.readIndexEntries();
|
|
1523
|
-
const live = this.store.persistedIds();
|
|
2496
|
+
const rows = (await this.store.readIndexEntries());
|
|
2497
|
+
const live = (await this.store.persistedIds());
|
|
1524
2498
|
const survivors = rows.filter((entry) => {
|
|
1525
2499
|
const id = str(entry.session_id);
|
|
1526
2500
|
if (!id || live.has(id) || this.store.sessions.has(id))
|
|
@@ -1531,20 +2505,20 @@ export class SessionService {
|
|
|
1531
2505
|
return false;
|
|
1532
2506
|
});
|
|
1533
2507
|
if (survivors.length < rows.length) {
|
|
1534
|
-
this.store.writeIndex();
|
|
2508
|
+
await this.store.writeIndex();
|
|
1535
2509
|
rewroteIndex = true;
|
|
1536
2510
|
}
|
|
1537
2511
|
}
|
|
1538
2512
|
catch { /* corrupt index is rebuilt below */ }
|
|
1539
2513
|
if (removed.size && !rewroteIndex) {
|
|
1540
2514
|
try {
|
|
1541
|
-
this.store.writeIndex();
|
|
2515
|
+
(await this.store.writeIndex());
|
|
1542
2516
|
}
|
|
1543
2517
|
catch { /* ignore */ }
|
|
1544
2518
|
}
|
|
1545
2519
|
return { ok: true, cleaned };
|
|
1546
2520
|
}
|
|
1547
|
-
import(body) {
|
|
2521
|
+
async import(body) {
|
|
1548
2522
|
const messages = stripPublicInternalFields(body.messages, { messageRecords: true });
|
|
1549
2523
|
if (!Array.isArray(messages))
|
|
1550
2524
|
throw new HttpFailure(400, 'JSON must contain a "messages" array');
|
|
@@ -1553,48 +2527,48 @@ export class SessionService {
|
|
|
1553
2527
|
throw new HttpFailure(400, 'JSON "tool_calls" must be an array');
|
|
1554
2528
|
let workspace;
|
|
1555
2529
|
try {
|
|
1556
|
-
workspace = this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null);
|
|
2530
|
+
workspace = (await this.deps.workspaces.resolveTrusted(typeof body.workspace === 'string' ? body.workspace : null));
|
|
1557
2531
|
}
|
|
1558
2532
|
catch (error) {
|
|
1559
2533
|
throw new HttpFailure(400, error.message);
|
|
1560
2534
|
}
|
|
1561
|
-
const defaults = this.store.deps.defaults(this.deps.activeProfile());
|
|
2535
|
+
const defaults = (await this.store.deps.defaults(this.deps.activeProfile()));
|
|
1562
2536
|
const s = new Session({ title: typeof body.title === 'string' ? body.title : 'Imported session', workspace, model: typeof body.model === 'string' ? body.model : defaults.model, messages: messages, tool_calls: stripPublicInternalFields(rawToolCalls), profile: this.deps.activeProfile() }, defaults);
|
|
1563
2537
|
s.pinned = Boolean(body.pinned);
|
|
1564
|
-
this.store.touch(s);
|
|
1565
|
-
this.store.save(s);
|
|
2538
|
+
await this.store.touch(s);
|
|
2539
|
+
await this.store.save(s);
|
|
1566
2540
|
this.publish('session_import');
|
|
1567
|
-
return { ok: true, session: this.publicSession(s) };
|
|
2541
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1568
2542
|
}
|
|
1569
2543
|
// ── drafts ───────────────────────────────────────────────────────────────
|
|
1570
|
-
readDraft(sid) {
|
|
2544
|
+
async readDraft(sid) {
|
|
1571
2545
|
if (!sid)
|
|
1572
2546
|
throw new HttpFailure(400, 'session_id is required');
|
|
1573
|
-
let hasSidecar;
|
|
1574
2547
|
try {
|
|
1575
|
-
|
|
2548
|
+
this.deps.drafts.validate(sid);
|
|
1576
2549
|
}
|
|
1577
2550
|
catch {
|
|
1578
2551
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
1579
2552
|
}
|
|
2553
|
+
const hasSidecar = await this.deps.drafts.exists(sid);
|
|
1580
2554
|
if (hasSidecar) {
|
|
1581
2555
|
const cached = this.store.sessions.get(sid);
|
|
1582
|
-
if (!cached && !existsSessionFile(this.store, sid))
|
|
2556
|
+
if (!cached && !(await existsSessionFile(this.store, sid)))
|
|
1583
2557
|
throw new HttpFailure(404, 'Session not found');
|
|
1584
|
-
const [draft, version] = this.deps.drafts.readState(sid);
|
|
2558
|
+
const [draft, version] = (await this.deps.drafts.readState(sid));
|
|
1585
2559
|
if (cached)
|
|
1586
2560
|
cached.composer_draft = draft;
|
|
1587
2561
|
return { draft, draft_version: version };
|
|
1588
2562
|
}
|
|
1589
|
-
const s = this.get404(sid, { metadataOnly: true });
|
|
1590
|
-
const [draft, version] = this.deps.drafts.readState(sid, s.composer_draft);
|
|
2563
|
+
const s = (await this.get404(sid, { metadataOnly: true }));
|
|
2564
|
+
const [draft, version] = (await this.deps.drafts.readState(sid, s.composer_draft));
|
|
1591
2565
|
return { draft, draft_version: version };
|
|
1592
2566
|
}
|
|
1593
2567
|
async writeDraft(body) {
|
|
1594
2568
|
const sid = str(body.session_id);
|
|
1595
2569
|
if (!sid)
|
|
1596
2570
|
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
1597
|
-
if (this.isSubagentViewOnly(sid))
|
|
2571
|
+
if ((await this.isSubagentViewOnly(sid)))
|
|
1598
2572
|
throw new HttpFailure(400, 'Subagent sessions are view-only and cannot store a draft from WebUI');
|
|
1599
2573
|
let text = body.text;
|
|
1600
2574
|
let files = body.files;
|
|
@@ -1607,7 +2581,7 @@ export class SessionService {
|
|
|
1607
2581
|
if (Array.isArray(files) && files.length > 50)
|
|
1608
2582
|
files = files.slice(0, 50);
|
|
1609
2583
|
try {
|
|
1610
|
-
this.deps.drafts.
|
|
2584
|
+
this.deps.drafts.validate(sid);
|
|
1611
2585
|
}
|
|
1612
2586
|
catch {
|
|
1613
2587
|
throw new HttpFailure(400, 'Invalid session_id');
|
|
@@ -1624,18 +2598,18 @@ export class SessionService {
|
|
|
1624
2598
|
if (BigInt(draftVersion) > maxVersion)
|
|
1625
2599
|
throw new HttpFailure(400, 'draft_version is too far in the future');
|
|
1626
2600
|
}
|
|
1627
|
-
return this.store.withLock(sid, () => {
|
|
2601
|
+
return this.store.withLock(sid, async () => {
|
|
1628
2602
|
let s = this.store.sessions.get(sid) ?? null;
|
|
1629
2603
|
if (s && s.session_id !== sid)
|
|
1630
2604
|
s = null;
|
|
1631
|
-
const sessionExists = existsSessionFile(this.store, sid);
|
|
2605
|
+
const sessionExists = (await existsSessionFile(this.store, sid));
|
|
1632
2606
|
if (!s) {
|
|
1633
2607
|
if (!sessionExists)
|
|
1634
2608
|
throw new HttpFailure(404, 'Session not found');
|
|
1635
|
-
if (!this.deps.drafts.exists(sid))
|
|
1636
|
-
s = this.get404(sid, { metadataOnly: true });
|
|
2609
|
+
if (!(await this.deps.drafts.exists(sid)))
|
|
2610
|
+
s = (await this.get404(sid, { metadataOnly: true }));
|
|
1637
2611
|
}
|
|
1638
|
-
const [currentDraft, currentVersion] = this.deps.drafts.readState(sid, s?.composer_draft);
|
|
2612
|
+
const [currentDraft, currentVersion] = (await this.deps.drafts.readState(sid, s?.composer_draft));
|
|
1639
2613
|
const next = { ...currentDraft };
|
|
1640
2614
|
if (text !== null && text !== undefined)
|
|
1641
2615
|
next.text = text;
|
|
@@ -1658,10 +2632,10 @@ export class SessionService {
|
|
|
1658
2632
|
throw new HttpFailure(404, 'Session not found');
|
|
1659
2633
|
if (!sessionExists && s) {
|
|
1660
2634
|
s.composer_draft = next;
|
|
1661
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2635
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1662
2636
|
}
|
|
1663
2637
|
try {
|
|
1664
|
-
saved = this.deps.drafts.write(sid, next, draftVersion);
|
|
2638
|
+
saved = (await this.deps.drafts.write(sid, next, draftVersion));
|
|
1665
2639
|
}
|
|
1666
2640
|
catch (error) {
|
|
1667
2641
|
if (error instanceof DraftVersionConflict)
|
|
@@ -1686,7 +2660,7 @@ export class SessionService {
|
|
|
1686
2660
|
throw new HttpFailure(400, 'Missing required field(s): scene');
|
|
1687
2661
|
let s;
|
|
1688
2662
|
try {
|
|
1689
|
-
s = this.getForMutation(sid);
|
|
2663
|
+
s = (await this.getForMutation(sid));
|
|
1690
2664
|
}
|
|
1691
2665
|
catch (error) {
|
|
1692
2666
|
if (error instanceof SessionNotFound)
|
|
@@ -1697,7 +2671,7 @@ export class SessionService {
|
|
|
1697
2671
|
}
|
|
1698
2672
|
if (!this.visibleToActiveProfile(s.profile))
|
|
1699
2673
|
throw new HttpFailure(404, 'Session not found');
|
|
1700
|
-
return this.
|
|
2674
|
+
return this.withMutationLock(sid, async () => {
|
|
1701
2675
|
let placement;
|
|
1702
2676
|
try {
|
|
1703
2677
|
placement = storeAnchorScene(s, body, this.deps.now());
|
|
@@ -1707,16 +2681,16 @@ export class SessionService {
|
|
|
1707
2681
|
}
|
|
1708
2682
|
if (!placement)
|
|
1709
2683
|
throw new HttpFailure(404, 'Assistant message not found');
|
|
1710
|
-
this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
|
|
2684
|
+
await this.store.save(s, { touchUpdatedAt: false, skipIndex: true });
|
|
1711
2685
|
return { ok: true, ...placement };
|
|
1712
2686
|
});
|
|
1713
2687
|
}
|
|
1714
2688
|
/** The session and the full transcript its detail is built from; a state.db-only session gives its synthesized one. */
|
|
1715
|
-
fullTranscript(sid) {
|
|
2689
|
+
async fullTranscript(sid) {
|
|
1716
2690
|
try {
|
|
1717
|
-
let session = this.store.get(sid);
|
|
2691
|
+
let session = (await this.store.get(sid));
|
|
1718
2692
|
if (session.loadedMetadataOnly)
|
|
1719
|
-
session = this.store.load(sid) ?? session;
|
|
2693
|
+
session = (await this.store.load(sid)) ?? session;
|
|
1720
2694
|
if (!this.visibleToActiveProfile(session.profile))
|
|
1721
2695
|
throw new HttpFailure(404, 'Session not found');
|
|
1722
2696
|
return { session, transcript: this.mergedTranscript(session), stored: true };
|
|
@@ -1724,17 +2698,17 @@ export class SessionService {
|
|
|
1724
2698
|
catch (error) {
|
|
1725
2699
|
if (error instanceof HttpFailure)
|
|
1726
2700
|
throw error;
|
|
1727
|
-
const session = this.foreignSession(sid).synth;
|
|
2701
|
+
const session = (await this.foreignSession(sid)).synth;
|
|
1728
2702
|
return { session, transcript: session.messages, stored: false };
|
|
1729
2703
|
}
|
|
1730
2704
|
}
|
|
1731
|
-
readAnchorScene(query) {
|
|
2705
|
+
async readAnchorScene(query) {
|
|
1732
2706
|
const sid = str(query.session_id).trim();
|
|
1733
2707
|
const messageRef = normalizeAnchorSceneMessageRef(query.message_ref);
|
|
1734
2708
|
const messageIndex = anchorSceneIntOrNull(query.message_index);
|
|
1735
2709
|
if (!sid || (!messageRef && messageIndex === null))
|
|
1736
2710
|
throw new HttpFailure(400, 'session_id and message_ref or message_index are required');
|
|
1737
|
-
const { session, transcript, stored } = this.fullTranscript(sid);
|
|
2711
|
+
const { session, transcript, stored } = (await this.fullTranscript(sid));
|
|
1738
2712
|
const result = readAnchorSceneRows(session, { messageRef, messageIndex, before: anchorSceneIntOrNull(query.before), limit: anchorSceneIntOrNull(query.limit) }, withToolCallOutcomes(withTurnIds(transcript), session.tool_calls, session.active_stream_id));
|
|
1739
2713
|
if (!result)
|
|
1740
2714
|
throw new HttpFailure(404, 'Anchor activity scene not found');
|
|
@@ -1743,26 +2717,26 @@ export class SessionService {
|
|
|
1743
2717
|
const redacted = redactValue(result, enabled);
|
|
1744
2718
|
const rows = withSceneToolDisplay(result.rows, redacted.rows, enabled);
|
|
1745
2719
|
// `/api/media` serves stored sessions only, so a state.db-only session's rows stay as written, as its detail does.
|
|
1746
|
-
return { ...redacted, rows: stored ? withSceneRowMedia(rows, this.mediaProjector(session)) : rows };
|
|
2720
|
+
return { ...redacted, rows: stored ? withSceneRowMedia(rows, (await this.mediaProjector(session))) : rows };
|
|
1747
2721
|
}
|
|
1748
2722
|
/** TAL-331: one tool call's whole result, which a limited response clipped (`result_truncated`), redacted like the detail. */
|
|
1749
|
-
readToolResult(query) {
|
|
2723
|
+
async readToolResult(query) {
|
|
1750
2724
|
const sid = query.session_id.trim();
|
|
1751
2725
|
const id = query.tool_call_id.trim();
|
|
1752
2726
|
if (!sid || !id)
|
|
1753
2727
|
throw new HttpFailure(400, 'session_id and tool_call_id are required');
|
|
1754
|
-
const full = fullToolResult(this.fullTranscript(sid).transcript, id);
|
|
2728
|
+
const full = fullToolResult((await this.fullTranscript(sid)).transcript, id);
|
|
1755
2729
|
if (full === null)
|
|
1756
2730
|
throw new HttpFailure(404, 'Tool result not found');
|
|
1757
2731
|
return { tool_call_id: id, ...redactValue(full, this.deps.redactEnabled()) };
|
|
1758
2732
|
}
|
|
1759
2733
|
// ── shares ───────────────────────────────────────────────────────────────
|
|
1760
|
-
createShare(sid) {
|
|
2734
|
+
async createShare(sid) {
|
|
1761
2735
|
this.rejectClaudeCode(sid, 'shared');
|
|
1762
|
-
let s = this.get404(sid);
|
|
2736
|
+
let s = (await this.get404(sid));
|
|
1763
2737
|
if (!this.visibleToActiveProfile(s.profile))
|
|
1764
2738
|
throw new HttpFailure(404, 'Session not found');
|
|
1765
|
-
s = this.store.ensureFull(sid, s);
|
|
2739
|
+
s = (await this.store.ensureFull(sid, s));
|
|
1766
2740
|
let snapshot;
|
|
1767
2741
|
try {
|
|
1768
2742
|
snapshot = buildShareSnapshot(s, s.messages, { hermesHome: this.deps.hermesHome, attachmentRoot: this.deps.attachmentDir('').replace(/\/[^/]*$/, ''), home: this.deps.home });
|
|
@@ -1770,31 +2744,31 @@ export class SessionService {
|
|
|
1770
2744
|
catch (error) {
|
|
1771
2745
|
throw new HttpFailure(400, error.message);
|
|
1772
2746
|
}
|
|
1773
|
-
const meta = this.deps.shares.createOrRefresh(s, snapshot);
|
|
2747
|
+
const meta = (await this.deps.shares.createOrRefresh(s, snapshot));
|
|
1774
2748
|
s.share_token = meta.share_token;
|
|
1775
2749
|
s.share_created_at = meta.share_created_at;
|
|
1776
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2750
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1777
2751
|
this.publish('session_share_create', s.profile, sid);
|
|
1778
2752
|
return {
|
|
1779
2753
|
ok: true,
|
|
1780
2754
|
share: { token: meta.share_token, url: sharePath(meta.share_token) ?? '', title: meta.share_title, message_count: meta.share_message_count, created_at: meta.share_created_at, updated_at: meta.share_updated_at },
|
|
1781
|
-
session: this.publicSession(s),
|
|
2755
|
+
session: (await this.publicSession(s)),
|
|
1782
2756
|
};
|
|
1783
2757
|
}
|
|
1784
|
-
revokeShare(sid) {
|
|
1785
|
-
let s = this.get404(sid);
|
|
2758
|
+
async revokeShare(sid) {
|
|
2759
|
+
let s = (await this.get404(sid));
|
|
1786
2760
|
if (!this.visibleToActiveProfile(s.profile))
|
|
1787
2761
|
throw new HttpFailure(404, 'Session not found');
|
|
1788
|
-
s = this.store.ensureFull(sid, s);
|
|
1789
|
-
this.deps.shares.revoke(s);
|
|
2762
|
+
s = (await this.store.ensureFull(sid, s));
|
|
2763
|
+
await this.deps.shares.revoke(s);
|
|
1790
2764
|
s.share_token = null;
|
|
1791
2765
|
s.share_created_at = null;
|
|
1792
|
-
this.store.save(s, { touchUpdatedAt: false });
|
|
2766
|
+
await this.store.save(s, { touchUpdatedAt: false });
|
|
1793
2767
|
this.publish('session_share_revoke', s.profile, sid);
|
|
1794
|
-
return { ok: true, session: this.publicSession(s) };
|
|
2768
|
+
return { ok: true, session: (await this.publicSession(s)) };
|
|
1795
2769
|
}
|
|
1796
|
-
loadShare(token) {
|
|
1797
|
-
const share = this.deps.shares.load(token);
|
|
2770
|
+
async loadShare(token) {
|
|
2771
|
+
const share = (await this.deps.shares.load(token));
|
|
1798
2772
|
if (!share)
|
|
1799
2773
|
throw new HttpFailure(404, 'Shared conversation not found');
|
|
1800
2774
|
return { share };
|
|
@@ -1805,15 +2779,16 @@ export class SessionService {
|
|
|
1805
2779
|
throw new HttpFailure(400, 'Missing session_id');
|
|
1806
2780
|
return { yolo_enabled: this.deps.yolo.isEnabled(sid) };
|
|
1807
2781
|
}
|
|
1808
|
-
setYolo(sid, enabled) {
|
|
2782
|
+
async setYolo(sid, enabled) {
|
|
1809
2783
|
if (!sid)
|
|
1810
2784
|
throw new HttpFailure(400, 'Missing required field(s): session_id');
|
|
2785
|
+
await this.assertWritable(sid);
|
|
1811
2786
|
this.deps.yolo.set(sid, enabled);
|
|
1812
2787
|
return { ok: true, yolo_enabled: this.deps.yolo.isEnabled(sid) };
|
|
1813
2788
|
}
|
|
1814
2789
|
}
|
|
1815
|
-
function existsSessionFile(store, sid) {
|
|
1816
|
-
return store.persistedIds().has(sid);
|
|
2790
|
+
async function existsSessionFile(store, sid) {
|
|
2791
|
+
return (await store.persistedIds()).has(sid);
|
|
1817
2792
|
}
|
|
1818
2793
|
import { createHash } from 'node:crypto';
|
|
1819
2794
|
function hashRevision(signature) {
|
|
@@ -1857,13 +2832,24 @@ function runStateDbRows(rows, prompt) {
|
|
|
1857
2832
|
function settledIdentity(m) {
|
|
1858
2833
|
return messageIdentity(m, Infinity) ?? JSON.stringify([m.role ?? null, m.content ?? null, reasoningFieldsText(m)]);
|
|
1859
2834
|
}
|
|
1860
|
-
/** `rows` minus one occurrence of each row
|
|
2835
|
+
/** `rows` minus one occurrence of each base row, retaining its numeric provenance on the Agent's history copy. */
|
|
1861
2836
|
function withoutRows(rows, base) {
|
|
1862
2837
|
const left = new Map();
|
|
1863
|
-
for (const m of base)
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
2838
|
+
for (const m of base) {
|
|
2839
|
+
const key = settledIdentity(m);
|
|
2840
|
+
const entry = left.get(key) ?? { rows: [], at: 0 };
|
|
2841
|
+
entry.rows.push(m);
|
|
2842
|
+
left.set(key, entry);
|
|
2843
|
+
}
|
|
2844
|
+
return rows.filter((m) => {
|
|
2845
|
+
const entry = left.get(settledIdentity(m));
|
|
2846
|
+
if (!entry || entry.at === entry.rows.length)
|
|
2847
|
+
return true;
|
|
2848
|
+
const original = entry.rows[entry.at++];
|
|
2849
|
+
if (!('_state_db_generation' in m) && '_state_db_generation' in original)
|
|
2850
|
+
m._state_db_generation = original._state_db_generation;
|
|
2851
|
+
return false;
|
|
2852
|
+
});
|
|
1867
2853
|
}
|
|
1868
2854
|
/**
|
|
1869
2855
|
* TAL-493: what the state.db marker was recorded under: the boundary fields every boundary writer sets (an older release
|