@gamaze/hicortex 0.23.1 → 0.24.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 (67) hide show
  1. package/assets/dashboard.html +64 -13
  2. package/dist/calibration.d.ts +31 -0
  3. package/dist/calibration.js +38 -1
  4. package/dist/capture.d.ts +7 -0
  5. package/dist/capture.js +10 -1
  6. package/dist/dashboard.d.ts +32 -7
  7. package/dist/dashboard.js +50 -10
  8. package/dist/db.js +45 -0
  9. package/dist/dedup.js +2 -2
  10. package/dist/distill-queue.d.ts +203 -0
  11. package/dist/distill-queue.js +440 -0
  12. package/dist/health.d.ts +13 -1
  13. package/dist/health.js +6 -1
  14. package/dist/hosted-boot.d.ts +1 -1
  15. package/dist/index.js +17 -2
  16. package/dist/learnings-identity.js +20 -1
  17. package/dist/localhost-bypass.js +1 -1
  18. package/dist/mcp-server.js +92 -7
  19. package/dist/nightly.js +88 -1
  20. package/dist/nofit.d.ts +1 -1
  21. package/dist/nofit.js +1 -1
  22. package/dist/schema-prototypes.d.ts +1 -1
  23. package/dist/schema-prototypes.js +1 -1
  24. package/dist/status.d.ts +11 -0
  25. package/dist/status.js +25 -0
  26. package/dist/types.d.ts +20 -0
  27. package/hermes-plugin/hicortex/README.md +13 -6
  28. package/hermes-plugin/hicortex/__init__.py +7 -0
  29. package/hermes-plugin/hicortex/client.py +64 -2
  30. package/hermes-plugin/hicortex/plugin.yaml +1 -1
  31. package/hermes-plugin/hicortex/provider.py +24 -1
  32. package/opencode-plugin/hicortex/index.ts +24 -1
  33. package/package.json +2 -1
  34. package/pi-extension/hicortex/index.ts +24 -1
  35. package/server.json +2 -2
  36. package/dist/eval/decay-eval.d.ts +0 -111
  37. package/dist/eval/decay-eval.js +0 -214
  38. package/dist/eval/dups.d.ts +0 -100
  39. package/dist/eval/dups.js +0 -174
  40. package/dist/eval/eval-clock.d.ts +0 -32
  41. package/dist/eval/eval-clock.js +0 -47
  42. package/dist/eval/eval-db.d.ts +0 -25
  43. package/dist/eval/eval-db.js +0 -67
  44. package/dist/eval/graph-eval.d.ts +0 -89
  45. package/dist/eval/graph-eval.js +0 -246
  46. package/dist/eval/importance-eval.d.ts +0 -85
  47. package/dist/eval/importance-eval.js +0 -286
  48. package/dist/eval/planted-eval.d.ts +0 -30
  49. package/dist/eval/planted-eval.js +0 -122
  50. package/dist/eval/planted-fixtures.d.ts +0 -107
  51. package/dist/eval/planted-fixtures.js +0 -283
  52. package/dist/eval/planted-harness.d.ts +0 -183
  53. package/dist/eval/planted-harness.js +0 -651
  54. package/dist/eval/ranking-battery.d.ts +0 -125
  55. package/dist/eval/ranking-battery.js +0 -289
  56. package/dist/eval/ranking-eval.d.ts +0 -61
  57. package/dist/eval/ranking-eval.js +0 -554
  58. package/dist/eval/ranking-fixtures.d.ts +0 -117
  59. package/dist/eval/ranking-fixtures.js +0 -485
  60. package/dist/eval/recall-sweep.d.ts +0 -87
  61. package/dist/eval/recall-sweep.js +0 -1030
  62. package/dist/eval/reflection-census.d.ts +0 -19
  63. package/dist/eval/reflection-census.js +0 -25
  64. package/dist/eval/relevance-eval.d.ts +0 -178
  65. package/dist/eval/relevance-eval.js +0 -2240
  66. package/dist/eval/run-eval.d.ts +0 -20
  67. package/dist/eval/run-eval.js +0 -299
