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 +1 -1
- package/package.json +1 -1
- package/src/amem.ts +60 -15
- package/src/entity.ts +45 -15
- package/src/llm.ts +76 -8
- package/src/memory.ts +22 -0
- package/src/openclaw/index.ts +4 -4
- package/src/store.ts +32 -12
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
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
|
-
|
|
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 =
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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 =
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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")
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/openclaw/index.ts
CHANGED
|
@@ -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:
|
|
41
|
-
* `handleBeforePromptBuild` (which IS awaited at `attempt.ts:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
//
|
|
1132
|
-
//
|
|
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
|
-
|
|
3072
|
-
|
|
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
|
|
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 ');
|