memwarden 0.0.1
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/LICENSE +202 -0
- package/README.md +402 -0
- package/dist/bundle/bundle.d.ts +28 -0
- package/dist/bundle/bundle.js +85 -0
- package/dist/cli/bin.d.ts +2 -0
- package/dist/cli/bin.js +593 -0
- package/dist/cli/connect.d.ts +63 -0
- package/dist/cli/connect.js +121 -0
- package/dist/cli/hook.d.ts +24 -0
- package/dist/cli/hook.js +186 -0
- package/dist/cli/tools.d.ts +47 -0
- package/dist/cli/tools.js +246 -0
- package/dist/daemon/ensure.d.ts +12 -0
- package/dist/daemon/ensure.js +54 -0
- package/dist/daemon/service.d.ts +15 -0
- package/dist/daemon/service.js +210 -0
- package/dist/embedding/index.d.ts +10 -0
- package/dist/embedding/index.js +33 -0
- package/dist/embedding/local-embedding.d.ts +14 -0
- package/dist/embedding/local-embedding.js +80 -0
- package/dist/functions/access-tracker.d.ts +13 -0
- package/dist/functions/access-tracker.js +92 -0
- package/dist/functions/audit.d.ts +46 -0
- package/dist/functions/audit.js +0 -0
- package/dist/functions/cjk-segmenter.d.ts +6 -0
- package/dist/functions/cjk-segmenter.js +120 -0
- package/dist/functions/compress-synthetic.d.ts +2 -0
- package/dist/functions/compress-synthetic.js +104 -0
- package/dist/functions/config.d.ts +68 -0
- package/dist/functions/config.js +231 -0
- package/dist/functions/conflicts.d.ts +19 -0
- package/dist/functions/conflicts.js +328 -0
- package/dist/functions/context.d.ts +3 -0
- package/dist/functions/context.js +155 -0
- package/dist/functions/dedup.d.ts +11 -0
- package/dist/functions/dedup.js +51 -0
- package/dist/functions/dejafix.d.ts +96 -0
- package/dist/functions/dejafix.js +356 -0
- package/dist/functions/doctor.d.ts +29 -0
- package/dist/functions/doctor.js +137 -0
- package/dist/functions/forget.d.ts +3 -0
- package/dist/functions/forget.js +87 -0
- package/dist/functions/hybrid-search.d.ts +17 -0
- package/dist/functions/hybrid-search.js +205 -0
- package/dist/functions/index.d.ts +32 -0
- package/dist/functions/index.js +44 -0
- package/dist/functions/keyed-mutex.d.ts +1 -0
- package/dist/functions/keyed-mutex.js +21 -0
- package/dist/functions/logger.d.ts +6 -0
- package/dist/functions/logger.js +37 -0
- package/dist/functions/memory-utils.d.ts +2 -0
- package/dist/functions/memory-utils.js +29 -0
- package/dist/functions/observe.d.ts +5 -0
- package/dist/functions/observe.js +326 -0
- package/dist/functions/paths.d.ts +1 -0
- package/dist/functions/paths.js +38 -0
- package/dist/functions/privacy.d.ts +1 -0
- package/dist/functions/privacy.js +30 -0
- package/dist/functions/provenance.d.ts +9 -0
- package/dist/functions/provenance.js +57 -0
- package/dist/functions/quantized-vector-index.d.ts +60 -0
- package/dist/functions/quantized-vector-index.js +275 -0
- package/dist/functions/receipt.d.ts +31 -0
- package/dist/functions/receipt.js +95 -0
- package/dist/functions/search-index.d.ts +27 -0
- package/dist/functions/search-index.js +217 -0
- package/dist/functions/search.d.ts +25 -0
- package/dist/functions/search.js +523 -0
- package/dist/functions/stemmer.d.ts +1 -0
- package/dist/functions/stemmer.js +110 -0
- package/dist/functions/synonyms.d.ts +1 -0
- package/dist/functions/synonyms.js +69 -0
- package/dist/functions/turboquant.d.ts +53 -0
- package/dist/functions/turboquant.js +278 -0
- package/dist/functions/types.d.ts +217 -0
- package/dist/functions/types.js +8 -0
- package/dist/functions/vector-index.d.ts +25 -0
- package/dist/functions/vector-index.js +125 -0
- package/dist/functions/vector-persistence.d.ts +14 -0
- package/dist/functions/vector-persistence.js +75 -0
- package/dist/functions/verify.d.ts +13 -0
- package/dist/functions/verify.js +104 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +219 -0
- package/dist/kernel/http.d.ts +24 -0
- package/dist/kernel/http.js +261 -0
- package/dist/kernel/index.d.ts +19 -0
- package/dist/kernel/index.js +21 -0
- package/dist/kernel/kernel.d.ts +80 -0
- package/dist/kernel/kernel.js +297 -0
- package/dist/kernel/pubsub.d.ts +21 -0
- package/dist/kernel/pubsub.js +38 -0
- package/dist/kernel/types.d.ts +139 -0
- package/dist/kernel/types.js +20 -0
- package/dist/mcp/bin.d.ts +2 -0
- package/dist/mcp/bin.js +27 -0
- package/dist/mcp/server.d.ts +34 -0
- package/dist/mcp/server.js +377 -0
- package/dist/observability/metrics.d.ts +26 -0
- package/dist/observability/metrics.js +104 -0
- package/dist/proxy/server.d.ts +30 -0
- package/dist/proxy/server.js +331 -0
- package/dist/state/kv.d.ts +41 -0
- package/dist/state/kv.js +50 -0
- package/dist/state/oplog.d.ts +25 -0
- package/dist/state/oplog.js +57 -0
- package/dist/state/schema.d.ts +60 -0
- package/dist/state/schema.js +88 -0
- package/dist/state/store-libsql.d.ts +46 -0
- package/dist/state/store-libsql.js +263 -0
- package/dist/state/store-memory.d.ts +23 -0
- package/dist/state/store-memory.js +121 -0
- package/dist/state/store.d.ts +87 -0
- package/dist/state/store.js +58 -0
- package/dist/triggers/api.d.ts +14 -0
- package/dist/triggers/api.js +510 -0
- package/dist/triggers/auth.d.ts +1 -0
- package/dist/triggers/auth.js +13 -0
- package/package.json +58 -0
|
@@ -0,0 +1,523 @@
|
|
|
1
|
+
//
|
|
2
|
+
// Search (mem::search): hybrid BM25 + vector (RRF) retrieval with a lazy index
|
|
3
|
+
// rebuild, project/cwd over-fetch + canonical-path post-filter, a memory-scope
|
|
4
|
+
// fallback, an optional Verified Recall firewall (safe_only), and three output
|
|
5
|
+
// formats (full / compact / narrative) with token-budget packing. When an
|
|
6
|
+
// embedding provider is active (the default: on-device MiniLM + TurboQuant)
|
|
7
|
+
// the vector stream is fused in; with no provider it runs BM25-only.
|
|
8
|
+
import { KV } from "../state/schema.js";
|
|
9
|
+
import { SearchIndex } from "./search-index.js";
|
|
10
|
+
import { VectorIndex } from "./vector-index.js";
|
|
11
|
+
import { QuantizedVectorIndex } from "./quantized-vector-index.js";
|
|
12
|
+
import { isQuantizedVectorEnabled, getQuantBits, getQuantRescoreDepth, getQuantSeed, } from "./config.js";
|
|
13
|
+
import { memoryToObservation } from "./memory-utils.js";
|
|
14
|
+
import { canonicalizePath } from "./paths.js";
|
|
15
|
+
import { classifyProvenance } from "./verify.js";
|
|
16
|
+
import { recordAccessBatch } from "./access-tracker.js";
|
|
17
|
+
import { loadVectorIndex, persistVectorIndex } from "./vector-persistence.js";
|
|
18
|
+
import { logger } from "./logger.js";
|
|
19
|
+
import { metrics } from "../observability/metrics.js";
|
|
20
|
+
let index = null;
|
|
21
|
+
let vectorIndex = null;
|
|
22
|
+
let currentEmbeddingProvider = null;
|
|
23
|
+
export function getSearchIndex() {
|
|
24
|
+
if (!index)
|
|
25
|
+
index = new SearchIndex();
|
|
26
|
+
return index;
|
|
27
|
+
}
|
|
28
|
+
export function setVectorIndex(idx) {
|
|
29
|
+
vectorIndex = idx;
|
|
30
|
+
}
|
|
31
|
+
export function getVectorIndex() {
|
|
32
|
+
return vectorIndex;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Constructs the configured vector index: TurboQuant-backed when
|
|
36
|
+
* MEMWARDEN_QUANT_VECTOR=true, the full-precision VectorIndex otherwise.
|
|
37
|
+
* `dims` comes from the embedding provider that will feed the index.
|
|
38
|
+
*/
|
|
39
|
+
export function makeVectorIndex(dims) {
|
|
40
|
+
if (isQuantizedVectorEnabled()) {
|
|
41
|
+
return new QuantizedVectorIndex({
|
|
42
|
+
dims,
|
|
43
|
+
bits: getQuantBits(),
|
|
44
|
+
seed: getQuantSeed(),
|
|
45
|
+
rescoreDepth: getQuantRescoreDepth(),
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
return new VectorIndex();
|
|
49
|
+
}
|
|
50
|
+
export function setEmbeddingProvider(provider) {
|
|
51
|
+
currentEmbeddingProvider = provider;
|
|
52
|
+
}
|
|
53
|
+
export function getEmbeddingProvider() {
|
|
54
|
+
return currentEmbeddingProvider;
|
|
55
|
+
}
|
|
56
|
+
export function vectorIndexRemove(id) {
|
|
57
|
+
vectorIndex?.remove(id);
|
|
58
|
+
}
|
|
59
|
+
// Hard cap on embedding input length. Truncate defensively so a huge
|
|
60
|
+
// content blob can't 400 the embed call or blow context budget on a single
|
|
61
|
+
// doc. 16k chars ≈ 4k tokens, safely under every provider.
|
|
62
|
+
const EMBED_MAX_CHARS = 16_000;
|
|
63
|
+
export function clipEmbedInput(text) {
|
|
64
|
+
if (text.length <= EMBED_MAX_CHARS)
|
|
65
|
+
return text;
|
|
66
|
+
return text.slice(0, EMBED_MAX_CHARS);
|
|
67
|
+
}
|
|
68
|
+
// Single guarded vector-index write. Returns true on success. Soft-fails
|
|
69
|
+
// (logs + no-op) on dimension mismatch or embed error so a downed embedder
|
|
70
|
+
// never breaks the upstream save. With no provider configured this returns
|
|
71
|
+
// false immediately; observe.ts treats false as "vector skipped", not an error.
|
|
72
|
+
export async function vectorIndexAddGuarded(id, sessionId, text, context) {
|
|
73
|
+
const vi = vectorIndex;
|
|
74
|
+
const ep = currentEmbeddingProvider;
|
|
75
|
+
if (!vi || !ep)
|
|
76
|
+
return false;
|
|
77
|
+
try {
|
|
78
|
+
const embedding = await ep.embed(clipEmbedInput(text));
|
|
79
|
+
if (embedding.length !== ep.dimensions) {
|
|
80
|
+
logger.warn("vector-index add: dimension mismatch — skipping", {
|
|
81
|
+
kind: context.kind,
|
|
82
|
+
id: context.logId,
|
|
83
|
+
provider: ep.name,
|
|
84
|
+
expected: ep.dimensions,
|
|
85
|
+
received: embedding.length,
|
|
86
|
+
});
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
vi.add(id, sessionId, embedding);
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
logger.warn("vector-index add: embed failed — skipping", {
|
|
94
|
+
kind: context.kind,
|
|
95
|
+
id: context.logId,
|
|
96
|
+
provider: ep.name,
|
|
97
|
+
error: err instanceof Error ? err.message : String(err),
|
|
98
|
+
});
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Rebuilds the BM25 index from KV. Walks the memories scope (so
|
|
103
|
+
// mem::remember entries survive a restart) and every session's
|
|
104
|
+
// observations. The vector index is cleared in lockstep so BM25 and vector
|
|
105
|
+
// stay in sync; with no provider it stays empty. When a persisted
|
|
106
|
+
// quantized index was just restored (vector-persistence.ts), pass
|
|
107
|
+
// `preserveVectorIndex` to switch the vector side to INCREMENTAL SYNC:
|
|
108
|
+
// restored codes are kept, only docs missing from the index are embedded,
|
|
109
|
+
// and ghosts (ids in the blob that no longer exist in KV) are evicted at
|
|
110
|
+
// the end of the walk.
|
|
111
|
+
export async function rebuildIndex(kv, opts) {
|
|
112
|
+
const preserveVectors = opts?.preserveVectorIndex === true;
|
|
113
|
+
const idx = getSearchIndex();
|
|
114
|
+
idx.clear();
|
|
115
|
+
if (!preserveVectors)
|
|
116
|
+
vectorIndex?.clear();
|
|
117
|
+
// Ids seen in KV during this walk; used to evict ghosts from a restored
|
|
118
|
+
// vector index. Only tracked in preserve mode.
|
|
119
|
+
const liveIds = preserveVectors ? new Set() : null;
|
|
120
|
+
let count = 0;
|
|
121
|
+
// Memories live in their own KV scope outside per-session observation
|
|
122
|
+
// scopes, so they need a separate walk.
|
|
123
|
+
try {
|
|
124
|
+
const memories = await kv.list(KV.memories);
|
|
125
|
+
for (const memory of memories) {
|
|
126
|
+
if (memory.isLatest === false)
|
|
127
|
+
continue;
|
|
128
|
+
if (!memory.title || !memory.content)
|
|
129
|
+
continue;
|
|
130
|
+
idx.add(memoryToObservation(memory));
|
|
131
|
+
liveIds?.add(memory.id);
|
|
132
|
+
if (!preserveVectors || !vectorIndex?.has(memory.id)) {
|
|
133
|
+
await vectorIndexAddGuarded(memory.id, memory.sessionIds?.[0] ?? "memory", memory.title + " " + memory.content, { kind: "memory", logId: memory.id });
|
|
134
|
+
}
|
|
135
|
+
count++;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
logger.warn("rebuildIndex: failed to load memories", {
|
|
140
|
+
error: err instanceof Error ? err.message : String(err),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const sessions = await kv.list(KV.sessions);
|
|
144
|
+
if (!sessions.length) {
|
|
145
|
+
evictGhostVectors(liveIds);
|
|
146
|
+
return count;
|
|
147
|
+
}
|
|
148
|
+
const obsPerSession = [];
|
|
149
|
+
const failedSessions = [];
|
|
150
|
+
for (let batch = 0; batch < sessions.length; batch += 10) {
|
|
151
|
+
const chunk = sessions.slice(batch, batch + 10);
|
|
152
|
+
const results = await Promise.all(chunk.map(async (s) => {
|
|
153
|
+
try {
|
|
154
|
+
return await kv.list(KV.observations(s.id));
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
failedSessions.push(s.id);
|
|
158
|
+
return [];
|
|
159
|
+
}
|
|
160
|
+
}));
|
|
161
|
+
obsPerSession.push(...results);
|
|
162
|
+
}
|
|
163
|
+
if (failedSessions.length > 0) {
|
|
164
|
+
logger.warn("rebuildIndex: failed to load observations for sessions", {
|
|
165
|
+
failedSessions,
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
for (const observations of obsPerSession) {
|
|
169
|
+
for (const obs of observations) {
|
|
170
|
+
if (obs.title && obs.narrative) {
|
|
171
|
+
idx.add(obs);
|
|
172
|
+
liveIds?.add(obs.id);
|
|
173
|
+
if (!preserveVectors || !vectorIndex?.has(obs.id)) {
|
|
174
|
+
await vectorIndexAddGuarded(obs.id, obs.sessionId, obs.title + " " + obs.narrative, {
|
|
175
|
+
kind: "observation",
|
|
176
|
+
logId: obs.id,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
count++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
evictGhostVectors(liveIds);
|
|
184
|
+
return count;
|
|
185
|
+
}
|
|
186
|
+
// In preserve (incremental-sync) mode, removes vector entries whose ids no
|
|
187
|
+
// longer exist in KV — docs deleted after the index blob was persisted.
|
|
188
|
+
// No-op when liveIds is null (full-rebuild mode already cleared the index).
|
|
189
|
+
function evictGhostVectors(liveIds) {
|
|
190
|
+
if (!liveIds || !vectorIndex)
|
|
191
|
+
return;
|
|
192
|
+
let evicted = 0;
|
|
193
|
+
for (const id of vectorIndex.ids()) {
|
|
194
|
+
if (!liveIds.has(id)) {
|
|
195
|
+
vectorIndex.remove(id);
|
|
196
|
+
evicted++;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
if (evicted > 0) {
|
|
200
|
+
logger.info("vector index: evicted ghost entries after restore", {
|
|
201
|
+
evicted,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
// Reciprocal Rank Fusion of two ranked lists that share the
|
|
206
|
+
// {obsId, sessionId, score} shape (BM25 keyword + semantic vector). Score
|
|
207
|
+
// becomes the summed RRF contribution; ties resolve by it. Same K as the
|
|
208
|
+
// HybridSearch helper.
|
|
209
|
+
const RRF_K = 60;
|
|
210
|
+
function fuseRrf(a, b, limit) {
|
|
211
|
+
const acc = new Map();
|
|
212
|
+
const add = (list) => list.forEach((r, i) => {
|
|
213
|
+
const rrf = 1 / (RRF_K + i + 1);
|
|
214
|
+
const cur = acc.get(r.obsId);
|
|
215
|
+
if (cur) {
|
|
216
|
+
cur.score += rrf;
|
|
217
|
+
if (!cur.sessionId && r.sessionId)
|
|
218
|
+
cur.sessionId = r.sessionId;
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
acc.set(r.obsId, { sessionId: r.sessionId, score: rrf });
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
add(a);
|
|
225
|
+
add(b);
|
|
226
|
+
return Array.from(acc.entries())
|
|
227
|
+
.map(([obsId, v]) => ({ obsId, sessionId: v.sessionId, score: v.score }))
|
|
228
|
+
.sort((x, y) => y.score - x.score)
|
|
229
|
+
.slice(0, limit);
|
|
230
|
+
}
|
|
231
|
+
export function registerSearchFunction(sdk, kv) {
|
|
232
|
+
sdk.registerFunction("mem::search", async (data) => {
|
|
233
|
+
const idx = getSearchIndex();
|
|
234
|
+
// Input validation / normalization.
|
|
235
|
+
if (typeof data?.query !== "string" || !data.query.trim()) {
|
|
236
|
+
throw new Error("mem::search: query must be a non-empty string");
|
|
237
|
+
}
|
|
238
|
+
const query = data.query.trim();
|
|
239
|
+
const MAX_LIMIT = 100;
|
|
240
|
+
let effectiveLimit = 20;
|
|
241
|
+
if (data.limit !== undefined) {
|
|
242
|
+
if (!Number.isInteger(data.limit) || data.limit < 1) {
|
|
243
|
+
throw new Error("mem::search: limit must be a positive integer");
|
|
244
|
+
}
|
|
245
|
+
effectiveLimit = Math.min(data.limit, MAX_LIMIT);
|
|
246
|
+
}
|
|
247
|
+
// Canonicalize the scope filters (resolve symlinks, trailing slashes,
|
|
248
|
+
// `..`) so /tmp and /private/tmp — or any two spellings of the same
|
|
249
|
+
// directory — match. Stored values are canonicalized the same way at
|
|
250
|
+
// comparison time below.
|
|
251
|
+
const projectFilter = typeof data.project === "string" && data.project.trim().length > 0
|
|
252
|
+
? canonicalizePath(data.project)
|
|
253
|
+
: undefined;
|
|
254
|
+
const cwdFilter = typeof data.cwd === "string" && data.cwd.trim().length > 0
|
|
255
|
+
? canonicalizePath(data.cwd)
|
|
256
|
+
: undefined;
|
|
257
|
+
// Verified Recall firewall: when on (recall surfaces default it on),
|
|
258
|
+
// drop results that reference files now deleted or content-changed, so
|
|
259
|
+
// stale memory is never injected. Needs a cwd to check against.
|
|
260
|
+
const safeOnly = data.safe_only === true && cwdFilter !== undefined;
|
|
261
|
+
const format = typeof data.format === "string" ? data.format : "full";
|
|
262
|
+
if (!["full", "compact", "narrative"].includes(format)) {
|
|
263
|
+
throw new Error("mem::search: format must be one of 'full', 'compact', or 'narrative'");
|
|
264
|
+
}
|
|
265
|
+
let tokenBudget;
|
|
266
|
+
if (data.token_budget !== undefined) {
|
|
267
|
+
if (!Number.isInteger(data.token_budget) || data.token_budget < 1) {
|
|
268
|
+
throw new Error("mem::search: token_budget must be a positive integer");
|
|
269
|
+
}
|
|
270
|
+
tokenBudget = data.token_budget;
|
|
271
|
+
}
|
|
272
|
+
if (idx.size === 0) {
|
|
273
|
+
// Restore persisted quantized codes first (no-op unless
|
|
274
|
+
// MEMWARDEN_QUANT_VECTOR is on and a valid blob exists), then
|
|
275
|
+
// rebuild BM25. With a successful restore the vector side runs in
|
|
276
|
+
// incremental-sync mode (embed only missing ids, evict ghosts);
|
|
277
|
+
// afterwards the reconciled index is persisted again so the blob
|
|
278
|
+
// converges with KV. One blob write per cold rebuild.
|
|
279
|
+
const restoredVectors = await loadVectorIndex(kv);
|
|
280
|
+
const count = await rebuildIndex(kv, {
|
|
281
|
+
preserveVectorIndex: restoredVectors,
|
|
282
|
+
});
|
|
283
|
+
const persisted = await persistVectorIndex(kv);
|
|
284
|
+
logger.info("Search index rebuilt", {
|
|
285
|
+
entries: count,
|
|
286
|
+
restoredVectors,
|
|
287
|
+
persisted,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
// When filtering by project/cwd, over-fetch from the index so the
|
|
291
|
+
// post-filter still has a chance of returning `effectiveLimit` results.
|
|
292
|
+
// safe_only over-fetches much harder (and caps at SAFE_SCAN_CAP) so a run
|
|
293
|
+
// of stale high-ranking hits is unlikely to starve a verified result; if
|
|
294
|
+
// the scan window is exhausted we log it rather than hide it.
|
|
295
|
+
const SAFE_SCAN_CAP = 2000;
|
|
296
|
+
const filtering = !!(projectFilter || cwdFilter);
|
|
297
|
+
const fetchLimit = safeOnly
|
|
298
|
+
? Math.min(SAFE_SCAN_CAP, Math.max(effectiveLimit * 50, 500))
|
|
299
|
+
: filtering
|
|
300
|
+
? Math.max(effectiveLimit * 10, 100)
|
|
301
|
+
: effectiveLimit;
|
|
302
|
+
// Measure retrieval itself (not the one-time cold rebuild above) — the
|
|
303
|
+
// "is finding context fast?" number.
|
|
304
|
+
const searchStartedAt = performance.now();
|
|
305
|
+
const bm25Results = idx.search(query, fetchLimit);
|
|
306
|
+
// Fuse in the semantic stream when an embedding provider + vector index
|
|
307
|
+
// are present, so meaning-based queries (different words than the
|
|
308
|
+
// memory) resolve. Provider-less mode stays pure BM25. A failing
|
|
309
|
+
// embed falls back to BM25 rather than breaking search.
|
|
310
|
+
let results = bm25Results;
|
|
311
|
+
const vIdx = getVectorIndex();
|
|
312
|
+
const ep = currentEmbeddingProvider;
|
|
313
|
+
if (vIdx && ep && vIdx.size > 0) {
|
|
314
|
+
try {
|
|
315
|
+
const qVec = await ep.embed(clipEmbedInput(query));
|
|
316
|
+
if (qVec.length === ep.dimensions) {
|
|
317
|
+
results = fuseRrf(bm25Results, vIdx.search(qVec, fetchLimit), fetchLimit);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
logger.warn("search: vector stream failed — BM25 only", {
|
|
322
|
+
error: err instanceof Error ? err.message : String(err),
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
metrics.recordSearch(performance.now() - searchStartedAt);
|
|
327
|
+
// Resolve session -> project/cwd once per sessionId we touch.
|
|
328
|
+
const sessionCache = new Map();
|
|
329
|
+
const loadSession = async (sessionId) => {
|
|
330
|
+
if (sessionCache.has(sessionId))
|
|
331
|
+
return sessionCache.get(sessionId);
|
|
332
|
+
const s = await kv.get(KV.sessions, sessionId);
|
|
333
|
+
sessionCache.set(sessionId, s ?? null);
|
|
334
|
+
return s ?? null;
|
|
335
|
+
};
|
|
336
|
+
// Cache for memory project lookups. Memories indexed via mem::remember
|
|
337
|
+
// use a synthetic sessionId that either has no KV.sessions entry or
|
|
338
|
+
// belongs to a different project. When loadSession returns null we
|
|
339
|
+
// fall through to a KV.memories probe so project-filtered search can
|
|
340
|
+
// include or exclude them correctly.
|
|
341
|
+
const memoryProjectCache = new Map();
|
|
342
|
+
const loadMemoryProject = async (obsId) => {
|
|
343
|
+
if (memoryProjectCache.has(obsId))
|
|
344
|
+
return memoryProjectCache.get(obsId);
|
|
345
|
+
const mem = await kv
|
|
346
|
+
.get(KV.memories, obsId)
|
|
347
|
+
.catch(() => null);
|
|
348
|
+
const proj = mem?.project ?? null;
|
|
349
|
+
memoryProjectCache.set(obsId, proj);
|
|
350
|
+
return proj;
|
|
351
|
+
};
|
|
352
|
+
// A candidate's observation (or a memory rendered as one). Cached so the
|
|
353
|
+
// Verified Recall firewall and the final assembly never load it twice.
|
|
354
|
+
const obsCache = new Map();
|
|
355
|
+
const loadObsOrMemory = async (r) => {
|
|
356
|
+
if (obsCache.has(r.obsId))
|
|
357
|
+
return obsCache.get(r.obsId);
|
|
358
|
+
let obs = await kv
|
|
359
|
+
.get(KV.observations(r.sessionId), r.obsId)
|
|
360
|
+
.catch(() => null);
|
|
361
|
+
if (!obs) {
|
|
362
|
+
const mem = await kv.get(KV.memories, r.obsId).catch(() => null);
|
|
363
|
+
obs = mem ? memoryToObservation(mem) : null;
|
|
364
|
+
}
|
|
365
|
+
obsCache.set(r.obsId, obs);
|
|
366
|
+
return obs;
|
|
367
|
+
};
|
|
368
|
+
// First pass: scope-filter, and — when safe_only is on — apply the
|
|
369
|
+
// Verified Recall firewall WHILE filling, so stale top hits don't starve
|
|
370
|
+
// out lower-ranked verified ones. We keep scanning the fetched results
|
|
371
|
+
// (fetchLimit, up to SAFE_SCAN_CAP) until we have effectiveLimit safe ones.
|
|
372
|
+
const candidateTarget = safeOnly
|
|
373
|
+
? Math.min(fetchLimit, Math.max(effectiveLimit * 3, effectiveLimit + 20))
|
|
374
|
+
: effectiveLimit;
|
|
375
|
+
const candidates = [];
|
|
376
|
+
let staleDropped = 0;
|
|
377
|
+
for (const r of results) {
|
|
378
|
+
if (candidates.length >= candidateTarget)
|
|
379
|
+
break;
|
|
380
|
+
if (filtering) {
|
|
381
|
+
const s = await loadSession(r.sessionId);
|
|
382
|
+
if (s) {
|
|
383
|
+
if (projectFilter && canonicalizePath(s.project) !== projectFilter)
|
|
384
|
+
continue;
|
|
385
|
+
if (cwdFilter && canonicalizePath(s.cwd) !== cwdFilter)
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
else if (projectFilter) {
|
|
389
|
+
// Synthetic/memory entry: a null memProject means "unknown" and is
|
|
390
|
+
// let through for backward-compatibility; cwd filter doesn't apply.
|
|
391
|
+
const memProject = await loadMemoryProject(r.obsId);
|
|
392
|
+
if (memProject !== null &&
|
|
393
|
+
canonicalizePath(memProject) !== projectFilter)
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (safeOnly && cwdFilter) {
|
|
398
|
+
const obs = await loadObsOrMemory(r);
|
|
399
|
+
// Fail closed for stale/missing candidates. Sourced-unverified memory
|
|
400
|
+
// is allowed by design, but stale memory never gets injected.
|
|
401
|
+
if (!obs || classifyProvenance(obs.provenance, cwdFilter).status === "stale") {
|
|
402
|
+
staleDropped++;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
candidates.push(r);
|
|
407
|
+
}
|
|
408
|
+
if (safeOnly && staleDropped > 0) {
|
|
409
|
+
logger.info("Verified Recall dropped stale results", { dropped: staleDropped });
|
|
410
|
+
}
|
|
411
|
+
// No silent cap: if we ran out of safe candidates AND exhausted the scan
|
|
412
|
+
// window, a verified result could exist beyond it — say so.
|
|
413
|
+
if (safeOnly &&
|
|
414
|
+
candidates.length < effectiveLimit &&
|
|
415
|
+
results.length >= fetchLimit) {
|
|
416
|
+
logger.warn("Verified Recall scan window exhausted; verified results may exist beyond it", {
|
|
417
|
+
scanned: results.length,
|
|
418
|
+
fetchLimit,
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
// Second pass: assemble results, reusing any observation loaded above.
|
|
422
|
+
const obsResults = await Promise.all(candidates.map((c) => loadObsOrMemory(c)));
|
|
423
|
+
const enriched = [];
|
|
424
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
425
|
+
const obs = obsResults[i];
|
|
426
|
+
const cand = candidates[i];
|
|
427
|
+
if (obs) {
|
|
428
|
+
enriched.push({
|
|
429
|
+
observation: obs,
|
|
430
|
+
score: cand.score,
|
|
431
|
+
sessionId: cand.sessionId,
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// Safe recall NEVER silently drops a memory on a fuzzy contradiction
|
|
436
|
+
// heuristic — that would lose correct facts from a trust tool. The only
|
|
437
|
+
// thing safe_only firewalls is STALE memory (handled above, when the
|
|
438
|
+
// referenced files are deleted/changed). Conflict detection is advisory
|
|
439
|
+
// only and lives in mem::doctor, not in recall.
|
|
440
|
+
const recallResults = enriched.slice(0, effectiveLimit);
|
|
441
|
+
void recordAccessBatch(kv, recallResults.map((r) => r.observation.id));
|
|
442
|
+
const estimateTokens = (value) => Math.max(1, Math.ceil(JSON.stringify(value).length / 3));
|
|
443
|
+
const applyTokenBudget = (items) => {
|
|
444
|
+
if (!tokenBudget)
|
|
445
|
+
return {
|
|
446
|
+
items,
|
|
447
|
+
used: items.reduce((sum, item) => sum + estimateTokens(item), 0),
|
|
448
|
+
truncated: false,
|
|
449
|
+
};
|
|
450
|
+
const selected = [];
|
|
451
|
+
let used = 0;
|
|
452
|
+
for (const item of items) {
|
|
453
|
+
const itemTokens = estimateTokens(item);
|
|
454
|
+
if (used + itemTokens > tokenBudget) {
|
|
455
|
+
return {
|
|
456
|
+
items: selected,
|
|
457
|
+
used,
|
|
458
|
+
truncated: selected.length < items.length,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
selected.push(item);
|
|
462
|
+
used += itemTokens;
|
|
463
|
+
}
|
|
464
|
+
return { items: selected, used, truncated: false };
|
|
465
|
+
};
|
|
466
|
+
if (format === "compact") {
|
|
467
|
+
const compactResults = recallResults.map((r) => ({
|
|
468
|
+
obsId: r.observation.id,
|
|
469
|
+
sessionId: r.sessionId,
|
|
470
|
+
title: r.observation.title,
|
|
471
|
+
type: r.observation.type,
|
|
472
|
+
score: r.score,
|
|
473
|
+
timestamp: r.observation.timestamp,
|
|
474
|
+
}));
|
|
475
|
+
const packed = applyTokenBudget(compactResults);
|
|
476
|
+
return {
|
|
477
|
+
format,
|
|
478
|
+
results: packed.items,
|
|
479
|
+
tokens_used: packed.used,
|
|
480
|
+
tokens_budget: tokenBudget,
|
|
481
|
+
truncated: packed.truncated,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
if (format === "narrative") {
|
|
485
|
+
const narrativeResults = recallResults.map((r) => ({
|
|
486
|
+
obsId: r.observation.id,
|
|
487
|
+
sessionId: r.sessionId,
|
|
488
|
+
title: r.observation.title,
|
|
489
|
+
narrative: r.observation.narrative,
|
|
490
|
+
score: r.score,
|
|
491
|
+
timestamp: r.observation.timestamp,
|
|
492
|
+
}));
|
|
493
|
+
const packed = applyTokenBudget(narrativeResults);
|
|
494
|
+
const text = packed.items
|
|
495
|
+
.map((r, idxN) => `${idxN + 1}. ${r.title}\n${r.narrative}`)
|
|
496
|
+
.join("\n\n");
|
|
497
|
+
return {
|
|
498
|
+
format,
|
|
499
|
+
results: packed.items,
|
|
500
|
+
text,
|
|
501
|
+
tokens_used: packed.used,
|
|
502
|
+
tokens_budget: tokenBudget,
|
|
503
|
+
truncated: packed.truncated,
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
const packed = applyTokenBudget(recallResults);
|
|
507
|
+
// Avoid logging raw cwd/project (host paths). Log only that filters
|
|
508
|
+
// were active.
|
|
509
|
+
logger.info("Search completed", {
|
|
510
|
+
query,
|
|
511
|
+
results: packed.items.length,
|
|
512
|
+
hasProjectFilter: !!projectFilter,
|
|
513
|
+
hasCwdFilter: !!cwdFilter,
|
|
514
|
+
});
|
|
515
|
+
return {
|
|
516
|
+
format,
|
|
517
|
+
results: packed.items,
|
|
518
|
+
tokens_used: packed.used,
|
|
519
|
+
tokens_budget: tokenBudget,
|
|
520
|
+
truncated: packed.truncated,
|
|
521
|
+
};
|
|
522
|
+
});
|
|
523
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function stem(word: string): string;
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
//
|
|
2
|
+
// A compact Porter stemmer. Porter's algorithm is public-domain; this code
|
|
3
|
+
// implements its steps from scratch (plural/past-tense stripping, the step
|
|
4
|
+
// 2-4 suffix maps, and final -e / double-l cleanup). The BM25 tokenizer
|
|
5
|
+
// and the synonym map both run terms through it, so indexing and querying
|
|
6
|
+
// reduce words the same way.
|
|
7
|
+
// Porter's step-2 and step-3 suffix replacements (public algorithm tables).
|
|
8
|
+
const LONG_SUFFIXES = [
|
|
9
|
+
["ational", "ate"], ["tional", "tion"], ["enci", "ence"], ["anci", "ance"],
|
|
10
|
+
["izer", "ize"], ["iser", "ise"], ["abli", "able"], ["alli", "al"],
|
|
11
|
+
["entli", "ent"], ["eli", "e"], ["ousli", "ous"], ["ization", "ize"],
|
|
12
|
+
["isation", "ise"], ["ation", "ate"], ["ator", "ate"], ["alism", "al"],
|
|
13
|
+
["iveness", "ive"], ["fulness", "ful"], ["ousness", "ous"], ["aliti", "al"],
|
|
14
|
+
["iviti", "ive"], ["biliti", "ble"],
|
|
15
|
+
];
|
|
16
|
+
const DERIVATIONAL = [
|
|
17
|
+
["icate", "ic"], ["ative", ""], ["alize", "al"], ["alise", "al"],
|
|
18
|
+
["iciti", "ic"], ["ical", "ic"], ["ful", ""], ["ness", ""],
|
|
19
|
+
];
|
|
20
|
+
const STEP4_SUFFIX = /(ement|ment|tion|sion|ance|ence|able|ible|ism|ate|iti|ous|ive|ize|ise|ant|ent|al|er|ic|ou)$/;
|
|
21
|
+
const hasVowel = (s) => /[aeiou]/.test(s);
|
|
22
|
+
// Porter's "measure": the count of vowel-then-consonant transitions.
|
|
23
|
+
function measure(s) {
|
|
24
|
+
return (s.replace(/[^aeiouy]+/g, "C").replace(/[aeiouy]+/g, "V").match(/VC/g) ?? [])
|
|
25
|
+
.length;
|
|
26
|
+
}
|
|
27
|
+
function endsDoubledConsonant(s) {
|
|
28
|
+
const n = s.length;
|
|
29
|
+
return n >= 2 && s[n - 1] === s[n - 2] && !/[aeiou]/.test(s[n - 1] ?? "");
|
|
30
|
+
}
|
|
31
|
+
// consonant-vowel-consonant where the final consonant is not w, x, or y.
|
|
32
|
+
function endsCvc(s) {
|
|
33
|
+
const n = s.length;
|
|
34
|
+
if (n < 3)
|
|
35
|
+
return false;
|
|
36
|
+
return (!/[aeiou]/.test(s[n - 3] ?? "") &&
|
|
37
|
+
/[aeiou]/.test(s[n - 2] ?? "") &&
|
|
38
|
+
!/[aeiouwxy]/.test(s[n - 1] ?? ""));
|
|
39
|
+
}
|
|
40
|
+
// Shared -ed / -ing tail cleanup (Porter step 1b second half).
|
|
41
|
+
function restoreShortStem(s) {
|
|
42
|
+
if (s.endsWith("at") || s.endsWith("bl") || s.endsWith("iz"))
|
|
43
|
+
return s + "e";
|
|
44
|
+
if (endsDoubledConsonant(s) && !/[lsz]$/.test(s))
|
|
45
|
+
return s.slice(0, -1);
|
|
46
|
+
if (measure(s) === 1 && endsCvc(s))
|
|
47
|
+
return s + "e";
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
export function stem(word) {
|
|
51
|
+
if (word.length <= 2)
|
|
52
|
+
return word;
|
|
53
|
+
let w = word;
|
|
54
|
+
// 1a — plurals
|
|
55
|
+
if (w.endsWith("sses"))
|
|
56
|
+
w = w.slice(0, -2);
|
|
57
|
+
else if (w.endsWith("ies"))
|
|
58
|
+
w = w.slice(0, -2);
|
|
59
|
+
else if (w.endsWith("s") && !w.endsWith("ss"))
|
|
60
|
+
w = w.slice(0, -1);
|
|
61
|
+
// 1b — past tense / progressive
|
|
62
|
+
if (w.endsWith("eed")) {
|
|
63
|
+
if (measure(w.slice(0, -3)) > 0)
|
|
64
|
+
w = w.slice(0, -1);
|
|
65
|
+
}
|
|
66
|
+
else if (w.endsWith("ed") && hasVowel(w.slice(0, -2))) {
|
|
67
|
+
w = restoreShortStem(w.slice(0, -2));
|
|
68
|
+
}
|
|
69
|
+
else if (w.endsWith("ing") && hasVowel(w.slice(0, -3))) {
|
|
70
|
+
w = restoreShortStem(w.slice(0, -3));
|
|
71
|
+
}
|
|
72
|
+
// 1c — terminal y to i
|
|
73
|
+
if (w.endsWith("y") && hasVowel(w.slice(0, -1)))
|
|
74
|
+
w = w.slice(0, -1) + "i";
|
|
75
|
+
// 2 and 3 — suffix maps, applied only when a real stem remains
|
|
76
|
+
for (const [suffix, repl] of LONG_SUFFIXES) {
|
|
77
|
+
if (w.endsWith(suffix)) {
|
|
78
|
+
const base = w.slice(0, -suffix.length);
|
|
79
|
+
if (measure(base) > 0)
|
|
80
|
+
w = base + repl;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
for (const [suffix, repl] of DERIVATIONAL) {
|
|
85
|
+
if (w.endsWith(suffix)) {
|
|
86
|
+
const base = w.slice(0, -suffix.length);
|
|
87
|
+
if (measure(base) > 0)
|
|
88
|
+
w = base + repl;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// 4 — strip a residual suffix from a long enough stem
|
|
93
|
+
const m4 = w.match(STEP4_SUFFIX);
|
|
94
|
+
if (m4) {
|
|
95
|
+
const base = w.slice(0, -m4[0].length);
|
|
96
|
+
if (measure(base) > 1)
|
|
97
|
+
w = base;
|
|
98
|
+
}
|
|
99
|
+
// 5a — drop a trailing e
|
|
100
|
+
if (w.endsWith("e")) {
|
|
101
|
+
const base = w.slice(0, -1);
|
|
102
|
+
if (measure(base) > 1 || (measure(base) === 1 && !endsCvc(base)))
|
|
103
|
+
w = base;
|
|
104
|
+
}
|
|
105
|
+
// 5b — collapse a doubled l
|
|
106
|
+
if (endsDoubledConsonant(w) && w.endsWith("l") && measure(w.slice(0, -1)) > 1) {
|
|
107
|
+
w = w.slice(0, -1);
|
|
108
|
+
}
|
|
109
|
+
return w;
|
|
110
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function getSynonyms(stemmedTerm: string): string[];
|