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,575 @@
1
+ 'use strict';
2
+ // v4 wave 2: Devin Local → normalised session log records (docs/design/sessions-log.md).
3
+ //
4
+ // Devin Local is the Rust CLI (`devin acp`) that Devin Desktop (the Windsurf app) runs over ACP. It
5
+ // keeps its sessions in ~/.local/share/devin/cli/sessions.db: a second store of harness `devin`, with
6
+ // harness_path = resolveNative(<home>/.local/share/devin/cli). dataFolder('devin') is Devin Desktop's
7
+ // app-support folder, which stays on the 0.18 bridge as a separate store of the same harness.
8
+ // Spec: the Windsurf / Devin section of docs/design/editor-shapes.md (repository),
9
+ // fixtures misc/fixtures/editors/windsurf-devin/; sessions-model v0 §1, §2, §4 and its annex (devin).
10
+ // The tree rules below were checked read-only against a lab store (5 sessions, 1,028 nodes).
11
+ //
12
+ // Store (SQLite, WAL):
13
+ // - sessions(id = a two-word slug, working_directory, title, created_at (s), main_chain_id, hidden);
14
+ // - message_nodes(node_id, parent_node_id, chat_message JSON, metadata JSON): a node tree.
15
+ // chat_message = {message_id, role, content, tool_calls[{id, name, arguments}], tool_call_id,
16
+ // thinking{thinking, signature}, images[], metadata{created_at (RFC 3339), generation_model,
17
+ // finish_reason, metrics, extensions, telemetry}};
18
+ // - tool_call_state(tool_call_id, tool_call_update_json): the final ACP status of each call.
19
+ //
20
+ // Rules:
21
+ // - every turn deletes and re-inserts all of the session's nodes, so row_id and the row's created_at
22
+ // are useless. A message is chat_message.message_id; its ts is chat_message.metadata.created_at;
23
+ // - the current line is the chain from sessions.main_chain_id up the parent_node_id pointers. Each
24
+ // message_id is emitted once: streaming twins (two sibling nodes, the chain through the second) and
25
+ // prefix copies (`compact/prior_node_ids`: the chain re-rooted under a new system prefix, at the
26
+ // first turns and on every mode switch) repeat it. Order is the walk, root first;
27
+ // - left out: the system prefix (node metadata `is_system_prefix`: the system prompt, the sub-agent
28
+ // profiles, the Ask/Plan mode prompts), cache keepalive "continue" nodes (telemetry source
29
+ // cache_keepalive), and tool nodes, which are folded into their call;
30
+ // - injected context on the line (system_info, rules, skills, editor context, mode transitions,
31
+ // user-edit reports, the compaction continuation) is a system message, source 'injected', hidden.
32
+ // "[Response interrupted by user]" is a visible notice, and the reply before it is `interrupted`;
33
+ // - tool parts: call_id = tool_calls[].id = the tool node's tool_call_id. The tool node's content is
34
+ // the output; its chisel/tool_result_meta, chisel/tool_failure and the tool_call_state update give
35
+ // the status: rejected → denied, canceled → cancelled, failed → error, else completed (a non-zero
36
+ // exit stays completed, with is_error false and exit_code from chisel/terminal_output);
37
+ // - sub-agents live in the same tree: the parent's tool result names subagent/agent_id and
38
+ // subagent/chain_node_id. Their chain follows the calling message, with agent_id set; the task
39
+ // prompt is source 'automated';
40
+ // - compaction: a node on the line with `summarized_from: X` continues a compacted history. The chain
41
+ // ending at X comes first (so the log only appends), then a compaction lifecycle record, then the
42
+ // compactor's reply (the assistant node with summarized_from X, kept off the line) as a summary
43
+ // part replacing through X's message. The continuation node stays as injected context;
44
+ // - revert moves main_chain_id back and the abandoned branch stays in the table. Only the line is
45
+ // emitted: messages that leave it vanish (the engine keeps them as removed). Each abandoned subtree
46
+ // still in the store (an edit is a revert plus a new branch) gives a `rewind` lifecycle record
47
+ // after the message it hangs off: message_ids = its messages, through = that message, at = null
48
+ // (the store keeps no revert time);
49
+ // - held back: the tail of the line while a tool call has no result, and a tail reply with no
50
+ // finish_reason (streaming). A later node releases it; a call that never got a result is
51
+ // `interrupted`;
52
+ // - usage: chat_message.metadata.metrics per reply, basis unknown (the fixtures fit net, nothing
53
+ // establishes it). Model: generation_model per message. No effort, no cost. The session-level
54
+ // sessions.metadata total_credit_cost / total_acu_cost are running totals the turn path writes
55
+ // (a session saved before its first turn has none; seen on a test machine), so they would change the session
56
+ // record every turn: no usage_total;
57
+ // - user text: `content` is what the model saw (a slash skill's expanded body, an image placeholder
58
+ // before the prompt). What the user typed, kept apart in chisel/acp-content-blocks, is the text
59
+ // part's display_text when it differs. An image's model-written caption is the file part's caption;
60
+ // - sessions: hidden=1 (the summary agent a fork spawns, cwd /) gets kind 'hidden', and its prompts
61
+ // are source 'automated'. Slugs: reuse is undetectable, incarnation ''. A fork copies the chain with
62
+ // the same ids and names no origin, so there is no explicit link. Archive state lives in Devin
63
+ // Desktop's globalStorage, not here: archived null. agent_mode, last_activity_at: volatile, left out;
64
+ // - redaction by field path: only the projection above is read. tool_call_state.tool_call_json
65
+ // (rawInput), sessions.cogs_json and metadata, chisel/undo (original file content) and
66
+ // chisel/conversation_history are never selected or emitted; image bytes become a sha and a
67
+ // length; a reasoning signature becomes signature_len. No auth store is opened;
68
+ // - list(): one entry per sessions row. The DB is shared by every session, so the entry names it
69
+ // relative to harness_path (db_rel) and carries a per-session change key read in the list query:
70
+ // main_chain_id (moves on every save that grows the line, and back on a revert), max(node_id) and
71
+ // the node count (every new node: a turn, a tool result, a mode-switch copy, a compactor or
72
+ // sub-agent chain; nodes keep their ids when re-inserted), count(*) and max(rowid) of the session's
73
+ // tool_call_state and subagent_heads rows, hidden, and a hash of the title (a rename or
74
+ // auto-retitle changes the session record). Nothing volatile (last_activity_at, the
75
+ // credit totals). normalise() is deterministic and works from the entry alone;
76
+ // - reading: openReadOnly (editors/sqlite.js), which never creates -wal/-shm, then one read
77
+ // transaction for a consistent snapshot. A pointer that dangles (in the main chain, a compacted
78
+ // history or a sub-agent chain) means a torn read: normalise returns null and the pass skips. Every other error throws (module contract): a busy or locked
79
+ // store, a damaged page, a JSON column that does not parse; list() throwing keeps the runner's
80
+ // index. Only a missing table or column reads as empty.
81
+
82
+ const fs = require('fs');
83
+ const os = require('os');
84
+ const path = require('path');
85
+ const crypto = require('crypto');
86
+ const { openReadOnly, textOf } = require('../sqlite');
87
+ const harnessPaths = require('../harness-paths');
88
+
89
+ const HARNESS = 'devin';
90
+ const DB_FILE = 'sessions.db';
91
+ const INTERRUPTED = /^\s*\[Response interrupted by user\]\s*$/;
92
+
93
+ /** Devin Local's store folder. Not dataFolder('devin'): that is Devin Desktop's. */
94
+ function storeRootDefault(home = os.homedir()) {
95
+ return path.join(home, '.local', 'share', 'devin', 'cli');
96
+ }
97
+
98
+ // The per-session change key, in the list query itself; every subquery SEARCHes a session index
99
+ // (the test checks the plan). Every change normalise() can emit moves one of these columns:
100
+ // message_nodes for the conversation, tool_call_state for a call's final status (count, max(rowid),
101
+ // and the bytes of the final updates, which an in-place UPDATE changes), subagent_heads for a
102
+ // sub-agent's head. A table an older store lacks reads as null.
103
+ function listSql(tables) {
104
+ const agg = (t, alias) => (tables.has(t)
105
+ ? `(SELECT count(*) FROM ${t} x WHERE x.session_id = s.id) AS ${alias}_count, (SELECT max(x.rowid) FROM ${t} x WHERE x.session_id = s.id) AS ${alias}_max_rowid`
106
+ : `NULL AS ${alias}_count, NULL AS ${alias}_max_rowid`);
107
+ const bytes = tables.has('tool_call_state')
108
+ ? '(SELECT sum(octet_length(x.tool_call_update_json)) FROM tool_call_state x WHERE x.session_id = s.id) AS tool_state_bytes'
109
+ : 'NULL AS tool_state_bytes';
110
+ return `SELECT s.id, s.title, s.hidden, s.main_chain_id,
111
+ (SELECT max(m.node_id) FROM message_nodes m WHERE m.session_id = s.id) AS max_node_id,
112
+ (SELECT count(*) FROM message_nodes m WHERE m.session_id = s.id) AS node_count,
113
+ ${agg('tool_call_state', 'tool_state')}, ${bytes},
114
+ ${agg('subagent_heads', 'subagent_head')}
115
+ FROM sessions s ORDER BY s.created_at, s.id`;
116
+ }
117
+ const intOrNull = (v) => (v == null ? null : Number(v));
118
+ /** A store older than a table or column: the one error that may read as empty. */
119
+ const isMissing = (e) => /no such (table|column)/i.test(String((e && e.message) || e));
120
+
121
+ /**
122
+ * Sessions in one store: every row of sessions.db's sessions table, hidden ones included. The DB is
123
+ * shared, so an entry names it relative to harness_path (a relative string is never stat'ed) and
124
+ * carries its own change key; a write to another session leaves this entry unchanged.
125
+ */
126
+ function list(storeRoot = storeRootDefault()) {
127
+ const harnessPath = harnessPaths.resolveNative(storeRoot);
128
+ const file = path.join(harnessPath, DB_FILE);
129
+ try { if (!fs.statSync(file).isFile()) return []; } catch { return []; } // no store
130
+ // A store that is busy or locked mid-save must not read as "no sessions": an open or a query that
131
+ // fails throws, and the runner keeps its index. Only a missing table or column reads as empty.
132
+ const db = openReadOnly(file);
133
+ try {
134
+ const tables = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all().map((t) => String(t.name)));
135
+ return db.prepare(listSql(tables)).all().map((r) => ({
136
+ harness: HARNESS, harness_path: harnessPath, session_id: String(r.id), db_rel: DB_FILE,
137
+ main_chain_id: intOrNull(r.main_chain_id), max_node_id: intOrNull(r.max_node_id), node_count: Number(r.node_count) || 0,
138
+ tool_state_count: intOrNull(r.tool_state_count), tool_state_max_rowid: intOrNull(r.tool_state_max_rowid), tool_state_bytes: intOrNull(r.tool_state_bytes),
139
+ subagent_head_count: intOrNull(r.subagent_head_count), subagent_head_max_rowid: intOrNull(r.subagent_head_max_rowid),
140
+ hidden: Number(r.hidden) === 1, title_sha: sha256(String(r.title ?? '')).slice(0, 16),
141
+ }));
142
+ } catch (e) {
143
+ if (isMissing(e)) return [];
144
+ throw e;
145
+ } finally { db.close(); }
146
+ }
147
+
148
+ /**
149
+ * Normalise one session. `session` is a list() entry, or `{ session_id, session, nodes, tool_states,
150
+ * subagent_heads }` already loaded (tests): `session` the sessions row, `nodes` message_nodes rows,
151
+ * `tool_states` tool_call_state rows (JSON columns as strings or objects). Returns the log records;
152
+ * null when the session is gone or a pointer dangles (a torn read); throws on every other failure.
153
+ */
154
+ function normalise(session) {
155
+ const sid = String(session.session_id);
156
+ if (Array.isArray(session.nodes)) return build(sid, session.session || null, session.nodes, session.tool_states || [], session.subagent_heads || []);
157
+ const file = path.join(String(session.harness_path || storeRootDefault()), String(session.db_rel || DB_FILE));
158
+ // Every error but a missing table propagates (module contract, docs/design/sessions-log.md): a busy
159
+ // or locked store, a damaged page. A throw shows in the runner's errors and is retried.
160
+ const db = openReadOnly(file);
161
+ let row;
162
+ let nodes;
163
+ let states = [];
164
+ let heads = [];
165
+ try {
166
+ db.exec('BEGIN');
167
+ row = db.prepare('SELECT id, working_directory, title, created_at, main_chain_id, hidden FROM sessions WHERE id = ?').get(sid);
168
+ if (!row) return null;
169
+ nodes = db.prepare('SELECT node_id, parent_node_id, chat_message, metadata FROM message_nodes WHERE session_id = ? ORDER BY node_id').all(sid);
170
+ // Only an older store's missing table reads as empty; anything else (busy, locked) fails the
171
+ // whole read below, so a session is never written with fewer statuses or sub-agents than it has.
172
+ try { states = db.prepare('SELECT tool_call_id, tool_call_update_json FROM tool_call_state WHERE session_id = ?').all(sid); } catch (e) { if (!isMissing(e)) throw e; states = []; }
173
+ try { heads = db.prepare('SELECT agent_id, chain_node_id FROM subagent_heads WHERE session_id = ?').all(sid); } catch (e) { if (!isMissing(e)) throw e; heads = []; }
174
+ } catch (e) {
175
+ if (isMissing(e)) return null;
176
+ throw e;
177
+ } finally {
178
+ try { db.exec('COMMIT'); } catch { /* nothing to end */ }
179
+ db.close();
180
+ }
181
+ return build(sid, row, nodes, states, heads);
182
+ }
183
+
184
+ // ── field helpers ─────────────────────────────────────────────────────────────────────────────
185
+ /**
186
+ * A stored JSON column. Devin writes them with serde, so one that does not parse is a damaged read
187
+ * (a page overwritten in place): it throws rather than drop a node, which would emit fewer messages.
188
+ */
189
+ function jsonCol(v, what, { nullable = true } = {}) {
190
+ if (v == null) { if (nullable) return null; throw new Error(`devin: ${what} is missing`); }
191
+ if (typeof v === 'object' && !(v instanceof Uint8Array)) return v;
192
+ let o;
193
+ try { o = JSON.parse(textOf(v)); } catch { throw new Error(`devin: ${what} does not parse (a damaged read)`); }
194
+ if (o != null && typeof o !== 'object') throw new Error(`devin: ${what} is not an object (a damaged read)`);
195
+ if (o == null && !nullable) throw new Error(`devin: ${what} is null`);
196
+ return o;
197
+ }
198
+ const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : (typeof v === 'bigint' ? Number(v) : null));
199
+ function ms(iso) {
200
+ if (typeof iso !== 'string' || !iso) return null;
201
+ let t = Date.parse(iso);
202
+ if (!Number.isFinite(t)) t = Date.parse(iso.replace(/(\.\d{3})\d+/, '$1')); // nanosecond fractions
203
+ return Number.isFinite(t) ? t : null;
204
+ }
205
+ /** sessions.created_at is in seconds. */
206
+ function epochMs(v) {
207
+ const n = num(typeof v === 'string' ? Number(v) : v);
208
+ if (n == null || n <= 0) return null;
209
+ return n < 1e11 ? n * 1000 : n;
210
+ }
211
+ const sha256 = (b) => crypto.createHash('sha256').update(b).digest('hex');
212
+ function textOfContent(c) {
213
+ if (c == null) return '';
214
+ if (typeof c === 'string') return c;
215
+ if (Array.isArray(c)) return c.map((b) => (typeof b === 'string' ? b : (b && typeof b.text === 'string' ? b.text : ''))).join('');
216
+ return '';
217
+ }
218
+ function argsOf(a) {
219
+ if (a == null) return {};
220
+ if (typeof a === 'string') { try { return JSON.parse(a); } catch { return { raw: a }; } }
221
+ return a;
222
+ }
223
+
224
+ function toNode(r) {
225
+ if (r.node_id == null) throw new Error('devin: a node without node_id (a damaged read)');
226
+ const cm = jsonCol(r.chat_message, `chat_message of node ${r.node_id}`, { nullable: false });
227
+ const md = cm.metadata && typeof cm.metadata === 'object' ? cm.metadata : {};
228
+ return {
229
+ id: Number(r.node_id),
230
+ parent: r.parent_node_id == null ? null : Number(r.parent_node_id),
231
+ mid: cm.message_id == null ? '' : String(cm.message_id),
232
+ role: String(cm.role || ''),
233
+ cm,
234
+ md,
235
+ ext: md.extensions && typeof md.extensions === 'object' ? md.extensions : {},
236
+ nm: jsonCol(r.metadata, `metadata of node ${r.node_id}`) || {},
237
+ };
238
+ }
239
+ const isPrefix = (n) => n.nm.is_system_prefix === true;
240
+ const isKeepalive = (n) => (n.md.telemetry || {}).source === 'cache_keepalive' || n.ext['barista/prompt_cache_footer'] === true;
241
+ const sumFrom = (n) => (n.nm.summarized_from == null ? null : Number(n.nm.summarized_from));
242
+ const isContent = (n) => n.role !== 'tool' && !isPrefix(n) && !isKeepalive(n);
243
+ const isInterrupt = (n) => n.role === 'system' && INTERRUPTED.test(textOfContent(n.cm.content));
244
+
245
+ function imageParts(images) {
246
+ return (Array.isArray(images) ? images : []).map((im, i) => {
247
+ const b64 = im && typeof im.base64_data === 'string' ? im.base64_data : '';
248
+ const bytes = b64 ? Buffer.from(b64, 'base64') : null;
249
+ const caption = im && typeof im.caption === 'string' && im.caption ? { caption: im.caption } : {};
250
+ return {
251
+ part_id: `image:${i}`, kind: 'file', media_type: String((im && im.mime_type) || ''),
252
+ att_sha: bytes ? sha256(bytes) : null, att_len: bytes ? bytes.length : null, ref: String((im && im.source_path) || ''),
253
+ ...caption,
254
+ };
255
+ });
256
+ }
257
+
258
+ /** What the user typed, where the store keeps it apart from the content the model saw. */
259
+ function typedText(n) {
260
+ const blocks = n.ext['chisel/acp-content-blocks'];
261
+ if (!Array.isArray(blocks)) return '';
262
+ return blocks.filter((b) => b && b.type === 'text' && typeof b.text === 'string').map((b) => b.text).join('');
263
+ }
264
+
265
+ function reasoningPart(th) {
266
+ if (typeof th === 'string') return th ? { part_id: 'thinking', kind: 'reasoning', text: th } : null;
267
+ if (!th || typeof th !== 'object') return null;
268
+ const sig = typeof th.signature === 'string' ? th.signature : '';
269
+ const sigLen = sig ? { signature_len: Buffer.byteLength(sig) } : {};
270
+ if (typeof th.thinking === 'string' && th.thinking) return { part_id: 'thinking', kind: 'reasoning', text: th.thinking, ...sigLen };
271
+ if (sig) return { part_id: 'thinking', kind: 'reasoning', opaque: true, ...sigLen };
272
+ return null;
273
+ }
274
+
275
+ function usageOf(md) {
276
+ const m = md.metrics;
277
+ if (!m || typeof m !== 'object') return null;
278
+ const u = {
279
+ basis: 'unknown', input: num(m.input_tokens), output: num(m.output_tokens),
280
+ cache_read: num(m.cache_read_tokens), cache_write: num(m.cache_creation_tokens), reasoning: null, cost: null,
281
+ };
282
+ return u.input == null && u.output == null && u.cache_read == null && u.cache_write == null ? null : u;
283
+ }
284
+
285
+ /** One call and its result (the tool node on the same line), folded into one part. */
286
+ function toolPart(call, res, st) {
287
+ const id = String(call.id);
288
+ const part = {
289
+ part_id: `tool:${id}`, kind: 'tool', call_id: id, tool: String(call.name || ''), args: argsOf(call.arguments),
290
+ output: null, is_error: false, status: 'interrupted',
291
+ };
292
+ if (!res) return part;
293
+ const x = res.ext;
294
+ const meta = x['chisel/tool_result_meta'] && typeof x['chisel/tool_result_meta'] === 'object' ? x['chisel/tool_result_meta'] : {};
295
+ const failure = (x['chisel/tool_failure'] && x['chisel/tool_failure'].reason) ?? meta.failure_reason ?? null;
296
+ const has = (k) => failure === k || (!!failure && typeof failure === 'object' && Object.prototype.hasOwnProperty.call(failure, k));
297
+ const u = st || {};
298
+ const um = u._meta && typeof u._meta === 'object' ? u._meta : {};
299
+ const rejected = um['cognition.ai/rejected'] === true || has('Rejected');
300
+ const canceled = um['cognition.ai/canceled'] === true || has('Canceled');
301
+ const failed = meta.success === false || u.status === 'failed';
302
+ part.output = textOfContent(res.cm.content);
303
+ part.is_error = !!(failed || rejected || canceled);
304
+ part.status = rejected ? 'denied' : (canceled ? 'cancelled' : (failed ? 'error' : 'completed'));
305
+ const term = x['chisel/terminal_output'];
306
+ const ec = num(term && term.exit && term.exit.exit_code) ?? num(um.terminal_exit && um.terminal_exit.exit_code);
307
+ if (ec != null) part.exit_code = ec;
308
+ const ts = ms(res.md.created_at);
309
+ if (ts != null) part.ts = ts;
310
+ return part;
311
+ }
312
+
313
+ /** A pointer that dangles inside the line: a torn read. normalise returns null, never fewer messages. */
314
+ class TornRead extends Error {}
315
+
316
+ function build(sid, row, rawNodes, rawStates, rawHeads) {
317
+ try { return derive(sid, row, rawNodes, rawStates, rawHeads); } catch (e) {
318
+ if (e instanceof TornRead) return null;
319
+ throw e;
320
+ }
321
+ }
322
+
323
+ function derive(sid, row, rawNodes, rawStates, rawHeads) {
324
+ if (!row) return null;
325
+ const nodes = new Map();
326
+ for (const r of rawNodes || []) {
327
+ const n = toNode(r);
328
+ if (!Number.isFinite(n.id)) throw new Error('devin: a node id that is not a number (a damaged read)');
329
+ nodes.set(n.id, n);
330
+ }
331
+ const states = new Map();
332
+ for (const r of rawStates || []) if (r && r.tool_call_id != null) states.set(String(r.tool_call_id), jsonCol(r.tool_call_update_json, `tool_call_state ${r.tool_call_id}`) || {});
333
+
334
+ /** The chain ending at `id`, root first; null when a pointer dangles or loops (a torn read). */
335
+ const walk = (id) => {
336
+ const out = [];
337
+ const seen = new Set();
338
+ let n = nodes.get(id);
339
+ if (!n) return null;
340
+ while (n) {
341
+ if (seen.has(n.id)) return null;
342
+ seen.add(n.id);
343
+ out.push(n);
344
+ if (n.parent == null) break;
345
+ n = nodes.get(n.parent);
346
+ if (!n) return null;
347
+ }
348
+ return out.reverse();
349
+ };
350
+
351
+ const hidden = Number(row.hidden) === 1 || row.hidden === true;
352
+ const head = {
353
+ k: 'session',
354
+ title: row.title == null ? '' : String(row.title),
355
+ cwd: row.working_directory == null ? '' : String(row.working_directory),
356
+ created: epochMs(row.created_at),
357
+ updated: null, archived: null,
358
+ version: '',
359
+ incarnation: '',
360
+ ...(hidden ? { kind: 'hidden' } : {}),
361
+ links: [],
362
+ };
363
+ if (row.main_chain_id == null) return [head]; // no turn saved yet
364
+ const chain = walk(Number(row.main_chain_id));
365
+ if (!chain) return null;
366
+
367
+ // ── the current line: compacted histories first, then the chain ──────────────────────────────
368
+ const expanded = new Set();
369
+ const lineOf = (ch) => {
370
+ const out = [];
371
+ let hoisted = false;
372
+ for (const n of ch) {
373
+ const x = sumFrom(n);
374
+ if (x != null && !expanded.has(x)) {
375
+ expanded.add(x);
376
+ // The compacted history must read whole: a dangling pointer would log all of it as removed.
377
+ const prior = walk(x);
378
+ if (!prior) throw new TornRead(`compacted history ending at node ${x}`);
379
+ const piece = [...lineOf(prior), { compaction: x, trigger: n }];
380
+ // The first compaction's history goes before the whole chain: the log before the compaction
381
+ // stays an exact prefix, so it appends.
382
+ if (!hoisted) { out.unshift(...piece); hoisted = true; } else out.push(...piece);
383
+ }
384
+ out.push({ node: n });
385
+ }
386
+ return out;
387
+ };
388
+ const line = lineOf(chain);
389
+
390
+ const records = [];
391
+ const emitted = new Map(); // message_id of a node → the log message that carries it
392
+ const owner = new Map(); // call id → the assistant message that made the call
393
+ const seenNodes = new Set(); // nodes the line and its sub-agent chains went through
394
+ const subChains = new Set();
395
+ let order = 0;
396
+ let held = false;
397
+
398
+ const pushMessage = (n, m) => { records.push(m); emitted.set(n.mid, m.message_id); };
399
+ const base = (n, role, agent) => ({ k: 'message', message_id: n.mid, role, ts: ms(n.md.created_at), order: order++, agent_id: agent });
400
+
401
+ const emitSummary = (n, agent, through) => {
402
+ const parts = [];
403
+ const r = reasoningPart(n.cm.thinking);
404
+ if (r) parts.push(r);
405
+ parts.push({ part_id: 'summary', kind: 'summary', text: textOfContent(n.cm.content), replaces: through ? { through_id: through } : 'unknown' });
406
+ const asst = n.role === 'assistant';
407
+ pushMessage(n, {
408
+ ...base(n, asst ? 'assistant' : 'system', agent), state: 'current', status: 'complete',
409
+ ...(asst ? { model: String(n.md.generation_model || ''), effort: '', usage: usageOf(n.md) } : { source: 'injected', hidden: true }),
410
+ parts,
411
+ });
412
+ };
413
+
414
+ const emitCompaction = (e, agent) => {
415
+ const xNode = nodes.get(e.compaction);
416
+ const through = xNode ? (emitted.get(xNode.mid) || null) : null;
417
+ // The compactor's reply: an assistant node with summarized_from X, usually off the line.
418
+ const sums = [];
419
+ const sumIds = new Set();
420
+ for (const n of [...nodes.values()].sort((a, b) => b.id - a.id)) {
421
+ if (n.role !== 'assistant' || sumFrom(n) !== e.compaction || !n.mid || sumIds.has(n.mid) || emitted.has(n.mid)) continue;
422
+ sumIds.add(n.mid);
423
+ sums.unshift(n);
424
+ }
425
+ const carrier = sums.length ? null : (e.trigger.role === 'system' && !emitted.has(e.trigger.mid) ? e.trigger : null);
426
+ const first = sums[0] || carrier;
427
+ records.push({ k: 'lifecycle', event: 'compaction', at: first ? ms(first.md.created_at) : ms(e.trigger.md.created_at), ...(through ? { through } : {}) });
428
+ for (const n of sums) { seenNodes.add(n.id); emitSummary(n, agent, through); }
429
+ if (carrier) emitSummary(carrier, agent, through);
430
+ };
431
+
432
+ const emitEntries = (entries, agent, canHold) => {
433
+ const results = new Map();
434
+ for (const e of entries) {
435
+ if (!e.node) continue;
436
+ seenNodes.add(e.node.id);
437
+ const c = e.node.role === 'tool' && e.node.cm.tool_call_id != null ? String(e.node.cm.tool_call_id) : null;
438
+ if (c && !results.has(c)) results.set(c, e.node);
439
+ }
440
+ // For each entry: whether real content follows it, and which entry comes next.
441
+ const follows = new Array(entries.length).fill(false);
442
+ const nextOf = new Array(entries.length).fill(null);
443
+ let seen = false;
444
+ let next = null;
445
+ for (let i = entries.length - 1; i >= 0; i--) {
446
+ follows[i] = seen;
447
+ nextOf[i] = next;
448
+ const e = entries[i];
449
+ if (e.compaction != null || isContent(e.node)) { seen = true; next = e; }
450
+ }
451
+
452
+ for (let i = 0; i < entries.length && !held; i++) {
453
+ const e = entries[i];
454
+ if (e.compaction != null) { emitCompaction(e, agent); continue; }
455
+ const n = e.node;
456
+ if (!n.mid || isPrefix(n) || isKeepalive(n) || emitted.has(n.mid)) continue;
457
+
458
+ if (n.role === 'tool') {
459
+ const c = n.cm.tool_call_id == null ? '' : String(n.cm.tool_call_id);
460
+ if (owner.has(c)) { emitted.set(n.mid, owner.get(c)); continue; } // folded into its call
461
+ // A result whose call is not on the line: a tool message of its own.
462
+ const st = states.get(c);
463
+ const name = (st && st._meta && st._meta['cognition.ai/inferenceToolName']) || (n.md.telemetry || {}).operation || '';
464
+ pushMessage(n, { ...base(n, 'tool', agent), state: 'current', status: 'complete', parts: [toolPart({ id: c, name, arguments: null }, n, st)] });
465
+ continue;
466
+ }
467
+
468
+ if (n.role === 'assistant') {
469
+ const calls = Array.isArray(n.cm.tool_calls) ? n.cm.tool_calls.filter((c) => c && c.id != null) : [];
470
+ const missing = calls.some((c) => !results.has(String(c.id)));
471
+ const inference = (n.md.telemetry || {}).operation === 'inference' || !!n.md.request_id;
472
+ if (canHold && !follows[i] && (missing || (inference && n.md.finish_reason == null))) { held = true; break; }
473
+ const parts = [];
474
+ const r = reasoningPart(n.cm.thinking);
475
+ if (r) parts.push(r);
476
+ const text = textOfContent(n.cm.content);
477
+ if (text) parts.push({ part_id: 'text', kind: 'text', text });
478
+ parts.push(...imageParts(n.cm.images));
479
+ for (const c of calls) parts.push(toolPart(c, results.get(String(c.id)), states.get(String(c.id))));
480
+ const nx = nextOf[i];
481
+ const interrupted = (nx && nx.node && isInterrupt(nx.node)) || parts.some((p) => p.kind === 'tool' && (p.status === 'interrupted' || p.status === 'cancelled'));
482
+ pushMessage(n, {
483
+ ...base(n, 'assistant', agent), state: 'current', status: interrupted ? 'interrupted' : 'complete',
484
+ model: String(n.md.generation_model || ''), effort: '', usage: usageOf(n.md), parts,
485
+ });
486
+ for (const c of calls) owner.set(String(c.id), n.mid);
487
+ // Sub-agents this message started: their chains live in the same tree.
488
+ for (const c of calls) {
489
+ const res = results.get(String(c.id));
490
+ const x = res ? res.ext : null;
491
+ if (!x || x['subagent/agent_id'] == null || x['subagent/chain_node_id'] == null) continue;
492
+ const head2 = Number(x['subagent/chain_node_id']);
493
+ if (subChains.has(head2)) continue;
494
+ subChains.add(head2);
495
+ const sub = walk(head2);
496
+ if (!sub) throw new TornRead(`sub-agent chain ending at node ${head2}`);
497
+ emitEntries(lineOf(sub), String(x['subagent/agent_id']), false);
498
+ }
499
+ continue;
500
+ }
501
+
502
+ if (n.role === 'user') {
503
+ const parts = [];
504
+ const text = textOfContent(n.cm.content);
505
+ const typed = typedText(n);
506
+ if (text) parts.push({ part_id: 'text', kind: 'text', text, ...(typed && typed !== text ? { display_text: typed } : {}) });
507
+ parts.push(...imageParts(n.cm.images));
508
+ const automated = hidden || agent !== '' || n.md.is_user_input !== true;
509
+ pushMessage(n, { ...base(n, 'user', agent), state: 'current', status: 'complete', ...(automated ? { source: 'automated' } : {}), parts });
510
+ continue;
511
+ }
512
+
513
+ // system (and any other role): the interrupt notice, else injected context.
514
+ const text = textOfContent(n.cm.content);
515
+ if (isInterrupt(n)) {
516
+ pushMessage(n, { ...base(n, 'system', agent), state: 'current', status: 'complete', parts: [{ part_id: 'notice', kind: 'notice', text: text.trim() }] });
517
+ continue;
518
+ }
519
+ pushMessage(n, {
520
+ ...base(n, 'system', agent), state: 'current', status: 'complete', source: 'injected', hidden: true,
521
+ parts: text ? [{ part_id: 'text', kind: 'text', text }] : [],
522
+ });
523
+ }
524
+ };
525
+ emitEntries(line, '', true);
526
+
527
+ // ── reverts: abandoned subtrees still in the table ───────────────────────────────────────────
528
+ // Not on the line, not a sub-agent or compactor chain, not the prefix or a keepalive, and a
529
+ // message id the line does not carry (streaming twins and prefix copies share the line's ids).
530
+ const aux = new Set();
531
+ const markUp = (id) => {
532
+ for (let n = nodes.get(id), k = 0; n && !aux.has(n.id) && k <= nodes.size; n = n.parent == null ? null : nodes.get(n.parent), k++) aux.add(n.id);
533
+ };
534
+ for (const n of nodes.values()) {
535
+ if (n.role === 'assistant' && sumFrom(n) != null) markUp(n.id);
536
+ if (n.ext['subagent/chain_node_id'] != null) markUp(Number(n.ext['subagent/chain_node_id']));
537
+ }
538
+ for (const h of rawHeads || []) if (h && h.chain_node_id != null) markUp(Number(h.chain_node_id));
539
+ const known = new Set();
540
+ for (const id of seenNodes) known.add(nodes.get(id).mid);
541
+ const cand = new Map();
542
+ for (const n of nodes.values()) {
543
+ if (seenNodes.has(n.id) || aux.has(n.id) || isPrefix(n) || isKeepalive(n) || !n.mid || known.has(n.mid)) continue;
544
+ cand.set(n.id, n);
545
+ }
546
+ const groups = new Map(); // top of an abandoned subtree → { base, ids }
547
+ for (const n of [...cand.values()].sort((a, b) => a.id - b.id)) {
548
+ let top = n;
549
+ for (let k = 0; top.parent != null && cand.has(top.parent) && k <= nodes.size; k++) top = cand.get(top.parent);
550
+ if (!groups.has(top.id)) groups.set(top.id, { base: top.parent == null ? null : nodes.get(top.parent) || null, ids: [] });
551
+ const g = groups.get(top.id);
552
+ if (n.role !== 'tool' && !g.ids.includes(n.mid)) g.ids.push(n.mid);
553
+ }
554
+ let atStart = 1;
555
+ for (const [, g] of [...groups.entries()].sort((a, b) => a[0] - b[0])) {
556
+ if (!g.ids.length) continue;
557
+ const through = g.base ? (emitted.get(g.base.mid) || null) : null;
558
+ const rec = { k: 'lifecycle', event: 'rewind', at: null, message_ids: g.ids, through };
559
+ if (!through) {
560
+ if (g.base && known.has(g.base.mid) && !isPrefix(g.base)) continue; // hangs off a held-back message
561
+ records.splice(atStart - 1, 0, rec);
562
+ atStart++;
563
+ continue;
564
+ }
565
+ let at = records.findIndex((m) => m.k === 'message' && m.message_id === through);
566
+ if (at < 0) continue; // its message is held back: the record comes with it
567
+ at++;
568
+ while (at < records.length && records[at].k === 'lifecycle' && records[at].event === 'rewind') at++;
569
+ records.splice(at, 0, rec);
570
+ }
571
+
572
+ return [head, ...records];
573
+ }
574
+
575
+ module.exports = { HARNESS, list, normalise, storeRootDefault, listSql };