create-walle 0.9.25 → 0.9.26
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 +8 -0
- package/bin/create-walle.js +815 -45
- package/package.json +2 -2
- package/template/bin/ctm-dev-cleanup.js +90 -4
- package/template/bin/ctm-launch.sh +49 -1
- package/template/bin/dev.sh +45 -1
- package/template/bin/ensure-stable-node.js +132 -0
- package/template/bin/install-service.sh +9 -0
- package/template/claude-task-manager/api-prompts.js +899 -119
- package/template/claude-task-manager/approval-agent.js +360 -40
- package/template/claude-task-manager/bin/ctm-disclaim.c +42 -0
- package/template/claude-task-manager/bin/ctm-hotkey.swift +67 -81
- package/template/claude-task-manager/bin/ctm-screen-auth.swift +37 -0
- package/template/claude-task-manager/bin/install-hotkey.sh +97 -49
- package/template/claude-task-manager/bin/restart-ctm.sh +14 -0
- package/template/claude-task-manager/db.js +399 -48
- package/template/claude-task-manager/docs/approval-hook-sandbox.md +84 -0
- package/template/claude-task-manager/docs/codex-app-server-approvals.md +72 -0
- package/template/claude-task-manager/docs/codex-native-sandbox.md +47 -0
- package/template/claude-task-manager/docs/prompt-editing-tree-design.md +18 -1
- package/template/claude-task-manager/lib/approval-hook.js +200 -0
- package/template/claude-task-manager/lib/approval-self-adapt.js +1 -0
- package/template/claude-task-manager/lib/auth-rules.js +11 -0
- package/template/claude-task-manager/lib/background-llm.js +32 -4
- package/template/claude-task-manager/lib/codesign-identity.js +140 -0
- package/template/claude-task-manager/lib/codex-app-server-client.js +119 -0
- package/template/claude-task-manager/lib/codex-approval-bridge.js +118 -0
- package/template/claude-task-manager/lib/codex-history-terminal-renderer.js +571 -0
- package/template/claude-task-manager/lib/codex-paths.js +73 -0
- package/template/claude-task-manager/lib/codex-rollout-snapshot.js +164 -0
- package/template/claude-task-manager/lib/codex-rollout-tail.js +72 -0
- package/template/claude-task-manager/lib/codex-sandbox-args.js +47 -0
- package/template/claude-task-manager/lib/coding-agent-models.js +118 -71
- package/template/claude-task-manager/lib/command-targets.js +163 -0
- package/template/claude-task-manager/lib/conversation-tail-merge.js +61 -19
- package/template/claude-task-manager/lib/db-owner-worker-client.js +29 -1
- package/template/claude-task-manager/lib/escalation-review.js +80 -3
- package/template/claude-task-manager/lib/flow-control.js +52 -0
- package/template/claude-task-manager/lib/fs-watcher.js +24 -15
- package/template/claude-task-manager/lib/ingest-cooldown.js +68 -0
- package/template/claude-task-manager/lib/jsonl-conversation-parser.js +8 -4
- package/template/claude-task-manager/lib/launchd-recovery.js +92 -0
- package/template/claude-task-manager/lib/microsoft-dev-tunnel-setup.js +207 -52
- package/template/claude-task-manager/lib/mobile-push-store.js +7 -0
- package/template/claude-task-manager/lib/model-overview-brain-fallback.js +102 -1
- package/template/claude-task-manager/lib/model-overview-cache.js +1 -0
- package/template/claude-task-manager/lib/oauth-proxy-supervisor.js +2 -1
- package/template/claude-task-manager/lib/perf-tracker.js +29 -2
- package/template/claude-task-manager/lib/permission-match.js +146 -16
- package/template/claude-task-manager/lib/project-slug.js +33 -0
- package/template/claude-task-manager/lib/prompt-intent.js +51 -4
- package/template/claude-task-manager/lib/read-pool-client.js +48 -3
- package/template/claude-task-manager/lib/real-node.js +73 -0
- package/template/claude-task-manager/lib/runtime-work-registry.js +131 -14
- package/template/claude-task-manager/lib/session-content-backfill.js +24 -5
- package/template/claude-task-manager/lib/session-diagnostics-batch.js +87 -0
- package/template/claude-task-manager/lib/session-history.js +5 -7
- package/template/claude-task-manager/lib/session-host-manager.js +19 -0
- package/template/claude-task-manager/lib/session-jobs.js +6 -0
- package/template/claude-task-manager/lib/session-message-response-cache.js +89 -0
- package/template/claude-task-manager/lib/session-messages-page.js +211 -0
- package/template/claude-task-manager/lib/session-messages-projection.js +170 -0
- package/template/claude-task-manager/lib/session-standup.js +8 -0
- package/template/claude-task-manager/lib/session-timeline-summary.js +16 -2
- package/template/claude-task-manager/lib/session-token-usage.js +30 -8
- package/template/claude-task-manager/lib/session-workspace-binding.js +29 -15
- package/template/claude-task-manager/lib/storage-migration.js +2 -1
- package/template/claude-task-manager/lib/transcript-store.js +179 -12
- package/template/claude-task-manager/lib/walle-ctm-history.js +298 -11
- package/template/claude-task-manager/lib/walle-permission-reply.js +49 -0
- package/template/claude-task-manager/lib/walle-session-cache.js +22 -1
- package/template/claude-task-manager/lib/walle-supervisor.js +42 -3
- package/template/claude-task-manager/package.json +5 -2
- package/template/claude-task-manager/prompt-harvest.js +31 -11
- package/template/claude-task-manager/providers/claude-code.js +29 -1
- package/template/claude-task-manager/providers/codex.js +13 -1
- package/template/claude-task-manager/public/css/setup.css +11 -0
- package/template/claude-task-manager/public/css/walle-session.css +132 -4
- package/template/claude-task-manager/public/css/walle.css +89 -0
- package/template/claude-task-manager/public/icon-16.png +0 -0
- package/template/claude-task-manager/public/icon-32.png +0 -0
- package/template/claude-task-manager/public/icon-512.png +0 -0
- package/template/claude-task-manager/public/index.html +2483 -165
- package/template/claude-task-manager/public/js/activation-render-check.js +55 -0
- package/template/claude-task-manager/public/js/flow-control-policy.js +52 -0
- package/template/claude-task-manager/public/js/message-renderer.js +60 -1
- package/template/claude-task-manager/public/js/prompts.js +13 -1
- package/template/claude-task-manager/public/js/session-status-precedence.js +9 -3
- package/template/claude-task-manager/public/js/setup.js +54 -10
- package/template/claude-task-manager/public/js/stream-resize-policy.js +80 -0
- package/template/claude-task-manager/public/js/stream-view.js +78 -0
- package/template/claude-task-manager/public/js/terminal-reconciler.js +52 -2
- package/template/claude-task-manager/public/js/tool-state.js +155 -0
- package/template/claude-task-manager/public/js/walle-session.js +887 -326
- package/template/claude-task-manager/public/js/walle.js +306 -195
- package/template/claude-task-manager/public/m/app.css +1 -0
- package/template/claude-task-manager/public/m/app.js +33 -3
- package/template/claude-task-manager/queue-engine.js +45 -1
- package/template/claude-task-manager/server.js +3367 -540
- package/template/claude-task-manager/workers/approval-blocklist.js +130 -17
- package/template/claude-task-manager/workers/db-owner-worker.js +31 -1
- package/template/claude-task-manager/workers/read-pool-worker.js +92 -5
- package/template/claude-task-manager/workers/session-host-process.js +10 -0
- package/template/claude-task-manager/workers/state-detectors/codex.js +58 -7
- package/template/package.json +2 -3
- package/template/shared/icons/AppIcon-ctm.icns +0 -0
- package/template/shared/icons/AppIcon-walle.icns +0 -0
- package/template/wall-e/agent.js +139 -18
- package/template/wall-e/api-walle.js +201 -22
- package/template/wall-e/bin/train-gemma-e4b-tooluse.js +1981 -0
- package/template/wall-e/brain.js +1049 -39
- package/template/wall-e/chat.js +427 -86
- package/template/wall-e/coding/acceptance-contract.js +26 -1
- package/template/wall-e/coding/action-memory-policy.js +353 -0
- package/template/wall-e/coding/action-memory-store.js +814 -0
- package/template/wall-e/coding/initial-messages.js +197 -0
- package/template/wall-e/coding/no-progress-guard.js +327 -0
- package/template/wall-e/coding/permission-service.js +88 -22
- package/template/wall-e/coding/session-workspaces.js +81 -0
- package/template/wall-e/coding/shell-sandbox.js +124 -0
- package/template/wall-e/coding/stream-processor.js +63 -2
- package/template/wall-e/coding/tool-execution-controller.js +14 -1
- package/template/wall-e/coding/tool-registry.js +1 -1
- package/template/wall-e/coding/transcript-writer.js +3 -0
- package/template/wall-e/coding-orchestrator.js +636 -35
- package/template/wall-e/coding-prompts.js +51 -2
- package/template/wall-e/docs/model-routing-policy.md +59 -0
- package/template/wall-e/docs/walle-shell-sandbox.md +61 -0
- package/template/wall-e/extraction/knowledge-extractor.js +76 -23
- package/template/wall-e/http/chat-api.js +30 -12
- package/template/wall-e/http/model-admin.js +93 -1
- package/template/wall-e/lib/background-lanes.js +133 -0
- package/template/wall-e/lib/boot-profile.js +11 -0
- package/template/wall-e/lib/brain-owner-worker-client.js +324 -0
- package/template/wall-e/lib/brain-read-pool-client.js +311 -0
- package/template/wall-e/lib/diagnostics-flags.js +87 -0
- package/template/wall-e/lib/event-loop-monitor.js +74 -3
- package/template/wall-e/lib/mcp-integration.js +7 -1
- package/template/wall-e/lib/real-node.js +98 -0
- package/template/wall-e/lib/runtime-health.js +206 -0
- package/template/wall-e/lib/runtime-worker-pool.js +101 -0
- package/template/wall-e/lib/scheduler-worker-jobs.js +231 -0
- package/template/wall-e/lib/scheduler.js +446 -17
- package/template/wall-e/lib/service-health.js +61 -2
- package/template/wall-e/lib/service-readiness.js +258 -0
- package/template/wall-e/lib/usage.js +152 -0
- package/template/wall-e/lib/worker-thread-pool.js +389 -0
- package/template/wall-e/llm/client.js +81 -4
- package/template/wall-e/llm/default-fallback.js +54 -8
- package/template/wall-e/llm/mlx.js +536 -73
- package/template/wall-e/llm/mlx.plugin.json +1 -1
- package/template/wall-e/llm/ollama.js +342 -43
- package/template/wall-e/llm/provider-error.js +18 -1
- package/template/wall-e/llm/provider-health-state.js +176 -0
- package/template/wall-e/llm/routing-policy.js +796 -0
- package/template/wall-e/llm/supported-models.js +5 -0
- package/template/wall-e/loops/tasks.js +60 -14
- package/template/wall-e/loops/think.js +89 -24
- package/template/wall-e/mcp-server.js +192 -28
- package/template/wall-e/server.js +32 -7
- package/template/wall-e/skills/script-skill-runner.js +8 -1
- package/template/wall-e/skills/skill-planner.js +64 -1
- package/template/wall-e/tools/builtin-middleware.js +67 -2
- package/template/wall-e/tools/local-tools.js +116 -26
- package/template/wall-e/tools/permission-checker.js +52 -4
- package/template/wall-e/tools/permission-rules.js +36 -0
- package/template/wall-e/tools/shell-analyzer.js +46 -1
- package/template/wall-e/training/gemma-e4b-qlora.js +314 -0
- package/template/wall-e/training/real-trajectory-miner.js +2617 -0
- package/template/wall-e/training/replay-eval-analysis.js +151 -0
- package/template/wall-e/training/run-shell-command-selector.js +277 -0
- package/template/wall-e/training/tool-sft-dataset.js +312 -0
- package/template/wall-e/training/tool-sft-renderers.js +144 -0
- package/template/wall-e/training/tool-trace-harvester.js +1440 -0
- package/template/wall-e/training/trajectory-action-selector.js +364 -0
- package/template/wall-e/weather-runtime.js +232 -0
- package/template/wall-e/workers/brain-owner-worker.js +162 -0
- package/template/wall-e/workers/brain-read-worker.js +148 -0
- package/template/wall-e/workers/runtime-worker.js +145 -0
|
@@ -8,7 +8,7 @@ const { threadId, isMainThread } = require('node:worker_threads');
|
|
|
8
8
|
const { execFileSync } = require('child_process');
|
|
9
9
|
const { normalizeAgentType } = require('./lib/agent-capabilities');
|
|
10
10
|
const { codexRolloutIdFromPath, readCodexRolloutMetadata } = require('./lib/session-history');
|
|
11
|
-
const { ensureTranscriptTables } = require('./lib/transcript-store');
|
|
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
14
|
const {
|
|
@@ -693,6 +693,26 @@ function _openDbConnection(dbPath) {
|
|
|
693
693
|
d.pragma('journal_mode = WAL');
|
|
694
694
|
d.pragma(`busy_timeout = ${busyTimeoutMs}`);
|
|
695
695
|
d.pragma('foreign_keys = ON');
|
|
696
|
+
// synchronous=NORMAL (safe under WAL): the default FULL fsync's on EVERY commit, which is the
|
|
697
|
+
// bulk of how long a write txn holds the cross-process write lock — and that hold is what parks
|
|
698
|
+
// the main thread OFF-CPU (the "(unknown)" lock-wait freezes that stall Codex keystrokes). Under
|
|
699
|
+
// WAL, NORMAL only fsync's at checkpoint, not per commit; the documented trade-off is that a
|
|
700
|
+
// power-loss/OS-crash can lose the last few committed txns (NOT corrupt the db). For a local
|
|
701
|
+
// session cache that's continuously re-derivable from the JSONL transcripts, that's an easy win.
|
|
702
|
+
// Override with CTM_SQLITE_SYNCHRONOUS=FULL to restore the old behavior.
|
|
703
|
+
const _sync = String(process.env.CTM_SQLITE_SYNCHRONOUS || 'NORMAL').toUpperCase();
|
|
704
|
+
if (['OFF', 'NORMAL', 'FULL', 'EXTRA'].includes(_sync)) d.pragma(`synchronous = ${_sync}`);
|
|
705
|
+
// Disable SQLite's INLINE auto-checkpoint (default 1000 pages). Otherwise any commit that
|
|
706
|
+
// pushes the WAL past the threshold runs a PASSIVE checkpoint SYNCHRONOUSLY on that connection,
|
|
707
|
+
// holding CTM's cross-process write lock for the checkpoint I/O while every other writer stalls.
|
|
708
|
+
// Once the WAL bloats (reader-pool snapshots pin frames so PASSIVE can't truncate, observed 49MB)
|
|
709
|
+
// that inline checkpoint scans the whole WAL — measured 457ms on a trivial cursor upsert and up to
|
|
710
|
+
// 3s on the periodic pass — landing the cost on a random user-facing write (the off-CPU lock-wait
|
|
711
|
+
// freezes). We OWN checkpointing explicitly off the event loop: the db-owner worker runs a periodic
|
|
712
|
+
// TRUNCATE (bounds the file) plus flushWal PASSIVE nudges after critical writes. Set
|
|
713
|
+
// CTM_WAL_AUTOCHECKPOINT to a positive page count to restore SQLite's inline backstop.
|
|
714
|
+
const _autoCkpt = Number(process.env.CTM_WAL_AUTOCHECKPOINT);
|
|
715
|
+
d.pragma(`wal_autocheckpoint = ${Number.isFinite(_autoCkpt) && _autoCkpt >= 0 ? Math.floor(_autoCkpt) : 0}`);
|
|
696
716
|
return d;
|
|
697
717
|
}
|
|
698
718
|
|
|
@@ -1127,6 +1147,13 @@ function runMigrations() {
|
|
|
1127
1147
|
getDb().exec('ALTER TABLE perm_rules ADD COLUMN always_ask INTEGER DEFAULT 0');
|
|
1128
1148
|
}
|
|
1129
1149
|
|
|
1150
|
+
// Add exceptions column to perm_rules (migration) — allow-exceptions attached
|
|
1151
|
+
// to deny rules ("deny rm except under /tmp"); JSON array, NULL = none.
|
|
1152
|
+
const hasExceptions = getDb().prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='perm_rules'").get();
|
|
1153
|
+
if (hasExceptions && !hasExceptions.sql.includes('exceptions')) {
|
|
1154
|
+
getDb().exec('ALTER TABLE perm_rules ADD COLUMN exceptions TEXT DEFAULT NULL');
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1130
1157
|
// Collapse CTM permission rules into a single GLOBAL config (decoupled from
|
|
1131
1158
|
// per-project Claude/Codex settings). CTM owns permissions in its own DB and
|
|
1132
1159
|
// applies them to all sessions; the project column is legacy. Idempotent: runs
|
|
@@ -1305,6 +1332,17 @@ function runMigrations() {
|
|
|
1305
1332
|
getDb().exec("ALTER TABLE approval_decisions ADD COLUMN command_signature TEXT DEFAULT ''");
|
|
1306
1333
|
}
|
|
1307
1334
|
|
|
1335
|
+
// When a Wall-E coding turn parks on approval it registers the park here so it
|
|
1336
|
+
// surfaces in the shared "Pending" tab and survives reload. walle_request_id is
|
|
1337
|
+
// the Wall-E PermissionService request id (permId); resolving the row calls back
|
|
1338
|
+
// that id to unpark the live turn. walle_session_id scopes the cascade re-check.
|
|
1339
|
+
try {
|
|
1340
|
+
getDb().prepare("SELECT walle_request_id FROM approval_decisions LIMIT 1").get();
|
|
1341
|
+
} catch {
|
|
1342
|
+
getDb().exec("ALTER TABLE approval_decisions ADD COLUMN walle_request_id TEXT DEFAULT ''");
|
|
1343
|
+
getDb().exec("ALTER TABLE approval_decisions ADD COLUMN walle_session_id TEXT DEFAULT ''");
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1308
1346
|
// Approval observations are the pre-decision control-plane audit trail.
|
|
1309
1347
|
// approval_decisions records what the agent did; this table records every
|
|
1310
1348
|
// approval-shaped terminal screen, including prompts rejected by structural
|
|
@@ -1866,9 +1904,16 @@ function runMigrations() {
|
|
|
1866
1904
|
// table-create block above only fires for fresh DBs; run this every init
|
|
1867
1905
|
// (idempotent) so existing DBs get the index too. Turns a filtered partial
|
|
1868
1906
|
// scan + sort into a pre-ordered index range scan.
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1907
|
+
// Guard on the column: a v0 DB still has session_messages.session_id at this
|
|
1908
|
+
// point — the rename to ctm_session_id happens later in this same pass — so an
|
|
1909
|
+
// unguarded CREATE INDEX here throws "no such column: ctm_session_id" and aborts
|
|
1910
|
+
// the whole v0→current migration. migrateToV1() builds it itself right after
|
|
1911
|
+
// the rename so a migrating DB still gets the index in this same pass.
|
|
1912
|
+
if (_tableHasColumn('session_messages', 'ctm_session_id')) {
|
|
1913
|
+
getDb().exec(
|
|
1914
|
+
"CREATE INDEX IF NOT EXISTS idx_session_messages_user_prompts ON session_messages(ctm_session_id, role, message_index);"
|
|
1915
|
+
);
|
|
1916
|
+
}
|
|
1872
1917
|
|
|
1873
1918
|
// --- FTS5 on session_messages ---
|
|
1874
1919
|
const hasMsgFts = getDb().prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='session_messages_fts'").get();
|
|
@@ -1937,6 +1982,13 @@ function runMigrations() {
|
|
|
1937
1982
|
// migration time instead of the first user search.
|
|
1938
1983
|
try {
|
|
1939
1984
|
ensureTranscriptTables(getDb());
|
|
1985
|
+
// Heal any transcript rows orphaned by the historical self-link (rollouts keyed by their
|
|
1986
|
+
// own agent_session_id instead of the owning ctm_session_id — splits a resumed session's
|
|
1987
|
+
// Conv tab). Idempotent + bounded; a no-op once clean. See healSelfLinkedTranscriptKeys.
|
|
1988
|
+
const healed = healSelfLinkedTranscriptKeys(getDb());
|
|
1989
|
+
if (healed && (healed.files || healed.events)) {
|
|
1990
|
+
console.log(`[db] healed self-linked transcript keys: sessions=${healed.sessions}, files=${healed.files}, events=${healed.events}`);
|
|
1991
|
+
}
|
|
1940
1992
|
} catch (e) {
|
|
1941
1993
|
console.error('[db] transcript row-store migration failed:', e.message);
|
|
1942
1994
|
}
|
|
@@ -2911,6 +2963,10 @@ function migrateToV1() {
|
|
|
2911
2963
|
const smCols = d.prepare("PRAGMA table_info(session_messages)").all();
|
|
2912
2964
|
if (smCols.find(c => c.name === 'session_id')) {
|
|
2913
2965
|
d.exec('ALTER TABLE session_messages RENAME COLUMN session_id TO ctm_session_id');
|
|
2966
|
+
// The "every init" index build earlier in runMigrations skips a v0 table
|
|
2967
|
+
// (column not renamed yet); build it here so a migrating DB gets the
|
|
2968
|
+
// covering index in this same pass instead of one boot later.
|
|
2969
|
+
d.exec('CREATE INDEX IF NOT EXISTS idx_session_messages_user_prompts ON session_messages(ctm_session_id, role, message_index)');
|
|
2914
2970
|
}
|
|
2915
2971
|
|
|
2916
2972
|
// session_analyses: session_id -> ctm_session_id (it is the PK)
|
|
@@ -3073,10 +3129,63 @@ function addSessionDiagnostic(sessionId, event, details = {}) {
|
|
|
3073
3129
|
LIMIT 500
|
|
3074
3130
|
)
|
|
3075
3131
|
`).run(cleanSessionId, cleanSessionId);
|
|
3076
|
-
flushWal()
|
|
3132
|
+
// No flushWal() here — the WAL is durable on commit, and the throttled
|
|
3133
|
+
// periodic checkpoint (flushWal trailing timer) owns flushing. Calling it on
|
|
3134
|
+
// every diagnostic write made the checkpoint contend with the write storm for
|
|
3135
|
+
// the single writer lock (see flushSessionDiagnostics for the batched path).
|
|
3077
3136
|
return { id: result.lastInsertRowid, session_id: cleanSessionId, event: cleanEvent, ...payload };
|
|
3078
3137
|
}
|
|
3079
3138
|
|
|
3139
|
+
// UTC timestamp string matching SQLite's datetime('now') format, so batched
|
|
3140
|
+
// inserts carry the same shape as the legacy per-row datetime('now') default.
|
|
3141
|
+
function _utcSqliteTimestamp(ts) {
|
|
3142
|
+
const d = ts != null ? new Date(ts) : new Date();
|
|
3143
|
+
const iso = Number.isNaN(d.getTime()) ? new Date().toISOString() : d.toISOString();
|
|
3144
|
+
return iso.replace('T', ' ').replace(/\.\d+Z$/, '');
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
// Batched diagnostic writer: persist a coalesced array of diagnostics in ONE
|
|
3148
|
+
// transaction (one writer-lock acquire / one commit for the whole batch) and run
|
|
3149
|
+
// the per-session retention sweep at most once per affected session. Replaces the
|
|
3150
|
+
// per-event INSERT+DELETE+flushWal storm that saturated the single-writer DB under
|
|
3151
|
+
// heavy multi-session load. Sanitization + JSON happen OUTSIDE the txn to minimize
|
|
3152
|
+
// lock-hold time. `entries`: [{ sessionId, event, details, createdAt }].
|
|
3153
|
+
function flushSessionDiagnostics(entries) {
|
|
3154
|
+
const list = Array.isArray(entries) ? entries : [];
|
|
3155
|
+
const prepared = [];
|
|
3156
|
+
const sessions = new Set();
|
|
3157
|
+
for (const e of list) {
|
|
3158
|
+
if (!e) continue;
|
|
3159
|
+
const sid = String(e.sessionId || '').trim();
|
|
3160
|
+
if (!sid) continue;
|
|
3161
|
+
const event = String(e.event || '').trim() || 'event';
|
|
3162
|
+
const payload = _sanitizeDiagnosticDetails(e.details || {});
|
|
3163
|
+
prepared.push([sid, event, JSON.stringify(payload), e.createdAt || _utcSqliteTimestamp()]);
|
|
3164
|
+
sessions.add(sid);
|
|
3165
|
+
}
|
|
3166
|
+
if (!prepared.length) return { inserted: 0, sessionsSwept: 0 };
|
|
3167
|
+
const d = getDb();
|
|
3168
|
+
const insert = d.prepare(
|
|
3169
|
+
'INSERT INTO session_diagnostics (session_id, event, details_json, created_at) VALUES (?, ?, ?, ?)'
|
|
3170
|
+
);
|
|
3171
|
+
const sweep = d.prepare(`
|
|
3172
|
+
DELETE FROM session_diagnostics
|
|
3173
|
+
WHERE session_id = ?
|
|
3174
|
+
AND id NOT IN (
|
|
3175
|
+
SELECT id FROM session_diagnostics
|
|
3176
|
+
WHERE session_id = ?
|
|
3177
|
+
ORDER BY id DESC
|
|
3178
|
+
LIMIT 500
|
|
3179
|
+
)
|
|
3180
|
+
`);
|
|
3181
|
+
const txn = d.transaction(() => {
|
|
3182
|
+
for (const row of prepared) insert.run(row[0], row[1], row[2], row[3]);
|
|
3183
|
+
for (const sid of sessions) sweep.run(sid, sid);
|
|
3184
|
+
});
|
|
3185
|
+
txn();
|
|
3186
|
+
return { inserted: prepared.length, sessionsSwept: sessions.size };
|
|
3187
|
+
}
|
|
3188
|
+
|
|
3080
3189
|
function listSessionDiagnostics(sessionId, limit = 120) {
|
|
3081
3190
|
const cleanSessionId = String(sessionId || '').trim();
|
|
3082
3191
|
if (!cleanSessionId) return [];
|
|
@@ -4097,11 +4206,12 @@ function listPermRules({ project, listType } = {}) {
|
|
|
4097
4206
|
return getDb().prepare(sql).all(...params);
|
|
4098
4207
|
}
|
|
4099
4208
|
|
|
4100
|
-
function addPermRule({ rule, listType, scope, project }) {
|
|
4209
|
+
function addPermRule({ rule, listType, scope, project, exceptions }) {
|
|
4101
4210
|
getDb().prepare(
|
|
4102
|
-
`INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project)
|
|
4103
|
-
VALUES (?, ?, ?, ?)`
|
|
4104
|
-
).run(rule, listType || 'allow', scope || 'global', project || '__global__'
|
|
4211
|
+
`INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project, exceptions)
|
|
4212
|
+
VALUES (?, ?, ?, ?, ?)`
|
|
4213
|
+
).run(rule, listType || 'allow', scope || 'global', project || '__global__',
|
|
4214
|
+
Array.isArray(exceptions) && exceptions.length ? JSON.stringify(exceptions) : null);
|
|
4105
4215
|
}
|
|
4106
4216
|
|
|
4107
4217
|
function removePermRule({ rule, listType, project }) {
|
|
@@ -4110,15 +4220,28 @@ function removePermRule({ rule, listType, project }) {
|
|
|
4110
4220
|
).run(rule, listType || 'allow', project || '__global__');
|
|
4111
4221
|
}
|
|
4112
4222
|
|
|
4223
|
+
// Replace the exceptions array on one rule row. `exceptions` is a validated
|
|
4224
|
+
// array (the API layer validates shape/limits) or [] to clear. Returns the
|
|
4225
|
+
// number of rows updated (0 = rule not found).
|
|
4226
|
+
function setPermRuleExceptions({ rule, listType, project, exceptions }) {
|
|
4227
|
+
const json = Array.isArray(exceptions) && exceptions.length ? JSON.stringify(exceptions) : null;
|
|
4228
|
+
const res = getDb().prepare(
|
|
4229
|
+
'UPDATE perm_rules SET exceptions = ? WHERE rule = ? AND list_type = ? AND project = ?'
|
|
4230
|
+
).run(json, rule, listType || 'deny', project || '__global__');
|
|
4231
|
+
return res.changes;
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4113
4234
|
function bulkSetPermRules(rules) {
|
|
4114
|
-
// rules: [{ rule, listType, scope, project }]
|
|
4235
|
+
// rules: [{ rule, listType, scope, project, exceptions? }]
|
|
4115
4236
|
const txn = getDb().transaction(() => {
|
|
4116
4237
|
getDb().exec('DELETE FROM perm_rules');
|
|
4117
4238
|
const ins = getDb().prepare(
|
|
4118
|
-
'INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project) VALUES (?, ?, ?, ?)'
|
|
4239
|
+
'INSERT OR IGNORE INTO perm_rules (rule, list_type, scope, project, exceptions) VALUES (?, ?, ?, ?, ?)'
|
|
4119
4240
|
);
|
|
4120
4241
|
for (const r of rules) {
|
|
4121
|
-
|
|
4242
|
+
const exc = Array.isArray(r.exceptions) && r.exceptions.length ? JSON.stringify(r.exceptions)
|
|
4243
|
+
: (typeof r.exceptions === 'string' && r.exceptions ? r.exceptions : null);
|
|
4244
|
+
ins.run(r.rule, r.listType || 'allow', r.scope || 'global', r.project || '__global__', exc);
|
|
4122
4245
|
}
|
|
4123
4246
|
});
|
|
4124
4247
|
txn();
|
|
@@ -4244,6 +4367,12 @@ function _lastMessageAtFromMessages(messages) {
|
|
|
4244
4367
|
}
|
|
4245
4368
|
|
|
4246
4369
|
const _overCapImportLogged = new Set(); // session_id → already warned about an over-parse-cap import (log once)
|
|
4370
|
+
// session_id → message count at the last full token estimate. The estimate is an O(N) walk of the
|
|
4371
|
+
// WHOLE message array; recomputing it on every re-import of an actively-appending session (just to
|
|
4372
|
+
// nudge an APPROXIMATE (~) count by one message) is wasted work under the write lock. Only re-estimate
|
|
4373
|
+
// when the count grew past a delta (or the model changed); otherwise the upsert's COALESCE preserves
|
|
4374
|
+
// the prior stored value — exact recompute still happens on the live path for Claude/Codex.
|
|
4375
|
+
const _importTokenEstimateLen = new Map();
|
|
4247
4376
|
function importSessionConversation({
|
|
4248
4377
|
session_id, project_path, messages, user_msg_count, assistant_msg_count,
|
|
4249
4378
|
search_messages,
|
|
@@ -4253,6 +4382,10 @@ function importSessionConversation({
|
|
|
4253
4382
|
// v3 title-fallback fields; optional for backward compatibility with any
|
|
4254
4383
|
// caller that doesn't yet populate them.
|
|
4255
4384
|
last_user_content, first_assistant_text, rename_name,
|
|
4385
|
+
// When true, DON'T write session_message_rows here — the caller owns the row write because the
|
|
4386
|
+
// render shape differs from `messages` (Wall-E persists review-shaped rows, not the raw transcript
|
|
4387
|
+
// blob shape). The metadata upsert + blob still happen so the freshness gate + legacy reads work.
|
|
4388
|
+
skipMessageRows,
|
|
4256
4389
|
}) {
|
|
4257
4390
|
// Attribution: the whole import is one synchronous span — a JSON.stringify of
|
|
4258
4391
|
// the full message array (multi-MB for 2000+ prompt sessions) + an upsert + a
|
|
@@ -4293,13 +4426,23 @@ function importSessionConversation({
|
|
|
4293
4426
|
// null when there are no messages, and the COALESCE guards below then preserve
|
|
4294
4427
|
// any existing value rather than wiping it.
|
|
4295
4428
|
let _tokTotal = null, _tokCtx = null, _tokWindow = null, _tokExact = 0, _tokBreakdown = null;
|
|
4296
|
-
|
|
4429
|
+
// Only pay the O(N) full-array estimate when it can actually move the (~) number: first time for
|
|
4430
|
+
// this session, a model change, or the count grew past a delta. Otherwise leave the fields null so
|
|
4431
|
+
// the upsert COALESCEs the prior stored value (no loss, no recompute). Delta default 32 messages;
|
|
4432
|
+
// CTM_IMPORT_TOKEN_REESTIMATE_DELTA tunes it, 0 = always re-estimate (old behavior).
|
|
4433
|
+
const _msgLen = Array.isArray(messages) ? messages.length : 0;
|
|
4434
|
+
const _reestimateDelta = Math.max(0, Number(process.env.CTM_IMPORT_TOKEN_REESTIMATE_DELTA ?? 32));
|
|
4435
|
+
const _prevEst = _importTokenEstimateLen.get(session_id);
|
|
4436
|
+
const _modelChanged = !_prevEst || _prevEst.model !== (model_id || '');
|
|
4437
|
+
const _grewEnough = !_prevEst || _reestimateDelta === 0 || (_msgLen - _prevEst.len) >= _reestimateDelta;
|
|
4438
|
+
if (!_overParseCap && (_modelChanged || _grewEnough)) {
|
|
4297
4439
|
try {
|
|
4298
4440
|
const summary = require('./lib/session-token-usage').estimateFromMessages(messages || [], model_id || '');
|
|
4299
4441
|
if (summary && summary.total > 0) {
|
|
4300
4442
|
_tokTotal = summary.total; _tokCtx = summary.ctx; _tokWindow = summary.ctxWindow;
|
|
4301
4443
|
_tokExact = summary.exact ? 1 : 0;
|
|
4302
4444
|
_tokBreakdown = summary.breakdown ? JSON.stringify(summary.breakdown) : null;
|
|
4445
|
+
_importTokenEstimateLen.set(session_id, { len: _msgLen, model: model_id || '' });
|
|
4303
4446
|
}
|
|
4304
4447
|
} catch { /* token estimate is best-effort */ }
|
|
4305
4448
|
}
|
|
@@ -4370,19 +4513,22 @@ function importSessionConversation({
|
|
|
4370
4513
|
// Faithful per-message render store (the blob replacement). Uses `messages` (the exact
|
|
4371
4514
|
// blob source array, full text) so the rows are byte-faithful to the blob. O(Δ) append.
|
|
4372
4515
|
// Stamps extracted_source_len (the render gate's HWM) so it's self-sufficient.
|
|
4373
|
-
|
|
4374
|
-
|
|
4375
|
-
|
|
4376
|
-
|
|
4377
|
-
|
|
4378
|
-
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4516
|
+
// skipMessageRows: the caller writes rows itself in a different (render) shape — see Wall-E.
|
|
4517
|
+
if (!skipMessageRows) {
|
|
4518
|
+
try {
|
|
4519
|
+
replaceSessionMessageRows(session_id, messages || []);
|
|
4520
|
+
} catch (e) {
|
|
4521
|
+
console.error('[db] replaceSessionMessageRows error:', e.message);
|
|
4522
|
+
// If we retired the blob ('[]') but the faithful rows failed to write, this session
|
|
4523
|
+
// would read empty. Restore the real blob (the rare failure path pays the stringify)
|
|
4524
|
+
// so a read is never blank — correctness wins over the perf optimization here.
|
|
4525
|
+
if (_retireBlob) {
|
|
4526
|
+
try {
|
|
4527
|
+
getDb().prepare('UPDATE session_conversations SET messages = ? WHERE ctm_session_id = ?')
|
|
4528
|
+
.run(JSON.stringify(messages || []), session_id);
|
|
4529
|
+
} catch (e2) {
|
|
4530
|
+
console.error('[db] blob-restore after row-write failure also failed:', e2.message);
|
|
4531
|
+
}
|
|
4386
4532
|
}
|
|
4387
4533
|
}
|
|
4388
4534
|
}
|
|
@@ -5085,11 +5231,11 @@ function incrementApprovalRuleMatch(id) {
|
|
|
5085
5231
|
}
|
|
5086
5232
|
|
|
5087
5233
|
// --- Approval Decisions (audit log) ---
|
|
5088
|
-
function addApprovalDecision({ sessionId, toolName, commandSummary, fullContext, warning, decision, reasoning, decidedBy, ruleId, riskLevel, commandSignature }) {
|
|
5234
|
+
function addApprovalDecision({ sessionId, toolName, commandSummary, fullContext, warning, decision, reasoning, decidedBy, ruleId, riskLevel, commandSignature, walleRequestId, walleSessionId }) {
|
|
5089
5235
|
const result = getDb().prepare(
|
|
5090
|
-
`INSERT INTO approval_decisions (session_id, tool_name, command_summary, full_context, warning_text, decision, reasoning, decided_by, rule_id, risk_level, command_signature)
|
|
5091
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
5092
|
-
).run(sessionId || '', toolName || '', commandSummary || '', fullContext || '', warning || '', decision, reasoning || '', decidedBy || 'ai', ruleId || null, riskLevel || 'low', commandSignature || '');
|
|
5236
|
+
`INSERT INTO approval_decisions (session_id, tool_name, command_summary, full_context, warning_text, decision, reasoning, decided_by, rule_id, risk_level, command_signature, walle_request_id, walle_session_id)
|
|
5237
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
|
5238
|
+
).run(sessionId || '', toolName || '', commandSummary || '', fullContext || '', warning || '', decision, reasoning || '', decidedBy || 'ai', ruleId || null, riskLevel || 'low', commandSignature || '', walleRequestId || '', walleSessionId || '');
|
|
5093
5239
|
return result.lastInsertRowid;
|
|
5094
5240
|
}
|
|
5095
5241
|
|
|
@@ -7237,9 +7383,45 @@ function replaceSessionMessageRows(sessionId, messages) {
|
|
|
7237
7383
|
}
|
|
7238
7384
|
}
|
|
7239
7385
|
|
|
7240
|
-
// Full rewrite (first import / compaction / prefix change). Chunked so the write lock is
|
|
7241
|
-
// never held for the whole conversation; AFTER DELETE/INSERT triggers re-sync the FTS.
|
|
7242
7386
|
const CHUNK = Math.max(50, Math.min(5000, Number(process.env.CTM_MESSAGE_REWRITE_CHUNK) || 500));
|
|
7387
|
+
|
|
7388
|
+
// Incremental tail diff (the Codex freeze fix). The pure-extension fast path above misses whenever
|
|
7389
|
+
// the LAST stored message's text changed between imports — and Codex mutates its trailing assistant
|
|
7390
|
+
// message as tokens stream, so EVERY re-import of a long Codex session fell through to the full
|
|
7391
|
+
// rewrite below: DELETE + re-INSERT all N rows, re-tokenizing the whole conversation through the
|
|
7392
|
+
// FTS triggers while holding the cross-process write lock (~1s/16k rows measured). That lock-hold is
|
|
7393
|
+
// the off-CPU main-loop freeze that lagged Codex typing. Claude appends immutably so it always hit
|
|
7394
|
+
// the fast path — hence "especially in codex". Here, for a DENSE store, find the first index where
|
|
7395
|
+
// the stored rows and the new array diverge (forward scan; a lock-free read) and rewrite ONLY from
|
|
7396
|
+
// there. A last-message edit then rewrites ~1 row instead of N. If divergence is at the very start
|
|
7397
|
+
// (true compaction / prefix change) this naturally degrades to a full rewrite (same cost as before).
|
|
7398
|
+
// Gate: CTM_MESSAGE_ROWS_INCREMENTAL_DIFF=0 forces the old full-rewrite behavior.
|
|
7399
|
+
const _denseStore = !!(minRow && maxRow) && minRow.idx === 0 && count === maxRow.idx + 1 && count > 0;
|
|
7400
|
+
if (_denseStore && process.env.CTM_MESSAGE_ROWS_INCREMENTAL_DIFF !== '0') {
|
|
7401
|
+
const existing = d.prepare(
|
|
7402
|
+
'SELECT message_index AS idx, role, text FROM session_message_rows WHERE ctm_session_id = ? ORDER BY message_index ASC'
|
|
7403
|
+
).all(sessionId);
|
|
7404
|
+
const sameAt = (i) => {
|
|
7405
|
+
const s = existing[i]; const m = normalized[i] || {};
|
|
7406
|
+
return String(m.role || '') === s.role && String(m.text != null ? m.text : (m.content != null ? m.content : '')) === s.text;
|
|
7407
|
+
};
|
|
7408
|
+
let div = 0;
|
|
7409
|
+
const lim = Math.min(existing.length, normalized.length);
|
|
7410
|
+
while (div < lim && sameAt(div)) div++;
|
|
7411
|
+
// Delete the stale tail [div, count) and (re)insert the new tail [div, len). Both chunked so the
|
|
7412
|
+
// write lock is never held for the whole conversation; the FTS triggers re-sync per affected row.
|
|
7413
|
+
const tailIds = d.prepare('SELECT id FROM session_message_rows WHERE ctm_session_id = ? AND message_index >= ?').all(sessionId, div);
|
|
7414
|
+
const delById = d.prepare('DELETE FROM session_message_rows WHERE id = ?');
|
|
7415
|
+
const delChunk = d.transaction((slice) => { for (const r of slice) delById.run(r.id); });
|
|
7416
|
+
for (let i = 0; i < tailIds.length; i += CHUNK) delChunk(tailIds.slice(i, i + CHUNK));
|
|
7417
|
+
const insChunk = d.transaction((start, end) => { for (let i = start; i < end; i++) writeRow(i); });
|
|
7418
|
+
for (let i = div; i < normalized.length; i += CHUNK) insChunk(i, Math.min(i + CHUNK, normalized.length));
|
|
7419
|
+
_setExtractedSourceLen(d, sessionId, normalized.length, _lastMessageAtFromMessages(normalized));
|
|
7420
|
+
return normalized.length;
|
|
7421
|
+
}
|
|
7422
|
+
|
|
7423
|
+
// Full rewrite (first import / compaction / prefix change / non-dense store). Chunked so the write
|
|
7424
|
+
// lock is never held for the whole conversation; AFTER DELETE/INSERT triggers re-sync the FTS.
|
|
7243
7425
|
const oldIds = d.prepare('SELECT id FROM session_message_rows WHERE ctm_session_id = ?').all(sessionId);
|
|
7244
7426
|
const delById = d.prepare('DELETE FROM session_message_rows WHERE id = ?');
|
|
7245
7427
|
const delChunk = d.transaction((slice) => { for (const r of slice) delById.run(r.id); });
|
|
@@ -7250,6 +7432,128 @@ function replaceSessionMessageRows(sessionId, messages) {
|
|
|
7250
7432
|
return normalized.length;
|
|
7251
7433
|
}
|
|
7252
7434
|
|
|
7435
|
+
// O(Δ) append of NEW messages after a verified-dense, unchanged-prefix base. Unlike
|
|
7436
|
+
// replaceSessionMessageRows this NEVER loads or rewrites the base array, so it stays O(Δ) for an
|
|
7437
|
+
// active multi-thousand-message session (that full-array load + re-process was the giant-transcript
|
|
7438
|
+
// freeze). Returns the new total length on success, or null when the base is not safe to append
|
|
7439
|
+
// onto — no rows, not a dense 0..n-1 block, or the row count disagrees with `expectedBaseCount`
|
|
7440
|
+
// (the conversation's known message HWM). On null the caller MUST fall back to the full path.
|
|
7441
|
+
function appendSessionMessageRows(sessionId, newMessages, expectedBaseCount) {
|
|
7442
|
+
if (!sessionId || !_tableExists('session_message_rows')) return null;
|
|
7443
|
+
const add = Array.isArray(newMessages) ? newMessages : [];
|
|
7444
|
+
if (add.length === 0) return null;
|
|
7445
|
+
const d = getDb();
|
|
7446
|
+
const minRow = d.prepare('SELECT MIN(message_index) AS idx FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId);
|
|
7447
|
+
const maxRow = d.prepare('SELECT MAX(message_index) AS idx FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId);
|
|
7448
|
+
const count = Number(d.prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n);
|
|
7449
|
+
// Dense 0..count-1 block — no gaps, starts at 0 — else appending would misorder.
|
|
7450
|
+
if (count === 0 || minRow == null || minRow.idx !== 0 || count !== Number(maxRow.idx) + 1) return null;
|
|
7451
|
+
// The row count must equal the base length the delta was parsed against. When the caller can't
|
|
7452
|
+
// prove it (HWM unset / -1), refuse and let the full path re-establish the invariant.
|
|
7453
|
+
if (!Number.isFinite(expectedBaseCount) || expectedBaseCount < 0 || count !== expectedBaseCount) return null;
|
|
7454
|
+
const insertRow = d.prepare(
|
|
7455
|
+
'INSERT OR IGNORE INTO session_message_rows (ctm_session_id, message_index, role, text, timestamp, meta) VALUES (?, ?, ?, ?, ?, ?)'
|
|
7456
|
+
);
|
|
7457
|
+
const writeRow = (i, m) => insertRow.run(
|
|
7458
|
+
sessionId, i, String(m.role || ''),
|
|
7459
|
+
String(m.text != null ? m.text : (m.content != null ? m.content : '')),
|
|
7460
|
+
m.timestamp != null ? String(m.timestamp) : '', _messageRowMeta(m)
|
|
7461
|
+
);
|
|
7462
|
+
const newLen = count + add.length;
|
|
7463
|
+
d.transaction(() => { for (let k = 0; k < add.length; k++) writeRow(count + k, add[k] || {}); })();
|
|
7464
|
+
_setExtractedSourceLen(d, sessionId, newLen, _lastMessageAtFromMessages(add));
|
|
7465
|
+
return newLen;
|
|
7466
|
+
}
|
|
7467
|
+
|
|
7468
|
+
// Incremental counterpart of importSessionConversation for an APPEND (file grew, prefix
|
|
7469
|
+
// unchanged). Appends only the Δ messages to the faithful row store and bumps the conversation
|
|
7470
|
+
// metadata (counts, last_message_at/last_user_content, file_size, token ESTIMATE) without loading
|
|
7471
|
+
// or re-serialising the full conversation. Returns { ok:true, length } on success, or
|
|
7472
|
+
// { ok:false, reason } when the base isn't safe to append onto — the caller then does a full
|
|
7473
|
+
// importSessionConversation. Only the (~) estimate token tier is touched; the live exact path wins.
|
|
7474
|
+
function appendSessionConversation({
|
|
7475
|
+
session_id, new_messages, model_id, file_size,
|
|
7476
|
+
last_user_content, model_provider, import_parser_version,
|
|
7477
|
+
// Cheap metadata that can be corrected on re-import (provider cwd, branch, title, host, rename).
|
|
7478
|
+
// first_message / first_assistant_text / session_created_at are cold-import-only and never change
|
|
7479
|
+
// on an append, so they are deliberately NOT touched here (that would need the full array).
|
|
7480
|
+
project_path, git_branch, title, hostname, rename_name,
|
|
7481
|
+
} = {}) {
|
|
7482
|
+
if (!session_id) return { ok: false, reason: 'no-session' };
|
|
7483
|
+
const add = Array.isArray(new_messages) ? new_messages : [];
|
|
7484
|
+
if (add.length === 0) return { ok: false, reason: 'empty-delta' };
|
|
7485
|
+
// The append path only maintains the row store. If the legacy session_messages index is being
|
|
7486
|
+
// dual-written (or rows are off), defer to the full path so that index stays in lockstep.
|
|
7487
|
+
if (process.env.CTM_SESSION_ROWS === '0' || process.env.CTM_DUAL_WRITE_SESSION_MESSAGES === '1') {
|
|
7488
|
+
return { ok: false, reason: 'legacy-index-dual-write' };
|
|
7489
|
+
}
|
|
7490
|
+
const d = getDb();
|
|
7491
|
+
const existing = d.prepare(
|
|
7492
|
+
'SELECT extracted_source_len, tokens_total, tokens_exact FROM session_conversations WHERE ctm_session_id = ?'
|
|
7493
|
+
).get(session_id);
|
|
7494
|
+
if (!existing) return { ok: false, reason: 'no-existing' };
|
|
7495
|
+
const baseCount = Number(existing.extracted_source_len);
|
|
7496
|
+
// Append the rows first — the strong dense+count guard lives there.
|
|
7497
|
+
const newLen = appendSessionMessageRows(session_id, add, baseCount);
|
|
7498
|
+
if (newLen == null) return { ok: false, reason: 'rows-not-appendable' };
|
|
7499
|
+
|
|
7500
|
+
// Δ-only counts (O(Δ), never re-walks the base).
|
|
7501
|
+
let dUsers = 0, dAssist = 0;
|
|
7502
|
+
for (const m of add) {
|
|
7503
|
+
const role = String(m && m.role || '');
|
|
7504
|
+
if (role === 'user') dUsers++;
|
|
7505
|
+
else if (role === 'assistant') dAssist++;
|
|
7506
|
+
}
|
|
7507
|
+
const lastAt = _lastMessageAtFromMessages(add) || '';
|
|
7508
|
+
|
|
7509
|
+
// Incremental token estimate: add ONLY the delta's tokens to the running total. Skip entirely
|
|
7510
|
+
// when an exact (live) total already owns the row — never downgrade exact → estimate.
|
|
7511
|
+
let dTokens = 0;
|
|
7512
|
+
if (!Number(existing.tokens_exact)) {
|
|
7513
|
+
try {
|
|
7514
|
+
const summ = require('./lib/session-token-usage').estimateFromMessages(add, model_id || '');
|
|
7515
|
+
dTokens = (summ && summ.total) || 0;
|
|
7516
|
+
} catch { /* estimate is best-effort */ }
|
|
7517
|
+
}
|
|
7518
|
+
const newTokens = dTokens > 0 ? (Number(existing.tokens_total) || 0) + dTokens : null;
|
|
7519
|
+
|
|
7520
|
+
d.prepare(
|
|
7521
|
+
`UPDATE session_conversations SET
|
|
7522
|
+
user_msg_count = user_msg_count + ?,
|
|
7523
|
+
assistant_msg_count = assistant_msg_count + ?,
|
|
7524
|
+
file_size = ?,
|
|
7525
|
+
last_message_at = CASE
|
|
7526
|
+
WHEN ? = '' THEN last_message_at
|
|
7527
|
+
WHEN last_message_at IS NULL OR last_message_at = '' THEN ?
|
|
7528
|
+
WHEN ? > last_message_at THEN ?
|
|
7529
|
+
ELSE last_message_at END,
|
|
7530
|
+
last_user_content = COALESCE(NULLIF(?, ''), last_user_content),
|
|
7531
|
+
project_path = COALESCE(NULLIF(?, ''), project_path),
|
|
7532
|
+
git_branch = COALESCE(NULLIF(?, ''), git_branch),
|
|
7533
|
+
title = COALESCE(NULLIF(?, ''), title),
|
|
7534
|
+
hostname = COALESCE(NULLIF(?, ''), hostname),
|
|
7535
|
+
rename_name = COALESCE(NULLIF(?, ''), rename_name),
|
|
7536
|
+
model_provider = COALESCE(NULLIF(?, ''), model_provider),
|
|
7537
|
+
model_id = COALESCE(NULLIF(?, ''), model_id),
|
|
7538
|
+
import_parser_version = ?,
|
|
7539
|
+
tokens_total = COALESCE(?, tokens_total),
|
|
7540
|
+
tokens_ctx = COALESCE(?, tokens_ctx),
|
|
7541
|
+
tokens_updated_at = CASE WHEN ? IS NULL THEN tokens_updated_at ELSE datetime('now') END,
|
|
7542
|
+
imported_at = datetime('now')
|
|
7543
|
+
WHERE ctm_session_id = ?`
|
|
7544
|
+
).run(
|
|
7545
|
+
dUsers, dAssist, Number(file_size) || 0,
|
|
7546
|
+
lastAt, lastAt, lastAt, lastAt,
|
|
7547
|
+
last_user_content || '',
|
|
7548
|
+
project_path || '', git_branch || '', title || '', hostname || '', rename_name || '',
|
|
7549
|
+
model_provider || '', model_id || '',
|
|
7550
|
+
Number(import_parser_version || 0),
|
|
7551
|
+
newTokens, newTokens, newTokens,
|
|
7552
|
+
session_id
|
|
7553
|
+
);
|
|
7554
|
+
return { ok: true, length: newLen };
|
|
7555
|
+
}
|
|
7556
|
+
|
|
7253
7557
|
// Whole conversation as the faithful message array (blob-identical), from rows; blob fallback.
|
|
7254
7558
|
function getSessionMessagesArray(sessionId, { fallbackToBlob = true } = {}) {
|
|
7255
7559
|
const d = getDb();
|
|
@@ -7283,17 +7587,19 @@ function sessionContentRowsAvailable(sessionId) {
|
|
|
7283
7587
|
}
|
|
7284
7588
|
|
|
7285
7589
|
// Newest-first offset page (matches lib/message-pagination.js semantics), O(limit) rows.
|
|
7590
|
+
// Delegates to the shared lib/session-messages-page module so the SAME logic runs on the main
|
|
7591
|
+
// thread (here) and on the read-pool worker (off-thread, read-only handle) — parity by construction.
|
|
7286
7592
|
function getSessionMessagesPage(sessionId, { offset = 0, limit = 200 } = {}) {
|
|
7287
|
-
|
|
7288
|
-
|
|
7289
|
-
|
|
7290
|
-
|
|
7291
|
-
|
|
7292
|
-
|
|
7293
|
-
|
|
7294
|
-
'
|
|
7295
|
-
|
|
7296
|
-
|
|
7593
|
+
return require('./lib/session-messages-page').getMessagesPage(getDb(), { sessionId, offset, limit });
|
|
7594
|
+
}
|
|
7595
|
+
|
|
7596
|
+
// Cheap row count (no fetch) — a content-version signal so callers can skip an
|
|
7597
|
+
// expensive page fetch + re-render when the message set is unchanged.
|
|
7598
|
+
function countSessionMessageRows(sessionId) {
|
|
7599
|
+
try {
|
|
7600
|
+
if (!_tableExists('session_message_rows')) return 0;
|
|
7601
|
+
return Number(getDb().prepare('SELECT COUNT(*) AS n FROM session_message_rows WHERE ctm_session_id = ?').get(sessionId).n) || 0;
|
|
7602
|
+
} catch { return 0; }
|
|
7297
7603
|
}
|
|
7298
7604
|
|
|
7299
7605
|
function _defaultSessionTurnStart(message) {
|
|
@@ -7392,9 +7698,14 @@ function getSessionMessagesTurnPage(sessionId, { offset = 0, limit = 50, isTurnS
|
|
|
7392
7698
|
// backfill sweep using THIS module's writer + handle, so the worker/scheduler never thread
|
|
7393
7699
|
// the handle through. Idempotent + resumable; returns the sweep summary {swept,migrated,rows,remaining}.
|
|
7394
7700
|
function runContentRowsBackfillSweep(options = {}) {
|
|
7395
|
-
const
|
|
7701
|
+
const o = options && typeof options === 'object' ? options : {};
|
|
7702
|
+
// Smaller per-sweep batch by default (was 50) — this background migration shares the write lock
|
|
7703
|
+
// with interactive work; keep each sweep cheap and let it converge across ticks.
|
|
7704
|
+
const limit = Math.max(1, Math.min(500, Number(o.limit) || 25));
|
|
7705
|
+
const budgetMs = Math.max(0, Number(o.budgetMs ?? process.env.CTM_ROWS_BACKFILL_BUDGET_MS ?? 250));
|
|
7706
|
+
const maxRows = Math.max(0, Number(o.maxRows ?? process.env.CTM_ROWS_BACKFILL_MAX_ROWS ?? 3000));
|
|
7396
7707
|
const bf = require('./lib/session-content-backfill');
|
|
7397
|
-
return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, { limit });
|
|
7708
|
+
return bf.runContentRowsBackfillSweep(getDb(), replaceSessionMessageRows, { limit, budgetMs, maxRows });
|
|
7398
7709
|
}
|
|
7399
7710
|
|
|
7400
7711
|
// Migration completeness for GET /api/ctm/session-rows-status (+ the cutover gate).
|
|
@@ -8766,6 +9077,45 @@ function getSessionTitleNew(id) {
|
|
|
8766
9077
|
return row && row.title ? { title: row.title, userRenamed: !!row.user_renamed } : null;
|
|
8767
9078
|
}
|
|
8768
9079
|
|
|
9080
|
+
// Batch form of getSessionTitleNew: resolve titles for many ids in TWO queries instead of N point
|
|
9081
|
+
// reads, so a per-session gather loop (e.g. model-sync) doesn't block the main loop. Returns a
|
|
9082
|
+
// Map<id,{title,userRenamed}> keyed by the ORIGINAL input id; ids with no title are omitted (a miss
|
|
9083
|
+
// is `map.get(id) === undefined`, parity with getSessionTitleNew returning null). Precedence matches
|
|
9084
|
+
// getSessionTitleNew EXACTLY: a direct ctm_sessions row wins — and a titleless direct row resolves to
|
|
9085
|
+
// a miss WITHOUT falling through to the agent-session-id alias (getSessionTitleNew only tries the
|
|
9086
|
+
// alias when no ctm row exists for the id).
|
|
9087
|
+
function getSessionTitlesByIds(ids) {
|
|
9088
|
+
const out = new Map();
|
|
9089
|
+
const list = Array.from(new Set((Array.isArray(ids) ? ids : []).map((x) => String(x || '')).filter(Boolean)));
|
|
9090
|
+
if (list.length === 0) return out;
|
|
9091
|
+
const db = getDb();
|
|
9092
|
+
const ph = list.map(() => '?').join(',');
|
|
9093
|
+
const directRows = db.prepare(`SELECT id, title, user_renamed FROM ctm_sessions WHERE id IN (${ph})`).all(...list);
|
|
9094
|
+
const directById = new Map(directRows.map((r) => [String(r.id), r]));
|
|
9095
|
+
// Only ids with NO ctm row at all fall through to the agent_session_id alias.
|
|
9096
|
+
const unresolved = list.filter((id) => !directById.has(id));
|
|
9097
|
+
let aliasByAgentId = new Map();
|
|
9098
|
+
if (unresolved.length) {
|
|
9099
|
+
const aph = unresolved.map(() => '?').join(',');
|
|
9100
|
+
const aliasRows = db.prepare(
|
|
9101
|
+
`SELECT a.agent_session_id AS aid, c.title AS title, c.user_renamed AS user_renamed
|
|
9102
|
+
FROM agent_sessions a JOIN ctm_sessions c ON c.id = a.ctm_session_id
|
|
9103
|
+
WHERE a.agent_session_id IN (${aph})`
|
|
9104
|
+
).all(...unresolved);
|
|
9105
|
+
aliasByAgentId = new Map(aliasRows.map((r) => [String(r.aid), r]));
|
|
9106
|
+
}
|
|
9107
|
+
for (const id of list) {
|
|
9108
|
+
if (directById.has(id)) {
|
|
9109
|
+
const direct = directById.get(id);
|
|
9110
|
+
if (direct && direct.title) out.set(id, { title: direct.title, userRenamed: !!direct.user_renamed });
|
|
9111
|
+
continue; // titleless direct row → miss, no alias (matches getSessionTitleNew)
|
|
9112
|
+
}
|
|
9113
|
+
const alias = aliasByAgentId.get(id);
|
|
9114
|
+
if (alias && alias.title) out.set(id, { title: alias.title, userRenamed: !!alias.user_renamed });
|
|
9115
|
+
}
|
|
9116
|
+
return out;
|
|
9117
|
+
}
|
|
9118
|
+
|
|
8769
9119
|
function getSessionDisplayTitleInfo(id) {
|
|
8770
9120
|
const cleanId = String(id || '').trim();
|
|
8771
9121
|
if (!cleanId) return null;
|
|
@@ -9226,7 +9576,7 @@ module.exports = {
|
|
|
9226
9576
|
getStorageRisk,
|
|
9227
9577
|
getSqliteDriverStatus,
|
|
9228
9578
|
getSetting, getSettingsByPrefix, setSetting,
|
|
9229
|
-
addSessionDiagnostic, listSessionDiagnostics,
|
|
9579
|
+
addSessionDiagnostic, flushSessionDiagnostics, listSessionDiagnostics,
|
|
9230
9580
|
upsertRuntimeTask, updateRuntimeTask, getRuntimeTask, listRuntimeTasks, deleteRuntimeTasksForSession,
|
|
9231
9581
|
createPrompt, updatePrompt, getPrompt, listPrompts, listChildPrompts, setPromptParent, createGroupFromPrompts, deletePrompt, duplicatePrompt, reorderPrompts,
|
|
9232
9582
|
getPromptVersions, restorePromptVersion,
|
|
@@ -9236,8 +9586,8 @@ module.exports = {
|
|
|
9236
9586
|
listPermissionRules, upsertPermissionRule, deletePermissionRule,
|
|
9237
9587
|
listPermissionLog, addPermissionLog,
|
|
9238
9588
|
getAlwaysAskTools, setAlwaysAsk,
|
|
9239
|
-
importSessionConversation, listSessionConversations, getSessionConversation, getSessionConversationMeta, getSessionConversationMessages, updateSessionModel,
|
|
9240
|
-
replaceSessionMessageRows, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage,
|
|
9589
|
+
importSessionConversation, appendSessionConversation, appendSessionMessageRows, listSessionConversations, getSessionConversation, getSessionConversationMeta, getSessionConversationMessages, updateSessionModel,
|
|
9590
|
+
replaceSessionMessageRows, getSessionMessagesArray, sessionContentRowsAvailable, getSessionMessagesPage, getSessionMessagesTurnPage, countSessionMessageRows,
|
|
9241
9591
|
runContentRowsBackfillSweep, getSessionRowsStatus, retireLegacyStores, runVacuum,
|
|
9242
9592
|
updateSessionTokens, getSessionTokens,
|
|
9243
9593
|
pickDisplayTitle,
|
|
@@ -9247,6 +9597,7 @@ module.exports = {
|
|
|
9247
9597
|
checkpointWal, checkpointWalOrThrow, setWalCheckpointRunner, createBackup, copyLiveDatabaseTo, createBackupSync, listBackups, restoreBackup, deleteBackup, startDailyBackup,
|
|
9248
9598
|
saveQueue, loadQueue, loadAllQueues, deleteQueueDb,
|
|
9249
9599
|
listPermRules, addPermRule, removePermRule, bulkSetPermRules, getPermRulesByProject,
|
|
9600
|
+
setPermRuleExceptions,
|
|
9250
9601
|
listAutoApprovals, upsertAutoApproval, toggleAutoApproval, deleteAutoApproval, getEnabledAutoApprovals,
|
|
9251
9602
|
listApprovalRules, upsertApprovalRule, findApprovalRuleBySignature, toggleApprovalRule, deleteApprovalRule, incrementApprovalRuleMatch,
|
|
9252
9603
|
addApprovalDecision, listApprovalDecisions, resolveApprovalDecision, getPendingEscalations,
|
|
@@ -9285,7 +9636,7 @@ module.exports = {
|
|
|
9285
9636
|
upsertSessionIndex, resolveSessionId,
|
|
9286
9637
|
// CTM Sessions + Agent Sessions (v1 schema)
|
|
9287
9638
|
getSessionIdentity, getLatestAgentSessionForCtm, getSessionConversationSourceIds, getSessionMessageIndexFreshness, getSessionUserPromptFreshness, resolveSession, batchRestoreIdentities, upsertSession, upsertAgentSessionIdentity, setSessionStar, getStarredSessionIds,
|
|
9288
|
-
setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
|
|
9639
|
+
setSessionTitleNew, setSessionTitleStatusNew, getSessionTitleNew, getSessionTitlesByIds, getSessionDisplayTitleInfo, isUserSessionTitleCandidate, promoteStartupTaskTitleIfUserNamed, repairSessionTitlesFromStartupTasks, getAllSessionsData,
|
|
9289
9640
|
repairCodexSessionMetadataFromConversations, repairCodexRolloutAgentIdentities, detachPromotedProviderChildSessions,
|
|
9290
9641
|
listProviderChildAgentOwnerMappings,
|
|
9291
9642
|
getAgentSessions, getAgentSession, deleteCtmSession,
|