memhouse 0.18.10 → 0.18.12

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +10 -11
  2. package/CHANGELOG.md +153 -13
  3. package/README.md +2 -2
  4. package/SECURITY.md +4 -2
  5. package/bin/memhouse.js +105 -49
  6. package/docs/guides/admin-password.md +1 -1
  7. package/docs/guides/configuration.md +2 -2
  8. package/docs/guides/install.md +1 -1
  9. package/docs/guides/invite.md +1 -1
  10. package/docs/guides/storage.md +10 -7
  11. package/docs/guides/uninstall.md +4 -2
  12. package/docs/releases/README.md +2 -0
  13. package/docs/releases/v0.18.10.md +7 -7
  14. package/docs/releases/v0.18.11.md +187 -0
  15. package/docs/releases/v0.18.12.md +59 -0
  16. package/docs/tutorials/01-first-house.md +2 -2
  17. package/editors/claude.js +70 -12
  18. package/editors/cursor-agent.js +1 -1
  19. package/examples/01-local-one-machine/README.md +4 -4
  20. package/examples/02-existing-clickhouse/README.md +7 -7
  21. package/memhouse/DESIGN.md +9 -2
  22. package/memhouse/RESUME-AND-UPDATE.md +1 -1
  23. package/memhouse/capabilities.js +1 -1
  24. package/memhouse/convert.js +3 -3
  25. package/memhouse/delivery/PROMPT.md +1 -1
  26. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +1 -1
  27. package/memhouse/delivery/plugin/reference/HOUSE.md +21 -9
  28. package/memhouse/delivery/plugin/skills/access/SKILL.md +2 -2
  29. package/memhouse/delivery/plugin/skills/admin/SKILL.md +3 -3
  30. package/memhouse/delivery/plugin/skills/house/SKILL.md +2 -2
  31. package/memhouse/delivery/plugin/skills/recall/SKILL.md +36 -16
  32. package/memhouse/delivery/plugin/skills/sql/SKILL.md +2 -2
  33. package/memhouse/flags.js +28 -1
  34. package/memhouse/host.js +11 -6
  35. package/memhouse/house/HOUSE.md +6 -6
  36. package/memhouse/house/house.js +31 -5
  37. package/memhouse/invite-target.js +37 -0
  38. package/memhouse/self-update.js +1 -1
  39. package/memhouse/server/queries.js +11 -6
  40. package/memhouse/shipper/ship.js +148 -31
  41. package/package.json +3 -3
  42. package/public/THIRD_PARTY_NOTICES.txt +2114 -0
  43. package/ui/README.md +16 -0
  44. package/ui/vite.config.js +4 -1
@@ -3,7 +3,7 @@ name: access
3
3
  description: Bring someone onto this memhouse ClickHouse, or let them read your house — mint a new member with their own house and the env file their install needs, grant a housemate read-only access to yours, list who can currently read it, or revoke. Use when the user says "invite X", "add X to memhouse", "get my friend set up", "share my memory with X", "let X see my sessions", "who can read my memory", "stop sharing with X". Inviting comes BEFORE sharing — you can only grant a user who already exists. Read-only by construction on the sharing side: a share can never let anyone write to or delete your memory.
4
4
  user-invocable: true
5
5
  argument-hint: "invite <name> | share <name> | list | revoke <name>"
6
- allowed-tools: Bash
6
+ allowed-tools: Bash(memhouse whoami *), Bash(memhouse status *), Bash(memhouse share --list), Bash(memhouse share --list --json)
7
7
  ---
8
8
 
9
9
  # /mem:access — who exists, and who may read
@@ -13,7 +13,7 @@ Two things that look alike and are not:
13
13
  - **Invite** mints a ClickHouse *user* and their own *house*. Needs an administrator.
14
14
  - **Share** grants an existing user `SELECT` on *your* house. You can do this yourself.
15
15
 
16
- **Read `../reference/HOUSE.md` first** for the connection block. One rule dominates
16
+ **Read `../../reference/HOUSE.md` first** for the connection block. One rule dominates
17
17
  everything here: **a password must never enter this conversation.** memhouse ships this
