@jbarket/loomai 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +114 -1
- package/README.md +97 -0
- package/dist/backends/dedup.d.ts +23 -0
- package/dist/backends/dedup.js +100 -0
- package/dist/backends/embedding-runtime.js +38 -9
- package/dist/backends/episodes.js +1 -1
- package/dist/backends/fastembed.js +30 -4
- package/dist/backends/migrations.js +200 -0
- package/dist/backends/mmr.d.ts +8 -0
- package/dist/backends/mmr.js +18 -4
- package/dist/backends/people.d.ts +192 -0
- package/dist/backends/people.js +824 -0
- package/dist/backends/proposals.js +1 -1
- package/dist/backends/recorder-health.d.ts +34 -0
- package/dist/backends/recorder-health.js +117 -0
- package/dist/backends/salience.d.ts +39 -0
- package/dist/backends/salience.js +52 -4
- package/dist/backends/scope.d.ts +56 -0
- package/dist/backends/scope.js +75 -0
- package/dist/backends/sqlite-knowledge.d.ts +24 -4
- package/dist/backends/sqlite-knowledge.js +253 -91
- package/dist/backends/sqlite-vec.d.ts +32 -1
- package/dist/backends/sqlite-vec.js +394 -75
- package/dist/backends/types.d.ts +203 -3
- package/dist/categories.d.ts +2 -0
- package/dist/categories.js +2 -0
- package/dist/cli/args.d.ts +16 -1
- package/dist/cli/args.js +45 -1
- package/dist/cli/index.d.ts +7 -0
- package/dist/cli/index.js +57 -3
- package/dist/cli/knowledge.js +4 -1
- package/dist/cli/memory.js +2 -2
- package/dist/cli/person.d.ts +2 -0
- package/dist/cli/person.js +116 -0
- package/dist/cli/recall.js +2 -1
- package/dist/cli/remember.js +10 -0
- package/dist/cli/remote.d.ts +36 -0
- package/dist/cli/remote.js +173 -0
- package/dist/cli/serve.d.ts +7 -0
- package/dist/cli/serve.js +94 -5
- package/dist/cli/subcommands.d.ts +1 -1
- package/dist/cli/subcommands.js +1 -1
- package/dist/cli/update.js +30 -11
- package/dist/clients.js +7 -4
- package/dist/config.d.ts +14 -0
- package/dist/config.js +25 -2
- package/dist/index.js +22 -7
- package/dist/injection/harnesses.js +9 -3
- package/dist/install/harnesses.js +7 -1
- package/dist/server.d.ts +8 -1
- package/dist/server.js +461 -374
- package/dist/tools/dossier.js +5 -1
- package/dist/tools/eurorack-import.js +3 -0
- package/dist/tools/identity.d.ts +2 -0
- package/dist/tools/identity.js +35 -2
- package/dist/tools/knowledge-history.d.ts +6 -1
- package/dist/tools/knowledge-history.js +26 -8
- package/dist/tools/knowledge-maintain.d.ts +5 -0
- package/dist/tools/knowledge-maintain.js +92 -5
- package/dist/tools/knowledge-recall.d.ts +7 -1
- package/dist/tools/knowledge-recall.js +53 -3
- package/dist/tools/knowledge-verify.d.ts +11 -1
- package/dist/tools/knowledge-verify.js +17 -4
- package/dist/tools/knowledge-write.d.ts +8 -1
- package/dist/tools/knowledge-write.js +27 -9
- package/dist/tools/memory-history.d.ts +5 -0
- package/dist/tools/memory-history.js +52 -0
- package/dist/tools/memory-list.js +2 -1
- package/dist/tools/memory-merge.d.ts +2 -0
- package/dist/tools/memory-merge.js +40 -0
- package/dist/tools/memory-revision-restore.d.ts +5 -0
- package/dist/tools/memory-revision-restore.js +33 -0
- package/dist/tools/person.d.ts +32 -0
- package/dist/tools/person.js +111 -0
- package/dist/tools/prune.js +1 -1
- package/dist/tools/recall.js +7 -1
- package/dist/tools/remember.js +18 -2
- package/dist/tools/update.js +5 -1
- package/dist/transport/cli-endpoint.d.ts +13 -0
- package/dist/transport/cli-endpoint.js +97 -0
- package/dist/transport/guards.d.ts +14 -0
- package/dist/transport/guards.js +26 -0
- package/dist/transport/http-server.d.ts +28 -1
- package/dist/transport/http-server.js +156 -18
- package/dist/transport/knowledge-server.d.ts +11 -0
- package/dist/transport/knowledge-server.js +30 -0
- package/dist/transport/knowledge-tools.d.ts +50 -0
- package/dist/transport/knowledge-tools.js +290 -0
- package/dist/transport/mcp-app-hello.d.ts +1 -1
- package/dist/widgets/display-modes.html +62 -56
- package/dist/widgets/hello.html +62 -56
- package/dist/widgets/proposals.html +62 -56
- package/dist/widgets/top-of-mind.html +60 -54
- package/package.json +7 -5
|
@@ -18,6 +18,10 @@ import { mmrSelect, DEFAULT_DIVERSITY } from './mmr.js';
|
|
|
18
18
|
import { globToMatcher } from './glob.js';
|
|
19
19
|
import { runMigrations } from './migrations.js';
|
|
20
20
|
import { retryWrite } from './retry.js';
|
|
21
|
+
import { computeUniqKey } from './dedup.js';
|
|
22
|
+
import { effectiveScope, isVisibleInProject } from './scope.js';
|
|
23
|
+
import { FEEDBACK_CATEGORY } from '../categories.js';
|
|
24
|
+
import { FEEDBACK_CONFIDENCE_DEFAULT, applyObservation, decayedConfidence, } from './salience.js';
|
|
21
25
|
function slugify(text) {
|
|
22
26
|
return text
|
|
23
27
|
.toLowerCase()
|
|
@@ -37,36 +41,82 @@ export class SqliteVecBackend {
|
|
|
37
41
|
mkdirSync(dirname(config.dbPath), { recursive: true });
|
|
38
42
|
this.db = new BetterSqlite3(config.dbPath);
|
|
39
43
|
this.db.pragma('journal_mode = WAL');
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
// refused, which is exactly the guarantee. No torn write, no silent race.
|
|
46
|
-
this.db.pragma('busy_timeout = 0');
|
|
44
|
+
// WAL + 5s busy_timeout: concurrent sleeves (wakes, lanes, voice) each open
|
|
45
|
+
// their own connection. A brief write-lock window (checkpoint, concurrent
|
|
46
|
+
// write) retries for up to 5s rather than failing immediately. A genuine
|
|
47
|
+
// second loom instance will still fail — it won't hold the lock that long.
|
|
48
|
+
this.db.pragma('busy_timeout = 5000');
|
|
47
49
|
sqliteVec.load(this.db);
|
|
48
50
|
this.initSchema();
|
|
49
51
|
}
|
|
50
52
|
// ── MemoryBackend interface ──
|
|
51
53
|
async remember(input) {
|
|
52
|
-
const uuid = randomUUID();
|
|
53
54
|
const timestamp = new Date().toISOString();
|
|
55
|
+
const uniqKey = computeUniqKey({
|
|
56
|
+
category: input.category,
|
|
57
|
+
project: input.project,
|
|
58
|
+
title: input.title,
|
|
59
|
+
content: input.content,
|
|
60
|
+
scope: input.scope,
|
|
61
|
+
});
|
|
62
|
+
// Write-time dedup (t-335): a character-identical restatement (after
|
|
63
|
+
// normalizing) reheats the existing active row instead of inserting a
|
|
64
|
+
// second one. Scoped to active rows only — an archived memory that gets
|
|
65
|
+
// restated is a deliberate un-archive-by-rewrite, not a reheat.
|
|
66
|
+
//
|
|
67
|
+
// This first lookup is only a fast path that skips the embedder for a
|
|
68
|
+
// restatement. It is NOT the guarantee: another writer (this process or
|
|
69
|
+
// another connection on the same file) can insert the same key while we
|
|
70
|
+
// await the embedder. The authoritative check is repeated inside the
|
|
71
|
+
// IMMEDIATE insert transaction below, and the partial UNIQUE index
|
|
72
|
+
// idx_memories_uniq_exact makes a second active holder impossible
|
|
73
|
+
// (PR120 review).
|
|
74
|
+
const hit = this.db
|
|
75
|
+
.prepare('SELECT 1 FROM memories WHERE uniq = ? AND archived = 0')
|
|
76
|
+
.get(uniqKey);
|
|
77
|
+
if (hit) {
|
|
78
|
+
// Re-read under the write lock; if the holder was archived meanwhile,
|
|
79
|
+
// fall through to a fresh insert.
|
|
80
|
+
const reheated = retryWrite(() => this.reheatTx(uniqKey, timestamp).immediate());
|
|
81
|
+
if (reheated)
|
|
82
|
+
return reheated;
|
|
83
|
+
}
|
|
84
|
+
const uuid = randomUUID();
|
|
54
85
|
const slug = slugify(input.title);
|
|
55
86
|
const ref = `${input.category}/${slug}-${uuid.slice(0, 8)}`;
|
|
56
87
|
const expiresAt = computeExpiresAt(timestamp, input.ttl);
|
|
57
88
|
const vector = await this.embedder.embed(`${input.title}\n\n${input.content}`);
|
|
89
|
+
// A feedback memory is evidence-backed from the moment it's written: the
|
|
90
|
+
// observation that created it is the first piece of evidence. Every other
|
|
91
|
+
// category leaves confidence NULL ("not applicable") — this model is
|
|
92
|
+
// scoped to feedback, per t-338, not a generic trust score.
|
|
93
|
+
const isFeedback = input.category === FEEDBACK_CATEGORY;
|
|
94
|
+
const confidence = isFeedback ? FEEDBACK_CONFIDENCE_DEFAULT : null;
|
|
95
|
+
const evidenceCount = isFeedback ? 1 : 0;
|
|
58
96
|
const insertMem = this.db.prepare(`
|
|
59
97
|
INSERT INTO memories (
|
|
60
98
|
uuid, ref, title, category, project, content, metadata,
|
|
61
|
-
created, ttl, expires_at, salience
|
|
62
|
-
|
|
99
|
+
created, ttl, expires_at, salience, sourcing, provenance,
|
|
100
|
+
confidence, evidence_count, observed_at, scope, uniq
|
|
101
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
63
102
|
`);
|
|
64
103
|
const insertVec = this.db.prepare('INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)');
|
|
65
104
|
const tx = this.db.transaction(() => {
|
|
66
|
-
|
|
105
|
+
// Recheck under the write lock: a concurrent writer may have stored
|
|
106
|
+
// this exact content while we were embedding.
|
|
107
|
+
const winner = this.reheatTx(uniqKey, timestamp)();
|
|
108
|
+
if (winner)
|
|
109
|
+
return winner;
|
|
110
|
+
const result = insertMem.run(uuid, ref, input.title, input.category, input.project ?? null, input.content, JSON.stringify(input.metadata ?? {}), timestamp, input.ttl ?? null, expiresAt, 1.0, // a freshly authored memory is hot; the lane decays it from here
|
|
111
|
+
input.sourcing ?? null, input.provenance ?? null, confidence, evidenceCount, isFeedback ? timestamp : null, input.scope ?? null, uniqKey);
|
|
67
112
|
insertVec.run(BigInt(result.lastInsertRowid), toVecBuffer(vector));
|
|
113
|
+
return undefined;
|
|
68
114
|
});
|
|
69
|
-
|
|
115
|
+
// IMMEDIATE: take the write lock before the recheck, so no other
|
|
116
|
+
// connection can commit the same key between the check and the insert.
|
|
117
|
+
const reheated = retryWrite(() => tx.immediate());
|
|
118
|
+
if (reheated)
|
|
119
|
+
return reheated;
|
|
70
120
|
return {
|
|
71
121
|
ref,
|
|
72
122
|
category: input.category,
|
|
@@ -88,13 +138,17 @@ export class SqliteVecBackend {
|
|
|
88
138
|
const candidates = this.searchVectors(queryVector, poolSize, poolSize * 4, (mem) => {
|
|
89
139
|
if (categoryFilter && mem.category !== categoryFilter)
|
|
90
140
|
return false;
|
|
91
|
-
if (
|
|
141
|
+
if (!isVisibleInProject(mem, projectFilter))
|
|
92
142
|
return false;
|
|
93
143
|
return true;
|
|
94
144
|
});
|
|
95
145
|
// MMR only re-orders/selects among candidates that already passed the
|
|
96
146
|
// filters; with <= limit candidates it returns them in relevance order.
|
|
147
|
+
// `blended` carries the score that actually drove each pick, aligned by
|
|
148
|
+
// index with `hits` — null means MMR didn't touch anything, so the
|
|
149
|
+
// blended score is just the raw similarity (see rowToMatch).
|
|
97
150
|
let hits = candidates;
|
|
151
|
+
let blended = null;
|
|
98
152
|
let diversityDrops = 0;
|
|
99
153
|
if (diversity > 0 && candidates.length > limit) {
|
|
100
154
|
const vectors = this.loadVectors(candidates.map((c) => c.mem.id));
|
|
@@ -105,9 +159,10 @@ export class SqliteVecBackend {
|
|
|
105
159
|
}));
|
|
106
160
|
const picked = mmrSelect(pool, limit, diversity);
|
|
107
161
|
hits = picked.selected.map((c) => c.hit);
|
|
162
|
+
blended = picked.scores;
|
|
108
163
|
diversityDrops = picked.diversityDrops;
|
|
109
164
|
}
|
|
110
|
-
const results = hits.map((h) => rowToMatch(h.mem, h.distance));
|
|
165
|
+
const results = hits.map((h, i) => rowToMatch(h.mem, h.distance, blended?.[i]));
|
|
111
166
|
const hitIds = hits.map((h) => h.mem.id);
|
|
112
167
|
if (hitIds.length > 0) {
|
|
113
168
|
const now = new Date().toISOString();
|
|
@@ -181,48 +236,90 @@ export class SqliteVecBackend {
|
|
|
181
236
|
return { deleted: targets.map((r) => r.ref) };
|
|
182
237
|
}
|
|
183
238
|
async update(input) {
|
|
184
|
-
let
|
|
239
|
+
let found;
|
|
185
240
|
if (input.ref) {
|
|
186
|
-
|
|
187
|
-
.prepare('SELECT
|
|
241
|
+
found = this.db
|
|
242
|
+
.prepare('SELECT id, ref, title FROM memories WHERE ref = ?')
|
|
188
243
|
.get(input.ref);
|
|
189
244
|
}
|
|
190
245
|
else if (input.category && input.title) {
|
|
191
|
-
|
|
192
|
-
.prepare('SELECT
|
|
246
|
+
found = this.db
|
|
247
|
+
.prepare('SELECT id, ref, title FROM memories WHERE category = ? AND title = ?')
|
|
193
248
|
.get(input.category, input.title);
|
|
194
249
|
}
|
|
195
250
|
else {
|
|
196
251
|
return { updated: false };
|
|
197
252
|
}
|
|
198
|
-
if (!
|
|
253
|
+
if (!found)
|
|
199
254
|
return { updated: false };
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
255
|
+
const { id, ref, title } = found;
|
|
256
|
+
// The only async step, so it runs before the transaction. Everything that
|
|
257
|
+
// reads the row runs INSIDE the transaction below, against a fresh read —
|
|
258
|
+
// a row read up here would be stale by the time an overlapping update()
|
|
259
|
+
// commits, and writing it back would drop that update's content, metadata
|
|
260
|
+
// or evidence (PR130 review).
|
|
261
|
+
const vector = input.content !== undefined ? await this.embedder.embed(`${title}\n\n${input.content}`) : undefined;
|
|
205
262
|
const updatedAt = new Date().toISOString();
|
|
206
|
-
const updateStmt = this.db.prepare(`
|
|
207
|
-
UPDATE memories
|
|
208
|
-
SET content = ?, metadata = ?, updated = ?
|
|
209
|
-
WHERE id = ?
|
|
210
|
-
`);
|
|
211
|
-
const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
|
|
212
|
-
const vector = await this.embedder.embed(`${row.title}\n\n${newContent}`);
|
|
213
|
-
// Snapshot the current body before overwriting — same protection the knowledge
|
|
214
|
-
// wing added after the 2026-06-01 verify run stomped 13 pages with no recovery.
|
|
215
|
-
// Only snapshot when content actually changes to avoid noise in the history.
|
|
216
263
|
let snapshotId;
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
}
|
|
264
|
+
let newConfidence;
|
|
265
|
+
let newEvidenceCount;
|
|
220
266
|
const tx = this.db.transaction(() => {
|
|
221
|
-
|
|
222
|
-
|
|
267
|
+
const row = this.db.prepare('SELECT * FROM memories WHERE id = ?').get(id);
|
|
268
|
+
if (!row)
|
|
269
|
+
return false; // deleted between lookup and write
|
|
270
|
+
// Only the columns this call is about get written; everything else is
|
|
271
|
+
// left as the row has it now.
|
|
272
|
+
const sets = ['updated = ?'];
|
|
273
|
+
const params = [updatedAt];
|
|
274
|
+
const contentChanged = input.content !== undefined && input.content !== row.content;
|
|
275
|
+
if (contentChanged) {
|
|
276
|
+
// Snapshot the current body before overwriting — same protection the
|
|
277
|
+
// knowledge wing added after the 2026-06-01 verify run stomped 13
|
|
278
|
+
// pages with no recovery. Only on a real change, to keep history quiet.
|
|
279
|
+
snapshotId = this.snapshotRevision(row.id, row.ref, row.content, 'update', updatedAt);
|
|
280
|
+
sets.push('content = ?', 'uniq = ?');
|
|
281
|
+
// The dedup key describes the current body — a stale one would make
|
|
282
|
+
// remember() of the OLD body return this row (PR120 review).
|
|
283
|
+
params.push(input.content, this.claimableUniq(row.id, { ...row, content: input.content }));
|
|
284
|
+
}
|
|
285
|
+
if (input.metadata) {
|
|
286
|
+
const existingMeta = JSON.parse(row.metadata);
|
|
287
|
+
sets.push('metadata = ?');
|
|
288
|
+
params.push(JSON.stringify({ ...existingMeta, ...input.metadata }));
|
|
289
|
+
}
|
|
290
|
+
// Confirm/contradict (t-338): computed in code, feedback memories only.
|
|
291
|
+
// The delta applies to the confidence as it reads NOW (decay
|
|
292
|
+
// materialized), then the evidence clock restarts — so a contradict
|
|
293
|
+
// always lowers the current value and a confirm always raises it, and
|
|
294
|
+
// the evidence an aged score has already lost can't come back.
|
|
295
|
+
if (input.observation && row.category === FEEDBACK_CATEGORY) {
|
|
296
|
+
const current = row.confidence !== null
|
|
297
|
+
? decayedConfidence(row.confidence, row.observed_at ?? row.created, row.category, Date.parse(updatedAt))
|
|
298
|
+
: FEEDBACK_CONFIDENCE_DEFAULT;
|
|
299
|
+
newConfidence = applyObservation(current, input.observation);
|
|
300
|
+
newEvidenceCount = row.evidence_count + 1;
|
|
301
|
+
sets.push('confidence = ?', 'evidence_count = ?', 'observed_at = ?');
|
|
302
|
+
params.push(newConfidence, newEvidenceCount, updatedAt);
|
|
303
|
+
}
|
|
304
|
+
this.db.prepare(`UPDATE memories SET ${sets.join(', ')} WHERE id = ?`).run(...params, row.id);
|
|
305
|
+
if (contentChanged) {
|
|
306
|
+
this.db
|
|
307
|
+
.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?')
|
|
308
|
+
.run(toVecBuffer(vector), BigInt(row.id));
|
|
309
|
+
}
|
|
310
|
+
return true;
|
|
223
311
|
});
|
|
224
|
-
|
|
225
|
-
|
|
312
|
+
// IMMEDIATE takes the write lock before the read, so another process
|
|
313
|
+
// (loom-http and the CLI share the file) can't commit between them.
|
|
314
|
+
if (!retryWrite(() => tx.immediate()))
|
|
315
|
+
return { updated: false };
|
|
316
|
+
return {
|
|
317
|
+
updated: true,
|
|
318
|
+
ref,
|
|
319
|
+
snapshotId,
|
|
320
|
+
confidence: newConfidence,
|
|
321
|
+
evidenceCount: newEvidenceCount,
|
|
322
|
+
};
|
|
226
323
|
}
|
|
227
324
|
async prune(options) {
|
|
228
325
|
const dryRun = options?.dryRun ?? false;
|
|
@@ -250,7 +347,20 @@ export class SqliteVecBackend {
|
|
|
250
347
|
}
|
|
251
348
|
}
|
|
252
349
|
if (!dryRun && expiredIds.length > 0) {
|
|
253
|
-
|
|
350
|
+
// Archive instead of hard-delete: content is preserved for recovery,
|
|
351
|
+
// the embedding is dropped (no KNN slots wasted), archived = 1 hides
|
|
352
|
+
// the row from recall/list/audit — matching the contract in t-332.
|
|
353
|
+
const now = new Date().toISOString();
|
|
354
|
+
const tombstone = JSON.stringify({ note: 'TTL elapsed', archived_at: now });
|
|
355
|
+
const archiveStmt = this.db.prepare('UPDATE memories SET archived = 1, archive_note = ?, updated = ? WHERE id = ?');
|
|
356
|
+
const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
|
|
357
|
+
const tx = this.db.transaction(() => {
|
|
358
|
+
for (const id of expiredIds) {
|
|
359
|
+
archiveStmt.run(tombstone, now, id);
|
|
360
|
+
delVec.run(BigInt(id));
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
retryWrite(() => tx());
|
|
254
364
|
}
|
|
255
365
|
return { expired, stale };
|
|
256
366
|
}
|
|
@@ -269,7 +379,8 @@ export class SqliteVecBackend {
|
|
|
269
379
|
const where = `WHERE ${clauses.join(' AND ')}`;
|
|
270
380
|
params.push(input.limit ?? 50);
|
|
271
381
|
const rows = this.db
|
|
272
|
-
.prepare(`SELECT ref, title, category, project, created
|
|
382
|
+
.prepare(`SELECT ref, title, category, project, created, sourcing, provenance,
|
|
383
|
+
confidence, evidence_count, observed_at
|
|
273
384
|
FROM memories ${where}
|
|
274
385
|
ORDER BY created DESC LIMIT ?`)
|
|
275
386
|
.all(...params);
|
|
@@ -279,6 +390,12 @@ export class SqliteVecBackend {
|
|
|
279
390
|
category: r.category,
|
|
280
391
|
project: r.project ?? undefined,
|
|
281
392
|
created: r.created,
|
|
393
|
+
sourcing: r.sourcing ?? undefined,
|
|
394
|
+
provenance: r.provenance ?? undefined,
|
|
395
|
+
confidence: r.confidence !== null
|
|
396
|
+
? decayedConfidence(r.confidence, r.observed_at ?? r.created, r.category, Date.now())
|
|
397
|
+
: undefined,
|
|
398
|
+
evidenceCount: r.confidence !== null ? r.evidence_count : undefined,
|
|
282
399
|
}));
|
|
283
400
|
}
|
|
284
401
|
async findSimilar(input) {
|
|
@@ -299,7 +416,7 @@ export class SqliteVecBackend {
|
|
|
299
416
|
.get(BigInt(row.id));
|
|
300
417
|
if (!vecRow)
|
|
301
418
|
throw new Error(`findSimilar: embedding missing for ${input.ref}`);
|
|
302
|
-
queryVector = Array.from(
|
|
419
|
+
queryVector = Array.from(bufToF32(vecRow.embedding));
|
|
303
420
|
}
|
|
304
421
|
else {
|
|
305
422
|
queryVector = await (this.embedder.embedQuery?.(input.text) ??
|
|
@@ -316,7 +433,7 @@ export class SqliteVecBackend {
|
|
|
316
433
|
return false;
|
|
317
434
|
if (categoryFilter && mem.category !== categoryFilter)
|
|
318
435
|
return false;
|
|
319
|
-
if (
|
|
436
|
+
if (!isVisibleInProject(mem, projectFilter))
|
|
320
437
|
return false;
|
|
321
438
|
if (1 - vr.distance < minRelevance)
|
|
322
439
|
return false;
|
|
@@ -357,24 +474,33 @@ export class SqliteVecBackend {
|
|
|
357
474
|
});
|
|
358
475
|
}
|
|
359
476
|
}
|
|
360
|
-
// Duplicate detection:
|
|
361
|
-
//
|
|
362
|
-
|
|
477
|
+
// Duplicate detection: batch-load all embeddings in one query, then
|
|
478
|
+
// for each memory find its k-nearest neighbours (k=10 to surface
|
|
479
|
+
// clusters, not just the single closest), collect ALL pairs above the
|
|
480
|
+
// threshold, sort by descending similarity, and truncate to maxDuplicates.
|
|
481
|
+
// One DB query per row (the MATCH) instead of two (fetch + MATCH).
|
|
482
|
+
const allPairs = [];
|
|
363
483
|
const seenPair = new Set();
|
|
364
484
|
const pairKey = (a, b) => a < b ? `${a}:${b}` : `${b}:${a}`;
|
|
365
485
|
const byIdRow = new Map(rows.map((r) => [r.id, r]));
|
|
486
|
+
// Load all stored embeddings in one pass so the inner loop only runs
|
|
487
|
+
// the MATCH query rather than a fetch-then-match pair.
|
|
488
|
+
const allEmbedRows = this.db
|
|
489
|
+
.prepare('SELECT rowid, embedding FROM vec_memories')
|
|
490
|
+
.all();
|
|
491
|
+
const embeddingMap = new Map();
|
|
492
|
+
for (const er of allEmbedRows) {
|
|
493
|
+
embeddingMap.set(Number(er.rowid), er.embedding);
|
|
494
|
+
}
|
|
495
|
+
const kNeighbours = 10;
|
|
366
496
|
const vecStmt = this.db.prepare(`SELECT rowid, distance FROM vec_memories
|
|
367
|
-
WHERE embedding MATCH ? AND k =
|
|
497
|
+
WHERE embedding MATCH ? AND k = ${kNeighbours}
|
|
368
498
|
ORDER BY distance`);
|
|
369
499
|
for (const r of rows) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
const stored = this.db
|
|
373
|
-
.prepare('SELECT embedding FROM vec_memories WHERE rowid = ?')
|
|
374
|
-
.get(BigInt(r.id));
|
|
375
|
-
if (!stored)
|
|
500
|
+
const emb = embeddingMap.get(r.id);
|
|
501
|
+
if (!emb)
|
|
376
502
|
continue;
|
|
377
|
-
const hits = vecStmt.all(
|
|
503
|
+
const hits = vecStmt.all(emb);
|
|
378
504
|
for (const h of hits) {
|
|
379
505
|
if (h.rowid === r.id)
|
|
380
506
|
continue;
|
|
@@ -388,15 +514,17 @@ export class SqliteVecBackend {
|
|
|
388
514
|
const other = byIdRow.get(h.rowid);
|
|
389
515
|
if (!other)
|
|
390
516
|
continue;
|
|
391
|
-
|
|
517
|
+
allPairs.push({
|
|
392
518
|
a: { ref: r.ref, title: r.title },
|
|
393
519
|
b: { ref: other.ref, title: other.title },
|
|
394
520
|
relevance,
|
|
395
521
|
});
|
|
396
|
-
if (duplicates.length >= maxDuplicates)
|
|
397
|
-
break;
|
|
398
522
|
}
|
|
399
523
|
}
|
|
524
|
+
// Sort highest-similarity pairs first so the truncation keeps the worst
|
|
525
|
+
// duplicates, not just the ones discovered first by insertion order.
|
|
526
|
+
allPairs.sort((x, y) => y.relevance - x.relevance);
|
|
527
|
+
const duplicates = allPairs.slice(0, maxDuplicates);
|
|
400
528
|
return {
|
|
401
529
|
totalMemories: rows.length,
|
|
402
530
|
byCategory,
|
|
@@ -443,14 +571,14 @@ export class SqliteVecBackend {
|
|
|
443
571
|
let rows = [];
|
|
444
572
|
if (input.ref) {
|
|
445
573
|
const row = this.db
|
|
446
|
-
.prepare('SELECT id, ref, title, content FROM memories WHERE ref = ? AND archived = 1')
|
|
574
|
+
.prepare('SELECT id, ref, title, content, category, project, scope FROM memories WHERE ref = ? AND archived = 1')
|
|
447
575
|
.get(input.ref);
|
|
448
576
|
if (row)
|
|
449
577
|
rows = [row];
|
|
450
578
|
}
|
|
451
579
|
else if (input.category && input.title) {
|
|
452
580
|
rows = this.db
|
|
453
|
-
.prepare('SELECT id, ref, title, content FROM memories WHERE category = ? AND title = ? AND archived = 1')
|
|
581
|
+
.prepare('SELECT id, ref, title, content, category, project, scope FROM memories WHERE category = ? AND title = ? AND archived = 1')
|
|
454
582
|
.all(input.category, input.title);
|
|
455
583
|
}
|
|
456
584
|
else {
|
|
@@ -461,19 +589,22 @@ export class SqliteVecBackend {
|
|
|
461
589
|
// Archive dropped the embedding; rebuild it from the stored content.
|
|
462
590
|
const vectors = await this.embedder.embedBatch(rows.map((r) => `${r.title}\n\n${r.content}`));
|
|
463
591
|
const now = new Date().toISOString();
|
|
464
|
-
const stmt = this.db.prepare('UPDATE memories SET archived = 0, archive_note = NULL, updated = ? WHERE id = ?');
|
|
592
|
+
const stmt = this.db.prepare('UPDATE memories SET archived = 0, archive_note = NULL, uniq = ?, updated = ? WHERE id = ?');
|
|
465
593
|
// Delete-then-insert: legacy rows archived before delete-on-archive
|
|
466
594
|
// may still hold a vector, and vec0 rowids must stay unique.
|
|
467
595
|
const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
|
|
468
596
|
const insVec = this.db.prepare('INSERT INTO vec_memories(rowid, embedding) VALUES (?, ?)');
|
|
469
597
|
const tx = this.db.transaction(() => {
|
|
470
598
|
rows.forEach((row, i) => {
|
|
471
|
-
|
|
599
|
+
// Re-derive the key: the row may carry a stale or pre-rekey one, and
|
|
600
|
+
// while it sat archived the same body may have been remembered again
|
|
601
|
+
// — that active row keeps the key (PR120 review).
|
|
602
|
+
stmt.run(this.claimableUniq(row.id, row), now, row.id);
|
|
472
603
|
delVec.run(BigInt(row.id));
|
|
473
604
|
insVec.run(BigInt(row.id), toVecBuffer(vectors[i]));
|
|
474
605
|
});
|
|
475
606
|
});
|
|
476
|
-
retryWrite(() => tx());
|
|
607
|
+
retryWrite(() => tx.immediate());
|
|
477
608
|
return { restored: rows.map((r) => r.ref) };
|
|
478
609
|
}
|
|
479
610
|
listRevisions(ref) {
|
|
@@ -517,13 +648,13 @@ export class SqliteVecBackend {
|
|
|
517
648
|
// Snapshot the current body before replacing it.
|
|
518
649
|
const snapshotId = this.snapshotRevision(mem.id, mem.ref, mem.content, 'revision-restore', timestamp);
|
|
519
650
|
const vector = await this.embedder.embed(`${mem.title}\n\n${rev.content}`);
|
|
520
|
-
const updateStmt = this.db.prepare('UPDATE memories SET content = ?, updated = ? WHERE id = ?');
|
|
651
|
+
const updateStmt = this.db.prepare('UPDATE memories SET content = ?, uniq = ?, updated = ? WHERE id = ?');
|
|
521
652
|
const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
|
|
522
653
|
const tx = this.db.transaction(() => {
|
|
523
|
-
updateStmt.run(rev.content, timestamp, mem.id);
|
|
654
|
+
updateStmt.run(rev.content, this.claimableUniq(mem.id, { ...mem, content: rev.content }), timestamp, mem.id);
|
|
524
655
|
updateVec.run(toVecBuffer(vector), BigInt(mem.id));
|
|
525
656
|
});
|
|
526
|
-
retryWrite(() => tx());
|
|
657
|
+
retryWrite(() => tx.immediate());
|
|
527
658
|
return { ref: input.ref, revision_id: input.revision_id, restored: true, snapshot_id: snapshotId };
|
|
528
659
|
}
|
|
529
660
|
supersede(input) {
|
|
@@ -563,6 +694,83 @@ export class SqliteVecBackend {
|
|
|
563
694
|
retryWrite(() => tx());
|
|
564
695
|
return Promise.resolve({ old_ref: input.old_ref, new_ref: input.new_ref, archived: true });
|
|
565
696
|
}
|
|
697
|
+
async mergeMemories(input) {
|
|
698
|
+
if (!input.source_refs || input.source_refs.length === 0) {
|
|
699
|
+
return Promise.reject(new Error('mergeMemories: source_refs must not be empty'));
|
|
700
|
+
}
|
|
701
|
+
const targetRow = this.db
|
|
702
|
+
.prepare('SELECT * FROM memories WHERE ref = ? AND archived = 0')
|
|
703
|
+
.get(input.target_ref);
|
|
704
|
+
if (!targetRow) {
|
|
705
|
+
return Promise.reject(new Error(`mergeMemories: target_ref not found or already archived: ${input.target_ref}`));
|
|
706
|
+
}
|
|
707
|
+
const sourceRows = [];
|
|
708
|
+
for (const ref of input.source_refs) {
|
|
709
|
+
if (ref === input.target_ref) {
|
|
710
|
+
return Promise.reject(new Error(`mergeMemories: source_ref '${ref}' is the same as target_ref — cannot merge a memory into itself`));
|
|
711
|
+
}
|
|
712
|
+
const row = this.db
|
|
713
|
+
.prepare('SELECT * FROM memories WHERE ref = ? AND archived = 0')
|
|
714
|
+
.get(ref);
|
|
715
|
+
if (!row) {
|
|
716
|
+
return Promise.reject(new Error(`mergeMemories: source_ref not found or already archived: ${ref}`));
|
|
717
|
+
}
|
|
718
|
+
sourceRows.push(row);
|
|
719
|
+
}
|
|
720
|
+
const timestamp = new Date().toISOString();
|
|
721
|
+
const losers = sourceRows.map((r) => ({ ref: r.ref, title: r.title, content: r.content }));
|
|
722
|
+
const combinedTimesSeen = targetRow.times_seen + sourceRows.reduce((sum, r) => sum + r.times_seen, 0);
|
|
723
|
+
let newContent = targetRow.content;
|
|
724
|
+
if (input.append_loser_bodies) {
|
|
725
|
+
for (const loser of losers) {
|
|
726
|
+
newContent += `\n\n--- merged from ${loser.ref} ---\n\n${loser.content}`;
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
let vector;
|
|
730
|
+
if (newContent !== targetRow.content) {
|
|
731
|
+
// Snapshot the target body before overwriting — same protection update() gives.
|
|
732
|
+
this.snapshotRevision(targetRow.id, targetRow.ref, targetRow.content, 'update', timestamp);
|
|
733
|
+
vector = await this.embedder.embed(`${targetRow.title}\n\n${newContent}`);
|
|
734
|
+
}
|
|
735
|
+
const updateTarget = this.db.prepare('UPDATE memories SET content = ?, times_seen = ?, updated = ?, uniq = ? WHERE id = ?');
|
|
736
|
+
const updateVec = this.db.prepare('UPDATE vec_memories SET embedding = ? WHERE rowid = ?');
|
|
737
|
+
const repointRevisions = this.db.prepare('UPDATE memory_revisions SET memory_id = ? WHERE memory_id = ?');
|
|
738
|
+
const archiveSource = this.db.prepare('UPDATE memories SET archived = 1, archive_note = ?, updated = ? WHERE id = ?');
|
|
739
|
+
const delVec = this.db.prepare('DELETE FROM vec_memories WHERE rowid = ?');
|
|
740
|
+
const insertSup = this.db.prepare('INSERT INTO memory_supersessions (old_ref, new_ref, note, created) VALUES (?, ?, ?, ?)');
|
|
741
|
+
const tx = this.db.transaction(() => {
|
|
742
|
+
for (const source of sourceRows) {
|
|
743
|
+
// Re-parent revision history before the source row can be deleted.
|
|
744
|
+
repointRevisions.run(targetRow.id, source.id);
|
|
745
|
+
const tombstoneNote = input.note
|
|
746
|
+
? `Merged into ${input.target_ref}. ${input.note}`
|
|
747
|
+
: `Merged into ${input.target_ref}.`;
|
|
748
|
+
archiveSource.run(JSON.stringify({ note: tombstoneNote, archived_at: timestamp }), timestamp, source.id);
|
|
749
|
+
delVec.run(BigInt(source.id));
|
|
750
|
+
insertSup.run(source.ref, input.target_ref, input.note ?? null, timestamp);
|
|
751
|
+
if (input.hard_delete_losers) {
|
|
752
|
+
// Journaled like every other hard delete (t-436); nested here, so
|
|
753
|
+
// the journal row and the delete commit or roll back with the merge.
|
|
754
|
+
this.deleteByIdTx([source.id], 'merge')();
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
// After the sources are archived, so a key one of them held is free
|
|
758
|
+
// for the target's (possibly concatenated) body.
|
|
759
|
+
const newUniq = newContent !== targetRow.content
|
|
760
|
+
? this.claimableUniq(targetRow.id, { ...targetRow, content: newContent })
|
|
761
|
+
: targetRow.uniq;
|
|
762
|
+
updateTarget.run(newContent, combinedTimesSeen, timestamp, newUniq, targetRow.id);
|
|
763
|
+
if (vector)
|
|
764
|
+
updateVec.run(toVecBuffer(vector), BigInt(targetRow.id));
|
|
765
|
+
});
|
|
766
|
+
retryWrite(() => tx.immediate());
|
|
767
|
+
return {
|
|
768
|
+
target_ref: input.target_ref,
|
|
769
|
+
sources_merged: sourceRows.length,
|
|
770
|
+
times_seen: combinedTimesSeen,
|
|
771
|
+
losers,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
566
774
|
close() {
|
|
567
775
|
this.db.close();
|
|
568
776
|
}
|
|
@@ -570,6 +778,45 @@ export class SqliteVecBackend {
|
|
|
570
778
|
return this.db;
|
|
571
779
|
}
|
|
572
780
|
// ── Internals ──
|
|
781
|
+
/**
|
|
782
|
+
* A transaction that reheats the active row holding `uniqKey` (times_seen
|
|
783
|
+
* + 1, updated = timestamp) and returns its ref, or returns undefined if no
|
|
784
|
+
* active row holds it. Run it with .immediate() on its own, or call it
|
|
785
|
+
* inside another transaction (better-sqlite3 nests it as a savepoint).
|
|
786
|
+
*/
|
|
787
|
+
reheatTx(uniqKey, timestamp) {
|
|
788
|
+
return this.db.transaction(() => {
|
|
789
|
+
const existing = this.db
|
|
790
|
+
.prepare('SELECT id, ref, title, category FROM memories WHERE uniq = ? AND archived = 0 LIMIT 1')
|
|
791
|
+
.get(uniqKey);
|
|
792
|
+
if (!existing)
|
|
793
|
+
return undefined;
|
|
794
|
+
this.db
|
|
795
|
+
.prepare('UPDATE memories SET times_seen = times_seen + 1, updated = ? WHERE id = ?')
|
|
796
|
+
.run(timestamp, existing.id);
|
|
797
|
+
const slashIdx = existing.ref.indexOf('/');
|
|
798
|
+
return {
|
|
799
|
+
ref: existing.ref,
|
|
800
|
+
category: existing.category,
|
|
801
|
+
filename: slashIdx >= 0 ? existing.ref.slice(slashIdx + 1) : existing.ref,
|
|
802
|
+
title: existing.title,
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
/**
|
|
807
|
+
* The dedup key a row should hold for the given body, or NULL when another
|
|
808
|
+
* ACTIVE row already holds that key — the existing holder keeps it, same
|
|
809
|
+
* policy as the migration backfill (rebuildUniqKeys). Call inside the
|
|
810
|
+
* write transaction that changes the body, so the check and the write see
|
|
811
|
+
* the same state.
|
|
812
|
+
*/
|
|
813
|
+
claimableUniq(id, row) {
|
|
814
|
+
const key = computeUniqKey(row);
|
|
815
|
+
const holder = this.db
|
|
816
|
+
.prepare('SELECT id FROM memories WHERE uniq = ? AND archived = 0 AND id != ? LIMIT 1')
|
|
817
|
+
.get(key, id);
|
|
818
|
+
return holder ? null : key;
|
|
819
|
+
}
|
|
573
820
|
/**
|
|
574
821
|
* Insert a content snapshot into memory_revisions and prune beyond the per-memory
|
|
575
822
|
* cap. Mirrors the knowledge wing's snapshotRevision() in sqlite-knowledge.ts.
|
|
@@ -660,7 +907,7 @@ export class SqliteVecBackend {
|
|
|
660
907
|
const row = stmt.get(BigInt(id));
|
|
661
908
|
if (!row)
|
|
662
909
|
continue;
|
|
663
|
-
out.set(id,
|
|
910
|
+
out.set(id, bufToF32(row.embedding));
|
|
664
911
|
}
|
|
665
912
|
return out;
|
|
666
913
|
}
|
|
@@ -697,31 +944,97 @@ export class SqliteVecBackend {
|
|
|
697
944
|
// aborts startup loudly rather than leaving memory half-broken.
|
|
698
945
|
runMigrations(this.db, { strict: true });
|
|
699
946
|
}
|
|
700
|
-
|
|
947
|
+
/**
|
|
948
|
+
* Hard-delete rows by id. Every call is journaled to memory_deletions first
|
|
949
|
+
* (t-436) — this is the ONLY path that permanently destroys a memory, so it
|
|
950
|
+
* is the one place that must record what it destroyed. The journal table is
|
|
951
|
+
* standalone (keyed by ref, no FK to memories) so it survives the delete it
|
|
952
|
+
* describes.
|
|
953
|
+
*/
|
|
954
|
+
deleteById(ids, op = 'forget') {
|
|
701
955
|
if (ids.length === 0)
|
|
702
956
|
return;
|
|
957
|
+
const tx = this.deleteByIdTx(ids, op);
|
|
958
|
+
retryWrite(() => tx());
|
|
959
|
+
}
|
|
960
|
+
/**
|
|
961
|
+
* The journal-then-delete transaction behind deleteById. Callers already
|
|
962
|
+
* inside a transaction (mergeMemories with hard_delete_losers) invoke it
|
|
963
|
+
* directly; better-sqlite3 nests it as a savepoint.
|
|
964
|
+
*/
|
|
965
|
+
deleteByIdTx(ids, op) {
|
|
703
966
|
const placeholders = ids.map(() => '?').join(',');
|
|
967
|
+
const selectRows = this.db.prepare(`SELECT ref, category, title, project, content, created
|
|
968
|
+
FROM memories WHERE id IN (${placeholders})`);
|
|
969
|
+
const journalStmt = this.db.prepare(`INSERT INTO memory_deletions (ref, category, title, project, content, created, deleted_at, op)
|
|
970
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
|
|
704
971
|
const delMem = this.db.prepare(`DELETE FROM memories WHERE id IN (${placeholders})`);
|
|
705
972
|
const delVec = this.db.prepare(`DELETE FROM vec_memories WHERE rowid IN (${placeholders})`);
|
|
706
973
|
const bigIds = ids.map((n) => BigInt(n));
|
|
707
|
-
|
|
974
|
+
return this.db.transaction(() => {
|
|
975
|
+
// Read inside the transaction, so the journal records exactly the
|
|
976
|
+
// rows this delete removes.
|
|
977
|
+
const rows = selectRows.all(...ids);
|
|
978
|
+
const deletedAt = new Date().toISOString();
|
|
979
|
+
for (const row of rows) {
|
|
980
|
+
journalStmt.run(row.ref, row.category, row.title, row.project, row.content, row.created, deletedAt, op);
|
|
981
|
+
}
|
|
708
982
|
delMem.run(...ids);
|
|
709
983
|
delVec.run(...bigIds);
|
|
710
984
|
});
|
|
711
|
-
retryWrite(() => tx());
|
|
712
985
|
}
|
|
713
986
|
}
|
|
714
|
-
// ── Helpers ──
|
|
715
987
|
const EMPTY_VECTOR = new Float32Array(0);
|
|
988
|
+
/** Float32Array view of a Buffer; copies only when alignment forces it. */
|
|
989
|
+
export function bufToF32(buf) {
|
|
990
|
+
if (buf.byteOffset % Float32Array.BYTES_PER_ELEMENT === 0) {
|
|
991
|
+
return new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / Float32Array.BYTES_PER_ELEMENT);
|
|
992
|
+
}
|
|
993
|
+
return new Float32Array(buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength));
|
|
994
|
+
}
|
|
716
995
|
function clamp01(x) {
|
|
717
996
|
if (!Number.isFinite(x))
|
|
718
997
|
return DEFAULT_DIVERSITY;
|
|
719
998
|
return Math.min(1, Math.max(0, x));
|
|
720
999
|
}
|
|
1000
|
+
/**
|
|
1001
|
+
* An all-zero (or otherwise signal-free) embedding matches every row
|
|
1002
|
+
* equally in cosine search — it doesn't fail loudly, it just sits there
|
|
1003
|
+
* forever as a vector that neither rejects nor ever truly matches
|
|
1004
|
+
* anything. Refuse to persist one instead of writing that landmine (t-334;
|
|
1005
|
+
* mirrors Memori's embedding_rows_have_signal).
|
|
1006
|
+
*/
|
|
1007
|
+
function hasEmbeddingSignal(vector) {
|
|
1008
|
+
let sumSq = 0;
|
|
1009
|
+
for (const v of vector)
|
|
1010
|
+
sumSq += v * v;
|
|
1011
|
+
return sumSq > 1e-12;
|
|
1012
|
+
}
|
|
721
1013
|
function toVecBuffer(vector) {
|
|
1014
|
+
if (!hasEmbeddingSignal(vector)) {
|
|
1015
|
+
throw new Error(`refusing to persist a degenerate embedding — ${vector.length} dims with no real signal (all-zero or near-zero)`);
|
|
1016
|
+
}
|
|
722
1017
|
return Buffer.from(new Float32Array(vector).buffer);
|
|
723
1018
|
}
|
|
724
|
-
|
|
1019
|
+
/**
|
|
1020
|
+
* Decayed confidence for display: undefined for non-feedback memories (NULL
|
|
1021
|
+
* stored confidence), else the raw value relaxed toward neutral by how long
|
|
1022
|
+
* it's been since the last observation. `observed_at` (not `updated`, which
|
|
1023
|
+
* every edit stamps, nor `last_accessed`) is the decay clock — see decayedConfidence()'s doc comment in salience.ts.
|
|
1024
|
+
*/
|
|
1025
|
+
function decayedRowConfidence(row) {
|
|
1026
|
+
if (row.confidence === null)
|
|
1027
|
+
return undefined;
|
|
1028
|
+
const lastObserved = row.observed_at ?? row.created;
|
|
1029
|
+
return decayedConfidence(row.confidence, lastObserved, row.category, Date.now());
|
|
1030
|
+
}
|
|
1031
|
+
/**
|
|
1032
|
+
* `blendedRelevance`, when given, is the MMR score that actually placed this
|
|
1033
|
+
* row (see recall()'s `blended` array) — omit it (findSimilar, or recall
|
|
1034
|
+
* when MMR didn't run) and relevance falls back to the raw similarity.
|
|
1035
|
+
*/
|
|
1036
|
+
function rowToMatch(row, distance, blendedRelevance) {
|
|
1037
|
+
const similarity = 1 - distance;
|
|
725
1038
|
return {
|
|
726
1039
|
path: row.ref,
|
|
727
1040
|
title: row.title,
|
|
@@ -729,9 +1042,15 @@ function rowToMatch(row, distance) {
|
|
|
729
1042
|
project: row.project ?? undefined,
|
|
730
1043
|
created: row.created,
|
|
731
1044
|
content: row.content,
|
|
732
|
-
relevance:
|
|
1045
|
+
relevance: blendedRelevance ?? similarity,
|
|
1046
|
+
similarity,
|
|
1047
|
+
scope: effectiveScope(row),
|
|
733
1048
|
lastAccessed: row.last_accessed ?? undefined,
|
|
734
1049
|
ttl: row.ttl ?? undefined,
|
|
735
1050
|
expiresAt: row.expires_at ?? undefined,
|
|
1051
|
+
sourcing: row.sourcing ?? undefined,
|
|
1052
|
+
provenance: row.provenance ?? undefined,
|
|
1053
|
+
confidence: decayedRowConfidence(row),
|
|
1054
|
+
evidenceCount: row.confidence !== null ? row.evidence_count : undefined,
|
|
736
1055
|
};
|
|
737
1056
|
}
|