clawmem 0.15.1 → 0.17.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 -1
- package/docs/troubleshooting.md +2 -2
- package/package.json +1 -1
- package/src/clawmem.ts +54 -0
- package/src/hooks/context-surfacing.ts +48 -9
- package/src/hooks.ts +19 -7
- package/src/llm.ts +51 -4
- package/src/store.ts +118 -14
package/AGENTS.md
CHANGED
|
@@ -174,7 +174,7 @@ compositeScore = (0.50·searchScore + 0.25·recencyScore + 0.25·confidenceScore
|
|
|
174
174
|
- **Vector search empty but BM25 works** → missing embeddings (the watcher indexes but does NOT embed). Run `clawmem embed`.
|
|
175
175
|
- **`intent_search` weak for WHY/ENTITY** → sparse graph. Run `build_graphs`. Don't run it after every reindex (A-MEM links per-doc automatically).
|
|
176
176
|
- **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.
|
|
177
|
-
- **Intermittent `UserPromptSubmit hook timed out after 8s — output discarded`** →
|
|
177
|
+
- **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
178
|
- **Anything setup-shaped** (download blocked, server unreachable, watcher memory bloat, indexer bugs) → [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
179
179
|
|
|
180
180
|
---
|
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 +
|
|
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.
|
|
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 —
|
|
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.
|
|
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/clawmem.ts
CHANGED
|
@@ -8,6 +8,9 @@ import { existsSync, mkdirSync, readFileSync } from "fs";
|
|
|
8
8
|
import { resolve as pathResolve, basename } from "path";
|
|
9
9
|
import {
|
|
10
10
|
createStore,
|
|
11
|
+
prewarmVectors,
|
|
12
|
+
startPeriodicPrewarm,
|
|
13
|
+
resolvePrewarmIntervalMs,
|
|
11
14
|
enableProductionMode,
|
|
12
15
|
getDefaultDbPath,
|
|
13
16
|
canonicalDocId,
|
|
@@ -1070,6 +1073,15 @@ function printResults(results: Array<{ displayPath: string; title: string; compo
|
|
|
1070
1073
|
// Hook dispatch
|
|
1071
1074
|
// =============================================================================
|
|
1072
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
|
+
|
|
1073
1085
|
async function cmdHook(args: string[]) {
|
|
1074
1086
|
const hookName = args[0];
|
|
1075
1087
|
if (!hookName) die("Usage: clawmem hook <name>");
|
|
@@ -1081,6 +1093,11 @@ async function cmdHook(args: string[]) {
|
|
|
1081
1093
|
try {
|
|
1082
1094
|
switch (hookName) {
|
|
1083
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 */ }
|
|
1084
1101
|
output = await contextSurfacing(s, input);
|
|
1085
1102
|
break;
|
|
1086
1103
|
case "session-bootstrap":
|
|
@@ -1830,6 +1847,7 @@ async function cmdWatch() {
|
|
|
1830
1847
|
let stopHeavyLane: (() => Promise<void>) | null = null;
|
|
1831
1848
|
let watcherHandle: { close: () => void } | null = null;
|
|
1832
1849
|
let checkpointTimerHandle: Timer | null = null;
|
|
1850
|
+
let prewarmTimerHandle: ReturnType<typeof setInterval> | null = null;
|
|
1833
1851
|
|
|
1834
1852
|
// Graceful shutdown — stop workers, close watchers, then exit. SIGTERM
|
|
1835
1853
|
// handling is critical for systemd `systemctl --user stop` to shut down
|
|
@@ -1838,6 +1856,13 @@ async function cmdWatch() {
|
|
|
1838
1856
|
// its own withWorkerLease finally block before we close the store.
|
|
1839
1857
|
const shutdown = async (signal: string) => {
|
|
1840
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
|
+
}
|
|
1841
1866
|
if (stopHeavyLane) {
|
|
1842
1867
|
await stopHeavyLane();
|
|
1843
1868
|
stopHeavyLane = null;
|
|
@@ -1886,6 +1911,35 @@ async function cmdWatch() {
|
|
|
1886
1911
|
stopHeavyLane = startHeavyMaintenanceWorker(s, llm, cfg);
|
|
1887
1912
|
}
|
|
1888
1913
|
|
|
1914
|
+
// Prewarm the sqlite-vec chunks into OS page cache ONCE, in the single long-lived watcher
|
|
1915
|
+
// process only (never in the per-session MCP processes — N concurrent cold scans would be an
|
|
1916
|
+
// I/O storm). The context-surfacing UserPromptSubmit hook runs a SYNCHRONOUS sqlite-vec MATCH
|
|
1917
|
+
// that cannot be time-bounded in-thread (bun:sqlite exposes no interrupt); a cold ~1.5 GB scan
|
|
1918
|
+
// can blow the hook's 8-15s budget. A single warm scan here keeps the hook-path scan sub-second.
|
|
1919
|
+
// Deferred + best-effort so it never delays watcher startup and never throws.
|
|
1920
|
+
setTimeout(() => {
|
|
1921
|
+
try {
|
|
1922
|
+
// prewarmVectors is embed-independent and returns true ONLY when a scan actually ran,
|
|
1923
|
+
// so we never log a false-positive "prewarmed" (embed down at boot / no vectors yet).
|
|
1924
|
+
if (prewarmVectors(s.db)) console.log(`${c.dim}[watch] vector cache prewarmed${c.reset}`);
|
|
1925
|
+
} catch { /* best-effort: unexpected SQL error */ }
|
|
1926
|
+
}, 0);
|
|
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
|
+
|
|
1889
1943
|
watcherHandle = startWatcher(dirs, {
|
|
1890
1944
|
debounceMs: 2000,
|
|
1891
1945
|
onChanged: async (fullPath, event) => {
|
|
@@ -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
|
|
@@ -183,14 +198,24 @@ export async function contextSurfacing(
|
|
|
183
198
|
// When vector succeeds, also supplement with FTS for keyword-exact recall
|
|
184
199
|
let results: SearchResult[] = [];
|
|
185
200
|
if (profile.useVector) {
|
|
201
|
+
let vectorTimer: ReturnType<typeof setTimeout> | undefined;
|
|
186
202
|
try {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
203
|
+
// Pass a wall-clock deadline into searchVec: the Promise.race below abandons the vector
|
|
204
|
+
// promise on timeout but cannot CANCEL it (and cannot interrupt its synchronous scan). The
|
|
205
|
+
// deadline makes searchVec self-abort before the blocking MATCH, so a slow embed cannot let
|
|
206
|
+
// an already-timed-out vector leg resume and re-block the hook after it fell back to FTS.
|
|
207
|
+
const vectorDeadline = Date.now() + profile.vectorTimeout;
|
|
208
|
+
const vectorPromise = store.searchVec(retrievalQuery, DEFAULT_EMBED_MODEL, maxResults, undefined, undefined, undefined, vectorDeadline);
|
|
209
|
+
const timeoutPromise = new Promise<SearchResult[]>((_, reject) => {
|
|
210
|
+
vectorTimer = setTimeout(() => reject(new Error("vector timeout")), profile.vectorTimeout);
|
|
211
|
+
});
|
|
191
212
|
results = await Promise.race([vectorPromise, timeoutPromise]);
|
|
192
213
|
} catch {
|
|
193
214
|
// Vector search unavailable, timed out, or errored — fall back to BM25
|
|
215
|
+
} finally {
|
|
216
|
+
// Clear the timer when the vector promise won the race: a pending (ref'd) setTimeout keeps the
|
|
217
|
+
// Bun hook process alive for the full vectorTimeout after results are already in hand.
|
|
218
|
+
if (vectorTimer) clearTimeout(vectorTimer);
|
|
194
219
|
}
|
|
195
220
|
}
|
|
196
221
|
|
|
@@ -211,7 +236,7 @@ export async function contextSurfacing(
|
|
|
211
236
|
// Dual-query: also search skill vault if configured (secondary source)
|
|
212
237
|
if (getVaultPath("skill")) {
|
|
213
238
|
try {
|
|
214
|
-
const skillStore = resolveStore("skill");
|
|
239
|
+
const skillStore = resolveStore("skill", skillStoreOpts);
|
|
215
240
|
const skillResults = skillStore.searchFTS(retrievalQuery, 5);
|
|
216
241
|
// Tag skill vault results for identification in output
|
|
217
242
|
for (const r of skillResults) {
|
|
@@ -269,7 +294,21 @@ export async function contextSurfacing(
|
|
|
269
294
|
if (eq.type === 'lex') {
|
|
270
295
|
hits = store.searchFTS(eq.query, 5);
|
|
271
296
|
} else if (profile.useVector) {
|
|
272
|
-
|
|
297
|
+
// Bound BOTH the async embed wait (Promise.race on the remaining 6s budget) AND the
|
|
298
|
+
// late synchronous MATCH (the deadline arg makes the abandoned promise self-abort
|
|
299
|
+
// before the scan) — mirroring the balanced leg above. The loop guard only breaks
|
|
300
|
+
// BETWEEN iterations, so without the race a slow embed here can still blow the budget.
|
|
301
|
+
const remainingMs = startTime + 6000 - Date.now();
|
|
302
|
+
if (remainingMs <= 0) break;
|
|
303
|
+
let deepTimer: ReturnType<typeof setTimeout> | undefined;
|
|
304
|
+
try {
|
|
305
|
+
const deepVec = store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 5, undefined, undefined, undefined, startTime + 6000);
|
|
306
|
+
const deepTimeout = new Promise<SearchResult[]>((_, reject) => {
|
|
307
|
+
deepTimer = setTimeout(() => reject(new Error("vector timeout")), remainingMs);
|
|
308
|
+
});
|
|
309
|
+
hits = await Promise.race([deepVec, deepTimeout]);
|
|
310
|
+
} catch { /* vector leg non-fatal (timed out or errored) */ }
|
|
311
|
+
finally { if (deepTimer) clearTimeout(deepTimer); } // don't let a pending timer keep the hook process alive
|
|
273
312
|
}
|
|
274
313
|
for (const r of hits) {
|
|
275
314
|
if (!seen.has(r.filepath)) {
|
|
@@ -322,7 +361,7 @@ export async function contextSurfacing(
|
|
|
322
361
|
// expects the collection-relative path, not the full virtual path
|
|
323
362
|
const parsed = r.filepath.startsWith('clawmem://') ? r.filepath.replace(/^clawmem:\/\/[^/]+\/?/, '') : r.filepath;
|
|
324
363
|
// Use the correct store for skill-vault results
|
|
325
|
-
const targetStore = (r as any)._fromVault === "skill" ? (() => { try { return resolveStore("skill"); } catch { return store; } })() : store;
|
|
364
|
+
const targetStore = (r as any)._fromVault === "skill" ? (() => { try { return resolveStore("skill", skillStoreOpts); } catch { return store; } })() : store;
|
|
326
365
|
const doc = targetStore.findActiveDocument(r.collectionName, parsed);
|
|
327
366
|
if (!doc) return true;
|
|
328
367
|
if (doc.snoozed_until && new Date(doc.snoozed_until) > now) return false;
|
|
@@ -350,7 +389,7 @@ export async function contextSurfacing(
|
|
|
350
389
|
let enriched = enrichResults(store, generalResults, prompt);
|
|
351
390
|
if (skillResults.length > 0) {
|
|
352
391
|
try {
|
|
353
|
-
const skillStore = resolveStore("skill");
|
|
392
|
+
const skillStore = resolveStore("skill", skillStoreOpts);
|
|
354
393
|
enriched = [...enriched, ...enrichResults(skillStore, skillResults, prompt)];
|
|
355
394
|
} catch {
|
|
356
395
|
// Skill store unavailable — enrich with general store as fallback
|
|
@@ -476,7 +515,7 @@ export async function contextSurfacing(
|
|
|
476
515
|
writeRecallEvents(store, input.sessionId, qHash, mappedDocs, validUsageId, turnIndex);
|
|
477
516
|
} else {
|
|
478
517
|
try {
|
|
479
|
-
const vaultStore = resolveStore(vault);
|
|
518
|
+
const vaultStore = resolveStore(vault, skillStoreOpts);
|
|
480
519
|
// Mirror context_usage row into named vault for correct FK + attribution
|
|
481
520
|
const vaultPaths = docs.map(r => r.displayPath);
|
|
482
521
|
const vaultUsageId = vaultStore.insertUsage({
|
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
|
/**
|
|
@@ -697,7 +705,7 @@ export class LlamaCpp implements LLM {
|
|
|
697
705
|
// Remote server or cloud API — preferred path
|
|
698
706
|
if (this.remoteEmbedUrl && !this.isRemoteEmbedDown()) {
|
|
699
707
|
const extraParams = this.getCloudEmbedParams(!!options.isQuery);
|
|
700
|
-
const result = await this.embedRemote(text, extraParams);
|
|
708
|
+
const result = await this.embedRemote(text, extraParams, undefined, options.signal);
|
|
701
709
|
if (result) return result;
|
|
702
710
|
// Cloud providers don't fall back — if API key is set, the user chose cloud
|
|
703
711
|
if (this.isCloudEmbedding()) return null;
|
|
@@ -710,6 +718,13 @@ export class LlamaCpp implements LLM {
|
|
|
710
718
|
// Remote is in cooldown or was never configured — try local fallback
|
|
711
719
|
if (this.remoteEmbedUrl && this.isRemoteEmbedDown()) {
|
|
712
720
|
if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") return null;
|
|
721
|
+
// Medium-fix (B4): a deadline-bounded caller (query path, signal set)
|
|
722
|
+
// cannot afford a local model load/download during a remote cooldown —
|
|
723
|
+
// embedLocal ignores the abort signal and can run for seconds/minutes.
|
|
724
|
+
// Skip the local fallback and let the caller degrade (searchVec → [] →
|
|
725
|
+
// FTS). Pure-local mode (no remoteEmbedUrl) never enters this branch, so
|
|
726
|
+
// local-only deployments still embed.
|
|
727
|
+
if (options.signal) return null;
|
|
713
728
|
this.noteRemoteFallback(
|
|
714
729
|
"embed",
|
|
715
730
|
this.isLoopbackUrl(this.remoteEmbedUrl)
|
|
@@ -952,22 +967,47 @@ export class LlamaCpp implements LLM {
|
|
|
952
967
|
return Math.floor(delayMs * (0.75 + Math.random() * 0.5));
|
|
953
968
|
}
|
|
954
969
|
|
|
955
|
-
|
|
970
|
+
/**
|
|
971
|
+
* Sleep for `ms`, resolving early if `signal` aborts. Returns true if the
|
|
972
|
+
* wait was cut short by an abort (caller should stop retrying), false if it
|
|
973
|
+
* slept the full duration. Without this, a 429 backoff (up to 30s) would run
|
|
974
|
+
* to completion even after the caller's deadline elapsed (B4).
|
|
975
|
+
*/
|
|
976
|
+
private async abortableDelay(ms: number, signal?: AbortSignal): Promise<boolean> {
|
|
977
|
+
if (signal?.aborted) return true;
|
|
978
|
+
if (!signal) {
|
|
979
|
+
await new Promise(r => setTimeout(r, ms));
|
|
980
|
+
return false;
|
|
981
|
+
}
|
|
982
|
+
return await new Promise<boolean>((resolve) => {
|
|
983
|
+
const onAbort = () => { clearTimeout(timer); resolve(true); };
|
|
984
|
+
const timer = setTimeout(() => {
|
|
985
|
+
signal.removeEventListener("abort", onAbort);
|
|
986
|
+
resolve(false);
|
|
987
|
+
}, ms);
|
|
988
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
989
|
+
});
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
private async embedRemote(text: string, extraParams: Record<string, unknown> = {}, retries = 5, signal?: AbortSignal): Promise<EmbeddingResult | null> {
|
|
956
993
|
if (this.isRemoteEmbedDown()) return null;
|
|
957
994
|
const input = this.truncateForEmbed(text);
|
|
958
995
|
for (let attempt = 0; attempt < retries; attempt++) {
|
|
996
|
+
if (signal?.aborted) return null; // caller deadline already elapsed — do not start another attempt
|
|
959
997
|
try {
|
|
960
998
|
const body: Record<string, unknown> = { input, model: this.remoteEmbedModel, ...extraParams };
|
|
961
999
|
const resp = await fetch(`${this.remoteEmbedUrl}/v1/embeddings`, {
|
|
962
1000
|
method: "POST",
|
|
963
1001
|
headers: this.getEmbedHeaders(),
|
|
964
1002
|
body: JSON.stringify(body),
|
|
1003
|
+
signal,
|
|
965
1004
|
});
|
|
966
1005
|
if (resp.status === 429) {
|
|
967
1006
|
const retryAfter = this.parseRetryAfter(resp);
|
|
968
1007
|
const delay = retryAfter ?? Math.min(1000 * 2 ** attempt, 30000);
|
|
969
|
-
|
|
970
|
-
|
|
1008
|
+
const jittered = this.jitter(delay);
|
|
1009
|
+
console.error(`Remote embed rate-limited, retry ${attempt + 1}/${retries} in ${jittered}ms`);
|
|
1010
|
+
if (await this.abortableDelay(jittered, signal)) return null; // deadline elapsed during backoff
|
|
971
1011
|
continue;
|
|
972
1012
|
}
|
|
973
1013
|
if (!resp.ok) {
|
|
@@ -983,6 +1023,13 @@ export class LlamaCpp implements LLM {
|
|
|
983
1023
|
model: data.model || this.remoteEmbedUrl!,
|
|
984
1024
|
};
|
|
985
1025
|
} catch (error) {
|
|
1026
|
+
// An abort/timeout is an intentional caller-driven cancellation (the
|
|
1027
|
+
// query-path deadline), NOT a transport failure — do not trip the 60s
|
|
1028
|
+
// remote-down cooldown, which would needlessly force local fallback.
|
|
1029
|
+
const name = (error as { name?: string })?.name;
|
|
1030
|
+
if (signal?.aborted || name === "AbortError" || name === "TimeoutError") {
|
|
1031
|
+
return null;
|
|
1032
|
+
}
|
|
986
1033
|
if (this.isTransportError(error)) {
|
|
987
1034
|
this.markRemoteEmbedDown();
|
|
988
1035
|
} else {
|
package/src/store.ts
CHANGED
|
@@ -365,7 +365,7 @@ function loadVecExtension(db: Database): void {
|
|
|
365
365
|
}
|
|
366
366
|
}
|
|
367
367
|
|
|
368
|
-
function initializeDatabase(db: Database): void {
|
|
368
|
+
function initializeDatabase(db: Database, busyTimeoutMs: number = 15000): void {
|
|
369
369
|
// Set busy_timeout FIRST so subsequent PRAGMAs (journal_mode in particular,
|
|
370
370
|
// which acquires a write lock when switching or initializing WAL state) wait
|
|
371
371
|
// instead of returning SQLITE_BUSY when concurrent Stop hooks
|
|
@@ -373,10 +373,12 @@ function initializeDatabase(db: Database): void {
|
|
|
373
373
|
// before_reset hook fan-out in src/openclaw/engine.ts — open the DB
|
|
374
374
|
// simultaneously. busy_timeout is a connection-level setting that only
|
|
375
375
|
// governs *subsequent* statements (default busy handler is NULL → SQLITE_BUSY
|
|
376
|
-
// returns immediately), so it must precede the contending PRAGMAs.
|
|
377
|
-
// well within the 30s Stop hook timeout
|
|
376
|
+
// returns immediately), so it must precede the contending PRAGMAs. The init
|
|
377
|
+
// busy_timeout defaults to 15s (well within the 30s Stop hook timeout) but is
|
|
378
|
+
// capped to the caller's opts.busyTimeout — hook opens pass 5000 so init cannot
|
|
379
|
+
// wait out the 8-15s UserPromptSubmit budget. createStore() resets to operational
|
|
378
380
|
// value (5000ms or opts.busyTimeout) after DDL completes. Issue #13.
|
|
379
|
-
db.exec(
|
|
381
|
+
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
|
|
380
382
|
loadVecExtension(db);
|
|
381
383
|
db.exec("PRAGMA journal_mode = WAL");
|
|
382
384
|
db.exec("PRAGMA foreign_keys = ON");
|
|
@@ -450,9 +452,17 @@ function initializeDatabase(db: Database): void {
|
|
|
450
452
|
}
|
|
451
453
|
}
|
|
452
454
|
|
|
453
|
-
// Backfill last_accessed_at from modified_at for existing docs
|
|
455
|
+
// Backfill last_accessed_at from modified_at for existing docs.
|
|
456
|
+
// Guarded by a read first: on an already-backfilled DB the UPDATE is skipped, so a writable
|
|
457
|
+
// open takes NO write lock here and cannot wait on busy_timeout under concurrent writers.
|
|
458
|
+
// (The unconditional UPDATE previously ran on EVERY writable open — including the
|
|
459
|
+
// context-surfacing UserPromptSubmit hook — and could block up to busy_timeout when another
|
|
460
|
+
// process held the write lock, pushing the hook past its 8-15s deadline.)
|
|
454
461
|
try {
|
|
455
|
-
db.
|
|
462
|
+
const needsBackfill = db.prepare(`SELECT 1 FROM documents WHERE last_accessed_at IS NULL LIMIT 1`).get();
|
|
463
|
+
if (needsBackfill) {
|
|
464
|
+
db.exec(`UPDATE documents SET last_accessed_at = modified_at WHERE last_accessed_at IS NULL`);
|
|
465
|
+
}
|
|
456
466
|
} catch { /* ignore if already backfilled */ }
|
|
457
467
|
|
|
458
468
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_documents_collection ON documents(collection, active)`);
|
|
@@ -1136,6 +1146,83 @@ export function getVecTableDim(db: Database): number | null {
|
|
|
1136
1146
|
return readVecTableDim(db);
|
|
1137
1147
|
}
|
|
1138
1148
|
|
|
1149
|
+
/**
|
|
1150
|
+
* Prewarm the sqlite-vec payload into OS page cache with a single brute-force MATCH using a ZERO
|
|
1151
|
+
* query vector. Decoupled from the embedding server on purpose — it works when the embed server is
|
|
1152
|
+
* down at boot or CLAWMEM_NO_LOCAL_MODELS=true, unlike a searchVec()-based prewarm (which embeds
|
|
1153
|
+
* first and would silently no-op). Returns true ONLY if a scan actually ran (a vector table with a
|
|
1154
|
+
* known dimension exists), so callers never log a false-positive "warmed". The k-NN MATCH is
|
|
1155
|
+
* brute-force, so it touches every vector chunk — exactly the payload we want cache-resident.
|
|
1156
|
+
*/
|
|
1157
|
+
export function prewarmVectors(db: Database): boolean {
|
|
1158
|
+
const dim = getVecTableDim(db);
|
|
1159
|
+
if (!dim || dim <= 0) return false;
|
|
1160
|
+
const zero = new Float32Array(dim);
|
|
1161
|
+
db.prepare(`SELECT hash_seq FROM vectors_vec WHERE embedding MATCH ? AND k = ?`).all(zero, 1);
|
|
1162
|
+
return true;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Keep the sqlite-vec payload resident in the OS page cache by re-running the brute-force
|
|
1167
|
+
* prewarm on an interval. The one-shot prewarm at watcher startup warms the cache ONCE; on a
|
|
1168
|
+
* long-running host under memory pressure the kernel can evict the (potentially ~1.5 GB) vector
|
|
1169
|
+
* payload between hook calls, and the next cold SYNCHRONOUS MATCH in the context-surfacing hook
|
|
1170
|
+
* path can then blow the 8-15s hook budget (bun:sqlite exposes no interrupt, so an in-flight
|
|
1171
|
+
* scan cannot be abandoned). Re-touching the pages biases the kernel LRU toward keeping them
|
|
1172
|
+
* resident — a PROBABILITY reduction, NOT a hard cap. The hard cap (moving the blocking scan off
|
|
1173
|
+
* the hook's event loop so its deadline can fire) is the deferred BACKLOG Source 46 daemon.
|
|
1174
|
+
*
|
|
1175
|
+
* Best-effort: never throws; a per-tick failure is swallowed so the timer keeps running. Returns
|
|
1176
|
+
* the interval handle (the caller MUST clear it on shutdown) or null when disabled (intervalMs
|
|
1177
|
+
* <= 0 or non-finite). The handle is unref'd so it never by itself keeps the process alive.
|
|
1178
|
+
* `onPrewarm(ran)` is an optional observability hook fired after each attempt — `ran` is whether
|
|
1179
|
+
* a scan actually executed (i.e. a dimensioned vector table exists).
|
|
1180
|
+
*/
|
|
1181
|
+
export function startPeriodicPrewarm(
|
|
1182
|
+
db: Database,
|
|
1183
|
+
intervalMs: number,
|
|
1184
|
+
onPrewarm?: (ran: boolean) => void,
|
|
1185
|
+
): ReturnType<typeof setInterval> | null {
|
|
1186
|
+
if (!Number.isFinite(intervalMs) || intervalMs <= 0) return null;
|
|
1187
|
+
const timer = setInterval(() => {
|
|
1188
|
+
let ran = false;
|
|
1189
|
+
try { ran = prewarmVectors(db); } catch { /* best-effort: unexpected SQL error */ }
|
|
1190
|
+
if (onPrewarm) { try { onPrewarm(ran); } catch { /* observer must never break the timer */ } }
|
|
1191
|
+
}, intervalMs);
|
|
1192
|
+
(timer as { unref?: () => void }).unref?.();
|
|
1193
|
+
return timer;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/** Floor for the periodic re-prewarm interval. Below this, a large-vault (~1.5 GB) brute-force scan
|
|
1197
|
+
* runs near-continuously and can saturate the watcher event loop + I/O, so any smaller positive
|
|
1198
|
+
* request is clamped UP to this value. */
|
|
1199
|
+
export const PREWARM_MIN_INTERVAL_MS = 60_000;
|
|
1200
|
+
/** Default periodic re-prewarm interval when CLAWMEM_PREWARM_INTERVAL_MS is unset or unparseable. */
|
|
1201
|
+
export const PREWARM_DEFAULT_INTERVAL_MS = 600_000;
|
|
1202
|
+
|
|
1203
|
+
/**
|
|
1204
|
+
* Resolve the raw CLAWMEM_PREWARM_INTERVAL_MS env value into a SAFE interval for the watcher. This
|
|
1205
|
+
* policy is kept OUT of the permissive `startPeriodicPrewarm` mechanism (so unit tests can still use
|
|
1206
|
+
* tiny intervals) and applied only on the production env path.
|
|
1207
|
+
* - unset / empty / unparseable → default (600000). Garbage must NOT silently disable the
|
|
1208
|
+
* mitigation, nor be read as a tiny interval.
|
|
1209
|
+
* - exactly 0 → 0 (the documented off switch; `startPeriodicPrewarm` then returns null).
|
|
1210
|
+
* - negative → default (nonsensical; neither an intentional disable nor a fast loop).
|
|
1211
|
+
* - 0 < n < floor → clamped UP to the 60s floor. Prevents the near-continuous scan loop that e.g.
|
|
1212
|
+
* "1" or "1e3" would otherwise schedule. NOTE: `Number("1e3") === 1000` whereas
|
|
1213
|
+
* `parseInt("1e3", 10) === 1`, so `Number()` is used deliberately (parseInt silently truncates
|
|
1214
|
+
* at the "e").
|
|
1215
|
+
* - n >= floor → floored to an integer and used as-is.
|
|
1216
|
+
*/
|
|
1217
|
+
export function resolvePrewarmIntervalMs(raw: string | undefined): number {
|
|
1218
|
+
if (raw === undefined || raw.trim() === "") return PREWARM_DEFAULT_INTERVAL_MS;
|
|
1219
|
+
const n = Number(raw);
|
|
1220
|
+
if (!Number.isFinite(n)) return PREWARM_DEFAULT_INTERVAL_MS;
|
|
1221
|
+
if (n === 0) return 0;
|
|
1222
|
+
if (n < 0) return PREWARM_DEFAULT_INTERVAL_MS;
|
|
1223
|
+
return Math.max(PREWARM_MIN_INTERVAL_MS, Math.floor(n));
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1139
1226
|
/**
|
|
1140
1227
|
* The DISTINCT non-empty embedding models stored in the vault (empty array if no
|
|
1141
1228
|
* embeddings exist). Used to detect model drift BETWEEN runs — a different model at
|
|
@@ -1204,7 +1291,7 @@ export type Store = {
|
|
|
1204
1291
|
|
|
1205
1292
|
// Search
|
|
1206
1293
|
searchFTS: (query: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => SearchResult[];
|
|
1207
|
-
searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => Promise<SearchResult[]>;
|
|
1294
|
+
searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, deadlineMs?: number) => Promise<SearchResult[]>;
|
|
1208
1295
|
|
|
1209
1296
|
// Query expansion & reranking
|
|
1210
1297
|
expandQuery: (query: string, model?: string, intent?: string) => Promise<ExpandedQuery[]>;
|
|
@@ -1339,7 +1426,7 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1339
1426
|
? new Database(resolvedPath, { readonly: true })
|
|
1340
1427
|
: new Database(resolvedPath);
|
|
1341
1428
|
if (!opts?.readonly) {
|
|
1342
|
-
initializeDatabase(db);
|
|
1429
|
+
initializeDatabase(db, opts?.busyTimeout ?? 15000);
|
|
1343
1430
|
} else {
|
|
1344
1431
|
// Readonly: set busy_timeout FIRST so the journal_mode PRAGMA below
|
|
1345
1432
|
// doesn't race when concurrent processes open the DB. PRAGMA
|
|
@@ -1352,7 +1439,7 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1352
1439
|
db.exec("PRAGMA journal_mode = WAL");
|
|
1353
1440
|
db.exec("PRAGMA query_only = ON");
|
|
1354
1441
|
}
|
|
1355
|
-
// For the writable branch: initializeDatabase() set 15000 during DDL —
|
|
1442
|
+
// For the writable branch: initializeDatabase() set opts.busyTimeout (default 15000) during DDL —
|
|
1356
1443
|
// reset to operational value here. For readonly: already set inside the
|
|
1357
1444
|
// branch above; this assignment is a no-op rewrite to the same value.
|
|
1358
1445
|
db.exec(`PRAGMA busy_timeout = ${opts?.busyTimeout ?? 5000}`);
|
|
@@ -1398,7 +1485,7 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
|
|
|
1398
1485
|
|
|
1399
1486
|
// Search
|
|
1400
1487
|
searchFTS: (query: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => searchFTS(db, query, limit, collectionId, collections, dateRange),
|
|
1401
|
-
searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }) => searchVec(db, query, model, limit, collectionId, collections, dateRange),
|
|
1488
|
+
searchVec: (query: string, model: string, limit?: number, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }, deadlineMs?: number) => searchVec(db, query, model, limit, collectionId, collections, dateRange, deadlineMs),
|
|
1402
1489
|
|
|
1403
1490
|
// Query expansion & reranking
|
|
1404
1491
|
expandQuery: (query: string, model?: string, intent?: string) => expandQuery(query, model, db, intent),
|
|
@@ -3361,13 +3448,19 @@ export function searchFTS(db: Database, query: string, limit: number = 20, colle
|
|
|
3361
3448
|
// Vector Search
|
|
3362
3449
|
// =============================================================================
|
|
3363
3450
|
|
|
3364
|
-
export async function searchVec(db: Database, query: string, model: string, limit: number = 20, collectionId?: number, collections?: string[], dateRange?: { start: string; end: string }): Promise<SearchResult[]> {
|
|
3451
|
+
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[]> {
|
|
3365
3452
|
const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
3366
3453
|
if (!tableExists) return [];
|
|
3367
3454
|
|
|
3368
|
-
const embedding = await getEmbedding(query, model, true);
|
|
3455
|
+
const embedding = await getEmbedding(query, model, true, deadlineMs);
|
|
3369
3456
|
if (!embedding) return [];
|
|
3370
3457
|
|
|
3458
|
+
// Guard-defect fix: the caller's Promise.race(vectorTimeout) cannot interrupt the SYNCHRONOUS
|
|
3459
|
+
// sqlite-vec MATCH below (bun:sqlite blocks the event loop) and does NOT cancel this promise.
|
|
3460
|
+
// If the wall-clock budget already elapsed during the async embed above, bail here so a
|
|
3461
|
+
// timed-out vector leg cannot resume and re-block the hook after it fell back to FTS.
|
|
3462
|
+
if (deadlineMs !== undefined && Date.now() >= deadlineMs) return [];
|
|
3463
|
+
|
|
3371
3464
|
// IMPORTANT: We use a two-step query approach here because sqlite-vec virtual tables
|
|
3372
3465
|
// hang indefinitely when combined with JOINs in the same query. Do NOT try to
|
|
3373
3466
|
// "optimize" this by combining into a single query with JOINs - it will break.
|
|
@@ -3467,11 +3560,22 @@ export async function searchVec(db: Database, query: string, model: string, limi
|
|
|
3467
3560
|
// Embeddings
|
|
3468
3561
|
// =============================================================================
|
|
3469
3562
|
|
|
3470
|
-
async function getEmbedding(text: string, model: string, isQuery: boolean): Promise<number[] | null> {
|
|
3563
|
+
async function getEmbedding(text: string, model: string, isQuery: boolean, deadlineMs?: number): Promise<number[] | null> {
|
|
3471
3564
|
const llm = getDefaultLlamaCpp();
|
|
3472
3565
|
// Format text using the appropriate prompt template
|
|
3473
3566
|
const formattedText = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text);
|
|
3474
|
-
|
|
3567
|
+
// B4: bound the remote embed fetch + its 429 backoff to the caller's wall-clock
|
|
3568
|
+
// deadline. Under the context-surfacing hook's Promise.race the abandoned embed
|
|
3569
|
+
// promise otherwise keeps its fetch + retry sleeps running; AbortSignal.timeout
|
|
3570
|
+
// actually cancels them, so a slow/rate-limited embed can no longer outlive the
|
|
3571
|
+
// hook budget.
|
|
3572
|
+
let signal: AbortSignal | undefined;
|
|
3573
|
+
if (deadlineMs !== undefined) {
|
|
3574
|
+
const remaining = deadlineMs - Date.now();
|
|
3575
|
+
if (remaining <= 0) return null; // deadline already elapsed — skip the embed entirely
|
|
3576
|
+
signal = AbortSignal.timeout(remaining);
|
|
3577
|
+
}
|
|
3578
|
+
const result = await llm.embed(formattedText, { model, isQuery, signal });
|
|
3475
3579
|
return result?.embedding || null;
|
|
3476
3580
|
}
|
|
3477
3581
|
|