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,181 @@
1
+ // The dashboard's token: a per-user secret every /api request must present.
2
+ //
3
+ // $MEMHOUSE_HOME/dashboard-token 64 hex characters (32 random bytes), mode 600, made by the
4
+ // first start when absent. A file group or other users can
5
+ // read or write is refused, as memhouse refuses such a password
6
+ // file (memhouse/admin-secret.js).
7
+ // $MEMHOUSE_HOME/dashboard.html written by `memhouse dashboard`, mode 600: a page that
8
+ // redirects to http://127.0.0.1:<port>/#token=<token>. The
9
+ // browser is given this FILE, so the token is in no argv.
10
+ //
11
+ // The server reads the token from the file; it is never passed to it in argv or the environment.
12
+ //
13
+ // The token travels in the URL's fragment, which a browser sends to no server and puts in no
14
+ // Referer. The page (ui/src/lib/dashboard-token.js) moves it to sessionStorage, which is scoped to
15
+ // the exact origin, port included, strips it from the address bar, and sends it as
16
+ // `Authorization: Bearer <token>` on every /api request. No cookie is set or accepted.
17
+ //
18
+ // /api/* without a valid Bearer: 401. The one exception is GET /api/proof?nonce=<hex>, which
19
+ // answers HMAC(token, "memhouse dashboard proof:" + nonce): `memhouse dashboard` checks it before
20
+ // it writes or opens anything, so the token is only ever handed to the server that holds it. The
21
+ // static page itself carries no house data and needs no token. Every comparison is constant-time.
22
+
23
+ const crypto = require('crypto');
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const { readSecretFile } = require('../admin-secret');
27
+
28
+ const TOKEN_FILE = 'dashboard-token';
29
+ const OPENER_FILE = 'dashboard.html';
30
+ const TOKEN_RE = /^[0-9a-f]{64}$/;
31
+ const NONCE_RE = /^[0-9a-f]{32,128}$/;
32
+ const LABEL = 'the dashboard token';
33
+ // The address the dashboard binds by default, opened literally: `localhost` can resolve to ::1
34
+ // first, where a different listener may be.
35
+ const LOOPBACK = '127.0.0.1';
36
+
37
+ const tokenPath = (home) => path.join(home, TOKEN_FILE);
38
+ const openerPath = (home) => path.join(home, OPENER_FILE);
39
+
40
+ /** Create `file` holding `data`, mode 600, only when it does not exist. False when it already did. */
41
+ function createExclusive(file, data) {
42
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
43
+ fs.writeFileSync(tmp, data, { mode: 0o600, flag: 'wx' });
44
+ try {
45
+ // link() fails when the name exists, so two starts at once agree on one token, and a reader
46
+ // never sees a half-written file.
47
+ try { fs.linkSync(tmp, file); return true; } catch (e) {
48
+ if (e.code === 'EEXIST') return false;
49
+ // A filesystem without hard links.
50
+ try { fs.writeFileSync(file, data, { mode: 0o600, flag: 'wx' }); return true; } catch (e2) {
51
+ if (e2.code === 'EEXIST') return false;
52
+ throw e2;
53
+ }
54
+ }
55
+ } finally { fs.rmSync(tmp, { force: true }); }
56
+ }
57
+
58
+ /**
59
+ * The token in `home`, checked: mode 600 (on POSIX) and 64 hex characters. Throws with the fix
60
+ * when it is absent, open to other users, or malformed.
61
+ */
62
+ function readToken(home, io = {}) {
63
+ const file = tokenPath(home);
64
+ if (!fs.existsSync(file)) throw new Error(`${LABEL} ${file} does not exist — memhouse start creates it`);
65
+ const token = readSecretFile(file, io, LABEL).trim();
66
+ if (!TOKEN_RE.test(token)) {
67
+ throw new Error(`${LABEL} ${file} does not hold a token (64 hex characters) — delete it, then restart the dashboard (memhouse stop && memhouse start) to make a new one`);
68
+ }
69
+ return token;
70
+ }
71
+
72
+ /** The token in `home`, made first when there is none. Throws as readToken does. */
73
+ function ensureToken(home, io = {}) {
74
+ const file = tokenPath(home);
75
+ if (!fs.existsSync(file)) {
76
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
77
+ createExclusive(file, `${crypto.randomBytes(32).toString('hex')}\n`);
78
+ }
79
+ return readToken(home, io);
80
+ }
81
+
82
+ const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest();
83
+ /** Equal strings, in time that does not depend on where they differ (or on their lengths). */
84
+ function tokensEqual(a, b) {
85
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
86
+ return crypto.timingSafeEqual(sha256(a), sha256(b));
87
+ }
88
+
89
+ /** What the server holding `token` answers for `nonce` at /api/proof. */
90
+ const proof = (token, nonce) => crypto.createHmac('sha256', token).update(`memhouse dashboard proof:${nonce}`).digest('hex');
91
+ const newNonce = () => crypto.randomBytes(16).toString('hex');
92
+
93
+ const baseUrl = (port) => `http://${LOOPBACK}:${port}`;
94
+ const openUrl = (port, token) => `${baseUrl(port)}/#token=${token}`;
95
+
96
+ /**
97
+ * Is this an /api request? Decided on the decoded, lower-cased path with repeated slashes folded,
98
+ * so no spelling the router might still match (`/API/…`, `//api/…`, `/api%2F…`) gets past it.
99
+ */
100
+ function apiPath(req) {
101
+ let p = String(req.path || String(req.originalUrl || req.url || '/').split('?')[0]);
102
+ try { p = decodeURIComponent(p); } catch { /* malformed escapes: judged as written */ }
103
+ p = p.toLowerCase().replace(/\/{2,}/g, '/');
104
+ return p === '/api' || p.startsWith('/api/') ? p : null;
105
+ }
106
+
107
+ function deny(res) {
108
+ res.setHeader('Cache-Control', 'no-store');
109
+ res.setHeader('WWW-Authenticate', 'Bearer realm="memhouse"');
110
+ return res.status(401).json({ error: 'unauthorized — open the dashboard with: memhouse dashboard' });
111
+ }
112
+
113
+ /**
114
+ * Express middleware, ahead of every route. `token()` is read per request: until the server has
115
+ * read it, every /api request is refused. Paths outside /api (the page and its assets) pass.
116
+ */
117
+ function middleware({ token }) {
118
+ return (req, res, next) => {
119
+ const p = apiPath(req);
120
+ if (!p) return next();
121
+ const t = token();
122
+ if (!t) return deny(res);
123
+ if (p === '/api/proof' && (req.method === 'GET' || req.method === 'HEAD')) {
124
+ const nonce = String((req.query && req.query.nonce) || '');
125
+ res.setHeader('Cache-Control', 'no-store');
126
+ if (!NONCE_RE.test(nonce)) return res.status(400).json({ error: 'nonce: 32 to 128 hex characters' });
127
+ return res.json({ proof: proof(t, nonce) });
128
+ }
129
+ const m = /^Bearer\s+(\S+)\s*$/i.exec(String(req.headers.authorization || ''));
130
+ if (m && tokensEqual(m[1], t)) return next();
131
+ return deny(res);
132
+ };
133
+ }
134
+
135
+ const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
136
+
137
+ /** The page `memhouse dashboard` opens: it redirects to `url`. Written mode 600; returns its path. */
138
+ function writeOpener(home, url) {
139
+ const file = openerPath(home);
140
+ const u = escapeHtml(url);
141
+ const html = `<!doctype html>
142
+ <html lang="en"><head><meta charset="utf-8">
143
+ <meta name="referrer" content="no-referrer">
144
+ <meta http-equiv="refresh" content="0; url=${u}">
145
+ <title>memhouse dashboard</title></head>
146
+ <body><p><a href="${u}">Open the memhouse dashboard</a></p></body></html>
147
+ `;
148
+ fs.mkdirSync(home, { recursive: true, mode: 0o700 });
149
+ const tmp = `${file}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
150
+ fs.writeFileSync(tmp, html, { mode: 0o600, flag: 'wx' });
151
+ try { fs.renameSync(tmp, file); } catch (e) { fs.rmSync(tmp, { force: true }); throw e; }
152
+ return file;
153
+ }
154
+
155
+ /**
156
+ * Ask the server on 127.0.0.1:<port> to prove it holds `token`, without sending the token.
157
+ * Resolves true only for the matching HMAC of a fresh nonce.
158
+ */
159
+ function verifyServer(port, token, { timeoutMs = 3000 } = {}) {
160
+ const http = require('http');
161
+ const nonce = newNonce();
162
+ return new Promise((resolve) => {
163
+ const req = http.get({ host: LOOPBACK, port, path: `/api/proof?nonce=${nonce}`, timeout: timeoutMs }, (res) => {
164
+ let body = '';
165
+ res.setEncoding('utf-8');
166
+ res.on('data', (d) => { if (body.length < 4096) body += d; });
167
+ res.on('end', () => {
168
+ let got = '';
169
+ try { got = String(JSON.parse(body).proof || ''); } catch { /* not ours */ }
170
+ resolve(res.statusCode === 200 && tokensEqual(got, proof(token, nonce)));
171
+ });
172
+ });
173
+ req.on('timeout', () => { req.destroy(); resolve(false); });
174
+ req.on('error', () => resolve(false));
175
+ });
176
+ }
177
+
178
+ module.exports = {
179
+ TOKEN_FILE, OPENER_FILE, LOOPBACK, tokenPath, openerPath, readToken, ensureToken, tokensEqual,
180
+ proof, newNonce, baseUrl, openUrl, apiPath, middleware, deny, writeOpener, verifyServer,
181
+ };
@@ -0,0 +1,91 @@
1
+ // The dashboard's port: which one it listens on, where that is recorded, and what `memhouse start`
2
+ // says when the dashboard exits right after starting.
3
+ //
4
+ // The dashboard asks for MEMHOUSE_PORT (default 4640). When that port is in use it takes the next
5
+ // free one, up to SPAN above it, and says which. When all of them are in use it exits, naming the
6
+ // range and MEMHOUSE_PORT. The port it listens on is written to $MEMHOUSE_HOME/run/dashboard.port,
7
+ // which `memhouse status`, `start` and `dashboard` read.
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+
12
+ const SPAN = 10;
13
+ const DEFAULT_PORT = 4640;
14
+
15
+ const portFile = (home) => path.join(home, 'run', 'dashboard.port');
16
+
17
+ function recordPort(home, port) {
18
+ fs.mkdirSync(path.dirname(portFile(home)), { recursive: true });
19
+ fs.writeFileSync(portFile(home), String(port));
20
+ }
21
+
22
+ /** The port the running dashboard recorded, or null. */
23
+ function readPort(home) {
24
+ try {
25
+ const n = parseInt(fs.readFileSync(portFile(home), 'utf-8').trim(), 10);
26
+ return Number.isInteger(n) && n > 0 && n < 65536 ? n : null;
27
+ } catch { return null; }
28
+ }
29
+
30
+ const inUseNote = (wanted, got) => `port ${wanted} is in use, possibly by another account on this machine — the dashboard is on port ${got} instead (set MEMHOUSE_PORT to choose the port)`;
31
+ const exhaustedNote = (wanted, last) => `ports ${wanted}–${last} are all in use, possibly by other accounts on this machine — set MEMHOUSE_PORT to a free port`;
32
+
33
+ /**
34
+ * Listen on `port`, or the next free port up to `span` above it. Resolves with the port bound.
35
+ * Port 0 asks the system for any free port. `server` is a net/http server (or anything with the
36
+ * same listen/once/removeListener/address surface).
37
+ */
38
+ function listen(server, { port, host, span = SPAN, log = () => {} }) {
39
+ const last = port === 0 ? 0 : Math.min(port + span, 65535);
40
+ return new Promise((resolve, reject) => {
41
+ const attempt = (p) => {
42
+ const onError = (err) => {
43
+ server.removeListener('listening', onListening);
44
+ if (err && err.code === 'EADDRINUSE' && p < last) return attempt(p + 1);
45
+ if (err && err.code === 'EADDRINUSE') return reject(Object.assign(new Error(exhaustedNote(port, last)), { code: 'EADDRINUSE' }));
46
+ return reject(err);
47
+ };
48
+ const onListening = () => {
49
+ server.removeListener('error', onError);
50
+ const a = server.address();
51
+ const bound = a && typeof a === 'object' ? a.port : p;
52
+ if (p !== port) log(inUseNote(port, bound));
53
+ resolve(bound);
54
+ };
55
+ server.once('error', onError);
56
+ server.once('listening', onListening);
57
+ server.listen(p, host);
58
+ };
59
+ attempt(port);
60
+ });
61
+ }
62
+
63
+ // The line the dashboard prints before it exits for a reason it knows (a refused token file, every
64
+ // port in the range in use). `memhouse start` quotes it.
65
+ const CANNOT_START = 'cannot start:';
66
+
67
+ /**
68
+ * What `memhouse start` says about a daemon that exited right after it was started, and whether
69
+ * that fails the command. The shipper is what `start` keeps running: when it exits, `start` exits 1.
70
+ * The dashboard is optional: when it exits, `start` warns and exits 0.
71
+ *
72
+ * @param {object} o
73
+ * @param {string} o.name 'shipper' | 'dashboard'
74
+ * @param {string} o.log the tail of the daemon's log
75
+ * @param {number} o.port the port the dashboard asked for
76
+ * @returns {{ fatal: boolean, why: string }}
77
+ */
78
+ function earlyExit({ name, log = '', port }) {
79
+ const lines = String(log).split('\n').filter(Boolean).reverse();
80
+ const own = lines.find((l) => l.includes(`[memhouse] ${CANNOT_START}`));
81
+ let why = '';
82
+ if (own) why = own.slice(own.indexOf(CANNOT_START) + CANNOT_START.length).trim();
83
+ else {
84
+ const err = lines.find((l) => /EADDRINUSE|Error|error:/i.test(l));
85
+ if (/EADDRINUSE/.test(err || '')) why = `port ${port} is in use, possibly by another account on this machine — set MEMHOUSE_PORT to choose another port`;
86
+ else if (err) why = err.trim().slice(0, 200);
87
+ }
88
+ return { fatal: name !== 'dashboard', why };
89
+ }
90
+
91
+ module.exports = { SPAN, DEFAULT_PORT, CANNOT_START, portFile, recordPort, readPort, listen, earlyExit, inUseNote, exhaustedNote };
@@ -1151,7 +1151,37 @@ async function schema() {
1151
1151
  };
1152
1152
  }
1153
1153
 
1154
+ // ── the v3 dashboard's calls, answered on schema 2 ──────────────────────────────────
1155
+ // The SPA now loads the landing page in one request and polls cheap version tokens instead
1156
+ // of re-fetching (see ./api-v3.js). These keep one UI build working on a 0.18 house: the
1157
+ // landing composes the same five reads the page used to fan out (same cost, one HTTP call);
1158
+ // the tokens are bare `count(), max(ingested_at)` reads without FINAL — a session-key prefix
1159
+ // for one session, the small sessions room for the house. Any shipped write moves them.
1160
+ async function landing(opts = {}) {
1161
+ const [overview, daily, stats, costs, chats, version] = await Promise.all([
1162
+ getOverview(opts), getDailyActivity(opts), getDashboardStats(opts),
1163
+ estimateCosts(opts), getChats({ ...opts, limit: 500, named: false }), houseVersion(),
1164
+ ]);
1165
+ const big = chats.filter((c) => c.bubbleCount >= 100).sort((a, b) => b.bubbleCount - a.bubbleCount);
1166
+ return { overview, daily, stats, costs, largeContext: { total: big.length, chats: big.slice(0, 10) }, version: version.token };
1167
+ }
1168
+
1169
+ async function houseVersion() {
1170
+ const [row] = await q('SELECT count() AS n, toString(max(ingested_at)) AS last FROM {{sessions_raw}}', {}, { final: 0 });
1171
+ return { token: `${row.n}:${row.last}`, sessions: Number(row.n) };
1172
+ }
1173
+
1174
+ async function getChatVersion(id) {
1175
+ const cut = String(id).lastIndexOf('::');
1176
+ const sid = cut === -1 ? String(id) : String(id).slice(0, cut);
1177
+ const uid = cut === -1 ? null : String(id).slice(cut + 2);
1178
+ const [row] = await q(`SELECT count() AS n, toString(max(ingested_at)) AS last FROM {{messages_raw}}
1179
+ WHERE session_id = {sid:String}${uid ? ' AND user_id = {uid:String}' : ''}`, uid ? { sid, uid } : { sid }, { final: 0 });
1180
+ return Number(row.n) ? { version: `${row.n}:${row.last}`, messages: Number(row.n) } : null;
1181
+ }
1182
+
1154
1183
  module.exports = { adoptMovedHouse,
1184
+ landing, houseVersion, getChatVersion, schemaVersion: 2,
1155
1185
  getClient, config,
1156
1186
  getOverview, getDailyActivity, getDashboardStats,
1157
1187
  getChats, countChats, getChat,
@@ -5,27 +5,48 @@
5
5
  // queries.js computes every response; this file is routing + config + stubs. Serves
6
6
  // the built SPA from the repo root's public/.
7
7
  //
8
+ // Every /api request needs the dashboard's token as a Bearer ($MEMHOUSE_HOME/dashboard-token,
9
+ // read from the file at start): ./dashboard-auth.js. `memhouse dashboard` opens the page with it.
10
+ //
8
11
  // node memhouse/server/server.js (env: MEMHOUSE_* per DESIGN.md; port 4640)
9
12
 
10
13
  const express = require('express');
11
14
  const path = require('path');
12
15
  const fs = require('fs');
13
16
  const os = require('os');
14
- const qy = require('./queries');
15
-
16
- const PORT = parseInt(process.env.MEMHOUSE_PORT || '4640', 10);
17
- // Localhost by default: the dashboard exposes full transcripts and a SQL console
18
- // with no auth. Remote access is an explicit opt-in (MEMHOUSE_HOST=0.0.0.0).
17
+ // MEMHOUSE_SCHEMA=3: the conversation layer of docs/design/source-identity.md (sessions /
18
+ // turns / calls) through the named queries; otherwise the 0.18 rooms. Same routes, same shapes.
19
+ const SCHEMA = process.env.MEMHOUSE_SCHEMA === '3' ? 3 : 2;
20
+ const qy = SCHEMA === 3 ? require('./api-v3') : require('./queries');
21
+
22
+ const dashAuth = require('./dashboard-auth');
23
+ const dashPort = require('./dashboard-port');
24
+
25
+ // The port asked for; when it is in use the server takes the next free one (./dashboard-port.js).
26
+ const PORT = parseInt(process.env.MEMHOUSE_PORT || String(dashPort.DEFAULT_PORT), 10);
27
+ // Localhost by default: the dashboard serves full transcripts and a SQL console. Remote
28
+ // access is an explicit opt-in (MEMHOUSE_HOST=0.0.0.0); the token is required either way.
19
29
  const HOST = process.env.MEMHOUSE_HOST || '127.0.0.1';
20
30
  const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
31
+ const HOME = process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse');
21
32
  // MEMHOUSE_HOME, like every other component. childEnv() already passes it to this child;
22
33
  // this was the one place that ignored it and wrote to the real home instead. Anyone
23
34
  // running a second house under MEMHOUSE_HOME — the documented way to test, and what every
24
35
  // acceptance run is told to do — hid a project in their throwaway dashboard and had it
25
- // written into the PILOT'S ~/.memhouse/config.json.
26
- const CONFIG_PATH = path.join(process.env.MEMHOUSE_HOME || path.join(os.homedir(), '.memhouse'), 'config.json');
36
+ // written into the USER'S ~/.memhouse/config.json.
37
+ const CONFIG_PATH = path.join(HOME, 'config.json');
38
+
39
+ // Set when the server starts listening. Until both are set, and whenever the module is loaded
40
+ // without being run, every request is refused.
41
+ let TOKEN = null;
42
+ let BOUND_PORT = null;
27
43
 
28
44
  const app = express();
45
+ // Routes match their path exactly as written, so the token check and the router agree on what
46
+ // an /api path is (dashboard-auth.js also folds case and slashes, on its own side).
47
+ app.set('case sensitive routing', true);
48
+ // First, ahead of the static files, the body parser and every route.
49
+ app.use(dashAuth.middleware({ token: () => TOKEN }));
29
50
  app.use(express.json());
30
51
  if (fs.existsSync(PUBLIC_DIR)) app.use(express.static(PUBLIC_DIR));
31
52
 
@@ -55,14 +76,26 @@ const route = (fn) => async (req, res) => {
55
76
  // `new Error('')` — no message, no code, no own keys. An empty error is a dead end for
56
77
  // whoever is looking, so say what we do know: the class of failure and the route.
57
78
  const why = (err && err.message) || (err && err.code) || `${err && err.constructor ? err.constructor.name : 'Error'} with no message — often the server or a proxy in front of it refusing the request (URI too long, body too large, timeout)`;
79
+ // A bad cursor, an unknown harness:id form, an empty search: the caller's mistake, a 400.
80
+ if (err && err.status === 400) return res.status(400).json({ error: String(why) });
58
81
  console.error(`[memhouse] ${req.method} ${req.originalUrl} failed: ${why}`);
59
82
  res.status(500).json({ error: String(why) });
60
83
  }
61
84
  };
62
85
 
63
- app.get('/api/ping', (req, res) => res.json({ app: 'agentlytics', pid: process.pid }));
86
+ app.get('/api/ping', (req, res) => res.json({ app: 'agentlytics', pid: process.pid, schema: SCHEMA }));
64
87
  app.get('/api/mode', (req, res) => res.json({ mode: 'local' }));
65
88
 
89
+ // The landing page in ONE request: overview, daily activity, stats, costs and the large-context
90
+ // card, plus the house version token. Replaces the five-endpoint fan-out (33–34 statements on
91
+ // schema 2; 11 small reads of sessions FINAL on schema 3).
92
+ app.get('/api/landing', route(async (req, res) => {
93
+ res.json(await qy.landing({ editor: req.query.editor || req.query.harness || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
94
+ }));
95
+
96
+ // The house-wide change token Live polls. It never ships anything.
97
+ app.get('/api/version', route(async (req, res) => res.json(await qy.houseVersion())));
98
+
66
99
  app.get('/api/overview', route(async (req, res) => {
67
100
  res.json(await qy.getOverview({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
68
101
  }));
@@ -89,18 +122,62 @@ app.get('/api/chats', route(async (req, res) => {
89
122
  ...parseDateOpts(req.query),
90
123
  hiddenFolders: hiddenFolders(),
91
124
  };
125
+ // Schema 3: keyset pagination — `cursor` is the opaque `nextCursor` of the previous page
126
+ // (offset is not supported there; no UI caller ever sent one).
127
+ if (qy.listChats) {
128
+ if (req.query.harness) opts.harness = String(req.query.harness);
129
+ return res.json(await qy.listChats({ ...opts, cursor: req.query.cursor || null }));
130
+ }
92
131
  const [total, chats] = await Promise.all([qy.countChats(opts), qy.getChats(opts)]);
93
132
  res.json({ total, chats });
94
133
  }));
95
134
 
96
135
  app.get('/api/chats/:id', route(async (req, res) => {
97
- const result = await qy.getChat(req.params.id);
136
+ // Schema 3 returns the header and the TAIL page of turns (`prevCursor` pages back through
137
+ // /turns); schema 2 returns the whole session, as before.
138
+ const result = await qy.getChat(req.params.id, SCHEMA === 3 ? {
139
+ limit: clampInt(req.query.limit, 1000, 1, 5000), agent: req.query.agent ?? null,
140
+ } : undefined);
98
141
  if (!result) return res.status(404).json({ error: 'Chat not found' });
99
142
  res.json(result);
100
143
  }));
101
144
 
145
+ // The sidebar poll: one point read of the session's version. The client refetches turns only
146
+ // when it changes.
147
+ app.get('/api/chats/:id/version', route(async (req, res) => {
148
+ const v = await qy.getChatVersion(req.params.id);
149
+ if (!v) return res.status(404).json({ error: 'Chat not found' });
150
+ res.json(v);
151
+ }));
152
+
153
+ // A page of turns and the calls it owns. `dir=backward` (default) pages from the end.
154
+ app.get('/api/chats/:id/turns', route(async (req, res) => {
155
+ if (!qy.getChatTurns) return res.status(501).json({ error: 'paged turns need MEMHOUSE_SCHEMA=3' });
156
+ const page = await qy.getChatTurns(req.params.id, {
157
+ cursor: req.query.cursor || null, dir: req.query.dir === 'forward' ? 'forward' : 'backward',
158
+ limit: clampInt(req.query.limit, 500, 1, 5000), agent: req.query.agent ?? null,
159
+ });
160
+ if (!page) return res.status(404).json({ error: 'Chat not found' });
161
+ res.json(page);
162
+ }));
163
+
164
+ app.get('/api/search', route(async (req, res) => {
165
+ if (!qy.search) return res.status(501).json({ error: 'search needs MEMHOUSE_SCHEMA=3' });
166
+ if (!req.query.q) return res.status(400).json({ error: 'q query param required' });
167
+ res.json(await qy.search({
168
+ q: String(req.query.q), match: req.query.match === 'word' ? 'word' : 'substring',
169
+ harness: req.query.harness || req.query.editor || null, folder: req.query.folder || null, project: req.query.project || null,
170
+ limit: clampInt(req.query.limit, 20, 1, 200), ...parseDateOpts(req.query),
171
+ }));
172
+ }));
173
+
174
+ app.get('/api/models', route(async (req, res) => {
175
+ if (!qy.models) return res.status(501).json({ error: 'models need MEMHOUSE_SCHEMA=3' });
176
+ res.json(await qy.models({ editor: req.query.editor || req.query.harness || null, folder: req.query.folder || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
177
+ }));
178
+
102
179
  app.get('/api/chats/:id/markdown', route(async (req, res) => {
103
- const result = await qy.getChat(req.params.id);
180
+ const result = qy.getChatAll ? await qy.getChatAll(req.params.id) : await qy.getChat(req.params.id);
104
181
  if (!result) return res.status(404).json({ error: 'Chat not found' });
105
182
  const lines = [];
106
183
  const title = result.name || 'Untitled Session';
@@ -400,16 +477,25 @@ app.put('/api/config', route(async (req, res) => {
400
477
  res.json(config);
401
478
  }));
402
479
 
403
- // Real refetch: run one full shipper pass, streaming SSE like the root contract.
404
- // POST, not GET. A full re-ship behind a GET is the easiest request on a machine to make
405
- // by accident — a prefetch, a link checker, a curl in a log. The dashboard calls it
406
- // explicitly, so nothing legitimate depended on the verb.
480
+ // Refetch, streaming SSE like the root contract. POST, not GET: a re-ship behind a GET is the
481
+ // easiest request on a machine to make by accident — a prefetch, a link checker, a curl in a
482
+ // log. The dashboard calls it explicitly, so nothing legitimate depended on the verb.
483
+ //
484
+ // It NEVER runs a full ship (draft 5 §5). A full pass re-inserted every current-parse row
485
+ // (~309k messages) and re-ran the stat refresh, and Live used to do it every 60 s. Schema 2
486
+ // runs one INCREMENTAL pass; schema 3 ships nothing at all — the capture engine runs on its
487
+ // own timer — and answers with the house version so the page knows whether to re-read.
407
488
  app.post('/api/refetch', async (req, res) => {
408
489
  res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
409
490
  try {
410
- const { runShip } = require('../shipper/ship');
411
491
  res.write(`data: ${JSON.stringify({ scanned: 0, analyzed: 0, skipped: 0, total: 0 })}\n\n`);
412
- const r = await runShip(qy.getClient(), { full: true });
492
+ if (SCHEMA === 3) {
493
+ const v = await qy.houseVersion();
494
+ res.write(`data: ${JSON.stringify({ done: true, total: v.sessions, analyzed: 0, version: v.token })}\n\n`);
495
+ return res.end();
496
+ }
497
+ const { runShip } = require('../shipper/ship');
498
+ const r = await runShip(qy.getClient(), { full: false });
413
499
  res.write(`data: ${JSON.stringify({ done: true, total: r.sessions + r.skipped, analyzed: r.sessions })}\n\n`);
414
500
  } catch (err) {
415
501
  res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
@@ -443,15 +529,27 @@ app.get('*', (req, res) => {
443
529
  });
444
530
 
445
531
  if (require.main === module) {
532
+ // The token, from its file only (never argv or the environment). Made when absent; a file
533
+ // other users can read is refused.
534
+ try { TOKEN = dashAuth.ensureToken(HOME); } catch (e) {
535
+ console.error(`[memhouse] ${dashPort.CANNOT_START} ${e.message}`);
536
+ process.exit(1);
537
+ }
446
538
  // Before serving: has the house moved under us? (see queries.adoptMovedHouse)
447
539
  qy.adoptMovedHouse().catch(() => {});
448
- app.listen(PORT, HOST, () => {
449
- console.log(`[memhouse] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
450
- + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
540
+ const server = require('http').createServer(app);
541
+ dashPort.listen(server, { port: PORT, host: HOST, log: (l) => console.log(`[memhouse] ${l}`) }).then((port) => {
542
+ BOUND_PORT = port;
543
+ try { dashPort.recordPort(HOME, port); } catch (e) { console.error(`[memhouse] could not record the port in ${dashPort.portFile(HOME)}: ${e.message}`); }
544
+ console.log(`[memhouse] dashboard listening on ${HOST}:${port} — open it with: memhouse dashboard (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')}, schema ${SCHEMA})`
545
+ + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable; the token is required]` : ''));
546
+ }).catch((e) => {
547
+ console.error(`[memhouse] ${dashPort.CANNOT_START} ${e.code === 'EADDRINUSE' ? e.message : `listening on ${HOST}:${PORT} failed: ${e.message}`}`);
548
+ process.exit(1);
451
549
  });
452
550
  // An upgrade replaces public/ as well as the code, and this process holds neither: the
453
551
  // static middleware bound to the old directory at boot, so the SPA it serves stays the
454
- // old bundle however many times the pilot reloads. The shipper checks once per pass; a
552
+ // old bundle however many times the user reloads. The shipper checks once per pass; a
455
553
  // server has no pass, so it polls. unref() so the timer never holds the process open.
456
554
  const selfUpdate = require('../self-update');
457
555
  const snap = selfUpdate.snapshot(__filename);
@@ -74,7 +74,7 @@ Type=simple
74
74
  # Restart=always, not on-failure: the shipper EXITS 0 ON PURPOSE when an upgrade replaces
75
75
  # the files underneath it (self-update.js, supervised path) and counts on the supervisor
76
76
  # to start the new version. Under on-failure that handover left the service dead after
77
- # every npm upgrade — found on testbed, service inactive(dead) status=0/SUCCESS.
77
+ # every npm upgrade — found on a test VM, service inactive(dead) status=0/SUCCESS.
78
78
  # RestartSec plus self-update's re-exec chain cap guard against restart loops.
79
79
  ${envLines(env)}
80
80
  ExecStart=${[node, script, ...args].map(execToken).join(' ')}