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.
Files changed (169) hide show
  1. package/AGENTS.md +22 -151
  2. package/CHANGELOG.md +263 -29
  3. package/LICENSE +202 -22
  4. package/LICENSES/agentlytics-MIT.txt +21 -0
  5. package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
  6. package/LICENSES/models.dev-MIT.txt +22 -0
  7. package/NOTICE +28 -0
  8. package/README.md +42 -20
  9. package/SECURITY.md +21 -8
  10. package/bin/memhouse.js +2223 -311
  11. package/docs/README.md +11 -9
  12. package/docs/guides/admin-password.md +37 -10
  13. package/docs/guides/configuration.md +13 -6
  14. package/docs/guides/daemons.md +64 -3
  15. package/docs/guides/import-claude-ai.md +204 -0
  16. package/docs/guides/install.md +11 -6
  17. package/docs/guides/invite.md +21 -7
  18. package/docs/guides/operate.md +114 -7
  19. package/docs/guides/search-and-resume.md +72 -3
  20. package/docs/guides/share.md +91 -52
  21. package/docs/guides/storage.md +44 -4
  22. package/docs/guides/troubleshooting.md +121 -14
  23. package/docs/guides/uninstall.md +4 -3
  24. package/docs/guides/update.md +16 -0
  25. package/docs/guides/upgrade-to-0.20.md +188 -0
  26. package/docs/releases/README.md +2 -1
  27. package/docs/releases/v0.18.10.md +5 -5
  28. package/docs/releases/v0.18.11.md +3 -3
  29. package/docs/releases/v0.20.0.md +627 -0
  30. package/docs/releases/v0.20.1.md +114 -0
  31. package/docs/tutorials/01-first-house.md +10 -7
  32. package/docs/tutorials/02-join-a-house.md +24 -15
  33. package/docs/tutorials/03-share-your-memory.md +40 -31
  34. package/editors/antigravity.js +1 -80
  35. package/editors/base.js +463 -9
  36. package/editors/bridge.js +119 -0
  37. package/editors/claude.js +273 -105
  38. package/editors/codex.js +278 -52
  39. package/editors/copilot.js +1 -70
  40. package/editors/cursor.js +1 -75
  41. package/editors/derive-fingerprints.json +18 -0
  42. package/editors/harness-paths.js +69 -0
  43. package/editors/index.js +1 -20
  44. package/editors/normalise/agy.js +910 -0
  45. package/editors/normalise/copilot-cli.js +321 -0
  46. package/editors/normalise/cursor-agent.js +852 -0
  47. package/editors/normalise/cursor.js +474 -0
  48. package/editors/normalise/devin.js +575 -0
  49. package/editors/normalise/gemini-cli.js +602 -0
  50. package/editors/normalise/goose.js +673 -0
  51. package/editors/normalise/opencode.js +633 -0
  52. package/editors/normalise/vscode.js +549 -0
  53. package/editors/normalise/zed.js +473 -0
  54. package/editors/opencode-v4.js +119 -0
  55. package/editors/scope.js +14 -3
  56. package/editors/sessions-log.js +349 -0
  57. package/editors/sqlite.js +28 -1
  58. package/editors/vscode.js +1 -72
  59. package/editors/windsurf.js +1 -143
  60. package/examples/01-local-one-machine/README.md +24 -19
  61. package/examples/01-local-one-machine/run.sh +1 -1
  62. package/examples/02-existing-clickhouse/README.md +30 -22
  63. package/examples/02-existing-clickhouse/run.sh +5 -4
  64. package/examples/03-team-house/README.md +44 -37
  65. package/examples/03-team-house/run.sh +35 -18
  66. package/examples/04-self-hosted-team/README.md +29 -14
  67. package/examples/04-self-hosted-team/compose.yml +5 -2
  68. package/examples/04-self-hosted-team/smoke.sh +26 -12
  69. package/examples/README.md +8 -4
  70. package/examples/fixtures/README.md +4 -1
  71. package/examples/fixtures/make-sessions.js +8 -5
  72. package/memhouse/admin-secret.js +89 -24
  73. package/memhouse/argv-secrets.js +64 -0
  74. package/memhouse/channel.js +49 -9
  75. package/memhouse/codex-plugin.js +194 -0
  76. package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
  77. package/memhouse/delivery/AGENT-INSTALL.md +22 -11
  78. package/memhouse/delivery/INVITATION.md.tpl +44 -45
  79. package/memhouse/delivery/PROMPT.md +53 -5
  80. package/memhouse/delivery/UPGRADE.md.tpl +1 -1
  81. package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
  82. package/memhouse/delivery/codex/SKILL.md +50 -0
  83. package/memhouse/delivery/kernel-install.md +17 -9
  84. package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
  85. package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
  86. package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
  87. package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
  88. package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
  89. package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
  90. package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
  91. package/memhouse/deploy.js +1 -1
  92. package/memhouse/flags.js +35 -18
  93. package/memhouse/help.js +37 -0
  94. package/memhouse/host.js +82 -2
  95. package/memhouse/house/HOUSE.md +2 -2
  96. package/memhouse/house/convert-profile.js +638 -0
  97. package/memhouse/house/convert-v3.js +742 -0
  98. package/memhouse/house/doctor-v3.js +324 -0
  99. package/memhouse/house/host-alias.js +85 -0
  100. package/memhouse/house/house.js +3 -3
  101. package/memhouse/house/migrate.js +2 -2
  102. package/memhouse/house/profile-dedupe.js +183 -0
  103. package/memhouse/house/profile-edges.js +554 -0
  104. package/memhouse/house/profile-house.js +430 -0
  105. package/memhouse/house/profile-rollup.js +190 -0
  106. package/memhouse/house/q-cli.js +863 -0
  107. package/memhouse/house/queries-v3.js +1013 -0
  108. package/memhouse/house/reconcile-v3.js +192 -0
  109. package/memhouse/house/records-to-profile.js +174 -0
  110. package/memhouse/house/records-to-rows.js +169 -0
  111. package/memhouse/house/relocate.js +2 -1
  112. package/memhouse/house/schema-choice.js +121 -0
  113. package/memhouse/house/schema-profile.js +290 -0
  114. package/memhouse/house/schema-profile.sql.tpl +398 -0
  115. package/memhouse/house/schema-v3.js +267 -0
  116. package/memhouse/house/schema-v3.sql.tpl +213 -0
  117. package/memhouse/import/claude-ai.js +420 -0
  118. package/memhouse/import/import.js +372 -0
  119. package/memhouse/import/pyjson.js +206 -0
  120. package/memhouse/import/records.js +64 -0
  121. package/memhouse/import/writer-profile.js +192 -0
  122. package/memhouse/import/writer-v3.js +240 -0
  123. package/memhouse/import/zip.js +112 -0
  124. package/memhouse/invitation.js +102 -0
  125. package/memhouse/invitefile.js +5 -2
  126. package/memhouse/plugin-copies.js +176 -0
  127. package/memhouse/provision.js +7 -2
  128. package/memhouse/release-hint.js +24 -0
  129. package/memhouse/resume.js +4 -4
  130. package/memhouse/server/api-v3.js +632 -0
  131. package/memhouse/server/dashboard-auth.js +181 -0
  132. package/memhouse/server/dashboard-port.js +91 -0
  133. package/memhouse/server/queries.js +30 -0
  134. package/memhouse/server/server.js +118 -20
  135. package/memhouse/service.js +1 -1
  136. package/memhouse/share.js +687 -1
  137. package/memhouse/shipper/adapter-capture.js +756 -0
  138. package/memhouse/shipper/capture.js +2571 -0
  139. package/memhouse/shipper/dedupe-profile.js +237 -0
  140. package/memhouse/shipper/dedupe-v3.js +1027 -0
  141. package/memhouse/shipper/forks.js +233 -0
  142. package/memhouse/shipper/log-adapter.js +219 -0
  143. package/memhouse/shipper/maintenance.js +49 -0
  144. package/memhouse/shipper/normalise-run.js +148 -0
  145. package/memhouse/shipper/passlog.js +68 -5
  146. package/memhouse/shipper/profile-capture.js +903 -0
  147. package/memhouse/shipper/ship.js +91 -8
  148. package/memhouse/shipper/snapshots.js +193 -0
  149. package/memhouse/shipper/source-roots.js +111 -0
  150. package/memhouse/version-notice.js +66 -0
  151. package/package.json +14 -7
  152. package/public/THIRD_PARTY_NOTICES.txt +2 -2
  153. package/public/assets/index-CcNnhfM3.js +78 -0
  154. package/public/index.html +1 -1
  155. package/ui/package-lock.json +56 -37
  156. package/ui/package.json +1 -0
  157. package/ui/src/App.jsx +29 -14
  158. package/ui/src/components/ChatSidebar.jsx +69 -12
  159. package/ui/src/lib/api.js +76 -0
  160. package/ui/src/lib/dashboard-token.js +41 -0
  161. package/ui/src/main.jsx +22 -5
  162. package/ui/src/pages/Dashboard.jsx +22 -36
  163. package/ui/third-party-notices.js +119 -0
  164. package/ui/vite.config.js +5 -1
  165. package/docs/releases/v0.18.12.md +0 -59
  166. package/memhouse/COMPETITION.md +0 -221
  167. package/memhouse/DESIGN.md +0 -222
  168. package/memhouse/RESUME-AND-UPDATE.md +0 -182
  169. package/public/assets/index-CnWzrjF3.js +0 -78
