clawmem 0.19.0 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -175,7 +175,7 @@ compositeScore = (0.50·searchScore + 0.25·recencyScore + 0.25·confidenceScore
175
175
  - **Vector search empty but BM25 works** → missing embeddings (the watcher indexes but does NOT embed). Run `clawmem embed`.
176
176
  - **`intent_search` weak for WHY/ENTITY** → sparse graph. Run `build_graphs`. Don't run it after every reindex (A-MEM links per-doc automatically).
177
177
  - **Rankings look RRF-flat / reranker suspect** → `clawmem rerank-health`. A mis-served reranker (e.g. a GGUF that drops the score head) returns HTTP 200 but inert scores, silently collapsing ranking to RRF.
178
- - **Intermittent `UserPromptSubmit hook timed out after 8s — output discarded`** → **fixed in v0.16.0** (upgrade). Root cause was not inference or host RAM alone: the `context-surfacing` vector leg ran a *synchronous* `sqlite-vec` scan that the `Promise.race(vectorTimeout)` guard could not bound (a synchronous call blocks the event loop, so the timer never fires), and every writable hook open ran an unconditional backfill `UPDATE` that could wait out `busy_timeout` under writer contention. v0.16.0 bounds both vector legs with real deadlines + timer cleanup, read-guards the init backfill, caps the init `busy_timeout`, and adds a watcher-side vector prewarm. A cold OS page cache still adds latency to the first post-boot call, so RAM headroom helps the margin — but on a large vault the scan cost, not RAM, was the dominant trigger. Still seeing it after upgrading? Raise the hook `timeout` in `~/.claude/settings.json` (8s default; no CLI knob) as a secondary margin. Detail: [docs/troubleshooting.md](docs/troubleshooting.md).
178
+ - **Intermittent `UserPromptSubmit hook timed out after 8s — output discarded`** → **fixed in v0.16.0** (upgrade). Root cause was not inference or host RAM alone: the `context-surfacing` vector leg ran a *synchronous* `sqlite-vec` scan that the `Promise.race(vectorTimeout)` guard could not bound (a synchronous call blocks the event loop, so the timer never fires), and every writable hook open ran an unconditional backfill `UPDATE` that could wait out `busy_timeout` under writer contention. v0.16.0 bounds both vector legs with real deadlines + timer cleanup, read-guards the init backfill, caps the init `busy_timeout`, and adds a watcher-side vector prewarm. **v0.20.0** closes the residual with an opt-in vector-query daemon (run `clawmem watch`): it runs the blocking sqlite-vec scan off the hook's event loop, so a cold scan times out fast and falls back to FTS instead of blocking the turn — a true hard cap, not just a probability reduction; when the watcher isn't running, behavior is unchanged. A cold OS page cache still adds latency to the first post-boot call, so RAM headroom helps the margin — but on a large vault the scan cost, not RAM, was the dominant trigger. Still seeing it after upgrading? Raise the hook `timeout` in `~/.claude/settings.json` (8s default; no CLI knob) as a secondary margin. Detail: [docs/troubleshooting.md](docs/troubleshooting.md).
179
179
  - **Anything setup-shaped** (download blocked, server unreachable, watcher memory bloat, indexer bugs) → [docs/troubleshooting.md](docs/troubleshooting.md).
180
180
 
181
181
  ---
package/README.md CHANGED
@@ -85,7 +85,7 @@ Full version history is in [RELEASE_NOTES.md](RELEASE_NOTES.md). Upgrade instruc
85
85
  - [Claude Code](https://docs.anthropic.com/en/docs/claude-code) — for hooks + MCP integration
86
86
  - [OpenClaw](https://github.com/openclaw/openclaw) — for native plugin integration
87
87
  - [Hermes Agent](https://github.com/NousResearch/hermes-agent) — for `MemoryProvider` plugin integration
88
- - [bd CLI](https://github.com/dolthub/dolt) v0.58.0+ — for Beads issue tracker sync (only if using Beads)
88
+ - [bd CLI](https://github.com/gastownhall/beads) v0.58.0+ (verified through v1.1.0) — for Beads issue tracker sync (only if using Beads)
89
89
 
90
90
  ### Install from npm (recommended)
91
91
 
@@ -572,7 +572,7 @@ Registered by `clawmem setup mcp`. Available to any MCP-compatible client.
572
572
 
573
573
  | Tool | Description |
574
574
  |---|---|
575
- | `beads_sync` | Sync Beads issues from Dolt backend (`bd` CLI) into memory: creates docs, bridges all dep types to `memory_relations`, runs A-MEM enrichment |
575
+ | `beads_sync` | Sync Beads issues from Dolt backend (`bd` CLI) into memory: syncs the full backlog (no 50-issue cap), creates docs, bridges all dep types to `memory_relations`, surfaces bd ≥1.1.0 claim leases, runs A-MEM enrichment |
576
576
 
577
577
  ### Vault Management
578
578
 
@@ -280,6 +280,8 @@ Sync Beads issues from Dolt backend into the search index.
280
280
  |-------|------|---------|-------------|
281
281
  | `project_path` | string | cwd | Path to project with `.beads/` directory |
282
282
 
283
+ Runs `bd list --json --limit 0` — the full backlog, not bd's default 50-issue page (v0.20.1). The spawned call disables bd usage metrics (`BD_DISABLE_METRICS=1`, ignored by pre-1.1.0 binaries). Issues carrying bd ≥1.1.0 claim-lease fields render a `**Claim Lease**` line in the indexed document (v0.20.1); upstream's removed `quality_score` field is no longer parsed.
284
+
283
285
  ## Vault management
284
286
 
285
287
  ### list_vaults
@@ -136,7 +136,7 @@ Common issues when running ClawMem with hooks, MCP server, or OpenClaw plugin. O
136
136
  - If the error persists after v0.1.8: restart the watcher to clear accumulated state (`systemctl --user restart clawmem-watcher.service`). Check `systemctl --user status clawmem-watcher.service` for memory usage — healthy is under 100MB, bloated is 400MB+.
137
137
  - **v0.2.4 fix:** Hook's SQLite `busy_timeout` was 500ms — too tight. During A-MEM enrichment or heavy indexing, the watcher can hold write locks for 500ms+, causing the hook's DB open to fail with SQLITE_BUSY. Raised to 5000ms (matches MCP server). The hook's 8s outer timeout still leaves 3s for actual work after a 5s busy wait.
138
138
  - **v0.3.1 fix:** Shell `timeout` wrappers (e.g., `timeout 8 clawmem hook context-surfacing`) kill the process with exit 124 and no stderr — Claude Code reports "Failed with non-blocking status code: No stderr output". This affects all hook events (UserPromptSubmit, Stop, SessionStart, PreCompact), not just Stop hooks. Fix: Remove shell `timeout` from all hook commands and use Claude Code's native `timeout` property instead. Run `clawmem setup hooks` to reinstall with correct config (v0.3.1+), or manually update `~/.claude/settings.json` — see [setup-hooks](guides/setup-hooks.md).
139
- - **Large vault + intermittent hook timeout (`timed out after 8s`) — FIXED in v0.16.0.** Earlier this was diagnosed as pure cold-start (fresh Bun process, opening a large `index.sqlite`, re-reading evicted index pages) with "give the host more RAM" as the durable fix — but the dominant causes were two code-level defects: (1) the `context-surfacing` vector leg ran a *synchronous* `sqlite-vec` scan that the `Promise.race(vectorTimeout)` guard could not bound (a synchronous call blocks the event loop, so the timer never fires), and (2) every writable hook open ran an unconditional backfill `UPDATE` that could wait out `busy_timeout` under writer contention. **v0.16.0 fixes both:** `searchVec` takes a real wall-clock deadline and self-aborts before the blocking scan; both vector legs race the embed against the remaining budget and clear their timers; the init backfill is read-guarded and the init `busy_timeout` is capped to the caller's value; and the watcher prewarms the sqlite-vec payload into the page cache on startup (embed-independent, watcher-only). A cold page cache still adds latency to the genuine first post-boot call, so host RAM headroom + the prewarm help the margin — but on a large vault the scan cost, not RAM, was the trigger. A modest `timeout` bump (see the tradeoffs table under *Hooks slow or near timeout*) remains a secondary margin. The `deep` profile additionally reranks (extra remote round-trips), widening the cold-call window; `balanced` (default) does not rerank.
139
+ - **Large vault + intermittent hook timeout (`timed out after 8s`) — FIXED in v0.16.0.** Earlier this was diagnosed as pure cold-start (fresh Bun process, opening a large `index.sqlite`, re-reading evicted index pages) with "give the host more RAM" as the durable fix — but the dominant causes were two code-level defects: (1) the `context-surfacing` vector leg ran a *synchronous* `sqlite-vec` scan that the `Promise.race(vectorTimeout)` guard could not bound (a synchronous call blocks the event loop, so the timer never fires), and (2) every writable hook open ran an unconditional backfill `UPDATE` that could wait out `busy_timeout` under writer contention. **v0.16.0 fixes both:** `searchVec` takes a real wall-clock deadline and self-aborts before the blocking scan; both vector legs race the embed against the remaining budget and clear their timers; the init backfill is read-guarded and the init `busy_timeout` is capped to the caller's value; and the watcher prewarms the sqlite-vec payload into the page cache on startup (embed-independent, watcher-only). A cold page cache still adds latency to the genuine first post-boot call, so host RAM headroom + the prewarm help the margin — but on a large vault the scan cost, not RAM, was the trigger. A modest `timeout` bump (see the tradeoffs table under *Hooks slow or near timeout*) remains a secondary margin. The `deep` profile additionally reranks (extra remote round-trips), widening the cold-call window; `balanced` (default) does not rerank. **v0.20.0** adds the true hard cap: run `clawmem watch` and the hook sends the query to the watcher, which runs the blocking scan off the hook's event loop and returns the raw matches for local hydration — a cold scan then times out fast and falls back to FTS instead of blocking the turn. It is a pure optimization layer: when the watcher isn't running the hook uses the in-process, deadline-bounded path unchanged.
140
140
 
141
141
  **Watcher memory bloat (400MB+)**
142
142
  - The watcher accumulates memory when processing high-frequency file change events. The most common trigger was Claude Code session transcript `.jsonl` files changing on every keystroke during active conversations. Each event opened the database briefly, and over hours of active use, memory grew to 400-800MB.
@@ -222,7 +222,7 @@ Common issues when running ClawMem with hooks, MCP server, or OpenClaw plugin. O
222
222
 
223
223
  The timeout applies per invocation. A slow first prompt (cold start) doesn't mean subsequent prompts will be slow — Bun caches modules after the first load, and `node-llama-cpp` model files are cached on disk after the first download. Subsequent prompts in the same session are typically faster.
224
224
 
225
- **Exception — large vaults (intermittent, pre-v0.16.0):** before v0.16.0 the hook could time out on *certain* turns (not just the first) because of an unbounded synchronous `sqlite-vec` scan plus an init-time write-lock wait — see *"UserPromptSubmit hook error" (intermittent)* above. **Upgrade to v0.16.0**, which bounds the scan and the init path and prewarms the cache. Host RAM headroom + the watcher prewarm still help the genuine cold-call margin, but they were not the root cause.
225
+ **Exception — large vaults (intermittent, pre-v0.16.0):** before v0.16.0 the hook could time out on *certain* turns (not just the first) because of an unbounded synchronous `sqlite-vec` scan plus an init-time write-lock wait — see *"UserPromptSubmit hook error" (intermittent)* above. **Upgrade to v0.16.0**, which bounds the scan and the init path and prewarms the cache (v0.20.0 adds the vector-query daemon — a true hard cap on the cold scan when the watcher runs). Host RAM headroom + the watcher prewarm still help the genuine cold-call margin, but they were not the root cause.
226
226
 
227
227
  **Stop hooks** (`decision-extractor`, `handoff-generator`, `feedback-loop`) default to 30s (v0.3.1+, was 10s prior) because they run LLM inference (observer model). These run at session end, so latency doesn't block the user.
228
228
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.19.0",
3
+ "version": "0.20.2",
4
4
  "description": "On-device memory layer for AI agents. Claude Code, OpenClaw, and Hermes. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/beads.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import { existsSync } from "node:fs";
11
11
  import { join } from "node:path";
12
- import { execSync } from "node:child_process";
12
+ import { execFileSync, execSync } from "node:child_process";
13
13
 
14
14
  // =============================================================================
15
15
  // Types (matches bd list --json output: IssueWithCounts)
@@ -43,7 +43,8 @@ export interface BeadsIssue {
43
43
  metadata?: Record<string, unknown>;
44
44
  labels?: string[];
45
45
  dependencies?: BeadsDependency[];
46
- quality_score?: number;
46
+ lease_expires_at?: string;
47
+ heartbeat_at?: string;
47
48
  // Computed counts from bd list --json
48
49
  dependency_count?: number;
49
50
  dependent_count?: number;
@@ -92,11 +93,13 @@ function runBd(projectDir: string, args: string[], timeoutMs = 10000): string |
92
93
  }
93
94
 
94
95
  try {
95
- return execSync(`${bd} ${args.join(" ")}`, {
96
+ return execFileSync(bd, args, {
96
97
  cwd: projectDir,
97
98
  encoding: "utf-8",
98
99
  timeout: timeoutMs,
99
- env: { ...process.env },
100
+ // No shell interpolation of args; bd usage metrics + event flush stay off
101
+ // (default-on with a remote endpoint as of bd 1.1.0).
102
+ env: { ...process.env, BD_DISABLE_METRICS: "1", BD_DISABLE_EVENT_FLUSH: "1" },
100
103
  stdio: ["pipe", "pipe", "pipe"],
101
104
  });
102
105
  } catch (err: any) {
@@ -114,7 +117,7 @@ function runBd(projectDir: string, args: string[], timeoutMs = 10000): string |
114
117
  * Returns parsed issues with labels and dependencies populated.
115
118
  */
116
119
  export function queryBeadsList(projectDir: string): BeadsIssue[] {
117
- const output = runBd(projectDir, ["list", "--json"]);
120
+ const output = runBd(projectDir, ["list", "--json", "--limit", "0"]);
118
121
  if (!output) return [];
119
122
 
120
123
  try {
@@ -158,7 +161,8 @@ function normalizeBeadsIssue(raw: any): BeadsIssue {
158
161
  metadata: raw.metadata,
159
162
  labels: raw.labels || [],
160
163
  dependencies: deps,
161
- quality_score: raw.quality_score,
164
+ lease_expires_at: raw.lease_expires_at,
165
+ heartbeat_at: raw.heartbeat_at,
162
166
  dependency_count: raw.dependency_count,
163
167
  dependent_count: raw.dependent_count,
164
168
  comment_count: raw.comment_count,
@@ -213,7 +217,7 @@ export function formatBeadsIssueAsMarkdown(issue: BeadsIssue): string {
213
217
  }
214
218
  if (issue.blocks && issue.blocks.length > 0) lines.push(`**Blocks**: ${issue.blocks.join(", ")}`);
215
219
  if (issue.external_ref) lines.push(`**External Ref**: ${issue.external_ref}`);
216
- if (issue.quality_score != null) lines.push(`**Quality Score**: ${issue.quality_score}`);
220
+ if (issue.lease_expires_at) lines.push(`**Claim Lease**: expires ${issue.lease_expires_at}`);
217
221
 
218
222
  if (issue.description) {
219
223
  lines.push("", "## Description", "", issue.description);
package/src/clawmem.ts CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  VecModelMismatchError,
28
28
  EmbedLeaseLostError,
29
29
  } from "./store.ts";
30
+ import { startVectorDaemon, type VectorDaemonHandle } from "./vector-daemon.ts";
30
31
  import {
31
32
  getDefaultLlamaCpp,
32
33
  setDefaultLlamaCpp,
@@ -96,9 +97,9 @@ enableProductionMode();
96
97
 
97
98
  let store: Store | null = null;
98
99
 
99
- function getStore(): Store {
100
+ function getStore(busyTimeout: number = 5000): Store {
100
101
  if (!store) {
101
- store = createStore(undefined, { busyTimeout: 5000 });
102
+ store = createStore(undefined, { busyTimeout });
102
103
  }
103
104
  return store;
104
105
  }
@@ -1087,7 +1088,10 @@ async function cmdHook(args: string[]) {
1087
1088
  if (!hookName) die("Usage: clawmem hook <name>");
1088
1089
 
1089
1090
  const input = await readHookInput();
1090
- const s = getStore();
1091
+ // Open the store capped from the START for the context-surfacing hook (not just after open via the
1092
+ // PRAGMA below) so a contended init cannot wait the full 5000ms default before it is narrowed. Other
1093
+ // hooks (Stop-lane, 30s budget) keep the 5000ms default.
1094
+ const s = getStore(hookName === "context-surfacing" ? CONTEXT_SURFACING_WRITE_BUSY_TIMEOUT_MS : 5000);
1091
1095
  let output: HookOutput;
1092
1096
 
1093
1097
  try {
@@ -1848,6 +1852,7 @@ async function cmdWatch() {
1848
1852
  let watcherHandle: { close: () => void } | null = null;
1849
1853
  let checkpointTimerHandle: Timer | null = null;
1850
1854
  let prewarmTimerHandle: ReturnType<typeof setInterval> | null = null;
1855
+ let vectorDaemonHandle: VectorDaemonHandle | null = null;
1851
1856
 
1852
1857
  // Graceful shutdown — stop workers, close watchers, then exit. SIGTERM
1853
1858
  // handling is critical for systemd `systemctl --user stop` to shut down
@@ -1863,6 +1868,12 @@ async function cmdWatch() {
1863
1868
  clearInterval(prewarmTimerHandle);
1864
1869
  prewarmTimerHandle = null;
1865
1870
  }
1871
+ // Stop the vector daemon early — before the awaited worker drain below — so no new socket-driven
1872
+ // scan starts mid-shutdown. close() stops the listener and unlinks the socket file.
1873
+ if (vectorDaemonHandle) {
1874
+ vectorDaemonHandle.close();
1875
+ vectorDaemonHandle = null;
1876
+ }
1866
1877
  if (stopHeavyLane) {
1867
1878
  await stopHeavyLane();
1868
1879
  stopHeavyLane = null;
@@ -1940,6 +1951,11 @@ async function cmdWatch() {
1940
1951
  console.log(`${c.dim}[watch] periodic vector prewarm every ${Math.round(prewarmIntervalMs / 1000)}s${c.reset}`);
1941
1952
  }
1942
1953
 
1954
+ // BACKLOG Source 46: vector-query daemon — HARD cap on the cold synchronous MATCH. Runs Step 1 off
1955
+ // the hook's event loop on this long-lived watcher; the hook connects only when the socket exists,
1956
+ // so it is a pure optimization layer (null on bind failure → the hook keeps its in-process fallback).
1957
+ vectorDaemonHandle = await startVectorDaemon(s, (msg) => console.log(`${c.dim}${msg}${c.reset}`));
1958
+
1943
1959
  watcherHandle = startWatcher(dirs, {
1944
1960
  debounceMs: 2000,
1945
1961
  onChanged: async (fullPath, event) => {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import type { Store, SearchResult } from "../store.ts";
10
10
  import { DEFAULT_EMBED_MODEL, DEFAULT_QUERY_MODEL, DEFAULT_RERANK_MODEL, warnOnceOnVectorModelMismatch, extractSnippet, resolveStore } from "../store.ts";
11
+ import { searchVecBounded } from "../vector-daemon.ts";
11
12
  import { getVaultPath, getActiveProfile } from "../config.ts";
12
13
  import type { HookInput, HookOutput } from "../hooks.ts";
13
14
  import {
@@ -205,7 +206,11 @@ export async function contextSurfacing(
205
206
  // deadline makes searchVec self-abort before the blocking MATCH, so a slow embed cannot let
206
207
  // an already-timed-out vector leg resume and re-block the hook after it fell back to FTS.
207
208
  const vectorDeadline = Date.now() + profile.vectorTimeout;
208
- const vectorPromise = store.searchVec(retrievalQuery, DEFAULT_EMBED_MODEL, maxResults, undefined, undefined, undefined, vectorDeadline);
209
+ // searchVecBounded runs Step 1 (the blocking MATCH) in the vector daemon when it is live, so the
210
+ // Promise.race timer below can ACTUALLY fire (this event loop stays free during the scan). When the
211
+ // daemon is absent it falls back to the in-process searchVec unchanged; when the daemon is
212
+ // busy/errors it returns [] and we drop to FTS below.
213
+ const vectorPromise = searchVecBounded(store, retrievalQuery, DEFAULT_EMBED_MODEL, maxResults, undefined, undefined, undefined, vectorDeadline);
209
214
  const timeoutPromise = new Promise<SearchResult[]>((_, reject) => {
210
215
  vectorTimer = setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout);
211
216
  });
@@ -305,7 +310,7 @@ export async function contextSurfacing(
305
310
  if (remainingMs <= 0) break;
306
311
  let deepTimer: ReturnType<typeof setTimeout> | undefined;
307
312
  try {
308
- const deepVec = store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 5, undefined, undefined, undefined, startTime + 6000);
313
+ const deepVec = searchVecBounded(store, eq.query, DEFAULT_EMBED_MODEL, 5, undefined, undefined, undefined, startTime + 6000);
309
314
  const deepTimeout = new Promise<SearchResult[]>((_, reject) => {
310
315
  deepTimer = setTimeout(() => reject(new Error("vector timeout")), remainingMs);
311
316
  });
package/src/store.ts CHANGED
@@ -3519,7 +3519,13 @@ function assertQueryEmbedModelConsistent(db: Database, endpointModel: string): v
3519
3519
  verifiedQueryEmbedModels.set(db, { dataVersion, model: endpointModel });
3520
3520
  }
3521
3521
 
3522
- export async function searchVec(db: Database, query: string, model: string, limit: number = 20, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, deadlineMs?: number): Promise<SearchResult[]> {
3522
+ // Step 1 of vector search the expensive, off-loadable half: embed the query, guard the wall-clock
3523
+ // deadline, then run the SYNCHRONOUS sqlite-vec MATCH. Returns raw {hash_seq, distance} hits;
3524
+ // collection/date filtering is a Step-2 concern. Split out (BACKLOG Source 46) so the vector-query
3525
+ // daemon can run JUST this half on the long-lived watcher — keeping the blocking MATCH off the hook's
3526
+ // event loop — while the hook hydrates locally via hydrateVecResults(). In-process searchVec() below
3527
+ // composes the two, so its public contract is unchanged.
3528
+ export async function searchVecMatch(db: Database, query: string, model: string, limit: number = 20, deadlineMs?: number): Promise<{ hash_seq: string; distance: number }[]> {
3523
3529
  const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
3524
3530
  if (!tableExists) return [];
3525
3531
 
@@ -3546,12 +3552,17 @@ export async function searchVec(db: Database, query: string, model: string, limi
3546
3552
  // See: https://github.com/tobi/qmd/pull/23
3547
3553
 
3548
3554
  // Step 1: Get vector matches from sqlite-vec (no JOINs allowed)
3549
- const vecResults = db.prepare(`
3555
+ return db.prepare(`
3550
3556
  SELECT hash_seq, distance
3551
3557
  FROM vectors_vec
3552
3558
  WHERE embedding MATCH ? AND k = ?
3553
3559
  `).all(new Float32Array(embedding), limit * 3) as { hash_seq: string; distance: number }[];
3560
+ }
3554
3561
 
3562
+ // Step 2 of vector search — the cheap, local half: hydrate raw {hash_seq, distance} hits into
3563
+ // SearchResult[] via indexed JOINs, collection/date filtering, and per-doc dedup. Pure primary-key
3564
+ // SQLite lookups — safe to run in the short-lived hook process even when Step 1 ran in the daemon.
3565
+ export function hydrateVecResults(db: Database, vecResults: { hash_seq: string; distance: number }[], limit: number = 20, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }): SearchResult[] {
3555
3566
  if (vecResults.length === 0) return [];
3556
3567
 
3557
3568
  // Step 2: Get chunk info and document data
@@ -3635,6 +3646,14 @@ export async function searchVec(db: Database, query: string, model: string, limi
3635
3646
  });
3636
3647
  }
3637
3648
 
3649
+ // In-process vector search — Step 1 (MATCH) + Step 2 (hydrate) composed. Public contract unchanged;
3650
+ // the daemon-backed hook path (context-surfacing) instead calls searchVecMatch (in the daemon) +
3651
+ // hydrateVecResults (locally), so the blocking MATCH never runs on the hook's event loop.
3652
+ export async function searchVec(db: Database, query: string, model: string, limit: number = 20, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, deadlineMs?: number): Promise<SearchResult[]> {
3653
+ const vecResults = await searchVecMatch(db, query, model, limit, deadlineMs);
3654
+ return hydrateVecResults(db, vecResults, limit, collectionId, collections, dateRange);
3655
+ }
3656
+
3638
3657
  // =============================================================================
3639
3658
  // Embeddings
3640
3659
  // =============================================================================
@@ -0,0 +1,320 @@
1
+ /**
2
+ * Vector-query daemon (BACKLOG Source 46) — HARD cap on the cold synchronous sqlite-vec MATCH.
3
+ *
4
+ * The context-surfacing UserPromptSubmit hook runs a SYNCHRONOUS sqlite-vec MATCH (searchVecMatch).
5
+ * bun:sqlite exposes no interrupt/progress handler, so a cold scan on a large vault blocks the hook's
6
+ * single event loop past its 8-15s budget — an in-thread `Promise.race(vectorTimeout)` cannot fire
7
+ * while a synchronous call runs. This daemon relocates JUST Step 1 (the MATCH) onto the long-lived
8
+ * watcher process: the hook sends the query string over a per-vault unix socket and races the reply
9
+ * against a REAL setTimeout (its own event loop is now free), then hydrates locally (Step 2).
10
+ *
11
+ * The daemon is a strict OPTIMIZATION LAYER, never a dependency:
12
+ * - daemon absent/refused → the hook uses the in-process searchVec path UNCHANGED (today's behavior;
13
+ * users who don't run the watcher lose nothing).
14
+ * - daemon busy/error/timeout → the hook returns [] and falls back to FTS (it must NOT re-run the scan
15
+ * in-process, which would reintroduce the very block this exists to avoid).
16
+ *
17
+ * Design record: BACKLOG.md Source 46 "DESIGN-gate outcome + build contract" (2026-07-05, codex-cleared).
18
+ */
19
+
20
+ import { existsSync, mkdirSync, chmodSync, rmSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { tmpdir } from "node:os";
23
+ import { createHash } from "node:crypto";
24
+ import type { Socket } from "bun";
25
+ import { searchVecMatch, hydrateVecResults, VecReadModelMismatchError, type Store, type SearchResult } from "./store.ts";
26
+
27
+ // Newline-delimited JSON, one request/response per connection. A query string and a list of
28
+ // {hash_seq, distance} are both small; anything over this cap is a protocol violation, so both sides
29
+ // reject it rather than buffer unbounded on a runaway/hostile peer.
30
+ const MAX_FRAME_BYTES = 256 * 1024;
31
+ const DEFAULT_IPC_TIMEOUT_MS = 5000;
32
+ // A stray/hostile `limit` (same-user socket) must not become an unbounded `k = limit * 3` scan. The
33
+ // hook only ever asks for ~5-10; clamp anything outside a sane range.
34
+ const MAX_VEC_LIMIT = 500;
35
+
36
+ // Env-gated phase timing (CLAWMEM_VEC_TIMING=1): logs each hook vector leg's outcome + elapsed to
37
+ // stderr so a future `UserPromptSubmit hook timed out` is attributable — daemon engaged & fast, vs
38
+ // daemon-absent in-process fallback (the old slow path), vs daemon busy/error → FTS. Off by default.
39
+ const VEC_TIMING = process.env.CLAWMEM_VEC_TIMING === "1" || process.env.CLAWMEM_VEC_TIMING === "true";
40
+
41
+ type VecHit = { hash_seq: string; distance: number };
42
+ type VecReq = { query: string; model: string; limit: number; deadlineMs?: number };
43
+ type VecResp = { results: VecHit[] } | { error: string; storedModels?: string[]; activeModel?: string };
44
+
45
+ // ─────────────────────────────────────────────────────────────────────────────
46
+ // Socket path — shared by daemon + hook client, keyed per vault DB path
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ /** Parent dir for all clawmem daemon sockets ($XDG_RUNTIME_DIR/clawmem, fallback tmpdir). */
50
+ export function vecDaemonSocketDir(): string {
51
+ const base = process.env.XDG_RUNTIME_DIR || tmpdir();
52
+ return join(base, "clawmem");
53
+ }
54
+
55
+ /**
56
+ * Per-vault socket path, keyed by a short hash of the vault DB path so multiple vaults
57
+ * (general/work/personal) never collide on one socket. The daemon and the hook client both derive
58
+ * the SAME path from the SAME `store.dbPath`, so they rendezvous without any shared registry.
59
+ */
60
+ export function vecDaemonSocketPath(dbPath: string): string {
61
+ const key = createHash("sha256").update(dbPath).digest("hex").slice(0, 16);
62
+ return join(vecDaemonSocketDir(), `vec-${key}.sock`);
63
+ }
64
+
65
+ /** Brief liveness probe: true if a listener accepts a connection on `sockPath`, false if refused/timeout. */
66
+ function isSocketAlive(sockPath: string): Promise<boolean> {
67
+ return new Promise<boolean>((resolve) => {
68
+ let done = false;
69
+ const finish = (v: boolean) => { if (done) return; done = true; clearTimeout(timer); resolve(v); };
70
+ const timer = setTimeout(() => finish(false), 250);
71
+ Bun.connect<undefined>({
72
+ unix: sockPath,
73
+ socket: {
74
+ open(s) { try { s.end(); } catch { /* ignore */ } finish(true); },
75
+ connectError() { finish(false); },
76
+ error() { finish(false); },
77
+ },
78
+ }).catch(() => finish(false));
79
+ });
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────
83
+ // Daemon (server) — hosted in the long-lived watcher (cmdWatch)
84
+ // ─────────────────────────────────────────────────────────────────────────────
85
+
86
+ export type VectorDaemonHandle = { close: () => void };
87
+
88
+ /**
89
+ * Start the vector-query daemon on `store`'s DB. Returns a handle whose close() stops the listener and
90
+ * unlinks the socket, or null if the socket could not be bound (best-effort — the hook's in-process
91
+ * fallback still works, so a bind failure degrades to today's behavior, it never breaks the hook).
92
+ *
93
+ * SINGLE-FLIGHT: at most one MATCH runs at a time per vault. A request arriving while a scan is in
94
+ * flight gets {error:"busy"} immediately (→ hook falls to FTS), never queues — this is what stops
95
+ * abandoned cold scans from serializing and starving the watcher. Paired with a deadline check on
96
+ * receipt: a request whose deadline already elapsed is rejected WITHOUT scanning, so requests that
97
+ * piled up behind a cold-scan freeze are dropped (their hooks already gave up) instead of each
98
+ * triggering a fresh wasted scan.
99
+ */
100
+ export async function startVectorDaemon(
101
+ store: Store,
102
+ log: (msg: string) => void = () => {},
103
+ // The Step-1 scan is injectable so tests can exercise the socket/single-flight/framing logic without
104
+ // a live embedding server. Production omits it → the real searchVecMatch on the watcher's warm store.
105
+ scan: (query: string, model: string, limit: number, deadlineMs?: number) => Promise<VecHit[]> =
106
+ (query, model, limit, deadlineMs) => searchVecMatch(store.db, query, model, limit, deadlineMs),
107
+ ): Promise<VectorDaemonHandle | null> {
108
+ const sockPath = vecDaemonSocketPath(store.dbPath);
109
+ try {
110
+ // 0700 dir is the real access control (UnixSocketOptions has no `mode` field); the socket chmod
111
+ // below is defense-in-depth. mkdir's `mode` only applies on CREATE, so chmod an existing dir too.
112
+ mkdirSync(vecDaemonSocketDir(), { recursive: true, mode: 0o700 });
113
+ try { chmodSync(vecDaemonSocketDir(), 0o700); } catch { /* best-effort */ }
114
+ if (existsSync(sockPath)) {
115
+ // A socket file already exists. If a LIVE daemon (another watcher for this vault) owns it, do NOT
116
+ // clobber it — unlinking a live socket only makes the old listener unreachable-by-path while it
117
+ // keeps running, stranding it. Probe first: bind only over a stale (dead) socket.
118
+ if (await isSocketAlive(sockPath)) {
119
+ log(`[vec-daemon] a live daemon already owns ${sockPath}; not starting a second`);
120
+ return { close() { /* not ours — nothing to stop or unlink */ } };
121
+ }
122
+ rmSync(sockPath, { force: true }); // stale socket from a crashed watcher
123
+ }
124
+ } catch (e) {
125
+ log(`[vec-daemon] socket dir prep failed: ${(e as Error).message}`);
126
+ return null;
127
+ }
128
+
129
+ let scanInFlight = false;
130
+
131
+ const respond = (socket: Socket<{ buf: string }>, resp: VecResp) => {
132
+ try {
133
+ socket.write(JSON.stringify(resp) + "\n");
134
+ socket.end();
135
+ } catch { /* peer already gone — nothing to send */ }
136
+ };
137
+
138
+ const handleRequest = async (socket: Socket<{ buf: string }>, line: string) => {
139
+ let req: VecReq;
140
+ try {
141
+ req = JSON.parse(line) as VecReq;
142
+ if (typeof req.query !== "string" || typeof req.model !== "string" || typeof req.limit !== "number" || !Number.isFinite(req.limit)) {
143
+ throw new Error("bad request shape");
144
+ }
145
+ } catch {
146
+ respond(socket, { error: "malformed" });
147
+ return;
148
+ }
149
+ // Deadline-on-receipt: drop already-expired requests without scanning (the pile-up guard).
150
+ if (req.deadlineMs !== undefined && Date.now() >= req.deadlineMs) {
151
+ respond(socket, { error: "expired" });
152
+ return;
153
+ }
154
+ if (scanInFlight) {
155
+ respond(socket, { error: "busy" });
156
+ return;
157
+ }
158
+ // Clamp limit so a stray/hostile value can't drive an unbounded k = limit*3 scan.
159
+ const safeLimit = Math.min(Math.max(1, Math.trunc(req.limit)), MAX_VEC_LIMIT);
160
+ scanInFlight = true;
161
+ try {
162
+ const results = await scan(req.query, req.model, safeLimit, req.deadlineMs);
163
+ respond(socket, { results });
164
+ } catch (e) {
165
+ // Preserve the v0.18 read-model-mismatch "warn loudly once" contract across the wire: return a
166
+ // TYPED error the client reconstructs into VecReadModelMismatchError so the hook's
167
+ // warnOnceOnVectorModelMismatch (an instanceof check) still fires. Other errors stay generic.
168
+ if (e instanceof VecReadModelMismatchError) {
169
+ respond(socket, { error: "read_model_mismatch", storedModels: e.storedModels, activeModel: e.activeModel });
170
+ } else {
171
+ respond(socket, { error: `internal: ${(e as Error).message}` });
172
+ }
173
+ } finally {
174
+ scanInFlight = false;
175
+ }
176
+ };
177
+
178
+ let server: { stop: (closeActiveConnections?: boolean) => void };
179
+ try {
180
+ server = Bun.listen<{ buf: string }>({
181
+ unix: sockPath,
182
+ socket: {
183
+ open(socket) { socket.data = { buf: "" }; },
184
+ data(socket, chunk) {
185
+ socket.data.buf += chunk.toString();
186
+ if (socket.data.buf.length > MAX_FRAME_BYTES) { respond(socket, { error: "oversized" }); return; }
187
+ const nl = socket.data.buf.indexOf("\n");
188
+ if (nl < 0) return; // partial frame — wait for the newline
189
+ const line = socket.data.buf.slice(0, nl);
190
+ socket.data.buf = ""; // one request per connection
191
+ void handleRequest(socket, line);
192
+ },
193
+ error(_socket, err) { log(`[vec-daemon] socket error: ${err.message}`); },
194
+ },
195
+ });
196
+ } catch (e) {
197
+ log(`[vec-daemon] bind failed: ${(e as Error).message}`);
198
+ return null;
199
+ }
200
+
201
+ try { chmodSync(sockPath, 0o600); } catch { /* best-effort; 0700 dir already restricts to owner */ }
202
+ log(`[vec-daemon] listening on ${sockPath}`);
203
+
204
+ return {
205
+ close() {
206
+ try { server.stop(true); } catch { /* already stopped */ }
207
+ try { if (existsSync(sockPath)) rmSync(sockPath, { force: true }); } catch { /* best-effort */ }
208
+ },
209
+ };
210
+ }
211
+
212
+ // ─────────────────────────────────────────────────────────────────────────────
213
+ // Hook client + bounded search — used by BOTH context-surfacing vector legs
214
+ // ─────────────────────────────────────────────────────────────────────────────
215
+
216
+ type DaemonOutcome =
217
+ | { status: "ok"; results: VecHit[] }
218
+ | { status: "busy" } // daemon busy/expired → hook falls to FTS (do NOT re-run in-process)
219
+ | { status: "error" } // daemon present but timed out/misbehaving → fall to FTS
220
+ | { status: "absent" } // daemon not running → hook uses the in-process path
221
+ | { status: "model_mismatch"; storedModels: string[]; activeModel: string }; // typed → hook warns once
222
+
223
+ /**
224
+ * Send one Step-1 request to the daemon and classify the outcome. Self-bounds to `ipcTimeoutMs`
225
+ * (the caller passes the remaining wall-clock budget) so the client cleans up its own socket well
226
+ * before the hook's outer timer fires. Every failure mode resolves — this promise never rejects.
227
+ */
228
+ export async function daemonVecMatch(dbPath: string, req: VecReq, ipcTimeoutMs: number): Promise<DaemonOutcome> {
229
+ const sockPath = vecDaemonSocketPath(dbPath);
230
+ if (!existsSync(sockPath)) return { status: "absent" };
231
+ if (ipcTimeoutMs <= 0) return { status: "error" }; // no budget left — don't even connect
232
+
233
+ return await new Promise<DaemonOutcome>((resolve) => {
234
+ let settled = false;
235
+ let buf = "";
236
+ let sock: Socket<undefined> | null = null;
237
+ const finish = (o: DaemonOutcome) => {
238
+ if (settled) return;
239
+ settled = true;
240
+ clearTimeout(timer);
241
+ try { sock?.end(); } catch { /* ignore */ }
242
+ resolve(o);
243
+ };
244
+ const timer = setTimeout(() => finish({ status: "error" }), ipcTimeoutMs);
245
+
246
+ Bun.connect<undefined>({
247
+ unix: sockPath,
248
+ socket: {
249
+ open(socket) {
250
+ sock = socket;
251
+ try { socket.write(JSON.stringify(req) + "\n"); }
252
+ catch { finish({ status: "error" }); }
253
+ },
254
+ data(_socket, chunk) {
255
+ buf += chunk.toString();
256
+ if (buf.length > MAX_FRAME_BYTES) { finish({ status: "error" }); return; }
257
+ const nl = buf.indexOf("\n");
258
+ if (nl < 0) return; // partial frame — keep reading
259
+ try {
260
+ const resp = JSON.parse(buf.slice(0, nl)) as VecResp;
261
+ if ("results" in resp) finish({ status: "ok", results: resp.results });
262
+ else if (resp.error === "busy" || resp.error === "expired") finish({ status: "busy" });
263
+ else if (resp.error === "read_model_mismatch") finish({ status: "model_mismatch", storedModels: resp.storedModels ?? [], activeModel: resp.activeModel ?? "" });
264
+ else finish({ status: "error" });
265
+ } catch { finish({ status: "error" }); }
266
+ },
267
+ // Connection refused / stale socket file (no live listener) → daemon effectively absent.
268
+ connectError() { finish({ status: "absent" }); },
269
+ error() { finish({ status: "error" }); },
270
+ close() { finish({ status: "error" }); }, // closed before a full frame arrived
271
+ },
272
+ }).catch(() => finish({ status: "absent" })); // belt-and-suspenders for connect rejection
273
+ });
274
+ }
275
+
276
+ /**
277
+ * Bounded vector search for the hook path. Tries the daemon (Step-1 MATCH off the hook's event loop),
278
+ * hydrates locally (Step-2), and degrades cleanly per the design contract:
279
+ * - ok → hydrate the daemon's hits and return
280
+ * - absent/refused → in-process searchVec (today's self-bounded behavior; daemon not deployed)
281
+ * - model_mismatch → throw VecReadModelMismatchError (mirrors in-process; the leg warns once → FTS)
282
+ * - busy/error/timeout → return [] so the caller falls back to FTS
283
+ *
284
+ * Used by BOTH the primary and deep-escalation vector legs in context-surfacing, so every hook vector
285
+ * call is bounded — not just the first.
286
+ */
287
+ export async function searchVecBounded(
288
+ store: Store,
289
+ query: string,
290
+ model: string,
291
+ limit: number,
292
+ collectionId?: number,
293
+ collections?: string[],
294
+ dateRange?: { start: string; end: string },
295
+ deadlineMs?: number,
296
+ ): Promise<SearchResult[]> {
297
+ const startedAt = VEC_TIMING ? Date.now() : 0;
298
+ const ipcTimeoutMs = deadlineMs !== undefined ? deadlineMs - Date.now() : DEFAULT_IPC_TIMEOUT_MS;
299
+ const outcome = await daemonVecMatch(store.dbPath, { query, model, limit, deadlineMs }, ipcTimeoutMs);
300
+ let results: SearchResult[];
301
+ switch (outcome.status) {
302
+ case "ok":
303
+ results = hydrateVecResults(store.db, outcome.results, limit, collectionId, collections, dateRange);
304
+ break;
305
+ case "absent":
306
+ results = await store.searchVec(query, model, limit, collectionId, collections, dateRange, deadlineMs);
307
+ break;
308
+ case "model_mismatch":
309
+ // Mirror the in-process path: throw the typed error so the leg's catch fires
310
+ // warnOnceOnVectorModelMismatch (a persistent config error, warned loudly once), then FTS.
311
+ throw new VecReadModelMismatchError(outcome.storedModels, outcome.activeModel);
312
+ default: // "busy" | "error" → let the caller's FTS fallback take over
313
+ results = [];
314
+ break;
315
+ }
316
+ if (VEC_TIMING) {
317
+ console.error(`[vec-timing] path=${outcome.status} elapsedMs=${Date.now() - startedAt} results=${results.length}`);
318
+ }
319
+ return results;
320
+ }