@gamaze/hicortex 0.16.10 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * /dashboard — view-only memory analytics (#224).
3
+ *
4
+ * STRICTLY view-only: this module computes metrics, reads snapshots, writes
5
+ * ONE snapshot row per full nightly run (the writer is here because the
6
+ * metric SQL lives next to its definition, not in nightly.ts), and exposes
7
+ * the pure data handler mounted at GET /dashboard/data. There are NO mutation
8
+ * endpoints on the dashboard surface — the only write path is the nightly
9
+ * snapshot writer + the one-time backfill, both internal.
10
+ *
11
+ * Layering (mirrors how recall-index.ts holds pure logic and viz.ts holds
12
+ * thin express adapters): all metric SQL + snapshot shape lives HERE so it
13
+ * can be unit-tested without booting express. viz.ts owns only the HTML
14
+ * shell handler + the auth exemption; mcp-server.ts wires both.
15
+ *
16
+ * Headline metric: uses-per-showing = SUM(access_count) / SUM(shown_count)
17
+ * across the corpus — the recall-quality signal (#192 adoption aggregate,
18
+ * promoted to a top-line metric here). Divide-by-zero → null (no showings
19
+ * means undefined, not zero).
20
+ */
21
+ import type express from "express";
22
+ import type Database from "better-sqlite3";
23
+ /** Corpus-shape snapshot. `adoption` is null in backfilled rows (point-in-time,
24
+ * can't be reconstructed from created_at). */
25
+ export interface DashboardMetrics {
26
+ totals: {
27
+ mem: number;
28
+ lesson: number;
29
+ link: number;
30
+ };
31
+ by_type: Record<string, number>;
32
+ by_domain: Record<string, number>;
33
+ by_source_agent: Record<string, number>;
34
+ /** Per-run deltas; undefined on backfilled rows (created_at can't reconstruct
35
+ * what a given nightly produced). */
36
+ new_this_run?: {
37
+ added: number;
38
+ lessonsGenerated?: number;
39
+ dedup: number;
40
+ supersession: number;
41
+ };
42
+ /** Recall adoption aggregate. Null in backfilled rows. uses_per_showing is
43
+ * null when shown_sum = 0 (divide-by-zero guard). */
44
+ adoption?: {
45
+ shown_sum: number;
46
+ used_sum: number;
47
+ cold_count: number;
48
+ uses_per_showing: number | null;
49
+ };
50
+ }
51
+ /** One row of the snapshot series (run_at + parsed metrics). */
52
+ export interface DashboardSnapshot {
53
+ run_at: string;
54
+ metrics: DashboardMetrics;
55
+ }
56
+ /** The /dashboard/data response — the full payload the page renders. */
57
+ export interface DashboardData {
58
+ range: "7d" | "30d" | "90d" | "all";
59
+ headline: {
60
+ total_memories: number;
61
+ uses_per_showing: number | null;
62
+ cold_count: number;
63
+ };
64
+ series: DashboardSnapshot[];
65
+ composition: {
66
+ by_type: Record<string, number>;
67
+ by_domain: Record<string, number>;
68
+ by_source_agent: Record<string, number>;
69
+ };
70
+ digest: {
71
+ date: string | null;
72
+ run_at: string | null;
73
+ sample: {
74
+ id: string;
75
+ line: string;
76
+ created_at: string;
77
+ }[];
78
+ lessons: {
79
+ id: string;
80
+ content: string;
81
+ created_at: string;
82
+ }[];
83
+ stages: {
84
+ lessonsGenerated?: number;
85
+ dedup: number;
86
+ supersession: number;
87
+ added: number;
88
+ };
89
+ dedup_merges: {
90
+ loser_id: string;
91
+ canonical_id: string;
92
+ content_head: string | null;
93
+ merged_at: string;
94
+ }[];
95
+ };
96
+ }
97
+ /**
98
+ * Compute the full corpus-shape metrics from the live DB. The same function
99
+ * backs both the nightly snapshot writer and the live /dashboard/data
100
+ * composition view — one definition of corpus shape.
101
+ */
102
+ export declare function computeDashboardMetrics(db: Database.Database): DashboardMetrics;
103
+ export interface NightlyDelta {
104
+ added: number;
105
+ lessonsGenerated?: number;
106
+ dedup: number;
107
+ supersession: number;
108
+ }
109
+ /**
110
+ * Write one snapshot row for `runAt` (an ISO timestamp the caller chooses —
111
+ * nightly.ts passes `now`). OR-replace on the PRIMARY KEY is intentional: a
112
+ * manual re-run for the same instant overwrites, the nightly never produces
113
+ * two rows for the same instant. Returns the row that was written.
114
+ */
115
+ export declare function writeSnapshot(db: Database.Database, runAt: string, delta: NightlyDelta): DashboardSnapshot;
116
+ /**
117
+ * When the dashboard_snapshots table is empty, synthesize one row per day from
118
+ * existing memories. Idempotent (only runs when the table is empty — the
119
+ * caller gates on that). Returns the number of rows written.
120
+ *
121
+ * Rows are keyed with a SYNTHETIC ISO timestamp `<YYYY-MM-DD>T00:00:00.000Z`
122
+ * (start of the UTC day), NOT a `backfill-` string prefix. The column is a
123
+ * timestamp sort key everywhere it is read (nightly delta floor, series ASC,
124
+ * digest-day picker), so the value MUST sort like a real ISO timestamp. A
125
+ * `backfill-` prefix would sort AFTER every `2xxx-...` ISO value (`'b' 0x62 >
126
+ * '2' 0x32`), silently breaking the delta floor and the chart ordering. Using
127
+ * midnight-of-day means a real nightly for the same day (which runs later,
128
+ * e.g. 03:00) sorts AFTER its day's backfill row — correct chronological
129
+ * intent, and every `ORDER BY run_at` query is uniform with no special-casing.
130
+ *
131
+ * Derivation:
132
+ * - For each day D (UTC date of created_at), the row carries cumulative
133
+ * counts up to and including D (memories whose created_at <= end of D).
134
+ * - by_type / by_domain / by_source_agent are likewise cumulative slices.
135
+ * - new_this_run.added/dedup/supersession are derivable (row counts +
136
+ * timestamp aggregations); lessonsGenerated is NOT (a stage outcome) and
137
+ * stays undefined.
138
+ * - adoption is point-in-time and CANNOT be reconstructed from created_at,
139
+ * so backfilled rows OMIT it (the page treats undefined as "no data").
140
+ */
141
+ export declare function backfillSnapshots(db: Database.Database): number;
142
+ /**
143
+ * The pure data handler for GET /dashboard/data. Reads query params
144
+ * (`range`, `date`, `digestLimit`) off the request, returns the DashboardData
145
+ * payload. Never throws on empty/missing data — returns a valid empty shape.
146
+ *
147
+ * `config` is the saved config object (for `dashboardDigestLimit`); it is read
148
+ * DEFENSIVELY via readPositiveConfig (invalid → default + warn, never crash).
149
+ */
150
+ export declare function handleDashboardData(db: Database.Database, query: {
151
+ range?: unknown;
152
+ date?: unknown;
153
+ }, config: Record<string, unknown> | null | undefined): {
154
+ status: number;
155
+ body: DashboardData;
156
+ };
157
+ /**
158
+ * Express adapter for GET /dashboard/data. Wraps the pure handler so the
159
+ * route stays thin (same convention as context-store handlers in viz.ts).
160
+ * Failures surface as a 500 with the usual {error} shape — no silent degrade.
161
+ */
162
+ export declare function dashboardDataHandler(getDb: () => Database.Database, getConfig: () => Record<string, unknown> | null | undefined): express.RequestHandler;
@@ -0,0 +1,410 @@
1
+ "use strict";
2
+ /**
3
+ * /dashboard — view-only memory analytics (#224).
4
+ *
5
+ * STRICTLY view-only: this module computes metrics, reads snapshots, writes
6
+ * ONE snapshot row per full nightly run (the writer is here because the
7
+ * metric SQL lives next to its definition, not in nightly.ts), and exposes
8
+ * the pure data handler mounted at GET /dashboard/data. There are NO mutation
9
+ * endpoints on the dashboard surface — the only write path is the nightly
10
+ * snapshot writer + the one-time backfill, both internal.
11
+ *
12
+ * Layering (mirrors how recall-index.ts holds pure logic and viz.ts holds
13
+ * thin express adapters): all metric SQL + snapshot shape lives HERE so it
14
+ * can be unit-tested without booting express. viz.ts owns only the HTML
15
+ * shell handler + the auth exemption; mcp-server.ts wires both.
16
+ *
17
+ * Headline metric: uses-per-showing = SUM(access_count) / SUM(shown_count)
18
+ * across the corpus — the recall-quality signal (#192 adoption aggregate,
19
+ * promoted to a top-line metric here). Divide-by-zero → null (no showings
20
+ * means undefined, not zero).
21
+ */
22
+ Object.defineProperty(exports, "__esModule", { value: true });
23
+ exports.computeDashboardMetrics = computeDashboardMetrics;
24
+ exports.writeSnapshot = writeSnapshot;
25
+ exports.backfillSnapshots = backfillSnapshots;
26
+ exports.handleDashboardData = handleDashboardData;
27
+ exports.dashboardDataHandler = dashboardDataHandler;
28
+ const recall_index_js_1 = require("./recall-index.js");
29
+ const config_read_js_1 = require("./config-read.js");
30
+ // ---------------------------------------------------------------------------
31
+ // Metric computation — one SELECT each, prepared inline. Pure: takes a db,
32
+ // returns a value. No side effects, no I/O beyond the open db handle.
33
+ // ---------------------------------------------------------------------------
34
+ function countBy(db, col) {
35
+ // Column name is from a fixed allowlist at the call site (never user input).
36
+ const rows = db
37
+ .prepare(`SELECT COALESCE(${col}, '(unscoped)') AS k, COUNT(*) AS c
38
+ FROM memories
39
+ GROUP BY ${col}`)
40
+ .all();
41
+ const out = {};
42
+ for (const r of rows)
43
+ out[r.k] = r.c;
44
+ return out;
45
+ }
46
+ /**
47
+ * Compute the full corpus-shape metrics from the live DB. The same function
48
+ * backs both the nightly snapshot writer and the live /dashboard/data
49
+ * composition view — one definition of corpus shape.
50
+ */
51
+ function computeDashboardMetrics(db) {
52
+ const mem = db.prepare("SELECT COUNT(*) AS c FROM memories").get().c;
53
+ const lesson = db
54
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE memory_type = 'lesson'")
55
+ .get().c;
56
+ const link = db.prepare("SELECT COUNT(*) AS c FROM memory_links").get().c;
57
+ const adoptionRow = db
58
+ .prepare(`SELECT
59
+ COALESCE(SUM(shown_count), 0) AS shown,
60
+ COALESCE(SUM(access_count), 0) AS uses,
61
+ SUM(CASE WHEN COALESCE(shown_count, 0) = 0
62
+ AND COALESCE(access_count, 0) = 0 THEN 1 ELSE 0 END) AS cold
63
+ FROM memories`)
64
+ .get();
65
+ return {
66
+ totals: { mem, lesson, link },
67
+ by_type: countBy(db, "memory_type"),
68
+ by_domain: countBy(db, "domain"),
69
+ by_source_agent: countBy(db, "source_agent"),
70
+ adoption: {
71
+ shown_sum: adoptionRow.shown,
72
+ used_sum: adoptionRow.uses,
73
+ cold_count: adoptionRow.cold,
74
+ // Divide-by-zero guard: no showings → undefined adoption, not 0.
75
+ uses_per_showing: adoptionRow.shown > 0
76
+ ? Number((adoptionRow.uses / adoptionRow.shown).toFixed(4))
77
+ : null,
78
+ },
79
+ };
80
+ }
81
+ /**
82
+ * Write one snapshot row for `runAt` (an ISO timestamp the caller chooses —
83
+ * nightly.ts passes `now`). OR-replace on the PRIMARY KEY is intentional: a
84
+ * manual re-run for the same instant overwrites, the nightly never produces
85
+ * two rows for the same instant. Returns the row that was written.
86
+ */
87
+ function writeSnapshot(db, runAt, delta) {
88
+ const metrics = computeDashboardMetrics(db);
89
+ metrics.new_this_run = {
90
+ added: delta.added,
91
+ lessonsGenerated: delta.lessonsGenerated,
92
+ dedup: delta.dedup,
93
+ supersession: delta.supersession,
94
+ };
95
+ db.prepare("INSERT OR REPLACE INTO dashboard_snapshots (run_at, metrics) VALUES (?, ?)").run(runAt, JSON.stringify(metrics));
96
+ return { run_at: runAt, metrics };
97
+ }
98
+ // ---------------------------------------------------------------------------
99
+ // Backfill — synthesize one snapshot per day from memories.created_at so the
100
+ // growth/composition charts have history on day one. Adoption is point-in-time
101
+ // and CANNOT be reconstructed from created_at, so backfilled rows OMIT it
102
+ // (the page treats undefined as "no data for this day"). See backfillSnapshots
103
+ // for the full derivation.
104
+ // ---------------------------------------------------------------------------
105
+ function utcDay(iso) {
106
+ // created_at is ISO; slice the YYYY-MM-DD prefix. Best-effort — malformed
107
+ // rows fall to the '(unknown)' bucket (rare; created_at is NOT NULL by the
108
+ // insert contract, but legacy imports can carry odd shapes).
109
+ const d = iso.slice(0, 10);
110
+ return /^\d{4}-\d{2}-\d{2}$/.test(d) ? d : "(unknown)";
111
+ }
112
+ /**
113
+ * When the dashboard_snapshots table is empty, synthesize one row per day from
114
+ * existing memories. Idempotent (only runs when the table is empty — the
115
+ * caller gates on that). Returns the number of rows written.
116
+ *
117
+ * Rows are keyed with a SYNTHETIC ISO timestamp `<YYYY-MM-DD>T00:00:00.000Z`
118
+ * (start of the UTC day), NOT a `backfill-` string prefix. The column is a
119
+ * timestamp sort key everywhere it is read (nightly delta floor, series ASC,
120
+ * digest-day picker), so the value MUST sort like a real ISO timestamp. A
121
+ * `backfill-` prefix would sort AFTER every `2xxx-...` ISO value (`'b' 0x62 >
122
+ * '2' 0x32`), silently breaking the delta floor and the chart ordering. Using
123
+ * midnight-of-day means a real nightly for the same day (which runs later,
124
+ * e.g. 03:00) sorts AFTER its day's backfill row — correct chronological
125
+ * intent, and every `ORDER BY run_at` query is uniform with no special-casing.
126
+ *
127
+ * Derivation:
128
+ * - For each day D (UTC date of created_at), the row carries cumulative
129
+ * counts up to and including D (memories whose created_at <= end of D).
130
+ * - by_type / by_domain / by_source_agent are likewise cumulative slices.
131
+ * - new_this_run.added/dedup/supersession are derivable (row counts +
132
+ * timestamp aggregations); lessonsGenerated is NOT (a stage outcome) and
133
+ * stays undefined.
134
+ * - adoption is point-in-time and CANNOT be reconstructed from created_at,
135
+ * so backfilled rows OMIT it (the page treats undefined as "no data").
136
+ */
137
+ function backfillSnapshots(db) {
138
+ const existing = db.prepare("SELECT COUNT(*) AS c FROM dashboard_snapshots").get().c;
139
+ if (existing > 0)
140
+ return 0; // never overwrite real history
141
+ const rows = db
142
+ .prepare("SELECT created_at, memory_type, domain, source_agent FROM memories ORDER BY created_at ASC")
143
+ .all();
144
+ // Daily dedup merges + supersession links (timestamp-derived → aggregable).
145
+ const dedupByDay = new Map();
146
+ const dedupRows = db
147
+ .prepare("SELECT merged_at FROM dedup_log")
148
+ .all();
149
+ for (const r of dedupRows) {
150
+ const d = utcDay(r.merged_at);
151
+ dedupByDay.set(d, (dedupByDay.get(d) ?? 0) + 1);
152
+ }
153
+ const superByDay = new Map();
154
+ const superRows = db
155
+ .prepare("SELECT created_at FROM memory_links WHERE relationship = 'superseded_by'")
156
+ .all();
157
+ for (const r of superRows) {
158
+ const d = utcDay(r.created_at);
159
+ superByDay.set(d, (superByDay.get(d) ?? 0) + 1);
160
+ }
161
+ // Accumulate.
162
+ let mem = 0;
163
+ let lesson = 0;
164
+ let link = 0;
165
+ const byType = {};
166
+ const byDomain = {};
167
+ const byAgent = {};
168
+ // Cumulative link count — memory_links has no created_at? It DOES (schema
169
+ // line 122). Aggregate the same way as memories.
170
+ const linkRows = db
171
+ .prepare("SELECT created_at FROM memory_links ORDER BY created_at ASC")
172
+ .all();
173
+ const linkDays = new Map();
174
+ for (const r of linkRows) {
175
+ const d = utcDay(r.created_at);
176
+ linkDays.set(d, (linkDays.get(d) ?? 0) + 1);
177
+ }
178
+ // Group memory rows by day, preserve ascending order.
179
+ const byDay = new Map();
180
+ for (const r of rows) {
181
+ const d = utcDay(r.created_at);
182
+ if (!byDay.has(d))
183
+ byDay.set(d, []);
184
+ byDay.get(d).push(r);
185
+ }
186
+ const allDays = Array.from(byDay.keys()).sort();
187
+ if (allDays.length === 0)
188
+ return 0; // nothing to backfill
189
+ const insert = db.prepare("INSERT OR REPLACE INTO dashboard_snapshots (run_at, metrics) VALUES (?, ?)");
190
+ // Walk days in order; each day's snapshot is cumulative THROUGH that day.
191
+ // We include every memory's day — a backfill row only exists for a day with
192
+ // at least one memory (the chart interpolates visually between sparse days).
193
+ const tx = db.transaction(() => {
194
+ for (const d of allDays) {
195
+ const dayRows = byDay.get(d);
196
+ for (const r of dayRows) {
197
+ mem++;
198
+ if (r.memory_type === "lesson")
199
+ lesson++;
200
+ byType[r.memory_type] = (byType[r.memory_type] ?? 0) + 1;
201
+ const domKey = r.domain ?? "(unscoped)";
202
+ byDomain[domKey] = (byDomain[domKey] ?? 0) + 1;
203
+ byAgent[r.source_agent] = (byAgent[r.source_agent] ?? 0) + 1;
204
+ }
205
+ link += linkDays.get(d) ?? 0;
206
+ const metrics = {
207
+ totals: { mem, lesson, link },
208
+ by_type: { ...byType },
209
+ by_domain: { ...byDomain },
210
+ by_source_agent: { ...byAgent },
211
+ // new_this_run on a backfill row = the deltas DERIVED for that day
212
+ // (added/lesson/dedup/supersession); lessonsGenerated is undefined
213
+ // (it's a stage-outcome, not a row count — can't be reconstructed).
214
+ new_this_run: {
215
+ added: dayRows.length,
216
+ dedup: dedupByDay.get(d) ?? 0,
217
+ supersession: superByDay.get(d) ?? 0,
218
+ },
219
+ // adoption intentionally omitted — point-in-time, not derivable.
220
+ };
221
+ insert.run(`${d}T00:00:00.000Z`, JSON.stringify(metrics));
222
+ }
223
+ });
224
+ tx();
225
+ return allDays.length;
226
+ }
227
+ // ---------------------------------------------------------------------------
228
+ // Data query — reads the snapshot series for the selected range, computes the
229
+ // LIVE composition (so day-one, before any snapshot is written, still shows
230
+ // the current corpus shape), and builds the digest for the selected day.
231
+ // ---------------------------------------------------------------------------
232
+ const VALID_RANGES = new Set(["7d", "30d", "90d", "all"]);
233
+ const DEFAULT_RANGE = "30d";
234
+ const DEFAULT_DIGEST_LIMIT = 10;
235
+ function rangeToCutoff(range) {
236
+ if (range === "all")
237
+ return null;
238
+ const days = parseInt(range, 10);
239
+ if (!Number.isFinite(days))
240
+ return null;
241
+ const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000);
242
+ return cutoff.toISOString();
243
+ }
244
+ /**
245
+ * Render a memory row the SAME way the recall index does — imported directly
246
+ * from recall-index.ts, never reimplemented. This is an acceptance criterion:
247
+ * the dashboard cannot drift from what agents see. Returns the rendered line
248
+ * plus the row's bare fields (the page links the id to /memory?id= and /viz).
249
+ */
250
+ function renderIndexLine(row, maxLen) {
251
+ // Shape matches MemorySearchResult & { domain } — formatIndexLine reads only
252
+ // these fields. access_count / connections / score are not used by the
253
+ // renderer (only provenance + title), so stubbing them is safe.
254
+ const line = (0, recall_index_js_1.formatIndexLine)({
255
+ id: row.id,
256
+ content: row.content,
257
+ score: 0,
258
+ effective_strength: 0,
259
+ access_count: 0,
260
+ memory_type: row.memory_type,
261
+ project: row.project,
262
+ domain: row.domain,
263
+ source_agent: row.source_agent,
264
+ created_at: row.created_at,
265
+ connections: 0,
266
+ }, maxLen);
267
+ return { id: row.id, line, created_at: row.created_at };
268
+ }
269
+ /**
270
+ * The pure data handler for GET /dashboard/data. Reads query params
271
+ * (`range`, `date`, `digestLimit`) off the request, returns the DashboardData
272
+ * payload. Never throws on empty/missing data — returns a valid empty shape.
273
+ *
274
+ * `config` is the saved config object (for `dashboardDigestLimit`); it is read
275
+ * DEFENSIVELY via readPositiveConfig (invalid → default + warn, never crash).
276
+ */
277
+ function handleDashboardData(db, query, config) {
278
+ const rangeParam = typeof query.range === "string" && VALID_RANGES.has(query.range)
279
+ ? query.range
280
+ : DEFAULT_RANGE;
281
+ const digestLimit = (0, config_read_js_1.readPositiveConfig)(config ?? {}, "dashboardDigestLimit", DEFAULT_DIGEST_LIMIT);
282
+ // Series: snapshots within the range window.
283
+ const cutoff = rangeToCutoff(rangeParam);
284
+ const seriesRows = cutoff
285
+ ? db
286
+ .prepare("SELECT run_at, metrics FROM dashboard_snapshots WHERE run_at >= ? ORDER BY run_at ASC")
287
+ .all(cutoff)
288
+ : db
289
+ .prepare("SELECT run_at, metrics FROM dashboard_snapshots ORDER BY run_at ASC")
290
+ .all();
291
+ const series = seriesRows.map((r) => ({
292
+ run_at: r.run_at,
293
+ metrics: JSON.parse(r.metrics),
294
+ }));
295
+ // Live composition (so day-one with no snapshots still shows the corpus).
296
+ const live = computeDashboardMetrics(db);
297
+ // Headline = live corpus (the chart shows history; the headline shows now).
298
+ const headline = {
299
+ total_memories: live.totals.mem,
300
+ uses_per_showing: live.adoption?.uses_per_showing ?? null,
301
+ cold_count: live.adoption?.cold_count ?? 0,
302
+ };
303
+ // Digest: pick the day to summarize. `date` (YYYY-MM-DD) wins; else the most
304
+ // recent snapshot's day (real nightly OR backfill — both carry valid ISO
305
+ // run_at since backfill rows use a synthetic midnight timestamp); else today.
306
+ let dateStr = null;
307
+ if (typeof query.date === "string" && /^\d{4}-\d{2}-\d{2}$/.test(query.date)) {
308
+ dateStr = query.date;
309
+ }
310
+ else {
311
+ const last = db
312
+ .prepare("SELECT run_at FROM dashboard_snapshots ORDER BY run_at DESC LIMIT 1")
313
+ .get();
314
+ dateStr = last ? last.run_at.slice(0, 10) : new Date().toISOString().slice(0, 10);
315
+ }
316
+ const dayStart = `${dateStr}T00:00:00.000Z`;
317
+ const dayEnd = `${dateStr}T23:59:59.999Z`;
318
+ // Sample of memories created that day, rendered via the production index
319
+ // line renderer. Ordered by created_at so the page is stable across reloads.
320
+ const sampleRows = db
321
+ .prepare(`SELECT id, content, created_at, domain, project, source_agent, memory_type
322
+ FROM memories
323
+ WHERE created_at BETWEEN ? AND ?
324
+ ORDER BY created_at ASC
325
+ LIMIT ?`)
326
+ .all(dayStart, dayEnd, digestLimit);
327
+ // Render each sample through the production index line at its DEFAULT title
328
+ // length (100) — the issue spec: the digest matches what agents see, so the
329
+ // title truncation is identical, not dashboard-specific.
330
+ const sample = sampleRows.map((r) => renderIndexLine(r, 100));
331
+ // Lessons created that day — full content (the issue says "full text").
332
+ const lessonRows = db
333
+ .prepare(`SELECT id, content, created_at
334
+ FROM memories
335
+ WHERE memory_type = 'lesson' AND created_at BETWEEN ? AND ?
336
+ ORDER BY created_at ASC`)
337
+ .all(dayStart, dayEnd);
338
+ // Stage outcomes for the day: dedup merges + supersession links that day.
339
+ const dedupRows = db
340
+ .prepare(`SELECT loser_id, canonical_id, content_head, merged_at
341
+ FROM dedup_log
342
+ WHERE merged_at BETWEEN ? AND ?
343
+ ORDER BY merged_at ASC`)
344
+ .all(dayStart, dayEnd);
345
+ const supersessionCount = db
346
+ .prepare(`SELECT COUNT(*) AS c FROM memory_links
347
+ WHERE relationship = 'superseded_by'
348
+ AND created_at BETWEEN ? AND ?`)
349
+ .get(dayStart, dayEnd).c;
350
+ // Try to find the night's snapshot for lessonsGenerated + the real added
351
+ // count (new_this_run.added reflects the distill count for that run, a more
352
+ // faithful signal than created_at when the snapshot exists). One query for
353
+ // both run_at and metrics.
354
+ const daySnap = db
355
+ .prepare(`SELECT run_at, metrics FROM dashboard_snapshots
356
+ WHERE run_at BETWEEN ? AND ?
357
+ ORDER BY run_at DESC LIMIT 1`)
358
+ .get(dayStart, dayEnd);
359
+ const dayMetrics = daySnap
360
+ ? JSON.parse(daySnap.metrics)
361
+ : undefined;
362
+ const digest = {
363
+ date: dateStr,
364
+ run_at: daySnap ? daySnap.run_at : null,
365
+ sample,
366
+ lessons: lessonRows,
367
+ stages: {
368
+ lessonsGenerated: dayMetrics?.new_this_run?.lessonsGenerated,
369
+ dedup: dayMetrics?.new_this_run?.dedup ?? dedupRows.length,
370
+ supersession: dayMetrics?.new_this_run?.supersession ?? supersessionCount,
371
+ added: dayMetrics?.new_this_run?.added ?? sampleRows.length,
372
+ },
373
+ dedup_merges: dedupRows.map((r) => ({
374
+ loser_id: r.loser_id,
375
+ canonical_id: r.canonical_id,
376
+ content_head: r.content_head,
377
+ merged_at: r.merged_at,
378
+ })),
379
+ };
380
+ return {
381
+ status: 200,
382
+ body: {
383
+ range: rangeParam,
384
+ headline,
385
+ series,
386
+ composition: {
387
+ by_type: live.by_type,
388
+ by_domain: live.by_domain,
389
+ by_source_agent: live.by_source_agent,
390
+ },
391
+ digest,
392
+ },
393
+ };
394
+ }
395
+ /**
396
+ * Express adapter for GET /dashboard/data. Wraps the pure handler so the
397
+ * route stays thin (same convention as context-store handlers in viz.ts).
398
+ * Failures surface as a 500 with the usual {error} shape — no silent degrade.
399
+ */
400
+ function dashboardDataHandler(getDb, getConfig) {
401
+ return (req, res) => {
402
+ try {
403
+ const { status, body } = handleDashboardData(getDb(), req.query, getConfig());
404
+ res.status(status).json(body);
405
+ }
406
+ catch (err) {
407
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
408
+ }
409
+ };
410
+ }
package/dist/db.js CHANGED
@@ -464,6 +464,30 @@ const MIGRATIONS = [
464
464
  }
