@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.
- package/LICENSE +21 -0
- package/README.md +290 -0
- package/dist/args.js +82 -0
- package/dist/backend.js +115 -0
- package/dist/bootstrap.test.js +64 -0
- package/dist/bundles/vfkb-mcp.mjs +32139 -0
- package/dist/bundles/vfkb.mjs +17583 -0
- package/dist/bundles.test.js +177 -0
- package/dist/cli.js +635 -0
- package/dist/counters.js +61 -0
- package/dist/curator.js +87 -0
- package/dist/distiller.js +116 -0
- package/dist/doctor.js +479 -0
- package/dist/doctor.test.js +179 -0
- package/dist/engine.js +668 -0
- package/dist/env-compat.test.js +23 -0
- package/dist/export.js +371 -0
- package/dist/gating.js +34 -0
- package/dist/git.js +43 -0
- package/dist/import.js +99 -0
- package/dist/import.test.js +54 -0
- package/dist/index-store.js +101 -0
- package/dist/init.js +264 -0
- package/dist/init.test.js +148 -0
- package/dist/lock.js +158 -0
- package/dist/manifest.js +38 -0
- package/dist/mcp-server.js +213 -0
- package/dist/pi-extension.js +117 -0
- package/dist/pi-mcp-bridge.js +94 -0
- package/dist/pi-types.js +6 -0
- package/dist/read.js +112 -0
- package/dist/secrets.js +36 -0
- package/dist/session-end.js +183 -0
- package/dist/session-end.test.js +149 -0
- package/dist/session.js +157 -0
- package/dist/stop-reminder.js +130 -0
- package/dist/storage.js +145 -0
- package/dist/types.js +6 -0
- package/dist/validate.js +83 -0
- package/dist/version.js +32 -0
- package/package.json +51 -0
package/dist/counters.js
ADDED
|
@@ -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
|
+
}
|
package/dist/curator.js
ADDED
|
@@ -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
|
+
}
|