memhouse 0.3.2 → 0.4.3
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/README.md +64 -6
- package/bin/memhouse.js +1014 -41
- package/editors/antigravity.js +11 -2
- package/editors/cursor.js +16 -7
- package/editors/opencode.js +12 -3
- package/editors/zed.js +13 -4
- package/index.js +2 -2
- package/mem-house/COMPETITION.md +217 -0
- package/mem-house/DESIGN.md +61 -32
- package/mem-house/delivery/AGENT-INSTALL-PROMPT.md +58 -0
- package/mem-house/delivery/AGENT-INSTALL.md +12 -4
- package/mem-house/delivery/PROMPT.md +23 -10
- package/mem-house/delivery/install.sh +9 -7
- package/mem-house/delivery/kernel-install.md +56 -25
- package/mem-house/delivery/skills/memhouse-search/SKILL.md +46 -16
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +38 -7
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +48 -17
- package/mem-house/deploy.js +333 -0
- package/mem-house/envfile.js +49 -0
- package/mem-house/per-member/INSTALL.md +183 -0
- package/mem-house/per-member/PLAN.md +174 -0
- package/mem-house/per-member/PROVISIONING.md +177 -0
- package/mem-house/per-member/README.md +48 -0
- package/mem-house/per-member/SCHEMA.md +73 -0
- package/mem-house/per-member/SHARING.md +102 -0
- package/mem-house/per-member/provision.js +142 -0
- package/mem-house/per-member/rooms.js +132 -0
- package/mem-house/per-member/schema-member.sql.tpl +85 -0
- package/mem-house/per-member/schema-merge.sql.tpl +24 -0
- package/mem-house/server/queries.js +103 -53
- package/mem-house/service.js +400 -0
- package/mem-house/shipper/ship.js +61 -18
- package/package.json +3 -3
- package/pricing.js +20 -2
- package/pricing.json +216 -24
- package/public/assets/{index-CCPh7a2Z.js → index-CtH-5i95.js} +5 -5
- package/public/index.html +1 -1
- package/ui/src/App.jsx +3 -2
- package/ui/src/pages/SqlViewer.jsx +39 -21
- package/mem-house/rls.sql +0 -17
- 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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(/&/g, '&').replace(/</g, '<').replace(/>/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
|
|
4
|
-
//
|
|
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
|
|
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 / '' /
|
|
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 || '
|
|
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
|
-
//
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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.3",
|
|
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:
|
|
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) && (
|
|
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
|
}
|