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,192 @@
1
+ 'use strict';
2
+ // The profile writer for imported sessions: harness-sessions-sql's profile tables (<m>_session_parts,
3
+ // <m>_calls, <m>_sessions; memhouse/house/schema-profile.sql.tpl). The parse becomes sessions-model
4
+ // records (records.js), records-to-profile.js turns them into rows, and the shared rollup
5
+ // (profile-rollup.js) fills the sessions row's derived columns after the rows are in. The keys are
6
+ // the parser's (claude-ai.js), copied; only the store differs from the layout 3.2 writer.
7
+ //
8
+ // The store of a claude.ai conversation is the service, not a machine (the sessions-model v1.x
9
+ // service-host proposal): host s- + the first 32 hex of sha256("sessions-model/service-host/v0:" +
10
+ // lowercase domain), harness_path the service origin, host_label the domain.
11
+
12
+ const crypto = require('crypto');
13
+ const py = require('./pyjson');
14
+ const C = require('./claude-ai');
15
+ const { recordsOf, iso } = require('./records');
16
+ const { profileRows } = require('../house/records-to-profile');
17
+ const sp = require('../house/schema-profile');
18
+ const rollup = require('../house/profile-rollup');
19
+ const V = require('./writer-v3');
20
+
21
+ const LAYOUT = 'profile';
22
+ const TABLES = ['session_parts', 'calls', 'sessions'];
23
+ const PARTS = 'session_parts';
24
+ const TOMBSTONE_ORDER = ['calls', 'session_parts', 'sessions'];
25
+ const ADAPTER = 'memhouse-import';
26
+ const { sqlStr, batchTag, tombstoneSQL, revBySession } = V;
27
+
28
+ /** A service's host (sessions-model v1.x service-host proposal): s- + 32 hex. */
29
+ function serviceHost(domain) {
30
+ return `s-${crypto.createHash('sha256').update(`sessions-model/service-host/v0:${String(domain).toLowerCase()}`).digest('hex').slice(0, 32)}`;
31
+ }
32
+
33
+ const SRC = {
34
+ harness: C.HARNESS, host: serviceHost(C.STORE), store: `https://${C.STORE}`, hostLabel: C.STORE,
35
+ adapter: ADAPTER, parserVersion: C.PARSER_VERSION,
36
+ };
37
+
38
+ function names(db, member) { return sp.profileNames(db, member); }
39
+
40
+ function storeWhere(src) {
41
+ return `host = ${sqlStr(src.host)} AND harness = ${sqlStr(src.harness)} AND harness_path = ${sqlStr(src.store)} AND incarnation = ''`;
42
+ }
43
+
44
+ let COLS = null;
45
+ const columns = (t) => {
46
+ if (!COLS) { const tpl = sp.readTemplate(); COLS = Object.fromEntries(TABLES.map((x) => [x, new Set(sp.insertColumns(tpl, x))])); }
47
+ return COLS[t];
48
+ };
49
+ const pick = (t, r) => Object.fromEntries(Object.entries(r).filter(([k, v]) => columns(t).has(k) && v !== undefined));
50
+
51
+ /** Every row of one session, by table. The sessions row holds the spec columns; the rollup adds the rest. */
52
+ function rows(s, { src, batch, rev }) {
53
+ const tag = batchTag(batch);
54
+ const key = { host: src.host, harness: src.harness, harness_path: src.store, session_id: s.sessionId, incarnation: '' };
55
+ const mapped = profileRows(recordsOf(s, { harnessVersion: tag }), key, { firstSeen: () => iso(s.created) });
56
+ const own = { rev, is_deleted: 0, removed: 0, adapter: src.adapter, adapter_ver: String(src.parserVersion), model_version: '' };
57
+ const byKey = new Map(s.parts.map((p) => [`${p.messageId}\u0000${p.partId}`, p]));
58
+ // A tool call's arguments as the export wrote them (compact JSON, numbers and key order kept): a
59
+ // JSON round trip would round an integer past 2^53 and drop a float's ".0".
60
+ const argsOf = new Map(s.parts.filter((p) => p.kind === 'tool_call').map((p) => [p.callId, py.compact(p.input === undefined ? {} : p.input)]));
61
+ const parts = mapped.parts.map((r) => {
62
+ const p = byKey.get(`${r.message_id}\u0000${r.part_id}`) || {};
63
+ return pick('session_parts', {
64
+ // text_len counts what was said, as the capture does: a tool result's output is in neither role's characters.
65
+ ...r, ...(r.kind === 'tool_call' && argsOf.has(r.call_id) ? { args: argsOf.get(r.call_id) } : {}), ...own, folder: s.folder, project: s.project, harness_version: tag, text_len: py.pyLen(String(r.text || '')),
66
+ origin: 'import', extra: py.dumps({ import_batch: batch, ...(p.extra || {}) }),
67
+ });
68
+ });
69
+ const calls = mapped.calls.map((c) => pick('calls', { ...c, ...(argsOf.has(c.call_id) ? { args: argsOf.get(c.call_id) } : {}), ...own, folder: s.folder, project: s.project }));
70
+ const sessions = [pick('sessions', { ...mapped.session, ...own, host_label: src.hostLabel, folder: s.folder, project: s.project, origin: 'import' })];
71
+ return { session_parts: parts, calls, sessions };
72
+ }
73
+
74
+ function presentSQL(N, src) {
75
+ return `SELECT session_id, toString(updated_at) AS updated_at, project, folder, title AS name, toString(mode) AS mode,
76
+ toString(created_at) AS created_at
77
+ FROM ${N.sessions} FINAL WHERE is_deleted = 0 AND ${storeWhere(src)}`;
78
+ }
79
+
80
+ function maxRevSQL(N, src, ids) {
81
+ const list = ids.map(sqlStr).join(', ');
82
+ return TABLES.map((t) => `SELECT session_id, max(rev) AS rev FROM ${N[t]} WHERE ${storeWhere(src)} AND session_id IN (${list}) GROUP BY session_id`);
83
+ }
84
+
85
+ /** A batch's live rows, per table: its parts and sessions carry the batch; calls go by its live parts. */
86
+ function batchWhere(N, src, batch) {
87
+ const own = `${storeWhere(src)} AND origin = 'import' AND harness_version = ${sqlStr(batchTag(batch))}`;
88
+ return {
89
+ session_parts: own,
90
+ sessions: own,
91
+ calls: `${storeWhere(src)} AND session_id IN (SELECT DISTINCT session_id FROM ${N.session_parts} FINAL WHERE is_deleted = 0 AND ${own})`,
92
+ };
93
+ }
94
+
95
+ function sessionsWhere(src, ids) {
96
+ const w = `${storeWhere(src)} AND session_id IN (${ids.map(sqlStr).join(', ')})`;
97
+ return { session_parts: w, calls: w, sessions: w };
98
+ }
99
+
100
+ // ── content: what a conversation says, apart from where and when it was imported ──────────
101
+ //
102
+ // The parts' own shas (records-to-profile.js: the part as it sits in its message, and the
103
+ // message's), which cover the text, the tool calls, their arguments and results, the roles and
104
+ // times, and nothing of the store or the batch; plus the session's name, mode and created_at.
105
+ // Calls are derived from the parts and add nothing. updated_at is compared on its own.
106
+
107
+ function contentSQL(N, src, ids) {
108
+ return {
109
+ // extra as stored (a String, returned verbatim): JSONExtractRaw would write the JSON out again.
110
+ parts: `SELECT session_id, message_id, part_id, content_sha, message_content_sha, extra FROM ${N.session_parts} FINAL
111
+ WHERE is_deleted = 0 AND removed = 0 AND ${storeWhere(src)} AND session_id IN (${ids.map(sqlStr).join(', ')})`,
112
+ };
113
+ }
114
+
115
+ function contentDigest(session, byName) {
116
+ const h = crypto.createHash('sha256');
117
+ h.update(JSON.stringify([session.name, session.mode, session.created_at === undefined ? null : session.created_at]));
118
+ // The files attached to a message ride in its first part's extra (claude-ai.js), outside the shas:
119
+ // compared parsed, so how the JSON was written (separators, escapes) never counts.
120
+ for (const l of (byName.parts || []).map((r) => JSON.stringify([r.message_id, r.part_id, String(r.content_sha), String(r.message_content_sha), filesOfExtra(r.extra)])).sort()) h.update(`\nP${l}`);
121
+ return h.digest('hex');
122
+ }
123
+
124
+ /** A part's attached file names, from its stored extra: '' when it has none. */
125
+ function filesOfExtra(extra) {
126
+ let o = null;
127
+ try { o = typeof extra === 'string' ? JSON.parse(extra || '{}') : extra; } catch { return ''; }
128
+ return o && Array.isArray(o.files) && o.files.length ? JSON.stringify(o.files) : '';
129
+ }
130
+
131
+ function parsedDigest(s, src) {
132
+ const r = rows(s, { src, batch: 'digest', rev: 0 });
133
+ const x = r.sessions[0];
134
+ return contentDigest({ name: x.title, mode: x.mode, created_at: s.created }, { parts: r.session_parts });
135
+ }
136
+
137
+ // ClickHouse refuses a statement over max_query_size (262,144 bytes by default), and the rollup
138
+ // repeats its session filter many times: each refresh stays under this.
139
+ const MAX_STATEMENT = 200000;
140
+
141
+ /** Fill the derived columns of these sessions' rows (the shared rollup), in statements under MAX_STATEMENT. */
142
+ async function refresh(house, N, ids, src) {
143
+ let size = 500;
144
+ for (let i = 0; i < ids.length;) {
145
+ const part = ids.slice(i, i + size);
146
+ const sql = rollup.refreshSessionsSQL(N, `${storeWhere(src)} AND session_id IN (${part.map(sqlStr).join(', ')})`);
147
+ if (sql.length > MAX_STATEMENT && part.length > 1) { size = Math.max(1, Math.floor(part.length / 2)); continue; }
148
+ await house.exec(sql);
149
+ i += part.length;
150
+ }
151
+ }
152
+
153
+ /**
154
+ * The sessions of this store whose row the rollup never filled: a run stopped between the sessions
155
+ * insert and the refresh (it left a live row with no start) while their parts are in. plan() lists
156
+ * them, and apply() refreshes them, whatever else it writes.
157
+ */
158
+ function unrefreshedSQL(N, src) {
159
+ return `SELECT session_id, harness_version FROM ${N.sessions} FINAL WHERE is_deleted = 0 AND removed = 0 AND ${storeWhere(src)} AND started IS NULL
160
+ AND session_id IN (SELECT DISTINCT session_id FROM ${N.session_parts} FINAL WHERE is_deleted = 0 AND ${storeWhere(src)})`;
161
+ }
162
+
163
+ /** Insert the rows of a batch, then let the shared rollup fill each sessions row's derived columns. */
164
+ async function writeRows(house, N, built, src, onProgress = () => {}, onInserted = async () => {}) {
165
+ const written = {};
166
+ for (const t of TABLES) {
167
+ const all = built.flatMap((b) => b[t]);
168
+ await house.insert(N[t], all);
169
+ written[t] = all.length;
170
+ onProgress(`inserted ${t}: ${all.length}`);
171
+ }
172
+ await onInserted();
173
+ const ids = built.map((b) => b.sessions[0].session_id);
174
+ await refresh(house, N, ids, src);
175
+ onProgress(`refreshed the sessions rows: ${ids.length}`);
176
+ return written;
177
+ }
178
+
179
+ function countsSQL(N, src, batch) {
180
+ const w = batchWhere(N, src, batch);
181
+ return {
182
+ sessions: `SELECT count() AS sessions, sum(messages) AS messages, sum(tool_calls) AS tool_calls FROM ${N.sessions} FINAL WHERE is_deleted = 0 AND ${w.sessions}`,
183
+ parts: `SELECT kind, count() AS n FROM ${N.session_parts} FINAL WHERE is_deleted = 0 AND ${w.session_parts} GROUP BY kind ORDER BY kind`,
184
+ calls: `SELECT count() AS n FROM ${N.calls} FINAL WHERE is_deleted = 0 AND ${w.calls}`,
185
+ };
186
+ }
187
+
188
+ module.exports = {
189
+ LAYOUT, TABLES, PARTS, TOMBSTONE_ORDER, ADAPTER, SRC, serviceHost, names, sqlStr, batchTag, storeWhere, rows, presentSQL, maxRevSQL,
190
+ batchWhere, sessionsWhere, tombstoneSQL, revBySession, contentSQL, contentDigest, parsedDigest, writeRows, countsSQL,
191
+ refresh, unrefreshedSQL, MAX_STATEMENT,
192
+ };
@@ -0,0 +1,240 @@
1
+ 'use strict';
2
+ // The layout 3.2 writer for imported sessions: <m>_turns, <m>_calls and <m>_sessions
3
+ // (memhouse/house/schema-v3.sql.tpl). It turns the parser's sessions into rows and says which
4
+ // rows belong to the import. Keys come from the parser unchanged (claude-ai.js); a writer for
5
+ // another layout implements the same functions over its own tables.
6
+
7
+ const crypto = require('crypto');
8
+ const py = require('./pyjson');
9
+
10
+ const C = require('./claude-ai');
11
+ const V3 = require('../house/schema-v3');
12
+
13
+ const LAYOUT = '3.2';
14
+ const TABLES = ['turns', 'calls', 'sessions'];
15
+ const PARTS = 'turns';
16
+ // Calls first: an undo finds them through the batch's live turns.
17
+ const TOMBSTONE_ORDER = ['calls', 'turns', 'sessions'];
18
+ const SRC = { harness: C.HARNESS, store: C.STORE, parserVersion: C.PARSER_VERSION };
19
+ function names(db, member) { return V3.v3Names(db, member); }
20
+
21
+ const sqlStr = (s) => `'${String(s).replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
22
+
23
+ /** harness_version of every turn and session row an import batch writes. */
24
+ function batchTag(batch) { return `import:${batch}`; }
25
+
26
+ /** The import's store: every row of an imported session matches this, and nothing else does. */
27
+ function storeWhere(src) {
28
+ return `harness = ${sqlStr(src.harness)} AND host = ${sqlStr(src.store)} AND harness_path = ${sqlStr(src.store)} AND incarnation = ''`;
29
+ }
30
+
31
+ function turnRows(s, { src, batch, rev }) {
32
+ const tag = batchTag(batch);
33
+ return s.parts.map((p, ord) => ({
34
+ harness: src.harness, harness_version: tag, harness_path: src.store, session_id: s.sessionId, incarnation: '',
35
+ agent_id: '', message_id: p.messageId, part_id: p.partId, part_ord: p.partOrd, parent_id: p.parentId,
36
+ response_id: p.responseId, ts: p.ts, ord, rev, is_deleted: 0, removed: 0, state: '', role: p.role, kind: p.kind,
37
+ model: '', effort: '', input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0,
38
+ reasoning_tokens: 0, text: p.text, text_len: py.pyLen(p.text), folder: s.folder, project: s.project,
39
+ host: src.store, origin: 'import', extra: py.dumps({ import_batch: batch, ...(p.extra || {}) }),
40
+ adapter_ver: src.parserVersion,
41
+ }));
42
+ }
43
+
44
+ function callRows(s, { src, rev }) {
45
+ return s.calls.map((c) => ({
46
+ harness: src.harness, harness_path: src.store, session_id: s.sessionId, incarnation: '', agent_id: '',
47
+ call_id: c.callId, message_id: c.messageId, ts: c.ts, rev, is_deleted: 0, removed: 0, tool_name: c.toolName,
48
+ args: c.args, result_ts: c.resultTs, is_error: c.isError, result_len: c.resultLen, model: '',
49
+ folder: s.folder, project: s.project, host: src.store, adapter_ver: src.parserVersion,
50
+ }));
51
+ }
52
+
53
+ const byCodePoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
54
+
55
+ function sessionRow(s, { src, batch, rev }) {
56
+ const turns = turnRows(s, { src, batch, rev });
57
+ const calls = callRows(s, { src, rev });
58
+ const text = turns.filter((t) => t.kind === 'text');
59
+ const ts = turns.map((t) => t.ts).sort(byCodePoint);
60
+ const hours = new Map();
61
+ for (const t of text) { const h = `${t.ts.slice(0, 13)}:00:00`; hours.set(h, (hours.get(h) || 0) + 1); }
62
+ const tools = new Map();
63
+ for (const c of calls) {
64
+ const v = tools.get(c.tool_name) || [0, 0];
65
+ v[0] += 1; v[1] += c.is_error ? 1 : 0;
66
+ tools.set(c.tool_name, v);
67
+ }
68
+ const hs = [...hours.keys()].sort(byCodePoint);
69
+ const tn = [...tools.keys()].sort(byCodePoint);
70
+ const first = text.find((t) => t.role === 'user');
71
+ const zeros = hs.map(() => 0);
72
+ return {
73
+ harness: src.harness, session_id: s.sessionId, incarnation: '', harness_version: batchTag(batch),
74
+ harness_path: src.store, host: src.store, host_label: src.store, name: s.name, mode: s.mode,
75
+ folder: s.folder, project: s.project, git_branch: '', created_at: s.created, updated_at: s.updated,
76
+ started: ts[0], ended: ts[ts.length - 1],
77
+ messages: text.length,
78
+ user_messages: text.filter((t) => t.role === 'user').length,
79
+ assistant_messages: text.filter((t) => t.role === 'assistant').length,
80
+ agent_messages: 0, tool_calls: calls.length,
81
+ input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, cache_write_tokens: 0, reasoning_tokens: 0,
82
+ user_chars: text.filter((t) => t.role === 'user').reduce((a, t) => a + t.text_len, 0),
83
+ assistant_chars: text.filter((t) => t.role === 'assistant').reduce((a, t) => a + t.text_len, 0),
84
+ first_prompt: first ? first.text : '', models: [], dominant_model: '',
85
+ days: [...new Set(turns.map((t) => t.ts.slice(0, 10)))].sort(byCodePoint),
86
+ 'usage.hour': hs, 'usage.model': hs.map(() => ''), 'usage.messages': hs.map((h) => hours.get(h)),
87
+ 'usage.input': zeros, 'usage.output': zeros, 'usage.cache_read': zeros, 'usage.cache_write': zeros,
88
+ 'usage.reasoning': zeros,
89
+ 'tools.name': tn, 'tools.calls': tn.map((n) => tools.get(n)[0]), 'tools.errors': tn.map((n) => tools.get(n)[1]),
90
+ 'agents.agent_id': [], 'agents.type': [], 'agents.description': [], 'agents.workflow': [],
91
+ 'agents.messages': [], 'agents.first_ts': [],
92
+ 'links.kind': [], 'links.origin': [], 'links.origin_store': [], 'links.evidence': [], 'links.at': [],
93
+ 'links.through': [],
94
+ // The export carries no token counts: unmeasured, not free.
95
+ usage_basis: 'none', origin: 'import', rev, is_deleted: 0, removed: 0,
96
+ };
97
+ }
98
+
99
+ /** Every row of one session, by table. */
100
+ function rows(s, opts) {
101
+ return { turns: turnRows(s, opts), calls: callRows(s, opts), sessions: [sessionRow(s, opts)] };
102
+ }
103
+
104
+ /** The imported sessions the house holds now: id, the export's updated_at, project, folder, and the session's content fields. */
105
+ function presentSQL(names, src) {
106
+ return `SELECT session_id, toString(updated_at) AS updated_at, project, folder, name, toString(mode) AS mode,
107
+ toString(created_at) AS created_at
108
+ FROM ${names.sessions} FINAL WHERE is_deleted = 0 AND ${storeWhere(src)}`;
109
+ }
110
+
111
+ // ── content: what a conversation says, apart from where and when it was imported ──────────
112
+ //
113
+ // Two imports of one conversation are the same content when these match:
114
+ // session name, mode, created_at
115
+ // turns message_id, part_id, part_ord, parent_id, response_id, ts, ord, role, kind,
116
+ // md5(text), md5(extra without import_batch)
117
+ // calls call_id, message_id, ts, tool_name, md5(args), result_ts, is_error, result_len
118
+ // Never in it: project and folder (an archive gives a slug and a path, an export none),
119
+ // harness_version and extra.import_batch (they name the batch), rev, is_deleted, removed,
120
+ // ingested_at, adapter_ver, updated_at (compared on its own), the session's derived totals, and
121
+ // the columns every imported row has alike. Long values go as their md5, computed by the house
122
+ // for stored rows and here for parsed ones, so the comparison reads hashes, not texts.
123
+
124
+ const md5 = (s) => crypto.createHash('md5').update(String(s), 'utf8').digest('hex');
125
+ // extra as the writer stores it is {"import_batch": "<batch>", …the part's own…}, pyjson's text with
126
+ // the batch first; the batch id has no quote in it (import.js checkBatchId). One pattern removes
127
+ // exactly that key, applied as text on both sides: in the house to the stored extra, here to the
128
+ // extra the writer would store. Parsing the JSON in ClickHouse would change its separators and
129
+ // number forms, and every row would differ.
130
+ const STRIP_BATCH = '^\\{"import_batch": "[^"]*"(, )?';
131
+ const STRIP_BATCH_SQL = `replaceRegexpOne(extra, '${STRIP_BATCH.replace(/\\/g, '\\\\')}', '{')`;
132
+ const stripBatch = (extra) => extra.replace(new RegExp(STRIP_BATCH), '{');
133
+
134
+ const turnContent = (t) => JSON.stringify([t.message_id, t.part_id, Number(t.part_ord), t.parent_id, t.response_id, t.ts,
135
+ Number(t.ord), t.role, t.kind, t.text_md5, t.extra_md5]);
136
+ const callContent = (c) => JSON.stringify([c.call_id, c.message_id, c.ts, c.tool_name, c.args_md5, c.result_ts === undefined ? null : c.result_ts,
137
+ Boolean(c.is_error), Number(c.result_len)]);
138
+ const sessionContent = (x) => JSON.stringify([x.name, x.mode, x.created_at === undefined ? null : x.created_at]);
139
+
140
+ /** One digest from a session's content lines; order-free. */
141
+ function contentDigest(session, turns, calls) {
142
+ const h = crypto.createHash('sha256');
143
+ h.update(sessionContent(session));
144
+ for (const l of turns.map(turnContent).sort()) h.update(`\nT${l}`);
145
+ for (const l of calls.map(callContent).sort()) h.update(`\nC${l}`);
146
+ return h.digest('hex');
147
+ }
148
+
149
+ /** The content digest of a parsed session: of the rows this writer would store for it. */
150
+ function parsedDigest(s, src) {
151
+ const r = rows(s, { src, batch: 'digest', rev: 0 });
152
+ const turns = r.turns.map((t) => ({ ...t, text_md5: md5(t.text), extra_md5: md5(stripBatch(t.extra)) }));
153
+ const calls = r.calls.map((c) => ({ ...c, args_md5: md5(c.args) }));
154
+ return contentDigest(r.sessions[0], turns, calls);
155
+ }
156
+
157
+ /** The stored rows' content of these sessions, per table, hashed in the house. */
158
+ function contentSQL(names, src, ids) {
159
+ const w = `is_deleted = 0 AND ${storeWhere(src)} AND session_id IN (${ids.map(sqlStr).join(', ')})`;
160
+ return {
161
+ turns: `SELECT session_id, message_id, part_id, part_ord, parent_id, response_id, toString(ts) AS ts, ord, toString(role) AS role,
162
+ toString(kind) AS kind, lower(hex(MD5(text))) AS text_md5, lower(hex(MD5(${STRIP_BATCH_SQL}))) AS extra_md5
163
+ FROM ${names.turns} FINAL WHERE ${w}`,
164
+ calls: `SELECT session_id, call_id, message_id, toString(ts) AS ts, toString(tool_name) AS tool_name, lower(hex(MD5(args))) AS args_md5,
165
+ toString(result_ts) AS result_ts, is_error, result_len
166
+ FROM ${names.calls} FINAL WHERE ${w}`,
167
+ };
168
+ }
169
+
170
+ /** The highest rev any row of these sessions carries, tombstones included (no FINAL), per table. */
171
+ function maxRevSQL(names, src, ids) {
172
+ const list = ids.map(sqlStr).join(', ');
173
+ return TABLES.map((t) => `SELECT session_id, max(rev) AS rev FROM ${names[t]}
174
+ WHERE ${storeWhere(src)} AND session_id IN (${list}) GROUP BY session_id`);
175
+ }
176
+
177
+ /** Which live rows belong to a batch, per table. Calls carry no batch, so they go by the batch's live turns. */
178
+ function batchWhere(names, src, batch) {
179
+ const own = `${storeWhere(src)} AND origin = 'import' AND harness_version = ${sqlStr(batchTag(batch))}`;
180
+ return {
181
+ turns: own,
182
+ sessions: own,
183
+ calls: `${storeWhere(src)} AND session_id IN (SELECT DISTINCT session_id FROM ${names.turns} FINAL WHERE is_deleted = 0 AND ${own})`,
184
+ };
185
+ }
186
+
187
+ /** Which live rows belong to these sessions, per table. */
188
+ function sessionsWhere(src, ids) {
189
+ const w = `${storeWhere(src)} AND session_id IN (${ids.map(sqlStr).join(', ')})`;
190
+ return { turns: w, calls: w, sessions: w };
191
+ }
192
+
193
+ /**
194
+ * A tombstone for every live row the predicate selects: a full copy at a higher rev with
195
+ * is_deleted = 1, which FINAL reads hide at once and the shipper's maintenance cleanup drops.
196
+ * `cols` are the table's insertable columns; ingested_at is left to its default. `revExpr` is
197
+ * SQL over the row (`rev + 1`, or a per-session value).
198
+ */
199
+ function tombstoneSQL(fq, cols, where, revExpr) {
200
+ const use = cols.filter((c) => c !== 'ingested_at');
201
+ const q = (c) => `\`${c}\``;
202
+ const sel = use.map((c) => (c === 'rev' ? `toUInt64(${revExpr})` : c === 'is_deleted' ? 'toUInt8(1)' : q(c)));
203
+ return `INSERT INTO ${fq} (${use.map(q).join(', ')}) SELECT ${sel.join(', ')} FROM ${fq} FINAL WHERE is_deleted = 0 AND ${where}`;
204
+ }
205
+
206
+ /** `rev` for each session id, as SQL over session_id. */
207
+ function revBySession(revs) {
208
+ const ids = [...revs.keys()];
209
+ return `transform(session_id, [${ids.map(sqlStr).join(', ')}], [${ids.map((i) => `toUInt64(${revs.get(i)})`).join(', ')}], toUInt64(0))`;
210
+ }
211
+
212
+ /** contentDigest over contentSQL's rows, by query name. */
213
+ const contentDigestOf = (session, by) => contentDigest(session, by.turns || [], by.calls || []);
214
+
215
+ /** Insert the rows of a batch: turns, calls, then the session rows. */
216
+ async function writeRows(house, N, built, src, onProgress = () => {}, onInserted = async () => {}) {
217
+ const written = {};
218
+ for (const t of TABLES) {
219
+ const all = built.flatMap((b) => b[t]);
220
+ await house.insert(N[t], all);
221
+ written[t] = all.length;
222
+ onProgress(`inserted ${t}: ${all.length}`);
223
+ }
224
+ await onInserted();
225
+ return written;
226
+ }
227
+
228
+ function countsSQL(N, src, batch) {
229
+ const w = batchWhere(N, src, batch);
230
+ return {
231
+ sessions: `SELECT count() AS sessions, sum(messages) AS messages, sum(tool_calls) AS tool_calls FROM ${N.sessions} FINAL WHERE is_deleted = 0 AND ${w.sessions}`,
232
+ parts: `SELECT kind, count() AS n FROM ${N.turns} FINAL WHERE is_deleted = 0 AND ${w.turns} GROUP BY kind ORDER BY kind`,
233
+ calls: `SELECT count() AS n FROM ${N.calls} FINAL WHERE is_deleted = 0 AND ${w.calls}`,
234
+ };
235
+ }
236
+
237
+ module.exports = {
238
+ LAYOUT, TABLES, PARTS, TOMBSTONE_ORDER, SRC, names, writeRows, countsSQL, contentDigestOf, sqlStr, batchTag, storeWhere, turnRows, callRows, sessionRow, rows,
239
+ presentSQL, maxRevSQL, batchWhere, sessionsWhere, tombstoneSQL, revBySession, contentSQL, contentDigest, parsedDigest, stripBatch, STRIP_BATCH_SQL,
240
+ };
@@ -0,0 +1,112 @@
1
+ 'use strict';
2
+ // Read entries out of a .zip file: enough for a claude.ai data export, with no dependency.
3
+ // Stored and deflated entries, ZIP64 sizes and offsets, CRC-checked. Reads only the entries
4
+ // asked for, through the file descriptor, so a large export is never loaded whole.
5
+
6
+ const fs = require('fs');
7
+ const zlib = require('zlib');
8
+
9
+ const SIG = { eocd: 0x06054b50, eocd64: 0x06064b50, locator64: 0x07064b50, central: 0x02014b50, local: 0x04034b50 };
10
+
11
+ class ZipError extends Error {}
12
+
13
+ function readAt(fd, pos, len) {
14
+ const buf = Buffer.alloc(len);
15
+ let got = 0;
16
+ while (got < len) {
17
+ const r = fs.readSync(fd, buf, got, len - got, pos + got);
18
+ if (r === 0) throw new ZipError('unexpected end of file');
19
+ got += r;
20
+ }
21
+ return buf;
22
+ }
23
+
24
+ function isZip(file) {
25
+ let fd;
26
+ try {
27
+ fd = fs.openSync(file, 'r');
28
+ const b = Buffer.alloc(4);
29
+ return fs.readSync(fd, b, 0, 4, 0) === 4 && b.readUInt32LE(0) === SIG.local;
30
+ } catch { return false; } finally { if (fd !== undefined) fs.closeSync(fd); }
31
+ }
32
+
33
+ /** Open a zip: { names, has(name), read(name) -> Buffer, close() }. */
34
+ function openZip(file) {
35
+ const fd = fs.openSync(file, 'r');
36
+ try {
37
+ const size = fs.fstatSync(fd).size;
38
+ const tailLen = Math.min(size, 22 + 0xffff);
39
+ const tail = readAt(fd, size - tailLen, tailLen);
40
+ let at = -1;
41
+ for (let k = tailLen - 22; k >= 0; k--) if (tail.readUInt32LE(k) === SIG.eocd) { at = k; break; }
42
+ if (at < 0) throw new ZipError('not a zip file (no end of central directory)');
43
+ let count = tail.readUInt16LE(at + 10);
44
+ let cdSize = tail.readUInt32LE(at + 12);
45
+ let cdOffset = tail.readUInt32LE(at + 16);
46
+ if (count === 0xffff || cdSize === 0xffffffff || cdOffset === 0xffffffff) {
47
+ const locPos = size - tailLen + at - 20;
48
+ const loc = readAt(fd, locPos, 20);
49
+ if (loc.readUInt32LE(0) !== SIG.locator64) throw new ZipError('ZIP64 locator missing');
50
+ const e64 = readAt(fd, Number(loc.readBigUInt64LE(8)), 56);
51
+ if (e64.readUInt32LE(0) !== SIG.eocd64) throw new ZipError('ZIP64 end of central directory missing');
52
+ count = Number(e64.readBigUInt64LE(32));
53
+ cdSize = Number(e64.readBigUInt64LE(40));
54
+ cdOffset = Number(e64.readBigUInt64LE(48));
55
+ }
56
+ const cd = readAt(fd, cdOffset, cdSize);
57
+ const entries = new Map();
58
+ let p = 0;
59
+ for (let k = 0; k < count; k++) {
60
+ if (cd.readUInt32LE(p) !== SIG.central) throw new ZipError('bad central directory entry');
61
+ const flags = cd.readUInt16LE(p + 8);
62
+ const method = cd.readUInt16LE(p + 10);
63
+ const crc = cd.readUInt32LE(p + 16);
64
+ let comp = cd.readUInt32LE(p + 20);
65
+ let uncomp = cd.readUInt32LE(p + 24);
66
+ const nameLen = cd.readUInt16LE(p + 28);
67
+ const extraLen = cd.readUInt16LE(p + 30);
68
+ const commentLen = cd.readUInt16LE(p + 32);
69
+ let offset = cd.readUInt32LE(p + 42);
70
+ const name = cd.toString('utf-8', p + 46, p + 46 + nameLen);
71
+ let x = p + 46 + nameLen;
72
+ const xEnd = x + extraLen;
73
+ while (x + 4 <= xEnd) {
74
+ const id = cd.readUInt16LE(x);
75
+ const len = cd.readUInt16LE(x + 2);
76
+ if (id === 0x0001) {
77
+ let y = x + 4;
78
+ if (uncomp === 0xffffffff) { uncomp = Number(cd.readBigUInt64LE(y)); y += 8; }
79
+ if (comp === 0xffffffff) { comp = Number(cd.readBigUInt64LE(y)); y += 8; }
80
+ if (offset === 0xffffffff) { offset = Number(cd.readBigUInt64LE(y)); y += 8; }
81
+ }
82
+ x += 4 + len;
83
+ }
84
+ entries.set(name, { name, flags, method, crc, comp, uncomp, offset });
85
+ p = xEnd + commentLen;
86
+ }
87
+ const read = (name) => {
88
+ const e = entries.get(name);
89
+ if (!e) throw new ZipError(`no entry ${name}`);
90
+ if (e.flags & 1) throw new ZipError(`${name} is encrypted`);
91
+ const h = readAt(fd, e.offset, 30);
92
+ if (h.readUInt32LE(0) !== SIG.local) throw new ZipError(`bad local header for ${name}`);
93
+ const data = readAt(fd, e.offset + 30 + h.readUInt16LE(26) + h.readUInt16LE(28), e.comp);
94
+ let out;
95
+ if (e.method === 0) out = data;
96
+ else if (e.method === 8) {
97
+ // Never inflate past the declared size: an entry that claims little and expands to a lot
98
+ // is refused here, not after it has filled memory.
99
+ try { out = zlib.inflateRawSync(data, { maxOutputLength: Math.max(1, e.uncomp) }); } catch (err) {
100
+ throw new ZipError(`${name}: ${err && err.code === 'ERR_BUFFER_TOO_LARGE' ? 'larger than its declared size' : `cannot inflate (${err.message})`}`);
101
+ }
102
+ }
103
+ else throw new ZipError(`${name}: compression method ${e.method} is not supported`);
104
+ if (out.length !== e.uncomp) throw new ZipError(`${name}: size mismatch`);
105
+ if (typeof zlib.crc32 === 'function' && (zlib.crc32(out) >>> 0) !== e.crc) throw new ZipError(`${name}: CRC mismatch`);
106
+ return out;
107
+ };
108
+ return { names: [...entries.keys()], has: (n) => entries.has(n), read, close: () => fs.closeSync(fd) };
109
+ } catch (e) { fs.closeSync(fd); throw e; }
110
+ }
111
+
112
+ module.exports = { openZip, isZip, ZipError };
@@ -0,0 +1,102 @@
1
+ // The invitation page, MEMHOUSE-INVITATION.md: what `memhouse invite` writes beside the invite
2
+ // file it hands over (delivery/INVITATION.md.tpl, rendered here).
3
+ //
4
+ // Rendered, not linked: it names THIS invite's file, this house, the tables this invite created
5
+ // and, until the build is published, the exact tarball to install. A static page can carry none
6
+ // of that. Nothing secret goes into it, and nothing here reads the invite file.
7
+ //
8
+ // Two ways the page used to contradict the invite it came with:
9
+ // * the tables. It listed the 0.18 rooms (`<m>_messages`, …) for every invite, so an invite
10
+ // into a schema-3 house described tables that do not exist. The names now come from the
11
+ // functions provisioning names them with: profileNames() for schema 3, roomNames() and
12
+ // statTableNames() for the 0.18 rooms.
13
+ // * the file. Step 0 was `ls invite-*.env`, which matches nothing when the invite was written
14
+ // with `--out <dir>/<other-name>.env`. Every command now names the file this invite wrote.
15
+ //
16
+ // Pure apart from reading the template: a test renders it without a server.
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+ const { roomNames, statTableNames, ROOM_TYPES, META_TYPES } = require('./house/house');
21
+ const SP = require('./house/schema-profile');
22
+ const { isInviteFileName } = require('./invitefile');
23
+ const { quoteShell } = require('./envfile');
24
+
25
+ const PAGE = 'MEMHOUSE-INVITATION.md';
26
+ const TEMPLATE = path.join(__dirname, 'delivery', 'INVITATION.md.tpl');
27
+
28
+ function readTemplate() { return fs.readFileSync(TEMPLATE, 'utf-8'); }
29
+
30
+ const code = (s) => `\`${s}\``;
31
+
32
+ /** A file name as one shell word: bare when it needs no quoting, never read as an option. */
33
+ function shellWord(name) {
34
+ const s = String(name);
35
+ const w = /^[A-Za-z0-9._+@%=:,-]+$/.test(s) ? s : quoteShell(s);
36
+ return s.startsWith('-') ? `./${w}` : w;
37
+ }
38
+
39
+ /** Section 4's opening: the tables this invite created, by the layout it provisioned. */
40
+ function ownedTables({ db, name, schema }) {
41
+ if (String(schema) === '3') {
42
+ const N = SP.profileNames(db, name);
43
+ return [
44
+ `Your memory lives in tables named for you. Your conversations, in ${code(db)}, under one grant on ${code(`${db}.${name}_*`)}:`,
45
+ '',
46
+ ...SP.CONVERSATION_TYPES.map((t) => `- ${code(N[t])}`),
47
+ '',
48
+ `Every transcript byte as captured, in the archive database ${code(N.archive)}, where you may read and add rows (${code('SELECT, INSERT')}) and nothing more:`,
49
+ '',
50
+ ...SP.CAPTURE_TYPES.map((t) => `- ${code(N[t])}`),
51
+ ].join('\n');
52
+ }
53
+ const R = roomNames(name).physical;
54
+ const fq = (t) => code(`${db}.${t}`);
55
+ return [
56
+ `Your memory lives in tables named for you inside ${code(db)}, and you hold exactly one grant, on ${code(`${db}.${name}_*`)}:`,
57
+ '',
58
+ `- ${ROOM_TYPES.map((t) => fq(R[t])).join(', ')}: your conversations`,
59
+ `- ${META_TYPES.map((t) => fq(R[t])).join(', ')}: bookkeeping`,
60
+ `- ${Object.values(statTableNames(name)).map(fq).join(', ')}: rollups the dashboard reads`,
61
+ ].join('\n');
62
+ }
63
+
64
+ /** Step 2's command. Bare `memhouse` offers only a file it recognises by name (invitefile.js). */
65
+ function joinStep(file) {
66
+ const arg = shellWord(file);
67
+ if (isInviteFileName(file)) {
68
+ return [
69
+ `From the directory holding ${code(file)}, just run memhouse — it finds the invitation and asks:`,
70
+ '', '```', 'memhouse', '```', '',
71
+ `(or, without the question: ${code(`memhouse install --env ${arg}`)})`,
72
+ ].join('\n');
73
+ }
74
+ return [`From the directory holding ${code(file)}:`, '', '```', `memhouse install --env ${arg}`, '```'].join('\n');
75
+ }
76
+
77
+ /** `{{KEY}}` → values[KEY], in one pass, so a value is never itself rendered. */
78
+ function render(tpl, values) {
79
+ return String(tpl).replace(/\{\{([A-Z_]+)\}\}/g, (m, k) => (Object.hasOwn(values, k) ? String(values[k]) : m));
80
+ }
81
+
82
+ /**
83
+ * The page for the invite file written at `out`: where it goes (beside that file) and what it
84
+ * says. `schema` is the layout the invite provisioned ('3', anything else for the 0.18 rooms);
85
+ * `install` is step 1's block and `version` what `memhouse --version` should print.
86
+ */
87
+ /** When the invite's password stops working, or '' for an invite with no expiry. */
88
+ function expiryLine(expires, inviter) {
89
+ return expires ? `**This invitation expires at ${expires} (UTC).** Its password stops working then; install before that, or ask ${inviter} for a new one.\n\n` : '';
90
+ }
91
+
92
+ function invitationPage({ out, name, url, db, schema, version, install, inviter, template, expires = null }) {
93
+ const file = path.basename(out);
94
+ const text = render(template === undefined ? readTemplate() : template, {
95
+ NAME: name, FILE: file, FILE_ARG: shellWord(file), URL: url, DB: db, VERSION: version,
96
+ INSTALL: install, INVITER: inviter, JOIN: joinStep(file), OWNED: ownedTables({ db, name, schema }),
97
+ EXPIRY: expiryLine(expires, inviter),
98
+ });
99
+ return { path: path.join(path.dirname(out), PAGE), file, text };
100
+ }
101
+
102
+ module.exports = { PAGE, TEMPLATE, readTemplate, shellWord, ownedTables, joinStep, expiryLine, render, invitationPage };