memhouse 0.20.2 → 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,37 @@ 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
+
7
38
  ## 0.20.2 — 2026-11-03
8
39
 
9
40
  Release notes: [`docs/releases/v0.20.2.md`](docs/releases/v0.20.2.md). Read its "Read this
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.
@@ -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.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 |
8
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 |
9
10
  | [0.20.1](v0.20.1.md) | 2026-11-02 | invites expire; `memhouse plugins install codex`; per-command help and clearer CLI answers |
10
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 |
@@ -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);
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+ // The normalisers' file reads, with one rule: a path that is not there (ENOENT, ENOTDIR) is an
3
+ // answer (no store, no session, no file), and any other error is an error. A read that took every
4
+ // error for "not there" turned a permission denial (macOS privacy on ~/Library, a mode change, an
5
+ // unreadable mount) into an editor with no sessions and no word of why.
6
+ //
7
+ // Each helper returns its absent answer (null, or false) only for absence, and throws anything else
8
+ // as an FsError: the path, the operation, and the errno as `code`. normalise-run.js reports it by
9
+ // editor, and `memhouse status` and `doctor` show it with hintOf's advice.
10
+ const fs = require('fs');
11
+ const os = require('os');
12
+ const path = require('path');
13
+
14
+ const ABSENT = new Set(['ENOENT', 'ENOTDIR']);
15
+ /** True for the errors that mean the path is not there. */
16
+ const absent = (e) => !!e && ABSENT.has(e.code);
17
+
18
+ class FsError extends Error {
19
+ constructor(op, p, e) {
20
+ const code = (e && e.code) || 'EIO';
21
+ super(`cannot ${op} ${p} (${code})`);
22
+ this.name = 'FsError';
23
+ this.code = code;
24
+ this.path = p;
25
+ }
26
+ }
27
+ const fail = (op, p, e) => (e instanceof FsError ? e : new FsError(op, p, e));
28
+
29
+ /** A folder's entries, or null when the folder is not there. */
30
+ function readdir(dir, opts) {
31
+ try { return fs.readdirSync(dir, opts); } catch (e) { if (absent(e)) return null; throw fail('read the folder', dir, e); }
32
+ }
33
+ /** fs.Stats, or null when the path is not there. */
34
+ function stat(p) {
35
+ try { return fs.statSync(p); } catch (e) { if (absent(e)) return null; throw fail('stat', p, e); }
36
+ }
37
+ /** fs.Stats of the link itself (a symlink is not followed), or null when the path is not there. */
38
+ function lstat(p) {
39
+ try { return fs.lstatSync(p); } catch (e) { if (absent(e)) return null; throw fail('stat', p, e); }
40
+ }
41
+ const isFile = (p) => { const s = stat(p); return !!s && s.isFile(); };
42
+ const isDir = (p) => { const s = stat(p); return !!s && s.isDirectory(); };
43
+ /** The file's text (UTF-8), or null when it is not there. */
44
+ function readText(p) {
45
+ try { return fs.readFileSync(p, 'utf-8'); } catch (e) { if (absent(e)) return null; throw fail('read', p, e); }
46
+ }
47
+ /** The file's bytes, or null when it is not there. */
48
+ function readBytes(p) {
49
+ try { return fs.readFileSync(p); } catch (e) { if (absent(e)) return null; throw fail('read', p, e); }
50
+ }
51
+ /** The resolved path, or null when it is not there. */
52
+ function realpath(p) {
53
+ try { return fs.realpathSync.native(p); } catch (e) { if (absent(e)) return null; throw fail('resolve', p, e); }
54
+ }
55
+ /** A file descriptor open for reading, or null when the file is not there. */
56
+ function openRead(p) {
57
+ try { return fs.openSync(p, 'r'); } catch (e) { if (absent(e)) return null; throw fail('open', p, e); }
58
+ }
59
+ /** True when the file opens for reading, false when it is not there; anything else throws. */
60
+ function probe(p) {
61
+ const fd = openRead(p);
62
+ if (fd === null) return false;
63
+ fs.closeSync(fd);
64
+ return true;
65
+ }
66
+
67
+ /**
68
+ * One entry's read inside a store that read (a session's file or folder in a readable root): a refusal
69
+ * skips that entry alone, recorded in `errors` as { session_id, code, path, error } (the session's id,
70
+ * or the entry's name when the id is inside the file), and the store's other entries are listed.
71
+ * One stuck file must not stop an editor's new sessions from being captured. Returns `skipped` then.
72
+ * Not there is fn's own absent answer, and any other error propagates.
73
+ */
74
+ function entry(errors, id, fn, skipped) {
75
+ try { return fn(); } catch (e) {
76
+ if (!(e instanceof FsError)) throw e;
77
+ errors.push({ session_id: String(id), code: e.code, path: e.path, error: e.message });
78
+ return skipped;
79
+ }
80
+ }
81
+ /** A list's entries, carrying the entries it skipped as `errors` (normalise-run reports them). */
82
+ const listed = (out, errors) => (errors.length ? Object.assign(out, { errors }) : out);
83
+
84
+ /**
85
+ * The advice for an error a read met, or ''. On macOS, a denial under ~/Library is almost always the
86
+ * privacy setting: the app that runs memhouse (the terminal, or the service's node) needs Full Disk
87
+ * Access to read another app's folder there.
88
+ */
89
+ function hintOf(err, { platform = process.platform, home = os.homedir() } = {}) {
90
+ const code = err && err.code;
91
+ if (code !== 'EACCES' && code !== 'EPERM') return '';
92
+ const p = String((err && err.path) || '');
93
+ if (platform === 'darwin' && p && path.resolve(p).startsWith(path.join(home, 'Library') + path.sep)) {
94
+ return 'macOS privacy: give the app that runs memhouse Full Disk Access (System Settings > Privacy & Security > Full Disk Access), then restart it';
95
+ }
96
+ return 'memhouse cannot read it: check the permissions of the path and its folders';
97
+ }
98
+
99
+ module.exports = { absent, FsError, readdir, stat, lstat, isFile, isDir, readText, readBytes, realpath, openRead, probe, entry, listed, hintOf };
@@ -69,6 +69,7 @@
69
69
  // `authType`, sits in files this module never opens.
70
70
 
71
71
  const fs = require('fs');
72
+ const fsx = require('./fsx');
72
73
  const path = require('path');
73
74
  const crypto = require('crypto');
74
75
  const { dataFolder, defaultFolder, resolveNative } = require('../harness-paths');
@@ -89,8 +90,8 @@ function storeRootDefault() {
89
90
  return defaultFolder(HARNESS);
90
91
  }
91
92
 
