eklavya 1.18.3 → 1.19.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 (96) hide show
  1. package/dist/assets/dashboard.html +559 -14
  2. package/dist/assets/tutor/references/focus-and-level.md +29 -0
  3. package/dist/cli.js +648 -20
  4. package/dist/cli.js.map +1 -1
  5. package/dist/config-path.js +156 -0
  6. package/dist/config-path.js.map +1 -0
  7. package/dist/config.js +231 -2
  8. package/dist/config.js.map +1 -1
  9. package/dist/dashboard.js +355 -0
  10. package/dist/dashboard.js.map +1 -1
  11. package/dist/eval/retrieval-score.js +77 -0
  12. package/dist/eval/retrieval-score.js.map +1 -0
  13. package/dist/hooks/capture-tool.js +107 -0
  14. package/dist/hooks/capture-tool.js.map +1 -0
  15. package/dist/hooks/lib.js +4 -30
  16. package/dist/hooks/lib.js.map +1 -1
  17. package/dist/hooks/memory-lib.js +181 -0
  18. package/dist/hooks/memory-lib.js.map +1 -0
  19. package/dist/hooks/prompt-submit-nudge.js +56 -20
  20. package/dist/hooks/prompt-submit-nudge.js.map +1 -1
  21. package/dist/hooks/session-start.js +73 -74
  22. package/dist/hooks/session-start.js.map +1 -1
  23. package/dist/hooks/stop-quiz-check.js +26 -0
  24. package/dist/hooks/stop-quiz-check.js.map +1 -1
  25. package/dist/install.js +37 -0
  26. package/dist/install.js.map +1 -1
  27. package/dist/memory/capture.js +123 -0
  28. package/dist/memory/capture.js.map +1 -0
  29. package/dist/memory/code.js +186 -0
  30. package/dist/memory/code.js.map +1 -0
  31. package/dist/memory/collections.js +87 -0
  32. package/dist/memory/collections.js.map +1 -0
  33. package/dist/memory/embed.js +81 -0
  34. package/dist/memory/embed.js.map +1 -0
  35. package/dist/memory/hosts.js +71 -0
  36. package/dist/memory/hosts.js.map +1 -0
  37. package/dist/memory/identity.js +74 -0
  38. package/dist/memory/identity.js.map +1 -0
  39. package/dist/memory/import.js +916 -0
  40. package/dist/memory/import.js.map +1 -0
  41. package/dist/memory/learning.js +148 -0
  42. package/dist/memory/learning.js.map +1 -0
  43. package/dist/memory/notify.js +220 -0
  44. package/dist/memory/notify.js.map +1 -0
  45. package/dist/memory/privacy.js +117 -0
  46. package/dist/memory/privacy.js.map +1 -0
  47. package/dist/memory/provider.js +155 -0
  48. package/dist/memory/provider.js.map +1 -0
  49. package/dist/memory/recall.js +327 -0
  50. package/dist/memory/recall.js.map +1 -0
  51. package/dist/memory/replay.js +174 -0
  52. package/dist/memory/replay.js.map +1 -0
  53. package/dist/memory/search.js +143 -0
  54. package/dist/memory/search.js.map +1 -0
  55. package/dist/memory/spool.js +93 -0
  56. package/dist/memory/spool.js.map +1 -0
  57. package/dist/memory/store.js +399 -0
  58. package/dist/memory/store.js.map +1 -0
  59. package/dist/memory/summarize.js +178 -0
  60. package/dist/memory/summarize.js.map +1 -0
  61. package/dist/memory/sync.js +595 -0
  62. package/dist/memory/sync.js.map +1 -0
  63. package/dist/memory/tokens.js +55 -0
  64. package/dist/memory/tokens.js.map +1 -0
  65. package/dist/memory/worker.js +207 -0
  66. package/dist/memory/worker.js.map +1 -0
  67. package/dist/migrations/009_memory.sql +226 -0
  68. package/dist/migrations/010_import.sql +26 -0
  69. package/dist/migrations/011_sync.sql +76 -0
  70. package/dist/migrations/012_job_backoff.sql +13 -0
  71. package/dist/migrations/013_batch_provenance.sql +18 -0
  72. package/dist/migrations/014_batch_events_index.sql +18 -0
  73. package/dist/plugin/.claude-plugin/plugin.json +1 -1
  74. package/dist/plugin/agents/tutor.md +3 -1
  75. package/dist/plugin/hooks/CLAUDE.md +25 -3
  76. package/dist/plugin/hooks/hooks.json +10 -0
  77. package/dist/plugin/skills/CLAUDE.md +17 -7
  78. package/dist/plugin/skills/memory/SKILL.md +88 -0
  79. package/dist/plugin/skills/setup/SKILL.md +16 -2
  80. package/dist/plugin/skills/tutor/references/focus-and-level.md +29 -0
  81. package/dist/time.js +41 -0
  82. package/dist/time.js.map +1 -0
  83. package/dist/tools/code_tools.js +73 -0
  84. package/dist/tools/code_tools.js.map +1 -0
  85. package/dist/tools/collection_tools.js +92 -0
  86. package/dist/tools/collection_tools.js.map +1 -0
  87. package/dist/tools/config_tools.js +104 -1
  88. package/dist/tools/config_tools.js.map +1 -1
  89. package/dist/tools/index.js +15 -0
  90. package/dist/tools/index.js.map +1 -1
  91. package/dist/tools/memory_read_tools.js +277 -0
  92. package/dist/tools/memory_read_tools.js.map +1 -0
  93. package/dist/tools/memory_write_tools.js +115 -0
  94. package/dist/tools/memory_write_tools.js.map +1 -0
  95. package/dist/user-skill/eklavya/SKILL.md +64 -8
  96. package/package.json +2 -1
@@ -172,6 +172,16 @@
172
172
  .ctx { color:var(--dim); }
173
173
  .empty { color:var(--faint); padding:var(--space-5) 0; font-size:13.5px; }
174
174
 
175
+ /* A bar above the view, not a banner over it: the reader is mid-page and the
176
+ notice must not move what they are reading. */
177
+ .stale { display:block; width:100%; text-align:left; margin-bottom:var(--space-6);
178
+ font-family:var(--font-mono); font-size:11.5px; letter-spacing:.04em;
179
+ text-transform:uppercase; background:none; border:1px solid var(--spot);
180
+ color:var(--spot); padding:7px 10px; cursor:pointer;
181
+ transition:background var(--duration-fast) var(--ease), color var(--duration-fast) var(--ease); }
182
+ .stale:hover { background:var(--spot); color:var(--spot-ink); }
183
+ .stale[hidden] { display:none; }
184
+
175
185
  /* ---------- controls ---------- */
176
186
  .filters { display:flex; gap:var(--space-3); flex-wrap:wrap; align-items:center;
177
187
  margin-bottom:var(--space-4); }
