@gamaze/hicortex 0.16.9 → 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.
@@ -8,6 +8,18 @@ import type { Memory, ConsolidationReport } from "./types.js";
8
8
  import type { LlmClient } from "./llm.js";
9
9
  import type { EmbedFn } from "./retrieval.js";
10
10
  import { type DomainDef } from "./domain-classify.js";
11
+ /**
12
+ * Default ceiling on total LLM calls across all classify-tier consolidation
13
+ * stages (content-domain, link discovery, supersession) per run. This is a
14
+ * runaway BACKSTOP, not a throughput throttle — on a free local model there is
15
+ * no per-call cost to defend against; the binding constraint is the nightly
16
+ * unit's wall-clock timeout (TimeoutStartSec), not call count. 5000 clears a
17
+ * one-time classification backlog (a ~2000-memory batch drains in ~1-2 runs
18
+ * instead of ~11 nights at the old 200) with margin for link/supersession, and
19
+ * ~5000 calls x ~1-3s/call ≈ 1.4-4.2h fits the 6h consolidation backstop.
20
+ * Config-overridable as `consolidateMaxLlmCalls` (#241).
21
+ */
22
+ export declare const CONSOLIDATE_MAX_LLM_CALLS = 5000;
11
23
  /**
12
24
  * Minimum COSINE similarity for a link candidate.
13
25
  *
@@ -216,7 +228,11 @@ export interface DomainStageOptions {
216
228
  */
217
229
  weakPrimaryFloor?: number;
218
230
  }
219
- export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions, supersessionOptions?: SupersessionOptions): Promise<ConsolidationReport>;
231
+ export declare function runConsolidation(db: Database.Database, llm: LlmClient, embedFn: EmbedFn, dryRun?: boolean, skipReflection?: boolean, stateDir?: string, domainOptions?: DomainStageOptions, supersessionOptions?: SupersessionOptions,
232
+ /** Total LLM-call ceiling across classify-tier stages (#241). The caller
233
+ * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
234
+ * exported `CONSOLIDATE_MAX_LLM_CALLS` default (5000). */
235
+ budgetMaxCalls?: number): Promise<ConsolidationReport>;
220
236
  /**
221
237
  * Calculate milliseconds until the next occurrence of a given hour (local time).
222
238
  */
@@ -38,7 +38,7 @@ var __importStar = (this && this.__importStar) || (function () {
38
38
  };
39
39
  })();
40
40
  Object.defineProperty(exports, "__esModule", { value: true });
41
- exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = void 0;
41
+ exports.DEFAULT_SUPERSESSION_MAX_CALLS = exports.DEFAULT_SUPERSESSION_MIN_SIMILARITY = exports.BudgetTracker = exports.REFLECTION_CONTRADICTION_MIN_COSINE = exports.l2ToCosine = exports.CROSS_PROJECT_LINK_THRESHOLD = exports.CONSOLIDATE_LINK_TOP_K = exports.CONSOLIDATE_LINK_THRESHOLD = exports.CONSOLIDATE_MAX_LLM_CALLS = void 0;
42
42
  exports.isContradictionCandidate = isContradictionCandidate;
43
43
  exports.parseJsonLenient = parseJsonLenient;
44
44
  exports.rebuildContentModuleIndex = rebuildContentModuleIndex;
@@ -63,7 +63,18 @@ const domain_classify_js_1 = require("./domain-classify.js");
63
63
  const schema_prototypes_js_1 = require("./schema-prototypes.js");
64
64
  const nofit_js_1 = require("./nofit.js");
65
65
  // Default config constants (matching Python config.py)
66
- const CONSOLIDATE_MAX_LLM_CALLS = 200;
66
+ /**
67
+ * Default ceiling on total LLM calls across all classify-tier consolidation
68
+ * stages (content-domain, link discovery, supersession) per run. This is a
69
+ * runaway BACKSTOP, not a throughput throttle — on a free local model there is
70
+ * no per-call cost to defend against; the binding constraint is the nightly
71
+ * unit's wall-clock timeout (TimeoutStartSec), not call count. 5000 clears a
72
+ * one-time classification backlog (a ~2000-memory batch drains in ~1-2 runs
73
+ * instead of ~11 nights at the old 200) with margin for link/supersession, and
74
+ * ~5000 calls x ~1-3s/call ≈ 1.4-4.2h fits the 6h consolidation backstop.
75
+ * Config-overridable as `consolidateMaxLlmCalls` (#241).
76
+ */
77
+ exports.CONSOLIDATE_MAX_LLM_CALLS = 5000;
67
78
  const CONSOLIDATE_PRUNE_MIN_AGE_DAYS = 90;
68
79
  /**
69
80
  * Minimum COSINE similarity for a link candidate.
@@ -1100,7 +1111,11 @@ function stageDecayPrune(db, dryRun) {
1100
1111
  }
1101
1112
  return { candidates, pruned, failed };
1102
1113
  }
1103
- async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions) {
1114
+ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection = false, stateDir, domainOptions, supersessionOptions,
1115
+ /** Total LLM-call ceiling across classify-tier stages (#241). The caller
1116
+ * reads `consolidateMaxLlmCalls` from config and passes it; unset → the
1117
+ * exported `CONSOLIDATE_MAX_LLM_CALLS` default (5000). */
1118
+ budgetMaxCalls) {
1104
1119
  const start = new Date();
1105
1120
  const report = {
1106
1121
  started_at: start.toISOString(),
@@ -1131,7 +1146,10 @@ async function runConsolidation(db, llm, embedFn, dryRun = false, skipReflection
1131
1146
  report.completed_at = new Date().toISOString();
1132
1147
  return report;
1133
1148
  }
1134
- const budget = new BudgetTracker(CONSOLIDATE_MAX_LLM_CALLS);
1149
+ // Config-overridable total LLM-call ceiling (#241). Default 5000 (was 200) —
1150
+ // see CONSOLIDATE_MAX_LLM_CALLS. The caller reads `consolidateMaxLlmCalls`
1151
+ // from config and passes it here.
1152
+ const budget = new BudgetTracker(budgetMaxCalls ?? exports.CONSOLIDATE_MAX_LLM_CALLS);
1135
1153
  try {
1136
1154
  // Stage 2: Importance Scoring
1137
1155
  report.stages.importance = await stageImportance(db, scoreMemories, llm, budget, dryRun);
@@ -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
+ }