soulbridge 0.0.26 → 0.0.98

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 (44) hide show
  1. package/LICENSE +202 -202
  2. package/dist/packages/cli/src/cli.js +0 -0
  3. package/dist/packages/core/src/config/dimensions.d.ts +18 -0
  4. package/dist/packages/core/src/config/dimensions.js +33 -0
  5. package/dist/packages/core/src/config/domains.d.ts +70 -0
  6. package/dist/packages/core/src/config/domains.js +70 -0
  7. package/dist/packages/core/src/config/labels.d.ts +22 -0
  8. package/dist/packages/core/src/config/labels.js +21 -0
  9. package/dist/packages/core/src/config/status.d.ts +51 -0
  10. package/dist/packages/core/src/config/status.js +47 -0
  11. package/dist/packages/core/src/consolidate.d.ts +170 -0
  12. package/dist/packages/core/src/consolidate.js +311 -0
  13. package/dist/packages/core/src/context.d.ts +2 -0
  14. package/dist/packages/core/src/context.js +48 -17
  15. package/dist/packages/core/src/dimension-text.d.ts +6 -0
  16. package/dist/packages/core/src/dimension-text.js +24 -0
  17. package/dist/packages/core/src/dimensions.d.ts +28 -0
  18. package/dist/packages/core/src/dimensions.js +53 -0
  19. package/dist/packages/core/src/extract.d.ts +140 -0
  20. package/dist/packages/core/src/extract.js +611 -0
  21. package/dist/packages/core/src/index.d.ts +362 -10
  22. package/dist/packages/core/src/index.js +983 -127
  23. package/dist/packages/core/src/inject-text.d.ts +16 -0
  24. package/dist/packages/core/src/inject-text.js +24 -0
  25. package/dist/packages/core/src/profile-prompt.d.ts +18 -0
  26. package/dist/packages/core/src/profile-prompt.js +103 -0
  27. package/dist/packages/core/src/prompts.d.ts +122 -0
  28. package/dist/packages/core/src/prompts.generated.d.ts +9 -0
  29. package/dist/packages/core/src/prompts.generated.js +9 -0
  30. package/dist/packages/core/src/prompts.js +293 -0
  31. package/dist/packages/core/src/reflect.d.ts +7 -0
  32. package/dist/packages/core/src/reflect.js +25 -27
  33. package/dist/packages/core/src/retrieval.d.ts +39 -0
  34. package/dist/packages/core/src/retrieval.js +105 -0
  35. package/dist/packages/core/src/roles.d.ts +18 -0
  36. package/dist/packages/core/src/roles.js +17 -0
  37. package/dist/packages/core/src/rules.d.ts +69 -0
  38. package/dist/packages/core/src/rules.js +152 -0
  39. package/dist/packages/core/src/sqlite.d.ts +26 -5
  40. package/dist/packages/core/src/sqlite.js +944 -33
  41. package/dist/packages/core/src/store.d.ts +92 -3
  42. package/dist/packages/core/src/types.d.ts +442 -1
  43. package/dist/packages/core/src/types.js +1 -0
  44. package/package.json +5 -3
@@ -6,11 +6,13 @@
6
6
  * Older databases are upgraded in place after a one-off backup; a database
7
7
  * from a newer binary is refused instead of being silently written.
8
8
  */
9
- import { copyFileSync, existsSync } from 'node:fs';
9
+ import { copyFileSync, existsSync, renameSync } from 'node:fs';
10
10
  import { DatabaseSync } from 'node:sqlite';
11
11
  import { DEFAULT_EMOTION } from './types.js';
12
+ import { FACT_STATUS, MEMORY_STATUS } from './config/status.js';
13
+ import { LEGACY_ROLE_BY_TYPE } from './config/domains.js';
12
14
  import { bm25, buildCorpusStats, tokenize } from './scoring.js';
13
- export const SCHEMA_VERSION = 4;
15
+ export const SCHEMA_VERSION = 9;
14
16
  /** How many recent pairs are considered before scoring. Bounded so recall stays O(candidates). */
15
17
  const RECALL_CANDIDATES = 500;
16
18
  export const AFFECT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
@@ -106,6 +108,130 @@ CREATE INDEX IF NOT EXISTS idx_turn_pairs_scope ON turn_pairs(soul_id, user_id,
106
108
  -- Idempotency: one exchange per (session, turn). A repeat is ignored, not merged.
107
109
  CREATE UNIQUE INDEX IF NOT EXISTS idx_turn_pairs_turn
108
110
  ON turn_pairs(soul_id, user_id, session_id, turn);
111
+
112
+ -- v5: a turn is the unit of memory; its steps are the shape (briefs/0014).
113
+ -- assistant_text stays denormalized so recall keeps one row per turn.
114
+ CREATE TABLE IF NOT EXISTS turns (
115
+ id TEXT PRIMARY KEY,
116
+ soul_id TEXT NOT NULL,
117
+ user_id TEXT NOT NULL,
118
+ session_id TEXT NOT NULL,
119
+ turn_no INTEGER NOT NULL,
120
+ started_at INTEGER NOT NULL,
121
+ ended_at INTEGER,
122
+ user_text TEXT NOT NULL,
123
+ assistant_text TEXT NOT NULL DEFAULT '',
124
+ outcome TEXT,
125
+ status TEXT NOT NULL DEFAULT 'closed',
126
+ created_at INTEGER NOT NULL
127
+ );
128
+ CREATE INDEX IF NOT EXISTS idx_turns_scope ON turns(soul_id, user_id, started_at DESC);
129
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_turns_turn
130
+ ON turns(soul_id, user_id, session_id, turn_no);
131
+
132
+ CREATE TABLE IF NOT EXISTS steps (
133
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
134
+ turn_id TEXT NOT NULL,
135
+ idx INTEGER NOT NULL,
136
+ kind TEXT NOT NULL,
137
+ text TEXT,
138
+ tool_name TEXT,
139
+ tool_args_chars INTEGER,
140
+ tool_result_chars INTEGER,
141
+ ok INTEGER,
142
+ at INTEGER NOT NULL
143
+ );
144
+ CREATE INDEX IF NOT EXISTS idx_steps_turn ON steps(turn_id, idx);
145
+
146
+ -- v6: the model proper (spec/model.v0.md). A fact is a connection; slots are
147
+ -- its ends; gaps are the connections that have not happened yet.
148
+ CREATE TABLE IF NOT EXISTS entities (
149
+ id TEXT PRIMARY KEY,
150
+ soul_id TEXT NOT NULL,
151
+ user_id TEXT NOT NULL,
152
+ type TEXT NOT NULL,
153
+ name TEXT NOT NULL,
154
+ aliases TEXT NOT NULL DEFAULT '[]',
155
+ attrs TEXT NOT NULL DEFAULT '{}',
156
+ first_seen_at INTEGER NOT NULL,
157
+ last_mentioned_at INTEGER NOT NULL,
158
+ created_at INTEGER NOT NULL
159
+ );
160
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_name
161
+ ON entities(soul_id, user_id, type, name);
162
+ CREATE INDEX IF NOT EXISTS idx_entities_recent
163
+ ON entities(soul_id, user_id, last_mentioned_at DESC);
164
+
165
+ CREATE TABLE IF NOT EXISTS facts (
166
+ id TEXT PRIMARY KEY,
167
+ soul_id TEXT NOT NULL,
168
+ user_id TEXT NOT NULL,
169
+ statement TEXT NOT NULL,
170
+ type_name TEXT,
171
+ role TEXT NOT NULL DEFAULT 'observation',
172
+ holder TEXT NOT NULL DEFAULT 'world',
173
+ at INTEGER,
174
+ at_text TEXT,
175
+ at_end INTEGER,
176
+ at_precision TEXT NOT NULL DEFAULT 'unknown',
177
+ place_id TEXT,
178
+ status TEXT NOT NULL DEFAULT 'none',
179
+ confidence REAL NOT NULL DEFAULT 1,
180
+ source TEXT NOT NULL DEFAULT 'experienced',
181
+ source_turn_id TEXT,
182
+ superseded_by TEXT,
183
+ created_at INTEGER NOT NULL,
184
+ recall_count INTEGER NOT NULL DEFAULT 0,
185
+ last_recalled_at INTEGER
186
+ );
187
+ CREATE INDEX IF NOT EXISTS idx_facts_scope ON facts(soul_id, user_id, at DESC);
188
+ CREATE INDEX IF NOT EXISTS idx_facts_status ON facts(soul_id, user_id, status);
189
+ CREATE INDEX IF NOT EXISTS idx_facts_created ON facts(soul_id, user_id, created_at DESC);
190
+
191
+ CREATE TABLE IF NOT EXISTS fact_slots (
192
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
193
+ fact_id TEXT NOT NULL,
194
+ slot TEXT NOT NULL,
195
+ value_type TEXT NOT NULL,
196
+ entity_id TEXT,
197
+ value_num REAL,
198
+ value_text TEXT,
199
+ ref_fact_id TEXT
200
+ );
201
+ CREATE INDEX IF NOT EXISTS idx_fact_slots_fact ON fact_slots(fact_id);
202
+ CREATE INDEX IF NOT EXISTS idx_fact_slots_entity ON fact_slots(entity_id);
203
+
204
+ CREATE TABLE IF NOT EXISTS gaps (
205
+ id TEXT PRIMARY KEY,
206
+ soul_id TEXT NOT NULL,
207
+ user_id TEXT NOT NULL,
208
+ about_entity_id TEXT,
209
+ about_fact_id TEXT,
210
+ topic TEXT NOT NULL,
211
+ tension REAL NOT NULL DEFAULT 0,
212
+ status TEXT NOT NULL DEFAULT 'open',
213
+ opened_at INTEGER NOT NULL,
214
+ closed_at INTEGER,
215
+ closed_by_fact_id TEXT
216
+ );
217
+ CREATE INDEX IF NOT EXISTS idx_gaps_open ON gaps(soul_id, user_id, status, tension DESC);
218
+
219
+ -- A topic is a thread, not a segment: its turns need not be adjacent, because a
220
+ -- conversation can interleave (A B A) and come back. Consolidation runs when a
221
+ -- thread closes, not when a batch of facts fills up.
222
+ CREATE TABLE IF NOT EXISTS threads (
223
+ id TEXT PRIMARY KEY,
224
+ soul_id TEXT NOT NULL,
225
+ user_id TEXT NOT NULL,
226
+ session_id TEXT NOT NULL,
227
+ topic TEXT NOT NULL,
228
+ status TEXT NOT NULL DEFAULT 'open',
229
+ opened_at INTEGER NOT NULL,
230
+ closed_at INTEGER,
231
+ last_turn_no INTEGER,
232
+ summary_fact_id TEXT
233
+ );
234
+ CREATE INDEX IF NOT EXISTS idx_threads_open ON threads(soul_id, user_id, status, opened_at DESC);
109
235
  `;
110
236
  const sleepSync = (ms) => {
111
237
  Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
@@ -167,22 +293,126 @@ function upgradeToV3(db) {
167
293
  )`);
