@gamaze/hicortex 0.22.0 → 0.22.2
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/assets/dashboard.html +209 -76
- package/dist/calibration.d.ts +92 -12
- package/dist/calibration.js +102 -15
- package/dist/classify-domains.js +6 -1
- package/dist/consolidate.js +87 -19
- package/dist/dashboard.d.ts +11 -0
- package/dist/dashboard.js +9 -0
- package/dist/db.js +74 -0
- package/dist/eval/decay-eval.d.ts +4 -2
- package/dist/eval/decay-eval.js +4 -4
- package/dist/eval/eval-clock.d.ts +32 -0
- package/dist/eval/eval-clock.js +47 -0
- package/dist/eval/graph-eval.d.ts +15 -2
- package/dist/eval/graph-eval.js +51 -5
- package/dist/eval/planted-eval.d.ts +4 -0
- package/dist/eval/planted-eval.js +27 -2
- package/dist/eval/planted-harness.d.ts +7 -0
- package/dist/eval/planted-harness.js +2 -0
- package/dist/eval/ranking-battery.d.ts +49 -2
- package/dist/eval/ranking-battery.js +110 -2
- package/dist/eval/ranking-eval.d.ts +26 -6
- package/dist/eval/ranking-eval.js +197 -34
- package/dist/eval/ranking-fixtures.d.ts +41 -1
- package/dist/eval/ranking-fixtures.js +261 -2
- package/dist/eval/recall-sweep.d.ts +7 -2
- package/dist/eval/recall-sweep.js +42 -13
- package/dist/eval/relevance-eval.d.ts +115 -1
- package/dist/eval/relevance-eval.js +318 -32
- package/dist/eval/run-eval.d.ts +7 -4
- package/dist/eval/run-eval.js +36 -9
- package/dist/mcp-server.js +37 -9
- package/dist/nightly.js +14 -0
- package/dist/recall-index.d.ts +46 -3
- package/dist/recall-index.js +83 -26
- package/dist/recall-precision.d.ts +212 -0
- package/dist/recall-precision.js +381 -0
- package/dist/retrieval.d.ts +34 -15
- package/dist/retrieval.js +132 -59
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
- package/server.json +3 -3
package/dist/calibration.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
* the removal is never silent.
|
|
25
25
|
*/
|
|
26
26
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
27
|
-
exports.
|
|
27
|
+
exports.STAGE_TRUTH_STRENGTH = exports.STAGE_BELIEF_STRENGTH = exports.STAGE_FADING_STRENGTH = exports.STAGE_FADING_DAYS = exports.WEAK_PRIMARY_FLOOR = exports.CORRECTION_REWRITE_MIN_CONFIDENCE = exports.CORRECTION_MIN_SIMILARITY = exports.SUPERSESSION_MIN_SIMILARITY = exports.DEDUP_AUTO_MERGE_THRESHOLD = exports.IMPORTANCE_SETTLE_WINDOW_DAYS = exports.IMPORTANCE_CEILING = exports.BM25_WEIGHT_DOMAIN = exports.BM25_WEIGHT_PROJECT = exports.BM25_WEIGHT_BODY = exports.BOTH_CHANNEL_BOOST = exports.RRF_VECTOR_WEIGHT = exports.RRF_FTS_WEIGHT = exports.RRF_COMPOSITE_WEIGHT = exports.RRF_K = exports.SCOPE_AFFINITY_WEIGHT = exports.SUPERSEDED_DEMOTION = exports.RECENCY_HEAD_DAYS = exports.RECENCY_HEAD_WEIGHT = exports.RECENCY_HOURLY_DECAY = exports.SCORE_RECENCY_WEIGHT = exports.CONNECTIONS_SATURATION_DEGREE = exports.SCORE_CONNECTIONS_WEIGHT = exports.SCORE_STRENGTH_WEIGHT = exports.SCORE_SIMILARITY_WEIGHT = exports.MEMORY_PRECISION_PROMPT_EXCERPT_CHARS = exports.MEMORY_PRECISION_DIVERGENCE_MIN_SHOWN = exports.MEMORY_PRECISION_REDUNDANT_ABOVE = exports.MEMORY_PRECISION_WINDOW_DAYS = exports.RECALL_USES_AXIS_MAX = exports.RECALL_USES_NORMAL_MAX = exports.RECALL_USES_LOW_MAX = exports.RECALL_RESHOW_TURNS = exports.NOVELTY_FLOOR_SLOTS = exports.RECALL_TITLE_CHARS = exports.RECALL_MIN_PROMPT_CHARS = exports.RECALL_MAX_ITEMS = exports.RECALL_MIN_SIMILARITY = exports.SESSION_INTENT_WEIGHT = exports.COLD_EXPOSURE_SLOTS = exports.RECENT_WINDOW_DAYS = exports.RECENT_LIMIT = exports.SEARCH_LIMIT = exports.PROMOTION_STRENGTH_FLOOR = exports.PROMOTION_RATE = exports.DECAY_HALF_LIFE_DAYS = void 0;
|
|
28
|
+
exports.DIAGNOSTIC_ENV_TIER = exports.OLLAMA_FLUSH_WAIT_MS = exports.OLLAMA_FLUSH_EVERY = exports.NUM_CTX = exports.DEFAULT_FIRST_RUN_LOOKBACK_DAYS = exports.ENRICH_STRENGTH_DELTA = void 0;
|
|
28
29
|
exports.resolveNumCtx = resolveNumCtx;
|
|
29
30
|
exports.resolveOllamaFlushEvery = resolveOllamaFlushEvery;
|
|
30
31
|
exports.resolveOllamaFlushWaitMs = resolveOllamaFlushWaitMs;
|
|
@@ -126,9 +127,48 @@ exports.RECALL_USES_NORMAL_MAX = 0.25;
|
|
|
126
127
|
* PROVISIONAL (owner anchor 2026-09-13, #426) — chosen so Overfetching
|
|
127
128
|
* keeps a visible span, not a measured bound. */
|
|
128
129
|
exports.RECALL_USES_AXIS_MAX = 0.30;
|
|
130
|
+
// Memory-precision family (#476) — the console card's deterministic proxies.
|
|
131
|
+
// All four are release-managed per this module's evolution contract: no config
|
|
132
|
+
// keys, nothing hardcoded elsewhere. Values are stored THRESHOLD-FREE (raw
|
|
133
|
+
// cosines in recall_events); the thresholds below are applied at RENDER time
|
|
134
|
+
// only, so recalibrating them never rewrites history.
|
|
135
|
+
/**
|
|
136
|
+
* Retention horizon for the recall_pushes/recall_events sidecar tables AND the
|
|
137
|
+
* longest window the Memory Precision card shows — ONE constant feeds both
|
|
138
|
+
* (the CAPTURE_HEALTH_WINDOW_DAYS single-constant law: the card can never
|
|
139
|
+
* claim a window the store no longer has rows for). 90 = the longest #452
|
|
140
|
+
* range option that the retention can honestly serve; 180d and all clamp to
|
|
141
|
+
* it server-side and the payload echoes the effective window_days.
|
|
142
|
+
*/
|
|
143
|
+
exports.MEMORY_PRECISION_WINDOW_DAYS = 90;
|
|
144
|
+
/**
|
|
145
|
+
* Cosine at/above which a pushed line reads "redundant" (restating the
|
|
146
|
+
* session's standing context — the top lessons + identity every session-start
|
|
147
|
+
* hook injects). Direction-only display guidance, never a gate. 0.80 mirrors
|
|
148
|
+
* SUPERSESSION_MIN_SIMILARITY: at/above it a pair reads as the same
|
|
149
|
+
* statement, below it as topical overlap — a reasonable first anchor until
|
|
150
|
+
* the judged-calibration follow-up measures the real distribution (the
|
|
151
|
+
* #426 provisional-anchor posture).
|
|
152
|
+
*/
|
|
153
|
+
exports.MEMORY_PRECISION_REDUNDANT_ABOVE = 0.8;
|
|
154
|
+
/**
|
|
155
|
+
* Window showings a live memory needs (with zero window fetches) to count as
|
|
156
|
+
* DIVERGING — the index keeps pushing it, nothing ever reads it. 5 is the
|
|
157
|
+
* spec's anchor (#476): enough showings that the silence is a pattern, not
|
|
158
|
+
* one prompt's turn-suppression.
|
|
159
|
+
*/
|
|
160
|
+
exports.MEMORY_PRECISION_DIVERGENCE_MIN_SHOWN = 5;
|
|
161
|
+
/**
|
|
162
|
+
* Cap on the prompt excerpt persisted per recall_pushes row (Option A,
|
|
163
|
+
* owner-blessed 2026-09-19: the first-ever server-side persistence of raw
|
|
164
|
+
* prompt text, bounded to 256 chars and pruned at the retention horizon —
|
|
165
|
+
* the same trust boundary as /distill's denoised conversation text).
|
|
166
|
+
*/
|
|
167
|
+
exports.MEMORY_PRECISION_PROMPT_EXCERPT_CHARS = 256;
|
|
129
168
|
// ---------------------------------------------------------------------------
|
|
130
|
-
// Composite ranking weights (was: score*Weight,
|
|
131
|
-
//
|
|
169
|
+
// Composite ranking weights (was: score*Weight, supersededDemotion,
|
|
170
|
+
// *AffinityWeight, rrf*; the pre-#430 freshness bonus constants merged into
|
|
171
|
+
// the RECENCY family below)
|
|
132
172
|
// ---------------------------------------------------------------------------
|
|
133
173
|
/** Semantic-similarity share of the composite score. 0.50 (raised from 0.40
|
|
134
174
|
* in the 0.15.2 rebalance, #191 Phase B): on the production corpus effective
|
|
@@ -140,23 +180,58 @@ exports.SCORE_SIMILARITY_WEIGHT = 0.50;
|
|
|
140
180
|
exports.SCORE_STRENGTH_WEIGHT = 0.20;
|
|
141
181
|
/** Graph-centrality share of the composite score (was 0.20; see above). */
|
|
142
182
|
exports.SCORE_CONNECTIONS_WEIGHT = 0.15;
|
|
143
|
-
/**
|
|
183
|
+
/**
|
|
184
|
+
* Log-saturation degree K for the connections term (#449 PR E, items 1+3):
|
|
185
|
+
* the credit is min(1, log1p(k) / log1p(K)) × SCORE_CONNECTIONS_WEIGHT on
|
|
186
|
+
* the ABSOLUTE undirected-degree scale k — full at k = K, exactly +0 at
|
|
187
|
+
* k = 0 (unlinked rows score bit-identically to the linear term they
|
|
188
|
+
* replace). Provenance: p99 of the REAL undirected degree distribution
|
|
189
|
+
* (read-only audit 2026-09-17 of the canonical wave snapshot, 18,798
|
|
190
|
+
* memories / 14,600 linked: p50 = 4, p90 = 8, p95 = 9, p99 = 15, p99.9 =
|
|
191
|
+
* 28.4, max = 40; 135 memories ≥ 16 links = 0.92% of linked) — so the top
|
|
192
|
+
* ~1% of hubs tie at full credit and everything below differentiates
|
|
193
|
+
* modestly. Shape grounded in the #449 research base: ACT-R fan saturation
|
|
194
|
+
* (Anderson & Reder 1999 — activation falls with the LOG of fan), SAM's
|
|
195
|
+
* saturating returns, cue overload (Watkins & Watkins 1975). Replaces the
|
|
196
|
+
* candidate-set-relative normalization #449 deleted (k / the per-query max
|
|
197
|
+
* degree) — a row's score no longer depends on which other rows happened
|
|
198
|
+
* to match. Alternatives K = 8 (p90 — collapses all p90+ rows to
|
|
199
|
+
* full credit) and K = 32 (≈p99.9 — leaves the p99 hub at 0.12 of the
|
|
200
|
+
* term) were tabled in the #449 spec. RELEASE-MANAGED per this module's
|
|
201
|
+
* evolution contract — overridable only through the configureScoring seam.
|
|
202
|
+
*/
|
|
203
|
+
exports.CONNECTIONS_SATURATION_DEGREE = 16;
|
|
204
|
+
/** Time-curve share of the composite score — the merged curve's blend weight
|
|
205
|
+
* AND slow-region amplitude (was 0.10; see above). The four blend weights
|
|
206
|
+
* still sum to 1.0; the head amplitude below is a documented overshoot, NOT
|
|
207
|
+
* a fifth blend weight (#430). Zero (via the seam) disables the whole term. */
|
|
144
208
|
exports.SCORE_RECENCY_WEIGHT = 0.15;
|
|
145
|
-
/**
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
209
|
+
/** Hourly decay of the merged curve's slow region (#430) — promoted from the
|
|
210
|
+
* inline literal computeScore carried; half-life ≈ 57.7 days. At and beyond
|
|
211
|
+
* RECENCY_HEAD_DAYS the curve is bit-identical to the pre-#430 slow term
|
|
212
|
+
* (SCORE_RECENCY_WEIGHT × RECENCY_HOURLY_DECAY^hours). */
|
|
213
|
+
exports.RECENCY_HOURLY_DECAY = 0.9995;
|
|
214
|
+
/** Merged time-curve head amplitude at age 0 (#430) — the pre-#430 slow
|
|
215
|
+
* weight 0.15 + fresh bonus 0.15: the freshness job is now the early steep
|
|
216
|
+
* part of ONE curve. The head joins value-continuously onto the slow region
|
|
217
|
+
* at RECENCY_HEAD_DAYS; its rate is DERIVED from that continuity, never a
|
|
218
|
+
* free constant. */
|
|
219
|
+
exports.RECENCY_HEAD_WEIGHT = 0.30;
|
|
220
|
+
/** Join age in days of the merged time curve (#430) — the head window edge,
|
|
221
|
+
* carried over from the pre-#430 freshness window. Nightly capture means
|
|
222
|
+
* 1 day is the floor of "fresh" (#191 Phase B). */
|
|
223
|
+
exports.RECENCY_HEAD_DAYS = 7;
|
|
151
224
|
/** Score multiplier for a memory a later decision superseded (0.15.2; the
|
|
152
225
|
* belief walk (#393 D) is the primary mechanism — this is the safety net
|
|
153
226
|
* for rows the walk does not reach). */
|
|
154
227
|
exports.SUPERSEDED_DEMOTION = 0.50;
|
|
155
|
-
/** #
|
|
156
|
-
*
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
228
|
+
/** #430 merged scope affinity — ONE term replacing #203's two boosts (the
|
|
229
|
+
* project-affinity and domain-affinity constants) at their shared value.
|
|
230
|
+
* Boost = max(project-match indicator (1 on exact match), max overlapping
|
|
231
|
+
* domain-tag weight) × this weight: the strongest single scope signal counts
|
|
232
|
+
* once, never stacked. ADDITIVE, zero-boost neutral, never a penalty — an
|
|
233
|
+
* absent scope adds exactly 0; a foreign memory ranks equal, not lower. */
|
|
234
|
+
exports.SCOPE_AFFINITY_WEIGHT = 0.15;
|
|
160
235
|
/** #205 RRF k parameter (1/(k+rank+1)) — matches the pre-#205 hardcoded 60
|
|
161
236
|
* so the no-config path was byte-identical to 0.15.3. */
|
|
162
237
|
exports.RRF_K = 60;
|
|
@@ -223,6 +298,18 @@ exports.BM25_WEIGHT_DOMAIN = 2.0;
|
|
|
223
298
|
* base-1.0 rows decay again.
|
|
224
299
|
*/
|
|
225
300
|
exports.IMPORTANCE_CEILING = 0.95;
|
|
301
|
+
/**
|
|
302
|
+
* How many days a memory stays in the nightly importance-settle pool after
|
|
303
|
+
* ingest (#478): a row's importance re-settles nightly while young — keyed
|
|
304
|
+
* on PER-ROW age (ingested_at), never the global lastConsolidated
|
|
305
|
+
* watermark, which sticks whenever a run defers and used to drag a growing
|
|
306
|
+
* cohort back through re-scoring (each re-settle overwrites base_strength,
|
|
307
|
+
* erasing promotion gains). Rows leave the pool after the window; promoted
|
|
308
|
+
* or enriched rows leave earlier via the paid-gain guard. Release-managed
|
|
309
|
+
* (#408 discipline): moving it is a release decision with soak evidence,
|
|
310
|
+
* not a config knob.
|
|
311
|
+
*/
|
|
312
|
+
exports.IMPORTANCE_SETTLE_WINDOW_DAYS = 3;
|
|
226
313
|
/** Deterministic merge ceiling of the unified resolution pass (#392): pairs
|
|
227
314
|
* at/above this cosine merge LLM-free; [CORRECTION_MIN_SIMILARITY, this)
|
|
228
315
|
* get the one verdict call. 0.92 — measured on the #191 mechanical audit
|
package/dist/classify-domains.js
CHANGED
|
@@ -175,10 +175,15 @@ async function runClassifyDomains(options = {}) {
|
|
|
175
175
|
// everything from the final tag sets.
|
|
176
176
|
const { prototypes } = await (0, schema_prototypes_js_1.computeDomainPrototypes)(db, domains, getEmbedFn);
|
|
177
177
|
// Scope filter: default = NULL / not-in-set / no tags yet; --all = everything.
|
|
178
|
+
// #477: absorbed dedup losers are excluded from the default scope (dead
|
|
179
|
+
// evidence — the merge already cleared their tags and nulled domain);
|
|
180
|
+
// conjoined OUTSIDE the parenthesized OR group, same as the nightly
|
|
181
|
+
// stageContentDomains twin. --all stays a wholesale operator re-judge.
|
|
178
182
|
const placeholders = domains.map(() => "?").join(", ");
|
|
179
183
|
const scopeSql = all
|
|
180
184
|
? "rowid > ?"
|
|
181
|
-
: `rowid > ? AND (
|
|
185
|
+
: `rowid > ? AND COALESCE(status, '') != 'absorbed' ` +
|
|
186
|
+
`AND (domain IS NULL OR domain NOT IN (${placeholders}) ` +
|
|
182
187
|
`OR id NOT IN (SELECT DISTINCT memory_id FROM memory_tags))`;
|
|
183
188
|
const batchStmt = db.prepare(`SELECT rowid AS __rowid, id, content, project, domain FROM memories
|
|
184
189
|
WHERE ${scopeSql} ORDER BY rowid ASC LIMIT ?`);
|
package/dist/consolidate.js
CHANGED
|
@@ -467,8 +467,13 @@ async function scoreMemoriesImportance(db, memories, llm, opts = {}) {
|
|
|
467
467
|
}
|
|
468
468
|
return { scored, failed, skipped_budget: skippedBudget };
|
|
469
469
|
}
|
|
470
|
-
async function stageImportance(db, memories, llm, budget, dryRun, deadline
|
|
471
|
-
|
|
470
|
+
async function stageImportance(db, memories, llm, budget, dryRun, deadline,
|
|
471
|
+
/** #478: pool candidates the paid-gain guard dropped at the stage
|
|
472
|
+
* boundary — reported, not scored, so the run's evidence shows the
|
|
473
|
+
* promotion/enrichment gains that survived the nightly. */
|
|
474
|
+
guardSkipped = 0) {
|
|
475
|
+
const r = await scoreMemoriesImportance(db, memories, llm, { budget, deadline, dryRun });
|
|
476
|
+
return { ...r, guard_skipped: guardSkipped };
|
|
472
477
|
}
|
|
473
478
|
// ---------------------------------------------------------------------------
|
|
474
479
|
// Stage 2.5: Reflection
|
|
@@ -639,12 +644,18 @@ async function stageContentDomains(db, domains, llm, budget, embedFn, dryRun, st
|
|
|
639
644
|
// - domain NOT IN the current vocabulary (a rename/removal re-files), OR
|
|
640
645
|
// - no memory_tags rows yet (single-domain memories from feat/content-domains
|
|
641
646
|
// that have a primary but no tag set — backfill them to multi-tag).
|
|
647
|
+
// #477: absorbed dedup losers are excluded — the merge clears their tags
|
|
648
|
+
// and nulls domain before absorbing, so without the predicate every dead
|
|
649
|
+
// loser re-enters this scope nightly (one classify call + a halving on
|
|
650
|
+
// evidence no recall can ever see). Conjoined OUTSIDE the parenthesized OR
|
|
651
|
+
// group so SQL precedence cannot let an OR arm absorb it.
|
|
642
652
|
const placeholders = domains.map(() => "?").join(", ");
|
|
643
653
|
const rows = db
|
|
644
654
|
.prepare(`SELECT id, content, project FROM memories
|
|
645
|
-
WHERE
|
|
646
|
-
|
|
647
|
-
|
|
655
|
+
WHERE COALESCE(status, '') != 'absorbed'
|
|
656
|
+
AND (domain IS NULL
|
|
657
|
+
OR domain NOT IN (${placeholders})
|
|
658
|
+
OR id NOT IN (SELECT DISTINCT memory_id FROM memory_tags))`)
|
|
648
659
|
.all(...domains.map((d) => d.name));
|
|
649
660
|
if (dryRun) {
|
|
650
661
|
return { curated: false, domains: domains.length, classified: 0, reason: `dry_run (${rows.length} would classify)` };
|
|
@@ -1376,6 +1387,7 @@ function stagePromotion(db, dryRun) {
|
|
|
1376
1387
|
const demoted = (0, retrieval_js_1.findDemotedIds)(db, rows.map((r) => r.id));
|
|
1377
1388
|
let promoted = 0;
|
|
1378
1389
|
let demotedSkipped = 0;
|
|
1390
|
+
let totalGain = 0;
|
|
1379
1391
|
const writes = [];
|
|
1380
1392
|
for (const row of rows) {
|
|
1381
1393
|
const accessCount = row.access_count ?? 0;
|
|
@@ -1389,18 +1401,25 @@ function stagePromotion(db, dryRun) {
|
|
|
1389
1401
|
}
|
|
1390
1402
|
// base_strength is NOT NULL after scoring; the `?? 0.5` mirrors
|
|
1391
1403
|
// stageDecayPrune's defensive default for unscored rows (inserts at 0.5).
|
|
1392
|
-
|
|
1404
|
+
const startStrength = row.base_strength ?? 0.5;
|
|
1405
|
+
let strength = startStrength;
|
|
1393
1406
|
for (let i = 0; i < row.delta; i++)
|
|
1394
1407
|
strength = applyStrengthPromotion(strength);
|
|
1408
|
+
totalGain += strength - startStrength;
|
|
1395
1409
|
promoted++;
|
|
1396
1410
|
writes.push({
|
|
1397
1411
|
id: row.id,
|
|
1398
1412
|
fields: { base_strength: strength, promotion_last_count: accessCount },
|
|
1399
1413
|
});
|
|
1400
1414
|
}
|
|
1415
|
+
// #459: the stage's one-line summary in the shared stage idiom (rows
|
|
1416
|
+
// examined / promoted / total gain, like the supersession summary) — the
|
|
1417
|
+
// stage writes its report in-memory only, so the log line is the soak-time
|
|
1418
|
+
// health signal. Zero examined rows stay silent (the supersession gate).
|
|
1401
1419
|
if (dryRun) {
|
|
1402
|
-
console.log(`[hicortex] Strength promotion (dry-run):
|
|
1403
|
-
|
|
1420
|
+
console.log(`[hicortex] Strength promotion (dry-run): ${rows.length} examined, would promote ` +
|
|
1421
|
+
`${promoted} (+${totalGain.toFixed(3)} total strength, ` +
|
|
1422
|
+
`${demotedSkipped} demotion-set rows advance their baseline only).`);
|
|
1404
1423
|
return { promoted, demoted_skipped: demotedSkipped };
|
|
1405
1424
|
}
|
|
1406
1425
|
// One transaction for the whole stage (the stageMemoryCapEviction pattern):
|
|
@@ -1411,8 +1430,9 @@ function stagePromotion(db, dryRun) {
|
|
|
1411
1430
|
storage.updateMemory(db, w.id, w.fields);
|
|
1412
1431
|
});
|
|
1413
1432
|
tx();
|
|
1414
|
-
console.log(`[hicortex] Strength promotion: promoted ${promoted}
|
|
1415
|
-
`(
|
|
1433
|
+
console.log(`[hicortex] Strength promotion: ${rows.length} examined, promoted ${promoted} ` +
|
|
1434
|
+
`(+${totalGain.toFixed(3)} total strength, ` +
|
|
1435
|
+
`${demotedSkipped} demotion-set rows advance their baseline only).`);
|
|
1416
1436
|
return { promoted, demoted_skipped: demotedSkipped };
|
|
1417
1437
|
}
|
|
1418
1438
|
// ---------------------------------------------------------------------------
|
|
@@ -1655,12 +1675,29 @@ deadline) {
|
|
|
1655
1675
|
};
|
|
1656
1676
|
// Stage 1: Pre-check
|
|
1657
1677
|
const precheck = stagePrecheck(db, stateDir);
|
|
1658
|
-
//
|
|
1678
|
+
// #478: the importance pool is ROW-AGE bound — young (ingested within
|
|
1679
|
+
// IMPORTANCE_SETTLE_WINDOW_DAYS) ∪ never-scored. The lastConsolidated
|
|
1680
|
+
// watermark no longer defines any part of it: a deferred run's stuck
|
|
1681
|
+
// watermark used to re-settle a growing cohort nightly, and every
|
|
1682
|
+
// re-settle overwrites base_strength outright (erasing gains
|
|
1683
|
+
// stagePromotion had already paid — 5/5 observed erasures). The watermark
|
|
1684
|
+
// cohort (precheck.newMemories) STILL feeds reflection + links below —
|
|
1685
|
+
// their work is batch retry by design; only importance's per-row settling
|
|
1686
|
+
// was mis-bound to the batch marker.
|
|
1687
|
+
const settleCutoff = new Date(Date.now() - CALIBRATION.IMPORTANCE_SETTLE_WINDOW_DAYS * 86_400_000).toISOString();
|
|
1688
|
+
// getMemoriesSince keys on ingested_at only — absorbed rows (invisible to
|
|
1689
|
+
// recall) are filtered here in the same vocabulary getUnscoredMemories
|
|
1690
|
+
// uses in SQL: no LLM call on dead evidence.
|
|
1691
|
+
const young = storage
|
|
1692
|
+
.getMemoriesSince(db, settleCutoff)
|
|
1693
|
+
.filter((m) => m.status !== "absorbed");
|
|
1694
|
+
const youngIds = new Set(young.map((m) => m.id));
|
|
1695
|
+
// Also check for unscored memories (#425 watermark pool) — first settle is
|
|
1696
|
+
// age-independent, so a row used before its first score is never stranded.
|
|
1659
1697
|
const unscored = storage.getUnscoredMemories(db);
|
|
1660
|
-
const newIds = new Set(precheck.newMemories.map((m) => m.id));
|
|
1661
1698
|
const scoreMemories = [
|
|
1662
|
-
...
|
|
1663
|
-
...unscored.filter((m) => !
|
|
1699
|
+
...young,
|
|
1700
|
+
...unscored.filter((m) => !youngIds.has(m.id)),
|
|
1664
1701
|
];
|
|
1665
1702
|
// #194 no-fit scope: untagged rows (domain IS NULL) stay in the
|
|
1666
1703
|
// re-evaluation scope — the decay/re-attempt contract ("re-halves once per
|
|
@@ -1669,15 +1706,26 @@ deadline) {
|
|
|
1669
1706
|
// never caught because stagePrecheck used to read the AMBIENT (always
|
|
1670
1707
|
// empty in the suite) state instead of the run's own watermark — threading
|
|
1671
1708
|
// stateDir (#357) exposed the divergence between test and production.
|
|
1672
|
-
|
|
1673
|
-
|
|
1709
|
+
// #477: absorbed dedup losers (tags cleared, domain NULLed by the merge)
|
|
1710
|
+
// are dead evidence — they must not keep the no-fit scope (and the run)
|
|
1711
|
+
// alive every night. Same predicate spelling as getUnscoredMemories.
|
|
1712
|
+
const nofitInScope = db
|
|
1713
|
+
.prepare("SELECT COUNT(*) AS n FROM memories WHERE domain IS NULL AND COALESCE(status, '') != 'absorbed'")
|
|
1714
|
+
.get().n;
|
|
1715
|
+
// #478: the quiet-night gate is the UNION — watermark cohort OR young OR
|
|
1716
|
+
// unscored OR no-fit scope. A stuck watermark alone must never silence
|
|
1717
|
+
// reflection/links (their pool is the watermark cohort, and their retry
|
|
1718
|
+
// semantics are the reason a deferred run holds the watermark at all).
|
|
1719
|
+
const skip = scoreMemories.length === 0 &&
|
|
1720
|
+
precheck.newMemories.length === 0 &&
|
|
1721
|
+
nofitInScope === 0;
|
|
1674
1722
|
report.stages.precheck = {
|
|
1675
1723
|
skip,
|
|
1676
1724
|
reason: skip
|
|
1677
1725
|
? precheck.reason
|
|
1678
|
-
: `${precheck.newMemories.length} new + ${scoreMemories.length -
|
|
1726
|
+
: `${precheck.newMemories.length} new (watermark) + ${young.length} young + ${scoreMemories.length - young.length} unscored memories`,
|
|
1679
1727
|
new_memory_count: precheck.newMemories.length,
|
|
1680
|
-
unscored_count:
|
|
1728
|
+
unscored_count: unscored.length,
|
|
1681
1729
|
};
|
|
1682
1730
|
// Strength promotion (#448) — runs in the pre-skip deterministic zone, in
|
|
1683
1731
|
// the same placement discipline as memory_cap BELOW it: accesses happen on
|
|
@@ -1715,7 +1763,27 @@ deadline) {
|
|
|
1715
1763
|
// deferred. Deferred stages drain next run (cursors hold below them).
|
|
1716
1764
|
// Stage 2: Importance Scoring
|
|
1717
1765
|
if (!deadline?.hit("importance")) {
|
|
1718
|
-
|
|
1766
|
+
// #478 paid-gain guard — evaluated HERE, at the stage boundary, not at
|
|
1767
|
+
// pool-build time: the pool above was built before this run's
|
|
1768
|
+
// stagePromotion payment, so a row whose FIRST use lands this run
|
|
1769
|
+
// passes a pool-build check and its just-paid gain is overwritten at
|
|
1770
|
+
// the first re-settle. This read sits after promotion by construction
|
|
1771
|
+
// and sees the advanced baseline. Already-scored rows carrying a paid
|
|
1772
|
+
// gain (promotion baseline advanced OR owner corroboration) keep it —
|
|
1773
|
+
// re-settling would stomp base_strength; `hicortex rescore-importance`
|
|
1774
|
+
// remains the wholesale operator re-judge. Never-scored rows
|
|
1775
|
+
// (importance_scored_at IS NULL) are NEVER skipped: first settle
|
|
1776
|
+
// always happens, whatever their use history.
|
|
1777
|
+
const paidGainIds = scoreMemories.length
|
|
1778
|
+
? new Set(db.prepare(`SELECT id FROM memories
|
|
1779
|
+
WHERE importance_scored_at IS NOT NULL
|
|
1780
|
+
AND (COALESCE(promotion_last_count, 0) > 0
|
|
1781
|
+
OR COALESCE(corroboration_count, 0) > 0)
|
|
1782
|
+
AND id IN (${scoreMemories.map(() => "?").join(", ")})`).all(...scoreMemories.map((m) => m.id))
|
|
1783
|
+
.map((r) => r.id))
|
|
1784
|
+
: new Set();
|
|
1785
|
+
const settleCandidates = scoreMemories.filter((m) => !paidGainIds.has(m.id));
|
|
1786
|
+
report.stages.importance = await stageImportance(db, settleCandidates, llm, budget, dryRun, deadline, scoreMemories.length - settleCandidates.length);
|
|
1719
1787
|
}
|
|
1720
1788
|
// Stage 2.5: Reflection
|
|
1721
1789
|
if (deadline?.hit("reflection")) {
|
package/dist/dashboard.d.ts
CHANGED
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
import type express from "express";
|
|
24
24
|
import type Database from "better-sqlite3";
|
|
25
25
|
import { type CaptureHealthRow } from "./capture-health.js";
|
|
26
|
+
import { type MemoryPrecision } from "./recall-precision.js";
|
|
26
27
|
import { type Stage } from "./stages.js";
|
|
27
28
|
/** Corpus-shape snapshot. `adoption` is null in backfilled rows (point-in-time,
|
|
28
29
|
* can't be reconstructed from created_at). */
|
|
@@ -257,6 +258,16 @@ export interface DashboardData {
|
|
|
257
258
|
last_outcome: string;
|
|
258
259
|
}>;
|
|
259
260
|
};
|
|
261
|
+
/**
|
|
262
|
+
* #476 — the Memory Precision card: Level 1 (pushed-index precision
|
|
263
|
+
* proxies over the recall_pushes/recall_events window) beside Level 2
|
|
264
|
+
* (recall depth = the uses-per-showing ratio over the SAME window, from
|
|
265
|
+
* snapshot deltas) + the divergence list. ALWAYS present; edges echoed
|
|
266
|
+
* like every threshold (the page never hardcodes), and the block measures
|
|
267
|
+
* ≤ 2 KB against the live 30-day payload. The page degrades to the
|
|
268
|
+
* pre-#476 recall-card rendering when the block is absent.
|
|
269
|
+
*/
|
|
270
|
+
memory_precision: MemoryPrecision;
|
|
260
271
|
digest: {
|
|
261
272
|
date: string | null;
|
|
262
273
|
run_at: string | null;
|
package/dist/dashboard.js
CHANGED
|
@@ -45,6 +45,7 @@ const recall_index_js_1 = require("./recall-index.js");
|
|
|
45
45
|
const config_read_js_1 = require("./config-read.js");
|
|
46
46
|
const consolidate_js_1 = require("./consolidate.js");
|
|
47
47
|
const capture_health_js_1 = require("./capture-health.js");
|
|
48
|
+
const recall_precision_js_1 = require("./recall-precision.js");
|
|
48
49
|
const capture_pause_js_1 = require("./capture-pause.js");
|
|
49
50
|
const state_js_1 = require("./state.js");
|
|
50
51
|
const retrieval_js_1 = require("./retrieval.js");
|
|
@@ -576,6 +577,14 @@ function handleDashboardData(db, query, config) {
|
|
|
576
577
|
by_source_machine: live.by_source_machine,
|
|
577
578
|
},
|
|
578
579
|
capture_health: { ...(0, capture_health_js_1.readCaptureHealth)(db), ...(0, capture_health_js_1.readCaptureHealthWindow)(db) },
|
|
580
|
+
// #476 Memory Precision: the window aggregation over the precision
|
|
581
|
+
// event tables + snapshot deltas. Level 2's "latest" is the SAME live
|
|
582
|
+
// adoption this handler already computed (one definition of corpus
|
|
583
|
+
// shape — computeDashboardMetrics); readMemoryPrecision owns the rest.
|
|
584
|
+
memory_precision: (0, recall_precision_js_1.readMemoryPrecision)(db, rangeParam, {
|
|
585
|
+
shown_sum: live.adoption?.shown_sum ?? 0,
|
|
586
|
+
used_sum: live.adoption?.used_sum ?? 0,
|
|
587
|
+
}),
|
|
579
588
|
// #423 phase 3: pauses + presence in one block — one source of truth
|
|
580
589
|
// for the rail's dots, toggles and the capture card's PAUSED badges.
|
|
581
590
|
fleet: {
|
package/dist/db.js
CHANGED
|
@@ -668,6 +668,80 @@ const MIGRATIONS = [
|
|
|
668
668
|
db.exec("UPDATE memories SET promotion_last_count = access_count WHERE promotion_last_count IS NULL");
|
|
669
669
|
},
|
|
670
670
|
},
|
|
671
|
+
{
|
|
672
|
+
version: 21,
|
|
673
|
+
name: "memory_layout",
|
|
674
|
+
up: (db) => {
|
|
675
|
+
// #464 — the console field's server-computed placement. One row per
|
|
676
|
+
// placed memory at one epoch; the CURRENT epoch is MAX(epoch) and the
|
|
677
|
+
// payload (dashboard.ts handleDashboardField) LEFT JOINs on it. A full
|
|
678
|
+
// re-projection (`hicortex layout --reproject`) writes a NEW epoch and
|
|
679
|
+
// drops older ones in the SAME transaction (atomic swap — a mid-write
|
|
680
|
+
// failure leaves the previous epoch fully intact). Epochs exist so the
|
|
681
|
+
// whole store moves at once (one announced visual shift), never as a
|
|
682
|
+
// mix of projections. Idempotent: IF NOT EXISTS (the v18 pattern —
|
|
683
|
+
// plain CREATE, no ALTER).
|
|
684
|
+
// Kept after the #464/#475 field revert (#474): nothing reads or writes
|
|
685
|
+
// this table now — a dormant orphan. Rc databases already applied v21
|
|
686
|
+
// (user_version 21), so removing it would fork schema versions; it
|
|
687
|
+
// stays per the append-only migration discipline.
|
|
688
|
+
db.exec(`
|
|
689
|
+
CREATE TABLE IF NOT EXISTS memory_layout (
|
|
690
|
+
memory_id TEXT PRIMARY KEY,
|
|
691
|
+
x REAL NOT NULL,
|
|
692
|
+
y REAL NOT NULL,
|
|
693
|
+
epoch INTEGER NOT NULL
|
|
694
|
+
)
|
|
695
|
+
`);
|
|
696
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_memory_layout_epoch ON memory_layout(epoch)");
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
{
|
|
700
|
+
version: 22,
|
|
701
|
+
name: "recall_precision_events",
|
|
702
|
+
up: (db) => {
|
|
703
|
+
// #476 — the Memory Precision card's event store (Option A, owner
|
|
704
|
+
// decision 2026-09-19: per-push event rows incl. a ≤256-char prompt
|
|
705
|
+
// excerpt — the exact-window Level-1 measures + the judge follow-up's
|
|
706
|
+
// sample frame). Two sidecar tables, the v16 distill_activity pattern:
|
|
707
|
+
// OPERATIONS telemetry, no memories FK — recall_events.memory_id is
|
|
708
|
+
// DATA (absorbed memories' history stays queryable; rows outliving
|
|
709
|
+
// their memory are valid). recall_pushes: one row per NON-skipped
|
|
710
|
+
// /recall-index call (short-prompt skips and resets record nothing; a
|
|
711
|
+
// silent turn records its push row with zero events — the silence rate
|
|
712
|
+
// is computable). recall_events: one kind='shown' row per pushed line
|
|
713
|
+
// (similarity = cosine vs the PURE prompt embedding, redundancy = max
|
|
714
|
+
// cosine vs the standing-context basis — both RAW, verdicts computed at
|
|
715
|
+
// render so recalibration never rewrites history) + one kind='fetch'
|
|
716
|
+
// row per handleMemoryGet (push_id NULL). Pruned nightly at the
|
|
717
|
+
// retention horizon (= the longest window, the capture-health
|
|
718
|
+
// single-constant law). Idempotent: IF NOT EXISTS everywhere.
|
|
719
|
+
db.exec(`
|
|
720
|
+
CREATE TABLE IF NOT EXISTS recall_pushes (
|
|
721
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
722
|
+
ts TEXT NOT NULL,
|
|
723
|
+
day TEXT NOT NULL,
|
|
724
|
+
session_id TEXT,
|
|
725
|
+
prompt_excerpt TEXT
|
|
726
|
+
)
|
|
727
|
+
`);
|
|
728
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_recall_pushes_day ON recall_pushes(day)");
|
|
729
|
+
db.exec(`
|
|
730
|
+
CREATE TABLE IF NOT EXISTS recall_events (
|
|
731
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
732
|
+
ts TEXT NOT NULL,
|
|
733
|
+
day TEXT NOT NULL,
|
|
734
|
+
push_id INTEGER,
|
|
735
|
+
memory_id TEXT NOT NULL,
|
|
736
|
+
similarity REAL,
|
|
737
|
+
redundancy REAL,
|
|
738
|
+
kind TEXT NOT NULL
|
|
739
|
+
)
|
|
740
|
+
`);
|
|
741
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_recall_events_day ON recall_events(day)");
|
|
742
|
+
db.exec("CREATE INDEX IF NOT EXISTS idx_recall_events_memory_kind ON recall_events(memory_id, kind)");
|
|
743
|
+
},
|
|
744
|
+
},
|
|
671
745
|
];
|
|
672
746
|
/**
|
|
673
747
|
* Run all pending migrations against the database.
|
|
@@ -70,8 +70,10 @@ export interface StructuralStrengthStats {
|
|
|
70
70
|
* "if nothing changes" projection), plus the asymptotic floor per memory.
|
|
71
71
|
* Requires `configureDecay` to already reflect the desired half-life (call
|
|
72
72
|
* `runPruneDryRun` first, or `configureDecay` directly, in the same process).
|
|
73
|
+
* `now` (#458): the instant "now" means — pinned by run-eval's --now for
|
|
74
|
+
* wall-clock-independent before/after audits; default the live clock.
|
|
73
75
|
*/
|
|
74
|
-
export declare function runStructuralStrengthStats(db: Database.Database): StructuralStrengthStats;
|
|
76
|
+
export declare function runStructuralStrengthStats(db: Database.Database, now?: Date): StructuralStrengthStats;
|
|
75
77
|
export interface AdoptionStats {
|
|
76
78
|
totalMemories: number;
|
|
77
79
|
shownCountHistogram: Record<string, number>;
|
|
@@ -106,4 +108,4 @@ export interface AdoptionStats {
|
|
|
106
108
|
* feature's youth as much as its effectiveness. Report callers should state
|
|
107
109
|
* that caveat alongside the numbers, not just the numbers.
|
|
108
110
|
*/
|
|
109
|
-
export declare function runAdoptionStats(db: Database.Database): AdoptionStats;
|
|
111
|
+
export declare function runAdoptionStats(db: Database.Database, now?: Date): AdoptionStats;
|
package/dist/eval/decay-eval.js
CHANGED
|
@@ -97,12 +97,13 @@ function histogram(values, edges = STRENGTH_BUCKET_EDGES) {
|
|
|
97
97
|
* "if nothing changes" projection), plus the asymptotic floor per memory.
|
|
98
98
|
* Requires `configureDecay` to already reflect the desired half-life (call
|
|
99
99
|
* `runPruneDryRun` first, or `configureDecay` directly, in the same process).
|
|
100
|
+
* `now` (#458): the instant "now" means — pinned by run-eval's --now for
|
|
101
|
+
* wall-clock-independent before/after audits; default the live clock.
|
|
100
102
|
*/
|
|
101
|
-
function runStructuralStrengthStats(db) {
|
|
103
|
+
function runStructuralStrengthStats(db, now = new Date()) {
|
|
102
104
|
const rows = db
|
|
103
105
|
.prepare("SELECT id, base_strength, last_accessed FROM memories")
|
|
104
106
|
.all();
|
|
105
|
-
const now = new Date();
|
|
106
107
|
const nowVals = [];
|
|
107
108
|
const at180 = [];
|
|
108
109
|
const at365 = [];
|
|
@@ -147,11 +148,10 @@ function ageBucketLabel(ageDays) {
|
|
|
147
148
|
* feature's youth as much as its effectiveness. Report callers should state
|
|
148
149
|
* that caveat alongside the numbers, not just the numbers.
|
|
149
150
|
*/
|
|
150
|
-
function runAdoptionStats(db) {
|
|
151
|
+
function runAdoptionStats(db, now = new Date()) {
|
|
151
152
|
const rows = db
|
|
152
153
|
.prepare("SELECT id, shown_count, access_count, source_agent, created_at FROM memories")
|
|
153
154
|
.all();
|
|
154
|
-
const now = new Date();
|
|
155
155
|
const shownVals = [];
|
|
156
156
|
const accessVals = [];
|
|
157
157
|
let totalShown = 0;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #458 — the shared pinned-clock helper for eval harnesses.
|
|
3
|
+
*
|
|
4
|
+
* The recall evals score against wall-clock time (recency + decay terms in
|
|
5
|
+
* computeScore/effectiveStrength), so a pre-photo and a post-run hours apart
|
|
6
|
+
* drift even on identical code — PR D evidenced mode-OFF units moving with no
|
|
7
|
+
* changed code in the path. The fix is a harness-visible pin: every eval that
|
|
8
|
+
* calls retrieve() (or the decay/adoption stats) accepts a `--now <ISO>` flag
|
|
9
|
+
* and threads the SAME instant into the retrieve() `now` seam (retrieval.ts —
|
|
10
|
+
* the injectable-clock idiom of run-deadline.ts), making before/after runs
|
|
11
|
+
* wall-clock-independent. Default stays the live clock — pinning is opt-in,
|
|
12
|
+
* for comparability.
|
|
13
|
+
*
|
|
14
|
+
* Fail-explicit by design (the repo convention): an invalid pin THROWS with a
|
|
15
|
+
* clear message rather than silently falling back to the live clock — a run
|
|
16
|
+
* that believed it was pinned but wasn't would silently reintroduce the drift
|
|
17
|
+
* class this helper exists to remove.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Parse a `--now` flag value into the pinned instant.
|
|
21
|
+
*
|
|
22
|
+
* @param raw the raw flag value. undefined or empty/whitespace → null (the
|
|
23
|
+
* live clock — the flag was not passed). Anything else must parse as a
|
|
24
|
+
* Date; garbage or an invalid instant (e.g. month 13) throws.
|
|
25
|
+
* @returns the pinned Date, or null for the live clock.
|
|
26
|
+
*/
|
|
27
|
+
export declare function parsePinnedNow(raw: string | undefined): Date | null;
|
|
28
|
+
/**
|
|
29
|
+
* Human-readable clock mode for report headers / photo JSON: "pinned <ISO>"
|
|
30
|
+
* or "live" — so artifacts are self-describing about which clock produced them.
|
|
31
|
+
*/
|
|
32
|
+
export declare function clockLabel(now: Date | null): string;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* #458 — the shared pinned-clock helper for eval harnesses.
|
|
4
|
+
*
|
|
5
|
+
* The recall evals score against wall-clock time (recency + decay terms in
|
|
6
|
+
* computeScore/effectiveStrength), so a pre-photo and a post-run hours apart
|
|
7
|
+
* drift even on identical code — PR D evidenced mode-OFF units moving with no
|
|
8
|
+
* changed code in the path. The fix is a harness-visible pin: every eval that
|
|
9
|
+
* calls retrieve() (or the decay/adoption stats) accepts a `--now <ISO>` flag
|
|
10
|
+
* and threads the SAME instant into the retrieve() `now` seam (retrieval.ts —
|
|
11
|
+
* the injectable-clock idiom of run-deadline.ts), making before/after runs
|
|
12
|
+
* wall-clock-independent. Default stays the live clock — pinning is opt-in,
|
|
13
|
+
* for comparability.
|
|
14
|
+
*
|
|
15
|
+
* Fail-explicit by design (the repo convention): an invalid pin THROWS with a
|
|
16
|
+
* clear message rather than silently falling back to the live clock — a run
|
|
17
|
+
* that believed it was pinned but wasn't would silently reintroduce the drift
|
|
18
|
+
* class this helper exists to remove.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.parsePinnedNow = parsePinnedNow;
|
|
22
|
+
exports.clockLabel = clockLabel;
|
|
23
|
+
/**
|
|
24
|
+
* Parse a `--now` flag value into the pinned instant.
|
|
25
|
+
*
|
|
26
|
+
* @param raw the raw flag value. undefined or empty/whitespace → null (the
|
|
27
|
+
* live clock — the flag was not passed). Anything else must parse as a
|
|
28
|
+
* Date; garbage or an invalid instant (e.g. month 13) throws.
|
|
29
|
+
* @returns the pinned Date, or null for the live clock.
|
|
30
|
+
*/
|
|
31
|
+
function parsePinnedNow(raw) {
|
|
32
|
+
if (raw === undefined || raw.trim() === "")
|
|
33
|
+
return null;
|
|
34
|
+
const parsed = new Date(raw);
|
|
35
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
36
|
+
throw new Error(`eval clock: invalid --now value "${raw}" — pass a full ISO 8601 instant ` +
|
|
37
|
+
`(e.g. 2026-09-17T09:00:00.000Z) or omit the flag to run on the live clock`);
|
|
38
|
+
}
|
|
39
|
+
return parsed;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Human-readable clock mode for report headers / photo JSON: "pinned <ISO>"
|
|
43
|
+
* or "live" — so artifacts are self-describing about which clock produced them.
|
|
44
|
+
*/
|
|
45
|
+
function clockLabel(now) {
|
|
46
|
+
return now === null ? "live" : `pinned ${now.toISOString()}`;
|
|
47
|
+
}
|