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,633 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// v4 wave 2: OpenCode → normalised session log records (docs/design/sessions-log.md).
|
|
3
|
+
//
|
|
4
|
+
// Store: <data>/opencode.db (SQLite, WAL), opened through sqlite.js openReadOnly only (a fresh DB keeps
|
|
5
|
+
// its data in the -wal file, so the main file alone is never copied or parsed). <data> is
|
|
6
|
+
// harness-paths' dataFolder('opencode'): $XDG_DATA_HOME/opencode, default ~/.local/share/opencode. The DB is
|
|
7
|
+
// opencode.db for the channels latest/beta/prod, opencode-<channel>.db otherwise; OPENCODE_DB names
|
|
8
|
+
// another file (absolute, or relative to <data>). Spec: docs/design/adapter-specs/opencode.md
|
|
9
|
+
// (repository; OpenCode 1.19.3), sessions-model v0 and its annex.
|
|
10
|
+
//
|
|
11
|
+
// Rules from the spec:
|
|
12
|
+
// - two engines in one DB. v1: `message` rows ordered by (time_created, id), `part` rows by id. v2
|
|
13
|
+
// (reached through /api/session): `session_message` rows ordered by seq, each assistant content
|
|
14
|
+
// element a part keyed (message id, content id); `session_input` adds delivery steer|queue;
|
|
15
|
+
// - one tool part holds both the call and its result (v1 `tool` part, v2 `tool` content element);
|
|
16
|
+
// - usage: v1 step-finish parts (else the message's own tokens), v2 the assistant's tokens; basis
|
|
17
|
+
// net (input = prompt − cache.read, output = completion − reasoning). An all-zero record (aborted,
|
|
18
|
+
// overflowed, errored step) is unknown, so null; cost 0 (no price table) is null;
|
|
19
|
+
// - held back: an assistant still streaming (no time.completed, no error) and everything after it,
|
|
20
|
+
// so the log only grows while the session only grows. One with a later assistant after it is a
|
|
21
|
+
// dead run (killed, abandoned): kept, status interrupted;
|
|
22
|
+
// - `session.revert` hides messages the user can still bring back: state 'reverted' (v1 from the
|
|
23
|
+
// revert message on, v2 after it; a v1 revert at a part hides that message's later parts only);
|
|
24
|
+
// - prompt-while-reverted hard-deletes rows and writes `message.removed` events: the deleted ids
|
|
25
|
+
// vanish from the log, and an `edit` lifecycle record names them, placed at the event's time (the
|
|
26
|
+
// id-embedded time of its evt_ id). A committed v2 revert gives a `rewind` record at its seq;
|
|
27
|
+
// - compaction: the user message's `compaction` part {auto, overflow, tail_start_id} gives a
|
|
28
|
+
// lifecycle record after it, and the summary assistant's (`summary: true`) text becomes summary
|
|
29
|
+
// parts replacing {first_kept_id: tail_start_id}. The overflow path re-inserts the original prompt
|
|
30
|
+
// as a new, non-synthetic user message: kept as it is;
|
|
31
|
+
// - synthetic text and file parts are `source: 'injected'`; a user message made only of them is a
|
|
32
|
+
// harness-made turn, `source: 'automated'` (compaction continue, subtask summarise), and so is an
|
|
33
|
+
// automatic compaction's trigger. Shell mode `!cmd` is a synthetic user message plus an assistant
|
|
34
|
+
// with a bash part and no step parts: the assistant is `source: 'user'` (the user ran it);
|
|
35
|
+
// - subtask parts are the prompt the user's command sends to the sub-agent: a text part;
|
|
36
|
+
// - a tool output cut at the limit keeps the cut text as output, and `output_ref` names the
|
|
37
|
+
// spilled `tool-output/tool_<id>` file by sha256 and length (hashed, never logged);
|
|
38
|
+
// - links: explicit only. A child names its parent (`session.parent_id`, subagent, at ''); the
|
|
39
|
+
// parent names its children from the `task` tool part's metadata.sessionId (at = the call id).
|
|
40
|
+
// Forks store no pointer (new ids, `(fork #N)` title): no link here, inference is the engine's;
|
|
41
|
+
// - `time_archived` → archived;
|
|
42
|
+
// - a `patch` part (the files a step changed) is a notice part, its text the files, one per line;
|
|
43
|
+
// - not conversation, left out: step-start, step-finish (folded into usage), snapshot, retry,
|
|
44
|
+
// agent mentions (their text is in the user's text part), v2 agent-switched / model-switched;
|
|
45
|
+
// - no MCP `server`: OpenCode names an MCP tool `<server>_<tool>`, which does not split reliably;
|
|
46
|
+
// - volatile fields stay out: time_updated, the session's running token sums, summary_*, model,
|
|
47
|
+
// agent, metadata, and the tool state's `time.compacted` (prune). `updated` is null;
|
|
48
|
+
// - redaction: no OpenCode field path is declared secret (sessions-model §4). Provider error
|
|
49
|
+
// payloads (responseHeaders, responseBody, metadata.url) are never copied: an error part keeps the
|
|
50
|
+
// error name and message only. The credential tables are never queried; only session, message,
|
|
51
|
+
// part, session_message, session_input and event are read.
|
|
52
|
+
|
|
53
|
+
const fs = require('fs');
|
|
54
|
+
const path = require('path');
|
|
55
|
+
const crypto = require('crypto');
|
|
56
|
+
const { fileURLToPath } = require('url');
|
|
57
|
+
const harnessPaths = require('../harness-paths');
|
|
58
|
+
const { openReadOnly, textOf } = require('../sqlite');
|
|
59
|
+
const { placeChildren } = require('../sessions-log');
|
|
60
|
+
|
|
61
|
+
const HARNESS = 'opencode';
|
|
62
|
+
const DEFAULT_DB = 'opencode.db';
|
|
63
|
+
const CHANNEL_DB = /^opencode-[A-Za-z0-9._-]+\.db$/;
|
|
64
|
+
const MS_MOD = 2 ** 36; // ids carry the low 48 bits of ms·4096 + counter: ms modulo 2^36
|
|
65
|
+
const ID_SLACK_MS = 60000;
|
|
66
|
+
const SPILL = /^tool_[0-9A-Za-z]+$/;
|
|
67
|
+
|
|
68
|
+
// ── store discovery ───────────────────────────────────────────────────────────────────────────
|
|
69
|
+
/** The data folder: harness-paths' dataFolder (it honours XDG_DATA_HOME, default ~/.local/share/opencode). */
|
|
70
|
+
function storeRootDefault(env = process.env) {
|
|
71
|
+
return harnessPaths.dataFolder(HARNESS, { env });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function isFile(p) { try { return fs.statSync(p).isFile(); } catch { return false; } }
|
|
75
|
+
function readdir(dir) { try { return fs.readdirSync(dir).sort(); } catch { return []; } }
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The session DBs of one data folder: OPENCODE_DB when set (absolute, or relative to the folder;
|
|
79
|
+
* `:memory:` holds nothing), opencode.db, and every opencode-<channel>.db. Every one is a store of
|
|
80
|
+
* its own, keyed by its file's real path (sessions-model §1), the default DB
|
|
81
|
+
* included: an import into another channel can repeat a session id, and only a per-file
|
|
82
|
+
* harness_path keeps the two apart. One file reached two ways resolves to one real path.
|
|
83
|
+
*/
|
|
84
|
+
function dbFiles(storeRoot, env = process.env) {
|
|
85
|
+
const out = [];
|
|
86
|
+
const seen = new Set();
|
|
87
|
+
const folder = harnessPaths.resolveNative(storeRoot);
|
|
88
|
+
const add = (file) => {
|
|
89
|
+
if (!isFile(file)) return;
|
|
90
|
+
const real = harnessPaths.resolveNative(file);
|
|
91
|
+
if (seen.has(real)) return;
|
|
92
|
+
seen.add(real);
|
|
93
|
+
out.push({ file, harness_path: real });
|
|
94
|
+
};
|
|
95
|
+
add(path.join(storeRoot, DEFAULT_DB));
|
|
96
|
+
for (const f of readdir(storeRoot)) if (CHANNEL_DB.test(f)) add(path.join(storeRoot, f));
|
|
97
|
+
const o = env.OPENCODE_DB;
|
|
98
|
+
if (o && o !== ':memory:') add(path.isAbsolute(o) ? o : path.join(storeRoot, o));
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Every read error throws (busy, locked, malformed, IOERR, not a database, a locking-protocol
|
|
103
|
+
// failure): fewer rows would rewrite the log as if the missing messages had been removed. list()
|
|
104
|
+
// throwing keeps the runner's index; normalise() throwing is retried. Only a table or column an
|
|
105
|
+
// older schema lacks (v1 versus v2) reads as empty.
|
|
106
|
+
const MISSING = /no such (table|column)/i;
|
|
107
|
+
const BUSY_TIMEOUT_MS = 1000; // OpenCode's own transactions take milliseconds
|
|
108
|
+
|
|
109
|
+
function openDb(file) {
|
|
110
|
+
const db = openReadOnly(file); // immutable only for a WAL store with no -wal: nothing is created
|
|
111
|
+
try { db.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`); } catch { /* the pragma is optional */ }
|
|
112
|
+
return db;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// The events that change a session's log without a row of it changing: removals (an edit) and v2
|
|
116
|
+
// reverts, as ranges on `type` ('/' follows '.'). One range per query lets event_aggregate_type_seq_idx
|
|
117
|
+
// find them from the index; OR-ed together, SQLite reads every event row of the session instead.
|
|
118
|
+
const EVENT_RANGES = ['message.removed.', 'message.part.removed.', 'session.next.revert.']
|
|
119
|
+
.map((t) => `(type >= '${t}' AND type < '${t.slice(0, -1)}/')`);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A table's columns; empty when the table is missing (PRAGMA table_info reads no rows for it). Every
|
|
123
|
+
* query is built from these, so a column an older or newer schema lacks reads as null instead of
|
|
124
|
+
* failing the query, and with it every session of the DB.
|
|
125
|
+
*/
|
|
126
|
+
function columnsOf(db, table) {
|
|
127
|
+
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => String(r.name)));
|
|
128
|
+
}
|
|
129
|
+
/** The wanted columns, `NULL AS <col>` for each one the table lacks. */
|
|
130
|
+
const pick = (cols, names, alias = '') => names.map((c) => (cols.has(c) ? `${alias}${c}` : `NULL AS ${c}`)).join(', ');
|
|
131
|
+
const SCHEMA = {
|
|
132
|
+
session: { need: ['id'], opt: ['parent_id', 'directory', 'title', 'version', 'time_created', 'time_archived', 'revert'] },
|
|
133
|
+
message: { need: ['id', 'session_id', 'data'], opt: ['time_created'] },
|
|
134
|
+
part: { need: ['id', 'message_id', 'session_id', 'data'], opt: ['time_created'] },
|
|
135
|
+
session_message: { need: ['id', 'session_id', 'type', 'data'], opt: ['seq', 'time_created'] },
|
|
136
|
+
session_input: { need: ['id', 'session_id'], opt: ['delivery', 'admitted_seq', 'promoted_seq'] },
|
|
137
|
+
event: { need: ['id', 'aggregate_id', 'type', 'data'], opt: ['seq'] },
|
|
138
|
+
};
|
|
139
|
+
function schemaOf(db) {
|
|
140
|
+
const out = {};
|
|
141
|
+
for (const t of Object.keys(SCHEMA)) out[t] = columnsOf(db, t);
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
const usable = (sc, t) => SCHEMA[t].need.every((c) => sc[t].has(c));
|
|
145
|
+
|
|
146
|
+
/** The per-session change key, as SQL columns of the list query (what an older DB lacks: null). */
|
|
147
|
+
function keyColumns(sc) {
|
|
148
|
+
const agg = (t, fk, fn, col, as) => (sc[t].has(fk) && sc[t].has(col) ? `(SELECT ${fn}(${col}) FROM ${t} WHERE ${fk} = s.id) AS ${as}` : `NULL AS ${as}`);
|
|
149
|
+
const ev = sc.event.has('aggregate_id') && sc.event.has('type') && sc.event.has('seq');
|
|
150
|
+
return [
|
|
151
|
+
agg('message', 'session_id', 'count', 'id', 'm_n'), agg('message', 'session_id', 'max', 'time_updated', 'm_tu'),
|
|
152
|
+
agg('part', 'session_id', 'count', 'id', 'p_n'), agg('part', 'session_id', 'max', 'time_updated', 'p_tu'),
|
|
153
|
+
agg('session_message', 'session_id', 'count', 'id', 'v_n'), agg('session_message', 'session_id', 'max', 'seq', 'v_seq'),
|
|
154
|
+
agg('session_message', 'session_id', 'max', 'time_updated', 'v_tu'), agg('session_input', 'session_id', 'count', 'id', 'i_n'),
|
|
155
|
+
ev ? `max(${EVENT_RANGES.map((r) => `coalesce((SELECT max(seq) FROM event WHERE aggregate_id = s.id AND ${r}), -1)`).join(', ')}) AS ev_seq` : 'NULL AS ev_seq',
|
|
156
|
+
].join(', ');
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Sessions in one data folder: one entry per `session` row of each of its DBs.
|
|
161
|
+
*
|
|
162
|
+
* A DB is shared by all of its sessions, so no entry names it by absolute path (the runner would
|
|
163
|
+
* re-normalise every session on every write). The entry names it relative to harness_path
|
|
164
|
+
* (`db_rel`; '' when harness_path is the DB file itself), and the spill folder the same way
|
|
165
|
+
* (`spill_rel`). It carries a per-session change `key`, read in the same query: the count and the
|
|
166
|
+
* newest time_updated of the session's messages and parts (rows added, upserted, deleted, imported)
|
|
167
|
+
* and of its v2 messages with their newest seq, its v2 inputs, the newest removal or revert event,
|
|
168
|
+
* and a hash of the session-record fields (title, directory, version, parent_id, time_archived,
|
|
169
|
+
* revert). The session's own time_updated is left out: usage updates skip it, and a rename alone
|
|
170
|
+
* already changes the title.
|
|
171
|
+
*/
|
|
172
|
+
function list(storeRoot = storeRootDefault(), { env = process.env } = {}) {
|
|
173
|
+
storeRoot = path.resolve(storeRoot);
|
|
174
|
+
try { if (!fs.statSync(storeRoot).isDirectory()) return []; } catch { return []; }
|
|
175
|
+
const spills = path.join(harnessPaths.resolveNative(storeRoot), 'tool-output');
|
|
176
|
+
const out = [];
|
|
177
|
+
for (const { file, harness_path } of dbFiles(storeRoot, env)) {
|
|
178
|
+
const rel = { db_rel: path.relative(harness_path, harnessPaths.resolveNative(file)), spill_rel: path.relative(harness_path, spills) };
|
|
179
|
+
let db = null;
|
|
180
|
+
try {
|
|
181
|
+
db = openDb(file);
|
|
182
|
+
const sc = schemaOf(db);
|
|
183
|
+
if (!usable(sc, 'session')) continue; // no session table: not an OpenCode DB
|
|
184
|
+
const sql = `SELECT s.id, ${pick(sc.session, ['title', 'directory', 'version', 'parent_id', 'time_archived', 'revert'], 's.')}, ${keyColumns(sc)} FROM session s ORDER BY s.id`;
|
|
185
|
+
for (const r of db.prepare(sql).all()) {
|
|
186
|
+
const head = sha256(JSON.stringify([r.title, r.directory, r.version, r.parent_id, r.time_archived, textOf(r.revert)])).slice(0, 16);
|
|
187
|
+
const key = [r.m_n, r.m_tu, r.p_n, r.p_tu, r.v_n, r.v_seq, r.v_tu, r.i_n, r.ev_seq].map((v) => (v == null ? '' : String(v))).join(':');
|
|
188
|
+
out.push({ harness: HARNESS, harness_path, session_id: String(r.id), ...rel, key: `${key}:${head}` });
|
|
189
|
+
}
|
|
190
|
+
} catch (e) {
|
|
191
|
+
if (!MISSING.test(String(e && e.message))) throw e; // busy, damaged, unreadable: the runner keeps its index
|
|
192
|
+
} finally {
|
|
193
|
+
if (db) try { db.close(); } catch { /* closed */ }
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** One session's rows, read in one snapshot of the live WAL DB. */
|
|
200
|
+
function readSession(file, sid) {
|
|
201
|
+
const db = openDb(file);
|
|
202
|
+
let began = false;
|
|
203
|
+
const all = (sql, ...args) => {
|
|
204
|
+
try { return db.prepare(sql).all(...args); } catch (e) {
|
|
205
|
+
if (MISSING.test(String(e && e.message))) return []; // an older schema
|
|
206
|
+
throw e;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
try {
|
|
210
|
+
db.exec('BEGIN'); // one snapshot; a failure here is a failure to read
|
|
211
|
+
began = true;
|
|
212
|
+
const sc = schemaOf(db);
|
|
213
|
+
if (!usable(sc, 'session')) return null;
|
|
214
|
+
const cols = (t) => pick(sc[t], [...SCHEMA[t].need, ...SCHEMA[t].opt]);
|
|
215
|
+
const rows = (t, sql, ...args) => (usable(sc, t) ? all(sql.replace('<cols>', cols(t)), ...args) : []);
|
|
216
|
+
const session = db.prepare(`SELECT ${cols('session')} FROM session WHERE id = ?`).get(sid);
|
|
217
|
+
if (!session) return null;
|
|
218
|
+
return {
|
|
219
|
+
session_id: sid,
|
|
220
|
+
session,
|
|
221
|
+
messages: rows('message', 'SELECT <cols> FROM message WHERE session_id = ? ORDER BY time_created, id', sid),
|
|
222
|
+
parts: rows('part', 'SELECT <cols> FROM part WHERE session_id = ? ORDER BY message_id, id', sid),
|
|
223
|
+
session_messages: rows('session_message', 'SELECT <cols> FROM session_message WHERE session_id = ? ORDER BY seq, id', sid),
|
|
224
|
+
session_inputs: rows('session_input', 'SELECT <cols> FROM session_input WHERE session_id = ?', sid),
|
|
225
|
+
events: rows('event', `${EVENT_RANGES.map((r) => `SELECT ${cols('event')} FROM event WHERE aggregate_id = ? AND ${r}`).join(' UNION ALL ')} ORDER BY seq, id`, sid, sid, sid),
|
|
226
|
+
};
|
|
227
|
+
} finally {
|
|
228
|
+
if (began) try { db.exec('COMMIT'); } catch { /* read-only */ }
|
|
229
|
+
try { db.close(); } catch { /* closed */ }
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── small helpers ─────────────────────────────────────────────────────────────────────────────
|
|
234
|
+
/** A stored JSON object (a row's data, an event's data, session.revert): null when empty, a throw when unparsable. */
|
|
235
|
+
function strictJson(v, what) {
|
|
236
|
+
if (v == null) return null;
|
|
237
|
+
if (typeof v === 'object' && !(v instanceof Uint8Array)) return v;
|
|
238
|
+
const t = textOf(v).trim();
|
|
239
|
+
if (t === '' || t === 'null') return null;
|
|
240
|
+
let d;
|
|
241
|
+
try { d = JSON.parse(t); } catch { d = undefined; }
|
|
242
|
+
if (d == null || typeof d !== 'object') throw new Error(`opencode: unreadable ${what}`); // a damaged read
|
|
243
|
+
return d;
|
|
244
|
+
}
|
|
245
|
+
/** A row's `data` column: unparsable is a damaged read, so it throws rather than read as empty. */
|
|
246
|
+
const rowData = (r, table) => strictJson(r.data, `${table}.data for ${r.id}`) || {};
|
|
247
|
+
const eventData = (e) => strictJson(e.data, `event.data for ${e.id}`) || {};
|
|
248
|
+
const num = (v) => { const x = Number(v); return Number.isFinite(x) ? x : null; };
|
|
249
|
+
const ms = (v) => { const x = num(v); return x && x > 0 ? x : null; };
|
|
250
|
+
const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
|
|
251
|
+
const byOrder = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
252
|
+
|
|
253
|
+
/** The ms an ascending OpenCode id (msg_, prt_, evt_, tool_) was minted at, taken at or after `ref`. */
|
|
254
|
+
function idTime(id, ref) {
|
|
255
|
+
const m = /^[a-z]+_([0-9a-f]{12})/.exec(String(id));
|
|
256
|
+
if (!m) return null;
|
|
257
|
+
const x = Math.floor(parseInt(m[1], 16) / 4096) % MS_MOD;
|
|
258
|
+
const base = Math.max(0, Number(ref) - ID_SLACK_MS);
|
|
259
|
+
return base + (((x - (base % MS_MOD)) % MS_MOD) + MS_MOD) % MS_MOD;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** sha256 + length of a spilled tool output, `<data>/tool-output/tool_<id>`. Only that folder. */
|
|
263
|
+
function spilled(spillDir, p) {
|
|
264
|
+
const ref = path.basename(String(p || ''));
|
|
265
|
+
if (!SPILL.test(ref)) return null;
|
|
266
|
+
if (!spillDir) return { ref, sha: null, len: null };
|
|
267
|
+
try {
|
|
268
|
+
const file = path.join(spillDir, ref);
|
|
269
|
+
if (!fs.lstatSync(file).isFile()) return { ref, sha: null, len: null };
|
|
270
|
+
const buf = fs.readFileSync(file);
|
|
271
|
+
return { ref, sha: sha256(buf), len: buf.length };
|
|
272
|
+
} catch { return { ref, sha: null, len: null }; }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** A non-file URL cut to scheme://host (its path and query may carry a token), else one marker. */
|
|
276
|
+
function hideUrl(v) {
|
|
277
|
+
try {
|
|
278
|
+
const u = new URL(String(v));
|
|
279
|
+
if (u.host) return `${u.protocol}//${u.host}`;
|
|
280
|
+
} catch { /* not a URL */ }
|
|
281
|
+
return `<redacted len=${Buffer.byteLength(String(v), 'utf8')}>`;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** A file part from a v1 FilePart or a v2 attachment: a data: URL by the sha of its bytes, else a ref. */
|
|
285
|
+
function filePart(partId, f, extra = {}) {
|
|
286
|
+
const url = String(f.url || f.uri || '');
|
|
287
|
+
let mime = f.mime || f.mediaType || '';
|
|
288
|
+
let bytes = null;
|
|
289
|
+
let ref = '';
|
|
290
|
+
const dm = /^data:([^;,]*)((?:;[^;,]*)*),(.*)$/s.exec(url);
|
|
291
|
+
if (dm) {
|
|
292
|
+
if (!mime) mime = dm[1];
|
|
293
|
+
try { bytes = /;base64/i.test(dm[2]) ? Buffer.from(dm[3], 'base64') : Buffer.from(decodeURIComponent(dm[3]), 'utf-8'); } catch { bytes = null; }
|
|
294
|
+
ref = f.filename || f.name || '';
|
|
295
|
+
} else if (url.startsWith('file:')) {
|
|
296
|
+
try { ref = fileURLToPath(url); } catch { ref = url.split(/[?#]/)[0]; }
|
|
297
|
+
} else if (/^[a-z][a-z0-9+.-]*:/i.test(url) && !/^[a-z]:[\\/]/i.test(url)) { // C:\… or C:/… is a path
|
|
298
|
+
ref = hideUrl(url); // http(s), MCP resource and other URLs: a query string may carry a token
|
|
299
|
+
} else {
|
|
300
|
+
ref = url || f.filename || f.name || '';
|
|
301
|
+
}
|
|
302
|
+
return { part_id: partId, kind: 'file', media_type: mime, att_sha: bytes ? sha256(bytes) : null, att_len: bytes ? bytes.length : null, ref, ...extra };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function usageOf(t, cost) {
|
|
306
|
+
if (!t) return null;
|
|
307
|
+
const c = t.cache || {};
|
|
308
|
+
const u = { basis: 'net', input: num(t.input) || 0, output: num(t.output) || 0, cache_read: num(c.read) || 0, cache_write: num(c.write) || 0, reasoning: num(t.reasoning) || 0 };
|
|
309
|
+
if (!u.input && !u.output && !u.cache_read && !u.cache_write && !u.reasoning) return null; // a stored all-zero is unknown
|
|
310
|
+
return { ...u, cost: num(cost) > 0 ? num(cost) : null };
|
|
311
|
+
}
|
|
312
|
+
function sumUsage(steps) {
|
|
313
|
+
const acc = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } };
|
|
314
|
+
let cost = 0;
|
|
315
|
+
for (const d of steps) {
|
|
316
|
+
const t = d.tokens || {};
|
|
317
|
+
acc.input += num(t.input) || 0; acc.output += num(t.output) || 0; acc.reasoning += num(t.reasoning) || 0;
|
|
318
|
+
acc.cache.read += num(t.cache && t.cache.read) || 0; acc.cache.write += num(t.cache && t.cache.write) || 0;
|
|
319
|
+
cost += num(d.cost) || 0;
|
|
320
|
+
}
|
|
321
|
+
return usageOf(acc, cost);
|
|
322
|
+
}
|
|
323
|
+
function errorText(e) {
|
|
324
|
+
if (!e) return '';
|
|
325
|
+
if (typeof e === 'string') return e;
|
|
326
|
+
const d = e.data || {};
|
|
327
|
+
const msg = typeof d.message === 'string' ? d.message : (typeof e.message === 'string' ? e.message : '');
|
|
328
|
+
const name = e.name || e.type || '';
|
|
329
|
+
return [name, msg].filter(Boolean).join(': ') || 'error';
|
|
330
|
+
}
|
|
331
|
+
const effortOf = (v) => (v && v !== 'default' ? String(v) : '');
|
|
332
|
+
const toolStatus = (status, error) => (status === 'error' ? (/rejected permission/i.test(String(error || '')) ? 'denied' : 'error') : (status === 'completed' ? 'completed' : 'interrupted'));
|
|
333
|
+
|
|
334
|
+
// ── v1 engine ─────────────────────────────────────────────────────────────────────────────────
|
|
335
|
+
function v1Tool(p, d, spillDir) {
|
|
336
|
+
const st = d.state || {};
|
|
337
|
+
const meta = st.metadata || {};
|
|
338
|
+
const done = st.status === 'completed' || st.status === 'error';
|
|
339
|
+
const part = {
|
|
340
|
+
part_id: p.id, kind: 'tool', call_id: d.callID || '', tool: d.tool || '',
|
|
341
|
+
args: st.input ?? {},
|
|
342
|
+
output: st.status === 'completed' ? String(st.output ?? '') : (st.status === 'error' ? String(st.error ?? '') : null),
|
|
343
|
+
is_error: st.status === 'error', status: toolStatus(st.status, st.error), ts: ms(p.time_created),
|
|
344
|
+
};
|
|
345
|
+
if (done && Number.isFinite(meta.exit)) part.exit_code = meta.exit;
|
|
346
|
+
if (meta.outputPath) { const r = spilled(spillDir, meta.outputPath); if (r) part.output_ref = r; }
|
|
347
|
+
return part;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** v1 rows → { items: the message and lifecycle records in native order, children }, held-back cut applied. */
|
|
351
|
+
function v1Records(rows, ctx) {
|
|
352
|
+
const partsOf = new Map();
|
|
353
|
+
for (const p of rows.parts || []) {
|
|
354
|
+
const d = rowData(p, 'part');
|
|
355
|
+
if (!partsOf.has(p.message_id)) partsOf.set(p.message_id, []);
|
|
356
|
+
partsOf.get(p.message_id).push({ ...p, d });
|
|
357
|
+
}
|
|
358
|
+
for (const l of partsOf.values()) l.sort((a, b) => byOrder(String(a.id), String(b.id)));
|
|
359
|
+
const msgs = (rows.messages || []).map((m) => ({ ...m, d: rowData(m, 'message'), parts: partsOf.get(m.id) || [] }))
|
|
360
|
+
.sort((a, b) => (Number(a.time_created) - Number(b.time_created)) || byOrder(String(a.id), String(b.id)));
|
|
361
|
+
const byId = new Map(msgs.map((m) => [m.id, m]));
|
|
362
|
+
const isShell = (m) => m.d.role === 'assistant' && !m.d.finish && !m.d.error
|
|
363
|
+
&& m.parts.some((p) => p.d.type === 'tool') && !m.parts.some((p) => p.d.type === 'step-start' || p.d.type === 'step-finish');
|
|
364
|
+
const shellParents = new Set(msgs.filter(isShell).map((m) => m.d.parentID));
|
|
365
|
+
const compactionOf = (m) => m.parts.find((p) => p.d.type === 'compaction');
|
|
366
|
+
|
|
367
|
+
// Held back: the first assistant still streaming with no later assistant; everything after it waits.
|
|
368
|
+
let cut = msgs.length;
|
|
369
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
370
|
+
const d = msgs[i].d;
|
|
371
|
+
if (d.role !== 'assistant' || (d.time && d.time.completed) || d.error) continue;
|
|
372
|
+
if (!msgs.slice(i + 1).some((m) => m.d.role === 'assistant')) { cut = i; break; }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// The live revert: v1 hides the revert message and everything after it.
|
|
376
|
+
const rv = ctx.revert;
|
|
377
|
+
let revertAt = -1;
|
|
378
|
+
let revertPart = null;
|
|
379
|
+
if (rv && rv.messageID) {
|
|
380
|
+
revertAt = msgs.findIndex((m) => m.id === rv.messageID);
|
|
381
|
+
if (revertAt >= 0 && rv.partID && msgs[revertAt].parts.some((p) => p.id === rv.partID)) revertPart = rv.partID;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const items = [];
|
|
385
|
+
const children = [];
|
|
386
|
+
for (let i = 0; i < cut; i++) {
|
|
387
|
+
const m = msgs[i];
|
|
388
|
+
const d = m.d;
|
|
389
|
+
const role = d.role === 'assistant' ? 'assistant' : 'user';
|
|
390
|
+
const state = revertAt >= 0 && (i > revertAt || (i === revertAt && !revertPart)) ? 'reverted' : 'current';
|
|
391
|
+
const hideFrom = i === revertAt && revertPart ? revertPart : null;
|
|
392
|
+
const parts = [];
|
|
393
|
+
const push = (p, raw) => {
|
|
394
|
+
if (hideFrom && String(raw.id) >= hideFrom) p.hidden = true;
|
|
395
|
+
parts.push(p);
|
|
396
|
+
};
|
|
397
|
+
const summary = role === 'assistant' && (d.summary === true || d.mode === 'compaction' || d.agent === 'compaction');
|
|
398
|
+
const trigger = summary && byId.get(d.parentID) ? compactionOf(byId.get(d.parentID)) : null;
|
|
399
|
+
const replaces = trigger && trigger.d.tail_start_id ? { first_kept_id: trigger.d.tail_start_id } : 'unknown';
|
|
400
|
+
for (const p of m.parts) {
|
|
401
|
+
const x = p.d;
|
|
402
|
+
const ts = ms(p.time_created);
|
|
403
|
+
const inj = x.synthetic ? { source: 'injected' } : {};
|
|
404
|
+
if (x.type === 'text') {
|
|
405
|
+
push(summary ? { part_id: p.id, kind: 'summary', text: String(x.text ?? ''), replaces, ts } : { part_id: p.id, kind: 'text', text: String(x.text ?? ''), ts, ...inj }, p);
|
|
406
|
+
} else if (x.type === 'reasoning') {
|
|
407
|
+
push(x.text ? { part_id: p.id, kind: 'reasoning', text: String(x.text), ts } : { part_id: p.id, kind: 'reasoning', text: '', opaque: true, ts }, p);
|
|
408
|
+
} else if (x.type === 'tool') {
|
|
409
|
+
const tp = v1Tool(p, x, ctx.spillDir);
|
|
410
|
+
push(tp, p);
|
|
411
|
+
const child = x.tool === 'task' && x.state && x.state.metadata ? x.state.metadata.sessionId : null;
|
|
412
|
+
if (child && !children.some((c) => c.session_id === String(child) && c.at === tp.call_id)) children.push({ kind: 'subagent', session_id: String(child), evidence: 'explicit', after: m.id, at: tp.call_id });
|
|
413
|
+
const atts = (x.state && x.state.status === 'completed' && Array.isArray(x.state.attachments)) ? x.state.attachments : [];
|
|
414
|
+
atts.forEach((a, j) => push(filePart(a.id || `${p.id}:att:${j}`, a, { ts }), p));
|
|
415
|
+
} else if (x.type === 'file') {
|
|
416
|
+
push(filePart(p.id, x, { ts, ...inj }), p);
|
|
417
|
+
} else if (x.type === 'subtask') {
|
|
418
|
+
push({ part_id: p.id, kind: 'text', text: String(x.prompt ?? ''), ts }, p);
|
|
419
|
+
} else if (x.type === 'patch') {
|
|
420
|
+
push({ part_id: p.id, kind: 'notice', text: (Array.isArray(x.files) ? x.files : []).map(String).join('\n'), ts }, p);
|
|
421
|
+
}
|
|
422
|
+
// compaction (lifecycle below), step-start, step-finish (usage), snapshot, retry, agent: left out
|
|
423
|
+
}
|
|
424
|
+
const rec = { k: 'message', message_id: m.id, role, ts: ms(m.time_created), order: 0, state, status: 'complete' };
|
|
425
|
+
if (d.parentID) rec.parent_id = d.parentID;
|
|
426
|
+
if (role === 'user') {
|
|
427
|
+
const comp = compactionOf(m);
|
|
428
|
+
const content = m.parts.filter((p) => ['text', 'file', 'subtask'].includes(p.d.type));
|
|
429
|
+
if (comp && comp.d.auto) rec.source = 'automated';
|
|
430
|
+
else if (!comp && content.length && content.every((p) => p.d.synthetic) && !shellParents.has(m.id)) rec.source = 'automated';
|
|
431
|
+
} else if (role === 'assistant') {
|
|
432
|
+
const parent = byId.get(d.parentID);
|
|
433
|
+
const req = parent && parent.d.model ? parent.d.model : null;
|
|
434
|
+
rec.model = d.modelID || (d.model && d.model.modelID) || '';
|
|
435
|
+
if (req && req.modelID && req.modelID !== rec.model) rec.model_requested = req.modelID;
|
|
436
|
+
rec.effort = effortOf(d.variant || (req && req.variant));
|
|
437
|
+
if (isShell(m)) rec.source = 'user';
|
|
438
|
+
if (d.error) {
|
|
439
|
+
rec.status = d.error.name === 'MessageAbortedError' ? 'interrupted' : 'error';
|
|
440
|
+
parts.push({ part_id: 'error', kind: 'error', text: errorText(d.error) });
|
|
441
|
+
} else if (!(d.time && d.time.completed)) {
|
|
442
|
+
rec.status = 'interrupted'; // a dead run: a later assistant took over
|
|
443
|
+
} else if (!d.finish && !isShell(m) && !parts.length) {
|
|
444
|
+
rec.status = 'interrupted'; // overflow: completed with no finish, error or parts
|
|
445
|
+
}
|
|
446
|
+
const steps = m.parts.filter((p) => p.d.type === 'step-finish').map((p) => p.d);
|
|
447
|
+
rec.usage = steps.length ? sumUsage(steps) : usageOf(d.tokens, d.cost);
|
|
448
|
+
}
|
|
449
|
+
rec.parts = parts;
|
|
450
|
+
items.push({ rec, ts: Number(m.time_created) });
|
|
451
|
+
// Compaction: the trigger's lifecycle record follows it.
|
|
452
|
+
const comp = role === 'user' ? compactionOf(m) : null;
|
|
453
|
+
if (comp) {
|
|
454
|
+
// Replaced: everything before tail_start_id; with none, everything before the trigger.
|
|
455
|
+
const tail = comp.d.tail_start_id ? msgs.findIndex((x) => x.id === comp.d.tail_start_id) : i;
|
|
456
|
+
const through = tail > 0 ? msgs[tail - 1].id : null;
|
|
457
|
+
items.push({ rec: { k: 'lifecycle', event: 'compaction', at: ms(m.time_created), ...(through ? { through } : {}) }, ts: Number(m.time_created), after: true });
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Edits: rows deleted on prompt-while-reverted, as their message.removed events say.
|
|
462
|
+
const groups = [];
|
|
463
|
+
for (const e of ctx.events || []) {
|
|
464
|
+
const t = String(e.type || '');
|
|
465
|
+
if (!/^message\.(part\.)?removed\./.test(t)) continue;
|
|
466
|
+
const d = eventData(e);
|
|
467
|
+
const mid = d.messageID || (d.part && d.part.messageID);
|
|
468
|
+
if (!mid) continue;
|
|
469
|
+
const last = groups[groups.length - 1];
|
|
470
|
+
if (last && Number(e.seq) === last.seq + 1) { last.seq = Number(e.seq); if (!last.ids.includes(mid)) last.ids.push(mid); continue; }
|
|
471
|
+
groups.push({ seq: Number(e.seq), id: e.id, ids: [mid] });
|
|
472
|
+
}
|
|
473
|
+
for (const g of groups) {
|
|
474
|
+
const at = idTime(g.id, ctx.created);
|
|
475
|
+
let pos = items.findIndex((x) => x.rec.k === 'message' && !x.after && x.ts >= at);
|
|
476
|
+
if (pos < 0) pos = items.length;
|
|
477
|
+
const prev = items.slice(0, pos).filter((x) => x.rec.k === 'message').pop();
|
|
478
|
+
items.splice(pos, 0, { rec: { k: 'lifecycle', event: 'edit', at, message_ids: g.ids, ...(prev ? { through: prev.rec.message_id } : {}) }, ts: at, after: true });
|
|
479
|
+
}
|
|
480
|
+
return { items: items.map((x) => x.rec), children };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ── v2 engine ─────────────────────────────────────────────────────────────────────────────────
|
|
484
|
+
function v2Content(c, spillDir, ts) {
|
|
485
|
+
if (c.type === 'text') return [{ part_id: String(c.id), kind: 'text', text: String(c.text ?? ''), ts }];
|
|
486
|
+
if (c.type === 'reasoning') return [c.text ? { part_id: String(c.id), kind: 'reasoning', text: String(c.text), ts } : { part_id: String(c.id), kind: 'reasoning', text: '', opaque: true, ts }];
|
|
487
|
+
if (c.type !== 'tool') return [];
|
|
488
|
+
const st = c.state || {};
|
|
489
|
+
const time = c.time || {};
|
|
490
|
+
const done = st.status === 'completed' || st.status === 'error';
|
|
491
|
+
const text = Array.isArray(st.content) ? st.content.map((x) => (typeof x === 'string' ? x : (x && x.text) || '')).join('') : '';
|
|
492
|
+
const part = {
|
|
493
|
+
part_id: String(c.id), kind: 'tool', call_id: String(c.id), tool: c.name || '',
|
|
494
|
+
args: typeof st.input === 'string' ? { raw: st.input } : (st.input ?? {}),
|
|
495
|
+
output: st.status === 'completed' ? text : (st.status === 'error' ? (text || errorText(st.error)) : null),
|
|
496
|
+
is_error: st.status === 'error', status: toolStatus(st.status, errorText(st.error)), ts: ms(time.created) || ts,
|
|
497
|
+
};
|
|
498
|
+
const s = st.structured || {};
|
|
499
|
+
if (done && Number.isFinite(s.exit)) part.exit_code = s.exit;
|
|
500
|
+
const out = (Array.isArray(st.outputPaths) ? st.outputPaths : []).find((p) => SPILL.test(path.basename(String(p))));
|
|
501
|
+
if (out) { const r = spilled(spillDir, out); if (r) part.output_ref = r; }
|
|
502
|
+
const parts = [part];
|
|
503
|
+
(st.status === 'completed' && Array.isArray(st.attachments) ? st.attachments : []).forEach((a, j) => parts.push(filePart(`${c.id}:att:${j}`, a, { ts })));
|
|
504
|
+
return parts;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function v2Records(rows, ctx) {
|
|
508
|
+
const inputs = new Map((rows.session_inputs || []).map((r) => [String(r.id), r]));
|
|
509
|
+
const msgs = (rows.session_messages || []).map((m) => ({ ...m, d: rowData(m, 'session_message') }))
|
|
510
|
+
.sort((a, b) => (Number(a.seq) - Number(b.seq)) || byOrder(String(a.id), String(b.id)));
|
|
511
|
+
|
|
512
|
+
let cut = msgs.length;
|
|
513
|
+
for (let i = 0; i < msgs.length; i++) {
|
|
514
|
+
const m = msgs[i];
|
|
515
|
+
if (m.type !== 'assistant' || (m.d.time && m.d.time.completed) || m.d.error) continue;
|
|
516
|
+
if (!msgs.slice(i + 1).some((x) => x.type === 'assistant')) { cut = i; break; }
|
|
517
|
+
}
|
|
518
|
+
// The live revert: v2 hides what follows the staged message (a commit deletes that, keeps it).
|
|
519
|
+
const rv = ctx.revert;
|
|
520
|
+
const revertAt = rv && rv.messageID ? msgs.findIndex((m) => m.id === rv.messageID) : -1;
|
|
521
|
+
|
|
522
|
+
const items = [];
|
|
523
|
+
const children = [];
|
|
524
|
+
for (let i = 0; i < cut; i++) {
|
|
525
|
+
const m = msgs[i];
|
|
526
|
+
const d = m.d;
|
|
527
|
+
const ts = ms(m.time_created) || ms(d.time && d.time.created);
|
|
528
|
+
const rec = { k: 'message', message_id: String(m.id), role: 'user', ts, order: 0, state: revertAt >= 0 && i > revertAt ? 'reverted' : 'current', status: 'complete' };
|
|
529
|
+
const parts = [];
|
|
530
|
+
if (m.type === 'user') {
|
|
531
|
+
if (d.text != null && d.text !== '') parts.push({ part_id: '0', kind: 'text', text: String(d.text) });
|
|
532
|
+
(Array.isArray(d.files) ? d.files : []).forEach((f, j) => parts.push(filePart(String(j + 1), f)));
|
|
533
|
+
const inp = inputs.get(String(m.id));
|
|
534
|
+
if (inp) rec.raw = { session_input: { delivery: inp.delivery, admitted_seq: num(inp.admitted_seq), promoted_seq: num(inp.promoted_seq) } };
|
|
535
|
+
} else if (m.type === 'synthetic') {
|
|
536
|
+
rec.source = 'injected';
|
|
537
|
+
parts.push({ part_id: '0', kind: 'text', text: String(d.text ?? ''), source: 'injected' });
|
|
538
|
+
} else if (m.type === 'system') {
|
|
539
|
+
rec.role = 'system';
|
|
540
|
+
rec.source = 'injected';
|
|
541
|
+
parts.push({ part_id: '0', kind: 'notice', text: String(d.text ?? '') });
|
|
542
|
+
} else if (m.type === 'shell') {
|
|
543
|
+
parts.push({
|
|
544
|
+
part_id: String(d.callID || 'shell'), kind: 'tool', call_id: String(d.callID || ''), tool: 'bash', args: { command: String(d.command ?? '') },
|
|
545
|
+
output: d.time && d.time.completed ? String(d.output ?? '') : null, is_error: false, status: d.time && d.time.completed ? 'completed' : 'interrupted',
|
|
546
|
+
});
|
|
547
|
+
} else if (m.type === 'compaction') {
|
|
548
|
+
items.push({ rec: { k: 'lifecycle', event: 'compaction', at: ts, ...(i > 0 ? { through: String(msgs[i - 1].id) } : {}) }, seq: Number(m.seq) });
|
|
549
|
+
rec.role = 'system';
|
|
550
|
+
if (d.reason === 'auto') rec.source = 'automated';
|
|
551
|
+
parts.push({ part_id: '0', kind: 'summary', text: String(d.summary ?? ''), replaces: 'unknown' });
|
|
552
|
+
} else if (m.type === 'assistant') {
|
|
553
|
+
rec.role = 'assistant';
|
|
554
|
+
const model = d.model || {};
|
|
555
|
+
rec.model = model.id || '';
|
|
556
|
+
rec.effort = effortOf(model.variant);
|
|
557
|
+
for (const c of Array.isArray(d.content) ? d.content : []) {
|
|
558
|
+
parts.push(...v2Content(c, ctx.spillDir, ts));
|
|
559
|
+
if (c.type === 'tool' && c.name === 'task') {
|
|
560
|
+
const s = (c.state && (c.state.structured || c.state.metadata)) || {};
|
|
561
|
+
if (s.sessionId) children.push({ kind: 'subagent', session_id: String(s.sessionId), evidence: 'explicit', after: rec.message_id, at: String(c.id) });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
if (d.error) {
|
|
565
|
+
rec.status = /abort/i.test(errorText(d.error)) ? 'interrupted' : 'error';
|
|
566
|
+
parts.push({ part_id: 'error', kind: 'error', text: errorText(d.error) });
|
|
567
|
+
} else if (!(d.time && d.time.completed)) rec.status = 'interrupted';
|
|
568
|
+
rec.usage = usageOf(d.tokens, d.cost);
|
|
569
|
+
} else {
|
|
570
|
+
continue; // agent-switched, model-switched: session bookkeeping, not conversation
|
|
571
|
+
}
|
|
572
|
+
rec.parts = parts;
|
|
573
|
+
items.push({ rec, seq: Number(m.seq) });
|
|
574
|
+
}
|
|
575
|
+
// A committed revert deleted rows: a rewind at its seq, back to the staged message.
|
|
576
|
+
for (const e of ctx.events || []) {
|
|
577
|
+
if (!/^session\.next\.revert\.committed\./.test(String(e.type || ''))) continue;
|
|
578
|
+
const d = eventData(e);
|
|
579
|
+
let pos = items.findIndex((x) => x.rec.k === 'message' && x.seq > Number(e.seq));
|
|
580
|
+
if (pos < 0) pos = items.length;
|
|
581
|
+
items.splice(pos, 0, { rec: { k: 'lifecycle', event: 'rewind', at: ms(d.timestamp), ...(d.messageID ? { through: String(d.messageID) } : {}) }, seq: Number(e.seq) });
|
|
582
|
+
}
|
|
583
|
+
return { items: items.map((x) => x.rec), children };
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
/** The revert in force: the session's `revert`, else a v2 revert staged and not yet committed or cleared. */
|
|
587
|
+
function liveRevert(session, events) {
|
|
588
|
+
const r = strictJson(session.revert, `session.revert for ${session.id}`);
|
|
589
|
+
if (r && r.messageID) return r;
|
|
590
|
+
let staged = null;
|
|
591
|
+
for (const e of events || []) {
|
|
592
|
+
const t = String(e.type || '');
|
|
593
|
+
if (/^session\.next\.revert\.staged\./.test(t)) staged = eventData(e).revert || null;
|
|
594
|
+
else if (/^session\.next\.revert\.(committed|cleared)\./.test(t)) staged = null;
|
|
595
|
+
}
|
|
596
|
+
return staged && staged.messageID ? staged : null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Normalise one session: a list() entry (`{ harness_path, session_id, db_rel, spill_rel, key }`: the DB
|
|
601
|
+
* and the spill folder rebuilt from harness_path), or its rows already loaded (tests):
|
|
602
|
+
* `{ session_id, session, messages, parts, session_messages, session_inputs, events, data_dir }`,
|
|
603
|
+
* JSON columns as strings or objects. Returns the log records, the session record first; null when
|
|
604
|
+
* the session is not in the store.
|
|
605
|
+
*/
|
|
606
|
+
function normalise(session) {
|
|
607
|
+
const fromStore = typeof session.db_rel === 'string' && session.harness_path;
|
|
608
|
+
const rows = fromStore ? readSession(path.join(session.harness_path, session.db_rel), session.session_id) : session;
|
|
609
|
+
if (!rows || !rows.session) return null;
|
|
610
|
+
const s = rows.session;
|
|
611
|
+
const events = rows.events || [];
|
|
612
|
+
const spillDir = fromStore ? path.join(session.harness_path, String(session.spill_rel ?? 'tool-output'))
|
|
613
|
+
: (rows.data_dir ? path.join(rows.data_dir, 'tool-output') : null);
|
|
614
|
+
const ctx = { spillDir, revert: liveRevert(s, events), events, created: Number(s.time_created) || 0 };
|
|
615
|
+
const hasV2 = (rows.session_messages || []).length > 0;
|
|
616
|
+
const v1 = (rows.messages || []).length || !hasV2 ? v1Records(rows, ctx) : { items: [], children: [] };
|
|
617
|
+
const v2 = hasV2 ? v2Records(rows, ctx) : { items: [], children: [] };
|
|
618
|
+
const records = [...v1.items, ...v2.items]; // both engines in one session: not observed; v1 first
|
|
619
|
+
let order = 0;
|
|
620
|
+
for (const r of records) if (r.k === 'message') r.order = order++;
|
|
621
|
+
const children = [...v1.children, ...v2.children];
|
|
622
|
+
const head = {
|
|
623
|
+
k: 'session',
|
|
624
|
+
title: s.title || '', cwd: s.directory || '', version: s.version || '',
|
|
625
|
+
created: ms(s.time_created), updated: null, archived: ms(s.time_archived),
|
|
626
|
+
incarnation: '', engine: hasV2 ? 'v2' : 'v1',
|
|
627
|
+
links: s.parent_id ? [{ kind: 'subagent', origin: String(s.parent_id), evidence: 'explicit', at: '' }] : [],
|
|
628
|
+
...(children.length ? { children } : {}),
|
|
629
|
+
};
|
|
630
|
+
return placeChildren([head, ...records]);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
module.exports = { HARNESS, list, normalise, storeRootDefault, dbFiles, idTime, readSession };
|