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
|
@@ -8,7 +8,10 @@
|
|
|
8
8
|
// option, nothing in <db>_archive granted to anyone else
|
|
9
9
|
//
|
|
10
10
|
// The DDL lives in schema-profile.sql.tpl, rendered with {db} and {m}; every statement is
|
|
11
|
-
// CREATE … IF NOT EXISTS.
|
|
11
|
+
// CREATE … IF NOT EXISTS. The reading views (harness-sessions-sql v1.1 §6) live apart, in
|
|
12
|
+
// schema-profile-views.sql.tpl, as CREATE OR REPLACE VIEW: they are not tables, so they stay out
|
|
13
|
+
// of templateColumns, the column heal and the key check. Transport-free like schema-v3.js: every
|
|
14
|
+
// entry point takes `q`.
|
|
12
15
|
|
|
13
16
|
const fs = require('fs');
|
|
14
17
|
const path = require('path');
|
|
@@ -16,6 +19,7 @@ const { assertUsableName, legacyTextIndexDialect, isTextIndexGrammarRefusal, tex
|
|
|
16
19
|
const { stripComments, qFromClient, DDL_SETTINGS } = require('./schema-v3');
|
|
17
20
|
|
|
18
21
|
const TEMPLATE = path.join(__dirname, 'schema-profile.sql.tpl');
|
|
22
|
+
const VIEWS_TEMPLATE = path.join(__dirname, 'schema-profile-views.sql.tpl');
|
|
19
23
|
|
|
20
24
|
// The columns retyped in harness-sessions-sql v1, which ensureProfileSchema brings an older table to:
|
|
21
25
|
// adapter_ver UInt32 -> LowCardinality(String), child_stored Bool -> Nullable(Bool), orig_sha256
|
|
@@ -47,19 +51,43 @@ const SERVER_COLUMNS = ['writer', 'ingested_at', 'search_ngram', 'search_word',
|
|
|
47
51
|
|
|
48
52
|
function archiveDb(db) { return `${db}_archive`; }
|
|
49
53
|
|
|
50
|
-
/**
|
|
54
|
+
/** The reading views, by their name after `<m>_` (schema-profile-views.sql.tpl). */
|
|
55
|
+
const VIEW_TYPES = ['session_turns_view'];
|
|
56
|
+
|
|
57
|
+
/** Every fully-qualified name of one member's profile tables and views. */
|
|
51
58
|
function profileNames(db, member) {
|
|
52
59
|
assertUsableName(db, 'house');
|
|
53
60
|
assertUsableName(member, 'member');
|
|
54
61
|
const out = { db, archive: archiveDb(db), member };
|
|
55
62
|
for (const t of CONVERSATION_TYPES) out[t] = `${db}.${member}_${t}`;
|
|
56
63
|
for (const t of CAPTURE_TYPES) out[t] = `${archiveDb(db)}.${member}_${t}`;
|
|
64
|
+
for (const t of VIEW_TYPES) out[t] = `${db}.${member}_${t}`;
|
|
57
65
|
out.meta = `${db}.${member}_meta`;
|
|
58
66
|
out.events = `${db}.${member}_events`;
|
|
59
67
|
return out;
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
function readTemplate() { return fs.readFileSync(TEMPLATE, 'utf-8'); }
|
|
71
|
+
function readViewsTemplate() { return fs.readFileSync(VIEWS_TEMPLATE, 'utf-8'); }
|
|
72
|
+
|
|
73
|
+
/** The view statements for one member, in template order: [{ type, fq, sql }]. Each reads the member's own house. */
|
|
74
|
+
function renderViews(tpl, { db, member }) {
|
|
75
|
+
assertUsableName(db, 'house');
|
|
76
|
+
assertUsableName(member, 'member');
|
|
77
|
+
const body = stripComments(tpl).replaceAll('{db}', db).replaceAll('{m}', member);
|
|
78
|
+
const out = [];
|
|
79
|
+
for (const raw of body.split(';')) {
|
|
80
|
+
const sql = raw.trim();
|
|
81
|
+
if (!sql) continue;
|
|
82
|
+
const m = /^CREATE OR REPLACE VIEW ([A-Za-z0-9_]+)\.([A-Za-z0-9_]+) SQL SECURITY INVOKER AS\b/.exec(sql);
|
|
83
|
+
if (!m) throw new Error(`schema-profile views template: unexpected statement: ${sql.slice(0, 80)}`);
|
|
84
|
+
if (m[1] !== db) throw new Error(`schema-profile views template: '${m[1]}.${m[2]}' is outside the house`);
|
|
85
|
+
const type = m[2].startsWith(`${member}_`) ? m[2].slice(member.length + 1) : m[2];
|
|
86
|
+
if (!VIEW_TYPES.includes(type)) throw new Error(`schema-profile views template declares an unknown view '${m[2]}'`);
|
|
87
|
+
out.push({ type, fq: `${m[1]}.${m[2]}`, sql });
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
63
91
|
|
|
64
92
|
/** The CREATE statements for one member, in template order: [{ type, database, table, fq, sql }]. */
|
|
65
93
|
function renderStatements(tpl, { db, member }) {
|
|
@@ -156,12 +184,12 @@ function msg(e) { return e && e.message ? e.message : String(e); }
|
|
|
156
184
|
* Create one member's profile tables, idempotently, then verify them against PROFILE_KEYS.
|
|
157
185
|
* As ensureV3Schema: a privilege denial is counted and skipped, a lost create race is success.
|
|
158
186
|
*/
|
|
159
|
-
async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null } = {}, ui = null) {
|
|
187
|
+
async function ensureProfileSchema(q, { db, member, createDatabases = false, grantCapture = false, tpl = null, viewsTpl = null } = {}, ui = null) {
|
|
160
188
|
const n = profileNames(db, member);
|
|
161
189
|
const say = (m) => ui && ui.ok && ui.ok(m);
|
|
162
190
|
const version = String(((await q.rows('SELECT version() AS v', { final: 0 }))[0] || {}).v || '');
|
|
163
191
|
const dialect = textIndexDialectFor(version);
|
|
164
|
-
const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], tables: {}, ok: false };
|
|
192
|
+
const out = { version, dialect, created: [], denied: [], unhealed: [], grants: [], grant_failures: [], views: [], view_failures: [], tables: {}, ok: false };
|
|
165
193
|
if (createDatabases) {
|
|
166
194
|
for (const d of [n.db, n.archive]) {
|
|
167
195
|
try { await q.sql(`CREATE DATABASE IF NOT EXISTS ${d}`, DDL_SETTINGS); } catch (e) {
|
|
@@ -257,6 +285,18 @@ async function ensureProfileSchema(q, { db, member, createDatabases = false, gra
|
|
|
257
285
|
}
|
|
258
286
|
}
|
|
259
287
|
if (out.retyped.length) say(`profile tables for ${member}: retyped ${out.retyped.join(', ')}`);
|
|
288
|
+
// The reading views, over session_parts as healed above. A view is a reader's convenience, not
|
|
289
|
+
// something the capture writes: a refusal goes to `denied`, any other failure to view_failures,
|
|
290
|
+
// and neither makes the tables not ok. Not over a session_parts that is missing or lacks a column.
|
|
291
|
+
if (out.created.includes(n.session_parts) && !lacking.has('session_parts')) {
|
|
292
|
+
for (const v of renderViews(viewsTpl || readViewsTemplate(), { db, member })) {
|
|
293
|
+
try { await q.sql(v.sql, DDL_SETTINGS); out.views.push(v.fq); } catch (e) {
|
|
294
|
+
const m = msg(e);
|
|
295
|
+
if (isDenied(m)) out.denied.push(`CREATE VIEW ${v.fq}`); else out.view_failures.push(`${v.fq}: ${m.split('\n')[0]}`);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
if (out.views.length) say(`reading views for ${member}: ${out.views.join(', ')}`);
|
|
299
|
+
}
|
|
260
300
|
if (grantCapture) {
|
|
261
301
|
for (const g of captureGrants(db, member)) {
|
|
262
302
|
try { await q.sql(g, DDL_SETTINGS); out.grants.push(g); } catch (e) {
|
|
@@ -284,7 +324,7 @@ async function ensureProfileSchema(q, { db, member, createDatabases = false, gra
|
|
|
284
324
|
}
|
|
285
325
|
|
|
286
326
|
module.exports = {
|
|
287
|
-
TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
|
|
288
|
-
archiveDb, profileNames, readTemplate, renderStatements, templateColumns, templateKeys, rowColumns, insertColumns,
|
|
327
|
+
TEMPLATE, VIEWS_TEMPLATE, CONVERSATION_TYPES, CAPTURE_TYPES, PROFILE_TYPES, VIEW_TYPES, PROFILE_KEYS, SERVER_COLUMNS,
|
|
328
|
+
archiveDb, profileNames, readTemplate, readViewsTemplate, renderStatements, renderViews, templateColumns, templateKeys, rowColumns, insertColumns,
|
|
289
329
|
keyProblem, captureGrants, ensureProfileSchema, qFromClient,
|
|
290
330
|
};
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
-- memhouse's house tables as harness-sessions-sql's ClickHouse profile (step B1, tables and keys). The
|
|
2
|
-
-- tables, columns, keys and engines are harness-sessions-sql's schema/clickhouse.sql (v1.
|
|
2
|
+
-- tables, columns, keys and engines are harness-sessions-sql's schema/clickhouse.sql (v1.1: v1's,
|
|
3
|
+
-- with the native and units columns of §4.2 and §4.3; its reading view is
|
|
4
|
+
-- schema-profile-views.sql.tpl);
|
|
3
5
|
-- memhouse adds, beside them:
|
|
4
6
|
-- * the member prefix {m}_ and the archive database {db}_archive (spec §3: physical names);
|
|
5
7
|
-- * on session_parts: first_seen (a first-seen time, never written into ts), folder, project,
|
|
@@ -48,6 +50,8 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_session_parts (
|
|
|
48
50
|
message_aliases Array(Tuple(id String, kind LowCardinality(String))),
|
|
49
51
|
cmp_sha FixedString(64),
|
|
50
52
|
message_content_sha FixedString(64),
|
|
53
|
+
message_native String DEFAULT '', -- v1.1: the message's native fields (sessions-model §2.7), canonical JSON; '' when none
|
|
54
|
+
message_units Array(Tuple(source_key String, generation UInt32, unit_key String)), -- v1.1: the units the message came from
|
|
51
55
|
kind LowCardinality(String),
|
|
52
56
|
text String DEFAULT '' CODEC(ZSTD(3)),
|
|
53
57
|
call_id String DEFAULT '',
|
|
@@ -78,6 +82,7 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_session_parts (
|
|
|
78
82
|
native_type LowCardinality(String) DEFAULT '',
|
|
79
83
|
part_source LowCardinality(String) DEFAULT '',
|
|
80
84
|
part_state LowCardinality(String) DEFAULT '',
|
|
85
|
+
part_native String DEFAULT '', -- v1.1: the part's own native fields, canonical JSON; '' when none
|
|
81
86
|
content_sha FixedString(64),
|
|
82
87
|
adapter LowCardinality(String),
|
|
83
88
|
adapter_ver LowCardinality(String),
|
|
@@ -131,6 +136,8 @@ CREATE TABLE IF NOT EXISTS {db}.{m}_sessions (
|
|
|
131
136
|
aliases Array(Tuple(id String, kind LowCardinality(String))),
|
|
132
137
|
fingerprint Array(FixedString(64)),
|
|
133
138
|
setup Nullable(String),
|
|
139
|
+
native String DEFAULT '', -- v1.1: the session's native fields (sessions-model §2.7), canonical JSON; '' when none
|
|
140
|
+
units Array(Tuple(source_key String, generation UInt32, unit_key String)), -- v1.1: the units the session record came from
|
|
134
141
|
content_sha FixedString(64),
|
|
135
142
|
adapter LowCardinality(String),
|
|
136
143
|
adapter_ver LowCardinality(String),
|
package/memhouse/share.js
CHANGED
|
@@ -195,9 +195,11 @@ function memberCollisions(member, tables) {
|
|
|
195
195
|
}
|
|
196
196
|
|
|
197
197
|
// The names memhouse gives a member's tables in the house, as the part after `<m>_`: its tables,
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
198
|
+
// its reading views (schema-profile.js VIEW_TYPES: session_turns_view, named so it does not end in
|
|
199
|
+
// _turns, which memberCollisions reads as a 0.18 member's room), the copies migrate keeps of a
|
|
200
|
+
// 0.18 room while it moves one (`<room>__migrating`) and after (`<room>_pre_epoch`), and the
|
|
201
|
+
// scratch table share's probe makes and drops (`_mh_probe_<pid>`).
|
|
202
|
+
const MEMBER_TABLES = [...new Set([...V3_TABLES, ...LEGACY_ROOMS, ...DEV_ROOMS, 'meta', 'events', 'session_stats', 'session_model_stats', 'session_tool_stats', 'session_turns_view',
|
|
201
203
|
...LEGACY_ROOMS.flatMap((r) => [`${r}_pre_epoch`, `${r}__migrating`])])];
|
|
202
204
|
const isMemberTable = (rest) => MEMBER_TABLES.includes(rest) || /^_mh_probe_\d+$/.test(rest);
|
|
203
205
|
const startsMemberTable = (p) => [...MEMBER_TABLES, '_mh_probe_1'].some((n) => n.startsWith(`${p}_`));
|
|
@@ -445,6 +445,18 @@ async function tombstonesFor(ctx, fc, vanished, rev, { removed = false } = {}) {
|
|
|
445
445
|
}
|
|
446
446
|
return out;
|
|
447
447
|
}
|
|
448
|
+
/**
|
|
449
|
+
* An adapter upgrade's rows, at a rev above every row the house holds for the file. An unchanged
|
|
450
|
+
* file re-derives at revOf(fgen, upto), the rev its last pass wrote, and at an equal rev the new
|
|
451
|
+
* version's row would win by insertion order alone. The raise is a rev or two; the next append's
|
|
452
|
+
* revOf moves on by the length of what it read.
|
|
453
|
+
*/
|
|
454
|
+
async function aboveHouse(ctx, ref, agentId, agentFileIds, rev, res) {
|
|
455
|
+
const floor = BigInt(await ctx.house.maxRev(ref, agentId, agentFileIds)) + 1n;
|
|
456
|
+
if (BigInt(rev) >= floor) return { rev, turns: res.turns, calls: res.calls };
|
|
457
|
+
const up = String(floor);
|
|
458
|
+
return { rev: up, turns: res.turns.map((r) => ({ ...r, rev: up })), calls: res.calls.map((r) => ({ ...r, rev: up })) };
|
|
459
|
+
}
|
|
448
460
|
/** Keys ({ t: [[message_id, part_id, ts]], c: [[call_id, ts]] }) the new derivation no longer produces. */
|
|
449
461
|
function vanishedFrom(old, res) {
|
|
450
462
|
const have = { t: new Set(res.turns.map(base.turnKey)), c: new Set(res.calls.map(base.callKey)) };
|
|
@@ -571,7 +583,10 @@ async function captureFile(ctx, f, H, C, budget) {
|
|
|
571
583
|
const fbIds = (fb) => Object.fromEntries(Object.entries(fb || {}).filter(([k]) => k.startsWith('id:')));
|
|
572
584
|
const freshState = { ...base.initialState(ad.adapter), fb: rewrite ? fbIds(state && state.fb) : ((state && state.fb) || {}) };
|
|
573
585
|
const res = ad.derive({ file: fc, records, state: freshState });
|
|
574
|
-
const
|
|
586
|
+
const at = reAdapter
|
|
587
|
+
? await aboveHouse(ctx, refOf(f, ctx.host), f.agent_id || '', ctx.agentFileIds(f), String(base.revOf(newFgen, newUpto)), res)
|
|
588
|
+
: { rev: String(base.revOf(newFgen, newUpto)), turns: res.turns, calls: res.calls };
|
|
589
|
+
const rev = at.rev;
|
|
575
590
|
const tomb = rewrite && reAdapter ? await retireBoth(ctx, f, H, state, fc, oldKeys, res, rev)
|
|
576
591
|
: await tombstonesFor(ctx, fc, vanishedFrom(oldKeys, res), rev, { removed: !!rewrite });
|
|
577
592
|
// A new generation is archived whole; an upgrade archives only what lies past `upto`.
|
|
@@ -580,8 +595,8 @@ async function captureFile(ctx, f, H, C, budget) {
|
|
|
580
595
|
// an upgrade that read past the cursor appended to the one it had.
|
|
581
596
|
if (rewrite) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'reset', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
|
|
582
597
|
else if (newUpto > upto) out.archive.push(manifestRow(f, newFgen, { progress: newUpto, continuity: 'appended', units: unitsOf(out.archive) }, ctx.host, base.chTs(now)));
|
|
583
|
-
out.turns = [...
|
|
584
|
-
out.calls = [...
|
|
598
|
+
out.turns = [...at.turns, ...tomb.turns];
|
|
599
|
+
out.calls = [...at.calls, ...tomb.calls];
|
|
585
600
|
const digest = prefixDigest(fd, newUpto);
|
|
586
601
|
out.files = filesRow(f, ctx, {
|
|
587
602
|
attr, fgen: newFgen, upto: newUpto, reparse_from: res.reparse_from, state: res.state, adapter_ver: ad.deriveVersion,
|
|
@@ -1238,9 +1253,10 @@ async function upgradeSweep(ctx, sessions, stats) {
|
|
|
1238
1253
|
const res = ad.derive({ file: fc, records: recs, state: { ...base.initialState(ad.adapter), fb: (state && state.fb) || {} } });
|
|
1239
1254
|
const S = sessions.get(skey(harness, H.session_id, H.harness_path, H.incarnation));
|
|
1240
1255
|
const agentIds = S ? [...S.files.values()].filter((x) => x.role === 'agent').map((x) => x.agent_id) : [];
|
|
1241
|
-
const
|
|
1242
|
-
await ctx.house.
|
|
1243
|
-
await ctx.house.insert('
|
|
1256
|
+
const at = await aboveHouse(ctx, ref, f.agent_id || '', agentIds, String(base.revOf(H.fgen, H.upto)), res);
|
|
1257
|
+
const tomb = await tombstonesFor(ctx, fc, vanishedFrom(await ctx.house.fileKeys(ref, f.agent_id || '', agentIds), res), at.rev);
|
|
1258
|
+
await ctx.house.insert('turns', [...at.turns, ...tomb.turns]);
|
|
1259
|
+
await ctx.house.insert('calls', [...at.calls, ...tomb.calls]);
|
|
1244
1260
|
const row = { ...H, reparse_from: res.reparse_from, derive_state: JSON.stringify(res.state), adapter_ver: ad.deriveVersion, contrib: JSON.stringify(res.contrib), host: ctx.host };
|
|
1245
1261
|
delete row.ver;
|
|
1246
1262
|
await ctx.house.insert('files', [row]);
|
|
@@ -1553,6 +1569,11 @@ function storeWhere(ref) {
|
|
|
1553
1569
|
return `harness = ${sqlStr(ref.harness)} AND host = ${sqlStr(ref.host)} AND harness_path = ${sqlStr(ref.harness_path)}`
|
|
1554
1570
|
+ ` AND session_id = ${sqlStr(ref.session_id)} AND incarnation = ${sqlStr(ref.incarnation || '')}`;
|
|
1555
1571
|
}
|
|
1572
|
+
/** WHERE clause for one file of a session: a sub-agent's by its id, the main file as every other id. */
|
|
1573
|
+
function fileWho(agentId, agentFileIds) {
|
|
1574
|
+
return agentId ? `agent_id = ${sqlStr(agentId)}`
|
|
1575
|
+
: (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
|
|
1576
|
+
}
|
|
1556
1577
|
const STORE_TUPLE = '(harness, host, harness_path, session_id, incarnation)';
|
|
1557
1578
|
const storeTuple = (ref) => `(${[ref.harness, ref.host, ref.harness_path, ref.session_id, ref.incarnation || ''].map(sqlStr).join(', ')})`;
|
|
1558
1579
|
/** Split a column list at its top-level commas. */
|
|
@@ -1774,17 +1795,25 @@ class HttpHouse {
|
|
|
1774
1795
|
}
|
|
1775
1796
|
/** The keys this file's shipped rows hold in the conversation layer (adapter upgrade only). */
|
|
1776
1797
|
async fileKeys(ref, agentId, agentFileIds) {
|
|
1777
|
-
const who = agentId ? `agent_id = ${sqlStr(agentId)}`
|
|
1778
|
-
: (agentFileIds.length ? `agent_id NOT IN (${agentFileIds.map(sqlStr).join(', ')})` : '1');
|
|
1779
1798
|
// Removed history (removed = 1) is never a candidate: it is what the source no longer has, not a
|
|
1780
1799
|
// duplicate of the new derivation, and an upgrade must not erase it.
|
|
1781
|
-
const where = `${storeWhere(ref)} AND ${
|
|
1800
|
+
const where = `${storeWhere(ref)} AND ${fileWho(agentId, agentFileIds)} AND is_deleted = 0 AND removed = 0`;
|
|
1782
1801
|
const t = await this.query(`SELECT message_id, part_id, toString(ts) AS ts FROM ${this.t.turns} FINAL
|
|
1783
1802
|
WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
|
|
1784
1803
|
const c = await this.query(`SELECT call_id, toString(ts) AS ts FROM ${this.t.calls} FINAL
|
|
1785
1804
|
WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
|
|
1786
1805
|
return { t: t.map((r) => [r.message_id, r.part_id, r.ts]), c: c.map((r) => [r.call_id, r.ts]) };
|
|
1787
1806
|
}
|
|
1807
|
+
/** The highest rev this file's shipped rows hold, tombstones and removed versions included (adapter upgrade only). */
|
|
1808
|
+
async maxRev(ref, agentId, agentFileIds) {
|
|
1809
|
+
const where = `${storeWhere(ref)} AND ${fileWho(agentId, agentFileIds)}`;
|
|
1810
|
+
const [t] = await this.query(`SELECT toString(max(rev)) AS rev FROM ${this.t.turns}
|
|
1811
|
+
WHERE ${where} AND origin = 'ship' AND NOT startsWith(message_id, 'legacy:')`);
|
|
1812
|
+
const [c] = await this.query(`SELECT toString(max(rev)) AS rev FROM ${this.t.calls}
|
|
1813
|
+
WHERE ${where} AND NOT startsWith(call_id, 'legacy:')`);
|
|
1814
|
+
const a = BigInt((t && t.rev) || 0); const b = BigInt((c && c.rev) || 0);
|
|
1815
|
+
return String(a > b ? a : b);
|
|
1816
|
+
}
|
|
1788
1817
|
/**
|
|
1789
1818
|
* Every `legacy:` row of one session in EVERY version — no FINAL, so tombstoned rows come back
|
|
1790
1819
|
* too (dedupe-v3 takes the newest per key: a tombstone is a full copy, it still has its text).
|
|
@@ -1957,6 +1986,7 @@ class DryHouse {
|
|
|
1957
1986
|
async firstCaptured() { return new Map(); }
|
|
1958
1987
|
async rowsByKey() { return []; }
|
|
1959
1988
|
async fileKeys() { return { t: [], c: [] }; }
|
|
1989
|
+
async maxRev() { return '0'; }
|
|
1960
1990
|
async hasRemoved() { return false; }
|
|
1961
1991
|
async legacyRowsAll() { return { turns: [], calls: [] }; }
|
|
1962
1992
|
async capturedRows() { return { turns: [], calls: [] }; }
|
|
@@ -2006,6 +2036,25 @@ const refusesVanish = (x) => !!(x.vanishRefused || x.stillThere || x.candidateEr
|
|
|
2006
2036
|
const storesN = (n) => `${n} store${n === 1 ? '' : 's'}`;
|
|
2007
2037
|
/** One shell word: as is when it is plain, else single-quoted (a store path may hold spaces). */
|
|
2008
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
|
+
}
|
|
2009
2058
|
function vanishLines(s) {
|
|
2010
2059
|
const out = [];
|
|
2011
2060
|
for (const x of s.adapters || []) {
|
|
@@ -2531,6 +2580,7 @@ async function main(argv = process.argv.slice(2), opts = {}) {
|
|
|
2531
2580
|
s = await runPass(ctx);
|
|
2532
2581
|
console.log(`${summary(s)} in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
2533
2582
|
for (const line of vanishLines(s)) console.log(line);
|
|
2583
|
+
for (const line of normaliseLines(s)) console.log(line);
|
|
2534
2584
|
// Without the package a profile pass captures no Claude Code or Codex session at all; that
|
|
2535
2585
|
// must not read as a quiet pass with nothing new.
|
|
2536
2586
|
const ac = require('./adapter-capture');
|
|
@@ -2561,7 +2611,7 @@ module.exports = {
|
|
|
2561
2611
|
applyAdapters, adapterHarnesses, adapterPass, routeAdapterSessions, ADAPTER_MAX_SESSION_BYTES,
|
|
2562
2612
|
parseSourceRoots, classifyRoot, discover, Cache, cachePath, captureFile, runPass, buildSession, batches,
|
|
2563
2613
|
archiveRows, recordsFromArchive, rebuildFromArchive, replayArchive, manifestRow, fkey, splitLines, prefixDigest, extendDigest, digestOf, digestBuffer, HttpHouse, DryHouse,
|
|
2564
|
-
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,
|
|
2565
2615
|
passLock, PASS_LOCK_STALE_MS, PASS_LOCK_REFRESH_MS, duplicatesRefusal, reloadCache,
|
|
2566
2616
|
BLOCK, ANCHOR, CHUNK, BATCH_ROWS, BATCH_BYTES, FRAGMENT_MS,
|
|
2567
2617
|
};
|
|
@@ -14,7 +14,9 @@
|
|
|
14
14
|
// A line longer than the read cap is read whole: a read always makes progress
|
|
15
15
|
// derive the log's records as sessions-model records, the last line of a key winning:
|
|
16
16
|
// session title, cwd, version, created, updated, archived, mode, kind, engine, client; its
|
|
17
|
-
// `links` (stated on the child) as link records
|
|
17
|
+
// `links` (stated on the child) as link records; its `children` (a child no message
|
|
18
|
+
// names, Cursor's sub-agents) as links stated on the origin, one per child: a `child`
|
|
19
|
+
// record for the same child that says where it was found is the one kept
|
|
18
20
|
// message order_key the native `order`, then the line's offset; ts the source's or null; a
|
|
19
21
|
// `tool` part as a tool_call part and, once it has an output or an error, a
|
|
20
22
|
// tool_result part; `file` as an attachment, `error` and `notice` as theirs; its
|
|
@@ -31,7 +33,7 @@ const path = require('path');
|
|
|
31
33
|
const slog = require('../../editors/sessions-log');
|
|
32
34
|
|
|
33
35
|
const ADAPTER = 'memhouse-bridge';
|
|
34
|
-
const ADAPTER_VER = '1.0.
|
|
36
|
+
const ADAPTER_VER = '1.0.2';
|
|
35
37
|
const STATE_VER = 1;
|
|
36
38
|
const MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
37
39
|
const UNFLAGGED = { 'gemini-cli': { input_includes_cache: true, output_includes_reasoning: false } };
|
|
@@ -70,6 +72,12 @@ function usageRecord(id, anchor, u, harness, model) {
|
|
|
70
72
|
reasoning: num(u.reasoning), cost: num(u.cost), input_includes_cache: iic, output_includes_reasoning: oir, granularity: 'per_message', model: String(model || '') };
|
|
71
73
|
}
|
|
72
74
|
|
|
75
|
+
/** A child the session started, as a link stated on the origin: a `child` record, or one of the session record's `children`. */
|
|
76
|
+
function originLink(c, harness) {
|
|
77
|
+
return { type: 'link', kind: String(c.kind || ''), stated_on: 'origin', origin: {}, child: { harness, session_id: String(c.session_id || '') },
|
|
78
|
+
at: String(c.at || ''), evidence: c.evidence === 'inferred' ? 'inferred' : 'explicit' };
|
|
79
|
+
}
|
|
80
|
+
|
|
73
81
|
/** One log line (its offset `at`) as sessions-model records. */
|
|
74
82
|
function recordsOf(o, at, harness) {
|
|
75
83
|
if (!o || typeof o !== 'object') return [];
|
|
@@ -80,16 +88,14 @@ function recordsOf(o, at, harness) {
|
|
|
80
88
|
const links = (Array.isArray(o.links) ? o.links : []).map((l) => ({ type: 'link', kind: String(l.kind || ''), stated_on: 'child', child: {},
|
|
81
89
|
origin: { harness, session_id: String(l.origin || ''), ...(l.origin_store ? { harness_path: String(l.origin_store) } : {}) }, at: String(l.at || ''),
|
|
82
90
|
inherits: l.through ? { through_message_id: String(l.through) } : {}, evidence: l.evidence === 'inferred' ? 'inferred' : 'explicit' }));
|
|
83
|
-
|
|
91
|
+
const children = (Array.isArray(o.children) ? o.children : []).map((c) => originLink(c, harness));
|
|
92
|
+
return [s, ...links, ...children];
|
|
84
93
|
}
|
|
85
94
|
if (o.k === 'usage') {
|
|
86
95
|
if (!o.usage) return [];
|
|
87
96
|
return [usageRecord(o.usage_id != null && o.usage_id !== '' ? `usage:${o.usage_id}` : `usage:@${at}`, {}, o.usage, harness, o.model)];
|
|
88
97
|
}
|
|
89
|
-
if (o.k === 'child')
|
|
90
|
-
return [{ type: 'link', kind: String(o.kind || ''), stated_on: 'origin', origin: {}, child: { harness, session_id: String(o.session_id || '') },
|
|
91
|
-
at: String(o.at || ''), evidence: o.evidence === 'inferred' ? 'inferred' : 'explicit' }];
|
|
92
|
-
}
|
|
98
|
+
if (o.k === 'child') return [originLink(o, harness)];
|
|
93
99
|
if (o.k !== 'message' || o.message_id == null) return [];
|
|
94
100
|
const id = String(o.message_id);
|
|
95
101
|
const parts = [];
|
|
@@ -204,6 +210,11 @@ function logAdapter(home, harness, { maxReadBytes = MAX_READ_BYTES } = {}) {
|
|
|
204
210
|
for (const at of Object.keys(st.lines).sort((a, b) => Number(a) - Number(b))) {
|
|
205
211
|
for (const r of recordsOf(st.lines[at], Number(at), harness)) byKey.set(`${r.type}\u0000${keyOf(r)}`, r);
|
|
206
212
|
}
|
|
213
|
+
// One link per child: a link with no `at` (a session record's child) gives way to one of the
|
|
214
|
+
// same kind and child that says where it was found (a `child` record).
|
|
215
|
+
const placed = new Set();
|
|
216
|
+
for (const r of byKey.values()) if (r.type === 'link' && r.stated_on === 'origin' && r.at) placed.add(canon([r.kind, r.child]));
|
|
217
|
+
for (const [k, r] of byKey) if (r.type === 'link' && r.stated_on === 'origin' && !r.at && placed.has(canon([r.kind, r.child]))) byKey.delete(k);
|
|
207
218
|
const changed = []; const live = {}; const emitted = {};
|
|
208
219
|
for (const [k, r] of byKey) {
|
|
209
220
|
const j = JSON.stringify(r);
|
|
@@ -56,15 +56,33 @@ const RETRY_MS = 5 * 60 * 1000;
|
|
|
56
56
|
const BUDGET_MS = 30 * 1000;
|
|
57
57
|
const keyOf = (s, inc = '') => `${s.harness}\t${s.harness_path}\t${s.session_id}\t${inc}`;
|
|
58
58
|
|
|
59
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Messages in an existing log (0 when there is none). A log that is there but cannot be read throws:
|
|
61
|
+
* counted as 0, it would disarm the refusal to rewrite a log of messages into none.
|
|
62
|
+
*/
|
|
60
63
|
function messagesIn(file) {
|
|
61
64
|
let s;
|
|
62
|
-
try { s = fs.readFileSync(file, 'utf-8'); } catch {
|
|
65
|
+
try { s = fs.readFileSync(file, 'utf-8'); } catch (e) {
|
|
66
|
+
if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return 0;
|
|
67
|
+
throw new Error(`cannot read the existing log (${(e && e.code) || 'error'}): ${path.basename(file)}`);
|
|
68
|
+
}
|
|
63
69
|
let n = 0;
|
|
64
70
|
for (const l of s.split('\n')) if (l.startsWith('{"k":"message"') || /"k"\s*:\s*"message"/.test(l.slice(0, 200))) n++;
|
|
65
71
|
return n;
|
|
66
72
|
}
|
|
67
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
|
+
|
|
68
86
|
function loadIndex(file) {
|
|
69
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: {} }; }
|
|
70
88
|
}
|
|
@@ -77,7 +95,7 @@ function saveIndex(file, idx) {
|
|
|
77
95
|
|
|
78
96
|
/**
|
|
79
97
|
* Bring every normalised log up to date. Returns { harnesses, sessions, normalised, written:
|
|
80
|
-
* { new, append, rewrite, same }, errors: [{ harness, session_id?, error }], ms }.
|
|
98
|
+
* { new, append, rewrite, same }, errors: [{ harness, session_id?, error, code?, path? }], ms } (errorOf).
|
|
81
99
|
* opts.mods: the modules (default: editors/normalise/); opts.storeRoot(harness): a store to list
|
|
82
100
|
* instead of the module's default (tests).
|
|
83
101
|
*/
|
|
@@ -93,12 +111,19 @@ function run({ home, mods = modules(), storeRoot = null, log = () => {}, now = D
|
|
|
93
111
|
for (const m of mods.filter((x) => scope.normaliserInScope(x.HARNESS, editors))) {
|
|
94
112
|
let list;
|
|
95
113
|
try { list = storeRoot && storeRoot(m.HARNESS) ? m.list(storeRoot(m.HARNESS)) : m.list(); } catch (e) {
|
|
96
|
-
st.errors.push({ harness: m.HARNESS,
|
|
114
|
+
st.errors.push({ harness: m.HARNESS, ...errorOf(e) });
|
|
97
115
|
// A store busy for a moment (an editor mid-save) is no reason to forget its sessions: keep
|
|
98
116
|
// their index entries, or the next pass would normalise every one of them again.
|
|
99
117
|
for (const [k, v] of Object.entries(idx.sessions)) if (k.startsWith(`${m.HARNESS}\t`)) seen[k] = v;
|
|
100
118
|
continue;
|
|
101
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
|
+
}
|
|
102
127
|
if (!list || !list.length) continue;
|
|
103
128
|
st.harnesses++;
|
|
104
129
|
for (const s of list) {
|
|
@@ -132,7 +157,7 @@ function run({ home, mods = modules(), storeRoot = null, log = () => {}, now = D
|
|
|
132
157
|
st.normalised++;
|
|
133
158
|
seen[k0] = { sig, file, at: now };
|
|
134
159
|
} catch (e) {
|
|
135
|
-
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) });
|
|
136
161
|
seen[k0] = { sig, file: '', failed: true, at: now, tries: prev && prev.failed && prev.sig === sig ? (prev.tries || 0) + 1 : 0 };
|
|
137
162
|
}
|
|
138
163
|
}
|
|
@@ -145,4 +170,4 @@ function run({ home, mods = modules(), storeRoot = null, log = () => {}, now = D
|
|
|
145
170
|
return st;
|
|
146
171
|
}
|
|
147
172
|
|
|
148
|
-
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 };
|