dsh-loci 2.0.0 → 2.0.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.
@@ -1,485 +1,485 @@
1
- /**
2
- * The memory store: one SQLite database, immutable revision rows, and the
3
- * lifecycle operations every surface (tools, panel, MCP, CLI) shares.
4
- *
5
- * Invariants this class owns:
6
- * - a fact is a chain of `(id, revision)` rows; an edit closes the old row and
7
- * opens a new one, so history is never rewritten;
8
- * - `memory_head` always points at the newest row of each chain;
9
- * - `index_rev` changes only when the injected index could change, so a
10
- * read-only turn never invalidates the prompt prefix;
11
- * - an injection gate of `status = 'verified'` is enforced here, not by
12
- * callers, so no surface can inject an unverified fact by accident.
13
- * @module dsh-loci/store
14
- */
15
- import { createHash } from 'node:crypto';
16
- import { existsSync, statSync } from 'node:fs';
17
- import { join } from 'node:path';
18
- import { AuditLog } from './audit.js';
19
- import { backupTo, closeDatabase, integrityCheck, openDatabase, quickCheck, tableCounts, withTransaction } from './db.js';
20
- import { newEntityId, newMemoryId } from './ids.js';
21
- import { migrate } from './migrate.js';
22
- import { EmbeddingChannel, vectorToBlob } from './embedding.js';
23
- import { META_KEYS, SCHEMA_VERSION, databaseVersion, readMeta, writeMeta } from './schema.js';
24
- import { normalizeText, tokenStream, tokenize, buildQueryPlan } from './tokenize.js';
25
- import { normalizeTitle, prepareForStorage } from '../inject/sanitize.js';
26
-
27
- /** Ids that were revoked or never existed. */
28
- export class MemoryNotFoundError extends Error {
29
- code = 'MEMORY_NOT_FOUND';
30
- constructor(id) {
31
- super(`memory not found: ${id}`);
32
- this.name = 'MemoryNotFoundError';
33
- }
34
- }
35
-
36
- /** Optimistic concurrency: the caller edited a revision that is no longer the head. */
37
- export class RevisionConflictError extends Error {
38
- code = 'REVISION_CONFLICT';
39
- constructor(id, expected, actual) {
40
- super(`memory ${id} is at revision ${actual}, not ${expected}; re-read it and retry`);
41
- this.name = 'RevisionConflictError';
42
- this.expected = expected;
43
- this.actual = actual;
44
- }
45
- }
46
-
47
- /** A caller input the store refuses to persist. */
48
- export class MemoryRejectedError extends Error {
49
- code = 'MEMORY_REJECTED';
50
- constructor(message) {
51
- super(message);
52
- this.name = 'MemoryRejectedError';
53
- }
54
- }
55
-
56
- /** Fields an entry exposes to callers, without the internal `ord`. */
57
- function mapRow(row) {
58
- if (row === undefined) return undefined;
59
- return {
60
- id: row.id,
61
- revision: Number(row.revision),
62
- kind: row.kind,
63
- scope: row.scope,
64
- scopeKey: row.scope_key,
65
- scopeLabel: row.scope_label,
66
- title: row.title,
67
- body: row.body,
68
- tags: JSON.parse(row.tags ?? '[]'),
69
- entities: JSON.parse(row.entities ?? '[]'),
70
- status: row.status,
71
- trustSource: row.trust_source,
72
- flags: JSON.parse(row.flags ?? '[]'),
73
- validFrom: Number(row.valid_from),
74
- validTo: row.valid_to === null || row.valid_to === undefined ? null : Number(row.valid_to),
75
- recordedAt: Number(row.recorded_at),
76
- supersedes: row.supersedes ?? null,
77
- supersededBy: row.superseded_by ?? null,
1
+ /**
2
+ * The memory store: one SQLite database, immutable revision rows, and the
3
+ * lifecycle operations every surface (tools, panel, MCP, CLI) shares.
4
+ *
5
+ * Invariants this class owns:
6
+ * - a fact is a chain of `(id, revision)` rows; an edit closes the old row and
7
+ * opens a new one, so history is never rewritten;
8
+ * - `memory_head` always points at the newest row of each chain;
9
+ * - `index_rev` changes only when the injected index could change, so a
10
+ * read-only turn never invalidates the prompt prefix;
11
+ * - an injection gate of `status = 'verified'` is enforced here, not by
12
+ * callers, so no surface can inject an unverified fact by accident.
13
+ * @module dsh-loci/store
14
+ */
15
+ import { createHash } from 'node:crypto';
16
+ import { existsSync, statSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+ import { AuditLog } from './audit.js';
19
+ import { backupTo, closeDatabase, integrityCheck, openDatabase, quickCheck, tableCounts, withTransaction } from './db.js';
20
+ import { newEntityId, newMemoryId } from './ids.js';
21
+ import { migrate } from './migrate.js';
22
+ import { EmbeddingChannel, vectorToBlob } from './embedding.js';
23
+ import { META_KEYS, SCHEMA_VERSION, databaseVersion, readMeta, writeMeta } from './schema.js';
24
+ import { normalizeText, tokenStream, tokenize, buildQueryPlan } from './tokenize.js';
25
+ import { normalizeTitle, prepareForStorage } from '../inject/sanitize.js';
26
+
27
+ /** Ids that were revoked or never existed. */
28
+ export class MemoryNotFoundError extends Error {
29
+ code = 'MEMORY_NOT_FOUND';
30
+ constructor(id) {
31
+ super(`memory not found: ${id}`);
32
+ this.name = 'MemoryNotFoundError';
33
+ }
34
+ }
35
+
36
+ /** Optimistic concurrency: the caller edited a revision that is no longer the head. */
37
+ export class RevisionConflictError extends Error {
38
+ code = 'REVISION_CONFLICT';
39
+ constructor(id, expected, actual) {
40
+ super(`memory ${id} is at revision ${actual}, not ${expected}; re-read it and retry`);
41
+ this.name = 'RevisionConflictError';
42
+ this.expected = expected;
43
+ this.actual = actual;
44
+ }
45
+ }
46
+
47
+ /** A caller input the store refuses to persist. */
48
+ export class MemoryRejectedError extends Error {
49
+ code = 'MEMORY_REJECTED';
50
+ constructor(message) {
51
+ super(message);
52
+ this.name = 'MemoryRejectedError';
53
+ }
54
+ }
55
+
56
+ /** Fields an entry exposes to callers, without the internal `ord`. */
57
+ function mapRow(row) {
58
+ if (row === undefined) return undefined;
59
+ return {
60
+ id: row.id,
61
+ revision: Number(row.revision),
62
+ kind: row.kind,
63
+ scope: row.scope,
64
+ scopeKey: row.scope_key,
65
+ scopeLabel: row.scope_label,
66
+ title: row.title,
67
+ body: row.body,
68
+ tags: JSON.parse(row.tags ?? '[]'),
69
+ entities: JSON.parse(row.entities ?? '[]'),
70
+ status: row.status,
71
+ trustSource: row.trust_source,
72
+ flags: JSON.parse(row.flags ?? '[]'),
73
+ validFrom: Number(row.valid_from),
74
+ validTo: row.valid_to === null || row.valid_to === undefined ? null : Number(row.valid_to),
75
+ recordedAt: Number(row.recorded_at),
76
+ supersedes: row.supersedes ?? null,
77
+ supersededBy: row.superseded_by ?? null,
78
78
  evidence: JSON.parse(row.evidence ?? '{}'),
79
79
  /** Citation back to the code or document the fact came from. */
80
- sourceRef: JSON.parse(row.evidence ?? '{}').ref ?? null,
81
- sourceSession: row.source_session ?? null,
82
- sourceEvent: row.source_event === null || row.source_event === undefined ? null : Number(row.source_event),
83
- verifiedAt: row.verified_at === null || row.verified_at === undefined ? null : Number(row.verified_at),
84
- verifiedBy: row.verified_by ?? null,
85
- pinned: Number(row.pinned) === 1,
86
- importance: Number(row.importance),
87
- createdAt: Number(row.created_at),
88
- updatedAt: Number(row.updated_at),
89
- deletedAt: row.deleted_at === null || row.deleted_at === undefined ? null : Number(row.deleted_at),
90
- };
91
- }
92
-
93
- /** The tier an entry occupies in the injected index; rules first, then pinned. */
94
- function tierOf(kind, pinned) {
95
- if (kind === 'rule') return 0;
96
- if (pinned === 1 || pinned === true) return 1;
97
- if (kind === 'semantic') return 2;
98
- if (kind === 'procedural') return 3;
99
- return 4;
100
- }
101
-
102
- /** Statuses that may be injected or returned by default. */
103
- export const INJECTABLE_STATUSES = Object.freeze(['verified']);
104
-
105
- /** The phrase a caller must echo before the whole store is forgotten. */
106
- export const CONFIRM_ALL = 'forget everything';
107
-
108
- /**
109
- * Open (and, when needed, create or migrate) the database behind one plugin
110
- * activation.
111
- */
112
- export class Store {
113
- /**
114
- * @param options - `memoryRoot`, `paths`, `config`, `log`, `pluginVersion`,
115
- * `now`, `quoteVerifier`.
116
- */
117
- constructor(options) {
118
- this.memoryRoot = options.memoryRoot;
119
- this.paths = options.paths;
120
- this.config = options.config ?? {};
121
- this.live = options.live ?? ((key) => this.config[key]);
122
- this.log = options.log;
123
- this.now = options.now ?? (() => Date.now());
124
- this.quoteVerifier = options.quoteVerifier ?? (() => false);
125
- this.pluginVersion = options.pluginVersion ?? '0.0.0';
126
- this.readOnly = options.readOnly === true;
127
- this.db = openDatabase({ file: this.paths.db, readOnly: this.readOnly });
128
- this.audit = this.readOnly
129
- ? { append() {}, flush() {}, tail() { return []; }, close() {} }
130
- : new AuditLog({
131
- file: this.paths.audit,
132
- maxBytes: this.config.auditMaxBytes,
133
- keep: this.config.auditKeep,
134
- log: this.log,
135
- });
136
- this.migration = this.readOnly
137
- ? { from: databaseVersion(this.db), to: SCHEMA_VERSION, steps: [], backup: undefined }
138
- : migrate(this.db, {
139
- backupDir: this.paths.backups,
140
- pluginVersion: this.pluginVersion,
141
- log: this.log,
142
- now: this.now(),
143
- });
144
- this.vector = { mode: this.config.vectorMode ?? 'off', healthy: false, lastError: null, lastCheckedAt: null };
145
- this.embedding = new EmbeddingChannel({
146
- mode: this.config.vectorMode,
147
- endpoint: this.config.vectorEndpoint,
148
- model: this.config.vectorModel,
149
- timeoutMs: this.config.vectorTimeoutMs,
150
- minScore: this.config.vectorMinScore,
151
- log: this.log,
152
- });
153
- this.quoteChecked = 0;
154
- }
155
-
156
- /** Refuse a mutation on a read-only (MCP facade) handle. */
157
- #assertWritable() {
158
- if (this.readOnly === true) throw new MemoryRejectedError('this handle is read-only');
159
- }
160
- /** Close the database and flush the audit buffer. */
161
- close() {
162
- this.audit?.close();
163
- closeDatabase(this.db);
164
- this.db = undefined;
165
- }
166
-
167
- /** Current schema version of the open database. */
168
- schemaVersion() {
169
- return databaseVersion(this.db);
170
- }
171
-
172
- /** Monotonic index revision; changes only when the injected set may change. */
173
- indexRev() {
174
- return Number(readMeta(this.db, META_KEYS.indexRev) ?? '0');
175
- }
176
-
177
- /** Bump the index revision inside the caller's transaction. */
178
- bumpIndexRev() {
179
- writeMeta(this.db, META_KEYS.indexRev, String(this.indexRev() + 1));
180
- }
181
-
182
- /**
183
- * Persist one memory, applying the trust gate, dedupe, and revision rules.
184
- *
185
- * @param input - see {@link writeShape}.
186
- * @returns `{entry, created, updated, deduplicated, possibleDuplicates, warnings}`.
187
- */
188
- write(input) {
189
- this.#assertWritable();
190
- const now = this.now();
191
- const prepared = prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
192
- if (prepared.rejected) {
193
- throw new MemoryRejectedError(
194
- `memory contains a high-confidence secret (${prepared.redactions.join(', ')}) and secretPolicy is "reject"`,
195
- );
196
- }
197
- if (prepared.title.length === 0) throw new MemoryRejectedError('memory title must not be empty');
198
- if (prepared.body.length === 0) throw new MemoryRejectedError('memory body must not be empty');
199
-
200
- const kind = ['working', 'episodic', 'semantic', 'procedural', 'rule'].includes(input.kind)
201
- ? input.kind
202
- : 'semantic';
203
- const scope = input.scope ?? 'workspace';
204
- const scopeKey = input.scopeKey;
205
- if (typeof scopeKey !== 'string' || scopeKey.length === 0) {
206
- throw new MemoryRejectedError('scopeKey is required');
207
- }
208
- const trustSource = input.trustSource ?? 'tool';
209
- const evidence = prepared.evidence ?? {};
210
- const status = this.#resolveStatus({ input, prepared, evidence, trustSource });
211
-
212
- const contentHash = createHash('sha256')
213
- .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
214
- .digest('hex');
215
- const titleNorm = normalizeTitle(prepared.title);
216
-
217
- const existing = this.#findByHash(scopeKey, contentHash);
218
- if (existing !== undefined && input.force !== true) {
219
- return {
220
- entry: this.get(existing.id).entry,
221
- created: false,
222
- updated: false,
223
- deduplicated: true,
224
- possibleDuplicates: [],
225
- warnings: [],
226
- };
227
- }
228
-
229
- const sameTitle = this.#findByTitle(scopeKey, titleNorm);
230
- // A same-title write is *reported*, never silently applied: a model that
231
- // reuses a title must not overwrite text the user already confirmed. The
232
- // caller has to address the entry by id (`op:'update'`) once it has seen it.
233
- if ((input.op ?? 'create') === 'create' && sameTitle !== undefined && input.allowUpdateByTitle === true) {
234
- return this.update(sameTitle.id, {
235
- ...input,
236
- expectedRevision: sameTitle.revision,
237
- status,
238
- kind,
239
- preparedOverride: prepared,
240
- });
241
- }
242
-
243
- const warnings = [];
244
- const possibleDuplicates = [];
245
- if (sameTitle !== undefined) {
246
- possibleDuplicates.push({
247
- id: sameTitle.id,
248
- revision: Number(sameTitle.revision),
249
- title: prepared.title,
250
- similarity: 1,
251
- reason: 'same-title',
252
- });
253
- warnings.push(
254
- `an entry with this title already exists (${sameTitle.id}); writing it again creates a second entry - use id + op:update to revise it`,
255
- );
256
- }
257
- if (input.force !== true) {
258
- const similar = this.#findSimilar(scopeKey, prepared, contentHash);
259
- possibleDuplicates.push(...similar);
260
- if (similar.length > 0) {
261
- warnings.push(
262
- `similar memory already exists (${similar.map((hit) => hit.id).join(', ')}); pass force:true to keep both`,
263
- );
264
- if (input.requireUnique === true) {
265
- return { entry: null, created: false, updated: false, deduplicated: false, possibleDuplicates, warnings };
266
- }
267
- }
268
- }
269
-
270
- const id = input.id ?? newMemoryId(now);
271
- const ord = withTransaction(this.db, () => {
272
- const inserted = this.#insertRevision({
273
- id,
274
- revision: 1,
275
- kind,
276
- scope,
277
- scopeKey,
278
- scopeLabel: input.scopeLabel ?? '',
279
- title: prepared.title,
280
- titleNorm,
281
- body: prepared.body,
282
- tags: prepared.tags,
283
- entities: prepared.entities,
284
- status,
285
- trustSource,
286
- flags: prepared.flags,
287
- validFrom: Number.isFinite(input.validFrom) ? input.validFrom : now,
288
- validTo: Number.isFinite(input.validTo) ? input.validTo : null,
289
- supersedes: null,
290
- evidence,
291
- sourceSession: input.sourceSession ?? null,
292
- sourceEvent: Number.isSafeInteger(input.sourceEvent) ? input.sourceEvent : null,
293
- pinned: input.pinned === true ? 1 : 0,
294
- importance: Number.isInteger(input.importance) ? input.importance : 3,
295
- contentHash,
296
- createdAt: now,
297
- now,
298
- verifiedBy: status === 'verified' ? (input.verifiedBy ?? trustSource) : null,
299
- verifiedAt: status === 'verified' ? now : null,
300
- });
301
- this.bumpIndexRev();
302
- return inserted;
303
- });
304
-
305
- this.audit.append(
306
- 'write',
307
- {
308
- id,
309
- rev: 1,
310
- op: 'create',
311
- kind,
312
- scope,
313
- scopeKey,
314
- status,
315
- bytes: Buffer.byteLength(prepared.title) + Buffer.byteLength(prepared.body),
316
- redactions: prepared.redactions.length,
317
- flags: prepared.flags,
318
- },
319
- { sync: true },
320
- );
321
- return {
322
- entry: this.get(id, { ord }).entry,
323
- created: true,
324
- updated: false,
325
- deduplicated: false,
326
- possibleDuplicates,
327
- warnings,
328
- };
329
- }
330
-
331
- /**
332
- * Write a new revision of an existing memory.
333
- * @param id - memory id.
334
- * @param input - the same shape as {@link Store.write} plus `expectedRevision`.
335
- */
336
- update(id, input) {
337
- this.#assertWritable();
338
- const now = this.now();
339
- const head = this.#headRow(id);
340
- if (head === undefined || head.status === 'deleted') throw new MemoryNotFoundError(id);
341
- if (Number.isSafeInteger(input.expectedRevision) && input.expectedRevision !== Number(head.row.revision)) {
342
- throw new RevisionConflictError(id, input.expectedRevision, Number(head.row.revision));
343
- }
344
- const prepared = input.preparedOverride ?? prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
345
- if (prepared.rejected) {
346
- throw new MemoryRejectedError(`memory contains a high-confidence secret (${prepared.redactions.join(', ')})`);
347
- }
348
- const evidence = prepared.evidence ?? {};
349
- const revision = Number(head.row.revision) + 1;
350
- const contentHash = createHash('sha256')
351
- .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
352
- .digest('hex');
353
- // Trust is bound to the exact text that was verified. An edit keeps
354
- // `verified` only when the new text is byte-identical to a revision of this
355
- // entry that a human (or an evidence-checked write) already verified, and the
356
- // entry has not moved to another scope. Anything else re-opens verification:
357
- // the new revision lands as a candidate and waits for a human. A caller cannot
358
- // opt out by supplying a quote, because a quote proves what the user said,
359
- // not that it supports different words.
360
- const sameScope = input.scopeKey === undefined || input.scopeKey === head.row.scope_key;
361
- const keepsVerification = sameScope && this.#textWasVerified(id, contentHash);
362
- const status = input.status ?? (prepared.instructionLike ? 'disputed' : keepsVerification ? 'verified' : 'candidate');
363
-
364
- const ord = withTransaction(this.db, () => {
365
- this.db
366
- .prepare(
367
- `UPDATE memory SET valid_to = ?, status = 'superseded', superseded_by = ?, updated_at = ?
368
- WHERE id = ? AND revision = ?`,
369
- )
370
- .run(now, `${id}@${revision}`, now, id, Number(head.row.revision));
371
- const inserted = this.#insertRevision({
372
- id,
373
- revision,
374
- kind: input.kind ?? head.row.kind,
375
- scope: input.scope ?? head.row.scope,
376
- scopeKey: input.scopeKey ?? head.row.scope_key,
377
- scopeLabel: input.scopeLabel ?? head.row.scope_label,
378
- title: prepared.title,
379
- titleNorm: normalizeTitle(prepared.title),
380
- body: prepared.body,
381
- tags: prepared.tags,
382
- entities: prepared.entities,
383
- status,
384
- trustSource: input.trustSource ?? head.row.trust_source,
385
- flags: prepared.flags,
386
- validFrom: Number.isFinite(input.validFrom) ? input.validFrom : now,
387
- validTo: Number.isFinite(input.validTo) ? input.validTo : null,
388
- supersedes: `${id}@${Number(head.row.revision)}`,
389
- evidence,
390
- sourceSession: input.sourceSession ?? null,
391
- sourceEvent: Number.isSafeInteger(input.sourceEvent) ? input.sourceEvent : null,
392
- pinned: input.pinned === true ? 1 : Number(head.row.pinned),
393
- importance: Number.isInteger(input.importance) ? input.importance : Number(head.row.importance),
394
- contentHash,
395
- createdAt: Number(head.row.created_at),
396
- now,
397
- verifiedBy: status === 'verified' ? (input.verifiedBy ?? 'tool') : null,
398
- verifiedAt: status === 'verified' ? now : null,
399
- });
400
- this.db
401
- .prepare(
402
- `INSERT OR IGNORE INTO memory_edge (src_id, src_rev, dst_id, dst_rev, relation, created_at)
403
- VALUES (?, ?, ?, ?, 'supersedes', ?)`,
404
- )
405
- .run(id, revision, id, Number(head.row.revision), now);
406
- this.bumpIndexRev();
407
- return inserted;
408
- });
409
-
410
- this.audit.append(
411
- 'write',
412
- {
413
- id,
414
- rev: revision,
415
- op: 'update',
416
- status,
417
- scopeKey: input.scopeKey ?? head.row.scope_key,
418
- bytes: Buffer.byteLength(prepared.title) + Buffer.byteLength(prepared.body),
419
- redactions: prepared.redactions.length,
420
- flags: prepared.flags,
421
- },
422
- { sync: true },
423
- );
424
- return {
425
- entry: this.get(id, { ord }).entry,
426
- created: false,
427
- updated: true,
428
- deduplicated: false,
429
- possibleDuplicates: [],
430
- warnings: [],
431
- };
432
- }
433
-
434
- /**
435
- * Merge near-duplicate entries into one.
436
- *
437
- * Two facts that say the same thing differently are worse than one: they split
438
- * the ranking, they both take an index line, and a correction has to be applied
439
- * twice. Dedupe on the write path only catches byte-identical or same-title
440
- * writes, so the realistic case — "we wrote this twice, months apart, with
441
- * different words" — needs an explicit operation.
442
- *
443
- * The sources are **superseded, never deleted**: their history stays readable
444
- * and `get(id)` keeps working, which is what makes a merge reviewable later.
445
- *
446
- * Verification is not inherited: if the merged entry's text changes, the target
447
- * goes back to `candidate` through the same path as any other edit.
448
- *
449
- * @param input - `targetId`, `sourceIds[]`, `expectedRevisions[]` (optional,
450
- * aligned with target-then-sources order), `title`, `body`, `actor`, `reason`.
451
- * @returns `{entry, merged: string[], revision}`.
452
- */
453
- merge(input) {
454
- this.#assertWritable();
455
- const targetId = typeof input?.targetId === 'string' ? input.targetId : undefined;
456
- if (targetId === undefined) throw new MemoryRejectedError('merge requires targetId');
457
- const sourceIds = Array.isArray(input?.sourceIds) ? [...new Set(input.sourceIds)] : [];
458
- if (sourceIds.length === 0) throw new MemoryRejectedError('merge requires at least one sourceId');
459
- if (sourceIds.includes(targetId)) throw new MemoryRejectedError('a merge cannot list the target as its own source');
460
-
461
- const now = this.now();
462
- const target = this.#headRow(targetId);
463
- if (target === undefined || target.status === 'deleted') throw new MemoryNotFoundError(targetId);
464
- const sources = sourceIds.map((id) => {
465
- const head = this.#headRow(id);
466
- if (head === undefined || head.status === 'deleted') throw new MemoryNotFoundError(id);
467
- return { id, head };
468
- });
469
-
470
- // Optimistic lock, when the caller supplies revisions (target first, then
471
- // sources in the order given). A stale expectation aborts before any write.
472
- const expected = Array.isArray(input.expectedRevisions) ? input.expectedRevisions : [];
473
- const expectedList = [Number(target.row.revision), ...sources.map((source) => Number(source.head.row.revision))];
474
- expectedList.forEach((actual, index) => {
475
- const wanted = expected[index];
476
- if (Number.isSafeInteger(wanted) && wanted !== actual) {
477
- throw new RevisionConflictError(index === 0 ? targetId : sourceIds[index - 1], wanted, actual);
478
- }
479
- });
480
-
481
- // Union the metadata first: the merged entry should carry every tag and entity
482
- // either side had, or the merge loses searchability.
80
+ sourceRef: JSON.parse(row.evidence ?? '{}').ref ?? null,
81
+ sourceSession: row.source_session ?? null,
82
+ sourceEvent: row.source_event === null || row.source_event === undefined ? null : Number(row.source_event),
83
+ verifiedAt: row.verified_at === null || row.verified_at === undefined ? null : Number(row.verified_at),
84
+ verifiedBy: row.verified_by ?? null,
85
+ pinned: Number(row.pinned) === 1,
86
+ importance: Number(row.importance),
87
+ createdAt: Number(row.created_at),
88
+ updatedAt: Number(row.updated_at),
89
+ deletedAt: row.deleted_at === null || row.deleted_at === undefined ? null : Number(row.deleted_at),
90
+ };
91
+ }
92
+
93
+ /** The tier an entry occupies in the injected index; rules first, then pinned. */
94
+ function tierOf(kind, pinned) {
95
+ if (kind === 'rule') return 0;
96
+ if (pinned === 1 || pinned === true) return 1;
97
+ if (kind === 'semantic') return 2;
98
+ if (kind === 'procedural') return 3;
99
+ return 4;
100
+ }
101
+
102
+ /** Statuses that may be injected or returned by default. */
103
+ export const INJECTABLE_STATUSES = Object.freeze(['verified']);
104
+
105
+ /** The phrase a caller must echo before the whole store is forgotten. */
106
+ export const CONFIRM_ALL = 'forget everything';
107
+
108
+ /**
109
+ * Open (and, when needed, create or migrate) the database behind one plugin
110
+ * activation.
111
+ */
112
+ export class Store {
113
+ /**
114
+ * @param options - `memoryRoot`, `paths`, `config`, `log`, `pluginVersion`,
115
+ * `now`, `quoteVerifier`.
116
+ */
117
+ constructor(options) {
118
+ this.memoryRoot = options.memoryRoot;
119
+ this.paths = options.paths;
120
+ this.config = options.config ?? {};
121
+ this.live = options.live ?? ((key) => this.config[key]);
122
+ this.log = options.log;
123
+ this.now = options.now ?? (() => Date.now());
124
+ this.quoteVerifier = options.quoteVerifier ?? (() => false);
125
+ this.pluginVersion = options.pluginVersion ?? '0.0.0';
126
+ this.readOnly = options.readOnly === true;
127
+ this.db = openDatabase({ file: this.paths.db, readOnly: this.readOnly });
128
+ this.audit = this.readOnly
129
+ ? { append() {}, flush() {}, tail() { return []; }, close() {} }
130
+ : new AuditLog({
131
+ file: this.paths.audit,
132
+ maxBytes: this.config.auditMaxBytes,
133
+ keep: this.config.auditKeep,
134
+ log: this.log,
135
+ });
136
+ this.migration = this.readOnly
137
+ ? { from: databaseVersion(this.db), to: SCHEMA_VERSION, steps: [], backup: undefined }
138
+ : migrate(this.db, {
139
+ backupDir: this.paths.backups,
140
+ pluginVersion: this.pluginVersion,
141
+ log: this.log,
142
+ now: this.now(),
143
+ });
144
+ this.vector = { mode: this.config.vectorMode ?? 'off', healthy: false, lastError: null, lastCheckedAt: null };
145
+ this.embedding = new EmbeddingChannel({
146
+ mode: this.config.vectorMode,
147
+ endpoint: this.config.vectorEndpoint,
148
+ model: this.config.vectorModel,
149
+ timeoutMs: this.config.vectorTimeoutMs,
150
+ minScore: this.config.vectorMinScore,
151
+ log: this.log,
152
+ });
153
+ this.quoteChecked = 0;
154
+ }
155
+
156
+ /** Refuse a mutation on a read-only (MCP facade) handle. */
157
+ #assertWritable() {
158
+ if (this.readOnly === true) throw new MemoryRejectedError('this handle is read-only');
159
+ }
160
+ /** Close the database and flush the audit buffer. */
161
+ close() {
162
+ this.audit?.close();
163
+ closeDatabase(this.db);
164
+ this.db = undefined;
165
+ }
166
+
167
+ /** Current schema version of the open database. */
168
+ schemaVersion() {
169
+ return databaseVersion(this.db);
170
+ }
171
+
172
+ /** Monotonic index revision; changes only when the injected set may change. */
173
+ indexRev() {
174
+ return Number(readMeta(this.db, META_KEYS.indexRev) ?? '0');
175
+ }
176
+
177
+ /** Bump the index revision inside the caller's transaction. */
178
+ bumpIndexRev() {
179
+ writeMeta(this.db, META_KEYS.indexRev, String(this.indexRev() + 1));
180
+ }
181
+
182
+ /**
183
+ * Persist one memory, applying the trust gate, dedupe, and revision rules.
184
+ *
185
+ * @param input - see {@link writeShape}.
186
+ * @returns `{entry, created, updated, deduplicated, possibleDuplicates, warnings}`.
187
+ */
188
+ write(input) {
189
+ this.#assertWritable();
190
+ const now = this.now();
191
+ const prepared = prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
192
+ if (prepared.rejected) {
193
+ throw new MemoryRejectedError(
194
+ `memory contains a high-confidence secret (${prepared.redactions.join(', ')}) and secretPolicy is "reject"`,
195
+ );
196
+ }
197
+ if (prepared.title.length === 0) throw new MemoryRejectedError('memory title must not be empty');
198
+ if (prepared.body.length === 0) throw new MemoryRejectedError('memory body must not be empty');
199
+
200
+ const kind = ['working', 'episodic', 'semantic', 'procedural', 'rule'].includes(input.kind)
201
+ ? input.kind
202
+ : 'semantic';
203
+ const scope = input.scope ?? 'workspace';
204
+ const scopeKey = input.scopeKey;
205
+ if (typeof scopeKey !== 'string' || scopeKey.length === 0) {
206
+ throw new MemoryRejectedError('scopeKey is required');
207
+ }
208
+ const trustSource = input.trustSource ?? 'tool';
209
+ const evidence = prepared.evidence ?? {};
210
+ const status = this.#resolveStatus({ input, prepared, evidence, trustSource });
211
+
212
+ const contentHash = createHash('sha256')
213
+ .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
214
+ .digest('hex');
215
+ const titleNorm = normalizeTitle(prepared.title);
216
+
217
+ const existing = this.#findByHash(scopeKey, contentHash);
218
+ if (existing !== undefined && input.force !== true) {
219
+ return {
220
+ entry: this.get(existing.id).entry,
221
+ created: false,
222
+ updated: false,
223
+ deduplicated: true,
224
+ possibleDuplicates: [],
225
+ warnings: [],
226
+ };
227
+ }
228
+
229
+ const sameTitle = this.#findByTitle(scopeKey, titleNorm);
230
+ // A same-title write is *reported*, never silently applied: a model that
231
+ // reuses a title must not overwrite text the user already confirmed. The
232
+ // caller has to address the entry by id (`op:'update'`) once it has seen it.
233
+ if ((input.op ?? 'create') === 'create' && sameTitle !== undefined && input.allowUpdateByTitle === true) {
234
+ return this.update(sameTitle.id, {
235
+ ...input,
236
+ expectedRevision: sameTitle.revision,
237
+ status,
238
+ kind,
239
+ preparedOverride: prepared,
240
+ });
241
+ }
242
+
243
+ const warnings = [];
244
+ const possibleDuplicates = [];
245
+ if (sameTitle !== undefined) {
246
+ possibleDuplicates.push({
247
+ id: sameTitle.id,
248
+ revision: Number(sameTitle.revision),
249
+ title: prepared.title,
250
+ similarity: 1,
251
+ reason: 'same-title',
252
+ });
253
+ warnings.push(
254
+ `an entry with this title already exists (${sameTitle.id}); writing it again creates a second entry - use id + op:update to revise it`,
255
+ );
256
+ }
257
+ if (input.force !== true) {
258
+ const similar = this.#findSimilar(scopeKey, prepared, contentHash);
259
+ possibleDuplicates.push(...similar);
260
+ if (similar.length > 0) {
261
+ warnings.push(
262
+ `similar memory already exists (${similar.map((hit) => hit.id).join(', ')}); pass force:true to keep both`,
263
+ );
264
+ if (input.requireUnique === true) {
265
+ return { entry: null, created: false, updated: false, deduplicated: false, possibleDuplicates, warnings };
266
+ }
267
+ }
268
+ }
269
+
270
+ const id = input.id ?? newMemoryId(now);
271
+ const ord = withTransaction(this.db, () => {
272
+ const inserted = this.#insertRevision({
273
+ id,
274
+ revision: 1,
275
+ kind,
276
+ scope,
277
+ scopeKey,
278
+ scopeLabel: input.scopeLabel ?? '',
279
+ title: prepared.title,
280
+ titleNorm,
281
+ body: prepared.body,
282
+ tags: prepared.tags,
283
+ entities: prepared.entities,
284
+ status,
285
+ trustSource,
286
+ flags: prepared.flags,
287
+ validFrom: Number.isFinite(input.validFrom) ? input.validFrom : now,
288
+ validTo: Number.isFinite(input.validTo) ? input.validTo : null,
289
+ supersedes: null,
290
+ evidence,
291
+ sourceSession: input.sourceSession ?? null,
292
+ sourceEvent: Number.isSafeInteger(input.sourceEvent) ? input.sourceEvent : null,
293
+ pinned: input.pinned === true ? 1 : 0,
294
+ importance: Number.isInteger(input.importance) ? input.importance : 3,
295
+ contentHash,
296
+ createdAt: now,
297
+ now,
298
+ verifiedBy: status === 'verified' ? (input.verifiedBy ?? trustSource) : null,
299
+ verifiedAt: status === 'verified' ? now : null,
300
+ });
301
+ this.bumpIndexRev();
302
+ return inserted;
303
+ });
304
+
305
+ this.audit.append(
306
+ 'write',
307
+ {
308
+ id,
309
+ rev: 1,
310
+ op: 'create',
311
+ kind,
312
+ scope,
313
+ scopeKey,
314
+ status,
315
+ bytes: Buffer.byteLength(prepared.title) + Buffer.byteLength(prepared.body),
316
+ redactions: prepared.redactions.length,
317
+ flags: prepared.flags,
318
+ },
319
+ { sync: true },
320
+ );
321
+ return {
322
+ entry: this.get(id, { ord }).entry,
323
+ created: true,
324
+ updated: false,
325
+ deduplicated: false,
326
+ possibleDuplicates,
327
+ warnings,
328
+ };
329
+ }
330
+
331
+ /**
332
+ * Write a new revision of an existing memory.
333
+ * @param id - memory id.
334
+ * @param input - the same shape as {@link Store.write} plus `expectedRevision`.
335
+ */
336
+ update(id, input) {
337
+ this.#assertWritable();
338
+ const now = this.now();
339
+ const head = this.#headRow(id);
340
+ if (head === undefined || head.status === 'deleted') throw new MemoryNotFoundError(id);
341
+ if (Number.isSafeInteger(input.expectedRevision) && input.expectedRevision !== Number(head.row.revision)) {
342
+ throw new RevisionConflictError(id, input.expectedRevision, Number(head.row.revision));
343
+ }
344
+ const prepared = input.preparedOverride ?? prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
345
+ if (prepared.rejected) {
346
+ throw new MemoryRejectedError(`memory contains a high-confidence secret (${prepared.redactions.join(', ')})`);
347
+ }
348
+ const evidence = prepared.evidence ?? {};
349
+ const revision = Number(head.row.revision) + 1;
350
+ const contentHash = createHash('sha256')
351
+ .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
352
+ .digest('hex');
353
+ // Trust is bound to the exact text that was verified. An edit keeps
354
+ // `verified` only when the new text is byte-identical to a revision of this
355
+ // entry that a human (or an evidence-checked write) already verified, and the
356
+ // entry has not moved to another scope. Anything else re-opens verification:
357
+ // the new revision lands as a candidate and waits for a human. A caller cannot
358
+ // opt out by supplying a quote, because a quote proves what the user said,
359
+ // not that it supports different words.
360
+ const sameScope = input.scopeKey === undefined || input.scopeKey === head.row.scope_key;
361
+ const keepsVerification = sameScope && this.#textWasVerified(id, contentHash);
362
+ const status = input.status ?? (prepared.instructionLike ? 'disputed' : keepsVerification ? 'verified' : 'candidate');
363
+
364
+ const ord = withTransaction(this.db, () => {
365
+ this.db
366
+ .prepare(
367
+ `UPDATE memory SET valid_to = ?, status = 'superseded', superseded_by = ?, updated_at = ?
368
+ WHERE id = ? AND revision = ?`,
369
+ )
370
+ .run(now, `${id}@${revision}`, now, id, Number(head.row.revision));
371
+ const inserted = this.#insertRevision({
372
+ id,
373
+ revision,
374
+ kind: input.kind ?? head.row.kind,
375
+ scope: input.scope ?? head.row.scope,
376
+ scopeKey: input.scopeKey ?? head.row.scope_key,
377
+ scopeLabel: input.scopeLabel ?? head.row.scope_label,
378
+ title: prepared.title,
379
+ titleNorm: normalizeTitle(prepared.title),
380
+ body: prepared.body,
381
+ tags: prepared.tags,
382
+ entities: prepared.entities,
383
+ status,
384
+ trustSource: input.trustSource ?? head.row.trust_source,
385
+ flags: prepared.flags,
386
+ validFrom: Number.isFinite(input.validFrom) ? input.validFrom : now,
387
+ validTo: Number.isFinite(input.validTo) ? input.validTo : null,
388
+ supersedes: `${id}@${Number(head.row.revision)}`,
389
+ evidence,
390
+ sourceSession: input.sourceSession ?? null,
391
+ sourceEvent: Number.isSafeInteger(input.sourceEvent) ? input.sourceEvent : null,
392
+ pinned: input.pinned === true ? 1 : Number(head.row.pinned),
393
+ importance: Number.isInteger(input.importance) ? input.importance : Number(head.row.importance),
394
+ contentHash,
395
+ createdAt: Number(head.row.created_at),
396
+ now,
397
+ verifiedBy: status === 'verified' ? (input.verifiedBy ?? 'tool') : null,
398
+ verifiedAt: status === 'verified' ? now : null,
399
+ });
400
+ this.db
401
+ .prepare(
402
+ `INSERT OR IGNORE INTO memory_edge (src_id, src_rev, dst_id, dst_rev, relation, created_at)
403
+ VALUES (?, ?, ?, ?, 'supersedes', ?)`,
404
+ )
405
+ .run(id, revision, id, Number(head.row.revision), now);
406
+ this.bumpIndexRev();
407
+ return inserted;
408
+ });
409
+
410
+ this.audit.append(
411
+ 'write',
412
+ {
413
+ id,
414
+ rev: revision,
415
+ op: 'update',
416
+ status,
417
+ scopeKey: input.scopeKey ?? head.row.scope_key,
418
+ bytes: Buffer.byteLength(prepared.title) + Buffer.byteLength(prepared.body),
419
+ redactions: prepared.redactions.length,
420
+ flags: prepared.flags,
421
+ },
422
+ { sync: true },
423
+ );
424
+ return {
425
+ entry: this.get(id, { ord }).entry,
426
+ created: false,
427
+ updated: true,
428
+ deduplicated: false,
429
+ possibleDuplicates: [],
430
+ warnings: [],
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Merge near-duplicate entries into one.
436
+ *
437
+ * Two facts that say the same thing differently are worse than one: they split
438
+ * the ranking, they both take an index line, and a correction has to be applied
439
+ * twice. Dedupe on the write path only catches byte-identical or same-title
440
+ * writes, so the realistic case — "we wrote this twice, months apart, with
441
+ * different words" — needs an explicit operation.
442
+ *
443
+ * The sources are **superseded, never deleted**: their history stays readable
444
+ * and `get(id)` keeps working, which is what makes a merge reviewable later.
445
+ *
446
+ * Verification is not inherited: if the merged entry's text changes, the target
447
+ * goes back to `candidate` through the same path as any other edit.
448
+ *
449
+ * @param input - `targetId`, `sourceIds[]`, `expectedRevisions[]` (optional,
450
+ * aligned with target-then-sources order), `title`, `body`, `actor`, `reason`.
451
+ * @returns `{entry, merged: string[], revision}`.
452
+ */
453
+ merge(input) {
454
+ this.#assertWritable();
455
+ const targetId = typeof input?.targetId === 'string' ? input.targetId : undefined;
456
+ if (targetId === undefined) throw new MemoryRejectedError('merge requires targetId');
457
+ const sourceIds = Array.isArray(input?.sourceIds) ? [...new Set(input.sourceIds)] : [];
458
+ if (sourceIds.length === 0) throw new MemoryRejectedError('merge requires at least one sourceId');
459
+ if (sourceIds.includes(targetId)) throw new MemoryRejectedError('a merge cannot list the target as its own source');
460
+
461
+ const now = this.now();
462
+ const target = this.#headRow(targetId);
463
+ if (target === undefined || target.status === 'deleted') throw new MemoryNotFoundError(targetId);
464
+ const sources = sourceIds.map((id) => {
465
+ const head = this.#headRow(id);
466
+ if (head === undefined || head.status === 'deleted') throw new MemoryNotFoundError(id);
467
+ return { id, head };
468
+ });
469
+
470
+ // Optimistic lock, when the caller supplies revisions (target first, then
471
+ // sources in the order given). A stale expectation aborts before any write.
472
+ const expected = Array.isArray(input.expectedRevisions) ? input.expectedRevisions : [];
473
+ const expectedList = [Number(target.row.revision), ...sources.map((source) => Number(source.head.row.revision))];
474
+ expectedList.forEach((actual, index) => {
475
+ const wanted = expected[index];
476
+ if (Number.isSafeInteger(wanted) && wanted !== actual) {
477
+ throw new RevisionConflictError(index === 0 ? targetId : sourceIds[index - 1], wanted, actual);
478
+ }
479
+ });
480
+
481
+ // Union the metadata first: the merged entry should carry every tag and entity
482
+ // either side had, or the merge loses searchability.
483
483
  const asArray = (value) => {
484
484
  try {
485
485
  const parsed = JSON.parse(value ?? '[]');
@@ -488,1061 +488,1061 @@ export class Store {
488
488
  return [];
489
489
  }
490
490
  };
491
- const unionTags = (rows) => [...new Set(rows.flatMap((row) => asArray(row.tags)))];
492
- const unionEntities = (rows) => [...new Set(rows.flatMap((row) => asArray(row.entities)))];
493
- const rows = [target.row, ...sources.map((source) => source.head.row)];
494
- const tags = unionTags(rows);
495
- const entities = unionEntities(rows);
496
-
497
- let entry;
498
- if (typeof input.title === 'string' || typeof input.body === 'string') {
499
- // A content change goes through update(), so it lands as a candidate unless
500
- // the text is a previously verified revision.
501
- const updated = this.update(targetId, {
502
- kind: input.kind ?? target.row.kind,
503
- scope: input.scope ?? target.row.scope,
504
- scopeKey: input.scopeKey ?? target.row.scope_key,
505
- scopeLabel: input.scopeLabel ?? target.row.scope_label,
506
- title: typeof input.title === 'string' ? input.title : target.row.title,
507
- body: typeof input.body === 'string' ? input.body : target.row.body,
508
- tags,
509
- entities,
510
- trustSource: 'tool',
511
- expectedRevision: Number(target.row.revision),
512
- });
513
- entry = updated.entry;
514
- } else {
515
- this.db
516
- .prepare('UPDATE memory SET tags = ?, entities = ?, updated_at = ? WHERE id = ? AND revision = ?')
517
- .run(JSON.stringify(tags), JSON.stringify(entities), now, targetId, Number(target.row.revision));
518
- entry = this.get(targetId).entry;
519
- }
520
-
521
- const mergedAt = `${targetId}@${entry.revision}`;
522
- withTransaction(this.db, () => {
523
- for (const source of sources) {
524
- this.db
525
- .prepare(
526
- `UPDATE memory SET status = 'superseded', valid_to = ?, superseded_by = ?, updated_at = ?
527
- WHERE id = ? AND revision = ?`,
528
- )
529
- .run(now, mergedAt, now, source.id, Number(source.head.row.revision));
530
- this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run('superseded', source.id);
531
- }
532
- this.#recomputeTokenStats();
533
- this.bumpIndexRev();
534
- });
535
-
536
- this.audit.append(
537
- 'merge',
538
- {
539
- targetId,
540
- sources: sourceIds,
541
- revision: entry.revision,
542
- bytes: Buffer.byteLength(entry.title) + Buffer.byteLength(entry.body),
543
- actor: input.actor ?? 'tool',
544
- reason: input.reason ?? null,
545
- },
546
- { sync: true },
547
- );
548
- return { entry, merged: sourceIds, revision: entry.revision };
549
- }
550
-
551
- /**
552
- * Insert many entries in one transaction, skipping the per-write duplicate
553
- * search. This is the import and benchmark path: the caller owns dedupe
554
- * (imports are all candidates, and a benchmark seeds synthetic rows).
555
- *
556
- * Why it exists: a bulk import of a thousand files otherwise pays a
557
- * similarity query and a commit per row; at 10k rows the real write path
558
- * costs about 22 ms per row, which turns a routine import into a minute-long
559
- * tool call.
560
- *
561
- * @param entries - `{kind, scope, scopeKey, scopeLabel, title, body, tags,
562
- * entities, status, trustSource, evidence, sourceSession, sourceEvent}`.
563
- * @param options - `source` for the audit line.
564
- * @returns the new ids, in order.
565
- */
566
- writeMany(entries, options = {}) {
567
- this.#assertWritable();
568
- if (!Array.isArray(entries) || entries.length === 0) return [];
569
- const now = this.now();
570
- const ids = [];
571
- withTransaction(this.db, () => {
572
- for (const input of entries) {
573
- const prepared = prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
574
- if (prepared.rejected) {
575
- throw new MemoryRejectedError(
576
- `memory contains a high-confidence secret (${prepared.redactions.join(', ')}) and secretPolicy is "reject"`,
577
- );
578
- }
579
- if (prepared.title.length === 0 || prepared.body.length === 0) {
580
- throw new MemoryRejectedError('memory title and body must not be empty');
581
- }
582
- const id = input.id ?? newMemoryId(now);
583
- const kind = ['working', 'episodic', 'semantic', 'procedural', 'rule'].includes(input.kind)
584
- ? input.kind
585
- : 'semantic';
586
- const status = ['candidate', 'verified', 'disputed'].includes(input.status) ? input.status : 'candidate';
587
- const trustSource = input.trustSource ?? 'import';
588
- this.#insertRevision({
589
- id,
590
- revision: 1,
591
- kind,
592
- scope: input.scope ?? 'workspace',
593
- scopeKey: input.scopeKey,
594
- scopeLabel: input.scopeLabel ?? '',
595
- title: prepared.title,
596
- titleNorm: normalizeTitle(prepared.title),
597
- body: prepared.body,
598
- tags: prepared.tags,
599
- entities: prepared.entities,
600
- status,
601
- trustSource,
602
- flags: prepared.flags,
603
- validFrom: Number.isFinite(input.validFrom) ? input.validFrom : now,
604
- validTo: Number.isFinite(input.validTo) ? input.validTo : null,
605
- supersedes: null,
606
- evidence: prepared.evidence ?? {},
607
- sourceSession: input.sourceSession ?? null,
608
- sourceEvent: Number.isSafeInteger(input.sourceEvent) ? input.sourceEvent : null,
609
- pinned: input.pinned === true ? 1 : 0,
610
- importance: Number.isInteger(input.importance) ? input.importance : 3,
611
- contentHash: createHash('sha256')
612
- .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
613
- .digest('hex'),
614
- createdAt: now,
615
- now,
616
- verifiedBy: status === 'verified' ? trustSource : null,
617
- verifiedAt: status === 'verified' ? now : null,
618
- });
619
- ids.push(id);
620
- }
621
- this.bumpIndexRev();
622
- });
623
- this.audit.append('import', { count: ids.length, source: options.source ?? 'bulk' }, { sync: true });
624
- return ids;
625
- }
626
-
627
- /**
628
- * Read one memory: the current revision plus, optionally, every revision and
629
- * its edges.
630
- * @param id - memory id.
631
- * @param options - `revision`, `includeHistory` (default true), `ord` (internal).
632
- * @returns `{entry, history, edges}`.
633
- */
634
- get(id, options = {}) {
635
- let row;
636
- if (Number.isSafeInteger(options.revision)) {
637
- row = this.db.prepare('SELECT * FROM memory WHERE id = ? AND revision = ?').get(id, options.revision);
638
- } else if (Number.isSafeInteger(options.ord)) {
639
- row = this.db.prepare('SELECT * FROM memory WHERE ord = ?').get(options.ord);
640
- } else {
641
- const head = this.#headRow(id);
642
- row = head?.row;
643
- }
644
- if (row === undefined) throw new MemoryNotFoundError(id);
645
- const includeHistory = options.includeHistory !== false;
646
- const history = includeHistory
647
- ? this.db
648
- .prepare('SELECT * FROM memory WHERE id = ? ORDER BY revision ASC')
649
- .all(id)
650
- .map((entry) => {
651
- const mapped = mapRow(entry);
652
- delete mapped.body;
653
- return mapped;
654
- })
655
- : [];
656
- const edges = this.db
657
- .prepare('SELECT relation, src_id, src_rev, dst_id, dst_rev, created_at FROM memory_edge WHERE src_id = ? OR dst_id = ?')
658
- .all(id, id)
659
- .map((edge) => ({ ...edge }));
660
- return { entry: mapRow(row), history, edges };
661
- }
662
-
663
- /**
664
- * Change only the trust state of the head revision.
665
- * @param id - memory id.
666
- * @param status - `verified` | `disputed` | `candidate`.
667
- * @param options - `actor`, `evidence`.
668
- */
669
- setTrust(id, status, options = {}) {
670
- this.#assertWritable();
671
- if (!['verified', 'disputed', 'candidate'].includes(status)) {
672
- throw new MemoryRejectedError(`unsupported trust status: ${status}`);
673
- }
674
- const head = this.#headRow(id);
675
- if (head === undefined) throw new MemoryNotFoundError(id);
676
- const now = this.now();
677
- const evidence = options.evidence === undefined ? head.row.evidence : JSON.stringify(options.evidence);
678
- withTransaction(this.db, () => {
679
- this.db
680
- .prepare(
681
- `UPDATE memory SET status = ?, evidence = ?, verified_at = ?, verified_by = ?, updated_at = ?
682
- WHERE id = ? AND revision = ?`,
683
- )
684
- .run(
685
- status,
686
- evidence,
687
- status === 'verified' ? now : null,
688
- status === 'verified' ? (options.actor ?? 'panel') : null,
689
- now,
690
- id,
691
- Number(head.row.revision),
692
- );
693
- this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run(status, id);
694
- this.bumpIndexRev();
695
- });
696
- this.audit.append('verify', { id, rev: Number(head.row.revision), status, actor: options.actor ?? 'panel' }, { sync: true });
697
- return this.get(id).entry;
698
- }
699
-
700
- /**
701
- * Pin or unpin the head revision.
702
- * @param id - memory id.
703
- * @param pinned - target state.
704
- * @param actor - who asked.
705
- */
706
- setPinned(id, pinned, actor = 'panel') {
707
- this.#assertWritable();
708
- const head = this.#headRow(id);
709
- if (head === undefined) throw new MemoryNotFoundError(id);
710
- withTransaction(this.db, () => {
711
- this.db
712
- .prepare('UPDATE memory SET pinned = ?, tier = ?, updated_at = ? WHERE id = ? AND revision = ?')
713
- .run(pinned ? 1 : 0, tierOf(head.row.kind, pinned === true), this.now(), id, Number(head.row.revision));
714
- this.bumpIndexRev();
715
- });
716
- this.audit.append('verify', { id, rev: Number(head.row.revision), pinned: pinned === true, actor }, { sync: true });
717
- return this.get(id).entry;
718
- }
719
-
720
- /**
721
- * Soft-delete, restore, or purge one memory.
722
- * @param id - memory id.
723
- * @param options - `mode` (`soft`|`restore`|`purge`), `reason`, `confirm`, `actor`.
724
- */
725
- forget(id, options = {}) {
726
- this.#assertWritable();
727
- const mode = options.mode ?? 'soft';
728
- const head = this.#headRow(id);
729
- if (head === undefined) throw new MemoryNotFoundError(id);
730
- const now = this.now();
731
-
732
- if (mode === 'purge') {
733
- if (this.live('allowHardDelete') !== true) {
734
- throw new MemoryRejectedError('hard delete is disabled; set allowHardDelete:true to permit it');
735
- }
736
- if (options.confirm !== id) throw new MemoryRejectedError(`purge requires confirm: "${id}"`);
737
- const ords = this.db.prepare('SELECT ord FROM memory WHERE id = ?').all(id).map((row) => Number(row.ord));
738
- withTransaction(this.db, () => {
739
- for (const ord of ords) {
740
- this.db.prepare('DELETE FROM memory_fts WHERE rowid = ?').run(ord);
741
- this.db.prepare('DELETE FROM memory_entity WHERE ord = ?').run(ord);
742
- this.db.prepare('DELETE FROM embedding WHERE ord = ?').run(ord);
743
- }
744
- this.db.prepare('DELETE FROM memory WHERE id = ?').run(id);
745
- this.db.prepare('DELETE FROM memory_head WHERE id = ?').run(id);
746
- this.db.prepare('DELETE FROM memory_edge WHERE src_id = ? OR dst_id = ?').run(id, id);
747
- this.#recomputeTokenStats();
748
- this.bumpIndexRev();
749
- });
750
- this.audit.append('delete', { id, mode, actor: options.actor ?? 'panel', reason: options.reason ?? null }, { sync: true });
751
- return { id, mode, deleted: true };
752
- }
753
-
754
- if (mode === 'restore') {
755
- withTransaction(this.db, () => {
756
- this.db
757
- .prepare('UPDATE memory SET status = ?, deleted_at = NULL, updated_at = ? WHERE id = ? AND revision = ?')
758
- .run('candidate', now, id, Number(head.row.revision));
759
- this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run('candidate', id);
760
- this.bumpIndexRev();
761
- });
762
- this.audit.append('delete', { id, mode, actor: options.actor ?? 'panel' }, { sync: true });
763
- return { id, mode, deleted: false };
764
- }
765
-
766
- withTransaction(this.db, () => {
767
- this.db
768
- .prepare('UPDATE memory SET status = ?, deleted_at = ? WHERE id = ? AND revision = ?')
769
- .run('deleted', now, id, Number(head.row.revision));
770
- this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run('deleted', id);
771
- this.bumpIndexRev();
772
- });
773
- this.audit.append('delete', { id, mode: 'soft', actor: options.actor ?? 'panel', reason: options.reason ?? null }, { sync: true });
774
- return { id, mode: 'soft', deleted: true };
775
- }
776
-
777
- /**
778
- * The entries the injected index may show, in stable order.
779
- * @param options - `scopeKeys`, `now`, `limit`.
780
- * @returns rows ordered by tier then creation time.
781
- */
782
- indexEntries(options) {
783
- const keys = options.scopeKeys ?? [];
784
- if (keys.length === 0) return [];
785
- const now = options.now ?? this.now();
786
- const limit = Math.max(1, Math.min(5000, options.limit ?? 500));
787
- const placeholders = keys.map(() => '?').join(', ');
788
- const rows = this.db
789
- .prepare(
790
- `SELECT m.id, m.revision, m.kind, m.scope, m.title, m.valid_from, m.updated_at, m.pinned, m.created_at, m.tier
791
- FROM memory m
792
- WHERE m.status = 'verified'
793
- AND m.deleted_at IS NULL
794
- AND (m.valid_to IS NULL OR m.valid_to > ?)
795
- AND m.scope_key IN (${placeholders})
796
- ORDER BY m.tier ASC, m.created_at ASC, m.ord ASC
797
- LIMIT ?`,
798
- )
799
- .all(now, ...keys, limit);
800
- return rows.map((row) => ({
801
- id: row.id,
802
- revision: Number(row.revision),
803
- kind: row.kind,
804
- scope: row.scope,
805
- title: row.title,
806
- validFrom: Number(row.valid_from),
807
- updatedAt: Number(row.updated_at),
808
- pinned: Number(row.pinned) === 1,
809
- }));
810
- }
811
-
812
- /**
813
- * How many entries the injected index would show without a limit. Used for
814
- * the honest "N shown of M" footer.
815
- * @param scopeKeys - read scope keys.
816
- * @param now - reference time.
817
- */
818
- countIndex(scopeKeys, now = this.now()) {
819
- if (scopeKeys.length === 0) return 0;
820
- const marks = scopeKeys.map(() => '?').join(', ');
821
- return Number(
822
- this.db
823
- .prepare(
824
- `SELECT COUNT(*) AS n FROM memory m
825
- WHERE m.status = 'verified' AND m.deleted_at IS NULL
826
- AND (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${marks})`,
827
- )
828
- .get(now, ...scopeKeys)?.n ?? 0,
829
- );
830
- }
831
-
832
- /**
833
- * Verified rules for the rules block, newest last.
834
- * @param options - `scopeKeys`, `now`, `limit`.
835
- */
836
- ruleEntries(options) {
837
- const keys = options.scopeKeys ?? [];
838
- if (keys.length === 0) return [];
839
- const now = options.now ?? this.now();
840
- const placeholders = keys.map(() => '?').join(', ');
841
- return this.db
842
- .prepare(
843
- `SELECT m.id, m.title, m.updated_at
844
- FROM memory m
845
- WHERE m.status = 'verified' AND m.tier = 0 AND m.kind = 'rule' AND m.deleted_at IS NULL
846
- AND (m.valid_to IS NULL OR m.valid_to > ?)
847
- AND m.scope_key IN (${placeholders})
848
- ORDER BY m.pinned DESC, m.created_at ASC
849
- LIMIT ?`,
850
- )
851
- .all(now, ...keys, options.limit ?? 40)
852
- .map((row) => ({ id: row.id, title: row.title, updatedAt: Number(row.updated_at) }));
853
- }
854
-
855
- /**
856
- * Every scope key the database knows. Used by the owner-only panel and the
857
- * Markdown export, which are administrative views rather than session reads.
858
- * @returns distinct scope keys.
859
- */
860
- allScopeKeys() {
861
- return this.db
862
- .prepare('SELECT DISTINCT m.scope_key AS key FROM memory m JOIN memory_head h ON h.ord = m.ord')
863
- .all()
864
- .map((row) => row.key);
865
- }
866
-
867
- /**
868
- * Every head id, newest first. Export iterates this rather than a page.
869
- * @returns memory ids.
870
- */
871
- /**
872
- * Forget everything in one scope, or the whole store.
873
- *
874
- * A privacy request ("delete what you remember about me") is not an exotic
875
- * operation, and telling the user to go delete a directory by hand is a bad
876
- * answer. The gate is the same shape as a single purge: the caller has to echo
877
- * the exact confirmation phrase, and hard deletion additionally needs
878
- * `allowHardDelete`.
879
- *
880
- * @param options - `scopeKey` (omit for every scope), `mode` (`soft`|`purge`),
881
- * `confirm`, `actor`, `reason`.
882
- * @returns `{mode, scopeKey, count}`.
883
- */
884
- forgetAll(options = {}) {
885
- this.#assertWritable();
886
- const mode = options.mode === 'purge' ? 'purge' : 'soft';
887
- const scopeKey = typeof options.scopeKey === 'string' && options.scopeKey.length > 0 ? options.scopeKey : undefined;
888
- const expected = scopeKey === undefined ? CONFIRM_ALL : `forget ${scopeKey}`;
889
- if (options.confirm !== expected) {
890
- throw new MemoryRejectedError(`forget-all requires confirm: "${expected}"`);
891
- }
892
- if (mode === 'purge' && this.live('allowHardDelete') !== true) {
893
- throw new MemoryRejectedError('hard delete is disabled; set allowHardDelete:true to permit it');
894
- }
895
- // `memory_head` carries no scope column; the scope lives on the revisions.
896
- const ids =
897
- scopeKey === undefined
898
- ? this.allHeadIds()
899
- : this.db
900
- .prepare('SELECT DISTINCT id FROM memory WHERE scope_key = ?')
901
- .all(scopeKey)
902
- .map((row) => row.id);
903
- for (const id of ids) {
904
- // One entry at a time keeps the per-entry audit line and the index bump,
905
- // at the cost of a slower loop — a bulk delete is rare and honesty wins.
906
- this.forget(id, { mode, confirm: mode === 'purge' ? id : undefined, actor: options.actor ?? 'panel', reason: options.reason });
907
- }
908
- this.audit.append(
909
- 'delete',
910
- { mode: `all:${mode}`, count: ids.length, scopeKey: scopeKey ?? null, actor: options.actor ?? 'panel' },
911
- { sync: true },
912
- );
913
- return { mode, scopeKey: scopeKey ?? null, count: ids.length };
914
- }
915
-
916
- allHeadIds() {
917
- return this.db
918
- .prepare('SELECT id FROM memory_head ORDER BY revision DESC, id ASC')
919
- .all()
920
- .map((row) => row.id);
921
- }
922
-
923
- /**
924
- * Page over head rows for the panel.
925
- * @param options - `scopeKeys`, `statuses`, `kinds`, `limit`, `offset`.
926
- * @returns `{items, total}`.
927
- */
928
- list(options = {}) {
929
- const keys = options.scopeKeys ?? [];
930
- if (keys.length === 0) return { items: [], total: 0 };
931
- const statuses = options.statuses ?? ['verified'];
932
- const kinds = Array.isArray(options.kinds) && options.kinds.length > 0 ? options.kinds : null;
933
- const keyMarks = keys.map(() => '?').join(', ');
934
- const statusMarks = statuses.map(() => '?').join(', ');
935
- const kindClause = kinds === null ? '' : ` AND m.kind IN (${kinds.map(() => '?').join(', ')})`;
936
- const params = [nowOrMax(options.now), ...keys, ...statuses, ...(kinds ?? [])];
937
- const total = Number(
938
- this.db
939
- .prepare(
940
- `SELECT COUNT(*) AS n FROM memory m
941
- WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
942
- AND m.status IN (${statusMarks})${kindClause}`,
943
- )
944
- .get(...params)?.n ?? 0,
945
- );
946
- const limit = Math.max(1, Math.min(200, options.limit ?? 50));
947
- const offset = Math.max(0, options.offset ?? 0);
948
- const items = this.db
949
- .prepare(
950
- `SELECT m.id, m.revision, m.kind, m.scope, m.scope_label, m.title, m.status, m.pinned, m.tags,
951
- m.valid_from, m.valid_to, m.updated_at, m.created_at,
952
- substr(m.body, 1, 240) AS snippet
953
- FROM memory m
954
- WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
955
- AND m.status IN (${statusMarks})${kindClause}
956
- ORDER BY m.pinned DESC, m.updated_at DESC, m.ord DESC
957
- LIMIT ? OFFSET ?`,
958
- )
959
- .all(...params, limit, offset)
960
- .map((row) => ({
961
- id: row.id,
962
- revision: Number(row.revision),
963
- kind: row.kind,
964
- scope: row.scope,
965
- scopeLabel: row.scope_label,
966
- title: row.title,
967
- status: row.status,
968
- pinned: Number(row.pinned) === 1,
969
- tags: JSON.parse(row.tags ?? '[]'),
970
- validFrom: Number(row.valid_from),
971
- validTo: row.valid_to === null ? null : Number(row.valid_to),
972
- updatedAt: Number(row.updated_at),
973
- createdAt: Number(row.created_at),
974
- snippet: row.snippet ?? '',
975
- }));
976
- return { items, total };
977
- }
978
-
979
- /**
980
- * Aggregate counters for `loci_stats`, the panel, and doctor.
981
- * @param options - `scopeKeys` (omit for the whole database).
982
- */
983
- stats(options = {}) {
984
- const keys = options.scopeKeys ?? [];
985
- const filtered = keys.length > 0;
986
- const marks = filtered ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
987
- const byStatus = {};
988
- for (const row of this.db
989
- .prepare(
990
- `SELECT h.status AS status, COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE 1=1${marks} GROUP BY h.status`,
991
- )
992
- .all(...keys)) {
993
- byStatus[row.status] = Number(row.n);
994
- }
995
- const byKind = {};
996
- for (const row of this.db
997
- .prepare(
998
- `SELECT m.kind AS kind, COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE 1=1${marks} GROUP BY m.kind`,
999
- )
1000
- .all(...keys)) {
1001
- byKind[row.kind] = Number(row.n);
1002
- }
1003
- const byScope = {};
1004
- for (const row of this.db
1005
- .prepare(
1006
- `SELECT m.scope AS scope, COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE 1=1${marks} GROUP BY m.scope`,
1007
- )
1008
- .all(...keys)) {
1009
- byScope[row.scope] = Number(row.n);
1010
- }
1011
- const pending = Number(
1012
- this.db
1013
- .prepare(
1014
- `SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord
1015
- WHERE h.status = 'candidate' AND m.deleted_at IS NULL${marks}`,
1016
- )
1017
- .get(...keys)?.n ?? 0,
1018
- );
1019
- const disputed = Number(
1020
- this.db
1021
- .prepare(`SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE h.status = 'disputed'${marks}`)
1022
- .get(...keys)?.n ?? 0,
1023
- );
1024
- const lastInjection = this.db
1025
- .prepare('SELECT ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden FROM request_stat ORDER BY id DESC LIMIT 1')
1026
- .get();
1027
- const revisions = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory').get()?.n ?? 0);
1028
- return {
1029
- schemaVersion: this.schemaVersion(),
1030
- indexRev: this.indexRev(),
1031
- revisions,
1032
- heads: Object.values(byStatus).reduce((total, n) => total + n, 0),
1033
- byStatus,
1034
- byKind,
1035
- byScope,
1036
- pendingCandidates: pending,
1037
- disputed,
1038
- lastInjection:
1039
- lastInjection === undefined
1040
- ? null
1041
- : {
1042
- ts: Number(lastInjection.ts),
1043
- sessionId: lastInjection.session_id,
1044
- scopeKey: lastInjection.scope_key,
1045
- bytes: Number(lastInjection.section_bytes),
1046
- listed: Number(lastInjection.entries_listed),
1047
- hidden: Number(lastInjection.entries_hidden),
1048
- },
1049
- dbBytes: existsSync(this.paths.db) ? statSync(this.paths.db).size : 0,
1050
- vector: { ...this.vector, ...this.embedding.health() },
1051
- memoryRoot: this.memoryRoot,
1052
- };
1053
- }
1054
-
1055
- /**
1056
- * Record one injected section, so the cost of each request is queryable.
1057
- * @param row - `sessionId`, `scopeKey`, `bytes`, `listed`, `hidden`.
1058
- */
1059
- recordInjection(row) {
1060
- this.db
1061
- .prepare(
1062
- 'INSERT INTO request_stat (ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden) VALUES (?, ?, ?, ?, ?, ?)',
1063
- )
1064
- .run(this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden);
1065
- this.db.prepare('DELETE FROM request_stat WHERE ts < ?').run(this.now() - 90 * 86_400_000);
1066
- this.audit.append('inject', {
1067
- sessionId: row.sessionId ?? null,
1068
- scopeKey: row.scopeKey ?? null,
1069
- bytes: row.bytes,
1070
- listed: row.listed,
1071
- hidden: row.hidden,
1072
- });
1073
- }
1074
-
1075
- /**
1076
- * Record one recall.
1077
- * @param row - `query` length, counts, timing, channels.
1078
- */
1079
- recordRecall(row) {
1080
- this.audit.append('recall', {
1081
- sessionId: row.sessionId ?? null,
1082
- scopeKey: row.scopeKey ?? null,
1083
- queryChars: typeof row.query === 'string' ? row.query.length : 0,
1084
- total: row.total,
1085
- returned: row.returned,
1086
- tookMs: row.tookMs,
1087
- channels: row.channels,
1088
- });
1089
- }
1090
-
1091
- /** The audit ring, for the panel and stats. */
1092
- auditTail(limit = 100) {
1093
- return this.audit.tail(limit);
1094
- }
1095
-
1096
- /**
1097
- * The most recent injected sections, newest first.
1098
- * @param limit - how many rows.
1099
- * @returns `[{ts, sessionId, bytes, listed, hidden}]`.
1100
- */
1101
- recentInjections(limit = 50) {
1102
- return this.db
1103
- .prepare(
1104
- 'SELECT ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden FROM request_stat ORDER BY id DESC LIMIT ?',
1105
- )
1106
- .all(Math.max(1, Math.min(500, limit)))
1107
- .map((row) => ({
1108
- ts: Number(row.ts),
1109
- sessionId: row.session_id,
1110
- scopeKey: row.scope_key,
1111
- sectionBytes: Number(row.section_bytes),
1112
- listed: Number(row.entries_listed),
1113
- hidden: Number(row.entries_hidden),
1114
- }));
1115
- }
1116
-
1117
- /**
1118
- * A predicate that marks tokens too common to count as evidence.
1119
- *
1120
- * A term present in more than 15% of the scope's memories is a hub word
1121
- * ("project", "插件"); letting it drive a query recalls everything and ranks
1122
- * nothing. The token counts are maintained on write, so this stays a couple
1123
- * of indexed lookups per query.
1124
- * @returns `(token) => boolean`.
1125
- */
1126
- hubPredicate() {
1127
- const total = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory_head').get()?.n ?? 0);
1128
- if (total < 20) return () => false;
1129
- const statement = this.db.prepare('SELECT df FROM token_stat WHERE token = ?');
1130
- return (token) => Number(statement.get(token)?.df ?? 0) / total > 0.15;
1131
- }
1132
-
1133
- /**
1134
- * Rebuild the FTS index and token statistics from `memory`.
1135
- * Used after an interrupted write, by doctor, and by the reindex command.
1136
- * @returns `{rows, tokens}`.
1137
- */
1138
- reindex() {
1139
- this.#assertWritable();
1140
- return withTransaction(this.db, () => {
1141
- this.db.exec('DELETE FROM memory_fts');
1142
- this.db.exec('DELETE FROM token_stat');
1143
- const rows = this.db.prepare('SELECT ord, title, body, tags FROM memory').all();
1144
- const insert = this.db.prepare('INSERT INTO memory_fts (rowid, tokens) VALUES (?, ?)');
1145
- const counts = new Map();
1146
- for (const row of rows) {
1147
- const tags = JSON.parse(row.tags ?? '[]').join(' ');
1148
- const stream = tokenStream(`${row.title} ${row.body} ${tags}`);
1149
- insert.run(Number(row.ord), stream);
1150
- for (const token of new Set(tokenize(`${row.title} ${row.body} ${tags}`))) {
1151
- counts.set(token, (counts.get(token) ?? 0) + 1);
1152
- }
1153
- }
1154
- const stat = this.db.prepare('INSERT INTO token_stat (token, df) VALUES (?, ?) ON CONFLICT(token) DO UPDATE SET df = excluded.df');
1155
- for (const [token, df] of counts) stat.run(token, df);
1156
- return { rows: rows.length, tokens: counts.size };
1157
- });
1158
- }
1159
-
1160
- /**
1161
- * Fill missing embeddings for verified entries. No-op when the channel is
1162
- * off or unavailable; a failure leaves the entry lexically searchable.
1163
- * @param limit - most entries to embed in one pass.
1164
- * @returns `{added, skipped}`.
1165
- */
1166
- async backfillEmbeddings(limit = 200) {
1167
- if (this.embedding === undefined || !this.embedding.available()) return { added: 0, skipped: true };
1168
- const rows = this.db
1169
- .prepare(
1170
- `SELECT m.ord, m.title, m.body FROM memory m
1171
- JOIN memory_head h ON h.ord = m.ord
1172
- LEFT JOIN embedding e ON e.ord = m.ord
1173
- WHERE e.ord IS NULL AND h.status = 'verified' AND m.deleted_at IS NULL
1174
- LIMIT ?`,
1175
- )
1176
- .all(limit);
1177
- if (rows.length === 0) return { added: 0, skipped: false };
1178
- let added = 0;
1179
- const complete = await this.embedding.embedAll(
1180
- rows.map((row) => `${row.title}\n${row.body}`),
1181
- (start, vectors) => {
1182
- withTransaction(this.db, () => {
1183
- const insert = this.db.prepare(
1184
- 'INSERT OR REPLACE INTO embedding (ord, model, dim, vec, created_at) VALUES (?, ?, ?, ?, ?)',
1185
- );
1186
- vectors.forEach((vector, index) => {
1187
- insert.run(
1188
- Number(rows[start + index].ord),
1189
- this.embedding.model,
1190
- vector.length,
1191
- vectorToBlob(vector),
1192
- this.now(),
1193
- );
1194
- });
1195
- });
1196
- added += vectors.length;
1197
- },
1198
- );
1199
- return { added, skipped: !complete };
1200
- }
1201
-
1202
- /**
1203
- * Whether the FTS index covers every stored revision.
1204
- * @returns `{memory, fts, missing}`.
1205
- */
1206
- indexCoverage() {
1207
- const memory = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory').get()?.n ?? 0);
1208
- const fts = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory_fts').get()?.n ?? 0);
1209
- return { memory, fts, missing: Math.max(0, memory - fts) };
1210
- }
1211
-
1212
- /**
1213
- * One maintenance pass: expire working memory, close stale candidates,
1214
- * rebuild a drifted index, and refresh query-planner statistics.
1215
- * @param options - `now`.
1216
- * @returns a summary of what changed.
1217
- */
1218
- maintenance(options = {}) {
1219
- this.#assertWritable();
1220
- const now = options.now ?? this.now();
1221
- const summary = { expiredWorking: 0, closedCandidates: 0, reindexed: false, optimized: false };
1222
- const workingTtlDays = Number(this.live('workingTtlDays') ?? 7);
1223
- if (workingTtlDays > 0) {
1224
- const cutoff = now - workingTtlDays * 86_400_000;
1225
- const result = this.db
1226
- .prepare(
1227
- `UPDATE memory SET valid_to = ?, updated_at = ?
1228
- WHERE kind = 'working' AND valid_to IS NULL AND created_at < ? AND status <> 'deleted'`,
1229
- )
1230
- .run(now, now, cutoff);
1231
- summary.expiredWorking = Number(result.changes ?? 0);
1232
- }
1233
- const candidateTtlDays = Number(this.live('candidateTtlDays') ?? 30);
1234
- if (candidateTtlDays > 0) {
1235
- const cutoff = now - candidateTtlDays * 86_400_000;
1236
- const result = this.db
1237
- .prepare(
1238
- `UPDATE memory SET valid_to = ?, updated_at = ?
1239
- WHERE status = 'candidate' AND valid_to IS NULL AND created_at < ?`,
1240
- )
1241
- .run(now, now, cutoff);
1242
- summary.closedCandidates = Number(result.changes ?? 0);
1243
- }
1244
- const coverage = this.indexCoverage();
1245
- if (coverage.missing > 0) {
1246
- this.reindex();
1247
- summary.reindexed = true;
1248
- this.bumpIndexRev();
1249
- }
1250
- try {
1251
- this.db.exec('PRAGMA optimize');
1252
- summary.optimized = true;
1253
- } catch {
1254
- summary.optimized = false;
1255
- }
1256
- writeMeta(this.db, META_KEYS.lastMaintenance, String(now));
1257
- // A sweep that changed nothing is not news. Keeping it out of the audit is
1258
- // what stops the activity feed from being a wall of "nothing to do" and
1259
- // hiding the writes a user actually wants to see.
1260
- const changed = summary.expiredWorking > 0 || summary.closedCandidates > 0 || summary.reindexed;
1261
- if (changed) this.audit.append('maintenance', summary, { sync: true });
1262
- return summary;
1263
- }
1264
-
1265
- /**
1266
- * Take a consistent snapshot next to the database.
1267
- * @param label - file label (`manual`, `pre-import`).
1268
- * @returns the snapshot path.
1269
- */
1270
- snapshot(label = 'manual') {
1271
- this.#assertWritable();
1272
- return backupTo(this.db, join(this.paths.backups, `${label}-${this.now()}.db`));
1273
- }
1274
-
1275
- /** Health checks used by `doctor`. */
1276
- health() {
1277
- return {
1278
- quickCheck: quickCheck(this.db),
1279
- integrity: integrityCheck(this.db),
1280
- coverage: this.indexCoverage(),
1281
- counts: tableCounts(this.db),
1282
- schemaVersion: this.schemaVersion(),
1283
- expectedSchemaVersion: SCHEMA_VERSION,
1284
- migration: this.migration,
1285
- dbBytes: existsSync(this.paths.db) ? statSync(this.paths.db).size : 0,
1286
- };
1287
- }
1288
-
1289
- // ---------------------------------------------------------------- internals
1290
-
1291
- #headRow(id) {
1292
- const row = this.db
1293
- .prepare(
1294
- `SELECT m.* FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE m.id = ? LIMIT 1`,
1295
- )
1296
- .get(id);
1297
- if (row === undefined) return undefined;
1298
- return { row };
1299
- }
1300
-
1301
- #findByHash(scopeKey, contentHash) {
1302
- return this.db
1303
- .prepare(
1304
- `SELECT m.id, m.revision FROM memory m JOIN memory_head h ON h.ord = m.ord
1305
- WHERE m.scope_key = ? AND m.content_hash = ? AND m.deleted_at IS NULL
1306
- ORDER BY m.revision DESC LIMIT 1`,
1307
- )
1308
- .get(scopeKey, contentHash);
1309
- }
1310
-
1311
- #findByTitle(scopeKey, titleNorm) {
1312
- return this.db
1313
- .prepare(
1314
- `SELECT m.id, m.revision FROM memory m JOIN memory_head h ON h.ord = m.ord
1315
- WHERE m.scope_key = ? AND m.title_norm = ? AND m.deleted_at IS NULL
1316
- ORDER BY m.revision DESC LIMIT 1`,
1317
- )
1318
- .get(scopeKey, titleNorm);
1319
- }
1320
-
1321
- #findSimilar(scopeKey, prepared) {
1322
- // A duplicate is signalled by a *rare* shared term. Anchoring the check on
1323
- // the rarest title token keeps the cost proportional to that term's posting
1324
- // list instead of the whole scope, and a title made only of hub words
1325
- // ("发布流程约定") cannot be a meaningful duplicate signal anyway.
1326
- const tokens = tokenize(prepared.title).filter((token) => token.length >= 2);
1327
- if (tokens.length === 0) return [];
1328
- const total = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory_head').get()?.n ?? 0);
1329
- if (total < 2) return [];
1330
- const dfStatement = this.db.prepare('SELECT df FROM token_stat WHERE token = ?');
1331
- const ranked = tokens
1332
- .map((token) => ({ token, df: Number(dfStatement.get(token)?.df ?? 0) }))
1333
- .sort((a, b) => a.df - b.df);
1334
- const anchor = ranked[0];
1335
- if (anchor.df === 0 || anchor.df / total > 0.15) return [];
1336
- const candidates = this.db
1337
- .prepare(
1338
- `SELECT m.id, m.revision, m.title, m.body
1339
- FROM memory_fts
1340
- JOIN memory m ON m.ord = memory_fts.rowid
1341
- JOIN memory_head h ON h.ord = m.ord
1342
- WHERE memory_fts MATCH ? AND m.scope_key = ? AND h.status <> 'deleted'
1343
- LIMIT 16`,
1344
- )
1345
- .all(`"${anchor.token}"`, scopeKey);
1346
- const threshold = Number(this.live('dedupeSimilarity') ?? 0.8);
1347
- const hits = [];
1348
- for (const row of candidates) {
1349
- const score = jaccard(`${prepared.title}\n${prepared.body}`, `${row.title}\n${row.body}`);
1350
- if (score >= threshold) {
1351
- hits.push({ id: row.id, revision: Number(row.revision), title: row.title, similarity: Number(score.toFixed(3)) });
1352
- }
1353
- }
1354
- return hits.slice(0, 5);
1355
- }
1356
-
1357
- #resolveStatus({ input, prepared, evidence, trustSource }) {
1358
- // working memory is scratch for this session; it is never injected, so it
1359
- // never needs verifying.
1360
- if (input.kind === 'working') return 'candidate';
1361
- if (['candidate', 'verified', 'disputed'].includes(input.status)) return input.status;
1362
- if (prepared.instructionLike) return 'disputed';
1363
- // An import or a session summary is not testimony: neither is something the
1364
- // user just said, so neither can be verified by a quote.
1365
- if (trustSource === 'import' || trustSource === 'summary') return 'candidate';
1366
- // A rule the capture layer lifted out of the user's own message **is**
1367
- // testimony — it carries the sentence verbatim. It goes through the same
1368
- // quote gate as a model-supplied quote instead of being parked in the review
1369
- // queue forever, which is what "everything needs a click" used to mean.
1370
- if (trustSource === 'rule' && this.live('autoVerifyCaptured') === false) return 'candidate';
1371
- if (trustSource === 'user') return 'verified';
1372
- return this.#quoteVerifies(evidence, input) ? 'verified' : 'candidate';
1373
- }
1374
-
1375
- /**
1376
- * Whether this exact text was ever verified for this entry.
1377
- *
1378
- * Comparison is against every revision, not just the head: reverting an edit
1379
- * back to previously verified wording must restore trust, because the text is
1380
- * literally the text a human confirmed. Anything stricter would make a
1381
- * correction one-way, which is how users learn to stop correcting.
1382
- * @param id - memory id.
1383
- * @param contentHash - hash of the candidate revision's title and body.
1384
- * @returns true when some revision with that hash carries a verification stamp.
1385
- */
1386
- #textWasVerified(id, contentHash) {
1387
- const row = this.db
1388
- .prepare(
1389
- `SELECT 1 AS hit FROM memory
1390
- WHERE id = ? AND content_hash = ? AND verified_at IS NOT NULL
1391
- LIMIT 1`,
1392
- )
1393
- .get(id, contentHash);
1394
- return row !== undefined;
1395
- }
1396
-
1397
- /**
1398
- * Whether the evidence carried by a write is a quote this session can confirm.
1399
- * The single place verification is decided — the create path, the update path
1400
- * and the tool surface all call it, so they cannot drift apart.
1401
- * @param evidence - `{quote, sessionId, eventSeq}`.
1402
- * @param input - the write input (for `sourceSession`/`sourceEvent` fallbacks).
1403
- * @returns true when the quote is long enough and the verifier accepts it.
1404
- */
1405
- #quoteVerifies(evidence, input) {
1406
- const quote = typeof evidence?.quote === 'string' ? evidence.quote.trim() : '';
1407
- if (quote.length < 8) return false;
1408
- let verified = false;
1409
- try {
1410
- verified =
1411
- this.quoteVerifier(quote, {
1412
- sessionId: evidence.sessionId ?? input?.sourceSession ?? undefined,
1413
- eventSeq: Number.isSafeInteger(evidence.eventSeq) ? evidence.eventSeq : input?.sourceEvent,
1414
- }) === true;
1415
- } catch {
1416
- verified = false;
1417
- }
1418
- this.quoteChecked += 1;
1419
- return verified;
1420
- }
1421
-
1422
- #insertRevision(row) {
1423
- const result = this.db
1424
- .prepare(
1425
- `INSERT INTO memory (
1426
- id, revision, kind, scope, scope_key, scope_label, title, title_norm, body, tags, entities,
1427
- status, trust_source, flags, valid_from, valid_to, recorded_at, supersedes, superseded_by,
1428
- evidence, source_session, source_event, verified_at, verified_by, pinned, importance, tier,
1429
- content_hash, created_at, updated_at, deleted_at
1430
- ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)`,
1431
- )
1432
- .run(
1433
- row.id,
1434
- row.revision,
1435
- row.kind,
1436
- row.scope,
1437
- row.scopeKey,
1438
- row.scopeLabel,
1439
- row.title,
1440
- row.titleNorm,
1441
- row.body,
1442
- JSON.stringify(row.tags ?? []),
1443
- JSON.stringify(row.entities ?? []),
1444
- row.status,
1445
- row.trustSource,
1446
- JSON.stringify(row.flags ?? []),
1447
- row.validFrom,
1448
- row.validTo,
1449
- row.now,
1450
- row.supersedes,
1451
- null,
1452
- JSON.stringify(row.evidence ?? {}),
1453
- row.sourceSession,
1454
- row.sourceEvent,
1455
- row.verifiedAt,
1456
- row.verifiedBy,
1457
- row.pinned,
1458
- row.importance,
1459
- tierOf(row.kind, row.pinned),
1460
- row.contentHash,
1461
- row.createdAt,
1462
- row.now,
1463
- );
1464
- const ord = Number(result.lastInsertRowid);
1465
- const text = `${row.title} ${row.body} ${(row.tags ?? []).join(' ')}`;
1466
- this.db.prepare('INSERT INTO memory_fts (rowid, tokens) VALUES (?, ?)').run(ord, tokenStream(text));
1467
- const tokens = new Set(tokenize(text));
1468
- const stat = this.db.prepare('INSERT INTO token_stat (token, df) VALUES (?, 1) ON CONFLICT(token) DO UPDATE SET df = df + 1');
1469
- for (const token of tokens) stat.run(token);
1470
- this.#linkEntities(ord, row.entities ?? [], row.scopeKey, row.now);
1471
- this.db
1472
- .prepare(
1473
- 'INSERT INTO memory_head (id, ord, revision, status) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET ord = excluded.ord, revision = excluded.revision, status = excluded.status',
1474
- )
1475
- .run(row.id, ord, row.revision, row.status);
1476
- return ord;
1477
- }
1478
-
1479
- #linkEntities(ord, entities, scopeKey, now) {
1480
- if (!Array.isArray(entities) || entities.length === 0) return;
1481
- const upsert = this.db.prepare(
1482
- `INSERT INTO entity (id, scope_key, norm_name, name, kind, aliases, created_at) VALUES (?, ?, ?, ?, ?, '[]', ?)
1483
- ON CONFLICT(scope_key, norm_name) DO UPDATE SET name = excluded.name, kind = excluded.kind`,
1484
- );
1485
- const find = this.db.prepare('SELECT id FROM entity WHERE scope_key = ? AND norm_name = ?');
1486
- const link = this.db.prepare('INSERT OR IGNORE INTO memory_entity (ord, entity_id, role) VALUES (?, ?, ?)');
1487
- const bump = this.db.prepare('UPDATE entity SET link_count = link_count + 1 WHERE id = ?');
1488
- for (const entity of entities) {
1489
- const norm = normalizeText(entity.name).replace(/\s+/g, '');
1490
- if (norm.length === 0) continue;
1491
- upsert.run(newEntityId(), scopeKey, norm, entity.name, entity.kind ?? 'thing', now);
1492
- const found = find.get(scopeKey, norm);
1493
- if (found === undefined) continue;
1494
- const linked = link.run(ord, found.id, 'mention');
1495
- if (Number(linked.changes ?? 0) > 0) bump.run(found.id);
1496
- }
1497
- }
1498
-
1499
- #recomputeTokenStats() {
1500
- this.db.exec('DELETE FROM token_stat');
1501
- const counts = new Map();
1502
- const rows = this.db.prepare('SELECT title, body, tags FROM memory').all();
1503
- for (const row of rows) {
1504
- const text = `${row.title} ${row.body} ${JSON.parse(row.tags ?? '[]').join(' ')}`;
1505
- for (const token of new Set(tokenize(text))) counts.set(token, (counts.get(token) ?? 0) + 1);
1506
- }
1507
- const stat = this.db.prepare('INSERT INTO token_stat (token, df) VALUES (?, ?)');
1508
- for (const [token, df] of counts) stat.run(token, df);
1509
- }
1510
- }
1511
-
1512
- /** `now` or the largest safe timestamp, for filters that must not exclude open rows. */
1513
- function nowOrMax(value) {
1514
- return Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
1515
- }
1516
-
1517
- /**
1518
- * Whether a verbatim quote is about the same claim as this text.
1519
- *
1520
- * Verification proves the user said the quote; it does not prove the quote
1521
- * supports a *different* sentence. Reusing an old quote while replacing the
1522
- * claim is the cheapest way to launder an edit, so an update keeps `verified`
1523
- * only when the quote and the new text still overlap.
1524
- *
1525
- * @param quote - evidence quote.
1526
- * @param text - new title and body.
1527
- * @returns true when the two share at least 15% of their character bigrams.
1528
- */
1529
- function evidenceSupports(quote, text) {
1530
- if (typeof quote !== 'string' || quote.trim().length < 8) return false;
1531
- return jaccard(quote, text) >= 0.15;
1532
- }
1533
- /** Jaccard over whitespace-collapsed character bigrams (duplicate heuristic). */
1534
- function jaccard(a, b) {
1535
- const grams = (text) => {
1536
- const value = normalizeText(text).replace(/\s+/g, '');
1537
- const set = new Set();
1538
- if (value.length === 1) set.add(value);
1539
- for (let i = 0; i + 1 < value.length; i += 1) set.add(value.slice(i, i + 2));
1540
- return set;
1541
- };
1542
- const left = grams(a);
1543
- const right = grams(b);
1544
- if (left.size === 0 || right.size === 0) return 0;
1545
- let shared = 0;
1546
- for (const gram of left) if (right.has(gram)) shared += 1;
1547
- return shared / (left.size + right.size - shared);
1548
- }
491
+ const unionTags = (rows) => [...new Set(rows.flatMap((row) => asArray(row.tags)))];
492
+ const unionEntities = (rows) => [...new Set(rows.flatMap((row) => asArray(row.entities)))];
493
+ const rows = [target.row, ...sources.map((source) => source.head.row)];
494
+ const tags = unionTags(rows);
495
+ const entities = unionEntities(rows);
496
+
497
+ let entry;
498
+ if (typeof input.title === 'string' || typeof input.body === 'string') {
499
+ // A content change goes through update(), so it lands as a candidate unless
500
+ // the text is a previously verified revision.
501
+ const updated = this.update(targetId, {
502
+ kind: input.kind ?? target.row.kind,
503
+ scope: input.scope ?? target.row.scope,
504
+ scopeKey: input.scopeKey ?? target.row.scope_key,
505
+ scopeLabel: input.scopeLabel ?? target.row.scope_label,
506
+ title: typeof input.title === 'string' ? input.title : target.row.title,
507
+ body: typeof input.body === 'string' ? input.body : target.row.body,
508
+ tags,
509
+ entities,
510
+ trustSource: 'tool',
511
+ expectedRevision: Number(target.row.revision),
512
+ });
513
+ entry = updated.entry;
514
+ } else {
515
+ this.db
516
+ .prepare('UPDATE memory SET tags = ?, entities = ?, updated_at = ? WHERE id = ? AND revision = ?')
517
+ .run(JSON.stringify(tags), JSON.stringify(entities), now, targetId, Number(target.row.revision));
518
+ entry = this.get(targetId).entry;
519
+ }
520
+
521
+ const mergedAt = `${targetId}@${entry.revision}`;
522
+ withTransaction(this.db, () => {
523
+ for (const source of sources) {
524
+ this.db
525
+ .prepare(
526
+ `UPDATE memory SET status = 'superseded', valid_to = ?, superseded_by = ?, updated_at = ?
527
+ WHERE id = ? AND revision = ?`,
528
+ )
529
+ .run(now, mergedAt, now, source.id, Number(source.head.row.revision));
530
+ this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run('superseded', source.id);
531
+ }
532
+ this.#recomputeTokenStats();
533
+ this.bumpIndexRev();
534
+ });
535
+
536
+ this.audit.append(
537
+ 'merge',
538
+ {
539
+ targetId,
540
+ sources: sourceIds,
541
+ revision: entry.revision,
542
+ bytes: Buffer.byteLength(entry.title) + Buffer.byteLength(entry.body),
543
+ actor: input.actor ?? 'tool',
544
+ reason: input.reason ?? null,
545
+ },
546
+ { sync: true },
547
+ );
548
+ return { entry, merged: sourceIds, revision: entry.revision };
549
+ }
550
+
551
+ /**
552
+ * Insert many entries in one transaction, skipping the per-write duplicate
553
+ * search. This is the import and benchmark path: the caller owns dedupe
554
+ * (imports are all candidates, and a benchmark seeds synthetic rows).
555
+ *
556
+ * Why it exists: a bulk import of a thousand files otherwise pays a
557
+ * similarity query and a commit per row; at 10k rows the real write path
558
+ * costs about 22 ms per row, which turns a routine import into a minute-long
559
+ * tool call.
560
+ *
561
+ * @param entries - `{kind, scope, scopeKey, scopeLabel, title, body, tags,
562
+ * entities, status, trustSource, evidence, sourceSession, sourceEvent}`.
563
+ * @param options - `source` for the audit line.
564
+ * @returns the new ids, in order.
565
+ */
566
+ writeMany(entries, options = {}) {
567
+ this.#assertWritable();
568
+ if (!Array.isArray(entries) || entries.length === 0) return [];
569
+ const now = this.now();
570
+ const ids = [];
571
+ withTransaction(this.db, () => {
572
+ for (const input of entries) {
573
+ const prepared = prepareForStorage(input, { secretPolicy: this.live('secretPolicy') });
574
+ if (prepared.rejected) {
575
+ throw new MemoryRejectedError(
576
+ `memory contains a high-confidence secret (${prepared.redactions.join(', ')}) and secretPolicy is "reject"`,
577
+ );
578
+ }
579
+ if (prepared.title.length === 0 || prepared.body.length === 0) {
580
+ throw new MemoryRejectedError('memory title and body must not be empty');
581
+ }
582
+ const id = input.id ?? newMemoryId(now);
583
+ const kind = ['working', 'episodic', 'semantic', 'procedural', 'rule'].includes(input.kind)
584
+ ? input.kind
585
+ : 'semantic';
586
+ const status = ['candidate', 'verified', 'disputed'].includes(input.status) ? input.status : 'candidate';
587
+ const trustSource = input.trustSource ?? 'import';
588
+ this.#insertRevision({
589
+ id,
590
+ revision: 1,
591
+ kind,
592
+ scope: input.scope ?? 'workspace',
593
+ scopeKey: input.scopeKey,
594
+ scopeLabel: input.scopeLabel ?? '',
595
+ title: prepared.title,
596
+ titleNorm: normalizeTitle(prepared.title),
597
+ body: prepared.body,
598
+ tags: prepared.tags,
599
+ entities: prepared.entities,
600
+ status,
601
+ trustSource,
602
+ flags: prepared.flags,
603
+ validFrom: Number.isFinite(input.validFrom) ? input.validFrom : now,
604
+ validTo: Number.isFinite(input.validTo) ? input.validTo : null,
605
+ supersedes: null,
606
+ evidence: prepared.evidence ?? {},
607
+ sourceSession: input.sourceSession ?? null,
608
+ sourceEvent: Number.isSafeInteger(input.sourceEvent) ? input.sourceEvent : null,
609
+ pinned: input.pinned === true ? 1 : 0,
610
+ importance: Number.isInteger(input.importance) ? input.importance : 3,
611
+ contentHash: createHash('sha256')
612
+ .update(`${normalizeTitle(prepared.title)}\n${normalizeText(prepared.body)}`, 'utf8')
613
+ .digest('hex'),
614
+ createdAt: now,
615
+ now,
616
+ verifiedBy: status === 'verified' ? trustSource : null,
617
+ verifiedAt: status === 'verified' ? now : null,
618
+ });
619
+ ids.push(id);
620
+ }
621
+ this.bumpIndexRev();
622
+ });
623
+ this.audit.append('import', { count: ids.length, source: options.source ?? 'bulk' }, { sync: true });
624
+ return ids;
625
+ }
626
+
627
+ /**
628
+ * Read one memory: the current revision plus, optionally, every revision and
629
+ * its edges.
630
+ * @param id - memory id.
631
+ * @param options - `revision`, `includeHistory` (default true), `ord` (internal).
632
+ * @returns `{entry, history, edges}`.
633
+ */
634
+ get(id, options = {}) {
635
+ let row;
636
+ if (Number.isSafeInteger(options.revision)) {
637
+ row = this.db.prepare('SELECT * FROM memory WHERE id = ? AND revision = ?').get(id, options.revision);
638
+ } else if (Number.isSafeInteger(options.ord)) {
639
+ row = this.db.prepare('SELECT * FROM memory WHERE ord = ?').get(options.ord);
640
+ } else {
641
+ const head = this.#headRow(id);
642
+ row = head?.row;
643
+ }
644
+ if (row === undefined) throw new MemoryNotFoundError(id);
645
+ const includeHistory = options.includeHistory !== false;
646
+ const history = includeHistory
647
+ ? this.db
648
+ .prepare('SELECT * FROM memory WHERE id = ? ORDER BY revision ASC')
649
+ .all(id)
650
+ .map((entry) => {
651
+ const mapped = mapRow(entry);
652
+ delete mapped.body;
653
+ return mapped;
654
+ })
655
+ : [];
656
+ const edges = this.db
657
+ .prepare('SELECT relation, src_id, src_rev, dst_id, dst_rev, created_at FROM memory_edge WHERE src_id = ? OR dst_id = ?')
658
+ .all(id, id)
659
+ .map((edge) => ({ ...edge }));
660
+ return { entry: mapRow(row), history, edges };
661
+ }
662
+
663
+ /**
664
+ * Change only the trust state of the head revision.
665
+ * @param id - memory id.
666
+ * @param status - `verified` | `disputed` | `candidate`.
667
+ * @param options - `actor`, `evidence`.
668
+ */
669
+ setTrust(id, status, options = {}) {
670
+ this.#assertWritable();
671
+ if (!['verified', 'disputed', 'candidate'].includes(status)) {
672
+ throw new MemoryRejectedError(`unsupported trust status: ${status}`);
673
+ }
674
+ const head = this.#headRow(id);
675
+ if (head === undefined) throw new MemoryNotFoundError(id);
676
+ const now = this.now();
677
+ const evidence = options.evidence === undefined ? head.row.evidence : JSON.stringify(options.evidence);
678
+ withTransaction(this.db, () => {
679
+ this.db
680
+ .prepare(
681
+ `UPDATE memory SET status = ?, evidence = ?, verified_at = ?, verified_by = ?, updated_at = ?
682
+ WHERE id = ? AND revision = ?`,
683
+ )
684
+ .run(
685
+ status,
686
+ evidence,
687
+ status === 'verified' ? now : null,
688
+ status === 'verified' ? (options.actor ?? 'panel') : null,
689
+ now,
690
+ id,
691
+ Number(head.row.revision),
692
+ );
693
+ this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run(status, id);
694
+ this.bumpIndexRev();
695
+ });
696
+ this.audit.append('verify', { id, rev: Number(head.row.revision), status, actor: options.actor ?? 'panel' }, { sync: true });
697
+ return this.get(id).entry;
698
+ }
699
+
700
+ /**
701
+ * Pin or unpin the head revision.
702
+ * @param id - memory id.
703
+ * @param pinned - target state.
704
+ * @param actor - who asked.
705
+ */
706
+ setPinned(id, pinned, actor = 'panel') {
707
+ this.#assertWritable();
708
+ const head = this.#headRow(id);
709
+ if (head === undefined) throw new MemoryNotFoundError(id);
710
+ withTransaction(this.db, () => {
711
+ this.db
712
+ .prepare('UPDATE memory SET pinned = ?, tier = ?, updated_at = ? WHERE id = ? AND revision = ?')
713
+ .run(pinned ? 1 : 0, tierOf(head.row.kind, pinned === true), this.now(), id, Number(head.row.revision));
714
+ this.bumpIndexRev();
715
+ });
716
+ this.audit.append('verify', { id, rev: Number(head.row.revision), pinned: pinned === true, actor }, { sync: true });
717
+ return this.get(id).entry;
718
+ }
719
+
720
+ /**
721
+ * Soft-delete, restore, or purge one memory.
722
+ * @param id - memory id.
723
+ * @param options - `mode` (`soft`|`restore`|`purge`), `reason`, `confirm`, `actor`.
724
+ */
725
+ forget(id, options = {}) {
726
+ this.#assertWritable();
727
+ const mode = options.mode ?? 'soft';
728
+ const head = this.#headRow(id);
729
+ if (head === undefined) throw new MemoryNotFoundError(id);
730
+ const now = this.now();
731
+
732
+ if (mode === 'purge') {
733
+ if (this.live('allowHardDelete') !== true) {
734
+ throw new MemoryRejectedError('hard delete is disabled; set allowHardDelete:true to permit it');
735
+ }
736
+ if (options.confirm !== id) throw new MemoryRejectedError(`purge requires confirm: "${id}"`);
737
+ const ords = this.db.prepare('SELECT ord FROM memory WHERE id = ?').all(id).map((row) => Number(row.ord));
738
+ withTransaction(this.db, () => {
739
+ for (const ord of ords) {
740
+ this.db.prepare('DELETE FROM memory_fts WHERE rowid = ?').run(ord);
741
+ this.db.prepare('DELETE FROM memory_entity WHERE ord = ?').run(ord);
742
+ this.db.prepare('DELETE FROM embedding WHERE ord = ?').run(ord);
743
+ }
744
+ this.db.prepare('DELETE FROM memory WHERE id = ?').run(id);
745
+ this.db.prepare('DELETE FROM memory_head WHERE id = ?').run(id);
746
+ this.db.prepare('DELETE FROM memory_edge WHERE src_id = ? OR dst_id = ?').run(id, id);
747
+ this.#recomputeTokenStats();
748
+ this.bumpIndexRev();
749
+ });
750
+ this.audit.append('delete', { id, mode, actor: options.actor ?? 'panel', reason: options.reason ?? null }, { sync: true });
751
+ return { id, mode, deleted: true };
752
+ }
753
+
754
+ if (mode === 'restore') {
755
+ withTransaction(this.db, () => {
756
+ this.db
757
+ .prepare('UPDATE memory SET status = ?, deleted_at = NULL, updated_at = ? WHERE id = ? AND revision = ?')
758
+ .run('candidate', now, id, Number(head.row.revision));
759
+ this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run('candidate', id);
760
+ this.bumpIndexRev();
761
+ });
762
+ this.audit.append('delete', { id, mode, actor: options.actor ?? 'panel' }, { sync: true });
763
+ return { id, mode, deleted: false };
764
+ }
765
+
766
+ withTransaction(this.db, () => {
767
+ this.db
768
+ .prepare('UPDATE memory SET status = ?, deleted_at = ? WHERE id = ? AND revision = ?')
769
+ .run('deleted', now, id, Number(head.row.revision));
770
+ this.db.prepare('UPDATE memory_head SET status = ? WHERE id = ?').run('deleted', id);
771
+ this.bumpIndexRev();
772
+ });
773
+ this.audit.append('delete', { id, mode: 'soft', actor: options.actor ?? 'panel', reason: options.reason ?? null }, { sync: true });
774
+ return { id, mode: 'soft', deleted: true };
775
+ }
776
+
777
+ /**
778
+ * The entries the injected index may show, in stable order.
779
+ * @param options - `scopeKeys`, `now`, `limit`.
780
+ * @returns rows ordered by tier then creation time.
781
+ */
782
+ indexEntries(options) {
783
+ const keys = options.scopeKeys ?? [];
784
+ if (keys.length === 0) return [];
785
+ const now = options.now ?? this.now();
786
+ const limit = Math.max(1, Math.min(5000, options.limit ?? 500));
787
+ const placeholders = keys.map(() => '?').join(', ');
788
+ const rows = this.db
789
+ .prepare(
790
+ `SELECT m.id, m.revision, m.kind, m.scope, m.title, m.valid_from, m.updated_at, m.pinned, m.created_at, m.tier
791
+ FROM memory m
792
+ WHERE m.status = 'verified'
793
+ AND m.deleted_at IS NULL
794
+ AND (m.valid_to IS NULL OR m.valid_to > ?)
795
+ AND m.scope_key IN (${placeholders})
796
+ ORDER BY m.tier ASC, m.created_at ASC, m.ord ASC
797
+ LIMIT ?`,
798
+ )
799
+ .all(now, ...keys, limit);
800
+ return rows.map((row) => ({
801
+ id: row.id,
802
+ revision: Number(row.revision),
803
+ kind: row.kind,
804
+ scope: row.scope,
805
+ title: row.title,
806
+ validFrom: Number(row.valid_from),
807
+ updatedAt: Number(row.updated_at),
808
+ pinned: Number(row.pinned) === 1,
809
+ }));
810
+ }
811
+
812
+ /**
813
+ * How many entries the injected index would show without a limit. Used for
814
+ * the honest "N shown of M" footer.
815
+ * @param scopeKeys - read scope keys.
816
+ * @param now - reference time.
817
+ */
818
+ countIndex(scopeKeys, now = this.now()) {
819
+ if (scopeKeys.length === 0) return 0;
820
+ const marks = scopeKeys.map(() => '?').join(', ');
821
+ return Number(
822
+ this.db
823
+ .prepare(
824
+ `SELECT COUNT(*) AS n FROM memory m
825
+ WHERE m.status = 'verified' AND m.deleted_at IS NULL
826
+ AND (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${marks})`,
827
+ )
828
+ .get(now, ...scopeKeys)?.n ?? 0,
829
+ );
830
+ }
831
+
832
+ /**
833
+ * Verified rules for the rules block, newest last.
834
+ * @param options - `scopeKeys`, `now`, `limit`.
835
+ */
836
+ ruleEntries(options) {
837
+ const keys = options.scopeKeys ?? [];
838
+ if (keys.length === 0) return [];
839
+ const now = options.now ?? this.now();
840
+ const placeholders = keys.map(() => '?').join(', ');
841
+ return this.db
842
+ .prepare(
843
+ `SELECT m.id, m.title, m.updated_at
844
+ FROM memory m
845
+ WHERE m.status = 'verified' AND m.tier = 0 AND m.kind = 'rule' AND m.deleted_at IS NULL
846
+ AND (m.valid_to IS NULL OR m.valid_to > ?)
847
+ AND m.scope_key IN (${placeholders})
848
+ ORDER BY m.pinned DESC, m.created_at ASC
849
+ LIMIT ?`,
850
+ )
851
+ .all(now, ...keys, options.limit ?? 40)
852
+ .map((row) => ({ id: row.id, title: row.title, updatedAt: Number(row.updated_at) }));
853
+ }
854
+
855
+ /**
856
+ * Every scope key the database knows. Used by the owner-only panel and the
857
+ * Markdown export, which are administrative views rather than session reads.
858
+ * @returns distinct scope keys.
859
+ */
860
+ allScopeKeys() {
861
+ return this.db
862
+ .prepare('SELECT DISTINCT m.scope_key AS key FROM memory m JOIN memory_head h ON h.ord = m.ord')
863
+ .all()
864
+ .map((row) => row.key);
865
+ }
866
+
867
+ /**
868
+ * Every head id, newest first. Export iterates this rather than a page.
869
+ * @returns memory ids.
870
+ */
871
+ /**
872
+ * Forget everything in one scope, or the whole store.
873
+ *
874
+ * A privacy request ("delete what you remember about me") is not an exotic
875
+ * operation, and telling the user to go delete a directory by hand is a bad
876
+ * answer. The gate is the same shape as a single purge: the caller has to echo
877
+ * the exact confirmation phrase, and hard deletion additionally needs
878
+ * `allowHardDelete`.
879
+ *
880
+ * @param options - `scopeKey` (omit for every scope), `mode` (`soft`|`purge`),
881
+ * `confirm`, `actor`, `reason`.
882
+ * @returns `{mode, scopeKey, count}`.
883
+ */
884
+ forgetAll(options = {}) {
885
+ this.#assertWritable();
886
+ const mode = options.mode === 'purge' ? 'purge' : 'soft';
887
+ const scopeKey = typeof options.scopeKey === 'string' && options.scopeKey.length > 0 ? options.scopeKey : undefined;
888
+ const expected = scopeKey === undefined ? CONFIRM_ALL : `forget ${scopeKey}`;
889
+ if (options.confirm !== expected) {
890
+ throw new MemoryRejectedError(`forget-all requires confirm: "${expected}"`);
891
+ }
892
+ if (mode === 'purge' && this.live('allowHardDelete') !== true) {
893
+ throw new MemoryRejectedError('hard delete is disabled; set allowHardDelete:true to permit it');
894
+ }
895
+ // `memory_head` carries no scope column; the scope lives on the revisions.
896
+ const ids =
897
+ scopeKey === undefined
898
+ ? this.allHeadIds()
899
+ : this.db
900
+ .prepare('SELECT DISTINCT id FROM memory WHERE scope_key = ?')
901
+ .all(scopeKey)
902
+ .map((row) => row.id);
903
+ for (const id of ids) {
904
+ // One entry at a time keeps the per-entry audit line and the index bump,
905
+ // at the cost of a slower loop — a bulk delete is rare and honesty wins.
906
+ this.forget(id, { mode, confirm: mode === 'purge' ? id : undefined, actor: options.actor ?? 'panel', reason: options.reason });
907
+ }
908
+ this.audit.append(
909
+ 'delete',
910
+ { mode: `all:${mode}`, count: ids.length, scopeKey: scopeKey ?? null, actor: options.actor ?? 'panel' },
911
+ { sync: true },
912
+ );
913
+ return { mode, scopeKey: scopeKey ?? null, count: ids.length };
914
+ }
915
+
916
+ allHeadIds() {
917
+ return this.db
918
+ .prepare('SELECT id FROM memory_head ORDER BY revision DESC, id ASC')
919
+ .all()
920
+ .map((row) => row.id);
921
+ }
922
+
923
+ /**
924
+ * Page over head rows for the panel.
925
+ * @param options - `scopeKeys`, `statuses`, `kinds`, `limit`, `offset`.
926
+ * @returns `{items, total}`.
927
+ */
928
+ list(options = {}) {
929
+ const keys = options.scopeKeys ?? [];
930
+ if (keys.length === 0) return { items: [], total: 0 };
931
+ const statuses = options.statuses ?? ['verified'];
932
+ const kinds = Array.isArray(options.kinds) && options.kinds.length > 0 ? options.kinds : null;
933
+ const keyMarks = keys.map(() => '?').join(', ');
934
+ const statusMarks = statuses.map(() => '?').join(', ');
935
+ const kindClause = kinds === null ? '' : ` AND m.kind IN (${kinds.map(() => '?').join(', ')})`;
936
+ const params = [nowOrMax(options.now), ...keys, ...statuses, ...(kinds ?? [])];
937
+ const total = Number(
938
+ this.db
939
+ .prepare(
940
+ `SELECT COUNT(*) AS n FROM memory m
941
+ WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
942
+ AND m.status IN (${statusMarks})${kindClause}`,
943
+ )
944
+ .get(...params)?.n ?? 0,
945
+ );
946
+ const limit = Math.max(1, Math.min(200, options.limit ?? 50));
947
+ const offset = Math.max(0, options.offset ?? 0);
948
+ const items = this.db
949
+ .prepare(
950
+ `SELECT m.id, m.revision, m.kind, m.scope, m.scope_label, m.title, m.status, m.pinned, m.tags,
951
+ m.valid_from, m.valid_to, m.updated_at, m.created_at,
952
+ substr(m.body, 1, 240) AS snippet
953
+ FROM memory m
954
+ WHERE (m.valid_to IS NULL OR m.valid_to > ?) AND m.scope_key IN (${keyMarks})
955
+ AND m.status IN (${statusMarks})${kindClause}
956
+ ORDER BY m.pinned DESC, m.updated_at DESC, m.ord DESC
957
+ LIMIT ? OFFSET ?`,
958
+ )
959
+ .all(...params, limit, offset)
960
+ .map((row) => ({
961
+ id: row.id,
962
+ revision: Number(row.revision),
963
+ kind: row.kind,
964
+ scope: row.scope,
965
+ scopeLabel: row.scope_label,
966
+ title: row.title,
967
+ status: row.status,
968
+ pinned: Number(row.pinned) === 1,
969
+ tags: JSON.parse(row.tags ?? '[]'),
970
+ validFrom: Number(row.valid_from),
971
+ validTo: row.valid_to === null ? null : Number(row.valid_to),
972
+ updatedAt: Number(row.updated_at),
973
+ createdAt: Number(row.created_at),
974
+ snippet: row.snippet ?? '',
975
+ }));
976
+ return { items, total };
977
+ }
978
+
979
+ /**
980
+ * Aggregate counters for `loci_stats`, the panel, and doctor.
981
+ * @param options - `scopeKeys` (omit for the whole database).
982
+ */
983
+ stats(options = {}) {
984
+ const keys = options.scopeKeys ?? [];
985
+ const filtered = keys.length > 0;
986
+ const marks = filtered ? ` AND m.scope_key IN (${keys.map(() => '?').join(', ')})` : '';
987
+ const byStatus = {};
988
+ for (const row of this.db
989
+ .prepare(
990
+ `SELECT h.status AS status, COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE 1=1${marks} GROUP BY h.status`,
991
+ )
992
+ .all(...keys)) {
993
+ byStatus[row.status] = Number(row.n);
994
+ }
995
+ const byKind = {};
996
+ for (const row of this.db
997
+ .prepare(
998
+ `SELECT m.kind AS kind, COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE 1=1${marks} GROUP BY m.kind`,
999
+ )
1000
+ .all(...keys)) {
1001
+ byKind[row.kind] = Number(row.n);
1002
+ }
1003
+ const byScope = {};
1004
+ for (const row of this.db
1005
+ .prepare(
1006
+ `SELECT m.scope AS scope, COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE 1=1${marks} GROUP BY m.scope`,
1007
+ )
1008
+ .all(...keys)) {
1009
+ byScope[row.scope] = Number(row.n);
1010
+ }
1011
+ const pending = Number(
1012
+ this.db
1013
+ .prepare(
1014
+ `SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord
1015
+ WHERE h.status = 'candidate' AND m.deleted_at IS NULL${marks}`,
1016
+ )
1017
+ .get(...keys)?.n ?? 0,
1018
+ );
1019
+ const disputed = Number(
1020
+ this.db
1021
+ .prepare(`SELECT COUNT(*) AS n FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE h.status = 'disputed'${marks}`)
1022
+ .get(...keys)?.n ?? 0,
1023
+ );
1024
+ const lastInjection = this.db
1025
+ .prepare('SELECT ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden FROM request_stat ORDER BY id DESC LIMIT 1')
1026
+ .get();
1027
+ const revisions = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory').get()?.n ?? 0);
1028
+ return {
1029
+ schemaVersion: this.schemaVersion(),
1030
+ indexRev: this.indexRev(),
1031
+ revisions,
1032
+ heads: Object.values(byStatus).reduce((total, n) => total + n, 0),
1033
+ byStatus,
1034
+ byKind,
1035
+ byScope,
1036
+ pendingCandidates: pending,
1037
+ disputed,
1038
+ lastInjection:
1039
+ lastInjection === undefined
1040
+ ? null
1041
+ : {
1042
+ ts: Number(lastInjection.ts),
1043
+ sessionId: lastInjection.session_id,
1044
+ scopeKey: lastInjection.scope_key,
1045
+ bytes: Number(lastInjection.section_bytes),
1046
+ listed: Number(lastInjection.entries_listed),
1047
+ hidden: Number(lastInjection.entries_hidden),
1048
+ },
1049
+ dbBytes: existsSync(this.paths.db) ? statSync(this.paths.db).size : 0,
1050
+ vector: { ...this.vector, ...this.embedding.health() },
1051
+ memoryRoot: this.memoryRoot,
1052
+ };
1053
+ }
1054
+
1055
+ /**
1056
+ * Record one injected section, so the cost of each request is queryable.
1057
+ * @param row - `sessionId`, `scopeKey`, `bytes`, `listed`, `hidden`.
1058
+ */
1059
+ recordInjection(row) {
1060
+ this.db
1061
+ .prepare(
1062
+ 'INSERT INTO request_stat (ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden) VALUES (?, ?, ?, ?, ?, ?)',
1063
+ )
1064
+ .run(this.now(), row.sessionId ?? null, row.scopeKey ?? null, row.bytes, row.listed, row.hidden);
1065
+ this.db.prepare('DELETE FROM request_stat WHERE ts < ?').run(this.now() - 90 * 86_400_000);
1066
+ this.audit.append('inject', {
1067
+ sessionId: row.sessionId ?? null,
1068
+ scopeKey: row.scopeKey ?? null,
1069
+ bytes: row.bytes,
1070
+ listed: row.listed,
1071
+ hidden: row.hidden,
1072
+ });
1073
+ }
1074
+
1075
+ /**
1076
+ * Record one recall.
1077
+ * @param row - `query` length, counts, timing, channels.
1078
+ */
1079
+ recordRecall(row) {
1080
+ this.audit.append('recall', {
1081
+ sessionId: row.sessionId ?? null,
1082
+ scopeKey: row.scopeKey ?? null,
1083
+ queryChars: typeof row.query === 'string' ? row.query.length : 0,
1084
+ total: row.total,
1085
+ returned: row.returned,
1086
+ tookMs: row.tookMs,
1087
+ channels: row.channels,
1088
+ });
1089
+ }
1090
+
1091
+ /** The audit ring, for the panel and stats. */
1092
+ auditTail(limit = 100) {
1093
+ return this.audit.tail(limit);
1094
+ }
1095
+
1096
+ /**
1097
+ * The most recent injected sections, newest first.
1098
+ * @param limit - how many rows.
1099
+ * @returns `[{ts, sessionId, bytes, listed, hidden}]`.
1100
+ */
1101
+ recentInjections(limit = 50) {
1102
+ return this.db
1103
+ .prepare(
1104
+ 'SELECT ts, session_id, scope_key, section_bytes, entries_listed, entries_hidden FROM request_stat ORDER BY id DESC LIMIT ?',
1105
+ )
1106
+ .all(Math.max(1, Math.min(500, limit)))
1107
+ .map((row) => ({
1108
+ ts: Number(row.ts),
1109
+ sessionId: row.session_id,
1110
+ scopeKey: row.scope_key,
1111
+ sectionBytes: Number(row.section_bytes),
1112
+ listed: Number(row.entries_listed),
1113
+ hidden: Number(row.entries_hidden),
1114
+ }));
1115
+ }
1116
+
1117
+ /**
1118
+ * A predicate that marks tokens too common to count as evidence.
1119
+ *
1120
+ * A term present in more than 15% of the scope's memories is a hub word
1121
+ * ("project", "插件"); letting it drive a query recalls everything and ranks
1122
+ * nothing. The token counts are maintained on write, so this stays a couple
1123
+ * of indexed lookups per query.
1124
+ * @returns `(token) => boolean`.
1125
+ */
1126
+ hubPredicate() {
1127
+ const total = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory_head').get()?.n ?? 0);
1128
+ if (total < 20) return () => false;
1129
+ const statement = this.db.prepare('SELECT df FROM token_stat WHERE token = ?');
1130
+ return (token) => Number(statement.get(token)?.df ?? 0) / total > 0.15;
1131
+ }
1132
+
1133
+ /**
1134
+ * Rebuild the FTS index and token statistics from `memory`.
1135
+ * Used after an interrupted write, by doctor, and by the reindex command.
1136
+ * @returns `{rows, tokens}`.
1137
+ */
1138
+ reindex() {
1139
+ this.#assertWritable();
1140
+ return withTransaction(this.db, () => {
1141
+ this.db.exec('DELETE FROM memory_fts');
1142
+ this.db.exec('DELETE FROM token_stat');
1143
+ const rows = this.db.prepare('SELECT ord, title, body, tags FROM memory').all();
1144
+ const insert = this.db.prepare('INSERT INTO memory_fts (rowid, tokens) VALUES (?, ?)');
1145
+ const counts = new Map();
1146
+ for (const row of rows) {
1147
+ const tags = JSON.parse(row.tags ?? '[]').join(' ');
1148
+ const stream = tokenStream(`${row.title} ${row.body} ${tags}`);
1149
+ insert.run(Number(row.ord), stream);
1150
+ for (const token of new Set(tokenize(`${row.title} ${row.body} ${tags}`))) {
1151
+ counts.set(token, (counts.get(token) ?? 0) + 1);
1152
+ }
1153
+ }
1154
+ const stat = this.db.prepare('INSERT INTO token_stat (token, df) VALUES (?, ?) ON CONFLICT(token) DO UPDATE SET df = excluded.df');
1155
+ for (const [token, df] of counts) stat.run(token, df);
1156
+ return { rows: rows.length, tokens: counts.size };
1157
+ });
1158
+ }
1159
+
1160
+ /**
1161
+ * Fill missing embeddings for verified entries. No-op when the channel is
1162
+ * off or unavailable; a failure leaves the entry lexically searchable.
1163
+ * @param limit - most entries to embed in one pass.
1164
+ * @returns `{added, skipped}`.
1165
+ */
1166
+ async backfillEmbeddings(limit = 200) {
1167
+ if (this.embedding === undefined || !this.embedding.available()) return { added: 0, skipped: true };
1168
+ const rows = this.db
1169
+ .prepare(
1170
+ `SELECT m.ord, m.title, m.body FROM memory m
1171
+ JOIN memory_head h ON h.ord = m.ord
1172
+ LEFT JOIN embedding e ON e.ord = m.ord
1173
+ WHERE e.ord IS NULL AND h.status = 'verified' AND m.deleted_at IS NULL
1174
+ LIMIT ?`,
1175
+ )
1176
+ .all(limit);
1177
+ if (rows.length === 0) return { added: 0, skipped: false };
1178
+ let added = 0;
1179
+ const complete = await this.embedding.embedAll(
1180
+ rows.map((row) => `${row.title}\n${row.body}`),
1181
+ (start, vectors) => {
1182
+ withTransaction(this.db, () => {
1183
+ const insert = this.db.prepare(
1184
+ 'INSERT OR REPLACE INTO embedding (ord, model, dim, vec, created_at) VALUES (?, ?, ?, ?, ?)',
1185
+ );
1186
+ vectors.forEach((vector, index) => {
1187
+ insert.run(
1188
+ Number(rows[start + index].ord),
1189
+ this.embedding.model,
1190
+ vector.length,
1191
+ vectorToBlob(vector),
1192
+ this.now(),
1193
+ );
1194
+ });
1195
+ });
1196
+ added += vectors.length;
1197
+ },
1198
+ );
1199
+ return { added, skipped: !complete };
1200
+ }
1201
+
1202
+ /**
1203
+ * Whether the FTS index covers every stored revision.
1204
+ * @returns `{memory, fts, missing}`.
1205
+ */
1206
+ indexCoverage() {
1207
+ const memory = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory').get()?.n ?? 0);
1208
+ const fts = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory_fts').get()?.n ?? 0);
1209
+ return { memory, fts, missing: Math.max(0, memory - fts) };
1210
+ }
1211
+
1212
+ /**
1213
+ * One maintenance pass: expire working memory, close stale candidates,
1214
+ * rebuild a drifted index, and refresh query-planner statistics.
1215
+ * @param options - `now`.
1216
+ * @returns a summary of what changed.
1217
+ */
1218
+ maintenance(options = {}) {
1219
+ this.#assertWritable();
1220
+ const now = options.now ?? this.now();
1221
+ const summary = { expiredWorking: 0, closedCandidates: 0, reindexed: false, optimized: false };
1222
+ const workingTtlDays = Number(this.live('workingTtlDays') ?? 7);
1223
+ if (workingTtlDays > 0) {
1224
+ const cutoff = now - workingTtlDays * 86_400_000;
1225
+ const result = this.db
1226
+ .prepare(
1227
+ `UPDATE memory SET valid_to = ?, updated_at = ?
1228
+ WHERE kind = 'working' AND valid_to IS NULL AND created_at < ? AND status <> 'deleted'`,
1229
+ )
1230
+ .run(now, now, cutoff);
1231
+ summary.expiredWorking = Number(result.changes ?? 0);
1232
+ }
1233
+ const candidateTtlDays = Number(this.live('candidateTtlDays') ?? 30);
1234
+ if (candidateTtlDays > 0) {
1235
+ const cutoff = now - candidateTtlDays * 86_400_000;
1236
+ const result = this.db
1237
+ .prepare(
1238
+ `UPDATE memory SET valid_to = ?, updated_at = ?
1239
+ WHERE status = 'candidate' AND valid_to IS NULL AND created_at < ?`,
1240
+ )
1241
+ .run(now, now, cutoff);
1242
+ summary.closedCandidates = Number(result.changes ?? 0);
1243
+ }
1244
+ const coverage = this.indexCoverage();
1245
+ if (coverage.missing > 0) {
1246
+ this.reindex();
1247
+ summary.reindexed = true;
1248
+ this.bumpIndexRev();
1249
+ }
1250
+ try {
1251
+ this.db.exec('PRAGMA optimize');
1252
+ summary.optimized = true;
1253
+ } catch {
1254
+ summary.optimized = false;
1255
+ }
1256
+ writeMeta(this.db, META_KEYS.lastMaintenance, String(now));
1257
+ // A sweep that changed nothing is not news. Keeping it out of the audit is
1258
+ // what stops the activity feed from being a wall of "nothing to do" and
1259
+ // hiding the writes a user actually wants to see.
1260
+ const changed = summary.expiredWorking > 0 || summary.closedCandidates > 0 || summary.reindexed;
1261
+ if (changed) this.audit.append('maintenance', summary, { sync: true });
1262
+ return summary;
1263
+ }
1264
+
1265
+ /**
1266
+ * Take a consistent snapshot next to the database.
1267
+ * @param label - file label (`manual`, `pre-import`).
1268
+ * @returns the snapshot path.
1269
+ */
1270
+ snapshot(label = 'manual') {
1271
+ this.#assertWritable();
1272
+ return backupTo(this.db, join(this.paths.backups, `${label}-${this.now()}.db`));
1273
+ }
1274
+
1275
+ /** Health checks used by `doctor`. */
1276
+ health() {
1277
+ return {
1278
+ quickCheck: quickCheck(this.db),
1279
+ integrity: integrityCheck(this.db),
1280
+ coverage: this.indexCoverage(),
1281
+ counts: tableCounts(this.db),
1282
+ schemaVersion: this.schemaVersion(),
1283
+ expectedSchemaVersion: SCHEMA_VERSION,
1284
+ migration: this.migration,
1285
+ dbBytes: existsSync(this.paths.db) ? statSync(this.paths.db).size : 0,
1286
+ };
1287
+ }
1288
+
1289
+ // ---------------------------------------------------------------- internals
1290
+
1291
+ #headRow(id) {
1292
+ const row = this.db
1293
+ .prepare(
1294
+ `SELECT m.* FROM memory m JOIN memory_head h ON h.ord = m.ord WHERE m.id = ? LIMIT 1`,
1295
+ )
1296
+ .get(id);
1297
+ if (row === undefined) return undefined;
1298
+ return { row };
1299
+ }
1300
+
1301
+ #findByHash(scopeKey, contentHash) {
1302
+ return this.db
1303
+ .prepare(
1304
+ `SELECT m.id, m.revision FROM memory m JOIN memory_head h ON h.ord = m.ord
1305
+ WHERE m.scope_key = ? AND m.content_hash = ? AND m.deleted_at IS NULL
1306
+ ORDER BY m.revision DESC LIMIT 1`,
1307
+ )
1308
+ .get(scopeKey, contentHash);
1309
+ }
1310
+
1311
+ #findByTitle(scopeKey, titleNorm) {
1312
+ return this.db
1313
+ .prepare(
1314
+ `SELECT m.id, m.revision FROM memory m JOIN memory_head h ON h.ord = m.ord
1315
+ WHERE m.scope_key = ? AND m.title_norm = ? AND m.deleted_at IS NULL
1316
+ ORDER BY m.revision DESC LIMIT 1`,
1317
+ )
1318
+ .get(scopeKey, titleNorm);
1319
+ }
1320
+
1321
+ #findSimilar(scopeKey, prepared) {
1322
+ // A duplicate is signalled by a *rare* shared term. Anchoring the check on
1323
+ // the rarest title token keeps the cost proportional to that term's posting
1324
+ // list instead of the whole scope, and a title made only of hub words
1325
+ // ("发布流程约定") cannot be a meaningful duplicate signal anyway.
1326
+ const tokens = tokenize(prepared.title).filter((token) => token.length >= 2);
1327
+ if (tokens.length === 0) return [];
1328
+ const total = Number(this.db.prepare('SELECT COUNT(*) AS n FROM memory_head').get()?.n ?? 0);
1329
+ if (total < 2) return [];
1330
+ const dfStatement = this.db.prepare('SELECT df FROM token_stat WHERE token = ?');
1331
+ const ranked = tokens
1332
+ .map((token) => ({ token, df: Number(dfStatement.get(token)?.df ?? 0) }))
1333
+ .sort((a, b) => a.df - b.df);
1334
+ const anchor = ranked[0];
1335
+ if (anchor.df === 0 || anchor.df / total > 0.15) return [];
1336
+ const candidates = this.db
1337
+ .prepare(
1338
+ `SELECT m.id, m.revision, m.title, m.body
1339
+ FROM memory_fts
1340
+ JOIN memory m ON m.ord = memory_fts.rowid
1341
+ JOIN memory_head h ON h.ord = m.ord
1342
+ WHERE memory_fts MATCH ? AND m.scope_key = ? AND h.status <> 'deleted'
1343
+ LIMIT 16`,
1344
+ )
1345
+ .all(`"${anchor.token}"`, scopeKey);
1346
+ const threshold = Number(this.live('dedupeSimilarity') ?? 0.8);
1347
+ const hits = [];
1348
+ for (const row of candidates) {
1349
+ const score = jaccard(`${prepared.title}\n${prepared.body}`, `${row.title}\n${row.body}`);
1350
+ if (score >= threshold) {
1351
+ hits.push({ id: row.id, revision: Number(row.revision), title: row.title, similarity: Number(score.toFixed(3)) });
1352
+ }
1353
+ }
1354
+ return hits.slice(0, 5);
1355
+ }
1356
+
1357
+ #resolveStatus({ input, prepared, evidence, trustSource }) {
1358
+ // working memory is scratch for this session; it is never injected, so it
1359
+ // never needs verifying.
1360
+ if (input.kind === 'working') return 'candidate';
1361
+ if (['candidate', 'verified', 'disputed'].includes(input.status)) return input.status;
1362
+ if (prepared.instructionLike) return 'disputed';
1363
+ // An import or a session summary is not testimony: neither is something the
1364
+ // user just said, so neither can be verified by a quote.
1365
+ if (trustSource === 'import' || trustSource === 'summary') return 'candidate';
1366
+ // A rule the capture layer lifted out of the user's own message **is**
1367
+ // testimony — it carries the sentence verbatim. It goes through the same
1368
+ // quote gate as a model-supplied quote instead of being parked in the review
1369
+ // queue forever, which is what "everything needs a click" used to mean.
1370
+ if (trustSource === 'rule' && this.live('autoVerifyCaptured') === false) return 'candidate';
1371
+ if (trustSource === 'user') return 'verified';
1372
+ return this.#quoteVerifies(evidence, input) ? 'verified' : 'candidate';
1373
+ }
1374
+
1375
+ /**
1376
+ * Whether this exact text was ever verified for this entry.
1377
+ *
1378
+ * Comparison is against every revision, not just the head: reverting an edit
1379
+ * back to previously verified wording must restore trust, because the text is
1380
+ * literally the text a human confirmed. Anything stricter would make a
1381
+ * correction one-way, which is how users learn to stop correcting.
1382
+ * @param id - memory id.
1383
+ * @param contentHash - hash of the candidate revision's title and body.
1384
+ * @returns true when some revision with that hash carries a verification stamp.
1385
+ */
1386
+ #textWasVerified(id, contentHash) {
1387
+ const row = this.db
1388
+ .prepare(
1389
+ `SELECT 1 AS hit FROM memory
1390
+ WHERE id = ? AND content_hash = ? AND verified_at IS NOT NULL
1391
+ LIMIT 1`,
1392
+ )
1393
+ .get(id, contentHash);
1394
+ return row !== undefined;
1395
+ }
1396
+
1397
+ /**
1398
+ * Whether the evidence carried by a write is a quote this session can confirm.
1399
+ * The single place verification is decided — the create path, the update path
1400
+ * and the tool surface all call it, so they cannot drift apart.
1401
+ * @param evidence - `{quote, sessionId, eventSeq}`.
1402
+ * @param input - the write input (for `sourceSession`/`sourceEvent` fallbacks).
1403
+ * @returns true when the quote is long enough and the verifier accepts it.
1404
+ */
1405
+ #quoteVerifies(evidence, input) {
1406
+ const quote = typeof evidence?.quote === 'string' ? evidence.quote.trim() : '';
1407
+ if (quote.length < 8) return false;
1408
+ let verified = false;
1409
+ try {
1410
+ verified =
1411
+ this.quoteVerifier(quote, {
1412
+ sessionId: evidence.sessionId ?? input?.sourceSession ?? undefined,
1413
+ eventSeq: Number.isSafeInteger(evidence.eventSeq) ? evidence.eventSeq : input?.sourceEvent,
1414
+ }) === true;
1415
+ } catch {
1416
+ verified = false;
1417
+ }
1418
+ this.quoteChecked += 1;
1419
+ return verified;
1420
+ }
1421
+
1422
+ #insertRevision(row) {
1423
+ const result = this.db
1424
+ .prepare(
1425
+ `INSERT INTO memory (
1426
+ id, revision, kind, scope, scope_key, scope_label, title, title_norm, body, tags, entities,
1427
+ status, trust_source, flags, valid_from, valid_to, recorded_at, supersedes, superseded_by,
1428
+ evidence, source_session, source_event, verified_at, verified_by, pinned, importance, tier,
1429
+ content_hash, created_at, updated_at, deleted_at
1430
+ ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,NULL)`,
1431
+ )
1432
+ .run(
1433
+ row.id,
1434
+ row.revision,
1435
+ row.kind,
1436
+ row.scope,
1437
+ row.scopeKey,
1438
+ row.scopeLabel,
1439
+ row.title,
1440
+ row.titleNorm,
1441
+ row.body,
1442
+ JSON.stringify(row.tags ?? []),
1443
+ JSON.stringify(row.entities ?? []),
1444
+ row.status,
1445
+ row.trustSource,
1446
+ JSON.stringify(row.flags ?? []),
1447
+ row.validFrom,
1448
+ row.validTo,
1449
+ row.now,
1450
+ row.supersedes,
1451
+ null,
1452
+ JSON.stringify(row.evidence ?? {}),
1453
+ row.sourceSession,
1454
+ row.sourceEvent,
1455
+ row.verifiedAt,
1456
+ row.verifiedBy,
1457
+ row.pinned,
1458
+ row.importance,
1459
+ tierOf(row.kind, row.pinned),
1460
+ row.contentHash,
1461
+ row.createdAt,
1462
+ row.now,
1463
+ );
1464
+ const ord = Number(result.lastInsertRowid);
1465
+ const text = `${row.title} ${row.body} ${(row.tags ?? []).join(' ')}`;
1466
+ this.db.prepare('INSERT INTO memory_fts (rowid, tokens) VALUES (?, ?)').run(ord, tokenStream(text));
1467
+ const tokens = new Set(tokenize(text));
1468
+ const stat = this.db.prepare('INSERT INTO token_stat (token, df) VALUES (?, 1) ON CONFLICT(token) DO UPDATE SET df = df + 1');
1469
+ for (const token of tokens) stat.run(token);
1470
+ this.#linkEntities(ord, row.entities ?? [], row.scopeKey, row.now);
1471
+ this.db
1472
+ .prepare(
1473
+ 'INSERT INTO memory_head (id, ord, revision, status) VALUES (?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET ord = excluded.ord, revision = excluded.revision, status = excluded.status',
1474
+ )
1475
+ .run(row.id, ord, row.revision, row.status);
1476
+ return ord;
1477
+ }
1478
+
1479
+ #linkEntities(ord, entities, scopeKey, now) {
1480
+ if (!Array.isArray(entities) || entities.length === 0) return;
1481
+ const upsert = this.db.prepare(
1482
+ `INSERT INTO entity (id, scope_key, norm_name, name, kind, aliases, created_at) VALUES (?, ?, ?, ?, ?, '[]', ?)
1483
+ ON CONFLICT(scope_key, norm_name) DO UPDATE SET name = excluded.name, kind = excluded.kind`,
1484
+ );
1485
+ const find = this.db.prepare('SELECT id FROM entity WHERE scope_key = ? AND norm_name = ?');
1486
+ const link = this.db.prepare('INSERT OR IGNORE INTO memory_entity (ord, entity_id, role) VALUES (?, ?, ?)');
1487
+ const bump = this.db.prepare('UPDATE entity SET link_count = link_count + 1 WHERE id = ?');
1488
+ for (const entity of entities) {
1489
+ const norm = normalizeText(entity.name).replace(/\s+/g, '');
1490
+ if (norm.length === 0) continue;
1491
+ upsert.run(newEntityId(), scopeKey, norm, entity.name, entity.kind ?? 'thing', now);
1492
+ const found = find.get(scopeKey, norm);
1493
+ if (found === undefined) continue;
1494
+ const linked = link.run(ord, found.id, 'mention');
1495
+ if (Number(linked.changes ?? 0) > 0) bump.run(found.id);
1496
+ }
1497
+ }
1498
+
1499
+ #recomputeTokenStats() {
1500
+ this.db.exec('DELETE FROM token_stat');
1501
+ const counts = new Map();
1502
+ const rows = this.db.prepare('SELECT title, body, tags FROM memory').all();
1503
+ for (const row of rows) {
1504
+ const text = `${row.title} ${row.body} ${JSON.parse(row.tags ?? '[]').join(' ')}`;
1505
+ for (const token of new Set(tokenize(text))) counts.set(token, (counts.get(token) ?? 0) + 1);
1506
+ }
1507
+ const stat = this.db.prepare('INSERT INTO token_stat (token, df) VALUES (?, ?)');
1508
+ for (const [token, df] of counts) stat.run(token, df);
1509
+ }
1510
+ }
1511
+
1512
+ /** `now` or the largest safe timestamp, for filters that must not exclude open rows. */
1513
+ function nowOrMax(value) {
1514
+ return Number.isFinite(value) ? value : Number.MAX_SAFE_INTEGER;
1515
+ }
1516
+
1517
+ /**
1518
+ * Whether a verbatim quote is about the same claim as this text.
1519
+ *
1520
+ * Verification proves the user said the quote; it does not prove the quote
1521
+ * supports a *different* sentence. Reusing an old quote while replacing the
1522
+ * claim is the cheapest way to launder an edit, so an update keeps `verified`
1523
+ * only when the quote and the new text still overlap.
1524
+ *
1525
+ * @param quote - evidence quote.
1526
+ * @param text - new title and body.
1527
+ * @returns true when the two share at least 15% of their character bigrams.
1528
+ */
1529
+ function evidenceSupports(quote, text) {
1530
+ if (typeof quote !== 'string' || quote.trim().length < 8) return false;
1531
+ return jaccard(quote, text) >= 0.15;
1532
+ }
1533
+ /** Jaccard over whitespace-collapsed character bigrams (duplicate heuristic). */
1534
+ function jaccard(a, b) {
1535
+ const grams = (text) => {
1536
+ const value = normalizeText(text).replace(/\s+/g, '');
1537
+ const set = new Set();
1538
+ if (value.length === 1) set.add(value);
1539
+ for (let i = 0; i + 1 < value.length; i += 1) set.add(value.slice(i, i + 2));
1540
+ return set;
1541
+ };
1542
+ const left = grams(a);
1543
+ const right = grams(b);
1544
+ if (left.size === 0 || right.size === 0) return 0;
1545
+ let shared = 0;
1546
+ for (const gram of left) if (right.has(gram)) shared += 1;
1547
+ return shared / (left.size + right.size - shared);
1548
+ }