92
- const readdir = (d) => { try { return fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } catch { return []; } };
93
- const readText = (f) => { try { return fs.readFileSync(f, 'utf-8'); } catch { return null; } };
93
+ const readdir = (d) => (fsx.readdir(d, { withFileTypes: true }) || []).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
94
+ const readText = (f) => fsx.readText(f);
94
95
  const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
95
96
  const str = (v) => (typeof v === 'string' ? v : '');
96
97
  const ms = (iso) => { const t = Date.parse(iso); return Number.isFinite(t) ? t : null; };
@@ -159,8 +160,8 @@ function topLevelStrings(text, keys) {
159
160
 
160
161
  /** A file's first bytes: HEAD_CHUNK, or (untilNewline) its first line, null while it has none. */
161
162
  function readPrefix(file, untilNewline) {
162
- let fd;
163
- try { fd = fs.openSync(file, 'r'); } catch { return null; }
163
+ const fd = fsx.openRead(file);
164
+ if (fd === null) return null;
164
165
  try {
165
166
  let buf = Buffer.alloc(0);
166
167
  let size = HEAD_CHUNK;
@@ -175,7 +176,7 @@ function readPrefix(file, untilNewline) {
175
176
  if (n < size || buf.length >= HEAD_MAX) return null; // no complete first line (still being written), or no header
176
177
  size = Math.min(size * 2, HEAD_MAX - buf.length);
177
178
  }
178
- } catch { return null; } finally { try { fs.closeSync(fd); } catch { /* closed */ } }
179
+ } catch (e) { throw new fsx.FsError('read', file, e); } finally { try { fs.closeSync(fd); } catch { /* closed */ } }
179
180
  }
180
181
 
181
182
  /**
@@ -203,7 +204,7 @@ function readHeader(file, format) {
203
204
  */
