create-walle 0.9.13 → 0.9.15
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 -3
- package/bin/create-walle.js +232 -32
- package/bin/mcp-inject.js +18 -53
- package/package.json +3 -1
- package/template/claude-task-manager/api-prompts.js +11 -2
- package/template/claude-task-manager/approval-agent.js +7 -0
- package/template/claude-task-manager/db.js +94 -75
- package/template/claude-task-manager/docs/session-standup-command-center-design.md +242 -0
- package/template/claude-task-manager/docs/session-tooltip-freshness-design.md +224 -0
- package/template/claude-task-manager/docs/session-ux-issue-review-2026-05-01.md +369 -0
- package/template/claude-task-manager/fuzzy-utils.js +10 -2
- package/template/claude-task-manager/git-utils.js +140 -10
- package/template/claude-task-manager/lib/agent-capabilities.js +1 -1
- package/template/claude-task-manager/lib/agent-presets.js +38 -5
- package/template/claude-task-manager/lib/codex-terminal-final.js +53 -0
- package/template/claude-task-manager/lib/ctm-session-context-api.js +222 -0
- package/template/claude-task-manager/lib/session-diagnostics.js +56 -0
- package/template/claude-task-manager/lib/session-history.js +309 -16
- package/template/claude-task-manager/lib/session-standup.js +409 -0
- package/template/claude-task-manager/lib/session-stream.js +253 -20
- package/template/claude-task-manager/lib/standup-attention.js +200 -0
- package/template/claude-task-manager/lib/status-hooks.js +8 -2
- package/template/claude-task-manager/lib/update-telemetry.js +114 -0
- package/template/claude-task-manager/lib/walle-ctm-history.js +49 -6
- package/template/claude-task-manager/lib/walle-default-model.js +55 -0
- package/template/claude-task-manager/lib/walle-mcp-auto-config.js +66 -0
- package/template/claude-task-manager/lib/walle-supervisor.js +86 -19
- package/template/claude-task-manager/lib/walle-transcript.js +1 -3
- package/template/claude-task-manager/lib/worktree-cwd.js +82 -0
- package/template/claude-task-manager/package.json +1 -0
- package/template/claude-task-manager/providers/codex-mcp.js +104 -0
- package/template/claude-task-manager/providers/index.js +2 -0
- package/template/claude-task-manager/public/css/setup.css +2 -1
- package/template/claude-task-manager/public/css/walle.css +71 -0
- package/template/claude-task-manager/public/index.html +2388 -429
- package/template/claude-task-manager/public/js/message-renderer.js +314 -35
- package/template/claude-task-manager/public/js/session-search-utils.js +185 -3
- package/template/claude-task-manager/public/js/session-status-precedence.js +125 -0
- package/template/claude-task-manager/public/js/setup.js +62 -19
- package/template/claude-task-manager/public/js/stream-view.js +396 -55
- package/template/claude-task-manager/public/js/terminal-restore-state.js +57 -0
- package/template/claude-task-manager/public/js/walle-session.js +234 -26
- package/template/claude-task-manager/public/js/walle.js +143 -2
- package/template/claude-task-manager/server.js +1402 -433
- package/template/claude-task-manager/session-integrity.js +77 -28
- package/template/claude-task-manager/workers/approval-widget-validator.js +15 -5
- package/template/claude-task-manager/workers/scrollback-worker.js +5 -6
- package/template/claude-task-manager/workers/state-detectors/codex.js +6 -0
- package/template/package.json +1 -1
- package/template/wall-e/agent-runners/claude-code.js +2 -0
- package/template/wall-e/agent.js +63 -8
- package/template/wall-e/api-walle.js +330 -52
- package/template/wall-e/brain.js +291 -42
- package/template/wall-e/chat.js +172 -15
- package/template/wall-e/coding/compaction-service.js +19 -5
- package/template/wall-e/coding/stream-processor.js +22 -2
- package/template/wall-e/coding/workspace-replay.js +1 -4
- package/template/wall-e/coding-orchestrator.js +250 -80
- package/template/wall-e/compat.js +0 -28
- package/template/wall-e/context/context-builder.js +3 -1
- package/template/wall-e/embeddings.js +2 -7
- package/template/wall-e/eval/agent-runner.js +30 -9
- package/template/wall-e/eval/benchmark-generator.js +21 -1
- package/template/wall-e/eval/benchmarks/chat-eval.json +66 -6
- package/template/wall-e/eval/benchmarks/coding-agent.json +0 -596
- package/template/wall-e/eval/cc-replay.js +1 -0
- package/template/wall-e/eval/codex-cli-baseline.js +633 -0
- package/template/wall-e/eval/debug-agent003.js +1 -0
- package/template/wall-e/eval/eval-orchestrator.js +3 -3
- package/template/wall-e/eval/run-agent-benchmarks.js +11 -3
- package/template/wall-e/eval/run-codex-cli-baseline.js +177 -0
- package/template/wall-e/eval/run-model-comparison.js +1 -0
- package/template/wall-e/eval/swebench-adapter.js +1 -0
- package/template/wall-e/evaluation/quorum-evaluator.js +0 -1
- package/template/wall-e/extraction/knowledge-extractor.js +1 -2
- package/template/wall-e/lib/mcp-integration.js +336 -0
- package/template/wall-e/llm/ollama.js +47 -8
- package/template/wall-e/llm/ollama.plugin.json +1 -1
- package/template/wall-e/llm/tool-adapter.js +1 -0
- package/template/wall-e/loops/ingest.js +42 -8
- package/template/wall-e/loops/initiative.js +87 -2
- package/template/wall-e/mcp-server.js +872 -19
- package/template/wall-e/memory/ctm-context-client.js +230 -0
- package/template/wall-e/memory/ctm-session-context.js +1376 -0
- package/template/wall-e/prompts/coding/memory-protocol.md +6 -0
- package/template/wall-e/server.js +30 -1
- package/template/wall-e/skills/_bundled/memory-search/SKILL.md +8 -0
- package/template/wall-e/skills/_bundled/scan-ctm-sessions/SKILL.md +20 -0
- package/template/wall-e/skills/_bundled/scan-ctm-sessions/run.js +43 -0
- package/template/wall-e/skills/_bundled/slack-mentions/run.js +471 -188
- package/template/wall-e/skills/skill-planner.js +86 -4
- package/template/wall-e/slack/socket-mode-listener.js +276 -0
- package/template/wall-e/telemetry.js +70 -2
- package/template/wall-e/tools/builtin-middleware.js +55 -2
- package/template/wall-e/tools/shell-policy.js +1 -1
- package/template/wall-e/tools/slack-owner.js +104 -0
- package/template/website/index.html +4 -4
- package/template/builder-journal.md +0 -17
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const os = require('node:os');
|
|
5
|
+
const path = require('node:path');
|
|
6
|
+
const crypto = require('node:crypto');
|
|
7
|
+
const Database = require('better-sqlite3');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_SEARCH_LIMIT = 10;
|
|
10
|
+
const MAX_SEARCH_LIMIT = 50;
|
|
11
|
+
const DEFAULT_CONTEXT_LIMIT = 200;
|
|
12
|
+
const MAX_CONTEXT_LIMIT = 5000;
|
|
13
|
+
const DEFAULT_TOKEN_BUDGET = 12000;
|
|
14
|
+
const CTM_SESSION_TABLES = [
|
|
15
|
+
'session_conversations',
|
|
16
|
+
'session_messages',
|
|
17
|
+
'session_messages_fts',
|
|
18
|
+
'ctm_sessions',
|
|
19
|
+
'agent_sessions',
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
function resolveCtmDbPath(env = process.env) {
|
|
23
|
+
if (env.CTM_DB_PATH) return env.CTM_DB_PATH;
|
|
24
|
+
if (env.CTM_DATA_DIR) return path.join(env.CTM_DATA_DIR, 'task-manager.db');
|
|
25
|
+
if (env.WALLE_DEV_DIR) return path.join(env.WALLE_DEV_DIR, 'task-manager.db');
|
|
26
|
+
return path.join(env.HOME || os.homedir(), '.walle', 'data', 'task-manager.db');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function resolveCtmDbCandidatePaths(env = process.env) {
|
|
30
|
+
const candidates = [];
|
|
31
|
+
const push = (dbPath, source) => {
|
|
32
|
+
if (!dbPath) return;
|
|
33
|
+
const resolved = path.resolve(dbPath);
|
|
34
|
+
if (candidates.some((candidate) => candidate.dbPath === resolved)) return;
|
|
35
|
+
candidates.push({ dbPath: resolved, source });
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
push(env.CTM_DB_PATH, 'configured-ctm-db');
|
|
39
|
+
if (env.CTM_DATA_DIR) push(path.join(env.CTM_DATA_DIR, 'task-manager.db'), 'ctm-data-dir');
|
|
40
|
+
if (env.WALLE_DEV_DIR) push(path.join(env.WALLE_DEV_DIR, 'task-manager.db'), 'walle-dev-db');
|
|
41
|
+
push(path.join(env.HOME || os.homedir(), '.walle', 'data', 'task-manager.db'), 'wall-e-cache');
|
|
42
|
+
if (!candidates.length) push(resolveCtmDbPath(env), 'ctm-db');
|
|
43
|
+
return candidates;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function openCtmDb({ dbPath, readonly = true, busyTimeoutMs = 2500 } = {}) {
|
|
47
|
+
const resolved = path.resolve(dbPath || resolveCtmDbPath());
|
|
48
|
+
if (!fs.existsSync(resolved)) {
|
|
49
|
+
return { db: null, dbPath: resolved, close: () => {}, unavailable: true };
|
|
50
|
+
}
|
|
51
|
+
const db = new Database(resolved, { readonly, fileMustExist: true });
|
|
52
|
+
const timeout = Number.isFinite(Number(busyTimeoutMs)) ? Math.max(0, Math.trunc(Number(busyTimeoutMs))) : 2500;
|
|
53
|
+
db.pragma(`busy_timeout = ${timeout}`);
|
|
54
|
+
if (readonly) {
|
|
55
|
+
db.pragma('query_only = ON');
|
|
56
|
+
}
|
|
57
|
+
return { db, dbPath: resolved, readonly: Boolean(readonly), close: () => db.close(), unavailable: false };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function withCtmDb(options, fn) {
|
|
61
|
+
if (options?.db) {
|
|
62
|
+
return fn(options.db, { dbPath: options.dbPath || '', close: () => {}, owned: false });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const attempts = [];
|
|
66
|
+
const candidates = options?.dbPath
|
|
67
|
+
? [{ dbPath: path.resolve(options.dbPath), source: 'explicit-db-path' }]
|
|
68
|
+
: resolveCtmDbCandidatePaths(options?.env || process.env);
|
|
69
|
+
|
|
70
|
+
let lastFailure = null;
|
|
71
|
+
for (let i = 0; i < candidates.length; i += 1) {
|
|
72
|
+
const candidate = candidates[i];
|
|
73
|
+
let handle;
|
|
74
|
+
try {
|
|
75
|
+
handle = openCtmDb({ ...options, dbPath: candidate.dbPath });
|
|
76
|
+
} catch (err) {
|
|
77
|
+
const classified = classifySqliteFailure(err, 'ctm_db_open_failed');
|
|
78
|
+
const failure = {
|
|
79
|
+
db_path: candidate.dbPath,
|
|
80
|
+
active_source: candidate.source,
|
|
81
|
+
ok: false,
|
|
82
|
+
reason: classified.reason,
|
|
83
|
+
error: err?.message || String(err),
|
|
84
|
+
};
|
|
85
|
+
attempts.push(failure);
|
|
86
|
+
lastFailure = failure;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (!handle.db) {
|
|
91
|
+
const failure = {
|
|
92
|
+
db_path: handle.dbPath,
|
|
93
|
+
active_source: candidate.source,
|
|
94
|
+
ok: false,
|
|
95
|
+
unavailable: true,
|
|
96
|
+
reason: 'ctm_db_not_found',
|
|
97
|
+
};
|
|
98
|
+
attempts.push(failure);
|
|
99
|
+
lastFailure = failure;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const result = fn(handle.db, {
|
|
105
|
+
dbPath: handle.dbPath,
|
|
106
|
+
activeSource: candidate.source,
|
|
107
|
+
close: handle.close,
|
|
108
|
+
owned: true,
|
|
109
|
+
});
|
|
110
|
+
const annotated = annotateDbResult(result, {
|
|
111
|
+
dbPath: handle.dbPath,
|
|
112
|
+
activeSource: candidate.source,
|
|
113
|
+
attempts,
|
|
114
|
+
fallbackUsed: i > 0,
|
|
115
|
+
});
|
|
116
|
+
attempts.push(attemptFromResult(annotated));
|
|
117
|
+
if (!isDbSourceFailure(annotated) || options?.fallback === false) {
|
|
118
|
+
return annotated;
|
|
119
|
+
}
|
|
120
|
+
lastFailure = annotated;
|
|
121
|
+
} finally {
|
|
122
|
+
handle.close();
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
source: 'ctm-db',
|
|
129
|
+
unavailable: true,
|
|
130
|
+
db_path: lastFailure?.db_path || candidates[0]?.dbPath || '',
|
|
131
|
+
active_source: lastFailure?.active_source || candidates[0]?.source || 'ctm-db',
|
|
132
|
+
fallback_used: attempts.length > 1,
|
|
133
|
+
attempts,
|
|
134
|
+
reason: lastFailure?.reason || 'ctm_db_not_found',
|
|
135
|
+
error: lastFailure?.error || '',
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function annotateDbResult(result, { dbPath, activeSource, attempts, fallbackUsed }) {
|
|
140
|
+
if (!result || typeof result !== 'object') return result;
|
|
141
|
+
return {
|
|
142
|
+
...result,
|
|
143
|
+
db_path: result.db_path || dbPath,
|
|
144
|
+
active_source: result.active_source || activeSource,
|
|
145
|
+
fallback_used: Boolean(result.fallback_used || fallbackUsed || attempts.length > 0),
|
|
146
|
+
attempts: attempts.length ? [...attempts] : result.attempts,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function attemptFromResult(result) {
|
|
151
|
+
return {
|
|
152
|
+
db_path: result?.db_path || '',
|
|
153
|
+
active_source: result?.active_source || '',
|
|
154
|
+
ok: Boolean(result?.ok),
|
|
155
|
+
reason: result?.reason || '',
|
|
156
|
+
error: result?.error || '',
|
|
157
|
+
tables: result?.tables || result?.cached_tables || [],
|
|
158
|
+
missing_tables: result?.missing_tables || [],
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isDbSourceFailure(result) {
|
|
163
|
+
if (!result || result.ok !== false) return false;
|
|
164
|
+
return [
|
|
165
|
+
'ctm_db_not_found',
|
|
166
|
+
'ctm_db_open_failed',
|
|
167
|
+
'ctm_db_locked',
|
|
168
|
+
'ctm_db_schema_read_failed',
|
|
169
|
+
'ctm_session_tables_missing',
|
|
170
|
+
].includes(result.reason);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function classifySqliteFailure(err, fallbackReason) {
|
|
174
|
+
const code = String(err?.code || '');
|
|
175
|
+
const message = String(err?.message || err || '');
|
|
176
|
+
if (/SQLITE_(BUSY|LOCKED)/.test(code) || /database is locked|database table is locked|busy/i.test(message)) {
|
|
177
|
+
return { reason: 'ctm_db_locked' };
|
|
178
|
+
}
|
|
179
|
+
return { reason: fallbackReason };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function hasOpenableCtmDb(env = process.env) {
|
|
183
|
+
return resolveCtmDbCandidatePaths(env).some((candidate) => fs.existsSync(candidate.dbPath));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function tableRowCount(db, table) {
|
|
187
|
+
if (!hasTable(db, table)) return null;
|
|
188
|
+
try {
|
|
189
|
+
return db.prepare(`SELECT COUNT(*) AS count FROM ${quoteIdent(table)}`).get().count;
|
|
190
|
+
} catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function searchCtmSessions({ query, limit = DEFAULT_SEARCH_LIMIT, db, dbPath, env } = {}) {
|
|
196
|
+
const cleanQuery = String(query || '').trim();
|
|
197
|
+
if (!cleanQuery) return { ok: false, source: 'ctm-db', results: [], reason: 'query_required' };
|
|
198
|
+
const requestedLimit = clampLimit(limit, DEFAULT_SEARCH_LIMIT, MAX_SEARCH_LIMIT);
|
|
199
|
+
|
|
200
|
+
return withCtmDb({ db, dbPath, env }, (conn, handle) => {
|
|
201
|
+
const tableStatus = inspectSessionTables(conn);
|
|
202
|
+
if (!tableStatus.ok) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
source: 'ctm-db',
|
|
206
|
+
db_path: handle.dbPath,
|
|
207
|
+
results: [],
|
|
208
|
+
...tableStatus,
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const rawResults = [];
|
|
213
|
+
rawResults.push(...searchSessionMessagesFts(conn, cleanQuery, requestedLimit * 4));
|
|
214
|
+
rawResults.push(...searchSessionMessagesLike(conn, cleanQuery, requestedLimit * 4));
|
|
215
|
+
rawResults.push(...searchSessionConversationMessages(conn, cleanQuery, requestedLimit * 6));
|
|
216
|
+
rawResults.push(...searchSessionMetadata(conn, cleanQuery, requestedLimit * 2));
|
|
217
|
+
|
|
218
|
+
const results = dedupeItems(rawResults, {
|
|
219
|
+
limit: requestedLimit,
|
|
220
|
+
contentFields: ['snippet', 'content'],
|
|
221
|
+
keyPrefix: (item) => `${item.session_id || ''}:${item.role || ''}`,
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
source: 'ctm-db',
|
|
227
|
+
db_path: handle.dbPath,
|
|
228
|
+
active_source: handle.activeSource || 'ctm-db',
|
|
229
|
+
query: cleanQuery,
|
|
230
|
+
count: results.length,
|
|
231
|
+
results,
|
|
232
|
+
};
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function getCtmSessionContext({
|
|
237
|
+
session_id,
|
|
238
|
+
source_id,
|
|
239
|
+
session_ids,
|
|
240
|
+
ids,
|
|
241
|
+
limit = DEFAULT_CONTEXT_LIMIT,
|
|
242
|
+
cursor = 0,
|
|
243
|
+
include_raw = false,
|
|
244
|
+
dedupe = true,
|
|
245
|
+
format = 'messages',
|
|
246
|
+
db,
|
|
247
|
+
dbPath,
|
|
248
|
+
env,
|
|
249
|
+
} = {}) {
|
|
250
|
+
const identifiers = normalizeIdentifiers(session_ids || ids || session_id || source_id);
|
|
251
|
+
if (identifiers.length === 0) {
|
|
252
|
+
return { ok: false, source: 'ctm-db', reason: 'session_id_required', sessions: [], messages: [] };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return withCtmDb({ db, dbPath, env }, (conn, handle) => {
|
|
256
|
+
const tableStatus = inspectSessionTables(conn);
|
|
257
|
+
if (!tableStatus.ok) {
|
|
258
|
+
return {
|
|
259
|
+
ok: false,
|
|
260
|
+
source: 'ctm-db',
|
|
261
|
+
db_path: handle.dbPath,
|
|
262
|
+
sessions: [],
|
|
263
|
+
messages: [],
|
|
264
|
+
...tableStatus,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const bundles = identifiers.map((id) => resolveSessionBundle(conn, id)).filter(Boolean);
|
|
269
|
+
const seenBundles = new Set();
|
|
270
|
+
const uniqueBundles = bundles.filter((bundle) => {
|
|
271
|
+
const key = bundle.ctm_session_id || bundle.requested_id;
|
|
272
|
+
if (seenBundles.has(key)) return false;
|
|
273
|
+
seenBundles.add(key);
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const sessions = [];
|
|
278
|
+
const allMessages = [];
|
|
279
|
+
for (const bundle of uniqueBundles) {
|
|
280
|
+
const conversations = getConversationRows(conn, bundle);
|
|
281
|
+
const sessionMessages = [];
|
|
282
|
+
for (const row of conversations) {
|
|
283
|
+
const messages = parseConversationMessages(row);
|
|
284
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
285
|
+
const normalized = normalizeMessage(messages[i], {
|
|
286
|
+
row,
|
|
287
|
+
bundle,
|
|
288
|
+
index: i,
|
|
289
|
+
includeRaw: include_raw,
|
|
290
|
+
});
|
|
291
|
+
if (normalized.content || include_raw) sessionMessages.push(normalized);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const dedupedSessionMessages = dedupe ? dedupeMessages(sessionMessages) : sessionMessages;
|
|
296
|
+
sessions.push(formatSessionSummary(bundle, conversations, dedupedSessionMessages));
|
|
297
|
+
allMessages.push(...dedupedSessionMessages);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
const orderedMessages = orderMessages(dedupe ? dedupeMessages(allMessages) : allMessages);
|
|
301
|
+
const start = Math.max(0, Number.parseInt(cursor, 10) || 0);
|
|
302
|
+
const cap = normalizeContextLimit(limit);
|
|
303
|
+
const pagedMessages = cap === 0 ? orderedMessages.slice(start) : orderedMessages.slice(start, start + cap);
|
|
304
|
+
const nextCursor = cap !== 0 && start + cap < orderedMessages.length ? start + cap : null;
|
|
305
|
+
const result = {
|
|
306
|
+
ok: true,
|
|
307
|
+
source: 'ctm-db',
|
|
308
|
+
db_path: handle.dbPath,
|
|
309
|
+
active_source: handle.activeSource || 'ctm-db',
|
|
310
|
+
requested_ids: identifiers,
|
|
311
|
+
sessions,
|
|
312
|
+
count: pagedMessages.length,
|
|
313
|
+
total_count: orderedMessages.length,
|
|
314
|
+
cursor: start,
|
|
315
|
+
next_cursor: nextCursor,
|
|
316
|
+
messages: pagedMessages,
|
|
317
|
+
transfer: {
|
|
318
|
+
full_context_supported: true,
|
|
319
|
+
served_from: 'session_conversations/session_messages',
|
|
320
|
+
jsonl_fallback_used: false,
|
|
321
|
+
deduped: Boolean(dedupe),
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
if (format === 'markdown' || format === 'compact') {
|
|
326
|
+
result.text = renderContextMarkdown(result, { compact: format === 'compact' });
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function buildContextPack({
|
|
333
|
+
task,
|
|
334
|
+
query,
|
|
335
|
+
session_ids,
|
|
336
|
+
ids,
|
|
337
|
+
limit = 5,
|
|
338
|
+
token_budget = DEFAULT_TOKEN_BUDGET,
|
|
339
|
+
include_raw = false,
|
|
340
|
+
mode = 'auto',
|
|
341
|
+
db,
|
|
342
|
+
dbPath,
|
|
343
|
+
env,
|
|
344
|
+
} = {}) {
|
|
345
|
+
const identifiers = normalizeIdentifiers(session_ids || ids);
|
|
346
|
+
const effectiveQuery = String(query || task || '').trim();
|
|
347
|
+
const maxSessions = clampLimit(limit, 5, MAX_SEARCH_LIMIT);
|
|
348
|
+
const budgetChars = Math.max(1000, Math.min(Number(token_budget || DEFAULT_TOKEN_BUDGET), 200000) * 4);
|
|
349
|
+
|
|
350
|
+
return withCtmDb({ db, dbPath, env }, (conn, handle) => {
|
|
351
|
+
const selectedIds = [];
|
|
352
|
+
const search = effectiveQuery
|
|
353
|
+
? searchCtmSessions({ query: effectiveQuery, limit: maxSessions, db: conn, dbPath: handle.dbPath })
|
|
354
|
+
: { ok: true, results: [] };
|
|
355
|
+
|
|
356
|
+
for (const id of identifiers) selectedIds.push(id);
|
|
357
|
+
for (const item of search.results || []) {
|
|
358
|
+
if (item.session_id) selectedIds.push(item.session_id);
|
|
359
|
+
if (item.agent_session_id) selectedIds.push(item.agent_session_id);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const uniqueIds = [...new Set(selectedIds)].slice(0, maxSessions);
|
|
363
|
+
const context = getCtmSessionContext({
|
|
364
|
+
session_ids: uniqueIds,
|
|
365
|
+
limit: mode === 'full' ? 0 : 1000,
|
|
366
|
+
include_raw,
|
|
367
|
+
dedupe: true,
|
|
368
|
+
db: conn,
|
|
369
|
+
dbPath: handle.dbPath,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const pack = trimContextToBudget(context, budgetChars);
|
|
373
|
+
return {
|
|
374
|
+
ok: true,
|
|
375
|
+
source: 'ctm-db',
|
|
376
|
+
db_path: handle.dbPath,
|
|
377
|
+
active_source: handle.activeSource || 'ctm-db',
|
|
378
|
+
task: task || '',
|
|
379
|
+
query: effectiveQuery,
|
|
380
|
+
mode,
|
|
381
|
+
search_results: search.results || [],
|
|
382
|
+
selected_session_ids: uniqueIds,
|
|
383
|
+
sessions: pack.sessions,
|
|
384
|
+
messages: pack.messages,
|
|
385
|
+
text: renderContextMarkdown(pack, { compact: mode !== 'full' }),
|
|
386
|
+
transfer: {
|
|
387
|
+
full_context_supported: true,
|
|
388
|
+
served_from: 'session_conversations/session_messages',
|
|
389
|
+
jsonl_fallback_used: false,
|
|
390
|
+
deduped: true,
|
|
391
|
+
token_budget: Number(token_budget || DEFAULT_TOKEN_BUDGET),
|
|
392
|
+
truncated: pack.truncated,
|
|
393
|
+
},
|
|
394
|
+
};
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function hasAnySessionTable(db) {
|
|
399
|
+
return inspectSessionTables(db).ok;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function inspectSessionTables(db) {
|
|
403
|
+
try {
|
|
404
|
+
const placeholders = CTM_SESSION_TABLES.map(() => '?').join(', ');
|
|
405
|
+
const rows = db.prepare(
|
|
406
|
+
`SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name IN (${placeholders})`
|
|
407
|
+
).all(...CTM_SESSION_TABLES);
|
|
408
|
+
const tables = rows.map((row) => row.name).filter(Boolean).sort();
|
|
409
|
+
const missingTables = CTM_SESSION_TABLES.filter((table) => !tables.includes(table));
|
|
410
|
+
const tableCounts = Object.fromEntries(
|
|
411
|
+
CTM_SESSION_TABLES
|
|
412
|
+
.filter((table) => tables.includes(table))
|
|
413
|
+
.map((table) => [table, tableRowCount(db, table)])
|
|
414
|
+
);
|
|
415
|
+
if (tables.length === 0) {
|
|
416
|
+
return {
|
|
417
|
+
ok: false,
|
|
418
|
+
reason: 'ctm_session_tables_missing',
|
|
419
|
+
tables,
|
|
420
|
+
missing_tables: missingTables,
|
|
421
|
+
table_counts: tableCounts,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
return { ok: true, reason: '', tables, missing_tables: missingTables, table_counts: tableCounts };
|
|
425
|
+
} catch (err) {
|
|
426
|
+
const classified = classifySqliteFailure(err, 'ctm_db_schema_read_failed');
|
|
427
|
+
return {
|
|
428
|
+
ok: false,
|
|
429
|
+
reason: classified.reason,
|
|
430
|
+
error: err?.message || String(err),
|
|
431
|
+
tables: [],
|
|
432
|
+
missing_tables: [...CTM_SESSION_TABLES],
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function getCtmDbHealth(options = {}) {
|
|
438
|
+
return withCtmDb(options, (conn, handle) => {
|
|
439
|
+
const tableStatus = inspectSessionTables(conn);
|
|
440
|
+
return {
|
|
441
|
+
ok: tableStatus.ok,
|
|
442
|
+
source: 'ctm-db',
|
|
443
|
+
unavailable: false,
|
|
444
|
+
db_path: handle.dbPath,
|
|
445
|
+
...tableStatus,
|
|
446
|
+
};
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function hasTable(db, name) {
|
|
451
|
+
try {
|
|
452
|
+
return Boolean(db.prepare("SELECT name FROM sqlite_master WHERE type IN ('table', 'view') AND name = ?").get(name));
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function tableColumns(db, table) {
|
|
459
|
+
try {
|
|
460
|
+
return db.prepare(`PRAGMA table_info(${quoteIdent(table)})`).all().map((row) => row.name);
|
|
461
|
+
} catch {
|
|
462
|
+
return [];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function idColumnFor(db, table) {
|
|
467
|
+
const cols = tableColumns(db, table);
|
|
468
|
+
if (cols.includes('ctm_session_id')) return 'ctm_session_id';
|
|
469
|
+
if (cols.includes('session_id')) return 'session_id';
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function quoteIdent(value) {
|
|
474
|
+
return `"${String(value).replace(/"/g, '""')}"`;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function searchSessionMessagesFts(db, query, limit) {
|
|
478
|
+
if (!hasTable(db, 'session_messages') || !hasTable(db, 'session_messages_fts')) return [];
|
|
479
|
+
const msgSessionCol = idColumnFor(db, 'session_messages');
|
|
480
|
+
if (!msgSessionCol) return [];
|
|
481
|
+
const ftsQueries = buildFtsQueries(query);
|
|
482
|
+
if (!ftsQueries.length) return [];
|
|
483
|
+
|
|
484
|
+
try {
|
|
485
|
+
const stmt = db.prepare(`
|
|
486
|
+
SELECT sm.${quoteIdent(msgSessionCol)} AS conversation_id,
|
|
487
|
+
sm.id AS message_rowid,
|
|
488
|
+
sm.role,
|
|
489
|
+
sm.message_index,
|
|
490
|
+
sm.content,
|
|
491
|
+
sm.created_at,
|
|
492
|
+
fts.rank AS rank
|
|
493
|
+
FROM session_messages_fts fts
|
|
494
|
+
JOIN session_messages sm ON sm.id = fts.rowid
|
|
495
|
+
WHERE session_messages_fts MATCH ?
|
|
496
|
+
ORDER BY fts.rank
|
|
497
|
+
LIMIT ?
|
|
498
|
+
`);
|
|
499
|
+
let rows = [];
|
|
500
|
+
for (const ftsQuery of ftsQueries) {
|
|
501
|
+
rows = stmt.all(ftsQuery, limit);
|
|
502
|
+
if (rows.length) break;
|
|
503
|
+
}
|
|
504
|
+
return rows.map((row, index) => enrichSearchRow(db, row, {
|
|
505
|
+
source_table: 'session_messages_fts',
|
|
506
|
+
rank: index,
|
|
507
|
+
score: row.rank,
|
|
508
|
+
}));
|
|
509
|
+
} catch {
|
|
510
|
+
return [];
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function searchSessionMessagesLike(db, query, limit) {
|
|
515
|
+
if (!hasTable(db, 'session_messages')) return [];
|
|
516
|
+
const msgSessionCol = idColumnFor(db, 'session_messages');
|
|
517
|
+
if (!msgSessionCol) return [];
|
|
518
|
+
const like = `%${escapeLike(query)}%`;
|
|
519
|
+
try {
|
|
520
|
+
const rows = db.prepare(`
|
|
521
|
+
SELECT ${quoteIdent(msgSessionCol)} AS conversation_id,
|
|
522
|
+
id AS message_rowid,
|
|
523
|
+
role,
|
|
524
|
+
message_index,
|
|
525
|
+
content,
|
|
526
|
+
created_at
|
|
527
|
+
FROM session_messages
|
|
528
|
+
WHERE content LIKE ? ESCAPE '\\'
|
|
529
|
+
ORDER BY created_at DESC, id DESC
|
|
530
|
+
LIMIT ?
|
|
531
|
+
`).all(like, limit);
|
|
532
|
+
return rows.map((row, index) => enrichSearchRow(db, row, {
|
|
533
|
+
source_table: 'session_messages',
|
|
534
|
+
rank: index + 1000,
|
|
535
|
+
}));
|
|
536
|
+
} catch {
|
|
537
|
+
return [];
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function searchSessionConversationMessages(db, query, limit) {
|
|
542
|
+
if (!hasTable(db, 'session_conversations')) return [];
|
|
543
|
+
const convIdCol = idColumnFor(db, 'session_conversations');
|
|
544
|
+
if (!convIdCol) return [];
|
|
545
|
+
const cols = tableColumns(db, 'session_conversations');
|
|
546
|
+
if (!cols.includes('messages')) return [];
|
|
547
|
+
const terms = searchTerms(query);
|
|
548
|
+
const predicates = terms.length
|
|
549
|
+
? terms.slice(0, 6).map(() => `messages LIKE ? ESCAPE '\\'`)
|
|
550
|
+
: [`messages LIKE ? ESCAPE '\\'`];
|
|
551
|
+
const params = terms.length
|
|
552
|
+
? terms.slice(0, 6).map((term) => `%${escapeLike(term)}%`)
|
|
553
|
+
: [`%${escapeLike(query)}%`];
|
|
554
|
+
const selectCols = [
|
|
555
|
+
`${quoteIdent(convIdCol)} AS _conversation_id`,
|
|
556
|
+
'messages',
|
|
557
|
+
cols.includes('project_path') ? 'project_path' : "'' AS project_path",
|
|
558
|
+
cols.includes('title') ? 'title' : "'' AS title",
|
|
559
|
+
cols.includes('first_message') ? 'first_message' : "'' AS first_message",
|
|
560
|
+
cols.includes('git_branch') ? 'git_branch' : "'' AS git_branch",
|
|
561
|
+
cols.includes('session_created_at') ? 'session_created_at' : "'' AS session_created_at",
|
|
562
|
+
cols.includes('imported_at') ? 'imported_at' : "'' AS imported_at",
|
|
563
|
+
cols.includes('model_provider') ? 'model_provider' : "'' AS model_provider",
|
|
564
|
+
cols.includes('model_id') ? 'model_id' : "'' AS model_id",
|
|
565
|
+
];
|
|
566
|
+
try {
|
|
567
|
+
const rows = db.prepare(`
|
|
568
|
+
SELECT ${selectCols.join(', ')}
|
|
569
|
+
FROM session_conversations
|
|
570
|
+
WHERE ${predicates.join(' OR ')}
|
|
571
|
+
ORDER BY imported_at DESC
|
|
572
|
+
LIMIT ?
|
|
573
|
+
`).all(...params, Math.max(limit, 20));
|
|
574
|
+
const results = [];
|
|
575
|
+
for (const row of rows) {
|
|
576
|
+
const conversationId = row._conversation_id;
|
|
577
|
+
const bundle = resolveSessionBundle(db, conversationId) || {
|
|
578
|
+
ctm_session_id: resolveConversationOwner(db, conversationId).ctm_session_id || conversationId,
|
|
579
|
+
agent_session_ids: [],
|
|
580
|
+
provider: row.model_provider || '',
|
|
581
|
+
cwd: row.project_path || '',
|
|
582
|
+
project_path: row.project_path || '',
|
|
583
|
+
title: row.title || row.first_message || '',
|
|
584
|
+
};
|
|
585
|
+
const messages = parseConversationMessages(row);
|
|
586
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
587
|
+
const normalized = normalizeMessage(messages[i], {
|
|
588
|
+
row,
|
|
589
|
+
bundle,
|
|
590
|
+
index: i,
|
|
591
|
+
includeRaw: false,
|
|
592
|
+
});
|
|
593
|
+
if (!normalized.content || !matchesQueryText(normalized.content, query, terms)) continue;
|
|
594
|
+
results.push({
|
|
595
|
+
...normalized,
|
|
596
|
+
source_table: 'session_conversations.messages',
|
|
597
|
+
snippet: snippetAround(normalized.content, 800),
|
|
598
|
+
rank: 500 + results.length,
|
|
599
|
+
});
|
|
600
|
+
if (results.length >= limit) return results;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return results;
|
|
604
|
+
} catch {
|
|
605
|
+
return [];
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function searchSessionMetadata(db, query, limit) {
|
|
610
|
+
const results = [];
|
|
611
|
+
const like = `%${escapeLike(query)}%`;
|
|
612
|
+
if (hasTable(db, 'session_conversations')) {
|
|
613
|
+
const convIdCol = idColumnFor(db, 'session_conversations');
|
|
614
|
+
if (convIdCol) {
|
|
615
|
+
const cols = tableColumns(db, 'session_conversations');
|
|
616
|
+
const selectCols = [
|
|
617
|
+
`${quoteIdent(convIdCol)} AS conversation_id`,
|
|
618
|
+
cols.includes('project_path') ? 'project_path' : "'' AS project_path",
|
|
619
|
+
cols.includes('title') ? 'title' : "'' AS title",
|
|
620
|
+
cols.includes('first_message') ? 'first_message' : "'' AS first_message",
|
|
621
|
+
cols.includes('git_branch') ? 'git_branch' : "'' AS git_branch",
|
|
622
|
+
cols.includes('imported_at') ? 'imported_at' : "'' AS imported_at",
|
|
623
|
+
];
|
|
624
|
+
const predicates = ['1=0'];
|
|
625
|
+
const params = [];
|
|
626
|
+
for (const col of ['title', 'first_message', 'project_path']) {
|
|
627
|
+
if (cols.includes(col)) {
|
|
628
|
+
predicates.push(`${quoteIdent(col)} LIKE ? ESCAPE '\\'`);
|
|
629
|
+
params.push(like);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
try {
|
|
633
|
+
const rows = db.prepare(`
|
|
634
|
+
SELECT ${selectCols.join(', ')}
|
|
635
|
+
FROM session_conversations
|
|
636
|
+
WHERE ${predicates.join(' OR ')}
|
|
637
|
+
ORDER BY imported_at DESC
|
|
638
|
+
LIMIT ?
|
|
639
|
+
`).all(...params, limit);
|
|
640
|
+
results.push(...rows.map((row, index) => enrichMetadataRow(db, row, index)));
|
|
641
|
+
} catch {}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (hasTable(db, 'ctm_sessions')) {
|
|
646
|
+
try {
|
|
647
|
+
const rows = db.prepare(`
|
|
648
|
+
SELECT id AS ctm_session_id, provider, project_path, cwd, title, updated_at
|
|
649
|
+
FROM ctm_sessions
|
|
650
|
+
WHERE id = ? OR title LIKE ? ESCAPE '\\' OR project_path LIKE ? ESCAPE '\\' OR cwd LIKE ? ESCAPE '\\'
|
|
651
|
+
ORDER BY updated_at DESC
|
|
652
|
+
LIMIT ?
|
|
653
|
+
`).all(query, like, like, like, limit);
|
|
654
|
+
results.push(...rows.map((row, index) => ({
|
|
655
|
+
source: 'ctm-db',
|
|
656
|
+
source_table: 'ctm_sessions',
|
|
657
|
+
session_id: row.ctm_session_id,
|
|
658
|
+
ctm_session_id: row.ctm_session_id,
|
|
659
|
+
provider: row.provider || '',
|
|
660
|
+
cwd: row.cwd || row.project_path || '',
|
|
661
|
+
title: row.title || '',
|
|
662
|
+
timestamp: row.updated_at || '',
|
|
663
|
+
snippet: row.title || row.cwd || row.project_path || row.ctm_session_id,
|
|
664
|
+
rank: index + 2000,
|
|
665
|
+
})));
|
|
666
|
+
} catch {}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
if (hasTable(db, 'agent_sessions')) {
|
|
670
|
+
try {
|
|
671
|
+
const rows = db.prepare(`
|
|
672
|
+
SELECT agent_session_id, ctm_session_id, provider, project_path, first_message, modified_at, model, git_branch
|
|
673
|
+
FROM agent_sessions
|
|
674
|
+
WHERE agent_session_id = ? OR ctm_session_id = ? OR first_message LIKE ? ESCAPE '\\' OR project_path LIKE ? ESCAPE '\\'
|
|
675
|
+
ORDER BY modified_at DESC, updated_at DESC
|
|
676
|
+
LIMIT ?
|
|
677
|
+
`).all(query, query, like, like, limit);
|
|
678
|
+
results.push(...rows.map((row, index) => ({
|
|
679
|
+
source: 'ctm-db',
|
|
680
|
+
source_table: 'agent_sessions',
|
|
681
|
+
session_id: row.ctm_session_id || row.agent_session_id,
|
|
682
|
+
ctm_session_id: row.ctm_session_id || '',
|
|
683
|
+
agent_session_id: row.agent_session_id || '',
|
|
684
|
+
provider: row.provider || '',
|
|
685
|
+
cwd: row.project_path || '',
|
|
686
|
+
branch: row.git_branch || '',
|
|
687
|
+
model: row.model || '',
|
|
688
|
+
timestamp: row.modified_at || '',
|
|
689
|
+
snippet: row.first_message || row.project_path || row.agent_session_id,
|
|
690
|
+
rank: index + 3000,
|
|
691
|
+
})));
|
|
692
|
+
} catch {}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
return results;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function searchTerms(query) {
|
|
699
|
+
return String(query || '')
|
|
700
|
+
.match(/[A-Za-z0-9_./:-]+/g)
|
|
701
|
+
?.map((term) => term.replace(/^[-:.\\/]+|[-:.\\/]+$/g, '').toLowerCase())
|
|
702
|
+
.filter((term) => term.length > 1)
|
|
703
|
+
.slice(0, 12) || [];
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function matchesQueryText(content, query, terms = searchTerms(query)) {
|
|
707
|
+
const normalized = normalizeContent(content);
|
|
708
|
+
const phrase = normalizeContent(query);
|
|
709
|
+
if (phrase && normalized.includes(phrase)) return true;
|
|
710
|
+
if (!terms.length) return false;
|
|
711
|
+
return terms.every((term) => normalized.includes(term.toLowerCase()));
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function enrichSearchRow(db, row, extra = {}) {
|
|
715
|
+
const resolved = resolveConversationOwner(db, row.conversation_id);
|
|
716
|
+
return {
|
|
717
|
+
source: 'ctm-db',
|
|
718
|
+
source_table: extra.source_table,
|
|
719
|
+
session_id: resolved.ctm_session_id || row.conversation_id,
|
|
720
|
+
ctm_session_id: resolved.ctm_session_id || '',
|
|
721
|
+
agent_session_id: resolved.agent_session_id || '',
|
|
722
|
+
conversation_id: row.conversation_id,
|
|
723
|
+
role: row.role || '',
|
|
724
|
+
message_index: row.message_index,
|
|
725
|
+
timestamp: row.created_at || resolved.modified_at || resolved.updated_at || '',
|
|
726
|
+
cwd: resolved.cwd || resolved.project_path || '',
|
|
727
|
+
branch: resolved.git_branch || '',
|
|
728
|
+
title: resolved.title || resolved.first_message || '',
|
|
729
|
+
provider: resolved.provider || '',
|
|
730
|
+
model: resolved.model || '',
|
|
731
|
+
snippet: snippetAround(row.content || '', 800),
|
|
732
|
+
content_hash: contentFingerprint(row.content || ''),
|
|
733
|
+
rank: extra.rank || 0,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function enrichMetadataRow(db, row, rank) {
|
|
738
|
+
const resolved = resolveConversationOwner(db, row.conversation_id);
|
|
739
|
+
return {
|
|
740
|
+
source: 'ctm-db',
|
|
741
|
+
source_table: 'session_conversations',
|
|
742
|
+
session_id: resolved.ctm_session_id || row.conversation_id,
|
|
743
|
+
ctm_session_id: resolved.ctm_session_id || '',
|
|
744
|
+
agent_session_id: resolved.agent_session_id || '',
|
|
745
|
+
conversation_id: row.conversation_id,
|
|
746
|
+
timestamp: row.imported_at || resolved.modified_at || '',
|
|
747
|
+
cwd: resolved.cwd || row.project_path || '',
|
|
748
|
+
branch: resolved.git_branch || row.git_branch || '',
|
|
749
|
+
title: resolved.title || row.title || row.first_message || '',
|
|
750
|
+
provider: resolved.provider || '',
|
|
751
|
+
model: resolved.model || '',
|
|
752
|
+
snippet: row.title || row.first_message || row.project_path || row.conversation_id,
|
|
753
|
+
content_hash: contentFingerprint([row.title, row.first_message, row.project_path].filter(Boolean).join('\n')),
|
|
754
|
+
rank: rank + 1500,
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function resolveConversationOwner(db, conversationId) {
|
|
759
|
+
const id = String(conversationId || '');
|
|
760
|
+
const empty = { ctm_session_id: id, agent_session_id: '', provider: '', project_path: '', cwd: '', title: '' };
|
|
761
|
+
if (!id) return empty;
|
|
762
|
+
|
|
763
|
+
let agent = null;
|
|
764
|
+
if (hasTable(db, 'agent_sessions')) {
|
|
765
|
+
try {
|
|
766
|
+
agent = db.prepare('SELECT * FROM agent_sessions WHERE agent_session_id = ?').get(id) || null;
|
|
767
|
+
} catch {}
|
|
768
|
+
}
|
|
769
|
+
const ctmId = agent?.ctm_session_id || id;
|
|
770
|
+
let ctm = null;
|
|
771
|
+
if (hasTable(db, 'ctm_sessions')) {
|
|
772
|
+
try {
|
|
773
|
+
ctm = db.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(ctmId) || null;
|
|
774
|
+
} catch {}
|
|
775
|
+
}
|
|
776
|
+
if (!agent && hasTable(db, 'agent_sessions')) {
|
|
777
|
+
try {
|
|
778
|
+
agent = db.prepare('SELECT * FROM agent_sessions WHERE ctm_session_id = ? ORDER BY modified_at DESC, created_at DESC LIMIT 1').get(ctmId) || null;
|
|
779
|
+
} catch {}
|
|
780
|
+
}
|
|
781
|
+
return {
|
|
782
|
+
ctm_session_id: ctm?.id || agent?.ctm_session_id || ctmId,
|
|
783
|
+
agent_session_id: agent?.agent_session_id || (agent?.ctm_session_id ? id : ''),
|
|
784
|
+
provider: ctm?.provider || agent?.provider || '',
|
|
785
|
+
project_path: ctm?.project_path || agent?.project_path || '',
|
|
786
|
+
cwd: ctm?.cwd || ctm?.project_path || agent?.project_path || '',
|
|
787
|
+
title: ctm?.title || agent?.first_message || '',
|
|
788
|
+
first_message: agent?.first_message || '',
|
|
789
|
+
git_branch: agent?.git_branch || '',
|
|
790
|
+
model: agent?.model || '',
|
|
791
|
+
modified_at: agent?.modified_at || '',
|
|
792
|
+
updated_at: ctm?.updated_at || '',
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function resolveSessionBundle(db, rawId) {
|
|
797
|
+
const candidates = candidateIds(rawId);
|
|
798
|
+
for (const candidate of candidates) {
|
|
799
|
+
const bundle = resolveSessionBundleExact(db, candidate, rawId);
|
|
800
|
+
if (bundle) return bundle;
|
|
801
|
+
}
|
|
802
|
+
return null;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function resolveSessionBundleExact(db, id, requestedId = id) {
|
|
806
|
+
const requested = String(requestedId || id || '').trim();
|
|
807
|
+
const cleanId = String(id || '').trim();
|
|
808
|
+
if (!cleanId) return null;
|
|
809
|
+
|
|
810
|
+
let ctm = null;
|
|
811
|
+
let agent = null;
|
|
812
|
+
if (hasTable(db, 'ctm_sessions')) {
|
|
813
|
+
try { ctm = db.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(cleanId) || null; } catch {}
|
|
814
|
+
}
|
|
815
|
+
if (hasTable(db, 'agent_sessions')) {
|
|
816
|
+
try { agent = db.prepare('SELECT * FROM agent_sessions WHERE agent_session_id = ?').get(cleanId) || null; } catch {}
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const ctmId = ctm?.id || agent?.ctm_session_id || cleanId;
|
|
820
|
+
if (!ctm && hasTable(db, 'ctm_sessions')) {
|
|
821
|
+
try { ctm = db.prepare('SELECT * FROM ctm_sessions WHERE id = ?').get(ctmId) || null; } catch {}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
let agents = [];
|
|
825
|
+
if (hasTable(db, 'agent_sessions')) {
|
|
826
|
+
try {
|
|
827
|
+
agents = db.prepare('SELECT * FROM agent_sessions WHERE ctm_session_id = ? ORDER BY modified_at DESC, created_at DESC').all(ctmId);
|
|
828
|
+
} catch {}
|
|
829
|
+
}
|
|
830
|
+
if (agent && !agents.some((row) => row.agent_session_id === agent.agent_session_id)) agents.unshift(agent);
|
|
831
|
+
|
|
832
|
+
const conversationIds = new Set();
|
|
833
|
+
for (const idCandidate of [cleanId, ctmId, ...agents.map((row) => row.agent_session_id)]) {
|
|
834
|
+
if (idCandidate && hasConversation(db, idCandidate)) conversationIds.add(idCandidate);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (!ctm && !agent && conversationIds.size === 0) return null;
|
|
838
|
+
return {
|
|
839
|
+
requested_id: requested,
|
|
840
|
+
ctm_session_id: ctmId,
|
|
841
|
+
agent_session_ids: agents.map((row) => row.agent_session_id).filter(Boolean),
|
|
842
|
+
conversation_ids: [...conversationIds],
|
|
843
|
+
provider: ctm?.provider || agent?.provider || '',
|
|
844
|
+
project_path: ctm?.project_path || agent?.project_path || '',
|
|
845
|
+
cwd: ctm?.cwd || ctm?.project_path || agent?.project_path || '',
|
|
846
|
+
title: ctm?.title || agent?.first_message || '',
|
|
847
|
+
starred: Boolean(ctm?.starred),
|
|
848
|
+
user_renamed: Boolean(ctm?.user_renamed),
|
|
849
|
+
created_at: ctm?.created_at || agent?.created_at || '',
|
|
850
|
+
updated_at: ctm?.updated_at || agent?.updated_at || agent?.modified_at || '',
|
|
851
|
+
agents,
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function hasConversation(db, sessionId) {
|
|
856
|
+
if (!hasTable(db, 'session_conversations')) return false;
|
|
857
|
+
const idCol = idColumnFor(db, 'session_conversations');
|
|
858
|
+
if (!idCol) return false;
|
|
859
|
+
try {
|
|
860
|
+
return Boolean(db.prepare(`SELECT 1 FROM session_conversations WHERE ${quoteIdent(idCol)} = ? LIMIT 1`).get(sessionId));
|
|
861
|
+
} catch {
|
|
862
|
+
return false;
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
function getConversationRows(db, bundle) {
|
|
867
|
+
if (!hasTable(db, 'session_conversations')) return [];
|
|
868
|
+
const idCol = idColumnFor(db, 'session_conversations');
|
|
869
|
+
if (!idCol) return [];
|
|
870
|
+
const ids = bundle.conversation_ids && bundle.conversation_ids.length
|
|
871
|
+
? bundle.conversation_ids
|
|
872
|
+
: [bundle.ctm_session_id, ...bundle.agent_session_ids].filter(Boolean);
|
|
873
|
+
const rows = [];
|
|
874
|
+
const seen = new Set();
|
|
875
|
+
for (const id of ids) {
|
|
876
|
+
if (seen.has(id)) continue;
|
|
877
|
+
seen.add(id);
|
|
878
|
+
try {
|
|
879
|
+
const row = db.prepare(`SELECT *, ${quoteIdent(idCol)} AS _conversation_id FROM session_conversations WHERE ${quoteIdent(idCol)} = ?`).get(id);
|
|
880
|
+
if (row) rows.push(row);
|
|
881
|
+
} catch {}
|
|
882
|
+
}
|
|
883
|
+
return rows.sort((a, b) => compareTimestamp(a.session_created_at || a.imported_at, b.session_created_at || b.imported_at));
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
function parseConversationMessages(row) {
|
|
887
|
+
try {
|
|
888
|
+
const parsed = JSON.parse(row?.messages || '[]');
|
|
889
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
890
|
+
} catch {
|
|
891
|
+
return [];
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
function normalizeMessage(raw, { row, bundle, index, includeRaw }) {
|
|
896
|
+
const role = normalizeRole(raw);
|
|
897
|
+
const content = messageText(raw);
|
|
898
|
+
const conversationId = row?._conversation_id || row?.ctm_session_id || row?.session_id || bundle.ctm_session_id;
|
|
899
|
+
return {
|
|
900
|
+
source: 'ctm-db',
|
|
901
|
+
source_table: 'session_conversations',
|
|
902
|
+
session_id: bundle.ctm_session_id,
|
|
903
|
+
ctm_session_id: bundle.ctm_session_id,
|
|
904
|
+
agent_session_id: conversationId !== bundle.ctm_session_id ? conversationId : '',
|
|
905
|
+
conversation_id: conversationId,
|
|
906
|
+
message_index: Number.isInteger(raw?.message_index) ? raw.message_index : index,
|
|
907
|
+
role,
|
|
908
|
+
timestamp: raw?.timestamp || raw?.created_at || row?.session_created_at || row?.imported_at || '',
|
|
909
|
+
content,
|
|
910
|
+
content_hash: contentFingerprint(`${role}\n${content}`),
|
|
911
|
+
metadata: compactObject({
|
|
912
|
+
provider: bundle.provider || row?.model_provider || '',
|
|
913
|
+
model: row?.model_id || '',
|
|
914
|
+
title: bundle.title || row?.title || '',
|
|
915
|
+
cwd: bundle.cwd || row?.project_path || '',
|
|
916
|
+
branch: row?.git_branch || '',
|
|
917
|
+
tool_calls: raw?.tool_calls || raw?.toolCalls,
|
|
918
|
+
files: raw?.files || raw?.filesEdited,
|
|
919
|
+
}),
|
|
920
|
+
raw: includeRaw ? raw : undefined,
|
|
921
|
+
};
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function normalizeRole(raw) {
|
|
925
|
+
const role = raw?.role || raw?.speaker || raw?.type || '';
|
|
926
|
+
if (role === 'human') return 'user';
|
|
927
|
+
if (role === 'ai') return 'assistant';
|
|
928
|
+
if (role === 'tool_result') return 'tool';
|
|
929
|
+
return String(role || 'unknown').toLowerCase();
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function messageText(raw) {
|
|
933
|
+
if (!raw) return '';
|
|
934
|
+
if (typeof raw.content === 'string') return raw.content.trim();
|
|
935
|
+
if (Array.isArray(raw.content)) {
|
|
936
|
+
return raw.content.map((part) => {
|
|
937
|
+
if (!part) return '';
|
|
938
|
+
if (typeof part === 'string') return part;
|
|
939
|
+
if (typeof part.text === 'string') return part.text;
|
|
940
|
+
if (typeof part.content === 'string') return part.content;
|
|
941
|
+
if (typeof part.input === 'object') return JSON.stringify(part.input);
|
|
942
|
+
return '';
|
|
943
|
+
}).filter(Boolean).join('\n').trim();
|
|
944
|
+
}
|
|
945
|
+
if (typeof raw.text === 'string') return raw.text.trim();
|
|
946
|
+
if (typeof raw.message === 'string') return raw.message.trim();
|
|
947
|
+
if (raw.message && typeof raw.message.content === 'string') return raw.message.content.trim();
|
|
948
|
+
return '';
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
function formatSessionSummary(bundle, conversations, messages) {
|
|
952
|
+
const firstConversation = conversations[0] || {};
|
|
953
|
+
return {
|
|
954
|
+
session_id: bundle.ctm_session_id,
|
|
955
|
+
ctm_session_id: bundle.ctm_session_id,
|
|
956
|
+
agent_session_ids: bundle.agent_session_ids || [],
|
|
957
|
+
conversation_ids: conversations.map((row) => row._conversation_id || row.ctm_session_id || row.session_id).filter(Boolean),
|
|
958
|
+
provider: bundle.provider || firstConversation.model_provider || '',
|
|
959
|
+
cwd: bundle.cwd || firstConversation.project_path || '',
|
|
960
|
+
branch: firstConversation.git_branch || bundle.agents?.find((a) => a.git_branch)?.git_branch || '',
|
|
961
|
+
title: bundle.title || firstConversation.title || firstConversation.first_message || '',
|
|
962
|
+
created_at: bundle.created_at || firstConversation.session_created_at || '',
|
|
963
|
+
updated_at: bundle.updated_at || firstConversation.imported_at || '',
|
|
964
|
+
message_count: messages.length,
|
|
965
|
+
user_msg_count: firstConversation.user_msg_count || messages.filter((msg) => msg.role === 'user').length,
|
|
966
|
+
assistant_msg_count: firstConversation.assistant_msg_count || messages.filter((msg) => msg.role === 'assistant').length,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function orderMessages(messages) {
|
|
971
|
+
return [...messages].sort((a, b) => {
|
|
972
|
+
const t = compareTimestamp(a.timestamp, b.timestamp);
|
|
973
|
+
if (t !== 0) return t;
|
|
974
|
+
return (a.message_index || 0) - (b.message_index || 0);
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function compareTimestamp(a, b) {
|
|
979
|
+
const at = Date.parse(a || '');
|
|
980
|
+
const bt = Date.parse(b || '');
|
|
981
|
+
if (Number.isFinite(at) && Number.isFinite(bt) && at !== bt) return at - bt;
|
|
982
|
+
if (Number.isFinite(at) && !Number.isFinite(bt)) return -1;
|
|
983
|
+
if (!Number.isFinite(at) && Number.isFinite(bt)) return 1;
|
|
984
|
+
return 0;
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
function dedupeMessages(messages) {
|
|
988
|
+
return dedupeItems(messages, {
|
|
989
|
+
contentFields: ['content'],
|
|
990
|
+
keyPrefix: (item) => item.role || '',
|
|
991
|
+
minContentLength: 12,
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
function dedupeItems(items, {
|
|
996
|
+
limit = 0,
|
|
997
|
+
contentFields = ['content'],
|
|
998
|
+
keyPrefix = () => '',
|
|
999
|
+
minContentLength = 0,
|
|
1000
|
+
} = {}) {
|
|
1001
|
+
const seen = new Map();
|
|
1002
|
+
for (const item of items || []) {
|
|
1003
|
+
const content = contentFields.map((field) => item?.[field]).find((value) => typeof value === 'string' && value.trim()) || '';
|
|
1004
|
+
const normalized = normalizeContent(content);
|
|
1005
|
+
const prefix = typeof keyPrefix === 'function' ? keyPrefix(item) : String(keyPrefix || '');
|
|
1006
|
+
const key = normalized.length >= minContentLength
|
|
1007
|
+
? `${prefix}:${contentFingerprint(normalized)}`
|
|
1008
|
+
: `${prefix}:${item.session_id || ''}:${item.conversation_id || ''}:${item.message_index ?? item.id ?? normalized}`;
|
|
1009
|
+
|
|
1010
|
+
if (!seen.has(key)) {
|
|
1011
|
+
seen.set(key, { ...item });
|
|
1012
|
+
continue;
|
|
1013
|
+
}
|
|
1014
|
+
const existing = seen.get(key);
|
|
1015
|
+
seen.set(key, mergeDuplicateItem(existing, item));
|
|
1016
|
+
}
|
|
1017
|
+
const deduped = [...seen.values()].sort((a, b) => (a.rank || 0) - (b.rank || 0));
|
|
1018
|
+
return limit ? deduped.slice(0, limit) : deduped;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
function mergeDuplicateItem(existing, next) {
|
|
1022
|
+
const merged = { ...existing };
|
|
1023
|
+
const provenance = [
|
|
1024
|
+
...(Array.isArray(existing.provenance) ? existing.provenance : [provenanceOf(existing)]),
|
|
1025
|
+
provenanceOf(next),
|
|
1026
|
+
].filter(Boolean);
|
|
1027
|
+
merged.provenance = dedupeProvenance(provenance);
|
|
1028
|
+
if (!merged.agent_session_id && next.agent_session_id) merged.agent_session_id = next.agent_session_id;
|
|
1029
|
+
if (!merged.ctm_session_id && next.ctm_session_id) merged.ctm_session_id = next.ctm_session_id;
|
|
1030
|
+
if (!merged.conversation_id && next.conversation_id) merged.conversation_id = next.conversation_id;
|
|
1031
|
+
if (!merged.timestamp && next.timestamp) merged.timestamp = next.timestamp;
|
|
1032
|
+
if ((String(next.snippet || '').length > String(merged.snippet || '').length) && String(next.snippet || '').length <= 1200) {
|
|
1033
|
+
merged.snippet = next.snippet;
|
|
1034
|
+
}
|
|
1035
|
+
return merged;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function provenanceOf(item) {
|
|
1039
|
+
if (!item) return null;
|
|
1040
|
+
return compactObject({
|
|
1041
|
+
source: item.source,
|
|
1042
|
+
source_table: item.source_table,
|
|
1043
|
+
session_id: item.session_id,
|
|
1044
|
+
ctm_session_id: item.ctm_session_id,
|
|
1045
|
+
agent_session_id: item.agent_session_id,
|
|
1046
|
+
conversation_id: item.conversation_id,
|
|
1047
|
+
message_index: item.message_index,
|
|
1048
|
+
id: item.id,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
function dedupeProvenance(items) {
|
|
1053
|
+
const seen = new Set();
|
|
1054
|
+
const out = [];
|
|
1055
|
+
for (const item of items) {
|
|
1056
|
+
const key = JSON.stringify(item);
|
|
1057
|
+
if (seen.has(key)) continue;
|
|
1058
|
+
seen.add(key);
|
|
1059
|
+
out.push(item);
|
|
1060
|
+
}
|
|
1061
|
+
return out;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
function normalizeContent(value) {
|
|
1065
|
+
return String(value || '')
|
|
1066
|
+
.replace(/\r\n/g, '\n')
|
|
1067
|
+
.replace(/\s+/g, ' ')
|
|
1068
|
+
.trim()
|
|
1069
|
+
.toLowerCase();
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
function contentFingerprint(value) {
|
|
1073
|
+
return crypto.createHash('sha256').update(String(value || '')).digest('hex').slice(0, 16);
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function buildFtsQueries(query) {
|
|
1077
|
+
const terms = String(query || '')
|
|
1078
|
+
.match(/[A-Za-z0-9_./:-]+/g)
|
|
1079
|
+
?.map((term) => term.replace(/^[-:.\\/]+|[-:.\\/]+$/g, ''))
|
|
1080
|
+
.filter((term) => term.length > 1)
|
|
1081
|
+
.slice(0, 12) || [];
|
|
1082
|
+
if (!terms.length) return [];
|
|
1083
|
+
const quoted = terms.map((term) => `"${term.replace(/"/g, '""')}"`);
|
|
1084
|
+
const andQuery = quoted.join(' AND ');
|
|
1085
|
+
const orQuery = quoted.join(' OR ');
|
|
1086
|
+
return andQuery === orQuery ? [andQuery] : [andQuery, orQuery];
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
function escapeLike(value) {
|
|
1090
|
+
return String(value || '').replace(/[\\%_]/g, (match) => `\\${match}`);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function snippetAround(content, max = 800) {
|
|
1094
|
+
const text = String(content || '').replace(/\s+/g, ' ').trim();
|
|
1095
|
+
if (text.length <= max) return text;
|
|
1096
|
+
return `${text.slice(0, Math.max(0, max - 1)).trim()}...`;
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
function normalizeIdentifiers(value) {
|
|
1100
|
+
if (Array.isArray(value)) return value.flatMap((item) => normalizeIdentifiers(item));
|
|
1101
|
+
if (value == null) return [];
|
|
1102
|
+
return String(value).split(',').map((part) => part.trim()).filter(Boolean);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function candidateIds(id) {
|
|
1106
|
+
const raw = String(id || '').trim();
|
|
1107
|
+
if (!raw) return [];
|
|
1108
|
+
const out = [raw];
|
|
1109
|
+
const parts = raw.split(':').filter(Boolean);
|
|
1110
|
+
if (parts.length > 1) {
|
|
1111
|
+
out.push(parts[parts.length - 1]);
|
|
1112
|
+
if (parts.length >= 4 && parts[0] === 'diary') out.push(parts.slice(2, -1).join(':'));
|
|
1113
|
+
if (parts.length >= 2) out.push(parts.slice(1).join(':'));
|
|
1114
|
+
}
|
|
1115
|
+
return [...new Set(out.filter(Boolean))];
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function clampLimit(value, fallback, max) {
|
|
1119
|
+
const n = Number(value || fallback);
|
|
1120
|
+
if (!Number.isFinite(n)) return fallback;
|
|
1121
|
+
return Math.min(Math.max(Math.trunc(n), 1), max);
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
function normalizeContextLimit(value) {
|
|
1125
|
+
if (value === 0 || value === '0' || value === 'all' || value === 'full') return 0;
|
|
1126
|
+
return clampLimit(value, DEFAULT_CONTEXT_LIMIT, MAX_CONTEXT_LIMIT);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function trimContextToBudget(context, budgetChars) {
|
|
1130
|
+
const sessions = context.sessions || [];
|
|
1131
|
+
const messages = [];
|
|
1132
|
+
let used = JSON.stringify(sessions).length;
|
|
1133
|
+
let truncated = false;
|
|
1134
|
+
for (const message of context.messages || []) {
|
|
1135
|
+
const size = String(message.content || '').length + 200;
|
|
1136
|
+
if (used + size > budgetChars) {
|
|
1137
|
+
truncated = true;
|
|
1138
|
+
break;
|
|
1139
|
+
}
|
|
1140
|
+
messages.push(message);
|
|
1141
|
+
used += size;
|
|
1142
|
+
}
|
|
1143
|
+
return {
|
|
1144
|
+
...context,
|
|
1145
|
+
sessions,
|
|
1146
|
+
messages,
|
|
1147
|
+
count: messages.length,
|
|
1148
|
+
truncated,
|
|
1149
|
+
};
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function renderContextMarkdown(context, { compact = false } = {}) {
|
|
1153
|
+
const lines = [];
|
|
1154
|
+
lines.push('# Wall-E Session Context');
|
|
1155
|
+
lines.push('');
|
|
1156
|
+
if (context.task) {
|
|
1157
|
+
lines.push(`Task: ${context.task}`);
|
|
1158
|
+
lines.push('');
|
|
1159
|
+
}
|
|
1160
|
+
if (context.sessions?.length) {
|
|
1161
|
+
lines.push('## Sessions');
|
|
1162
|
+
for (const session of context.sessions) {
|
|
1163
|
+
const title = session.title ? ` - ${session.title}` : '';
|
|
1164
|
+
const cwd = session.cwd ? ` (${session.cwd})` : '';
|
|
1165
|
+
lines.push(`- ${session.session_id}${title}${cwd}; messages=${session.message_count}`);
|
|
1166
|
+
}
|
|
1167
|
+
lines.push('');
|
|
1168
|
+
}
|
|
1169
|
+
lines.push('## Messages');
|
|
1170
|
+
const maxContent = compact ? 1200 : 8000;
|
|
1171
|
+
for (const msg of context.messages || []) {
|
|
1172
|
+
const label = [msg.role || 'unknown', msg.timestamp || '', msg.session_id || ''].filter(Boolean).join(' | ');
|
|
1173
|
+
lines.push(`### ${label}`);
|
|
1174
|
+
const content = String(msg.content || '');
|
|
1175
|
+
lines.push(content.length > maxContent ? `${content.slice(0, maxContent).trim()}...` : content);
|
|
1176
|
+
lines.push('');
|
|
1177
|
+
}
|
|
1178
|
+
if (context.transfer?.truncated || context.truncated) {
|
|
1179
|
+
lines.push('_Context pack truncated to fit the requested budget._');
|
|
1180
|
+
}
|
|
1181
|
+
return lines.join('\n').trim();
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
function ensureMessageIndexTables(db) {
|
|
1185
|
+
const created = [];
|
|
1186
|
+
if (!hasTable(db, 'session_messages')) {
|
|
1187
|
+
db.exec(`
|
|
1188
|
+
CREATE TABLE session_messages (
|
|
1189
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1190
|
+
ctm_session_id TEXT NOT NULL,
|
|
1191
|
+
message_index INTEGER NOT NULL,
|
|
1192
|
+
role TEXT NOT NULL,
|
|
1193
|
+
content TEXT NOT NULL,
|
|
1194
|
+
created_at TEXT,
|
|
1195
|
+
UNIQUE(ctm_session_id, message_index)
|
|
1196
|
+
)
|
|
1197
|
+
`);
|
|
1198
|
+
created.push('session_messages');
|
|
1199
|
+
}
|
|
1200
|
+
if (!hasTable(db, 'session_messages_fts')) {
|
|
1201
|
+
db.exec(`
|
|
1202
|
+
CREATE VIRTUAL TABLE session_messages_fts USING fts5(
|
|
1203
|
+
content,
|
|
1204
|
+
content='', contentless_delete=1, tokenize='porter unicode61'
|
|
1205
|
+
)
|
|
1206
|
+
`);
|
|
1207
|
+
created.push('session_messages_fts');
|
|
1208
|
+
}
|
|
1209
|
+
return created;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
function backfillCtmSessionMessages({ db, dbPath, limit = 100, dry_run = false, allow_write = false } = {}) {
|
|
1213
|
+
if (!dry_run && !allow_write) {
|
|
1214
|
+
return {
|
|
1215
|
+
ok: false,
|
|
1216
|
+
source: 'ctm-db',
|
|
1217
|
+
reason: 'ctm_db_write_requires_ctm_api',
|
|
1218
|
+
dry_run: false,
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
return withCtmDb({ db, dbPath, readonly: Boolean(dry_run), fallback: false }, (conn, handle) => {
|
|
1222
|
+
const tableStatus = inspectSessionTables(conn);
|
|
1223
|
+
if (!tableStatus.ok && tableStatus.reason !== 'ctm_session_tables_missing') {
|
|
1224
|
+
return {
|
|
1225
|
+
ok: false,
|
|
1226
|
+
source: 'ctm-db',
|
|
1227
|
+
db_path: handle.dbPath,
|
|
1228
|
+
active_source: handle.activeSource || 'ctm-db',
|
|
1229
|
+
...tableStatus,
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
if (!hasTable(conn, 'session_conversations')) {
|
|
1233
|
+
return {
|
|
1234
|
+
ok: false,
|
|
1235
|
+
source: 'ctm-db',
|
|
1236
|
+
db_path: handle.dbPath,
|
|
1237
|
+
active_source: handle.activeSource || 'ctm-db',
|
|
1238
|
+
reason: 'session_conversations_missing',
|
|
1239
|
+
};
|
|
1240
|
+
}
|
|
1241
|
+
if (!idColumnFor(conn, 'session_conversations')) {
|
|
1242
|
+
return {
|
|
1243
|
+
ok: false,
|
|
1244
|
+
source: 'ctm-db',
|
|
1245
|
+
db_path: handle.dbPath,
|
|
1246
|
+
active_source: handle.activeSource || 'ctm-db',
|
|
1247
|
+
reason: 'session_conversations_id_missing',
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
const created_tables = dry_run ? [] : ensureMessageIndexTables(conn);
|
|
1252
|
+
if (dry_run && (!hasTable(conn, 'session_messages') || !hasTable(conn, 'session_messages_fts'))) {
|
|
1253
|
+
return backfillPlan(conn, { limit, created_tables: ['session_messages', 'session_messages_fts'] });
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const rows = selectBackfillConversationRows(conn, { limit });
|
|
1257
|
+
const plan = backfillPlanFromRows(rows, { created_tables });
|
|
1258
|
+
if (dry_run) return plan;
|
|
1259
|
+
|
|
1260
|
+
const msgSessionCol = idColumnFor(conn, 'session_messages') || 'ctm_session_id';
|
|
1261
|
+
const insertMsg = conn.prepare(`
|
|
1262
|
+
INSERT OR IGNORE INTO session_messages (${quoteIdent(msgSessionCol)}, message_index, role, content, created_at)
|
|
1263
|
+
VALUES (?, ?, ?, ?, ?)
|
|
1264
|
+
`);
|
|
1265
|
+
const insertFts = conn.prepare('INSERT INTO session_messages_fts(rowid, content) VALUES (?, ?)');
|
|
1266
|
+
let inserted_messages = 0;
|
|
1267
|
+
let inserted_fts = 0;
|
|
1268
|
+
const txn = conn.transaction((rows) => {
|
|
1269
|
+
for (const row of rows) {
|
|
1270
|
+
const messages = parseConversationMessages(row);
|
|
1271
|
+
let sessionInserted = 0;
|
|
1272
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
1273
|
+
const raw = messages[i];
|
|
1274
|
+
const role = normalizeRole(raw);
|
|
1275
|
+
if (role !== 'user' && role !== 'assistant') continue;
|
|
1276
|
+
const content = messageText(raw);
|
|
1277
|
+
if (content.length < 5) continue;
|
|
1278
|
+
const info = insertMsg.run(row._conversation_id, i, role, content, raw?.timestamp || raw?.created_at || null);
|
|
1279
|
+
if (info.changes > 0) {
|
|
1280
|
+
inserted_messages += 1;
|
|
1281
|
+
sessionInserted += 1;
|
|
1282
|
+
try {
|
|
1283
|
+
insertFts.run(info.lastInsertRowid, content.length > 2000 ? content.slice(0, 2000) : content);
|
|
1284
|
+
inserted_fts += 1;
|
|
1285
|
+
} catch {}
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
if (sessionInserted === 0) {
|
|
1289
|
+
insertMsg.run(row._conversation_id, -1, 'system', '(no extractable messages)', null);
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
txn(rows);
|
|
1294
|
+
|
|
1295
|
+
return {
|
|
1296
|
+
...plan,
|
|
1297
|
+
dry_run: false,
|
|
1298
|
+
inserted_messages,
|
|
1299
|
+
inserted_fts,
|
|
1300
|
+
table_counts: inspectSessionTables(conn).table_counts || {},
|
|
1301
|
+
};
|
|
1302
|
+
});
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function backfillPlan(db, { limit, created_tables = [] } = {}) {
|
|
1306
|
+
const rows = selectBackfillConversationRows(db, { limit });
|
|
1307
|
+
return backfillPlanFromRows(rows, { created_tables });
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function selectBackfillConversationRows(db, { limit } = {}) {
|
|
1311
|
+
const convIdCol = idColumnFor(db, 'session_conversations');
|
|
1312
|
+
if (!convIdCol) {
|
|
1313
|
+
return [];
|
|
1314
|
+
}
|
|
1315
|
+
const cap = clampLimit(limit, 100, 1000);
|
|
1316
|
+
const hasMessages = hasTable(db, 'session_messages');
|
|
1317
|
+
const msgSessionCol = hasMessages ? idColumnFor(db, 'session_messages') : null;
|
|
1318
|
+
const join = hasMessages && msgSessionCol
|
|
1319
|
+
? `LEFT JOIN session_messages sm ON sm.${quoteIdent(msgSessionCol)} = sc.${quoteIdent(convIdCol)}
|
|
1320
|
+
WHERE sm.${quoteIdent(msgSessionCol)} IS NULL`
|
|
1321
|
+
: '';
|
|
1322
|
+
return db.prepare(`
|
|
1323
|
+
SELECT sc.*, sc.${quoteIdent(convIdCol)} AS _conversation_id
|
|
1324
|
+
FROM session_conversations sc
|
|
1325
|
+
${join}
|
|
1326
|
+
ORDER BY COALESCE(sc.imported_at, sc.session_created_at, '') DESC
|
|
1327
|
+
LIMIT ?
|
|
1328
|
+
`).all(cap);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
function backfillPlanFromRows(rows, { created_tables = [] } = {}) {
|
|
1332
|
+
return {
|
|
1333
|
+
ok: true,
|
|
1334
|
+
source: 'ctm-db',
|
|
1335
|
+
dry_run: true,
|
|
1336
|
+
created_tables,
|
|
1337
|
+
planned_sessions: rows.length,
|
|
1338
|
+
sessions: rows.map((row) => ({
|
|
1339
|
+
conversation_id: row._conversation_id,
|
|
1340
|
+
project_path: row.project_path || '',
|
|
1341
|
+
title: row.title || row.first_message || '',
|
|
1342
|
+
user_msg_count: row.user_msg_count || 0,
|
|
1343
|
+
assistant_msg_count: row.assistant_msg_count || 0,
|
|
1344
|
+
imported_at: row.imported_at || '',
|
|
1345
|
+
})),
|
|
1346
|
+
};
|
|
1347
|
+
}
|
|
1348
|
+
|
|
1349
|
+
function compactObject(obj) {
|
|
1350
|
+
const out = {};
|
|
1351
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
1352
|
+
if (value === undefined || value === null || value === '') continue;
|
|
1353
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
1354
|
+
out[key] = value;
|
|
1355
|
+
}
|
|
1356
|
+
return out;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
module.exports = {
|
|
1360
|
+
CTM_SESSION_TABLES,
|
|
1361
|
+
resolveCtmDbPath,
|
|
1362
|
+
resolveCtmDbCandidatePaths,
|
|
1363
|
+
openCtmDb,
|
|
1364
|
+
hasOpenableCtmDb,
|
|
1365
|
+
inspectSessionTables,
|
|
1366
|
+
getCtmDbHealth,
|
|
1367
|
+
searchCtmSessions,
|
|
1368
|
+
getCtmSessionContext,
|
|
1369
|
+
buildContextPack,
|
|
1370
|
+
backfillCtmSessionMessages,
|
|
1371
|
+
dedupeItems,
|
|
1372
|
+
dedupeMessages,
|
|
1373
|
+
normalizeContent,
|
|
1374
|
+
contentFingerprint,
|
|
1375
|
+
renderContextMarkdown,
|
|
1376
|
+
};
|