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 +1 -1
- package/public/index.html +145 -6
- package/public/ledger-view.js +122 -0
package/package.json
CHANGED
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">
|
|
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) {
|
|
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
|
-
|
|
7217
|
-
|
|
7218
|
-
|
|
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
|
+
}
|