create-walle 0.9.28 → 0.9.29

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.
Files changed (105) hide show
  1. package/README.md +2 -2
  2. package/bin/create-walle.js +166 -6
  3. package/package.json +1 -1
  4. package/template/bin/ctm-launch.sh +7 -0
  5. package/template/bin/dev.sh +5 -0
  6. package/template/claude-task-manager/api-prompts.js +32 -15
  7. package/template/claude-task-manager/db.js +717 -38
  8. package/template/claude-task-manager/docs/backfill-incremental-no-main-fallback.md +48 -0
  9. package/template/claude-task-manager/docs/conversation-import-freshness.md +21 -0
  10. package/template/claude-task-manager/docs/conversation-log-redesign.html +587 -0
  11. package/template/claude-task-manager/lib/auth-rules.js +13 -0
  12. package/template/claude-task-manager/lib/codex-rollout-snapshot.js +53 -0
  13. package/template/claude-task-manager/lib/headless-term-service.js +246 -4
  14. package/template/claude-task-manager/lib/message-identity.js +115 -0
  15. package/template/claude-task-manager/lib/mirror-feed-guards.js +25 -0
  16. package/template/claude-task-manager/lib/path-suggest.js +77 -0
  17. package/template/claude-task-manager/lib/prompt-index-inputs.js +136 -0
  18. package/template/claude-task-manager/lib/real-node.js +36 -4
  19. package/template/claude-task-manager/lib/restore-auto-resume-policy.js +67 -0
  20. package/template/claude-task-manager/lib/restore-resume-batch.js +20 -0
  21. package/template/claude-task-manager/lib/restore-terminal-dims.js +109 -0
  22. package/template/claude-task-manager/lib/resume-cwd.js +124 -3
  23. package/template/claude-task-manager/lib/runtime-approval-recorder.js +152 -0
  24. package/template/claude-task-manager/lib/runtime-context-truth.js +226 -0
  25. package/template/claude-task-manager/lib/runtime-contract.js +195 -0
  26. package/template/claude-task-manager/lib/runtime-history-builder.js +205 -0
  27. package/template/claude-task-manager/lib/runtime-hook-bus.js +98 -0
  28. package/template/claude-task-manager/lib/runtime-input-queue.js +114 -0
  29. package/template/claude-task-manager/lib/runtime-input-recorder.js +156 -0
  30. package/template/claude-task-manager/lib/runtime-lineage.js +189 -0
  31. package/template/claude-task-manager/lib/runtime-registry.js +263 -0
  32. package/template/claude-task-manager/lib/runtime-session-history.js +41 -0
  33. package/template/claude-task-manager/lib/server-phase-conditions.js +103 -0
  34. package/template/claude-task-manager/lib/session-content-backfill.js +55 -8
  35. package/template/claude-task-manager/lib/session-db-read-contract.js +67 -0
  36. package/template/claude-task-manager/lib/session-history.js +5 -1
  37. package/template/claude-task-manager/lib/session-host-manager.js +154 -2
  38. package/template/claude-task-manager/lib/session-messages-defer.js +50 -0
  39. package/template/claude-task-manager/lib/session-messages-projection.js +37 -2
  40. package/template/claude-task-manager/lib/session-stream.js +19 -1
  41. package/template/claude-task-manager/lib/state-sync/cell-diff.js +41 -0
  42. package/template/claude-task-manager/lib/state-sync/frame-emitter.js +173 -0
  43. package/template/claude-task-manager/lib/state-sync/frame-rate.js +75 -0
  44. package/template/claude-task-manager/lib/state-sync/row-serializer.js +166 -0
  45. package/template/claude-task-manager/lib/terminal-fingerprint.js +19 -3
  46. package/template/claude-task-manager/lib/transcript-store.js +87 -6
  47. package/template/claude-task-manager/lib/wal-checkpoint-policy.js +40 -0
  48. package/template/claude-task-manager/lib/worktree-output-binding.js +93 -0
  49. package/template/claude-task-manager/lib/write-coalescer.js +83 -0
  50. package/template/claude-task-manager/public/index.html +952 -188
  51. package/template/claude-task-manager/public/js/feedback.js +8 -1
  52. package/template/claude-task-manager/public/js/message-renderer.js +72 -2
  53. package/template/claude-task-manager/public/js/session-phase.js +4 -0
  54. package/template/claude-task-manager/public/js/session-status-precedence.js +7 -173
  55. package/template/claude-task-manager/public/js/setup.js +46 -3
  56. package/template/claude-task-manager/public/js/state-sync-client.js +218 -0
  57. package/template/claude-task-manager/public/js/state-sync-predictor.js +41 -0
  58. package/template/claude-task-manager/public/js/stream-view.js +113 -9
  59. package/template/claude-task-manager/public/js/terminal-reconciler.js +24 -4
  60. package/template/claude-task-manager/public/js/walle-session.js +28 -0
  61. package/template/claude-task-manager/public/js/walle.js +26 -9
  62. package/template/claude-task-manager/queue-engine.js +140 -0
  63. package/template/claude-task-manager/server.js +2263 -351
  64. package/template/claude-task-manager/session-integrity.js +16 -1
  65. package/template/claude-task-manager/workers/db-owner-worker.js +8 -0
  66. package/template/claude-task-manager/workers/read-pool-worker.js +18 -1
  67. package/template/claude-task-manager/workers/session-host-pool-process.js +188 -0
  68. package/template/claude-task-manager/workers/session-host-process.js +42 -11
  69. package/template/package.json +1 -1
  70. package/template/wall-e/agent.js +113 -15
  71. package/template/wall-e/api-walle.js +73 -9
  72. package/template/wall-e/auth/flow-manager.js +78 -1
  73. package/template/wall-e/auth/provider-flows.js +56 -2
  74. package/template/wall-e/brain.js +53 -12
  75. package/template/wall-e/embeddings.js +70 -0
  76. package/template/wall-e/events/event-bus.js +11 -1
  77. package/template/wall-e/http/auth.js +3 -1
  78. package/template/wall-e/lib/brain-owner-worker-client.js +16 -4
  79. package/template/wall-e/lib/diagnostics-flags.js +9 -0
  80. package/template/wall-e/lib/event-loop-monitor.js +84 -5
  81. package/template/wall-e/lib/mcp-scan-lifecycle.js +247 -0
  82. package/template/wall-e/lib/runtime-process-inventory.js +114 -0
  83. package/template/wall-e/lib/runtime-worker-pool.js +200 -23
  84. package/template/wall-e/lib/scheduler-worker-jobs.js +19 -3
  85. package/template/wall-e/lib/scheduler.js +250 -34
  86. package/template/wall-e/lib/worker-thread-pool.js +6 -4
  87. package/template/wall-e/llm/claude-cli.js +21 -3
  88. package/template/wall-e/llm/cli-binary.js +77 -0
  89. package/template/wall-e/llm/codex-cli.js +22 -3
  90. package/template/wall-e/llm/default-fallback.js +10 -4
  91. package/template/wall-e/llm/mlx.js +46 -8
  92. package/template/wall-e/llm/provider-detector.js +112 -22
  93. package/template/wall-e/loops/tasks.js +521 -25
  94. package/template/wall-e/memory/ctm-session-context.js +93 -0
  95. package/template/wall-e/skills/_bundled/google-calendar/run.js +15 -23
  96. package/template/wall-e/skills/_bundled/gws-workspace/gws-router +180 -0
  97. package/template/wall-e/skills/_bundled/gws-workspace/setup.js +112 -1
  98. package/template/wall-e/skills/_bundled/mcp-scan/run.js +265 -41
  99. package/template/wall-e/skills/_bundled/slack-mentions/run.js +267 -41
  100. package/template/wall-e/skills/internal-skill-registry.js +27 -5
  101. package/template/wall-e/skills/mcp-client.js +18 -3
  102. package/template/wall-e/skills/script-skill-runner.js +53 -5
  103. package/template/wall-e/training/real-trajectory-miner.js +24 -114
  104. package/template/wall-e/workers/brain-owner-worker.js +8 -0
  105. package/template/website/index.html +3 -0
