shadok-ai 0.12.1 → 0.12.3

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.
@@ -9,11 +9,15 @@ open, a PR being unmerged, a task undone, a campaign live — **verify it first*
9
9
  `git log`), never assume "not merged".
10
10
  - **Everything else** (infra, marketing, site, ops, tasks): the `shadok-ledger`
11
11
  skill — `node ~/.claude/skills/shadok-ledger/ledger.mjs check "<topic>"`.
12
+ Ask in plain language; it ranks by word overlap, so you need not guess the
13
+ exact wording a sibling used.
12
14
 
13
15
  Then let it steer you:
14
16
  - a record says **resolved / done** → do NOT re-raise or re-act; report it handled;
15
17
  - **nothing recorded is UNKNOWN, not "not done"** → ask the human or hedge
16
18
  ("de mémoire, à faire — dis-moi si c'est déjà réglé"), don't assert;
19
+ - rows printed under **"related rows — they may NOT answer your question"** are
20
+ leads, not an answer: you are still in the UNKNOWN case above;
17
21
  - a **stale** record (days old) → confirm before acting.
18
22
 
19
23
  **You are also pushed the changes.** Ahead of each turn you are given — a user
@@ -20,13 +20,23 @@ system of record**: infra changes, marketing actions, site activity, ops, tasks.
20
20
  Run `check` and let it steer you:
21
21
 
22
22
  ```
23
- node ~/.claude/skills/shadok-ledger/ledger.mjs check "<topic or entity>"
23
+ node ~/.claude/skills/shadok-ledger/ledger.mjs check "<topic, in your own words>"
24
24
  ```
25
25
 
26
+ **Ask in plain language — don't try to guess how a sibling worded the row.** The
27
+ search ranks rows by word overlap, so `check "claude launcher stub spawn
28
+ failure"` finds `claude-launcher-stub-breaks-spawns`. Describing the topic in a
29
+ few words beats one lucky keyword; extra words that turn out to be absent cost
30
+ you nothing.
31
+
26
32
  - a row says **resolved / done** → do NOT re-raise or re-act; report it as handled.
27
33
  - **nothing recorded** → treat as **UNKNOWN**, not as "not done": ask the human
28
34
  or hedge ("from memory, still to do — tell me if it is already handled"),
29
35
  don't assert.
36
+ - **related rows — they may NOT answer your question** → exactly what it says:
37
+ the search found no row that answers you, only rows sharing some words. You
38
+ are still in the UNKNOWN case above. Read them as leads (they may point at the
39
+ right topic under another name), never as a status to assert.
30
40
  - a **stale** row (many days old) → hedge and confirm before acting.
31
41
 
