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,192 @@
|
|
|
1
|
+
// Reconciliation — does a session's new form hold everything its old form held?
|
|
2
|
+
// (docs/design/source-identity.md draft 5 §7 steps 4–5.)
|
|
3
|
+
//
|
|
4
|
+
// Two questions, asked per session with the same instruments:
|
|
5
|
+
//
|
|
6
|
+
// reconcileConversion legacy current parse (<db>_archive.<m>_*_pre_v3) vs the converted
|
|
7
|
+
// `legacy:` rows in turns. Exact: same rows, same role/agent/ts, same
|
|
8
|
+
// text, same tokens (legacy input as the conversion maps it,
|
|
9
|
+
// convert-v3 legacyInput). Run after convert-v3, before anything is
|
|
10
|
+
// tombstoned.
|
|
11
|
+
//
|
|
12
|
+
// reconcileCapture a session's converted `legacy:ship:` rows vs the rows the capture
|
|
13
|
+
// layer derived from disk (every message_id not starting 'legacy:').
|
|
14
|
+
// Not exact — the two parsers cut a transcript differently (a legacy row
|
|
15
|
+
// is a whole record or Codex turn, a new row one block) and legacy Claude
|
|
16
|
+
// tokens are per record (about double) — so it measures instead:
|
|
17
|
+
// counts kind='text' rows per (role, agent_id), user and assistant;
|
|
18
|
+
// the capture side must hold at least as many
|
|
19
|
+
// text every line of every legacy user/assistant text row (subagent tag and
|
|
20
|
+
// marker lines '[thinking] ', '[tool-call: ', '[tool-result: '
|
|
21
|
+
// dropped) must occur as a line of some captured row
|
|
22
|
+
// tokens both sums, reported, never a pass/fail (per_record vs
|
|
23
|
+
// per_response)
|
|
24
|
+
// The capture stream calls this before tombstoning a session's legacy
|
|
25
|
+
// ship rows; a session that does not reconcile stays legacy (§7 step 5).
|
|
26
|
+
//
|
|
27
|
+
// Reads use FINAL and drop tombstones (is_deleted = 0). Nothing here writes. Transport-free.
|
|
28
|
+
|
|
29
|
+
const { kindSQL, harnessSQL, sessionSQL, currentParseSQL, legacyInputSQL, lit } = require('./convert-v3');
|
|
30
|
+
|
|
31
|
+
const SETTINGS = { final: 0, join_use_nulls: 0, output_format_json_quote_64bit_integers: 0 };
|
|
32
|
+
|
|
33
|
+
function sessionFilter(h, s, session) {
|
|
34
|
+
return session ? ` AND ${h} = ${lit(session.harness)} AND ${s} = ${lit(session.session_id)}` : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Legacy current-parse rows, v3-named: h, s, rl, aid, mid, t, tokens, th (text hash). */
|
|
38
|
+
function legacyRowsSQL(N, session) {
|
|
39
|
+
const L = N.legacy;
|
|
40
|
+
return `SELECT * FROM (
|
|
41
|
+
SELECT ${harnessSQL('m')} AS h, ${sessionSQL('m')} AS s, toString(m.role) AS rl,
|
|
42
|
+
JSONExtractString(toJSONString(m.extra), 'agent', 'id') AS aid,
|
|
43
|
+
concat('legacy:', toString(m.origin), ':', toString(m.epoch), ':', toString(m.seq)) AS mid,
|
|
44
|
+
m.ts AS t, ${kindSQL('m.role', 'm.text', 'h')} AS kd,
|
|
45
|
+
${legacyInputSQL('m', 'h')} AS ti, m.output_tokens AS tout, m.cache_read_tokens AS tcr, m.cache_write_tokens AS tcw,
|
|
46
|
+
cityHash64(m.text) AS th
|
|
47
|
+
FROM ${L.messages} AS m FINAL
|
|
48
|
+
WHERE ${currentParseSQL('m', L.messages, N.member)})
|
|
49
|
+
WHERE 1${sessionFilter('h', 's', session)}`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function convertedRowsSQL(N, session, prefix = 'legacy:') {
|
|
53
|
+
return `SELECT toString(harness) AS h, session_id AS s, toString(role) AS rl, agent_id AS aid, message_id AS mid,
|
|
54
|
+
ts AS t, toString(kind) AS kd,
|
|
55
|
+
input_tokens AS ti, output_tokens AS tout, cache_read_tokens AS tcr, cache_write_tokens AS tcw,
|
|
56
|
+
cityHash64(text) AS th
|
|
57
|
+
FROM ${N.turns} FINAL
|
|
58
|
+
WHERE is_deleted = 0 AND startsWith(message_id, ${lit(prefix)})${sessionFilter('harness', 'session_id', session)}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Legacy vs converted, per session. `session` ({ harness, session_id }) narrows to one.
|
|
63
|
+
* @returns {{ sessions, ok_sessions, ok, mismatched: [...], all: [...] }}
|
|
64
|
+
*/
|
|
65
|
+
async function reconcileConversion(q, N, { session = null } = {}) {
|
|
66
|
+
const Lg = legacyRowsSQL(N, session);
|
|
67
|
+
const Cv = convertedRowsSQL(N, session);
|
|
68
|
+
// Row by row on the full old key: missing, extra, text and field differences.
|
|
69
|
+
const rows = await q.rows(`
|
|
70
|
+
SELECT if(l.p = 1, l.h, c.h) AS kh, if(l.p = 1, l.s, c.s) AS ks,
|
|
71
|
+
countIf(l.p = 1) AS legacy_rows, countIf(c.p = 1) AS converted_rows,
|
|
72
|
+
countIf(l.p = 1 AND c.p = 0) AS missing, countIf(c.p = 1 AND l.p = 0) AS extra,
|
|
73
|
+
countIf(l.p = 1 AND c.p = 1 AND l.th != c.th) AS text_diff,
|
|
74
|
+
countIf(l.p = 1 AND c.p = 1 AND (l.rl != c.rl OR l.aid != c.aid OR l.t != c.t OR l.kd != c.kd)) AS field_diff,
|
|
75
|
+
sumIf(l.ti, l.p = 1) AS l_in, sumIf(c.ti, c.p = 1) AS c_in,
|
|
76
|
+
sumIf(l.tout, l.p = 1) AS l_out, sumIf(c.tout, c.p = 1) AS c_out,
|
|
77
|
+
sumIf(l.tcr, l.p = 1) AS l_cr, sumIf(c.tcr, c.p = 1) AS c_cr,
|
|
78
|
+
sumIf(l.tcw, l.p = 1) AS l_cw, sumIf(c.tcw, c.p = 1) AS c_cw
|
|
79
|
+
FROM (SELECT *, 1 AS p FROM (${Lg})) AS l
|
|
80
|
+
FULL OUTER JOIN (SELECT *, 1 AS p FROM (${Cv})) AS c ON l.h = c.h AND l.s = c.s AND l.mid = c.mid
|
|
81
|
+
GROUP BY kh, ks ORDER BY kh, ks`, SETTINGS);
|
|
82
|
+
// Counts per (role, agent): the shape the design names (§7 step 5). Distinct ids, so a
|
|
83
|
+
// legacy collision (two user_ids, one key — reported by convert) is not a mismatch here.
|
|
84
|
+
const groups = await q.rows(`
|
|
85
|
+
SELECT if(l.p = 1, l.h, c.h) AS kh, if(l.p = 1, l.s, c.s) AS ks, countIf(l.n != c.n) AS group_diff
|
|
86
|
+
FROM (SELECT h, s, rl, aid, uniqExact(mid) AS n, 1 AS p FROM (${Lg}) GROUP BY h, s, rl, aid) AS l
|
|
87
|
+
FULL OUTER JOIN (SELECT h, s, rl, aid, uniqExact(mid) AS n, 1 AS p FROM (${Cv}) GROUP BY h, s, rl, aid) AS c
|
|
88
|
+
ON l.h = c.h AND l.s = c.s AND l.rl = c.rl AND l.aid = c.aid
|
|
89
|
+
GROUP BY kh, ks`, SETTINGS);
|
|
90
|
+
const g = new Map(groups.map((x) => [`${x.kh}\u0000${x.ks}`, Number(x.group_diff) || 0]));
|
|
91
|
+
const all = rows.map((x) => {
|
|
92
|
+
const r = {
|
|
93
|
+
harness: x.kh, session_id: x.ks,
|
|
94
|
+
legacy_rows: Number(x.legacy_rows), converted_rows: Number(x.converted_rows),
|
|
95
|
+
missing: Number(x.missing), extra: Number(x.extra),
|
|
96
|
+
text_diff: Number(x.text_diff), field_diff: Number(x.field_diff),
|
|
97
|
+
group_diff: g.get(`${x.kh}\u0000${x.ks}`) || 0,
|
|
98
|
+
tokens: {
|
|
99
|
+
legacy: { input: Number(x.l_in), output: Number(x.l_out), cache_read: Number(x.l_cr), cache_write: Number(x.l_cw) },
|
|
100
|
+
converted: { input: Number(x.c_in), output: Number(x.c_out), cache_read: Number(x.c_cr), cache_write: Number(x.c_cw) },
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
r.token_diff = ['input', 'output', 'cache_read', 'cache_write'].some((k) => r.tokens.legacy[k] !== r.tokens.converted[k]);
|
|
104
|
+
r.ok = !r.missing && !r.extra && !r.text_diff && !r.field_diff && !r.group_diff && !r.token_diff;
|
|
105
|
+
return r;
|
|
106
|
+
});
|
|
107
|
+
const mismatched = all.filter((r) => !r.ok);
|
|
108
|
+
return { sessions: all.length, ok_sessions: all.length - mismatched.length, ok: mismatched.length === 0, mismatched, all };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Lines of a text, normalised for containment: the subagent tag dropped, each line trimmed,
|
|
112
|
+
// empty lines and legacy marker lines skipped. The same expression on both sides.
|
|
113
|
+
const LINES = (text) => `arrayFilter(x -> x != '', arrayMap(x -> trimBoth(x), splitByChar('\\n',
|
|
114
|
+
if(startsWith(${text}, '[subagent] '), substring(${text}, 12), ${text}))))`;
|
|
115
|
+
const NOT_MARKER = (x) => `NOT (startsWith(${x}, '[thinking] ') OR startsWith(${x}, '[tool-call: ') OR startsWith(${x}, '[tool-result: '))`;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One session: its converted `legacy:ship:` rows vs the rows the capture layer wrote.
|
|
119
|
+
* `minContainment` is the share of legacy lines that must be found (default 1 — every line).
|
|
120
|
+
*
|
|
121
|
+
* @returns {{ harness, session_id, legacy_rows, captured_rows, groups: [...], lines: {legacy, found,
|
|
122
|
+
* containment, missing_message_ids}, tokens: {legacy, captured}, ok, reasons: [] }}
|
|
123
|
+
*/
|
|
124
|
+
async function reconcileCapture(q, N, { harness, session_id }, { minContainment = 1 } = {}) {
|
|
125
|
+
const sess = { harness, session_id };
|
|
126
|
+
const where = `harness = ${lit(harness)} AND session_id = ${lit(session_id)} AND is_deleted = 0`;
|
|
127
|
+
const legacy = `startsWith(message_id, 'legacy:ship:')`;
|
|
128
|
+
const captured = `NOT startsWith(message_id, 'legacy:')`;
|
|
129
|
+
|
|
130
|
+
const groups = (await q.rows(`
|
|
131
|
+
SELECT if(l.p = 1, l.rl, c.rl) AS role, if(l.p = 1, l.aid, c.aid) AS agent_id,
|
|
132
|
+
l.n AS legacy, c.n AS captured
|
|
133
|
+
FROM (SELECT toString(role) AS rl, agent_id AS aid, count() AS n, 1 AS p FROM ${N.turns} FINAL
|
|
134
|
+
WHERE ${where} AND ${legacy} AND kind = 'text' AND role IN ('user', 'assistant') GROUP BY rl, aid) AS l
|
|
135
|
+
FULL OUTER JOIN
|
|
136
|
+
(SELECT toString(role) AS rl, agent_id AS aid, count() AS n, 1 AS p FROM ${N.turns} FINAL
|
|
137
|
+
WHERE ${where} AND ${captured} AND kind = 'text' AND role IN ('user', 'assistant') GROUP BY rl, aid) AS c
|
|
138
|
+
ON l.rl = c.rl AND l.aid = c.aid
|
|
139
|
+
ORDER BY role, agent_id`, SETTINGS)).map((x) => ({ role: x.role, agent_id: x.agent_id, legacy: Number(x.legacy), captured: Number(x.captured) }));
|
|
140
|
+
|
|
141
|
+
const capturedLines = `SELECT DISTINCT cityHash64(line) AS lh, 1 AS hit
|
|
142
|
+
FROM (SELECT arrayJoin(${LINES('text')}) AS line FROM ${N.turns} FINAL WHERE ${where} AND ${captured})`;
|
|
143
|
+
const legacyLines = `SELECT message_id AS mid, cityHash64(line) AS lh
|
|
144
|
+
FROM (SELECT message_id, arrayJoin(${LINES('text')}) AS line FROM ${N.turns} FINAL
|
|
145
|
+
WHERE ${where} AND ${legacy} AND kind = 'text' AND role IN ('user', 'assistant'))
|
|
146
|
+
WHERE ${NOT_MARKER('line')}`;
|
|
147
|
+
const lines = (await q.rows(`
|
|
148
|
+
SELECT uniqExact(l.lh) AS legacy_lines, uniqExactIf(l.lh, c.hit = 1) AS found,
|
|
149
|
+
arraySlice(arraySort(groupUniqArrayIf(l.mid, c.hit = 0)), 1, 10) AS missing_message_ids
|
|
150
|
+
FROM (${legacyLines}) AS l LEFT JOIN (${capturedLines}) AS c ON l.lh = c.lh`, SETTINGS))[0] || {};
|
|
151
|
+
|
|
152
|
+
const tok = (await q.rows(`
|
|
153
|
+
SELECT count() AS n, sumIf(1, ${legacy}) AS l_rows, sumIf(1, ${captured}) AS c_rows,
|
|
154
|
+
sumIf(input_tokens, ${legacy}) AS l_in, sumIf(input_tokens, ${captured}) AS c_in,
|
|
155
|
+
sumIf(output_tokens, ${legacy}) AS l_out, sumIf(output_tokens, ${captured}) AS c_out,
|
|
156
|
+
sumIf(cache_read_tokens, ${legacy}) AS l_cr, sumIf(cache_read_tokens, ${captured}) AS c_cr,
|
|
157
|
+
sumIf(cache_write_tokens, ${legacy}) AS l_cw, sumIf(cache_write_tokens, ${captured}) AS c_cw
|
|
158
|
+
FROM ${N.turns} FINAL WHERE ${where}`, SETTINGS))[0] || {};
|
|
159
|
+
|
|
160
|
+
const legacyLinesN = Number(lines.legacy_lines) || 0;
|
|
161
|
+
const found = Number(lines.found) || 0;
|
|
162
|
+
const containment = legacyLinesN ? found / legacyLinesN : 1;
|
|
163
|
+
const reasons = [];
|
|
164
|
+
if (!Number(tok.l_rows)) reasons.push('no legacy ship rows for this session');
|
|
165
|
+
if (!Number(tok.c_rows)) reasons.push('no captured rows for this session');
|
|
166
|
+
for (const x of groups) {
|
|
167
|
+
if (x.captured < x.legacy) reasons.push(`${x.role}${x.agent_id ? `/${x.agent_id}` : ''}: ${x.captured} captured text rows < ${x.legacy} legacy`);
|
|
168
|
+
}
|
|
169
|
+
if (containment < minContainment) reasons.push(`text: ${found}/${legacyLinesN} legacy lines found (${(containment * 100).toFixed(2)}%)`);
|
|
170
|
+
return {
|
|
171
|
+
harness, session_id: sess.session_id,
|
|
172
|
+
legacy_rows: Number(tok.l_rows) || 0, captured_rows: Number(tok.c_rows) || 0,
|
|
173
|
+
groups,
|
|
174
|
+
lines: { legacy: legacyLinesN, found, containment, missing_message_ids: lines.missing_message_ids || [] },
|
|
175
|
+
tokens: {
|
|
176
|
+
legacy: { input: Number(tok.l_in), output: Number(tok.l_out), cache_read: Number(tok.l_cr), cache_write: Number(tok.l_cw) },
|
|
177
|
+
captured: { input: Number(tok.c_in), output: Number(tok.c_out), cache_read: Number(tok.c_cr), cache_write: Number(tok.c_cw) },
|
|
178
|
+
},
|
|
179
|
+
ok: reasons.length === 0, reasons,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** draft 5 §2.6: any (harness, session_id, message_id) — or call_id — with more than one ts. */
|
|
184
|
+
async function duplicateTs(q, N) {
|
|
185
|
+
const one = async (t, id) => Number((await q.rows(`
|
|
186
|
+
SELECT count() AS n FROM (SELECT harness, session_id, ${id} AS i FROM ${t} FINAL WHERE is_deleted = 0
|
|
187
|
+
GROUP BY harness, host, harness_path, session_id, incarnation, i HAVING uniqExact(ts) > 1)`, SETTINGS))[0]?.n || 0);
|
|
188
|
+
// Per message part (a part may carry its own ts) and per call, in the session's store.
|
|
189
|
+
return { turns: await one(N.turns, "concat(message_id, '#', part_id)"), calls: await one(N.calls, 'call_id') };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
module.exports = { reconcileConversion, reconcileCapture, duplicateTs, legacyRowsSQL, convertedRowsSQL };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// sessions-model records as rows of harness-sessions-sql's profile tables (step B2, the writer). Nearly 1:1: a message's parts are its session_parts rows, a usage record a
|
|
3
|
+
// session_usage row (numbers and flags as given, null for unknown), a link a session_links row
|
|
4
|
+
// (owner 'adapter'), a lifecycle record a session_lifecycle row, the session record the spec
|
|
5
|
+
// columns of its sessions row. Pure: records in, rows out. The engine assigns rev, adapter,
|
|
6
|
+
// adapter_ver, model_version and the derived columns (folder, project, totals).
|
|
7
|
+
//
|
|
8
|
+
// content_sha (harness-sessions-sql §4): a record's own sha (sessions-model §2: the JCS of the
|
|
9
|
+
// record without content_sha and cmp_sha) on every row; on a part row, the sha of the part object
|
|
10
|
+
// as it sits in its message's parts, with message_content_sha the message's. A message's header
|
|
11
|
+
// row (part_id '', kind 'none') carries the message's sha in both.
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
|
|
15
|
+
/** RFC 8785 for JSON values: object keys sorted, ECMAScript serialisation of scalars. */
|
|
16
|
+
function jcs(v) {
|
|
17
|
+
if (Array.isArray(v)) return `[${v.map(jcs).join(',')}]`;
|
|
18
|
+
if (v && typeof v === 'object') return `{${Object.keys(v).filter((k) => v[k] !== undefined).sort().map((k) => `${JSON.stringify(k)}:${jcs(v[k])}`).join(',')}}`;
|
|
19
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
20
|
+
}
|
|
21
|
+
const sha = (v) => crypto.createHash('sha256').update(jcs(v)).digest('hex');
|
|
22
|
+
/** A record's content_sha: the one it carries (sealed by the package), else computed the same way. */
|
|
23
|
+
function recordSha(r) {
|
|
24
|
+
if (/^[0-9a-f]{64}$/.test(String(r.content_sha || ''))) return r.content_sha;
|
|
25
|
+
const { content_sha: _c, cmp_sha: _m, ...rest } = r;
|
|
26
|
+
return sha(rest);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const str = (v) => (v == null ? '' : String(v));
|
|
30
|
+
const nb = (v) => (typeof v === 'boolean' ? v : null);
|
|
31
|
+
const nn = (v) => (Number.isFinite(Number(v)) && v !== null && v !== '' ? Number(v) : null);
|
|
32
|
+
const tsOrNull = (v) => (v && Number.isFinite(Date.parse(v)) ? v : null);
|
|
33
|
+
|
|
34
|
+
/** The part columns of one part. */
|
|
35
|
+
function partColumns(p) {
|
|
36
|
+
const ref = (x) => (x && typeof x === 'object' ? x : (x ? { ref: String(x) } : null));
|
|
37
|
+
const out = {
|
|
38
|
+
part_id: str(p.part_id), kind: str(p.kind), text: '',
|
|
39
|
+
call_id: '', tool_name: '', args: '', server: '', is_error: null, result_status: '', output: '', output_ref: '', output_ref_sha256: '', output_ref_bytes: null,
|
|
40
|
+
output_len: null, truncated: null, exit_code: null, media_type: '', inline_b64: '', ref: '', bytes_len: null, opaque: null, signature_len: null,
|
|
41
|
+
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),
|
|
43
|
+
};
|
|
44
|
+
switch (p.kind) {
|
|
45
|
+
case 'text': case 'notice': case 'summary': case 'reasoning':
|
|
46
|
+
out.text = str(p.text);
|
|
47
|
+
if (p.kind === 'notice') { out.level = str(p.level); out.native_type = str(p.native_type); }
|
|
48
|
+
if (p.kind === 'reasoning') { out.opaque = nb(p.opaque); out.signature_len = nn(p.signature_len); }
|
|
49
|
+
if (p.kind === 'summary' && p.replaces && typeof p.replaces === 'object') {
|
|
50
|
+
const [form] = Object.keys(p.replaces);
|
|
51
|
+
if (form) { out.replaces_form = form; out.replaces_value = typeof p.replaces[form] === 'string' ? p.replaces[form] : JSON.stringify(p.replaces[form]); }
|
|
52
|
+
}
|
|
53
|
+
break;
|
|
54
|
+
case 'tool_call':
|
|
55
|
+
out.call_id = str(p.call_id); out.tool_name = str(p.name); out.args = p.args == null ? '' : JSON.stringify(p.args); out.server = str(p.server);
|
|
56
|
+
break;
|
|
57
|
+
case 'tool_result': {
|
|
58
|
+
out.call_id = str(p.call_id); out.is_error = nb(p.is_error); out.result_status = str(p.status); out.output = str(p.output);
|
|
59
|
+
out.output_len = nn(p.output_len); out.truncated = nb(p.truncated); out.exit_code = nn(p.exit_code);
|
|
60
|
+
const r = ref(p.output_ref);
|
|
61
|
+
if (r) { out.output_ref = str(r.ref); out.output_ref_sha256 = str(r.sha256); out.output_ref_bytes = nn(r.bytes); }
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
case 'attachment': {
|
|
65
|
+
out.media_type = str(p.media_type); out.inline_b64 = str(p.inline); out.bytes_len = nn(p.bytes_len);
|
|
66
|
+
const r = ref(p.ref);
|
|
67
|
+
if (r) out.ref = str(r.ref);
|
|
68
|
+
break;
|
|
69
|
+
}
|
|
70
|
+
case 'error':
|
|
71
|
+
out.error_kind = str(p.error_kind); out.error_message = str(p.message); out.http_status = nn(p.http_status);
|
|
72
|
+
break;
|
|
73
|
+
default: break;
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Profile rows for one session's record view.
|
|
80
|
+
* @param {object[]} records every live record of the session
|
|
81
|
+
* @param {object} key { host, harness, harness_path, session_id, incarnation }
|
|
82
|
+
* @param {object} [o] { firstSeen(messageId) → ISO }: the engine's first-seen time
|
|
83
|
+
* @returns {{ parts, usage, links, lifecycle, session, calls }}
|
|
84
|
+
*/
|
|
85
|
+
function profileRows(records, key, { firstSeen = () => '1970-01-01T00:00:00.000Z' } = {}) {
|
|
86
|
+
const K = { host: key.host, harness: key.harness, harness_path: key.harness_path, session_id: key.session_id, incarnation: key.incarnation || '' };
|
|
87
|
+
const messages = records.filter((r) => r.type === 'message');
|
|
88
|
+
const parts = []; const calls = new Map();
|
|
89
|
+
const tsOfMessage = new Map(); const byResponse = new Map();
|
|
90
|
+
for (const m of messages) {
|
|
91
|
+
const msgSha = recordSha(m);
|
|
92
|
+
const ts = tsOrNull(m.ts);
|
|
93
|
+
tsOfMessage.set(String(m.message_id), ts);
|
|
94
|
+
// A response's time is its earliest message's that has one: a first message without a time does
|
|
95
|
+
// not leave the response's usage untimed.
|
|
96
|
+
if (m.response_id && ts && (!byResponse.get(m.response_id) || Date.parse(ts) < Date.parse(byResponse.get(m.response_id)))) byResponse.set(m.response_id, ts);
|
|
97
|
+
const message = {
|
|
98
|
+
...K, message_id: str(m.message_id), parent_id: str(m.parent_id), order_key: str(m.order), agent_id: str(m.agent_id), role: str(m.role),
|
|
99
|
+
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
|
+
ts, requested_model: str(m.requested_model), served_model: str(m.served_model), effort: str(m.effort), response_id: str(m.response_id),
|
|
101
|
+
message_aliases: Array.isArray(m.aliases) ? m.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
|
|
102
|
+
cmp_sha: str(m.cmp_sha), message_content_sha: msgSha, first_seen: firstSeen(str(m.message_id)),
|
|
103
|
+
};
|
|
104
|
+
const ps = Array.isArray(m.parts) ? m.parts : [];
|
|
105
|
+
ps.forEach((p, i) => {
|
|
106
|
+
const row = { ...message, part_index: i, ...partColumns(p), content_sha: sha(p) };
|
|
107
|
+
parts.push(row);
|
|
108
|
+
// A call a revert hides (its part's state, else its message's) counts nowhere, as its parts do not:
|
|
109
|
+
// it is left out of {m}_calls, and a call written before the revert is retired with them.
|
|
110
|
+
const gone = ['reverted', 'retracted'].includes(String(p.state || m.state || ''));
|
|
111
|
+
if (p.kind === 'tool_call' && p.call_id && !gone) {
|
|
112
|
+
calls.set(p.call_id, { result_ts: null, is_error: null, output_len: null, ...(calls.get(p.call_id) || {}), ...K, call_id: str(p.call_id), message_id: message.message_id, agent_id: message.agent_id, ts, first_seen: message.first_seen,
|
|
113
|
+
tool_name: str(p.name), args: p.args == null ? '' : JSON.stringify(p.args), model: message.served_model || message.requested_model, content_sha: row.content_sha });
|
|
114
|
+
}
|
|
115
|
+
if (p.kind === 'tool_result' && p.call_id && !gone) {
|
|
116
|
+
calls.set(p.call_id, { ...(calls.get(p.call_id) || {}), result_ts: ts, is_error: nb(p.is_error), output_len: nn(p.output_len) ?? (p.output == null ? null : String(p.output).length) });
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
if (!ps.length) parts.push({ ...message, part_index: 0, ...partColumns({ part_id: '', kind: 'none' }), content_sha: msgSha });
|
|
120
|
+
}
|
|
121
|
+
const anchorTs = (a) => (a.response_id != null ? byResponse.get(a.response_id) : (a.message_id != null ? tsOfMessage.get(String(a.message_id)) : null)) ?? null;
|
|
122
|
+
const usage = records.filter((r) => r.type === 'usage').map((u) => {
|
|
123
|
+
const a = u.anchor || {};
|
|
124
|
+
const kind = a.response_id != null ? 'response' : a.message_id != null ? 'message' : a.compaction != null ? 'compaction' : 'session';
|
|
125
|
+
const id = kind === 'response' ? a.response_id : kind === 'message' ? a.message_id : kind === 'compaction' ? a.compaction : '';
|
|
126
|
+
return {
|
|
127
|
+
...K, usage_id: str(u.usage_id), anchor_kind: kind, anchor_id: str(id),
|
|
128
|
+
input: nn(u.input), output: nn(u.output), cache_read: nn(u.cache_read), cache_write: nn(u.cache_write), reasoning: nn(u.reasoning),
|
|
129
|
+
tool_prompt: nn(u.tool_prompt), cost: nn(u.cost), input_includes_cache: nb(u.input_includes_cache), output_includes_reasoning: nb(u.output_includes_reasoning),
|
|
130
|
+
granularity: str(u.granularity), model: str(u.model), content_sha: recordSha(u), ts: anchorTs(a),
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
const refCols = (prefix, r) => ({ [`${prefix}_host`]: str(r && r.host), [`${prefix}_harness_path`]: str(r && r.harness_path), [`${prefix}_harness`]: str(r && r.harness),
|
|
134
|
+
[`${prefix}_session_id`]: str(r && r.session_id), [`${prefix}_incarnation`]: r && r.incarnation != null ? String(r.incarnation) : null });
|
|
135
|
+
const links = records.filter((r) => r.type === 'link').map((l) => {
|
|
136
|
+
const other = l.stated_on === 'origin' ? l.child : l.origin;
|
|
137
|
+
const inh = l.inherits || {};
|
|
138
|
+
return {
|
|
139
|
+
...K, owner: 'adapter', kind: str(l.kind), other_host: str(other && other.host), other_harness_path: str(other && other.harness_path), other_harness: str(other && other.harness),
|
|
140
|
+
other_session_id: str(other && other.session_id), other_incarnation: str(other && other.incarnation), at: str(l.at), stated_on: str(l.stated_on),
|
|
141
|
+
...refCols('child', l.child), ...refCols('origin', l.origin),
|
|
142
|
+
through_message_id: str(inh.through_message_id), ids_preserved: nb(inh.ids_preserved), marked: nb(inh.marked), usage_copied: nb(inh.usage_copied),
|
|
143
|
+
child_stored: typeof l.child_stored === 'boolean' ? l.child_stored : null, evidence: str(l.evidence) || 'explicit', verified: null, statements: [], content_sha: recordSha(l),
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
const lifecycle = records.filter((r) => r.type === 'lifecycle').map((x) => {
|
|
147
|
+
const t = x.target || {};
|
|
148
|
+
const kind = t.session ? 'session' : t.range ? 'range' : 'message';
|
|
149
|
+
return {
|
|
150
|
+
...K, lifecycle_id: str(x.lifecycle_id), op: str(x.op), target_kind: kind, target_id: str(t.message_id),
|
|
151
|
+
target_from: str(t.range && t.range.from_id), target_through: str(t.range && t.range.through_id), at: str(x.at), ts: tsOrNull(x.ts),
|
|
152
|
+
evidence: str(x.evidence) || 'explicit', content_sha: recordSha(x),
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
const s = records.find((r) => r.type === 'session') || null;
|
|
156
|
+
const session = s ? {
|
|
157
|
+
...K, engine: str(s.engine), client: str(s.client), harness_version: str(s.harness_version), title: str(s.title), title_set_by: str(s.title_set_by) || 'unknown',
|
|
158
|
+
cwd: str(s.cwd), last_cwd: str(s.last_cwd), created_at: tsOrNull(s.created_at), updated_at: tsOrNull(s.updated_at), state: str(s.state), pinned: nb(s.pinned),
|
|
159
|
+
mode: str(s.mode), kind: str(s.kind), reuse: str(s.reuse), ts_source: str(s.ts_source),
|
|
160
|
+
aliases: Array.isArray(s.aliases) ? s.aliases.map((a) => ({ id: str(a.id), kind: str(a.kind) })) : [],
|
|
161
|
+
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),
|
|
163
|
+
} : null;
|
|
164
|
+
return { parts, usage, links, lifecycle, session, calls: [...calls.values()].filter((c) => c.call_id && c.tool_name !== undefined) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The version of this mapping. A change to what rows the same records make (2: a reverted call counts
|
|
169
|
+
* nowhere; a response takes its earliest time) moves it, and every captured session is mapped again
|
|
170
|
+
* once from the view its local state keeps: no source read or derive, only the rows that differ written.
|
|
171
|
+
*/
|
|
172
|
+
const ROWS_VER = 2;
|
|
173
|
+
|
|
174
|
+
module.exports = { profileRows, partColumns, jcs, recordSha, ROWS_VER };
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// sessions-model records — what the session adapters' derive emits — as schema-3 rows. Step 2 of
|
|
3
|
+
// the switch to @agent-realm/harness-session-adapters (decided 2026-10-23: the package derives the
|
|
4
|
+
// records, memhouse owns the mapping from records to its rows). The engine keeps each session's
|
|
5
|
+
// record view (the package's applyDerive) and calls rowsOf on it; it assigns rev, adapter_ver,
|
|
6
|
+
// folder/project, host, origin and ingested_at. Pure: records in, rows out.
|
|
7
|
+
//
|
|
8
|
+
// THE MAPPING (each line is a decision)
|
|
9
|
+
//
|
|
10
|
+
// message one turns row per part: message_id, the part's own part_id, part_ord = its
|
|
11
|
+
// position in parts. A message with no parts keeps a header row (part_id '',
|
|
12
|
+
// kind 'none'), which carries its status and its usage.
|
|
13
|
+
// text text
|
|
14
|
+
// reasoning thinking; an opaque one has empty text and extra.opaque
|
|
15
|
+
// summary summary
|
|
16
|
+
// notice, error event: extra.notice is the part kind, plus the error's fields
|
|
17
|
+
// attachment attachment: `<media_type> <sha256 | ref>`, extra.media_type/att_sha/att_len
|
|
18
|
+
// tool_call tool_call: `<name> <args JSON>`, and a calls row (call_id, tool_name, args)
|
|
19
|
+
// tool_result tool_result, role user (as 0.20 stores results); its call's row gets
|
|
20
|
+
// result_ts, is_error and result_len
|
|
21
|
+
// role user | assistant | system; a tool message's rows are user rows
|
|
22
|
+
// model served_model, else requested_model
|
|
23
|
+
// state '' for current, else the record's (retracted, superseded, …)
|
|
24
|
+
// extra status when not complete; on_active_branch when false; source when it is
|
|
25
|
+
// neither user nor model; the record's order
|
|
26
|
+
// ts the message's ts. One without takes tsOf(message_id), the engine's first-seen
|
|
27
|
+
// time, so its key (toDate(ts) and ts are in the ORDER BY) never moves.
|
|
28
|
+
// ord the message's rank among the session's messages by `order`
|
|
29
|
+
// usage tokens on its anchor's first row: response_id, the first message (by order)
|
|
30
|
+
// with that response; message_id, that message. A session or compaction
|
|
31
|
+
// anchor, or an anchor no message has, is a row of its own (message_id
|
|
32
|
+
// 'usage:<usage_id>', kind 'usage'; with '~' added while a message of the
|
|
33
|
+
// session has that id). Several usage records on one row add up.
|
|
34
|
+
// houseTokens applies input_includes_cache / output_includes_reasoning.
|
|
35
|
+
// session the sessions row's metadata: title, cwd as folder, created_at, updated_at,
|
|
36
|
+
// harness_version, mode
|
|
37
|
+
// link stated by the child: the sessions row's links (kind, origin, origin_store,
|
|
38
|
+
// evidence, at, through); stated by the origin (a sub-agent it started):
|
|
39
|
+
// `children`, for the child's side
|
|
40
|
+
// lifecycle no row: its effect is the message's state, as the 0.20 log derive did.
|
|
41
|
+
// Step B gives it harness-sessions-sql's session_lifecycle.
|
|
42
|
+
|
|
43
|
+
const ROLES = new Set(['user', 'assistant', 'system']);
|
|
44
|
+
|
|
45
|
+
function n(v) { const x = Math.trunc(Number(v)); return Number.isFinite(x) && x > 0 ? x : 0; }
|
|
46
|
+
|
|
47
|
+
/** A usage record's tokens in the house split (sessions-model §2.4): input without cached input,
|
|
48
|
+
* output with reasoning. A flag that is not a boolean stores the number as given. `tool_prompt`
|
|
49
|
+
* (v1: prompt tokens of tool use that `input` leaves out, Gemini's toolUsePromptTokenCount) is input. */
|
|
50
|
+
function houseTokens(u) {
|
|
51
|
+
const input = n(u.input); const cr = n(u.cache_read); const cw = n(u.cache_write); const rs = n(u.reasoning);
|
|
52
|
+
return {
|
|
53
|
+
input_tokens: (u.input_includes_cache === true ? Math.max(input - cr - cw, 0) : input) + n(u.tool_prompt),
|
|
54
|
+
output_tokens: n(u.output) + (u.output_includes_reasoning === false ? rs : 0),
|
|
55
|
+
cache_read_tokens: cr, cache_write_tokens: cw, reasoning_tokens: rs,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const TOKENS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'];
|
|
59
|
+
const anyTokens = (t) => TOKENS.some((k) => t[k] > 0);
|
|
60
|
+
|
|
61
|
+
function partRow(p) {
|
|
62
|
+
switch (p.kind) {
|
|
63
|
+
case 'text': return { kind: 'text', text: String(p.text ?? '') };
|
|
64
|
+
case 'reasoning': return p.opaque ? { kind: 'thinking', text: '', extra: { opaque: true } } : { kind: 'thinking', text: String(p.text ?? '') };
|
|
65
|
+
case 'summary': return { kind: 'summary', text: String(p.text ?? '') };
|
|
66
|
+
case 'notice': return { kind: 'event', text: String(p.text ?? ''), extra: { notice: 'notice', ...(p.level ? { level: p.level } : {}), ...(p.native_type ? { native_type: p.native_type } : {}) } };
|
|
67
|
+
case 'error': return { kind: 'event', text: String(p.message ?? ''), extra: { notice: 'error', error_kind: p.error_kind ?? null, ...(p.http_status != null ? { http_status: p.http_status } : {}) } };
|
|
68
|
+
case 'attachment': {
|
|
69
|
+
const ref = p.ref && typeof p.ref === 'object' ? p.ref : null;
|
|
70
|
+
const sha = (ref && ref.sha256) || p.sha256 || '';
|
|
71
|
+
return { kind: 'attachment', text: `${p.media_type || 'file'} ${sha || (ref && ref.ref) || ''}`.trim(),
|
|
72
|
+
extra: { media_type: p.media_type ?? null, att_sha: sha || null, att_len: p.bytes_len ?? (ref && ref.bytes) ?? null } };
|
|
73
|
+
}
|
|
74
|
+
case 'tool_call': return { kind: 'tool_call', text: `${p.name ?? ''} ${JSON.stringify(p.args ?? {})}` };
|
|
75
|
+
case 'tool_result': return { kind: 'tool_result', text: String(p.output ?? ''), role: 'user' };
|
|
76
|
+
default: return { kind: 'text', text: String(p.text ?? ''), extra: { part_kind: String(p.kind) } };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Rows for one session's record view.
|
|
82
|
+
* @param {object[]} records every live record of the session (any order)
|
|
83
|
+
* @param {object} [o]
|
|
84
|
+
* @param {(messageId: string) => string} [o.tsOf] a first-seen ts (ISO) for a message without one
|
|
85
|
+
* @returns {{ turns: object[], calls: object[], meta: object|null, links: object[], children: object[] }}
|
|
86
|
+
*/
|
|
87
|
+
function rowsOf(records, { tsOf = () => '1970-01-01T00:00:00.000Z' } = {}) {
|
|
88
|
+
const messages = records.filter((r) => r.type === 'message')
|
|
89
|
+
.sort((a, b) => (String(a.order) < String(b.order) ? -1 : String(a.order) > String(b.order) ? 1 : (a.message_id < b.message_id ? -1 : 1)));
|
|
90
|
+
const turns = [];
|
|
91
|
+
const calls = new Map();
|
|
92
|
+
const firstRow = new Map(); // message_id -> its first turns row
|
|
93
|
+
const byResponse = new Map(); // response_id -> the first message with it
|
|
94
|
+
messages.forEach((m, rank) => {
|
|
95
|
+
if (m.response_id && !byResponse.has(m.response_id)) byResponse.set(m.response_id, m.message_id);
|
|
96
|
+
const ts = m.ts || tsOf(m.message_id);
|
|
97
|
+
const role = ROLES.has(m.role) ? m.role : 'user';
|
|
98
|
+
const extra = {};
|
|
99
|
+
if (m.status && m.status !== 'complete') extra.status = m.status;
|
|
100
|
+
if (m.on_active_branch === false) extra.on_active_branch = false;
|
|
101
|
+
if (m.source && m.source !== 'user' && m.source !== 'model') extra.source = m.source;
|
|
102
|
+
if (m.order != null) extra.order = String(m.order);
|
|
103
|
+
const base = {
|
|
104
|
+
session_id: m.session_id, incarnation: m.incarnation ?? '', message_id: String(m.message_id),
|
|
105
|
+
parent_id: m.parent_id ?? '', response_id: m.response_id ?? '', agent_id: m.agent_id ?? '',
|
|
106
|
+
ts, ord: rank, state: m.state && m.state !== 'current' ? String(m.state) : '',
|
|
107
|
+
model: m.served_model || m.requested_model || '', effort: m.effort || '',
|
|
108
|
+
};
|
|
109
|
+
const parts = Array.isArray(m.parts) ? m.parts : [];
|
|
110
|
+
parts.forEach((p, i) => {
|
|
111
|
+
const r = partRow(p);
|
|
112
|
+
const row = { ...base, part_id: String(p.part_id ?? i), part_ord: i, role: r.role || role, kind: r.kind, text: r.text, extra: { ...extra, ...(r.extra || {}) } };
|
|
113
|
+
turns.push(row);
|
|
114
|
+
if (!firstRow.has(base.message_id)) firstRow.set(base.message_id, row);
|
|
115
|
+
if (p.kind === 'tool_call' && p.call_id) {
|
|
116
|
+
calls.set(p.call_id, { ...(calls.get(p.call_id) || {}), session_id: base.session_id, incarnation: base.incarnation, call_id: String(p.call_id),
|
|
117
|
+
message_id: base.message_id, agent_id: base.agent_id, ts, tool_name: String(p.name ?? ''), args: JSON.stringify(p.args ?? {}), model: base.model });
|
|
118
|
+
}
|
|
119
|
+
if (p.kind === 'tool_result' && p.call_id) {
|
|
120
|
+
calls.set(p.call_id, { ...(calls.get(p.call_id) || {}), result_ts: ts, is_error: !!p.is_error, result_len: n(p.output_len) || String(p.output ?? '').length });
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
if (!parts.length) {
|
|
124
|
+
const row = { ...base, part_id: '', part_ord: 0, role, kind: 'none', text: '', extra };
|
|
125
|
+
turns.push(row);
|
|
126
|
+
firstRow.set(base.message_id, row);
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// A usage row's id must not be a message's: the (message_id, part_id) key would be shared.
|
|
131
|
+
const taken = new Set(messages.map((m) => String(m.message_id)));
|
|
132
|
+
for (const u of records.filter((r) => r.type === 'usage').sort((a, b) => (a.usage_id < b.usage_id ? -1 : 1))) {
|
|
133
|
+
const tok = houseTokens(u);
|
|
134
|
+
if (!anyTokens(tok)) continue;
|
|
135
|
+
const a = u.anchor || {};
|
|
136
|
+
const mid = a.response_id != null ? byResponse.get(a.response_id) : (a.message_id != null ? a.message_id : null);
|
|
137
|
+
let row = mid != null ? firstRow.get(String(mid)) : null;
|
|
138
|
+
if (!row) {
|
|
139
|
+
let id = `usage:${u.usage_id}`;
|
|
140
|
+
while (taken.has(id)) id += '~';
|
|
141
|
+
taken.add(id);
|
|
142
|
+
row = { session_id: u.session_id, incarnation: u.incarnation ?? '', message_id: id, parent_id: '', response_id: a.response_id ?? '',
|
|
143
|
+
agent_id: '', ts: tsOf(id), ord: messages.length, state: '', model: u.model || '', effort: '',
|
|
144
|
+
part_id: '', part_ord: 0, role: 'assistant', kind: 'usage', text: '', extra: { anchor: Object.keys(a)[0] || 'none', granularity: u.granularity ?? null } };
|
|
145
|
+
turns.push(row);
|
|
146
|
+
}
|
|
147
|
+
for (const k of TOKENS) row[k] = (row[k] || 0) + tok[k];
|
|
148
|
+
if (!row.model && u.model) row.model = u.model;
|
|
149
|
+
}
|
|
150
|
+
for (const r of turns) for (const k of TOKENS) r[k] = r[k] || 0;
|
|
151
|
+
|
|
152
|
+
const s = records.find((r) => r.type === 'session') || null;
|
|
153
|
+
const meta = s ? { title: s.title ?? '', folder: s.cwd ?? '', created_at: s.created_at ?? null, updated_at: s.updated_at ?? null,
|
|
154
|
+
harness_version: s.harness_version ?? '', mode: s.mode ?? '' } : null;
|
|
155
|
+
const linkRecs = records.filter((r) => r.type === 'link');
|
|
156
|
+
// What the session states about its own origin goes on its row, as 0.20's links did; what it
|
|
157
|
+
// states about a child (a sub-agent it started) is returned apart, for the child's side.
|
|
158
|
+
const links = linkRecs.filter((l) => l.stated_on !== 'origin').map((l) => ({
|
|
159
|
+
kind: String(l.kind || ''), origin: String((l.origin || {}).session_id || ''), origin_store: String((l.origin || {}).harness_path || ''),
|
|
160
|
+
evidence: String(l.evidence || ''), at: l.at == null ? '' : String(l.at),
|
|
161
|
+
through: l.inherits && l.inherits.through_message_id ? String(l.inherits.through_message_id) : '',
|
|
162
|
+
}));
|
|
163
|
+
const children = linkRecs.filter((l) => l.stated_on === 'origin').map((l) => ({
|
|
164
|
+
kind: String(l.kind || ''), session_id: String((l.child || {}).session_id || ''), evidence: String(l.evidence || ''), at: l.at == null ? '' : String(l.at),
|
|
165
|
+
}));
|
|
166
|
+
return { turns, calls: [...calls.values()].filter((c) => c.call_id), meta, links, children };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports = { rowsOf, houseTokens };
|
|
@@ -19,6 +19,7 @@ const DERIVED = ['text_ngram', 'text_word'];
|
|
|
19
19
|
function isDurableMetaKey(key) {
|
|
20
20
|
return key === 'schema_version'
|
|
21
21
|
|| key === 'min_writer_schema'
|
|
22
|
+
|| key === 'profile_converted' // the profile conversion ran to the end (schema-choice.js)
|
|
22
23
|
|| key === 'house_id' // forward-compat: harmless if absent today
|
|
23
24
|
|| /^share:/.test(key);
|
|
24
25
|
}
|
|
@@ -41,7 +42,7 @@ function copyColumns(destCols, srcCols) {
|
|
|
41
42
|
*
|
|
42
43
|
* @param url the source MEMHOUSE_URL (http(s)://host:8123)
|
|
43
44
|
* @param opts.host override the host — the DESTINATION's route to the source may differ
|
|
44
|
-
* from the
|
|
45
|
+
* from the user's URL (NAT, split-horizon DNS, a private peering name)
|
|
45
46
|
* @param opts.port override the native port
|
|
46
47
|
* @param opts.insecure use remote() + plain 9000 instead of remoteSecure() + 9440
|
|
47
48
|
* @returns { fn: 'remoteSecure'|'remote', host, port, addr: 'host:port' }
|