465
465
  },
466
466
  },
467
+ {
468
+ version: 12,
469
+ name: "add_dashboard_snapshots",
470
+ up: (db) => {
471
+ // #224 memory analytics dashboard. One row per FULL nightly run (never
472
+ // capture-only — the snapshot reflects corpus state, so it is written
473
+ // from the same !dryRun && !captureOnly block that runs consolidation).
474
+ // `run_at` is the ISO timestamp of the run (PRIMARY KEY so a re-run for
475
+ // the same instant is idempotent — the nightly never retries within one
476
+ // process, and a manual re-run uses a fresh `now`). `metrics` is a JSON
477
+ // blob (not typed columns): the metric set is meant to evolve without a
478
+ // migration (add a key, ship), so the schema stays one column. The
479
+ // backfill (dashboard.ts:backfillSnapshots) also writes here, one row
480
+ // per derived day. Adoption fields are point-in-time and can't be
481
+ // reconstructed from created_at — backfilled rows leave them null.
482
+ // IF NOT EXISTS keeps it idempotent across partial migrations.
483
+ db.exec(`
484
+ CREATE TABLE IF NOT EXISTS dashboard_snapshots (
485
+ run_at TEXT PRIMARY KEY,
486
+ metrics TEXT NOT NULL
487
+ )
488
+ `);
489
+ },
490
+ },
467
491
  ];