@@ -0,0 +1,673 @@
1
+ 'use strict';
2
+ // v4 wave 2: Goose → normalised session log records (docs/design/sessions-log.md, with the format
3
+ // additions v2/v3: archived, children, exit_code, model_requested, hidden, lifecycle, server,
4
+ // source, content_id).
5
+ //
6
+ // Store: <data>/sessions/sessions.db (SQLite, WAL), opened read-only in place (rows live in the WAL
7
+ // for hours: never copy the main file alone). <data> is $GOOSE_PATH_ROOT/data, else
8
+ // $XDG_DATA_HOME/goose, else harness-paths' data folder (~/.local/share/goose).
9
+ // - sessions: one row per session; `id` is `YYYYMMDD_N` (UTC date, MAX(N)+1), reused after a
10
+ // delete of the max;
11
+ // - messages: one row per message; content_json[] (parts), metadata_json (visibility flags,
12
+ // inference, usage, operations); `id` is a rowid, re-minted by `--edit`;
13
+ // - usage_ledger: one row per LLM call (authoritative), including compaction calls.
14
+ // Spec: docs/design/adapter-specs/goose.md (gs3, repository); sessions-model v0 and its annex.
15
+ //
16
+ // Rules:
17
+ // - message id = messages.message_id, never messages.id (positions are not identities). A NULL id is
18
+ // anchored on the previous native id (`<id>+n`); a repeat inside one session is `<id>+n` too;
19
+ // - order = ORDER BY created_timestamp, id (the harness's own load query), as the position in that
20
+ // order. Same-second rows can sort against causality (an elicitation response precedes its
21
+ // request): both are kept as stored, nothing is reordered;
22
+ // - reuse is undetectable (sessions-model §1 rule 2): incarnation = sessions.created_at as stored,
23
+ // so the log file is fileNameFor(id, created_at). A same-second delete and recreate is not told
24
+ // apart, which is accepted;
25
+ // - tool results are role-user rows carrying toolResponse parts. Each is folded into its call's tool
26
+ // part by id (toolRequest.id == toolResponse.id), whatever order parallel results completed in;
27
+ // the result row's own message_id is kept as the part's `content_id`. A result row whose every
28
+ // part was folded is not a message of its own; an unclaimed result is a role `tool` message.
29
+ // Images in a result are file parts after the tool part; exit_code from structuredContent;
30
+ // - tool status: a declined call (confirmation deny, or goose's "declined" result) is `denied`; goose's
31
+ // "interrupted before completing" result, and a Ctrl-C `(no output)` error with no exit code, are
32
+ // `cancelled`;
33
+ // - held back: an assistant row whose tool call has no result yet, until the result row lands or a
34
+ // later user prompt shows the turn ended (then it is written with the call `interrupted`). Every
35
+ // row after a held-back one waits too, so the log only grows while the session only grows. Goose
36
+ // persists nothing while a reply streams;
37
+ // - visibility: metadata.userVisible=false is `hidden: true`; agentVisible=false (kept from the model
38
+ // only) is `model_visible: false`. Compaction flips agentVisible in place on every earlier row, so
39
+ // a compaction rewrites the earlier lines once; their keys stay;
40
+ // - compaction: the summary row (role user, userVisible false, agentVisible true, a provider id or
41
+ // `# Conversation Summary`) is a `system` message with one summary part (replaces: {flags}),
42
+ // hidden, preceded by a compaction lifecycle record (through = the last message before it);
43
+ // - source: turn-context rows and goose's own hidden notices (compaction continuation, sub-agent
44
+ // completion) are `injected`; goose-written hidden user turns (goal nudges, the auto-compaction
45
+ // replay of the prompt) and the prompts of `sub_agent` and `scheduled` sessions are `automated`.
46
+ // Steer rows are the user's own steering: kept, no source;
47
+ // - usage (basis unknown): the ledger (one row per LLM call) is authoritative. A ledger row links to
48
+ // the assistant row of the same call: equal input/output/cache-read counts, written at the call's
49
+ // end (within elapsedMs of the row's time). A linked row is that message's usage and nothing else.
50
+ // Every other ledger row is a `k: 'usage'` record (usage_id = the ledger row's id), placed by its
51
+ // time among the messages: a compaction call (reason 'compaction'; the summary message carries no
52
+ // usage) and calls whose messages are gone (/clear and truncate delete rows, goose keeps the
53
+ // ledger);
54
+ // - copied usage: a fork or an import copies rows with their metadata.usage but not the ledger. So in
55
+ // a store that keeps a ledger, an assistant row with metadata.usage and no linked ledger row, whose
56
+ // call ended at or after the store's first ledger row, is a copy: it gets no `usage`, and its metadata.usage goes to
57
+ // the message's `raw`, so the call is billed once, in the session that made it. A store with no
58
+ // ledger rows at all (older goose), and calls that ended before its first ledger row (an upgraded store),
59
+ // keep metadata.usage as the message's usage. Billing across a fork is M4's;
60
+ // - model: served = the linked ledger row's `model`, else inference.resolvedModel, else the requested
61
+ // one; model_requested = inference.requestedModel. Effort is per session
62
+ // (model_config_json.request_params.thinking_effort), written on each LLM reply;
63
+ // - status: an error part is `error`; a reply with inference but no usage (Ctrl-C mid-stream) and an
64
+ // outputTokenLimitReached row are `interrupted`;
65
+ // - session: title = name, cwd = working_dir, kind = session_type, mode = goose_mode, archived =
66
+ // archived_at (RFC 3339) plus an archive lifecycle record placed by time. `updated` is null
67
+ // (updated_at, the token counters and accumulated_* are volatile and stay out). Child side:
68
+ // parent_session_id is an explicit subagent link. Origin side: `children` from a row's
69
+ // operations.foreground_subagent.delivered. A fork stores no link (inferred forks are the engine's);
70
+ // - raw: the session record keeps the sessions row's stable columns (JSON parsed), redacted;
71
+ // - redaction by field path (sessions-model §4), in the session raw. Tokens sit in extension env
72
+ // values and headers, in stdio args (`--token …`) and in SSE/HTTP URL queries, so for every
73
+ // extension (in extension_data and in recipe_json): each `envs` / `headers` value and each `args`
74
+ // element becomes `<redacted len=N>` (keys kept); `uri` / `url` is cut to scheme://host; a `cmd`
75
+ // with spaces in it (args folded in) is redacted whole; any extension field not known to be
76
+ // harmless is redacted. In recipes, sub-recipe `values` and parameter `default`s are redacted, and
77
+ // so is every value of user_recipe_values_json (keys kept). In tool results, an MCP resource URI
78
+ // other than a file: URI or a plain/Windows path is cut to scheme://host (or redacted with no host). Map keys are sorted and the extension
79
+ // list is sorted by name, so a re-save in another order does not rewrite the log. Free text
80
+ // (prompts, tool output) is out of reach (§4.6). Nothing outside sessions.db is opened
81
+ // (config.yaml, keyring and logs are never read), and only through openReadOnly, which creates
82
+ // nothing next to the DB;
83
+ // - list(): sessions.db is shared by every session, so an entry names no absolute file (the runner
84
+ // would re-read every session on any write). It names the DB relative to harness_path and carries
85
+ // a change key read in the list query, per session through the session_id indexes: updated_at,
86
+ // the session's message count, max rowid (an insert, a delete, --edit's re-insert) and
87
+ // total(octet_length(metadata_json)) (the spec's fingerprint: an agentVisible flip in place changes
88
+ // it by one; octet_length reads the stored size, not each overflow chain), its ledger count and max id (a ledger row landing after its message re-links
89
+ // usage), a hash of the session's raw columns (rename, archive, mode, extensions, effort: whatever
90
+ // line 1 is made of) and the store's first ledger second (the copy rule; the lowest ledger rowid's,
91
+ // read by rowid, not a scan).
92
+
93
+ const crypto = require('crypto');
94
+ const fs = require('fs');
95
+ const path = require('path');
96
+ const { openReadOnly, textOf } = require('../sqlite');
97
+ const harnessPaths = require('../harness-paths');
98
+ const { placeChildren } = require('../sessions-log');
99
+
100
+ const HARNESS = 'goose';
101
+
102
+ // ── store ─────────────────────────────────────────────────────────────────────────────────────
103
+ /**
104
+ * Goose's data folder: harness-paths' (GOOSE_PATH_ROOT/data, then XDG_DATA_HOME/goose, then
105
+ * ~/.local/share/goose; adapter spec §1), the one source the bridge uses too.
106
+ */
107
+ function storeRootDefault(env = process.env) { return harnessPaths.defaultFolder(HARNESS, { env }); }
108
+ function dbPathOf(storeRoot) { return path.join(storeRoot, 'sessions', 'sessions.db'); }
109
+ /** harness_path: dataFolder('goose') for the default store (so the bridge switch dedupes), else the resolved folder. */
110
+ function harnessPathOf(storeRoot) {
111
+ const def = harnessPaths.defaultFolder(HARNESS);
112
+ return def && path.resolve(storeRoot) === path.resolve(def) ? harnessPaths.dataFolder(HARNESS) : harnessPaths.resolveNative(storeRoot);
113
+ }
114
+
115
+ // The module contract (docs/design/sessions-log.md): null or a throw when the store cannot be read
116
+ // consistently, never fewer records than the session holds. So no SQLite error is caught here: busy,
117
+ // locked, malformed, IOERR, NOTADB, a locking-protocol failure all propagate (list() throwing keeps
118
+ // the runner's index; normalise() throwing is retried). Only a missing table or column reads as
119
+ // empty, and PRAGMA table_info returns no rows for those by itself.
120
+ function columnsOf(db, table) {
121
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => String(r.name)));
122
+ }
123
+ /** One read transaction (one snapshot) over the store: wait a moment on a lock, then fail. */
124
+ function withRead(file, fn) {
125
+ const db = openReadOnly(file);
126
+ try {
127
+ db.exec('PRAGMA busy_timeout = 1000');
128
+ db.exec('BEGIN');
129
+ const out = fn(db);
130
+ db.exec('COMMIT');
131
+ return out;
132
+ } finally { db.close(); }
133
+ }
134
+
135
+ const DB_REL = path.join('sessions', 'sessions.db');
136
+
137
+ /** The list query: every sessions row with its change key, each count a per-session index lookup. */
138
+ function listSql(db) {
139
+ const mc = columnsOf(db, 'messages');
140
+ const lc = columnsOf(db, 'usage_ledger');
141
+ const per = (t, cols, a) => (cols.has('session_id')
142
+ ? `(SELECT count(*) FROM ${t} x WHERE x.session_id = s.id) AS mh_${a}_n, (SELECT max(x.id) FROM ${t} x WHERE x.session_id = s.id) AS mh_${a}_max`
143
+ : `0 AS mh_${a}_n, NULL AS mh_${a}_max`);
144
+ const meta = mc.has('session_id') && mc.has('metadata_json')
145
+ ? '(SELECT total(octet_length(x.metadata_json)) FROM messages x WHERE x.session_id = s.id) AS mh_msg_meta' : '0 AS mh_msg_meta';
146
+ return `SELECT s.*, ${per('messages', mc, 'msg')}, ${meta}, ${per('usage_ledger', lc, 'led')} FROM sessions s ORDER BY s.created_at, s.id`;
147
+ }
148
+ /**
149
+ * The store's first ledger second (the copy rule), or null when it keeps no ledger: the lowest
150
+ * rowid's (rows are inserted as calls end), read by rowid rather than by scanning the table.
151
+ */
152
+ function ledgerSince(db) {
153
+ if (!columnsOf(db, 'usage_ledger').has('created_timestamp')) return null;
154
+ const first = db.prepare('SELECT created_timestamp AS since FROM usage_ledger ORDER BY id LIMIT 1').get();
155
+ return first && first.since != null ? Number(first.since) : null;
156
+ }
157
+
158
+ /**
159
+ * Sessions in one store: one per sessions row, with its incarnation (created_at as stored). The DB
160
+ * is shared, so the entry names it relative to harness_path (`db_rel`: not stat'ed by the runner) and
161
+ * carries `key`, this session's change key, read in the same query. Nothing volatile.
162
+ */
163
+ function list(storeRoot = storeRootDefault()) {
164
+ const file = path.resolve(dbPathOf(storeRoot));
165
+ let st;
166
+ try { st = fs.statSync(file); } catch (e) {
167
+ if (e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')) return []; // no store here
168
+ throw e;
169
+ }
170
+ if (!st.isFile()) return [];
171
+ const harnessPath = harnessPathOf(storeRoot);
172
+ return withRead(file, (db) => {
173
+ if (!columnsOf(db, 'sessions').has('id')) return [];
174
+ const since = ledgerSince(db);
175
+ return db.prepare(listSql(db)).all().map((r) => ({
176
+ harness: HARNESS, harness_path: harnessPath, session_id: str(r.id), incarnation: str(r.created_at), db_rel: DB_REL,
177
+ key: {
178
+ updated_at: str(r.updated_at), messages: Number(r.mh_msg_n) || 0, max_message: num(r.mh_msg_max), meta_len: Number(r.mh_msg_meta) || 0,
179
+ ledger: Number(r.mh_led_n) || 0, max_ledger: num(r.mh_led_max), ledger_since: since,
180
+ row: sha256(JSON.stringify(rawSession(r))).slice(0, 16),
181
+ },
182
+ }));
183
+ });
184
+ }
185
+ /** The DB a list() entry (or a test's `{ db }`) names. */
186
+ const dbFileOf = (session) => session.db || path.join(session.harness_path, session.db_rel || DB_REL);
187
+
188
+ /** One session's rows, read in one read transaction: its sessions row, messages and ledger. */
189
+ function loadFromDb(session) {
190
+ return withRead(dbFileOf(session), (db) => {
191
+ const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(String(session.session_id)) || null;
192
+ let messages = [];
193
+ let ledger = [];
194
+ let since = null;
195
+ if (row) {
196
+ const mc = columnsOf(db, 'messages');
197
+ const pick = (cols, c) => (cols.has(c) ? c : `NULL AS ${c}`);
198
+ if (mc.has('content_json')) {
199
+ messages = db.prepare(`SELECT id, ${pick(mc, 'message_id')}, role, content_json, created_timestamp, ${pick(mc, 'metadata_json')}
200
+ FROM messages WHERE session_id = ? ORDER BY created_timestamp, id`).all(row.id);
201
+ }
202
+ const lc = columnsOf(db, 'usage_ledger');
203
+ if (lc.has('session_id')) {
204
+ const cols = ['model', 'input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'cost', 'cost_source', 'is_compaction'].map((c) => pick(lc, c)).join(', ');
205
+ ledger = db.prepare(`SELECT id, created_timestamp, ${cols} FROM usage_ledger WHERE session_id = ? ORDER BY created_timestamp, id`).all(row.id);
206
+ // The store's first ledger row: from then on, every call this store makes has one.
207
+ since = ledgerSince(db);
208
+ }
209
+ }
210
+ return { row, messages, ledger, ledger_since: since };
211
+ });
212
+ }
213
+
214
+ // ── small helpers ─────────────────────────────────────────────────────────────────────────────
215
+ function parseJson(v) {
216
+ if (v == null) return null;
217
+ if (typeof v === 'object' && !(v instanceof Uint8Array)) return v;
218
+ try { return JSON.parse(textOf(v)); } catch { return null; }
219
+ }
220
+ const str = (v) => (v == null ? '' : String(textOf(v)));
221
+ const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
222
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
223
+ const num = (v) => (v == null || v === '' || !Number.isFinite(Number(v)) ? null : Number(v));
224
+
225
+ // Text timestamps without a zone are UTC (SQLite CURRENT_TIMESTAMP); archived_at is RFC 3339 with ns.
226
+ const TS_RE = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?\s*(Z|[+-]\d{2}:?\d{2})?$/i;
227
+ function utcMs(v) {
228
+ if (v == null || v === '') return null;
229
+ if (typeof v === 'number') return Number.isFinite(v) && v > 0 ? (v < 1e11 ? v * 1000 : v) : null;
230
+ const s = String(textOf(v)).trim();
231
+ const m = TS_RE.exec(s);
232
+ if (!m) { const t = Date.parse(s); return Number.isFinite(t) ? t : null; }
233
+ let t = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5], +m[6], m[7] ? Math.floor(Number(`0.${m[7]}`) * 1000) : 0);
234
+ if (m[8] && m[8].toUpperCase() !== 'Z') {
235
+ const sign = m[8][0] === '-' ? -1 : 1;
236
+ const digits = m[8].replace(/[^0-9]/g, '');
237
+ t -= sign * (Number(digits.slice(0, 2)) * 60 + Number(digits.slice(2, 4))) * 60000;
238
+ }
239
+ return t;
240
+ }
241
+ const secMs = (v) => { const x = Number(v); return Number.isFinite(x) && x > 0 ? x * 1000 : null; };
242
+
243
+ // ── redaction (sessions-model §4: Goose extensions and recipe values) ─────────────────────────────
244
+ function marker(v) {
245
+ const n = typeof v === 'string' ? Buffer.byteLength(v, 'utf8') : Buffer.byteLength(JSON.stringify(v) ?? '', 'utf8');
246
+ return `<redacted len=${n}>`;
247
+ }
248
+ const isMarker = (v) => typeof v === 'string' && /^<redacted len=\d+>$/.test(v);
249
+ const hide = (v) => (v == null || isMarker(v) ? v : marker(v));
250
+ const cmp = (a, b) => (a < b ? -1 : a > b ? 1 : 0); // code-unit order: the same on every machine
251
+ const sortedKeys = (o) => Object.keys(o).sort(cmp);
252
+ /** Every value redacted, the keys kept (sorted). */
253
+ function hideValues(x) {
254
+ if (Array.isArray(x)) return x.map(hide);
255
+ if (isObj(x)) return Object.fromEntries(sortedKeys(x).map((k) => [k, hide(x[k])]));
256
+ return hide(x);
257
+ }
258
+ /** A URL cut to scheme://host (the path and query can carry a token); anything else redacted. */
259
+ function hideUrl(v) {
260
+ if (v == null || isMarker(v)) return v;
261
+ try {
262
+ const u = new URL(String(v));
263
+ if (u.host) return `${u.protocol}//${u.host}`;
264
+ } catch { /* not a URL */ }
265
+ return marker(v);
266
+ }
267
+ // Extension fields kept as stored: names, descriptions, numbers, flags and env var NAMES (env_keys).
268
+ const EXT_SAFE = new Set(['type', 'name', 'display_name', 'description', 'timeout', 'bundled', 'cwd', 'env_keys', 'available_tools', 'enabled']);
269
+ function redactExtension(e) {
270
+ if (!isObj(e)) return hide(e);
271
+ const o = {};
272
+ for (const k of Object.keys(e)) {
273
+ const x = e[k];
274
+ if (x == null) o[k] = x;
275
+ else if (k === 'envs' || k === 'headers') o[k] = hideValues(x);
276
+ else if (k === 'args') o[k] = Array.isArray(x) ? x.map(hide) : hide(x);
277
+ else if (k === 'uri' || k === 'url') o[k] = hideUrl(x);
278
+ else if (k === 'cmd') o[k] = typeof x === 'string' && !/\s/.test(x) ? x : hide(x);
279
+ else o[k] = EXT_SAFE.has(k) ? x : hide(x);
280
+ }
281
+ return o;
282
+ }
283
+ /**
284
+ * extension_data or recipe_json, redacted: extension lists (sorted by name), env/header maps anywhere,
285
+ * and in a recipe (`recipe`) sub-recipe `values` and parameter `default`s.
286
+ */
287
+ function redactExtensions(v, recipe = false, depth = 0) {
288
+ if (depth > 32) return hide(v);
289
+ if (Array.isArray(v)) return v.map((x) => redactExtensions(x, recipe, depth + 1));
290
+ if (!isObj(v)) return v;
291
+ const o = {};
292
+ for (const k of Object.keys(v)) {
293
+ const x = v[k];
294
+ if (x == null) o[k] = x;
295
+ else if (k === 'extensions' && Array.isArray(x)) {
296
+ o[k] = x.map(redactExtension).sort((a, b) => cmp(`${str(a && a.name)}\u0000${str(a && a.type)}`, `${str(b && b.name)}\u0000${str(b && b.type)}`));
297
+ } else if (k === 'envs' || k === 'headers' || (recipe && k === 'values')) o[k] = hideValues(x);
298
+ else if (recipe && k === 'default') o[k] = hide(x);
299
+ else o[k] = redactExtensions(x, recipe, depth + 1);
300
+ }
301
+ return o;
302
+ }
303
+
304
+ // The sessions row's stable columns. Left out as volatile: updated_at, the last-call token columns and
305
+ // accumulated_* (they move without the transcript changing: kept after deletes, copied on forks).
306
+ const RAW_COLS = ['id', 'name', 'description', 'user_set_name', 'session_type', 'working_dir', 'created_at', 'archived_at',
307
+ 'provider_name', 'model_config_json', 'goose_mode', 'project_id', 'parent_session_id', 'schedule_id', 'recipe_json',
308
+ 'user_recipe_values_json', 'system_prompt_override', 'system_prompt_extras_json', 'container_id', 'extension_data'];
309
+ const JSON_COLS = new Set(['model_config_json', 'recipe_json', 'user_recipe_values_json', 'system_prompt_extras_json', 'extension_data']);
310
+ const REDACTED_COLS = new Set(['extension_data', 'recipe_json', 'user_recipe_values_json']);
311
+ function rawSession(row) {
312
+ const o = {};
313
+ for (const c of RAW_COLS) {
314
+ if (!(c in row)) continue;
315
+ let v = row[c];
316
+ if (v instanceof Uint8Array) v = textOf(v);
317
+ if (JSON_COLS.has(c) && v != null) {
318
+ const p = parseJson(v);
319
+ // An unparseable column on a secret path cannot be walked: the whole value is withheld.
320
+ if (p == null) v = REDACTED_COLS.has(c) ? marker(str(v)) : str(v);
321
+ else if (c === 'user_recipe_values_json') v = isObj(p) ? hideValues(p) : hide(p);
322
+ else v = REDACTED_COLS.has(c) ? redactExtensions(p, c === 'recipe_json') : p;
323
+ }
324
+ o[c] = v;
325
+ }
326
+ return o;
327
+ }
328
+
329
+ /**
330
+ * A reference in a tool result (an MCP resource URI): a file: URI, a plain path and a Windows drive
331
+ * path stay as they are; any other URI is cut to scheme://host (its query can carry a token), or
332
+ * redacted when it has no host.
333
+ */
334
+ function safeRef(v) {
335
+ const s0 = str(v);
336
+ if (!s0 || /^file:/i.test(s0) || /^[A-Za-z]:[\\/]/.test(s0) || !/^[A-Za-z][A-Za-z0-9+.-]*:/.test(s0)) return s0;
337
+ return hideUrl(s0);
338
+ }
339
+
340
+ // ── parts ─────────────────────────────────────────────────────────────────────────────────────
341
+ function filePart(partId, mime, data) {
342
+ const bytes = typeof data === 'string' && data ? Buffer.from(data, 'base64') : null;
343
+ return { part_id: partId, kind: 'file', media_type: str(mime), att_sha: bytes ? sha256(bytes) : null, att_len: bytes ? bytes.length : null, ref: '' };
344
+ }
345
+
346
+ /** A toolResponse's result: output text, error flag, exit code and the binary items as file parts. */
347
+ function resultOf(resp, partId) {
348
+ const tr = resp && resp.toolResult;
349
+ const files = [];
350
+ if (!isObj(tr)) return { output: '', isError: false, exit: null, files };
351
+ if (tr.status === 'error') {
352
+ const e = tr.error ?? tr.value;
353
+ return { output: typeof e === 'string' ? e : (isObj(e) && typeof e.message === 'string' ? e.message : JSON.stringify(e ?? null)), isError: true, exit: null, files };
354
+ }
355
+ const v = tr.value;
356
+ const content = Array.isArray(v) ? v : (isObj(v) && Array.isArray(v.content) ? v.content : []);
357
+ const text = [];
358
+ content.forEach((c, k) => {
359
+ if (!isObj(c)) return;
360
+ if (c.type === 'text') text.push(str(c.text));
361
+ else if (c.type === 'image' || c.type === 'audio') files.push(filePart(`${partId}:${c.type}:${k}`, c.mimeType, c.data));
362
+ else if (c.type === 'resource_link') text.push(`[resource_link ${safeRef(c.uri)}]`);
363
+ else if (c.type === 'resource') {
364
+ const r = isObj(c.resource) ? c.resource : {};
365
+ if (typeof r.text === 'string') text.push(r.text);
366
+ else if (typeof r.blob === 'string') files.push({ ...filePart(`${partId}:resource:${k}`, r.mimeType, r.blob), ref: safeRef(r.uri) });
367
+ else text.push(`[resource ${safeRef(r.uri)}]`);
368
+ } else text.push(`[${str(c.type)}]`);
369
+ });
370
+ const sc = isObj(v) && isObj(v.structuredContent) ? v.structuredContent : null;
371
+ const exit = sc && Number.isFinite(sc.exit_code) ? sc.exit_code : null;
372
+ return { output: text.join('\n'), isError: !!(isObj(v) && v.isError === true), exit, files };
373
+ }
374
+
375
+ const DECLINED = /^The user has declined to run this tool/;
376
+ const INTERRUPTED = /^Tool call was interrupted before completing/;
377
+ function toolStatus(res, permission) {
378
+ if (!res) return 'interrupted';
379
+ if (INTERRUPTED.test(res.output)) return 'cancelled';
380
+ if (permission === 'cancel') return 'cancelled';
381
+ if (permission === 'deny_once' || permission === 'always_deny' || DECLINED.test(res.output)) return 'denied';
382
+ if (res.isError && res.output === '(no output)' && res.exit === null) return 'cancelled'; // Ctrl-C during a shell call
383
+ return 'completed';
384
+ }
385
+
386
+ function actionText(d) {
387
+ if (!isObj(d)) return '[actionRequired]';
388
+ const t = str(d.actionType);
389
+ switch (t) {
390
+ case 'toolConfirmation': return `[toolConfirmation] ${str(d.toolName)} ${JSON.stringify(d.arguments ?? {})}${d.prompt ? `\n${str(d.prompt)}` : ''}`;
391
+ case 'toolConfirmationResponse': return `[toolConfirmationResponse] ${str(d.permission)}`;
392
+ case 'elicitation': return `[elicitation] ${str(d.message)}${d.requested_schema ? `\n${JSON.stringify(d.requested_schema)}` : ''}`;
393
+ case 'elicitationResponse': return `[elicitationResponse] ${str(d.action)} ${JSON.stringify(d.user_data ?? null)}`;
394
+ default: return `[${t || 'actionRequired'}]`;
395
+ }
396
+ }
397
+
398
+ function ledgerUsage(l) {
399
+ return { basis: 'unknown', input: num(l.input_tokens), output: num(l.output_tokens), cache_read: num(l.cache_read_tokens), cache_write: num(l.cache_write_tokens), reasoning: null, cost: num(l.cost) };
400
+ }
401
+ function metaUsage(u) {
402
+ if (!isObj(u)) return null;
403
+ return { basis: 'unknown', input: num(u.inputTokens), output: num(u.outputTokens), cache_read: num(u.cacheReadTokens), cache_write: num(u.cacheWriteTokens), reasoning: null, cost: num(u.cost) };
404
+ }
405
+
406
+ // ── normalise ─────────────────────────────────────────────────────────────────────────────────
407
+ /** A message row's JSON column. A value that does not parse is a damaged read and throws: read as
408
+ * null it would become an empty message (content_json) or a visible one (metadata_json), and the
409
+ * module contract allows never fewer records than the session holds. */
410
+ function rowJson(v, col) {
411
+ if (v == null) return null;
412
+ if (typeof v === 'object' && !(v instanceof Uint8Array)) return v;
413
+ try { return JSON.parse(textOf(v)); } catch { throw new Error(`goose: damaged read: messages.${col} does not parse`); }
414
+ }
415
+ function prep(r) {
416
+ const content = rowJson(r.content_json, 'content_json');
417
+ const meta = rowJson(r.metadata_json, 'metadata_json');
418
+ const m = isObj(meta) ? meta : {};
419
+ return {
420
+ rowid: Number(r.id) || 0, mid: r.message_id == null || r.message_id === '' ? null : str(r.message_id),
421
+ role: str(r.role), cts: Number(r.created_timestamp) || 0, ts: secMs(r.created_timestamp),
422
+ parts: Array.isArray(content) ? content : [], meta: m, U: m.userVisible !== false, A: m.agentVisible !== false,
423
+ };
424
+ }
425
+ const only = (r, types) => r.parts.length > 0 && r.parts.every((p) => isObj(p) && types.includes(p.type));
426
+ const firstText = (r) => { const p = r.parts.find((x) => isObj(x) && x.type === 'text'); return p ? str(p.text) : ''; };
427
+
428
+ /**
429
+ * The compaction summary: role user, hidden from the user, a provider id (or the summary heading).
430
+ * Not keyed on agentVisible: a later compaction flips an earlier summary to false in place.
431
+ */
432
+ function isSummary(r) {
433
+ if (r.role !== 'user' || r.U || r.meta.turnContext || r.meta.steer || !only(r, ['text'])) return false;
434
+ return (!!r.mid && !/^(msg_|steer_)/.test(r.mid)) || /^# Conversation Summary\b/.test(firstText(r));
435
+ }
436
+ /** A prompt the user typed (or goose sent as the user's turn): it ends whatever turn came before. */
437
+ function isPrompt(r) {
438
+ return r.role === 'user' && r.U && !r.meta.steer && !r.meta.turnContext && only(r, ['text', 'image']);
439
+ }
440
+ function deliveredOf(meta) {
441
+ const ops = isObj(meta.operations) ? meta.operations : {};
442
+ const fs0 = isObj(ops.foreground_subagent) ? ops.foreground_subagent.delivered : null;
443
+ return (Array.isArray(fs0) ? fs0 : (fs0 == null ? [] : [fs0])).map(str).filter(Boolean);
444
+ }
445
+
446
+ /**
447
+ * Normalise one session. `session` is a list() entry, or `{ session_id, row, messages, ledger,
448
+ * ledger_since? }` already loaded (tests): `row` is the sessions row, `messages` the messages rows,
449
+ * `ledger` the session's usage_ledger rows and `ledger_since` the store's first ledger second (null:
450
+ * the store keeps no ledger; default: the session's own first ledger row). JSON columns may be text
451
+ * or parsed. Returns the log records, the session record first; null when the store holds no
452
+ * sessions row for the id.
453
+ */
454
+ function normalise(session) {
455
+ const loaded = (session.row !== undefined || Array.isArray(session.messages))
456
+ ? {
457
+ row: session.row || null, messages: session.messages || [], ledger: session.ledger || [],
458
+ ledger_since: session.ledger_since !== undefined ? session.ledger_since
459
+ : ((session.ledger || []).reduce((a, l) => (a == null || Number(l.created_timestamp) < a ? Number(l.created_timestamp) : a), null)),
460
+ }
461
+ : loadFromDb(session);
462
+ const row = loaded.row;
463
+ if (!row) return null;
464
+ const kind = str(row.session_type);
465
+ const mc = parseJson(row.model_config_json);
466
+ const rp = isObj(mc) && isObj(mc.request_params) ? mc.request_params : {};
467
+ const effort = rp.thinking_effort == null ? '' : str(rp.thinking_effort);
468
+
469
+ const rows = loaded.messages.map(prep).sort((a, b) => (a.cts - b.cts) || (a.rowid - b.rowid));
470
+ const key = (i, j) => `${i}:${j}`;
471
+
472
+ // Results and confirmations by call id; each call claims the first unclaimed result after it.
473
+ const responses = new Map();
474
+ const permission = new Map();
475
+ rows.forEach((r, i) => r.parts.forEach((p, j) => {
476
+ if (!isObj(p)) return;
477
+ if (p.type === 'toolResponse' && p.id != null) {
478
+ const id = str(p.id);
479
+ if (!responses.has(id)) responses.set(id, []);
480
+ responses.get(id).push({ i, j, part: p });
481
+ } else if (p.type === 'actionRequired' && isObj(p.data) && p.data.actionType === 'toolConfirmationResponse' && p.data.id != null) {
482
+ permission.set(str(p.data.id), str(p.data.permission));
483
+ }
484
+ }));
485
+ const claim = new Map();
486
+ const claimed = new Set();
487
+ rows.forEach((r, i) => r.parts.forEach((p, j) => {
488
+ if (!isObj(p) || p.type !== 'toolRequest' || p.id == null) return;
489
+ const cands = responses.get(str(p.id)) || [];
490
+ const hit = cands.find((c) => c.i > i && !claimed.has(key(c.i, c.j))) || cands.find((c) => !claimed.has(key(c.i, c.j)));
491
+ if (hit) { claimed.add(key(hit.i, hit.j)); claim.set(key(i, j), hit); }
492
+ }));
493
+
494
+ // Whether a user prompt follows each row: the turn it belongs to has ended.
495
+ const promptAfter = new Array(rows.length).fill(false);
496
+ for (let i = rows.length - 1, seen = false; i >= 0; i--) { promptAfter[i] = seen; if (isPrompt(rows[i])) seen = true; }
497
+
498
+ // Ledger rows → the message of the same call.
499
+ const ledger = loaded.ledger.map((l) => ({ ...l, lid: Number(l.id) || 0, lts: Number(l.created_timestamp) || 0 }))
500
+ .sort((a, b) => (a.lts - b.lts) || (a.lid - b.lid));
501
+ const used = new Set();
502
+ const linked = new Map();
503
+ const same = (a, b) => (a == null && b == null) || (a != null && b != null && Number(a) === Number(b));
504
+ const fits = (a, b) => a == null || b == null || Number(a) === Number(b);
505
+ // How long after a row's time its call's ledger row can be written (the row is the first token).
506
+ const spanOf = (u) => { const el = Number(u.elapsedMs); return Number.isFinite(el) && el >= 0 ? Math.ceil(el / 1000) + 2 : 900; };
507
+ rows.forEach((r, i) => {
508
+ const u = r.meta.usage;
509
+ if (r.role !== 'assistant' || !isObj(u)) return;
510
+ const span = spanOf(u);
511
+ const hit = ledger.find((l) => !used.has(l.lid) && !Number(l.is_compaction) && same(l.input_tokens, u.inputTokens) && same(l.output_tokens, u.outputTokens)
512
+ && fits(l.cache_read_tokens, u.cacheReadTokens) && l.lts >= r.cts - 1 && l.lts <= r.cts + span);
513
+ if (hit) { used.add(hit.lid); linked.set(i, hit); }
514
+ });
515
+ const since = loaded.ledger_since == null || !Number.isFinite(Number(loaded.ledger_since)) ? null : Number(loaded.ledger_since);
516
+ // A call whose ledger row would fall before the store's first one predates the ledger (older goose).
517
+ const copied = (r, i) => since !== null && r.role === 'assistant' && isObj(r.meta.usage) && !linked.has(i) && r.cts + spanOf(r.meta.usage) >= since;
518
+
519
+ const out = [];
520
+ const children = [];
521
+ const seenIds = new Map();
522
+ let anchor = '';
523
+ let anchorN = 0;
524
+ let lastId = '';
525
+ let heldTs = null; // the time of the first held-back row: nothing at or after it is written yet
526
+ for (let i = 0; i < rows.length; i++) {
527
+ const r = rows[i];
528
+ const reqs = r.parts.map((p, j) => (isObj(p) && p.type === 'toolRequest' ? j : -1)).filter((j) => j >= 0);
529
+ const pending = reqs.some((j) => !claim.has(key(i, j)));
530
+ if (pending && !promptAfter[i]) { heldTs = r.ts ?? 0; break; } // a tool still running: wait
531
+
532
+ // The id: native; anchored on the previous native id where NULL or repeated.
533
+ let id;
534
+ if (r.mid) {
535
+ const n = seenIds.get(r.mid) || 0;
536
+ seenIds.set(r.mid, n + 1);
537
+ id = n ? `${r.mid}+${n}` : r.mid;
538
+ anchor = r.mid; anchorN = 0;
539
+ } else id = `${anchor || '@start'}+${++anchorN}`;
540
+
541
+ // A result row whose every part was folded into its call is not a message of its own.
542
+ if (r.parts.length && r.parts.every((p, j) => isObj(p) && p.type === 'toolResponse' && claimed.has(key(i, j)))) continue;
543
+
544
+ const summary = isSummary(r);
545
+ const parts = [];
546
+ let hasError = false;
547
+ r.parts.forEach((p, j) => {
548
+ if (!isObj(p)) return;
549
+ const pid = String(j);
550
+ switch (p.type) {
551
+ case 'text':
552
+ if (summary) parts.push({ part_id: pid, kind: 'summary', text: str(p.text), replaces: { flags: { agentVisible: false } } });
553
+ else parts.push({ part_id: pid, kind: 'text', text: str(p.text) });
554
+ break;
555
+ case 'image': parts.push(filePart(pid, p.mimeType, p.data)); break;
556
+ case 'thinking': {
557
+ const t = str(p.thinking);
558
+ if (t) parts.push({ part_id: pid, kind: 'reasoning', text: t });
559
+ else if (p.signature) parts.push({ part_id: pid, kind: 'reasoning', text: '', opaque: true });
560
+ break;
561
+ }
562
+ case 'redactedThinking': parts.push({ part_id: pid, kind: 'reasoning', text: '', opaque: true }); break;
563
+ case 'toolRequest': {
564
+ const tc = isObj(p.toolCall) ? p.toolCall : {};
565
+ const v = isObj(tc.value) ? tc.value : {};
566
+ const meta = isObj(p._meta) ? p._meta : {};
567
+ const hit = claim.get(key(i, j));
568
+ const res = hit ? resultOf(hit.part, pid) : null;
569
+ const callId = str(p.id);
570
+ const bad = tc.status === 'error';
571
+ parts.push({
572
+ part_id: pid, kind: 'tool', call_id: callId, tool: str(v.name) || 'unknown',
573
+ args: isObj(v.arguments) || Array.isArray(v.arguments) ? v.arguments : (v.arguments == null ? {} : { raw: v.arguments }),
574
+ output: res ? res.output : (bad ? str(isObj(tc.error) ? tc.error.message : tc.error) : null),
575
+ is_error: res ? res.isError : bad,
576
+ ...(res && res.exit !== null ? { exit_code: res.exit } : {}),
577
+ status: toolStatus(res, permission.get(callId)),
578
+ ...(meta.goose_extension ? { server: str(meta.goose_extension) } : {}),
579
+ ...(hit && rows[hit.i].mid ? { content_id: rows[hit.i].mid } : {}),
580
+ ts: hit ? rows[hit.i].ts : r.ts,
581
+ });
582
+ if (res) for (const f of res.files) parts.push({ ...f, ts: rows[hit.i].ts });
583
+ break;
584
+ }
585
+ case 'toolResponse': {
586
+ if (claimed.has(key(i, j))) break;
587
+ const res = resultOf(p, pid); // a result no call in this session claims
588
+ parts.push({ part_id: pid, kind: 'text', text: res.output });
589
+ parts.push(...res.files);
590
+ break;
591
+ }
592
+ case 'actionRequired': parts.push({ part_id: pid, kind: 'notice', text: actionText(p.data) }); break;
593
+ case 'error': hasError = true; parts.push({ part_id: pid, kind: 'error', text: str(p.message) }); break;
594
+ case 'systemNotification': parts.push({ part_id: pid, kind: 'notice', text: str(p.msg) }); break;
595
+ default: parts.push({ part_id: pid, kind: 'notice', text: `[${str(p.type)}]${typeof p.text === 'string' ? ` ${p.text}` : ''}` });
596
+ }
597
+ });
598
+
599
+ let role = r.role === 'assistant' ? 'assistant' : 'user';
600
+ if (summary) role = 'system';
601
+ else if (r.role === 'user' && only(r, ['toolResponse'])) role = 'tool';
602
+
603
+ let source;
604
+ if (r.meta.turnContext) source = 'injected';
605
+ else if (!summary && !r.U && r.role === 'assistant' && !r.meta.inference && only(r, ['text'])) source = 'injected'; // compaction continuation
606
+ else if (!summary && !r.U && r.role === 'user' && deliveredOf(r.meta).length) source = 'injected'; // sub-agent completion
607
+ else if (!summary && !r.U && r.role === 'user' && only(r, ['text'])) source = 'automated'; // goal nudge, compaction replay
608
+ else if (r.U && isPrompt(r) && (kind === 'sub_agent' || kind === 'scheduled')) source = 'automated';
609
+
610
+ const inf = isObj(r.meta.inference) ? r.meta.inference : null;
611
+ const l = linked.get(i) || null;
612
+ let status = 'complete';
613
+ if (hasError) status = 'error';
614
+ else if (pending) status = 'interrupted';
615
+ else if (r.role === 'assistant' && (r.meta.outputTokenLimitReached || (inf && !isObj(r.meta.usage) && !l))) status = 'interrupted';
616
+
617
+ const m = { k: 'message', message_id: id, role, ts: r.ts, order: i, state: 'current', status };
618
+ if (!r.U) m.hidden = true;
619
+ if (!r.A) m.model_visible = false;
620
+ if (source) m.source = source;
621
+ const copy = copied(r, i);
622
+ if (role === 'assistant') {
623
+ m.model = (l && str(l.model)) || (inf && (str(inf.resolvedModel) || str(inf.requestedModel))) || '';
624
+ if (inf && inf.requestedModel) m.model_requested = str(inf.requestedModel);
625
+ m.effort = inf ? effort : '';
626
+ m.usage = l ? ledgerUsage(l) : (copy ? null : metaUsage(r.meta.usage));
627
+ }
628
+ m.parts = parts;
629
+ if (copy) m.raw = { metadata: { usage: r.meta.usage } }; // a copied call: billed where it was made
630
+
631
+ if (summary) out.push({ k: 'lifecycle', event: 'compaction', at: r.ts, ...(lastId ? { through: lastId } : {}) });
632
+ out.push(m);
633
+ lastId = id;
634
+ for (const c of deliveredOf(r.meta)) if (!children.some((x) => x.session_id === c)) children.push({ kind: 'subagent', session_id: c, evidence: 'explicit', after: id });
635
+ }
636
+
637
+ // Records with a time of their own go in by that time, after every record at or before it; one that
638
+ // would land at or after a held-back row waits with it.
639
+ const timeOf = (x) => Number(x.k === 'message' || x.k === 'usage' ? x.ts : x.at) || 0;
640
+ const place = (rec, t) => {
641
+ const idx = out.findIndex((x) => timeOf(x) > t);
642
+ if (idx >= 0) out.splice(idx, 0, rec);
643
+ else if (heldTs === null || t < heldTs) out.push(rec);
644
+ };
645
+ // Ledger rows no message carries: compaction calls, and calls whose messages are gone.
646
+ for (const l of ledger) {
647
+ if (used.has(l.lid)) continue;
648
+ const t = l.lts * 1000;
649
+ place({
650
+ k: 'usage', usage_id: str(l.id), ts: t, ...(l.model ? { model: str(l.model) } : {}),
651
+ ...(Number(l.is_compaction) ? { reason: 'compaction' } : {}), usage: ledgerUsage(l),
652
+ }, t);
653
+ }
654
+ // Archive: the session record says when; a lifecycle record sits at that time among the messages.
655
+ const archivedRaw = row.archived_at == null || row.archived_at === '' ? null : row.archived_at;
656
+ const archived = archivedRaw == null ? null : (utcMs(archivedRaw) ?? true);
657
+ if (typeof archived === 'number') place({ k: 'lifecycle', event: 'archive', at: archived }, archived);
658
+
659
+ const parent = str(row.parent_session_id);
660
+ const head = {
661
+ k: 'session',
662
+ title: str(row.name), cwd: str(row.working_dir),
663
+ created: utcMs(row.created_at), updated: null, archived,
664
+ version: '', incarnation: str(row.created_at),
665
+ kind, mode: str(row.goose_mode),
666
+ links: parent ? [{ kind: 'subagent', origin: parent, evidence: 'explicit', at: '' }] : [],
667
+ ...(children.length ? { children } : {}),
668
+ raw: rawSession(row),
669
+ };
670
+ return placeChildren([head, ...out]);
671
+ }
672
+
673
+ module.exports = { HARNESS, list, listSql, normalise, storeRootDefault, dbPathOf, harnessPathOf, redactExtensions, utcMs };