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,372 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// `memhouse import claude-ai`: plan, apply, undo and list import batches. Transport-free: every
|
|
3
|
+
// entry point takes `house` — { rows(sql) -> object[], exec(sql), insert(fq, rows) } — and the
|
|
4
|
+
// member's names ({ db, member, … }), so the CLI, a lab test and a unit test drive the same code.
|
|
5
|
+
// The member's layout picks the writer: writer-v3.js for <m>_turns (layout 3.2), writer-profile.js
|
|
6
|
+
// for harness-sessions-sql's profile. Both take the parser's keys as they are.
|
|
7
|
+
//
|
|
8
|
+
// What is written, and when:
|
|
9
|
+
// new a conversation the house has never held: its rows at rev 1.
|
|
10
|
+
// same the house holds it and the export's updated_at is not newer: nothing.
|
|
11
|
+
// changed the export's updated_at is newer than the house's: nothing, unless --update.
|
|
12
|
+
// rewritten a session written before whose rows the house still has (changed with --update,
|
|
13
|
+
// or rows left by an undone batch or a stopped run): every live row of it is
|
|
14
|
+
// tombstoned at R = 1 + the highest rev any of its rows carries, and the new rows
|
|
15
|
+
// are written at R + 1. So a re-import is never hidden behind an older tombstone,
|
|
16
|
+
// and a message the export no longer holds does not stay current.
|
|
17
|
+
// The session row is written last: a run that stops before it leaves the session not present,
|
|
18
|
+
// and the next run writes it again.
|
|
19
|
+
|
|
20
|
+
const C = require('./claude-ai');
|
|
21
|
+
const V = require('./writer-v3');
|
|
22
|
+
const PR = require('./writer-profile');
|
|
23
|
+
|
|
24
|
+
const WRITERS = { [V.LAYOUT]: V, [PR.LAYOUT]: PR };
|
|
25
|
+
// The layout 3.2 writer's store, kept for callers that build its rows directly.
|
|
26
|
+
const SRC = V.SRC;
|
|
27
|
+
// The member layouts this build writes imports into (layoutOf's names).
|
|
28
|
+
const WRITES_LAYOUTS = Object.keys(WRITERS);
|
|
29
|
+
const BATCH_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
|
|
30
|
+
const META_PREFIX = 'import:';
|
|
31
|
+
const ID_CHUNK = 500;
|
|
32
|
+
|
|
33
|
+
class ImportRefusal extends Error {}
|
|
34
|
+
|
|
35
|
+
function newBatchId(now = new Date()) {
|
|
36
|
+
const iso = now.toISOString();
|
|
37
|
+
return `claude-ai-${iso.slice(0, 10)}-${iso.slice(11, 19).replace(/:/g, '')}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function checkBatchId(id) {
|
|
41
|
+
if (!BATCH_ID.test(String(id))) throw new ImportRefusal(`'${id}' is not a batch id (letters, digits, '.', '_' and '-')`);
|
|
42
|
+
return String(id);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The member's layout as its tables show it: '3.2' (<m>_turns), 'profile' (<m>_session_parts), '0.18', or null. */
|
|
46
|
+
async function layoutOf(house, db, member) {
|
|
47
|
+
const rows = await house.rows(`SELECT name FROM system.tables WHERE database = ${V.sqlStr(db)}
|
|
48
|
+
AND name IN (${[`${member}_turns`, `${member}_session_parts`, `${member}_messages`].map(V.sqlStr).join(', ')})`);
|
|
49
|
+
const has = (n) => rows.some((r) => r.name === `${member}_${n}`);
|
|
50
|
+
return has('turns') ? '3.2' : has('session_parts') ? 'profile' : has('messages') ? '0.18' : null;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The writer for the member's layout and its table names: { layout, W, N }. A member on both
|
|
55
|
+
* layouts (the profile laid beside <m>_turns) is written on the profile, which its readers read.
|
|
56
|
+
*/
|
|
57
|
+
async function requireLayout(house, names) {
|
|
58
|
+
const layout = await layoutOf(house, names.db, names.member);
|
|
59
|
+
const turnsToo = layout === '3.2' && (await house.rows(`SELECT name FROM system.tables WHERE database = ${V.sqlStr(names.db)}
|
|
60
|
+
AND name = ${V.sqlStr(`${names.member}_session_parts`)}`)).length > 0;
|
|
61
|
+
if (turnsToo) {
|
|
62
|
+
// Both layouts name <m>_sessions and <m>_calls: the profile is written only when those are its own.
|
|
63
|
+
const cols = await house.rows(`SELECT name FROM system.columns WHERE database = ${V.sqlStr(names.db)}
|
|
64
|
+
AND table = ${V.sqlStr(`${names.member}_sessions`)} AND name IN ('title', 'harness_path')`);
|
|
65
|
+
if (cols.length < 2) {
|
|
66
|
+
throw new ImportRefusal(`'${names.member}' has ${names.member}_session_parts beside the 3.2 tables, but ${names.member}_sessions is not the profile's: `
|
|
67
|
+
+ 'finish the move to the profile first (docs/guides/upgrade-to-0.20.md)');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const use = turnsToo ? PR.LAYOUT : layout;
|
|
71
|
+
if (WRITERS[use]) return { layout: use, W: WRITERS[use], N: WRITERS[use].names(names.db, names.member) };
|
|
72
|
+
if (layout === '0.18') throw new ImportRefusal(`'${names.member}' is on the 0.18 rooms; import needs schema 3 (docs/guides/upgrade-to-0.20.md)`);
|
|
73
|
+
throw new ImportRefusal(`'${names.member}' has no schema-3 tables in ${names.db}`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Insertable columns of a table, as the server has them (no MATERIALIZED / ALIAS / EPHEMERAL). */
|
|
77
|
+
async function columnsOf(house, fq) {
|
|
78
|
+
const [db, table] = fq.split('.');
|
|
79
|
+
const rows = await house.rows(`SELECT name, default_kind FROM system.columns
|
|
80
|
+
WHERE database = ${V.sqlStr(db)} AND table = ${V.sqlStr(table)} ORDER BY position`);
|
|
81
|
+
return rows.filter((r) => !['MATERIALIZED', 'ALIAS', 'EPHEMERAL'].includes(r.default_kind)).map((r) => r.name);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function chunks(list, n) {
|
|
85
|
+
const out = [];
|
|
86
|
+
for (let k = 0; k < list.length; k += n) out.push(list.slice(k, k + n));
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** The content digest of each of these stored sessions (each writer says what it covers). */
|
|
91
|
+
async function storedDigests(house, L, present, ids) {
|
|
92
|
+
const by = new Map(ids.map((id) => [id, {}]));
|
|
93
|
+
for (const part of chunks(ids, ID_CHUNK)) {
|
|
94
|
+
for (const [name, sql] of Object.entries(L.W.contentSQL(L.N, L.W.SRC, part))) {
|
|
95
|
+
for (const r of await house.rows(sql)) { const o = by.get(r.session_id); if (o) (o[name] || (o[name] = [])).push(r); }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const digestOf = L.W.contentDigestOf || L.W.contentDigest;
|
|
99
|
+
return new Map(ids.map((id) => [id, digestOf(present.get(id), by.get(id))]));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function maxRevs(house, L, ids) {
|
|
103
|
+
const out = new Map();
|
|
104
|
+
for (const part of chunks(ids, ID_CHUNK)) {
|
|
105
|
+
for (const sql of L.W.maxRevSQL(L.N, L.W.SRC, part)) {
|
|
106
|
+
for (const r of await house.rows(sql)) out.set(r.session_id, Math.max(out.get(r.session_id) || 0, Number(r.rev)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* What an import would do. `input` is readInput()'s result. Returns counts (never content) and,
|
|
114
|
+
* under `work`, the sessions to write with their revs.
|
|
115
|
+
*/
|
|
116
|
+
async function plan(house, names, input, { update = false } = {}) {
|
|
117
|
+
const L = await requireLayout(house, names);
|
|
118
|
+
const { W } = L;
|
|
119
|
+
const present = new Map((await house.rows(W.presentSQL(L.N, W.SRC))).map((r) => [r.session_id, r]));
|
|
120
|
+
const counts = { new: 0, same: 0, changed: 0, changed_newer: 0, changed_content: 0, older: 0 };
|
|
121
|
+
const chosen = [];
|
|
122
|
+
const sameTime = [];
|
|
123
|
+
const changed = (s, h, how) => {
|
|
124
|
+
counts.changed++;
|
|
125
|
+
counts[how]++;
|
|
126
|
+
if (!update) return;
|
|
127
|
+
// An export says nothing of projects: a changed session keeps the project the house gave it.
|
|
128
|
+
const keep = input.kind === 'archive' ? {} : { project: h.project, folder: h.folder };
|
|
129
|
+
chosen.push({ s: { ...s, ...keep }, why: 'changed' });
|
|
130
|
+
};
|
|
131
|
+
for (const s of input.sessions) {
|
|
132
|
+
const h = present.get(s.sessionId);
|
|
133
|
+
if (!h) { counts.new++; chosen.push({ s, why: 'new' }); continue; }
|
|
134
|
+
const known = h.updated_at !== null && h.updated_at !== undefined;
|
|
135
|
+
// The export's updated_at moves when a conversation gets a message or a new name; an export
|
|
136
|
+
// older than what the house holds never replaces it.
|
|
137
|
+
if (!known || s.updated > h.updated_at) changed(s, h, 'changed_newer');
|
|
138
|
+
else if (s.updated < h.updated_at) { counts.older++; counts.same++; }
|
|
139
|
+
else sameTime.push([s, h]);
|
|
140
|
+
}
|
|
141
|
+
// At the same updated_at the content can still differ: claude.ai adds a summary, or a newer
|
|
142
|
+
// export carries what an older copy lacked. The stored rows are compared with the parse.
|
|
143
|
+
if (sameTime.length) {
|
|
144
|
+
const stored = await storedDigests(house, L, present, sameTime.map(([s]) => s.sessionId));
|
|
145
|
+
for (const [s, h] of sameTime) {
|
|
146
|
+
if (stored.get(s.sessionId) === W.parsedDigest(s, W.SRC)) counts.same++;
|
|
147
|
+
else changed(s, h, 'changed_content');
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const revs = await maxRevs(house, L, chosen.map((c) => c.s.sessionId));
|
|
151
|
+
const work = chosen.map((c) => {
|
|
152
|
+
const had = revs.get(c.s.sessionId);
|
|
153
|
+
return had === undefined ? { ...c, rev: 1, tombstoneRev: null } : { ...c, rev: had + 2, tombstoneRev: had + 1 };
|
|
154
|
+
});
|
|
155
|
+
const write = { sessions: work.length, turns: 0, calls: 0, rewritten: work.filter((w) => w.tombstoneRev !== null).length };
|
|
156
|
+
for (const w of work) { write.turns += w.s.parts.length; write.calls += w.s.calls.length; }
|
|
157
|
+
// Sessions a stopped run left with an unfilled row (profile only), apart from the ones this run
|
|
158
|
+
// writes again: apply refreshes them.
|
|
159
|
+
const writing = new Set(work.map((w) => w.s.sessionId));
|
|
160
|
+
const unfilled = W.unrefreshedSQL ? await house.rows(W.unrefreshedSQL(L.N, W.SRC)) : [];
|
|
161
|
+
const repair = unfilled.map((r) => r.session_id).filter((id) => !writing.has(id));
|
|
162
|
+
// Batches whose run stopped after all their rows went in: applied once this run has filled every
|
|
163
|
+
// row a stopped run left.
|
|
164
|
+
const stopped = [...(await readMeta(house, names))].filter(([, m]) => m.status === 'applying' && m.inserted).map(([b]) => b).sort();
|
|
165
|
+
const span = work.length
|
|
166
|
+
? [work.reduce((a, w) => (w.s.created < a ? w.s.created : a), work[0].s.created).slice(0, 10),
|
|
167
|
+
work.reduce((a, w) => (w.s.updated > a ? w.s.updated : a), work[0].s.updated).slice(0, 10)]
|
|
168
|
+
: null;
|
|
169
|
+
return {
|
|
170
|
+
layout: L.layout,
|
|
171
|
+
input: { kind: input.kind, conversations: input.sessions.length + input.empty + input.duplicates, categories: input.categories,
|
|
172
|
+
read: input.read || [], ignored: input.ignored || [] },
|
|
173
|
+
house: { claude_ai_sessions: present.size },
|
|
174
|
+
sessions: { ...counts, empty: input.empty, duplicates: input.duplicates, update },
|
|
175
|
+
write,
|
|
176
|
+
repair,
|
|
177
|
+
stopped_batches: stopped,
|
|
178
|
+
skipped_blocks: input.skipped,
|
|
179
|
+
span,
|
|
180
|
+
work,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
async function readMeta(house, names) {
|
|
185
|
+
const meta = names.meta || `${names.db}.${names.member}_meta`;
|
|
186
|
+
const rows = await house.rows(`SELECT key, value, toString(updated_at) AS at FROM ${meta} FINAL
|
|
187
|
+
WHERE startsWith(key, ${V.sqlStr(META_PREFIX)})`);
|
|
188
|
+
const out = new Map();
|
|
189
|
+
for (const r of rows) {
|
|
190
|
+
let v = {};
|
|
191
|
+
try { v = JSON.parse(r.value); } catch { v = { status: 'unreadable' }; }
|
|
192
|
+
out.set(r.key.slice(META_PREFIX.length), { ...v, at: r.at });
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function writeMeta(house, names, batch, value) {
|
|
198
|
+
const meta = names.meta || `${names.db}.${names.member}_meta`;
|
|
199
|
+
await house.exec(`INSERT INTO ${meta} (key, value) VALUES (${V.sqlStr(META_PREFIX + batch)}, ${V.sqlStr(JSON.stringify(value))})`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function batchExists(house, L, names, batch) {
|
|
203
|
+
if ((await readMeta(house, names)).has(batch)) return true;
|
|
204
|
+
const r = await house.rows(`SELECT count() AS n FROM ${L.N.sessions} WHERE ${L.W.storeWhere(L.W.SRC)}
|
|
205
|
+
AND harness_version = ${V.sqlStr(V.batchTag(batch))}`);
|
|
206
|
+
return Number(r[0] && r[0].n) > 0;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function checkColumns(house, L, sample) {
|
|
210
|
+
const cols = {};
|
|
211
|
+
for (const t of L.W.TABLES) {
|
|
212
|
+
cols[t] = await columnsOf(house, L.N[t]);
|
|
213
|
+
const have = new Set(cols[t]);
|
|
214
|
+
const extra = sample[t] ? Object.keys(sample[t]).filter((k) => !have.has(k)) : [];
|
|
215
|
+
if (extra.length) throw new ImportRefusal(`${L.N[t]} does not take: ${extra.join(', ')}`);
|
|
216
|
+
}
|
|
217
|
+
return cols;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Counts of the live rows a batch holds. `turns` counts the message parts, whatever the layout calls them. */
|
|
221
|
+
async function batchCounts(house, names, batch, L = null) {
|
|
222
|
+
const X = L || await requireLayout(house, names);
|
|
223
|
+
const q = X.W.countsSQL(X.N, X.W.SRC, batch);
|
|
224
|
+
const s = (await house.rows(q.sessions))[0] || {};
|
|
225
|
+
const t = await house.rows(q.parts);
|
|
226
|
+
const c = (await house.rows(q.calls))[0] || {};
|
|
227
|
+
const turnsByKind = Object.fromEntries(t.map((r) => [r.kind, Number(r.n)]));
|
|
228
|
+
return {
|
|
229
|
+
sessions: Number(s.sessions || 0), messages: Number(s.messages || 0), tool_calls: Number(s.tool_calls || 0),
|
|
230
|
+
turns: Object.values(turnsByKind).reduce((a, n) => a + n, 0), turns_by_kind: turnsByKind, calls: Number(c.n || 0),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Write what plan() chose, as one batch. Returns the batch's read-back counts. */
|
|
235
|
+
async function apply(house, names, p, { batch, version = '', onProgress = () => {} } = {}) {
|
|
236
|
+
checkBatchId(batch);
|
|
237
|
+
const L = await requireLayout(house, names);
|
|
238
|
+
const { W } = L;
|
|
239
|
+
if (L.layout !== p.layout) throw new ImportRefusal(`the plan was made for layout ${p.layout}; the house is now on ${L.layout} — plan again`);
|
|
240
|
+
const repair = p.repair || [];
|
|
241
|
+
if (!p.work.length) {
|
|
242
|
+
// Nothing to write: still fill the rows a stopped run left and finish its batch. No batch is used
|
|
243
|
+
// or recorded, so a retry under the stopped run's own --batch is no refusal.
|
|
244
|
+
if (repair.length) { await W.refresh(house, L.N, repair, W.SRC); onProgress(`refreshed ${repair.length} session row(s) a stopped run left`); }
|
|
245
|
+
const completed = await completeStopped(house, names, p.stopped_batches);
|
|
246
|
+
return { batch: null, written: { sessions: 0, turns: 0, calls: 0 }, counts: null, repaired: repair.length, completed };
|
|
247
|
+
}
|
|
248
|
+
if (await batchExists(house, L, names, batch)) throw new ImportRefusal(`batch '${batch}' already exists in this house; name another with --batch`);
|
|
249
|
+
const built = p.work.map((w) => W.rows(w.s, { src: W.SRC, batch, rev: w.rev }));
|
|
250
|
+
const sample = {};
|
|
251
|
+
for (const t of W.TABLES) sample[t] = (built.find((b) => b[t].length) || { [t]: [] })[t][0];
|
|
252
|
+
const cols = await checkColumns(house, L, sample);
|
|
253
|
+
const record = { kind: C.HARNESS, layout: L.layout, input: p.input.kind, sessions: p.write.sessions, turns: p.write.turns,
|
|
254
|
+
calls: p.write.calls, rewritten: p.write.rewritten, update: p.sessions.update, version };
|
|
255
|
+
await writeMeta(house, names, batch, { ...record, status: 'applying' });
|
|
256
|
+
const again = new Map(p.work.filter((w) => w.tombstoneRev !== null).map((w) => [w.s.sessionId, w.tombstoneRev]));
|
|
257
|
+
if (again.size) {
|
|
258
|
+
// Calls first, as in an undo; then the parts; the session row last.
|
|
259
|
+
for (const ids of chunks([...again.keys()], ID_CHUNK)) {
|
|
260
|
+
const revs = new Map(ids.map((i) => [i, again.get(i)]));
|
|
261
|
+
const where = W.sessionsWhere(W.SRC, ids);
|
|
262
|
+
for (const t of W.TOMBSTONE_ORDER) await house.exec(W.tombstoneSQL(L.N[t], cols[t], where[t], W.revBySession(revs)));
|
|
263
|
+
}
|
|
264
|
+
onProgress(`tombstoned the earlier rows of ${again.size} session(s)`);
|
|
265
|
+
}
|
|
266
|
+
// Every row in: a run that stops from here on is finished by the next one (completeStopped).
|
|
267
|
+
const w = await W.writeRows(house, L.N, built, W.SRC, onProgress, () => writeMeta(house, names, batch, { ...record, status: 'applying', inserted: true }));
|
|
268
|
+
if (repair.length) { await W.refresh(house, L.N, repair, W.SRC); onProgress(`refreshed ${repair.length} session row(s) a stopped run left`); }
|
|
269
|
+
const completed = await completeStopped(house, names, p.stopped_batches);
|
|
270
|
+
const written = { sessions: w.sessions || 0, turns: w[W.PARTS] || 0, calls: w.calls || 0 };
|
|
271
|
+
const counts = await batchCounts(house, names, batch, L);
|
|
272
|
+
await writeMeta(house, names, batch, { ...record, status: 'applied' });
|
|
273
|
+
return { batch, written, counts, repaired: repair.length, completed };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The batches a run stopped in after all their rows went in ('applying', inserted): the caller has
|
|
278
|
+
* filled every row a stopped run left, so each is applied, whatever --batch this run has and whatever
|
|
279
|
+
* it wrote again. A batch whose insert stopped part way was never marked inserted and stays applying;
|
|
280
|
+
* an undo in progress is 'undoing', never applying. Returns the batches it applied.
|
|
281
|
+
*/
|
|
282
|
+
async function completeStopped(house, names, batches = []) {
|
|
283
|
+
if (!batches.length) return [];
|
|
284
|
+
const metas = await readMeta(house, names);
|
|
285
|
+
const done = [];
|
|
286
|
+
for (const b of batches) {
|
|
287
|
+
const meta = metas.get(b);
|
|
288
|
+
if (!meta || meta.status !== 'applying' || !meta.inserted) continue;
|
|
289
|
+
await writeMeta(house, names, b, { ...meta, at: undefined, status: 'applied' });
|
|
290
|
+
done.push(b);
|
|
291
|
+
}
|
|
292
|
+
return done;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Tombstone every live row of a batch. Dry unless `write`. Returns counts before and after. */
|
|
296
|
+
async function undo(house, names, batch, { write = false, version = '' } = {}) {
|
|
297
|
+
checkBatchId(batch);
|
|
298
|
+
const L = await requireLayout(house, names);
|
|
299
|
+
const { W } = L;
|
|
300
|
+
const before = await batchCounts(house, names, batch, L);
|
|
301
|
+
const meta = (await readMeta(house, names)).get(batch) || {};
|
|
302
|
+
// An undo that stopped part way left 'undoing' and the counts it found. A retry keeps those counts;
|
|
303
|
+
// one that finds every row already tombstoned only records the undo as done.
|
|
304
|
+
const resuming = meta.status === 'undoing';
|
|
305
|
+
const live = Boolean(before.sessions || before.turns || before.calls);
|
|
306
|
+
const finishing = resuming && !live;
|
|
307
|
+
if (!write || (!live && !finishing)) return { batch, before, after: null, written: false, finishing };
|
|
308
|
+
const rec = { ...meta, at: undefined, kind: C.HARNESS, undone: resuming && meta.undone ? meta.undone : before, version };
|
|
309
|
+
if (live) {
|
|
310
|
+
// Recorded first: an undo that stops part way leaves 'undoing', which no import completes.
|
|
311
|
+
await writeMeta(house, names, batch, { ...rec, status: 'undoing' });
|
|
312
|
+
const where = W.batchWhere(L.N, W.SRC, batch);
|
|
313
|
+
// Calls first: they are found through the batch's live parts, so the parts must still be there.
|
|
314
|
+
for (const t of W.TOMBSTONE_ORDER) {
|
|
315
|
+
await house.exec(W.tombstoneSQL(L.N[t], await columnsOf(house, L.N[t]), where[t], 'rev + 1'));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
const after = await batchCounts(house, names, batch, L);
|
|
319
|
+
await writeMeta(house, names, batch, { ...rec, status: 'undone' });
|
|
320
|
+
return { batch, before, after, written: true, finishing };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const INSERT_SETTINGS = { async_insert: 0, date_time_input_format: 'best_effort', input_format_skip_unknown_fields: 0 };
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A `house` over two calls: rows(sql) -> object[] for reads, write(sql, settings) for writes.
|
|
327
|
+
* Inserts go as JSONEachRow in chunks of at most 2,000 rows or 8 MB, async_insert off.
|
|
328
|
+
*/
|
|
329
|
+
function houseOver({ rows, write }) {
|
|
330
|
+
return {
|
|
331
|
+
rows,
|
|
332
|
+
exec: (sql) => write(sql, { async_insert: 0 }),
|
|
333
|
+
async insert(fq, list) {
|
|
334
|
+
let buf = []; let size = 0;
|
|
335
|
+
const flush = async () => {
|
|
336
|
+
if (!buf.length) return;
|
|
337
|
+
await write(`INSERT INTO ${fq} FORMAT JSONEachRow\n${buf.join('\n')}`, INSERT_SETTINGS);
|
|
338
|
+
buf = []; size = 0;
|
|
339
|
+
};
|
|
340
|
+
for (const r of list) {
|
|
341
|
+
const line = JSON.stringify(r);
|
|
342
|
+
if (buf.length && (buf.length >= 2000 || size + line.length > 8 * 1024 * 1024)) await flush();
|
|
343
|
+
buf.push(line); size += line.length;
|
|
344
|
+
}
|
|
345
|
+
await flush();
|
|
346
|
+
},
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Every import batch the house holds live rows of, with what meta recorded about it. */
|
|
351
|
+
async function list(house, names) {
|
|
352
|
+
const L = await requireLayout(house, names);
|
|
353
|
+
const rows = await house.rows(`SELECT replaceOne(harness_version, ${V.sqlStr(META_PREFIX)}, '') AS batch, count() AS sessions,
|
|
354
|
+
sum(messages) AS messages, sum(tool_calls) AS tool_calls, toString(min(started)) AS first, toString(max(ended)) AS last
|
|
355
|
+
FROM ${L.N.sessions} FINAL WHERE is_deleted = 0 AND ${L.W.storeWhere(L.W.SRC)} AND origin = 'import'
|
|
356
|
+
GROUP BY batch ORDER BY batch`);
|
|
357
|
+
const meta = await readMeta(house, names);
|
|
358
|
+
const out = new Map();
|
|
359
|
+
for (const r of rows) {
|
|
360
|
+
out.set(r.batch, { batch: r.batch, sessions: Number(r.sessions), messages: Number(r.messages), tool_calls: Number(r.tool_calls),
|
|
361
|
+
first: r.first ? r.first.slice(0, 10) : null, last: r.last ? r.last.slice(0, 10) : null, status: null, recorded_at: null });
|
|
362
|
+
}
|
|
363
|
+
for (const [b, m] of meta) {
|
|
364
|
+
const e = out.get(b) || { batch: b, sessions: 0, messages: 0, tool_calls: 0, first: null, last: null };
|
|
365
|
+
out.set(b, { ...e, status: m.status || null, recorded_at: m.at || null });
|
|
366
|
+
}
|
|
367
|
+
return [...out.values()].sort((a, b) => (a.batch < b.batch ? -1 : 1));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
module.exports = {
|
|
371
|
+
SRC, WRITERS, WRITES_LAYOUTS, ImportRefusal, houseOver, newBatchId, checkBatchId, layoutOf, requireLayout, plan, apply, undo, list, batchCounts, readMeta,
|
|
372
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// JSON read and written the way Python's json module does it, for the claude.ai import.
|
|
3
|
+
//
|
|
4
|
+
// The import's row texts (a tool call's `Name(args)`, its args, a turn's `extra`) were first
|
|
5
|
+
// produced by Python's json.dumps(…, ensure_ascii=False): ', ' and ': ' separators, a float
|
|
6
|
+
// that was written 1.0 stays 1.0, integers of any size, keys in source order. JSON.parse and
|
|
7
|
+
// JSON.stringify differ on all four, so a re-import would write different text for the same
|
|
8
|
+
// message. parse() keeps what dumps() needs to reproduce Python exactly: the source order of
|
|
9
|
+
// an object whose keys JavaScript would reorder (integer-like keys), and the source text of a
|
|
10
|
+
// number JavaScript cannot round-trip (a float literal, an integer past 2^53). Everything else
|
|
11
|
+
// is a plain value, so callers read the result like JSON.parse's.
|
|
12
|
+
|
|
13
|
+
const ORDER = Symbol('pyjson.order'); // object -> its keys in source order (only when JS would reorder them)
|
|
14
|
+
const NUMSRC = Symbol('pyjson.numsrc'); // container -> { key or index: number source text }
|
|
15
|
+
|
|
16
|
+
const INDEX_KEY = /^(?:0|[1-9]\d*)$/;
|
|
17
|
+
const NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
18
|
+
|
|
19
|
+
class JsonError extends Error {}
|
|
20
|
+
|
|
21
|
+
function parse(text) {
|
|
22
|
+
let i = 0;
|
|
23
|
+
const n = text.length;
|
|
24
|
+
let numSrc = null; // set by value() when the number it returned needs its source text
|
|
25
|
+
const fail = (what) => { throw new JsonError(`invalid JSON at offset ${i}: ${what}`); };
|
|
26
|
+
const ws = () => {
|
|
27
|
+
while (i < n) {
|
|
28
|
+
const c = text.charCodeAt(i);
|
|
29
|
+
if (c === 32 || c === 10 || c === 13 || c === 9) i++; else break;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function str() {
|
|
33
|
+
const start = i++;
|
|
34
|
+
for (;;) {
|
|
35
|
+
const j = text.indexOf('"', i);
|
|
36
|
+
if (j < 0) { i = start; fail('unterminated string'); }
|
|
37
|
+
let k = j - 1; let bs = 0;
|
|
38
|
+
while (k > start && text.charCodeAt(k) === 92) { bs++; k--; }
|
|
39
|
+
i = j + 1;
|
|
40
|
+
if (bs % 2 === 0) {
|
|
41
|
+
const raw = text.slice(start + 1, j);
|
|
42
|
+
return raw.indexOf('\\') < 0 ? raw : JSON.parse(text.slice(start, j + 1));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function keep(container, key) {
|
|
47
|
+
if (numSrc === null) return;
|
|
48
|
+
if (!container[NUMSRC]) Object.defineProperty(container, NUMSRC, { value: {}, enumerable: false });
|
|
49
|
+
container[NUMSRC][key] = numSrc;
|
|
50
|
+
numSrc = null;
|
|
51
|
+
}
|
|
52
|
+
function value() {
|
|
53
|
+
numSrc = null;
|
|
54
|
+
ws();
|
|
55
|
+
const c = text.charCodeAt(i);
|
|
56
|
+
if (c === 123) { // {
|
|
57
|
+
i++;
|
|
58
|
+
const o = {};
|
|
59
|
+
const keys = [];
|
|
60
|
+
let reordered = false;
|
|
61
|
+
ws();
|
|
62
|
+
if (text.charCodeAt(i) === 125) { i++; return o; }
|
|
63
|
+
for (;;) {
|
|
64
|
+
ws();
|
|
65
|
+
if (text.charCodeAt(i) !== 34) fail('expected a key');
|
|
66
|
+
const k = str();
|
|
67
|
+
ws();
|
|
68
|
+
if (text.charCodeAt(i) !== 58) fail("expected ':'");
|
|
69
|
+
i++;
|
|
70
|
+
const v = value();
|
|
71
|
+
if (!Object.prototype.hasOwnProperty.call(o, k)) {
|
|
72
|
+
keys.push(k);
|
|
73
|
+
if (INDEX_KEY.test(k) && Number(k) < 4294967295) reordered = true;
|
|
74
|
+
}
|
|
75
|
+
if (k === '__proto__') Object.defineProperty(o, k, { value: v, enumerable: true, writable: true, configurable: true });
|
|
76
|
+
else o[k] = v;
|
|
77
|
+
keep(o, k);
|
|
78
|
+
ws();
|
|
79
|
+
const d = text.charCodeAt(i++);
|
|
80
|
+
if (d === 44) continue;
|
|
81
|
+
if (d === 125) break;
|
|
82
|
+
i--; fail("expected ',' or '}'");
|
|
83
|
+
}
|
|
84
|
+
if (reordered) Object.defineProperty(o, ORDER, { value: keys, enumerable: false });
|
|
85
|
+
return o;
|
|
86
|
+
}
|
|
87
|
+
if (c === 91) { // [
|
|
88
|
+
i++;
|
|
89
|
+
const a = [];
|
|
90
|
+
ws();
|
|
91
|
+
if (text.charCodeAt(i) === 93) { i++; return a; }
|
|
92
|
+
for (;;) {
|
|
93
|
+
a.push(value());
|
|
94
|
+
keep(a, a.length - 1);
|
|
95
|
+
ws();
|
|
96
|
+
const d = text.charCodeAt(i++);
|
|
97
|
+
if (d === 44) continue;
|
|
98
|
+
if (d === 93) break;
|
|
99
|
+
i--; fail("expected ',' or ']'");
|
|
100
|
+
}
|
|
101
|
+
return a;
|
|
102
|
+
}
|
|
103
|
+
if (c === 34) return str();
|
|
104
|
+
if (text.startsWith('true', i)) { i += 4; return true; }
|
|
105
|
+
if (text.startsWith('false', i)) { i += 5; return false; }
|
|
106
|
+
if (text.startsWith('null', i)) { i += 4; return null; }
|
|
107
|
+
NUMBER.lastIndex = i;
|
|
108
|
+
const m = NUMBER.exec(text);
|
|
109
|
+
if (!m) fail('unexpected character');
|
|
110
|
+
i += m[0].length;
|
|
111
|
+
const src = m[0];
|
|
112
|
+
const v = Number(src);
|
|
113
|
+
if (/[.eE]/.test(src) || !Number.isSafeInteger(v)) numSrc = src;
|
|
114
|
+
return v;
|
|
115
|
+
}
|
|
116
|
+
const out = value();
|
|
117
|
+
numSrc = null;
|
|
118
|
+
ws();
|
|
119
|
+
if (i < n) fail('trailing data');
|
|
120
|
+
return out;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Python's repr() of a float: the shortest round-trip digits, exponent form outside 1e-4 ≤ |x| < 1e16. */
|
|
124
|
+
function pyFloat(x) {
|
|
125
|
+
if (Number.isNaN(x)) return 'NaN';
|
|
126
|
+
if (x === Infinity) return 'Infinity';
|
|
127
|
+
if (x === -Infinity) return '-Infinity';
|
|
128
|
+
if (x === 0) return Object.is(x, -0) ? '-0.0' : '0.0';
|
|
129
|
+
const [m, e] = x.toExponential().split('e');
|
|
130
|
+
const exp = Number(e);
|
|
131
|
+
const neg = m[0] === '-';
|
|
132
|
+
const digits = m.replace('-', '').replace('.', '');
|
|
133
|
+
let s;
|
|
134
|
+
if (exp >= -4 && exp < 16) {
|
|
135
|
+
if (exp >= 0) {
|
|
136
|
+
const whole = digits.slice(0, exp + 1).padEnd(exp + 1, '0');
|
|
137
|
+
s = `${whole}.${digits.slice(exp + 1) || '0'}`;
|
|
138
|
+
} else {
|
|
139
|
+
s = `0.${'0'.repeat(-exp - 1)}${digits}`;
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
const mant = digits.length > 1 ? `${digits[0]}.${digits.slice(1)}` : digits;
|
|
143
|
+
s = `${mant}e${exp < 0 ? '-' : '+'}${String(Math.abs(exp)).padStart(2, '0')}`;
|
|
144
|
+
}
|
|
145
|
+
return neg ? `-${s}` : s;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function numberText(v, src) {
|
|
149
|
+
if (src !== undefined) return /[.eE]/.test(src) ? pyFloat(Number(src)) : BigInt(src).toString();
|
|
150
|
+
return Number.isInteger(v) ? String(v === 0 ? 0 : v) : pyFloat(v);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** json.dumps(v, ensure_ascii=False) — Python's default separators, source key order and number forms. */
|
|
154
|
+
function dumps(v, src, sep = PY_SEP) {
|
|
155
|
+
if (v === null || v === undefined) return 'null';
|
|
156
|
+
if (v === true) return 'true';
|
|
157
|
+
if (v === false) return 'false';
|
|
158
|
+
if (typeof v === 'number') return numberText(v, src);
|
|
159
|
+
if (typeof v === 'bigint') return v.toString();
|
|
160
|
+
if (typeof v === 'string') return JSON.stringify(v);
|
|
161
|
+
const srcs = v[NUMSRC] || {};
|
|
162
|
+
if (Array.isArray(v)) return `[${v.map((x, k) => dumps(x, srcs[k], sep)).join(sep.item)}]`;
|
|
163
|
+
const keys = v[ORDER] || Object.keys(v);
|
|
164
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}${sep.key}${dumps(v[k], srcs[k], sep)}`).join(sep.item)}}`;
|
|
165
|
+
}
|
|
166
|
+
const PY_SEP = { item: ', ', key: ': ' };
|
|
167
|
+
|
|
168
|
+
/** The same value as compact JSON (no spaces), numbers and key order still as the source wrote them. */
|
|
169
|
+
function compact(v, src) { return dumps(v, src, { item: ',', key: ':' }); }
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* str(v) for a value Python read from JSON. Strings, numbers, booleans and null are exact;
|
|
173
|
+
* a list or dict falls back to its JSON text (Python would print its repr, with single quotes —
|
|
174
|
+
* the export never puts one where this is used).
|
|
175
|
+
*/
|
|
176
|
+
function pyStr(v, src) {
|
|
177
|
+
if (typeof v === 'string') return v;
|
|
178
|
+
if (v === true) return 'True';
|
|
179
|
+
if (v === false) return 'False';
|
|
180
|
+
if (v === null || v === undefined) return 'None';
|
|
181
|
+
if (typeof v === 'number') return numberText(v, src);
|
|
182
|
+
return dumps(v, src);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** len() of a Python str: code points, not UTF-16 units. */
|
|
186
|
+
function pyLen(s) {
|
|
187
|
+
let pairs = 0;
|
|
188
|
+
for (let k = 0; k < s.length - 1; k++) {
|
|
189
|
+
const c = s.charCodeAt(k);
|
|
190
|
+
if (c >= 0xd800 && c <= 0xdbff) {
|
|
191
|
+
const d = s.charCodeAt(k + 1);
|
|
192
|
+
if (d >= 0xdc00 && d <= 0xdfff) { pairs++; k++; }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return s.length - pairs;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** str.strip() with no argument: whitespace as Python's str.isspace() counts it. */
|
|
199
|
+
const PY_SPACE = '\\t\\n\\v\\f\\r\\x1c-\\x20\\x85\\xa0\\u1680\\u2000-\\u200a\\u2028\\u2029\\u202f\\u205f\\u3000';
|
|
200
|
+
const STRIP = new RegExp(`^[${PY_SPACE}]+|[${PY_SPACE}]+$`, 'g');
|
|
201
|
+
function pyStrip(s) { return s.replace(STRIP, ''); }
|
|
202
|
+
|
|
203
|
+
/** The source text of the number at `key` in a parsed container, when parse() kept it. */
|
|
204
|
+
function numSource(container, key) { return container && container[NUMSRC] ? container[NUMSRC][key] : undefined; }
|
|
205
|
+
|
|
206
|
+
module.exports = { parse, dumps, compact, pyStr, pyFloat, pyLen, pyStrip, numSource, JsonError, ORDER, NUMSRC };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// A parsed claude.ai conversation (claude-ai.js) as sessions-model records: one session record and
|
|
3
|
+
// one message record per claude.ai message, its parts in order. Layout-free, like the parser: the
|
|
4
|
+
// profile writer turns these into rows with records-to-profile.js. The keys are the parser's own —
|
|
5
|
+
// session id, message ids, part ids, call ids — copied, never derived again.
|
|
6
|
+
//
|
|
7
|
+
// parser part record part
|
|
8
|
+
// text text
|
|
9
|
+
// thinking reasoning
|
|
10
|
+
// tool_call tool_call (call_id, name, args as the export's input)
|
|
11
|
+
// tool_result tool_result (call_id, is_error, status completed, output, output_len)
|
|
12
|
+
// summary summary (replaces: unknown), in a 'system' message of its own
|
|
13
|
+
// An attachment's extracted text is a text part, as the parser stores it.
|
|
14
|
+
|
|
15
|
+
const py = require('./pyjson');
|
|
16
|
+
|
|
17
|
+
/** The parser's 'YYYY-MM-DD HH:MM:SS.mmm' (UTC) as ISO 8601. */
|
|
18
|
+
const iso = (t) => (t ? `${String(t).replace(' ', 'T')}Z` : null);
|
|
19
|
+
|
|
20
|
+
function partOf(p) {
|
|
21
|
+
switch (p.kind) {
|
|
22
|
+
case 'text': return { part_id: p.partId, kind: 'text', text: p.text };
|
|
23
|
+
case 'thinking': return { part_id: p.partId, kind: 'reasoning', text: p.text };
|
|
24
|
+
case 'tool_call': return { part_id: p.partId, kind: 'tool_call', call_id: p.callId, name: p.toolName, args: p.input === undefined ? {} : p.input };
|
|
25
|
+
case 'tool_result': return {
|
|
26
|
+
part_id: p.partId, kind: 'tool_result', call_id: p.callId, is_error: Boolean(p.isError), status: 'completed',
|
|
27
|
+
output: p.text, output_len: py.pyLen(p.text),
|
|
28
|
+
};
|
|
29
|
+
case 'summary': return { part_id: p.partId, kind: 'summary', text: p.text, replaces: 'unknown' };
|
|
30
|
+
default: throw new Error(`no record part for a '${p.kind}' part`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The records of one parsed session. `harnessVersion` names the import batch (import:<batch>) on
|
|
36
|
+
* the session record; nothing else in the records depends on the batch.
|
|
37
|
+
*/
|
|
38
|
+
function recordsOf(s, { harnessVersion = '' } = {}) {
|
|
39
|
+
const messages = [];
|
|
40
|
+
const byId = new Map();
|
|
41
|
+
for (const p of s.parts) {
|
|
42
|
+
let m = byId.get(p.messageId);
|
|
43
|
+
if (!m) {
|
|
44
|
+
const role = p.messageRole || p.role;
|
|
45
|
+
m = {
|
|
46
|
+
type: 'message', message_id: p.messageId, order: String(messages.length).padStart(6, '0'), parent_id: p.parentId || null,
|
|
47
|
+
agent_id: '', role, source: null, to_user: null, to_model: null, status: 'complete', state: 'current', on_active_branch: null,
|
|
48
|
+
ts: iso(p.ts), requested_model: '', served_model: '', effort: '', response_id: role === 'assistant' ? p.messageId : null,
|
|
49
|
+
aliases: [], parts: [],
|
|
50
|
+
};
|
|
51
|
+
byId.set(p.messageId, m);
|
|
52
|
+
messages.push(m);
|
|
53
|
+
}
|
|
54
|
+
m.parts.push(partOf(p));
|
|
55
|
+
}
|
|
56
|
+
const session = {
|
|
57
|
+
type: 'session', harness: 'claude-ai', engine: '', client: 'claude.ai', harness_version: harnessVersion,
|
|
58
|
+
title: s.name, title_set_by: 'unknown', cwd: '', last_cwd: '', created_at: iso(s.created), updated_at: iso(s.updated),
|
|
59
|
+
state: 'active', pinned: null, mode: s.mode, kind: '', reuse: '', ts_source: 'event', aliases: [], fingerprint: null, setup: null,
|
|
60
|
+
};
|
|
61
|
+
return [session, ...messages];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { recordsOf, partOf, iso };
|