memhouse 0.0.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +59 -3
  3. package/bin/memhouse.js +487 -0
  4. package/cache.js +850 -0
  5. package/ch-ingest.js +329 -0
  6. package/ch-schema.js +152 -0
  7. package/clickhouse.js +87 -0
  8. package/editors/antigravity.js +1019 -0
  9. package/editors/base.js +321 -0
  10. package/editors/claude.js +386 -0
  11. package/editors/codebuff.js +338 -0
  12. package/editors/codex.js +537 -0
  13. package/editors/commandcode.js +166 -0
  14. package/editors/copilot-jetbrains.js +207 -0
  15. package/editors/copilot.js +260 -0
  16. package/editors/cursor-agent.js +202 -0
  17. package/editors/cursor.js +438 -0
  18. package/editors/gemini.js +231 -0
  19. package/editors/goose.js +358 -0
  20. package/editors/gsd.js +366 -0
  21. package/editors/index.js +179 -0
  22. package/editors/kiro.js +315 -0
  23. package/editors/opencode.js +374 -0
  24. package/editors/vscode.js +413 -0
  25. package/editors/windsurf.js +634 -0
  26. package/editors/zed.js +213 -0
  27. package/index.js +424 -2
  28. package/mcp-server.js +294 -0
  29. package/mem-house/DESIGN.md +119 -0
  30. package/mem-house/delivery/AGENT-INSTALL.md +75 -0
  31. package/mem-house/delivery/PROMPT.md +42 -0
  32. package/mem-house/delivery/install.sh +181 -0
  33. package/mem-house/delivery/kernel-install.md +76 -0
  34. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  35. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  36. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  37. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  38. package/mem-house/rls.sql +17 -0
  39. package/mem-house/schema.sql +145 -0
  40. package/mem-house/server/queries.js +878 -0
  41. package/mem-house/server/server.js +228 -0
  42. package/mem-house/shipper/ship.js +402 -0
  43. package/misc/syntax-check.js +38 -0
  44. package/package.json +56 -7
  45. package/pricing.js +88 -0
  46. package/pricing.json +992 -0
  47. package/public/assets/index-BsgjOSTv.js +75 -0
  48. package/public/assets/index-DV6ONi_F.css +2 -0
  49. package/public/index.html +16 -0
  50. package/relay-client.js +306 -0
  51. package/relay-server.js +577 -0
  52. package/server.js +860 -0
  53. package/share-image.js +367 -0
  54. package/ui/eslint.config.js +29 -0
  55. package/ui/index.html +15 -0
  56. package/ui/package-lock.json +4229 -0
  57. package/ui/package.json +38 -0
  58. package/ui/src/App.jsx +364 -0
  59. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  60. package/ui/src/components/AiAuditCard.jsx +254 -0
  61. package/ui/src/components/AnimatedLoader.jsx +14 -0
  62. package/ui/src/components/AnimatedLogo.jsx +96 -0
  63. package/ui/src/components/ChatSidebar.jsx +226 -0
  64. package/ui/src/components/DateRangePicker.jsx +104 -0
  65. package/ui/src/components/EditorDot.jsx +11 -0
  66. package/ui/src/components/EditorIcon.jsx +117 -0
  67. package/ui/src/components/KpiCard.jsx +9 -0
  68. package/ui/src/components/LiveFeed.jsx +128 -0
  69. package/ui/src/components/LoginScreen.jsx +81 -0
  70. package/ui/src/components/MessageRenderer.jsx +148 -0
  71. package/ui/src/components/PageHeader.jsx +11 -0
  72. package/ui/src/components/SectionTitle.jsx +3 -0
  73. package/ui/src/components/ShareModal.jsx +305 -0
  74. package/ui/src/components/TokenTimeline.jsx +258 -0
  75. package/ui/src/hooks/useLive.jsx +15 -0
  76. package/ui/src/index.css +83 -0
  77. package/ui/src/lib/api.js +343 -0
  78. package/ui/src/lib/constants.js +88 -0
  79. package/ui/src/lib/theme.jsx +23 -0
  80. package/ui/src/main.jsx +16 -0
  81. package/ui/src/pages/Artifacts.jsx +575 -0
  82. package/ui/src/pages/Compare.jsx +232 -0
  83. package/ui/src/pages/CostAnalysis.jsx +354 -0
  84. package/ui/src/pages/Dashboard.jsx +529 -0
  85. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  86. package/ui/src/pages/GSD.jsx +726 -0
  87. package/ui/src/pages/MCPs.jsx +744 -0
  88. package/ui/src/pages/ProjectDetail.jsx +325 -0
  89. package/ui/src/pages/Projects.jsx +249 -0
  90. package/ui/src/pages/RelayDashboard.jsx +590 -0
  91. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  92. package/ui/src/pages/Sessions.jsx +535 -0
  93. package/ui/src/pages/Settings.jsx +241 -0
  94. package/ui/src/pages/SqlViewer.jsx +344 -0
  95. package/ui/src/pages/Subscriptions.jsx +411 -0
  96. package/ui/vite.config.js +18 -0
