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.
- package/AGENTS.md +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
26
|
-
const CONFIG_PATH = path.join(
|
|
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
|
-
|
|
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
|
-
//
|
|
404
|
-
//
|
|
405
|
-
//
|
|
406
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
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);
|
package/memhouse/service.js
CHANGED
|
@@ -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
|
|
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(' ')}
|