@framers/agentos 0.1.105 → 0.1.106

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 (65) hide show
  1. package/dist/api/AgentOS.d.ts +80 -0
  2. package/dist/api/AgentOS.d.ts.map +1 -1
  3. package/dist/api/AgentOS.js +128 -9
  4. package/dist/api/AgentOS.js.map +1 -1
  5. package/dist/api/AgentOSOrchestrator.d.ts.map +1 -1
  6. package/dist/api/AgentOSOrchestrator.js +32 -4
  7. package/dist/api/AgentOSOrchestrator.js.map +1 -1
  8. package/dist/memory/AgentMemory.d.ts +62 -16
  9. package/dist/memory/AgentMemory.d.ts.map +1 -1
  10. package/dist/memory/AgentMemory.js +236 -28
  11. package/dist/memory/AgentMemory.js.map +1 -1
  12. package/dist/memory/consolidation/ConsolidationLoop.d.ts.map +1 -1
  13. package/dist/memory/consolidation/ConsolidationLoop.js +32 -9
  14. package/dist/memory/consolidation/ConsolidationLoop.js.map +1 -1
  15. package/dist/memory/extension/MemoryToolsExtension.d.ts +53 -0
  16. package/dist/memory/extension/MemoryToolsExtension.d.ts.map +1 -0
  17. package/dist/memory/extension/MemoryToolsExtension.js +54 -0
  18. package/dist/memory/extension/MemoryToolsExtension.js.map +1 -0
  19. package/dist/memory/extension/StandaloneMemoryExtension.d.ts +27 -0
  20. package/dist/memory/extension/StandaloneMemoryExtension.d.ts.map +1 -0
  21. package/dist/memory/extension/StandaloneMemoryExtension.js +122 -0
  22. package/dist/memory/extension/StandaloneMemoryExtension.js.map +1 -0
  23. package/dist/memory/facade/Memory.d.ts +45 -0
  24. package/dist/memory/facade/Memory.d.ts.map +1 -1
  25. package/dist/memory/facade/Memory.js +296 -137
  26. package/dist/memory/facade/Memory.js.map +1 -1
  27. package/dist/memory/facade/types.d.ts +12 -8
  28. package/dist/memory/facade/types.d.ts.map +1 -1
  29. package/dist/memory/feedback/RetrievalFeedbackSignal.d.ts +14 -5
  30. package/dist/memory/feedback/RetrievalFeedbackSignal.d.ts.map +1 -1
  31. package/dist/memory/feedback/RetrievalFeedbackSignal.js +131 -20
  32. package/dist/memory/feedback/RetrievalFeedbackSignal.js.map +1 -1
  33. package/dist/memory/index.d.ts +7 -1
  34. package/dist/memory/index.d.ts.map +1 -1
  35. package/dist/memory/index.js +5 -1
  36. package/dist/memory/index.js.map +1 -1
  37. package/dist/memory/integration/StandaloneMemoryBridge.d.ts +55 -0
  38. package/dist/memory/integration/StandaloneMemoryBridge.d.ts.map +1 -0
  39. package/dist/memory/integration/StandaloneMemoryBridge.js +398 -0
  40. package/dist/memory/integration/StandaloneMemoryBridge.js.map +1 -0
  41. package/dist/memory/io/CsvImporter.d.ts +51 -0
  42. package/dist/memory/io/CsvImporter.d.ts.map +1 -0
  43. package/dist/memory/io/CsvImporter.js +229 -0
  44. package/dist/memory/io/CsvImporter.js.map +1 -0
  45. package/dist/memory/io/index.d.ts +2 -0
  46. package/dist/memory/io/index.d.ts.map +1 -1
  47. package/dist/memory/io/index.js +2 -0
  48. package/dist/memory/io/index.js.map +1 -1
  49. package/dist/memory/store/tracePersistence.d.ts +68 -0
  50. package/dist/memory/store/tracePersistence.d.ts.map +1 -0
  51. package/dist/memory/store/tracePersistence.js +159 -0
  52. package/dist/memory/store/tracePersistence.js.map +1 -0
  53. package/dist/memory/tools/MemoryAddTool.d.ts.map +1 -1
  54. package/dist/memory/tools/MemoryAddTool.js +18 -6
  55. package/dist/memory/tools/MemoryAddTool.js.map +1 -1
  56. package/dist/memory/tools/MemoryMergeTool.d.ts.map +1 -1
  57. package/dist/memory/tools/MemoryMergeTool.js +41 -13
  58. package/dist/memory/tools/MemoryMergeTool.js.map +1 -1
  59. package/dist/memory/tools/MemorySearchTool.d.ts.map +1 -1
  60. package/dist/memory/tools/MemorySearchTool.js +24 -4
  61. package/dist/memory/tools/MemorySearchTool.js.map +1 -1
  62. package/dist/memory/tools/MemoryUpdateTool.d.ts.map +1 -1
  63. package/dist/memory/tools/MemoryUpdateTool.js +38 -16
  64. package/dist/memory/tools/MemoryUpdateTool.js.map +1 -1
  65. package/package.json +1 -1
