memhouse 0.4.4 → 0.7.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 (63) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1162 -230
  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/memhouse/per-member/provision.js +265 -0
  23. package/memhouse/per-member/rooms.js +235 -0
  24. package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
  25. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  26. package/memhouse/resume.js +150 -0
  27. package/memhouse/self-update.js +150 -0
  28. package/{mem-house → memhouse}/server/queries.js +80 -34
  29. package/memhouse/server/server.js +451 -0
  30. package/{mem-house → memhouse}/service.js +48 -29
  31. package/memhouse/shipper/ship.js +949 -0
  32. package/package.json +5 -20
  33. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  34. package/public/index.html +2 -2
  35. package/ui/index.html +1 -1
  36. package/ui/src/App.jsx +1 -1
  37. package/ui/src/lib/api.js +29 -13
  38. package/ui/src/pages/Dashboard.jsx +8 -5
  39. package/ui/src/pages/Sessions.jsx +3 -1
  40. package/ui/src/pages/SqlViewer.jsx +6 -23
  41. package/cache.js +0 -850
  42. package/ch-ingest.js +0 -329
  43. package/ch-schema.js +0 -152
  44. package/clickhouse.js +0 -87
  45. package/index.js +0 -425
  46. package/mcp-server.js +0 -294
  47. package/mem-house/delivery/install.sh +0 -183
  48. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  49. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  50. package/mem-house/per-member/provision.js +0 -142
  51. package/mem-house/per-member/rooms.js +0 -132
  52. package/mem-house/server/server.js +0 -228
  53. package/mem-house/shipper/ship.js +0 -566
  54. package/relay-client.js +0 -306
  55. package/relay-server.js +0 -577
  56. package/server.js +0 -860
  57. package/share-image.js +0 -367
  58. /package/{mem-house → memhouse}/deploy.js +0 -0
  59. /package/{mem-house → memhouse}/envfile.js +0 -0
  60. /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
  61. /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
  62. /package/{mem-house → memhouse}/per-member/README.md +0 -0
  63. /package/{mem-house → memhouse}/per-member/SHARING.md +0 -0
@@ -1,4 +1,4 @@
1
- -- mem — one member's rooms. Applied per member; {{MEMBER}} is the ClickHouse username.
1
+ -- mem — one member's rooms. Applied per member — {{MEMBER}} is the ClickHouse username.
2
2
  --
3
3
  -- Room naming is TYPE-FIRST (sessions_<m>, not <m>_sessions) so the Merge rooms
4
4
  -- can anchor on a fixed room type and never match themselves.
@@ -7,7 +7,7 @@
7
7
  -- it keeps provenance across a share and keeps the Merge rooms meaningful. Writers must
8
8
  -- use async_insert=0 or the stamp does not happen.
9
9
  --
10
- -- ClickHouse >= 26.2 natively; 25.11 works because the shipper passes
10
+ -- ClickHouse >= 26.2 natively — 25.11 works because the shipper passes
11
11
  -- allow_experimental_full_text_index=1 per-query when applying this file.
12
12
 
13
13
  CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
@@ -25,23 +25,27 @@ CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
25
25
  message_count UInt32 DEFAULT 0,
26
26
  path String DEFAULT '',
27
27
  extra JSON,