204
205
  function list(storeRoot) {
205
206
  const root = path.resolve(storeRoot == null ? defaultFolder(HARNESS) : storeRoot);
206
- if (!fs.existsSync(root)) return [];
207
+ if (!fsx.stat(root)) return [];
207
208
  // The default store's harness_path is harness-paths' data folder (GEMINI_CLI_HOME included), so the
208
209
  // bridge→normaliser switch dedupes; an explicit root is its own resolved path.
209
210
  const harnessPath = storeRoot == null ? dataFolder(HARNESS) : resolveNative(root);
@@ -211,8 +212,9 @@ function list(storeRoot) {
211
212
  const projectOf = (slug) => {
212
213
  if (!bySlug) {
213
214
  bySlug = new Map();
215
+ const text = fsx.readText(path.join(root, 'projects.json'));
214
216
  try {
215
- const pj = JSON.parse(fs.readFileSync(path.join(root, 'projects.json'), 'utf-8'));
217
+ const pj = text === null ? null : JSON.parse(text);
216
218
  for (const [dir, sl] of Object.entries((pj && pj.projects) || {}).sort()) if (typeof sl === 'string' && !bySlug.has(sl)) bySlug.set(sl, dir);
217
219
  } catch { /* no index */ }
218
220
  }
@@ -224,30 +226,38 @@ function list(storeRoot) {
224
226
  const head = readHeader(file, format);
225
227
  if (!head || !head.sessionId) return;
226
228
  let size = 0;
227
- try { size = fs.statSync(file).size; } catch { return; }
229
+ const st = fsx.stat(file);
230
+ if (!st) return;
231
+ size = st.size;
228
232
  const e = { harness: HARNESS, harness_path: harnessPath, session_id: head.sessionId, file, format, kind: head.kind, parent, slug, cwd, size };
229
233
  const prev = found.get(e.session_id);
230
234
  const better = !prev || (e.format === 'jsonl' && prev.format !== 'jsonl')
231
235
  || (e.format === prev.format && (e.size > prev.size || (e.size === prev.size && e.file < prev.file)));
232
236
  if (better) found.set(e.session_id, e);
233
237
  };
238
+ const errors = [];
234
239
  for (const s of readdir(path.join(root, 'tmp'))) {
235
240
  if (!s.isDirectory()) continue;
236
241
  const slugDir = path.join(root, 'tmp', s.name);
237
242
  const chats = path.join(slugDir, 'chats');
238
- const names = readdir(chats);
243
+ // A project folder that could not be read: its chats are skipped, reported (fsx.entry).
244
+ const names = fsx.entry(errors, s.name, () => readdir(chats), []);
239
245
  if (!names.length) continue;
240
- const cwd = (readText(path.join(slugDir, '.project_root')) || '').trim() || projectOf(s.name);
246
+ const root0 = fsx.entry(errors, s.name, () => readText(path.join(slugDir, '.project_root')), false);
247
+ if (root0 === false) continue;
248
+ const cwd = (root0 || '').trim() || projectOf(s.name);
241
249
  for (const f of names) {
242
250
  const full = path.join(chats, f.name);
243
251
  const m = /^session-.+\.(jsonl|json)$/.exec(f.name);
244
- if (m && f.isFile()) { consider(full, m[1], s.name, cwd, ''); continue; }
252
+ if (m && f.isFile()) { fsx.entry(errors, f.name, () => consider(full, m[1], s.name, cwd, '')); continue; }
245
253
  if (!f.isDirectory()) continue;
246
254
  // A sub-agent directory, named for the main session's (sanitised) id.
247
- for (const c of readdir(full)) if (c.isFile() && /\.jsonl$/.test(c.name)) consider(path.join(full, c.name), 'jsonl', s.name, cwd, f.name);
255
+ for (const c of fsx.entry(errors, f.name, () => readdir(full), [])) {
256
+ if (c.isFile() && /\.jsonl$/.test(c.name)) fsx.entry(errors, c.name, () => consider(path.join(full, c.name), 'jsonl', s.name, cwd, f.name));
257
+ }
248
258
  }
249
259
  }
250
- return [...found.values()].map(({ size, ...e }) => e);
260
+ return fsx.listed([...found.values()].map(({ size, ...e }) => e), errors);
251
261
  }
252
262
 
253
263
  /** Complete lines only (a partial last line is held back); malformed lines are skipped. */
@@ -51,6 +51,7 @@
51
51
  // part, session_message, session_input and event are read.
52
52
 
53
53
  const fs = require('fs');
54
+ const fsx = require('./fsx');
54
55
  const path = require('path');
55
56
  const crypto = require('crypto');
56
57
  const { fileURLToPath } = require('url');
@@ -71,8 +72,8 @@ function storeRootDefault(env = process.env) {
71
72
  return harnessPaths.dataFolder(HARNESS, { env });
72
73
  }
73
74
 
74
- function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
75
- function readdir(dir) { try { return fs.readdirSync(dir).sort(); } catch { return []; } }
75
+ function isFile(p) { return fsx.isFile(p); }
76
+ function readdir(dir) { return (fsx.readdir(dir) || []).sort(); }
76
77
 
77
78
  /**
78
79
  * The session DBs of one data folder: OPENCODE_DB when set (absolute, or relative to the folder;
@@ -171,7 +172,7 @@ function keyColumns(sc) {
171
172
  */
172
173
  function list(storeRoot = storeRootDefault(), { env = process.env } = {}) {
173
174
  storeRoot = path.resolve(storeRoot);
174
- try { if (!fs.statSync(storeRoot).isDirectory()) return []; } catch { return []; }
175
+ if (!fsx.isDir(storeRoot)) return [];
175
176
  const spills = path.join(harnessPaths.resolveNative(storeRoot), 'tool-output');
176
177
  const out = [];
177
178
  for (const { file, harness_path } of dbFiles(storeRoot, env)) {
@@ -264,12 +265,12 @@ function spilled(spillDir, p) {
264
265
  const ref = path.basename(String(p || ''));
265
266
  if (!SPILL.test(ref)) return null;
266
267
  if (!spillDir) return { ref, sha: null, len: null };
267
- try {
268
- const file = path.join(spillDir, ref);
269
- if (!fs.lstatSync(file).isFile()) return { ref, sha: null, len: null };
270
- const buf = fs.readFileSync(file);
271
- return { ref, sha: sha256(buf), len: buf.length };
272
- } catch { return { ref, sha: null, len: null }; }
268
+ const file = path.join(spillDir, ref);
269
+ const st = fsx.lstat(file); // a link is never followed
270
+ if (!st || !st.isFile()) return { ref, sha: null, len: null };
271
+ const buf = fsx.readBytes(file);
272
+ if (!buf) return { ref, sha: null, len: null };
273
+ return { ref, sha: sha256(buf), len: buf.length };
273
274
  }
274
275
 
275
276
  /** A non-file URL cut to scheme://host (its path and query may carry a token), else one marker. */
@@ -39,6 +39,7 @@
39
39
  // - redaction by field path: `selectedModel.metadata.auth`, wherever a `selectedModel` sits.
40
40
 
41
41
  const fs = require('fs');
42
+ const fsx = require('./fsx');
42
43
  const path = require('path');
43
44
  const { fileURLToPath } = require('url');
44
45
 
@@ -64,7 +65,9 @@ function storeRootDefault() {
64
65
  /** A workspace's folder from workspaceStorage/<ws>/workspace.json: a path for file URIs, else the URI. */
65
66
  function workspaceFolder(wsDir) {
66
67
  let j;
67
- try { j = JSON.parse(fs.readFileSync(path.join(wsDir, 'workspace.json'), 'utf-8')); } catch { return ''; }
68
+ const text = fsx.readText(path.join(wsDir, 'workspace.json'));
69
+ if (text === null) return '';
70
+ try { j = JSON.parse(text); } catch { return ''; }
68
71
  const uri = typeof j.folder === 'string' ? j.folder : (typeof j.workspace === 'string' ? j.workspace : '');
69
72
  if (!uri.startsWith('file:')) return uri;
70
73
  try { return fileURLToPath(uri); } catch { return decodeURIComponent(uri.replace(/^file:\/\//, '')); }
@@ -78,30 +81,32 @@ function workspaceFolder(wsDir) {
78
81
  * while the file stays the same (the mtime only picks between two files).
79
82
  */
80
83
  function list(storeRoot = storeRootDefault()) {
81
- let harnessPath;
82
- try { harnessPath = fs.realpathSync.native(storeRoot); } catch { return []; }
84
+ const harnessPath = fsx.realpath(storeRoot);
85
+ if (!harnessPath) return [];
83
86
  const client = CLIENTS[path.basename(harnessPath)] || 'vscode';
84
87
  const user = path.join(harnessPath, 'User');
85
88
  const wsRoot = path.join(user, 'workspaceStorage');
86
89
  const dirs = [];
87
- let wss = [];
88
- try { wss = fs.readdirSync(wsRoot).sort(); } catch { wss = []; }
90
+ const wss = (fsx.readdir(wsRoot) || []).sort();
89
91
  for (const ws of wss) dirs.push({ dir: path.join(wsRoot, ws, 'chatSessions'), wsDir: path.join(wsRoot, ws), workspace: ws });
90
92
  dirs.push({ dir: path.join(user, 'globalStorage', 'emptyWindowChatSessions'), wsDir: null, workspace: '' });
91
93
 
92
94
  const bySid = new Map();
95
+ const errors = []; // a workspace or chat file that could not be read: skipped, reported (fsx.entry)
93
96
  for (const d of dirs) {
94
- let names;
95
- try { names = fs.readdirSync(d.dir).sort(); } catch { continue; }
97
+ const where = d.workspace || 'emptyWindowChatSessions';
98
+ const names = fsx.entry(errors, where, () => fsx.readdir(d.dir), null);
99
+ if (!names) continue;
100
+ names.sort();
96
101
  let cwd = null;
97
102
  for (const name of names) {
98
103
  const m = /^(.+)\.(jsonl|json)$/.exec(name);
99
104
  if (!m) continue;
100
105
  const file = path.join(d.dir, name);
101
- let st;
102
- try { st = fs.statSync(file); } catch { continue; }
103
- if (!st.isFile()) continue;
104
- if (cwd === null) cwd = d.wsDir ? workspaceFolder(d.wsDir) : '';
106
+ const st = fsx.entry(errors, m[1], () => fsx.stat(file), null);
107
+ if (!st || !st.isFile()) continue;
108
+ if (cwd === null) cwd = d.wsDir ? fsx.entry(errors, where, () => workspaceFolder(d.wsDir), null) : '';
109
+ if (cwd === null) break; // its workspace.json refused: the workspace's chats are skipped, reported
105
110
  const e = {
106
111
  harness: HARNESS, session_id: m[1], file, format: m[2], workspace: d.workspace, cwd, client, harness_path: harnessPath, mtime: st.mtimeMs,
107
112
  };
@@ -110,7 +115,7 @@ function list(storeRoot = storeRootDefault()) {
110
115
  if (better) bySid.set(e.session_id, e);
111
116
  }
112
117
  }
113
- return [...bySid.values()].map(({ mtime, ...e }) => e);
118
+ return fsx.listed([...bySid.values()].map(({ mtime, ...e }) => e), errors);
114
119
  }
115
120
 
116
121
  /** Change-log lines: complete lines only (a partial last line is held back), malformed ones skipped. */
@@ -437,7 +442,8 @@ function load(session) {
437
442
  if (Array.isArray(session.ops)) return replay(session.ops.map((o) => (typeof o === 'string' ? JSON.parse(o) : JSON.parse(JSON.stringify(o)))));
438
443
  let text = typeof session.text === 'string' ? session.text : null;
439
444
  if (text == null) {
440
- try { text = fs.readFileSync(session.file, 'utf-8'); } catch { return snap(null); }
445
+ text = fsx.readText(session.file);
446
+ if (text === null) return snap(null);
441
447
  }
442
448
  const json = session.format === 'json' || (session.file && /\.json$/.test(session.file));
443
449
  if (json) { try { return snap(JSON.parse(text)); } catch { return snap(null); } }
@@ -79,6 +79,7 @@
79
79
  // signatures, the git snapshot (its remote URL) and sandbox grants are never emitted.
80
80
 
81
81
  const fs = require('fs');
82
+ const fsx = require('./fsx');
82
83
  const path = require('path');
83
84
  const crypto = require('crypto');
84
85
  const zlib = require('zlib');
@@ -154,23 +155,28 @@ function openThreads(file) {
154
155
  }
155
156
 
156
157
  /** session_id → true for the threads any channel's sidebar marks archived (a flag, no time). */
157
- function readArchived(storeRoot) {
158
+ function readArchived(storeRoot, errors) {
158
159
  const out = new Map();
159
160
  let channels = [];
160
- try { channels = fs.readdirSync(path.join(storeRoot, 'db')).sort(); } catch { return out; }
161
+ const names = fsx.readdir(path.join(storeRoot, 'db'));
162
+ if (!names) return out;
163
+ channels = names.sort();
164
+ // A channel's workspace DB that could not be read: its archive flags are skipped, reported (fsx.entry).
161
165
  for (const ch of channels) {
162
166
  const file = path.join(storeRoot, 'db', ch, 'db.sqlite');
163
- try { if (!fs.statSync(file).isFile()) continue; } catch { continue; }
164
- let db = null;
165
- try {
166
- db = openReadOnly(file);
167
- const cols = columnsOf(db, 'sidebar_threads');
168
- if (!cols.has('session_id') || !cols.has('archived')) continue;
169
- for (const r of db.prepare('SELECT session_id, archived FROM sidebar_threads WHERE session_id IS NOT NULL').all()) {
170
- const sid = String(textOf(r.session_id));
171
- if (sid && Number(r.archived) === 1) out.set(sid, true);
172
- }
173
- } finally { if (db) try { db.close(); } catch { /* closed */ } }
167
+ fsx.entry(errors, path.join('db', ch), () => {
168
+ if (!fsx.isFile(file)) return;
169
+ let db = null;
170
+ try {
171
+ db = openReadOnly(file);
172
+ const cols = columnsOf(db, 'sidebar_threads');
173
+ if (!cols.has('session_id') || !cols.has('archived')) return;
174
+ for (const r of db.prepare('SELECT session_id, archived FROM sidebar_threads WHERE session_id IS NOT NULL').all()) {
175
+ const sid = String(textOf(r.session_id));
176
+ if (sid && Number(r.archived) === 1) out.set(sid, true);
177
+ }
178
+ } finally { if (db) try { db.close(); } catch { /* closed */ } }
179
+ });
174
180
  }
175
181
  return out;
176
182
  }
@@ -186,7 +192,7 @@ function readArchived(storeRoot) {
186
192
  */
187
193
  function list(storeRoot = storeRootDefault()) {
188
194
  const file = dbPathOf(storeRoot);
189
- try { if (!fs.statSync(file).isFile()) return []; } catch { return []; }
195
+ if (!fsx.isFile(file)) return [];
190
196
  const harnessPath = harnessPaths.resolveNative(storeRoot);
191
197
  const db = openThreads(file);
192
198
  let rows = [];
@@ -196,9 +202,10 @@ function list(storeRoot = storeRootDefault()) {
196
202
  rows = db.prepare(`SELECT id, ${cols.has('updated_at') ? 'updated_at' : "'' AS updated_at"} FROM threads ORDER BY id`).all()
197
203
  .map((r) => ({ id: String(textOf(r.id)), updated_at: r.updated_at == null ? '' : String(textOf(r.updated_at)) }));
198
204
  } finally { db.close(); }
199
- const archived = readArchived(storeRoot);
205
+ const errors = [];
206
+ const archived = readArchived(storeRoot, errors);
200
207
  const rel = path.relative(storeRoot, file);
201
- return rows.map((r) => ({ harness: HARNESS, harness_path: harnessPath, session_id: r.id, db_rel: rel, updated_at: r.updated_at, archived: archived.get(r.id) === true }));
208
+ return fsx.listed(rows.map((r) => ({ harness: HARNESS, harness_path: harnessPath, session_id: r.id, db_rel: rel, updated_at: r.updated_at, archived: archived.get(r.id) === true })), errors);
202
209
  }
203
210
 
204
211
  /** One thread's row and blob, read together so they are of the same save. */
package/editors/sqlite.js CHANGED
@@ -21,6 +21,7 @@
21
21
  const fs = require('fs');
22
22
  const path = require('path');
23
23
  const { pathToFileURL } = require('url');
24
+ const fsx = require('./normalise/fsx');
24
25
 
25
26
  let DatabaseSync = null;
26
27
 
@@ -48,21 +49,30 @@ function openReadOnly(file) {
48
49
  // torn — measured: 3,525 "malformed" and 304 corrupt reads in 10,625 against a live writer, none
49
50
  // with the plain open, which in rollback mode creates no file. The header says which (byte 18:
50
51
  // 1 rollback, 2 WAL).
51
- if (!fs.existsSync(file)) throw new Error(`unable to open database file: ${file}`);
52
- if (!fs.existsSync(`${file}-wal`) && isWalHeader(file)) return new DatabaseSync(`${pathToFileURL(path.resolve(file)).href}?immutable=1`, { readOnly: true });
52
+ //
53
+ // The file is probed first, by the normalisers' rule (normalise/fsx.js): not there throws with code
54
+ // ENOENT (a caller's "no store"); any other error, a permission denial or macOS privacy, throws
55
+ // with its own errno and path, never as SQLite's "unable to open" that a caller could take for
56
+ // absence. A SQLite error on a file the probe could read is SQLite's, and stays an error.
57
+ const head = headerOf(file);
58
+ if (head === null) throw Object.assign(new Error(`unable to open database file: ${file}`), { code: 'ENOENT', path: file });
59
+ if (!fsx.stat(`${file}-wal`) && isWal(head)) return new DatabaseSync(`${pathToFileURL(path.resolve(file)).href}?immutable=1`, { readOnly: true });
53
60
  return new DatabaseSync(file, { readOnly: true });
54
61
  }
55
62
 
56
- /** True when the file's SQLite header says WAL mode (file format write version, byte 18, is 2). */
57
- function isWalHeader(file) {
58
- let fd;
63
+ /** The file's first 20 bytes, or null when it is not there; any other error throws (fsx.FsError). */
64
+ function headerOf(file) {
65
+ const fd = fsx.openRead(file);
66
+ if (fd === null) return null;
59
67
  try {
60
- fd = fs.openSync(file, 'r');
61
68
  const b = Buffer.alloc(20);
62
- return fs.readSync(fd, b, 0, 20, 0) === 20 && b.toString('latin1', 0, 15) === 'SQLite format 3' && b[18] === 2;
63
- } catch { return false; } finally { if (fd !== undefined) fs.closeSync(fd); }
69
+ return b.subarray(0, fs.readSync(fd, b, 0, 20, 0));
70
+ } catch (e) { throw new fsx.FsError('read', file, e); } finally { fs.closeSync(fd); }
64
71
  }
65
72
 
73
+ /** True when a SQLite header says WAL mode (file format write version, byte 18, is 2). */
74
+ const isWal = (b) => b.length === 20 && b.toString('latin1', 0, 15) === 'SQLite format 3' && b[18] === 2;
75
+
66
76
  /**
67
77
  * A column's value as text, whether SQLite handed back TEXT or BLOB.
68
78
  *
@@ -2036,6 +2036,25 @@ const refusesVanish = (x) => !!(x.vanishRefused || x.stillThere || x.candidateEr
2036
2036
  const storesN = (n) => `${n} store${n === 1 ? '' : 's'}`;
2037
2037
  /** One shell word: as is when it is plain, else single-quoted (a store path may hold spaces). */
2038
2038
  const shellWord = (w) => (/^[A-Za-z0-9_./:@%+=,-]+$/.test(w) ? w : `'${String(w).replace(/'/g, "'\\''")}'`);
2039
+ /**
2040
+ * The pass's normaliser errors (normalise-run.js), one line per editor and code, and the advice for
2041
+ * a denial (normalise/fsx.js hintOf): `status` and `doctor` read them back from the log (passlog.js).
2042
+ */
2043
+ function normaliseLines(s) {
2044
+ const by = new Map();
2045
+ for (const e of (s.normalise && s.normalise.errors) || []) {
2046
+ const k = `${e.harness}\u0000${e.code || ''}`;
2047
+ if (!by.has(k)) by.set(k, { ...e, n: 0 });
2048
+ by.get(k).n++;
2049
+ }
2050
+ const out = [];
2051
+ for (const e of by.values()) {
2052
+ out.push(`[memhouse] normalise error: ${e.harness} (${e.code || 'error'}): ${e.error}${e.n > 1 ? ` (and ${e.n - 1} more)` : ''}`);
2053
+ const hint = require('../../editors/normalise/fsx').hintOf(e);
2054
+ if (hint) out.push(`[memhouse] normalise hint: ${e.harness} (${e.code || 'error'}): ${hint}`);
2055
+ }
2056
+ return out;
2057
+ }
2039
2058
  function vanishLines(s) {
2040
2059
  const out = [];
2041
2060
  for (const x of s.adapters || []) {
@@ -2561,6 +2580,7 @@ async function main(argv = process.argv.slice(2), opts = {}) {
2561
2580
  s = await runPass(ctx);
2562
2581
  console.log(`${summary(s)} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
2563
2582
  for (const line of vanishLines(s)) console.log(line);
2583
+ for (const line of normaliseLines(s)) console.log(line);
2564
2584
  // Without the package a profile pass captures no Claude Code or Codex session at all; that
2565
2585
  // must not read as a quiet pass with nothing new.
2566
2586
  const ac = require('./adapter-capture');
@@ -2591,7 +2611,7 @@ module.exports = {
2591
2611
  applyAdapters, adapterHarnesses, adapterPass, routeAdapterSessions, ADAPTER_MAX_SESSION_BYTES,
2592
2612
  parseSourceRoots, classifyRoot, discover, Cache, cachePath, captureFile, runPass, buildSession, batches,
2593
2613
  archiveRows, recordsFromArchive, rebuildFromArchive, replayArchive, manifestRow, fkey, splitLines, prefixDigest, extendDigest, digestOf, digestBuffer, HttpHouse, DryHouse,
2594
- TABLE_COLUMNS, dedupeSession, dedupeSweep, codexRepeatsOf, profileRepeatsOf, profileAdapterPass, profileRouting, main, defaultAdapters, applyBridge, combineRoots, storeRoots, adapterOf, summary, vanishLines,
2614
+ TABLE_COLUMNS, dedupeSession, dedupeSweep, codexRepeatsOf, profileRepeatsOf, profileAdapterPass, profileRouting, main, defaultAdapters, applyBridge, combineRoots, storeRoots, adapterOf, summary, vanishLines, normaliseLines,
2595
2615
  passLock, PASS_LOCK_STALE_MS, PASS_LOCK_REFRESH_MS, duplicatesRefusal, reloadCache,
2596
2616
  BLOCK, ANCHOR, CHUNK, BATCH_ROWS, BATCH_BYTES, FRAGMENT_MS,
2597
2617
  };
@@ -71,6 +71,18 @@ function messagesIn(file) {
71
71
  return n;
72
72
  }
73
73
 
74
+ // An error as the pass reports it: its first line, and a code `status` and `doctor` key their advice on,
75
+ // with the path when a read named one. The code is the errno of a file read (normalise/fsx.js: EACCES,
76
+ // EPERM, EIO) or SQLite's, named (SQLITE_CANTOPEN on a file the probe could open, SQLITE_NOTADB).
77
+ const SQLITE_CODES = { 5: 'BUSY', 6: 'LOCKED', 10: 'IOERR', 11: 'CORRUPT', 14: 'CANTOPEN', 23: 'AUTH', 26: 'NOTADB' };
78
+ function errorOf(e) {
79
+ const out = { error: String(e && e.message).split('\n')[0] };
80
+ if (e && typeof e.code === 'string' && /^E[A-Z0-9]+$/.test(e.code)) out.code = e.code;
81
+ else if (e && Number.isInteger(e.errcode)) out.code = `SQLITE_${SQLITE_CODES[e.errcode & 0xff] || e.errcode & 0xff}`;
82
+ if (e && e.path) out.path = String(e.path);
83
+ return out;
84
+ }
85
+
74
86
  function loadIndex(file) {
75
87
  try { const d = JSON.parse(fs.readFileSync(file, 'utf-8')); return d && d.v === 1 && d.sessions ? d : { v: 1, sessions: {} }; } catch { return { v: 1, sessions: {} }; }
76
88
  }
@@ -83,7 +95,7 @@ function saveIndex(file, idx) {
83
95
 
84
96
  /**
85
97
  * Bring every normalised log up to date. Returns { harnesses, sessions, normalised, written:
86
- * { new, append, rewrite, same }, errors: [{ harness, session_id?, error }], ms }.
98
+ * { new, append, rewrite, same }, errors: [{ harness, session_id?, error, code?, path? }], ms } (errorOf).
87
99
  * opts.mods: the modules (default: editors/normalise/); opts.storeRoot(harness): a store to list
88
100
  * instead of the module's default (tests).
89
101
  */
@@ -99,12 +111,19 @@ function run({ home, mods = modules(), storeRoot = null, log = () => {}, now = D
99
111
  for (const m of mods.filter((x) => scope.normaliserInScope(x.HARNESS, editors))) {
100
112
  let list;
101
113
  try { list = storeRoot && storeRoot(m.HARNESS) ? m.list(storeRoot(m.HARNESS)) : m.list(); } catch (e) {
102
- st.errors.push({ harness: m.HARNESS, error: String(e && e.message).split('\n')[0] });
114
+ st.errors.push({ harness: m.HARNESS, ...errorOf(e) });
103
115
  // A store busy for a moment (an editor mid-save) is no reason to forget its sessions: keep
104
116
  // their index entries, or the next pass would normalise every one of them again.
105
117
  for (const [k, v] of Object.entries(idx.sessions)) if (k.startsWith(`${m.HARNESS}\t`)) seen[k] = v;
106
118
  continue;
107
119
  }
120
+ // An entry a readable store could not read (editors/normalise/fsx.js entry: one session's file or
121
+ // folder refused) is that session's error, the store's other sessions listed. Its index entries are
122
+ // kept, as a failed list keeps them, so it is not normalised again from scratch once it reads.
123
+ for (const e of (list && list.errors) || []) {
124
+ st.errors.push({ harness: m.HARNESS, ...e });
125
+ for (const [k, v] of Object.entries(idx.sessions)) { const [h, , sid] = k.split('\t'); if (h === m.HARNESS && sid === e.session_id) seen[k] = v; }
126
+ }
108
127
  if (!list || !list.length) continue;
109
128
  st.harnesses++;
110
129
  for (const s of list) {
@@ -138,7 +157,7 @@ function run({ home, mods = modules(), storeRoot = null, log = () => {}, now = D
138
157
  st.normalised++;
139
158
  seen[k0] = { sig, file, at: now };
140
159
  } catch (e) {
141
- st.errors.push({ harness: m.HARNESS, session_id: String(s.session_id), error: String(e && e.message).split('\n')[0] });
160
+ st.errors.push({ harness: m.HARNESS, session_id: String(s.session_id), ...errorOf(e) });
142
161
  seen[k0] = { sig, file: '', failed: true, at: now, tries: prev && prev.failed && prev.sig === sig ? (prev.tries || 0) + 1 : 0 };
143
162
  }
144
163
  }
@@ -151,4 +170,4 @@ function run({ home, mods = modules(), storeRoot = null, log = () => {}, now = D
151
170
  return st;
152
171
  }
153
172
 
154
- module.exports = { run, modules, sigOf, DIR };
173
+ module.exports = { run, modules, sigOf, errorOf, DIR };
@@ -19,8 +19,12 @@ const FAIL = /\[memhouse\] (?:v3 )?pass failed: ([^\n]*)/;
19
19
  // A pass's refused vanish sweeps and its vanish warnings, printed after its pass line (capture.js vanishLines).
20
20
  const VANISH = /^\[memhouse\] vanish refused: ([^\n]*)$/gm;
21
21
  const WARNED = /^\[memhouse\] vanish warning: ([^\n]*)$/gm;
22
+ // The pass's normaliser errors and their advice, printed after its pass line (capture.js normaliseLines).
23
+ const NORM_ERR = /^\[memhouse\] normalise error: (\S+) \(([^)]*)\): ([^\n]*)$/gm;
24
+ // A hint names its editor and code: one editor can fail on two codes that need different advice.
25
+ const NORM_HINT = /^\[memhouse\] normalise hint: (\S+) \(([^)]*)\): ([^\n]*)$/gm;
22
26
 
23
- /** @returns {{outcome:'ok'|'failed'|null, sessions?:number, rows?:number, why?:string, auth?:boolean, vanishRefused?:string[]}} */
27
+ /** @returns {{outcome:'ok'|'failed'|null, sessions?:number, rows?:number, why?:string, auth?:boolean, vanishRefused?:string[], normaliseErrors?:object[]}} */
24
28
  function lastPass(logText) {
25
29
  const text = String(logText || '');
26
30
  let lastOk = -1; let lastFail = -1; let okM = null; let failM = null;
@@ -33,12 +37,18 @@ function lastPass(logText) {
33
37
  }
34
38
  const refused = [...text.slice(lastOk).matchAll(VANISH)].map((m) => m[1].trim());
35
39
  const warned = [...text.slice(lastOk).matchAll(WARNED)].map((m) => m[1].trim());
40
+ const hints = new Map([...text.slice(lastOk).matchAll(NORM_HINT)].map((m) => [`${m[1]}\u0000${m[2]}`, m[3].trim()]));
41
+ const normalise = [...text.slice(lastOk).matchAll(NORM_ERR)].map((m) => {
42
+ const hint = hints.get(`${m[1]}\u0000${m[2]}`);
43
+ return { harness: m[1], code: m[2], error: m[3].trim(), ...(hint ? { hint } : {}) };
44
+ });
36
45
  return {
37
46
  ...(okM[1] !== undefined ? { outcome: 'ok', sessions: Number(okM[1]), rows: Number(okM[2]) }
38
47
  : okM[3] !== undefined ? { outcome: 'ok', sessions: Number(okM[3]), rows: Number(okM[4]), v3: true }
39
48
  : { outcome: 'ok', sessions: Number(okM[5]), rows: Number(okM[6]), v3: true, profile: true }),
40
49
  ...(refused.length ? { vanishRefused: refused } : {}),
41
50
  ...(warned.length ? { vanishWarned: warned } : {}),
51
+ ...(normalise.length ? { normaliseErrors: normalise } : {}),
42
52
  };
43
53
  }
44
54
 
@@ -86,6 +96,16 @@ function vanishChecks(lp) {
86
96
  return out;
87
97
  }
88
98
 
99
+ /**
100
+ * doctor's checks for the last pass's normaliser errors: one per editor and code, a failure, with the
101
+ * advice capture.js printed (macOS: Full Disk Access) or the log to read.
102
+ */
103
+ function normaliseChecks(lp) {
104
+ return ((lp && lp.normaliseErrors) || []).map((e) => ({
105
+ ok: false, label: `${e.harness}: a read failed in the last pass (${e.code}): ${e.error}`, hint: e.hint || 'read: ~/.memhouse/logs/shipper.log',
106
+ }));
107
+ }
108
+
89
109
  /** The last pass of the shipper log in `logDir` (its last 64 KiB): what `status` and doctor read. */
90
110
  function readLastPass(logDir) {
91
111
  const fs = require('fs'); const path = require('path');
@@ -97,4 +117,4 @@ function readLastPass(logDir) {
97
117
  } catch { return { outcome: null }; }
98
118
  }
99
119
 
100
- module.exports = { lastPass, readLastPass, describeFailure, describeVanish, describeVanishWarning, vanishChecks };
120
+ module.exports = { lastPass, readLastPass, describeFailure, describeVanish, describeVanishWarning, vanishChecks, normaliseChecks };
@@ -7,13 +7,16 @@
7
7
  // THE PASS (each line is a decision; the rest is adapter-capture.js's)
8
8
  // archive session_units: every unit verbatim (enc, sha256, redacted, orig_sha256), its body in
9
9
  // 1 MiB chunks; and a read manifest (unit_key '', enc 'manifest') for every read that
10
- // opens a generation or delivers units or gone keys (harness-sessions-sql §4.1). Only a reset
11
- // opens a generation past the first (sessions §3); a snapshot replaces its one unit.
10
+ // opens a generation or delivers units or gone keys (harness-sessions-sql §4.1). A read
11
+ // opens a generation when it is the source's first, a reset or a snapshot (§4.1): a
12
+ // changed snapshot's one unit goes into a generation of its own.
12
13
  // An archive write carries one captured_at, set by the capture and later than the
13
14
  // session's last one (kept in the local state as archivedAt; on a refold, the
14
15
  // archive's latest), so a capped read's steps stay apart and a clock that steps back
15
16
  // between passes never reorders them; never left to the server's default, which a
16
- // parallel parse of a big insert may evaluate per chunk: it is the replay's pass id. A write that fits one batch is one statement, manifests last;
17
+ // parallel parse of a big insert may evaluate per chunk: it is the replay's pass id. The one
18
+ // exception is the re-run of a pass that stopped before its cursor write: it writes under
19
+ // that pass's captured_at, so the two replay as one pass (header: crash). A write that fits one batch is one statement, manifests last;
17
20
  // a bigger one writes its units first (~8 MiB a statement), then all its manifests in
18
21
  // one statement however big: a manifest never lands before its units (a stop between
19
22
  // the two leaves units no manifest names, which the fold ignores and the re-read
@@ -35,9 +38,13 @@
35
38
  // statement holds a pass's manifests, header: archive). Each pass reads the unit
36
39
  // versions it needs and does not hold, at most REPLAY_MAX_BYTES (32 MiB) and 1,000
37
40
  // units a response, and keeps only what the fold holds, so a unit edited at every pass
38
- // is not read or held in all its versions at once. A pass
39
- // that stopped before its cursor write cannot be told from one that did not: its reads
40
- // replay as a pass of their own
41
+ // is not read or held in all its versions at once. A pass that stopped before its
42
+ // cursor write cannot be told from one that did not by the archive alone; its re-run,
43
+ // which sees the archive ahead of the cursor, writes under the stopped pass's
44
+ // captured_at (header: crash), so the stopped read and the re-run replay as one pass.
45
+ // Each source's input to a pass's derive is that pass's net change to its fold: with
46
+ // one manifest a source, as every other pass has, exactly that manifest's units and gone
47
+ // keys; across a stopped read and its re-run, a unit the re-run took back is no change
41
48
  // re-derive an upgrade's refold (harness-sessions-sql §8: a new adapter_ver replayed from session_units
42
49
  // alone) replays the archive the same way, under the new version, over the house's rows:
43
50
  // every row it makes, current or a removed version, is written above the house's revs at
@@ -84,13 +91,22 @@
84
91
  // crash the local state is dropped before the first write and saved after the last, so a
85
92
  // pass that stops anywhere leaves none, and the next one refolds from the house. A
86
93
  // refold reads the archive's manifests first: when a stopped pass left them ahead of the
87
- // cursor, its generation, progress and delta fold continue from the archive (a reset
88
- // opens the generation past the archive's), so no two manifests of a generation share a
89
- // progress and the fold is what the archive says. A source re-read from scratch that
90
- // delivers exactly what the generation the stopped pass opened holds (its own read: no
91
- // cursor yet, or a reset; never a source_gone generation) stays in it and archives
92
- // nothing: only its position moves. So does a stream re-read from the cursor that
93
- // delivers only what the stopped pass appended to the cursor's generation
94
+ // cursor, its generation, progress and delta fold continue from the archive (a reset or
95
+ // a snapshot opens the generation past the archive's), so no two manifests of a
96
+ // generation share a progress and the fold is what the archive says. A source re-read
97
+ // from scratch that delivers exactly what the generation the stopped pass opened holds
98
+ // (its own read: no cursor yet, a reset or a snapshot; never a source_gone generation)
99
+ // stays in it and archives nothing: only its position moves. So does a stream re-read
100
+ // from the cursor that delivers only what the stopped pass appended to the cursor's
101
+ // generation, and a snapshot that delivers what a stopped pass archived in the cursor's
102
+ // own generation (an archive from before a snapshot opened a generation). The re-run
103
+ // writes its archive under the stopped pass's captured_at (archive), so a replay folds
104
+ // the stopped read and the re-run as one pass: a unit the stopped read delivered that the
105
+ // re-run no longer sees is never derived, as in a clean run of what was committed
106
+ // (harness-sessions-sql SPEC-QUESTIONS q20). A stop past the rows has already written the
107
+ // stopped read's records: the re-run keeps any it no longer sees as removed versions,
108
+ // which a replay does not make (nor does the reference engine's), and an upgrade's
109
+ // re-derive tombstones them
94
110
  // edges after the rows, before the cursor (harness-sessions-sql §5 step 4): the engine edges the session
95
111
  // is the fork or the origin of (profile-edges.js), so the rollup bills a fork's copied
96
112
  // usage once. A fork whose edge moved and is not this session gets its sessions row
@@ -284,6 +300,9 @@ async function captureSession(ctx, ad, d, store, listed) {
284
300
  ...archOnly.filter((sk) => ours.has(sk) && !A.get(sk).gone).map((sk) => ({ source_key: sk, mode: '', role: 'main', omitted: true })),
285
301
  ];
286
302
  const confirmed = new Set(); // omitted sources the adapter answered are not there
303
+ // The captured_at of a pass that stopped before its cursor write (an archive ahead of the cursor): this
304
+ // re-run writes under it (header: crash). null when no source is ahead.
305
+ let stoppedAt = null;
287
306
  for (const src of [...listed.sources, ...omitted]) {
288
307
  const sk = src.source_key;
289
308
  const h = H.get(sk);
@@ -306,6 +325,7 @@ async function captureSession(ctx, ad, d, store, listed) {
306
325
  // Only a generation the stopped pass opened (newer than the cursor's) and that holds a read
307
326
  // (not a source_gone one) can be the one a re-read from scratch repeats.
308
327
  resumed = a.gen > hGen && !a.gone ? a.fold : null;
328
+ if (a.at && (stoppedAt === null || String(a.at) > stoppedAt)) stoppedAt = String(a.at);
309
329
  appendedTo = a.gen === hGen && !a.gone ? { fold: a.fold, progress: String(a.progress) } : null;
310
330
  } else prior = h && !goneOf(h) ? { gen: Number(h.generation), progress: String(h.progress), position: parse(h.position, null), known: parse(h.known, {}) } : null;
311
331
  }
@@ -339,7 +359,15 @@ async function captureSession(ctx, ad, d, store, listed) {
339
359
  units = r.units; gone = []; knownNext = {}; full = true; opens = true;
340
360
  break;
341
361
  case 'snapshot':
342
- units = r.units; knownNext = {};
362
+ // A changed snapshot opens the next generation (harness-sessions-sql §4.1), unless it is the
363
+ // stopped pass's own read again (header: crash): the unit a generation the stopped pass opened
364
+ // holds, or one it archived in the cursor's own generation (before a snapshot opened one).
365
+ if ((resumed && sameUnits(r.units, resumed)) || (appendedTo && sameUnits(r.units, appendedTo.fold))) {
366
+ reread = r.units; knownNext = Object.fromEntries(resumed || appendedTo.fold); full = true;
367
+ break;
368
+ }
369
+ gen = prior ? gen + 1 : gen;
370
+ units = r.units; knownNext = {}; opens = true;
343
371
  break;
344
372
  case 'appended': case 'delta':
345
373
  units = r.units;
@@ -368,7 +396,7 @@ async function captureSession(ctx, ad, d, store, listed) {
368
396
  }
369
397
  sources[sk] = { gen, progress, position: r.position ?? null, known: src.mode === 'stream' ? {} : knownNext, mode: src.mode || '', role: src.role || 'main' };
370
398
  if (changed) fresh.push({ source_key: sk, generation: gen, complete: false, units, gone });
371
- if (!prior || r.continuity === 'reset') {
399
+ if (!prior || r.continuity === 'reset' || r.continuity === 'snapshot') {
372
400
  const byKey = new Map();
373
401
  for (const u of reread || units) { const k = String(u.unit_key); byKey.delete(k); byKey.set(k, { ...u, unit_key: k }); }
374
402
  opened.set(sk, { gen, units: [...byKey.values()] });
@@ -501,7 +529,9 @@ async function captureSession(ctx, ad, d, store, listed) {
501
529
  last = known.length || local == null ? Math.max(0, ...known)
502
530
  : (typeof ctx.house.archivedAt === 'function' ? await ctx.house.archivedAt(ref) : 0);
503
531
  }
504
- archivedAt = Math.max(Number(ctx.now()), last + 1);
532
+ // The re-run of a stopped pass writes under that pass's captured_at (header: crash); every other
533
+ // write is later than the session's last one.
534
+ archivedAt = stoppedAt !== null ? Date.parse(`${stoppedAt.replace(' ', 'T')}Z`) : Math.max(Number(ctx.now()), last + 1);
505
535
  for (const r of archive) r.captured_at = base.chTs(archivedAt);
506
536
  const units = archive.filter((r) => r.enc !== 'manifest');
507
537
  const manifests = archive.filter((r) => r.enc === 'manifest');
@@ -792,8 +822,9 @@ async function replayArchive(ctx, ad, d, key, ref, { adapter, adapterVer, order,
792
822
  const byOrder = (a, b) => (rank.has(a) ? rank.get(a) : order.length) - (rank.has(b) ? rank.get(b) : order.length) || (a < b ? -1 : a > b ? 1 : 0);
793
823
  const sources = new Map();
794
824
  // The units a pass needs and does not hold yet are read for that pass (paged by pass), and only
795
- // what the fold still holds is kept after it: a snapshot or delta source keeps one generation and
796
- // adds a version of a unit at every edit, so holding (or reading at once) every version a
825
+ // what the fold still holds is kept after it: a delta source keeps one generation and adds a
826
+ // version of a unit at every edit (so did a snapshot source, in an archive from before a snapshot
827
+ // opened a generation), so holding (or reading at once) every version a
797
828
  // generation ever named grows with the session's history. A read is bounded by bytes, at most
798
829
  // `maxBytes` of the units' archived text and 1,000 units a response (one unit larger than that
799
830
  // alone); the sizes of every unit a generation's manifests name are read once, with that
@@ -863,12 +894,24 @@ async function replayArchive(ctx, ad, d, key, ref, { adapter, adapterVer, order,
863
894
  let view = new Map();
864
895
  for (const pass of passes) {
865
896
  let full = state === null;
866
- const fresh = [];
897
+ // Each source's input is the pass's net change to its fold (header: replay): with one manifest a
898
+ // source, that manifest's units and gone keys; across a stopped read and its re-run (one captured_at,
899
+ // header: crash), a unit the re-run took back is no change, and is never derived.
900
+ const before = new Map();
867
901
  for (const m of pass) {
902
+ if (!before.has(m.source_key)) { const s = sources.get(m.source_key); before.set(m.source_key, s ? { generation: s.generation, gone: s.gone, units: new Map(s.units) } : null); }
868
903
  foldManifest(sources, m);
869
904
  if (m.source_gone ? sources.get(m.source_key).gone !== 'kept' : m.continuity === 'reset') full = true;
870
- else if (!m.source_gone && ((m.units || []).length || (m.gone || []).length)) {
871
- fresh.push({ source_key: m.source_key, generation: m.generation, complete: false, units: await unitsAt(m.source_key, m.generation, m.units || []), gone: m.gone || [] });
905
+ }
906
+ const fresh = [];
907
+ if (!full) {
908
+ for (const [sk, b] of before) {
909
+ const s = sources.get(sk);
910
+ if (s.gone) continue; // a kept vanish: its units stay as they were
911
+ const same = !!b && !b.gone && b.generation === s.generation;
912
+ const units = [...s.units].filter(([uk, sha]) => !same || b.units.get(uk) !== sha);
913
+ const gone = same ? [...b.units.keys()].filter((uk) => !s.units.has(uk)) : [];
914
+ if (units.length || gone.length) fresh.push({ source_key: sk, generation: s.generation, complete: false, units: await unitsAt(sk, s.generation, units), gone });
872
915
  }
873
916
  }
874
917
  fresh.sort((a, b) => byOrder(a.source_key, b.source_key));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memhouse",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "description": "memhouse — agent conversation memory across 17 editors: parse-on-client shippers, a typed ClickHouse store with one set of rooms per member, and the agentlytics dashboard",
5
5
  "main": "bin/memhouse.js",
6
6
  "bin": {