clawmem 0.16.0 → 0.18.0
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 -0
- package/README.md +2 -0
- package/docs/reference/cli.md +2 -0
- package/docs/reference/configuration.md +2 -0
- package/package.json +1 -1
- package/src/clawmem.ts +39 -0
- package/src/consolidation.ts +20 -3
- package/src/conversation-synthesis.ts +16 -10
- package/src/hooks/context-surfacing.ts +26 -8
- package/src/hooks/decision-extractor.ts +5 -3
- package/src/hooks.ts +19 -7
- package/src/llm.ts +70 -5
- package/src/maintenance.ts +1 -0
- package/src/mcp.ts +7 -5
- package/src/observer.ts +4 -25
- package/src/schema-placeholder.ts +56 -0
- package/src/server.ts +8 -5
- package/src/store.ts +163 -6
package/AGENTS.md
CHANGED
|
@@ -19,6 +19,7 @@ Three services — **embedding**, **LLM** (query expansion / intent / A-MEM), **
|
|
|
19
19
|
- The zerank-2 **GGUF is inert** — llama.cpp drops the score head → ranking silently collapses to RRF. Use the **seq-cls sidecar**; verify with `clawmem rerank-health` (liveness ≠ correctness).
|
|
20
20
|
- `-ub` must equal `-b` for embedding/reranking (non-causal attention) or `llama-server` asserts.
|
|
21
21
|
- Changing embedding dimensions → `clawmem embed --force` (full re-embed).
|
|
22
|
+
- Changing the embedding **model** (even at the same dimension) → `clawmem embed --force`; querying otherwise now throws `VecReadModelMismatchError` instead of serving cosine-meaningless results (v0.18.0).
|
|
22
23
|
- `CLAWMEM_NO_LOCAL_MODELS=true` to fail fast instead of silent CPU fallback.
|
|
23
24
|
|
|
24
25
|
→ Stack decision matrix + server setup: [docs/guides/inference-services.md](docs/guides/inference-services.md) · cloud: [docs/guides/cloud-embedding.md](docs/guides/cloud-embedding.md) · all env vars: [docs/reference/configuration.md](docs/reference/configuration.md).
|
package/README.md
CHANGED
|
@@ -782,10 +782,12 @@ Notes referenced by the agent during a session get boosted (`access_count++`). U
|
|
|
782
782
|
| `CLAWMEM_EMBED_TPM_LIMIT` | `100000` | Tokens-per-minute limit for cloud embedding pacing. Match to your provider tier. |
|
|
783
783
|
| `CLAWMEM_EMBED_DIMENSIONS` | (none) | Output dimensions for OpenAI `text-embedding-3-*` Matryoshka models (e.g. `512`, `1024`). |
|
|
784
784
|
| `CLAWMEM_LLM_URL` | `http://localhost:8089` | LLM server URL for intent/query/A-MEM. Without it, falls to `node-llama-cpp` (if allowed). |
|
|
785
|
+
| `CLAWMEM_LLM_API_KEY` | (none) | Bearer token for an authenticated remote LLM endpoint. Independent of the embed and rerank keys. |
|
|
785
786
|
| `CLAWMEM_LLM_MODEL` | `qwen3` | Model name sent to the configured LLM endpoint. Override this for OpenAI-compatible proxies such as `gpt-5.4-mini`. |
|
|
786
787
|
| `CLAWMEM_LLM_REASONING_EFFORT` | (none) | Optional top-level `reasoning_effort` field for Chat Completions endpoints that support it (for example OpenAI reasoning models). Leave unset for llama-server/vLLM unless your serving stack explicitly accepts that field. |
|
|
787
788
|
| `CLAWMEM_LLM_NO_THINK` | `true` | Append `/no_think` to remote LLM prompts. Set to `false` for standard OpenAI models and other endpoints that reject or treat the Qwen-style suffix as literal prompt text. |
|
|
788
789
|
| `CLAWMEM_RERANK_URL` | `http://localhost:8090` | Reranker server URL. Without it, falls to `node-llama-cpp` (if allowed). |
|
|
790
|
+
| `CLAWMEM_RERANK_API_KEY` | (none) | Bearer token for an authenticated remote reranker endpoint. Independent of the embed and LLM keys. |
|
|
789
791
|
| `CLAWMEM_NO_LOCAL_MODELS` | `false` | Block `node-llama-cpp` from auto-downloading GGUF models. Set `true` for remote-only setups where you want fail-fast on unreachable endpoints. |
|
|
790
792
|
| `CLAWMEM_MERGE_SCORE_NORMAL` | `0.93` | **v0.7.1.** Phase 2 consolidation merge-safety threshold when candidate and existing anchors align. Merges above this normalized 3-gram cosine score are allowed. |
|
|
791
793
|
| `CLAWMEM_MERGE_SCORE_STRICT` | `0.98` | **v0.7.1.** Strictest merge-safety threshold — fallback when anchor sets are ambiguous. |
|
package/docs/reference/cli.md
CHANGED
|
@@ -169,10 +169,12 @@ The session ID is resolved from `--session-id <id>`, then `CLAUDE_SESSION_ID`, t
|
|
|
169
169
|
| `CLAWMEM_EMBED_TPM_LIMIT` | `100000` | Tokens-per-minute limit for cloud embedding pacing |
|
|
170
170
|
| `CLAWMEM_EMBED_DIMENSIONS` | — | Output dimensions for OpenAI `text-embedding-3-*` models |
|
|
171
171
|
| `CLAWMEM_LLM_URL` | `http://localhost:8089` | LLM server |
|
|
172
|
+
| `CLAWMEM_LLM_API_KEY` | — | Bearer token for an authenticated remote LLM endpoint |
|
|
172
173
|
| `CLAWMEM_LLM_MODEL` | `qwen3` | Model name sent to the configured LLM endpoint |
|
|
173
174
|
| `CLAWMEM_LLM_REASONING_EFFORT` | — | Optional top-level `reasoning_effort` field for Chat Completions endpoints that support it (for example OpenAI reasoning models). Leave unset for llama-server/vLLM unless explicitly supported. |
|
|
174
175
|
| `CLAWMEM_LLM_NO_THINK` | `true` | Append `/no_think` to remote prompts; set `false` for standard OpenAI models and other endpoints that reject or treat it as literal prompt text |
|
|
175
176
|
| `CLAWMEM_RERANK_URL` | `http://localhost:8090` | Reranker server |
|
|
177
|
+
| `CLAWMEM_RERANK_API_KEY` | — | Bearer token for an authenticated remote reranker endpoint |
|
|
176
178
|
| `CLAWMEM_NO_LOCAL_MODELS` | `false` | Block node-llama-cpp auto-downloads |
|
|
177
179
|
| `CLAWMEM_PROFILE` | `balanced` | Performance profile: `speed` (BM25 only), `balanced` (BM25+vector), `deep` (BM25+vector+expansion+reranking) |
|
|
178
180
|
| `CLAWMEM_VAULTS` | — | JSON map of vault name to SQLite path |
|
|
@@ -12,7 +12,9 @@ See also: [../guides/inference-services.md](../guides/inference-services.md) (st
|
|
|
12
12
|
|---|---|---|
|
|
13
13
|
| `CLAWMEM_EMBED_URL` | `http://localhost:8088` | Embedding server URL. Local `llama-server`, cloud API, or in-process `node-llama-cpp` fallback if unset. |
|
|
14
14
|
| `CLAWMEM_LLM_URL` | `http://localhost:8089` | LLM server for intent, expansion, A-MEM, entity extraction. Falls to `node-llama-cpp` if unset + `NO_LOCAL_MODELS=false`. Point at a 7B+ model or cloud API during `reindex --enrich` for better entity extraction. |
|
|
15
|
+
| `CLAWMEM_LLM_API_KEY` | (none) | Bearer token for an authenticated remote LLM endpoint. Independent of the embed/rerank keys — set it when the LLM points at a different authenticated host. |
|
|
15
16
|
| `CLAWMEM_RERANK_URL` | `http://localhost:8090` | Reranker server. Falls to `node-llama-cpp` if unset + `NO_LOCAL_MODELS=false`. |
|
|
17
|
+
| `CLAWMEM_RERANK_API_KEY` | (none) | Bearer token for an authenticated remote reranker endpoint. Independent of the embed/LLM keys. |
|
|
16
18
|
| `CLAWMEM_LLM_MODEL` | `qwen3` | Model name sent on LLM requests. |
|
|
17
19
|
| `CLAWMEM_LLM_REASONING_EFFORT` | (none) | Top-level `reasoning_effort` for Chat Completions endpoints that support it (e.g. a remote reasoning model). Optional. |
|
|
18
20
|
| `CLAWMEM_LLM_NO_THINK` | enabled | Appends `/no_think` to remote LLM prompts (Qwen3 emits thinking tokens by default). Set `false` for standard OpenAI-compatible models that would treat `/no_think` as literal text. |
|
package/package.json
CHANGED
package/src/clawmem.ts
CHANGED
|
@@ -9,6 +9,8 @@ import { resolve as pathResolve, basename } from "path";
|
|
|
9
9
|
import {
|
|
10
10
|
createStore,
|
|
11
11
|
prewarmVectors,
|
|
12
|
+
startPeriodicPrewarm,
|
|
13
|
+
resolvePrewarmIntervalMs,
|
|
12
14
|
enableProductionMode,
|
|
13
15
|
getDefaultDbPath,
|
|
14
16
|
canonicalDocId,
|
|
@@ -1071,6 +1073,15 @@ function printResults(results: Array<{ displayPath: string; title: string; compo
|
|
|
1071
1073
|
// Hook dispatch
|
|
1072
1074
|
// =============================================================================
|
|
1073
1075
|
|
|
1076
|
+
// B3: the context-surfacing UserPromptSubmit hook runs under a tight budget
|
|
1077
|
+
// (8s repo default). Its OWN writes — dedup UPSERT, context_usage, recall
|
|
1078
|
+
// events, co-activations — are all best-effort/fail-open, but under writer
|
|
1079
|
+
// contention each could otherwise wait up to the store default busy_timeout
|
|
1080
|
+
// (5000ms) and blow the budget. Cap this process's busy_timeout so a contended
|
|
1081
|
+
// write fails fast (SQLITE_BUSY → skipped by the fail-open guards) instead of
|
|
1082
|
+
// stalling. Reads are unaffected (WAL readers never wait on the write lock).
|
|
1083
|
+
const CONTEXT_SURFACING_WRITE_BUSY_TIMEOUT_MS = 1500;
|
|
1084
|
+
|
|
1074
1085
|
async function cmdHook(args: string[]) {
|
|
1075
1086
|
const hookName = args[0];
|
|
1076
1087
|
if (!hookName) die("Usage: clawmem hook <name>");
|
|
@@ -1082,6 +1093,11 @@ async function cmdHook(args: string[]) {
|
|
|
1082
1093
|
try {
|
|
1083
1094
|
switch (hookName) {
|
|
1084
1095
|
case "context-surfacing":
|
|
1096
|
+
// Scope the small busy_timeout to THIS process only. Each `clawmem
|
|
1097
|
+
// hook` invocation runs exactly one hook, so the Stop hooks
|
|
1098
|
+
// (decision-extractor / handoff-generator / feedback-loop, 30s budget)
|
|
1099
|
+
// run in separate processes and keep the store default (5000ms).
|
|
1100
|
+
try { s.db.exec(`PRAGMA busy_timeout = ${CONTEXT_SURFACING_WRITE_BUSY_TIMEOUT_MS}`); } catch { /* non-fatal */ }
|
|
1085
1101
|
output = await contextSurfacing(s, input);
|
|
1086
1102
|
break;
|
|
1087
1103
|
case "session-bootstrap":
|
|
@@ -1831,6 +1847,7 @@ async function cmdWatch() {
|
|
|
1831
1847
|
let stopHeavyLane: (() => Promise<void>) | null = null;
|
|
1832
1848
|
let watcherHandle: { close: () => void } | null = null;
|
|
1833
1849
|
let checkpointTimerHandle: Timer | null = null;
|
|
1850
|
+
let prewarmTimerHandle: ReturnType<typeof setInterval> | null = null;
|
|
1834
1851
|
|
|
1835
1852
|
// Graceful shutdown — stop workers, close watchers, then exit. SIGTERM
|
|
1836
1853
|
// handling is critical for systemd `systemctl --user stop` to shut down
|
|
@@ -1839,6 +1856,13 @@ async function cmdWatch() {
|
|
|
1839
1856
|
// its own withWorkerLease finally block before we close the store.
|
|
1840
1857
|
const shutdown = async (signal: string) => {
|
|
1841
1858
|
console.log(`\n${c.dim}[watch] Received ${signal}, shutting down...${c.reset}`);
|
|
1859
|
+
// Clear the periodic prewarm FIRST — before the awaited worker drains below. The timer is
|
|
1860
|
+
// unref'd but still fires while the loop is alive; a tick landing mid-drain would run the
|
|
1861
|
+
// synchronous ~1.5 GB scan and delay shutdown. Clearing it here is the only guard against that.
|
|
1862
|
+
if (prewarmTimerHandle) {
|
|
1863
|
+
clearInterval(prewarmTimerHandle);
|
|
1864
|
+
prewarmTimerHandle = null;
|
|
1865
|
+
}
|
|
1842
1866
|
if (stopHeavyLane) {
|
|
1843
1867
|
await stopHeavyLane();
|
|
1844
1868
|
stopHeavyLane = null;
|
|
@@ -1901,6 +1925,21 @@ async function cmdWatch() {
|
|
|
1901
1925
|
} catch { /* best-effort: unexpected SQL error */ }
|
|
1902
1926
|
}, 0);
|
|
1903
1927
|
|
|
1928
|
+
// B5 Option C: keep the vector payload warm against OS page-cache eviction BETWEEN hook calls.
|
|
1929
|
+
// The one-shot prewarm above warms once; on a long-running host under memory pressure the kernel
|
|
1930
|
+
// can evict the payload and let a cold synchronous MATCH creep back into the context-surfacing
|
|
1931
|
+
// hook path. Re-touching the pages on an interval biases the kernel LRU toward keeping them
|
|
1932
|
+
// resident (a PROBABILITY reduction, not a hard cap — the hard cap is the deferred BACKLOG
|
|
1933
|
+
// Source 46 daemon). Cleared FIRST in shutdown(); the handle is unref'd so it never keeps the
|
|
1934
|
+
// process alive by itself. resolvePrewarmIntervalMs enforces a strict parse + 60s floor so a
|
|
1935
|
+
// stray tiny value (e.g. "1", or "1e3" which parseInt would read as 1) cannot schedule a
|
|
1936
|
+
// near-continuous scan loop. Set CLAWMEM_PREWARM_INTERVAL_MS=0 to disable. Default 10 min.
|
|
1937
|
+
const prewarmIntervalMs = resolvePrewarmIntervalMs(Bun.env.CLAWMEM_PREWARM_INTERVAL_MS);
|
|
1938
|
+
prewarmTimerHandle = startPeriodicPrewarm(s.db, prewarmIntervalMs);
|
|
1939
|
+
if (prewarmTimerHandle) {
|
|
1940
|
+
console.log(`${c.dim}[watch] periodic vector prewarm every ${Math.round(prewarmIntervalMs / 1000)}s${c.reset}`);
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1904
1943
|
watcherHandle = startWatcher(dirs, {
|
|
1905
1944
|
debounceMs: 2000,
|
|
1906
1945
|
onChanged: async (fullPath, event) => {
|
package/src/consolidation.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import type { Store } from "./store.ts";
|
|
16
16
|
import type { LlamaCpp } from "./llm.ts";
|
|
17
17
|
import { extractJsonFromLLM } from "./amem.ts";
|
|
18
|
+
import { isSchemaPlaceholder } from "./schema-placeholder.ts";
|
|
18
19
|
import { hashContent } from "./indexer.ts";
|
|
19
20
|
import { passesMergeSafety } from "./text-similarity.ts";
|
|
20
21
|
import { withWorkerLease } from "./worker-lease.ts";
|
|
@@ -68,6 +69,8 @@ export interface DeductiveSynthesisStats {
|
|
|
68
69
|
unsupportedRejects: number;
|
|
69
70
|
/** Drafts rejected because the conclusion was empty/trivial */
|
|
70
71
|
emptyRejects: number;
|
|
72
|
+
/** Drafts rejected because the conclusion echoed the schema skeleton residue (anti-parrot) */
|
|
73
|
+
placeholderRejects: number;
|
|
71
74
|
/** Accepted drafts that were then skipped as deductive dedupe duplicates */
|
|
72
75
|
dedupSkipped: number;
|
|
73
76
|
/**
|
|
@@ -93,6 +96,7 @@ function emptyDeductiveStats(considered: number = 0): DeductiveSynthesisStats {
|
|
|
93
96
|
invalidIndexRejects: 0,
|
|
94
97
|
unsupportedRejects: 0,
|
|
95
98
|
emptyRejects: 0,
|
|
99
|
+
placeholderRejects: 0,
|
|
96
100
|
dedupSkipped: 0,
|
|
97
101
|
validatorFallbackAccepts: 0,
|
|
98
102
|
};
|
|
@@ -1030,11 +1034,12 @@ For each valid deduction:
|
|
|
1030
1034
|
2. List the premises (which observations support it)
|
|
1031
1035
|
3. List the source indices (1-indexed)
|
|
1032
1036
|
|
|
1033
|
-
Return ONLY
|
|
1037
|
+
Return ONLY a JSON array in this shape (structure only — replace every {{...}} with real content
|
|
1038
|
+
drawn from the observations above; never emit the {{...}} tokens or any schema text literally):
|
|
1034
1039
|
[
|
|
1035
1040
|
{
|
|
1036
|
-
"conclusion": "
|
|
1037
|
-
"premises": ["
|
|
1041
|
+
"conclusion": "{{new conclusion combining 2+ observations, 1-2 sentences}}",
|
|
1042
|
+
"premises": ["{{fact from one source observation}}", "{{fact from another}}"],
|
|
1038
1043
|
"source_indices": [1, 3]
|
|
1039
1044
|
}
|
|
1040
1045
|
]
|
|
@@ -1073,6 +1078,18 @@ Return ONLY the JSON array. /no_think`;
|
|
|
1073
1078
|
continue;
|
|
1074
1079
|
}
|
|
1075
1080
|
|
|
1081
|
+
// Anti-parrot: reject a draft whose conclusion echoes the JSON skeleton's placeholder residue
|
|
1082
|
+
// ({{...}} tokens / "clear deductive statement") before the expensive validator runs. Premises
|
|
1083
|
+
// that are placeholder residue are filtered (non-fatal — the conclusion carries the deduction).
|
|
1084
|
+
if (isSchemaPlaceholder(deduction.conclusion)) {
|
|
1085
|
+
stats.rejected++;
|
|
1086
|
+
stats.placeholderRejects++;
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
if (Array.isArray(deduction.premises)) {
|
|
1090
|
+
deduction.premises = deduction.premises.filter(p => typeof p === "string" && !isSchemaPlaceholder(p));
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1076
1093
|
const sourceDocIds = [...new Set(
|
|
1077
1094
|
deduction.source_indices
|
|
1078
1095
|
.filter(i => i >= 1 && i <= recentObs.length)
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import type { Store } from "./store.ts";
|
|
29
29
|
import type { LlamaCpp } from "./llm.ts";
|
|
30
30
|
import { extractJsonFromLLM } from "./amem.ts";
|
|
31
|
+
import { isSchemaPlaceholder } from "./schema-placeholder.ts";
|
|
31
32
|
import type { ContentType } from "./memory.ts";
|
|
32
33
|
|
|
33
34
|
// =============================================================================
|
|
@@ -179,7 +180,7 @@ For each fact provide:
|
|
|
179
180
|
- contentType: one of [decision, preference, milestone, problem]
|
|
180
181
|
- narrative: 1-3 sentence description of the fact in context
|
|
181
182
|
- facts: optional array of supporting fact strings (evidence)
|
|
182
|
-
- aliases: optional alternative titles for linking (
|
|
183
|
+
- aliases: optional alternative titles for linking (help match this fact to related ones)
|
|
183
184
|
- links: optional array of cross-fact references. Each link is
|
|
184
185
|
{targetTitle, relationType, weight}
|
|
185
186
|
- targetTitle may refer to another fact extracted from this conversation OR from
|
|
@@ -189,18 +190,20 @@ For each fact provide:
|
|
|
189
190
|
- weight is 0.0-1.0 (default 0.6)
|
|
190
191
|
|
|
191
192
|
Only extract facts the conversation clearly supports. Do NOT fabricate.
|
|
193
|
+
Never copy the {{...}} tokens or any schema text below into your output — replace every {{...}}
|
|
194
|
+
with real content drawn from the conversation. Emit only real extracted facts.
|
|
192
195
|
Return ONLY valid JSON array. Return empty array [] if no structured facts found.
|
|
193
196
|
|
|
194
|
-
|
|
197
|
+
Shape (structure only — not example content):
|
|
195
198
|
[
|
|
196
199
|
{
|
|
197
|
-
"title": "
|
|
198
|
-
"contentType": "decision",
|
|
199
|
-
"narrative": "
|
|
200
|
-
"facts": ["
|
|
201
|
-
"aliases": ["
|
|
200
|
+
"title": "{{concise 3-8 word title}}",
|
|
201
|
+
"contentType": "{{one of: decision, preference, milestone, problem}}",
|
|
202
|
+
"narrative": "{{1-3 sentence description of the fact in context}}",
|
|
203
|
+
"facts": ["{{supporting evidence string}}"],
|
|
204
|
+
"aliases": ["{{optional alternative title}}"],
|
|
202
205
|
"links": [
|
|
203
|
-
{ "targetTitle": "
|
|
206
|
+
{ "targetTitle": "{{another fact's title}}", "relationType": "{{one of: semantic, supporting, contradicts, causal, temporal, entity}}", "weight": 0.6 }
|
|
204
207
|
]
|
|
205
208
|
}
|
|
206
209
|
]`;
|
|
@@ -219,6 +222,8 @@ function normalizeExtractedFact(
|
|
|
219
222
|
|
|
220
223
|
const title = typeof obj.title === "string" ? obj.title.trim() : "";
|
|
221
224
|
if (!title) return null;
|
|
225
|
+
// Anti-parrot: reject a fact whose title is echoed schema/skeleton residue.
|
|
226
|
+
if (isSchemaPlaceholder(title)) return null;
|
|
222
227
|
|
|
223
228
|
const contentType = obj.contentType;
|
|
224
229
|
if (typeof contentType !== "string") return null;
|
|
@@ -226,13 +231,14 @@ function normalizeExtractedFact(
|
|
|
226
231
|
|
|
227
232
|
const narrative = typeof obj.narrative === "string" ? obj.narrative.trim() : "";
|
|
228
233
|
if (!narrative) return null;
|
|
234
|
+
if (isSchemaPlaceholder(narrative)) return null;
|
|
229
235
|
|
|
230
236
|
const facts: string[] = Array.isArray(obj.facts)
|
|
231
|
-
? obj.facts.filter((f): f is string => typeof f === "string" && f.trim().length > 0)
|
|
237
|
+
? obj.facts.filter((f): f is string => typeof f === "string" && f.trim().length > 0 && !isSchemaPlaceholder(f))
|
|
232
238
|
: [];
|
|
233
239
|
|
|
234
240
|
const aliases: string[] = Array.isArray(obj.aliases)
|
|
235
|
-
? obj.aliases.filter((a): a is string => typeof a === "string" && a.trim().length > 0)
|
|
241
|
+
? obj.aliases.filter((a): a is string => typeof a === "string" && a.trim().length > 0 && !isSchemaPlaceholder(a))
|
|
236
242
|
: [];
|
|
237
243
|
|
|
238
244
|
const links: ExtractedFactLink[] = Array.isArray(obj.links)
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type { Store, SearchResult } from "../store.ts";
|
|
10
|
-
import { DEFAULT_EMBED_MODEL, DEFAULT_QUERY_MODEL, DEFAULT_RERANK_MODEL, extractSnippet, resolveStore } from "../store.ts";
|
|
10
|
+
import { DEFAULT_EMBED_MODEL, DEFAULT_QUERY_MODEL, DEFAULT_RERANK_MODEL, warnOnceOnVectorModelMismatch, extractSnippet, resolveStore } from "../store.ts";
|
|
11
11
|
import { getVaultPath, getActiveProfile } from "../config.ts";
|
|
12
12
|
import type { HookInput, HookOutput } from "../hooks.ts";
|
|
13
13
|
import {
|
|
@@ -149,6 +149,21 @@ export async function contextSurfacing(
|
|
|
149
149
|
const tokenBudget = profile.tokenBudget;
|
|
150
150
|
const startTime = Date.now();
|
|
151
151
|
|
|
152
|
+
// High-fix (B3): the hook's writes to the MAIN store are bounded by the
|
|
153
|
+
// busy_timeout cmdHook set for this process (1500ms for context-surfacing).
|
|
154
|
+
// But skill-vault stores are opened separately via resolveStore(), which
|
|
155
|
+
// would otherwise use the 5000ms operational default — so a contended
|
|
156
|
+
// skill-vault write (the recall mirror below) could still stall the hook up
|
|
157
|
+
// to 5s. Inherit the main store's current cap and pass it to EVERY
|
|
158
|
+
// skill-vault open so those opens/writes are bounded identically. (Reads are
|
|
159
|
+
// WAL-safe regardless; this primarily bounds the mirror write.)
|
|
160
|
+
let hookBusyTimeout = 5000;
|
|
161
|
+
try {
|
|
162
|
+
const bt = (store.db.prepare("PRAGMA busy_timeout").get() as { timeout?: number } | undefined)?.timeout;
|
|
163
|
+
if (typeof bt === "number" && bt > 0) hookBusyTimeout = bt;
|
|
164
|
+
} catch { /* keep default */ }
|
|
165
|
+
const skillStoreOpts = { busyTimeout: hookBusyTimeout };
|
|
166
|
+
|
|
152
167
|
// §11.4: Resolve session-scoped focus topic. Primary signal is the
|
|
153
168
|
// per-session focus file at ~/.cache/clawmem/sessions/<id>.focus
|
|
154
169
|
// (file > env var precedence via resolveSessionTopic). Env var
|
|
@@ -195,8 +210,11 @@ export async function contextSurfacing(
|
|
|
195
210
|
vectorTimer = setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout);
|
|
196
211
|
});
|
|
197
212
|
results = await Promise.race([vectorPromise, timeoutPromise]);
|
|
198
|
-
} catch {
|
|
199
|
-
// Vector search unavailable, timed out, or errored — fall back to BM25
|
|
213
|
+
} catch (e) {
|
|
214
|
+
// Vector search unavailable, timed out, or errored — fall back to BM25. A vault-wide
|
|
215
|
+
// embedding-model mismatch is a persistent config error, not a transient miss: surface it
|
|
216
|
+
// loudly once, then degrade (the hook stays fail-open).
|
|
217
|
+
warnOnceOnVectorModelMismatch(e);
|
|
200
218
|
} finally {
|
|
201
219
|
// Clear the timer when the vector promise won the race: a pending (ref'd) setTimeout keeps the
|
|
202
220
|
// Bun hook process alive for the full vectorTimeout after results are already in hand.
|
|
@@ -221,7 +239,7 @@ export async function contextSurfacing(
|
|
|
221
239
|
// Dual-query: also search skill vault if configured (secondary source)
|
|
222
240
|
if (getVaultPath("skill")) {
|
|
223
241
|
try {
|
|
224
|
-
const skillStore = resolveStore("skill");
|
|
242
|
+
const skillStore = resolveStore("skill", skillStoreOpts);
|
|
225
243
|
const skillResults = skillStore.searchFTS(retrievalQuery, 5);
|
|
226
244
|
// Tag skill vault results for identification in output
|
|
227
245
|
for (const r of skillResults) {
|
|
@@ -292,7 +310,7 @@ export async function contextSurfacing(
|
|
|
292
310
|
deepTimer = setTimeout(() => reject(new Error("vector timeout")), remainingMs);
|
|
293
311
|
});
|
|
294
312
|
hits = await Promise.race([deepVec, deepTimeout]);
|
|
295
|
-
} catch { /* vector leg non-fatal (timed out or errored) */ }
|
|
313
|
+
} catch (e) { warnOnceOnVectorModelMismatch(e); /* vector leg non-fatal (timed out or errored) */ }
|
|
296
314
|
finally { if (deepTimer) clearTimeout(deepTimer); } // don't let a pending timer keep the hook process alive
|
|
297
315
|
}
|
|
298
316
|
for (const r of hits) {
|
|
@@ -346,7 +364,7 @@ export async function contextSurfacing(
|
|
|
346
364
|
// expects the collection-relative path, not the full virtual path
|
|
347
365
|
const parsed = r.filepath.startsWith('clawmem://') ? r.filepath.replace(/^clawmem:\/\/[^/]+\/?/, '') : r.filepath;
|
|
348
366
|
// Use the correct store for skill-vault results
|
|
349
|
-
const targetStore = (r as any)._fromVault === "skill" ? (() => { try { return resolveStore("skill"); } catch { return store; } })() : store;
|
|
367
|
+
const targetStore = (r as any)._fromVault === "skill" ? (() => { try { return resolveStore("skill", skillStoreOpts); } catch { return store; } })() : store;
|
|
350
368
|
const doc = targetStore.findActiveDocument(r.collectionName, parsed);
|
|
351
369
|
if (!doc) return true;
|
|
352
370
|
if (doc.snoozed_until && new Date(doc.snoozed_until) > now) return false;
|
|
@@ -374,7 +392,7 @@ export async function contextSurfacing(
|
|
|
374
392
|
let enriched = enrichResults(store, generalResults, prompt);
|
|
375
393
|
if (skillResults.length > 0) {
|
|
376
394
|
try {
|
|
377
|
-
const skillStore = resolveStore("skill");
|
|
395
|
+
const skillStore = resolveStore("skill", skillStoreOpts);
|
|
378
396
|
enriched = [...enriched, ...enrichResults(skillStore, skillResults, prompt)];
|
|
379
397
|
} catch {
|
|
380
398
|
// Skill store unavailable — enrich with general store as fallback
|
|
@@ -500,7 +518,7 @@ export async function contextSurfacing(
|
|
|
500
518
|
writeRecallEvents(store, input.sessionId, qHash, mappedDocs, validUsageId, turnIndex);
|
|
501
519
|
} else {
|
|
502
520
|
try {
|
|
503
|
-
const vaultStore = resolveStore(vault);
|
|
521
|
+
const vaultStore = resolveStore(vault, skillStoreOpts);
|
|
504
522
|
// Mirror context_usage row into named vault for correct FK + attribution
|
|
505
523
|
const vaultPaths = docs.map(r => r.displayPath);
|
|
506
524
|
const vaultUsageId = vaultStore.insertUsage({
|
|
@@ -23,7 +23,7 @@ import { loadConfig } from "../collections.ts";
|
|
|
23
23
|
import { getDefaultLlamaCpp } from "../llm.ts";
|
|
24
24
|
import type { ObservationWithDoc } from "../amem.ts";
|
|
25
25
|
import { extractJsonFromLLM } from "../amem.ts";
|
|
26
|
-
import { DEFAULT_EMBED_MODEL, extractSnippet, type SearchResult } from "../store.ts";
|
|
26
|
+
import { DEFAULT_EMBED_MODEL, warnOnceOnVectorModelMismatch, extractSnippet, type SearchResult } from "../store.ts";
|
|
27
27
|
import { ensureEntityCanonical, resolveEntityTypeExact } from "../entity.ts";
|
|
28
28
|
|
|
29
29
|
// Observation types that are allowed to contribute SPO triples. Widened from the
|
|
@@ -92,7 +92,8 @@ export async function checkMergePolicy(
|
|
|
92
92
|
if (sameType.length > 0) {
|
|
93
93
|
return { action: 'skip' };
|
|
94
94
|
}
|
|
95
|
-
} catch {
|
|
95
|
+
} catch (e) {
|
|
96
|
+
warnOnceOnVectorModelMismatch(e);
|
|
96
97
|
// Vector search unavailable — fall through to insert
|
|
97
98
|
}
|
|
98
99
|
return { action: 'insert' };
|
|
@@ -211,7 +212,8 @@ async function detectContradictions(
|
|
|
211
212
|
let existingDocs: SearchResult[];
|
|
212
213
|
try {
|
|
213
214
|
existingDocs = await store.searchVec(queryText, DEFAULT_EMBED_MODEL, 5);
|
|
214
|
-
} catch {
|
|
215
|
+
} catch (e) {
|
|
216
|
+
warnOnceOnVectorModelMismatch(e);
|
|
215
217
|
existingDocs = store.searchFTS(queryText, 5);
|
|
216
218
|
}
|
|
217
219
|
|
package/src/hooks.ts
CHANGED
|
@@ -213,13 +213,25 @@ export function wasPromptSeenRecently(store: Store, hookName: string, prompt: st
|
|
|
213
213
|
}
|
|
214
214
|
|
|
215
215
|
const preview = normalized.slice(0, 120);
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
216
|
+
// Best-effort dedup bookkeeping. Under writer contention this UPSERT can hit
|
|
217
|
+
// SQLITE_BUSY — especially from the context-surfacing hook, which caps its
|
|
218
|
+
// busy_timeout low (B3) so its own writes cannot stall the tight
|
|
219
|
+
// UserPromptSubmit budget. A failed write only means the next identical
|
|
220
|
+
// prompt won't be suppressed; it is never a reason to throw and abort the
|
|
221
|
+
// hook. The `recent` verdict comes from the READ above (WAL-safe, does not
|
|
222
|
+
// wait on the write lock), so same-prompt dedup still works when the row
|
|
223
|
+
// already exists even if this refresh write is skipped.
|
|
224
|
+
try {
|
|
225
|
+
store.db.prepare(`
|
|
226
|
+
INSERT INTO hook_dedupe (hook_name, prompt_hash, prompt_preview, last_seen_at)
|
|
227
|
+
VALUES (?, ?, ?, ?)
|
|
228
|
+
ON CONFLICT(hook_name, prompt_hash) DO UPDATE SET
|
|
229
|
+
prompt_preview = excluded.prompt_preview,
|
|
230
|
+
last_seen_at = excluded.last_seen_at
|
|
231
|
+
`).run(hookName, hash, preview, nowIso);
|
|
232
|
+
} catch {
|
|
233
|
+
/* best-effort: contended/failed dedup write must never abort the hook */
|
|
234
|
+
}
|
|
223
235
|
|
|
224
236
|
return recent;
|
|
225
237
|
}
|
package/src/llm.ts
CHANGED
|
@@ -109,6 +109,14 @@ export type EmbedOptions = {
|
|
|
109
109
|
model?: string;
|
|
110
110
|
isQuery?: boolean;
|
|
111
111
|
title?: string;
|
|
112
|
+
/**
|
|
113
|
+
* Abort signal for the remote embed fetch AND its 429-retry backoff (B4).
|
|
114
|
+
* The query path passes AbortSignal.timeout(<remaining budget>) so a slow or
|
|
115
|
+
* rate-limited embed cannot outlive the caller's deadline. Without it, the
|
|
116
|
+
* hook's Promise.race only ABANDONS the embed promise — the underlying fetch
|
|
117
|
+
* and retry sleeps keep running; the signal actually CANCELS them.
|
|
118
|
+
*/
|
|
119
|
+
signal?: AbortSignal;
|
|
112
120
|
};
|
|
113
121
|
|
|
114
122
|
/**
|
|
@@ -319,6 +327,13 @@ export type LlamaCppConfig = {
|
|
|
319
327
|
* When set, generate() calls /v1/chat/completions instead of local node-llama-cpp.
|
|
320
328
|
*/
|
|
321
329
|
remoteLlmUrl?: string;
|
|
330
|
+
/**
|
|
331
|
+
* API key for the remote LLM service (independent of the embed/rerank keys —
|
|
332
|
+
* the LLM endpoint may point at a different authenticated host than embedding).
|
|
333
|
+
* When set, sent as Authorization: Bearer header with chat completion requests.
|
|
334
|
+
* Env: CLAWMEM_LLM_API_KEY
|
|
335
|
+
*/
|
|
336
|
+
remoteLlmApiKey?: string;
|
|
322
337
|
/**
|
|
323
338
|
* Remote LLM model name to send with chat completion requests.
|
|
324
339
|
* Env: CLAWMEM_LLM_MODEL
|
|
@@ -393,6 +408,7 @@ export class LlamaCpp implements LLM {
|
|
|
393
408
|
private remoteEmbedApiKey: string | null;
|
|
394
409
|
private remoteEmbedModel: string;
|
|
395
410
|
private remoteLlmUrl: string | null;
|
|
411
|
+
private remoteLlmApiKey: string | null;
|
|
396
412
|
private remoteLlmModel: string;
|
|
397
413
|
private remoteLlmReasoningEffort: string | null;
|
|
398
414
|
private remoteLlmNoThink: boolean;
|
|
@@ -428,6 +444,7 @@ export class LlamaCpp implements LLM {
|
|
|
428
444
|
this.remoteEmbedApiKey = config.remoteEmbedApiKey || null;
|
|
429
445
|
this.remoteEmbedModel = config.remoteEmbedModel || "embedding";
|
|
430
446
|
this.remoteLlmUrl = config.remoteLlmUrl || null;
|
|
447
|
+
this.remoteLlmApiKey = config.remoteLlmApiKey || null;
|
|
431
448
|
const normalizedRemoteLlmModel = config.remoteLlmModel?.trim();
|
|
432
449
|
this.remoteLlmModel = normalizedRemoteLlmModel || "qwen3";
|
|
433
450
|
this.remoteLlmReasoningEffort = normalizeRemoteLlmReasoningEffort(config.remoteLlmReasoningEffort);
|
|
@@ -697,7 +714,7 @@ export class LlamaCpp implements LLM {
|
|
|
697
714
|
// Remote server or cloud API — preferred path
|
|
698
715
|
if (this.remoteEmbedUrl && !this.isRemoteEmbedDown()) {
|
|
699
716
|
const extraParams = this.getCloudEmbedParams(!!options.isQuery);
|
|
700
|
-
const result = await this.embedRemote(text, extraParams);
|
|
717
|
+
const result = await this.embedRemote(text, extraParams, undefined, options.signal);
|
|
701
718
|
if (result) return result;
|
|
702
719
|
// Cloud providers don't fall back — if API key is set, the user chose cloud
|
|
703
720
|
if (this.isCloudEmbedding()) return null;
|
|
@@ -710,6 +727,13 @@ export class LlamaCpp implements LLM {
|
|
|
710
727
|
// Remote is in cooldown or was never configured — try local fallback
|
|
711
728
|
if (this.remoteEmbedUrl && this.isRemoteEmbedDown()) {
|
|
712
729
|
if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") return null;
|
|
730
|
+
// Medium-fix (B4): a deadline-bounded caller (query path, signal set)
|
|
731
|
+
// cannot afford a local model load/download during a remote cooldown —
|
|
732
|
+
// embedLocal ignores the abort signal and can run for seconds/minutes.
|
|
733
|
+
// Skip the local fallback and let the caller degrade (searchVec → [] →
|
|
734
|
+
// FTS). Pure-local mode (no remoteEmbedUrl) never enters this branch, so
|
|
735
|
+
// local-only deployments still embed.
|
|
736
|
+
if (options.signal) return null;
|
|
713
737
|
this.noteRemoteFallback(
|
|
714
738
|
"embed",
|
|
715
739
|
this.isLoopbackUrl(this.remoteEmbedUrl)
|
|
@@ -929,6 +953,14 @@ export class LlamaCpp implements LLM {
|
|
|
929
953
|
return headers;
|
|
930
954
|
}
|
|
931
955
|
|
|
956
|
+
private getLlmHeaders(): Record<string, string> {
|
|
957
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
958
|
+
if (this.remoteLlmApiKey) {
|
|
959
|
+
headers["Authorization"] = `Bearer ${this.remoteLlmApiKey}`;
|
|
960
|
+
}
|
|
961
|
+
return headers;
|
|
962
|
+
}
|
|
963
|
+
|
|
932
964
|
private truncateForEmbed(text: string): string {
|
|
933
965
|
// Cloud providers handle their own context window limits
|
|
934
966
|
if (this.isCloudEmbedding()) return text;
|
|
@@ -952,22 +984,47 @@ export class LlamaCpp implements LLM {
|
|
|
952
984
|
return Math.floor(delayMs * (0.75 + Math.random() * 0.5));
|
|
953
985
|
}
|
|
954
986
|
|
|
955
|
-
|
|
987
|
+
/**
|
|
988
|
+
* Sleep for `ms`, resolving early if `signal` aborts. Returns true if the
|
|
989
|
+
* wait was cut short by an abort (caller should stop retrying), false if it
|
|
990
|
+
* slept the full duration. Without this, a 429 backoff (up to 30s) would run
|
|
991
|
+
* to completion even after the caller's deadline elapsed (B4).
|
|
992
|
+
*/
|
|
993
|
+
private async abortableDelay(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
994
|
+
if (signal?.aborted) return true;
|
|
995
|
+
if (!signal) {
|
|
996
|
+
await new Promise(r => setTimeout(r, ms));
|
|
997
|
+
return false;
|
|
998
|
+
}
|
|
999
|
+
return await new Promise<boolean>((resolve) => {
|
|
1000
|
+
const onAbort = () => { clearTimeout(timer); resolve(true); };
|
|
1001
|
+
const timer = setTimeout(() => {
|
|
1002
|
+
signal.removeEventListener("abort", onAbort);
|
|
1003
|
+
resolve(false);
|
|
1004
|
+
}, ms);
|
|
1005
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
private async embedRemote(text: string, extraParams: Record<string, unknown> = {}, retries = 5, signal?: AbortSignal): Promise<EmbeddingResult | null> {
|
|
956
1010
|
if (this.isRemoteEmbedDown()) return null;
|
|
957
1011
|
const input = this.truncateForEmbed(text);
|
|
958
1012
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
1013
|
+
if (signal?.aborted) return null; // caller deadline already elapsed — do not start another attempt
|
|
959
1014
|
try {
|
|
960
1015
|
const body: Record<string, unknown> = { input, model: this.remoteEmbedModel, ...extraParams };
|
|
961
1016
|
const resp = await fetch(`${this.remoteEmbedUrl}/v1/embeddings`, {
|
|
962
1017
|
method: "POST",
|
|
963
1018
|
headers: this.getEmbedHeaders(),
|
|
964
1019
|
body: JSON.stringify(body),
|
|
1020
|
+
signal,
|
|
965
1021
|
});
|
|
966
1022
|
if (resp.status === 429) {
|
|
967
1023
|
const retryAfter = this.parseRetryAfter(resp);
|
|
968
1024
|
const delay = retryAfter ?? Math.min(1000 * 2 ** attempt, 30000);
|
|
969
|
-
|
|
970
|
-
|
|
1025
|
+
const jittered = this.jitter(delay);
|
|
1026
|
+
console.error(`Remote embed rate-limited, retry ${attempt + 1}/${retries} in ${jittered}ms`);
|
|
1027
|
+
if (await this.abortableDelay(jittered, signal)) return null; // deadline elapsed during backoff
|
|
971
1028
|
continue;
|
|
972
1029
|
}
|
|
973
1030
|
if (!resp.ok) {
|
|
@@ -983,6 +1040,13 @@ export class LlamaCpp implements LLM {
|
|
|
983
1040
|
model: data.model || this.remoteEmbedUrl!,
|
|
984
1041
|
};
|
|
985
1042
|
} catch (error) {
|
|
1043
|
+
// An abort/timeout is an intentional caller-driven cancellation (the
|
|
1044
|
+
// query-path deadline), NOT a transport failure — do not trip the 60s
|
|
1045
|
+
// remote-down cooldown, which would needlessly force local fallback.
|
|
1046
|
+
const name = (error as { name?: string })?.name;
|
|
1047
|
+
if (signal?.aborted || name === "AbortError" || name === "TimeoutError") {
|
|
1048
|
+
return null;
|
|
1049
|
+
}
|
|
986
1050
|
if (this.isTransportError(error)) {
|
|
987
1051
|
this.markRemoteEmbedDown();
|
|
988
1052
|
} else {
|
|
@@ -1120,7 +1184,7 @@ export class LlamaCpp implements LLM {
|
|
|
1120
1184
|
}
|
|
1121
1185
|
const resp = await fetch(buildRemoteChatCompletionsUrl(this.remoteLlmUrl!), {
|
|
1122
1186
|
method: "POST",
|
|
1123
|
-
headers:
|
|
1187
|
+
headers: this.getLlmHeaders(),
|
|
1124
1188
|
body: JSON.stringify(body),
|
|
1125
1189
|
signal,
|
|
1126
1190
|
});
|
|
@@ -1438,6 +1502,7 @@ export function getDefaultLlamaCpp(): LlamaCpp {
|
|
|
1438
1502
|
remoteEmbedApiKey: embedApiKey,
|
|
1439
1503
|
remoteEmbedModel: process.env.CLAWMEM_EMBED_MODEL || undefined,
|
|
1440
1504
|
remoteLlmUrl: process.env.CLAWMEM_LLM_URL || undefined,
|
|
1505
|
+
remoteLlmApiKey: process.env.CLAWMEM_LLM_API_KEY || undefined,
|
|
1441
1506
|
remoteLlmModel: process.env.CLAWMEM_LLM_MODEL?.trim() || undefined,
|
|
1442
1507
|
remoteLlmReasoningEffort: process.env.CLAWMEM_LLM_REASONING_EFFORT || undefined,
|
|
1443
1508
|
remoteLlmNoThink: (() => {
|
package/src/maintenance.ts
CHANGED
|
@@ -479,6 +479,7 @@ export async function runHeavyMaintenanceTick(
|
|
|
479
479
|
invalidIndexRejects: stats.invalidIndexRejects,
|
|
480
480
|
unsupportedRejects: stats.unsupportedRejects,
|
|
481
481
|
emptyRejects: stats.emptyRejects,
|
|
482
|
+
placeholderRejects: stats.placeholderRejects,
|
|
482
483
|
dedupSkipped: stats.dedupSkipped,
|
|
483
484
|
validatorFallbackAccepts: stats.validatorFallbackAccepts,
|
|
484
485
|
},
|
package/src/mcp.ts
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
DEFAULT_QUERY_MODEL,
|
|
21
21
|
DEFAULT_RERANK_MODEL,
|
|
22
22
|
DEFAULT_MULTI_GET_MAX_BYTES,
|
|
23
|
+
rethrowIfFatalVectorError,
|
|
23
24
|
type Store,
|
|
24
25
|
type SearchResult,
|
|
25
26
|
type CausalLink,
|
|
@@ -283,7 +284,7 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
283
284
|
const intent = await classifyIntent(query, llm, store.db);
|
|
284
285
|
const bm25Results = store.searchFTS(query, 30);
|
|
285
286
|
let vecResults: SearchResult[] = [];
|
|
286
|
-
try { vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch { /* no vectors */ }
|
|
287
|
+
try { vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch (e) { rethrowIfFatalVectorError(e); /* else: no vectors */ }
|
|
287
288
|
const rrfWeights = intent.intent === 'WHY' ? [1.0, 1.5] : intent.intent === 'WHEN' ? [1.5, 1.0] : [1.0, 1.0];
|
|
288
289
|
const fusedRanked = reciprocalRankFusion([bm25Results.map(toRanked), vecResults.map(toRanked)], rrfWeights);
|
|
289
290
|
const allSearch = [...bm25Results, ...vecResults];
|
|
@@ -354,7 +355,7 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
354
355
|
for (const clause of clauses.sort((a, b) => a.priority - b.priority)) {
|
|
355
356
|
let results: SearchResult[] = [];
|
|
356
357
|
if (clause.type === 'bm25') results = store.searchFTS(clause.query, 20, undefined, clause.collections);
|
|
357
|
-
else if (clause.type === 'vector') { try { results = await store.searchVec(clause.query, DEFAULT_EMBED_MODEL, 20, undefined, clause.collections); } catch { /* */ } }
|
|
358
|
+
else if (clause.type === 'vector') { try { results = await store.searchVec(clause.query, DEFAULT_EMBED_MODEL, 20, undefined, clause.collections); } catch (e) { rethrowIfFatalVectorError(e); /* */ } }
|
|
358
359
|
else if (clause.type === 'graph') { results = store.searchFTS(clause.query, 15, undefined, clause.collections); }
|
|
359
360
|
allResults.push(...results);
|
|
360
361
|
}
|
|
@@ -378,12 +379,12 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
378
379
|
if (effectiveMode === "keyword") {
|
|
379
380
|
results = store.searchFTS(query, lim);
|
|
380
381
|
} else if (effectiveMode === "semantic" || effectiveMode === "discovery") {
|
|
381
|
-
try { results = await store.searchVec(query, DEFAULT_EMBED_MODEL, lim); } catch { results = store.searchFTS(query, lim); }
|
|
382
|
+
try { results = await store.searchVec(query, DEFAULT_EMBED_MODEL, lim); } catch (e) { rethrowIfFatalVectorError(e); results = store.searchFTS(query, lim); }
|
|
382
383
|
} else {
|
|
383
384
|
// Hybrid: BM25 + vector + RRF
|
|
384
385
|
const bm25 = store.searchFTS(query, 30);
|
|
385
386
|
let vec: SearchResult[] = [];
|
|
386
|
-
try { vec = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch { /* */ }
|
|
387
|
+
try { vec = await store.searchVec(query, DEFAULT_EMBED_MODEL, 30); } catch (e) { rethrowIfFatalVectorError(e); /* */ }
|
|
387
388
|
if (vec.length > 0) {
|
|
388
389
|
const fusedRanked = reciprocalRankFusion([bm25.map(toRanked), vec.map(toRanked)], [1.0, 1.0]);
|
|
389
390
|
const allSearch = [...bm25, ...vec];
|
|
@@ -961,7 +962,8 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
961
962
|
}));
|
|
962
963
|
}
|
|
963
964
|
}
|
|
964
|
-
} catch {
|
|
965
|
+
} catch (e) {
|
|
966
|
+
rethrowIfFatalVectorError(e);
|
|
965
967
|
// Vector search unavailable — degrade gracefully
|
|
966
968
|
}
|
|
967
969
|
|
package/src/observer.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { TranscriptMessage } from "./hooks.ts";
|
|
10
10
|
import { getDefaultLlamaCpp } from "./llm.ts";
|
|
11
11
|
import { MAX_LLM_GENERATE_TIMEOUT_MS } from "./limits.ts";
|
|
12
|
+
import { isSchemaPlaceholder } from "./schema-placeholder.ts";
|
|
12
13
|
|
|
13
14
|
// =============================================================================
|
|
14
15
|
// Types
|
|
@@ -179,31 +180,9 @@ export const VALID_PREDICATES = new Set([
|
|
|
179
180
|
// Predicates whose <object> should be stored as a literal (not resolved to an entity).
|
|
180
181
|
export const LITERAL_PREDICATES = new Set(["prefers", "avoids"]);
|
|
181
182
|
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
const SCHEMA_PLACEHOLDER_STRINGS = new Set([
|
|
186
|
-
"individual atomic fact",
|
|
187
|
-
"atomic fact",
|
|
188
|
-
"one atomic claim per fact element",
|
|
189
|
-
"brief descriptive title",
|
|
190
|
-
"canonical entity name",
|
|
191
|
-
]);
|
|
192
|
-
|
|
193
|
-
// Regex for template placeholder markers: {{...}}, <!--...-->, ${...}.
|
|
194
|
-
// Intentionally narrow — earlier drafts rejected any line starting with
|
|
195
|
-
// "example:" / "placeholder:", which false-positived legitimate facts like
|
|
196
|
-
// "Example: QMD switched to Bun in v0.2". Shape-only matching avoids that
|
|
197
|
-
// drift; the exact-string blocklist above handles known echoed placeholders.
|
|
198
|
-
const PLACEHOLDER_REGEX = /^(\{\{.*\}\}|<!--.*-->|\$\{.*\})/;
|
|
199
|
-
|
|
200
|
-
function isSchemaPlaceholder(text: string): boolean {
|
|
201
|
-
if (!text) return true;
|
|
202
|
-
const normalized = text.trim().toLowerCase();
|
|
203
|
-
if (SCHEMA_PLACEHOLDER_STRINGS.has(normalized)) return true;
|
|
204
|
-
if (PLACEHOLDER_REGEX.test(normalized)) return true;
|
|
205
|
-
return false;
|
|
206
|
-
}
|
|
183
|
+
// Anti-parrot residue guard (SCHEMA_PLACEHOLDER_STRINGS / PLACEHOLDER_REGEX / isSchemaPlaceholder)
|
|
184
|
+
// now lives in ./schema-placeholder.ts, shared with the consolidation + conversation-synthesis
|
|
185
|
+
// extraction paths. Imported at the top of this file.
|
|
207
186
|
|
|
208
187
|
export function parseObservationXml(xml: string): Observation | null {
|
|
209
188
|
const typeMatch = xml.match(/<type>\s*(.*?)\s*<\/type>/s);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared anti-parrot guard.
|
|
3
|
+
*
|
|
4
|
+
* A weak local extraction model, run out-of-distribution, may echo the schema example text or
|
|
5
|
+
* template placeholders from its prompt verbatim instead of extracting real content — the confirmed
|
|
6
|
+
* root cause of a failed mining run with the 1.7B model. This guard rejects that residue on output.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from observer.ts so all three extraction paths — observer (Stop-hook observations),
|
|
9
|
+
* consolidation (Phase-3 deductive synthesis), and conversation-synthesis (mining/import) — share
|
|
10
|
+
* one residue check instead of each re-implementing (or omitting) it.
|
|
11
|
+
*
|
|
12
|
+
* Design note on what does NOT belong here: this blocklist holds only strings that no real fact
|
|
13
|
+
* could legitimately be (schema-instruction words, obviously-synthetic skeleton phrases). It must
|
|
14
|
+
* NOT hold plausible real content — an earlier draft rejected any line starting with
|
|
15
|
+
* "example:" / "placeholder:", which false-positived legitimate facts like
|
|
16
|
+
* "Example: QMD switched to Bun in v0.2". The prompts kill the parrot at the source by using
|
|
17
|
+
* {{...}} skeleton tokens (caught generically by PLACEHOLDER_REGEX) rather than copyable
|
|
18
|
+
* real-looking examples, so this blocklist stays narrow.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
// Exact placeholder strings that must never be persisted as facts, titles, or triple components.
|
|
22
|
+
// Defense-in-depth: even though prompts now use structure-only skeletons (no copyable example
|
|
23
|
+
// content), a weak model could still echo these phrases from the schema description itself.
|
|
24
|
+
export const SCHEMA_PLACEHOLDER_STRINGS = new Set([
|
|
25
|
+
// observer.ts schema-instruction residue
|
|
26
|
+
"individual atomic fact",
|
|
27
|
+
"atomic fact",
|
|
28
|
+
"one atomic claim per fact element",
|
|
29
|
+
"brief descriptive title",
|
|
30
|
+
"canonical entity name",
|
|
31
|
+
// consolidation.ts deductive-skeleton residue (former copyable example — safe to blocklist
|
|
32
|
+
// because no genuine deduction is literally "clear deductive statement" / "premise from obs N")
|
|
33
|
+
"clear deductive statement",
|
|
34
|
+
"premise from obs 1",
|
|
35
|
+
"premise from obs 3",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Regex for template placeholder markers: {{...}}, <!--...-->, ${...}.
|
|
39
|
+
// Intentionally narrow — earlier drafts rejected any line starting with
|
|
40
|
+
// "example:" / "placeholder:", which false-positived legitimate facts like
|
|
41
|
+
// "Example: QMD switched to Bun in v0.2". Shape-only matching avoids that
|
|
42
|
+
// drift; the exact-string blocklist above handles known echoed placeholders.
|
|
43
|
+
export const PLACEHOLDER_REGEX = /^(\{\{.*\}\}|<!--.*-->|\$\{.*\})/;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* True when `text` is empty, a known schema-placeholder string, or a template marker — i.e.
|
|
47
|
+
* residue a model echoed from its prompt rather than real extracted content.
|
|
48
|
+
*/
|
|
49
|
+
export function isSchemaPlaceholder(text: string): boolean {
|
|
50
|
+
if (!text) return true;
|
|
51
|
+
const normalized = text.trim().toLowerCase();
|
|
52
|
+
if (!normalized) return true; // whitespace-only is effectively empty content
|
|
53
|
+
if (SCHEMA_PLACEHOLDER_STRINGS.has(normalized)) return true;
|
|
54
|
+
if (PLACEHOLDER_REGEX.test(normalized)) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
DEFAULT_QUERY_MODEL,
|
|
24
24
|
DEFAULT_RERANK_MODEL,
|
|
25
25
|
extractSnippet,
|
|
26
|
+
rethrowIfFatalVectorError,
|
|
26
27
|
} from "./store.ts";
|
|
27
28
|
|
|
28
29
|
// =============================================================================
|
|
@@ -149,7 +150,8 @@ async function handleSearch(req: Request, _url: URL, store: Store): Promise<Resp
|
|
|
149
150
|
} else if (mode === "semantic") {
|
|
150
151
|
try {
|
|
151
152
|
results = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit * 2, undefined, collections);
|
|
152
|
-
} catch {
|
|
153
|
+
} catch (e) {
|
|
154
|
+
rethrowIfFatalVectorError(e);
|
|
153
155
|
results = store.searchFTS(query, limit * 2, undefined, collections);
|
|
154
156
|
}
|
|
155
157
|
} else {
|
|
@@ -158,7 +160,7 @@ async function handleSearch(req: Request, _url: URL, store: Store): Promise<Resp
|
|
|
158
160
|
let vecResults: SearchResult[] = [];
|
|
159
161
|
try {
|
|
160
162
|
vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit * 2, undefined, collections);
|
|
161
|
-
} catch { /* vector unavailable */ }
|
|
163
|
+
} catch (e) { rethrowIfFatalVectorError(e); /* vector unavailable */ }
|
|
162
164
|
// Simple merge — dedupe by filepath, take max score
|
|
163
165
|
const merged = new Map<string, SearchResult>();
|
|
164
166
|
for (const r of [...ftsResults, ...vecResults]) {
|
|
@@ -612,7 +614,7 @@ async function handleRetrieve(req: Request, _url: URL, store: Store): Promise<Re
|
|
|
612
614
|
let vec: SearchResult[] = [];
|
|
613
615
|
try {
|
|
614
616
|
vec = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit * 2, undefined, collections);
|
|
615
|
-
} catch { /* vector unavailable */ }
|
|
617
|
+
} catch (e) { rethrowIfFatalVectorError(e); /* vector unavailable */ }
|
|
616
618
|
const weights = intent.intent === "WHEN" ? [1.5, 1.0] : [1.0, 1.5];
|
|
617
619
|
const fused = reciprocalRankFusion([bm25.map(toRanked), vec.map(toRanked)], weights);
|
|
618
620
|
const allResults = [...bm25, ...vec];
|
|
@@ -625,7 +627,8 @@ async function handleRetrieve(req: Request, _url: URL, store: Store): Promise<Re
|
|
|
625
627
|
} else if (mode === "semantic") {
|
|
626
628
|
try {
|
|
627
629
|
results = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit * 2, undefined, collections);
|
|
628
|
-
} catch {
|
|
630
|
+
} catch (e) {
|
|
631
|
+
rethrowIfFatalVectorError(e);
|
|
629
632
|
results = store.searchFTS(query, limit * 2, undefined, collections);
|
|
630
633
|
}
|
|
631
634
|
} else {
|
|
@@ -634,7 +637,7 @@ async function handleRetrieve(req: Request, _url: URL, store: Store): Promise<Re
|
|
|
634
637
|
let vec: SearchResult[] = [];
|
|
635
638
|
try {
|
|
636
639
|
vec = await store.searchVec(query, DEFAULT_EMBED_MODEL, limit * 2, undefined, collections);
|
|
637
|
-
} catch { /* vector unavailable */ }
|
|
640
|
+
} catch (e) { rethrowIfFatalVectorError(e); /* vector unavailable */ }
|
|
638
641
|
const merged = new Map<string, SearchResult>();
|
|
639
642
|
for (const r of [...fts, ...vec]) {
|
|
640
643
|
const existing = merged.get(r.filepath);
|
package/src/store.ts
CHANGED
|
@@ -1035,6 +1035,16 @@ const contextUsageHasQueryTextCache = new WeakMap<Database, boolean>();
|
|
|
1035
1035
|
*/
|
|
1036
1036
|
export class FatalVectorError extends Error {}
|
|
1037
1037
|
|
|
1038
|
+
/**
|
|
1039
|
+
* Rethrow a fatal vector error (dimension / model / schema mismatch) so a searchVec() fallback
|
|
1040
|
+
* catch surfaces it instead of silently degrading to BM25. Transient conditions (timeout, absent
|
|
1041
|
+
* vectors) are NOT FatalVectorError and remain swallowed by the caller, as before. Apply this at the
|
|
1042
|
+
* FTS-fallback catch sites that wrap searchVec.
|
|
1043
|
+
*/
|
|
1044
|
+
export function rethrowIfFatalVectorError(e: unknown): void {
|
|
1045
|
+
if (e instanceof FatalVectorError) throw e;
|
|
1046
|
+
}
|
|
1047
|
+
|
|
1038
1048
|
export class VecDimensionMismatchError extends FatalVectorError {
|
|
1039
1049
|
constructor(public readonly existingDim: number, public readonly requestedDim: number) {
|
|
1040
1050
|
super(`Embedding dimension changed: vectors_vec is float[${existingDim}] but the model now returns float[${requestedDim}]. Run 'clawmem embed --force' to clear and rebuild the full vault.`);
|
|
@@ -1056,6 +1066,33 @@ export class VecModelMismatchError extends FatalVectorError {
|
|
|
1056
1066
|
}
|
|
1057
1067
|
}
|
|
1058
1068
|
|
|
1069
|
+
/**
|
|
1070
|
+
* Read-path sibling of VecModelMismatchError. VecModelMismatchError fires only mid-embed-run; this
|
|
1071
|
+
* one fires on the QUERY path when the vault's stored vectors were embedded with one model but the
|
|
1072
|
+
* active embedding endpoint now returns a different model at the SAME dimension (so the dimension
|
|
1073
|
+
* guard cannot catch it). Matching the new model's query vector against the old model's stored
|
|
1074
|
+
* vectors is cosine-meaningless, so searchVec throws this rather than serving corrupted results.
|
|
1075
|
+
*/
|
|
1076
|
+
export class VecReadModelMismatchError extends FatalVectorError {
|
|
1077
|
+
constructor(public readonly storedModels: string[], public readonly activeModel: string) {
|
|
1078
|
+
super(`Embedding model mismatch on the query path: the vault's vectors were embedded with ${storedModels.map(m => `"${m}"`).join(", ")} but the active embedding endpoint now returns "${activeModel}". At the same dimension the dimension guard cannot catch this, and matching the new model's query vector against the old model's stored vectors makes cosine similarity meaningless. Run 'clawmem embed --force' to rebuild the vault with the current model.`);
|
|
1079
|
+
this.name = "VecReadModelMismatchError";
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
// Fail-open surfacing for a read-path model mismatch: warn LOUDLY once per process, then let the
|
|
1084
|
+
// caller degrade to BM25. For hooks that MUST NOT throw — context-surfacing (UserPromptSubmit) and
|
|
1085
|
+
// the Stop hooks (decision-extractor) — a throwing hook breaks that turn. Explicit query paths use
|
|
1086
|
+
// rethrowIfFatalVectorError instead. The once-flag is process-global so the warning fires exactly
|
|
1087
|
+
// once no matter which hook trips it first.
|
|
1088
|
+
let _warnedVectorModelMismatch = false;
|
|
1089
|
+
export function warnOnceOnVectorModelMismatch(e: unknown): void {
|
|
1090
|
+
if (e instanceof VecReadModelMismatchError && !_warnedVectorModelMismatch) {
|
|
1091
|
+
_warnedVectorModelMismatch = true;
|
|
1092
|
+
console.warn(`[clawmem] ${e.message}`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1059
1096
|
// A FatalVectorError so it propagates out of the per-fragment catch and aborts the
|
|
1060
1097
|
// embed run. Thrown both by cmdEmbed (between fragments) and INSIDE insertEmbedding's
|
|
1061
1098
|
// write transaction (atomic lease-token fence) when the lease was reclaimed.
|
|
@@ -1162,6 +1199,67 @@ export function prewarmVectors(db: Database): boolean {
|
|
|
1162
1199
|
return true;
|
|
1163
1200
|
}
|
|
1164
1201
|
|
|
1202
|
+
/**
|
|
1203
|
+
* Keep the sqlite-vec payload resident in the OS page cache by re-running the brute-force
|
|
1204
|
+
* prewarm on an interval. The one-shot prewarm at watcher startup warms the cache ONCE; on a
|
|
1205
|
+
* long-running host under memory pressure the kernel can evict the (potentially ~1.5 GB) vector
|
|
1206
|
+
* payload between hook calls, and the next cold SYNCHRONOUS MATCH in the context-surfacing hook
|
|
1207
|
+
* path can then blow the 8-15s hook budget (bun:sqlite exposes no interrupt, so an in-flight
|
|
1208
|
+
* scan cannot be abandoned). Re-touching the pages biases the kernel LRU toward keeping them
|
|
1209
|
+
* resident — a PROBABILITY reduction, NOT a hard cap. The hard cap (moving the blocking scan off
|
|
1210
|
+
* the hook's event loop so its deadline can fire) is the deferred BACKLOG Source 46 daemon.
|
|
1211
|
+
*
|
|
1212
|
+
* Best-effort: never throws; a per-tick failure is swallowed so the timer keeps running. Returns
|
|
1213
|
+
* the interval handle (the caller MUST clear it on shutdown) or null when disabled (intervalMs
|
|
1214
|
+
* <= 0 or non-finite). The handle is unref'd so it never by itself keeps the process alive.
|
|
1215
|
+
* `onPrewarm(ran)` is an optional observability hook fired after each attempt — `ran` is whether
|
|
1216
|
+
* a scan actually executed (i.e. a dimensioned vector table exists).
|
|
1217
|
+
*/
|
|
1218
|
+
export function startPeriodicPrewarm(
|
|
1219
|
+
db: Database,
|
|
1220
|
+
intervalMs: number,
|
|
1221
|
+
onPrewarm?: (ran: boolean) => void,
|
|
1222
|
+
): ReturnType<typeof setInterval> | null {
|
|
1223
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return null;
|
|
1224
|
+
const timer = setInterval(() => {
|
|
1225
|
+
let ran = false;
|
|
1226
|
+
try { ran = prewarmVectors(db); } catch { /* best-effort: unexpected SQL error */ }
|
|
1227
|
+
if (onPrewarm) { try { onPrewarm(ran); } catch { /* observer must never break the timer */ } }
|
|
1228
|
+
}, intervalMs);
|
|
1229
|
+
(timer as { unref?: () => void }).unref?.();
|
|
1230
|
+
return timer;
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
/** Floor for the periodic re-prewarm interval. Below this, a large-vault (~1.5 GB) brute-force scan
|
|
1234
|
+
* runs near-continuously and can saturate the watcher event loop + I/O, so any smaller positive
|
|
1235
|
+
* request is clamped UP to this value. */
|
|
1236
|
+
export const PREWARM_MIN_INTERVAL_MS = 60_000;
|
|
1237
|
+
/** Default periodic re-prewarm interval when CLAWMEM_PREWARM_INTERVAL_MS is unset or unparseable. */
|
|
1238
|
+
export const PREWARM_DEFAULT_INTERVAL_MS = 600_000;
|
|
1239
|
+
|
|
1240
|
+
/**
|
|
1241
|
+
* Resolve the raw CLAWMEM_PREWARM_INTERVAL_MS env value into a SAFE interval for the watcher. This
|
|
1242
|
+
* policy is kept OUT of the permissive `startPeriodicPrewarm` mechanism (so unit tests can still use
|
|
1243
|
+
* tiny intervals) and applied only on the production env path.
|
|
1244
|
+
* - unset / empty / unparseable → default (600000). Garbage must NOT silently disable the
|
|
1245
|
+
* mitigation, nor be read as a tiny interval.
|
|
1246
|
+
* - exactly 0 → 0 (the documented off switch; `startPeriodicPrewarm` then returns null).
|
|
1247
|
+
* - negative → default (nonsensical; neither an intentional disable nor a fast loop).
|
|
1248
|
+
* - 0 < n < floor → clamped UP to the 60s floor. Prevents the near-continuous scan loop that e.g.
|
|
1249
|
+
* "1" or "1e3" would otherwise schedule. NOTE: `Number("1e3") === 1000` whereas
|
|
1250
|
+
* `parseInt("1e3", 10) === 1`, so `Number()` is used deliberately (parseInt silently truncates
|
|
1251
|
+
* at the "e").
|
|
1252
|
+
* - n >= floor → floored to an integer and used as-is.
|
|
1253
|
+
*/
|
|
1254
|
+
export function resolvePrewarmIntervalMs(raw: string | undefined): number {
|
|
1255
|
+
if (raw === undefined || raw.trim() === "") return PREWARM_DEFAULT_INTERVAL_MS;
|
|
1256
|
+
const n = Number(raw);
|
|
1257
|
+
if (!Number.isFinite(n)) return PREWARM_DEFAULT_INTERVAL_MS;
|
|
1258
|
+
if (n === 0) return 0;
|
|
1259
|
+
if (n < 0) return PREWARM_DEFAULT_INTERVAL_MS;
|
|
1260
|
+
return Math.max(PREWARM_MIN_INTERVAL_MS, Math.floor(n));
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1165
1263
|
/**
|
|
1166
1264
|
* The DISTINCT non-empty embedding models stored in the vault (empty array if no
|
|
1167
1265
|
* embeddings exist). Used to detect model drift BETWEEN runs — a different model at
|
|
@@ -3387,12 +3485,54 @@ export function searchFTS(db: Database, query: string, limit: number = 20, colle
|
|
|
3387
3485
|
// Vector Search
|
|
3388
3486
|
// =============================================================================
|
|
3389
3487
|
|
|
3488
|
+
// W1 read-path model-consistency cache, keyed on SQLite's `data_version` so a CROSS-PROCESS vault
|
|
3489
|
+
// rebuild invalidates a stale OK verdict. `data_version` changes whenever ANOTHER connection commits
|
|
3490
|
+
// (e.g. a separate `clawmem embed --force` process re-embeds with a different model) — exactly the
|
|
3491
|
+
// multi-process staleness case — and is a cheap header read (no scan), unlike the getVecModels()
|
|
3492
|
+
// DISTINCT+JOIN it guards. Same-connection writes don't bump it, but in-process model swaps are
|
|
3493
|
+
// already blocked by the embed-time VecModelMismatchError / clearAllEmbeddings drop.
|
|
3494
|
+
const verifiedQueryEmbedModels = new WeakMap<Database, { dataVersion: number; model: string }>();
|
|
3495
|
+
|
|
3496
|
+
/**
|
|
3497
|
+
* Guard the query path against a same-dimension embedding-model swap. Compares the ENDPOINT-returned
|
|
3498
|
+
* model (NOT the caller's DEFAULT_EMBED_MODEL arg, which is a local alias unrelated to what the
|
|
3499
|
+
* endpoint actually serves) against the models the vault's active vectors were embedded with. The
|
|
3500
|
+
* vault is consistent ONLY when it holds EXACTLY ONE model equal to the endpoint's — a heterogeneous
|
|
3501
|
+
* vault (length > 1) is cosine-corrupt even if the endpoint matches one of the models, because the
|
|
3502
|
+
* other model's vectors still pollute the space. Throws VecReadModelMismatchError otherwise; no-ops
|
|
3503
|
+
* when the vault has no vectors yet.
|
|
3504
|
+
*/
|
|
3505
|
+
function assertQueryEmbedModelConsistent(db: Database, endpointModel: string): void {
|
|
3506
|
+
const dataVersion = (db.prepare("PRAGMA data_version").get() as { data_version: number }).data_version;
|
|
3507
|
+
const cached = verifiedQueryEmbedModels.get(db);
|
|
3508
|
+
if (cached && cached.dataVersion === dataVersion && cached.model === endpointModel) return;
|
|
3509
|
+
|
|
3510
|
+
const storedModels = getVecModels(db);
|
|
3511
|
+
if (storedModels.length === 0) return; // nothing embedded yet — nothing to be inconsistent with
|
|
3512
|
+
|
|
3513
|
+
if (!(storedModels.length === 1 && storedModels[0] === endpointModel)) {
|
|
3514
|
+
throw new VecReadModelMismatchError(storedModels, endpointModel);
|
|
3515
|
+
}
|
|
3516
|
+
|
|
3517
|
+
// Consistent — memoize under the current data_version. A cross-process rebuild bumps data_version,
|
|
3518
|
+
// invalidating this entry so the next query re-reads content_vectors.
|
|
3519
|
+
verifiedQueryEmbedModels.set(db, { dataVersion, model: endpointModel });
|
|
3520
|
+
}
|
|
3521
|
+
|
|
3390
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[]> {
|
|
3391
3523
|
const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
3392
3524
|
if (!tableExists) return [];
|
|
3393
3525
|
|
|
3394
|
-
const
|
|
3395
|
-
if (!
|
|
3526
|
+
const embedResult = await getEmbedding(query, model, true, deadlineMs);
|
|
3527
|
+
if (!embedResult) return [];
|
|
3528
|
+
|
|
3529
|
+
// W1: read-path embedding-model consistency gate. On a same-dimension model swap (which
|
|
3530
|
+
// VecDimensionMismatchError cannot catch) this throws VecReadModelMismatchError rather than
|
|
3531
|
+
// serving cosine-meaningless results. Cached per (db, model) — the DISTINCT runs at most once
|
|
3532
|
+
// per model per process.
|
|
3533
|
+
assertQueryEmbedModelConsistent(db, embedResult.model);
|
|
3534
|
+
|
|
3535
|
+
const embedding = embedResult.embedding;
|
|
3396
3536
|
|
|
3397
3537
|
// Guard-defect fix: the caller's Promise.race(vectorTimeout) cannot interrupt the SYNCHRONOUS
|
|
3398
3538
|
// sqlite-vec MATCH below (bun:sqlite blocks the event loop) and does NOT cancel this promise.
|
|
@@ -3499,12 +3639,24 @@ export async function searchVec(db: Database, query: string, model: string, limi
|
|
|
3499
3639
|
// Embeddings
|
|
3500
3640
|
// =============================================================================
|
|
3501
3641
|
|
|
3502
|
-
async function getEmbedding(text: string, model: string, isQuery: boolean): Promise<number[] | null> {
|
|
3642
|
+
async function getEmbedding(text: string, model: string, isQuery: boolean, deadlineMs?: number): Promise<{ embedding: number[]; model: string } | null> {
|
|
3503
3643
|
const llm = getDefaultLlamaCpp();
|
|
3504
3644
|
// Format text using the appropriate prompt template
|
|
3505
3645
|
const formattedText = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text);
|
|
3506
|
-
|
|
3507
|
-
|
|
3646
|
+
// B4: bound the remote embed fetch + its 429 backoff to the caller's wall-clock
|
|
3647
|
+
// deadline. Under the context-surfacing hook's Promise.race the abandoned embed
|
|
3648
|
+
// promise otherwise keeps its fetch + retry sleeps running; AbortSignal.timeout
|
|
3649
|
+
// actually cancels them, so a slow/rate-limited embed can no longer outlive the
|
|
3650
|
+
// hook budget.
|
|
3651
|
+
let signal: AbortSignal | undefined;
|
|
3652
|
+
if (deadlineMs !== undefined) {
|
|
3653
|
+
const remaining = deadlineMs - Date.now();
|
|
3654
|
+
if (remaining <= 0) return null; // deadline already elapsed — skip the embed entirely
|
|
3655
|
+
signal = AbortSignal.timeout(remaining);
|
|
3656
|
+
}
|
|
3657
|
+
const result = await llm.embed(formattedText, { model, isQuery, signal });
|
|
3658
|
+
if (!result?.embedding) return null;
|
|
3659
|
+
return { embedding: result.embedding, model: result.model };
|
|
3508
3660
|
}
|
|
3509
3661
|
|
|
3510
3662
|
/**
|
|
@@ -3807,19 +3959,24 @@ export async function rerank(query: string, documents: { file: string; text: str
|
|
|
3807
3959
|
// Cap parallelism at 4 to prevent VRAM exhaustion
|
|
3808
3960
|
if (uncachedDocs.length > 0) {
|
|
3809
3961
|
const rerankUrl = Bun.env.CLAWMEM_RERANK_URL;
|
|
3962
|
+
const rerankApiKey = Bun.env.CLAWMEM_RERANK_API_KEY;
|
|
3810
3963
|
let scored = false;
|
|
3811
3964
|
|
|
3812
3965
|
// Try remote GPU reranker first
|
|
3813
3966
|
// Truncate to ~400 chars per doc to fit within server's 512-token context
|
|
3814
3967
|
// (query + document must fit in one pair; ~2 chars/token for mixed content)
|
|
3815
3968
|
if (rerankUrl) {
|
|
3969
|
+
// Independent of the embed/LLM keys — the rerank endpoint may be a different
|
|
3970
|
+
// authenticated host. Sent as Authorization: Bearer when CLAWMEM_RERANK_API_KEY is set.
|
|
3971
|
+
const rerankHeaders: Record<string, string> = { "Content-Type": "application/json" };
|
|
3972
|
+
if (rerankApiKey) rerankHeaders["Authorization"] = `Bearer ${rerankApiKey}`;
|
|
3816
3973
|
try {
|
|
3817
3974
|
// Process in batches of 4 to prevent VRAM exhaustion
|
|
3818
3975
|
for (let i = 0; i < uncachedDocs.length; i += 4) {
|
|
3819
3976
|
const batch = uncachedDocs.slice(i, i + 4);
|
|
3820
3977
|
const resp = await fetch(`${rerankUrl}/v1/rerank`, {
|
|
3821
3978
|
method: "POST",
|
|
3822
|
-
headers:
|
|
3979
|
+
headers: rerankHeaders,
|
|
3823
3980
|
body: JSON.stringify({
|
|
3824
3981
|
query: rerankQuery,
|
|
3825
3982
|
documents: batch.map(d => d.text.slice(0, 400)),
|