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,473 @@
1
+ 'use strict';
2
+ // v4 wave 2: Zed (the native agent) → normalised session log records (docs/design/sessions-log.md,
3
+ // plus the wave-1 format additions: archived, children, lifecycle, hidden, source, exit_code).
4
+ //
5
+ // Store: <data folder>/threads/threads.db (harness-paths dataFolder('zed'): ~/.local/share/zed on
6
+ // Linux, ~/Library/Application Support/Zed on macOS), SQLite with a rollback journal, opened
7
+ // read-only. Table `threads(id, summary, updated_at, data_type, data, parent_id, folder_paths,
8
+ // folder_paths_order, created_at)`: one row per thread; `data` is the whole thread as JSON
9
+ // (data_type 'json') or zstd-compressed JSON ('zstd'), rewritten on every save. Columns vary by
10
+ // build, so the reader asks the table what it has. Archive state lives elsewhere: the workspace
11
+ // DB <data folder>/db/<channel>/db.sqlite, table `sidebar_threads(session_id, archived, …)` (WAL);
12
+ // only those two columns are read.
13
+ // Spec: docs/design/editor-shapes.md "Zed 1.24.3" (repository) with the annex's correction
14
+ // (User messages carry an `id`), sessions-model v0 §1 rule 4 (anchored ids), §2.3, §2.4, §2.5.
15
+ //
16
+ // Rules:
17
+ // - message ids: a User message keeps its `id`. Every other message (Agent, Resume, Compaction,
18
+ // an unknown entry's notice) has none and is anchored to the nearest preceding User id:
19
+ // `<user id>+n`, n counting from 1 (a message before any User message anchors to the thread id).
20
+ // A checkpoint restore truncates messages[] in place from a User message on, and the regrown turn
21
+ // has a new User id, so the old anchored keys vanish from the log (the engine keeps them as
22
+ // removed): no lifecycle record, the store keeps no trace of the restore;
23
+ // - order: the array position. ts: none per message (null; the engine falls back to first-seen);
24
+ // - parts: block index as part_id. User Text | Mention (hidden: the expanded context, not what was
25
+ // typed) | Image (file part, sha of the decoded PNG). Agent Text | Thinking (opaque when only a
26
+ // signature) | RedactedThinking (opaque) | ToolUse, whose result is `tool_results[<ToolUse.id>]`,
27
+ // folded into the same part. `reasoning_details` becomes a reasoning part only when the message
28
+ // has no Thinking content (it is the provider's copy of the same reasoning);
29
+ // - tool results: `is_error` is unreliable (a failing command stores false), so only `true` is
30
+ // kept and anything else is null. The terminal tool's own header `Command "…" failed with exit
31
+ // code N.` gives exit_code. Zed's own canned results (`Tool canceled by user`, the permission
32
+ // denials) set status cancelled | denied;
33
+ // - usage: one per-session series from the thread's own total, `cumulative_token_usage`
34
+ // (sessions-model §2.4 "No overlap": "Zed has one `per_session` record"; annex zed: "The per-turn
35
+ // value is not a turn total"). It is handed to writeLog as a running total (`running: true`,
36
+ // usage_id `session`), which keeps it in the log as increments (`session#1`, `session#2`, …), so the
37
+ // log still only appends as the thread grows: the total itself, changed in place on every turn,
38
+ // would rewrite the whole log each time. No message carries usage. `request_token_usage[<user id>]`
39
+ // is not written: it holds only a turn's last request (each request of the turn overwrites it) and
40
+ // is part of the total, so writing it too would count those tokens twice. An empty total (no
41
+ // counter above zero) writes no usage at all, even when request_token_usage has values: those are
42
+ // last-request snapshots, and a number known to be partial would be billed as the session's (the
43
+ // harness-session-adapters package writes none either). Basis anthropic: Zed's own source defines total
44
+ // input = input + cache_creation + cache_read, so input excludes cache, and output has no
45
+ // reasoning split. Absent counters (Zed omits zeros) are null. A checkpoint restore does not
46
+ // reduce the total (annex), so the increments already written stay. Time: the thread's own
47
+ // `updated_at` (its last save, of the same blob as the total), else the row's: each increment is
48
+ // dated by the save that first showed its tokens, and keeps that time (and its key) through any
49
+ // later rewrite. Only a thread with neither falls back to first-seen;
50
+ // - model: thread `model.model` (the provider name is not carried), effort: thread
51
+ // `thinking_effort`. Both are per thread, so a mid-thread change relabels earlier messages;
52
+ // - held back: in the last turn (no later User message), an Agent message with a ToolUse that has no
53
+ // result or whose input is incomplete, and everything after it (the usage increments do not wait:
54
+ // they are tokens already spent, and they only append).
55
+ // Zed (main) skips saves while a reply streams and gives every unanswered ToolUse a cancel result
56
+ // when it flushes, so this should not fire; an earlier turn keeps such a call as interrupted;
57
+ // - Resume (a "continue" the harness sends as a user turn): role user, source automated, one
58
+ // notice part. Compaction{Summary}: a lifecycle record, then a system message with a summary part
59
+ // (`replaces.through_id` = the message before it); ProviderNative: the same, opaque. Both are
60
+ // source-read (Zed main), not seen in the lab;
61
+ // - nothing in messages[] is dropped (sessions-model §2.3: a notice covers "any native kind the
62
+ // model lacks … Never dropped"). An entry this reader does not know (a new variant, or a known one
63
+ // in a shape it does not read) is a system message with one notice part naming the variant,
64
+ // anchored like the rest, so every message after it keeps its native position and its
65
+ // `<anchor>+n` (§1 rule 4: never an index of emitted rows). A User message without an `id` is
66
+ // anchored the same way. A content segment this reader does not know is a notice part at its own
67
+ // index. Only the variant's name is written, never its payload: what an unknown variant carries
68
+ // is unknown. A name is an identifier (`^[A-Z][A-Za-z0-9_]{0,63}$`); anything else, such as a
69
+ // bare string in place of an entry or a segment, is written as `unknown`;
70
+ // - session: title = row `summary`, cwd = the first of `folder_paths` in its stored order (else the
71
+ // initial project snapshot's first worktree), created = row `created_at`, mode = `profile`,
72
+ // archived = true when a sidebar row says so (a flag; no time stored). Sub-agent: the child row's
73
+ // `parent_id` / `subagent_context.parent_thread_id` is one explicit child-side link; a parent's
74
+ // `spawn_agent` result naming a `session_id` is origin-side (`children`);
75
+ // - not read: legacy agent-1 thread blobs (messages with `role`; version 0.1.0/0.2.0/none). They
76
+ // yield null (nothing written) until Zed re-saves them as 0.3.0;
77
+ // - volatile fields stay out: row/thread `updated_at`, `ui_scroll_position`, `draft_prompt`;
78
+ // - redaction: the thread holds no credential (the provider key is in env or the keyring). Tool
79
+ // signatures, the git snapshot (its remote URL) and sandbox grants are never emitted.
80
+
81
+ const fs = require('fs');
82
+ const path = require('path');
83
+ const crypto = require('crypto');
84
+ const zlib = require('zlib');
85
+ const { openReadOnly, textOf, bytesOf } = require('../sqlite');
86
+ const harnessPaths = require('../harness-paths');
87
+ const { placeChildren } = require('../sessions-log');
88
+
89
+ const HARNESS = 'zed';
90
+ // Bumped when the records change for an unchanged store, so the runner normalises every session again
91
+ // (normalise-run.js) and the engine re-derives its rewritten log at a higher rev. 2: usage from
92
+ // cumulative_token_usage as a running total; an unknown messages[] entry keeps its place.
93
+ const VERSION = 2;
94
+ const ROW_COLS = ['id', 'summary', 'created_at', 'parent_id', 'folder_paths', 'folder_paths_order', 'data_type'];
95
+ const CANCELLED = new Set(['Tool canceled by user']);
96
+ const DENIED = new Set([
97
+ 'Permission to run tool denied by user',
98
+ 'Permission denied: user sent a follow-up message instead of approving the tool call.',
99
+ ]);
100
+
101
+ function storeRootDefault() { return harnessPaths.dataFolder(HARNESS); }
102
+ function dbPathOf(storeRoot) { return path.join(storeRoot, 'threads', 'threads.db'); }
103
+
104
+ // ── small helpers ─────────────────────────────────────────────────────────────────────────────
105
+ const ms = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? t : null; };
106
+ const sha256 = (buf) => crypto.createHash('sha256').update(buf).digest('hex');
107
+ const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
108
+ /** The single key of an externally tagged serde enum ({Text: …}), or a unit variant's string. */
109
+ function variant(v) {
110
+ if (typeof v === 'string') return [v, null];
111
+ if (isObj(v)) { const k = Object.keys(v); if (k.length === 1) return [k[0], v[k[0]]]; }
112
+ return [null, null];
113
+ }
114
+ /** A token counter: absent (Zed omits zeros) or invalid is null. */
115
+ function count(v) { const x = Math.trunc(Number(v)); return v != null && Number.isFinite(x) && x >= 0 ? x : null; }
116
+
117
+ /** zstd: Node's own (>= 22.15 / 23.8; the engines floor is 24), else the zstd CLI over a pipe. */
118
+ function zstdDecompress(buf) {
119
+ if (typeof zlib.zstdDecompressSync === 'function') return zlib.zstdDecompressSync(buf);
120
+ const { execFileSync } = require('child_process');
121
+ return execFileSync(process.platform === 'win32' ? 'zstd.exe' : 'zstd', ['-d', '-c', '-q'], { input: buf, maxBuffer: 1 << 30, stdio: ['pipe', 'pipe', 'ignore'] });
122
+ }
123
+ /** A `threads.data` value → the parsed thread, or null when it cannot be read. */
124
+ function decodeData(data, dataType) {
125
+ if (data == null) return null;
126
+ try {
127
+ const buf = bytesOf(data);
128
+ const json = String(dataType || 'zstd') === 'json' ? buf.toString('utf-8') : zstdDecompress(buf).toString('utf-8');
129
+ const t = JSON.parse(json);
130
+ return isObj(t) ? t : null;
131
+ } catch { return null; }
132
+ }
133
+
134
+ // ── store reads ───────────────────────────────────────────────────────────────────────────────
135
+ // Every read error propagates (the module contract, docs/design/sessions-log.md): a save in progress
136
+ // (threads.db keeps a rollback journal, so a reader waits on the writer's lock, then fails busy), a
137
+ // damaged page, a file that is not a database. The pass fails and the runner retries it; nothing
138
+ // reads as no threads, no columns or no archive flag. Only a missing table or column is empty, and
139
+ // PRAGMA table_info gives that as no rows, without an error.
140
+ function columnsOf(db, table) {
141
+ return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => String(r.name)));
142
+ }
143
+ function rowOf(r) {
144
+ const s = (v) => (v == null ? null : String(textOf(v)));
145
+ return {
146
+ id: String(textOf(r.id)), summary: s(r.summary), created_at: s(r.created_at), parent_id: s(r.parent_id),
147
+ folder_paths: s(r.folder_paths), folder_paths_order: s(r.folder_paths_order), data_type: s(r.data_type),
148
+ };
149
+ }
150
+ function openThreads(file) {
151
+ const db = openReadOnly(file);
152
+ try { db.exec('PRAGMA busy_timeout = 2000'); } catch { /* a plain read still works */ }
153
+ return db;
154
+ }
155
+
156
+ /** session_id → true for the threads any channel's sidebar marks archived (a flag, no time). */
157
+ function readArchived(storeRoot) {
158
+ const out = new Map();
159
+ let channels = [];
160
+ try { channels = fs.readdirSync(path.join(storeRoot, 'db')).sort(); } catch { return out; }
161
+ for (const ch of channels) {
162
+ const file = path.join(storeRoot, 'db', ch, 'db.sqlite');
163
+ try { if (!fs.statSync(file).isFile()) continue; } catch { continue; }
164
+ let db = null;
165
+ try {
166
+ db = openReadOnly(file);
167
+ const cols = columnsOf(db, 'sidebar_threads');
168
+ if (!cols.has('session_id') || !cols.has('archived')) continue;
169
+ for (const r of db.prepare('SELECT session_id, archived FROM sidebar_threads WHERE session_id IS NOT NULL').all()) {
170
+ const sid = String(textOf(r.session_id));
171
+ if (sid && Number(r.archived) === 1) out.set(sid, true);
172
+ }
173
+ } finally { if (db) try { db.close(); } catch { /* closed */ } }
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /**
179
+ * Sessions in one store: one per `threads` row. No blob is read here. Each entry carries
180
+ * harness_path (the data folder, resolved: = dataFolder('zed') for the default store), the threads
181
+ * DB relative to it (`db_rel`: every thread shares that DB, so its absolute path would make any
182
+ * write re-run every thread), the row's `updated_at` as this thread's change key (Zed sets it when
183
+ * it saves the thread), and the sidebar's archive flag as a field. No row metadata: folder_paths is
184
+ * a project path the runner would stat. The workspace DB is not listed as a file: Zed writes it on
185
+ * every UI change, and the one value read from it is the field.
186
+ */
187
+ function list(storeRoot = storeRootDefault()) {
188
+ const file = dbPathOf(storeRoot);
189
+ try { if (!fs.statSync(file).isFile()) return []; } catch { return []; }
190
+ const harnessPath = harnessPaths.resolveNative(storeRoot);
191
+ const db = openThreads(file);
192
+ let rows = [];
193
+ try {
194
+ const cols = columnsOf(db, 'threads');
195
+ if (!cols.has('id')) return [];
196
+ rows = db.prepare(`SELECT id, ${cols.has('updated_at') ? 'updated_at' : "'' AS updated_at"} FROM threads ORDER BY id`).all()
197
+ .map((r) => ({ id: String(textOf(r.id)), updated_at: r.updated_at == null ? '' : String(textOf(r.updated_at)) }));
198
+ } finally { db.close(); }
199
+ const archived = readArchived(storeRoot);
200
+ const rel = path.relative(storeRoot, file);
201
+ return rows.map((r) => ({ harness: HARNESS, harness_path: harnessPath, session_id: r.id, db_rel: rel, updated_at: r.updated_at, archived: archived.get(r.id) === true }));
202
+ }
203
+
204
+ /** One thread's row and blob, read together so they are of the same save. */
205
+ function loadFromDb(session) {
206
+ const db = openThreads(session.db || path.join(session.harness_path, session.db_rel));
207
+ try {
208
+ const cols = columnsOf(db, 'threads');
209
+ const sel = ROW_COLS.filter((c) => cols.has(c));
210
+ const r = db.prepare(`SELECT ${sel.join(', ')}, data FROM threads WHERE id = ?`).get(String(session.session_id));
211
+ if (!r) return null;
212
+ const row = rowOf(r);
213
+ return { row, thread: decodeData(r.data, row.data_type) };
214
+ } finally { db.close(); }
215
+ }
216
+
217
+ // ── parts ─────────────────────────────────────────────────────────────────────────────────────
218
+ /** LanguageModelToolUseInput: {type: json|text, value}, or (older) the bare JSON. */
219
+ function argsOf(tu) {
220
+ const inp = tu.input;
221
+ if (isObj(inp) && Object.keys(inp).length === 2 && typeof inp.type === 'string' && 'value' in inp) {
222
+ if (inp.type === 'json') return inp.value ?? {};
223
+ if (inp.type === 'text') return { raw: String(inp.value ?? '') };
224
+ }
225
+ if (inp != null) return inp;
226
+ if (typeof tu.raw_input === 'string' && tu.raw_input) { try { return JSON.parse(tu.raw_input); } catch { return { raw: tu.raw_input }; } }
227
+ return {};
228
+ }
229
+ /** LanguageModelToolResultContent, one or a list: Text | Image, in any of serde's accepted shapes. */
230
+ function resultText(content) {
231
+ const one = (c) => {
232
+ if (typeof c === 'string') return c;
233
+ if (!isObj(c)) return '';
234
+ const key = (n) => Object.keys(c).find((k) => k.toLowerCase() === n);
235
+ const t = key('text');
236
+ if (t && typeof c[t] === 'string' && (Object.keys(c).length === 1 || String(c[key('type')] || '').toLowerCase() === 'text')) return c[t];
237
+ if (key('image')) return '[image]';
238
+ return '';
239
+ };
240
+ return Array.isArray(content) ? content.map(one).join('') : one(content);
241
+ }
242
+ /** The terminal tool's own header, before any command output (after Zed's sandbox notes). */
243
+ function exitCodeOf(tool, text) {
244
+ if (tool !== 'terminal' || !text) return null;
245
+ for (const para of text.split('\n\n')) {
246
+ if (para.startsWith('Note:')) continue;
247
+ const m = /^Command "[\s\S]*" failed with exit code (-?\d+)\.(?: |$)/.exec(para);
248
+ return m ? Number(m[1]) : null;
249
+ }
250
+ return null;
251
+ }
252
+
253
+ function toolPart(tu, idx, results) {
254
+ const callId = String(tu.id ?? idx);
255
+ const tool = String(tu.name || 'unknown');
256
+ const r = isObj(results) && Object.prototype.hasOwnProperty.call(results, callId) ? results[callId] : null;
257
+ const part = { part_id: String(idx), kind: 'tool', call_id: callId, tool, args: argsOf(tu), output: null, is_error: null, status: 'interrupted' };
258
+ if (!isObj(r)) return { part, open: true };
259
+ const output = resultText(r.content);
260
+ const exit = exitCodeOf(tool, output);
261
+ part.output = output;
262
+ part.is_error = r.is_error === true ? true : null;
263
+ part.status = CANCELLED.has(output) ? 'cancelled' : DENIED.has(output) ? 'denied' : (r.is_error === true ? 'error' : 'completed');
264
+ if (exit !== null) part.exit_code = exit;
265
+ return { part, open: tu.is_input_complete === false };
266
+ }
267
+
268
+ /** `reasoning_details` (provider-shaped JSON): readable text where an entry has some, else opaque. */
269
+ function reasoningDetailsPart(rd) {
270
+ if (rd == null) return null;
271
+ const texts = [];
272
+ for (const e of Array.isArray(rd) ? rd : [rd]) {
273
+ if (typeof e === 'string') { texts.push(e); continue; }
274
+ if (!isObj(e)) continue;
275
+ if (typeof e.text === 'string' && e.text) texts.push(e.text);
276
+ else if (typeof e.summary === 'string' && e.summary) texts.push(e.summary);
277
+ }
278
+ return texts.length ? { part_id: 'reasoning_details', kind: 'reasoning', text: texts.join('\n') }
279
+ : { part_id: 'reasoning_details', kind: 'reasoning', text: '', opaque: true };
280
+ }
281
+
282
+ /**
283
+ * What a notice may say about an entry or segment this reader does not know: its variant's name,
284
+ * when it is one (serde's names: an identifier, capitalised), else `unknown`. variant() returns a
285
+ * bare string whole as its "name", and an object's key could be anything: neither is written, so
286
+ * nothing of an unknown payload reaches the log.
287
+ */
288
+ const VARIANT_NAME = /^[A-Z][A-Za-z0-9_]{0,63}$/;
289
+ const nameOf = (k) => (typeof k === 'string' && VARIANT_NAME.test(k) ? k : 'unknown');
290
+ /** A notice part for a content segment this reader does not know: its variant's name, never its payload. */
291
+ const unknownPart = (i, k) => ({ part_id: String(i), kind: 'notice', text: nameOf(k) });
292
+
293
+ function userParts(content) {
294
+ const parts = [];
295
+ (Array.isArray(content) ? content : []).forEach((c, i) => {
296
+ const [k, v] = variant(c);
297
+ if (k === 'Text' && typeof v === 'string') parts.push({ part_id: String(i), kind: 'text', text: v });
298
+ else if (k === 'Mention' && isObj(v)) {
299
+ const ref = typeof v.uri === 'string' ? v.uri : JSON.stringify(v.uri ?? '');
300
+ parts.push({ part_id: String(i), kind: 'text', text: String(v.content ?? ''), ref, hidden: true });
301
+ } else if (k === 'Image' && isObj(v)) {
302
+ const bytes = typeof v.source === 'string' ? Buffer.from(v.source, 'base64') : null;
303
+ parts.push({ part_id: String(i), kind: 'file', media_type: 'image/png', att_sha: bytes ? sha256(bytes) : null, att_len: bytes ? bytes.length : null, ref: '' });
304
+ } else parts.push(unknownPart(i, k));
305
+ });
306
+ return parts;
307
+ }
308
+
309
+ function agentParts(msg) {
310
+ const parts = [];
311
+ let open = false;
312
+ let thinking = false;
313
+ (Array.isArray(msg.content) ? msg.content : []).forEach((c, i) => {
314
+ const [k, v] = variant(c);
315
+ if (k === 'Text' && typeof v === 'string') parts.push({ part_id: String(i), kind: 'text', text: v });
316
+ else if (k === 'Thinking' && isObj(v)) {
317
+ thinking = true;
318
+ const text = typeof v.text === 'string' ? v.text : '';
319
+ if (text) parts.push({ part_id: String(i), kind: 'reasoning', text });
320
+ else if (v.signature) parts.push({ part_id: String(i), kind: 'reasoning', text: '', opaque: true });
321
+ } else if (k === 'RedactedThinking') {
322
+ thinking = true;
323
+ parts.push({ part_id: String(i), kind: 'reasoning', text: '', opaque: true });
324
+ } else if (k === 'ToolUse' && isObj(v)) {
325
+ const t = toolPart(v, i, msg.tool_results);
326
+ parts.push(t.part);
327
+ if (t.open) open = true;
328
+ } else parts.push(unknownPart(i, k));
329
+ });
330
+ if (!thinking) { const rd = reasoningDetailsPart(msg.reasoning_details); if (rd) parts.unshift(rd); }
331
+ return { parts, open };
332
+ }
333
+
334
+ function usageOf(u) {
335
+ if (!isObj(u)) return null;
336
+ const x = { input: count(u.input_tokens), output: count(u.output_tokens), cache_read: count(u.cache_read_input_tokens), cache_write: count(u.cache_creation_input_tokens) };
337
+ if (Object.values(x).every((v) => v === null)) return null;
338
+ return { basis: 'anthropic', ...x, reasoning: null, cost: null };
339
+ }
340
+
341
+ /** folder_paths: lexicographic, `\n`-separated; folder_paths_order: each path's original position. */
342
+ function firstFolder(row, thread) {
343
+ const paths = row && row.folder_paths ? row.folder_paths.split('\n').filter(Boolean) : [];
344
+ if (paths.length) {
345
+ const order = String((row && row.folder_paths_order) || '').split(',').map((s) => Number.parseInt(s, 10));
346
+ const j = order.length === paths.length ? order.indexOf(0) : -1;
347
+ return paths[j >= 0 ? j : 0];
348
+ }
349
+ const ws = thread && thread.initial_project_snapshot && thread.initial_project_snapshot.worktree_snapshots;
350
+ return Array.isArray(ws) && ws[0] && typeof ws[0].worktree_path === 'string' ? ws[0].worktree_path : '';
351
+ }
352
+
353
+ // ── normalise ─────────────────────────────────────────────────────────────────────────────────
354
+ /**
355
+ * Normalise one thread. `session` is a list() entry, or native data already loaded (tests):
356
+ * `{ session_id, row?, thread }` (the parsed JSON) or `{ session_id, row?, data, data_type }` (the
357
+ * stored blob), with `archived` optional. Returns the log records, the session record first; null
358
+ * when the thread cannot be read (missing row, undecodable blob, a legacy agent-1 thread), so the
359
+ * caller writes nothing rather than an empty log.
360
+ */
361
+ function normalise(session) {
362
+ const sid = String(session.session_id);
363
+ let row = session.row || null;
364
+ let thread = null;
365
+ if (isObj(session.thread)) thread = session.thread;
366
+ else if (session.data != null) thread = decodeData(session.data, session.data_type || (row && row.data_type));
367
+ else if (session.db || (session.db_rel && session.harness_path)) {
368
+ const l = loadFromDb(session);
369
+ if (!l) return null;
370
+ row = l.row; thread = l.thread;
371
+ }
372
+ if (!thread || !Array.isArray(thread.messages)) return null;
373
+ if (thread.messages.some((m) => isObj(m) && 'role' in m)) return null; // legacy agent-1 shape
374
+
375
+ const model = isObj(thread.model) && thread.model.model ? String(thread.model.model) : '';
376
+ const effort = thread.thinking_effort ? String(thread.thinking_effort) : '';
377
+ const sub = isObj(thread.subagent_context) ? thread.subagent_context : null;
378
+ const origin = (sub && sub.parent_thread_id ? String(sub.parent_thread_id) : '') || (row && row.parent_id) || '';
379
+ const links = origin && origin !== sid ? [{ kind: 'subagent', origin, evidence: 'explicit', at: '' }] : [];
380
+
381
+ const msgs = thread.messages;
382
+ let lastUser = -1;
383
+ msgs.forEach((m, i) => { if (variant(m)[0] === 'User') lastUser = i; });
384
+
385
+ const out = [];
386
+ const children = [];
387
+ let anchor = sid;
388
+ let n = 0;
389
+ let prevId = null;
390
+ for (let i = 0; i < msgs.length; i++) {
391
+ const [k, v] = variant(msgs[i]);
392
+ if (k === 'User' && isObj(v) && v.id != null) {
393
+ anchor = String(v.id); n = 0;
394
+ out.push({ k: 'message', message_id: anchor, role: 'user', ts: null, order: i, state: 'current', status: 'complete', parts: userParts(v.content) });
395
+ prevId = anchor;
396
+ continue;
397
+ }
398
+ if (k === 'User' && isObj(v)) {
399
+ // A User message without its id: anchored like the others, so it keeps its place.
400
+ const id = `${anchor}+${++n}`;
401
+ out.push({ k: 'message', message_id: id, role: 'user', ts: null, order: i, state: 'current', status: 'complete', parts: userParts(v.content) });
402
+ prevId = id;
403
+ continue;
404
+ }
405
+ if (k === 'Agent' && isObj(v)) {
406
+ const id = `${anchor}+${++n}`;
407
+ const { parts, open } = agentParts(v);
408
+ if (open && i > lastUser) break; // the last turn is still running
409
+ for (const p of parts) {
410
+ if (p.kind !== 'tool' || p.tool !== 'spawn_agent' || p.output == null) continue;
411
+ const o = isObj(v.tool_results) && isObj(v.tool_results[p.call_id]) ? v.tool_results[p.call_id].output : null;
412
+ let child = isObj(o) && typeof o.session_id === 'string' ? o.session_id : null;
413
+ if (!child) { try { const j = JSON.parse(p.output); child = isObj(j) && typeof j.session_id === 'string' ? j.session_id : null; } catch { child = null; } }
414
+ if (child && child !== sid && !children.some((c) => c.session_id === child)) children.push({ kind: 'subagent', session_id: child, evidence: 'explicit', after: id });
415
+ }
416
+ const rec = {
417
+ k: 'message', message_id: id, role: 'assistant', ts: null, order: i, state: 'current',
418
+ status: parts.some((p) => p.kind === 'tool' && p.status === 'interrupted') ? 'interrupted' : 'complete',
419
+ model, effort, usage: null, parts,
420
+ };
421
+ out.push(rec);
422
+ prevId = id;
423
+ continue;
424
+ }
425
+ if (k === 'Resume') {
426
+ const id = `${anchor}+${++n}`;
427
+ out.push({ k: 'message', message_id: id, role: 'user', source: 'automated', ts: null, order: i, state: 'current', status: 'complete', parts: [{ part_id: '0', kind: 'notice', text: 'Resume' }] });
428
+ prevId = id;
429
+ continue;
430
+ }
431
+ if (k === 'Compaction') {
432
+ const id = `${anchor}+${++n}`;
433
+ const [ck, cv] = variant(v);
434
+ const summary = ck === 'Summary' && typeof cv === 'string'
435
+ ? { part_id: '0', kind: 'summary', text: cv, replaces: { through_id: prevId } }
436
+ : { part_id: '0', kind: 'summary', text: '', opaque: true, replaces: { through_id: prevId } };
437
+ out.push({ k: 'lifecycle', event: 'compaction', at: null, ...(prevId ? { through: prevId } : {}) });
438
+ out.push({ k: 'message', message_id: id, role: 'system', ts: null, order: i, state: 'current', status: 'complete', parts: [summary] });
439
+ prevId = id;
440
+ continue;
441
+ }
442
+ // Any other entry: kept in its place as a notice naming the variant, and counted, so the
443
+ // messages after it keep their anchored ids (the header's rule).
444
+ const id = `${anchor}+${++n}`;
445
+ out.push({ k: 'message', message_id: id, role: 'system', ts: null, order: i, state: 'current', status: 'complete', parts: [{ part_id: '0', kind: 'notice', text: nameOf(k) }] });
446
+ prevId = id;
447
+ }
448
+
449
+ // Usage: the thread's own total, one per-session series, as a running total that writeLog keeps as
450
+ // increments (the header's usage rule), dated by the thread's last save. request_token_usage is
451
+ // not read.
452
+ const total = usageOf(thread.cumulative_token_usage);
453
+ const spent = total && [total.input, total.output, total.cache_read, total.cache_write].some((x) => x > 0);
454
+ const saved = ms(thread.updated_at) ?? ms(session.updated_at) ?? (row ? ms(row.updated_at) : null);
455
+ const usage = spent ? [{ k: 'usage', usage_id: 'session', running: true, ts: saved, ...(model ? { model } : {}), usage: total }] : [];
456
+
457
+ const head = {
458
+ k: 'session',
459
+ title: String((row && row.summary) || thread.title || ''),
460
+ cwd: firstFolder(row, thread),
461
+ created: row ? ms(row.created_at) : null,
462
+ updated: null,
463
+ archived: session.archived === true ? true : null,
464
+ version: '', incarnation: '',
465
+ mode: thread.profile ? String(thread.profile) : '',
466
+ ...(origin ? { kind: 'subagent' } : {}),
467
+ links,
468
+ ...(children.length ? { children } : {}),
469
+ };
470
+ return [...placeChildren([head, ...out]), ...usage];
471
+ }
472
+
473
+ module.exports = { HARNESS, VERSION, list, normalise, decodeData, storeRootDefault, dbPathOf };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+ // v4 prototype: OpenCode (v1 engine) → normalised session log records (editors/sessions-log.js).
3
+ // Input is the database read as rows ({ sessions, messages, parts }, the shapes in
4
+ // docs/design/adapter-specs/opencode.md §3); output is one record list per session.
5
+ //
6
+ // Rules from the spec:
7
+ // - order: messages by (time_created, id), parts by id;
8
+ // - a tool part holds both the call and its result;
9
+ // - tokens: step-finish parts, basis net (input = prompt − cache.read, output = completion − reasoning);
10
+ // - an assistant message still streaming (no time.completed, no error) is held back;
11
+ // - sub-agent: session.parent_id (explicit); fork: no column, a copied message's id-embedded time is
12
+ // later than its time_created (inferred), the origin found by content.
13
+
14
+ const crypto = require('crypto');
15
+
16
+ const MS_MOD = 2 ** 36; // ids carry the low 48 bits of ms·4096 + counter: ms modulo 2^36
17
+ const COPY_SLACK_MS = 1000;
18
+
19
+ /** The id-embedded time of an OpenCode id, modulo 2^36 ms. */
20
+ function idTime(id) {
21
+ const m = /^[a-z]+_([0-9a-f]{12})/.exec(String(id));
22
+ return m ? Math.floor(parseInt(m[1], 16) / 4096) % MS_MOD : null;
23
+ }
24
+ function wasCopied(row) {
25
+ const t = idTime(row.id);
26
+ if (t === null) return false;
27
+ return ((t - (Number(row.time_created) % MS_MOD) + MS_MOD) % MS_MOD) > COPY_SLACK_MS
28
+ && ((t - (Number(row.time_created) % MS_MOD) + MS_MOD) % MS_MOD) < MS_MOD / 2;
29
+ }
30
+
31
+ function partRecord(p) {
32
+ const d = p.data || {};
33
+ if (d.type === 'text') return { part_id: p.id, kind: 'text', text: d.text || '', ts: p.time_created };
34
+ if (d.type === 'reasoning') return { part_id: p.id, kind: 'reasoning', text: d.text || '', ts: p.time_created };
35
+ if (d.type === 'tool') {
36
+ const st = d.state || {};
37
+ return {
38
+ part_id: p.id, kind: 'tool', call_id: d.callID, tool: d.tool, args: st.input || {},
39
+ output: st.status === 'completed' || st.status === 'error' ? (st.output ?? st.error ?? '') : null,
40
+ is_error: st.status === 'error', ts: p.time_created,
41
+ };
42
+ }
43
+ return null; // step-start / step-finish / patch / snapshot: not message content
44
+ }
45
+ function usageOf(parts) {
46
+ const fins = parts.filter((p) => (p.data || {}).type === 'step-finish');
47
+ if (!fins.length) return null;
48
+ const u = { basis: 'net', input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 };
49
+ for (const p of fins) {
50
+ const t = p.data.tokens || {};
51
+ u.input += t.input || 0; u.output += t.output || 0; u.reasoning += t.reasoning || 0;
52
+ u.cache_read += (t.cache && t.cache.read) || 0; u.cache_write += (t.cache && t.cache.write) || 0;
53
+ }
54
+ return u;
55
+ }
56
+ const contentKey = (m) => crypto.createHash('sha256').update(JSON.stringify([m.role, (m.parts || []).map((p) => [p.kind, p.text ?? p.output ?? p.tool ?? ''])])).digest('hex');
57
+
58
+ /** { sessions, messages, parts } → Map(session_id → records) */
59
+ function normalise(db) {
60
+ const bySession = new Map();
61
+ const partsByMsg = new Map();
62
+ for (const p of db.parts || []) {
63
+ if (!partsByMsg.has(p.message_id)) partsByMsg.set(p.message_id, []);
64
+ partsByMsg.get(p.message_id).push(p);
65
+ }
66
+ for (const list of partsByMsg.values()) list.sort((a, b) => (a.id < b.id ? -1 : 1));
67
+ const msgsOf = new Map();
68
+ for (const m of db.messages || []) {
69
+ if (!msgsOf.has(m.session_id)) msgsOf.set(m.session_id, []);
70
+ msgsOf.get(m.session_id).push(m);
71
+ }
72
+ const messageRecords = new Map();
73
+ for (const s of db.sessions || []) {
74
+ const rows = (msgsOf.get(s.id) || []).sort((a, b) => (a.time_created - b.time_created) || (a.id < b.id ? -1 : 1));
75
+ const out = [];
76
+ for (const m of rows) {
77
+ const d = m.data || {};
78
+ if (d.role === 'assistant' && !(d.time && d.time.completed) && !d.error) continue; // still streaming
79
+ const parts = partsByMsg.get(m.id) || [];
80
+ out.push({
81
+ k: 'message', message_id: m.id, role: d.role, ts: m.time_created,
82
+ parent_id: d.parentID || '', model: d.modelID || (d.model && d.model.modelID) || '',
83
+ effort: d.variant || (d.model && d.model.variant) || '',
84
+ status: d.error ? 'error' : 'complete',
85
+ parts: parts.map(partRecord).filter(Boolean),
86
+ usage: d.role === 'assistant' ? usageOf(parts) : null,
87
+ _copied: wasCopied(m),
88
+ });
89
+ }
90
+ messageRecords.set(s.id, out);
91
+ }
92
+ // Links. Sub-agent: explicit. Fork: the copied prefix's content found in another session.
93
+ const index = new Map();
94
+ for (const [sid, recs] of messageRecords) for (const r of recs) if (!r._copied) {
95
+ const k = contentKey(r);
96
+ if (!index.has(k)) index.set(k, []);
97
+ index.get(k).push({ sid, message_id: r.message_id });
98
+ }
99
+ for (const s of db.sessions || []) {
100
+ const recs = messageRecords.get(s.id) || [];
101
+ const links = [];
102
+ if (s.parent_id) links.push({ kind: 'subagent', origin: s.parent_id, evidence: 'explicit', at: '' });
103
+ const copied = recs.filter((r) => r._copied);
104
+ if (copied.length) {
105
+ const hits = copied.map((r) => (index.get(contentKey(r)) || []).filter((h) => h.sid !== s.id));
106
+ const origins = new Set(hits.flat().map((h) => h.sid));
107
+ if (origins.size === 1 && hits.every((h) => h.length)) {
108
+ const origin = [...origins][0];
109
+ const last = hits[hits.length - 1].find((h) => h.sid === origin);
110
+ links.push({ kind: 'fork', origin, evidence: 'inferred', at: last.message_id, through: copied[copied.length - 1].message_id });
111
+ }
112
+ }
113
+ const session = { k: 'session', title: s.title || '', cwd: s.directory || '', created: s.time_created, archived: s.time_archived || null, version: s.version || '', links };
114
+ bySession.set(s.id, [session, ...recs.map(({ _copied, ...r }) => r)]);
115
+ }
116
+ return bySession;
117
+ }
118
+
119
+ module.exports = { normalise, idTime, wasCopied };
package/editors/scope.js CHANGED
@@ -29,16 +29,27 @@ function parseList(value) {
29
29
  }
30
30
 
31
31
  /** The adapters that should run, in the order given. */
32
+ // Names the v4 normalisers answer to (editors/normalise/): known to MEMHOUSE_EDITORS, though no 0.18
33
+ // adapter carries them. agy (the Antigravity CLI) also answers to 'antigravity'.
34
+ const NORMALISER_NAMES = ['agy', 'copilot-cli', 'cursor', 'cursor-agent', 'devin', 'gemini-cli', 'goose', 'opencode', 'vscode', 'zed'];
35
+ const NORMALISER_ALIASES = { agy: ['antigravity'] };
36
+ /** Whether MEMHOUSE_EDITORS lets a normaliser run (nothing set: every one). */
37
+ function normaliserInScope(harness, value) {
38
+ const wanted = parseList(value);
39
+ return !wanted.length || wanted.includes(harness) || (NORMALISER_ALIASES[harness] || []).some((a) => wanted.includes(a));
40
+ }
41
+
32
42
  function selectEditors(editors, value) {
33
43
  const wanted = parseList(value);
34
44
  if (!wanted.length) return editors;
35
45
  const byName = new Map(editors.map((e) => [e.name, e]));
36
- const unknown = wanted.filter((n) => !byName.has(n));
46
+ const aliases = Object.values(NORMALISER_ALIASES).flat();
47
+ const unknown = wanted.filter((n) => !byName.has(n) && !NORMALISER_NAMES.includes(n) && !aliases.includes(n));
37
48
  if (unknown.length) {
38
49
  throw new Error(`MEMHOUSE_EDITORS names an adapter that does not exist: ${unknown.join(', ')}. `
39
50
  + `Known: ${editors.map((e) => e.name).join(', ')}`);
40
51
  }
41
- return wanted.map((n) => byName.get(n));
52
+ return wanted.filter((n) => byName.has(n)).map((n) => byName.get(n));
42
53
  }
43
54
 
44
55
  function expandHome(p) {
@@ -123,4 +134,4 @@ function watching() {
123
134
  return [...watched.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([name, w]) => ({ name, ...w }));
124
135
  }
125
136
 
126
- module.exports = { parseList, selectEditors, selectClaudeRoots, selectRoot, expandHome, describe, keyFor, root, roots, fixed, watching };
137
+ module.exports = { parseList, selectEditors, selectClaudeRoots, selectRoot, expandHome, describe, keyFor, root, roots, fixed, watching, normaliserInScope, NORMALISER_NAMES, NORMALISER_ALIASES };