memhouse 0.18.12 → 0.20.1
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/AGENTS.md +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- package/public/assets/index-CnWzrjF3.js +0 -78
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// Fork billing once (plan v4 M4; sessions-model §2.5). A fork copies its origin's messages, and
|
|
3
|
+
// usually their usage with them: summed per session, the house bills the copied prefix twice, once
|
|
4
|
+
// in each session. This finds the copies and takes their usage out of the fork's totals, through one
|
|
5
|
+
// more `files` row on the fork — role 'inherited', like dedupe's 'legacy' row — whose contrib carries
|
|
6
|
+
// the copied usage with a minus sign. buildSession sums it like a file. Nothing else is touched: the
|
|
7
|
+
// copied messages stay in the fork, searchable and counted as messages; only their tokens are not
|
|
8
|
+
// billed again.
|
|
9
|
+
//
|
|
10
|
+
// Which sessions are forks (§2.5 "Inferred links belong to the consumer"):
|
|
11
|
+
// - an explicit link on the session record (kind fork or rewind_copy, in its own store); or
|
|
12
|
+
// - for the harnesses that keep no fork pointer and mint new ids (OpenCode, VS Code, cursor-agent,
|
|
13
|
+
// Cursor IDE, Goose), a session born later in the same store whose first assistant text is that
|
|
14
|
+
// of another session (nomination only).
|
|
15
|
+
// Either way the fork is VERIFIED on full content before anything is billed: its messages,
|
|
16
|
+
// contiguous from its first, against the origin's from its first, each compared on the sha256 of
|
|
17
|
+
// its full parts (kind and text, in order: tool args, full outputs, attachments by their bytes'
|
|
18
|
+
// sha), main line only. A truncated text on either side leaves the fork unresolved. A copied
|
|
19
|
+
// message is billed once only where its usage equals the origin's: a message the fork re-ran with
|
|
20
|
+
// its own usage is billed where it ran.
|
|
21
|
+
|
|
22
|
+
const INHERITED_ROLE = 'inherited';
|
|
23
|
+
const INHERITED_FILE_KEY = 'inherited:fork';
|
|
24
|
+
const COPY_KINDS = new Set(['fork', 'rewind_copy']);
|
|
25
|
+
// Harnesses that keep no pointer from a fork to its origin and mint new ids for the copies
|
|
26
|
+
// (sessions-model §2.5): only these are nominated by content.
|
|
27
|
+
const INFERRED = new Set(['opencode', 'vscode', 'cursor-agent', 'cursor', 'goose']);
|
|
28
|
+
// Native transcripts: no fork link and no nomination in M4 (Claude Code's and Codex's own copies are
|
|
29
|
+
// not verified to carry usage), so the pass never queries for them.
|
|
30
|
+
const NATIVE = new Set(['claude-code', 'codex']);
|
|
31
|
+
const EPOCH_TS = '1970-01-01 00:00:00.000';
|
|
32
|
+
// Nomination by content is bounded: a first reply shorter than this ("Hello! How can I help?")
|
|
33
|
+
// nominates nothing, and a pass verifies at most FORK_BUDGET sessions; the rest wait for the next.
|
|
34
|
+
const MIN_NOMINATION_TEXT = 40;
|
|
35
|
+
const FORK_BUDGET = 100;
|
|
36
|
+
const ZERO_SHA = '0'.repeat(64);
|
|
37
|
+
const TOK = ['input', 'output', 'cache_read', 'cache_write', 'reasoning'];
|
|
38
|
+
const sameTok = (a, b) => TOK.every((k) => Number(a[k]) === Number(b[k]));
|
|
39
|
+
const anyTok = (m) => TOK.some((k) => Number(m[k]) > 0);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The verified copied prefix of `child` from `origin` (both: house.sessionMessages lists, in order).
|
|
43
|
+
* Returns { pairs: [[childMsg, originMsg]], unresolved: reason | null }. `through`: an explicit
|
|
44
|
+
* link's last copied message id (the match stops after it).
|
|
45
|
+
*/
|
|
46
|
+
function verifyPrefix(child, origin, { through = '' } = {}) {
|
|
47
|
+
const pairs = [];
|
|
48
|
+
const n = Math.min(child.length, origin.length);
|
|
49
|
+
for (let i = 0; i < n; i++) {
|
|
50
|
+
const c = child[i]; const o = origin[i];
|
|
51
|
+
if (c.role !== o.role || c.sha !== o.sha) break;
|
|
52
|
+
if (c.truncated || o.truncated) return { pairs: [], unresolved: `message ${c.message_id}: truncated text, cannot verify in full` };
|
|
53
|
+
pairs.push([c, o]);
|
|
54
|
+
if (through && (c.message_id === through || o.message_id === through)) break;
|
|
55
|
+
}
|
|
56
|
+
return { pairs, unresolved: null };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The contrib of the 'inherited' row: the copied usage, negated, in the shape buildSession sums. */
|
|
60
|
+
function inheritedContrib(pairs, inherits) {
|
|
61
|
+
const c = { input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0, usage: [], inherits };
|
|
62
|
+
const usage = new Map();
|
|
63
|
+
let billedOnce = 0;
|
|
64
|
+
for (const [cm, om] of pairs) {
|
|
65
|
+
if (!anyTok(cm) || !sameTok(cm, om)) continue;
|
|
66
|
+
billedOnce++;
|
|
67
|
+
for (const k of TOK) c[`${k}_tokens`] -= Number(cm[k]) || 0;
|
|
68
|
+
const key = `${cm.hour}\u0000${cm.model}`;
|
|
69
|
+
const u = usage.get(key) || { hour: cm.hour, model: cm.model, messages: 0, input: 0, output: 0, cache_read: 0, cache_write: 0, reasoning: 0 };
|
|
70
|
+
for (const k of TOK) u[k] -= Number(cm[k]) || 0;
|
|
71
|
+
usage.set(key, u);
|
|
72
|
+
}
|
|
73
|
+
c.usage = [...usage.values()].sort((a, b) => a.hour.localeCompare(b.hour) || a.model.localeCompare(b.model));
|
|
74
|
+
c.inherits = { ...inherits, copied: pairs.length, billed_once: billedOnce };
|
|
75
|
+
return billedOnce ? c : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inheritedRow(ref, contrib, prev, host) {
|
|
79
|
+
const fgen = prev ? Number(prev.fgen) + 1 : 0;
|
|
80
|
+
return {
|
|
81
|
+
harness: ref.harness, harness_path: ref.harness_path, session_id: ref.session_id, incarnation: ref.incarnation || '',
|
|
82
|
+
file_key: INHERITED_FILE_KEY, agent_id: '', role: INHERITED_ROLE, agent_type: '', agent_desc: '',
|
|
83
|
+
fgen, upto: contrib ? contrib.inherits.copied : 0, reparse_from: 0, derive_state: '{}',
|
|
84
|
+
// Never a tombstone: a deleted row leaves sessions.rev (Σ live files.ver), and the fork's rebuilt
|
|
85
|
+
// row would carry a LOWER rev than the one it replaces. Cleared, it stays live with nothing to subtract.
|
|
86
|
+
adapter_ver: 0, size: 0, inode: 0, anchor_sha: ZERO_SHA, prefix_sha: ZERO_SHA, is_deleted: 0,
|
|
87
|
+
contrib: JSON.stringify(contrib || {}), first_ts: EPOCH_TS, last_ts: EPOCH_TS, host: String(host || ''),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A comparable form of an inherited row's contrib (no derived counts that could only drift with it). */
|
|
92
|
+
const contribKey = (c) => JSON.stringify(c && c.inherits ? c : null);
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The origin this session copies, verified, or null. `rows`: the session's files rows.
|
|
96
|
+
* Returns { origin: ref, pairs, inherits } | { unresolved } | null.
|
|
97
|
+
*/
|
|
98
|
+
async function findOrigin(ctx, ref, rows, child, meta) {
|
|
99
|
+
const store = { harness: ref.harness, host: ref.host, harness_path: ref.harness_path };
|
|
100
|
+
// Explicit links first: the adapter stated them (child side).
|
|
101
|
+
const links = (meta.links || []).filter((l) => COPY_KINDS.has(l.kind) && l.origin && !l.origin_store);
|
|
102
|
+
for (const l of links) {
|
|
103
|
+
const found = await ctx.house.sessionsById(store, String(l.origin));
|
|
104
|
+
if (found.length !== 1) return { unresolved: `explicit ${l.kind} link to ${l.origin}: ${found.length} sessions of that id in the store` };
|
|
105
|
+
const origin = { ...store, session_id: String(l.origin), incarnation: found[0].incarnation || '' };
|
|
106
|
+
const v = verifyPrefix(child, await ctx.house.sessionMessages(origin), { through: String(l.through || '') });
|
|
107
|
+
if (v.unresolved) return { unresolved: v.unresolved };
|
|
108
|
+
if (!v.pairs.length) return { unresolved: `explicit ${l.kind} link to ${l.origin}: the first message does not match` };
|
|
109
|
+
return { origin, pairs: v.pairs, inherits: { kind: l.kind, session_id: origin.session_id, incarnation: origin.incarnation, evidence: 'explicit', at: String(l.at || ''), through: v.pairs[v.pairs.length - 1][1].message_id } };
|
|
110
|
+
}
|
|
111
|
+
if (!INFERRED.has(ref.harness)) return null;
|
|
112
|
+
// Nominated by content: sessions of the store born earlier whose first assistant text is this one's.
|
|
113
|
+
const first = child.find((m) => m.role === 'assistant' && m.first_text);
|
|
114
|
+
if (!first || first.first_text.length < MIN_NOMINATION_TEXT || !Number.isFinite(meta.created)) return null;
|
|
115
|
+
const seen = new Set();
|
|
116
|
+
const cands = (await ctx.house.sessionsWithText(store, first.first_text))
|
|
117
|
+
.filter((s) => !(s.session_id === ref.session_id && (s.incarnation || '') === (ref.incarnation || '')))
|
|
118
|
+
.filter((s) => { const k = `${s.session_id}\t${s.incarnation || ''}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
|
119
|
+
// The longest verified prefix wins. Two forks of one origin each verify against the other's copies
|
|
120
|
+
// as far as against the origin: an equal length goes to the earliest born (the copies' source),
|
|
121
|
+
// and only an equal length at an equal birth stays unresolved.
|
|
122
|
+
let best = null; let tie = false;
|
|
123
|
+
for (const s of cands.slice(0, 20)) {
|
|
124
|
+
const origin = { ...store, session_id: s.session_id, incarnation: s.incarnation || '' };
|
|
125
|
+
const oRows = await ctx.house.filesRows([origin]);
|
|
126
|
+
const oCreated = createdOf(oRows);
|
|
127
|
+
// Born strictly earlier: the copy cannot be the origin. No birth time on either side: no verdict.
|
|
128
|
+
if (!Number.isFinite(oCreated) || !(oCreated < meta.created)) continue;
|
|
129
|
+
const v = verifyPrefix(child, await ctx.house.sessionMessages(origin));
|
|
130
|
+
if (v.unresolved || !v.pairs.length) continue;
|
|
131
|
+
if (!best || v.pairs.length > best.pairs.length || (v.pairs.length === best.pairs.length && oCreated < best.created)) { best = { origin, pairs: v.pairs, created: oCreated }; tie = false; }
|
|
132
|
+
else if (v.pairs.length === best.pairs.length && oCreated === best.created) tie = true;
|
|
133
|
+
}
|
|
134
|
+
if (!best) return null;
|
|
135
|
+
if (tie) return { unresolved: 'two origins born at the same moment verify to the same length' };
|
|
136
|
+
return { origin: best.origin, pairs: best.pairs, inherits: { kind: 'fork', session_id: best.origin.session_id, incarnation: best.origin.incarnation, evidence: 'inferred', at: '', through: best.pairs[best.pairs.length - 1][1].message_id } };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The session's birth time from its main files rows (the log's session record `created`), or NaN. */
|
|
140
|
+
function createdOf(rows) {
|
|
141
|
+
for (const r of rows) {
|
|
142
|
+
if (r.role !== 'main' || Number(r.is_deleted)) continue;
|
|
143
|
+
const c = typeof r.contrib === 'string' ? JSON.parse(r.contrib || '{}') : (r.contrib || {});
|
|
144
|
+
const v = Number(c.meta && c.meta.created);
|
|
145
|
+
if (Number.isFinite(v) && v > 0) return v;
|
|
146
|
+
}
|
|
147
|
+
return NaN;
|
|
148
|
+
}
|
|
149
|
+
function metaOf(rows) {
|
|
150
|
+
const meta = { links: [], created: createdOf(rows) };
|
|
151
|
+
for (const r of rows) {
|
|
152
|
+
if (r.role !== 'main' || Number(r.is_deleted)) continue;
|
|
153
|
+
const c = typeof r.contrib === 'string' ? JSON.parse(r.contrib || '{}') : (r.contrib || {});
|
|
154
|
+
if (c.meta && Array.isArray(c.meta.links)) meta.links = c.meta.links;
|
|
155
|
+
}
|
|
156
|
+
return meta;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* The 'inherited' rows these sessions need, and the forks of any of them that is an origin. Writes
|
|
161
|
+
* nothing: returns { writes: [{ ref, row }], deferred: [ref] } — the caller saves the rows as owed
|
|
162
|
+
* work before it inserts them, so no failure between the insert and the sessions rebuild loses one.
|
|
163
|
+
* `deferred`: sessions past the pass's budget, for the next pass.
|
|
164
|
+
*/
|
|
165
|
+
async function forkPass(ctx, items, stats, { budget = FORK_BUDGET } = {}) {
|
|
166
|
+
items = items.filter((x) => !NATIVE.has(x.ref.harness));
|
|
167
|
+
if (!items.length || typeof ctx.house.sessionMessages !== 'function') return { writes: [], deferred: [] };
|
|
168
|
+
const refs = items.map((x) => x.ref);
|
|
169
|
+
const known = new Map(items.map((x) => [skey(x.ref), x.rows]));
|
|
170
|
+
const want = new Map(refs.map((r) => [skey(r), r]));
|
|
171
|
+
// A changed session may be an origin: its forks are verified again — the ones already resolved
|
|
172
|
+
// (inheritedFrom), and the ones that could not be while it was missing (a fork captured first, a
|
|
173
|
+
// backfill split across passes): sessions whose explicit link names it, and, for the harnesses
|
|
174
|
+
// nominated by content, the sessions sharing its first assistant text (their own check decides
|
|
175
|
+
// which side was born first).
|
|
176
|
+
const msgs = new Map();
|
|
177
|
+
const messagesOf = async (ref) => { const k = skey(ref); if (!msgs.has(k)) msgs.set(k, await ctx.house.sessionMessages(ref)); return msgs.get(k); };
|
|
178
|
+
// A session nominated ONLY by origins' first text must be born after one of them: the earliest
|
|
179
|
+
// such origin's birth is its bound. Wanted for any other reason (touched, a resolved fork, a link
|
|
180
|
+
// naming the origin), it is verified with no bound.
|
|
181
|
+
const byText = new Map();
|
|
182
|
+
const unbounded = new Set(want.keys());
|
|
183
|
+
const add = (store, s, bornAfter = null) => {
|
|
184
|
+
const ref = { harness: store.harness, host: store.host, harness_path: store.harness_path, session_id: s.session_id, incarnation: s.incarnation || '' };
|
|
185
|
+
const k = skey(ref);
|
|
186
|
+
if (!want.has(k)) want.set(k, ref);
|
|
187
|
+
if (bornAfter === null) { unbounded.add(k); byText.delete(k); }
|
|
188
|
+
else if (!unbounded.has(k)) byText.set(k, byText.has(k) ? Math.min(byText.get(k), bornAfter) : bornAfter);
|
|
189
|
+
};
|
|
190
|
+
for (const r of refs) {
|
|
191
|
+
const store = { harness: r.harness, host: r.host, harness_path: r.harness_path };
|
|
192
|
+
for (const f of await ctx.house.inheritedFrom(store, r.session_id)) add(store, f);
|
|
193
|
+
for (const f of await ctx.house.linkedChildren(store, r.session_id)) add(store, f);
|
|
194
|
+
if (INFERRED.has(r.harness)) {
|
|
195
|
+
const first = (await messagesOf(r)).find((m) => m.role === 'assistant' && m.first_text);
|
|
196
|
+
const born = createdOf(known.get(skey(r)) || await ctx.house.filesRows([r]));
|
|
197
|
+
if (first && first.first_text.length >= MIN_NOMINATION_TEXT && Number.isFinite(born)) {
|
|
198
|
+
for (const f of await ctx.house.sessionsWithText(store, first.first_text)) add(store, f, born);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const writes = [];
|
|
203
|
+
const deferred = [];
|
|
204
|
+
let verified = 0;
|
|
205
|
+
for (const ref of want.values()) {
|
|
206
|
+
const rows = known.get(skey(ref)) || await ctx.house.filesRows([ref]);
|
|
207
|
+
if (!rows.some((r) => r.role === 'main' && !Number(r.is_deleted))) continue;
|
|
208
|
+
// Nominated as a possible fork by an origin's text: only one born after it can be.
|
|
209
|
+
if (byText.has(skey(ref)) && !(createdOf(rows) > byText.get(skey(ref)))) continue;
|
|
210
|
+
const prev = rows.find((r) => r.role === INHERITED_ROLE) || null;
|
|
211
|
+
const live = prev && !Number(prev.is_deleted) && JSON.parse(prev.contrib || '{}').inherits ? prev : null;
|
|
212
|
+
const meta = metaOf(rows);
|
|
213
|
+
const linked = (meta.links || []).some((l) => COPY_KINDS.has(l.kind));
|
|
214
|
+
if (!linked && !INFERRED.has(ref.harness) && !live) continue;
|
|
215
|
+
if (verified >= budget) { deferred.push(ref); continue; }
|
|
216
|
+
verified++;
|
|
217
|
+
const child = await messagesOf(ref);
|
|
218
|
+
const found = child.length ? await findOrigin(ctx, ref, rows, child, meta) : null;
|
|
219
|
+
let contrib = null;
|
|
220
|
+
if (found && found.unresolved) stats.forksUnresolved = (stats.forksUnresolved || 0) + 1;
|
|
221
|
+
else if (found) contrib = inheritedContrib(found.pairs, found.inherits);
|
|
222
|
+
const prevContrib = live ? JSON.parse(live.contrib || '{}') : null;
|
|
223
|
+
if (contribKey(contrib) === contribKey(prevContrib)) continue;
|
|
224
|
+
writes.push({ ref, row: inheritedRow(ref, contrib, prev, ctx.host) });
|
|
225
|
+
if (contrib) stats.forksBilledOnce = (stats.forksBilledOnce || 0) + 1;
|
|
226
|
+
}
|
|
227
|
+
if (deferred.length) stats.forksDeferred = (stats.forksDeferred || 0) + deferred.length;
|
|
228
|
+
return { writes, deferred };
|
|
229
|
+
}
|
|
230
|
+
// One pass is one host (capture builds every ref with refOf(…, ctx.host)); the host is in the key all the same.
|
|
231
|
+
const skey = (r) => `${r.host || ''}\t${r.harness}\t${r.harness_path}\t${r.session_id}\t${r.incarnation || ''}`;
|
|
232
|
+
|
|
233
|
+
module.exports = { forkPass, verifyPrefix, inheritedContrib, INHERITED_ROLE, INHERITED_FILE_KEY, INFERRED, NATIVE, COPY_KINDS, MIN_NOMINATION_TEXT, FORK_BUDGET };
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// The normalised session logs (editors/sessions-log.js, docs/design/sessions-log.md) as a session
|
|
3
|
+
// adapter, so the profile engine (profile-capture.js) captures them as it captures the package's
|
|
4
|
+
// harnesses. Its rows carry adapter 'memhouse-bridge': harness-sessions-sql §4.1's named exemption for
|
|
5
|
+
// harnesses no session adapter covers yet, whose archive is memhouse's own log.
|
|
6
|
+
//
|
|
7
|
+
// THE ADAPTER (each line is a decision)
|
|
8
|
+
// list one store per log directory ($MEMHOUSE_HOME/session-logs/<harness>/<harness_path>),
|
|
9
|
+
// one session per log file, one stream source per session (the file's name)
|
|
10
|
+
// read a stream of complete lines from a byte offset: unit_key the line's offset. A file
|
|
11
|
+
// replaced (another inode or device), shorter than the offset, or whose first line or
|
|
12
|
+
// last read line changed (a rewrite that reused the inode) is a reset: writeLog rewrites a
|
|
13
|
+
// log whose history changed, so the engine re-derives and keeps what vanished as removed.
|
|
14
|
+
// A line longer than the read cap is read whole: a read always makes progress
|
|
15
|
+
// derive the log's records as sessions-model records, the last line of a key winning:
|
|
16
|
+
// session title, cwd, version, created, updated, archived, mode, kind, engine, client; its
|
|
17
|
+
// `links` (stated on the child) as link records
|
|
18
|
+
// message order_key the native `order`, then the line's offset; ts the source's or null; a
|
|
19
|
+
// `tool` part as a tool_call part and, once it has an output or an error, a
|
|
20
|
+
// tool_result part; `file` as an attachment, `error` and `notice` as theirs; its
|
|
21
|
+
// `usage` as a usage record on the message (granularity per_message)
|
|
22
|
+
// usage a usage record on the session (a store's ledger row with no message)
|
|
23
|
+
// child a link record stated on the origin
|
|
24
|
+
// usage flags the record's own; else from `basis` (net: false/false, gross: true/true,
|
|
25
|
+
// anthropic: false/true); else the harness's unflagged default (sessions-log.js)
|
|
26
|
+
|
|
27
|
+
const crypto = require('crypto');
|
|
28
|
+
const fs = require('fs');
|
|
29
|
+
const path = require('path');
|
|
30
|
+
|
|
31
|
+
const slog = require('../../editors/sessions-log');
|
|
32
|
+
|
|
33
|
+
const ADAPTER = 'memhouse-bridge';
|
|
34
|
+
const ADAPTER_VER = '1.0.1';
|
|
35
|
+
const STATE_VER = 1;
|
|
36
|
+
const MAX_READ_BYTES = 64 * 1024 * 1024;
|
|
37
|
+
const UNFLAGGED = { 'gemini-cli': { input_includes_cache: true, output_includes_reasoning: false } };
|
|
38
|
+
const BASIS = { net: [false, false], gross: [true, true], anthropic: [false, true] };
|
|
39
|
+
|
|
40
|
+
const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
|
|
41
|
+
const iso = (ms) => (Number(ms) > 0 ? new Date(Number(ms)).toISOString() : null);
|
|
42
|
+
const pad = (n, w) => String(Math.max(0, Math.trunc(Number(n) || 0))).padStart(w, '0');
|
|
43
|
+
const canon = (v) => (Array.isArray(v) ? `[${v.map(canon).join(',')}]` : v && typeof v === 'object'
|
|
44
|
+
? `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canon(v[k])}`).join(',')}}` : JSON.stringify(v === undefined ? null : v));
|
|
45
|
+
|
|
46
|
+
/** A record's key in its session (as adapter-capture recordKey). */
|
|
47
|
+
function keyOf(r) {
|
|
48
|
+
switch (r.type) {
|
|
49
|
+
case 'session': return '';
|
|
50
|
+
case 'message': return String(r.message_id);
|
|
51
|
+
case 'usage': return String(r.usage_id);
|
|
52
|
+
case 'link': return canon([r.kind, r.stated_on === 'origin' ? r.child : r.origin, r.at ?? '']);
|
|
53
|
+
default: return '';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The flags of one log usage object (header). */
|
|
58
|
+
function flagsOf(u, harness) {
|
|
59
|
+
if (typeof u.input_includes_cache === 'boolean' || typeof u.output_includes_reasoning === 'boolean') {
|
|
60
|
+
return [typeof u.input_includes_cache === 'boolean' ? u.input_includes_cache : null, typeof u.output_includes_reasoning === 'boolean' ? u.output_includes_reasoning : null];
|
|
61
|
+
}
|
|
62
|
+
if (BASIS[u.basis]) return BASIS[u.basis];
|
|
63
|
+
if (UNFLAGGED[harness]) return [UNFLAGGED[harness].input_includes_cache, UNFLAGGED[harness].output_includes_reasoning];
|
|
64
|
+
return [null, null];
|
|
65
|
+
}
|
|
66
|
+
const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
|
|
67
|
+
function usageRecord(id, anchor, u, harness, model) {
|
|
68
|
+
const [iic, oir] = flagsOf(u, harness);
|
|
69
|
+
return { type: 'usage', usage_id: id, anchor, input: num(u.input), output: num(u.output), cache_read: num(u.cache_read), cache_write: num(u.cache_write),
|
|
70
|
+
reasoning: num(u.reasoning), cost: num(u.cost), input_includes_cache: iic, output_includes_reasoning: oir, granularity: 'per_message', model: String(model || '') };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** One log line (its offset `at`) as sessions-model records. */
|
|
74
|
+
function recordsOf(o, at, harness) {
|
|
75
|
+
if (!o || typeof o !== 'object') return [];
|
|
76
|
+
if (o.k === 'session') {
|
|
77
|
+
const s = { type: 'session', title: String(o.title || ''), title_set_by: 'unknown', cwd: String(o.cwd || ''), harness_version: String(o.version || ''),
|
|
78
|
+
created_at: iso(o.created), updated_at: iso(o.updated), state: o.archived ? 'archived' : 'active', mode: String(o.mode || ''), kind: String(o.kind || ''),
|
|
79
|
+
engine: String(o.engine || ''), client: String(o.client || ''), reuse: '', ts_source: '', aliases: [], fingerprint: { cmp_shas: [] }, setup: null };
|
|
80
|
+
const links = (Array.isArray(o.links) ? o.links : []).map((l) => ({ type: 'link', kind: String(l.kind || ''), stated_on: 'child', child: {},
|
|
81
|
+
origin: { harness, session_id: String(l.origin || ''), ...(l.origin_store ? { harness_path: String(l.origin_store) } : {}) }, at: String(l.at || ''),
|
|
82
|
+
inherits: l.through ? { through_message_id: String(l.through) } : {}, evidence: l.evidence === 'inferred' ? 'inferred' : 'explicit' }));
|
|
83
|
+
return [s, ...links];
|
|
84
|
+
}
|
|
85
|
+
if (o.k === 'usage') {
|
|
86
|
+
if (!o.usage) return [];
|
|
87
|
+
return [usageRecord(o.usage_id != null && o.usage_id !== '' ? `usage:${o.usage_id}` : `usage:@${at}`, {}, o.usage, harness, o.model)];
|
|
88
|
+
}
|
|
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
|
+
}
|
|
93
|
+
if (o.k !== 'message' || o.message_id == null) return [];
|
|
94
|
+
const id = String(o.message_id);
|
|
95
|
+
const parts = [];
|
|
96
|
+
// A part's id: its own, else its position in the message.
|
|
97
|
+
const list = Array.isArray(o.parts) ? o.parts : [];
|
|
98
|
+
for (let k = 0; k < list.length; k++) {
|
|
99
|
+
const p = list[k];
|
|
100
|
+
const pid = String(p.part_id ?? k);
|
|
101
|
+
switch (p.kind) {
|
|
102
|
+
case 'tool':
|
|
103
|
+
parts.push({ part_id: `${pid}:call`, kind: 'tool_call', call_id: String(p.call_id || ''), name: String(p.tool || ''), args: p.args ?? {} });
|
|
104
|
+
if (p.output != null || p.is_error) {
|
|
105
|
+
parts.push({ part_id: `${pid}:result`, kind: 'tool_result', call_id: String(p.call_id || ''), output: String(p.output ?? ''), is_error: !!p.is_error,
|
|
106
|
+
...(p.exit_code != null ? { exit_code: p.exit_code } : {}), ...(p.status ? { status: String(p.status) } : {}) });
|
|
107
|
+
}
|
|
108
|
+
break;
|
|
109
|
+
case 'reasoning': parts.push({ part_id: pid, kind: 'reasoning', text: String(p.text ?? ''), ...(p.opaque ? { opaque: true } : {}) }); break;
|
|
110
|
+
case 'summary': parts.push({ part_id: pid, kind: 'summary', text: String(p.text ?? ''), ...(p.replaces ? { replaces: p.replaces } : {}) }); break;
|
|
111
|
+
case 'file': parts.push({ part_id: pid, kind: 'attachment', media_type: String(p.media_type || ''), ...(p.ref ? { ref: String(p.ref) } : {}), ...(p.att_len != null ? { bytes_len: p.att_len } : {}) }); break;
|
|
112
|
+
case 'error': parts.push({ part_id: pid, kind: 'error', message: String(p.text ?? '') }); break;
|
|
113
|
+
case 'notice': parts.push({ part_id: pid, kind: 'notice', text: String(p.text ?? '') }); break;
|
|
114
|
+
default: parts.push({ part_id: pid, kind: 'text', text: String(p.text ?? '') });
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Every part id is unique within its message (a repeat takes ~n), so a log that mixes parts with and
|
|
118
|
+
// without ids, or an id that looks like a derived one (p1:call), never puts two parts on one key.
|
|
119
|
+
const seen = new Set();
|
|
120
|
+
for (const q of parts) {
|
|
121
|
+
if (seen.has(q.part_id)) { let n = 1; while (seen.has(`${q.part_id}~${n}`)) n++; q.part_id = `${q.part_id}~${n}`; }
|
|
122
|
+
seen.add(q.part_id);
|
|
123
|
+
}
|
|
124
|
+
const role = ['user', 'assistant', 'system', 'tool'].includes(o.role) ? o.role : 'assistant';
|
|
125
|
+
const out = [{ type: 'message', message_id: id, role, ts: iso(o.ts), order: `${pad(o.order != null ? o.order : at, 16)}|${pad(at, 12)}`,
|
|
126
|
+
status: String(o.status || 'complete'), state: String(o.state || 'current'), parent_id: String(o.parent_id || ''), agent_id: String(o.agent_id || ''),
|
|
127
|
+
served_model: String(o.model || ''), requested_model: String(o.model_requested || ''), effort: String(o.effort || ''), parts }];
|
|
128
|
+
if (o.usage && typeof o.usage === 'object') out.push(usageRecord(`message:${id}`, { message_id: id }, o.usage, harness, o.model));
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The adapter for one harness's logs under `home` (the profile engine's interface: describe, list, read, derive). */
|
|
133
|
+
function logAdapter(home, harness, { maxReadBytes = MAX_READ_BYTES } = {}) {
|
|
134
|
+
const dirOf = (store) => slog.logDir(home, harness, store.harness_path);
|
|
135
|
+
return {
|
|
136
|
+
describe: () => ({ adapter: ADAPTER, adapter_ver: ADAPTER_VER, state_ver: STATE_VER, model_version: 'sessions-model/1', harness }),
|
|
137
|
+
// A store folder that is not there lists nothing; any other folder error (one that cannot be read)
|
|
138
|
+
// throws, so the store pass reports it instead of taking an empty list for an empty store. A log
|
|
139
|
+
// gone between the folder read and its stat is left out; one whose stat fails otherwise is left out
|
|
140
|
+
// too and named in the list's `errors`, which the store pass reports, so it never blocks the others.
|
|
141
|
+
list(store) {
|
|
142
|
+
let names = [];
|
|
143
|
+
try { names = fs.readdirSync(dirOf(store)).filter((x) => x.endsWith('.jsonl')); } catch (e) { if (e && e.code === 'ENOENT') return []; throw e; }
|
|
144
|
+
const out = [];
|
|
145
|
+
out.errors = [];
|
|
146
|
+
for (const x of names) {
|
|
147
|
+
const p = slog.parseFileName(x);
|
|
148
|
+
if (!p || !p.session_id) continue;
|
|
149
|
+
let st;
|
|
150
|
+
try { st = fs.statSync(path.join(dirOf(store), x)); } catch (e) {
|
|
151
|
+
if (!(e && e.code === 'ENOENT')) out.errors.push({ code: e && e.code, error: `${x}: ${String(e && e.message).split('\n')[0]}` });
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
out.push({ session_id: p.session_id, incarnation: p.incarnation, updated_hint: [st.size, Math.trunc(st.mtimeMs), st.ino],
|
|
155
|
+
sources: [{ source_key: x, mode: 'stream', role: 'main' }] });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
},
|
|
159
|
+
read(store, key, sourceKey, from) {
|
|
160
|
+
const file = path.join(dirOf(store), sourceKey);
|
|
161
|
+
let fd;
|
|
162
|
+
// Gone between list and read: an empty source this pass (the next list no longer names it).
|
|
163
|
+
try { fd = fs.openSync(file, 'r'); } catch { return { continuity: from ? 'unchanged' : 'reset', units: [], gone: [], position: from || null }; }
|
|
164
|
+
try {
|
|
165
|
+
const st = fs.fstatSync(fd);
|
|
166
|
+
const bytesAt = (at, n) => { const b = Buffer.alloc(n); return at + n <= st.size ? b.subarray(0, fs.readSync(fd, b, 0, n, at)) : null; };
|
|
167
|
+
// The bytes the last read rests on, still there: its first line, and the last line it consumed.
|
|
168
|
+
const same = (m) => !m || (() => { const b = bytesAt(Number(m.at), Number(m.len)); return !!b && b.length === Number(m.len) && sha256(b) === m.sha; })();
|
|
169
|
+
const reset = !from || from.ino !== st.ino || (from.dev != null && from.dev !== st.dev) || Number(from.offset) > st.size || !same(from.head) || !same(from.tail);
|
|
170
|
+
const start = reset ? 0 : Number(from.offset);
|
|
171
|
+
let len = Math.min(st.size - start, maxReadBytes);
|
|
172
|
+
let buf = Buffer.alloc(Math.max(len, 0));
|
|
173
|
+
if (len > 0) fs.readSync(fd, buf, 0, len, start);
|
|
174
|
+
// A single line longer than the cap is read whole.
|
|
175
|
+
while (len > 0 && start + len < st.size && buf.indexOf(0x0a) < 0) {
|
|
176
|
+
const more = Math.min(st.size - start - len, maxReadBytes);
|
|
177
|
+
const next = Buffer.alloc(len + more);
|
|
178
|
+
buf.copy(next, 0, 0, len); fs.readSync(fd, next, len, more, start + len);
|
|
179
|
+
buf = next; len += more;
|
|
180
|
+
}
|
|
181
|
+
const units = [];
|
|
182
|
+
let pos = 0; let tail = null;
|
|
183
|
+
for (;;) {
|
|
184
|
+
const nl = buf.indexOf(0x0a, pos);
|
|
185
|
+
if (nl < 0) break; // a partial last line is not a unit: held back until whole
|
|
186
|
+
const line = buf.subarray(pos, nl);
|
|
187
|
+
if (line.length) units.push({ source_key: sourceKey, unit_key: String(start + pos), enc: 'utf8', bytes: line.length, sha256: sha256(line), redacted: false, body: line.toString('utf8') });
|
|
188
|
+
tail = { at: start + pos, len: nl + 1 - pos, sha: sha256(buf.subarray(pos, nl + 1)) };
|
|
189
|
+
pos = nl + 1;
|
|
190
|
+
}
|
|
191
|
+
let head = !reset && from.head ? from.head : null;
|
|
192
|
+
if (!head && start === 0 && pos > 0) { const nl = buf.indexOf(0x0a); head = { at: 0, len: nl + 1, sha: sha256(buf.subarray(0, nl + 1)) }; }
|
|
193
|
+
const position = { offset: start + pos, ino: st.ino, dev: st.dev, head, tail: tail || (!reset && from.tail ? from.tail : null) };
|
|
194
|
+
return { continuity: reset ? 'reset' : units.length ? 'appended' : 'unchanged', units, gone: [], position, held_back: [], more: start + len < st.size };
|
|
195
|
+
} finally { fs.closeSync(fd); }
|
|
196
|
+
},
|
|
197
|
+
derive(key, input, state) {
|
|
198
|
+
const st = state ? { lines: { ...state.lines }, emitted: { ...state.emitted } } : { lines: {}, emitted: {} };
|
|
199
|
+
for (const src of input) {
|
|
200
|
+
if (src.complete) st.lines = {};
|
|
201
|
+
for (const u of src.units) { try { st.lines[u.unit_key] = JSON.parse(u.body); } catch { /* not a record */ } }
|
|
202
|
+
}
|
|
203
|
+
const byKey = new Map();
|
|
204
|
+
for (const at of Object.keys(st.lines).sort((a, b) => Number(a) - Number(b))) {
|
|
205
|
+
for (const r of recordsOf(st.lines[at], Number(at), harness)) byKey.set(`${r.type}\u0000${keyOf(r)}`, r);
|
|
206
|
+
}
|
|
207
|
+
const changed = []; const live = {}; const emitted = {};
|
|
208
|
+
for (const [k, r] of byKey) {
|
|
209
|
+
const j = JSON.stringify(r);
|
|
210
|
+
emitted[k] = j;
|
|
211
|
+
if (st.emitted[k] !== j) changed.push(r);
|
|
212
|
+
(live[r.type] = live[r.type] || []).push(keyOf(r));
|
|
213
|
+
}
|
|
214
|
+
return { changed, live, held: {}, state: { lines: st.lines, emitted }, checkpoint: true, adapter_ver: ADAPTER_VER, model_version: 'sessions-model/1', state_ver: STATE_VER };
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
module.exports = { logAdapter, recordsOf, flagsOf, ADAPTER, ADAPTER_VER };
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// House maintenance — what the capture engine itself never does. capture.js is insert-only by
|
|
3
|
+
// rule (misc/capture-test.js): every change it makes is an insert at a higher rev. Compacting the
|
|
4
|
+
// tombstones those inserts leave behind is a separate, explicit step, here.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Tombstone cleanup. A tombstone (is_deleted = 1: a full copy of the row at rev + 1) must outlive
|
|
8
|
+
* every older version of its key, which is why merges keep it; once a merge has both in one part,
|
|
9
|
+
* ReplacingMergeTree WITH CLEANUP drops the pair. On the dev house every tombstone was a converted
|
|
10
|
+
* 0.18 row that dedupe replaced — 31% of turns. Per partition holding at least 10,000 tombstones, or
|
|
11
|
+
* 1,000 that are 5% of it (any number when forced): enable the table setting, then
|
|
12
|
+
* OPTIMIZE … PARTITION … FINAL CLEANUP.
|
|
13
|
+
* Safe because nothing writes an older version of a tombstoned key again: capture revs only rise,
|
|
14
|
+
* and the conversion refuses to run once the member has deduped sessions (convert-v3).
|
|
15
|
+
*/
|
|
16
|
+
async function tombstoneCleanup(house, { force = false } = {}) {
|
|
17
|
+
const out = [];
|
|
18
|
+
if (typeof house.query !== 'function' || !house.t) return out; // DryHouse, MemoryHouse
|
|
19
|
+
for (const kind of ['turns', 'calls']) {
|
|
20
|
+
const t = house.t[kind];
|
|
21
|
+
// Per partition (turns and calls are partitioned by month; an unpartitioned table is one
|
|
22
|
+
// partition, 'all'): only the partitions that hold tombstones are rewritten. Measured on a copy
|
|
23
|
+
// of the dev house: one month 8.4 s / 167 MiB against 37 s / 682 MiB for the whole table.
|
|
24
|
+
const parts = async () => (await house.query(`SELECT _partition_id AS p, countIf(is_deleted = 1) AS dead, count() AS n FROM ${t} GROUP BY p ORDER BY p`))
|
|
25
|
+
.map((x) => ({ p: String(x.p), dead: Number(x.dead) || 0, n: Number(x.n) || 0 }));
|
|
26
|
+
const before = await parts();
|
|
27
|
+
const dead = before.reduce((a, x) => a + x.dead, 0); const n = before.reduce((a, x) => a + x.n, 0);
|
|
28
|
+
const r = { kind, tombstones: dead, rows: n, cleaned: false, partitions: [] };
|
|
29
|
+
// Each partition decides on its own: at least 10,000 tombstones, or 1,000 that are 5% of it.
|
|
30
|
+
const due = (x) => x.dead > 0 && (force || x.dead >= 10000 || (x.dead >= 1000 && x.dead / Math.max(1, x.n) >= 0.05));
|
|
31
|
+
if (before.some(due)) {
|
|
32
|
+
const t0 = Date.now();
|
|
33
|
+
try {
|
|
34
|
+
await house.query(`ALTER TABLE ${t} MODIFY SETTING allow_experimental_replacing_merge_with_cleanup = 1`);
|
|
35
|
+
for (const x of before) {
|
|
36
|
+
if (!due(x)) continue;
|
|
37
|
+
await house.query(`OPTIMIZE TABLE ${t} PARTITION ID '${x.p.replace(/[^0-9A-Za-z_-]/g, '')}' FINAL CLEANUP`);
|
|
38
|
+
r.partitions.push(x.p);
|
|
39
|
+
}
|
|
40
|
+
const after = await parts();
|
|
41
|
+
Object.assign(r, { cleaned: r.partitions.length > 0, after_tombstones: after.reduce((a, x) => a + x.dead, 0), after_rows: after.reduce((a, x) => a + x.n, 0), ms: Date.now() - t0 });
|
|
42
|
+
} catch (e) { r.error = e.message.split('\n')[0]; }
|
|
43
|
+
}
|
|
44
|
+
out.push(r);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
module.exports = { tombstoneCleanup };
|