memhouse 0.4.4 → 0.7.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 (63) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1177 -231
  3. package/editors/base.js +6 -0
  4. package/editors/claude.js +44 -9
  5. package/editors/goose.js +4 -0
  6. package/editors/index.js +0 -0
  7. package/editors/opencode.js +3 -0
  8. package/{mem-house → memhouse}/COMPETITION.md +5 -5
  9. package/{mem-house → memhouse}/DESIGN.md +18 -12
  10. package/memhouse/RESUME-AND-UPDATE.md +185 -0
  11. package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
  12. package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
  13. package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
  14. package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
  15. package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
  16. package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
  17. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
  18. package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
  19. package/memhouse/host.js +101 -0
  20. package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
  21. package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
  22. package/{mem-house → memhouse}/per-member/SHARING.md +47 -0
  23. package/memhouse/per-member/provision.js +297 -0
  24. package/memhouse/per-member/rooms.js +252 -0
  25. package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
  26. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  27. package/memhouse/resume.js +150 -0
  28. package/memhouse/self-update.js +150 -0
  29. package/{mem-house → memhouse}/server/queries.js +80 -34
  30. package/memhouse/server/server.js +451 -0
  31. package/{mem-house → memhouse}/service.js +48 -29
  32. package/memhouse/shipper/ship.js +949 -0
  33. package/package.json +5 -20
  34. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  35. package/public/index.html +2 -2
  36. package/ui/index.html +1 -1
  37. package/ui/src/App.jsx +1 -1
  38. package/ui/src/lib/api.js +29 -13
  39. package/ui/src/pages/Dashboard.jsx +8 -5
  40. package/ui/src/pages/Sessions.jsx +3 -1
  41. package/ui/src/pages/SqlViewer.jsx +6 -23
  42. package/cache.js +0 -850
  43. package/ch-ingest.js +0 -329
  44. package/ch-schema.js +0 -152
  45. package/clickhouse.js +0 -87
  46. package/index.js +0 -425
  47. package/mcp-server.js +0 -294
  48. package/mem-house/delivery/install.sh +0 -183
  49. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  50. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  51. package/mem-house/per-member/provision.js +0 -142
  52. package/mem-house/per-member/rooms.js +0 -132
  53. package/mem-house/server/server.js +0 -228
  54. package/mem-house/shipper/ship.js +0 -566
  55. package/relay-client.js +0 -306
  56. package/relay-server.js +0 -577
  57. package/server.js +0 -860
  58. package/share-image.js +0 -367
  59. /package/{mem-house → memhouse}/deploy.js +0 -0
  60. /package/{mem-house → memhouse}/envfile.js +0 -0
  61. /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
  62. /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
  63. /package/{mem-house → memhouse}/per-member/README.md +0 -0
@@ -1,12 +1,12 @@
1
- // queries.js — mem-house read/query layer.
1
+ // queries.js — memhouse read/query layer.
2
2
  //
3
3
  // Serves the SAME response shapes as the root cache.js (the agentlytics API
4
- // contract) but computed over the mem-house typed schema — one set of rooms per member,
4
+ // contract) but computed over the memhouse typed schema — one set of rooms per member,
5
5
  // `sessions_<m>` / `messages_<m>` / `tool_calls_<m>` (../per-member/schema-member.sql.tpl),
6
6
  // plus the session rollup, which is a SAVED QUERY and not an object
7
7
  // (../per-member/rooms.js). The dashboard SPA consumes these shapes unchanged.
8
8
  //
9
- // Schema mapping notes (root cache schema → mem-house):
9
+ // Schema mapping notes (root cache schema → memhouse):
10
10
  // chats → sessions_<m> / the rollup (id → session_id, bubble_count → total_msgs)
11
11
  // chat_stats → rollup aggregates (token/char sums, models array)
12
12
  // messages.content → messages_<m>.text (cache_read → cache_read_tokens, …)
@@ -16,7 +16,7 @@
16
16
  // All reads run with the `final: 1` setting so ReplacingMergeTree collapses
17
17
  // duplicate versions at read time (see DESIGN.md data-plane rules).
18
18
 
19
- const { createClient } = require('@clickhouse/client');
19
+ const { createClient, ClickHouseLogLevel } = require('@clickhouse/client');
20
20
  const { calculateCost, normalizeModelName } = require('../../pricing');
