@rohirik/openltm-core 2.8.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 (97) hide show
  1. package/README.md +67 -0
  2. package/assets/opencode/agents/aegis.md +211 -0
  3. package/assets/opencode/plugins/aegis.ts +3 -0
  4. package/assets/opencode/skills/AgentTrustBoundaries/ContextCrushDefense.md +104 -0
  5. package/assets/opencode/skills/AgentTrustBoundaries/SKILL.md +31 -0
  6. package/assets/opencode/skills/AgentTrustBoundaries/TrustBoundaryPatterns.md +114 -0
  7. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/DefendContextCrush.md +27 -0
  8. package/assets/opencode/skills/AgentTrustBoundaries/Workflows/HandleUntrustedContent.md +27 -0
  9. package/assets/opencode/skills/CommandPathSafety/CommandInjectionPatterns.md +95 -0
  10. package/assets/opencode/skills/CommandPathSafety/PathTraversalAndInstallerSafety.md +106 -0
  11. package/assets/opencode/skills/CommandPathSafety/SKILL.md +31 -0
  12. package/assets/opencode/skills/CommandPathSafety/Workflows/EnforcePathBoundaries.md +27 -0
  13. package/assets/opencode/skills/CommandPathSafety/Workflows/HardenCommandExecution.md +27 -0
  14. package/assets/opencode/skills/SecretSafeHandling/CloudCredentialPatterns.md +106 -0
  15. package/assets/opencode/skills/SecretSafeHandling/SKILL.md +31 -0
  16. package/assets/opencode/skills/SecretSafeHandling/SecretHandlingPlaybook.md +102 -0
  17. package/assets/opencode/skills/SecretSafeHandling/Workflows/DesignSecretSafeFlow.md +27 -0
  18. package/assets/opencode/skills/SecretSafeHandling/Workflows/RemoveSecretExposure.md +27 -0
  19. package/package.json +41 -0
  20. package/src/__tests__/cli/claude.test.ts +122 -0
  21. package/src/__tests__/cli/detect.test.ts +91 -0
  22. package/src/__tests__/cli/install.test.ts +161 -0
  23. package/src/__tests__/cli/opencode.test.ts +169 -0
  24. package/src/__tests__/cli/pi.test.ts +113 -0
  25. package/src/__tests__/cli.test.ts +70 -0
  26. package/src/__tests__/events/crossProcess.test.ts +82 -0
  27. package/src/__tests__/events/index.test.ts +32 -0
  28. package/src/__tests__/extensions.test.ts +81 -0
  29. package/src/__tests__/migrations/retention.test.ts +118 -0
  30. package/src/__tests__/queue/index.test.ts +61 -0
  31. package/src/__tests__/scheduler/index.test.ts +39 -0
  32. package/src/__tests__/vec/index.test.ts +130 -0
  33. package/src/__tests__/vec/parity.test.ts +70 -0
  34. package/src/adapterTypes.ts +23 -0
  35. package/src/cli/_shared.ts +120 -0
  36. package/src/cli/bin.ts +97 -0
  37. package/src/cli/claude.ts +124 -0
  38. package/src/cli/detect.ts +55 -0
  39. package/src/cli/hook.ts +25 -0
  40. package/src/cli/index.ts +22 -0
  41. package/src/cli/install.ts +185 -0
  42. package/src/cli/opencode.ts +193 -0
  43. package/src/cli/pi.ts +74 -0
  44. package/src/cli/types.ts +78 -0
  45. package/src/config.ts +163 -0
  46. package/src/context.ts +172 -0
  47. package/src/dao/conflicts.ts +26 -0
  48. package/src/dao/contextItems.ts +70 -0
  49. package/src/dao/embeddings.ts +78 -0
  50. package/src/dao/index.ts +9 -0
  51. package/src/dao/provenanceAudit.ts +108 -0
  52. package/src/dao/types.ts +142 -0
  53. package/src/db.ts +780 -0
  54. package/src/dedup.ts +12 -0
  55. package/src/embeddings.ts +386 -0
  56. package/src/events/index.ts +130 -0
  57. package/src/extensions.ts +140 -0
  58. package/src/graph.ts +268 -0
  59. package/src/index.ts +95 -0
  60. package/src/janitor/archive.ts +66 -0
  61. package/src/janitor/decay.ts +60 -0
  62. package/src/janitor/dedup.ts +333 -0
  63. package/src/janitor/embeddings.ts +209 -0
  64. package/src/janitor/index.ts +215 -0
  65. package/src/janitor/promote.ts +188 -0
  66. package/src/janitor/providers/anthropic.ts +91 -0
  67. package/src/janitor/providers/cohere.ts +135 -0
  68. package/src/janitor/providers/gemini.ts +156 -0
  69. package/src/janitor/providers/ollama.ts +177 -0
  70. package/src/janitor/providers/openai.ts +121 -0
  71. package/src/janitor/providers/openrouter.ts +182 -0
  72. package/src/janitor/providers/types.ts +154 -0
  73. package/src/janitor/providers/utils.ts +35 -0
  74. package/src/janitor/supersedes.ts +199 -0
  75. package/src/lib/honker.ts +54 -0
  76. package/src/lib/honkerTypes.ts +109 -0
  77. package/src/lib/jsonlLogger.ts +92 -0
  78. package/src/lib/writeQueue.ts +28 -0
  79. package/src/migrations.ts +415 -0
  80. package/src/paths.ts +22 -0
  81. package/src/proposals.ts +120 -0
  82. package/src/providers/disabled.ts +19 -0
  83. package/src/providers/embeddingProvider.ts +49 -0
  84. package/src/providers/gemini.ts +37 -0
  85. package/src/providers/index.ts +2 -0
  86. package/src/providers/ollama.ts +43 -0
  87. package/src/providers/openai.ts +35 -0
  88. package/src/queue/index.ts +53 -0
  89. package/src/queue/worker.ts +77 -0
  90. package/src/recall/categorise.ts +139 -0
  91. package/src/recall/explainer.ts +76 -0
  92. package/src/scheduler/index.ts +97 -0
  93. package/src/schema.sql +191 -0
  94. package/src/secretsScrubber.ts +105 -0
  95. package/src/shared-db.ts +158 -0
  96. package/src/vec/index.ts +161 -0
  97. package/tsconfig.json +9 -0