18
18
  transcript into the house, and the archive is insert-only — a credential pasted here
19
19
  cannot be withdrawn.
@@ -3,7 +3,7 @@ name: admin
3
3
  description: Operate the memhouse ClickHouse as its administrator — list and inspect every user and house, provision or remove members, grant and revoke access, and read server-wide health (disk, parts, mutations, running queries). Use when the user asks to "do X as admin", "manage users", "drop a house", "see disk usage", "who is on this server", or when a member-scoped skill refused for want of privileges. Requires an administrator credential in the environment; says so plainly, and how to supply one, when there is not.
4
4
  user-invocable: true
5
5
  argument-hint: "<what you want done>"
6
- allowed-tools: Bash
6
+ allowed-tools: Bash(memhouse whoami *), Bash(memhouse status *), Bash(memhouse instance *)
7
7
  ---
8
8
 
9
9
  # /mem:admin — operate the house as its administrator
@@ -12,7 +12,7 @@ A member owns a house, or rooms in one. An **administrator** owns the ClickHouse
12
12
  account, every grant. This skill is the second one, and it is the only skill allowed to
13
13
  act outside the caller's own house.
14
14
 
15
- **Read `../reference/HOUSE.md` first** — connection, schema, and the traps every query
15
+ **Read `../../reference/HOUSE.md` first** — connection, schema, and the traps every query
16
16
  here inherits.
17
17
 
18
18
  **An admin password must never enter this conversation.** memhouse ships this transcript
@@ -128,7 +128,7 @@ Before any of them:
128
128
 
129
129
  1. **Look at the target and report what is really there** — row counts per room, who
130
130
  wrote them, the date span, which machines. A house you expected to be empty may not
131
- be: one `ege` house looked disposable and held 3,733 messages belonging to somebody
131
+ be: one `carol` house looked disposable and held 3,733 messages belonging to somebody
132
132
  else entirely.
133
133
  2. **Name the collateral.** Dropping a user stops that account's shipper on every machine
134
134
  it runs on. Revoking a grant closes a share someone may be mid-query on. Rotating a
@@ -3,7 +3,7 @@ name: house
3
3
  description: The state of your memory and who is around it — what memhouse is and how to use it, whether shipping is working, what the house holds (sessions, messages, tokens per editor), how fresh it is, which machines write into it, which houses you can read, and who can read yours. Use for "what is memhouse", "how do I use this", "is my memory working", "how much is stored", "when did it last ship", "which machines are shipping", "who can see my memory", "whose memory can I see", "who else is on this server" — and as the first answer when another /mem skill fails because nothing is configured yet, or before debugging why a search found nothing.
4
4
  user-invocable: true
5
5
  argument-hint: ""
6
- allowed-tools: Bash
6
+ allowed-tools: Bash(memhouse instance *), Bash(memhouse whoami *), Bash(memhouse status *), Bash(memhouse stats *), Bash(memhouse doctor *), Bash(memhouse share --list), Bash(memhouse share --list --json)
7
7
  ---
8
8
 
9
9
  # /mem:house — what this is, whether it works, and who is around it
@@ -11,7 +11,7 @@ allowed-tools: Bash
11
11
  **Invoking this IS the request. Run the report and present it; there is nothing to
12
12
  clarify.** Read-only throughout.
13
13
 
14
- **Read `../reference/HOUSE.md` first** — connection, schema, and the epoch filter every
14
+ **Read `../../reference/HOUSE.md` first** — connection, schema, and the epoch filter every
15
15
  count below depends on.
16
16
 
