shadok-ai 0.14.11 → 0.14.12

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.14.11",
3
+ "version": "0.14.12",
4
4
  "main": "dist/session.js",
5
5
  "scripts": {
6
6
  "test": "node --import tsx --test test/*.ts context/*/test/*.test.mjs",
package/public/index.html CHANGED
@@ -1693,7 +1693,37 @@
1693
1693
  #ledgerList .ldg-kind { font-size: 10px; text-transform: uppercase; letter-spacing: .04em; opacity: .55;
1694
1694
  border: 1px solid currentColor; border-radius: 3px; padding: 0 4px; }
1695
1695
  #ledgerList .ldg-meta { color: var(--text-dim); font-size: 10px; margin-top: 2px; }
1696
- #ledgerList .ldg-note { font-size: 12px; opacity: .85; margin-top: 3px; }
1696
+ #ledgerList .ldg-note { font-size: 12px; opacity: .85; margin-top: 3px; cursor: pointer;
1697
+ display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
1698
+ #ledgerList .ldg-note.open { -webkit-line-clamp: unset; overflow: visible; }
1699
+ #ledgerList .ldg-id { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 10px; opacity: .55;
1700
+ border: 1px solid var(--line, #3a3d42); border-radius: 3px; padding: 0 4px; cursor: pointer; }
1701
+ #ledgerList .ldg-id:hover { opacity: .95; }
1702
+ #ledgerList .ldg-stale { color: #e9a23b; }
1703
+ #ledgerTools { margin-bottom: 10px; }
1704
+ #ledgerTools .ldg-row { display: flex; gap: 6px; flex-wrap: wrap; }
1705
+ #ledgerTools input, #ledgerTools select { background: rgba(255,255,255,.04); border: 1px solid var(--line, #3a3d42);
1706
+ border-radius: 6px; color: inherit; font-family: inherit; padding: 5px 8px; }
1707
+ /* The compact size is gated on a FINE pointer, and that is the whole of why.
1708
+ `#ledgerTools input` (0,1,0,1) ties with the same selector in the
1709
+ (pointer: coarse) block and comes LATER, so naming it there does nothing —
1710
+ measured in a real phone viewport, the field stayed 13px. Under 16px Safari
1711
+ zooms on focus, which widens the layout viewport and sets the entire
1712
+ cockpit sliding sideways (invariant 28). So the small size never exists on
1713
+ touch rather than being overridden on it — and the shorthand is
1714
+ `font-family`, not `font`, because `font: inherit` sets the SIZE too (to
1715
+ the body's 15px) and would lose the same tie in the same silent way. */
1716
+ @media (pointer: fine) {
1717
+ #ledgerTools input, #ledgerTools select { font-size: 13px; }
1718
+ }
1719
+ #ledgerTools #ledgerSearch { flex: 1 1 150px; min-width: 120px; }
1720
+ #ledgerTools select { flex: 0 1 auto; max-width: 46%; }
1721
+ #ledgerTools .ldg-chips { display: flex; gap: 5px; flex-wrap: wrap; margin-top: 7px; }
1722
+ #ledgerTools .ldg-chip { background: none; border: 1px solid var(--line, #3a3d42); border-radius: 999px;
1723
+ color: inherit; cursor: pointer; font-size: 11px; opacity: .65; padding: 2px 9px; }
1724
+ #ledgerTools .ldg-chip[aria-pressed="true"] { opacity: 1; border-color: currentColor; background: rgba(255,255,255,.08); }
1725
+ #ledgerTools .ldg-chip .n { opacity: .55; margin-left: 4px; }
1726
+ #ledgerTools .ldg-count { color: var(--text-dim); font-size: 11px; margin-top: 7px; }
1697
1727
  #secretsPanel .sec-repo { font-size: 12px; opacity: .8; margin-bottom: 4px; word-break: break-all; }
1698
1728
  #secretsPanel .sec-note { font-size: 12px; opacity: .65; margin: 6px 0 12px; }
1699
1729
  #secretsList { list-style: none; padding: 0; margin: 0 0 12px; }
@@ -1909,6 +1939,15 @@
1909
1939
  <button id="ledgerClose" title="Close">✕</button>
