agent-working-memory 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts CHANGED
@@ -245,6 +245,29 @@ function health() {
245
245
  }
246
246
  }
247
247
 
248
+ // ─── BACKEND-AGNOSTIC STORE (export/import) ──────────────────────────────────
249
+ //
250
+ // export/import route through openStore() so they work on ANY backend (SQLite,
251
+ // PGlite, Postgres) — not just better-sqlite3. `--db <path>` maps to AWM_DB_PATH
252
+ // (a SQLite file or PGlite dir, by shape); for a Postgres target set
253
+ // AWM_STORE_BACKEND=postgres + AWM_DATABASE_URL (no --db). This is what lets you
254
+ // port a memory store INTO managed Postgres (the SQLite-hardcoded path could not).
255
+
256
+ function toISOStr(d: Date | string | null | undefined): string | null {
257
+ if (d == null) return null;
258
+ return d instanceof Date ? d.toISOString() : String(d);
259
+ }
260
+
261
+ async function openCliStore(dbPath?: string): Promise<{ store: any; backend: string; close: () => Promise<void> }> {
262
+ // --db sets the path only when the env doesn't already select a backend/path.
263
+ if (dbPath && !process.env.AWM_DB_PATH && (process.env.AWM_STORE_BACKEND ?? '') !== 'postgres') {
264
+ process.env.AWM_DB_PATH = dbPath;
265
+ }
266
+ const { openStore } = await import('./storage/factory.js');
267
+ const { store, backend } = await openStore();
268
+ return { store, backend, close: async () => { try { await store.close?.(); } catch { /* */ } } };
269
+ }
270
+
248
271
  // ─── EXPORT ──────────────────────────────────────
249
272
 