@@ -11,6 +11,13 @@ const { codexRolloutIdFromPath, readCodexRolloutMetadata } = require('./lib/sess
11
11
  const { ensureTranscriptTables, healSelfLinkedTranscriptKeys } = require('./lib/transcript-store');
12
12
  const { isProviderGeneratedUserContextText } = require('./lib/provider-user-context');
13
13
  const { buildSessionImageRefs } = require('./lib/session-image-refs');
14
+ const {
15
+ normalizeRuntimeEvent,
16
+ normalizeTurnRecord,
17
+ normalizeInputEnvelope,
18
+ normalizeRouteSnapshot,
19
+ normalizeApprovalRecord,
20
+ } = require('./lib/runtime-contract');
14
21
  const {
15
22
  classifySqliteError,
16
23
  ensureSqliteDriverReady,
@@ -785,6 +792,117 @@ function connectDb(dbPath) {
785
792
  }
786
793
  }
787
794
 
795
+ function runtimeKernelSchemaSql() {
796
+ return `
797
+ CREATE TABLE IF NOT EXISTS ctm_runtime_events (
798
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
799
+ event_id TEXT NOT NULL UNIQUE,
800
+ idempotency_key TEXT NOT NULL UNIQUE,
801
+ ctm_session_id TEXT NOT NULL,
802
+ agent_session_id TEXT DEFAULT '',
803
+ turn_id TEXT DEFAULT '',
804
+ adapter TEXT NOT NULL,
805
+ event_type TEXT NOT NULL,
806
+ payload_json TEXT NOT NULL DEFAULT '{}',
807
+ created_at_ms INTEGER NOT NULL,
808
+ created_at TEXT DEFAULT (datetime('now'))
809
+ );
810
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_session
811
+ ON ctm_runtime_events(ctm_session_id, created_at_ms, id);
812
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_turn
813
+ ON ctm_runtime_events(ctm_session_id, turn_id, created_at_ms, id);
814
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_events_type
815
+ ON ctm_runtime_events(event_type, created_at_ms);
816
+
817
+ CREATE TABLE IF NOT EXISTS ctm_runtime_turns (
818
+ ctm_session_id TEXT NOT NULL,
819
+ turn_id TEXT NOT NULL,
820
+ agent_session_id TEXT DEFAULT '',
821
+ parent_turn_id TEXT DEFAULT '',
822
+ user_input_event_id TEXT DEFAULT '',
823
+ status TEXT NOT NULL DEFAULT 'open',
824
+ opened_at_ms INTEGER NOT NULL,
825
+ closed_at_ms INTEGER DEFAULT 0,
826
+ model_route_json TEXT DEFAULT '{}',
827
+ compact_boundary_json TEXT DEFAULT '{}',
828
+ metadata_json TEXT DEFAULT '{}',
829
+ updated_at_ms INTEGER NOT NULL,
830
+ PRIMARY KEY (ctm_session_id, turn_id)
831
+ );
832
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_turns_session_status
833
+ ON ctm_runtime_turns(ctm_session_id, status, updated_at_ms DESC);
834
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_turns_agent
835
+ ON ctm_runtime_turns(agent_session_id, updated_at_ms DESC);
836
+
837
+ CREATE TABLE IF NOT EXISTS ctm_runtime_input_envelopes (
838
+ input_id TEXT PRIMARY KEY,
839
+ ctm_session_id TEXT NOT NULL,
840
+ agent_session_id TEXT DEFAULT '',
841
+ mode TEXT NOT NULL DEFAULT 'mailbox',
842
+ source TEXT NOT NULL DEFAULT 'runtime',
843
+ text TEXT NOT NULL DEFAULT '',
844
+ attachments_json TEXT NOT NULL DEFAULT '[]',
845
+ created_at_ms INTEGER NOT NULL,
846
+ accepted_at_ms INTEGER DEFAULT 0,
847
+ delivered_at_ms INTEGER DEFAULT 0,
848
+ result TEXT NOT NULL DEFAULT 'pending',
849
+ result_reason TEXT DEFAULT '',
850
+ metadata_json TEXT DEFAULT '{}',
851
+ updated_at_ms INTEGER NOT NULL
852
+ );
853
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_input_session_result
854
+ ON ctm_runtime_input_envelopes(ctm_session_id, result, created_at_ms);
855
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_input_agent
856
+ ON ctm_runtime_input_envelopes(agent_session_id, created_at_ms);
857
+
858
+ CREATE TABLE IF NOT EXISTS ctm_runtime_route_snapshots (
859
+ route_id TEXT PRIMARY KEY,
860
+ ctm_session_id TEXT NOT NULL,
861
+ agent_session_id TEXT DEFAULT '',
862
+ turn_id TEXT DEFAULT '',
863
+ requested_model TEXT DEFAULT '',
864
+ resolved_model TEXT DEFAULT '',
865
+ provider TEXT DEFAULT '',
866
+ connection_layer TEXT DEFAULT 'unknown',
867
+ route_source TEXT DEFAULT 'unknown',
868
+ supports_images INTEGER DEFAULT 0,
869
+ supports_tools INTEGER DEFAULT 0,
870
+ supports_mcp INTEGER DEFAULT 0,
871
+ created_at_ms INTEGER NOT NULL,
872
+ metadata_json TEXT DEFAULT '{}'
873
+ );
874
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_route_session
875
+ ON ctm_runtime_route_snapshots(ctm_session_id, created_at_ms DESC);
876
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_route_turn
877
+ ON ctm_runtime_route_snapshots(ctm_session_id, turn_id, created_at_ms DESC);
878
+
879
+ CREATE TABLE IF NOT EXISTS ctm_runtime_approvals (
880
+ approval_id TEXT PRIMARY KEY,
881
+ ctm_session_id TEXT NOT NULL,
882
+ agent_session_id TEXT DEFAULT '',
883
+ turn_id TEXT DEFAULT '',
884
+ source_event_id TEXT DEFAULT '',
885
+ status TEXT NOT NULL DEFAULT 'pending',
886
+ prompt TEXT DEFAULT '',
887
+ tool_name TEXT DEFAULT '',
888
+ decision TEXT DEFAULT '',
889
+ decided_by TEXT DEFAULT '',
890
+ requested_at_ms INTEGER NOT NULL,
891
+ resolved_at_ms INTEGER DEFAULT 0,
892
+ metadata_json TEXT DEFAULT '{}',
893
+ updated_at_ms INTEGER NOT NULL
894
+ );
895
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_approvals_session_status
896
+ ON ctm_runtime_approvals(ctm_session_id, status, requested_at_ms DESC);
897
+ CREATE INDEX IF NOT EXISTS idx_ctm_runtime_approvals_source_event
898
+ ON ctm_runtime_approvals(source_event_id);
899
+ `;
900
+ }
901
+
902
+ function ensureRuntimeKernelTables(handle = getDb()) {
903
+ handle.exec(runtimeKernelSchemaSql());
904
+ }
905
+
788
906
  function createTables() {
789
907
  db.exec(`
790
908
  -- Settings (key-value store)
@@ -1006,6 +1124,7 @@ function createTables() {
1006
1124
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_session_status ON runtime_tasks(ctm_session_id, status, updated_at DESC);
1007
1125
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_agent ON runtime_tasks(agent_session_id, updated_at DESC);
1008
1126
  `);
1127
+ ensureRuntimeKernelTables(db);
1009
1128
  }
1010
1129
 
1011
1130
  function runMigrations() {
@@ -1047,6 +1166,7 @@ function runMigrations() {
1047
1166
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_session_status ON runtime_tasks(ctm_session_id, status, updated_at DESC);
1048
1167
  CREATE INDEX IF NOT EXISTS idx_runtime_tasks_agent ON runtime_tasks(agent_session_id, updated_at DESC);
1049
1168
  `);
1169
+ ensureRuntimeKernelTables(getDb());
1050
1170
 
1051
1171
  // Add pinned column to prompts if not exists
1052
1172
  const cols = getDb().prepare("PRAGMA table_info(prompts)").all();
@@ -3384,6 +3504,475 @@ function deleteRuntimeTasksForSession(ctmSessionId, { completedOnly = false } =
3384
3504
  return result.changes || 0;
3385
3505
  }
3386
3506
 
3507
+ function _parseJsonObject(value) {
3508
+ try {
3509
+ const parsed = JSON.parse(value || '{}');
3510
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
3511
+ } catch {
3512
+ return {};
3513
+ }
3514
+ }
3515
+
3516
+ function _parseJsonArray(value) {
3517
+ try {
3518
+ const parsed = JSON.parse(value || '[]');
3519
+ return Array.isArray(parsed) ? parsed : [];
3520
+ } catch {
3521
+ return [];
3522
+ }
3523
+ }
3524
+
3525
+ function _parseRuntimeEvent(row) {
3526
+ if (!row) return null;
3527
+ return {
3528
+ id: row.id,
3529
+ eventId: row.event_id,
3530
+ event_id: row.event_id,
3531
+ idempotencyKey: row.idempotency_key,
3532
+ idempotency_key: row.idempotency_key,
3533
+ ctmSessionId: row.ctm_session_id,
3534
+ ctm_session_id: row.ctm_session_id,
3535
+ agentSessionId: row.agent_session_id || '',
3536
+ agent_session_id: row.agent_session_id || '',
3537
+ turnId: row.turn_id || '',
3538
+ turn_id: row.turn_id || '',
3539
+ adapter: row.adapter || 'unknown',
3540
+ type: row.event_type || '',
3541
+ event_type: row.event_type || '',
3542
+ payload: _parseJsonObject(row.payload_json),
3543
+ payload_json: row.payload_json || '{}',
3544
+ createdAtMs: Number(row.created_at_ms || 0),
3545
+ created_at_ms: Number(row.created_at_ms || 0),
3546
+ created_at: row.created_at || '',
3547
+ };
3548
+ }
3549
+
3550
+ function appendRuntimeEvent(event = {}) {
3551
+ const normalized = normalizeRuntimeEvent(event);
3552
+ if (!normalized.ctmSessionId || !normalized.type) return null;
3553
+ getDb().prepare(`
3554
+ INSERT OR IGNORE INTO ctm_runtime_events (
3555
+ event_id, idempotency_key, ctm_session_id, agent_session_id, turn_id,
3556
+ adapter, event_type, payload_json, created_at_ms
3557
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
3558
+ `).run(
3559
+ normalized.eventId,
3560
+ normalized.idempotencyKey,
3561
+ normalized.ctmSessionId,
3562
+ normalized.agentSessionId,
3563
+ normalized.turnId,
3564
+ normalized.adapter,
3565
+ normalized.type,
3566
+ _runtimeJsonObject(normalized.payload),
3567
+ normalized.createdAtMs
3568
+ );
3569
+ return _parseRuntimeEvent(getDb().prepare(`
3570
+ SELECT * FROM ctm_runtime_events
3571
+ WHERE idempotency_key = ?
3572
+ ORDER BY id ASC
3573
+ LIMIT 1
3574
+ `).get(normalized.idempotencyKey));
3575
+ }
3576
+
3577
+ function listRuntimeEvents(ctmSessionId, { sinceId = 0, turnId = '', limit = 1000 } = {}) {
3578
+ const cleanSessionId = String(ctmSessionId || '').trim();
3579
+ if (!cleanSessionId) return [];
3580
+ const cap = Math.max(1, Math.min(Number(limit) || 1000, 5000));
3581
+ const minId = Math.max(0, Number(sinceId || 0) || 0);
3582
+ const cleanTurnId = String(turnId || '').trim();
3583
+ const rows = cleanTurnId
3584
+ ? getDb().prepare(`
3585
+ SELECT * FROM ctm_runtime_events
3586
+ WHERE ctm_session_id = ? AND turn_id = ? AND id > ?
3587
+ ORDER BY id ASC
3588
+ LIMIT ?
3589
+ `).all(cleanSessionId, cleanTurnId, minId, cap)
3590
+ : getDb().prepare(`
3591
+ SELECT * FROM ctm_runtime_events
3592
+ WHERE ctm_session_id = ? AND id > ?
3593
+ ORDER BY id ASC
3594
+ LIMIT ?
3595
+ `).all(cleanSessionId, minId, cap);
3596
+ return rows.map(_parseRuntimeEvent).filter(Boolean);
3597
+ }
3598
+
3599
+ function _parseRuntimeTurn(row) {
3600
+ if (!row) return null;
3601
+ return {
3602
+ ctmSessionId: row.ctm_session_id,
3603
+ ctm_session_id: row.ctm_session_id,
3604
+ turnId: row.turn_id,
3605
+ turn_id: row.turn_id,
3606
+ agentSessionId: row.agent_session_id || '',
3607
+ agent_session_id: row.agent_session_id || '',
3608
+ parentTurnId: row.parent_turn_id || '',
3609
+ parent_turn_id: row.parent_turn_id || '',
3610
+ userInputEventId: row.user_input_event_id || '',
3611
+ user_input_event_id: row.user_input_event_id || '',
3612
+ status: row.status || 'open',
3613
+ openedAtMs: Number(row.opened_at_ms || 0),
3614
+ opened_at_ms: Number(row.opened_at_ms || 0),
3615
+ closedAtMs: Number(row.closed_at_ms || 0),
3616
+ closed_at_ms: Number(row.closed_at_ms || 0),
3617
+ modelRoute: _parseJsonObject(row.model_route_json),
3618
+ model_route_json: row.model_route_json || '{}',
3619
+ compactBoundary: _parseJsonObject(row.compact_boundary_json),
3620
+ compact_boundary_json: row.compact_boundary_json || '{}',
3621
+ metadata: _parseJsonObject(row.metadata_json),
3622
+ metadata_json: row.metadata_json || '{}',
3623
+ updatedAtMs: Number(row.updated_at_ms || 0),
3624
+ updated_at_ms: Number(row.updated_at_ms || 0),
3625
+ };
3626
+ }
3627
+
3628
+ function upsertRuntimeTurn(turn = {}) {
3629
+ const normalized = normalizeTurnRecord(turn);
3630
+ if (!normalized.ctmSessionId || !normalized.turnId) return null;
3631
+ const now = Date.now();
3632
+ getDb().prepare(`
3633
+ INSERT INTO ctm_runtime_turns (
3634
+ ctm_session_id, turn_id, agent_session_id, parent_turn_id, user_input_event_id,
3635
+ status, opened_at_ms, closed_at_ms, model_route_json, compact_boundary_json,
3636
+ metadata_json, updated_at_ms
3637
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3638
+ ON CONFLICT(ctm_session_id, turn_id) DO UPDATE SET
3639
+ agent_session_id = excluded.agent_session_id,
3640
+ parent_turn_id = excluded.parent_turn_id,
3641
+ user_input_event_id = excluded.user_input_event_id,
3642
+ status = excluded.status,
3643
+ opened_at_ms = MIN(ctm_runtime_turns.opened_at_ms, excluded.opened_at_ms),
3644
+ closed_at_ms = excluded.closed_at_ms,
3645
+ model_route_json = CASE
3646
+ WHEN excluded.model_route_json = '{}' THEN ctm_runtime_turns.model_route_json
3647
+ ELSE excluded.model_route_json
3648
+ END,
3649
+ compact_boundary_json = CASE
3650
+ WHEN excluded.compact_boundary_json = '{}' THEN ctm_runtime_turns.compact_boundary_json
3651
+ ELSE excluded.compact_boundary_json
3652
+ END,
3653
+ metadata_json = CASE
3654
+ WHEN excluded.metadata_json = '{}' THEN ctm_runtime_turns.metadata_json
3655
+ ELSE excluded.metadata_json
3656
+ END,
3657
+ updated_at_ms = excluded.updated_at_ms
3658
+ `).run(
3659
+ normalized.ctmSessionId,
3660
+ normalized.turnId,
3661
+ normalized.agentSessionId,
3662
+ normalized.parentTurnId,
3663
+ normalized.userInputEventId,
3664
+ normalized.status,
3665
+ normalized.openedAtMs,
3666
+ normalized.closedAtMs,
3667
+ _runtimeJsonObject(normalized.modelRoute),
3668
+ _runtimeJsonObject(normalized.compactBoundary),
3669
+ _runtimeJsonObject(normalized.metadata),
3670
+ now
3671
+ );
3672
+ return getRuntimeTurn(normalized.ctmSessionId, normalized.turnId);
3673
+ }
3674
+
3675
+ function getRuntimeTurn(ctmSessionId, turnId) {
3676
+ const row = getDb().prepare('SELECT * FROM ctm_runtime_turns WHERE ctm_session_id = ? AND turn_id = ?')
3677
+ .get(String(ctmSessionId || '').trim(), String(turnId || '').trim());
3678
+ return _parseRuntimeTurn(row);
3679
+ }
3680
+
3681
+ function listRuntimeTurns(ctmSessionId, { status = '', limit = 200 } = {}) {
3682
+ const cleanSessionId = String(ctmSessionId || '').trim();
3683
+ if (!cleanSessionId) return [];
3684
+ const cap = Math.max(1, Math.min(Number(limit) || 200, 1000));
3685
+ const cleanStatus = String(status || '').trim();
3686
+ const rows = cleanStatus
3687
+ ? getDb().prepare('SELECT * FROM ctm_runtime_turns WHERE ctm_session_id = ? AND status = ? ORDER BY opened_at_ms ASC LIMIT ?').all(cleanSessionId, cleanStatus, cap)
3688
+ : getDb().prepare('SELECT * FROM ctm_runtime_turns WHERE ctm_session_id = ? ORDER BY opened_at_ms ASC LIMIT ?').all(cleanSessionId, cap);
3689
+ return rows.map(_parseRuntimeTurn).filter(Boolean);
3690
+ }
3691
+
3692
+ function _parseRuntimeInputEnvelope(row) {
3693
+ if (!row) return null;
3694
+ return {
3695
+ inputId: row.input_id,
3696
+ input_id: row.input_id,
3697
+ ctmSessionId: row.ctm_session_id,
3698
+ ctm_session_id: row.ctm_session_id,
3699
+ agentSessionId: row.agent_session_id || '',
3700
+ agent_session_id: row.agent_session_id || '',
3701
+ mode: row.mode || 'mailbox',
3702
+ source: row.source || 'runtime',
3703
+ text: row.text || '',
3704
+ attachments: _parseJsonArray(row.attachments_json),
3705
+ attachments_json: row.attachments_json || '[]',
3706
+ createdAtMs: Number(row.created_at_ms || 0),
3707
+ created_at_ms: Number(row.created_at_ms || 0),
3708
+ acceptedAtMs: Number(row.accepted_at_ms || 0),
3709
+ accepted_at_ms: Number(row.accepted_at_ms || 0),
3710
+ deliveredAtMs: Number(row.delivered_at_ms || 0),
3711
+ delivered_at_ms: Number(row.delivered_at_ms || 0),
3712
+ result: row.result || 'pending',
3713
+ resultReason: row.result_reason || '',
3714
+ result_reason: row.result_reason || '',
3715
+ metadata: _parseJsonObject(row.metadata_json),
3716
+ metadata_json: row.metadata_json || '{}',
3717
+ updatedAtMs: Number(row.updated_at_ms || 0),
3718
+ updated_at_ms: Number(row.updated_at_ms || 0),
3719
+ };
3720
+ }
3721
+
3722
+ function upsertRuntimeInputEnvelope(input = {}) {
3723
+ const normalized = normalizeInputEnvelope(input);
3724
+ if (!normalized.ctmSessionId || !normalized.inputId) return null;
3725
+ const now = Date.now();
3726
+ getDb().prepare(`
3727
+ INSERT INTO ctm_runtime_input_envelopes (
3728
+ input_id, ctm_session_id, agent_session_id, mode, source, text,
3729
+ attachments_json, created_at_ms, accepted_at_ms, delivered_at_ms,
3730
+ result, result_reason, metadata_json, updated_at_ms
3731
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3732
+ ON CONFLICT(input_id) DO UPDATE SET
3733
+ ctm_session_id = excluded.ctm_session_id,
3734
+ agent_session_id = excluded.agent_session_id,
3735
+ mode = excluded.mode,
3736
+ source = excluded.source,
3737
+ text = excluded.text,
3738
+ attachments_json = excluded.attachments_json,
3739
+ created_at_ms = MIN(ctm_runtime_input_envelopes.created_at_ms, excluded.created_at_ms),
3740
+ accepted_at_ms = excluded.accepted_at_ms,
3741
+ delivered_at_ms = excluded.delivered_at_ms,
3742
+ result = excluded.result,
3743
+ result_reason = excluded.result_reason,
3744
+ metadata_json = excluded.metadata_json,
3745
+ updated_at_ms = excluded.updated_at_ms
3746
+ `).run(
3747
+ normalized.inputId,
3748
+ normalized.ctmSessionId,
3749
+ normalized.agentSessionId,
3750
+ normalized.mode,
3751
+ normalized.source,
3752
+ normalized.text,
3753
+ _runtimeJsonArray(normalized.attachments),
3754
+ normalized.createdAtMs,
3755
+ normalized.acceptedAtMs,
3756
+ normalized.deliveredAtMs,
3757
+ normalized.result,
3758
+ normalized.resultReason,
3759
+ _runtimeJsonObject(normalized.metadata),
3760
+ now
3761
+ );
3762
+ return _parseRuntimeInputEnvelope(getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE input_id = ?').get(normalized.inputId));
3763
+ }
3764
+
3765
+ function updateRuntimeInputEnvelope(inputId, patch = {}) {
3766
+ const existing = _parseRuntimeInputEnvelope(getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE input_id = ?').get(String(inputId || '').trim()));
3767
+ if (!existing) return null;
3768
+ return upsertRuntimeInputEnvelope({
3769
+ ...existing,
3770
+ ...patch,
3771
+ inputId: existing.inputId,
3772
+ ctmSessionId: patch.ctmSessionId || patch.ctm_session_id || existing.ctmSessionId,
3773
+ });
3774
+ }
3775
+
3776
+ function getRuntimeInputEnvelope(inputId) {
3777
+ const cleanInputId = String(inputId || '').trim();
3778
+ if (!cleanInputId) return null;
3779
+ return _parseRuntimeInputEnvelope(getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE input_id = ?').get(cleanInputId));
3780
+ }
3781
+
3782
+ function listRuntimeInputEnvelopes(ctmSessionId, { result = '', limit = 200 } = {}) {
3783
+ const cleanSessionId = String(ctmSessionId || '').trim();
3784
+ if (!cleanSessionId) return [];
3785
+ const cap = Math.max(1, Math.min(Number(limit) || 200, 1000));
3786
+ const cleanResult = String(result || '').trim();
3787
+ const rows = cleanResult
3788
+ ? getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE ctm_session_id = ? AND result = ? ORDER BY created_at_ms ASC LIMIT ?').all(cleanSessionId, cleanResult, cap)
3789
+ : getDb().prepare('SELECT * FROM ctm_runtime_input_envelopes WHERE ctm_session_id = ? ORDER BY created_at_ms ASC LIMIT ?').all(cleanSessionId, cap);
3790
+ return rows.map(_parseRuntimeInputEnvelope).filter(Boolean);
3791
+ }
3792
+
3793
+ function _parseRuntimeRouteSnapshot(row) {
3794
+ if (!row) return null;
3795
+ return {
3796
+ routeId: row.route_id,
3797
+ route_id: row.route_id,
3798
+ ctmSessionId: row.ctm_session_id,
3799
+ ctm_session_id: row.ctm_session_id,
3800
+ agentSessionId: row.agent_session_id || '',
3801
+ agent_session_id: row.agent_session_id || '',
3802
+ turnId: row.turn_id || '',
3803
+ turn_id: row.turn_id || '',
3804
+ requestedModel: row.requested_model || '',
3805
+ requested_model: row.requested_model || '',
3806
+ resolvedModel: row.resolved_model || '',
3807
+ resolved_model: row.resolved_model || '',
3808
+ provider: row.provider || '',
3809
+ connectionLayer: row.connection_layer || 'unknown',
3810
+ connection_layer: row.connection_layer || 'unknown',
3811
+ routeSource: row.route_source || 'unknown',
3812
+ route_source: row.route_source || 'unknown',
3813
+ supportsImages: !!row.supports_images,
3814
+ supports_images: !!row.supports_images,
3815
+ supportsTools: !!row.supports_tools,
3816
+ supports_tools: !!row.supports_tools,
3817
+ supportsMcp: !!row.supports_mcp,
3818
+ supports_mcp: !!row.supports_mcp,
3819
+ createdAtMs: Number(row.created_at_ms || 0),
3820
+ created_at_ms: Number(row.created_at_ms || 0),
3821
+ metadata: _parseJsonObject(row.metadata_json),
3822
+ metadata_json: row.metadata_json || '{}',
3823
+ };
3824
+ }
3825
+
3826
+ function upsertRuntimeRouteSnapshot(snapshot = {}) {
3827
+ const normalized = normalizeRouteSnapshot(snapshot);
3828
+ if (!normalized.ctmSessionId || !normalized.routeId) return null;
3829
+ getDb().prepare(`
3830
+ INSERT INTO ctm_runtime_route_snapshots (
3831
+ route_id, ctm_session_id, agent_session_id, turn_id, requested_model,
3832
+ resolved_model, provider, connection_layer, route_source, supports_images,
3833
+ supports_tools, supports_mcp, created_at_ms, metadata_json
3834
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3835
+ ON CONFLICT(route_id) DO UPDATE SET
3836
+ ctm_session_id = excluded.ctm_session_id,
3837
+ agent_session_id = excluded.agent_session_id,
3838
+ turn_id = excluded.turn_id,
3839
+ requested_model = excluded.requested_model,
3840
+ resolved_model = excluded.resolved_model,
3841
+ provider = excluded.provider,
3842
+ connection_layer = excluded.connection_layer,
3843
+ route_source = excluded.route_source,
3844
+ supports_images = excluded.supports_images,
3845
+ supports_tools = excluded.supports_tools,
3846
+ supports_mcp = excluded.supports_mcp,
3847
+ created_at_ms = excluded.created_at_ms,
3848
+ metadata_json = excluded.metadata_json
3849
+ `).run(
3850
+ normalized.routeId,
3851
+ normalized.ctmSessionId,
3852
+ normalized.agentSessionId,
3853
+ normalized.turnId,
3854
+ normalized.requestedModel,
3855
+ normalized.resolvedModel,
3856
+ normalized.provider,
3857
+ normalized.connectionLayer,
3858
+ normalized.routeSource,
3859
+ normalized.supportsImages ? 1 : 0,
3860
+ normalized.supportsTools ? 1 : 0,
3861
+ normalized.supportsMcp ? 1 : 0,
3862
+ normalized.createdAtMs,
3863
+ _runtimeJsonObject(normalized.metadata)
3864
+ );
3865
+ return _parseRuntimeRouteSnapshot(getDb().prepare('SELECT * FROM ctm_runtime_route_snapshots WHERE route_id = ?').get(normalized.routeId));
3866
+ }
3867
+
3868
+ function getLatestRuntimeRouteSnapshot(ctmSessionId, { turnId = '' } = {}) {
3869
+ const cleanSessionId = String(ctmSessionId || '').trim();
3870
+ if (!cleanSessionId) return null;
3871
+ const cleanTurnId = String(turnId || '').trim();
3872
+ const row = cleanTurnId
3873
+ ? getDb().prepare('SELECT * FROM ctm_runtime_route_snapshots WHERE ctm_session_id = ? AND turn_id = ? ORDER BY created_at_ms DESC LIMIT 1').get(cleanSessionId, cleanTurnId)
3874
+ : getDb().prepare('SELECT * FROM ctm_runtime_route_snapshots WHERE ctm_session_id = ? ORDER BY created_at_ms DESC LIMIT 1').get(cleanSessionId);
3875
+ return _parseRuntimeRouteSnapshot(row);
3876
+ }
3877
+
3878
+ function _parseRuntimeApproval(row) {
3879
+ if (!row) return null;
3880
+ return {
3881
+ approvalId: row.approval_id,
3882
+ approval_id: row.approval_id,
3883
+ ctmSessionId: row.ctm_session_id,
3884
+ ctm_session_id: row.ctm_session_id,
3885
+ agentSessionId: row.agent_session_id || '',
3886
+ agent_session_id: row.agent_session_id || '',
3887
+ turnId: row.turn_id || '',
3888
+ turn_id: row.turn_id || '',
3889
+ sourceEventId: row.source_event_id || '',
3890
+ source_event_id: row.source_event_id || '',
3891
+ status: row.status || 'pending',
3892
+ prompt: row.prompt || '',
3893
+ toolName: row.tool_name || '',
3894
+ tool_name: row.tool_name || '',
3895
+ decision: row.decision || '',
3896
+ decidedBy: row.decided_by || '',
3897
+ decided_by: row.decided_by || '',
3898
+ requestedAtMs: Number(row.requested_at_ms || 0),
3899
+ requested_at_ms: Number(row.requested_at_ms || 0),
3900
+ resolvedAtMs: Number(row.resolved_at_ms || 0),
3901
+ resolved_at_ms: Number(row.resolved_at_ms || 0),
3902
+ metadata: _parseJsonObject(row.metadata_json),
3903
+ metadata_json: row.metadata_json || '{}',
3904
+ updatedAtMs: Number(row.updated_at_ms || 0),
3905
+ updated_at_ms: Number(row.updated_at_ms || 0),
3906
+ };
3907
+ }
3908
+
3909
+ function upsertRuntimeApproval(approval = {}) {
3910
+ const normalized = normalizeApprovalRecord(approval);
3911
+ if (!normalized.ctmSessionId || !normalized.approvalId) return null;
3912
+ const now = Date.now();
3913
+ getDb().prepare(`
3914
+ INSERT INTO ctm_runtime_approvals (
3915
+ approval_id, ctm_session_id, agent_session_id, turn_id, source_event_id,
3916
+ status, prompt, tool_name, decision, decided_by, requested_at_ms,
3917
+ resolved_at_ms, metadata_json, updated_at_ms
3918
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
3919
+ ON CONFLICT(approval_id) DO UPDATE SET
3920
+ ctm_session_id = excluded.ctm_session_id,
3921
+ agent_session_id = excluded.agent_session_id,
3922
+ turn_id = excluded.turn_id,
3923
+ source_event_id = excluded.source_event_id,
3924
+ status = excluded.status,
3925
+ prompt = excluded.prompt,
3926
+ tool_name = excluded.tool_name,
3927
+ decision = excluded.decision,
3928
+ decided_by = excluded.decided_by,
3929
+ requested_at_ms = MIN(ctm_runtime_approvals.requested_at_ms, excluded.requested_at_ms),
3930
+ resolved_at_ms = excluded.resolved_at_ms,
3931
+ metadata_json = excluded.metadata_json,
3932
+ updated_at_ms = excluded.updated_at_ms
3933
+ `).run(
3934
+ normalized.approvalId,
3935
+ normalized.ctmSessionId,
3936
+ normalized.agentSessionId,
3937
+ normalized.turnId,
3938
+ normalized.sourceEventId,
3939
+ normalized.status,
3940
+ normalized.prompt,
3941
+ normalized.toolName,
3942
+ normalized.decision,
3943
+ normalized.decidedBy,
3944
+ normalized.requestedAtMs,
3945
+ normalized.resolvedAtMs,
3946
+ _runtimeJsonObject(normalized.metadata),
3947
+ now
3948
+ );
3949
+ return _parseRuntimeApproval(getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE approval_id = ?').get(normalized.approvalId));
3950
+ }
3951
+
3952
+ function resolveRuntimeApproval(approvalId, { decision = '', decidedBy = '', status = 'approved', metadata = {} } = {}) {
3953
+ const existing = _parseRuntimeApproval(getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE approval_id = ?').get(String(approvalId || '').trim()));
3954
+ if (!existing) return null;
3955
+ return upsertRuntimeApproval({
3956
+ ...existing,
3957
+ status,
3958
+ decision,
3959
+ decidedBy,
3960
+ resolvedAtMs: Date.now(),
3961
+ metadata: { ...(existing.metadata || {}), ...(metadata || {}) },
3962
+ });
3963
+ }
3964
+
3965
+ function listRuntimeApprovals(ctmSessionId, { status = '', limit = 100 } = {}) {
3966
+ const cleanSessionId = String(ctmSessionId || '').trim();
3967
+ if (!cleanSessionId) return [];
3968
+ const cap = Math.max(1, Math.min(Number(limit) || 100, 500));
3969
+ const cleanStatus = String(status || '').trim();
3970
+ const rows = cleanStatus
3971
+ ? getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE ctm_session_id = ? AND status = ? ORDER BY requested_at_ms DESC LIMIT ?').all(cleanSessionId, cleanStatus, cap)
3972
+ : getDb().prepare('SELECT * FROM ctm_runtime_approvals WHERE ctm_session_id = ? ORDER BY requested_at_ms DESC LIMIT ?').all(cleanSessionId, cap);
3973
+ return rows.map(_parseRuntimeApproval).filter(Boolean);
3974
+ }
3975
+
3387
3976
  // --- Domain auto-classification ---
3388
3977
  const DOMAIN_RULES = [
3389
3978
  { domain: 'coding', patterns: /\b(code|function|class|method|variable|bug|test|refactor|implement|api|endpoint|import|export|module|compile|syntax|type(script)?|lint|format|regex|array|object|string|int|float|boolean|null|undefined|return|async|await|promise|callback|error handling|unit test|integration test|debug|stack trace|exception|try.catch|react|component|hook|useState|useEffect|vue|angular|svelte|html|css|dom|frontend|backend|database|sql|orm|schema|migration|git|commit|branch|merge|pr|pull.request|npm|pip|cargo|maven|gradle|python|javascript|java|rust|go|ruby|php|swift|kotlin)\b/i },
@@ -4373,6 +4962,14 @@ const _overCapImportLogged = new Set(); // session_id → already warned about a
4373
4962
  // when the count grew past a delta (or the model changed); otherwise the upsert's COALESCE preserves
4374
4963
  // the prior stored value — exact recompute still happens on the live path for Claude/Codex.
4375
4964
  const _importTokenEstimateLen = new Map();
4965
+ // Blob retirement is the shipped default: stop writing the monolithic `messages` blob (store '[]')
4966
+ // because the faithful session_message_rows serve every read. Two safety conditions gate it:
4967
+ // - CTM_DUAL_WRITE_BLOB=1 → rollback hatch: keep dual-writing the real blob.
4968
+ // - CTM_SESSION_ROWS=0 → rows READ path is off, so nulling the blob would blind reads → keep it.
4969
+ // lib/session-content-backfill.js mirrors this predicate for the reclaim sweep (keep them in lockstep).
4970
+ function _blobRetirementActive() {
4971
+ return process.env.CTM_DUAL_WRITE_BLOB !== '1' && process.env.CTM_SESSION_ROWS !== '0';
4972
+ }
4376
4973
  function importSessionConversation({
4377
4974
  session_id, project_path, messages, user_msg_count, assistant_msg_count,
4378
4975
  search_messages,
@@ -4404,15 +5001,15 @@ function importSessionConversation({
4404
5001
  let _isOverParseCap = null;
4405
5002
  try { _isOverParseCap = require('./lib/size-cap').isOverParseCap; } catch { /* optional */ }
4406
5003
  const _overParseCap = typeof _isOverParseCap === 'function' ? _isOverParseCap(file_size) : false;
4407
- // Phase 7 (blob retirement): once reads are served from session_message_rows
4408
- // (CTM_SESSION_ROWS=1, verified 100% via /api/ctm/session-rows-status), set
4409
- // CTM_DUAL_WRITE_BLOB=0 to STOP writing the monolithic messages blob (store '[]')
4410
- // this is what ends the O(N²) JSON.stringify-the-whole-array on every append.
4411
- // SAFETY: only honor the flag when the rows READ path is on (CTM_SESSION_ROWS=1),
4412
- // otherwise nulling the blob would blind every read. And the row write below
4413
- // restores the real blob if it fails, so a session is never left empty. Default
4414
- // unset = keep dual-writing the blob; existing users/legacy reads are unchanged.
4415
- const _retireBlob = process.env.CTM_DUAL_WRITE_BLOB === '0' && process.env.CTM_SESSION_ROWS !== '0';
5004
+ // Phase 7 (blob retirement): reads are served from session_message_rows (the
5005
+ // faithful per-message store), so the monolithic `messages` blob is dead weight
5006
+ // and its full-array JSON.stringify on every append is the O(N²) write cost we
5007
+ // want gone. DEFAULT (shipped to everyone, incl. npx): retire the blob (store
5008
+ // '[]'). Rollback hatch: CTM_DUAL_WRITE_BLOB=1 keeps dual-writing the real blob.
5009
+ // SAFETY: only retire when the rows READ path is on (CTM_SESSION_ROWS != '0',
5010
+ // the default), otherwise nulling the blob would blind every read; and the row
5011
+ // write below restores the real blob if it fails, so a session is never empty.
5012
+ const _retireBlob = _blobRetirementActive();
4416
5013
  const _skipBlobWrite = _overParseCap || _retireBlob;
4417
5014
  if (_overParseCap && !_overCapImportLogged.has(session_id)) {
4418
5015
  _overCapImportLogged.add(session_id);
@@ -5280,7 +5877,27 @@ const _AI_REFINEMENT_FAILED_MAX_ROWS = Number(process.env.CTM_AI_REFINEMENT_FAIL
5280
5877
  // (a few new failed rows/hour) never approaches the cap.
5281
5878
  const _AI_REFINEMENT_PRUNE_MAX_PER_RUN = Number(process.env.CTM_AI_REFINEMENT_PRUNE_MAX_PER_RUN || 5000);
5282
5879
 
5283
- function addApprovalObservation({
5880
+ const _APPROVAL_OBS_INSERT_SQL = `
5881
+ INSERT INTO approval_observations (
5882
+ session_id,
5883
+ provider_id,
5884
+ source,
5885
+ raw_detected,
5886
+ gated,
5887
+ gate_reason,
5888
+ parse_status,
5889
+ policy_decision,
5890
+ decided_by,
5891
+ keystroke_status,
5892
+ screen_fingerprint,
5893
+ redacted_screen_tail,
5894
+ debug_lines
5895
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5896
+ `;
5897
+
5898
+ // Bind args for one approval_observations row. SHARED by the single-row insert and the batch
5899
+ // insert so per-row semantics (tail trimming, debug cap) can never diverge between the two paths.
5900
+ function _bindApprovalObservationRow({
5284
5901
  sessionId,
5285
5902
  providerId,
5286
5903
  source,
@@ -5304,23 +5921,7 @@ function addApprovalObservation({
5304
5921
  const _tailMax = _keepFullTail
5305
5922
  ? _APPROVAL_OBS_TAIL_MAX_GATED
5306
5923
  : _APPROVAL_OBS_TAIL_MAX_UNGATED;
5307
- const result = getDb().prepare(`
5308
- INSERT INTO approval_observations (
5309
- session_id,
5310
- provider_id,
5311
- source,
5312
- raw_detected,
5313
- gated,
5314
- gate_reason,
5315
- parse_status,
5316
- policy_decision,
5317
- decided_by,
5318
- keystroke_status,
5319
- screen_fingerprint,
5320
- redacted_screen_tail,
5321
- debug_lines
5322
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
5323
- `).run(
5924
+ return [
5324
5925
  sessionId || '',
5325
5926
  providerId || '',
5326
5927
  source || '',
@@ -5334,10 +5935,36 @@ function addApprovalObservation({
5334
5935
  screenFingerprint || '',
5335
5936
  String(redactedScreenTail || '').slice(-_tailMax),
5336
5937
  debug.slice(-2000),
5337
- );
5938
+ ];
5939
+ }
5940
+
5941
+ function addApprovalObservation(observation = {}) {
5942
+ const result = getDb().prepare(_APPROVAL_OBS_INSERT_SQL).run(..._bindApprovalObservationRow(observation));
5338
5943
  return result.lastInsertRowid;
5339
5944
  }
5340
5945
 
5946
+ // Coalesced sink for the approval-observation write path: insert N observations in ONE
5947
+ // transaction (one write-lock acquire / one commit) with byte-identical per-row semantics to
5948
+ // addApprovalObservation. The single-threaded db-owner worker was flooded by one INSERT op per
5949
+ // event (~20k/day); batching collapses that to a few ops/flush-window. Falsy entries are skipped;
5950
+ // returns the number of rows inserted.
5951
+ function addApprovalObservationsBatch(entries) {
5952
+ if (!Array.isArray(entries) || entries.length === 0) return 0;
5953
+ const valid = entries.filter((e) => e && typeof e === 'object');
5954
+ if (valid.length === 0) return 0;
5955
+ const d = getDb();
5956
+ const stmt = d.prepare(_APPROVAL_OBS_INSERT_SQL);
5957
+ const insertAll = d.transaction((rows) => {
5958
+ let inserted = 0;
5959
+ for (const obs of rows) {
5960
+ stmt.run(..._bindApprovalObservationRow(obs));
5961
+ inserted++;
5962
+ }
5963
+ return inserted;
5964
+ });
5965
+ return insertAll(valid);
5966
+ }
5967
+
5341
5968
  // Retention for approval_observations. The approver records ~20k observations/day, each
5342
5969
  // historically carrying a ~4KB screen tail, with no pruning — the table had grown to
5343
5970
  // 267MB. This caps it by age and by row count. Heavy DELETE → run on the db-owner worker
@@ -7432,6 +8059,42 @@ function replaceSessionMessageRows(sessionId, messages) {
7432
8059
  return normalized.length;
7433
8060
  }
7434
8061
 
8062
+ // Incremental, resumable backfill of ONE session's rows in a bounded chunk. The background
8063
+ // migration calls this once per session per scheduler tick so a single giant session (16k+
8064
+ // messages) is never rewritten in one write-lock-holding transaction — it fills in index order
8065
+ // across ticks. The store is dense and keyed by message_index, so INSERT OR IGNORE makes this
8066
+ // idempotent: re-running resumes from the existing row count (passed as startIndex) and never
8067
+ // double-writes. extracted_source_len (the render gate's target) is only stamped when the final
8068
+ // chunk lands, so a half-filled session stays on the blob path (sessionContentRowsAvailable=false)
8069
+ // until it is 100% backfilled. Returns {written, nextIndex, complete}.
8070
+ function appendSessionMessageRowsChunk(sessionId, messages, startIndex = 0, maxCount = 0) {
8071
+ if (!sessionId || !_tableExists('session_message_rows')) {
8072
+ return { written: 0, nextIndex: Number(startIndex) || 0, complete: false };
8073
+ }
8074
+ const d = getDb();
8075
+ const normalized = Array.isArray(messages) ? messages : [];
8076
+ const start = Math.max(0, Math.min(normalized.length, Number(startIndex) || 0));
8077
+ const cap = Number(maxCount) > 0 ? Number(maxCount) : (normalized.length - start);
8078
+ const end = Math.min(normalized.length, start + Math.max(1, cap));
8079
+ const insertRow = d.prepare(
8080
+ 'INSERT OR IGNORE INTO session_message_rows (ctm_session_id, message_index, role, text, timestamp, meta) VALUES (?, ?, ?, ?, ?, ?)'
8081
+ );
8082
+ const writeRow = (i) => {
8083
+ const m = normalized[i] || {};
8084
+ return insertRow.run(
8085
+ sessionId, i, String(m.role || ''),
8086
+ String(m.text != null ? m.text : (m.content != null ? m.content : '')),
8087
+ m.timestamp != null ? String(m.timestamp) : '',
8088
+ _messageRowMeta(m)
8089
+ );
8090
+ };
8091
+ let written = 0;
8092
+ d.transaction(() => { for (let i = start; i < end; i++) { if (writeRow(i).changes > 0) written++; } })();
8093
+ const complete = end >= normalized.length;
8094
+ if (complete) _setExtractedSourceLen(d, sessionId, normalized.length, _lastMessageAtFromMessages(normalized));
8095
+ return { written, nextIndex: end, complete };
8096
+ }
8097
+
7435
8098
  // O(Δ) append of NEW messages after a verified-dense, unchanged-prefix base. Unlike
7436
8099
  // replaceSessionMessageRows this NEVER loads or rewrites the base array, so it stays O(Δ) for an
7437
8100
  // active multi-thousand-message session (that full-array load + re-process was the giant-transcript
@@ -7704,8 +8367,15 @@ function runContentRowsBackfillSweep(options = {}) {
7704
8367
  const limit = Math.max(1, Math.min(500, Number(o.limit) || 25));
7705
8368
  const budgetMs = Math.max(0, Number(o.budgetMs ?? process.env.CTM_ROWS_BACKFILL_BUDGET_MS ?? 250));
7706
8369
  const maxRows = Math.max(0, Number(o.maxRows ?? process.env.CTM_ROWS_BACKFILL_MAX_ROWS ?? 3000));
8370
+ // Per-session row cap: migrate a giant session in bounded slices across ticks (resumable) so one
8371
+ // session can't hold the write lock for its whole length. 0 = whole-session (legacy/tests).
8372
+ const maxRowsPerSession = Math.max(0, Number(o.maxRowsPerSession ?? process.env.CTM_ROWS_BACKFILL_MAX_ROWS_PER_SESSION ?? 2000));
7707
8373
  const bf = require('./lib/session-content-backfill');
7708
- return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, { limit, budgetMs, maxRows });
8374
+ return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, {
8375
+ limit, budgetMs, maxRows, maxRowsPerSession,
8376
+ appendChunk: appendSessionMessageRowsChunk,
8377
+ getRowCount: (id) => countSessionMessageRows(id),
8378
+ });
7709
8379
  }
7710
8380
 
7711
8381
  // Migration completeness for GET /api/ctm/session-rows-status (+ the cutover gate).
@@ -8180,10 +8850,10 @@ function getLatestAgentSessionForCtm(ctmSessionId, options = {}) {
8180
8850
  return null;
8181
8851
  }
8182
8852
 
8183
- function getSessionConversationSourceIds(id, options = {}) {
8853
+ function getSessionConversationSourceIds(id, options = {}, db) {
8184
8854
  const cleanId = String(id || '').trim();
8185
8855
  if (!cleanId) return [];
8186
- const identity = getSessionIdentity(cleanId);
8856
+ const identity = getSessionIdentity(cleanId, db);
8187
8857
  const ids = [];
8188
8858
  const add = (value) => {
8189
8859
  const s = String(value || '').trim();
@@ -8210,10 +8880,13 @@ function getSessionConversationSourceIds(id, options = {}) {
8210
8880
  * lifecycle/restore table; orphan recovery can use it explicitly, but normal
8211
8881
  * reads must not treat it as a second mapping source.
8212
8882
  */
8213
- function getSessionIdentity(id) {
8883
+ function getSessionIdentity(id, db) {
8214
8884
  const cleanId = String(id || '').trim();
8215
8885
  if (!cleanId) return null;
8216
- const d = getDb();
8886
+ // db-injectable: the read-pool worker passes its own read-only connection so the
8887
+ // prompt-index input resolution runs off the main loop (parity by construction —
8888
+ // same query bodies, different handle). Existing callers pass nothing → singleton.
8889
+ const d = db || getDb();
8217
8890
 
8218
8891
  let ctm = d.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(cleanId) || null;
8219
8892
  let ctmSessionId = ctm ? ctm.id : '';
@@ -9558,8 +10231,8 @@ function deleteCtmSession(ctmSessionId) {
9558
10231
 
9559
10232
  // Legacy compatibility: upsertSessionIndex is now a no-op (session_index dropped)
9560
10233
  function upsertSessionIndex() {}
9561
- function resolveSessionId(id) {
9562
- const identity = getSessionIdentity(id);
10234
+ function resolveSessionId(id, db) {
10235
+ const identity = getSessionIdentity(id, db);
9563
10236
  if (!identity) return null;
9564
10237
  return {
9565
10238
  ctm_session_id: identity.ctm_session_id,
@@ -9575,6 +10248,12 @@ module.exports = {
9575
10248
  getWriteLockStats, resetWriteLockStats,
9576
10249
  getStorageRisk,
9577
10250
  getSqliteDriverStatus,
10251
+ ensureRuntimeKernelTables,
10252
+ appendRuntimeEvent, listRuntimeEvents,
10253
+ upsertRuntimeTurn, getRuntimeTurn, listRuntimeTurns,
10254
+ upsertRuntimeInputEnvelope, updateRuntimeInputEnvelope, getRuntimeInputEnvelope, listRuntimeInputEnvelopes,
10255
+ upsertRuntimeRouteSnapshot, getLatestRuntimeRouteSnapshot,
10256
+ upsertRuntimeApproval, resolveRuntimeApproval, listRuntimeApprovals,
9578
10257
  getSetting, getSettingsByPrefix, setSetting,
9579
10258
  addSessionDiagnostic, flushSessionDiagnostics, listSessionDiagnostics,
9580
10259
  upsertRuntimeTask, updateRuntimeTask, getRuntimeTask, listRuntimeTasks, deleteRuntimeTasksForSession,
@@ -9587,8 +10266,8 @@ module.exports = {
9587
10266
  listPermissionLog, addPermissionLog,
9588
10267
  getAlwaysAskTools, setAlwaysAsk,
9589
10268
  importSessionConversation, appendSessionConversation, appendSessionMessageRows, listSessionConversations, getSessionConversation, getSessionConversationMeta, getSessionConversationMessages, updateSessionModel,
9590
- replaceSessionMessageRows, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage, countSessionMessageRows,
9591
- runContentRowsBackfillSweep, getSessionRowsStatus, retireLegacyStores, runVacuum,
10269
+ replaceSessionMessageRows, appendSessionMessageRowsChunk, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage, countSessionMessageRows,
10270
+ runContentRowsBackfillSweep, getSessionRowsStatus, retireLegacyStores, runVacuum, _blobRetirementActive,
9592
10271
  updateSessionTokens, getSessionTokens,
9593
10272
  pickDisplayTitle,
9594
10273
  getSessionTitle, setSessionTitle, isSessionUserRenamed, getAllSessionTitles,
@@ -9601,7 +10280,7 @@ module.exports = {
9601
10280
  listAutoApprovals, upsertAutoApproval, toggleAutoApproval, deleteAutoApproval, getEnabledAutoApprovals,
9602
10281
  listApprovalRules, upsertApprovalRule, findApprovalRuleBySignature, toggleApprovalRule, deleteApprovalRule, incrementApprovalRuleMatch,
9603
10282
  addApprovalDecision, listApprovalDecisions, resolveApprovalDecision, getPendingEscalations,
9604
- addApprovalObservation, listApprovalObservations, pruneApprovalObservations, pruneApprovalAiRefinementRules,
10283
+ addApprovalObservation, addApprovalObservationsBatch, listApprovalObservations, pruneApprovalObservations, pruneApprovalAiRefinementRules,
9605
10284
  getApprovalRescuePattern, saveApprovalRescuePattern, listApprovalRescuePatterns,
9606
10285
  getApprovalAiRefinementRule, saveApprovalAiRefinementRule, listApprovalAiRefinementRules,
9607
10286
  findActiveApprovalAiRefinementRules, saveApprovalAiRefinementWarning, listApprovalAiRefinementWarnings,