17
17
  ## Nothing configured yet? Answer that instead
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: recall
3
- description: Retrieve from past agent conversations and answer from them — search every session ever shipped from all 17 editors (Claude Code, Codex, Cursor, VS Code, Zed, Gemini CLI, …) across every project and machine, read the actual transcripts, and answer with citations. Use whenever the user refers to something not in the current context - "how did I fix X last time", "what approach did we settle on", "why did we choose Z", "find that conversation about Y", "did I ever try W", "what was I working on", "show my recent sessions", "sessions from last week", "remind me how I did it". Reach for this BEFORE saying you do not know about prior work. Also reads a housemate's memory when they name it ("how did yigit fix X"). For numbers rather than transcripts — spend, model mix, tool rankings — use /mem:sql.
3
+ description: Retrieve from past agent conversations and answer from them — search every session ever shipped from all 17 editors (Claude Code, Codex, Cursor, VS Code, Zed, Gemini CLI, …) across every project and machine, read the actual transcripts, and answer with citations. Use whenever the user refers to something not in the current context - "how did I fix X last time", "what approach did we settle on", "why did we choose Z", "find that conversation about Y", "did I ever try W", "what was I working on", "show my recent sessions", "sessions from last week", "remind me how I did it". Reach for this BEFORE saying you do not know about prior work. Also reads a housemate's memory when they name it ("how did bob fix X"). For numbers rather than transcripts — spend, model mix, tool rankings — use /mem:sql.
4
4
  user-invocable: true
5
5
  argument-hint: "<question, terms, or 'recent sessions'>"
6
- allowed-tools: Bash
6
+ allowed-tools: Bash(memhouse search *), Bash(memhouse resume *), Bash(memhouse sessions-query *), Bash(memhouse status *), Bash(memhouse whoami *)
7
7
  ---
8
8
 
9
9
  # /mem:recall — find it, read it, answer from it
@@ -16,7 +16,7 @@ answer still exists.
16
16
  path — this skill retrieves rows and YOU read them. Never present a guess as a retrieval:
17
17
  if the house has nothing, say so.
18
18
 
19
- **Read `../reference/HOUSE.md` first** — connection, schema, and the three traps
19
+ **Read `../../reference/HOUSE.md` first** — connection, schema, and the three traps
20
20
  (epoch filtering, `(session_id, user_id)` joins, escaping). Every query below assumes it.
21
21
 
22
22
  ## Which shape is being asked for
@@ -43,7 +43,8 @@ found 8 messages where `%clickhouse%` AND `%ttl%` found 1,461.
43
43
  ```sql
44
44
  SELECT session_id, user_id, any(folder) AS folder,
45
45
  formatDateTime(max(ts), '%Y-%m-%d') AS day, count() AS hits,
46
- substring(anyIf(text, positionCaseInsensitive(text, 'NEEDLE') > 0), 1, 300) AS sample
46
+ min(seq) AS hit_seq,
47
+ substring(argMin(text, seq), 1, 300) AS sample
47
48
  FROM messages
48
49
  WHERE text_ngram LIKE '%needle%'
49
50
  AND (origin != 'ship'
@@ -55,8 +56,8 @@ ORDER BY hits DESC, max(ts) DESC
55
56
  LIMIT 8
56
57
  ```
57
58
 
58
- `anyIf(...)` so the sample is a line that actually matched — `any(text)` returns an
59
- arbitrary message from the session and misled two readers in three. **Order by `hits`,
59
+ Every row counted here matched, so `sample` is a line that actually matched: the
60
+ session's first hit, at `hit_seq`. Step 2 reads around that position. **Order by `hits`,
60
61
  not just recency**: a session mentioning the term 197 times outranks yesterday's passing
61
62
  reference.
62
63
 
@@ -64,15 +65,27 @@ reference.
64
65
  the match so you see the resolution, not the complaint:
65
66
 
66
67
  ```sql
67
- SELECT seq, role, substring(text, 1, 2000) AS text
68
- FROM messages
69
- WHERE session_id = '<sid>' AND user_id = '<uid>'
70
- AND seq BETWEEN <hit_seq - 3> AND <hit_seq + 12>
68
+ SELECT seq, role, is_subagent, substring(text, 1, 2000) AS text FROM (
69
+ (SELECT seq, role, is_subagent, text FROM messages FINAL
70
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq < <hit_seq>
71
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
72
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
73
+ ORDER BY seq DESC LIMIT 3)
74
+ UNION ALL
75
+ (SELECT seq, role, is_subagent, text FROM messages FINAL
76
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND seq >= <hit_seq>
77
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
78
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
79
+ ORDER BY seq LIMIT 13))
71
80
  ORDER BY seq
72
81
  ```
73
82
 