21
21
  const { resolveRooms, READ_SETTINGS } = require('../per-member/rooms');
22
22
 
@@ -31,6 +31,9 @@ let _client = null;
31
31
  function getClient() {
32
32
  if (_client) return _client;
33
33
  _client = createClient({
34
+ // See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
35
+ // message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
36
+ log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
34
37
  url: config.url,
35
38
  username: config.username,
36
39
  password: config.password,
@@ -74,9 +77,10 @@ function applyRooms(sql, r) {
74
77
  });
75
78
  }
76
79
 
77
- async function q(query, params = {}) {
80
+ async function q(query, params = {}, settings = undefined) {
78
81
  const rs = await getClient().query({
79
82
  query: applyRooms(query, await rooms()), query_params: params, format: 'JSONEachRow',
83
+ ...(settings ? { clickhouse_settings: settings } : {}),
80
84
  });
81
85
  return rs.json();
82
86
  }
@@ -145,18 +149,30 @@ function normalizedModelFreq(rows) {
145
149
  }
146
150
  return freq;
147
151
  }
152
+ // The same exclusions as EXCLUDED_MODELS, usable in JS.
153
+ const EXCLUDED_MODEL_SET = new Set(['', '<synthetic>']);
148
154
  const topN = (freq, n) => Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, n)
149
155
  .map(([name, count]) => ({ name, count }));
150
156
 
151
157
  // ── overview ────────────────────────────────────────────────────────────────────