1910
1940
  </div>
1911
1941
  <p class="sec-note">What sibling agents have resolved or decided, so no one re-raises it. One row per entity; a new record supersedes it. Read-only here — agents write it via the <code>shadok-ledger</code> skill.</p>
1942
+ <div id="ledgerTools">
1943
+ <div class="ldg-row">
1944
+ <input id="ledgerSearch" type="search" placeholder="search label, note, source, id…" autocomplete="off" />
1945
+ <select id="ledgerAge" title="How long since the row's claim was last confirmed"></select>
1946
+ <select id="ledgerSort" title="Order"></select>
1947
+ </div>
1948
+ <div id="ledgerChips" class="ldg-chips"></div>
1949
+ <div id="ledgerCount" class="ldg-count"></div>
1950
+ </div>
1912
1951
  </div>
1913
1952
  <div class="pin-body">
1914
1953
  <ul id="ledgerList"></ul>
@@ -2387,6 +2426,12 @@
2387
2426
  window.claudeUpdateState = claudeUpdateState;
2388
2427
  import { uploadFailure } from "/upload.js?v=__ASSET_V__";
2389
2428
  window.uploadFailure = uploadFailure;
2429
+ import { filterLedger, ledgerFacets, ledgerAge, LEDGER_AGES, LEDGER_SORTS } from "/ledger-view.js?v=__ASSET_V__";
2430
+ window.filterLedger = filterLedger;
2431
+ window.ledgerFacets = ledgerFacets;
2432
+ window.ledgerAge = ledgerAge;
2433
+ window.LEDGER_AGES = LEDGER_AGES;
2434
+ window.LEDGER_SORTS = LEDGER_SORTS;
2390
2435
  window.MODEL_CHOICES = MODEL_CHOICES;
2391
2436
  window.composeModel = composeModel;
2392
2437
  window.pickChannelSource = pickChannelSource;
@@ -2464,6 +2509,13 @@
2464
2509
  bubblePlacement: () => null,
2465
2510
  unionRect: () => null,
2466
2511
  visibleSteps: (steps) => steps ?? [],
2512
+ // The viewer is opened by a click, so the module is long since loaded —
2513
+ // but a stub is still the rule (invariant 10), and the honest one here
2514
+ // shows the table UNFILTERED rather than an empty panel that reads as
2515
+ // "the ledger is empty".
2516
+ filterLedger: (entries) => entries ?? [],
2517
+ ledgerFacets: () => ({ status: [], kind: [] }),
2518
+ ledgerAge: () => null,
2467
2519
  // A missing module must leave the picker EMPTY and silent rather than
2468
2520
  // half-built: an empty list renders no chips, so the form still starts an
2469
2521
  // agent on its profile's model, which is what it did before this existed.
@@ -7181,11 +7233,18 @@
7181
7233
  const age = days <= 0 ? "today" : days === 1 ? "yesterday" : days + "d ago";
7182
7234
  return abs + " · " + age;
7183
7235
  }
