@viloforge/vfkb 0.2.1

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.
@@ -0,0 +1,61 @@
1
+ // Counter/signal stream (M2b — ADR-0021 pt 3/4). Helpful/harmful tallies keyed by
2
+ // entry id, recorded as an APPEND-ONLY stream and AGGREGATED AT READ — the entry
3
+ // envelope is NEVER edited (deltas-not-rewrites; counters fight the immutability model
4
+ // if stored on the entry). This is the evidence that drives CORROBORATED promotion:
5
+ // auto-distill alone cannot mint trusted knowledge (ADR-0021 pt 4).
6
+ //
7
+ // Storage (M2b sub-decision a, settled 2026-06-25): OPERATIONAL / gitignored, under
8
+ // <brain>/.signals/counters.jsonl — mirrors <brain>/.sessions. The DURABLE effect
9
+ // (promotion) lands in the committed entries.jsonl SoR; raw tallies are append-only
10
+ // agent-trust telemetry that survives container restart but is not committed. Keeps the
11
+ // brain the single committed source of truth.
12
+ import { appendFileSync, mkdirSync, readFileSync, existsSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { brainDir } from './storage.js';
15
+ function signalsFile() {
16
+ return join(brainDir(), '.signals', 'counters.jsonl');
17
+ }
18
+ // Append a single signal. Never reads/mutates the entry — purely additive.
19
+ export function recordSignal(entryId, kind, source) {
20
+ const sig = { entryId, kind, at: new Date().toISOString(), source };
21
+ mkdirSync(join(brainDir(), '.signals'), { recursive: true });
22
+ appendFileSync(signalsFile(), JSON.stringify(sig) + '\n', 'utf8');
23
+ return sig;
24
+ }
25
+ export function readSignals() {
26
+ const f = signalsFile();
27
+ if (!existsSync(f))
28
+ return [];
29
+ return readFileSync(f, 'utf8')
30
+ .split('\n')
31
+ .filter((l) => l.trim().length > 0)
32
+ .map((l) => JSON.parse(l));
33
+ }
34
+ // Aggregate-at-read for one entry.
35
+ export function tally(entryId, signals = readSignals()) {
36
+ let helpful = 0;
37
+ let harmful = 0;
38
+ for (const s of signals) {
39
+ if (s.entryId !== entryId)
40
+ continue;
41
+ if (s.kind === 'helpful')
42
+ helpful++;
43
+ else if (s.kind === 'harmful')
44
+ harmful++;
45
+ }
46
+ return { helpful, harmful, net: helpful - harmful };
47
+ }
48
+ // Aggregate-at-read for the whole stream.
49
+ export function tallies(signals = readSignals()) {
50
+ const out = new Map();
51
+ for (const s of signals) {
52
+ const t = out.get(s.entryId) ?? { helpful: 0, harmful: 0, net: 0 };
53
+ if (s.kind === 'helpful')
54
+ t.helpful++;
55
+ else if (s.kind === 'harmful')
56
+ t.harmful++;
57
+ t.net = t.helpful - t.harmful;
58
+ out.set(s.entryId, t);
59
+ }
60
+ return out;
61
+ }
@@ -0,0 +1,87 @@
1
+ // ACE curator (ADR-0021 / RFC-006) — the maintenance side of auto-distill.
2
+ // DELTAS + COUNTERS, NEVER REWRITES (IMPL-PLAN L12 context-collapse). Every op here
3
+ // acts ONLY through the engine's non-destructive primitives and leaves the entry's
4
+ // TEXT byte-identical. That invariant is the load-bearing safety rail and is enforced
5
+ // by the structural Brake in test/curator.test.ts (any in-place text edit fails the
6
+ // build) — because a prose "don't rewrite" rule an LLM curator will eventually ignore.
7
+ //
8
+ // M2a ships the curator ops + the Brake + the retrieval-quality regression. The
9
+ // distiller (write side) and the counter/signal stream that DRIVES promotion are M2b.
10
+ import { readAll, updateEntry, setProvenanceStatus, transitionDecision, isDecisionFamily } from './engine.js';
11
+ import { tally } from './counters.js';
12
+ function get(id) {
13
+ const e = readAll().find((x) => x.id === id);
14
+ if (!e)
15
+ throw new Error(`no such entry: ${id}`);
16
+ return e;
17
+ }
18
+ // promote: an `incoming` candidate becomes trusted-zone (`established`). Fluid types
19
+ // move zone; a decision's standing is its status (transitionDecision), never a zone
20
+ // promotion here. Text is never touched.
21
+ export function promote(id) {
22
+ const e = get(id);
23
+ if (isDecisionFamily(e.type)) {
24
+ throw new Error(`promote() is for fluid types; a decision's standing is its status (use transitionDecision)`);
25
+ }
26
+ return updateEntry(id, { zone: 'established' });
27
+ }
28
+ // CORROBORATED promotion (ADR-0021 pt 4): auto-distilled `incoming` knowledge CANNOT be
29
+ // minted into the trusted set on a single distillation — it needs ≥N independent
30
+ // corroborating signals (or a human, who uses the unguarded promote() above). The
31
+ // evidence is the append-only counter stream, aggregated at read; promotion itself is
32
+ // still the same non-destructive zone transition (text never touched). This is the gate
33
+ // that keeps machine extraction from self-promoting.
34
+ export const PROMOTION_THRESHOLD = 2; // net helpful signals required (≥2 corroborations)
35
+ export function eligibleForPromotion(id, threshold = PROMOTION_THRESHOLD) {
36
+ return tally(id).net >= threshold;
37
+ }
38
+ export function promoteIfCorroborated(id, threshold = PROMOTION_THRESHOLD) {
39
+ const t = tally(id);
40
+ if (t.net < threshold) {
41
+ throw new Error(`entry ${id} is not corroborated (net ${t.net} < ${threshold}) — auto-distill alone cannot mint trusted knowledge (ADR-0021); needs more signals or a human promote`);
42
+ }
43
+ // D-iii (ADR-0024): the trust elevation must be AGENT-OBSERVABLE, not just a zone move.
44
+ // Corroboration-by-recurrence is the independent "second agent" signal §3.6 requires
45
+ // (the recurrence, not the author, asserts it) — so promotion both moves the zone AND
46
+ // re-stamps provenance verified, putting the lesson into the verified-only view + the ✓
47
+ // glyph. Text stays byte-identical (the never-rewrite Brake holds — setProvenanceStatus
48
+ // touches only metadata).
49
+ promote(id);
50
+ return setProvenanceStatus(id, 'verified');
51
+ }
52
+ // archive: retire a stale/noise entry out of the injection set. Fluid → zone
53
+ // `archive`; decision → `deprecated` (status). Text is never touched.
54
+ export function archive(id) {
55
+ const e = get(id);
56
+ if (isDecisionFamily(e.type))
57
+ return transitionDecision(id, 'deprecated');
58
+ return updateEntry(id, { zone: 'archive' });
59
+ }
60
+ // mergeDuplicate: keep `winnerId`, retire the fluid duplicate `loserId` by archiving
61
+ // it + tagging the edge so the merge is auditable. NEITHER entry's text is rewritten.
62
+ // Decisions are merged by an explicit supersede() edge, never here.
63
+ export function mergeDuplicate(loserId, winnerId) {
64
+ const loser = get(loserId);
65
+ get(winnerId); // winner must exist
66
+ if (isDecisionFamily(loser.type)) {
67
+ throw new Error(`merge a decision via supersede(), not the curator`);
68
+ }
69
+ const tags = [...new Set([...loser.tags, `merged-into:${winnerId}`])];
70
+ return updateEntry(loserId, { zone: 'archive', tags });
71
+ }
72
+ export function findLexicalDuplicates(entries = readAll()) {
73
+ const norm = (t) => t.toLowerCase().replace(/\s+/g, ' ').trim();
74
+ const seen = new Map(); // normalized text → first (winner) id
75
+ const out = [];
76
+ for (const e of entries) {
77
+ if (e.zone === 'archive' || isDecisionFamily(e.type))
78
+ continue;
79
+ const key = `${e.type}:${norm(e.text)}`;
80
+ const winner = seen.get(key);
81
+ if (winner)
82
+ out.push({ loser: e.id, winner });
83
+ else
84
+ seen.set(key, e.id);
85
+ }
86
+ return out;
87
+ }
@@ -0,0 +1,116 @@
1
+ // Auto-distill write side (M2b — ADR-0021 pt 1 / RFC-006 / D7b). Turns a session's
2
+ // captured signals into CANDIDATE knowledge written ONLY to `incoming` / `unverified` /
3
+ // agent-trust. CONTAINMENT is the safety property: the trusted set is never polluted by
4
+ // machine extraction — a deterministic test asserts every distilled entry is
5
+ // incoming+unverified+agent (test/distiller.test.ts). v1 is a DETERMINISTIC distiller;
6
+ // an optional off-hot-path LLM distiller slots behind the same `Distiller` seam
7
+ // (ADR-0013: opt-in, graceful-degrade, NEVER on the always-on inject path).
8
+ //
9
+ // Signal v1 (the realistic deterministic lesson): a captured tool call whose bounded
10
+ // outcome (M2b sub-decision b) is `error` → a candidate gotcha "Tool X can fail: …".
11
+ // RECURRENCE = CORROBORATION: a second session re-distilling the same error SIGNATURE
12
+ // does NOT duplicate — it records a corroborating counter signal on the existing
13
+ // candidate, which is what drives corroborated promotion (ADR-0021 pt 4).
14
+ import { createHash } from 'node:crypto';
15
+ import { addEntry, readAll } from './engine.js';
16
+ import { recordSignal } from './counters.js';
17
+ const SIG_PREFIX = 'distill-sig:';
18
+ const DISTILLED_TAG = 'distilled';
19
+ // A captured error fact carries tag `capture:error`, origin.tool, and text
20
+ // "Tool X invoked: … → error: <summary>". Normalize the summary to an error CLASS so
21
+ // transient specifics (paths, ids, timestamps) don't fragment the signature.
22
+ function errorClass(summary) {
23
+ return summary
24
+ .toLowerCase()
25
+ .replace(/0x[0-9a-f]+|\b[0-9a-f]{8,}\b/g, '#') // hashes / hex ids
26
+ .replace(/\b\d+\b/g, '#') // numbers
27
+ .replace(/[\/\\][^\s'"]+/g, '/path') // file paths
28
+ .replace(/\s+/g, ' ')
29
+ .trim()
30
+ .slice(0, 80);
31
+ }
32
+ function toolOf(e) {
33
+ return e.provenance.origin?.kind === 'tool_call' ? e.provenance.origin.tool : 'unknown';
34
+ }
35
+ // The bounded error summary as captured after the "→ error:" marker.
36
+ function summaryOf(e) {
37
+ const m = e.text.match(/→ error:\s*(.*)$/);
38
+ return (m ? m[1] : e.text).trim();
39
+ }
40
+ function signature(tool, summary) {
41
+ const basis = `${tool}::${errorClass(summary)}`;
42
+ return SIG_PREFIX + createHash('sha256').update(basis).digest('hex').slice(0, 12);
43
+ }
44
+ // Select the captured ERROR facts this distiller acts on. `capturedIds` (a session's
45
+ // signals) restricts the set; absent it, all `capture:error`-tagged live facts. We read
46
+ // from the brain, so already-skipped self-tool calls never appear (the 31f4266 skip
47
+ // holds end-to-end); a defensive guard re-asserts it.
48
+ function errorCaptures(capturedIds, all = readAll()) {
49
+ const idSet = capturedIds && capturedIds.length ? new Set(capturedIds) : null;
50
+ return all.filter((e) => e.type === 'fact' &&
51
+ e.tags.includes('capture:error') &&
52
+ e.provenance.origin?.kind === 'tool_call' &&
53
+ !/^kb_|vfkb/i.test(toolOf(e)) &&
54
+ (!idSet || idSet.has(e.id)));
55
+ }
56
+ // PROPOSE (does not write): the candidate gotchas implied by the error captures, one per
57
+ // distinct signature. Deterministic: same captures → same candidates (stable sigs).
58
+ export function distillCandidates(capturedIds, all = readAll()) {
59
+ const bySig = new Map();
60
+ for (const e of errorCaptures(capturedIds, all)) {
61
+ const tool = toolOf(e);
62
+ const summary = summaryOf(e);
63
+ const sig = signature(tool, summary);
64
+ const cur = bySig.get(sig);
65
+ if (cur) {
66
+ cur.sourceIds.push(e.id);
67
+ continue;
68
+ }
69
+ bySig.set(sig, {
70
+ sig,
71
+ tool,
72
+ // Trust lives in the GLYPH/provenance (provStatus below), NOT baked into the immutable
73
+ // text — so when corroborated promotion re-stamps the entry verified (D-iii/ADR-0024) the
74
+ // text doesn't contradict the ✓. (Text-Brake-safe: only new distills; never rewrites existing.)
75
+ text: `Tool ${tool} can fail: ${summary} — auto-distilled from a captured failure`,
76
+ sourceIds: [e.id],
77
+ origin: { kind: 'tool_call', tool },
78
+ });
79
+ }
80
+ return [...bySig.values()];
81
+ }
82
+ // WRITE side. For each candidate signature: if no live (non-archive) distilled gotcha
83
+ // with that signature exists, create one in incoming/unverified/agent-trust
84
+ // (CONTAINMENT — explicit, not relying on defaults). If one already exists, this is a
85
+ // recurrence → record a corroborating counter signal, never a duplicate.
86
+ export function distill(capturedIds) {
87
+ const all = readAll();
88
+ const existingBySig = new Map();
89
+ for (const e of all) {
90
+ if (e.zone === 'archive')
91
+ continue;
92
+ const sigTag = e.tags.find((t) => t.startsWith(SIG_PREFIX));
93
+ if (sigTag)
94
+ existingBySig.set(sigTag, e);
95
+ }
96
+ const created = [];
97
+ const corroborated = [];
98
+ for (const c of distillCandidates(capturedIds, all)) {
99
+ const existing = existingBySig.get(c.sig);
100
+ if (existing) {
101
+ recordSignal(existing.id, 'helpful', 'distill:recurrence');
102
+ corroborated.push(existing.id);
103
+ continue;
104
+ }
105
+ const entry = addEntry('gotcha', c.text, {
106
+ role: 'executor', // agent-trust (deriveTrust → 'agent')
107
+ zone: 'incoming', // CONTAINMENT — never the trusted set
108
+ provStatus: 'unverified', // CONTAINMENT — never verified by machine extraction
109
+ tags: [DISTILLED_TAG, c.sig, `tool:${c.tool}`],
110
+ origin: c.origin,
111
+ });
112
+ created.push(entry);
113
+ existingBySig.set(c.sig, entry); // a repeated sig within one pass corroborates, not duplicates
114
+ }
115
+ return { created, corroborated };
116
+ }