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 +31 -0
- package/bin/memhouse.js +9 -0
- package/docs/releases/README.md +1 -0
- package/docs/releases/v0.20.3.md +111 -0
- package/editors/normalise/agy.js +16 -10
- package/editors/normalise/copilot-cli.js +7 -6
- package/editors/normalise/cursor-agent.js +13 -7
- package/editors/normalise/cursor.js +2 -1
- package/editors/normalise/devin.js +2 -1
- package/editors/normalise/fsx.js +99 -0
- package/editors/normalise/gemini-cli.js +23 -13
- package/editors/normalise/opencode.js +10 -9
- package/editors/normalise/vscode.js +19 -13
- package/editors/normalise/zed.js +23 -16
- package/editors/sqlite.js +18 -8
- package/memhouse/shipper/capture.js +21 -1
- package/memhouse/shipper/normalise-run.js +23 -4
- package/memhouse/shipper/passlog.js +22 -2
- package/memhouse/shipper/profile-capture.js +64 -21
- package/package.json +1 -1
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.
|
package/docs/releases/README.md
CHANGED
|
@@ -5,6 +5,7 @@ do, how to upgrade, what is deprecated and when it goes, and what was deferred.
|
|
|
5
5
|
|
|
6
6
|
| version | date | headline |
|
|
7
7
|
|---|---|---|
|
|
8
|
+
| [0.20.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).
|
package/editors/normalise/agy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) {
|
|
807
|
-
function isFile(p) {
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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) =>
|
|
93
|
-
const readText = (f) =>
|
|
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
|
-
|
|
163
|
-
|
|
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 {
|
|
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 (!
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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) {
|
|
75
|
-
function readdir(dir) {
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
if (cwd === null)
|
|
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
|
-
|
|
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); } }
|
package/editors/normalise/zed.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
/**
|
|
57
|
-
function
|
|
58
|
-
|
|
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)
|
|
63
|
-
} catch {
|
|
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,
|
|
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),
|
|
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).
|
|
11
|
-
// opens a generation
|
|
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.
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
|
89
|
-
// progress and the fold is what the archive says. A source re-read
|
|
90
|
-
// delivers exactly what the generation the stopped pass opened holds
|
|
91
|
-
// cursor yet, or a
|
|
92
|
-
// nothing: only its position moves. So does a stream re-read
|
|
93
|
-
// delivers only what the stopped pass appended to the cursor's
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
796
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
871
|
-
|
|
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.
|
|
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": {
|