@gamaze/hicortex 0.20.9 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +8 -0
  2. package/assets/dashboard.html +4174 -835
  3. package/dist/calibration.d.ts +119 -0
  4. package/dist/calibration.js +149 -1
  5. package/dist/capture-health.d.ts +87 -0
  6. package/dist/capture-health.js +106 -0
  7. package/dist/capture-pause.d.ts +86 -0
  8. package/dist/capture-pause.js +127 -0
  9. package/dist/capture.d.ts +9 -0
  10. package/dist/capture.js +2 -1
  11. package/dist/cli.js +36 -0
  12. package/dist/consolidate.d.ts +35 -0
  13. package/dist/consolidate.js +85 -9
  14. package/dist/dashboard.d.ts +322 -3
  15. package/dist/dashboard.js +592 -7
  16. package/dist/db.js +105 -0
  17. package/dist/eval/importance-eval.d.ts +85 -0
  18. package/dist/eval/importance-eval.js +286 -0
  19. package/dist/eval/planted-fixtures.d.ts +1 -1
  20. package/dist/eval/ranking-battery.d.ts +78 -0
  21. package/dist/eval/ranking-battery.js +181 -0
  22. package/dist/eval/ranking-eval.d.ts +41 -0
  23. package/dist/eval/ranking-eval.js +391 -0
  24. package/dist/eval/ranking-fixtures.d.ts +77 -0
  25. package/dist/eval/ranking-fixtures.js +226 -0
  26. package/dist/identity-store.d.ts +21 -0
  27. package/dist/identity-store.js +49 -0
  28. package/dist/init.d.ts +14 -0
  29. package/dist/init.js +32 -0
  30. package/dist/mcp-server.d.ts +12 -0
  31. package/dist/mcp-server.js +184 -3
  32. package/dist/nightly.d.ts +9 -1
  33. package/dist/nightly.js +59 -7
  34. package/dist/prompts.d.ts +10 -0
  35. package/dist/prompts.js +28 -5
  36. package/dist/reconsolidation.d.ts +59 -30
  37. package/dist/reconsolidation.js +526 -296
  38. package/dist/rescore-importance.d.ts +80 -0
  39. package/dist/rescore-importance.js +236 -0
  40. package/dist/retrieval.d.ts +12 -0
  41. package/dist/retrieval.js +30 -1
  42. package/dist/stages.d.ts +37 -0
  43. package/dist/stages.js +51 -0
  44. package/dist/state.d.ts +32 -6
  45. package/dist/storage.d.ts +34 -2
  46. package/dist/storage.js +63 -6
  47. package/dist/types.d.ts +48 -0
  48. package/package.json +3 -1
package/dist/dashboard.js CHANGED
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  /**
3
- * /dashboard — view-only memory analytics (#224).
3
+ * /dashboard — view-only memory analytics (#224) + the console's live-data
4
+ * endpoints (#409/#421 Phase 1: /dashboard/field, /dashboard/events).
4
5
  *
5
6
  * STRICTLY view-only: this module computes metrics, reads snapshots, writes
6
7
  * ONE snapshot row per full nightly run (the writer is here because the
7
8
  * 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
+ * the pure data handlers mounted at GET /dashboard/data,
10
+ * GET /dashboard/field and GET /dashboard/events. There are NO mutation
9
11
  * endpoints on the dashboard surface — the only write path is the nightly
10
12
  * snapshot writer + the one-time backfill, both internal.
11
13
  *
@@ -21,27 +23,44 @@
21
23
  */
22
24
  Object.defineProperty(exports, "__esModule", { value: true });
23
25
  exports.computeDashboardMetrics = computeDashboardMetrics;
26
+ exports.computeStageCounts = computeStageCounts;
24
27
  exports.writeSnapshot = writeSnapshot;
25
28
  exports.backfillSnapshots = backfillSnapshots;
26
29
  exports.handleDashboardData = handleDashboardData;
27
30
  exports.dashboardDataHandler = dashboardDataHandler;
28
31
  exports.accountHandler = accountHandler;
29
32
  exports.accountTokenHandler = accountTokenHandler;
33
+ exports.handleDashboardField = handleDashboardField;
34
+ exports.dashboardFieldHandler = dashboardFieldHandler;
35
+ exports.handleDashboardEvents = handleDashboardEvents;
36
+ exports.dashboardEventsHandler = dashboardEventsHandler;
37
+ exports.handleDashboardModelGet = handleDashboardModelGet;
38
+ exports.handleDashboardModelPut = handleDashboardModelPut;
39
+ exports.dashboardModelGetHandler = dashboardModelGetHandler;
40
+ exports.dashboardModelPutHandler = dashboardModelPutHandler;
41
+ exports.handleDashboardCapturePausePut = handleDashboardCapturePausePut;
42
+ exports.dashboardCapturePausePutHandler = dashboardCapturePausePutHandler;
43
+ const node_zlib_1 = require("node:zlib");
30
44
  const recall_index_js_1 = require("./recall-index.js");
