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 +61 -0
- package/bin/memhouse.js +3 -4
- package/docs/guides/share.md +1 -0
- package/docs/releases/README.md +1 -0
- package/docs/releases/v0.20.2.md +134 -0
- package/memhouse/house/q-cli.js +14 -1
- package/memhouse/house/records-to-profile.js +14 -3
- package/memhouse/house/schema-profile-views.sql.tpl +53 -0
- package/memhouse/house/schema-profile.js +46 -6
- package/memhouse/house/schema-profile.sql.tpl +8 -1
- package/memhouse/share.js +5 -3
- package/memhouse/shipper/capture.js +39 -9
- package/memhouse/shipper/log-adapter.js +18 -7
- package/memhouse/shipper/normalise-run.js +8 -2
- package/package.json +2 -2
- package/ui/package-lock.json +264 -318
- package/ui/package.json +5 -4
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
|
-
|
|
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); }
|
package/docs/guides/share.md
CHANGED
|
@@ -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
|
package/docs/releases/README.md
CHANGED
|
@@ -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).
|
package/memhouse/house/q-cli.js
CHANGED
|
@@ -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,
|
|
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.
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
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}_`));
|