memhouse 0.16.0 → 0.17.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/README.md +1 -1
- package/bin/memhouse.js +270 -22
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -2
- package/memhouse/delivery/AGENT-INSTALL.md +2 -2
- package/memhouse/delivery/PROMPT.md +15 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +154 -0
- package/memhouse/delivery/plugin/skills/access/SKILL.md +148 -0
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +29 -0
- package/memhouse/delivery/plugin/skills/house/SKILL.md +99 -0
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +119 -0
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +73 -194
- package/memhouse/flags.js +1 -0
- package/memhouse/house/house.js +151 -1
- package/memhouse/server/queries.js +271 -137
- package/memhouse/share.js +64 -0
- package/memhouse/shipper/ship.js +32 -0
- package/package.json +1 -1
- package/memhouse/delivery/plugin/skills/ask/SKILL.md +0 -131
- package/memhouse/delivery/plugin/skills/hello/SKILL.md +0 -121
- package/memhouse/delivery/plugin/skills/invite/SKILL.md +0 -130
- package/memhouse/delivery/plugin/skills/search/SKILL.md +0 -186
- package/memhouse/delivery/plugin/skills/sessions/SKILL.md +0 -164
- package/memhouse/delivery/plugin/skills/share/SKILL.md +0 -130
- package/memhouse/delivery/plugin/skills/status/SKILL.md +0 -139
- package/memhouse/delivery/plugin/skills/users/SKILL.md +0 -97
|
@@ -1,226 +1,105 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: sql
|
|
3
|
-
description: Run
|
|
3
|
+
description: Run read-only SQL over memhouse conversation memory for numbers rather than transcripts — token spend, cost by model or editor or project, tool-call rankings, activity over time, busiest days, cache-hit ratios, session length distributions, or any custom aggregate across the typed sessions/messages/tool_calls tables. Use when the user wants a figure, a ranking, a trend or a breakdown - "what did I spend on Opus", "which tools do I use most", "how many sessions per project", "my busiest week", "compare editors". For finding or answering FROM past conversations, use /mem:recall instead. Works over your own house, or a housemate's when they name it.
|
|
4
4
|
user-invocable: true
|
|
5
|
-
argument-hint: "<question or SQL>"
|
|
5
|
+
argument-hint: "<the question, or the SQL>"
|
|
6
6
|
allowed-tools: Bash
|
|
7
7
|
---
|
|
8
8
|
|
|
9
|
-
# /mem:sql —
|
|
9
|
+
# /mem:sql — analytics over the rooms
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
For **numbers**. When the answer is a passage from a past conversation, that is
|
|
12
|
+
`/mem:recall` — this skill counts, groups and ranks.
|
|
13
13
|
|
|
14
|
-
**Read
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
here. The shipping path needs `SELECT, INSERT, ALTER ADD COLUMN, OPTIMIZE` and nothing
|
|
18
|
-
more — the `ALTER DELETE` this line used to list was there for the per-session clear that
|
|
19
|
-
no longer exists, so no unattended process can lose you a row. `ALTER DELETE` is still
|
|
20
|
-
needed by the one command that is *meant* to remove rows, `memhouse reset`, which asks
|
|
21
|
-
first. Most houses grant `ALL` on the database anyway. Reads need no scoping clause: the
|
|
22
|
-
house you can name is already yours, and there is no policy to work around.
|
|
14
|
+
**Read `../reference/HOUSE.md` first.** Schema, connection, and the three traps. The epoch
|
|
15
|
+
filter in particular is not optional here: this is the skill most likely to produce a
|
|
16
|
+
figure someone acts on, and omitting it over-counted a real house by **34%**.
|
|
23
17
|
|
|
24
|
-
|
|
25
|
-
shrank for any other reason, keeps its earlier parse — that is the whole point of `epoch`,
|
|
26
|
-
and it is why nothing is ever deleted. Ad-hoc SQL must filter to the current parse or it
|
|
27
|
-
counts such a session twice:
|
|
18
|
+
## Read-only, and that is enforced
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
WHERE origin = 'ship' GROUP BY session_id, user_id)
|
|
34
|
-
```
|
|
20
|
+
Every query goes through `readonly=1`. The shipper is the only writer; a skill that
|
|
21
|
+
mutates the archive is a skill that can destroy it. Never `INSERT`, `ALTER`, `DROP`,
|
|
22
|
+
`TRUNCATE` or `OPTIMIZE` from here — not even "just to tidy up". If the user genuinely
|
|
23
|
+
wants a write, that is `/mem:admin` with its confirmations, not this.
|
|
35
24
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
`SELECT`-only credential and point the skills at that instead.
|
|
47
|
-
|
|
48
|
-
## Connection
|
|
49
|
-
|
|
50
|
-
**Never print this file or the variables in it.** No `cat "$MH_ENV"`, no `env | grep
|
|
51
|
-
MEMHOUSE`, no `set -x` around these commands. Anything you print becomes part of a
|
|
52
|
-
transcript that memhouse itself ships into the house.
|
|
53
|
-
|
|
54
|
-
Credentials resolve as **flags > exported `MEMHOUSE_*` > `$MEMHOUSE_HOME/env`**
|
|
55
|
-
(default `~/.memhouse/env`) — the same order the `memhouse` CLI uses. Every query
|
|
56
|
-
runs over ClickHouse HTTP with `final=1` and `join_use_nulls=1` (ReplacingMergeTree
|
|
57
|
-
keeps stale row versions until merges; `final=1` collapses to latest-wins — always
|
|
58
|
-
include it on reads):
|
|
59
|
-
|
|
60
|
-
```bash
|
|
61
|
-
# Config lives at $MEMHOUSE_HOME/env (default ~/.memhouse/env). Exported MEMHOUSE_*
|
|
62
|
-
# vars WIN over the file — snapshot them, source, then put them back. Sourcing alone
|
|
63
|
-
# lets a stale file silently override the house you were pointed at.
|
|
64
|
-
MH_ENV="${MEMHOUSE_HOME:-$HOME/.memhouse}/env"
|
|
65
|
-
_u=${MEMHOUSE_URL-}; _s=${MEMHOUSE_USER-}; _p=${MEMHOUSE_PASSWORD-}; _d=${MEMHOUSE_DB-}
|
|
66
|
-
set -a; [ -f "$MH_ENV" ] && . "$MH_ENV"; set +a
|
|
67
|
-
[ -n "$_u" ] && MEMHOUSE_URL=$_u; [ -n "$_s" ] && MEMHOUSE_USER=$_s
|
|
68
|
-
[ -n "$_p" ] && MEMHOUSE_PASSWORD=$_p; [ -n "$_d" ] && MEMHOUSE_DB=$_d
|
|
69
|
-
# No default URL. localhost:8123 as memhouse_root is a REAL house on many machines,
|
|
70
|
-
# usually the pilot's own — guessing it reads someone else's memory and looks like it
|
|
71
|
-
# worked. If there is no config, say so and stop.
|
|
72
|
-
if [ -z "${MEMHOUSE_URL:-}" ] || [ -z "${MEMHOUSE_USER:-}" ]; then
|
|
73
|
-
# An explicit test, not ${VAR:?msg}: zsh does not expand the message, so under the
|
|
74
|
-
# shell Claude Code actually uses the refusal read "nothing in $MH_ENV" literally.
|
|
75
|
-
echo "no memhouse house configured — nothing in $MH_ENV and no MEMHOUSE_URL/MEMHOUSE_USER set." >&2
|
|
76
|
-
echo "Run: memhouse install" >&2
|
|
77
|
-
exit 1
|
|
78
|
-
fi
|
|
79
|
-
|
|
80
|
-
# --user, not a -K config file. The config-file parser treats `"` and `\` specially, so a
|
|
81
|
-
# password containing either authenticates fine from the CLI and fails from every skill
|
|
82
|
-
# with "password is incorrect" — measured. The argv exposure -K was meant to avoid is not
|
|
83
|
-
# observable here either: curl blanks the --user argument before `ps` can read it (0 hits
|
|
84
|
-
# in 50 samples). What actually leaks a credential is PRINTING it, which the rule above
|
|
85
|
-
# covers.
|
|
86
|
-
curl -sS --fail-with-body --user "$MEMHOUSE_USER:${MEMHOUSE_PASSWORD:-}" \
|
|
87
|
-
--data-binary @- "$MEMHOUSE_URL/?database=${MEMHOUSE_DB:-$MEMHOUSE_USER}&final=1&join_use_nulls=1&readonly=1" <<'SQL'
|
|
88
|
-
<the query>
|
|
89
|
-
FORMAT PrettyCompact
|
|
90
|
-
SQL
|
|
91
|
-
```
|
|
25
|
+
## Method
|
|
26
|
+
|
|
27
|
+
1. **Say what you are counting** before running it — "messages, current parse only, by
|
|
28
|
+
model, this year". Half of wrong analytics is a right query answering a different
|
|
29
|
+
question.
|
|
30
|
+
2. **Apply the epoch filter to anything aggregating.** Copy it from the reference.
|
|
31
|
+
3. **Group by `(session_id, user_id)`** wherever a session is the unit.
|
|
32
|
+
4. **Show the SQL** beside the result. The user can re-run and adjust it; an unexplained
|
|
33
|
+
number is not an answer.
|
|
34
|
+
5. **`FORMAT PrettyCompact`** for humans, `JSONEachRow` when you need to post-process.
|
|
92
35
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
The house's rooms are three plain tables: `messages`, `sessions`, `tool_calls` — resolved
|
|
98
|
-
by the connection's database (`MEMHOUSE_DB`), not by who is asking. Everyone in the house
|
|
99
|
-
writes into the same tables; `user_id` (stamped by the server) says whose row it is and
|
|
100
|
-
`host` says which machine shipped it. Filter with `WHERE user_id = '<name>'` when you want
|
|
101
|
-
one person, or leave it off for the whole house.
|
|
102
|
-
|
|
103
|
-
**Another person's house (shared with you).** A house is a database; a share is a
|
|
104
|
-
read-only GRANT on it. Because the rooms resolve by the connection's database, you read a
|
|
105
|
-
friend's memory by pointing the connection at THEIR house — same credentials, different
|
|
106
|
-
`database`. `SHOW DATABASES` lists what you may read; a name that is not `system` /
|
|
107
|
-
`information_schema` / `default` and not your own `$MEMHOUSE_DB` is a house shared with
|
|
108
|
-
you. When the user names one ("query yigit's house", "on yigit", "from yigit"), replace
|
|
109
|
-
`database=${MEMHOUSE_DB:-$MEMHOUSE_USER}` with `database=<house>` in the connection recipe and leave
|
|
110
|
-
the table names bare — or, to join across houses in one query, qualify tables explicitly
|
|
111
|
-
(`yigit.messages`). Read-only: a refused SELECT means that house was not shared with you.
|
|
112
|
-
|
|
113
|
-
**Keep the SQL heredoc quoted (`<<'SQL'`).** This skill once told the agent to substitute
|
|
114
|
-
a shell variable into the table names, which cannot expand inside a quoted heredoc — so
|
|
115
|
-
the agent unquotes it, and then the SEARCH TERMS expand too. Both instances driving this
|
|
116
|
-
skill did exactly that, unprompted, on the first attempt. Measured consequences, from
|
|
117
|
-
`system.query_log`:
|
|
118
|
-
|
|
119
|
-
- a term containing `$home` became the empty string, so `LIKE '%%'` matched **every row**
|
|
120
|
-
and reported hits with exit 0 — silently wrong, not an error;
|
|
121
|
-
- a term containing a backtick **executed the command inside it**.
|
|
122
|
-
|
|
123
|
-
A user's search term is arbitrary text. With the heredoc quoted, neither can happen.
|
|
124
|
-
|
|
125
|
-
**There is no `sessions_v` object.** The session rollup is a saved query over those same
|
|
126
|
-
rooms — `memhouse sessions-query` prints it for whoever you are connected as, ready to
|
|
127
|
-
paste into a `FROM (...) AS c` position.
|
|
128
|
-
|
|
129
|
-
**The rollup is self-contained.** As of 0.4.5 the printed text carries its own `FINAL` on
|
|
130
|
-
both rooms and a trailing `SETTINGS join_use_nulls = 1`, so it is correct wherever you
|
|
131
|
-
paste it. Both matter: without `FINAL` every message is counted once per undeleted
|
|
132
|
-
ReplacingMergeTree version (2x right after a ship, 3x a few ships later — measured, and it
|
|
133
|
-
grows); without `join_use_nulls` a session with no messages reports `total_msgs = 1`
|
|
134
|
-
rather than 0.
|
|
135
|
-
|
|
136
|
-
**If the `memhouse` binary is not on PATH**, you cannot print the rollup — the skills are
|
|
137
|
-
installable on their own. Query the rooms directly instead: `sessions` for metadata and
|
|
138
|
-
`messages` for counts, each read `FINAL`, joined on `session_id` **and `user_id`** — the
|
|
139
|
-
tables are shared, and joining on `session_id` alone can merge two housemates' rows.
|
|
140
|
-
Prefer the binary when it is there; a rollup you assemble by hand and one printed by a
|
|
141
|
-
DIFFERENT memhouse version are the two ways this goes quietly wrong.
|
|
142
|
-
|
|
143
|
-
## Schema (the house)
|
|
144
|
-
|
|
145
|
-
| Object | Kind | Columns |
|
|
146
|
-
|---|---|---|
|
|
147
|
-
| `sessions` | table, 1 row/session | `session_id, source, host, name, mode, folder, project, git_branch, created_at, last_updated_at, message_count, path, extra JSON, origin, epoch, user_id, ingested_at` |
|
|
148
|
-
| `messages` | table, 1 row/message **per parse** | `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, origin, epoch, user_id, ingested_at` + FTS columns `text_ngram`/`text_word` (lowercased; see /mem:search) |
|
|
149
|
-
| `tool_calls` | table, 1 row/tool call **per parse** | `session_id, seq, idx, source, host, tool_name, args, ts, project, folder, origin, epoch, user_id, ingested_at` |
|
|
150
|
-
| `house_meta` | table, house's own record | `key, value, updated_at, updated_by, host` — `schema_version`, per-member `client_version:<user>` |
|
|
151
|
-
| `house_events` | append-only log | `event_at, kind, id, status, from_version, to_version, actor, host, rows_before, rows_after, detail` — migrations, version changes |
|
|
152
|
-
| 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` |
|
|
153
|
-
|
|
154
|
-
Notes: `source` = editor id (`claude-code`, `codex`, `cursor`, `cursor-agent`,
|
|
155
|
-
`vscode`, `zed`, `opencode`, `gemini-cli`, `windsurf`, `antigravity`, …);
|
|
156
|
-
`seq` = message index within its session (0-based); `tool_calls.idx` = call
|
|
157
|
-
index within the session; `user_id` is server-stamped (`currentUser()`); `origin` is
|
|
158
|
-
`ship` for rows the shipper wrote and anything else for imports; `epoch` is which parse
|
|
159
|
-
of the session a row belongs to — see the filter above;
|
|
160
|
-
`models` in the rollup is an `Array(String)`. Exclude `''` and
|
|
161
|
-
`'<synthetic>'` from model aggregates.
|
|
162
|
-
|
|
163
|
-
## Examples
|
|
164
|
-
|
|
165
|
-
Token spend by model:
|
|
36
|
+
## Shapes worth knowing
|
|
37
|
+
|
|
38
|
+
Cost is **derived**, not stored — `pricing.json` × tokens, per model.
|
|
166
39
|
|
|
167
40
|
```sql
|
|
168
|
-
|
|
169
|
-
|
|
41
|
+
-- spend by model, current parse only
|
|
42
|
+
SELECT model,
|
|
43
|
+
count() AS messages,
|
|
44
|
+
sum(input_tokens) AS in_tok,
|
|
45
|
+
sum(output_tokens) AS out_tok,
|
|
46
|
+
sum(cache_read_tokens) AS cache_read,
|
|
47
|
+
sum(cache_write_tokens) AS cache_write
|
|
170
48
|
FROM messages
|
|
171
|
-
WHERE model
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
49
|
+
WHERE model != ''
|
|
50
|
+
AND (origin != 'ship'
|
|
51
|
+
OR (session_id, user_id, epoch) IN (
|
|
52
|
+
SELECT session_id, user_id, max(epoch) FROM messages
|
|
53
|
+
WHERE origin = 'ship' GROUP BY session_id, user_id))
|
|
54
|
+
GROUP BY model ORDER BY out_tok DESC
|
|
175
55
|
```
|
|
176
56
|
|
|
177
|
-
Which tools do I use most (memhouse exclusive — memory-house has no tool table):
|
|
178
|
-
|
|
179
57
|
```sql
|
|
58
|
+
-- which tools, how often
|
|
180
59
|
SELECT tool_name, count() AS calls, uniqExact(session_id) AS sessions
|
|
181
60
|
FROM tool_calls
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
61
|
+
WHERE origin != 'ship'
|
|
62
|
+
OR (session_id, user_id, epoch) IN (
|
|
63
|
+
SELECT session_id, user_id, max(epoch) FROM messages
|
|
64
|
+
WHERE origin = 'ship' GROUP BY session_id, user_id)
|
|
65
|
+
GROUP BY tool_name ORDER BY calls DESC LIMIT 25
|
|
186
66
|
```
|
|
187
67
|
|
|
188
|
-
|
|
68
|
+
Note `tool_calls` takes its epoch from **`messages`** — both rooms are written by the same
|
|
69
|
+
pass at the same epoch, and a parse that produced messages but no tool calls would
|
|
70
|
+
otherwise resolve to a superseded epoch and serve the wrong parse's calls.
|
|
189
71
|
|
|
190
72
|
```sql
|
|
191
|
-
|
|
73
|
+
-- activity by day, one machine
|
|
74
|
+
SELECT toDate(ts) AS day, uniqExact(session_id) AS sessions, count() AS messages
|
|
192
75
|
FROM messages
|
|
193
|
-
WHERE
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
76
|
+
WHERE host = '<host>'
|
|
77
|
+
AND (origin != 'ship'
|
|
78
|
+
OR (session_id, user_id, epoch) IN (
|
|
79
|
+
SELECT session_id, user_id, max(epoch) FROM messages
|
|
80
|
+
WHERE origin = 'ship' GROUP BY session_id, user_id))
|
|
81
|
+
GROUP BY day ORDER BY day DESC LIMIT 30
|
|
197
82
|
```
|
|
198
83
|
|
|
199
|
-
|
|
84
|
+
For per-session totals, `memhouse sessions-query` prints a self-contained rollup that
|
|
85
|
+
already carries `FINAL` — prefer it to hand-joining `sessions` and `messages`.
|
|
200
86
|
|
|
201
|
-
|
|
202
|
-
SELECT source, count() AS msgs, countIf(is_subagent) AS subagent_msgs
|
|
203
|
-
FROM messages
|
|
204
|
-
WHERE ts > now() - INTERVAL 7 DAY
|
|
205
|
-
GROUP BY source
|
|
206
|
-
ORDER BY msgs DESC
|
|
207
|
-
FORMAT PrettyCompact
|
|
208
|
-
```
|
|
87
|
+
## Caveat the answer, every time it needs it
|
|
209
88
|
|
|
210
|
-
|
|
89
|
+
These are properties of the data, not of your query, and a number presented without them
|
|
90
|
+
misleads:
|
|
211
91
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
92
|
+
- **Several adapters ship no tokens at all** — `kiro`, `vscode`, `zed`, and `cursor` on
|
|
93
|
+
one storage path. Their cost reads `$0`, which means *unknown*. Exclude them or label
|
|
94
|
+
them; never let them dilute an average silently.
|
|
95
|
+
- **Per-message timestamps are interpolated** for most adapters — only session bounds are
|
|
96
|
+
real. Any hour-of-day or "peak time" result inherits that and should say so.
|
|
97
|
+
- **`text` truncates at 50,000 chars**, `args` at 20,000, so length statistics have a
|
|
98
|
+
ceiling.
|
|
99
|
+
- **Tool results are not stored**, so nothing here can measure what a command returned.
|
|
219
100
|
|
|
220
|
-
##
|
|
101
|
+
## Someone else's house
|
|
221
102
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
dashboard server from pricing.js — token sums here are the raw material, not
|
|
226
|
-
dollars.
|
|
103
|
+
`SHOW DATABASES` lists what your credential may read; anything that is not `system`,
|
|
104
|
+
`information_schema`, `default` or your own is a share. Point `database=` at it, leave
|
|
105
|
+
table names bare, and say whose house the number came from.
|
package/memhouse/flags.js
CHANGED
|
@@ -15,6 +15,7 @@ const GLOBAL_FLAGS = ['json', 'yes', 'help', 'version'];
|
|
|
15
15
|
const COMMAND_FLAGS = {
|
|
16
16
|
null: [], help: [], version: [],
|
|
17
17
|
whoami: ['admin'],
|
|
18
|
+
share: ['only', 'revoke', 'list'],
|
|
18
19
|
discover: [], stats: [], stop: [], doctor: [], 'sessions-query': [],
|
|
19
20
|
status: [], start: [],
|
|
20
21
|
onboard: ['url', 'user', 'password', 'db', 'port', 'no-ship', 'target', 'house-port', 'tag', 'local'],
|
package/memhouse/house/house.js
CHANGED
|
@@ -288,6 +288,97 @@ function sessionsRollup({ sessions, messages }) {
|
|
|
288
288
|
)`;
|
|
289
289
|
}
|
|
290
290
|
|
|
291
|
+
// The precomputed rollup's table name. One place, because the migration, the runtime
|
|
292
|
+
// probe and the read layer must all agree on it.
|
|
293
|
+
const SESSION_STATS = 'session_stats';
|
|
294
|
+
// The per-(session, model) and per-(session, tool) rollups — what lets the cost, model
|
|
295
|
+
// and tool queries stop scanning the messages/tool_calls rooms entirely. Same refresh
|
|
296
|
+
// mechanism as SESSION_STATS; same fallback rule: absent tables mean the legacy SQL.
|
|
297
|
+
const MODEL_STATS = 'session_model_stats';
|
|
298
|
+
const TOOL_STATS = 'session_tool_stats';
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* The DDL that materializes the rollup, as [target, view].
|
|
302
|
+
*
|
|
303
|
+
* A REFRESHABLE materialized view, not an ordinary one, and the distinction is not a
|
|
304
|
+
* preference — an ordinary MV is an insert trigger, and all three of this schema's
|
|
305
|
+
* defining properties break it:
|
|
306
|
+
*
|
|
307
|
+
* DUPLICATES. The rooms are ReplacingMergeTree; the engine collapses re-inserted rows
|
|
308
|
+
* at MERGE time, long after a trigger has already added them to a sum(). On a real
|
|
309
|
+
* house that is 1,226,770 stored rows for 685,649 real ones — token totals ~1.8x high,
|
|
310
|
+
* with nothing to indicate it.
|
|
311
|
+
*
|
|
312
|
+
* EPOCHS. A trigger firing at insert cannot know a later parse will supersede the rows
|
|
313
|
+
* it is aggregating, so superseded parses would stay in the totals forever.
|
|
314
|
+
*
|
|
315
|
+
* THE JOIN. A trigger fires on one source table; the rollup joins sessions to messages.
|
|
316
|
+
*
|
|
317
|
+
* A refreshable view has none of those problems because it is not a trigger: it runs
|
|
318
|
+
* THIS ALREADY-CORRECT QUERY on a schedule and atomically swaps the target. FINAL, the
|
|
319
|
+
* epoch filter and the join all work exactly as they do in the subquery, because it IS
|
|
320
|
+
* the subquery. The price is staleness bounded by the interval — and the shipper's own
|
|
321
|
+
* default cadence is 300s, so a 5-minute refresh adds no lag to data arriving every 5.
|
|
322
|
+
*
|
|
323
|
+
* The target's columns are inferred with EMPTY AS, so the table and the rollup cannot
|
|
324
|
+
* drift: change sessionsRollup and the next migration rebuilds the table to match.
|
|
325
|
+
*/
|
|
326
|
+
function sessionStatsStatements(rooms, { intervalMinutes = 5 } = {}) {
|
|
327
|
+
const rollup = sessionsRollup(rooms);
|
|
328
|
+
// sessionsRollup wraps itself in parentheses for the FROM position; strip them.
|
|
329
|
+
const body = rollup.replace(/^\s*\(/, '').replace(/\)\s*$/, '');
|
|
330
|
+
return [
|
|
331
|
+
`CREATE TABLE IF NOT EXISTS ${SESSION_STATS} ENGINE = MergeTree `
|
|
332
|
+
+ `ORDER BY (session_id, user_id) EMPTY AS ${body}`,
|
|
333
|
+
`CREATE MATERIALIZED VIEW IF NOT EXISTS ${SESSION_STATS}_mv `
|
|
334
|
+
+ `REFRESH EVERY ${intervalMinutes} MINUTE TO ${SESSION_STATS} AS ${body}`,
|
|
335
|
+
];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The two fine-grained rollups, same shape as sessionStatsStatements.
|
|
340
|
+
*
|
|
341
|
+
* MODEL_STATS one row per (session, user, source, folder, model), token sums included.
|
|
342
|
+
* Every model/cost aggregate the dashboard runs is a sum or argMax over these rows —
|
|
343
|
+
* a few thousand of them — instead of a scan of the full messages room. The ORPHAN
|
|
344
|
+
* (any-token) predicates in the read layer survive unchanged: a message row with zero
|
|
345
|
+
* tokens contributes zero to every sum, so filtering it out before summing and summing
|
|
346
|
+
* over everything produce the same number.
|
|
347
|
+
*
|
|
348
|
+
* TOOL_STATS one row per (session, user, source, folder, tool_name) with the call
|
|
349
|
+
* count. NOTE the epoch source: currentParse(tool_calls, 'messages') — the tool room
|
|
350
|
+
* takes its epoch from MESSAGES, because a parse that keeps its messages but emits no
|
|
351
|
+
* tool calls writes nothing here at the new epoch, and this room's own max(epoch)
|
|
352
|
+
* would resurrect the superseded parse's calls.
|
|
353
|
+
*/
|
|
354
|
+
function sessionModelStatsStatements(rooms, { intervalMinutes = 5 } = {}) {
|
|
355
|
+
const body = `SELECT session_id, user_id, any(source) AS source, any(folder) AS folder, model,
|
|
356
|
+
count() AS msgs,
|
|
357
|
+
sum(input_tokens) AS input_tokens, sum(output_tokens) AS output_tokens,
|
|
358
|
+
sum(cache_read_tokens) AS cache_read_tokens, sum(cache_write_tokens) AS cache_write_tokens
|
|
359
|
+
FROM ${rooms.messages} AS m
|
|
360
|
+
GROUP BY session_id, user_id, model`;
|
|
361
|
+
return [
|
|
362
|
+
`CREATE TABLE IF NOT EXISTS ${MODEL_STATS} ENGINE = MergeTree `
|
|
363
|
+
+ `ORDER BY (session_id, user_id, model) EMPTY AS ${body}`,
|
|
364
|
+
`CREATE MATERIALIZED VIEW IF NOT EXISTS ${MODEL_STATS}_mv `
|
|
365
|
+
+ `REFRESH EVERY ${intervalMinutes} MINUTE TO ${MODEL_STATS} AS ${body}`,
|
|
366
|
+
];
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function sessionToolStatsStatements(rooms, { intervalMinutes = 5 } = {}) {
|
|
370
|
+
const body = `SELECT session_id, user_id, any(source) AS source, any(folder) AS folder, tool_name,
|
|
371
|
+
count() AS calls
|
|
372
|
+
FROM ${rooms.tool_calls} AS tc
|
|
373
|
+
GROUP BY session_id, user_id, tool_name`;
|
|
374
|
+
return [
|
|
375
|
+
`CREATE TABLE IF NOT EXISTS ${TOOL_STATS} ENGINE = MergeTree `
|
|
376
|
+
+ `ORDER BY (session_id, user_id, tool_name) EMPTY AS ${body}`,
|
|
377
|
+
`CREATE MATERIALIZED VIEW IF NOT EXISTS ${TOOL_STATS}_mv `
|
|
378
|
+
+ `REFRESH EVERY ${intervalMinutes} MINUTE TO ${TOOL_STATS} AS ${body}`,
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
291
382
|
/**
|
|
292
383
|
* The settings every read needs. `final` collapses ReplacingMergeTree versions;
|
|
293
384
|
* `join_use_nulls` is what the rollup's coalesce depends on.
|
|
@@ -333,8 +424,65 @@ async function currentUser(client) {
|
|
|
333
424
|
}
|
|
334
425
|
|
|
335
426
|
/** Resolve the rooms for whoever this client is connected as. */
|
|
427
|
+
/**
|
|
428
|
+
* The precomputed rollup, when the house has one.
|
|
429
|
+
*
|
|
430
|
+
* `sessions_v` is a SAVED QUERY that rebuilds the whole rollup — sessions FINAL joined
|
|
431
|
+
* to the current parse of messages, grouped over the entire house — on every call. A
|
|
432
|
+
* house that has run the session_stats migration carries the same rows as a flat table,
|
|
433
|
+
* refreshed on a timer, and the read layer can name that instead. Measured on a 714k
|
|
434
|
+
* message house: 1,413,624 rows read per query becomes 1,230, and p50 query time 68ms
|
|
435
|
+
* becomes 4ms.
|
|
436
|
+
*
|
|
437
|
+
* Resolved at RUNTIME rather than by version, and that is the point: one binary has to
|
|
438
|
+
* serve a migrated house, a house whose migration has not run, and a friend's house
|
|
439
|
+
* shared read-only that was never migrated at all. Asking the server what exists is the
|
|
440
|
+
* only answer that is correct in all three.
|
|
441
|
+
*/
|
|
442
|
+
async function statTables(client) {
|
|
443
|
+
try {
|
|
444
|
+
const rs = await client.query({
|
|
445
|
+
query: `SELECT name FROM system.tables
|
|
446
|
+
WHERE database = currentDatabase()
|
|
447
|
+
AND name IN ('${SESSION_STATS}', '${MODEL_STATS}', '${TOOL_STATS}')`,
|
|
448
|
+
format: 'JSONEachRow',
|
|
449
|
+
});
|
|
450
|
+
const present = (await rs.json()).map((r) => r.name);
|
|
451
|
+
// Present is not enough: a refreshable view populates its target on its FIRST
|
|
452
|
+
// refresh, which is scheduled, not immediate. Between `install` and that refresh the
|
|
453
|
+
// table exists and is empty — and a dashboard reading it would show a house with no
|
|
454
|
+
// sessions at all (caught by house-test the first time). An empty stat table reads
|
|
455
|
+
// as absent, so the inline rollup answers until the table can.
|
|
456
|
+
const usable = new Set();
|
|
457
|
+
for (const name of present) {
|
|
458
|
+
try {
|
|
459
|
+
const c = await client.query({ query: `SELECT count() AS n FROM ${name}`, format: 'JSONEachRow' });
|
|
460
|
+
const rows = await c.json();
|
|
461
|
+
if (Number(rows[0] && rows[0].n) > 0) usable.add(name);
|
|
462
|
+
} catch { /* unreadable: treat as absent */ }
|
|
463
|
+
}
|
|
464
|
+
return usable;
|
|
465
|
+
} catch {
|
|
466
|
+
// No grant on system.tables is not an error — it means "assume none", and the
|
|
467
|
+
// subquery path is always correct.
|
|
468
|
+
return new Set();
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function hasSessionStats(client) {
|
|
473
|
+
return (await statTables(client)).has(SESSION_STATS);
|
|
474
|
+
}
|
|
475
|
+
|
|
336
476
|
async function resolveRooms(client) {
|
|
337
|
-
|
|
477
|
+
const rooms = roomNames(await currentUser(client));
|
|
478
|
+
const have = await statTables(client);
|
|
479
|
+
if (have.has(SESSION_STATS)) rooms.sessions_v = SESSION_STATS;
|
|
480
|
+
// Null when absent: the read layer branches to the legacy scan-the-room SQL. Never a
|
|
481
|
+
// token substitution, because the legacy SQL has a different shape, not just a
|
|
482
|
+
// different table name.
|
|
483
|
+
rooms.model_stats = have.has(MODEL_STATS) ? MODEL_STATS : null;
|
|
484
|
+
rooms.tool_stats = have.has(TOOL_STATS) ? TOOL_STATS : null;
|
|
485
|
+
return rooms;
|
|
338
486
|
}
|
|
339
487
|
|
|
340
488
|
module.exports = {
|
|
@@ -343,4 +491,6 @@ module.exports = {
|
|
|
343
491
|
READ_SETTINGS, MEMBER_PIN,
|
|
344
492
|
installCommand, assertUsableName,
|
|
345
493
|
sessionsRollup, currentParse, createStatement, roomNames, currentUser, resolveRooms,
|
|
494
|
+
SESSION_STATS, MODEL_STATS, TOOL_STATS, hasSessionStats, statTables,
|
|
495
|
+
sessionStatsStatements, sessionModelStatsStatements, sessionToolStatsStatements,
|
|
346
496
|
};
|