nlm-memory 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/nlm.js +54 -0
  3. package/dist/cli/nlm.js.map +1 -1
  4. package/dist/core/dataset/build-dataset.d.ts +13 -0
  5. package/dist/core/dataset/build-dataset.js +31 -2
  6. package/dist/core/dataset/build-dataset.js.map +1 -1
  7. package/dist/core/integrity/check-invariants.d.ts +41 -0
  8. package/dist/core/integrity/check-invariants.js +349 -0
  9. package/dist/core/integrity/check-invariants.js.map +1 -0
  10. package/dist/core/scheduler/scan-once.d.ts +4 -1
  11. package/dist/core/scheduler/scan-once.js +53 -14
  12. package/dist/core/scheduler/scan-once.js.map +1 -1
  13. package/dist/core/scheduler/scheduler.js +64 -11
  14. package/dist/core/scheduler/scheduler.js.map +1 -1
  15. package/dist/core/storage/live-status.d.ts +5 -3
  16. package/dist/core/storage/live-status.js +6 -2
  17. package/dist/core/storage/live-status.js.map +1 -1
  18. package/dist/core/storage/migrate.d.ts +5 -0
  19. package/dist/core/storage/migrate.js +24 -8
  20. package/dist/core/storage/migrate.js.map +1 -1
  21. package/dist/core/storage/pg-session-store.d.ts +2 -2
  22. package/dist/core/storage/pg-session-store.js +31 -6
  23. package/dist/core/storage/pg-session-store.js.map +1 -1
  24. package/dist/core/storage/sqlite-fact-store.js +7 -15
  25. package/dist/core/storage/sqlite-fact-store.js.map +1 -1
  26. package/dist/core/storage/sqlite-session-store.d.ts +14 -2
  27. package/dist/core/storage/sqlite-session-store.js +61 -24
  28. package/dist/core/storage/sqlite-session-store.js.map +1 -1
  29. package/dist/hook/prompt-recall-hook.js +1 -1
  30. package/dist/hook/prompt-recall-hook.js.map +1 -1
  31. package/dist/hook/stop-hook.js +1 -1
  32. package/dist/hook/stop-hook.js.map +1 -1
  33. package/dist/http/app.js +3 -1
  34. package/dist/http/app.js.map +1 -1
  35. package/dist/shared/types.d.ts +24 -5
  36. package/dist/ui/assets/index-CJgVgxlM.js +11 -0
  37. package/dist/ui/assets/index-D5lKaksH.css +1 -0
  38. package/dist/ui/index.html +2 -2
  39. package/migrations/000_initial_schema.sql +174 -0
  40. package/migrations/001_entity_type_rename.sql +17 -0
  41. package/migrations/002_adapter_state_extend.sql +12 -0
  42. package/migrations/003_session_embeddings.sql +11 -0
  43. package/migrations/004_facts.sql +46 -0
  44. package/migrations/005_sources.sql +31 -0
  45. package/migrations/006_providers.sql +33 -0
  46. package/migrations/007_source_tokens.sql +17 -0
  47. package/migrations/008_fts_rebuild.sql +9 -0
  48. package/migrations/009_session_embedding_chunks.sql +46 -0
  49. package/migrations/010_sources_opencode.sql +30 -0
  50. package/migrations/011_sources_hermes_agent.sql +30 -0
  51. package/migrations/012_sources_aider.sql +30 -0
  52. package/migrations/013_adapter_state_failure_count.sql +12 -0
  53. package/migrations/014_sources_cursor.sql +30 -0
  54. package/migrations/015_sources_windsurf.sql +30 -0
  55. package/migrations/016_sources_codex.sql +30 -0
  56. package/migrations/017_signals.sql +31 -0
  57. package/migrations/018_repair_self_supersede.sql +21 -0
  58. package/migrations/019_split_replaces.sql +117 -0
  59. package/migrations/020_collapse_duplicate_active_facts.sql +45 -0
  60. package/migrations/pg/001_initial.sql +214 -0
  61. package/migrations/pg/018_repair_self_supersede.sql +16 -0
  62. package/migrations/pg/019_split_replaces.sql +32 -0
  63. package/migrations/pg/020_collapse_duplicate_active_facts.sql +46 -0
  64. package/nlm/index.js +1 -1
  65. package/package.json +2 -1
  66. package/plugin/scripts/prompt-recall-hook.mjs +1 -1
  67. package/plugin/scripts/stop-hook.mjs +1 -1
  68. package/dist/ui/assets/index-BTj60bAH.js +0 -11
  69. package/dist/ui/assets/index-Baw2qIKc.css +0 -1
