instar 1.3.136 → 1.3.138

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 (40) hide show
  1. package/dashboard/index.html +62 -0
  2. package/dashboard/preferences-learning.js +388 -0
  3. package/dist/commands/server.d.ts.map +1 -1
  4. package/dist/commands/server.js +15 -0
  5. package/dist/commands/server.js.map +1 -1
  6. package/dist/config/ConfigDefaults.d.ts.map +1 -1
  7. package/dist/config/ConfigDefaults.js +3 -0
  8. package/dist/config/ConfigDefaults.js.map +1 -1
  9. package/dist/core/PostUpdateMigrator.d.ts.map +1 -1
  10. package/dist/core/PostUpdateMigrator.js +31 -10
  11. package/dist/core/PostUpdateMigrator.js.map +1 -1
  12. package/dist/core/types.d.ts +11 -0
  13. package/dist/core/types.d.ts.map +1 -1
  14. package/dist/core/types.js.map +1 -1
  15. package/dist/monitoring/CorrectionLedger.d.ts +6 -0
  16. package/dist/monitoring/CorrectionLedger.d.ts.map +1 -1
  17. package/dist/monitoring/CorrectionLedger.js +17 -0
  18. package/dist/monitoring/CorrectionLedger.js.map +1 -1
  19. package/dist/monitoring/CorrectionLoopDriver.d.ts +59 -9
  20. package/dist/monitoring/CorrectionLoopDriver.d.ts.map +1 -1
  21. package/dist/monitoring/CorrectionLoopDriver.js +87 -12
  22. package/dist/monitoring/CorrectionLoopDriver.js.map +1 -1
  23. package/dist/scaffold/templates.d.ts.map +1 -1
  24. package/dist/scaffold/templates.js +3 -2
  25. package/dist/scaffold/templates.js.map +1 -1
  26. package/dist/server/CapabilityIndex.js +4 -4
  27. package/dist/server/CapabilityIndex.js.map +1 -1
  28. package/dist/server/routes.d.ts.map +1 -1
  29. package/dist/server/routes.js +88 -6
  30. package/dist/server/routes.js.map +1 -1
  31. package/package.json +1 -1
  32. package/scripts/framework-issue-backfill/codex-session-findings.json +116 -0
  33. package/scripts/framework-issue-backfill/import-findings.mjs +75 -0
  34. package/src/data/builtin-manifest.json +62 -62
  35. package/src/scaffold/templates.ts +3 -2
  36. package/upgrades/1.3.135.md +80 -36
  37. package/upgrades/1.3.137.md +97 -0
  38. package/upgrades/1.3.138.md +58 -0
  39. package/upgrades/side-effects/correction-preference-learning-sentinel-slice2.md +86 -0
  40. package/upgrades/side-effects/framework-issue-observe-write-path.md +44 -0
@@ -2636,6 +2636,7 @@
2636
2636
  <button class="tab" data-tab="threadline" onclick="switchTab('threadline')">Threadline</button>
2637
2637
  <button class="tab" data-tab="evidence" onclick="switchTab('evidence')">Evidence</button>
2638
2638
  <button class="tab" data-tab="process-health" onclick="switchTab('process-health')">Process Health</button>
2639
+ <button class="tab" data-tab="preferences-learning" onclick="switchTab('preferences-learning')">Preferences</button>
2639
2640
  <button class="tab" data-tab="machines" onclick="switchTab('machines')">Machines</button>
2640
2641
  </nav>
2641
2642
  </div>
@@ -3149,6 +3150,26 @@
3149
3150
  </details>
3150
3151
  </div>
3151
3152
 
