@gamaze/hicortex 0.20.7 → 0.20.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -41
- package/assets/dashboard.html +3989 -836
- package/dist/calibration.d.ts +293 -0
- package/dist/calibration.js +379 -0
- package/dist/capture-health.d.ts +87 -0
- package/dist/capture-health.js +106 -0
- package/dist/capture-pause.d.ts +86 -0
- package/dist/capture-pause.js +127 -0
- package/dist/capture.d.ts +24 -3
- package/dist/capture.js +11 -1
- package/dist/classify-domains.d.ts +6 -0
- package/dist/classify-domains.js +7 -1
- package/dist/cli.js +38 -3
- package/dist/config-read.d.ts +1 -1
- package/dist/config-read.js +96 -9
- package/dist/consolidate.d.ts +114 -68
- package/dist/consolidate.js +302 -182
- package/dist/dashboard.d.ts +326 -6
- package/dist/dashboard.js +592 -7
- package/dist/db.js +105 -0
- package/dist/dedup.d.ts +34 -26
- package/dist/dedup.js +91 -57
- package/dist/distiller.js +1 -1
- package/dist/domain-classify.d.ts +7 -6
- package/dist/domain-classify.js +12 -10
- package/dist/eval/decay-eval.d.ts +3 -3
- package/dist/eval/decay-eval.js +4 -4
- package/dist/eval/importance-eval.d.ts +85 -0
- package/dist/eval/importance-eval.js +286 -0
- package/dist/eval/planted-eval.d.ts +26 -0
- package/dist/eval/planted-eval.js +97 -0
- package/dist/eval/planted-fixtures.d.ts +107 -0
- package/dist/eval/planted-fixtures.js +283 -0
- package/dist/eval/planted-harness.d.ts +176 -0
- package/dist/eval/planted-harness.js +649 -0
- package/dist/eval/ranking-battery.d.ts +78 -0
- package/dist/eval/ranking-battery.js +181 -0
- package/dist/eval/ranking-eval.d.ts +41 -0
- package/dist/eval/ranking-eval.js +391 -0
- package/dist/eval/ranking-fixtures.d.ts +77 -0
- package/dist/eval/ranking-fixtures.js +226 -0
- package/dist/identity-store.d.ts +21 -0
- package/dist/identity-store.js +49 -0
- package/dist/index.js +4 -3
- package/dist/init.d.ts +23 -3
- package/dist/init.js +84 -9
- package/dist/llm.d.ts +43 -58
- package/dist/llm.js +87 -101
- package/dist/mcp-server.d.ts +12 -0
- package/dist/mcp-server.js +213 -32
- package/dist/nightly.d.ts +9 -1
- package/dist/nightly.js +164 -110
- package/dist/nofit.d.ts +4 -11
- package/dist/nofit.js +6 -23
- package/dist/prompts.d.ts +10 -0
- package/dist/prompts.js +28 -5
- package/dist/recall-index.d.ts +30 -28
- package/dist/recall-index.js +21 -18
- package/dist/recall-registry.d.ts +2 -1
- package/dist/recall-registry.js +35 -1
- package/dist/reconsolidation.d.ts +168 -87
- package/dist/reconsolidation.js +818 -377
- package/dist/relink.js +3 -4
- package/dist/rescore-importance.d.ts +80 -0
- package/dist/rescore-importance.js +236 -0
- package/dist/retrieval.d.ts +80 -35
- package/dist/retrieval.js +322 -105
- package/dist/run-deadline.d.ts +62 -0
- package/dist/run-deadline.js +73 -0
- package/dist/schema-prototypes.d.ts +3 -3
- package/dist/schema-prototypes.js +3 -3
- package/dist/stages.d.ts +37 -0
- package/dist/stages.js +51 -0
- package/dist/state.d.ts +34 -9
- package/dist/storage.d.ts +50 -18
- package/dist/storage.js +125 -30
- package/dist/telemetry.d.ts +8 -7
- package/dist/token-budget.js +3 -4
- package/dist/type-classify.js +4 -4
- package/dist/types.d.ts +143 -155
- package/domains.example.json +4 -5
- package/hermes-plugin/hicortex/README.md +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +4 -1
- package/pi-extension/hicortex/README.md +1 -1
- package/server.json +3 -3
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
|
|
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},
|
|
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
|
+
}
|