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.
package/context/ledger-reflex.md
CHANGED
|
@@ -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
|
|
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
|
|
72
|
-
*
|
|
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
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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,
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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 =
|
|
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
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
|
-
|
|
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
|
}
|