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/editors/claude.js CHANGED
@@ -74,13 +74,18 @@ function getChats() {
74
74
  } catch { /* no index */ }
75
75
 
76
76
  // Scan all .jsonl files on disk (some may not be in the index)
77
- let files;
78
- try { files = fs.readdirSync(dir).filter(f => f.endsWith('.jsonl')); } catch { continue; }
77
+ let names;
78
+ try { names = fs.readdirSync(dir); } catch { continue; }
79
+ const files = names.filter(f => f.endsWith('.jsonl'));
80
+ // A session with subagents also has a directory named for it; one without has none, so
81
+ // this listing is how the mtime check below stays free for the sessions it cannot help.
82
+ const sessionDirs = new Set(names.filter(f => !f.endsWith('.jsonl')));
79
83
 
80
84
  for (const file of files) {
81
85
  const sessionId = file.replace('.jsonl', '');
82
86
  const fullPath = path.join(dir, file);
83
87
  const entry = indexed.get(sessionId);
88
+ const subTouched = sessionDirs.has(sessionId) ? newestSubagentMtime(path.join(dir, sessionId, 'subagents')) : 0;
84
89
 
85
90
  if (entry) {
86
91
  // Use index metadata
@@ -89,7 +94,7 @@ function getChats() {
89
94
  composerId: sessionId,
90
95
  name: cleanPrompt(entry.firstPrompt),
91
96
  createdAt: entry.created ? new Date(entry.created).getTime() : null,
92
- lastUpdatedAt: entry.modified ? new Date(entry.modified).getTime() : null,
97
+ lastUpdatedAt: latest(entry.modified ? new Date(entry.modified).getTime() : null, subTouched),
93
98
  mode: 'claude',
94
99
  folder: entry.projectPath || decodedFolder,
95
100
  encrypted: false,
@@ -107,7 +112,7 @@ function getChats() {
107
112
  composerId: sessionId,
108
113
  name: meta.firstPrompt ? cleanPrompt(meta.firstPrompt) : null,
109
114
  createdAt: meta.timestamp || stat.birthtime.getTime(),
110
- lastUpdatedAt: stat.mtime.getTime(),
115
+ lastUpdatedAt: latest(stat.mtime.getTime(), subTouched),
111
116
  mode: 'claude',
112
117
  folder: meta.cwd || decodedFolder,
113
118
  encrypted: false,
@@ -195,7 +200,7 @@ function cleanPrompt(prompt) {
195
200
  // Parse one Claude session .jsonl into the adapter's message shape. When
196
201
  // `isSubagent` is set, each message is tagged `[subagent]` so folded subagent turns
197
202
  // are clearly attributed in the transcript.
198
- function parseSessionFile(filePath, isSubagent, agent = null) {
203
+ function parseSessionFile(filePath, isSubagent, agent = null, info = null) {
199
204
  const messages = [];
200
205
  let lines;
201
206
  try { lines = fs.readFileSync(filePath, 'utf-8').split('\n').filter(Boolean); } catch { return messages; }
@@ -205,7 +210,9 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
205
210
  // cited, or told apart from a sibling's. An agent looking for the fork as its own session
206
211
  // concluded it was never shipped. Every turn now carries the agent's id, the description
207
212
  // the parent gave it, and its position within that subagent — in `extra`, which is not
208
- // part of the line hash, so nothing already shipped is re-shipped for this.
213
+ // part of the line hash. The position is not only a label any more: the shipper numbers
214
+ // a subagent's rows from it (seq = block + turn, see subagentSeq in ship.js), so `turn`
215
+ // must count exactly the turns this parse keeps, in order.
209
216
  let turn = 0;
210
217
  const stamp = (m) => { if (agent) { m._agent = { ...agent, turn: turn++ }; } return m; };
211
218
 
@@ -228,6 +235,7 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
228
235
  // time simply omit it.
229
236
  const _ts = obj.timestamp ? Date.parse(obj.timestamp) : undefined;
230
237
  const at = Number.isFinite(_ts) ? _ts : undefined;
238
+ if (info && info.firstTs === undefined && at !== undefined) info.firstTs = at;
231
239
 
232
240
  if (obj.type === 'user' && obj.message) {
233
241
  const content = extractContent(obj.message.content);
@@ -249,6 +257,10 @@ function parseSessionFile(filePath, isSubagent, agent = null) {
249
257
  return messages;
250
258
  }
251
259
 
260
+ // How long a subagent transcript without a single timestamped line may hold back its
261
+ // session (see getMessages). A real one gets its first line within seconds.
262
+ const SUBAGENT_STAMP_GRACE_MS = 10 * 60 * 1000;
263
+
252
264
  function getMessages(chat) {
253
265
  const filePath = chat._fullPath;
254
266
  if (!filePath || !fs.existsSync(filePath)) return [];
@@ -262,30 +274,76 @@ function getMessages(chat) {
262
274
  const subagentsDir = path.join(filePath.replace(/\.jsonl$/, ''), 'subagents');
263
275
  const subFiles = subagentFiles(subagentsDir);
264
276
  const named = subFiles.length ? subagentNames(filePath) : new Map();
277
+ const folded = [];
278
+ let unstamped = null;
265
279
  for (const { file, workflow } of subFiles) {
266
280
  const id = path.basename(file).replace(/^agent-/, '').replace(/\.jsonl$/, '');
267
281
  const meta = named.get(id) || {};
268
282
  const agent = { id, description: meta.description || '', type: meta.type || (workflow ? 'workflow-subagent' : ''), file: path.basename(file) };
269
283
  if (workflow) agent.workflow = workflow;
270
- const turns = parseSessionFile(file, true, agent);
284
+ const info = {};
285
+ const turns = parseSessionFile(file, true, agent, info);
271
286
  // A fork the parent never named (a Workflow-run agent, or a parent from before the
272
287
  // ids were reported) is described by its own first prompt — what it was told to do.
273
288
  if (!agent.description) {
274
289
  const first = turns.find((m) => m.role === 'user');
275
290
  if (first) { const d = String(first.content).replace(/^\[subagent\] /, '').replace(/\s+/g, ' ').trim().slice(0, 160); for (const m of turns) m._agent.description = d; }
276
291
  }
277
- messages.push(...turns);
292
+ folded.push({ key: path.relative(subagentsDir, file), firstTs: info.firstTs, turns });
293
+ // A subagent caught before its first line is written has no start time, so it ranks
294
+ // last — and when its first line lands with an EARLIER timestamp than a sibling's, it
295
+ // moves, renumbers every sibling after it, and the session forks and re-ships whole
296
+ // (GLM-F1). So a fresh one defers the whole session to the next pass, when it has a
297
+ // time. Bounded by age: a file that never gets a timestamped line stops deferring after
298
+ // SUBAGENT_STAMP_GRACE_MS and ranks last, as before.
299
+ if (info.firstTs === undefined && !unstamped) {
300
+ let age = Infinity;
301
+ try { age = Date.now() - fs.statSync(file).mtimeMs; } catch { /* gone: nothing to wait for */ }
302
+ if (age < SUBAGENT_STAMP_GRACE_MS) unstamped = path.relative(subagentsDir, file);
303
+ }
304
+ }
305
+ // Each subagent gets a SLOT, and the shipper numbers its rows from that slot rather than
306
+ // from where they happen to land in this array (see subagentSeq in ship.js). The slot is
307
+ // the subagent's rank by when it started — the timestamp on its transcript's first line —
308
+ // because that is the one order a new subagent cannot disturb: it started after every
309
+ // subagent already on disk, so it ranks last. The file-name order this used to fold in
310
+ // does not have that property: agent ids are random, so a new fork sorted into the middle
311
+ // and pushed every later subagent's rows down, and a session with subagents re-shipped
312
+ // whole on every pass (one 6,120-line session was stored 60 times in a day). A file with
313
+ // no timestamp yet ranks after every one that has one; the path breaks ties.
314
+ folded.sort((a, b) => (a.firstTs === undefined) - (b.firstTs === undefined)
315
+ || (a.firstTs || 0) - (b.firstTs || 0) || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
316
+ folded.forEach(({ turns }, slot) => { for (const m of turns) m._agent.slot = slot; messages.push(...turns); });
317
+ if (unstamped) {
318
+ Object.defineProperty(messages, '_defer', { value: `subagent ${unstamped} has no timestamped line yet`, enumerable: false });
278
319
  }
279
320
 
280
321
  return messages;
281
322
  }
282
323
 
324
+ /**
325
+ * When a session was last written, counting its subagents. The shipper's incremental skip
326
+ * compares `lastUpdatedAt` with what the house recorded, and a subagent writes only its own
327
+ * file: a background agent that runs on after its parent goes quiet left the parent's mtime
328
+ * (and the index's `modified`) where they were, so its turns shipped at the parent's next
329
+ * write, or never. Stat calls only, and only for a session that has a directory.
330
+ */
331
+ function newestSubagentMtime(subagentsDir) {
332
+ let newest = 0;
333
+ for (const { file } of subagentFiles(subagentsDir)) {
334
+ try { newest = Math.max(newest, fs.statSync(file).mtimeMs); } catch { /* vanished mid-pass */ }
335
+ }
336
+ return Math.floor(newest);
337
+ }
338
+ function latest(a, b) { return b && (!a || b > a) ? b : a; }
339
+
283
340
  /**
284
341
  * Every subagent transcript under a session, wherever Claude Code put it:
285
342
  * <session>/subagents/agent-<id>.jsonl — Agent tool forks
286
343
  * <session>/subagents/workflows/wf_<id>/agent-<id>.jsonl — Workflow-run agents
287
- * journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted for a stable
288
- * fold order. On real data the workflow layer held 100 of 165 forks and shipped none.
344
+ * journal.jsonl and *.meta.json are bookkeeping, not transcripts. Sorted so the listing is
345
+ * deterministic; the fold ORDER is set by start time in getMessages, not by this.
346
+ * On real data the workflow layer held 100 of 165 forks and shipped none.
289
347
  */
290
348
  function subagentFiles(subagentsDir) {
291
349
  const out = [];
@@ -134,8 +134,15 @@ Config file: `~/.memhouse/config.json` (`hiddenProjects`, future prefs).
134
134
  - Per-message `ts`: interpolated across the session's `[createdAt, lastUpdatedAt]`
135
135
  (adapters don't expose per-message timestamps; monotonic by `seq`; documented
136
136
  approximation, refine per-adapter later).
137
- - `seq` is the message index within the session (0-based). `tool_calls.idx` is the
138
- call index within the session. `is_subagent` = message content begins with
137
+ - `seq` is the message's place in the session. The session's own turns are dense from 0;
138
+ a folded subagent's turns are numbered from their own block,
139
+ `1,000,000,000 + slot × 100,000 + turn`, where `slot` is the subagent's rank by start
140
+ time. `tool_calls.idx` is laid out the same way (the session's own calls dense from 0,
141
+ a subagent's counted within its block). A row's key therefore never depends on how long
142
+ the parent or any other subagent is, which is what lets a session with subagents ship
143
+ tail-only; positional numbering moved every subagent row whenever the parent grew. A
144
+ parse that does not fit the blocks falls back to positional numbering. Readers order by
145
+ `seq` and count with `count()`; nothing may treat `seq` as dense. `is_subagent` = message content begins with
139
146
  `[subagent]` (the fold marker from the Claude adapter).
140
147
  - Incremental shipping: read existing `(session_id, last_updated_at, message_count)`
141
148
  (FINAL) and skip chats that haven't grown/changed; a full re-ship must remain
@@ -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.10",
4
+ "version": "0.18.11",
5
5
  "author": {
6
6
  "name": "Ramazan Polat"
7
7
  },
@@ -101,23 +101,35 @@ session finds nothing — look inside the parent:
101
101
  ```sql
102
102
  -- the subagents a session spawned
103
103
  SELECT toString(extra.agent.id) AS agent, any(toString(extra.agent.description)) AS asked_to, count() AS turns
104
- FROM messages WHERE session_id = '<sid>' AND is_subagent GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
104
+ FROM messages FINAL
105
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND is_subagent
106
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
107
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
108
+ GROUP BY agent -- toString: a JSON path cannot be a GROUP BY key
105
109
  -- one subagent's transcript, in order
106
- SELECT seq, role, substring(text, 1, 2000) FROM messages
107
- WHERE session_id = '<sid>' AND extra.agent.id = '<agent>' ORDER BY seq
110
+ SELECT seq, role, substring(text, 1, 2000) AS text FROM messages FINAL
111
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND toString(extra.agent.id) = '<agent>'
112
+ AND (origin != 'ship' OR epoch = (SELECT max(epoch) FROM messages
113
+ WHERE session_id = '<sid>' AND user_id = '<uid>' AND origin = 'ship'))
114
+ ORDER BY seq
108
115
  ```
109
116
 
117
+ Both carry the current-epoch filter (trap 1 below). A session whose subagents were
118
+ re-parsed holds every parse, so an unfiltered count doubles each subagent's turns, and an
119
+ unfiltered transcript repeats them. `<uid>` is the session's `user_id`, from whatever
120
+ query found it (trap 2).
121
+
110
122
  **`messages`** — one row per turn.
111
123
 
112
124
  | column | meaning |
113
125
  |---|---|
114
126
  | `session_id` | `<source>:<adapter-local id>`, e.g. `claude-code:6b1f…` |
115
- | `seq` | position in the session, from 0 |
127
+ | `seq` | order within the session. The session's own turns are `0, 1, 2, …`; a subagent's turns sit in their own block, `1,000,000,000 + slot × 100,000 + turn` (slot = the subagent's rank by start time), so `seq` is **sparse** — count rows with `count()`, never `max(seq) + 1` |
116
128
  | `source` | editor: `claude-code`, `codex`, `cursor`, … |
117
- | `host` | the machine, `<hostname>-<8 hex>` — random per install, not derived |
129
+ | `host` | the machine, `<hostname>-<8 hex>` — the 8 hex are derived from the OS machine id (random only where none can be read), so a reinstall keeps it and same-named machines differ |
118
130
  | `ts` | when the turn happened (`DateTime64(3,'UTC')`) |
119
131
  | `role` | `user`, `assistant`, `system` |
120
- | `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns. Subagents are never separate sessions. |
132
+ | `is_subagent` | 1 for a turn that a Claude Code **subagent** (a fork) produced; folded into the parent session after the parent's own turns, each subagent's turns contiguous and the subagents in the order they started. Subagents are never separate sessions. |
121
133
  | `extra` | JSON. For a subagent turn: `extra.agent.id`, `extra.agent.description` (what the parent asked it to do, else the fork's own first prompt), `extra.agent.type` (`general-purpose`, `workflow-subagent`, …), `extra.agent.turn` (position within that subagent), and `extra.agent.workflow` (`wf_…`) when a Workflow run spawned it. Empty `{}` otherwise, and on rows shipped before 0.18.3. |
122
134
  | `model`, `input_tokens`, `output_tokens`, `cache_read_tokens`, `cache_write_tokens` | usage; **empty for several adapters** — see below |
123
135
  | `text` | the turn, **truncated at 50,000 chars** |
@@ -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.
@@ -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
@@ -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
@@ -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.
@@ -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');
@@ -17,7 +17,7 @@ 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
22
  `WHERE user_id = 'alice'` is one person. `WHERE host = 'macbook-4127a95b'` is one
23
23
  machine. No filter is the whole house — which is what a team dashboard wants.
@@ -8,7 +8,7 @@
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.
@@ -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}))`;
@@ -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 };
@@ -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;