clementine-agent 1.0.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 (190) hide show
  1. package/.env.example +44 -0
  2. package/LICENSE +21 -0
  3. package/README.md +795 -0
  4. package/dist/agent/agent-manager.d.ts +69 -0
  5. package/dist/agent/agent-manager.js +441 -0
  6. package/dist/agent/assistant.d.ts +225 -0
  7. package/dist/agent/assistant.js +3888 -0
  8. package/dist/agent/auto-update.d.ts +32 -0
  9. package/dist/agent/auto-update.js +186 -0
  10. package/dist/agent/daily-planner.d.ts +24 -0
  11. package/dist/agent/daily-planner.js +379 -0
  12. package/dist/agent/execution-advisor.d.ts +10 -0
  13. package/dist/agent/execution-advisor.js +272 -0
  14. package/dist/agent/hooks.d.ts +45 -0
  15. package/dist/agent/hooks.js +564 -0
  16. package/dist/agent/insight-engine.d.ts +66 -0
  17. package/dist/agent/insight-engine.js +225 -0
  18. package/dist/agent/intent-classifier.d.ts +48 -0
  19. package/dist/agent/intent-classifier.js +214 -0
  20. package/dist/agent/link-extractor.d.ts +19 -0
  21. package/dist/agent/link-extractor.js +90 -0
  22. package/dist/agent/mcp-bridge.d.ts +62 -0
  23. package/dist/agent/mcp-bridge.js +435 -0
  24. package/dist/agent/metacognition.d.ts +66 -0
  25. package/dist/agent/metacognition.js +221 -0
  26. package/dist/agent/orchestrator.d.ts +81 -0
  27. package/dist/agent/orchestrator.js +790 -0
  28. package/dist/agent/profiles.d.ts +22 -0
  29. package/dist/agent/profiles.js +91 -0
  30. package/dist/agent/prompt-cache.d.ts +24 -0
  31. package/dist/agent/prompt-cache.js +68 -0
  32. package/dist/agent/prompt-evolver.d.ts +28 -0
  33. package/dist/agent/prompt-evolver.js +279 -0
  34. package/dist/agent/role-scaffolds.d.ts +28 -0
  35. package/dist/agent/role-scaffolds.js +433 -0
  36. package/dist/agent/safe-restart.d.ts +41 -0
  37. package/dist/agent/safe-restart.js +150 -0
  38. package/dist/agent/self-improve.d.ts +66 -0
  39. package/dist/agent/self-improve.js +1706 -0
  40. package/dist/agent/session-event-log.d.ts +114 -0
  41. package/dist/agent/session-event-log.js +233 -0
  42. package/dist/agent/skill-extractor.d.ts +72 -0
  43. package/dist/agent/skill-extractor.js +435 -0
  44. package/dist/agent/source-mods.d.ts +61 -0
  45. package/dist/agent/source-mods.js +230 -0
  46. package/dist/agent/source-preflight.d.ts +25 -0
  47. package/dist/agent/source-preflight.js +100 -0
  48. package/dist/agent/stall-guard.d.ts +62 -0
  49. package/dist/agent/stall-guard.js +109 -0
  50. package/dist/agent/strategic-planner.d.ts +60 -0
  51. package/dist/agent/strategic-planner.js +352 -0
  52. package/dist/agent/team-bus.d.ts +89 -0
  53. package/dist/agent/team-bus.js +556 -0
  54. package/dist/agent/team-router.d.ts +26 -0
  55. package/dist/agent/team-router.js +37 -0
  56. package/dist/agent/tool-loop-detector.d.ts +59 -0
  57. package/dist/agent/tool-loop-detector.js +242 -0
  58. package/dist/agent/workflow-runner.d.ts +36 -0
  59. package/dist/agent/workflow-runner.js +317 -0
  60. package/dist/agent/workflow-variables.d.ts +16 -0
  61. package/dist/agent/workflow-variables.js +62 -0
  62. package/dist/channels/discord-agent-bot.d.ts +101 -0
  63. package/dist/channels/discord-agent-bot.js +881 -0
  64. package/dist/channels/discord-bot-manager.d.ts +80 -0
  65. package/dist/channels/discord-bot-manager.js +262 -0
  66. package/dist/channels/discord-utils.d.ts +51 -0
  67. package/dist/channels/discord-utils.js +293 -0
  68. package/dist/channels/discord.d.ts +12 -0
  69. package/dist/channels/discord.js +1832 -0
  70. package/dist/channels/slack-agent-bot.d.ts +73 -0
  71. package/dist/channels/slack-agent-bot.js +320 -0
  72. package/dist/channels/slack-bot-manager.d.ts +66 -0
  73. package/dist/channels/slack-bot-manager.js +236 -0
  74. package/dist/channels/slack-utils.d.ts +39 -0
  75. package/dist/channels/slack-utils.js +189 -0
  76. package/dist/channels/slack.d.ts +11 -0
  77. package/dist/channels/slack.js +196 -0
  78. package/dist/channels/telegram.d.ts +10 -0
  79. package/dist/channels/telegram.js +235 -0
  80. package/dist/channels/webhook.d.ts +9 -0
  81. package/dist/channels/webhook.js +78 -0
  82. package/dist/channels/whatsapp.d.ts +11 -0
  83. package/dist/channels/whatsapp.js +181 -0
  84. package/dist/cli/chat.d.ts +14 -0
  85. package/dist/cli/chat.js +220 -0
  86. package/dist/cli/cron.d.ts +17 -0
  87. package/dist/cli/cron.js +552 -0
  88. package/dist/cli/dashboard.d.ts +15 -0
  89. package/dist/cli/dashboard.js +17677 -0
  90. package/dist/cli/index.d.ts +3 -0
  91. package/dist/cli/index.js +2474 -0
  92. package/dist/cli/routes/delegations.d.ts +19 -0
  93. package/dist/cli/routes/delegations.js +154 -0
  94. package/dist/cli/routes/digest.d.ts +17 -0
  95. package/dist/cli/routes/digest.js +375 -0
  96. package/dist/cli/routes/goals.d.ts +14 -0
  97. package/dist/cli/routes/goals.js +258 -0
  98. package/dist/cli/routes/workflows.d.ts +18 -0
  99. package/dist/cli/routes/workflows.js +97 -0
  100. package/dist/cli/setup.d.ts +8 -0
  101. package/dist/cli/setup.js +619 -0
  102. package/dist/cli/tunnel.d.ts +35 -0
  103. package/dist/cli/tunnel.js +141 -0
  104. package/dist/config.d.ts +145 -0
  105. package/dist/config.js +278 -0
  106. package/dist/events/bus.d.ts +43 -0
  107. package/dist/events/bus.js +136 -0
  108. package/dist/gateway/cron-scheduler.d.ts +166 -0
  109. package/dist/gateway/cron-scheduler.js +1767 -0
  110. package/dist/gateway/delivery-queue.d.ts +30 -0
  111. package/dist/gateway/delivery-queue.js +110 -0
  112. package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
  113. package/dist/gateway/heartbeat-scheduler.js +1298 -0
  114. package/dist/gateway/heartbeat.d.ts +3 -0
  115. package/dist/gateway/heartbeat.js +3 -0
  116. package/dist/gateway/lanes.d.ts +24 -0
  117. package/dist/gateway/lanes.js +76 -0
  118. package/dist/gateway/notifications.d.ts +29 -0
  119. package/dist/gateway/notifications.js +75 -0
  120. package/dist/gateway/router.d.ts +210 -0
  121. package/dist/gateway/router.js +1330 -0
  122. package/dist/index.d.ts +12 -0
  123. package/dist/index.js +1015 -0
  124. package/dist/memory/chunker.d.ts +28 -0
  125. package/dist/memory/chunker.js +226 -0
  126. package/dist/memory/consolidation.d.ts +44 -0
  127. package/dist/memory/consolidation.js +171 -0
  128. package/dist/memory/context-assembler.d.ts +50 -0
  129. package/dist/memory/context-assembler.js +149 -0
  130. package/dist/memory/embeddings.d.ts +38 -0
  131. package/dist/memory/embeddings.js +180 -0
  132. package/dist/memory/graph-store.d.ts +66 -0
  133. package/dist/memory/graph-store.js +613 -0
  134. package/dist/memory/mmr.d.ts +21 -0
  135. package/dist/memory/mmr.js +75 -0
  136. package/dist/memory/search.d.ts +26 -0
  137. package/dist/memory/search.js +67 -0
  138. package/dist/memory/store.d.ts +530 -0
  139. package/dist/memory/store.js +2022 -0
  140. package/dist/security/integrity.d.ts +24 -0
  141. package/dist/security/integrity.js +58 -0
  142. package/dist/security/patterns.d.ts +34 -0
  143. package/dist/security/patterns.js +110 -0
  144. package/dist/security/scanner.d.ts +32 -0
  145. package/dist/security/scanner.js +263 -0
  146. package/dist/tools/admin-tools.d.ts +12 -0
  147. package/dist/tools/admin-tools.js +1278 -0
  148. package/dist/tools/external-tools.d.ts +11 -0
  149. package/dist/tools/external-tools.js +1327 -0
  150. package/dist/tools/goal-tools.d.ts +9 -0
  151. package/dist/tools/goal-tools.js +159 -0
  152. package/dist/tools/mcp-server.d.ts +13 -0
  153. package/dist/tools/mcp-server.js +141 -0
  154. package/dist/tools/memory-tools.d.ts +10 -0
  155. package/dist/tools/memory-tools.js +568 -0
  156. package/dist/tools/session-tools.d.ts +6 -0
  157. package/dist/tools/session-tools.js +146 -0
  158. package/dist/tools/shared.d.ts +216 -0
  159. package/dist/tools/shared.js +340 -0
  160. package/dist/tools/team-tools.d.ts +6 -0
  161. package/dist/tools/team-tools.js +447 -0
  162. package/dist/tools/tool-meta.d.ts +34 -0
  163. package/dist/tools/tool-meta.js +133 -0
  164. package/dist/tools/vault-tools.d.ts +8 -0
  165. package/dist/tools/vault-tools.js +457 -0
  166. package/dist/types.d.ts +716 -0
  167. package/dist/types.js +16 -0
  168. package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
  169. package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
  170. package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
  171. package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
  172. package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
  173. package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
  174. package/dist/vault-migrations/helpers.d.ts +14 -0
  175. package/dist/vault-migrations/helpers.js +44 -0
  176. package/dist/vault-migrations/runner.d.ts +14 -0
  177. package/dist/vault-migrations/runner.js +139 -0
  178. package/dist/vault-migrations/types.d.ts +42 -0
  179. package/dist/vault-migrations/types.js +9 -0
  180. package/install.sh +320 -0
  181. package/package.json +84 -0
  182. package/scripts/postinstall.js +125 -0
  183. package/vault/00-System/AGENTS.md +66 -0
  184. package/vault/00-System/CRON.md +71 -0
  185. package/vault/00-System/HEARTBEAT.md +58 -0
  186. package/vault/00-System/MEMORY.md +16 -0
  187. package/vault/00-System/SOUL.md +96 -0
  188. package/vault/05-Tasks/TASKS.md +19 -0
  189. package/vault/06-Templates/_Daily-Template.md +28 -0
  190. package/vault/06-Templates/_People-Template.md +22 -0
