@clauderecallhq/cli 0.94.0 → 0.95.4

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.
@@ -0,0 +1,758 @@
1
+ /* Claude Recall (proprietary). See LICENSE for terms. */
2
+ var _e=Object.defineProperty;var B=(e,s)=>{for(var t in s)_e(e,t,{get:s[t],enumerable:!0})};import{parentPort as m}from"node:worker_threads";import{createRequire as Ne}from"node:module";{let e=process.emit.bind(process);process.emit=function(s,...t){let r=t[0];return s==="warning"&&r instanceof Error&&r.name==="ExperimentalWarning"&&/SQLite/i.test(r.message)?!1:e(s,...t)}}var pe=Ne(import.meta.url),he=["node","sqlite"].join(":"),Le=pe(he),X=class{inner;constructor(s){this.inner=s}get(...s){return s.length===0?this.inner.get():this.inner.get(...s)}all(...s){return s.length===0?this.inner.all():this.inner.all(...s)}run(...s){let t=s.length===0?this.inner.run():this.inner.run(...s);return{changes:t.changes,lastInsertRowid:t.lastInsertRowid}}iterate(...s){return s.length===0?this.inner.iterate():this.inner.iterate(...s)}},v=class{inner;extensionLoadingEnabled=!1;txDepth=0;constructor(s,t={}){this.inner=new Le.DatabaseSync(s,{readOnly:t.readonly??!1,allowExtension:!0})}prepare(s){return new X(this.inner.prepare(s))}exec(s){this.inner.exec(s)}close(){this.inner.close()}pragma(s,t={}){if(s.includes("=")){this.inner.exec(`PRAGMA ${s}`);return}if(t.simple){let r=this.inner.prepare(`PRAGMA ${s}`).get();return r&&typeof r=="object"?Object.values(r)[0]:void 0}return this.inner.prepare(`PRAGMA ${s}`).all()}transaction(s){return((...r)=>{this.txDepth===0?this.inner.exec("BEGIN"):this.inner.exec(`SAVEPOINT sp_${this.txDepth}`),this.txDepth+=1;try{let o=s(...r);return this.txDepth-=1,this.txDepth===0?this.inner.exec("COMMIT"):this.inner.exec(`RELEASE sp_${this.txDepth}`),o}catch(o){this.txDepth-=1;try{this.txDepth===0?this.inner.exec("ROLLBACK"):(this.inner.exec(`ROLLBACK TO sp_${this.txDepth}`),this.inner.exec(`RELEASE sp_${this.txDepth}`))}catch{}throw o}})}loadExtension(s,t){this.extensionLoadingEnabled||(this.inner.enableLoadExtension(!0),this.extensionLoadingEnabled=!0),t===void 0?this.inner.loadExtension(s):this.inner.loadExtension(s,t)}},Y=v;import{homedir as q}from"node:os";import{join as U,basename as ns}from"node:path";import{existsSync as ge,mkdirSync as Ie,chmodSync as fe,readdirSync as is,statSync as as}from"node:fs";var ds=process.env.CLAUDE_PROJECTS_DIR?process.env.CLAUDE_PROJECTS_DIR:U(q(),".claude","projects"),L=process.env.RECALL_HOME?process.env.RECALL_HOME:U(q(),".recall"),K=U(L,"db.sqlite");function H(){ge(L)||Ie(L,{recursive:!0,mode:448}),process.platform!=="win32"&&fe(L,448)}var W=`
3
+ PRAGMA journal_mode = WAL;
4
+ PRAGMA synchronous = NORMAL;
5
+ PRAGMA foreign_keys = ON;
6
+
7
+ CREATE TABLE IF NOT EXISTS projects (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ encoded_path TEXT UNIQUE NOT NULL,
10
+ decoded_path TEXT NOT NULL,
11
+ name TEXT NOT NULL,
12
+ repo_root TEXT,
13
+ main_repo TEXT,
14
+ is_repo INTEGER NOT NULL DEFAULT 0,
15
+ is_worktree INTEGER NOT NULL DEFAULT 0
16
+ );
17
+
18
+ CREATE TABLE IF NOT EXISTS sessions (
19
+ id TEXT PRIMARY KEY,
20
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
21
+ file_path TEXT NOT NULL,
22
+ file_mtime REAL NOT NULL,
23
+ started_at TEXT,
24
+ ended_at TEXT,
25
+ message_count INTEGER NOT NULL DEFAULT 0,
26
+ user_message_count INTEGER NOT NULL DEFAULT 0,
27
+ assistant_message_count INTEGER NOT NULL DEFAULT 0,
28
+ first_user_message TEXT,
29
+ cwd TEXT,
30
+ git_branch TEXT,
31
+ version TEXT,
32
+ indexed_at TEXT NOT NULL
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
35
+ CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);
36
+
37
+ CREATE TABLE IF NOT EXISTS messages (
38
+ rowid INTEGER PRIMARY KEY AUTOINCREMENT,
39
+ uuid TEXT UNIQUE NOT NULL,
40
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
41
+ parent_uuid TEXT,
42
+ type TEXT NOT NULL,
43
+ role TEXT,
44
+ timestamp TEXT,
45
+ is_sidechain INTEGER NOT NULL DEFAULT 0,
46
+ content_text TEXT,
47
+ tool_names TEXT,
48
+ raw_json TEXT NOT NULL
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);
51
+
52
+ CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
53
+ content_text,
54
+ tool_names,
55
+ content='messages',
56
+ content_rowid='rowid',
57
+ tokenize = 'porter unicode61'
58
+ );
59
+
60
+ CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
61
+ INSERT INTO messages_fts(rowid, content_text, tool_names)
62
+ VALUES (new.rowid, new.content_text, new.tool_names);
63
+ END;
64
+
65
+ CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
66
+ INSERT INTO messages_fts(messages_fts, rowid, content_text, tool_names)
67
+ VALUES ('delete', old.rowid, old.content_text, old.tool_names);
68
+ END;
69
+
70
+ CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
71
+ INSERT INTO messages_fts(messages_fts, rowid, content_text, tool_names)
72
+ VALUES ('delete', old.rowid, old.content_text, old.tool_names);
73
+ INSERT INTO messages_fts(rowid, content_text, tool_names)
74
+ VALUES (new.rowid, new.content_text, new.tool_names);
75
+ END;
76
+
77
+ -- v0.4.1 \u2014 session aliases. The UUID stays the primary key forever; aliases
78
+ -- are a display layer on top. Every edit archives the prior value into
79
+ -- previous_aliases (JSON array) \u2014 we never destroy history.
80
+ CREATE TABLE IF NOT EXISTS session_aliases (
81
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
82
+ alias TEXT NOT NULL,
83
+ updated_at TEXT NOT NULL,
84
+ previous_aliases TEXT NOT NULL DEFAULT '[]'
85
+ );
86
+
87
+ -- v0.4.2 \u2014 per-session markdown notes.
88
+ -- Every save archives the prior content into previous_versions and also mirrors
89
+ -- the full note out to ~/.recall/notes/<session_id>.md on disk. Nothing is ever
90
+ -- destroyed: an empty string means "cleared" with full history preserved.
91
+ CREATE TABLE IF NOT EXISTS session_notes (
92
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
93
+ content TEXT NOT NULL DEFAULT '',
94
+ updated_at TEXT NOT NULL,
95
+ previous_versions TEXT NOT NULL DEFAULT '[]'
96
+ );
97
+
98
+ -- v0.4.3 \u2014 session tags. Many-to-many. Never destructively delete:
99
+ -- tag_events is an append-only log of every add and remove across all time,
100
+ -- so the full history of what was tagged when and why is always recoverable.
101
+ CREATE TABLE IF NOT EXISTS session_tags (
102
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
103
+ tag TEXT NOT NULL,
104
+ created_at TEXT NOT NULL,
105
+ PRIMARY KEY (session_id, tag)
106
+ );
107
+ CREATE INDEX IF NOT EXISTS idx_session_tags_tag ON session_tags(tag);
108
+
109
+ CREATE TABLE IF NOT EXISTS tag_events (
110
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
111
+ session_id TEXT NOT NULL,
112
+ tag TEXT NOT NULL,
113
+ action TEXT NOT NULL, -- 'add' | 'remove'
114
+ at TEXT NOT NULL
115
+ );
116
+ CREATE INDEX IF NOT EXISTS idx_tag_events_session ON tag_events(session_id, at DESC);
117
+ CREATE INDEX IF NOT EXISTS idx_tag_events_tag ON tag_events(tag, at DESC);
118
+
119
+ -- v0.4.5 \u2014 opt-in clipboard archive. This is the ONE table where a user can
120
+ -- truly purge a row on demand (not soft-delete), because it may contain
121
+ -- accidentally-archived secrets. This carve-out is documented as a deliberate
122
+ -- exception to the "never delete" rule specifically for this data class.
123
+ CREATE TABLE IF NOT EXISTS paste_archives (
124
+ id TEXT PRIMARY KEY,
125
+ created_at TEXT NOT NULL,
126
+ content TEXT NOT NULL,
127
+ size_bytes INTEGER NOT NULL,
128
+ source TEXT NOT NULL DEFAULT 'cli', -- 'cli' | 'cli-piped' | 'ui' | \u2026
129
+ label TEXT -- optional user-supplied short description
130
+ );
131
+ CREATE INDEX IF NOT EXISTS idx_paste_archives_created ON paste_archives(created_at DESC);
132
+
133
+ -- v0.8 \u2014 collections. User-curated hand-picked groupings of sessions that
134
+ -- cut across the coarse-grained cwd-based "projects". A collection can nest
135
+ -- as a tree (parent_id \u2192 null means root). Soft-deletion only: archived_at
136
+ -- hides rows from default views, the row itself stays forever.
137
+ --
138
+ -- Durability: SQLite (this table + collection_events append-only log) plus a
139
+ -- plain-text mirror at ~/.recall/collections.json rewritten on every mutation.
140
+ CREATE TABLE IF NOT EXISTS collections (
141
+ id TEXT PRIMARY KEY,
142
+ name TEXT NOT NULL,
143
+ description TEXT,
144
+ icon TEXT,
145
+ color TEXT,
146
+ parent_id TEXT REFERENCES collections(id) ON DELETE SET NULL,
147
+ sort_key TEXT NOT NULL DEFAULT '',
148
+ created_at TEXT NOT NULL,
149
+ updated_at TEXT NOT NULL,
150
+ archived_at TEXT
151
+ );
152
+ CREATE INDEX IF NOT EXISTS idx_collections_parent ON collections(parent_id);
153
+ CREATE INDEX IF NOT EXISTS idx_collections_archived ON collections(archived_at);
154
+
155
+ CREATE TABLE IF NOT EXISTS collection_sessions (
156
+ collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
157
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
158
+ added_at TEXT NOT NULL,
159
+ note TEXT,
160
+ PRIMARY KEY (collection_id, session_id)
161
+ );
162
+ CREATE INDEX IF NOT EXISTS idx_collection_sessions_session ON collection_sessions(session_id);
163
+
164
+ CREATE TABLE IF NOT EXISTS collection_events (
165
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
166
+ collection_id TEXT NOT NULL,
167
+ session_id TEXT,
168
+ action TEXT NOT NULL, -- 'create' | 'rename' | 'describe' | 'recolor' | 'add' | 'remove' | 'archive' | 'restore' | 'move' | 'reorder'
169
+ payload TEXT, -- JSON context (old_name, new_parent, etc.)
170
+ at TEXT NOT NULL
171
+ );
172
+ CREATE INDEX IF NOT EXISTS idx_collection_events_at ON collection_events(at DESC);
173
+ CREATE INDEX IF NOT EXISTS idx_collection_events_collection ON collection_events(collection_id, at DESC);
174
+
175
+ -- v0.11 \u2014 semantic search (Tier 0: shells out to the user's local claude CLI to
176
+ -- summarize each session into 3-sentence prose + a keyword set). Both columns
177
+ -- are TEXT and feed sessions_fts so a conceptual query can hit this index in
178
+ -- addition to the per-message FTS5 index. Pipeline is OFF by default; users opt
179
+ -- in via "recall semantic on". Plain-text mirror at ~/.recall/semantic/<id>.json.
180
+ CREATE TABLE IF NOT EXISTS session_semantic (
181
+ session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
182
+ summary TEXT NOT NULL DEFAULT '',
183
+ keywords TEXT NOT NULL DEFAULT '', -- comma-separated
184
+ model TEXT,
185
+ source_message_count INTEGER NOT NULL DEFAULT 0,
186
+ generated_at TEXT NOT NULL
187
+ );
188
+
189
+ CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
190
+ summary,
191
+ keywords,
192
+ content='session_semantic',
193
+ content_rowid='rowid',
194
+ tokenize = 'porter unicode61'
195
+ );
196
+
197
+ CREATE TRIGGER IF NOT EXISTS session_semantic_ai AFTER INSERT ON session_semantic BEGIN
198
+ INSERT INTO sessions_fts(rowid, summary, keywords)
199
+ VALUES (new.rowid, new.summary, new.keywords);
200
+ END;
201
+
202
+ CREATE TRIGGER IF NOT EXISTS session_semantic_ad AFTER DELETE ON session_semantic BEGIN
203
+ INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords)
204
+ VALUES ('delete', old.rowid, old.summary, old.keywords);
205
+ END;
206
+
207
+ CREATE TRIGGER IF NOT EXISTS session_semantic_au AFTER UPDATE ON session_semantic BEGIN
208
+ INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords)
209
+ VALUES ('delete', old.rowid, old.summary, old.keywords);
210
+ INSERT INTO sessions_fts(rowid, summary, keywords)
211
+ VALUES (new.rowid, new.summary, new.keywords);
212
+ END;
213
+
214
+ -- v0.7 \u2014 vector search tier (Pro-only). Chunks are per-conversational-turn
215
+ -- segments of messages, embedded via local bge-base-en-v1.5 (768d). The vector
216
+ -- data is a derived cache; if lost, it is recomputable from messages.
217
+ -- Tables stay empty on Free tier (no model downloaded, no worker started).
218
+ --
219
+ -- NOTE: the vec_chunks vec0 virtual table is intentionally NOT created here.
220
+ -- It lives in src/db/vecLoader.ts (ensureVecChunksTable), loaded lazily so a
221
+ -- lean MCP child that only does FTS/metadata never instantiates the native
222
+ -- vec0 module. The daemon/worker/CLI call ensureVecChunksTable at open.
223
+
224
+ CREATE TABLE IF NOT EXISTS chunk_meta (
225
+ rowid INTEGER PRIMARY KEY,
226
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
227
+ message_uuids TEXT NOT NULL DEFAULT '[]',
228
+ text TEXT NOT NULL DEFAULT '',
229
+ embedding_model_id TEXT NOT NULL DEFAULT 'bge-base-en-v1.5',
230
+ embedding_dim INTEGER NOT NULL DEFAULT 768,
231
+ stale INTEGER NOT NULL DEFAULT 0,
232
+ generated_at TEXT NOT NULL DEFAULT ''
233
+ );
234
+ CREATE INDEX IF NOT EXISTS idx_chunk_meta_session ON chunk_meta(session_id);
235
+ CREATE INDEX IF NOT EXISTS idx_chunk_meta_stale ON chunk_meta(stale) WHERE stale = 1;
236
+
237
+ -- chunk_queue action CHECK constraint expanded in v0.78 to include
238
+ -- 'pending_post_migration'. New databases accept this value; existing
239
+ -- databases retain the older 3-action check (SQLite cannot ALTER a
240
+ -- CHECK constraint in place). The Phase 2 migrate command (src/semantic/
241
+ -- migrate.ts, TBD) pauses the embedder worker before enqueueing any
242
+ -- 'pending_post_migration' rows; on an older DB the watcher (D1) catches
243
+ -- the resulting CHECK violation and falls back to 'embed' \u2014 safe because
244
+ -- the worker is paused on the migration lock during the window.
245
+ CREATE TABLE IF NOT EXISTS chunk_queue (
246
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
247
+ session_id TEXT NOT NULL,
248
+ message_uuid TEXT,
249
+ action TEXT NOT NULL CHECK (action IN ('embed', 'reembed', 'delete', 'pending_post_migration')),
250
+ enqueued_at TEXT NOT NULL DEFAULT (datetime('now'))
251
+ );
252
+ CREATE INDEX IF NOT EXISTS idx_chunk_queue_session ON chunk_queue(session_id);
253
+
254
+ -- v0.78 \u2014 semantic migration state (Phase 2 of Tier 2 modernization).
255
+ -- Tracks the lifecycle of a \`recall semantic migrate\` run: in-progress with
256
+ -- a cursor, completed (vec_chunks now holds the new-backend vectors and
257
+ -- vec_chunks_v1_backup retains the prior set for 30 days), failed (the
258
+ -- partial vec_chunks_v2 is left in place for forensic inspection then
259
+ -- discarded by a future migrate retry), or rolled_back (the backup was
260
+ -- swapped back in via \`recall semantic rollback-migration\`).
261
+ --
262
+ -- schema_version lets a future CLI version refuse to resume a cursor it
263
+ -- cannot interpret rather than corrupt the migration state silently.
264
+ CREATE TABLE IF NOT EXISTS migration_state (
265
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
266
+ schema_version INTEGER NOT NULL,
267
+ status TEXT NOT NULL CHECK (status IN (
268
+ 'in_progress',
269
+ 'paused',
270
+ 'completed',
271
+ 'rolled_back',
272
+ 'failed'
273
+ )),
274
+ started_at TEXT NOT NULL,
275
+ completed_at TEXT,
276
+ lock_taken_at TEXT,
277
+ lock_taken_by_pid INTEGER,
278
+ cursor_session_id TEXT,
279
+ cursor_chunk_id INTEGER,
280
+ model_id_old TEXT NOT NULL,
281
+ model_id_new TEXT NOT NULL
282
+ );
283
+ CREATE INDEX IF NOT EXISTS idx_migration_state_status ON migration_state(status);
284
+
285
+ -- Belt-and-suspenders: at most one active (in-progress or paused) row.
286
+ -- Partial UNIQUE INDEX on schema_version with predicate status IN ('in_progress','paused')
287
+ -- means "at most one row per schema_version that is active." Since schema_version
288
+ -- is monotonic, this effectively caps active migrations at one regardless of
289
+ -- application-layer race conditions.
290
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_migration_state_single_active
291
+ ON migration_state(schema_version) WHERE status IN ('in_progress', 'paused');
292
+
293
+ -- v0.67.0 -- retention/archive (P6). Power-user durability: sessions older
294
+ -- than the user chosen window can be moved to messages_archive so the hot
295
+ -- DB stays small without dropping data. Sessions metadata stays hot for
296
+ -- list/search by title; only the message bodies move. The "recall archive
297
+ -- restore <id>" command reverses the move. Source JSONLs are still the
298
+ -- immutable ground truth -- the archive table is a fast-path; in extremis
299
+ -- a session can be reindexed from its file_path on disk.
300
+ --
301
+ -- archive_status on the sessions row drives the UI: archived sessions show
302
+ -- a badge and a "restore to load full transcript" affordance.
303
+ CREATE TABLE IF NOT EXISTS messages_archive (
304
+ uuid TEXT PRIMARY KEY,
305
+ session_id TEXT NOT NULL,
306
+ parent_uuid TEXT,
307
+ type TEXT,
308
+ role TEXT,
309
+ timestamp TEXT,
310
+ is_sidechain INTEGER NOT NULL DEFAULT 0,
311
+ content_text TEXT,
312
+ tool_names TEXT,
313
+ raw_json TEXT,
314
+ archived_at TEXT NOT NULL DEFAULT (datetime('now'))
315
+ );
316
+ CREATE INDEX IF NOT EXISTS idx_messages_archive_session ON messages_archive(session_id);
317
+
318
+ -- v0.67.0 \u2014 semantic enqueue gate. Without this, every INSERT/DELETE/UPDATE
319
+ -- on messages enqueued a chunk_queue row, regardless of whether the embedder
320
+ -- worker was running. Power users with semantic disabled (the default)
321
+ -- accumulated 50M+ orphan rows in days, growing the DB by ~1 GB/day.
322
+ -- The gate is a single key in app_settings, kept in sync with
323
+ -- ~/.recall/config.json:semantic.enabled by the daemon at boot and on every
324
+ -- semantic-config write.
325
+ CREATE TABLE IF NOT EXISTS app_settings (
326
+ key TEXT PRIMARY KEY,
327
+ value TEXT NOT NULL
328
+ );
329
+ INSERT OR IGNORE INTO app_settings(key, value) VALUES ('semantic_enabled', '0');
330
+
331
+ -- Drop unconditional v0.10/v0.11-era triggers; replaced below with the gated
332
+ -- versions. Idempotent: SQLite no-ops when the trigger does not exist, so
333
+ -- this is safe to run on every boot and on fresh databases.
334
+ --
335
+ -- 2026-05-23 \u2014 added IF NOT EXISTS guards on the CREATE side too. Without
336
+ -- them, any failure between the DROPs and the CREATEs (e.g. a partially
337
+ -- applied prior boot, or a future statement inserted between them) left
338
+ -- the live DB with the gated triggers but the next SCHEMA_SQL re-exec
339
+ -- still threw "trigger messages_vec_ai already exists". That threw all the
340
+ -- way out of getDb(), which caused syncSemanticEnabledToDb to silently
341
+ -- skip the gate flip \u2014 and the live (still-present) triggers fired with
342
+ -- the prior semantic_enabled='1' value, causing spurious chunk_queue
343
+ -- inserts even when semantic search was disabled. Belt and suspenders:
344
+ -- drop-then-create-if-not-exists is fully idempotent.
345
+ DROP TRIGGER IF EXISTS messages_vec_ai;
346
+ DROP TRIGGER IF EXISTS messages_vec_ad;
347
+ DROP TRIGGER IF EXISTS messages_vec_au;
348
+
349
+ CREATE TRIGGER IF NOT EXISTS messages_vec_ai AFTER INSERT ON messages
350
+ WHEN new.is_sidechain = 0
351
+ AND new.content_text IS NOT NULL
352
+ AND (SELECT value FROM app_settings WHERE key = 'semantic_enabled') = '1'
353
+ BEGIN
354
+ INSERT INTO chunk_queue(session_id, message_uuid, action, enqueued_at)
355
+ VALUES (new.session_id, new.uuid, 'embed', datetime('now'));
356
+ END;
357
+
358
+ CREATE TRIGGER IF NOT EXISTS messages_vec_ad AFTER DELETE ON messages
359
+ WHEN (SELECT value FROM app_settings WHERE key = 'semantic_enabled') = '1'
360
+ BEGIN
361
+ INSERT INTO chunk_queue(session_id, message_uuid, action, enqueued_at)
362
+ VALUES (old.session_id, old.uuid, 'delete', datetime('now'));
363
+ END;
364
+
365
+ CREATE TRIGGER IF NOT EXISTS messages_vec_au AFTER UPDATE OF content_text ON messages
366
+ WHEN new.is_sidechain = 0
367
+ AND (SELECT value FROM app_settings WHERE key = 'semantic_enabled') = '1'
368
+ BEGIN
369
+ INSERT INTO chunk_queue(session_id, message_uuid, action, enqueued_at)
370
+ VALUES (new.session_id, new.uuid, 'reembed', datetime('now'));
371
+ END;
372
+
373
+ -- v0.10a \u2014 cost / token analytics. Claude Code already writes per-assistant-
374
+ -- message usage + model into the source JSONLs; we persist the raw counts
375
+ -- here and derive dollar amounts at render time (pricing changes; token
376
+ -- counts don't). message_usage is 1:1 with messages (keyed by message_uuid)
377
+ -- so a reindex rebuilds it cleanly; rollup columns on sessions are a
378
+ -- cache for fast list rendering.
379
+ CREATE TABLE IF NOT EXISTS message_usage (
380
+ message_uuid TEXT PRIMARY KEY REFERENCES messages(uuid) ON DELETE CASCADE,
381
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
382
+ model TEXT,
383
+ input_tokens INTEGER NOT NULL DEFAULT 0,
384
+ output_tokens INTEGER NOT NULL DEFAULT 0,
385
+ cache_create_tokens INTEGER NOT NULL DEFAULT 0,
386
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
387
+ timestamp TEXT
388
+ );
389
+ CREATE INDEX IF NOT EXISTS idx_message_usage_session ON message_usage(session_id);
390
+ CREATE INDEX IF NOT EXISTS idx_message_usage_model ON message_usage(model);
391
+ -- Windowed stats queries (7d / 30d) filter on mu.timestamp >= since.
392
+ -- Without this index every refresh full-scans message_usage.
393
+ CREATE INDEX IF NOT EXISTS idx_message_usage_timestamp
394
+ ON message_usage(timestamp);
395
+ -- Daily-bucket aggregation groups by substr(timestamp, 1, 10) and joins
396
+ -- on session_id. The composite covers the common "by session in window"
397
+ -- access pattern and lets the engine skip the row lookup for many plans.
398
+ CREATE INDEX IF NOT EXISTS idx_message_usage_session_ts
399
+ ON message_usage(session_id, timestamp);
400
+
401
+ -- v0.10b \u2014 git correlation. For any session whose cwd is a git worktree we
402
+ -- run a read-only \`git log\` scoped to that cwd for the [started_at, ended_at]
403
+ -- window and record every resulting commit. Composite PK (session_id,
404
+ -- commit_sha) lets a single commit belong to multiple sessions (rare, but
405
+ -- happens when a long session ends right as another starts). The reverse
406
+ -- index on commit_sha powers \`recall blame <sha>\`.
407
+ --
408
+ -- correlated_at lets the correlator skip sessions it has already processed
409
+ -- recently; cwd_snapshot captures the directory we ran git in so stale rows
410
+ -- can be identified if the user later points the session elsewhere.
411
+ CREATE TABLE IF NOT EXISTS session_commits (
412
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
413
+ commit_sha TEXT NOT NULL,
414
+ committed_at TEXT,
415
+ subject TEXT,
416
+ cwd_snapshot TEXT,
417
+ correlated_at TEXT NOT NULL,
418
+ PRIMARY KEY (session_id, commit_sha)
419
+ );
420
+ CREATE INDEX IF NOT EXISTS idx_session_commits_sha ON session_commits(commit_sha);
421
+ CREATE INDEX IF NOT EXISTS idx_session_commits_session ON session_commits(session_id);
422
+ -- v0.13 \u2014 MCP write audit log. Every write tool invocation appends one row,
423
+ -- regardless of outcome (ok / error / rate_limited). Tags and collections
424
+ -- already have their own append-only event tables (tag_events,
425
+ -- collection_events) \u2014 this table covers note/alias writes and provides a
426
+ -- single chronological feed across all MCP write activity for review.
427
+ CREATE TABLE IF NOT EXISTS mcp_audit_events (
428
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
429
+ tool TEXT NOT NULL,
430
+ args_json TEXT NOT NULL,
431
+ result TEXT NOT NULL, -- 'ok' | 'error' | 'rate_limited'
432
+ error_message TEXT,
433
+ caller TEXT,
434
+ at TEXT NOT NULL
435
+ );
436
+ CREATE INDEX IF NOT EXISTS idx_mcp_audit_events_at ON mcp_audit_events(at DESC);
437
+ CREATE INDEX IF NOT EXISTS idx_mcp_audit_events_tool ON mcp_audit_events(tool, at DESC);
438
+
439
+ -- v0.15 T6 \u2014 auto-collections. Rules match sessions by cwd prefix, project
440
+ -- id, tag, plan-file reference, or git branch prefix, and insert auto
441
+ -- memberships into collection_sessions (tagged with source='auto' + the
442
+ -- originating rule_id). Manual memberships (source='manual') are never
443
+ -- touched by the rule engine \u2014 so a user's hand-curated pick always wins.
444
+ --
445
+ -- Suggestions are the discovery half: the daemon periodically surveys the
446
+ -- corpus, detects clusters that *would* make good auto-collections, and
447
+ -- stashes them here for the user to accept or dismiss. UNIQUE(type,pattern)
448
+ -- means the same cluster won't be re-suggested every scan.
449
+ --
450
+ -- Durability mirror: ~/.recall/auto-rules/{rules.json,suggestions.json}
451
+ -- rewritten on every mutation. Deleting a rule removes ONLY its auto
452
+ -- memberships (matched via rule_id); the target collection stays so the
453
+ -- user can keep whatever they manually added into it.
454
+ CREATE TABLE IF NOT EXISTS auto_collection_rules (
455
+ id TEXT PRIMARY KEY,
456
+ name TEXT NOT NULL,
457
+ type TEXT NOT NULL CHECK (type IN ('cwd-prefix','project-id','tag','plan-file','git-branch-prefix')),
458
+ pattern TEXT NOT NULL,
459
+ collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
460
+ priority INTEGER NOT NULL DEFAULT 100,
461
+ enabled INTEGER NOT NULL DEFAULT 1,
462
+ created_at TEXT NOT NULL,
463
+ created_by TEXT NOT NULL DEFAULT 'user' -- 'user' | 'suggestion-accepted' | 'seed'
464
+ );
465
+ CREATE INDEX IF NOT EXISTS idx_auto_rules_type_enabled ON auto_collection_rules(type, enabled);
466
+ CREATE INDEX IF NOT EXISTS idx_auto_rules_collection ON auto_collection_rules(collection_id);
467
+
468
+ CREATE TABLE IF NOT EXISTS auto_collection_suggestions (
469
+ id TEXT PRIMARY KEY,
470
+ type TEXT NOT NULL,
471
+ pattern TEXT NOT NULL,
472
+ suggested_name TEXT NOT NULL,
473
+ suggested_parent_collection_id TEXT,
474
+ session_count INTEGER NOT NULL,
475
+ detected_at TEXT NOT NULL,
476
+ dismissed INTEGER NOT NULL DEFAULT 0,
477
+ UNIQUE(type, pattern)
478
+ );
479
+ CREATE INDEX IF NOT EXISTS idx_auto_suggestions_detected ON auto_collection_suggestions(detected_at DESC);
480
+
481
+ -- v0.15 Threads. The headline intent-grouping primitive: a DAG of sessions
482
+ -- connected by shared purpose (one or more origin sessions plus their
483
+ -- children). Additive to Projects and Collections; neither is refactored.
484
+ -- thread_edges is many-to-many so a session can belong to multiple threads
485
+ -- (planning session that seeds two features). parent_session_id is
486
+ -- nullable; when null, the row is an origin. confidence = 1.0 for manual
487
+ -- edges; < 1.0 for auto-detected edges (v0.15b). source tracks provenance
488
+ -- for the audit/undo paths.
489
+ CREATE TABLE IF NOT EXISTS threads (
490
+ id TEXT PRIMARY KEY,
491
+ name TEXT NOT NULL,
492
+ summary TEXT,
493
+ created_at TEXT NOT NULL,
494
+ closed_at TEXT,
495
+ archived INTEGER NOT NULL DEFAULT 0
496
+ );
497
+
498
+ CREATE TABLE IF NOT EXISTS thread_edges (
499
+ thread_id TEXT NOT NULL,
500
+ session_id TEXT NOT NULL,
501
+ parent_session_id TEXT,
502
+ role TEXT NOT NULL CHECK(role IN ('origin','child')),
503
+ confidence REAL NOT NULL DEFAULT 1.0 CHECK(confidence >= 0 AND confidence <= 1),
504
+ source TEXT NOT NULL DEFAULT 'manual',
505
+ added_at TEXT NOT NULL,
506
+ PRIMARY KEY (thread_id, session_id),
507
+ FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
508
+ );
509
+ CREATE INDEX IF NOT EXISTS idx_thread_edges_session ON thread_edges(session_id);
510
+ CREATE INDEX IF NOT EXISTS idx_thread_edges_parent ON thread_edges(parent_session_id);
511
+ CREATE INDEX IF NOT EXISTS idx_thread_edges_thread_role ON thread_edges(thread_id, role);
512
+
513
+ -- v0.6 #4: user-defined SUBFOLDERS that nest inside the auto-derived
514
+ -- per-project folders the Threads sidebar already renders. Critical
515
+ -- design rule: auto-project folders are NOT in this table \u2014 they are
516
+ -- computed at render time from project membership of each thread's
517
+ -- sessions. So even with this table empty, every project folder still
518
+ -- renders and every thread still has a visible home. That's the
519
+ -- safety property that the previous full-replacement attempt lost
520
+ -- (when no user folders existed, every thread fell into a single
521
+ -- collapsed "(unfiled)" bucket and the sidebar looked broken).
522
+ --
523
+ -- project_scope: the project this user folder is nested under. NULL
524
+ -- means top-level (cross-project, e.g. "v0.6 Launch" that holds
525
+ -- threads from multiple repos).
526
+ -- parent_folder_id: another USER folder this one nests inside. NULL
527
+ -- means it's a direct child of project_scope's auto folder, or
528
+ -- top-level if project_scope is also NULL.
529
+ CREATE TABLE IF NOT EXISTS thread_folders (
530
+ id TEXT PRIMARY KEY,
531
+ name TEXT NOT NULL,
532
+ parent_folder_id TEXT REFERENCES thread_folders(id) ON DELETE CASCADE,
533
+ project_scope TEXT,
534
+ created_at TEXT NOT NULL,
535
+ archived INTEGER NOT NULL DEFAULT 0,
536
+ sort_order INTEGER NOT NULL DEFAULT 0
537
+ );
538
+ CREATE INDEX IF NOT EXISTS idx_thread_folders_parent ON thread_folders(parent_folder_id);
539
+ CREATE INDEX IF NOT EXISTS idx_thread_folders_project ON thread_folders(project_scope);
540
+
541
+ -- v0.17 -- recall event log. Every \`recall context\` invocation writes one
542
+ -- row. Powers share-card metadata ("recalled today") and monthly wrap
543
+ -- aggregation (total recalls, most-recalled session, etc.). Append-only;
544
+ -- no deletes. Plain-text mirror at ~/.recall/recall-events.json rewritten
545
+ -- on mutation.
546
+ CREATE TABLE IF NOT EXISTS recall_events (
547
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
548
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
549
+ recalled_at TEXT NOT NULL,
550
+ token_count INTEGER NOT NULL DEFAULT 0,
551
+ mode TEXT NOT NULL DEFAULT 'full',
552
+ caller TEXT NOT NULL DEFAULT 'cli'
553
+ );
554
+ CREATE INDEX IF NOT EXISTS idx_recall_events_session ON recall_events(session_id, recalled_at DESC);
555
+ CREATE INDEX IF NOT EXISTS idx_recall_events_at ON recall_events(recalled_at DESC);
556
+
557
+ -- v0.18 cog-graph Phase C \u2014 multi-edge schema.
558
+ --
559
+ -- Two parallel edge systems live side by side and MUST NOT be merged:
560
+ -- 1. thread_edges (above) \u2014 hierarchical DAG, intent grouping.
561
+ -- 2. session_links (below) \u2014 non-hierarchical: citations, semantic
562
+ -- similarity, skill tracks, bug-pattern membership, manual wiki
563
+ -- links, temporal proximity. Joined at query time; never schema-
564
+ -- level merged.
565
+ --
566
+ -- Every row carries provenance (source + evidence JSON + confidence) so
567
+ -- "why does this edge exist?" always has an answer. The unique constraint
568
+ -- on (source, target, link_type) is the soft idempotency key \u2014 re-running
569
+ -- inference upserts instead of duplicating. Approval gating via
570
+ -- session_link_suggestions (the queue) \u2192 session_links (the live store)
571
+ -- keeps trust intact: nothing auto-promotes without user opt-in.
572
+ --
573
+ -- Mirror layout: ~/.recall/links/<source-session-id>.json (per-source
574
+ -- denormalised; rewritten on mutation), ~/.recall/suggestions/index.json
575
+ -- (single file rewritten on mutation).
576
+ CREATE TABLE IF NOT EXISTS session_links (
577
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
578
+ source_session_id TEXT NOT NULL,
579
+ target_session_id TEXT NOT NULL,
580
+ link_type TEXT NOT NULL CHECK(link_type IN (
581
+ 'citation','similar','skill_track','bug_pattern',
582
+ 'wiki_link','temporal_proximity'
583
+ )),
584
+ confidence REAL NOT NULL CHECK(confidence BETWEEN 0 AND 1),
585
+ source TEXT NOT NULL,
586
+ evidence TEXT NOT NULL,
587
+ approved INTEGER NOT NULL DEFAULT 0,
588
+ created_at TEXT NOT NULL,
589
+ updated_at TEXT NOT NULL,
590
+ UNIQUE(source_session_id, target_session_id, link_type)
591
+ );
592
+ CREATE INDEX IF NOT EXISTS idx_links_source ON session_links(source_session_id);
593
+ CREATE INDEX IF NOT EXISTS idx_links_target ON session_links(target_session_id);
594
+ CREATE INDEX IF NOT EXISTS idx_links_type ON session_links(link_type);
595
+
596
+ -- v0.18 cog-graph Phase C \u2014 per-session structured outputs.
597
+ --
598
+ -- The "what did this session produce" index. Phase D's LLM-augmented
599
+ -- extractor sub-agent populates this row (Phase C ships the empty
600
+ -- table). Stays empty in Phase C. extractor_version lets us re-derive
601
+ -- when the extraction prompt or schema improves without losing the
602
+ -- existing rows. raw_extraction is the full sub-agent output blob so we
603
+ -- can re-derive structured fields without re-running extraction.
604
+ --
605
+ -- Mirror: ~/.recall/output-index/<session-id>.json rewritten on mutation.
606
+ CREATE TABLE IF NOT EXISTS session_output_index (
607
+ session_id TEXT PRIMARY KEY,
608
+ files_written TEXT,
609
+ brands_mentioned TEXT,
610
+ terms_introduced TEXT,
611
+ plan_ids_referenced TEXT,
612
+ bug_signatures TEXT,
613
+ raw_extraction TEXT,
614
+ extracted_at TEXT NOT NULL,
615
+ extractor_version INTEGER NOT NULL DEFAULT 1
616
+ );
617
+
618
+ -- v0.18 cog-graph Phase C \u2014 pending edge suggestions awaiting review.
619
+ --
620
+ -- Auto-inferred edges (from L1/L2/L3/L4 inference jobs) land here first
621
+ -- with status='pending'. User accepts \u2192 status='approved' AND a row gets
622
+ -- created in session_links with approved=1. User rejects \u2192 status=
623
+ -- 'rejected' (tombstone \u2014 prevents re-proposing the same pair from the
624
+ -- same inferer). Phase F builds the queue UI; Phase C ships the empty
625
+ -- table. The unique key includes inferred_by so two layers (e.g. L2
626
+ -- citation match + L3 embedding match) can both contribute evidence
627
+ -- without one stomping the other.
628
+ CREATE TABLE IF NOT EXISTS session_link_suggestions (
629
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
630
+ source_session_id TEXT NOT NULL,
631
+ target_session_id TEXT NOT NULL,
632
+ link_type TEXT NOT NULL,
633
+ confidence REAL NOT NULL,
634
+ evidence TEXT NOT NULL,
635
+ status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected')),
636
+ inferred_by TEXT NOT NULL,
637
+ created_at TEXT NOT NULL,
638
+ decided_at TEXT,
639
+ UNIQUE(source_session_id, target_session_id, link_type, inferred_by)
640
+ );
641
+ CREATE INDEX IF NOT EXISTS idx_suggestions_pending ON session_link_suggestions(status, created_at) WHERE status = 'pending';
642
+ CREATE INDEX IF NOT EXISTS idx_suggestions_source ON session_link_suggestions(source_session_id);
643
+ CREATE INDEX IF NOT EXISTS idx_suggestions_target ON session_link_suggestions(target_session_id);
644
+
645
+ -- v0.18 cog-graph Phase C \u2014 bug-pattern clusters. Empty in Phase C;
646
+ -- populated by Phase H (HDBSCAN over bug-signature embeddings). Tables
647
+ -- ship now so the schema is stable across the milestone. resolved_in_
648
+ -- session_id is set when the user marks "this fix worked" \u2014 surfaced
649
+ -- the next time a session's bug signature falls into the same cluster.
650
+ CREATE TABLE IF NOT EXISTS bug_pattern_clusters (
651
+ id TEXT PRIMARY KEY,
652
+ signature_hash TEXT NOT NULL,
653
+ example_message TEXT NOT NULL,
654
+ occurrence_count INTEGER NOT NULL,
655
+ first_seen_at TEXT NOT NULL,
656
+ last_seen_at TEXT NOT NULL,
657
+ resolved_in_session_id TEXT,
658
+ fix_summary TEXT
659
+ );
660
+ CREATE INDEX IF NOT EXISTS idx_bug_clusters_signature ON bug_pattern_clusters(signature_hash);
661
+ CREATE INDEX IF NOT EXISTS idx_bug_clusters_last_seen ON bug_pattern_clusters(last_seen_at DESC);
662
+
663
+ CREATE TABLE IF NOT EXISTS bug_pattern_members (
664
+ cluster_id TEXT NOT NULL REFERENCES bug_pattern_clusters(id) ON DELETE CASCADE,
665
+ session_id TEXT NOT NULL,
666
+ matched_at TEXT NOT NULL,
667
+ PRIMARY KEY (cluster_id, session_id)
668
+ );
669
+ CREATE INDEX IF NOT EXISTS idx_bug_members_session ON bug_pattern_members(session_id);
670
+
671
+ -- v0.20 / project rollups. The user's mental model is "I have ~18 repos"
672
+ -- but Recall's project model is "every cwd is a project" (~32 entries).
673
+ -- Macro repos are a deterministic, manual grouping layer the user
674
+ -- defines: pick N projects, label them as one logical repo. Display
675
+ -- surfaces collapse member projects into the macro repo when the user
676
+ -- toggles "by macro repo." See docs/internal/project-rollups.md.
677
+ --
678
+ -- We deliberately reject auto-grouping: a wrong auto-rollup is worse
679
+ -- than no rollup. Membership is always explicit, set by the user.
680
+ CREATE TABLE IF NOT EXISTS macro_repos (
681
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
682
+ name TEXT NOT NULL UNIQUE,
683
+ description TEXT,
684
+ created_at TEXT NOT NULL,
685
+ updated_at TEXT NOT NULL
686
+ );
687
+
688
+ CREATE TABLE IF NOT EXISTS macro_repo_members (
689
+ macro_repo_id INTEGER NOT NULL REFERENCES macro_repos(id) ON DELETE CASCADE,
690
+ project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
691
+ added_at TEXT NOT NULL,
692
+ PRIMARY KEY (macro_repo_id, project_id)
693
+ );
694
+ CREATE INDEX IF NOT EXISTS idx_macro_repo_members_project ON macro_repo_members(project_id);
695
+
696
+ -- v0.20 / synthesis result persistence. Every successful Bug Pattern
697
+ -- synthesis run writes its full Markdown output here so the user can
698
+ -- revisit reports later without re-spending tokens. Keyed by
699
+ -- (scope, target_id, mode, created_at) \u2014 multiple runs over time on the
700
+ -- same target are kept as a history (newest-first browse). Token spend
701
+ -- is recorded for audit. NOT subject to the three-layer durability rule
702
+ -- because the source data (bug_pattern_clusters + member sessions) can
703
+ -- always re-derive the synthesis.
704
+ CREATE TABLE IF NOT EXISTS bug_synthesis_results (
705
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
706
+ scope TEXT NOT NULL CHECK(scope IN ('cluster', 'project')),
707
+ target_id TEXT NOT NULL,
708
+ mode TEXT NOT NULL CHECK(mode IN ('synopsis', 'priorities', 'root_cause')),
709
+ model TEXT NOT NULL,
710
+ output_markdown TEXT NOT NULL,
711
+ input_tokens INTEGER NOT NULL DEFAULT 0,
712
+ output_tokens INTEGER NOT NULL DEFAULT 0,
713
+ context_summary TEXT NOT NULL DEFAULT '{}',
714
+ created_at TEXT NOT NULL,
715
+ job_id TEXT
716
+ );
717
+ CREATE INDEX IF NOT EXISTS idx_synth_results_target
718
+ ON bug_synthesis_results(scope, target_id, created_at DESC);
719
+ CREATE INDEX IF NOT EXISTS idx_synth_results_created
720
+ ON bug_synthesis_results(created_at DESC);
721
+
722
+ -- Incremental-indexing parse cursor. One row per JSONL file (a file may
723
+ -- hold multiple sessions, so the cursor is per-FILE, not per-session).
724
+ -- Lets the watcher stream only newly-appended bytes instead of reparsing
725
+ -- from byte 0. Derived/recomputable state \u2014 NOT subject to the three-layer
726
+ -- durability rule (a wrong/missing cursor just forces a full reparse).
727
+ -- size_bytes and line_count are diagnostic only: decideReadStrategy compares
728
+ -- the LIVE stat size against byte_offset, never the stored size_bytes.
729
+ CREATE TABLE IF NOT EXISTS file_cursor (
730
+ file_path TEXT PRIMARY KEY,
731
+ byte_offset INTEGER NOT NULL,
732
+ size_bytes INTEGER NOT NULL,
733
+ line_count INTEGER NOT NULL DEFAULT 0,
734
+ inode INTEGER,
735
+ prefix_hash TEXT,
736
+ mtime REAL,
737
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
738
+ );
739
+ `;function V(e){let s=e.prepare("PRAGMA table_info(sessions)").all(),t=new Set(s.map(n=>n.name)),r=[["total_input_tokens","INTEGER"],["total_output_tokens","INTEGER"],["total_cache_create_tokens","INTEGER"],["total_cache_read_tokens","INTEGER"],["primary_model","TEXT"],["auto_title","TEXT"],["auto_title_source","TEXT"],["auto_title_generated_at","INTEGER"],["auto_title_history","TEXT"],["verification_status","TEXT"],["verification_computed_at","INTEGER"],["title_quality","TEXT"],["title_quality_computed_at","INTEGER"],["archive_status","TEXT NOT NULL DEFAULT 'live'"],["archived_at","TEXT"],["skipped_reason","TEXT"]];for(let[n,u]of r)t.has(n)||e.exec(`ALTER TABLE sessions ADD COLUMN ${n} ${u}`);let o=e.prepare("PRAGMA table_info(collection_sessions)").all(),G=new Set(o.map(n=>n.name)),w=[["source","TEXT NOT NULL DEFAULT 'manual'"],["rule_id","TEXT"]];for(let[n,u]of w)G.has(n)||e.exec(`ALTER TABLE collection_sessions ADD COLUMN ${n} ${u}`);let i=e.prepare("PRAGMA table_info(session_notes)").all(),de=new Set(i.map(n=>n.name)),Ee=[["auto_synopsis","TEXT"],["auto_synopsis_generated_at","INTEGER"],["auto_synopsis_history","TEXT"]];for(let[n,u]of Ee)de.has(n)||e.exec(`ALTER TABLE session_notes ADD COLUMN ${n} ${u}`);let Te=e.prepare("PRAGMA table_info(threads)").all();new Set(Te.map(n=>n.name)).has("folder_id")||(e.exec("ALTER TABLE threads ADD COLUMN folder_id TEXT REFERENCES thread_folders(id) ON DELETE SET NULL"),e.exec("CREATE INDEX IF NOT EXISTS idx_threads_folder ON threads(folder_id)"));let le=e.prepare("PRAGMA table_info(thread_folders)").all(),j=new Set(le.map(n=>n.name));j.has("project_scope")||(e.exec("ALTER TABLE thread_folders ADD COLUMN project_scope TEXT"),e.exec("CREATE INDEX IF NOT EXISTS idx_thread_folders_project ON thread_folders(project_scope)")),j.has("sort_order")||e.exec("ALTER TABLE thread_folders ADD COLUMN sort_order INTEGER NOT NULL DEFAULT 0"),e.exec(`
740
+ CREATE TABLE IF NOT EXISTS message_embeddings (
741
+ message_uuid TEXT PRIMARY KEY REFERENCES messages(uuid) ON DELETE CASCADE,
742
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
743
+ embedding BLOB NOT NULL,
744
+ embedding_model_id TEXT NOT NULL DEFAULT 'bge-base-en-v1.5',
745
+ embedding_dim INTEGER NOT NULL DEFAULT 768,
746
+ text_length INTEGER NOT NULL,
747
+ generated_at TEXT NOT NULL
748
+ );
749
+ CREATE INDEX IF NOT EXISTS idx_message_embeddings_session ON message_embeddings(session_id);
750
+ CREATE INDEX IF NOT EXISTS idx_message_embeddings_generated ON message_embeddings(generated_at DESC);
751
+ `);let ce=e.prepare("PRAGMA table_info(projects)").all(),ue=new Set(ce.map(n=>n.name)),me=[["repo_root","TEXT"],["main_repo","TEXT"],["is_repo","INTEGER NOT NULL DEFAULT 0"],["is_worktree","INTEGER NOT NULL DEFAULT 0"]];for(let[n,u]of me)ue.has(n)||e.exec(`ALTER TABLE projects ADD COLUMN ${n} ${u}`)}var Re=new Set(["light","full","worker"]);function Q(e,s){let t=s.RECALL_DB_PROFILE;if(t&&Re.has(t))return t;let r=(e??"").split(/[\\/]/).pop()??"";return r==="mcp-server.js"||r==="claude-recall-mcp"?"light":r==="query-worker.js"?"worker":"full"}function $(e){let s=[["busy_timeout",15e3],["journal_size_limit",67108864],["wal_autocheckpoint",1e3]];return e==="full"?[["cache_size",-64e3],["mmap_size",268435456],...s]:e==="worker"?[["cache_size",-16e3],["mmap_size",0],...s]:[["cache_size",-4e3],["mmap_size",0],...s]}import*as J from"sqlite-vec";var z=new WeakSet;function _(e){let s=e;z.has(s)||(J.load(e),z.add(s))}var Ae=`
752
+ CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
753
+ embedding float[768] distance_metric=cosine
754
+ );`;function Z(e){e.prepare("SELECT 1 FROM sqlite_master WHERE name = 'vec_chunks'").get()||(_(e),e.exec(Ae))}var a=null,Oe="full";function ee(){if(a)return a;H();let e=Q(process.argv[1],process.env);Oe=e,a=new Y(K);for(let[s,t]of $(e))a.pragma(`${s} = ${t}`);a.pragma("temp_store = MEMORY"),e!=="light"&&_(a),a.exec(W),V(a),Z(a);try{a.exec("PRAGMA optimize")}catch{}return a}var b={};B(b,{embed:()=>Fe,embedQuery:()=>Pe,getEmbedderStatus:()=>ke,loadEmbedder:()=>xe,unloadEmbedder:()=>Me});import{Worker as we}from"node:worker_threads";import{join as Xe}from"node:path";import{existsSync as ve}from"node:fs";import{existsSync as be}from"node:fs";import{dirname as se}from"node:path";import{fileURLToPath as ye}from"node:url";var g=null;function I(){if(g)return g;let e=se(ye(import.meta.url));for(;!be(`${e}/package.json`);){let s=se(e);if(s===e)throw new Error(`package.json not found walking up from ${import.meta.url}`);e=s}return g=e,g}function f(e){let s=null;return()=>s||(s=(async()=>{try{return await e()}finally{s=null}})(),s)}var R="BAAI/bge-base-en-v1.5";var T=class extends Error{kind;path;underlying;cause;constructor(s){super(Se(s)),this.name="EmbedderUnavailableError",this.kind=s.kind,this.path=s.path,this.underlying=s.underlying,this.cause=s}};function Se(e){return["Semantic search is unavailable on this platform.","",`Reason: ${e.detail}`,"",`Platform: ${process.platform}/${process.arch}, Node ${process.version}`,"","Claude Recall supports macOS (arm64/x64), Linux (x64/arm64), and Windows (x64).","Core CLI features (search, list, context, daemon) work everywhere.","Only `recall semantic *` requires the on-device embedder.","","See: https://clauderecall.com/docs (Supported platforms) - or file an issue at","https://gitlab.com/clauderecallhq/clauderecallhq/-/issues with the platform line above."].join(`
755
+ `)}function Ue(){return Xe(I(),"dist","daemon","embedder-worker.js")}var l=null,N=new Map,C=0,d=!1;function te(e){for(let s of N.values())s.reject(e);N.clear()}function Ce(){if(l)return l;let e=Ue();if(!ve(e))throw new T({kind:"bundle-missing",detail:"embedder-worker bundle not found - run `npm run build:cli` to emit it",path:e});let s=new we(e);return s.on("message",t=>{let r=N.get(t.id);r&&(N.delete(t.id),r.resolve(t))}),s.on("error",t=>{console.error("[embedder-worker] thread error:",t);let r=t instanceof Error?t:new Error(String(t));te(r),l=null,d=!1}),s.on("exit",t=>{t!==0&&(console.error(`[embedder-worker] exited with code ${t}`),te(new Error(`embedder worker exited with code ${t}`))),l=null,d=!1}),l=s,s}function A(e){return new Promise((s,t)=>{let r;try{r=Ce()}catch(o){t(o instanceof Error?o:new Error(String(o)));return}N.set(e.id,{resolve:s,reject:t}),r.postMessage(e)})}function O(){return C=C+1>>>0,String(C)}function x(e){if(!e.ok)throw e.unavailable?new T({kind:"platform-unsupported",detail:"embedder worker reports the runtime is unavailable on this platform",underlying:new Error(e.error)}):new Error(e.error);return e}var De=f(async()=>{x(await A({id:O(),type:"load"}))});async function xe(){if(!(d&&l))try{await De(),d=!0}catch(e){throw d=!1,e}}function ke(){return{loaded:d,modelId:R,dim:768}}async function Fe(e){if(!d)throw new Error("[embedder] Model not loaded. Call loadEmbedder() before embedding.");return x(await A({id:O(),type:"embed",texts:e})).embeddings.map(t=>new Float32Array(t))}async function Pe(e){if(!d)throw new Error("[embedder] Model not loaded. Call loadEmbedder() before embedding.");let s=x(await A({id:O(),type:"embedQuery",text:e}));return new Float32Array(s.embedding)}async function Me(){if(!l){d=!1;return}try{await A({id:O(),type:"unload"})}catch{}d=!1;let e=l;l=null;try{await e.terminate()}catch{}}var P={};B(P,{LLAMACPP_MODEL_ID:()=>ne,MODEL_ID:()=>R,embed:()=>Ve,embedQuery:()=>Qe,getEmbedderStatus:()=>We,loadEmbedder:()=>He,unloadEmbedder:()=>$e});import{Worker as Ge}from"node:worker_threads";import{join as je}from"node:path";import{existsSync as Be}from"node:fs";var ne="BAAI/bge-base-en-v1.5-gguf-q8_0";function Ye(){return je(I(),"dist","daemon","embedder-worker-llamacpp.js")}var c=null,p=new Map,k=0,E=!1;function re(e){for(let s of p.values())s.reject(e);p.clear()}function qe(){if(c)return c;let e=Ye();if(!Be(e))throw new T({kind:"bundle-missing",detail:"embedder-worker-llamacpp bundle not found - run `npm run build:cli` to emit it",path:e});let s=new Ge(e);return s.on("message",t=>{let r=p.get(t.id);r&&(p.delete(t.id),r.resolve(t))}),s.on("error",t=>{console.error("[embedder-worker-llamacpp] thread error:",t);let r=t instanceof Error?t:new Error(String(t));re(r),c=null,E=!1}),s.on("exit",t=>{t!==0&&(console.error(`[embedder-worker-llamacpp] exited with code ${t}`),re(new Error(`embedder worker exited with code ${t}`))),c=null,E=!1}),c=s,s}function y(e){return new Promise((s,t)=>{let r;try{r=qe()}catch(o){t(o instanceof Error?o:new Error(String(o)));return}p.set(e.id,{resolve:s,reject:t}),r.postMessage(e)})}function S(){return k=k+1>>>0,String(k)}function F(e){if(!e.ok)throw e.unavailable?new T({kind:"platform-unsupported",detail:"embedder worker reports the llama.cpp runtime is unavailable on this platform",underlying:new Error(e.error)}):new Error(e.error);return e}var Ke=f(async()=>{F(await y({id:S(),type:"load"}))});async function He(){if(!(E&&c))try{await Ke(),E=!0}catch(e){throw E=!1,e}}function We(){return{loaded:E,modelId:ne,dim:768}}async function Ve(e){if(!E)throw new Error("[embedder] Model not loaded. Call loadEmbedder() before embedding.");return F(await y({id:S(),type:"embed",texts:e})).embeddings.map(t=>new Float32Array(t))}async function Qe(e){if(!E)throw new Error("[embedder] Model not loaded. Call loadEmbedder() before embedding.");let s=F(await y({id:S(),type:"embedQuery",text:e}));return new Float32Array(s.embedding)}async function $e(){if(!c){E=!1;return}try{await y({id:S(),type:"unload"})}catch{}E=!1;let e=c;c=null;try{await e.terminate()}catch{}}function ze(){let e=(process.env.RECALL_EMBEDDER_BACKEND??"").trim().toLowerCase();return e==="llama"||e==="llamacpp"?P:(e===""||e==="onnx"||process.stderr.write(`[embedder] RECALL_EMBEDDER_BACKEND="${e}" is not recognized; falling back to onnx. Valid values: onnx, llama.
756
+ `),b)}var h=ze(),oe=h.loadEmbedder,Ps=h.getEmbedderStatus,Ms=h.embed,ie=h.embedQuery,Gs=h.unloadEmbedder;var M=class extends Error{name="CorpusTooLargeError";code="CORPUS_TOO_LARGE";rowCount;limit;constructor(s,t){super(`semantic search refused: vec_chunks has ${s} rows (cap ${t}). An unindexed kNN over this corpus can pin the SQLite WAL for hours. Workarounds: (a) scope the search to a smaller project, (b) raise the cap via RECALL_KNN_MAX_CORPUS at your own risk, (c) wait for ANN indexing (tracked in the WAL death-spiral plan).`),this.rowCount=s,this.limit=t}};function ae(e,s={}){let t=s.limit??Je()??75e3,o=e.prepare("SELECT COUNT(*) AS n FROM vec_chunks").get()?.n??0;if(o>t)throw new M(o,t)}function Je(){let e=process.env.RECALL_KNN_MAX_CORPUS;if(!e)return;let s=Number(e);if(!(!Number.isFinite(s)||s<=0||s>1e7))return Math.floor(s)}if(!m)throw new Error("queryWorker.entry must run as a worker_threads child");m.on("message",async e=>{if(!Number.isInteger(e.limit)||e.limit<1||e.limit>1e3){m.postMessage({ok:!1,error:`invalid limit: ${e.limit}`});return}let s=typeof e.query=="string"&&e.query.length>0,t=e.precomputedVector!=null&&e.precomputedVector.byteLength>0;if(s===t){m.postMessage({ok:!1,error:"queryWorker requires exactly one of { query, precomputedVector }"});return}try{let r=ee();_(r),ae(r);let o;if(s){await oe();let i=await ie(e.query);o=Buffer.from(i.buffer,i.byteOffset,i.byteLength)}else{let i=e.precomputedVector;o=Buffer.isBuffer(i)?i:Buffer.from(i.buffer,i.byteOffset,i.byteLength)}let w=r.prepare(`SELECT v.rowid, v.distance, cm.session_id, cm.text, cm.message_uuids
757
+ FROM vec_chunks v JOIN chunk_meta cm ON cm.rowid = v.rowid
758
+ WHERE v.embedding MATCH ? AND k = ? ORDER BY v.distance`).all(o,e.limit).map(i=>({sessionId:i.session_id,chunkRowid:i.rowid,distance:i.distance,text:i.text,messageUuids:JSON.parse(i.message_uuids)}));m.postMessage({ok:!0,hits:w})}catch(r){m.postMessage({ok:!1,error:r.message})}});