eklavya 1.18.2 → 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.
- package/dist/assets/dashboard.html +559 -14
- package/dist/assets/tutor/references/focus-and-level.md +29 -0
- package/dist/cli.js +648 -20
- package/dist/cli.js.map +1 -1
- package/dist/config-path.js +156 -0
- package/dist/config-path.js.map +1 -0
- package/dist/config.js +231 -2
- package/dist/config.js.map +1 -1
- package/dist/dashboard.js +355 -0
- package/dist/dashboard.js.map +1 -1
- package/dist/eval/retrieval-score.js +77 -0
- package/dist/eval/retrieval-score.js.map +1 -0
- package/dist/hooks/capture-tool.js +107 -0
- package/dist/hooks/capture-tool.js.map +1 -0
- package/dist/hooks/checkpoint-quiz.js +5 -6
- package/dist/hooks/checkpoint-quiz.js.map +1 -1
- package/dist/hooks/lib.js +7 -33
- package/dist/hooks/lib.js.map +1 -1
- package/dist/hooks/memory-lib.js +181 -0
- package/dist/hooks/memory-lib.js.map +1 -0
- package/dist/hooks/prompt-submit-nudge.js +56 -20
- package/dist/hooks/prompt-submit-nudge.js.map +1 -1
- package/dist/hooks/session-start.js +73 -74
- package/dist/hooks/session-start.js.map +1 -1
- package/dist/hooks/stop-quiz-check.js +44 -7
- package/dist/hooks/stop-quiz-check.js.map +1 -1
- package/dist/install.js +37 -0
- package/dist/install.js.map +1 -1
- package/dist/memory/capture.js +123 -0
- package/dist/memory/capture.js.map +1 -0
- package/dist/memory/code.js +186 -0
- package/dist/memory/code.js.map +1 -0
- package/dist/memory/collections.js +87 -0
- package/dist/memory/collections.js.map +1 -0
- package/dist/memory/embed.js +81 -0
- package/dist/memory/embed.js.map +1 -0
- package/dist/memory/hosts.js +71 -0
- package/dist/memory/hosts.js.map +1 -0
- package/dist/memory/identity.js +74 -0
- package/dist/memory/identity.js.map +1 -0
- package/dist/memory/import.js +916 -0
- package/dist/memory/import.js.map +1 -0
- package/dist/memory/learning.js +148 -0
- package/dist/memory/learning.js.map +1 -0
- package/dist/memory/notify.js +220 -0
- package/dist/memory/notify.js.map +1 -0
- package/dist/memory/privacy.js +117 -0
- package/dist/memory/privacy.js.map +1 -0
- package/dist/memory/provider.js +155 -0
- package/dist/memory/provider.js.map +1 -0
- package/dist/memory/recall.js +327 -0
- package/dist/memory/recall.js.map +1 -0
- package/dist/memory/replay.js +174 -0
- package/dist/memory/replay.js.map +1 -0
- package/dist/memory/search.js +143 -0
- package/dist/memory/search.js.map +1 -0
- package/dist/memory/spool.js +93 -0
- package/dist/memory/spool.js.map +1 -0
- package/dist/memory/store.js +399 -0
- package/dist/memory/store.js.map +1 -0
- package/dist/memory/summarize.js +178 -0
- package/dist/memory/summarize.js.map +1 -0
- package/dist/memory/sync.js +595 -0
- package/dist/memory/sync.js.map +1 -0
- package/dist/memory/tokens.js +55 -0
- package/dist/memory/tokens.js.map +1 -0
- package/dist/memory/worker.js +207 -0
- package/dist/memory/worker.js.map +1 -0
- package/dist/migrations/009_memory.sql +226 -0
- package/dist/migrations/010_import.sql +26 -0
- package/dist/migrations/011_sync.sql +76 -0
- package/dist/migrations/012_job_backoff.sql +13 -0
- package/dist/migrations/013_batch_provenance.sql +18 -0
- package/dist/migrations/014_batch_events_index.sql +18 -0
- package/dist/plugin/.claude-plugin/plugin.json +1 -1
- package/dist/plugin/agents/tutor.md +3 -1
- package/dist/plugin/hooks/CLAUDE.md +30 -6
- package/dist/plugin/hooks/hooks.json +10 -0
- package/dist/plugin/skills/CLAUDE.md +17 -7
- package/dist/plugin/skills/memory/SKILL.md +88 -0
- package/dist/plugin/skills/setup/SKILL.md +16 -2
- package/dist/plugin/skills/tutor/references/focus-and-level.md +29 -0
- package/dist/time.js +41 -0
- package/dist/time.js.map +1 -0
- package/dist/tools/code_tools.js +73 -0
- package/dist/tools/code_tools.js.map +1 -0
- package/dist/tools/collection_tools.js +92 -0
- package/dist/tools/collection_tools.js.map +1 -0
- package/dist/tools/config_tools.js +104 -1
- package/dist/tools/config_tools.js.map +1 -1
- package/dist/tools/index.js +15 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/memory_read_tools.js +277 -0
- package/dist/tools/memory_read_tools.js.map +1 -0
- package/dist/tools/memory_write_tools.js +115 -0
- package/dist/tools/memory_write_tools.js.map +1 -0
- package/dist/user-skill/eklavya/SKILL.md +64 -8
- 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
|
-
|
|
504
|
-
if (!
|
|
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
|
-
|
|
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
|
|
1122
|
-
|
|
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, '
|
|
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 =
|
|
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
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
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
|