3153
+ <!-- preferences-learning tab (Correction & Preference Learning Sentinel §10 Slice-2) -->
3154
+ <div id="preferencesLearningPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
3155
+ <div style="display:flex;justify-content:space-between;align-items:baseline">
3156
+ <h2 class="ph-title">Preferences</h2>
3157
+ <div id="plStamp" class="ph-stamp" aria-live="polite"></div>
3158
+ </div>
3159
+ <p class="ph-intro">When you correct me the same way a few times — "no, plainer", "stop asking me that", "lead with the one action" — I save it as a preference and load it at the start of every conversation, so I follow it without you having to remind me. This is a quiet record of what I've picked up. It never blocks or changes a message — these are signals, not rules.</p>
3160
+ <div id="plHeadline" class="ph-headline-wrap" aria-live="polite"></div>
3161
+ <section class="ph-section">
3162
+ <h3 class="ph-h">Preferences I've picked up about you</h3>
3163
+ <p class="ph-h-sub">The ones I load at the start of every conversation. Real instructions and safety always win over these.</p>
3164
+ <div id="plPreferences"></div>
3165
+ </section>
3166
+ <section class="ph-section">
3167
+ <h3 class="ph-h">Corrections I've noticed lately</h3>
3168
+ <p class="ph-h-sub">Patterns I've distilled from moments you corrected me. The exact words are never stored — just a short, scrubbed summary.</p>
3169
+ <div id="plCorrections"></div>
3170
+ </section>
3171
+ </div>
3172
+
3152
3173
  <!-- machines tab (Multi-Machine Session Pool §L2) -->
3153
3174
  <div id="machinesPanel" class="ph-root" style="display:none;flex-direction:column;padding:28px;gap:24px;overflow-y:auto">
3154
3175
  <div style="display:flex;justify-content:space-between;align-items:baseline">
@@ -4403,6 +4424,13 @@
4403
4424
  onActivate: () => { if (typeof startProcessHealth === 'function') startProcessHealth(); },
4404
4425
  onDeactivate: () => { if (typeof stopProcessHealth === 'function') stopProcessHealth(); },
4405
4426
  },
4427
+ {
4428
+ id: 'preferences-learning',
4429
+ panels: ['preferencesLearningPanel'],
4430
+ display: ['flex'],
4431
+ onActivate: () => { if (typeof startPreferencesLearning === 'function') startPreferencesLearning(); },
4432
+ onDeactivate: () => { if (typeof stopPreferencesLearning === 'function') stopPreferencesLearning(); },
4433
+ },
4406
4434
  {
4407
4435
  id: 'machines',
4408
4436
  panels: ['machinesPanel'],
@@ -8244,16 +8272,50 @@
8244
8272
  if (__phController) __phController.stop();
8245
8273
  }
8246
8274
 
8275
+ // ── Preferences tab (Correction & Preference Learning Sentinel §10 Slice-2) ──
8276
+ // Same lazy dynamic-import pattern as Process Health: the controller lives in
8277
+ // this (classic) scope so it can read `token`; the module ships the pure
8278
+ // renderers + the polling controller.
8279
+ let __plController = null;
8280
+ let __plModule = null;
8281
+ async function startPreferencesLearning() {
8282
+ if (!__plModule) {
8283
+ try { __plModule = await import('/dashboard/preferences-learning.js'); }
8284
+ catch (e) { console.error('[preferences-learning] module load failed', e); return; }
8285
+ }
8286
+ if (!__plController) {
8287
+ const els = {
8288
+ headline: document.getElementById('plHeadline'),
8289
+ preferences: document.getElementById('plPreferences'),
8290
+ corrections: document.getElementById('plCorrections'),
8291
+ stamp: document.getElementById('plStamp'),
8292
+ };
8293
+ // Raw fetch (NOT apiFetch) so the controller sees 503 itself.
8294
+ const fetchImpl = (url, opts = {}) => fetch(url, {
8295
+ headers: { 'Authorization': `Bearer ${token}`, ...(opts.headers || {}) },
8296
+ signal: opts.signal,
8297
+ });
8298
+ __plController = __plModule.createController({ doc: document, els, fetchImpl });
8299
+ }
8300
+ __plController.start();
8301
+ }
8302
+ function stopPreferencesLearning() {
8303
+ if (__plController) __plController.stop();
8304
+ }
8305
+
8247
8306
  // Handle tab visibility — close SSE when tab is backgrounded