32
42
  This fires on status-dependent claims (a bug being open, a PR unmerged, a task
@@ -67,21 +67,118 @@ export function upsertEntry(entries, patch, now, newId) {
67
67
  return [...entries.filter((e) => normEntity(e.entity) !== key), row];
68
68
  }
69
69
 
70
+ /* --------------------------------------------------------------------- *
71
+ * Recall: answering a QUESTION, not demanding the exact wording.
72
+ *
73
+ * `check` used to match the whole query as a SUBSTRING of the id, entity or
74
+ * note — so it answered only when the caller already knew how a sibling had
75
+ * phrased the row, which is exactly when they did not need to ask. Measured on
76
+ * the live 64-row table against fifteen queries agents really typed: ZERO
77
+ * returned anything, while the matching row sat there in plain sight
78
+ * ("claude launcher stub spawn failure" vs `claude-launcher-stub-breaks-spawns`).
79
+ * Each got "(nothing recorded — treat as UNKNOWN)", which the reflex reads as
80
+ * "there is nothing here" — the very silo the ledger exists to close.
81
+ *
82
+ * So: word overlap weighted by IDF, computed over the table itself.
83
+ * Design and the tuning sweep: docs/superpowers/specs/2026-09-09-ledger-recall.md
84
+ * --------------------------------------------------------------------- */
85
+
86
+ /** A hit in the entity is worth more than one in the details — but the details
87
+ * are still evidence, so this BOOSTS rather than caps: a query fully covered by
88
+ * a note must still be able to be a strong match. */
89
+ const FIELD_WEIGHT = { entity: 1, note: 0.7, source: 0.7 };
90
+ /** A word the table has never seen is DISCOUNTED, not ignored. Ignoring it
91
+ * outright let a query whose subject is absent score a perfect match on the
92
+ * filler words it happened to share with a note; counting it in full threw away
93
+ * long real queries whose subject IS recorded under other words. */
94
+ const UNKNOWN_TERM_PENALTY = 0.2;
95
+ /** Two tiers, because "nothing recorded is UNKNOWN" is load-bearing: a search
96
+ * that always returns SOMETHING would quietly erase that third state. */
97
+ const STRONG = 0.55;
98
+ const WEAK = 0.25;
99
+
100
+ /** Words, not substrings: lowercased, split on non-alphanumerics, 1-char tokens
101
+ * dropped, a trailing plural stripped — which is what lets "spawn failure"
102
+ * reach "…-breaks-spawns". */
103
+ export function tokenize(s) {
104
+ return String(s ?? "")
105
+ .toLowerCase()
106
+ .split(/[^a-z0-9]+/)
107
+ .filter((w) => w.length > 1)
108
+ .map((w) => (w.length > 3 && w.endsWith("s") && !w.endsWith("ss") ? w.slice(0, -1) : w));
109
+ }
110
+
111
+ /** Document frequency per term over the whole table — the corpus the scoring is
112
+ * relative to, so it self-tunes and needs no domain vocabulary. */
113
+ function docFreq(entries) {
114
+ const df = new Map();
115
+ for (const e of entries) {
116
+ const seen = new Set([...tokenize(e.entity), ...tokenize(e.note), ...tokenize(e.source)]);
117
+ for (const t of seen) df.set(t, (df.get(t) ?? 0) + 1);
118
+ }
119
+ return df;
120
+ }
121
+
70
122
  /**
71
- * Rows whose id, entity OR note contains the query (case-insensitive), most-
72
- * recent first. An empty query returns the whole table (for `list`).
123
+ * Rows matching `query`, split into `strong` (this is your answer) and `weak`
124
+ * (this merely shares words with your question), each ranked best-first and
125
+ * capped. An empty query returns the whole table in `strong`, recent-first.
126
+ *
127
+ * The caller MUST keep the two apart: a weak row is a lead, never a status to
128
+ * assert on.
73
129
  */
74
- export function findEntries(entries, query) {
130
+ export function searchEntries(entries, query, opts = {}) {
131
+ const strongAt = opts.strong ?? STRONG;
132
+ const weakAt = opts.weak ?? WEAK;
133
+ const byRecency = (a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0);
75
134
  const q = normEntity(query);
76
- const rows = q
77
- ? entries.filter(
78
- (e) =>
79
- String(e.id ?? "").toLowerCase().includes(q) ||
80
- normEntity(e.entity).includes(q) ||
81
- normEntity(e.note ?? "").includes(q),
82
- )
83
- : entries.slice();
84
- return rows.sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
135
+ if (!q) return { strong: entries.slice().sort(byRecency), weak: [] };
136
+
137
+ // An id is a durable handle an agent quotes from the pushed block: it must
138
+ // resolve whatever the corpus thinks of it as a word. Before the term guard,
139
+ // which would drop it as a term the table has never seen.
140
+ const byId = resolveId(entries, q);
141
+ if (byId) return { strong: [byId], weak: [] };
142
+
143
+ const df = docFreq(entries);
144
+ const N = entries.length || 1;
145
+ const idf = (t) => Math.log(1 + N / (1 + (df.get(t) ?? 0)));
146
+ // A term in most of the table cannot discriminate. Derived, never listed:
147
+ // "to" is a stopword in one table and "ledger" in none of ours, and no
148
+ // hand-written list would have known that.
149
+ const terms = [...new Set(tokenize(query))].filter((t) => !((df.get(t) ?? 0) >= 4 && df.get(t) > 0.4 * N));
150
+ const known = terms.filter((t) => df.get(t));
151
+ if (!known.length) return { strong: [], weak: [] };
152
+ const denom =
153
+ known.reduce((a, t) => a + idf(t), 0) +
154
+ UNKNOWN_TERM_PENALTY * terms.filter((t) => !df.get(t)).reduce((a, t) => a + idf(t), 0);
155
+
156
+ const scored = entries
157
+ .map((e) => {
158
+ // A verbatim substring is the old behaviour and still wins outright, so
159
+ // what `check` used to answer it still answers, identically.
160
+ if (String(e.id ?? "").toLowerCase() === q || `${e.entity} ${e.note ?? ""}`.toLowerCase().includes(q))
161
+ return { e, score: 1 };
162
+ const F = { entity: tokenize(e.entity), note: tokenize(e.note), source: tokenize(e.source) };
163
+ let matched = 0;
164
+ for (const t of known) {
165
+ const w = F.entity.includes(t)
166
+ ? FIELD_WEIGHT.entity
167
+ : F.note.includes(t)
168
+ ? FIELD_WEIGHT.note
169
+ : F.source.includes(t)
170
+ ? FIELD_WEIGHT.source
171
+ : 0;
172
+ matched += idf(t) * w;
173
+ }
174
+ return { e, score: denom ? matched / denom : 0 };
175
+ })
176
+ .sort((a, b) => b.score - a.score || byRecency(a.e, b.e));
177
+
178
+ return {
179
+ strong: scored.filter((x) => x.score >= strongAt).slice(0, 5).map((x) => x.e),
180
+ weak: scored.filter((x) => x.score < strongAt && x.score >= weakAt).slice(0, 3).map((x) => x.e),
181
+ };
85
182
  }
86
183
 
87
184
  /** Whole days since a row was last touched — the freshness the reflex hedges on. */
@@ -10,7 +10,7 @@ import fs from "node:fs";
10
10
  import os from "node:os";
11
11
  import path from "node:path";
12
12
  import crypto from "node:crypto";
13
- import { upsertEntry, findEntries, ageDays, resolveId, normEntity } from "./ledger-core.mjs";
13
+ import { upsertEntry, searchEntries, ageDays, resolveId, normEntity } from "./ledger-core.mjs";
14
14
 
15
15
  const FILE =
16
16
  (process.env.SHADOK_LEDGER_FILE || "").trim() ||
@@ -68,12 +68,21 @@ if (cmd === "check") {
68
68
  console.error('usage: ledger check "<topic or entity>"');
69
69
  process.exit(2);
70
70
  }
71
- const hits = findEntries(load(), q);
72
- if (hits.length === 0) {
71
+ // Two tiers, and the difference is the whole point: `strong` answers the
72
+ // question, `weak` merely shares words with it. Collapsing them would make a
73
+ // loosely-related row read as a status — the opposite of what this gates.
74
+ const { strong, weak } = searchEntries(load(), q);
75
+ if (strong.length) {
76
+ console.log(strong.map((e) => fmt(e, now)).join("\n"));
77
+ } else {
73
78
  // Nothing recorded is NOT "not done" — it's UNKNOWN. Tell the agent to hedge.
79
+ // This line comes FIRST and unchanged even when there are leads below it:
80
+ // the reflex keys on it, and a lead must not quietly become an answer.
74
81
  console.log(`(nothing recorded for "${q}" — treat as UNKNOWN: ask or hedge, do not assert)`);
75
- } else {
76
- console.log(hits.map((e) => fmt(e, now)).join("\n"));
82
+ if (weak.length) {
83
+ console.log(`\nrelated rows — they may NOT answer your question, do not assert on them:`);
84
+ console.log(weak.map((e) => fmt(e, now)).join("\n"));
85
+ }
77
86
  }
78
87
  } else if (cmd === "record") {
79
88
  const f = parseFlags(rest);
@@ -105,7 +114,7 @@ if (cmd === "check") {
105
114
  const row = byId ? resolveId(rows, f.id) : rows.find((r) => normEntity(r.entity) === normEntity(f.entity));
106
115
  console.log(`recorded: ${row.entity} — ${row.status} [${row.id}]`);
107
116
  } else if (cmd === "list") {
108
- const rows = findEntries(load(), "");
117
+ const rows = searchEntries(load(), "").strong;
109
118
  console.log(rows.length ? rows.map((e) => fmt(e, now)).join("\n") : "(ledger empty)");
110
119
  } else {
111
120
  console.error("usage: ledger <check|record|list> …");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shadok-ai",
3
- "version": "0.12.1",
3
+ "version": "0.12.3",
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
@@ -879,13 +879,15 @@
879
879
  overflow-x: auto;
880
880
  }
881
881
  .bubble.md pre code { background: none; padding: 0; }
882
+ /* The horizontal scroll for a wide table lives on this wrapper (added in JS
883
+ after sanitising), NOT on the <table>: `display:block` on a table breaks its
884
+ own layout — thead and tbody stop sharing column widths, so the header row
885
+ detaches and misaligns from the body. */
886
+ .bubble.md .md-table-wrap { overflow-x: auto; max-width: 100%; margin: 8px 0; }
882
887
  .bubble.md table {
883
888
  border-collapse: collapse;
884
- margin: 8px 0;
885
889
  font-size: 13px;
886
- display: block;
887
- overflow-x: auto;
888
- max-width: 100%;
890
+ width: auto;
889
891
  }
890
892
  .bubble.md th, .bubble.md td {
891
893
  border: 1px solid var(--line);
@@ -4077,6 +4079,17 @@
4077
4079
  if (role === "claude" && (extraClass === "hist" || extraClass === "live") && window.marked && window.DOMPurify) {
4078
4080
  bubble.classList.add("md");
4079
4081
  bubble.innerHTML = DOMPurify.sanitize(marked.parse(text));
4082
+ // A wide table must be able to scroll sideways WITHOUT `display:block` on
4083
+ // the <table> itself — that turns the thead and tbody into separate stacked
4084
+ // blocks that size their columns independently, so the header detaches from
4085
+ // the body and the columns no longer line up (a real, reported render bug).
4086
+ // The scroll belongs on a wrapper; the table stays a real table.
4087
+ for (const t of bubble.querySelectorAll("table")) {
4088
+ const wrap = document.createElement("div");
4089
+ wrap.className = "md-table-wrap";
4090
+ t.replaceWith(wrap);
4091
+ wrap.appendChild(t);
4092
+ }
4080
4093
  } else {
4081
4094
  bubble.textContent = text;
4082
4095
  }