memhouse 0.20.1 → 0.20.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,67 @@ Versions before 0.8.0 were beta-only. Beta installs of 0.7.x and earlier should
4
4
  uninstall and reinstall — the 0.8.0 layout is new, and an in-place `update` +
5
5
  migration path is planned work, not a promise the old versions can cash.
6
6
 
7
+ ## 0.20.2 — 2026-11-03
8
+
9
+ Release notes: [`docs/releases/v0.20.2.md`](docs/releases/v0.20.2.md). Read its "Read this
10
+ before upgrading".
11
+
12
+ - **Before upgrading: the first capture re-derives every session from its archive.** memhouse now
13
+ reads editors through `@agent-realm/harness-session-adapters` 1.1.0, whose adapters state
14
+ `adapter_ver` 0.3.0. A new adapter version is an upgrade (harness-sessions-sql §8, "Re-derive from
15
+ the archive"), so the first pass after `memhouse update` replays each session's archived passes
16
+ under 0.3.0 and writes its rows again above the house's revs. It reads the archive, not the
17
+ editors' stores, and writes every session once; on a large house that first pass takes longer
18
+ than a usual one. Later passes are as before. The editors memhouse reads through its own session
19
+ logs (every editor but Claude Code and Codex) re-derive in the same pass: their adapter,
20
+ `memhouse-bridge`, moves to 1.0.2 for the Cursor sub-agent links (below).
21
+ - **The profile tables follow harness-sessions-sql v1.1: a record's native fields, and the units it
22
+ came from.** Five columns, added to an existing house by the schema step (`ADD COLUMN`, at the
23
+ end of the table; a new house has them at the specification's positions):
24
+ - `sessions.native` and `session_parts.message_native`, `part_native`: the fields of the
25
+ editor's own record that the adapter declares (a Claude Code message's `message.stop_reason`,
26
+ say), as canonical JSON, `''` when there are none;
27
+ - `sessions.units` and `session_parts.message_units`: the `(source_key, generation, unit_key)`
28
+ of each archived unit the record came from, so a row joins back to its raw line in
29
+ `<db>_archive.<m>_session_units`.
30
+ A row the capture wrote before 0.20.2 holds `''` and `[]` until its session is re-derived (above).
31
+ The legacy conversion leaves the five columns out of its `content_sha` line, as v1.1 asks, so a
32
+ converted session keeps its shas.
33
+ - **A member's reading view of turns: `<m>_session_turns_view`** (harness-sessions-sql v1.1 §6,
34
+ `session_turns`, still a plan there). One row per turn of one session, a turn running from a
35
+ prompt to the next one, with its messages, tool calls, failed tool results and the models that
36
+ served it:
37
+ `SELECT * FROM <db>.<m>_session_turns_view(host = '…', harness = '…', harness_path = '…', session_id = '…', incarnation = '')`.
38
+ The schema step makes it (`CREATE OR REPLACE`). It runs as its reader (`SQL SECURITY INVOKER`),
39
+ so it shows nobody a row the tables would not, and a share does not reach it: a share is still
40
+ the six tables. It is not named `<m>_session_turns`, because a table ending in `_turns` reads as
41
+ a 0.18 member's room when memhouse looks for colliding member names.
42
+
43
+ - **`memhouse room-sql` runs `SHOW ROW POLICIES` on ClickHouse 25.11.** It appended its output
44
+ format (`FORMAT PrettyCompact`, or `JSONEachRow` with `--json`) to the statement, and 25.11 parses
45
+ no FORMAT after a bare `SHOW ROW POLICIES` or `SHOW POLICIES`, so the statement failed with a
46
+ syntax error. The format now goes as the `default_format` setting, which applies to every
47
+ statement; a FORMAT the statement names itself still wins.
48
+ - **The development layout (`<m>_turns`, `MEMHOUSE_LAYOUT=v3-dev`): an adapter upgrade writes above
49
+ the rows it replaces.** A new adapter version re-derived an unchanged file, from disk or from the
50
+ archive, at the rev the file's last pass had written, so its rows tied with the old ones and won
51
+ only by insertion order (listed under Deferred in 0.20.0). It now writes one rev above the
52
+ highest the house holds for that file, tombstones included. No derive code changed, so nothing is
53
+ re-derived. The profile tables were not affected.
54
+ - **Cursor: a chat's sub-agents are captured as its links.** Cursor names a chat's sub-agents only
55
+ in the chat's own record (`subagentComposerIds`), never in a message, and the profile capture did
56
+ not read them there, so the links from a chat to its sub-agents were missing. The log adapter
57
+ (`memhouse-bridge` 1.0.2) now reads them as links stated on the chat, one per sub-agent. No live
58
+ Cursor store has shown a sub-agent yet: the tests use synthetic sessions. Each new sub-agent
59
+ still rewrites the chat's log; placing it in the log where it appeared waits for a live sample
60
+ (`docs/design/sessions-engine.md`).
61
+ - **A session log memhouse cannot read is never rewritten into an empty one.** memhouse refuses to
62
+ rewrite the normalised log of a session that had messages into one with none, since an empty
63
+ read is far likelier a misread store than a real clear. It counted the old log's messages as 0
64
+ when the log was there but could not be read (a permission or disk error), which let that
65
+ rewrite through. Such a log now stops the session's pass with "cannot read the existing log
66
+ (<code>)", reported and retried like any other failed read.
67
+
7
68
  ## 0.20.1 — 2026-11-02