7236
+ // Everything `GET /ledger` returned, narrowed client-side: the table is small
7237
+ // enough to send whole and large enough (144 rows here, 498 on a sibling one
7238
+ // day later) that a flat list stops being readable.
7239
+ let ledgerAll = [];
7240
+ const ledgerQuery = { text: "", status: [], kind: [], age: "any", sort: "recent" };
7184
7241
  function renderLedger(entries) {
7185
7242
  const ul = $("ledgerList");
7186
7243
  ul.innerHTML = "";
7187
7244
  if (!entries || !entries.length) {
7188
- ul.innerHTML = '<li><span style="opacity:.6">the ledger is empty</span></li>';
7245
+ ul.innerHTML = '<li><span style="opacity:.6">'
7246
+ + (ledgerAll.length ? "no row matches these filters" : "the ledger is empty")
7247
+ + "</span></li>";
7189
7248
  return;
7190
7249
  }
7191
7250
  for (const e of entries) {
@@ -7207,16 +7266,96 @@
7207
7266
  const verified = e.verifiedAt && e.verifiedAt !== e.updatedAt ? " · verified " + ldgWhen(e.verifiedAt) : "";
7208
7267
  meta.textContent = ldgWhen(e.updatedAt) + verified + (e.source ? " · " + e.source : "");
7209
7268
  if (e.updatedAt) meta.title = new Date(e.updatedAt).toLocaleString();
7269
+ // A month without a confirmation is the state the skill's hedge is about,
7270
+ // so it is marked rather than left to be computed off a date.
7271
+ const age = window.ledgerAge(e);
7272
+ if (age != null && age > 30) meta.classList.add("ldg-stale");
7273
+ // The id is the durable handle: `record --id a1b2` is what you tell an
7274
+ // agent, and until now the viewer was the one place that did not show it.
7275
+ if (e.id) {
7276
+ const idb = document.createElement("button");
7277
+ idb.className = "ldg-id"; idb.type = "button"; idb.textContent = e.id;
7278
+ idb.title = "copy this id — agents update a row with `record --id " + e.id + "`";
7279
+ idb.addEventListener("click", async () => {
7280
+ try { await navigator.clipboard.writeText(e.id); idb.textContent = "copied"; }
7281
+ catch { idb.textContent = e.id; }
7282
+ setTimeout(() => { idb.textContent = e.id; }, 900);
7283
+ });
7284
+ head.appendChild(idb);
7285
+ }
7210
7286
  li.append(head, meta);
7211
- if (e.note) { const n = document.createElement("div"); n.className = "ldg-note"; n.textContent = e.note; li.appendChild(n); }
7287
+ if (e.note) {
7288
+ // Notes run to a couple of thousand characters on a busy table, so a
7289
+ // filtered list of them is a wall. Three lines, and a click opens the
7290
+ // one you came for — the first sentence is the headline anyway, which
7291
+ // is exactly what the pushed block carries.
7292
+ const n = document.createElement("div");
7293
+ n.className = "ldg-note"; n.textContent = e.note; n.title = "click to expand";
7294
+ n.addEventListener("click", () => n.classList.toggle("open"));
7295
+ li.appendChild(n);
7296
+ }
7212
7297
  ul.appendChild(li);
7213
7298
  }
7214
7299
  }
7300
+ /** Re-narrow and repaint from the rows already in hand — no round trip. */
7301
+ function applyLedgerFilters() {
7302
+ const shown = window.filterLedger(ledgerAll, ledgerQuery);
7303
+ renderLedger(shown);
7304
+ const n = ledgerAll.length;
7305
+ $("ledgerCount").textContent = shown.length === n
7306
+ ? n + (n === 1 ? " row" : " rows")
7307
+ : shown.length + " of " + n + " rows";
7308
+ }
7309
+ /** The chips are built from the data, never from a fixed list: `status` is
7310
+ * free text, so a hardcoded set would hide whatever word nobody predicted. */
7311
+ function paintLedgerChips() {
7312
+ const box = $("ledgerChips");
7313
+ box.innerHTML = "";
7314
+ const facets = window.ledgerFacets(ledgerAll);
7315
+ for (const [dim, items] of [["status", facets.status], ["kind", facets.kind]]) {
7316
+ for (const it of items) {
7317
+ const b = document.createElement("button");
7318
+ b.type = "button"; b.className = "ldg-chip";
7319
+ b.setAttribute("aria-pressed", String(ledgerQuery[dim].includes(it.value)));
7320
+ b.innerHTML = "";
7321
+ b.appendChild(document.createTextNode(it.value));
7322
+ const n = document.createElement("span"); n.className = "n"; n.textContent = it.count;
7323
+ b.appendChild(n);
7324
+ b.addEventListener("click", () => {
7325
+ const sel = ledgerQuery[dim];
7326
+ const i = sel.indexOf(it.value);
7327
+ if (i < 0) sel.push(it.value); else sel.splice(i, 1);
7328
+ b.setAttribute("aria-pressed", String(i < 0));
7329
+ applyLedgerFilters();
7330
+ });
7331
+ box.appendChild(b);
7332
+ }
7333
+ }
7334
+ }
7335
+ for (const [id, key] of [["ledgerAge", "age"], ["ledgerSort", "sort"]]) {
7336
+ const sel = $(id);
7337
+ sel.addEventListener("change", () => { ledgerQuery[key] = sel.value; applyLedgerFilters(); });
7338
+ }
7339
+ $("ledgerSearch").addEventListener("input", () => {
7340
+ ledgerQuery.text = $("ledgerSearch").value;
7341
+ applyLedgerFilters();
7342
+ });
7215
7343
  $("ledgerBtn").addEventListener("click", async () => {
7216
- let entries = [];
7217
- try { entries = (await (await fetch("/ledger")).json()).entries || []; } catch { entries = []; }
7218
- renderLedger(entries);
7344
+ try { ledgerAll = (await (await fetch("/ledger")).json()).entries || []; } catch { ledgerAll = []; }
7345
+ // The option lists come from the module, so the control and the filter it
7346
+ // drives cannot drift apart.
7347
+ for (const [id, list] of [["ledgerAge", window.LEDGER_AGES], ["ledgerSort", window.LEDGER_SORTS]]) {
7348
+ const sel = $(id);
7349
+ if (!sel.options.length) {
7350
+ for (const o of list ?? []) sel.add(new Option(o.label, o.value));
7351
+ }
7352
+ }
7353
+ $("ledgerAge").value = ledgerQuery.age;
7354
+ $("ledgerSort").value = ledgerQuery.sort;
7355
+ paintLedgerChips();
7356
+ applyLedgerFilters();
7219
7357
  $("ledgerOverlay").hidden = false;
7358
+ $("ledgerSearch").focus();
7220
7359
  });