168
294
  db.exec('CREATE INDEX IF NOT EXISTS idx_corrections ON corrections(soul_id, user_id, at DESC)');
169
295
  }
296
+ /**
297
+ * v3/v4 → v5: turns become the unit of memory (briefs/0014). Existing exchanges
298
+ * are copied across as turns with a single text step, so nothing is lost; the
299
+ * old table stays as a read-only legacy copy.
300
+ */
301
+ /** v6 → v7: turn threads (a topic's turns need not be adjacent) plus turns.thread_id. */
302
+ export function upgradeToV9(db) {
303
+ // Time became locatability (ADR 0032): a fact may be known to exist without a
304
+ // date, and that is a value — unknown — not a reason to discard it.
305
+ ensureColumn(db, 'facts', 'at_precision', "TEXT NOT NULL DEFAULT 'unknown'");
306
+ // What a fact is *for* (config/domains.ts FACT_ROLES). Backfill from the old type label so
307
+ // rows written before this column still reach the right consumer.
308
+ ensureColumn(db, 'facts', 'role', "TEXT NOT NULL DEFAULT 'observation'");
309
+ }
310
+ export function upgradeToV8(db) {
311
+ // What decays is the chance of being recalled, so the store has to know how
312
+ // often and how recently that happened (0019).
313
+ ensureColumn(db, 'facts', 'recall_count', 'INTEGER NOT NULL DEFAULT 0');
314
+ ensureColumn(db, 'facts', 'last_recalled_at', 'INTEGER');
315
+ db.exec('CREATE INDEX IF NOT EXISTS idx_facts_created ON facts(soul_id, user_id, created_at DESC)');
316
+ }
317
+ export function upgradeToV7(db) {
318
+ db.exec(`CREATE TABLE IF NOT EXISTS threads (
319
+ id TEXT PRIMARY KEY,
320
+ soul_id TEXT NOT NULL,
321
+ user_id TEXT NOT NULL,
322
+ session_id TEXT NOT NULL,
323
+ topic TEXT NOT NULL,
324
+ status TEXT NOT NULL DEFAULT 'open',
325
+ opened_at INTEGER NOT NULL,
326
+ closed_at INTEGER,
327
+ last_turn_no INTEGER,
328
+ summary_fact_id TEXT
329
+ )`);
330
+ db.exec('CREATE INDEX IF NOT EXISTS idx_threads_open ON threads(soul_id, user_id, status, opened_at DESC)');
331
+ ensureColumn(db, 'turns', 'thread_id', 'TEXT');
332
+ }
333
+ export function upgradeToV5(db) {
334
+ const hasLegacy = db
335
+ .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'turn_pairs'")
336
+ .get();
337
+ if (!hasLegacy)
338
+ return;
339
+ const rows = db.prepare('SELECT * FROM turn_pairs').all();
340
+ const insertTurn = db.prepare(`INSERT OR IGNORE INTO turns
341
+ (id, soul_id, user_id, session_id, turn_no, started_at, ended_at,
342
+ user_text, assistant_text, outcome, status, created_at)
343
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, 'closed', ?)`);
344
+ const insertStep = db.prepare("INSERT INTO steps (turn_id, idx, kind, text, at) VALUES (?, 0, 'text', ?, ?)");
345
+ for (const row of rows) {
346
+ const id = String(row.id);
347
+ const assistant = String(row.assistant_text ?? '');
348
+ const inserted = insertTurn.run(id, String(row.soul_id), String(row.user_id), String(row.session_id), Number(row.turn), Number(row.at), Number(row.at), String(row.user_text), assistant, Number(row.created_at));
349
+ // Only a freshly inserted turn gets a step. The migration can be re-run after an
350
+ // interruption before the version bump, and steps has no unique key — writing it
351
+ // unconditionally appended a second copy of the same reply on every retry.
352
+ if (Number(inserted.changes) > 0 && assistant.length > 0)
353
+ insertStep.run(id, assistant, Number(row.at));
354
+ }
355
+ }
170
356
  /**
171
357
  * Copy the database aside before a schema upgrade.
172
358
  *
173
- * In WAL mode the most recent commits can live in the `-wal` sidecar, so a copy
174
- * of the main file alone can be missing data exactly when it matters. Checkpoint
175
- * first (TRUNCATE moves the WAL back into the main file and empties it), then
176
- * copy. Exported for the upgrade test.
359
+ * The backup is the only way back from a bad migration, so it has to be *valid*,
360
+ * not merely present. Two failures were possible before: a plain file copy after
361
+ * `wal_checkpoint(TRUNCATE)` dropped the newest commits when another connection
362
+ * pinned an old snapshot (the checkpoint returns busy and only partially flushes,
363
+ * and that return value was ignored), and an existing backup file was trusted
364
+ * forever even if it was the broken one from an earlier attempt.
365
+ *
366
+ * `VACUUM INTO` reads through the WAL and writes a consistent snapshot regardless
367
+ * of other readers, so it is the primary path; a checkpoint+copy remains as the
368
+ * fallback, and whatever it produces is opened and integrity-checked. A copy that
369
+ * fails its own check is moved aside (kept for forensics) and the upgrade refuses
370
+ * to run — a corrupt backup is worse than none, because it is the one thing we
371
+ * would trust when everything else failed.
372
+ *
373
+ * Exported for the upgrade test.
177
374
  */
