@devflow-tools/memory-engine 0.16.11 → 0.16.13

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.
Files changed (69) hide show
  1. package/dist/embedding-model-store.d.ts +67 -0
  2. package/dist/embedding-model-store.d.ts.map +1 -0
  3. package/dist/embedding-model-store.js +350 -0
  4. package/dist/embedding-model-store.js.map +1 -0
  5. package/dist/embedding-provider.d.ts +8 -0
  6. package/dist/embedding-provider.d.ts.map +1 -1
  7. package/dist/embedding-provider.js +68 -11
  8. package/dist/embedding-provider.js.map +1 -1
  9. package/dist/explicit-memory-enricher.d.ts +24 -0
  10. package/dist/explicit-memory-enricher.d.ts.map +1 -0
  11. package/dist/explicit-memory-enricher.js +292 -0
  12. package/dist/explicit-memory-enricher.js.map +1 -0
  13. package/dist/hybrid-search.d.ts +31 -0
  14. package/dist/hybrid-search.d.ts.map +1 -1
  15. package/dist/hybrid-search.js +132 -32
  16. package/dist/hybrid-search.js.map +1 -1
  17. package/dist/index.d.ts +8 -2
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +4 -1
  20. package/dist/index.js.map +1 -1
  21. package/dist/memory-engine.d.ts +22 -0
  22. package/dist/memory-engine.d.ts.map +1 -1
  23. package/dist/memory-engine.js +199 -47
  24. package/dist/memory-engine.js.map +1 -1
  25. package/dist/memory-gate.d.ts +20 -1
  26. package/dist/memory-gate.d.ts.map +1 -1
  27. package/dist/memory-gate.js +89 -56
  28. package/dist/memory-gate.js.map +1 -1
  29. package/dist/memory-maintenance.d.ts.map +1 -1
  30. package/dist/memory-maintenance.js +4 -2
  31. package/dist/memory-maintenance.js.map +1 -1
  32. package/dist/memory-relevance.d.ts +38 -0
  33. package/dist/memory-relevance.d.ts.map +1 -0
  34. package/dist/memory-relevance.js +289 -0
  35. package/dist/memory-relevance.js.map +1 -0
  36. package/dist/memory-store.d.ts +52 -2
  37. package/dist/memory-store.d.ts.map +1 -1
  38. package/dist/memory-store.js +599 -20
  39. package/dist/memory-store.js.map +1 -1
  40. package/dist/migrations/v2-to-v3.js +1 -1
  41. package/dist/migrations/v2-to-v3.js.map +1 -1
  42. package/dist/migrations/v3-to-v4.d.ts.map +1 -1
  43. package/dist/migrations/v3-to-v4.js +4 -0
  44. package/dist/migrations/v3-to-v4.js.map +1 -1
  45. package/dist/migrations/v6-to-v7.d.ts +3 -0
  46. package/dist/migrations/v6-to-v7.d.ts.map +1 -0
  47. package/dist/migrations/v6-to-v7.js +18 -0
  48. package/dist/migrations/v6-to-v7.js.map +1 -0
  49. package/dist/migrations/v6-to-v7.test.d.ts +2 -0
  50. package/dist/migrations/v6-to-v7.test.d.ts.map +1 -0
  51. package/dist/migrations/v6-to-v7.test.js +33 -0
  52. package/dist/migrations/v6-to-v7.test.js.map +1 -0
  53. package/dist/migrations/v7-to-v8.d.ts +3 -0
  54. package/dist/migrations/v7-to-v8.d.ts.map +1 -0
  55. package/dist/migrations/v7-to-v8.js +20 -0
  56. package/dist/migrations/v7-to-v8.js.map +1 -0
  57. package/dist/migrations/v8-to-v9.d.ts +3 -0
  58. package/dist/migrations/v8-to-v9.d.ts.map +1 -0
  59. package/dist/migrations/v8-to-v9.js +18 -0
  60. package/dist/migrations/v8-to-v9.js.map +1 -0
  61. package/dist/preference-conflicts.d.ts +19 -0
  62. package/dist/preference-conflicts.d.ts.map +1 -0
  63. package/dist/preference-conflicts.js +293 -0
  64. package/dist/preference-conflicts.js.map +1 -0
  65. package/dist/preference-conflicts.test.d.ts +2 -0
  66. package/dist/preference-conflicts.test.d.ts.map +1 -0
  67. package/dist/preference-conflicts.test.js +72 -0
  68. package/dist/preference-conflicts.test.js.map +1 -0
  69. package/package.json +4 -4
@@ -2,11 +2,15 @@ import Database from 'better-sqlite3';
2
2
  import * as sqliteVec from 'sqlite-vec';
3
3
  import { dirname, join } from 'node:path';
4
4
  import { existsSync, mkdirSync } from 'node:fs';
5
- import { randomUUID } from 'node:crypto';
5
+ import { createHash, randomUUID } from 'node:crypto';
6
6
  import { applyV2ToV3Migration } from './migrations/v2-to-v3.js';
7
7
  import { applyV3ToV4Migration } from './migrations/v3-to-v4.js';
8
8
  import { applyV4ToV5Migration } from './migrations/v4-to-v5.js';
9
+ import { applyV6ToV7Migration } from './migrations/v6-to-v7.js';
10
+ import { applyV7ToV8Migration } from './migrations/v7-to-v8.js';
11
+ import { applyV8ToV9Migration } from './migrations/v8-to-v9.js';
9
12
  import { observationDedupKey, } from './observation-types.js';
13
+ import { analysesConflict, analyzePreference, analyzePreferenceObservation, } from './preference-conflicts.js';
10
14
  // ── Schema ─────────────────────────────────────────────
