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,1013 @@
1
+ // The named queries of memhouse's read layer, over harness-sessions-sql's profile tables (step B4;
2
+ // docs/design/source-identity.md §6 for the rules, harness-sessions-sql §3–4 for the tables).
3
+ //
4
+ // Four tables, harness-agnostic, one set per member, each keyed by the session key first:
5
+ //
6
+ // <m>_sessions one row per session: the spec columns and memhouse's totals (the rollup)
7
+ // <m>_session_parts one row per message part, keyed (session, message_id, part_id); read in
8
+ // order_key order
9
+ // <m>_session_usage one row per usage record, keyed (session, usage_id); a reader applies the
10
+ // flags and counts a per_record response once (harness-sessions-sql §4.4)
11
+ // <m>_calls one row per tool call (memhouse's convenience table), keyed (session, call_id)
12
+ //
13
+ // All four are ReplacingMergeTree(rev, is_deleted). Two rules hold for every query here,
14
+ // and each builder states how it meets them:
15
+ //
16
+ // 1. EVERY READ FILTERS `is_deleted = 0 AND removed = 0`. FINAL already hides a row whose newest version is
17
+ // a tombstone (measured on 26.7.4.58: FINAL and FINAL + is_deleted = 0 AND removed = 0 return the same
18
+ // 417,087 of 490,427 rows), so under FINAL the predicate is belt and braces; WITHOUT
19
+ // FINAL it is what keeps a tombstone out of a candidate set.
20
+ // 2. FINAL IS PER TABLE, IN THE SQL, NEVER A SESSION SETTING. The 0.18 read layer ran every
21
+ // query with `final=1`, which put FINAL on subqueries that did not want it (the epoch
22
+ // scan) and kept the text indexes off (search). Here each builder says which table it
23
+ // reads FINAL and why; the client that runs them must NOT set `final`.
24
+ //
25
+ // SKIP INDEXES UNDER FINAL. 26.7 enables `use_skip_indexes_if_final` AND
26
+ // `use_skip_indexes_if_final_exact_mode` by default. Exact mode matters: a tombstone is the
27
+ // old KEY at a higher rev, and nothing obliges it to repeat non-key columns. Measured on a
28
+ // key-only call tombstone (tool_name = ''): `calls FINAL WHERE tool_name = 'Bash'` returned
29
+ // 7,404 rows with skip indexes off or exact, and 9,357 with exact mode off — the index
30
+ // skipped the tombstone's granule and the deleted calls came back. So every builder that
31
+ // reads FINAL pins the two settings explicitly instead of trusting a server default:
32
+ // `tools` turns them on (exact), `search` stage 2 turns them off (its key set already prunes).
33
+ //
34
+ // SHAPE. Each builder is pure: it returns `{ query, sql, params, settings, final }` and
35
+ // touches no server, so the unit tests can assert the SQL. The orchestrators at the bottom
36
+ // (`readSession`, `listSessionsPage`, …) take an `exec(spec) -> rows` function, which the
37
+ // dashboard backs with @clickhouse/client and misc/v3-read-check.js with plain HTTP.
38
+
39
+ 'use strict';
40
+
41
+ const EXCLUDED_MODELS = ['', '<synthetic>'];
42
+ const FAR_FUTURE_MS = 7258118400000; // 2200-01-01, an upper bound for open windows
43
+ // A session store's display label, as before layout 3.2: the playbook name for a data folder under
44
+ // ~/.claude-playbooks/<name>, 'default' for any other (convert-v3 rootSQL over harness_path).
45
+ const ROOT_LABEL = (col) => require('./convert-v3').rootSQL(col);
46
+ // A session's identity is its store and id (layout 3.2, sessions-model §1): every read that names,
47
+ // joins or filters a session uses the whole key, so two sessions sharing an id on two hosts, in two
48
+ // stores or as two incarnations are never mixed.
49
+ const SKEY = ['harness', 'host', 'harness_path', 'session_id', 'incarnation'];
50
+ const skeyOf = (a = '') => SKEY.map((c) => `${a}${c}`).join(', ');
51
+ const hasStore = (r) => r && r.host != null && r.harness_path != null;
52
+ /** The store a read names: nested under `store`, or flat on the ref itself (a session ref). */
53
+ const storeOf = (o) => (o && o.store != null ? o.store : o);
54
+ /** host / harness_path / incarnation predicates for a ref that names its store ('' otherwise). */
55
+ function storePred(ref, a = '', p = 'r') {
56
+ // Half a store is refused, never read as no store: a session is its store in every read.
57
+ if (ref && (ref.host != null) !== (ref.harness_path != null)) throw new QueryInputError('a session names its store with both host and harness_path');
58
+ if (!hasStore(ref)) return { sql: '', params: {} };
59
+ return {
60
+ sql: ` AND ${a}host = {${p}_host:String} AND ${a}harness_path = {${p}_hp:String} AND ${a}incarnation = {${p}_inc:String}`,
61
+ params: { [`${p}_host`]: String(ref.host), [`${p}_hp`]: String(ref.harness_path), [`${p}_inc`]: String(ref.incarnation || '') },
62
+ };
63
+ }
64
+
65
+ class QueryInputError extends Error {
66
+ constructor(msg) { super(msg); this.name = 'QueryInputError'; this.status = 400; }
67
+ }
68
+
69
+ /** The member's four conversation tables. `db` qualifies them when the client's default
70
+ * database is not the house (the check script); the dashboard connects with database = house. */
71
+ function v3Rooms(member, db = null) {
72
+ if (!member || !/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) throw new QueryInputError(`'${member}' is not a member name`);
73
+ if (db && !/^[A-Za-z][A-Za-z0-9_]*$/.test(db)) throw new QueryInputError(`'${db}' is not a database name`);
74
+ const q = (t) => `${db ? `${db}.` : ''}${member}_${t}`;
75
+ // meta: where the member's machines record their two host ids (host-alias.js); readers load
76
+ // those into rooms.hostAliases and fold the 0.18 id's rows into the machine's.
77
+ return { member, db, sessions: q('sessions'), parts: q('session_parts'), usage: q('session_usage'), calls: q('calls'), meta: q('meta') };
78
+ }
79
+
80
+ // ── small helpers ─────────────────────────────────────────────────────────────────
81
+
82
+ function clampInt(v, dflt, min, max) {
83
+ if (v === undefined || v === null || v === '') return dflt;
84
+ const n = Number(v);
85
+ if (!Number.isFinite(n)) return dflt;
86
+ return Math.min(Math.max(Math.trunc(n), min), max);
87
+ }
88
+
89
+ /** Opaque keyset cursor: base64url(JSON array). Validated on the way back in. */
90
+ function encodeCursor(parts) { return Buffer.from(JSON.stringify(parts)).toString('base64url'); }
91
+ function decodeCursor(s, shape) {
92
+ if (s === undefined || s === null || s === '') return null;
93
+ let v;
94
+ try { v = JSON.parse(Buffer.from(String(s), 'base64url').toString('utf-8')); } catch { throw new QueryInputError('bad cursor'); }
95
+ if (!Array.isArray(v) || v.length !== shape.length) throw new QueryInputError('bad cursor');
96
+ shape.forEach((t, i) => {
97
+ if (t === 'n' && !Number.isFinite(v[i])) throw new QueryInputError('bad cursor');
98
+ if (t === 's' && typeof v[i] !== 'string') throw new QueryInputError('bad cursor');
99
+ });
100
+ return v;
101
+ }
102
+
103
+ /** LIKE pattern for a literal substring: escape the LIKE metacharacters, then wrap. */
104
+ function likeContains(term) { return `%${String(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`; }
105
+
106
+ const toDay = (ms) => new Date(ms).toISOString().slice(0, 10);
107
+
108
+ // ── the session filter ────────────────────────────────────────────────────────────
109
+
110
+ /**
111
+ * WHERE fragments over `<m>_sessions` (alias optional). Every key is optional:
112
+ *
113
+ * harness exact (or an array: IN) harnessLike substring (legacy `editor`)
114
+ * folder exact folderLike substring (legacy Sessions page)
115
+ * project, host exact hidden folders to exclude
116
+ * from, to ms; a session is IN the window when it was ACTIVE in it:
117
+ * `ended >= from AND started <= to` (see the report: 0.18 used last-updated)
118
+ * named true -> drop placeholders (no name and no messages), as 0.18 did
119
+ * minMessages messages >= n
120
+ * session { harness, session_id } one session
121
+ * keys [{ harness, session_id }] an explicit set
122
+ * latest n: only the n most recently ended sessions matching the rest
123
+ *
124
+ * Returns { sql: ' AND …' (always starts with is_deleted = 0 AND removed = 0), params }.
125
+ */
126
+ function sessionWhere(f = {}, rooms, alias = '') {
127
+ const a = alias ? `${alias}.` : '';
128
+ const parts = [`${a}is_deleted = 0`, `${a}removed = 0`];
129
+ const params = {};
130
+ if (Array.isArray(f.harness) && f.harness.length) { parts.push(`${a}harness IN {f_harnesses:Array(String)}`); params.f_harnesses = f.harness.map(String); }
131
+ else if (f.harness) { parts.push(`${a}harness = {f_harness:String}`); params.f_harness = String(f.harness); }
132
+ if (f.harnessLike) { parts.push(`${a}harness LIKE {f_harness_like:String}`); params.f_harness_like = likeContains(f.harnessLike); }
133
+ if (f.folder) { parts.push(`${a}folder = {f_folder:String}`); params.f_folder = String(f.folder); }
134
+ if (f.folderLike) { parts.push(`${a}folder LIKE {f_folder_like:String}`); params.f_folder_like = likeContains(f.folderLike); }
135
+ if (f.project) { parts.push(`${a}project = {f_project:String}`); params.f_project = String(f.project); }
136
+ // A machine's rows include those under its 0.18 id when the member recorded the pair (host-alias.js).
137
+ if (f.host) { parts.push(`${a}host IN {f_hosts:Array(String)}`); params.f_hosts = require('./host-alias').idsOf(String(f.host), rooms && rooms.hostAliases); }
138
+ if (Array.isArray(f.hidden) && f.hidden.length) { parts.push(`${a}folder NOT IN {f_hidden:Array(String)}`); params.f_hidden = f.hidden.map(String); }
139
+ if (f.from != null) { parts.push(`${a}ended >= fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`); params.f_from = Number(f.from); }
140
+ if (f.to != null) { parts.push(`${a}started <= fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`); params.f_to = Number(f.to); }
141
+ if (f.named) parts.push(`(${a}title != '' OR ${a}messages > 0)`);
142
+ if (f.minMessages != null) { parts.push(`${a}messages >= {f_min_messages:UInt32}`); params.f_min_messages = clampInt(f.minMessages, 0, 0, 4294967295); }
143
+ if (f.session) {
144
+ parts.push(`${a}harness = {f_s_harness:String} AND ${a}session_id = {f_s_id:String}`);
145
+ params.f_s_harness = String(f.session.harness); params.f_s_id = String(f.session.session_id);
146
+ const sp = storePred(f.session, a, 'f_s');
147
+ if (sp.sql) { parts.push(sp.sql.replace(/^ AND /, '')); Object.assign(params, sp.params); }
148
+ }
149
+ if (Array.isArray(f.keys)) {
150
+ // arrayZip, not two INs: the identity is the PAIR, and matching the columns separately
151
+ // would admit (codex, X) because (claude-code, X) was asked for.
152
+ parts.push(`(${a}harness, ${a}session_id) IN arrayZip({f_k_harness:Array(String)}, {f_k_id:Array(String)})`);
153
+ params.f_k_harness = f.keys.map((k) => String(k.harness)); params.f_k_id = f.keys.map((k) => String(k.session_id));
154
+ }
155
+ if (f.latest) {
156
+ if (!rooms) throw new QueryInputError('latest needs the rooms');
157
+ // Its own alias, so no alias of the outer query can capture a column name in here.
158
+ const inner = sessionWhere({ ...f, latest: undefined }, rooms, 'ls');
159
+ parts.push(`(${skeyOf(a)}) IN (SELECT ${skeyOf('ls.')} FROM ${rooms.sessions} AS ls FINAL WHERE ${inner.sql}`
160
+ + ` ORDER BY ls.ended DESC, ls.session_id DESC, ls.harness DESC LIMIT {f_latest:UInt32})`);
161
+ Object.assign(params, inner.params); params.f_latest = clampInt(f.latest, 500, 1, 100000);
162
+ }
163
+ return { sql: parts.join(' AND '), params };
164
+ }
165
+
166
+ // ── listSessions ──────────────────────────────────────────────────────────────────
167
+
168
+ const SESSION_COLUMNS = `
169
+ harness, session_id, incarnation, title AS name, mode, folder, project, host, host_label, harness_path, ${ROOT_LABEL('harness_path')} AS root, git_branch, harness_version, origin,
170
+ toUnixTimestamp64Milli(created_at) AS created_ms, toUnixTimestamp64Milli(updated_at) AS updated_ms,
171
+ toUnixTimestamp64Milli(started) AS started_ms, toUnixTimestamp64Milli(ended) AS ended_ms,
172
+ messages, user_messages, assistant_messages, agent_messages, tool_calls,
173
+ input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens,
174
+ user_chars, assistant_chars, first_prompt, models, dominant_model,
175
+ toString(rev) AS rev`;
176
+
177
+ // Each session priced from its OWN usage (§6: no P1–P5 chunks). sumMap folds the per-hour
178
+ // rows into one entry per model, so a 200-hour session sends a handful of numbers, not 200.
179
+ const USAGE_BY_MODEL = `
180
+ arrayReduce('sumMap', [usage.model], [arrayMap(x -> toUInt64(x), usage.messages)], [usage.input], [usage.output],
181
+ [usage.cache_read], [usage.cache_write], [usage.reasoning]) AS usage_by_model`;
182
+
183
+ const SORTS = {
184
+ ended: { col: 'ended', cur: (v) => `fromUnixTimestamp64Milli(${v}, 'UTC')`, type: 'Int64', of: (r) => Number(r.ended_ms) },
185
+ started: { col: 'started', cur: (v) => `fromUnixTimestamp64Milli(${v}, 'UTC')`, type: 'Int64', of: (r) => Number(r.started_ms) },
186
+ messages: { col: 'messages', cur: (v) => v, type: 'UInt32', of: (r) => Number(r.messages) },
187
+ };
188
+
189
+ /**
190
+ * listSessions — `sessions FINAL` (≤ one row per session; the table is small, so a top-N
191
+ * over it is a full read of a few thousand rows, which is the point of the table).
192
+ *
193
+ * Keyset pagination on (sort, session_id, harness), descending by default: the cursor is the
194
+ * last row's triple, and the next page is everything strictly after it. Total order, so no
195
+ * page repeats or skips a row (the 0.18 ORDER BY ts, LIMIT/OFFSET did, on ties).
196
+ *
197
+ * opts: { filters, sort: 'ended'|'started'|'messages', dir: 'desc'|'asc', limit, cursor, withUsage }
198
+ * The caller asks for limit+1 rows (done here) so `finish` can say whether a next page exists.
199
+ */
200
+ function listSessions(rooms, opts = {}) {
201
+ const sort = SORTS[opts.sort || 'ended'];
202
+ if (!sort) throw new QueryInputError(`sort must be one of ${Object.keys(SORTS).join(', ')}`);
203
+ const dir = opts.dir === 'asc' ? 'ASC' : 'DESC';
204
+ const limit = clampInt(opts.limit, 50, 1, 5000);
205
+ const w = sessionWhere(opts.filters, rooms);
206
+ const params = { ...w.params, limit: limit + 1 };
207
+ let keyset = '';
208
+ const cur = decodeCursor(opts.cursor, ['n', 's', 's']);
209
+ if (cur) {
210
+ keyset = ` AND (${sort.col}, session_id, harness) ${dir === 'DESC' ? '<' : '>'} (${sort.cur(`{c_v:${sort.type}}`)}, {c_s:String}, {c_h:String})`;
211
+ Object.assign(params, { c_v: cur[0], c_s: cur[1], c_h: cur[2] });
212
+ }
213
+ const sql = `SELECT ${SESSION_COLUMNS}${opts.withUsage ? `,${USAGE_BY_MODEL}` : ''}
214
+ FROM ${rooms.sessions} FINAL
215
+ WHERE ${w.sql}${keyset}
216
+ ORDER BY ${sort.col} ${dir}, session_id ${dir}, harness ${dir}
217
+ LIMIT {limit:UInt32}`;
218
+ return {
219
+ query: 'listSessions', final: 'sessions', sql, params, settings: {},
220
+ finish(rows) {
221
+ const more = rows.length > limit;
222
+ const page = more ? rows.slice(0, limit) : rows;
223
+ const last = page[page.length - 1];
224
+ return { rows: page, nextCursor: more && last ? encodeCursor([sort.of(last), last.session_id, last.harness]) : null };
225
+ },
226
+ };
227
+ }
228
+
229
+ /** The count that goes with a listSessions filter. `sessions FINAL`, one number. */
230
+ function countSessions(rooms, filters = {}) {
231
+ const w = sessionWhere(filters, rooms);
232
+ return {
233
+ query: 'countSessions', final: 'sessions', settings: {}, params: w.params,
234
+ sql: `SELECT count() AS n FROM ${rooms.sessions} FINAL WHERE ${w.sql}`,
235
+ finish: (rows) => Number(rows[0] ? rows[0].n : 0),
236
+ };
237
+ }
238
+
239
+ // ── readSession ───────────────────────────────────────────────────────────────────
240
+
241
+ /** Step 1: the header — a point read on the sessions key. `sessions FINAL`. */
242
+ function sessionHeader(rooms, ref) {
243
+ const { harness, session_id } = ref || {};
244
+ if (!harness || !session_id) throw new QueryInputError('harness and session_id are required');
245
+ const sp = storePred(ref);
246
+ return {
247
+ query: 'sessionHeader', final: 'sessions', settings: {},
248
+ params: { h: String(harness), s: String(session_id), ...sp.params },
249
+ sql: `SELECT ${SESSION_COLUMNS}, days,
250
+ agents.agent_id AS agent_ids, agents.type AS agent_types, agents.description AS agent_descriptions,
251
+ agents.workflow AS agent_workflows, agents.messages AS agent_message_counts,
252
+ arrayMap(x -> toUnixTimestamp64Milli(x), agents.first_ts) AS agent_first_ms,
253
+ tools.name AS tool_names, tools.calls AS tool_call_counts, tools.errors AS tool_error_counts
254
+ FROM ${rooms.sessions} FINAL
255
+ WHERE harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0
256
+ ORDER BY ended DESC, host, harness_path, incarnation
257
+ LIMIT 1`,
258
+ finish(rows) {
259
+ const r = rows[0];
260
+ if (!r) return null;
261
+ const agents = (r.agent_ids || []).map((id, i) => ({
262
+ agent_id: id, type: r.agent_types[i], description: r.agent_descriptions[i], workflow: r.agent_workflows[i],
263
+ messages: Number(r.agent_message_counts[i]), first_ms: Number(r.agent_first_ms[i]),
264
+ }));
265
+ const tools = (r.tool_names || []).map((name, i) => ({ name, calls: Number(r.tool_call_counts[i]), errors: Number(r.tool_error_counts[i]) }));
266
+ for (const k of ['agent_ids', 'agent_types', 'agent_descriptions', 'agent_workflows', 'agent_message_counts', 'agent_first_ms', 'tool_names', 'tool_call_counts', 'tool_error_counts']) delete r[k];
267
+ return { ...r, agents, tools };
268
+ },
269
+ };
270
+ }
271
+
272
+ // The session's order: the adapter's order_key, then the message and the part (sessions-model §2).
273
+ const PART_ORDER = ['order_key', 'message_id', 'part_index'];
274
+ // What a read of parts returns per row (partsPage, and q-cli's window).
275
+ const PART_SELECT = `agent_id, message_id, part_index AS part, part_id, parent_id, response_id, order_key,
276
+ toUnixTimestamp64Milli(ts) AS ts_ms, role, kind, state, status, if(served_model != '', served_model, requested_model) AS model, effort,
277
+ text, text_len, call_id, tool_name, args, output, output_len, is_error, error_kind, error_message, origin`;
278
+
279
+ /** The days of `days` that can hold rows in [lowerMs, upperMs). Strings 'YYYY-MM-DD'. */
280
+ function daysWithin(days, lowerMs, upperMs) {
281
+ const lo = lowerMs == null ? null : toDay(lowerMs);
282
+ const hi = upperMs == null ? null : toDay(upperMs);
283
+ return (days || []).map(String).filter((d) => (lo === null || d >= lo) && (hi === null || d <= hi));
284
+ }
285
+
286
+ /**
287
+ * Step 2: one page of a session's parts. `session_parts FINAL` on the leading key columns (the
288
+ * session), in the session's order (PART_ORDER), which is total, so a keyset cursor can stand on
289
+ * any row. `dir: 'backward'` reads the page BEFORE the cursor (or the tail when there is none):
290
+ * what a sidebar wants.
291
+ */
292
+ function partsPage(rooms, opts) {
293
+ const { harness, session_id, cursor = null, dir = 'forward', limit = 500, agent_id = null } = opts;
294
+ const lim = clampInt(limit, 500, 1, 5000);
295
+ const back = dir === 'backward';
296
+ const cur = Array.isArray(cursor) ? cursor : decodeCursor(cursor, ['s', 's', 'n']);
297
+ const params = { h: String(harness), s: String(session_id), limit: lim + 1 };
298
+ const sp = storePred(storeOf(opts));
299
+ let where = `harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0`;
300
+ Object.assign(params, sp.params);
301
+ if (agent_id !== null && agent_id !== undefined) { where += ' AND agent_id = {agent:String}'; params.agent = String(agent_id); }
302
+ if (cur) {
303
+ where += ` AND (order_key, message_id, part_index) ${back ? '<' : '>'} ({c_ok:String}, {c_mid:String}, {c_part:UInt32})`;
304
+ Object.assign(params, { c_ok: String(cur[0]), c_mid: String(cur[1]), c_part: Number(cur[2]) });
305
+ }
306
+ const d = back ? 'DESC' : 'ASC';
307
+ return {
308
+ query: 'partsPage', final: 'session_parts', settings: {}, params,
309
+ sql: `SELECT ${PART_SELECT}
310
+ FROM ${rooms.parts} FINAL
311
+ WHERE ${where}
312
+ ORDER BY ${PART_ORDER.map((c) => `${c} ${d}`).join(', ')}
313
+ LIMIT {limit:UInt32}`,
314
+ };
315
+ }
316
+
317
+ /**
318
+ * Step 3: the calls of the messages that START on a page (their part 0 is on it), so consecutive
319
+ * pages partition the session's calls exactly. `calls FINAL` within the session.
320
+ */
321
+ function callsOf(rooms, opts) {
322
+ const { harness, session_id, message_ids } = opts;
323
+ const params = { h: String(harness), s: String(session_id), mids: (message_ids || []).map(String) };
324
+ const sp = storePred(storeOf(opts));
325
+ Object.assign(params, sp.params);
326
+ return {
327
+ query: 'callsOf', final: 'calls', settings: {}, params,
328
+ sql: `SELECT agent_id, call_id, message_id, toUnixTimestamp64Milli(ts) AS ts_ms, tool_name, args,
329
+ toUnixTimestamp64Milli(result_ts) AS result_ts_ms, is_error, output_len AS result_len, model
330
+ FROM ${rooms.calls} FINAL
331
+ WHERE harness = {h:String} AND session_id = {s:String}${sp.sql} AND message_id IN {mids:Array(String)} AND is_deleted = 0 AND removed = 0
332
+ ORDER BY ts, call_id`,
333
+ empty: params.mids.length === 0,
334
+ };
335
+ }
336
+
337
+ /**
338
+ * Step 4: the usage rows of a page: those anchored on its messages, and on its responses. Each row
339
+ * names its `target`, the message it is shown on: its own message, or a response's first message
340
+ * in the session's order (so a response's usage is shown once, on the page that starts it).
341
+ */
342
+ function usageOf(rooms, opts) {
343
+ const { harness, session_id, message_ids, response_ids } = opts;
344
+ const params = { h: String(harness), s: String(session_id), mids: (message_ids || []).map(String), rids: (response_ids || []).map(String) };
345
+ const sp = storePred(storeOf(opts));
346
+ Object.assign(params, sp.params);
347
+ const session = `harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0`;
348
+ return {
349
+ query: 'usageOf', final: 'session_usage, session_parts', settings: {}, params,
350
+ sql: `SELECT u.usage_id AS usage_id, u.anchor_kind AS anchor_kind, u.anchor_id AS anchor_id, u.input AS input, u.output AS output,
351
+ u.cache_read AS cache_read, u.cache_write AS cache_write, u.reasoning AS reasoning, u.tool_prompt AS tool_prompt,
352
+ u.input_includes_cache AS input_includes_cache, u.output_includes_reasoning AS output_includes_reasoning, u.granularity AS granularity,
353
+ u.model AS model, if(u.anchor_kind = 'response', h.head, u.anchor_id) AS target
354
+ FROM (
355
+ SELECT *, toString(anchor_kind) AS ak, if(ak = 'response', anchor_id, '') AS rkey FROM ${rooms.usage} FINAL
356
+ WHERE ${session} AND ((ak = 'message' AND anchor_id IN {mids:Array(String)}) OR (ak = 'response' AND anchor_id IN {rids:Array(String)}))
357
+ ) AS u
358
+ LEFT JOIN (
359
+ SELECT response_id AS hr, argMin(message_id, (order_key, message_id)) AS head FROM ${rooms.parts} FINAL
360
+ WHERE ${session} AND response_id IN {rids:Array(String)}
361
+ GROUP BY hr
362
+ ) AS h ON h.hr = u.rkey`,
363
+ empty: params.mids.length === 0,
364
+ };
365
+ }
366
+
367
+ /** A usage row in the house split (sessions-model §2.4; profile-rollup.js applies the same). */
368
+ function houseTokens(u) {
369
+ const n = (x) => (x == null ? 0 : Number(x));
370
+ return {
371
+ input_tokens: Math.max(u.input_includes_cache === true ? n(u.input) - n(u.cache_read) - n(u.cache_write) : n(u.input), 0) + n(u.tool_prompt),
372
+ output_tokens: n(u.output) + (u.output_includes_reasoning === false ? n(u.reasoning) : 0),
373
+ cache_read_tokens: n(u.cache_read), cache_write_tokens: n(u.cache_write), reasoning_tokens: n(u.reasoning),
374
+ };
375
+ }
376
+ const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'];
377
+ /**
378
+ * Put the page's usage on its rows: every row gets the token fields (0 by default), and a usage
379
+ * row's tokens go on the first part (part 0) of its target message when that part is on the page. A
380
+ * per_record response counts once, from its record with the greatest n (harness-sessions-sql §4.4).
381
+ */
382
+ function attachUsage(page, usage) {
383
+ for (const r of page) for (const k of TOKEN_FIELDS) r[k] = 0;
384
+ const nOf = (u) => Number(String(u.usage_id).split('#').pop()) || 0;
385
+ const last = new Map();
386
+ for (const u of usage) if (u.anchor_kind === 'response') last.set(u.anchor_id, Math.max(last.has(u.anchor_id) ? last.get(u.anchor_id) : -1, nOf(u)));
387
+ const head = new Map();
388
+ for (const r of page) if (Number(r.part) === 0 && !head.has(r.message_id)) head.set(r.message_id, r);
389
+ for (const u of usage) {
390
+ if (u.granularity === 'per_record' && u.anchor_kind === 'response' && nOf(u) !== last.get(u.anchor_id)) continue;
391
+ const r = head.get(u.target);
392
+ if (!r) continue;
393
+ const t = houseTokens(u);
394
+ for (const k of TOKEN_FIELDS) r[k] += t[k];
395
+ }
396
+ return page;
397
+ }
398
+
399
+ /**
400
+ * readSession(page) — the header, a page of parts, the calls of the messages that start on the
401
+ * page, and its usage. The page and the header are key reads; calls and usage are read within the
402
+ * session for the page's messages only.
403
+ *
404
+ * Returns null when the session does not exist or is deleted.
405
+ */
406
+ async function readSession(exec, rooms, { harness, session_id, host = null, harness_path = null, incarnation = null, cursor = null, dir = 'forward', limit = 500, agent_id = null }) {
407
+ const header = await run(exec, sessionHeader(rooms, { harness, session_id, host, harness_path, incarnation }));
408
+ if (!header) return null;
409
+ const back = dir === 'backward';
410
+ const lim = clampInt(limit, 500, 1, 5000);
411
+ const cur = decodeCursor(cursor, ['s', 's', 'n']);
412
+ // The header names the store; parts, calls and usage are read from that store only.
413
+ const store = { host: header.host, harness_path: header.harness_path, incarnation: header.incarnation };
414
+ const raw = await exec(partsPage(rooms, { harness, session_id, cursor: cur, dir, limit: lim, agent_id, store }));
415
+ const more = raw.length > lim;
416
+ const page = more ? raw.slice(0, lim) : raw.slice();
417
+ if (back) page.reverse();
418
+ const starts = [...new Set(page.filter((r) => Number(r.part) === 0).map((r) => r.message_id))];
419
+ const responses = [...new Set(page.map((r) => r.response_id).filter(Boolean))];
420
+ const calls = await run(exec, callsOf(rooms, { harness, session_id, store, message_ids: starts }));
421
+ attachUsage(page, await run(exec, usageOf(rooms, { harness, session_id, store, message_ids: starts, response_ids: responses })));
422
+ const key = (r) => encodeCursor([r.order_key, r.message_id, Number(r.part)]);
423
+ return {
424
+ header,
425
+ turns: page,
426
+ calls,
427
+ // forward: the next page continues after the last row; backward: the previous page ends
428
+ // before the first row. Either way the cursor is a row's full order key.
429
+ nextCursor: !back && more && page.length ? key(page[page.length - 1]) : null,
430
+ prevCursor: back && more && page.length ? key(page[0]) : null,
431
+ };
432
+ }
433
+
434
+ // ── sessionVersion ────────────────────────────────────────────────────────────────
435
+
436
+ /**
437
+ * The sidebar poll (§6): one point read of `sessions.rev`. `sessions FINAL` on the full
438
+ * sorting key. `rev = Σ files.ver` rises whenever any file of the session changes, so an
439
+ * unchanged rev means an unchanged session — the client refetches turns only when it moves.
440
+ */
441
+ function sessionVersion(rooms, ref) {
442
+ const { harness, session_id } = ref || {};
443
+ if (!harness || !session_id) throw new QueryInputError('harness and session_id are required');
444
+ const sp = storePred(ref);
445
+ return {
446
+ query: 'sessionVersion', final: 'sessions', settings: {},
447
+ params: { h: String(harness), s: String(session_id), ...sp.params },
448
+ sql: `SELECT toString(rev) AS rev, toUnixTimestamp64Milli(ended) AS ended_ms, messages
449
+ FROM ${rooms.sessions} FINAL
450
+ WHERE harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0
451
+ ORDER BY ended DESC, host, harness_path, incarnation
452
+ LIMIT 1`,
453
+ finish: (rows) => (rows[0] ? { rev: rows[0].rev, ended_ms: Number(rows[0].ended_ms), messages: Number(rows[0].messages) } : null),
454
+ };
455
+ }
456
+
457
+ /**
458
+ * The house-wide token for Live: changes when any session changes, is added or is dropped.
459
+ * `sessions FINAL` — a full read of the small table; stable across merges (a raw count is not).
460
+ */
461
+ function houseVersion(rooms) {
462
+ return {
463
+ query: 'houseVersion', final: 'sessions', settings: {}, params: {},
464
+ sql: `SELECT count() AS n, toString(sum(rev)) AS rev_sum, toUnixTimestamp64Milli(max(ended)) AS last_ms
465
+ FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0`,
466
+ finish: (rows) => {
467
+ const r = rows[0] || { n: 0, rev_sum: '0', last_ms: null };
468
+ return { token: `${r.n}:${r.rev_sum}`, sessions: Number(r.n), last_ms: r.last_ms == null ? null : Number(r.last_ms) };
469
+ },
470
+ };
471
+ }
472
+
473
+ // ── search ────────────────────────────────────────────────────────────────────────
474
+
475
+ /**
476
+ * Split a search string into terms: whitespace-separated, "quoted phrases" kept whole,
477
+ * lowercased (the indexed columns are lower(text)), deduplicated, at most 8.
478
+ */
479
+ function searchTerms(q) {
480
+ const out = [];
481
+ const re = /"([^"]+)"|(\S+)/g;
482
+ let m;
483
+ while ((m = re.exec(String(q || ''))) !== null) {
484
+ const t = (m[1] || m[2]).toLowerCase().trim();
485
+ if (t && !out.includes(t)) out.push(t);
486
+ }
487
+ if (!out.length) throw new QueryInputError('search needs at least one term');
488
+ if (out.length > 8) throw new QueryInputError('at most 8 search terms');
489
+ return out;
490
+ }
491
+
492
+ // What the indexed copies hold: a part's text and a tool result's output, lowercased.
493
+ const BODY = "lower(concat(text, '\\n', output))";
494
+ // A part's time for "newest" without nulls (ts is null where the source gave none), then its order.
495
+ const AT = "(ifNull(ts, toDateTime64(0, 3, 'UTC')), order_key)";
496
+
497
+ // One token for the splitByNonAlpha tokenizer: ASCII alphanumerics, or any non-ASCII byte.
498
+ const SINGLE_TOKEN = /^(?:[a-z0-9]|[^\x00-\x7f])+$/;
499
+
500
+ /**
501
+ * The predicate for one term, and which index it can use. Substring semantics for every
502
+ * term (what 0.18's search and /mem:recall mean by a match):
503
+ *
504
+ * single token, ≥ 4 chars `search_word LIKE '%t%'` the word index answers it by scanning its
505
+ * token dictionary (26.7: use_text_index_like_evaluation_by_dictionary_scan,
506
+ * min pattern length 4) — measured 5 ms vs 182 ms on the ngram index
507
+ * anything else (≥ 3) `search_ngram LIKE '%t%'` the ngram index (phrases, 'a-b', 3 chars)
508
+ * 1–2 chars the same predicate, no index; still exact
509
+ *
510
+ * `match: 'word'` asks for whole tokens instead: `hasToken(search_word, t)` (single-token terms only).
511
+ */
512
+ function termPredicate(term, i, match) {
513
+ const p = `t${i}`;
514
+ // stage1 uses the indexed copies; stage2 re-checks the SAME predicate on what both copies hold
515
+ // (MATERIALIZED lower(concat(text, '\n', output)): a part's text and a tool result's output), so
516
+ // stage 2 reads the two columns once instead of a stored copy per term.
517
+ if (match === 'word') {
518
+ if (!SINGLE_TOKEN.test(term)) throw new QueryInputError(`'${term}' is not a single word — use substring search`);
519
+ return { stage1: `hasToken(search_word, {${p}:String})`, stage2: `hasToken(${BODY}, {${p}:String})`, params: { [p]: term }, index: 'idx_search_word' };
520
+ }
521
+ const like = { [p]: likeContains(term) };
522
+ if (SINGLE_TOKEN.test(term) && term.length >= 4) {
523
+ return { stage1: `search_word LIKE {${p}:String}`, stage2: `${BODY} LIKE {${p}:String}`, params: like, index: 'idx_search_word' };
524
+ }
525
+ return { stage1: `search_ngram LIKE {${p}:String}`, stage2: `${BODY} LIKE {${p}:String}`, params: like, index: term.length >= 3 ? 'idx_search_ngram' : null };
526
+ }
527
+
528
+ /**
529
+ * search — rewritten 2026-10-16 after measuring where its time went on a real house. The text
530
+ * index was never the cost: it keeps exactly the granules that hold a term (10–33 ms, < 1 MiB).
531
+ * The cost was reading those granules three times — a session restriction, the candidate keys
532
+ * (which ran the restriction again), and a FINAL re-check with skip indexes OFF that read `text`
533
+ * for every row of every candidate granule ('replacingmergetree': 366 ms, 1.03M rows, 193 MiB).
534
+ *
535
+ * Skip indexes were off under FINAL because, without exact mode, a text index can skip the granule
536
+ * of a newer revision that lost the term, and FINAL would then keep the old matching version — a
537
+ * false hit. `use_skip_indexes_if_final_exact_mode` (on by default in 26.7) closes that: the ranges
538
+ * the index keeps are widened to every range that intersects them in primary key, so FINAL sees
539
+ * each key's newest version. So the whole search is now ONE FINAL pass with the index on:
540
+ *
541
+ * hits (MATERIALIZED: one row per session, at most `limit`) turns FINAL where every term
542
+ * matches, is_deleted = 0 AND removed = 0, live sessions only, grouped per session, newest hit first.
543
+ * snippet `text` read for those `limit` newest hits only, by full sorting key.
544
+ *
545
+ * Measured on the dev house: identical results to the two-stage statement on every term tried,
546
+ * 46–102 ms against 41–203 ms. A session's hit count is now every live hit, not the hits among
547
+ * the first 50,000 candidates. Servers without materialized CTEs or exact mode run the previous
548
+ * statement (searchTwoStage, below) via `fallback`.
549
+ *
550
+ * opts: { q, match: 'substring'|'word', filters: { harness, folder, project, from, to }, limit }
551
+ */
552
+ function search(rooms, opts = {}) {
553
+ const terms = searchTerms(opts.q);
554
+ const limit = clampInt(opts.limit, 20, 1, 200);
555
+ const preds = terms.map((t, i) => termPredicate(t, i, opts.match));
556
+ const params = Object.assign({ limit, snip: terms[0] }, ...preds.map((p) => p.params));
557
+ const f = opts.filters || {};
558
+ const scope = [];
559
+ if (f.harness) { scope.push('harness = {f_harness:String}'); params.f_harness = String(f.harness); }
560
+ if (f.folder) { scope.push('folder = {f_folder:String}'); params.f_folder = String(f.folder); }
561
+ if (f.project) { scope.push('project = {f_project:String}'); params.f_project = String(f.project); }
562
+ if (f.from != null) { scope.push(tsBound('>=', `fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`)); params.f_from = Number(f.from); }
563
+ if (f.to != null) { scope.push(tsBound('<=', `fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`)); params.f_to = Number(f.to); }
564
+ // Every term on the indexed copies; under FINAL they are evaluated on each key's newest version.
565
+ const match = preds.map((p) => p.stage1).join(' AND ');
566
+ const scopeSql = scope.length ? ` AND ${scope.join(' AND ')}` : '';
567
+ const sql = `WITH hits AS MATERIALIZED (
568
+ SELECT harness AS ah, host AS ahost, harness_path AS ahp, session_id AS asid, incarnation AS ainc, count() AS hits, min(ts) AS first_hit, max(ts) AS last_hit,
569
+ argMax(message_id, ${AT}) AS hit_message_id, argMax(part_id, ${AT}) AS hit_part,
570
+ argMax(agent_id, ${AT}) AS hit_agent_id, argMax(role, ${AT}) AS hit_role
571
+ FROM ${rooms.parts} FINAL
572
+ WHERE ${match} AND is_deleted = 0 AND removed = 0${scopeSql}
573
+ AND (${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0)
574
+ GROUP BY ah, ahost, ahp, asid, ainc
575
+ ORDER BY last_hit DESC, ah, asid, ahost, ahp, ainc
576
+ LIMIT {limit:UInt32}
577
+ )
578
+ SELECT a.ah AS harness, a.asid AS session_id, a.ahost AS host, a.ahp AS harness_path, a.ainc AS incarnation,
579
+ s.title AS name, s.folder AS folder, s.project AS project, toUnixTimestamp64Milli(s.ended) AS ended_ms,
580
+ a.hits AS hits, toUnixTimestamp64Milli(a.first_hit) AS first_hit_ms, toUnixTimestamp64Milli(a.last_hit) AS last_hit_ms,
581
+ a.hit_message_id AS message_id, a.hit_agent_id AS agent_id, a.hit_role AS role, n.snippet AS snippet
582
+ FROM hits AS a
583
+ INNER JOIN (
584
+ SELECT harness AS sh, host AS shost, harness_path AS shp, session_id AS ss, incarnation AS sinc, title, folder, project, ended
585
+ FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0
586
+ ) AS s ON s.sh = a.ah AND s.shost = a.ahost AND s.shp = a.ahp AND s.ss = a.asid AND s.sinc = a.ainc
587
+ LEFT JOIN (
588
+ SELECT harness AS nh, host AS nhost, harness_path AS nhp, session_id AS ns, incarnation AS ninc,
589
+ argMax(substring(body, greatest(1, toInt64(positionCaseInsensitiveUTF8(body, {snip:String})) - 80), 240), rev) AS snippet
590
+ FROM (SELECT harness, host, harness_path, session_id, incarnation, rev, concat(text, '\\n', output) AS body FROM ${rooms.parts}
591
+ WHERE (host, harness, harness_path, session_id, incarnation, message_id, part_id)
592
+ IN (SELECT ahost, ah, ahp, asid, ainc, hit_message_id, hit_part FROM hits))
593
+ GROUP BY nh, nhost, nhp, ns, ninc
594
+ ) AS n ON n.nh = a.ah AND n.nhost = a.ahost AND n.nhp = a.ahp AND n.ns = a.asid AND n.ninc = a.ainc
595
+ ORDER BY last_hit_ms DESC, harness, session_id, host, harness_path, incarnation
596
+ LIMIT {limit:UInt32}`;
597
+ return {
598
+ query: 'search', final: 'session_parts FINAL (skip indexes in exact mode), sessions FINAL; snippet by argMax(rev)', sql, params,
599
+ settings: { enable_materialized_cte: 1, use_skip_indexes_if_final: 1, use_skip_indexes_if_final_exact_mode: 1 },
600
+ terms, indexes: preds.map((p) => p.index),
601
+ // A server without materialized CTEs or exact mode runs the previous two-stage statement.
602
+ fallback: searchTwoStage(rooms, opts),
603
+ };
604
+ }
605
+
606
+ /**
607
+ * searchTwoStage — the previous search (the fallback): the two stages of §6, in ONE statement so it costs one round trip:
608
+ *
609
+ * stage 1 `turns` WITHOUT FINAL: the text indexes prune, is_deleted = 0 AND removed = 0 drops tombstones,
610
+ * and the matching rows of the newest 3×limit matching SESSIONS (at most `cap`)
611
+ * give their full sorting keys — candidates only, since an old revision may match
612
+ * while its replacement does not. Restricting to the sessions the answer can hold
613
+ * bounds stage 2 by the size of the answer, not by how widely a term is spread:
614
+ * a rare term in 40% of granules read 250 MiB / 271 ms unrestricted, 65 MiB / 121 ms
615
+ * restricted (synthetic house, 2,000 sessions). The index scan runs twice; with the
616
+ * word index each run is ~5–15 ms.
617
+ * stage 2 `turns FINAL` on exactly those keys (the tuple is the whole ORDER BY, so the set
618
+ * is a primary-key lookup), is_deleted = 0 AND removed = 0, and EVERY term re-checked on the newest
619
+ * version. Skip indexes are switched OFF for this FINAL: with exact mode off, a
620
+ * text index would skip the granule of a new revision that lost the term and FINAL
621
+ * would keep the old one — the very false hit stage 2 exists to remove.
622
+ *
623
+ * Hits are grouped per session and joined to live sessions (`sessions FINAL`, is_deleted = 0 AND removed = 0),
624
+ * newest hit first. Measured on the synthetic house: 'platypus' matches 4,163 raw rows and
625
+ * 208 live ones; stage 2 is what tells them apart.
626
+ *
627
+ * Completeness: a session whose every raw hit is superseded still takes one of the 3×limit
628
+ * slots and then drops out in stage 2, so a page can hold fewer than `limit` sessions when
629
+ * more exist. Never a false hit; the over-fetch makes a short page rare.
630
+ *
631
+ * opts: { q, match: 'substring'|'word', filters: { harness, folder, project, from, to }, limit, cap }
632
+ */
633
+ function searchTwoStage(rooms, opts = {}) {
634
+ const terms = searchTerms(opts.q);
635
+ const limit = clampInt(opts.limit, 20, 1, 200);
636
+ // The row cap is a safety bound only: stage 2 is bounded by the session restriction. A low
637
+ // cap lost live hits — superseded versions crowd the newest-first candidate list (measured:
638
+ // 'platypus', 3,955 stale rows + 208 live; a 2,000 cap returned 100 of the 208).
639
+ const cap = clampInt(opts.cap, 50000, 1, 200000);
640
+ const preds = terms.map((t, i) => termPredicate(t, i, opts.match));
641
+ const params = Object.assign({ limit, cap, scap: Math.min(limit * 3, 600), snip: terms[0] }, ...preds.map((p) => p.params));
642
+ const f = opts.filters || {};
643
+ const scope = [];
644
+ if (f.harness) { scope.push('harness = {f_harness:String}'); params.f_harness = String(f.harness); }
645
+ if (f.folder) { scope.push('folder = {f_folder:String}'); params.f_folder = String(f.folder); }
646
+ if (f.project) { scope.push('project = {f_project:String}'); params.f_project = String(f.project); }
647
+ if (f.from != null) { scope.push(tsBound('>=', `fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`)); params.f_from = Number(f.from); }
648
+ if (f.to != null) { scope.push(tsBound('<=', `fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`)); params.f_to = Number(f.to); }
649
+ const stage1 = preds.map((p) => p.stage1).join(' AND ');
650
+ const stage2 = preds.map((p) => p.stage2).join(' AND ');
651
+ const scopeSql = scope.length ? ` AND ${scope.join(' AND ')}` : '';
652
+ const sql = `SELECT h.harness AS harness, h.session_id AS session_id, h.host AS host, h.harness_path AS harness_path, h.incarnation AS incarnation,
653
+ s.title AS name, s.folder AS folder, s.project AS project, toUnixTimestamp64Milli(s.ended) AS ended_ms,
654
+ h.hits AS hits, toUnixTimestamp64Milli(h.first_hit) AS first_hit_ms, toUnixTimestamp64Milli(h.last_hit) AS last_hit_ms,
655
+ h.hit_message_id AS message_id, h.hit_agent_id AS agent_id, h.hit_role AS role, h.snippet AS snippet
656
+ FROM (
657
+ SELECT ${skeyOf()}, count() AS hits, min(ts) AS first_hit, max(ts) AS last_hit,
658
+ -- aliases must not reuse column names: ClickHouse aliases are query-global, and
659
+ -- argMax(message_id, ts) AS message_id would replace the column in the WHERE below
660
+ argMax(message_id, ${AT}) AS hit_message_id, argMax(agent_id, ${AT}) AS hit_agent_id, argMax(role, ${AT}) AS hit_role,
661
+ argMax(substring(concat(text, '\\n', output), greatest(1, toInt64(positionCaseInsensitiveUTF8(concat(text, '\\n', output), {snip:String})) - 80), 240), ${AT}) AS snippet
662
+ FROM ${rooms.parts} FINAL
663
+ WHERE (host, harness, harness_path, session_id, incarnation, message_id, part_id) IN (
664
+ SELECT host, harness, harness_path, session_id, incarnation, message_id, part_id
665
+ FROM ${rooms.parts}
666
+ WHERE ${stage1} AND is_deleted = 0 AND removed = 0${scopeSql}
667
+ AND (${skeyOf()}) IN (
668
+ SELECT ${skeyOf()} FROM ${rooms.parts}
669
+ WHERE ${stage1} AND is_deleted = 0 AND removed = 0${scopeSql}
670
+ AND (${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0)
671
+ GROUP BY ${skeyOf()}
672
+ ORDER BY max(${AT}) DESC
673
+ LIMIT {scap:UInt32})
674
+ ORDER BY ${AT} DESC
675
+ LIMIT {cap:UInt32})
676
+ AND is_deleted = 0 AND removed = 0 AND ${stage2}
677
+ GROUP BY ${skeyOf()}
678
+ ) AS h
679
+ INNER JOIN (
680
+ SELECT ${skeyOf()}, title, folder, project, ended FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0
681
+ ) AS s ON s.harness = h.harness AND s.host = h.host AND s.harness_path = h.harness_path AND s.session_id = h.session_id AND s.incarnation = h.incarnation
682
+ ORDER BY last_hit_ms DESC, harness, session_id, host, harness_path, incarnation
683
+ LIMIT {limit:UInt32}`;
684
+ return {
685
+ query: 'search', final: 'session_parts (stage 2), sessions', sql, params,
686
+ settings: { use_skip_indexes_if_final: 0 },
687
+ terms, indexes: preds.map((p) => p.index),
688
+ };
689
+ }
690
+
691
+ // ── aggregate ─────────────────────────────────────────────────────────────────────
692
+
693
+ // Dimensions. `s` = the session row, `u` = one usage entry (ARRAY JOIN s.usage AS u), `t` = one
694
+ // tools entry (ARRAY JOIN s.tools AS t). Session-grain time dims bucket by `started` (§3: session
695
+ // counts keep counting by start); usage-grain time dims bucket by the usage hour.
696
+ //
697
+ // EVERY column is qualified (`s.messages`, never `messages`). ClickHouse aliases are global to
698
+ // the query: with a metric `sum(messages) AS messages` in the SELECT, a bare `messages` anywhere
699
+ // else — another metric, the depth dimension, the WHERE of the `named` filter — resolves to the
700
+ // ALIAS, and the query fails with "aggregate function found inside another aggregate function".
701
+ // A qualified name always means the column.
702
+ const SESSION_DIMS = {
703
+ harness: 's.harness', mode: 's.mode', folder: 's.folder', project: 's.project', host: 's.host', root: ROOT_LABEL('s.harness_path'), harness_path: 's.harness_path',
704
+ origin: 's.origin', dominant_model: 's.dominant_model',
705
+ day: "formatDateTime(s.started, '%Y-%m-%d', {tz:String})",
706
+ hour: 'toHour(s.started, {tz:String})',
707
+ dow: 'toDayOfWeek(s.started, 0, {tz:String}) % 7',
708
+ month: "formatDateTime(s.started, '%Y-%m', {tz:String})",
709
+ end_month: "formatDateTime(s.ended, '%Y-%m', {tz:String})",
710
+ depth: "multiIf(s.messages <= 1, '1', s.messages <= 5, '2-5', s.messages <= 10, '6-10', s.messages <= 20, '11-20', s.messages <= 50, '21-50', s.messages <= 100, '51-100', '100+')",
711
+ };
712
+ const USAGE_DIMS = {
713
+ model: 'u.model',
714
+ // The model a usage entry is PRICED at: its own, or the session's dominant one when the
715
+ // source recorded none (0.18's orphan rule, now per session from its own row).
716
+ price_model: `if(u.model IN ('${EXCLUDED_MODELS.join("', '")}'), s.dominant_model, u.model)`,
717
+ day: "formatDateTime(u.hour, '%Y-%m-%d', {tz:String})",
718
+ hour: 'toHour(u.hour, {tz:String})',
719
+ month: "formatDateTime(u.hour, '%Y-%m', {tz:String})",
720
+ };
721
+ const TOOL_DIMS = { tool: 't.name' };
722
+
723
+ const SESSION_METRICS = {
724
+ sessions: 'count()',
725
+ named_sessions: "countIf(s.title != '' OR s.messages > 0)",
726
+ messages: 'sum(s.messages)', user_messages: 'sum(s.user_messages)', assistant_messages: 'sum(s.assistant_messages)',
727
+ agent_messages: 'sum(s.agent_messages)', tool_calls: 'sum(s.tool_calls)',
728
+ input_tokens: 'sum(s.input_tokens)', output_tokens: 'sum(s.output_tokens)',
729
+ cache_read_tokens: 'sum(s.cache_read_tokens)', cache_write_tokens: 'sum(s.cache_write_tokens)',
730
+ reasoning_tokens: 'sum(s.reasoning_tokens)',
731
+ user_chars: 'sum(s.user_chars)', assistant_chars: 'sum(s.assistant_chars)',
732
+ first_started_ms: 'toUnixTimestamp64Milli(minIf(s.started, toUnixTimestamp64Milli(s.started) > 0))', last_ended_ms: 'toUnixTimestamp64Milli(max(s.ended))',
733
+ avg_messages: 'avg(s.messages)', avg_tokens: 'avg(s.input_tokens + s.output_tokens)',
734
+ };
735
+ const USAGE_METRICS = {
736
+ sessions: 'uniqExact(s.harness, s.session_id)',
737
+ messages: 'sum(u.messages)', input_tokens: 'sum(u.input)', output_tokens: 'sum(u.output)',
738
+ cache_read_tokens: 'sum(u.cache_read)', cache_write_tokens: 'sum(u.cache_write)', reasoning_tokens: 'sum(u.reasoning)',
739
+ first_hour_ms: 'toUnixTimestamp(min(u.hour)) * 1000', last_hour_ms: 'toUnixTimestamp(max(u.hour)) * 1000',
740
+ };
741
+ const TOOL_METRICS = {
742
+ sessions: 'uniqExact(s.harness, s.session_id)', calls: 'sum(t.calls)', errors: 'sum(t.errors)',
743
+ };
744
+
745
+ const SOURCES = {
746
+ sessions: { dims: SESSION_DIMS, metrics: SESSION_METRICS, join: '' },
747
+ usage: { dims: { ...SESSION_DIMS, ...USAGE_DIMS }, metrics: USAGE_METRICS, join: ' ARRAY JOIN s.usage AS u' },
748
+ tools: { dims: { ...SESSION_DIMS, ...TOOL_DIMS }, metrics: TOOL_METRICS, join: ' ARRAY JOIN s.tools AS t' },
749
+ };
750
+
751
+ /**
752
+ * aggregate — dims × metrics over `sessions FINAL` (≤ one row per session, is_deleted = 0 AND removed = 0).
753
+ *
754
+ * source 'sessions' one row per session
755
+ * source 'usage' ARRAY JOIN usage: one row per (session, hour, model) — message-grain
756
+ * series, tokens and cost. With from/to, usage hours outside the window
757
+ * are dropped too, so tokens and cost are the window's, not whole sessions'.
758
+ * source 'tools' ARRAY JOIN tools: one row per (session, tool)
759
+ *
760
+ * usage and tools are never expanded in the same query (§3: their product is meaningless).
761
+ * Dims and metrics are names from the registries above — never SQL from the caller.
762
+ *
763
+ * opts: { source, dims: [...], metrics: [...], filters, where: { excludeModels }, order: [[name, 'asc'|'desc']], limit, tz }
764
+ */
765
+ function aggregate(rooms, opts = {}) {
766
+ const src = SOURCES[opts.source || 'sessions'];
767
+ if (!src) throw new QueryInputError(`source must be one of ${Object.keys(SOURCES).join(', ')}`);
768
+ const dims = opts.dims || [];
769
+ const metrics = opts.metrics && opts.metrics.length ? opts.metrics : ['sessions'];
770
+ for (const d of dims) if (!src.dims[d]) throw new QueryInputError(`unknown dimension '${d}' for ${opts.source || 'sessions'}`);
771
+ for (const m of metrics) if (!src.metrics[m]) throw new QueryInputError(`unknown metric '${m}' for ${opts.source || 'sessions'}`);
772
+ const w = sessionWhere(opts.filters, rooms, 's');
773
+ const params = { ...w.params };
774
+ const uses = (x) => x.includes('{tz:String}');
775
+ const extra = [];
776
+ if (opts.source === 'usage') {
777
+ const f = opts.filters || {};
778
+ if (f.from != null) extra.push(`u.hour >= toStartOfHour(fromUnixTimestamp64Milli({f_from:Int64}, 'UTC'))`);
779
+ if (f.to != null) extra.push(`u.hour <= fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`);
780
+ if (opts.where && opts.where.excludeModels) extra.push(`u.model NOT IN ('${EXCLUDED_MODELS.join("', '")}')`);
781
+ }
782
+ const select = [...dims.map((d) => `${src.dims[d]} AS ${d}`), ...metrics.map((m) => `${src.metrics[m]} AS ${m}`)];
783
+ if (select.some(uses)) params.tz = opts.tz || 'UTC';
784
+ let sql = `SELECT ${select.join(', ')}
785
+ FROM ${rooms.sessions} AS s FINAL${src.join}
786
+ WHERE ${w.sql}${extra.length ? ` AND ${extra.join(' AND ')}` : ''}`;
787
+ if (dims.length) sql += `\nGROUP BY ${dims.join(', ')}`;
788
+ const order = (opts.order || []).map(([name, d]) => {
789
+ if (!dims.includes(name) && !metrics.includes(name)) throw new QueryInputError(`cannot order by '${name}'`);
790
+ return `${name} ${d === 'desc' ? 'DESC' : 'ASC'}`;
791
+ });
792
+ // A total order whenever the caller asked for one, so a top-N cut never depends on arrival.
793
+ if (order.length) sql += `\nORDER BY ${[...order, ...dims.filter((d) => !order.some((o) => o.startsWith(`${d} `))).map((d) => `${d} ASC`)].join(', ')}`;
794
+ else if (dims.length) sql += `\nORDER BY ${dims.join(', ')}`;
795
+ if (opts.limit) { sql += '\nLIMIT {limit:UInt32}'; params.limit = clampInt(opts.limit, 100, 1, 100000); }
796
+ return { query: `aggregate:${opts.source || 'sessions'}`, final: 'sessions', settings: {}, params, sql };
797
+ }
798
+
799
+ // ── tools (drill-down) ────────────────────────────────────────────────────────────
800
+
801
+ /**
802
+ * tools — the calls of one tool, newest first. `calls FINAL` by tool_name (the set skip index)
803
+ * and the time window, with skip indexes ON under FINAL in EXACT mode
804
+ * (see the header: exact mode is what keeps a key-only tombstone from being skipped), joined
805
+ * to live sessions for their names.
806
+ *
807
+ * opts: { tool, filters: { harness, folder, hidden, from, to }, limit }
808
+ */
809
+ function tools(rooms, opts = {}) {
810
+ if (!opts.tool) throw new QueryInputError('tool is required');
811
+ const limit = clampInt(opts.limit, 200, 1, 1000);
812
+ const f = opts.filters || {};
813
+ const params = { tool: String(opts.tool), limit };
814
+ // Live sessions only, decided BEFORE the LIMIT: joining them afterwards returned short pages
815
+ // (996 of 1,000) whenever a dropped session's calls were among the newest.
816
+ const where = ['tool_name = {tool:String}', 'is_deleted = 0 AND removed = 0',
817
+ `(${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0)`];
818
+ if (f.from != null) { where.unshift(tsBound('>=', `fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`)); params.f_from = Number(f.from); }
819
+ if (f.to != null) { where.unshift(tsBound('<=', `fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`)); params.f_to = Number(f.to); }
820
+ if (f.harness) { where.push('harness = {f_harness:String}'); params.f_harness = String(f.harness); }
821
+ if (f.harnessLike) { where.push('harness LIKE {f_harness_like:String}'); params.f_harness_like = likeContains(f.harnessLike); }
822
+ if (f.folder) { where.push('folder = {f_folder:String}'); params.f_folder = String(f.folder); }
823
+ if (Array.isArray(f.hidden) && f.hidden.length) { where.push('folder NOT IN {f_hidden:Array(String)}'); params.f_hidden = f.hidden.map(String); }
824
+ return {
825
+ query: 'tools', final: 'calls, sessions',
826
+ settings: { use_skip_indexes_if_final: 1, use_skip_indexes_if_final_exact_mode: 1 },
827
+ params,
828
+ sql: `SELECT c.harness AS harness, c.session_id AS session_id, c.agent_id AS agent_id, c.call_id AS call_id,
829
+ c.message_id AS message_id, toUnixTimestamp64Milli(c.ts) AS ts_ms, c.tool_name AS tool_name, c.args AS args,
830
+ toUnixTimestamp64Milli(c.result_ts) AS result_ts_ms, c.is_error AS is_error, c.output_len AS result_len,
831
+ c.model AS model, c.folder AS folder, c.project AS project, c.host AS host, c.harness_path AS harness_path, c.incarnation AS incarnation, s.title AS session_name
832
+ FROM (
833
+ SELECT harness, session_id, harness_path, incarnation, agent_id, call_id, message_id, ts, tool_name, args, result_ts, is_error, output_len,
834
+ model, folder, project, host
835
+ FROM ${rooms.calls} FINAL
836
+ WHERE ${where.join(' AND ')}
837
+ ORDER BY ts DESC, call_id
838
+ LIMIT {limit:UInt32}
839
+ ) AS c
840
+ INNER JOIN (SELECT ${skeyOf()}, title FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0) AS s
841
+ ON s.harness = c.harness AND s.host = c.host AND s.harness_path = c.harness_path AND s.session_id = c.session_id AND s.incarnation = c.incarnation
842
+ ORDER BY ts_ms DESC, call_id`,
843
+ };
844
+ }
845
+
846
+ // ── coverage / machines ───────────────────────────────────────────────────────────
847
+
848
+ /** What the house holds, per harness: sessions, span, hosts, origins. `sessions FINAL`; per_record_sessions from `session_usage FINAL`. */
849
+ function coverage(rooms) {
850
+ return {
851
+ query: 'coverage', final: 'sessions', settings: {}, params: {},
852
+ sql: `SELECT harness, count() AS sessions, sum(messages) AS messages, sum(tool_calls) AS tool_calls,
853
+ toUnixTimestamp64Milli(minIf(started, toUnixTimestamp64Milli(started) > 0)) AS first_ms, toUnixTimestamp64Milli(max(ended)) AS last_ms,
854
+ arraySort(groupUniqArray(host)) AS hosts, arraySort(groupUniqArray(origin)) AS origins,
855
+ countIf((${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.usage} FINAL WHERE granularity = 'per_record' AND is_deleted = 0 AND removed = 0)) AS per_record_sessions
856
+ FROM ${rooms.sessions} FINAL
857
+ WHERE is_deleted = 0 AND removed = 0
858
+ GROUP BY harness
859
+ ORDER BY sessions DESC, harness`,
860
+ finish(rows) {
861
+ const total = { sessions: 0, messages: 0, tool_calls: 0, first_ms: null, last_ms: null };
862
+ for (const r of rows) {
863
+ total.sessions += Number(r.sessions); total.messages += Number(r.messages); total.tool_calls += Number(r.tool_calls);
864
+ if (total.first_ms === null || Number(r.first_ms) < total.first_ms) total.first_ms = Number(r.first_ms);
865
+ if (total.last_ms === null || Number(r.last_ms) > total.last_ms) total.last_ms = Number(r.last_ms);
866
+ }
867
+ return { harnesses: rows, total };
868
+ },
869
+ };
870
+ }
871
+
872
+ /** The machines that wrote sessions. `sessions FINAL`. */
873
+ function machines(rooms) {
874
+ return {
875
+ query: 'machines', final: 'sessions', settings: {}, params: {},
876
+ sql: `SELECT host, anyIf(host_label, host_label != '') AS label, count() AS sessions, arraySort(groupUniqArray(harness)) AS harnesses,
877
+ toUnixTimestamp64Milli(max(ended)) AS last_ms
878
+ FROM ${rooms.sessions} FINAL
879
+ WHERE is_deleted = 0 AND removed = 0
880
+ GROUP BY host
881
+ ORDER BY last_ms DESC, host`,
882
+ };
883
+ }
884
+
885
+ // ── running a spec ────────────────────────────────────────────────────────────────
886
+
887
+ async function run(exec, spec) {
888
+ const rows = spec.empty ? [] : await exec(spec);
889
+ return spec.finish ? spec.finish(rows) : rows;
890
+ }
891
+
892
+ // A spec may carry `fallback`: the same answer for a server without a feature the spec needs
893
+ // (search: materialized CTEs, skip indexes under FINAL in exact mode). The first refusal switches
894
+ // that query to its fallback for the life of the process; any other error is the caller's.
895
+ const FEATURE_REFUSED = /UNKNOWN_SETTING|Unknown setting|SYNTAX_ERROR|Syntax error|NOT_IMPLEMENTED|materialized/i;
896
+ const onFallback = new Set();
897
+ function withFallback(exec) {
898
+ return async (spec) => {
899
+ if (!spec || !spec.fallback) return exec(spec);
900
+ if (onFallback.has(spec.query)) return exec(spec.fallback);
901
+ try { return await exec(spec); } catch (e) {
902
+ if (!FEATURE_REFUSED.test(String(e && e.message))) throw e;
903
+ onFallback.add(spec.query);
904
+ return exec(spec.fallback);
905
+ }
906
+ };
907
+ }
908
+
909
+ /** usage_by_model (a sumMap tuple) -> [{ model, messages, input, output, cache_read, cache_write, reasoning }] */
910
+ function usageByModel(t) {
911
+ if (!t) return [];
912
+ const [models, msgs, inp, outp, cr, cw, rs] = Array.isArray(t) ? t : [t[0], t[1], t[2], t[3], t[4], t[5], t[6]];
913
+ return (models || []).map((model, i) => ({
914
+ model, messages: Number(msgs[i]) || 0, input: Number(inp[i]) || 0, output: Number(outp[i]) || 0,
915
+ cache_read: Number(cr[i]) || 0, cache_write: Number(cw[i]) || 0, reasoning: Number(rs[i]) || 0,
916
+ }));
917
+ }
918
+
919
+ // A session reference: `<harness>:<session_id>`, then `~<store>` when it names its store — the
920
+ // base64url of [host, harness_path, incarnation]. Without a store (a 0.18 link, a typed id) the
921
+ // newest session of that harness and id is read, and then only its own store's rows.
922
+ const STORE_SEP = '~';
923
+ function storeToken(r) { return Buffer.from(JSON.stringify([String(r.host), String(r.harness_path), String(r.incarnation || '')]), 'utf-8').toString('base64url'); }
924
+ function parseStoreToken(t) {
925
+ try {
926
+ const v = JSON.parse(Buffer.from(t, 'base64url').toString('utf-8'));
927
+ return Array.isArray(v) && v.length === 3 && v.every((x) => typeof x === 'string') ? { host: v[0], harness_path: v[1], incarnation: v[2] } : null;
928
+ } catch { return null; }
929
+ }
930
+ /** Parse a session reference (above) or an object. */
931
+ function parseSessionRef(ref, harness = null) {
932
+ if (ref && typeof ref === 'object') {
933
+ const o = { harness: String(ref.harness), session_id: String(ref.session_id) };
934
+ return hasStore(ref) ? { ...o, host: String(ref.host), harness_path: String(ref.harness_path), incarnation: String(ref.incarnation || '') } : o;
935
+ }
936
+ let s = String(ref || '');
937
+ let store = null;
938
+ const at = s.lastIndexOf(STORE_SEP);
939
+ if (at > 0) { const st = parseStoreToken(s.slice(at + 1)); if (st) { store = st; s = s.slice(0, at); } }
940
+ let out;
941
+ if (harness) out = { harness: String(harness), session_id: s };
942
+ else {
943
+ const cut = s.indexOf(':');
944
+ if (cut <= 0) throw new QueryInputError(`'${s}' is not <harness>:<session_id>`);
945
+ out = { harness: s.slice(0, cut), session_id: s.slice(cut + 1) };
946
+ }
947
+ return store ? { ...out, ...store } : out;
948
+ }
949
+ const sessionRef = (r) => `${r.harness}:${r.session_id}${hasStore(r) ? `${STORE_SEP}${storeToken(r)}` : ''}`;
950
+
951
+ /**
952
+ * removedContent — the messages kept as removed versions (plan v4: a key that left its source on a
953
+ * rewrite — a revert, a rewind, a deleted message — is written again with removed = 1, never
954
+ * deleted). One row per removed message, newest first: the one read that shows them, since every
955
+ * other read filters removed = 0. Optional: one session (`store` names its store) or one harness,
956
+ * and a day window (`days`, by the message's own date).
957
+ */
958
+ /**
959
+ * A time bound on a part or call: its own ts, or when it has none (a harness that keeps no time) the
960
+ * first_seen of the pass that captured it.
961
+ */
962
+ /**
963
+ * What a part says, as text: a call its name and arguments, a result its output, an error its message
964
+ * (the profile keeps an error's content in error_message, its text empty), anything else its text.
965
+ */
966
+ function partText(t) {
967
+ if (t.kind === 'tool_call') return `${t.tool_name || ''} ${t.args || ''}`.trim();
968
+ if (t.kind === 'tool_result') return t.output || '';
969
+ if (t.kind === 'error') return t.error_message || t.text || (t.error_kind ? `[${t.error_kind}]` : '');
970
+ return t.text;
971
+ }
972
+
973
+ function tsBound(op, at) { return `(ts ${op} ${at} OR (ts IS NULL AND first_seen ${op} ${at}))`; }
974
+
975
+ function removedContent(rooms, opts = {}) {
976
+ const limit = clampInt(opts.limit, 50, 1, 1000);
977
+ const chars = clampInt(opts.chars, 200, 0, 100000);
978
+ const params = { limit, chars };
979
+ const where = ['removed = 1', 'is_deleted = 0'];
980
+ if (opts.days != null) { where.unshift(tsBound('>=', 'now64(3) - toIntervalDay({days:UInt32})')); params.days = clampInt(opts.days, 14, 1, 100000); }
981
+ if (opts.harness) { where.push('harness = {h:String}'); params.h = String(opts.harness); }
982
+ if (opts.session_id) {
983
+ where.push('session_id = {s:String}'); params.s = String(opts.session_id);
984
+ const sp = storePred(opts.store || null);
985
+ if (sp.sql) { where.push(sp.sql.replace(/^ AND /, '')); Object.assign(params, sp.params); }
986
+ }
987
+ return {
988
+ query: 'removedContent', final: 'session_parts', params,
989
+ sql: `SELECT toString(harness) AS harness, host, toString(harness_path) AS harness_path, session_id, incarnation, message_id,
990
+ any(role) AS role, any(state) AS state, toUnixTimestamp64Milli(min(ts)) AS ts_ms, count() AS parts,
991
+ substring(argMinIf(text, part_index, kind IN ('text', 'summary', 'reasoning')), 1, {chars:UInt32}) AS snippet
992
+ FROM ${rooms.parts} FINAL
993
+ WHERE ${where.join(' AND ')}
994
+ GROUP BY harness, host, harness_path, session_id, incarnation, message_id
995
+ ORDER BY ts_ms DESC
996
+ LIMIT {limit:UInt32}`,
997
+ };
998
+ }
999
+
1000
+ const NAMED = {
1001
+ listSessions, countSessions, sessionHeader, partsPage, callsOf, usageOf, sessionVersion, houseVersion,
1002
+ search, aggregate, tools, coverage, machines, removedContent,
1003
+ };
1004
+
1005
+ module.exports = {
1006
+ ROOT_LABEL,
1007
+ withFallback,
1008
+ v3Rooms, NAMED, QueryInputError, EXCLUDED_MODELS, FAR_FUTURE_MS,
1009
+ sessionWhere, listSessions, countSessions, sessionHeader, partsPage, callsOf, usageOf, attachUsage, houseTokens, readSession,
1010
+ sessionVersion, houseVersion, search, searchTerms, termPredicate, aggregate, tools, coverage, machines, removedContent,
1011
+ run, usageByModel, parseSessionRef, sessionRef, encodeCursor, decodeCursor, likeContains, daysWithin, PART_SELECT, storePred, tsBound, partText,
1012
+ SESSION_DIMS, USAGE_DIMS, TOOL_DIMS, SESSION_METRICS, USAGE_METRICS, TOOL_METRICS,
1013
+ };