@@ -280,6 +290,24 @@
280
290
  .link:hover { text-decoration:underline; }
281
291
  .tags { display:flex; gap:var(--space-2); flex-wrap:wrap; }
282
292
 
293
+ /* ---------- memory ---------- */
294
+ /* A superseded or deleted row stays in the list — it is the audit trail — but
295
+ it must never read as current, so the title is struck through as well as
296
+ labelled. Colour alone is not a marker. */
297
+ tr.gone .title, .gone-title { text-decoration:line-through; color:var(--faint); }
298
+ .pill.gone { color:var(--faint); border:1px dashed var(--line-2); background:none; }
299
+ /* A <details> is the drill-down: native, keyboard-operable, no JS. */
300
+ summary.qa__meta { cursor:pointer; list-style:none; }
301
+ summary.qa__meta::-webkit-details-marker { display:none; }
302
+ summary.qa__meta::before { content:'▸'; color:var(--faint); }
303
+ details[open] > summary.qa__meta::before { content:'▾'; color:var(--spot); }
304
+ summary.qa__meta:focus-visible { outline:none; box-shadow:var(--ring); }
305
+ .raw { font-family:var(--font-mono); font-size:12px; line-height:1.55; white-space:pre-wrap;
306
+ word-break:break-word; color:var(--dim); background:var(--mass); padding:var(--space-3);
307
+ max-height:320px; overflow:auto; margin:var(--space-3) 0 0; }
308
+ .facts { margin:var(--space-3) 0 0; padding-left:var(--space-5); color:var(--dim); font-size:13.5px; }
309
+ .facts li + li { margin-top:var(--space-1); }
310
+
283
311
  .banner { border:1px solid var(--line-2); border-left:2px solid var(--spot); padding:var(--space-4) var(--space-5);
284
312
  background:var(--panel); display:flex; gap:var(--space-4); align-items:center; flex-wrap:wrap; }
285
313
  .banner p { margin:0; color:var(--dim); font-size:13.5px; }
@@ -353,6 +381,9 @@
353
381
  </aside>
354
382
 
355
383
  <main>
384
+ <button type="button" class="stale inner" id="stale" hidden aria-live="polite">
385
+ New activity since this page loaded — refresh
386
+ </button>
356
387
  <div class="inner stack" id="view">
357
388
  <p class="empty">Loading your learning history…</p>
358
389
  </div>
@@ -428,6 +459,8 @@ const T = {
428
459
  domain: { page:1 },
429
460
  concept: { page:1 },
430
461
  session: { page:1 },
462
+ memory: { page:1, q:'', tag:'all', since:'', until:'' },
463
+ reuse: { page:1, only:'all' },
431
464
  };
432
465
  const PER = 20;
433
466
 
