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 +1 -1
- package/README.md +2 -2
- package/docs/reference/mcp-tools.md +2 -0
- package/docs/troubleshooting.md +2 -2
- package/package.json +1 -1
- package/src/beads.ts +11 -7
- package/src/clawmem.ts +19 -3
- package/src/hooks/context-surfacing.ts +7 -2
- package/src/store.ts +21 -2
- package/src/vector-daemon.ts +320 -0
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/
|
|
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
|
package/docs/troubleshooting.md
CHANGED
|
@@ -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
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
|
-
|
|
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
|
|
96
|
+
return execFileSync(bd, args, {
|
|
96
97
|
cwd: projectDir,
|
|
97
98
|
encoding: "utf-8",
|
|
98
99
|
timeout: timeoutMs,
|
|
99
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|