clawmem 0.16.0 → 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/package.json +1 -1
- package/src/clawmem.ts +39 -0
- package/src/hooks/context-surfacing.ts +19 -4
- package/src/hooks.ts +19 -7
- package/src/llm.ts +51 -4
- package/src/store.ts +75 -3
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) => {
|
|
@@ -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
|
|
@@ -221,7 +236,7 @@ export async function contextSurfacing(
|
|
|
221
236
|
// Dual-query: also search skill vault if configured (secondary source)
|
|
222
237
|
if (getVaultPath("skill")) {
|
|
223
238
|
try {
|
|
224
|
-
const skillStore = resolveStore("skill");
|
|
239
|
+
const skillStore = resolveStore("skill", skillStoreOpts);
|
|
225
240
|
const skillResults = skillStore.searchFTS(retrievalQuery, 5);
|
|
226
241
|
// Tag skill vault results for identification in output
|
|
227
242
|
for (const r of skillResults) {
|
|
@@ -346,7 +361,7 @@ export async function contextSurfacing(
|
|
|
346
361
|
// expects the collection-relative path, not the full virtual path
|
|
347
362
|
const parsed = r.filepath.startsWith('clawmem://') ? r.filepath.replace(/^clawmem:\/\/[^/]+\/?/, '') : r.filepath;
|
|
348
363
|
// Use the correct store for skill-vault results
|
|
349
|
-
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;
|
|
350
365
|
const doc = targetStore.findActiveDocument(r.collectionName, parsed);
|
|
351
366
|
if (!doc) return true;
|
|
352
367
|
if (doc.snoozed_until && new Date(doc.snoozed_until) > now) return false;
|
|
@@ -374,7 +389,7 @@ export async function contextSurfacing(
|
|
|
374
389
|
let enriched = enrichResults(store, generalResults, prompt);
|
|
375
390
|
if (skillResults.length > 0) {
|
|
376
391
|
try {
|
|
377
|
-
const skillStore = resolveStore("skill");
|
|
392
|
+
const skillStore = resolveStore("skill", skillStoreOpts);
|
|
378
393
|
enriched = [...enriched, ...enrichResults(skillStore, skillResults, prompt)];
|
|
379
394
|
} catch {
|
|
380
395
|
// Skill store unavailable — enrich with general store as fallback
|
|
@@ -500,7 +515,7 @@ export async function contextSurfacing(
|
|
|
500
515
|
writeRecallEvents(store, input.sessionId, qHash, mappedDocs, validUsageId, turnIndex);
|
|
501
516
|
} else {
|
|
502
517
|
try {
|
|
503
|
-
const vaultStore = resolveStore(vault);
|
|
518
|
+
const vaultStore = resolveStore(vault, skillStoreOpts);
|
|
504
519
|
// Mirror context_usage row into named vault for correct FK + attribution
|
|
505
520
|
const vaultPaths = docs.map(r => r.displayPath);
|
|
506
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
|
@@ -1162,6 +1162,67 @@ export function prewarmVectors(db: Database): boolean {
|
|
|
1162
1162
|
return true;
|
|
1163
1163
|
}
|
|
1164
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
|
+
|
|
1165
1226
|
/**
|
|
1166
1227
|
* The DISTINCT non-empty embedding models stored in the vault (empty array if no
|
|
1167
1228
|
* embeddings exist). Used to detect model drift BETWEEN runs — a different model at
|
|
@@ -3391,7 +3452,7 @@ export async function searchVec(db: Database, query: string, model: string, limi
|
|
|
3391
3452
|
const tableExists = db.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`).get();
|
|
3392
3453
|
if (!tableExists) return [];
|
|
3393
3454
|
|
|
3394
|
-
const embedding = await getEmbedding(query, model, true);
|
|
3455
|
+
const embedding = await getEmbedding(query, model, true, deadlineMs);
|
|
3395
3456
|
if (!embedding) return [];
|
|
3396
3457
|
|
|
3397
3458
|
// Guard-defect fix: the caller's Promise.race(vectorTimeout) cannot interrupt the SYNCHRONOUS
|
|
@@ -3499,11 +3560,22 @@ export async function searchVec(db: Database, query: string, model: string, limi
|
|
|
3499
3560
|
// Embeddings
|
|
3500
3561
|
// =============================================================================
|
|
3501
3562
|
|
|
3502
|
-
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> {
|
|
3503
3564
|
const llm = getDefaultLlamaCpp();
|
|
3504
3565
|
// Format text using the appropriate prompt template
|
|
3505
3566
|
const formattedText = isQuery ? formatQueryForEmbedding(text) : formatDocForEmbedding(text);
|
|
3506
|
-
|
|
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 });
|
|
3507
3579
|
return result?.embedding || null;
|
|
3508
3580
|
}
|
|
3509
3581
|
|