memhouse 0.20.1 → 0.20.3

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,98 @@ 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.3 — 2026-11-03
8
+
9
+ Release notes: [`docs/releases/v0.20.3.md`](docs/releases/v0.20.3.md). Read its "Read this
10
+ before upgrading".
11
+
12
+ - **An editor store memhouse cannot read is an error, never an editor with no sessions.** The
13
+ normalisers (agy, Copilot CLI, Cursor, cursor-agent, Devin, Gemini CLI, Goose, OpenCode, VS Code,
14
+ Zed) took any error reading a store for "not there", so a permission denial (macOS privacy on
15
+ `~/Library/Application Support`, for one) showed as no sessions and said nothing. Now only a path
16
+ that is not there (ENOENT, ENOTDIR) is no store. Any other error reading the store itself (its
17
+ folder, its database) stops that editor's pass with its errno, or SQLite's code. Each SQLite store
18
+ file is probed before it is opened, so a refused open is reported with its errno, not as SQLite's
19
+ "unable to open".
20
+ - One session's file or folder that cannot be read inside a readable store is skipped and
21
+ reported as that session's error; the editor's other sessions are still captured, and the
22
+ session is read once it can be.
23
+ - `memhouse status` and `memhouse doctor` name each editor whose store the last pass could not
24
+ read, with the error. On macOS, a denial under `~/Library` comes with the fix: Full Disk Access
25
+ for the app that runs memhouse.
26
+ - The normalised session logs of readable stores do not change.
27
+ - **A changed snapshot opens the next generation** (harness-sessions-sql §4.1), and a pass stopped
28
+ after archiving one no longer archives it a second time when it runs again. No house reads a
29
+ snapshot source today, so no row changes.
30
+ - **A pass stopped before its cursor write, then run again on other content, replays as a clean
31
+ run** (harness-sessions-sql SPEC-QUESTIONS q20). A replay from the archive, and an adapter
32
+ upgrade's re-derive, took the stopped read as a pass of its own, so a record the re-run no longer
33
+ saw (say, in a log rewritten between the two passes) was written as a removed version none of the
34
+ committed reads had. The re-run now writes under the stopped pass's time, and a replay folds the
35
+ two as one pass. Removed versions an earlier replay or re-derive wrote this way, 0.20.2's first
36
+ pass among them, stay until something re-derives that session.
37
+
38
+ ## 0.20.2 — 2026-11-03
39
+
40
+ Release notes: [`docs/releases/v0.20.2.md`](docs/releases/v0.20.2.md). Read its "Read this
41
+ before upgrading".
42
+
43
+ - **Before upgrading: the first capture re-derives every session from its archive.** memhouse now
44
+ reads editors through `@agent-realm/harness-session-adapters` 1.1.0, whose adapters state
45
+ `adapter_ver` 0.3.0. A new adapter version is an upgrade (harness-sessions-sql §8, "Re-derive from
46
+ the archive"), so the first pass after `memhouse update` replays each session's archived passes
47
+ under 0.3.0 and writes its rows again above the house's revs. It reads the archive, not the
48
+ editors' stores, and writes every session once; on a large house that first pass takes longer
49
+ than a usual one. Later passes are as before. The editors memhouse reads through its own session
50
+ logs (every editor but Claude Code and Codex) re-derive in the same pass: their adapter,
51
+ `memhouse-bridge`, moves to 1.0.2 for the Cursor sub-agent links (below).
52
+ - **The profile tables follow harness-sessions-sql v1.1: a record's native fields, and the units it
53
+ came from.** Five columns, added to an existing house by the schema step (`ADD COLUMN`, at the
54
+ end of the table; a new house has them at the specification's positions):
55
+ - `sessions.native` and `session_parts.message_native`, `part_native`: the fields of the
56
+ editor's own record that the adapter declares (a Claude Code message's `message.stop_reason`,
57
+ say), as canonical JSON, `''` when there are none;
58
+ - `sessions.units` and `session_parts.message_units`: the `(source_key, generation, unit_key)`
59
+ of each archived unit the record came from, so a row joins back to its raw line in
60
+ `<db>_archive.<m>_session_units`.
61
+ A row the capture wrote before 0.20.2 holds `''` and `[]` until its session is re-derived (above).
62
+ The legacy conversion leaves the five columns out of its `content_sha` line, as v1.1 asks, so a
63
+ converted session keeps its shas.
64
+ - **A member's reading view of turns: `<m>_session_turns_view`** (harness-sessions-sql v1.1 §6,
65
+ `session_turns`, still a plan there). One row per turn of one session, a turn running from a
66
+ prompt to the next one, with its messages, tool calls, failed tool results and the models that
67
+ served it:
68
+ `SELECT * FROM <db>.<m>_session_turns_view(host = '…', harness = '…', harness_path = '…', session_id = '…', incarnation = '')`.
69
+ The schema step makes it (`CREATE OR REPLACE`). It runs as its reader (`SQL SECURITY INVOKER`),
70
+ so it shows nobody a row the tables would not, and a share does not reach it: a share is still
71
+ the six tables. It is not named `<m>_session_turns`, because a table ending in `_turns` reads as
72
+ a 0.18 member's room when memhouse looks for colliding member names.
73
+
74
+ - **`memhouse room-sql` runs `SHOW ROW POLICIES` on ClickHouse 25.11.** It appended its output
75
+ format (`FORMAT PrettyCompact`, or `JSONEachRow` with `--json`) to the statement, and 25.11 parses
76
+ no FORMAT after a bare `SHOW ROW POLICIES` or `SHOW POLICIES`, so the statement failed with a
77
+ syntax error. The format now goes as the `default_format` setting, which applies to every
78
+ statement; a FORMAT the statement names itself still wins.
79
+ - **The development layout (`<m>_turns`, `MEMHOUSE_LAYOUT=v3-dev`): an adapter upgrade writes above
80
+ the rows it replaces.** A new adapter version re-derived an unchanged file, from disk or from the
81
+ archive, at the rev the file's last pass had written, so its rows tied with the old ones and won
82
+ only by insertion order (listed under Deferred in 0.20.0). It now writes one rev above the
83
+ highest the house holds for that file, tombstones included. No derive code changed, so nothing is
84
+ re-derived. The profile tables were not affected.
85
+ - **Cursor: a chat's sub-agents are captured as its links.** Cursor names a chat's sub-agents only
86
+ in the chat's own record (`subagentComposerIds`), never in a message, and the profile capture did
87
+ not read them there, so the links from a chat to its sub-agents were missing. The log adapter
88
+ (`memhouse-bridge` 1.0.2) now reads them as links stated on the chat, one per sub-agent. No live
89
+ Cursor store has shown a sub-agent yet: the tests use synthetic sessions. Each new sub-agent
90
+ still rewrites the chat's log; placing it in the log where it appeared waits for a live sample
91
+ (`docs/design/sessions-engine.md`).
92
+ - **A session log memhouse cannot read is never rewritten into an empty one.** memhouse refuses to
93
+ rewrite the normalised log of a session that had messages into one with none, since an empty
94
+ read is far likelier a misread store than a real clear. It counted the old log's messages as 0
95
+ when the log was there but could not be read (a permission or disk error), which let that
96
+ rewrite through. Such a log now stops the session's pass with "cannot read the existing log
97
+ (<code>)", reported and retried like any other failed read.
98
+
7
99
  ## 0.20.1 — 2026-11-02
