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.
- package/.env.example +44 -0
- package/LICENSE +21 -0
- package/README.md +795 -0
- package/dist/agent/agent-manager.d.ts +69 -0
- package/dist/agent/agent-manager.js +441 -0
- package/dist/agent/assistant.d.ts +225 -0
- package/dist/agent/assistant.js +3888 -0
- package/dist/agent/auto-update.d.ts +32 -0
- package/dist/agent/auto-update.js +186 -0
- package/dist/agent/daily-planner.d.ts +24 -0
- package/dist/agent/daily-planner.js +379 -0
- package/dist/agent/execution-advisor.d.ts +10 -0
- package/dist/agent/execution-advisor.js +272 -0
- package/dist/agent/hooks.d.ts +45 -0
- package/dist/agent/hooks.js +564 -0
- package/dist/agent/insight-engine.d.ts +66 -0
- package/dist/agent/insight-engine.js +225 -0
- package/dist/agent/intent-classifier.d.ts +48 -0
- package/dist/agent/intent-classifier.js +214 -0
- package/dist/agent/link-extractor.d.ts +19 -0
- package/dist/agent/link-extractor.js +90 -0
- package/dist/agent/mcp-bridge.d.ts +62 -0
- package/dist/agent/mcp-bridge.js +435 -0
- package/dist/agent/metacognition.d.ts +66 -0
- package/dist/agent/metacognition.js +221 -0
- package/dist/agent/orchestrator.d.ts +81 -0
- package/dist/agent/orchestrator.js +790 -0
- package/dist/agent/profiles.d.ts +22 -0
- package/dist/agent/profiles.js +91 -0
- package/dist/agent/prompt-cache.d.ts +24 -0
- package/dist/agent/prompt-cache.js +68 -0
- package/dist/agent/prompt-evolver.d.ts +28 -0
- package/dist/agent/prompt-evolver.js +279 -0
- package/dist/agent/role-scaffolds.d.ts +28 -0
- package/dist/agent/role-scaffolds.js +433 -0
- package/dist/agent/safe-restart.d.ts +41 -0
- package/dist/agent/safe-restart.js +150 -0
- package/dist/agent/self-improve.d.ts +66 -0
- package/dist/agent/self-improve.js +1706 -0
- package/dist/agent/session-event-log.d.ts +114 -0
- package/dist/agent/session-event-log.js +233 -0
- package/dist/agent/skill-extractor.d.ts +72 -0
- package/dist/agent/skill-extractor.js +435 -0
- package/dist/agent/source-mods.d.ts +61 -0
- package/dist/agent/source-mods.js +230 -0
- package/dist/agent/source-preflight.d.ts +25 -0
- package/dist/agent/source-preflight.js +100 -0
- package/dist/agent/stall-guard.d.ts +62 -0
- package/dist/agent/stall-guard.js +109 -0
- package/dist/agent/strategic-planner.d.ts +60 -0
- package/dist/agent/strategic-planner.js +352 -0
- package/dist/agent/team-bus.d.ts +89 -0
- package/dist/agent/team-bus.js +556 -0
- package/dist/agent/team-router.d.ts +26 -0
- package/dist/agent/team-router.js +37 -0
- package/dist/agent/tool-loop-detector.d.ts +59 -0
- package/dist/agent/tool-loop-detector.js +242 -0
- package/dist/agent/workflow-runner.d.ts +36 -0
- package/dist/agent/workflow-runner.js +317 -0
- package/dist/agent/workflow-variables.d.ts +16 -0
- package/dist/agent/workflow-variables.js +62 -0
- package/dist/channels/discord-agent-bot.d.ts +101 -0
- package/dist/channels/discord-agent-bot.js +881 -0
- package/dist/channels/discord-bot-manager.d.ts +80 -0
- package/dist/channels/discord-bot-manager.js +262 -0
- package/dist/channels/discord-utils.d.ts +51 -0
- package/dist/channels/discord-utils.js +293 -0
- package/dist/channels/discord.d.ts +12 -0
- package/dist/channels/discord.js +1832 -0
- package/dist/channels/slack-agent-bot.d.ts +73 -0
- package/dist/channels/slack-agent-bot.js +320 -0
- package/dist/channels/slack-bot-manager.d.ts +66 -0
- package/dist/channels/slack-bot-manager.js +236 -0
- package/dist/channels/slack-utils.d.ts +39 -0
- package/dist/channels/slack-utils.js +189 -0
- package/dist/channels/slack.d.ts +11 -0
- package/dist/channels/slack.js +196 -0
- package/dist/channels/telegram.d.ts +10 -0
- package/dist/channels/telegram.js +235 -0
- package/dist/channels/webhook.d.ts +9 -0
- package/dist/channels/webhook.js +78 -0
- package/dist/channels/whatsapp.d.ts +11 -0
- package/dist/channels/whatsapp.js +181 -0
- package/dist/cli/chat.d.ts +14 -0
- package/dist/cli/chat.js +220 -0
- package/dist/cli/cron.d.ts +17 -0
- package/dist/cli/cron.js +552 -0
- package/dist/cli/dashboard.d.ts +15 -0
- package/dist/cli/dashboard.js +17677 -0
- package/dist/cli/index.d.ts +3 -0
- package/dist/cli/index.js +2474 -0
- package/dist/cli/routes/delegations.d.ts +19 -0
- package/dist/cli/routes/delegations.js +154 -0
- package/dist/cli/routes/digest.d.ts +17 -0
- package/dist/cli/routes/digest.js +375 -0
- package/dist/cli/routes/goals.d.ts +14 -0
- package/dist/cli/routes/goals.js +258 -0
- package/dist/cli/routes/workflows.d.ts +18 -0
- package/dist/cli/routes/workflows.js +97 -0
- package/dist/cli/setup.d.ts +8 -0
- package/dist/cli/setup.js +619 -0
- package/dist/cli/tunnel.d.ts +35 -0
- package/dist/cli/tunnel.js +141 -0
- package/dist/config.d.ts +145 -0
- package/dist/config.js +278 -0
- package/dist/events/bus.d.ts +43 -0
- package/dist/events/bus.js +136 -0
- package/dist/gateway/cron-scheduler.d.ts +166 -0
- package/dist/gateway/cron-scheduler.js +1767 -0
- package/dist/gateway/delivery-queue.d.ts +30 -0
- package/dist/gateway/delivery-queue.js +110 -0
- package/dist/gateway/heartbeat-scheduler.d.ts +99 -0
- package/dist/gateway/heartbeat-scheduler.js +1298 -0
- package/dist/gateway/heartbeat.d.ts +3 -0
- package/dist/gateway/heartbeat.js +3 -0
- package/dist/gateway/lanes.d.ts +24 -0
- package/dist/gateway/lanes.js +76 -0
- package/dist/gateway/notifications.d.ts +29 -0
- package/dist/gateway/notifications.js +75 -0
- package/dist/gateway/router.d.ts +210 -0
- package/dist/gateway/router.js +1330 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +1015 -0
- package/dist/memory/chunker.d.ts +28 -0
- package/dist/memory/chunker.js +226 -0
- package/dist/memory/consolidation.d.ts +44 -0
- package/dist/memory/consolidation.js +171 -0
- package/dist/memory/context-assembler.d.ts +50 -0
- package/dist/memory/context-assembler.js +149 -0
- package/dist/memory/embeddings.d.ts +38 -0
- package/dist/memory/embeddings.js +180 -0
- package/dist/memory/graph-store.d.ts +66 -0
- package/dist/memory/graph-store.js +613 -0
- package/dist/memory/mmr.d.ts +21 -0
- package/dist/memory/mmr.js +75 -0
- package/dist/memory/search.d.ts +26 -0
- package/dist/memory/search.js +67 -0
- package/dist/memory/store.d.ts +530 -0
- package/dist/memory/store.js +2022 -0
- package/dist/security/integrity.d.ts +24 -0
- package/dist/security/integrity.js +58 -0
- package/dist/security/patterns.d.ts +34 -0
- package/dist/security/patterns.js +110 -0
- package/dist/security/scanner.d.ts +32 -0
- package/dist/security/scanner.js +263 -0
- package/dist/tools/admin-tools.d.ts +12 -0
- package/dist/tools/admin-tools.js +1278 -0
- package/dist/tools/external-tools.d.ts +11 -0
- package/dist/tools/external-tools.js +1327 -0
- package/dist/tools/goal-tools.d.ts +9 -0
- package/dist/tools/goal-tools.js +159 -0
- package/dist/tools/mcp-server.d.ts +13 -0
- package/dist/tools/mcp-server.js +141 -0
- package/dist/tools/memory-tools.d.ts +10 -0
- package/dist/tools/memory-tools.js +568 -0
- package/dist/tools/session-tools.d.ts +6 -0
- package/dist/tools/session-tools.js +146 -0
- package/dist/tools/shared.d.ts +216 -0
- package/dist/tools/shared.js +340 -0
- package/dist/tools/team-tools.d.ts +6 -0
- package/dist/tools/team-tools.js +447 -0
- package/dist/tools/tool-meta.d.ts +34 -0
- package/dist/tools/tool-meta.js +133 -0
- package/dist/tools/vault-tools.d.ts +8 -0
- package/dist/tools/vault-tools.js +457 -0
- package/dist/types.d.ts +716 -0
- package/dist/types.js +16 -0
- package/dist/vault-migrations/0001-add-execution-framework.d.ts +10 -0
- package/dist/vault-migrations/0001-add-execution-framework.js +47 -0
- package/dist/vault-migrations/0002-add-agentic-communication.d.ts +12 -0
- package/dist/vault-migrations/0002-add-agentic-communication.js +79 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.d.ts +11 -0
- package/dist/vault-migrations/0003-update-execution-pipeline-narration.js +73 -0
- package/dist/vault-migrations/helpers.d.ts +14 -0
- package/dist/vault-migrations/helpers.js +44 -0
- package/dist/vault-migrations/runner.d.ts +14 -0
- package/dist/vault-migrations/runner.js +139 -0
- package/dist/vault-migrations/types.d.ts +42 -0
- package/dist/vault-migrations/types.js +9 -0
- package/install.sh +320 -0
- package/package.json +84 -0
- package/scripts/postinstall.js +125 -0
- package/vault/00-System/AGENTS.md +66 -0
- package/vault/00-System/CRON.md +71 -0
- package/vault/00-System/HEARTBEAT.md +58 -0
- package/vault/00-System/MEMORY.md +16 -0
- package/vault/00-System/SOUL.md +96 -0
- package/vault/05-Tasks/TASKS.md +19 -0
- package/vault/06-Templates/_Daily-Template.md +28 -0
- 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
|