74
- Apply the epoch filter here too — an unfiltered read of a compacted session interleaves
75
- two parses of the same conversation.
83
+ `<sid>`, `<uid>` and `<hit_seq>` come from step 1's row. The window is counted in rows,
84
+ not in `seq` values: three turns before the hit and twelve after. `seq` has gaps. A
85
+ subagent's turns are numbered in a block of their own, far above the parent's, so
86
+ `seq BETWEEN hit_seq - 3 AND hit_seq + 12` could return the hit alone. The epoch filter
87
+ keeps one parse: an unfiltered read of a compacted session interleaves two versions of
88
+ the same conversation. `FINAL` collapses row versions the server has not merged yet.
76
89
 
77
90
  **3. Answer, then cite.** Lead with the answer. Under it list the sessions it came from —
78
91
  `session_id`, date, project — so the user can reopen one (`memhouse resume <session_id>`
@@ -95,13 +108,20 @@ one representative line each. Offer to read the strongest. Do not dump ten rows
95
108
  prefer it over hand-joining. For a plain list:
96
109
 
97
110
  ```sql
98
- SELECT session_id, name, source, host, project,
99
- formatDateTime(started_at, '%Y-%m-%d %H:%i') AS started, message_count
100
- FROM sessions
101
- ORDER BY started_at DESC
111
+ SELECT session_id, user_id, name, source, host, project,
112
+ formatDateTime(created_at, '%Y-%m-%d %H:%i') AS started,
113
+ formatDateTime(coalesce(last_updated_at, created_at), '%Y-%m-%d %H:%i') AS last_active,
114
+ message_count
115
+ FROM sessions FINAL
116
+ ORDER BY coalesce(last_updated_at, created_at) DESC
102
117
  LIMIT 20
103
118
  ```
104
119
 
120
+ `sessions` keeps one row per session and the newest write wins, so read it `FINAL`.
121
+ "Recent" means recently active (`last_updated_at`): a session started last month and
122
+ worked on today is recent. The room has no `started_at`; `created_at` is when the
123
+ session began.
124
+
105
125
  Narrow with `WHERE project = '…'`, `source = 'cursor'`, `host = '…'`, or a date range as
106
126
  asked. In a shared house every member's sessions are here — add `AND user_id = '<name>'`
107
127
  only when the user asks for one person's.
@@ -3,7 +3,7 @@ name: sql
3
3
  description: Run read-only SQL over memhouse conversation memory for numbers rather than transcripts — token spend, cost by model or editor or project, tool-call rankings, activity over time, busiest days, cache-hit ratios, session length distributions, or any custom aggregate across the typed sessions/messages/tool_calls tables. Use when the user wants a figure, a ranking, a trend or a breakdown - "what did I spend on Opus", "which tools do I use most", "how many sessions per project", "my busiest week", "compare editors". For finding or answering FROM past conversations, use /mem:recall instead. Works over your own house, or a housemate's when they name it.
4
4
  user-invocable: true
5
5
  argument-hint: "<the question, or the SQL>"
6
- allowed-tools: Bash
6
+ allowed-tools: Bash(memhouse sessions-query *), Bash(memhouse stats *), Bash(memhouse status *), Bash(memhouse whoami *)
7
7
  ---
8
8
 
9
9
  # /mem:sql — analytics over the rooms
@@ -11,7 +11,7 @@ allowed-tools: Bash
11
11
  For **numbers**. When the answer is a passage from a past conversation, that is
12
12
  `/mem:recall` — this skill counts, groups and ranks.
13
13
 
14
- **Read `../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
14
+ **Read `../../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
15
15
  filter in particular is not optional here: this is the skill most likely to produce a
16
16
  figure someone acts on, and omitting it over-counted a real house by **34%**.
17
17
 
package/memhouse/flags.js CHANGED
@@ -75,4 +75,31 @@ function allowedFlags(cmd) {
75
75
  return new Set([...GLOBAL_FLAGS, ...known]);
76
76
  }
77
77
 
78
- module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, unknownFlags, allowedFlags, suggestFlag, editDistance };
78
+ // Options that never take a value. The parser used to read `--x y` as x = "y" for every
79
+ // option, so a switch swallowed the word after it: `update --no-install yes` set
80
+ // no-install to "yes" (not true) and ran the npm install it was asked to skip, and
81
+ // `share --revoke bob` revoked nobody. A switch is true when present, and the next word
82
+ // stays a positional.
83
+ const BOOLEAN_FLAGS = new Set([
84
+ 'json', 'yes', 'help', 'version', 'full', 'check', 'migrate', 'no-install', 'all-origins',
85
+ 'dry-run', 'print-sql', 'no-ship', 'keep-admin', 'ensure-schema', 'rotate-password', 'force',
86
+ 'adopt', 'allow-local', 'local', 'down', 'credentials', 'full-removal', 'insecure-native',
87
+ 'keep-shipper', 'guides', 'revoke', 'list', 'admin', 'install', 'all',
88
+ ]);
89
+
90
+ /** argv (after the command) -> { flags, positional }. */
91
+ function parseArgv(rest) {
92
+ const flags = {}; const positional = [];
93
+ for (let i = 0; i < rest.length; i++) {
94
+ const a = rest[i];
95
+ if (a.startsWith('--')) {
96
+ const key = a.slice(2);
97
+ const next = rest[i + 1];
98
+ if (!BOOLEAN_FLAGS.has(key) && next !== undefined && !next.startsWith('--')) { flags[key] = next; i++; }
99
+ else flags[key] = true;
100
+ } else positional.push(a);
101
+ }
102
+ return { flags, positional };
103
+ }
104
+
105
+ module.exports = { GLOBAL_FLAGS, COMMAND_FLAGS, BOOLEAN_FLAGS, parseArgv, unknownFlags, allowedFlags, suggestFlag, editDistance };
package/memhouse/host.js CHANGED
@@ -14,13 +14,18 @@
14
14
  // * One machine splits. Rename the box and every row shipped afterwards lands under a