152
158
  async function getOverview(opts = {}) {
153
159
  const f = filters(opts);
154
- const totalChats = Number((await q1(`SELECT count() AS cnt FROM {{sessions_v}} AS c WHERE 1=1${f.and}`, f.params)).cnt);
155
-
156
- // Root parity: without a folder filter the editor breakdown is global.
157
- const editors = opts.folder
158
- ? await q(`SELECT source, count() AS count FROM {{sessions_v}} AS c WHERE 1=1${f.and} GROUP BY source ORDER BY count DESC`, f.params)
159
- : await q('SELECT source, count() AS count FROM {{sessions_v}} GROUP BY source ORDER BY count DESC');
160
+ // Same definition as getChats(). The nav bar read this and the Sessions page read
161
+ // getChats, so a house with one placeholder session showed "9 sessions" beside
162
+ // "8 sessions" in the same header, and the depth histogram summed to 7. A session row
163
+ // with no name and no messages is a placeholder the shipper keeps to absorb the
164
+ // incremental skip; it is not a conversation, and only one of the two counters knew.
165
+ const totalChats = Number((await q1(
166
+ `SELECT count() AS cnt FROM {{sessions_v}} AS c WHERE 1=1${f.and} AND (c.name != '' OR c.total_msgs > 0)`,
167
+ f.params)).cnt);
168
+
169
+ // Root parity WAS: without a folder filter the editor breakdown is global — which put
170
+ // "34 + 3" chips under a header reading "3 sessions" when an editor filter was applied,
171
+ // and 12 under a header of 11 with no filter at all. Parity with a wrong number is not a
172
+ // feature.
173
+ const editors = await q(
174
+ `SELECT source, count() AS count FROM {{sessions_v}} AS c
175
+ WHERE (c.name != '' OR c.total_msgs > 0)${f.and} GROUP BY source ORDER BY count DESC`, f.params);
160
176
 
161
177
  const modes = await q(`SELECT mode, count() AS count FROM {{sessions_v}} AS c WHERE mode != ''${f.and} GROUP BY mode`, f.params);
162
178
  const byMode = {};
@@ -253,7 +269,11 @@ async function getDashboardStats(opts = {}) {
253
269
  SELECT COALESCE(sum(input_tokens), 0) AS input, COALESCE(sum(output_tokens), 0) AS output,
254
270
  COALESCE(sum(cache_read_tokens), 0) AS cacheRead, COALESCE(sum(cache_write_tokens), 0) AS cacheWrite,
255
271
  COALESCE(sum(user_chars), 0) AS userChars, COALESCE(sum(assistant_chars), 0) AS assistantChars,
256
- count() AS sessions
272
+ COALESCE(sum(total_msgs), 0) AS messages,
273
+ -- The SAME definition the KPI beside it uses. A plain count() here put a
274
+ -- fourth session number on a screen that already had three: nav 11, chips 12,
275
+ -- avg depth 40/12, histogram 10.
276
+ countIf(c.name != '' OR c.total_msgs > 0) AS sessions
257
277
  FROM {{sessions_v}} AS c WHERE 1=1${f.and}`, f.params);
258
278
 
259
279
  const streakRows = await q(`
@@ -323,7 +343,7 @@ async function getDashboardStats(opts = {}) {
323
343
  tokens: {
324
344
  input: inputTokens, output: outputTokens, cacheRead: Number(tokenRow.cacheRead), cacheWrite: Number(tokenRow.cacheWrite),
325
345
  userChars: Number(tokenRow.userChars), assistantChars: Number(tokenRow.assistantChars),
326
- sessions: Number(tokenRow.sessions), estimated: tokensEstimated,
346
+ sessions: Number(tokenRow.sessions), messages: Number(tokenRow.messages), estimated: tokensEstimated,
327
347
  },
328
348
  streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
329
349
  monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
@@ -359,26 +379,30 @@ async function getChats(opts = {}) {
359
379
  const rows = await q(sql, params);
360
380
  if (rows.length === 0) return [];
361
381
 
362
- // Top model per rollup = most frequent model across THAT WRITER's messages
363
- // (session_id alone would blend colliding sessions across members).
364
- const ids = rows.map(r => r.id);
365
- const tmRows = await q(`
366
- SELECT session_id, user_id, argMax(model, cnt) AS top_model
367
- FROM (SELECT session_id, user_id, model, count() AS cnt FROM {{messages}}
368
- WHERE session_id IN {ids:Array(String)} AND model NOT IN ${EXCLUDED_MODELS}
369
- GROUP BY session_id, user_id, model)
370
- GROUP BY session_id, user_id`, { ids });
382
+ // ONE cost implementation, shared with the Costs page.
383
+ //
384
+ // getChats used to compute its own, and the two disagreed three times running: first the
385
+ // whole session priced at its dominant model ($10.02 against $150), then sessions whose
386
+ // rows carry no model at all ($0 against $10.50), then sessions with zero tokens but
387
+ // non-zero chars ($0 against $0.033). Each round fixed the case that had been measured
388
+ // and left the next one. computePerChatCosts already handles all of them — the orphan
389
+ // bucket, the source-dominant and global-dominant fallbacks, the char estimate — so call
390
+ // it instead of growing a second copy of the same logic.
391
+ const perChat = await computePerChatCosts(f);
392
+ const costBySession = {};
371
393
  const topModelBySession = {};
372
- for (const r of tmRows) topModelBySession[`${r.session_id}::${r.user_id}`] = r.top_model;
394
+ for (const r of perChat) {
395
+ // computePerChatCosts already emits the composite key as `id`.
396
+ const k = r.id;
397
+ costBySession[k] = { total: r.totalCost, unpriced: !!r.hasUnpriced };
398
+ if (r.byModel[0]) topModelBySession[k] = r.byModel[0].model;
399
+ }
373
400
 
374
401
  return rows.map(r => {
375
- const topModel = topModelBySession[`${r.id}::${r.user_id}`] || null;
376
- let inTok = Number(r._inTok) || 0, outTok = Number(r._outTok) || 0;
377
- if (inTok === 0 && outTok === 0 && ((r._uChars || 0) > 0 || (r._aChars || 0) > 0)) {
378
- inTok = Math.round((r._uChars || 0) / 4);
379
- outTok = Math.round((r._aChars || 0) / 4);
380
- }
381
- const cost = topModel ? (calculateCost(topModel, inTok, outTok, Number(r._cacheR) || 0, Number(r._cacheW) || 0) || 0) : 0;
402
+ const k = `${r.id}::${r.user_id}`;
403
+ const topModel = topModelBySession[k] || null;
404
+ const summed = costBySession[k];
405
+ const cost = summed ? summed.total : 0;
382
406
  return {
383
407
  // Composite API id: rollups are keyed (session_id, user_id), so the id a
384
408
  // client clicks must pin BOTH — otherwise opening one of two colliding
@@ -388,6 +412,9 @@ async function getChats(opts = {}) {
388
412
  source: r.source, name: r.name, mode: r.mode, folder: r.folder,
389
413
  createdAt: r.created_at, lastUpdatedAt: r.last_updated_at,
390
414
  encrypted: false, bubbleCount: Number(r.bubble_count), topModel, cost,
415
+ // Surfaced, not just computed. A session containing an unpriced model reports a cost
416
+ // that silently omits part of itself; the caller can now say so.
417
+ costPartial: !!(summed && summed.unpriced),
391
418
  };
392
419
  });
393
420
  }
@@ -643,7 +670,7 @@ async function getToolCalls(toolName, opts = {}) {
643
670
  // ── cost estimation ─────────────────────────────────────────────────────────────
644
671
  // Messages with model '' or '<synthetic>' are "orphans": their tokens are
645
672
  // attributed to the session's dominant model (root cache.js treats model IS NULL
646
- // the same way; '' is the mem-house null and synthetic models have no pricing).
673
+ // the same way; '' is the memhouse null and synthetic models have no pricing).
647
674
  const ORPHAN_TOKENS = '(m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read_tokens > 0 OR m.cache_write_tokens > 0)';
648
675
 
649
676
  // session_id → dominant model (most frequent across the session's messages).
@@ -821,15 +848,19 @@ async function computePerChatCosts(f) {
821
848
  if (srcDom) add(srcDom, c.ti, c.to_, c.cr, c.cw);
822
849
  }
823
850
  let totalCost = 0; const byModel = [];
851
+ // A model with no price contributes nothing to totalCost, so the number a session
852
+ // reports silently omits part of itself. Carry the fact alongside the number.
853
+ let hasUnpriced = false;
824
854
  for (const [model, tok] of Object.entries(tokenMap)) {
825
855
  const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
826
856
  if (cost !== null) { totalCost += cost; byModel.push({ model, cost }); }
857
+ else hasUnpriced = true;
827
858
  }
828
859
  byModel.sort((a, b) => b.cost - a.cost);
829
860
  out.push({
830
861
  id: key, user: c.user_id, source: c.source, name: c.name, folder: c.folder,
831
862
  last_updated_at: c.last_updated_at, created_at: c.created_at,
832
- msgs: Number(c.msgs), month: c.month, totalCost, byModel,
863
+ msgs: Number(c.msgs), month: c.month, totalCost, byModel, hasUnpriced,
833
864
  });
834
865
  }
835
866
  return out;
@@ -881,10 +912,22 @@ async function getCostAnalytics(opts = {}) {
881
912
  }
882
913
 
883
914
  // ── raw SQL / schema (SqlViewer) ────────────────────────────────────────────────
915
+ // Bounded at the SERVER, not in JS. `q()` buffers every row into memory, so
916
+ // `SELECT number FROM numbers(2000000000)` killed the whole dashboard process —
917
+ // "FATAL ERROR: Ineffective mark-compacts near heap limit", unauthenticated, on the bound
918
+ // port. Capping after the rows arrive is too late; ClickHouse has to stop sending.
919
+ const RAW_MAX_ROWS = 10000;
920
+ const RAW_MAX_BYTES = 64 * 1024 * 1024;
884
921
  async function rawQuery(sql) {
885
- const rows = await q(sql);
922
+ const rows = await q(sql, {}, {
923
+ max_result_rows: RAW_MAX_ROWS,
924
+ max_result_bytes: RAW_MAX_BYTES,
925
+ // Cut the result off rather than failing a query that was legitimately large.
926
+ result_overflow_mode: 'break',
927
+ max_execution_time: 30,
928
+ });
886
929
  const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
887
- return { columns, rows, count: rows.length };
930
+ return { columns, rows, count: rows.length, truncated: rows.length >= RAW_MAX_ROWS };
888
931
  }
889
932
 
890
933
  async function schema() {
@@ -925,4 +968,7 @@ module.exports = {
925
968
  getProjects, getDeepAnalytics, getToolCalls,
926
969
  estimateCosts, getCostAnalytics,
927
970
  rawQuery, schema,
971
+ // Used only by the server's own guard to learn which database names are real. Not
972
+ // reachable from any route.
973
+ rawQueryUnguarded: (sql) => q(sql),
928
974
  };