11
15
  const SCHEMA_V2 = `
12
16
  -- ============================================================
@@ -169,7 +173,10 @@ CREATE TABLE IF NOT EXISTS observations (
169
173
  source_event_ids TEXT NOT NULL DEFAULT '[]',
170
174
  memory_id TEXT,
171
175
  embedding BLOB,
172
- timestamp INTEGER NOT NULL
176
+ timestamp INTEGER NOT NULL,
177
+ explicit_accepted_at INTEGER,
178
+ evidence_status TEXT NOT NULL DEFAULT 'unverified',
179
+ evidence_details TEXT NOT NULL DEFAULT '[]'
173
180
  );
174
181
  CREATE INDEX IF NOT EXISTS idx_observations_session ON observations(session_id);
175
182
  CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
@@ -256,6 +263,126 @@ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
256
263
  VALUES (new.rowid, new.id, new.content, new.scope);
257
264
  END;
258
265
  `;
266
+ const OBSERVATION_FTS_SCHEMA = `
267
+ CREATE VIRTUAL TABLE IF NOT EXISTS observations_fts USING fts5(
268
+ id UNINDEXED,
269
+ title,
270
+ narrative,
271
+ facts,
272
+ concepts,
273
+ tokenize='trigram'
274
+ );
275
+
276
+ CREATE VIRTUAL TABLE IF NOT EXISTS observations_terms_fts USING fts5(
277
+ id UNINDEXED,
278
+ title,
279
+ narrative,
280
+ facts,
281
+ concepts,
282
+ tokenize = "unicode61 tokenchars '#+'"
283
+ );
284
+
285
+ CREATE TRIGGER IF NOT EXISTS observations_fts_ai
286
+ AFTER INSERT ON observations
287
+ WHEN new.quality_status = 'accepted' AND new.resolution_state = 'current'
288
+ BEGIN
289
+ INSERT INTO observations_fts(rowid, id, title, narrative, facts, concepts)
290
+ VALUES (new.rowid, new.id, new.title, new.narrative, new.facts, new.concepts);
291
+ INSERT INTO observations_terms_fts(rowid, id, title, narrative, facts, concepts)
292
+ VALUES (new.rowid, new.id, new.title, new.narrative, new.facts, new.concepts);
293
+ END;
294
+
295
+ CREATE TRIGGER IF NOT EXISTS observations_fts_ad
296
+ AFTER DELETE ON observations
297
+ WHEN old.quality_status = 'accepted' AND old.resolution_state = 'current'
298
+ BEGIN
299
+ DELETE FROM observations_fts WHERE rowid = old.rowid;
300
+ DELETE FROM observations_terms_fts WHERE rowid = old.rowid;
301
+ END;
302
+
303
+ CREATE TRIGGER IF NOT EXISTS observations_fts_au
304
+ AFTER UPDATE OF title, narrative, facts, concepts, quality_status, resolution_state ON observations
305
+ BEGIN
306
+ DELETE FROM observations_fts WHERE rowid = old.rowid;
307
+ DELETE FROM observations_terms_fts WHERE rowid = old.rowid;
308
+ INSERT INTO observations_fts(rowid, id, title, narrative, facts, concepts)
309
+ SELECT new.rowid, new.id, new.title, new.narrative, new.facts, new.concepts
310
+ WHERE new.quality_status = 'accepted' AND new.resolution_state = 'current';
311
+ INSERT INTO observations_terms_fts(rowid, id, title, narrative, facts, concepts)
312
+ SELECT new.rowid, new.id, new.title, new.narrative, new.facts, new.concepts
313
+ WHERE new.quality_status = 'accepted' AND new.resolution_state = 'current';
314
+ END;
315
+ `;
316
+ const MAX_OBSERVATION_FTS_QUERY_LENGTH = 512;
317
+ const MAX_OBSERVATION_FTS_TERMS = 16;
318
+ const MAX_OBSERVATION_FTS_TERM_LENGTH = 64;
319
+ const OBSERVATION_FTS_SCHEMA_VERSION = 6;
320
+ const OBSERVATION_EXCLUSION_CTE = `
321
+ WITH excluded_observation_ids(id) AS (
322
+ SELECT CAST(value AS TEXT) FROM json_each(?)
323
+ )
324
+ `;
325
+ function serializeObservationSearchExcludedIds(excludedIds) {
326
+ return JSON.stringify(excludedIds ? [...excludedIds] : []);
327
+ }
328
+ function normalizeObservationLexicalQuery(query) {
329
+ const phrase = query
330
+ .slice(0, MAX_OBSERVATION_FTS_QUERY_LENGTH)
331
+ .normalize('NFKC')
332
+ .slice(0, MAX_OBSERVATION_FTS_QUERY_LENGTH)
333
+ .replace(/\s+/gu, ' ')
334
+ .trim();
335
+ if (!phrase)
336
+ return { trigramQuery: null, termQuery: null };
337
+ const lexicalTerms = phrase.match(/[\p{L}\p{N}]+/gu) ?? [];
338
+ const compact = lexicalTerms.join('');
339
+ const candidates = [compact, phrase, ...lexicalTerms];
340
+ const trigramTerms = [];
341
+ const termTerms = [];
342
+ const seenTrigrams = new Set();
343
+ const seenTerms = new Set();
344
+ for (const candidate of candidates) {
345
+ const term = [...candidate].slice(0, MAX_OBSERVATION_FTS_TERM_LENGTH).join('');
346
+ const searchableCharacters = term.match(/[\p{L}\p{N}]/gu) ?? [];
347
+ const normalized = term.toLocaleLowerCase('en-US');
348
+ const quoted = `"${term.replaceAll('"', '""')}"`;
349
+ if (searchableCharacters.length >= 3 && !seenTrigrams.has(normalized)) {
350
+ seenTrigrams.add(normalized);
351
+ trigramTerms.push(quoted);
352
+ }
353
+ if (searchableCharacters.length >= 1 && !seenTerms.has(normalized)) {
354
+ seenTerms.add(normalized);
355
+ termTerms.push(quoted);
356
+ }
357
+ if (trigramTerms.length >= MAX_OBSERVATION_FTS_TERMS
358
+ && termTerms.length >= MAX_OBSERVATION_FTS_TERMS)
359
+ break;
360
+ }
361
+ return {
362
+ trigramQuery: trigramTerms.length > 0
363
+ ? trigramTerms.slice(0, MAX_OBSERVATION_FTS_TERMS).join(' OR ')
364
+ : null,
365
+ termQuery: termTerms.length > 0
366
+ ? termTerms.slice(0, MAX_OBSERVATION_FTS_TERMS).join(' OR ')
367
+ : null,
368
+ };
369
+ }
370
+ export function isRecoverableObservationFtsError(error) {
371
+ const candidate = error;
372
+ const code = typeof candidate?.code === 'string' ? candidate.code.toUpperCase() : '';
373
+ const message = typeof candidate?.message === 'string' ? candidate.message : '';
374
+ if (/^SQLITE_(?:BUSY|LOCKED|READONLY|IOERR|FULL|CANTOPEN|PERM)/.test(code))
375
+ return false;
376
+ if (code === 'SQLITE_CORRUPT_VTAB')
377
+ return true;
378
+ const referencesObservationFts = /observations_(?:terms_)?fts(?:_(?:data|idx|content|docsize|config))?/i
379
+ .test(message);
380
+ if (!referencesObservationFts)
381
+ return false;
382
+ if (/^SQLITE_CORRUPT(?:_|$)/.test(code))
383
+ return true;
384
+ return code === 'SQLITE_ERROR' && /no such table|malformed schema|vtable constructor failed/i.test(message);
385
+ }
259
386
  // ── MemoryStore ─────────────────────────────────────────