8
69
 
9
70
  Release notes: [`docs/releases/v0.20.1.md`](docs/releases/v0.20.1.md).
package/bin/memhouse.js CHANGED
@@ -2896,14 +2896,13 @@ async function cmdRoomSql() {
2896
2896
  const body0 = qc().rewriteRooms(sql, { member, db: cfg.db, schema });
2897
2897
  qc().assertRead(body0);
2898
2898
  if (flags.print === true) { console.log(body0); return 0; }
2899
- const stripped = body0.trim().replace(/;\s*$/, '');
2899
+ // The format travels as a setting, never appended to the statement (q-cli roomSqlRequest).
2900
+ const req = qc().roomSqlRequest(body0, { json: JSON_OUT });
2901
+ const text = await t.raw(req.body, { database: cfg.db, settings: req.settings });
2900
2902
  if (JSON_OUT) {
2901
- if (qc().hasFormat(stripped)) return qError(Object.assign(new Error('--json picks the format itself — drop the FORMAT clause'), { status: 400 }));
2902
- const text = await t.raw(`${stripped}\nFORMAT JSONEachRow`, { database: cfg.db });
2903
2903
  console.log(JSON.stringify(text.split('\n').filter(Boolean).map((l) => JSON.parse(l))));
2904
2904
  return 0;
2905
2905
  }
2906
- const text = await t.raw(qc().hasFormat(stripped) ? stripped : `${stripped}\nFORMAT PrettyCompact`, { database: cfg.db });
2907
2906
  process.stdout.write(text.endsWith('\n') || !text ? text : `${text}\n`);
2908
2907
  return 0;
2909
2908
  } catch (e) { return qError(e); }
@@ -26,6 +26,7 @@ Full or scoped, a share is `SELECT` on exactly your six conversation tables:
26
26
  | rows | all | those matching the scope, through a row policy on each table |
27
27
  | capture archive (`<db>_archive`) | not granted | not granted |
28
28
  | `<you>_meta`, `<you>_events` | not granted | not granted |
29
+ | `<you>_session_turns_view` (your turns, a view) | not granted | not granted |
29
30
  | tables added later | not granted | not granted |
30
31
 
31
32
  ## Scopes
@@ -5,6 +5,7 @@ do, how to upgrade, what is deprecated and when it goes, and what was deferred.
5
5
 
6
6
  | version | date | headline |
7
7
  |---|---|---|
8
+ | [0.20.2](v0.20.2.md) | 2026-11-03 | harness-sessions-sql v1.1's columns and a turns view; Cursor sub-agent links; the first capture re-derives every session |
8
9
  | [0.20.1](v0.20.1.md) | 2026-11-02 | invites expire; `memhouse plugins install codex`; per-command help and clearer CLI answers |
9
10
  | [0.20.0](v0.20.0.md) | 2026-10-29 | schema 3: a session is its store, nothing cut or lost, forks billed once, ten editors through normalisers; Apache-2.0 |
10
11
  | [0.18.11](v0.18.11.md) | 2026-10-10 | sessions with subagents stop re-shipping; security: no stored credential sent to `invite --url` |