@@ -22,14 +22,18 @@ import fs from 'node:fs/promises';
22
22
  import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { SqliteBrain } from '../store/SqliteBrain.js';
25
+ import { buildNaturalLanguageFtsQuery, buildInitialTraceMetadata, parseTraceMetadata, readPersistedDecayState, withPersistedDecayState, } from '../store/tracePersistence.js';
25
26
  import { SqliteKnowledgeGraph } from '../store/SqliteKnowledgeGraph.js';
26
27
  import { SqliteMemoryGraph } from '../store/SqliteMemoryGraph.js';
27
28
  import { LoaderRegistry } from '../ingestion/LoaderRegistry.js';
28
29
  import { FolderScanner } from '../ingestion/FolderScanner.js';
29
30
  import { ChunkingEngine } from '../ingestion/ChunkingEngine.js';
31
+ import { UrlLoader } from '../ingestion/UrlLoader.js';
30
32
  import { RetrievalFeedbackSignal } from '../feedback/RetrievalFeedbackSignal.js';
31
33
  import { ConsolidationLoop } from '../consolidation/ConsolidationLoop.js';
32
- import { JsonExporter, JsonImporter, MarkdownExporter, MarkdownImporter, ObsidianExporter, ObsidianImporter, SqliteExporter, SqliteImporter, ChatGptImporter, } from '../io/index.js';
34
+ import { penalizeUnused, updateOnRetrieval } from '../decay/DecayModel.js';
35
+ import { JsonExporter, JsonImporter, MarkdownExporter, MarkdownImporter, ObsidianExporter, ObsidianImporter, SqliteExporter, SqliteImporter, ChatGptImporter, CsvImporter, } from '../io/index.js';
36
+ import { MemoryAddTool, MemoryUpdateTool, MemoryDeleteTool, MemoryMergeTool, MemorySearchTool, MemoryReflectTool, } from '../tools/index.js';
33
37
  // ---------------------------------------------------------------------------
34
38
  // Constants & defaults
35
39
  // ---------------------------------------------------------------------------
@@ -104,6 +108,10 @@ export class Memory {
104
108
  decay: true,
105
109
  ...config,
106
110
  };
111
+ if (this._config.store !== 'sqlite') {
112
+ throw new Error(`Memory currently supports only the SQLite-backed facade at runtime. ` +
113
+ `Received store="${this._config.store}".`);
114
+ }
107
115
  // Step 2: create SqliteBrain.
108
116
  this._brain = new SqliteBrain(this._config.path);
109
117
  // Step 3: check embedding dimension compatibility.