250
273
  async function exportMemories() {
@@ -252,113 +275,107 @@ async function exportMemories() {
252
275
  let agentFilter: string | null = null;
253
276
  let outputPath: string | null = null;
254
277
  let activeOnly = false;
278
+ let allStages = false;
279
+ let includeRetracted = false;
255
280
 
256
281
  for (let i = 1; i < args.length; i++) {
257
282
  if (args[i] === '--db' && args[i + 1]) dbPath = args[++i];
258
283
  else if (args[i] === '--agent' && args[i + 1]) agentFilter = args[++i];
259
284
  else if (args[i] === '--output' && args[i + 1]) outputPath = args[++i];
260
285
  else if (args[i] === '--active-only') activeOnly = true;
286
+ else if (args[i] === '--all-stages') allStages = true;
287
+ else if (args[i] === '--include-retracted') includeRetracted = true;
261
288
  }
262
289
 
263
- if (!dbPath) {
264
- console.error('Error: --db <path> is required');
265
- process.exit(1);
266
- }
267
-
268
- if (!existsSync(dbPath)) {
269
- console.error(`Error: database not found: ${dbPath}`);
270
- process.exit(1);
271
- }
272
-
273
- // Dynamic import to avoid loading better-sqlite3 for other commands
274
- const Database = (await import('better-sqlite3')).default;
275
- const db = new Database(dbPath, { readonly: true });
276
-
277
- // Build memory query
278
- let memQuery = 'SELECT * FROM engrams';
279
- const conditions: string[] = [];
280
- const params: any[] = [];
281
-
282
- if (agentFilter) {
283
- conditions.push('agent_id = ?');
284
- params.push(agentFilter);
285
- }
286
- if (activeOnly) {
287
- conditions.push('retracted = 0');
290
+ // --db must exist for a file/dir backend; a Postgres source is selected by env instead.
291
+ const usingPostgres = (process.env.AWM_STORE_BACKEND ?? '').toLowerCase() === 'postgres';
292
+ if (!usingPostgres) {
293
+ if (!dbPath) { console.error('Error: --db <path> is required (or set AWM_STORE_BACKEND=postgres + AWM_DATABASE_URL)'); process.exit(1); }
294
+ if (!existsSync(dbPath)) { console.error(`Error: database not found: ${dbPath}`); process.exit(1); }
288
295
  }
289
296
 
290
- if (conditions.length > 0) {
291
- memQuery += ' WHERE ' + conditions.join(' AND ');
292
- }
293
- memQuery += ' ORDER BY created_at ASC';
294
-
295
- const rows = db.prepare(memQuery).all(...params) as any[];
296
-
297
- // Build memory objects (exclude embedding blobs)
298
- const memories = rows.map((r: any) => ({
299
- id: r.id,
300
- agent_id: r.agent_id,
301
- concept: r.concept,
302
- content: r.content,
303
- confidence: r.confidence,
304
- salience: r.salience,
305
- access_count: r.access_count,
306
- last_accessed: r.last_accessed,
307
- created_at: r.created_at,
308
- stage: r.stage,
309
- tags: r.tags ? JSON.parse(r.tags) : [],
310
- memory_class: r.memory_class ?? 'working',
311
- episode_id: r.episode_id ?? null,
312
- task_status: r.task_status ?? null,
313
- task_priority: r.task_priority ?? null,
314
- supersedes: r.supersedes ?? null,
315
- superseded_by: r.superseded_by ?? null,
316
- retracted: r.retracted ?? 0,
317
- }));
318
-
319
- // Get memory IDs for association filtering
320
- const memIds = new Set(memories.map((m: any) => m.id));
321
-
322
- // Build associations
323
- let assocQuery = 'SELECT * FROM associations';
324
- const allAssocs = db.prepare(assocQuery).all() as any[];
325
- const associations = allAssocs
326
- .filter((a: any) => memIds.has(a.from_engram_id) && memIds.has(a.to_engram_id))
327
- .map((a: any) => ({
328
- from_id: a.from_engram_id,
329
- to_id: a.to_engram_id,
330
- weight: a.weight,
331
- type: a.type ?? 'hebbian',
332
- activation_count: a.activation_count ?? 0,
297
+ const { store, backend, close } = await openCliStore(dbPath);
298
+ try {
299
+ const agentIds: string[] = agentFilter
300
+ ? [agentFilter]
301
+ : ((await store.getActiveAgents()) as any[]).map((a) => a.agentId);
302
+ if (agentIds.length === 0) {
303
+ console.error('Warning: no agents found to export. Pass --agent <id> if the store has no tracked activity yet.');
304
+ }
305
+ // Default to the meaningful memory set (active stage, non-retracted). --all-stages
306
+ // widens to every stage; --include-retracted adds retracted (off with --active-only).
307
+ const stage = allStages ? undefined : 'active';
308
+ const wantRetracted = includeRetracted && !activeOnly;
309
+ const engrams: any[] = (await store.getEngramsByAgents(agentIds, stage, wantRetracted)) ?? [];
310
+
311
+ const memories = engrams.map((e: any) => ({
312
+ id: e.id,
313
+ agent_id: e.agentId,
314
+ concept: e.concept,
315
+ content: e.content,
316
+ // Embeddings ARE included now (the old SQLite-only export stripped them, forcing a
317
+ // re-embed after import) → a faithful, recall-ready port when source/target embed
318
+ // models match. import skips them with --no-embeddings (then re-embed).
319
+ embedding: Array.isArray(e.embedding) ? e.embedding : null,
320
+ confidence: e.confidence,
321
+ salience: e.salience,
322
+ access_count: e.accessCount ?? 0,
323
+ last_accessed: toISOStr(e.lastAccessed),
324
+ created_at: toISOStr(e.createdAt),
325
+ stage: e.stage ?? 'active',
326
+ tags: Array.isArray(e.tags) ? e.tags : [],
327
+ memory_class: e.memoryClass ?? 'working',
328
+ memory_type: e.memoryType ?? 'unclassified',
329
+ episode_id: e.episodeId ?? null,
330
+ task_status: e.taskStatus ?? null,
331
+ task_priority: e.taskPriority ?? null,
332
+ supersedes: e.supersedes ?? null,
333
+ superseded_by: e.supersededBy ?? null,
334
+ retracted: e.retracted ? 1 : 0,
333
335
  }));
334
336
 
335
- // Collect unique agents
336
- const agents = [...new Set(memories.map((m: any) => m.agent_id))];
337
-
338
- const exportData = {
339
- version: '0.8.8',
340
- exported_at: new Date().toISOString(),
341
- source_db: dbPath,
342
- agent_filter: agentFilter,
343
- memories,
344
- associations,
345
- stats: {
346
- total_memories: memories.length,
347
- total_associations: associations.length,
348
- agents,
349
- },
350
- };
351
-
352
- const json = JSON.stringify(exportData, null, 2);
337
+ const memIds = new Set(memories.map((m) => m.id));
338
+ const seen = new Set<string>();
339
+ const associations: any[] = [];
340
+ for (const aid of agentIds) {
341
+ for (const a of ((await store.getAllAssociations(aid)) as any[]) ?? []) {
342
+ if (!memIds.has(a.fromEngramId) || !memIds.has(a.toEngramId)) continue;
343
+ const k = `${a.fromEngramId}>${a.toEngramId}`;
344
+ if (seen.has(k)) continue;
345
+ seen.add(k);
346
+ associations.push({
347
+ from_id: a.fromEngramId, to_id: a.toEngramId,
348
+ weight: a.weight, type: a.type ?? 'hebbian',
349
+ activation_count: a.activationCount ?? 0, confidence: a.confidence ?? 0.5,
350
+ });
351
+ }
352
+ }
353
353
 
354
- if (outputPath) {
355
- writeFileSync(outputPath, json + '\n');
356
- console.error(`Exported ${memories.length} memories, ${associations.length} associations → ${outputPath}`);
357
- } else {
358
- process.stdout.write(json + '\n');
354
+ const exportData = {
355
+ version: '0.9.2',
356
+ exported_at: new Date().toISOString(),
357
+ source_backend: backend,
358
+ agent_filter: agentFilter,
359
+ embedding_model: process.env.AWM_EMBED_MODEL ?? null,
360
+ memories,
361
+ associations,
362
+ stats: {
363
+ total_memories: memories.length,
364
+ total_associations: associations.length,
365
+ agents: [...new Set(memories.map((m) => m.agent_id))],
366
+ },
367
+ };
368
+
369
+ const json = JSON.stringify(exportData, null, 2);
370
+ if (outputPath) {
371
+ writeFileSync(outputPath, json + '\n');
372
+ console.error(`Exported ${memories.length} memories, ${associations.length} associations → ${outputPath} (backend: ${backend})`);
373
+ } else {
374
+ process.stdout.write(json + '\n');
375
+ }
376
+ } finally {
377
+ await close();
359
378
  }
360
-
361
- db.close();
362
379
  }
363
380
 
364
381
  // ─── IMPORT ──────────────────────────────────────
@@ -381,12 +398,17 @@ async function importMemories() {
381
398
  else if (!args[i].startsWith('--') && !filePath) filePath = args[i];
382
399
  }
383
400
 
401
+ // --no-embeddings: skip importing embedding vectors (use when source/target embed models
402
+ // differ → import without, then re-embed). Parsed alongside the existing flags above.
403
+ const noEmbeddings = args.includes('--no-embeddings');
404
+
384
405
  if (!filePath) {
385
406
  console.error('Error: <file> is required');
386
407
  process.exit(1);
387
408
  }
388
- if (!dbPath) {
389
- console.error('Error: --db <path> is required');
409
+ const usingPostgres = (process.env.AWM_STORE_BACKEND ?? '').toLowerCase() === 'postgres';
410
+ if (!dbPath && !usingPostgres) {
411
+ console.error('Error: --db <path> is required (or set AWM_STORE_BACKEND=postgres + AWM_DATABASE_URL)');
390
412
  process.exit(1);
391
413
  }
392
414
  if (!existsSync(filePath)) {
@@ -400,134 +422,100 @@ async function importMemories() {
400
422
  process.exit(1);
401
423
  }
402
424
 
403
- const Database = (await import('better-sqlite3')).default;
404
- const db = new Database(dbPath);
405
-
406
- // Ensure tables exist in target
407
- db.exec(`
408
- CREATE TABLE IF NOT EXISTS engrams (
409
- id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, concept TEXT NOT NULL, content TEXT NOT NULL,
410
- embedding BLOB, confidence REAL NOT NULL DEFAULT 0.5, salience REAL NOT NULL DEFAULT 0.5,
411
- access_count INTEGER NOT NULL DEFAULT 0, last_accessed TEXT NOT NULL, created_at TEXT NOT NULL,
412
- salience_features TEXT NOT NULL DEFAULT '{}', reason_codes TEXT NOT NULL DEFAULT '[]',
413
- stage TEXT NOT NULL DEFAULT 'active', ttl INTEGER, retracted INTEGER NOT NULL DEFAULT 0,
414
- retracted_by TEXT, retracted_at TEXT, tags TEXT NOT NULL DEFAULT '[]',
415
- episode_id TEXT, task_status TEXT, task_priority TEXT, blocked_by TEXT,
416
- memory_class TEXT NOT NULL DEFAULT 'working', superseded_by TEXT, supersedes TEXT
417
- );
418
- CREATE TABLE IF NOT EXISTS associations (
419
- id TEXT PRIMARY KEY, from_engram_id TEXT NOT NULL, to_engram_id TEXT NOT NULL,
420
- weight REAL NOT NULL DEFAULT 0.1, confidence REAL NOT NULL DEFAULT 0.5,
421
- type TEXT NOT NULL DEFAULT 'hebbian', activation_count INTEGER NOT NULL DEFAULT 0,
422
- created_at TEXT NOT NULL, last_activated TEXT
423
- );
424
- `);
425
-
426
- // Build dedup set if needed
427
- const existingHashes = new Set<string>();
428
- if (dedupe) {
429
- const existing = db.prepare('SELECT concept, content FROM engrams').all() as any[];
430
- for (const row of existing) {
431
- const hash = (row.concept ?? '').toLowerCase().trim() + '||' + (row.content ?? '').toLowerCase().trim();
432
- existingHashes.add(hash);
425
+ const { store, backend, close } = await openCliStore(dbPath);
426
+ try {
427
+ // Dedupe against existing memories for the TARGET agent(s).
428
+ const existingHashes = new Set<string>();
429
+ if (dedupe) {
430
+ const targetAgents = remapAgent
431
+ ? [remapAgent]
432
+ : [...new Set(importData.memories.map((m: any) => m.agent_id))] as string[];
433
+ for (const aid of targetAgents) {
434
+ for (const e of ((await store.getEngramsByAgent(aid, undefined, true)) as any[]) ?? []) {
435
+ existingHashes.add(`${(e.concept ?? '').toLowerCase().trim()}||${(e.content ?? '').toLowerCase().trim()}`);
436
+ }
437
+ }
433
438
  }
434
- }
435
- const idMap = new Map<string, string>();
436
- let imported = 0;
437
- let skippedDupes = 0;
438
- let skippedRetracted = 0;
439
-
440
- const insertMem = db.prepare(`
441
- INSERT INTO engrams (id, agent_id, concept, content, confidence, salience,
442
- access_count, last_accessed, created_at, stage, tags, memory_class,
443
- episode_id, task_status, task_priority, supersedes, superseded_by, retracted)
444
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
445
- `);
446
439
 
447
- // NOTE: associations.last_activated is NOT NULL on the engrams DB (storage/sqlite.ts),
448
- // so importing into an existing store fails if it's omitted — and because import wraps
449
- // memories+associations in ONE transaction, that rolls back the memories too (silent
450
- // "empty store"). Set it alongside created_at. (migrate/merge paths already do this.)
451
- const insertAssoc = db.prepare(`
452
- INSERT INTO associations (id, from_engram_id, to_engram_id, weight, type, activation_count, created_at, last_activated)
453
- VALUES (?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
454
- `);
440
+ const idMap = new Map<string, string>(); // old export id → new store id
441
+ let imported = 0, skippedDupes = 0, skippedRetracted = 0;
455
442
 
456
- const importTx = db.transaction(() => {
457
- // Import memories
443
+ // Pass 1 — create engrams (createEngram mints a fresh id; we capture it for remapping).
444
+ // createdAt/accessCount normalize to import time (the contract's createEngram stamps
445
+ // them) — see CHANGELOG; everything semantic (content/tags/confidence/salience/classes/
446
+ // embedding) is preserved, so recall is faithful.
458
447
  for (const mem of importData.memories) {
459
- // Skip retracted unless --include-retracted
460
- if (mem.retracted && !includeRetracted) {
461
- skippedRetracted++;
462
- continue;
463
- }
464
-
465
- // Dedupe check
448
+ if (mem.retracted && !includeRetracted) { skippedRetracted++; continue; }
466
449
  if (dedupe) {
467
- const hash = (mem.concept ?? '').toLowerCase().trim() + '||' + (mem.content ?? '').toLowerCase().trim();
468
- if (existingHashes.has(hash)) {
469
- skippedDupes++;
470
- continue;
471
- }
450
+ const h = `${(mem.concept ?? '').toLowerCase().trim()}||${(mem.content ?? '').toLowerCase().trim()}`;
451
+ if (existingHashes.has(h)) { skippedDupes++; continue; }
452
+ existingHashes.add(h); // also catch duplicates WITHIN this import file, not just vs the target
472
453
  }
473
-
474
- const newId = randomUUID();
475
- idMap.set(mem.id, newId);
476
-
477
- const agentId = remapAgent ?? mem.agent_id;
478
- const tags = Array.isArray(mem.tags) ? JSON.stringify(mem.tags) : (mem.tags ?? '[]');
479
-
480
- if (!dryRun) {
481
- insertMem.run(
482
- newId, agentId, mem.concept, mem.content,
483
- mem.confidence ?? 0.5, mem.salience ?? 0.5,
484
- mem.access_count ?? 0, mem.last_accessed ?? mem.created_at,
485
- mem.created_at, mem.stage ?? 'active', tags,
486
- mem.memory_class ?? 'working', mem.episode_id ?? null,
487
- mem.task_status ?? null, mem.task_priority ?? null,
488
- mem.supersedes ?? null, mem.superseded_by ?? null,
489
- mem.retracted ?? 0
490
- );
454
+ // dry-run: still map the id so the association-count preview isn't always 0
455
+ if (dryRun) { idMap.set(mem.id, mem.id); imported++; continue; }
456
+ const created = await store.createEngram({
457
+ agentId: remapAgent ?? mem.agent_id,
458
+ concept: mem.concept,
459
+ content: mem.content,
460
+ tags: Array.isArray(mem.tags) ? mem.tags : [],
461
+ embedding: (!noEmbeddings && Array.isArray(mem.embedding) && mem.embedding.length > 0) ? mem.embedding : undefined,
462
+ confidence: mem.confidence ?? 0.5,
463
+ salience: mem.salience ?? 0.5,
464
+ memoryClass: mem.memory_class ?? 'working',
465
+ memoryType: mem.memory_type ?? undefined,
466
+ episodeId: mem.episode_id ?? undefined,
467
+ taskStatus: mem.task_status ?? undefined,
468
+ taskPriority: mem.task_priority ?? undefined,
469
+ });
470
+ idMap.set(mem.id, created.id);
471
+ // Restore stage + retracted status. createEngram always mints an ACTIVE, non-retracted engram, so
472
+ // without this an `--include-retracted` import RESURRECTS retracted memories as live, and every
473
+ // non-active stage (staging/consolidated/archived/fading) silently flattens to active.
474
+ if (typeof mem.stage === 'string' && mem.stage && mem.stage !== 'active') {
475
+ try { await store.updateStage(created.id, mem.stage); } catch { /* best-effort */ }
476
+ }
477
+ if (mem.retracted) { // only reached when --include-retracted (retracted are skipped above otherwise)
478
+ try { await store.retractEngram(created.id, (mem as { retracted_by?: string }).retracted_by ?? null); } catch { /* best-effort */ }
491
479
  }
492
480
  imported++;
493
481
  }
494
482
 
495
- // Import associations (using remapped IDs)
496
- let assocImported = 0;
497
- const associations = importData.associations ?? [];
498
- for (const assoc of associations) {
499
- const fromId = idMap.get(assoc.from_id);
500
- const toId = idMap.get(assoc.to_id);
501
- if (!fromId || !toId) continue; // skip if either memory was skipped
502
-
503
- if (!dryRun) {
504
- insertAssoc.run(
505
- randomUUID(), fromId, toId,
506
- assoc.weight ?? 0.5, assoc.type ?? 'hebbian',
507
- assoc.activation_count ?? 0
508
- );
483
+ // Pass 2 re-link supersession with remapped ids (supersedeEngram sets both sides:
484
+ // old.superseded_by = new, new.supersedes = old). Skipped in dry-run.
485
+ if (!dryRun) {
486
+ for (const mem of importData.memories) {
487
+ const newId = idMap.get(mem.id);
488
+ if (!newId || !mem.supersedes) continue;
489
+ const supersededNew = idMap.get(mem.supersedes);
490
+ if (supersededNew) { try { await store.supersedeEngram(supersededNew, newId); } catch { /* best-effort */ } }
509
491
  }
510
- assocImported++;
511
492
  }
512
493
 
513
- return assocImported;
514
- });
515
-
516
- const assocCount = importTx();
517
-
518
- const prefix = dryRun ? '[DRY RUN] Would import' : 'Imported';
519
- console.log(`${prefix} ${imported} memories, ${assocCount} associations` +
520
- (skippedDupes > 0 ? `, ${skippedDupes} skipped (dupes)` : '') +
521
- (skippedRetracted > 0 ? `, ${skippedRetracted} skipped (retracted)` : '') +
522
- (remapAgent ? ` (agent remapped to: ${remapAgent})` : ''));
494
+ // Pass 3 — associations, remapped; skip any whose endpoints weren't imported.
495
+ let assocImported = 0;
496
+ for (const a of (importData.associations ?? [])) {
497
+ const fromId = idMap.get(a.from_id), toId = idMap.get(a.to_id);
498
+ if (!fromId || !toId) continue;
499
+ if (!dryRun) { try { await store.upsertAssociation(fromId, toId, a.weight ?? 0.5, a.type ?? 'hebbian', a.confidence ?? 0.5); } catch { /* best-effort */ } }
500
+ assocImported++;
501
+ }
523
502
 
524
- db.close();
503
+ const prefix = dryRun ? '[DRY RUN] Would import' : 'Imported';
504
+ console.log(`${prefix} ${imported} memories, ${assocImported} associations` +
505
+ (skippedDupes > 0 ? `, ${skippedDupes} skipped (dupes)` : '') +
506
+ (skippedRetracted > 0 ? `, ${skippedRetracted} skipped (retracted)` : '') +
507
+ (remapAgent ? ` (agent remapped to: ${remapAgent})` : '') +
508
+ ` (backend: ${backend}${noEmbeddings ? ', embeddings skipped' : ''})`);
509
+ } finally {
510
+ await close();
511
+ }
525
512
  }
526
513
 
527
514
  // ─── MERGE ──────────────────────────────────────
528
515
 
529
516
  async function mergeMemories() {
530
517
  const Database = (await import('better-sqlite3')).default;
518
+ const { EngramStore } = await import('./storage/sqlite.js');
531
519
  const { createHash, randomUUID } = await import('node:crypto');
532
520
 
533
521
  let target = '';
@@ -574,29 +562,19 @@ async function mergeMemories() {
574
562
 
575
563
  console.log(`Target: ${target}${dryRun ? ' (DRY RUN)' : ''}`);
576
564
 
577
- const targetDb = new Database(target);
578
- targetDb.pragma('journal_mode = WAL');
579
- targetDb.pragma('foreign_keys = ON');
580
-
581
- // Ensure tables exist in target
582
- targetDb.exec(`
583
- CREATE TABLE IF NOT EXISTS engrams (
584
- id TEXT PRIMARY KEY, agent_id TEXT NOT NULL, concept TEXT NOT NULL, content TEXT NOT NULL,
585
- embedding BLOB, confidence REAL NOT NULL DEFAULT 0.5, salience REAL NOT NULL DEFAULT 0.5,
586
- access_count INTEGER NOT NULL DEFAULT 0, last_accessed TEXT NOT NULL, created_at TEXT NOT NULL,
587
- salience_features TEXT NOT NULL DEFAULT '{}', reason_codes TEXT NOT NULL DEFAULT '[]',
588
- stage TEXT NOT NULL DEFAULT 'active', ttl INTEGER, retracted INTEGER NOT NULL DEFAULT 0,
589
- retracted_by TEXT, retracted_at TEXT, tags TEXT NOT NULL DEFAULT '[]'
590
- );
591
- CREATE TABLE IF NOT EXISTS associations (
592
- id TEXT PRIMARY KEY, from_engram_id TEXT NOT NULL, to_engram_id TEXT NOT NULL,
593
- weight REAL NOT NULL DEFAULT 0.1, confidence REAL NOT NULL DEFAULT 0.5,
594
- type TEXT NOT NULL DEFAULT 'hebbian', activation_count INTEGER NOT NULL DEFAULT 0,
595
- created_at TEXT NOT NULL, last_activated TEXT NOT NULL
596
- );
597
- `);
565
+ // Open the target through the REAL store so it has the full, current schema (all columns) + the FTS
566
+ // triggers. The previous hand-rolled schema dropped embedding/memory_class/memory_type/supersession/
567
+ // task columns and never created engrams_fts — so merged rows lost vector recall, class, AND BM25.
568
+ const store: any = new EngramStore(target);
569
+ const targetDb = store.db as import('better-sqlite3').Database; // the store's better-sqlite3 handle
570
+
571
+ const blobToArr = (b: unknown): number[] | undefined => {
572
+ const buf = b as Buffer | null | undefined;
573
+ return buf && buf.length ? Array.from(new Float32Array(buf.buffer, buf.byteOffset, Math.floor(buf.length / 4))) : undefined;
574
+ };
575
+ const parseJson = <T>(s: unknown, fallback: T): T => { try { return s ? JSON.parse(String(s)) as T : fallback; } catch { return fallback; } };
598
576
 
599
- // Build dedupe hash set from existing target memories
577
+ // Build dedupe hash set from existing target memories (cross-agent read via the store's handle)
600
578
  const existingHashes = new Set<string>();
601
579
  if (dedupe) {
602
580
  const rows = targetDb.prepare('SELECT concept, content FROM engrams').all() as { concept: string; content: string }[];
@@ -604,12 +582,6 @@ async function mergeMemories() {
604
582
  console.log(`Target has ${existingHashes.size} unique memories (for dedupe)\n`);
605
583
  }
606
584
 
607
- const insertEngram = targetDb.prepare(`
608
- INSERT OR IGNORE INTO engrams (id, agent_id, concept, content, confidence, salience, access_count,
609
- last_accessed, created_at, salience_features, reason_codes, stage, ttl,
610
- retracted, retracted_by, retracted_at, tags)
611
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
612
- `);
613
585
  const insertAssoc = targetDb.prepare(`
614
586
  INSERT OR IGNORE INTO associations (id, from_engram_id, to_engram_id, weight, confidence, type,
615
587
  activation_count, created_at, last_activated)
@@ -618,6 +590,7 @@ async function mergeMemories() {
618
590
 
619
591
  let totalMemories = 0, totalAssociations = 0, totalSkipped = 0;
620
592
 
593
+ try {
621
594
  for (const sourcePath of sources) {
622
595
  if (!existsSync(sourcePath)) {
623
596
  console.error(` Source not found: ${sourcePath}`);
@@ -625,63 +598,84 @@ async function mergeMemories() {
625
598
  }
626
599
 
627
600
  const sourceDb = new Database(sourcePath, { readonly: true });
628
- const engrams = sourceDb.prepare(
629
- `SELECT id, agent_id, concept, content, confidence, salience, access_count,
630
- last_accessed, created_at, salience_features, reason_codes, stage, ttl,
631
- retracted, retracted_by, retracted_at, tags FROM engrams`
632
- ).all() as any[];
633
- const assocs = sourceDb.prepare(
634
- `SELECT id, from_engram_id, to_engram_id, weight, confidence, type,
635
- activation_count, created_at, last_activated FROM associations`
636
- ).all() as any[];
601
+ // SELECT * is robust to older source schemas — a column a source predates just reads back undefined
602
+ // and createEngram fills the default.
603
+ const engrams = sourceDb.prepare('SELECT * FROM engrams').all() as any[];
604
+ const assocs = sourceDb.prepare('SELECT * FROM associations').all() as any[];
605
+ sourceDb.close(); // reads done — release the source handle before any (throwing) write work
637
606
 
638
607
  const idMap = new Map<string, string>();
639
608
  const skippedIds = new Set<string>();
609
+ let imported = 0, skipped = 0, assocImported = 0;
640
610
 
641
- const result = targetDb.transaction(() => {
642
- let imported = 0, skipped = 0;
611
+ for (const e of engrams) {
612
+ const hash = contentHash(e.concept, e.content);
613
+ if (dedupe && existingHashes.has(hash)) { skippedIds.add(e.id); skipped++; continue; }
614
+ existingHashes.add(hash);
615
+ if (dryRun) { idMap.set(e.id, e.id); imported++; continue; }
616
+ // Route each engram through the store's createEngram so EVERY column (embedding, memory_class,
617
+ // memory_type, task fields, sequence, references) AND the FTS index are populated correctly.
618
+ const created = store.createEngram({
619
+ agentId: remapAgentId(e.agent_id),
620
+ concept: e.concept, content: e.content,
621
+ embedding: blobToArr(e.embedding),
622
+ confidence: e.confidence ?? 0.5, salience: e.salience ?? 0.5,
623
+ salienceFeatures: parseJson(e.salience_features, undefined),
624
+ reasonCodes: parseJson(e.reason_codes, undefined),
625
+ tags: parseJson<string[]>(e.tags, []),
626
+ memoryClass: e.memory_class ?? 'working',
627
+ memoryType: e.memory_type ?? undefined,
628
+ episodeId: e.episode_id ?? undefined,
629
+ taskStatus: e.task_status ?? undefined,
630
+ taskPriority: e.task_priority ?? undefined,
631
+ blockedBy: e.blocked_by ?? undefined,
632
+ ttl: e.ttl ?? undefined,
633
+ sequence: e.sequence ?? undefined,
634
+ references: parseJson(e.references_json, undefined),
635
+ });
636
+ idMap.set(e.id, created.id);
637
+ // preserve stage + retracted (createEngram always mints active/non-retracted)
638
+ if (typeof e.stage === 'string' && e.stage && e.stage !== 'active') { try { store.updateStage(created.id, e.stage); } catch { /* */ } }
639
+ if (e.retracted) { try { store.retractEngram(created.id, e.retracted_by ?? null); } catch { /* */ } }
640
+ imported++;
641
+ }
642
+ // second pass — re-link supersession with remapped ids
643
+ if (!dryRun) {
643
644
  for (const e of engrams) {
644
- const hash = contentHash(e.concept, e.content);
645
- if (dedupe && existingHashes.has(hash)) { skippedIds.add(e.id); skipped++; continue; }
646
- const newId = randomUUID();
647
- idMap.set(e.id, newId);
648
- existingHashes.add(hash);
649
- if (!dryRun) {
650
- insertEngram.run(newId, remapAgentId(e.agent_id), e.concept, e.content, e.confidence,
651
- e.salience, e.access_count, e.last_accessed, e.created_at, e.salience_features,
652
- e.reason_codes, e.stage, e.ttl, e.retracted, e.retracted_by, e.retracted_at, e.tags);
653
- }
654
- imported++;
645
+ const newId = idMap.get(e.id);
646
+ if (!newId || !e.supersedes) continue;
647
+ const supNew = idMap.get(e.supersedes);
648
+ if (supNew) { try { store.supersedeEngram(supNew, newId); } catch { /* */ } }
655
649
  }
656
- let assocImported = 0;
657
- for (const a of assocs) {
658
- if (skippedIds.has(a.from_engram_id) || skippedIds.has(a.to_engram_id)) continue;
659
- const fromId = idMap.get(a.from_engram_id);
660
- const toId = idMap.get(a.to_engram_id);
661
- if (!fromId || !toId) continue;
662
- if (!dryRun) {
650
+ }
651
+ for (const a of assocs) {
652
+ if (skippedIds.has(a.from_engram_id) || skippedIds.has(a.to_engram_id)) continue;
653
+ const fromId = idMap.get(a.from_engram_id);
654
+ const toId = idMap.get(a.to_engram_id);
655
+ if (!fromId || !toId) continue;
656
+ if (!dryRun) {
657
+ try {
663
658
  insertAssoc.run(randomUUID(), fromId, toId, a.weight, a.confidence, a.type,
664
659
  a.activation_count, a.created_at, a.last_activated);
665
- }
666
- assocImported++;
660
+ } catch { /* skip an association whose source row has an unbindable/undefined column */ }
667
661
  }
668
- return { imported, skipped, assocImported };
669
- })();
670
-
671
- sourceDb.close();
662
+ assocImported++;
663
+ }
672
664
 
673
665
  const agentSet = new Set(engrams.map((e: any) => remapAgentId(e.agent_id)));
674
666
  console.log(` Source: ${sourcePath}`);
675
- console.log(` Engrams: ${engrams.length} total, ${result.imported} imported, ${result.skipped} skipped`);
676
- console.log(` Associations: ${assocs.length} total, ${result.assocImported} imported`);
667
+ console.log(` Engrams: ${engrams.length} total, ${imported} imported, ${skipped} skipped`);
668
+ console.log(` Associations: ${assocs.length} total, ${assocImported} imported`);
677
669
  console.log(` Agents: ${agentSet.size} (${[...agentSet].slice(0, 5).join(', ')}${agentSet.size > 5 ? '...' : ''})\n`);
678
670
 
679
- totalMemories += result.imported;
680
- totalAssociations += result.assocImported;
681
- totalSkipped += result.skipped;
671
+ totalMemories += imported;
672
+ totalAssociations += assocImported;
673
+ totalSkipped += skipped;
682
674
  }
683
675
 
684
- targetDb.close();
676
+ } finally {
677
+ try { store.close(); } catch { /* */ }
678
+ }
685
679
  console.log(`\nTotal: ${totalMemories} memories, ${totalAssociations} associations imported. ${totalSkipped} skipped.`);
686
680
  if (dryRun) console.log('(dry run — no data written)');
687
681
  }