/* Claude Recall (proprietary). See LICENSE for terms. */
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=`
PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;
PRAGMA foreign_keys = ON;

CREATE TABLE IF NOT EXISTS projects (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  encoded_path TEXT UNIQUE NOT NULL,
  decoded_path TEXT NOT NULL,
  name TEXT NOT NULL,
  repo_root TEXT,
  main_repo TEXT,
  is_repo INTEGER NOT NULL DEFAULT 0,
  is_worktree INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS sessions (
  id TEXT PRIMARY KEY,
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  file_path TEXT NOT NULL,
  file_mtime REAL NOT NULL,
  started_at TEXT,
  ended_at TEXT,
  message_count INTEGER NOT NULL DEFAULT 0,
  user_message_count INTEGER NOT NULL DEFAULT 0,
  assistant_message_count INTEGER NOT NULL DEFAULT 0,
  first_user_message TEXT,
  cwd TEXT,
  git_branch TEXT,
  version TEXT,
  indexed_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_project ON sessions(project_id);
CREATE INDEX IF NOT EXISTS idx_sessions_started ON sessions(started_at DESC);

CREATE TABLE IF NOT EXISTS messages (
  rowid INTEGER PRIMARY KEY AUTOINCREMENT,
  uuid TEXT UNIQUE NOT NULL,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  parent_uuid TEXT,
  type TEXT NOT NULL,
  role TEXT,
  timestamp TEXT,
  is_sidechain INTEGER NOT NULL DEFAULT 0,
  content_text TEXT,
  tool_names TEXT,
  raw_json TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, timestamp);

CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
  content_text,
  tool_names,
  content='messages',
  content_rowid='rowid',
  tokenize = 'porter unicode61'
);

CREATE TRIGGER IF NOT EXISTS messages_ai AFTER INSERT ON messages BEGIN
  INSERT INTO messages_fts(rowid, content_text, tool_names)
  VALUES (new.rowid, new.content_text, new.tool_names);
END;

CREATE TRIGGER IF NOT EXISTS messages_ad AFTER DELETE ON messages BEGIN
  INSERT INTO messages_fts(messages_fts, rowid, content_text, tool_names)
  VALUES ('delete', old.rowid, old.content_text, old.tool_names);
END;

CREATE TRIGGER IF NOT EXISTS messages_au AFTER UPDATE ON messages BEGIN
  INSERT INTO messages_fts(messages_fts, rowid, content_text, tool_names)
  VALUES ('delete', old.rowid, old.content_text, old.tool_names);
  INSERT INTO messages_fts(rowid, content_text, tool_names)
  VALUES (new.rowid, new.content_text, new.tool_names);
END;

-- v0.4.1 \u2014 session aliases. The UUID stays the primary key forever; aliases
-- are a display layer on top. Every edit archives the prior value into
-- previous_aliases (JSON array) \u2014 we never destroy history.
CREATE TABLE IF NOT EXISTS session_aliases (
  session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
  alias TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  previous_aliases TEXT NOT NULL DEFAULT '[]'
);

-- v0.4.2 \u2014 per-session markdown notes.
-- Every save archives the prior content into previous_versions and also mirrors
-- the full note out to ~/.recall/notes/<session_id>.md on disk. Nothing is ever
-- destroyed: an empty string means "cleared" with full history preserved.
CREATE TABLE IF NOT EXISTS session_notes (
  session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
  content TEXT NOT NULL DEFAULT '',
  updated_at TEXT NOT NULL,
  previous_versions TEXT NOT NULL DEFAULT '[]'
);

-- v0.4.3 \u2014 session tags. Many-to-many. Never destructively delete:
-- tag_events is an append-only log of every add and remove across all time,
-- so the full history of what was tagged when and why is always recoverable.
CREATE TABLE IF NOT EXISTS session_tags (
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  tag TEXT NOT NULL,
  created_at TEXT NOT NULL,
  PRIMARY KEY (session_id, tag)
);
CREATE INDEX IF NOT EXISTS idx_session_tags_tag ON session_tags(tag);

CREATE TABLE IF NOT EXISTS tag_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT NOT NULL,
  tag TEXT NOT NULL,
  action TEXT NOT NULL,  -- 'add' | 'remove'
  at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tag_events_session ON tag_events(session_id, at DESC);
CREATE INDEX IF NOT EXISTS idx_tag_events_tag ON tag_events(tag, at DESC);

-- v0.4.5 \u2014 opt-in clipboard archive. This is the ONE table where a user can
-- truly purge a row on demand (not soft-delete), because it may contain
-- accidentally-archived secrets. This carve-out is documented as a deliberate
-- exception to the "never delete" rule specifically for this data class.
CREATE TABLE IF NOT EXISTS paste_archives (
  id TEXT PRIMARY KEY,
  created_at TEXT NOT NULL,
  content TEXT NOT NULL,
  size_bytes INTEGER NOT NULL,
  source TEXT NOT NULL DEFAULT 'cli',  -- 'cli' | 'cli-piped' | 'ui' | \u2026
  label TEXT  -- optional user-supplied short description
);
CREATE INDEX IF NOT EXISTS idx_paste_archives_created ON paste_archives(created_at DESC);

-- v0.8 \u2014 collections. User-curated hand-picked groupings of sessions that
-- cut across the coarse-grained cwd-based "projects". A collection can nest
-- as a tree (parent_id \u2192 null means root). Soft-deletion only: archived_at
-- hides rows from default views, the row itself stays forever.
--
-- Durability: SQLite (this table + collection_events append-only log) plus a
-- plain-text mirror at ~/.recall/collections.json rewritten on every mutation.
CREATE TABLE IF NOT EXISTS collections (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  description TEXT,
  icon TEXT,
  color TEXT,
  parent_id TEXT REFERENCES collections(id) ON DELETE SET NULL,
  sort_key TEXT NOT NULL DEFAULT '',
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  archived_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_collections_parent ON collections(parent_id);
CREATE INDEX IF NOT EXISTS idx_collections_archived ON collections(archived_at);

CREATE TABLE IF NOT EXISTS collection_sessions (
  collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  added_at TEXT NOT NULL,
  note TEXT,
  PRIMARY KEY (collection_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_collection_sessions_session ON collection_sessions(session_id);

CREATE TABLE IF NOT EXISTS collection_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  collection_id TEXT NOT NULL,
  session_id TEXT,
  action TEXT NOT NULL,  -- 'create' | 'rename' | 'describe' | 'recolor' | 'add' | 'remove' | 'archive' | 'restore' | 'move' | 'reorder'
  payload TEXT,          -- JSON context (old_name, new_parent, etc.)
  at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_collection_events_at ON collection_events(at DESC);
CREATE INDEX IF NOT EXISTS idx_collection_events_collection ON collection_events(collection_id, at DESC);

-- v0.11 \u2014 semantic search (Tier 0: shells out to the user's local claude CLI to
-- summarize each session into 3-sentence prose + a keyword set). Both columns
-- are TEXT and feed sessions_fts so a conceptual query can hit this index in
-- addition to the per-message FTS5 index. Pipeline is OFF by default; users opt
-- in via "recall semantic on". Plain-text mirror at ~/.recall/semantic/<id>.json.
CREATE TABLE IF NOT EXISTS session_semantic (
  session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
  summary TEXT NOT NULL DEFAULT '',
  keywords TEXT NOT NULL DEFAULT '',  -- comma-separated
  model TEXT,
  source_message_count INTEGER NOT NULL DEFAULT 0,
  generated_at TEXT NOT NULL
);

CREATE VIRTUAL TABLE IF NOT EXISTS sessions_fts USING fts5(
  summary,
  keywords,
  content='session_semantic',
  content_rowid='rowid',
  tokenize = 'porter unicode61'
);

CREATE TRIGGER IF NOT EXISTS session_semantic_ai AFTER INSERT ON session_semantic BEGIN
  INSERT INTO sessions_fts(rowid, summary, keywords)
  VALUES (new.rowid, new.summary, new.keywords);
END;

CREATE TRIGGER IF NOT EXISTS session_semantic_ad AFTER DELETE ON session_semantic BEGIN
  INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords)
  VALUES ('delete', old.rowid, old.summary, old.keywords);
END;

CREATE TRIGGER IF NOT EXISTS session_semantic_au AFTER UPDATE ON session_semantic BEGIN
  INSERT INTO sessions_fts(sessions_fts, rowid, summary, keywords)
  VALUES ('delete', old.rowid, old.summary, old.keywords);
  INSERT INTO sessions_fts(rowid, summary, keywords)
  VALUES (new.rowid, new.summary, new.keywords);
END;

-- v0.7 \u2014 vector search tier (Pro-only). Chunks are per-conversational-turn
-- segments of messages, embedded via local bge-base-en-v1.5 (768d). The vector
-- data is a derived cache; if lost, it is recomputable from messages.
-- Tables stay empty on Free tier (no model downloaded, no worker started).
--
-- NOTE: the vec_chunks vec0 virtual table is intentionally NOT created here.
-- It lives in src/db/vecLoader.ts (ensureVecChunksTable), loaded lazily so a
-- lean MCP child that only does FTS/metadata never instantiates the native
-- vec0 module. The daemon/worker/CLI call ensureVecChunksTable at open.

CREATE TABLE IF NOT EXISTS chunk_meta (
  rowid INTEGER PRIMARY KEY,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  message_uuids TEXT NOT NULL DEFAULT '[]',
  text TEXT NOT NULL DEFAULT '',
  embedding_model_id TEXT NOT NULL DEFAULT 'bge-base-en-v1.5',
  embedding_dim INTEGER NOT NULL DEFAULT 768,
  stale INTEGER NOT NULL DEFAULT 0,
  generated_at TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_chunk_meta_session ON chunk_meta(session_id);
CREATE INDEX IF NOT EXISTS idx_chunk_meta_stale ON chunk_meta(stale) WHERE stale = 1;

-- chunk_queue action CHECK constraint expanded in v0.78 to include
-- 'pending_post_migration'. New databases accept this value; existing
-- databases retain the older 3-action check (SQLite cannot ALTER a
-- CHECK constraint in place). The Phase 2 migrate command (src/semantic/
-- migrate.ts, TBD) pauses the embedder worker before enqueueing any
-- 'pending_post_migration' rows; on an older DB the watcher (D1) catches
-- the resulting CHECK violation and falls back to 'embed' \u2014 safe because
-- the worker is paused on the migration lock during the window.
CREATE TABLE IF NOT EXISTS chunk_queue (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT NOT NULL,
  message_uuid TEXT,
  action TEXT NOT NULL CHECK (action IN ('embed', 'reembed', 'delete', 'pending_post_migration')),
  enqueued_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_chunk_queue_session ON chunk_queue(session_id);

-- v0.78 \u2014 semantic migration state (Phase 2 of Tier 2 modernization).
-- Tracks the lifecycle of a \`recall semantic migrate\` run: in-progress with
-- a cursor, completed (vec_chunks now holds the new-backend vectors and
-- vec_chunks_v1_backup retains the prior set for 30 days), failed (the
-- partial vec_chunks_v2 is left in place for forensic inspection then
-- discarded by a future migrate retry), or rolled_back (the backup was
-- swapped back in via \`recall semantic rollback-migration\`).
--
-- schema_version lets a future CLI version refuse to resume a cursor it
-- cannot interpret rather than corrupt the migration state silently.
CREATE TABLE IF NOT EXISTS migration_state (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  schema_version INTEGER NOT NULL,
  status TEXT NOT NULL CHECK (status IN (
    'in_progress',
    'paused',
    'completed',
    'rolled_back',
    'failed'
  )),
  started_at TEXT NOT NULL,
  completed_at TEXT,
  lock_taken_at TEXT,
  lock_taken_by_pid INTEGER,
  cursor_session_id TEXT,
  cursor_chunk_id INTEGER,
  model_id_old TEXT NOT NULL,
  model_id_new TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_migration_state_status ON migration_state(status);

-- Belt-and-suspenders: at most one active (in-progress or paused) row.
-- Partial UNIQUE INDEX on schema_version with predicate status IN ('in_progress','paused')
-- means "at most one row per schema_version that is active." Since schema_version
-- is monotonic, this effectively caps active migrations at one regardless of
-- application-layer race conditions.
CREATE UNIQUE INDEX IF NOT EXISTS idx_migration_state_single_active
  ON migration_state(schema_version) WHERE status IN ('in_progress', 'paused');

-- v0.67.0 -- retention/archive (P6). Power-user durability: sessions older
-- than the user chosen window can be moved to messages_archive so the hot
-- DB stays small without dropping data. Sessions metadata stays hot for
-- list/search by title; only the message bodies move. The "recall archive
-- restore <id>" command reverses the move. Source JSONLs are still the
-- immutable ground truth -- the archive table is a fast-path; in extremis
-- a session can be reindexed from its file_path on disk.
--
-- archive_status on the sessions row drives the UI: archived sessions show
-- a badge and a "restore to load full transcript" affordance.
CREATE TABLE IF NOT EXISTS messages_archive (
  uuid TEXT PRIMARY KEY,
  session_id TEXT NOT NULL,
  parent_uuid TEXT,
  type TEXT,
  role TEXT,
  timestamp TEXT,
  is_sidechain INTEGER NOT NULL DEFAULT 0,
  content_text TEXT,
  tool_names TEXT,
  raw_json TEXT,
  archived_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_messages_archive_session ON messages_archive(session_id);

-- v0.67.0 \u2014 semantic enqueue gate. Without this, every INSERT/DELETE/UPDATE
-- on messages enqueued a chunk_queue row, regardless of whether the embedder
-- worker was running. Power users with semantic disabled (the default)
-- accumulated 50M+ orphan rows in days, growing the DB by ~1 GB/day.
-- The gate is a single key in app_settings, kept in sync with
-- ~/.recall/config.json:semantic.enabled by the daemon at boot and on every
-- semantic-config write.
CREATE TABLE IF NOT EXISTS app_settings (
  key TEXT PRIMARY KEY,
  value TEXT NOT NULL
);
INSERT OR IGNORE INTO app_settings(key, value) VALUES ('semantic_enabled', '0');

-- Drop unconditional v0.10/v0.11-era triggers; replaced below with the gated
-- versions. Idempotent: SQLite no-ops when the trigger does not exist, so
-- this is safe to run on every boot and on fresh databases.
--
-- 2026-05-23 \u2014 added IF NOT EXISTS guards on the CREATE side too. Without
-- them, any failure between the DROPs and the CREATEs (e.g. a partially
-- applied prior boot, or a future statement inserted between them) left
-- the live DB with the gated triggers but the next SCHEMA_SQL re-exec
-- still threw "trigger messages_vec_ai already exists". That threw all the
-- way out of getDb(), which caused syncSemanticEnabledToDb to silently
-- skip the gate flip \u2014 and the live (still-present) triggers fired with
-- the prior semantic_enabled='1' value, causing spurious chunk_queue
-- inserts even when semantic search was disabled. Belt and suspenders:
-- drop-then-create-if-not-exists is fully idempotent.
DROP TRIGGER IF EXISTS messages_vec_ai;
DROP TRIGGER IF EXISTS messages_vec_ad;
DROP TRIGGER IF EXISTS messages_vec_au;

CREATE TRIGGER IF NOT EXISTS messages_vec_ai AFTER INSERT ON messages
WHEN new.is_sidechain = 0
  AND new.content_text IS NOT NULL
  AND (SELECT value FROM app_settings WHERE key = 'semantic_enabled') = '1'
BEGIN
  INSERT INTO chunk_queue(session_id, message_uuid, action, enqueued_at)
  VALUES (new.session_id, new.uuid, 'embed', datetime('now'));
END;

CREATE TRIGGER IF NOT EXISTS messages_vec_ad AFTER DELETE ON messages
WHEN (SELECT value FROM app_settings WHERE key = 'semantic_enabled') = '1'
BEGIN
  INSERT INTO chunk_queue(session_id, message_uuid, action, enqueued_at)
  VALUES (old.session_id, old.uuid, 'delete', datetime('now'));
END;

CREATE TRIGGER IF NOT EXISTS messages_vec_au AFTER UPDATE OF content_text ON messages
WHEN new.is_sidechain = 0
  AND (SELECT value FROM app_settings WHERE key = 'semantic_enabled') = '1'
BEGIN
  INSERT INTO chunk_queue(session_id, message_uuid, action, enqueued_at)
  VALUES (new.session_id, new.uuid, 'reembed', datetime('now'));
END;

-- v0.10a \u2014 cost / token analytics. Claude Code already writes per-assistant-
-- message usage + model into the source JSONLs; we persist the raw counts
-- here and derive dollar amounts at render time (pricing changes; token
-- counts don't). message_usage is 1:1 with messages (keyed by message_uuid)
-- so a reindex rebuilds it cleanly; rollup columns on sessions are a
-- cache for fast list rendering.
CREATE TABLE IF NOT EXISTS message_usage (
  message_uuid TEXT PRIMARY KEY REFERENCES messages(uuid) ON DELETE CASCADE,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  model TEXT,
  input_tokens INTEGER NOT NULL DEFAULT 0,
  output_tokens INTEGER NOT NULL DEFAULT 0,
  cache_create_tokens INTEGER NOT NULL DEFAULT 0,
  cache_read_tokens INTEGER NOT NULL DEFAULT 0,
  timestamp TEXT
);
CREATE INDEX IF NOT EXISTS idx_message_usage_session ON message_usage(session_id);
CREATE INDEX IF NOT EXISTS idx_message_usage_model ON message_usage(model);
-- Windowed stats queries (7d / 30d) filter on mu.timestamp >= since.
-- Without this index every refresh full-scans message_usage.
CREATE INDEX IF NOT EXISTS idx_message_usage_timestamp
  ON message_usage(timestamp);
-- Daily-bucket aggregation groups by substr(timestamp, 1, 10) and joins
-- on session_id. The composite covers the common "by session in window"
-- access pattern and lets the engine skip the row lookup for many plans.
CREATE INDEX IF NOT EXISTS idx_message_usage_session_ts
  ON message_usage(session_id, timestamp);

-- v0.10b \u2014 git correlation. For any session whose cwd is a git worktree we
-- run a read-only \`git log\` scoped to that cwd for the [started_at, ended_at]
-- window and record every resulting commit. Composite PK (session_id,
-- commit_sha) lets a single commit belong to multiple sessions (rare, but
-- happens when a long session ends right as another starts). The reverse
-- index on commit_sha powers \`recall blame <sha>\`.
--
-- correlated_at lets the correlator skip sessions it has already processed
-- recently; cwd_snapshot captures the directory we ran git in so stale rows
-- can be identified if the user later points the session elsewhere.
CREATE TABLE IF NOT EXISTS session_commits (
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  commit_sha TEXT NOT NULL,
  committed_at TEXT,
  subject TEXT,
  cwd_snapshot TEXT,
  correlated_at TEXT NOT NULL,
  PRIMARY KEY (session_id, commit_sha)
);
CREATE INDEX IF NOT EXISTS idx_session_commits_sha ON session_commits(commit_sha);
CREATE INDEX IF NOT EXISTS idx_session_commits_session ON session_commits(session_id);
-- v0.13 \u2014 MCP write audit log. Every write tool invocation appends one row,
-- regardless of outcome (ok / error / rate_limited). Tags and collections
-- already have their own append-only event tables (tag_events,
-- collection_events) \u2014 this table covers note/alias writes and provides a
-- single chronological feed across all MCP write activity for review.
CREATE TABLE IF NOT EXISTS mcp_audit_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  tool TEXT NOT NULL,
  args_json TEXT NOT NULL,
  result TEXT NOT NULL,        -- 'ok' | 'error' | 'rate_limited'
  error_message TEXT,
  caller TEXT,
  at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_mcp_audit_events_at ON mcp_audit_events(at DESC);
CREATE INDEX IF NOT EXISTS idx_mcp_audit_events_tool ON mcp_audit_events(tool, at DESC);

-- v0.15 T6 \u2014 auto-collections. Rules match sessions by cwd prefix, project
-- id, tag, plan-file reference, or git branch prefix, and insert auto
-- memberships into collection_sessions (tagged with source='auto' + the
-- originating rule_id). Manual memberships (source='manual') are never
-- touched by the rule engine \u2014 so a user's hand-curated pick always wins.
--
-- Suggestions are the discovery half: the daemon periodically surveys the
-- corpus, detects clusters that *would* make good auto-collections, and
-- stashes them here for the user to accept or dismiss. UNIQUE(type,pattern)
-- means the same cluster won't be re-suggested every scan.
--
-- Durability mirror: ~/.recall/auto-rules/{rules.json,suggestions.json}
-- rewritten on every mutation. Deleting a rule removes ONLY its auto
-- memberships (matched via rule_id); the target collection stays so the
-- user can keep whatever they manually added into it.
CREATE TABLE IF NOT EXISTS auto_collection_rules (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  type TEXT NOT NULL CHECK (type IN ('cwd-prefix','project-id','tag','plan-file','git-branch-prefix')),
  pattern TEXT NOT NULL,
  collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE,
  priority INTEGER NOT NULL DEFAULT 100,
  enabled INTEGER NOT NULL DEFAULT 1,
  created_at TEXT NOT NULL,
  created_by TEXT NOT NULL DEFAULT 'user'  -- 'user' | 'suggestion-accepted' | 'seed'
);
CREATE INDEX IF NOT EXISTS idx_auto_rules_type_enabled ON auto_collection_rules(type, enabled);
CREATE INDEX IF NOT EXISTS idx_auto_rules_collection ON auto_collection_rules(collection_id);

CREATE TABLE IF NOT EXISTS auto_collection_suggestions (
  id TEXT PRIMARY KEY,
  type TEXT NOT NULL,
  pattern TEXT NOT NULL,
  suggested_name TEXT NOT NULL,
  suggested_parent_collection_id TEXT,
  session_count INTEGER NOT NULL,
  detected_at TEXT NOT NULL,
  dismissed INTEGER NOT NULL DEFAULT 0,
  UNIQUE(type, pattern)
);
CREATE INDEX IF NOT EXISTS idx_auto_suggestions_detected ON auto_collection_suggestions(detected_at DESC);

-- v0.15 Threads. The headline intent-grouping primitive: a DAG of sessions
-- connected by shared purpose (one or more origin sessions plus their
-- children). Additive to Projects and Collections; neither is refactored.
-- thread_edges is many-to-many so a session can belong to multiple threads
-- (planning session that seeds two features). parent_session_id is
-- nullable; when null, the row is an origin. confidence = 1.0 for manual
-- edges; < 1.0 for auto-detected edges (v0.15b). source tracks provenance
-- for the audit/undo paths.
CREATE TABLE IF NOT EXISTS threads (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  summary TEXT,
  created_at TEXT NOT NULL,
  closed_at TEXT,
  archived INTEGER NOT NULL DEFAULT 0
);

CREATE TABLE IF NOT EXISTS thread_edges (
  thread_id TEXT NOT NULL,
  session_id TEXT NOT NULL,
  parent_session_id TEXT,
  role TEXT NOT NULL CHECK(role IN ('origin','child')),
  confidence REAL NOT NULL DEFAULT 1.0 CHECK(confidence >= 0 AND confidence <= 1),
  source TEXT NOT NULL DEFAULT 'manual',
  added_at TEXT NOT NULL,
  PRIMARY KEY (thread_id, session_id),
  FOREIGN KEY (thread_id) REFERENCES threads(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_thread_edges_session ON thread_edges(session_id);
CREATE INDEX IF NOT EXISTS idx_thread_edges_parent ON thread_edges(parent_session_id);
CREATE INDEX IF NOT EXISTS idx_thread_edges_thread_role ON thread_edges(thread_id, role);

-- v0.6 #4: user-defined SUBFOLDERS that nest inside the auto-derived
-- per-project folders the Threads sidebar already renders. Critical
-- design rule: auto-project folders are NOT in this table \u2014 they are
-- computed at render time from project membership of each thread's
-- sessions. So even with this table empty, every project folder still
-- renders and every thread still has a visible home. That's the
-- safety property that the previous full-replacement attempt lost
-- (when no user folders existed, every thread fell into a single
-- collapsed "(unfiled)" bucket and the sidebar looked broken).
--
-- project_scope: the project this user folder is nested under. NULL
--   means top-level (cross-project, e.g. "v0.6 Launch" that holds
--   threads from multiple repos).
-- parent_folder_id: another USER folder this one nests inside. NULL
--   means it's a direct child of project_scope's auto folder, or
--   top-level if project_scope is also NULL.
CREATE TABLE IF NOT EXISTS thread_folders (
  id TEXT PRIMARY KEY,
  name TEXT NOT NULL,
  parent_folder_id TEXT REFERENCES thread_folders(id) ON DELETE CASCADE,
  project_scope TEXT,
  created_at TEXT NOT NULL,
  archived INTEGER NOT NULL DEFAULT 0,
  sort_order INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_thread_folders_parent ON thread_folders(parent_folder_id);
CREATE INDEX IF NOT EXISTS idx_thread_folders_project ON thread_folders(project_scope);

-- v0.17 -- recall event log. Every \`recall context\` invocation writes one
-- row. Powers share-card metadata ("recalled today") and monthly wrap
-- aggregation (total recalls, most-recalled session, etc.). Append-only;
-- no deletes. Plain-text mirror at ~/.recall/recall-events.json rewritten
-- on mutation.
CREATE TABLE IF NOT EXISTS recall_events (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
  recalled_at TEXT NOT NULL,
  token_count INTEGER NOT NULL DEFAULT 0,
  mode TEXT NOT NULL DEFAULT 'full',
  caller TEXT NOT NULL DEFAULT 'cli'
);
CREATE INDEX IF NOT EXISTS idx_recall_events_session ON recall_events(session_id, recalled_at DESC);
CREATE INDEX IF NOT EXISTS idx_recall_events_at ON recall_events(recalled_at DESC);

-- v0.18 cog-graph Phase C \u2014 multi-edge schema.
--
-- Two parallel edge systems live side by side and MUST NOT be merged:
--   1. thread_edges (above)  \u2014 hierarchical DAG, intent grouping.
--   2. session_links (below) \u2014 non-hierarchical: citations, semantic
--      similarity, skill tracks, bug-pattern membership, manual wiki
--      links, temporal proximity. Joined at query time; never schema-
--      level merged.
--
-- Every row carries provenance (source + evidence JSON + confidence) so
-- "why does this edge exist?" always has an answer. The unique constraint
-- on (source, target, link_type) is the soft idempotency key \u2014 re-running
-- inference upserts instead of duplicating. Approval gating via
-- session_link_suggestions (the queue) \u2192 session_links (the live store)
-- keeps trust intact: nothing auto-promotes without user opt-in.
--
-- Mirror layout: ~/.recall/links/<source-session-id>.json  (per-source
-- denormalised; rewritten on mutation), ~/.recall/suggestions/index.json
-- (single file rewritten on mutation).
CREATE TABLE IF NOT EXISTS session_links (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  source_session_id TEXT NOT NULL,
  target_session_id TEXT NOT NULL,
  link_type TEXT NOT NULL CHECK(link_type IN (
    'citation','similar','skill_track','bug_pattern',
    'wiki_link','temporal_proximity'
  )),
  confidence REAL NOT NULL CHECK(confidence BETWEEN 0 AND 1),
  source TEXT NOT NULL,
  evidence TEXT NOT NULL,
  approved INTEGER NOT NULL DEFAULT 0,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL,
  UNIQUE(source_session_id, target_session_id, link_type)
);
CREATE INDEX IF NOT EXISTS idx_links_source ON session_links(source_session_id);
CREATE INDEX IF NOT EXISTS idx_links_target ON session_links(target_session_id);
CREATE INDEX IF NOT EXISTS idx_links_type ON session_links(link_type);

-- v0.18 cog-graph Phase C \u2014 per-session structured outputs.
--
-- The "what did this session produce" index. Phase D's LLM-augmented
-- extractor sub-agent populates this row (Phase C ships the empty
-- table). Stays empty in Phase C. extractor_version lets us re-derive
-- when the extraction prompt or schema improves without losing the
-- existing rows. raw_extraction is the full sub-agent output blob so we
-- can re-derive structured fields without re-running extraction.
--
-- Mirror: ~/.recall/output-index/<session-id>.json rewritten on mutation.
CREATE TABLE IF NOT EXISTS session_output_index (
  session_id TEXT PRIMARY KEY,
  files_written TEXT,
  brands_mentioned TEXT,
  terms_introduced TEXT,
  plan_ids_referenced TEXT,
  bug_signatures TEXT,
  raw_extraction TEXT,
  extracted_at TEXT NOT NULL,
  extractor_version INTEGER NOT NULL DEFAULT 1
);

-- v0.18 cog-graph Phase C \u2014 pending edge suggestions awaiting review.
--
-- Auto-inferred edges (from L1/L2/L3/L4 inference jobs) land here first
-- with status='pending'. User accepts \u2192 status='approved' AND a row gets
-- created in session_links with approved=1. User rejects \u2192 status=
-- 'rejected' (tombstone \u2014 prevents re-proposing the same pair from the
-- same inferer). Phase F builds the queue UI; Phase C ships the empty
-- table. The unique key includes inferred_by so two layers (e.g. L2
-- citation match + L3 embedding match) can both contribute evidence
-- without one stomping the other.
CREATE TABLE IF NOT EXISTS session_link_suggestions (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  source_session_id TEXT NOT NULL,
  target_session_id TEXT NOT NULL,
  link_type TEXT NOT NULL,
  confidence REAL NOT NULL,
  evidence TEXT NOT NULL,
  status TEXT NOT NULL DEFAULT 'pending' CHECK(status IN ('pending','approved','rejected')),
  inferred_by TEXT NOT NULL,
  created_at TEXT NOT NULL,
  decided_at TEXT,
  UNIQUE(source_session_id, target_session_id, link_type, inferred_by)
);
CREATE INDEX IF NOT EXISTS idx_suggestions_pending ON session_link_suggestions(status, created_at) WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS idx_suggestions_source ON session_link_suggestions(source_session_id);
CREATE INDEX IF NOT EXISTS idx_suggestions_target ON session_link_suggestions(target_session_id);

-- v0.18 cog-graph Phase C \u2014 bug-pattern clusters. Empty in Phase C;
-- populated by Phase H (HDBSCAN over bug-signature embeddings). Tables
-- ship now so the schema is stable across the milestone. resolved_in_
-- session_id is set when the user marks "this fix worked" \u2014 surfaced
-- the next time a session's bug signature falls into the same cluster.
CREATE TABLE IF NOT EXISTS bug_pattern_clusters (
  id TEXT PRIMARY KEY,
  signature_hash TEXT NOT NULL,
  example_message TEXT NOT NULL,
  occurrence_count INTEGER NOT NULL,
  first_seen_at TEXT NOT NULL,
  last_seen_at TEXT NOT NULL,
  resolved_in_session_id TEXT,
  fix_summary TEXT
);
CREATE INDEX IF NOT EXISTS idx_bug_clusters_signature ON bug_pattern_clusters(signature_hash);
CREATE INDEX IF NOT EXISTS idx_bug_clusters_last_seen ON bug_pattern_clusters(last_seen_at DESC);

CREATE TABLE IF NOT EXISTS bug_pattern_members (
  cluster_id TEXT NOT NULL REFERENCES bug_pattern_clusters(id) ON DELETE CASCADE,
  session_id TEXT NOT NULL,
  matched_at TEXT NOT NULL,
  PRIMARY KEY (cluster_id, session_id)
);
CREATE INDEX IF NOT EXISTS idx_bug_members_session ON bug_pattern_members(session_id);

-- v0.20 / project rollups. The user's mental model is "I have ~18 repos"
-- but Recall's project model is "every cwd is a project" (~32 entries).
-- Macro repos are a deterministic, manual grouping layer the user
-- defines: pick N projects, label them as one logical repo. Display
-- surfaces collapse member projects into the macro repo when the user
-- toggles "by macro repo." See docs/internal/project-rollups.md.
--
-- We deliberately reject auto-grouping: a wrong auto-rollup is worse
-- than no rollup. Membership is always explicit, set by the user.
CREATE TABLE IF NOT EXISTS macro_repos (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  name TEXT NOT NULL UNIQUE,
  description TEXT,
  created_at TEXT NOT NULL,
  updated_at TEXT NOT NULL
);

CREATE TABLE IF NOT EXISTS macro_repo_members (
  macro_repo_id INTEGER NOT NULL REFERENCES macro_repos(id) ON DELETE CASCADE,
  project_id INTEGER NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
  added_at TEXT NOT NULL,
  PRIMARY KEY (macro_repo_id, project_id)
);
CREATE INDEX IF NOT EXISTS idx_macro_repo_members_project ON macro_repo_members(project_id);

-- v0.20 / synthesis result persistence. Every successful Bug Pattern
-- synthesis run writes its full Markdown output here so the user can
-- revisit reports later without re-spending tokens. Keyed by
-- (scope, target_id, mode, created_at) \u2014 multiple runs over time on the
-- same target are kept as a history (newest-first browse). Token spend
-- is recorded for audit. NOT subject to the three-layer durability rule
-- because the source data (bug_pattern_clusters + member sessions) can
-- always re-derive the synthesis.
CREATE TABLE IF NOT EXISTS bug_synthesis_results (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  scope TEXT NOT NULL CHECK(scope IN ('cluster', 'project')),
  target_id TEXT NOT NULL,
  mode TEXT NOT NULL CHECK(mode IN ('synopsis', 'priorities', 'root_cause')),
  model TEXT NOT NULL,
  output_markdown TEXT NOT NULL,
  input_tokens INTEGER NOT NULL DEFAULT 0,
  output_tokens INTEGER NOT NULL DEFAULT 0,
  context_summary TEXT NOT NULL DEFAULT '{}',
  created_at TEXT NOT NULL,
  job_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_synth_results_target
  ON bug_synthesis_results(scope, target_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_synth_results_created
  ON bug_synthesis_results(created_at DESC);

-- Incremental-indexing parse cursor. One row per JSONL file (a file may
-- hold multiple sessions, so the cursor is per-FILE, not per-session).
-- Lets the watcher stream only newly-appended bytes instead of reparsing
-- from byte 0. Derived/recomputable state \u2014 NOT subject to the three-layer
-- durability rule (a wrong/missing cursor just forces a full reparse).
-- size_bytes and line_count are diagnostic only: decideReadStrategy compares
-- the LIVE stat size against byte_offset, never the stored size_bytes.
CREATE TABLE IF NOT EXISTS file_cursor (
  file_path   TEXT PRIMARY KEY,
  byte_offset INTEGER NOT NULL,
  size_bytes  INTEGER NOT NULL,
  line_count  INTEGER NOT NULL DEFAULT 0,
  inode       INTEGER,
  prefix_hash TEXT,
  mtime       REAL,
  updated_at  TEXT NOT NULL DEFAULT (datetime('now'))
);
`;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(`
    CREATE TABLE IF NOT EXISTS message_embeddings (
      message_uuid TEXT PRIMARY KEY REFERENCES messages(uuid) ON DELETE CASCADE,
      session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
      embedding BLOB NOT NULL,
      embedding_model_id TEXT NOT NULL DEFAULT 'bge-base-en-v1.5',
      embedding_dim INTEGER NOT NULL DEFAULT 768,
      text_length INTEGER NOT NULL,
      generated_at TEXT NOT NULL
    );
    CREATE INDEX IF NOT EXISTS idx_message_embeddings_session ON message_embeddings(session_id);
    CREATE INDEX IF NOT EXISTS idx_message_embeddings_generated ON message_embeddings(generated_at DESC);
  `);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=`
CREATE VIRTUAL TABLE IF NOT EXISTS vec_chunks USING vec0(
  embedding float[768] distance_metric=cosine
);`;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(`
`)}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.
`),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
       FROM vec_chunks v JOIN chunk_meta cm ON cm.rowid = v.rowid
       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})}});