15
15
  // new host, so the machine's own history appears to end and a stranger's to begin.
16
16
  //
17
- // A random fingerprint, written once and kept, fixes both: nothing derives it, so nothing
18
- // can collide with it, and nothing about the machine changing can move it.
17
+ // So the fingerprint comes from the OS's own machine id (IOPlatformUUID, /etc/machine-id,
18
+ // MachineGuid), hashed with a fixed salt (machineFingerprint), never from the hostname: two
19
+ // machines cannot share it and a rename cannot move it. Since 0.18.6. Before that it was
20
+ // random, which fixed both of the above but split a machine whenever MEMHOUSE_HOME was
21
+ // wiped: a re-imaged laptop came back as a stranger. Where no machine id can be read it is
22
+ // still random.
19
23
  //
20
- // It lives in MEMHOUSE_HOME and is deliberately NOT removed by a plain `memhouse
21
- // uninstall` — reinstalling on the same machine should continue that machine's history,
22
- // not start a second one beside it. `uninstall --full-removal` drops it, and then the
23
- // machine genuinely is a new host next time; that is the honest meaning of full removal.
24
+ // It is written once to MEMHOUSE_HOME/host.json and an existing file always wins, so no
25
+ // install changes identity under it. `uninstall --full-removal` drops the file, and the
26
+ // next install derives the same fingerprint again from the same machine. The id also
27
+ // carries the hostname the machine had at first install, so it comes back the same unless
28
+ // the machine was renamed in between, or has no readable machine id.
24
29
 
25
30
  const fs = require('fs');
26
31
  const os = require('os');
@@ -1,12 +1,12 @@
1
1
  # The house — a database, and the rooms in it
2
2
 
3
3
  **A house is a ClickHouse database.** Any database, named whatever its people name it —
4
- `polat`, `team_a`, even `default`. Its rooms are tables: `sessions`, `messages`,
4
+ `sam`, `team_a`, even `default`. Its rooms are tables: `sessions`, `messages`,
5
5
  `tool_calls`. No views, no Merge rooms, no settings profile.
6
6
 