@@ -0,0 +1,2022 @@
1
+ /**
2
+ * Clementine TypeScript — SQLite FTS5 memory store.
3
+ *
4
+ * Mirrors the Obsidian vault as a search-optimized index. The vault remains
5
+ * the source of truth; this is a read-optimized cache.
6
+ *
7
+ * FTS5 = full-text search built into SQLite. Zero cost. Zero latency.
8
+ *
9
+ * Concurrency: WAL mode allows concurrent readers. Writes are serialized
10
+ * (single-user, one MCP subprocess handles all writes).
11
+ */
12
+ import { createHash } from 'node:crypto';
13
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync } from 'node:fs';
14
+ import path from 'node:path';
15
+ import Database from 'better-sqlite3';
16
+ import * as embeddingsModule from './embeddings.js';
17
+ import { chunkFile } from './chunker.js';
18
+ import { mmrRerank } from './mmr.js';
19
+ import { deduplicateResults } from './search.js';
20
+ const WIKILINK_RE = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g;
21
+ export class MemoryStore {
22
+ dbPath;
23
+ vaultDir;
24
+ db = null;
25
+ // Cached prepared statements for hot-path queries
26
+ _stmtChunkCount = null;
27
+ _stmtInsertTranscript = null;
28
+ _stmtInsertUsage = null;
29
+ constructor(dbPath, vaultDir) {
30
+ this.dbPath = dbPath;
31
+ this.vaultDir = vaultDir;
32
+ }
33
+ // ── Lifecycle ──────────────────────────────────────────────────────
34
+ /**
35
+ * Create the database and schema if needed.
36
+ */
37
+ initialize() {
38
+ const dir = path.dirname(this.dbPath);
39
+ if (!existsSync(dir)) {
40
+ mkdirSync(dir, { recursive: true });
41
+ }
42
+ this.db = new Database(this.dbPath);
43
+ this.db.pragma('journal_mode = WAL');
44
+ this.db.pragma('synchronous = NORMAL');
45
+ this.db.exec(`
46
+ CREATE TABLE IF NOT EXISTS chunks (
47
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
48
+ source_file TEXT NOT NULL,
49
+ section TEXT NOT NULL,
50
+ content TEXT NOT NULL,
51
+ chunk_type TEXT NOT NULL,
52
+ frontmatter_json TEXT DEFAULT '',
53
+ embedding BLOB,
54
+ content_hash TEXT NOT NULL,
55
+ created_at TEXT DEFAULT (datetime('now')),
56
+ updated_at TEXT DEFAULT (datetime('now'))
57
+ );
58
+
59
+ CREATE TABLE IF NOT EXISTS file_hashes (
60
+ rel_path TEXT PRIMARY KEY,
61
+ content_hash TEXT NOT NULL,
62
+ last_synced TEXT DEFAULT (datetime('now'))
63
+ );
64
+
65
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunks_fts USING fts5(
66
+ source_file, section, content,
67
+ content='chunks', content_rowid='id',
68
+ tokenize='porter unicode61'
69
+ );
70
+
71
+ CREATE TRIGGER IF NOT EXISTS chunks_ai AFTER INSERT ON chunks BEGIN
72
+ INSERT INTO chunks_fts(rowid, source_file, section, content)
73
+ VALUES (new.id, new.source_file, new.section, new.content);
74
+ END;
75
+
76
+ CREATE TRIGGER IF NOT EXISTS chunks_ad AFTER DELETE ON chunks BEGIN
77
+ INSERT INTO chunks_fts(chunks_fts, rowid, source_file, section, content)
78
+ VALUES ('delete', old.id, old.source_file, old.section, old.content);
79
+ END;
80
+
81
+ CREATE TRIGGER IF NOT EXISTS chunks_au AFTER UPDATE ON chunks BEGIN
82
+ INSERT INTO chunks_fts(chunks_fts, rowid, source_file, section, content)
83
+ VALUES ('delete', old.id, old.source_file, old.section, old.content);
84
+ INSERT INTO chunks_fts(rowid, source_file, section, content)
85
+ VALUES (new.id, new.source_file, new.section, new.content);
86
+ END;
87
+
88
+ CREATE INDEX IF NOT EXISTS idx_chunks_source ON chunks(source_file);
89
+
90
+ CREATE TABLE IF NOT EXISTS wikilinks (
91
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
92
+ source_file TEXT NOT NULL,
93
+ target_file TEXT NOT NULL,
94
+ context TEXT DEFAULT '',
95
+ link_type TEXT DEFAULT 'wikilink'
96
+ );
97
+
98
+ CREATE INDEX IF NOT EXISTS idx_wikilinks_source ON wikilinks(source_file);
99
+ CREATE INDEX IF NOT EXISTS idx_wikilinks_target ON wikilinks(target_file);
100
+
101
+ CREATE TABLE IF NOT EXISTS transcripts (
102
+ id INTEGER PRIMARY KEY,
103
+ session_key TEXT NOT NULL,
104
+ role TEXT NOT NULL,
105
+ content TEXT NOT NULL,
106
+ model TEXT DEFAULT '',
107
+ created_at TEXT DEFAULT (datetime('now'))
108
+ );
109
+
110
+ CREATE INDEX IF NOT EXISTS idx_transcripts_session ON transcripts(session_key);
111
+ CREATE INDEX IF NOT EXISTS idx_transcripts_created ON transcripts(created_at);
112
+
113
+ CREATE TABLE IF NOT EXISTS session_summaries (
114
+ id INTEGER PRIMARY KEY,
115
+ session_key TEXT NOT NULL,
116
+ summary TEXT NOT NULL,
117
+ exchange_count INTEGER DEFAULT 0,
118
+ created_at TEXT DEFAULT (datetime('now'))
119
+ );
120
+
121
+ CREATE INDEX IF NOT EXISTS idx_session_summaries_key ON session_summaries(session_key);
122
+ CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at);
123
+ `);
124
+ // ── Migrations ────────────────────────────────────────────────
125
+ // Add salience column to chunks
126
+ try {
127
+ this.conn.exec('ALTER TABLE chunks ADD COLUMN salience REAL DEFAULT 0.0');
128
+ }
129
+ catch {
130
+ // Column already exists
131
+ }
132
+ // Add sector column to chunks (for episodic memory)
133
+ try {
134
+ this.conn.exec("ALTER TABLE chunks ADD COLUMN sector TEXT DEFAULT 'semantic'");
135
+ }
136
+ catch {
137
+ // Column already exists
138
+ }
139
+ // Add agent_slug column to chunks (for agent-scoped memory)
140
+ try {
141
+ this.conn.exec('ALTER TABLE chunks ADD COLUMN agent_slug TEXT DEFAULT NULL');
142
+ }
143
+ catch {
144
+ // Column already exists
145
+ }
146
+ // Index for agent-scoped queries
147
+ try {
148
+ this.conn.exec('CREATE INDEX idx_chunks_agent ON chunks(agent_slug)');
149
+ }
150
+ catch {
151
+ // Index already exists
152
+ }
153
+ // Add consolidated flag to chunks (for memory consolidation)
154
+ try {
155
+ this.conn.exec('ALTER TABLE chunks ADD COLUMN consolidated INTEGER DEFAULT 0');
156
+ }
157
+ catch {
158
+ // Column already exists
159
+ }
160
+ // Add category column to chunks (hierarchical tag: facts/events/discoveries/preferences/advice)
161
+ try {
162
+ this.conn.exec('ALTER TABLE chunks ADD COLUMN category TEXT DEFAULT NULL');
163
+ }
164
+ catch {
165
+ // Column already exists
166
+ }
167
+ // Add topic column to chunks (hierarchical tag: free-form topic string)
168
+ try {
169
+ this.conn.exec('ALTER TABLE chunks ADD COLUMN topic TEXT DEFAULT NULL');
170
+ }
171
+ catch {
172
+ // Column already exists
173
+ }
174
+ // Indexes for category/topic filtering
175
+ try {
176
+ this.conn.exec('CREATE INDEX idx_chunks_category ON chunks(category)');
177
+ }
178
+ catch {
179
+ // Index already exists
180
+ }
181
+ try {
182
+ this.conn.exec('CREATE INDEX idx_chunks_topic ON chunks(topic)');
183
+ }
184
+ catch {
185
+ // Index already exists
186
+ }
187
+ // Access log table for salience tracking
188
+ this.conn.exec(`
189
+ CREATE TABLE IF NOT EXISTS access_log (
190
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
191
+ chunk_id INTEGER NOT NULL,
192
+ access_type TEXT NOT NULL,
193
+ accessed_at TEXT DEFAULT (datetime('now'))
194
+ );
195
+ CREATE INDEX IF NOT EXISTS idx_access_log_chunk ON access_log(chunk_id);
196
+ `);
197
+ // Memory extractions table for transparency
198
+ this.conn.exec(`
199
+ CREATE TABLE IF NOT EXISTS memory_extractions (
200
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
201
+ session_key TEXT NOT NULL,
202
+ user_message TEXT NOT NULL,
203
+ tool_name TEXT NOT NULL,
204
+ tool_input TEXT NOT NULL,
205
+ extracted_at TEXT NOT NULL DEFAULT (datetime('now')),
206
+ status TEXT NOT NULL DEFAULT 'active',
207
+ correction TEXT
208
+ );
209
+ CREATE INDEX IF NOT EXISTS idx_extractions_session ON memory_extractions(session_key);
210
+ CREATE INDEX IF NOT EXISTS idx_extractions_status ON memory_extractions(status);
211
+ `);
212
+ // Add agent_slug column to memory_extractions
213
+ try {
214
+ this.conn.exec('ALTER TABLE memory_extractions ADD COLUMN agent_slug TEXT DEFAULT NULL');
215
+ }
216
+ catch {
217
+ // Column already exists
218
+ }
219
+ // Add metacognitive_summary column to session_reflections
220
+ try {
221
+ this.conn.exec('ALTER TABLE session_reflections ADD COLUMN metacognitive_summary TEXT DEFAULT NULL');
222
+ }
223
+ catch {
224
+ // Column already exists
225
+ }
226
+ // Feedback table for response quality tracking
227
+ this.conn.exec(`
228
+ CREATE TABLE IF NOT EXISTS feedback (
229
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
230
+ session_key TEXT,
231
+ channel TEXT NOT NULL,
232
+ message_snippet TEXT,
233
+ response_snippet TEXT,
234
+ rating TEXT NOT NULL CHECK(rating IN ('positive', 'negative', 'mixed')),
235
+ comment TEXT,
236
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
237
+ );
238
+ CREATE INDEX IF NOT EXISTS idx_feedback_rating ON feedback(rating);
239
+ CREATE INDEX IF NOT EXISTS idx_feedback_created ON feedback(created_at);
240
+ `);
241
+ // Session reflections for conversational learning
242
+ this.conn.exec(`
243
+ CREATE TABLE IF NOT EXISTS session_reflections (
244
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
245
+ session_key TEXT NOT NULL,
246
+ exchange_count INTEGER DEFAULT 0,
247
+ friction_signals TEXT DEFAULT '[]',
248
+ quality_score INTEGER DEFAULT 3,
249
+ behavioral_corrections TEXT DEFAULT '[]',
250
+ preferences_learned TEXT DEFAULT '[]',
251
+ metacognitive_summary TEXT DEFAULT NULL,
252
+ agent_slug TEXT DEFAULT NULL,
253
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
254
+ );
255
+ CREATE INDEX IF NOT EXISTS idx_reflections_created ON session_reflections(created_at);
256
+ CREATE INDEX IF NOT EXISTS idx_reflections_agent ON session_reflections(agent_slug);
257
+ `);
258
+ // Usage log table for token tracking
259
+ this.conn.exec(`
260
+ CREATE TABLE IF NOT EXISTS usage_log (
261
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
262
+ session_key TEXT NOT NULL,
263
+ source TEXT NOT NULL DEFAULT 'chat',
264
+ model TEXT NOT NULL,
265
+ input_tokens INTEGER NOT NULL DEFAULT 0,
266
+ output_tokens INTEGER NOT NULL DEFAULT 0,
267
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
268
+ cache_creation_tokens INTEGER NOT NULL DEFAULT 0,
269
+ num_turns INTEGER NOT NULL DEFAULT 0,
270
+ duration_ms INTEGER NOT NULL DEFAULT 0,
271
+ created_at TEXT DEFAULT (datetime('now'))
272
+ );
273
+ CREATE INDEX IF NOT EXISTS idx_usage_session ON usage_log(session_key);
274
+ CREATE INDEX IF NOT EXISTS idx_usage_source ON usage_log(source);
275
+ CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at);
276
+ `);
277
+ // Migration: add agent_slug column for per-agent observability
278
+ try {
279
+ this.conn.exec(`ALTER TABLE usage_log ADD COLUMN agent_slug TEXT DEFAULT NULL`);
280
+ this.conn.exec(`CREATE INDEX IF NOT EXISTS idx_usage_agent ON usage_log(agent_slug)`);
281
+ }
282
+ catch { /* column already exists */ }
283
+ // ── SDR Operational Tables ───────────────────────────────────────
284
+ // Leads — structured prospect records for SDR workflows
285
+ this.conn.exec(`
286
+ CREATE TABLE IF NOT EXISTS leads (
287
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
288
+ agent_slug TEXT,
289
+ email TEXT NOT NULL UNIQUE,
290
+ name TEXT NOT NULL,
291
+ company TEXT,
292
+ title TEXT,
293
+ status TEXT NOT NULL DEFAULT 'new',
294
+ source TEXT,
295
+ sf_id TEXT,
296
+ metadata JSON DEFAULT '{}',
297
+ created_at TEXT DEFAULT (datetime('now')),
298
+ updated_at TEXT DEFAULT (datetime('now'))
299
+ );
300
+ CREATE INDEX IF NOT EXISTS idx_leads_agent ON leads(agent_slug);
301
+ CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
302
+ CREATE INDEX IF NOT EXISTS idx_leads_company ON leads(company);
303
+ CREATE INDEX IF NOT EXISTS idx_leads_email ON leads(email);
304
+ `);
305
+ // Sequence enrollments — tracks each lead's position in an outbound cadence
306
+ this.conn.exec(`
307
+ CREATE TABLE IF NOT EXISTS sequence_enrollments (
308
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
309
+ lead_id INTEGER NOT NULL REFERENCES leads(id),
310
+ sequence_name TEXT NOT NULL,
311
+ current_step INTEGER NOT NULL DEFAULT 0,
312
+ status TEXT NOT NULL DEFAULT 'active',
313
+ next_step_due_at TEXT,
314
+ started_at TEXT DEFAULT (datetime('now')),
315
+ updated_at TEXT DEFAULT (datetime('now'))
316
+ );
317
+ CREATE INDEX IF NOT EXISTS idx_seq_lead ON sequence_enrollments(lead_id);
318
+ CREATE INDEX IF NOT EXISTS idx_seq_status ON sequence_enrollments(status);
319
+ CREATE INDEX IF NOT EXISTS idx_seq_due ON sequence_enrollments(next_step_due_at);
320
+ `);
321
+ // Activities — log of all SDR actions (emails sent, meetings booked, etc.)
322
+ this.conn.exec(`
323
+ CREATE TABLE IF NOT EXISTS activities (
324
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
325
+ lead_id INTEGER REFERENCES leads(id),
326
+ agent_slug TEXT,
327
+ type TEXT NOT NULL,
328
+ subject TEXT,
329
+ detail TEXT,
330
+ template_used TEXT,
331
+ performed_at TEXT DEFAULT (datetime('now'))
332
+ );
333
+ CREATE INDEX IF NOT EXISTS idx_activities_lead ON activities(lead_id);
334
+ CREATE INDEX IF NOT EXISTS idx_activities_agent ON activities(agent_slug);
335
+ CREATE INDEX IF NOT EXISTS idx_activities_type ON activities(type);
336
+ CREATE INDEX IF NOT EXISTS idx_activities_performed ON activities(performed_at);
337
+ `);
338
+ // Suppression list — emails that must never be contacted (opt-out, bounce, complaint)
339
+ this.conn.exec(`
340
+ CREATE TABLE IF NOT EXISTS suppression_list (
341
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
342
+ email TEXT NOT NULL UNIQUE,
343
+ reason TEXT NOT NULL,
344
+ added_at TEXT DEFAULT (datetime('now')),
345
+ added_by TEXT
346
+ );
347
+ CREATE INDEX IF NOT EXISTS idx_suppression_email ON suppression_list(email);
348
+ `);
349
+ // Send log — tracks every outbound email for daily cap enforcement and audit
350
+ this.conn.exec(`
351
+ CREATE TABLE IF NOT EXISTS send_log (
352
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
353
+ agent_slug TEXT,
354
+ recipient TEXT NOT NULL,
355
+ subject TEXT,
356
+ template_used TEXT,
357
+ sent_at TEXT DEFAULT (datetime('now')),
358
+ policy_ref TEXT
359
+ );
360
+ CREATE INDEX IF NOT EXISTS idx_sendlog_agent ON send_log(agent_slug);
361
+ CREATE INDEX IF NOT EXISTS idx_sendlog_sent ON send_log(sent_at);
362
+ `);
363
+ // Approval queue — pending actions awaiting human review
364
+ this.conn.exec(`
365
+ CREATE TABLE IF NOT EXISTS approval_queue (
366
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
367
+ agent_slug TEXT,
368
+ action_type TEXT NOT NULL,
369
+ summary TEXT NOT NULL,
370
+ detail JSON DEFAULT '{}',
371
+ status TEXT NOT NULL DEFAULT 'pending',
372
+ requested_at TEXT DEFAULT (datetime('now')),
373
+ resolved_at TEXT,
374
+ resolved_by TEXT
375
+ );
376
+ CREATE INDEX IF NOT EXISTS idx_approval_agent ON approval_queue(agent_slug);
377
+ CREATE INDEX IF NOT EXISTS idx_approval_status ON approval_queue(status);
378
+ `);
379
+ // Config revisions — versioned snapshots of agent config files
380
+ this.conn.exec(`
381
+ CREATE TABLE IF NOT EXISTS config_revisions (
382
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
383
+ agent_slug TEXT NOT NULL,
384
+ file_name TEXT NOT NULL,
385
+ content TEXT NOT NULL,
386
+ changed_by TEXT,
387
+ created_at TEXT DEFAULT (datetime('now'))
388
+ );
389
+ CREATE INDEX IF NOT EXISTS idx_configrev_agent ON config_revisions(agent_slug);
390
+ CREATE INDEX IF NOT EXISTS idx_configrev_file ON config_revisions(agent_slug, file_name);
391
+ `);
392
+ // Salesforce sync log — audit trail for CRM sync operations
393
+ this.conn.exec(`
394
+ CREATE TABLE IF NOT EXISTS sf_sync_log (
395
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
396
+ local_table TEXT NOT NULL,
397
+ local_id INTEGER NOT NULL,
398
+ sf_object_type TEXT NOT NULL,
399
+ sf_id TEXT NOT NULL,
400
+ sync_direction TEXT NOT NULL,
401
+ synced_at TEXT DEFAULT (datetime('now')),
402
+ sync_status TEXT NOT NULL DEFAULT 'success',
403
+ error_message TEXT
404
+ );
405
+ CREATE INDEX IF NOT EXISTS idx_sf_sync_local ON sf_sync_log(local_table, local_id);
406
+ CREATE INDEX IF NOT EXISTS idx_sf_sync_sfid ON sf_sync_log(sf_id);
407
+ CREATE INDEX IF NOT EXISTS idx_sf_sync_status ON sf_sync_log(sync_status);
408
+ `);
409
+ }
410
+ /**
411
+ * Close the database connection.
412
+ */
413
+ close() {
414
+ if (this.db) {
415
+ this.db.close();
416
+ this.db = null;
417
+ }
418
+ }
419
+ /** Lazily-initializing accessor for the database connection. */
420
+ get conn() {
421
+ if (!this.db) {
422
+ this.initialize();
423
+ }
424
+ return this.db;
425
+ }
426
+ /** Return the total number of indexed chunks. */
427
+ getChunkCount() {
428
+ try {
429
+ if (!this._stmtChunkCount) {
430
+ this._stmtChunkCount = this.conn.prepare('SELECT COUNT(*) as cnt FROM chunks');
431
+ }
432
+ const row = this._stmtChunkCount.get();
433
+ return row?.cnt ?? 0;
434
+ }
435
+ catch {
436
+ return 0;
437
+ }
438
+ }
439
+ // ── Full Sync ──────────────────────────────────────────────────────
440
+ /**
441
+ * Scan the entire vault, hash-compare, and re-index changed files.
442
+ */
443
+ fullSync() {
444
+ const stats = {
445
+ filesScanned: 0,
446
+ filesUpdated: 0,
447
+ filesDeleted: 0,
448
+ chunksTotal: 0,
449
+ };
450
+ // Get current file hashes from DB
451
+ const existing = new Map();
452
+ const hashRows = this.conn
453
+ .prepare('SELECT rel_path, content_hash FROM file_hashes')
454
+ .all();
455
+ for (const row of hashRows) {
456
+ existing.set(row.rel_path, row.content_hash);
457
+ }
458
+ // Scan vault
459
+ const seenFiles = new Set();
460
+ const filesToUpdate = [];
461
+ this.walkMdFiles(this.vaultDir, (filePath) => {
462
+ const rel = path.relative(this.vaultDir, filePath);
463
+ // Skip .obsidian and templates
464
+ if (rel.includes('.obsidian') || rel.startsWith('06-Templates')) {
465
+ return;
466
+ }
467
+ seenFiles.add(rel);
468
+ stats.filesScanned++;
469
+ // Hash the file content
470
+ let fileHash;
471
+ try {
472
+ const bytes = readFileSync(filePath);
473
+ fileHash = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
474
+ }
475
+ catch {
476
+ return;
477
+ }
478
+ // Skip unchanged files
479
+ if (existing.has(rel) && existing.get(rel) === fileHash) {
480
+ return;
481
+ }
482
+ filesToUpdate.push(filePath);
483
+ });
484
+ // Delete removed files
485
+ for (const relPath of existing.keys()) {
486
+ if (!seenFiles.has(relPath)) {
487
+ this.deleteFileChunks(relPath);
488
+ stats.filesDeleted++;
489
+ }
490
+ }
491
+ // Process changed/new files
492
+ for (const filePath of filesToUpdate) {
493
+ const rel = path.relative(this.vaultDir, filePath);
494
+ const chunks = chunkFile(filePath, this.vaultDir);
495
+ if (chunks.length === 0)
496
+ continue;
497
+ // Delete old chunks for this file
498
+ this.deleteFileChunks(rel);
499
+ // Insert new chunks
500
+ const insertStmt = this.conn.prepare(`INSERT INTO chunks
501
+ (source_file, section, content, chunk_type, frontmatter_json, content_hash, category, topic)
502
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
503
+ for (const chunk of chunks) {
504
+ insertStmt.run(chunk.sourceFile, chunk.section, chunk.content, chunk.chunkType, chunk.frontmatterJson, chunk.contentHash, chunk.category ?? null, chunk.topic ?? null);
505
+ }
506
+ // Parse and index wikilinks
507
+ this.indexWikilinks(rel, filePath);
508
+ // Update file hash
509
+ const bytes = readFileSync(filePath);
510
+ const fileHash = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
511
+ this.conn
512
+ .prepare(`INSERT OR REPLACE INTO file_hashes (rel_path, content_hash, last_synced)
513
+ VALUES (?, ?, datetime('now'))`)
514
+ .run(rel, fileHash);
515
+ stats.filesUpdated++;
516
+ }
517
+ // Count total chunks
518
+ const countRow = this.conn
519
+ .prepare('SELECT COUNT(*) as cnt FROM chunks')
520
+ .get();
521
+ stats.chunksTotal = countRow?.cnt ?? 0;
522
+ // Rebuild embedding vocabulary and backfill missing embeddings
523
+ if (filesToUpdate.length > 0) {
524
+ try {
525
+ this.buildEmbeddings();
526
+ }
527
+ catch {
528
+ // Non-fatal — FTS search still works without embeddings
529
+ }
530
+ }
531
+ return stats;
532
+ }
533
+ // ── Incremental Update ─────────────────────────────────────────────
534
+ /**
535
+ * Re-index a single file after a write operation.
536
+ */
537
+ updateFile(relPath, agentSlug) {
538
+ const fullPath = path.join(this.vaultDir, relPath);
539
+ if (!existsSync(fullPath)) {
540
+ this.deleteFileChunks(relPath);
541
+ return;
542
+ }
543
+ // Delete old chunks
544
+ this.deleteFileChunks(relPath);
545
+ // Re-chunk
546
+ const chunks = chunkFile(fullPath, this.vaultDir);
547
+ if (chunks.length === 0)
548
+ return;
549
+ // Insert new chunks
550
+ const insertStmt = this.conn.prepare(`INSERT INTO chunks
551
+ (source_file, section, content, chunk_type, frontmatter_json, content_hash, agent_slug, category, topic)
552
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
553
+ for (const chunk of chunks) {
554
+ insertStmt.run(chunk.sourceFile, chunk.section, chunk.content, chunk.chunkType, chunk.frontmatterJson, chunk.contentHash, agentSlug ?? null, chunk.category ?? null, chunk.topic ?? null);
555
+ }
556
+ // Parse and index wikilinks
557
+ this.indexWikilinks(relPath, fullPath);
558
+ // Update file hash
559
+ const bytes = readFileSync(fullPath);
560
+ const fileHash = createHash('sha256').update(bytes).digest('hex').slice(0, 16);
561
+ this.conn
562
+ .prepare(`INSERT OR REPLACE INTO file_hashes (rel_path, content_hash, last_synced)
563
+ VALUES (?, ?, datetime('now'))`)
564
+ .run(relPath, fileHash);
565
+ }
566
+ // ── Search: FTS5 ──────────────────────────────────────────────────
567
+ /**
568
+ * Full-text search using FTS5 with BM25 ranking.
569
+ */
570
+ searchFts(query, limit = 20, filters, isolateAgentSlug) {
571
+ const sanitized = MemoryStore.sanitizeFtsQuery(query);
572
+ if (!sanitized)
573
+ return [];
574
+ try {
575
+ let sql = `SELECT c.id, c.source_file, c.section, c.content, c.chunk_type,
576
+ c.updated_at, c.salience, c.agent_slug, c.category, c.topic,
577
+ bm25(chunks_fts) as score
578
+ FROM chunks_fts f
579
+ JOIN chunks c ON c.id = f.rowid
580
+ WHERE chunks_fts MATCH ?`;
581
+ const params = [sanitized];
582
+ if (isolateAgentSlug) {
583
+ sql += ' AND (c.agent_slug = ? OR c.agent_slug IS NULL)';
584
+ params.push(isolateAgentSlug);
585
+ }
586
+ if (filters?.category) {
587
+ sql += ' AND c.category = ?';
588
+ params.push(filters.category);
589
+ }
590
+ if (filters?.topic) {
591
+ sql += ' AND c.topic = ?';
592
+ params.push(filters.topic);
593
+ }
594
+ sql += ' ORDER BY bm25(chunks_fts) LIMIT ?';
595
+ params.push(limit);
596
+ const rows = this.conn.prepare(sql).all(...params);
597
+ return rows.map((row) => ({
598
+ sourceFile: row.source_file,
599
+ section: row.section,
600
+ content: row.content,
601
+ score: -row.score, // BM25 returns negative scores (lower = better)
602
+ chunkType: row.chunk_type,
603
+ matchType: 'fts',
604
+ lastUpdated: row.updated_at ?? '',
605
+ chunkId: row.id,
606
+ salience: row.salience ?? 0,
607
+ agentSlug: row.agent_slug ?? null,
608
+ category: row.category,
609
+ topic: row.topic,
610
+ }));
611
+ }
612
+ catch {
613
+ return [];
614
+ }
615
+ }
616
+ // ── Search: Recent Chunks ─────────────────────────────────────────
617
+ /**
618
+ * Get the most recently updated chunks.
619
+ */
620
+ getRecentChunks(limit = 5, agentSlug, filters, strict = false) {
621
+ const mapRow = (row) => ({
622
+ sourceFile: row.source_file,
623
+ section: row.section,
624
+ content: row.content,
625
+ score: 0,
626
+ chunkType: row.chunk_type,
627
+ matchType: 'recency',
628
+ lastUpdated: row.updated_at ?? '',
629
+ chunkId: row.id,
630
+ salience: row.salience ?? 0,
631
+ agentSlug: row.agent_slug ?? null,
632
+ category: row.category,
633
+ topic: row.topic,
634
+ });
635
+ // Build optional WHERE clauses for category/topic
636
+ let filterSql = '';
637
+ const filterParams = [];
638
+ if (filters?.category) {
639
+ filterSql += ' AND category = ?';
640
+ filterParams.push(filters.category);
641
+ }
642
+ if (filters?.topic) {
643
+ filterSql += ' AND topic = ?';
644
+ filterParams.push(filters.topic);
645
+ }
646
+ // If agent specified: hard isolation = only own + global; soft = mix with extra global
647
+ if (agentSlug) {
648
+ if (strict) {
649
+ // Hard isolation: own chunks + global in one query
650
+ const rows = this.conn.prepare(`SELECT id, source_file, section, content, chunk_type,
651
+ updated_at, salience, agent_slug, category, topic
652
+ FROM chunks
653
+ WHERE (agent_slug = ? OR agent_slug IS NULL)${filterSql}
654
+ ORDER BY updated_at DESC LIMIT ?`).all(agentSlug, ...filterParams, limit);
655
+ return rows.map(mapRow);
656
+ }
657
+ // Soft isolation: weighted mix — 60% agent, 40% global
658
+ const agentRows = this.conn.prepare(`SELECT id, source_file, section, content, chunk_type,
659
+ updated_at, salience, agent_slug, category, topic
660
+ FROM chunks WHERE agent_slug = ?${filterSql}
661
+ ORDER BY updated_at DESC LIMIT ?`).all(agentSlug, ...filterParams, Math.ceil(limit * 0.6));
662
+ const globalRows = this.conn.prepare(`SELECT id, source_file, section, content, chunk_type,
663
+ updated_at, salience, agent_slug, category, topic
664
+ FROM chunks WHERE agent_slug IS NULL${filterSql}
665
+ ORDER BY updated_at DESC LIMIT ?`).all(...filterParams, Math.ceil(limit * 0.4));
666
+ return [...agentRows, ...globalRows].slice(0, limit).map(mapRow);
667
+ }
668
+ const rows = this.conn
669
+ .prepare(`SELECT id, source_file, section, content, chunk_type,
670
+ updated_at, salience, agent_slug, category, topic
671
+ FROM chunks
672
+ WHERE 1=1${filterSql}
673
+ ORDER BY updated_at DESC
674
+ LIMIT ?`)
675
+ .all(...filterParams, limit);
676
+ return rows.map(mapRow);
677
+ }
678
+ // ── Search: Context (Layer 3) ─────────────────────────────────────
679
+ /**
680
+ * Combined FTS5 relevance + recency search for context injection.
681
+ *
682
+ * Layer 3 of the memory architecture:
683
+ * 1. FTS5 search -> top N relevant
684
+ * 2. Recency fetch -> N most recent chunks
685
+ * 3. Deduplicate by (source_file, section)
686
+ * 4. Apply salience boost to FTS results
687
+ */
688
+ searchContext(query, limitOrOpts = 3, recencyLimitArg = 5) {
689
+ let limit;
690
+ let recencyLimit;
691
+ let agentSlug;
692
+ let category;
693
+ let topic;
694
+ let strict = false;
695
+ if (typeof limitOrOpts === 'object') {
696
+ limit = limitOrOpts.limit ?? 3;
697
+ recencyLimit = limitOrOpts.recencyLimit ?? 5;
698
+ agentSlug = limitOrOpts.agentSlug;
699
+ category = limitOrOpts.category;
700
+ topic = limitOrOpts.topic;
701
+ strict = limitOrOpts.strict ?? false;
702
+ }
703
+ else {
704
+ limit = limitOrOpts;
705
+ recencyLimit = recencyLimitArg;
706
+ }
707
+ const tagFilters = (category || topic) ? { category, topic } : undefined;
708
+ // 1. FTS5 relevance (fetch extra to allow re-ranking after boost)
709
+ const ftsResults = this.searchFts(query, agentSlug ? limit * 2 : limit, tagFilters, agentSlug && strict ? agentSlug : undefined);
710
+ // Apply salience boost to FTS results
711
+ for (const r of ftsResults) {
712
+ if (r.salience > 0) {
713
+ r.score *= 1.0 + r.salience;
714
+ }
715
+ }
716
+ // Soft-isolation: apply agent affinity boost when not strict
717
+ if (agentSlug && !strict) {
718
+ for (const r of ftsResults) {
719
+ if (r.agentSlug === agentSlug) {
720
+ r.score *= 1.4;
721
+ }
722
+ }
723
+ }
724
+ // 2. Vector similarity (if embeddings available)
725
+ let vectorResults = [];
726
+ try {
727
+ if (embeddingsModule.isReady()) {
728
+ const queryVec = embeddingsModule.embed(query);
729
+ if (queryVec) {
730
+ vectorResults = this.searchByEmbedding(queryVec, limit, agentSlug, strict);
731
+ }
732
+ }
733
+ }
734
+ catch {
735
+ // Embeddings not available — fallback to FTS only
736
+ }
737
+ // 3. Recency
738
+ const recentResults = this.getRecentChunks(recencyLimit, agentSlug, tagFilters, strict);
739
+ // 4. Merge and deduplicate (FTS results first, then vector, then recency)
740
+ const merged = [...ftsResults, ...vectorResults, ...recentResults];
741
+ return mmrRerank(deduplicateResults(merged), 0.7, limit + recencyLimit);
742
+ }
743
+ /**
744
+ * Search chunks by embedding cosine similarity.
745
+ * Scans chunks that have stored embeddings and returns top matches.
746
+ */
747
+ searchByEmbedding(queryVec, limit, agentSlug, strict = false) {
748
+ const rows = this.conn
749
+ .prepare(`SELECT id, source_file, section, content, chunk_type, embedding, salience, agent_slug, updated_at, category, topic
750
+ FROM chunks
751
+ WHERE embedding IS NOT NULL AND consolidated = 0
752
+ ORDER BY updated_at DESC
753
+ LIMIT 500`)
754
+ .all();
755
+ const scored = [];
756
+ for (const row of rows) {
757
+ try {
758
+ // Hard isolation: skip chunks from other agents (allow own + global)
759
+ if (strict && agentSlug && row.agent_slug !== null && row.agent_slug !== agentSlug)
760
+ continue;
761
+ const vec = embeddingsModule.deserializeEmbedding(row.embedding);
762
+ const sim = embeddingsModule.cosineSimilarity(queryVec, vec);
763
+ if (sim < 0.15)
764
+ continue; // threshold for relevance
765
+ let score = sim * 10; // scale to comparable range with FTS scores
766
+ if (row.salience > 0)
767
+ score *= (1.0 + row.salience);
768
+ // Soft isolation: apply boost (only when not strict)
769
+ if (!strict && agentSlug && row.agent_slug === agentSlug)
770
+ score *= 1.4;
771
+ scored.push({
772
+ sourceFile: row.source_file,
773
+ section: row.section,
774
+ content: row.content,
775
+ score,
776
+ chunkType: row.chunk_type,
777
+ matchType: 'vector',
778
+ lastUpdated: row.updated_at,
779
+ chunkId: row.id,
780
+ salience: row.salience,
781
+ agentSlug: row.agent_slug ?? undefined,
782
+ category: row.category,
783
+ topic: row.topic,
784
+ });
785
+ }
786
+ catch {
787
+ continue;
788
+ }
789
+ }
790
+ return scored.sort((a, b) => b.score - a.score).slice(0, limit);
791
+ }
792
+ // ── Cross-agent learning: promote insight to global ──────────────
793
+ /**
794
+ * Promote a memory chunk to global visibility (agent_slug = NULL).
795
+ * Used by agents to deliberately share an insight across the agent ecosystem.
796
+ * Does NOT copy the chunk — it promotes the existing chunk in-place.
797
+ *
798
+ * @param chunkId - ID of the chunk to promote
799
+ * @param promotedBy - slug of the agent promoting it (for audit log)
800
+ * @returns description of what was promoted, or error message
801
+ */
802
+ promoteToGlobal(chunkId, promotedBy) {
803
+ try {
804
+ const existing = this.conn.prepare('SELECT id, source_file, section, content, agent_slug FROM chunks WHERE id = ?').get(chunkId);
805
+ if (!existing)
806
+ return `Chunk ${chunkId} not found.`;
807
+ if (existing.agent_slug === null)
808
+ return `Chunk ${chunkId} is already global.`;
809
+ this.conn.prepare('UPDATE chunks SET agent_slug = NULL WHERE id = ?').run(chunkId);
810
+ const preview = existing.content.slice(0, 80).replace(/\n/g, ' ');
811
+ const msg = `Promoted chunk ${chunkId} (from ${existing.agent_slug ?? 'global'}) to global: "${preview}..."`;
812
+ // Append to promoted-insights log for audit trail
813
+ try {
814
+ const logDir = path.join(path.dirname(this.dbPath), '..', 'logs');
815
+ if (!existsSync(logDir))
816
+ mkdirSync(logDir, { recursive: true });
817
+ appendFileSync(path.join(logDir, 'promoted-insights.jsonl'), JSON.stringify({ ts: new Date().toISOString(), chunkId, promotedBy, section: existing.section, preview }) + '\n');
818
+ }
819
+ catch { /* non-fatal */ }
820
+ return msg;
821
+ }
822
+ catch (err) {
823
+ return `Failed to promote chunk: ${err}`;
824
+ }
825
+ }
826
+ // ── Wikilink Graph ────────────────────────────────────────────────
827
+ /**
828
+ * Get all notes connected to/from the given note via wikilinks.
829
+ */
830
+ getConnections(noteName) {
831
+ const results = [];
832
+ // Outgoing links (this note links to others)
833
+ const outgoing = this.conn
834
+ .prepare('SELECT target_file, context FROM wikilinks WHERE source_file LIKE ?')
835
+ .all(`%${noteName}%`);
836
+ for (const row of outgoing) {
837
+ results.push({
838
+ direction: 'outgoing',
839
+ file: row.target_file,
840
+ context: row.context,
841
+ });
842
+ }
843
+ // Incoming links (other notes link to this one)
844
+ const incoming = this.conn
845
+ .prepare('SELECT source_file, context FROM wikilinks WHERE target_file LIKE ?')
846
+ .all(`%${noteName}%`);
847
+ for (const row of incoming) {
848
+ results.push({
849
+ direction: 'incoming',
850
+ file: row.source_file,
851
+ context: row.context,
852
+ });
853
+ }
854
+ return results;
855
+ }
856
+ // ── Transcripts ───────────────────────────────────────────────────
857
+ /**
858
+ * Save a conversation turn to the transcripts table.
859
+ */
860
+ saveTurn(sessionKey, role, content, model = '') {
861
+ if (!this._stmtInsertTranscript) {
862
+ this._stmtInsertTranscript = this.conn.prepare('INSERT INTO transcripts (session_key, role, content, model) VALUES (?, ?, ?, ?)');
863
+ }
864
+ this._stmtInsertTranscript.run(sessionKey, role, content, model);
865
+ }
866
+ /**
867
+ * Get all turns for a given session, ordered chronologically.
868
+ */
869
+ getSessionTranscript(sessionKey) {
870
+ const rows = this.conn
871
+ .prepare(`SELECT session_key, role, content, model, created_at
872
+ FROM transcripts WHERE session_key = ? ORDER BY id`)
873
+ .all(sessionKey);
874
+ return rows.map((row) => ({
875
+ sessionKey: row.session_key,
876
+ role: row.role,
877
+ content: row.content,
878
+ model: row.model,
879
+ createdAt: row.created_at,
880
+ }));
881
+ }
882
+ /**
883
+ * Get recent transcript activity across all sessions since a given timestamp.
884
+ * Returns a compact summary of what happened (sessions, message counts, snippets).
885
+ */
886
+ getRecentActivity(sinceIso, maxEntries = 10) {
887
+ const rows = this.conn
888
+ .prepare(`SELECT session_key, role, content, created_at
889
+ FROM transcripts
890
+ WHERE created_at > ? AND role IN ('user', 'assistant', 'system')
891
+ ORDER BY created_at DESC
892
+ LIMIT ?`)
893
+ .all(sinceIso, maxEntries);
894
+ return rows.map((row) => ({
895
+ sessionKey: row.session_key,
896
+ role: row.role,
897
+ content: row.content.slice(0, 300),
898
+ createdAt: row.created_at,
899
+ }));
900
+ }
901
+ /**
902
+ * Search transcripts by keyword. Returns matching turns with context.
903
+ */
904
+ searchTranscripts(query, limit = 20, sessionKey = '') {
905
+ const queryLower = `%${query.toLowerCase()}%`;
906
+ let rows;
907
+ if (sessionKey) {
908
+ rows = this.conn
909
+ .prepare(`SELECT session_key, role, content, model, created_at
910
+ FROM transcripts
911
+ WHERE session_key = ? AND LOWER(content) LIKE ?
912
+ ORDER BY created_at DESC LIMIT ?`)
913
+ .all(sessionKey, queryLower, limit);
914
+ }
915
+ else {
916
+ rows = this.conn
917
+ .prepare(`SELECT session_key, role, content, model, created_at
918
+ FROM transcripts
919
+ WHERE LOWER(content) LIKE ?
920
+ ORDER BY created_at DESC LIMIT ?`)
921
+ .all(queryLower, limit);
922
+ }
923
+ return rows.map((row) => ({
924
+ sessionKey: row.session_key,
925
+ role: row.role,
926
+ content: row.content.slice(0, 2000), // Truncate for readability
927
+ model: row.model,
928
+ createdAt: row.created_at,
929
+ }));
930
+ }
931
+ // ── Session Summaries ─────────────────────────────────────────────
932
+ /**
933
+ * Save a session summary for cross-session context.
934
+ */
935
+ saveSessionSummary(sessionKey, summary, exchangeCount = 0) {
936
+ this.conn
937
+ .prepare('INSERT INTO session_summaries (session_key, summary, exchange_count) VALUES (?, ?, ?)')
938
+ .run(sessionKey, summary, exchangeCount);
939
+ }
940
+ /**
941
+ * Get the most recent session summaries.
942
+ */
943
+ getRecentSummaries(limit = 3) {
944
+ const rows = this.conn
945
+ .prepare(`SELECT session_key, summary, exchange_count, created_at
946
+ FROM session_summaries ORDER BY created_at DESC LIMIT ?`)
947
+ .all(limit);
948
+ return rows.map((row) => ({
949
+ sessionKey: row.session_key,
950
+ summary: row.summary,
951
+ exchangeCount: row.exchange_count,
952
+ createdAt: row.created_at,
953
+ }));
954
+ }
955
+ // ── Salience Tracking ─────────────────────────────────────────────
956
+ /**
957
+ * Record that chunks were accessed (retrieved/displayed).
958
+ */
959
+ recordAccess(chunkIds, accessType = 'retrieval') {
960
+ if (chunkIds.length === 0)
961
+ return;
962
+ const insertStmt = this.conn.prepare('INSERT INTO access_log (chunk_id, access_type) VALUES (?, ?)');
963
+ for (const cid of chunkIds) {
964
+ insertStmt.run(cid, accessType);
965
+ }
966
+ // Recompute salience for accessed chunks
967
+ for (const cid of chunkIds) {
968
+ this.recomputeSalience(cid);
969
+ }
970
+ }
971
+ /**
972
+ * Recompute salience score for a chunk based on access patterns.
973
+ *
974
+ * salience = frequency_bonus + recency_bonus
975
+ * frequency_bonus = log(access_count + 1) * 0.15
976
+ * recency_bonus = decay(days_since_last_access, half_life=7) * 0.3
977
+ */
978
+ recomputeSalience(chunkId) {
979
+ const row = this.conn
980
+ .prepare('SELECT COUNT(*) as cnt, MAX(accessed_at) as last_access FROM access_log WHERE chunk_id = ?')
981
+ .get(chunkId);
982
+ if (!row || row.cnt === 0)
983
+ return;
984
+ const frequencyBonus = Math.log(row.cnt + 1) * 0.15;
985
+ let recencyBonus = 0;
986
+ if (row.last_access) {
987
+ try {
988
+ const last = new Date(row.last_access);
989
+ const daysOld = (Date.now() - last.getTime()) / 86_400_000;
990
+ recencyBonus = Math.exp(-0.693 * daysOld / 7.0) * 0.3;
991
+ }
992
+ catch {
993
+ // Invalid date, skip recency bonus
994
+ }
995
+ }
996
+ const salience = frequencyBonus + recencyBonus;
997
+ this.conn
998
+ .prepare('UPDATE chunks SET salience = ? WHERE id = ?')
999
+ .run(salience, chunkId);
1000
+ }
1001
+ // ── Decay & Pruning ─────────────────────────────────────────────
1002
+ /**
1003
+ * Apply temporal decay to all chunk salience scores.
1004
+ *
1005
+ * Call daily (or on startup). Reduces salience for chunks that
1006
+ * haven't been accessed recently, so stale memories naturally
1007
+ * sink below active ones.
1008
+ *
1009
+ * decay = exp(-0.693 * daysSinceLastAccess / halfLife)
1010
+ */
1011
+ decaySalience(halfLifeDays = 30) {
1012
+ // Get chunks that have salience > 0 and their most recent access
1013
+ const rows = this.conn
1014
+ .prepare(`SELECT c.id, c.salience,
1015
+ MAX(a.accessed_at) as last_access
1016
+ FROM chunks c
1017
+ LEFT JOIN access_log a ON a.chunk_id = c.id
1018
+ WHERE c.salience > 0.001
1019
+ GROUP BY c.id`)
1020
+ .all();
1021
+ if (rows.length === 0)
1022
+ return 0;
1023
+ let updated = 0;
1024
+ const updateStmt = this.conn.prepare('UPDATE chunks SET salience = ? WHERE id = ?');
1025
+ for (const row of rows) {
1026
+ let daysOld = halfLifeDays; // default if no access log
1027
+ if (row.last_access) {
1028
+ try {
1029
+ const last = new Date(row.last_access);
1030
+ daysOld = (Date.now() - last.getTime()) / 86_400_000;
1031
+ }
1032
+ catch {
1033
+ // Use default
1034
+ }
1035
+ }
1036
+ const decayFactor = Math.exp(-0.693 * daysOld / halfLifeDays);
1037
+ const newSalience = row.salience * decayFactor;
1038
+ // Only update if meaningfully changed
1039
+ if (Math.abs(newSalience - row.salience) > 0.001) {
1040
+ updateStmt.run(newSalience < 0.001 ? 0 : newSalience, row.id);
1041
+ updated++;
1042
+ }
1043
+ }
1044
+ return updated;
1045
+ }
1046
+ /**
1047
+ * Prune stale data to keep the database bounded.
1048
+ *
1049
+ * - Deletes episodic chunks with salience < threshold and age > maxDays
1050
+ * - Trims access_log entries older than retentionDays
1051
+ * - Trims transcripts older than retentionDays
1052
+ *
1053
+ * Returns counts of deleted items.
1054
+ */
1055
+ pruneStaleData(opts = {}) {
1056
+ const maxAge = opts.maxAgeDays ?? 90;
1057
+ const threshold = opts.salienceThreshold ?? 0.01;
1058
+ const accessRetention = opts.accessLogRetentionDays ?? 60;
1059
+ const transcriptRetention = opts.transcriptRetentionDays ?? 90;
1060
+ // Prune stale episodic chunks (not vault-sourced content)
1061
+ const episodicResult = this.conn
1062
+ .prepare(`DELETE FROM chunks
1063
+ WHERE sector = 'episodic'
1064
+ AND salience < ?
1065
+ AND created_at < datetime('now', ?)`)
1066
+ .run(threshold, `-${maxAge} days`);
1067
+ // Trim old access_log entries
1068
+ const accessResult = this.conn
1069
+ .prepare(`DELETE FROM access_log
1070
+ WHERE accessed_at < datetime('now', ?)`)
1071
+ .run(`-${accessRetention} days`);
1072
+ // Clean orphaned access_log entries (chunk was deleted but access_log wasn't)
1073
+ this.conn.exec('DELETE FROM access_log WHERE chunk_id NOT IN (SELECT id FROM chunks)');
1074
+ // Trim old transcripts (keep session_summaries which are more compact)
1075
+ const transcriptResult = this.conn
1076
+ .prepare(`DELETE FROM transcripts
1077
+ WHERE created_at < datetime('now', ?)`)
1078
+ .run(`-${transcriptRetention} days`);
1079
+ return {
1080
+ episodicPruned: episodicResult.changes,
1081
+ accessLogPruned: accessResult.changes,
1082
+ transcriptsPruned: transcriptResult.changes,
1083
+ };
1084
+ }
1085
+ // ── Timeline Query ─────────────────────────────────────────────
1086
+ /**
1087
+ * Get chunks within a date range, ordered chronologically.
1088
+ * Useful for "what happened last week" type queries.
1089
+ */
1090
+ getTimeline(startDate, endDate, limit = 20) {
1091
+ const rows = this.conn
1092
+ .prepare(`SELECT id, source_file, section, content, chunk_type,
1093
+ updated_at, salience
1094
+ FROM chunks
1095
+ WHERE updated_at >= ? AND updated_at <= ?
1096
+ ORDER BY updated_at DESC
1097
+ LIMIT ?`)
1098
+ .all(startDate, endDate + 'T23:59:59', limit);
1099
+ return rows.map((row) => ({
1100
+ sourceFile: row.source_file,
1101
+ section: row.section,
1102
+ content: row.content,
1103
+ score: 0,
1104
+ chunkType: row.chunk_type,
1105
+ matchType: 'timeline',
1106
+ lastUpdated: row.updated_at ?? '',
1107
+ chunkId: row.id,
1108
+ salience: row.salience ?? 0,
1109
+ }));
1110
+ }
1111
+ // ── Episodic Memory ───────────────────────────────────────────────
1112
+ /**
1113
+ * Index a session summary as an episodic memory chunk.
1114
+ *
1115
+ * These chunks have sector='episodic' and a synthetic source_file
1116
+ * so they can be found by search but distinguished from vault content.
1117
+ */
1118
+ indexEpisodicChunk(sessionKey, summaryText) {
1119
+ const sourceFile = `_episodic/${sessionKey}`;
1120
+ const hash = createHash('sha256')
1121
+ .update(summaryText)
1122
+ .digest('hex')
1123
+ .slice(0, 16);
1124
+ this.conn
1125
+ .prepare(`INSERT INTO chunks
1126
+ (source_file, section, content, chunk_type, frontmatter_json,
1127
+ content_hash, sector, category)
1128
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
1129
+ .run(sourceFile, 'session-summary', summaryText, 'episodic', '', hash, 'episodic', 'events');
1130
+ }
1131
+ // ── Deduplication ──────────────────────────────────────────────
1132
+ /**
1133
+ * Check if content is a duplicate of something already stored.
1134
+ * Returns match info or null if content is unique.
1135
+ *
1136
+ * Strategy:
1137
+ * 1. Exact match via content_hash (fast)
1138
+ * 2. Near-duplicate via FTS5 BM25 + word overlap (conservative)
1139
+ */
1140
+ checkDuplicate(content, sourceFile) {
1141
+ // Skip dedup for very short content
1142
+ if (content.length < 20) {
1143
+ return { isDuplicate: false, matchType: null };
1144
+ }
1145
+ // 1. Exact hash match
1146
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
1147
+ try {
1148
+ const exactMatch = this.conn
1149
+ .prepare(`SELECT id FROM chunks WHERE content_hash = ?${sourceFile ? ' AND source_file = ?' : ''} LIMIT 1`)
1150
+ .get(...(sourceFile ? [hash, sourceFile] : [hash]));
1151
+ if (exactMatch) {
1152
+ return { isDuplicate: true, matchType: 'exact', matchId: exactMatch.id };
1153
+ }
1154
+ }
1155
+ catch {
1156
+ // Fall through to near-duplicate check
1157
+ }
1158
+ // 2. Near-duplicate via FTS5 BM25 + word overlap
1159
+ try {
1160
+ // Extract significant words (>3 chars, no stop words)
1161
+ const stopWords = new Set(['the', 'and', 'for', 'that', 'this', 'with', 'from', 'have', 'been', 'will', 'would', 'could', 'should']);
1162
+ const words = content
1163
+ .toLowerCase()
1164
+ .split(/\s+/)
1165
+ .map(w => w.replace(/[^a-z0-9]/g, ''))
1166
+ .filter(w => w.length > 3 && !stopWords.has(w));
1167
+ if (words.length < 3) {
1168
+ return { isDuplicate: false, matchType: null };
1169
+ }
1170
+ // Take top 8 most significant words for the FTS query
1171
+ const queryWords = [...new Set(words)].slice(0, 8);
1172
+ const ftsQuery = queryWords.map(w => `"${w}"`).join(' OR ');
1173
+ const rows = this.conn
1174
+ .prepare(`SELECT c.id, c.content, bm25(chunks_fts) as score
1175
+ FROM chunks_fts f
1176
+ JOIN chunks c ON c.id = f.rowid
1177
+ WHERE chunks_fts MATCH ?
1178
+ ORDER BY bm25(chunks_fts)
1179
+ LIMIT 5`)
1180
+ .all(ftsQuery);
1181
+ // Check word overlap with top results
1182
+ const contentWordsSet = new Set(words);
1183
+ for (const row of rows) {
1184
+ const matchWords = row.content
1185
+ .toLowerCase()
1186
+ .split(/\s+/)
1187
+ .map(w => w.replace(/[^a-z0-9]/g, ''))
1188
+ .filter(w => w.length > 3 && !stopWords.has(w));
1189
+ const matchWordsSet = new Set(matchWords);
1190
+ const overlap = [...contentWordsSet].filter(w => matchWordsSet.has(w)).length;
1191
+ const overlapRatio = overlap / Math.max(contentWordsSet.size, 1);
1192
+ // Conservative threshold: >70% word overlap AND good BM25 score
1193
+ if (overlapRatio > 0.7 && -row.score > 5) {
1194
+ return { isDuplicate: true, matchType: 'near', matchId: row.id };
1195
+ }
1196
+ }
1197
+ }
1198
+ catch {
1199
+ // FTS5 query failed — fall through (exact-hash-only is fine)
1200
+ }
1201
+ return { isDuplicate: false, matchType: null };
1202
+ }
1203
+ /**
1204
+ * Bump a chunk's salience and update its timestamp when a duplicate is detected.
1205
+ * Instead of discarding duplicate mentions, this reinforces the existing chunk
1206
+ * so frequently-mentioned facts surface higher in search results.
1207
+ */
1208
+ bumpChunkSalience(chunkId, boost = 0.1) {
1209
+ this.conn
1210
+ .prepare(`UPDATE chunks
1211
+ SET salience = MIN(salience + ?, 1.0),
1212
+ updated_at = datetime('now')
1213
+ WHERE id = ?`)
1214
+ .run(boost, chunkId);
1215
+ }
1216
+ // ── Memory Extractions ──────────────────────────────────────────
1217
+ /**
1218
+ * Log a memory extraction event for transparency tracking.
1219
+ */
1220
+ logExtraction(extraction) {
1221
+ this.conn
1222
+ .prepare(`INSERT INTO memory_extractions
1223
+ (session_key, user_message, tool_name, tool_input, extracted_at, status, agent_slug)
1224
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
1225
+ .run(extraction.sessionKey, extraction.userMessage, extraction.toolName, extraction.toolInput, extraction.extractedAt, extraction.status, extraction.agentSlug ?? null);
1226
+ }
1227
+ /**
1228
+ * Get recent memory extractions, optionally filtered by status.
1229
+ */
1230
+ getRecentExtractions(limit = 10, status) {
1231
+ let rows;
1232
+ if (status) {
1233
+ rows = this.conn
1234
+ .prepare(`SELECT id, session_key, user_message, tool_name, tool_input,
1235
+ extracted_at, status, correction
1236
+ FROM memory_extractions
1237
+ WHERE status = ?
1238
+ ORDER BY extracted_at DESC LIMIT ?`)
1239
+ .all(status, limit);
1240
+ }
1241
+ else {
1242
+ rows = this.conn
1243
+ .prepare(`SELECT id, session_key, user_message, tool_name, tool_input,
1244
+ extracted_at, status, correction
1245
+ FROM memory_extractions
1246
+ ORDER BY extracted_at DESC LIMIT ?`)
1247
+ .all(limit);
1248
+ }
1249
+ return rows.map((row) => ({
1250
+ id: row.id,
1251
+ sessionKey: row.session_key,
1252
+ userMessage: row.user_message,
1253
+ toolName: row.tool_name,
1254
+ toolInput: row.tool_input,
1255
+ extractedAt: row.extracted_at,
1256
+ status: row.status,
1257
+ correction: row.correction ?? undefined,
1258
+ }));
1259
+ }
1260
+ /**
1261
+ * Mark an extraction as corrected with a replacement fact.
1262
+ * Also removes the wrong content from the search index so it stops surfacing.
1263
+ */
1264
+ correctExtraction(id, correction) {
1265
+ // Mark the extraction record
1266
+ this.conn
1267
+ .prepare(`UPDATE memory_extractions
1268
+ SET status = 'corrected', correction = ?
1269
+ WHERE id = ?`)
1270
+ .run(correction, id);
1271
+ // Find the original extraction to identify what was written
1272
+ const extraction = this.conn
1273
+ .prepare('SELECT tool_name, tool_input FROM memory_extractions WHERE id = ?')
1274
+ .get(id);
1275
+ if (!extraction)
1276
+ return;
1277
+ // Try to find and remove the wrong content from the chunks index.
1278
+ // Parse the tool_input to extract the content that was originally written.
1279
+ try {
1280
+ const input = JSON.parse(extraction.tool_input);
1281
+ const content = input.content ?? input.text ?? '';
1282
+ if (content && content.length > 10) {
1283
+ // Find chunks that match the wrong content via FTS5
1284
+ const dup = this.checkDuplicate(content);
1285
+ if (dup.isDuplicate && dup.matchId) {
1286
+ // Delete the wrong chunk from the search index
1287
+ this.conn.prepare('DELETE FROM chunks WHERE id = ?').run(dup.matchId);
1288
+ }
1289
+ }
1290
+ }
1291
+ catch {
1292
+ // Non-fatal — the extraction record is still corrected even if we can't find the chunk
1293
+ }
1294
+ }
1295
+ /**
1296
+ * Dismiss an extraction (mark as invalid).
1297
+ * Also removes the content from the search index.
1298
+ */
1299
+ dismissExtraction(id) {
1300
+ // Find the original extraction before dismissing
1301
+ const extraction = this.conn
1302
+ .prepare('SELECT tool_name, tool_input FROM memory_extractions WHERE id = ?')
1303
+ .get(id);
1304
+ this.conn
1305
+ .prepare(`UPDATE memory_extractions
1306
+ SET status = 'dismissed'
1307
+ WHERE id = ?`)
1308
+ .run(id);
1309
+ // Remove wrong content from chunks index
1310
+ if (extraction) {
1311
+ try {
1312
+ const input = JSON.parse(extraction.tool_input);
1313
+ const content = input.content ?? input.text ?? '';
1314
+ if (content && content.length > 10) {
1315
+ const dup = this.checkDuplicate(content);
1316
+ if (dup.isDuplicate && dup.matchId) {
1317
+ this.conn.prepare('DELETE FROM chunks WHERE id = ?').run(dup.matchId);
1318
+ }
1319
+ }
1320
+ }
1321
+ catch {
1322
+ // Non-fatal
1323
+ }
1324
+ }
1325
+ }
1326
+ /**
1327
+ * Get recent corrections to use as negative examples in auto-memory extraction.
1328
+ * Returns corrections from the last 30 days so the extraction prompt knows
1329
+ * what facts have been corrected and shouldn't be re-extracted.
1330
+ */
1331
+ getRecentCorrections(limit = 20) {
1332
+ const rows = this.conn
1333
+ .prepare(`SELECT tool_input, correction
1334
+ FROM memory_extractions
1335
+ WHERE status IN ('corrected', 'dismissed')
1336
+ AND extracted_at >= datetime('now', '-30 days')
1337
+ ORDER BY extracted_at DESC
1338
+ LIMIT ?`)
1339
+ .all(limit);
1340
+ return rows.map((row) => ({
1341
+ toolInput: row.tool_input,
1342
+ correction: row.correction ?? '(dismissed — this was wrong)',
1343
+ }));
1344
+ }
1345
+ // ── Feedback ───────────────────────────────────────────────────────
1346
+ /**
1347
+ * Log feedback about response quality.
1348
+ */
1349
+ logFeedback(feedback) {
1350
+ this.conn
1351
+ .prepare(`INSERT INTO feedback
1352
+ (session_key, channel, message_snippet, response_snippet, rating, comment)
1353
+ VALUES (?, ?, ?, ?, ?, ?)`)
1354
+ .run(feedback.sessionKey ?? null, feedback.channel, feedback.messageSnippet ?? null, feedback.responseSnippet ?? null, feedback.rating, feedback.comment ?? null);
1355
+ }
1356
+ /**
1357
+ * Get recent feedback entries.
1358
+ */
1359
+ getRecentFeedback(limit = 10) {
1360
+ const rows = this.conn
1361
+ .prepare(`SELECT id, session_key, channel, message_snippet, response_snippet,
1362
+ rating, comment, created_at
1363
+ FROM feedback
1364
+ ORDER BY created_at DESC LIMIT ?`)
1365
+ .all(limit);
1366
+ return rows.map((row) => ({
1367
+ id: row.id,
1368
+ sessionKey: row.session_key ?? undefined,
1369
+ channel: row.channel,
1370
+ messageSnippet: row.message_snippet ?? undefined,
1371
+ responseSnippet: row.response_snippet ?? undefined,
1372
+ rating: row.rating,
1373
+ comment: row.comment ?? undefined,
1374
+ createdAt: row.created_at,
1375
+ }));
1376
+ }
1377
+ /**
1378
+ * Get aggregate feedback statistics.
1379
+ */
1380
+ getFeedbackStats() {
1381
+ const rows = this.conn
1382
+ .prepare('SELECT rating, COUNT(*) as cnt FROM feedback GROUP BY rating')
1383
+ .all();
1384
+ const stats = { positive: 0, negative: 0, mixed: 0, total: 0 };
1385
+ for (const row of rows) {
1386
+ if (row.rating === 'positive')
1387
+ stats.positive = row.cnt;
1388
+ else if (row.rating === 'negative')
1389
+ stats.negative = row.cnt;
1390
+ else if (row.rating === 'mixed')
1391
+ stats.mixed = row.cnt;
1392
+ stats.total += row.cnt;
1393
+ }
1394
+ return stats;
1395
+ }
1396
+ // ── Session Reflections ──────────────────────────────────────────
1397
+ saveSessionReflection(reflection) {
1398
+ this.conn
1399
+ .prepare(`INSERT INTO session_reflections
1400
+ (session_key, exchange_count, friction_signals, quality_score, behavioral_corrections, preferences_learned, agent_slug)
1401
+ VALUES (?, ?, ?, ?, ?, ?, ?)`)
1402
+ .run(reflection.sessionKey, reflection.exchangeCount, JSON.stringify(reflection.frictionSignals), reflection.qualityScore, JSON.stringify(reflection.behavioralCorrections), JSON.stringify(reflection.preferencesLearned), reflection.agentSlug ?? null);
1403
+ }
1404
+ getRecentReflections(limit = 20, agentSlug) {
1405
+ const query = agentSlug
1406
+ ? `SELECT * FROM session_reflections WHERE agent_slug = ? ORDER BY created_at DESC LIMIT ?`
1407
+ : `SELECT * FROM session_reflections ORDER BY created_at DESC LIMIT ?`;
1408
+ const params = agentSlug ? [agentSlug, limit] : [limit];
1409
+ const rows = this.conn.prepare(query).all(...params);
1410
+ return rows.map(r => ({
1411
+ sessionKey: r.session_key,
1412
+ exchangeCount: r.exchange_count,
1413
+ frictionSignals: JSON.parse(r.friction_signals || '[]'),
1414
+ qualityScore: r.quality_score,
1415
+ behavioralCorrections: JSON.parse(r.behavioral_corrections || '[]'),
1416
+ preferencesLearned: JSON.parse(r.preferences_learned || '[]'),
1417
+ agentSlug: r.agent_slug,
1418
+ createdAt: r.created_at,
1419
+ }));
1420
+ }
1421
+ /** Get recurring behavioral corrections (appeared in 2+ sessions). */
1422
+ getBehavioralPatterns(minOccurrences = 2) {
1423
+ const rows = this.conn.prepare(`SELECT behavioral_corrections, created_at FROM session_reflections
1424
+ WHERE created_at >= datetime('now', '-30 days')
1425
+ ORDER BY created_at DESC`).all();
1426
+ // Count occurrences of each correction (normalized lowercase)
1427
+ const counts = new Map();
1428
+ for (const row of rows) {
1429
+ try {
1430
+ const corrections = JSON.parse(row.behavioral_corrections || '[]');
1431
+ for (const c of corrections) {
1432
+ const key = c.correction.toLowerCase().trim();
1433
+ const existing = counts.get(key);
1434
+ if (existing) {
1435
+ existing.count++;
1436
+ }
1437
+ else {
1438
+ counts.set(key, { correction: c.correction, category: c.category, count: 1, lastSeen: row.created_at });
1439
+ }
1440
+ }
1441
+ }
1442
+ catch { /* skip malformed */ }
1443
+ }
1444
+ return [...counts.values()]
1445
+ .filter(c => c.count >= minOccurrences)
1446
+ .sort((a, b) => b.count - a.count);
1447
+ }
1448
+ // ── Usage Tracking ────────────────────────────────────────────────
1449
+ /**
1450
+ * Log token usage from an SDK query result.
1451
+ * Iterates modelUsage record and inserts one row per model.
1452
+ */
1453
+ logUsage(entry) {
1454
+ if (!this._stmtInsertUsage) {
1455
+ this._stmtInsertUsage = this.conn.prepare(`INSERT INTO usage_log (session_key, source, model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens, num_turns, duration_ms, agent_slug)
1456
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
1457
+ }
1458
+ for (const [model, usage] of Object.entries(entry.modelUsage)) {
1459
+ this._stmtInsertUsage.run(entry.sessionKey, entry.source, model, usage.inputTokens ?? 0, usage.outputTokens ?? 0, usage.cacheReadInputTokens ?? 0, usage.cacheCreationInputTokens ?? 0, entry.numTurns ?? 0, entry.durationMs ?? 0, entry.agentSlug ?? null);
1460
+ }
1461
+ }
1462
+ /**
1463
+ * Get aggregated usage summary, optionally filtered by time.
1464
+ */
1465
+ getUsageSummary(sinceIso) {
1466
+ const where = sinceIso ? `WHERE created_at >= ?` : '';
1467
+ const params = sinceIso ? [sinceIso] : [];
1468
+ // Totals
1469
+ const totals = this.conn.prepare(`SELECT COALESCE(SUM(input_tokens), 0) as ti, COALESCE(SUM(output_tokens), 0) as to_,
1470
+ COALESCE(SUM(cache_read_tokens), 0) as tcr, COALESCE(SUM(cache_creation_tokens), 0) as tcc
1471
+ FROM usage_log ${where}`).get(...params);
1472
+ // By model
1473
+ const byModel = this.conn.prepare(`SELECT model, SUM(input_tokens) as input, SUM(output_tokens) as output, SUM(cache_read_tokens) as cacheRead
1474
+ FROM usage_log ${where} GROUP BY model ORDER BY input DESC`).all(...params);
1475
+ // By source
1476
+ const bySource = this.conn.prepare(`SELECT source, SUM(input_tokens) as input, SUM(output_tokens) as output
1477
+ FROM usage_log ${where} GROUP BY source ORDER BY input DESC`).all(...params);
1478
+ // By day (last 7 days)
1479
+ const byDay = this.conn.prepare(`SELECT date(created_at) as day, SUM(input_tokens) as input, SUM(output_tokens) as output
1480
+ FROM usage_log ${where ? where + ' AND' : 'WHERE'} created_at >= date('now', '-7 days')
1481
+ GROUP BY date(created_at) ORDER BY day`).all(...params);
1482
+ return {
1483
+ totalInput: totals.ti,
1484
+ totalOutput: totals.to_,
1485
+ totalCacheRead: totals.tcr,
1486
+ totalCacheCreation: totals.tcc,
1487
+ totalTokens: totals.ti + totals.to_,
1488
+ byModel,
1489
+ bySource,
1490
+ byDay,
1491
+ };
1492
+ }
1493
+ /**
1494
+ * Get per-agent usage stats for observability dashboard.
1495
+ */
1496
+ getAgentStats(agentSlug, sinceIso) {
1497
+ const where = sinceIso
1498
+ ? `WHERE agent_slug = ? AND created_at >= ?`
1499
+ : `WHERE agent_slug = ?`;
1500
+ const params = sinceIso ? [agentSlug, sinceIso] : [agentSlug];
1501
+ const totals = this.conn.prepare(`SELECT COALESCE(SUM(input_tokens), 0) as ti, COALESCE(SUM(output_tokens), 0) as to_,
1502
+ COUNT(*) as cnt, COALESCE(AVG(num_turns), 0) as avg_turns,
1503
+ COALESCE(AVG(duration_ms), 0) as avg_dur
1504
+ FROM usage_log ${where}`).get(...params);
1505
+ const bySource = this.conn.prepare(`SELECT source, COUNT(*) as count, SUM(input_tokens + output_tokens) as tokens
1506
+ FROM usage_log ${where} GROUP BY source ORDER BY tokens DESC`).all(...params);
1507
+ const byDay = this.conn.prepare(`SELECT date(created_at) as day, SUM(input_tokens + output_tokens) as tokens, COUNT(*) as count
1508
+ FROM usage_log ${where} ${sinceIso ? 'AND' : 'AND'} created_at >= date('now', '-14 days')
1509
+ GROUP BY date(created_at) ORDER BY day`).all(...params);
1510
+ return {
1511
+ totalInput: totals.ti,
1512
+ totalOutput: totals.to_,
1513
+ totalTokens: totals.ti + totals.to_,
1514
+ numQueries: totals.cnt,
1515
+ avgTurns: Math.round(totals.avg_turns * 10) / 10,
1516
+ avgDurationMs: Math.round(totals.avg_dur),
1517
+ bySource,
1518
+ byDay,
1519
+ };
1520
+ }
1521
+ /**
1522
+ * Compare all agents by usage. Returns a leaderboard.
1523
+ */
1524
+ getAgentComparison(sinceIso) {
1525
+ const where = sinceIso ? `WHERE agent_slug IS NOT NULL AND created_at >= ?` : `WHERE agent_slug IS NOT NULL`;
1526
+ const params = sinceIso ? [sinceIso] : [];
1527
+ return this.conn.prepare(`SELECT agent_slug as agentSlug,
1528
+ SUM(input_tokens + output_tokens) as totalTokens,
1529
+ COUNT(*) as numQueries,
1530
+ COALESCE(AVG(num_turns), 0) as avgTurns
1531
+ FROM usage_log ${where}
1532
+ GROUP BY agent_slug ORDER BY totalTokens DESC`).all(...params);
1533
+ }
1534
+ /**
1535
+ * Get usage summary for a specific session.
1536
+ */
1537
+ getSessionUsage(sessionKey) {
1538
+ const row = this.conn.prepare(`SELECT COALESCE(SUM(input_tokens), 0) as ti, COALESCE(SUM(output_tokens), 0) as to_,
1539
+ COUNT(*) as cnt
1540
+ FROM usage_log WHERE session_key = ?`).get(sessionKey);
1541
+ return {
1542
+ totalInput: row.ti,
1543
+ totalOutput: row.to_,
1544
+ totalTokens: row.ti + row.to_,
1545
+ numQueries: row.cnt,
1546
+ };
1547
+ }
1548
+ // ── Memory Consolidation ──────────────────────────────────────────
1549
+ /**
1550
+ * Get chunks that are candidates for consolidation:
1551
+ * - Older than `minAgeDays`
1552
+ * - Not already consolidated
1553
+ * - Grouped by source file prefix (topic area)
1554
+ *
1555
+ * Returns groups with 3+ chunks that can be synthesized into summaries.
1556
+ */
1557
+ getConsolidationCandidates(minAgeDays = 30) {
1558
+ const rows = this.conn
1559
+ .prepare(`SELECT id, source_file, section, content, topic AS chunk_topic
1560
+ FROM chunks
1561
+ WHERE consolidated = 0
1562
+ AND sector = 'semantic'
1563
+ AND updated_at <= datetime('now', ? || ' days')
1564
+ AND chunk_type != 'frontmatter'
1565
+ ORDER BY source_file, section`)
1566
+ .all(`-${minAgeDays}`);
1567
+ // Group by topic column (preferred) or fall back to directory path
1568
+ const groups = new Map();
1569
+ for (const row of rows) {
1570
+ const topic = row.chunk_topic || row.source_file.split('/').slice(0, 2).join('/') || row.source_file;
1571
+ const group = groups.get(topic) ?? { chunkIds: [], contents: [], totalChars: 0 };
1572
+ group.chunkIds.push(row.id);
1573
+ group.contents.push(`[${row.section}] ${row.content}`);
1574
+ group.totalChars += row.content.length;
1575
+ groups.set(topic, group);
1576
+ }
1577
+ // Only return groups with 3+ chunks (worth consolidating)
1578
+ return [...groups.entries()]
1579
+ .filter(([, g]) => g.chunkIds.length >= 3)
1580
+ .map(([topic, g]) => ({ topic, ...g }))
1581
+ .sort((a, b) => b.chunkIds.length - a.chunkIds.length);
1582
+ }
1583
+ /**
1584
+ * Mark chunks as consolidated after they've been synthesized into a summary.
1585
+ * Reduces salience so they appear lower in search results (but aren't deleted).
1586
+ */
1587
+ markConsolidated(chunkIds) {
1588
+ if (chunkIds.length === 0)
1589
+ return;
1590
+ const placeholders = chunkIds.map(() => '?').join(',');
1591
+ this.conn
1592
+ .prepare(`UPDATE chunks
1593
+ SET consolidated = 1, salience = MAX(salience - 0.3, 0.0)
1594
+ WHERE id IN (${placeholders})`)
1595
+ .run(...chunkIds);
1596
+ }
1597
+ /**
1598
+ * Get consolidation stats for monitoring.
1599
+ */
1600
+ getConsolidationStats() {
1601
+ const row = this.conn
1602
+ .prepare(`SELECT
1603
+ COUNT(*) as total,
1604
+ COALESCE(SUM(CASE WHEN consolidated = 1 THEN 1 ELSE 0 END), 0) as consolidated
1605
+ FROM chunks`)
1606
+ .get();
1607
+ return {
1608
+ totalChunks: row.total,
1609
+ consolidated: row.consolidated,
1610
+ unconsolidated: row.total - row.consolidated,
1611
+ };
1612
+ }
1613
+ /**
1614
+ * Insert a summary chunk created by the consolidation engine.
1615
+ * Gets higher initial salience than regular chunks.
1616
+ */
1617
+ insertSummaryChunk(sourceFile, section, content) {
1618
+ const hash = createHash('sha256').update(content).digest('hex').slice(0, 16);
1619
+ this.conn
1620
+ .prepare(`INSERT INTO chunks (source_file, section, content, chunk_type, content_hash, salience, consolidated)
1621
+ VALUES (?, ?, ?, 'summary', ?, 0.8, 0)`)
1622
+ .run(sourceFile, section, content, hash);
1623
+ }
1624
+ // ── SDR Operational Data ─────────────────────────────────────────
1625
+ // -- Leads --
1626
+ upsertLead(lead) {
1627
+ const existing = this.conn.prepare('SELECT id FROM leads WHERE email = ?').get(lead.email);
1628
+ if (existing) {
1629
+ const sets = ['updated_at = datetime(\'now\')'];
1630
+ const vals = [];
1631
+ if (lead.name) {
1632
+ sets.push('name = ?');
1633
+ vals.push(lead.name);
1634
+ }
1635
+ if (lead.company !== undefined) {
1636
+ sets.push('company = ?');
1637
+ vals.push(lead.company);
1638
+ }
1639
+ if (lead.title !== undefined) {
1640
+ sets.push('title = ?');
1641
+ vals.push(lead.title);
1642
+ }
1643
+ if (lead.status) {
1644
+ sets.push('status = ?');
1645
+ vals.push(lead.status);
1646
+ }
1647
+ if (lead.source !== undefined) {
1648
+ sets.push('source = ?');
1649
+ vals.push(lead.source);
1650
+ }
1651
+ if (lead.sfId !== undefined) {
1652
+ sets.push('sf_id = ?');
1653
+ vals.push(lead.sfId);
1654
+ }
1655
+ if (lead.metadata) {
1656
+ sets.push('metadata = ?');
1657
+ vals.push(JSON.stringify(lead.metadata));
1658
+ }
1659
+ vals.push(existing.id);
1660
+ this.conn.prepare(`UPDATE leads SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1661
+ return { id: existing.id, created: false };
1662
+ }
1663
+ const result = this.conn.prepare(`INSERT INTO leads (agent_slug, email, name, company, title, status, source, sf_id, metadata)
1664
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(lead.agentSlug, lead.email, lead.name, lead.company ?? null, lead.title ?? null, lead.status ?? 'new', lead.source ?? null, lead.sfId ?? null, JSON.stringify(lead.metadata ?? {}));
1665
+ return { id: Number(result.lastInsertRowid), created: true };
1666
+ }
1667
+ searchLeads(filters) {
1668
+ const where = [];
1669
+ const vals = [];
1670
+ if (filters.agentSlug) {
1671
+ where.push('agent_slug = ?');
1672
+ vals.push(filters.agentSlug);
1673
+ }
1674
+ if (filters.status) {
1675
+ where.push('status = ?');
1676
+ vals.push(filters.status);
1677
+ }
1678
+ if (filters.company) {
1679
+ where.push('company LIKE ?');
1680
+ vals.push(`%${filters.company}%`);
1681
+ }
1682
+ if (filters.query) {
1683
+ where.push('(name LIKE ? OR email LIKE ? OR company LIKE ?)');
1684
+ vals.push(`%${filters.query}%`, `%${filters.query}%`, `%${filters.query}%`);
1685
+ }
1686
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
1687
+ const limit = Math.min(filters.limit ?? 50, 200);
1688
+ return this.conn.prepare(`SELECT * FROM leads ${clause} ORDER BY updated_at DESC LIMIT ?`).all(...vals, limit);
1689
+ }
1690
+ getLeadByEmail(email) {
1691
+ return this.conn.prepare('SELECT * FROM leads WHERE email = ?').get(email);
1692
+ }
1693
+ getLeadById(id) {
1694
+ return this.conn.prepare('SELECT * FROM leads WHERE id = ?').get(id);
1695
+ }
1696
+ // -- Sequence Enrollments --
1697
+ enrollSequence(enrollment) {
1698
+ const result = this.conn.prepare(`INSERT INTO sequence_enrollments (lead_id, sequence_name, current_step, status, next_step_due_at)
1699
+ VALUES (?, ?, 0, 'active', ?)`).run(enrollment.leadId, enrollment.sequenceName, enrollment.nextStepDueAt ?? null);
1700
+ return Number(result.lastInsertRowid);
1701
+ }
1702
+ advanceSequence(id, updates) {
1703
+ const sets = ['updated_at = datetime(\'now\')'];
1704
+ const vals = [];
1705
+ if (updates.currentStep !== undefined) {
1706
+ sets.push('current_step = ?');
1707
+ vals.push(updates.currentStep);
1708
+ }
1709
+ if (updates.status) {
1710
+ sets.push('status = ?');
1711
+ vals.push(updates.status);
1712
+ }
1713
+ if (updates.nextStepDueAt !== undefined) {
1714
+ sets.push('next_step_due_at = ?');
1715
+ vals.push(updates.nextStepDueAt);
1716
+ }
1717
+ vals.push(id);
1718
+ this.conn.prepare(`UPDATE sequence_enrollments SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
1719
+ }
1720
+ getDueSequences(agentSlug) {
1721
+ const base = `SELECT se.*, l.email, l.name, l.company FROM sequence_enrollments se
1722
+ JOIN leads l ON l.id = se.lead_id
1723
+ WHERE se.status = 'active' AND se.next_step_due_at <= datetime('now')`;
1724
+ const clause = agentSlug ? ` AND l.agent_slug = ?` : '';
1725
+ return this.conn.prepare(`${base}${clause} ORDER BY se.next_step_due_at ASC`).all(...(agentSlug ? [agentSlug] : []));
1726
+ }
1727
+ getSequencesByLead(leadId) {
1728
+ return this.conn.prepare('SELECT * FROM sequence_enrollments WHERE lead_id = ? ORDER BY started_at DESC').all(leadId);
1729
+ }
1730
+ // -- Activities --
1731
+ logActivity(activity) {
1732
+ const result = this.conn.prepare(`INSERT INTO activities (lead_id, agent_slug, type, subject, detail, template_used)
1733
+ VALUES (?, ?, ?, ?, ?, ?)`).run(activity.leadId ?? null, activity.agentSlug, activity.type, activity.subject ?? null, activity.detail ?? null, activity.templateUsed ?? null);
1734
+ return Number(result.lastInsertRowid);
1735
+ }
1736
+ getActivities(filters) {
1737
+ const where = [];
1738
+ const vals = [];
1739
+ if (filters.leadId) {
1740
+ where.push('lead_id = ?');
1741
+ vals.push(filters.leadId);
1742
+ }
1743
+ if (filters.agentSlug) {
1744
+ where.push('agent_slug = ?');
1745
+ vals.push(filters.agentSlug);
1746
+ }
1747
+ if (filters.type) {
1748
+ where.push('type = ?');
1749
+ vals.push(filters.type);
1750
+ }
1751
+ if (filters.sinceIso) {
1752
+ where.push('performed_at >= ?');
1753
+ vals.push(filters.sinceIso);
1754
+ }
1755
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
1756
+ const limit = Math.min(filters.limit ?? 50, 500);
1757
+ return this.conn.prepare(`SELECT * FROM activities ${clause} ORDER BY performed_at DESC LIMIT ?`).all(...vals, limit);
1758
+ }
1759
+ // -- Suppression List --
1760
+ addSuppression(email, reason, addedBy) {
1761
+ this.conn.prepare(`INSERT OR IGNORE INTO suppression_list (email, reason, added_by) VALUES (?, ?, ?)`).run(email.toLowerCase(), reason, addedBy ?? null);
1762
+ }
1763
+ isSuppressed(email) {
1764
+ const row = this.conn.prepare('SELECT 1 FROM suppression_list WHERE email = ?').get(email.toLowerCase());
1765
+ return !!row;
1766
+ }
1767
+ getSuppressionList(limit = 100) {
1768
+ return this.conn.prepare('SELECT * FROM suppression_list ORDER BY added_at DESC LIMIT ?').all(limit);
1769
+ }
1770
+ // -- Send Log --
1771
+ logSend(entry) {
1772
+ this.conn.prepare(`INSERT INTO send_log (agent_slug, recipient, subject, template_used, policy_ref) VALUES (?, ?, ?, ?, ?)`).run(entry.agentSlug, entry.recipient, entry.subject ?? null, entry.templateUsed ?? null, entry.policyRef ?? null);
1773
+ }
1774
+ getDailySendCount(agentSlug) {
1775
+ const row = this.conn.prepare(`SELECT COUNT(*) as cnt FROM send_log WHERE agent_slug = ? AND sent_at >= date('now')`).get(agentSlug);
1776
+ return row.cnt;
1777
+ }
1778
+ getSendLog(filters) {
1779
+ const where = [];
1780
+ const vals = [];
1781
+ if (filters.agentSlug) {
1782
+ where.push('agent_slug = ?');
1783
+ vals.push(filters.agentSlug);
1784
+ }
1785
+ if (filters.sinceIso) {
1786
+ where.push('sent_at >= ?');
1787
+ vals.push(filters.sinceIso);
1788
+ }
1789
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
1790
+ const limit = Math.min(filters.limit ?? 50, 500);
1791
+ return this.conn.prepare(`SELECT * FROM send_log ${clause} ORDER BY sent_at DESC LIMIT ?`).all(...vals, limit);
1792
+ }
1793
+ // -- Approval Queue --
1794
+ addApproval(entry) {
1795
+ const result = this.conn.prepare(`INSERT INTO approval_queue (agent_slug, action_type, summary, detail) VALUES (?, ?, ?, ?)`).run(entry.agentSlug, entry.actionType, entry.summary, JSON.stringify(entry.detail ?? {}));
1796
+ return Number(result.lastInsertRowid);
1797
+ }
1798
+ resolveApproval(id, status, resolvedBy) {
1799
+ this.conn.prepare(`UPDATE approval_queue SET status = ?, resolved_at = datetime('now'), resolved_by = ? WHERE id = ?`).run(status, resolvedBy ?? null, id);
1800
+ }
1801
+ getPendingApprovals(agentSlug) {
1802
+ if (agentSlug) {
1803
+ return this.conn.prepare(`SELECT * FROM approval_queue WHERE status = 'pending' AND agent_slug = ? ORDER BY requested_at DESC`).all(agentSlug);
1804
+ }
1805
+ return this.conn.prepare(`SELECT * FROM approval_queue WHERE status = 'pending' ORDER BY requested_at DESC`).all();
1806
+ }
1807
+ getApprovalById(id) {
1808
+ return this.conn.prepare('SELECT * FROM approval_queue WHERE id = ?').get(id);
1809
+ }
1810
+ // -- Agent KPIs --
1811
+ getAgentKpis(agentSlug, sinceIso) {
1812
+ const since = sinceIso ?? new Date(Date.now() - 7 * 86400000).toISOString();
1813
+ const emailsSent = this.conn.prepare(`SELECT COUNT(*) as cnt FROM activities WHERE agent_slug = ? AND type = 'email_sent' AND performed_at >= ?`).get(agentSlug, since).cnt;
1814
+ const emailsReceived = this.conn.prepare(`SELECT COUNT(*) as cnt FROM activities WHERE agent_slug = ? AND type = 'email_received' AND performed_at >= ?`).get(agentSlug, since).cnt;
1815
+ const meetingsBooked = this.conn.prepare(`SELECT COUNT(*) as cnt FROM activities WHERE agent_slug = ? AND type = 'meeting_booked' AND performed_at >= ?`).get(agentSlug, since).cnt;
1816
+ const leadsCreated = this.conn.prepare(`SELECT COUNT(*) as cnt FROM leads WHERE agent_slug = ? AND created_at >= ?`).get(agentSlug, since).cnt;
1817
+ const leadsContacted = this.conn.prepare(`SELECT COUNT(DISTINCT lead_id) as cnt FROM activities WHERE agent_slug = ? AND type = 'email_sent' AND performed_at >= ?`).get(agentSlug, since).cnt;
1818
+ const sequencesActive = this.conn.prepare(`SELECT COUNT(*) as cnt FROM sequence_enrollments se JOIN leads l ON l.id = se.lead_id
1819
+ WHERE l.agent_slug = ? AND se.status = 'active'`).get(agentSlug).cnt;
1820
+ const sequencesCompleted = this.conn.prepare(`SELECT COUNT(*) as cnt FROM sequence_enrollments se JOIN leads l ON l.id = se.lead_id
1821
+ WHERE l.agent_slug = ? AND se.status = 'completed' AND se.updated_at >= ?`).get(agentSlug, since).cnt;
1822
+ const replyRate = emailsSent > 0 ? Math.round((emailsReceived / emailsSent) * 1000) / 10 : 0;
1823
+ return {
1824
+ emailsSent, emailsReceived, replyRate, meetingsBooked,
1825
+ leadsCreated, leadsContacted, sequencesActive, sequencesCompleted,
1826
+ };
1827
+ }
1828
+ // -- Agent Budget --
1829
+ /** Get current month's token spend for an agent (in cents, estimated from token counts). */
1830
+ getAgentMonthlySpend(agentSlug) {
1831
+ // Estimate cost from tokens: ~$3/M input, ~$15/M output for Sonnet-class
1832
+ // This is a rough estimate — can be refined with actual pricing
1833
+ const row = this.conn.prepare(`SELECT
1834
+ COALESCE(SUM(input_tokens), 0) as inp,
1835
+ COALESCE(SUM(output_tokens), 0) as out
1836
+ FROM usage_log
1837
+ WHERE session_key LIKE ? AND created_at >= date('now', 'start of month')`).get(`%${agentSlug}%`);
1838
+ // Rough pricing: $3/M input = 0.3 cents/1K, $15/M output = 1.5 cents/1K
1839
+ const inputCents = (row.inp / 1000) * 0.3;
1840
+ const outputCents = (row.out / 1000) * 1.5;
1841
+ return Math.round(inputCents + outputCents);
1842
+ }
1843
+ /** Check if an agent has exceeded its monthly budget. */
1844
+ isOverBudget(agentSlug, budgetCents) {
1845
+ if (!budgetCents || budgetCents <= 0)
1846
+ return false;
1847
+ return this.getAgentMonthlySpend(agentSlug) >= budgetCents;
1848
+ }
1849
+ // -- Config Revisions --
1850
+ /** Snapshot a config file before writing changes. */
1851
+ snapshotConfig(agentSlug, fileName, content, changedBy) {
1852
+ this.conn.prepare(`INSERT INTO config_revisions (agent_slug, file_name, content, changed_by) VALUES (?, ?, ?, ?)`).run(agentSlug, fileName, content, changedBy ?? null);
1853
+ // Keep max 20 revisions per file
1854
+ this.conn.prepare(`DELETE FROM config_revisions WHERE agent_slug = ? AND file_name = ? AND id NOT IN (
1855
+ SELECT id FROM config_revisions WHERE agent_slug = ? AND file_name = ? ORDER BY created_at DESC LIMIT 20
1856
+ )`).run(agentSlug, fileName, agentSlug, fileName);
1857
+ }
1858
+ /** Get revision history for an agent's config files. */
1859
+ getConfigRevisions(agentSlug, fileName, limit = 10) {
1860
+ if (fileName) {
1861
+ return this.conn.prepare(`SELECT id, agent_slug, file_name, length(content) as size_bytes, changed_by, created_at
1862
+ FROM config_revisions WHERE agent_slug = ? AND file_name = ? ORDER BY created_at DESC LIMIT ?`).all(agentSlug, fileName, limit);
1863
+ }
1864
+ return this.conn.prepare(`SELECT id, agent_slug, file_name, length(content) as size_bytes, changed_by, created_at
1865
+ FROM config_revisions WHERE agent_slug = ? ORDER BY created_at DESC LIMIT ?`).all(agentSlug, limit);
1866
+ }
1867
+ /** Get a specific config revision's content. */
1868
+ getConfigRevisionContent(id) {
1869
+ const row = this.conn.prepare('SELECT content FROM config_revisions WHERE id = ?').get(id);
1870
+ return row?.content ?? null;
1871
+ }
1872
+ // ── Salesforce Sync ──────────────────────────────────────────────
1873
+ logSfSync(record) {
1874
+ const result = this.conn.prepare(`INSERT INTO sf_sync_log (local_table, local_id, sf_object_type, sf_id, sync_direction, sync_status, error_message)
1875
+ VALUES (?, ?, ?, ?, ?, ?, ?)`).run(record.localTable, record.localId, record.sfObjectType, record.sfId, record.syncDirection, record.syncStatus ?? 'success', record.errorMessage ?? null);
1876
+ return Number(result.lastInsertRowid);
1877
+ }
1878
+ getSfSyncHistory(opts = {}) {
1879
+ const where = [];
1880
+ const vals = [];
1881
+ if (opts.sfObjectType) {
1882
+ where.push('sf_object_type = ?');
1883
+ vals.push(opts.sfObjectType);
1884
+ }
1885
+ if (opts.syncStatus) {
1886
+ where.push('sync_status = ?');
1887
+ vals.push(opts.syncStatus);
1888
+ }
1889
+ const clause = where.length > 0 ? `WHERE ${where.join(' AND ')}` : '';
1890
+ const limit = Math.min(opts.limit ?? 50, 500);
1891
+ return this.conn.prepare(`SELECT * FROM sf_sync_log ${clause} ORDER BY synced_at DESC LIMIT ?`).all(...vals, limit);
1892
+ }
1893
+ getLeadBySfId(sfId) {
1894
+ return this.conn.prepare('SELECT * FROM leads WHERE sf_id = ?').get(sfId);
1895
+ }
1896
+ getUnsyncedLeads(agentSlug) {
1897
+ const base = `SELECT * FROM leads WHERE sf_id IS NULL AND status != 'opted_out'`;
1898
+ const clause = agentSlug ? ` AND agent_slug = ?` : '';
1899
+ return this.conn.prepare(`${base}${clause} ORDER BY created_at ASC`).all(...(agentSlug ? [agentSlug] : []));
1900
+ }
1901
+ getLeadsModifiedSince(since, agentSlug) {
1902
+ const base = `SELECT * FROM leads WHERE updated_at >= ?`;
1903
+ const clause = agentSlug ? ` AND agent_slug = ?` : '';
1904
+ return this.conn.prepare(`${base}${clause} ORDER BY updated_at ASC`).all(since, ...(agentSlug ? [agentSlug] : []));
1905
+ }
1906
+ // ── Embeddings ──────────────────────────────────────────────────
1907
+ /**
1908
+ * Build the TF-IDF vocabulary from all chunk contents, then backfill
1909
+ * embeddings for any chunks that don't have one yet.
1910
+ * Safe to call repeatedly — skips chunks that already have embeddings.
1911
+ */
1912
+ buildEmbeddings() {
1913
+ // Gather all chunk contents for vocabulary building
1914
+ const rows = this.conn
1915
+ .prepare('SELECT id, content FROM chunks WHERE consolidated = 0')
1916
+ .all();
1917
+ if (rows.length === 0)
1918
+ return { vocabSize: 0, backfilled: 0 };
1919
+ // Build vocabulary from corpus
1920
+ embeddingsModule.buildVocab(rows.map((r) => r.content));
1921
+ if (!embeddingsModule.isReady())
1922
+ return { vocabSize: 0, backfilled: 0 };
1923
+ // Backfill embeddings for chunks that don't have one
1924
+ const missing = this.conn
1925
+ .prepare('SELECT id, content FROM chunks WHERE embedding IS NULL AND consolidated = 0')
1926
+ .all();
1927
+ const updateStmt = this.conn.prepare('UPDATE chunks SET embedding = ? WHERE id = ?');
1928
+ let backfilled = 0;
1929
+ for (const row of missing) {
1930
+ const vec = embeddingsModule.embed(row.content);
1931
+ if (vec) {
1932
+ updateStmt.run(embeddingsModule.serializeEmbedding(vec), row.id);
1933
+ backfilled++;
1934
+ }
1935
+ }
1936
+ return { vocabSize: rows.length, backfilled };
1937
+ }
1938
+ // ── Helpers ───────────────────────────────────────────────────────
1939
+ /**
1940
+ * Delete all chunks, wikilinks, file hash, and access log for a given file.
1941
+ */
1942
+ deleteFileChunks(relPath) {
1943
+ // Delete access_log entries for chunks being removed (prevent orphans)
1944
+ this.conn
1945
+ .prepare('DELETE FROM access_log WHERE chunk_id IN (SELECT id FROM chunks WHERE source_file = ?)')
1946
+ .run(relPath);
1947
+ this.conn.prepare('DELETE FROM chunks WHERE source_file = ?').run(relPath);
1948
+ this.conn.prepare('DELETE FROM wikilinks WHERE source_file = ?').run(relPath);
1949
+ this.conn.prepare('DELETE FROM file_hashes WHERE rel_path = ?').run(relPath);
1950
+ }
1951
+ /**
1952
+ * Sanitize a query for FTS5 syntax.
1953
+ *
1954
+ * Quotes each word and joins with OR to match any word (not all).
1955
+ * This works better for natural language queries.
1956
+ */
1957
+ static sanitizeFtsQuery(query) {
1958
+ const words = query.split(/\s+/).filter((w) => w.length > 0);
1959
+ if (words.length === 0)
1960
+ return '';
1961
+ const quoted = words
1962
+ .map((w) => w.replace(/"/g, ''))
1963
+ .filter((w) => w.length > 0)
1964
+ .map((w) => `"${w}"`);
1965
+ return quoted.join(' OR ');
1966
+ }
1967
+ /**
1968
+ * Parse and index [[wikilinks]] from a file.
1969
+ */
1970
+ indexWikilinks(relPath, filePath) {
1971
+ this.conn
1972
+ .prepare('DELETE FROM wikilinks WHERE source_file = ?')
1973
+ .run(relPath);
1974
+ let content;
1975
+ try {
1976
+ content = readFileSync(filePath, 'utf-8');
1977
+ }
1978
+ catch {
1979
+ return;
1980
+ }
1981
+ const insertStmt = this.conn.prepare('INSERT INTO wikilinks (source_file, target_file, context, link_type) VALUES (?, ?, ?, ?)');
1982
+ for (const line of content.split('\n')) {
1983
+ let match;
1984
+ // Reset regex lastIndex for each line since it's global
1985
+ WIKILINK_RE.lastIndex = 0;
1986
+ while ((match = WIKILINK_RE.exec(line)) !== null) {
1987
+ const target = match[1].trim();
1988
+ const context = line.trim().slice(0, 200);
1989
+ insertStmt.run(relPath, target, context, 'wikilink');
1990
+ }
1991
+ }
1992
+ }
1993
+ /**
1994
+ * Recursively walk a directory for .md files.
1995
+ */
1996
+ walkMdFiles(dir, callback) {
1997
+ let entries;
1998
+ try {
1999
+ entries = readdirSync(dir);
2000
+ }
2001
+ catch {
2002
+ return;
2003
+ }
2004
+ for (const entry of entries) {
2005
+ const fullPath = path.join(dir, entry);
2006
+ let stat;
2007
+ try {
2008
+ stat = statSync(fullPath);
2009
+ }
2010
+ catch {
2011
+ continue;
2012
+ }
2013
+ if (stat.isDirectory()) {
2014
+ this.walkMdFiles(fullPath, callback);
2015
+ }
2016
+ else if (entry.endsWith('.md')) {
2017
+ callback(fullPath);
2018
+ }
2019
+ }
2020
+ }
2021
+ }
2022
+ //# sourceMappingURL=store.js.map