260
387
  export class MemoryStore {
261
388
  db;
@@ -275,10 +402,15 @@ export class MemoryStore {
275
402
  sqliteVec.load(this.db);
276
403
  this.db.exec(SCHEMA_V2);
277
404
  this.db.exec(FTS_TRIGGERS);
405
+ this.dropOrphanedObservationFtsTriggers();
278
406
  this.migrateV1ToV2();
279
407
  applyV2ToV3Migration(this.db);
280
408
  applyV3ToV4Migration(this.db);
281
409
  applyV4ToV5Migration(this.db);
410
+ this.ensureObservationFts();
411
+ applyV6ToV7Migration(this.db);
412
+ applyV7ToV8Migration(this.db);
413
+ applyV8ToV9Migration(this.db);
282
414
  this.repairOrphanSessions(rootPath);
283
415
  console.log('[MemoryStore] Schema initialized', {
284
416
  dbPath: this.dbPath,
@@ -292,6 +424,66 @@ export class MemoryStore {
292
424
  const row = this.db.prepare("SELECT COUNT(*) as c FROM sqlite_master WHERE type='table'").get();
293
425
  return row.c;
294
426
  }
427
+ dropOrphanedObservationFtsTriggers() {
428
+ const ftsTables = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('observations_fts', 'observations_terms_fts')").all();
429
+ if (ftsTables.length === 2)
430
+ return;
431
+ this.db.exec(`
432
+ DROP TRIGGER IF EXISTS observations_fts_ai;
433
+ DROP TRIGGER IF EXISTS observations_fts_ad;
434
+ DROP TRIGGER IF EXISTS observations_fts_au;
435
+ `);
436
+ }
437
+ ensureObservationFts(forceRebuild = false) {
438
+ this.db.transaction(() => {
439
+ const ftsTableCount = this.db.prepare("SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN ('observations_fts', 'observations_terms_fts')").get().count;
440
+ const migrationApplied = this.db.prepare('SELECT 1 FROM schema_versions WHERE version = ?').get(OBSERVATION_FTS_SCHEMA_VERSION) !== undefined;
441
+ const triggersCurrent = this.db.prepare(`
442
+ SELECT COUNT(*) AS count
443
+ FROM sqlite_master
444
+ WHERE type = 'trigger'
445
+ AND name IN ('observations_fts_ai', 'observations_fts_ad', 'observations_fts_au')
446
+ AND sql LIKE '%observations_terms_fts%'
447
+ `).get().count === 3;
448
+ const needsRebuild = forceRebuild
449
+ || ftsTableCount !== 2
450
+ || !migrationApplied
451
+ || !triggersCurrent;
452
+ if (needsRebuild) {
453
+ this.db.exec(`
454
+ DROP TRIGGER IF EXISTS observations_fts_ai;
455
+ DROP TRIGGER IF EXISTS observations_fts_ad;
456
+ DROP TRIGGER IF EXISTS observations_fts_au;
457
+ `);
458
+ }
459
+ if (forceRebuild) {
460
+ this.db.exec(`
461
+ DROP TABLE IF EXISTS observations_fts;
462
+ DROP TABLE IF EXISTS observations_terms_fts;
463
+ `);
464
+ }
465
+ this.db.exec(OBSERVATION_FTS_SCHEMA);
466
+ if (!needsRebuild)
467
+ return;
468
+ this.db.exec(`
469
+ DELETE FROM observations_fts;
470
+ DELETE FROM observations_terms_fts;
471
+
472
+ INSERT INTO observations_fts(rowid, id, title, narrative, facts, concepts)
473
+ SELECT o.rowid, o.id, o.title, o.narrative, o.facts, o.concepts
474
+ FROM observations o
475
+ WHERE o.quality_status = 'accepted'
476
+ AND o.resolution_state = 'current';
477
+
478
+ INSERT INTO observations_terms_fts(rowid, id, title, narrative, facts, concepts)
479
+ SELECT o.rowid, o.id, o.title, o.narrative, o.facts, o.concepts
480
+ FROM observations o
481
+ WHERE o.quality_status = 'accepted'
482
+ AND o.resolution_state = 'current';
483
+ `);
484
+ this.db.prepare('INSERT OR REPLACE INTO schema_versions(version, applied_at) VALUES (?, ?)').run(OBSERVATION_FTS_SCHEMA_VERSION, Date.now());
485
+ })();
486
+ }
295
487
  // ── V1 → V2 Migration ────────────────────────────
296
488
  migrateV1ToV2() {
297
489
  const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name);
@@ -503,16 +695,18 @@ export class MemoryStore {
503
695
  const row = this.db.prepare('SELECT vector FROM embeddings WHERE source_type = ? AND source_id = ?').get(sourceType, sourceId);
504
696
  return row?.vector ?? null;
505
697
  }
506
- searchObservationVector(embedding, limit = 20) {
698
+ searchObservationVector(embedding, limit = 20, excludedIds) {
507
699
  const buffer = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
508
- const rows = this.db.prepare(`
700
+ const excludedJson = serializeObservationSearchExcludedIds(excludedIds);
701
+ const rows = this.db.prepare(`${OBSERVATION_EXCLUSION_CTE}
509
702
  SELECT o.*, vec_distance_cosine(o.embedding, ?) AS distance
510
703
  FROM observations o
511
704
  WHERE o.embedding IS NOT NULL
512
705
  AND o.quality_status = 'accepted'
513
706
  AND o.resolution_state <> 'superseded'
707
+ AND o.id NOT IN (SELECT id FROM excluded_observation_ids)
514
708
  ORDER BY distance LIMIT ?
515
- `).all(buffer, limit);
709
+ `).all(excludedJson, buffer, limit);
516
710
  return rows.map(row => ({ observation: row, distance: row.distance }));
517
711
  }
518
712
  // ── Search ───────────────────────────────────────
@@ -525,6 +719,66 @@ export class MemoryStore {
525
719
  ORDER BY rank LIMIT ?
526
720
  `).all(query, limit);
527
721
  }
722
+ searchObservationBM25(query, limit = 20, excludedIds) {
723
+ const normalizedQuery = normalizeObservationLexicalQuery(query);
724
+ if ((!normalizedQuery.trigramQuery && !normalizedQuery.termQuery) || limit <= 0)
725
+ return [];
726
+ const boundedLimit = Number.isFinite(limit)
727
+ ? Math.min(Math.max(Math.floor(limit), 1), 100)
728
+ : 20;
729
+ const excludedJson = serializeObservationSearchExcludedIds(excludedIds);
730
+ const scores = new Map();
731
+ const addRanked = (rows) => {
732
+ for (let index = 0; index < rows.length; index++) {
733
+ const id = rows[index].id;
734
+ scores.set(id, (scores.get(id) ?? 0) + 1 / (60 + index + 1));
735
+ }
736
+ };
737
+ if (normalizedQuery.trigramQuery) {
738
+ addRanked(this.searchObservationFtsWithRecovery('observations_fts', normalizedQuery.trigramQuery, boundedLimit, excludedJson));
739
+ }
740
+ if (normalizedQuery.termQuery) {
741
+ addRanked(this.searchObservationFtsWithRecovery('observations_terms_fts', normalizedQuery.termQuery, boundedLimit, excludedJson));
742
+ }
743
+ return [...scores]
744
+ .map(([id, score]) => ({ id, rank: -score }))
745
+ .sort((left, right) => left.rank - right.rank || left.id.localeCompare(right.id))
746
+ .slice(0, boundedLimit);
747
+ }
748
+ searchObservationFtsWithRecovery(table, query, limit, excludedJson) {
749
+ try {
750
+ return this.searchObservationFts(table, query, limit, excludedJson);
751
+ }
752
+ catch (error) {
753
+ if (!isRecoverableObservationFtsError(error))
754
+ throw error;
755
+ this.ensureObservationFts(true);
756
+ return this.searchObservationFts(table, query, limit, excludedJson);
757
+ }
758
+ }
759
+ searchObservationFts(table, query, limit, excludedJson) {
760
+ const sql = table === 'observations_fts' ? `${OBSERVATION_EXCLUSION_CTE}
761
+ SELECT o.id, bm25(observations_fts) AS rank
762
+ FROM observations_fts f
763
+ JOIN observations o ON o.rowid = f.rowid
764
+ WHERE observations_fts MATCH ?
765
+ AND o.quality_status = 'accepted'
766
+ AND o.resolution_state = 'current'
767
+ AND o.id NOT IN (SELECT id FROM excluded_observation_ids)
768
+ ORDER BY rank LIMIT ?
769
+ ` : `${OBSERVATION_EXCLUSION_CTE}
770
+ SELECT o.id, bm25(observations_terms_fts) AS rank
771
+ FROM observations_terms_fts f
772
+ JOIN observations o ON o.rowid = f.rowid
773
+ WHERE observations_terms_fts MATCH ?
774
+ AND o.quality_status = 'accepted'
775
+ AND o.resolution_state = 'current'
776
+ AND o.id NOT IN (SELECT id FROM excluded_observation_ids)
777
+ ORDER BY rank, updated_at DESC
778
+ LIMIT ?
779
+ `;
780
+ return this.db.prepare(sql).all(excludedJson, query, limit);
781
+ }
528
782
  searchVector(embedding, limit = 20) {
529
783
  const buffer = Buffer.from(embedding.buffer);
530
784
  return this.db.prepare(`
@@ -569,6 +823,123 @@ export class MemoryStore {
569
823
  getSessionEvents(sessionId) {
570
824
  return this.db.prepare('SELECT * FROM session_events WHERE session_id = ? ORDER BY created_at ASC').all(sessionId);
571
825
  }
826
+ reconcileDecidedTurnEventWindows(sessionId, turns, sessionEndAt = Number.MAX_SAFE_INTEGER) {
827
+ const normalizedSessionId = sessionId.trim();
828
+ if (!normalizedSessionId)
829
+ throw new Error('Session event reconciliation requires a session ID');
830
+ if (!Number.isSafeInteger(sessionEndAt) || sessionEndAt < 0) {
831
+ throw new Error('Session event reconciliation requires a safe session end timestamp');
832
+ }
833
+ const orderedTurns = turns.filter(turn => turn.sessionId === normalizedSessionId);
834
+ if (orderedTurns.length !== turns.length) {
835
+ throw new Error('Memory turn session does not match event reconciliation session');
836
+ }
837
+ for (let index = 1; index < orderedTurns.length; index += 1) {
838
+ if (orderedTurns[index].createdAt < orderedTurns[index - 1].createdAt) {
839
+ throw new Error('Memory turns must be ordered by createdAt for event reconciliation');
840
+ }
841
+ }
842
+ const selectSessionEvents = this.db.prepare(`
843
+ SELECT id, created_at FROM session_events
844
+ WHERE session_id = ? AND processed = 0 AND maintenance_status = 'active'
845
+ ORDER BY created_at ASC, id ASC
846
+ `);
847
+ const markCommitted = this.db.prepare(`
848
+ UPDATE session_events SET processed = 1
849
+ WHERE id = ? AND session_id = ? AND processed = 0 AND maintenance_status = 'active'
850
+ `);
851
+ const markSkipped = this.db.prepare(`
852
+ UPDATE session_events
853
+ SET processed = 1, maintenance_status = 'discarded', quarantine_reason = ?
854
+ WHERE id = ? AND session_id = ? AND processed = 0 AND maintenance_status = 'active'
855
+ `);
856
+ const insertAudit = this.db.prepare(`
857
+ INSERT INTO event_processing_audit
858
+ (id, event_id, disposition, reason, run_id, timestamp)
859
+ VALUES (?, ?, ?, ?, ?, ?)
860
+ `);
861
+ const countAuditsForRuns = this.db.prepare(`
862
+ WITH requested_runs(run_id) AS (
863
+ SELECT DISTINCT value FROM json_each(?)
864
+ )
865
+ SELECT audit.disposition, COUNT(*) AS count
866
+ FROM event_processing_audit audit
867
+ INNER JOIN requested_runs requested ON requested.run_id = audit.run_id
868
+ GROUP BY audit.disposition
869
+ `);
870
+ return this.db.transaction(() => {
871
+ const reconciledAt = Date.now();
872
+ const groups = [];
873
+ for (const turn of orderedTurns) {
874
+ const current = groups.at(-1);
875
+ if (current?.[0]?.createdAt === turn.createdAt)
876
+ current.push(turn);
877
+ else
878
+ groups.push([turn]);
879
+ }
880
+ let processedThisRun = 0;
881
+ let discardedThisRun = 0;
882
+ let decidedTurnCount = 0;
883
+ let eventIndex = 0;
884
+ const canonicalRunIds = [];
885
+ const events = selectSessionEvents.all(normalizedSessionId);
886
+ for (let groupIndex = 0; groupIndex < groups.length; groupIndex += 1) {
887
+ const group = groups[groupIndex];
888
+ const windowStart = group[0].createdAt;
889
+ const windowEnd = Math.min(groups[groupIndex + 1]?.[0]?.createdAt ?? sessionEndAt, sessionEndAt);
890
+ while (eventIndex < events.length && events[eventIndex].created_at < windowStart)
891
+ eventIndex += 1;
892
+ if (windowStart >= windowEnd)
893
+ continue;
894
+ const allCanonical = group.every(turn => turn.status !== 'pending' && turn.receiptId?.startsWith('memory-receipt:'));
895
+ const allCommitted = allCanonical && group.every(turn => turn.status === 'committed');
896
+ const allSkipped = allCanonical && group.every(turn => turn.status === 'skipped');
897
+ const disposition = allCommitted ? 'processed' : allSkipped ? 'discarded' : null;
898
+ const canonicalGroup = disposition
899
+ ? group.map(turn => `${turn.turnId}=${turn.receiptId}`).join(',')
900
+ : '';
901
+ const reason = disposition
902
+ ? `canonical_turn_group_${allCommitted ? 'committed' : 'skipped'}:${windowStart}:${canonicalGroup}`
903
+ : '';
904
+ const runId = disposition
905
+ ? `turn-reconcile:${createHash('sha256')
906
+ .update(`${normalizedSessionId}\0${windowStart}\0${allCommitted ? 'committed' : 'skipped'}\0${canonicalGroup}`)
907
+ .digest('hex').slice(0, 32)}`
908
+ : '';
909
+ if (disposition) {
910
+ decidedTurnCount += group.length;
911
+ canonicalRunIds.push(runId);
912
+ }
913
+ while (eventIndex < events.length && events[eventIndex].created_at < windowEnd) {
914
+ const event = events[eventIndex++];
915
+ if (!disposition)
916
+ continue;
917
+ const changed = disposition === 'processed'
918
+ ? markCommitted.run(event.id, normalizedSessionId).changes
919
+ : markSkipped.run(reason, event.id, normalizedSessionId).changes;
920
+ if (changed === 0)
921
+ continue;
922
+ if (disposition === 'processed')
923
+ processedThisRun += changed;
924
+ else
925
+ discardedThisRun += changed;
926
+ insertAudit.run(`epa:${reconciledAt}:${randomUUID().slice(0, 8)}`, event.id, disposition, reason, runId, reconciledAt);
927
+ }
928
+ }
929
+ const historicalCounts = canonicalRunIds.length > 0
930
+ ? countAuditsForRuns.all(JSON.stringify(canonicalRunIds))
931
+ : [];
932
+ const countByDisposition = new Map(historicalCounts.map(row => [row.disposition, Number(row.count)]));
933
+ return {
934
+ sessionId: normalizedSessionId,
935
+ turnCount: orderedTurns.length,
936
+ decidedTurnCount,
937
+ committedProcessed: countByDisposition.get('processed') ?? 0,
938
+ skippedDiscarded: countByDisposition.get('discarded') ?? 0,
939
+ remainingUndecided: events.length - processedThisRun - discardedThisRun,
940
+ };
941
+ })();
942
+ }
572
943
  getUnprocessedEvents() {
573
944
  const events = this.db.prepare(`SELECT * FROM session_events
574
945
  WHERE processed = 0 AND maintenance_status = 'active'
@@ -873,9 +1244,18 @@ export class MemoryStore {
873
1244
  (id, session_id, project, type, title, narrative, facts, files, tools, concepts,
874
1245
  importance, confidence, source, dedup_key, source_event_ids, timestamp, updated_at,
875
1246
  quality_status, quarantine_reason, quality_details, quarantined_at,
876
- failure_category, resolution_state)
877
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
878
- `).run(id, obs.sessionId, obs.project ?? this.defaultProjectRoot, obs.type, obs.title, obs.narrative ?? '', JSON.stringify(obs.facts ?? []), JSON.stringify(obs.files ?? []), JSON.stringify(obs.tools ?? []), JSON.stringify(obs.concepts ?? []), obs.importance ?? 5, obs.confidence ?? 0.5, obs.source ?? 'distill', dedupKey, JSON.stringify(obs.sourceEventIds ?? []), now, now, obs.qualityStatus ?? 'accepted', obs.quarantineReason ?? null, obs.qualityDetails ? JSON.stringify(obs.qualityDetails) : null, obs.qualityStatus === 'quarantined' ? now : null, obs.failureCategory ?? null, obs.resolutionState ?? 'current');
1247
+ failure_category, resolution_state, evidence_status, evidence_details)
1248
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1249
+ `).run(id, obs.sessionId, obs.project ?? this.defaultProjectRoot, obs.type, obs.title, obs.narrative ?? '', JSON.stringify(obs.facts ?? []), JSON.stringify(obs.files ?? []), JSON.stringify(obs.tools ?? []), JSON.stringify(obs.concepts ?? []), obs.importance ?? 5, obs.confidence ?? 0.5, obs.source ?? 'distill', dedupKey, JSON.stringify(obs.sourceEventIds ?? []), now, now, obs.qualityStatus ?? 'accepted', obs.quarantineReason ?? null, obs.qualityDetails ? JSON.stringify(obs.qualityDetails) : null, obs.qualityStatus === 'quarantined' ? now : null, obs.failureCategory ?? null, obs.resolutionState ?? 'current', obs.evidenceStatus
1250
+ ?? (obs.source === 'explicit_intent'
1251
+ ? 'unverified'
1252
+ : obs.source === 'manual' || (obs.sourceEventIds?.length ?? 0) > 0
1253
+ ? 'verified'
1254
+ : 'unverified'), JSON.stringify(obs.evidenceDetails ?? (obs.sourceEventIds ?? []).map(source => ({
1255
+ kind: 'source_event',
1256
+ source,
1257
+ summary: 'Observation was accepted from an evidence-bound session event.',
1258
+ }))));
879
1259
  // Insert concept junction rows
880
1260
  if (obs.concepts && obs.concepts.length > 0) {
881
1261
  const insertConcept = this.db.prepare('INSERT OR IGNORE INTO observation_concepts (observation_id, concept) VALUES (?, ?)');
@@ -895,6 +1275,197 @@ export class MemoryStore {
895
1275
  return this.getObservation(row.id);
896
1276
  })();
897
1277
  }
1278
+ acceptExplicitPreference(input) {
1279
+ return this.db.transaction(() => {
1280
+ this.ensureSession(input.sessionId, input.project);
1281
+ const latest = this.db.prepare(`
1282
+ SELECT MAX(COALESCE(explicit_accepted_at, timestamp)) AS latest
1283
+ FROM observations
1284
+ WHERE project = ? AND type IN ('user_preference', 'project_convention', 'decision_made')
1285
+ AND source = 'explicit_intent'
1286
+ `).get(input.project);
1287
+ const acceptedAt = Math.max(Date.now(), (latest.latest ?? 0) + 1);
1288
+ const dedupKey = observationDedupKey('user_preference', input.title);
1289
+ const exact = this.db.prepare('SELECT * FROM observations WHERE dedup_key = ? AND project = ?').get(dedupKey, input.project) ?? null;
1290
+ let row;
1291
+ if (exact) {
1292
+ const contentChanged = exact.title !== input.title || exact.narrative !== input.content;
1293
+ const sourceEventIds = new Set(parseStringArray(exact.source_event_ids));
1294
+ if (input.sourceEventId)
1295
+ sourceEventIds.add(input.sourceEventId);
1296
+ this.db.prepare(`
1297
+ UPDATE observations
1298
+ SET title = ?, narrative = ?, source = 'explicit_intent',
1299
+ confidence = MAX(confidence, 0.95),
1300
+ importance = MAX(importance, 10), quality_status = 'accepted',
1301
+ quarantine_reason = NULL, quality_details = NULL, quarantined_at = NULL,
1302
+ resolution_state = 'current', superseded_by_observation = NULL,
1303
+ source_event_ids = ?, updated_at = ?, explicit_accepted_at = ?,
1304
+ embedding = CASE WHEN ? = 1 THEN NULL ELSE embedding END
1305
+ WHERE id = ?
1306
+ `).run(input.title, input.content, JSON.stringify([...sourceEventIds]), acceptedAt, acceptedAt, contentChanged ? 1 : 0, exact.id);
1307
+ if (contentChanged) {
1308
+ this.db.prepare("DELETE FROM embeddings WHERE source_type = 'observation' AND source_id = ?").run(exact.id);
1309
+ }
1310
+ this.incrementObservationAccess(exact.id, acceptedAt);
1311
+ row = this.getObservation(exact.id);
1312
+ }
1313
+ else {
1314
+ row = this.addObservation({
1315
+ sessionId: input.sessionId,
1316
+ project: input.project,
1317
+ type: 'user_preference',
1318
+ title: input.title,
1319
+ narrative: input.content,
1320
+ importance: 10,
1321
+ confidence: 0.95,
1322
+ source: 'explicit_intent',
1323
+ sourceEventIds: input.sourceEventId ? [input.sourceEventId] : [],
1324
+ timestamp: acceptedAt,
1325
+ qualityStatus: 'accepted',
1326
+ });
1327
+ this.db.prepare('UPDATE observations SET explicit_accepted_at = ? WHERE id = ?').run(acceptedAt, row.id);
1328
+ row = this.getObservation(row.id);
1329
+ }
1330
+ this.supersedeConflictingExplicitPreferences(row, analyzePreference(input.content));
1331
+ if (input.sourceEventId)
1332
+ this.markEventsProcessed([input.sourceEventId]);
1333
+ return this.getObservation(row.id);
1334
+ })();
1335
+ }
1336
+ applyExplicitMemoryEnrichment(observationId, enrichment) {
1337
+ return this.db.transaction(() => {
1338
+ const current = this.getObservation(observationId);
1339
+ if (!current)
1340
+ throw new Error(`Observation ${observationId} not found`);
1341
+ if (current.source !== 'explicit_intent') {
1342
+ throw new Error(`Observation ${observationId} is not an explicit memory`);
1343
+ }
1344
+ const dedupKey = observationDedupKey(enrichment.type, enrichment.title);
1345
+ const collisions = this.db.prepare(`
1346
+ SELECT id, timestamp, explicit_accepted_at FROM observations
1347
+ WHERE project = ? AND dedup_key = ? AND id <> ?
1348
+ `).all(current.project, dedupKey, observationId);
1349
+ let dedupKeyForCurrent = dedupKey;
1350
+ if (collisions.length > 0) {
1351
+ const candidates = [
1352
+ {
1353
+ id: current.id,
1354
+ timestamp: current.timestamp,
1355
+ explicit_accepted_at: current.explicit_accepted_at,
1356
+ },
1357
+ ...collisions,
1358
+ ].sort((left, right) => (right.explicit_accepted_at ?? right.timestamp) - (left.explicit_accepted_at ?? left.timestamp)
1359
+ || right.id.localeCompare(left.id));
1360
+ const winner = candidates[0];
1361
+ const now = Date.now();
1362
+ const supersede = this.db.prepare(`
1363
+ UPDATE observations
1364
+ SET resolution_state = 'superseded', superseded_by_observation = ?,
1365
+ dedup_key = NULL, updated_at = ?
1366
+ WHERE id = ?
1367
+ `);
1368
+ for (const candidate of candidates.slice(1)) {
1369
+ supersede.run(winner.id, now, candidate.id);
1370
+ }
1371
+ this.db.prepare(`
1372
+ UPDATE observations
1373
+ SET resolution_state = 'current', superseded_by_observation = NULL,
1374
+ dedup_key = ?, updated_at = ?
1375
+ WHERE id = ?
1376
+ `).run(dedupKey, now, winner.id);
1377
+ dedupKeyForCurrent = winner.id === current.id ? dedupKey : null;
1378
+ }
1379
+ const nextFacts = JSON.stringify(enrichment.facts);
1380
+ const nextFiles = JSON.stringify(enrichment.files);
1381
+ const nextTools = JSON.stringify(enrichment.tools);
1382
+ const nextConcepts = JSON.stringify(enrichment.concepts);
1383
+ const semanticContentChanged = current.type !== enrichment.type
1384
+ || current.title !== enrichment.title
1385
+ || current.narrative !== enrichment.narrative
1386
+ || current.facts !== nextFacts
1387
+ || current.concepts !== nextConcepts;
1388
+ const now = Date.now();
1389
+ this.db.prepare(`
1390
+ UPDATE observations
1391
+ SET type = ?, title = ?, narrative = ?, facts = ?, files = ?, tools = ?, concepts = ?,
1392
+ dedup_key = ?, evidence_status = ?, evidence_details = ?, updated_at = ?,
1393
+ embedding = CASE WHEN ? = 1 THEN NULL ELSE embedding END
1394
+ WHERE id = ?
1395
+ `).run(enrichment.type, enrichment.title, enrichment.narrative, nextFacts, nextFiles, nextTools, nextConcepts, dedupKeyForCurrent, enrichment.evidenceStatus, JSON.stringify(enrichment.evidence), now, semanticContentChanged ? 1 : 0, observationId);
1396
+ if (semanticContentChanged) {
1397
+ this.db.prepare("DELETE FROM embeddings WHERE source_type = 'observation' AND source_id = ?").run(observationId);
1398
+ }
1399
+ this.db.prepare('DELETE FROM observation_concepts WHERE observation_id = ?').run(observationId);
1400
+ const insertConcept = this.db.prepare('INSERT OR IGNORE INTO observation_concepts (observation_id, concept) VALUES (?, ?)');
1401
+ for (const concept of enrichment.concepts)
1402
+ insertConcept.run(observationId, concept);
1403
+ this.reconcileExplicitPreferenceConflicts(current.project);
1404
+ return this.getObservation(observationId);
1405
+ })();
1406
+ }
1407
+ reconcileExplicitPreferenceConflicts(project = this.defaultProjectRoot) {
1408
+ return this.db.transaction(() => {
1409
+ const rows = this.listCurrentExplicitPreferences(project).sort((left, right) => this.explicitPreferenceAcceptanceTime(left)
1410
+ - this.explicitPreferenceAcceptanceTime(right)
1411
+ || left.timestamp - right.timestamp
1412
+ || left.id.localeCompare(right.id));
1413
+ let superseded = 0;
1414
+ const active = [];
1415
+ for (const row of rows) {
1416
+ const analysis = analyzePreferenceObservation(row);
1417
+ if (analysis.status !== 'high_confidence') {
1418
+ active.push({ row, analysis });
1419
+ continue;
1420
+ }
1421
+ for (let index = active.length - 1; index >= 0; index--) {
1422
+ const previous = active[index];
1423
+ if (!analysesConflict(previous.analysis, analysis))
1424
+ continue;
1425
+ this.db.prepare(`
1426
+ UPDATE observations
1427
+ SET resolution_state = 'superseded', superseded_by_observation = ?
1428
+ WHERE id = ? AND resolution_state = 'current'
1429
+ `).run(row.id, previous.row.id);
1430
+ active.splice(index, 1);
1431
+ superseded += 1;
1432
+ }
1433
+ active.push({ row, analysis });
1434
+ }
1435
+ return superseded;
1436
+ })();
1437
+ }
1438
+ listCurrentExplicitPreferences(project = this.defaultProjectRoot) {
1439
+ return this.db.prepare(`
1440
+ SELECT * FROM observations
1441
+ WHERE project = ? AND type IN ('user_preference', 'project_convention', 'decision_made')
1442
+ AND source = 'explicit_intent'
1443
+ AND quality_status = 'accepted' AND resolution_state = 'current'
1444
+ ORDER BY COALESCE(explicit_accepted_at, timestamp) DESC, timestamp DESC, id DESC
1445
+ `).all(project);
1446
+ }
1447
+ explicitPreferenceAcceptanceTime(row) {
1448
+ return row.explicit_accepted_at ?? row.timestamp;
1449
+ }
1450
+ supersedeConflictingExplicitPreferences(winner, winnerAnalysis) {
1451
+ if (winnerAnalysis.status !== 'high_confidence')
1452
+ return 0;
1453
+ const update = this.db.prepare(`
1454
+ UPDATE observations
1455
+ SET resolution_state = 'superseded', superseded_by_observation = ?
1456
+ WHERE id = ? AND resolution_state = 'current'
1457
+ `);
1458
+ let superseded = 0;
1459
+ for (const candidate of this.listCurrentExplicitPreferences(winner.project)) {
1460
+ if (candidate.id === winner.id)
1461
+ continue;
1462
+ const analysis = analyzePreferenceObservation(candidate);
1463
+ if (!analysesConflict(analysis, winnerAnalysis))
1464
+ continue;
1465
+ superseded += update.run(winner.id, candidate.id).changes;
1466
+ }
1467
+ return superseded;
1468
+ }
898
1469
  getObservation(id) {
899
1470
  return this.db.prepare('SELECT * FROM observations WHERE id = ?').get(id) ?? null;
900
1471
  }
@@ -935,7 +1506,7 @@ export class MemoryStore {
935
1506
  this.recordRetrievalAccess({
936
1507
  sourceType: 'observation',
937
1508
  sourceId: id,
938
- accessType: 'operational',
1509
+ accessType: 'adopted',
939
1510
  purpose: 'dedup_or_reinforcement',
940
1511
  timestamp: now,
941
1512
  });
@@ -1125,7 +1696,9 @@ export class MemoryStore {
1125
1696
  const timestamp = input.timestamp ?? Date.now();
1126
1697
  const id = `racc:${timestamp}:${randomUUID().slice(0, 8)}`;
1127
1698
  this.db.transaction(() => {
1128
- if (input.sourceType === 'memory') {
1699
+ const accessType = input.accessType ?? 'exposed';
1700
+ const reinforces = accessType === 'adopted';
1701
+ if (input.sourceType === 'memory' && reinforces) {
1129
1702
  const updated = this.db.prepare(`
1130
1703
  UPDATE memories SET access_count = access_count + 1,
1131
1704
  last_reinforced_at = COALESCE(last_reinforced_at, ?),
@@ -1134,17 +1707,13 @@ export class MemoryStore {
1134
1707
  `).run(timestamp, timestamp, timestamp, input.sourceId);
1135
1708
  if (updated.changes !== 1)
1136
1709
  throw new Error(`Memory ${input.sourceId} not found`);
1137
- const legacyAccessTypes = new Set(['search_hit', 'user_view', 'agent_use', 'manual_ref']);
1138
- const accessType = legacyAccessTypes.has(input.accessType ?? '')
1139
- ? input.accessType
1140
- : 'agent_use';
1141
1710
  this.db.prepare(`
1142
1711
  INSERT INTO access_log
1143
1712
  (id, memory_id, access_type, session_id, timestamp, query, purpose, rank, score)
1144
1713
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1145
- `).run(`acc:${timestamp}:${randomUUID().slice(0, 8)}`, input.sourceId, accessType, input.sessionId ?? null, timestamp, input.query ?? null, input.purpose ?? null, input.rank ?? null, input.score ?? null);
1714
+ `).run(`acc:${timestamp}:${randomUUID().slice(0, 8)}`, input.sourceId, 'agent_use', input.sessionId ?? null, timestamp, input.query ?? null, input.purpose ?? null, input.rank ?? null, input.score ?? null);
1146
1715
  }
1147
- else {
1716
+ else if (input.sourceType === 'observation' && reinforces) {
1148
1717
  const updated = this.db.prepare(`
1149
1718
  UPDATE observations
1150
1719
  SET access_count = access_count + 1, updated_at = ?
@@ -1155,9 +1724,10 @@ export class MemoryStore {
1155
1724
  }
1156
1725
  this.db.prepare(`
1157
1726
  INSERT INTO retrieval_access_log
1158
- (id, source_type, source_id, access_type, session_id, query, purpose, rank, score, timestamp)
1159
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1160
- `).run(id, input.sourceType, input.sourceId, input.accessType ?? 'search_hit', input.sessionId ?? null, input.query ?? null, input.purpose ?? null, input.rank ?? null, input.score ?? null, timestamp);
1727
+ (id, source_type, source_id, access_type, project, session_id, request_id,
1728
+ execution_id, query, purpose, rank, score, timestamp)
1729
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1730
+ `).run(id, input.sourceType, input.sourceId, accessType, input.project ?? this.defaultProjectRoot, input.sessionId ?? null, input.requestId ?? null, input.executionId ?? null, input.query ?? null, input.purpose ?? null, input.rank ?? null, input.score ?? null, timestamp);
1161
1731
  })();
1162
1732
  }
1163
1733
  recordAccess(memoryId, accessType, sessionId) {
@@ -1172,7 +1742,7 @@ export class MemoryStore {
1172
1742
  this.recordRetrievalAccess({
1173
1743
  sourceType: 'memory',
1174
1744
  sourceId: id,
1175
- accessType: 'agent_use',
1745
+ accessType: 'adopted',
1176
1746
  purpose: 'reinforcement',
1177
1747
  });
1178
1748
  }
@@ -1181,6 +1751,15 @@ export class MemoryStore {
1181
1751
  SELECT * FROM retrieval_access_log ORDER BY timestamp DESC, id DESC LIMIT ?
1182
1752
  `).all(limit);
1183
1753
  }
1754
+ listRetrievalAccessForAttribution(sessionId, executionId) {
1755
+ return this.db.prepare(`
1756
+ SELECT * FROM retrieval_access_log
1757
+ WHERE session_id = ? AND access_type = 'selected'
1758
+ AND (? IS NULL OR execution_id = ?)
1759
+ ORDER BY timestamp DESC, id DESC
1760
+ LIMIT 500
1761
+ `).all(sessionId, executionId ?? null, executionId ?? null);
1762
+ }
1184
1763
  // ── Memory History ───────────────────────────────
1185
1764
  recordHistory(entry) {
1186
1765
  const id = `hist:${Date.now()}:${randomUUID().slice(0, 8)}`;