178
375
  export function backupBeforeUpgrade(db, file, from) {
179
376
  if (file === ':memory:' || !existsSync(file))
180
377
  return;
181
378
  const backup = `${file}.bak-v${from}`;
182
- if (existsSync(backup))
183
- return;
184
- db.exec('PRAGMA wal_checkpoint(TRUNCATE);');
185
- copyFileSync(file, backup);
379
+ if (existsSync(backup)) {
380
+ if (integrityOf(backup))
381
+ return;
382
+ const corrupt = `${backup}.corrupt-${Date.now()}`;
383
+ renameSync(backup, corrupt);
384
+ throw new Error(`existing backup ${backup} is not readable; moved aside to ${corrupt}`);
385
+ }
386
+ try {
387
+ // Quotes are escaped because the path is spliced into SQL; VACUUM INTO takes a
388
+ // string expression, and binding it is not supported on every SQLite build.
389
+ db.exec(`VACUUM INTO '${backup.replace(/'/g, "''")}'`);
390
+ }
391
+ catch {
392
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE);');
393
+ copyFileSync(file, backup);
394
+ }
395
+ if (!integrityOf(backup)) {
396
+ const corrupt = `${backup}.corrupt-${Date.now()}`;
397
+ renameSync(backup, corrupt);
398
+ throw new Error(`backup for schema v${from} failed its integrity check (kept at ${corrupt})`);
399
+ }
400
+ }
401
+ /** Does this file open and report `integrity_check = ok`? */
402
+ function integrityOf(file) {
403
+ try {
404
+ const copy = new DatabaseSync(file, { readOnly: true });
405
+ try {
406
+ const row = copy.prepare('PRAGMA integrity_check').get();
407
+ return row.integrity_check === 'ok';
408
+ }
409
+ finally {
410
+ copy.close();
411
+ }
412
+ }
413
+ catch {
414
+ return false;
415
+ }
186
416
  }