@@ -496,18 +529,34 @@ function streaks(map) {
496
529
  function sessions() {
497
530
  const m = new Map();
498
531
  const get = (id) => {
499
- if (!m.has(id)) m.set(id, { id, repo:null, logged:[], asked:[], first:null, last:null });
532
+ if (!m.has(id)) m.set(id, { id, repo:null, logged:[], asked:[], mem:null, first:null, last:null });
500
533
  return m.get(id);
501
534
  };
535
+ /* Two timestamp shapes meet here: SQLite's `2026-09-05 21:04:00` from
536
+ attempts, and the ISO string evidence carries. Compared as text, 'T' sorts
537
+ after ' ', so the same instant from the two halves would order wrongly —
538
+ flatten both to one shape before comparing. */
539
+ const flat = (ts) => String(ts ?? '').replace('T', ' ').replace('Z', '');
502
540
  const touch = (s, ts) => {
503
- if (!s.first || ts < s.first) s.first = ts;
504
- if (!s.last || ts > s.last) s.last = ts;
541
+ const t = flat(ts);
542
+ if (!t) return;
543
+ if (!s.first || t < s.first) s.first = t;
544
+ if (!s.last || t > s.last) s.last = t;
505
545
  };
506
546
  for (const l of loggedInScope()) { const s = get(l.session_id); s.logged.push(l); s.repo = s.repo ?? l.repo; touch(s, l.ts); }
507
547
  for (const a of attemptsInScope()) {
508
548
  if (!a.session_id) continue;
509
549
  const s = get(a.session_id); s.asked.push(a); s.repo = s.repo ?? a.repo; touch(s, a.ts);
510
550
  }
551
+ // A session that captured evidence but logged no concept is still a session:
552
+ // leaving it out would make the memory half invisible in the one place the
553
+ // two halves are supposed to line up.
554
+ for (const mem of S.memory_sessions ?? []) {
555
+ if (!inScope(mem.project)) continue;
556
+ const s = get(mem.session_id);
557
+ s.mem = mem; s.repo = s.repo ?? mem.project;
558
+ touch(s, mem.first); touch(s, mem.last);
559
+ }
511
560
  return [...m.values()]
512
561
  .map((s) => ({ ...s, passed: s.asked.filter((a) => a.grade >= 3).length }))
513
562
  .sort((a, b) => String(b.last).localeCompare(String(a.last)));
@@ -525,7 +574,9 @@ function sessionLabel(s) {
525
574
  if (ctx) return ctx.context;
526
575
  const names = s.logged.map((l) => l.name);
527
576
  if (names.length) return names.slice(0, 4).join(' · ') + (names.length > 4 ? ` +${names.length - 4}` : '');
528
- return s.asked.length ? plural(s.asked.length, 'question') + ' answered' : s.id;
577
+ if (s.asked.length) return plural(s.asked.length, 'question') + ' answered';
578
+ if (s.mem) return plural(s.mem.events, 'evidence event') + ' captured';
579
+ return s.id;
529
580
  }
530
581
 
531
582
  /* ============================================================
@@ -774,6 +825,76 @@ function segbar(parts) {
774
825
 
775
826
  const chart = (id, fn) => { AFTER.push(() => { const el = $(id); if (el) fn(el); }); };
776
827
 
828
+ /* ============================================================
829
+ The memory half
830
+
831
+ Everything above is derived from one payload. The corpus is the exception:
832
+ an observation carries a narrative and the tool output behind it, so the
833
+ timeline is paged over `/api/memory` instead of shipped whole on every load.
834
+ That makes exactly two views asynchronous, and both go through `fill()`.
835
+ ============================================================ */
836
+
837
+ /** Monotonic token: a response that lands after the reader has moved on is dropped. */
838
+ let SEQ = 0;
839
+
840
+ /**
841
+ * Renders a placeholder now and the real markup when the fetch lands. Queued
842
+ * through `AFTER` like a chart, for the same reason — the target element does
843
+ * not exist until `render()` has written the view's `innerHTML`.
844
+ */
845
+ function fill(id, url, draw) {
846
+ const seq = ++SEQ;
847
+ AFTER.push(() => {
848
+ fetch(url).then((r) => r.json()).then((d) => {
849
+ const el = $(id);
850
+ if (!el || seq !== SEQ) return;
851
+ el.innerHTML = draw(d);
852
+ el.querySelectorAll('tr.row').forEach((r) => { r.tabIndex = 0; });
853
+ }).catch((e) => {
854
+ const el = $(id);
855
+ if (el && seq === SEQ) el.innerHTML = `<p class="empty">Could not load: ${esc(e.message)}</p>`;
856
+ });
857
+ });
858
+ }
859
+
860
+ /** Everything stored is untrusted text: a title is whatever a tool printed. */
861
+ const tagPills = (tags) => (tags ?? []).map((t) => `<span class="pill">${esc(t)}</span>`).join(' ');
862
+ const jsonList = (raw) => { try { const v = JSON.parse(raw || '[]'); return Array.isArray(v) ? v : []; } catch (e) { return []; } };
863
+
864
+ /** The visible marker on a row that is no longer the current claim. */
865
+ function entryMark(e) {
866
+ if (e.deleted_at) return '<span class="pill gone">deleted</span>';
867
+ if (e.superseded_by) return '<span class="pill gone">superseded</span>';
868
+ return '';
869
+ }
870
+
871
+ /** Project scope as a query parameter; `~` (unattributed) predates memory entirely. */
872
+ function memScope(params) {
873
+ if (FILTER !== '*' && FILTER !== '~') params.set('project', FILTER);
874
+ return params;
875
+ }
876
+
877
+ const deliveryPill = (d) =>
878
+ d === 'confirmed' ? '<span class="pill ok">confirmed</span>'
879
+ : d === 'prepared' ? '<span class="pill due">prepared</span>'
880
+ : '<span class="pill">unknown</span>';
881
+
882
+ /**
883
+ * The one sentence that says what a savings verdict means, in words, not a bare
884
+ * percentage. `none` has two readings and they are not the same story: nothing
885
+ * was ever reused, or plenty was and none of it was ever confirmed delivered —
886
+ * which is why the receipt count is part of the sentence.
887
+ */
888
+ function savingsText(s, receipts) {
889
+ if (!s) return 'No receipts yet.';
890
+ if (s.kind === 'saving') return `${s.percent}% less context than reading the same material from source (estimated).`;
891
+ if (s.kind === 'overhead') return `${s.tokens} tokens more than reading from source — reuse cost more than it saved.`;
892
+ if (s.kind === 'unavailable') return 'No confirmed delivery yet, so there is no saving to report.';
893
+ return receipts
894
+ ? `${plural(receipts, 'receipt')}, none of them a confirmed delivery — so there is no saving to report.`
895
+ : 'No context has been reused yet.';
896
+ }
897
+
777
898
  /* ============================================================
778
899
  Views
779
900
  ============================================================ */
@@ -819,6 +940,36 @@ function viewOverview() {
819
940
  <a class="link" data-go="#/review">review queue</a> to see what is waiting.</p></div>`
820
941
  : '',
821
942
 
943
+ // The memory half, in two summaries. Both are all-project: the timeline is
944
+ // paged on the server, so narrowing these to a scope the page cannot
945
+ // recount would be a number nobody could check.
946
+ `<div class="g2">
947
+ ${card('Context reuse', 'B is what the same material would have cost read from source; D is what was delivered instead.',
948
+ `<div class="kv">
949
+ <div><b>B — from source</b><span>${S.reuse.base}</span></div>
950
+ <div><b>D — delivered</b><span>${S.reuse.delivered}</span></div>
951
+ <div><b>Net</b><span>${S.reuse.base - S.reuse.delivered}</span></div>
952
+ <div><b>Verdict</b><span>${S.reuse.savings.kind === 'saving' ? S.reuse.savings.percent + '%'
953
+ : S.reuse.savings.kind === 'overhead' ? '+' + S.reuse.savings.tokens + ' tokens' : '—'}</span></div>
954
+ </div>
955
+ <p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">${esc(savingsText(S.reuse.savings, S.reuse.receipts))}
956
+ Estimated with <code class="slug">${esc(S.reuse.estimator)}</code> on both sides of the subtraction —
957
+ a reduction in context volume, not a bill.
958
+ <a class="link" data-go="#/reuse">Every receipt →</a></p>`)}
959
+ ${card('Memory', 'Captured is not assessed. Each of these is a different thing that happened to one piece of evidence.',
960
+ `<div class="kv">
961
+ <div><b>Observations</b><span>${S.memory.entries}</span></div>
962
+ <div><b>Evidence captured</b><span>${S.memory.captured}</span></div>
963
+ <div><b>Pending</b><span>${S.memory.pending}</span></div>
964
+ <div><b>Queue</b><span>${S.health.queue.pending}</span></div>
965
+ </div>
966
+ <p style="margin:var(--space-4) 0 0;color:var(--faint);font-size:13.5px">
967
+ ${S.memory.processed} distilled · ${S.memory.indexed} indexed · ${S.memory.reused} reused ·
968
+ ${S.memory.exposed} delivered · ${S.memory.assessed} assessed.
969
+ <a class="link" data-go="#/memory">The timeline →</a>
970
+ <a class="link" data-go="#/health">Health →</a></p>`)}
971
+ </div>`,
972
+
822
973
  card('Answers over the last 30 days', 'Right, missed and skipped, per day. Empty days are shown as empty.',
823
974
  `<div class="legend">
824
975
  <span><b style="background:var(--spot)"></b>Right</span>
@@ -1107,6 +1258,8 @@ function viewSessions() {
1107
1258
  return `<tr class="row" data-go="#/session/${encodeURIComponent(s.id)}">
1108
1259
  <td style="min-width:260px"><span class="title">${esc(base(s.repo))}</span>
1109
1260
  <div class="slug">${esc(sessionLabel(s).slice(0, 120))}</div></td>
1261
+ <td class="num">${s.mem ? s.mem.events : '—'}</td>
1262
+ <td class="num">${s.mem ? s.mem.entries : '—'}</td>
1110
1263
  <td class="num">${s.logged.length}</td>
1111
1264
  <td class="num">${s.asked.length}</td>
1112
1265
  <td style="min-width:96px">${acc === null ? '<span class="slug">—</span>'
@@ -1118,17 +1271,19 @@ function viewSessions() {
1118
1271
  return [
1119
1272
  `<div>
1120
1273
  <h1 class="page__title">Sessions</h1>
1121
- <p class="page__sub">One row per Claude Code session that logged something. Open one to see what the
1122
- agent built, what it said each concept was for, and what you were asked about it.</p>
1274
+ <p class="page__sub">One row per Claude Code session that captured or logged something. Open one for the
1275
+ whole thread: the evidence it captured, what it remembered, the concepts that came out of it, and every
1276
+ question you were asked.</p>
1123
1277
  </div>`,
1124
1278
  tiles([
1125
- ['Sessions', all.length, 'that logged a concept'],
1279
+ ['Sessions', all.length, 'with captured or logged work'],
1126
1280
  ['With questions', withQuestions, `${pct(withQuestions, all.length)}% of sessions`],
1281
+ ['Evidence', all.reduce((a, s) => a + (s.mem ? s.mem.events : 0), 0), 'events captured'],
1127
1282
  ['Concepts logged', all.reduce((a, s) => a + s.logged.length, 0), 'context lines recorded'],
1128
1283
  ['Answers', all.reduce((a, s) => a + s.asked.length, 0), 'across every session'],
1129
1284
  ]),
1130
1285
  card(null, null,
1131
- table([{t:'Session'},{t:'Logged',n:1},{t:'Asked',n:1},{t:'Accuracy'},{t:'When',n:1}], rows)
1286
+ table([{t:'Session'},{t:'Captured',n:1},{t:'Remembered',n:1},{t:'Logged',n:1},{t:'Asked',n:1},{t:'Accuracy'},{t:'When',n:1}], rows)
1132
1287
  + pager('sessions', all.length, p)),
1133
1288
  ].join('');
1134
1289
  }
@@ -1168,6 +1323,32 @@ function viewSession(id) {
1168
1323
  </article>`;
1169
1324
  }).join('');
1170
1325
 
1326
+ // The memory half of this session, in the order it happened: evidence in,
1327
+ // observations out, candidates proposed — then the questions above.
1328
+ if (s.mem) {
1329
+ fill('sess-mem', '/api/memory?per=100&session=' + encodeURIComponent(id), (d) => {
1330
+ const entries = d.rows.map((e) => `
1331
+ <tr class="row${e.deleted_at || e.superseded_by ? ' gone' : ''}" data-go="#/entry/${e.id}">
1332
+ <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1333
+ <td style="min-width:260px"><span class="title">${esc(e.title)}</span>
1334
+ <div class="ctx">${esc(e.snippet ?? '')}</div></td>
1335
+ <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1336
+ <td class="num">${e.event_count}</td>
1337
+ <td>${entryMark(e)}</td></tr>`);
1338
+ const cands = (d.candidates ?? []).map((c) => {
1339
+ const go = c.concept_slug ? ` class="row" data-go="#/concept/${encodeURIComponent(c.concept_slug)}"` : '';
1340
+ return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
1341
+ <td><span class="pill">${esc(c.domain ?? '—')}</span></td>
1342
+ <td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
1343
+ <td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
1344
+ });
1345
+ return card('What the session remembered', 'Observations distilled from the evidence this session captured.',
1346
+ table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Evidence',n:1},{t:''}], entries))
1347
+ + card('Concepts it proposed', 'Derived from evidence, and still only a proposal — nothing here moved mastery.',
1348
+ table([{t:'Candidate'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], cands));
1349
+ });
1350
+ }
1351
+
1171
1352
  return [
1172
1353
  `<div>
1173
1354
  <a class="page__back" data-go="#/sessions">← Sessions</a>
@@ -1175,12 +1356,15 @@ function viewSession(id) {
1175
1356
  <p class="page__sub">${esc(stamp(s.first))} → ${esc(stamp(s.last))} · <code class="slug">${esc(s.id)}</code></p>
1176
1357
  </div>`,
1177
1358
  tiles([
1359
+ ['Captured', s.mem ? s.mem.events : 0, s.mem && s.mem.redacted ? `${s.mem.redacted} redacted` : 'evidence events'],
1360
+ ['Remembered', s.mem ? s.mem.entries : 0, 'observations written'],
1178
1361
  ['Logged', s.logged.length, 'concepts the work touched'],
1179
1362
  ['Asked', s.asked.length, s.asked.length ? `${s.passed} right` : 'no questions in this one'],
1180
1363
  ['Accuracy', s.asked.length ? pct(s.passed, s.asked.length) + '%' : '—', 'in this session',
1181
1364
  s.asked.length && pct(s.passed, s.asked.length) >= 70 ? 'good' : ''],
1182
1365
  ['Review pulled in', review.length, 'from earlier work'],
1183
1366
  ]),
1367
+ s.mem ? `<div class="stack" id="sess-mem"><p class="empty">Loading what this session remembered…</p></div>` : '',
1184
1368
  card('What the work taught', 'Each line is what the agent said the concept was for, in the code it wrote.',
1185
1369
  table([{t:'Concept'},{t:'What you did'},{t:'State now'}], work.map(conceptRow))),
1186
1370
  review.length
@@ -1337,6 +1521,304 @@ function viewDomain(name) {
1337
1521
  ].join('');
1338
1522
  }
1339
1523
 
1524
+ /* ---------- Memory: the timeline, paged over /api/memory ---------- */
1525
+
1526
+ function memoryQuery(type) {
1527
+ const t = T.memory;
1528
+ const p = memScope(new URLSearchParams());
1529
+ if (type && type !== 'all') p.set('type', type);
1530
+ if (t.tag !== 'all') p.set('tag', t.tag);
1531
+ if (t.q.trim()) p.set('q', t.q.trim());
1532
+ // A date input is a day, not an instant, and `occurred_at` is an ISO string —
1533
+ // so the bounds have to be ISO too, or the text comparison puts `T` after the
1534
+ // space in `23:59:59` and the last day of the range drops out.
1535
+ if (t.since) p.set('since', t.since + 'T00:00:00.000Z');
1536
+ if (t.until) p.set('until', t.until + 'T23:59:59.999Z');
1537
+ p.set('page', t.page);
1538
+ p.set('per', S.memory.per_page || 25);
1539
+ return p.toString();
1540
+ }
1541
+
1542
+ function drawMemory(d) {
1543
+ T.memory.page = d.page; // the server clamps a page number a filter shrank
1544
+ const rows = d.rows.map((e) => {
1545
+ const gone = e.deleted_at || e.superseded_by;
1546
+ const more = e.narrative_length > String(e.snippet ?? '').length;
1547
+ return `<tr class="row${gone ? ' gone' : ''}" data-go="#/entry/${e.id}">
1548
+ <td class="slug" style="white-space:nowrap">${esc(stamp(e.occurred_at))}</td>
1549
+ <td style="min-width:280px"><span class="title">${esc(e.title)}</span>
1550
+ <div class="ctx">${esc(e.snippet ?? '')}${more ? '…' : ''}</div></td>
1551
+ <td><span class="pill">${esc(e.type ?? e.kind)}</span></td>
1552
+ <td style="min-width:120px"><div class="tags">${tagPills(e.tags)}</div></td>
1553
+ <td class="slug">${esc(base(e.project))}</td>
1554
+ <td class="num">${e.event_count}</td>
1555
+ <td>${entryMark(e)}</td>
1556
+ </tr>`;
1557
+ });
1558
+ return table([{t:'When'},{t:'Observation'},{t:'Type'},{t:'Tags'},{t:'Project'},{t:'Evidence',n:1},{t:''}], rows)
1559
+ + pager('memory', d.total, d.page, d.per);
1560
+ }
1561
+
1562
+ function viewMemory(param) {
1563
+ const t = T.memory;
1564
+ const M = S.memory;
1565
+ const types = M.types ?? [];
1566
+ const type = types.some((x) => x.type === param) ? param : 'all';
1567
+ fill('mem-rows', '/api/memory?' + memoryQuery(type), drawMemory);
1568
+
1569
+ const chip = (k, label, n) =>
1570
+ `<button class="chip" data-mtype="${esc(k)}" aria-pressed="${type === k}">${esc(label)}${n === undefined ? '' : ' ' + n}</button>`;
1571
+
1572
+ return [
1573
+ `<div>
1574
+ <h1 class="page__title">Memory</h1>
1575
+ <p class="page__sub">What your sessions actually did, distilled into observations and kept with the raw
1576
+ evidence behind each one. Corrections supersede rather than overwrite and deletions are marked, so what
1577
+ you see is the audit trail and not a tidied version of it.</p>
1578
+ </div>`,
1579
+
1580
+ tiles([
1581
+ ['Observations', M.entries, `${M.entries_total} written, ${M.superseded} superseded, ${M.deleted} deleted`],
1582
+ ['Evidence', M.captured, `${M.processed} distilled · ${M.pending} still pending`, M.pending ? 'warn' : '', '#/health'],
1583
+ ['Indexed', M.indexed, 'reachable by semantic search'],
1584
+ ['Reused', M.reused, `${M.exposed} actually delivered`, '', '#/reuse'],
1585
+ ['Assessed', M.assessed, 'evidence concepts you were asked about', '', '#/concepts'],
1586
+ ]),
1587
+
1588
+ card(null, null,
1589
+ `<div class="filters">
1590
+ <div class="grow"><input type="search" id="mq" value="${esc(t.q)}" placeholder="Search titles, narrative, facts and files…" aria-label="Search memory"></div>
1591
+ <select id="mtag" aria-label="Tag">
1592
+ <option value="all">All tags</option>
1593
+ ${(M.tags ?? []).map((x) => `<option value="${esc(x.tag)}"${t.tag === x.tag ? ' selected' : ''}>${esc(x.tag)} (${x.n})</option>`).join('')}
1594
+ </select>
1595
+ <input type="date" id="mfrom" value="${esc(t.since)}" aria-label="From date">
1596
+ <input type="date" id="mto" value="${esc(t.until)}" aria-label="To date">
1597
+ </div>
1598
+ <div class="chips" id="mtypes">
1599
+ ${chip('all', 'All')}${types.map((x) => chip(x.type, x.type, x.n)).join('')}
1600
+ </div>
1601
+ <div style="margin-top:var(--space-4)" id="mem-rows"><p class="empty">Loading the timeline…</p></div>`),
1602
+ ].join('');
1603
+ }
1604
+
1605
+ function drawEntry(d) {
1606
+ const e = d.entry;
1607
+ const gone = e.deleted_at || e.superseded_by;
1608
+ const facts = jsonList(e.facts);
1609
+ const files = jsonList(e.files);
1610
+ const link = (x) => `<a class="link" data-go="#/entry/${x.id}">${esc(x.title)}</a> <span class="slug">${esc(fmtDate(x.occurred_at))}</span>`;
1611
+
1612
+ const evidence = d.events.map((ev) => `
1613
+ <details class="qa">
1614
+ <summary class="qa__meta">
1615
+ <span>${esc(ev.kind)}</span>${ev.tool ? `<span>${esc(ev.tool)}</span>` : ''}
1616
+ <span>${esc(stamp(ev.occurred_at))}</span><span>${esc(ev.source)}</span>
1617
+ ${ev.redacted ? '<span class="pill due">redacted</span>' : ''}
1618
+ <span class="sp slug">${esc(ev.title ?? ev.event_uid.slice(0, 12))}</span>
1619
+ </summary>
1620
+ <pre class="raw">${esc(ev.body)}${ev.body_length > String(ev.body ?? '').length ? '\n… truncated' : ''}</pre>
1621
+ ${jsonList(ev.files).length ? `<div class="tags" style="margin-top:var(--space-3)">${jsonList(ev.files).map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
1622
+ </details>`).join('');
1623
+
1624
+ return [
1625
+ `<div>
1626
+ <p class="page__eyebrow">${esc(e.type ?? e.kind)}</p>
1627
+ <h1 class="page__title${gone ? ' gone-title' : ''}">${esc(e.title)}</h1>
1628
+ <p class="page__sub">${esc(stamp(e.occurred_at))} · ${esc(base(e.project))} ·
1629
+ written by <code class="slug">${esc(e.generator)}</code>
1630
+ ${e.import_source ? ` · imported from ${esc(e.import_source)}` : ''}
1631
+ ${e.session_id ? ` · <a class="link" data-go="#/session/${encodeURIComponent(e.session_id)}">that session</a>` : ''}</p>
1632
+ </div>`,
1633
+
1634
+ e.deleted_at
1635
+ ? `<div class="banner"><p><strong>Deleted ${esc(ago(e.deleted_at))}.</strong> It is kept here so the record
1636
+ stays checkable, and it is excluded from search and recall.</p></div>` : '',
1637
+ d.replaced_by
1638
+ ? `<div class="banner"><p><strong>Superseded.</strong> A later observation corrected this one:
1639
+ ${link(d.replaced_by)}. Recall prefers the replacement; this row is the audit trail.</p></div>` : '',
1640
+
1641
+ tiles([
1642
+ ['Confidence', e.confidence == null ? '—' : Math.round(e.confidence * 100) + '%', 'the summariser’s own, not a score'],
1643
+ ['Evidence', d.events.length, 'raw events behind this claim'],
1644
+ ['Candidates', d.candidates.length, 'concepts it proposed'],
1645
+ ['Reused', d.receipts.length, d.receipts.length ? 'times it was selected for context' : 'never selected yet', '', '#/reuse'],
1646
+ ]),
1647
+
1648
+ card('What happened', 'The observation, as written.',
1649
+ `<p class="qa__q">${esc(e.narrative) || '<span class="empty">No narrative.</span>'}</p>
1650
+ ${facts.length ? `<ul class="facts">${facts.map((f) => `<li>${esc(f)}</li>`).join('')}</ul>` : ''}
1651
+ ${files.length ? `<div class="tags" style="margin-top:var(--space-4)">${files.map((f) => `<span class="pill">${esc(f)}</span>`).join('')}</div>` : ''}
1652
+ ${d.tags.length ? `<div class="tags" style="margin-top:var(--space-3)">${tagPills(d.tags)}</div>` : ''}`),
1653
+
1654
+ d.candidates.length
1655
+ ? card('Concepts it proposed', 'A candidate is a proposal, not a concept — and never a passing answer. Only a question you answered moves mastery.',
1656
+ table([{t:'Slug'},{t:'Domain'},{t:'Confidence',n:1},{t:'Status'}], d.candidates.map((c) => {
1657
+ const go = c.concept_slug ? ` class="row" data-go="#/concept/${encodeURIComponent(c.concept_slug)}"` : '';
1658
+ return `<tr${go}><td><span class="title">${esc(c.name ?? c.slug)}</span><div class="slug">${esc(c.slug)}</div></td>
1659
+ <td><span class="pill">${esc(c.domain ?? '—')}</span></td>
1660
+ <td class="num">${Math.round((c.confidence ?? 0) * 100)}%</td>
1661
+ <td><span class="pill${c.status === 'accepted' ? ' ok' : ''}">${esc(c.status)}</span></td></tr>`;
1662
+ })))
1663
+ : '',
1664
+
1665
+ d.replaces.length
1666
+ ? card('What this replaced', 'Earlier claims this observation corrected.',
1667
+ `<div class="list">${d.replaces.map((x) => `<div>${link(x)}</div>`).join('')}</div>`)
1668
+ : '',
1669
+
1670
+ card('The raw evidence', 'Every event this was distilled from, exactly as captured. Open one to read it.',
1671
+ evidence || '<p class="empty">No evidence is linked — this entry was written by hand or imported.</p>'),
1672
+
1673
+ d.receipts.length
1674
+ ? card('Where it was reused', 'Each time this entry was selected for a context injection.',
1675
+ table([{t:'When'},{t:'Scope'},{t:'Stage'},{t:'From source',n:1},{t:'Sent',n:1},{t:'Delivery'}],
1676
+ d.receipts.map((r) => `<tr><td class="slug">${esc(stamp(r.created_at))}</td>
1677
+ <td>${esc(r.scope)}</td><td class="slug">${esc(r.stage)}</td>
1678
+ <td class="num">${r.source_tokens}</td><td class="num">${r.sent_tokens}</td>
1679
+ <td>${deliveryPill(r.delivery)}</td></tr>`)))
1680
+ : '',
1681
+ ].join('');
1682
+ }
1683
+
1684
+ function viewEntry(id) {
1685
+ fill('entry-body', '/api/memory/entry?id=' + encodeURIComponent(id), (d) =>
1686
+ d && d.entry ? drawEntry(d) : `<p class="empty">No observation <code>${esc(id)}</code>.</p>`);
1687
+ return `<div><a class="page__back" data-go="#/memory">← Memory</a></div>
1688
+ <div class="stack" id="entry-body"><p class="empty">Loading the observation…</p></div>`;
1689
+ }
1690
+
1691
+ /* ---------- Reuse: the savings ledger, receipt by receipt ---------- */
1692
+
1693
+ function viewReuse() {
1694
+ const R = S.reuse;
1695
+ const rows = R.rows.filter((r) => FILTER === '*' || FILTER === '~' || r.project === FILTER);
1696
+ const p = page('reuse', rows.length);
1697
+ const net = R.base - R.delivered;
1698
+ const verdict = R.savings.kind === 'saving' ? R.savings.percent + '%'
1699
+ : R.savings.kind === 'overhead' ? '+' + R.savings.tokens : '—';
1700
+
1701
+ const body = slice(rows, p).map((r) => {
1702
+ // A saving is only a saving when the delivery was confirmed. An unknown or
1703
+ // prepared receipt has a B and a D but no verdict, and printing one would
1704
+ // be counting context that may never have reached the model.
1705
+ const saved = r.delivery === 'confirmed' && r.base_tokens > 0
1706
+ ? Math.round((100 * (r.base_tokens - r.delivered_tokens)) / r.base_tokens) + '%'
1707
+ : '—';
1708
+ return `<tr>
1709
+ <td class="slug" style="white-space:nowrap">${esc(stamp(r.created_at))}</td>
1710
+ <td><span class="title">${esc(r.scope)}</span><div class="slug">${esc(base(r.project))}</div></td>
1711
+ <td class="num">${r.base_tokens}</td>
1712
+ <td class="num">${r.delivered_tokens}</td>
1713
+ <td class="num">${saved}</td>
1714
+ <td class="num">${r.item_count}</td>
1715
+ <td class="num">${r.detail_tokens ? '+' + r.detail_tokens : '—'}</td>
1716
+ <td>${deliveryPill(r.delivery)}</td>
1717
+ <td class="slug">${esc(r.method)}</td>
1718
+ </tr>`;
1719
+ });
1720
+
1721
+ return [
1722
+ `<div>
1723
+ <h1 class="page__title">Context reuse</h1>
1724
+ <p class="page__sub"><strong>B</strong> is what the same material would have cost to read from source.
1725
+ <strong>D</strong> is what was actually delivered instead. Both are counted with the same estimator
1726
+ (<code class="slug">${esc(R.estimator)}</code>), and the percentage is an estimate of how much
1727
+ <em>context volume</em> was avoided — not a bill, not a price, and not a measure of model spend.</p>
1728
+ </div>`,
1729
+
1730
+ tiles([
1731
+ ['B — from source', R.base, 'estimated tokens, confirmed receipts'],
1732
+ ['D — delivered', R.delivered, 'estimated tokens actually injected'],
1733
+ ['Net', net, net >= 0 ? 'fewer tokens of context' : 'more tokens than reading source', net < 0 ? 'warn' : ''],
1734
+ ['Verdict', verdict, 'estimated, all projects', R.savings.kind === 'saving' ? 'good' : R.savings.kind === 'overhead' ? 'warn' : ''],
1735
+ ['Receipts', R.receipts, `${R.confirmed} confirmed of ${R.receipts}`],
1736
+ ]),
1737
+
1738
+ `<div class="banner"><p>${esc(savingsText(R.savings, R.receipts))}
1739
+ Receipts whose delivery is <code>unknown</code> or <code>prepared</code> are counted in the ledger below
1740
+ but never in the verdict — an injection that may not have reached the model is not a saving.</p></div>`,
1741
+
1742
+ card('Every receipt', `${plural(rows.length, 'receipt')} in scope. One row per injection.`,
1743
+ table([{t:'When'},{t:'Scope'},{t:'B',n:1},{t:'D',n:1},{t:'Saved',n:1},{t:'Items',n:1},{t:'Detail fetch',n:1},{t:'Delivery'},{t:'Method'}], body)
1744
+ + pager('reuse', rows.length, p)
1745
+ + (R.receipts > R.receipts_shown
1746
+ ? `<p class="slug" style="margin:var(--space-4) 0 0">Showing the most recent ${R.receipts_shown} of ${R.receipts} receipts. The totals above cover all of them.</p>`
1747
+ : '')),
1748
+ ].join('');
1749
+ }
1750
+
1751
+ /* ---------- Health: is capture actually working? ---------- */
1752
+
1753
+ function viewHealth() {
1754
+ const H = S.health, M = S.memory, Q = H.queue;
1755
+ const ladder = [
1756
+ ['Captured', M.captured, 'evidence events the hooks accepted'],
1757
+ ['Processed', M.processed, 'distilled into an observation'],
1758
+ ['Indexed', M.indexed, 'reachable by semantic search'],
1759
+ ['Reused', M.reused, 'selected for a context injection'],
1760
+ ['Exposed', M.exposed, 'in a confirmed delivery'],
1761
+ ['Assessed', M.assessed, 'concepts you were actually asked about'],
1762
+ ];
1763
+ const oldest = Q.oldest ? ago(Q.oldest) : '—';
1764
+
1765
+ return [
1766
+ `<div>
1767
+ <h1 class="page__title">Health</h1>
1768
+ <p class="page__sub">Where memory stops, when it stops. Capture, processing, indexing, retrieval and
1769
+ delivery fail in different ways and this page keeps them apart. Error classes only — no log dumps,
1770
+ no keys, no raw request bodies.</p>
1771
+ </div>`,
1772
+
1773
+ tiles([
1774
+ ['Last capture', H.capture.newest_event ? ago(H.capture.newest_event) : 'never',
1775
+ H.capture.enabled ? `capture is ${H.capture.mode}` : 'memory is disabled',
1776
+ H.capture.enabled && H.capture.newest_event ? '' : 'warn'],
1777
+ ['Queued', Q.pending, Q.pending ? `oldest ${oldest}` : 'nothing waiting', Q.pending ? 'warn' : 'good'],
1778
+ ['Paused', Q.paused, 'waiting on a decision', Q.paused ? 'warn' : ''],
1779
+ ['Failed', Q.failed, 'gave up after retries', Q.failed ? 'warn' : ''],
1780
+ ['Dropped', H.spool_dropped, 'events the spool could not keep', H.spool_dropped ? 'warn' : ''],
1781
+ ]),
1782
+
1783
+ card('Captured is not assessed', 'Each step is a place the pipeline can stop. A number that falls off between two rows is where to look.',
1784
+ `<div class="checks">` + ladder.map(([label, n, note]) => `
1785
+ <div><span class="${n ? 'mark-ok' : 'mark-no'}">${n ? '✓' : '○'}</span>
1786
+ <span style="min-width:110px">${esc(label)}</span>
1787
+ <span class="bar">${meter(pct(n, ladder[0][1] || 1), n ? '' : 'mute')}</span>
1788
+ <span class="slug" style="white-space:nowrap">${n} — ${esc(note)}</span></div>`).join('') + `</div>`),
1789
+
1790
+ `<div class="g2">
1791
+ ${card('Capture', 'What the hooks are allowed to record, and how long it is kept.',
1792
+ `<div class="kv">
1793
+ <div><b>Memory</b><span>${H.capture.enabled ? 'on' : 'off'}</span></div>
1794
+ <div><b>Capture mode</b><span>${esc(H.capture.mode)}</span></div>
1795
+ <div><b>Newest event</b><span>${esc(H.capture.newest_event ? stamp(H.capture.newest_event) : 'never')}</span></div>
1796
+ <div><b>Redacted</b><span>${M.redacted}</span></div>
1797
+ <div><b>Retention</b><span>${H.capture.retention_days ? plural(H.capture.retention_days, 'day') : 'kept until deleted'}</span></div>
1798
+ <div><b>Last prune</b><span>${esc(H.capture.pruned_at ? ago(H.capture.pruned_at) : 'never')}</span></div>
1799
+ </div>`)}
1800
+ ${card('Providers', 'Nothing leaves this machine unless a provider is configured here.',
1801
+ `<div class="kv">
1802
+ <div><b>Observer</b><span>${H.providers.observer ? esc(H.providers.observer.kind + ' · ' + H.providers.observer.model) : 'local summariser'}</span></div>
1803
+ <div><b>Embeddings</b><span>${H.providers.embeddings ? esc(H.providers.embeddings.kind + ' · ' + H.providers.embeddings.model) : 'local embedder'}</span></div>
1804
+ <div><b>Retrieval</b><span>${esc(H.providers.retrieval_mode)}</span></div>
1805
+ </div>
1806
+ <p class="slug" style="margin:var(--space-4) 0 0">API keys are read from the environment and never
1807
+ stored, logged or shown here.</p>`)}
1808
+ </div>`,
1809
+
1810
+ card('Stalled jobs', 'Grouped by why they stopped. Transient retries on its own; auth and quota need you.',
1811
+ H.stalled.length
1812
+ ? table([{t:'Status'},{t:'Why'},{t:'Jobs',n:1},{t:'Attempts',n:1},{t:'Last',n:1}],
1813
+ H.stalled.map((j) => `<tr>
1814
+ <td><span class="pill due">${esc(j.status)}</span></td>
1815
+ <td><code class="slug">${esc(j.error_class)}</code></td>
1816
+ <td class="num">${j.n}</td><td class="num">${j.attempts}</td>
1817
+ <td class="num slug">${esc(ago(j.last))}</td></tr>`))
1818
+ : '<p class="empty">No job has failed or paused.</p>'),
1819
+ ].join('');
1820
+ }
1821
+
1340
1822
  /* ============================================================
1341
1823
  Router
1342
1824
  ============================================================ */
@@ -1347,6 +1829,9 @@ const NAV = [
1347
1829
  ['sessions', 'Sessions', '<rect x="3" y="4" width="18" height="16"/><path d="M3 9h18M8 4v16"/>'],
1348
1830
  ['projects', 'Projects', '<path d="M3 7l9-4 9 4-9 4-9-4z"/><path d="M3 12l9 4 9-4M3 17l9 4 9-4"/>'],
1349
1831
  ['domains', 'Domains', '<circle cx="6" cy="6" r="3"/><circle cx="18" cy="18" r="3"/><circle cx="6" cy="18" r="3"/><path d="M6 9v6M9 6h6a3 3 0 0 1 3 3v6"/>'],
1832
+ ['memory', 'Memory', '<path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/>'],
1833
+ ['reuse', 'Reuse', '<path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/>'],
1834
+ ['health', 'Health', '<path d="M3 12h4l2-6 3 12 2-6h7"/>'],
1350
1835
  ];
1351
1836
 
1352
1837
  const VIEWS = {
@@ -1359,6 +1844,10 @@ const VIEWS = {
1359
1844
  projects: viewProjects,
1360
1845
  domains: viewDomains,
1361
1846
  domain: viewDomain,
1847
+ memory: viewMemory,
1848
+ entry: viewEntry,
1849
+ reuse: viewReuse,
1850
+ health: viewHealth,
1362
1851
  };
1363
1852
 
1364
1853
  function route() {
@@ -1375,6 +1864,10 @@ function renderNav(active) {
1375
1864
  sessions: sessions().length,
1376
1865
  projects: S.projects.filter((p) => inScope(p.repo)).length,
1377
1866
  domains: new Set(conceptsInScope().map((c) => c.domain)).size,
1867
+ // Memory counts are all-project: the timeline is paged on the server, so
1868
+ // the page cannot honestly narrow a total it was never sent.
1869
+ memory: S.memory.entries,
1870
+ reuse: S.reuse.receipts,
1378
1871
  };
1379
1872
  $('nav').innerHTML = NAV.map(([key, label, icon]) => `
1380
1873
  <a href="#/${key}"${key === active ? ' aria-current="page"' : ''}>
@@ -1386,8 +1879,7 @@ function renderNav(active) {
1386
1879
 
1387
1880
  function render(keepFocus) {
1388
1881
  const { name, param } = route();
1389
- const root = name === 'concept' || name === 'session' || name === 'domain'
1390
- ? { concept:'concepts', session:'sessions', domain:'domains' }[name] : name;
1882
+ const root = { concept:'concepts', session:'sessions', domain:'domains', entry:'memory' }[name] ?? name;
1391
1883
  renderNav(root);
1392
1884
 
1393
1885
  AFTER.length = 0;
@@ -1423,6 +1915,11 @@ function onActivate(e) {
1423
1915
  }
1424
1916
  const tab = e.target.closest('[data-tab]');
1425
1917
  if (tab) { T.review.page = 1; return goTo(tab.dataset.tab === 'due' ? '#/review' : '#/review/' + tab.dataset.tab); }
1918
+ const mtype = e.target.closest('[data-mtype]');
1919
+ if (mtype) {
1920
+ T.memory.page = 1;
1921
+ return goTo(mtype.dataset.mtype === 'all' ? '#/memory' : '#/memory/' + encodeURIComponent(mtype.dataset.mtype));
1922
+ }
1426
1923
  const go = e.target.closest('[data-go]');
1427
1924
  if (go) return goTo(go.dataset.go);
1428
1925
  }
@@ -1438,15 +1935,24 @@ $('view').addEventListener('keydown', (e) => {
1438
1935
  });
1439
1936
 
1440
1937
  $('view').addEventListener('input', (e) => {
1441
- if (e.target.id !== 'q') return;
1442
- T.concepts.q = e.target.value;
1443
- T.concepts.page = 1;
1444
- render({ id:'q', pos:e.target.selectionStart });
1938
+ if (e.target.id === 'q') {
1939
+ T.concepts.q = e.target.value;
1940
+ T.concepts.page = 1;
1941
+ render({ id:'q', pos:e.target.selectionStart });
1942
+ }
1943
+ if (e.target.id === 'mq') {
1944
+ T.memory.q = e.target.value;
1945
+ T.memory.page = 1;
1946
+ render({ id:'mq', pos:e.target.selectionStart });
1947
+ }
1445
1948
  });
1446
1949
 
1447
1950
  $('view').addEventListener('change', (e) => {
1448
1951
  if (e.target.id === 'dom') { T.concepts.domain = e.target.value; T.concepts.page = 1; render(); }
1449
1952
  if (e.target.id === 'sort') { T.concepts.sort = e.target.value; T.concepts.page = 1; render(); }
1953
+ if (e.target.id === 'mtag') { T.memory.tag = e.target.value; T.memory.page = 1; render(); }
1954
+ if (e.target.id === 'mfrom') { T.memory.since = e.target.value; T.memory.page = 1; render(); }
1955
+ if (e.target.id === 'mto') { T.memory.until = e.target.value; T.memory.page = 1; render(); }
1450
1956
  });
1451
1957
 
1452
1958
  addEventListener('hashchange', () => render());
@@ -1461,6 +1967,45 @@ document.querySelectorAll('[data-ground]').forEach((b) => {
1461
1967
  b.addEventListener('click', () => { window.eklavyaGround.set(b.dataset.ground); render(); });
1462
1968
  });
1463
1969
 
1970
+ /* ---------- staying honest about how old this page is ---------- */
1971
+ // ponytail: a poll, not SSE. Ceiling: a change shows up to POLL_MS late, and
1972
+ // each tick rebuilds the whole payload server-side to read one field off it.
1973
+ // Both are nothing for one reader over loopback once a minute. The upgrade is
1974
+ // SSE over an event cursor (PRD DASH-01) — `s.cursor` is already the cursor,
1975
+ // so that swap is this function, not the page.
1976
+ const POLL_MS = 60000;
1977
+ let polling = false;
1978
+
1979
+ /**
1980
+ * Announced, never silent, and that is the whole design.
1981
+ *
1982
+ * A silent re-render moves the table the reader is halfway down, scrolls them
1983
+ * back to the top and drops the caret out of the search box — the page would
1984
+ * be fighting the person reading it. So the poll touches nothing but a button:
1985
+ * the view keeps rendering from the payload it booted with until the reader
1986
+ * asks for the new one. That also means there is no live state to reconcile
1987
+ * with a view derived from the old snapshot; the reload does that.
1988
+ */
1989
+ function poll() {
1990
+ // Nothing to do if we never loaded, a request is already out, the notice is
1991
+ // already up, or nobody is looking — a background tab must not poll at all.
1992
+ if (!S || polling || !$('stale').hidden || document.visibilityState !== 'visible') return;
1993
+ polling = true;
1994
+ fetch('/api/state')
1995
+ .then((r) => r.json())
1996
+ .then((s) => { if (s.cursor !== S.cursor) $('stale').hidden = false; })
1997
+ // A failed poll changes nothing: the page keeps working on the data it has.
1998
+ // One unreachable server must not blank a dashboard that is merely stale.
1999
+ .catch(() => {})
2000
+ .then(() => { polling = false; });
2001
+ }
2002
+
2003
+ setInterval(poll, POLL_MS);
2004
+ // Coming back to a tab left open all afternoon should not mean waiting out the
2005
+ // interval before the page admits it is stale.
2006
+ addEventListener('visibilitychange', poll);
2007
+ $('stale').addEventListener('click', () => location.reload());
2008
+
1464
2009
  /* ---------- boot ---------- */
1465
2010
  // The head script runs before the toggle exists, so its aria-pressed state has
1466
2011
  // to be applied again once the markup is there — otherwise a reader on paper is