7
- **Every member's rooms are named for them, and one grant covers them.** `mem.polat_*`
8
- for polat, `mem.alice_*` for alice, in the same database — `mem` unless somebody has a
9
- reason. The grant is `GRANT … ON mem.polat_* TO polat WITH GRANT OPTION`: it lets the
7
+ **Every member's rooms are named for them, and one grant covers them.** `mem.sam_*`
8
+ for sam, `mem.alice_*` for alice, in the same database — `mem` unless somebody has a
9
+ reason. The grant is `GRANT … ON mem.sam_* TO sam WITH GRANT OPTION`: it lets the
10
10
  member create and rebuild their own rooms, share them, and reach nothing else. Nobody is
11
11
  granted the database itself. A house with one member is a house of one, not a different
12
12
  kind of house — a colleague joins as one more member and nothing about the first changes.
@@ -17,9 +17,9 @@ Two columns say where every row came from:
17
17
  | Column | Meaning | Comes from |
18
18
  |---|---|---|
19
19
  | `user_id` | who wrote it | `String MATERIALIZED currentUser()` — stamped by the server, unforgeable by clients. `async_insert = 0 CONST` is pinned on each user (`ADD SETTING`, which merges — a bare `SETTINGS` clause replaces the user's whole list) because an async flush stores the stamp as the empty string (measured on 25.11.9.34) |
20
- | `host` | which machine | the install's fingerprint (`../host.js`) — random, minted once, so two machines with the same hostname cannot collide and renaming one cannot split its history |
20
+ | `host` | which machine | the machine's fingerprint (`../host.js`) — derived from the OS machine id (random only where none can be read), so two machines with the same hostname cannot collide and renaming one cannot split its history |
21
21
 
22
- `WHERE user_id = 'alice'` is one person. `WHERE host = 'macbook-4127a95b'` is one
22
+ `WHERE user_id = 'alice'` is one person. `WHERE host = 'my-laptop-1f2e3d4c'` is one
23
23
  machine. No filter is the whole house — which is what a team dashboard wants.
24
24
 
25
25
  ## Joining a house
@@ -8,19 +8,19 @@
8
8
  // clients (async_insert is pinned to 0
9
9
  // on the user so the stamp cannot be
10
10
  // skipped)
11
- // host LowCardinality(String) — the machine's fingerprint, minted once
11
+ // host LowCardinality(String) — the machine's fingerprint, derived from its OS machine id
12
12
  // per install (../host.js)
13
13
  //
14
14
  // That pair IS the provenance model.
15
15
  //
16
16
  // ONE LAYOUT. A house is a database — `mem` unless somebody has a reason — and every
17
- // member's rooms in it carry the member's name: `mem.alice_messages`, `mem.polat_messages`.
17
+ // member's rooms in it carry the member's name: `mem.alice_messages`, `mem.sam_messages`.
18
18
  // A member holds one grant, on the wildcard `mem.<name>_*`, and nothing else in the
19
19
  // database. That is the whole access model, and roomNames() below is the only place a
20
20
  // table name is produced.
21
21
  //
22
- // alone on a laptop mem.polat_* a house of one
23
- // a team on one server mem.polat_*, mem.alice_*
22
+ // alone on a laptop mem.sam_* a house of one
23
+ // a team on one server mem.sam_*, mem.alice_*
24
24
  // an agency on a kernel the same, in the kernel's `mem`
25
25
  //
26
26
  // Nobody ever holds ALL ON <db>.*. That grant is dynamic — it covers rooms created later —
@@ -412,6 +412,31 @@ function statRefreshStatements(rooms, at = 'now64(3)') {
412
412
  `INSERT INTO ${names[k]} SELECT ${at} AS refreshed_at, * FROM (${bodies[k].body})`);
413
413
  }
414
414
 
