memhouse 0.20.1 → 0.20.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
  *
@@ -741,6 +741,19 @@ function hasFormat(sql) {
741
741
  return /\bFORMAT\s+[A-Za-z0-9_]+\s*$/i.test(s);
742
742
  }
743
743
 
744
+ /**
745
+ * What `memhouse room-sql` sends: the statement as written, and the output format as the
746
+ * default_format setting. A FORMAT appended to the statement fails where the statement takes
747
+ * none (ClickHouse 25.11 parses no FORMAT after a bare SHOW ROW POLICIES); a setting applies to
748
+ * every statement, and a FORMAT the statement names itself still wins. --json picks the format,
749
+ * so a statement that names one is refused there.
750
+ */
751
+ function roomSqlRequest(sql, { json = false } = {}) {
752
+ const body = String(sql).trim().replace(/;\s*$/, '');
753
+ if (json && hasFormat(body)) throw new QueryInputError('--json picks the format itself — drop the FORMAT clause');
754
+ return { body, settings: { default_format: json ? 'JSONEachRow' : 'PrettyCompact' } };
755
+ }
756
+
744
757
  /** Refuse what is not a read before it leaves the machine (the server's readonly=2 refuses it too). */
745
758
  function assertRead(sql) {
746
759
  const s = String(sql).replace(/--[^\n]*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '').trim();
@@ -858,6 +871,6 @@ module.exports = {
858
871
  renderSchemaBlocks,
859
872
  NAMES, ALIASES, FILTER_FLAGS, QueryInputError,
860
873
  schemaOf, httpTransport, fmtTop, fmtInner, parseWhen, filtersFrom, parseRef, parseQ, runQ, renderQ, allQFlags, canonicalName,
861
- resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
874
+ resolveRef, hitSpec, lookupSpec, withIso, rewriteRooms, hasFormat, roomSqlRequest, assertRead, statsV3, renderStats, statusV3, statusSpec, machinesFolded,
862
875
  sessionsQueryV3, resumeSpec, table,
863
876
  };
@@ -9,6 +9,12 @@
9
9
  // record without content_sha and cmp_sha) on every row; on a part row, the sha of the part object
10
10
  // as it sits in its message's parts, with message_content_sha the message's. A message's header
11
11
  // row (part_id '', kind 'none') carries the message's sha in both.
12
+ //
13
+ // native and units (harness-sessions-sql v1.1, sessions-model §2.7): a record's `native` as
14
+ // canonical JSON text, its `units` as (source_key, generation, unit_key) tuples. A part row carries
15
+ // its message's (message_native, message_units) and its own part_native. A record without them
16
+ // (an adapter older than sessions-model v1.4) leaves the columns out, so the server's defaults
17
+ // ('' and []) stand and a row mapped before them keeps its digest.
12
18
 
13
19
  const crypto = require('crypto');
14
20
 
@@ -30,6 +36,10 @@ const str = (v) => (v == null ? '' : String(v));
30
36
  const nb = (v) => (typeof v === 'boolean' ? v : null);
31
37
  const nn = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
32
38
  const tsOrNull = (v) => (v && Number.isFinite(Date.parse(v)) ? v : null);
39
+ /** A record's native as its column holds it: canonical JSON, or undefined (the column's default) when absent. */
40
+ const nativeText = (v) => (v != null && typeof v === 'object' ? jcs(v) : undefined);
41
+ /** A record's units as named tuples, or undefined (the column's default) when the record states none. */
42
+ const unitsOf = (v) => (Array.isArray(v) ? v.map(([sourceKey, generation, unitKey]) => ({ source_key: str(sourceKey), generation: Number(generation) || 0, unit_key: str(unitKey) })) : undefined);
33
43
 
34
44
  /** The part columns of one part. */
35
45
  function partColumns(p) {
@@ -39,7 +49,7 @@ function partColumns(p) {
39
49
  call_id: '', tool_name: '', args: '', server: '', is_error: null, result_status: '', output: '', output_ref: '', output_ref_sha256: '', output_ref_bytes: null,
40
50
  output_len: null, truncated: null, exit_code: null, media_type: '', inline_b64: '', ref: '', bytes_len: null, opaque: null, signature_len: null,
41
51
  replaces_form: '', replaces_value: '', error_kind: '', error_message: '', http_status: null, level: '', native_type: '',
42
- part_source: str(p.source), part_state: str(p.state),
52
+ part_source: str(p.source), part_state: str(p.state), part_native: nativeText(p.native),
43
53
  };
44
54
  switch (p.kind) {
45
55
  case 'text': case 'notice': case 'summary': case 'reasoning':
@@ -99,7 +109,8 @@ function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z
99
109
  source: str(m.source), to_user: nb(m.to_user), to_model: nb(m.to_model), status: str(m.status), state: str(m.state), on_active_branch: nb(m.on_active_branch),
100
110
  ts, requested_model: str(m.requested_model), served_model: str(m.served_model), effort: str(m.effort), response_id: str(m.response_id),
101
111
  message_aliases: Array.isArray(m.aliases) ? m.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
102
- cmp_sha: str(m.cmp_sha), message_content_sha: msgSha, first_seen: firstSeen(str(m.message_id)),
112
+ cmp_sha: str(m.cmp_sha), message_content_sha: msgSha, message_native: nativeText(m.native), message_units: unitsOf(m.units),
113
+ first_seen: firstSeen(str(m.message_id)),
103
114
  };
104
115
  const ps = Array.isArray(m.parts) ? m.parts : [];
105
116
  ps.forEach((p, i) => {
@@ -159,7 +170,7 @@ function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z
159
170
  mode: str(s.mode), kind: str(s.kind), reuse: str(s.reuse), ts_source: str(s.ts_source),
160
171
  aliases: Array.isArray(s.aliases) ? s.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
161
172
  fingerprint: s.fingerprint && Array.isArray(s.fingerprint.cmp_shas) ? s.fingerprint.cmp_shas.map(String) : [],
162
- setup: s.setup == null ? null : jcs(s.setup), content_sha: recordSha(s),
173
+ setup: s.setup == null ? null : jcs(s.setup), native: nativeText(s.native), units: unitsOf(s.units), content_sha: recordSha(s),
163
174
  } : null;
164
175
  return { parts, usage, links, lifecycle, session, calls: [...calls.values()].filter((c) => c.call_id && c.tool_name !== undefined) };
165
176
  }
@@ -0,0 +1,53 @@
1
+ -- memhouse's reading views over one member's profile tables: harness-sessions-sql v1.1 §6
2
+ -- (schema/clickhouse-views.sql), each reading the member's own {db}.{m}_* tables. Rendered by
3
+ -- schema-profile.js with {db} and {m}, after the tables (schema-profile.sql.tpl). Views are not
4
+ -- tables: they are never in the column heal or the key check.
5
+ -- * CREATE OR REPLACE, so a house created before a definition changed gets the new one at its
6
+ -- next ensure (install, update, migrate).
7
+ -- * SQL SECURITY INVOKER: a read runs with the reader's own grants and row policies (share.js),
8
+ -- never the creator's, so a view shows a reader no row the tables would not.
9
+ -- * {m}_session_turns_view, not {m}_session_turns: a table name ending in _turns reads as a 0.18
10
+ -- member's turns room (share.js memberCollisions).
11
+
12
+ -- §6, session_turns (plan): one row per turn of one session. A turn starts at a message a person
13
+ -- sent in the main conversation (role 'user', source 'user', agent_id '') and runs to the next
14
+ -- one; messages before the first prompt are turn 0. The session key is the view's parameters, so
15
+ -- the filter on the sort-key prefix comes before FINAL:
16
+ -- SELECT * FROM {db}.{m}_session_turns_view(host = '…', harness = '…', harness_path = '…',
17
+ -- session_id = '…', incarnation = '')
18
+ CREATE OR REPLACE VIEW {db}.{m}_session_turns_view SQL SECURITY INVOKER AS
19
+ WITH
20
+ parts AS (
21
+ SELECT * FROM {db}.{m}_session_parts FINAL
22
+ WHERE host = {host:String} AND harness = {harness:String}
23
+ AND harness_path = {harness_path:String}
24
+ AND session_id = {session_id:String} AND incarnation = {incarnation:String}
25
+ AND is_deleted = 0 AND removed = 0
26
+ AND state = 'current' AND part_state IN ('', 'current')
27
+ AND ifNull(on_active_branch, true)
28
+ ),
29
+ messages AS (
30
+ SELECT message_id,
31
+ min(order_key) AS order_key,
32
+ toUInt32(any(role) = 'user' AND any(source) = 'user' AND any(agent_id) = '') AS is_prompt
33
+ FROM parts
34
+ GROUP BY message_id
35
+ ),
36
+ numbered AS (
37
+ SELECT message_id, is_prompt,
38
+ sum(is_prompt) OVER (ORDER BY order_key, message_id
39
+ ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS turn
40
+ FROM messages
41
+ )
42
+ SELECT n.turn AS turn,
43
+ anyIf(p.message_id, n.is_prompt = 1) AS prompt_message_id,
44
+ min(p.ts) AS started_at,
45
+ max(p.ts) AS ended_at,
46
+ uniqExact(p.message_id) AS messages,
47
+ countIf(p.kind = 'tool_call') AS tool_calls,
48
+ countIf(p.kind = 'tool_result' AND ifNull(p.is_error, false)) AS tool_errors,
49
+ arraySort(groupUniqArrayIf(p.served_model, p.served_model != '')) AS served_models
50
+ FROM parts AS p
51
+ INNER JOIN numbered AS n USING (message_id)
52
+ GROUP BY n.turn
53
+ ORDER BY n.turn;