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.
- package/LICENSE +22 -0
- package/README.md +72 -3
- package/bin/memhouse.js +559 -0
- package/cache.js +850 -0
- package/ch-ingest.js +329 -0
- package/ch-schema.js +152 -0
- package/clickhouse.js +87 -0
- package/editors/adapter-errors.js +52 -0
- package/editors/antigravity.js +1048 -0
- package/editors/base.js +321 -0
- package/editors/claude.js +386 -0
- package/editors/codebuff.js +338 -0
- package/editors/codex.js +537 -0
- package/editors/commandcode.js +166 -0
- package/editors/copilot-jetbrains.js +207 -0
- package/editors/copilot.js +260 -0
- package/editors/cursor-agent.js +202 -0
- package/editors/cursor.js +498 -0
- package/editors/gemini.js +231 -0
- package/editors/goose.js +386 -0
- package/editors/gsd.js +366 -0
- package/editors/index.js +274 -0
- package/editors/kiro.js +315 -0
- package/editors/opencode.js +432 -0
- package/editors/vscode.js +413 -0
- package/editors/windsurf.js +634 -0
- package/editors/zed.js +229 -0
- package/index.js +424 -2
- package/mcp-server.js +294 -0
- package/mem-house/DESIGN.md +119 -0
- package/mem-house/delivery/AGENT-INSTALL.md +87 -0
- package/mem-house/delivery/PROMPT.md +42 -0
- package/mem-house/delivery/install.sh +181 -0
- package/mem-house/delivery/kernel-install.md +83 -0
- package/mem-house/delivery/plugin/.claude-plugin/plugin.json +8 -0
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +104 -0
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +94 -0
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +115 -0
- package/mem-house/rls.sql +17 -0
- package/mem-house/schema.sql +145 -0
- package/mem-house/server/queries.js +878 -0
- package/mem-house/server/server.js +228 -0
- package/mem-house/shipper/ship.js +461 -0
- package/misc/syntax-check.js +38 -0
- package/package.json +59 -7
- package/pricing.js +88 -0
- package/pricing.json +992 -0
- package/public/assets/index-CCPh7a2Z.js +75 -0
- package/public/assets/index-DV6ONi_F.css +2 -0
- package/public/index.html +16 -0
- package/relay-client.js +306 -0
- package/relay-server.js +577 -0
- package/server.js +860 -0
- package/share-image.js +367 -0
- package/ui/eslint.config.js +29 -0
- package/ui/index.html +15 -0
- package/ui/package-lock.json +4229 -0
- package/ui/package.json +38 -0
- package/ui/src/App.jsx +364 -0
- package/ui/src/components/ActivityHeatmap.jsx +206 -0
- package/ui/src/components/AiAuditCard.jsx +254 -0
- package/ui/src/components/AnimatedLoader.jsx +14 -0
- package/ui/src/components/AnimatedLogo.jsx +96 -0
- package/ui/src/components/ChatSidebar.jsx +226 -0
- package/ui/src/components/DateRangePicker.jsx +104 -0
- package/ui/src/components/EditorDot.jsx +11 -0
- package/ui/src/components/EditorIcon.jsx +117 -0
- package/ui/src/components/KpiCard.jsx +9 -0
- package/ui/src/components/LiveFeed.jsx +128 -0
- package/ui/src/components/LoginScreen.jsx +81 -0
- package/ui/src/components/MessageRenderer.jsx +148 -0
- package/ui/src/components/PageHeader.jsx +11 -0
- package/ui/src/components/SectionTitle.jsx +3 -0
- package/ui/src/components/ShareModal.jsx +305 -0
- package/ui/src/components/TokenTimeline.jsx +258 -0
- package/ui/src/hooks/useLive.jsx +15 -0
- package/ui/src/index.css +83 -0
- package/ui/src/lib/api.js +343 -0
- package/ui/src/lib/constants.js +88 -0
- package/ui/src/lib/theme.jsx +23 -0
- package/ui/src/main.jsx +16 -0
- package/ui/src/pages/Artifacts.jsx +575 -0
- package/ui/src/pages/Compare.jsx +232 -0
- package/ui/src/pages/CostAnalysis.jsx +354 -0
- package/ui/src/pages/Dashboard.jsx +529 -0
- package/ui/src/pages/DeepAnalysis.jsx +495 -0
- package/ui/src/pages/GSD.jsx +726 -0
- package/ui/src/pages/MCPs.jsx +744 -0
- package/ui/src/pages/ProjectDetail.jsx +325 -0
- package/ui/src/pages/Projects.jsx +249 -0
- package/ui/src/pages/RelayDashboard.jsx +590 -0
- package/ui/src/pages/RelayUserDetail.jsx +466 -0
- package/ui/src/pages/Sessions.jsx +535 -0
- package/ui/src/pages/Settings.jsx +241 -0
- package/ui/src/pages/SqlViewer.jsx +370 -0
- package/ui/src/pages/Subscriptions.jsx +411 -0
- 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.
|
|
4
|
-
"description": "
|
|
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": "
|
|
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
|
-
"
|
|
13
|
-
|
|
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
|
-
"
|
|
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 };
|