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