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.
Files changed (63) hide show
  1. package/README.md +244 -114
  2. package/bin/memhouse.js +1162 -230
  3. package/editors/base.js +6 -0
  4. package/editors/claude.js +44 -9
  5. package/editors/goose.js +4 -0
  6. package/editors/index.js +0 -0
  7. package/editors/opencode.js +3 -0
  8. package/{mem-house → memhouse}/COMPETITION.md +5 -5
  9. package/{mem-house → memhouse}/DESIGN.md +18 -12
  10. package/memhouse/RESUME-AND-UPDATE.md +185 -0
  11. package/{mem-house → memhouse}/delivery/AGENT-INSTALL-PROMPT.md +1 -1
  12. package/{mem-house → memhouse}/delivery/AGENT-INSTALL.md +9 -7
  13. package/{mem-house → memhouse}/delivery/PROMPT.md +8 -8
  14. package/{mem-house → memhouse}/delivery/kernel-install.md +3 -3
  15. package/{mem-house → memhouse}/delivery/plugin/.claude-plugin/plugin.json +1 -1
  16. package/{mem-house/delivery/skills/memhouse-search → memhouse/delivery/plugin/skills/search}/SKILL.md +60 -18
  17. package/memhouse/delivery/plugin/skills/sessions/SKILL.md +177 -0
  18. package/memhouse/delivery/plugin/skills/sql/SKILL.md +206 -0
  19. package/memhouse/host.js +101 -0
  20. package/{mem-house → memhouse}/per-member/INSTALL.md +59 -14
  21. package/{mem-house → memhouse}/per-member/SCHEMA.md +47 -0
  22. package/memhouse/per-member/provision.js +265 -0
  23. package/memhouse/per-member/rooms.js +235 -0
  24. package/{mem-house → memhouse}/per-member/schema-member.sql.tpl +18 -14
  25. package/{mem-house → memhouse}/per-member/schema-merge.sql.tpl +3 -3
  26. package/memhouse/resume.js +150 -0
  27. package/memhouse/self-update.js +150 -0
  28. package/{mem-house → memhouse}/server/queries.js +80 -34
  29. package/memhouse/server/server.js +451 -0
  30. package/{mem-house → memhouse}/service.js +48 -29
  31. package/memhouse/shipper/ship.js +949 -0
  32. package/package.json +5 -20
  33. package/public/assets/{index-CtH-5i95.js → index-CnWzrjF3.js} +10 -7
  34. package/public/index.html +2 -2
  35. package/ui/index.html +1 -1
  36. package/ui/src/App.jsx +1 -1
  37. package/ui/src/lib/api.js +29 -13
  38. package/ui/src/pages/Dashboard.jsx +8 -5
  39. package/ui/src/pages/Sessions.jsx +3 -1
  40. package/ui/src/pages/SqlViewer.jsx +6 -23
  41. package/cache.js +0 -850
  42. package/ch-ingest.js +0 -329
  43. package/ch-schema.js +0 -152
  44. package/clickhouse.js +0 -87
  45. package/index.js +0 -425
  46. package/mcp-server.js +0 -294
  47. package/mem-house/delivery/install.sh +0 -183
  48. package/mem-house/delivery/skills/memhouse-sessions/SKILL.md +0 -125
  49. package/mem-house/delivery/skills/memhouse-sql/SKILL.md +0 -146
  50. package/mem-house/per-member/provision.js +0 -142
  51. package/mem-house/per-member/rooms.js +0 -132
  52. package/mem-house/server/server.js +0 -228
  53. package/mem-house/shipper/ship.js +0 -566
  54. package/relay-client.js +0 -306
  55. package/relay-server.js +0 -577
  56. package/server.js +0 -860
  57. package/share-image.js +0 -367
  58. /package/{mem-house → memhouse}/deploy.js +0 -0
  59. /package/{mem-house → memhouse}/envfile.js +0 -0
  60. /package/{mem-house → memhouse}/per-member/PLAN.md +0 -0
  61. /package/{mem-house → memhouse}/per-member/PROVISIONING.md +0 -0
  62. /package/{mem-house → memhouse}/per-member/README.md +0 -0
  63. /package/{mem-house → memhouse}/per-member/SHARING.md +0 -0
