@inetafrica/open-claudia 3.1.28 → 3.2.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/CHANGELOG.md +18 -0
- package/bin/cli.js +12 -0
- package/bin/entity.js +9 -0
- package/bin/pack.js +10 -0
- package/bin/recall-case.js +90 -0
- package/bin/remind.js +82 -0
- package/bin/tool.js +8 -3
- package/core/apprenticeship.js +104 -0
- package/core/approvals.js +39 -0
- package/core/cost-report.js +140 -0
- package/core/dedup-adjudicator.js +250 -0
- package/core/dream.js +41 -8
- package/core/entities.js +27 -5
- package/core/pack-review.js +115 -12
- package/core/packs.js +102 -4
- package/core/prospective.js +135 -0
- package/core/recall-regressions.js +171 -0
- package/core/system-prompt.js +75 -5
- package/core/tools.js +77 -0
- package/package.json +2 -2
- package/test-provider-language.js +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.2.0 — The employee batch: memory hygiene at write time, prospective memory, earned trust, and learning from the owner's judgment calls
|
|
4
|
+
|
|
5
|
+
Three approved plans land together — memory adoption A1-A4 (from the TencentDB-Agent-Memory review), codegraph Phase 1, and employee capabilities C1-C6 — plus the Kazee group speaker-identity fix. Two rules hold throughout: **report-only where trust is involved** (nothing here grants, acts, or loosens an approval rule by itself), and **no new standing context cost** (everything injects only when already-matched content fires, or lands in the nightly dream summary).
|
|
6
|
+
|
|
7
|
+
- **A1 — write-time dedup adjudicator, gated (the only hot-path item).** The post-turn reviewer's journal facts used to append unconditionally; duplicates accumulated all day for the one nightly pass that can lose things (merge) to clean up. Now cheap extractive recall checks each proposed fact against existing journal lines first. The gate keeps the hot path free: no candidates → zero LLM cost, store as proposed; exact-normalized duplicates auto-skip mechanically; only the fuzzy remainder goes to ONE batched low-tier call returning per-fact `store | update | merge | skip`. Fail-open everywhere — any error, timeout, or malformed judgement stores facts as proposed, because dedup failure must never make memory worse than append-only.
|
|
8
|
+
- **A2 — merges keep the absorbed packs' history.** Dream merges used to delete the absorbed pack's entire Journal (surviving only in the backup dir); a merged pack read younger and thinner than the history it represents. Absorbed Journal lines now carry into the survivor — normalized-exact dedup only (never fuzzy: similar-but-distinct events must both survive), date-ascending, the cap keeps newest and drops undated lines first.
|
|
9
|
+
- **A3 — substitutability stamps.** The reviewer self-rates each journal line 0-10 for how fully its wording substitutes for the turn's original words — a checkable claim about the text, not a guess about future importance. The score rides the stored line as a trailing `⟨s:N⟩` marker (announcements stay clean, near-dup similarity ignores it) and protects low-scored, verbatim-precious lines from the dream's journal collapse.
|
|
10
|
+
- **A4 — section budgets are enforced, not requested.** "Keep State under ~150 words" was prose; States drifted. LLM-edited sections now have mechanical ceilings: pack State trims at the cap with a visible cut marker + flag, entity Notes and persona sections trim at theirs, and the review prompt measures State against the target so over-budget turns the reviewer's job into an edit, not an append. Mandate (owner-set policy) and user-origin writes are never trimmed.
|
|
11
|
+
- **codegraph — code-structure queries as a reusable read-only tool (Phase 1).** Machine-side `codegraph` tool wrapping `@colbymchenry/codegraph`, pinned exactly 1.5.0 (native binaries from a solo author — upgrades are deliberate, reviewed events, never `^` drift), `CODEGRAPH_TELEMETRY=0` baked into every invocation, 12 read-only verbs with query verbs auto-syncing the index first. TOOL.md carries the graded trust model: `explore`/`node`/`search`/`files` are high-trust retrieval; `callers`/`impact`/`affected` are **advisory, positive-signal only** (verified misses: CJS namespace calls, some TS class-method sites) — grep remains the completeness check for destructive refactors. Two-week usage evaluation follows; MCP exposure was dropped against the token-efficiency goal.
|
|
12
|
+
- **C1 — prospective memory.** "Next time X comes up, mention Y" — reminders bound to a TOPIC (pack or entity), not a time; crons and watches already own time and condition. They fire as a `📌 Pending:` line when the target surfaces in recall injection or is opened with `pack show`/`entity show`, and are consumed ONLY at commit points — a dropped render can never eat a reminder. One-shot by default with a fired ledger; `--sticky` survives; the dream GCs expired and orphaned entries. New `open-claudia remind` CLI.
|
|
13
|
+
- **C2 — recall misses become permanent tests.** `open-claudia recall-case add "<query>" --expect <slug>` saves a case whenever a miss is corrected; the dream re-grades the suite nightly against the deterministic recall floor (matchPacks + matchEntities — the candidate stage every engine builds on) so the suite runs, and means the same thing, on nights the providers are down. Capped with oldest-first rotation; orphaned cases drop; pass-rate + delta land in the dream's memory-health section. "It feels degraded" is now a number.
|
|
14
|
+
- **C3 — skill certification: trust is earned per verb, and granting stays human.** Tools already log per-verb runs in state.json; a certification tier is now derived from that telemetry — `certified` at ≥5 clean runs with no recent failure, `probation` for 7 days after a failure (only clean runs SINCE the failure re-certify, read from history), `learning` otherwise. `tool show` renders the tier per verb, labeled informational — nothing in the run gate consults it. The dream proposes at most 2 certified gated-tier verbs per night as Always-allow candidates; read-only verbs need no grant, and no auto-grant exists, ever.
|
|
15
|
+
- **C4 — apprenticeship radar: it asks to be taught.** A deterministic 7-day transcript scan spots repeated manual work — the same ad-hoc command re-derived by the agent (inline code + `$`-lines; its own CLI excluded) or the owner asking for the same thing again — at ≥3 occurrences across ≥2 distinct days. The dream's introspection report offers at most 2 as `/learn` candidates with the counts as evidence. Report-only: `/learn` stays owner-triggered, and no evidence means no section, no standing cost.
|
|
16
|
+
- **C5 — denied-approval mining: trained by the owner's judgment calls.** Every Deny tap was training data we dropped. Recently DENIED approval records (never approvals — an approve confirms, it corrects nothing) now feed the post-turn reviewer as fenced DATA with explicit never-instructions framing, on owner-turn reviews only — deny content can never surface via an external speaker's turn. Each deny is fed exactly once (stamped after a parseable decision; parse failure re-feeds). Forge-proof by structure: a deny-sourced lesson's trigger must cite a real record with status denied — the model cannot invent a refusal, and an approved id confers nothing.
|
|
17
|
+
- **C6 — weekly cost self-report.** The dream summary gains a cost section: rolling 7-day totals over the existing per-turn usage log, top burns by project, week-over-week delta, and ONE concrete suggested cut picked by deterministic heuristics. Pure arithmetic — no model call, no new logging surface.
|
|
18
|
+
- **Kazee groups: the Speaker block now knows the env-configured owner.** The prompt's Speaker block resolved identity ONLY through `people.findByHandle`, so an env-configured owner (`KAZEE_OWNER_USER_ID` — no people record, because auto-contact skips owners by design) and any not-yet-recorded group member spoke anonymously, and in group chats the model refused to attribute their messages (the "Ok go" incident, 2026-08-03). The block now falls back to the canonical owner check and the transport-reported display name — without a name ever conferring owner standing (R1 intact).
|
|
19
|
+
- **Eleven new release-chain tests** (chain now 67): journal union, substitutability stamps, section budgets, dedup adjudicator, prospective memory, recall regressions, cost report, speaker context, approval mining, tool certification, apprenticeship radar. The 360° dangling-pathway pass found and closed one real gap pre-release: the C5 deny-feed originally fired on ALL reviewed turns, including external speakers' — now owner-gated with tests proving external turns see nothing and records stay un-mined.
|
|
20
|
+
|
|
3
21
|
## v3.1.28 — The Telegram reboot loop was self-inflicted: the heal kept tearing down its own recovery
|
|
4
22
|
|
|
5
23
|
- **Symptom.** v3.1.27 shipped to all four Telegram pods and the loop continued (euvy: 6 restarts in 3h). Its fix was real but addressed a different failure — the dominant one never enters `_enterOutagePause` at all (zero "Telegram unreachable" lines in the crash logs). The decisive observation was that the bot reproduced it identically on a laptop — 155 self-exits in a local `bot.log` — which retired the cluster-network and conntrack theories outright. The shape is bursts: healthy for 2.7–4.2h, then ~10 minutes of failed heals, then `process.exit(1)`, repeat.
|
package/bin/cli.js
CHANGED
|
@@ -375,6 +375,16 @@ switch (command) {
|
|
|
375
375
|
break;
|
|
376
376
|
}
|
|
377
377
|
|
|
378
|
+
case "remind": {
|
|
379
|
+
require("./remind").run(args.slice(1));
|
|
380
|
+
break;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
case "recall-case": {
|
|
384
|
+
require("./recall-case").run(args.slice(1));
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
|
|
378
388
|
case "lessons": {
|
|
379
389
|
require("./lessons").run(args.slice(1));
|
|
380
390
|
break;
|
|
@@ -473,6 +483,8 @@ Memory tools:
|
|
|
473
483
|
open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
|
|
474
484
|
open-claudia tool list|search|show|scaffold|add|set|note|edit|run|migrate|remove Reusable tools: git-tracked dirs (exec + TOOL.md + telemetry), risk-gated runs
|
|
475
485
|
open-claudia entity list|show|match|note Entity notes: people/places/projects memory
|
|
486
|
+
open-claudia remind "<note>" --pack <dir>|--entity <slug> Prospective memory: fires when that topic next comes up (remind list|remove <id>)
|
|
487
|
+
open-claudia recall-case add "<query>" --expect <slug> Memory regression suite: corrected recall misses become nightly-graded tests (list|run|remove)
|
|
476
488
|
open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
|
|
477
489
|
open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
|
|
478
490
|
open-claudia keyring list|get|set|remove Operational creds the agent can reuse (plaintext; vault for secrets)
|
package/bin/entity.js
CHANGED
|
@@ -31,6 +31,15 @@ function run(args) {
|
|
|
31
31
|
const e = entities.findEntity(rest.join(" "));
|
|
32
32
|
if (!e) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
|
|
33
33
|
console.log(fs.readFileSync(path.join(entities.ENTITIES_DIR, e.slug + ".md"), "utf-8"));
|
|
34
|
+
// C1: topic-bound reminders fire when the target is explicitly opened.
|
|
35
|
+
try {
|
|
36
|
+
const prospective = require("../core/prospective");
|
|
37
|
+
const pending = prospective.pendingFor("entity", e.slug);
|
|
38
|
+
if (pending.length) {
|
|
39
|
+
console.log("\n" + prospective.renderPendingLines(pending));
|
|
40
|
+
prospective.fire(pending, "entity show");
|
|
41
|
+
}
|
|
42
|
+
} catch (err) { /* reminders optional */ }
|
|
34
43
|
break;
|
|
35
44
|
}
|
|
36
45
|
|
package/bin/pack.js
CHANGED
|
@@ -113,6 +113,16 @@ function run(args) {
|
|
|
113
113
|
for (const t of linked) console.log(`# ${t.name} — ${t.description || "(no description)"}`);
|
|
114
114
|
}
|
|
115
115
|
} catch (e) { /* tools optional */ }
|
|
116
|
+
// C1: topic-bound reminders also fire when their target is explicitly
|
|
117
|
+
// opened — the show output IS the surface, so consuming here is safe.
|
|
118
|
+
try {
|
|
119
|
+
const prospective = require("../core/prospective");
|
|
120
|
+
const pending = prospective.pendingFor("pack", p.dir);
|
|
121
|
+
if (pending.length) {
|
|
122
|
+
console.log("\n" + prospective.renderPendingLines(pending));
|
|
123
|
+
prospective.fire(pending, "pack show");
|
|
124
|
+
}
|
|
125
|
+
} catch (e) { /* reminders optional */ }
|
|
116
126
|
break;
|
|
117
127
|
}
|
|
118
128
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// CLI: memory regression suite — corrected recall misses become permanent
|
|
2
|
+
// test cases, re-graded nightly by the dream (deterministic, no LLM).
|
|
3
|
+
// open-claudia recall-case add "<query>" --expect <slug> [--expect <slug2>] [--source live-miss|manual] [--note "..."]
|
|
4
|
+
// open-claudia recall-case list
|
|
5
|
+
// open-claudia recall-case run [--cap N] — grade now and print per-case results
|
|
6
|
+
// open-claudia recall-case remove <id>
|
|
7
|
+
// Add a case the moment a recall miss gets corrected in chat — same moment a
|
|
8
|
+
// lesson forms. The query should be the user's original phrasing; the expected
|
|
9
|
+
// slug is the pack dir / entity slug that SHOULD have surfaced.
|
|
10
|
+
|
|
11
|
+
const regressions = require("../core/recall-regressions");
|
|
12
|
+
|
|
13
|
+
function usage() {
|
|
14
|
+
console.error('Usage: open-claudia recall-case add "<query>" --expect <slug> [--expect <slug2>] [--source live-miss|manual] [--note "..."]');
|
|
15
|
+
console.error(" open-claudia recall-case list | run [--cap N] | remove <id>");
|
|
16
|
+
process.exitCode = 1;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function run(args) {
|
|
20
|
+
const cmd = (args[0] || "").toLowerCase();
|
|
21
|
+
|
|
22
|
+
if (cmd === "" || cmd === "help" || cmd === "--help") return usage();
|
|
23
|
+
|
|
24
|
+
if (cmd === "list") {
|
|
25
|
+
const all = regressions.listCases();
|
|
26
|
+
if (all.length === 0) return console.log("No regression cases yet. Add one with: open-claudia recall-case add \"<query>\" --expect <slug>");
|
|
27
|
+
for (const c of all) {
|
|
28
|
+
const state = c.lastResult ? `${c.lastResult}${c.lastRunAt ? ` @ ${String(c.lastRunAt).slice(0, 10)}` : ""}` : "never run";
|
|
29
|
+
console.log(`${c.id} [${state}] (${c.source})\n query: ${c.query}\n expect: ${c.expectSlugs.join(", ")}${c.note ? `\n note: ${c.note}` : ""}`);
|
|
30
|
+
}
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (cmd === "remove") {
|
|
35
|
+
const id = args[1];
|
|
36
|
+
if (!id) return usage();
|
|
37
|
+
if (!regressions.removeCase(id)) { console.error(`No case: ${id} (open-claudia recall-case list)`); process.exitCode = 1; return; }
|
|
38
|
+
console.log(`Removed case ${id}.`);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (cmd === "run") {
|
|
43
|
+
let cap;
|
|
44
|
+
const capIdx = args.indexOf("--cap");
|
|
45
|
+
if (capIdx !== -1) cap = Math.max(1, parseInt(args[capIdx + 1], 10) || regressions.NIGHTLY_CAP);
|
|
46
|
+
const before = regressions.listCases();
|
|
47
|
+
if (before.length === 0) return console.log("No regression cases to run.");
|
|
48
|
+
const res = regressions.runSuite(cap ? { cap } : {});
|
|
49
|
+
for (const c of regressions.listCases()) {
|
|
50
|
+
if (!c.lastResult) continue;
|
|
51
|
+
console.log(`${c.lastResult === "pass" ? "✅" : "❌"} ${c.id} ${c.query.slice(0, 60)}${c.lastResult === "fail" ? " (expected: " + c.expectSlugs.join(", ") + ")" : ""}`);
|
|
52
|
+
}
|
|
53
|
+
const rate = res.ran > 0 ? Math.round((res.passed / res.ran) * 100) : 0;
|
|
54
|
+
console.log(`\n${res.passed}/${res.ran} passing (${rate}%)${res.orphaned ? `, ${res.orphaned} orphaned case(s) dropped` : ""}${res.total > res.ran ? `, ${res.total - res.ran} deferred to next rotation` : ""}.`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (cmd === "add") {
|
|
59
|
+
const rest = args.slice(1);
|
|
60
|
+
const expect = [];
|
|
61
|
+
let source = "manual", note = "";
|
|
62
|
+
for (let i = 0; i < rest.length; i++) {
|
|
63
|
+
if (rest[i] === "--expect") { expect.push(rest[++i]); continue; }
|
|
64
|
+
if (rest[i] === "--source") { source = rest[++i]; continue; }
|
|
65
|
+
if (rest[i] === "--note") { note = rest[++i] || ""; continue; }
|
|
66
|
+
}
|
|
67
|
+
const query = rest.filter((a, i) => {
|
|
68
|
+
if (a === "--expect" || a === "--source" || a === "--note") return false;
|
|
69
|
+
if (i > 0 && ["--expect", "--source", "--note"].includes(rest[i - 1])) return false;
|
|
70
|
+
return true;
|
|
71
|
+
}).join(" ").trim();
|
|
72
|
+
if (!query || expect.length === 0) return usage();
|
|
73
|
+
// Warn (don't refuse) on an unknown slug — the pack may be created next.
|
|
74
|
+
const packs = require("../core/packs");
|
|
75
|
+
const entities = require("../core/entities");
|
|
76
|
+
for (const s of expect) {
|
|
77
|
+
let known = false;
|
|
78
|
+
try { known = !!packs.readPack(s); } catch (e) {}
|
|
79
|
+
if (!known) { try { known = !!entities.readEntity(s); } catch (e) {} }
|
|
80
|
+
if (!known) console.log(`⚠️ ${s} is not a known pack dir or entity slug right now — the case will grade as orphaned until it exists.`);
|
|
81
|
+
}
|
|
82
|
+
const entry = regressions.addCase({ query, expectSlugs: expect, source, note });
|
|
83
|
+
console.log(`🧪 Case ${entry.id} added — nightly recall must surface ${expect.join(", ")} for: "${query}"`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
usage();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { run };
|
package/bin/remind.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// CLI: prospective memory — "next time X comes up, mention Y".
|
|
2
|
+
// open-claudia remind "<note>" --pack <dir> | --entity <slug> [--sticky] [--expires <YYYY-MM-DD>]
|
|
3
|
+
// open-claudia remind list
|
|
4
|
+
// open-claudia remind remove <id>
|
|
5
|
+
// Reminders are topic-bound, not time-bound: they fire when the target pack/
|
|
6
|
+
// entity next surfaces in recall injection or is opened with pack show /
|
|
7
|
+
// entity show. Time-based reminding belongs to schedule-wakeup / cron-add;
|
|
8
|
+
// condition-based reminding belongs to watch-add.
|
|
9
|
+
|
|
10
|
+
const prospective = require("../core/prospective");
|
|
11
|
+
|
|
12
|
+
function usage() {
|
|
13
|
+
console.error('Usage: open-claudia remind "<note>" --pack <dir> | --entity <slug> [--sticky] [--expires <YYYY-MM-DD>]');
|
|
14
|
+
console.error(" open-claudia remind list | remove <id>");
|
|
15
|
+
process.exitCode = 1;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function run(args) {
|
|
19
|
+
const cmd = (args[0] || "").toLowerCase();
|
|
20
|
+
|
|
21
|
+
if (cmd === "" || cmd === "help" || cmd === "--help") return usage();
|
|
22
|
+
|
|
23
|
+
if (cmd === "list") {
|
|
24
|
+
const all = prospective.listReminders();
|
|
25
|
+
if (all.length === 0) return console.log("No pending reminders.");
|
|
26
|
+
for (const r of all) {
|
|
27
|
+
const extras = [r.sticky ? "sticky" : "one-shot", r.expiresAt ? `expires ${String(r.expiresAt).slice(0, 10)}` : ""].filter(Boolean).join(", ");
|
|
28
|
+
console.log(`${r.id} [${r.targetType}:${r.targetSlug}] (${extras}, asked ${String(r.createdAt).slice(0, 10)})\n ${r.note}`);
|
|
29
|
+
}
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (cmd === "remove") {
|
|
34
|
+
const id = args[1];
|
|
35
|
+
if (!id) return usage();
|
|
36
|
+
if (!prospective.removeReminder(id)) { console.error(`No reminder: ${id} (open-claudia remind list)`); process.exitCode = 1; return; }
|
|
37
|
+
console.log(`Removed reminder ${id}.`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Default verb: add. The note is everything that isn't a flag.
|
|
42
|
+
const rest = args.slice();
|
|
43
|
+
const takeFlag = (flag, hasValue = true) => {
|
|
44
|
+
const i = rest.indexOf(flag);
|
|
45
|
+
if (i === -1) return null;
|
|
46
|
+
const v = hasValue ? rest[i + 1] : true;
|
|
47
|
+
rest.splice(i, hasValue ? 2 : 1);
|
|
48
|
+
return v == null ? "" : v;
|
|
49
|
+
};
|
|
50
|
+
const packArg = takeFlag("--pack");
|
|
51
|
+
const entityArg = takeFlag("--entity");
|
|
52
|
+
const sticky = takeFlag("--sticky", false) === true;
|
|
53
|
+
const expires = takeFlag("--expires");
|
|
54
|
+
const note = rest.join(" ").trim();
|
|
55
|
+
|
|
56
|
+
if (!note || (!packArg && !entityArg) || (packArg && entityArg)) return usage();
|
|
57
|
+
|
|
58
|
+
// Resolve the target now so a typo surfaces immediately, not as a reminder
|
|
59
|
+
// that silently never fires (dream GC would drop it as an orphan).
|
|
60
|
+
let targetType, targetSlug, targetName;
|
|
61
|
+
if (packArg) {
|
|
62
|
+
const p = require("../core/packs").findPack(packArg);
|
|
63
|
+
if (!p) { console.error(`No pack: ${packArg} (open-claudia pack list)`); process.exitCode = 1; return; }
|
|
64
|
+
targetType = "pack"; targetSlug = p.dir; targetName = p.name || p.dir;
|
|
65
|
+
} else {
|
|
66
|
+
const e = require("../core/entities").findEntity(entityArg);
|
|
67
|
+
if (!e) { console.error(`No entity: ${entityArg} (open-claudia entity list)`); process.exitCode = 1; return; }
|
|
68
|
+
targetType = "entity"; targetSlug = e.slug; targetName = e.name || e.slug;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
let expiresAt = null;
|
|
72
|
+
if (expires) {
|
|
73
|
+
const t = Date.parse(expires);
|
|
74
|
+
if (!Number.isFinite(t)) { console.error(`Bad --expires date: ${expires} (use ISO, e.g. 2026-09-01)`); process.exitCode = 1; return; }
|
|
75
|
+
expiresAt = new Date(t).toISOString();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const entry = prospective.addReminder({ note, targetType, targetSlug, sticky, expiresAt });
|
|
79
|
+
console.log(`📌 Reminder ${entry.id} armed — fires when ${targetType} "${targetName}" next comes up${sticky ? " (sticky: repeats until removed)" : " (one-shot)"}${expiresAt ? `, expires ${expiresAt.slice(0, 10)}` : ""}.`);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { run };
|
package/bin/tool.js
CHANGED
|
@@ -214,9 +214,14 @@ async function run(args) {
|
|
|
214
214
|
console.log(`Runs: ${t.runCount}${t.lastUsed ? ` · last used ${t.lastUsed.slice(0, 16).replace("T", " ")}` : ""}${exit}`);
|
|
215
215
|
}
|
|
216
216
|
if (t.verbs && Object.keys(t.verbs).length) {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
217
|
+
let certs = {};
|
|
218
|
+
try { certs = tools.certificationForState({ verbs: t.verbs, history: t.history }); } catch (e) {}
|
|
219
|
+
const rows = Object.entries(t.verbs).map(([v, s]) => {
|
|
220
|
+
const c = certs[v];
|
|
221
|
+
const cert = c ? ` · ${c.tier}${c.tier === "certified" ? "" : ` (${c.cleanRuns}/${tools.CERTIFY_CLEAN_RUNS} clean)`}` : "";
|
|
222
|
+
return ` • ${v}: ${s.runs} run(s), last exit ${s.lastExit}${s.lastSuccess ? `, last success ${s.lastSuccess.slice(0, 10)}` : ""}${cert}`;
|
|
223
|
+
});
|
|
224
|
+
console.log("Verbs (certification is earned trust, informational only):\n" + rows.join("\n"));
|
|
220
225
|
}
|
|
221
226
|
try {
|
|
222
227
|
const log = repo.toolLog(t.name, 5);
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// C4: apprenticeship radar. A deterministic 7-day scan of the project
|
|
4
|
+
// transcripts for repeated manual work — the same ad-hoc command re-derived by
|
|
5
|
+
// the agent, or the owner asking for the same thing again and again. The scan
|
|
6
|
+
// only gathers EVIDENCE; the dream's introspection pass turns it into at most
|
|
7
|
+
// two /learn CANDIDATES in its report. Report-only end to end: /learn remains
|
|
8
|
+
// the owner-triggered capture, and nothing here creates a pack or tool.
|
|
9
|
+
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
const { TRANSCRIPTS_DIR } = require("./config");
|
|
13
|
+
|
|
14
|
+
const WINDOW_DAYS = 7;
|
|
15
|
+
const MIN_REPEATS = 3;
|
|
16
|
+
const MIN_DAYS = 2;
|
|
17
|
+
const MAX_CANDIDATES = 2;
|
|
18
|
+
const MIN_COMMAND_CHARS = 12;
|
|
19
|
+
const MIN_REQUEST_CHARS = 16;
|
|
20
|
+
const MAX_REQUEST_CHARS = 200;
|
|
21
|
+
|
|
22
|
+
function normalize(s) {
|
|
23
|
+
return String(s || "").replace(/\s+/g, " ").trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Commands the assistant quoted in a turn: inline code spans plus "$ "-prefixed
|
|
27
|
+
// lines. The bot's own bookkeeping CLI is excluded — /learn-ing `open-claudia
|
|
28
|
+
// task list` would be circular.
|
|
29
|
+
function commandsIn(text) {
|
|
30
|
+
const out = [];
|
|
31
|
+
const t = String(text || "");
|
|
32
|
+
for (const m of t.matchAll(/`([^`\n]{4,160})`/g)) out.push(m[1]);
|
|
33
|
+
for (const m of t.matchAll(/^\s*\$\s+(.{4,160})$/gm)) out.push(m[1]);
|
|
34
|
+
return out
|
|
35
|
+
.map(normalize)
|
|
36
|
+
.filter((c) => c.length >= MIN_COMMAND_CHARS)
|
|
37
|
+
.filter((c) => !c.startsWith("open-claudia "))
|
|
38
|
+
.filter((c) => /^[a-zA-Z_./~]/.test(c) && /\s/.test(c)); // looks like an invocation, not a bare word or path
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function scanForApprenticeship({
|
|
42
|
+
now = Date.now(), days = WINDOW_DAYS, dir = TRANSCRIPTS_DIR,
|
|
43
|
+
minRepeats = MIN_REPEATS, minDays = MIN_DAYS, max = MAX_CANDIDATES,
|
|
44
|
+
} = {}) {
|
|
45
|
+
let files;
|
|
46
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")); }
|
|
47
|
+
catch (e) { return []; }
|
|
48
|
+
const from = now - days * 86400000;
|
|
49
|
+
const tally = new Map(); // key → {kind, sample, count, days:Set, files:Set, lastAt}
|
|
50
|
+
const bump = (kind, sample, day, file, at) => {
|
|
51
|
+
const key = `${kind}:${sample.toLowerCase()}`;
|
|
52
|
+
const e = tally.get(key) || { kind, sample, count: 0, days: new Set(), files: new Set(), lastAt: "" };
|
|
53
|
+
e.count += 1;
|
|
54
|
+
e.days.add(day);
|
|
55
|
+
e.files.add(file);
|
|
56
|
+
if (at > e.lastAt) { e.lastAt = at; e.sample = sample; }
|
|
57
|
+
tally.set(key, e);
|
|
58
|
+
};
|
|
59
|
+
for (const f of files) {
|
|
60
|
+
let raw;
|
|
61
|
+
try { raw = fs.readFileSync(path.join(dir, f), "utf-8"); } catch (e) { continue; }
|
|
62
|
+
for (const line of raw.split("\n")) {
|
|
63
|
+
if (!line.trim()) continue;
|
|
64
|
+
let rec;
|
|
65
|
+
try { rec = JSON.parse(line); } catch (e) { continue; }
|
|
66
|
+
const t = Date.parse(rec.timestamp || "");
|
|
67
|
+
if (!Number.isFinite(t) || t < from || t > now) continue;
|
|
68
|
+
const day = new Date(t).toISOString().slice(0, 10);
|
|
69
|
+
if (rec.role === "assistant") {
|
|
70
|
+
for (const cmd of new Set(commandsIn(rec.text))) bump("command", cmd, day, f, rec.timestamp);
|
|
71
|
+
} else if (rec.role === "user") {
|
|
72
|
+
const req = normalize(rec.text);
|
|
73
|
+
if (req.length >= MIN_REQUEST_CHARS && req.length <= MAX_REQUEST_CHARS && !req.startsWith("/")) {
|
|
74
|
+
bump("request", req, day, f, rec.timestamp);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return [...tally.values()]
|
|
80
|
+
.filter((e) => e.count >= minRepeats && e.days.size >= minDays)
|
|
81
|
+
.sort((a, b) => b.count - a.count || b.days.size - a.days.size)
|
|
82
|
+
.slice(0, max)
|
|
83
|
+
.map((e) => ({ kind: e.kind, sample: e.sample, count: e.count, days: e.days.size, sessions: e.files.size }));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Prompt block for the dream's introspection pass. Empty string when nothing
|
|
87
|
+
// repeated — the section only exists when there is evidence (no standing cost).
|
|
88
|
+
function radarSection(opts = {}) {
|
|
89
|
+
const found = scanForApprenticeship(opts);
|
|
90
|
+
if (!found.length) return "";
|
|
91
|
+
const lines = found.map((c) =>
|
|
92
|
+
`- [${c.kind}] ${c.kind === "command" ? `\`${c.sample}\`` : `"${c.sample}"`} — ${c.count}× across ${c.days} day${c.days === 1 ? "" : "s"} (${c.sessions} session${c.sessions === 1 ? "" : "s"})`);
|
|
93
|
+
return [
|
|
94
|
+
"═══ APPRENTICESHIP RADAR (deterministic scan of the last 7 days of transcripts) ═══",
|
|
95
|
+
"Repeated manual work — the same thing re-derived by hand instead of taught once:",
|
|
96
|
+
...lines,
|
|
97
|
+
`In your report, offer at most ${MAX_CANDIDATES} of these as /learn candidates ("want to teach me this properly? say /learn after we next do it"), citing the counts above as evidence. Report-only: /learn stays owner-triggered — NEVER capture a pack, tool, or lesson from this radar yourself.`,
|
|
98
|
+
].join("\n");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
WINDOW_DAYS, MIN_REPEATS, MIN_DAYS, MAX_CANDIDATES,
|
|
103
|
+
commandsIn, scanForApprenticeship, radarSection,
|
|
104
|
+
};
|
package/core/approvals.js
CHANGED
|
@@ -231,6 +231,43 @@ function consume(id, commandLine) {
|
|
|
231
231
|
} finally { releaseRecordLock(held); }
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
// Read-side for the reviewer's denied-approval mining (C5): recent decided
|
|
235
|
+
// records, newest first. The ledger itself is already persisted per-record —
|
|
236
|
+
// this only lists it.
|
|
237
|
+
function listDecided({ withinMs = MAX_AGE_MS, statuses = ["denied", "approved"] } = {}) {
|
|
238
|
+
let entries;
|
|
239
|
+
try { entries = fs.readdirSync(APPROVALS_DIR); } catch (e) { return []; }
|
|
240
|
+
const wanted = new Set(statuses);
|
|
241
|
+
const cutoff = Date.now() - withinMs;
|
|
242
|
+
const out = [];
|
|
243
|
+
for (const f of entries) {
|
|
244
|
+
if (!f.endsWith(".json")) continue;
|
|
245
|
+
let rec;
|
|
246
|
+
try { rec = JSON.parse(fs.readFileSync(path.join(APPROVALS_DIR, f), "utf-8")); } catch (e) { continue; }
|
|
247
|
+
if (!rec || !wanted.has(rec.status)) continue;
|
|
248
|
+
const decidedAt = Date.parse(rec.decidedAt || "");
|
|
249
|
+
if (!Number.isFinite(decidedAt) || decidedAt < cutoff) continue;
|
|
250
|
+
out.push(rec);
|
|
251
|
+
}
|
|
252
|
+
return out.sort((a, b) => Date.parse(b.decidedAt) - Date.parse(a.decidedAt));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Stamp records the reviewer has already been shown, so each deny is fed to
|
|
256
|
+
// the correction-detector exactly once.
|
|
257
|
+
function markMined(ids) {
|
|
258
|
+
for (const id of ids || []) {
|
|
259
|
+
const held = acquireRecordLock(id);
|
|
260
|
+
if (!held) continue;
|
|
261
|
+
try {
|
|
262
|
+
const rec = read(id);
|
|
263
|
+
if (rec && !rec.minedAt) {
|
|
264
|
+
rec.minedAt = new Date().toISOString();
|
|
265
|
+
writeRecord(rec);
|
|
266
|
+
}
|
|
267
|
+
} finally { releaseRecordLock(held); }
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
234
271
|
module.exports = {
|
|
235
272
|
APPROVALS_DIR,
|
|
236
273
|
MAX_COMMAND_BYTES,
|
|
@@ -240,6 +277,8 @@ module.exports = {
|
|
|
240
277
|
decide,
|
|
241
278
|
detach,
|
|
242
279
|
formatApprovedCommand,
|
|
280
|
+
listDecided,
|
|
281
|
+
markMined,
|
|
243
282
|
read,
|
|
244
283
|
waitForDecision,
|
|
245
284
|
};
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
// C6: cost self-report. Aggregates the existing per-turn usage-history.jsonl
|
|
4
|
+
// (ts, project, provider/model, token parts, costUsd) into a rolling 7-day
|
|
5
|
+
// section for the dream summary: total spend + tokens, top burns by project,
|
|
6
|
+
// week-over-week delta, and ONE concrete suggested cut picked by
|
|
7
|
+
// deterministic heuristics. Pure arithmetic — no model call, no new logging
|
|
8
|
+
// surface, nothing recorded at turn time beyond what already exists.
|
|
9
|
+
|
|
10
|
+
const usageLog = require("./usage-log");
|
|
11
|
+
|
|
12
|
+
const WINDOW_DAYS = 7;
|
|
13
|
+
// Heuristic thresholds (exported for tests): concentration of spend in one
|
|
14
|
+
// project, poor cache-hit ratio on a meaningful token volume, oversized
|
|
15
|
+
// average context.
|
|
16
|
+
const CONCENTRATION_SHARE = 0.6;
|
|
17
|
+
const CONCENTRATION_MIN_USD = 1;
|
|
18
|
+
const CACHE_RATIO_FLOOR = 0.5;
|
|
19
|
+
const CACHE_MIN_INPUT_TOKENS = 1_000_000;
|
|
20
|
+
const CONTEXT_AVG_CEILING = 120_000;
|
|
21
|
+
|
|
22
|
+
function fmtTokens(n) {
|
|
23
|
+
if (!Number.isFinite(n) || n <= 0) return "0";
|
|
24
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
25
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
|
|
26
|
+
return String(n);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inWindow(ts, from, to) {
|
|
30
|
+
const t = Date.parse(ts);
|
|
31
|
+
return Number.isFinite(t) && t >= from && t < to;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function aggregate(records) {
|
|
35
|
+
const agg = {
|
|
36
|
+
turns: 0, costUsd: 0, inputTokens: 0, outputTokens: 0,
|
|
37
|
+
cacheReadTokens: 0, contextTokenSum: 0, contextTurns: 0,
|
|
38
|
+
byProject: new Map(),
|
|
39
|
+
};
|
|
40
|
+
for (const r of records) {
|
|
41
|
+
agg.turns += 1;
|
|
42
|
+
const cost = Number(r.costUsd) || 0;
|
|
43
|
+
agg.costUsd += cost;
|
|
44
|
+
// session_total records carry cumulative token snapshots — counting them
|
|
45
|
+
// as a turn's tokens would double-bill. Their per-turn cost still counts.
|
|
46
|
+
if (r.usageScope !== "session_total") {
|
|
47
|
+
agg.inputTokens += Number(r.inputTokens) || 0;
|
|
48
|
+
agg.outputTokens += Number(r.outputTokens) || 0;
|
|
49
|
+
agg.cacheReadTokens += Number(r.cacheReadTokens) || 0;
|
|
50
|
+
const ctx = Number(r.billedContextTokens ?? r.contextTokens) || 0;
|
|
51
|
+
if (ctx > 0) { agg.contextTokenSum += ctx; agg.contextTurns += 1; }
|
|
52
|
+
}
|
|
53
|
+
const key = String(r.project || "(no project)");
|
|
54
|
+
const p = agg.byProject.get(key) || { costUsd: 0, turns: 0 };
|
|
55
|
+
p.costUsd += cost;
|
|
56
|
+
p.turns += 1;
|
|
57
|
+
agg.byProject.set(key, p);
|
|
58
|
+
}
|
|
59
|
+
return agg;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function topBurns(agg, n = 3) {
|
|
63
|
+
return [...agg.byProject.entries()]
|
|
64
|
+
.map(([project, v]) => ({ project, costUsd: v.costUsd, turns: v.turns }))
|
|
65
|
+
.sort((a, b) => b.costUsd - a.costUsd)
|
|
66
|
+
.slice(0, n);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ONE concrete cut, first matching heuristic. Deterministic on purpose — a
|
|
70
|
+
// suggestion the owner can check against the numbers shown next to it.
|
|
71
|
+
function suggestCut(agg, burns) {
|
|
72
|
+
const top = burns[0];
|
|
73
|
+
if (top && agg.costUsd > 0 && top.costUsd / agg.costUsd > CONCENTRATION_SHARE && top.costUsd >= CONCENTRATION_MIN_USD) {
|
|
74
|
+
return `${Math.round((top.costUsd / agg.costUsd) * 100)}% of spend is "${top.project}" — review that channel's model tier and recall engine first.`;
|
|
75
|
+
}
|
|
76
|
+
const cacheDenom = agg.cacheReadTokens + agg.inputTokens;
|
|
77
|
+
if (cacheDenom >= CACHE_MIN_INPUT_TOKENS && agg.cacheReadTokens / cacheDenom < CACHE_RATIO_FLOOR) {
|
|
78
|
+
return `cache hit ratio is only ${Math.round((agg.cacheReadTokens / cacheDenom) * 100)}% — an unstable prompt head or long gaps between turns are re-billing the prefix; keeping the always-on head stable would cut input cost.`;
|
|
79
|
+
}
|
|
80
|
+
const avgCtx = agg.contextTurns ? Math.round(agg.contextTokenSum / agg.contextTurns) : 0;
|
|
81
|
+
if (avgCtx > CONTEXT_AVG_CEILING) {
|
|
82
|
+
return `average billed context is ${fmtTokens(avgCtx)} tokens/turn — earlier compaction or fewer injected packs would trim every turn.`;
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function weeklyReport({ now = Date.now(), days = WINDOW_DAYS, history } = {}) {
|
|
88
|
+
const records = history || usageLog.loadUsageHistory();
|
|
89
|
+
const from = now - days * 86400000;
|
|
90
|
+
const prevFrom = from - days * 86400000;
|
|
91
|
+
const current = aggregate(records.filter((r) => inWindow(r.ts, from, now)));
|
|
92
|
+
const previous = aggregate(records.filter((r) => inWindow(r.ts, prevFrom, from)));
|
|
93
|
+
const burns = topBurns(current);
|
|
94
|
+
return {
|
|
95
|
+
days,
|
|
96
|
+
turns: current.turns,
|
|
97
|
+
costUsd: Math.round(current.costUsd * 100) / 100,
|
|
98
|
+
inputTokens: current.inputTokens,
|
|
99
|
+
outputTokens: current.outputTokens,
|
|
100
|
+
cacheReadTokens: current.cacheReadTokens,
|
|
101
|
+
cacheHitRatio: (current.cacheReadTokens + current.inputTokens) > 0
|
|
102
|
+
? current.cacheReadTokens / (current.cacheReadTokens + current.inputTokens)
|
|
103
|
+
: null,
|
|
104
|
+
prevCostUsd: Math.round(previous.costUsd * 100) / 100,
|
|
105
|
+
deltaPct: previous.costUsd > 0 ? Math.round(((current.costUsd - previous.costUsd) / previous.costUsd) * 100) : null,
|
|
106
|
+
topBurns: burns,
|
|
107
|
+
suggestedCut: suggestCut(current, burns),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Dream entry point: returns announce lines (empty when there is no usage in
|
|
112
|
+
// the window — silence over noise).
|
|
113
|
+
function reportLines(opts = {}) {
|
|
114
|
+
const r = weeklyReport(opts);
|
|
115
|
+
if (r.turns === 0) return { report: r, lines: [] };
|
|
116
|
+
const deltaTxt = r.deltaPct === null ? "" : ` · ${r.deltaPct >= 0 ? "+" : ""}${r.deltaPct}% vs prior week`;
|
|
117
|
+
const cacheTxt = r.cacheHitRatio === null ? "" : ` (cache ${Math.round(r.cacheHitRatio * 100)}%)`;
|
|
118
|
+
const burnsTxt = r.topBurns.length
|
|
119
|
+
? ` Top: ${r.topBurns.map((b) => `${b.project} $${b.costUsd.toFixed(2)}`).join(" · ")}.`
|
|
120
|
+
: "";
|
|
121
|
+
const lines = [
|
|
122
|
+
`💸 Cost, last ${r.days}d: $${r.costUsd.toFixed(2)} · ${r.turns} turn${r.turns === 1 ? "" : "s"} · ${fmtTokens(r.inputTokens)} in / ${fmtTokens(r.outputTokens)} out${cacheTxt}${deltaTxt}.${burnsTxt}`,
|
|
123
|
+
];
|
|
124
|
+
if (r.suggestedCut) lines.push(`💡 Cut: ${r.suggestedCut}`);
|
|
125
|
+
return { report: r, lines };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
module.exports = {
|
|
129
|
+
WINDOW_DAYS,
|
|
130
|
+
CONCENTRATION_SHARE,
|
|
131
|
+
CONCENTRATION_MIN_USD,
|
|
132
|
+
CACHE_RATIO_FLOOR,
|
|
133
|
+
CACHE_MIN_INPUT_TOKENS,
|
|
134
|
+
CONTEXT_AVG_CEILING,
|
|
135
|
+
aggregate,
|
|
136
|
+
topBurns,
|
|
137
|
+
suggestCut,
|
|
138
|
+
weeklyReport,
|
|
139
|
+
reportLines,
|
|
140
|
+
};
|