hippo-memory 1.14.0 → 1.15.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/dist/db.js CHANGED
@@ -6,83 +6,83 @@ import { cleanupArchivedMirrors } from './raw-archive-mirror-cleanup.js';
6
6
  import { PACKAGE_VERSION, compareSemver } from './version.js';
7
7
  const require = createRequire(import.meta.url);
8
8
  const { DatabaseSync } = require('node:sqlite');
9
- const CURRENT_SCHEMA_VERSION = 29;
9
+ const CURRENT_SCHEMA_VERSION = 30;
10
10
  const MIGRATIONS = [
11
11
  {
12
12
  version: 1,
13
13
  up: (db) => {
14
- db.exec(`
15
- CREATE TABLE IF NOT EXISTS memories (
16
- id TEXT PRIMARY KEY,
17
- created TEXT NOT NULL,
18
- last_retrieved TEXT NOT NULL,
19
- retrieval_count INTEGER NOT NULL,
20
- strength REAL NOT NULL,
21
- half_life_days REAL NOT NULL,
22
- layer TEXT NOT NULL,
23
- tags_json TEXT NOT NULL,
24
- emotional_valence TEXT NOT NULL,
25
- schema_fit REAL NOT NULL,
26
- source TEXT NOT NULL,
27
- outcome_score REAL,
28
- conflicts_with_json TEXT NOT NULL,
29
- pinned INTEGER NOT NULL,
30
- confidence TEXT NOT NULL,
31
- content TEXT NOT NULL,
32
- updated_at TEXT NOT NULL DEFAULT (datetime('now'))
33
- );
34
-
35
- CREATE INDEX IF NOT EXISTS idx_memories_layer_created ON memories(layer, created);
36
- CREATE INDEX IF NOT EXISTS idx_memories_last_retrieved ON memories(last_retrieved);
37
-
38
- CREATE TABLE IF NOT EXISTS consolidation_runs (
39
- id INTEGER PRIMARY KEY AUTOINCREMENT,
40
- timestamp TEXT NOT NULL,
41
- decayed INTEGER NOT NULL,
42
- merged INTEGER NOT NULL,
43
- removed INTEGER NOT NULL
44
- );
14
+ db.exec(`
15
+ CREATE TABLE IF NOT EXISTS memories (
16
+ id TEXT PRIMARY KEY,
17
+ created TEXT NOT NULL,
18
+ last_retrieved TEXT NOT NULL,
19
+ retrieval_count INTEGER NOT NULL,
20
+ strength REAL NOT NULL,
21
+ half_life_days REAL NOT NULL,
22
+ layer TEXT NOT NULL,
23
+ tags_json TEXT NOT NULL,
24
+ emotional_valence TEXT NOT NULL,
25
+ schema_fit REAL NOT NULL,
26
+ source TEXT NOT NULL,
27
+ outcome_score REAL,
28
+ conflicts_with_json TEXT NOT NULL,
29
+ pinned INTEGER NOT NULL,
30
+ confidence TEXT NOT NULL,
31
+ content TEXT NOT NULL,
32
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
33
+ );
34
+
35
+ CREATE INDEX IF NOT EXISTS idx_memories_layer_created ON memories(layer, created);
36
+ CREATE INDEX IF NOT EXISTS idx_memories_last_retrieved ON memories(last_retrieved);
37
+
38
+ CREATE TABLE IF NOT EXISTS consolidation_runs (
39
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
40
+ timestamp TEXT NOT NULL,
41
+ decayed INTEGER NOT NULL,
42
+ merged INTEGER NOT NULL,
43
+ removed INTEGER NOT NULL
44
+ );
45
45
  `);
46
46
  },
47
47
  },
48
48
  {
49
49
  version: 2,
50
50
  up: (db) => {
51
- db.exec(`
52
- CREATE TABLE IF NOT EXISTS task_snapshots (
53
- id INTEGER PRIMARY KEY AUTOINCREMENT,
54
- task TEXT NOT NULL,
55
- summary TEXT NOT NULL,
56
- next_step TEXT NOT NULL,
57
- status TEXT NOT NULL,
58
- source TEXT NOT NULL,
59
- created_at TEXT NOT NULL,
60
- updated_at TEXT NOT NULL
61
- );
62
-
63
- CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated
64
- ON task_snapshots(status, updated_at DESC, id DESC);
51
+ db.exec(`
52
+ CREATE TABLE IF NOT EXISTS task_snapshots (
53
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
54
+ task TEXT NOT NULL,
55
+ summary TEXT NOT NULL,
56
+ next_step TEXT NOT NULL,
57
+ status TEXT NOT NULL,
58
+ source TEXT NOT NULL,
59
+ created_at TEXT NOT NULL,
60
+ updated_at TEXT NOT NULL
61
+ );
62
+
63
+ CREATE INDEX IF NOT EXISTS idx_task_snapshots_status_updated
64
+ ON task_snapshots(status, updated_at DESC, id DESC);
65
65
  `);
66
66
  },
67
67
  },
68
68
  {
69
69
  version: 3,
70
70
  up: (db) => {
71
- db.exec(`
72
- CREATE TABLE IF NOT EXISTS memory_conflicts (
73
- id INTEGER PRIMARY KEY AUTOINCREMENT,
74
- memory_a_id TEXT NOT NULL,
75
- memory_b_id TEXT NOT NULL,
76
- reason TEXT NOT NULL,
77
- score REAL NOT NULL DEFAULT 0,
78
- status TEXT NOT NULL,
79
- detected_at TEXT NOT NULL,
80
- updated_at TEXT NOT NULL,
81
- UNIQUE(memory_a_id, memory_b_id)
82
- );
83
-
84
- CREATE INDEX IF NOT EXISTS idx_memory_conflicts_status_updated
85
- ON memory_conflicts(status, updated_at DESC, id DESC);
71
+ db.exec(`
72
+ CREATE TABLE IF NOT EXISTS memory_conflicts (
73
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
74
+ memory_a_id TEXT NOT NULL,
75
+ memory_b_id TEXT NOT NULL,
76
+ reason TEXT NOT NULL,
77
+ score REAL NOT NULL DEFAULT 0,
78
+ status TEXT NOT NULL,
79
+ detected_at TEXT NOT NULL,
80
+ updated_at TEXT NOT NULL,
81
+ UNIQUE(memory_a_id, memory_b_id)
82
+ );
83
+
84
+ CREATE INDEX IF NOT EXISTS idx_memory_conflicts_status_updated
85
+ ON memory_conflicts(status, updated_at DESC, id DESC);
86
86
  `);
87
87
  },
88
88
  },
@@ -92,67 +92,67 @@ const MIGRATIONS = [
92
92
  if (!tableHasColumn(db, 'task_snapshots', 'session_id')) {
93
93
  db.exec(`ALTER TABLE task_snapshots ADD COLUMN session_id TEXT`);
94
94
  }
95
- db.exec(`
96
- CREATE TABLE IF NOT EXISTS session_events (
97
- id INTEGER PRIMARY KEY AUTOINCREMENT,
98
- session_id TEXT NOT NULL,
99
- task TEXT,
100
- event_type TEXT NOT NULL,
101
- content TEXT NOT NULL,
102
- source TEXT NOT NULL,
103
- metadata_json TEXT NOT NULL,
104
- created_at TEXT NOT NULL
105
- );
106
-
107
- CREATE INDEX IF NOT EXISTS idx_session_events_session_created
108
- ON session_events(session_id, created_at DESC, id DESC);
109
-
110
- CREATE INDEX IF NOT EXISTS idx_session_events_task_created
111
- ON session_events(task, created_at DESC, id DESC);
95
+ db.exec(`
96
+ CREATE TABLE IF NOT EXISTS session_events (
97
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ session_id TEXT NOT NULL,
99
+ task TEXT,
100
+ event_type TEXT NOT NULL,
101
+ content TEXT NOT NULL,
102
+ source TEXT NOT NULL,
103
+ metadata_json TEXT NOT NULL,
104
+ created_at TEXT NOT NULL
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_session_events_session_created
108
+ ON session_events(session_id, created_at DESC, id DESC);
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_session_events_task_created
111
+ ON session_events(task, created_at DESC, id DESC);
112
112
  `);
113
113
  },
114
114
  },
115
115
  {
116
116
  version: 5,
117
117
  up: (db) => {
118
- db.exec(`
119
- CREATE TABLE IF NOT EXISTS session_handoffs (
120
- id INTEGER PRIMARY KEY AUTOINCREMENT,
121
- session_id TEXT NOT NULL,
122
- repo_root TEXT,
123
- task_id TEXT,
124
- summary TEXT NOT NULL,
125
- next_action TEXT,
126
- artifacts_json TEXT NOT NULL DEFAULT '[]',
127
- created_at TEXT NOT NULL
128
- );
129
-
130
- CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
131
- ON session_handoffs(session_id, created_at DESC);
118
+ db.exec(`
119
+ CREATE TABLE IF NOT EXISTS session_handoffs (
120
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
121
+ session_id TEXT NOT NULL,
122
+ repo_root TEXT,
123
+ task_id TEXT,
124
+ summary TEXT NOT NULL,
125
+ next_action TEXT,
126
+ artifacts_json TEXT NOT NULL DEFAULT '[]',
127
+ created_at TEXT NOT NULL
128
+ );
129
+
130
+ CREATE INDEX IF NOT EXISTS idx_session_handoffs_session
131
+ ON session_handoffs(session_id, created_at DESC);
132
132
  `);
133
133
  },
134
134
  },
135
135
  {
136
136
  version: 6,
137
137
  up: (db) => {
138
- db.exec(`
139
- CREATE TABLE IF NOT EXISTS working_memory (
140
- id INTEGER PRIMARY KEY AUTOINCREMENT,
141
- scope TEXT NOT NULL,
142
- session_id TEXT,
143
- task_id TEXT,
144
- importance REAL NOT NULL DEFAULT 0,
145
- content TEXT NOT NULL,
146
- metadata_json TEXT NOT NULL DEFAULT '{}',
147
- created_at TEXT NOT NULL,
148
- updated_at TEXT NOT NULL
149
- );
150
-
151
- CREATE INDEX IF NOT EXISTS idx_working_memory_scope
152
- ON working_memory(scope, importance DESC, created_at DESC);
153
-
154
- CREATE INDEX IF NOT EXISTS idx_working_memory_session
155
- ON working_memory(session_id, created_at DESC);
138
+ db.exec(`
139
+ CREATE TABLE IF NOT EXISTS working_memory (
140
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
141
+ scope TEXT NOT NULL,
142
+ session_id TEXT,
143
+ task_id TEXT,
144
+ importance REAL NOT NULL DEFAULT 0,
145
+ content TEXT NOT NULL,
146
+ metadata_json TEXT NOT NULL DEFAULT '{}',
147
+ created_at TEXT NOT NULL,
148
+ updated_at TEXT NOT NULL
149
+ );
150
+
151
+ CREATE INDEX IF NOT EXISTS idx_working_memory_scope
152
+ ON working_memory(scope, importance DESC, created_at DESC);
153
+
154
+ CREATE INDEX IF NOT EXISTS idx_working_memory_session
155
+ ON working_memory(session_id, created_at DESC);
156
156
  `);
157
157
  },
158
158
  },
@@ -193,9 +193,9 @@ const MIGRATIONS = [
193
193
  if (!tableHasColumn(db, 'memories', 'source_session_id')) {
194
194
  db.exec(`ALTER TABLE memories ADD COLUMN source_session_id TEXT`);
195
195
  }
196
- db.exec(`
197
- CREATE INDEX IF NOT EXISTS idx_memories_source_session_id
198
- ON memories(source_session_id) WHERE source_session_id IS NOT NULL
196
+ db.exec(`
197
+ CREATE INDEX IF NOT EXISTS idx_memories_source_session_id
198
+ ON memories(source_session_id) WHERE source_session_id IS NOT NULL
199
199
  `);
200
200
  },
201
201
  },
@@ -257,44 +257,44 @@ const MIGRATIONS = [
257
257
  db.exec(`UPDATE memories SET kind = 'superseded' WHERE kind IS NULL AND superseded_by IS NOT NULL`);
258
258
  db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
259
259
  // raw_archive: legitimate path for kind='raw' removal (used by archiveRawMemory).
260
- db.exec(`
261
- CREATE TABLE IF NOT EXISTS raw_archive (
262
- id INTEGER PRIMARY KEY AUTOINCREMENT,
263
- memory_id TEXT NOT NULL,
264
- archived_at TEXT NOT NULL,
265
- reason TEXT NOT NULL,
266
- archived_by TEXT,
267
- payload_json TEXT NOT NULL
268
- )
260
+ db.exec(`
261
+ CREATE TABLE IF NOT EXISTS raw_archive (
262
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
263
+ memory_id TEXT NOT NULL,
264
+ archived_at TEXT NOT NULL,
265
+ reason TEXT NOT NULL,
266
+ archived_by TEXT,
267
+ payload_json TEXT NOT NULL
268
+ )
269
269
  `);
270
270
  db.exec(`CREATE INDEX IF NOT EXISTS idx_raw_archive_memory_id ON raw_archive(memory_id)`);
271
271
  // Append-only invariant: kind='raw' rows cannot be deleted directly.
272
272
  // Use raw_archive flow: archive-then-update-then-delete (see src/raw-archive.ts).
273
- db.exec(`
274
- CREATE TRIGGER IF NOT EXISTS trg_memories_raw_append_only
275
- BEFORE DELETE ON memories
276
- WHEN OLD.kind = 'raw'
277
- BEGIN
278
- SELECT RAISE(ABORT, 'raw is append-only');
279
- END
273
+ db.exec(`
274
+ CREATE TRIGGER IF NOT EXISTS trg_memories_raw_append_only
275
+ BEFORE DELETE ON memories
276
+ WHEN OLD.kind = 'raw'
277
+ BEGIN
278
+ SELECT RAISE(ABORT, 'raw is append-only');
279
+ END
280
280
  `);
281
281
  // CHECK substitute: ALTER TABLE cannot add CHECK, so enforce kind allowed-set
282
282
  // via INSERT/UPDATE triggers.
283
- db.exec(`
284
- CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_insert
285
- BEFORE INSERT ON memories
286
- WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
287
- BEGIN
288
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
289
- END
283
+ db.exec(`
284
+ CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_insert
285
+ BEFORE INSERT ON memories
286
+ WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
287
+ BEGIN
288
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
289
+ END
290
290
  `);
291
- db.exec(`
292
- CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_update
293
- BEFORE UPDATE ON memories
294
- WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
295
- BEGIN
296
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
297
- END
291
+ db.exec(`
292
+ CREATE TRIGGER IF NOT EXISTS trg_memories_kind_check_update
293
+ BEFORE UPDATE ON memories
294
+ WHEN NEW.kind IS NOT NULL AND NEW.kind NOT IN ('raw','distilled','superseded','archived')
295
+ BEGIN
296
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived');
297
+ END
298
298
  `);
299
299
  },
300
300
  },
@@ -312,30 +312,30 @@ const MIGRATIONS = [
312
312
  // allowed (different timestamps).
313
313
  db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_insert`);
314
314
  db.exec(`DROP TRIGGER IF EXISTS trg_memories_kind_check_update`);
315
- db.exec(`
316
- CREATE TRIGGER trg_memories_kind_check_insert
317
- BEFORE INSERT ON memories
318
- WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
319
- BEGIN
320
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
321
- END
315
+ db.exec(`
316
+ CREATE TRIGGER trg_memories_kind_check_insert
317
+ BEFORE INSERT ON memories
318
+ WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
319
+ BEGIN
320
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
321
+ END
322
322
  `);
323
- db.exec(`
324
- CREATE TRIGGER trg_memories_kind_check_update
325
- BEFORE UPDATE ON memories
326
- WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
327
- BEGIN
328
- SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
329
- END
323
+ db.exec(`
324
+ CREATE TRIGGER trg_memories_kind_check_update
325
+ BEFORE UPDATE ON memories
326
+ WHEN NEW.kind IS NULL OR NEW.kind NOT IN ('raw','distilled','superseded','archived')
327
+ BEGIN
328
+ SELECT RAISE(ABORT, 'invalid kind: must be raw|distilled|superseded|archived (not null)');
329
+ END
330
330
  `);
331
331
  // Defensive: any rows that somehow have NULL kind get fixed (shouldn't exist post-v14
332
332
  // backfill, but cheap insurance).
333
333
  db.exec(`UPDATE memories SET kind = 'distilled' WHERE kind IS NULL`);
334
334
  // raw_archive uniqueness. SQLite cannot ADD CONSTRAINT, but a partial unique index
335
335
  // on (memory_id, archived_at) is equivalent for INSERT-time enforcement.
336
- db.exec(`
337
- CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_archive_id_at
338
- ON raw_archive(memory_id, archived_at)
336
+ db.exec(`
337
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_raw_archive_id_at
338
+ ON raw_archive(memory_id, archived_at)
339
339
  `);
340
340
  },
341
341
  },
@@ -369,31 +369,31 @@ const MIGRATIONS = [
369
369
  // A5 stub auth: api_keys (scrypt-hashed; plaintext returned to caller exactly once)
370
370
  // and audit_log (append-only mutation trail). Both carry tenant_id from day 1 so
371
371
  // future multi-tenant enforcement is a config flip, not a re-migration.
372
- db.exec(`
373
- CREATE TABLE IF NOT EXISTS api_keys (
374
- id INTEGER PRIMARY KEY AUTOINCREMENT,
375
- key_id TEXT UNIQUE NOT NULL,
376
- key_hash TEXT NOT NULL,
377
- tenant_id TEXT NOT NULL DEFAULT 'default',
378
- label TEXT,
379
- created_at TEXT NOT NULL,
380
- revoked_at TEXT
381
- )
372
+ db.exec(`
373
+ CREATE TABLE IF NOT EXISTS api_keys (
374
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
375
+ key_id TEXT UNIQUE NOT NULL,
376
+ key_hash TEXT NOT NULL,
377
+ tenant_id TEXT NOT NULL DEFAULT 'default',
378
+ label TEXT,
379
+ created_at TEXT NOT NULL,
380
+ revoked_at TEXT
381
+ )
382
382
  `);
383
- db.exec(`
384
- CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
385
- ON api_keys(tenant_id) WHERE revoked_at IS NULL
383
+ db.exec(`
384
+ CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
385
+ ON api_keys(tenant_id) WHERE revoked_at IS NULL
386
386
  `);
387
- db.exec(`
388
- CREATE TABLE IF NOT EXISTS audit_log (
389
- id INTEGER PRIMARY KEY AUTOINCREMENT,
390
- ts TEXT NOT NULL,
391
- tenant_id TEXT NOT NULL DEFAULT 'default',
392
- actor TEXT NOT NULL,
393
- op TEXT NOT NULL,
394
- target_id TEXT,
395
- metadata_json TEXT NOT NULL DEFAULT '{}'
396
- )
387
+ db.exec(`
388
+ CREATE TABLE IF NOT EXISTS audit_log (
389
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
390
+ ts TEXT NOT NULL,
391
+ tenant_id TEXT NOT NULL DEFAULT 'default',
392
+ actor TEXT NOT NULL,
393
+ op TEXT NOT NULL,
394
+ target_id TEXT,
395
+ metadata_json TEXT NOT NULL DEFAULT '{}'
396
+ )
397
397
  `);
398
398
  db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
399
399
  },
@@ -403,102 +403,102 @@ const MIGRATIONS = [
403
403
  up: (db) => {
404
404
  // E1.3 Slack ingestion: idempotency log, per-channel backfill cursors, DLQ.
405
405
  // See docs/plans/2026-04-29-e1.3-slack-ingestion.md.
406
- db.exec(`
407
- CREATE TABLE IF NOT EXISTS slack_event_log (
408
- event_id TEXT PRIMARY KEY,
409
- ingested_at TEXT NOT NULL,
410
- memory_id TEXT
411
- )
406
+ db.exec(`
407
+ CREATE TABLE IF NOT EXISTS slack_event_log (
408
+ event_id TEXT PRIMARY KEY,
409
+ ingested_at TEXT NOT NULL,
410
+ memory_id TEXT
411
+ )
412
412
  `);
413
413
  db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_event_log_memory ON slack_event_log(memory_id) WHERE memory_id IS NOT NULL`);
414
- db.exec(`
415
- CREATE TABLE IF NOT EXISTS slack_cursors (
416
- tenant_id TEXT NOT NULL,
417
- channel_id TEXT NOT NULL,
418
- latest_ts TEXT NOT NULL,
419
- updated_at TEXT NOT NULL,
420
- PRIMARY KEY (tenant_id, channel_id)
421
- )
414
+ db.exec(`
415
+ CREATE TABLE IF NOT EXISTS slack_cursors (
416
+ tenant_id TEXT NOT NULL,
417
+ channel_id TEXT NOT NULL,
418
+ latest_ts TEXT NOT NULL,
419
+ updated_at TEXT NOT NULL,
420
+ PRIMARY KEY (tenant_id, channel_id)
421
+ )
422
422
  `);
423
- db.exec(`
424
- CREATE TABLE IF NOT EXISTS slack_dlq (
425
- id INTEGER PRIMARY KEY AUTOINCREMENT,
426
- tenant_id TEXT NOT NULL,
427
- raw_payload TEXT NOT NULL,
428
- error TEXT NOT NULL,
429
- received_at TEXT NOT NULL,
430
- retried_at TEXT
431
- )
423
+ db.exec(`
424
+ CREATE TABLE IF NOT EXISTS slack_dlq (
425
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
426
+ tenant_id TEXT NOT NULL,
427
+ raw_payload TEXT NOT NULL,
428
+ error TEXT NOT NULL,
429
+ received_at TEXT NOT NULL,
430
+ retried_at TEXT
431
+ )
432
432
  `);
433
433
  db.exec(`CREATE INDEX IF NOT EXISTS idx_slack_dlq_tenant_received ON slack_dlq(tenant_id, received_at)`);
434
434
  // Multi-tenant routing seam (review patch #6). Empty by default — single-
435
435
  // tenant deployments resolve via HIPPO_TENANT fallback. Multi-workspace
436
436
  // deployments populate this table to map team_id → tenant_id.
437
- db.exec(`
438
- CREATE TABLE IF NOT EXISTS slack_workspaces (
439
- team_id TEXT PRIMARY KEY,
440
- tenant_id TEXT NOT NULL,
441
- added_at TEXT NOT NULL
442
- )
437
+ db.exec(`
438
+ CREATE TABLE IF NOT EXISTS slack_workspaces (
439
+ team_id TEXT PRIMARY KEY,
440
+ tenant_id TEXT NOT NULL,
441
+ added_at TEXT NOT NULL
442
+ )
443
443
  `);
444
444
  },
445
445
  },
446
446
  {
447
447
  version: 18,
448
448
  up: (db) => {
449
- db.exec(`
450
- CREATE TABLE IF NOT EXISTS goal_stack (
451
- id TEXT PRIMARY KEY,
452
- session_id TEXT NOT NULL,
453
- tenant_id TEXT NOT NULL DEFAULT 'default',
454
- goal_name TEXT NOT NULL,
455
- level INTEGER NOT NULL DEFAULT 0
456
- CHECK (level BETWEEN 0 AND 2),
457
- parent_goal_id TEXT REFERENCES goal_stack(id) ON DELETE SET NULL,
458
- status TEXT NOT NULL CHECK (status IN ('active','suspended','completed')),
459
- success_condition TEXT,
460
- retrieval_policy_id TEXT,
461
- created_at TEXT NOT NULL,
462
- completed_at TEXT,
463
- outcome_score REAL
464
- CHECK (outcome_score IS NULL OR (outcome_score >= 0 AND outcome_score <= 1))
465
- );
466
-
467
- CREATE INDEX IF NOT EXISTS idx_goal_stack_tenant_session_status
468
- ON goal_stack(tenant_id, session_id, status, created_at);
469
-
470
- CREATE TABLE IF NOT EXISTS retrieval_policy (
471
- id TEXT PRIMARY KEY,
472
- goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
473
- policy_type TEXT NOT NULL CHECK (policy_type IN
474
- ('schema-fit-biased','error-prioritized','recency-first','hybrid')),
475
- weight_schema_fit REAL NOT NULL DEFAULT 1.0
476
- CHECK (weight_schema_fit >= 0 AND weight_schema_fit <= 100),
477
- weight_recency REAL NOT NULL DEFAULT 1.0
478
- CHECK (weight_recency >= 0 AND weight_recency <= 100),
479
- weight_outcome REAL NOT NULL DEFAULT 1.0
480
- CHECK (weight_outcome >= 0 AND weight_outcome <= 100),
481
- error_priority REAL NOT NULL DEFAULT 1.0
482
- CHECK (error_priority >= 0 AND error_priority <= 100)
483
- );
484
-
485
- CREATE INDEX IF NOT EXISTS idx_retrieval_policy_goal
486
- ON retrieval_policy(goal_id);
487
-
488
- CREATE TABLE IF NOT EXISTS goal_recall_log (
489
- id INTEGER PRIMARY KEY AUTOINCREMENT,
490
- goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
491
- memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
492
- tenant_id TEXT NOT NULL DEFAULT 'default',
493
- session_id TEXT NOT NULL,
494
- recalled_at TEXT NOT NULL,
495
- score REAL NOT NULL
496
- );
497
-
498
- CREATE INDEX IF NOT EXISTS idx_goal_recall_log_goal
499
- ON goal_recall_log(goal_id);
500
- CREATE UNIQUE INDEX IF NOT EXISTS uniq_goal_recall_log_memory_goal
501
- ON goal_recall_log(memory_id, goal_id);
449
+ db.exec(`
450
+ CREATE TABLE IF NOT EXISTS goal_stack (
451
+ id TEXT PRIMARY KEY,
452
+ session_id TEXT NOT NULL,
453
+ tenant_id TEXT NOT NULL DEFAULT 'default',
454
+ goal_name TEXT NOT NULL,
455
+ level INTEGER NOT NULL DEFAULT 0
456
+ CHECK (level BETWEEN 0 AND 2),
457
+ parent_goal_id TEXT REFERENCES goal_stack(id) ON DELETE SET NULL,
458
+ status TEXT NOT NULL CHECK (status IN ('active','suspended','completed')),
459
+ success_condition TEXT,
460
+ retrieval_policy_id TEXT,
461
+ created_at TEXT NOT NULL,
462
+ completed_at TEXT,
463
+ outcome_score REAL
464
+ CHECK (outcome_score IS NULL OR (outcome_score >= 0 AND outcome_score <= 1))
465
+ );
466
+
467
+ CREATE INDEX IF NOT EXISTS idx_goal_stack_tenant_session_status
468
+ ON goal_stack(tenant_id, session_id, status, created_at);
469
+
470
+ CREATE TABLE IF NOT EXISTS retrieval_policy (
471
+ id TEXT PRIMARY KEY,
472
+ goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
473
+ policy_type TEXT NOT NULL CHECK (policy_type IN
474
+ ('schema-fit-biased','error-prioritized','recency-first','hybrid')),
475
+ weight_schema_fit REAL NOT NULL DEFAULT 1.0
476
+ CHECK (weight_schema_fit >= 0 AND weight_schema_fit <= 100),
477
+ weight_recency REAL NOT NULL DEFAULT 1.0
478
+ CHECK (weight_recency >= 0 AND weight_recency <= 100),
479
+ weight_outcome REAL NOT NULL DEFAULT 1.0
480
+ CHECK (weight_outcome >= 0 AND weight_outcome <= 100),
481
+ error_priority REAL NOT NULL DEFAULT 1.0
482
+ CHECK (error_priority >= 0 AND error_priority <= 100)
483
+ );
484
+
485
+ CREATE INDEX IF NOT EXISTS idx_retrieval_policy_goal
486
+ ON retrieval_policy(goal_id);
487
+
488
+ CREATE TABLE IF NOT EXISTS goal_recall_log (
489
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
490
+ goal_id TEXT NOT NULL REFERENCES goal_stack(id) ON DELETE CASCADE,
491
+ memory_id TEXT NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
492
+ tenant_id TEXT NOT NULL DEFAULT 'default',
493
+ session_id TEXT NOT NULL,
494
+ recalled_at TEXT NOT NULL,
495
+ score REAL NOT NULL
496
+ );
497
+
498
+ CREATE INDEX IF NOT EXISTS idx_goal_recall_log_goal
499
+ ON goal_recall_log(goal_id);
500
+ CREATE UNIQUE INDEX IF NOT EXISTS uniq_goal_recall_log_memory_goal
501
+ ON goal_recall_log(memory_id, goal_id);
502
502
  `);
503
503
  },
504
504
  },
@@ -595,27 +595,27 @@ const MIGRATIONS = [
595
595
  // EXISTS bodies before ALTERing. A silent skip would otherwise stamp
596
596
  // schema_version=22 on a DB missing the underlying tables, leaving
597
597
  // them permanently absent.
598
- db.exec(`
599
- CREATE TABLE IF NOT EXISTS session_events (
600
- id INTEGER PRIMARY KEY AUTOINCREMENT,
601
- session_id TEXT NOT NULL,
602
- task TEXT,
603
- event_type TEXT NOT NULL,
604
- content TEXT NOT NULL,
605
- source TEXT NOT NULL,
606
- metadata_json TEXT NOT NULL,
607
- created_at TEXT NOT NULL
608
- );
609
- CREATE TABLE IF NOT EXISTS session_handoffs (
610
- id INTEGER PRIMARY KEY AUTOINCREMENT,
611
- session_id TEXT NOT NULL,
612
- repo_root TEXT,
613
- task_id TEXT,
614
- summary TEXT NOT NULL,
615
- next_action TEXT,
616
- artifacts_json TEXT NOT NULL DEFAULT '[]',
617
- created_at TEXT NOT NULL
618
- );
598
+ db.exec(`
599
+ CREATE TABLE IF NOT EXISTS session_events (
600
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
601
+ session_id TEXT NOT NULL,
602
+ task TEXT,
603
+ event_type TEXT NOT NULL,
604
+ content TEXT NOT NULL,
605
+ source TEXT NOT NULL,
606
+ metadata_json TEXT NOT NULL,
607
+ created_at TEXT NOT NULL
608
+ );
609
+ CREATE TABLE IF NOT EXISTS session_handoffs (
610
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
611
+ session_id TEXT NOT NULL,
612
+ repo_root TEXT,
613
+ task_id TEXT,
614
+ summary TEXT NOT NULL,
615
+ next_action TEXT,
616
+ artifacts_json TEXT NOT NULL DEFAULT '[]',
617
+ created_at TEXT NOT NULL
618
+ );
619
619
  `);
620
620
  if (!tableHasColumn(db, 'session_events', 'tenant_id')) {
621
621
  db.exec(`ALTER TABLE session_events ADD COLUMN tenant_id TEXT NOT NULL DEFAULT 'default'`);
@@ -635,25 +635,25 @@ const MIGRATIONS = [
635
635
  // tenant boundaries on guesses. The COUNT(DISTINCT) gate is the load-
636
636
  // bearing check; without it, rows with multiple tenants under the same
637
637
  // session_id would silently pick whichever group came first.
638
- db.exec(`
639
- UPDATE session_events
640
- SET tenant_id = (
641
- SELECT MAX(t.tenant_id) FROM task_snapshots t
642
- WHERE t.session_id = session_events.session_id
643
- )
644
- WHERE tenant_id = 'default'
645
- AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
646
- WHERE t.session_id = session_events.session_id) = 1
638
+ db.exec(`
639
+ UPDATE session_events
640
+ SET tenant_id = (
641
+ SELECT MAX(t.tenant_id) FROM task_snapshots t
642
+ WHERE t.session_id = session_events.session_id
643
+ )
644
+ WHERE tenant_id = 'default'
645
+ AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
646
+ WHERE t.session_id = session_events.session_id) = 1
647
647
  `);
648
- db.exec(`
649
- UPDATE session_handoffs
650
- SET tenant_id = (
651
- SELECT MAX(t.tenant_id) FROM task_snapshots t
652
- WHERE t.session_id = session_handoffs.session_id
653
- )
654
- WHERE tenant_id = 'default'
655
- AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
656
- WHERE t.session_id = session_handoffs.session_id) = 1
648
+ db.exec(`
649
+ UPDATE session_handoffs
650
+ SET tenant_id = (
651
+ SELECT MAX(t.tenant_id) FROM task_snapshots t
652
+ WHERE t.session_id = session_handoffs.session_id
653
+ )
654
+ WHERE tenant_id = 'default'
655
+ AND (SELECT COUNT(DISTINCT t.tenant_id) FROM task_snapshots t
656
+ WHERE t.session_id = session_handoffs.session_id) = 1
657
657
  `);
658
658
  db.exec(`CREATE INDEX IF NOT EXISTS idx_session_events_tenant_session ON session_events(tenant_id, session_id, created_at DESC, id DESC)`);
659
659
  db.exec(`CREATE INDEX IF NOT EXISTS idx_session_handoffs_tenant_session ON session_handoffs(tenant_id, session_id, created_at DESC)`);
@@ -665,19 +665,19 @@ const MIGRATIONS = [
665
665
  // task_snapshots: add scope so all three continuity tables carry it.
666
666
  // Self-heal partial-init stores via CREATE TABLE IF NOT EXISTS (the v22
667
667
  // session_events / session_handoffs healing is upstream).
668
- db.exec(`
669
- CREATE TABLE IF NOT EXISTS task_snapshots (
670
- id INTEGER PRIMARY KEY AUTOINCREMENT,
671
- task TEXT NOT NULL,
672
- summary TEXT NOT NULL,
673
- next_step TEXT NOT NULL,
674
- status TEXT NOT NULL,
675
- source TEXT NOT NULL,
676
- session_id TEXT,
677
- tenant_id TEXT NOT NULL DEFAULT 'default',
678
- created_at TEXT NOT NULL,
679
- updated_at TEXT NOT NULL
680
- )
668
+ db.exec(`
669
+ CREATE TABLE IF NOT EXISTS task_snapshots (
670
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
671
+ task TEXT NOT NULL,
672
+ summary TEXT NOT NULL,
673
+ next_step TEXT NOT NULL,
674
+ status TEXT NOT NULL,
675
+ source TEXT NOT NULL,
676
+ session_id TEXT,
677
+ tenant_id TEXT NOT NULL DEFAULT 'default',
678
+ created_at TEXT NOT NULL,
679
+ updated_at TEXT NOT NULL
680
+ )
681
681
  `);
682
682
  if (!tableHasColumn(db, 'task_snapshots', 'scope')) {
683
683
  db.exec(`ALTER TABLE task_snapshots ADD COLUMN scope TEXT`);
@@ -710,64 +710,64 @@ const MIGRATIONS = [
710
710
  up: (db) => {
711
711
  // v1.3.0 GitHub connector schema (codex round 1, 2026-05-04).
712
712
  // Six tables + a min_compatible_binary meta row for rollback safety.
713
- db.exec(`
714
- CREATE TABLE IF NOT EXISTS github_event_log (
715
- idempotency_key TEXT PRIMARY KEY,
716
- delivery_id TEXT NOT NULL,
717
- event_name TEXT NOT NULL,
718
- ingested_at TEXT NOT NULL,
719
- memory_id TEXT
720
- )
713
+ db.exec(`
714
+ CREATE TABLE IF NOT EXISTS github_event_log (
715
+ idempotency_key TEXT PRIMARY KEY,
716
+ delivery_id TEXT NOT NULL,
717
+ event_name TEXT NOT NULL,
718
+ ingested_at TEXT NOT NULL,
719
+ memory_id TEXT
720
+ )
721
721
  `);
722
722
  db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_memory ON github_event_log(memory_id) WHERE memory_id IS NOT NULL`);
723
723
  db.exec(`CREATE INDEX IF NOT EXISTS idx_github_event_log_delivery ON github_event_log(delivery_id)`);
724
- db.exec(`
725
- CREATE TABLE IF NOT EXISTS github_cursors (
726
- tenant_id TEXT NOT NULL,
727
- repo_full_name TEXT NOT NULL,
728
- issues_hwm TEXT,
729
- issue_comments_hwm TEXT,
730
- pr_review_comments_hwm TEXT,
731
- updated_at TEXT NOT NULL,
732
- PRIMARY KEY (tenant_id, repo_full_name)
733
- )
724
+ db.exec(`
725
+ CREATE TABLE IF NOT EXISTS github_cursors (
726
+ tenant_id TEXT NOT NULL,
727
+ repo_full_name TEXT NOT NULL,
728
+ issues_hwm TEXT,
729
+ issue_comments_hwm TEXT,
730
+ pr_review_comments_hwm TEXT,
731
+ updated_at TEXT NOT NULL,
732
+ PRIMARY KEY (tenant_id, repo_full_name)
733
+ )
734
734
  `);
735
- db.exec(`
736
- CREATE TABLE IF NOT EXISTS github_dlq (
737
- id INTEGER PRIMARY KEY AUTOINCREMENT,
738
- tenant_id TEXT NOT NULL,
739
- raw_payload TEXT NOT NULL,
740
- error TEXT NOT NULL,
741
- event_name TEXT,
742
- delivery_id TEXT,
743
- signature TEXT,
744
- installation_id TEXT,
745
- repo_full_name TEXT,
746
- retry_count INTEGER NOT NULL DEFAULT 0,
747
- received_at TEXT NOT NULL,
748
- retried_at TEXT,
749
- bucket TEXT NOT NULL DEFAULT 'parse_error'
750
- )
735
+ db.exec(`
736
+ CREATE TABLE IF NOT EXISTS github_dlq (
737
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
738
+ tenant_id TEXT NOT NULL,
739
+ raw_payload TEXT NOT NULL,
740
+ error TEXT NOT NULL,
741
+ event_name TEXT,
742
+ delivery_id TEXT,
743
+ signature TEXT,
744
+ installation_id TEXT,
745
+ repo_full_name TEXT,
746
+ retry_count INTEGER NOT NULL DEFAULT 0,
747
+ received_at TEXT NOT NULL,
748
+ retried_at TEXT,
749
+ bucket TEXT NOT NULL DEFAULT 'parse_error'
750
+ )
751
751
  `);
752
752
  db.exec(`CREATE INDEX IF NOT EXISTS idx_github_dlq_tenant_received ON github_dlq(tenant_id, received_at)`);
753
- db.exec(`
754
- CREATE TABLE IF NOT EXISTS github_installations (
755
- installation_id TEXT PRIMARY KEY,
756
- tenant_id TEXT NOT NULL,
757
- added_at TEXT NOT NULL
758
- )
753
+ db.exec(`
754
+ CREATE TABLE IF NOT EXISTS github_installations (
755
+ installation_id TEXT PRIMARY KEY,
756
+ tenant_id TEXT NOT NULL,
757
+ added_at TEXT NOT NULL
758
+ )
759
759
  `);
760
760
  // PAT-mode multi-tenant routing (codex P0 #4). Maps repo_full_name to
761
761
  // tenant when the webhook envelope has no `installation` field. Composite
762
762
  // PK so the same repo can intentionally be visible to multiple tenants
763
763
  // (e.g., shared tooling accounts) — collision is on (repo, tenant) pair.
764
- db.exec(`
765
- CREATE TABLE IF NOT EXISTS github_repositories (
766
- repo_full_name TEXT NOT NULL,
767
- tenant_id TEXT NOT NULL,
768
- added_at TEXT NOT NULL,
769
- PRIMARY KEY (repo_full_name, tenant_id)
770
- )
764
+ db.exec(`
765
+ CREATE TABLE IF NOT EXISTS github_repositories (
766
+ repo_full_name TEXT NOT NULL,
767
+ tenant_id TEXT NOT NULL,
768
+ added_at TEXT NOT NULL,
769
+ PRIMARY KEY (repo_full_name, tenant_id)
770
+ )
771
771
  `);
772
772
  // Rollback-safety guard (codex P0 #2). Any binary < 1.2.1 lacks the
773
773
  // generic *:private:* default-deny and would leak github:private:* rows
@@ -797,26 +797,26 @@ const MIGRATIONS = [
797
797
  // Backfill descendant_count for existing level-2 summary rows. Use
798
798
  // dag_parent_id pointing at the summary id. Level-3 (entity profiles)
799
799
  // not built today; their descendant_count stays at default 0.
800
- db.exec(`
801
- UPDATE memories
802
- SET descendant_count = (
803
- SELECT COUNT(*) FROM memories AS c WHERE c.dag_parent_id = memories.id
804
- )
805
- WHERE dag_level >= 2
806
- AND descendant_count = 0
800
+ db.exec(`
801
+ UPDATE memories
802
+ SET descendant_count = (
803
+ SELECT COUNT(*) FROM memories AS c WHERE c.dag_parent_id = memories.id
804
+ )
805
+ WHERE dag_level >= 2
806
+ AND descendant_count = 0
807
807
  `);
808
808
  // Backfill earliest_at / latest_at from child created timestamps for
809
809
  // existing summaries. Children of a level-2 summary are level-1 facts.
810
- db.exec(`
811
- UPDATE memories
812
- SET earliest_at = (
813
- SELECT MIN(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
814
- ),
815
- latest_at = (
816
- SELECT MAX(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
817
- )
818
- WHERE dag_level >= 2
819
- AND earliest_at IS NULL
810
+ db.exec(`
811
+ UPDATE memories
812
+ SET earliest_at = (
813
+ SELECT MIN(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
814
+ ),
815
+ latest_at = (
816
+ SELECT MAX(c.created) FROM memories AS c WHERE c.dag_parent_id = memories.id
817
+ )
818
+ WHERE dag_level >= 2
819
+ AND earliest_at IS NULL
820
820
  `);
821
821
  },
822
822
  },
@@ -859,32 +859,32 @@ const MIGRATIONS = [
859
859
  // bug, fixes anyone who has it. Includes the role column from the
860
860
  // start so it matches v26's intent without needing v26 to ALTER on
861
861
  // this heal path.
862
- db.exec(`
863
- CREATE TABLE IF NOT EXISTS api_keys (
864
- id INTEGER PRIMARY KEY AUTOINCREMENT,
865
- key_id TEXT UNIQUE NOT NULL,
866
- key_hash TEXT NOT NULL,
867
- tenant_id TEXT NOT NULL DEFAULT 'default',
868
- label TEXT,
869
- created_at TEXT NOT NULL,
870
- revoked_at TEXT,
871
- role TEXT NOT NULL DEFAULT 'admin'
872
- )
862
+ db.exec(`
863
+ CREATE TABLE IF NOT EXISTS api_keys (
864
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
865
+ key_id TEXT UNIQUE NOT NULL,
866
+ key_hash TEXT NOT NULL,
867
+ tenant_id TEXT NOT NULL DEFAULT 'default',
868
+ label TEXT,
869
+ created_at TEXT NOT NULL,
870
+ revoked_at TEXT,
871
+ role TEXT NOT NULL DEFAULT 'admin'
872
+ )
873
873
  `);
874
- db.exec(`
875
- CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
876
- ON api_keys(tenant_id) WHERE revoked_at IS NULL
874
+ db.exec(`
875
+ CREATE INDEX IF NOT EXISTS idx_api_keys_tenant_active
876
+ ON api_keys(tenant_id) WHERE revoked_at IS NULL
877
877
  `);
878
- db.exec(`
879
- CREATE TABLE IF NOT EXISTS audit_log (
880
- id INTEGER PRIMARY KEY AUTOINCREMENT,
881
- ts TEXT NOT NULL,
882
- tenant_id TEXT NOT NULL DEFAULT 'default',
883
- actor TEXT NOT NULL,
884
- op TEXT NOT NULL,
885
- target_id TEXT,
886
- metadata_json TEXT NOT NULL DEFAULT '{}'
887
- )
878
+ db.exec(`
879
+ CREATE TABLE IF NOT EXISTS audit_log (
880
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
881
+ ts TEXT NOT NULL,
882
+ tenant_id TEXT NOT NULL DEFAULT 'default',
883
+ actor TEXT NOT NULL,
884
+ op TEXT NOT NULL,
885
+ target_id TEXT,
886
+ metadata_json TEXT NOT NULL DEFAULT '{}'
887
+ )
888
888
  `);
889
889
  db.exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_tenant_ts ON audit_log(tenant_id, ts DESC)`);
890
890
  // Belt-and-braces: if api_keys existed before this migration WITHOUT
@@ -952,58 +952,142 @@ const MIGRATIONS = [
952
952
  // J3 computes accuracy (clean vs regressed) from (estimate_value,
953
953
  // actual_value) at query time.
954
954
  if (!tableExists(db, 'predictions')) {
955
- db.exec(`
956
- CREATE TABLE predictions (
957
- id INTEGER PRIMARY KEY AUTOINCREMENT,
958
- memory_id TEXT,
959
- tenant_id TEXT NOT NULL,
960
- class_tag TEXT NOT NULL,
961
- claim_text TEXT NOT NULL,
962
- estimate_value REAL,
963
- estimate_unit TEXT,
964
- target_date TEXT,
965
- actual_value REAL,
966
- closure_state TEXT NOT NULL DEFAULT 'open'
967
- CHECK (closure_state IN ('open', 'closed', 'closed-unknown')),
968
- closed_at TEXT,
969
- closure_note TEXT,
970
- created_at TEXT NOT NULL,
971
- FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
972
- )
955
+ db.exec(`
956
+ CREATE TABLE predictions (
957
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
958
+ memory_id TEXT,
959
+ tenant_id TEXT NOT NULL,
960
+ class_tag TEXT NOT NULL,
961
+ claim_text TEXT NOT NULL,
962
+ estimate_value REAL,
963
+ estimate_unit TEXT,
964
+ target_date TEXT,
965
+ actual_value REAL,
966
+ closure_state TEXT NOT NULL DEFAULT 'open'
967
+ CHECK (closure_state IN ('open', 'closed', 'closed-unknown')),
968
+ closed_at TEXT,
969
+ closure_note TEXT,
970
+ created_at TEXT NOT NULL,
971
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL
972
+ )
973
973
  `);
974
- db.exec(`
975
- CREATE INDEX IF NOT EXISTS idx_predictions_tenant_class
976
- ON predictions(tenant_id, class_tag, closure_state)
974
+ db.exec(`
975
+ CREATE INDEX IF NOT EXISTS idx_predictions_tenant_class
976
+ ON predictions(tenant_id, class_tag, closure_state)
977
977
  `);
978
- db.exec(`
979
- CREATE INDEX IF NOT EXISTS idx_predictions_memory
980
- ON predictions(memory_id) WHERE memory_id IS NOT NULL
978
+ db.exec(`
979
+ CREATE INDEX IF NOT EXISTS idx_predictions_memory
980
+ ON predictions(memory_id) WHERE memory_id IS NOT NULL
981
981
  `);
982
982
  // Cross-tenant safety: tenant_id must match the referenced memory's
983
983
  // tenant_id when memory_id IS NOT NULL. INSERT + UPDATE pair, mirroring
984
984
  // v14 memories.kind enforcement (db.ts:298-322).
985
- db.exec(`
986
- CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_insert
987
- BEFORE INSERT ON predictions
988
- WHEN NEW.memory_id IS NOT NULL
989
- BEGIN
990
- SELECT CASE
991
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
992
- THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
993
- END;
994
- END
985
+ db.exec(`
986
+ CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_insert
987
+ BEFORE INSERT ON predictions
988
+ WHEN NEW.memory_id IS NOT NULL
989
+ BEGIN
990
+ SELECT CASE
991
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
992
+ THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
993
+ END;
994
+ END
995
995
  `);
996
- db.exec(`
997
- CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_update
998
- BEFORE UPDATE ON predictions
999
- WHEN NEW.memory_id IS NOT NULL
1000
- AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1001
- BEGIN
1002
- SELECT CASE
1003
- WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1004
- THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
1005
- END;
1006
- END
996
+ db.exec(`
997
+ CREATE TRIGGER IF NOT EXISTS trg_predictions_tenant_match_update
998
+ BEFORE UPDATE ON predictions
999
+ WHEN NEW.memory_id IS NOT NULL
1000
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1001
+ BEGIN
1002
+ SELECT CASE
1003
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1004
+ THEN RAISE(ABORT, 'predictions.tenant_id must match memories.tenant_id for the referenced memory')
1005
+ END;
1006
+ END
1007
+ `);
1008
+ }
1009
+ },
1010
+ },
1011
+ {
1012
+ version: 30,
1013
+ up: (db) => {
1014
+ // E2 decision first-class object (docs/plans/2026-05-28-e2-decision-object.md).
1015
+ // Promotes `hippo decide` from a tagged memory (which decayed on a 90-day
1016
+ // half-life even while the decision was still in force) to a canonical
1017
+ // decisions table that is the source of truth. The memory mirror is kept
1018
+ // for recall but is no longer authoritative; memory_id is NULLABLE with
1019
+ // ON DELETE SET NULL so forget/consolidate/archive does not lose a
1020
+ // decision. Mirrors the v29 predictions tenant-match trigger pattern.
1021
+ //
1022
+ // status (active|superseded|closed): superseded carries a self-FK
1023
+ // superseded_by to the successor decision; closed is a terminal
1024
+ // retire-without-successor. A superseded_by same-tenant trigger makes
1025
+ // cross-tenant supersession unrepresentable at the schema level.
1026
+ if (!tableExists(db, 'decisions')) {
1027
+ db.exec(`
1028
+ CREATE TABLE decisions (
1029
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
1030
+ memory_id TEXT,
1031
+ tenant_id TEXT NOT NULL,
1032
+ decision_text TEXT NOT NULL,
1033
+ context TEXT,
1034
+ status TEXT NOT NULL DEFAULT 'active'
1035
+ CHECK (status IN ('active', 'superseded', 'closed')),
1036
+ superseded_by INTEGER,
1037
+ superseded_at TEXT,
1038
+ closed_at TEXT,
1039
+ created_at TEXT NOT NULL,
1040
+ FOREIGN KEY (memory_id) REFERENCES memories(id) ON DELETE SET NULL,
1041
+ FOREIGN KEY (superseded_by) REFERENCES decisions(id) ON DELETE SET NULL
1042
+ )
1043
+ `);
1044
+ db.exec(`
1045
+ CREATE INDEX IF NOT EXISTS idx_decisions_tenant_status
1046
+ ON decisions(tenant_id, status)
1047
+ `);
1048
+ db.exec(`
1049
+ CREATE INDEX IF NOT EXISTS idx_decisions_memory
1050
+ ON decisions(memory_id) WHERE memory_id IS NOT NULL
1051
+ `);
1052
+ // Cross-tenant safety vs the referenced memory (mirrors predictions v29).
1053
+ db.exec(`
1054
+ CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_insert
1055
+ BEFORE INSERT ON decisions
1056
+ WHEN NEW.memory_id IS NOT NULL
1057
+ BEGIN
1058
+ SELECT CASE
1059
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1060
+ THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
1061
+ END;
1062
+ END
1063
+ `);
1064
+ db.exec(`
1065
+ CREATE TRIGGER IF NOT EXISTS trg_decisions_tenant_match_update
1066
+ BEFORE UPDATE ON decisions
1067
+ WHEN NEW.memory_id IS NOT NULL
1068
+ AND (NEW.memory_id IS NOT OLD.memory_id OR NEW.tenant_id IS NOT OLD.tenant_id)
1069
+ BEGIN
1070
+ SELECT CASE
1071
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM memories WHERE id = NEW.memory_id)
1072
+ THEN RAISE(ABORT, 'decisions.tenant_id must match memories.tenant_id for the referenced memory')
1073
+ END;
1074
+ END
1075
+ `);
1076
+ // Cross-tenant safety vs the successor decision (self-FK). superseded_by
1077
+ // is set only via UPDATE (the supersede path); the successor must share
1078
+ // the tenant. The successor row already exists in the same transaction
1079
+ // when this fires, so the subquery resolves.
1080
+ db.exec(`
1081
+ CREATE TRIGGER IF NOT EXISTS trg_decisions_supersede_tenant_match_update
1082
+ BEFORE UPDATE ON decisions
1083
+ WHEN NEW.superseded_by IS NOT NULL
1084
+ AND NEW.superseded_by IS NOT OLD.superseded_by
1085
+ BEGIN
1086
+ SELECT CASE
1087
+ WHEN NEW.tenant_id != (SELECT tenant_id FROM decisions WHERE id = NEW.superseded_by)
1088
+ THEN RAISE(ABORT, 'decisions.superseded_by must reference a decision in the same tenant')
1089
+ END;
1090
+ END
1007
1091
  `);
1008
1092
  }
1009
1093
  },
@@ -1101,11 +1185,11 @@ function runMigrations(db) {
1101
1185
  ensureOptionalFts(db);
1102
1186
  }
1103
1187
  function ensureMetaTable(db) {
1104
- db.exec(`
1105
- CREATE TABLE IF NOT EXISTS meta (
1106
- key TEXT PRIMARY KEY,
1107
- value TEXT NOT NULL
1108
- );
1188
+ db.exec(`
1189
+ CREATE TABLE IF NOT EXISTS meta (
1190
+ key TEXT PRIMARY KEY,
1191
+ value TEXT NOT NULL
1192
+ );
1109
1193
  `);
1110
1194
  }
1111
1195
  export function getSchemaVersion(db) {
@@ -1148,17 +1232,17 @@ function backfillFtsIndex(db) {
1148
1232
  const ftsCount = db.prepare(`SELECT COUNT(*) AS c FROM memories_fts`).get()?.c ?? 0;
1149
1233
  if (memCount === ftsCount)
1150
1234
  return;
1151
- db.exec(`
1152
- INSERT INTO memories_fts(id, content, tags)
1153
- SELECT m.id, m.content, m.tags_json
1154
- FROM memories m
1155
- WHERE NOT EXISTS (
1156
- SELECT 1 FROM memories_fts f WHERE f.id = m.id
1157
- )
1235
+ db.exec(`
1236
+ INSERT INTO memories_fts(id, content, tags)
1237
+ SELECT m.id, m.content, m.tags_json
1238
+ FROM memories m
1239
+ WHERE NOT EXISTS (
1240
+ SELECT 1 FROM memories_fts f WHERE f.id = m.id
1241
+ )
1158
1242
  `);
1159
- db.exec(`
1160
- DELETE FROM memories_fts
1161
- WHERE id NOT IN (SELECT id FROM memories)
1243
+ db.exec(`
1244
+ DELETE FROM memories_fts
1245
+ WHERE id NOT IN (SELECT id FROM memories)
1162
1246
  `);
1163
1247
  }
1164
1248
  export function closeHippoDb(db) {
@@ -1175,13 +1259,13 @@ export function isFtsAvailable(db) {
1175
1259
  return getMeta(db, 'fts5_available', '0') === '1';
1176
1260
  }
1177
1261
  export function pruneConsolidationRuns(db, keep = 50) {
1178
- db.prepare(`
1179
- DELETE FROM consolidation_runs
1180
- WHERE id NOT IN (
1181
- SELECT id FROM consolidation_runs
1182
- ORDER BY timestamp DESC, id DESC
1183
- LIMIT ?
1184
- )
1262
+ db.prepare(`
1263
+ DELETE FROM consolidation_runs
1264
+ WHERE id NOT IN (
1265
+ SELECT id FROM consolidation_runs
1266
+ ORDER BY timestamp DESC, id DESC
1267
+ LIMIT ?
1268
+ )
1185
1269
  `).run(keep);
1186
1270
  }
1187
1271
  //# sourceMappingURL=db.js.map