memhouse 0.18.12 → 0.20.1

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 (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
@@ -0,0 +1,863 @@
1
+ // `memhouse q` and the CLI's other schema-3 reads — the named queries of
2
+ // docs/design/source-identity.md §6 (memhouse/house/queries-v3.js), reachable from a shell. The
3
+ // tables are harness-sessions-sql's profile (sessions, session_parts, session_usage, calls).
4
+ //
5
+ // This is what the /mem skills call on a schema-3 house instead of hand-written SQL over the
6
+ // 0.18 rooms: `memhouse q search …`, `memhouse q window …`, `memhouse q spend …`. Each name
7
+ // maps to one or more builders in queries-v3.js, so the FINAL / is_deleted = 0 AND removed = 0 / skip-index
8
+ // rules those builders state hold here too — nothing in this file writes its own FROM over
9
+ // parts or calls except where noted (the window's pivot lookup and the bounded doctor checks),
10
+ // and those follow the same rules.
11
+ //
12
+ // Three layers, so the unit tests can hold each one without a server:
13
+ // parseQ(positional, flags) -> { name, ref?, terms?, opts } pure; throws QueryInputError
14
+ // runQ(exec, rooms, parsed) -> a JSON-able result takes an injected exec
15
+ // renderQ(name, result) -> lines for a human pure
16
+ // plus the transport (httpTransport — node's fetch, no node_modules, and NEVER `final=1`),
17
+ // the room-name rewrite behind `memhouse room-sql`, and the schema switch the skills read.
18
+
19
+ 'use strict';
20
+
21
+ const Q = require('./queries-v3');
22
+ const HA = require('./host-alias');
23
+
24
+ const { QueryInputError } = Q;
25
+
26
+ // ── the schema switch ─────────────────────────────────────────────────────────────
27
+
28
+ /**
29
+ * Which schema this install reads: MEMHOUSE_SCHEMA from the process env, else the env file,
30
+ * else 2. Only '3' selects the conversation layer; anything else is the 0.18 rooms.
31
+ * @returns {{ schema: 2|3, source: 'flag'|'env'|'env-file'|'default', raw: string|null }}
32
+ */
33
+ function schemaOf({ flag = undefined, env = process.env, file = {} } = {}) {
34
+ let raw = null; let source = 'default';
35
+ if (flag !== undefined && flag !== null && flag !== true && flag !== '') { raw = String(flag); source = 'flag'; }
36
+ else if (env.MEMHOUSE_SCHEMA !== undefined && env.MEMHOUSE_SCHEMA !== '') { raw = String(env.MEMHOUSE_SCHEMA); source = 'env'; }
37
+ else if (file.MEMHOUSE_SCHEMA !== undefined && file.MEMHOUSE_SCHEMA !== '') { raw = String(file.MEMHOUSE_SCHEMA); source = 'env-file'; }
38
+ return { schema: raw === '3' ? 3 : 2, source, raw };
39
+ }
40
+
41
+ // ── transport ─────────────────────────────────────────────────────────────────────
42
+
43
+ // Query parameters in the text form @clickhouse/client sends (same as misc/v3-read-check.js).
44
+ function fmtInner(v) {
45
+ if (Array.isArray(v)) return `[${v.map(fmtInner).join(',')}]`;
46
+ if (typeof v === 'string') return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
47
+ if (typeof v === 'boolean') return v ? '1' : '0';
48
+ if (v === null || v === undefined) return 'NULL';
49
+ return String(v);
50
+ }
51
+ function fmtTop(v) {
52
+ if (Array.isArray(v)) return fmtInner(v);
53
+ if (typeof v === 'string') return v.replace(/\\/g, '\\\\').replace(/\t/g, '\\t').replace(/\n/g, '\\n');
54
+ if (typeof v === 'boolean') return v ? '1' : '0';
55
+ return String(v);
56
+ }
57
+
58
+ /**
59
+ * HTTP to ClickHouse with node's fetch. Reads only: every request carries `readonly=2` (reads
60
+ * and per-query settings, no writes, no DDL) unless the caller is the schema step, which
61
+ * passes `write: true`. It never sets `final` — FINAL is per table, in the SQL (queries-v3.js).
62
+ *
63
+ * @returns {{ exec(spec), q: {sql, rows}, raw(text, opts), close() }}
64
+ */
65
+ function httpTransport({ url, user, password = '', db = 'mem', timeout = 60000, fetchImpl = null } = {}) {
66
+ if (!url) throw new Error('no house URL');
67
+ const base = String(url).replace(/\/+$/, '');
68
+ const doFetch = fetchImpl || fetch;
69
+ async function post(body, { params = {}, settings = {}, database = db, write = false, ms = timeout } = {}) {
70
+ const u = new URL(`${base}/`);
71
+ if (database) u.searchParams.set('database', database);
72
+ if (!write) u.searchParams.set('readonly', '2');
73
+ u.searchParams.set('output_format_json_quote_64bit_integers', '0');
74
+ for (const [k, v] of Object.entries(settings || {})) u.searchParams.set(k, String(v));
75
+ for (const [k, v] of Object.entries(params || {})) u.searchParams.set(`param_${k}`, fmtTop(v));
76
+ let res;
77
+ try {
78
+ res = await doFetch(u, {
79
+ method: 'POST', body,
80
+ headers: { 'X-ClickHouse-User': user || 'default', 'X-ClickHouse-Key': password || '' },
81
+ signal: AbortSignal.timeout(ms),
82
+ });
83
+ } catch (e) {
84
+ const c = e && e.cause && (e.cause.code || e.cause.message);
85
+ throw new Error(`cannot reach ${base}: ${c || (e && e.name === 'TimeoutError' ? 'timed out' : e.message)}`);
86
+ }
87
+ const text = await res.text();
88
+ if (!res.ok) {
89
+ const err = new Error(text.trim().split('\n')[0].slice(0, 600) || `HTTP ${res.status}`); err.http = res.status;
90
+ // ClickHouse's exception code, as bin/memhouse.js ch() carries it (schema-choice.js isAuthFailure).
91
+ const code = Number(res.headers && res.headers.get && res.headers.get('x-clickhouse-exception-code'));
92
+ if (code) err.chCode = code;
93
+ throw err;
94
+ }
95
+ return text;
96
+ }
97
+ const lines = (t) => t.split('\n').filter(Boolean).map((l) => JSON.parse(l));
98
+ return {
99
+ exec: Q.withFallback(async (spec) => (spec.empty ? [] : lines(await post(`${spec.sql}\nFORMAT JSONEachRow`, { params: spec.params, settings: spec.settings })))),
100
+ // The transport-free interface of memhouse/house/{schema,reconcile}-v3.js. DDL needs write.
101
+ q: {
102
+ sql: (text, settings) => post(text, { settings: dropFinal(settings), write: true }),
103
+ rows: async (text, settings) => lines(await post(`${text}\nFORMAT JSONEachRow`, { settings: dropFinal(settings) })),
104
+ },
105
+ raw: (text, opts = {}) => post(text, opts),
106
+ };
107
+ }
108
+ // The schema/reconcile modules pass `final: 0` defensively; a readonly=2 request may change it,
109
+ // but there is no reason to send it at all.
110
+ function dropFinal(s) { if (!s) return {}; const o = { ...s }; delete o.final; delete o.join_use_nulls; return o; }
111
+
112
+ // ── small parsers ─────────────────────────────────────────────────────────────────
113
+
114
+ const DAY = 86400000;
115
+
116
+ /**
117
+ * A point in time from the command line, as epoch ms. Accepts 'YYYY-MM-DD' (UTC midnight; as an
118
+ * upper bound, the END of that day), an ISO datetime, epoch seconds or ms, 'today', 'yesterday',
119
+ * and relative spans back from now: '30m', '24h', '7d', '2w', '3mo', '1y'.
120
+ */
121
+ function parseWhen(s, { end = false, now = Date.now() } = {}) {
122
+ if (s === undefined || s === null || s === true || s === '') return null;
123
+ const v = String(s).trim().toLowerCase();
124
+ if (v === 'now') return now;
125
+ if (v === 'today' || v === 'yesterday') {
126
+ const d = new Date(now); d.setUTCHours(0, 0, 0, 0);
127
+ const start = d.getTime() - (v === 'yesterday' ? DAY : 0);
128
+ return end ? start + DAY - 1 : start;
129
+ }
130
+ let m = /^(\d+)\s*(m|min|h|d|w|mo|y)$/.exec(v);
131
+ if (m) {
132
+ const n = Number(m[1]);
133
+ const unit = { m: 60000, min: 60000, h: 3600000, d: DAY, w: 7 * DAY, mo: 30 * DAY, y: 365 * DAY }[m[2]];
134
+ return now - n * unit;
135
+ }
136
+ if (/^\d{4}-\d{2}-\d{2}$/.test(v)) {
137
+ const t = Date.parse(`${v}T00:00:00Z`);
138
+ if (Number.isFinite(t)) return end ? t + DAY - 1 : t;
139
+ }
140
+ if (/^\d{9,13}$/.test(v)) { const n = Number(v); return n < 1e11 ? n * 1000 : n; }
141
+ const t = Date.parse(String(s).trim());
142
+ if (Number.isFinite(t)) return t;
143
+ throw new QueryInputError(`'${s}' is not a time — use YYYY-MM-DD, an ISO time, or a span like 7d`);
144
+ }
145
+
146
+ function intFlag(v, name, dflt, min = 0, max = 1e9) {
147
+ if (v === undefined || v === null || v === true) return dflt;
148
+ const n = Number(v);
149
+ if (!Number.isFinite(n) || Math.trunc(n) !== n || n < min || n > max) throw new QueryInputError(`--${name} must be an integer from ${min} to ${max}`);
150
+ return n;
151
+ }
152
+ function listFlag(v) { return v === undefined || v === null || v === true ? [] : String(v).split(',').map((x) => x.trim()).filter(Boolean); }
153
+ function strFlag(v, name) {
154
+ if (v === undefined || v === null) return null;
155
+ if (v === true) throw new QueryInputError(`--${name} needs a value`);
156
+ return String(v);
157
+ }
158
+
159
+ /** The session filter of queries-v3 from command-line flags. */
160
+ function filtersFrom(flags, { now = Date.now(), named = false } = {}) {
161
+ const f = {};
162
+ const h = listFlag(flags.harness);
163
+ if (h.length === 1) f.harness = h[0]; else if (h.length > 1) f.harness = h;
164
+ for (const k of ['project', 'folder', 'host']) { const v = strFlag(flags[k], k); if (v !== null) f[k] = v; }
165
+ const from = parseWhen(flags.since, { now }); if (from !== null) f.from = from;
166
+ const to = parseWhen(flags.until, { end: true, now }); if (to !== null) f.to = to;
167
+ if (flags['min-messages'] !== undefined) f.minMessages = intFlag(flags['min-messages'], 'min-messages', 0);
168
+ if (named && flags.all !== true) f.named = true;
169
+ return f;
170
+ }
171
+
172
+ /** A session reference: `<harness>:<session_id>`, or a bare id with --harness. */
173
+ function parseRef(ref, flags = {}) {
174
+ if (!ref) throw new QueryInputError('which session? give <harness>:<session_id> (the `ref` that search and recent print)');
175
+ const h = listFlag(flags.harness);
176
+ const s = String(ref);
177
+ // A ref may name its store (`~<token>`, what search and recent print): Q.parseSessionRef reads it.
178
+ if (h.length === 1) return Q.parseSessionRef(s.startsWith(`${h[0]}:`) ? s.slice(h[0].length + 1) : s, h[0]);
179
+ if (s.indexOf(':') <= 0) return { harness: null, session_id: s }; // bare: looked up in sessions
180
+ return Q.parseSessionRef(s);
181
+ }
182
+
183
+ // ── the registry ──────────────────────────────────────────────────────────────────
184
+
185
+ const FILTER_FLAGS = ['harness', 'project', 'folder', 'host', 'since', 'until'];
186
+ const NAMES = {
187
+ schema: { usage: 'q schema', what: 'which schema this install reads: 3 (harness-sessions-sql\'s profile: sessions, session_parts, session_usage, calls) or 2 (the 0.18 rooms)', house: false, flags: [] },
188
+ search: { usage: 'q search <terms…> [--match word] [--limit N]', what: 'sessions whose live text holds EVERY term (substring, lowercase-insensitive), newest hit first', flags: [...FILTER_FLAGS, 'match', 'limit'] },
189
+ recent: { aliases: ['sessions', 'listSessions', 'list'], usage: 'q recent [--limit N] [--sort ended|started|messages] [--cursor C] [--all]', what: 'sessions by most recent activity (placeholders hidden unless --all)', flags: [...FILTER_FLAGS, 'limit', 'sort', 'dir', 'cursor', 'min-messages', 'all'] },
190
+ session: { aliases: ['readSession', 'read', 'transcript'], usage: 'q session <ref> [--limit N] [--cursor C] [--dir forward|backward] [--agent ID|main] [--chars N]', what: 'one session: header, a page of turns in order, and the tool calls of that page', flags: ['harness', 'limit', 'cursor', 'dir', 'agent', 'chars'] },
191
+ window: { usage: 'q window <ref> [--at <message_id> | --ts <time>] [--before 3] [--after 12] [--agent ID|main] [--chars N]', what: 'the turns around one message (a search hit) — what led to it and what resolved it; with neither --at nor --ts, the session\'s opening turns', flags: ['harness', 'at', 'ts', 'before', 'after', 'agent', 'chars'] },
192
+ agents: { usage: 'q agents <ref>', what: 'the sub-agents a session spawned: id, type, what it was asked, turns', flags: ['harness'] },
193
+ aggregate: { usage: 'q aggregate --source sessions|usage|tools --dims a,b --metrics x,y [--order m:desc] [--limit N] [--tz Z]', what: 'any registered dims × metrics over sessions FINAL (names from queries-v3.js, never SQL)', flags: [...FILTER_FLAGS, 'source', 'dims', 'metrics', 'order', 'limit', 'tz', 'exclude-models', 'min-messages', 'all'] },
194
+ spend: { usage: 'q spend [--by model|harness|project|folder|host|day|month] [--since 30d]', what: 'tokens and derived cost (pricing.json), each usage entry priced at its own model', flags: [...FILTER_FLAGS, 'by', 'limit', 'tz'] },
195
+ models: { usage: 'q models', what: 'model mix: sessions, messages, tokens and cost per model', flags: [...FILTER_FLAGS, 'limit'] },
196
+ tools: { usage: 'q tools [--limit 25]', what: 'tool ranking: calls, errors, sessions per tool', flags: [...FILTER_FLAGS, 'limit'] },
197
+ tool: { usage: 'q tool <name> [--limit N] [--chars N]', what: 'the calls of one tool, newest first, with their arguments', flags: [...FILTER_FLAGS, 'limit', 'chars'] },
198
+ activity: { usage: 'q activity [--by day|month|hour|dow|host|harness|project][,…] [--grain sessions|usage] [--tz Z]', what: 'activity over time (sessions by start, or messages/tokens by usage hour)', flags: [...FILTER_FLAGS, 'by', 'grain', 'limit', 'tz'] },
199
+ hosts: { usage: 'q hosts', what: 'per machine: sessions, messages, tokens, first and last activity', flags: [...FILTER_FLAGS] },
200
+ coverage: { usage: 'q coverage', what: 'what the house holds per harness: sessions, span, hosts, origins', flags: [] },
201
+ machines: { usage: 'q machines', what: 'the machines that wrote sessions, and when last', flags: [] },
202
+ count: { usage: 'q count', what: 'how many sessions match the filters', flags: [...FILTER_FLAGS, 'min-messages', 'all'] },
203
+ removed: { usage: 'q removed [<ref>] [--harness H] [--days N] [--limit 50] [--chars 200]', what: 'messages kept as removed versions (a revert, a rewind, a message deleted at the source), newest first: what every other read hides', flags: ['harness', 'days', 'limit', 'chars'] },
204
+ version: { usage: 'q version [<ref>]', what: "the house's change token, or one session's rev", flags: ['harness'] },
205
+ };
206
+ const ALIASES = Object.fromEntries(Object.entries(NAMES).flatMap(([n, d]) => (d.aliases || []).map((a) => [a.toLowerCase(), n])));
207
+ const COMMON_FLAGS = ['json', 'member', 'schema', 'list', 'yes', 'help', 'version'];
208
+
209
+ function canonicalName(n) {
210
+ if (!n) return null;
211
+ if (NAMES[n]) return n;
212
+ return ALIASES[String(n).toLowerCase()] || null;
213
+ }
214
+
215
+ /** Every flag any q name takes — what memhouse/flags.js declares for the `q` command. */
216
+ function allQFlags() { return [...new Set(Object.values(NAMES).flatMap((d) => d.flags).concat(['member', 'schema', 'list']))]; }
217
+
218
+ /**
219
+ * positional (after `q`) + flags -> what to run. Pure. Unknown names and flags a name does not
220
+ * take are refused here with the usage line, so a typo is never a silently different query.
221
+ */
222
+ function parseQ(positional, flags = {}) {
223
+ if (flags.schema !== undefined && !positional.length) return { name: 'schema', opts: {} };
224
+ if (flags.list === true && !positional.length) return { name: 'list', opts: {} };
225
+ const name = canonicalName(positional[0]);
226
+ if (!positional.length) return { name: 'list', opts: {} };
227
+ if (!name) throw new QueryInputError(`no named query '${positional[0]}' — known: ${Object.keys(NAMES).join(', ')}`);
228
+ const def = NAMES[name];
229
+ const bad = Object.keys(flags).filter((f) => !def.flags.includes(f) && !COMMON_FLAGS.includes(f));
230
+ if (bad.length) throw new QueryInputError(`q ${name} does not take --${bad.join(', --')} — usage: memhouse ${def.usage}`);
231
+ const args = positional.slice(1);
232
+ const out = { name, opts: {}, member: strFlag(flags.member, 'member') };
233
+ switch (name) {
234
+ case 'schema': case 'coverage': case 'machines':
235
+ if (args.length) throw new QueryInputError(`q ${name} takes no arguments`);
236
+ break;
237
+ case 'search': {
238
+ if (!args.length) throw new QueryInputError('q search needs at least one term');
239
+ // A positional that holds a space was quoted on the command line: keep it a phrase.
240
+ out.q = args.map((t) => (/\s/.test(t) ? `"${t.replace(/"/g, '')}"` : t)).join(' ');
241
+ out.terms = Q.searchTerms(out.q);
242
+ const f = filtersFrom(flags);
243
+ if (Array.isArray(f.harness)) throw new QueryInputError('search takes one --harness');
244
+ if (f.host) throw new QueryInputError('search filters by --harness, --project, --folder, --since, --until');
245
+ out.opts = { q: out.q, filters: f, limit: intFlag(flags.limit, 'limit', 10, 1, 200) };
246
+ if (flags.match !== undefined) {
247
+ const m = strFlag(flags.match, 'match');
248
+ if (!['word', 'substring'].includes(m)) throw new QueryInputError('--match is word or substring');
249
+ out.opts.match = m;
250
+ }
251
+ break;
252
+ }
253
+ case 'recent': {
254
+ if (args.length) throw new QueryInputError('q recent takes no positional arguments — filter with --harness/--project/--since …');
255
+ const sort = flags.sort === undefined ? 'ended' : strFlag(flags.sort, 'sort');
256
+ if (!['ended', 'started', 'messages'].includes(sort)) throw new QueryInputError('--sort is ended, started or messages');
257
+ const dir = flags.dir === undefined ? 'desc' : strFlag(flags.dir, 'dir');
258
+ if (!['asc', 'desc'].includes(dir)) throw new QueryInputError('--dir is asc or desc for recent');
259
+ out.opts = { filters: filtersFrom(flags, { named: true }), sort, dir, limit: intFlag(flags.limit, 'limit', 20, 1, 5000), cursor: strFlag(flags.cursor, 'cursor') };
260
+ break;
261
+ }
262
+ case 'session': case 'window': case 'agents': {
263
+ if (args.length !== 1) throw new QueryInputError(`q ${name} takes one session: <harness>:<session_id>`);
264
+ out.ref = parseRef(args[0], flags);
265
+ out.opts.agent_id = agentFlag(flags.agent);
266
+ out.opts.chars = intFlag(flags.chars, 'chars', 2000, 0, 10000000);
267
+ if (name === 'session') {
268
+ const dir = flags.dir === undefined ? 'forward' : strFlag(flags.dir, 'dir');
269
+ if (!['forward', 'backward'].includes(dir)) throw new QueryInputError('--dir is forward or backward for session');
270
+ Object.assign(out.opts, { dir, limit: intFlag(flags.limit, 'limit', 200, 1, 5000), cursor: strFlag(flags.cursor, 'cursor') });
271
+ }
272
+ if (name === 'window') {
273
+ // A ref straight from a listing (q recent) reads too: with neither --at nor --ts the window
274
+ // opens at the session's first turn. It used to refuse, and a reader holding only the ref
275
+ // had to invent a time.
276
+ const at = strFlag(flags.at, 'at'); const ts = flags.ts === undefined ? null : parseWhen(flags.ts);
277
+ Object.assign(out.opts, { at, ts, start: !at && ts === null, before: intFlag(flags.before, 'before', 3, 0, 500), after: intFlag(flags.after, 'after', 12, 0, 2000) });
278
+ }
279
+ break;
280
+ }
281
+ case 'aggregate': {
282
+ if (args.length) throw new QueryInputError('q aggregate takes flags only');
283
+ const source = flags.source === undefined ? 'sessions' : strFlag(flags.source, 'source');
284
+ const order = listFlag(flags.order).map((o) => { const [n, d] = o.split(':'); return [n, d === 'desc' ? 'desc' : 'asc']; });
285
+ out.opts = {
286
+ source, dims: listFlag(flags.dims), metrics: listFlag(flags.metrics), order,
287
+ filters: filtersFrom(flags, { named: false }), tz: strFlag(flags.tz, 'tz') || 'UTC',
288
+ where: flags['exclude-models'] === true ? { excludeModels: true } : undefined,
289
+ };
290
+ if (flags.limit !== undefined) out.opts.limit = intFlag(flags.limit, 'limit', 100, 1, 100000);
291
+ if (flags.all !== true && flags['min-messages'] === undefined) { /* every live session; placeholders count too */ }
292
+ Q.aggregate(Q.v3Rooms('m'), out.opts); // validate dims/metrics/order now, against the real registry
293
+ break;
294
+ }
295
+ case 'spend': {
296
+ if (args.length) throw new QueryInputError('q spend takes flags only');
297
+ const by = flags.by === undefined ? 'model' : strFlag(flags.by, 'by');
298
+ if (!['model', 'harness', 'project', 'folder', 'host', 'day', 'month', 'root'].includes(by)) throw new QueryInputError('--by is model, harness, project, folder, host, root, day or month');
299
+ out.opts = { by, filters: filtersFrom(flags), tz: strFlag(flags.tz, 'tz') || 'UTC', limit: flags.limit === undefined ? null : intFlag(flags.limit, 'limit', 50, 1, 100000) };
300
+ break;
301
+ }
302
+ case 'models': case 'tools': case 'hosts': case 'count': {
303
+ if (args.length) throw new QueryInputError(`q ${name} takes flags only`);
304
+ out.opts = { filters: filtersFrom(flags, { named: name === 'count' }), limit: flags.limit === undefined ? (name === 'tools' ? 25 : null) : intFlag(flags.limit, 'limit', 25, 1, 100000) };
305
+ break;
306
+ }
307
+ case 'tool': {
308
+ if (args.length !== 1) throw new QueryInputError('q tool takes one tool name (q tools lists them)');
309
+ const f = filtersFrom(flags);
310
+ if (Array.isArray(f.harness)) throw new QueryInputError('q tool takes one --harness');
311
+ out.opts = { tool: args[0], filters: f, limit: intFlag(flags.limit, 'limit', 50, 1, 1000), chars: intFlag(flags.chars, 'chars', 300, 0, 10000000) };
312
+ break;
313
+ }
314
+ case 'activity': {
315
+ if (args.length) throw new QueryInputError('q activity takes flags only');
316
+ const grain = flags.grain === undefined ? 'sessions' : strFlag(flags.grain, 'grain');
317
+ if (!['sessions', 'usage'].includes(grain)) throw new QueryInputError('--grain is sessions or usage');
318
+ const by = flags.by === undefined ? ['day'] : listFlag(flags.by);
319
+ const allowed = grain === 'usage' ? Object.keys({ ...Q.SESSION_DIMS, ...Q.USAGE_DIMS }) : Object.keys(Q.SESSION_DIMS);
320
+ for (const d of by) if (!allowed.includes(d)) throw new QueryInputError(`--by ${d}: not a ${grain} dimension (${allowed.join(', ')})`);
321
+ out.opts = { grain, by, filters: filtersFrom(flags), tz: strFlag(flags.tz, 'tz') || 'UTC', limit: flags.limit === undefined ? null : intFlag(flags.limit, 'limit', 100, 1, 100000) };
322
+ break;
323
+ }
324
+ case 'removed':
325
+ if (args.length > 1) throw new QueryInputError('q removed takes at most one session');
326
+ if (args.length) out.ref = parseRef(args[0], flags);
327
+ out.opts = {
328
+ harness: strFlag(flags.harness, 'harness'), days: flags.days === undefined ? null : intFlag(flags.days, 'days', 14, 1, 100000),
329
+ limit: intFlag(flags.limit, 'limit', 50, 1, 1000), chars: intFlag(flags.chars, 'chars', 200, 0, 100000),
330
+ };
331
+ break;
332
+ case 'version':
333
+ if (args.length > 1) throw new QueryInputError('q version takes at most one session');
334
+ if (args.length) out.ref = parseRef(args[0], flags);
335
+ break;
336
+ default: break;
337
+ }
338
+ return out;
339
+ }
340
+
341
+ function agentFlag(v) {
342
+ if (v === undefined || v === null) return null;
343
+ if (v === true) throw new QueryInputError('--agent needs an agent id, or main for the main conversation');
344
+ return String(v) === 'main' ? '' : String(v);
345
+ }
346
+
347
+ // ── running ───────────────────────────────────────────────────────────────────────
348
+
349
+ const iso = (ms) => (ms === null || ms === undefined || ms === '' || !Number.isFinite(Number(ms)) ? null : new Date(Number(ms)).toISOString());
350
+ const num = (v) => Number(v) || 0;
351
+ const cap = (s, n) => (n && typeof s === 'string' && s.length > n ? `${s.slice(0, n)}… [${s.length - n} more chars]` : s);
352
+
353
+ /** Every `<x>_ms` field gets a readable `<x>_at` beside it (agents read ISO, not epoch ms). */
354
+ function withIso(row) {
355
+ const out = { ...row };
356
+ for (const [k, v] of Object.entries(row)) if (k.endsWith('_ms') && v !== null && v !== undefined) out[`${k.slice(0, -3)}_at`] = iso(v);
357
+ return out;
358
+ }
359
+ const refOf = (r) => Q.sessionRef(r);
360
+
361
+ /** A bare session id (no harness) resolved through sessions FINAL: unique, or a clear error. */
362
+ function lookupSpec(rooms, sessionId) {
363
+ return {
364
+ query: 'lookupSession', final: 'sessions', settings: {}, params: { s: String(sessionId) },
365
+ sql: `SELECT harness, session_id, host, harness_path, incarnation FROM ${rooms.sessions} FINAL WHERE session_id = {s:String} AND is_deleted = 0 AND removed = 0 ORDER BY harness, ended DESC LIMIT 5`,
366
+ };
367
+ }
368
+ async function resolveRef(exec, rooms, ref) {
369
+ if (ref.harness) return ref;
370
+ const rows = await exec(lookupSpec(rooms, ref.session_id));
371
+ if (!rows.length) { const e = new QueryInputError(`no live session '${ref.session_id}' (give <harness>:<session_id>)`); e.notFound = true; throw e; }
372
+ // One id in several harnesses or stores (two hosts, two incarnations): each is its own session.
373
+ if (rows.length > 1) throw new QueryInputError(`'${ref.session_id}' names ${rows.length} sessions (${rows.map((r) => `${r.harness}${r.host ? ` on ${String(r.host).slice(0, 8)}` : ''}`).join(', ')}) — name one: ${rows.map(refOf).join(', ')}`);
374
+ return Q.parseSessionRef(rows[0]);
375
+ }
376
+
377
+ /**
378
+ * The part a window centres on: a message's first part (`at`), or the session's first part at or
379
+ * after an instant (`ts`). `session_parts FINAL` within the session, is_deleted = 0 AND removed = 0;
380
+ * the same rows readSession's pages return.
381
+ */
382
+ function hitSpec(rooms, { harness, session_id, message_id = null, ts = null, agent_id = null, store = null }) {
383
+ const params = { h: harness, s: session_id };
384
+ const sp = Q.storePred(store);
385
+ let where = `harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0`;
386
+ Object.assign(params, sp.params);
387
+ if (message_id !== null) { where += ' AND message_id = {mid:String}'; params.mid = String(message_id); }
388
+ // An untimed part counts by its first_seen, as every other time-bounded read does, but only where no
389
+ // part with a time qualifies: a session captured late gives its untimed parts the capture day.
390
+ else { where += ` AND ${Q.tsBound('>=', `fromUnixTimestamp64Milli({at:Int64}, 'UTC')`)}`; params.at = Number(ts); }
391
+ if (agent_id !== null && agent_id !== undefined) { where += ' AND agent_id = {agent:String}'; params.agent = agent_id; }
392
+ return {
393
+ query: 'windowHit', final: 'session_parts', settings: {}, params,
394
+ sql: `SELECT ${Q.PART_SELECT}
395
+ FROM ${rooms.parts} FINAL
396
+ WHERE ${where}
397
+ ORDER BY ${message_id !== null ? '' : 'ts IS NULL, '}order_key, message_id, part_index
398
+ LIMIT 1`,
399
+ };
400
+ }
401
+
402
+ /** A part's readable text: a tool call's name and arguments, a tool result's output, else its text. */
403
+ const partText = (t) => Q.partText(t);
404
+ function turnOut(t, chars) {
405
+ const text = partText(t);
406
+ return withIso({
407
+ ts_ms: t.ts_ms === null || t.ts_ms === undefined ? null : num(t.ts_ms), role: t.role, kind: t.kind, agent_id: t.agent_id, message_id: t.message_id, part: num(t.part),
408
+ model: t.model, text: cap(text, chars), text_len: [...String(text || '')].length,
409
+ ...(num(t.input_tokens) || num(t.output_tokens) ? { tokens: { input: num(t.input_tokens), output: num(t.output_tokens), cache_read: num(t.cache_read_tokens), cache_write: num(t.cache_write_tokens), reasoning: num(t.reasoning_tokens) } } : {}),
410
+ });
411
+ }
412
+ function callOut(c, chars) {
413
+ return withIso({
414
+ ts_ms: num(c.ts_ms), tool_name: c.tool_name, agent_id: c.agent_id, call_id: c.call_id, message_id: c.message_id,
415
+ args: cap(c.args, chars), is_error: Boolean(c.is_error === true || c.is_error === 1 || c.is_error === '1'),
416
+ result_len: num(c.result_len), result_ts_ms: c.result_ts_ms === null || c.result_ts_ms === undefined ? null : num(c.result_ts_ms),
417
+ });
418
+ }
419
+ function headerOut(h) {
420
+ const { days, ...rest } = h;
421
+ return { ref: refOf(h), ...withIso(rest), days: (days || []).map(String) };
422
+ }
423
+
424
+ function priceRow(model, r) {
425
+ const { calculateCost } = require('../../pricing');
426
+ return calculateCost(model, num(r.input_tokens), num(r.output_tokens), num(r.cache_read_tokens), num(r.cache_write_tokens));
427
+ }
428
+
429
+ /** Run one parsed q against an exec. Returns the JSON-able result. */
430
+ async function runQ(exec, rooms, p, { schema = 3 } = {}) {
431
+ const o = p.opts || {};
432
+ switch (p.name) {
433
+ case 'schema': return { schema };
434
+ case 'list': return { names: Object.entries(NAMES).map(([name, d]) => ({ name, aliases: d.aliases || [], usage: `memhouse ${d.usage}`, what: d.what })) };
435
+ case 'search': {
436
+ const spec = Q.search(rooms, o);
437
+ const rows = await exec(spec);
438
+ return {
439
+ query: 'search', terms: spec.terms,
440
+ hits: rows.map((h) => ({ ref: refOf(h), ...withIso({ ...h, hits: num(h.hits) }) })),
441
+ next: 'read a hit with: memhouse q window <ref> --at <message_id>',
442
+ };
443
+ }
444
+ case 'recent': {
445
+ const r = await Q.run(exec, Q.listSessions(rooms, o));
446
+ return {
447
+ query: 'recent', sessions: r.rows.map((s) => ({ ref: refOf(s), ...withIso(s) })), nextCursor: r.nextCursor,
448
+ next: 'read one with: memhouse q session <ref>, or its opening turns: memhouse q window <ref> (--ts <time> for a moment in it)',
449
+ };
450
+ }
451
+ case 'session': {
452
+ const ref = await resolveRef(exec, rooms, p.ref);
453
+ const r = await Q.readSession(exec, rooms, { ...ref, cursor: o.cursor, dir: o.dir, limit: o.limit, agent_id: o.agent_id });
454
+ if (!r) return notFound(ref);
455
+ return {
456
+ query: 'session', header: headerOut(r.header),
457
+ turns: r.turns.map((t) => turnOut(t, o.chars)), calls: r.calls.map((c) => callOut(c, o.chars)),
458
+ nextCursor: r.nextCursor, prevCursor: r.prevCursor,
459
+ };
460
+ }
461
+ case 'window': {
462
+ const ref = await resolveRef(exec, rooms, p.ref);
463
+ const header = await Q.run(exec, Q.sessionHeader(rooms, ref));
464
+ if (!header) return notFound(ref);
465
+ // The header names the store; the hit and its window are read from that store only.
466
+ const store = { host: header.host, harness_path: header.harness_path, incarnation: header.incarnation };
467
+ let center = null;
468
+ if (o.at) {
469
+ const [hit] = await exec(hitSpec(rooms, { ...ref, message_id: o.at, agent_id: o.agent_id, store }));
470
+ if (!hit) throw Object.assign(new QueryInputError(`no live turn '${o.at}' in ${refOf(ref)}`), { notFound: true });
471
+ center = hit;
472
+ }
473
+ // The window pivots on the hit, or with --ts on the first part at or after that instant (none:
474
+ // the window is the session's tail before it). The cursor is the pivot's full order key.
475
+ // With neither: the first part from the beginning of time, so nothing comes before it.
476
+ const [pivot] = center ? [center] : await exec(hitSpec(rooms, { ...ref, ts: o.start ? 0 : o.ts, agent_id: o.agent_id, store }));
477
+ const key = pivot ? [pivot.order_key, pivot.message_id, num(pivot.part)] : null;
478
+ const page = async (dir, limit) => {
479
+ if (!limit || (!key && dir === 'forward')) return [];
480
+ const rows = await exec(Q.partsPage(rooms, { ...ref, cursor: key, dir, limit, agent_id: o.agent_id, store }));
481
+ return rows.slice(0, limit);
482
+ };
483
+ const before = (await page('backward', o.before)).reverse();
484
+ const after = await page('forward', o.after);
485
+ const turns = [...before, ...(pivot ? [pivot] : []), ...after];
486
+ // Tokens on the messages that start in the window (a response's on its first message).
487
+ const starts = [...new Set(turns.filter((t) => num(t.part) === 0).map((t) => t.message_id))];
488
+ Q.attachUsage(turns, await Q.run(exec, Q.usageOf(rooms, { ...ref, store, message_ids: starts, response_ids: [...new Set(turns.map((t) => t.response_id).filter(Boolean))] })));
489
+ return {
490
+ query: 'window', ref: refOf(ref), name: header.name, folder: header.folder, project: header.project,
491
+ at: center ? center.message_id : null, at_ts: center ? iso(center.ts_ms) : o.start ? null : iso(o.ts), ...(o.start ? { from: 'start' } : {}),
492
+ turns: turns.map((t) => ({ ...turnOut(t, o.chars), ...(center && t.message_id === center.message_id && num(t.part) === num(center.part) && t.agent_id === center.agent_id ? { hit: true } : {}) })),
493
+ agents: header.agents,
494
+ };
495
+ }
496
+ case 'agents': {
497
+ const ref = await resolveRef(exec, rooms, p.ref);
498
+ const header = await Q.run(exec, Q.sessionHeader(rooms, ref));
499
+ if (!header) return notFound(ref);
500
+ return { query: 'agents', ref: refOf(ref), name: header.name, agents: header.agents.map(withIso) };
501
+ }
502
+ case 'aggregate': {
503
+ const rows = await exec(Q.aggregate(rooms, o));
504
+ return { query: `aggregate:${o.source}`, rows: rows.map(withIso) };
505
+ }
506
+ case 'spend': {
507
+ const dims = o.by === 'model' ? ['price_model'] : [o.by, 'price_model'];
508
+ const rows = await exec(Q.aggregate(rooms, {
509
+ source: 'usage', dims, filters: o.filters, tz: o.tz,
510
+ metrics: ['sessions', 'messages', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'],
511
+ }));
512
+ const groups = new Map();
513
+ const unpriced = new Set();
514
+ let total = 0;
515
+ for (const r of rows) {
516
+ const g = o.by === 'model' ? r.price_model : r[o.by];
517
+ const cost = r.price_model ? priceRow(r.price_model, r) : null;
518
+ const toks = num(r.input_tokens) + num(r.output_tokens) + num(r.cache_read_tokens) + num(r.cache_write_tokens);
519
+ if (cost === null && toks) unpriced.add(r.price_model || '(no model)');
520
+ if (cost !== null) total += cost;
521
+ const x = groups.get(g) || { [o.by]: g, messages: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, cost: 0, priced: true };
522
+ for (const k of ['messages', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens']) x[k] += num(r[k]);
523
+ if (cost === null) { if (toks) x.priced = false; } else x.cost += cost;
524
+ groups.set(g, x);
525
+ }
526
+ let out = [...groups.values()].map((x) => ({ ...x, cost: Math.round(x.cost * 100) / 100 }));
527
+ out.sort((a, b) => (o.by === 'day' || o.by === 'month') ? String(b[o.by]).localeCompare(String(a[o.by])) : (b.cost - a.cost) || (b.output_tokens - a.output_tokens));
528
+ if (o.limit) out = out.slice(0, o.limit);
529
+ return {
530
+ query: 'spend', by: o.by, rows: out, total_cost: Math.round(total * 100) / 100, unpriced: [...unpriced].sort(),
531
+ note: 'cost is derived (pricing.json × tokens); an unpriced model costs nothing here — it is unknown, not free. Converted 0.18 Claude Code sessions count usage once per inferred response run (about 1.1× the billed figure where the transcript is gone); captured ones per real response.',
532
+ };
533
+ }
534
+ case 'models': {
535
+ const rows = await exec(Q.aggregate(rooms, {
536
+ source: 'usage', dims: ['model'], filters: o.filters, where: { excludeModels: true },
537
+ metrics: ['sessions', 'messages', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens', 'first_hour_ms', 'last_hour_ms'],
538
+ order: [['messages', 'desc']], ...(o.limit ? { limit: o.limit } : {}),
539
+ }));
540
+ let total = 0;
541
+ const out = rows.map((r) => { const cost = priceRow(r.model, r); if (cost !== null) total += cost; return withIso({ ...r, cost: cost === null ? null : Math.round(cost * 100) / 100 }); });
542
+ return { query: 'models', rows: out, total_cost: Math.round(total * 100) / 100 };
543
+ }
544
+ case 'tools': {
545
+ const rows = await exec(Q.aggregate(rooms, { source: 'tools', dims: ['tool'], metrics: ['calls', 'errors', 'sessions'], filters: o.filters, order: [['calls', 'desc']], limit: o.limit || 25 }));
546
+ return { query: 'tools', rows, next: 'one tool\'s calls: memhouse q tool <name>' };
547
+ }
548
+ case 'tool': {
549
+ const rows = await exec(Q.tools(rooms, o));
550
+ return { query: 'tool', tool: o.tool, calls: rows.map((c) => ({ ref: refOf(c), session_name: c.session_name, folder: c.folder, project: c.project, host: c.host, ...callOut(c, o.chars) })) };
551
+ }
552
+ case 'activity': {
553
+ const usage = o.grain === 'usage';
554
+ const metrics = usage ? ['sessions', 'messages', 'input_tokens', 'output_tokens'] : ['sessions', 'messages', 'tool_calls', 'input_tokens', 'output_tokens'];
555
+ const timeDims = ['day', 'month', 'hour', 'dow'];
556
+ const order = o.by.map((d) => [d, timeDims.includes(d) && (d === 'day' || d === 'month') ? 'desc' : 'asc']);
557
+ const rows = await exec(Q.aggregate(rooms, { source: usage ? 'usage' : 'sessions', dims: o.by, metrics, filters: o.filters, tz: o.tz, order, ...(o.limit ? { limit: o.limit } : {}) }));
558
+ return { query: `activity:${o.grain}`, by: o.by, rows, note: usage ? 'messages and tokens bucketed by the hour they happened' : 'sessions bucketed by when they started' };
559
+ }
560
+ case 'hosts': {
561
+ const rows = await exec(Q.aggregate(rooms, {
562
+ dims: ['host'], filters: o.filters,
563
+ metrics: ['sessions', 'messages', 'tool_calls', 'input_tokens', 'output_tokens', 'first_started_ms', 'last_ended_ms'],
564
+ order: [['last_ended_ms', 'desc']],
565
+ }));
566
+ // One machine under its 0.18 id and its new one reads as one (host-alias.js).
567
+ const folded = HA.foldRows(rows, rooms.hostAliases, { sum: ['sessions', 'messages', 'tool_calls', 'input_tokens', 'output_tokens'], min: ['first_started_ms'], max: ['last_ended_ms'] })
568
+ .sort((a, b) => Number(b.last_ended_ms || 0) - Number(a.last_ended_ms || 0));
569
+ return { query: 'hosts', rows: folded.map(withIso) };
570
+ }
571
+ case 'coverage': {
572
+ const r = await Q.run(exec, Q.coverage(rooms));
573
+ const hosts = (h) => [...new Set((h || []).map((x) => HA.fold(x, rooms.hostAliases)))].sort();
574
+ return { query: 'coverage', harnesses: r.harnesses.map((x) => withIso({ ...x, hosts: hosts(x.hosts) })), total: withIso(r.total) };
575
+ }
576
+ case 'machines': return { query: 'machines', rows: machinesFolded(await exec(Q.machines(rooms)), rooms).map(withIso) };
577
+ case 'count': return { query: 'count', sessions: await Q.run(exec, Q.countSessions(rooms, o.filters)) };
578
+ case 'removed': {
579
+ const o2 = { ...o };
580
+ if (p.ref) {
581
+ const ref = await resolveRef(exec, rooms, p.ref);
582
+ Object.assign(o2, { harness: ref.harness, session_id: ref.session_id, store: ref.host != null && ref.harness_path != null ? ref : null });
583
+ }
584
+ const rows = await exec(Q.removedContent(rooms, o2));
585
+ return { query: 'removed', messages: rows.map((r) => ({ ref: refOf(r), message_id: r.message_id, role: r.role, state: r.state, ts_at: iso(r.ts_ms), parts: Number(r.parts), snippet: r.snippet })) };
586
+ }
587
+ case 'version': {
588
+ if (!p.ref) return { query: 'version', ...(await Q.run(exec, Q.houseVersion(rooms))) };
589
+ const ref = await resolveRef(exec, rooms, p.ref);
590
+ const v = await Q.run(exec, Q.sessionVersion(rooms, ref));
591
+ return v ? { query: 'version', ref: refOf(ref), ...withIso(v) } : notFound(ref);
592
+ }
593
+ default: throw new QueryInputError(`no named query '${p.name}'`);
594
+ }
595
+ }
596
+ function notFound(ref) { return { error: 'not_found', ref: refOf(ref), message: `no live session ${refOf(ref)}` }; }
597
+
598
+ // ── rendering for a human ─────────────────────────────────────────────────────────
599
+
600
+ function table(rows, cols) {
601
+ if (!rows.length) return [' (no rows)'];
602
+ const cells = rows.map((r) => cols.map((c) => { const v = typeof c.get === 'function' ? c.get(r) : r[c.key]; return v === null || v === undefined ? '' : String(v); }));
603
+ const w = cols.map((c, i) => Math.min(c.max || 60, Math.max(c.title.length, ...cells.map((x) => x[i].length))));
604
+ const fit = (s, n) => (s.length > n ? `${s.slice(0, n - 1)}…` : s);
605
+ const line = (vals) => ` ${vals.map((v, i) => (cols[i].right ? fit(v, w[i]).padStart(w[i]) : fit(v, w[i]).padEnd(w[i]))).join(' ')}`.trimEnd();
606
+ return [line(cols.map((c) => c.title)), ...cells.map(line)];
607
+ }
608
+ const day = (s) => (s ? String(s).slice(0, 16).replace('T', ' ') : '');
609
+ const oneLine = (s, n = 160) => String(s || '').replace(/\s+/g, ' ').trim().slice(0, n);
610
+
611
+ function renderQ(name, r) {
612
+ if (r && r.error) return [` ${r.message || r.error}`];
613
+ switch (name) {
614
+ case 'schema': return [String(r.schema)];
615
+ case 'list': return ['memhouse q <name> [--json] — the named queries:', '',
616
+ ...r.names.map((n) => ` ${n.name.padEnd(9)} ${n.what}\n ${n.usage}${n.aliases.length ? ` (also: ${n.aliases.join(', ')})` : ''}`),
617
+ '', ' Every name takes --member <name> to read a housemate\'s tables you were granted, and --json.'];
618
+ case 'search': {
619
+ if (!r.hits.length) return [` no live turn holds ${r.terms.map((t) => `'${t}'`).join(' and ')}`];
620
+ const out = [];
621
+ for (const h of r.hits) {
622
+ out.push(`\x1b[1m${h.ref}\x1b[0m ${h.project || h.folder || '-'} ${day(h.last_hit_at)} (${h.hits} hit${h.hits === 1 ? '' : 's'}${h.name ? ` · ${oneLine(h.name, 60)}` : ''})`);
623
+ out.push(` ${h.role}${h.agent_id ? ` [agent ${h.agent_id}]` : ''} ${h.message_id}: ${oneLine(h.snippet)}`);
624
+ }
625
+ out.push('', ` ${r.next}`);
626
+ return out;
627
+ }
628
+ case 'recent': return [...table(r.sessions, [
629
+ { title: 'ref', key: 'ref', max: 50 }, { title: 'last active', get: (s) => day(s.ended_at) }, { title: 'msgs', key: 'messages', right: true },
630
+ { title: 'host', key: 'host', max: 24 }, { title: 'project', get: (s) => s.project || s.folder, max: 30 }, { title: 'name', get: (s) => oneLine(s.name || s.first_prompt, 60) },
631
+ ]), ...(r.nextCursor ? ['', ` more: --cursor ${r.nextCursor}`] : []), ...(r.sessions.length && r.next ? ['', ` ${r.next}`] : [])];
632
+ case 'session': case 'window': {
633
+ const h = name === 'session' ? r.header : r;
634
+ const out = [`\x1b[1m${h.ref}\x1b[0m ${h.name ? oneLine(h.name, 80) : ''}`, ` ${h.project || h.folder || ''}${name === 'session' ? ` ${day(h.started_at)} → ${day(h.ended_at)} ${h.messages} messages` : ''}`, ''];
635
+ const calls = name === 'session' ? r.calls : [];
636
+ // The parts in the order the read gave (the session's order_key: a part may have no time), each
637
+ // call after the part of its message, or by its time when its message is not on the page.
638
+ const pos = new Map(r.turns.map((t, i) => [String(t.message_id), i]));
639
+ const after = (c) => {
640
+ if (pos.has(String(c.message_id))) return pos.get(String(c.message_id)) + 0.5;
641
+ let k = -1;
642
+ r.turns.forEach((t, i) => { if (t.ts_ms != null && c.ts_ms != null && t.ts_ms <= c.ts_ms) k = i; });
643
+ return k + 0.5;
644
+ };
645
+ const items = [...r.turns.map((t, i) => ({ t, at: i })), ...calls.map((c) => ({ c, at: after(c) }))].sort((a, b) => a.at - b.at);
646
+ for (const it of items) {
647
+ if (it.t) {
648
+ const t = it.t;
649
+ out.push(`${t.hit ? '\x1b[1m>>' : ' '} ${day(t.ts_at)} ${t.role}${t.kind !== 'text' ? `/${t.kind}` : ''}${t.agent_id ? ` [agent ${t.agent_id}]` : ''}${t.hit ? '\x1b[0m' : ''}`);
650
+ for (const l of String(t.text || '').split('\n')) out.push(` ${l}`);
651
+ } else out.push(` ${day(it.c.ts_at)} tool ${it.c.tool_name}${it.c.is_error ? ' (error)' : ''}: ${oneLine(it.c.args, 200)}`);
652
+ }
653
+ if (r.nextCursor) out.push('', ` next page: --cursor ${r.nextCursor}`);
654
+ if (r.prevCursor) out.push('', ` previous page: --cursor ${r.prevCursor} --dir backward`);
655
+ return out;
656
+ }
657
+ case 'agents': return [`${r.ref} ${oneLine(r.name, 80)}`, ...table(r.agents, [
658
+ { title: 'agent', key: 'agent_id', max: 40 }, { title: 'type', key: 'type', max: 24 }, { title: 'turns', key: 'messages', right: true },
659
+ { title: 'first', get: (a) => day(a.first_at) }, { title: 'asked to', get: (a) => oneLine(a.description, 70) }])];
660
+ case 'spend': return [...table(r.rows, [
661
+ { title: r.by, key: r.by, max: 40 }, { title: 'messages', key: 'messages', right: true }, { title: 'input', key: 'input_tokens', right: true },
662
+ { title: 'output', key: 'output_tokens', right: true }, { title: 'cache read', key: 'cache_read_tokens', right: true },
663
+ { title: 'cache write', key: 'cache_write_tokens', right: true }, { title: 'cost $', get: (x) => (x.priced ? x.cost.toFixed(2) : `${x.cost.toFixed(2)}+?`), right: true }]),
664
+ '', ` total $${r.total_cost.toFixed(2)}${r.unpriced.length ? ` unpriced (cost unknown): ${r.unpriced.join(', ')}` : ''}`, ` ${r.note}`];
665
+ case 'models': return [...table(r.rows, [
666
+ { title: 'model', key: 'model', max: 40 }, { title: 'sessions', key: 'sessions', right: true }, { title: 'messages', key: 'messages', right: true },
667
+ { title: 'input', key: 'input_tokens', right: true }, { title: 'output', key: 'output_tokens', right: true },
668
+ { title: 'cost $', get: (x) => (x.cost === null ? 'unpriced' : x.cost.toFixed(2)), right: true }, { title: 'last', get: (x) => day(x.last_hour_at) }]),
669
+ '', ` total $${r.total_cost.toFixed(2)}`];
670
+ case 'tools': return table(r.rows, [{ title: 'tool', key: 'tool', max: 50 }, { title: 'calls', key: 'calls', right: true }, { title: 'errors', key: 'errors', right: true }, { title: 'sessions', key: 'sessions', right: true }]);
671
+ case 'tool': return table(r.calls, [{ title: 'when', get: (c) => day(c.ts_at) }, { title: 'ref', key: 'ref', max: 50 }, { title: 'err', get: (c) => (c.is_error ? 'x' : '') }, { title: 'args', get: (c) => oneLine(c.args, 100) }]);
672
+ case 'hosts': return table(r.rows, [{ title: 'host', key: 'host', max: 40 }, { title: 'sessions', key: 'sessions', right: true }, { title: 'messages', key: 'messages', right: true }, { title: 'first', get: (x) => day(x.first_started_at) }, { title: 'last', get: (x) => day(x.last_ended_at) }]);
673
+ case 'machines': return table(r.rows, [{ title: 'host', key: 'host', max: 40 }, { title: 'sessions', key: 'sessions', right: true }, { title: 'harnesses', get: (x) => (x.harnesses || []).join(',') }, { title: 'last', get: (x) => day(x.last_at) }]);
674
+ case 'coverage': return [...table(r.harnesses, [{ title: 'harness', key: 'harness' }, { title: 'sessions', key: 'sessions', right: true }, { title: 'messages', key: 'messages', right: true }, { title: 'first', get: (x) => day(x.first_at) }, { title: 'last', get: (x) => day(x.last_at) }, { title: 'hosts', get: (x) => (x.hosts || []).join(',') }, { title: 'origins', get: (x) => (x.origins || []).join(',') }]),
675
+ '', ` total: ${r.total.sessions} sessions, ${r.total.messages} messages, ${day(r.total.first_at)} → ${day(r.total.last_at)}`];
676
+ case 'count': return [String(r.sessions)];
677
+ case 'removed': return r.messages.length ? table(r.messages, [{ title: 'when', get: (m) => day(m.ts_at) }, { title: 'ref', key: 'ref', max: 50 }, { title: 'message', key: 'message_id', max: 28 }, { title: 'role', key: 'role' }, { title: 'state', get: (m) => m.state || 'removed' }, { title: 'text', get: (m) => oneLine(m.snippet, 80) }]) : [' (no removed content)'];
678
+ case 'version': return [r.ref ? `${r.ref} rev ${r.rev} (${r.messages} messages, last ${day(r.ended_at)})` : `${r.token} (${r.sessions} sessions, last ${day(iso(r.last_ms))})`];
679
+ case 'aggregate': case 'activity': {
680
+ const rows = r.rows || [];
681
+ if (!rows.length) return [' (no rows)'];
682
+ const cols = Object.keys(rows[0]).filter((k) => !k.endsWith('_ms')).map((k) => ({ title: k, key: k, right: typeof rows[0][k] === 'number' }));
683
+ return table(rows, cols);
684
+ }
685
+ default: return [JSON.stringify(r, null, 2)];
686
+ }
687
+ }
688
+
689
+ // ── room-sql: SQL over the member's tables by their bare names ────────────────────
690
+
691
+ const V3_BARE = ['session_parts', 'session_usage', 'session_links', 'session_lifecycle', 'calls', 'sessions', 'meta', 'events'];
692
+ const V2_BARE = ['messages', 'tool_calls', 'sessions', 'meta', 'events'];
693
+
694
+ /**
695
+ * Rewrite bare table names after FROM / JOIN to the member's physical names, OUTSIDE string
696
+ * literals and quoted identifiers (a needle like '%from sessions%' is text, not a table). A name
697
+ * already qualified (`mem.x`) or prefixed (`bob_sessions`) is left alone: the match needs
698
+ * whitespace before the bare word and a boundary after it. The statement's own head names a table
699
+ * too — `DESCRIBE sessions`, `DESC TABLE calls`, `SHOW CREATE sessions`, `EXISTS TABLE meta` — and
700
+ * left bare it reached the server as `<db>.sessions`, which no member may read, so a member asking
701
+ * for its own columns was told it lacked the privilege.
702
+ *
703
+ * @param {string} sql
704
+ * @param {{ member, db, schema }} o schema 3 → the profile's tables, meta and events; 2 → the 0.18 rooms
705
+ */
706
+ function rewriteRooms(sql, { member, db = null, schema = 3 }) {
707
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(String(member || ''))) throw new QueryInputError(`'${member}' is not a member name`);
708
+ const names = schema === 3 ? V3_BARE : V2_BARE;
709
+ const qual = (t) => `${db ? `${db}.` : ''}${member}_${t}`;
710
+ const re = new RegExp(`(^|[\\s(,])(FROM|JOIN)(\\s+)(${names.join('|')})(?=$|[\\s;,)])`, 'gi');
711
+ const head = new RegExp(`^(\\s*)((?:DESCRIBE|DESC|EXISTS|SHOW\\s+CREATE)(?:\\s+TABLE)?)(\\s+)(${names.join('|')})(?=$|[\\s;,)])`, 'i');
712
+ let out = ''; let i = 0; let buf = ''; let atHead = true; // nothing but comments and blanks so far
713
+ const flush = () => {
714
+ let b = buf;
715
+ if (atHead && /\S/.test(b)) { b = b.replace(head, (m, pre, kw, sp, t) => `${pre}${kw}${sp}${qual(t.toLowerCase())}`); atHead = false; }
716
+ out += b.replace(re, (m, pre, kw, sp, t) => `${pre}${kw}${sp}${qual(t.toLowerCase())}`); buf = '';
717
+ };
718
+ while (i < sql.length) {
719
+ const c = sql[i];
720
+ if (c === "'" || c === '"' || c === '`') {
721
+ flush(); atHead = false;
722
+ let j = i + 1;
723
+ while (j < sql.length) {
724
+ if (sql[j] === '\\') { j += 2; continue; }
725
+ if (sql[j] === c) { if (sql[j + 1] === c) { j += 2; continue; } break; }
726
+ j++;
727
+ }
728
+ out += sql.slice(i, j + 1); i = j + 1; continue;
729
+ }
730
+ if (c === '-' && sql[i + 1] === '-') { flush(); const j = sql.indexOf('\n', i); const e = j === -1 ? sql.length : j; out += sql.slice(i, e); i = e; continue; }
731
+ if (c === '/' && sql[i + 1] === '*') { flush(); const j = sql.indexOf('*/', i + 2); const e = j === -1 ? sql.length : j + 2; out += sql.slice(i, e); i = e; continue; }
732
+ buf += c; i++;
733
+ }
734
+ flush();
735
+ return out;
736
+ }
737
+
738
+ /** Does this statement already name its output FORMAT? (trailing, after comments are gone) */
739
+ function hasFormat(sql) {
740
+ const s = String(sql).replace(/--[^\n]*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim().replace(/;\s*$/, '');
741
+ return /\bFORMAT\s+[A-Za-z0-9_]+\s*$/i.test(s);
742
+ }
743
+
744
+ /** Refuse what is not a read before it leaves the machine (the server's readonly=2 refuses it too). */
745
+ function assertRead(sql) {
746
+ const s = String(sql).replace(/--[^\n]*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
747
+ if (!s) throw new QueryInputError('no SQL given');
748
+ if (!/^(SELECT|WITH|SHOW|DESCRIBE|DESC|EXPLAIN|EXISTS|\()/i.test(s)) throw new QueryInputError('room-sql runs reads only (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN)');
749
+ if (/;\s*\S/.test(s.replace(/'(?:[^'\\]|\\.|'')*'/g, "''"))) throw new QueryInputError('one statement at a time');
750
+ }
751
+
752
+ // ── stats / status / sessions-query on schema 3 ───────────────────────────────────
753
+
754
+ /** `memhouse stats`: per harness, from sessions FINAL (≤ one row per session). */
755
+ async function statsV3(exec, rooms) {
756
+ const rows = await exec(Q.aggregate(rooms, {
757
+ dims: ['harness'], order: [['sessions', 'desc']],
758
+ metrics: ['sessions', 'messages', 'tool_calls', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'last_ended_ms'],
759
+ }));
760
+ const out = rows.map((r) => withIso({ ...r, tokens: num(r.input_tokens) + num(r.output_tokens) + num(r.cache_read_tokens) + num(r.cache_write_tokens) }));
761
+ const total = out.reduce((a, r) => ({ sessions: a.sessions + num(r.sessions), messages: a.messages + num(r.messages), tool_calls: a.tool_calls + num(r.tool_calls), tokens: a.tokens + r.tokens }), { sessions: 0, messages: 0, tool_calls: 0, tokens: 0 });
762
+ return { schema: 3, harnesses: out, total };
763
+ }
764
+ function renderStats(s) {
765
+ if (!s.harnesses.length) return ['house is empty'];
766
+ return table([...s.harnesses, { harness: 'total', ...s.total }], [
767
+ { title: 'harness', key: 'harness' }, { title: 'sessions', key: 'sessions', right: true }, { title: 'messages', key: 'messages', right: true },
768
+ { title: 'tool calls', key: 'tool_calls', right: true }, { title: 'tokens', key: 'tokens', right: true }, { title: 'last active', get: (r) => day(r.last_ended_at) }]).map((l) => l.slice(2));
769
+ }
770
+
771
+ /**
772
+ * What `status` reads on schema 3: counts, freshness and the machines, all from sessions FINAL
773
+ * (one row per session — the table is small by design). The freshest write is max(ingested_at)
774
+ * over the session rows: the capture rewrites a session's row on every change.
775
+ */
776
+ function statusSpec(rooms) {
777
+ return {
778
+ query: 'status', final: 'sessions', settings: {}, params: {},
779
+ sql: `SELECT host, count() AS sessions, sum(messages) AS messages, sum(tool_calls) AS tool_calls,
780
+ toUnixTimestamp64Milli(max(ingested_at)) AS last_write_ms, toUnixTimestamp64Milli(max(ended)) AS last_active_ms,
781
+ arraySort(groupUniqArray(harness)) AS harnesses
782
+ FROM ${rooms.sessions} FINAL
783
+ WHERE is_deleted = 0 AND removed = 0
784
+ GROUP BY host
785
+ ORDER BY last_write_ms DESC, host`,
786
+ };
787
+ }
788
+ /** Q.machines rows with a machine's 0.18 id folded into it (host-alias.js); doctor uses the same. */
789
+ function machinesFolded(rows, rooms) {
790
+ return HA.foldRows(rows, rooms && rooms.hostAliases, { sum: ['sessions'], max: ['last_ms'], union: ['harnesses'], any: ['label'] })
791
+ .sort((a, b) => Number(b.last_ms || 0) - Number(a.last_ms || 0));
792
+ }
793
+
794
+ async function statusV3(exec, rooms) {
795
+ const hosts = HA.foldRows(await exec(statusSpec(rooms)), rooms.hostAliases, {
796
+ sum: ['sessions', 'messages', 'tool_calls'], max: ['last_write_ms', 'last_active_ms'], union: ['harnesses'],
797
+ }).sort((a, b) => Number(b.last_write_ms || 0) - Number(a.last_write_ms || 0)).map(withIso);
798
+ const t = hosts.reduce((a, h) => ({
799
+ sessions: a.sessions + num(h.sessions), messages: a.messages + num(h.messages), tool_calls: a.tool_calls + num(h.tool_calls),
800
+ last_write_ms: Math.max(a.last_write_ms, num(h.last_write_ms)),
801
+ }), { sessions: 0, messages: 0, tool_calls: 0, last_write_ms: 0 });
802
+ return { ...t, freshest: t.last_write_ms ? iso(t.last_write_ms) : null, hosts };
803
+ }
804
+
805
+ /**
806
+ * `memhouse sessions-query` on schema 3. The sessions table IS the rollup now (exact totals,
807
+ * one row per session), so this is that table read FINAL with tombstones dropped — plus the
808
+ * 0.18 rollup's column names as aliases, so `FROM $(memhouse sessions-query) AS c` keeps working
809
+ * for the common columns. `session_id` is the NATIVE id now; `id` is `<harness>:<session_id>`.
810
+ */
811
+ function sessionsQueryV3(rooms) {
812
+ return `(
813
+ SELECT concat(harness, ':', session_id) AS id, harness, session_id, harness AS source, ${Q.ROOT_LABEL('harness_path')} AS root, harness_path, host, title AS name, mode,
814
+ folder, project, git_branch, created_at, updated_at, updated_at AS last_updated_at, started, ended,
815
+ dateDiff('second', started, ended) AS duration_sec,
816
+ messages, messages AS total_msgs, user_messages, user_messages AS user_msgs,
817
+ assistant_messages, assistant_messages AS assistant_msgs, agent_messages, agent_messages AS subagent_msgs,
818
+ tool_calls, models, dominant_model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens,
819
+ reasoning_tokens, user_chars, assistant_chars, first_prompt, days, origin, writer AS user_id
820
+ FROM ${rooms.sessions} FINAL
821
+ WHERE is_deleted = 0 AND removed = 0
822
+ )`;
823
+ }
824
+
825
+ /** `memhouse resume` on schema 3: the session row, by `<harness>:<id>` or a bare native id. */
826
+ function resumeSpec(rooms, ref) {
827
+ const params = { s: ref.session_id };
828
+ let where = 'session_id = {s:String} AND is_deleted = 0 AND removed = 0';
829
+ if (ref.harness) { where = `harness = {h:String} AND ${where}`; params.h = ref.harness; }
830
+ if (ref.host != null) {
831
+ where += ' AND host = {r_host:String} AND harness_path = {r_hp:String} AND incarnation = {r_inc:String}';
832
+ Object.assign(params, { r_host: String(ref.host), r_hp: String(ref.harness_path), r_inc: String(ref.incarnation || '') });
833
+ }
834
+ return {
835
+ query: 'resume', final: 'sessions', settings: {}, params,
836
+ sql: `SELECT harness, session_id, host, harness_path, incarnation, folder, title AS name, origin, toUnixTimestamp64Milli(ended) AS ended_ms
837
+ FROM ${rooms.sessions} FINAL
838
+ WHERE ${where}
839
+ ORDER BY ended DESC
840
+ LIMIT 5`,
841
+ };
842
+ }
843
+
844
+ /**
845
+ * Keep the blocks of a delivery text that belong to this schema. A block is
846
+ * <!-- schema 3 --> … <!-- end schema 3 -->
847
+ * and is dropped, markers and all, when the install is on the other schema; the markers of a
848
+ * kept block are dropped too. Text outside any block is kept as is. (PROMPT.md is rendered
849
+ * this way by `memhouse prompt`; the plugin skills, which are copied verbatim, carry both and
850
+ * choose at run time with `memhouse q schema`.)
851
+ */
852
+ function renderSchemaBlocks(text, schema) {
853
+ const re = /^<!-- schema ([23]) -->\n([\s\S]*?)^<!-- end schema \1 -->\n?/gm;
854
+ return String(text).replace(re, (m, which, body) => (Number(which) === Number(schema) ? body : ''));
855
+ }
856
+
857
+ module.exports = {
858
+ renderSchemaBlocks,
859
+ NAMES, ALIASES, FILTER_FLAGS, QueryInputError,
860
+ schemaOf, httpTransport, fmtTop, fmtInner, parseWhen, filtersFrom, parseRef, parseQ, runQ, renderQ, allQFlags, canonicalName,
861
+ resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
862
+ sessionsQueryV3, resumeSpec, table,
863
+ };