memhouse 0.18.10 → 0.18.11

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/CHANGELOG.md CHANGED
@@ -4,6 +4,133 @@ Versions before 0.8.0 were beta-only. Beta installs of 0.7.x and earlier should
4
4
  uninstall and reinstall — the 0.8.0 layout is new, and an in-place `update` +
5
5
  migration path is planned work, not a promise the old versions can cash.
6
6
 
7
+ ## 0.18.11 — 2026-10-10
8
+
9
+ Release notes: [`docs/releases/v0.18.11.md`](docs/releases/v0.18.11.md). Read its upgrade
10
+ section first: the first pass after the upgrade re-sends every stored session with
11
+ subagents once.
12
+
13
+ - Sessions with subagents ship only their new rows.
14
+ - **Security:** `invite` no longer sends a stored credential to `--url` (G4).
15
+ - The 0.18.10 pre-tag list is finished, and the gentar kit is v0.9.3.
16
+ - The dashboard ships the license notices of every package it bundles (`public/THIRD_PARTY_NOTICES.txt`).
17
+ - **No drill was run for this release; waived (2026-10-08) under the pilot's 0.18.10 ruling. The drill runs before 0.19.0.**
18
+
19
+ - **The dashboard now ships the license notices of what it bundles.** The built dashboard
20
+ (`public/`) includes React, chart.js, lucide-react (ISC), react-markdown, the remark and
21
+ micromark family, tailwindcss and others: every bundled package. Their licenses require the
22
+ notice to travel with every copy, and Vite's minifier strips the license comments, so
23
+ every published package so far carried those copies without one.
24
+ - `ui/third-party-notices.js`, a Vite plugin with no dependencies of its own, now writes
25
+ `public/THIRD_PARTY_NOTICES.txt` at build time from the bundle's module graph. It
26
+ lists each package with its version, declared license, and the license text it ships.
27
+ - A bundled package with no license text fails the build. The only exception is an
28
+ explicit, reasoned list of packages that truly ship none; it is empty today. Each
29
+ `name@version` is listed once, so a second bundled version keeps its own text.
30
+ - CI's tarball audit also fails if the file is missing or does not list the main
31
+ packages.
32
+ - **A session with subagents ships only its new rows, like any other.** 0.18.8's tail-only
33
+ shipping did not reach the sessions that grow the most: folded subagent rows were numbered
34
+ by position after the parent's turns, so one new parent turn moved every one of them, the
35
+ session forked to a new epoch and re-sent itself whole on every pass. Measured on a real
36
+ house: 1.07 M message rows written in 24 h against 10.2 M in total, most of them full
37
+ copies of five sessions, one 6,120-line session stored 60 times in a day. A subagent's
38
+ rows now sit in a block of their own — `seq = 1,000,000,000 + slot × 100,000 + turn`,
39
+ `tool_calls.idx` the same — where `slot` is the subagent's rank by start time, so neither
40
+ the parent growing, a running subagent growing, nor a new subagent appearing moves an
41
+ existing row. The session's own turns keep `0, 1, 2, …`, so a session without subagents
42
+ is numbered exactly as before. Readers that `ORDER BY seq` see the parent first, then each
43
+ subagent contiguous, in the order they started (they were in agent-id order, which is
44
+ random). `seq` is sparse now: count with `count()`, never `max(seq) + 1`.
45
+ **Upgrade cost, once:** every stored session with subagents forks exactly one time, the
46
+ first time it is re-shipped after the upgrade (it grows, or `ship --full`), and is
47
+ tail-only from then on. The superseded epoch is kept, as every epoch is. Sessions without
48
+ subagents are not affected. Two things widen that first pass:
49
+ - A session whose subagent files are newer than the house's record of it is read again on
50
+ the first pass even if it did not grow. `lastUpdatedAt` now counts subagent writes; see
51
+ the next entry. So the first pass after the upgrade forks and re-sends every such
52
+ session whole, once.
53
+ - Two installs that ship the SAME session files (two instances on one machine, or a
54
+ synced directory) must be upgraded together. A 0.18.10 and a 0.18.11 number the same
55
+ session differently, and each would fork it back on every pass.
56
+
57
+ **The first pass is a one-time bulk write into the house.** Schedule it, and do not
58
+ run it on a house whose storage is degraded. A parse that does not fit the blocks (a parent past 1e9
59
+ turns, a subagent past 100,000, more than 32,949 subagents) falls back to positional
60
+ numbering, which is correct and merely re-ships whole.
61
+ - **A subagent that runs on after its parent goes quiet is shipped on the next pass.** The
62
+ incremental skip compared only the parent file's mtime (or the index's `modified`), and a
63
+ subagent writes only its own file, so a background agent's turns waited for the parent's
64
+ next write, or were never shipped. A session's `lastUpdatedAt` is now the newest mtime of
65
+ the parent and every subagent transcript, Workflow-run agents included — stat calls only,
66
+ and only for sessions that have a subagent directory. After upgrading, a session whose
67
+ subagents are newer than its parent is re-read once on the first pass, and ships the turns
68
+ it was missing.
69
+ - **`memhouse update --no-install` restarts a tarball or nightly install.** It is the command
70
+ every nightly prints for itself, and on exactly those builds it stopped at "no automatic
71
+ update for this install" (a tarball is on no registry tag), exited 1, and left the old
72
+ shipper running the old code in memory. `--no-install` no longer needs a channel: the
73
+ files are already in place, and the restart, migrations and schema check run. Found while
74
+ upgrading testbed from 0.18.9 to a nightly of this release.
75
+
76
+ - **A subagent caught before its first line is written holds its session for one pass.**
77
+ A subagent with no timestamped line ranked last. When its first line then landed with an
78
+ earlier time than a sibling's, it moved ahead of it, renumbered every sibling after it,
79
+ and the session forked and re-shipped whole. Now the shipper waits a pass (`held for one
80
+ pass` in the log) and ranks it by its real start time. A file that never gets a
81
+ timestamped line stops holding the session after 10 minutes. (GLM-F1)
82
+ - **An option that takes no value no longer swallows the next word.**
83
+ - `memhouse update --no-install yes` set no-install to "yes", and ran the npm install it
84
+ was told to skip.
85
+ - `memhouse share --revoke bob` revoked nobody.
86
+ - Switches are now true when present, and the next word stays a positional.
87
+ (A6, review G34)
88
+ - **`update --channel <name>` pins only a channel the registry has.** It compared the flag
89
+ with itself and never wrote the pin at all. A name that is not a tag is now refused, and
90
+ nothing is pinned. When the registry does not answer, the channel is not pinned. (A5)
91
+ - **`update --no-install` no longer prints "files updated"** for files it did not touch.
92
+ It also now runs on a project-dependency install too; it installs nothing, so where the
93
+ files came from does not matter. (A6)
94
+ - **The epoch log line counts rows, not the highest seq.** On a session with subagents it
95
+ read "1000000123 messages stored". (A4)
96
+ - **Docs: the host id is derived from the machine's own id, not random,** since 0.18.6.
97
+ The guides, `HOUSE.md`, `host.js` and `uninstall --full-removal` said a reinstall mints a
98
+ new identity. It derives the same one again. (A8)
99
+ - **`invite` never sends a credential this install keeps to `--url` (G4, security).** `--url`
100
+ is the invitee's address, typed on a command line. A typo, a tunnel or somebody else's
101
+ server got the member's own credential (the reachability probe and the grants read), or
102
+ a kept admin's (provisioning). `resolveConfig()` lets the flag outrank the env file, so
103
+ the "configured" URL the code used WAS `--url`.
104
+ - A stored credential now goes only to the house it was saved for: the member's, a
105
+ `--keep-admin` admin's, or a password resolved from the env file.
106
+ - `--url` gets an unauthenticated `/ping` to confirm the invitee can reach it.
107
+ - An admin given for this command (`--admin-user` or an exported
108
+ `MEMHOUSE_ADMIN_USER`, with its password from a file, stdin, the environment or a
109
+ prompt) still provisions at `--url`.
110
+ - `misc/invite-matrix.sh` points `--url` at a spy server and asserts it receives
111
+ nothing but a ping.
112
+ - **The dashboard no longer goes empty on a quiet house (G11).** The stats tables carry a
113
+ one-day TTL and were refreshed only by a pass that shipped something. So a house whose
114
+ machines went quiet for a day lost every generation, and the dashboard, which had chosen
115
+ its tables once per process, kept reading the emptied one.
116
+ - The shipper now also refreshes when the newest generation is over an hour old.
117
+ - The dashboard re-resolves its rooms every five minutes, so it falls back to the inline
118
+ rollup when a stats table is empty.
119
+ - **The skills find their reference again (G26).** All five said
120
+ `Read ../reference/HOUSE.md`, a path that does not exist in the installed plugin; the
121
+ file is at `../../reference/HOUSE.md`. A unit test now resolves every skill's reference
122
+ path from its own directory.
123
+ - **`/mem:recall` queries that run (A2).**
124
+ - "Recent sessions" ordered by `started_at`, which the room does not have; it now uses
125
+ `created_at` / `last_updated_at`, read `FINAL`.
126
+ - The search now returns `hit_seq`, which the transcript read needs.
127
+ - The read takes its window by position (three turns before, twelve after) instead of
128
+ by `seq` arithmetic, which a subagent's block numbering defeats.
129
+ - **`HOUSE.md`'s subagent queries keep to the current parse (A3).** They had no epoch
130
+ filter, so a re-parsed session doubled each subagent's turns.
131
+ - `misc/skill-recipe.sh` now runs these queries verbatim, extracted from the docs, against
132
+ fixture rows with a superseded parse and a subagent block.
133
+
7
134
  ## 0.18.10 — 2026-10-08