415
+ /**
416
+ * When the shipper refreshes the stat tables (G11). After every pass that shipped something,
417
+ * as before — and ALSO when the newest generation is older than STATS_MAX_AGE_MS, even if
418
+ * nothing shipped. The tables carry a one-day TTL, so a house whose machines went quiet for
419
+ * a day used to lose every generation and the dashboard went empty while the rooms still
420
+ * held everything. `ageMs` null means "could not tell" (no table, no rights): do not refresh
421
+ * on age then; the read layer is already on the inline rollup.
422
+ */
423
+ const STATS_MAX_AGE_MS = 60 * 60 * 1000;
424
+ function statsNeedRefresh({ shipped, ageMs, maxAgeMs = STATS_MAX_AGE_MS }) {
425
+ if (shipped > 0) return true;
426
+ return ageMs !== null && ageMs !== undefined && ageMs > maxAgeMs;
427
+ }
428
+
429
+ /**
430
+ * How long a reader may keep its resolved rooms (G11). resolveRooms() decides once whether
431
+ * the stat tables are usable or the inline rollup must answer; a long-running dashboard that
432
+ * never asked again kept reading a stat table after its TTL had emptied it. Re-resolving
433
+ * every few minutes costs one small round trip and lets it follow the house either way.
434
+ */
435
+ const ROOMS_TTL_MS = 5 * 60 * 1000;
436
+ function roomsCacheStale(resolvedAt, now = Date.now(), ttlMs = ROOMS_TTL_MS) {
437
+ return !resolvedAt || now - resolvedAt > ttlMs;
438
+ }
439
+
415
440
  /** The read-side source for one stat table: its newest generation, and nothing else. */