28
- -- Who put this row here. The shipper CLEARS a session before re-inserting it (a
29
- -- shorter re-parse must not leave a stale seq tail), and that clear used to remove
30
- -- every row for the session regardless of origin -- destroying imported history the
31
- -- adapters cannot reproduce. The clear now binds origin='ship', so anything imported
32
- -- survives a re-ship of the same session.
33
- -- IN THE SORTING KEY, and that is the load-bearing half. Guarding the DELETE alone
34
- -- is not enough: ReplacingMergeTree collapses on the sorting key, so an imported row
35
- -- and a shipped row sharing (session_id, user_id, seq) are the SAME row and the newer
36
- -- ingested_at wins. Measured: 3 imported + 2 shipped rows became 3, and the two
37
- -- imported ones the shipper happened to overlap were gone. Same loss as the
38
- -- unguarded delete, reached through the merge instead.
28
+ -- Who put this row here. Carried on every room so a reader can tell shipped rows
29
+ -- from imported ones, and so the shipper's clear can bind origin='ship'.
30
+ --
31
+ -- NOTE the sorting key below does NOT include origin, and sessions is the one room
32
+ -- where that is correct. A session has exactly ONE metadata row: title, counts,
33
+ -- bounds. If origin were in the key, an imported session and its shipped twin would
34
+ -- be two rows, and everything that reads sessions as one-row-per-session breaks --
35
+ -- sessions_v joins messages twice and over-reports (measured on a real house: one
36
+ -- duplicate row inflated the totals by 1,764 messages and 655M tokens), and the
37
+ -- incremental skip cannot decide which row is current. Collapsing them is what we
38
+ -- want: the newer ingested_at wins, which is the shipper's row for any session that
39
+ -- still exists on disk. Nothing is lost -- a session the adapters no longer see is
40
+ -- never cleared and never re-inserted, so its imported row stands untouched.
41
+ --
42
+ -- messages and tool_calls are the opposite case and DO key on origin -- see there.
39
43
  origin LowCardinality(String) DEFAULT 'ship',
40
44
  user_id String MATERIALIZED currentUser(),
41
45
  ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
42
46
  )
43
47
  ENGINE = ReplacingMergeTree(ingested_at)
44
- ORDER BY (session_id, user_id, origin);
48
+ ORDER BY (session_id, user_id);
45
49
 
46
50
  CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
