clawmem 0.10.4 → 0.10.6

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/SKILL.md CHANGED
@@ -332,7 +332,7 @@ The query string directly feeds BM25 (which probes first and can short-circuit t
332
332
  **For keyword recall (BM25 path):**
333
333
  - 2-5 precise terms, no filler words
334
334
  - Code identifiers work: `handleError async`
335
- - BM25 tokenizes on whitespace and AND's all terms as prefix matches (`perf` matches "performance")
335
+ - BM25 tokenizes on whitespace and separators (`_ - . /` etc., mirroring the index), then AND's all terms as prefix matches (`perf` matches "performance"; `before_compaction` matches docs containing `before` and `compaction`)
336
336
  - No phrase search or negation syntax — all terms are positive prefix matches
337
337
  - A strong keyword hit (score >= 0.85 with gap >= 0.15) skips expansion entirely — faster results
338
338
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "clawmem",
3
- "version": "0.10.4",
3
+ "version": "0.10.6",
4
4
  "description": "On-device memory layer for AI agents. Claude Code, OpenClaw, and Hermes. Hooks + MCP server + hybrid RAG search.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/amem.ts CHANGED
@@ -588,6 +588,25 @@ export interface MemoryLink {
588
588
  reasoning: string;
589
589
  }
590
590
 
591
+ function isVectorIndexUnavailable(error: unknown): boolean {
592
+ const message = String((error as any)?.message ?? error).toLowerCase();
593
+ return message.includes("no such table: vectors_vec") ||
594
+ message.includes("no such column: hash_seq") ||
595
+ message.includes("no such column: embedding") ||
596
+ message.includes("no such column: v1.embedding") ||
597
+ message.includes("no such column: v2.embedding") ||
598
+ message.includes("no such function: vec_distance_cosine") ||
599
+ message.includes("no such module: vec0");
600
+ }
601
+
602
+ function isSqlSchemaError(error: unknown): boolean {
603
+ const message = String((error as any)?.message ?? error).toLowerCase();
604
+ return message.includes("no such column:") ||
605
+ message.includes("no such table:") ||
606
+ message.includes("no such function:") ||
607
+ message.includes("no such module:");
608
+ }
609
+
591
610
  /**
592
611
  * Generate typed memory links for a document based on semantic similarity.
593
612
  * Finds k-nearest neighbors and uses LLM to determine relationship types.
@@ -617,26 +636,49 @@ export async function generateMemoryLinks(
617
636
  return 0;
618
637
  }
619
638
 
639
+ // Indexing may run before vector tables/embeddings are ready.
640
+ // Degrade quietly here instead of surfacing misleading SQL errors.
641
+ const vecTableExists = !!store.db.prepare(
642
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='vectors_vec'`
643
+ ).get();
644
+ if (!vecTableExists) return 0;
645
+
620
646
  // Find k-nearest neighbors using vector similarity
621
- const neighbors = store.db.prepare(`
622
- SELECT
623
- d2.id as target_id,
624
- d2.title as target_title,
625
- d2.amem_context as target_context,
626
- vec_distance_cosine(v1.embedding, v2.embedding) as distance
627
- FROM vectors_vec v1, vectors_vec v2
628
- JOIN documents d2 ON v2.hash_seq = d2.hash || '_0'
629
- WHERE v1.hash_seq = ? || '_0'
630
- AND d2.id != ?
631
- AND d2.active = 1
632
- ORDER BY distance
633
- LIMIT ?
634
- `).all(sourceDoc.hash, sourceDoc.id, kNeighbors) as {
647
+ let neighbors: {
635
648
  target_id: number;
636
649
  target_title: string;
637
650
  target_context: string | null;
638
651
  distance: number;
639
- }[];
652
+ }[] = [];
653
+ try {
654
+ const sourceVectorExists = !!store.db.prepare(
655
+ `SELECT 1 FROM vectors_vec WHERE hash_seq = ? LIMIT 1`
656
+ ).get(`${sourceDoc.hash}_0`);
657
+ if (!sourceVectorExists) return 0;
658
+
659
+ neighbors = store.db.prepare(`
660
+ SELECT
661
+ d2.id as target_id,
662
+ d2.title as target_title,
663
+ d2.amem_context as target_context,
664
+ vec_distance_cosine(v1.embedding, v2.embedding) as distance
665
+ FROM vectors_vec v1, vectors_vec v2
666
+ JOIN documents d2 ON v2.hash_seq = d2.hash || '_0'
667
+ WHERE v1.hash_seq = ? || '_0'
668
+ AND d2.id != ?
669
+ AND d2.active = 1
670
+ ORDER BY distance
671
+ LIMIT ?
672
+ `).all(sourceDoc.hash, sourceDoc.id, kNeighbors) as {
673
+ target_id: number;
674
+ target_title: string;
675
+ target_context: string | null;
676
+ distance: number;
677
+ }[];
678
+ } catch (error) {
679
+ if (isVectorIndexUnavailable(error)) return 0;
680
+ throw error;
681
+ }
640
682
 
641
683
  if (neighbors.length === 0) {
642
684
  console.log(`[amem] No neighbors found for docId ${docId}`);
@@ -737,6 +779,9 @@ Include all ${neighbors.length} neighbors in your response.`;
737
779
  console.log(`[amem] Created ${linksCreated} links for docId ${docId}`);
738
780
  return linksCreated;
739
781
  } catch (err) {
782
+ if (isSqlSchemaError(err) && !isVectorIndexUnavailable(err)) {
783
+ throw err;
784
+ }
740
785
  console.log(`[amem] Error generating memory links for docId ${docId}:`, err);
741
786
  return 0;
742
787
  }
package/src/entity.ts CHANGED
@@ -11,6 +11,7 @@ import type { Database } from "bun:sqlite";
11
11
  import { createHash } from "crypto";
12
12
  import type { LLM } from "./llm.ts";
13
13
  import { extractJsonFromLLM } from "./amem.ts";
14
+ import { tokenizeForFTS5 } from "./store.ts";
14
15
 
15
16
  // =============================================================================
16
17
  // Types
@@ -286,6 +287,33 @@ Return ONLY the JSON array. /no_think`;
286
287
  // Entity Resolution (canonical normalization)
287
288
  // =============================================================================
288
289
 
290
+ /**
291
+ * Exact-first entity FTS candidate gathering.
292
+ *
293
+ * `entities_fts` is queried with a LIMIT applied BEFORE the downstream
294
+ * Levenshtein / mention-count ranking. A pure prefix query (`"tok"*`) can match
295
+ * a large set (e.g. "Go" -> "go"* matches every "Golang*"), filling the LIMIT
296
+ * pool and starving the exact row out before it can be ranked. So we gather
297
+ * exact-token matches first, then top up with prefix matches (deduped by
298
+ * entity_id) only while under the limit — the exact row is therefore always
299
+ * present for ranking, and multi-char prefix recall (e.g. "clawme"* ->
300
+ * "clawmem") is preserved as a supplement. `runMatch` runs the caller's own SQL
301
+ * because vault scoping / ordering / selected columns differ per call site.
302
+ */
303
+ function gatherEntityFTSCandidates<T extends { entity_id: string }>(
304
+ tokens: string[],
305
+ limit: number,
306
+ runMatch: (matchExpr: string, lim: number) => T[],
307
+ ): T[] {
308
+ if (tokens.length === 0) return [];
309
+ const exact = runMatch(tokens.map(t => `"${t}"`).join(' OR '), limit);
310
+ if (exact.length >= limit) return exact;
311
+ const seen = new Set(exact.map(r => r.entity_id));
312
+ const prefix = runMatch(tokens.map(t => `"${t}"*`).join(' OR '), limit)
313
+ .filter(r => !seen.has(r.entity_id));
314
+ return exact.concat(prefix).slice(0, limit);
315
+ }
316
+
289
317
  /**
290
318
  * Resolve an entity name to its canonical form.
291
319
  * Uses FTS5 candidate lookup + Levenshtein fuzzy matching.
@@ -316,13 +344,14 @@ export function resolveEntityCanonical(
316
344
  // Step 1: FTS5 candidate lookup — type-agnostic, vault-scoped
317
345
  let candidates: { entity_id: string; name: string; entity_type: string }[] = [];
318
346
  try {
319
- candidates = db.prepare(`
320
- SELECT f.entity_id, f.name, f.entity_type
321
- FROM entities_fts f
322
- JOIN entity_nodes e ON e.entity_id = f.entity_id
323
- WHERE entities_fts MATCH ? AND e.vault = ?
324
- LIMIT 20
325
- `).all(normalizedName.split(/\s+/).map(w => `"${w}"`).join(' OR '), vault) as typeof candidates;
347
+ candidates = gatherEntityFTSCandidates(tokenizeForFTS5(normalizedName), 20, (matchExpr, lim) =>
348
+ db.prepare(`
349
+ SELECT f.entity_id, f.name, f.entity_type
350
+ FROM entities_fts f
351
+ JOIN entity_nodes e ON e.entity_id = f.entity_id
352
+ WHERE entities_fts MATCH ? AND e.vault = ?
353
+ LIMIT ?
354
+ `).all(matchExpr, vault, lim) as typeof candidates);
326
355
  } catch {
327
356
  // FTS5 match may fail on special chars — fall back to LIKE on entity_nodes directly
328
357
  candidates = db.prepare(`
@@ -853,14 +882,15 @@ export function searchEntities(
853
882
  // Try FTS first
854
883
  let results: { entity_id: string; name: string; entity_type: string; mention_count: number }[] = [];
855
884
  try {
856
- results = db.prepare(`
857
- SELECT e.entity_id, e.name, e.entity_type, e.mention_count
858
- FROM entities_fts f
859
- JOIN entity_nodes e ON e.entity_id = f.entity_id
860
- WHERE entities_fts MATCH ?
861
- ORDER BY e.mention_count DESC
862
- LIMIT ?
863
- `).all(normalizedQuery.split(/\s+/).map(w => `"${w}"`).join(' OR '), limit) as typeof results;
885
+ results = gatherEntityFTSCandidates(tokenizeForFTS5(normalizedQuery), limit, (matchExpr, lim) =>
886
+ db.prepare(`
887
+ SELECT e.entity_id, e.name, e.entity_type, e.mention_count
888
+ FROM entities_fts f
889
+ JOIN entity_nodes e ON e.entity_id = f.entity_id
890
+ WHERE entities_fts MATCH ?
891
+ ORDER BY e.mention_count DESC
892
+ LIMIT ?
893
+ `).all(matchExpr, lim) as typeof results);
864
894
  } catch {
865
895
  // Fallback to LIKE
866
896
  results = db.prepare(`
package/src/llm.ts CHANGED
@@ -332,6 +332,8 @@ export class LlamaCpp implements LLM {
332
332
  // Resets after cooldown expires — one network hiccup doesn't permanently disable GPU.
333
333
  private remoteEmbedDownUntil = 0;
334
334
  private remoteLlmDownUntil = 0;
335
+ private remoteEmbedFallbackNotifiedUntil = 0;
336
+ private remoteLlmFallbackNotifiedUntil = 0;
335
337
  private static readonly REMOTE_COOLDOWN_MS = 60_000; // 60s cooldown on transport failure
336
338
 
337
339
  constructor(config: LlamaCppConfig = {}) {
@@ -616,13 +618,21 @@ export class LlamaCpp implements LLM {
616
618
  if (result) return result;
617
619
  // Cloud providers don't fall back — if API key is set, the user chose cloud
618
620
  if (this.isCloudEmbedding()) return null;
621
+ // HTTP/API errors mean the endpoint is reachable; only transport
622
+ // failures set cooldown and fall through to local fallback.
623
+ if (!this.isRemoteEmbedDown()) return null;
619
624
  // Transport failure already set cooldown in embedRemote — fall through
620
625
  }
621
626
 
622
627
  // Remote is in cooldown or was never configured — try local fallback
623
628
  if (this.remoteEmbedUrl && this.isRemoteEmbedDown()) {
624
629
  if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") return null;
625
- console.error("[embed] Remote embed in cooldown, using in-process fallback");
630
+ this.noteRemoteFallback(
631
+ "embed",
632
+ this.isLoopbackUrl(this.remoteEmbedUrl)
633
+ ? "[embed] Local embedding endpoint unavailable; using in-process fallback during cooldown"
634
+ : "[embed] Remote embed in cooldown, using in-process fallback"
635
+ );
626
636
  }
627
637
 
628
638
  // In-process fallback via node-llama-cpp (auto-downloads EmbeddingGemma on first use)
@@ -645,13 +655,21 @@ export class LlamaCpp implements LLM {
645
655
  if (results.some(r => r !== null)) return results;
646
656
  // Cloud providers don't fall back
647
657
  if (this.isCloudEmbedding()) return results;
658
+ // HTTP/API errors mean the endpoint is reachable; only transport
659
+ // failures set cooldown and fall through to local fallback.
660
+ if (!this.isRemoteEmbedDown()) return results;
648
661
  // Transport failure already set cooldown in embedRemoteBatch — fall through
649
662
  }
650
663
 
651
664
  // Remote is in cooldown or was never configured — try local fallback
652
665
  if (this.remoteEmbedUrl && this.isRemoteEmbedDown()) {
653
666
  if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") return texts.map(() => null);
654
- console.error("[embed] Remote embed in cooldown, using in-process fallback");
667
+ this.noteRemoteFallback(
668
+ "embed",
669
+ this.isLoopbackUrl(this.remoteEmbedUrl)
670
+ ? "[embed] Local embedding endpoint unavailable; using in-process fallback during cooldown"
671
+ : "[embed] Remote embed in cooldown, using in-process fallback"
672
+ );
655
673
  }
656
674
 
657
675
  // In-process fallback via node-llama-cpp
@@ -717,7 +735,9 @@ export class LlamaCpp implements LLM {
717
735
  code === "UND_ERR_CONNECT_TIMEOUT") return true;
718
736
  const msg = String((error as any)?.message || "").toLowerCase();
719
737
  if (msg.includes("econnrefused") || msg.includes("etimedout") || msg.includes("enotfound") ||
720
- msg.includes("ehostunreach") || msg.includes("enetunreach")) return true;
738
+ msg.includes("ehostunreach") || msg.includes("enetunreach") ||
739
+ msg.includes("unable to connect") || msg.includes("connectionrefused") ||
740
+ msg.includes("connection refused")) return true;
721
741
  return false;
722
742
  }
723
743
 
@@ -734,12 +754,53 @@ export class LlamaCpp implements LLM {
734
754
  return Date.now() < this.remoteEmbedDownUntil;
735
755
  }
736
756
 
757
+ private isLoopbackUrl(url: string | null | undefined): boolean {
758
+ if (!url) return false;
759
+ try {
760
+ const parsed = new URL(url);
761
+ return parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
762
+ } catch {
763
+ const lower = url.toLowerCase();
764
+ return lower.includes("localhost") || lower.includes("127.0.0.1") || lower.includes("[::1]");
765
+ }
766
+ }
767
+
768
+ private noteRemoteFallback(kind: "embed" | "llm", message: string): void {
769
+ const now = Date.now();
770
+ if (kind === "embed") {
771
+ if (now < this.remoteEmbedFallbackNotifiedUntil) return;
772
+ this.remoteEmbedFallbackNotifiedUntil = this.remoteEmbedDownUntil || (now + LlamaCpp.REMOTE_COOLDOWN_MS);
773
+ if (this.isLoopbackUrl(this.remoteEmbedUrl)) console.warn(message);
774
+ else console.error(message);
775
+ return;
776
+ }
777
+
778
+ if (now < this.remoteLlmFallbackNotifiedUntil) return;
779
+ this.remoteLlmFallbackNotifiedUntil = this.remoteLlmDownUntil || (now + LlamaCpp.REMOTE_COOLDOWN_MS);
780
+ if (this.isLoopbackUrl(this.remoteLlmUrl)) console.warn(message);
781
+ else console.error(message);
782
+ }
783
+
737
784
  private markRemoteLlmDown(): void {
738
785
  this.remoteLlmDownUntil = Date.now() + LlamaCpp.REMOTE_COOLDOWN_MS;
786
+ this.remoteLlmFallbackNotifiedUntil = 0;
787
+ this.noteRemoteFallback(
788
+ "llm",
789
+ this.isLoopbackUrl(this.remoteLlmUrl)
790
+ ? "[generate] Local LLM endpoint unavailable, cooldown 60s before retry"
791
+ : "[generate] Remote LLM unreachable, cooldown 60s before retry"
792
+ );
739
793
  }
740
794
 
741
795
  private markRemoteEmbedDown(): void {
742
796
  this.remoteEmbedDownUntil = Date.now() + LlamaCpp.REMOTE_COOLDOWN_MS;
797
+ this.remoteEmbedFallbackNotifiedUntil = 0;
798
+ this.noteRemoteFallback(
799
+ "embed",
800
+ this.isLoopbackUrl(this.remoteEmbedUrl)
801
+ ? "[embed] Local embedding endpoint unavailable, cooldown 60s before retry"
802
+ : "[embed] Remote embed server unreachable, cooldown 60s before retry"
803
+ );
743
804
  }
744
805
 
745
806
  // ---------- Remote embedding (GPU server or cloud API via /v1/embeddings) ----------
@@ -840,7 +901,6 @@ export class LlamaCpp implements LLM {
840
901
  };
841
902
  } catch (error) {
842
903
  if (this.isTransportError(error)) {
843
- console.error("[embed] Remote embed server unreachable, cooldown 60s");
844
904
  this.markRemoteEmbedDown();
845
905
  } else {
846
906
  console.error("[embed] Remote embed error:", error);
@@ -892,7 +952,6 @@ export class LlamaCpp implements LLM {
892
952
  return results;
893
953
  } catch (error) {
894
954
  if (this.isTransportError(error)) {
895
- console.error("[embed] Remote batch embed server unreachable, cooldown 60s");
896
955
  this.markRemoteEmbedDown();
897
956
  } else {
898
957
  console.error("[embed] Remote batch embed error:", error);
@@ -920,7 +979,12 @@ export class LlamaCpp implements LLM {
920
979
  // Remote is in cooldown or was never configured — try local fallback
921
980
  if (this.remoteLlmUrl && this.isRemoteLlmDown()) {
922
981
  if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") return null;
923
- console.error("[generate] Remote LLM in cooldown, falling back to in-process generation");
982
+ this.noteRemoteFallback(
983
+ "llm",
984
+ this.isLoopbackUrl(this.remoteLlmUrl)
985
+ ? "[generate] Local LLM endpoint unavailable; using in-process generation during cooldown"
986
+ : "[generate] Remote LLM in cooldown, falling back to in-process generation"
987
+ );
924
988
  }
925
989
 
926
990
  // Local fallback via node-llama-cpp (CPU)
@@ -1000,7 +1064,6 @@ export class LlamaCpp implements LLM {
1000
1064
  return null;
1001
1065
  }
1002
1066
  if (this.isTransportError(error)) {
1003
- console.error("[generate] Remote LLM server unreachable, cooldown 60s");
1004
1067
  this.markRemoteLlmDown();
1005
1068
  } else {
1006
1069
  console.error("[generate] Remote LLM error:", error);
@@ -1089,7 +1152,12 @@ Output:`;
1089
1152
  if (includeLexical) fallback.unshift({ type: 'lex', text: query });
1090
1153
  return fallback;
1091
1154
  }
1092
- console.error("[expandQuery] Remote LLM in cooldown, falling back to in-process grammar expansion");
1155
+ this.noteRemoteFallback(
1156
+ "llm",
1157
+ this.isLoopbackUrl(this.remoteLlmUrl)
1158
+ ? "[expandQuery] Local LLM endpoint unavailable; using in-process grammar expansion during cooldown"
1159
+ : "[expandQuery] Remote LLM in cooldown, falling back to in-process grammar expansion"
1160
+ );
1093
1161
  }
1094
1162
 
1095
1163
  const llama = await this.ensureLlama();
package/src/memory.ts CHANGED
@@ -254,6 +254,26 @@ export type ScoredResult = EnrichedResult & {
254
254
 
255
255
  export type CoActivationFn = (path: string) => { path: string; count: number }[];
256
256
 
257
+ const STABLE_PROFILE_PATTERNS = [
258
+ /\b(pronouns?|timezone|time\s*zone|location|locale|home\s+base)\b/i,
259
+ /\b(preferences?|prefers?|identity|profile|bio|about\s+me)\b/i,
260
+ /\b(who\s+(am\s+i|is\s+\w+)|what\s+(do\s+i|does\s+\w+)\s+(prefer|use|avoid))\b/i,
261
+ ];
262
+
263
+ function hasStableProfileIntent(query: string): boolean {
264
+ return STABLE_PROFILE_PATTERNS.some(p => p.test(query));
265
+ }
266
+
267
+ function canonicalMemoryMultiplier(path: string, contentType: string, query: string): number {
268
+ if (hasRecencyIntent(query) || !hasStableProfileIntent(query)) return 1.0;
269
+
270
+ const lower = path.toLowerCase();
271
+ if (/(^|\/)(core\/memory|memory\/core|profile|identity|soul)\.md$/.test(lower)) return 1.14;
272
+ if (/(^|\/)users\/[^/]+\.md$/.test(lower)) return 1.14;
273
+ if (contentType === "preference") return 1.08;
274
+ return 1.0;
275
+ }
276
+
257
277
  export function applyCompositeScoring(
258
278
  results: EnrichedResult[],
259
279
  query: string,
@@ -291,6 +311,8 @@ export function applyCompositeScoring(
291
311
  const freqBoost = freqSignal > 0 ? Math.min(0.10, Math.log1p(freqSignal) * 0.03) : 0;
292
312
  adjusted *= (1 + freqBoost);
293
313
 
314
+ adjusted *= canonicalMemoryMultiplier(r.displayPath, r.contentType, query);
315
+
294
316
  // Pin boost: +0.3 additive, capped at 1.0
295
317
  if (r.pinned) {
296
318
  adjusted = Math.min(1.0, adjusted + 0.3);
@@ -37,8 +37,8 @@
37
37
  * 4. REST API service (`clawmem serve`) lifecycle — unchanged.
38
38
  *
39
39
  * §14.3 critical correctness contract: `agent_end` is fire-and-forget at
40
- * `attempt.ts:3379-3402`. Precompact-extract MUST run inside
41
- * `handleBeforePromptBuild` (which IS awaited at `attempt.ts:2610`), gated
40
+ * `attempt.ts:3870-3892`. Precompact-extract MUST run inside
41
+ * `handleBeforePromptBuild` (which IS awaited at `attempt.ts:2973`), gated
42
42
  * by the proximity heuristic in `compaction-threshold.ts`. See `engine.ts`
43
43
  * top-of-file comment for the full rationale.
44
44
  */
@@ -161,7 +161,7 @@ const clawmemPlugin = {
161
161
  // ----- Plugin Hook: before_prompt_build (AWAITED — load-bearing path) -----
162
162
  // Both context-surfacing retrieval injection and pre-emptive precompact
163
163
  // extraction live here. handleBeforePromptBuild is async and the OpenClaw
164
- // attempt path awaits the result at attempt.ts:2610 before building the
164
+ // attempt path awaits the result at attempt.ts:2973 before building the
165
165
  // effective prompt. precompact-extract therefore runs strictly before
166
166
  // the LLM call that could trigger compaction on this turn.
167
167
  api.on(
@@ -175,7 +175,7 @@ const clawmemPlugin = {
175
175
  // ----- Plugin Hook: agent_end (FIRE-AND-FORGET in core) -----
176
176
  // Decision-extractor, handoff-generator, and feedback-loop run here.
177
177
  // These writes are eventually-consistent (saveMemory dedupes), so the
178
- // fire-and-forget context at attempt.ts:3379-3402 is acceptable.
178
+ // fire-and-forget context at attempt.ts:3870-3892 is acceptable.
179
179
  // OpenClaw v2026.4.26+ also enforces a 30s default void-hook timeout
180
180
  // (DEFAULT_VOID_HOOK_TIMEOUT_MS_BY_HOOK in src/plugins/hooks.ts) — a
181
181
  // timed-out handler is logged but our underlying postrun work is not
package/src/store.ts CHANGED
@@ -298,13 +298,20 @@ if (process.platform === "darwin") {
298
298
  }
299
299
 
300
300
  function initializeDatabase(db: Database): void {
301
+ // Set busy_timeout FIRST so subsequent PRAGMAs (journal_mode in particular,
302
+ // which acquires a write lock when switching or initializing WAL state) wait
303
+ // instead of returning SQLITE_BUSY when concurrent Stop hooks
304
+ // (decision-extractor, handoff-generator, feedback-loop) — and the parallel
305
+ // before_reset hook fan-out in src/openclaw/engine.ts — open the DB
306
+ // simultaneously. busy_timeout is a connection-level setting that only
307
+ // governs *subsequent* statements (default busy handler is NULL → SQLITE_BUSY
308
+ // returns immediately), so it must precede the contending PRAGMAs. 15s is
309
+ // well within the 30s Stop hook timeout. createStore() resets to operational
310
+ // value (5000ms or opts.busyTimeout) after DDL completes. Issue #13.
311
+ db.exec("PRAGMA busy_timeout = 15000");
301
312
  sqliteVec.load(db);
302
313
  db.exec("PRAGMA journal_mode = WAL");
303
314
  db.exec("PRAGMA foreign_keys = ON");
304
- // Set generous busy_timeout during DDL — concurrent Stop hooks (decision-extractor,
305
- // handoff-generator, feedback-loop) all run initializeDatabase simultaneously.
306
- // 15s is well within the 30s Stop hook timeout. Reset to normal after DDL completes.
307
- db.exec("PRAGMA busy_timeout = 15000");
308
315
 
309
316
  // Drop legacy tables that are now managed in YAML
310
317
  db.exec(`DROP TABLE IF EXISTS path_contexts`);
@@ -1123,13 +1130,20 @@ export function createStore(dbPath?: string, opts?: { readonly?: boolean; busyTi
1123
1130
  if (!opts?.readonly) {
1124
1131
  initializeDatabase(db);
1125
1132
  } else {
1126
- // Readonly: load sqlite-vec extension and set WAL mode pragma only
1133
+ // Readonly: set busy_timeout FIRST so the journal_mode PRAGMA below
1134
+ // doesn't race when concurrent processes open the DB. PRAGMA
1135
+ // journal_mode=WAL can contend when switching or initializing WAL
1136
+ // state, even on readonly handles. Public-API hardening — no
1137
+ // production caller in this repo currently passes readonly:true,
1138
+ // but the ordering invariant should hold regardless. Issue #13.
1139
+ db.exec(`PRAGMA busy_timeout = ${opts?.busyTimeout ?? 5000}`);
1127
1140
  sqliteVec.load(db);
1128
1141
  db.exec("PRAGMA journal_mode = WAL");
1129
1142
  db.exec("PRAGMA query_only = ON");
1130
1143
  }
1131
- // Reset busy_timeout to operational value after DDL init (which uses 15s).
1132
- // Default 5000ms for normal operations callers can override via opts.
1144
+ // For the writable branch: initializeDatabase() set 15000 during DDL
1145
+ // reset to operational value here. For readonly: already set inside the
1146
+ // branch above; this assignment is a no-op rewrite to the same value.
1133
1147
  db.exec(`PRAGMA busy_timeout = ${opts?.busyTimeout ?? 5000}`);
1134
1148
 
1135
1149
  return {
@@ -3068,14 +3082,20 @@ export function getTopLevelPathsWithoutContext(db: Database, collectionName: str
3068
3082
  // FTS Search
3069
3083
  // =============================================================================
3070
3084
 
3071
- function sanitizeFTS5Term(term: string): string {
3072
- return term.replace(/[^\p{L}\p{N}']/gu, '').toLowerCase();
3085
+ // Split on any run of non-token chars so query tokenization mirrors the FTS
3086
+ // index tokenizer (unicode61) which treats _ - . / ' and all punctuation as
3087
+ // token boundaries. Stripping the separators (the old behavior) concatenated
3088
+ // word-parts into a token that was never indexed — e.g. "before_compaction"
3089
+ // became "beforecompaction" and matched nothing. Shared with entities_fts in
3090
+ // entity.ts. Must stay a hoisted `function` declaration: entity.ts imports it
3091
+ // across the store<->entity module cycle, which only resolves for hoisted
3092
+ // bindings (it is called at runtime, never at module-eval time).
3093
+ export function tokenizeForFTS5(query: string): string[] {
3094
+ return query.toLowerCase().split(/[^\p{L}\p{N}]+/u).filter(t => t.length > 0);
3073
3095
  }
3074
3096
 
3075
3097
  function buildFTS5Query(query: string): string | null {
3076
- const terms = query.split(/\s+/)
3077
- .map(t => sanitizeFTS5Term(t))
3078
- .filter(t => t.length > 0);
3098
+ const terms = tokenizeForFTS5(query);
3079
3099
  if (terms.length === 0) return null;
3080
3100
  if (terms.length === 1) return `"${terms[0]}"*`;
3081
3101
  return terms.map(t => `"${t}"*`).join(' AND ');