package/ch-ingest.js ADDED
@@ -0,0 +1,329 @@
1
+ // ClickHouse ingest layer — async, batched port of cache.js's scan/analyze path
2
+ // (initDb + analyzeAndStore + scanAll/scanAllAsync + cacheGSDProjects).
3
+ //
4
+ // The message-extraction and stats logic is copied verbatim from cache.js so the
5
+ // rows are byte-for-byte comparable to the SQLite output; only the storage calls
6
+ // change (synchronous better-sqlite3 statements → batched ClickHouse inserts).
7
+ //
8
+ // Batching: analyzeChat() computes a chat's rows in memory; the scanner accumulates
9
+ // rows across chats and flushes them to ClickHouse in chunks (one INSERT per N rows),
10
+ // because each ClickHouse insert is an HTTP round-trip — per-row inserts would crawl.
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const { getClient } = require('./clickhouse');
15
+ const { initSchema } = require('./ch-schema');
16
+ const { getAllChats, getMessages, resetCaches } = require('./editors');
17
+
18
+ // ── path/source normalization (identical to cache.js) ─────────────────────────
19
+ function normalizeFolder(folder) {
20
+ if (!folder) return folder;
21
+ folder = folder.replace(/^file:\/\//, '');
22
+ if (process.platform === 'win32') {
23
+ try {
24
+ folder = path.resolve(folder);
25
+ try { folder = fs.realpathSync.native(folder); }
26
+ catch { if (/^[a-zA-Z]:/.test(folder)) folder = folder[0].toUpperCase() + folder.slice(1); }
27
+ folder = folder.replace(/\\$/, '');
28
+ if (/^[A-Z]:$/.test(folder)) folder += '\\';
29
+ folder = folder.replace(/\\/g, '/');
30
+ } catch { folder = folder.replace(/\\/g, '/'); }
31
+ } else {
32
+ try { folder = fs.realpathSync(folder); } catch { /* path gone, keep as-is */ }
33
+ }
34
+ return folder;
35
+ }
36
+
37
+ function normalizeEditorSource(source) {
38
+ if (source === 'windsurf') return 'devin';
39
+ if (source === 'windsurf-next') return 'devin-next';
40
+ return source;
41
+ }
42
+
43
+ // Coerce to an integer for Int64 columns. Some adapters (e.g. Codex) emit
44
+ // fractional-millisecond timestamps; JSONEachRow rejects a float for Int64.
45
+ // null/undefined pass through (Nullable columns). SQLite tolerated this via
46
+ // dynamic typing; ClickHouse needs it clean.
47
+ const int = (v) => (v == null ? null : Math.round(v));
48
+
49
+ const META_KEYS = ['_type', '_dbPath', '_filePath', '_port', '_csrf', '_https',
50
+ '_rootBlobId', '_dataType', '_rawSource', '_originator', '_cliVersion', '_modelProvider'];
51
+
52
+ function pickMeta(chat) {
53
+ const o = {};
54
+ for (const k of META_KEYS) o[k] = chat[k];
55
+ return JSON.stringify(o);
56
+ }
57
+
58
+ // Compute a chat's stats + message/tool rows. Mirrors cache.js analyzeAndStore.
59
+ // Returns null when the chat should be skipped (encrypted or no messages).
60
+ function analyzeChat(chat) {
61
+ if (chat.encrypted) return null;
62
+ let messages;
63
+ try { messages = getMessages(chat); } catch { return null; }
64
+ if (!messages || messages.length === 0) return null;
65
+
66
+ const stats = {
67
+ total: messages.length, user: 0, assistant: 0, tool: 0, system: 0,
68
+ toolCalls: [], models: [],
69
+ userChars: 0, assistantChars: 0,
70
+ inputTokens: 0, outputTokens: 0, cacheRead: 0, cacheWrite: 0,
71
+ };
72
+
73
+ const chatTs = chat.lastUpdatedAt || chat.createdAt || null;
74
+ const chatSource = normalizeEditorSource(chat.source);
75
+ const msgRows = [];
76
+ const toolRows = [];
77
+ let toolIdx = 0;
78
+ let seq = 0;
79
+
80
+ for (const msg of messages) {
81
+ const text = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content);
82
+
83
+ if (msg.role === 'user') {
84
+ stats.user++;
85
+ stats.userChars += text.length;
86
+ } else if (msg.role === 'assistant') {
87
+ stats.assistant++;
88
+ stats.assistantChars += text.length;
89
+ if (msg._toolCalls && msg._toolCalls.length > 0) {
90
+ for (const tc of msg._toolCalls) {
91
+ stats.toolCalls.push(tc.name);
92
+ toolRows.push({
93
+ chat_id: chat.composerId, idx: toolIdx++, tool_name: tc.name,
94
+ args_json: JSON.stringify(tc.args || {}), source: chatSource,
95
+ folder: chat.folder || null, timestamp: int(chatTs),
96
+ });
97
+ }
98
+ } else {
99
+ const toolMatches = text.match(/\[tool-call: ([^\]]+)\]/g);
100
+ if (toolMatches) {
101
+ for (const m of toolMatches) {
102
+ const name = (m.match(/\[tool-call: ([^(]+)/)?.[1] || 'unknown').trim();
103
+ stats.toolCalls.push(name);
104
+ toolRows.push({
105
+ chat_id: chat.composerId, idx: toolIdx++, tool_name: name,
106
+ args_json: '{}', source: chatSource, folder: chat.folder || null, timestamp: int(chatTs),
107
+ });
108
+ }
109
+ }
110
+ }
111
+ if (msg._inputTokens) stats.inputTokens += msg._inputTokens;
112
+ if (msg._outputTokens) stats.outputTokens += msg._outputTokens;
113
+ if (msg._cacheRead) stats.cacheRead += msg._cacheRead;
114
+ if (msg._cacheWrite) stats.cacheWrite += msg._cacheWrite;
115
+ } else if (msg.role === 'tool') {
116
+ stats.tool++;
117
+ } else if (msg.role === 'system') {
118
+ stats.system++;
119
+ }
120
+ if (msg._model) stats.models.push(msg._model);
121
+
122
+ const storedContent = text.length > 50000 ? text.substring(0, 50000) : text;
123
+ msgRows.push({
124
+ chat_id: chat.composerId, seq: seq++, role: msg.role, content: storedContent,
125
+ model: msg._model || null,
126
+ input_tokens: int(msg._inputTokens || null), output_tokens: int(msg._outputTokens || null),
127
+ cache_read: int(msg._cacheRead || null), cache_write: int(msg._cacheWrite || null),
128
+ });
129
+ }
130
+
131
+ const statRow = {
132
+ chat_id: chat.composerId,
133
+ total_messages: stats.total, user_messages: stats.user, assistant_messages: stats.assistant,
134
+ tool_messages: stats.tool, system_messages: stats.system,
135
+ tool_calls: JSON.stringify(stats.toolCalls), models: JSON.stringify(stats.models),
136
+ total_user_chars: int(stats.userChars), total_assistant_chars: int(stats.assistantChars),
137
+ total_input_tokens: int(stats.inputTokens), total_output_tokens: int(stats.outputTokens),
138
+ total_cache_read: int(stats.cacheRead), total_cache_write: int(stats.cacheWrite),
139
+ };
140
+
141
+ return { statRow, msgRows, toolRows, bubbleCount: messages.length };
142
+ }
143
+
144
+ // ── insert helpers ────────────────────────────────────────────────────────────
145
+ async function insertChunked(table, rows, chunkSize) {
146
+ if (!rows.length) return;
147
+ const client = getClient();
148
+ for (let i = 0; i < rows.length; i += chunkSize) {
149
+ await client.insert({ table, values: rows.slice(i, i + chunkSize), format: 'JSONEachRow' });
150
+ }
151
+ }
152
+
153
+ async function setMeta(key, value) {
154
+ await getClient().insert({
155
+ table: 'meta', values: [{ key, value: String(value), _ver: Date.now() }], format: 'JSONEachRow',
156
+ });
157
+ }
158
+
159
+ // Ensure database + schema exist. Replaces cache.js initDb().
160
+ async function initDb() {
161
+ const { ensureDatabase } = require('./clickhouse');
162
+ await ensureDatabase();
163
+ await initSchema(getClient());
164
+ }
165
+
166
+ // ── scan ──────────────────────────────────────────────────────────────────────
167
+ // Async, batched equivalent of cache.js scanAll/scanAllAsync. onProgress receives
168
+ // { scanned, analyzed, skipped, total } like the SQLite version (drives SSE later).
169
+ async function scanAllAsync(onProgress, opts = {}) {
170
+ const client = getClient();
171
+ const force = opts.force || false;
172
+ if (force || opts.resetCaches) resetCaches();
173
+
174
+ const chats = opts.chats || getAllChats();
175
+ const total = chats.length;
176
+ let scanned = 0, analyzed = 0, skipped = 0;
177
+
178
+ // Existing-cache map (id → {ts, bc}) + which chats already have a stats row.
179
+ const existing = {};
180
+ const statTotals = new Map();
181
+ const msgCounts = new Map();
182
+ {
183
+ const rs = await client.query({
184
+ query: 'SELECT id, last_updated_at AS ts, bubble_count AS bc FROM chats', format: 'JSONEachRow',
185
+ });
186
+ for (const r of await rs.json()) existing[r.id] = { ts: r.ts, bc: r.bc };
187
+ const rs2 = await client.query({ query: 'SELECT chat_id, total_messages FROM chat_stats', format: 'JSONEachRow' });
188
+ for (const r of await rs2.json()) statTotals.set(r.chat_id, r.total_messages);
189
+ // Crash recovery: a kill between the clear-deletes and the inserts — or
190
+ // mid-flush on a large transcript — leaves fresh-looking chats/chat_stats
191
+ // rows with a missing or PARTIAL transcript. The skip below must compare the
192
+ // actual message row count to chat_stats.total_messages, not mere existence.
193
+ const rs3 = await client.query({ query: 'SELECT chat_id, count() AS n FROM messages GROUP BY chat_id', format: 'JSONEachRow' });
194
+ for (const r of await rs3.json()) msgCounts.set(r.chat_id, r.n);
195
+ }
196
+
197
+ for (const chat of chats) chat.folder = normalizeFolder(chat.folder);
198
+
199
+ const nowVer = Date.now();
200
+ const chatRows = [];
201
+ const statRows = [];
202
+ const msgRows = [];
203
+ const toolRows = [];
204
+ const clearIds = []; // chats whose old messages/tool_calls must be deleted (re-analysis)
205
+
206
+ if (onProgress) onProgress({ scanned: 0, analyzed: 0, skipped: 0, total });
207
+
208
+ for (const chat of chats) {
209
+ scanned++;
210
+ const chatTs = chat.lastUpdatedAt || chat.createdAt || 0;
211
+ const chatBc = chat.bubbleCount || 0;
212
+
213
+ // Skip if already cached, not newer, bubble count hasn't grown, and stats exist.
214
+ const cached = existing[chat.composerId];
215
+ let didAnalyze = false;
216
+ let bubbleCount = chatBc;
217
+
218
+ if (!force && cached && cached.ts && cached.ts >= chatTs && cached.bc >= chatBc
219
+ && statTotals.has(chat.composerId)
220
+ && (msgCounts.get(chat.composerId) || 0) === statTotals.get(chat.composerId)) {
221
+ skipped++;
222
+ } else if (!chat.encrypted && (chat.name || chat.bubbleCount > 0)) {
223
+ const res = analyzeChat(chat);
224
+ if (res) {
225
+ statRows.push({ ...res.statRow, analyzed_at: nowVer, _ver: nowVer });
226
+ for (const m of res.msgRows) msgRows.push(m);
227
+ for (const t of res.toolRows) toolRows.push(t);
228
+ bubbleCount = res.bubbleCount;
229
+ if (existing[chat.composerId] || statTotals.has(chat.composerId)) clearIds.push(chat.composerId);
230
+ didAnalyze = true;
231
+ analyzed++;
232
+ } else {
233
+ skipped++;
234
+ }
235
+ } else {
236
+ skipped++;
237
+ }
238
+
239
+ chatRows.push({
240
+ id: chat.composerId,
241
+ source: normalizeEditorSource(chat.source),
242
+ name: chat.name || null,
243
+ mode: chat.mode || null,
244
+ folder: chat.folder || null,
245
+ created_at: int(chat.createdAt || null),
246
+ last_updated_at: int(chat.lastUpdatedAt || null),
247
+ encrypted: chat.encrypted ? 1 : 0,
248
+ bubble_count: int(bubbleCount),
249
+ _meta: pickMeta(chat),
250
+ _ver: nowVer,
251
+ });
252
+
253
+ if (onProgress) onProgress({ scanned, analyzed, skipped, total });
254
+ await new Promise((r) => setImmediate(r)); // yield so SSE progress flushes
255
+ }
256
+
257
+ // Clear superseded message/tool rows for re-analyzed chats (lightweight delete).
258
+ // Empty on a first scan, so the common path is insert-only.
259
+ if (clearIds.length) {
260
+ await client.command({
261
+ query: 'DELETE FROM messages WHERE chat_id IN {ids:Array(String)}',
262
+ query_params: { ids: clearIds },
263
+ });
264
+ await client.command({
265
+ query: 'DELETE FROM tool_calls WHERE chat_id IN {ids:Array(String)}',
266
+ query_params: { ids: clearIds },
267
+ });
268
+ }
269
+
270
+ await insertChunked('chats', chatRows, 5000);
271
+ await insertChunked('chat_stats', statRows, 5000);
272
+ await insertChunked('messages', msgRows, 1000);
273
+ await insertChunked('tool_calls', toolRows, 5000);
274
+
275
+ await setMeta('last_scan', nowVer);
276
+ await setMeta('total_chats', total);
277
+
278
+ await cacheGSDProjects();
279
+
280
+ return { total, analyzed, skipped };
281
+ }
282
+
283
+ // ── GSD (async port of cache.js cacheGSDProjects) ───────────────────────────────
284
+ const gsd = require('./editors/gsd');
285
+
286
+ async function cacheGSDProjects() {
287
+ const client = getClient();
288
+ const rs = await client.query({
289
+ query: 'SELECT DISTINCT folder FROM chats WHERE folder IS NOT NULL', format: 'JSONEachRow',
290
+ });
291
+ const knownFolders = (await rs.json()).map((r) => r.folder);
292
+
293
+ const projects = gsd.getGSDProjects(knownFolders);
294
+ const nowVer = Date.now();
295
+ const projectRows = [];
296
+ const phaseRows = [];
297
+ const clearFolders = [];
298
+
299
+ for (const p of projects) {
300
+ projectRows.push({
301
+ folder: p.folder, name: p.name ?? null, description: p.description ?? null,
302
+ milestone: p.milestone ?? null, total_phases: int(p.totalPhases || 0),
303
+ completed_phases: int(p.completedPhases || 0), active_phase: p.activePhase ?? null,
304
+ todos: int(p.todos || 0), backlog: int(p.backlog || 0), notes: int(p.notes || 0),
305
+ last_modified: int(p.lastModified ?? null), scanned_at: nowVer, _ver: nowVer,
306
+ });
307
+ clearFolders.push(p.folder);
308
+ for (const ph of gsd.getGSDPhases(p.folder)) {
309
+ phaseRows.push({
310
+ id: `${p.folder}::${ph.phaseDir}`, folder: p.folder,
311
+ phase_number: int(ph.number || 0), phase_name: ph.name ?? null, status: ph.status ?? null,
312
+ total_tasks: int(ph.tasks.total || 0), completed_tasks: int(ph.tasks.completed || 0),
313
+ has_plan: ph.hasPlan ? 1 : 0, has_research: ph.hasResearch ? 1 : 0,
314
+ has_verification: ph.hasVerification ? 1 : 0, last_modified: int(ph.lastModified ?? null),
315
+ });
316
+ }
317
+ }
318
+
319
+ if (clearFolders.length) {
320
+ await client.command({
321
+ query: 'DELETE FROM gsd_phases WHERE folder IN {folders:Array(String)}',
322
+ query_params: { folders: clearFolders },
323
+ });
324
+ }
325
+ await insertChunked('gsd_projects', projectRows, 5000);
326
+ await insertChunked('gsd_phases', phaseRows, 5000);
327
+ }
328
+
329
+ module.exports = { initDb, scanAllAsync, analyzeChat, cacheGSDProjects, normalizeFolder, normalizeEditorSource };
package/ch-schema.js ADDED
@@ -0,0 +1,152 @@
1
+ // ClickHouse schema for the analytics cache — the replacement for the embedded
2
+ // SQLite schema in cache.js (initDb / CREATE TABLE ...).
3
+ //
4
+ // Design notes (SQLite → ClickHouse):
5
+ // - Timestamps stay as epoch-ms Int64 (exactly what the adapters + JS expect),
6
+ // Nullable where SQLite allowed NULL.
7
+ // - Tables that SQLite *upserted* (INSERT OR REPLACE: chats, chat_stats, meta,
8
+ // gsd_projects) become ReplacingMergeTree(_ver): re-ingest appends a row with a
9
+ // higher _ver, reads collapse to the latest. The client sets `final=1` so plain
10
+ // SELECTs already see the deduplicated (latest) row.
11
+ // - Tables that SQLite rewrote via DELETE-then-INSERT per chat (messages,
12
+ // tool_calls, gsd_phases) are plain MergeTree; the ingest layer clears a chat's
13
+ // old rows with a lightweight DELETE before re-inserting (only on re-analysis;
14
+ // a first scan is insert-only).
15
+ // - SQLite AUTOINCREMENT ids are dropped; ordering is carried by explicit columns
16
+ // (messages.seq, tool_calls.idx) that the ingest layer assigns.
17
+
18
+ const SCHEMA_VERSION = 1; // bump when any DDL below changes → triggers drop+recreate
19
+
20
+ // name → CREATE TABLE body. Order matters only for drop/create symmetry.
21
+ const TABLES = {
22
+ chats: `(
23
+ id String,
24
+ source String,
25
+ name Nullable(String),
26
+ mode Nullable(String),
27
+ folder Nullable(String),
28
+ created_at Nullable(Int64),
29
+ last_updated_at Nullable(Int64),
30
+ encrypted UInt8 DEFAULT 0,
31
+ bubble_count Int64 DEFAULT 0,
32
+ _meta String DEFAULT '{}',
33
+ _ver UInt64
34
+ ) ENGINE = ReplacingMergeTree(_ver) ORDER BY id`,
35
+
36
+ chat_stats: `(
37
+ chat_id String,
38
+ total_messages Int64 DEFAULT 0,
39
+ user_messages Int64 DEFAULT 0,
40
+ assistant_messages Int64 DEFAULT 0,
41
+ tool_messages Int64 DEFAULT 0,
42
+ system_messages Int64 DEFAULT 0,
43
+ tool_calls String DEFAULT '[]',
44
+ models String DEFAULT '[]',
45
+ total_user_chars Int64 DEFAULT 0,
46
+ total_assistant_chars Int64 DEFAULT 0,
47
+ total_input_tokens Int64 DEFAULT 0,
48
+ total_output_tokens Int64 DEFAULT 0,
49
+ total_cache_read Int64 DEFAULT 0,
50
+ total_cache_write Int64 DEFAULT 0,
51
+ analyzed_at Int64,
52
+ _ver UInt64
53
+ ) ENGINE = ReplacingMergeTree(_ver) ORDER BY chat_id`,
54
+
55
+ messages: `(
56
+ chat_id String,
57
+ seq Int64,
58
+ role String,
59
+ content String,
60
+ model Nullable(String),
61
+ input_tokens Nullable(Int64),
62
+ output_tokens Nullable(Int64),
63
+ cache_read Nullable(Int64),
64
+ cache_write Nullable(Int64)
65
+ ) ENGINE = MergeTree ORDER BY (chat_id, seq)`,
66
+
67
+ tool_calls: `(
68
+ chat_id String,
69
+ idx Int64,
70
+ tool_name String,
71
+ args_json String DEFAULT '{}',
72
+ source Nullable(String),
73
+ folder Nullable(String),
74
+ timestamp Nullable(Int64)
75
+ ) ENGINE = MergeTree ORDER BY (chat_id, idx)`,
76
+
77
+ meta: `(
78
+ key String,
79
+ value String,
80
+ _ver UInt64
81
+ ) ENGINE = ReplacingMergeTree(_ver) ORDER BY key`,
82
+
83
+ gsd_projects: `(
84
+ folder String,
85
+ name Nullable(String),
86
+ description Nullable(String),
87
+ milestone Nullable(String),
88
+ total_phases Int64 DEFAULT 0,
89
+ completed_phases Int64 DEFAULT 0,
90
+ active_phase Nullable(String),
91
+ todos Int64 DEFAULT 0,
92
+ backlog Int64 DEFAULT 0,
93
+ notes Int64 DEFAULT 0,
94
+ last_modified Nullable(Int64),
95
+ scanned_at Int64,
96
+ _ver UInt64
97
+ ) ENGINE = ReplacingMergeTree(_ver) ORDER BY folder`,
98
+
99
+ gsd_phases: `(
100
+ id String,
101
+ folder String,
102
+ phase_number Int64,
103
+ phase_name Nullable(String),
104
+ status Nullable(String),
105
+ total_tasks Int64 DEFAULT 0,
106
+ completed_tasks Int64 DEFAULT 0,
107
+ has_plan UInt8 DEFAULT 0,
108
+ has_research UInt8 DEFAULT 0,
109
+ has_verification UInt8 DEFAULT 0,
110
+ last_modified Nullable(Int64)
111
+ ) ENGINE = MergeTree ORDER BY (folder, phase_number)`,
112
+ };
113
+
114
+ async function getStoredVersion(client) {
115
+ try {
116
+ // Check existence first so a fresh DB doesn't log a scary UNKNOWN_TABLE error.
117
+ const ex = await client.query({
118
+ query: "SELECT 1 FROM system.tables WHERE database = currentDatabase() AND name = 'meta' LIMIT 1",
119
+ format: 'JSONEachRow',
120
+ });
121
+ if ((await ex.json()).length === 0) return null;
122
+ const rs = await client.query({
123
+ query: "SELECT value FROM meta WHERE key = 'schema_version' LIMIT 1",
124
+ format: 'JSONEachRow',
125
+ });
126
+ const rows = await rs.json();
127
+ return rows.length ? parseInt(rows[0].value, 10) : null;
128
+ } catch {
129
+ return null; // treat any lookup failure as "no version yet"
130
+ }
131
+ }
132
+
133
+ // Create the tables if needed; if a stored schema version mismatches, drop and
134
+ // recreate everything (the ClickHouse equivalent of SQLite wiping the .db file).
135
+ async function initSchema(client) {
136
+ const stored = await getStoredVersion(client);
137
+ if (stored !== null && stored !== SCHEMA_VERSION) {
138
+ for (const name of Object.keys(TABLES)) {
139
+ await client.command({ query: `DROP TABLE IF EXISTS ${name}` });
140
+ }
141
+ }
142
+ for (const [name, body] of Object.entries(TABLES)) {
143
+ await client.command({ query: `CREATE TABLE IF NOT EXISTS ${name} ${body}` });
144
+ }
145
+ await client.insert({
146
+ table: 'meta',
147
+ values: [{ key: 'schema_version', value: String(SCHEMA_VERSION), _ver: Date.now() }],
148
+ format: 'JSONEachRow',
149
+ });
150
+ }
151
+
152
+ module.exports = { SCHEMA_VERSION, TABLES, initSchema, getStoredVersion };
package/clickhouse.js ADDED
@@ -0,0 +1,87 @@
1
+ // Endpoint-agnostic ClickHouse connection for the analytics cache.
2
+ //
3
+ // The same code runs against a local clickhouse-server, ClickHouse Cloud, or an
4
+ // embedded/kernel-hosted instance — only the CLICKHOUSE_* env vars change. This is
5
+ // the replacement for the embedded SQLite cache (better-sqlite3 / ~/.agentlytics/
6
+ // cache.db): storage is now a real ClickHouse database reachable anywhere.
7
+ //
8
+ // Config resolution: process.env first, then a local `.env` file (dev convenience,
9
+ // gitignored), then local-server defaults. No dotenv dependency — tiny inline loader.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { createClient } = require('@clickhouse/client');
14
+
15
+ // --- Minimal .env loader (no dependency). Does not override already-set env. ---
16
+ function loadDotEnv() {
17
+ try {
18
+ const raw = fs.readFileSync(path.join(__dirname, '.env'), 'utf-8');
19
+ for (const line of raw.split('\n')) {
20
+ const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*?)\s*$/);
21
+ if (!m) continue; // skip blanks and `# comments`
22
+ const [, key, rawVal] = m;
23
+ let val = rawVal;
24
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
25
+ val = val.slice(1, -1);
26
+ }
27
+ if (process.env[key] === undefined) process.env[key] = val;
28
+ }
29
+ } catch { /* no .env — rely on real env / defaults */ }
30
+ }
31
+ loadDotEnv();
32
+
33
+ const config = {
34
+ url: process.env.CLICKHOUSE_URL || 'http://localhost:8123',
35
+ username: process.env.CLICKHOUSE_USER || 'default',
36
+ password: process.env.CLICKHOUSE_PASSWORD || '',
37
+ database: process.env.CLICKHOUSE_DATABASE || 'agentlytics',
38
+ };
39
+
40
+ let _client = null;
41
+
42
+ // Lazily create a singleton client. The client is connectionless (HTTP), so this
43
+ // is cheap; we reuse it to share the keep-alive pool.
44
+ function getClient() {
45
+ if (_client) return _client;
46
+ _client = createClient({
47
+ url: config.url,
48
+ username: config.username,
49
+ password: config.password,
50
+ database: config.database,
51
+ request_timeout: 60000, // cloud instances can cold-start; don't cut queries short
52
+ clickhouse_settings: {
53
+ // Make ReplacingMergeTree collapse duplicates at read time so the cache
54
+ // upsert pattern (append-new-version) reads back deduplicated.
55
+ final: 1,
56
+ // Return Int64/UInt64 as JSON numbers, not quoted strings, so the getters'
57
+ // arithmetic works. All our values (tokens, ms timestamps) are < 2^53.
58
+ output_format_json_quote_64bit_integers: 0,
59
+ },
60
+ });
61
+ return _client;
62
+ }
63
+
64
+ // Ensure the target database exists (idempotent). Requires a user allowed to
65
+ // CREATE DATABASE, or an already-existing DB — falls back silently if it exists.
66
+ async function ensureDatabase() {
67
+ // Connect without a fixed database to run the CREATE DATABASE, then the normal
68
+ // client (bound to `config.database`) is used everywhere else.
69
+ const admin = createClient({
70
+ url: config.url,
71
+ username: config.username,
72
+ password: config.password,
73
+ request_timeout: 60000,
74
+ });
75
+ try {
76
+ await admin.command({ query: `CREATE DATABASE IF NOT EXISTS ${config.database}` });
77
+ } finally {
78
+ await admin.close();
79
+ }
80
+ }
81
+
82
+ async function ping() {
83
+ const rs = await getClient().query({ query: 'SELECT version() AS v', format: 'JSONEachRow' });
84
+ return (await rs.json())[0].v;
85
+ }
86
+
87
+ module.exports = { getClient, ensureDatabase, ping, config };