@@ -0,0 +1,134 @@
1
+ # memhouse 0.20.2 — 2026-11-03
2
+
3
+ A patch release on 0.20.1. The profile tables gain five columns, which the schema step adds in
4
+ place, and the first capture after upgrading re-derives every session from its archive. Install
5
+ it with `memhouse update`, or `npm install -g memhouse`.
6
+
7
+ - **The profile tables gain harness-sessions-sql v1.1's five columns.** Each session and message
8
+ keeps the editor's own fields that its adapter declares, and the archived units it came from,
9
+ so a row joins back to its raw line. These notes claim no v1.1 conformance; the conformance
10
+ run's result is below.
11
+ - **A reading view of turns:** `<m>_session_turns_view`, one row per turn of a session (v1.1's
12
+ `session_turns`, still a plan there).
13
+ - **Cursor: a chat's sub-agents are captured as its links.**
14
+ - **Fixes:**
15
+ - `memhouse room-sql` runs `SHOW ROW POLICIES` on ClickHouse 25.11;
16
+ - a session log memhouse cannot read is never rewritten into an empty one;
17
+ - on the development layout, an adapter upgrade writes above the rows it replaces.
18
+
19
+ ## Read this before upgrading
20
+
21
+ - **The first capture re-derives every session from its archive.** This happens once, on the
22
+ first pass after `memhouse update`. It reads the archive, not the editors' stores, and writes
23
+ every session's rows again, so it takes longer than a usual pass; later passes are as before.
24
+ - Measured on a synthetic house of 2,500 sessions (2,000 Claude Code and 500 Codex; 110 MiB
25
+ of session files, 193,814 message parts), in a 4-core container with 6 GiB of memory, that
26
+ pass took 18 minutes on ClickHouse 26.7.4.58 and 27 minutes on 25.11.9.34: 1.5 and 1.3
27
+ times as long as capturing the same house for the first time. The shipper's memory peaked
28
+ at 348 MB and ClickHouse's at 58 MiB per query, and the next pass took 7 seconds.
29
+ - The time grows with the number of sessions, 0.43 seconds a session on 26.7 and 0.64 on
30
+ 25.11 in that measurement: a house with four times as many sessions takes about four times
31
+ as long.
32
+ - Claude Code and Codex are read through `@agent-realm/harness-session-adapters` 1.1.0, whose
33
+ adapters state `adapter_ver` 0.3.0.
34
+ - Every other editor is read through memhouse's own session logs, whose adapter,
35
+ `memhouse-bridge`, moves to 1.0.2 (the Cursor links below).
36
+ - A new adapter version is an upgrade (harness-sessions-sql §8, "Re-derive from the
37
+ archive"), so each session is replayed under the new version and written above the house's
38
+ revs.
39
+ - **The schema step adds five columns** to an existing house's `<m>_sessions` and
40
+ `<m>_session_parts`, at the end of each table (a new house has them at the specification's
41
+ positions). The member's own credential adds them; the archive tables do not change. A row
42
+ captured before 0.20.2 holds `''` and `[]` there until its session is re-derived, which the
43
+ first capture does.
44
+
45
+ ```bash
46
+ memhouse update # then: memhouse status — every line a check mark
47
+ ```
48
+
49
+ ## The profile tables: harness-sessions-sql v1.1's columns
50
+
51
+ This adds the v1.1 columns and the `session_turns` view (a plan in v1.1).
52
+
53
+ - `sessions.native`, `session_parts.message_native` and `part_native`: the fields of the
54
+ editor's own record that the adapter declares (a Claude Code message's `message.stop_reason`,
55
+ say), as canonical JSON, `''` when there are none.
56
+ - `sessions.units` and `session_parts.message_units`: the `(source_key, generation, unit_key)`
57
+ of each archived unit the record came from, so a row joins back to its raw line in
58
+ `<db>_archive.<m>_session_units`.
59
+ - The legacy conversion leaves the five columns out of its `content_sha` line, as v1.1 asks, so
60
+ a converted session keeps its shas.
61
+
62
+ These notes claim no conformance. The harness-sessions-sql conformance kit, run against this
63
+ release's code on ClickHouse 26.7 and 25.11 with the session adapters 1.1.0:
64
+
65
+ - **Native is declared** passes.
66
+ - **Units resolve** fails. memhouse numbers a source's first archived generation 0, where v1.1
67
+ numbers it 1, so each triple's generation is one lower than the specification's. Every triple
68
+ still names a unit of the session's own archive, so a row joins back to its raw line. memhouse
69
+ has numbered generations this way since 0.20.0. Aligning the two is 0.20.3 work.
70
+ - **Crash-safe** keeps the failure 0.20.0 reported: a stopped read whose source changed before
71
+ the re-run.
72
+
73
+ ## A reading view of turns
74
+
75
+ `<m>_session_turns_view` (harness-sessions-sql v1.1 §6, `session_turns`, still a plan there)
76
+ gives one row per turn of one session, a turn running from a prompt to the next one, with its
77
+ messages, tool calls, failed tool results and the models that served it:
78
+
79
+ ```sql
80
+ SELECT * FROM <db>.<m>_session_turns_view(host = '…', harness = '…', harness_path = '…', session_id = '…', incarnation = '')
81
+ ```
82
+
83
+ - The schema step makes it (`CREATE OR REPLACE`).
84
+ - It runs as its reader (`SQL SECURITY INVOKER`), so it shows nobody a row the tables would not.
85
+ - A share does not reach it: a share is still the six tables.
86
+ - It is not named `<m>_session_turns`, because a table ending in `_turns` reads as a 0.18
87
+ member's room when memhouse looks for colliding member names.
88
+
89
+ ## Cursor sub-agents
90
+
91
+ Cursor names a chat's sub-agents only in the chat's own record (`subagentComposerIds`), never in
92
+ a message, and the profile capture did not read them there, so the links from a chat to its
93
+ sub-agents were missing. `memhouse-bridge` 1.0.2 reads them as links stated on the chat, one per
94
+ sub-agent.
95
+
96
+ - No live Cursor store has shown a sub-agent yet; the tests use synthetic sessions.
97
+ - Each new sub-agent still rewrites the chat's normalised log. Placing it where it appeared
98
+ waits for a live sample (Deferred).
99
+
100
+ ## Smaller changes
101
+
102
+ - **`memhouse room-sql` runs `SHOW ROW POLICIES` on ClickHouse 25.11.** It appended its output
103
+ format to the statement, and 25.11 parses no FORMAT after a bare `SHOW ROW POLICIES` or `SHOW
104
+ POLICIES`. The format now goes as the `default_format` setting; a FORMAT the statement names
105
+ itself still wins.
106
+ - **A session log memhouse cannot read is never rewritten into an empty one.** An old log that
107
+ was there but could not be read (a permission or disk error) counted as having no messages,
108
+ which let an empty read through. It now stops that session's pass with "cannot read the
109
+ existing log (<code>)", reported and retried.
110
+ - **The development layout (`<m>_turns`, `MEMHOUSE_LAYOUT=v3-dev`):** an adapter upgrade writes
111
+ one rev above the highest the house holds for the file, instead of tying with the rows it
112
+ replaces. The profile tables were not affected.
113
+ - **The site** no longer points readers at a repository-only document.
114
+
115
+ ## Deferred
116
+
117
+ - **The normalisers' filesystem errors** (0.20.3): about thirty reads in the editor normalisers
118
+ take any error for "not there", so a permission denial (macOS privacy on
119
+ `~/Library/Application Support`, for one) shows as no sessions. 0.20.3 reports them by editor
120
+ and errno in `status` and `doctor`, with a Full Disk Access hint on macOS.
121
+ - **Generation numbering** (0.20.3): a source's first archived generation is 0 where v1.1 says 1
122
+ (above).
123
+ - **Cursor sub-agents as appended records.** An anchor would place each after the parent's last
124
+ message at or before the sub-agent's own `createdAt`; that waits for a live store to confirm
125
+ that a composer's and its bubbles' `createdAt` share one clock.
126
+ - **`text_len` on the development layout** counts UTF-16 units, not code points.
127
+ - **`memhouse reset` and `relocate` on schema 3**: 0.21, with a design note first.
128
+
129
+ ## Deprecated
130
+
131
+ Unchanged from 0.20.0: passwords on the command line (`--password`, `--member-password`,
132
+ `--to-password`), and `MEMHOUSE_USER` / `MEMHOUSE_PASSWORD` as the admin for `migrate --v3-*`,
133
+ still work with a warning and are refused from the next minor release. See
134
+ [0.20.0's Deprecated](v0.20.0.md#deprecated).
@@ -741,6 +741,19 @@ function hasFormat(sql) {
741
741
  return /\bFORMAT\s+[A-Za-z0-9_]+\s*$/i.test(s);
742
742
  }
743
743
 
744
+ /**
745
+ * What `memhouse room-sql` sends: the statement as written, and the output format as the
746
+ * default_format setting. A FORMAT appended to the statement fails where the statement takes
747
+ * none (ClickHouse 25.11 parses no FORMAT after a bare SHOW ROW POLICIES); a setting applies to
748
+ * every statement, and a FORMAT the statement names itself still wins. --json picks the format,
749
+ * so a statement that names one is refused there.
750
+ */
751
+ function roomSqlRequest(sql, { json = false } = {}) {
752
+ const body = String(sql).trim().replace(/;\s*$/, '');
753
+ if (json && hasFormat(body)) throw new QueryInputError('--json picks the format itself — drop the FORMAT clause');
754
+ return { body, settings: { default_format: json ? 'JSONEachRow' : 'PrettyCompact' } };
755
+ }
756
+
744
757
  /** Refuse what is not a read before it leaves the machine (the server's readonly=2 refuses it too). */
745
758
  function assertRead(sql) {
746
759
  const s = String(sql).replace(/--[^\n]*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
@@ -858,6 +871,6 @@ module.exports = {
858
871
  renderSchemaBlocks,
859
872
  NAMES, ALIASES, FILTER_FLAGS, QueryInputError,
860
873
  schemaOf, httpTransport, fmtTop, fmtInner, parseWhen, filtersFrom, parseRef, parseQ, runQ, renderQ, allQFlags, canonicalName,
861
- resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
874
+ resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, roomSqlRequest, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
862
875
  sessionsQueryV3, resumeSpec, table,
863
876
  };
@@ -9,6 +9,12 @@
9
9
  // record without content_sha and cmp_sha) on every row; on a part row, the sha of the part object
10
10
  // as it sits in its message's parts, with message_content_sha the message's. A message's header
11
11
  // row (part_id '', kind 'none') carries the message's sha in both.
12
+ //
13
+ // native and units (harness-sessions-sql v1.1, sessions-model §2.7): a record's `native` as
14
+ // canonical JSON text, its `units` as (source_key, generation, unit_key) tuples. A part row carries
15
+ // its message's (message_native, message_units) and its own part_native. A record without them
16
+ // (an adapter older than sessions-model v1.4) leaves the columns out, so the server's defaults
17
+ // ('' and []) stand and a row mapped before them keeps its digest.
12
18
 
13
19
  const crypto = require('crypto');
14
20
 
@@ -30,6 +36,10 @@ const str = (v) => (v == null ? '' : String(v));
30
36
  const nb = (v) => (typeof v === 'boolean' ? v : null);
31
37
  const nn = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
32
38
  const tsOrNull = (v) => (v && Number.isFinite(Date.parse(v)) ? v : null);
39
+ /** A record's native as its column holds it: canonical JSON, or undefined (the column's default) when absent. */
40
+ const nativeText = (v) => (v != null && typeof v === 'object' ? jcs(v) : undefined);
41
+ /** A record's units as named tuples, or undefined (the column's default) when the record states none. */
42
+ const unitsOf = (v) => (Array.isArray(v) ? v.map(([sourceKey, generation, unitKey]) => ({ source_key: str(sourceKey), generation: Number(generation) || 0, unit_key: str(unitKey) })) : undefined);
33
43
 
34
44
  /** The part columns of one part. */
35
45
  function partColumns(p) {
@@ -39,7 +49,7 @@ function partColumns(p) {
39
49
  call_id: '', tool_name: '', args: '', server: '', is_error: null, result_status: '', output: '', output_ref: '', output_ref_sha256: '', output_ref_bytes: null,
40
50
  output_len: null, truncated: null, exit_code: null, media_type: '', inline_b64: '', ref: '', bytes_len: null, opaque: null, signature_len: null,
41
51
  replaces_form: '', replaces_value: '', error_kind: '', error_message: '', http_status: null, level: '', native_type: '',
42
- part_source: str(p.source), part_state: str(p.state),
52
+ part_source: str(p.source), part_state: str(p.state), part_native: nativeText(p.native),
43
53
  };
44
54
  switch (p.kind) {
45
55
  case 'text': case 'notice': case 'summary': case 'reasoning':
@@ -99,7 +109,8 @@ function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z
99
109
  source: str(m.source), to_user: nb(m.to_user), to_model: nb(m.to_model), status: str(m.status), state: str(m.state), on_active_branch: nb(m.on_active_branch),
100
110
  ts, requested_model: str(m.requested_model), served_model: str(m.served_model), effort: str(m.effort), response_id: str(m.response_id),
101
111
  message_aliases: Array.isArray(m.aliases) ? m.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
102
- cmp_sha: str(m.cmp_sha), message_content_sha: msgSha, first_seen: firstSeen(str(m.message_id)),
112
+ cmp_sha: str(m.cmp_sha), message_content_sha: msgSha, message_native: nativeText(m.native), message_units: unitsOf(m.units),
113
+ first_seen: firstSeen(str(m.message_id)),
103
114
  };
104
115
  const ps = Array.isArray(m.parts) ? m.parts : [];
105
116
  ps.forEach((p, i) => {
@@ -159,7 +170,7 @@ function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z
159
170
  mode: str(s.mode), kind: str(s.kind), reuse: str(s.reuse), ts_source: str(s.ts_source),
160
171
  aliases: Array.isArray(s.aliases) ? s.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
161
172
  fingerprint: s.fingerprint && Array.isArray(s.fingerprint.cmp_shas) ? s.fingerprint.cmp_shas.map(String) : [],
162
- setup: s.setup == null ? null : jcs(s.setup), content_sha: recordSha(s),
173
+ setup: s.setup == null ? null : jcs(s.setup), native: nativeText(s.native), units: unitsOf(s.units), content_sha: recordSha(s),
163
174
  } : null;
164
175
  return { parts, usage, links, lifecycle, session, calls: [...calls.values()].filter((c) => c.call_id && c.tool_name !== undefined) };
165
176
  }
@@ -0,0 +1,53 @@
1
+ -- memhouse's reading views over one member's profile tables: harness-sessions-sql v1.1 §6
2
+ -- (schema/clickhouse-views.sql), each reading the member's own {db}.{m}_* tables. Rendered by
3
+ -- schema-profile.js with {db} and {m}, after the tables (schema-profile.sql.tpl). Views are not
4
+ -- tables: they are never in the column heal or the key check.
5
+ -- * CREATE OR REPLACE, so a house created before a definition changed gets the new one at its
6
+ -- next ensure (install, update, migrate).
7
+ -- * SQL SECURITY INVOKER: a read runs with the reader's own grants and row policies (share.js),
8
+ -- never the creator's, so a view shows a reader no row the tables would not.
9
+ -- * {m}_session_turns_view, not {m}_session_turns: a table name ending in _turns reads as a 0.18
10
+ -- member's turns room (share.js memberCollisions).
11
+
12
+ -- §6, session_turns (plan): one row per turn of one session. A turn starts at a message a person
13
+ -- sent in the main conversation (role 'user', source 'user', agent_id '') and runs to the next
14
+ -- one; messages before the first prompt are turn 0. The session key is the view's parameters, so
15
+ -- the filter on the sort-key prefix comes before FINAL:
16
+ -- SELECT * FROM {db}.{m}_session_turns_view(host = '…', harness = '…', harness_path = '…',
17
+ -- session_id = '…', incarnation = '')
18
+ CREATE OR REPLACE VIEW {db}.{m}_session_turns_view SQL SECURITY INVOKER AS
19
+ WITH
20
+ parts AS (
21
+ SELECT * FROM {db}.{m}_session_parts FINAL
22
+ WHERE host = {host:String} AND harness = {harness:String}
23
+ AND harness_path = {harness_path:String}
24
+ AND session_id = {session_id:String} AND incarnation = {incarnation:String}
25
+ AND is_deleted = 0 AND removed = 0
26
+ AND state = 'current' AND part_state IN ('', 'current')
27
+ AND ifNull(on_active_branch, true)
28
+ ),
29
+ messages AS (
30
+ SELECT message_id,
31
+ min(order_key) AS order_key,
32
+ toUInt32(any(role) = 'user' AND any(source) = 'user' AND any(agent_id) = '') AS is_prompt
33
+ FROM parts
34
+ GROUP BY message_id
35
+ ),
36
+ numbered AS (
37
+ SELECT message_id, is_prompt,
38
+ sum(is_prompt) OVER (ORDER BY order_key, message_id
39
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS turn
40
+ FROM messages
41
+ )
42
+ SELECT n.turn AS turn,
43
+ anyIf(p.message_id, n.is_prompt = 1) AS prompt_message_id,
44
+ min(p.ts) AS started_at,
45
+ max(p.ts) AS ended_at,
46
+ uniqExact(p.message_id) AS messages,
47
+ countIf(p.kind = 'tool_call') AS tool_calls,
48
+ countIf(p.kind = 'tool_result' AND ifNull(p.is_error, false)) AS tool_errors,
49
+ arraySort(groupUniqArrayIf(p.served_model, p.served_model != '')) AS served_models
50
+ FROM parts AS p
51
+ INNER JOIN numbered AS n USING (message_id)
52
+ GROUP BY n.turn
53
+ ORDER BY n.turn;
@@ -8,7 +8,10 @@
8
8
  // option, nothing in <db>_archive granted to anyone else
9
9
  //
10
10
  // The DDL lives in schema-profile.sql.tpl, rendered with {db} and {m}; every statement is
11
- // CREATE … IF NOT EXISTS. Transport-free like schema-v3.js: every entry point takes `q`.
11
+ // CREATE … IF NOT EXISTS. The reading views (harness-sessions-sql v1.1 §6) live apart, in
12
+ // schema-profile-views.sql.tpl, as CREATE OR REPLACE VIEW: they are not tables, so they stay out
13
+ // of templateColumns, the column heal and the key check. Transport-free like schema-v3.js: every
14
+ // entry point takes `q`.
12
15
 
13
16
  const fs = require('fs');
14
17
  const path = require('path');
@@ -16,6 +19,7 @@ const { assertUsableName, legacyTextIndexDialect, isTextIndexGrammarRefusal, tex
16
19
  const { stripComments, qFromClient, DDL_SETTINGS } = require('./schema-v3');
17
20
 
18
21
  const TEMPLATE = path.join(__dirname, 'schema-profile.sql.tpl');
22
+ const VIEWS_TEMPLATE = path.join(__dirname, 'schema-profile-views.sql.tpl');
19
23
 
20
24
  // The columns retyped in harness-sessions-sql v1, which ensureProfileSchema brings an older table to:
21
25
  // adapter_ver UInt32 -> LowCardinality(String), child_stored Bool -> Nullable(Bool), orig_sha256
@@ -47,19 +51,43 @@ const SERVER_COLUMNS = ['writer', 'ingested_at', 'search_ngram', 'search_word',
47
51
 
48
52
  function archiveDb(db) { return `${db}_archive`; }
49
53
 
50
- /** Every fully-qualified name of one member's profile tables. */
54
+ /** The reading views, by their name after `<m>_` (schema-profile-views.sql.tpl). */
55
+ const VIEW_TYPES = ['session_turns_view'];
56
+
57
+ /** Every fully-qualified name of one member's profile tables and views. */
51
58
  function profileNames(db, member) {
52
59
  assertUsableName(db, 'house');
53
60
  assertUsableName(member, 'member');
54
61
  const out = { db, archive: archiveDb(db), member };
55
62
  for (const t of CONVERSATION_TYPES) out[t] = `${db}.${member}_${t}`;
56
63
  for (const t of CAPTURE_TYPES) out[t] = `${archiveDb(db)}.${member}_${t}`;
64
+ for (const t of VIEW_TYPES) out[t] = `${db}.${member}_${t}`;
57
65
  out.meta = `${db}.${member}_meta`;
58
66
  out.events = `${db}.${member}_events`;
59
67
  return out;
60
68
  }
61
69
 
62
70
  function readTemplate() { return fs.readFileSync(TEMPLATE, 'utf-8'); }
71
+ function readViewsTemplate() { return fs.readFileSync(VIEWS_TEMPLATE, 'utf-8'); }
72
+
73
+ /** The view statements for one member, in template order: [{ type, fq, sql }]. Each reads the member's own house. */
74
+ function renderViews(tpl, { db, member }) {
75
+ assertUsableName(db, 'house');
76
+ assertUsableName(member, 'member');
77
+ const body = stripComments(tpl).replaceAll('{db}', db).replaceAll('{m}', member);
78
+ const out = [];
79
+ for (const raw of body.split(';')) {
80
+ const sql = raw.trim();
81
+ if (!sql) continue;
82
+ const m = /^CREATE OR REPLACE VIEW ([A-Za-z0-9_]+)\.([A-Za-z0-9_]+) SQL SECURITY INVOKER AS\b/.exec(sql);
83
+ if (!m) throw new Error(`schema-profile views template: unexpected statement: ${sql.slice(0, 80)}`);
84
+ if (m[1] !== db) throw new Error(`schema-profile views template: '${m[1]}.${m[2]}' is outside the house`);
85
+ const type = m[2].startsWith(`${member}_`) ? m[2].slice(member.length + 1) : m[2];
86
+ if (!VIEW_TYPES.includes(type)) throw new Error(`schema-profile views template declares an unknown view '${m[2]}'`);
87
+ out.push({ type, fq: `${m[1]}.${m[2]}`, sql });
88
+ }
89
+ return out;
90
+ }
63
91
 
64
92
  /** The CREATE statements for one member, in template order: [{ type, database, table, fq, sql }]. */
65
93
  function renderStatements(tpl, { db, member }) {
@@ -156,12 +184,12 @@ function msg(e) { return e && e.message ? e.message : String(e); }
156
184
  * Create one member's profile tables, idempotently, then verify them against PROFILE_KEYS.
157
185
  * As ensureV3Schema: a privilege denial is counted and skipped, a lost create race is success.
158
186
  */
159
- async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null } = {}, ui = null) {
187
+ async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null, viewsTpl = null } = {}, ui = null) {
160
188
  const n = profileNames(db, member);
161
189
  const say = (m) => ui && ui.ok && ui.ok(m);
162
190
  const version = String(((await q.rows('SELECT version() AS v', { final: 0 }))[0] || {}).v || '');
163
191
  const dialect = textIndexDialectFor(version);
164
- const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], tables: {}, ok: false };
192
+ const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], views: [], view_failures: [], tables: {}, ok: false };
165
193
  if (createDatabases) {
166
194
  for (const d of [n.db, n.archive]) {
167
195
  try { await q.sql(`CREATE DATABASE IF NOT EXISTS ${d}`, DDL_SETTINGS); } catch (e) {
@@ -257,6 +285,18 @@ async function ensureProfileSchema(q, { db, member, createDatabases = false, gra
257
285
  }
258
286
  }
259
287
  if (out.retyped.length) say(`profile tables for ${member}: retyped ${out.retyped.join(', ')}`);
288
+ // The reading views, over session_parts as healed above. A view is a reader's convenience, not
289
+ // something the capture writes: a refusal goes to `denied`, any other failure to view_failures,
290
+ // and neither makes the tables not ok. Not over a session_parts that is missing or lacks a column.
291
+ if (out.created.includes(n.session_parts) && !lacking.has('session_parts')) {
292
+ for (const v of renderViews(viewsTpl || readViewsTemplate(), { db, member })) {
293
+ try { await q.sql(v.sql, DDL_SETTINGS); out.views.push(v.fq); } catch (e) {
294
+ const m = msg(e);
295
+ if (isDenied(m)) out.denied.push(`CREATE VIEW ${v.fq}`); else out.view_failures.push(`${v.fq}: ${m.split('\n')[0]}`);
296
+ }
297
+ }
298
+ if (out.views.length) say(`reading views for ${member}: ${out.views.join(', ')}`);
299
+ }
260
300
  if (grantCapture) {
261
301
  for (const g of captureGrants(db, member)) {
262
302
  try { await q.sql(g, DDL_SETTINGS); out.grants.push(g); } catch (e) {
@@ -284,7 +324,7 @@ async function ensureProfileSchema(q, { db, member, createDatabases = false, gra
284
324
  }
285
325
 
286
326
  module.exports = {
287
- TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
288
- archiveDb, profileNames, readTemplate, renderStatements, templateColumns, templateKeys, rowColumns, insertColumns,
327
+ TEMPLATE, VIEWS_TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, VIEW_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
328
+ archiveDb, profileNames, readTemplate, readViewsTemplate, renderStatements, renderViews, templateColumns, templateKeys, rowColumns, insertColumns,
289
329
  keyProblem, captureGrants, ensureProfileSchema, qFromClient,
290
330
  };
@@ -1,5 +1,7 @@
1
1
  -- memhouse's house tables as harness-sessions-sql's ClickHouse profile (step B1, tables and keys). The
2
- -- tables, columns, keys and engines are harness-sessions-sql's schema/clickhouse.sql (v1.0.0);
2
+ -- tables, columns, keys and engines are harness-sessions-sql's schema/clickhouse.sql (v1.1: v1's,
3
+ -- with the native and units columns of §4.2 and §4.3; its reading view is
4
+ -- schema-profile-views.sql.tpl);
3
5
  -- memhouse adds, beside them:
4
6
  -- * the member prefix {m}_ and the archive database {db}_archive (spec §3: physical names);
5
7
  -- * on session_parts: first_seen (a first-seen time, never written into ts), folder, project,
@@ -48,6 +50,8 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_session_parts (
48
50
  message_aliases Array(Tuple(id String, kind LowCardinality(String))),
49
51
  cmp_sha FixedString(64),
50
52
  message_content_sha FixedString(64),
53
+ message_native String DEFAULT '', -- v1.1: the message's native fields (sessions-model §2.7), canonical JSON; '' when none
54
+ message_units Array(Tuple(source_key String, generation UInt32, unit_key String)), -- v1.1: the units the message came from
51
55
  kind LowCardinality(String),
52
56
  text String DEFAULT '' CODEC(ZSTD(3)),
53
57
  call_id String DEFAULT '',
@@ -78,6 +82,7 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_session_parts (
78
82
  native_type LowCardinality(String) DEFAULT '',
79
83
  part_source LowCardinality(String) DEFAULT '',
80
84
  part_state LowCardinality(String) DEFAULT '',
85
+ part_native String DEFAULT '', -- v1.1: the part's own native fields, canonical JSON; '' when none
81
86
  content_sha FixedString(64),
82
87
  adapter LowCardinality(String),
83
88
  adapter_ver LowCardinality(String),
@@ -131,6 +136,8 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_sessions (
131
136
  aliases Array(Tuple(id String, kind LowCardinality(String))),
132
137
  fingerprint Array(FixedString(64)),
133
138
  setup Nullable(String),
139
+ native String DEFAULT '', -- v1.1: the session's native fields (sessions-model §2.7), canonical JSON; '' when none
140
+ units Array(Tuple(source_key String, generation UInt32, unit_key String)), -- v1.1: the units the session record came from
134
141
  content_sha FixedString(64),
135
142
  adapter LowCardinality(String),
136
143
  adapter_ver LowCardinality(String),
package/memhouse/share.js CHANGED
@@ -195,9 +195,11 @@ function memberCollisions(member, tables) {
195
195
  }
196
196
 
197
197
  // The names memhouse gives a member's tables in the house, as the part after `<m>_`: its tables,
198
- // the copies migrate keeps of a 0.18 room while it moves one (`<room>__migrating`) and after
199
- // (`<room>_pre_epoch`), and the scratch table share's probe makes and drops (`_mh_probe_<pid>`).
200
- const MEMBER_TABLES = [...new Set([...V3_TABLES, ...LEGACY_ROOMS, ...DEV_ROOMS, 'meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats',
198
+ // its reading views (schema-profile.js VIEW_TYPES: session_turns_view, named so it does not end in
199
+ // _turns, which memberCollisions reads as a 0.18 member's room), the copies migrate keeps of a
200
+ // 0.18 room while it moves one (`<room>__migrating`) and after (`<room>_pre_epoch`), and the
201
+ // scratch table share's probe makes and drops (`_mh_probe_<pid>`).
202
+ const MEMBER_TABLES = [...new Set([...V3_TABLES, ...LEGACY_ROOMS, ...DEV_ROOMS, 'meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats', 'session_turns_view',
201
203
  ...LEGACY_ROOMS.flatMap((r) => [`${r}_pre_epoch`, `${r}__migrating`])])];
202
204
  const isMemberTable = (rest) => MEMBER_TABLES.includes(rest) || /^_mh_probe_\d+$/.test(rest);
203
205
  const startsMemberTable = (p) => [...MEMBER_TABLES, '_mh_probe_1'].some((n) => n.startsWith(`${p}_`));