8248
8307
  document.addEventListener('visibilitychange', () => {
8249
8308
  if (document.hidden) {
8250
8309
  disconnectJobsSSE();
8251
8310
  if (currentTab === 'process-health') stopProcessHealth();
8311
+ if (currentTab === 'preferences-learning') stopPreferencesLearning();
8252
8312
  } else if (currentTab === 'jobs') {
8253
8313
  connectJobsSSE();
8254
8314
  loadJobs(); // Resync on return
8255
8315
  } else if (currentTab === 'process-health') {
8256
8316
  startProcessHealth(); // resume on return: one immediate refresh + re-arm
8317
+ } else if (currentTab === 'preferences-learning') {
8318
+ startPreferencesLearning(); // resume on return
8257
8319
  }
8258
8320
  // Always refresh vital signs on tab focus
8259
8321
  if (!document.hidden) pollVitalSigns();
@@ -0,0 +1,388 @@
1
+ // Preferences tab — a calm, human-readable read surface for the Correction &
2
+ // Preference Learning Sentinel (Slice 2).
3
+ // Spec: docs/specs/CORRECTION-PREFERENCE-LEARNING-SENTINEL-SPEC.md §10 Slice-2.
4
+ //
5
+ // Browser-native ESM (no build step; served at /dashboard/preferences-learning.js
6
+ // and loaded by index.html via a lazy dynamic import). The pure functions are
7
+ // exported so the jsdom tests exercise the SHIPPED code, not a copy; the
8
+ // controller is driven by the (classic) index.html script on tab activation.
9
+ //
10
+ // Mirrors the Process Health tab precedent (dashboard/process-health.js):
11
+ // - one self-contained module for the tab (NOT a modularization of the SPA);
12
+ // - every dynamic value flows through sanitizeForDisplay before the DOM;
13
+ // - all DOM writes are textContent only — never innerHTML;
14
+ // - a friendly "not turned on yet" disabled (503) state;
15
+ // - a visibility-gated, abort-safe polling controller.
16
+ //
17
+ // Two endpoints back this tab, BOTH already shipping (Slice 1a/1b):
18
+ // - GET /preferences/session-context → { present, block, count } — the active
19
+ // learned preferences as a single plain-language block (the SAME text the
20
+ // session-start hook injects). Raw learning text is never served; the block
21
+ // is the formatted, scrubbed summary.
22
+ // - GET /corrections?limit=N → { records, count, totalRecords, nextBefore } —
23
+ // the deduped, scrubbed correction/preference ledger records (toApiView strips
24
+ // the raw `learning`; only scrubbed_summary + metadata cross the boundary).
25
+ // Either returning 503 means the feature is off → the disabled state renders.
26
+
27
+ // ── Safety: structural sanitize (mirrors process-health §4.6) ──────────────────
28
+ // Strip, after the NFKC fold, control + bidi-control characters and cap visible
29
+ // length. Presentation chrome here is renderer-OWNED static literals only.
30
+ const CAPS = { summary: 240, block: 6000, label: 64, detail: 320 };
31
+
32
+ const CONTROL_RE = new RegExp('[\\u0000-\\u0008\\u000B\\u000C\\u000E-\\u001F\\u007F-\\u009F]', 'g');
33
+ const BIDI_RE = new RegExp('[\\u202A-\\u202E\\u2066-\\u2069]', 'g');
34
+
35
+ export function sanitizeForDisplay(value, fieldKind = 'summary') {
36
+ let s = value == null ? '' : String(value);
37
+ s = s.normalize('NFKC');
38
+ s = s.replace(CONTROL_RE, ''); // keep \n=0A, \t=09
39
+ s = s.replace(BIDI_RE, '');
40
+ s = s.replace(/\n{3,}/g, '\n\n').replace(/[ \t]{5,}/g, ' ');
41
+ s = capGraphemes(s, CAPS[fieldKind] ?? CAPS.summary);
42
+ return s;
43
+ }
44
+
45
+ function capGraphemes(s, max) {
46
+ if (s.length <= max) return s;
47
+ if (typeof Intl !== 'undefined' && Intl.Segmenter) {
48
+ const arr = Array.from(new Intl.Segmenter(undefined, { granularity: 'grapheme' }).segment(s), (x) => x.segment);
49
+ if (arr.length <= max) return s;
50
+ return arr.slice(0, max - 1).join('') + '…';
51
+ }
52
+ let cut = max - 1;
53
+ const c = s.charCodeAt(cut - 1);
54
+ if (c >= 0xd800 && c <= 0xdbff) cut -= 1;
55
+ return s.slice(0, cut) + '…';
56
+ }
57
+
58
+ // ── Friendly wording ───────────────────────────────────────────────────────
59
+ const KIND_WORDS = {
60
+ 'user-preference': 'How you like things',
61
+ 'infra-gap': 'Something the tool itself should handle better',
62
+ noise: 'No clear lesson',
63
+ };
64
+ export function friendlyKind(kind) {
65
+ return KIND_WORDS[kind] || 'A pattern';
66
+ }
67
+
68
+ // Correction-record lifecycle wording — informational only, no action language.
69
+ const STATUS_WORDS = {
70
+ open: 'Just noticed',
71
+ 'acted-on': 'Saved and being watched',
72
+ verified: 'Confirmed and sticking',
73
+ inconclusive: 'Watched, but nothing conclusive',
74
+ reopened: 'Came back after I tried adapting',
75
+ };
76
+ export function friendlyCorrectionStatus(status) {
77
+ return STATUS_WORDS[status] || 'Not set';
78
+ }
79
+
80
+ export function statusDotClass(status) {
81
+ switch (status) {
82
+ case 'open': case 'reopened': return 'status-open';
83
+ case 'acted-on': return 'status-attributed';
84
+ case 'verified': return 'status-verified';
85
+ case 'inconclusive': case 'closed': default: return 'status-closed';
86
+ }
87
+ }
88
+
89
+ export function relativeTime(iso, now = Date.now()) {
90
+ const t = typeof iso === 'string' ? Date.parse(iso) : NaN;
91
+ if (Number.isNaN(t)) return 'recently';
92
+ const sec = Math.max(0, Math.floor((now - t) / 1000));
93
+ if (sec < 90) return 'just now';
94
+ const min = Math.floor(sec / 60);
95
+ if (min < 60) return `about ${min} minute${min === 1 ? '' : 's'} ago`;
96
+ const hr = Math.floor(min / 60);
97
+ if (hr < 24) return `about ${hr} hour${hr === 1 ? '' : 's'} ago`;
98
+ const day = Math.floor(hr / 24);
99
+ if (day < 14) return `about ${day} day${day === 1 ? '' : 's'} ago`;
100
+ const wk = Math.floor(day / 7);
101
+ if (wk < 8) return `about ${wk} week${wk === 1 ? '' : 's'} ago`;
102
+ const mo = Math.floor(day / 30);
103
+ return `about ${mo} month${mo === 1 ? '' : 's'} ago`;
104
+ }
105
+
106
+ // ── DOM helpers (textContent ONLY — never innerHTML) ─────────────────────────
107
+ function el(doc, tag, cls, text) {
108
+ const node = doc.createElement(tag);
109
+ if (cls) node.setAttribute('class', cls);
110
+ if (text != null) node.textContent = text;
111
+ return node;
112
+ }
113
+
114
+ function labeledRow(doc, label, value) {
115
+ const row = el(doc, 'div', 'ph-item-row');
116
+ row.appendChild(el(doc, 'span', 'ph-label', `${label}:`));
117
+ row.appendChild(doc.createTextNode(' '));
118
+ row.appendChild(el(doc, 'span', 'ph-value', value));
119
+ return row;
120
+ }
121
+
122
+ function statusDot(doc, status) {
123
+ const dot = doc.createElement('span');
124
+ dot.setAttribute('class', `ph-status-dot ${statusDotClass(status)}`);
125
+ dot.setAttribute('aria-hidden', 'true');
126
+ return dot;
127
+ }
128
+
129
+ // ── Renderers (pure data→DOM) ────────────────────────────────────────────────
130
+
131
+ /**
132
+ * Headline (informational, not a verdict): how many preferences I've learned.
133
+ * `stale` shows the calm "can't refresh" line, mirroring process-health.
134
+ */
135
+ export function renderHeadline(doc, target, { prefCount, stale }) {
136
+ if (!target) return;
137
+ target.replaceChildren();
138
+ if (stale) {
139
+ target.appendChild(el(doc, 'div', 'ph-headline', "Can't refresh right now — showing the last update"));
140
+ target.appendChild(el(doc, 'div', 'ph-subline', "We'll keep trying — nothing is lost."));
141
+ return;
142
+ }
143
+ const n = Number.isFinite(prefCount) ? prefCount : 0;
144
+ const headline = n === 0
145
+ ? "I haven't picked up any preferences yet"
146
+ : `${n} preference${n === 1 ? '' : 's'} I've picked up about you`;
147
+ target.appendChild(el(doc, 'div', 'ph-headline', headline));
148
+ const subline = n === 0
149
+ ? 'When you correct me the same way a few times, I save it here so I stop making you repeat yourself.'
150
+ : 'These get loaded at the start of every conversation, so I follow them without you having to remind me.';
151
+ target.appendChild(el(doc, 'div', 'ph-subline', subline));
152
+ }
153
+
154
+ /**
155
+ * The active preferences block (§10 — "Preferences I've picked up about you").
156
+ * `/preferences/session-context` serves the preferences as ONE formatted,
157
+ * already-scrubbed plain-language block (the same text the session-start hook
158
+ * injects). We render its lines as plain text, dropping the envelope wrapper
159
+ * tags so the user reads the preferences, not the machinery.
160
+ */
161
+ export function renderPreferences(doc, target, sessionContext) {
162
+ if (!target) return;
163
+ target.replaceChildren();
164
+ const present = sessionContext && sessionContext.present === true;
165
+ const block = sessionContext && typeof sessionContext.block === 'string' ? sessionContext.block : '';
166
+ if (!present || !block.trim()) {
167
+ target.appendChild(el(doc, 'p', 'ph-empty', "Nothing saved yet — that's normal. Correct me the same way a couple of times and it'll show up here."));
168
+ return;
169
+ }
170
+ const safe = sanitizeForDisplay(block, 'block');
171
+ // Strip the <auto-learned-preference …> envelope tags + the intro line; show
172
+ // the actual preference lines (each begins with a "- " bullet in the block).
173
+ const lines = safe.split('\n');
174
+ const prefLines = lines
175
+ .map((l) => l.trim())
176
+ .filter((l) => l.startsWith('- '))
177
+ .map((l) => l.replace(/^- /, '').trim());
178
+ if (prefLines.length === 0) {
179
+ // Fall back to showing the block as a paragraph (minus the envelope tags).
180
+ const body = lines.filter((l) => !/^<\/?auto-learned-preference/.test(l.trim())).join('\n').trim();
181
+ target.appendChild(el(doc, 'p', 'ph-card-summary', body || safe));
182
+ return;
183
+ }
184
+ for (const line of prefLines) {
185
+ const item = el(doc, 'div', 'ph-item');
186
+ const sum = el(doc, 'div', 'ph-item-summary');
187
+ sum.appendChild(statusDot(doc, 'verified'));
188
+ sum.appendChild(el(doc, 'div', 'ph-card-summary', sanitizeForDisplay(line, 'summary')));
189
+ item.appendChild(sum);
190
+ target.appendChild(item);
191
+ }
192
+ }
193
+
194
+ /**
195
+ * The recent corrections ledger (§10 — scrubbed summaries + metadata + status).
196
+ * Each item is an expandable fact sheet: collapsed shows the plain-language
197
+ * summary; expanded shows kind · status · times seen · first noticed. The raw
198
+ * `learning` is NEVER present in the API view, so it can never reach the DOM.
199
+ */
200
+ export function renderCorrections(doc, target, body, now = Date.now()) {
201
+ if (!target) return;
202
+ target.replaceChildren();
203
+ const list = body && Array.isArray(body.records) ? body.records.slice(0, 20) : [];
204
+ if (list.length === 0) {
205
+ target.appendChild(el(doc, 'p', 'ph-empty', "Nothing recorded yet — I only save a pattern after it shows up across a few different days."));
206
+ return;
207
+ }
208
+ for (const r of list) {
209
+ if (!r) continue;
210
+ const kind = friendlyKind(r.kind);
211
+ const summary = sanitizeForDisplay(r.scrubbedSummary, 'summary');
212
+ const item = doc.createElement('details');
213
+ item.setAttribute('class', 'ph-item');
214
+ const sum = doc.createElement('summary');
215
+ sum.setAttribute('class', 'ph-item-summary');
216
+ sum.appendChild(statusDot(doc, r.status));
217
+ sum.appendChild(el(doc, 'div', 'ph-item-text', summary || kind));
218
+ item.appendChild(sum);
219
+ const detail = el(doc, 'div', 'ph-item-body');
220
+ detail.appendChild(labeledRow(doc, 'Kind', kind));
221
+ detail.appendChild(labeledRow(doc, 'Status', friendlyCorrectionStatus(r.status)));
222
+ const occ = Number.isFinite(r.occurrenceCount) ? r.occurrenceCount : 1;
223
+ detail.appendChild(labeledRow(doc, 'Times seen', occ > 1 ? `${occ} times so far` : 'Just once'));
224
+ if (r.detectedAt) detail.appendChild(labeledRow(doc, 'First noticed', relativeTime(r.detectedAt, now)));
225
+ item.appendChild(detail);
226
+ target.appendChild(item);
227
+ }
228
+ }
229
+
230
+ /**
231
+ * Disabled (503) state. Pinned copy + operator hint as PLAIN PROSE — no
232
+ * `<code>`, no monospace, no config-key string (Dashboard Standard).
233
+ */
234
+ export function renderDisabled(doc, els) {
235
+ if (els.headline) {
236
+ els.headline.replaceChildren();
237
+ els.headline.appendChild(el(doc, 'div', 'ph-headline', "Learning your preferences isn't turned on for this agent yet."));
238
+ els.headline.appendChild(el(doc, 'div', 'ph-subline', 'Once it is, this page will show the preferences I pick up about how you like to work.'));
239
+ const det = doc.createElement('details');
240
+ det.setAttribute('class', 'ph-operator');
241
+ const s = doc.createElement('summary');
242
+ s.setAttribute('class', 'ph-operator-summary');
243
+ s.textContent = 'For whoever set this up';
244
+ det.appendChild(s);
245
+ det.appendChild(el(doc, 'p', 'ph-operator-hint', 'You can switch it on in this agent’s settings.'));
246
+ els.headline.appendChild(det);
247
+ }
248
+ for (const k of ['preferences', 'corrections']) {
249
+ if (els[k]) els[k].replaceChildren();
250
+ }
251
+ if (els.stamp) els.stamp.textContent = '';
252
+ }
253
+
254
+ // ── Polling controller (visibility-gated, abort-safe, diff-aware) ─────────────
255
+ const URLS = {
256
+ prefs: '/preferences/session-context',
257
+ corrections: '/corrections?limit=20',
258
+ };
259
+
260
+ /**
261
+ * Factory so tests can inject fetch + clock + DOM. opts:
262
+ * - doc: Document; els: { headline, preferences, corrections, stamp }
263
+ * - fetchImpl: (url, {signal}) => Promise<Response-like>
264
+ * - now: () => ms; cadenceMs (default 60_000); staleMs (default 180_000)
265
+ * - schedule/cancel: timer hooks (default setTimeout/clearTimeout)
266
+ */
267
+ export function createController(opts) {
268
+ const {
269
+ doc,
270
+ els = {},
271
+ fetchImpl,
272
+ now = () => Date.now(),
273
+ cadenceMs = 60_000,
274
+ staleMs = 180_000,
275
+ schedule = (fn, ms) => setTimeout(fn, ms),
276
+ cancel = (id) => clearTimeout(id),
277
+ } = opts;
278
+
279
+ const state = {
280
+ timerId: null,
281
+ inFlight: null,
282
+ active: false,
283
+ snapshot: { prefs: null, corrections: null },
284
+ last200At: 0,
285
+ consecutiveFailedTicks: 0,
286
+ renderedSig: { headline: null, preferences: null, corrections: null, disabled: false },
287
+ };
288
+
289
+ async function fetchOne(key, controller) {
290
+ const resp = await fetchImpl(URLS[key], { signal: controller.signal });
291
+ if (resp.status === 503) return { key, status: 503 };
292
+ if (!resp.ok) throw new Error(`${key} ${resp.status}`);
293
+ const body = await resp.json();
294
+ return { key, status: 200, body };
295
+ }
296
+
297
+ async function tick() {
298
+ if (!state.active) return;
299
+ if (state.inFlight) {
300
+ try { state.inFlight.abort(); } catch { /* ignore */ }
301
+ }
302
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : { signal: undefined, abort() {} };
303
+ state.inFlight = controller;
304
+ let results;
305
+ try {
306
+ results = await Promise.all(['prefs', 'corrections'].map((k) => fetchOne(k, controller)));
307
+ } catch {
308
+ if (controller.signal && controller.signal.aborted) return;
309
+ state.inFlight = null;
310
+ state.consecutiveFailedTicks += 1;
311
+ render();
312
+ reschedule();
313
+ return;
314
+ }
315
+ if (controller.signal && controller.signal.aborted) return;
316
+ state.inFlight = null;
317
+ // Feature OFF (503 on EITHER endpoint) → pinned disabled copy.
318
+ if (results.some((r) => r.status === 503)) {
319
+ state.consecutiveFailedTicks = 0;
320
+ if (state.renderedSig.disabled !== true) {
321
+ renderDisabled(doc, els);
322
+ state.renderedSig = { headline: null, preferences: null, corrections: null, disabled: true };
323
+ }
324
+ reschedule();
325
+ return;
326
+ }
327
+ state.renderedSig.disabled = false;
328
+ for (const r of results) {
329
+ if (r.status === 200) { state.snapshot[r.key] = r.body; }
330
+ }
331
+ state.consecutiveFailedTicks = 0;
332
+ state.last200At = now();
333
+ render();
334
+ reschedule();
335
+ }
336
+
337
+ function reschedule() {
338
+ if (!state.active) return;
339
+ if (state.timerId != null) cancel(state.timerId);
340
+ state.timerId = schedule(() => { void tick(); }, cadenceMs);
341
+ }
342
+
343
+ function headlineStale() {
344
+ if (state.consecutiveFailedTicks >= 2) return true;
345
+ if (state.last200At === 0) return false;
346
+ return now() - state.last200At > staleMs;
347
+ }
348
+
349
+ function render() {
350
+ const stale = headlineStale();
351
+ const prefs = state.snapshot.prefs;
352
+ const corrections = state.snapshot.corrections;
353
+ const prefCount = prefs && Number.isFinite(prefs.count) ? prefs.count : 0;
354
+
355
+ sectionRender('headline', sig(stale, prefCount), () => renderHeadline(doc, els.headline, { prefCount, stale }));
356
+ sectionRender('preferences', sig(prefs), () => renderPreferences(doc, els.preferences, prefs));
357
+ sectionRender('corrections', sig(corrections), () => renderCorrections(doc, els.corrections, corrections, now()));
358
+
359
+ if (els.stamp) {
360
+ const ageS = state.last200At ? Math.max(0, Math.round((now() - state.last200At) / 1000)) : null;
361
+ const stampText = ageS == null ? '' : ageS < 60 ? `updated ${ageS}s ago` : `updated ${Math.round(ageS / 60)}m ago`;
362
+ if (els.stamp.textContent !== stampText) els.stamp.textContent = stampText;
363
+ }
364
+ }
365
+
366
+ function sectionRender(key, signature, fn) {
367
+ if (state.renderedSig[key] === signature) return;
368
+ state.renderedSig[key] = signature;
369
+ fn();
370
+ }
371
+
372
+ function start() {
373
+ if (state.active) return;
374
+ state.active = true;
375
+ void tick();
376
+ }
377
+ function stop() {
378
+ state.active = false;
379
+ if (state.timerId != null) { cancel(state.timerId); state.timerId = null; }
380
+ if (state.inFlight) { try { state.inFlight.abort(); } catch { /* ignore */ } state.inFlight = null; }
381
+ }
382
+
383
+ return { start, stop, tick, render, _state: state };
384
+ }
385
+
386
+ function sig(...parts) {
387
+ try { return JSON.stringify(parts); } catch { return String(Math.random()); }
388
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAmSH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwvDD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAqpPtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../../src/commands/server.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAmSH,UAAU,YAAY;IACpB,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb;2DACuD;IACvD,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwvDD,wBAAsB,WAAW,CAAC,OAAO,EAAE,YAAY,GAAG,OAAO,CAAC,IAAI,CAAC,CAoqPtE;AAED,wBAAsB,UAAU,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAsDzE;AAED;;;;;;;;;;;GAWG;AACH,wBAAsB,aAAa,CAAC,OAAO,EAAE;IAAE,GAAG,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAuD5E"}
@@ -6165,6 +6165,21 @@ export async function startServer(options) {
6165
6165
  maxConcurrent: cl.llmMaxConcurrent ?? 1,
6166
6166
  maxDailyCents: cl.llmDailyCents ?? 25,
6167
6167
  });
6168
+ // Drift-canary sub-budget (Slice 2 NEW-1): a SEPARATE LlmQueue instance
6169
+ // with its own (small) daily cap, so the periodic "would this have been
6170
+ // a correction?" sampler can never starve the main distill path's cap.
6171
+ // Constructed only when the canary is on (it ships dark); when off this
6172
+ // is null and the canary path never runs. The separate instance IS the
6173
+ // sub-budget (LlmQueue has no per-feature sub-cap, spec §2).
6174
+ const driftCanaryLlmQueue = cl.driftCanary === true
6175
+ ? new LlmQueue({
6176
+ maxConcurrent: 1,
6177
+ maxDailyCents: cl.driftCanaryDailyCents ?? 5,
6178
+ })
6179
+ : null;
6180
+ void driftCanaryLlmQueue; // reserved for the canary sampler (dark); the
6181
+ // budget is provisioned here so enabling the canary cannot regress the
6182
+ // main cap. Referenced to satisfy no-unused under the dark default.
6168
6183
  const ring = new CaptureRing({
6169
6184
  captureContextTurns: cl.captureContextTurns ?? 6,
6170
6185
  captureTopicMapMax: cl.captureTopicMapMax ?? 64,