187
417
  export function openSqlite(file) {
188
418
  const db = new DatabaseSync(file);
@@ -196,11 +426,38 @@ export function openSqlite(file) {
196
426
  }
197
427
  if (version < SCHEMA_VERSION) {
198
428
  backupBeforeUpgrade(db, file, version);
199
- if (version < 2)
200
- upgradeToV2(db);
201
- if (version < 3)
202
- upgradeToV3(db);
203
- setVersion(db, SCHEMA_VERSION);
429
+ // All upgrades and the version bump commit together. Before this, an interruption
430
+ // left the version at the old value with half the migration applied, so the next
431
+ // open re-ran it against its own output (v5 appended duplicate steps).
432
+ // VACUUM INTO in the backup runs before the transaction: it cannot run inside one.
433
+ db.exec('BEGIN IMMEDIATE');
434
+ try {
435
+ if (version < 2)
436
+ upgradeToV2(db);
437
+ if (version < 3)
438
+ upgradeToV3(db);
439
+ if (version < 5)
440
+ upgradeToV5(db);
441
+ if (version < 7)
442
+ upgradeToV7(db);
443
+ if (version < 8)
444
+ upgradeToV8(db);
445
+ if (version < 9)
446
+ upgradeToV9(db);
447
+ // v6 adds tables only; SCHEMA creates them on every open, nothing to copy.
448
+ setVersion(db, SCHEMA_VERSION);
449
+ db.exec('COMMIT');
450
+ }
451
+ catch (error) {
452
+ try {
453
+ db.exec('ROLLBACK');
454
+ }
455
+ catch {
456
+ /* the error below is the one worth reporting */
457
+ }
458
+ db.close();
459
+ throw error;
460
+ }
204
461
  }
205
462
  const stmtPut = db.prepare(`
206
463
  INSERT INTO memories (
@@ -298,11 +555,25 @@ export function openSqlite(file) {
298
555
  if (!replacement)
299
556
  return false;
300
557
  const result = withRetry(() => db
301
- .prepare(`UPDATE memories SET status = 'superseded', superseded_by = ?, updated_at = ?
302
- WHERE id = ? AND status = 'active' AND deleted_at IS NULL`)
558
+ .prepare(`UPDATE memories SET status = '${MEMORY_STATUS.superseded}', superseded_by = ?, updated_at = ?
559
+ WHERE id = ? AND status = '${MEMORY_STATUS.active}' AND deleted_at IS NULL`)
303
560
  .run(newId, now, oldId));
304
561
  return Number(result.changes ?? 0) > 0;
305
562
  },
563
+ /**
564
+ * Retire a legacy memory pointing at something outside this table.
565
+ *
566
+ * `supersede()` requires the replacement to be a memory, which is right while the
567
+ * table is live. The migration replaces rows with facts, so it needs the same
568
+ * bookkeeping without that check — the row stays for audit, the pointer is kept.
569
+ */
570
+ retireMemory(oldId, replacedBy, now = Date.now()) {
571
+ const result = withRetry(() => db
572
+ .prepare(`UPDATE memories SET status = '${MEMORY_STATUS.superseded}', superseded_by = ?, updated_at = ?
573
+ WHERE id = ? AND status = '${MEMORY_STATUS.active}' AND deleted_at IS NULL`)
574
+ .run(replacedBy ?? null, now, oldId));
575
+ return Number(result.changes ?? 0) > 0;
576
+ },
306
577
  saveAffect(soulId, userId, signal, at, deviceId) {
307
578
  withRetry(() => {
308
579
  db.prepare(`INSERT OR IGNORE INTO affect_samples
@@ -367,24 +638,526 @@ export function openSqlite(file) {
367
638
  .get(soulId, userId);
368
639
  return Number(row.n);
369
640
  },
370
- putTurnPair(pair) {
641
+ putTurn(spec) {
371
642
  // INSERT OR IGNORE + the unique index is the whole idempotency story:
372
- // one exchange per (session, turn), repeats are dropped, never merged.
643
+ // one turn per (session, turn_no); repeats are dropped, never merged.
644
+ const id = spec.id ?? `tn_${spec.sessionId}_${spec.turnNo}`;
645
+ const createdAt = spec.createdAt ?? spec.startedAt;
646
+ const assistantText = spec.steps
647
+ .filter((step) => step.kind === 'text' && typeof step.text === 'string')
648
+ .map((step) => step.text)
649
+ .join('\n');
650
+ withRetry(() => {
651
+ const inserted = db
652
+ .prepare(`INSERT OR IGNORE INTO turns
653
+ (id, soul_id, user_id, session_id, turn_no, started_at, ended_at,
654
+ user_text, assistant_text, outcome, status, created_at)
655
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
656
+ .run(id, spec.soulId, spec.userId, spec.sessionId, spec.turnNo, spec.startedAt, spec.endedAt ?? spec.startedAt, spec.userText, assistantText, spec.outcome ?? null, spec.status ?? 'closed', createdAt);
657
+ // Steps are rewritten whenever the turn is written, so a retry after a
658
+ // partial write converges on the same shape instead of duplicating.
659
+ if (Number(inserted.changes) > 0) {
660
+ const insertStep = db.prepare(`INSERT INTO steps (turn_id, idx, kind, text, tool_name, tool_args_chars,
661
+ tool_result_chars, ok, at)
662
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
663
+ for (const step of spec.steps) {
664
+ insertStep.run(id, step.idx, step.kind, step.text ?? null, step.toolName ?? null, step.toolArgsChars ?? null, step.toolResultChars ?? null, step.ok === undefined ? null : step.ok ? 1 : 0, step.at);
665
+ }
666
+ }
667
+ });
668
+ },
669
+ findTurns(spec) {
670
+ // Substring match, deliberately not the tokenizer: an exhaustive fetch is
671
+ // a filter, and CJK bigrams would miss short needle text (eval-boundary-0027).
672
+ const where = ['soul_id = ?', 'user_id = ?'];
673
+ const args = [spec.soulId, spec.userId];
674
+ if (spec.text) {
675
+ const needle = `%${spec.text.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
676
+ where.push("(user_text LIKE ? ESCAPE '\\' OR assistant_text LIKE ? ESCAPE '\\')");
677
+ args.push(needle, needle);
678
+ }
679
+ if (spec.sessionId) {
680
+ where.push('session_id = ?');
681
+ args.push(spec.sessionId);
682
+ }
683
+ if (spec.since !== undefined) {
684
+ where.push('started_at >= ?');
685
+ args.push(spec.since);
686
+ }
687
+ if (spec.until !== undefined) {
688
+ where.push('started_at <= ?');
689
+ args.push(spec.until);
690
+ }
691
+ const clause = where.join(' AND ');
692
+ const total = Number(db.prepare(`SELECT COUNT(*) AS n FROM turns WHERE ${clause}`).get(...args).n);
693
+ const limit = spec.limit ?? 200;
694
+ const rows = db
695
+ .prepare(`SELECT * FROM turns WHERE ${clause} ORDER BY started_at DESC LIMIT ?`)
696
+ .all(...args, limit);
697
+ return { turns: rows.map(rowToTurnPair), total, truncated: total > limit };
698
+ },
699
+ /* ---------------- entities ---------------- */
700
+ putEntity(spec) {
701
+ const now = spec.seenAt ?? Date.now();
702
+ const id = spec.id ?? `en_${spec.type}_${encodeURIComponent(spec.name)}`;
703
+ // A refresh that only names the entity must not erase what is known about it.
704
+ // The upsert used to write `excluded.aliases` unconditionally, so every
705
+ // "just mention them again" call wiped the nickname and the attributes —
706
+ // and `latestAbout` lost its alias match, meaning the person stopped being
707
+ // recognized by anything but their full name.
708
+ const existingRow = db
709
+ .prepare('SELECT aliases, attrs FROM entities WHERE soul_id = ? AND user_id = ? AND type = ? AND name = ?')
710
+ .get(spec.soulId, spec.userId, spec.type, spec.name);
711
+ const readJson = (raw, fallback) => {
712
+ if (raw === undefined)
713
+ return fallback;
714
+ try {
715
+ return JSON.parse(raw);
716
+ }
717
+ catch {
718
+ return fallback;
719
+ }
720
+ };
721
+ const existingAliases = readJson(existingRow?.aliases, []);
722
+ const existingAttrs = readJson(existingRow?.attrs, {});
723
+ const aliases = spec.aliases === undefined ? existingAliases : [...new Set([...existingAliases, ...spec.aliases])];
724
+ const attrs = spec.attrs === undefined ? existingAttrs : { ...existingAttrs, ...spec.attrs };
725
+ withRetry(() => db
726
+ .prepare(`INSERT INTO entities
727
+ (id, soul_id, user_id, type, name, aliases, attrs,
728
+ first_seen_at, last_mentioned_at, created_at)
729
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
730
+ ON CONFLICT(soul_id, user_id, type, name) DO UPDATE SET
731
+ last_mentioned_at = excluded.last_mentioned_at,
732
+ aliases = excluded.aliases,
733
+ attrs = excluded.attrs`)
734
+ .run(id, spec.soulId, spec.userId, spec.type, spec.name, JSON.stringify(aliases), JSON.stringify(attrs), now, now, now));
735
+ const row = db
736
+ .prepare('SELECT * FROM entities WHERE soul_id = ? AND user_id = ? AND type = ? AND name = ?')
737
+ .get(spec.soulId, spec.userId, spec.type, spec.name);
738
+ return rowToEntity(row);
739
+ },
740
+ entity(id) {
741
+ const row = db.prepare('SELECT * FROM entities WHERE id = ?').get(id);
742
+ return row ? rowToEntity(row) : null;
743
+ },
744
+ findEntities(query) {
745
+ const where = ['soul_id = ?', 'user_id = ?'];
746
+ const args = [query.soulId, query.userId];
747
+ if (query.type) {
748
+ where.push('type = ?');
749
+ args.push(query.type);
750
+ }
751
+ if (query.name) {
752
+ where.push("(name LIKE ? ESCAPE '\\' OR aliases LIKE ? ESCAPE '\\')");
753
+ const needle = `%${query.name.replace(/[\\%_]/g, (c) => `\\${c}`)}%`;
754
+ args.push(needle, needle);
755
+ }
756
+ const rows = db
757
+ .prepare(`SELECT * FROM entities WHERE ${where.join(' AND ')}
758
+ ORDER BY last_mentioned_at DESC LIMIT ?`)
759
+ .all(...args, query.limit ?? 20);
760
+ return rows.map(rowToEntity);
761
+ },
762
+ /* ---------------- facts ---------------- */
763
+ putFact(spec) {
764
+ const id = spec.id ?? `fc_${Date.now().toString(36)}_${(factSeq += 1).toString(36)}`;
765
+ const createdAt = Date.now();
766
+ // The writer assigns the role: callers may set one explicitly, otherwise the old type
767
+ // label decides. Without this every new row would default to observation and quietly
768
+ // disappear from rules()/profile()/conclusions().
769
+ const role = spec.role ?? LEGACY_ROLE_BY_TYPE[spec.typeName ?? ''] ?? 'observation';
770
+ withRetry(() => {
771
+ const inserted = db
772
+ .prepare(`INSERT OR IGNORE INTO facts
773
+ (id, soul_id, user_id, statement, type_name, role, holder, at, at_text, at_end, at_precision,
774
+ place_id, status, confidence, source, source_turn_id, superseded_by, created_at)
775
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
776
+ .run(id, spec.soulId, spec.userId, spec.statement, spec.typeName ?? null, role, spec.holder ?? 'world', spec.at ?? null, spec.atText ?? null, spec.atEnd ?? null, spec.atPrecision ?? 'unknown', spec.placeId ?? null, spec.status ?? 'active', spec.confidence ?? 1, spec.source ?? 'experienced', spec.sourceTurnId ?? null, spec.supersededBy ?? null, createdAt);
777
+ if (Number(inserted.changes) > 0) {
778
+ const insertSlot = db.prepare(`INSERT INTO fact_slots (fact_id, slot, value_type, entity_id, value_num, value_text, ref_fact_id)
779
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
780
+ for (const s of spec.slots ?? []) {
781
+ insertSlot.run(id, s.slot, s.value.kind, s.value.kind === 'entity' ? s.value.entityId : null, s.value.kind === 'number' ? s.value.value : null, s.value.kind === 'text' ? s.value.value : null, s.value.kind === 'fact' ? s.value.factId : null);
782
+ }
783
+ }
784
+ });
785
+ const fact = this.fact(id);
786
+ if (!fact)
787
+ throw new Error(`fact ${id} disappeared right after writing`);
788
+ return fact;
789
+ },
790
+ fact(id) {
791
+ const row = db.prepare('SELECT * FROM facts WHERE id = ?').get(id);
792
+ if (!row)
793
+ return null;
794
+ return rowToFact(row, slotsOfRows(db, [id]));
795
+ },
796
+ facts(query) {
797
+ const where = ['f.soul_id = ?', 'f.user_id = ?'];
798
+ const args = [query.soulId, query.userId];
799
+ if (query.entityId) {
800
+ where.push('EXISTS (SELECT 1 FROM fact_slots s WHERE s.fact_id = f.id AND s.entity_id = ?)');
801
+ args.push(query.entityId);
802
+ }
803
+ if (query.since !== undefined) {
804
+ where.push('f.at >= ?');
805
+ args.push(query.since);
806
+ }
807
+ if (query.until !== undefined) {
808
+ where.push('f.at <= ?');
809
+ args.push(query.until);
810
+ }
811
+ if (query.status) {
812
+ where.push('f.status = ?');
813
+ args.push(query.status);
814
+ }
815
+ if (query.holder) {
816
+ where.push('f.holder = ?');
817
+ args.push(query.holder);
818
+ }
819
+ const direction = query.order === 'asc' ? 'ASC' : 'DESC';
820
+ const rows = db
821
+ .prepare(`SELECT f.* FROM facts f WHERE ${where.join(' AND ')}
822
+ ORDER BY COALESCE(f.at, f.created_at) ${direction} LIMIT ?`)
823
+ .all(...args, query.limit ?? 200);
824
+ const ids = rows.map((r) => String(r.id));
825
+ const slots = slotsOfRows(db, ids);
826
+ return rows.map((row) => rowToFact(row, slots));
827
+ },
828
+ supersedeFact(oldId, newId) {
829
+ withRetry(() => db
830
+ .prepare(`UPDATE facts SET status = '${FACT_STATUS.superseded}', superseded_by = ? WHERE id = ?`)
831
+ .run(newId, oldId));
832
+ },
833
+ /* ---------------- gaps ---------------- */
834
+ putGap(spec) {
835
+ const id = spec.id ?? `gp_${Date.now().toString(36)}_${(gapSeq += 1).toString(36)}`;
836
+ withRetry(() => db
837
+ .prepare(`INSERT OR IGNORE INTO gaps
838
+ (id, soul_id, user_id, about_entity_id, about_fact_id, topic,
839
+ tension, status, opened_at, closed_at, closed_by_fact_id)
840
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
841
+ .run(id, spec.soulId, spec.userId, spec.aboutEntityId ?? null, spec.aboutFactId ?? null, spec.topic, spec.tension ?? 0, spec.status ?? 'open', spec.openedAt ?? Date.now(), spec.closedAt ?? null, spec.closedByFactId ?? null));
842
+ const row = db.prepare('SELECT * FROM gaps WHERE id = ?').get(id);
843
+ return rowToGap(row);
844
+ },
845
+ openGaps(soulId, userId, limit = 20) {
846
+ const rows = db
847
+ .prepare(`SELECT * FROM gaps WHERE soul_id = ? AND user_id = ? AND status = 'open'
848
+ ORDER BY tension DESC, opened_at ASC LIMIT ?`)
849
+ .all(soulId, userId, limit);
850
+ return rows.map(rowToGap);
851
+ },
852
+ closeGap(id, closedByFactId, at = Date.now()) {
373
853
  withRetry(() => db
374
- .prepare(`INSERT OR IGNORE INTO turn_pairs
375
- (id, soul_id, user_id, session_id, turn, at, user_text, assistant_text,
376
- metadata_json, created_at)
377
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
378
- .run(pair.id, pair.soulId, pair.userId, pair.sessionId, pair.turn, pair.at, pair.userText, pair.assistantText, JSON.stringify(pair.metadata), pair.createdAt));
854
+ .prepare(`UPDATE gaps SET status = 'closed', closed_at = ?, closed_by_fact_id = ?
855
+ WHERE id = ?`)
856
+ .run(at, closedByFactId ?? null, id));
857
+ },
858
+ turn(id) {
859
+ const row = db.prepare('SELECT * FROM turns WHERE id = ?').get(id);
860
+ return row ? rowToTurnPair(row) : null;
861
+ },
862
+ openThread(spec) {
863
+ const id = spec.id ?? `th_${spec.openedAt.toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
864
+ withRetry(() => db
865
+ .prepare(`INSERT INTO threads (id, soul_id, user_id, session_id, topic, status, opened_at, last_turn_no)
866
+ VALUES (?, ?, ?, ?, ?, 'open', ?, ?)`)
867
+ .run(id, spec.soulId, spec.userId, spec.sessionId, spec.topic, spec.openedAt, spec.turnNo ?? null));
868
+ return {
869
+ id,
870
+ soulId: spec.soulId,
871
+ userId: spec.userId,
872
+ sessionId: spec.sessionId,
873
+ topic: spec.topic,
874
+ status: 'open',
875
+ openedAt: spec.openedAt,
876
+ lastTurnNo: spec.turnNo,
877
+ };
878
+ },
879
+ attachTurn(turnId, threadId) {
880
+ withRetry(() => db.prepare('UPDATE turns SET thread_id = ? WHERE id = ?').run(threadId, turnId));
881
+ },
882
+ touchThread(id, turnNo) {
883
+ withRetry(() => db.prepare('UPDATE threads SET last_turn_no = ? WHERE id = ?').run(turnNo, id));
884
+ },
885
+ updateThreadTopic(id, topic) {
886
+ withRetry(() => db.prepare('UPDATE threads SET topic = ? WHERE id = ?').run(topic, id));
887
+ },
888
+ openThreads(soulId, userId, limit = 5) {
889
+ const rows = db
890
+ .prepare(`SELECT * FROM threads WHERE soul_id = ? AND user_id = ? AND status = 'open'
891
+ ORDER BY COALESCE(last_turn_no, 0) DESC, opened_at DESC LIMIT ?`)
892
+ .all(soulId, userId, limit);
893
+ return rows.map(rowToThread);
894
+ },
895
+ thread(id) {
896
+ const row = db.prepare('SELECT * FROM threads WHERE id = ?').get(id);
897
+ return row ? rowToThread(row) : null;
898
+ },
899
+ closeThread(id, at, summaryFactId) {
900
+ withRetry(() => db
901
+ .prepare("UPDATE threads SET status = 'closed', closed_at = ?, summary_fact_id = ? WHERE id = ?")
902
+ .run(at, summaryFactId ?? null, id));
903
+ },
904
+ /**
905
+ * Close topics the conversation has moved past.
906
+ *
907
+ * Three signals, ordered by how much they actually know:
908
+ *
909
+ * - `archived`: the user put the session away. A declaration, not an
910
+ * inference — the strongest signal, so it wins even over recency.
911
+ * - `quiet-turns`: inside the running session, the conversation moved on N
912
+ * turns. Counted as **rows**, because the host's turn number is not a
913
+ * per-exchange counter (it jumps by 10–30 per message).
914
+ * - `quiet-time`: silence in another session. The weakest signal, kept only
915
+ * as a safety net, and deliberately long: closing early writes a partial
916
+ * conclusion into durable memory, while closing late only delays one.
917
+ *
918
+ * The reason is returned so the log can say which signal actually fired, and
919
+ * so a threshold that fires too early is visible instead of silent.
920
+ */
921
+ closeIdleThreads(soulId, userId, sessionId, idleTurns, idleMs, archivedSessionIds, at) {
922
+ const archived = new Set(archivedSessionIds);
923
+ const open = db
924
+ .prepare(`SELECT t.id AS id, t.session_id AS session_id,
925
+ COALESCE((SELECT MAX(x.started_at) FROM turns x WHERE x.session_id = t.session_id),
926
+ t.opened_at) AS last_activity,
927
+ (SELECT COUNT(*) FROM turns x
928
+ WHERE x.session_id = t.session_id
929
+ AND x.turn_no > COALESCE(t.last_turn_no, 0)) AS quiet_turns
930
+ FROM threads t
931
+ WHERE t.soul_id = ? AND t.user_id = ? AND t.status = 'open'`)
932
+ .all(soulId, userId);
933
+ const cutoff = at - Math.max(60_000, idleMs);
934
+ const closing = [];
935
+ for (const row of open) {
936
+ const threadSession = String(row.session_id);
937
+ if (threadSession === sessionId) {
938
+ if (Number(row.quiet_turns) >= Math.max(1, idleTurns)) {
939
+ closing.push({ id: String(row.id), reason: 'quiet-turns' });
940
+ }
941
+ }
942
+ else if (archived.has(threadSession)) {
943
+ closing.push({ id: String(row.id), reason: 'archived' });
944
+ }
945
+ else if (Number(row.last_activity) <= cutoff) {
946
+ closing.push({ id: String(row.id), reason: 'quiet-time' });
947
+ }
948
+ }
949
+ const close = db.prepare("UPDATE threads SET status = 'closed', closed_at = ? WHERE id = ?");
950
+ for (const thread of closing)
951
+ withRetry(() => close.run(at, thread.id));
952
+ return closing;
953
+ },
954
+ threadTurns(threadId) {
955
+ const rows = db
956
+ .prepare('SELECT * FROM turns WHERE thread_id = ? ORDER BY turn_no ASC')
957
+ .all(threadId);
958
+ return rows.map(rowToTurnPair);
959
+ },
960
+ /**
961
+ * The thread worth handing over: a finished topic that produced a conclusion.
962
+ *
963
+ * A one-message question also closes as a "thread", and handing that over tells
964
+ * the next session nothing but noise ("we last talked about: who am I"). So a
965
+ * summary comes first; otherwise a topic needs at least two turns to qualify.
966
+ */
967
+ lastClosedThread(soulId, userId) {
968
+ const summarised = db
969
+ .prepare(`SELECT * FROM threads WHERE soul_id = ? AND user_id = ? AND status = 'closed'
970
+ AND summary_fact_id IS NOT NULL
971
+ ORDER BY COALESCE(closed_at, 0) DESC LIMIT 1`)
972
+ .get(soulId, userId);
973
+ if (summarised)
974
+ return rowToThread(summarised);
975
+ const substantial = db
976
+ .prepare(`SELECT t.* FROM threads t WHERE t.soul_id = ? AND t.user_id = ? AND t.status = 'closed'
977
+ AND (SELECT COUNT(*) FROM turns x WHERE x.thread_id = t.id) >= 2
978
+ ORDER BY COALESCE(t.closed_at, 0) DESC LIMIT 1`)
979
+ .get(soulId, userId);
980
+ return substantial ? rowToThread(substantial) : null;
981
+ },
982
+ /** The thread the previous turn belonged to, if any — the cheapest continuity hint. */
983
+ threadBefore(sessionId, turnNo) {
984
+ const row = db
985
+ .prepare(`SELECT thread_id FROM turns
986
+ WHERE session_id = ? AND turn_no < ? AND thread_id IS NOT NULL
987
+ ORDER BY turn_no DESC LIMIT 1`)
988
+ .get(sessionId, turnNo);
989
+ return row ? String(row.thread_id) : null;
990
+ },
991
+ previousTurnText(sessionId, turnNo) {
992
+ const row = db
993
+ .prepare(`SELECT user_text FROM turns
994
+ WHERE session_id = ? AND turn_no < ?
995
+ ORDER BY turn_no DESC LIMIT 1`)
996
+ .get(sessionId, turnNo);
997
+ const text = row ? String(row.user_text ?? '').trim() : '';
998
+ return text === '' ? null : text;
999
+ },
1000
+ factsByTurns(turnIds) {
1001
+ if (turnIds.length === 0)
1002
+ return [];
1003
+ const placeholders = turnIds.map(() => '?').join(',');
1004
+ const rows = db
1005
+ .prepare(`SELECT f.* FROM facts f WHERE f.source_turn_id IN (${placeholders}) ORDER BY f.created_at ASC`)
1006
+ .all(...turnIds);
1007
+ const slots = slotsOfRows(db, rows.map((row) => String(row.id)));
1008
+ return rows.map((row) => rowToFact(row, slots));
1009
+ },
1010
+ /**
1011
+ * Append slots to an existing fact, skipping ones it already has.
1012
+ *
1013
+ * Used when a duplicate conclusion is merged into the held one: the holder
1014
+ * keeps its statement, and gains the new evidence so it gets stronger rather
1015
+ * than being restated.
1016
+ */
1017
+ addSlots(factId, slots) {
1018
+ const existing = new Set(db
1019
+ .prepare("SELECT ref_fact_id, value_text, slot FROM fact_slots WHERE fact_id = ?")
1020
+ .all(factId).map((row) => `${String(row.slot)}|${String(row.ref_fact_id ?? row.value_text ?? '')}`));
1021
+ const insert = db.prepare(`INSERT INTO fact_slots (fact_id, slot, value_type, entity_id, value_num, value_text, ref_fact_id)
1022
+ VALUES (?, ?, ?, ?, ?, ?, ?)`);
1023
+ let added = 0;
1024
+ for (const s of slots) {
1025
+ const key = `${s.slot}|${s.value.kind === 'fact' ? s.value.factId : s.value.kind === 'entity' ? s.value.entityId : s.value.kind === 'text' ? s.value.value : String(s.value.value)}`;
1026
+ if (existing.has(key))
1027
+ continue;
1028
+ insert.run(factId, s.slot, s.value.kind, s.value.kind === 'entity' ? s.value.entityId : null, s.value.kind === 'number' ? s.value.value : null, s.value.kind === 'text' ? s.value.value : null, s.value.kind === 'fact' ? s.value.factId : null);
1029
+ existing.add(key);
1030
+ added += 1;
1031
+ }
1032
+ return added;
1033
+ },
1034
+ /** Retag a fact, e.g. a lesson that turned out to be a rule (ADR 0033). */
1035
+ setFactTypeName(factId, typeName) {
1036
+ // Role follows the label: markRule/markProfile change what the row is for, and a stale
1037
+ // role would send it to the wrong consumer (the exact split this refactor removes).
1038
+ const role = LEGACY_ROLE_BY_TYPE[typeName] ?? 'observation';
1039
+ withRetry(() => db.prepare('UPDATE facts SET type_name = ?, role = ? WHERE id = ?').run(typeName, role, factId));
1040
+ },
1041
+ setFactConfidence(factId, confidence) {
1042
+ withRetry(() => db.prepare('UPDATE facts SET confidence = ? WHERE id = ?').run(confidence, factId));
1043
+ },
1044
+ /**
1045
+ * Count that retrieval surfaced these facts.
1046
+ *
1047
+ * This is the rehearsal in 0019: a memory used often decays more slowly, so the
1048
+ * counter is the mechanism, not a statistic.
1049
+ */
1050
+ recordRecalls(factIds, at) {
1051
+ if (factIds.length === 0)
1052
+ return;
1053
+ const stmt = db.prepare('UPDATE facts SET recall_count = recall_count + 1, last_recalled_at = ? WHERE id = ?');
1054
+ for (const id of factIds)
1055
+ withRetry(() => stmt.run(at, id));
1056
+ },
1057
+ /**
1058
+ * The entity timeline: facts about one person/thing, newest event first.
1059
+ *
1060
+ * This is the primitive behind "上次和 a 吃饭" — the subject decides what is
1061
+ * fetched, and `predicate` narrows within that subject's own facts. It must
1062
+ * never look outside the subject: answering about b when asked about a is the
1063
+ * one error worse than saying nothing.
1064
+ */
1065
+ /**
1066
+ * The entity timeline: facts about one person/thing, ranked.
1067
+ *
1068
+ * This is the primitive behind "上次和 a 吃饭". Recency alone is not enough — an
1069
+ * entity with a thousand facts would return its three most recent ones, which
1070
+ * answers "what happened to a lately", not "the meal". So the ranking is
1071
+ * relevance to the question × power-law recency × recall practice, the same
1072
+ * weights the relevance primitive uses (0019, 0020).
1073
+ *
1074
+ * It must never look outside the subject: answering about b when asked about a
1075
+ * is the one error worse than saying nothing.
1076
+ */
1077
+ latestAbout(soulId, userId, entityName, options = {}) {
1078
+ const limit = Math.max(1, options.limit ?? 5);
1079
+ const predicate = options.predicate?.trim();
1080
+ // Bounded so a long-lived entity cannot make this unbounded.
1081
+ const CANDIDATES = 500;
1082
+ const rows = db
1083
+ .prepare(`SELECT DISTINCT f.* FROM facts f
1084
+ JOIN fact_slots s ON s.fact_id = f.id AND s.value_type = 'entity'
1085
+ JOIN entities e ON e.id = s.entity_id
1086
+ WHERE f.soul_id = ? AND f.user_id = ? AND f.status != '${FACT_STATUS.superseded}'
1087
+ AND (e.name = ? OR e.aliases LIKE ?)
1088
+ AND (? IS NULL OR f.statement LIKE ? OR COALESCE(f.type_name, '') LIKE ?)
1089
+ ORDER BY COALESCE(f.at, f.created_at) DESC
1090
+ LIMIT ?`)
1091
+ .all(soulId, userId, entityName, `%${entityName}%`, predicate ?? null, predicate ? `%${predicate}%` : null, predicate ? `%${predicate}%` : null, CANDIDATES);
1092
+ const slots = slotsOfRows(db, rows.map((row) => String(row.id)));
1093
+ const facts = rows.map((row) => rowToFact(row, slots));
1094
+ const query = options.query?.trim();
1095
+ if (!query)
1096
+ return facts.slice(0, limit);
1097
+ const now = options.now ?? Date.now();
1098
+ const queryTokens = tokenize(query);
1099
+ const stats = buildCorpusStats(facts.map((fact) => tokenize(fact.statement)));
1100
+ const scored = facts.map((fact) => {
1101
+ const relevanceScore = bm25(queryTokens, tokenize(fact.statement), stats);
1102
+ const ageDays = Math.max(0, (now - (fact.at ?? fact.createdAt)) / 86_400_000);
1103
+ // Power law, not exponential: the tail of a memory stays reachable (0019).
1104
+ const decay = 1 / Math.sqrt(1 + ageDays);
1105
+ const practice = 1 + 0.2 * Math.log(1 + fact.recallCount);
1106
+ return { fact, score: relevanceScore * decay * practice };
1107
+ });
1108
+ scored.sort((a, b) => b.score - a.score);
1109
+ return scored.slice(0, limit).map((entry) => entry.fact);
1110
+ },
1111
+ turnByTurnNo(sessionId, turnNo) {
1112
+ const row = db
1113
+ .prepare('SELECT * FROM turns WHERE session_id = ? AND turn_no = ? LIMIT 1')
1114
+ .get(sessionId, turnNo);
1115
+ return row ? rowToTurnPair(row) : null;
1116
+ },
1117
+ stepsOf(turnId) {
1118
+ const rows = db
1119
+ .prepare('SELECT * FROM steps WHERE turn_id = ? ORDER BY idx ASC')
1120
+ .all(turnId);
1121
+ return rows.map((row) => ({
1122
+ idx: Number(row.idx),
1123
+ kind: String(row.kind),
1124
+ text: row.text === null ? undefined : String(row.text),
1125
+ toolName: row.tool_name === null ? undefined : String(row.tool_name),
1126
+ toolArgsChars: row.tool_args_chars === null ? undefined : Number(row.tool_args_chars),
1127
+ toolResultChars: row.tool_result_chars === null ? undefined : Number(row.tool_result_chars),
1128
+ ok: row.ok === null ? undefined : Number(row.ok) === 1,
1129
+ at: Number(row.at),
1130
+ }));
1131
+ },
1132
+ putTurnPair(pair, steps) {
1133
+ this.putTurn({
1134
+ id: pair.id,
1135
+ soulId: pair.soulId,
1136
+ userId: pair.userId,
1137
+ sessionId: pair.sessionId,
1138
+ turnNo: pair.turn,
1139
+ startedAt: pair.at,
1140
+ endedAt: pair.at,
1141
+ userText: pair.userText,
1142
+ status: 'closed',
1143
+ createdAt: pair.createdAt,
1144
+ // Adapters may hand over the real step list (reasoning, text, tool) so a
1145
+ // turn keeps its shape; otherwise the reply collapses into one text step.
1146
+ steps: steps && steps.length > 0
1147
+ ? steps
1148
+ : pair.assistantText
1149
+ ? [{ idx: 0, kind: 'text', text: pair.assistantText, at: pair.at }]
1150
+ : [],
1151
+ });
379
1152
  },
380
1153
  recallTurnPairs(spec) {
381
1154
  // No cap by default: every related exchange is returned (bounded only by
382
1155
  // RECALL_CANDIDATES, which limits rows read, not rows injected).
383
1156
  const limit = spec.limit ?? RECALL_CANDIDATES;
384
1157
  const rows = db
385
- .prepare(`SELECT * FROM turn_pairs
386
- WHERE soul_id = ? AND user_id = ? AND at >= ?
387
- ORDER BY at DESC LIMIT ?`)
1158
+ .prepare(`SELECT * FROM turns
1159
+ WHERE soul_id = ? AND user_id = ? AND started_at >= ?
1160
+ ORDER BY started_at DESC LIMIT ?`)
388
1161
  .all(spec.soulId, spec.userId, spec.since ?? 0, Math.max(limit, RECALL_CANDIDATES));
389
1162
  const pairs = rows.map(rowToTurnPair);
390
1163
  const query = spec.query?.trim();
@@ -402,12 +1175,45 @@ export function openSqlite(file) {
402
1175
  .slice(0, limit)
403
1176
  .map((scored) => scored.pair);
404
1177
  },
405
- countTurnPairs(soulId, userId) {
406
- const row = db
407
- .prepare('SELECT COUNT(*) AS n FROM turn_pairs WHERE soul_id = ? AND user_id = ?')
408
- .get(soulId, userId);
1178
+ countTurnPairs(soulId, userId, sessionId) {
1179
+ const row = sessionId
1180
+ ? db
1181
+ .prepare('SELECT COUNT(*) AS n FROM turns WHERE soul_id = ? AND user_id = ? AND session_id = ?')
1182
+ .get(soulId, userId, sessionId)
1183
+ : db
1184
+ .prepare('SELECT COUNT(*) AS n FROM turns WHERE soul_id = ? AND user_id = ?')
1185
+ .get(soulId, userId);
409
1186
  return Number(row.n);
410
1187
  },
1188
+ listSessions(soulId, userId, limit) {
1189
+ const rows = db
1190
+ .prepare(`SELECT session_id, MIN(started_at) AS start, MAX(started_at) AS end, COUNT(*) AS turns
1191
+ FROM turns WHERE soul_id = ? AND user_id = ?
1192
+ GROUP BY session_id ORDER BY MAX(started_at) DESC LIMIT ?`)
1193
+ .all(soulId, userId, limit);
1194
+ return rows.map((row) => ({
1195
+ sessionId: String(row.session_id),
1196
+ start: Number(row.start),
1197
+ end: Number(row.end),
1198
+ turns: Number(row.turns),
1199
+ }));
1200
+ },
1201
+ sessionPairs(sessionId, soulId, userId, limit) {
1202
+ const rows = db
1203
+ .prepare(`SELECT * FROM turns
1204
+ WHERE soul_id = ? AND user_id = ? AND session_id = ?
1205
+ ORDER BY started_at ASC LIMIT ?`)
1206
+ .all(soulId, userId, sessionId, limit);
1207
+ return rows.map(rowToTurnPair);
1208
+ },
1209
+ sessionTail(sessionId, soulId, userId, limit) {
1210
+ const rows = db
1211
+ .prepare(`SELECT * FROM turns
1212
+ WHERE soul_id = ? AND user_id = ? AND session_id = ?
1213
+ ORDER BY started_at DESC LIMIT ?`)
1214
+ .all(soulId, userId, sessionId, limit);
1215
+ return rows.map(rowToTurnPair).reverse();
1216
+ },
411
1217
  close() {
412
1218
  db.close();
413
1219
  },
@@ -417,6 +1223,111 @@ export function openSqlite(file) {
417
1223
  function pairTokens(pair) {
418
1224
  return [...tokenize(pair.userText), ...tokenize(pair.assistantText)];
419
1225
  }
1226
+ /** Id counters keep fact/gap ids unique within a process without extra deps. */
1227
+ let factSeq = 0;
1228
+ let gapSeq = 0;
1229
+ function parseJson(raw, fallback) {
1230
+ try {
1231
+ return raw ? JSON.parse(String(raw)) : fallback;
1232
+ }
1233
+ catch {
1234
+ return fallback;
1235
+ }
1236
+ }
1237
+ function rowToEntity(row) {
1238
+ return {
1239
+ id: String(row.id),
1240
+ soulId: String(row.soul_id),
1241
+ userId: String(row.user_id),
1242
+ type: String(row.type),
1243
+ name: String(row.name),
1244
+ aliases: parseJson(row.aliases, []),
1245
+ attrs: parseJson(row.attrs, {}),
1246
+ firstSeenAt: Number(row.first_seen_at),
1247
+ lastMentionedAt: Number(row.last_mentioned_at),
1248
+ createdAt: Number(row.created_at),
1249
+ };
1250
+ }
1251
+ /** Slots of many facts in one query, grouped by fact id. */
1252
+ function slotsOfRows(db, factIds) {
1253
+ const map = new Map();
1254
+ if (factIds.length === 0)
1255
+ return map;
1256
+ const placeholders = factIds.map(() => '?').join(',');
1257
+ const rows = db
1258
+ .prepare(`SELECT * FROM fact_slots WHERE fact_id IN (${placeholders}) ORDER BY id ASC`)
1259
+ .all(...factIds);
1260
+ for (const row of rows) {
1261
+ const factId = String(row.fact_id);
1262
+ const list = map.get(factId) ?? [];
1263
+ const valueType = String(row.value_type);
1264
+ const value = valueType === 'entity'
1265
+ ? { kind: 'entity', entityId: String(row.entity_id) }
1266
+ : valueType === 'number'
1267
+ ? { kind: 'number', value: Number(row.value_num) }
1268
+ : valueType === 'fact'
1269
+ ? { kind: 'fact', factId: String(row.ref_fact_id) }
1270
+ : { kind: 'text', value: String(row.value_text ?? '') };
1271
+ list.push({ slot: String(row.slot), value });
1272
+ map.set(factId, list);
1273
+ }
1274
+ return map;
1275
+ }
1276
+ function rowToFact(row, slots) {
1277
+ const id = String(row.id);
1278
+ return {
1279
+ id,
1280
+ soulId: String(row.soul_id),
1281
+ userId: String(row.user_id),
1282
+ statement: String(row.statement),
1283
+ typeName: row.type_name === null ? undefined : String(row.type_name),
1284
+ role: row.role === null || row.role === undefined ? undefined : String(row.role),
1285
+ holder: String(row.holder),
1286
+ at: row.at === null ? undefined : Number(row.at),
1287
+ atText: row.at_text === null ? undefined : String(row.at_text),
1288
+ atEnd: row.at_end === null ? undefined : Number(row.at_end),
1289
+ atPrecision: (row.at_precision ?? 'unknown'),
1290
+ placeId: row.place_id === null ? undefined : String(row.place_id),
1291
+ status: String(row.status),
1292
+ confidence: Number(row.confidence),
1293
+ source: String(row.source),
1294
+ sourceTurnId: row.source_turn_id === null ? undefined : String(row.source_turn_id),
1295
+ supersededBy: row.superseded_by === null ? undefined : String(row.superseded_by),
1296
+ createdAt: Number(row.created_at),
1297
+ recallCount: Number(row.recall_count ?? 0),
1298
+ lastRecalledAt: row.last_recalled_at === null || row.last_recalled_at === undefined ? undefined : Number(row.last_recalled_at),
1299
+ slots: slots.get(id) ?? [],
1300
+ };
1301
+ }
1302
+ function rowToThread(row) {
1303
+ return {
1304
+ id: String(row.id),
1305
+ soulId: String(row.soul_id),
1306
+ userId: String(row.user_id),
1307
+ sessionId: String(row.session_id),
1308
+ topic: String(row.topic),
1309
+ status: String(row.status),
1310
+ openedAt: Number(row.opened_at),
1311
+ closedAt: row.closed_at === null || row.closed_at === undefined ? undefined : Number(row.closed_at),
1312
+ lastTurnNo: row.last_turn_no === null || row.last_turn_no === undefined ? undefined : Number(row.last_turn_no),
1313
+ summaryFactId: row.summary_fact_id === null || row.summary_fact_id === undefined ? undefined : String(row.summary_fact_id),
1314
+ };
1315
+ }
1316
+ function rowToGap(row) {
1317
+ return {
1318
+ id: String(row.id),
1319
+ soulId: String(row.soul_id),
1320
+ userId: String(row.user_id),
1321
+ aboutEntityId: row.about_entity_id === null ? undefined : String(row.about_entity_id),
1322
+ aboutFactId: row.about_fact_id === null ? undefined : String(row.about_fact_id),
1323
+ topic: String(row.topic),
1324
+ tension: Number(row.tension),
1325
+ status: String(row.status),
1326
+ openedAt: Number(row.opened_at),
1327
+ closedAt: row.closed_at === null ? undefined : Number(row.closed_at),
1328
+ closedByFactId: row.closed_by_fact_id === null ? undefined : String(row.closed_by_fact_id),
1329
+ };
1330
+ }
420
1331
  function rowToTurnPair(row) {
421
1332
  let metadata = {};
422
1333
  try {
@@ -430,8 +1341,8 @@ function rowToTurnPair(row) {
430
1341
  soulId: String(row.soul_id),
431
1342
  userId: String(row.user_id),
432
1343
  sessionId: String(row.session_id),
433
- turn: Number(row.turn),
434
- at: Number(row.at),
1344
+ turn: Number(row.turn ?? row.turn_no),
1345
+ at: Number(row.at ?? row.started_at),
435
1346
  userText: String(row.user_text),
436
1347
  assistantText: String(row.assistant_text),
437
1348
  metadata,