clawmem 0.10.7 → 0.11.2
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 +2 -2
- package/CLAUDE.md +2 -2
- package/package.json +2 -2
- package/src/clawmem.ts +352 -188
- package/src/hooks/context-surfacing.ts +8 -2
- package/src/llm.ts +107 -36
- package/src/mcp.ts +32 -12
- package/src/store.ts +373 -131
- package/src/worker-lease.ts +34 -0
- package/src/hermes/__pycache__/__init__.cpython-312.pyc +0 -0
package/src/llm.ts
CHANGED
|
@@ -141,6 +141,88 @@ export type Queryable = {
|
|
|
141
141
|
text: string;
|
|
142
142
|
};
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Template-residue / leak patterns that indicate an expansion line is NOT a real
|
|
146
|
+
* query. The qmd-query-expansion finetune, when prompted out of distribution,
|
|
147
|
+
* echoed the old verbose prompt's format hints and leaked Qwen3 thinking tags;
|
|
148
|
+
* these never belong in a search query. Kept as a defensive guard even though the
|
|
149
|
+
* terse QMD-faithful prompt (expandQueryRemote, 2026-06-23) fixed generation.
|
|
150
|
+
*/
|
|
151
|
+
const EXPANSION_JUNK_PATTERNS: RegExp[] = [
|
|
152
|
+
/<\/?think>/i,
|
|
153
|
+
/keyword search terms \(/i,
|
|
154
|
+
/semantic search queries \(/i,
|
|
155
|
+
/hypothetical document passage that answers the query/i,
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
/** True if an expansion text is empty or matches a known template-residue pattern. */
|
|
159
|
+
export function isJunkExpansion(text: string): boolean {
|
|
160
|
+
const t = text.trim();
|
|
161
|
+
if (!t) return true;
|
|
162
|
+
return EXPANSION_JUNK_PATTERNS.some(re => re.test(t));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Strip stray Qwen3 `/no_think` / `/think` control tokens the model sometimes
|
|
167
|
+
* echoes mid-line (generateRemote appends ` /no_think`, and the finetune can copy
|
|
168
|
+
* it into a hyde passage). Only matches the token when it stands alone (start/space
|
|
169
|
+
* bounded) so real paths like `src/think.ts` are never touched. Unlike the `<think>`
|
|
170
|
+
* tag (which signals reasoning leakage → whole line rejected), a stray control token
|
|
171
|
+
* just gets cleaned out so the otherwise-good line survives.
|
|
172
|
+
*/
|
|
173
|
+
function stripControlTokens(text: string): string {
|
|
174
|
+
return text.replace(/(?:^|\s)\/(?:no_)?think(?=\s|$)/gi, " ").replace(/\s+/g, " ").trim();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Shared guard for query-expansion output. Drops empty / template-residue /
|
|
179
|
+
* think-tag lines and de-duplicates by (type, text). Used by BOTH the llm.ts
|
|
180
|
+
* parsers and the store.ts wrapper (defense-in-depth across every provider).
|
|
181
|
+
* Does NOT inject a fallback — callers decide what to do with an empty result,
|
|
182
|
+
* and does NOT require the original query terms to appear (that would reject
|
|
183
|
+
* legitimate synonyms and keyword-only expansions, e.g. a future zegen lex leg).
|
|
184
|
+
*/
|
|
185
|
+
export function sanitizeExpandedQueries(items: Queryable[]): Queryable[] {
|
|
186
|
+
const seen = new Set<string>();
|
|
187
|
+
const out: Queryable[] = [];
|
|
188
|
+
for (const q of items) {
|
|
189
|
+
const text = stripControlTokens(q.text);
|
|
190
|
+
if (isJunkExpansion(text)) continue;
|
|
191
|
+
const key = `${q.type}:${text}`;
|
|
192
|
+
if (seen.has(key)) continue;
|
|
193
|
+
seen.add(key);
|
|
194
|
+
out.push({ type: q.type, text });
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Typed fallback expansion set, used when generation fails or sanitization leaves
|
|
201
|
+
* nothing usable. lex+vec reuse the original query; hyde gets a minimal stub so the
|
|
202
|
+
* vector leg still has a hypothetical-document signal.
|
|
203
|
+
*/
|
|
204
|
+
export function expansionFallback(query: string, includeLexical: boolean = true): Queryable[] {
|
|
205
|
+
const out: Queryable[] = [
|
|
206
|
+
{ type: 'vec', text: query },
|
|
207
|
+
{ type: 'hyde', text: `Information about ${query}` },
|
|
208
|
+
];
|
|
209
|
+
if (includeLexical) out.unshift({ type: 'lex', text: query });
|
|
210
|
+
return out;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* True if `items` is exactly the typed fallback set for `query` — i.e. what every
|
|
215
|
+
* llm.expandQuery failure path returns. The store wrapper uses this to detect a
|
|
216
|
+
* leaked generation failure so it can return an expansions-only form WITHOUT
|
|
217
|
+
* caching it (a transient failure must not poison the cache). Compares against the
|
|
218
|
+
* default (lexical-included) fallback, which is what the store always requests.
|
|
219
|
+
*/
|
|
220
|
+
export function isFallbackExpansion(items: Queryable[], query: string): boolean {
|
|
221
|
+
const fb = expansionFallback(query);
|
|
222
|
+
return items.length === fb.length
|
|
223
|
+
&& items.every((q, i) => q.type === fb[i]!.type && q.text === fb[i]!.text);
|
|
224
|
+
}
|
|
225
|
+
|
|
144
226
|
/**
|
|
145
227
|
* Document to rerank
|
|
146
228
|
*/
|
|
@@ -290,6 +372,7 @@ function normalizeRemoteLlmReasoningEffort(value?: string): string | null {
|
|
|
290
372
|
|
|
291
373
|
function buildRemoteChatCompletionsUrl(remoteLlmUrl: string): string {
|
|
292
374
|
const baseUrl = remoteLlmUrl.replace(/\/+$/, "");
|
|
375
|
+
if (baseUrl.endsWith("/chat/completions")) return baseUrl;
|
|
293
376
|
const endpoint = baseUrl.endsWith("/v1") ? "/chat/completions" : "/v1/chat/completions";
|
|
294
377
|
return `${baseUrl}${endpoint}`;
|
|
295
378
|
}
|
|
@@ -1073,24 +1156,22 @@ export class LlamaCpp implements LLM {
|
|
|
1073
1156
|
}
|
|
1074
1157
|
|
|
1075
1158
|
private async expandQueryRemote(query: string, includeLexical: boolean, context?: string, intent?: string): Promise<Queryable[]> {
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1159
|
+
// QMD-faithful terse prompt. The qmd-query-expansion-1.7B finetune was trained
|
|
1160
|
+
// on "/no_think Expand this search query: X" (cf. QMD src/llm.ts:1467). The prior
|
|
1161
|
+
// verbose prose prompt was out-of-distribution: the model echoed the template
|
|
1162
|
+
// ("lex: keyword search terms (") and leaked </think> (verified live 2026-06-23).
|
|
1163
|
+
let prompt = intent
|
|
1164
|
+
? `/no_think Expand this search query: ${query}\nQuery intent: ${intent}`
|
|
1165
|
+
: `/no_think Expand this search query: ${query}`;
|
|
1166
|
+
if (context) prompt += `\nContext: ${context}`;
|
|
1084
1167
|
|
|
1085
1168
|
const result = await this.generateRemote(prompt, 500, 0.7);
|
|
1086
1169
|
if (!result?.text) {
|
|
1087
|
-
|
|
1088
|
-
if (includeLexical) fallback.unshift({ type: 'lex', text: query });
|
|
1089
|
-
return fallback;
|
|
1170
|
+
return expansionFallback(query, includeLexical);
|
|
1090
1171
|
}
|
|
1091
1172
|
|
|
1092
1173
|
const lines = result.text.trim().split("\n");
|
|
1093
|
-
const
|
|
1174
|
+
const parsed: Queryable[] = lines.map(line => {
|
|
1094
1175
|
const colonIdx = line.indexOf(":");
|
|
1095
1176
|
if (colonIdx === -1) return null;
|
|
1096
1177
|
const type = line.slice(0, colonIdx).trim();
|
|
@@ -1100,16 +1181,11 @@ Output:`;
|
|
|
1100
1181
|
return { type: type as QueryType, text };
|
|
1101
1182
|
}).filter((q): q is Queryable => q !== null);
|
|
1102
1183
|
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
if (!includeLexical) {
|
|
1110
|
-
return queryables.filter(q => q.type !== 'lex');
|
|
1111
|
-
}
|
|
1112
|
-
return queryables;
|
|
1184
|
+
// Drop template residue / <think> leaks / dups, then scope to requested types.
|
|
1185
|
+
const cleaned = sanitizeExpandedQueries(parsed);
|
|
1186
|
+
const scoped = includeLexical ? cleaned : cleaned.filter(q => q.type !== 'lex');
|
|
1187
|
+
if (scoped.length === 0) return expansionFallback(query, includeLexical);
|
|
1188
|
+
return scoped;
|
|
1113
1189
|
}
|
|
1114
1190
|
|
|
1115
1191
|
async modelExists(modelUri: string): Promise<ModelInfo> {
|
|
@@ -1147,10 +1223,8 @@ Output:`;
|
|
|
1147
1223
|
// Remote is in cooldown (pre-existing or just set) — fall through to local
|
|
1148
1224
|
if (this.remoteLlmUrl && this.isRemoteLlmDown()) {
|
|
1149
1225
|
if (process.env.CLAWMEM_NO_LOCAL_MODELS === "true") {
|
|
1150
|
-
// Can't fall back — return passthrough
|
|
1151
|
-
|
|
1152
|
-
if (includeLexical) fallback.unshift({ type: 'lex', text: query });
|
|
1153
|
-
return fallback;
|
|
1226
|
+
// Can't fall back to local inference — return the typed passthrough set
|
|
1227
|
+
return expansionFallback(query, includeLexical);
|
|
1154
1228
|
}
|
|
1155
1229
|
this.noteRemoteFallback(
|
|
1156
1230
|
"llm",
|
|
@@ -1229,7 +1303,7 @@ Final Output:`;
|
|
|
1229
1303
|
});
|
|
1230
1304
|
|
|
1231
1305
|
const lines = result.trim().split("\n");
|
|
1232
|
-
const
|
|
1306
|
+
const parsed: Queryable[] = lines.map(line => {
|
|
1233
1307
|
const colonIdx = line.indexOf(":");
|
|
1234
1308
|
if (colonIdx === -1) return null;
|
|
1235
1309
|
const type = line.slice(0, colonIdx).trim();
|
|
@@ -1238,17 +1312,14 @@ Final Output:`;
|
|
|
1238
1312
|
return { type: type as QueryType, text };
|
|
1239
1313
|
}).filter((q): q is Queryable => q !== null);
|
|
1240
1314
|
|
|
1241
|
-
//
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
return
|
|
1315
|
+
// Same guard as the remote path — drop residue/dups, scope to requested types.
|
|
1316
|
+
const cleaned = sanitizeExpandedQueries(parsed);
|
|
1317
|
+
const scoped = includeLexical ? cleaned : cleaned.filter(q => q.type !== 'lex');
|
|
1318
|
+
if (scoped.length === 0) return expansionFallback(query, includeLexical);
|
|
1319
|
+
return scoped;
|
|
1246
1320
|
} catch (error) {
|
|
1247
1321
|
console.error("Structured query expansion failed:", error);
|
|
1248
|
-
|
|
1249
|
-
const fallback: Queryable[] = [{ type: 'vec', text: query }];
|
|
1250
|
-
if (includeLexical) fallback.unshift({ type: 'lex', text: query });
|
|
1251
|
-
return fallback;
|
|
1322
|
+
return expansionFallback(query, includeLexical);
|
|
1252
1323
|
} finally {
|
|
1253
1324
|
await genContext.dispose();
|
|
1254
1325
|
}
|
package/src/mcp.ts
CHANGED
|
@@ -629,19 +629,38 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
629
629
|
const hasStrongSignal = !intent && initialFts.length > 0
|
|
630
630
|
&& topScore >= 0.85 && (topScore - secondScore) >= 0.15;
|
|
631
631
|
|
|
632
|
-
// Step 2: Query expansion (skipped if strong signal)
|
|
633
|
-
|
|
634
|
-
|
|
632
|
+
// Step 2: Query expansion (skipped if strong signal). Typed routing —
|
|
633
|
+
// original → BOTH FTS + vector (2× RRF anchor), lex → FTS only, vec/hyde → vector only.
|
|
634
|
+
const expanded = hasStrongSignal
|
|
635
|
+
? []
|
|
635
636
|
: await store.expandQuery(query, DEFAULT_QUERY_MODEL, intent);
|
|
636
637
|
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
638
|
+
// Original query — both backends, pushed FIRST so the positional 2× weight
|
|
639
|
+
// below lands on exactly the original's lists.
|
|
640
|
+
if (initialFts.length > 0) {
|
|
641
|
+
for (const r of initialFts) docidMap.set(r.filepath, r.docid);
|
|
642
|
+
rankedLists.push(initialFts.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
|
|
643
|
+
}
|
|
644
|
+
if (hasVectors) {
|
|
645
|
+
const vecResults = await store.searchVec(query, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
|
|
646
|
+
if (vecResults.length > 0) {
|
|
647
|
+
for (const r of vecResults) docidMap.set(r.filepath, r.docid);
|
|
648
|
+
rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
|
|
642
649
|
}
|
|
643
|
-
|
|
644
|
-
|
|
650
|
+
}
|
|
651
|
+
// Lists contributed by the original query — these get the 2× RRF weight.
|
|
652
|
+
const numOriginalLists = rankedLists.length;
|
|
653
|
+
|
|
654
|
+
// Typed expansions — route by type: lex → FTS, vec/hyde → vector.
|
|
655
|
+
for (const eq of expanded) {
|
|
656
|
+
if (eq.type === 'lex') {
|
|
657
|
+
const ftsResults = store.searchFTS(eq.query, 20, undefined, collections, dateRange);
|
|
658
|
+
if (ftsResults.length > 0) {
|
|
659
|
+
for (const r of ftsResults) docidMap.set(r.filepath, r.docid);
|
|
660
|
+
rankedLists.push(ftsResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
|
|
661
|
+
}
|
|
662
|
+
} else if (hasVectors) {
|
|
663
|
+
const vecResults = await store.searchVec(eq.query, DEFAULT_EMBED_MODEL, 20, undefined, collections, dateRange);
|
|
645
664
|
if (vecResults.length > 0) {
|
|
646
665
|
for (const r of vecResults) docidMap.set(r.filepath, r.docid);
|
|
647
666
|
rankedLists.push(vecResults.map(r => ({ file: r.filepath, displayPath: r.displayPath, title: r.title, body: r.body || "", score: r.score })));
|
|
@@ -705,8 +724,9 @@ This is the recommended entry point for ALL memory queries.`,
|
|
|
705
724
|
}
|
|
706
725
|
}
|
|
707
726
|
|
|
708
|
-
// Weight: original query
|
|
709
|
-
|
|
727
|
+
// Weight: the original query's lists (pushed first) get 2×; expansion, temporal,
|
|
728
|
+
// and entity legs get 1×. numOriginalLists (computed above) is the actual count
|
|
729
|
+
// the original contributed — robust to an empty BM25 or vector leg.
|
|
710
730
|
const weights = rankedLists.map((_, i) => i < numOriginalLists ? 2.0 : 1.0);
|
|
711
731
|
const fused = reciprocalRankFusion(rankedLists, weights);
|
|
712
732
|
const candidates = fused.slice(0, candLimit);
|