memhouse 0.4.4 → 0.7.0
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/README.md +244 -114
- package/bin/memhouse.js +1162 -230
- package/editors/base.js +6 -0
- package/editors/claude.js +44 -9
- package/editors/goose.js +4 -0
- package/editors/index.js +0 -0
- package/editors/opencode.js +3 -0
- package/{mem-house → memhouse}/COMPETITION.md +5 -5
- package/{mem-house → memhouse}/DESIGN.md +18 -12
- package/memhouse/RESUME-AND-UPDATE.md +185 -0
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
- package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
- package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
- package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
- package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
- package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
- package/memhouse/host.js +101 -0
- package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
- package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
- package/memhouse/per-member/provision.js +265 -0
- package/memhouse/per-member/rooms.js +235 -0
- package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
- package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
- package/memhouse/resume.js +150 -0
- package/memhouse/self-update.js +150 -0
- package/{mem-house → memhouse}/server/queries.js +80 -34
- package/memhouse/server/server.js +451 -0
- package/{mem-house → memhouse}/service.js +48 -29
- package/memhouse/shipper/ship.js +949 -0
- package/package.json +5 -20
- package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
- package/public/index.html +2 -2
- package/ui/index.html +1 -1
- package/ui/src/App.jsx +1 -1
- package/ui/src/lib/api.js +29 -13
- package/ui/src/pages/Dashboard.jsx +8 -5
- package/ui/src/pages/Sessions.jsx +3 -1
- package/ui/src/pages/SqlViewer.jsx +6 -23
- package/cache.js +0 -850
- package/ch-ingest.js +0 -329
- package/ch-schema.js +0 -152
- package/clickhouse.js +0 -87
- package/index.js +0 -425
- package/mcp-server.js +0 -294
- package/mem-house/delivery/install.sh +0 -183
- package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
- package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
- package/mem-house/per-member/provision.js +0 -142
- package/mem-house/per-member/rooms.js +0 -132
- package/mem-house/server/server.js +0 -228
- package/mem-house/shipper/ship.js +0 -566
- package/relay-client.js +0 -306
- package/relay-server.js +0 -577
- package/server.js +0 -860
- package/share-image.js +0 -367
- /package/{mem-house → memhouse}/deploy.js +0 -0
- /package/{mem-house → memhouse}/envfile.js +0 -0
- /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
- /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
- /package/{mem-house → memhouse}/per-member/README.md +0 -0
- /package/{mem-house → memhouse}/per-member/SHARING.md +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
-- mem — one member's rooms. Applied per member
|
|
1
|
+
-- mem — one member's rooms. Applied per member — {{MEMBER}} is the ClickHouse username.
|
|
2
2
|
--
|
|
3
3
|
-- Room naming is TYPE-FIRST (sessions_<m>, not <m>_sessions) so the Merge rooms
|
|
4
4
|
-- can anchor on a fixed room type and never match themselves.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
-- it keeps provenance across a share and keeps the Merge rooms meaningful. Writers must
|
|
8
8
|
-- use async_insert=0 or the stamp does not happen.
|
|
9
9
|
--
|
|
10
|
-
-- ClickHouse >= 26.2 natively
|
|
10
|
+
-- ClickHouse >= 26.2 natively — 25.11 works because the shipper passes
|
|
11
11
|
-- allow_experimental_full_text_index=1 per-query when applying this file.
|
|
12
12
|
|
|
13
13
|
CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
|
|
@@ -25,23 +25,27 @@ CREATE TABLE IF NOT EXISTS sessions_{{MEMBER}}
|
|
|
25
25
|
message_count UInt32 DEFAULT 0,
|
|
26
26
|
path String DEFAULT '',
|
|
27
27
|
extra JSON,
|
|
28
|
-
-- Who put this row here.
|
|
29
|
-
--
|
|
30
|
-
--
|
|
31
|
-
--
|
|
32
|
-
--
|
|
33
|
-
--
|
|
34
|
-
--
|
|
35
|
-
--
|
|
36
|
-
--
|
|
37
|
-
--
|
|
38
|
-
--
|
|
28
|
+
-- Who put this row here. Carried on every room so a reader can tell shipped rows
|
|
29
|
+
-- from imported ones, and so the shipper's clear can bind origin='ship'.
|
|
30
|
+
--
|
|
31
|
+
-- NOTE the sorting key below does NOT include origin, and sessions is the one room
|
|
32
|
+
-- where that is correct. A session has exactly ONE metadata row: title, counts,
|
|
33
|
+
-- bounds. If origin were in the key, an imported session and its shipped twin would
|
|
34
|
+
-- be two rows, and everything that reads sessions as one-row-per-session breaks --
|
|
35
|
+
-- sessions_v joins messages twice and over-reports (measured on a real house: one
|
|
36
|
+
-- duplicate row inflated the totals by 1,764 messages and 655M tokens), and the
|
|
37
|
+
-- incremental skip cannot decide which row is current. Collapsing them is what we
|
|
38
|
+
-- want: the newer ingested_at wins, which is the shipper's row for any session that
|
|
39
|
+
-- still exists on disk. Nothing is lost -- a session the adapters no longer see is
|
|
40
|
+
-- never cleared and never re-inserted, so its imported row stands untouched.
|
|
41
|
+
--
|
|
42
|
+
-- messages and tool_calls are the opposite case and DO key on origin -- see there.
|
|
39
43
|
origin LowCardinality(String) DEFAULT 'ship',
|
|
40
44
|
user_id String MATERIALIZED currentUser(),
|
|
41
45
|
ingested_at DateTime64(3, 'UTC') DEFAULT now64(3)
|
|
42
46
|
)
|
|
43
47
|
ENGINE = ReplacingMergeTree(ingested_at)
|
|
44
|
-
ORDER BY (session_id, user_id
|
|
48
|
+
ORDER BY (session_id, user_id);
|
|
45
49
|
|
|
46
50
|
CREATE TABLE IF NOT EXISTS messages_{{MEMBER}}
|
|
47
51
|
(
|
|
@@ -6,13 +6,13 @@
|
|
|
6
6
|
--
|
|
7
7
|
-- Three measured properties this relies on (ClickHouse 26.7.1):
|
|
8
8
|
-- * fails closed on permissions — a caller sees only the underlying rooms they hold
|
|
9
|
-
-- grants for, with no leak and no error, so these can be granted broadly
|
|
10
|
-
-- * auto-discovers rooms created after them, so member onboarding needs no DDL here
|
|
9
|
+
-- grants for, with no leak and no error, so these can be granted broadly —
|
|
10
|
+
-- * auto-discovers rooms created after them, so member onboarding needs no DDL here —
|
|
11
11
|
-- * tolerates schema drift, returning a column's default for rooms lacking it — which
|
|
12
12
|
-- is what makes schema rollout progressive. Merge REJECTS mutations, so a new column
|
|
13
13
|
-- is added with ADD COLUMN, on the Merge room FIRST.
|
|
14
14
|
--
|
|
15
|
-
-- {{TEMPLATE_MEMBER}} is any existing member
|
|
15
|
+
-- {{TEMPLATE_MEMBER}} is any existing member — the Merge room borrows its column list.
|
|
16
16
|
|
|
17
17
|
CREATE TABLE IF NOT EXISTS all_sessions AS sessions_{{TEMPLATE_MEMBER}}
|
|
18
18
|
ENGINE = Merge(currentDatabase(), '^sessions_');
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// Resume: hand the pilot back into a session memhouse only ever read.
|
|
2
|
+
//
|
|
3
|
+
// Nothing here needs a schema change. A stored session already carries everything a
|
|
4
|
+
// resume needs: `session_id` is canonical `<source>:<native-id>` (ship.js:575) and
|
|
5
|
+
// `folder` is the directory the session ran in. Resume is a read-time computation over
|
|
6
|
+
// two columns.
|
|
7
|
+
//
|
|
8
|
+
// Two rules, both learned the hard way in this repo:
|
|
9
|
+
//
|
|
10
|
+
// 1. PRINT the command, never run it. A wrong cwd or a stale id does not fail — it
|
|
11
|
+
// starts a NEW session, and the transcript the pilot wanted is still lost while the
|
|
12
|
+
// tool reports success. The pilot pastes it, sees what it does, and owns the result.
|
|
13
|
+
//
|
|
14
|
+
// 2. Never write a flag from memory. Round 10 exists because a field name was asserted
|
|
15
|
+
// from a failed search rather than read from the source. Every entry below was read
|
|
16
|
+
// out of that CLI's own `--help` on a machine where it is installed, and the date and
|
|
17
|
+
// version are recorded with it. An editor whose flag has not been read that way is
|
|
18
|
+
// ABSENT from this table — an absent entry prints an honest refusal, a guessed entry
|
|
19
|
+
// prints a command that silently does the wrong thing.
|
|
20
|
+
//
|
|
21
|
+
// To add one: install the CLI, run `<cli> --help` (and `<cli> resume --help` if it is a
|
|
22
|
+
// subcommand), copy the exact syntax, and record what you read it from.
|
|
23
|
+
|
|
24
|
+
// Keyed by the string that is actually STORED in the `source` column, which is NOT the
|
|
25
|
+
// adapter's module `name`: editors/claude.js is `const name = 'claude'` and emits
|
|
26
|
+
// `source: 'claude-code'` on every chat it returns. This table was written against the
|
|
27
|
+
// module names first and would have refused to resume a single Claude Code session — 508 of
|
|
28
|
+
// them on the machine it was tested on. Read the values out of a real house
|
|
29
|
+
// (`SELECT DISTINCT source FROM sessions_<you>`), never off the adapter's name.
|
|
30
|
+
const RESUMERS = {
|
|
31
|
+
// claude --help, 2026-08-12: "-r, --resume [value] Resume a conversation by session ID"
|
|
32
|
+
'claude-code': {
|
|
33
|
+
cli: 'claude',
|
|
34
|
+
argv: (id) => ['--resume', id],
|
|
35
|
+
verified: '2026-08-12',
|
|
36
|
+
},
|
|
37
|
+
// codex resume --help, 2026-08-12: "Usage: codex resume [OPTIONS] [SESSION_ID] [PROMPT]"
|
|
38
|
+
// — positional, and "UUIDs take precedence if it parses".
|
|
39
|
+
codex: {
|
|
40
|
+
cli: 'codex',
|
|
41
|
+
argv: (id) => ['resume', id],
|
|
42
|
+
verified: '2026-08-12',
|
|
43
|
+
},
|
|
44
|
+
// opencode --help, 2026-08-12: "-s, --session session id to continue [string]"
|
|
45
|
+
opencode: {
|
|
46
|
+
cli: 'opencode',
|
|
47
|
+
argv: (id) => ['--session', id],
|
|
48
|
+
verified: '2026-08-12',
|
|
49
|
+
},
|
|
50
|
+
// NOT here, deliberately, and each for a different reason:
|
|
51
|
+
//
|
|
52
|
+
// goose, gemini-cli, cursor-agent — plausibly resumable, but not installed on the
|
|
53
|
+
// machine this table was written on, so the flag could only have been guessed. Install
|
|
54
|
+
// one, read its --help, add it.
|
|
55
|
+
//
|
|
56
|
+
// everything in GUI_ONLY — no CLI entry point takes a session id there. Opening the
|
|
57
|
+
// folder is not resuming the session and must not be dressed up as it.
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Surfaces with no CLI to resume into AT ALL, separated from "the flag has not been read
|
|
61
|
+
// yet" so the refusal can say which of the two it is — the same reason `discover` separates
|
|
62
|
+
// a skipped adapter from an editor you do not have.
|
|
63
|
+
//
|
|
64
|
+
// Stored `source` values again, and two corrections already: `copilot.js` emits
|
|
65
|
+
// `copilot-cli` and IS a command line, so it does not belong here; and windsurf never emits
|
|
66
|
+
// 'windsurf' at all — it emits its VARIANTS id, `devin` or `devin-next` — so the name this
|
|
67
|
+
// list first carried matched nothing and every Devin session fell through to the wrong
|
|
68
|
+
// refusal.
|
|
69
|
+
//
|
|
70
|
+
// Membership means: no command line takes a session id here, so no amount of checking will
|
|
71
|
+
// produce one. Anything merely UNCHECKED belongs in UNVERIFIED — asserting "there is
|
|
72
|
+
// nothing to resume into" about a tool nobody has looked at is the same invented fact as
|
|
73
|
+
// guessing a flag.
|
|
74
|
+
const GUI_ONLY = new Set([
|
|
75
|
+
'cursor', 'vscode', 'zed', 'kiro', 'copilot-jetbrains', 'antigravity',
|
|
76
|
+
'devin', 'devin-next',
|
|
77
|
+
]);
|
|
78
|
+
|
|
79
|
+
// A plausible CLI that nobody has verified. Listed explicitly instead of left to fall
|
|
80
|
+
// through a default, so the completeness test can prove every source the adapters can emit
|
|
81
|
+
// lands in exactly one bucket — which is what would have caught both corrections above
|
|
82
|
+
// before they shipped.
|
|
83
|
+
const UNVERIFIED = new Set([
|
|
84
|
+
'goose', 'gemini-cli', 'cursor-agent', 'copilot-cli', 'codebuff', 'commandcode', 'gsd',
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// A session_id is `<source>:<native-id>`. Split on the FIRST colon only: several editors
|
|
88
|
+
// use colons inside their own ids, and slicing on the last one hands the CLI a truncated id
|
|
89
|
+
// that resolves to nothing.
|
|
90
|
+
function splitSessionId(sessionId) {
|
|
91
|
+
const i = String(sessionId).indexOf(':');
|
|
92
|
+
if (i <= 0) return null;
|
|
93
|
+
return { source: sessionId.slice(0, i), nativeId: sessionId.slice(i + 1) };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Shell-quote for the line we PRINT. This never feeds an exec — it is text a human pastes
|
|
97
|
+
// — but a session folder with a space in it must still paste correctly, and an id is
|
|
98
|
+
// server-supplied data that has no business being read as shell syntax.
|
|
99
|
+
function shq(s) {
|
|
100
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(s) ? s : `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Resolve a stored session row into something the pilot can paste.
|
|
105
|
+
*
|
|
106
|
+
* @param {{session_id: string, source?: string, folder?: string, origin?: string}} row
|
|
107
|
+
* @returns {{ok: boolean, source: string, nativeId: string|null, folder: string,
|
|
108
|
+
* command: string|null, reason: string|null}}
|
|
109
|
+
*/
|
|
110
|
+
function resumeFor(row) {
|
|
111
|
+
const parts = splitSessionId(row.session_id);
|
|
112
|
+
// `source` is its own column, but the id prefix is what the CLI half of the pair is keyed
|
|
113
|
+
// on; prefer the column and fall back to the prefix so a row from an older house still
|
|
114
|
+
// resolves.
|
|
115
|
+
const source = row.source || (parts && parts.source) || '';
|
|
116
|
+
const nativeId = parts ? parts.nativeId : null;
|
|
117
|
+
const folder = row.folder || '';
|
|
118
|
+
const base = { source, nativeId, folder, command: null, ok: false, reason: null };
|
|
119
|
+
|
|
120
|
+
if (!nativeId) {
|
|
121
|
+
return { ...base, reason: `'${row.session_id}' is not a canonical <source>:<id> session id` };
|
|
122
|
+
}
|
|
123
|
+
// Imported rows come from an older house, another product, or a machine that no longer
|
|
124
|
+
// exists — the whole point of the `origin` guard the shipper carries. There is no local
|
|
125
|
+
// session store behind them, so no resume command can be right, and this is checked
|
|
126
|
+
// BEFORE the source table: 502 of the 1,304 sessions on the machine this was built on are
|
|
127
|
+
// imported `claude-ai` rows, and a source-only check would have offered to resume them.
|
|
128
|
+
if (row.origin && row.origin !== 'ship') {
|
|
129
|
+
return {
|
|
130
|
+
...base,
|
|
131
|
+
reason: `this session was imported (origin='${row.origin}'), not read from a local editor — there is nothing on this machine to resume into`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const r = RESUMERS[source];
|
|
135
|
+
if (!r) {
|
|
136
|
+
return {
|
|
137
|
+
...base,
|
|
138
|
+
reason: GUI_ONLY.has(source)
|
|
139
|
+
? `${source} is a GUI editor — it has no CLI that takes a session id, so there is nothing to resume into`
|
|
140
|
+
: `no verified resume command for ${source} — see memhouse/resume.js for how to add one`,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
const cmd = [r.cli, ...r.argv(nativeId)].map(shq).join(' ');
|
|
144
|
+
// The cd is half the command. Claude Code and Codex both scope their session lists by
|
|
145
|
+
// working directory, so the same id run from the wrong folder opens the picker or a new
|
|
146
|
+
// session instead of the transcript that was asked for.
|
|
147
|
+
return { ...base, ok: true, command: folder ? `cd ${shq(folder)} && ${cmd}` : cmd };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { RESUMERS, GUI_ONLY, UNVERIFIED, resumeFor, splitSessionId };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// A daemon that keeps executing the code it booted with.
|
|
2
|
+
//
|
|
3
|
+
// `npm i -g memhouse@latest` replaces the files under the global prefix and does not touch
|
|
4
|
+
// the running processes, so the shipper keeps parsing with the old adapters and the
|
|
5
|
+
// dashboard keeps serving the old bundle. Nothing reports it: `status` prints the version
|
|
6
|
+
// of the CLI you just typed, not the version the daemon is running. On macminim a shipper
|
|
7
|
+
// ran 1d16h out of a directory that had been MOVED — the fix was live, the machine was not.
|
|
8
|
+
//
|
|
9
|
+
// npm's own `postinstall` is the obvious hook and the wrong one. Install scripts are
|
|
10
|
+
// blocked by default (`--allow-scripts`), which is the very condition this product already
|
|
11
|
+
// has to work around, and a package install that restarts a user's daemons is a surprise
|
|
12
|
+
// even when it works.
|
|
13
|
+
//
|
|
14
|
+
// So the daemon notices for itself. Once per loop pass it compares what is on disk against
|
|
15
|
+
// what it booted with, and when they differ it hands over:
|
|
16
|
+
//
|
|
17
|
+
// supervised (systemd/launchd set MEMHOUSE_SUPERVISED=1) — exit 0 and let the supervisor
|
|
18
|
+
// start a fresh process. Re-execing under a supervisor fights it: the unit's own
|
|
19
|
+
// restart would then race the child we spawned.
|
|
20
|
+
//
|
|
21
|
+
// unsupervised (`memhouse start` pidfiles) — re-exec ourselves detached, rewrite the
|
|
22
|
+
// pidfile, exit. Nothing else would ever restart it, and exiting silently would stop
|
|
23
|
+
// collecting memory, which is worse than running one pass of stale code.
|
|
24
|
+
//
|
|
25
|
+
// Guards, because a daemon that restarts itself wrongly is worse than one that is stale:
|
|
26
|
+
// a re-exec chain is counted in the environment and capped, and there is a floor on how
|
|
27
|
+
// often a restart may happen at all.
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { spawn } = require('child_process');
|
|
32
|
+
|
|
33
|
+
const CHAIN_VAR = 'MEMHOUSE_REEXEC_CHAIN';
|
|
34
|
+
const MAX_CHAIN = 3;
|
|
35
|
+
const MIN_INTERVAL_MS = 60_000;
|
|
36
|
+
|
|
37
|
+
function runDir() {
|
|
38
|
+
const home = process.env.MEMHOUSE_HOME || path.join(require('os').homedir(), '.memhouse');
|
|
39
|
+
return path.join(home, 'run');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Read the version off DISK, never `require()`. require caches the first read for the
|
|
43
|
+
// lifetime of the process, so a required package.json reports the version this daemon
|
|
44
|
+
// booted with no matter how many times it is re-read — which is exactly the value we are
|
|
45
|
+
// trying to detect a change in.
|
|
46
|
+
function diskVersion(repoRoot) {
|
|
47
|
+
try { return JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf-8')).version || null; }
|
|
48
|
+
catch { return null; }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* What this process is running, captured at boot.
|
|
53
|
+
* @param {string} entry absolute path of the daemon's own entry script
|
|
54
|
+
* @param {string} [repoRoot] where package.json lives; defaults two levels up from the
|
|
55
|
+
* entry's directory, which is where both daemons sit (memhouse/<part>/<entry>.js).
|
|
56
|
+
* Pass it explicitly rather than relying on that depth if you move an entry.
|
|
57
|
+
*/
|
|
58
|
+
function snapshot(entry, repoRoot = path.join(path.dirname(entry), '..', '..')) {
|
|
59
|
+
let real = null, mtimeMs = null;
|
|
60
|
+
try { real = fs.realpathSync(entry); mtimeMs = fs.statSync(real).mtimeMs; } catch { /* reported as drift */ }
|
|
61
|
+
return { entry, repoRoot, version: diskVersion(repoRoot), real, mtimeMs, at: Date.now() };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Has the installation underneath us changed?
|
|
66
|
+
* @returns {string|null} a human-readable reason, or null when nothing moved
|
|
67
|
+
*/
|
|
68
|
+
function driftReason(snap) {
|
|
69
|
+
const now = diskVersion(snap.repoRoot);
|
|
70
|
+
if (snap.version && now && now !== snap.version) return `version changed ${snap.version} → ${now}`;
|
|
71
|
+
let real = null, mtimeMs = null;
|
|
72
|
+
try { real = fs.realpathSync(snap.entry); mtimeMs = fs.statSync(real).mtimeMs; } catch { real = null; }
|
|
73
|
+
// The entry is gone: the directory this daemon runs out of was deleted, moved, or
|
|
74
|
+
// replaced. Reported, but NOT restartable — see maybeRestart, which refuses to re-exec a
|
|
75
|
+
// path that no longer resolves.
|
|
76
|
+
if (!real) return 'the directory this daemon runs from no longer exists';
|
|
77
|
+
if (snap.real && real !== snap.real) return `entry moved ${snap.real} → ${real}`;
|
|
78
|
+
// mtime alone, with the version unchanged, is a checkout being rebuilt or edited in
|
|
79
|
+
// place. Worth acting on for the same reason the version is: the running process is not
|
|
80
|
+
// the code on disk.
|
|
81
|
+
if (snap.mtimeMs && mtimeMs && mtimeMs !== snap.mtimeMs) return 'the entry script changed on disk';
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function chainCount() {
|
|
86
|
+
const n = Number(process.env[CHAIN_VAR]);
|
|
87
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Only ever rewrite a pidfile that names US. A daemon started by hand has no pidfile, and a
|
|
91
|
+
// pidfile naming some other process belongs to that process — overwriting it would point
|
|
92
|
+
// `memhouse stop` at ours and leave theirs running forever.
|
|
93
|
+
function adoptPidfile(name, newPid, log) {
|
|
94
|
+
const file = path.join(runDir(), name + '.pid');
|
|
95
|
+
try {
|
|
96
|
+
if (Number(fs.readFileSync(file, 'utf-8').trim()) !== process.pid) return;
|
|
97
|
+
fs.writeFileSync(file, String(newPid));
|
|
98
|
+
} catch { log(`[memhouse] could not update ${file} — 'memhouse stop' may not find the new process`); }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Act on drift. Returns false when nothing was done, and does not return at all when it
|
|
103
|
+
* hands over (the process exits).
|
|
104
|
+
*
|
|
105
|
+
* @param {object} o
|
|
106
|
+
* @param {ReturnType<snapshot>} o.snap what we booted with
|
|
107
|
+
* @param {string} o.name daemon name, matching its pidfile ('shipper' | 'dashboard')
|
|
108
|
+
* @param {(s: string) => void} [o.log]
|
|
109
|
+
*/
|
|
110
|
+
function maybeRestart({ snap, name, log = console.error }) {
|
|
111
|
+
const reason = driftReason(snap);
|
|
112
|
+
if (!reason) return false;
|
|
113
|
+
if (Date.now() - snap.at < MIN_INTERVAL_MS) return false;
|
|
114
|
+
|
|
115
|
+
const supervised = process.env.MEMHOUSE_SUPERVISED === '1';
|
|
116
|
+
log(`[memhouse] ${reason} — this ${name} is running code that is no longer installed`);
|
|
117
|
+
|
|
118
|
+
if (supervised) {
|
|
119
|
+
log('[memhouse] exiting so the service manager starts the new version');
|
|
120
|
+
process.exit(0);
|
|
121
|
+
}
|
|
122
|
+
// An entry that does not resolve cannot be re-executed, and there is no supervisor to
|
|
123
|
+
// resolve a fresh one. Keep shipping with what is loaded and say so every pass: a stale
|
|
124
|
+
// daemon still collects sessions, a dead one collects nothing.
|
|
125
|
+
let real = null;
|
|
126
|
+
try { real = fs.realpathSync(snap.entry); } catch { /* handled next */ }
|
|
127
|
+
if (!real) {
|
|
128
|
+
log(`[memhouse] cannot restart — nothing to exec. Fix it with: memhouse stop && memhouse start`);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
if (chainCount() >= MAX_CHAIN) {
|
|
132
|
+
log(`[memhouse] already restarted ${MAX_CHAIN} times in this chain — not restarting again. Fix it with: memhouse stop && memhouse start`);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const child = spawn(process.execPath, [real, ...process.argv.slice(2)], {
|
|
137
|
+
env: { ...process.env, [CHAIN_VAR]: String(chainCount() + 1) },
|
|
138
|
+
detached: true,
|
|
139
|
+
// The daemon's stdout/stderr are already the log file `memhouse start` opened, and a
|
|
140
|
+
// detached child inherits those descriptors — so the new process keeps writing to the
|
|
141
|
+
// same log with no second file to find.
|
|
142
|
+
stdio: 'inherit',
|
|
143
|
+
});
|
|
144
|
+
child.unref();
|
|
145
|
+
adoptPidfile(name, child.pid, log);
|
|
146
|
+
log(`[memhouse] restarted as pid ${child.pid}`);
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { snapshot, driftReason, maybeRestart, CHAIN_VAR, MAX_CHAIN, MIN_INTERVAL_MS };
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
// queries.js —
|
|
1
|
+
// queries.js — memhouse read/query layer.
|
|
2
2
|
//
|
|
3
3
|
// Serves the SAME response shapes as the root cache.js (the agentlytics API
|
|
4
|
-
// contract) but computed over the
|
|
4
|
+
// contract) but computed over the memhouse typed schema — one set of rooms per member,
|
|
5
5
|
// `sessions_<m>` / `messages_<m>` / `tool_calls_<m>` (../per-member/schema-member.sql.tpl),
|
|
6
6
|
// plus the session rollup, which is a SAVED QUERY and not an object
|
|
7
7
|
// (../per-member/rooms.js). The dashboard SPA consumes these shapes unchanged.
|
|
8
8
|
//
|
|
9
|
-
// Schema mapping notes (root cache schema →
|
|
9
|
+
// Schema mapping notes (root cache schema → memhouse):
|
|
10
10
|
// chats → sessions_<m> / the rollup (id → session_id, bubble_count → total_msgs)
|
|
11
11
|
// chat_stats → rollup aggregates (token/char sums, models array)
|
|
12
12
|
// messages.content → messages_<m>.text (cache_read → cache_read_tokens, …)
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
// All reads run with the `final: 1` setting so ReplacingMergeTree collapses
|
|
17
17
|
// duplicate versions at read time (see DESIGN.md data-plane rules).
|
|
18
18
|
|
|
19
|
-
const { createClient } = require('@clickhouse/client');
|
|
19
|
+
const { createClient, ClickHouseLogLevel } = require('@clickhouse/client');
|
|
20
20
|
const { calculateCost, normalizeModelName } = require('../../pricing');
|
|
21
21
|
const { resolveRooms, READ_SETTINGS } = require('../per-member/rooms');
|
|
22
22
|
|
|
@@ -31,6 +31,9 @@ let _client = null;
|
|
|
31
31
|
function getClient() {
|
|
32
32
|
if (_client) return _client;
|
|
33
33
|
_client = createClient({
|
|
34
|
+
// See the note in shipper/ship.js: the driver's ERROR-level dump lands ahead of every
|
|
35
|
+
// message we print, so a handled refusal reads like a crash. MEMHOUSE_DEBUG=1 restores it.
|
|
36
|
+
log: { level: process.env.MEMHOUSE_DEBUG ? ClickHouseLogLevel.DEBUG : ClickHouseLogLevel.OFF },
|
|
34
37
|
url: config.url,
|
|
35
38
|
username: config.username,
|
|
36
39
|
password: config.password,
|
|
@@ -74,9 +77,10 @@ function applyRooms(sql, r) {
|
|
|
74
77
|
});
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
async function q(query, params = {}) {
|
|
80
|
+
async function q(query, params = {}, settings = undefined) {
|
|
78
81
|
const rs = await getClient().query({
|
|
79
82
|
query: applyRooms(query, await rooms()), query_params: params, format: 'JSONEachRow',
|
|
83
|
+
...(settings ? { clickhouse_settings: settings } : {}),
|
|
80
84
|
});
|
|
81
85
|
return rs.json();
|
|
82
86
|
}
|
|
@@ -145,18 +149,30 @@ function normalizedModelFreq(rows) {
|
|
|
145
149
|
}
|
|
146
150
|
return freq;
|
|
147
151
|
}
|
|
152
|
+
// The same exclusions as EXCLUDED_MODELS, usable in JS.
|
|
153
|
+
const EXCLUDED_MODEL_SET = new Set(['', '<synthetic>']);
|
|
148
154
|
const topN = (freq, n) => Object.entries(freq).sort((a, b) => b[1] - a[1]).slice(0, n)
|
|
149
155
|
.map(([name, count]) => ({ name, count }));
|
|
150
156
|
|
|
151
157
|
// ── overview ────────────────────────────────────────────────────────────────────
|
|
152
158
|
async function getOverview(opts = {}) {
|
|
153
159
|
const f = filters(opts);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
+
// Same definition as getChats(). The nav bar read this and the Sessions page read
|
|
161
|
+
// getChats, so a house with one placeholder session showed "9 sessions" beside
|
|
162
|
+
// "8 sessions" in the same header, and the depth histogram summed to 7. A session row
|
|
163
|
+
// with no name and no messages is a placeholder the shipper keeps to absorb the
|
|
164
|
+
// incremental skip; it is not a conversation, and only one of the two counters knew.
|
|
165
|
+
const totalChats = Number((await q1(
|
|
166
|
+
`SELECT count() AS cnt FROM {{sessions_v}} AS c WHERE 1=1${f.and} AND (c.name != '' OR c.total_msgs > 0)`,
|
|
167
|
+
f.params)).cnt);
|
|
168
|
+
|
|
169
|
+
// Root parity WAS: without a folder filter the editor breakdown is global — which put
|
|
170
|
+
// "34 + 3" chips under a header reading "3 sessions" when an editor filter was applied,
|
|
171
|
+
// and 12 under a header of 11 with no filter at all. Parity with a wrong number is not a
|
|
172
|
+
// feature.
|
|
173
|
+
const editors = await q(
|
|
174
|
+
`SELECT source, count() AS count FROM {{sessions_v}} AS c
|
|
175
|
+
WHERE (c.name != '' OR c.total_msgs > 0)${f.and} GROUP BY source ORDER BY count DESC`, f.params);
|
|
160
176
|
|
|
161
177
|
const modes = await q(`SELECT mode, count() AS count FROM {{sessions_v}} AS c WHERE mode != ''${f.and} GROUP BY mode`, f.params);
|
|
162
178
|
const byMode = {};
|
|
@@ -253,7 +269,11 @@ async function getDashboardStats(opts = {}) {
|
|
|
253
269
|
SELECT COALESCE(sum(input_tokens), 0) AS input, COALESCE(sum(output_tokens), 0) AS output,
|
|
254
270
|
COALESCE(sum(cache_read_tokens), 0) AS cacheRead, COALESCE(sum(cache_write_tokens), 0) AS cacheWrite,
|
|
255
271
|
COALESCE(sum(user_chars), 0) AS userChars, COALESCE(sum(assistant_chars), 0) AS assistantChars,
|
|
256
|
-
|
|
272
|
+
COALESCE(sum(total_msgs), 0) AS messages,
|
|
273
|
+
-- The SAME definition the KPI beside it uses. A plain count() here put a
|
|
274
|
+
-- fourth session number on a screen that already had three: nav 11, chips 12,
|
|
275
|
+
-- avg depth 40/12, histogram 10.
|
|
276
|
+
countIf(c.name != '' OR c.total_msgs > 0) AS sessions
|
|
257
277
|
FROM {{sessions_v}} AS c WHERE 1=1${f.and}`, f.params);
|
|
258
278
|
|
|
259
279
|
const streakRows = await q(`
|
|
@@ -323,7 +343,7 @@ async function getDashboardStats(opts = {}) {
|
|
|
323
343
|
tokens: {
|
|
324
344
|
input: inputTokens, output: outputTokens, cacheRead: Number(tokenRow.cacheRead), cacheWrite: Number(tokenRow.cacheWrite),
|
|
325
345
|
userChars: Number(tokenRow.userChars), assistantChars: Number(tokenRow.assistantChars),
|
|
326
|
-
sessions: Number(tokenRow.sessions), estimated: tokensEstimated,
|
|
346
|
+
sessions: Number(tokenRow.sessions), messages: Number(tokenRow.messages), estimated: tokensEstimated,
|
|
327
347
|
},
|
|
328
348
|
streaks: { current: currentStreak, longest: longestStreak, totalDays: streakRows.length },
|
|
329
349
|
monthlyTrend: { months: Object.keys(monthEditors).sort(), sources: [...allSources], data: monthEditors },
|
|
@@ -359,26 +379,30 @@ async function getChats(opts = {}) {
|
|
|
359
379
|
const rows = await q(sql, params);
|
|
360
380
|
if (rows.length === 0) return [];
|
|
361
381
|
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
382
|
+
// ONE cost implementation, shared with the Costs page.
|
|
383
|
+
//
|
|
384
|
+
// getChats used to compute its own, and the two disagreed three times running: first the
|
|
385
|
+
// whole session priced at its dominant model ($10.02 against $150), then sessions whose
|
|
386
|
+
// rows carry no model at all ($0 against $10.50), then sessions with zero tokens but
|
|
387
|
+
// non-zero chars ($0 against $0.033). Each round fixed the case that had been measured
|
|
388
|
+
// and left the next one. computePerChatCosts already handles all of them — the orphan
|
|
389
|
+
// bucket, the source-dominant and global-dominant fallbacks, the char estimate — so call
|
|
390
|
+
// it instead of growing a second copy of the same logic.
|
|
391
|
+
const perChat = await computePerChatCosts(f);
|
|
392
|
+
const costBySession = {};
|
|
371
393
|
const topModelBySession = {};
|
|
372
|
-
for (const r of
|
|
394
|
+
for (const r of perChat) {
|
|
395
|
+
// computePerChatCosts already emits the composite key as `id`.
|
|
396
|
+
const k = r.id;
|
|
397
|
+
costBySession[k] = { total: r.totalCost, unpriced: !!r.hasUnpriced };
|
|
398
|
+
if (r.byModel[0]) topModelBySession[k] = r.byModel[0].model;
|
|
399
|
+
}
|
|
373
400
|
|
|
374
401
|
return rows.map(r => {
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
outTok = Math.round((r._aChars || 0) / 4);
|
|
380
|
-
}
|
|
381
|
-
const cost = topModel ? (calculateCost(topModel, inTok, outTok, Number(r._cacheR) || 0, Number(r._cacheW) || 0) || 0) : 0;
|
|
402
|
+
const k = `${r.id}::${r.user_id}`;
|
|
403
|
+
const topModel = topModelBySession[k] || null;
|
|
404
|
+
const summed = costBySession[k];
|
|
405
|
+
const cost = summed ? summed.total : 0;
|
|
382
406
|
return {
|
|
383
407
|
// Composite API id: rollups are keyed (session_id, user_id), so the id a
|
|
384
408
|
// client clicks must pin BOTH — otherwise opening one of two colliding
|
|
@@ -388,6 +412,9 @@ async function getChats(opts = {}) {
|
|
|
388
412
|
source: r.source, name: r.name, mode: r.mode, folder: r.folder,
|
|
389
413
|
createdAt: r.created_at, lastUpdatedAt: r.last_updated_at,
|
|
390
414
|
encrypted: false, bubbleCount: Number(r.bubble_count), topModel, cost,
|
|
415
|
+
// Surfaced, not just computed. A session containing an unpriced model reports a cost
|
|
416
|
+
// that silently omits part of itself; the caller can now say so.
|
|
417
|
+
costPartial: !!(summed && summed.unpriced),
|
|
391
418
|
};
|
|
392
419
|
});
|
|
393
420
|
}
|
|
@@ -643,7 +670,7 @@ async function getToolCalls(toolName, opts = {}) {
|
|
|
643
670
|
// ── cost estimation ─────────────────────────────────────────────────────────────
|
|
644
671
|
// Messages with model '' or '<synthetic>' are "orphans": their tokens are
|
|
645
672
|
// attributed to the session's dominant model (root cache.js treats model IS NULL
|
|
646
|
-
// the same way; '' is the
|
|
673
|
+
// the same way; '' is the memhouse null and synthetic models have no pricing).
|
|
647
674
|
const ORPHAN_TOKENS = '(m.input_tokens > 0 OR m.output_tokens > 0 OR m.cache_read_tokens > 0 OR m.cache_write_tokens > 0)';
|
|
648
675
|
|
|
649
676
|
// session_id → dominant model (most frequent across the session's messages).
|
|
@@ -821,15 +848,19 @@ async function computePerChatCosts(f) {
|
|
|
821
848
|
if (srcDom) add(srcDom, c.ti, c.to_, c.cr, c.cw);
|
|
822
849
|
}
|
|
823
850
|
let totalCost = 0; const byModel = [];
|
|
851
|
+
// A model with no price contributes nothing to totalCost, so the number a session
|
|
852
|
+
// reports silently omits part of itself. Carry the fact alongside the number.
|
|
853
|
+
let hasUnpriced = false;
|
|
824
854
|
for (const [model, tok] of Object.entries(tokenMap)) {
|
|
825
855
|
const cost = calculateCost(model, tok.input, tok.output, tok.cacheRead, tok.cacheWrite);
|
|
826
856
|
if (cost !== null) { totalCost += cost; byModel.push({ model, cost }); }
|
|
857
|
+
else hasUnpriced = true;
|
|
827
858
|
}
|
|
828
859
|
byModel.sort((a, b) => b.cost - a.cost);
|
|
829
860
|
out.push({
|
|
830
861
|
id: key, user: c.user_id, source: c.source, name: c.name, folder: c.folder,
|
|
831
862
|
last_updated_at: c.last_updated_at, created_at: c.created_at,
|
|
832
|
-
msgs: Number(c.msgs), month: c.month, totalCost, byModel,
|
|
863
|
+
msgs: Number(c.msgs), month: c.month, totalCost, byModel, hasUnpriced,
|
|
833
864
|
});
|
|
834
865
|
}
|
|
835
866
|
return out;
|
|
@@ -881,10 +912,22 @@ async function getCostAnalytics(opts = {}) {
|
|
|
881
912
|
}
|
|
882
913
|
|
|
883
914
|
// ── raw SQL / schema (SqlViewer) ────────────────────────────────────────────────
|
|
915
|
+
// Bounded at the SERVER, not in JS. `q()` buffers every row into memory, so
|
|
916
|
+
// `SELECT number FROM numbers(2000000000)` killed the whole dashboard process —
|
|
917
|
+
// "FATAL ERROR: Ineffective mark-compacts near heap limit", unauthenticated, on the bound
|
|
918
|
+
// port. Capping after the rows arrive is too late; ClickHouse has to stop sending.
|
|
919
|
+
const RAW_MAX_ROWS = 10000;
|
|
920
|
+
const RAW_MAX_BYTES = 64 * 1024 * 1024;
|
|
884
921
|
async function rawQuery(sql) {
|
|
885
|
-
const rows = await q(sql
|
|
922
|
+
const rows = await q(sql, {}, {
|
|
923
|
+
max_result_rows: RAW_MAX_ROWS,
|
|
924
|
+
max_result_bytes: RAW_MAX_BYTES,
|
|
925
|
+
// Cut the result off rather than failing a query that was legitimately large.
|
|
926
|
+
result_overflow_mode: 'break',
|
|
927
|
+
max_execution_time: 30,
|
|
928
|
+
});
|
|
886
929
|
const columns = rows.length > 0 ? Object.keys(rows[0]) : [];
|
|
887
|
-
return { columns, rows, count: rows.length };
|
|
930
|
+
return { columns, rows, count: rows.length, truncated: rows.length >= RAW_MAX_ROWS };
|
|
888
931
|
}
|
|
889
932
|
|
|
890
933
|
async function schema() {
|
|
@@ -925,4 +968,7 @@ module.exports = {
|
|
|
925
968
|
getProjects, getDeepAnalytics, getToolCalls,
|
|
926
969
|
estimateCosts, getCostAnalytics,
|
|
927
970
|
rawQuery, schema,
|
|
971
|
+
// Used only by the server's own guard to learn which database names are real. Not
|
|
972
|
+
// reachable from any route.
|
|
973
|
+
rawQueryUnguarded: (sql) => q(sql),
|
|
928
974
|
};
|