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
@@ -0,0 +1,228 @@
1
+ // mem-house server — the dashboard/API entrypoint.
2
+ //
3
+ // Speaks the agentlytics REST contract (same routes, same response shapes) over the
4
+ // mem-house typed schema, so the UNMODIFIED agentlytics React SPA is the dashboard:
5
+ // queries.js computes every response; this file is routing + config + stubs. Serves
6
+ // the built SPA from the repo root's public/.
7
+ //
8
+ // node mem-house/server/server.js (env: MEMHOUSE_* per DESIGN.md; port 4640)
9
+
10
+ const express = require('express');
11
+ const path = require('path');
12
+ const fs = require('fs');
13
+ const os = require('os');
14
+ const qy = require('./queries');
15
+
16
+ const PORT = parseInt(process.env.MEMHOUSE_PORT || '4640', 10);
17
+ // Localhost by default: the dashboard exposes full transcripts and a SQL console
18
+ // with no auth. Remote access is an explicit opt-in (MEMHOUSE_HOST=0.0.0.0).
19
+ const HOST = process.env.MEMHOUSE_HOST || '127.0.0.1';
20
+ const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
21
+ const CONFIG_PATH = path.join(os.homedir(), '.memhouse', 'config.json');
22
+
23
+ const app = express();
24
+ app.use(express.json());
25
+ if (fs.existsSync(PUBLIC_DIR)) app.use(express.static(PUBLIC_DIR));
26
+
27
+ function readConfig() {
28
+ try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
29
+ }
30
+ function writeConfig(config) {
31
+ fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
32
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
33
+ }
34
+ const hiddenFolders = () => readConfig().hiddenProjects || [];
35
+
36
+ function parseDateOpts(query) {
37
+ const opts = {};
38
+ if (query.dateFrom) opts.dateFrom = parseInt(query.dateFrom) || null;
39
+ if (query.dateTo) opts.dateTo = parseInt(query.dateTo) || null;
40
+ return opts;
41
+ }
42
+
43
+ // Route helper: async handler with the uniform 500 shape.
44
+ const route = (fn) => async (req, res) => {
45
+ try { await fn(req, res); } catch (err) { res.status(500).json({ error: err.message }); }
46
+ };
47
+
48
+ app.get('/api/ping', (req, res) => res.json({ app: 'agentlytics', pid: process.pid }));
49
+ app.get('/api/mode', (req, res) => res.json({ mode: 'local' }));
50
+
51
+ app.get('/api/overview', route(async (req, res) => {
52
+ res.json(await qy.getOverview({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
53
+ }));
54
+
55
+ app.get('/api/daily-activity', route(async (req, res) => {
56
+ res.json(await qy.getDailyActivity({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
57
+ }));
58
+
59
+ app.get('/api/dashboard-stats', route(async (req, res) => {
60
+ res.json(await qy.getDashboardStats({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
61
+ }));
62
+
63
+ app.get('/api/chats', route(async (req, res) => {
64
+ const opts = {
65
+ editor: req.query.editor || null,
66
+ folder: req.query.folder || null,
67
+ named: req.query.named !== 'false',
68
+ limit: req.query.limit ? parseInt(req.query.limit) : 200,
69
+ offset: req.query.offset ? parseInt(req.query.offset) : 0,
70
+ ...parseDateOpts(req.query),
71
+ hiddenFolders: hiddenFolders(),
72
+ };
73
+ const [total, chats] = await Promise.all([qy.countChats(opts), qy.getChats(opts)]);
74
+ res.json({ total, chats });
75
+ }));
76
+
77
+ app.get('/api/chats/:id', route(async (req, res) => {
78
+ const result = await qy.getChat(req.params.id);
79
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
80
+ res.json(result);
81
+ }));
82
+
83
+ app.get('/api/chats/:id/markdown', route(async (req, res) => {
84
+ const result = await qy.getChat(req.params.id);
85
+ if (!result) return res.status(404).json({ error: 'Chat not found' });
86
+ const lines = [];
87
+ const title = result.name || 'Untitled Session';
88
+ lines.push(`# ${title}\n`);
89
+ const meta = [];
90
+ if (result.source) meta.push(`**Editor:** ${result.source}`);
91
+ if (result.mode) meta.push(`**Mode:** ${result.mode}`);
92
+ if (result.folder) meta.push(`**Project:** ${result.folder}`);
93
+ if (result.createdAt) meta.push(`**Created:** ${new Date(result.createdAt).toISOString()}`);
94
+ if (result.lastUpdatedAt) meta.push(`**Updated:** ${new Date(result.lastUpdatedAt).toISOString()}`);
95
+ if (result.stats) {
96
+ meta.push(`**Messages:** ${result.stats.totalMessages}`);
97
+ if (result.stats.totalInputTokens) meta.push(`**Input Tokens:** ${result.stats.totalInputTokens}`);
98
+ if (result.stats.totalOutputTokens) meta.push(`**Output Tokens:** ${result.stats.totalOutputTokens}`);
99
+ const models = [...new Set(result.stats.models || [])];
100
+ if (models.length > 0) meta.push(`**Models:** ${models.join(', ')}`);
101
+ }
102
+ if (meta.length > 0) lines.push(meta.join(' \n') + '\n');
103
+ lines.push('---\n');
104
+ for (const msg of result.messages) {
105
+ const label = msg.role === 'user' ? '## User' : msg.role === 'assistant' ? '## Assistant' : `## ${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}`;
106
+ const modelTag = msg.model ? ` *(${msg.model})*` : '';
107
+ lines.push(`${label}${modelTag}\n`);
108
+ lines.push(msg.content + '\n');
109
+ }
110
+ const filename = title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 80) + '.md';
111
+ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
112
+ res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
113
+ res.send(lines.join('\n'));
114
+ }));
115
+
116
+ app.get('/api/projects', route(async (req, res) => {
117
+ res.json(await qy.getProjects({ ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
118
+ }));
119
+
120
+ app.get('/api/all-projects', route(async (req, res) => {
121
+ res.json(await qy.getProjects({ ...parseDateOpts(req.query), includeHidden: true }));
122
+ }));
123
+
124
+ app.get('/api/deep-analytics', route(async (req, res) => {
125
+ res.json(await qy.getDeepAnalytics({
126
+ editor: req.query.editor || null,
127
+ folder: req.query.folder || null,
128
+ limit: Math.min(parseInt(req.query.limit) || 500, 5000),
129
+ ...parseDateOpts(req.query),
130
+ hiddenFolders: hiddenFolders(),
131
+ }));
132
+ }));
133
+
134
+ app.get('/api/costs', route(async (req, res) => {
135
+ res.json(await qy.estimateCosts({
136
+ editor: req.query.editor || null,
137
+ folder: req.query.folder || null,
138
+ chatId: req.query.chatId || null,
139
+ ...parseDateOpts(req.query),
140
+ hiddenFolders: hiddenFolders(),
141
+ }));
142
+ }));
143
+
144
+ app.get('/api/cost-analytics', route(async (req, res) => {
145
+ res.json(await qy.getCostAnalytics({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
146
+ }));
147
+
148
+ app.get('/api/tool-calls', route(async (req, res) => {
149
+ const name = req.query.name;
150
+ if (!name) return res.status(400).json({ error: 'name query param required' });
151
+ // Same filter set as the analytics the drill-down is opened from.
152
+ res.json(await qy.getToolCalls(name, {
153
+ limit: Math.min(parseInt(req.query.limit) || 200, 1000),
154
+ folder: req.query.folder || null,
155
+ editor: req.query.editor || null,
156
+ ...parseDateOpts(req.query),
157
+ hiddenFolders: hiddenFolders(),
158
+ }));
159
+ }));
160
+
161
+ app.post('/api/query', route(async (req, res) => {
162
+ const { sql } = req.body;
163
+ if (!sql || typeof sql !== 'string') return res.status(400).json({ error: 'sql string required' });
164
+ const first = sql.trim().replace(/^--.*$/gm, '').trim().split(/\s+/)[0].toUpperCase();
165
+ if (!['SELECT', 'WITH', 'EXPLAIN', 'DESCRIBE', 'SHOW'].includes(first)) {
166
+ return res.status(403).json({ error: 'Only SELECT queries are allowed' });
167
+ }
168
+ try { res.json(await qy.rawQuery(sql)); }
169
+ catch (err) { res.status(400).json({ error: err.message }); }
170
+ }));
171
+
172
+ app.get('/api/schema', route(async (req, res) => res.json(await qy.schema())));
173
+
174
+ app.get('/api/config', (req, res) => res.json(readConfig()));
175
+ app.put('/api/config', route(async (req, res) => {
176
+ const config = readConfig();
177
+ Object.assign(config, req.body);
178
+ writeConfig(config);
179
+ res.json(config);
180
+ }));
181
+
182
+ // Real refetch: run one full shipper pass, streaming SSE like the root contract.
183
+ app.get('/api/refetch', async (req, res) => {
184
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
185
+ try {
186
+ const { runShip } = require('../shipper/ship');
187
+ res.write(`data: ${JSON.stringify({ scanned: 0, analyzed: 0, skipped: 0, total: 0 })}\n\n`);
188
+ const r = await runShip(qy.getClient(), { full: true });
189
+ res.write(`data: ${JSON.stringify({ done: true, total: r.sessions + r.skipped, analyzed: r.sessions })}\n\n`);
190
+ } catch (err) {
191
+ res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
192
+ }
193
+ res.end();
194
+ });
195
+
196
+ // Not applicable to mem-house (dashboard renders these pages empty/absent).
197
+ app.get('/api/usage', (req, res) => res.json([]));
198
+ app.get('/api/artifacts', (req, res) => res.json([]));
199
+ app.get('/api/artifact-content', (req, res) => res.status(404).json({ error: 'not available in mem-house' }));
200
+ app.get('/api/mcps', (req, res) => res.json({
201
+ servers: [], toolCalls: [], matchedTools: {}, topSessions: [], projectMcps: [],
202
+ summary: { totalServers: 0, totalToolCalls: 0, uniqueTools: 0, sessionsWithTools: 0, editorsWithServers: [] },
203
+ }));
204
+ app.get('/api/gsd/projects', (req, res) => res.json([]));
205
+ app.get('/api/gsd/phases', (req, res) => res.json([]));
206
+ app.get('/api/gsd/plan', (req, res) => res.status(404).json({ error: 'not available in mem-house' }));
207
+ app.get('/api/gsd/overview', (req, res) => res.json({ totalProjects: 0, totalPhases: 0, completedPhases: 0, activePhases: [], executingPhases: 0, plannedPhases: 0 }));
208
+ app.get('/api/gsd/config', (req, res) => res.json(null));
209
+ app.get('/api/gsd/phase-tokens', (req, res) => res.json([]));
210
+ app.get('/api/gsd/file', (req, res) => res.json({ content: null }));
211
+ app.get('/api/share-image', (req, res) => res.status(501).json({ error: 'not available in mem-house' }));
212
+ app.get('/api/check-ai', (req, res) => res.status(501).json({ error: 'not available in mem-house' }));
213
+
214
+ // SPA fallback
215
+ app.get('*', (req, res) => {
216
+ const index = path.join(PUBLIC_DIR, 'index.html');
217
+ if (fs.existsSync(index)) res.sendFile(index);
218
+ else res.status(503).json({ error: 'UI not built — run: cd ui && npm install && npm run build' });
219
+ });
220
+
221
+ if (require.main === module) {
222
+ app.listen(PORT, HOST, () => {
223
+ console.log(`[mem-house] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
224
+ + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
225
+ });
226
+ }
227
+
228
+ module.exports = app;
@@ -0,0 +1,402 @@
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, getMessages, resetCaches } = require('../../editors');
32
+
33
+ const BATCH_ROWS = 2000; // insert batch ceiling (binding)
34
+ const TEXT_MAX = 50000; // messages.text truncation
35
+ const ARGS_MAX = 20000; // tool_calls.args truncation
36
+
37
+ // Stable host id: <shorthostname>-<8hex sha256(machine seed)> (memory-house convention).
38
+ function hostId() {
39
+ const short = (os.hostname() || 'host').split('.')[0];
40
+ const seed = `${os.hostname()}|${os.platform()}|${os.arch()}`;
41
+ const h = crypto.createHash('sha256').update(seed).digest('hex').slice(0, 8);
42
+ return `${short}-${h}`;
43
+ }
44
+
45
+ // UInt64 (as decimal string) = first 8 bytes big-endian of sha256 over the row's
46
+ // content fields. Decimal string survives JSONEachRow → UInt64 without precision loss.
47
+ function lineHash(obj) {
48
+ return crypto.createHash('sha256').update(JSON.stringify(obj)).digest().readBigUInt64BE(0).toString();
49
+ }
50
+
51
+ // DateTime64(3) wire format: 'YYYY-MM-DD HH:MM:SS.mmm' in UTC (see header note).
52
+ function chTs(ms) {
53
+ const d = new Date(ms);
54
+ const p = (n, w = 2) => String(n).padStart(w, '0');
55
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ` +
56
+ `${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}.${p(d.getUTCMilliseconds(), 3)}`;
57
+ }
58
+
59
+ // Per-message timestamp interpolated across [createdAt, lastUpdatedAt] — adapters
60
+ // don't expose per-message times, so we synthesize monotonic-by-seq ones that make
61
+ // sessions_v started/ended line up with the session bounds (same approximation as
62
+ // agency/ingest.js; documented in DESIGN.md).
63
+ function messageTs(chat, seq, total) {
64
+ const start = chat.createdAt || chat.lastUpdatedAt || Date.now();
65
+ const end = chat.lastUpdatedAt || chat.createdAt || start;
66
+ if (total <= 1) return chTs(start);
67
+ return chTs(start + Math.round((end - start) * (seq / (total - 1))));
68
+ }
69
+
70
+ // UInt64-bound coercion: integers only, never negative, garbage → 0.
71
+ function toInt(v) {
72
+ const n = Math.trunc(Number(v));
73
+ return Number.isFinite(n) && n > 0 ? n : 0;
74
+ }
75
+
76
+ function makeClient() {
77
+ return createClient({
78
+ url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
79
+ username: process.env.MEMHOUSE_USER || 'memhouse_root',
80
+ password: process.env.MEMHOUSE_PASSWORD || '',
81
+ database: process.env.MEMHOUSE_DB || 'memhouse',
82
+ request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
83
+ clickhouse_settings: {
84
+ // Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
85
+ output_format_json_quote_64bit_integers: 0,
86
+ // With a 300s request_timeout the client warns unless progress headers
87
+ // keep long requests alive through proxies/load balancers — enable them
88
+ // (also silences the startup WARN on every ship/install run).
89
+ // The interval MUST sit below the smallest idle timeout in the path:
90
+ // 60s is the default for AWS ALB and nginx proxy_read_timeout, so a
91
+ // longer interval emits nothing before the socket is dropped and the
92
+ // setting only silences the warning. 30s leaves headroom for both.
93
+ send_progress_in_http_headers: 1,
94
+ http_headers_progress_interval_ms: '30000',
95
+ },
96
+ });
97
+ }
98
+
99
+ // Apply ../schema.sql statement by statement. Comments are stripped BEFORE the ';'
100
+ // split — schema comments legitimately contain semicolons.
101
+ async function ensureSchema(client) {
102
+ const sql = fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf-8');
103
+ const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
104
+ const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
105
+ for (const q of stmts) {
106
+ await client.command({
107
+ query: q,
108
+ // allow_experimental_full_text_index: on 25.x the messages text indexes
109
+ // are gated behind this flag (SUPPORT_IS_DISABLED without it); 26.x+
110
+ // accepts it as a no-op. Query-scoped, so no server config or admin
111
+ // rights are needed. Verified on 25.11 and 26.7.
112
+ clickhouse_settings: { async_insert: 0, allow_experimental_full_text_index: 1 },
113
+ });
114
+ }
115
+ return stmts.length;
116
+ }
117
+
118
+ // Incremental state: what the house already holds, keyed by session_id. We compare
119
+ // against extra.bubbleCount (the adapter's own cheap count, stored at ship time) —
120
+ // NOT message_count — because parsed-message count and bubbleCount are different
121
+ // units in several adapters (claude folds subagents, codex reports 0), and skipping
122
+ // must be decidable WITHOUT calling getMessages on every chat.
123
+ async function loadExisting(client) {
124
+ const rs = await client.query({
125
+ query: 'SELECT session_id, last_updated_at, message_count, extra FROM sessions FINAL WHERE user_id = currentUser()',
126
+ format: 'JSONEachRow',
127
+ });
128
+ // Actual message rows per session: an interrupted re-ship (crash between the
129
+ // clear-DELETE and the inserts, or mid-flush on a large transcript) leaves a
130
+ // fresh-looking session row with a missing or PARTIAL transcript — the skip
131
+ // must compare the real row count against the recorded message_count, not
132
+ // merely check that some row exists.
133
+ const mr = await client.query({
134
+ query: 'SELECT session_id, count() AS n FROM messages FINAL WHERE user_id = currentUser() GROUP BY session_id',
135
+ format: 'JSONEachRow',
136
+ });
137
+ const msgCounts = new Map();
138
+ for (const r of await mr.json()) msgCounts.set(r.session_id, toInt(r.n));
139
+ const map = new Map();
140
+ for (const r of await rs.json()) {
141
+ // DateTime64 comes back as 'YYYY-MM-DD HH:MM:SS.mmm' — re-parse as UTC.
142
+ const ms = r.last_updated_at ? Date.parse(r.last_updated_at.replace(' ', 'T') + 'Z') : null;
143
+ const bc = toInt(r.extra && r.extra.bubbleCount);
144
+ const count = toInt(r.message_count);
145
+ map.set(r.session_id, { ms, count, bc, intact: (msgCounts.get(r.session_id) || 0) === count });
146
+ }
147
+ return map;
148
+ }
149
+
150
+ // Regex fallback for adapters that render tool calls into text but don't expose
151
+ // m._toolCalls: matches '[tool-call: Name(' and '[tool-call: Name]' forms.
152
+ const TOOL_CALL_RE = /\[tool-call: ([^(\]]+)/g;
153
+
154
+ // Build the typed rows for one chat. Returns null when the chat is unreadable
155
+ // (adapter threw — e.g. a partially-written or locked session file): the caller
156
+ // must write NOTHING for it, so the next incremental pass retries. A chat that
157
+ // parses to zero messages still gets its session row (deliberate: the stored row
158
+ // absorbs the incremental skip for stable-empty chats).
159
+ function rowsForChat(chat, host) {
160
+ let messages;
161
+ try { messages = getMessages(chat) || []; } catch { return null; }
162
+
163
+ const source = chat.source;
164
+ // Canonical globally-unique session id: '<source>:<adapter-local id>'.
165
+ // composerId is only unique WITHIN one editor; two editors emitting the same
166
+ // id must never collide in keys, incremental state, deletes, or the API.
167
+ // Sources are colon-free, so the prefix parses back unambiguously.
168
+ const id = `${source}:${String(chat.composerId)}`;
169
+ const folder = chat.folder || '';
170
+ const project = folder ? path.basename(folder) : '';
171
+ const total = messages.length;
172
+
173
+ const session = {
174
+ session_id: id,
175
+ source,
176
+ host,
177
+ name: chat.name || '',
178
+ mode: chat.mode || '',
179
+ folder,
180
+ project,
181
+ git_branch: chat._gitBranch || '',
182
+ created_at: chat.createdAt ? chTs(chat.createdAt) : null,
183
+ last_updated_at: chat.lastUpdatedAt ? chTs(chat.lastUpdatedAt) : null,
184
+ message_count: total,
185
+ path: chat._fullPath || chat._dbPath || id,
186
+ extra: { bubbleCount: chat.bubbleCount || 0 },
187
+ };
188
+
189
+ const msgRows = [];
190
+ const toolRows = [];
191
+ let idx = 0; // session-wide tool-call index
192
+ for (let seq = 0; seq < total; seq++) {
193
+ const m = messages[seq];
194
+ const raw = typeof m.content === 'string' ? m.content : JSON.stringify(m.content) ?? '';
195
+ const text = raw.length > TEXT_MAX ? raw.slice(0, TEXT_MAX) : raw;
196
+ const ts = messageTs(chat, seq, total);
197
+ const row = {
198
+ session_id: id,
199
+ seq,
200
+ source,
201
+ host,
202
+ ts,
203
+ role: m.role || '',
204
+ model: m._model || '',
205
+ input_tokens: toInt(m._inputTokens),
206
+ output_tokens: toInt(m._outputTokens),
207
+ cache_read_tokens: toInt(m._cacheRead),
208
+ cache_write_tokens: toInt(m._cacheWrite),
209
+ text,
210
+ project,
211
+ folder,
212
+ is_subagent: text.startsWith('[subagent]'),
213
+ extra: {},
214
+ };
215
+ // Hash the content fields only (not ts — it is synthesized and may drift).
216
+ row.line_hash = lineHash({
217
+ session_id: id, seq, role: row.role, model: row.model,
218
+ input_tokens: row.input_tokens, output_tokens: row.output_tokens,
219
+ cache_read_tokens: row.cache_read_tokens, cache_write_tokens: row.cache_write_tokens,
220
+ text,
221
+ });
222
+ msgRows.push(row);
223
+
224
+ if (m.role !== 'assistant') continue;
225
+ let calls;
226
+ if (Array.isArray(m._toolCalls) && m._toolCalls.length) {
227
+ calls = m._toolCalls.map((tc) => ({ name: String(tc.name || 'unknown'), args: tc.args || {} }));
228
+ } else {
229
+ calls = [];
230
+ for (const match of text.matchAll(TOOL_CALL_RE)) {
231
+ calls.push({ name: match[1].trim(), args: {} });
232
+ }
233
+ }
234
+ for (const tc of calls) {
235
+ const args = JSON.stringify(tc.args);
236
+ toolRows.push({
237
+ session_id: id,
238
+ seq,
239
+ idx: idx++,
240
+ source,
241
+ host,
242
+ tool_name: tc.name,
243
+ args: args.length > ARGS_MAX ? args.slice(0, ARGS_MAX) : args,
244
+ ts,
245
+ project,
246
+ folder,
247
+ });
248
+ }
249
+ }
250
+ return { session, msgRows, toolRows };
251
+ }
252
+
253
+ // One shipping pass. Incremental unless opts.full: a chat is skipped when the house
254
+ // already has it at least as fresh (last_updated_at) and at least as large
255
+ // (extra.bubbleCount) — both readable without parsing the chat.
256
+ async function runShip(client, opts = {}) {
257
+ const { full = false } = opts;
258
+ const host = hostId();
259
+ // Always load what the house holds — even with --full. The skip decision uses it
260
+ // only in incremental mode, but re-shipping a KNOWN session must clear its old
261
+ // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
262
+ // otherwise: ReplacingMergeTree collapses same-key rows only).
263
+ const existing = await loadExisting(client);
264
+ const chats = getAllChats();
265
+
266
+ const batches = { sessions: [], messages: [], tool_calls: [] };
267
+ const flush = async (table) => {
268
+ if (!batches[table].length) return;
269
+ await client.insert({
270
+ table,
271
+ values: batches[table],
272
+ format: 'JSONEachRow',
273
+ clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
274
+ });
275
+ batches[table] = [];
276
+ };
277
+ const push = async (table, row) => {
278
+ batches[table].push(row);
279
+ if (batches[table].length >= BATCH_ROWS) await flush(table);
280
+ };
281
+
282
+ const seen = new Set(); // adapters must not double-ship a session id within a pass
283
+ let sessions = 0, skipped = 0, msgRows = 0, toolRows = 0, unreadable = 0;
284
+ for (const chat of chats) {
285
+ if (chat.encrypted) continue;
286
+ // Same canonical '<source>:<adapter-local id>' as rowsForChat: dedup and
287
+ // incremental state are per (source, id) — two editors sharing an id must
288
+ // not skip or overwrite each other.
289
+ const id = `${chat.source}:${String(chat.composerId)}`;
290
+ if (seen.has(id)) continue;
291
+ seen.add(id);
292
+
293
+ const prev = existing.get(id);
294
+ if (!full && prev) {
295
+ // floor: some adapters (codex) emit fractional-ms timestamps, but DateTime64(3)
296
+ // stores whole ms — without it every such session loses by <1ms and re-ships.
297
+ const chatLast = Math.floor(chat.lastUpdatedAt || chat.createdAt || 0);
298
+ const notNewer = chatLast === 0 || (prev.ms !== null && prev.ms >= chatLast);
299
+ // Freshness alone is not enough: the transcript must be COMPLETE (stored
300
+ // row count == recorded message_count) so an interrupted re-ship — even one
301
+ // that died mid-flush leaving a partial transcript — repairs itself on the
302
+ // next pass instead of being skipped forever.
303
+ if (notNewer && prev.bc >= (chat.bubbleCount || 0) && prev.intact) { skipped++; continue; }
304
+ }
305
+
306
+ const rows = rowsForChat(chat, host);
307
+ if (!rows) { unreadable++; continue; } // write nothing → retried next pass
308
+ if (prev) {
309
+ // Known session being re-shipped: clear its old rows BEFORE inserting so a
310
+ // shorter re-parse can't leave stale seq/idx tails. A crash between the
311
+ // delete and the inserts is repaired by the next pass: the skip predicate
312
+ // refuses to skip a non-empty session whose message rows are missing.
313
+ for (const t of ['messages', 'tool_calls']) {
314
+ await client.command({
315
+ query: `DELETE FROM ${t} WHERE session_id = {id:String} AND user_id = currentUser()`,
316
+ query_params: { id },
317
+ clickhouse_settings: { async_insert: 0 },
318
+ });
319
+ }
320
+ }
321
+ await push('sessions', rows.session);
322
+ sessions++;
323
+ for (const r of rows.msgRows) { await push('messages', r); msgRows++; }
324
+ for (const r of rows.toolRows) { await push('tool_calls', r); toolRows++; }
325
+ }
326
+ for (const table of Object.keys(batches)) await flush(table);
327
+ return { sessions, skipped, msgRows, toolRows, unreadable };
328
+ }
329
+
330
+ // Per-source rollup straight from sessions_v (final=1 so ReplacingMergeTree collapses).
331
+ async function printStats(client) {
332
+ const rs = await client.query({
333
+ query: `
334
+ SELECT source,
335
+ count() AS sessions,
336
+ sum(total_msgs) AS messages,
337
+ sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
338
+ FROM sessions_v
339
+ GROUP BY source
340
+ ORDER BY sessions DESC`,
341
+ format: 'JSONEachRow',
342
+ clickhouse_settings: { final: 1 },
343
+ });
344
+ const rows = await rs.json();
345
+ if (!rows.length) { console.log('[mem-house] house is empty'); return; }
346
+ const w = [Math.max(6, ...rows.map((r) => r.source.length)), 8, 8, 14];
347
+ const line = (a, b, c, d) =>
348
+ console.log(`${String(a).padEnd(w[0])} ${String(b).padStart(w[1])} ${String(c).padStart(w[2])} ${String(d).padStart(w[3])}`);
349
+ line('source', 'sessions', 'messages', 'tokens');
350
+ let ts = 0, tm = 0, tt = 0;
351
+ for (const r of rows) {
352
+ line(r.source, r.sessions, r.messages, r.tokens);
353
+ ts += Number(r.sessions); tm += Number(r.messages); tt += Number(r.tokens);
354
+ }
355
+ line('total', ts, tm, tt);
356
+ }
357
+
358
+ async function main() {
359
+ const argv = process.argv.slice(2);
360
+ const client = makeClient();
361
+ try {
362
+ if (argv.includes('--ensure-schema')) {
363
+ const n = await ensureSchema(client);
364
+ console.log(`[mem-house] schema ensured (${n} statements)`);
365
+ return;
366
+ }
367
+ if (argv.includes('--stats')) { await printStats(client); return; }
368
+
369
+ const loopIdx = argv.indexOf('--loop');
370
+ const loop = loopIdx !== -1;
371
+ let intervalSec = 300;
372
+ if (loop) {
373
+ const n = Number(argv[loopIdx + 1]);
374
+ if (Number.isFinite(n) && n > 0) intervalSec = n;
375
+ }
376
+ let full = argv.includes('--full');
377
+ do {
378
+ const t0 = Date.now();
379
+ try {
380
+ const r = await runShip(client, { full });
381
+ console.log(`[mem-house] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
382
+ `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
383
+ } catch (e) {
384
+ console.error(`[mem-house] pass failed: ${e.message}`);
385
+ if (!loop) process.exitCode = 1;
386
+ }
387
+ full = false; // --full applies to the first pass only; loop passes stay incremental
388
+ if (loop) {
389
+ await new Promise((r) => setTimeout(r, intervalSec * 1000));
390
+ resetCaches(); // adapters cache chat lists; drop them so new sessions surface
391
+ }
392
+ } while (loop);
393
+ } finally {
394
+ await client.close();
395
+ }
396
+ }
397
+
398
+ module.exports = { runShip, ensureSchema };
399
+
400
+ if (require.main === module) {
401
+ main().catch((e) => { console.error(`[mem-house] fatal: ${e.message}`); process.exit(1); });
402
+ }
@@ -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);