@@ -0,0 +1,30 @@
1
+ -- Migration 014: add 'cursor' to the sources.kind CHECK constraint.
2
+ --
3
+ -- SQLite does not support ALTER COLUMN to modify CHECK constraints in place.
4
+ -- Standard approach: rename → recreate → copy → drop old.
5
+
6
+ PRAGMA foreign_keys = OFF;
7
+
8
+ CREATE TABLE sources_new (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ kind TEXT NOT NULL CHECK (kind IN ('claude-code', 'hermes', 'hermes-agent', 'aider', 'cursor', 'opencode', 'pi', 'jsonl-generic', 'webhook')),
11
+ name TEXT NOT NULL UNIQUE,
12
+ path_or_url TEXT,
13
+ runtime_label TEXT NOT NULL,
14
+ parse_config TEXT NOT NULL DEFAULT '{}',
15
+ enabled INTEGER NOT NULL DEFAULT 1,
16
+ token TEXT,
17
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
19
+ );
20
+
21
+ INSERT INTO sources_new SELECT id, kind, name, path_or_url, runtime_label, parse_config, enabled, token, created_at, updated_at FROM sources;
22
+
23
+ DROP TABLE sources;
24
+ ALTER TABLE sources_new RENAME TO sources;
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_sources_enabled ON sources(enabled) WHERE enabled = 1;
27
+
28
+ PRAGMA foreign_keys = ON;
29
+
30
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (14, '014_sources_cursor');
@@ -0,0 +1,30 @@
1
+ -- Migration 015: add 'windsurf' to the sources.kind CHECK constraint.
2
+ --
3
+ -- SQLite does not support ALTER COLUMN to modify CHECK constraints in place.
4
+ -- Standard approach: rename → recreate → copy → drop old.
5
+
6
+ PRAGMA foreign_keys = OFF;
7
+
8
+ CREATE TABLE sources_new (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ kind TEXT NOT NULL CHECK (kind IN ('claude-code', 'hermes', 'hermes-agent', 'aider', 'cursor', 'windsurf', 'opencode', 'pi', 'jsonl-generic', 'webhook')),
11
+ name TEXT NOT NULL UNIQUE,
12
+ path_or_url TEXT,
13
+ runtime_label TEXT NOT NULL,
14
+ parse_config TEXT NOT NULL DEFAULT '{}',
15
+ enabled INTEGER NOT NULL DEFAULT 1,
16
+ token TEXT,
17
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
19
+ );
20
+
21
+ INSERT INTO sources_new SELECT id, kind, name, path_or_url, runtime_label, parse_config, enabled, token, created_at, updated_at FROM sources;
22
+
23
+ DROP TABLE sources;
24
+ ALTER TABLE sources_new RENAME TO sources;
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_sources_enabled ON sources(enabled) WHERE enabled = 1;
27
+
28
+ PRAGMA foreign_keys = ON;
29
+
30
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (15, '015_sources_windsurf');
@@ -0,0 +1,30 @@
1
+ -- Migration 016: add 'codex' to the sources.kind CHECK constraint.
2
+ --
3
+ -- SQLite does not support ALTER COLUMN to modify CHECK constraints in place.
4
+ -- Standard approach: rename → recreate → copy → drop old.
5
+
6
+ PRAGMA foreign_keys = OFF;
7
+
8
+ CREATE TABLE sources_new (
9
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
10
+ kind TEXT NOT NULL CHECK (kind IN ('claude-code', 'codex', 'hermes', 'hermes-agent', 'aider', 'cursor', 'windsurf', 'opencode', 'pi', 'jsonl-generic', 'webhook')),
11
+ name TEXT NOT NULL UNIQUE,
12
+ path_or_url TEXT,
13
+ runtime_label TEXT NOT NULL,
14
+ parse_config TEXT NOT NULL DEFAULT '{}',
15
+ enabled INTEGER NOT NULL DEFAULT 1,
16
+ token TEXT,
17
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
18
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
19
+ );
20
+
21
+ INSERT INTO sources_new SELECT id, kind, name, path_or_url, runtime_label, parse_config, enabled, token, created_at, updated_at FROM sources;
22
+
23
+ DROP TABLE sources;
24
+ ALTER TABLE sources_new RENAME TO sources;
25
+
26
+ CREATE INDEX IF NOT EXISTS idx_sources_enabled ON sources(enabled) WHERE enabled = 1;
27
+
28
+ PRAGMA foreign_keys = ON;
29
+
30
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (16, '016_sources_codex');
@@ -0,0 +1,31 @@
1
+ -- Migration 017: signals - agent self-improvement telemetry lane.
2
+ --
3
+ -- Distinct from facts: append-only, idempotent on a deterministic id, no
4
+ -- supersedence pointer, no embeddings. No FK to sessions; a signal can arrive
5
+ -- before or without a session row, session_id is a soft link.
6
+
7
+ CREATE TABLE IF NOT EXISTS signals (
8
+ id TEXT PRIMARY KEY,
9
+ v INTEGER NOT NULL DEFAULT 1,
10
+ install_scope TEXT NOT NULL,
11
+ kind TEXT NOT NULL CHECK (kind IN ('gate', 'eval', 'review', 'test')),
12
+ producer TEXT NOT NULL,
13
+ outcome TEXT NOT NULL CHECK (outcome IN ('pass', 'fail', 'fix', 'exhausted')),
14
+ model TEXT NOT NULL,
15
+ repo TEXT NOT NULL,
16
+ step TEXT,
17
+ detail TEXT,
18
+ session_id TEXT,
19
+ ts TEXT NOT NULL,
20
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
21
+ );
22
+
23
+ -- Aggregation hot path: failure-mode roll-up scoped to an install + repo/model.
24
+ CREATE INDEX IF NOT EXISTS idx_signals_agg
25
+ ON signals(install_scope, repo, model, kind, step);
26
+
27
+ -- Retention prune + recency window scans.
28
+ CREATE INDEX IF NOT EXISTS idx_signals_ts
29
+ ON signals(ts);
30
+
31
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (17, '017_signals');
@@ -0,0 +1,21 @@
1
+ -- Migration 018: repair self-supersedence damage.
2
+ --
3
+ -- The SQLite scanOnce path set supersedes = prior.session_id with no self-id
4
+ -- guard. Adapter session ids are deterministic, so a resumed (grown) transcript
5
+ -- re-ingested under the SAME id and insertSession wrote a self-loop edge
6
+ -- (id -> id, 'supersedes') then flipped that same row to status='superseded'.
7
+ -- Result: a session that supersedes itself and is marked superseded with no
8
+ -- real successor. The code fix guards both the scan path and insertSession;
9
+ -- this migration repairs rows already damaged.
10
+
11
+ DELETE FROM session_edges WHERE from_session = to_session AND kind = 'supersedes';
12
+
13
+ -- Restore sessions wrongly marked superseded: a legitimately superseded row
14
+ -- keeps a real incoming 'supersedes' edge (some other session -> it). Rows with
15
+ -- no remaining incoming edge were only self-superseded and should be 'closed'.
16
+ UPDATE sessions
17
+ SET status = 'closed', updated_at = datetime('now')
18
+ WHERE status = 'superseded'
19
+ AND id NOT IN (SELECT to_session FROM session_edges WHERE kind = 'supersedes');
20
+
21
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (18, '018_repair_self_supersede');
@@ -0,0 +1,117 @@
1
+ -- nlm:no-wrap
2
+ -- Migration 019: split the mechanical `replaces` relation out of `supersedes`.
3
+ --
4
+ -- Two relations were overloaded onto one edge kind ('supersedes') and one
5
+ -- status ('superseded'): mechanical re-ingest of a grown transcript
6
+ -- (supersede-on-resume) and operator-asserted epistemic overturn. This
7
+ -- migration introduces edge kind 'replaces' + status 'replaced' for the
8
+ -- mechanical case and reclassifies existing rows.
9
+ --
10
+ -- The mechanical signature is exact, no heuristics: an edge whose two sessions
11
+ -- share the same transcript_path is a re-parse (replaces); different paths is
12
+ -- operator supersedence (untouched). See
13
+ -- docs/plans/2026-06-10-supersedence-split.md.
14
+ --
15
+ -- Widening the CHECK constraints on sessions.status and session_edges.kind
16
+ -- requires a table rebuild (SQLite cannot ALTER a CHECK constraint, and
17
+ -- better-sqlite3 forbids writable_schema edits). The rebuild runs under
18
+ -- foreign_keys=OFF so dropping the old `sessions` table does not cascade-delete
19
+ -- its dependents; the pragma is a no-op inside a transaction, hence nlm:no-wrap.
20
+
21
+ PRAGMA foreign_keys = OFF;
22
+
23
+ BEGIN;
24
+
25
+ -- ── Rebuild sessions with the widened status CHECK ──────────────────────────
26
+ DROP TRIGGER IF EXISTS sessions_ai;
27
+ DROP TRIGGER IF EXISTS sessions_au;
28
+ DROP TRIGGER IF EXISTS sessions_ad;
29
+
30
+ CREATE TABLE sessions_new (
31
+ id TEXT PRIMARY KEY,
32
+ runtime TEXT NOT NULL,
33
+ runtime_session_id TEXT,
34
+ started_at TEXT NOT NULL,
35
+ ended_at TEXT,
36
+ duration_min INTEGER,
37
+ label TEXT NOT NULL,
38
+ summary TEXT NOT NULL,
39
+ body TEXT,
40
+ status TEXT NOT NULL CHECK(status IN ('active','closed','superseded','replaced')),
41
+ transcript_kind TEXT,
42
+ transcript_path TEXT,
43
+ transcript_offset INTEGER,
44
+ transcript_length INTEGER,
45
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
46
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
47
+ );
48
+
49
+ INSERT INTO sessions_new
50
+ (id, runtime, runtime_session_id, started_at, ended_at, duration_min,
51
+ label, summary, body, status, transcript_kind, transcript_path,
52
+ transcript_offset, transcript_length, created_at, updated_at)
53
+ SELECT
54
+ id, runtime, runtime_session_id, started_at, ended_at, duration_min,
55
+ label, summary, body, status, transcript_kind, transcript_path,
56
+ transcript_offset, transcript_length, created_at, updated_at
57
+ FROM sessions;
58
+
59
+ DROP TABLE sessions;
60
+ ALTER TABLE sessions_new RENAME TO sessions;
61
+
62
+ CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at DESC);
63
+ CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
64
+ CREATE INDEX IF NOT EXISTS idx_sessions_runtime ON sessions(runtime);
65
+
66
+ CREATE TRIGGER sessions_ai AFTER INSERT ON sessions BEGIN
67
+ INSERT INTO sessions_fts(rowid, label, summary, body)
68
+ VALUES (new.rowid, new.label, new.summary, new.body);
69
+ END;
70
+ CREATE TRIGGER sessions_au AFTER UPDATE ON sessions BEGIN
71
+ INSERT INTO sessions_fts(sessions_fts, rowid, label, summary, body)
72
+ VALUES('delete', old.rowid, old.label, old.summary, old.body);
73
+ INSERT INTO sessions_fts(rowid, label, summary, body)
74
+ VALUES (new.rowid, new.label, new.summary, new.body);
75
+ END;
76
+ CREATE TRIGGER sessions_ad AFTER DELETE ON sessions BEGIN
77
+ INSERT INTO sessions_fts(sessions_fts, rowid, label, summary, body)
78
+ VALUES('delete', old.rowid, old.label, old.summary, old.body);
79
+ END;
80
+
81
+ -- The rebuild reassigned rowids; rebuild the external-content FTS index so its
82
+ -- rowid → session mapping matches the new sessions table.
83
+ INSERT INTO sessions_fts(sessions_fts) VALUES('rebuild');
84
+
85
+ -- ── Rebuild session_edges with the widened kind CHECK ───────────────────────
86
+ CREATE TABLE session_edges_new (
87
+ from_session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
88
+ to_session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
89
+ kind TEXT NOT NULL CHECK(kind IN ('supersedes','replaces','continues','branched_from','merged_from')),
90
+ PRIMARY KEY (from_session, to_session, kind)
91
+ );
92
+
93
+ INSERT INTO session_edges_new (from_session, to_session, kind)
94
+ SELECT from_session, to_session, kind FROM session_edges;
95
+
96
+ DROP TABLE session_edges;
97
+ ALTER TABLE session_edges_new RENAME TO session_edges;
98
+
99
+ CREATE INDEX IF NOT EXISTS idx_session_edges_from ON session_edges(from_session);
100
+ CREATE INDEX IF NOT EXISTS idx_session_edges_to ON session_edges(to_session);
101
+ CREATE INDEX IF NOT EXISTS idx_session_edges_kind ON session_edges(kind);
102
+
103
+ -- ── Reclassify mechanical edges + predecessor rows ──────────────────────────
104
+ UPDATE session_edges SET kind = 'replaces'
105
+ WHERE kind = 'supersedes'
106
+ AND (SELECT transcript_path FROM sessions WHERE id = from_session)
107
+ = (SELECT transcript_path FROM sessions WHERE id = to_session);
108
+
109
+ UPDATE sessions SET status = 'replaced', updated_at = datetime('now')
110
+ WHERE status = 'superseded'
111
+ AND id IN (SELECT to_session FROM session_edges WHERE kind = 'replaces');
112
+
113
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (19, '019_split_replaces');
114
+
115
+ COMMIT;
116
+
117
+ PRAGMA foreign_keys = ON;
@@ -0,0 +1,45 @@
1
+ -- Migration 020: collapse duplicate active facts (NLM #301).
2
+ --
3
+ -- The SQLite supersedence loop superseded only the single most-recent active
4
+ -- prior per new fact (ORDER BY created_at DESC LIMIT 1). Once two facts for the
5
+ -- same (subject, predicate) were simultaneously active — a multi-pass backfill,
6
+ -- or an ON DELETE SET NULL un-supersede when a re-ingest deleted a chain head —
7
+ -- every later ingest cleared only one, so the duplicate persisted. The code fix
8
+ -- collapses ALL priors set-wise; this migration repairs the rows already stuck.
9
+ --
10
+ -- Within each conflicting (subject, predicate) group, the newest active fact
11
+ -- (created_at, then id as a deterministic tiebreak) stays active; the older
12
+ -- actives get superseded_by = that newest fact's id. Plain UPDATE — no table
13
+ -- rebuild, so the runner's BEGIN/COMMIT wrapper is fine (no -- nlm:no-wrap).
14
+
15
+ WITH winners AS (
16
+ SELECT subject, predicate, id AS winner_id
17
+ FROM (
18
+ SELECT
19
+ subject, predicate, id,
20
+ ROW_NUMBER() OVER (
21
+ PARTITION BY subject, predicate
22
+ ORDER BY created_at DESC, id DESC
23
+ ) AS rn
24
+ FROM facts
25
+ WHERE superseded_by IS NULL
26
+ )
27
+ WHERE rn = 1
28
+ )
29
+ UPDATE facts
30
+ SET superseded_by = (
31
+ SELECT w.winner_id FROM winners w
32
+ WHERE w.subject = facts.subject AND w.predicate = facts.predicate
33
+ )
34
+ WHERE superseded_by IS NULL
35
+ AND id != (
36
+ SELECT w.winner_id FROM winners w
37
+ WHERE w.subject = facts.subject AND w.predicate = facts.predicate
38
+ )
39
+ AND EXISTS (
40
+ SELECT 1 FROM facts dup
41
+ WHERE dup.subject = facts.subject AND dup.predicate = facts.predicate
42
+ AND dup.superseded_by IS NULL AND dup.id != facts.id
43
+ );
44
+
45
+ INSERT OR IGNORE INTO schema_migrations (version, name) VALUES (20, '020_collapse_duplicate_active_facts');
@@ -0,0 +1,214 @@
1
+ -- NLM PostgreSQL schema v1.
2
+ -- Mirrors SQLite migrations/000–016 but uses PG idioms:
3
+ -- - SERIAL / TEXT for PKs (no AUTOINCREMENT)
4
+ -- - pgvector for embeddings instead of sqlite-vec
5
+ -- - tsvector generated column + GIN for FTS5 equivalent
6
+ -- - NOW() instead of datetime('now')
7
+ -- - ON CONFLICT DO NOTHING / DO UPDATE instead of INSERT OR IGNORE / REPLACE
8
+
9
+ CREATE EXTENSION IF NOT EXISTS vector;
10
+
11
+ -- ── Sessions ────────────────────────────────────────────────────────────────
12
+ CREATE TABLE IF NOT EXISTS sessions (
13
+ id TEXT PRIMARY KEY,
14
+ runtime TEXT NOT NULL,
15
+ runtime_session_id TEXT,
16
+ started_at TEXT NOT NULL,
17
+ ended_at TEXT,
18
+ duration_min REAL,
19
+ label TEXT NOT NULL,
20
+ summary TEXT NOT NULL,
21
+ body TEXT,
22
+ status TEXT NOT NULL CHECK (status IN ('active', 'closed', 'superseded')),
23
+ transcript_kind TEXT,
24
+ transcript_path TEXT,
25
+ transcript_offset BIGINT,
26
+ transcript_length BIGINT,
27
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
28
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
29
+ fts_vector TSVECTOR GENERATED ALWAYS AS (
30
+ setweight(to_tsvector('english', coalesce(label, '')), 'A') ||
31
+ setweight(to_tsvector('english', coalesce(summary, '')), 'B') ||
32
+ setweight(to_tsvector('english', coalesce(body, '')), 'C')
33
+ ) STORED
34
+ );
35
+
36
+ CREATE INDEX IF NOT EXISTS sessions_fts_idx ON sessions USING GIN(fts_vector);
37
+ CREATE INDEX IF NOT EXISTS idx_sessions_started_at ON sessions(started_at);
38
+
39
+ -- ── Session embeddings (chunks + map, mirrors SQLite architecture) ──────────
40
+ CREATE TABLE IF NOT EXISTS session_embedding_chunks (
41
+ chunk_id SERIAL PRIMARY KEY,
42
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
43
+ chunk_idx INTEGER NOT NULL,
44
+ embedding vector(768) NOT NULL
45
+ );
46
+
47
+ CREATE INDEX IF NOT EXISTS session_chunks_embedding_idx
48
+ ON session_embedding_chunks USING ivfflat (embedding vector_l2_ops)
49
+ WITH (lists = 100);
50
+
51
+ CREATE TABLE IF NOT EXISTS session_chunk_map (
52
+ chunk_id INTEGER NOT NULL REFERENCES session_embedding_chunks(chunk_id) ON DELETE CASCADE,
53
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
54
+ chunk_idx INTEGER NOT NULL,
55
+ PRIMARY KEY (chunk_id)
56
+ );
57
+
58
+ CREATE INDEX IF NOT EXISTS idx_session_chunk_map_session ON session_chunk_map(session_id);
59
+
60
+ -- ── Markers (decisions + open questions) ────────────────────────────────────
61
+ CREATE TABLE IF NOT EXISTS markers (
62
+ id SERIAL PRIMARY KEY,
63
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
64
+ kind TEXT NOT NULL CHECK (kind IN ('decision', 'open')),
65
+ text TEXT NOT NULL,
66
+ position INTEGER NOT NULL DEFAULT 0
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_markers_session ON markers(session_id);
70
+
71
+ -- ── Entities ────────────────────────────────────────────────────────────────
72
+ CREATE TABLE IF NOT EXISTS entities (
73
+ canonical TEXT PRIMARY KEY,
74
+ type TEXT NOT NULL DEFAULT 'candidate',
75
+ status TEXT NOT NULL DEFAULT 'candidate',
76
+ source TEXT NOT NULL DEFAULT 'auto-detected',
77
+ first_seen_session TEXT REFERENCES sessions(id) ON DELETE SET NULL,
78
+ last_seen_session TEXT REFERENCES sessions(id) ON DELETE SET NULL,
79
+ session_count INTEGER NOT NULL DEFAULT 0,
80
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
81
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
82
+ );
83
+
84
+ CREATE TABLE IF NOT EXISTS session_entities (
85
+ session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
86
+ entity_canonical TEXT NOT NULL REFERENCES entities(canonical) ON DELETE CASCADE,
87
+ PRIMARY KEY (session_id, entity_canonical)
88
+ );
89
+
90
+ -- ── Session edges ────────────────────────────────────────────────────────────
91
+ CREATE TABLE IF NOT EXISTS session_edges (
92
+ from_session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
93
+ to_session TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
94
+ kind TEXT NOT NULL CHECK (kind IN ('supersedes', 'continues')),
95
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
96
+ PRIMARY KEY (from_session, to_session, kind)
97
+ );
98
+
99
+ -- ── Facts ────────────────────────────────────────────────────────────────────
100
+ CREATE TABLE IF NOT EXISTS facts (
101
+ id TEXT PRIMARY KEY,
102
+ kind TEXT NOT NULL CHECK (kind IN ('decision', 'open', 'attribute')),
103
+ subject TEXT NOT NULL,
104
+ predicate TEXT NOT NULL,
105
+ value TEXT NOT NULL,
106
+ source_session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
107
+ source_quote TEXT,
108
+ created_at TEXT NOT NULL DEFAULT to_char(NOW() AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'),
109
+ superseded_by TEXT REFERENCES facts(id) ON DELETE SET NULL,
110
+ confidence REAL NOT NULL CHECK (confidence >= 0.0 AND confidence <= 1.0)
111
+ );
112
+
113
+ CREATE INDEX IF NOT EXISTS idx_facts_subject_predicate_current
114
+ ON facts(subject, predicate) WHERE superseded_by IS NULL;
115
+ CREATE INDEX IF NOT EXISTS idx_facts_subject_current
116
+ ON facts(subject) WHERE superseded_by IS NULL;
117
+ CREATE INDEX IF NOT EXISTS idx_facts_session
118
+ ON facts(source_session_id);
119
+
120
+ -- ── Fact embeddings ──────────────────────────────────────────────────────────
121
+ CREATE TABLE IF NOT EXISTS fact_embeddings (
122
+ fact_id TEXT PRIMARY KEY REFERENCES facts(id) ON DELETE CASCADE,
123
+ embedding vector(768) NOT NULL
124
+ );
125
+
126
+ CREATE INDEX IF NOT EXISTS fact_embeddings_idx
127
+ ON fact_embeddings USING ivfflat (embedding vector_l2_ops)
128
+ WITH (lists = 100);
129
+
130
+ -- ── Actions (event-sourced action log) ───────────────────────────────────────
131
+ CREATE TABLE IF NOT EXISTS actions (
132
+ id TEXT PRIMARY KEY,
133
+ timestamp TEXT NOT NULL,
134
+ kind TEXT NOT NULL,
135
+ subject_type TEXT NOT NULL,
136
+ subject_id TEXT NOT NULL,
137
+ payload TEXT,
138
+ actor TEXT NOT NULL DEFAULT 'user',
139
+ runtime TEXT,
140
+ reverted_by TEXT REFERENCES actions(id),
141
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
142
+ );
143
+
144
+ CREATE INDEX IF NOT EXISTS idx_actions_subject ON actions(subject_type, subject_id);
145
+ CREATE INDEX IF NOT EXISTS idx_actions_kind ON actions(kind);
146
+ CREATE INDEX IF NOT EXISTS idx_actions_timestamp ON actions(timestamp DESC);
147
+
148
+ -- ── Sources registry ─────────────────────────────────────────────────────────
149
+ CREATE TABLE IF NOT EXISTS sources (
150
+ id SERIAL PRIMARY KEY,
151
+ kind TEXT NOT NULL CHECK (kind IN ('claude-code', 'codex', 'hermes', 'hermes-agent', 'aider', 'cursor', 'windsurf', 'opencode', 'pi', 'jsonl-generic', 'webhook')),
152
+ name TEXT NOT NULL UNIQUE,
153
+ path_or_url TEXT,
154
+ runtime_label TEXT NOT NULL,
155
+ parse_config TEXT NOT NULL DEFAULT '{}',
156
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
157
+ token TEXT,
158
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
159
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
160
+ );
161
+
162
+ -- ── Providers registry ───────────────────────────────────────────────────────
163
+ CREATE TABLE IF NOT EXISTS providers (
164
+ id SERIAL PRIMARY KEY,
165
+ kind TEXT NOT NULL CHECK (kind IN ('deepseek', 'ollama', 'openai', 'anthropic', 'openrouter', 'openai-compatible')),
166
+ name TEXT NOT NULL UNIQUE,
167
+ base_url TEXT,
168
+ api_key TEXT,
169
+ default_model TEXT,
170
+ enabled BOOLEAN NOT NULL DEFAULT TRUE,
171
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
172
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
173
+ );
174
+
175
+ -- ── Adapter state (per-runtime offsets for resumability) ─────────────────────
176
+ CREATE TABLE IF NOT EXISTS adapter_state (
177
+ adapter_name TEXT NOT NULL,
178
+ source_path TEXT NOT NULL,
179
+ last_offset BIGINT NOT NULL DEFAULT 0,
180
+ last_processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
181
+ file_size BIGINT,
182
+ session_id TEXT,
183
+ failure_count INTEGER NOT NULL DEFAULT 0,
184
+ PRIMARY KEY (adapter_name, source_path)
185
+ );
186
+
187
+ -- ── Schema migrations tracker ────────────────────────────────────────────────
188
+ CREATE TABLE IF NOT EXISTS schema_migrations (
189
+ version INTEGER PRIMARY KEY,
190
+ name TEXT NOT NULL,
191
+ applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
192
+ );
193
+
194
+ INSERT INTO schema_migrations (version, name) VALUES (1, '001_initial')
195
+ ON CONFLICT DO NOTHING;
196
+
197
+ -- Signals - agent self-improvement telemetry lane (mirror of SQLite 017).
198
+ CREATE TABLE IF NOT EXISTS signals (
199
+ id TEXT PRIMARY KEY,
200
+ v INTEGER NOT NULL DEFAULT 1,
201
+ install_scope TEXT NOT NULL,
202
+ kind TEXT NOT NULL CHECK (kind IN ('gate', 'eval', 'review', 'test')),
203
+ producer TEXT NOT NULL,
204
+ outcome TEXT NOT NULL CHECK (outcome IN ('pass', 'fail', 'fix', 'exhausted')),
205
+ model TEXT NOT NULL,
206
+ repo TEXT NOT NULL,
207
+ step TEXT,
208
+ detail TEXT,
209
+ session_id TEXT,
210
+ ts TEXT NOT NULL,
211
+ created_at TEXT NOT NULL DEFAULT (now()::text)
212
+ );
213
+ CREATE INDEX IF NOT EXISTS idx_signals_agg ON signals(install_scope, repo, model, kind, step);
214
+ CREATE INDEX IF NOT EXISTS idx_signals_ts ON signals(ts);
@@ -0,0 +1,16 @@
1
+ -- PG parity for SQLite migration 018: repair self-supersedence damage.
2
+ --
3
+ -- One-shot repair, applied manually by an operator against a PG canonical
4
+ -- store (PgStorage.init only runs 001_initial.sql, there is no version-gated
5
+ -- runner on the PG side). Mirrors migrations/018_repair_self_supersede.sql.
6
+ --
7
+ -- Removes self-loop 'supersedes' edges (id -> id) written by the unguarded
8
+ -- scan path, then restores sessions wrongly marked superseded — those left
9
+ -- without any real incoming 'supersedes' edge.
10
+
11
+ DELETE FROM session_edges WHERE from_session = to_session AND kind = 'supersedes';
12
+
13
+ UPDATE sessions
14
+ SET status = 'closed', updated_at = NOW()
15
+ WHERE status = 'superseded'
16
+ AND id NOT IN (SELECT to_session FROM session_edges WHERE kind = 'supersedes');
@@ -0,0 +1,32 @@
1
+ -- PG parity for SQLite migration 019: split the mechanical `replaces` relation
2
+ -- out of `supersedes`.
3
+ --
4
+ -- One-shot repair, applied manually by an operator against a PG canonical store
5
+ -- (PgStorage.init only runs 001_initial.sql; there is no version-gated runner on
6
+ -- the PG side). Mirrors migrations/019_split_replaces.sql.
7
+ --
8
+ -- Widens the CHECK constraints on sessions.status and session_edges.kind to
9
+ -- admit 'replaced' / 'replaces', then reclassifies existing mechanical edges:
10
+ -- an edge whose two sessions share the same transcript_path is a re-parse
11
+ -- (replaces); different paths is operator supersedence (untouched).
12
+
13
+ BEGIN;
14
+
15
+ ALTER TABLE sessions DROP CONSTRAINT IF EXISTS sessions_status_check;
16
+ ALTER TABLE sessions ADD CONSTRAINT sessions_status_check
17
+ CHECK (status IN ('active', 'closed', 'superseded', 'replaced'));
18
+
19
+ ALTER TABLE session_edges DROP CONSTRAINT IF EXISTS session_edges_kind_check;
20
+ ALTER TABLE session_edges ADD CONSTRAINT session_edges_kind_check
21
+ CHECK (kind IN ('supersedes', 'replaces', 'continues'));
22
+
23
+ UPDATE session_edges e SET kind = 'replaces'
24
+ WHERE e.kind = 'supersedes'
25
+ AND (SELECT transcript_path FROM sessions WHERE id = e.from_session)
26
+ = (SELECT transcript_path FROM sessions WHERE id = e.to_session);
27
+
28
+ UPDATE sessions SET status = 'replaced', updated_at = NOW()
29
+ WHERE status = 'superseded'
30
+ AND id IN (SELECT to_session FROM session_edges WHERE kind = 'replaces');
31
+
32
+ COMMIT;
@@ -0,0 +1,46 @@
1
+ -- PG parity for SQLite migration 020: collapse duplicate active facts (#301).
2
+ --
3
+ -- One-shot repair, applied manually by an operator against a PG canonical store
4
+ -- (PgStorage.init only runs 001_initial.sql; there is no version-gated runner on
5
+ -- the PG side). Mirrors migrations/020_collapse_duplicate_active_facts.sql.
6
+ --
7
+ -- Within each conflicting (subject, predicate) group, the newest active fact
8
+ -- (created_at, then id) stays active; the older actives get superseded_by set
9
+ -- to that newest fact's id. The PG ingest path already collapsed all priors
10
+ -- set-wise, so this only repairs rows the SQLite-era single-prior loop stranded
11
+ -- in a corpus migrated to PG.
12
+
13
+ BEGIN;
14
+
15
+ WITH winners AS (
16
+ SELECT subject, predicate, id AS winner_id
17
+ FROM (
18
+ SELECT
19
+ subject, predicate, id,
20
+ ROW_NUMBER() OVER (
21
+ PARTITION BY subject, predicate
22
+ ORDER BY created_at DESC, id DESC
23
+ ) AS rn
24
+ FROM facts
25
+ WHERE superseded_by IS NULL
26
+ ) ranked
27
+ WHERE rn = 1
28
+ ),
29
+ conflicts AS (
30
+ SELECT subject, predicate
31
+ FROM facts
32
+ WHERE superseded_by IS NULL
33
+ GROUP BY subject, predicate
34
+ HAVING COUNT(*) > 1
35
+ )
36
+ UPDATE facts f
37
+ SET superseded_by = w.winner_id
38
+ FROM winners w, conflicts c
39
+ WHERE f.subject = w.subject
40
+ AND f.predicate = w.predicate
41
+ AND f.subject = c.subject
42
+ AND f.predicate = c.predicate
43
+ AND f.superseded_by IS NULL
44
+ AND f.id != w.winner_id;
45
+
46
+ COMMIT;
package/nlm/index.js CHANGED
@@ -412,7 +412,7 @@ async function main() {
412
412
  const mode = process.env["NLM_HOOK_MODE"] === "live" ? "live" : "shadow";
413
413
  const out = await runHook(
414
414
  { prompt, conversationId },
415
- { mode, recall: (q) => recallOverHttp(q, "claude-code") }
415
+ { mode, recall: (q) => recallOverHttp(q, "claude-code", conversationId === "unknown" ? void 0 : conversationId) }
416
416
  );
417
417
  if (out) process.stdout.write(out);
418
418
  } catch {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nlm-memory",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "Local-first non-linear memory operating system for AI operators.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -28,6 +28,7 @@
28
28
  ],
29
29
  "files": [
30
30
  "dist",
31
+ "migrations",
31
32
  "plugin",
32
33
  "plugin-hermes-agent",
33
34
  "nlm",
@@ -414,7 +414,7 @@ async function main() {
414
414
  const mode = process.env["NLM_HOOK_MODE"] === "live" ? "live" : "shadow";
415
415
  const out = await runHook(
416
416
  { prompt, conversationId },
417
- { mode, recall: (q) => recallOverHttp(q, "claude-code") }
417
+ { mode, recall: (q) => recallOverHttp(q, "claude-code", conversationId === "unknown" ? void 0 : conversationId) }
418
418
  );
419
419
  if (out) process.stdout.write(out);
420
420
  } catch {
@@ -396,7 +396,7 @@ async function postCitationOverHttp(conversationId, citedId, kind, responsePrevi
396
396
  method: "POST",
397
397
  headers: hookAuthHeaders({ "content-type": "application/json" }),
398
398
  body: JSON.stringify({
399
- conversation_id: conversationId,
399
+ ...conversationId !== "unknown" ? { conversation_id: conversationId } : {},
400
400
  cited_id: citedId,
401
401
  kind,
402
402
  response_preview: responsePreview