@@ -1,146 +0,0 @@
1
- ---
2
- name: memhouse-sql
3
- description: Run free-form read-only SQL against memhouse conversation memory (typed sessions/messages/tool_calls rooms on ClickHouse, one set per member). Use for ad-hoc analytics the other memhouse skills don't cover — token spend, model/editor usage, tool rankings, activity heatmaps, busiest days/projects, cache-hit ratios, or any custom question over conversation data.
4
- user-invocable: true
5
- argument-hint: "<question or SQL>"
6
- allowed-tools: Bash(set -a*), Bash(. *), Bash(curl*)
7
- ---
8
-
9
- # memhouse-sql — ad-hoc analytics
10
-
11
- If the user gives SQL, run it (append `FORMAT PrettyCompact` if no FORMAT
12
- given). If they give a question, write the SQL yourself from the schema below.
13
-
14
- **Read-only rule:** the shipper (`ship.js`) is the only writer. Never INSERT/ALTER/DROP
15
- from here. A member credential normally holds `ALL` on its **own three rooms** — enough
16
- to destroy that memory — and nothing at all on anyone else's. Reads need no scoping
17
- clause: the rooms you can name are already only yours, and there is no policy to work
18
- around.
19
-
20
- ## Connection
21
-
22
- Credentials from `~/.memhouse/env` (or exported `MEMHOUSE_*`). Always read with
23
- `final=1` — every table is `ReplacingMergeTree(ingested_at)`, so without it you
24
- can double-count stale row versions — and `join_use_nulls=1`, which the session
25
- rollup depends on (see below):
26
-
27
- ```bash
28
- set -a; [ -f ~/.memhouse/env ] && . ~/.memhouse/env; set +a
29
- curl -sS --fail-with-body --user "${MEMHOUSE_USER:-memhouse_root}:${MEMHOUSE_PASSWORD:-}" \
30
- --data-binary @- "${MEMHOUSE_URL:-http://localhost:8123}/?database=${MEMHOUSE_DB:-mem}&final=1&join_use_nulls=1" <<'SQL'
31
- <the query>
32
- FORMAT PrettyCompact
33
- SQL
34
- ```
35
-
36
- `FORMAT PrettyCompact` for display, `FORMAT JSONEachRow` to parse.
37
-
38
- ## Room names — always suffixed
39
-
40
- Every member owns their own rooms, named for their ClickHouse user:
41
- `messages_<you>`, `sessions_<you>`, `tool_calls_<you>`. There are no unsuffixed rooms;
42
- the bare names fail with `UNKNOWN_TABLE` rather than returning nothing. You hold no grant
43
- on anyone else's rooms, so isolation is not something a query can work around.
44
-
45
- Resolve your own name once and substitute it into every table name below:
46
-
47
- ```bash
48
- MEM_ME="$(curl -sS --fail-with-body --user "$MEMHOUSE_USER:$MEMHOUSE_PASSWORD" \
49
- --data-binary "SELECT currentUser() FORMAT TabSeparated" \
50
- "${MEMHOUSE_URL:-http://localhost:8123}/" | tr -d '\r\n')"
51
- # rooms: messages_$MEM_ME, sessions_$MEM_ME, tool_calls_$MEM_ME
52
- ```
53
-
54
- **There is no `sessions_v` object.** The session rollup is a saved query over those same
55
- rooms — `memhouse sessions-query` prints it for whoever you are connected as, ready to
56
- paste into a `FROM (...) AS c` position.
57
-
58
- **Read the rollup with `join_use_nulls=1`.** The connection recipe above sets it. Without
59
- it, ClickHouse gives an unmatched `m.seq` a default instead of NULL, so a session with no
60
- messages reports `total_msgs = 1` rather than 0 — measured, not theoretical. A subquery
61
- has no `SETTINGS` clause of its own, so the setting has to come from the caller.
62
-
63
- If you hold `SELECT` on them, `all_messages` / `all_sessions` / `all_tool_calls` read
64
- across every member at once, narrowed to whatever grants you actually have — a Merge room
65
- reduces to the rooms the caller can read, so it fails closed rather than denying outright.
66
-
67
- ## Schema (the house)
68
-
69
- | Object | Kind | Columns |
70
- |---|---|---|
71
- | `sessions_<you>` | table, 1 row/session | `session_id, source, host, name, mode, folder, project, git_branch, created_at, last_updated_at, message_count, path, extra JSON, user_id, ingested_at` |
72
- | `messages_<you>` | table, 1 row/message | `session_id, seq, source, host, ts, role, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, text, project, folder, is_subagent, extra JSON, line_hash, user_id, ingested_at` + FTS columns `text_ngram`/`text_word` (lowercased; see memhouse-search) |
73
- | `tool_calls_<you>` | table, 1 row/tool call | `session_id, seq, idx, source, host, tool_name, args, ts, project, folder, user_id, ingested_at` |
74
- | the rollup | **saved query**, not an object — `memhouse sessions-query` prints it | `session_id, source, host, name, mode, folder, project, git_branch, user_id, created_at, last_updated_at, started, ended, duration_sec, total_msgs, user_msgs, assistant_msgs, subagent_msgs, models, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, user_chars, assistant_chars, first_prompt` |
75
-
76
- Notes: `source` = editor id (`claude-code`, `codex`, `cursor`, `cursor-agent`,
77
- `vscode`, `zed`, `opencode`, `gemini-cli`, `windsurf`, `antigravity`, …);
78
- `seq` = message index within its session (0-based); `tool_calls.idx` = call
79
- index within the session; `user_id` is server-stamped (`currentUser()`);
80
- `models` in the rollup is an `Array(String)`. Exclude `''` and
81
- `'<synthetic>'` from model aggregates.
82
-
83
- ## Examples
84
-
85
- Token spend by model:
86
-
87
- ```sql
88
- SELECT model, sum(input_tokens) AS in_tok, sum(output_tokens) AS out_tok,
89
- sum(cache_read_tokens) AS cache_read
90
- FROM messages_<you>
91
- WHERE model NOT IN ('', '<synthetic>')
92
- GROUP BY model
93
- ORDER BY out_tok DESC
94
- FORMAT PrettyCompact
95
- ```
96
-
97
- Which tools do I use most (memhouse exclusive — memory-house has no tool table):
98
-
99
- ```sql
100
- SELECT tool_name, count() AS calls, uniqExact(session_id) AS sessions
101
- FROM tool_calls_<you>
102
- GROUP BY tool_name
103
- ORDER BY calls DESC
104
- LIMIT 20
105
- FORMAT PrettyCompact
106
- ```
107
-
108
- Busiest days, last two weeks:
109
-
110
- ```sql
111
- SELECT toDate(ts) AS day, uniqExact(session_id) AS sessions, count() AS msgs
112
- FROM messages_<you>
113
- WHERE ts > now() - INTERVAL 14 DAY
114
- GROUP BY day
115
- ORDER BY day DESC
116
- FORMAT PrettyCompact
117
- ```
118
-
119
- Activity by editor this week / subagent share:
120
-
121
- ```sql
122
- SELECT source, count() AS msgs, countIf(is_subagent) AS subagent_msgs
123
- FROM messages_<you>
124
- WHERE ts > now() - INTERVAL 7 DAY
125
- GROUP BY source
126
- ORDER BY msgs DESC
127
- FORMAT PrettyCompact
128
- ```
129
-
130
- Longest sessions by wall-clock:
131
-
132
- ```sql
133
- SELECT name, project, source, duration_sec, total_msgs, output_tokens
134
- FROM (the rollup from `memhouse sessions-query`)
135
- ORDER BY duration_sec DESC
136
- LIMIT 10
137
- FORMAT PrettyCompact
138
- ```
139
-
140
- ## Output
141
-
142
- Run the query, present the result as a compact table, and add one or two lines
143
- of interpretation. Offer a natural follow-up cut (by source, by project, by
144
- host, by token volume) when relevant. Dollar costs are computed by the
145
- dashboard server from pricing.js — token sums here are the raw material, not
146
- dollars.
@@ -1,142 +0,0 @@
1
- #!/usr/bin/env node
2
- // mem — provision one member's rooms and grants. Idempotent; also the schema-rollout hook.
3
- //
4
- // provision.js --member <name> [--merge] [--owner-grants]
5
- //
6
- // Run as the OWNER. Steps, all IF NOT EXISTS / re-runnable:
7
- // 1. create the member's three rooms from schema-member.sql.tpl
8
- // 2. grant the member their own three rooms, TWO statements each: `ALL` (not
9
- // re-grantable), then `SELECT WITH GRANT OPTION`. The split is the point —
10
- // `ALL` covers the mutation privileges the shipper's clear-then-insert needs
11
- // without naming them, and attaching grant-option to `SELECT` alone makes a share
12
- // read-only by construction. See the notes at the grants themselves.
13
- //
14
- // THREE ROOMS, not four objects: the session rollup is a saved query over these
15
- // same rooms, not a stored view, so it needs no object and no grant of its own.
16
- // 3. --merge: create/refresh the three Merge rooms, borrowing this member's columns
17
- // 4. grant the member SELECT on whichever Merge rooms exist — a Merge reduces to the
18
- // rooms the caller can already read, so this is what makes the team room fail
19
- // CLOSED rather than deny outright
20
- //
21
- // Grants are explicit, per room, and NEVER `ON mem.*` — a member who can read the whole
22
- // house can read every other member's rooms, which would make this a shared house with
23
- // longer table names. No wildcards are used anywhere; the only pattern in the design is
24
- // the Merge regex, anchored on a fixed room type.
25
- //
26
- // Env: MEM_URL / MEM_USER / MEM_PASSWORD / MEM_DB, falling back to MEMHOUSE_* and then
27
- // to the shipper's own defaults — http://localhost:8123, memhouse_root, mem.
28
-
29
- const fs = require('fs');
30
- const path = require('path');
31
- const { createClient } = require('@clickhouse/client');
32
- const { ROOM_TYPES, mergeRooms, assertUsableMember } = require('./rooms');
33
-
34
- const args = process.argv.slice(2);
35
- const flag = (n) => args.includes(`--${n}`);
36
- const opt = (n, d = null) => { const i = args.indexOf(`--${n}`); return i >= 0 ? args[i + 1] : d; };
37
-
38
- // Defaults MUST match what the shipper and CLI use, or an operator who overrides only
39
- // the thing that differs — a password, say — silently provisions rooms in a house the
40
- // product never reads, or fails to authenticate as a user that does not exist.
41
- const cfg = {
42
- url: process.env.MEM_URL || process.env.MEMHOUSE_URL || 'http://localhost:8123',
43
- username: process.env.MEM_USER || process.env.MEMHOUSE_USER || 'memhouse_root',
44
- password: process.env.MEM_PASSWORD || process.env.MEMHOUSE_PASSWORD || '',
45
- database: process.env.MEM_DB || process.env.MEMHOUSE_DB || 'mem',
46
- };
47
-
48
- // Statements are split on ';' at end of line — the templates contain no ';' inside a
49
- // statement body, and keeping the splitter dumb keeps the templates readable.
50
- function statements(sql) {
51
- return sql
52
- .split(/;\s*$/m)
53
- .map((s) => s.replace(/^\s*--.*$/gm, '').trim())
54
- .filter(Boolean);
55
- }
56
-
57
- /** Which of the three Merge rooms actually exist — a member may be provisioned first. */
58
- async function mergeRoomsPresent(client, database) {
59
- const names = Object.values(mergeRooms());
60
- const rs = await client.query({
61
- query: `SELECT name FROM system.tables WHERE database = {db:String} AND name IN ({names:Array(String)}) ORDER BY name`,
62
- query_params: { db: database, names },
63
- format: 'JSONEachRow',
64
- });
65
- return (await rs.json()).map((r) => r.name);
66
- }
67
-
68
- async function main() {
69
- const member = opt('member');
70
- if (!member) { console.error('usage: provision.js --member <name> [--merge]'); process.exit(2); }
71
- assertUsableMember(member);
72
-
73
- const here = __dirname;
74
-
75
- // Create the house with a client that has NOT selected it — on a fresh server the
76
- // database does not exist yet, and selecting it fails before it can be created.
77
- const bootstrap = createClient({ ...cfg, database: '' });
78
- await bootstrap.command({ query: `CREATE DATABASE IF NOT EXISTS ${cfg.database}` });
79
- await bootstrap.close();
80
-
81
- console.log(`[mem] provisioning in ${cfg.url} database '${cfg.database}' as '${cfg.username}'`);
82
- const client = createClient({ ...cfg, clickhouse_settings: { async_insert: 0 } });
83
-
84
- // 1. rooms
85
- const tpl = fs.readFileSync(path.join(here, 'schema-member.sql.tpl'), 'utf-8');
86
- const roomSql = statements(tpl.replaceAll('{{MEMBER}}', member));
87
- for (const q of roomSql) {
88
- // allow_experimental_full_text_index: a no-op on 26.x, required on 25.x for the
89
- // messages text indexes.
90
- await client.command({ query: q, clickhouse_settings: { allow_experimental_full_text_index: 1 } });
91
- }
92
- console.log(`[mem] rooms ready for '${member}': ${ROOM_TYPES.map((t) => `${t}_${member}`).join(', ')}`);
93
-
94
- // 2. grants — two statements per room, and the split is the point.
95
- //
96
- // ALL, NOT re-grantable: the member owns their room outright, DROP and TRUNCATE
97
- // included, because it is their memory to destroy. It also covers the mutation
98
- // privileges the shipper needs without naming them — the shipper clears a session's
99
- // rows before re-inserting, and which of ALTER UPDATE / ALTER DELETE a server demands
100
- // varies by version (`DELETE FROM` is a lightweight delete, implemented as
101
- // `ALTER TABLE … UPDATE _row_exists = 0`, so 25.11 wants ALTER UPDATE where 26.7 wanted
102
- // ALTER DELETE). Granting one and not the other produces a pass that fails elsewhere.
103
- //
104
- // SELECT, re-grantable: a share is READ-ONLY BY CONSTRUCTION rather than by convention.
105
- // `GRANT ALL … WITH GRANT OPTION` expands to 45 privileges on 26.7 — DROP TABLE,
106
- // TRUNCATE, CREATE ROW POLICY, SYSTEM DROP REPLICA among them — and every one of those
107
- // would become something a member could hand to a colleague while meaning "let them
108
- // read my sessions". Measured: with this split, `GRANT SELECT … TO bob` succeeds and
109
- // `GRANT DROP TABLE … TO bob` is refused 497.
110
- for (const t of ROOM_TYPES) {
111
- const room = `${cfg.database}.${t}_${member}`;
112
- await client.command({ query: `GRANT ALL ON ${room} TO ${member}` });
113
- await client.command({ query: `GRANT SELECT ON ${room} TO ${member} WITH GRANT OPTION` });
114
- }
115
- console.log(`[mem] granted ALL on 3 rooms to '${member}'; SELECT is the only re-grantable one`);
116
-
117
- // 3. Merge rooms
118
- if (flag('merge')) {
119
- const mtpl = fs.readFileSync(path.join(here, 'schema-merge.sql.tpl'), 'utf-8');
120
- for (const q of statements(mtpl.replaceAll('{{TEMPLATE_MEMBER}}', member))) {
121
- await client.command({ query: q });
122
- }
123
- console.log('[mem] merge rooms ready: all_sessions, all_messages, all_tool_calls');
124
- }
125
-
126
- // 4. Merge-room grants. Broad on purpose, and safe for the reason the whole layout
127
- // rests on: a Merge table reduces to the underlying rooms the CALLER holds grants for.
128
- // Measured on 25.11 — bob with SELECT on all_messages and nothing on messages_alice
129
- // reads his own row and none of alice's, with `_table` showing only messages_bob, and
130
- // no error. Withholding this grant does not make anything safer; it makes the team
131
- // room deny outright instead of failing closed, which is a different and worse thing.
132
- const merged = await mergeRoomsPresent(client, cfg.database);
133
- for (const room of merged) {
134
- await client.command({ query: `GRANT SELECT ON ${cfg.database}.${room} TO ${member}` });
135
- }
136
- if (merged.length) console.log(`[mem] granted SELECT on ${merged.join(', ')} to '${member}'`);
137
- else console.log('[mem] no merge rooms yet — run once with --merge to create them');
138
-
139
- await client.close();
140
- }
141
-
142
- main().catch((e) => { console.error(`[mem] provision failed: ${e.message}`); process.exit(1); });
@@ -1,132 +0,0 @@
1
- // Room-name resolution.
2
- //
3
- // Every member owns their rooms, named TYPE-FIRST: `sessions_<member>`,
4
- // `messages_<member>`, `tool_calls_<member>`. Type-first is what lets the Merge rooms
5
- // anchor on a fixed room type (`^sessions_`) and never match themselves.
6
- //
7
- // THIS IS THE ONLY LAYOUT. There was a shared one — three rooms named `sessions`,
8
- // `messages`, `tool_calls`, with row policies narrowing each caller to their own rows —
9
- // and it is gone. A shared read is a Merge room plus a GRANT, which is strictly less
10
- // machinery than a policy that has to be right on every table and every read path; and
11
- // isolation by absent grant fails closed, where a row policy fails open the moment one is
12
- // missing.
13
- //
14
- // The member is never supplied by the caller — it is read back from the server with
15
- // currentUser(), the same value the rooms' user_id column is stamped with. A client that
16
- // could name its own member could write into someone else's rooms.
17
- //
18
- // `sessions_v` IS A SAVED QUERY, NOT A ROOM. It was briefly a stored view, which cost a
19
- // name inside the `^sessions_` namespace the Merge rooms select on (silently merged into
20
- // `all_sessions`, doubling every count), a fourth grant per member, and a fourth object to
21
- // provision, roll forward and drop. A saved query is substituted with the caller's own
22
- // room names and runs under the caller's credential, so it inherits their grants and
23
- // raises no view-ownership question at all.
24
-
25
- const ROOM_TYPES = ['sessions', 'messages', 'tool_calls'];
26
-
27
- // ClickHouse usernames are permissive; room names are not. Refuse anything that would
28
- // need quoting or could change how a Merge regex or a name-splitter reads.
29
- function assertUsableMember(member) {
30
- if (typeof member !== 'string' || !/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) {
31
- throw new Error(`cannot build room names for user '${member}': expected [A-Za-z][A-Za-z0-9_]*`);
32
- }
33
- }
34
-
35
- /**
36
- * The session rollup, as SQL text rather than a name.
37
- *
38
- * Two properties are load bearing: the join is a LEFT JOIN, so a session with no messages
39
- * still appears with zero aggregates instead of vanishing from every count; and
40
- * `join_use_nulls = 1` makes the unmatched message columns NULL so `coalesce` yields true
41
- * zeros rather than counting the placeholder row. The setting is applied by the CALLER (a
42
- * client setting, not a trailing SETTINGS clause) because this text is used as a subquery,
43
- * and a subquery cannot carry its own SETTINGS.
44
- */
45
- function sessionsRollup({ sessions, messages }) {
46
- return `(
47
- SELECT
48
- s.session_id AS session_id,
49
- any(s.source) AS source,
50
- any(s.host) AS host,
51
- any(s.name) AS name,
52
- any(s.mode) AS mode,
53
- any(s.folder) AS folder,
54
- any(s.project) AS project,
55
- any(s.git_branch) AS git_branch,
56
- s.user_id AS user_id,
57
- any(s.created_at) AS created_at,
58
- any(s.last_updated_at) AS last_updated_at,
59
- min(m.ts) AS started,
60
- max(m.ts) AS ended,
61
- coalesce(dateDiff('second', min(m.ts), max(m.ts)), 0) AS duration_sec,
62
- count(m.seq) AS total_msgs,
63
- countIf(m.role = 'user') AS user_msgs,
64
- countIf(m.role = 'assistant') AS assistant_msgs,
65
- countIf(m.is_subagent) AS subagent_msgs,
66
- groupUniqArrayIf(m.model, m.model NOT IN ('', '<synthetic>')) AS models,
67
- coalesce(sum(m.input_tokens), 0) AS input_tokens,
68
- coalesce(sum(m.output_tokens), 0) AS output_tokens,
69
- coalesce(sum(m.cache_read_tokens), 0) AS cache_read_tokens,
70
- coalesce(sum(m.cache_write_tokens), 0) AS cache_write_tokens,
71
- coalesce(sumIf(length(m.text), m.role = 'user'), 0) AS user_chars,
72
- coalesce(sumIf(length(m.text), m.role = 'assistant'), 0) AS assistant_chars,
73
- coalesce(substring(argMinIf(m.text, m.seq, m.role = 'user' AND m.text != ''), 1, 200), '') AS first_prompt
74
- FROM ${sessions} AS s
75
- LEFT JOIN ${messages} AS m ON m.session_id = s.session_id AND m.user_id = s.user_id
76
- GROUP BY s.session_id, s.user_id
77
- )`;
78
- }
79
-
80
- /**
81
- * The settings every read needs. `final` collapses ReplacingMergeTree versions;
82
- * `join_use_nulls` is what the rollup's coalesce depends on.
83
- */
84
- const READ_SETTINGS = { final: 1, join_use_nulls: 1 };
85
-
86
- /**
87
- * Names, given the member the server reported. Shared by every caller so the two
88
- * transports in the tree (@clickhouse/client and the CLI's raw fetch) cannot drift into
89
- * two different naming rules.
90
- *
91
- * `sessions_v` is the rollup as SQL text, usable in the same `FROM ... AS c` position a
92
- * view name would occupy — which is what lets the read layer treat it as just another
93
- * resolved name.
94
- */
95
- function roomNames(member, user = member) {
96
- assertUsableMember(member);
97
- const out = { member, user };
98
- for (const t of ROOM_TYPES) out[t] = `${t}_${member}`;
99
- out.sessions_v = sessionsRollup(out);
100
- return out;
101
- }
102
-
103
- async function currentUser(client) {
104
- const rs = await client.query({ query: 'SELECT currentUser() AS u', format: 'JSONEachRow' });
105
- const rows = await rs.json();
106
- const u = rows[0] && rows[0].u;
107
- if (!u) throw new Error('could not determine currentUser()');
108
- return u;
109
- }
110
-
111
- /**
112
- * Resolve the rooms for whoever this client is connected as.
113
- * Returns { sessions, messages, tool_calls, sessions_v, member, user }.
114
- *
115
- * `user` is the identity the server reports. Writers need the value itself, not just the
116
- * name it produces — see ship.js's delete, which must BIND the user rather than call
117
- * currentUser() inside a mutation, where it is not evaluated in the caller's context.
118
- */
119
- async function resolveRooms(client) {
120
- const user = await currentUser(client);
121
- return roomNames(user, user);
122
- }
123
-
124
- /** The three Merge rooms — team-wide read paths, owner-managed. */
125
- function mergeRooms() {
126
- return { sessions: 'all_sessions', messages: 'all_messages', tool_calls: 'all_tool_calls' };
127
- }
128
-
129
- module.exports = {
130
- ROOM_TYPES, READ_SETTINGS, assertUsableMember,
131
- sessionsRollup, roomNames, currentUser, resolveRooms, mergeRooms,
132
- };
@@ -1,228 +0,0 @@
1
- // mem-house server — the dashboard/API entrypoint.
2
- //
3
- // Speaks the agentlytics REST contract (same routes, same response shapes) over the
4
- // mem-house typed schema, so the UNMODIFIED agentlytics React SPA is the dashboard:
5
- // queries.js computes every response; this file is routing + config + stubs. Serves
6
- // the built SPA from the repo root's public/.
7
- //
8
- // node mem-house/server/server.js (env: MEMHOUSE_* per DESIGN.md; port 4640)
9
-
10
- const express = require('express');
11
- const path = require('path');
12
- const fs = require('fs');
13
- 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).
19
- const HOST = process.env.MEMHOUSE_HOST || '127.0.0.1';
20
- const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
21
- const CONFIG_PATH = path.join(os.homedir(), '.memhouse', 'config.json');
22
-
23
- const app = express();
24
- app.use(express.json());
25
- if (fs.existsSync(PUBLIC_DIR)) app.use(express.static(PUBLIC_DIR));
26
-
27
- function readConfig() {
28
- try { return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); } catch { return {}; }
29
- }
30
- function writeConfig(config) {
31
- fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
32
- fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
33
- }
34
- const hiddenFolders = () => readConfig().hiddenProjects || [];
35
-
36
- function parseDateOpts(query) {
37
- const opts = {};
38
- if (query.dateFrom) opts.dateFrom = parseInt(query.dateFrom) || null;
39
- if (query.dateTo) opts.dateTo = parseInt(query.dateTo) || null;
40
- return opts;
41
- }
42
-
43
- // Route helper: async handler with the uniform 500 shape.
44
- const route = (fn) => async (req, res) => {
45
- try { await fn(req, res); } catch (err) { res.status(500).json({ error: err.message }); }
46
- };
47
-
48
- app.get('/api/ping', (req, res) => res.json({ app: 'agentlytics', pid: process.pid }));
49
- app.get('/api/mode', (req, res) => res.json({ mode: 'local' }));
50
-
51
- app.get('/api/overview', route(async (req, res) => {
52
- res.json(await qy.getOverview({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
53
- }));
54
-
55
- app.get('/api/daily-activity', route(async (req, res) => {
56
- res.json(await qy.getDailyActivity({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
57
- }));
58
-
59
- app.get('/api/dashboard-stats', route(async (req, res) => {
60
- res.json(await qy.getDashboardStats({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
61
- }));
62
-
63
- app.get('/api/chats', route(async (req, res) => {
64
- const opts = {
65
- editor: req.query.editor || null,
66
- folder: req.query.folder || null,
67
- named: req.query.named !== 'false',
68
- limit: req.query.limit ? parseInt(req.query.limit) : 200,
69
- offset: req.query.offset ? parseInt(req.query.offset) : 0,
70
- ...parseDateOpts(req.query),
71
- hiddenFolders: hiddenFolders(),
72
- };
73
- const [total, chats] = await Promise.all([qy.countChats(opts), qy.getChats(opts)]);
74
- res.json({ total, chats });
75
- }));
76
-
77
- app.get('/api/chats/:id', route(async (req, res) => {
78
- const result = await qy.getChat(req.params.id);
79
- if (!result) return res.status(404).json({ error: 'Chat not found' });
80
- res.json(result);
81
- }));
82
-
83
- app.get('/api/chats/:id/markdown', route(async (req, res) => {
84
- const result = await qy.getChat(req.params.id);
85
- if (!result) return res.status(404).json({ error: 'Chat not found' });
86
- const lines = [];
87
- const title = result.name || 'Untitled Session';
88
- lines.push(`# ${title}\n`);
89
- const meta = [];
90
- if (result.source) meta.push(`**Editor:** ${result.source}`);
91
- if (result.mode) meta.push(`**Mode:** ${result.mode}`);
92
- if (result.folder) meta.push(`**Project:** ${result.folder}`);
93
- if (result.createdAt) meta.push(`**Created:** ${new Date(result.createdAt).toISOString()}`);
94
- if (result.lastUpdatedAt) meta.push(`**Updated:** ${new Date(result.lastUpdatedAt).toISOString()}`);
95
- if (result.stats) {
96
- meta.push(`**Messages:** ${result.stats.totalMessages}`);
97
- if (result.stats.totalInputTokens) meta.push(`**Input Tokens:** ${result.stats.totalInputTokens}`);
98
- if (result.stats.totalOutputTokens) meta.push(`**Output Tokens:** ${result.stats.totalOutputTokens}`);
99
- const models = [...new Set(result.stats.models || [])];
100
- if (models.length > 0) meta.push(`**Models:** ${models.join(', ')}`);
101
- }
102
- if (meta.length > 0) lines.push(meta.join(' \n') + '\n');
103
- lines.push('---\n');
104
- for (const msg of result.messages) {
105
- const label = msg.role === 'user' ? '## User' : msg.role === 'assistant' ? '## Assistant' : `## ${msg.role.charAt(0).toUpperCase() + msg.role.slice(1)}`;
106
- const modelTag = msg.model ? ` *(${msg.model})*` : '';
107
- lines.push(`${label}${modelTag}\n`);
108
- lines.push(msg.content + '\n');
109
- }
110
- const filename = title.replace(/[^a-zA-Z0-9_-]/g, '_').substring(0, 80) + '.md';
111
- res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
112
- res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
113
- res.send(lines.join('\n'));
114
- }));
115
-
116
- app.get('/api/projects', route(async (req, res) => {
117
- res.json(await qy.getProjects({ ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
118
- }));
119
-
120
- app.get('/api/all-projects', route(async (req, res) => {
121
- res.json(await qy.getProjects({ ...parseDateOpts(req.query), includeHidden: true }));
122
- }));
123
-
124
- app.get('/api/deep-analytics', route(async (req, res) => {
125
- res.json(await qy.getDeepAnalytics({
126
- editor: req.query.editor || null,
127
- folder: req.query.folder || null,
128
- limit: Math.min(parseInt(req.query.limit) || 500, 5000),
129
- ...parseDateOpts(req.query),
130
- hiddenFolders: hiddenFolders(),
131
- }));
132
- }));
133
-
134
- app.get('/api/costs', route(async (req, res) => {
135
- res.json(await qy.estimateCosts({
136
- editor: req.query.editor || null,
137
- folder: req.query.folder || null,
138
- chatId: req.query.chatId || null,
139
- ...parseDateOpts(req.query),
140
- hiddenFolders: hiddenFolders(),
141
- }));
142
- }));
143
-
144
- app.get('/api/cost-analytics', route(async (req, res) => {
145
- res.json(await qy.getCostAnalytics({ editor: req.query.editor || null, ...parseDateOpts(req.query), hiddenFolders: hiddenFolders() }));
146
- }));
147
-
148
- app.get('/api/tool-calls', route(async (req, res) => {
149
- const name = req.query.name;
150
- if (!name) return res.status(400).json({ error: 'name query param required' });
151
- // Same filter set as the analytics the drill-down is opened from.
152
- res.json(await qy.getToolCalls(name, {
153
- limit: Math.min(parseInt(req.query.limit) || 200, 1000),
154
- folder: req.query.folder || null,
155
- editor: req.query.editor || null,
156
- ...parseDateOpts(req.query),
157
- hiddenFolders: hiddenFolders(),
158
- }));
159
- }));
160
-
161
- app.post('/api/query', route(async (req, res) => {
162
- const { sql } = req.body;
163
- if (!sql || typeof sql !== 'string') return res.status(400).json({ error: 'sql string required' });
164
- const first = sql.trim().replace(/^--.*$/gm, '').trim().split(/\s+/)[0].toUpperCase();
165
- if (!['SELECT', 'WITH', 'EXPLAIN', 'DESCRIBE', 'SHOW'].includes(first)) {
166
- return res.status(403).json({ error: 'Only SELECT queries are allowed' });
167
- }
168
- try { res.json(await qy.rawQuery(sql)); }
169
- catch (err) { res.status(400).json({ error: err.message }); }
170
- }));
171
-
172
- app.get('/api/schema', route(async (req, res) => res.json(await qy.schema())));
173
-
174
- app.get('/api/config', (req, res) => res.json(readConfig()));
175
- app.put('/api/config', route(async (req, res) => {
176
- const config = readConfig();
177
- Object.assign(config, req.body);
178
- writeConfig(config);
179
- res.json(config);
180
- }));
181
-
182
- // Real refetch: run one full shipper pass, streaming SSE like the root contract.
183
- app.get('/api/refetch', async (req, res) => {
184
- res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive' });
185
- try {
186
- const { runShip } = require('../shipper/ship');
187
- res.write(`data: ${JSON.stringify({ scanned: 0, analyzed: 0, skipped: 0, total: 0 })}\n\n`);
188
- const r = await runShip(qy.getClient(), { full: true });
189
- res.write(`data: ${JSON.stringify({ done: true, total: r.sessions + r.skipped, analyzed: r.sessions })}\n\n`);
190
- } catch (err) {
191
- res.write(`data: ${JSON.stringify({ error: err.message })}\n\n`);
192
- }
193
- res.end();
194
- });
195
-
196
- // Not applicable to mem-house (dashboard renders these pages empty/absent).
197
- app.get('/api/usage', (req, res) => res.json([]));
198
- app.get('/api/artifacts', (req, res) => res.json([]));
199
- app.get('/api/artifact-content', (req, res) => res.status(404).json({ error: 'not available in mem-house' }));
200
- app.get('/api/mcps', (req, res) => res.json({
201
- servers: [], toolCalls: [], matchedTools: {}, topSessions: [], projectMcps: [],
202
- summary: { totalServers: 0, totalToolCalls: 0, uniqueTools: 0, sessionsWithTools: 0, editorsWithServers: [] },
203
- }));
204
- app.get('/api/gsd/projects', (req, res) => res.json([]));
205
- app.get('/api/gsd/phases', (req, res) => res.json([]));
206
- app.get('/api/gsd/plan', (req, res) => res.status(404).json({ error: 'not available in mem-house' }));
207
- app.get('/api/gsd/overview', (req, res) => res.json({ totalProjects: 0, totalPhases: 0, completedPhases: 0, activePhases: [], executingPhases: 0, plannedPhases: 0 }));
208
- app.get('/api/gsd/config', (req, res) => res.json(null));
209
- app.get('/api/gsd/phase-tokens', (req, res) => res.json([]));
210
- app.get('/api/gsd/file', (req, res) => res.json({ content: null }));
211
- app.get('/api/share-image', (req, res) => res.status(501).json({ error: 'not available in mem-house' }));
212
- app.get('/api/check-ai', (req, res) => res.status(501).json({ error: 'not available in mem-house' }));
213
-
214
- // SPA fallback
215
- app.get('*', (req, res) => {
216
- const index = path.join(PUBLIC_DIR, 'index.html');
217
- if (fs.existsSync(index)) res.sendFile(index);
218
- else res.status(503).json({ error: 'UI not built — run: cd ui && npm install && npm run build' });
219
- });
220
-
221
- if (require.main === module) {
222
- app.listen(PORT, HOST, () => {
223
- console.log(`[mem-house] dashboard → http://localhost:${PORT} (house '${qy.config.database}' @ ${qy.config.url.replace(/\/\/.*@/, '//')})`
224
- + (HOST !== '127.0.0.1' ? ` [bound to ${HOST} — remotely reachable]` : ''));
225
- });
226
- }
227
-
228
- module.exports = app;