memhouse 0.18.12 → 0.20.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +22 -151
- package/CHANGELOG.md +263 -29
- package/LICENSE +202 -22
- package/LICENSES/agentlytics-MIT.txt +21 -0
- package/LICENSES/memhouse-MIT-until-0.18.11.txt +22 -0
- package/LICENSES/models.dev-MIT.txt +22 -0
- package/NOTICE +28 -0
- package/README.md +42 -20
- package/SECURITY.md +21 -8
- package/bin/memhouse.js +2223 -311
- package/docs/README.md +11 -9
- package/docs/guides/admin-password.md +37 -10
- package/docs/guides/configuration.md +13 -6
- package/docs/guides/daemons.md +64 -3
- package/docs/guides/import-claude-ai.md +204 -0
- package/docs/guides/install.md +11 -6
- package/docs/guides/invite.md +21 -7
- package/docs/guides/operate.md +114 -7
- package/docs/guides/search-and-resume.md +72 -3
- package/docs/guides/share.md +91 -52
- package/docs/guides/storage.md +44 -4
- package/docs/guides/troubleshooting.md +121 -14
- package/docs/guides/uninstall.md +4 -3
- package/docs/guides/update.md +16 -0
- package/docs/guides/upgrade-to-0.20.md +188 -0
- package/docs/releases/README.md +2 -1
- package/docs/releases/v0.18.10.md +5 -5
- package/docs/releases/v0.18.11.md +3 -3
- package/docs/releases/v0.20.0.md +627 -0
- package/docs/releases/v0.20.1.md +114 -0
- package/docs/tutorials/01-first-house.md +10 -7
- package/docs/tutorials/02-join-a-house.md +24 -15
- package/docs/tutorials/03-share-your-memory.md +40 -31
- package/editors/antigravity.js +1 -80
- package/editors/base.js +463 -9
- package/editors/bridge.js +119 -0
- package/editors/claude.js +273 -105
- package/editors/codex.js +278 -52
- package/editors/copilot.js +1 -70
- package/editors/cursor.js +1 -75
- package/editors/derive-fingerprints.json +18 -0
- package/editors/harness-paths.js +69 -0
- package/editors/index.js +1 -20
- package/editors/normalise/agy.js +910 -0
- package/editors/normalise/copilot-cli.js +321 -0
- package/editors/normalise/cursor-agent.js +852 -0
- package/editors/normalise/cursor.js +474 -0
- package/editors/normalise/devin.js +575 -0
- package/editors/normalise/gemini-cli.js +602 -0
- package/editors/normalise/goose.js +673 -0
- package/editors/normalise/opencode.js +633 -0
- package/editors/normalise/vscode.js +549 -0
- package/editors/normalise/zed.js +473 -0
- package/editors/opencode-v4.js +119 -0
- package/editors/scope.js +14 -3
- package/editors/sessions-log.js +349 -0
- package/editors/sqlite.js +28 -1
- package/editors/vscode.js +1 -72
- package/editors/windsurf.js +1 -143
- package/examples/01-local-one-machine/README.md +24 -19
- package/examples/01-local-one-machine/run.sh +1 -1
- package/examples/02-existing-clickhouse/README.md +30 -22
- package/examples/02-existing-clickhouse/run.sh +5 -4
- package/examples/03-team-house/README.md +44 -37
- package/examples/03-team-house/run.sh +35 -18
- package/examples/04-self-hosted-team/README.md +29 -14
- package/examples/04-self-hosted-team/compose.yml +5 -2
- package/examples/04-self-hosted-team/smoke.sh +26 -12
- package/examples/README.md +8 -4
- package/examples/fixtures/README.md +4 -1
- package/examples/fixtures/make-sessions.js +8 -5
- package/memhouse/admin-secret.js +89 -24
- package/memhouse/argv-secrets.js +64 -0
- package/memhouse/channel.js +49 -9
- package/memhouse/codex-plugin.js +194 -0
- package/memhouse/delivery/AGENT-INSTALL-PROMPT.md +2 -1
- package/memhouse/delivery/AGENT-INSTALL.md +22 -11
- package/memhouse/delivery/INVITATION.md.tpl +44 -45
- package/memhouse/delivery/PROMPT.md +53 -5
- package/memhouse/delivery/UPGRADE.md.tpl +1 -1
- package/memhouse/delivery/codex/AGENTS-BLOCK.md +11 -0
- package/memhouse/delivery/codex/SKILL.md +50 -0
- package/memhouse/delivery/kernel-install.md +17 -9
- package/memhouse/delivery/plugin/.claude-plugin/plugin.json +2 -2
- package/memhouse/delivery/plugin/reference/HOUSE.md +168 -41
- package/memhouse/delivery/plugin/skills/access/SKILL.md +13 -5
- package/memhouse/delivery/plugin/skills/admin/SKILL.md +14 -6
- package/memhouse/delivery/plugin/skills/house/SKILL.md +25 -8
- package/memhouse/delivery/plugin/skills/recall/SKILL.md +66 -8
- package/memhouse/delivery/plugin/skills/sql/SKILL.md +66 -7
- package/memhouse/deploy.js +1 -1
- package/memhouse/flags.js +35 -18
- package/memhouse/help.js +37 -0
- package/memhouse/host.js +82 -2
- package/memhouse/house/HOUSE.md +2 -2
- package/memhouse/house/convert-profile.js +638 -0
- package/memhouse/house/convert-v3.js +742 -0
- package/memhouse/house/doctor-v3.js +324 -0
- package/memhouse/house/host-alias.js +85 -0
- package/memhouse/house/house.js +3 -3
- package/memhouse/house/migrate.js +2 -2
- package/memhouse/house/profile-dedupe.js +183 -0
- package/memhouse/house/profile-edges.js +554 -0
- package/memhouse/house/profile-house.js +430 -0
- package/memhouse/house/profile-rollup.js +190 -0
- package/memhouse/house/q-cli.js +863 -0
- package/memhouse/house/queries-v3.js +1013 -0
- package/memhouse/house/reconcile-v3.js +192 -0
- package/memhouse/house/records-to-profile.js +174 -0
- package/memhouse/house/records-to-rows.js +169 -0
- package/memhouse/house/relocate.js +2 -1
- package/memhouse/house/schema-choice.js +121 -0
- package/memhouse/house/schema-profile.js +290 -0
- package/memhouse/house/schema-profile.sql.tpl +398 -0
- package/memhouse/house/schema-v3.js +267 -0
- package/memhouse/house/schema-v3.sql.tpl +213 -0
- package/memhouse/import/claude-ai.js +420 -0
- package/memhouse/import/import.js +372 -0
- package/memhouse/import/pyjson.js +206 -0
- package/memhouse/import/records.js +64 -0
- package/memhouse/import/writer-profile.js +192 -0
- package/memhouse/import/writer-v3.js +240 -0
- package/memhouse/import/zip.js +112 -0
- package/memhouse/invitation.js +102 -0
- package/memhouse/invitefile.js +5 -2
- package/memhouse/plugin-copies.js +176 -0
- package/memhouse/provision.js +7 -2
- package/memhouse/release-hint.js +24 -0
- package/memhouse/resume.js +4 -4
- package/memhouse/server/api-v3.js +632 -0
- package/memhouse/server/dashboard-auth.js +181 -0
- package/memhouse/server/dashboard-port.js +91 -0
- package/memhouse/server/queries.js +30 -0
- package/memhouse/server/server.js +118 -20
- package/memhouse/service.js +1 -1
- package/memhouse/share.js +687 -1
- package/memhouse/shipper/adapter-capture.js +756 -0
- package/memhouse/shipper/capture.js +2571 -0
- package/memhouse/shipper/dedupe-profile.js +237 -0
- package/memhouse/shipper/dedupe-v3.js +1027 -0
- package/memhouse/shipper/forks.js +233 -0
- package/memhouse/shipper/log-adapter.js +219 -0
- package/memhouse/shipper/maintenance.js +49 -0
- package/memhouse/shipper/normalise-run.js +148 -0
- package/memhouse/shipper/passlog.js +68 -5
- package/memhouse/shipper/profile-capture.js +903 -0
- package/memhouse/shipper/ship.js +91 -8
- package/memhouse/shipper/snapshots.js +193 -0
- package/memhouse/shipper/source-roots.js +111 -0
- package/memhouse/version-notice.js +66 -0
- package/package.json +14 -7
- package/public/THIRD_PARTY_NOTICES.txt +2 -2
- package/public/assets/index-CcNnhfM3.js +78 -0
- package/public/index.html +1 -1
- package/ui/package-lock.json +56 -37
- package/ui/package.json +1 -0
- package/ui/src/App.jsx +29 -14
- package/ui/src/components/ChatSidebar.jsx +69 -12
- package/ui/src/lib/api.js +76 -0
- package/ui/src/lib/dashboard-token.js +41 -0
- package/ui/src/main.jsx +22 -5
- package/ui/src/pages/Dashboard.jsx +22 -36
- package/ui/third-party-notices.js +119 -0
- package/ui/vite.config.js +5 -1
- package/docs/releases/v0.18.12.md +0 -59
- package/memhouse/COMPETITION.md +0 -221
- package/memhouse/DESIGN.md +0 -222
- package/memhouse/RESUME-AND-UPDATE.md +0 -182
- package/public/assets/index-CnWzrjF3.js +0 -78
|
@@ -0,0 +1,1013 @@
|
|
|
1
|
+
// The named queries of memhouse's read layer, over harness-sessions-sql's profile tables (step B4;
|
|
2
|
+
// docs/design/source-identity.md §6 for the rules, harness-sessions-sql §3–4 for the tables).
|
|
3
|
+
//
|
|
4
|
+
// Four tables, harness-agnostic, one set per member, each keyed by the session key first:
|
|
5
|
+
//
|
|
6
|
+
// <m>_sessions one row per session: the spec columns and memhouse's totals (the rollup)
|
|
7
|
+
// <m>_session_parts one row per message part, keyed (session, message_id, part_id); read in
|
|
8
|
+
// order_key order
|
|
9
|
+
// <m>_session_usage one row per usage record, keyed (session, usage_id); a reader applies the
|
|
10
|
+
// flags and counts a per_record response once (harness-sessions-sql §4.4)
|
|
11
|
+
// <m>_calls one row per tool call (memhouse's convenience table), keyed (session, call_id)
|
|
12
|
+
//
|
|
13
|
+
// All four are ReplacingMergeTree(rev, is_deleted). Two rules hold for every query here,
|
|
14
|
+
// and each builder states how it meets them:
|
|
15
|
+
//
|
|
16
|
+
// 1. EVERY READ FILTERS `is_deleted = 0 AND removed = 0`. FINAL already hides a row whose newest version is
|
|
17
|
+
// a tombstone (measured on 26.7.4.58: FINAL and FINAL + is_deleted = 0 AND removed = 0 return the same
|
|
18
|
+
// 417,087 of 490,427 rows), so under FINAL the predicate is belt and braces; WITHOUT
|
|
19
|
+
// FINAL it is what keeps a tombstone out of a candidate set.
|
|
20
|
+
// 2. FINAL IS PER TABLE, IN THE SQL, NEVER A SESSION SETTING. The 0.18 read layer ran every
|
|
21
|
+
// query with `final=1`, which put FINAL on subqueries that did not want it (the epoch
|
|
22
|
+
// scan) and kept the text indexes off (search). Here each builder says which table it
|
|
23
|
+
// reads FINAL and why; the client that runs them must NOT set `final`.
|
|
24
|
+
//
|
|
25
|
+
// SKIP INDEXES UNDER FINAL. 26.7 enables `use_skip_indexes_if_final` AND
|
|
26
|
+
// `use_skip_indexes_if_final_exact_mode` by default. Exact mode matters: a tombstone is the
|
|
27
|
+
// old KEY at a higher rev, and nothing obliges it to repeat non-key columns. Measured on a
|
|
28
|
+
// key-only call tombstone (tool_name = ''): `calls FINAL WHERE tool_name = 'Bash'` returned
|
|
29
|
+
// 7,404 rows with skip indexes off or exact, and 9,357 with exact mode off — the index
|
|
30
|
+
// skipped the tombstone's granule and the deleted calls came back. So every builder that
|
|
31
|
+
// reads FINAL pins the two settings explicitly instead of trusting a server default:
|
|
32
|
+
// `tools` turns them on (exact), `search` stage 2 turns them off (its key set already prunes).
|
|
33
|
+
//
|
|
34
|
+
// SHAPE. Each builder is pure: it returns `{ query, sql, params, settings, final }` and
|
|
35
|
+
// touches no server, so the unit tests can assert the SQL. The orchestrators at the bottom
|
|
36
|
+
// (`readSession`, `listSessionsPage`, …) take an `exec(spec) -> rows` function, which the
|
|
37
|
+
// dashboard backs with @clickhouse/client and misc/v3-read-check.js with plain HTTP.
|
|
38
|
+
|
|
39
|
+
'use strict';
|
|
40
|
+
|
|
41
|
+
const EXCLUDED_MODELS = ['', '<synthetic>'];
|
|
42
|
+
const FAR_FUTURE_MS = 7258118400000; // 2200-01-01, an upper bound for open windows
|
|
43
|
+
// A session store's display label, as before layout 3.2: the playbook name for a data folder under
|
|
44
|
+
// ~/.claude-playbooks/<name>, 'default' for any other (convert-v3 rootSQL over harness_path).
|
|
45
|
+
const ROOT_LABEL = (col) => require('./convert-v3').rootSQL(col);
|
|
46
|
+
// A session's identity is its store and id (layout 3.2, sessions-model §1): every read that names,
|
|
47
|
+
// joins or filters a session uses the whole key, so two sessions sharing an id on two hosts, in two
|
|
48
|
+
// stores or as two incarnations are never mixed.
|
|
49
|
+
const SKEY = ['harness', 'host', 'harness_path', 'session_id', 'incarnation'];
|
|
50
|
+
const skeyOf = (a = '') => SKEY.map((c) => `${a}${c}`).join(', ');
|
|
51
|
+
const hasStore = (r) => r && r.host != null && r.harness_path != null;
|
|
52
|
+
/** The store a read names: nested under `store`, or flat on the ref itself (a session ref). */
|
|
53
|
+
const storeOf = (o) => (o && o.store != null ? o.store : o);
|
|
54
|
+
/** host / harness_path / incarnation predicates for a ref that names its store ('' otherwise). */
|
|
55
|
+
function storePred(ref, a = '', p = 'r') {
|
|
56
|
+
// Half a store is refused, never read as no store: a session is its store in every read.
|
|
57
|
+
if (ref && (ref.host != null) !== (ref.harness_path != null)) throw new QueryInputError('a session names its store with both host and harness_path');
|
|
58
|
+
if (!hasStore(ref)) return { sql: '', params: {} };
|
|
59
|
+
return {
|
|
60
|
+
sql: ` AND ${a}host = {${p}_host:String} AND ${a}harness_path = {${p}_hp:String} AND ${a}incarnation = {${p}_inc:String}`,
|
|
61
|
+
params: { [`${p}_host`]: String(ref.host), [`${p}_hp`]: String(ref.harness_path), [`${p}_inc`]: String(ref.incarnation || '') },
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
class QueryInputError extends Error {
|
|
66
|
+
constructor(msg) { super(msg); this.name = 'QueryInputError'; this.status = 400; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The member's four conversation tables. `db` qualifies them when the client's default
|
|
70
|
+
* database is not the house (the check script); the dashboard connects with database = house. */
|
|
71
|
+
function v3Rooms(member, db = null) {
|
|
72
|
+
if (!member || !/^[A-Za-z][A-Za-z0-9_]*$/.test(member)) throw new QueryInputError(`'${member}' is not a member name`);
|
|
73
|
+
if (db && !/^[A-Za-z][A-Za-z0-9_]*$/.test(db)) throw new QueryInputError(`'${db}' is not a database name`);
|
|
74
|
+
const q = (t) => `${db ? `${db}.` : ''}${member}_${t}`;
|
|
75
|
+
// meta: where the member's machines record their two host ids (host-alias.js); readers load
|
|
76
|
+
// those into rooms.hostAliases and fold the 0.18 id's rows into the machine's.
|
|
77
|
+
return { member, db, sessions: q('sessions'), parts: q('session_parts'), usage: q('session_usage'), calls: q('calls'), meta: q('meta') };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── small helpers ─────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
function clampInt(v, dflt, min, max) {
|
|
83
|
+
if (v === undefined || v === null || v === '') return dflt;
|
|
84
|
+
const n = Number(v);
|
|
85
|
+
if (!Number.isFinite(n)) return dflt;
|
|
86
|
+
return Math.min(Math.max(Math.trunc(n), min), max);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Opaque keyset cursor: base64url(JSON array). Validated on the way back in. */
|
|
90
|
+
function encodeCursor(parts) { return Buffer.from(JSON.stringify(parts)).toString('base64url'); }
|
|
91
|
+
function decodeCursor(s, shape) {
|
|
92
|
+
if (s === undefined || s === null || s === '') return null;
|
|
93
|
+
let v;
|
|
94
|
+
try { v = JSON.parse(Buffer.from(String(s), 'base64url').toString('utf-8')); } catch { throw new QueryInputError('bad cursor'); }
|
|
95
|
+
if (!Array.isArray(v) || v.length !== shape.length) throw new QueryInputError('bad cursor');
|
|
96
|
+
shape.forEach((t, i) => {
|
|
97
|
+
if (t === 'n' && !Number.isFinite(v[i])) throw new QueryInputError('bad cursor');
|
|
98
|
+
if (t === 's' && typeof v[i] !== 'string') throw new QueryInputError('bad cursor');
|
|
99
|
+
});
|
|
100
|
+
return v;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** LIKE pattern for a literal substring: escape the LIKE metacharacters, then wrap. */
|
|
104
|
+
function likeContains(term) { return `%${String(term).replace(/[\\%_]/g, (c) => `\\${c}`)}%`; }
|
|
105
|
+
|
|
106
|
+
const toDay = (ms) => new Date(ms).toISOString().slice(0, 10);
|
|
107
|
+
|
|
108
|
+
// ── the session filter ────────────────────────────────────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* WHERE fragments over `<m>_sessions` (alias optional). Every key is optional:
|
|
112
|
+
*
|
|
113
|
+
* harness exact (or an array: IN) harnessLike substring (legacy `editor`)
|
|
114
|
+
* folder exact folderLike substring (legacy Sessions page)
|
|
115
|
+
* project, host exact hidden folders to exclude
|
|
116
|
+
* from, to ms; a session is IN the window when it was ACTIVE in it:
|
|
117
|
+
* `ended >= from AND started <= to` (see the report: 0.18 used last-updated)
|
|
118
|
+
* named true -> drop placeholders (no name and no messages), as 0.18 did
|
|
119
|
+
* minMessages messages >= n
|
|
120
|
+
* session { harness, session_id } one session
|
|
121
|
+
* keys [{ harness, session_id }] an explicit set
|
|
122
|
+
* latest n: only the n most recently ended sessions matching the rest
|
|
123
|
+
*
|
|
124
|
+
* Returns { sql: ' AND …' (always starts with is_deleted = 0 AND removed = 0), params }.
|
|
125
|
+
*/
|
|
126
|
+
function sessionWhere(f = {}, rooms, alias = '') {
|
|
127
|
+
const a = alias ? `${alias}.` : '';
|
|
128
|
+
const parts = [`${a}is_deleted = 0`, `${a}removed = 0`];
|
|
129
|
+
const params = {};
|
|
130
|
+
if (Array.isArray(f.harness) && f.harness.length) { parts.push(`${a}harness IN {f_harnesses:Array(String)}`); params.f_harnesses = f.harness.map(String); }
|
|
131
|
+
else if (f.harness) { parts.push(`${a}harness = {f_harness:String}`); params.f_harness = String(f.harness); }
|
|
132
|
+
if (f.harnessLike) { parts.push(`${a}harness LIKE {f_harness_like:String}`); params.f_harness_like = likeContains(f.harnessLike); }
|
|
133
|
+
if (f.folder) { parts.push(`${a}folder = {f_folder:String}`); params.f_folder = String(f.folder); }
|
|
134
|
+
if (f.folderLike) { parts.push(`${a}folder LIKE {f_folder_like:String}`); params.f_folder_like = likeContains(f.folderLike); }
|
|
135
|
+
if (f.project) { parts.push(`${a}project = {f_project:String}`); params.f_project = String(f.project); }
|
|
136
|
+
// A machine's rows include those under its 0.18 id when the member recorded the pair (host-alias.js).
|
|
137
|
+
if (f.host) { parts.push(`${a}host IN {f_hosts:Array(String)}`); params.f_hosts = require('./host-alias').idsOf(String(f.host), rooms && rooms.hostAliases); }
|
|
138
|
+
if (Array.isArray(f.hidden) && f.hidden.length) { parts.push(`${a}folder NOT IN {f_hidden:Array(String)}`); params.f_hidden = f.hidden.map(String); }
|
|
139
|
+
if (f.from != null) { parts.push(`${a}ended >= fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`); params.f_from = Number(f.from); }
|
|
140
|
+
if (f.to != null) { parts.push(`${a}started <= fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`); params.f_to = Number(f.to); }
|
|
141
|
+
if (f.named) parts.push(`(${a}title != '' OR ${a}messages > 0)`);
|
|
142
|
+
if (f.minMessages != null) { parts.push(`${a}messages >= {f_min_messages:UInt32}`); params.f_min_messages = clampInt(f.minMessages, 0, 0, 4294967295); }
|
|
143
|
+
if (f.session) {
|
|
144
|
+
parts.push(`${a}harness = {f_s_harness:String} AND ${a}session_id = {f_s_id:String}`);
|
|
145
|
+
params.f_s_harness = String(f.session.harness); params.f_s_id = String(f.session.session_id);
|
|
146
|
+
const sp = storePred(f.session, a, 'f_s');
|
|
147
|
+
if (sp.sql) { parts.push(sp.sql.replace(/^ AND /, '')); Object.assign(params, sp.params); }
|
|
148
|
+
}
|
|
149
|
+
if (Array.isArray(f.keys)) {
|
|
150
|
+
// arrayZip, not two INs: the identity is the PAIR, and matching the columns separately
|
|
151
|
+
// would admit (codex, X) because (claude-code, X) was asked for.
|
|
152
|
+
parts.push(`(${a}harness, ${a}session_id) IN arrayZip({f_k_harness:Array(String)}, {f_k_id:Array(String)})`);
|
|
153
|
+
params.f_k_harness = f.keys.map((k) => String(k.harness)); params.f_k_id = f.keys.map((k) => String(k.session_id));
|
|
154
|
+
}
|
|
155
|
+
if (f.latest) {
|
|
156
|
+
if (!rooms) throw new QueryInputError('latest needs the rooms');
|
|
157
|
+
// Its own alias, so no alias of the outer query can capture a column name in here.
|
|
158
|
+
const inner = sessionWhere({ ...f, latest: undefined }, rooms, 'ls');
|
|
159
|
+
parts.push(`(${skeyOf(a)}) IN (SELECT ${skeyOf('ls.')} FROM ${rooms.sessions} AS ls FINAL WHERE ${inner.sql}`
|
|
160
|
+
+ ` ORDER BY ls.ended DESC, ls.session_id DESC, ls.harness DESC LIMIT {f_latest:UInt32})`);
|
|
161
|
+
Object.assign(params, inner.params); params.f_latest = clampInt(f.latest, 500, 1, 100000);
|
|
162
|
+
}
|
|
163
|
+
return { sql: parts.join(' AND '), params };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── listSessions ──────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
const SESSION_COLUMNS = `
|
|
169
|
+
harness, session_id, incarnation, title AS name, mode, folder, project, host, host_label, harness_path, ${ROOT_LABEL('harness_path')} AS root, git_branch, harness_version, origin,
|
|
170
|
+
toUnixTimestamp64Milli(created_at) AS created_ms, toUnixTimestamp64Milli(updated_at) AS updated_ms,
|
|
171
|
+
toUnixTimestamp64Milli(started) AS started_ms, toUnixTimestamp64Milli(ended) AS ended_ms,
|
|
172
|
+
messages, user_messages, assistant_messages, agent_messages, tool_calls,
|
|
173
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens,
|
|
174
|
+
user_chars, assistant_chars, first_prompt, models, dominant_model,
|
|
175
|
+
toString(rev) AS rev`;
|
|
176
|
+
|
|
177
|
+
// Each session priced from its OWN usage (§6: no P1–P5 chunks). sumMap folds the per-hour
|
|
178
|
+
// rows into one entry per model, so a 200-hour session sends a handful of numbers, not 200.
|
|
179
|
+
const USAGE_BY_MODEL = `
|
|
180
|
+
arrayReduce('sumMap', [usage.model], [arrayMap(x -> toUInt64(x), usage.messages)], [usage.input], [usage.output],
|
|
181
|
+
[usage.cache_read], [usage.cache_write], [usage.reasoning]) AS usage_by_model`;
|
|
182
|
+
|
|
183
|
+
const SORTS = {
|
|
184
|
+
ended: { col: 'ended', cur: (v) => `fromUnixTimestamp64Milli(${v}, 'UTC')`, type: 'Int64', of: (r) => Number(r.ended_ms) },
|
|
185
|
+
started: { col: 'started', cur: (v) => `fromUnixTimestamp64Milli(${v}, 'UTC')`, type: 'Int64', of: (r) => Number(r.started_ms) },
|
|
186
|
+
messages: { col: 'messages', cur: (v) => v, type: 'UInt32', of: (r) => Number(r.messages) },
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* listSessions — `sessions FINAL` (≤ one row per session; the table is small, so a top-N
|
|
191
|
+
* over it is a full read of a few thousand rows, which is the point of the table).
|
|
192
|
+
*
|
|
193
|
+
* Keyset pagination on (sort, session_id, harness), descending by default: the cursor is the
|
|
194
|
+
* last row's triple, and the next page is everything strictly after it. Total order, so no
|
|
195
|
+
* page repeats or skips a row (the 0.18 ORDER BY ts, LIMIT/OFFSET did, on ties).
|
|
196
|
+
*
|
|
197
|
+
* opts: { filters, sort: 'ended'|'started'|'messages', dir: 'desc'|'asc', limit, cursor, withUsage }
|
|
198
|
+
* The caller asks for limit+1 rows (done here) so `finish` can say whether a next page exists.
|
|
199
|
+
*/
|
|
200
|
+
function listSessions(rooms, opts = {}) {
|
|
201
|
+
const sort = SORTS[opts.sort || 'ended'];
|
|
202
|
+
if (!sort) throw new QueryInputError(`sort must be one of ${Object.keys(SORTS).join(', ')}`);
|
|
203
|
+
const dir = opts.dir === 'asc' ? 'ASC' : 'DESC';
|
|
204
|
+
const limit = clampInt(opts.limit, 50, 1, 5000);
|
|
205
|
+
const w = sessionWhere(opts.filters, rooms);
|
|
206
|
+
const params = { ...w.params, limit: limit + 1 };
|
|
207
|
+
let keyset = '';
|
|
208
|
+
const cur = decodeCursor(opts.cursor, ['n', 's', 's']);
|
|
209
|
+
if (cur) {
|
|
210
|
+
keyset = ` AND (${sort.col}, session_id, harness) ${dir === 'DESC' ? '<' : '>'} (${sort.cur(`{c_v:${sort.type}}`)}, {c_s:String}, {c_h:String})`;
|
|
211
|
+
Object.assign(params, { c_v: cur[0], c_s: cur[1], c_h: cur[2] });
|
|
212
|
+
}
|
|
213
|
+
const sql = `SELECT ${SESSION_COLUMNS}${opts.withUsage ? `,${USAGE_BY_MODEL}` : ''}
|
|
214
|
+
FROM ${rooms.sessions} FINAL
|
|
215
|
+
WHERE ${w.sql}${keyset}
|
|
216
|
+
ORDER BY ${sort.col} ${dir}, session_id ${dir}, harness ${dir}
|
|
217
|
+
LIMIT {limit:UInt32}`;
|
|
218
|
+
return {
|
|
219
|
+
query: 'listSessions', final: 'sessions', sql, params, settings: {},
|
|
220
|
+
finish(rows) {
|
|
221
|
+
const more = rows.length > limit;
|
|
222
|
+
const page = more ? rows.slice(0, limit) : rows;
|
|
223
|
+
const last = page[page.length - 1];
|
|
224
|
+
return { rows: page, nextCursor: more && last ? encodeCursor([sort.of(last), last.session_id, last.harness]) : null };
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** The count that goes with a listSessions filter. `sessions FINAL`, one number. */
|
|
230
|
+
function countSessions(rooms, filters = {}) {
|
|
231
|
+
const w = sessionWhere(filters, rooms);
|
|
232
|
+
return {
|
|
233
|
+
query: 'countSessions', final: 'sessions', settings: {}, params: w.params,
|
|
234
|
+
sql: `SELECT count() AS n FROM ${rooms.sessions} FINAL WHERE ${w.sql}`,
|
|
235
|
+
finish: (rows) => Number(rows[0] ? rows[0].n : 0),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── readSession ───────────────────────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
/** Step 1: the header — a point read on the sessions key. `sessions FINAL`. */
|
|
242
|
+
function sessionHeader(rooms, ref) {
|
|
243
|
+
const { harness, session_id } = ref || {};
|
|
244
|
+
if (!harness || !session_id) throw new QueryInputError('harness and session_id are required');
|
|
245
|
+
const sp = storePred(ref);
|
|
246
|
+
return {
|
|
247
|
+
query: 'sessionHeader', final: 'sessions', settings: {},
|
|
248
|
+
params: { h: String(harness), s: String(session_id), ...sp.params },
|
|
249
|
+
sql: `SELECT ${SESSION_COLUMNS}, days,
|
|
250
|
+
agents.agent_id AS agent_ids, agents.type AS agent_types, agents.description AS agent_descriptions,
|
|
251
|
+
agents.workflow AS agent_workflows, agents.messages AS agent_message_counts,
|
|
252
|
+
arrayMap(x -> toUnixTimestamp64Milli(x), agents.first_ts) AS agent_first_ms,
|
|
253
|
+
tools.name AS tool_names, tools.calls AS tool_call_counts, tools.errors AS tool_error_counts
|
|
254
|
+
FROM ${rooms.sessions} FINAL
|
|
255
|
+
WHERE harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0
|
|
256
|
+
ORDER BY ended DESC, host, harness_path, incarnation
|
|
257
|
+
LIMIT 1`,
|
|
258
|
+
finish(rows) {
|
|
259
|
+
const r = rows[0];
|
|
260
|
+
if (!r) return null;
|
|
261
|
+
const agents = (r.agent_ids || []).map((id, i) => ({
|
|
262
|
+
agent_id: id, type: r.agent_types[i], description: r.agent_descriptions[i], workflow: r.agent_workflows[i],
|
|
263
|
+
messages: Number(r.agent_message_counts[i]), first_ms: Number(r.agent_first_ms[i]),
|
|
264
|
+
}));
|
|
265
|
+
const tools = (r.tool_names || []).map((name, i) => ({ name, calls: Number(r.tool_call_counts[i]), errors: Number(r.tool_error_counts[i]) }));
|
|
266
|
+
for (const k of ['agent_ids', 'agent_types', 'agent_descriptions', 'agent_workflows', 'agent_message_counts', 'agent_first_ms', 'tool_names', 'tool_call_counts', 'tool_error_counts']) delete r[k];
|
|
267
|
+
return { ...r, agents, tools };
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// The session's order: the adapter's order_key, then the message and the part (sessions-model §2).
|
|
273
|
+
const PART_ORDER = ['order_key', 'message_id', 'part_index'];
|
|
274
|
+
// What a read of parts returns per row (partsPage, and q-cli's window).
|
|
275
|
+
const PART_SELECT = `agent_id, message_id, part_index AS part, part_id, parent_id, response_id, order_key,
|
|
276
|
+
toUnixTimestamp64Milli(ts) AS ts_ms, role, kind, state, status, if(served_model != '', served_model, requested_model) AS model, effort,
|
|
277
|
+
text, text_len, call_id, tool_name, args, output, output_len, is_error, error_kind, error_message, origin`;
|
|
278
|
+
|
|
279
|
+
/** The days of `days` that can hold rows in [lowerMs, upperMs). Strings 'YYYY-MM-DD'. */
|
|
280
|
+
function daysWithin(days, lowerMs, upperMs) {
|
|
281
|
+
const lo = lowerMs == null ? null : toDay(lowerMs);
|
|
282
|
+
const hi = upperMs == null ? null : toDay(upperMs);
|
|
283
|
+
return (days || []).map(String).filter((d) => (lo === null || d >= lo) && (hi === null || d <= hi));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Step 2: one page of a session's parts. `session_parts FINAL` on the leading key columns (the
|
|
288
|
+
* session), in the session's order (PART_ORDER), which is total, so a keyset cursor can stand on
|
|
289
|
+
* any row. `dir: 'backward'` reads the page BEFORE the cursor (or the tail when there is none):
|
|
290
|
+
* what a sidebar wants.
|
|
291
|
+
*/
|
|
292
|
+
function partsPage(rooms, opts) {
|
|
293
|
+
const { harness, session_id, cursor = null, dir = 'forward', limit = 500, agent_id = null } = opts;
|
|
294
|
+
const lim = clampInt(limit, 500, 1, 5000);
|
|
295
|
+
const back = dir === 'backward';
|
|
296
|
+
const cur = Array.isArray(cursor) ? cursor : decodeCursor(cursor, ['s', 's', 'n']);
|
|
297
|
+
const params = { h: String(harness), s: String(session_id), limit: lim + 1 };
|
|
298
|
+
const sp = storePred(storeOf(opts));
|
|
299
|
+
let where = `harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0`;
|
|
300
|
+
Object.assign(params, sp.params);
|
|
301
|
+
if (agent_id !== null && agent_id !== undefined) { where += ' AND agent_id = {agent:String}'; params.agent = String(agent_id); }
|
|
302
|
+
if (cur) {
|
|
303
|
+
where += ` AND (order_key, message_id, part_index) ${back ? '<' : '>'} ({c_ok:String}, {c_mid:String}, {c_part:UInt32})`;
|
|
304
|
+
Object.assign(params, { c_ok: String(cur[0]), c_mid: String(cur[1]), c_part: Number(cur[2]) });
|
|
305
|
+
}
|
|
306
|
+
const d = back ? 'DESC' : 'ASC';
|
|
307
|
+
return {
|
|
308
|
+
query: 'partsPage', final: 'session_parts', settings: {}, params,
|
|
309
|
+
sql: `SELECT ${PART_SELECT}
|
|
310
|
+
FROM ${rooms.parts} FINAL
|
|
311
|
+
WHERE ${where}
|
|
312
|
+
ORDER BY ${PART_ORDER.map((c) => `${c} ${d}`).join(', ')}
|
|
313
|
+
LIMIT {limit:UInt32}`,
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Step 3: the calls of the messages that START on a page (their part 0 is on it), so consecutive
|
|
319
|
+
* pages partition the session's calls exactly. `calls FINAL` within the session.
|
|
320
|
+
*/
|
|
321
|
+
function callsOf(rooms, opts) {
|
|
322
|
+
const { harness, session_id, message_ids } = opts;
|
|
323
|
+
const params = { h: String(harness), s: String(session_id), mids: (message_ids || []).map(String) };
|
|
324
|
+
const sp = storePred(storeOf(opts));
|
|
325
|
+
Object.assign(params, sp.params);
|
|
326
|
+
return {
|
|
327
|
+
query: 'callsOf', final: 'calls', settings: {}, params,
|
|
328
|
+
sql: `SELECT agent_id, call_id, message_id, toUnixTimestamp64Milli(ts) AS ts_ms, tool_name, args,
|
|
329
|
+
toUnixTimestamp64Milli(result_ts) AS result_ts_ms, is_error, output_len AS result_len, model
|
|
330
|
+
FROM ${rooms.calls} FINAL
|
|
331
|
+
WHERE harness = {h:String} AND session_id = {s:String}${sp.sql} AND message_id IN {mids:Array(String)} AND is_deleted = 0 AND removed = 0
|
|
332
|
+
ORDER BY ts, call_id`,
|
|
333
|
+
empty: params.mids.length === 0,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Step 4: the usage rows of a page: those anchored on its messages, and on its responses. Each row
|
|
339
|
+
* names its `target`, the message it is shown on: its own message, or a response's first message
|
|
340
|
+
* in the session's order (so a response's usage is shown once, on the page that starts it).
|
|
341
|
+
*/
|
|
342
|
+
function usageOf(rooms, opts) {
|
|
343
|
+
const { harness, session_id, message_ids, response_ids } = opts;
|
|
344
|
+
const params = { h: String(harness), s: String(session_id), mids: (message_ids || []).map(String), rids: (response_ids || []).map(String) };
|
|
345
|
+
const sp = storePred(storeOf(opts));
|
|
346
|
+
Object.assign(params, sp.params);
|
|
347
|
+
const session = `harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0`;
|
|
348
|
+
return {
|
|
349
|
+
query: 'usageOf', final: 'session_usage, session_parts', settings: {}, params,
|
|
350
|
+
sql: `SELECT u.usage_id AS usage_id, u.anchor_kind AS anchor_kind, u.anchor_id AS anchor_id, u.input AS input, u.output AS output,
|
|
351
|
+
u.cache_read AS cache_read, u.cache_write AS cache_write, u.reasoning AS reasoning, u.tool_prompt AS tool_prompt,
|
|
352
|
+
u.input_includes_cache AS input_includes_cache, u.output_includes_reasoning AS output_includes_reasoning, u.granularity AS granularity,
|
|
353
|
+
u.model AS model, if(u.anchor_kind = 'response', h.head, u.anchor_id) AS target
|
|
354
|
+
FROM (
|
|
355
|
+
SELECT *, toString(anchor_kind) AS ak, if(ak = 'response', anchor_id, '') AS rkey FROM ${rooms.usage} FINAL
|
|
356
|
+
WHERE ${session} AND ((ak = 'message' AND anchor_id IN {mids:Array(String)}) OR (ak = 'response' AND anchor_id IN {rids:Array(String)}))
|
|
357
|
+
) AS u
|
|
358
|
+
LEFT JOIN (
|
|
359
|
+
SELECT response_id AS hr, argMin(message_id, (order_key, message_id)) AS head FROM ${rooms.parts} FINAL
|
|
360
|
+
WHERE ${session} AND response_id IN {rids:Array(String)}
|
|
361
|
+
GROUP BY hr
|
|
362
|
+
) AS h ON h.hr = u.rkey`,
|
|
363
|
+
empty: params.mids.length === 0,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** A usage row in the house split (sessions-model §2.4; profile-rollup.js applies the same). */
|
|
368
|
+
function houseTokens(u) {
|
|
369
|
+
const n = (x) => (x == null ? 0 : Number(x));
|
|
370
|
+
return {
|
|
371
|
+
input_tokens: Math.max(u.input_includes_cache === true ? n(u.input) - n(u.cache_read) - n(u.cache_write) : n(u.input), 0) + n(u.tool_prompt),
|
|
372
|
+
output_tokens: n(u.output) + (u.output_includes_reasoning === false ? n(u.reasoning) : 0),
|
|
373
|
+
cache_read_tokens: n(u.cache_read), cache_write_tokens: n(u.cache_write), reasoning_tokens: n(u.reasoning),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
const TOKEN_FIELDS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'];
|
|
377
|
+
/**
|
|
378
|
+
* Put the page's usage on its rows: every row gets the token fields (0 by default), and a usage
|
|
379
|
+
* row's tokens go on the first part (part 0) of its target message when that part is on the page. A
|
|
380
|
+
* per_record response counts once, from its record with the greatest n (harness-sessions-sql §4.4).
|
|
381
|
+
*/
|
|
382
|
+
function attachUsage(page, usage) {
|
|
383
|
+
for (const r of page) for (const k of TOKEN_FIELDS) r[k] = 0;
|
|
384
|
+
const nOf = (u) => Number(String(u.usage_id).split('#').pop()) || 0;
|
|
385
|
+
const last = new Map();
|
|
386
|
+
for (const u of usage) if (u.anchor_kind === 'response') last.set(u.anchor_id, Math.max(last.has(u.anchor_id) ? last.get(u.anchor_id) : -1, nOf(u)));
|
|
387
|
+
const head = new Map();
|
|
388
|
+
for (const r of page) if (Number(r.part) === 0 && !head.has(r.message_id)) head.set(r.message_id, r);
|
|
389
|
+
for (const u of usage) {
|
|
390
|
+
if (u.granularity === 'per_record' && u.anchor_kind === 'response' && nOf(u) !== last.get(u.anchor_id)) continue;
|
|
391
|
+
const r = head.get(u.target);
|
|
392
|
+
if (!r) continue;
|
|
393
|
+
const t = houseTokens(u);
|
|
394
|
+
for (const k of TOKEN_FIELDS) r[k] += t[k];
|
|
395
|
+
}
|
|
396
|
+
return page;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* readSession(page) — the header, a page of parts, the calls of the messages that start on the
|
|
401
|
+
* page, and its usage. The page and the header are key reads; calls and usage are read within the
|
|
402
|
+
* session for the page's messages only.
|
|
403
|
+
*
|
|
404
|
+
* Returns null when the session does not exist or is deleted.
|
|
405
|
+
*/
|
|
406
|
+
async function readSession(exec, rooms, { harness, session_id, host = null, harness_path = null, incarnation = null, cursor = null, dir = 'forward', limit = 500, agent_id = null }) {
|
|
407
|
+
const header = await run(exec, sessionHeader(rooms, { harness, session_id, host, harness_path, incarnation }));
|
|
408
|
+
if (!header) return null;
|
|
409
|
+
const back = dir === 'backward';
|
|
410
|
+
const lim = clampInt(limit, 500, 1, 5000);
|
|
411
|
+
const cur = decodeCursor(cursor, ['s', 's', 'n']);
|
|
412
|
+
// The header names the store; parts, calls and usage are read from that store only.
|
|
413
|
+
const store = { host: header.host, harness_path: header.harness_path, incarnation: header.incarnation };
|
|
414
|
+
const raw = await exec(partsPage(rooms, { harness, session_id, cursor: cur, dir, limit: lim, agent_id, store }));
|
|
415
|
+
const more = raw.length > lim;
|
|
416
|
+
const page = more ? raw.slice(0, lim) : raw.slice();
|
|
417
|
+
if (back) page.reverse();
|
|
418
|
+
const starts = [...new Set(page.filter((r) => Number(r.part) === 0).map((r) => r.message_id))];
|
|
419
|
+
const responses = [...new Set(page.map((r) => r.response_id).filter(Boolean))];
|
|
420
|
+
const calls = await run(exec, callsOf(rooms, { harness, session_id, store, message_ids: starts }));
|
|
421
|
+
attachUsage(page, await run(exec, usageOf(rooms, { harness, session_id, store, message_ids: starts, response_ids: responses })));
|
|
422
|
+
const key = (r) => encodeCursor([r.order_key, r.message_id, Number(r.part)]);
|
|
423
|
+
return {
|
|
424
|
+
header,
|
|
425
|
+
turns: page,
|
|
426
|
+
calls,
|
|
427
|
+
// forward: the next page continues after the last row; backward: the previous page ends
|
|
428
|
+
// before the first row. Either way the cursor is a row's full order key.
|
|
429
|
+
nextCursor: !back && more && page.length ? key(page[page.length - 1]) : null,
|
|
430
|
+
prevCursor: back && more && page.length ? key(page[0]) : null,
|
|
431
|
+
};
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── sessionVersion ────────────────────────────────────────────────────────────────
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* The sidebar poll (§6): one point read of `sessions.rev`. `sessions FINAL` on the full
|
|
438
|
+
* sorting key. `rev = Σ files.ver` rises whenever any file of the session changes, so an
|
|
439
|
+
* unchanged rev means an unchanged session — the client refetches turns only when it moves.
|
|
440
|
+
*/
|
|
441
|
+
function sessionVersion(rooms, ref) {
|
|
442
|
+
const { harness, session_id } = ref || {};
|
|
443
|
+
if (!harness || !session_id) throw new QueryInputError('harness and session_id are required');
|
|
444
|
+
const sp = storePred(ref);
|
|
445
|
+
return {
|
|
446
|
+
query: 'sessionVersion', final: 'sessions', settings: {},
|
|
447
|
+
params: { h: String(harness), s: String(session_id), ...sp.params },
|
|
448
|
+
sql: `SELECT toString(rev) AS rev, toUnixTimestamp64Milli(ended) AS ended_ms, messages
|
|
449
|
+
FROM ${rooms.sessions} FINAL
|
|
450
|
+
WHERE harness = {h:String} AND session_id = {s:String}${sp.sql} AND is_deleted = 0 AND removed = 0
|
|
451
|
+
ORDER BY ended DESC, host, harness_path, incarnation
|
|
452
|
+
LIMIT 1`,
|
|
453
|
+
finish: (rows) => (rows[0] ? { rev: rows[0].rev, ended_ms: Number(rows[0].ended_ms), messages: Number(rows[0].messages) } : null),
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* The house-wide token for Live: changes when any session changes, is added or is dropped.
|
|
459
|
+
* `sessions FINAL` — a full read of the small table; stable across merges (a raw count is not).
|
|
460
|
+
*/
|
|
461
|
+
function houseVersion(rooms) {
|
|
462
|
+
return {
|
|
463
|
+
query: 'houseVersion', final: 'sessions', settings: {}, params: {},
|
|
464
|
+
sql: `SELECT count() AS n, toString(sum(rev)) AS rev_sum, toUnixTimestamp64Milli(max(ended)) AS last_ms
|
|
465
|
+
FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0`,
|
|
466
|
+
finish: (rows) => {
|
|
467
|
+
const r = rows[0] || { n: 0, rev_sum: '0', last_ms: null };
|
|
468
|
+
return { token: `${r.n}:${r.rev_sum}`, sessions: Number(r.n), last_ms: r.last_ms == null ? null : Number(r.last_ms) };
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// ── search ────────────────────────────────────────────────────────────────────────
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Split a search string into terms: whitespace-separated, "quoted phrases" kept whole,
|
|
477
|
+
* lowercased (the indexed columns are lower(text)), deduplicated, at most 8.
|
|
478
|
+
*/
|
|
479
|
+
function searchTerms(q) {
|
|
480
|
+
const out = [];
|
|
481
|
+
const re = /"([^"]+)"|(\S+)/g;
|
|
482
|
+
let m;
|
|
483
|
+
while ((m = re.exec(String(q || ''))) !== null) {
|
|
484
|
+
const t = (m[1] || m[2]).toLowerCase().trim();
|
|
485
|
+
if (t && !out.includes(t)) out.push(t);
|
|
486
|
+
}
|
|
487
|
+
if (!out.length) throw new QueryInputError('search needs at least one term');
|
|
488
|
+
if (out.length > 8) throw new QueryInputError('at most 8 search terms');
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// What the indexed copies hold: a part's text and a tool result's output, lowercased.
|
|
493
|
+
const BODY = "lower(concat(text, '\\n', output))";
|
|
494
|
+
// A part's time for "newest" without nulls (ts is null where the source gave none), then its order.
|
|
495
|
+
const AT = "(ifNull(ts, toDateTime64(0, 3, 'UTC')), order_key)";
|
|
496
|
+
|
|
497
|
+
// One token for the splitByNonAlpha tokenizer: ASCII alphanumerics, or any non-ASCII byte.
|
|
498
|
+
const SINGLE_TOKEN = /^(?:[a-z0-9]|[^\x00-\x7f])+$/;
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* The predicate for one term, and which index it can use. Substring semantics for every
|
|
502
|
+
* term (what 0.18's search and /mem:recall mean by a match):
|
|
503
|
+
*
|
|
504
|
+
* single token, ≥ 4 chars `search_word LIKE '%t%'` the word index answers it by scanning its
|
|
505
|
+
* token dictionary (26.7: use_text_index_like_evaluation_by_dictionary_scan,
|
|
506
|
+
* min pattern length 4) — measured 5 ms vs 182 ms on the ngram index
|
|
507
|
+
* anything else (≥ 3) `search_ngram LIKE '%t%'` the ngram index (phrases, 'a-b', 3 chars)
|
|
508
|
+
* 1–2 chars the same predicate, no index; still exact
|
|
509
|
+
*
|
|
510
|
+
* `match: 'word'` asks for whole tokens instead: `hasToken(search_word, t)` (single-token terms only).
|
|
511
|
+
*/
|
|
512
|
+
function termPredicate(term, i, match) {
|
|
513
|
+
const p = `t${i}`;
|
|
514
|
+
// stage1 uses the indexed copies; stage2 re-checks the SAME predicate on what both copies hold
|
|
515
|
+
// (MATERIALIZED lower(concat(text, '\n', output)): a part's text and a tool result's output), so
|
|
516
|
+
// stage 2 reads the two columns once instead of a stored copy per term.
|
|
517
|
+
if (match === 'word') {
|
|
518
|
+
if (!SINGLE_TOKEN.test(term)) throw new QueryInputError(`'${term}' is not a single word — use substring search`);
|
|
519
|
+
return { stage1: `hasToken(search_word, {${p}:String})`, stage2: `hasToken(${BODY}, {${p}:String})`, params: { [p]: term }, index: 'idx_search_word' };
|
|
520
|
+
}
|
|
521
|
+
const like = { [p]: likeContains(term) };
|
|
522
|
+
if (SINGLE_TOKEN.test(term) && term.length >= 4) {
|
|
523
|
+
return { stage1: `search_word LIKE {${p}:String}`, stage2: `${BODY} LIKE {${p}:String}`, params: like, index: 'idx_search_word' };
|
|
524
|
+
}
|
|
525
|
+
return { stage1: `search_ngram LIKE {${p}:String}`, stage2: `${BODY} LIKE {${p}:String}`, params: like, index: term.length >= 3 ? 'idx_search_ngram' : null };
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* search — rewritten 2026-10-16 after measuring where its time went on a real house. The text
|
|
530
|
+
* index was never the cost: it keeps exactly the granules that hold a term (10–33 ms, < 1 MiB).
|
|
531
|
+
* The cost was reading those granules three times — a session restriction, the candidate keys
|
|
532
|
+
* (which ran the restriction again), and a FINAL re-check with skip indexes OFF that read `text`
|
|
533
|
+
* for every row of every candidate granule ('replacingmergetree': 366 ms, 1.03M rows, 193 MiB).
|
|
534
|
+
*
|
|
535
|
+
* Skip indexes were off under FINAL because, without exact mode, a text index can skip the granule
|
|
536
|
+
* of a newer revision that lost the term, and FINAL would then keep the old matching version — a
|
|
537
|
+
* false hit. `use_skip_indexes_if_final_exact_mode` (on by default in 26.7) closes that: the ranges
|
|
538
|
+
* the index keeps are widened to every range that intersects them in primary key, so FINAL sees
|
|
539
|
+
* each key's newest version. So the whole search is now ONE FINAL pass with the index on:
|
|
540
|
+
*
|
|
541
|
+
* hits (MATERIALIZED: one row per session, at most `limit`) turns FINAL where every term
|
|
542
|
+
* matches, is_deleted = 0 AND removed = 0, live sessions only, grouped per session, newest hit first.
|
|
543
|
+
* snippet `text` read for those `limit` newest hits only, by full sorting key.
|
|
544
|
+
*
|
|
545
|
+
* Measured on the dev house: identical results to the two-stage statement on every term tried,
|
|
546
|
+
* 46–102 ms against 41–203 ms. A session's hit count is now every live hit, not the hits among
|
|
547
|
+
* the first 50,000 candidates. Servers without materialized CTEs or exact mode run the previous
|
|
548
|
+
* statement (searchTwoStage, below) via `fallback`.
|
|
549
|
+
*
|
|
550
|
+
* opts: { q, match: 'substring'|'word', filters: { harness, folder, project, from, to }, limit }
|
|
551
|
+
*/
|
|
552
|
+
function search(rooms, opts = {}) {
|
|
553
|
+
const terms = searchTerms(opts.q);
|
|
554
|
+
const limit = clampInt(opts.limit, 20, 1, 200);
|
|
555
|
+
const preds = terms.map((t, i) => termPredicate(t, i, opts.match));
|
|
556
|
+
const params = Object.assign({ limit, snip: terms[0] }, ...preds.map((p) => p.params));
|
|
557
|
+
const f = opts.filters || {};
|
|
558
|
+
const scope = [];
|
|
559
|
+
if (f.harness) { scope.push('harness = {f_harness:String}'); params.f_harness = String(f.harness); }
|
|
560
|
+
if (f.folder) { scope.push('folder = {f_folder:String}'); params.f_folder = String(f.folder); }
|
|
561
|
+
if (f.project) { scope.push('project = {f_project:String}'); params.f_project = String(f.project); }
|
|
562
|
+
if (f.from != null) { scope.push(tsBound('>=', `fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`)); params.f_from = Number(f.from); }
|
|
563
|
+
if (f.to != null) { scope.push(tsBound('<=', `fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`)); params.f_to = Number(f.to); }
|
|
564
|
+
// Every term on the indexed copies; under FINAL they are evaluated on each key's newest version.
|
|
565
|
+
const match = preds.map((p) => p.stage1).join(' AND ');
|
|
566
|
+
const scopeSql = scope.length ? ` AND ${scope.join(' AND ')}` : '';
|
|
567
|
+
const sql = `WITH hits AS MATERIALIZED (
|
|
568
|
+
SELECT harness AS ah, host AS ahost, harness_path AS ahp, session_id AS asid, incarnation AS ainc, count() AS hits, min(ts) AS first_hit, max(ts) AS last_hit,
|
|
569
|
+
argMax(message_id, ${AT}) AS hit_message_id, argMax(part_id, ${AT}) AS hit_part,
|
|
570
|
+
argMax(agent_id, ${AT}) AS hit_agent_id, argMax(role, ${AT}) AS hit_role
|
|
571
|
+
FROM ${rooms.parts} FINAL
|
|
572
|
+
WHERE ${match} AND is_deleted = 0 AND removed = 0${scopeSql}
|
|
573
|
+
AND (${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0)
|
|
574
|
+
GROUP BY ah, ahost, ahp, asid, ainc
|
|
575
|
+
ORDER BY last_hit DESC, ah, asid, ahost, ahp, ainc
|
|
576
|
+
LIMIT {limit:UInt32}
|
|
577
|
+
)
|
|
578
|
+
SELECT a.ah AS harness, a.asid AS session_id, a.ahost AS host, a.ahp AS harness_path, a.ainc AS incarnation,
|
|
579
|
+
s.title AS name, s.folder AS folder, s.project AS project, toUnixTimestamp64Milli(s.ended) AS ended_ms,
|
|
580
|
+
a.hits AS hits, toUnixTimestamp64Milli(a.first_hit) AS first_hit_ms, toUnixTimestamp64Milli(a.last_hit) AS last_hit_ms,
|
|
581
|
+
a.hit_message_id AS message_id, a.hit_agent_id AS agent_id, a.hit_role AS role, n.snippet AS snippet
|
|
582
|
+
FROM hits AS a
|
|
583
|
+
INNER JOIN (
|
|
584
|
+
SELECT harness AS sh, host AS shost, harness_path AS shp, session_id AS ss, incarnation AS sinc, title, folder, project, ended
|
|
585
|
+
FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0
|
|
586
|
+
) AS s ON s.sh = a.ah AND s.shost = a.ahost AND s.shp = a.ahp AND s.ss = a.asid AND s.sinc = a.ainc
|
|
587
|
+
LEFT JOIN (
|
|
588
|
+
SELECT harness AS nh, host AS nhost, harness_path AS nhp, session_id AS ns, incarnation AS ninc,
|
|
589
|
+
argMax(substring(body, greatest(1, toInt64(positionCaseInsensitiveUTF8(body, {snip:String})) - 80), 240), rev) AS snippet
|
|
590
|
+
FROM (SELECT harness, host, harness_path, session_id, incarnation, rev, concat(text, '\\n', output) AS body FROM ${rooms.parts}
|
|
591
|
+
WHERE (host, harness, harness_path, session_id, incarnation, message_id, part_id)
|
|
592
|
+
IN (SELECT ahost, ah, ahp, asid, ainc, hit_message_id, hit_part FROM hits))
|
|
593
|
+
GROUP BY nh, nhost, nhp, ns, ninc
|
|
594
|
+
) AS n ON n.nh = a.ah AND n.nhost = a.ahost AND n.nhp = a.ahp AND n.ns = a.asid AND n.ninc = a.ainc
|
|
595
|
+
ORDER BY last_hit_ms DESC, harness, session_id, host, harness_path, incarnation
|
|
596
|
+
LIMIT {limit:UInt32}`;
|
|
597
|
+
return {
|
|
598
|
+
query: 'search', final: 'session_parts FINAL (skip indexes in exact mode), sessions FINAL; snippet by argMax(rev)', sql, params,
|
|
599
|
+
settings: { enable_materialized_cte: 1, use_skip_indexes_if_final: 1, use_skip_indexes_if_final_exact_mode: 1 },
|
|
600
|
+
terms, indexes: preds.map((p) => p.index),
|
|
601
|
+
// A server without materialized CTEs or exact mode runs the previous two-stage statement.
|
|
602
|
+
fallback: searchTwoStage(rooms, opts),
|
|
603
|
+
};
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* searchTwoStage — the previous search (the fallback): the two stages of §6, in ONE statement so it costs one round trip:
|
|
608
|
+
*
|
|
609
|
+
* stage 1 `turns` WITHOUT FINAL: the text indexes prune, is_deleted = 0 AND removed = 0 drops tombstones,
|
|
610
|
+
* and the matching rows of the newest 3×limit matching SESSIONS (at most `cap`)
|
|
611
|
+
* give their full sorting keys — candidates only, since an old revision may match
|
|
612
|
+
* while its replacement does not. Restricting to the sessions the answer can hold
|
|
613
|
+
* bounds stage 2 by the size of the answer, not by how widely a term is spread:
|
|
614
|
+
* a rare term in 40% of granules read 250 MiB / 271 ms unrestricted, 65 MiB / 121 ms
|
|
615
|
+
* restricted (synthetic house, 2,000 sessions). The index scan runs twice; with the
|
|
616
|
+
* word index each run is ~5–15 ms.
|
|
617
|
+
* stage 2 `turns FINAL` on exactly those keys (the tuple is the whole ORDER BY, so the set
|
|
618
|
+
* is a primary-key lookup), is_deleted = 0 AND removed = 0, and EVERY term re-checked on the newest
|
|
619
|
+
* version. Skip indexes are switched OFF for this FINAL: with exact mode off, a
|
|
620
|
+
* text index would skip the granule of a new revision that lost the term and FINAL
|
|
621
|
+
* would keep the old one — the very false hit stage 2 exists to remove.
|
|
622
|
+
*
|
|
623
|
+
* Hits are grouped per session and joined to live sessions (`sessions FINAL`, is_deleted = 0 AND removed = 0),
|
|
624
|
+
* newest hit first. Measured on the synthetic house: 'platypus' matches 4,163 raw rows and
|
|
625
|
+
* 208 live ones; stage 2 is what tells them apart.
|
|
626
|
+
*
|
|
627
|
+
* Completeness: a session whose every raw hit is superseded still takes one of the 3×limit
|
|
628
|
+
* slots and then drops out in stage 2, so a page can hold fewer than `limit` sessions when
|
|
629
|
+
* more exist. Never a false hit; the over-fetch makes a short page rare.
|
|
630
|
+
*
|
|
631
|
+
* opts: { q, match: 'substring'|'word', filters: { harness, folder, project, from, to }, limit, cap }
|
|
632
|
+
*/
|
|
633
|
+
function searchTwoStage(rooms, opts = {}) {
|
|
634
|
+
const terms = searchTerms(opts.q);
|
|
635
|
+
const limit = clampInt(opts.limit, 20, 1, 200);
|
|
636
|
+
// The row cap is a safety bound only: stage 2 is bounded by the session restriction. A low
|
|
637
|
+
// cap lost live hits — superseded versions crowd the newest-first candidate list (measured:
|
|
638
|
+
// 'platypus', 3,955 stale rows + 208 live; a 2,000 cap returned 100 of the 208).
|
|
639
|
+
const cap = clampInt(opts.cap, 50000, 1, 200000);
|
|
640
|
+
const preds = terms.map((t, i) => termPredicate(t, i, opts.match));
|
|
641
|
+
const params = Object.assign({ limit, cap, scap: Math.min(limit * 3, 600), snip: terms[0] }, ...preds.map((p) => p.params));
|
|
642
|
+
const f = opts.filters || {};
|
|
643
|
+
const scope = [];
|
|
644
|
+
if (f.harness) { scope.push('harness = {f_harness:String}'); params.f_harness = String(f.harness); }
|
|
645
|
+
if (f.folder) { scope.push('folder = {f_folder:String}'); params.f_folder = String(f.folder); }
|
|
646
|
+
if (f.project) { scope.push('project = {f_project:String}'); params.f_project = String(f.project); }
|
|
647
|
+
if (f.from != null) { scope.push(tsBound('>=', `fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`)); params.f_from = Number(f.from); }
|
|
648
|
+
if (f.to != null) { scope.push(tsBound('<=', `fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`)); params.f_to = Number(f.to); }
|
|
649
|
+
const stage1 = preds.map((p) => p.stage1).join(' AND ');
|
|
650
|
+
const stage2 = preds.map((p) => p.stage2).join(' AND ');
|
|
651
|
+
const scopeSql = scope.length ? ` AND ${scope.join(' AND ')}` : '';
|
|
652
|
+
const sql = `SELECT h.harness AS harness, h.session_id AS session_id, h.host AS host, h.harness_path AS harness_path, h.incarnation AS incarnation,
|
|
653
|
+
s.title AS name, s.folder AS folder, s.project AS project, toUnixTimestamp64Milli(s.ended) AS ended_ms,
|
|
654
|
+
h.hits AS hits, toUnixTimestamp64Milli(h.first_hit) AS first_hit_ms, toUnixTimestamp64Milli(h.last_hit) AS last_hit_ms,
|
|
655
|
+
h.hit_message_id AS message_id, h.hit_agent_id AS agent_id, h.hit_role AS role, h.snippet AS snippet
|
|
656
|
+
FROM (
|
|
657
|
+
SELECT ${skeyOf()}, count() AS hits, min(ts) AS first_hit, max(ts) AS last_hit,
|
|
658
|
+
-- aliases must not reuse column names: ClickHouse aliases are query-global, and
|
|
659
|
+
-- argMax(message_id, ts) AS message_id would replace the column in the WHERE below
|
|
660
|
+
argMax(message_id, ${AT}) AS hit_message_id, argMax(agent_id, ${AT}) AS hit_agent_id, argMax(role, ${AT}) AS hit_role,
|
|
661
|
+
argMax(substring(concat(text, '\\n', output), greatest(1, toInt64(positionCaseInsensitiveUTF8(concat(text, '\\n', output), {snip:String})) - 80), 240), ${AT}) AS snippet
|
|
662
|
+
FROM ${rooms.parts} FINAL
|
|
663
|
+
WHERE (host, harness, harness_path, session_id, incarnation, message_id, part_id) IN (
|
|
664
|
+
SELECT host, harness, harness_path, session_id, incarnation, message_id, part_id
|
|
665
|
+
FROM ${rooms.parts}
|
|
666
|
+
WHERE ${stage1} AND is_deleted = 0 AND removed = 0${scopeSql}
|
|
667
|
+
AND (${skeyOf()}) IN (
|
|
668
|
+
SELECT ${skeyOf()} FROM ${rooms.parts}
|
|
669
|
+
WHERE ${stage1} AND is_deleted = 0 AND removed = 0${scopeSql}
|
|
670
|
+
AND (${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0)
|
|
671
|
+
GROUP BY ${skeyOf()}
|
|
672
|
+
ORDER BY max(${AT}) DESC
|
|
673
|
+
LIMIT {scap:UInt32})
|
|
674
|
+
ORDER BY ${AT} DESC
|
|
675
|
+
LIMIT {cap:UInt32})
|
|
676
|
+
AND is_deleted = 0 AND removed = 0 AND ${stage2}
|
|
677
|
+
GROUP BY ${skeyOf()}
|
|
678
|
+
) AS h
|
|
679
|
+
INNER JOIN (
|
|
680
|
+
SELECT ${skeyOf()}, title, folder, project, ended FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0
|
|
681
|
+
) AS s ON s.harness = h.harness AND s.host = h.host AND s.harness_path = h.harness_path AND s.session_id = h.session_id AND s.incarnation = h.incarnation
|
|
682
|
+
ORDER BY last_hit_ms DESC, harness, session_id, host, harness_path, incarnation
|
|
683
|
+
LIMIT {limit:UInt32}`;
|
|
684
|
+
return {
|
|
685
|
+
query: 'search', final: 'session_parts (stage 2), sessions', sql, params,
|
|
686
|
+
settings: { use_skip_indexes_if_final: 0 },
|
|
687
|
+
terms, indexes: preds.map((p) => p.index),
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ── aggregate ─────────────────────────────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
// Dimensions. `s` = the session row, `u` = one usage entry (ARRAY JOIN s.usage AS u), `t` = one
|
|
694
|
+
// tools entry (ARRAY JOIN s.tools AS t). Session-grain time dims bucket by `started` (§3: session
|
|
695
|
+
// counts keep counting by start); usage-grain time dims bucket by the usage hour.
|
|
696
|
+
//
|
|
697
|
+
// EVERY column is qualified (`s.messages`, never `messages`). ClickHouse aliases are global to
|
|
698
|
+
// the query: with a metric `sum(messages) AS messages` in the SELECT, a bare `messages` anywhere
|
|
699
|
+
// else — another metric, the depth dimension, the WHERE of the `named` filter — resolves to the
|
|
700
|
+
// ALIAS, and the query fails with "aggregate function found inside another aggregate function".
|
|
701
|
+
// A qualified name always means the column.
|
|
702
|
+
const SESSION_DIMS = {
|
|
703
|
+
harness: 's.harness', mode: 's.mode', folder: 's.folder', project: 's.project', host: 's.host', root: ROOT_LABEL('s.harness_path'), harness_path: 's.harness_path',
|
|
704
|
+
origin: 's.origin', dominant_model: 's.dominant_model',
|
|
705
|
+
day: "formatDateTime(s.started, '%Y-%m-%d', {tz:String})",
|
|
706
|
+
hour: 'toHour(s.started, {tz:String})',
|
|
707
|
+
dow: 'toDayOfWeek(s.started, 0, {tz:String}) % 7',
|
|
708
|
+
month: "formatDateTime(s.started, '%Y-%m', {tz:String})",
|
|
709
|
+
end_month: "formatDateTime(s.ended, '%Y-%m', {tz:String})",
|
|
710
|
+
depth: "multiIf(s.messages <= 1, '1', s.messages <= 5, '2-5', s.messages <= 10, '6-10', s.messages <= 20, '11-20', s.messages <= 50, '21-50', s.messages <= 100, '51-100', '100+')",
|
|
711
|
+
};
|
|
712
|
+
const USAGE_DIMS = {
|
|
713
|
+
model: 'u.model',
|
|
714
|
+
// The model a usage entry is PRICED at: its own, or the session's dominant one when the
|
|
715
|
+
// source recorded none (0.18's orphan rule, now per session from its own row).
|
|
716
|
+
price_model: `if(u.model IN ('${EXCLUDED_MODELS.join("', '")}'), s.dominant_model, u.model)`,
|
|
717
|
+
day: "formatDateTime(u.hour, '%Y-%m-%d', {tz:String})",
|
|
718
|
+
hour: 'toHour(u.hour, {tz:String})',
|
|
719
|
+
month: "formatDateTime(u.hour, '%Y-%m', {tz:String})",
|
|
720
|
+
};
|
|
721
|
+
const TOOL_DIMS = { tool: 't.name' };
|
|
722
|
+
|
|
723
|
+
const SESSION_METRICS = {
|
|
724
|
+
sessions: 'count()',
|
|
725
|
+
named_sessions: "countIf(s.title != '' OR s.messages > 0)",
|
|
726
|
+
messages: 'sum(s.messages)', user_messages: 'sum(s.user_messages)', assistant_messages: 'sum(s.assistant_messages)',
|
|
727
|
+
agent_messages: 'sum(s.agent_messages)', tool_calls: 'sum(s.tool_calls)',
|
|
728
|
+
input_tokens: 'sum(s.input_tokens)', output_tokens: 'sum(s.output_tokens)',
|
|
729
|
+
cache_read_tokens: 'sum(s.cache_read_tokens)', cache_write_tokens: 'sum(s.cache_write_tokens)',
|
|
730
|
+
reasoning_tokens: 'sum(s.reasoning_tokens)',
|
|
731
|
+
user_chars: 'sum(s.user_chars)', assistant_chars: 'sum(s.assistant_chars)',
|
|
732
|
+
first_started_ms: 'toUnixTimestamp64Milli(minIf(s.started, toUnixTimestamp64Milli(s.started) > 0))', last_ended_ms: 'toUnixTimestamp64Milli(max(s.ended))',
|
|
733
|
+
avg_messages: 'avg(s.messages)', avg_tokens: 'avg(s.input_tokens + s.output_tokens)',
|
|
734
|
+
};
|
|
735
|
+
const USAGE_METRICS = {
|
|
736
|
+
sessions: 'uniqExact(s.harness, s.session_id)',
|
|
737
|
+
messages: 'sum(u.messages)', input_tokens: 'sum(u.input)', output_tokens: 'sum(u.output)',
|
|
738
|
+
cache_read_tokens: 'sum(u.cache_read)', cache_write_tokens: 'sum(u.cache_write)', reasoning_tokens: 'sum(u.reasoning)',
|
|
739
|
+
first_hour_ms: 'toUnixTimestamp(min(u.hour)) * 1000', last_hour_ms: 'toUnixTimestamp(max(u.hour)) * 1000',
|
|
740
|
+
};
|
|
741
|
+
const TOOL_METRICS = {
|
|
742
|
+
sessions: 'uniqExact(s.harness, s.session_id)', calls: 'sum(t.calls)', errors: 'sum(t.errors)',
|
|
743
|
+
};
|
|
744
|
+
|
|
745
|
+
const SOURCES = {
|
|
746
|
+
sessions: { dims: SESSION_DIMS, metrics: SESSION_METRICS, join: '' },
|
|
747
|
+
usage: { dims: { ...SESSION_DIMS, ...USAGE_DIMS }, metrics: USAGE_METRICS, join: ' ARRAY JOIN s.usage AS u' },
|
|
748
|
+
tools: { dims: { ...SESSION_DIMS, ...TOOL_DIMS }, metrics: TOOL_METRICS, join: ' ARRAY JOIN s.tools AS t' },
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
/**
|
|
752
|
+
* aggregate — dims × metrics over `sessions FINAL` (≤ one row per session, is_deleted = 0 AND removed = 0).
|
|
753
|
+
*
|
|
754
|
+
* source 'sessions' one row per session
|
|
755
|
+
* source 'usage' ARRAY JOIN usage: one row per (session, hour, model) — message-grain
|
|
756
|
+
* series, tokens and cost. With from/to, usage hours outside the window
|
|
757
|
+
* are dropped too, so tokens and cost are the window's, not whole sessions'.
|
|
758
|
+
* source 'tools' ARRAY JOIN tools: one row per (session, tool)
|
|
759
|
+
*
|
|
760
|
+
* usage and tools are never expanded in the same query (§3: their product is meaningless).
|
|
761
|
+
* Dims and metrics are names from the registries above — never SQL from the caller.
|
|
762
|
+
*
|
|
763
|
+
* opts: { source, dims: [...], metrics: [...], filters, where: { excludeModels }, order: [[name, 'asc'|'desc']], limit, tz }
|
|
764
|
+
*/
|
|
765
|
+
function aggregate(rooms, opts = {}) {
|
|
766
|
+
const src = SOURCES[opts.source || 'sessions'];
|
|
767
|
+
if (!src) throw new QueryInputError(`source must be one of ${Object.keys(SOURCES).join(', ')}`);
|
|
768
|
+
const dims = opts.dims || [];
|
|
769
|
+
const metrics = opts.metrics && opts.metrics.length ? opts.metrics : ['sessions'];
|
|
770
|
+
for (const d of dims) if (!src.dims[d]) throw new QueryInputError(`unknown dimension '${d}' for ${opts.source || 'sessions'}`);
|
|
771
|
+
for (const m of metrics) if (!src.metrics[m]) throw new QueryInputError(`unknown metric '${m}' for ${opts.source || 'sessions'}`);
|
|
772
|
+
const w = sessionWhere(opts.filters, rooms, 's');
|
|
773
|
+
const params = { ...w.params };
|
|
774
|
+
const uses = (x) => x.includes('{tz:String}');
|
|
775
|
+
const extra = [];
|
|
776
|
+
if (opts.source === 'usage') {
|
|
777
|
+
const f = opts.filters || {};
|
|
778
|
+
if (f.from != null) extra.push(`u.hour >= toStartOfHour(fromUnixTimestamp64Milli({f_from:Int64}, 'UTC'))`);
|
|
779
|
+
if (f.to != null) extra.push(`u.hour <= fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`);
|
|
780
|
+
if (opts.where && opts.where.excludeModels) extra.push(`u.model NOT IN ('${EXCLUDED_MODELS.join("', '")}')`);
|
|
781
|
+
}
|
|
782
|
+
const select = [...dims.map((d) => `${src.dims[d]} AS ${d}`), ...metrics.map((m) => `${src.metrics[m]} AS ${m}`)];
|
|
783
|
+
if (select.some(uses)) params.tz = opts.tz || 'UTC';
|
|
784
|
+
let sql = `SELECT ${select.join(', ')}
|
|
785
|
+
FROM ${rooms.sessions} AS s FINAL${src.join}
|
|
786
|
+
WHERE ${w.sql}${extra.length ? ` AND ${extra.join(' AND ')}` : ''}`;
|
|
787
|
+
if (dims.length) sql += `\nGROUP BY ${dims.join(', ')}`;
|
|
788
|
+
const order = (opts.order || []).map(([name, d]) => {
|
|
789
|
+
if (!dims.includes(name) && !metrics.includes(name)) throw new QueryInputError(`cannot order by '${name}'`);
|
|
790
|
+
return `${name} ${d === 'desc' ? 'DESC' : 'ASC'}`;
|
|
791
|
+
});
|
|
792
|
+
// A total order whenever the caller asked for one, so a top-N cut never depends on arrival.
|
|
793
|
+
if (order.length) sql += `\nORDER BY ${[...order, ...dims.filter((d) => !order.some((o) => o.startsWith(`${d} `))).map((d) => `${d} ASC`)].join(', ')}`;
|
|
794
|
+
else if (dims.length) sql += `\nORDER BY ${dims.join(', ')}`;
|
|
795
|
+
if (opts.limit) { sql += '\nLIMIT {limit:UInt32}'; params.limit = clampInt(opts.limit, 100, 1, 100000); }
|
|
796
|
+
return { query: `aggregate:${opts.source || 'sessions'}`, final: 'sessions', settings: {}, params, sql };
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
// ── tools (drill-down) ────────────────────────────────────────────────────────────
|
|
800
|
+
|
|
801
|
+
/**
|
|
802
|
+
* tools — the calls of one tool, newest first. `calls FINAL` by tool_name (the set skip index)
|
|
803
|
+
* and the time window, with skip indexes ON under FINAL in EXACT mode
|
|
804
|
+
* (see the header: exact mode is what keeps a key-only tombstone from being skipped), joined
|
|
805
|
+
* to live sessions for their names.
|
|
806
|
+
*
|
|
807
|
+
* opts: { tool, filters: { harness, folder, hidden, from, to }, limit }
|
|
808
|
+
*/
|
|
809
|
+
function tools(rooms, opts = {}) {
|
|
810
|
+
if (!opts.tool) throw new QueryInputError('tool is required');
|
|
811
|
+
const limit = clampInt(opts.limit, 200, 1, 1000);
|
|
812
|
+
const f = opts.filters || {};
|
|
813
|
+
const params = { tool: String(opts.tool), limit };
|
|
814
|
+
// Live sessions only, decided BEFORE the LIMIT: joining them afterwards returned short pages
|
|
815
|
+
// (996 of 1,000) whenever a dropped session's calls were among the newest.
|
|
816
|
+
const where = ['tool_name = {tool:String}', 'is_deleted = 0 AND removed = 0',
|
|
817
|
+
`(${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0)`];
|
|
818
|
+
if (f.from != null) { where.unshift(tsBound('>=', `fromUnixTimestamp64Milli({f_from:Int64}, 'UTC')`)); params.f_from = Number(f.from); }
|
|
819
|
+
if (f.to != null) { where.unshift(tsBound('<=', `fromUnixTimestamp64Milli({f_to:Int64}, 'UTC')`)); params.f_to = Number(f.to); }
|
|
820
|
+
if (f.harness) { where.push('harness = {f_harness:String}'); params.f_harness = String(f.harness); }
|
|
821
|
+
if (f.harnessLike) { where.push('harness LIKE {f_harness_like:String}'); params.f_harness_like = likeContains(f.harnessLike); }
|
|
822
|
+
if (f.folder) { where.push('folder = {f_folder:String}'); params.f_folder = String(f.folder); }
|
|
823
|
+
if (Array.isArray(f.hidden) && f.hidden.length) { where.push('folder NOT IN {f_hidden:Array(String)}'); params.f_hidden = f.hidden.map(String); }
|
|
824
|
+
return {
|
|
825
|
+
query: 'tools', final: 'calls, sessions',
|
|
826
|
+
settings: { use_skip_indexes_if_final: 1, use_skip_indexes_if_final_exact_mode: 1 },
|
|
827
|
+
params,
|
|
828
|
+
sql: `SELECT c.harness AS harness, c.session_id AS session_id, c.agent_id AS agent_id, c.call_id AS call_id,
|
|
829
|
+
c.message_id AS message_id, toUnixTimestamp64Milli(c.ts) AS ts_ms, c.tool_name AS tool_name, c.args AS args,
|
|
830
|
+
toUnixTimestamp64Milli(c.result_ts) AS result_ts_ms, c.is_error AS is_error, c.output_len AS result_len,
|
|
831
|
+
c.model AS model, c.folder AS folder, c.project AS project, c.host AS host, c.harness_path AS harness_path, c.incarnation AS incarnation, s.title AS session_name
|
|
832
|
+
FROM (
|
|
833
|
+
SELECT harness, session_id, harness_path, incarnation, agent_id, call_id, message_id, ts, tool_name, args, result_ts, is_error, output_len,
|
|
834
|
+
model, folder, project, host
|
|
835
|
+
FROM ${rooms.calls} FINAL
|
|
836
|
+
WHERE ${where.join(' AND ')}
|
|
837
|
+
ORDER BY ts DESC, call_id
|
|
838
|
+
LIMIT {limit:UInt32}
|
|
839
|
+
) AS c
|
|
840
|
+
INNER JOIN (SELECT ${skeyOf()}, title FROM ${rooms.sessions} FINAL WHERE is_deleted = 0 AND removed = 0) AS s
|
|
841
|
+
ON s.harness = c.harness AND s.host = c.host AND s.harness_path = c.harness_path AND s.session_id = c.session_id AND s.incarnation = c.incarnation
|
|
842
|
+
ORDER BY ts_ms DESC, call_id`,
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// ── coverage / machines ───────────────────────────────────────────────────────────
|
|
847
|
+
|
|
848
|
+
/** What the house holds, per harness: sessions, span, hosts, origins. `sessions FINAL`; per_record_sessions from `session_usage FINAL`. */
|
|
849
|
+
function coverage(rooms) {
|
|
850
|
+
return {
|
|
851
|
+
query: 'coverage', final: 'sessions', settings: {}, params: {},
|
|
852
|
+
sql: `SELECT harness, count() AS sessions, sum(messages) AS messages, sum(tool_calls) AS tool_calls,
|
|
853
|
+
toUnixTimestamp64Milli(minIf(started, toUnixTimestamp64Milli(started) > 0)) AS first_ms, toUnixTimestamp64Milli(max(ended)) AS last_ms,
|
|
854
|
+
arraySort(groupUniqArray(host)) AS hosts, arraySort(groupUniqArray(origin)) AS origins,
|
|
855
|
+
countIf((${skeyOf()}) IN (SELECT ${skeyOf()} FROM ${rooms.usage} FINAL WHERE granularity = 'per_record' AND is_deleted = 0 AND removed = 0)) AS per_record_sessions
|
|
856
|
+
FROM ${rooms.sessions} FINAL
|
|
857
|
+
WHERE is_deleted = 0 AND removed = 0
|
|
858
|
+
GROUP BY harness
|
|
859
|
+
ORDER BY sessions DESC, harness`,
|
|
860
|
+
finish(rows) {
|
|
861
|
+
const total = { sessions: 0, messages: 0, tool_calls: 0, first_ms: null, last_ms: null };
|
|
862
|
+
for (const r of rows) {
|
|
863
|
+
total.sessions += Number(r.sessions); total.messages += Number(r.messages); total.tool_calls += Number(r.tool_calls);
|
|
864
|
+
if (total.first_ms === null || Number(r.first_ms) < total.first_ms) total.first_ms = Number(r.first_ms);
|
|
865
|
+
if (total.last_ms === null || Number(r.last_ms) > total.last_ms) total.last_ms = Number(r.last_ms);
|
|
866
|
+
}
|
|
867
|
+
return { harnesses: rows, total };
|
|
868
|
+
},
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** The machines that wrote sessions. `sessions FINAL`. */
|
|
873
|
+
function machines(rooms) {
|
|
874
|
+
return {
|
|
875
|
+
query: 'machines', final: 'sessions', settings: {}, params: {},
|
|
876
|
+
sql: `SELECT host, anyIf(host_label, host_label != '') AS label, count() AS sessions, arraySort(groupUniqArray(harness)) AS harnesses,
|
|
877
|
+
toUnixTimestamp64Milli(max(ended)) AS last_ms
|
|
878
|
+
FROM ${rooms.sessions} FINAL
|
|
879
|
+
WHERE is_deleted = 0 AND removed = 0
|
|
880
|
+
GROUP BY host
|
|
881
|
+
ORDER BY last_ms DESC, host`,
|
|
882
|
+
};
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// ── running a spec ────────────────────────────────────────────────────────────────
|
|
886
|
+
|
|
887
|
+
async function run(exec, spec) {
|
|
888
|
+
const rows = spec.empty ? [] : await exec(spec);
|
|
889
|
+
return spec.finish ? spec.finish(rows) : rows;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// A spec may carry `fallback`: the same answer for a server without a feature the spec needs
|
|
893
|
+
// (search: materialized CTEs, skip indexes under FINAL in exact mode). The first refusal switches
|
|
894
|
+
// that query to its fallback for the life of the process; any other error is the caller's.
|
|
895
|
+
const FEATURE_REFUSED = /UNKNOWN_SETTING|Unknown setting|SYNTAX_ERROR|Syntax error|NOT_IMPLEMENTED|materialized/i;
|
|
896
|
+
const onFallback = new Set();
|
|
897
|
+
function withFallback(exec) {
|
|
898
|
+
return async (spec) => {
|
|
899
|
+
if (!spec || !spec.fallback) return exec(spec);
|
|
900
|
+
if (onFallback.has(spec.query)) return exec(spec.fallback);
|
|
901
|
+
try { return await exec(spec); } catch (e) {
|
|
902
|
+
if (!FEATURE_REFUSED.test(String(e && e.message))) throw e;
|
|
903
|
+
onFallback.add(spec.query);
|
|
904
|
+
return exec(spec.fallback);
|
|
905
|
+
}
|
|
906
|
+
};
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** usage_by_model (a sumMap tuple) -> [{ model, messages, input, output, cache_read, cache_write, reasoning }] */
|
|
910
|
+
function usageByModel(t) {
|
|
911
|
+
if (!t) return [];
|
|
912
|
+
const [models, msgs, inp, outp, cr, cw, rs] = Array.isArray(t) ? t : [t[0], t[1], t[2], t[3], t[4], t[5], t[6]];
|
|
913
|
+
return (models || []).map((model, i) => ({
|
|
914
|
+
model, messages: Number(msgs[i]) || 0, input: Number(inp[i]) || 0, output: Number(outp[i]) || 0,
|
|
915
|
+
cache_read: Number(cr[i]) || 0, cache_write: Number(cw[i]) || 0, reasoning: Number(rs[i]) || 0,
|
|
916
|
+
}));
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
// A session reference: `<harness>:<session_id>`, then `~<store>` when it names its store — the
|
|
920
|
+
// base64url of [host, harness_path, incarnation]. Without a store (a 0.18 link, a typed id) the
|
|
921
|
+
// newest session of that harness and id is read, and then only its own store's rows.
|
|
922
|
+
const STORE_SEP = '~';
|
|
923
|
+
function storeToken(r) { return Buffer.from(JSON.stringify([String(r.host), String(r.harness_path), String(r.incarnation || '')]), 'utf-8').toString('base64url'); }
|
|
924
|
+
function parseStoreToken(t) {
|
|
925
|
+
try {
|
|
926
|
+
const v = JSON.parse(Buffer.from(t, 'base64url').toString('utf-8'));
|
|
927
|
+
return Array.isArray(v) && v.length === 3 && v.every((x) => typeof x === 'string') ? { host: v[0], harness_path: v[1], incarnation: v[2] } : null;
|
|
928
|
+
} catch { return null; }
|
|
929
|
+
}
|
|
930
|
+
/** Parse a session reference (above) or an object. */
|
|
931
|
+
function parseSessionRef(ref, harness = null) {
|
|
932
|
+
if (ref && typeof ref === 'object') {
|
|
933
|
+
const o = { harness: String(ref.harness), session_id: String(ref.session_id) };
|
|
934
|
+
return hasStore(ref) ? { ...o, host: String(ref.host), harness_path: String(ref.harness_path), incarnation: String(ref.incarnation || '') } : o;
|
|
935
|
+
}
|
|
936
|
+
let s = String(ref || '');
|
|
937
|
+
let store = null;
|
|
938
|
+
const at = s.lastIndexOf(STORE_SEP);
|
|
939
|
+
if (at > 0) { const st = parseStoreToken(s.slice(at + 1)); if (st) { store = st; s = s.slice(0, at); } }
|
|
940
|
+
let out;
|
|
941
|
+
if (harness) out = { harness: String(harness), session_id: s };
|
|
942
|
+
else {
|
|
943
|
+
const cut = s.indexOf(':');
|
|
944
|
+
if (cut <= 0) throw new QueryInputError(`'${s}' is not <harness>:<session_id>`);
|
|
945
|
+
out = { harness: s.slice(0, cut), session_id: s.slice(cut + 1) };
|
|
946
|
+
}
|
|
947
|
+
return store ? { ...out, ...store } : out;
|
|
948
|
+
}
|
|
949
|
+
const sessionRef = (r) => `${r.harness}:${r.session_id}${hasStore(r) ? `${STORE_SEP}${storeToken(r)}` : ''}`;
|
|
950
|
+
|
|
951
|
+
/**
|
|
952
|
+
* removedContent — the messages kept as removed versions (plan v4: a key that left its source on a
|
|
953
|
+
* rewrite — a revert, a rewind, a deleted message — is written again with removed = 1, never
|
|
954
|
+
* deleted). One row per removed message, newest first: the one read that shows them, since every
|
|
955
|
+
* other read filters removed = 0. Optional: one session (`store` names its store) or one harness,
|
|
956
|
+
* and a day window (`days`, by the message's own date).
|
|
957
|
+
*/
|
|
958
|
+
/**
|
|
959
|
+
* A time bound on a part or call: its own ts, or when it has none (a harness that keeps no time) the
|
|
960
|
+
* first_seen of the pass that captured it.
|
|
961
|
+
*/
|
|
962
|
+
/**
|
|
963
|
+
* What a part says, as text: a call its name and arguments, a result its output, an error its message
|
|
964
|
+
* (the profile keeps an error's content in error_message, its text empty), anything else its text.
|
|
965
|
+
*/
|
|
966
|
+
function partText(t) {
|
|
967
|
+
if (t.kind === 'tool_call') return `${t.tool_name || ''} ${t.args || ''}`.trim();
|
|
968
|
+
if (t.kind === 'tool_result') return t.output || '';
|
|
969
|
+
if (t.kind === 'error') return t.error_message || t.text || (t.error_kind ? `[${t.error_kind}]` : '');
|
|
970
|
+
return t.text;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
function tsBound(op, at) { return `(ts ${op} ${at} OR (ts IS NULL AND first_seen ${op} ${at}))`; }
|
|
974
|
+
|
|
975
|
+
function removedContent(rooms, opts = {}) {
|
|
976
|
+
const limit = clampInt(opts.limit, 50, 1, 1000);
|
|
977
|
+
const chars = clampInt(opts.chars, 200, 0, 100000);
|
|
978
|
+
const params = { limit, chars };
|
|
979
|
+
const where = ['removed = 1', 'is_deleted = 0'];
|
|
980
|
+
if (opts.days != null) { where.unshift(tsBound('>=', 'now64(3) - toIntervalDay({days:UInt32})')); params.days = clampInt(opts.days, 14, 1, 100000); }
|
|
981
|
+
if (opts.harness) { where.push('harness = {h:String}'); params.h = String(opts.harness); }
|
|
982
|
+
if (opts.session_id) {
|
|
983
|
+
where.push('session_id = {s:String}'); params.s = String(opts.session_id);
|
|
984
|
+
const sp = storePred(opts.store || null);
|
|
985
|
+
if (sp.sql) { where.push(sp.sql.replace(/^ AND /, '')); Object.assign(params, sp.params); }
|
|
986
|
+
}
|
|
987
|
+
return {
|
|
988
|
+
query: 'removedContent', final: 'session_parts', params,
|
|
989
|
+
sql: `SELECT toString(harness) AS harness, host, toString(harness_path) AS harness_path, session_id, incarnation, message_id,
|
|
990
|
+
any(role) AS role, any(state) AS state, toUnixTimestamp64Milli(min(ts)) AS ts_ms, count() AS parts,
|
|
991
|
+
substring(argMinIf(text, part_index, kind IN ('text', 'summary', 'reasoning')), 1, {chars:UInt32}) AS snippet
|
|
992
|
+
FROM ${rooms.parts} FINAL
|
|
993
|
+
WHERE ${where.join(' AND ')}
|
|
994
|
+
GROUP BY harness, host, harness_path, session_id, incarnation, message_id
|
|
995
|
+
ORDER BY ts_ms DESC
|
|
996
|
+
LIMIT {limit:UInt32}`,
|
|
997
|
+
};
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
const NAMED = {
|
|
1001
|
+
listSessions, countSessions, sessionHeader, partsPage, callsOf, usageOf, sessionVersion, houseVersion,
|
|
1002
|
+
search, aggregate, tools, coverage, machines, removedContent,
|
|
1003
|
+
};
|
|
1004
|
+
|
|
1005
|
+
module.exports = {
|
|
1006
|
+
ROOT_LABEL,
|
|
1007
|
+
withFallback,
|
|
1008
|
+
v3Rooms, NAMED, QueryInputError, EXCLUDED_MODELS, FAR_FUTURE_MS,
|
|
1009
|
+
sessionWhere, listSessions, countSessions, sessionHeader, partsPage, callsOf, usageOf, attachUsage, houseTokens, readSession,
|
|
1010
|
+
sessionVersion, houseVersion, search, searchTerms, termPredicate, aggregate, tools, coverage, machines, removedContent,
|
|
1011
|
+
run, usageByModel, parseSessionRef, sessionRef, encodeCursor, decodeCursor, likeContains, daysWithin, PART_SELECT, storePred, tsBound, partText,
|
|
1012
|
+
SESSION_DIMS, USAGE_DIMS, TOOL_DIMS, SESSION_METRICS, USAGE_METRICS, TOOL_METRICS,
|
|
1013
|
+
};
|