memhouse 0.3.2 → 0.4.2

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 (36) hide show
  1. package/README.md +64 -6
  2. package/bin/memhouse.js +882 -40
  3. package/index.js +2 -2
  4. package/mem-house/COMPETITION.md +217 -0
  5. package/mem-house/DESIGN.md +61 -32
  6. package/mem-house/delivery/AGENT-INSTALL.md +5 -3
  7. package/mem-house/delivery/PROMPT.md +23 -10
  8. package/mem-house/delivery/install.sh +9 -7
  9. package/mem-house/delivery/kernel-install.md +56 -25
  10. package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
  11. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
  12. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
  13. package/mem-house/deploy.js +333 -0
  14. package/mem-house/envfile.js +49 -0
  15. package/mem-house/per-member/INSTALL.md +183 -0
  16. package/mem-house/per-member/PLAN.md +174 -0
  17. package/mem-house/per-member/PROVISIONING.md +177 -0
  18. package/mem-house/per-member/README.md +48 -0
  19. package/mem-house/per-member/SCHEMA.md +73 -0
  20. package/mem-house/per-member/SHARING.md +102 -0
  21. package/mem-house/per-member/provision.js +142 -0
  22. package/mem-house/per-member/rooms.js +132 -0
  23. package/mem-house/per-member/schema-member.sql.tpl +85 -0
  24. package/mem-house/per-member/schema-merge.sql.tpl +24 -0
  25. package/mem-house/server/queries.js +103 -53
  26. package/mem-house/service.js +400 -0
  27. package/mem-house/shipper/ship.js +61 -18
  28. package/package.json +3 -3
  29. package/pricing.js +20 -2
  30. package/pricing.json +216 -24
  31. package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
  32. package/public/index.html +1 -1
  33. package/ui/src/App.jsx +3 -2
  34. package/ui/src/pages/SqlViewer.jsx +39 -21
  35. package/mem-house/rls.sql +0 -17
  36. package/mem-house/schema.sql +0 -145
