memhouse 0.0.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +72 -3
  3. package/bin/memhouse.js +559 -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/adapter-errors.js +52 -0
  9. package/editors/antigravity.js +1048 -0
  10. package/editors/base.js +321 -0
  11. package/editors/claude.js +386 -0
  12. package/editors/codebuff.js +338 -0
  13. package/editors/codex.js +537 -0
  14. package/editors/commandcode.js +166 -0
  15. package/editors/copilot-jetbrains.js +207 -0
  16. package/editors/copilot.js +260 -0
  17. package/editors/cursor-agent.js +202 -0
  18. package/editors/cursor.js +498 -0
  19. package/editors/gemini.js +231 -0
  20. package/editors/goose.js +386 -0
  21. package/editors/gsd.js +366 -0
  22. package/editors/index.js +274 -0
  23. package/editors/kiro.js +315 -0
  24. package/editors/opencode.js +432 -0
  25. package/editors/vscode.js +413 -0
  26. package/editors/windsurf.js +634 -0
  27. package/editors/zed.js +229 -0
  28. package/index.js +424 -2
  29. package/mcp-server.js +294 -0
  30. package/mem-house/DESIGN.md +119 -0
  31. package/mem-house/delivery/AGENT-INSTALL.md +87 -0
  32. package/mem-house/delivery/PROMPT.md +42 -0
  33. package/mem-house/delivery/install.sh +181 -0
  34. package/mem-house/delivery/kernel-install.md +83 -0
  35. package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
  36. package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
  37. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
  38. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
  39. package/mem-house/rls.sql +17 -0
  40. package/mem-house/schema.sql +145 -0
  41. package/mem-house/server/queries.js +878 -0
  42. package/mem-house/server/server.js +228 -0
  43. package/mem-house/shipper/ship.js +461 -0
  44. package/misc/syntax-check.js +38 -0
  45. package/package.json +59 -7
  46. package/pricing.js +88 -0
  47. package/pricing.json +992 -0
  48. package/public/assets/index-CCPh7a2Z.js +75 -0
  49. package/public/assets/index-DV6ONi_F.css +2 -0
  50. package/public/index.html +16 -0
  51. package/relay-client.js +306 -0
  52. package/relay-server.js +577 -0
  53. package/server.js +860 -0
  54. package/share-image.js +367 -0
  55. package/ui/eslint.config.js +29 -0
  56. package/ui/index.html +15 -0
  57. package/ui/package-lock.json +4229 -0
  58. package/ui/package.json +38 -0
  59. package/ui/src/App.jsx +364 -0
  60. package/ui/src/components/ActivityHeatmap.jsx +206 -0
  61. package/ui/src/components/AiAuditCard.jsx +254 -0
  62. package/ui/src/components/AnimatedLoader.jsx +14 -0
  63. package/ui/src/components/AnimatedLogo.jsx +96 -0
  64. package/ui/src/components/ChatSidebar.jsx +226 -0
  65. package/ui/src/components/DateRangePicker.jsx +104 -0
  66. package/ui/src/components/EditorDot.jsx +11 -0
  67. package/ui/src/components/EditorIcon.jsx +117 -0
  68. package/ui/src/components/KpiCard.jsx +9 -0
  69. package/ui/src/components/LiveFeed.jsx +128 -0
  70. package/ui/src/components/LoginScreen.jsx +81 -0
  71. package/ui/src/components/MessageRenderer.jsx +148 -0
  72. package/ui/src/components/PageHeader.jsx +11 -0
  73. package/ui/src/components/SectionTitle.jsx +3 -0
  74. package/ui/src/components/ShareModal.jsx +305 -0
  75. package/ui/src/components/TokenTimeline.jsx +258 -0
  76. package/ui/src/hooks/useLive.jsx +15 -0
  77. package/ui/src/index.css +83 -0
  78. package/ui/src/lib/api.js +343 -0
  79. package/ui/src/lib/constants.js +88 -0
  80. package/ui/src/lib/theme.jsx +23 -0
  81. package/ui/src/main.jsx +16 -0
  82. package/ui/src/pages/Artifacts.jsx +575 -0
  83. package/ui/src/pages/Compare.jsx +232 -0
  84. package/ui/src/pages/CostAnalysis.jsx +354 -0
  85. package/ui/src/pages/Dashboard.jsx +529 -0
  86. package/ui/src/pages/DeepAnalysis.jsx +495 -0
  87. package/ui/src/pages/GSD.jsx +726 -0
  88. package/ui/src/pages/MCPs.jsx +744 -0
  89. package/ui/src/pages/ProjectDetail.jsx +325 -0
  90. package/ui/src/pages/Projects.jsx +249 -0
  91. package/ui/src/pages/RelayDashboard.jsx +590 -0
  92. package/ui/src/pages/RelayUserDetail.jsx +466 -0
  93. package/ui/src/pages/Sessions.jsx +535 -0
  94. package/ui/src/pages/Settings.jsx +241 -0
  95. package/ui/src/pages/SqlViewer.jsx +370 -0
  96. package/ui/src/pages/Subscriptions.jsx +411 -0
  97. package/ui/vite.config.js +18 -0
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+ // mem-house SHIPPER — parse-on-client (bet #1 in ../DESIGN.md). Runs the 17 editor
3
+ // adapters locally and ships TYPED rows into the house (sessions / messages /
4
+ // tool_calls, ../schema.sql). The typed evolution of agency/ingest.js: same host id,
5
+ // same ts interpolation, same batching discipline — but rows land in physical
6
+ // columns instead of a raw JSON blob.
7
+ //
8
+ // Data-plane rules (binding, see DESIGN.md):
9
+ // - JSONEachRow, batches <= 2000 rows, async_insert=0 on EVERY insert — the house
10
+ // stamps user_id MATERIALIZED currentUser(), which is empty during async flushes.
11
+ // - DateTime64 values travel as 'YYYY-MM-DD HH:MM:SS.mmm' UTC strings (plain
12
+ // format; ISO 'T'/'Z' forms parse unreliably under JSONEachRow). Nullable → null.
13
+ // - Int64-bound values are integer-coerced (some adapters emit fractional ms).
14
+ // - Re-shipping is always safe: ReplacingMergeTree(ingested_at) keyed
15
+ // (session_id, user_id, seq) collapses to latest-wins at FINAL.
16
+ //
17
+ // CLI: node ship.js one incremental pass
18
+ // node ship.js --loop [sec] repeat every sec seconds (default 300)
19
+ // node ship.js --full ignore the incremental skip (re-ship everything)
20
+ // node ship.js --ensure-schema apply ../schema.sql and exit
21
+ // node ship.js --stats per-source counts from sessions_v and exit
22
+ //
23
+ // Env (DESIGN.md contract): MEMHOUSE_URL / MEMHOUSE_USER / MEMHOUSE_PASSWORD /
24
+ // MEMHOUSE_DB — defaults http://localhost:8123 / memhouse_root / '' / memhouse.
25
+
26
+ const os = require('os');
27
+ const fs = require('fs');
28
+ const path = require('path');
29
+ const crypto = require('crypto');
30
+ const { createClient } = require('@clickhouse/client');
31
+ const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
32
+ const adapterErrorSink = require('../../editors/adapter-errors');
33
+
34
+ const BATCH_ROWS = 2000; // insert batch ceiling (binding)
35
+ const TEXT_MAX = 50000; // messages.text truncation
36
+ const ARGS_MAX = 20000; // tool_calls.args truncation
37
+
38
+ // Stable host id: <shorthostname>-<8hex sha256(machine seed)> (memory-house convention).
39
+ function hostId() {
40
+ const short = (os.hostname() || 'host').split('.')[0];
41
+ const seed = `${os.hostname()}|${os.platform()}|${os.arch()}`;
42
+ const h = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 8);
43
+ return `${short}-${h}`;
44
+ }
45
+
46
+ // UInt64 (as decimal string) = first 8 bytes big-endian of sha256 over the row's
47
+ // content fields. Decimal string survives JSONEachRow → UInt64 without precision loss.
48
+ function lineHash(obj) {
49
+ return crypto.createHash('sha256').update(JSON.stringify(obj)).digest().readBigUInt64BE(0).toString();
50
+ }
51
+
52
+ // DateTime64(3) wire format: 'YYYY-MM-DD HH:MM:SS.mmm' in UTC (see header note).
53
+ function chTs(ms) {
54
+ const d = new Date(ms);
55
+ const p = (n, w = 2) => String(n).padStart(w, '0');
56
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
57
+ `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
58
+ }
59
+
60
+ // Per-message timestamp interpolated across [createdAt, lastUpdatedAt] — adapters
61
+ // don't expose per-message times, so we synthesize monotonic-by-seq ones that make
62
+ // sessions_v started/ended line up with the session bounds (same approximation as
63
+ // agency/ingest.js; documented in DESIGN.md).
64
+ function messageTs(chat, seq, total) {
65
+ const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
66
+ const end = chat.lastUpdatedAt || chat.createdAt || start;
67
+ if (total <= 1) return chTs(start);
68
+ return chTs(start + Math.round((end - start) * (seq / (total - 1))));
69
+ }
70
+
71
+ // UInt64-bound coercion: integers only, never negative, garbage → 0.
72
+ function toInt(v) {
73
+ const n = Math.trunc(Number(v));
74
+ return Number.isFinite(n) && n > 0 ? n : 0;
75
+ }
76
+
77
+ function makeClient() {
78
+ return createClient({
79
+ url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
80
+ username: process.env.MEMHOUSE_USER || 'memhouse_root',
81
+ password: process.env.MEMHOUSE_PASSWORD || '',
82
+ database: process.env.MEMHOUSE_DB || 'memhouse',
83
+ request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
84
+ clickhouse_settings: {
85
+ // Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
86
+ output_format_json_quote_64bit_integers: 0,
87
+ // With a 300s request_timeout the client warns unless progress headers
88
+ // keep long requests alive through proxies/load balancers — enable them
89
+ // (also silences the startup WARN on every ship/install run).
90
+ // The interval MUST sit below the smallest idle timeout in the path:
91
+ // 60s is the default for AWS ALB and nginx proxy_read_timeout, so a
92
+ // longer interval emits nothing before the socket is dropped and the
93
+ // setting only silences the warning. 30s leaves headroom for both.
94
+ send_progress_in_http_headers: 1,
95
+ http_headers_progress_interval_ms: '30000',
96
+ },
97
+ });
98
+ }
99
+
100
+ // Apply ../schema.sql statement by statement. Comments are stripped BEFORE the ';'
101
+ // split — schema comments legitimately contain semicolons.
102
+ async function ensureSchema(client) {
103
+ const sql = fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf-8');
104
+ const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
105
+ const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
106
+ for (const q of stmts) {
107
+ await client.command({
108
+ query: q,
109
+ // allow_experimental_full_text_index: on 25.x the messages text indexes
110
+ // are gated behind this flag (SUPPORT_IS_DISABLED without it); 26.x+
111
+ // accepts it as a no-op. Query-scoped, so no server config or admin
112
+ // rights are needed. Verified on 25.11 and 26.7.
113
+ clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
114
+ });
115
+ }
116
+ return stmts.length;
117
+ }
118
+
119
+ // Incremental state: what the house already holds, keyed by session_id. We compare
120
+ // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
121
+ // NOT message_count — because parsed-message count and bubbleCount are different
122
+ // units in several adapters (claude folds subagents, codex reports 0), and skipping
123
+ // must be decidable WITHOUT calling getMessages on every chat.
124
+ async function loadExisting(client) {
125
+ const rs = await client.query({
126
+ query: 'SELECT session_id, last_updated_at, message_count, extra FROM sessions FINAL WHERE user_id = currentUser()',
127
+ format: 'JSONEachRow',
128
+ });
129
+ // Actual message rows per session: an interrupted re-ship (crash between the
130
+ // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
131
+ // fresh-looking session row with a missing or PARTIAL transcript — the skip
132
+ // must compare the real row count against the recorded message_count, not
133
+ // merely check that some row exists.
134
+ const mr = await client.query({
135
+ query: 'SELECT session_id, count() AS n FROM messages FINAL WHERE user_id = currentUser() GROUP BY session_id',
136
+ format: 'JSONEachRow',
137
+ });
138
+ const msgCounts = new Map();
139
+ for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
140
+ const map = new Map();
141
+ for (const r of await rs.json()) {
142
+ // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
143
+ const ms = r.last_updated_at ? Date.parse(r.last_updated_at.replace(' ', 'T') + 'Z') : null;
144
+ const bc = toInt(r.extra && r.extra.bubbleCount);
145
+ const count = toInt(r.message_count);
146
+ map.set(r.session_id, { ms, count, bc, intact: (msgCounts.get(r.session_id) || 0) === count });
147
+ }
148
+ return map;
149
+ }
150
+
151
+ // Regex fallback for adapters that render tool calls into text but don't expose
152
+ // m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
153
+ const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
154
+
155
+ // Build the typed rows for one chat. Returns null when the chat is unreadable
156
+ // (adapter threw — e.g. a partially-written or locked session file): the caller
157
+ // must write NOTHING for it, so the next incremental pass retries. A chat that
158
+ // parses to zero messages still gets its session row (deliberate: the stored row
159
+ // absorbs the incremental skip for stable-empty chats).
160
+ function rowsForChat(chat, host) {
161
+ let messages;
162
+ try { messages = getMessages(chat) || []; }
163
+ catch (e) {
164
+ // Some readers throw instead of reporting through adapter-errors — Cursor's
165
+ // agent-store path on a bad blobs table, for one. Converting that to a bare
166
+ // `unreadable` count would leave the end-of-pass report with nothing to print,
167
+ // so operators would see a number rising every pass and no cause anywhere.
168
+ adapterErrorSink.record(chat.source, e, chat.composerId);
169
+ return null;
170
+ }
171
+
172
+ const source = chat.source;
173
+ // Canonical globally-unique session id: '<source>:<adapter-local id>'.
174
+ // composerId is only unique WITHIN one editor; two editors emitting the same
175
+ // id must never collide in keys, incremental state, deletes, or the API.
176
+ // Sources are colon-free, so the prefix parses back unambiguously.
177
+ const id = `${source}:${String(chat.composerId)}`;
178
+ const folder = chat.folder || '';
179
+ const project = folder ? path.basename(folder) : '';
180
+ const total = messages.length;
181
+
182
+ const session = {
183
+ session_id: id,
184
+ source,
185
+ host,
186
+ name: chat.name || '',
187
+ mode: chat.mode || '',
188
+ folder,
189
+ project,
190
+ git_branch: chat._gitBranch || '',
191
+ created_at: chat.createdAt ? chTs(chat.createdAt) : null,
192
+ last_updated_at: chat.lastUpdatedAt ? chTs(chat.lastUpdatedAt) : null,
193
+ message_count: total,
194
+ path: chat._fullPath || chat._dbPath || id,
195
+ extra: { bubbleCount: chat.bubbleCount || 0 },
196
+ };
197
+
198
+ const msgRows = [];
199
+ const toolRows = [];
200
+ let idx = 0; // session-wide tool-call index
201
+ for (let seq = 0; seq < total; seq++) {
202
+ const m = messages[seq];
203
+ const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
204
+ const text = raw.length > TEXT_MAX ? raw.slice(0, TEXT_MAX) : raw;
205
+ const ts = messageTs(chat, seq, total);
206
+ const row = {
207
+ session_id: id,
208
+ seq,
209
+ source,
210
+ host,
211
+ ts,
212
+ role: m.role || '',
213
+ model: m._model || '',
214
+ input_tokens: toInt(m._inputTokens),
215
+ output_tokens: toInt(m._outputTokens),
216
+ cache_read_tokens: toInt(m._cacheRead),
217
+ cache_write_tokens: toInt(m._cacheWrite),
218
+ text,
219
+ project,
220
+ folder,
221
+ is_subagent: text.startsWith('[subagent]'),
222
+ extra: {},
223
+ };
224
+ // Hash the content fields only (not ts — it is synthesized and may drift).
225
+ row.line_hash = lineHash({
226
+ session_id: id, seq, role: row.role, model: row.model,
227
+ input_tokens: row.input_tokens, output_tokens: row.output_tokens,
228
+ cache_read_tokens: row.cache_read_tokens, cache_write_tokens: row.cache_write_tokens,
229
+ text,
230
+ });
231
+ msgRows.push(row);
232
+
233
+ if (m.role !== 'assistant') continue;
234
+ let calls;
235
+ if (Array.isArray(m._toolCalls) && m._toolCalls.length) {
236
+ calls = m._toolCalls.map((tc) => ({ name: String(tc.name || 'unknown'), args: tc.args || {} }));
237
+ } else {
238
+ calls = [];
239
+ for (const match of text.matchAll(TOOL_CALL_RE)) {
240
+ calls.push({ name: match[1].trim(), args: {} });
241
+ }
242
+ }
243
+ for (const tc of calls) {
244
+ const args = JSON.stringify(tc.args);
245
+ toolRows.push({
246
+ session_id: id,
247
+ seq,
248
+ idx: idx++,
249
+ source,
250
+ host,
251
+ tool_name: tc.name,
252
+ args: args.length > ARGS_MAX ? args.slice(0, ARGS_MAX) : args,
253
+ ts,
254
+ project,
255
+ folder,
256
+ });
257
+ }
258
+ }
259
+ return { session, msgRows, toolRows };
260
+ }
261
+
262
+ // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
263
+ // already has it at least as fresh (last_updated_at) and at least as large
264
+ // (extra.bubbleCount) — both readable without parsing the chat.
265
+ async function runShip(client, opts = {}) {
266
+ const { full = false } = opts;
267
+ const host = hostId();
268
+ // Always load what the house holds — even with --full. The skip decision uses it
269
+ // only in incremental mode, but re-shipping a KNOWN session must clear its old
270
+ // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
271
+ // otherwise: ReplacingMergeTree collapses same-key rows only).
272
+ const existing = await loadExisting(client);
273
+ const chats = getAllChats();
274
+
275
+ // An adapter that cannot load contributes zero sessions, which is indistinguishable
276
+ // from an editor the user does not have — so the shipper would silently ship a
277
+ // partial history forever. Say so on every pass.
278
+ // Reported once here for what the scan already knows (a dead binding is visible
279
+ // before any work happens, and this pass may take minutes), and again after the
280
+ // loop for failures that only surface while reading messages.
281
+ const warned = new Set();
282
+ reportAdapterErrors(warned);
283
+
284
+ const batches = { sessions: [], messages: [], tool_calls: [] };
285
+ const flush = async (table) => {
286
+ if (!batches[table].length) return;
287
+ await client.insert({
288
+ table,
289
+ values: batches[table],
290
+ format: 'JSONEachRow',
291
+ clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
292
+ });
293
+ batches[table] = [];
294
+ };
295
+ const push = async (table, row) => {
296
+ batches[table].push(row);
297
+ if (batches[table].length >= BATCH_ROWS) await flush(table);
298
+ };
299
+
300
+ const seen = new Set(); // adapters must not double-ship a session id within a pass
301
+ let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
302
+ for (const chat of chats) {
303
+ if (chat.encrypted) continue;
304
+ // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
305
+ // incremental state are per (source, id) — two editors sharing an id must
306
+ // not skip or overwrite each other.
307
+ const id = `${chat.source}:${String(chat.composerId)}`;
308
+ if (seen.has(id)) continue;
309
+ seen.add(id);
310
+
311
+ const prev = existing.get(id);
312
+ if (!full && prev) {
313
+ // floor: some adapters (codex) emit fractional-ms timestamps, but DateTime64(3)
314
+ // stores whole ms — without it every such session loses by <1ms and re-ships.
315
+ const chatLast = Math.floor(chat.lastUpdatedAt || chat.createdAt || 0);
316
+ const notNewer = chatLast === 0 || (prev.ms !== null && prev.ms >= chatLast);
317
+ // Freshness alone is not enough: the transcript must be COMPLETE (stored
318
+ // row count == recorded message_count) so an interrupted re-ship — even one
319
+ // that died mid-flush leaving a partial transcript — repairs itself on the
320
+ // next pass instead of being skipped forever.
321
+ // _countUnknown means the adapter could not determine this chat's message
322
+ // count, so bubbleCount is a placeholder rather than a measurement. Skipping on
323
+ // it would compare against a number that means nothing — `prev.bc >= 0` is
324
+ // trivially true — and the session would stay stale forever. Re-read instead.
325
+ if (notNewer && !chat._countUnknown && prev.bc >= (chat.bubbleCount || 0) && prev.intact) { skipped++; continue; }
326
+ }
327
+
328
+ // rowsForChat returns null only when getMessages() *throws*. An adapter that
329
+ // swallows its own failure returns [] instead, which would look like a session
330
+ // that genuinely has no messages — and for a known session the re-ship below
331
+ // deletes the old transcript before inserting that emptiness. So treat a
332
+ // failure recorded while reading THIS chat as unreadable too: write nothing,
333
+ // delete nothing, retry next pass.
334
+ const errsBefore = adapterErrorSink.recorded().length;
335
+ const rows = rowsForChat(chat, host);
336
+ const failedHere = adapterErrorSink.recorded().slice(errsBefore).some((e) => e.source === chat.source);
337
+ // Write nothing on a failed read, whether or not the session is already stored.
338
+ //
339
+ // Shipping a partial first read looks tempting — nothing is there to destroy —
340
+ // but it is a trap: message_count would be written from the partial rows, so
341
+ // prev.intact becomes true, prev.bc already matches the source, the timestamp is
342
+ // unchanged, and the skip predicate above then withholds the session forever.
343
+ // The truncation would become permanent AND the warning would stop, because the
344
+ // adapter is never asked to read it again. Withholding keeps the session out of
345
+ // the skip predicate entirely, so every pass retries it and re-reports it until
346
+ // the underlying store is readable.
347
+ if (!rows || failedHere) { unreadable++; continue; }
348
+ if (prev) {
349
+ // Known session being re-shipped: clear its old rows BEFORE inserting so a
350
+ // shorter re-parse can't leave stale seq/idx tails. A crash between the
351
+ // delete and the inserts is repaired by the next pass: the skip predicate
352
+ // refuses to skip a non-empty session whose message rows are missing.
353
+ for (const t of ['messages', 'tool_calls']) {
354
+ await client.command({
355
+ query: `DELETE FROM ${t} WHERE session_id = {id:String} AND user_id = currentUser()`,
356
+ query_params: { id },
357
+ clickhouse_settings: { async_insert: 0 },
358
+ });
359
+ }
360
+ }
361
+ await push('sessions', rows.session);
362
+ sessions++;
363
+ for (const r of rows.msgRows) { await push('messages', r); msgRows++; }
364
+ for (const r of rows.toolRows) { await push('tool_calls', r); toolRows++; }
365
+ }
366
+ for (const table of Object.keys(batches)) await flush(table);
367
+ // Anything that only failed while reading messages — the sink is reset by the
368
+ // next getAllChats(), so unreported here means never reported at all.
369
+ reportAdapterErrors(warned);
370
+ return { sessions, skipped, msgRows, toolRows, unreadable };
371
+ }
372
+
373
+ // Warn once per adapter per pass. `warned` carries across the two call sites so a
374
+ // dead binding is not reported twice in the same run.
375
+ function reportAdapterErrors(warned) {
376
+ const errors = getAdapterErrors().filter((e) => !warned.has(e.source));
377
+ if (!errors.length) return;
378
+ for (const e of errors) warned.add(e.source);
379
+ const noBinding = errors.filter((e) => e.missingBinding).map((e) => e.source);
380
+ if (noBinding.length) {
381
+ console.log(`[mem-house] WARNING: ${noBinding.length} adapter(s) skipped, sessions NOT shipped — better-sqlite3 has no native binding: ${noBinding.join(', ')}`);
382
+ console.log('[mem-house] fix: npm install -g memhouse --allow-scripts=better-sqlite3');
383
+ }
384
+ for (const e of errors.filter((x) => !x.missingBinding)) {
385
+ console.log(`[mem-house] WARNING: ${e.source} skipped — ${e.message}`);
386
+ }
387
+ }
388
+
389
+ // Per-source rollup straight from sessions_v (final=1 so ReplacingMergeTree collapses).
390
+ async function printStats(client) {
391
+ const rs = await client.query({
392
+ query: `
393
+ SELECT source,
394
+ count() AS sessions,
395
+ sum(total_msgs) AS messages,
396
+ sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
397
+ FROM sessions_v
398
+ GROUP BY source
399
+ ORDER BY sessions DESC`,
400
+ format: 'JSONEachRow',
401
+ clickhouse_settings: { final: 1 },
402
+ });
403
+ const rows = await rs.json();
404
+ if (!rows.length) { console.log('[mem-house] house is empty'); return; }
405
+ const w = [Math.max(6, ...rows.map((r) => r.source.length)), 8, 8, 14];
406
+ const line = (a, b, c, d) =>
407
+ console.log(`${String(a).padEnd(w[0])} ${String(b).padStart(w[1])} ${String(c).padStart(w[2])} ${String(d).padStart(w[3])}`);
408
+ line('source', 'sessions', 'messages', 'tokens');
409
+ let ts = 0, tm = 0, tt = 0;
410
+ for (const r of rows) {
411
+ line(r.source, r.sessions, r.messages, r.tokens);
412
+ ts += Number(r.sessions); tm += Number(r.messages); tt += Number(r.tokens);
413
+ }
414
+ line('total', ts, tm, tt);
415
+ }
416
+
417
+ async function main() {
418
+ const argv = process.argv.slice(2);
419
+ const client = makeClient();
420
+ try {
421
+ if (argv.includes('--ensure-schema')) {
422
+ const n = await ensureSchema(client);
423
+ console.log(`[mem-house] schema ensured (${n} statements)`);
424
+ return;
425
+ }
426
+ if (argv.includes('--stats')) { await printStats(client); return; }
427
+
428
+ const loopIdx = argv.indexOf('--loop');
429
+ const loop = loopIdx !== -1;
430
+ let intervalSec = 300;
431
+ if (loop) {
432
+ const n = Number(argv[loopIdx + 1]);
433
+ if (Number.isFinite(n) && n > 0) intervalSec = n;
434
+ }
435
+ let full = argv.includes('--full');
436
+ do {
437
+ const t0 = Date.now();
438
+ try {
439
+ const r = await runShip(client, { full });
440
+ console.log(`[mem-house] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
441
+ `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
442
+ } catch (e) {
443
+ console.error(`[mem-house] pass failed: ${e.message}`);
444
+ if (!loop) process.exitCode = 1;
445
+ }
446
+ full = false; // --full applies to the first pass only; loop passes stay incremental
447
+ if (loop) {
448
+ await new Promise((r) => setTimeout(r, intervalSec * 1000));
449
+ resetCaches(); // adapters cache chat lists; drop them so new sessions surface
450
+ }
451
+ } while (loop);
452
+ } finally {
453
+ await client.close();
454
+ }
455
+ }
456
+
457
+ module.exports = { runShip, ensureSchema };
458
+
459
+ if (require.main === module) {
460
+ main().catch((e) => { console.error(`[mem-house] fatal: ${e.message}`); process.exit(1); });
461
+ }
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // Repo-wide JS syntax gate (`npm test`). Walks every .js file outside
3
+ // node_modules / public / ui (the UI has its own lint + build gates and uses
4
+ // JSX, which `node --check` cannot parse) and fails on the first parse error.
5
+ // A deliberately thin gate: it catches the "broken require tree / stray token"
6
+ // class of regression across the two storage implementations and 17 adapters
7
+ // until a real test suite exists.
8
+
9
+ const { execFileSync } = require('child_process');
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+
13
+ const ROOT = path.join(__dirname, '..');
14
+ const SKIP = new Set(['node_modules', 'public', 'ui', '.git']);
15
+
16
+ function walk(dir, out) {
17
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
18
+ if (e.isDirectory()) {
19
+ if (!SKIP.has(e.name)) walk(path.join(dir, e.name), out);
20
+ } else if (e.name.endsWith('.js')) {
21
+ out.push(path.join(dir, e.name));
22
+ }
23
+ }
24
+ return out;
25
+ }
26
+
27
+ const files = walk(ROOT, []);
28
+ let failed = 0;
29
+ for (const f of files) {
30
+ try {
31
+ execFileSync(process.execPath, ['--check', f], { stdio: 'pipe' });
32
+ } catch (e) {
33
+ failed++;
34
+ console.error(`SYNTAX ERROR: ${path.relative(ROOT, f)}\n${e.stderr}`);
35
+ }
36
+ }
37
+ console.log(`${files.length - failed}/${files.length} files parse clean`);
38
+ process.exit(failed ? 1 : 0);
package/package.json CHANGED
@@ -1,16 +1,45 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.0.1",
4
- "description": "mem-house \u2014 agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store, team sharing, and an analytics dashboard. Name reserved; first public release coming.",
3
+ "version": "0.3.1",
4
+ "description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store, kernel-agency install, and the agentlytics dashboard",
5
+ "main": "bin/memhouse.js",
5
6
  "bin": {
6
- "memhouse": "index.js"
7
+ "memhouse": "./bin/memhouse.js",
8
+ "agentlytics": "./index.js"
7
9
  },
8
10
  "files": [
11
+ "bin/",
12
+ "mem-house/",
13
+ "misc/syntax-check.js",
9
14
  "index.js",
15
+ "cache.js",
16
+ "ch-schema.js",
17
+ "ch-ingest.js",
18
+ "clickhouse.js",
19
+ "server.js",
20
+ "share-image.js",
21
+ "relay-server.js",
22
+ "relay-client.js",
23
+ "mcp-server.js",
24
+ "pricing.js",
25
+ "pricing.json",
26
+ "editors/",
27
+ "ui/src/",
28
+ "ui/index.html",
29
+ "ui/package.json",
30
+ "ui/package-lock.json",
31
+ "ui/vite.config.js",
32
+ "ui/eslint.config.js",
33
+ "public/",
10
34
  "README.md"
11
35
  ],
12
- "license": "MIT",
13
- "author": "Ramazan Polat <ramazanpolat@gmail.com>",
36
+ "scripts": {
37
+ "start": "node bin/memhouse.js",
38
+ "build": "cd ui && npm install && npm run build",
39
+ "dev": "cd ui && npm run dev",
40
+ "test": "node misc/syntax-check.js",
41
+ "prepack": "cd ui && npm install --no-audit --no-fund && npm run build"
42
+ },
14
43
  "keywords": [
15
44
  "memory",
16
45
  "agents",
@@ -18,6 +47,29 @@
18
47
  "claude",
19
48
  "codex",
20
49
  "gemini",
21
- "analytics"
22
- ]
50
+ "cursor",
51
+ "analytics",
52
+ "mem-house"
53
+ ],
54
+ "author": "Ramazan Polat <ramazanpolat@gmail.com> (built on agentlytics by fkadev)",
55
+ "license": "MIT",
56
+ "engines": {
57
+ "node": ">=20.19.0"
58
+ },
59
+ "allowScripts": {
60
+ "better-sqlite3": true
61
+ },
62
+ "repository": {
63
+ "type": "git",
64
+ "url": "https://github.com/agent-realm/memhouse"
65
+ },
66
+ "dependencies": {
67
+ "@clickhouse/client": "^1.23.1",
68
+ "@modelcontextprotocol/sdk": "^1.27.1",
69
+ "better-sqlite3": "^12.6.2",
70
+ "chalk": "^4.1.2",
71
+ "express": "^4.22.1",
72
+ "log-update": "^4.0.0",
73
+ "open": "^8.4.2"
74
+ }
23
75
  }
package/pricing.js ADDED
@@ -0,0 +1,88 @@
1
+ // Load pricing data from JSON – edit pricing.json to add/update models
2
+ const _raw = require('./pricing.json');
3
+ const MODEL_PRICING = Object.fromEntries(
4
+ Object.entries(_raw).filter(([k]) => !k.startsWith('_'))
5
+ );
6
+
7
+ // Normalize a model identifier to match pricing keys
8
+ // Handles versioned names like "claude-sonnet-4-20250514", "gpt-4o-2024-08-06", etc.
9
+ function normalizeModelName(name) {
10
+ if (!name) return null;
11
+ let n = name.toLowerCase().trim();
12
+
13
+ // Strip leading provider prefixes (e.g. "anthropic/claude-..." or "openai/gpt-...")
14
+ const slashIdx = n.lastIndexOf('/');
15
+ if (slashIdx !== -1) n = n.substring(slashIdx + 1);
16
+
17
+ // Strip dot-delimited provider prefixes (e.g. "us.anthropic.claude-sonnet-4-6")
18
+ // Only strip if all prefix segments are simple words (no dashes), to avoid
19
+ // splitting version dots like "claude-4.6-opus"
20
+ const dotParts = n.split('.');
21
+ if (dotParts.length > 1) {
22
+ const prefixes = dotParts.slice(0, -1);
23
+ const last = dotParts[dotParts.length - 1];
24
+ if (last.includes('-') && prefixes.every(p => !p.includes('-'))) n = last;
25
+ }
26
+
27
+ // Handle MODEL_CLAUDE_* / MODEL_GPT_* enum constants
28
+ if (n.startsWith('model_')) {
29
+ n = n.substring(6).replace(/_/g, '-');
30
+ }
31
+
32
+ // Build candidate list: original + dots→dashes + reversed claude names
33
+ const candidates = [n];
34
+ if (n.includes('.')) candidates.push(n.replace(/\./g, '-'));
35
+
36
+ // Rearrange reversed claude names: "claude-4-6-opus-..." → "claude-opus-4-6"
37
+ // Run on all candidates so dots→dashes variant is also checked
38
+ for (const c of [...candidates]) {
39
+ const rev = c.match(/^(claude)-(\d+)-(\d+)-(opus|sonnet|haiku)/);
40
+ if (rev) candidates.push(`${rev[1]}-${rev[4]}-${rev[2]}-${rev[3]}`);
41
+ }
42
+
43
+ // Pass 1: exact and precise matches across ALL candidates first
44
+ for (const c of candidates) {
45
+ if (MODEL_PRICING[c]) return c;
46
+ }
47
+ for (const c of candidates) {
48
+ const withoutDate = c.replace(/-\d{4}-?\d{2}-?\d{2}$/, '');
49
+ if (MODEL_PRICING[withoutDate]) return withoutDate;
50
+ const withoutTag = c.replace(/:(latest|thinking)$/, '');
51
+ if (MODEL_PRICING[withoutTag]) return withoutTag;
52
+ const withoutQual = c.replace(/-(thinking|high|xhigh|preview|latest)(-thinking|-high|-xhigh|-preview)*/g, '');
53
+ if (withoutQual !== c && MODEL_PRICING[withoutQual]) return withoutQual;
54
+ }
55
+
56
+ // Pass 2: fuzzy startsWith (longest key match wins)
57
+ const keys = Object.keys(MODEL_PRICING);
58
+ for (const c of candidates) {
59
+ let best = null;
60
+ for (const key of keys) {
61
+ if (c.startsWith(key) && (!best || key.length > best.length)) best = key;
62
+ }
63
+ if (best) return best;
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ function getModelPricing(modelName) {
70
+ const key = normalizeModelName(modelName);
71
+ return key ? MODEL_PRICING[key] : null;
72
+ }
73
+
74
+ // Calculate cost for a set of token counts and a model
75
+ // Returns cost in USD or null if model is unknown
76
+ function calculateCost(modelName, inputTokens, outputTokens, cacheRead, cacheWrite) {
77
+ const pricing = getModelPricing(modelName);
78
+ if (!pricing) return null;
79
+
80
+ const input = ((inputTokens || 0) / 1_000_000) * pricing.input;
81
+ const output = ((outputTokens || 0) / 1_000_000) * pricing.output;
82
+ const cr = ((cacheRead || 0) / 1_000_000) * pricing.cacheRead;
83
+ const cw = ((cacheWrite || 0) / 1_000_000) * pricing.cacheWrite;
84
+
85
+ return input + output + cr + cw;
86
+ }
87
+
88
+ module.exports = { MODEL_PRICING, normalizeModelName, getModelPricing, calculateCost };