7221
7360
  $("ledgerClose").addEventListener("click", () => { $("ledgerOverlay").hidden = true; });
7222
7361
  $("ledgerOverlay").addEventListener("click", (e) => { if (e.target === $("ledgerOverlay")) $("ledgerOverlay").hidden = true; });
@@ -0,0 +1,122 @@
1
+ // Filtering and sorting for the read-only ledger viewer.
2
+ //
3
+ // The table is small enough to send whole (`GET /ledger`) and large enough that
4
+ // a flat list stops being readable: 144 rows on the instance that develops
5
+ // shadok, 498 on a sibling one day later, and it only grows with the number of
6
+ // live topics. So the narrowing happens client-side, over rows already in hand
7
+ // — no endpoint, no pagination, no round trip per keystroke.
8
+ //
9
+ // Pure, and in its own module for the usual reason (invariant 10's family): the
10
+ // browser loads it AND `test/ledger-view.test.ts` imports it, so the rules below
11
+ // are exercised without a DOM.
12
+
13
+ /** Case- and ACCENT-insensitive, because a sibling instance's rows are written
14
+ * in French: searching `hopital` must find `hôpital`, or the box is a trap for
15
+ * exactly the people whose rows need it most. */
16
+ export function ldgNorm(s) {
17
+ return String(s ?? "")
18
+ .normalize("NFD")
19
+ .replace(/\p{Diacritic}/gu, "")
20
+ .toLowerCase();
21
+ }
22
+
23
+ /** Everything a row can be found by. The id is in there on purpose: it is the
24
+ * handle the pushed block and the CLI both print, so pasting `a1b2` back in
25
+ * has to land on the row it names. */
26
+ export function ledgerHaystack(e) {
27
+ return ldgNorm([e?.id, e?.entity, e?.status, e?.kind, e?.note, e?.source].filter(Boolean).join(" "));
28
+ }
29
+
30
+ /**
31
+ * How many days since the row's claim was last CONFIRMED — `verifiedAt` when it
32
+ * exists, else `updatedAt`. Same two-clock rule the CLI and the block use: an
33
+ * edit is not an observation, and the staleness judgement rests on the latter.
34
+ * Counted in CALENDAR days, so it agrees with the date the row shows rather
35
+ * than with a rolling 24 hours.
36
+ */
37
+ export function ledgerAge(e, now = Date.now()) {
38
+ const t = e?.verifiedAt ?? e?.updatedAt;
39
+ if (!t) return null;
40
+ const d = new Date(t);
41
+ const n = new Date(now);
42
+ const d0 = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
43
+ const n0 = new Date(n.getFullYear(), n.getMonth(), n.getDate()).getTime();
44
+ return Math.max(0, Math.round((n0 - d0) / 86400000));
45
+ }
46
+
47
+ /** The age windows offered, as data so the <select> is built from them and the
48
+ * filter reads the same list. `stale` is the inverse of the others and the one
49
+ * with operational value: a row nobody has re-confirmed in a month is exactly
50
+ * what the hedge in the skill is about. */
51
+ export const LEDGER_AGES = [
52
+ { value: "any", label: "any age" },
53
+ { value: "today", label: "today", max: 0 },
54
+ { value: "7", label: "last 7 days", max: 7 },
55
+ { value: "30", label: "last 30 days", max: 30 },
56
+ { value: "stale", label: "not confirmed in 30d", min: 31 },
57
+ ];
58
+
59
+ export const LEDGER_SORTS = [
60
+ { value: "recent", label: "most recent" },
61
+ { value: "stale", label: "least recently confirmed" },
62
+ { value: "entity", label: "name (A→Z)" },
63
+ ];
64
+
65
+ /** The distinct values really present, with their counts — never a hardcoded
66
+ * list: `status` is free text (an agent may write anything), so a fixed set of
67
+ * chips would silently hide whatever word it did not predict. */
68
+ export function ledgerFacets(entries) {
69
+ const tally = (pick) => {
70
+ const m = new Map();
71
+ for (const e of entries ?? []) {
72
+ const v = pick(e);
73
+ if (v) m.set(v, (m.get(v) ?? 0) + 1);
74
+ }
75
+ return [...m].map(([value, count]) => ({ value, count })).sort((a, b) => b.count - a.count || a.value.localeCompare(b.value));
76
+ };
77
+ return {
78
+ status: tally((e) => e.status),
79
+ // `task` is the default and nearly the whole table, so it is a chip like the
80
+ // others here (unlike the block, where tagging it would be noise on a line
81
+ // the reader did not ask for) — in a filter bar the reader DID ask.
82
+ kind: tally((e) => e.kind || "task"),
83
+ };
84
+ }
85
+
86
+ /**
87
+ * Narrow and order. An empty selection means "no constraint on that dimension",
88
+ * never "nothing" — a filter bar that can be driven into an empty list by doing
89
+ * nothing is a bug report waiting to happen.
90
+ *
91
+ * Every search term must match (AND), which is how a search box is expected to
92
+ * behave: typing more words narrows.
93
+ */
94
+ export function filterLedger(entries, query = {}, now = Date.now()) {
95
+ const { text = "", status = [], kind = [], age = "any", sort = "recent" } = query;
96
+ const terms = ldgNorm(text).split(/\s+/).filter(Boolean);
97
+ const window = LEDGER_AGES.find((a) => a.value === age) ?? LEDGER_AGES[0];
98
+ const out = (entries ?? []).filter((e) => {
99
+ if (status.length && !status.includes(e.status)) return false;
100
+ if (kind.length && !kind.includes(e.kind || "task")) return false;
101
+ if (window.max != null || window.min != null) {
102
+ const d = ledgerAge(e, now);
103
+ // A row with no date at all is NOT quietly dropped by an age window: it
104
+ // is a row we know nothing about, and hiding it is how it stops existing.
105
+ if (d != null) {
106
+ if (window.max != null && d > window.max) return false;
107
+ if (window.min != null && d < window.min) return false;
108
+ }
109
+ }
110
+ if (terms.length) {
111
+ const hay = ledgerHaystack(e);
112
+ if (!terms.every((t) => hay.includes(t))) return false;
113
+ }
114
+ return true;
115
+ });
116
+ const by = {
117
+ recent: (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0),
118
+ stale: (a, b) => (ledgerAge(b, now) ?? -1) - (ledgerAge(a, now) ?? -1) || (a.updatedAt ?? 0) - (b.updatedAt ?? 0),
119
+ entity: (a, b) => String(a.entity ?? "").localeCompare(String(b.entity ?? "")),
120
+ };
121
+ return out.sort(by[sort] ?? by.recent);
122
+ }