memhouse 0.18.5 → 0.18.6

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/bin/memhouse.js CHANGED
@@ -1642,13 +1642,29 @@ async function cmdStatus() {
1642
1642
  if (out.connected && !out.messages) console.log(ok(`house: empty — ${out.sessions} sessions, 0 messages (nothing shipped yet)`));
1643
1643
  else if (out.connected) console.log(ok(`house: ${out.sessions} sessions, ${out.messages} messages (freshest ingest ${out.freshest} UTC)`));
1644
1644
  if (out.fleet && out.fleet.length) {
1645
- const badOnes = out.fleet.filter((f) => f.verdict !== 'ok');
1646
- console.log((badOnes.length ? warn : ok)(`fleet: ${out.fleet.length} writer(s) known to this house`));
1647
- for (const f of out.fleet) {
1645
+ // A writer appears in this list because it has rows — the list is GROUP BY over the
1646
+ // messages, not a registry — so a machine that was re-identified (before host ids were
1647
+ // derived from the machine) stays here forever, and cannot be tidied away without
1648
+ // deleting its sessions. Retired writers are therefore folded into one line instead of
1649
+ // shouting a permanent warning: they are history, not a fleet to maintain. `--all`
1650
+ // prints every one. A writer is RETIRED when it has been silent past the cutoff; a
1651
+ // pre-0.10 writer that is still active keeps its warning, because that one can still bite.
1652
+ const RETIRED_MS = 14 * 24 * 3600 * 1000;
1653
+ const retired = out.fleet.filter((f) => f.ageMs !== null && f.ageMs > RETIRED_MS);
1654
+ const shown = flags.all === true ? out.fleet : out.fleet.filter((f) => !retired.includes(f));
1655
+ const live = out.fleet.filter((f) => !retired.includes(f));
1656
+ const badOnes = live.filter((f) => f.verdict !== 'ok');
1657
+ console.log((badOnes.length ? warn : ok)(`fleet: ${live.length} active writer(s)${retired.length ? `, ${retired.length} retired` : ''}`));
1658
+ for (const f of shown) {
1648
1659
  const mark = f.verdict === 'legacy' ? ' ⚠ pre-0.10 — upgrade it (or revoke its mutation grants); its re-ships delete retained parses'
1649
1660
  : f.verdict === 'outdated' ? ' ⚠ supports an older schema — refusing every pass until updated'
1650
1661
  : f.verdict === 'stale' ? ' • stale' : '';
1651
- console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${mark}`);
1662
+ const tag = retired.includes(f) ? ' · retired' : mark;
1663
+ console.log(` ${f.writer.padEnd(28)} ${String(f.version || '?').padEnd(8)} last ship ${fleetAge(f.ageMs)}${tag}`);
1664
+ }
1665
+ if (retired.length && flags.all !== true) {
1666
+ const oldest = fleetAge(Math.max(...retired.map((f) => f.ageMs)));
1667
+ console.log(` ${String(`${retired.length} retired writer(s)`).padEnd(28)} ${' '.padEnd(8)} silent ${oldest} and longer — their sessions are still here: memhouse status --all`);
1652
1668
  }
1653
1669
  }
1654
1670
  {
@@ -2232,6 +2248,21 @@ async function cmdUpdate() {
2232
2248
  // shipper restarts, or its first pass refuses the old address forever.
2233
2249
  try { await adoptMovedHouse(resolveConfig()); } catch { /* doctor will say */ }
2234
2250
 
2251
+ // Bring the house to the schema the NEW code expects, before any daemon restarts.
2252
+ // A release that adds rooms (0.18.3's stat tables) used to land after the restart, so
2253
+ // the freshly started shipper failed its first passes with "has no <member>_sessions…",
2254
+ // `status` quoted that failure, and the dashboard served a page whose every request
2255
+ // 500'd — all of it self-healing eventually, all of it alarming meanwhile. Two real
2256
+ // members hit this within an hour of each other. The new binary owns the new schema, so
2257
+ // it runs it here, in the one moment the daemons are already down.
2258
+ {
2259
+ const r = spawnSync(process.execPath, [path.join(REPO_ROOT, 'memhouse', 'shipper', 'ship.js'), '--ensure-schema'],
2260
+ { env: childEnv(resolveConfig()), encoding: 'utf-8', stdio: 'pipe', timeout: 120000 });
2261
+ const said = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').filter((l) => l.includes('[memhouse]')).pop();
2262
+ if (r.status === 0) console.log(ok(`house schema current${said ? ` — ${said.replace('[memhouse] ', '')}` : ''}`));
2263
+ else console.log(warn(`could not bring the house to this version's schema — the daemons start anyway; run: memhouse doctor${said ? `\n ${said}` : ''}`));
2264
+ }
2265
+
2235
2266
  // Refresh the Claude plugin wherever it is ALREADY installed, so `update` keeps the
2236
2267
  // skills in lockstep with the package instead of leaving a stale `/mem:*` behind. Only
2237
2268
  // dirs that already have it are touched — update never installs the plugin somewhere new.
@@ -4107,6 +4138,17 @@ async function cmdUninstall() {
4107
4138
  if (use.length) await ch(who, `INSERT INTO ${db}.${ev} (${use.join(', ')}) VALUES (${use.map((c) => want[c]).join(', ')})`, { database: '' });
4108
4139
  else console.log(warn(` ${p.member}: events room has none of the expected columns — no event recorded`));
4109
4140
  } catch (e) { console.log(warn(` ${p.member}: event not recorded — ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 120)}`)); }
4141
+ // The rooms moved, but the house also needs what THIS version adds — the
4142
+ // per-member stat tables the dashboard reads. `ensureSchema` makes them, and a
4143
+ // plain `ship` never calls it, so leaving them to the member meant a dashboard
4144
+ // that scanned millions of rows (or 500'd) until someone happened to run
4145
+ // `ship --ensure-schema`. Create them here, as the admin, while we are already
4146
+ // holding the member's rooms in mind.
4147
+ try {
4148
+ const st = convert.statTables(db, p.member);
4149
+ for (const sql of st) await ch(who, sql, { database: '' });
4150
+ if (st.length) console.log(ok(` ${p.member}: dashboard tables created (${st.length})`));
4151
+ } catch (e) { console.log(warn(` ${p.member}: dashboard tables not created — their first ship will do it: ${(e && e.message ? e.message : String(e)).split('\n')[0].slice(0, 90)}`)); }
4110
4152
  converted++;
4111
4153
  console.log(ok(`${p.member}: rooms now ${db}.${p.member}_* — ${p.shares.length} share(s) carried${p.policies.length ? `, ${p.policies.length} row polic${p.policies.length === 1 ? 'y' : 'ies'}` : ''}`));
4112
4154
  }
@@ -57,4 +57,19 @@ function render(steps) {
57
57
  return steps.map((s) => `-- ${s.why}${s.optional ? ' (optional)' : ''}${s.when ? ` (only when ${s.when})` : ''}\n${s.sql};`).join('\n');
58
58
  }
59
59
 
60
- module.exports = { planMember, render, LEGACY, STAT_VIEWS, STAT_TABLES };
60
+ /**
61
+ * The CREATE statements for a member's dashboard tables, in the one layout. `convert` runs
62
+ * these so a converted house is complete the moment it is converted — the alternative was
63
+ * a dashboard with nothing precomputed until someone ran `ship --ensure-schema` by hand.
64
+ * Same source of truth the shipper uses, so the two cannot drift.
65
+ */
66
+ function statTables(db, member) {
67
+ const house = require('./house/house');
68
+ // statCreateStatements builds unqualified names (the member runs it inside their own
69
+ // house); convert runs as the admin with no default database, so qualify every name.
70
+ return house.statCreateStatements(house.roomNames(member))
71
+ .map((sql) => sql.replace(/\b(CREATE TABLE IF NOT EXISTS |FROM )(?!\()/g, (m0) => m0)
72
+ .replace(new RegExp(`\\b(${member}_(?:session_stats|session_model_stats|session_tool_stats|sessions|messages|tool_calls|meta|events))\\b`, 'g'), `${db}.$1`));
73
+ }
74
+
75
+ module.exports = { planMember, render, statTables, LEGACY, STAT_VIEWS, STAT_TABLES };
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "mem",
3
3
  "description": "Conversation memory for AI agents over the memhouse typed ClickHouse store: recall past sessions and answer from them, run analytics, check the house, bring people in, and administer the server. Skills install as /mem:recall, /mem:sql, /mem:house, /mem:access, and /mem:admin.",
4
- "version": "0.18.5",
4
+ "version": "0.18.6",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
package/memhouse/flags.js CHANGED
@@ -17,7 +17,7 @@ const COMMAND_FLAGS = {
17
17
  whoami: ['admin'],
18
18
  share: ['only', 'revoke', 'list'],
19
19
  discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [], rooms: [], instance: [], members: ['db','admin-user','admin-password'], convert: ['url','db','admin-user','admin-password','member','print-sql','dry-run','guides','out'],
20
- status: [], start: [],
20
+ status: ['all'], start: [],
21
21
  onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
22
22
  install: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'env', 'print-sql', 'member',
23
23
  'member-password', 'admin-user', 'admin-password', 'ensure-schema', 'rotate-password', 'force',
package/memhouse/host.js CHANGED
@@ -53,6 +53,59 @@ function read(home = homeDir()) {
53
53
  return null;
54
54
  }
55
55
 
56
+ /**
57
+ * A fingerprint of THIS MACHINE, not of this install.
58
+ *
59
+ * It used to be `randomBytes(16)`, which lives only in host.json — so wiping MEMHOUSE_HOME
60
+ * (`uninstall --full-removal`, a reset, a new laptop image) made the same physical machine
61
+ * come back as a stranger. One MacBook read as three writers in the fleet list, each
62
+ * holding a slice of the same machine's history.
63
+ *
64
+ * Now it is derived from a stable per-machine id — IOPlatformUUID on macOS,
65
+ * /etc/machine-id (or the D-Bus one) on Linux, MachineGuid on Windows — hashed with a
66
+ * fixed salt so the raw hardware id never leaves the machine and cannot be recovered from
67
+ * a row. Same machine, same fingerprint, reinstall after reinstall. Where no such id can
68
+ * be read, it falls back to random: an identity that is merely unstable is far better than
69
+ * one that collides, and the fallback is recorded so callers can say which happened.
70
+ *
71
+ * @returns {string} 32 hex characters
72
+ */
73
+ function machineFingerprint(raw = stableMachineId()) {
74
+ const SALT = 'memhouse/host/v1'; // versioned: changing it re-identifies every machine
75
+ return raw
76
+ ? crypto.createHash('sha256').update(`${SALT}:${raw}`).digest('hex').slice(0, 32)
77
+ : crypto.randomBytes(16).toString('hex');
78
+ }
79
+
80
+ /** The OS's own machine id, or null when it cannot be read. Never stored, never shipped. */
81
+ function stableMachineId() {
82
+ const run = (cmd, args) => {
83
+ try {
84
+ const r = require('child_process').execFileSync(cmd, args, { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 });
85
+ return String(r || '').trim() || null;
86
+ } catch { return null; }
87
+ };
88
+ try {
89
+ if (process.platform === 'darwin') {
90
+ const out = run('/usr/sbin/ioreg', ['-rd1', '-c', 'IOPlatformExpertDevice']);
91
+ const m = out && /"IOPlatformUUID"\s*=\s*"([^"]+)"/.exec(out);
92
+ return m ? m[1] : null;
93
+ }
94
+ if (process.platform === 'linux') {
95
+ for (const f of ['/etc/machine-id', '/var/lib/dbus/machine-id']) {
96
+ try { const v = fs.readFileSync(f, 'utf-8').trim(); if (v) return v; } catch { /* next */ }
97
+ }
98
+ return null;
99
+ }
100
+ if (process.platform === 'win32') {
101
+ const out = run('reg', ['query', 'HKLM\\SOFTWARE\\Microsoft\\Cryptography', '/v', 'MachineGuid']);
102
+ const m = out && /MachineGuid\s+REG_SZ\s+(\S+)/.exec(out);
103
+ return m ? m[1] : null;
104
+ }
105
+ } catch { /* fall through */ }
106
+ return null;
107
+ }
108
+
56
109
  /**
57
110
  * The identity of this machine, creating it on first use.
58
111
  *
@@ -69,7 +122,7 @@ function identity(home = homeDir()) {
69
122
  let rec = read(home);
70
123
  if (!rec) {
71
124
  rec = {
72
- fingerprint: crypto.randomBytes(16).toString('hex'),
125
+ fingerprint: machineFingerprint(),
73
126
  hostname,
74
127
  platform: os.platform(),
75
128
  arch: os.arch(),
@@ -98,4 +151,4 @@ function identity(home = homeDir()) {
98
151
  };
99
152
  }
100
153
 
101
- module.exports = { identity, read, filePath, idFrom, FILE };
154
+ module.exports = { identity, read, filePath, idFrom, machineFingerprint, stableMachineId, FILE };
@@ -27,23 +27,54 @@ const config = {
27
27
  database: process.env.MEMHOUSE_DB || process.env.MEMHOUSE_USER, // house defaults to the user's own name
28
28
  };
29
29
 
30
+ /**
31
+ * The house may have moved under this process: the operator converts a pre-one-layout
32
+ * house and the member's rooms go from `<name>.*` to `mem.<name>_*`, while this server
33
+ * still holds the database its env named at boot. The shipper and `update` learned to
34
+ * adopt that; the dashboard did not, so every endpoint answered 500 behind a page that
35
+ * simply looked blank. Checked once at startup, cheap, and never fatal.
36
+ *
37
+ * @returns {Promise<'current'|'moved'|'unknown'>}
38
+ */
39
+ async function adoptMovedHouse() {
40
+ const me = config.username;
41
+ if (!me || config.database === 'mem') return 'current';
42
+ const probe = createClient({ ...clientOptions(), database: 'system' });
43
+ try {
44
+ const rs = await probe.query({
45
+ query: `SELECT database, name FROM system.tables WHERE (database = {old:String} AND name IN ('messages', {mine:String})) OR (database = 'mem' AND name = {mine:String})`,
46
+ query_params: { old: config.database, mine: `${me}_messages` },
47
+ format: 'JSONEachRow',
48
+ });
49
+ const rows = await rs.json();
50
+ const has = (d, n) => rows.some((r) => r.database === d && r.name === n);
51
+ if (has(config.database, `${me}_messages`) || has(config.database, 'messages')) return 'current';
52
+ if (!has('mem', `${me}_messages`)) return 'unknown';
53
+ console.log(`[memhouse] rooms moved: ${config.database}.messages is now mem.${me}_messages — reading house 'mem'`);
54
+ config.database = 'mem';
55
+ _client = null; // rebuilt with the new database on the next call
56
+ return 'moved';
57
+ } catch { return 'unknown'; }
58
+ finally { try { await probe.close(); } catch { /* nothing to close */ } }
59
+ }
60
+
30
61
  let _client = null;
31
- function getClient() {
32
- if (_client) return _client;
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.
62
+ function clientOptions() {
63
+ return {
36
64
  log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
37
65
  url: config.url,
38
66
  username: config.username,
39
67
  password: config.password,
40
68
  database: config.database,
41
69
  request_timeout: 60000,
42
- clickhouse_settings: {
43
- ...READ_SETTINGS,
44
- output_format_json_quote_64bit_integers: 0,
45
- },
46
- });
70
+ clickhouse_settings: { ...READ_SETTINGS, output_format_json_quote_64bit_integers: 0 },
71
+ };
72
+ }
73
+ function getClient() {
74
+ if (_client) return _client;
75
+ // See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
76
+ // message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
77
+ _client = createClient(clientOptions());
47
78
  return _client;
48
79
  }
49
80
 
@@ -1101,7 +1132,7 @@ async function schema() {
1101
1132
  };
1102
1133
  }
1103
1134
 
1104
- module.exports = {
1135
+ module.exports = { adoptMovedHouse,
1105
1136
  getClient, config,
1106
1137
  getOverview, getDailyActivity, getDashboardStats,
1107
1138
  getChats, countChats, getChat,
@@ -435,6 +435,8 @@ app.get('*', (req, res) => {
435
435
  });
436
436
 
437
437
  if (require.main === module) {
438
+ // Before serving: has the house moved under us? (see queries.adoptMovedHouse)
439
+ qy.adoptMovedHouse().catch(() => {});
438
440
  app.listen(PORT, HOST, () => {
439
441
  console.log(`[memhouse] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
440
442
  + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.18.5",
3
+ "version": "0.18.6",
4
4
  "description": "memhouse \u2014 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": {