416
441
  function latestGeneration(table) {
417
442
  return `(SELECT * FROM ${table} WHERE refreshed_at = (SELECT max(refreshed_at) FROM ${table}))`;
@@ -435,7 +460,7 @@ const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
435
460
  * The table a room type lands in. THE naming rule, and the only copy of it.
436
461
  *
437
462
  * ONE LAYOUT. Every member's rooms carry the member's own name: `mem.alice_messages`,
438
- * and `mem.polat_messages` even when polat is the only one in there. A house with one
463
+ * and `mem.sam_messages` even when sam is the only one in there. A house with one
439
464
  * member is a house of one, not a different kind of house — so there is no second case
440
465
  * here, no prefix to configure, and nothing for an invite file to carry.
441
466
  *
@@ -598,4 +623,5 @@ module.exports = {
598
623
  sessionsRollup, currentParse, createStatement, roomNames, currentUser, resolveRooms,
599
624
  SESSION_STATS, MODEL_STATS, TOOL_STATS, statTableNames, hasSessionStats, statTables,
600
625
  statCreateStatements, statRefreshStatements, latestGeneration,
626
+ STATS_MAX_AGE_MS, statsNeedRefresh, ROOMS_TTL_MS, roomsCacheStale,
601
627
  };
@@ -0,0 +1,37 @@
1
+ // Where `invite` may send a credential — and the one place that decides it (G4).
2
+ //
3
+ // `--url` is the address the INVITEE will reach the house at. It goes into the invite file;
4
+ // it is typed on a command line, so it can be a typo, a tunnel, or somebody else's server.
5
+ // A credential this install KEEPS — the member's own, or an admin saved with --keep-admin —
6
+ // belongs to the house it was saved for, and is only ever sent there. Before 0.18.11 the
7
+ // stored credential was sent to `--url`: resolveConfig() lets the flag outrank the env file,
8
+ // so `cfg.url` WAS `--url`, and the reachability probe, the grants read and the provisioning
9
+ // all authenticated against whatever `--url` named.
10
+ //
11
+ // A credential given for THIS command — `--admin-user`, or an exported MEMHOUSE_ADMIN_USER,
12
+ // with a password from a file, stdin, the environment or a prompt — was chosen by the person
13
+ // who also typed `--url`, so it provisions there, as it always has.
14
+ //
15
+ // Pure, so it is unit tested.
16
+
17
+ /**
18
+ * @param {object} o
19
+ * @param {string} o.url the invitee-facing --url
20
+ * @param {string|null} o.storedUrl the house this install is configured for (env or env file)
21
+ * @param {'flag'|'env'|'file'|'member'} o.adminUserSource
22
+ * where the credential's USER came from: --admin-user, an exported MEMHOUSE_ADMIN_USER,
23
+ * the env file's MEMHOUSE_ADMIN_USER, or (no admin at all) the member credential
24
+ * @param {string|null} [o.passwordSource] resolveAdminPassword's `source` ('stored' = the env file)
25
+ * @returns {{ url: string, stored: boolean } | { error: string }}
26
+ */
27
+ function provisionTarget({ url, storedUrl, adminUserSource, passwordSource = null }) {
28
+ const stored = adminUserSource === 'file' || adminUserSource === 'member' || passwordSource === 'stored';
29
+ if (!stored) return { url, stored: false };
30
+ if (!storedUrl) {
31
+ return { error: 'this install keeps no house URL to send its stored credential to — '
32
+ + 'give the admin for this command instead (--admin-user, password from MEMHOUSE_ADMIN_PASSWORD or --admin-password-file)' };
33
+ }
34
+ return { url: storedUrl, stored: true };
35
+ }
36
+
37
+ module.exports = { provisionTarget };
@@ -3,7 +3,7 @@
3
3
  // `npm i -g memhouse@latest` replaces the files under the global prefix and does not touch
4
4
  // the running processes, so the shipper keeps parsing with the old adapters and the
5
5
  // dashboard keeps serving the old bundle. Nothing reports it: `status` prints the version
6
- // of the CLI you just typed, not the version the daemon is running. On macminim a shipper
6
+ // of the CLI you just typed, not the version the daemon is running. On one machine a shipper
7
7
  // ran 1d16h out of a directory that had been MOVED — the fix was live, the machine was not.
8
8
  //
9
9
  // npm's own `postinstall` is the obvious hook and the wrong one. npm >= 12 blocks install
@@ -18,7 +18,7 @@
18
18
 
19
19
  const { createClient, ClickHouseLogLevel } = require('@clickhouse/client');
20
20
  const { calculateCost, normalizeModelName } = require('../../pricing');
21
- const { resolveRooms, READ_SETTINGS } = require('../house/house');
21
+ const { resolveRooms, READ_SETTINGS, roomsCacheStale } = require('../house/house');
22
22
 
23
23
  const config = {
24
24
  url: process.env.MEMHOUSE_URL || 'http://localhost:8123',
@@ -90,11 +90,16 @@ const TZ = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
90
90
  // braces, no type.
91
91
  const ROOM_TOKEN = /\{\{([a-z_]+)\}\}/g;
92
92
  let roomsPromise = null;
93
+ let roomsAt = 0;
93
94
 
94
95
  function rooms() {
95
- // Resolved once per process. resolveRooms asks the server for currentUser(), so it is a
96
- // round trip, and every dashboard request would otherwise pay for it.
97
- if (!roomsPromise) {
96
+ // Cached, because resolveRooms asks the server for currentUser() and which stat tables
97
+ // are usable, and every dashboard request would otherwise pay that round trip. But not
98
+ // for the life of the process: whether the stat tables can answer changes — their TTL
99
+ // empties them on a quiet house, a refresh fills them again — and a dashboard that
100
+ // decided once kept reading an emptied table and showed nothing (G11).
101
+ if (!roomsPromise || roomsCacheStale(roomsAt)) {
102
+ roomsAt = Date.now();
98
103
  roomsPromise = resolveRooms(getClient()).catch((e) => { roomsPromise = null; throw e; });
99
104
  }
100
105
  return roomsPromise;
@@ -473,8 +478,8 @@ async function getChats(opts = {}) {
473
478
  // per call, three endpoints calling it — and getChats used ~4% of what it asked for.
474
479
  // In CHUNKS. The scope travels as `{scopeIds:Array(String)}` query parameters, and the
475
480
  // driver puts query parameters in the REQUEST URI — so a page of N sessions builds a URI
476
- // of roughly 60 bytes per id, twice (ids and users). Zeo is reached through a Cloudflare
477
- // tunnel, which rejects a URI far below ClickHouse's own 1 MiB `http_max_uri_size`: at
481
+ // of roughly 60 bytes per id, twice (ids and users). A house reached through a Cloudflare
482
+ // tunnel sits behind a proxy that rejects a URI far below ClickHouse's own 1 MiB `http_max_uri_size`: at
478
483
  // 925 sessions the request came back 500 with an EMPTY message (the driver reports a
479
484
  // transport-level refusal as `new Error('')`), so `/api/chats?limit=1000` — exactly what
480
485
  // the Sessions page asks for — returned 500 and the page rendered blank. 900 worked,