8
100
 
9
101
  Release notes: [`docs/releases/v0.20.1.md`](docs/releases/v0.20.1.md).
package/bin/memhouse.js CHANGED
@@ -2303,6 +2303,12 @@ async function cmdStatus() {
2303
2303
  : fail ? warn(`shipper: running — ${out.shipper.via} — but ${fail}`)
2304
2304
  : ok(`shipper: running — ${out.shipper.via}`));
2305
2305
  if (stale) console.log(' restart it: memhouse stop && memhouse start');
2306
+ // An editor whose store the last pass could not read (a permission denial, macOS privacy): said, with
2307
+ // the advice, never left to read as an editor with no sessions.
2308
+ for (const e of (out.shipper.lastPass && out.shipper.lastPass.normaliseErrors) || []) {
2309
+ console.log(warn(`${e.harness}: a read failed in the last pass (${e.code}): ${e.error}`));
2310
+ if (e.hint) console.log(` ${e.hint}`);
2311
+ }
2306
2312
  }
2307
2313
  if (out.update) console.log(warn(`update available: ${out.update.target} on '${out.update.channel}' — you have ${PKG.version} — run: memhouse update`));
2308
2314
  console.log(out.daemons.dashboard
@@ -2737,6 +2743,9 @@ async function doctorTail(cfg, add) {
2737
2743
  // The last pass's vanish sweeps (passlog.js vanishChecks): a refused one fails, naming the command an
2738
2744
  // emptied store needs; a listed session's source its list left out that still read is a warning.
2739
2745
  for (const c of require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).vanishChecks(sh.lastPass)) add(c.ok, c.label, c.hint, { warn: !!c.warn });
2746
+ // The last pass's normaliser errors (passlog.js normaliseChecks): a store, or a session in one, that
2747
+ // could not be read fails, with the advice for a denial (macOS: Full Disk Access), never silently.
2748
+ for (const c of require(path.join(REPO_ROOT, 'memhouse', 'shipper', 'passlog')).normaliseChecks(sh.lastPass)) add(c.ok, c.label, c.hint);
2740
2749
  // The dashboard is optional: the house, the capture and the /mem skills work without it. Not
2741
2750
  // running is said as a warning, never a failure: doctor's exit is the house's health, and a
2742
2751
  // reader chasing "memory looks wrong" must not be sent after a page nobody opened.
@@ -2896,14 +2905,13 @@ async function cmdRoomSql() {
2896
2905
  const body0 = qc().rewriteRooms(sql, { member, db: cfg.db, schema });
2897
2906
  qc().assertRead(body0);
2898
2907
  if (flags.print === true) { console.log(body0); return 0; }
2899
- const stripped = body0.trim().replace(/;\s*$/, '');
2908
+ // The format travels as a setting, never appended to the statement (q-cli roomSqlRequest).
2909
+ const req = qc().roomSqlRequest(body0, { json: JSON_OUT });
2910
+ const text = await t.raw(req.body, { database: cfg.db, settings: req.settings });
2900
2911
  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
2912
  console.log(JSON.stringify(text.split('\n').filter(Boolean).map((l) => JSON.parse(l))));
2904
2913
  return 0;
2905
2914
  }
2906
- const text = await t.raw(qc().hasFormat(stripped) ? stripped : `${stripped}\nFORMAT PrettyCompact`, { database: cfg.db });
2907
2915
  process.stdout.write(text.endsWith('\n') || !text ? text : `${text}\n`);
2908
2916
  return 0;
2909
2917
  } 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,8 @@ 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.3](v0.20.3.md) | 2026-11-03 | an editor store memhouse cannot read is an error, with the macOS Full Disk Access hint; a stopped pass replays as a clean run |