@@ -76,7 +76,7 @@
76
76
  badged "best match" (server composite order kept — ranking is #425).
77
77
  All-below-floor renders "no matches for '<q>'" — NN junk never shows.
78
78
  - ONE-SCREEN LAYOUT: header + band + field fit the first viewport at
79
- ~1440×900 (MBP5 reference; 1280×800 verified). Band compacted; the
79
+ ~1440×900 (reference resolution; 1280×800 verified). Band compacted; the
80
80
  field takes the viewport remainder (clamp with a measured offset —
81
81
  see the #brainwrap CSS note). Scroll stays available as overflow.
82
82
 
@@ -574,7 +574,7 @@
574
574
 
575
575
  /* ---- the brain-field ---- */
576
576
  /* ONE-SCREEN AT-REST LAYOUT (#409 polish, owner ruling 2026-09-13): header
577
- + stats band + field fit the first viewport at ~1440×900 (MBP5 is the
577
+ + stats band + field fit the first viewport at ~1440×900 (that is the
578
578
  reference; 1280×800 verified too). The field takes the viewport REMAINDER
579
579
  — the subtracted constant is the MEASURED header+band+paddings total on
580
580
  the reference store (Chrome 152, headless): 412px at the 4-column width,
@@ -865,6 +865,15 @@
865
865
  /* #423 phase 3: the capture-pause badge — the same amber family as the
866
866
  held badge (an operator state, not an error) */
867
867
  .cap-paused{ color:var(--amber); margin-left:5px; font-size:9px; letter-spacing:.08em; }
868
+ /* #530 follow-up: the distill-inbox row — stale badge + bar fill in the
869
+ amber family once the oldest queued item passes the payload's ECHOED warn
870
+ age. [hidden] must win over .cap-row2's flex (author display beats the
871
+ UA's hidden — the #billing-banner idiom) so a pre-#529 server shows
872
+ nothing, theme tokens only */
873
+ .cap-qrow[hidden]{ display:none; }
874
+ .cap-stale{ color:var(--amber); margin-left:5px; }
875
+ .cap-qrow .fill.warn{ background:var(--amber); }
876
+ .cap-qrow.stale .cap-dot{ background:var(--amber); }
868
877
 
869
878
  /* ================= the memory dropdown — opens UNDER the left search ================= */
870
879
  #panel{
@@ -1277,6 +1286,11 @@
1277
1286
  <div class="brow">
1278
1287
  <div class="bseg" id="g-capture">
1279
1288
  <div class="gh"><span class="t">Capture health</span><span class="sub" id="cap-sub">memory counts</span></div>
1289
+ <!-- #530 follow-up: the distill inbox — the drain-side counterpart of
1290
+ this card's delivery-side health. Pinned above the scrollable
1291
+ #cap-rows; hidden until buildCapture's payload guard unhides it
1292
+ (pre-#529 servers omit the block). -->
1293
+ <div class="cap-row2 cap-qrow" id="cap-queue" hidden></div>
1280
1294
  <div id="cap-rows"></div>
1281
1295
  </div>
1282
1296
  <div class="bseg" id="g-roll">
@@ -1395,7 +1409,7 @@
1395
1409
  </label>
1396
1410
  <label class="mm-check"><input type="checkbox" id="mm-think"> enable thinking <small>(local endpoints only)</small></label>
1397
1411
  </div>
1398
- <div class="mm-note">applies on server restart</div>
1412
+ <div class="mm-note" id="mm-note">applies on server restart</div>
1399
1413
  <div class="mm-foot">
1400
1414
  <button class="btn" id="mm-cancel">Cancel</button>
1401
1415
  <button class="btn save" id="mm-save">Save</button>
@@ -2414,7 +2428,7 @@ function draw(now, dt){
2414
2428
  if(L.rk>=lodK) continue; // zoom LOD (lines only)
2415
2429
  const a=mems.get(L.a), b=mems.get(L.b);
2416
2430
  if(!a||!b||!aliveAt(a,t)||!aliveAt(b,t)) continue;
2417
- /* #474 final polish: the both-endpoints-in-window gate killed the mesh — links draw between ANY placed memories (bedrock parity: the full LOD-tamed mesh), endpoints carry positions regardless of window membership */
2431
+ /* #474 final polish: the both-endpoints-in-window gate killed the mesh — links draw between ANY placed memories (reference parity: the full LOD-tamed mesh), endpoints carry positions regardless of window membership */
2418
2432
  if(a._emerged===false||b._emerged===false) continue; // thread waits for both endpoints
2419
2433
  const [ax,ay]=bpos(a,now,t), [bx,by]=bpos(b,now,t);
2420
2434
  const ctrl=segCtrlN(ax,ay,bx,by,(L.use>18?1:-1));
@@ -3398,8 +3412,8 @@ function renderRecBand(ups){
3398
3412
  /* No trend arrow (owner 2026-09-20: removed — unclear). Its renderer and
3399
3413
  the element writes are removed WITH it: a leftover write to a removed
3400
3414
  element is a boot-killing TypeError whenever the recall band carries
3401
- real data (bedrock 0.22.2: "0 memories" — boot died before the counter
3402
- was ever seeded). */
3415
+ real data (a production instance on 0.22.2: "0 memories" — boot died
3416
+ before the counter was ever seeded). */
3403
3417
  }
3404
3418
 
3405
3419
  /* ---------- Memory Precision — Level 1, the pushed index (#476). A compact
@@ -3509,32 +3523,44 @@ function renderModelCard(){
3509
3523
  only as api_key_set), writes PUT with the subset the user touched. The
3510
3524
  api-key input is WRITE-ONLY: prefilled with nothing, sent only when
3511
3525
  non-empty ("leave blank to keep"). enable_thinking is sent only when the
3512
- user toggles the checkbox (tri-state not needed). ---------- */
3526
+ user toggles the checkbox (tri-state not needed).
3527
+ Hosted mode (#514): the GET carries managed:true — the hosting service
3528
+ owns the endpoint + credentials, so their inputs are disabled and never
3529
+ ride the PUT body (a save cannot hit the server's managed-field 403). */
3513
3530
  let mmThinkTouched=false;
3514
3531
  function openModelModal(){
3515
3532
  if(!M) return; // no settings payload → no dialog (degrade)
3516
3533
  const mdl=el('model-modal');
3534
+ const managed=!!M.managed;
3517
3535
  el('mm-backend').value=M.backend||''; // '' = auto / OpenAI-compatible
3518
3536
  el('mm-baseurl').value=M.base_url||'';
3519
3537
  el('mm-model').value=M.model||'';
3520
3538
  const ak=el('mm-apikey'); ak.value='';
3521
- ak.placeholder=M.api_key_set?'configured — leave blank to keep':'api key (optional for local endpoints)';
3539
+ ak.placeholder=managed?'managed by the hosting service'
3540
+ :(M.api_key_set?'configured — leave blank to keep':'api key (optional for local endpoints)');
3522
3541
  el('mm-maxtok').value=M.max_tokens!=null?String(M.max_tokens):'';
3523
3542
  el('mm-think').checked=!!M.enable_thinking;
3524
3543
  mmThinkTouched=false;
3544
+ for(const id of ['mm-backend','mm-baseurl','mm-apikey']) el(id).disabled=managed;
3545
+ el('mm-note').textContent=managed
3546
+ ?'endpoint and credentials are managed by the hosting service — applies on server restart'
3547
+ :'applies on server restart';
3525
3548
  mdl.hidden=false;
3526
- el('mm-baseurl').focus();
3549
+ (managed?el('mm-model'):el('mm-baseurl')).focus();
3527
3550
  }
3528
3551
  function closeModelModal(){ el('model-modal').hidden=true; }
3529
3552
  async function saveModelSettings(){
3530
3553
  /* Build the PUT body from CURRENT field state: empty base_url/model/max
3531
3554
  tokens = clear (null server-side); backend '' clears the named-backend
3532
- key; api_key only rides when typed; enable_thinking only when toggled. */
3555
+ key; api_key only rides when typed; enable_thinking only when toggled.
3556
+ Managed (hosted) mode: backend/base_url/api_key never ride the body. */
3533
3557
  const body={};
3534
- body.backend=el('mm-backend').value;
3535
- const bu=el('mm-baseurl').value.trim(); body.base_url=bu?bu:null;
3558
+ if(!M.managed){
3559
+ body.backend=el('mm-backend').value;
3560
+ const bu=el('mm-baseurl').value.trim(); body.base_url=bu?bu:null;
3561
+ const ak=el('mm-apikey').value; if(ak) body.api_key=ak;
3562
+ }
3536
3563
  const mo=el('mm-model').value.trim(); body.model=mo?mo:null;
3537
- const ak=el('mm-apikey').value; if(ak) body.api_key=ak;
3538
3564
  const mt=el('mm-maxtok').value.trim(); body.max_tokens=mt?Number(mt):null;
3539
3565
  if(mmThinkTouched) body.enable_thinking=el('mm-think').checked;
3540
3566
  try{
@@ -3754,6 +3780,31 @@ function agoLabel(iso){
3754
3780
  function buildCapture(){
3755
3781
  const host=el('cap-rows');
3756
3782
  host.innerHTML='';
3783
+ /* #530 follow-up: the distill-inbox row — rendered FIRST because every
3784
+ render branch below returns early (a call after them would only run in
3785
+ the degrade). The absent-block guard keeps it hidden on a pre-#529
3786
+ server; the warn threshold is always the payload's echo (#476
3787
+ degrade-literal precedent), never a page literal. */
3788
+ const qrow=el('cap-queue');
3789
+ qrow.innerHTML='';
3790
+ qrow.className='cap-row2 cap-qrow';
3791
+ const q=D&&D.distill_queue?D.distill_queue:null;
3792
+ qrow.hidden=!q;
3793
+ if(q){
3794
+ const thr=q.warn_age_hours||24;
3795
+ const oldest=(typeof q.oldest_age_hours==='number')?q.oldest_age_hours:null;
3796
+ const stale=oldest!==null&&oldest>thr;
3797
+ if(stale) qrow.classList.add('stale');
3798
+ qrow.title='Distill inbox — deliveries are stored durably at POST time and distilled by the scheduled nightly run before consolidation'
3799
+ +(stale?' — the oldest item is '+oldest+' h old (warn age '+thr+' h): the scheduled drain is behind':'');
3800
+ qrow.innerHTML='<span class="cap-dot"></span>'
3801
+ +'<span class="hn">Distill inbox</span>'
3802
+ +'<span class="cap-bar"><span class="fill'+(stale?' warn':'')+'" style="width:'+(oldest===null?0:Math.min(1,oldest/thr)*100)+'%"></span></span>'
3803
+ +'<span class="st"><b>'+fmt(q.depth||0)+'</b> queued'
3804
+ +(oldest===null?' · inbox empty':' · oldest '+(oldest>=48?(oldest/24).toFixed(1)+'d':oldest.toFixed(1)+'h'))
3805
+ +(stale?'<span class="cap-stale" title="oldest queued item past the '+thr+' h warn age — the scheduled drain is behind">&#9888; stale</span>':'')
3806
+ +'</span>';
3807
+ }
3757
3808
  const sub=el('cap-sub');
3758
3809
  const ch=D&&D.capture_health?D.capture_health:null;
3759
3810
  /* fix round 7: the rolling-window view — the card shows the normal 30 day
@@ -416,3 +416,34 @@ export declare function resolveOllamaFlushEvery(): number;
416
416
  * HICORTEX_OLLAMA_FLUSH_WAIT_MS wins; invalid warns and keeps
417
417
  * OLLAMA_FLUSH_WAIT_MS. */
418
418
  export declare function resolveOllamaFlushWaitMs(): number;
419
+ /**
420
+ * Re-check interval (ms) while the drain's yield signal reports the LLM
421
+ * endpoint busy (#529 owner ruling 05.10.2026: interactive use always
422
+ * yields). The drain waits one interval between probe re-checks instead of
423
+ * polling in a tight loop; 30 s is short enough that the run resumes within
424
+ * half a minute of the owner going idle, long enough not to hammer the
425
+ * signal endpoint while a long interactive session runs.
426
+ */
427
+ export declare const DRAIN_YIELD_POLL_MS = 30000;
428
+ /**
429
+ * Per-item cap (ms) on yield waits (#529). The run deadline is the primary
430
+ * bound; this cap is the fail-open backstop so a misconfigured signal that
431
+ * always answers "busy" cannot stall one item past half an hour — the item
432
+ * proceeds and the next between-items checks decide again. 30 min matches
433
+ * the order of a long local-model generation, the longest legitimate busy.
434
+ */
435
+ export declare const DRAIN_YIELD_WAIT_CAP_MS: number;
436
+ /**
437
+ * Timeout (ms) for ONE yield-probe GET (#529). Deliberately short: the probe
438
+ * sits between drain items, and an endpoint that answers busy-signals at all
439
+ * answers them fast — anything slower reads as unreachable (fail-open).
440
+ */
441
+ export declare const DRAIN_YIELD_PROBE_TIMEOUT_MS = 3000;
442
+ /**
443
+ * Oldest-item age (hours) above which the distill inbox warns in
444
+ * `hicortex status` and on the dashboard (#529). 24 h ≈ two missed scheduled
445
+ * runs on the default 2×/day consolidation grid — items older than that are
446
+ * not "waiting for tonight" anymore, they are stuck, and the operator should
447
+ * look at the drain's last outcome.
448
+ */
449
+ export declare const DRAIN_QUEUE_WARN_AGE_HOURS = 24;
@@ -25,7 +25,7 @@
25
25
  */
26
26
  Object.defineProperty(exports, "__esModule", { value: true });
27
27
  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.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.VOLATILE_GATE_MAX_CHARS = exports.VOLATILE_STATUS_FILTER = 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 = exports.STAGE_TRUTH_STRENGTH = void 0;
28
+ exports.DRAIN_QUEUE_WARN_AGE_HOURS = exports.DRAIN_YIELD_PROBE_TIMEOUT_MS = exports.DRAIN_YIELD_WAIT_CAP_MS = exports.DRAIN_YIELD_POLL_MS = 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 = exports.STAGE_TRUTH_STRENGTH = void 0;
29
29
  exports.resolveNumCtx = resolveNumCtx;
30
30
  exports.resolveOllamaFlushEvery = resolveOllamaFlushEvery;
31
31
  exports.resolveOllamaFlushWaitMs = resolveOllamaFlushWaitMs;
@@ -514,3 +514,40 @@ function resolveOllamaFlushWaitMs() {
514
514
  console.warn(`[hicortex] env HICORTEX_OLLAMA_FLUSH_WAIT_MS=${JSON.stringify(raw)} is not a positive finite number — using default ${exports.OLLAMA_FLUSH_WAIT_MS}.`);
515
515
  return exports.OLLAMA_FLUSH_WAIT_MS;
516
516
  }
517
+ // ---------------------------------------------------------------------------
518
+ // Distill inbox / drain family (#529) — delivery-compute separation. Delivery
519
+ // (cheap, must never lose data) stores segments durably; compute (expensive,
520
+ // scheduled) happens only inside the nightly's drain stage. These constants
521
+ // shape the drain's interactive-yield seam and the inbox's visibility signal.
522
+ // ---------------------------------------------------------------------------
523
+ /**
524
+ * Re-check interval (ms) while the drain's yield signal reports the LLM
525
+ * endpoint busy (#529 owner ruling 05.10.2026: interactive use always
526
+ * yields). The drain waits one interval between probe re-checks instead of
527
+ * polling in a tight loop; 30 s is short enough that the run resumes within
528
+ * half a minute of the owner going idle, long enough not to hammer the
529
+ * signal endpoint while a long interactive session runs.
530
+ */
531
+ exports.DRAIN_YIELD_POLL_MS = 30_000;
532
+ /**
533
+ * Per-item cap (ms) on yield waits (#529). The run deadline is the primary
534
+ * bound; this cap is the fail-open backstop so a misconfigured signal that
535
+ * always answers "busy" cannot stall one item past half an hour — the item
536
+ * proceeds and the next between-items checks decide again. 30 min matches
537
+ * the order of a long local-model generation, the longest legitimate busy.
538
+ */
539
+ exports.DRAIN_YIELD_WAIT_CAP_MS = 30 * 60_000;
540
+ /**
541
+ * Timeout (ms) for ONE yield-probe GET (#529). Deliberately short: the probe
542
+ * sits between drain items, and an endpoint that answers busy-signals at all
543
+ * answers them fast — anything slower reads as unreachable (fail-open).
544
+ */
545
+ exports.DRAIN_YIELD_PROBE_TIMEOUT_MS = 3_000;
546
+ /**
547
+ * Oldest-item age (hours) above which the distill inbox warns in
548
+ * `hicortex status` and on the dashboard (#529). 24 h ≈ two missed scheduled
549
+ * runs on the default 2×/day consolidation grid — items older than that are
550
+ * not "waiting for tonight" anymore, they are stuck, and the operator should
551
+ * look at the drain's last outcome.
552
+ */
553
+ exports.DRAIN_QUEUE_WARN_AGE_HOURS = 24;
package/dist/capture.d.ts CHANGED
@@ -90,6 +90,13 @@ export interface PostResult {
90
90
  completion: number;
91
91
  total: number;
92
92
  };
93
+ /**
94
+ * #529: the 201 confirmed durable QUEUEING, not distillation — a queue-mode
95
+ * server stores the segment and the nightly's drain distills it in the
96
+ * scheduled run. Contract-wise identical to any other 201 (the cursor
97
+ * advances); the capture loop only swaps its log line on it.
98
+ */
99
+ queued?: boolean;
93
100
  }
94
101
  export type PostFn = (body: DistillBody) => Promise<PostResult>;
95
102
  export interface CaptureOptions {
package/dist/capture.js CHANGED
@@ -301,7 +301,16 @@ async function captureBatches(batches, opts) {
301
301
  }
302
302
  if (advancesBoundary)
303
303
  lastConfirmedEnd = seg.segEnd;
304
- console.log(`[hicortex] → ${result.distilled ?? 0} memories (segment ${body.segment_id})`);
304
+ // #529: a queue-mode 201 means "durably stored, distilled on the
305
+ // server's next scheduled run" — same confirmation, different log
306
+ // line, so a nightly log never reads "0 memories" as a failure when
307
+ // the server simply queued the segment.
308
+ if (result.queued) {
309
+ console.log(`[hicortex] Queued on server (segment ${body.segment_id}) — distilled on the next scheduled run`);
310
+ }
311
+ else {
312
+ console.log(`[hicortex] → ${result.distilled ?? 0} memories (segment ${body.segment_id})`);
313
+ }
305
314
  for (const d of result.dropped ?? []) {
306
315
  // #489: the response array carries BOTH gate kinds (substance +
307
316
  // volatility) — the server's own log names the exact gate; this
@@ -268,6 +268,20 @@ export interface DashboardData {
268
268
  * pre-#476 recall-card rendering when the block is absent.
269
269
  */
270
270
  memory_precision: MemoryPrecision;
271
+ /**
272
+ * #529 — the distill inbox: live queue depth + oldest-item age (the
273
+ * drain-side health signal; delivery-side health is capture_health
274
+ * above). ALWAYS present; `oldest_age_hours` null when empty. The warn
275
+ * threshold is ECHOED like every threshold (the page renders its own
276
+ * stale badge from it, never hardcodes 24). Live view only — no snapshot
277
+ * history (depth is ~0 at snapshot time by construction). A pre-#529
278
+ * server omits the block; the page guards.
279
+ */
280
+ distill_queue: {
281
+ depth: number;
282
+ oldest_age_hours: number | null;
283
+ warn_age_hours: number;
284
+ };
271
285
  digest: {
272
286
  date: string | null;
273
287
  run_at: string | null;
@@ -667,15 +681,19 @@ export interface DashboardModelSettings {
667
681
  /** Constant true — the daemon resolves config at boot, so every write
668
682
  * lands on restart. The modal footnotes it. */
669
683
  applies_on_restart: true;
684
+ /** Present ONLY in hosted mode — the console uses it to disable the managed inputs (#514). */
685
+ managed?: true;
670
686
  }
671
687
  /**
672
688
  * The pure GET handler: echo the CONFIG values raw (null when unset — the UI
673
689
  * shows defaults as placeholders, so this never resolves them) + the runtime
674
- * provider from the daemon's in-memory llmConfig.
690
+ * provider from the daemon's in-memory llmConfig. `hostedMode` (#514) only
691
+ * adds `managed: true` to the body — self-hosted responses never carry the
692
+ * key at all (byte-identical wire).
675
693
  */
676
694
  export declare function handleDashboardModelGet(config: Record<string, unknown> | null | undefined, llmConfig: {
677
695
  provider: string;
678
- } | null): {
696
+ } | null, hostedMode?: boolean): {
679
697
  status: 200;
680
698
  body: DashboardModelSettings;
681
699
  };
@@ -684,10 +702,14 @@ export declare function handleDashboardModelGet(config: Record<string, unknown>
684
702
  * injected writer (which THROWS on a malformed config — the adapter maps that
685
703
  * to a 500 and the file stays untouched), answer with the fresh GET shape
686
704
  * built from the post-write config. `null` REMOVES a config key.
705
+ *
706
+ * `hostedMode` (#514): the managed fields (see HOSTED_MANAGED_MODEL_KEYS) are
707
+ * rejected BEFORE any validation or persistence — a refused PUT writes
708
+ * nothing, not even the allowed keys that rode the same body.
687
709
  */
688
710
  export declare function handleDashboardModelPut(body: unknown, persist: (updates: Record<string, unknown>) => Record<string, unknown>, getLlmConfig: () => {
689
711
  provider: string;
690
- } | null): {
712
+ } | null, hostedMode?: boolean): {
691
713
  status: number;
692
714
  body: DashboardModelSettings | {
693
715
  error: string;
@@ -696,21 +718,24 @@ export declare function handleDashboardModelPut(body: unknown, persist: (updates
696
718
  /**
697
719
  * Express adapter for GET /dashboard/model. Bearer-only by construction
698
720
  * (mounted after createAuthMiddleware — NO exemption, unlike the page shells:
699
- * this carries install config). Failures surface as a 500 {error}.
721
+ * this carries install config). Failures surface as a 500 {error}. The
722
+ * boot-resolved `hostedMode` flag (#514) threads straight through to the pure
723
+ * handler — it is a boot constant, not a per-request value.
700
724
  */
701
725
  export declare function dashboardModelGetHandler(getConfig: () => Record<string, unknown> | null | undefined, getLlmConfig: () => {
702
726
  provider: string;
703
- } | null): express.RequestHandler;
727
+ } | null, hostedMode?: boolean): express.RequestHandler;
704
728
  /**
705
729
  * Express adapter for PUT /dashboard/model. Same auth posture as the GET.
706
730
  * The injected persist closure owns the config path (the server passes
707
731
  * init.ts persistConfigUpdates over stateDir/config.json); its load/persist
708
732
  * failures (malformed config, unwritable file) map to a 500 {error} with the
709
- * file left untouched — never a silent partial write.
733
+ * file left untouched — never a silent partial write. `hostedMode` (#514)
734
+ * threads to the pure handler's managed-field gate.
710
735
  */
711
736
  export declare function dashboardModelPutHandler(persist: (updates: Record<string, unknown>) => Record<string, unknown>, getLlmConfig: () => {
712
737
  provider: string;
713
- } | null): express.RequestHandler;
738
+ } | null, hostedMode?: boolean): express.RequestHandler;
714
739
  /** The PUT's body: {machine?: string|null, harness: string, paused: boolean}. */
715
740
  export declare function handleDashboardCapturePausePut(body: unknown, setPause: (machine: string, harness: string, paused: boolean) => string | null): {
716
741
  status: number;
package/dist/dashboard.js CHANGED
@@ -46,6 +46,7 @@ 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
48
  const recall_precision_js_1 = require("./recall-precision.js");
49
+ const distill_queue_js_1 = require("./distill-queue.js");
49
50
  const capture_pause_js_1 = require("./capture-pause.js");
50
51
  const state_js_1 = require("./state.js");
51
52
  const retrieval_js_1 = require("./retrieval.js");
@@ -585,6 +586,13 @@ function handleDashboardData(db, query, config) {
585
586
  shown_sum: live.adoption?.shown_sum ?? 0,
586
587
  used_sum: live.adoption?.used_sum ?? 0,
587
588
  }),
589
+ // #529: live inbox state — depth + oldest age + the echoed warn
590
+ // threshold. Live only (no series): depth at snapshot time is ~0 by
591
+ // construction, so history would be a flat line.
592
+ distill_queue: {
593
+ ...(0, distill_queue_js_1.readQueueStats)(db),
594
+ warn_age_hours: calibration_js_1.DRAIN_QUEUE_WARN_AGE_HOURS,
595
+ },
588
596
  // #423 phase 3: pauses + presence in one block — one source of truth
589
597
  // for the rail's dots, toggles and the capture card's PAUSED badges.
590
598
  fleet: {
@@ -1005,9 +1013,11 @@ function configString(v) {
1005
1013
  /**
1006
1014
  * The pure GET handler: echo the CONFIG values raw (null when unset — the UI
1007
1015
  * shows defaults as placeholders, so this never resolves them) + the runtime
1008
- * provider from the daemon's in-memory llmConfig.
1016
+ * provider from the daemon's in-memory llmConfig. `hostedMode` (#514) only
1017
+ * adds `managed: true` to the body — self-hosted responses never carry the
1018
+ * key at all (byte-identical wire).
1009
1019
  */
1010
- function handleDashboardModelGet(config, llmConfig) {
1020
+ function handleDashboardModelGet(config, llmConfig, hostedMode = false) {
1011
1021
  const cfg = config ?? {};
1012
1022
  return {
1013
1023
  status: 200,
@@ -1022,6 +1032,7 @@ function handleDashboardModelGet(config, llmConfig) {
1022
1032
  enable_thinking: typeof cfg.enableThinking === "boolean" ? cfg.enableThinking : null,
1023
1033
  api_key_set: Boolean(configString(cfg.llmApiKey)),
1024
1034
  applies_on_restart: true,
1035
+ ...(hostedMode ? { managed: true } : {}),
1025
1036
  },
1026
1037
  };
1027
1038
  }
@@ -1034,6 +1045,12 @@ const MODEL_PUT_KEYS = {
1034
1045
  max_tokens: "maxTokens",
1035
1046
  enable_thinking: "enableThinking",
1036
1047
  };
1048
+ /** #514 — hosted mode: these model-settings fields are controlled by the
1049
+ * hosting service (it owns the endpoint and the credentials), so the PUT
1050
+ * rejects them wholesale. Key PRESENCE in the body is the trigger — the
1051
+ * value is never inspected (null or nested garbage is a change attempt like
1052
+ * any other). Self-hosted installs never hit this gate. */
1053
+ const HOSTED_MANAGED_MODEL_KEYS = ["backend", "base_url", "api_key"];
1037
1054
  /** The backends init ever writes (absence of llmBackend + baseUrl+apiKey =
1038
1055
  * the openai-compat path). "" clears (the modal's "auto" option). */
1039
1056
  const MODEL_BACKEND_VALUES = new Set(["", "ollama", "claude-cli"]);
@@ -1042,12 +1059,32 @@ const MODEL_BACKEND_VALUES = new Set(["", "ollama", "claude-cli"]);
1042
1059
  * injected writer (which THROWS on a malformed config — the adapter maps that
1043
1060
  * to a 500 and the file stays untouched), answer with the fresh GET shape
1044
1061
  * built from the post-write config. `null` REMOVES a config key.
1062
+ *
1063
+ * `hostedMode` (#514): the managed fields (see HOSTED_MANAGED_MODEL_KEYS) are
1064
+ * rejected BEFORE any validation or persistence — a refused PUT writes
1065
+ * nothing, not even the allowed keys that rode the same body.
1045
1066
  */
1046
- function handleDashboardModelPut(body, persist, getLlmConfig) {
1067
+ function handleDashboardModelPut(body, persist, getLlmConfig, hostedMode = false) {
1047
1068
  if (body === null || typeof body !== "object" || Array.isArray(body)) {
1048
1069
  return { status: 400, body: { error: "Body must be a JSON object of model settings" } };
1049
1070
  }
1050
1071
  const input = body;
1072
+ // #514 hosted-mode gate: the hosting service owns the endpoint and the
1073
+ // credentials, so a tenant PUT may not touch them. Key PRESENCE is the
1074
+ // trigger; the rejection precedes validation AND persistence, so nothing
1075
+ // is written on a refused PUT (atomicity for mixed allowed+blocked bodies).
1076
+ if (hostedMode) {
1077
+ const attempted = HOSTED_MANAGED_MODEL_KEYS.filter((k) => k in input);
1078
+ if (attempted.length > 0) {
1079
+ const named = attempted.map((k) => `'${k}'`).join(", ");
1080
+ return {
1081
+ status: 403,
1082
+ body: {
1083
+ error: `${named} ${attempted.length === 1 ? "is" : "are"} managed by the hosting service and cannot be changed here (managed fields: ${HOSTED_MANAGED_MODEL_KEYS.join(", ")})`,
1084
+ },
1085
+ };
1086
+ }
1087
+ }
1051
1088
  const updates = {};
1052
1089
  for (const [key, value] of Object.entries(input)) {
1053
1090
  const configKey = MODEL_PUT_KEYS[key];
@@ -1129,17 +1166,19 @@ function handleDashboardModelPut(body, persist, getLlmConfig) {
1129
1166
  // persist THROWS on a malformed config (strict load) — propagated to the
1130
1167
  // adapter → 500, file untouched. On success it returns the fresh config.
1131
1168
  const fresh = persist(updates);
1132
- return handleDashboardModelGet(fresh, getLlmConfig());
1169
+ return handleDashboardModelGet(fresh, getLlmConfig(), hostedMode);
1133
1170
  }
1134
1171
  /**
1135
1172
  * Express adapter for GET /dashboard/model. Bearer-only by construction
1136
1173
  * (mounted after createAuthMiddleware — NO exemption, unlike the page shells:
1137
- * this carries install config). Failures surface as a 500 {error}.
1174
+ * this carries install config). Failures surface as a 500 {error}. The
1175
+ * boot-resolved `hostedMode` flag (#514) threads straight through to the pure
1176
+ * handler — it is a boot constant, not a per-request value.
1138
1177
  */
1139
- function dashboardModelGetHandler(getConfig, getLlmConfig) {
1178
+ function dashboardModelGetHandler(getConfig, getLlmConfig, hostedMode = false) {
1140
1179
  return (_req, res) => {
1141
1180
  try {
1142
- const { status, body } = handleDashboardModelGet(getConfig(), getLlmConfig());
1181
+ const { status, body } = handleDashboardModelGet(getConfig(), getLlmConfig(), hostedMode);
1143
1182
  res.status(status).json(body);
1144
1183
  }
1145
1184
  catch (err) {
@@ -1152,12 +1191,13 @@ function dashboardModelGetHandler(getConfig, getLlmConfig) {
1152
1191
  * The injected persist closure owns the config path (the server passes
1153
1192
  * init.ts persistConfigUpdates over stateDir/config.json); its load/persist
1154
1193
  * failures (malformed config, unwritable file) map to a 500 {error} with the
1155
- * file left untouched — never a silent partial write.
1194
+ * file left untouched — never a silent partial write. `hostedMode` (#514)
1195
+ * threads to the pure handler's managed-field gate.
1156
1196
  */
1157
- function dashboardModelPutHandler(persist, getLlmConfig) {
1197
+ function dashboardModelPutHandler(persist, getLlmConfig, hostedMode = false) {
1158
1198
  return (req, res) => {
1159
1199
  try {
1160
- const { status, body } = handleDashboardModelPut(req.body ?? null, persist, getLlmConfig);
1200
+ const { status, body } = handleDashboardModelPut(req.body ?? null, persist, getLlmConfig, hostedMode);
1161
1201
  res.status(status).json(body);
1162
1202
  }
1163
1203
  catch (err) {
package/dist/db.js CHANGED
@@ -763,6 +763,51 @@ const MIGRATIONS = [
763
763
  `);
764
764
  },
765
765
  },
766
+ {
767
+ version: 24,
768
+ name: "distill_queue",
769
+ up: (db) => {
770
+ // #529 — the durable distill inbox. POST /distill in queue mode stores
771
+ // the REDACTED segment here and answers 201 immediately (no LLM call);
772
+ // the nightly drain distills rows oldest-first and deletes each row in
773
+ // the same transaction as its memory inserts. One table in the shared
774
+ // SQLite DB — one store, same backup and tooling as the corpus (owner
775
+ // decision; not files). The row carries every /distill wire field needed
776
+ // to re-distill later (attribution, project, session_date, privacy) so
777
+ // the drain reproduces the sync path's insert exactly.
778
+ //
779
+ // UNIQUE(session_id, segment_id) is the delivery-time idempotency key: a
780
+ // queued-but-undistilled segment re-POST answers 200 skipped (the
781
+ // handler's prechecks consult this table), and a failed drain attempt
782
+ // never duplicates on retry. Legacy no-segment posts normalize
783
+ // segment_id ''. session_id stays NULL for posts that carry no session
784
+ // key — SQLite unique indexes treat NULLs as distinct, so those rows
785
+ // (which have no dedup key by construction, same as the sync path's
786
+ // sourceSession-undefined inserts) each enqueue separately.
787
+ // Idempotent: IF NOT EXISTS (the v18/v21 pattern — plain CREATE).
788
+ db.exec(`
789
+ CREATE TABLE IF NOT EXISTS distill_queue (
790
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
791
+ session_id TEXT,
792
+ segment_id TEXT NOT NULL DEFAULT '',
793
+ source_agent TEXT,
794
+ source_agent_id TEXT,
795
+ source_domain TEXT,
796
+ source_machine TEXT,
797
+ project TEXT,
798
+ session_date TEXT,
799
+ privacy TEXT,
800
+ text TEXT NOT NULL,
801
+ arrived_at TEXT NOT NULL
802
+ )
803
+ `);
804
+ db.exec(`
805
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_distill_queue_session_segment
806
+ ON distill_queue(session_id, segment_id)
807
+ `);
808
+ db.exec("CREATE INDEX IF NOT EXISTS idx_distill_queue_arrived ON distill_queue(arrived_at)");
809
+ },
810
+ },
766
811
  ];
767
812
  /**
768
813
  * Run all pending migrations against the database.
package/dist/dedup.js CHANGED
@@ -670,8 +670,8 @@ async function runDedup(options = {}) {
670
670
  }
671
671
  for (const c of plan.mismatchSkipped) {
672
672
  // #206 decision 2: project_mismatch is the only skip reason left on
673
- // this rail (the source_agent rail was removed) — the bedrock dry run
674
- // sizes the project rail alone off this line.
673
+ // this rail (the source_agent rail was removed) — the reference dry
674
+ // run sizes the project rail alone off this line.
675
675
  console.log(`[hicortex] SKIPPED (project_mismatch): ${c.memberIds.map((id) => id.slice(0, 8)).join(", ")}`);
676
676
  }
677
677
  // #393 guard-C: listed for review like the mismatch clusters — a