create-walle 0.9.21 → 0.9.23
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -5
- package/package.json +2 -2
- package/template/CLAUDE.md +2 -2
- package/template/LICENSE +1 -1
- package/template/bin/ctm-dev-cleanup.js +24 -3
- package/template/bin/ctm-launch.sh +13 -0
- package/template/bin/dev.sh +156 -18
- package/template/bin/node-bin.sh +84 -0
- package/template/bin/pin-node.sh +51 -0
- package/template/claude-task-manager/api-prompts.js +1203 -182
- package/template/claude-task-manager/api-reviews.js +109 -15
- package/template/claude-task-manager/approval-agent.js +1360 -280
- package/template/claude-task-manager/bin/restart-ctm.sh +64 -23
- package/template/claude-task-manager/bin/storage-migration-supervisor.js +338 -0
- package/template/claude-task-manager/db.js +4417 -295
- package/template/claude-task-manager/docs/app-update-refresh-protocol.md +69 -0
- package/template/claude-task-manager/docs/approval-ai-refinement.md +138 -0
- package/template/claude-task-manager/docs/approval-rescue-loop.md +74 -0
- package/template/claude-task-manager/docs/codex-operational-warning-health.md +107 -0
- package/template/claude-task-manager/docs/codex-resume-state-guard-design.md +17 -12
- package/template/claude-task-manager/docs/codex-terminal-render-controller-handoff.md +311 -0
- package/template/claude-task-manager/docs/coding-agent-hooks-architecture.md +418 -0
- package/template/claude-task-manager/docs/conversation-import-freshness.md +20 -0
- package/template/claude-task-manager/docs/google-workspace-auth-health.md +77 -0
- package/template/claude-task-manager/docs/image-paste-ux.md +13 -0
- package/template/claude-task-manager/docs/ipad-web-preview.md +88 -0
- package/template/claude-task-manager/docs/main-loop-offload-architecture.md +66 -0
- package/template/claude-task-manager/docs/microsoft-dev-tunnel-phone-access-design.md +274 -519
- package/template/claude-task-manager/docs/mobile-live-streaming.md +27 -5
- package/template/claude-task-manager/docs/mobile-remote-submission-lifecycle.md +69 -0
- package/template/claude-task-manager/docs/phone-access-design.md +53 -15
- package/template/claude-task-manager/docs/phone-passkey-identity.md +122 -0
- package/template/claude-task-manager/docs/phone-setup.md +3 -0
- package/template/claude-task-manager/docs/prompt-editing-tree-design.md +25 -1
- package/template/claude-task-manager/docs/remote-desktop-access-design.md +268 -0
- package/template/claude-task-manager/docs/restart-lifecycle-architecture.md +95 -0
- package/template/claude-task-manager/docs/runtime-work-control-plane.md +53 -0
- package/template/claude-task-manager/docs/session-interactive-wait-surfaces.md +38 -0
- package/template/claude-task-manager/docs/session-needs-you-dismissal.md +84 -0
- package/template/claude-task-manager/docs/session-render-state-management-design.md +91 -3
- package/template/claude-task-manager/docs/session-standup-command-center-design.md +25 -1
- package/template/claude-task-manager/docs/session-title-authority.md +32 -0
- package/template/claude-task-manager/docs/session-workspace-binding.md +33 -0
- package/template/claude-task-manager/docs/skill-intent-resolution-design.md +72 -0
- package/template/claude-task-manager/docs/walle-mcp-supervisor-health.md +86 -0
- package/template/claude-task-manager/docs/walle-relay-phone-access-design.md +24 -15
- package/template/claude-task-manager/docs/walle-session-history-hydration.md +114 -0
- package/template/claude-task-manager/docs/walle-session-input-queue.md +104 -0
- package/template/claude-task-manager/docs/walle-session-model-catalog.md +90 -0
- package/template/claude-task-manager/docs/walle-session-model-preferences.md +15 -6
- package/template/claude-task-manager/git-utils.js +897 -27
- package/template/claude-task-manager/lib/agent-capabilities.js +33 -0
- package/template/claude-task-manager/lib/agent-cli-cache.js +37 -7
- package/template/claude-task-manager/lib/agent-hooks-installer.js +26 -2
- package/template/claude-task-manager/lib/agent-presets.js +17 -1
- package/template/claude-task-manager/lib/all-sessions-query.js +108 -0
- package/template/claude-task-manager/lib/approval-ai-refinement.js +488 -0
- package/template/claude-task-manager/lib/approval-self-adapt.js +168 -0
- package/template/claude-task-manager/lib/async-semaphore.js +44 -0
- package/template/claude-task-manager/lib/auth-context.js +5 -0
- package/template/claude-task-manager/lib/auth-rate-limit.js +47 -4
- package/template/claude-task-manager/lib/auth-rules.js +29 -2
- package/template/claude-task-manager/lib/auto-approval-verifier.js +129 -16
- package/template/claude-task-manager/lib/background-llm.js +144 -17
- package/template/claude-task-manager/lib/branch-inventory.js +212 -0
- package/template/claude-task-manager/lib/claude-desktop-sessions.js +15 -3
- package/template/claude-task-manager/lib/coalesce-sync-frames.js +151 -0
- package/template/claude-task-manager/lib/codex-launch-health.js +762 -0
- package/template/claude-task-manager/lib/codex-transcript-pager.js +51 -0
- package/template/claude-task-manager/lib/codex-zst.js +124 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +233 -30
- package/template/claude-task-manager/lib/connection-health.js +232 -0
- package/template/claude-task-manager/lib/conversation-blob-parser.js +42 -0
- package/template/claude-task-manager/lib/conversation-tail-merge.js +89 -26
- package/template/claude-task-manager/lib/ctm-session-context-api.js +39 -10
- package/template/claude-task-manager/lib/cursor-conversation-store.js +354 -0
- package/template/claude-task-manager/lib/db-owner-worker-client.js +315 -0
- package/template/claude-task-manager/lib/document-review.js +141 -6
- package/template/claude-task-manager/lib/escalation-review.js +152 -0
- package/template/claude-task-manager/lib/graceful-shutdown.js +159 -0
- package/template/claude-task-manager/lib/headless-term-service.js +678 -0
- package/template/claude-task-manager/lib/heavy-worker-fallback.js +38 -0
- package/template/claude-task-manager/lib/jsonl-conversation-parser.js +542 -0
- package/template/claude-task-manager/lib/jsonl-range-reader.js +112 -0
- package/template/claude-task-manager/lib/main-db-census.js +216 -0
- package/template/claude-task-manager/lib/message-pagination.js +106 -4
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +750 -26
- package/template/claude-task-manager/lib/mobile-auth-api.js +274 -7
- package/template/claude-task-manager/lib/mobile-auth-store.js +592 -10
- package/template/claude-task-manager/lib/mobile-notification-dispatcher.js +15 -0
- package/template/claude-task-manager/lib/model-overview-brain-fallback.js +311 -0
- package/template/claude-task-manager/lib/model-overview-cache.js +141 -0
- package/template/claude-task-manager/lib/models-health-routing-notice.js +126 -0
- package/template/claude-task-manager/lib/node-pin-guard.js +93 -0
- package/template/claude-task-manager/lib/perf-tracker.js +242 -6
- package/template/claude-task-manager/lib/permission-match.js +76 -0
- package/template/claude-task-manager/lib/permission-sync.js +133 -20
- package/template/claude-task-manager/lib/process-title.js +35 -0
- package/template/claude-task-manager/lib/prompt-executions-query.js +25 -0
- package/template/claude-task-manager/lib/prompt-index-disk-cache.js +44 -0
- package/template/claude-task-manager/lib/prompt-intent.js +132 -0
- package/template/claude-task-manager/lib/provider-user-context.js +34 -0
- package/template/claude-task-manager/lib/read-pool-client.js +313 -0
- package/template/claude-task-manager/lib/readpool-breaker.js +31 -0
- package/template/claude-task-manager/lib/recent-sessions-breaker.js +12 -0
- package/template/claude-task-manager/lib/remote-feedback-client.js +72 -0
- package/template/claude-task-manager/lib/remote-relay-protocol.js +37 -4
- package/template/claude-task-manager/lib/remote-relay-store.js +159 -0
- package/template/claude-task-manager/lib/remote-submission-observer.js +278 -0
- package/template/claude-task-manager/lib/restart-guard.js +109 -0
- package/template/claude-task-manager/lib/restore-interruption-detector.js +439 -0
- package/template/claude-task-manager/lib/restore-policy.js +13 -0
- package/template/claude-task-manager/lib/restore-resume-batch.js +74 -0
- package/template/claude-task-manager/lib/restore-runtime.js +68 -0
- package/template/claude-task-manager/lib/restore-storm.js +34 -0
- package/template/claude-task-manager/lib/resume-cwd.js +36 -0
- package/template/claude-task-manager/lib/resume-preflight.js +313 -0
- package/template/claude-task-manager/lib/runtime-work-registry.js +444 -0
- package/template/claude-task-manager/lib/sanitize-openai-auth.js +31 -0
- package/template/claude-task-manager/lib/scheduler.js +21 -1
- package/template/claude-task-manager/lib/scrollback-snapshot-store.js +159 -0
- package/template/claude-task-manager/lib/serial-task-queue.js +64 -0
- package/template/claude-task-manager/lib/server-listeners.js +239 -0
- package/template/claude-task-manager/lib/session-capture.js +42 -7
- package/template/claude-task-manager/lib/session-content-backfill.js +131 -0
- package/template/claude-task-manager/lib/session-history.js +388 -43
- package/template/claude-task-manager/lib/session-host-manager.js +287 -0
- package/template/claude-task-manager/lib/session-image-refs.js +209 -0
- package/template/claude-task-manager/lib/session-jobs.js +399 -59
- package/template/claude-task-manager/lib/session-prompt-index.js +137 -0
- package/template/claude-task-manager/lib/session-restore.js +53 -0
- package/template/claude-task-manager/lib/session-standup.js +123 -23
- package/template/claude-task-manager/lib/session-state-bus.js +14 -0
- package/template/claude-task-manager/lib/session-stream.js +64 -16
- package/template/claude-task-manager/lib/session-timeline-summary.js +260 -0
- package/template/claude-task-manager/lib/session-token-usage.js +494 -0
- package/template/claude-task-manager/lib/session-workspace-binding.js +356 -0
- package/template/claude-task-manager/lib/setup-network-config.js +9 -0
- package/template/claude-task-manager/lib/size-cap.js +45 -0
- package/template/claude-task-manager/lib/size-cap.test.js +62 -0
- package/template/claude-task-manager/lib/skill-autocomplete.js +180 -1
- package/template/claude-task-manager/lib/skill-intent-resolver.js +304 -0
- package/template/claude-task-manager/lib/sqlite-driver.js +19 -3
- package/template/claude-task-manager/lib/standup-attention.js +7 -3
- package/template/claude-task-manager/lib/status-authority.js +39 -0
- package/template/claude-task-manager/lib/status-hooks.js +4 -0
- package/template/claude-task-manager/lib/storage-migration.js +235 -0
- package/template/claude-task-manager/lib/structured-capture.js +298 -0
- package/template/claude-task-manager/lib/sync-io-census.js +163 -0
- package/template/claude-task-manager/lib/tailscale-setup.js +6 -0
- package/template/claude-task-manager/lib/terminal-activity-evidence.js +33 -0
- package/template/claude-task-manager/lib/terminal-choice.js +364 -0
- package/template/claude-task-manager/lib/terminal-control-sanitize.js +17 -0
- package/template/claude-task-manager/lib/terminal-fingerprint.js +48 -0
- package/template/claude-task-manager/lib/terminal-output-flush.js +84 -0
- package/template/claude-task-manager/lib/timeline-order.js +122 -0
- package/template/claude-task-manager/lib/transcript-store.js +348 -43
- package/template/claude-task-manager/lib/transport-security.js +84 -1
- package/template/claude-task-manager/lib/wait-state.js +184 -0
- package/template/claude-task-manager/lib/walle-client.js +47 -5
- package/template/claude-task-manager/lib/walle-ctm-history.js +564 -4
- package/template/claude-task-manager/lib/walle-external-actions.js +135 -16
- package/template/claude-task-manager/lib/walle-history-hydration.js +46 -0
- package/template/claude-task-manager/lib/walle-native-health.js +403 -0
- package/template/claude-task-manager/lib/walle-repair.js +701 -0
- package/template/claude-task-manager/lib/walle-session-cache.js +109 -0
- package/template/claude-task-manager/lib/walle-session-context.js +57 -21
- package/template/claude-task-manager/lib/walle-session-model-catalog.js +34 -0
- package/template/claude-task-manager/lib/walle-supervisor.js +539 -63
- package/template/claude-task-manager/lib/walle-transcript.js +52 -0
- package/template/claude-task-manager/lib/worktree-active-sync.js +11 -7
- package/template/claude-task-manager/lib/worktree-cwd.js +32 -1
- package/template/claude-task-manager/package.json +1 -1
- package/template/claude-task-manager/prompt-harvest.js +89 -66
- package/template/claude-task-manager/providers/claude-code.js +51 -3
- package/template/claude-task-manager/providers/cursor.js +140 -45
- package/template/claude-task-manager/public/css/reviews.css +551 -61
- package/template/claude-task-manager/public/css/setup.css +191 -0
- package/template/claude-task-manager/public/css/walle-session.css +865 -10
- package/template/claude-task-manager/public/css/walle.css +154 -0
- package/template/claude-task-manager/public/designs/ai-providers-consolidation-v2.html +830 -0
- package/template/claude-task-manager/public/index.html +18516 -2058
- package/template/claude-task-manager/public/ipad.html +363 -0
- package/template/claude-task-manager/public/js/document-review-links.js +301 -0
- package/template/claude-task-manager/public/js/image-normalize.js +69 -36
- package/template/claude-task-manager/public/js/message-renderer.js +1265 -77
- package/template/claude-task-manager/public/js/prompts.js +66 -29
- package/template/claude-task-manager/public/js/reviews.js +901 -133
- package/template/claude-task-manager/public/js/session-activity-utils.js +11 -1
- package/template/claude-task-manager/public/js/session-search-utils.js +94 -10
- package/template/claude-task-manager/public/js/session-status-precedence.js +23 -5
- package/template/claude-task-manager/public/js/setup.js +1273 -176
- package/template/claude-task-manager/public/js/stream-view.js +691 -73
- package/template/claude-task-manager/public/js/terminal-reconciler.js +210 -0
- package/template/claude-task-manager/public/js/walle-session.js +2455 -158
- package/template/claude-task-manager/public/js/walle.js +455 -28
- package/template/claude-task-manager/public/m/app.css +2909 -262
- package/template/claude-task-manager/public/m/app.js +6601 -398
- package/template/claude-task-manager/public/m/claim.html +224 -17
- package/template/claude-task-manager/public/m/index.html +117 -21
- package/template/claude-task-manager/public/m/sw.js +3 -1
- package/template/claude-task-manager/public/manifest.json +2 -2
- package/template/claude-task-manager/public/prompts.html +30 -14
- package/template/claude-task-manager/queue-engine.js +507 -28
- package/template/claude-task-manager/scripts/repair-claude-session-images.js +27 -8
- package/template/claude-task-manager/server.js +14341 -2197
- package/template/claude-task-manager/session-integrity.js +160 -18
- package/template/claude-task-manager/session-search-ranking.js +1 -0
- package/template/claude-task-manager/session-utils.js +25 -5
- package/template/claude-task-manager/workers/approval-blocklist.js +96 -6
- package/template/claude-task-manager/workers/approval-widget-validator.js +14 -8
- package/template/claude-task-manager/workers/conversation-import-worker.js +11 -50
- package/template/claude-task-manager/workers/db-owner-worker.js +386 -0
- package/template/claude-task-manager/workers/harvest-worker.js +9 -55
- package/template/claude-task-manager/workers/headless-term-worker.js +9 -530
- package/template/claude-task-manager/workers/read-pool-worker.js +387 -0
- package/template/claude-task-manager/workers/scrollback-worker.js +11 -72
- package/template/claude-task-manager/workers/session-host-process.js +146 -0
- package/template/claude-task-manager/workers/session-integrity-worker.js +10 -54
- package/template/claude-task-manager/workers/state-detectors/base.js +18 -1
- package/template/claude-task-manager/workers/state-detectors/claude-code.js +182 -9
- package/template/claude-task-manager/workers/state-detectors/codex.js +150 -2
- package/template/claude-task-manager/workers/state-detectors/cursor.js +127 -0
- package/template/claude-task-manager/workers/state-detectors/gemini.js +21 -0
- package/template/claude-task-manager/workers/state-detectors/index.js +29 -0
- package/template/claude-task-manager/workers/state-detectors/opencode.js +103 -0
- package/template/docs/design/markdown-review-pane.md +206 -0
- package/template/docs/designs/2026-05-17-portkey-gateway-provider-ux.md +129 -38
- package/template/docs/designs/2026-05-20-mobile-worktree-finish-command.md +27 -0
- package/template/docs/designs/2026-05-22-ai-configuration-consolidation.md +248 -0
- package/template/docs/designs/ai-configuration-consolidation-mock.html +812 -0
- package/template/docs/private-memory-and-pii-policy.md +69 -0
- package/template/package.json +2 -1
- package/template/scripts/check-private-data.js +201 -0
- package/template/shared/sqlite-owner-guard.js +30 -0
- package/template/shared/sqlite-owner-write-queue.js +225 -0
- package/template/shared/sqlite-storage-policy.js +111 -0
- package/template/shared/sqlite-write-lock.js +428 -0
- package/template/wall-e/agent-runners/claude-code.js +5 -0
- package/template/wall-e/agent.js +166 -22
- package/template/wall-e/api-walle.js +524 -70
- package/template/wall-e/auth/provider-flows.js +11 -1
- package/template/wall-e/bin/walle-mcp-stdio.js +341 -17
- package/template/wall-e/brain.js +1614 -141
- package/template/wall-e/chat/attachment-blocks.js +96 -0
- package/template/wall-e/chat/attachments.js +2 -1
- package/template/wall-e/chat/capability-resolver.js +7 -7
- package/template/wall-e/chat/context-messages.js +28 -0
- package/template/wall-e/chat/conversation-frame.js +630 -0
- package/template/wall-e/chat/provider-messages.js +125 -0
- package/template/wall-e/chat.js +1002 -233
- package/template/wall-e/coding/acceptance-contract.js +170 -0
- package/template/wall-e/coding/acp-adapter.js +1 -1
- package/template/wall-e/coding/agent-catalog.js +3 -0
- package/template/wall-e/coding/artifact-store.js +93 -0
- package/template/wall-e/coding/capability-router.js +120 -0
- package/template/wall-e/coding/coding-run-controller.js +423 -0
- package/template/wall-e/coding/compaction-service.js +157 -12
- package/template/wall-e/coding/frontend-verification.js +258 -0
- package/template/wall-e/coding/lifecycle-hooks.js +75 -0
- package/template/wall-e/coding/local-preview-contract.js +157 -0
- package/template/wall-e/coding/permission-service.js +57 -13
- package/template/wall-e/coding/prompt-bundle.js +19 -1
- package/template/wall-e/coding/prompt-section-registry.js +227 -0
- package/template/wall-e/coding/provider-compat.js +15 -0
- package/template/wall-e/coding/runtime-events.js +224 -0
- package/template/wall-e/coding/runtime-mode.js +3 -0
- package/template/wall-e/coding/side-git-snapshot.js +160 -4
- package/template/wall-e/coding/snapshot-service.js +143 -1
- package/template/wall-e/coding/stream-processor.js +388 -34
- package/template/wall-e/coding/task-tool.js +141 -4
- package/template/wall-e/coding/tool-execution-controller.js +365 -0
- package/template/wall-e/coding/tool-registry.js +43 -5
- package/template/wall-e/coding/user-hooks.js +217 -0
- package/template/wall-e/coding-orchestrator.js +1330 -221
- package/template/wall-e/coding-prompts.js +20 -4
- package/template/wall-e/context/context-builder.js +15 -2
- package/template/wall-e/decision/confidence.js +1 -1
- package/template/wall-e/docs/coding-acceptance-contract.md +41 -0
- package/template/wall-e/docs/external-action-controller.md +26 -6
- package/template/wall-e/docs/telemetry-lifecycle.md +8 -2
- package/template/wall-e/embeddings.js +591 -53
- package/template/wall-e/external-action-controller.js +12 -0
- package/template/wall-e/http/auth.js +1 -0
- package/template/wall-e/http/chat-api.js +46 -11
- package/template/wall-e/http/model-admin.js +836 -34
- package/template/wall-e/lib/boot-profile.js +88 -0
- package/template/wall-e/lib/event-loop-monitor.js +93 -0
- package/template/wall-e/lib/service-health.js +194 -0
- package/template/wall-e/llm/anthropic.js +130 -5
- package/template/wall-e/llm/client.js +266 -63
- package/template/wall-e/llm/default-fallback.js +382 -0
- package/template/wall-e/llm/health.js +19 -0
- package/template/wall-e/llm/message-guard.js +78 -0
- package/template/wall-e/llm/model-catalog.js +252 -1
- package/template/wall-e/llm/openai.js +26 -4
- package/template/wall-e/llm/portkey-sync.js +654 -0
- package/template/wall-e/llm/provider-error.js +30 -2
- package/template/wall-e/llm/registry.js +5 -1
- package/template/wall-e/llm/request-compat.js +67 -0
- package/template/wall-e/loops/backfill.js +79 -23
- package/template/wall-e/loops/brain-optimize.js +67 -0
- package/template/wall-e/loops/ingest.js +25 -10
- package/template/wall-e/loops/question-digest.js +160 -0
- package/template/wall-e/loops/reflect.js +6 -4
- package/template/wall-e/loops/think.js +39 -12
- package/template/wall-e/mcp-server.js +318 -36
- package/template/wall-e/memory/ctm-context-client.js +52 -14
- package/template/wall-e/memory/ctm-operational-context.js +237 -0
- package/template/wall-e/memory/ctm-prompt-executions-client.js +128 -0
- package/template/wall-e/memory/ctm-session-context.js +111 -63
- package/template/wall-e/prompts/coding/deepseek.txt +3 -0
- package/template/wall-e/prompts/coding/gemini.txt +6 -0
- package/template/wall-e/prompts/coding/gpt.txt +6 -0
- package/template/wall-e/prompts/coding/local.txt +7 -0
- package/template/wall-e/runtime/decision-hooks.js +115 -0
- package/template/wall-e/runtime/devbox-gateway.js +82 -8
- package/template/wall-e/runtime/prompt-manifest.js +86 -0
- package/template/wall-e/runtime/tool-executor.js +269 -0
- package/template/wall-e/runtime/tool-result-envelope.js +138 -0
- package/template/wall-e/runtime/transcript-projection.js +60 -0
- package/template/wall-e/runtime/walle-runtime.js +224 -0
- package/template/wall-e/scripts/db-optimize/migrate.js +162 -0
- package/template/wall-e/scripts/db-optimize/recall-eval.js +117 -0
- package/template/wall-e/server.js +15 -0
- package/template/wall-e/session-files.js +9 -0
- package/template/wall-e/skills/_bundled/google-calendar/run.js +1 -1
- package/template/wall-e/skills/_bundled/gws-workspace/run.js +1 -1
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +76 -6
- package/template/wall-e/skills/claude-code-reader.js +7 -3
- package/template/wall-e/skills/script-skill-runner.js +10 -0
- package/template/wall-e/skills/skill-planner.js +38 -0
- package/template/wall-e/tools/builtin-middleware.js +19 -9
- package/template/wall-e/tools/local-tools.js +1428 -16
- package/template/wall-e/tools/permission-checker.js +73 -5
- package/template/wall-e/tools/question-manager.js +117 -7
- package/template/wall-e/training/harvester.js +12 -28
- package/template/wall-e/training/replay.js +25 -80
- package/template/website/index.html +10 -10
- package/template/wall-e/eval/ab-test.js +0 -203
- package/template/wall-e/eval/agent-runner.js +0 -772
- package/template/wall-e/eval/agent-scorer.js +0 -461
- package/template/wall-e/eval/aggregator.js +0 -414
- package/template/wall-e/eval/allowed-test-commands.js +0 -34
- package/template/wall-e/eval/benchmark-generator.js +0 -113
- package/template/wall-e/eval/benchmarks/chat-eval.json +0 -1662
- package/template/wall-e/eval/benchmarks/chat.json +0 -82
- package/template/wall-e/eval/benchmarks/coding-agent-real.json +0 -1
- package/template/wall-e/eval/benchmarks/coding-agent.json +0 -1581
- package/template/wall-e/eval/benchmarks/coding.json +0 -122
- package/template/wall-e/eval/benchmarks/memory-retrieval.json +0 -234
- package/template/wall-e/eval/benchmarks/reasoning.json +0 -82
- package/template/wall-e/eval/benchmarks/swebench-lite-30.json +0 -212
- package/template/wall-e/eval/benchmarks.js +0 -669
- package/template/wall-e/eval/cc-replay.js +0 -719
- package/template/wall-e/eval/chat-eval.js +0 -525
- package/template/wall-e/eval/check-keys.js +0 -15
- package/template/wall-e/eval/check-providers.js +0 -42
- package/template/wall-e/eval/codex-cli-baseline.js +0 -669
- package/template/wall-e/eval/coding-agent-real.js +0 -570
- package/template/wall-e/eval/context-compactor.js +0 -251
- package/template/wall-e/eval/debug-agent003.js +0 -68
- package/template/wall-e/eval/diagnostics.js +0 -216
- package/template/wall-e/eval/eval-orchestrator.js +0 -642
- package/template/wall-e/eval/evaluate.js +0 -202
- package/template/wall-e/eval/evaluator.js +0 -373
- package/template/wall-e/eval/exporter.js +0 -212
- package/template/wall-e/eval/fixtures/express-basic/package.json +0 -9
- package/template/wall-e/eval/fixtures/express-basic/server.js +0 -115
- package/template/wall-e/eval/fixtures/express-basic/test.js +0 -83
- package/template/wall-e/eval/fixtures/express-buggy/package.json +0 -9
- package/template/wall-e/eval/fixtures/express-buggy/server.js +0 -113
- package/template/wall-e/eval/fixtures/express-buggy/test.js +0 -83
- package/template/wall-e/eval/fixtures/express-buggy-items/package.json +0 -9
- package/template/wall-e/eval/fixtures/express-buggy-items/server.js +0 -112
- package/template/wall-e/eval/fixtures/express-buggy-items/test.js +0 -83
- package/template/wall-e/eval/fixtures/express-buggy-search/package.json +0 -9
- package/template/wall-e/eval/fixtures/express-buggy-search/server.js +0 -121
- package/template/wall-e/eval/fixtures/express-buggy-search/test.js +0 -83
- package/template/wall-e/eval/fixtures/express-rename-data/data.js +0 -34
- package/template/wall-e/eval/fixtures/express-rename-data/package.json +0 -9
- package/template/wall-e/eval/fixtures/express-rename-data/server.js +0 -97
- package/template/wall-e/eval/fixtures/express-rename-data/test.js +0 -88
- package/template/wall-e/eval/fixtures/express-xss/package.json +0 -12
- package/template/wall-e/eval/fixtures/express-xss/server.js +0 -90
- package/template/wall-e/eval/fixtures/express-xss/test.js +0 -67
- package/template/wall-e/eval/fixtures/express-xss/views/profile.ejs +0 -9
- package/template/wall-e/eval/fixtures/fullstack-app/config/default.js +0 -9
- package/template/wall-e/eval/fixtures/fullstack-app/config/test.js +0 -13
- package/template/wall-e/eval/fixtures/fullstack-app/package.json +0 -11
- package/template/wall-e/eval/fixtures/fullstack-app/public/css/style.css +0 -137
- package/template/wall-e/eval/fixtures/fullstack-app/public/index.html +0 -46
- package/template/wall-e/eval/fixtures/fullstack-app/public/js/app.js +0 -121
- package/template/wall-e/eval/fixtures/fullstack-app/public/js/auth.js +0 -71
- package/template/wall-e/eval/fixtures/fullstack-app/public/js/items.js +0 -80
- package/template/wall-e/eval/fixtures/fullstack-app/public/js/users.js +0 -46
- package/template/wall-e/eval/fixtures/fullstack-app/public/login.html +0 -45
- package/template/wall-e/eval/fixtures/fullstack-app/public/register.html +0 -38
- package/template/wall-e/eval/fixtures/fullstack-app/scripts/migrate.js +0 -23
- package/template/wall-e/eval/fixtures/fullstack-app/scripts/seed.js +0 -46
- package/template/wall-e/eval/fixtures/fullstack-app/server/db.js +0 -99
- package/template/wall-e/eval/fixtures/fullstack-app/server/index.js +0 -94
- package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/auth.js +0 -19
- package/template/wall-e/eval/fixtures/fullstack-app/server/middleware/logger.js +0 -19
- package/template/wall-e/eval/fixtures/fullstack-app/server/router.js +0 -50
- package/template/wall-e/eval/fixtures/fullstack-app/server/routes/auth.js +0 -69
- package/template/wall-e/eval/fixtures/fullstack-app/server/routes/health.js +0 -23
- package/template/wall-e/eval/fixtures/fullstack-app/server/routes/items.js +0 -88
- package/template/wall-e/eval/fixtures/fullstack-app/server/routes/users.js +0 -75
- package/template/wall-e/eval/fixtures/fullstack-app/server/test.js +0 -198
- package/template/wall-e/eval/fixtures/fullstack-app/server/utils/response.js +0 -34
- package/template/wall-e/eval/fixtures/fullstack-app/server/utils/validate.js +0 -26
- package/template/wall-e/eval/fixtures/fullstack-app/server.js +0 -8
- package/template/wall-e/eval/fixtures/fullstack-app/test.js +0 -12
- package/template/wall-e/eval/fixtures/monorepo-basic/package.json +0 -8
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/data.js +0 -58
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/middleware.js +0 -46
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/package.json +0 -8
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/routes.js +0 -64
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/server.js +0 -56
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/api/test.js +0 -116
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/commands.js +0 -61
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/index.js +0 -62
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/output.js +0 -43
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/package.json +0 -11
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/cli/test.js +0 -44
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/formatters.js +0 -43
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/index.js +0 -12
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/package.json +0 -5
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/test.js +0 -55
- package/template/wall-e/eval/fixtures/monorepo-basic/packages/shared/validators.js +0 -29
- package/template/wall-e/eval/fixtures/monorepo-basic/test.js +0 -46
- package/template/wall-e/eval/fixtures/node-cli/index.js +0 -78
- package/template/wall-e/eval/fixtures/node-cli/package.json +0 -10
- package/template/wall-e/eval/fixtures/node-cli/test.js +0 -57
- package/template/wall-e/eval/fixtures/node-typed/package.json +0 -8
- package/template/wall-e/eval/fixtures/node-typed/src/handlers.js +0 -31
- package/template/wall-e/eval/fixtures/node-typed/src/utils.js +0 -33
- package/template/wall-e/eval/fixtures/node-typed/test.js +0 -36
- package/template/wall-e/eval/fixtures/python-flask/app.py +0 -14
- package/template/wall-e/eval/fixtures/python-flask/requirements.txt +0 -2
- package/template/wall-e/eval/fixtures/python-flask/test_app.py +0 -25
- package/template/wall-e/eval/fixtures/wall-e-subset/brain.js +0 -105
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/aggregator.js +0 -101
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/chat.json +0 -20
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks/coding.json +0 -32
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/benchmarks.js +0 -64
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/package.json +0 -6
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/server.js +0 -31
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/test.js +0 -18
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/fixtures/simple-project/utils.js +0 -34
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/runner.js +0 -104
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/scorer.js +0 -73
- package/template/wall-e/eval/fixtures/wall-e-subset/eval/test.js +0 -134
- package/template/wall-e/eval/fixtures/wall-e-subset/llm/client.js +0 -99
- package/template/wall-e/eval/fixtures/wall-e-subset/llm/providers.js +0 -63
- package/template/wall-e/eval/fixtures/wall-e-subset/llm/test.js +0 -70
- package/template/wall-e/eval/fixtures/wall-e-subset/package.json +0 -10
- package/template/wall-e/eval/fixtures/wall-e-subset/test.js +0 -86
- package/template/wall-e/eval/harvester.js +0 -685
- package/template/wall-e/eval/head-to-head.js +0 -388
- package/template/wall-e/eval/humaneval-adapter.js +0 -321
- package/template/wall-e/eval/list-models.js +0 -31
- package/template/wall-e/eval/livecodebench-adapter.js +0 -291
- package/template/wall-e/eval/mail-integration.js +0 -443
- package/template/wall-e/eval/manifest.js +0 -186
- package/template/wall-e/eval/meta-harness/adapters/coding-agent.js +0 -57
- package/template/wall-e/eval/meta-harness/bootstrap-snapshot.js +0 -149
- package/template/wall-e/eval/meta-harness/candidate-store.js +0 -117
- package/template/wall-e/eval/meta-harness/cli.js +0 -86
- package/template/wall-e/eval/meta-harness/domain-spec.js +0 -154
- package/template/wall-e/eval/meta-harness/domains/coding-agent.domain.json +0 -84
- package/template/wall-e/eval/meta-harness/examples/env-bootstrap-candidate.js +0 -29
- package/template/wall-e/eval/meta-harness/experience-store.js +0 -174
- package/template/wall-e/eval/meta-harness/frontier.js +0 -96
- package/template/wall-e/eval/meta-harness/harness-interface.js +0 -90
- package/template/wall-e/eval/meta-harness/leakage-guard.js +0 -80
- package/template/wall-e/eval/meta-harness/optimizer.js +0 -207
- package/template/wall-e/eval/meta-harness/proposer-runner.js +0 -110
- package/template/wall-e/eval/meta-harness/reporting.js +0 -58
- package/template/wall-e/eval/meta-harness/telemetry.js +0 -27
- package/template/wall-e/eval/meta-harness/validation.js +0 -81
- package/template/wall-e/eval/promoter.js +0 -228
- package/template/wall-e/eval/provider-normalizer.js +0 -33
- package/template/wall-e/eval/replay.js +0 -395
- package/template/wall-e/eval/run-agent-benchmarks.js +0 -386
- package/template/wall-e/eval/run-codex-cli-baseline.js +0 -177
- package/template/wall-e/eval/run-coding-agent-real.js +0 -187
- package/template/wall-e/eval/run-eval.js +0 -435
- package/template/wall-e/eval/run-model-comparison.js +0 -142
- package/template/wall-e/eval/session-evaluator.js +0 -187
- package/template/wall-e/eval/session-miner.js +0 -207
- package/template/wall-e/eval/session-retrieval-benchmark.js +0 -150
- package/template/wall-e/eval/session-transcripts.js +0 -509
- package/template/wall-e/eval/shadow.js +0 -161
- package/template/wall-e/eval/swebench-adapter.js +0 -345
- package/template/wall-e/eval/swebench-docker.js +0 -192
- package/template/wall-e/eval/train.py +0 -320
- package/template/wall-e/eval/trainer.js +0 -232
- package/template/wall-e/eval/weekly-eval-loop.js +0 -241
|
@@ -5,28 +5,91 @@ const os = require('os');
|
|
|
5
5
|
const db = require('./db');
|
|
6
6
|
const queueEngine = require('./queue-engine');
|
|
7
7
|
const harvest = require('./prompt-harvest');
|
|
8
|
-
const
|
|
8
|
+
const approvalAgent = require('./approval-agent');
|
|
9
|
+
const escalationReview = require('./lib/escalation-review');
|
|
10
|
+
// permission-sync (Claude/Codex settings mirroring) intentionally removed —
|
|
11
|
+
// CTM permissions are a standalone global config in the CTM DB.
|
|
9
12
|
const walleClient = require('./lib/walle-client');
|
|
10
13
|
const claudeDesktopSessions = require('./lib/claude-desktop-sessions');
|
|
11
14
|
const skillAutocomplete = require('./lib/skill-autocomplete');
|
|
15
|
+
const skillIntentResolver = require('./lib/skill-intent-resolver');
|
|
12
16
|
const resourceLinks = require('./lib/resource-links');
|
|
17
|
+
const storageMigration = require('./lib/storage-migration');
|
|
18
|
+
const { runSync } = require('./lib/perf-tracker');
|
|
13
19
|
const {
|
|
14
20
|
ingestJsonlFile,
|
|
15
21
|
normalizeProvider: normalizeTranscriptProvider,
|
|
16
22
|
sourceIdFromPath: transcriptSourceIdFromPath,
|
|
17
23
|
} = require('./lib/transcript-store');
|
|
24
|
+
const { queryPromptExecutions } = require('./lib/prompt-executions-query');
|
|
25
|
+
const { claudeFileSessionId, _usageMetadata } = require('./lib/jsonl-conversation-parser');
|
|
26
|
+
const structuredCapture = require('./lib/structured-capture');
|
|
18
27
|
// AI search uses direct HTTP calls to Claude API (supports Portkey proxy)
|
|
19
28
|
|
|
20
29
|
let dbMaintenanceRunner = null;
|
|
30
|
+
let imageSaveRunner = null;
|
|
21
31
|
const MOBILE_ATTACHMENT_MAX_BYTES = 10 * 1024 * 1024;
|
|
22
32
|
const TRANSCRIPT_IMPORT_MAX_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_MAX_BYTES || 10 * 1024 * 1024));
|
|
23
33
|
const TRANSCRIPT_IMPORT_LARGE_FILE_BYTES = Math.max(1024 * 1024, Number(process.env.CTM_TRANSCRIPT_IMPORT_LARGE_FILE_BYTES || 64 * 1024 * 1024));
|
|
24
34
|
const CONVERSATION_IMPORT_RETRY_AFTER_MS = 30 * 1000;
|
|
35
|
+
const BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES = 2 * 1024 * 1024;
|
|
25
36
|
|
|
26
37
|
function setDbMaintenanceRunner(fn) {
|
|
27
38
|
dbMaintenanceRunner = typeof fn === 'function' ? fn : null;
|
|
28
39
|
}
|
|
29
40
|
|
|
41
|
+
function setImageSaveRunner(fn) {
|
|
42
|
+
imageSaveRunner = typeof fn === 'function' ? fn : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// deferRow=true returns as soon as the file is on disk (id:null), firing the
|
|
46
|
+
// images-table INSERT in the background — used by the terminal paste hot path,
|
|
47
|
+
// which forwards the file path/token to the provider and never needs the row id.
|
|
48
|
+
// Callers that need the real id (feedback attachments, prompt-editor annotation)
|
|
49
|
+
// leave deferRow falsy and get the awaited row.
|
|
50
|
+
async function saveImageViaOwner(promptId, buffer, filename, mimeType, deferRow = false) {
|
|
51
|
+
if (imageSaveRunner) {
|
|
52
|
+
return await imageSaveRunner({ promptId, buffer, filename, mimeType, deferRow });
|
|
53
|
+
}
|
|
54
|
+
return await db.saveImageDecoupled(promptId, buffer, filename, mimeType, { deferRow });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Measurement scaffolding for "image paste sometimes takes a long time". Logs one
|
|
58
|
+
// `[img-upload]` line per save (→ ctm.log) and returns the result with the internal
|
|
59
|
+
// `_timing` stripped so it isn't sent to the client. `roundTripMs` is the full
|
|
60
|
+
// saveImageViaOwner/saveImageFromFile call; `queueMs` ≈ roundTrip minus the
|
|
61
|
+
// worker-side hash/write/insert (i.e. db-owner queue wait + IPC/structured-clone of
|
|
62
|
+
// the buffer). writeMs/copyMs is the suspected Dropbox file-provider cost.
|
|
63
|
+
function _logImageTiming(label, meta, result) {
|
|
64
|
+
try {
|
|
65
|
+
const t = (result && result._timing) || {};
|
|
66
|
+
const workMs = (t.hashMs || 0) + (t.writeMs || 0) + (t.copyMs || 0) + (t.insertMs || 0);
|
|
67
|
+
const rt = meta.roundTripMs;
|
|
68
|
+
const queueMs = rt != null ? Math.max(0, Math.round(rt - workMs)) : null;
|
|
69
|
+
const bytes = meta.bytes != null ? meta.bytes : (t.bytes != null ? t.bytes : '?');
|
|
70
|
+
const writePart = ('writeMs' in t)
|
|
71
|
+
? `writeMs=${t.writeMs}${t.wrote === false ? '(dedup)' : ''}`
|
|
72
|
+
: ('copyMs' in t ? `copyMs=${t.copyMs}` : 'writeMs=-');
|
|
73
|
+
// `path` (caller source, e.g. terminal/prompt/mobile) + `defer` (was the row INSERT
|
|
74
|
+
// deferred = the terminal fast path) disambiguate which upload route a slow line came
|
|
75
|
+
// from. defer=1 with insertMs=0 + tiny roundTrip is the terminal paste fast path;
|
|
76
|
+
// a slow line with defer=0 is a route that intentionally awaits the row (prompt
|
|
77
|
+
// editor / mobile / feedback).
|
|
78
|
+
const deferMark = (meta.defer != null) ? (meta.defer ? 1 : 0) : (t.deferred ? 1 : null);
|
|
79
|
+
console.log(
|
|
80
|
+
`[img-upload] ${label} bytes=${bytes}`
|
|
81
|
+
+ (meta.source ? ` path=${meta.source}` : '')
|
|
82
|
+
+ (deferMark != null ? ` defer=${deferMark}` : '')
|
|
83
|
+
+ (meta.readMs != null ? ` readMs=${Math.round(meta.readMs)}` : '')
|
|
84
|
+
+ (rt != null ? ` roundTripMs=${Math.round(rt)}` : '')
|
|
85
|
+
+ (queueMs != null ? ` queueMs=${queueMs}` : '')
|
|
86
|
+
+ ` hashMs=${t.hashMs != null ? t.hashMs : '-'} ${writePart} insertMs=${t.insertMs != null ? t.insertMs : '-'}`
|
|
87
|
+
);
|
|
88
|
+
} catch { /* never let logging break an upload */ }
|
|
89
|
+
if (result && result._timing) { const r = { ...result }; delete r._timing; return r; }
|
|
90
|
+
return result;
|
|
91
|
+
}
|
|
92
|
+
|
|
30
93
|
// Embed a prompt (async, fire-and-forget)
|
|
31
94
|
async function _embedPrompt(promptId, title, content) {
|
|
32
95
|
try {
|
|
@@ -142,7 +205,9 @@ function handlePromptApi(req, res, url) {
|
|
|
142
205
|
|
|
143
206
|
// --- Images ---
|
|
144
207
|
if (p === '/api/images/upload' && m === 'POST') return handleUploadImage(req, res, url);
|
|
208
|
+
if (p === '/api/images/ingest-path' && m === 'POST') return handleIngestImagePath(req, res);
|
|
145
209
|
if (p === '/api/mobile/attachments/upload' && m === 'POST') return handleUploadMobileAttachment(req, res, url);
|
|
210
|
+
if (p === '/api/session/image-refs' && m === 'POST') return handleSessionImageRefs(req, res);
|
|
146
211
|
if (p.match(/^\/api\/images\/\d+$/) && m === 'GET') return handleGetImage(req, res, url);
|
|
147
212
|
if (p.match(/^\/api\/images\/\d+\/annotations$/) && m === 'PUT') return handleUpdateAnnotations(req, res, url);
|
|
148
213
|
if (p.match(/^\/api\/images\/\d+$/) && m === 'DELETE') return handleDeleteImage(req, res, url);
|
|
@@ -194,6 +259,10 @@ function handlePromptApi(req, res, url) {
|
|
|
194
259
|
if (p === '/api/backups' && m === 'POST') return handleCreateBackup(req, res);
|
|
195
260
|
if (p === '/api/backups/restore' && m === 'POST') return handleRestoreBackup(req, res);
|
|
196
261
|
if (p.match(/^\/api\/backups\/[^/]+$/) && m === 'DELETE') return handleDeleteBackup(req, res, url);
|
|
262
|
+
if (p === '/api/storage/locations' && m === 'GET') return handleGetStorageLocations(req, res);
|
|
263
|
+
if (p === '/api/storage/backup-dirs' && m === 'PUT') return handlePutBackupDirs(req, res);
|
|
264
|
+
if (p === '/api/storage/migration/preview' && m === 'POST') return handlePreviewStorageMigration(req, res);
|
|
265
|
+
if (p === '/api/storage/migration/apply' && m === 'POST') return handleApplyStorageMigration(req, res);
|
|
197
266
|
|
|
198
267
|
// --- Tool Permissions (Claude Code native) ---
|
|
199
268
|
if (p === '/api/tool-permissions/scan' && m === 'POST') return handleScanToolUsage(req, res);
|
|
@@ -228,6 +297,10 @@ function handlePromptApi(req, res, url) {
|
|
|
228
297
|
return handleResolveApprovalDecision(req, res, parseInt(p.split('/')[3]));
|
|
229
298
|
}
|
|
230
299
|
|
|
300
|
+
// --- Escalation review (grouped "Needs Review" surface in the Permission page) ---
|
|
301
|
+
if (p === '/api/approval-escalations' && m === 'GET') return handleListEscalations(req, res);
|
|
302
|
+
if (p === '/api/approval-escalations/resolve' && m === 'POST') return handleResolveEscalations(req, res);
|
|
303
|
+
|
|
231
304
|
// --- Dangerous-command blocklist (opt-in safety net) ---
|
|
232
305
|
if (p === '/api/approval/blocklist' && m === 'GET') return handleGetBlocklist(req, res);
|
|
233
306
|
if (p === '/api/approval/blocklist' && m === 'POST') return handleSetBlocklistEnabled(req, res);
|
|
@@ -247,7 +320,7 @@ function handlePromptApi(req, res, url) {
|
|
|
247
320
|
if (draftMatch && m === 'PUT') return handleSaveQueueDraft(req, res, draftMatch[1]);
|
|
248
321
|
if (draftMatch && m === 'DELETE') return handleDeleteQueueDraft(req, res, draftMatch[1]);
|
|
249
322
|
const queueMatch = p.match(/^\/api\/queues\/([^/]+)$/);
|
|
250
|
-
const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode)$/);
|
|
323
|
+
const queueActionMatch = p.match(/^\/api\/queues\/([^/]+)\/(start|pause|resume|next|skip|stop|mode|remove|reorder)$/);
|
|
251
324
|
if (queueMatch && !queueActionMatch && m === 'GET') return handleGetQueue(req, res, queueMatch[1]);
|
|
252
325
|
if (queueMatch && !queueActionMatch && m === 'DELETE') return handleDeleteQueue(req, res, queueMatch[1]);
|
|
253
326
|
if (queueActionMatch && m === 'POST') return handleQueueAction(req, res, queueActionMatch[1], queueActionMatch[2]);
|
|
@@ -265,6 +338,7 @@ function handlePromptApi(req, res, url) {
|
|
|
265
338
|
if (p === '/api/copilot/chat' && m === 'POST') return handleCopilotChat(req, res);
|
|
266
339
|
if (p === '/api/prompt-quality' && m === 'GET') return handlePromptQuality(req, res, url);
|
|
267
340
|
if (p === '/api/prompt-executions' && m === 'GET') return handleListExecutions(req, res, url);
|
|
341
|
+
if (p === '/api/prompt-executions/stats' && m === 'GET') return handlePromptExecutionStats(req, res);
|
|
268
342
|
const execSessionMatch = p.match(/^\/api\/prompt-executions\/session\/([^/]+)$/);
|
|
269
343
|
if (execSessionMatch && m === 'GET') return handleSessionExecutions(req, res, execSessionMatch[1]);
|
|
270
344
|
const execOutcomeMatch = p.match(/^\/api\/prompt-executions\/(\d+)\/outcome$/);
|
|
@@ -283,6 +357,7 @@ function handlePromptApi(req, res, url) {
|
|
|
283
357
|
if (p === '/api/prompts/hybrid-search' && m === 'GET') return handleHybridSearch(req, res, url);
|
|
284
358
|
if (p === '/api/prompts/improve' && m === 'POST') return handleImprovePrompt(req, res);
|
|
285
359
|
if (p === '/api/skills/autocomplete' && m === 'GET') return handleSkillAutocomplete(req, res, url);
|
|
360
|
+
if (p === '/api/skills/resolve-intent' && m === 'GET') return handleSkillResolveIntent(req, res, url);
|
|
286
361
|
if (p === '/api/harvest/stats' && m === 'GET') return handleHarvestStats(req, res);
|
|
287
362
|
if (p === '/api/prompts/pattern-suggestions' && m === 'GET') return handlePatternSuggestions(req, res);
|
|
288
363
|
|
|
@@ -646,12 +721,120 @@ async function handleUploadImage(req, res, url) {
|
|
|
646
721
|
const promptId = parseInt(url.searchParams.get('prompt_id') || '0');
|
|
647
722
|
const filename = url.searchParams.get('filename') || 'image.png';
|
|
648
723
|
const mimeType = req.headers['content-type'] || 'image/png';
|
|
724
|
+
// defer_row=1: return as soon as the file is written (id:null), firing the DB
|
|
725
|
+
// INSERT in the background. The terminal paste path sets this so a paste isn't
|
|
726
|
+
// stuck behind the db-owner write queue.
|
|
727
|
+
const deferRow = url.searchParams.get('defer_row') === '1';
|
|
728
|
+
const _tRead0 = performance.now();
|
|
649
729
|
const buffer = await readRawBody(req);
|
|
650
|
-
const
|
|
651
|
-
|
|
730
|
+
const _tRead1 = performance.now();
|
|
731
|
+
const result = await saveImageViaOwner(promptId, buffer, filename, mimeType, deferRow);
|
|
732
|
+
const safe = _logImageTiming('upload', {
|
|
733
|
+
bytes: buffer.length, readMs: _tRead1 - _tRead0, roundTripMs: performance.now() - _tRead1,
|
|
734
|
+
source: url.searchParams.get('source') || '', defer: deferRow,
|
|
735
|
+
}, result);
|
|
736
|
+
jsonResponse(res, 201, safe);
|
|
652
737
|
} catch (e) { jsonResponse(res, 400, { error: e.message }); }
|
|
653
738
|
}
|
|
654
739
|
|
|
740
|
+
const INGEST_IMAGE_EXT_MIME = {
|
|
741
|
+
'.png': 'image/png',
|
|
742
|
+
'.jpg': 'image/jpeg',
|
|
743
|
+
'.jpeg': 'image/jpeg',
|
|
744
|
+
'.gif': 'image/gif',
|
|
745
|
+
'.webp': 'image/webp',
|
|
746
|
+
'.heic': 'image/heic',
|
|
747
|
+
'.heif': 'image/heif',
|
|
748
|
+
'.bmp': 'image/bmp',
|
|
749
|
+
'.tif': 'image/tiff',
|
|
750
|
+
'.tiff': 'image/tiff',
|
|
751
|
+
};
|
|
752
|
+
|
|
753
|
+
// Decode a file:// URL or accept a plain absolute path. Returns '' if neither.
|
|
754
|
+
function _ingestPathFromInput(value) {
|
|
755
|
+
const raw = String(value || '').trim();
|
|
756
|
+
if (!raw) return '';
|
|
757
|
+
if (/^file:\/\//i.test(raw)) {
|
|
758
|
+
try { return path.resolve(decodeURIComponent(new URL(raw).pathname || '')); }
|
|
759
|
+
catch { return ''; }
|
|
760
|
+
}
|
|
761
|
+
if (path.isAbsolute(raw)) return path.resolve(raw);
|
|
762
|
+
return '';
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// Only stabilize image references that live in user-controlled temp/home roots.
|
|
766
|
+
// This is the macOS screenshot/drag case (e.g. /var/folders/.../TemporaryItems/...png).
|
|
767
|
+
// Refuse arbitrary system paths so this localhost-only endpoint can't be coaxed
|
|
768
|
+
// into copying sensitive files into the served images dir.
|
|
769
|
+
//
|
|
770
|
+
// SECURITY: `candidate` MUST already be a realpath-resolved path (symlinks
|
|
771
|
+
// followed) and each root is realpath-resolved here, so a `.png` symlink living
|
|
772
|
+
// inside an allowed temp root but pointing at e.g. /etc/shadow cannot pass the
|
|
773
|
+
// containment check (its real target is outside the allowed roots). A purely
|
|
774
|
+
// string-based path.resolve() check would be bypassable by such a symlink.
|
|
775
|
+
function _ingestSourceAllowed(candidate) {
|
|
776
|
+
const realRoots = [];
|
|
777
|
+
const pushRoot = (p) => {
|
|
778
|
+
if (!p) return;
|
|
779
|
+
// Resolve each root through realpath too (e.g. macOS /tmp -> /private/tmp,
|
|
780
|
+
// /var -> /private/var) so the comparison is symlink-stable on both sides.
|
|
781
|
+
try { realRoots.push(fs.realpathSync(p)); } catch { /* root may not exist */ }
|
|
782
|
+
};
|
|
783
|
+
try { pushRoot(os.tmpdir()); } catch {}
|
|
784
|
+
for (const env of ['TMPDIR', 'HOME']) {
|
|
785
|
+
if (process.env[env]) pushRoot(process.env[env]);
|
|
786
|
+
}
|
|
787
|
+
// macOS per-user temp + screenshot scratch live under /var/folders and /private/var/folders.
|
|
788
|
+
for (const r of ['/var/folders', '/private/var/folders', '/tmp', '/private/tmp']) pushRoot(r);
|
|
789
|
+
return realRoots.some(root => {
|
|
790
|
+
const rel = path.relative(root, candidate);
|
|
791
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
// Ingest a local image FILE by path (not bytes) and return a stable, served copy.
|
|
796
|
+
// Used by the terminal image-reference paste path so CTM forwards a stable
|
|
797
|
+
// images-dir path to the provider instead of the ephemeral macOS temp path
|
|
798
|
+
// (which leaks the local fs and disappears once the screenshot scratch is reaped).
|
|
799
|
+
async function handleIngestImagePath(req, res) {
|
|
800
|
+
try {
|
|
801
|
+
const body = await readBody(req, 64 * 1024);
|
|
802
|
+
const resolved = _ingestPathFromInput(body && (body.path || body.url || body.reference));
|
|
803
|
+
if (!resolved) return jsonResponse(res, 400, { error: 'invalid_path' });
|
|
804
|
+
const ext = path.extname(resolved).toLowerCase();
|
|
805
|
+
const mimeType = INGEST_IMAGE_EXT_MIME[ext];
|
|
806
|
+
if (!mimeType) return jsonResponse(res, 400, { error: 'unsupported_image_type' });
|
|
807
|
+
// SECURITY: reject symlinks outright, then resolve the real (symlink-followed)
|
|
808
|
+
// path BEFORE the allowlist check. Otherwise a `.png` symlink inside an allowed
|
|
809
|
+
// temp root could point at an arbitrary file (e.g. /etc/shadow) and slip past a
|
|
810
|
+
// string-only containment check, then be copied into the served images dir.
|
|
811
|
+
let lst;
|
|
812
|
+
try { lst = await fs.promises.lstat(resolved); }
|
|
813
|
+
catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
|
|
814
|
+
if (lst.isSymbolicLink()) return jsonResponse(res, 403, { error: 'path_not_allowed' });
|
|
815
|
+
let realPath;
|
|
816
|
+
try { realPath = await fs.promises.realpath(resolved); }
|
|
817
|
+
catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
|
|
818
|
+
if (!_ingestSourceAllowed(realPath)) return jsonResponse(res, 403, { error: 'path_not_allowed' });
|
|
819
|
+
let stat;
|
|
820
|
+
try { stat = await fs.promises.stat(realPath); }
|
|
821
|
+
catch { return jsonResponse(res, 404, { error: 'file_not_found' }); }
|
|
822
|
+
if (!stat.isFile() || stat.size <= 0) return jsonResponse(res, 400, { error: 'not_a_file' });
|
|
823
|
+
// Copy (deleteSource omitted → default false): never disturb the user's temp file.
|
|
824
|
+
const _tSave0 = performance.now();
|
|
825
|
+
const result = await db.saveImageFromFile(0, realPath, path.basename(realPath), mimeType);
|
|
826
|
+
_logImageTiming('ingest-path', { bytes: stat.size, roundTripMs: performance.now() - _tSave0 }, result);
|
|
827
|
+
jsonResponse(res, 201, {
|
|
828
|
+
id: result.id,
|
|
829
|
+
filename: result.filename,
|
|
830
|
+
path: result.path,
|
|
831
|
+
url: `/api/images/file/${encodeURIComponent(result.filename)}`,
|
|
832
|
+
});
|
|
833
|
+
} catch (e) {
|
|
834
|
+
jsonResponse(res, 400, { error: e.message });
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
655
838
|
async function handleUploadMobileAttachment(req, res, url) {
|
|
656
839
|
try {
|
|
657
840
|
const kind = url.searchParams.get('kind') === 'image' ? 'image' : 'file';
|
|
@@ -664,9 +847,11 @@ async function handleUploadMobileAttachment(req, res, url) {
|
|
|
664
847
|
jsonResponse(res, 400, { error: 'attachment_empty' });
|
|
665
848
|
return;
|
|
666
849
|
}
|
|
667
|
-
const
|
|
850
|
+
const _tSave0 = performance.now();
|
|
851
|
+
const result = await saveImageViaOwner(0, buffer, storedFilename, mimeType);
|
|
852
|
+
const safe = _logImageTiming('mobile', { bytes: buffer.length, roundTripMs: performance.now() - _tSave0 }, result);
|
|
668
853
|
jsonResponse(res, 201, {
|
|
669
|
-
...
|
|
854
|
+
...safe,
|
|
670
855
|
kind,
|
|
671
856
|
originalName: filename,
|
|
672
857
|
mimeType,
|
|
@@ -677,6 +862,18 @@ async function handleUploadMobileAttachment(req, res, url) {
|
|
|
677
862
|
}
|
|
678
863
|
}
|
|
679
864
|
|
|
865
|
+
async function handleSessionImageRefs(req, res) {
|
|
866
|
+
try {
|
|
867
|
+
const body = await readBody(req, 256 * 1024);
|
|
868
|
+
const result = await db.recordSessionImageRefs(body || {});
|
|
869
|
+
const safeResult = { ...(result || {}) };
|
|
870
|
+
delete safeResult.refDir;
|
|
871
|
+
jsonResponse(res, 200, { ok: true, ...safeResult });
|
|
872
|
+
} catch (e) {
|
|
873
|
+
jsonResponse(res, 400, { ok: false, error: e.message });
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
680
877
|
function handleGetImage(req, res, url) {
|
|
681
878
|
const id = parseInt(url.pathname.split('/').pop());
|
|
682
879
|
const img = db.getImage(id);
|
|
@@ -856,13 +1053,30 @@ function handleListConversations(req, res, url) {
|
|
|
856
1053
|
|
|
857
1054
|
// Core import logic shared by API handler and auto-import
|
|
858
1055
|
const { getAllSessionFiles, getAllSessionFilesAsync, parseSessionFile } = require('./session-utils');
|
|
1056
|
+
const walleTranscript = require('./lib/walle-transcript');
|
|
1057
|
+
const { readWalleCtmHistory } = require('./lib/walle-ctm-history');
|
|
1058
|
+
const {
|
|
1059
|
+
persistWalleSessionConversation,
|
|
1060
|
+
WALLE_SESSION_CACHE_PARSER_VERSION,
|
|
1061
|
+
} = require('./lib/walle-session-cache');
|
|
859
1062
|
const {
|
|
860
|
-
|
|
1063
|
+
createCodexUserDeduper,
|
|
1064
|
+
parseCodexJsonlFileIntoMessagesAsync,
|
|
861
1065
|
parseCodexJsonlFileIntoMessages,
|
|
862
1066
|
parseCodexJsonlIntoMessages,
|
|
863
1067
|
readCodexRolloutMetadata,
|
|
864
1068
|
} = require('./lib/session-history');
|
|
865
1069
|
const fsp = require('fs').promises;
|
|
1070
|
+
const CODEX_CONVERSATION_IMPORT_PARSER_VERSION = 3;
|
|
1071
|
+
const DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION = 1;
|
|
1072
|
+
// Claude Code / Claude Desktop sessions are Anthropic, but their JSONL carries no explicit
|
|
1073
|
+
// `modelProvider` field, so the parser leaves it ''. Storing '' makes the import's
|
|
1074
|
+
// `missingModel` candidate predicate (`!existing.model_provider`) re-select the session on
|
|
1075
|
+
// EVERY scan tick forever — re-stringifying + rewriting the whole conversation each time
|
|
1076
|
+
// (the re-import storm: 75 sessions, db-owner-worker saturation, write-lock contention).
|
|
1077
|
+
// Default to a concrete provider so the row converges after one import. (Codex uses 'openai'
|
|
1078
|
+
// and Cursor 'cursor' at their own call sites.)
|
|
1079
|
+
const CLAUDE_MODEL_PROVIDER = 'anthropic';
|
|
866
1080
|
|
|
867
1081
|
// Parse JSONL content string into conversation messages (sync, CPU-bound but
|
|
868
1082
|
// called after async file read so only the parse blocks — not disk I/O).
|
|
@@ -968,7 +1182,7 @@ function _parseLargeConversationLine(line) {
|
|
|
968
1182
|
return null;
|
|
969
1183
|
}
|
|
970
1184
|
|
|
971
|
-
async function _parseConversationContent(content) {
|
|
1185
|
+
async function _parseConversationContent(content, opts = {}) {
|
|
972
1186
|
const messages = [];
|
|
973
1187
|
const searchMessages = [];
|
|
974
1188
|
let assistantCount = 0;
|
|
@@ -977,6 +1191,29 @@ async function _parseConversationContent(content) {
|
|
|
977
1191
|
let firstAssistantText = '';
|
|
978
1192
|
let renameName = '';
|
|
979
1193
|
|
|
1194
|
+
// Cross-file stitching (mirrors parseSessionFiles in lib/jsonl-conversation-parser):
|
|
1195
|
+
// a post-compact Claude file inherits the PARENT file's lines (old sessionId at the
|
|
1196
|
+
// top). When the owner file still exists next to this one, drop the inherited copy
|
|
1197
|
+
// so the owner's import is the only one that stores those turns. Orphan prefixes
|
|
1198
|
+
// (owner deleted) are kept — they are the only surviving copy.
|
|
1199
|
+
const fileSessionId = String(opts.fileSessionId || '');
|
|
1200
|
+
const fileDir = String(opts.fileDir || '');
|
|
1201
|
+
const ownerOnDisk = new Map();
|
|
1202
|
+
const dropInheritedEntry = (entryId) => {
|
|
1203
|
+
if (!fileSessionId || !fileDir || !entryId || entryId === fileSessionId) return false;
|
|
1204
|
+
if (!ownerOnDisk.has(entryId)) {
|
|
1205
|
+
let exists = false;
|
|
1206
|
+
try { exists = fs.existsSync(path.join(fileDir, `${entryId}.jsonl`)); } catch {}
|
|
1207
|
+
ownerOnDisk.set(entryId, exists);
|
|
1208
|
+
}
|
|
1209
|
+
return ownerOnDisk.get(entryId);
|
|
1210
|
+
};
|
|
1211
|
+
const seenUuids = new Set();
|
|
1212
|
+
const summaries = [];
|
|
1213
|
+
// Last contiguous run emitted for one assistant parentUuid (structured
|
|
1214
|
+
// emission replaces the whole run when the same row re-streams).
|
|
1215
|
+
let lastAssistantRun = null;
|
|
1216
|
+
|
|
980
1217
|
// Yield while scanning so a large JSONL does not monopolize the event loop.
|
|
981
1218
|
// Avoid content.split('\n') here: building the full line array for a 50MB+
|
|
982
1219
|
// file blocks before the parser gets its first chance to yield.
|
|
@@ -1001,6 +1238,12 @@ async function _parseConversationContent(content) {
|
|
|
1001
1238
|
i++;
|
|
1002
1239
|
try {
|
|
1003
1240
|
if (line.length > _LARGE_JSONL_LINE_BYTES) {
|
|
1241
|
+
if (dropInheritedEntry(_readJsonStringPrefix(line, '"sessionId":"', 64))) {
|
|
1242
|
+
await maybeYield();
|
|
1243
|
+
continue;
|
|
1244
|
+
}
|
|
1245
|
+
const largeUuid = _readJsonStringPrefix(line, '"uuid":"', 64);
|
|
1246
|
+
if (largeUuid) seenUuids.add(largeUuid);
|
|
1004
1247
|
const large = _parseLargeConversationLine(line);
|
|
1005
1248
|
if (large?.role === 'user') {
|
|
1006
1249
|
messages.push({ role: 'user', text: large.text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: large.timestamp });
|
|
@@ -1031,11 +1274,64 @@ async function _parseConversationContent(content) {
|
|
|
1031
1274
|
}
|
|
1032
1275
|
|
|
1033
1276
|
const entry = JSON.parse(line);
|
|
1034
|
-
if (entry.
|
|
1277
|
+
if (dropInheritedEntry(typeof entry.sessionId === 'string' ? entry.sessionId : '')) {
|
|
1278
|
+
await maybeYield();
|
|
1279
|
+
continue;
|
|
1280
|
+
}
|
|
1281
|
+
if (typeof entry.uuid === 'string' && entry.uuid) seenUuids.add(entry.uuid);
|
|
1282
|
+
if (entry.type === 'summary' && typeof entry.summary === 'string' && entry.summary.trim()) {
|
|
1283
|
+
// Async-generated session title ({summary, leafUuid}); not a turn.
|
|
1284
|
+
summaries.push({ summary: entry.summary.trim(), leafUuid: entry.leafUuid || '' });
|
|
1285
|
+
} else if (entry.type === 'system' && entry.subtype === 'compact_boundary'
|
|
1286
|
+
&& structuredCapture.structuredCaptureEnabled()) {
|
|
1287
|
+
const compactMeta = entry.compactMetadata || {};
|
|
1288
|
+
const boundary = structuredCapture.compactBoundaryMessage({
|
|
1289
|
+
provider: 'claude',
|
|
1290
|
+
trigger: compactMeta.trigger,
|
|
1291
|
+
preTokens: Number(compactMeta.preTokens),
|
|
1292
|
+
logicalParentUuid: entry.logicalParentUuid,
|
|
1293
|
+
timestamp: entry.timestamp,
|
|
1294
|
+
});
|
|
1295
|
+
messages.push(boundary);
|
|
1296
|
+
searchMessages.push(boundary);
|
|
1297
|
+
} else if (entry.type === 'user' && entry.message?.role === 'user') {
|
|
1035
1298
|
const c = entry.message.content;
|
|
1036
1299
|
const text = typeof c === 'string' ? c
|
|
1037
1300
|
: Array.isArray(c) ? c.filter(b => b.type === 'text').map(b => b.text).join('\n') : '';
|
|
1038
|
-
|
|
1301
|
+
const captureOn = structuredCapture.structuredCaptureEnabled();
|
|
1302
|
+
if (captureOn && (entry.isCompactSummary || entry.isVisibleInTranscriptOnly)) {
|
|
1303
|
+
// Post-compact synthetic context summary — a collapsed structured
|
|
1304
|
+
// row, never a user prompt (keeps first/last-user title signals clean).
|
|
1305
|
+
if (text) {
|
|
1306
|
+
const summaryMsg = structuredCapture.compactSummaryMessage({ provider: 'claude', text, timestamp: entry.timestamp });
|
|
1307
|
+
messages.push(summaryMsg);
|
|
1308
|
+
searchMessages.push(summaryMsg);
|
|
1309
|
+
}
|
|
1310
|
+
} else if (captureOn && Array.isArray(c) && c.some(b => b && b.type === 'tool_result')) {
|
|
1311
|
+
// Mirrors lib/jsonl-conversation-parser: one structured tool_result
|
|
1312
|
+
// row per block, enriched from the top-level toolUseResult.
|
|
1313
|
+
const resultBlocks = c.filter(b => b && b.type === 'tool_result');
|
|
1314
|
+
const tur = resultBlocks.length === 1 && entry.toolUseResult && typeof entry.toolUseResult === 'object'
|
|
1315
|
+
? entry.toolUseResult : null;
|
|
1316
|
+
for (const block of resultBlocks) {
|
|
1317
|
+
const blockText = typeof block.content === 'string' ? block.content
|
|
1318
|
+
: Array.isArray(block.content)
|
|
1319
|
+
? block.content.filter(b => b && b.type === 'text' && b.text).map(b => b.text).join('\n')
|
|
1320
|
+
: '';
|
|
1321
|
+
const resultMsg = structuredCapture.toolResultMessage({
|
|
1322
|
+
provider: 'claude',
|
|
1323
|
+
callId: block.tool_use_id,
|
|
1324
|
+
output: blockText || (tur && typeof tur.stdout === 'string' ? tur.stdout : ''),
|
|
1325
|
+
isError: block.is_error === true,
|
|
1326
|
+
filePath: tur ? (tur.filePath || tur.file_path) : undefined,
|
|
1327
|
+
durationMs: tur ? Number(tur.durationMs) : undefined,
|
|
1328
|
+
structuredPatch: tur ? tur.structuredPatch : undefined,
|
|
1329
|
+
timestamp: entry.timestamp,
|
|
1330
|
+
});
|
|
1331
|
+
messages.push(resultMsg);
|
|
1332
|
+
searchMessages.push(resultMsg);
|
|
1333
|
+
}
|
|
1334
|
+
} else if (text) {
|
|
1039
1335
|
messages.push({ role: 'user', text: text.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT), timestamp: entry.timestamp });
|
|
1040
1336
|
searchMessages.push({ role: 'user', text, timestamp: entry.timestamp });
|
|
1041
1337
|
if (!firstUserContent) firstUserContent = text.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
|
|
@@ -1047,6 +1343,80 @@ async function _parseConversationContent(content) {
|
|
|
1047
1343
|
} else if (entry.type === 'assistant' && entry.message?.role === 'assistant') {
|
|
1048
1344
|
const c = entry.message.content;
|
|
1049
1345
|
if (!Array.isArray(c)) continue;
|
|
1346
|
+
if (structuredCapture.structuredCaptureEnabled()) {
|
|
1347
|
+
// Mirrors lib/jsonl-conversation-parser: thinking → reasoning rows,
|
|
1348
|
+
// tool_use → tool_call rows, text blocks → ONE assistant message
|
|
1349
|
+
// (parentUuid + usage/model metadata), in block order. A re-streamed
|
|
1350
|
+
// parentUuid replaces the previous contiguous run.
|
|
1351
|
+
const run = [];
|
|
1352
|
+
const textParts = [];
|
|
1353
|
+
let textInsertIndex = -1;
|
|
1354
|
+
for (const block of c) {
|
|
1355
|
+
if (!block || typeof block !== 'object') continue;
|
|
1356
|
+
if (block.type === 'text' && block.text) {
|
|
1357
|
+
if (textInsertIndex === -1) textInsertIndex = run.length;
|
|
1358
|
+
textParts.push(block.text);
|
|
1359
|
+
} else if (block.type === 'thinking' && (block.thinking || block.text)) {
|
|
1360
|
+
run.push(structuredCapture.reasoningMessage({ provider: 'claude', text: block.thinking || block.text, timestamp: entry.timestamp }));
|
|
1361
|
+
} else if (block.type === 'tool_use') {
|
|
1362
|
+
run.push(structuredCapture.toolCallMessage({
|
|
1363
|
+
provider: 'claude', tool: block.name, callId: block.id, args: block.input, timestamp: entry.timestamp,
|
|
1364
|
+
}));
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
let searchText = '';
|
|
1368
|
+
if (textParts.length) {
|
|
1369
|
+
searchText = textParts.join('\n');
|
|
1370
|
+
if (!firstAssistantText) firstAssistantText = searchText.slice(0, _TITLE_SIGNAL_TEXT_LIMIT);
|
|
1371
|
+
const msg = {
|
|
1372
|
+
role: 'assistant',
|
|
1373
|
+
text: searchText.slice(0, _CONVERSATION_CACHE_TEXT_LIMIT),
|
|
1374
|
+
timestamp: entry.timestamp,
|
|
1375
|
+
parentUuid: entry.parentUuid,
|
|
1376
|
+
_parent: entry.parentUuid,
|
|
1377
|
+
};
|
|
1378
|
+
const usage = _usageMetadata(entry.message.usage);
|
|
1379
|
+
const model = typeof entry.message.model === 'string' ? entry.message.model : '';
|
|
1380
|
+
if (usage || model) {
|
|
1381
|
+
msg.metadata = {};
|
|
1382
|
+
if (usage) msg.metadata.usage = usage;
|
|
1383
|
+
if (model) msg.metadata.model = model;
|
|
1384
|
+
}
|
|
1385
|
+
run.splice(textInsertIndex === -1 ? run.length : textInsertIndex, 0, msg);
|
|
1386
|
+
}
|
|
1387
|
+
if (run.length) {
|
|
1388
|
+
if (entry.parentUuid && lastAssistantRun
|
|
1389
|
+
&& lastAssistantRun.parent === entry.parentUuid
|
|
1390
|
+
&& lastAssistantRun.mEnd === messages.length
|
|
1391
|
+
&& lastAssistantRun.sEnd === searchMessages.length) {
|
|
1392
|
+
messages.splice(lastAssistantRun.mStart);
|
|
1393
|
+
searchMessages.splice(lastAssistantRun.sStart);
|
|
1394
|
+
assistantCount -= lastAssistantRun.assistantCount;
|
|
1395
|
+
}
|
|
1396
|
+
const mStart = messages.length;
|
|
1397
|
+
const sStart = searchMessages.length;
|
|
1398
|
+
let runAssistantCount = 0;
|
|
1399
|
+
for (const m of run) {
|
|
1400
|
+
messages.push(m);
|
|
1401
|
+
if (m.role === 'assistant') {
|
|
1402
|
+
runAssistantCount++;
|
|
1403
|
+
// Search index keeps the FULL text (the messages copy is capped).
|
|
1404
|
+
searchMessages.push({ ...m, text: searchText });
|
|
1405
|
+
} else {
|
|
1406
|
+
searchMessages.push(m);
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
assistantCount += runAssistantCount;
|
|
1410
|
+
lastAssistantRun = {
|
|
1411
|
+
parent: entry.parentUuid,
|
|
1412
|
+
mStart, mEnd: messages.length,
|
|
1413
|
+
sStart, sEnd: searchMessages.length,
|
|
1414
|
+
assistantCount: runAssistantCount,
|
|
1415
|
+
};
|
|
1416
|
+
}
|
|
1417
|
+
await maybeYield();
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1050
1420
|
const parts = [];
|
|
1051
1421
|
for (const block of c) {
|
|
1052
1422
|
if (block.type === 'text' && block.text) parts.push(block.text);
|
|
@@ -1081,6 +1451,12 @@ async function _parseConversationContent(content) {
|
|
|
1081
1451
|
}
|
|
1082
1452
|
messages.forEach(m => delete m._parent);
|
|
1083
1453
|
searchMessages.forEach(m => delete m._parent);
|
|
1454
|
+
// Title candidate (lowest precedence): the last summary whose leafUuid we
|
|
1455
|
+
// actually saw in this content — or one with no leafUuid at all.
|
|
1456
|
+
let summaryTitle = '';
|
|
1457
|
+
for (const s of summaries) {
|
|
1458
|
+
if (!s.leafUuid || seenUuids.has(s.leafUuid)) summaryTitle = s.summary.slice(0, 200);
|
|
1459
|
+
}
|
|
1084
1460
|
return {
|
|
1085
1461
|
messages,
|
|
1086
1462
|
searchMessages,
|
|
@@ -1089,6 +1465,7 @@ async function _parseConversationContent(content) {
|
|
|
1089
1465
|
lastUserContent,
|
|
1090
1466
|
firstAssistantText,
|
|
1091
1467
|
renameName,
|
|
1468
|
+
summaryTitle,
|
|
1092
1469
|
};
|
|
1093
1470
|
}
|
|
1094
1471
|
|
|
@@ -1144,8 +1521,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
|
|
|
1144
1521
|
fsp.readFile(jsonlPath, 'utf8').catch(() => ''),
|
|
1145
1522
|
]);
|
|
1146
1523
|
|
|
1147
|
-
const
|
|
1148
|
-
const
|
|
1524
|
+
const stitchOpts = { fileSessionId: claudeFileSessionId(jsonlPath), fileDir: path.dirname(jsonlPath) };
|
|
1525
|
+
const bakParsed = await _parseConversationContent(bakContent, stitchOpts);
|
|
1526
|
+
const jsonlParsed = await _parseConversationContent(jsonlContent, stitchOpts);
|
|
1149
1527
|
|
|
1150
1528
|
// Concatenate then sort by timestamp so out-of-order writes (rare but
|
|
1151
1529
|
// possible mid-compact) end up in chronological order.
|
|
@@ -1176,7 +1554,7 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
|
|
|
1176
1554
|
search_messages: allSearchMessages,
|
|
1177
1555
|
user_msg_count: signals.userCount,
|
|
1178
1556
|
assistant_msg_count: signals.assistantCount,
|
|
1179
|
-
title: parsed.title || (existing && existing.title) || '',
|
|
1557
|
+
title: parsed.title || (existing && existing.title) || jsonlParsed.summaryTitle || bakParsed.summaryTitle || '',
|
|
1180
1558
|
first_message: mergedFirstUser,
|
|
1181
1559
|
last_user_content: mergedLastUser,
|
|
1182
1560
|
first_assistant_text: mergedFirstAssistant,
|
|
@@ -1185,8 +1563,9 @@ async function _importCompactPair(parsed, jsonlPath, bakPath, jsonlSize, bakSize
|
|
|
1185
1563
|
file_size: totalSize,
|
|
1186
1564
|
session_created_at: parsed.timestamp,
|
|
1187
1565
|
hostname: parsed.hostname,
|
|
1188
|
-
model_provider: parsed.modelProvider || (existing && existing.model_provider) ||
|
|
1566
|
+
model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
|
|
1189
1567
|
model_id: parsed.modelId || (existing && existing.model_id) || '',
|
|
1568
|
+
import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
|
|
1190
1569
|
});
|
|
1191
1570
|
return true;
|
|
1192
1571
|
}
|
|
@@ -1219,15 +1598,8 @@ function _loadIndexedSessionMessages(sessionId) {
|
|
|
1219
1598
|
}
|
|
1220
1599
|
}
|
|
1221
1600
|
|
|
1222
|
-
function
|
|
1223
|
-
|
|
1224
|
-
for (const msg of Array.isArray(messages) ? messages : []) {
|
|
1225
|
-
if (msg && msg.role === 'user') {
|
|
1226
|
-
const key = codexUserKey(msg.text || msg.content || '');
|
|
1227
|
-
if (key) seen.add(key);
|
|
1228
|
-
}
|
|
1229
|
-
}
|
|
1230
|
-
return seen;
|
|
1601
|
+
function _codexUserDeduperFromMessages(messages) {
|
|
1602
|
+
return createCodexUserDeduper((Array.isArray(messages) ? messages : []).filter(msg => msg && msg.role === 'user'));
|
|
1231
1603
|
}
|
|
1232
1604
|
|
|
1233
1605
|
async function _readFileRange(filePath, start, length) {
|
|
@@ -1242,21 +1614,28 @@ async function _readFileRange(filePath, start, length) {
|
|
|
1242
1614
|
}
|
|
1243
1615
|
|
|
1244
1616
|
function _conversationImportIndexRows() {
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1617
|
+
// Attribution: two full-table index scans built on every conversation-import
|
|
1618
|
+
// tick. They run as the sync prefix of _conversationImportCandidates, which is
|
|
1619
|
+
// itself called AFTER the job's `await getAllSessionFilesAsync()` — so the
|
|
1620
|
+
// scheduler's tag is already cleared and a slow scan would log as `(unknown)`.
|
|
1621
|
+
// Phase 2.
|
|
1622
|
+
return runSync('conversation-import:buildIndexRows', () => {
|
|
1623
|
+
const conversations = new Map();
|
|
1624
|
+
const linkedAgentIds = new Set();
|
|
1625
|
+
try {
|
|
1626
|
+
const rows = db.getDb().prepare(
|
|
1627
|
+
'SELECT ctm_session_id, file_size, model_provider, import_parser_version FROM session_conversations'
|
|
1628
|
+
).all();
|
|
1629
|
+
for (const row of rows) conversations.set(row.ctm_session_id, row);
|
|
1630
|
+
} catch {}
|
|
1631
|
+
try {
|
|
1632
|
+
const rows = db.getDb().prepare(
|
|
1633
|
+
'SELECT agent_session_id FROM agent_sessions WHERE agent_session_id IS NOT NULL AND agent_session_id != ""'
|
|
1634
|
+
).all();
|
|
1635
|
+
for (const row of rows) linkedAgentIds.add(row.agent_session_id);
|
|
1636
|
+
} catch {}
|
|
1637
|
+
return { conversations, linkedAgentIds };
|
|
1638
|
+
});
|
|
1260
1639
|
}
|
|
1261
1640
|
|
|
1262
1641
|
async function _conversationImportEffectiveSize(filePath, stat) {
|
|
@@ -1283,14 +1662,29 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
|
|
|
1283
1662
|
const stat = await fsp.stat(claudeDesktopSessions.sourcePathForStat(filePath));
|
|
1284
1663
|
if (!stat.isFile()) continue;
|
|
1285
1664
|
const sessionId = listedSessionId || path.basename(filePath).replace(/\.jsonl(\.bak)?$/, '');
|
|
1665
|
+
// Desktop sessions re-candidate forever via `linkedMissingCache`: importSessionFile
|
|
1666
|
+
// returns false for an empty desktop session (no messages), so no session_conversations
|
|
1667
|
+
// row is ever written, so `!existing` stays true and the session is re-selected every
|
|
1668
|
+
// import tick. Skip dead/empty desktop sessions here — there is nothing to import, and a
|
|
1669
|
+
// healthy one (messages present) writes a cache row and stops matching this guard.
|
|
1670
|
+
if (claudeDesktopSessions.isVirtualSessionPath(filePath)
|
|
1671
|
+
&& (claudeDesktopSessions.getMessages(sessionId) || []).length === 0) {
|
|
1672
|
+
continue;
|
|
1673
|
+
}
|
|
1286
1674
|
const existing = conversations.get(sessionId);
|
|
1287
1675
|
const existingSize = Number(existing?.file_size || 0);
|
|
1288
1676
|
const { effectiveSize, hasCompactSibling } = await _conversationImportEffectiveSize(filePath, stat);
|
|
1677
|
+
const isCodexRollout = projectEntry === 'codex' || String(filePath || '').includes(`${path.sep}.codex${path.sep}sessions${path.sep}`);
|
|
1678
|
+
const isWalleTranscript = projectEntry === walleTranscript.WALLE_PROJECT_ENTRY;
|
|
1289
1679
|
const changedSinceScan = stat.mtimeMs > lastScanAt;
|
|
1290
1680
|
const cacheBehind = !!existing && effectiveSize > existingSize;
|
|
1291
1681
|
const cacheShrankAfterChange = !!existing && effectiveSize < existingSize && changedSinceScan;
|
|
1292
1682
|
const linkedMissingCache = linkedAgentIds.has(sessionId) && !existing;
|
|
1293
1683
|
const missingModel = !!existing && !existing.model_provider;
|
|
1684
|
+
const staleParser = !!existing && (
|
|
1685
|
+
(isCodexRollout && Number(existing.import_parser_version || 0) < CODEX_CONVERSATION_IMPORT_PARSER_VERSION) ||
|
|
1686
|
+
(isWalleTranscript && Number(existing.import_parser_version || 0) < WALLE_SESSION_CACHE_PARSER_VERSION)
|
|
1687
|
+
);
|
|
1294
1688
|
const changedColdFile = changedSinceScan && !existing;
|
|
1295
1689
|
|
|
1296
1690
|
if (
|
|
@@ -1298,18 +1692,20 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
|
|
|
1298
1692
|
!cacheBehind &&
|
|
1299
1693
|
!cacheShrankAfterChange &&
|
|
1300
1694
|
!linkedMissingCache &&
|
|
1301
|
-
!missingModel
|
|
1695
|
+
!missingModel &&
|
|
1696
|
+
!staleParser
|
|
1302
1697
|
) {
|
|
1303
1698
|
continue;
|
|
1304
1699
|
}
|
|
1305
1700
|
|
|
1306
1701
|
let priority = 6;
|
|
1307
1702
|
if (cacheBehind) priority = 0;
|
|
1308
|
-
else if (
|
|
1309
|
-
else if (
|
|
1310
|
-
else if (
|
|
1311
|
-
else if (changedColdFile) priority = 4;
|
|
1312
|
-
else if (
|
|
1703
|
+
else if (staleParser) priority = 1;
|
|
1704
|
+
else if (linkedMissingCache) priority = 2;
|
|
1705
|
+
else if (cacheShrankAfterChange) priority = 3;
|
|
1706
|
+
else if (hasCompactSibling && changedColdFile) priority = 4;
|
|
1707
|
+
else if (changedColdFile) priority = 5;
|
|
1708
|
+
else if (missingModel) priority = 6;
|
|
1313
1709
|
|
|
1314
1710
|
candidates.push({
|
|
1315
1711
|
filePath,
|
|
@@ -1343,32 +1739,79 @@ async function _conversationImportCandidates(allFiles, lastScanAt) {
|
|
|
1343
1739
|
return candidates;
|
|
1344
1740
|
}
|
|
1345
1741
|
|
|
1346
|
-
|
|
1742
|
+
function _backgroundTranscriptImportMaxBytes() {
|
|
1743
|
+
const raw = Number(process.env.CTM_BACKGROUND_TRANSCRIPT_IMPORT_MAX_BYTES || BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
|
|
1744
|
+
return Math.max(256 * 1024, Number.isFinite(raw) ? raw : BACKGROUND_TRANSCRIPT_IMPORT_DEFAULT_BYTES);
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
function _codexImportedFileSize(parsedFileSize, prevFileSize, parsedTail) {
|
|
1748
|
+
const fileSize = Math.max(0, Number(parsedFileSize || 0));
|
|
1749
|
+
const prev = Math.max(0, Number(prevFileSize || 0));
|
|
1750
|
+
const base = prev > 0 && fileSize >= prev ? prev : 0;
|
|
1751
|
+
const rawConsumed = Number.isFinite(Number(parsedTail?.completeBytesRead))
|
|
1752
|
+
? Number(parsedTail.completeBytesRead)
|
|
1753
|
+
: Number(parsedTail?.bytesRead || 0);
|
|
1754
|
+
const consumed = Math.max(0, Number.isFinite(rawConsumed) ? rawConsumed : 0);
|
|
1755
|
+
return Math.min(fileSize, base + consumed);
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function _importCodexSessionFile(parsed, filePath, options = {}) {
|
|
1347
1759
|
const sessionId = parsed.sessionId;
|
|
1348
1760
|
if (!sessionId) return false;
|
|
1349
1761
|
|
|
1350
1762
|
const existing = db.getSessionConversation(sessionId);
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1763
|
+
const parserVersion = CODEX_CONVERSATION_IMPORT_PARSER_VERSION;
|
|
1764
|
+
const parserStale = !!existing && Number(existing.import_parser_version || 0) < parserVersion;
|
|
1765
|
+
if (existing && existing.file_size === parsed.fileSize && existing.model_provider && !parserStale) return false;
|
|
1766
|
+
|
|
1767
|
+
const prevFileSize = parserStale ? 0 : Number(existing?.file_size || 0);
|
|
1768
|
+
// Hard size cap (fail-open): a multi-GB transcript would make the existing-blob
|
|
1769
|
+
// parse + whole-array concat/sort below block the loop and exceed V8's ~512MB
|
|
1770
|
+
// string limit. Over cap ⇒ don't load the full base from the blob; import only
|
|
1771
|
+
// the freshly-read tail (the downstream blob write is also capped to '[]' and
|
|
1772
|
+
// the transcript-store rows carry full history).
|
|
1773
|
+
let _isOverParseCap = null;
|
|
1774
|
+
try { _isOverParseCap = require('./lib/size-cap').isOverParseCap; } catch { /* optional */ }
|
|
1775
|
+
const _overCap = typeof _isOverParseCap === 'function' ? _isOverParseCap(parsed.fileSize) : false;
|
|
1776
|
+
const _loadBase = !_overCap && prevFileSize > 0 && parsed.fileSize > prevFileSize;
|
|
1777
|
+
// Phase 6: load the incremental base from the faithful per-message rows when they're
|
|
1778
|
+
// known-complete (column read, no multi-MB JSON.parse of the blob — and it survives blob
|
|
1779
|
+
// retirement in Phase 7). Falls back to the blob when rows are absent/half-migrated.
|
|
1780
|
+
const _rowsComplete = _loadBase
|
|
1781
|
+
&& typeof db.sessionContentRowsAvailable === 'function'
|
|
1782
|
+
&& db.sessionContentRowsAvailable(sessionId);
|
|
1783
|
+
const baseMessages = _loadBase
|
|
1784
|
+
? (_rowsComplete ? db.getSessionMessagesArray(sessionId, { fallbackToBlob: false }) : _safeParseMessagesJson(existing.messages))
|
|
1356
1785
|
: [];
|
|
1357
|
-
const indexedMessages =
|
|
1358
|
-
|
|
1359
|
-
: [];
|
|
1360
|
-
const baseSearchMessages = (prevFileSize > 0 && parsed.fileSize > prevFileSize)
|
|
1786
|
+
const indexedMessages = _loadBase ? _loadIndexedSessionMessages(sessionId) : [];
|
|
1787
|
+
const baseSearchMessages = _loadBase
|
|
1361
1788
|
? (indexedMessages.length ? indexedMessages : baseMessages)
|
|
1362
1789
|
: [];
|
|
1363
|
-
const
|
|
1790
|
+
const codexUserDeduper = _codexUserDeduperFromMessages(baseMessages);
|
|
1364
1791
|
|
|
1365
1792
|
const newMessages = [];
|
|
1366
1793
|
let parsedTail;
|
|
1367
1794
|
if (prevFileSize > 0 && parsed.fileSize > prevFileSize) {
|
|
1368
1795
|
const content = await _readFileRange(filePath, prevFileSize, parsed.fileSize - prevFileSize);
|
|
1369
|
-
parsedTail = parseCodexJsonlIntoMessages(content, newMessages, {
|
|
1796
|
+
parsedTail = parseCodexJsonlIntoMessages(content, newMessages, { codexUserDeduper });
|
|
1797
|
+
} else if (options.cooperative) {
|
|
1798
|
+
parsedTail = await parseCodexJsonlFileIntoMessagesAsync(filePath, newMessages, {
|
|
1799
|
+
codexUserDeduper,
|
|
1800
|
+
yieldAfterMs: options.yieldAfterMs || 25,
|
|
1801
|
+
});
|
|
1370
1802
|
} else {
|
|
1371
|
-
parsedTail = parseCodexJsonlFileIntoMessages(filePath, newMessages, {
|
|
1803
|
+
parsedTail = parseCodexJsonlFileIntoMessages(filePath, newMessages, { codexUserDeduper });
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
// Re-import storm guard: codex `importedFileSize` tracks CONSUMED bytes, which can stay below
|
|
1807
|
+
// parsed.fileSize indefinitely (trailing non-message lines / a partial record still being
|
|
1808
|
+
// written), so the candidate selector's `cacheBehind` (effectiveSize > stored file_size) never
|
|
1809
|
+
// converges and this importer is re-run every scan tick. When the grown tail held NO new
|
|
1810
|
+
// messages, the whole-conversation re-stringify + replaceSessionMessages below is pure waste
|
|
1811
|
+
// that saturates the db-owner worker — skip it. The resume offset (stored file_size) is left
|
|
1812
|
+
// unchanged, so a record that only completes later is still picked up on a subsequent pass.
|
|
1813
|
+
if (prevFileSize > 0 && parsed.fileSize > prevFileSize && newMessages.length === 0) {
|
|
1814
|
+
return false;
|
|
1372
1815
|
}
|
|
1373
1816
|
|
|
1374
1817
|
const allMessages = baseMessages.concat(newMessages);
|
|
@@ -1379,6 +1822,8 @@ async function _importCodexSessionFile(parsed, filePath) {
|
|
|
1379
1822
|
const assistantMessages = allMessages.filter(m => m.role === 'assistant' && (m.text || m.content));
|
|
1380
1823
|
if (allMessages.length === 0 || userMessages.length === 0) return false;
|
|
1381
1824
|
|
|
1825
|
+
const importedFileSize = _codexImportedFileSize(parsed.fileSize, prevFileSize, parsedTail);
|
|
1826
|
+
|
|
1382
1827
|
const fileMeta = readCodexRolloutMetadata(filePath) || {};
|
|
1383
1828
|
const meta = parsedTail.sessionMeta || fileMeta || {};
|
|
1384
1829
|
const firstUser = userMessages[0]?.text || userMessages[0]?.content || '';
|
|
@@ -1404,14 +1849,45 @@ async function _importCodexSessionFile(parsed, filePath) {
|
|
|
1404
1849
|
first_assistant_text: firstAssistant.slice(0, 500),
|
|
1405
1850
|
rename_name: existing?.rename_name || '',
|
|
1406
1851
|
git_branch: meta.git_branch || parsed.gitBranch || '',
|
|
1407
|
-
file_size:
|
|
1852
|
+
file_size: importedFileSize,
|
|
1408
1853
|
session_created_at: meta.timestamp || parsed.timestamp || '',
|
|
1409
1854
|
hostname: parsed.hostname,
|
|
1410
1855
|
model_provider: modelProvider,
|
|
1411
1856
|
model_id: model || (existing && existing.model_id) || '',
|
|
1857
|
+
import_parser_version: parserVersion,
|
|
1412
1858
|
});
|
|
1413
1859
|
|
|
1414
1860
|
try {
|
|
1861
|
+
const threadSource = String(meta.thread_source || meta.threadSource || '').trim().toLowerCase();
|
|
1862
|
+
const parentAgentSessionId = String(
|
|
1863
|
+
meta.parent_agent_session_id
|
|
1864
|
+
|| meta.parentAgentSessionId
|
|
1865
|
+
|| meta.parent_thread_id
|
|
1866
|
+
|| meta.parentThreadId
|
|
1867
|
+
|| ''
|
|
1868
|
+
).trim();
|
|
1869
|
+
if (threadSource === 'subagent' && parentAgentSessionId) {
|
|
1870
|
+
db.upsertAgentSessionIdentity(sessionId, {
|
|
1871
|
+
provider: 'codex',
|
|
1872
|
+
providerResumeId: sessionId,
|
|
1873
|
+
cwd: projectPath,
|
|
1874
|
+
projectPath,
|
|
1875
|
+
title,
|
|
1876
|
+
jsonlPath: filePath,
|
|
1877
|
+
fileSize: parsed.fileSize,
|
|
1878
|
+
modifiedAt: parsed.modifiedAt,
|
|
1879
|
+
model,
|
|
1880
|
+
gitBranch: meta.git_branch || parsed.gitBranch || '',
|
|
1881
|
+
hostname: parsed.hostname,
|
|
1882
|
+
userMsgCount: userMessages.length,
|
|
1883
|
+
firstMessage: firstUser.slice(0, 500),
|
|
1884
|
+
threadSource,
|
|
1885
|
+
parentAgentSessionId,
|
|
1886
|
+
agentNickname: meta.agent_nickname || meta.agentNickname || '',
|
|
1887
|
+
agentRole: meta.agent_role || meta.agentRole || '',
|
|
1888
|
+
});
|
|
1889
|
+
return true;
|
|
1890
|
+
}
|
|
1415
1891
|
const owner = db.getDb().prepare('SELECT ctm_session_id FROM agent_sessions WHERE agent_session_id = ?').get(sessionId);
|
|
1416
1892
|
db.upsertSession(owner?.ctm_session_id || sessionId, {
|
|
1417
1893
|
agentSessionId: sessionId,
|
|
@@ -1436,21 +1912,28 @@ async function _importCodexSessionFile(parsed, filePath) {
|
|
|
1436
1912
|
return true;
|
|
1437
1913
|
}
|
|
1438
1914
|
|
|
1439
|
-
function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
|
|
1915
|
+
function _ingestTranscriptStoreForParsedFile(filePath, parsed, options = {}) {
|
|
1916
|
+
// Claude Desktop sessions use a virtual path (`…#ctm-claude-desktop=<uuid>`) that is never a
|
|
1917
|
+
// real file on disk — fs.statSync / ingestJsonlFile always throw ENOENT on it. Their
|
|
1918
|
+
// transcript data lives in the Desktop cache (read via getMessages), not the JSONL transcript
|
|
1919
|
+
// store, so this ingest can only ever fail for them. Skipping removes a guaranteed-failing
|
|
1920
|
+
// synchronous statSync that was firing ~97×/sec in a hot loop on dead desktop sessions.
|
|
1921
|
+
if (claudeDesktopSessions.isVirtualSessionPath(filePath)) return null;
|
|
1440
1922
|
try {
|
|
1441
1923
|
const size = Number(parsed?.fileSize || fs.statSync(filePath).size || 0);
|
|
1442
1924
|
const agentSessionId = String(parsed?.sessionId || transcriptSourceIdFromPath(filePath) || '').trim();
|
|
1443
1925
|
if (!agentSessionId) return null;
|
|
1444
1926
|
const provider = normalizeTranscriptProvider(parsed?.agent || parsed?.modelProvider || '', filePath);
|
|
1445
1927
|
const largeColdMode = size >= TRANSCRIPT_IMPORT_LARGE_FILE_BYTES ? 'tail' : undefined;
|
|
1928
|
+
const maxBytes = Math.max(256 * 1024, Number(options.transcriptMaxBytes || TRANSCRIPT_IMPORT_MAX_BYTES));
|
|
1446
1929
|
const result = ingestJsonlFile(db.getDb(), {
|
|
1447
1930
|
filePath,
|
|
1448
1931
|
agentSessionId,
|
|
1449
1932
|
ctmSessionId: agentSessionId,
|
|
1450
1933
|
provider,
|
|
1451
1934
|
mode: largeColdMode,
|
|
1452
|
-
initialTailBytes:
|
|
1453
|
-
maxBytes: largeColdMode ?
|
|
1935
|
+
initialTailBytes: maxBytes,
|
|
1936
|
+
maxBytes: largeColdMode ? maxBytes : Math.min(size || maxBytes, maxBytes),
|
|
1454
1937
|
});
|
|
1455
1938
|
if ((result.inserted || 0) > 0 || (result.bytesRead || 0) > 0) {
|
|
1456
1939
|
console.log(
|
|
@@ -1465,11 +1948,55 @@ function _ingestTranscriptStoreForParsedFile(filePath, parsed) {
|
|
|
1465
1948
|
}
|
|
1466
1949
|
}
|
|
1467
1950
|
|
|
1468
|
-
async function
|
|
1951
|
+
async function _importWalleSessionFile(parsed, filePath) {
|
|
1952
|
+
const sessionId = String(parsed?.sessionId || '').trim();
|
|
1953
|
+
if (!sessionId) return false;
|
|
1954
|
+
|
|
1955
|
+
const existing = db.getSessionConversation(sessionId);
|
|
1956
|
+
const fileStat = await fsp.stat(filePath).catch(() => null);
|
|
1957
|
+
if (!fileStat || !fileStat.isFile()) return false;
|
|
1958
|
+
const parserStale = !!existing &&
|
|
1959
|
+
Number(existing.import_parser_version || 0) < WALLE_SESSION_CACHE_PARSER_VERSION;
|
|
1960
|
+
if (existing && existing.file_size === fileStat.size && existing.model_provider && !parserStale) {
|
|
1961
|
+
return false;
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
const history = readWalleCtmHistory(filePath);
|
|
1965
|
+
if (!history.some((message) => message && message.role === 'user' && (message.content || message.text))) {
|
|
1966
|
+
return false;
|
|
1967
|
+
}
|
|
1968
|
+
const meta = walleTranscript.readSessionMeta(filePath) || {};
|
|
1969
|
+
const chatSessionId = String(meta.chatSessionId || meta.sessionId || sessionId).trim() || sessionId;
|
|
1970
|
+
const wrote = persistWalleSessionConversation({
|
|
1971
|
+
db,
|
|
1972
|
+
source: {
|
|
1973
|
+
...parsed,
|
|
1974
|
+
ctmSessionId: sessionId,
|
|
1975
|
+
conversationSessionId: sessionId,
|
|
1976
|
+
agentSessionId: sessionId,
|
|
1977
|
+
chatSessionId,
|
|
1978
|
+
jsonlPath: filePath,
|
|
1979
|
+
cwd: meta.cwd || parsed.cwd || parsed.project || '',
|
|
1980
|
+
title: meta.label || parsed.title || '',
|
|
1981
|
+
label: meta.label || parsed.title || '',
|
|
1982
|
+
model_provider: meta.modelProvider || parsed.modelProvider || '',
|
|
1983
|
+
model_id: meta.modelId || parsed.modelId || '',
|
|
1984
|
+
},
|
|
1985
|
+
history,
|
|
1986
|
+
fileStat,
|
|
1987
|
+
hostname: parsed.hostname,
|
|
1988
|
+
});
|
|
1989
|
+
return wrote > 0;
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
async function importSessionFile(filePath, projectPath, projectEntry, options = {}) {
|
|
1469
1993
|
const parsed = parseSessionFile(filePath, projectPath, projectEntry);
|
|
1470
|
-
_ingestTranscriptStoreForParsedFile(filePath, parsed);
|
|
1994
|
+
_ingestTranscriptStoreForParsedFile(filePath, parsed, options);
|
|
1471
1995
|
if (parsed.agent === 'codex') {
|
|
1472
|
-
return _importCodexSessionFile(parsed, filePath);
|
|
1996
|
+
return _importCodexSessionFile(parsed, filePath, options);
|
|
1997
|
+
}
|
|
1998
|
+
if (parsed.agent === 'walle') {
|
|
1999
|
+
return _importWalleSessionFile(parsed, filePath);
|
|
1473
2000
|
}
|
|
1474
2001
|
if (parsed.agent === claudeDesktopSessions.DESKTOP_AGENT) {
|
|
1475
2002
|
const messages = claudeDesktopSessions.getMessages(parsed.sessionId) || [];
|
|
@@ -1497,8 +2024,9 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
|
|
|
1497
2024
|
file_size: parsed.fileSize,
|
|
1498
2025
|
session_created_at: parsed.timestamp,
|
|
1499
2026
|
hostname: parsed.hostname,
|
|
1500
|
-
model_provider: parsed.modelProvider || (existing && existing.model_provider) ||
|
|
2027
|
+
model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
|
|
1501
2028
|
model_id: parsed.modelId || (existing && existing.model_id) || '',
|
|
2029
|
+
import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
|
|
1502
2030
|
});
|
|
1503
2031
|
return true;
|
|
1504
2032
|
}
|
|
@@ -1542,8 +2070,16 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
|
|
|
1542
2070
|
} finally {
|
|
1543
2071
|
await fh.close();
|
|
1544
2072
|
}
|
|
1545
|
-
// Carry forward existing parsed messages
|
|
1546
|
-
|
|
2073
|
+
// Carry forward existing parsed messages. Phase 6: load the base from the faithful
|
|
2074
|
+
// per-message rows when known-complete (column read, no multi-MB JSON.parse — and it
|
|
2075
|
+
// survives blob retirement in Phase 7); fall back to the blob when rows aren't ready.
|
|
2076
|
+
try {
|
|
2077
|
+
if (typeof db.sessionContentRowsAvailable === 'function' && db.sessionContentRowsAvailable(parsed.sessionId)) {
|
|
2078
|
+
baseMessages = db.getSessionMessagesArray(parsed.sessionId, { fallbackToBlob: false });
|
|
2079
|
+
} else {
|
|
2080
|
+
baseMessages = JSON.parse(existing.messages || '[]');
|
|
2081
|
+
}
|
|
2082
|
+
} catch {}
|
|
1547
2083
|
baseAssistantCount = existing.assistant_msg_count || 0;
|
|
1548
2084
|
} else {
|
|
1549
2085
|
// Full read for new files or when file shrank (truncated/rotated)
|
|
@@ -1555,7 +2091,11 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
|
|
|
1555
2091
|
searchMessages: newSearchMessages,
|
|
1556
2092
|
firstUserContent: parsedFirstUser, lastUserContent: parsedLastUser,
|
|
1557
2093
|
firstAssistantText: parsedFirstAssistant, renameName: parsedRename,
|
|
1558
|
-
|
|
2094
|
+
summaryTitle: parsedSummaryTitle,
|
|
2095
|
+
} = await _parseConversationContent(content, {
|
|
2096
|
+
fileSessionId: claudeFileSessionId(jsonlPath),
|
|
2097
|
+
fileDir: path.dirname(jsonlPath),
|
|
2098
|
+
});
|
|
1559
2099
|
const allMessages = baseMessages.concat(newMessages);
|
|
1560
2100
|
const indexedMessages = prevFileSize > 0 && parsed.fileSize > prevFileSize
|
|
1561
2101
|
? _loadIndexedSessionMessages(parsed.sessionId)
|
|
@@ -1587,7 +2127,7 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
|
|
|
1587
2127
|
search_messages: allSearchMessages,
|
|
1588
2128
|
user_msg_count: signals.userCount,
|
|
1589
2129
|
assistant_msg_count: signals.assistantCount || baseAssistantCount + newAssistants,
|
|
1590
|
-
title: parsed.title || (existing && existing.title) || '',
|
|
2130
|
+
title: parsed.title || (existing && existing.title) || parsedSummaryTitle || '',
|
|
1591
2131
|
first_message: mergedFirstUser,
|
|
1592
2132
|
last_user_content: mergedLastUser,
|
|
1593
2133
|
first_assistant_text: mergedFirstAssistant,
|
|
@@ -1596,8 +2136,9 @@ async function importSessionFile(filePath, projectPath, projectEntry) {
|
|
|
1596
2136
|
file_size: parsed.fileSize,
|
|
1597
2137
|
session_created_at: parsed.timestamp,
|
|
1598
2138
|
hostname: parsed.hostname,
|
|
1599
|
-
model_provider: parsed.modelProvider || (existing && existing.model_provider) ||
|
|
2139
|
+
model_provider: parsed.modelProvider || (existing && existing.model_provider) || CLAUDE_MODEL_PROVIDER,
|
|
1600
2140
|
model_id: parsed.modelId || (existing && existing.model_id) || '',
|
|
2141
|
+
import_parser_version: DEFAULT_CONVERSATION_IMPORT_PARSER_VERSION,
|
|
1601
2142
|
});
|
|
1602
2143
|
return true;
|
|
1603
2144
|
}
|
|
@@ -1653,7 +2194,10 @@ async function runIncrementalConversationImport() {
|
|
|
1653
2194
|
try {
|
|
1654
2195
|
if (error) throw error;
|
|
1655
2196
|
scanned++;
|
|
1656
|
-
if (await importSessionFile(filePath, projectPath, projectEntry
|
|
2197
|
+
if (await importSessionFile(filePath, projectPath, projectEntry, {
|
|
2198
|
+
cooperative: true,
|
|
2199
|
+
transcriptMaxBytes: _backgroundTranscriptImportMaxBytes(),
|
|
2200
|
+
})) imported++;
|
|
1657
2201
|
const importLimited = imported >= maxImportedPerRun;
|
|
1658
2202
|
const processedLimited = scanned >= maxProcessedPerRun;
|
|
1659
2203
|
if ((importLimited || processedLimited) && scanned < candidates.length) {
|
|
@@ -1695,6 +2239,108 @@ async function runIncrementalConversationImport() {
|
|
|
1695
2239
|
}
|
|
1696
2240
|
}
|
|
1697
2241
|
|
|
2242
|
+
// --- Cursor conversation import ----------------------------------------------
|
|
2243
|
+
// Cursor stores conversations as a content-addressed blob graph in its own SQLite
|
|
2244
|
+
// store (~/.cursor/chats/<ws>/<agent>/store.db), NOT a JSONL append log. Reconstructing
|
|
2245
|
+
// it is expensive (blob-graph BFS + per-blob JSON.parse), so — exactly like the JSONL
|
|
2246
|
+
// importer — import once into session_conversations and let the normal cache read path
|
|
2247
|
+
// serve it, instead of reconstructing on every UI poll (the old design; see the
|
|
2248
|
+
// _orderedBlobRefs CPU-profile finding). Signature-gated (store size:mtime) so an unchanged
|
|
2249
|
+
// store is skipped; the store path is resolved once per session and cached on a CONFIDENT
|
|
2250
|
+
// (agentId) match. Runs on the db-owner worker (off the main event loop) like the JSONL import.
|
|
2251
|
+
const CURSOR_IMPORT_PARSER_VERSION = 1; // bump to force a full cursor re-import on shape change
|
|
2252
|
+
const _cursorImportSignatures = new Map(); // ctm_session_id → last-imported store signature
|
|
2253
|
+
const _cursorResolvedStorePaths = new Map(); // ctm_session_id → resolved store.db path (confident match only)
|
|
2254
|
+
|
|
2255
|
+
async function runCursorConversationImport({ cursorHome } = {}) {
|
|
2256
|
+
let imported = 0;
|
|
2257
|
+
let scanned = 0;
|
|
2258
|
+
let skipped = 0;
|
|
2259
|
+
let failed = 0;
|
|
2260
|
+
let total = 0;
|
|
2261
|
+
let cursorStore;
|
|
2262
|
+
try {
|
|
2263
|
+
cursorStore = require('./lib/cursor-conversation-store');
|
|
2264
|
+
} catch (e) {
|
|
2265
|
+
return { imported, scanned, skipped, failed, total, error: `cursor store unavailable: ${e.message}` };
|
|
2266
|
+
}
|
|
2267
|
+
try {
|
|
2268
|
+
const sessions = db.getDb().prepare(
|
|
2269
|
+
"SELECT id, cwd, project_path, created_at, updated_at FROM ctm_sessions WHERE provider = 'cursor'"
|
|
2270
|
+
).all();
|
|
2271
|
+
total = sessions.length;
|
|
2272
|
+
for (const s of sessions) {
|
|
2273
|
+
try {
|
|
2274
|
+
scanned += 1;
|
|
2275
|
+
const ctmId = String(s.id);
|
|
2276
|
+
const cwd = String(s.cwd || s.project_path || '').trim();
|
|
2277
|
+
const agentRows = db.getDb().prepare(
|
|
2278
|
+
"SELECT agent_session_id FROM agent_sessions WHERE ctm_session_id = ? AND agent_session_id IS NOT NULL AND agent_session_id != ''"
|
|
2279
|
+
).all(ctmId);
|
|
2280
|
+
const agentSessionIds = agentRows.map((r) => String(r.agent_session_id)).filter(Boolean);
|
|
2281
|
+
|
|
2282
|
+
// Resolve the store. If we already have a confident path, gate on its cheap signature
|
|
2283
|
+
// BEFORE the expensive reconstruct; skip when unchanged since the last import.
|
|
2284
|
+
let storeDbPath = _cursorResolvedStorePaths.get(ctmId) || '';
|
|
2285
|
+
if (storeDbPath) {
|
|
2286
|
+
const sig = cursorStore.statSignature(storeDbPath);
|
|
2287
|
+
if (sig && _cursorImportSignatures.get(ctmId) === sig) { skipped += 1; continue; }
|
|
2288
|
+
}
|
|
2289
|
+
|
|
2290
|
+
let store = null;
|
|
2291
|
+
if (storeDbPath) {
|
|
2292
|
+
try { store = cursorStore.loadCursorStore(storeDbPath); } catch { store = null; }
|
|
2293
|
+
}
|
|
2294
|
+
if (!store) {
|
|
2295
|
+
// No cached path (or its load failed): resolve via match. loadCursorStore is itself
|
|
2296
|
+
// size:mtime-cached, so the enumeration is cheap for idle stores.
|
|
2297
|
+
store = cursorStore.loadCursorConversationForSession({
|
|
2298
|
+
cwd, createdAt: s.created_at, updatedAt: s.updated_at, agentSessionIds, cursorHome,
|
|
2299
|
+
});
|
|
2300
|
+
if (store && store.storeDbPath) {
|
|
2301
|
+
storeDbPath = store.storeDbPath;
|
|
2302
|
+
// Persist the resolved path ONLY on a confident (agentId) match — cwd+time alone is
|
|
2303
|
+
// a heuristic that could pin the wrong store.
|
|
2304
|
+
if (store.agentId && agentSessionIds.includes(store.agentId)) {
|
|
2305
|
+
_cursorResolvedStorePaths.set(ctmId, storeDbPath);
|
|
2306
|
+
}
|
|
2307
|
+
}
|
|
2308
|
+
}
|
|
2309
|
+
if (!store || !Array.isArray(store.messages) || store.messages.length === 0) continue;
|
|
2310
|
+
|
|
2311
|
+
const sig = storeDbPath ? cursorStore.statSignature(storeDbPath) : '';
|
|
2312
|
+
if (sig && _cursorImportSignatures.get(ctmId) === sig) { skipped += 1; continue; }
|
|
2313
|
+
|
|
2314
|
+
const fields = cursorStore.cursorStoreToConversationFields(store);
|
|
2315
|
+
db.importSessionConversation({
|
|
2316
|
+
session_id: ctmId,
|
|
2317
|
+
...fields,
|
|
2318
|
+
title: '',
|
|
2319
|
+
git_branch: '',
|
|
2320
|
+
file_size: 0, // cursor has no single jsonl footprint; freshness is gated by `sig`
|
|
2321
|
+
session_created_at: fields.session_created_at || s.created_at || '',
|
|
2322
|
+
hostname: '',
|
|
2323
|
+
import_parser_version: CURSOR_IMPORT_PARSER_VERSION,
|
|
2324
|
+
rename_name: '',
|
|
2325
|
+
});
|
|
2326
|
+
if (sig) _cursorImportSignatures.set(ctmId, sig);
|
|
2327
|
+
imported += 1;
|
|
2328
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
2329
|
+
} catch (e) {
|
|
2330
|
+
failed += 1;
|
|
2331
|
+
console.error(`[cursor-import] ${String(s.id).slice(0, 8)}: ${e.message}`);
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
if (imported || failed) {
|
|
2335
|
+
console.log(`[cursor-import] imported ${imported}, skipped ${skipped}, failed ${failed} (scanned ${scanned}/${total})`);
|
|
2336
|
+
}
|
|
2337
|
+
return { imported, scanned, skipped, failed, total };
|
|
2338
|
+
} catch (e) {
|
|
2339
|
+
console.error('[cursor-import] Top-level error:', e.message);
|
|
2340
|
+
return { imported, scanned, skipped, failed, total, error: e.message };
|
|
2341
|
+
}
|
|
2342
|
+
}
|
|
2343
|
+
|
|
1698
2344
|
function handleGetConversation(req, res, url) {
|
|
1699
2345
|
const sessionId = url.pathname.split('/').pop();
|
|
1700
2346
|
const conv = db.getSessionConversation(sessionId);
|
|
@@ -1741,7 +2387,7 @@ async function handleGetSettings(req, res, url) {
|
|
|
1741
2387
|
for (const r of rows) result[r.key] = r.value;
|
|
1742
2388
|
return result;
|
|
1743
2389
|
}
|
|
1744
|
-
const allKeys = ['db_path', 'images_dir', 'default_context_type', 'editor_theme', 'auto_version'];
|
|
2390
|
+
const allKeys = ['db_path', 'images_dir', 'backup_dir', 'default_context_type', 'editor_theme', 'auto_version'];
|
|
1745
2391
|
const result = {};
|
|
1746
2392
|
for (const k of allKeys) result[k] = db.getSetting(k);
|
|
1747
2393
|
return result;
|
|
@@ -1759,7 +2405,11 @@ async function handlePutSettings(req, res) {
|
|
|
1759
2405
|
const changedKeys = await withSqliteBusyRetry(() => {
|
|
1760
2406
|
const keys = [];
|
|
1761
2407
|
for (const [k, v] of Object.entries(data)) {
|
|
1762
|
-
|
|
2408
|
+
if (k === 'backup_dir' && typeof db.setBackupDir === 'function') {
|
|
2409
|
+
db.setBackupDir(v, { persist: true });
|
|
2410
|
+
} else {
|
|
2411
|
+
db.setSetting(k, v);
|
|
2412
|
+
}
|
|
1763
2413
|
keys.push(k);
|
|
1764
2414
|
}
|
|
1765
2415
|
return keys;
|
|
@@ -1896,29 +2546,50 @@ function handleHotkeyUninstall(req, res) {
|
|
|
1896
2546
|
|
|
1897
2547
|
// --- Screenshot (macOS) ---
|
|
1898
2548
|
async function handleScreenshot(req, res) {
|
|
2549
|
+
// Self-describing diagnostics for the "press hotkey / take screenshot → nothing
|
|
2550
|
+
// happens" reports. Logs one [screenshot] line per request with where the time
|
|
2551
|
+
// went (capture vs DB write), the live session count, and the write outcome —
|
|
2552
|
+
// so a stall can be attributed to a blocked loop, a busy lock, or a cancel.
|
|
2553
|
+
const t0 = Date.now();
|
|
2554
|
+
const { sessions, sessionEvents } = require('./server-state');
|
|
2555
|
+
const sessionCount = sessions ? sessions.size : -1;
|
|
2556
|
+
let captureMs = 0;
|
|
2557
|
+
let writeMs = 0;
|
|
1899
2558
|
try {
|
|
1900
2559
|
const { execFile } = require('child_process');
|
|
1901
2560
|
const tmpFile = path.join(db.DEFAULT_IMAGES_DIR, `screenshot-${Date.now()}.png`);
|
|
1902
2561
|
// Use async execFile so the event loop stays alive — this lets the browser
|
|
1903
2562
|
// remain responsive and allows screencapture to work across all monitors.
|
|
2563
|
+
const tCap = Date.now();
|
|
1904
2564
|
await new Promise((resolve, reject) => {
|
|
1905
2565
|
execFile('/usr/sbin/screencapture', ['-i', tmpFile], { timeout: 30000 }, (err) => {
|
|
1906
2566
|
if (err) reject(err); else resolve();
|
|
1907
2567
|
});
|
|
1908
2568
|
});
|
|
1909
|
-
|
|
2569
|
+
captureMs = Date.now() - tCap;
|
|
2570
|
+
try {
|
|
2571
|
+
await fs.promises.access(tmpFile, fs.constants.R_OK);
|
|
2572
|
+
} catch (err) {
|
|
2573
|
+
if (err && err.code !== 'ENOENT') throw err;
|
|
2574
|
+
console.warn(`[screenshot] cancelled — capture=${captureMs}ms sessions=${sessionCount}`);
|
|
1910
2575
|
return jsonResponse(res, 400, { error: 'Screenshot cancelled' });
|
|
1911
2576
|
}
|
|
1912
|
-
const
|
|
1913
|
-
const result = await db.
|
|
2577
|
+
const tWrite = Date.now();
|
|
2578
|
+
const result = await db.saveImageFromFile(0, tmpFile, path.basename(tmpFile), 'image/png', { deleteSource: true });
|
|
2579
|
+
writeMs = Date.now() - tWrite;
|
|
2580
|
+
if (result && result._timing) delete result._timing; // internal timing — don't emit/return it (this path has its own [screenshot] log)
|
|
1914
2581
|
// If triggered by hotkey daemon, notify browser clients to open the editor
|
|
1915
2582
|
const url = new URL(req.url, 'http://localhost');
|
|
1916
2583
|
if (!url.searchParams.get('token')) {
|
|
1917
|
-
const { sessionEvents } = require('./server-state');
|
|
1918
2584
|
sessionEvents.emit('screenshot-captured', result);
|
|
1919
2585
|
}
|
|
2586
|
+
console.warn(`[screenshot] ok id=${result.id} total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount}`);
|
|
1920
2587
|
jsonResponse(res, 201, result);
|
|
1921
|
-
} catch (e) {
|
|
2588
|
+
} catch (e) {
|
|
2589
|
+
const busy = /SQLITE_BUSY|write lock|database is locked/i.test(e && e.message || '');
|
|
2590
|
+
console.warn(`[screenshot] FAILED total=${Date.now() - t0}ms capture=${captureMs}ms write=${writeMs}ms sessions=${sessionCount} busy=${busy} err=${e && e.message}`);
|
|
2591
|
+
jsonResponse(res, 500, { error: e.message });
|
|
2592
|
+
}
|
|
1922
2593
|
}
|
|
1923
2594
|
|
|
1924
2595
|
// --- Backups ---
|
|
@@ -1927,7 +2598,19 @@ function handleListBackups(req, res) {
|
|
|
1927
2598
|
const dbPath = db.getDbPath();
|
|
1928
2599
|
let dbSize = 0;
|
|
1929
2600
|
try { dbSize = require('fs').statSync(dbPath).size; } catch {}
|
|
1930
|
-
|
|
2601
|
+
const backupInfo = typeof db.getBackupDirInfo === 'function'
|
|
2602
|
+
? db.getBackupDirInfo()
|
|
2603
|
+
: { backup_dir: db.getBackupDir() };
|
|
2604
|
+
jsonResponse(res, 200, {
|
|
2605
|
+
backups,
|
|
2606
|
+
db_path: dbPath,
|
|
2607
|
+
data_dir: dbPath ? path.dirname(dbPath) : '',
|
|
2608
|
+
backup_dir: backupInfo.backup_dir || db.getBackupDir(),
|
|
2609
|
+
default_backup_dir: backupInfo.default_backup_dir || '',
|
|
2610
|
+
configured_backup_dir: backupInfo.configured_backup_dir || '',
|
|
2611
|
+
backup_dir_source: backupInfo.source || 'default',
|
|
2612
|
+
db_size: dbSize,
|
|
2613
|
+
});
|
|
1931
2614
|
}
|
|
1932
2615
|
|
|
1933
2616
|
async function handleCreateBackup(req, res) {
|
|
@@ -1959,80 +2642,192 @@ function handleDeleteBackup(req, res, url) {
|
|
|
1959
2642
|
jsonResponse(res, 200, { ok: true });
|
|
1960
2643
|
}
|
|
1961
2644
|
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
2645
|
+
function _ctmStorageInfo() {
|
|
2646
|
+
const dbPath = db.getDbPath();
|
|
2647
|
+
const backupInfo = typeof db.getBackupDirInfo === 'function'
|
|
2648
|
+
? db.getBackupDirInfo()
|
|
2649
|
+
: { backup_dir: db.getBackupDir() };
|
|
2650
|
+
let dbSize = 0;
|
|
2651
|
+
try { dbSize = require('fs').statSync(dbPath).size; } catch {}
|
|
2652
|
+
return {
|
|
2653
|
+
service: 'ctm',
|
|
2654
|
+
label: 'CTM',
|
|
2655
|
+
db_path: dbPath,
|
|
2656
|
+
data_dir: dbPath ? path.dirname(dbPath) : '',
|
|
2657
|
+
db_size: dbSize,
|
|
2658
|
+
backup_dir: backupInfo.backup_dir || db.getBackupDir(),
|
|
2659
|
+
default_backup_dir: backupInfo.default_backup_dir || '',
|
|
2660
|
+
configured_backup_dir: backupInfo.configured_backup_dir || '',
|
|
2661
|
+
backup_dir_source: backupInfo.source || 'default',
|
|
2662
|
+
};
|
|
2663
|
+
}
|
|
1969
2664
|
|
|
1970
|
-
|
|
1971
|
-
const
|
|
2665
|
+
async function _requestWalleData(pathname, opts = {}, timeoutMs = 5000) {
|
|
2666
|
+
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
|
2667
|
+
let timer = null;
|
|
2668
|
+
if (controller) timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
2669
|
+
try {
|
|
2670
|
+
const upstream = await walleClient.requestJson(pathname, { ...opts, signal: controller ? controller.signal : null });
|
|
2671
|
+
if (upstream.status < 200 || upstream.status >= 300 || upstream.json?.error) {
|
|
2672
|
+
const err = new Error(upstream.json?.error || upstream.body || `Wall-E returned ${upstream.status}`);
|
|
2673
|
+
err.status = upstream.status;
|
|
2674
|
+
throw err;
|
|
2675
|
+
}
|
|
2676
|
+
return upstream.json?.data || upstream.json || {};
|
|
2677
|
+
} finally {
|
|
2678
|
+
if (timer) clearTimeout(timer);
|
|
2679
|
+
}
|
|
2680
|
+
}
|
|
1972
2681
|
|
|
1973
|
-
function
|
|
1974
|
-
|
|
2682
|
+
async function _storageLocations() {
|
|
2683
|
+
const ctm = _ctmStorageInfo();
|
|
2684
|
+
let walle = null;
|
|
2685
|
+
let walleError = '';
|
|
2686
|
+
try {
|
|
2687
|
+
walle = await _requestWalleData('/api/wall-e/storage', {}, 5000);
|
|
2688
|
+
walle.service = 'walle';
|
|
2689
|
+
walle.label = 'Wall-E';
|
|
2690
|
+
} catch (e) {
|
|
2691
|
+
walleError = e.message || String(e);
|
|
2692
|
+
}
|
|
2693
|
+
return { ctm, walle, walle_error: walleError };
|
|
1975
2694
|
}
|
|
1976
2695
|
|
|
1977
|
-
function
|
|
1978
|
-
|
|
2696
|
+
async function handleGetStorageLocations(req, res) {
|
|
2697
|
+
try {
|
|
2698
|
+
const locations = await _storageLocations();
|
|
2699
|
+
jsonResponse(res, 200, { ok: true, ...locations, migration: storageMigration.readActiveMigration() });
|
|
2700
|
+
} catch (e) {
|
|
2701
|
+
jsonResponse(res, 500, { error: e.message });
|
|
2702
|
+
}
|
|
1979
2703
|
}
|
|
1980
2704
|
|
|
1981
|
-
function
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
2705
|
+
async function handlePutBackupDirs(req, res) {
|
|
2706
|
+
try {
|
|
2707
|
+
const data = await readBody(req, 64 * 1024);
|
|
2708
|
+
const result = await _applyBackupDirChanges(data);
|
|
2709
|
+
jsonResponse(res, result.ok ? 200 : 207, result);
|
|
2710
|
+
} catch (e) {
|
|
2711
|
+
jsonResponse(res, 400, { error: e.message });
|
|
2712
|
+
}
|
|
2713
|
+
}
|
|
2714
|
+
|
|
2715
|
+
async function _applyBackupDirChanges(data = {}) {
|
|
2716
|
+
const moveExisting = data.move_existing !== false && data.moveExisting !== false;
|
|
2717
|
+
const result = { ok: true, ctm: null, walle: null, errors: [] };
|
|
2718
|
+
if (Object.prototype.hasOwnProperty.call(data, 'ctm_backup_dir')) {
|
|
2719
|
+
result.ctm = db.setBackupDir(data.ctm_backup_dir || '', { persist: true, moveExisting });
|
|
2720
|
+
}
|
|
2721
|
+
if (Object.prototype.hasOwnProperty.call(data, 'walle_backup_dir')) {
|
|
2722
|
+
try {
|
|
2723
|
+
result.walle = await _requestWalleData('/api/wall-e/storage/backup-dir', {
|
|
2724
|
+
method: 'PUT',
|
|
2725
|
+
body: { backup_dir: data.walle_backup_dir || '', move_existing: moveExisting },
|
|
2726
|
+
}, 10000);
|
|
2727
|
+
} catch (e) {
|
|
2728
|
+
result.ok = false;
|
|
2729
|
+
result.errors.push({ service: 'walle', error: e.message || String(e) });
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
return result;
|
|
2733
|
+
}
|
|
2734
|
+
|
|
2735
|
+
async function _buildPlanFromRequest(data) {
|
|
2736
|
+
const locations = await _storageLocations();
|
|
2737
|
+
return storageMigration.buildStorageMigrationPlan(data || {}, {
|
|
2738
|
+
dbModule: db,
|
|
2739
|
+
ctmCurrent: locations.ctm,
|
|
2740
|
+
walleCurrent: locations.walle || {
|
|
2741
|
+
data_dir: '',
|
|
2742
|
+
db_path: '',
|
|
2743
|
+
backup_dir: '',
|
|
2744
|
+
},
|
|
1988
2745
|
});
|
|
1989
|
-
jsonResponse(res, 200, result);
|
|
1990
2746
|
}
|
|
1991
2747
|
|
|
1992
|
-
function
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
2748
|
+
async function handlePreviewStorageMigration(req, res) {
|
|
2749
|
+
try {
|
|
2750
|
+
const data = await readBody(req, 128 * 1024);
|
|
2751
|
+
const plan = await _buildPlanFromRequest(data);
|
|
2752
|
+
jsonResponse(res, 200, { ok: true, plan });
|
|
2753
|
+
} catch (e) {
|
|
2754
|
+
jsonResponse(res, 400, { error: e.message });
|
|
1996
2755
|
}
|
|
1997
|
-
|
|
1998
|
-
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
async function handleApplyStorageMigration(req, res) {
|
|
2759
|
+
try {
|
|
2760
|
+
const data = await readBody(req, 128 * 1024);
|
|
2761
|
+
if (data.confirm !== true) return jsonResponse(res, 400, { error: 'confirmation_required' });
|
|
2762
|
+
const plan = await _buildPlanFromRequest(data);
|
|
2763
|
+
if (!plan.requires_restart) {
|
|
2764
|
+
const services = data.services || {};
|
|
2765
|
+
const backupInput = {
|
|
2766
|
+
move_existing: services.ctm?.move_backups !== false && services.walle?.move_backups !== false,
|
|
2767
|
+
};
|
|
2768
|
+
if (services.ctm && (Object.prototype.hasOwnProperty.call(services.ctm, 'backup_dir') || Object.prototype.hasOwnProperty.call(services.ctm, 'backupDir'))) {
|
|
2769
|
+
backupInput.ctm_backup_dir = services.ctm.backup_dir || services.ctm.backupDir || '';
|
|
2770
|
+
}
|
|
2771
|
+
if (services.walle && (Object.prototype.hasOwnProperty.call(services.walle, 'backup_dir') || Object.prototype.hasOwnProperty.call(services.walle, 'backupDir'))) {
|
|
2772
|
+
backupInput.walle_backup_dir = services.walle.backup_dir || services.walle.backupDir || '';
|
|
2773
|
+
}
|
|
2774
|
+
const backup = await _applyBackupDirChanges(backupInput);
|
|
2775
|
+
return jsonResponse(res, backup.ok ? 200 : 207, { ok: backup.ok, plan, backup });
|
|
2776
|
+
}
|
|
2777
|
+
const started = storageMigration.spawnStorageMigrationSupervisor(plan);
|
|
2778
|
+
jsonResponse(res, 202, { ok: true, plan, migration: started });
|
|
2779
|
+
} catch (e) {
|
|
2780
|
+
jsonResponse(res, 400, { error: e.message });
|
|
2781
|
+
}
|
|
2782
|
+
}
|
|
1999
2783
|
|
|
2784
|
+
// ============================================================
|
|
2785
|
+
// Tool Permissions — CTM's standalone, global permission config.
|
|
2786
|
+
// ============================================================
|
|
2787
|
+
// CTM permissions live ONLY in the CTM DB (perm_rules table) and are global
|
|
2788
|
+
// (apply to all projects/sessions). They are intentionally decoupled from
|
|
2789
|
+
// Claude Code / Codex settings files — CTM no longer reads or writes
|
|
2790
|
+
// ~/.claude/settings*.json, project .claude/settings.json, or ~/.claude.json.
|
|
2791
|
+
// The shadow approver (approval-agent.js + lib/permission-match.js) enforces them.
|
|
2792
|
+
|
|
2793
|
+
function handleGetProjects(req, res) {
|
|
2794
|
+
// CTM permissions are global — no per-project scoping, no Claude config read.
|
|
2795
|
+
jsonResponse(res, 200, []);
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2798
|
+
async function handleGetToolPermRules(req, res) {
|
|
2799
|
+
// CTM permissions are a standalone GLOBAL config stored only in the CTM DB —
|
|
2800
|
+
// no reading from Claude/Codex settings files. Every rule is global.
|
|
2801
|
+
const allRules = db.listPermRules();
|
|
2000
2802
|
const rules = [];
|
|
2001
2803
|
const denyRules = [];
|
|
2002
|
-
const projectSet = new Set();
|
|
2003
|
-
|
|
2004
2804
|
for (const r of allRules) {
|
|
2005
|
-
const entry = { scope:
|
|
2006
|
-
if (r.list_type === '
|
|
2007
|
-
else
|
|
2008
|
-
if (r.project !== '__global__') projectSet.add(r.project);
|
|
2009
|
-
}
|
|
2010
|
-
|
|
2011
|
-
// Also include projects from ~/.claude.json that might not have rules
|
|
2012
|
-
const claudeJson = readJsonFile(CLAUDE_JSON_PATH);
|
|
2013
|
-
for (const p of Object.keys(claudeJson.projects || {})) {
|
|
2014
|
-
projectSet.add(p);
|
|
2805
|
+
const entry = { scope: 'global', project: '__global__', rule: r.rule };
|
|
2806
|
+
if (r.list_type === 'deny') denyRules.push(entry);
|
|
2807
|
+
else rules.push(entry);
|
|
2015
2808
|
}
|
|
2016
|
-
|
|
2017
|
-
jsonResponse(res, 200, { rules, denyRules, projects: Array.from(projectSet) });
|
|
2809
|
+
jsonResponse(res, 200, { rules, denyRules, projects: [] });
|
|
2018
2810
|
}
|
|
2019
2811
|
|
|
2020
2812
|
async function handleSetToolPermRules(req, res) {
|
|
2021
2813
|
try {
|
|
2022
2814
|
const body = await readBody(req);
|
|
2023
|
-
const { action, rule,
|
|
2815
|
+
const { action, rule, listType } = body;
|
|
2024
2816
|
const lt = listType === 'deny' ? 'deny' : 'allow';
|
|
2025
|
-
const proj = project || '__global__';
|
|
2026
|
-
const scope = proj === '__global__' ? 'global' : 'project';
|
|
2027
2817
|
|
|
2028
2818
|
if (action === 'add') {
|
|
2029
|
-
|
|
2819
|
+
// Permission policy: rules apply globally to ALL projects. Every add is
|
|
2820
|
+
// stored as a global rule (~/.claude/settings.json) regardless of the UI
|
|
2821
|
+
// scope, so a permission granted once is honored everywhere.
|
|
2822
|
+
db.addPermRule({ rule, listType: lt, scope: 'global', project: '__global__' });
|
|
2030
2823
|
} else if (action === 'remove') {
|
|
2031
|
-
|
|
2824
|
+
// Rules are global; remove the global row (legacy per-project rows are
|
|
2825
|
+
// collapsed to global by the db migration).
|
|
2826
|
+
db.removePermRule({ rule, listType: lt, project: '__global__' });
|
|
2032
2827
|
}
|
|
2033
2828
|
|
|
2034
|
-
//
|
|
2035
|
-
|
|
2829
|
+
// CTM permissions are a standalone config in the CTM DB — intentionally NOT
|
|
2830
|
+
// synced to Claude/Codex settings files. The shadow approver enforces them.
|
|
2036
2831
|
|
|
2037
2832
|
jsonResponse(res, 200, { ok: true });
|
|
2038
2833
|
} catch (e) {
|
|
@@ -2569,20 +3364,21 @@ function handleListQueues(req, res) {
|
|
|
2569
3364
|
async function handleCreateQueue(req, res) {
|
|
2570
3365
|
try {
|
|
2571
3366
|
const body = await readBody(req);
|
|
2572
|
-
const { sessionId, mode, items, idleTimeoutMs, autoStart } = body;
|
|
3367
|
+
const { sessionId, mode, items, idleTimeoutMs, autoStart, append, strategy } = body;
|
|
2573
3368
|
if (!sessionId || !Array.isArray(items) || items.length === 0) {
|
|
2574
3369
|
return jsonResponse(res, 400, { error: 'sessionId and non-empty items[] required' });
|
|
2575
3370
|
}
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
|
|
3371
|
+
const appendMode = append === true || strategy === 'append';
|
|
3372
|
+
let state = appendMode
|
|
3373
|
+
? queueEngine.appendItems(sessionId, { mode, items, idleTimeoutMs, autoStart })
|
|
3374
|
+
: queueEngine.createQueue(sessionId, { mode, items, idleTimeoutMs });
|
|
3375
|
+
if (!appendMode && autoStart) state = queueEngine.start(sessionId) || state;
|
|
2580
3376
|
jsonResponse(res, 201, state);
|
|
2581
3377
|
} catch (e) { jsonResponse(res, 400, { error: e.message }); }
|
|
2582
3378
|
}
|
|
2583
3379
|
|
|
2584
3380
|
function handleGetQueue(req, res, sessionId) {
|
|
2585
|
-
const state = queueEngine.getState(sessionId);
|
|
3381
|
+
const state = queueEngine.getState(sessionId) || queueEngine.getPersistedState(sessionId);
|
|
2586
3382
|
if (!state) {
|
|
2587
3383
|
res.writeHead(204);
|
|
2588
3384
|
res.end();
|
|
@@ -2602,8 +3398,23 @@ async function handleQueueAction(req, res, sessionId, action) {
|
|
|
2602
3398
|
case 'start': state = queueEngine.start(sessionId); break;
|
|
2603
3399
|
case 'pause': state = queueEngine.pause(sessionId); break;
|
|
2604
3400
|
case 'resume': state = queueEngine.resume(sessionId); break;
|
|
2605
|
-
case 'next': state = queueEngine.
|
|
3401
|
+
case 'next': state = queueEngine.wake(sessionId, 'manual-next'); break;
|
|
2606
3402
|
case 'skip': state = queueEngine.skip(sessionId); break;
|
|
3403
|
+
case 'remove': {
|
|
3404
|
+
try {
|
|
3405
|
+
const body = await readBody(req);
|
|
3406
|
+
state = queueEngine.removeItem(sessionId, String(body.itemId || body.id || ''));
|
|
3407
|
+
} catch (e) { return jsonResponse(res, 400, { error: e.message }); }
|
|
3408
|
+
break;
|
|
3409
|
+
}
|
|
3410
|
+
case 'reorder': {
|
|
3411
|
+
try {
|
|
3412
|
+
const body = await readBody(req);
|
|
3413
|
+
const ids = Array.isArray(body.order) ? body.order : (Array.isArray(body.ids) ? body.ids : []);
|
|
3414
|
+
state = queueEngine.reorderItems(sessionId, ids.map(String));
|
|
3415
|
+
} catch (e) { return jsonResponse(res, 400, { error: e.message }); }
|
|
3416
|
+
break;
|
|
3417
|
+
}
|
|
2607
3418
|
case 'stop': state = queueEngine.stop(sessionId); break;
|
|
2608
3419
|
case 'mode': {
|
|
2609
3420
|
try {
|
|
@@ -2613,7 +3424,13 @@ async function handleQueueAction(req, res, sessionId, action) {
|
|
|
2613
3424
|
break;
|
|
2614
3425
|
}
|
|
2615
3426
|
}
|
|
2616
|
-
if (!state)
|
|
3427
|
+
if (!state) {
|
|
3428
|
+
const persisted = queueEngine.getPersistedState(sessionId);
|
|
3429
|
+
if (action === 'next' && persisted && persisted.status === 'done') {
|
|
3430
|
+
return jsonResponse(res, 200, persisted);
|
|
3431
|
+
}
|
|
3432
|
+
return jsonResponse(res, 404, { error: 'No queue for this session' });
|
|
3433
|
+
}
|
|
2617
3434
|
jsonResponse(res, 200, state);
|
|
2618
3435
|
}
|
|
2619
3436
|
|
|
@@ -2651,6 +3468,35 @@ function handleDeleteQueueLinkedItems(req, res, promptId) {
|
|
|
2651
3468
|
|
|
2652
3469
|
// --- Queue Draft (per-session builder state persisted to DB) ---
|
|
2653
3470
|
|
|
3471
|
+
function queueDraftStatusTimestamp(value) {
|
|
3472
|
+
const n = Number(value);
|
|
3473
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
3474
|
+
}
|
|
3475
|
+
|
|
3476
|
+
function mergeQueueDraftItemsByStatusTimestamp(existingItems, incomingItems) {
|
|
3477
|
+
const incoming = Array.isArray(incomingItems) ? incomingItems : [];
|
|
3478
|
+
const existing = Array.isArray(existingItems) ? existingItems : [];
|
|
3479
|
+
const existingById = new Map();
|
|
3480
|
+
for (const item of existing) {
|
|
3481
|
+
if (item && item.id) existingById.set(item.id, item);
|
|
3482
|
+
}
|
|
3483
|
+
return incoming.map((item) => {
|
|
3484
|
+
if (!item || typeof item !== 'object') return item;
|
|
3485
|
+
const previous = item.id ? existingById.get(item.id) : null;
|
|
3486
|
+
if (!previous) return item;
|
|
3487
|
+
const previousStatusAt = queueDraftStatusTimestamp(previous.statusUpdatedAt);
|
|
3488
|
+
const incomingStatusAt = queueDraftStatusTimestamp(item.statusUpdatedAt);
|
|
3489
|
+
if (previousStatusAt > incomingStatusAt && previous.status) {
|
|
3490
|
+
return {
|
|
3491
|
+
...item,
|
|
3492
|
+
status: previous.status,
|
|
3493
|
+
statusUpdatedAt: previous.statusUpdatedAt,
|
|
3494
|
+
};
|
|
3495
|
+
}
|
|
3496
|
+
return item;
|
|
3497
|
+
});
|
|
3498
|
+
}
|
|
3499
|
+
|
|
2654
3500
|
function handleGetQueueDraft(req, res, sessionId) {
|
|
2655
3501
|
const key = 'queue_draft_' + sessionId;
|
|
2656
3502
|
const draft = db.getSetting(key, { items: [], mode: 'manual' });
|
|
@@ -2662,7 +3508,10 @@ async function handleSaveQueueDraft(req, res, sessionId) {
|
|
|
2662
3508
|
const body = await readBody(req);
|
|
2663
3509
|
const key = 'queue_draft_' + sessionId;
|
|
2664
3510
|
const existing = db.getSetting(key, { items: [], mode: 'manual' });
|
|
2665
|
-
if (body.items !== undefined)
|
|
3511
|
+
if (body.items !== undefined) {
|
|
3512
|
+
existing.items = mergeQueueDraftItemsByStatusTimestamp(existing.items, body.items);
|
|
3513
|
+
if (body.savedAt !== undefined) existing.savedAt = body.savedAt;
|
|
3514
|
+
}
|
|
2666
3515
|
if (body.mode !== undefined) existing.mode = body.mode;
|
|
2667
3516
|
db.setSetting(key, existing);
|
|
2668
3517
|
jsonResponse(res, 200, { ok: true });
|
|
@@ -2684,6 +3533,94 @@ function handleListApprovalRules(req, res) {
|
|
|
2684
3533
|
});
|
|
2685
3534
|
}
|
|
2686
3535
|
|
|
3536
|
+
// Grouping key for an escalation row: prefer the recorded signature; for legacy
|
|
3537
|
+
// rows (recorded before signatures were stored) derive it from the captured
|
|
3538
|
+
// context via the same helper the approver uses, so old + new rows group together.
|
|
3539
|
+
function _escalationKeyFn(row) {
|
|
3540
|
+
if (row && row.command_signature) return row.command_signature;
|
|
3541
|
+
try {
|
|
3542
|
+
return approvalAgent.escalationCommandParts({
|
|
3543
|
+
toolName: row && row.tool_name,
|
|
3544
|
+
command: '',
|
|
3545
|
+
fullContext: row && row.full_context,
|
|
3546
|
+
}).signature;
|
|
3547
|
+
} catch { return ''; }
|
|
3548
|
+
}
|
|
3549
|
+
|
|
3550
|
+
// GET /api/approval-escalations — escalated commands grouped by TYPE for the
|
|
3551
|
+
// Permission "Needs Review" surface (collapses a huge pile into a few reviewable
|
|
3552
|
+
// types, secrets masked).
|
|
3553
|
+
function handleListEscalations(req, res) {
|
|
3554
|
+
try {
|
|
3555
|
+
const rows = db.getPendingEscalations() || [];
|
|
3556
|
+
const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
|
|
3557
|
+
jsonResponse(res, 200, { groups, total: rows.length, typeCount: groups.length });
|
|
3558
|
+
} catch (e) {
|
|
3559
|
+
console.error('[escalations] list error:', e.message);
|
|
3560
|
+
jsonResponse(res, 500, { error: e.message });
|
|
3561
|
+
}
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
// POST /api/approval-escalations/resolve — resolve a whole TYPE at once.
|
|
3565
|
+
// body: { action: 'whitelist'|'block'|'dismiss'|'dismiss-all', ids:[...], rule? }
|
|
3566
|
+
// whitelist/block create an authoritative perm_rules allow/deny (honored by the
|
|
3567
|
+
// approver + shown in the rule list); all matching escalated rows are cleared.
|
|
3568
|
+
async function handleResolveEscalations(req, res) {
|
|
3569
|
+
try {
|
|
3570
|
+
const body = await readBody(req);
|
|
3571
|
+
const action = String(body.action || '').toLowerCase();
|
|
3572
|
+
const rule = String(body.rule || '').trim();
|
|
3573
|
+
if (!['whitelist', 'block', 'dismiss', 'dismiss-all', 'approve-all'].includes(action)) {
|
|
3574
|
+
return jsonResponse(res, 400, { error: 'invalid_action' });
|
|
3575
|
+
}
|
|
3576
|
+
// Bulk approve: whitelist EVERY pending type (one allow rule per group from its
|
|
3577
|
+
// suggested narrow pattern) and clear the queue. Re-group server-side so the
|
|
3578
|
+
// action is atomic and can't be skewed by a stale client view.
|
|
3579
|
+
if (action === 'approve-all') {
|
|
3580
|
+
const rows = db.getPendingEscalations() || [];
|
|
3581
|
+
const groups = escalationReview.groupEscalations(rows, _escalationKeyFn);
|
|
3582
|
+
let resolved = 0;
|
|
3583
|
+
let rulesCreated = 0;
|
|
3584
|
+
for (const g of groups) {
|
|
3585
|
+
const r = String(g.suggestedRule || '').trim();
|
|
3586
|
+
if (/^[A-Za-z]+\([^)]*\)$/.test(r)) {
|
|
3587
|
+
try {
|
|
3588
|
+
db.addPermRule({ rule: r, listType: 'allow', scope: 'global', project: '__global__' });
|
|
3589
|
+
rulesCreated += 1;
|
|
3590
|
+
} catch (e) { /* rule may already exist — still clear the rows below */ }
|
|
3591
|
+
}
|
|
3592
|
+
for (const id of (g.ids || [])) {
|
|
3593
|
+
try { db.resolveApprovalDecision(id, 'approved'); resolved += 1; } catch (e) { /* skip */ }
|
|
3594
|
+
}
|
|
3595
|
+
}
|
|
3596
|
+
return jsonResponse(res, 200, { ok: true, action, resolved, rulesCreated, typeCount: groups.length });
|
|
3597
|
+
}
|
|
3598
|
+
let createdRule = null;
|
|
3599
|
+
if (action === 'whitelist' || action === 'block') {
|
|
3600
|
+
if (!/^[A-Za-z]+\([^)]*\)$/.test(rule)) {
|
|
3601
|
+
return jsonResponse(res, 400, { error: 'rule must look like Bash(cmd:*)' });
|
|
3602
|
+
}
|
|
3603
|
+
db.addPermRule({ rule, listType: action === 'whitelist' ? 'allow' : 'deny', scope: 'global', project: '__global__' });
|
|
3604
|
+
createdRule = rule;
|
|
3605
|
+
}
|
|
3606
|
+
let targetIds;
|
|
3607
|
+
if (action === 'dismiss-all') {
|
|
3608
|
+
targetIds = (db.getPendingEscalations() || []).map((r) => r.id);
|
|
3609
|
+
} else {
|
|
3610
|
+
targetIds = (Array.isArray(body.ids) ? body.ids : []).map(Number).filter(Number.isFinite);
|
|
3611
|
+
if (!targetIds.length) return jsonResponse(res, 400, { error: 'ids_required' });
|
|
3612
|
+
}
|
|
3613
|
+
const resolveDecision = action === 'block' ? 'denied' : action === 'whitelist' ? 'approved' : 'dismissed';
|
|
3614
|
+
let resolved = 0;
|
|
3615
|
+
for (const id of targetIds) {
|
|
3616
|
+
try { db.resolveApprovalDecision(id, resolveDecision); resolved += 1; } catch (e) { /* skip */ }
|
|
3617
|
+
}
|
|
3618
|
+
jsonResponse(res, 200, { ok: true, action, resolved, rule: createdRule });
|
|
3619
|
+
} catch (e) {
|
|
3620
|
+
jsonResponse(res, 400, { error: e.message });
|
|
3621
|
+
}
|
|
3622
|
+
}
|
|
3623
|
+
|
|
2687
3624
|
async function handleUpsertApprovalRule(req, res) {
|
|
2688
3625
|
try {
|
|
2689
3626
|
const body = await readBody(req);
|
|
@@ -2737,23 +3674,84 @@ async function handleResolveApprovalDecision(req, res, id) {
|
|
|
2737
3674
|
} catch (e) { jsonResponse(res, 400, { error: e.message }); }
|
|
2738
3675
|
}
|
|
2739
3676
|
|
|
2740
|
-
// --- Dangerous-command blocklist (
|
|
2741
|
-
// GET /api/approval/blocklist
|
|
2742
|
-
//
|
|
3677
|
+
// --- Dangerous-command blocklist (configurable; default ON) ---
|
|
3678
|
+
// GET /api/approval/blocklist
|
|
3679
|
+
// -> { enabled, patterns: [defaults], disabledIds: [int], custom: [{source,flags,reason,category,id}] }
|
|
3680
|
+
// POST /api/approval/blocklist { enabled?, disabledIds?, customPatterns? }
|
|
3681
|
+
// -> the same shape as GET (the merged view). Any field omitted is left as-is.
|
|
3682
|
+
// Custom patterns are validated server-side; an invalid one returns 400 and
|
|
3683
|
+
// nothing is persisted. The hard floor is editable but never silently broken.
|
|
3684
|
+
//
|
|
3685
|
+
// Persistence: `approval_blocklist_enabled` (bool) + `approval_blocklist_config`
|
|
3686
|
+
// ({ disabledIds:[int], customPatterns:[{source,flags,reason,category}] }).
|
|
3687
|
+
// The agent reads both on every check (approval-agent isBlocklistEnabled /
|
|
3688
|
+
// getBlocklistConfig), so edits take effect without a restart.
|
|
3689
|
+
function _blocklistView() {
|
|
3690
|
+
const { PATTERN_META } = require('./workers/approval-blocklist');
|
|
3691
|
+
const enabled = db.getSetting('approval_blocklist_enabled', true) !== false;
|
|
3692
|
+
const cfg = db.getSetting('approval_blocklist_config', null) || {};
|
|
3693
|
+
const disabledIds = Array.isArray(cfg.disabledIds)
|
|
3694
|
+
? cfg.disabledIds.filter((n) => Number.isInteger(n) && n >= 0 && n < PATTERN_META.length)
|
|
3695
|
+
: [];
|
|
3696
|
+
const custom = Array.isArray(cfg.customPatterns)
|
|
3697
|
+
? cfg.customPatterns.map((p, i) => ({
|
|
3698
|
+
id: (p && p.id != null) ? p.id : `c${i}`,
|
|
3699
|
+
source: String((p && p.source) || ''),
|
|
3700
|
+
flags: String((p && p.flags) || ''),
|
|
3701
|
+
reason: String((p && p.reason) || 'Custom blocklist pattern'),
|
|
3702
|
+
category: String((p && p.category) || 'custom'),
|
|
3703
|
+
}))
|
|
3704
|
+
: [];
|
|
3705
|
+
return { enabled, patterns: PATTERN_META, disabledIds, custom };
|
|
3706
|
+
}
|
|
2743
3707
|
function handleGetBlocklist(_req, res) {
|
|
2744
3708
|
try {
|
|
2745
|
-
|
|
2746
|
-
const enabled = !!db.getSetting('approval_blocklist_enabled', false);
|
|
2747
|
-
jsonResponse(res, 200, { enabled, patterns: PATTERN_META });
|
|
3709
|
+
jsonResponse(res, 200, _blocklistView());
|
|
2748
3710
|
} catch (e) { jsonResponse(res, 500, { error: e.message }); }
|
|
2749
3711
|
}
|
|
2750
3712
|
async function handleSetBlocklistEnabled(req, res) {
|
|
2751
3713
|
try {
|
|
2752
|
-
const
|
|
2753
|
-
const
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
3714
|
+
const { validateUserPattern, PATTERN_META } = require('./workers/approval-blocklist');
|
|
3715
|
+
const body = await readBody(req) || {};
|
|
3716
|
+
|
|
3717
|
+
if (Object.prototype.hasOwnProperty.call(body, 'enabled')) {
|
|
3718
|
+
db.setSetting('approval_blocklist_enabled', !!body.enabled);
|
|
3719
|
+
console.log(`[approval-blocklist] enabled=${!!body.enabled}`);
|
|
3720
|
+
}
|
|
3721
|
+
|
|
3722
|
+
const touchesConfig = Object.prototype.hasOwnProperty.call(body, 'disabledIds')
|
|
3723
|
+
|| Object.prototype.hasOwnProperty.call(body, 'customPatterns');
|
|
3724
|
+
if (touchesConfig) {
|
|
3725
|
+
const cfg = db.getSetting('approval_blocklist_config', null) || {};
|
|
3726
|
+
|
|
3727
|
+
if (Object.prototype.hasOwnProperty.call(body, 'disabledIds')) {
|
|
3728
|
+
if (!Array.isArray(body.disabledIds)) return jsonResponse(res, 400, { error: 'disabledIds must be an array of pattern ids' });
|
|
3729
|
+
const ids = [];
|
|
3730
|
+
for (const raw of body.disabledIds) {
|
|
3731
|
+
const n = Number(raw);
|
|
3732
|
+
if (!Number.isInteger(n) || n < 0 || n >= PATTERN_META.length) return jsonResponse(res, 400, { error: `invalid pattern id: ${raw}` });
|
|
3733
|
+
if (!ids.includes(n)) ids.push(n);
|
|
3734
|
+
}
|
|
3735
|
+
cfg.disabledIds = ids;
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
if (Object.prototype.hasOwnProperty.call(body, 'customPatterns')) {
|
|
3739
|
+
if (!Array.isArray(body.customPatterns)) return jsonResponse(res, 400, { error: 'customPatterns must be an array' });
|
|
3740
|
+
if (body.customPatterns.length > 200) return jsonResponse(res, 400, { error: 'too many custom patterns (max 200)' });
|
|
3741
|
+
const normalized = [];
|
|
3742
|
+
for (let i = 0; i < body.customPatterns.length; i++) {
|
|
3743
|
+
const v = validateUserPattern(body.customPatterns[i]);
|
|
3744
|
+
if (!v.ok) return jsonResponse(res, 400, { error: `pattern ${i + 1}: ${v.error}` });
|
|
3745
|
+
normalized.push(v.normalized); // { source, flags, reason, category }
|
|
3746
|
+
}
|
|
3747
|
+
cfg.customPatterns = normalized;
|
|
3748
|
+
}
|
|
3749
|
+
|
|
3750
|
+
db.setSetting('approval_blocklist_config', cfg);
|
|
3751
|
+
console.log(`[approval-blocklist] config updated: ${(cfg.disabledIds || []).length} disabled, ${(cfg.customPatterns || []).length} custom`);
|
|
3752
|
+
}
|
|
3753
|
+
|
|
3754
|
+
jsonResponse(res, 200, _blocklistView());
|
|
2757
3755
|
} catch (e) { jsonResponse(res, 400, { error: e.message }); }
|
|
2758
3756
|
}
|
|
2759
3757
|
|
|
@@ -3036,22 +4034,53 @@ function handlePromptQuality(req, res, url) {
|
|
|
3036
4034
|
jsonResponse(res, 200, harvest.assessPromptQuality(text));
|
|
3037
4035
|
}
|
|
3038
4036
|
|
|
3039
|
-
|
|
4037
|
+
// Injected by server.js: run the list query on the read-pool (off the main loop)
|
|
4038
|
+
// and resolve to the executions array, or null when the pool is unavailable so we
|
|
4039
|
+
// fall back to the main-thread query. SELECT * with a large LIMIT blocked the loop
|
|
4040
|
+
// ~3.9s on the request path per a CPU profile.
|
|
4041
|
+
let _promptExecutionsOffThread = null;
|
|
4042
|
+
function setPromptExecutionsOffThread(fn) { _promptExecutionsOffThread = typeof fn === 'function' ? fn : null; }
|
|
4043
|
+
|
|
4044
|
+
async function handleListExecutions(req, res, url) {
|
|
4045
|
+
const opts = {
|
|
4046
|
+
limit: url.searchParams.get('limit'),
|
|
4047
|
+
role: url.searchParams.get('role') || null,
|
|
4048
|
+
project: url.searchParams.get('project') || null,
|
|
4049
|
+
after: url.searchParams.get('after') || url.searchParams.get('since') || null,
|
|
4050
|
+
order: url.searchParams.get('order') || 'desc',
|
|
4051
|
+
};
|
|
3040
4052
|
try {
|
|
3041
|
-
|
|
3042
|
-
|
|
3043
|
-
|
|
3044
|
-
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
if (role) { sql += ' AND role = ?'; params.push(role); }
|
|
3048
|
-
if (project) { sql += ' AND project_path LIKE ?'; params.push('%' + project + '%'); }
|
|
3049
|
-
sql += ' ORDER BY executed_at DESC LIMIT ?';
|
|
3050
|
-
params.push(limit);
|
|
3051
|
-
jsonResponse(res, 200, { executions: d.prepare(sql).all(...params) });
|
|
4053
|
+
if (_promptExecutionsOffThread) {
|
|
4054
|
+
const executions = await _promptExecutionsOffThread(opts);
|
|
4055
|
+
if (executions) return jsonResponse(res, 200, { executions });
|
|
4056
|
+
// null → pool unavailable; fall through to the main-thread query.
|
|
4057
|
+
}
|
|
4058
|
+
jsonResponse(res, 200, { executions: queryPromptExecutions(db.getDb(), opts) });
|
|
3052
4059
|
} catch (e) { jsonResponse(res, 200, { executions: [] }); }
|
|
3053
4060
|
}
|
|
3054
4061
|
|
|
4062
|
+
function handlePromptExecutionStats(req, res) {
|
|
4063
|
+
try {
|
|
4064
|
+
const d = db.getDb();
|
|
4065
|
+
const totalSessions = d.prepare('SELECT COUNT(DISTINCT session_id) AS count FROM prompt_executions').get().count;
|
|
4066
|
+
const totalPairs = d.prepare(`
|
|
4067
|
+
SELECT COUNT(*) AS count FROM prompt_executions u
|
|
4068
|
+
JOIN prompt_executions a ON a.session_id = u.session_id AND a.message_index = u.message_index + 1
|
|
4069
|
+
WHERE u.role = 'user' AND a.role = 'assistant'
|
|
4070
|
+
AND length(u.message_text) >= 20 AND length(a.message_text) >= 20
|
|
4071
|
+
`).get().count;
|
|
4072
|
+
const multiTurnSessions = d.prepare(`
|
|
4073
|
+
SELECT COUNT(*) AS count FROM (
|
|
4074
|
+
SELECT session_id FROM prompt_executions WHERE role = 'user'
|
|
4075
|
+
GROUP BY session_id HAVING COUNT(*) >= 2
|
|
4076
|
+
)
|
|
4077
|
+
`).get().count;
|
|
4078
|
+
jsonResponse(res, 200, { totalSessions, totalPairs, multiTurnSessions });
|
|
4079
|
+
} catch (e) {
|
|
4080
|
+
jsonResponse(res, 200, { totalSessions: 0, totalPairs: 0, multiTurnSessions: 0, error: e.message });
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
|
|
3055
4084
|
function handleSessionExecutions(req, res, sessionId) {
|
|
3056
4085
|
try {
|
|
3057
4086
|
const result = harvest.getPromptsForSession(sessionId);
|
|
@@ -3190,30 +4219,28 @@ async function handleImprovePrompt(req, res) {
|
|
|
3190
4219
|
|
|
3191
4220
|
function handleSkillAutocomplete(req, res, url) {
|
|
3192
4221
|
try {
|
|
3193
|
-
const query =
|
|
4222
|
+
const query = url.searchParams.get('q') || '';
|
|
3194
4223
|
const agent = normalizeSkillAutocompleteAgent(url.searchParams.get('agent') || url.searchParams.get('agentType'));
|
|
3195
4224
|
const cwd = url.searchParams.get('cwd') || '';
|
|
3196
4225
|
const includeSessionSeen = !agent || agent === 'all' || url.searchParams.get('includeSession') === '1';
|
|
3197
4226
|
const skills = buildSkillAutocompleteItems({ cwd, agent, includeSessionSeen });
|
|
3198
4227
|
|
|
3199
|
-
|
|
3200
|
-
if (query) {
|
|
3201
|
-
filtered = skills.filter(s => fuzzySkillMatch(s.name, query));
|
|
3202
|
-
}
|
|
3203
|
-
|
|
3204
|
-
filtered.sort((a, b) => {
|
|
3205
|
-
if (b.frequency !== a.frequency) return b.frequency - a.frequency;
|
|
3206
|
-
if (agent) {
|
|
3207
|
-
const sourceDelta = skillAutocomplete.skillSourcePriorityForAgent(a.source, agent) - skillAutocomplete.skillSourcePriorityForAgent(b.source, agent);
|
|
3208
|
-
if (sourceDelta) return sourceDelta;
|
|
3209
|
-
}
|
|
3210
|
-
return a.name.localeCompare(b.name);
|
|
3211
|
-
});
|
|
4228
|
+
const filtered = skillAutocomplete.filterAndSortSkillAutocompleteItems(skills, query, agent || 'all');
|
|
3212
4229
|
|
|
3213
4230
|
jsonResponse(res, 200, filtered.slice(0, 20));
|
|
3214
4231
|
} catch (e) { jsonResponse(res, 500, { error: e.message }); }
|
|
3215
4232
|
}
|
|
3216
4233
|
|
|
4234
|
+
function handleSkillResolveIntent(req, res, url) {
|
|
4235
|
+
try {
|
|
4236
|
+
const intent = url.searchParams.get('intent') || '';
|
|
4237
|
+
const agent = normalizeSkillAutocompleteAgent(url.searchParams.get('agent') || url.searchParams.get('agentType'));
|
|
4238
|
+
const cwd = url.searchParams.get('cwd') || '';
|
|
4239
|
+
const result = skillIntentResolver.resolveSkillIntent({ intent, agent, cwd });
|
|
4240
|
+
jsonResponse(res, result.ok === false ? 400 : 200, result);
|
|
4241
|
+
} catch (e) { jsonResponse(res, 500, { ok: false, error: e.message }); }
|
|
4242
|
+
}
|
|
4243
|
+
|
|
3217
4244
|
function normalizeSkillAutocompleteAgent(value) {
|
|
3218
4245
|
return skillAutocomplete.normalizeSkillAgentType(value);
|
|
3219
4246
|
}
|
|
@@ -3231,6 +4258,8 @@ function buildSkillAutocompleteItems({ cwd, agent, includeSessionSeen }) {
|
|
|
3231
4258
|
description: skill.description || '',
|
|
3232
4259
|
source: skill.source || 'skill',
|
|
3233
4260
|
agents: Array.isArray(skill.agents) ? skill.agents : [],
|
|
4261
|
+
capabilities: Array.isArray(skill.capabilities) ? skill.capabilities : [],
|
|
4262
|
+
invocation: skill.invocation || '',
|
|
3234
4263
|
execution: skill.execution || '',
|
|
3235
4264
|
path: skill.filePath || skill.path || '',
|
|
3236
4265
|
frequency: 0,
|
|
@@ -3268,14 +4297,6 @@ function sanitizeSkillAutocompleteName(value) {
|
|
|
3268
4297
|
return /^[A-Za-z0-9_.:-]{1,80}$/.test(name) ? name : '';
|
|
3269
4298
|
}
|
|
3270
4299
|
|
|
3271
|
-
function fuzzySkillMatch(name, query) {
|
|
3272
|
-
let qi = 0;
|
|
3273
|
-
for (const ch of String(name || '').toLowerCase()) {
|
|
3274
|
-
if (qi < query.length && ch === query[qi]) qi++;
|
|
3275
|
-
}
|
|
3276
|
-
return qi === query.length;
|
|
3277
|
-
}
|
|
3278
|
-
|
|
3279
4300
|
function handleHarvestStats(req, res) {
|
|
3280
4301
|
try {
|
|
3281
4302
|
const state = harvest.getHarvestState();
|
|
@@ -3326,4 +4347,4 @@ function safeParse(json, fallback) {
|
|
|
3326
4347
|
try { return JSON.parse(json); } catch { return fallback; }
|
|
3327
4348
|
}
|
|
3328
4349
|
|
|
3329
|
-
module.exports = { handlePromptApi, queueEngine,
|
|
4350
|
+
module.exports = { handlePromptApi, queueEngine, runIncrementalConversationImport, runCursorConversationImport, importSessionFile, setUiPrefsBroadcaster, setPromptExecutionsOffThread, setDbMaintenanceRunner, setImageSaveRunner, _ingestPathFromInput, _ingestSourceAllowed, _conversationImportCandidates, _ingestTranscriptStoreForParsedFile };
|