8
135
 
9
136
  Release notes: [`docs/releases/v0.18.10.md`](docs/releases/v0.18.10.md), covering what
package/README.md CHANGED
@@ -14,8 +14,8 @@ can search every past session, see what it cost, and reopen the one that solved
14
14
  before.
15
15
 
16
16
  Every row says where it came from, in a way no client can fake. `user_id` is stamped by
17
- the server (`MATERIALIZED currentUser()`), and `host` is a fingerprint minted once per
18
- install. A team pointed at one house gets a shared memory with real attribution.
17
+ the server (`MATERIALIZED currentUser()`), and `host` is a fingerprint derived from
18
+ the machine's own id. A team pointed at one house gets a shared memory with real attribution.
19
19
 
20
20
  ## Install
21
21
 
package/bin/memhouse.js CHANGED
@@ -42,17 +42,7 @@ const LOG_DIR = path.join(HOME_DIR, 'logs');
42
42
  const argv = process.argv.slice(2);
43
43
  const cmd = argv[0] && !argv[0].startsWith('-') ? argv[0] : null;
44
44
  const rest = cmd ? argv.slice(1) : argv;
45
- const flags = {};
46
- const positional = [];
47
- for (let i = 0; i < rest.length; i++) {
48
- const a = rest[i];
49
- if (a.startsWith('--')) {
50
- const key = a.slice(2);
51
- const next = rest[i + 1];
52
- if (next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
53
- else flags[key] = true;
54
- } else positional.push(a);
55
- }
45
+ const { flags, positional } = require(path.join(REPO_ROOT, 'memhouse', 'flags')).parseArgv(rest);
56
46
  const JSON_OUT = flags.json === true;
57
47
 
58
48
  // Unknown flags stop the run — see memhouse/flags.js for why a shrug is not enough.
@@ -341,6 +331,22 @@ async function ask(question, dflt) {
341
331
  // reason in .cause. `✗ not connected: fetch failed` names neither the host nor the problem,
342
332
  // while `ship` prints ECONNREFUSED for the identical condition — the three commands
343
333
  // disagreed about the same event.
334
+ // Is `url` an HTTP endpoint that answers at all? ClickHouse's /ping needs no credential, so
335
+ // this checks an address without sending one. Any HTTP response counts — a proxy that
336
+ // wants auth on every path still proves the address is reachable.
337
+ async function pingUrl(url) {
338
+ let target;
339
+ try { target = new URL('ping', String(url).endsWith('/') ? String(url) : `${url}/`); }
340
+ catch { return { ok: false, reason: 'not a URL' }; }
341
+ try {
342
+ await fetch(target, { signal: AbortSignal.timeout(8000), redirect: 'manual' });
343
+ return { ok: true };
344
+ } catch (e) {
345
+ const c = e && e.cause && (e.cause.code || e.cause.message);
346
+ return { ok: false, reason: c || (e && e.name === 'TimeoutError' ? 'timed out' : String(e && e.message || e)) };
347
+ }
348
+ }
349
+
344
350
  function netReason(e) {
345
351
  const cause = e && e.cause;
346
352
  const inner = cause && (cause.message || cause.code);
@@ -358,7 +364,7 @@ function netReason(e) {
358
364
  // --admin-password (warned), then MEMHOUSE_ADMIN_PASSWORD, then the stored credential
359
365
  // of that SAME admin, then a TTY prompt. null = none, and the caller has been told how
360
366
  // to give one without putting it on the command line. See memhouse/admin-secret.js.
361
- async function adminPasswordFor(adminUser) {
367
+ async function adminPasswordFor(adminUser, out = {}) {
362
368
  const as = require(path.join(REPO_ROOT, 'memhouse', 'admin-secret'));
363
369
  const file = readEnvFile();
364
370
  let r;
@@ -369,9 +375,11 @@ async function adminPasswordFor(adminUser) {
369
375
  } catch (e) { console.log(bad(e.message)); return null; }
370
376
  // stderr: `members --json` and friends must emit nothing but JSON on stdout.
371
377
  if (r.source === 'flag') console.error(warn(as.ARGV_WARNING));
378
+ out.source = r.source;
372
379
  if (r.password !== undefined) return r.password;
373
380
  if (process.stdin.isTTY) {
374
381
  const p = await askSecret(` password for '${adminUser}'`);
382
+ out.source = 'prompt';
375
383
  if (p) return p;
376
384
  console.log(bad('no password given'));
377
385
  return null;
@@ -2248,15 +2256,31 @@ function installKind() {
2248
2256
  async function cmdUpdate() {
2249
2257
  const { kind, root } = installKind();
2250
2258
  const cfg0 = resolveConfig();
2251
- // --channel pins for this run AND every later one: the point of pinning is that the next
2252
- // person to type `memhouse update` does not have to remember it.
2253
- if (flags.channel && flags.channel !== true && String(flags.channel) !== cfg0.channel) {
2254
- writeEnvFile({ ...cfg0, channel: String(flags.channel) });
2255
- console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${flags.channel}`));
2256
- }
2257
- const pinned = flags.channel && flags.channel !== true ? String(flags.channel) : (cfg0.channel || null);
2259
+ const asked = flags.channel && flags.channel !== true ? String(flags.channel) : null;
2260
+ // What is PINNED is what the env file says. resolveConfig folds --channel into cfg0, so
2261
+ // comparing the flag with cfg0.channel compared it with itself, and the pin was never
2262
+ // written at all.
2263
+ const stored = readEnvFile().MEMHOUSE_CHANNEL || null;
2264
+ const pinned = asked || cfg0.channel || null;
2258
2265
  const tags = await chan.fetchTags();
2259
2266
  const pick = chan.pickChannel({ version: PKG.version, pinned, tags });
2267
+ // --channel pins for this run AND every later one: the point of pinning is that the next
2268
+ // person to type `memhouse update` does not have to remember it. Only a channel the
2269
+ // registry actually has is pinned. The pin used to be written before anything was
2270
+ // checked, so `--channel tema` (or any typo) left every later `update` following a tag
2271
+ // that does not exist, --no-install included, which checks no channel at all (A5).
2272
+ if (asked && asked !== stored) {
2273
+ if (!tags) {
2274
+ console.log(warn(`the registry did not answer, so channel '${asked}' cannot be checked — not pinned`));
2275
+ } else if (!tags[asked]) {
2276
+ console.log(bad(`no channel '${asked}' on the registry — nothing pinned, nothing changed`));
2277
+ console.log(` channels: ${Object.keys(tags).join(', ')}`);
2278
+ return 1;
2279
+ } else {
2280
+ writeEnvFile({ ...cfg0, channel: asked });
2281
+ console.log(ok(`channel pinned in ${short(ENV_FILE)}: ${asked}`));
2282
+ }
2283
+ }
2260
2284
  const latest = pick.target;
2261
2285
  if (JSON_OUT && flags.check) return console.log(JSON.stringify({ kind, root, current: PKG.version, channel: pick.channel, target: pick.target, reason: pick.reason, tags }, null, 2));
2262
2286
  console.log(` installed ${PKG.version} (${kind}: ${root})`);
@@ -2264,12 +2288,18 @@ async function cmdUpdate() {
2264
2288
  console.log(` target ${pick.target || (tags ? 'nothing to install' : 'unknown — the registry did not answer')}`);
2265
2289
  if (latest && latest === PKG.version && kind !== 'checkout') console.log(ok('already current'));
2266
2290
  if (flags.check) return 0;
2267
- if (!pick.channel && kind !== 'checkout') {
2291
+ // --no-install asks for the restart and the house checks only; the files are already in
2292
+ // place, so which channel they came from does not matter. Refusing here for want of one
2293
+ // made the command every nightly prints for itself ("memhouse update --no-install")
2294
+ // return 1 on exactly the builds that print it — a tarball is on no registry tag — and
2295
+ // leave the old shipper running the old code.
2296
+ const noInstall = flags['no-install'] === true;
2297
+ if (!pick.channel && kind !== 'checkout' && !noInstall) {
2268
2298
  console.log(warn('no automatic update for this install'));
2269
2299
  console.log(` ${pick.reason}`);
2270
2300
  return 1;
2271
2301
  }
2272
- if (pick.channel && !pick.target && kind !== 'checkout') {
2302
+ if (pick.channel && !pick.target && kind !== 'checkout' && !noInstall) {
2273
2303
  console.log(bad(`channel '${pick.channel}' has no version on the registry — nothing to install, nothing changed`));
2274
2304
  return 1;
2275
2305
  }
@@ -2279,7 +2309,9 @@ async function cmdUpdate() {
2279
2309
  console.log(' to keep a version around: npm install -g memhouse');
2280
2310
  return 1;
2281
2311
  }
2282
- if (kind === 'local-dep') {
2312
+ // --no-install installs nothing, so where the files came from does not matter: restart and
2313
+ // check the house. The refusal used to come first and turned it away (A6).
2314
+ if (kind === 'local-dep' && !noInstall) {
2283
2315
  console.log(warn('this is a project dependency, not a global install'));
2284
2316
  console.log(` upgrade it where it lives: npm install memhouse@${pick.channel || 'latest'}`);
2285
2317
  return 1;
@@ -2292,7 +2324,7 @@ async function cmdUpdate() {
2292
2324
  try { svc = require(path.join(REPO_ROOT, 'memhouse', 'service.js')).status(); } catch { /* unsupported platform */ }
2293
2325
  const wasRunning = { shipper: !!pidOf('shipper'), dashboard: !!pidOf('dashboard') };
2294
2326
 
2295
- if (flags['no-install'] === true) {
2327
+ if (noInstall) {
2296
2328
  // The files were already replaced by other means — a hand-typed `npm i -g`, a tarball,
2297
2329
  // a configuration manager. The npm/git half is exactly what such a pilot has already
2298
2330
  // done, and the half a bare install leaves undone (restart, migrations, schema heal)
@@ -2326,7 +2358,9 @@ async function cmdUpdate() {
2326
2358
  return 1;
2327
2359
  }
2328
2360
  }
2329
- console.log(ok('files updated'));
2361
+ // Said only when something was installed. Under --no-install it claimed "files updated"
2362
+ // for files nobody touched (A6).
2363
+ if (!noInstall) console.log(ok('files updated'));
2330
2364
  // The house may have been converted to the one layout since this machine last ran:
2331
2365
  // the member's rooms moved from <name>.* to mem.<name>_*. Fix the env here, before the
2332
2366
  // shipper restarts, or its first pass refuses the old address forever.
@@ -3709,14 +3743,36 @@ async function cmdInvite() {
3709
3743
  // itself is tried below — which works for whoever set the house up as its superuser.
3710
3744
  if (flags['admin-user'] === true) { console.log(bad('--admin-user needs a name, e.g. --admin-user default')); return 1; }
3711
3745
  let adminUser = (flags['admin-user'] ? String(flags['admin-user']) : '') || cfg.adminUser || undefined;
3746
+ // Who named this admin decides where its credential may go (G4, memhouse/invite-target.js):
3747
+ // one given for this command provisions at --url; one this install keeps provisions only
3748
+ // at the house it was saved for.
3749
+ const adminUserSource = flags['admin-user'] ? 'flag'
3750
+ : (process.env.MEMHOUSE_ADMIN_USER ? 'env' : (adminUser ? 'file' : 'member'));
3712
3751
  let adminPass;
3752
+ const pwSource = {};
3713
3753
  if (adminUser) {
3714
3754
  // --admin-password-file / --admin-password / MEMHOUSE_ADMIN_PASSWORD / the stored one
3715
3755
  // for this same admin / a prompt. `--admin-user` as a flag used to switch the
3716
3756
  // environment off, so the only scriptable path put the password on argv.
3717
- adminPass = await adminPasswordFor(adminUser);
3757
+ adminPass = await adminPasswordFor(adminUser, pwSource);
3718
3758
  if (adminPass === null) return 1;
3719
3759
  }
3760
+ const target = require(path.join(REPO_ROOT, 'memhouse', 'invite-target'))
3761
+ .provisionTarget({ url, storedUrl: configuredUrl, adminUserSource, passwordSource: pwSource.source || null });
3762
+ if (target.error) { console.log(bad(target.error)); return 1; }
3763
+ // A stored credential is never sent to --url, so check that the INVITEE can reach it
3764
+ // without one: ClickHouse answers /ping unauthenticated.
3765
+ if (target.stored) {
3766
+ const reach = await pingUrl(url);
3767
+ if (!reach.ok) {
3768
+ console.log(bad(`cannot reach ${url}: ${reach.reason}`));
3769
+ console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
3770
+ console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
3771
+ console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
3772
+ console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
3773
+ return 1;
3774
+ }
3775
+ }
3720
3776
  if (!adminUser) {
3721
3777
  const c = requireConfig(cfg, 'invite');
3722
3778
  if (!c.user) return 1;
@@ -3735,20 +3791,18 @@ async function cmdInvite() {
3735
3791
  // unreachable host — a typo, a loopback-bound house, a tunnel that is down — come back
3736
3792
  // as "you are only a member", which sends the reader after a privilege they already
3737
3793
  // have. Caught on the testbed, where a wrong --url produced exactly that.
3794
+ // The member credential is stored, so it is checked against the house it was saved for
3795
+ // (target.url), never against --url; --url's reachability was checked above, unauthenticated.
3738
3796
  try {
3739
- await ch({ ...c, url }, 'SELECT 1', { database: '' });
3797
+ await ch({ ...c, url: target.url }, 'SELECT 1', { database: '' });
3740
3798
  } catch (e) {
3741
- console.log(bad(`cannot reach ${url} as '${c.user}': ${netReason(e)}`));
3742
- console.log(' --url is the address the INVITEE will use, and invite checks it from here first.');
3743
- console.log(' A `deploy --local` house is bound to loopback on purpose, so no LAN address');
3744
- console.log(' reaches it and no invitee could either — publish it (tunnel, reverse proxy)');
3745
- console.log(' before inviting, or use --print-sql and let the invitee be told the address.');
3799
+ console.log(bad(`cannot reach your house ${target.url} as '${c.user}': ${netReason(e)}`));
3746
3800
  return 1;
3747
3801
  }
3748
3802
  // What this credential may do, judged from its own grants and SCOPE-AWARE — a member
3749
3803
  // holds CREATE DATABASE inside `ON <their-db>.*`, which mints no new house at all.
3750
3804
  // Shared with `whoami` so the two can never disagree about who is an administrator.
3751
- const caps = capabilitiesFrom(await readGrants({ ...c, url }, c.user));
3805
+ const caps = capabilitiesFrom(await readGrants({ ...c, url: target.url }, c.user));
3752
3806
  if (caps.canProvision) {
3753
3807
  adminUser = c.user; adminPass = c.password;
3754
3808
  console.log(ok(`inviting as your own credential '${c.user}' (it can create users and houses here)`));
@@ -3771,13 +3825,12 @@ async function cmdInvite() {
3771
3825
  }
3772
3826
  }
3773
3827
 
3774
- // WHERE to connect to provision. --url is the address the INVITEE will use — it may be
3775
- // a LAN IP or tunnel that is not this admin's own endpoint, and sending the stored
3776
- // credential there would hand it to whatever answers a typo'd or hostile --url. So the
3777
- // stored-credential path provisions at the admin's OWN configured cfg.url; only an
3778
- // EXPLICIT --admin-* (where the admin typed the target themselves) provisions at --url.
3779
- // Either way the invite FILE carries --url, the invitee's path.
3780
- const provisionUrl = flags['admin-user'] ? url : cfg.url;
3828
+ // WHERE to connect to provision: decided above by provisionTarget(). A stored credential
3829
+ // provisions at the house it was saved for; one given for this command, at --url. Either
3830
+ // way the invite FILE carries --url, the invitee's path. (This used to read
3831
+ // `flags['admin-user'] ? url : cfg.url` — but cfg.url IS --url once the flag is given, so
3832
+ // the stored credential went to whatever --url named.)
3833
+ const provisionUrl = target.url;
3781
3834
 
3782
3835
  // Is there already a house here, with somebody's memory in it? The house is created
3783
3836
  // with CREATE DATABASE IF NOT EXISTS, so inviting a name whose database already exists
@@ -4037,8 +4090,10 @@ async function cmdUninstall() {
4037
4090
  //
4038
4091
  // (default) daemons, service, runtime state. Config and identity KEPT.
4039
4092
  // --credentials also forget the house: the env file, with its password.
4040
- // --full-removal all of MEMHOUSE_HOME, fingerprint included. The next install is a
4041
- // NEW host whose rows do not join this machine's history.
4093
+ // --full-removal all of MEMHOUSE_HOME, host.json included. The next install
4094
+ // derives the same fingerprint from the OS machine id (0.18.6+), so it
4095
+ // is the same host unless the machine was renamed meanwhile or has no
4096
+ // readable machine id (then it falls back to random: a new host).
4042
4097
  //
4043
4098
  // No tier touches the house: transcripts live in ClickHouse and re-ship from the local
4044
4099
  // session stores regardless.
@@ -4059,8 +4114,9 @@ async function cmdUninstall() {
4059
4114
  if (flags.yes !== true && process.stdin.isTTY) {
4060
4115
  const id = hostjs.read(HOME_DIR);
4061
4116
  console.log(warn(`--full-removal also discards this machine's host identity${id ? ` (${id.id})` : ''}.`));
4062
- console.log(' Rows already shipped keep that name, a future install gets a new one, and');
4063
- console.log(" this machine then reads as two. Your transcripts themselves are safe.");
4117
+ console.log(' A reinstall derives it again from this machine\'s own id, so it normally comes');
4118
+ console.log(' back the same — unless the machine is renamed first, or has no readable machine');
4119
+ console.log(" id, and then it reads as two machines. Your transcripts themselves are safe.");
4064
4120
  const a = (await ask('Remove everything, including the host identity? (yes/no)', 'no')).toLowerCase();
4065
4121
  if (a !== 'yes' && a !== 'y') { console.log('aborted — daemons are stopped, nothing was removed'); return; }
4066
4122
  }
@@ -34,11 +34,14 @@ with you. Naming a room you were not granted is an error, never a silent omissio
34
34
  - **`user_id`** is stamped by the server: `MATERIALIZED currentUser()`. Async inserts are
35
35
  pinned off for members, so the stamp cannot be skipped. `WHERE user_id = 'alice'` is one
36
36
  person.
37
- - **`host`** is the machine. Each install mints a random fingerprint once and keeps it in
38
- `$MEMHOUSE_HOME/host.json`, and every row carries `<hostname>-<8 hex>`. It is random,
39
- not derived from the machine:
40
- - a derived id would merge two laptops that both answer to `MacBook-Pro`;
41
- - a derived id would split one machine in two when it is renamed.
37
+ - **`host`** is the machine. Every row carries `<hostname>-<8 hex>`, where the 8 hex come
38
+ from a fingerprint of the OS's own machine id (IOPlatformUUID on macOS,
39
+ `/etc/machine-id` on Linux), hashed so the raw id never leaves the machine. It is kept in
40
+ `$MEMHOUSE_HOME/host.json`. It is not taken from the hostname:
41
+ - two laptops that both answer to `MacBook-Pro` still get different ids;
42
+ - a renamed machine keeps its id, and `status` reports the new name beside it;
43
+ - reinstalling, even after `uninstall --full-removal`, brings the same machine back
44
+ under the same id. Where no machine id can be read, it falls back to a random one.
42
45
 
43
46
  `memhouse status` shows this machine's id. `memhouse doctor` names every host in your
44
47
  rooms.
@@ -12,8 +12,10 @@ npm uninstall -g memhouse # then remove the package
12
12
  - **The default is the conservative one.** Stopping the shipper is routine: before an
13
13
  upgrade, while debugging, or when a laptop should go quiet for a week. It should not
14
14
  also mean "forget which house I use and who this machine is".
15
- - **`--full-removal` asks first.** A later reinstall mints a **new** machine identity, and
16
- this machine then reads as two machines in your history.
15
+ - **`--full-removal` asks first.** It deletes `host.json`. A later reinstall derives the
16
+ same identity again from the machine's own id, so your history stays one machine, unless
17
+ the machine was renamed in between or has no readable machine id. Then it comes back as
18
+ a new host.
17
19
  - **Your memory stays.** The rooms in ClickHouse are untouched by every tier. What the
18
20
  shipper wrote can be rebuilt from the local session stores with `memhouse ship --full`,
19
21
  for as long as the editors keep them.
@@ -5,6 +5,7 @@ do, how to upgrade, what is deprecated and when it goes, and what was deferred.
5
5
 
6
6
  | version | date | headline |
7
7
  |---|---|---|
8
+ | [0.18.11](v0.18.11.md) | 2026-10-10 | sessions with subagents stop re-shipping; security: no stored credential sent to `invite --url` |
8
9
  | [0.18.10](v0.18.10.md) | 2026-10-08 | security: the admin password never on argv; no member password printed; a scoped share reaches only its scope |
9
10
 
10
11
  Earlier versions: [`CHANGELOG.md`](../../CHANGELOG.md).
@@ -0,0 +1,191 @@
1
+ # memhouse 0.18.11 — 2026-10-10
2
+
3
+ This release does three things:
4
+
5
+ - **Sessions with subagents stop re-shipping.** A Claude Code session that used subagents
6
+ now ships only its new rows. Before, it re-sent itself whole on every pass, and that was
7
+ most of the house's write load.
8
+ - **A security fix.** `invite` no longer sends a stored credential to whatever `--url` is
9
+ typed on the command line.
10
+ - **The 0.18.10 pre-tag list is finished**, and the gentar test kit is bumped.
11
+
12
+ **No drill was run for this release; waived (2026-10-08) under the pilot's 0.18.10 ruling. The drill runs before 0.19.0.**
13
+
14
+ ## Read this before upgrading
15
+
16
+ **The first pass after the upgrade is a one-time bulk write.** It re-sends, whole and once,
17
+ every stored session that has subagents; after that, those sessions ship only their new
18
+ rows. Two things widen that first pass:
19
+
20
+ - **Sessions whose subagents wrote recently.** A session whose subagent files are newer
21
+ than the house's record of it is read again even if it did not grow, because
22
+ `lastUpdatedAt` now counts subagent writes.
23
+ - **Installs that share session files.** Two installs that ship the same session files
24
+ (two instances on one machine, or a synced directory) must be upgraded together. A
25
+ 0.18.10 and a 0.18.11 number the same session differently, and each would fork it back
26
+ on every pass.
27
+
28
+ **Where that write lands, and when to do it (2026-10-08):**
29
+
30
+ - The pilot's machines (macminim, macbokum) ship to **zeo.memhouse.io**: VM 146 on arf,
31
+ ClickHouse 26.7.4.58.
32
+ - It is **not** LXC 112 "memhouse-production"; that is an archive, not a live house.
33
+ - zeo's data disk still sits on arf's degraded array. It was backed up to SSD on
34
+ 2026-10-08 (data and access storage), but the disk itself has not moved.
35
+ - **Upgrade those machines only once zeo's data disk is off the degraded array, or once
36
+ the array is repaired. Time it against the SSD moves.**
37
+ - Any house on healthy storage can upgrade normally.
38
+
39
+ ```bash
40
+ memhouse update # then: memhouse status — every line a check mark
41
+ ```
42
+
43
+ ## Security
44
+
45
+ ### `invite` never sends a stored credential to `--url` (G4)
46
+
47
+ `--url` is the invitee's address, typed on a command line. A typo, a tunnel, or somebody
48
+ else's server received credentials it should never have seen:
49
+
50
+ - the member's own credential, through the reachability probe and the read of its grants;
51
+ - an admin credential the install keeps, through provisioning.
52
+
53
+ The cause: `resolveConfig()` lets a flag outrank the env file, so the "configured house" URL
54
+ the code used *was* `--url`.
55
+
56
+ Now:
57
+
58
+ - **A stored credential goes only to the house it was saved for.** That covers the
59
+ member's own, a `--keep-admin` admin, and a password read from the env file.
60
+ - **`--url` gets an unauthenticated `/ping`**, only to confirm the invitee can reach it.
61
+ - **An admin given for this command** (`--admin-user`, or an exported
62
+ `MEMHOUSE_ADMIN_USER`, with its password from a file, stdin, the environment or a prompt)
63
+ still provisions at `--url`. That is what the person typing the command asked for.
64
+ - **`misc/invite-matrix.sh` points `--url` at a spy server** and asserts that it receives
65
+ nothing but a ping. On 0.18.10 the spy received two authenticated requests.
66
+
67
+ **Action:**
68
+
69
+ - If you ran `memhouse invite … --url <something unusual>` on 0.18.10 or earlier against a
70
+ host you do not control, rotate the member's password with `memhouse passwd`.
71
+ - If an admin credential is kept in the env file and the same applies, rotate that too.
72
+
73
+ ## The re-ship fix (#21)
74
+
75
+ Folded subagent rows were numbered by position, after the parent's turns. One new parent
76
+ turn moved every one of them, so the session forked to a new epoch and was re-sent whole.
77
+ Measured on a real house:
78
+
79
+ - 1.07 M message rows written in 24 h, against 10.2 M in total;
80
+ - one 6,120-line session stored 60 times in a day;
81
+ - polat's house at about 19 M message rows for roughly 295 k real ones.
82
+
83
+ What changed:
84
+
85
+ - **Subagent rows get a numbering block of their own:**
86
+ `seq = 1,000,000,000 + slot × 100,000 + turn`, and the same for tool calls' `idx`.
87
+ `slot` is the subagent's rank by start time, so neither the parent growing, a subagent
88
+ growing, nor a new subagent appearing moves a stored row.
89
+ - **A session without subagents is numbered exactly as before.**
90
+ - **`seq` is sparse.** Count rows with `count()`, never `max(seq) + 1`.
91
+ - **`ORDER BY seq` groups subagents.** Readers see the parent first, then each subagent's
92
+ turns together, in the order the subagents started. They used to be in agent-id order,
93
+ which is random.
94
+ - **A parse too big for the blocks falls back to positional numbering:**
95
+ - a parent past 1e9 turns;
96
+ - a subagent past 100,000 turns;
97
+ - more than 32,949 subagents.
98
+
99
+ Positional numbering is correct, and merely re-ships whole as before.
100
+ - **Subagent writes count as session activity.** A subagent that keeps running after its
101
+ parent goes quiet is shipped on the next pass.
102
+ - **A subagent caught before its first line is written holds its session for one pass.**
103
+ It is then ranked by its real start time; it no longer ranks last and moves later. A
104
+ file that never gets a timestamped line stops holding the session after 10 minutes.
105
+ - **Existing copies are not cleaned up.** Superseded epochs already in a house stay;
106
+ memhouse never deletes to make room. Reclaiming that space is a separate operator
107
+ decision, and no such cleanup may run on a house on degraded storage.
108
+
109
+ ## Also fixed
110
+
111
+ - **The dashboard no longer goes empty on a quiet house (G11).** The stats tables carry a
112
+ one-day TTL and were refreshed only by passes that shipped something.
113
+ - The shipper now also refreshes them when the newest generation is over an hour old.
114
+ - The dashboard re-resolves its tables every five minutes, falling back to the live
115
+ rollup when a stats table is empty.
116
+ - **The skills find their reference again (G26).** All five pointed at
117
+ `../reference/HOUSE.md`, a path that does not exist in the installed plugin; the file is
118
+ at `../../reference/HOUSE.md`.
119
+ - **`/mem:recall`'s queries run (A2).**
120
+ - "Recent sessions" used a column the room does not have.
121
+ - The search now returns the `hit_seq` that the transcript read needs.
122
+ - The read takes its window by position, so the subagent blocks cannot break it.
123
+ - **`HOUSE.md`'s subagent queries keep to the current parse (A3).** They used to double a
124
+ re-parsed session's subagent turns.
125
+ - **An option that takes no value no longer swallows the next word (A6).**
126
+ `update --no-install yes` ran the install it was told to skip, and `share --revoke bob`
127
+ revoked nobody.
128
+ - **`update --channel <name>` pins only a tag the registry has (A5).** Before, it never
129
+ wrote the pin at all.
130
+ - **`update --no-install` restarts a tarball or nightly install.** That is the command
131
+ every nightly build prints for itself. On exactly those builds it stopped at "no
132
+ automatic update for this install" and exited 1, leaving the old shipper running old
133
+ code. It now restarts the daemons, runs any migration, and checks the schema; it needs
134
+ no channel.
135
+ - **`update --no-install` (A6)** no longer prints "files updated", and it is no longer
136
+ refused on a project-dependency install.
137
+ - **The epoch log line counts rows (A4).**
138
+ - **Docs (A8): the host id is derived from the machine's own id**, as it has been since
139
+ 0.18.6, so a reinstall keeps it. Stale prose about timestamps and `extra.turn` is fixed
140
+ too.
141
+
142
+ ## License notices
143
+
144
+ **The dashboard now ships the license notices of what it bundles.** The built dashboard
145
+ (`public/`) includes React, chart.js, lucide-react, react-markdown, tailwindcss and every
146
+ other package it bundles. Their licenses (MIT, ISC) require the notice to travel with each
147
+ copy, and minification strips it from the code.
148
+ - `public/THIRD_PARTY_NOTICES.txt` is now generated at build time from the bundle's module
149
+ graph, and ships in the npm package.
150
+ - CI fails a package that lacks it.
151
+
152
+ Earlier published packages carried those copies without the notices.
153
+
154
+ ## Test infrastructure
155
+
156
+ - **gentar kit v0.9.3 (#18).** In phase 1, `memhouse-house` is reported UNVERIFIED (bench
157
+ only) instead of being dry-run on the CI host.
158
+ - **The suite tears down only a house it created.** It refuses to deploy over an existing
159
+ house, and a failed teardown fails it.
160
+ - **`misc/skill-recipe.sh` runs the skills' queries verbatim, extracted from the docs.**
161
+ The fixture includes a superseded parse and a subagent block.
162
+
163
+ ## Pre-tag list (0.18.10 review): final status
164
+
165
+ | item | where |
166
+ |---|---|
167
+ | A1 version bump | this release |
168
+ | A2 `/mem:recall` queries | #22 |
169
+ | A3 `HOUSE.md` subagent queries, epoch filter | #22 |
170
+ | A4 epoch log count | #21 |
171
+ | A5 `update --channel` validation | #21 |
172
+ | A6 `--no-install` checks; switches swallowing values | #21 |
173
+ | A7 upgrade-cost notes | #21, and this document |
174
+ | A8 stale prose (ts, shrink, `extra` turn, host id) | #21 |
175
+ | G4 `invite --url` and stored credentials (**security**) | #22 |
176
+ | G26 skills' reference path | #22 |
177
+ | G11 idle house's dashboard | #22 |
178
+ | GLM-F1 unstamped subagent file | #21 |
179
+
180
+ ## Verification
181
+
182
+ - **Unit tests:** `npm test` passes on main, 125 checks.
183
+ - **CI:** green on ClickHouse 25.11 and 26.7.
184
+ - **Matrices in a lab**, on ClickHouse 26.7.4.58 (production's version) and 26.7.1.1315.
185
+ They were run during #22 on main with #21 and #18 merged:
186
+ - skill recipe 18/18;
187
+ - invite 58/58;
188
+ - one-layout 62/62.
189
+ - **Second looks:** agy (gemini-3.8-flash-high) on #21 and #22. Codex was at its daily
190
+ quota.
191
+ - **Labs:** kept light, because arf's RAID is degraded. No live house was touched.