31
45
  const config_read_js_1 = require("./config-read.js");
32
46
  const consolidate_js_1 = require("./consolidate.js");
47
+ const capture_health_js_1 = require("./capture-health.js");
48
+ const capture_pause_js_1 = require("./capture-pause.js");
33
49
  const state_js_1 = require("./state.js");
50
+ const retrieval_js_1 = require("./retrieval.js");
51
+ const stages_js_1 = require("./stages.js");
52
+ const calibration_js_1 = require("./calibration.js");
34
53
  // ---------------------------------------------------------------------------
35
54
  // Metric computation — one SELECT each, prepared inline. Pure: takes a db,
36
55
  // returns a value. No side effects, no I/O beyond the open db handle.
37
56
  // ---------------------------------------------------------------------------
38
- function countBy(db, col) {
57
+ function countBy(db, col, nullLabel = "(unscoped)") {
39
58
  // Column name is from a fixed allowlist at the call site (never user input).
40
59
  const rows = db
41
- .prepare(`SELECT COALESCE(${col}, '(unscoped)') AS k, COUNT(*) AS c
60
+ .prepare(`SELECT COALESCE(${col}, ?) AS k, COUNT(*) AS c
42
61
  FROM memories
43
62
  GROUP BY ${col}`)
44
- .all();
63
+ .all(nullLabel);
45
64
  const out = {};
46
65
  for (const r of rows)
47
66
  out[r.k] = r.c;
@@ -50,10 +69,20 @@ function countBy(db, col) {
50
69
  /**
51
70
  * Compute the full corpus-shape metrics from the live DB. The same function
52
71
  * backs both the nightly snapshot writer and the live /dashboard/data
53
- * composition view — one definition of corpus shape.
72
+ * composition view — one definition of corpus shape. #422 adds the
73
+ * live/absorbed split (`totals.live_mem`/`absorbed`) and `stage_counts` —
74
+ * both derived here so every snapshot row carries them automatically.
54
75
  */
55
76
  function computeDashboardMetrics(db) {
56
77
  const mem = db.prepare("SELECT COUNT(*) AS c FROM memories").get().c;
78
+ // LIVE = non-absorbed (the same predicate as /dashboard/field and the cap
79
+ // eviction stage — #317's one-definition discipline). Absorbed rows are
80
+ // retained evidence, invisible to recall; they must not read as "memories
81
+ // the brain has" in the console's headline (the field paints live only, so
82
+ // the band number and the field must agree).
83
+ const liveMem = db
84
+ .prepare("SELECT COUNT(*) AS c FROM memories WHERE COALESCE(status, '') != 'absorbed'")
85
+ .get().c;
57
86
  const lesson = db
58
87
  .prepare("SELECT COUNT(*) AS c FROM memories WHERE memory_type = 'learnings'")
59
88
  .get().c;
@@ -67,10 +96,12 @@ function computeDashboardMetrics(db) {
67
96
  FROM memories`)
68
97
  .get();
69
98
  return {
70
- totals: { mem, lesson, link },
99
+ totals: { mem, lesson, link, live_mem: liveMem, absorbed: mem - liveMem },
100
+ stage_counts: computeStageCounts(db),
71
101
  by_type: countBy(db, "memory_type"),
72
102
  by_domain: countBy(db, "domain"),
73
103
  by_source_agent: countBy(db, "source_agent"),
104
+ by_source_machine: countBy(db, "source_machine", "(unstamped)"),
74
105
  adoption: {
75
106
  shown_sum: adoptionRow.shown,
76
107
  used_sum: adoptionRow.uses,
@@ -82,6 +113,65 @@ function computeDashboardMetrics(db) {
82
113
  },
83
114
  };
84
115
  }
116
+ /**
117
+ * One grouped pass over memory_links for per-memory link counts (batch —
118
+ * never per-row queries); shared by the field payload and stage_counts.
119
+ * Counts BOTH directions (a memory's connectivity is symmetric).
120
+ */
121
+ function linkCountsByMemory(db) {
122
+ const rows = db
123
+ .prepare(`SELECT id, SUM(c) AS c FROM (
124
+ SELECT source_id AS id, COUNT(*) AS c FROM memory_links GROUP BY source_id
125
+ UNION ALL
126
+ SELECT target_id AS id, COUNT(*) AS c FROM memory_links GROUP BY target_id
127
+ ) GROUP BY id`)
128
+ .all();
129
+ const linkCounts = new Map();
130
+ for (const r of rows)
131
+ linkCounts.set(r.id, r.c);
132
+ return linkCounts;
133
+ }
134
+ /**
135
+ * Derive one memory's {strength, stage} — the exact math of the field
136
+ * payload: effectiveStrength (importance = base, access + link hardening)
137
+ * rounded to the 1e-6 wire format, then the recency gate (days since
138
+ * last_accessed, falling back to created_at; unparseable → null, the gate is
139
+ * skipped rather than guessing "very old") and the calibrated strength bands.
140
+ */
141
+ function deriveStageForRow(row, linkCount, now, nowDay) {
142
+ const base = row.base_strength ?? 0.5;
143
+ const accessCount = row.access_count ?? 0;
144
+ const effStr = (0, retrieval_js_1.effectiveStrength)(base, row.last_accessed, now, {
145
+ importance: base,
146
+ accessCount,
147
+ linkCount,
148
+ });
149
+ const strength = Math.round(effStr * 1e6) / 1e6;
150
+ const refDay = utcDayNumber(row.last_accessed) ?? utcDayNumber(row.created_at);
151
+ const daysSince = refDay === null ? null : nowDay - refDay;
152
+ return { strength, stage: (0, stages_js_1.deriveStage)(strength, daysSince) };
153
+ }
154
+ /**
155
+ * Count LIVE (non-absorbed) memories per derived stage — the snapshot's
156
+ * `stage_counts` (#422 Phase 2). Same math as the field payload via the
157
+ * shared deriveStageForRow; absorbed rows are excluded exactly like the
158
+ * field paints them (invisible evidence is not a maturity stage).
159
+ */
160
+ function computeStageCounts(db) {
161
+ const now = new Date();
162
+ const nowDay = Math.floor(now.getTime() / 86_400_000);
163
+ const linkCounts = linkCountsByMemory(db);
164
+ const rows = db
165
+ .prepare(`SELECT id, base_strength, last_accessed, access_count, created_at
166
+ FROM memories
167
+ WHERE COALESCE(status, '') != 'absorbed'`)
168
+ .all();
169
+ const counts = { forming: 0, belief: 0, truth: 0, fading: 0 };
170
+ for (const r of rows) {
171
+ counts[deriveStageForRow(r, linkCounts.get(r.id) ?? 0, now, nowDay).stage]++;
172
+ }
173
+ return counts;
174
+ }
85
175
  /**
86
176
  * Write one snapshot row for `runAt` (an ISO timestamp the caller chooses —
87
177
  * nightly.ts passes `now`). OR-replace on the PRIMARY KEY is intentional: a
@@ -153,6 +243,16 @@ function writeSnapshot(db, runAt, delta, memorySoftCap) {
153
243
  },
154
244
  }
155
245
  : {}),
246
+ // #427: scout counters — flat snake_case mirroring the stage report,
247
+ // forwarded whenever consolidation ran (zeros are real quiet-night
248
+ // values). Backfill rows never reach this writer with them set.
249
+ ...(delta.scoutScanned !== undefined ? { scout_scanned: delta.scoutScanned } : {}),
250
+ ...(delta.scoutCorrectionShaped !== undefined
251
+ ? { scout_correction_shaped: delta.scoutCorrectionShaped }
252
+ : {}),
253
+ ...(delta.scoutCandidatesFound !== undefined
254
+ ? { scout_candidates_found: delta.scoutCandidatesFound }
255
+ : {}),
156
256
  };
157
257
  if (memorySoftCap !== undefined) {
158
258
  metrics.capacity = { memory_soft_cap: memorySoftCap };
@@ -273,6 +373,9 @@ function backfillSnapshots(db) {
273
373
  by_type: { ...byType },
274
374
  by_domain: { ...byDomain },
275
375
  by_source_agent: { ...byAgent },
376
+ // Backfilled history predates stamping by construction — every
377
+ // synthesized row is honestly "(unstamped)" (#421).
378
+ by_source_machine: { "(unstamped)": mem },
276
379
  // new_this_run on a backfill row = the deltas DERIVED for that day
277
380
  // (added/lesson/dedup/supersession); lessonsGenerated is undefined
278
381
  // (it's a stage-outcome, not a row count — can't be reconstructed).
@@ -374,6 +477,10 @@ function handleDashboardData(db, query, config) {
374
477
  const tokenState = (0, state_js_1.loadState)().llmTokensThisPeriod;
375
478
  const headline = {
376
479
  total_memories: live.totals.mem,
480
+ // #422: the field paints live rows only, so the band number must too —
481
+ // this closes the "console says 574 more than the field shows" gap.
482
+ // `?? mem` is type-narrowing only (computeDashboardMetrics always sets it).
483
+ live_memories: live.totals.live_mem ?? live.totals.mem,
377
484
  uses_per_showing: live.adoption?.uses_per_showing ?? null,
378
485
  cold_count: live.adoption?.cold_count ?? 0,
379
486
  memory_soft_cap: (0, consolidate_js_1.resolveMemorySoftCap)(config?.memorySoftCap),
@@ -488,6 +595,14 @@ function handleDashboardData(db, query, config) {
488
595
  by_type: live.by_type,
489
596
  by_domain: live.by_domain,
490
597
  by_source_agent: live.by_source_agent,
598
+ by_source_machine: live.by_source_machine,
599
+ },
600
+ capture_health: (0, capture_health_js_1.readCaptureHealth)(db),
601
+ // #423 phase 3: pauses + presence in one block — one source of truth
602
+ // for the rail's dots, toggles and the capture card's PAUSED badges.
603
+ fleet: {
604
+ pauses: (0, capture_pause_js_1.listCapturePauses)(db),
605
+ last_seen: (0, capture_pause_js_1.readFleetLastSeen)(db),
491
606
  },
492
607
  digest,
493
608
  },
@@ -558,3 +673,473 @@ function accountTokenHandler(getToken) {
558
673
  }
559
674
  };
560
675
  }
676
+ /** UTC day number (days since epoch) for an ISO timestamp; null when the
677
+ * string does not parse — the night-resolution clock for stage recency. */
678
+ function utcDayNumber(iso) {
679
+ if (!iso)
680
+ return null;
681
+ const t = Date.parse(iso);
682
+ return Number.isFinite(t) ? Math.floor(t / 86_400_000) : null;
683
+ }
684
+ /**
685
+ * The pure data handler for GET /dashboard/field. Reads the whole live store
686
+ * (minus absorbed rows) + all link edges, derives stage + effective strength
687
+ * per memory, and returns the field payload. Never throws on empty stores —
688
+ * an empty brain is a valid field.
689
+ */
690
+ function handleDashboardField(db) {
691
+ const now = new Date();
692
+ const nowDay = Math.floor(now.getTime() / 86_400_000);
693
+ // Shared derivation inputs (one batched pass — never per-row queries).
694
+ const linkCounts = linkCountsByMemory(db);
695
+ const rows = db
696
+ .prepare(`SELECT id, content, base_strength, last_accessed, access_count,
697
+ created_at, domain, source_agent, source_machine
698
+ FROM memories
699
+ WHERE COALESCE(status, '') != 'absorbed'
700
+ ORDER BY created_at ASC, id ASC`)
701
+ .all();
702
+ const memories = rows.map((r) => {
703
+ // Shared per-row derivation — the SAME math the snapshot stage_counts
704
+ // uses (deriveStageForRow); the field only adds its wire fields.
705
+ const { strength, stage } = deriveStageForRow(r, linkCounts.get(r.id) ?? 0, now, nowDay);
706
+ return {
707
+ id: r.id,
708
+ title: (0, recall_index_js_1.memoryTitle)(r.content, calibration_js_1.RECALL_TITLE_CHARS),
709
+ domain: r.domain,
710
+ stage,
711
+ strength,
712
+ access_count: r.access_count ?? 0,
713
+ created_at: r.created_at,
714
+ source_agent: r.source_agent,
715
+ source_machine: r.source_machine ?? null,
716
+ };
717
+ });
718
+ const linkRows = db
719
+ .prepare("SELECT source_id, target_id, relationship FROM memory_links ORDER BY created_at ASC")
720
+ .all();
721
+ return {
722
+ status: 200,
723
+ body: {
724
+ generated_at: now.toISOString(),
725
+ // #426 owner semantics ruling 2026-09-13: recall grades are REMOVED —
726
+ // recall depends on the conversation, higher is not a target. Only the
727
+ // stage thresholds ride the echo (calibrated 2026-09-13), plus the two
728
+ // #409/#426 display keys: the search floor and the recall-level band
729
+ // edges (PROVISIONAL — see calibration.ts).
730
+ thresholds: {
731
+ stage: {
732
+ fading_days: calibration_js_1.STAGE_FADING_DAYS,
733
+ fading_strength: calibration_js_1.STAGE_FADING_STRENGTH,
734
+ belief: calibration_js_1.STAGE_BELIEF_STRENGTH,
735
+ truth: calibration_js_1.STAGE_TRUTH_STRENGTH,
736
+ },
737
+ recall_min_similarity: calibration_js_1.RECALL_MIN_SIMILARITY,
738
+ recall_uses: {
739
+ low_max: calibration_js_1.RECALL_USES_LOW_MAX,
740
+ normal_max: calibration_js_1.RECALL_USES_NORMAL_MAX,
741
+ axis_max: calibration_js_1.RECALL_USES_AXIS_MAX,
742
+ },
743
+ },
744
+ memories,
745
+ links: linkRows.map((l) => ({ a: l.source_id, b: l.target_id, rel: l.relationship })),
746
+ },
747
+ };
748
+ }
749
+ /** True when the request genuinely accepts a gzip response (quality-aware —
750
+ * `gzip;q=0` negotiates to no). Uses the framework's content negotiation
751
+ * rather than substring-matching the header (#424 review). */
752
+ function acceptsGzip(req) {
753
+ return req.acceptsEncodings("gzip") === "gzip";
754
+ }
755
+ /**
756
+ * Express adapter for GET /dashboard/field. Bearer-only by construction
757
+ * (mounted after createAuthMiddleware — no exemption). Gzips the payload
758
+ * when the client advertises Accept-Encoding: gzip: the field is one row per
759
+ * memory on possibly very large stores, so the wire cost is the point
760
+ * (minimal fields + compression, per the #409 payload-size risk note).
761
+ * Failures surface as a 500 {error} like the sibling adapters.
762
+ */
763
+ function dashboardFieldHandler(getDb) {
764
+ return (req, res) => {
765
+ try {
766
+ const { status, body } = handleDashboardField(getDb());
767
+ if (status !== 200) {
768
+ res.status(status).json(body);
769
+ return;
770
+ }
771
+ if (acceptsGzip(req)) {
772
+ const buf = (0, node_zlib_1.gzipSync)(JSON.stringify(body));
773
+ res
774
+ .set({
775
+ "Content-Type": "application/json",
776
+ "Content-Encoding": "gzip",
777
+ "Vary": "Accept-Encoding",
778
+ })
779
+ .status(status)
780
+ .send(buf);
781
+ return;
782
+ }
783
+ res.status(status).json(body);
784
+ }
785
+ catch (err) {
786
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
787
+ }
788
+ };
789
+ }
790
+ const EVENTS_DEFAULT_DAYS = 90;
791
+ const EVENTS_MAX_DAYS = 365;
792
+ /** Parse the ?days= param: integer 1..365, default 90. Returns null when the
793
+ * value is present but invalid (→ 400); undefined when absent (→ default). */
794
+ function parseEventsDays(raw) {
795
+ if (raw === undefined)
796
+ return undefined;
797
+ const first = Array.isArray(raw) ? raw[0] : raw;
798
+ const n = typeof first === "number" ? first : Number(first);
799
+ if (!Number.isInteger(n) || n < 1 || n > EVENTS_MAX_DAYS)
800
+ return null;
801
+ return n;
802
+ }
803
+ /**
804
+ * The pure data handler for GET /dashboard/events?days=N. Buckets the four
805
+ * event sources into UTC nights (only nights with events, ascending) within
806
+ * the last `days` calendar days (today inclusive).
807
+ */
808
+ function handleDashboardEvents(db, query) {
809
+ const parsed = parseEventsDays(query.days);
810
+ if (parsed === null) {
811
+ return {
812
+ status: 400,
813
+ body: { error: `Invalid 'days' — expected an integer between 1 and ${EVENTS_MAX_DAYS}` },
814
+ };
815
+ }
816
+ const days = parsed ?? EVENTS_DEFAULT_DAYS;
817
+ // Calendar-day window: today's UTC day back through (days - 1) days ago.
818
+ const todayDay = Math.floor(Date.now() / 86_400_000);
819
+ const cutoffDay = todayDay - (days - 1);
820
+ const cutoffIso = new Date(cutoffDay * 86_400_000).toISOString();
821
+ const nights = new Map();
822
+ const inWindow = (iso) => {
823
+ const d = utcDayNumber(iso);
824
+ if (d === null || d < cutoffDay || d > todayDay)
825
+ return null;
826
+ return new Date(d * 86_400_000).toISOString().slice(0, 10);
827
+ };
828
+ const nightFor = (date) => {
829
+ let n = nights.get(date);
830
+ if (!n) {
831
+ n = { date, added: [], enriched: [], merged: [], linked: [], by_agent: {} };
832
+ nights.set(date, n);
833
+ }
834
+ return n;
835
+ };
836
+ const bumpAgent = (n, agent, key) => {
837
+ const k = agent ?? "(unknown)";
838
+ const rec = n.by_agent[k] ?? { learned: 0, enriched: 0, merged: 0 };
839
+ rec[key] += 1;
840
+ n.by_agent[k] = rec;
841
+ };
842
+ // added — every memory row born in the window (absorbed included: the
843
+ // replay needs the birth of rows a later merge folds away).
844
+ const addedRows = db
845
+ .prepare("SELECT id, source_agent, created_at FROM memories WHERE created_at >= ?")
846
+ .all(cutoffIso);
847
+ for (const r of addedRows) {
848
+ const date = inWindow(r.created_at);
849
+ if (date === null)
850
+ continue;
851
+ const n = nightFor(date);
852
+ n.added.push(r.id);
853
+ bumpAgent(n, r.source_agent, "learned");
854
+ }
855
+ // enriched — memory_history rows, DISTINCT memory per night (one rewrite of
856
+ // the same memory on a night is one enrichment). LEFT JOIN: the memory row
857
+ // can be gone (hard-deleted legacy losers); those count under "(unknown)".
858
+ const enrichRows = db
859
+ .prepare(`SELECT h.memory_id, h.created_at, m.source_agent AS agent
860
+ FROM memory_history h LEFT JOIN memories m ON m.id = h.memory_id
861
+ WHERE h.created_at >= ?`)
862
+ .all(cutoffIso);
863
+ const enrichedSeen = new Set(); // `${date}|${memory_id}` dedup
864
+ for (const r of enrichRows) {
865
+ const date = inWindow(r.created_at);
866
+ if (date === null)
867
+ continue;
868
+ const key = `${date}|${r.memory_id}`;
869
+ if (enrichedSeen.has(key))
870
+ continue;
871
+ enrichedSeen.add(key);
872
+ const n = nightFor(date);
873
+ n.enriched.push(r.memory_id);
874
+ bumpAgent(n, r.agent, "enriched");
875
+ }
876
+ // merged — dedup_log rows; agent = the LOSER's source_agent (the merge
877
+ // event belongs to the memory that went away).
878
+ const mergedRows = db
879
+ .prepare(`SELECT d.loser_id, d.canonical_id, d.merged_at, m.source_agent AS agent
880
+ FROM dedup_log d LEFT JOIN memories m ON m.id = d.loser_id
881
+ WHERE d.merged_at >= ?`)
882
+ .all(cutoffIso);
883
+ for (const r of mergedRows) {
884
+ const date = inWindow(r.merged_at);
885
+ if (date === null)
886
+ continue;
887
+ const n = nightFor(date);
888
+ n.merged.push({ loser: r.loser_id, canonical: r.canonical_id });
889
+ bumpAgent(n, r.agent, "merged");
890
+ }
891
+ // linked — memory_links rows born in the window.
892
+ const linkedRows = db
893
+ .prepare("SELECT source_id, target_id, created_at FROM memory_links WHERE created_at >= ?")
894
+ .all(cutoffIso);
895
+ for (const r of linkedRows) {
896
+ const date = inWindow(r.created_at);
897
+ if (date === null)
898
+ continue;
899
+ nightFor(date).linked.push({ a: r.source_id, b: r.target_id });
900
+ }
901
+ const sorted = Array.from(nights.values())
902
+ .filter((n) => n.added.length + n.enriched.length + n.merged.length + n.linked.length > 0)
903
+ .sort((x, y) => (x.date < y.date ? -1 : x.date > y.date ? 1 : 0));
904
+ return { status: 200, body: { nights: sorted } };
905
+ }
906
+ /**
907
+ * Express adapter for GET /dashboard/events. Bearer-only by construction
908
+ * (mounted after createAuthMiddleware — no exemption). The ledger is ids
909
+ * only (no titles), so it stays plain JSON — gzip is the /field adapter's
910
+ * concern. Failures surface as a 500 {error} like the sibling adapters.
911
+ */
912
+ function dashboardEventsHandler(getDb) {
913
+ return (req, res) => {
914
+ try {
915
+ const { status, body } = handleDashboardEvents(getDb(), req.query);
916
+ res.status(status).json(body);
917
+ }
918
+ catch (err) {
919
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
920
+ }
921
+ };
922
+ }
923
+ /** A config value echoed as a non-blank string, else null. */
924
+ function configString(v) {
925
+ return typeof v === "string" && v.trim().length > 0 ? v : null;
926
+ }
927
+ /**
928
+ * The pure GET handler: echo the CONFIG values raw (null when unset — the UI
929
+ * shows defaults as placeholders, so this never resolves them) + the runtime
930
+ * provider from the daemon's in-memory llmConfig.
931
+ */
932
+ function handleDashboardModelGet(config, llmConfig) {
933
+ const cfg = config ?? {};
934
+ return {
935
+ status: 200,
936
+ body: {
937
+ provider: llmConfig?.provider ?? null,
938
+ backend: configString(cfg.llmBackend),
939
+ base_url: configString(cfg.llmBaseUrl),
940
+ model: configString(cfg.llmModel),
941
+ max_tokens: typeof cfg.maxTokens === "number" && Number.isFinite(cfg.maxTokens)
942
+ ? cfg.maxTokens
943
+ : null,
944
+ enable_thinking: typeof cfg.enableThinking === "boolean" ? cfg.enableThinking : null,
945
+ api_key_set: Boolean(configString(cfg.llmApiKey)),
946
+ applies_on_restart: true,
947
+ },
948
+ };
949
+ }
950
+ /** The PUT's allowlisted body keys → the config keys they write. */
951
+ const MODEL_PUT_KEYS = {
952
+ backend: "llmBackend",
953
+ base_url: "llmBaseUrl",
954
+ model: "llmModel",
955
+ api_key: "llmApiKey",
956
+ max_tokens: "maxTokens",
957
+ enable_thinking: "enableThinking",
958
+ };
959
+ /** The backends init ever writes (absence of llmBackend + baseUrl+apiKey =
960
+ * the openai-compat path). "" clears (the modal's "auto" option). */
961
+ const MODEL_BACKEND_VALUES = new Set(["", "ollama", "claude-cli"]);
962
+ /**
963
+ * The pure PUT handler: validate the allowlisted subset, persist via the
964
+ * injected writer (which THROWS on a malformed config — the adapter maps that
965
+ * to a 500 and the file stays untouched), answer with the fresh GET shape
966
+ * built from the post-write config. `null` REMOVES a config key.
967
+ */
968
+ function handleDashboardModelPut(body, persist, getLlmConfig) {
969
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
970
+ return { status: 400, body: { error: "Body must be a JSON object of model settings" } };
971
+ }
972
+ const input = body;
973
+ const updates = {};
974
+ for (const [key, value] of Object.entries(input)) {
975
+ const configKey = MODEL_PUT_KEYS[key];
976
+ if (!configKey) {
977
+ return {
978
+ status: 400,
979
+ body: {
980
+ error: `Unknown key '${key}' — allowed: ${Object.keys(MODEL_PUT_KEYS).join(", ")}`,
981
+ },
982
+ };
983
+ }
984
+ // Per-key validation → a SPECIFIC message (the modal surfaces it in the
985
+ // toast). null is valid everywhere (the clear).
986
+ if (value === null) {
987
+ updates[configKey] = null;
988
+ continue;
989
+ }
990
+ switch (key) {
991
+ case "backend":
992
+ if (typeof value !== "string" || !MODEL_BACKEND_VALUES.has(value)) {
993
+ return {
994
+ status: 400,
995
+ body: { error: `Invalid 'backend' — expected null, "", "ollama" or "claude-cli" (absent + base_url + api_key = OpenAI-compatible)` },
996
+ };
997
+ }
998
+ // "" (the modal's "auto / OpenAI-compatible" option) means NO named
999
+ // backend — normalize to the clear so config.json never carries a
1000
+ // vestigial "" key (absence IS the openai-compat path).
1001
+ if (value === "") {
1002
+ updates[configKey] = null;
1003
+ continue;
1004
+ }
1005
+ break;
1006
+ case "base_url": {
1007
+ if (typeof value !== "string") {
1008
+ return { status: 400, body: { error: "Invalid 'base_url' — expected null or an http(s) URL string" } };
1009
+ }
1010
+ let url;
1011
+ try {
1012
+ url = new URL(value);
1013
+ }
1014
+ catch {
1015
+ return { status: 400, body: { error: `Invalid 'base_url' — "${value}" does not parse as a URL` } };
1016
+ }
1017
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
1018
+ return { status: 400, body: { error: `Invalid 'base_url' — protocol must be http(s), got "${url.protocol}"` } };
1019
+ }
1020
+ break;
1021
+ }
1022
+ case "model":
1023
+ if (typeof value !== "string" || value.trim().length === 0) {
1024
+ return { status: 400, body: { error: "Invalid 'model' — expected null or a non-empty string" } };
1025
+ }
1026
+ break;
1027
+ case "api_key":
1028
+ // Empty string is a 400, NOT a clear — the classic "pasted nothing"
1029
+ // typo guard. Clearing the key entirely is null (deliberate).
1030
+ if (typeof value !== "string" || value.length === 0) {
1031
+ return { status: 400, body: { error: "Invalid 'api_key' — expected null (clear) or a non-empty string" } };
1032
+ }
1033
+ break;
1034
+ case "max_tokens":
1035
+ // Strict integer ≥ 1 — floats and numeric strings are rejected so the
1036
+ // config never carries a value the runtime would have to re-coerce.
1037
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
1038
+ return { status: 400, body: { error: "Invalid 'max_tokens' — expected null or an integer ≥ 1" } };
1039
+ }
1040
+ break;
1041
+ case "enable_thinking":
1042
+ // Strict boolean — "false" (string) is the JSON-encoder artifact this
1043
+ // catches; null clears back to the default.
1044
+ if (typeof value !== "boolean") {
1045
+ return { status: 400, body: { error: "Invalid 'enable_thinking' — expected null or a boolean" } };
1046
+ }
1047
+ break;
1048
+ }
1049
+ updates[configKey] = value;
1050
+ }
1051
+ // persist THROWS on a malformed config (strict load) — propagated to the
1052
+ // adapter → 500, file untouched. On success it returns the fresh config.
1053
+ const fresh = persist(updates);
1054
+ return handleDashboardModelGet(fresh, getLlmConfig());
1055
+ }
1056
+ /**
1057
+ * Express adapter for GET /dashboard/model. Bearer-only by construction
1058
+ * (mounted after createAuthMiddleware — NO exemption, unlike the page shells:
1059
+ * this carries install config). Failures surface as a 500 {error}.
1060
+ */
1061
+ function dashboardModelGetHandler(getConfig, getLlmConfig) {
1062
+ return (_req, res) => {
1063
+ try {
1064
+ const { status, body } = handleDashboardModelGet(getConfig(), getLlmConfig());
1065
+ res.status(status).json(body);
1066
+ }
1067
+ catch (err) {
1068
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
1069
+ }
1070
+ };
1071
+ }
1072
+ /**
1073
+ * Express adapter for PUT /dashboard/model. Same auth posture as the GET.
1074
+ * The injected persist closure owns the config path (the server passes
1075
+ * init.ts persistConfigUpdates over stateDir/config.json); its load/persist
1076
+ * failures (malformed config, unwritable file) map to a 500 {error} with the
1077
+ * file left untouched — never a silent partial write.
1078
+ */
1079
+ function dashboardModelPutHandler(persist, getLlmConfig) {
1080
+ return (req, res) => {
1081
+ try {
1082
+ const { status, body } = handleDashboardModelPut(req.body ?? null, persist, getLlmConfig);
1083
+ res.status(status).json(body);
1084
+ }
1085
+ catch (err) {
1086
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
1087
+ }
1088
+ };
1089
+ }
1090
+ // ---------------------------------------------------------------------------
1091
+ // PUT /dashboard/capture-pause — the console's pause/resume toggle (#423
1092
+ // phase 3, D3). Same layering as the model PUT: a pure validation handler +
1093
+ // an injected setter closure (the live adapter wires setCapturePause over
1094
+ // the daemon's db), so the route stays thin and the logic is unit-testable.
1095
+ // ---------------------------------------------------------------------------
1096
+ /** The PUT's body: {machine?: string|null, harness: string, paused: boolean}. */
1097
+ function handleDashboardCapturePausePut(body, setPause) {
1098
+ if (body === null || typeof body !== "object" || Array.isArray(body)) {
1099
+ return { status: 400, body: { error: "Body must be a JSON object {harness, paused, machine?}" } };
1100
+ }
1101
+ const { machine, harness, paused } = body;
1102
+ // harness: the bundle's harness half, as the /distill pause key derives it.
1103
+ // Not trimmed — the key must match the traffic byte-for-byte; the console
1104
+ // derives it from already-normalized ids.
1105
+ if (typeof harness !== "string" || harness.length === 0 || harness.length > 128) {
1106
+ return { status: 400, body: { error: "Invalid 'harness' — expected a non-empty string of at most 128 chars" } };
1107
+ }
1108
+ // machine: null/undefined/"" → the unstamped bundle (''); any other value
1109
+ // must be a non-empty string ≤128 (never a number/object from a bad caller).
1110
+ let machineKey;
1111
+ if (machine === null || machine === undefined || machine === "") {
1112
+ machineKey = "";
1113
+ }
1114
+ else if (typeof machine === "string" && machine.length > 0 && machine.length <= 128) {
1115
+ machineKey = machine;
1116
+ }
1117
+ else {
1118
+ return { status: 400, body: { error: "Invalid 'machine' — expected null, \"\", or a non-empty string of at most 128 chars" } };
1119
+ }
1120
+ // Strict boolean — "false" (string) is the JSON-encoder artifact this
1121
+ // catches; there is no clear/null form of a two-state toggle.
1122
+ if (typeof paused !== "boolean") {
1123
+ return { status: 400, body: { error: "Invalid 'paused' — expected a boolean" } };
1124
+ }
1125
+ const pausedAt = setPause(machineKey, harness, paused);
1126
+ return { status: 200, body: { machine: machineKey, harness, paused, paused_at: pausedAt } };
1127
+ }
1128
+ /**
1129
+ * Express adapter for PUT /dashboard/capture-pause. Bearer-only by
1130
+ * construction (mounted after createAuthMiddleware, no shell exemption).
1131
+ * The effect is immediate — no restart: the /distill handler reads the
1132
+ * pause table on every post, so the very next capture POST from the bundle
1133
+ * is skipped (200) or captured as before.
1134
+ */
1135
+ function dashboardCapturePausePutHandler(getDb) {
1136
+ return (req, res) => {
1137
+ try {
1138
+ const { status, body } = handleDashboardCapturePausePut(req.body ?? null, (machine, harness, paused) => (0, capture_pause_js_1.setCapturePause)(getDb(), machine, harness, paused));
1139
+ res.status(status).json(body);
1140
+ }
1141
+ catch (err) {
1142
+ res.status(500).json({ error: err instanceof Error ? err.message : String(err) });
1143
+ }
1144
+ };
1145
+ }