@@ -0,0 +1,400 @@
1
+ // OS service integration — survive a reboot.
2
+ //
3
+ // `memhouse start` detaches the shipper and dashboard with pidfiles. They outlive the
4
+ // shell, but nothing brings them back after a restart, so a machine that reboots stops
5
+ // shipping until someone notices. This installs a real user-level service instead:
6
+ // systemd --user on Linux, launchd LaunchAgent on macOS.
7
+ //
8
+ // User-level deliberately, not system-level: the shipper reads the invoking user's own
9
+ // session stores (~/.claude, ~/.codex, editor SQLite files) and ships under their own
10
+ // credential. A root service would read the wrong home and stamp the wrong user_id.
11
+ //
12
+ // The whole environment is INLINED into the unit on both platforms, and both files are
13
+ // written 0600. systemd could read the env file directly, but ~/.memhouse/env is written
14
+ // in shell quoting so it can be `source`d, and systemd does not understand the `'\''`
15
+ // escape a password with a quote in it produces — it would start the shipper with a
16
+ // subtly different credential. One env representation, decoded once, per platform.
17
+ //
18
+ // Linux caveat: a systemd --user unit stops at logout unless lingering is enabled
19
+ // (`loginctl enable-linger <user>`). We detect and say so rather than silently
20
+ // installing something that dies on logout.
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const { execFileSync, spawnSync } = require('child_process');
26
+ const envfile = require('./envfile');
27
+
28
+ const LABEL = 'memhouse-shipper';
29
+ // Units this build never writes, but earlier ones did. A machine that ran the solo tier
30
+ // still has these enabled and possibly executing a script that no longer exists, so
31
+ // uninstall has to reap them or it reports success over a live service. Cleanup outlives
32
+ // the feature; nothing here ever installs them.
33
+ const LEGACY_UNITS = [
34
+ { unit: 'memhouse-solo.service', label: 'com.memhouse.solo', file: 'memhouse-solo.service', plist: 'com.memhouse.solo.plist' },
35
+ ];
36
+ // Environment an ADAPTER reads to find its sessions, as opposed to the connection
37
+ // settings that live in the env file. Keep this in step with `editors/` — today
38
+ // `editors/codex.js` is the only adapter with an override.
39
+ const ADAPTER_ENV = ['CODEX_HOME'];
40
+
41
+ function platform() {
42
+ if (process.platform === 'darwin') return 'launchd';
43
+ if (process.platform === 'linux') return 'systemd';
44
+ return null;
45
+ }
46
+
47
+ function unitPaths() {
48
+ const home = os.homedir();
49
+ return {
50
+ systemd: path.join(home, '.config', 'systemd', 'user', `${LABEL}.service`),
51
+ launchd: path.join(home, 'Library', 'LaunchAgents', 'com.memhouse.shipper.plist'),
52
+ };
53
+ }
54
+
55
+ function xml(s) {
56
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
57
+ }
58
+
59
+ function envLines(env) {
60
+ return Object.entries(env).map(([k, v]) => `Environment=${k}=${envfile.quoteSystemd(v)}`).join('\n');
61
+ }
62
+
63
+ // systemd splits ExecStart on whitespace, so an unquoted path containing a space becomes
64
+ // several arguments and the unit cannot start — `ExecStart=/tmp/a b/node …` resolves the
65
+ // executable as `/tmp/a`. Node itself lives under a space-bearing path often enough
66
+ // (`~/Library/Application Support/...`, `C:\Program Files` equivalents under WSL, any
67
+ // checkout in a folder with a space) that this is not exotic. Double quotes with C escapes
68
+ // are systemd's own syntax, the same rule `Environment=` uses.
69
+ function execToken(s) {
70
+ return /[\s"'\\]/.test(String(s)) ? envfile.quoteSystemd(s) : String(s);
71
+ }
72
+
73
+ function systemdUnit({ node, script, args, env, logDir, logName, description }) {
74
+ return `[Unit]
75
+ Description=${description}
76
+ After=network-online.target
77
+ Wants=network-online.target
78
+
79
+ [Service]
80
+ Type=simple
81
+ ${envLines(env)}
82
+ ExecStart=${[node, script, ...args].map(execToken).join(' ')}
83
+ Restart=on-failure
84
+ RestartSec=30
85
+ StandardOutput=append:${path.join(logDir, logName)}
86
+ StandardError=append:${path.join(logDir, logName)}
87
+
88
+ [Install]
89
+ WantedBy=default.target
90
+ `;
91
+ }
92
+
93
+ function launchdPlist({ node, script, args, env, logDir, logName, label }) {
94
+ const envEntries = Object.entries(env)
95
+ .map(([k, v]) => ` <key>${xml(k)}</key>\n <string>${xml(v)}</string>`)
96
+ .join('\n');
97
+ const argEntries = [node, script, ...args]
98
+ .map((a) => ` <string>${xml(a)}</string>`).join('\n');
99
+ return `<?xml version="1.0" encoding="UTF-8"?>
100
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
101
+ <plist version="1.0">
102
+ <dict>
103
+ <key>Label</key><string>${xml(label)}</string>
104
+ <key>ProgramArguments</key>
105
+ <array>
106
+ ${argEntries}
107
+ </array>
108
+ <key>EnvironmentVariables</key>
109
+ <dict>
110
+ ${envEntries}
111
+ </dict>
112
+ <key>RunAtLoad</key><true/>
113
+ <key>KeepAlive</key>
114
+ <dict><key>SuccessfulExit</key><false/></dict>
115
+ <key>StandardOutPath</key><string>${xml(path.join(logDir, logName))}</string>
116
+ <key>StandardErrorPath</key><string>${xml(path.join(logDir, logName))}</string>
117
+ </dict>
118
+ </plist>
119
+ `;
120
+ }
121
+
122
+ function parseEnvFile(file) {
123
+ try { return envfile.parse(fs.readFileSync(file, 'utf-8')); } catch { return {}; }
124
+ }
125
+
126
+ function lingerEnabled() {
127
+ try {
128
+ const r = spawnSync('loginctl', ['show-user', os.userInfo().username, '--property=Linger'], { encoding: 'utf-8' });
129
+ return /Linger=yes/.test(r.stdout || '');
130
+ } catch { return false; }
131
+ }
132
+
133
+ /**
134
+ * Everything install would fail on, asked before the caller stops anything.
135
+ *
136
+ * `service install` takes over from the detached shipper by killing it first. If the
137
+ * install then fails — no user manager on Linux, `launchctl bootstrap` refusing on macOS,
138
+ * a credential with a newline in it — the machine is left with no shipper at all. So the
139
+ * answerable questions are asked first.
140
+ */
141
+ function preflight({ envFile }) {
142
+ const kind = platform();
143
+ if (!kind) return { ok: false, msg: `no service integration for platform '${process.platform}'` };
144
+ try { envfile.assertSingleLine(parseEnvFile(envFile)); } catch (e) { return { ok: false, msg: e.message }; }
145
+ if (kind === 'systemd') {
146
+ const r = spawnSync('systemctl', ['--user', 'show-environment'], { encoding: 'utf-8' });
147
+ if (r.error) return { ok: false, msg: 'systemctl is not on PATH — no systemd user manager to install into' };
148
+ if (r.status !== 0) {
149
+ return { ok: false, msg: 'cannot reach the systemd user manager '
150
+ + `(${((r.stderr || '').trim().split('\n')[0]) || `exit ${r.status}`}). Is XDG_RUNTIME_DIR set for this session?` };
151
+ }
152
+ } else {
153
+ const r = spawnSync('launchctl', ['print', `gui/${process.getuid()}`], { encoding: 'utf-8' });
154
+ if (r.error) return { ok: false, msg: 'launchctl is not on PATH — no launchd session to install into' };
155
+ // A non-zero status is a refusal too, and the systemd branch already treats it as
156
+ // one. Over SSH there is often no accessible GUI domain, so `bootstrap` would fail —
157
+ // after the caller had already stopped the shipper and the shim it was replacing.
158
+ if (r.status !== 0) {
159
+ return { ok: false, msg: `no accessible launchd GUI domain for uid ${process.getuid()} `
160
+ + `(${((r.stderr || '').trim().split('\n')[0]) || `exit ${r.status}`}). `
161
+ + 'A LaunchAgent needs a logged-in session; over SSH there may not be one.' };
162
+ }
163
+ }
164
+ return { ok: true, kind };
165
+ }
166
+
167
+ /** Install the shipper as a user service. */
168
+ function install({ shipJs, envFile, logDir, interval = 300, home = null }) {
169
+ const kind = platform();
170
+ if (!kind) return { ok: false, msg: `no service integration for platform '${process.platform}'` };
171
+ const p = unitPaths()[kind];
172
+ fs.mkdirSync(path.dirname(p), { recursive: true });
173
+ fs.mkdirSync(logDir, { recursive: true });
174
+ const node = process.execPath;
175
+
176
+ // The whole persisted config, so the house a unit points at travels with it: an install
177
+ // whose service forgot the layout switch would come back after a reboot writing to the
178
+ // another member's rooms — which either fails on permissions or, if it can read them,
179
+ // quietly ships into the wrong place.
180
+ const env = parseEnvFile(envFile);
181
+ // MEMHOUSE_HOME is not in the env file — it is where the env file itself lives — and a
182
+ // service installed under a custom home must keep using it, or it reads a different
183
+ // config after a reboot than the one just written.
184
+ if (home) env.MEMHOUSE_HOME = home;
185
+ // Adapter location overrides travel too. A detached shipper inherits them from the
186
+ // invoking shell through childEnv(); a service inherits nothing, so an override that
187
+ // was working before `service install` silently stops applying — the adapter falls back
188
+ // to its default path and just stops finding sessions, with no error anywhere.
189
+ for (const k of ADAPTER_ENV) if (process.env[k]) env[k] = process.env[k];
190
+ try { envfile.assertSingleLine(env); } catch (e) { return { ok: false, msg: e.message }; }
191
+
192
+ if (kind === 'systemd') {
193
+ fs.writeFileSync(p, systemdUnit({
194
+ node, script: shipJs, args: ['--loop', String(interval)], env, logDir, logName: 'shipper.log',
195
+ description: 'memhouse shipper — parse local agent sessions and ship to the house',
196
+ }), { mode: 0o600 });
197
+ execFileSync('systemctl', ['--user', 'daemon-reload']);
198
+ // `enable --now` STARTS a unit; it does not restart one that is already running, and
199
+ // a running unit keeps the environment it was started with. Re-installing after
200
+ // changing the connection, the interval, CODEX_HOME or the tier would then report
201
+ // success while the service went on shipping with the old settings until a reboot.
202
+ // So: enable, then restart — which starts a stopped unit and replaces a running one.
203
+ for (const unit of [`${LABEL}.service`]) {
204
+ execFileSync('systemctl', ['--user', 'enable', unit]);
205
+ execFileSync('systemctl', ['--user', 'restart', unit]);
206
+ }
207
+ const warn = lingerEnabled() ? null
208
+ : `systemd --user services stop at logout. Run: loginctl enable-linger ${os.userInfo().username}`;
209
+ return { ok: true, kind, path: p, warn };
210
+ }
211
+
212
+ // launchd. The plist inlines the credential, so it must not be world-readable.
213
+ fs.writeFileSync(p, launchdPlist({
214
+ node, script: shipJs, args: ['--loop', String(interval)], env, logDir,
215
+ logName: 'shipper.log', label: 'com.memhouse.shipper',
216
+ }), { mode: 0o600 });
217
+ // bootout is asynchronous: it returns before launchd has finished tearing the job down,
218
+ // and a bootstrap issued in that window fails with `Service is being removed` (EBUSY,
219
+ // 36). Reinstalling over a RUNNING service is exactly when that happens, which is the
220
+ // common case — `service install` after a redeploy. Wait for the label to actually go.
221
+ spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/com.memhouse.shipper`]); // ignore if absent
222
+ for (let i = 0; i < 50; i++) {
223
+ const q = spawnSync('launchctl', ['print', `gui/${process.getuid()}/com.memhouse.shipper`], { encoding: 'utf-8' });
224
+ if (q.status !== 0) break; // gone
225
+ spawnSync('sleep', ['0.1']);
226
+ }
227
+ const r = spawnSync('launchctl', ['bootstrap', `gui/${process.getuid()}`, p], { encoding: 'utf-8' });
228
+ if (r.status !== 0) return { ok: false, msg: (r.stderr || '').trim() || 'launchctl bootstrap failed', path: p };
229
+ return { ok: true, kind, path: p, warn: null };
230
+ }
231
+
232
+ /**
233
+ * Remove the units — but only after they are actually stopped.
234
+ *
235
+ * Deleting a unit file does not stop a loaded service. If `disable --now` fails (a
236
+ * lingering user service running under a manager this shell cannot reach, say), removing
237
+ * the file leaves a shipper running with the credential inlined in a file the caller is
238
+ * about to delete, while every command reports the install gone. So: verify stopped, and
239
+ * refuse rather than report a success that is not one.
240
+ */
241
+ function uninstall() {
242
+ const kind = platform();
243
+ if (!kind) return { ok: false, msg: `no service integration for platform '${process.platform}'` };
244
+ const p = unitPaths()[kind];
245
+ const stillRunning = [];
246
+ const indeterminate = [];
247
+
248
+ const home = os.homedir();
249
+ const legacy = LEGACY_UNITS.map((l) => (kind === 'systemd'
250
+ ? [l.unit, path.join(home, '.config', 'systemd', 'user', l.file), l.label]
251
+ : [l.label, path.join(home, 'Library', 'LaunchAgents', l.plist), l.label]));
252
+ const units = [
253
+ ...(kind === 'systemd'
254
+ ? [[`${LABEL}.service`, p, 'com.memhouse.shipper']]
255
+ : [['com.memhouse.shipper', p, 'com.memhouse.shipper']]),
256
+ ...legacy,
257
+ ];
258
+
259
+ for (const [unit, file, label] of units) {
260
+ if (!fs.existsSync(file)) continue;
261
+ if (kind === 'systemd') spawnSync('systemctl', ['--user', 'disable', '--now', unit], { encoding: 'utf-8' });
262
+ else spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${label}`], { encoding: 'utf-8' });
263
+ // The command's own status is not the question — `disable` can report success while
264
+ // the unit stays active, and `bootout` returns non-zero for an already-absent job.
265
+ // Ask what state it is in now, and treat "cannot tell" as its own answer: deleting
266
+ // the unit on an unknown state is how a live shipper keeps running with a credential
267
+ // in a file everyone believes is gone.
268
+ const state = runState(kind, unit, label);
269
+ if (state === 'running') { stillRunning.push(unit); continue; }
270
+ if (state === 'unknown') { indeterminate.push(unit); continue; }
271
+ fs.unlinkSync(file);
272
+ }
273
+ if (kind === 'systemd') spawnSync('systemctl', ['--user', 'daemon-reload']);
274
+
275
+ if (stillRunning.length) {
276
+ return {
277
+ ok: false,
278
+ kind,
279
+ path: p,
280
+ msg: `${stillRunning.join(', ')} is still running after stop — unit file kept. `
281
+ + `Stop it yourself (systemctl --user stop ${stillRunning[0]}), then re-run.`,
282
+ };
283
+ }
284
+ if (indeterminate.length) {
285
+ return {
286
+ ok: false,
287
+ kind,
288
+ path: p,
289
+ msg: `cannot determine whether ${indeterminate.join(', ')} stopped — no usable `
290
+ + `${kind === 'systemd' ? 'systemctl' : 'launchctl'} on this host, so the unit file is kept. `
291
+ + 'Confirm it is stopped, remove the unit yourself, then re-run.',
292
+ };
293
+ }
294
+ return { ok: true, kind, path: p };
295
+ }
296
+
297
+ // Three states, not two. A missing service manager is a normal state and not a crash —
298
+ // `spawnSync` on an absent binary returns { error, stdout: null }, and dereferencing that
299
+ // took down `uninstall`, `status` and `doctor` on any Linux host without systemctl. But
300
+ // collapsing that into "not running" is its own bug: teardown would then treat "cannot
301
+ // tell" as "confirmed stopped" and delete the unit and the credential out from under a
302
+ // shipper that is still going. Display may round `unknown` down; teardown must not.
303
+ function runState(kind, unit, label) {
304
+ if (kind === 'systemd') {
305
+ const r = spawnSync('systemctl', ['--user', 'is-active', unit], { encoding: 'utf-8' });
306
+ if (r.error) return 'unknown'; // no systemctl on PATH
307
+ const out = (r.stdout || '').trim();
308
+ if (out === 'active') return 'running';
309
+ // `is-active` answers inactive/failed/activating on a manager it can reach. Anything
310
+ // else — empty output, a connection error on stderr — means it could not tell us.
311
+ // `deactivating` is a unit still shutting down, not one that has stopped. Calling it
312
+ // stopped let uninstall delete a credential-bearing unit whose process was still
313
+ // alive. Both transitional states count as running, so teardown keeps the file and
314
+ // refuses until systemd reports a terminal state.
315
+ if (['activating', 'deactivating'].includes(out)) return 'running';
316
+ if (['inactive', 'failed'].includes(out)) return 'stopped';
317
+ return 'unknown';
318
+ }
319
+ const r = spawnSync('launchctl', ['print', `gui/${process.getuid()}/${label}`], { encoding: 'utf-8' });
320
+ if (r.error) return 'unknown'; // no launchctl
321
+ if (r.status === 0) return /state = running|state = waiting/.test(r.stdout || '') ? 'running' : 'stopped';
322
+ // Non-zero has two very different meanings. "Could not find service" is launchd
323
+ // telling us the job is gone — a real `stopped`. "Could not find domain" is launchd
324
+ // telling us it cannot look, which happens over SSH with no GUI session, and treating
325
+ // that as stopped let `uninstall` delete a plist while its shipper kept running with
326
+ // the inlined credential.
327
+ const out = `${r.stderr || ''}${r.stdout || ''}`;
328
+ if (/could not find service|no such process|not find the specified service/i.test(out)) return 'stopped';
329
+ return 'unknown';
330
+ }
331
+
332
+ function isRunning(kind, unit, label) {
333
+ return runState(kind, unit, label) === 'running';
334
+ }
335
+
336
+ /**
337
+ * What the INSTALLED unit actually points at, read from the unit file rather than from
338
+ * the current config — the two drift the moment anything is redeployed, and callers
339
+ * asking "does this service care about the house I am removing?" need the unit's answer.
340
+ *
341
+ * Returns { installed, url, db } — null/false when undeterminable.
342
+ */
343
+ function installedConfig() {
344
+ const kind = platform();
345
+ if (!kind) return { installed: false, url: null, db: null };
346
+ const p = unitPaths()[kind];
347
+ if (!fs.existsSync(p)) return { installed: false, url: null, db: null };
348
+ let text = '';
349
+ try { text = fs.readFileSync(p, 'utf-8'); } catch { return { installed: true, url: null, db: null }; }
350
+ const env = {};
351
+ if (kind === 'systemd') {
352
+ for (const m of text.matchAll(/^Environment=([A-Z0-9_]+)=(.*)$/gm)) {
353
+ let v = m[2].trim();
354
+ if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1).replace(/\\(["\\])/g, '$1');
355
+ env[m[1]] = v;
356
+ }
357
+ } else {
358
+ // <key>NAME</key><newline><string>VALUE</string>
359
+ for (const m of text.matchAll(/<key>([A-Z0-9_]+)<\/key>\s*<string>([^<]*)<\/string>/g)) {
360
+ env[m[1]] = m[2].replace(/&amp;/g, '&').replace(/&lt;/g, '<').replace(/&gt;/g, '>');
361
+ }
362
+ }
363
+ return {
364
+ installed: true,
365
+ url: env.MEMHOUSE_URL || null,
366
+ // The endpoint is not the whole identity. A service on the same URL but a different
367
+ // DATABASE — or a different room layout — is still shipping somewhere else.
368
+ db: env.MEMHOUSE_DB || null,
369
+ };
370
+ }
371
+
372
+ function status() {
373
+ const kind = platform();
374
+ if (!kind) return { kind: null, installed: false, running: false };
375
+ const p = unitPaths()[kind];
376
+ const home = os.homedir();
377
+ // Legacy units are reported separately: `installed` must stay "this build's unit is
378
+ // here", or every caller that asks it starts meaning something else. But uninstall has
379
+ // to know, or a machine with ONLY a stale solo unit skips the service step entirely.
380
+ const legacy = LEGACY_UNITS
381
+ .map((l) => (kind === 'systemd'
382
+ ? path.join(home, '.config', 'systemd', 'user', l.file)
383
+ : path.join(home, 'Library', 'LaunchAgents', l.plist)))
384
+ .filter((f) => fs.existsSync(f));
385
+ return {
386
+ kind,
387
+ installed: fs.existsSync(p),
388
+ running: isRunning(kind, `${LABEL}.service`, 'com.memhouse.shipper'),
389
+ path: p,
390
+ legacy,
391
+ };
392
+ }
393
+
394
+ // The two renderers are exported so the unit gate can check what gets written without
395
+ // installing anything. A malformed plist or unit is only visible at load time otherwise,
396
+ // and "load it and see" is not available on a machine you must not touch.
397
+ module.exports = {
398
+ install, uninstall, status, platform, preflight, installedConfig, lingerEnabled,
399
+ _render: { systemdUnit, launchdPlist, unitPaths },
400
+ };
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env node
2
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,
3
+ // adapters locally and ships TYPED rows into THE CALLER'S OWN rooms — sessions_<m> /
4
+ // messages_<m> / tool_calls_<m>, where <m> is `SELECT currentUser()` and never config
5
+ // (../per-member/schema-member.sql.tpl, ../per-member/rooms.js). All of one member's
6
+ // machines land in that member's rooms, told apart by the `host` column; no member
7
+ // writes into another's. The typed evolution of agency/ingest.js: same host id,
5
8
  // same ts interpolation, same batching discipline — but rows land in physical
6
9
  // columns instead of a raw JSON blob.
7
10
  //
@@ -17,11 +20,11 @@
17
20
  // CLI: node ship.js one incremental pass
18
21
  // node ship.js --loop [sec] repeat every sec seconds (default 300)
19
22
  // node ship.js --full ignore the incremental skip (re-ship everything)
20
- // node ship.js --ensure-schema apply ../schema.sql and exit
23
+ // node ship.js --ensure-schema create the caller's own rooms and exit
21
24
  // node ship.js --stats per-source counts from sessions_v and exit
22
25
  //
23
26
  // Env (DESIGN.md contract): MEMHOUSE_URL / MEMHOUSE_USER / MEMHOUSE_PASSWORD /
24
- // MEMHOUSE_DB — defaults http://localhost:8123 / memhouse_root / '' / memhouse.
27
+ // MEMHOUSE_DB — defaults http://localhost:8123 / memhouse_root / '' / mem.
25
28
 
26
29
  const os = require('os');
27
30
  const fs = require('fs');
@@ -30,6 +33,7 @@ const crypto = require('crypto');
30
33
  const { createClient } = require('@clickhouse/client');
31
34
  const { getAllChats, getAdapterErrors, getMessages, resetCaches } = require('../../editors');
32
35
  const adapterErrorSink = require('../../editors/adapter-errors');
36
+ const { resolveRooms, READ_SETTINGS } = require('../per-member/rooms');
33
37
 
34
38
  const BATCH_ROWS = 2000; // insert batch ceiling (binding)
35
39
  const TEXT_MAX = 50000; // messages.text truncation
@@ -79,7 +83,7 @@ function makeClient() {
79
83
  url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
80
84
  username: process.env.MEMHOUSE_USER || 'memhouse_root',
81
85
  password: process.env.MEMHOUSE_PASSWORD || '',
82
- database: process.env.MEMHOUSE_DB || 'memhouse',
86
+ database: process.env.MEMHOUSE_DB || 'mem',
83
87
  request_timeout: 300000, // full re-ships move tens of MB; don't cut inserts short
84
88
  clickhouse_settings: {
85
89
  // Int64/UInt64 back as JSON numbers — our values (counts, tokens) are < 2^53.
@@ -97,10 +101,21 @@ function makeClient() {
97
101
  });
98
102
  }
99
103
 
100
- // Apply ../schema.sql statement by statement. Comments are stripped BEFORE the ';'
101
- // split — schema comments legitimately contain semicolons.
104
+ // Create the CALLER'S OWN rooms, from ../per-member/schema-member.sql.tpl. Comments are
105
+ // stripped BEFORE the ';' split — schema comments legitimately contain semicolons.
106
+ //
107
+ // This is the solo path: on a house you own, `memhouse install` mints your three rooms and
108
+ // you are done. It creates nobody else's — the template is rendered for currentUser(), the
109
+ // same identity the rooms' user_id is stamped with, so a client cannot name its way into
110
+ // someone else's rooms.
111
+ //
112
+ // It does NOT issue grants or create the Merge rooms. A solo owner needs neither. Adding a
113
+ // SECOND member — grants, Merge rooms, sharing — is owner work and lives in
114
+ // ../per-member/provision.js.
102
115
  async function ensureSchema(client) {
103
- const sql = fs.readFileSync(path.join(__dirname, '..', 'schema.sql'), 'utf-8');
116
+ const { member } = await resolveRooms(client);
117
+ const tpl = fs.readFileSync(path.join(__dirname, '..', 'per-member', 'schema-member.sql.tpl'), 'utf-8');
118
+ const sql = tpl.replaceAll('{{MEMBER}}', member);
104
119
  const stripped = sql.split('\n').map((l) => l.replace(/--.*$/, '')).join('\n');
105
120
  const stmts = stripped.split(';').map((s) => s.trim()).filter(Boolean);
106
121
  for (const q of stmts) {
@@ -121,9 +136,9 @@ async function ensureSchema(client) {
121
136
  // NOT message_count — because parsed-message count and bubbleCount are different
122
137
  // units in several adapters (claude folds subagents, codex reports 0), and skipping
123
138
  // must be decidable WITHOUT calling getMessages on every chat.
124
- async function loadExisting(client) {
139
+ async function loadExisting(client, rooms) {
125
140
  const rs = await client.query({
126
- query: 'SELECT session_id, last_updated_at, message_count, extra FROM sessions FINAL WHERE user_id = currentUser()',
141
+ query: `SELECT session_id, last_updated_at, message_count, extra FROM ${rooms.sessions} FINAL WHERE user_id = currentUser()`,
127
142
  format: 'JSONEachRow',
128
143
  });
129
144
  // Actual message rows per session: an interrupted re-ship (crash between the
@@ -132,7 +147,7 @@ async function loadExisting(client) {
132
147
  // must compare the real row count against the recorded message_count, not
133
148
  // merely check that some row exists.
134
149
  const mr = await client.query({
135
- query: 'SELECT session_id, count() AS n FROM messages FINAL WHERE user_id = currentUser() GROUP BY session_id',
150
+ query: `SELECT session_id, count() AS n FROM ${rooms.messages} FINAL WHERE user_id = currentUser() GROUP BY session_id`,
136
151
  format: 'JSONEachRow',
137
152
  });
138
153
  const msgCounts = new Map();
@@ -269,7 +284,8 @@ async function runShip(client, opts = {}) {
269
284
  // only in incremental mode, but re-shipping a KNOWN session must clear its old
270
285
  // message/tool rows first (a shrunken re-parse leaves stale higher-seq rows
271
286
  // otherwise: ReplacingMergeTree collapses same-key rows only).
272
- const existing = await loadExisting(client);
287
+ const rooms = await resolveRooms(client);
288
+ const existing = await loadExisting(client, rooms);
273
289
  const chats = getAllChats();
274
290
 
275
291
  // An adapter that cannot load contributes zero sessions, which is indistinguishable
@@ -285,7 +301,7 @@ async function runShip(client, opts = {}) {
285
301
  const flush = async (table) => {
286
302
  if (!batches[table].length) return;
287
303
  await client.insert({
288
- table,
304
+ table: rooms[table],
289
305
  values: batches[table],
290
306
  format: 'JSONEachRow',
291
307
  clickhouse_settings: { async_insert: 0 }, // binding: user_id stamping breaks otherwise
@@ -350,10 +366,17 @@ async function runShip(client, opts = {}) {
350
366
  // shorter re-parse can't leave stale seq/idx tails. A crash between the
351
367
  // delete and the inserts is repaired by the next pass: the skip predicate
352
368
  // refuses to skip a non-empty session whose message rows are missing.
369
+ // user_id is BOUND, not `= currentUser()`. A DELETE is a mutation, and a mutation
370
+ // does not necessarily evaluate currentUser() in the caller's context — it matches
371
+ // nothing at all, so the delete silently removes zero rows and the stale tail this
372
+ // code exists to clear survives forever. Measured on ClickHouse 25.11 —
373
+ // the identical predicate with the literal value deleted 2000 rows where
374
+ // currentUser() deleted 0. The value is the same identity either way: it is read
375
+ // from the server over this very connection.
353
376
  for (const t of ['messages', 'tool_calls']) {
354
377
  await client.command({
355
- query: `DELETE FROM ${t} WHERE session_id = {id:String} AND user_id = currentUser()`,
356
- query_params: { id },
378
+ query: `DELETE FROM ${rooms[t]} WHERE session_id = {id:String} AND user_id = {uid:String}`,
379
+ query_params: { id, uid: rooms.user },
357
380
  clickhouse_settings: { async_insert: 0 },
358
381
  });
359
382
  }
@@ -387,18 +410,20 @@ function reportAdapterErrors(warned) {
387
410
  }
388
411
 
389
412
  // Per-source rollup straight from sessions_v (final=1 so ReplacingMergeTree collapses).
413
+ // The rollup resolves like the rooms do — it is a subquery over the caller's own rooms.
390
414
  async function printStats(client) {
415
+ const rooms = await resolveRooms(client);
391
416
  const rs = await client.query({
392
417
  query: `
393
418
  SELECT source,
394
419
  count() AS sessions,
395
420
  sum(total_msgs) AS messages,
396
421
  sum(input_tokens + output_tokens + cache_read_tokens + cache_write_tokens) AS tokens
397
- FROM sessions_v
422
+ FROM ${rooms.sessions_v}
398
423
  GROUP BY source
399
424
  ORDER BY sessions DESC`,
400
425
  format: 'JSONEachRow',
401
- clickhouse_settings: { final: 1 },
426
+ clickhouse_settings: READ_SETTINGS,
402
427
  });
403
428
  const rows = await rs.json();
404
429
  if (!rows.length) { console.log('[mem-house] house is empty'); return; }
@@ -433,19 +458,37 @@ async function main() {
433
458
  if (Number.isFinite(n) && n > 0) intervalSec = n;
434
459
  }
435
460
  let full = argv.includes('--full');
461
+ // A failed pass does NOT wait the full interval. The common failure at startup is
462
+ // that the house is not up yet — the container is still booting — and sleeping 300s
463
+ // there means the first ship is
464
+ // five minutes late for a condition that clears in under a second. systemd's
465
+ // After= orders process start, not readiness, and launchd has no ordering at all, so
466
+ // this is the only place the race can be closed for every path at once.
467
+ // Backs off to the normal interval so a genuinely unreachable house is not hammered.
468
+ const RETRY_START_MS = 2000;
469
+ let retryMs = RETRY_START_MS;
436
470
  do {
437
471
  const t0 = Date.now();
472
+ let failed = false;
438
473
  try {
439
474
  const r = await runShip(client, { full });
440
475
  console.log(`[mem-house] shipped ${r.sessions} sessions (${r.skipped} skipped${r.unreadable ? `, ${r.unreadable} unreadable-will-retry` : ''}) → ` +
441
476
  `${r.msgRows} msg rows, ${r.toolRows} tool rows in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
442
477
  } catch (e) {
478
+ failed = true;
443
479
  console.error(`[mem-house] pass failed: ${e.message}`);
444
480
  if (!loop) process.exitCode = 1;
445
481
  }
446
482
  full = false; // --full applies to the first pass only; loop passes stay incremental
447
483
  if (loop) {
448
- await new Promise((r) => setTimeout(r, intervalSec * 1000));
484
+ const waitMs = failed ? Math.min(retryMs, intervalSec * 1000) : intervalSec * 1000;
485
+ if (failed) {
486
+ console.error(`[mem-house] retrying in ${Math.round(waitMs / 1000)}s`);
487
+ retryMs = Math.min(retryMs * 2, intervalSec * 1000);
488
+ } else {
489
+ retryMs = RETRY_START_MS;
490
+ }
491
+ await new Promise((r) => setTimeout(r, waitMs));
449
492
  resetCaches(); // adapters cache chat lists; drop them so new sessions surface
450
493
  }
451
494
  } while (loop);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.3.2",
4
- "description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store, kernel-agency install, and the agentlytics dashboard",
3
+ "version": "0.4.2",
4
+ "description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, kernel-agency install, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {
7
7
  "memhouse": "./bin/memhouse.js",
@@ -37,7 +37,7 @@
37
37
  "start": "node bin/memhouse.js",
38
38
  "build": "cd ui && npm install && npm run build",
39
39
  "dev": "cd ui && npm run dev",
40
- "test": "node misc/syntax-check.js",
40
+ "test": "node misc/syntax-check.js && node misc/unit-test.js",
41
41
  "prepack": "cd ui && npm install --no-audit --no-fund && npm run build"
42
42
  },
43
43
  "keywords": [
package/pricing.js CHANGED
@@ -53,12 +53,30 @@ function normalizeModelName(name) {
53
53
  if (withoutQual !== c && MODEL_PRICING[withoutQual]) return withoutQual;
54
54
  }
55
55
 
56
- // Pass 2: fuzzy startsWith (longest key match wins)
56
+ // Pass 2: prefix match, longest key wins — but only on a SEGMENT boundary.
57
+ //
58
+ // A bare startsWith matches across a version boundary and returns a confident wrong
59
+ // price rather than null. `gpt-4-mini`.startsWith('gpt-4') is true, so an unpriced
60
+ // model billed at gpt-4's $30/$60 instead of failing loudly; `gpt-5-6-sol` did exactly
61
+ // this against `gpt-5` and reported a quarter of its true cost for months. A missing
62
+ // model at least yields a zero someone might question.
63
+ //
64
+ // The rule: the remainder after a matched key must be a DATE OR VERSION STAMP —
65
+ // digit-only segments. `claude-opus-4-8-20260115` is claude-opus-4-8 on a given day and
66
+ // costs the same. `gpt-4-mini` is not gpt-4 with a stamp, it is a different model, and
67
+ // guessing its price from gpt-4 overstates it 40x.
68
+ //
69
+ // A word suffix therefore returns null rather than a neighbour's price. That is
70
+ // deliberate even though null means the model is dropped from cost totals: an absent
71
+ // number invites the question, a wrong one does not. `doctor` reports what is unpriced
72
+ // so the gap is visible rather than silent.
73
+ const stamped = (rest) => rest === '' || /^(-[0-9]+)+$/.test(rest);
57
74
  const keys = Object.keys(MODEL_PRICING);
58
75
  for (const c of candidates) {
59
76
  let best = null;
60
77
  for (const key of keys) {
61
- if (c.startsWith(key) && (!best || key.length > best.length)) best = key;
78
+ if (c.startsWith(key) && stamped(c.slice(key.length))
79
+ && (!best || key.length > best.length)) best = key;
62
80
  }
63
81
  if (best) return best;
64
82
  }