9
+ | [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
10
  | [0.20.1](v0.20.1.md) | 2026-11-02 | invites expire; `memhouse plugins install codex`; per-command help and clearer CLI answers |
9
11
  | [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
12
  | [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).
@@ -0,0 +1,111 @@
1
+ # memhouse 0.20.3 — 2026-11-03
2
+
3
+ A patch release on 0.20.2, with no schema change and no re-derive. Install it with `memhouse update`,
4
+ or `npm install -g memhouse`.
5
+
6
+ - **An editor store memhouse cannot read is an error, never an editor with no sessions.** A
7
+ permission denial (macOS privacy on `~/Library/Application Support`, for one) used to show as
8
+ an editor with nothing in it. `memhouse status` and `memhouse doctor` now name the editor, the
9
+ error and, on macOS, the fix: Full Disk Access for the app that runs memhouse.
10
+ - **A pass stopped before it finished, then run again on other content, replays as a clean run.**
11
+ A replay from the archive, and an adapter upgrade's re-derive, no longer write removed versions
12
+ for records no committed read had.
13
+ - **A changed snapshot opens the next generation** (harness-sessions-sql §4.1).
14
+
15
+ ## Read this before upgrading
16
+
17
+ Nothing in the house changes, and there is no migration or re-derive. Two things behave differently:
18
+
19
+ - **An unreadable store is reported.** Where an editor's store could not be read, the shipper used
20
+ to read it as empty; now the pass reports the error for that editor and leaves its sessions as
21
+ they were. If `memhouse status` shows a line like
22
+ `cursor: a read failed in the last pass (EACCES): …`, read the hint under it. On macOS it is
23
+ almost always the privacy setting: give the app that runs memhouse (your terminal, or the
24
+ service's node) Full Disk Access in System Settings > Privacy & Security, then restart it
25
+ (`memhouse stop && memhouse start`).
26
+ - One session's file that cannot be read inside a readable store is reported as that session's
27
+ error; the editor's other sessions are still captured.
28
+ - **Removed versions a replay wrote by mistake stay until that session is re-derived.** 0.20.2's
29
+ first pass re-derived every session, and a session whose archive held a pass stopped part way,
30
+ then re-run after its source changed, may hold a removed version of a record no committed read
31
+ had. It is never counted and shows only in `memhouse q removed`. Nothing removes it now; the next
32
+ re-derive of that session does.
33
+
34
+ ```bash
35
+ memhouse update # then: memhouse status — every line a check mark
36
+ ```
37
+
38
+ ## Editor stores memhouse cannot read
39
+
40
+ The editor normalisers (agy, Copilot CLI, Cursor, cursor-agent, Devin, Gemini CLI, Goose, OpenCode,
41
+ VS Code, Zed) took any error reading a store for "not there".
42
+
43
+ - Now only a path that is not there (ENOENT, ENOTDIR) is no store. Any other error reading the
44
+ store itself (its folder, its database) stops that editor's pass with its errno, or SQLite's code,
45
+ and the editor's sessions keep their logs.
46
+ - A refusal inside a readable store (one session's file or folder) skips that session alone and
47
+ reports it; it is read once it can be.
48
+ - Each SQLite store file is opened for reading before SQLite opens it, so a refused open is reported
49
+ with its errno (EACCES, EPERM), not as SQLite's "unable to open database file". A SQLite error on
50
+ a file that opened stays an error, named by its code (`SQLITE_CANTOPEN`, `SQLITE_NOTADB`).
51
+ - `memhouse status` prints each such error with its hint; `memhouse doctor` fails one check per
52
+ editor and code. On macOS, a denial under `~/Library` comes with the Full Disk Access hint.
53
+ - The normalised session logs of readable stores do not change.
54
+
55
+ ## A stopped pass, then other content
56
+
57
+ A pass that stops between its archive write and its cursor write (the shipper killed, a ClickHouse
58
+ error part way through a session) is run again by the next pass. When the editor's store changed in
59
+ between, so that something the stopped pass read is gone, the live state was always right, but a
60
+ replay from the archive treated the stopped read as a pass of its own and wrote what it read as a
61
+ removed version.
62
+
63
+ - The re-run now writes under the stopped pass's time, and a replay folds the two as one pass, as a
64
+ clean run of what was committed would (harness-sessions-sql SPEC-QUESTIONS q20).
65
+ - A pass stopped after it wrote its rows has already shown what it read; live keeps any of it the
66
+ re-run no longer sees as a removed version, which a replay does not make. An adapter upgrade's
67
+ re-derive removes it.
68
+
69
+ ## Conformance
70
+
71
+ These notes claim no conformance. The harness-sessions-sql conformance kit v1.1.1 (harness-sessions-sql
72
+ 4c7c330), with the session adapters 1.1.0 (harness-skeleton e1e5900), ran against memhouse at 1c6ffbc,
73
+ this release's code (the release commit adds only the version and these notes), on ClickHouse
74
+ 26.7.4.58 and 25.11.9.34, with the same result on both. The kit passed every item that applies to
75
+ memhouse: 25 pass.
76
+
77
+ - **Crash-safe** passes, the crash followed by other content included (0.20.2's one open fail).
78
+ - **Units resolve** passes: 1,170 triples, the broad scenario's 1,060 and a kept vanish's 55 live and
79
+ 55 replayed.
80
+ - **Units only**, **Native is declared** and **Absences replay** (reset and gone keys; vanished, kept)
81
+ pass.
82
+ - Two **Absences replay** parts are not applicable: memhouse declares only the `kept` vanish policy, so
83
+ "vanished, removed" and "policy changed" do not apply.
84
+ - **Database profile parity** was not run: memhouse has a ClickHouse profile only.
85
+
86
+ ## Smaller changes
87
+
88
+ - **A changed snapshot opens the next generation** (harness-sessions-sql §4.1), and a pass stopped
89
+ after archiving one no longer archives it twice when it runs again. No house reads a snapshot
90
+ source today (Claude Code, Codex and memhouse's own session logs are streams), so no row changes.
91
+ - **Generation numbering:** no change. harness-sessions-sql v1.1.1 (sessions v1.4.4) makes only the
92
+ order of a source's generations significant, so memhouse's archives, whose first generation is 0,
93
+ conform as they are.
94
+ - **The test suites of the capture engine run in CI** on both ClickHouse lines (repository).
95
+
96
+ ## Deferred
97
+
98
+ - **Cursor sub-agents as appended records.** An anchor would place each after the parent's last
99
+ message at or before the sub-agent's own `createdAt`; that waits for a live store to confirm
100
+ that a composer's and its bubbles' `createdAt` share one clock.
101
+ - **`text_len` on the development layout** counts UTF-16 units, not code points.
102
+ - **The capture's per-session rollup query** costs a fixed 0.2 to 0.4 seconds a session; 0.21, with
103
+ a design note first.
104
+ - **`memhouse reset` and `relocate` on schema 3**: 0.21, with a design note first.
105
+
106
+ ## Deprecated
107
+
108
+ Unchanged from 0.20.0: passwords on the command line (`--password`, `--member-password`,
109
+ `--to-password`), and `MEMHOUSE_USER` / `MEMHOUSE_PASSWORD` as the admin for `migrate --v3-*`,
110
+ still work with a warning and are refused from the next minor release. See
111
+ [0.20.0's Deprecated](v0.20.0.md#deprecated).
@@ -67,6 +67,7 @@
67
67
  // table or column throws. The runner retries a failed session.
68
68
 
69
69
  const fs = require('fs');
70
+ const fsx = require('./fsx');
70
71
  const path = require('path');
71
72
  const crypto = require('crypto');
72
73
  const { placeChildren } = require('../sessions-log');
@@ -377,10 +378,12 @@ function spillRef(uri, shown, stepsDir, sid) {
377
378
  if (!m || m[1] !== sid) return null;
378
379
  const file = path.join(stepsDir, m[2], 'output.txt');
379
380
  let st;
380
- try { st = fs.statSync(file); } catch { return null; }
381
+ st = fsx.stat(file);
382
+ if (!st) return null;
381
383
  if (!st.isFile() || st.size <= Buffer.byteLength(String(shown || ''), 'utf-8')) return null;
382
384
  let buf;
383
- try { buf = fs.readFileSync(file); } catch { return null; }
385
+ buf = fsx.readBytes(file);
386
+ if (!buf) return null;
384
387
  return buf.length > Buffer.byteLength(String(shown || ''), 'utf-8') ? { sha: sha256(buf), len: buf.length } : null;
385
388
  }
386
389
 
@@ -803,8 +806,8 @@ function fromTranscript(sid, text, summary, ann) {
803
806
  // The one error that may read as empty: a table or column an older schema lacks. Everything else
804
807
  // (busy, locked, SQLITE_PROTOCOL, malformed, IOERR, NOTADB) fails list() or normalise().
805
808
  const isMissing = (e) => /no such (table|column)/i.test(String((e && e.message) || e));
806
- function readdir(dir) { try { return fs.readdirSync(dir); } catch { return []; } }
807
- function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
809
+ function readdir(dir) { return fsx.readdir(dir) || []; }
810
+ function isFile(p) { return fsx.isFile(p); }
808
811
 
809
812
  function readSummaries(file) {
810
813
  const out = new Map();
@@ -831,30 +834,33 @@ function readSummaries(file) {
831
834
  * conversation_summaries.db is not named: what is read from it is in the entry.
832
835
  */
833
836
  function list(storeRoot = storeRootDefault()) {
834
- if (!fs.existsSync(storeRoot)) return [];
837
+ if (!fsx.stat(storeRoot)) return [];
835
838
  const harnessPath = require('../harness-paths').resolveNative(storeRoot); // = dataFolder('agy') for the default store
836
839
  const byId = new Map();
837
840
  const add = (id) => {
838
841
  if (!byId.has(id)) byId.set(id, { harness: HARNESS, session_id: id, harness_path: harnessPath, root: storeRoot, db_path: null, wal_path: null, transcript_path: null, annotation_path: null, summary: null });
839
842
  return byId.get(id);
840
843
  };
844
+ const errors = []; // a session whose file could not be read: skipped, reported (fsx.entry)
841
845
  const conv = path.join(storeRoot, 'conversations');
842
846
  for (const f of readdir(conv)) {
843
847
  const m = /^(.+)\.db$/.exec(f);
844
- if (m && isFile(path.join(conv, f))) { const e = add(m[1]); e.db_path = path.join(conv, f); e.wal_path = `${e.db_path}-wal`; }
848
+ if (m && fsx.entry(errors, m[1], () => isFile(path.join(conv, f)), false)) { const e = add(m[1]); e.db_path = path.join(conv, f); e.wal_path = `${e.db_path}-wal`; }
845
849
  }
846
850
  const brain = path.join(storeRoot, 'brain');
847
851
  for (const id of readdir(brain)) {
848
852
  const p = path.join(brain, id, '.system_generated', 'logs', 'transcript_full.jsonl');
849
- if (isFile(p)) add(id).transcript_path = p;
853
+ if (fsx.entry(errors, id, () => isFile(p), false)) add(id).transcript_path = p;
850
854
  }
851
855
  const sums = readSummaries(path.join(storeRoot, 'conversation_summaries.db'));
852
- for (const e of byId.values()) {
856
+ for (const e of [...byId.values()]) {
853
857
  e.summary = sums.get(e.session_id) || null;
854
858
  const ann = path.join(storeRoot, 'annotations', `${e.session_id}.pbtxt`);
855
- if (isFile(ann)) e.annotation_path = ann;
859
+ const has = fsx.entry(errors, e.session_id, () => isFile(ann), null);
860
+ if (has === null) { byId.delete(e.session_id); continue; } // its annotation refused: the session waits, reported
861
+ if (has) e.annotation_path = ann;
856
862
  }
857
- return [...byId.values()].sort((a, b) => (a.session_id < b.session_id ? -1 : a.session_id > b.session_id ? 1 : 0));
863
+ return fsx.listed([...byId.values()].sort((a, b) => (a.session_id < b.session_id ? -1 : a.session_id > b.session_id ? 1 : 0)), errors);
858
864
  }
859
865
 
860
866
  function dbSource(db, transcript) {
@@ -45,6 +45,7 @@
45
45
  // not conversation: they are left out of the log.
46
46
 
47
47
  const fs = require('fs');
48
+ const fsx = require('./fsx');
48
49
  const path = require('path');
49
50
  const crypto = require('crypto');
50
51
  const harnessPaths = require('../harness-paths');
@@ -63,16 +64,17 @@ function list(storeRoot = storeRootDefault()) {
63
64
  const base = path.join(storeRoot, 'session-state');
64
65
  // The data folder's real path: absolute, symlinks resolved, in the filesystem's own case.
65
66
  const harnessPath = harnessPaths.resolveNative(storeRoot);
66
- let names = [];
67
- try { names = fs.readdirSync(base); } catch { return []; }
67
+ const names = fsx.readdir(base);
68
+ if (!names) return [];
68
69
  const out = [];
70
+ const errors = []; // a session whose file could not be read: skipped, reported (fsx.entry)
69
71
  for (const sid of names) {
70
72
  const dir = path.join(base, sid);
71
73
  const events = path.join(dir, 'events.jsonl');
72
- try { if (!fs.statSync(events).isFile()) continue; } catch { continue; }
74
+ if (!fsx.entry(errors, sid, () => fsx.isFile(events), false)) continue;
73
75
  out.push({ harness: HARNESS, harness_path: harnessPath, session_id: sid, dir, events, workspace: path.join(dir, 'workspace.yaml') });
74
76
  }
75
- return out;
77
+ return fsx.listed(out, errors);
76
78
  }
77
79
 
78
80
  /** Complete lines only: a partial last line is held back. */
@@ -139,8 +141,7 @@ function normalise(session) {
139
141
  : parseEvents(fs.readFileSync(session.events, 'utf-8'));
140
142
  let ws = session.workspace;
141
143
  if (!ws || typeof ws === 'string') {
142
- let txt = '';
143
- try { txt = fs.readFileSync(ws || '', 'utf-8'); } catch { txt = ''; }
144
+ const txt = (ws && fsx.readText(ws)) || '';
144
145
  ws = parseWorkspace(txt);
145
146
  }
146
147
  const start = events.find((e) => e.type === 'session.start');
@@ -78,6 +78,7 @@
78
78
  // A referenced blob that is missing means a torn read: normalise returns null and the pass skips.
79
79
 
80
80
  const fs = require('fs');
81
+ const fsx = require('./fsx');
81
82
  const path = require('path');
82
83
  const crypto = require('crypto');
83
84
  const { fileURLToPath } = require('url');
@@ -99,7 +100,7 @@ function storeRootDefault() { return harnessPaths.dataFolder(HARNESS); }
99
100
 
100
101
  const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
101
102
  const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
102
- function sortedDir(dir) { try { return fs.readdirSync(dir).sort(); } catch { return []; } }
103
+ function sortedDir(dir) { return (fsx.readdir(dir) || []).sort(); }
103
104
 
104
105
  /**
105
106
  * Sessions in one store: every chats/<bucket>/<chatId>/ and acp-sessions/<id>/ that has a store.db.
@@ -109,19 +110,20 @@ function sortedDir(dir) { try { return fs.readdirSync(dir).sort(); } catch { ret
109
110
  * not named: it is rewritten (updatedAtMs) on every open, and only its cwd is read, as a fallback.
110
111
  */
111
112
  function list(storeRoot = storeRootDefault()) {
112
- try { if (!fs.statSync(storeRoot).isDirectory()) return []; } catch { return []; }
113
+ if (!fsx.isDir(storeRoot)) return [];
113
114
  const harnessPath = harnessPaths.resolveNative(storeRoot);
114
115
  const out = [];
116
+ const errors = []; // a chat whose folder or store could not be read: skipped, reported (fsx.entry)
115
117
  const add = (dir, sid, bucket) => {
116
118
  const db = path.join(dir, 'store.db');
117
- try { if (!fs.statSync(db).isFile()) return; } catch { return; } // empty or refused-headless chat
119
+ if (!fsx.entry(errors, sid, () => fsx.isFile(db), false)) return; // empty or refused-headless chat
118
120
  out.push({ harness: HARNESS, harness_path: harnessPath, session_id: sid, dir, db, wal: `${db}-wal`, bucket });
119
121
  };
120
122
  const chats = path.join(storeRoot, 'chats');
121
- for (const b of sortedDir(chats)) for (const id of sortedDir(path.join(chats, b))) add(path.join(chats, b, id), id, b);
123
+ for (const b of sortedDir(chats)) for (const id of fsx.entry(errors, b, () => sortedDir(path.join(chats, b)), [])) add(path.join(chats, b, id), id, b);
122
124
  const acp = path.join(storeRoot, 'acp-sessions');
123
125
  for (const id of sortedDir(acp)) add(path.join(acp, id), id, null);
124
- return out;
126
+ return fsx.listed(out, errors);
125
127
  }
126
128
 
127
129
  // ── protobuf wire format: just enough to walk the store ───────────────────────────────────────
@@ -468,7 +470,9 @@ function parseMetaValue(v) {
468
470
  } catch { return null; }
469
471
  }
470
472
  function readMetaJsonCwd(dir) {
471
- try { const j = JSON.parse(fs.readFileSync(path.join(dir, 'meta.json'), 'utf-8')); return typeof j.cwd === 'string' ? j.cwd : ''; } catch { return ''; }
473
+ const text = fsx.readText(path.join(dir, 'meta.json'));
474
+ if (text === null) return '';
475
+ try { const j = JSON.parse(text); return typeof j.cwd === 'string' ? j.cwd : ''; } catch { return ''; }
472
476
  }
473
477
  function uriPath(u) {
474
478
  if (!u) return '';
@@ -490,7 +494,9 @@ function normalise(session) {
490
494
  return build(session, pickMeta(session.meta), (id) => m.get(id), scan);
491
495
  }
492
496
  let db;
493
- try { db = openReadOnly(session.db); } catch { return null; }
497
+ // Gone since the list: no read this pass. Any other open error (a permission denial, a file SQLite
498
+ // cannot open) is reported, never taken for an empty chat.
499
+ try { db = openReadOnly(session.db); } catch (e) { if (fsx.absent(e)) return null; throw e; }
494
500
  try {
495
501
  db.exec('BEGIN');
496
502
  const row = db.prepare("SELECT value FROM meta WHERE key = '0'").get();
@@ -48,6 +48,7 @@
48
48
  // `encrypted_model`. ItemTable is never queried at all (it holds the login rows).
49
49
 
50
50
  const fs = require('fs');
51
+ const fsx = require('./fsx');
51
52
  const path = require('path');
52
53
  const { openReadOnly, textOf } = require('../sqlite');
53
54
  const harnessPaths = require('../harness-paths');
@@ -165,7 +166,7 @@ const bubbleRange = (cid) => [`bubbleId:${cid}:`, `bubbleId:${cid};`];
165
166
  */
166
167
  function list(storeRoot = storeRootDefault()) {
167
168
  const file = dbPathOf(storeRoot);
168
- if (!fs.existsSync(file)) return [];
169
+ if (!fsx.stat(file)) return [];
169
170
  const harnessPath = fs.realpathSync.native(storeRoot);
170
171
  const db = openReadOnly(file);
171
172
  try {
@@ -80,6 +80,7 @@
80
80
  // index. Only a missing table or column reads as empty.
81
81
 
82
82
  const fs = require('fs');
83
+ const fsx = require('./fsx');
83
84
  const os = require('os');
84
85
  const path = require('path');
85
86
  const crypto = require('crypto');
@@ -126,7 +127,7 @@ const isMissing = (e) => /no such (table|column)/i.test(String((e && e.message)
126
127
  function list(storeRoot = storeRootDefault()) {
127
128
  const harnessPath = harnessPaths.resolveNative(storeRoot);
128
129
  const file = path.join(harnessPath, DB_FILE);
129
- try { if (!fs.statSync(file).isFile()) return []; } catch { return []; } // no store
130
+ if (!fsx.isFile(file)) return []; // no store
130
131
  // A store that is busy or locked mid-save must not read as "no sessions": an open or a query that
131
132
  // fails throws, and the runner keeps its index. Only a missing table or column reads as empty.
132
133
  const db = openReadOnly(file);