468
492
  /**
469
493
  * Run all pending migrations against the database.
@@ -14,15 +14,24 @@
14
14
  * readable here nightly. No runtime plugin capture is needed; the Hermes plugin
15
15
  * is recall-only.
16
16
  *
17
- * We process only ENDED sessions (ended_at set) that ended since the last run.
18
- * A live session is distilled after it ends this avoids partial-session
19
- * distillation and keeps per-session dedup clean (chunks are stored as
20
- * `<sessionId>#<chunkIndex>`; see nightly.ts).
17
+ * We process ENDED sessions (ended_at set) that ended since the last run PLUS
18
+ * OPEN sessions (ended_at NULL) that have messagesa Discord conversation is
19
+ * one long-lived thread that stays open and accrues content, so gating only on
20
+ * ended_at would drop the whole interactive corpus (#240). Open sessions are
21
+ * delta-sliced by the per-session cursor; per-session dedup stays clean
22
+ * (chunks are stored as `<sessionId>#<chunkIndex>`; see nightly.ts).
21
23
  */
22
24
  import type { TranscriptBatch, CursorMap } from "./transcript-reader.js";
23
25
  /**
24
- * Read Hermes sessions that ended since `since`, across all profiles.
25
- * Returns one batch per session, parallel to readCcTranscripts().
26
+ * Read Hermes sessions across all profiles: ended sessions newer than `since`
27
+ * (the bulk watermark) PLUS open sessions (`ended_at IS NULL`) that have any
28
+ * message. Returns one batch per session, parallel to readCcTranscripts().
29
+ *
30
+ * Why open sessions are included (#240): in Hermes' Discord model a
31
+ * conversation is one long-lived thread = one session that stays open for
32
+ * days/weeks and accumulates the interactive content. The per-session cursor
33
+ * slices each such thread to its unseen delta across runs (same discover-
34
+ * broadly / delta-narrowly model as CC readers).
26
35
  *
27
36
  * @param cursors Per-session capture cursors (#189), keyed `hermes:<profile>:<sid>`.
28
37
  * The cursor value is the max `messages.id` already captured; a resumed +