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,430 @@
1
+ // The capture engine's reads and writes on harness-sessions-sql's profile tables (step B2, the writer):
2
+ // the profile's form of capture.js HttpHouse's archive, cursor and row methods. It takes a
3
+ // transport ({ query(sql, settings), post(params, body, gzip) }: HttpHouse is one) and the
4
+ // member's profile names.
5
+ //
6
+ // archive session_units: a unit's body in chunks, and the read manifests (unit_key '')
7
+ // replay the units of one source generation, folded from its manifests in progress order
8
+ // (harness-sessions-sql §4.1): each adds its units and drops its gone keys; a reset manifest
9
+ // starts the fold over. Only the folded units' chunks are read
10
+ // archive state per source, the newest generation the archive holds, its greatest manifest
11
+ // progress and its fold: what a pass stopped before its cursor write left ahead
12
+ // cursor session_sources rows of the session's sources (the legacy marker left out)
13
+ // current the session's live adapter-derived keys per table, with their rev and first_seen:
14
+ // what a refold reads once
15
+ // retire a key the view no longer makes: the live row again at the pass's rev, removed 1,
16
+ // server-side
17
+ // session the sessions row: the spec columns given, memhouse's derived columns from the
18
+ // shared rollup (profile-rollup.js) over the rows just written
19
+
20
+ 'use strict';
21
+
22
+ const rollup = require('./profile-rollup');
23
+ const { lit } = require('./convert-v3');
24
+
25
+ const STORE = '(host, harness, harness_path, session_id, incarnation)';
26
+ // The session key as the many-session reads return it: aliases that shadow no column (a SELECT alias
27
+ // named like a column would also stand for it in WHERE).
28
+ const STORE_OUT = 'host AS zo, toString(harness) AS zh, toString(harness_path) AS zp, session_id AS zs, incarnation AS zi';
29
+ const storeKeyOf = (r) => [r.host, r.harness, r.harness_path, r.session_id, r.incarnation || ''].map((x) => String(x ?? '')).join('\u0000');
30
+ const storeWhere = (ref) => `${STORE} = (${[ref.host, ref.harness, ref.harness_path, ref.session_id, ref.incarnation || ''].map(lit).join(', ')})`;
31
+
32
+ /** Each table's own key columns after the session key; a removed version is written by these. */
33
+ const KEY_COLUMNS = {
34
+ session_parts: ['message_id', 'part_id'],
35
+ session_usage: ['usage_id'],
36
+ session_links: ['owner', 'kind', 'other_host', 'other_harness_path', 'other_harness', 'other_session_id', 'other_incarnation', 'at'],
37
+ session_lifecycle: ['lifecycle_id'],
38
+ calls: ['call_id'],
39
+ };
40
+ const ENTITY_TABLES = Object.keys(KEY_COLUMNS);
41
+ /** A row's key within its session, as one string. */
42
+ function keyOf(table, r) { return KEY_COLUMNS[table].map((c) => String(r[c] ?? '')).join('\u0000'); }
43
+
44
+ // How long a read of the adapter_ver types stands: an admin may retype a table under a running shipper.
45
+ const VER_TYPES_TTL = 60_000;
46
+ const INSERT_SETTINGS = { async_insert: '0', input_format_skip_unknown_fields: '0', date_time_input_format: 'best_effort' };
47
+
48
+ class ProfileHouse {
49
+ /** @param {{query: Function, post: Function}} transport @param {object} P profileNames(db, member) */
50
+ constructor(transport, P) { this.t = transport; this.P = P; this.inserted = {}; this.clock = Date.now; this.batchBytes = 8 << 20; }
51
+ rows(sql, settings) { return this.t.query(sql, settings); }
52
+ run(sql, settings = {}) { return this.t.post(Object.fromEntries(Object.entries(settings).map(([k, v]) => [k, String(v)])), sql, false); }
53
+
54
+ /**
55
+ * Rows into `table`, ~8 MiB a statement. `whole`: every row in one statement, however big (the
56
+ * capture's read manifests of one pass: one archive write, so one captured_at, is one pass).
57
+ */
58
+ async insert(table, rows, { whole = false } = {}) {
59
+ if (!rows.length) return;
60
+ // A table not yet brought to harness-sessions-sql v1's types (a member holds no ALTER on its archive, so
61
+ // an admin's schema step heals those) still takes adapter_ver as the old number; it reads back as
62
+ // the version through the capture's verOf.
63
+ if (await this.numericVer(table)) rows = rows.map((r) => (r.adapter_ver === undefined ? r : { ...r, adapter_ver: oldVer(r.adapter_ver) }));
64
+ for (let i = 0; i < rows.length;) {
65
+ // ~8 MiB a batch (batchBytes): an archive chunk is up to 1 MiB of text.
66
+ const lines = []; let size = 0;
67
+ for (; i < rows.length && (whole || size < this.batchBytes || !lines.length); i++) { const l = JSON.stringify(rows[i]); lines.push(l); size += l.length; }
68
+ try { await this.t.post({ query: `INSERT INTO ${this.P[table]} FORMAT JSONEachRow`, ...INSERT_SETTINGS }, lines.join('\n'), true); }
69
+ catch (e) { this.verTypes = null; throw e; } // a retyped table: the next pass reads the types again
70
+ }
71
+ this.inserted[table] = (this.inserted[table] || 0) + rows.length;
72
+ }
73
+
74
+ /** Whether a table's adapter_ver is still the UInt32 from before harness-sessions-sql v1's types (read again after VER_TYPES_TTL or a failed insert). */
75
+ async numericVer(table) {
76
+ if (!this.verTypes || this.clock() - this.verAt > VER_TYPES_TTL) {
77
+ this.verAt = this.clock();
78
+ const dbs = [this.P.db, this.P.archive].filter(Boolean).map(lit).join(', ');
79
+ this.verTypes = new Map((await this.rows(`SELECT database, table, type FROM system.columns WHERE name = 'adapter_ver' AND database IN (${dbs})`))
80
+ .map((r) => [`${r.database}.${r.table}`, /^UInt/.test(String(r.type))]));
81
+ }
82
+ return !!this.verTypes.get(this.P[table]);
83
+ }
84
+
85
+ /** The session's source cursors (FINAL), the legacy marker left out. */
86
+ async sources(ref) {
87
+ return this.rows(`SELECT source_key, toString(mode) AS mode, position, generation, toString(progress) AS progress, state, state_ver, toString(adapter) AS adapter,
88
+ adapter_ver, toString(role) AS role, known, is_deleted
89
+ FROM ${this.P.session_sources} FINAL WHERE ${storeWhere(ref)} AND role != 'legacy'`);
90
+ }
91
+
92
+ /**
93
+ * The manifests of one source generation, folded: unit_key -> sha256 in delivery order, and the greatest
94
+ * progress. `upto`: only the manifests at or below this progress (the generation as a cursor saw it).
95
+ */
96
+ async foldOf(ref, sourceKey, generation, { upto = null } = {}) {
97
+ const where = `${storeWhere(ref)} AND source_key = ${lit(sourceKey)} AND generation = ${Number(generation)}`;
98
+ const manifests = (await this.rows(`SELECT DISTINCT raw FROM ${this.P.session_units} WHERE ${where} AND unit_key = '' AND enc = 'manifest'`))
99
+ .map((r) => JSON.parse(r.raw)).filter((m) => upto == null || BigInt(m.progress) <= BigInt(upto));
100
+ return foldManifests(manifests);
101
+ }
102
+
103
+ /** The units of one source generation, in delivery order (harness-sessions-sql §4.1 replay). */
104
+ async archiveUnits(ref, sourceKey, generation, { upto = null } = {}) {
105
+ const { fold } = await this.foldOf(ref, sourceKey, generation, { upto });
106
+ return this.unitsOf(ref, sourceKey, generation, [...fold]);
107
+ }
108
+
109
+ /** The units `wanted` ([unit_key, sha256] pairs, in the order given) of one source generation, from their chunks. */
110
+ async unitsOf(ref, sourceKey, generation, wanted) {
111
+ if (!wanted.length) return [];
112
+ const where = `${storeWhere(ref)} AND source_key = ${lit(sourceKey)} AND generation = ${Number(generation)}`;
113
+ // One row per chunk: two copies of a chunk that differ outside the key (an orig_sha256 written as ''
114
+ // before harness-sessions-sql v1's types and as null after) must not double the body.
115
+ const by = new Map();
116
+ for (let i = 0; i < wanted.length; i += 1000) {
117
+ const tuples = wanted.slice(i, i + 1000).map(([k, sha]) => `(${lit(k)}, ${lit(sha)})`).join(', ');
118
+ const chunks = await this.rows(`SELECT DISTINCT unit_key, sha256, chunk, toString(enc) AS enc, redacted, orig_sha256, raw FROM ${this.P.session_units}
119
+ WHERE ${where} AND unit_key != '' AND (unit_key, sha256) IN (${tuples})`);
120
+ for (const c of chunks) {
121
+ const k = `${c.unit_key}\u0000${c.sha256}`;
122
+ if (!by.has(k)) by.set(k, new Map());
123
+ if (!by.get(k).has(String(c.chunk))) by.get(k).set(String(c.chunk), c);
124
+ }
125
+ }
126
+ const out = [];
127
+ for (const [k, sha] of wanted) {
128
+ const cs = [...(by.get(`${k}\u0000${sha}`) || new Map()).values()].sort((a, b) => Number(a.chunk) - Number(b.chunk));
129
+ if (!cs.length) throw new Error(`archive: ${sourceKey} generation ${generation} lacks unit ${k} (${sha.slice(0, 12)})`);
130
+ out.push(unitOf(sourceKey, cs));
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * The archived size (bytes of text, each chunk once) of each of the units `wanted` ([unit_key,
137
+ * sha256] pairs) of one source generation: what a replay batches its unit reads by.
138
+ */
139
+ async unitSizes(ref, sourceKey, generation, wanted) {
140
+ const where = `${storeWhere(ref)} AND source_key = ${lit(sourceKey)} AND generation = ${Number(generation)}`;
141
+ const out = new Map();
142
+ for (let i = 0; i < wanted.length; i += 1000) {
143
+ const tuples = wanted.slice(i, i + 1000).map(([k, sha]) => `(${lit(k)}, ${lit(sha)})`).join(', ');
144
+ // One row per chunk (#64: a chunk can be held twice, '' and null orig_sha256).
145
+ for (const r of await this.rows(`SELECT unit_key, sha256, toString(sum(b)) AS n FROM (
146
+ SELECT unit_key, sha256, chunk, max(length(raw)) AS b FROM ${this.P.session_units}
147
+ WHERE ${where} AND unit_key != '' AND (unit_key, sha256) IN (${tuples}) GROUP BY unit_key, sha256, chunk)
148
+ GROUP BY unit_key, sha256`)) out.set(`${r.unit_key}\u0000${r.sha256}`, Number(r.n));
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Every read manifest of the session, each once, with the adapter that wrote it and the archive
155
+ * write that holds it (`at`, its captured_at; a manifest written again byte for byte counts as the
156
+ * latest write's): what a replay from the archive alone folds pass by pass (passesOf).
157
+ */
158
+ async manifests(ref) {
159
+ // Aliases that shadow no column (a SELECT alias named like a column would also stand for it in WHERE).
160
+ const rows = await this.rows(`SELECT source_key, generation, sha256, any(raw) AS m_raw, toString(max(captured_at)) AS m_at, toString(any(adapter)) AS m_adapter
161
+ FROM ${this.P.session_units} WHERE ${storeWhere(ref)} AND unit_key = '' AND enc = 'manifest' GROUP BY source_key, generation, sha256`);
162
+ return rows.map((r) => ({ ...JSON.parse(r.m_raw), source_key: r.source_key, generation: Number(r.generation), sha256: r.sha256, at: String(r.m_at), adapter: String(r.m_adapter) }));
163
+ }
164
+
165
+ /**
166
+ * Per source: the newest generation the archive holds, the greatest progress of its manifests and
167
+ * their fold. Ahead of the cursor only when a pass stopped between its archive and cursor writes.
168
+ */
169
+ async archiveState(ref) {
170
+ const gens = await this.rows(`SELECT source_key, max(generation) AS g, toString(max(captured_at)) AS at FROM ${this.P.session_units}
171
+ WHERE ${storeWhere(ref)} AND unit_key = '' AND enc = 'manifest' GROUP BY source_key`);
172
+ const out = new Map();
173
+ for (const r of gens) out.set(r.source_key, { gen: Number(r.g), at: String(r.at), ...(await this.foldOf(ref, r.source_key, r.g)) });
174
+ return out;
175
+ }
176
+
177
+ /** The session's latest archive write (ms since the epoch; 0 for none): its manifests' greatest captured_at. */
178
+ async archivedAt(ref) {
179
+ const [r] = await this.rows(`SELECT toString(max(captured_at)) AS at, count() AS n FROM ${this.P.session_units}
180
+ WHERE ${storeWhere(ref)} AND unit_key = '' AND enc = 'manifest'`);
181
+ return r && Number(r.n) ? Date.parse(`${String(r.at).replace(' ', 'T')}Z`) : 0;
182
+ }
183
+
184
+ /** The session's live adapter-derived keys per table, with rev and first_seen (a refold reads them once). */
185
+ async current(ref) {
186
+ const out = {};
187
+ for (const table of ENTITY_TABLES) {
188
+ const extra = table === 'session_parts' ? ', toString(first_seen) AS first_seen' : table === 'calls' ? ', message_id' : '';
189
+ out[table] = await this.rows(`SELECT ${KEY_COLUMNS[table].map((c) => `toString(${c}) AS ${c}`).join(', ')}, toString(rev) AS rev, adapter_ver, toString(adapter) AS adapter${extra}
190
+ FROM ${this.P[table]} FINAL WHERE ${storeWhere(ref)} AND removed = 0 AND ${table === 'session_links' ? "owner = 'adapter'" : "adapter != 'legacy'"}`);
191
+ }
192
+ const s = await this.rows(`SELECT toString(rev) AS rev, adapter_ver, toString(adapter) AS adapter FROM ${this.P.sessions} FINAL WHERE ${storeWhere(ref)}`);
193
+ out.sessionRev = s.length ? s[0].rev : null;
194
+ out.sessionVer = s.length ? String(s[0].adapter_ver) : null; out.sessionAdapter = s.length ? s[0].adapter : null;
195
+ return out;
196
+ }
197
+
198
+ /**
199
+ * The session's removed versions this adapter wrote (removed 1), per table, with rev, adapter_ver and
200
+ * first_seen: an upgrade's re-derive writes again those the new version makes, and tombstones the rest.
201
+ */
202
+ async removedOf(ref, adapter) {
203
+ const out = {};
204
+ for (const table of ENTITY_TABLES) {
205
+ const extra = table === 'session_parts' ? ', toString(first_seen) AS first_seen' : table === 'calls' ? ', message_id' : '';
206
+ out[table] = await this.rows(`SELECT ${KEY_COLUMNS[table].map((c) => `toString(${c}) AS ${c}`).join(', ')}, toString(rev) AS rev, adapter_ver, toString(adapter) AS adapter${extra}
207
+ FROM ${this.P[table]} FINAL WHERE ${storeWhere(ref)} AND removed = 1 AND adapter = ${lit(adapter)}${table === 'session_links' ? " AND owner = 'adapter'" : ''}`);
208
+ }
209
+ return out;
210
+ }
211
+
212
+ /**
213
+ * The live rows another adapter wrote in this session (a harness that moved to a new adapter): their
214
+ * keys and what they say, to tell the new adapter's twins (duplicates) from the rest (history).
215
+ */
216
+ async foreignContent(ref, adapter) {
217
+ const own = `${storeWhere(ref)} AND removed = 0 AND adapter != ${lit(adapter)} AND adapter != 'legacy'`;
218
+ return {
219
+ session_parts: await this.rows(`SELECT message_id, part_id, toString(role) AS role, toString(kind) AS kind, text, toString(tool_name) AS tool_name, args, output
220
+ FROM ${this.P.session_parts} FINAL WHERE ${own}`),
221
+ calls: await this.rows(`SELECT call_id, toString(tool_name) AS tool_name, args FROM ${this.P.calls} FINAL WHERE ${own}`),
222
+ session_usage: await this.rows(`SELECT usage_id, toString(input) AS input, toString(output) AS output, toString(cache_read) AS cache_read, toString(cache_write) AS cache_write
223
+ FROM ${this.P.session_usage} FINAL WHERE ${own}`),
224
+ };
225
+ }
226
+
227
+ // ── the same reads for many sessions at once (a pass's first sight of a group of sessions) ──
228
+ // One query per table for a group instead of one per session: a capture over a remote house is
229
+ // bound by round trips. Keyed by storeKeyOf(ref); a session with nothing stored is absent.
230
+ async manyRows(refs, sql) {
231
+ const out = new Map();
232
+ for (let i = 0; i < refs.length; i += 200) {
233
+ const part = refs.slice(i, i + 200);
234
+ const inRefs = `${STORE} IN (${part.map((r) => `(${[r.host, r.harness, r.harness_path, r.session_id, r.incarnation || ''].map(lit).join(', ')})`).join(', ')})`;
235
+ for (const r of await this.rows(sql(inRefs))) {
236
+ const k = storeKeyOf({ host: r.zo, harness: r.zh, harness_path: r.zp, session_id: r.zs, incarnation: r.zi });
237
+ if (!out.has(k)) out.set(k, []);
238
+ out.get(k).push(r);
239
+ }
240
+ }
241
+ return out;
242
+ }
243
+ /** sources() for many sessions. */
244
+ async sourcesMany(refs) {
245
+ return this.manyRows(refs, (inRefs) => `SELECT ${STORE_OUT}, source_key, toString(mode) AS mode, position, generation, toString(progress) AS progress, state, state_ver,
246
+ toString(adapter) AS adapter, adapter_ver, toString(role) AS role, known, is_deleted
247
+ FROM ${this.P.session_sources} FINAL WHERE ${inRefs} AND role != 'legacy'`);
248
+ }
249
+ /** archiveState() for many sessions: each source's newest generation, folded. */
250
+ async archiveStateMany(refs) {
251
+ const rows = await this.manyRows(refs, (inRefs) => `SELECT ${STORE_OUT}, source_key, toString(generation) AS generation, raw, toString(max(captured_at)) AS m_at FROM ${this.P.session_units}
252
+ WHERE ${inRefs} AND unit_key = '' AND enc = 'manifest' GROUP BY host, harness, harness_path, session_id, incarnation, source_key, generation, raw`);
253
+ const out = new Map();
254
+ for (const [k, list] of rows) {
255
+ const gens = new Map(); const ats = new Map();
256
+ for (const r of list) {
257
+ gens.set(r.source_key, Math.max(gens.has(r.source_key) ? gens.get(r.source_key) : -1, Number(r.generation)));
258
+ if (!ats.has(r.source_key) || String(r.m_at) > ats.get(r.source_key)) ats.set(r.source_key, String(r.m_at));
259
+ }
260
+ const state = new Map();
261
+ for (const [sk, g] of gens) state.set(sk, { gen: g, at: ats.get(sk), ...foldManifests(list.filter((r) => r.source_key === sk && Number(r.generation) === g).map((r) => JSON.parse(r.raw))) });
262
+ out.set(k, state);
263
+ }
264
+ return out;
265
+ }
266
+ /** current() for many sessions. */
267
+ async currentMany(refs) {
268
+ const out = new Map();
269
+ const of = (k) => { if (!out.has(k)) out.set(k, { ...Object.fromEntries(ENTITY_TABLES.map((t) => [t, []])), sessionRev: null, sessionVer: null, sessionAdapter: null }); return out.get(k); };
270
+ for (const k of refs.map(storeKeyOf)) of(k);
271
+ for (const table of ENTITY_TABLES) {
272
+ const extra = table === 'session_parts' ? ', toString(first_seen) AS first_seen' : table === 'calls' ? ', message_id' : '';
273
+ const rows = await this.manyRows(refs, (inRefs) => `SELECT ${STORE_OUT}, ${KEY_COLUMNS[table].map((c) => `toString(${c}) AS ${c}`).join(', ')}, toString(rev) AS rev, adapter_ver, toString(adapter) AS adapter${extra}
274
+ FROM ${this.P[table]} FINAL WHERE ${inRefs} AND removed = 0 AND ${table === 'session_links' ? "owner = 'adapter'" : "adapter != 'legacy'"}`);
275
+ for (const [k, list] of rows) of(k)[table] = list.map(({ zo, zh, zp, zs, zi, ...r }) => r);
276
+ }
277
+ const s = await this.manyRows(refs, (inRefs) => `SELECT ${STORE_OUT}, toString(rev) AS rev, adapter_ver, toString(adapter) AS adapter FROM ${this.P.sessions} FINAL WHERE ${inRefs}`);
278
+ for (const [k, list] of s) Object.assign(of(k), { sessionRev: list[0].rev, sessionVer: String(list[0].adapter_ver), sessionAdapter: list[0].adapter });
279
+ return out;
280
+ }
281
+
282
+ /**
283
+ * Retire these keys' live rows at `rev`, server-side: a removed version (removed 1: the key left its
284
+ * source), or with `tombstone` a tombstone (is_deleted 1: a key an upgraded adapter no longer
285
+ * produces, harness-sessions-sql §2 principle 3). With `removed`, the keys' removed versions are the rows
286
+ * tombstoned (an upgrade's re-derive).
287
+ */
288
+ async retire(table, ref, keys, rev, { tombstone = false, removed = false } = {}) {
289
+ if (removed && !tombstone) throw new Error('retire: a removed version is only ever tombstoned');
290
+ const cols = KEY_COLUMNS[table];
291
+ const mark = tombstone ? 'toUInt8(1) AS is_deleted' : 'toUInt8(1) AS removed';
292
+ for (let i = 0; i < keys.length; i += 500) {
293
+ const tuples = keys.slice(i, i + 500).map((k) => `(${k.split('\u0000').map(lit).join(', ')})`).join(', ');
294
+ // The filter sits in a subquery: ClickHouse resolves a SELECT alias in WHERE, so `1 AS removed`
295
+ // beside `WHERE removed = 0` would select nothing.
296
+ await this.run(`INSERT INTO ${this.P[table]} SELECT * REPLACE (toUInt64(${BigInt(rev)}) AS rev, ${mark}, now64(3) AS ingested_at)
297
+ FROM (SELECT * FROM ${this.P[table]} FINAL WHERE ${storeWhere(ref)} AND removed = ${removed ? 1 : 0} AND (${cols.join(', ')}) IN (${tuples}))`);
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Write the session's sessions row: `row` holds the spec columns and memhouse's own (folder,
303
+ * project, origin), the shared rollup the derived ones. Run after the rows it should count.
304
+ */
305
+ async writeSession(ref, row) {
306
+ const [r] = await this.rows(`SELECT ${rollup.ROLLUP_COLUMNS.map(([c, e]) => `${e} AS ${c}`).join(', ')}
307
+ FROM (${rollup.sessionRollupSQL(this.P, storeWhere(ref))}) AS r`);
308
+ // Another writer may have written the row above the pass's rev (the dedupe's refresh writes
309
+ // rev + 1): the row goes above it, never at the same rev with other content.
310
+ const [h] = await this.rows(`SELECT toString(max(rev)) AS rev FROM ${this.P.sessions} WHERE ${storeWhere(ref)}`);
311
+ let rev = BigInt(row.rev);
312
+ if (h && h.rev != null && BigInt(h.rev) >= rev) rev = BigInt(h.rev) + 1n;
313
+ await this.insert('sessions', [{ ...row, ...(r || {}), rev: String(rev) }]);
314
+ return rev;
315
+ }
316
+
317
+ /** Write the session's live sessions row again with its rollup recomputed (a fork whose edge moved). */
318
+ async refreshSession(ref) {
319
+ await this.run(rollup.refreshSessionsSQL(this.P, storeWhere(ref)));
320
+ }
321
+ }
322
+
323
+ /** A version string as the UInt32 from before harness-sessions-sql v1's types (adapter-capture verNumber); a plain number stays itself. */
324
+ function oldVer(v) {
325
+ const s = String(v ?? '');
326
+ if (/^\d+$/.test(s)) return Number(s);
327
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(s);
328
+ return m ? 1e9 + Number(m[1]) * 1e6 + Number(m[2]) * 1e3 + Number(m[3]) : 0;
329
+ }
330
+
331
+ /**
332
+ * Manifests folded in progress order (harness-sessions-sql §4.1): each adds its units and drops its gone
333
+ * keys, and a reset starts over (a reset opens its generation; a pass that stopped before its cursor
334
+ * write left one ahead, which the next pass's reset supersedes).
335
+ */
336
+ function foldManifests(manifests) {
337
+ const sorted = [...manifests].sort((a, b) => (BigInt(a.progress) < BigInt(b.progress) ? -1 : BigInt(a.progress) > BigInt(b.progress) ? 1 : 0));
338
+ const fold = new Map();
339
+ let progress = null;
340
+ let gone = false; // the newest manifest says the source vanished (its generation holds no read)
341
+ for (const m of sorted) {
342
+ if (m.continuity === 'reset') fold.clear();
343
+ for (const [k, sha] of m.units || []) { fold.delete(k); fold.set(k, sha); }
344
+ for (const k of m.gone || []) fold.delete(k);
345
+ progress = String(m.progress);
346
+ gone = !!m.source_gone;
347
+ }
348
+ return { fold, progress, gone };
349
+ }
350
+
351
+ const cmpProgress = (a, b) => { const x = BigInt(a.progress ?? 0); const y = BigInt(b.progress ?? 0); return x < y ? -1 : x > y ? 1 : 0; };
352
+
353
+ /**
354
+ * A session's manifests (ProfileHouse manifests()) as the passes that wrote them, in pass order
355
+ * (harness-sessions-sql §4.1: a replay derives once per pass). memhouse's `progress` counts per source (a
356
+ * stream's byte offset, else the source's reads), so it orders one source's manifests but does not
357
+ * group a session's: a pass is the manifests of one archive write, which share its `at`. Along each
358
+ * source, in (generation, progress) order, a write that reads as earlier than the one before it is
359
+ * taken as that one's, so no pass folds a source's later read before an earlier one. Each pass
360
+ * lists its manifests by source, each source's in (generation, progress) order.
361
+ */
362
+ function passesOf(manifests) {
363
+ const bySource = new Map();
364
+ for (const m of manifests) {
365
+ if (!bySource.has(m.source_key)) bySource.set(m.source_key, []);
366
+ bySource.get(m.source_key).push(m);
367
+ }
368
+ const passes = new Map();
369
+ for (const list of bySource.values()) {
370
+ list.sort((a, b) => a.generation - b.generation || cmpProgress(a, b));
371
+ let at = '';
372
+ for (const m of list) {
373
+ if (String(m.at) > at) at = String(m.at);
374
+ if (!passes.has(at)) passes.set(at, []);
375
+ passes.get(at).push(m);
376
+ }
377
+ }
378
+ const order = (a, b) => (a.source_key < b.source_key ? -1 : a.source_key > b.source_key ? 1 : a.generation - b.generation || cmpProgress(a, b));
379
+ return [...passes.keys()].sort().map((at) => passes.get(at).sort(order));
380
+ }
381
+
382
+ /**
383
+ * One manifest folded into its source's replay state (harness-sessions-sql §4.1 "Replay from the archive
384
+ * alone"). `sources`: source_key -> { generation, units (unit_key -> sha256, delivery order), from
385
+ * (the generation whose rows hold those units; null when none do), gone (null, or the policy of the
386
+ * source_gone generation it is in), progress }. A source_gone manifest replays as its recorded policy
387
+ * says: `kept` keeps the generation before it current, `removed` (and no policy, an archive from
388
+ * before policy existed) leaves the source empty. A reset, or a generation's first manifest, starts
389
+ * the source over; any other adds its units and drops its gone keys.
390
+ */
391
+ function foldManifest(sources, m) {
392
+ const s = sources.get(m.source_key);
393
+ if (m.source_gone) {
394
+ const policy = m.policy == null ? 'removed' : String(m.policy);
395
+ const kept = policy === 'kept' && s;
396
+ sources.set(m.source_key, { generation: m.generation, units: kept ? s.units : new Map(), from: kept ? s.from : null, gone: policy, progress: String(m.progress) });
397
+ return;
398
+ }
399
+ const units = m.continuity === 'reset' || !s || s.generation !== m.generation ? new Map() : s.units;
400
+ for (const [k, sha] of m.units || []) { units.delete(String(k)); units.set(String(k), sha); }
401
+ for (const k of m.gone || []) units.delete(String(k));
402
+ sources.set(m.source_key, { generation: m.generation, units, from: m.generation, gone: null, progress: String(m.progress) });
403
+ }
404
+
405
+ /** One unit back from its archive chunks. */
406
+ function unitOf(sourceKey, cs) {
407
+ const body = cs.map((c) => c.raw).join('');
408
+ const enc = cs[0].enc;
409
+ const u = { source_key: sourceKey, unit_key: cs[0].unit_key, enc, bytes: enc === 'b64' ? Buffer.from(body, 'base64').length : Buffer.byteLength(body, 'utf8'),
410
+ sha256: cs[0].sha256, redacted: !!cs[0].redacted, body };
411
+ if (cs[0].orig_sha256 && /^[0-9a-f]{64}$/.test(cs[0].orig_sha256)) u.orig_sha256 = cs[0].orig_sha256;
412
+ return u;
413
+ }
414
+
415
+ const CHUNK = 1 << 20;
416
+ /** A body in chunks of at most CHUNK UTF-16 units, never splitting a surrogate pair. */
417
+ function chunksOf(body) {
418
+ const s = String(body ?? '');
419
+ const out = [];
420
+ for (let i = 0; i < s.length || !out.length;) {
421
+ let end = Math.min(i + CHUNK, s.length);
422
+ if (end < s.length && /[\uD800-\uDBFF]/.test(s[end - 1])) end--;
423
+ out.push(s.slice(i, end));
424
+ i = end;
425
+ if (i >= s.length) break;
426
+ }
427
+ return out;
428
+ }
429
+
430
+ module.exports = { ProfileHouse, KEY_COLUMNS, ENTITY_TABLES, keyOf, storeWhere, storeKeyOf, chunksOf, unitOf, foldManifests, passesOf, foldManifest, oldVer, CHUNK };
@@ -0,0 +1,190 @@
1
+ // memhouse's derived session columns over harness-sessions-sql's profile tables (step B4: the shared
2
+ // rollup). One SQL builder for every writer of a sessions row, the conversion and the capture
3
+ // engine alike, so a session reads the same whichever wrote it.
4
+ //
5
+ // THE READER RULES (each line is a decision)
6
+ // live rows FINAL (tombstones drop out), removed = 0; parts whose state (their own
7
+ // part_state where they have one, else their message's) is 'reverted' or
8
+ // 'retracted' are off the session's line and count nowhere
9
+ // tokens the house split (sessions-model §2.4, as records-to-rows houseTokens): input
10
+ // without cached input (cache_read and cache_write taken out where
11
+ // input_includes_cache is true) plus tool_prompt; output with reasoning (added
12
+ // where output_includes_reasoning is false); a null flag leaves the number as given
13
+ // per_record a response's usage counts once, from its last record: the response-anchored
14
+ // row with the greatest n in its usage_id '<anchor>#<n>' (harness-sessions-sql §4.4)
15
+ // billed once a fork's usage rows that copy its origin's count only in the origin: the ones a
16
+ // verified engine edge on the fork lists in r_copied_usage (profile-edges.js;
17
+ // harness-sessions-sql §4.5, billing reads edges only)
18
+ // messages text parts; user/assistant by role; agent_messages those of a sub-agent
19
+ // chars text_len of the role's parts (a tool result is role 'tool': in neither)
20
+ // first_prompt the main agent's first user text part by order_key, 200 characters
21
+ // started, ended the parts' first and last ts
22
+ // models served model, else requested; '' and '<synthetic>' left out. dominant_model
23
+ // the one with the most parts
24
+ // days the days of the parts and the calls
25
+ // usage per hour × model: text parts, and the usage rows' house tokens by their ts
26
+ // tools per tool name from {m}_calls: calls, errors (is_error true)
27
+ // agents per sub-agent: type, description and workflow from its parts' extra.agent,
28
+ // text parts, first ts
29
+ // folder, project the first part's (memhouse's columns on session_parts)
30
+ //
31
+ // A refresh (refreshSessionsSQL) writes a live sessions row again at rev + 1 with these columns
32
+ // recomputed and its own columns as they were: the rollup is memhouse's, outside content_sha's line.
33
+ // A key with no live part or usage row left is retired (is_deleted 1).
34
+
35
+ 'use strict';
36
+
37
+ const sp = require('./schema-profile');
38
+
39
+ const KEY = 'host, harness, harness_path, session_id, incarnation';
40
+
41
+ /**
42
+ * One row per session key matching `where` (a predicate over harness and session_id), with
43
+ * memhouse's derived columns as r_*. Reads session_parts, session_usage and calls with FINAL.
44
+ * A part or call with no time of its own counts at the session's earliest time, or at its first_seen
45
+ * when the session has none at all (a harness that keeps no times); a usage record with none at its
46
+ * anchor's message or response, else at the session's latest part: never at 1970, and a dated
47
+ * session's start, end and days stay its own.
48
+ */
49
+ function sessionRollupSQL(P, where = '1') {
50
+ const parts = `SELECT host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation,
51
+ agent_id AS aid, ifNull(ts, ifNull(min(ts) OVER (PARTITION BY host, harness, harness_path, session_id, incarnation), first_seen)) AS t,
52
+ message_id AS mid, response_id AS rid, order_key AS ok, toString(role) AS rl, toString(kind) AS kd,
53
+ if(toString(served_model) != '', toString(served_model), toString(requested_model)) AS md,
54
+ text AS tx, text_len AS tlen, folder AS fo, project AS pr, extra AS ex
55
+ FROM ${P.session_parts} FINAL
56
+ WHERE removed = 0 AND if(part_state != '', part_state, state) NOT IN ('reverted', 'retracted') AND ${where}`;
57
+ // A per_record response's records: only its last one (the greatest n) counts. A fork's copies of its
58
+ // origin's usage, listed on its verified engine edges, do not: left out after the last record is
59
+ // chosen, so a copied last record never lets an earlier one count in its place.
60
+ const copied = `SELECT host, toString(harness), toString(harness_path), session_id, incarnation, arrayJoin(r_copied_usage)
61
+ FROM ${P.session_links} FINAL WHERE owner = 'engine' AND removed = 0 AND ifNull(verified, false) AND ${where}`;
62
+ const usage = `SELECT host, harness, harness_path, session_id, incarnation, t, md, ak, an,
63
+ toUInt64(greatest(if(ifNull(iic, false), toInt64(i0) - toInt64(cr0) - toInt64(cw0), toInt64(i0)), 0) + tp0) AS ui,
64
+ o0 + if(isNotNull(oir) AND NOT oir, rs0, 0) AS uo, cr0 AS ucr, cw0 AS ucw, rs0 AS urs
65
+ FROM (
66
+ SELECT host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, ts AS t, toString(model) AS md,
67
+ toString(granularity) AS g, toString(anchor_kind) AS ak, anchor_id AS an, usage_id AS uid, toUInt64OrZero(splitByChar('#', usage_id)[-1]) AS un,
68
+ max(un) OVER (PARTITION BY host, harness, harness_path, session_id, incarnation, ak, an) AS umax,
69
+ ifNull(input, 0) AS i0, ifNull(output, 0) AS o0, ifNull(cache_read, 0) AS cr0, ifNull(cache_write, 0) AS cw0,
70
+ ifNull(reasoning, 0) AS rs0, ifNull(tool_prompt, 0) AS tp0, input_includes_cache AS iic, output_includes_reasoning AS oir
71
+ FROM ${P.session_usage} FINAL WHERE removed = 0 AND ${where}
72
+ ) WHERE (NOT (g = 'per_record' AND ak = 'response') OR un = umax)
73
+ AND (host, harness, harness_path, session_id, incarnation, uid) NOT IN (${copied})`;
74
+ // A call with no time takes the session's earliest part time, else the earliest call time, else its first_seen.
75
+ const calls = `SELECT c.host AS host, c.harness AS harness, c.harness_path AS harness_path, c.session_id AS session_id, c.incarnation AS incarnation,
76
+ c.tn AS tn, c.ce AS ce, coalesce(c.ts, pm.pmin, c.cmin, c.first_seen) AS t
77
+ FROM (SELECT host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation,
78
+ toString(tool_name) AS tn, ifNull(is_error, false) AS ce, ts, first_seen,
79
+ min(ts) OVER (PARTITION BY host, harness, harness_path, session_id, incarnation) AS cmin
80
+ FROM ${P.calls} FINAL WHERE removed = 0 AND ${where}) AS c
81
+ LEFT JOIN (SELECT host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation, CAST(min(ts) AS Nullable(DateTime64(3, 'UTC'))) AS pmin
82
+ FROM ${P.session_parts} FINAL WHERE removed = 0 AND ${where} GROUP BY host, harness, harness_path, session_id, incarnation) AS pm
83
+ ON pm.host = c.host AND pm.harness = c.harness AND pm.harness_path = c.harness_path AND pm.session_id = c.session_id AND pm.incarnation = c.incarnation`;
84
+ // The stores with anything live: a part in any state (one a revert hides is still the session's),
85
+ // a usage record or a call. A store with none of them is retired by the refresh; any other stays.
86
+ const present = `SELECT DISTINCT host, toString(harness) AS harness, toString(harness_path) AS harness_path, session_id, incarnation
87
+ FROM ${P.session_parts} FINAL WHERE removed = 0 AND ${where}
88
+ UNION DISTINCT SELECT DISTINCT ${KEY} FROM (${usage})
89
+ UNION DISTINCT SELECT DISTINCT ${KEY} FROM (${calls})`;
90
+ const hour = (t) => `toStartOfHour(toDateTime(ifNull(${t}, toDateTime64(0, 3, 'UTC')), 'UTC'))`;
91
+ const on = (a) => KEY.split(', ').map((c) => `${a}.${c} = k.${c}`).join(' AND ');
92
+ return `SELECT k.host AS host, k.harness AS harness, k.harness_path AS harness_path, k.session_id AS session_id, k.incarnation AS incarnation,
93
+ coalesce(a.r_started, tl.tl_first, u.u_first) AS r_started, coalesce(a.r_ended, tl.tl_last, u.u_last) AS r_ended, a.r_messages, a.r_user_messages, a.r_assistant_messages, a.r_agent_messages,
94
+ toUInt32(ifNull(tl.tl_calls, 0)) AS r_tool_calls,
95
+ ifNull(u.u_in, 0) AS r_in, ifNull(u.u_out, 0) AS r_out, ifNull(u.u_cr, 0) AS r_cr, ifNull(u.u_cw, 0) AS r_cw, ifNull(u.u_rs, 0) AS r_rs,
96
+ a.r_user_chars, a.r_assistant_chars, a.r_first_prompt, a.r_models, dm.dm_model AS r_dominant,
97
+ arraySort(arrayDistinct(arrayConcat(a.r_days, ifNull(tl.tl_days, [])))) AS r_days,
98
+ ifNull(uh.uh_arr, []) AS r_usage, ifNull(tl.tl_arr, []) AS r_tools, ifNull(ag.ag_arr, []) AS r_agents,
99
+ a.r_folder, a.r_project, toUInt8(1) AS r_live
100
+ FROM (${present}) AS k
101
+ LEFT JOIN (
102
+ SELECT ${KEY}, CAST(min(t) AS Nullable(DateTime64(3, 'UTC'))) AS r_started, CAST(max(t) AS Nullable(DateTime64(3, 'UTC'))) AS r_ended,
103
+ toUInt32(countIf(kd = 'text')) AS r_messages, toUInt32(countIf(kd = 'text' AND rl = 'user')) AS r_user_messages,
104
+ toUInt32(countIf(kd = 'text' AND rl = 'assistant')) AS r_assistant_messages, toUInt32(countIf(kd = 'text' AND aid != '')) AS r_agent_messages,
105
+ sumIf(toUInt64(tlen), rl = 'user') AS r_user_chars, sumIf(toUInt64(tlen), rl = 'assistant') AS r_assistant_chars,
106
+ substringUTF8(argMinIf(tx, ok, rl = 'user' AND aid = '' AND kd = 'text' AND tx != ''), 1, 200) AS r_first_prompt,
107
+ arraySort(groupUniqArrayIf(md, md NOT IN ('', '<synthetic>'))) AS r_models,
108
+ arraySort(groupUniqArray(toDate(t))) AS r_days, argMin(fo, ok) AS r_folder, argMin(pr, ok) AS r_project
109
+ FROM (${parts}) GROUP BY ${KEY}
110
+ ) AS a ON ${on('a')}
111
+ LEFT JOIN (
112
+ SELECT ${KEY}, sum(ui) AS u_in, sum(uo) AS u_out, sum(ucr) AS u_cr, sum(ucw) AS u_cw, sum(urs) AS u_rs,
113
+ CAST(min(t) AS Nullable(DateTime64(3, 'UTC'))) AS u_first, CAST(max(t) AS Nullable(DateTime64(3, 'UTC'))) AS u_last FROM (${usage}) GROUP BY ${KEY}
114
+ ) AS u ON ${on('u')}
115
+ LEFT JOIN (
116
+ SELECT ${KEY}, arraySort(x -> (x.1, x.2), groupArray((hr, md, toUInt32(hm), hi, ho, hcr, hcw, hrs))) AS uh_arr
117
+ FROM (
118
+ SELECT ${KEY}, hr, md, sum(m) AS hm, sum(i) AS hi, sum(o) AS ho, sum(cr) AS hcr, sum(cw) AS hcw, sum(rs) AS hrs FROM (
119
+ SELECT ${KEY}, ${hour('t')} AS hr, md, toUInt64(kd = 'text') AS m, toUInt64(0) AS i, toUInt64(0) AS o, toUInt64(0) AS cr, toUInt64(0) AS cw, toUInt64(0) AS rs FROM (${parts})
120
+ UNION ALL
121
+ SELECT ${KEY.split(', ').map((c) => `uu.${c} AS ${c}`).join(', ')}, ${hour('coalesce(uu.t, am.mt, ar.rt, pm.pt)')} AS hr, uu.md AS md, toUInt64(0), uu.ui, uu.uo, uu.ucr, uu.ucw, uu.urs
122
+ FROM (${usage}) AS uu
123
+ LEFT JOIN (SELECT ${KEY}, mid, CAST(min(t) AS Nullable(DateTime64(3, 'UTC'))) AS mt FROM (${parts}) GROUP BY ${KEY}, mid) AS am
124
+ ON ${KEY.split(', ').map((c) => `am.${c} = uu.${c}`).join(' AND ')} AND am.mid = if(uu.ak = 'message', uu.an, '')
125
+ LEFT JOIN (SELECT ${KEY}, rid, CAST(min(t) AS Nullable(DateTime64(3, 'UTC'))) AS rt FROM (${parts}) WHERE rid != '' GROUP BY ${KEY}, rid) AS ar
126
+ ON ${KEY.split(', ').map((c) => `ar.${c} = uu.${c}`).join(' AND ')} AND ar.rid = if(uu.ak = 'response', uu.an, '')
127
+ LEFT JOIN (SELECT ${KEY}, CAST(max(t) AS Nullable(DateTime64(3, 'UTC'))) AS pt FROM (${parts}) GROUP BY ${KEY}) AS pm
128
+ ON ${KEY.split(', ').map((c) => `pm.${c} = uu.${c}`).join(' AND ')}
129
+ ) GROUP BY ${KEY}, hr, md
130
+ ) GROUP BY ${KEY}
131
+ ) AS uh ON ${on('uh')}
132
+ LEFT JOIN (
133
+ SELECT ${KEY}, arraySort(x -> x.1, groupArray((tn, n_calls, n_errors))) AS tl_arr, sum(n_calls) AS tl_calls, arraySort(arrayDistinct(groupArrayArray(td))) AS tl_days,
134
+ CAST(min(tmin) AS Nullable(DateTime64(3, 'UTC'))) AS tl_first, CAST(max(tmax) AS Nullable(DateTime64(3, 'UTC'))) AS tl_last
135
+ FROM (SELECT ${KEY}, tn, toUInt32(count()) AS n_calls, toUInt32(countIf(ce)) AS n_errors, groupUniqArray(toDate(t)) AS td, min(t) AS tmin, max(t) AS tmax
136
+ FROM (${calls}) GROUP BY ${KEY}, tn)
137
+ GROUP BY ${KEY}
138
+ ) AS tl ON ${on('tl')}
139
+ LEFT JOIN (
140
+ SELECT ${KEY}, arraySort(x -> (x.6, x.1), groupArray((aid, g_type, g_desc, g_wf, g_msgs, g_first))) AS ag_arr
141
+ FROM (
142
+ SELECT ${KEY}, aid, argMin(JSONExtractString(ex, 'agent', 'type'), ok) AS g_type, argMin(JSONExtractString(ex, 'agent', 'description'), ok) AS g_desc,
143
+ argMin(JSONExtractString(ex, 'agent', 'workflow'), ok) AS g_wf, toUInt32(countIf(kd = 'text')) AS g_msgs,
144
+ ifNull(min(t), toDateTime64(0, 3, 'UTC')) AS g_first
145
+ FROM (${parts}) WHERE aid != '' GROUP BY ${KEY}, aid
146
+ ) GROUP BY ${KEY}
147
+ ) AS ag ON ${on('ag')}
148
+ LEFT JOIN (
149
+ SELECT ${KEY}, argMax(md, (c, md)) AS dm_model FROM (SELECT ${KEY}, md, count() AS c FROM (${parts}) WHERE md NOT IN ('', '<synthetic>') GROUP BY ${KEY}, md) GROUP BY ${KEY}
150
+ ) AS dm ON ${on('dm')}`;
151
+ }
152
+
153
+ /** The sessions columns the rollup fills, with the r_* expression each takes (Nested as arrays). */
154
+ const ROLLUP_COLUMNS = [
155
+ ['started', 'r.r_started'], ['ended', 'r.r_ended'], ['messages', 'r.r_messages'], ['user_messages', 'r.r_user_messages'],
156
+ ['assistant_messages', 'r.r_assistant_messages'], ['agent_messages', 'r.r_agent_messages'], ['tool_calls', 'r.r_tool_calls'],
157
+ ['input_tokens', 'r.r_in'], ['output_tokens', 'r.r_out'], ['cache_read_tokens', 'r.r_cr'], ['cache_write_tokens', 'r.r_cw'], ['reasoning_tokens', 'r.r_rs'],
158
+ ['user_chars', 'r.r_user_chars'], ['assistant_chars', 'r.r_assistant_chars'], ['first_prompt', 'r.r_first_prompt'], ['models', 'r.r_models'],
159
+ ['dominant_model', 'r.r_dominant'], ['days', 'r.r_days'],
160
+ ['`usage.hour`', 'arrayMap(x -> x.1, r.r_usage)'], ['`usage.model`', 'arrayMap(x -> x.2, r.r_usage)'], ['`usage.messages`', 'arrayMap(x -> x.3, r.r_usage)'],
161
+ ['`usage.input`', 'arrayMap(x -> x.4, r.r_usage)'], ['`usage.output`', 'arrayMap(x -> x.5, r.r_usage)'], ['`usage.cache_read`', 'arrayMap(x -> x.6, r.r_usage)'],
162
+ ['`usage.cache_write`', 'arrayMap(x -> x.7, r.r_usage)'], ['`usage.reasoning`', 'arrayMap(x -> x.8, r.r_usage)'],
163
+ ['`tools.name`', 'arrayMap(x -> x.1, r.r_tools)'], ['`tools.calls`', 'arrayMap(x -> x.2, r.r_tools)'], ['`tools.errors`', 'arrayMap(x -> x.3, r.r_tools)'],
164
+ ['`agents.agent_id`', 'arrayMap(x -> x.1, r.r_agents)'], ['`agents.type`', 'arrayMap(x -> x.2, r.r_agents)'], ['`agents.description`', 'arrayMap(x -> x.3, r.r_agents)'],
165
+ ['`agents.workflow`', 'arrayMap(x -> x.4, r.r_agents)'], ['`agents.messages`', 'arrayMap(x -> x.5, r.r_agents)'], ['`agents.first_ts`', 'arrayMap(x -> x.6, r.r_agents)'],
166
+ ];
167
+
168
+ /**
169
+ * Write the live sessions rows matching `where` (a predicate over the session key) again: rev + 1,
170
+ * the rollup columns recomputed, every other column as stored. Run after the parts, usage and calls
171
+ * writes it should see.
172
+ */
173
+ function refreshSessionsSQL(P, where) {
174
+ const roll = new Map(ROLLUP_COLUMNS.map(([c, e]) => [c.replace(/`/g, ''), e]));
175
+ const cols = sp.insertColumns(sp.readTemplate(), 'sessions');
176
+ const q = (c) => `\`${c}\``;
177
+ const sel = cols.map((c) => {
178
+ if (roll.has(c)) return `${roll.get(c)} AS ${q(c)}`;
179
+ if (c === 'rev') return 's.rev + 1 AS rev';
180
+ if (c === 'is_deleted') return 'toUInt8(ifNull(r.r_live, 0) = 0) AS is_deleted';
181
+ return `s.${q(c)} AS ${q(c)}`;
182
+ });
183
+ return `INSERT INTO ${P.sessions} (${cols.map(q).join(', ')})
184
+ SELECT ${sel.join(', ')}
185
+ FROM (SELECT * FROM ${P.sessions} FINAL WHERE ${where}) AS s
186
+ LEFT JOIN (${sessionRollupSQL(P, where)}) AS r
187
+ ON ${KEY.split(', ').map((c) => `r.${c} = s.${c}`).join(' AND ')}`;
188
+ }
189
+
190
+ module.exports = { sessionRollupSQL, refreshSessionsSQL, ROLLUP_COLUMNS, KEY };