47
51
  (
@@ -6,13 +6,13 @@
6
6
  --
7
7
  -- Three measured properties this relies on (ClickHouse 26.7.1):
8
8
  -- * fails closed on permissions — a caller sees only the underlying rooms they hold
9
- -- grants for, with no leak and no error, so these can be granted broadly;
10
- -- * auto-discovers rooms created after them, so member onboarding needs no DDL here;
9
+ -- grants for, with no leak and no error, so these can be granted broadly —
10
+ -- * auto-discovers rooms created after them, so member onboarding needs no DDL here —
11
11
  -- * tolerates schema drift, returning a column's default for rooms lacking it — which
12
12
  -- is what makes schema rollout progressive. Merge REJECTS mutations, so a new column
13
13
  -- is added with ADD COLUMN, on the Merge room FIRST.
14
14
  --
15
- -- {{TEMPLATE_MEMBER}} is any existing member; the Merge room borrows its column list.
15
+ -- {{TEMPLATE_MEMBER}} is any existing member — the Merge room borrows its column list.
16
16
 
17
17
  CREATE TABLE IF NOT EXISTS all_sessions AS sessions_{{TEMPLATE_MEMBER}}
18
18
  ENGINE = Merge(currentDatabase(), '^sessions_');
@@ -0,0 +1,150 @@
1
+ // Resume: hand the pilot back into a session memhouse only ever read.
2
+ //
3
+ // Nothing here needs a schema change. A stored session already carries everything a
4
+ // resume needs: `session_id` is canonical `<source>:<native-id>` (ship.js:575) and
5
+ // `folder` is the directory the session ran in. Resume is a read-time computation over
6
+ // two columns.
7
+ //
8
+ // Two rules, both learned the hard way in this repo:
9
+ //
10
+ // 1. PRINT the command, never run it. A wrong cwd or a stale id does not fail — it
11
+ // starts a NEW session, and the transcript the pilot wanted is still lost while the
12
+ // tool reports success. The pilot pastes it, sees what it does, and owns the result.
13
+ //
14
+ // 2. Never write a flag from memory. Round 10 exists because a field name was asserted
15
+ // from a failed search rather than read from the source. Every entry below was read
16
+ // out of that CLI's own `--help` on a machine where it is installed, and the date and
17
+ // version are recorded with it. An editor whose flag has not been read that way is
18
+ // ABSENT from this table — an absent entry prints an honest refusal, a guessed entry
19
+ // prints a command that silently does the wrong thing.
20
+ //
21
+ // To add one: install the CLI, run `<cli> --help` (and `<cli> resume --help` if it is a
22
+ // subcommand), copy the exact syntax, and record what you read it from.
23
+
24
+ // Keyed by the string that is actually STORED in the `source` column, which is NOT the
25
+ // adapter's module `name`: editors/claude.js is `const name = 'claude'` and emits
26
+ // `source: 'claude-code'` on every chat it returns. This table was written against the
27
+ // module names first and would have refused to resume a single Claude Code session — 508 of
28
+ // them on the machine it was tested on. Read the values out of a real house
29
+ // (`SELECT DISTINCT source FROM sessions_<you>`), never off the adapter's name.
30
+ const RESUMERS = {
31
+ // claude --help, 2026-08-12: "-r, --resume [value] Resume a conversation by session ID"
32
+ 'claude-code': {
33
+ cli: 'claude',
34
+ argv: (id) => ['--resume', id],
35
+ verified: '2026-08-12',
36
+ },
37
+ // codex resume --help, 2026-08-12: "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]"
38
+ // — positional, and "UUIDs take precedence if it parses".
39
+ codex: {
40
+ cli: 'codex',
41
+ argv: (id) => ['resume', id],
42
+ verified: '2026-08-12',
43
+ },
44
+ // opencode --help, 2026-08-12: "-s, --session session id to continue [string]"
45
+ opencode: {
46
+ cli: 'opencode',
47
+ argv: (id) => ['--session', id],
48
+ verified: '2026-08-12',
49
+ },
50
+ // NOT here, deliberately, and each for a different reason:
51
+ //
52
+ // goose, gemini-cli, cursor-agent — plausibly resumable, but not installed on the
53
+ // machine this table was written on, so the flag could only have been guessed. Install
54
+ // one, read its --help, add it.
55
+ //
56
+ // everything in GUI_ONLY — no CLI entry point takes a session id there. Opening the
57
+ // folder is not resuming the session and must not be dressed up as it.
58
+ };
59
+
60
+ // Surfaces with no CLI to resume into AT ALL, separated from "the flag has not been read
61
+ // yet" so the refusal can say which of the two it is — the same reason `discover` separates
62
+ // a skipped adapter from an editor you do not have.
63
+ //
64
+ // Stored `source` values again, and two corrections already: `copilot.js` emits
65
+ // `copilot-cli` and IS a command line, so it does not belong here; and windsurf never emits
66
+ // 'windsurf' at all — it emits its VARIANTS id, `devin` or `devin-next` — so the name this
67
+ // list first carried matched nothing and every Devin session fell through to the wrong
68
+ // refusal.
69
+ //
70
+ // Membership means: no command line takes a session id here, so no amount of checking will
71
+ // produce one. Anything merely UNCHECKED belongs in UNVERIFIED — asserting "there is
72
+ // nothing to resume into" about a tool nobody has looked at is the same invented fact as
73
+ // guessing a flag.
74
+ const GUI_ONLY = new Set([
75
+ 'cursor', 'vscode', 'zed', 'kiro', 'copilot-jetbrains', 'antigravity',
76
+ 'devin', 'devin-next',
77
+ ]);
78
+
79
+ // A plausible CLI that nobody has verified. Listed explicitly instead of left to fall
80
+ // through a default, so the completeness test can prove every source the adapters can emit
81
+ // lands in exactly one bucket — which is what would have caught both corrections above
82
+ // before they shipped.
83
+ const UNVERIFIED = new Set([
84
+ 'goose', 'gemini-cli', 'cursor-agent', 'copilot-cli', 'codebuff', 'commandcode', 'gsd',
85
+ ]);
86
+
87
+ // A session_id is `<source>:<native-id>`. Split on the FIRST colon only: several editors
88
+ // use colons inside their own ids, and slicing on the last one hands the CLI a truncated id
89
+ // that resolves to nothing.
90
+ function splitSessionId(sessionId) {
91
+ const i = String(sessionId).indexOf(':');
92
+ if (i <= 0) return null;
93
+ return { source: sessionId.slice(0, i), nativeId: sessionId.slice(i + 1) };
94
+ }
95
+
96
+ // Shell-quote for the line we PRINT. This never feeds an exec — it is text a human pastes
97
+ // — but a session folder with a space in it must still paste correctly, and an id is
98
+ // server-supplied data that has no business being read as shell syntax.
99
+ function shq(s) {
100
+ return /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`;
101
+ }
102
+
103
+ /**
104
+ * Resolve a stored session row into something the pilot can paste.
105
+ *
106
+ * @param {{session_id: string, source?: string, folder?: string, origin?: string}} row
107
+ * @returns {{ok: boolean, source: string, nativeId: string|null, folder: string,
108
+ * command: string|null, reason: string|null}}
109
+ */
110
+ function resumeFor(row) {
111
+ const parts = splitSessionId(row.session_id);
112
+ // `source` is its own column, but the id prefix is what the CLI half of the pair is keyed
113
+ // on; prefer the column and fall back to the prefix so a row from an older house still
114
+ // resolves.
115
+ const source = row.source || (parts && parts.source) || '';
116
+ const nativeId = parts ? parts.nativeId : null;
117
+ const folder = row.folder || '';
118
+ const base = { source, nativeId, folder, command: null, ok: false, reason: null };
119
+
120
+ if (!nativeId) {
121
+ return { ...base, reason: `'${row.session_id}' is not a canonical <source>:<id> session id` };
122
+ }
123
+ // Imported rows come from an older house, another product, or a machine that no longer
124
+ // exists — the whole point of the `origin` guard the shipper carries. There is no local
125
+ // session store behind them, so no resume command can be right, and this is checked
126
+ // BEFORE the source table: 502 of the 1,304 sessions on the machine this was built on are
127
+ // imported `claude-ai` rows, and a source-only check would have offered to resume them.
128
+ if (row.origin && row.origin !== 'ship') {
129
+ return {
130
+ ...base,
131
+ reason: `this session was imported (origin='${row.origin}'), not read from a local editor — there is nothing on this machine to resume into`,
132
+ };
133
+ }
134
+ const r = RESUMERS[source];
135
+ if (!r) {
136
+ return {
137
+ ...base,
138
+ reason: GUI_ONLY.has(source)
139
+ ? `${source} is a GUI editor — it has no CLI that takes a session id, so there is nothing to resume into`
140
+ : `no verified resume command for ${source} — see memhouse/resume.js for how to add one`,
141
+ };
142
+ }
143
+ const cmd = [r.cli, ...r.argv(nativeId)].map(shq).join(' ');
144
+ // The cd is half the command. Claude Code and Codex both scope their session lists by
145
+ // working directory, so the same id run from the wrong folder opens the picker or a new
146
+ // session instead of the transcript that was asked for.
147
+ return { ...base, ok: true, command: folder ? `cd ${shq(folder)} && ${cmd}` : cmd };
148
+ }
149
+
150
+ module.exports = { RESUMERS, GUI_ONLY, UNVERIFIED, resumeFor, splitSessionId };
@@ -0,0 +1,150 @@
1
+ // A daemon that keeps executing the code it booted with.
2
+ //
3
+ // `npm i -g memhouse@latest` replaces the files under the global prefix and does not touch
4
+ // the running processes, so the shipper keeps parsing with the old adapters and the
5
+ // dashboard keeps serving the old bundle. Nothing reports it: `status` prints the version
6
+ // of the CLI you just typed, not the version the daemon is running. On macminim a shipper
7
+ // ran 1d16h out of a directory that had been MOVED — the fix was live, the machine was not.
8
+ //
9
+ // npm's own `postinstall` is the obvious hook and the wrong one. Install scripts are
10
+ // blocked by default (`--allow-scripts`), which is the very condition this product already
11
+ // has to work around, and a package install that restarts a user's daemons is a surprise
12
+ // even when it works.
13
+ //
14
+ // So the daemon notices for itself. Once per loop pass it compares what is on disk against
15
+ // what it booted with, and when they differ it hands over:
16
+ //
17
+ // supervised (systemd/launchd set MEMHOUSE_SUPERVISED=1) — exit 0 and let the supervisor
18
+ // start a fresh process. Re-execing under a supervisor fights it: the unit's own
19
+ // restart would then race the child we spawned.
20
+ //
21
+ // unsupervised (`memhouse start` pidfiles) — re-exec ourselves detached, rewrite the
22
+ // pidfile, exit. Nothing else would ever restart it, and exiting silently would stop
23
+ // collecting memory, which is worse than running one pass of stale code.
24
+ //
25
+ // Guards, because a daemon that restarts itself wrongly is worse than one that is stale:
26
+ // a re-exec chain is counted in the environment and capped, and there is a floor on how
27
+ // often a restart may happen at all.
28
+
29
+ const fs = require('fs');
30
+ const path = require('path');
31
+ const { spawn } = require('child_process');
32
+
33
+ const CHAIN_VAR = 'MEMHOUSE_REEXEC_CHAIN';
34
+ const MAX_CHAIN = 3;
35
+ const MIN_INTERVAL_MS = 60_000;
36
+
37
+ function runDir() {
38
+ const home = process.env.MEMHOUSE_HOME || path.join(require('os').homedir(), '.memhouse');
39
+ return path.join(home, 'run');
40
+ }
41
+
42
+ // Read the version off DISK, never `require()`. require caches the first read for the
43
+ // lifetime of the process, so a required package.json reports the version this daemon
44
+ // booted with no matter how many times it is re-read — which is exactly the value we are
45
+ // trying to detect a change in.
46
+ function diskVersion(repoRoot) {
47
+ try { return JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf-8')).version || null; }
48
+ catch { return null; }
49
+ }
50
+
51
+ /**
52
+ * What this process is running, captured at boot.
53
+ * @param {string} entry absolute path of the daemon's own entry script
54
+ * @param {string} [repoRoot] where package.json lives; defaults two levels up from the
55
+ * entry's directory, which is where both daemons sit (memhouse/<part>/<entry>.js).
56
+ * Pass it explicitly rather than relying on that depth if you move an entry.
57
+ */
58
+ function snapshot(entry, repoRoot = path.join(path.dirname(entry), '..', '..')) {
59
+ let real = null, mtimeMs = null;
60
+ try { real = fs.realpathSync(entry); mtimeMs = fs.statSync(real).mtimeMs; } catch { /* reported as drift */ }
61
+ return { entry, repoRoot, version: diskVersion(repoRoot), real, mtimeMs, at: Date.now() };
62
+ }
63
+
64
+ /**
65
+ * Has the installation underneath us changed?
66
+ * @returns {string|null} a human-readable reason, or null when nothing moved
67
+ */
68
+ function driftReason(snap) {
69
+ const now = diskVersion(snap.repoRoot);
70
+ if (snap.version && now && now !== snap.version) return `version changed ${snap.version} → ${now}`;
71
+ let real = null, mtimeMs = null;
72
+ try { real = fs.realpathSync(snap.entry); mtimeMs = fs.statSync(real).mtimeMs; } catch { real = null; }
73
+ // The entry is gone: the directory this daemon runs out of was deleted, moved, or
74
+ // replaced. Reported, but NOT restartable — see maybeRestart, which refuses to re-exec a
75
+ // path that no longer resolves.
76
+ if (!real) return 'the directory this daemon runs from no longer exists';
77
+ if (snap.real && real !== snap.real) return `entry moved ${snap.real} → ${real}`;
78
+ // mtime alone, with the version unchanged, is a checkout being rebuilt or edited in
79
+ // place. Worth acting on for the same reason the version is: the running process is not
80
+ // the code on disk.
81
+ if (snap.mtimeMs && mtimeMs && mtimeMs !== snap.mtimeMs) return 'the entry script changed on disk';
82
+ return null;
83
+ }
84
+
85
+ function chainCount() {
86
+ const n = Number(process.env[CHAIN_VAR]);
87
+ return Number.isFinite(n) && n > 0 ? n : 0;
88
+ }
89
+
90
+ // Only ever rewrite a pidfile that names US. A daemon started by hand has no pidfile, and a
91
+ // pidfile naming some other process belongs to that process — overwriting it would point
92
+ // `memhouse stop` at ours and leave theirs running forever.
93
+ function adoptPidfile(name, newPid, log) {
94
+ const file = path.join(runDir(), name + '.pid');
95
+ try {
96
+ if (Number(fs.readFileSync(file, 'utf-8').trim()) !== process.pid) return;
97
+ fs.writeFileSync(file, String(newPid));
98
+ } catch { log(`[memhouse] could not update ${file} — 'memhouse stop' may not find the new process`); }
99
+ }
100
+
101
+ /**
102
+ * Act on drift. Returns false when nothing was done, and does not return at all when it
103
+ * hands over (the process exits).
104
+ *
105
+ * @param {object} o
106
+ * @param {ReturnType<snapshot>} o.snap what we booted with
107
+ * @param {string} o.name daemon name, matching its pidfile ('shipper' | 'dashboard')
108
+ * @param {(s: string) => void} [o.log]
109
+ */
110
+ function maybeRestart({ snap, name, log = console.error }) {
111
+ const reason = driftReason(snap);
112
+ if (!reason) return false;
113
+ if (Date.now() - snap.at < MIN_INTERVAL_MS) return false;
114
+
115
+ const supervised = process.env.MEMHOUSE_SUPERVISED === '1';
116
+ log(`[memhouse] ${reason} — this ${name} is running code that is no longer installed`);
117
+
118
+ if (supervised) {
119
+ log('[memhouse] exiting so the service manager starts the new version');
120
+ process.exit(0);
121
+ }
122
+ // An entry that does not resolve cannot be re-executed, and there is no supervisor to
123
+ // resolve a fresh one. Keep shipping with what is loaded and say so every pass: a stale
124
+ // daemon still collects sessions, a dead one collects nothing.
125
+ let real = null;
126
+ try { real = fs.realpathSync(snap.entry); } catch { /* handled next */ }
127
+ if (!real) {
128
+ log(`[memhouse] cannot restart — nothing to exec. Fix it with: memhouse stop && memhouse start`);
129
+ return false;
130
+ }
131
+ if (chainCount() >= MAX_CHAIN) {
132
+ log(`[memhouse] already restarted ${MAX_CHAIN} times in this chain — not restarting again. Fix it with: memhouse stop && memhouse start`);
133
+ return false;
134
+ }
135
+
136
+ const child = spawn(process.execPath, [real, ...process.argv.slice(2)], {
137
+ env: { ...process.env, [CHAIN_VAR]: String(chainCount() + 1) },
138
+ detached: true,
139
+ // The daemon's stdout/stderr are already the log file `memhouse start` opened, and a
140
+ // detached child inherits those descriptors — so the new process keeps writing to the
141
+ // same log with no second file to find.
142
+ stdio: 'inherit',
143
+ });
144
+ child.unref();
145
+ adoptPidfile(name, child.pid, log);
146
+ log(`[memhouse] restarted as pid ${child.pid}`);
147
+ process.exit(0);
148
+ }
149
+
150
+ module.exports = { snapshot, driftReason, maybeRestart, CHAIN_VAR, MAX_CHAIN, MIN_INTERVAL_MS };
@@ -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
  };