package/src/schema.sql ADDED
@@ -0,0 +1,191 @@
1
+ -- Long-Term Memory Schema
2
+ -- Two-table design: context_items (per-project context) + memories (global learned insights)
3
+
4
+ -- ============================================================
5
+ -- schema_migrations: versioned migration tracking
6
+ -- ============================================================
7
+ CREATE TABLE IF NOT EXISTS schema_migrations (
8
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
9
+ version TEXT NOT NULL UNIQUE,
10
+ applied_at INTEGER NOT NULL DEFAULT (unixepoch()),
11
+ checksum TEXT
12
+ );
13
+
14
+ -- ============================================================
15
+ -- context_items: replaces the 4 per-project Markdown context files
16
+ -- ============================================================
17
+ CREATE TABLE IF NOT EXISTS context_items (
18
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
19
+ project_name TEXT NOT NULL,
20
+ type TEXT NOT NULL CHECK(type IN ('goal','decision','progress','gotcha')),
21
+ content TEXT NOT NULL,
22
+ title TEXT,
23
+ session_id TEXT, -- for progress dedup
24
+ permanent INTEGER NOT NULL DEFAULT 0, -- 1 = never auto-delete (decisions, gotchas)
25
+ memory_id INTEGER REFERENCES memories(id) ON DELETE SET NULL,
26
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending_promotion','promoted')),
27
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
28
+ );
29
+ CREATE INDEX IF NOT EXISTS idx_ctx_project ON context_items(project_name);
30
+ CREATE INDEX IF NOT EXISTS idx_ctx_type ON context_items(project_name, type);
31
+
32
+ -- ============================================================
33
+ -- memories: global + project-scoped learned insights
34
+ -- ============================================================
35
+ CREATE TABLE IF NOT EXISTS memories (
36
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
37
+ content TEXT NOT NULL,
38
+ title TEXT,
39
+ category TEXT NOT NULL CHECK(category IN (
40
+ 'preference','architecture','gotcha','pattern','workflow','constraint')),
41
+ importance INTEGER NOT NULL DEFAULT 3 CHECK(importance BETWEEN 1 AND 5),
42
+ confidence REAL NOT NULL DEFAULT 1.0 CHECK(confidence BETWEEN 0.0 AND 1.0),
43
+ source TEXT,
44
+ project_scope TEXT, -- NULL = global, else project name
45
+ dedup_key TEXT UNIQUE,
46
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
47
+ last_confirmed_at TEXT NOT NULL DEFAULT (datetime('now')),
48
+ confirm_count INTEGER NOT NULL DEFAULT 1,
49
+ -- Phase 2: janitor fields
50
+ status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active','pending','deprecated','superseded')),
51
+ embedding BLOB, -- float32 vector for semantic search
52
+ last_used_at TEXT NOT NULL DEFAULT (datetime('now')),
53
+ -- Phase 3: temporal metadata
54
+ first_recalled_at TEXT,
55
+ last_recalled_at TEXT,
56
+ recall_count INTEGER NOT NULL DEFAULT 0,
57
+ superseded_by INTEGER REFERENCES memories(id) ON DELETE SET NULL,
58
+ superseded_at TEXT,
59
+ -- Phase 7: graph humanization
60
+ hidden INTEGER NOT NULL DEFAULT 0,
61
+ color TEXT,
62
+ icon TEXT,
63
+ user_note TEXT,
64
+ -- Personal relevance signal: 'works' | 'doesnt' | NULL (unrated)
65
+ relevance_signal TEXT,
66
+ relevance_signal_at TEXT
67
+ );
68
+
69
+ CREATE INDEX IF NOT EXISTS idx_memories_category ON memories(category);
70
+ CREATE INDEX IF NOT EXISTS idx_memories_project ON memories(project_scope);
71
+ CREATE INDEX IF NOT EXISTS idx_memories_importance ON memories(importance DESC);
72
+ CREATE INDEX IF NOT EXISTS idx_memories_confidence ON memories(confidence DESC);
73
+ CREATE INDEX IF NOT EXISTS idx_memories_status ON memories(status);
74
+ CREATE INDEX IF NOT EXISTS idx_memories_last_used ON memories(last_used_at);
75
+ CREATE INDEX IF NOT EXISTS idx_memories_superseded ON memories(superseded_by);
76
+ CREATE INDEX IF NOT EXISTS idx_memories_recall_count ON memories(recall_count DESC);
77
+
78
+ -- ============================================================
79
+ -- tags + memory_tags: many-to-many tagging for memories
80
+ -- ============================================================
81
+ CREATE TABLE IF NOT EXISTS tags (
82
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
83
+ name TEXT NOT NULL UNIQUE
84
+ );
85
+
86
+ CREATE TABLE IF NOT EXISTS memory_tags (
87
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
88
+ tag_id INTEGER NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
89
+ PRIMARY KEY (memory_id, tag_id)
90
+ );
91
+
92
+ -- ============================================================
93
+ -- memory_relations: knowledge graph edges
94
+ -- ============================================================
95
+ CREATE TABLE IF NOT EXISTS memory_relations (
96
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
97
+ source_memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
98
+ target_memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
99
+ relationship_type TEXT NOT NULL CHECK(relationship_type IN (
100
+ 'supports','contradicts','refines','depends_on','related_to','supersedes')),
101
+ note TEXT,
102
+ weight REAL NOT NULL DEFAULT 1.0 CHECK(weight BETWEEN 0.0 AND 1.0),
103
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
104
+ UNIQUE(source_memory_id, target_memory_id, relationship_type)
105
+ );
106
+
107
+ CREATE INDEX IF NOT EXISTS idx_relations_source ON memory_relations(source_memory_id);
108
+ CREATE INDEX IF NOT EXISTS idx_relations_target ON memory_relations(target_memory_id);
109
+
110
+ -- ============================================================
111
+ -- memory_layout: per-view position persistence (Phase 7)
112
+ -- Only saved on explicit drag/pin — simulation-placed nodes have no row.
113
+ -- ============================================================
114
+ CREATE TABLE IF NOT EXISTS memory_layout (
115
+ memory_id INTEGER NOT NULL REFERENCES memories(id) ON DELETE CASCADE,
116
+ view TEXT NOT NULL,
117
+ x REAL NOT NULL,
118
+ y REAL NOT NULL,
119
+ pinned INTEGER NOT NULL DEFAULT 0,
120
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
121
+ PRIMARY KEY (memory_id, view)
122
+ );
123
+
124
+ CREATE INDEX IF NOT EXISTS idx_layout_view ON memory_layout(view);
125
+
126
+ -- ============================================================
127
+ -- FTS5 virtual table for full-text search on memories (includes title)
128
+ -- ============================================================
129
+ CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts USING fts5(
130
+ title,
131
+ content,
132
+ content='memories',
133
+ content_rowid='id'
134
+ );
135
+
136
+ -- Triggers to keep FTS in sync with memories table
137
+ CREATE TRIGGER IF NOT EXISTS memories_ai AFTER INSERT ON memories BEGIN
138
+ INSERT INTO memories_fts(rowid, title, content)
139
+ VALUES (new.id, coalesce(new.title, ''), new.content);
140
+ END;
141
+
142
+ CREATE TRIGGER IF NOT EXISTS memories_ad AFTER DELETE ON memories BEGIN
143
+ INSERT INTO memories_fts(memories_fts, rowid, title, content)
144
+ VALUES ('delete', old.id, coalesce(old.title, ''), old.content);
145
+ END;
146
+
147
+ CREATE TRIGGER IF NOT EXISTS memories_au AFTER UPDATE ON memories BEGIN
148
+ INSERT INTO memories_fts(memories_fts, rowid, title, content)
149
+ VALUES ('delete', old.id, coalesce(old.title, ''), old.content);
150
+ INSERT INTO memories_fts(rowid, title, content)
151
+ VALUES (new.id, coalesce(new.title, ''), new.content);
152
+ END;
153
+
154
+ -- ============================================================
155
+ -- FTS5 for context_items
156
+ -- ============================================================
157
+ CREATE VIRTUAL TABLE IF NOT EXISTS context_items_fts USING fts5(
158
+ title,
159
+ content,
160
+ content='context_items',
161
+ content_rowid='id'
162
+ );
163
+
164
+ CREATE TRIGGER IF NOT EXISTS context_items_ai AFTER INSERT ON context_items BEGIN
165
+ INSERT INTO context_items_fts(rowid, title, content)
166
+ VALUES (new.id, coalesce(new.title, ''), new.content);
167
+ END;
168
+
169
+ CREATE TRIGGER IF NOT EXISTS context_items_ad AFTER DELETE ON context_items BEGIN
170
+ INSERT INTO context_items_fts(context_items_fts, rowid, title, content)
171
+ VALUES ('delete', old.id, coalesce(old.title, ''), old.content);
172
+ END;
173
+
174
+ CREATE TRIGGER IF NOT EXISTS context_items_au AFTER UPDATE ON context_items BEGIN
175
+ INSERT INTO context_items_fts(context_items_fts, rowid, title, content)
176
+ VALUES ('delete', old.id, coalesce(old.title, ''), old.content);
177
+ INSERT INTO context_items_fts(rowid, title, content)
178
+ VALUES (new.id, coalesce(new.title, ''), new.content);
179
+ END;
180
+
181
+ -- ============================================================
182
+ -- settings: key-value store for janitor/provider configuration
183
+ -- ============================================================
184
+ CREATE TABLE IF NOT EXISTS settings (
185
+ key TEXT PRIMARY KEY,
186
+ value TEXT NOT NULL,
187
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
188
+ );
189
+
190
+ -- workspace_id + agent_id indexes are created by migration 007_workspaces.sql
191
+ -- after the columns are added via ALTER TABLE. Do not add them here.
@@ -0,0 +1,105 @@
1
+ /**
2
+ * secretsScrubber.ts — Redact secrets from LTM memory content before DB writes.
3
+ * Called from learn() in db.ts. Must never throw.
4
+ */
5
+
6
+ export interface ScrubResult {
7
+ scrubbed: string;
8
+ redactions: string[]; // deduplicated pattern IDs found
9
+ }
10
+
11
+ interface Pattern {
12
+ id: string;
13
+ regex: RegExp;
14
+ replacement: string;
15
+ }
16
+
17
+ const PATTERNS: Pattern[] = [
18
+ {
19
+ id: "aws-access-key",
20
+ regex: /AKIA[0-9A-Z]{16}/g,
21
+ replacement: "[REDACTED:aws-access-key]",
22
+ },
23
+ {
24
+ id: "aws-secret-key",
25
+ // 40-char base64 string near aws/secret context
26
+ regex: /(?:aws|secret|SECRET)[^a-zA-Z0-9]{0,20}[0-9a-zA-Z/+]{40}(?![0-9a-zA-Z/+])/g,
27
+ replacement: "[REDACTED:aws-secret-key]",
28
+ },
29
+ {
30
+ id: "github-token",
31
+ regex: /gh[ps]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{82}/g,
32
+ replacement: "[REDACTED:github-token]",
33
+ },
34
+ {
35
+ id: "openai-key",
36
+ regex: /sk-proj-[a-zA-Z0-9_-]{40,}(?![a-zA-Z0-9_-])|sk-[a-zA-Z0-9]{40,}(?![a-zA-Z0-9_-])/g,
37
+ replacement: "[REDACTED:openai-key]",
38
+ },
39
+ {
40
+ id: "anthropic-key",
41
+ regex: /sk-ant-[a-zA-Z0-9_-]{93,}/g,
42
+ replacement: "[REDACTED:anthropic-key]",
43
+ },
44
+ {
45
+ id: "google-api-key",
46
+ regex: /AIza[0-9A-Za-z_-]{35}/g,
47
+ replacement: "[REDACTED:google-api-key]",
48
+ },
49
+ {
50
+ id: "stripe-key",
51
+ regex: /(?:sk|pk)_(?:test|live)_[0-9a-zA-Z]{24,}/g,
52
+ replacement: "[REDACTED:stripe-key]",
53
+ },
54
+ {
55
+ id: "slack-token",
56
+ regex: /xox[baprs]-[0-9A-Za-z-]{10,}/g,
57
+ replacement: "[REDACTED:slack-token]",
58
+ },
59
+ {
60
+ id: "jwt",
61
+ regex: /eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g,
62
+ replacement: "[REDACTED:jwt]",
63
+ },
64
+ {
65
+ id: "bearer-token",
66
+ regex: /Bearer\s+[A-Za-z0-9._\-]{20,}/gi,
67
+ replacement: "Bearer [REDACTED:bearer-token]",
68
+ },
69
+ {
70
+ id: "connection-string",
71
+ regex: /(?:postgres|mysql|mongodb|redis):\/\/[^@\s]+@[^\s]+/gi,
72
+ replacement: "[REDACTED:connection-string]",
73
+ },
74
+ {
75
+ id: "private-key",
76
+ regex: /-----BEGIN [\w\s]*PRIVATE KEY-----[\s\S]*?-----END [\w\s]*PRIVATE KEY-----/g,
77
+ replacement: "[REDACTED:private-key]",
78
+ },
79
+ {
80
+ id: "generic-api-key",
81
+ regex: /(?:api[_-]?key|secret[_-]?key|access[_-]?token)[^a-zA-Z].*?['"][A-Za-z0-9_\-]{20,}['"]/gi,
82
+ replacement: "[REDACTED:generic-api-key]",
83
+ },
84
+ ];
85
+
86
+ export function scrubSecrets(text: string): ScrubResult {
87
+ try {
88
+ if (!text) return { scrubbed: text, redactions: [] };
89
+
90
+ let scrubbed = text;
91
+ const found = new Set<string>();
92
+
93
+ for (const pattern of PATTERNS) {
94
+ pattern.regex.lastIndex = 0;
95
+ const next = scrubbed.replace(pattern.regex, pattern.replacement);
96
+ if (next !== scrubbed) found.add(pattern.id);
97
+ scrubbed = next;
98
+ }
99
+
100
+ return { scrubbed, redactions: [...found] };
101
+ } catch {
102
+ // Never throw — return original text on any error
103
+ return { scrubbed: text, redactions: [] };
104
+ }
105
+ }
@@ -0,0 +1,158 @@
1
+ /**
2
+ * shared-db.ts — Single DB singleton shared by db.ts, context.ts, server.ts, and janitor.
3
+ * Prevents dual write connections that break WAL.
4
+ * Runs versioned schema migrations on first access via migrations.ts.
5
+ */
6
+ import { Database } from "bun:sqlite";
7
+ import { existsSync, mkdirSync, readFileSync } from "fs";
8
+ import { dirname } from "path";
9
+ import { getDbPath, getSchemaPath } from "./paths.js";
10
+ import { runPendingMigrations } from "./migrations.js";
11
+ import { writeQueue } from "./lib/writeQueue.js";
12
+ import { ensureCustomSqlite, loadExtensions, getCapabilities } from "./extensions.js";
13
+ import { backfillVecIndexIfEmpty } from "./vec/index.js";
14
+ import type { LtmCoreConfig } from "./adapterTypes.js";
15
+
16
+ export let DB_PATH = getDbPath();
17
+ let SCHEMA_PATH = getSchemaPath();
18
+
19
+ /** Configure openltm-core with host-specific paths. Call before first getDb(). */
20
+ export function configure(config: LtmCoreConfig): void {
21
+ DB_PATH = config.dbPath;
22
+ if (config.schemaPath) SCHEMA_PATH = config.schemaPath;
23
+ }
24
+
25
+ let _db: Database | null = null;
26
+ let _initPromise: Promise<Database> | null = null;
27
+
28
+ export async function initDb(opts?: { dbPath?: string; schemaPath?: string }): Promise<Database> {
29
+ if (opts?.dbPath) DB_PATH = opts.dbPath;
30
+ if (opts?.schemaPath) SCHEMA_PATH = opts.schemaPath;
31
+ const dir = dirname(DB_PATH);
32
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
33
+ // Must run before the first Database opens to switch to an extension-enabled
34
+ // system SQLite. No-op (degrades to FTS-only) when none is available.
35
+ ensureCustomSqlite();
36
+ const db = new Database(DB_PATH, { create: true });
37
+ db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
38
+ loadExtensions(db);
39
+ // Apply schema.sql first (CREATE IF NOT EXISTS — safe for fresh + existing DBs)
40
+ db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
41
+ // Then run versioned migrations (idempotent — skips already-applied versions)
42
+ await runPendingMigrations(db);
43
+ // One-time vec0 index backfill for DBs that predate vec wiring. No-op when
44
+ // vec is unavailable or the index is already populated.
45
+ if (getCapabilities().vec) backfillVecIndexIfEmpty(db);
46
+ return db;
47
+ }
48
+
49
+ export function getDb(): Database {
50
+ if (_db) return _db;
51
+ // Synchronous callers: run initDb eagerly and block via Bun's top-level await support.
52
+ // _initPromise guards against concurrent initialisation during async startup paths.
53
+ if (!_initPromise) {
54
+ _initPromise = initDb().then((db) => {
55
+ _db = db;
56
+ return db;
57
+ }).catch((err) => {
58
+ // Reset so the next call retries; swallow here — sync fallback below handles the cold path.
59
+ _initPromise = null;
60
+ throw err;
61
+ });
62
+ _initPromise.catch(() => {/* prevent unhandled rejection — sync fallback is active */});
63
+ }
64
+ // For synchronous callers on cold start: open a schema-only DB immediately.
65
+ // Migrations run via _initPromise (above) on a dedicated connection and swap
66
+ // _db once complete. Do NOT run runPendingMigrations here — that causes two
67
+ // concurrent runners on the same file and UNIQUE constraint races.
68
+ if (!_db) {
69
+ const dir = dirname(DB_PATH);
70
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
71
+ ensureCustomSqlite();
72
+ _db = new Database(DB_PATH, { create: true });
73
+ _db.exec("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;");
74
+ loadExtensions(_db);
75
+ _db.exec(readFileSync(SCHEMA_PATH, "utf-8"));
76
+ }
77
+ return _db;
78
+ }
79
+
80
+ /**
81
+ * Await full DB initialisation (schema + all pending migrations).
82
+ * Use in async startup paths (tests, server boot) when migrated columns are
83
+ * needed before the first async tick completes.
84
+ */
85
+ export async function waitForInit(): Promise<void> {
86
+ if (!_initPromise) getDb(); // ensure _initPromise is created
87
+ await _initPromise;
88
+ }
89
+
90
+ /**
91
+ * Test-only: inject a specific Database instance as the singleton.
92
+ * Closes any existing connection first. Never call in production code.
93
+ */
94
+ export function _setDbForTesting(db: Database): void {
95
+ try { _db?.close(); } catch {}
96
+ _db = db;
97
+ // Resolve immediately — db is already fully migrated; prevents a second
98
+ // runPendingMigrations if getDb() or waitForInit() is called after injection.
99
+ _initPromise = Promise.resolve(db);
100
+ }
101
+
102
+ /** Retry helper for SQLITE_BUSY errors — wraps a function with automatic retry. */
103
+ export function withRetry<T>(fn: () => T, maxRetries = 3): T {
104
+ let lastError: Error | null = null;
105
+ for (let i = 0; i < maxRetries; i++) {
106
+ try {
107
+ return fn();
108
+ } catch (err: unknown) {
109
+ const e = err as { message?: string; code?: number };
110
+ if (e?.message?.includes("SQLITE_BUSY") || e?.code === 5) {
111
+ lastError = err as Error;
112
+ if (i < maxRetries - 1) {
113
+ const delay = Math.pow(2, i) * 50;
114
+ const start = Date.now();
115
+ while (Date.now() - start < delay) { /* spin-wait */ }
116
+ }
117
+ continue;
118
+ }
119
+ throw err;
120
+ }
121
+ }
122
+ throw lastError;
123
+ }
124
+
125
+ // --- Settings helpers (used by janitor + server routes) ---
126
+
127
+ export interface SettingRow {
128
+ key: string;
129
+ value: string;
130
+ updated_at: string;
131
+ }
132
+
133
+ export function getSetting(key: string): string | null {
134
+ const db = getDb();
135
+ const row = db
136
+ .query<SettingRow, [string]>("SELECT value FROM settings WHERE key=?")
137
+ .get(key);
138
+ return row?.value ?? null;
139
+ }
140
+
141
+ export function setSetting(key: string, value: string): Promise<void> {
142
+ const db = getDb();
143
+ return writeQueue.enqueue(() => {
144
+ db.run(
145
+ `INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
146
+ ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at`,
147
+ [key, value],
148
+ );
149
+ }, db);
150
+ }
151
+
152
+ export function getAllSettings(): Record<string, string> {
153
+ const db = getDb();
154
+ const rows = db
155
+ .query<SettingRow, []>("SELECT key, value FROM settings")
156
+ .all();
157
+ return Object.fromEntries(rows.map((r) => [r.key, r.value]));
158
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * vec/index.ts — sqlite-vec (vec0) ANN index over memory embeddings.
3
+ *
4
+ * The vec0 virtual table `vec_memories` is a DERIVED index that mirrors the
5
+ * active-model rows of `memory_embeddings` (the source of truth). It is created
6
+ * lazily at runtime — never in schema.sql or a hard migration — because
7
+ * `CREATE VIRTUAL TABLE … USING vec0` throws "no such module: vec0" on any
8
+ * process where the extension failed to load, which would break DB init.
9
+ *
10
+ * Every function is gated on getCapabilities().vec and degrades to a no-op
11
+ * (false / [] / -1) when vec is unavailable; callers fall back to the brute-
12
+ * force JS-cosine path in embeddings.ts. Nothing here throws.
13
+ *
14
+ * Distance metric is cosine, so similarity = 1 - distance (identical → 1).
15
+ */
16
+ import type { Database } from "bun:sqlite";
17
+ import { getCapabilities } from "../extensions.js";
18
+
19
+ export const VEC_TABLE = "vec_memories";
20
+ export const DEFAULT_EMBED_DIM = 768;
21
+
22
+ type Blob = Uint8Array | Buffer;
23
+
24
+ /** Active embedding dimension from settings (ltm.embed.dim), default 768. */
25
+ export function getActiveEmbedDim(db: Database): number {
26
+ try {
27
+ const row = db
28
+ .query<{ value: string }, [string]>("SELECT value FROM settings WHERE key=?")
29
+ .get("ltm.embed.dim");
30
+ const n = row ? parseInt(row.value, 10) : NaN;
31
+ return Number.isFinite(n) && n > 0 ? n : DEFAULT_EMBED_DIM;
32
+ } catch {
33
+ return DEFAULT_EMBED_DIM;
34
+ }
35
+ }
36
+
37
+ export function isVecAvailable(): boolean {
38
+ return getCapabilities().vec;
39
+ }
40
+
41
+ /**
42
+ * Create the vec0 index table for the given dimension if vec is available.
43
+ * Idempotent (CREATE … IF NOT EXISTS). No-op + false when unavailable.
44
+ */
45
+ export function ensureVecTable(db: Database, dim?: number): boolean {
46
+ if (!getCapabilities().vec) return false;
47
+ const d = dim ?? getActiveEmbedDim(db);
48
+ try {
49
+ db.exec(
50
+ `CREATE VIRTUAL TABLE IF NOT EXISTS ${VEC_TABLE} USING vec0(embedding float[${d}] distance_metric=cosine)`,
51
+ );
52
+ return true;
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ /** Upsert one vector into the index by memory id (rowid). No-op when unavailable. */
59
+ export function upsertVec(db: Database, memoryId: number, blob: Blob): boolean {
60
+ if (!getCapabilities().vec) return false;
61
+ try {
62
+ db.transaction(() => {
63
+ db.run(`DELETE FROM ${VEC_TABLE} WHERE rowid=?`, [memoryId]);
64
+ db.run(`INSERT INTO ${VEC_TABLE}(rowid, embedding) VALUES (?, ?)`, [memoryId, blob]);
65
+ })();
66
+ return true;
67
+ } catch {
68
+ return false;
69
+ }
70
+ }
71
+
72
+ /** Remove one vector from the index by memory id. No-op when unavailable. */
73
+ export function deleteVec(db: Database, memoryId: number): boolean {
74
+ if (!getCapabilities().vec) return false;
75
+ try {
76
+ db.run(`DELETE FROM ${VEC_TABLE} WHERE rowid=?`, [memoryId]);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ export interface VecHit {
84
+ id: number;
85
+ distance: number;
86
+ similarity: number;
87
+ }
88
+
89
+ /**
90
+ * KNN search over the index. Returns [] when vec is unavailable.
91
+ * similarity = 1 - cosine distance (so higher = closer).
92
+ */
93
+ export function knnVec(db: Database, queryBlob: Blob, k: number): VecHit[] {
94
+ if (!getCapabilities().vec) return [];
95
+ try {
96
+ const rows = db
97
+ .query<{ id: number; distance: number }, [Blob, number]>(
98
+ `SELECT rowid AS id, distance FROM ${VEC_TABLE} WHERE embedding MATCH ? ORDER BY distance LIMIT ?`,
99
+ )
100
+ .all(queryBlob, k);
101
+ return rows.map((r) => ({ id: r.id, distance: r.distance, similarity: 1 - r.distance }));
102
+ } catch {
103
+ return [];
104
+ }
105
+ }
106
+
107
+ /**
108
+ * Drop and rebuild the index from memory_embeddings for the active dim (and
109
+ * model when given). Use after a provider/model switch — vectors from a
110
+ * different model are not comparable in the same vec0 space. Returns the number
111
+ * of rows indexed, or -1 when vec is unavailable.
112
+ */
113
+ export function rebuildVecIndex(db: Database, opts?: { model?: string; dim?: number }): number {
114
+ if (!getCapabilities().vec) return -1;
115
+ const dim = opts?.dim ?? getActiveEmbedDim(db);
116
+ try {
117
+ db.exec(`DROP TABLE IF EXISTS ${VEC_TABLE}`);
118
+ if (!ensureVecTable(db, dim)) return -1;
119
+ const where = opts?.model ? "WHERE dim=? AND model=?" : "WHERE dim=?";
120
+ const params: Array<number | string> = opts?.model ? [dim, opts.model] : [dim];
121
+ const rows = db
122
+ .query<{ memory_id: number; embedding: Buffer }, Array<number | string>>(
123
+ `SELECT memory_id, embedding FROM memory_embeddings ${where}`,
124
+ )
125
+ .all(...params);
126
+ let n = 0;
127
+ db.transaction(() => {
128
+ for (const r of rows) {
129
+ db.run(`INSERT INTO ${VEC_TABLE}(rowid, embedding) VALUES (?, ?)`, [r.memory_id, r.embedding]);
130
+ n++;
131
+ }
132
+ })();
133
+ return n;
134
+ } catch {
135
+ return -1;
136
+ }
137
+ }
138
+
139
+ /**
140
+ * One-time backfill for DBs that have embeddings but an empty/absent vec index
141
+ * (e.g. created before vec was wired in). Rebuilds the index only when it holds
142
+ * zero rows yet memory_embeddings has rows at the active dim. Cheap no-op on
143
+ * subsequent boots because vec0 shadow tables persist in the DB file. Returns
144
+ * the number of rows indexed, 0 when nothing to do, or -1 when unavailable.
145
+ */
146
+ export function backfillVecIndexIfEmpty(db: Database): number {
147
+ if (!getCapabilities().vec) return -1;
148
+ try {
149
+ const dim = getActiveEmbedDim(db);
150
+ if (!ensureVecTable(db, dim)) return -1;
151
+ const indexed = db.query<{ n: number }, []>(`SELECT COUNT(*) AS n FROM ${VEC_TABLE}`).get();
152
+ if ((indexed?.n ?? 0) > 0) return 0;
153
+ const stored = db
154
+ .query<{ n: number }, [number]>(`SELECT COUNT(*) AS n FROM memory_embeddings WHERE dim=?`)
155
+ .get(dim);
156
+ if ((stored?.n ?? 0) === 0) return 0;
157
+ return rebuildVecIndex(db, { dim });
158
+ } catch {
159
+ return -1;
160
+ }
161
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "rootDir": ".",
5
+ "outDir": "dist"
6
+ },
7
+ "include": ["src/**/*"],
8
+ "exclude": ["node_modules", "dist"]
9
+ }