@@ -151,22 +159,26 @@ export class Memory {
151
159
  */
152
160
  async remember(content, options) {
153
161
  await this._initPromise;
154
- const id = nextTraceId();
155
- const now = Date.now();
162
+ const contentHash = sha256(content);
156
163
  const type = options?.type ?? 'episodic';
157
164
  const scope = options?.scope ?? 'user';
158
165
  const scopeId = options?.scopeId ?? '';
166
+ const existing = this._findExistingTraceByHash(contentHash, type, scope, scopeId);
167
+ if (existing) {
168
+ return this._buildTrace(existing);
169
+ }
170
+ const id = nextTraceId();
171
+ const now = Date.now();
159
172
  const tags = options?.tags ?? [];
160
173
  const entities = options?.entities ?? [];
161
174
  const importance = options?.importance ?? 1.0;
162
- const contentHash = sha256(content);
163
175
  // Insert into memory_traces.
164
176
  this._brain.db
165
177
  .prepare(`INSERT INTO memory_traces
166
178
  (id, type, scope, content, embedding, strength, created_at,
167
179
  last_accessed, retrieval_count, tags, emotions, metadata, deleted)
168
180
  VALUES (?, ?, ?, ?, NULL, ?, ?, NULL, 0, ?, ?, ?, 0)`)
169
- .run(id, type, scope, content, importance, now, JSON.stringify(tags), JSON.stringify({}), JSON.stringify({ content_hash: contentHash, entities, scopeId }));
181
+ .run(id, type, scope, content, importance, now, JSON.stringify(tags), JSON.stringify({}), JSON.stringify(buildInitialTraceMetadata({}, { contentHash, entities, scopeId })));
170
182
  // Sync FTS5 index. The external-content FTS5 table needs explicit insert.
171
183
  this._brain.db
172
184
  .prepare(`INSERT INTO memory_traces_fts (rowid, content, tags)
@@ -199,7 +211,7 @@ export class Memory {
199
211
  retrieval_count: 0,
200
212
  tags: JSON.stringify(tags),
201
213
  emotions: JSON.stringify({}),
202
- metadata: JSON.stringify({ content_hash: contentHash, entities, scopeId }),
214
+ metadata: JSON.stringify(buildInitialTraceMetadata({}, { contentHash, entities, scopeId })),
203
215
  deleted: 0,
204
216
  });
205
217
  }
@@ -216,6 +228,10 @@ export class Memory {
216
228
  */
217
229
  async recall(query, options) {
218
230
  await this._initPromise;
231
+ const ftsQuery = buildNaturalLanguageFtsQuery(query);
232
+ if (!ftsQuery) {
233
+ return [];
234
+ }
219
235
  const limit = options?.limit ?? 10;
220
236
  const minStrength = options?.minStrength ?? 0;
221
237
  // Build WHERE clause fragments for optional filters.
@@ -229,6 +245,10 @@ export class Memory {
229
245
  conditions.push('t.scope = ?');
230
246
  params.push(options.scope);
231
247
  }
248
+ if (options?.scopeId) {
249
+ conditions.push(`json_extract(t.metadata, '$.scopeId') = ?`);
250
+ params.push(options.scopeId);
251
+ }
232
252
  if (minStrength > 0) {
233
253
  conditions.push('t.strength >= ?');
234
254
  params.push(minStrength);
@@ -247,11 +267,12 @@ export class Memory {
247
267
  ORDER BY (t.strength * abs(fts.rank)) DESC
248
268
  LIMIT ?
249
269
  `;
250
- params.push(query, limit);
270
+ params.push(ftsQuery, limit);
251
271
  const rows = this._brain.db
252
272
  .prepare(sql)
253
273
  .all(...params);
254
- return rows.map((row) => ({
274
+ const updatedRows = this._applyRecallAccessUpdates(rows);
275
+ return updatedRows.map((row) => ({
255
276
  trace: this._buildTrace(row),
256
277
  score: row.strength * Math.abs(row.rank),
257
278
  }));
@@ -295,9 +316,12 @@ export class Memory {
295
316
  chunksCreated: 0,
296
317
  tracesCreated: 0,
297
318
  };
298
- const chunkStrategy = this._config.ingestion?.chunkStrategy ?? 'semantic';
299
- const chunkSize = this._config.ingestion?.chunkSize ?? 512;
300
- const chunkOverlap = this._config.ingestion?.chunkOverlap ?? 64;
319
+ const chunking = {
320
+ strategy: (this._config.ingestion?.chunkStrategy ?? 'semantic'),
321
+ chunkSize: this._config.ingestion?.chunkSize ?? 512,
322
+ chunkOverlap: this._config.ingestion?.chunkOverlap ?? 64,
323
+ };
324
+ const urlLoader = new UrlLoader(this._loaderRegistry);
301
325
  try {
302
326
  // Detect source type.
303
327
  const stat = await fs.stat(source).catch(() => null);
@@ -315,51 +339,14 @@ export class Memory {
315
339
  result.failed.push(...scanResult.failed);
316
340
  // Chunk and store each loaded document.
317
341
  for (const doc of scanResult.documents) {
318
- const chunks = await this._chunkingEngine.chunk(doc.content, {
319
- strategy: chunkStrategy,
320
- chunkSize,
321
- chunkOverlap,
322
- });
323
- const docId = `doc_${Date.now()}_${_traceCounter++}`;
324
- const contentHash = sha256(doc.content);
325
- // Insert document record.
326
- this._brain.db
327
- .prepare(`INSERT OR IGNORE INTO documents
328
- (id, path, format, title, content_hash, chunk_count, metadata, ingested_at)
329
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
330
- .run(docId, doc.metadata.source ?? source, doc.format, doc.metadata.title ?? null, contentHash, chunks.length, JSON.stringify(doc.metadata), Date.now());
331
- // Insert chunks and create traces.
332
- // Insert memory_traces FIRST (document_chunks.trace_id is an FK).
333
- for (const chunk of chunks) {
334
- const chunkId = `chunk_${Date.now()}_${_traceCounter++}`;
335
- const traceId = nextTraceId();
336
- // 1. Create the memory trace for this chunk.
337
- this._brain.db
338
- .prepare(`INSERT INTO memory_traces
339
- (id, type, scope, content, embedding, strength, created_at,
340
- last_accessed, retrieval_count, tags, emotions, metadata, deleted)
341
- VALUES (?, 'semantic', 'user', ?, NULL, 1.0, ?, NULL, 0, '[]', '{}', ?, 0)`)
342
- .run(traceId, chunk.content, Date.now(), JSON.stringify({
343
- content_hash: sha256(chunk.content),
344
- document_id: docId,
345
- chunk_index: chunk.index,
346
- }));
347
- // 2. Sync FTS index.
348
- this._brain.db
349
- .prepare(`INSERT INTO memory_traces_fts (rowid, content, tags)
350
- VALUES (
351
- (SELECT rowid FROM memory_traces WHERE id = ?),
352
- ?,
353
- '[]'
354
- )`)
355
- .run(traceId, chunk.content);
356
- // 3. Insert the document chunk (FK to memory_traces now satisfied).
357
- this._brain.db
358
- .prepare(`INSERT INTO document_chunks (id, document_id, trace_id, content, chunk_index, page_number, embedding)
359
- VALUES (?, ?, ?, ?, ?, ?, NULL)`)
360
- .run(chunkId, docId, traceId, chunk.content, chunk.index, chunk.pageNumber ?? null);
361
- result.chunksCreated++;
362
- result.tracesCreated++;
342
+ try {
343
+ await this._ingestLoadedDocument(doc.metadata.source ?? source, doc, chunking, result);
344
+ }
345
+ catch (err) {
346
+ result.failed.push({
347
+ path: doc.metadata.source ?? source,
348
+ error: err instanceof Error ? err.message : String(err),
349
+ });
363
350
  }
364
351
  }
365
352
  }
@@ -368,49 +355,18 @@ export class Memory {
368
355
  try {
369
356
  const doc = await this._loaderRegistry.loadFile(source);
370
357
  result.succeeded.push(source);
371
- const chunks = await this._chunkingEngine.chunk(doc.content, {
372
- strategy: chunkStrategy,
373
- chunkSize,
374
- chunkOverlap,
375
- });
376
- const docId = `doc_${Date.now()}_${_traceCounter++}`;
377
- const contentHash = sha256(doc.content);
378
- this._brain.db
379
- .prepare(`INSERT OR IGNORE INTO documents
380
- (id, path, format, title, content_hash, chunk_count, metadata, ingested_at)
381
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
382
- .run(docId, source, doc.format, doc.metadata.title ?? null, contentHash, chunks.length, JSON.stringify(doc.metadata), Date.now());
383
- for (const chunk of chunks) {
384
- const chunkId = `chunk_${Date.now()}_${_traceCounter++}`;
385
- const traceId = nextTraceId();
386
- // 1. Create the memory trace first (FK target for document_chunks).
387
- this._brain.db
388
- .prepare(`INSERT INTO memory_traces
389
- (id, type, scope, content, embedding, strength, created_at,
390
- last_accessed, retrieval_count, tags, emotions, metadata, deleted)
391
- VALUES (?, 'semantic', 'user', ?, NULL, 1.0, ?, NULL, 0, '[]', '{}', ?, 0)`)
392
- .run(traceId, chunk.content, Date.now(), JSON.stringify({
393
- content_hash: sha256(chunk.content),
394
- document_id: docId,
395
- chunk_index: chunk.index,
396
- }));
397
- // 2. Sync FTS index.
398
- this._brain.db
399
- .prepare(`INSERT INTO memory_traces_fts (rowid, content, tags)
400
- VALUES (
401
- (SELECT rowid FROM memory_traces WHERE id = ?),
402
- ?,
403
- '[]'
404
- )`)
405
- .run(traceId, chunk.content);
406
- // 3. Insert document chunk (FK to memory_traces now satisfied).
407
- this._brain.db
408
- .prepare(`INSERT INTO document_chunks (id, document_id, trace_id, content, chunk_index, page_number, embedding)
409
- VALUES (?, ?, ?, ?, ?, ?, NULL)`)
410
- .run(chunkId, docId, traceId, chunk.content, chunk.index, chunk.pageNumber ?? null);
411
- result.chunksCreated++;
412
- result.tracesCreated++;
413
- }
358
+ await this._ingestLoadedDocument(source, doc, chunking, result);
359
+ }
360
+ catch (err) {
361
+ const message = err instanceof Error ? err.message : String(err);
362
+ result.failed.push({ path: source, error: message });
363
+ }
364
+ }
365
+ else if (urlLoader.canLoad(source)) {
366
+ try {
367
+ const doc = await urlLoader.load(source);
368
+ result.succeeded.push(source);
369
+ await this._ingestLoadedDocument(source, doc, chunking, result);
414
370
  }
415
371
  catch (err) {
416
372
  const message = err instanceof Error ? err.message : String(err);
@@ -418,10 +374,10 @@ export class Memory {
418
374
  }
419
375
  }
420
376
  else {
421
- // URL or unknown source -- treat as unsupported for now.
377
+ // Unknown source.
422
378
  result.failed.push({
423
379
  path: source,
424
- error: `Source "${source}" is not a file or directory.`,
380
+ error: `Source "${source}" is not a file, directory, or supported URL.`,
425
381
  });
426
382
  }
427
383
  }
@@ -530,11 +486,55 @@ export class Memory {
530
486
  feedback(traceId, signal) {
531
487
  if (!this._feedbackSignal)
532
488
  return;
533
- // Fire-and-forget: insert feedback row without awaiting.
534
- this._brain.db
535
- .prepare(`INSERT INTO retrieval_feedback (trace_id, signal, query, created_at)
536
- VALUES (?, ?, NULL, ?)`)
537
- .run(traceId, signal, Date.now());
489
+ try {
490
+ const now = Date.now();
491
+ const row = this._brain.db
492
+ .prepare(`SELECT id, type, scope, content, embedding, strength, created_at,
493
+ last_accessed, retrieval_count, tags, emotions, metadata, deleted
494
+ FROM memory_traces
495
+ WHERE id = ?
496
+ LIMIT 1`)
497
+ .get(traceId);
498
+ this._brain.db
499
+ .prepare(`INSERT INTO retrieval_feedback (trace_id, signal, query, created_at)
500
+ VALUES (?, ?, NULL, ?)`)
501
+ .run(traceId, signal, now);
502
+ if (!row)
503
+ return;
504
+ if (signal === 'used') {
505
+ const update = updateOnRetrieval(this._buildTrace(row), now);
506
+ const metadata = JSON.stringify(withPersistedDecayState(parseTraceMetadata(row.metadata), {
507
+ stability: update.stability,
508
+ accessCount: update.accessCount,
509
+ reinforcementInterval: update.reinforcementInterval,
510
+ nextReinforcementAt: update.nextReinforcementAt,
511
+ }));
512
+ this._brain.db
513
+ .prepare(`UPDATE memory_traces
514
+ SET strength = ?, last_accessed = ?, retrieval_count = ?, metadata = ?
515
+ WHERE id = ?`)
516
+ .run(update.encodingStrength, update.lastAccessedAt, update.retrievalCount, metadata, traceId);
517
+ return;
518
+ }
519
+ const penalty = penalizeUnused(this._buildTrace(row), now);
520
+ const existingDecay = readPersistedDecayState(parseTraceMetadata(row.metadata), row.retrieval_count);
521
+ const metadata = JSON.stringify(withPersistedDecayState(parseTraceMetadata(row.metadata), {
522
+ stability: penalty.stability,
523
+ accessCount: existingDecay.accessCount,
524
+ reinforcementInterval: existingDecay.reinforcementInterval,
525
+ ...(existingDecay.nextReinforcementAt !== undefined
526
+ ? { nextReinforcementAt: existingDecay.nextReinforcementAt }
527
+ : {}),
528
+ }));
529
+ this._brain.db
530
+ .prepare(`UPDATE memory_traces
531
+ SET strength = ?, last_accessed = ?, metadata = ?
532
+ WHERE id = ?`)
533
+ .run(penalty.encodingStrength, penalty.lastAccessedAt, metadata, traceId);
534
+ }
535
+ catch {
536
+ // Explicit feedback is best-effort; the caller should not fail on analytics updates.
537
+ }
538
538
  }
539
539
  // =========================================================================
540
540
  // Import / Export
@@ -592,31 +592,68 @@ export class Memory {
592
592
  async importFrom(source, options) {
593
593
  await this._initPromise;
594
594
  const format = await this._detectImportFormat(source, options);
595
+ let result;
595
596
  switch (format) {
596
- case 'json': {
597
- const importer = new JsonImporter(this._brain);
598
- return importer.import(source);
599
- }
600
- case 'markdown': {
601
- const importer = new MarkdownImporter(this._brain);
602
- return importer.import(source);
603
- }
604
- case 'obsidian': {
605
- const importer = new ObsidianImporter(this._brain);
606
- return importer.import(source);
607
- }
608
- case 'sqlite': {
609
- const importer = new SqliteImporter(this._brain);
610
- return importer.import(source);
611
- }
612
- case 'chatgpt': {
613
- const importer = new ChatGptImporter(this._brain);
614
- return importer.import(source);
615
- }
616
- default: {
617
- return { imported: 0, skipped: 0, errors: [`Unsupported import format: "${format}"`] };
618
- }
597
+ case 'json':
598
+ result = await new JsonImporter(this._brain).import(source);
599
+ break;
600
+ case 'markdown':
601
+ result = await new MarkdownImporter(this._brain).import(source);
602
+ break;
603
+ case 'obsidian':
604
+ result = await new ObsidianImporter(this._brain).import(source);
605
+ break;
606
+ case 'sqlite':
607
+ result = await new SqliteImporter(this._brain).import(source);
608
+ break;
609
+ case 'chatgpt':
610
+ result = await new ChatGptImporter(this._brain).import(source);
611
+ break;
612
+ case 'csv':
613
+ result = await new CsvImporter(this._brain).import(source);
614
+ break;
615
+ default:
616
+ result = { imported: 0, skipped: 0, errors: [`Unsupported import format: "${format}"`] };
617
+ break;
619
618
  }
619
+ if (result.imported > 0) {
620
+ this._rebuildFtsIndex();
621
+ }
622
+ return result;
623
+ }
624
+ // =========================================================================
625
+ // Tool integration
626
+ // =========================================================================
627
+ /**
628
+ * Create runtime `ITool` instances backed by this memory facade's SQLite brain.
629
+ *
630
+ * This is the supported bridge from the standalone memory engine into
631
+ * AgentOS tool registration. The returned tools share this `Memory`
632
+ * instance's underlying SQLite database and consolidation loop.
633
+ *
634
+ * Typical usage:
635
+ * ```ts
636
+ * for (const tool of memory.createTools()) {
637
+ * await agentos.getToolOrchestrator().registerTool(tool);
638
+ * }
639
+ * ```
640
+ *
641
+ * When self-improvement is disabled, `memory_reflect` is omitted because
642
+ * there is no backing {@link ConsolidationLoop} instance.
643
+ */
644
+ createTools(options) {
645
+ const tools = [
646
+ new MemoryAddTool(this._brain),
647
+ new MemoryUpdateTool(this._brain),
648
+ new MemoryDeleteTool(this._brain),
649
+ new MemoryMergeTool(this._brain),
650
+ new MemorySearchTool(this._brain),
651
+ ];
652
+ const includeReflect = options?.includeReflect ?? true;
653
+ if (includeReflect && this._consolidationLoop) {
654
+ tools.push(new MemoryReflectTool(this._brain, this._consolidationLoop));
655
+ }
656
+ return tools;
620
657
  }
621
658
  // =========================================================================
622
659
  // Health
@@ -730,13 +767,10 @@ export class Memory {
730
767
  emotions = JSON.parse(row.emotions);
731
768
  }
732
769
  catch { /* empty */ }
733
- let metadata = {};
734
- try {
735
- metadata = JSON.parse(row.metadata);
736
- }
737
- catch { /* empty */ }
770
+ const metadata = parseTraceMetadata(row.metadata);
738
771
  const entities = Array.isArray(metadata.entities) ? metadata.entities : [];
739
772
  const scopeId = typeof metadata.scopeId === 'string' ? metadata.scopeId : '';
773
+ const decayState = readPersistedDecayState(metadata, row.retrieval_count);
740
774
  return {
741
775
  id: row.id,
742
776
  type: row.type,
@@ -760,17 +794,142 @@ export class Memory {
760
794
  ...emotions,
761
795
  },
762
796
  encodingStrength: row.strength,
763
- stability: 86400000, // 1 day default
797
+ stability: decayState.stability,
764
798
  retrievalCount: row.retrieval_count,
765
799
  lastAccessedAt: row.last_accessed ?? row.created_at,
766
- accessCount: row.retrieval_count,
767
- reinforcementInterval: 86400000,
800
+ accessCount: decayState.accessCount,
801
+ reinforcementInterval: decayState.reinforcementInterval,
802
+ ...(decayState.nextReinforcementAt !== undefined
803
+ ? { nextReinforcementAt: decayState.nextReinforcementAt }
804
+ : {}),
768
805
  associatedTraceIds: [],
769
806
  createdAt: row.created_at,
770
807
  updatedAt: row.created_at,
771
808
  isActive: row.deleted === 0,
772
809
  };
773
810
  }
811
+ /**
812
+ * Find an active trace previously stored with the same content hash.
813
+ *
814
+ * Checks both the facade-native `content_hash` metadata key and the
815
+ * importer-used `import_hash` key so dedup works across facade and import
816
+ * workflows.
817
+ */
818
+ _findExistingTraceByHash(contentHash, type, scope, scopeId) {
819
+ return this._brain.db
820
+ .prepare(`SELECT id, type, scope, content, embedding, strength, created_at,
821
+ last_accessed, retrieval_count, tags, emotions, metadata, deleted
822
+ FROM memory_traces
823
+ WHERE deleted = 0
824
+ AND type = ?
825
+ AND scope = ?
826
+ AND ifnull(json_extract(metadata, '$.scopeId'), '') = ?
827
+ AND (
828
+ json_extract(metadata, '$.content_hash') = ?
829
+ OR json_extract(metadata, '$.import_hash') = ?
830
+ )
831
+ LIMIT 1`)
832
+ .get(type, scope, scopeId, contentHash, contentHash);
833
+ }
834
+ /**
835
+ * Apply spaced-repetition access updates to recalled rows and persist the
836
+ * updated retrieval metadata back to SQLite.
837
+ */
838
+ _applyRecallAccessUpdates(rows) {
839
+ if (rows.length === 0)
840
+ return rows;
841
+ const now = Date.now();
842
+ const updateStmt = this._brain.db.prepare(`UPDATE memory_traces
843
+ SET strength = ?, last_accessed = ?, retrieval_count = ?, metadata = ?
844
+ WHERE id = ?`);
845
+ return this._brain.db.transaction(() => rows.map((row) => {
846
+ const update = updateOnRetrieval(this._buildTrace(row), now);
847
+ const metadata = JSON.stringify(withPersistedDecayState(parseTraceMetadata(row.metadata), {
848
+ stability: update.stability,
849
+ accessCount: update.accessCount,
850
+ reinforcementInterval: update.reinforcementInterval,
851
+ nextReinforcementAt: update.nextReinforcementAt,
852
+ }));
853
+ updateStmt.run(update.encodingStrength, update.lastAccessedAt, update.retrievalCount, metadata, row.id);
854
+ return {
855
+ ...row,
856
+ strength: update.encodingStrength,
857
+ last_accessed: update.lastAccessedAt,
858
+ retrieval_count: update.retrievalCount,
859
+ metadata,
860
+ };
861
+ }))();
862
+ }
863
+ /**
864
+ * Persist one loaded document into the documents/chunks/traces tables.
865
+ *
866
+ * Document-level dedup is keyed by `documents.content_hash`, so re-ingesting
867
+ * the same source content is idempotent.
868
+ */
869
+ async _ingestLoadedDocument(source, doc, chunking, result) {
870
+ const contentHash = sha256(doc.content);
871
+ const existingDoc = this._brain.db
872
+ .prepare(`SELECT id FROM documents WHERE content_hash = ? LIMIT 1`)
873
+ .get(contentHash);
874
+ if (existingDoc) {
875
+ return;
876
+ }
877
+ const chunks = await this._chunkingEngine.chunk(doc.content, chunking);
878
+ const docId = `doc_${Date.now()}_${_traceCounter++}`;
879
+ this._brain.db
880
+ .prepare(`INSERT INTO documents
881
+ (id, path, format, title, content_hash, chunk_count, metadata, ingested_at)
882
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
883
+ .run(docId, doc.metadata.source ?? source, doc.format, doc.metadata.title ?? null, contentHash, chunks.length, JSON.stringify(doc.metadata), Date.now());
884
+ for (const chunk of chunks) {
885
+ const chunkId = `chunk_${Date.now()}_${_traceCounter++}`;
886
+ const traceId = nextTraceId();
887
+ const createdAt = Date.now();
888
+ this._brain.db
889
+ .prepare(`INSERT INTO memory_traces
890
+ (id, type, scope, content, embedding, strength, created_at,
891
+ last_accessed, retrieval_count, tags, emotions, metadata, deleted)
892
+ VALUES (?, 'semantic', 'user', ?, NULL, 1.0, ?, NULL, 0, '[]', '{}', ?, 0)`)
893
+ .run(traceId, chunk.content, createdAt, JSON.stringify(buildInitialTraceMetadata({
894
+ document_id: docId,
895
+ chunk_index: chunk.index,
896
+ }, { contentHash: sha256(chunk.content) })));
897
+ this._brain.db
898
+ .prepare(`INSERT INTO memory_traces_fts (rowid, content, tags)
899
+ VALUES (
900
+ (SELECT rowid FROM memory_traces WHERE id = ?),
901
+ ?,
902
+ '[]'
903
+ )`)
904
+ .run(traceId, chunk.content);
905
+ if (this._config.graph && !this._memoryGraph.hasNode(traceId)) {
906
+ await this._memoryGraph.addNode(traceId, {
907
+ type: 'semantic',
908
+ scope: 'user',
909
+ scopeId: docId,
910
+ strength: 1.0,
911
+ createdAt,
912
+ });
913
+ }
914
+ this._brain.db
915
+ .prepare(`INSERT INTO document_chunks (id, document_id, trace_id, content, chunk_index, page_number, embedding)
916
+ VALUES (?, ?, ?, ?, ?, ?, NULL)`)
917
+ .run(chunkId, docId, traceId, chunk.content, chunk.index, chunk.pageNumber ?? null);
918
+ result.chunksCreated++;
919
+ result.tracesCreated++;
920
+ }
921
+ }
922
+ /**
923
+ * Rebuild the external-content FTS index after bulk import operations.
924
+ */
925
+ _rebuildFtsIndex() {
926
+ try {
927
+ this._brain.db.exec(`INSERT INTO memory_traces_fts(memory_traces_fts) VALUES('rebuild')`);
928
+ }
929
+ catch {
930
+ // Best-effort; imports still succeed even if the FTS rebuild is unavailable.
931
+ }
932
+ }
774
933
  /**
775
934
  * Detect the export format from options or file extension.
776
935
  */