agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
408 lines • 22 kB
JavaScript
/**
* skillMatch — the DATA-matcher domain for skill-graph start rules (SG-A).
*
* One module owns everything a matcher is and does, so the three consumers can
* never drift apart:
*
* • what a matcher IS — {@link SkillMatch} (author form) and
* {@link SkillMatchData} (serializable description);
* • how it RUNS — {@link compileMatch}: ONE compilation returns the
* predicate that routes AND the data that describes it;
* • how two matchers RELATE — {@link compareMatchers}: what the check-up can
* honestly prove (`overlapping-rules` /
* `rules-shadowed-by-order`);
* • how it DRAWS — {@link mermaidMatchCaption}: the entry-edge caption
* `toMermaid()` uses when the author gave no label.
*
* Deliberately engine-type-free (the predicate context is structural —
* `{ userMessage }` — which the engine's `InjectionContext` satisfies), so
* `skillGraphCheckup.ts` can import it and keep its own "pure over strings"
* law intact. Imported by `skillGraph.ts` (compile + caption) and
* `skillGraphCheckup.ts` (data type + compare); imports nothing.
*/
/** The witness bound — 80 characters, ellipsis included. A record is evidence,
* not a transcript. */
export const WITNESS_MAX_CHARS = 80;
/** Normalize + bound one matched substring into witness text. Returns undefined
* when nothing quotable is left (a zero-length or whitespace-only match, e.g.
* `/^/` — "the message said ''" would be worse than saying nothing). */
function toWitnessText(matched) {
const collapsed = matched.replace(/\s+/g, ' ').trim();
if (collapsed.length === 0)
return undefined;
return collapsed.length > WITNESS_MAX_CHARS
? `${collapsed.slice(0, WITNESS_MAX_CHARS - 1)}…`
: collapsed;
}
/** Escape a literal for splicing into a RegExp source (local twin of the one in
* skillContract.ts — both are module-private one-liners over the same idiom). */
function escapeRegExpLiteral(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
/** Whole-word where sensible: anchor `\b` only at a keyword edge that is an ASCII
* word character, so `refund` does not match "refunds" while `visa card` matches
* as a phrase and a punctuation-edged keyword (`v2?`) matches literally wherever
* it appears. Case-insensitive. */
function keywordRegExp(keyword) {
const left = /^[A-Za-z0-9_]/.test(keyword) ? '\\b' : '';
const right = /[A-Za-z0-9_]$/.test(keyword) ? '\\b' : '';
return new RegExp(`${left}${escapeRegExpLiteral(keyword)}${right}`, 'i');
}
/**
* Compile a data matcher into its predicate + its serializable description — ONE
* compilation, so the predicate that routes and the data the check-up compares can
* never describe different matchers. Refuses a shape it cannot honor (a bare
* string, an empty keyword list, an intent without examples), naming the forms
* that ARE supported — accepting one and matching nothing would be the silent kind
* of wrong.
*
* The INTENT arm compiles to `predicate: undefined` — it cannot answer
* synchronously (a classifier judges it in the turn-start cascade), so the entry
* carrying it is cursor-gated instead of rule-gated. `skillGraph.ts` is the only
* caller that accepts a predicate-less compile, and it refuses `match: { intent }`
* when no classifier is configured.
*
* The third member of the returned triple is `witness` (9.28.0): the SAME
* compilation's evidence extractor — given a context the predicate accepted, it
* returns {@link RouteWitness} (the matched text, bounded). It is a separate
* function on purpose, so the hot path stays exactly what it was (a boolean
* `.test`) and the evidence is paid for ONCE per turn, on the rule that actually
* won. The intent arm has no witness (nothing matched synchronously).
*/
export function compileMatch(match, where) {
if (match instanceof RegExp) {
// `.test` on a /g or /y regex advances `lastIndex`, so the SAME message would
// alternate match/no-match across iterations. A matcher is a pure yes/no —
// the stateful flags are dropped, and the stored provenance carries the flags
// that actually run (never flags that silently do not).
const re = new RegExp(match.source, match.flags.replace(/[gy]/g, ''));
return {
predicate: (ctx) => re.test(ctx.userMessage),
data: { kind: 'regex', source: re.source, flags: re.flags },
// The evidence is the substring the regex actually matched (`m[0]`) —
// re-run only on the winning rule, on a regex with no stateful flags, so
// this answer is the same one `.test` gave.
witness: (ctx) => {
const m = re.exec(ctx.userMessage);
if (m === null)
return undefined;
const text = toWitnessText(m[0]);
return text === undefined ? undefined : { text };
},
};
}
// A JS caller can pass anything here (null, a string, a bare function) — every
// unhonorable shape gets the same teaching refusal, never a raw TypeError.
const shaped = match !== null && typeof match === 'object' ? match : undefined;
const all = shaped?.all;
if (all !== undefined)
return compileAllArm(all, where);
const intent = shaped?.intent;
if (intent !== undefined) {
// The INTENT arm — data for the turn-start classifier, no sync predicate.
// Both halves required and non-empty: an intent with no examples gives a
// keyword/embedding classifier nothing a user ever typed to match on, and
// an example list under a blank intent is a menu row with no name.
const examples = shaped.examples;
const intentUsable = typeof intent === 'string' && intent.trim().length > 0;
const examplesUsable = Array.isArray(examples) &&
examples.length > 0 &&
examples.every((e) => typeof e === 'string' && e.trim().length > 0);
if (!intentUsable || !examplesUsable) {
throw new Error(`skillGraph: ${where} declares \`match: { intent }\` this library cannot honor. An ` +
`intent matcher is { intent: 'one sentence naming the intent', examples: ['a real ` +
`user phrasing', …] } — both required, both non-empty (examples are what the ` +
`classifier actually matches against). Fix the ${!intentUsable ? '`intent` sentence' : '`examples` list'}, or use a RegExp / { keywords } / \`when\` instead.`);
}
return {
predicate: undefined,
data: { kind: 'intent', intent: intent.trim(), examples: examples.slice() },
};
}
const keywords = shaped?.keywords;
const usable = Array.isArray(keywords) &&
keywords.length > 0 &&
keywords.every((k) => typeof k === 'string' && k.trim().length > 0);
if (!usable) {
throw new Error(`skillGraph: ${where} has a \`match\` this library cannot honor. Supported matchers: ` +
`a RegExp (tested against the user message), { keywords: ['refund', …] } — a ` +
`non-empty array of non-empty strings (case-insensitive; any keyword present ` +
`matches, whole-word at word-character edges) — { all: [...] } (a conjunction: ` +
`EVERY listed RegExp / { keywords } member must match), or { intent: '…', ` +
`examples: [...] } (judged by the graph's classifier at turn start; needs ` +
`\`classify\`). For any other condition, use \`when: (ctx) => …\`.`);
}
const kws = keywords.slice();
const tests = kws.map(keywordRegExp);
return {
predicate: (ctx) => tests.some((re) => re.test(ctx.userMessage)),
data: { kind: 'keywords', keywords: kws },
// Matching is ANY-keyword in declaration order, so the witness names the
// FIRST keyword that hit — the one the predicate stopped on — plus the text
// it hit (they differ in case, and a phrase carries the message's spacing).
witness: (ctx) => {
for (let i = 0; i < tests.length; i++) {
const m = tests[i].exec(ctx.userMessage);
if (m === null)
continue;
const text = toWitnessText(m[0]);
return text === undefined ? undefined : { text, keyword: kws[i] };
}
return undefined;
},
};
}
/**
* The conjunction arm of {@link compileMatch} (9.20.0) — `{ all: [...] }`
* compiles to ONE predicate ANDing every member with ONE flat data record
* beside it, in the same single compilation.
*
* Three refusals, all teaching, all at build time:
* • an EMPTY `all` — nothing to check, so it could only match everything or
* nothing, both the silent kind of wrong;
* • an INTENT member — an intent is judged by the graph's classifier at turn
* start; it has no synchronous answer to AND with. The honest spelling is
* a SEPARATE `match: { intent }` rule (rules already compose by order);
* • any other shape `all` cannot hold, naming the member forms that work.
*
* A NESTED `all` is FLATTENED rather than refused: AND is associative, so
* `{ all: [a, { all: [b, c] }] }` runs identically to `{ all: [a, b, c] }`,
* and the stored data describes the form that actually runs — the same
* normalize-to-truth rule that drops a regex's stateful `g`/`y` flags. Every
* consumer therefore sees a flat list of leaf parts.
*/
function compileAllArm(all, where) {
if (!Array.isArray(all) || all.length === 0) {
throw new Error(`skillGraph: ${where} declares \`match: { all }\` this library cannot honor — \`all\` ` +
`must be a NON-EMPTY array of matchers that must every one match, e.g. ` +
`{ all: [/zone/i, { keywords: ['audit', 'sweep'] }] }. An empty \`all\` has nothing ` +
`to check, so it could only match everything or nothing — both silent surprises. ` +
`List at least one matcher, or drop the rule.`);
}
const predicates = [];
const witnesses = [];
const parts = [];
all.forEach((member, i) => {
const label = `${where}, \`all\` member ${i + 1}`;
const memberShaped = member !== null && typeof member === 'object' && !(member instanceof RegExp)
? member
: undefined;
if (memberShaped?.intent !== undefined) {
throw new Error(`skillGraph: ${label} is an intent matcher, which \`all\` cannot hold: an intent is ` +
`judged by the graph's classifier at turn start, so it has no synchronous answer ` +
`to AND with a RegExp or keyword test. Declare the intent as its OWN rule — ` +
`\`{ match: { intent, examples }, use: … }\` beside this one — and keep \`all\` ` +
`to the sync arms (RegExp / { keywords }).`);
}
if (memberShaped?.all !== undefined) {
const nested = compileAllArm(memberShaped.all, label);
predicates.push(nested.predicate);
witnesses.push(nested.witness);
parts.push(...nested.data.parts);
return;
}
const memberKeywords = memberShaped?.keywords;
const usableMember = member instanceof RegExp ||
(Array.isArray(memberKeywords) &&
memberKeywords.length > 0 &&
memberKeywords.every((k) => typeof k === 'string' && k.trim().length > 0));
if (!usableMember) {
throw new Error(`skillGraph: ${label} is not a matcher \`all\` can hold. Every member must answer ` +
`synchronously from the user message: a RegExp, { keywords: ['…', …] } (a non-empty ` +
`array of non-empty strings), or a nested { all: [...] } (flattened — AND is ` +
`associative). For an intent condition declare a separate intent rule; for any ` +
`other logic use \`when: (ctx) => …\` on the rule itself.`);
}
const compiled = compileMatch(member, label);
// Leaf arms always compile a predicate (only the intent arm does not, and
// it was refused above).
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
predicates.push(compiled.predicate);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
witnesses.push(compiled.witness);
parts.push(compiled.data);
});
return {
predicate: (ctx) => predicates.every((p) => p(ctx)),
data: { kind: 'all', parts },
// A conjunction matched because EVERY part matched, so any part's text is
// true evidence. The FIRST part that yields quotable text is quoted — the
// author's leading constraint, deterministically — rather than a stitched
// sentence no reader could find in their message.
witness: (ctx) => {
for (const w of witnesses) {
const found = w(ctx);
if (found !== undefined)
return found;
}
return undefined;
},
};
}
/**
* Compare two DATA matchers, earlier vs later (declaration order). Returns only
* what is PROVABLE:
* • `'shadows'` — every message the later matches, the earlier matches too, so
* the later can never be chosen (identical regex; earlier keywords ⊇ later's);
* • `'overlaps'` — a witness class of messages matches both (a shared keyword),
* but each rule also has messages of its own;
* • `undefined` — nothing provable here (different regex sources, a regex vs a
* keyword list): say nothing rather than guess.
* `why` is the human clause the check-up splices into its problem message.
*
* The `'all'` arm (9.20.0) keeps the same law — claim only what part-coverage
* proves. An `all` rule matches a SUBSET of each of its parts' messages, so:
* • a plain rule EARLIER that provably covers ONE part of a later `all` rule
* shadows it (every message the `all` matches satisfies that part, which
* the earlier already matches);
* • an `all` rule EARLIER shadows a later matcher when EVERY earlier part is
* provably covered by some later part — the later only ADDS constraints
* (identical part sets are the simplest case).
* "Provably covers" is same-kind only: an identical regex, or a keyword
* superset. No `'overlaps'` is ever claimed for a pair involving `all`: a
* conjunction can be unsatisfiable (`all: [/^a/, /^b/]` matches nothing), so a
* witness message cannot be asserted from the data — and the ordinary
* specific-rule-first layout (the `all` rule above its plain fallback) is a
* DESIGN, not a problem to warn about.
*/
export function compareMatchers(earlier, later) {
if (earlier.kind === 'all' || later.kind === 'all')
return compareWithAll(earlier, later);
if (earlier.kind === 'regex' && later.kind === 'regex') {
if (earlier.source === later.source && earlier.flags === later.flags) {
return {
relation: 'shadows',
why: `uses the identical regex /${earlier.source}/${earlier.flags}`,
};
}
return undefined; // regex intersection is not decided here — only identity is provable
}
if (earlier.kind === 'keywords' && later.kind === 'keywords') {
const a = new Set(earlier.keywords.map((k) => k.toLowerCase()));
const bList = later.keywords.map((k) => k.toLowerCase());
const shared = [...new Set(bList.filter((k) => a.has(k)))];
if (shared.length === 0)
return undefined;
// Matching is ANY-keyword, so "earlier ⊇ later" means every message the later
// rule matches (it contains one of later's keywords) also matches the earlier.
if (bList.every((k) => a.has(k))) {
return {
relation: 'shadows',
why: `already matches every keyword of the later rule (${quoteList(shared)})`,
};
}
return {
relation: 'overlaps',
why: `both match the shared keyword${shared.length === 1 ? '' : 's'} ${quoteList(shared)}`,
};
}
// Unlike kinds — and intent-vs-intent — are not comparable without guessing:
// two example SETS are not claimed comparable without running the configured
// scorer, which is `checkupIntents()`'s job (leave-one-out, async). The one
// provable intent-pair fact — the same example string under two intents — is
// its own check (`duplicate-intent-example`, skillIntent.ts).
return undefined;
}
/**
* Provable coverage between two LEAF matchers: `true` means every message `q`
* matches, `p` provably matches too (matches(q) ⊆ matches(p)). Same-kind only,
* mirroring `compareMatchers`' own claims: identical regex (source + runtime
* flags), or `p`'s keyword set ⊇ `q`'s (any-keyword matching, so a message
* carrying one of `q`'s keywords carries one of `p`'s). Everything else —
* different regexes, regex vs keywords — is NOT decided: answer `false` and
* let the caller stay silent.
*/
function partCoversProvably(p, q) {
if (p.kind === 'regex' && q.kind === 'regex') {
return p.source === q.source && p.flags === q.flags;
}
if (p.kind === 'keywords' && q.kind === 'keywords') {
const pSet = new Set(p.keywords.map((k) => k.toLowerCase()));
return q.keywords.every((k) => pSet.has(k.toLowerCase()));
}
return false;
}
/** Human name for one leaf part, for the `why` clauses (prose, not mermaid —
* no entity escaping). */
function describePart(p) {
if (p.kind === 'regex')
return `/${p.source}/${p.flags}`;
if (p.kind === 'keywords')
return quoteList(p.keywords);
return `(${p.kind})`; // unreachable for compiled parts; honest if hand-built data arrives
}
/**
* The `compareMatchers` arm for pairs involving an `'all'` conjunction —
* shadows-only, subset logic (see the honesty notes on `compareMatchers`).
*
* A matcher is treated as its constraint list: a leaf is the one-element list,
* an `all` is its parts. `matches(later) ⊆ matches(earlier)` is PROVEN when
* every earlier constraint is covered by some later constraint (the later
* satisfies everything the earlier requires, so it can only be narrower).
* Intent matchers never participate (no sync match set to reason about).
*/
function compareWithAll(earlier, later) {
if (earlier.kind === 'intent' || later.kind === 'intent')
return undefined;
const earlierParts = earlier.kind === 'all' ? earlier.parts : [earlier];
const laterParts = later.kind === 'all' ? later.parts : [later];
const covered = earlierParts.map((e) => laterParts.find((l) => partCoversProvably(e, l)));
if (covered.some((c) => c === undefined))
return undefined;
if (earlier.kind !== 'all') {
// Plain earlier, `all` later: the later requires a part the earlier already
// matches, and an `all` rule only matches messages where EVERY part holds.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const witness = covered[0];
return {
relation: 'shadows',
why: `already matches the later rule's required \`all\` part ${describePart(witness)} — a ` +
`conjunction only matches messages where EVERY part matches, so its matches are a ` +
`subset of the earlier rule's`,
};
}
return {
relation: 'shadows',
why: `requires only what the later rule also requires (every earlier \`all\` part — ` +
`${earlierParts.map(describePart).join('; ')} — is covered by a later part), so the ` +
`later rule can only ADD constraints, never escape one`,
};
}
/**
* Caption an entry edge with its data matcher, escaped for a mermaid `|…|` label
* (`|` would end the label early; `"` would open a string — both become mermaid
* entities). Keywords list at most three, then a count, so a wide router stays
* readable; an `all` conjunction joins its parts with ` AND ` (parts are always
* leaves — compilation flattened any nesting). Used only when the author gave no
* explicit `label` — an explicit label always wins, byte-for-byte, exactly as
* before.
*/
export function mermaidMatchCaption(m) {
return plainMatchCaption(m).replace(/\|/g, '#124;').replace(/"/g, '#quot;');
}
/**
* The UNESCAPED caption — one grammar for naming a matcher, shared by the
* mermaid label (which escapes it once, at the top) and by prose that quotes a
* matcher back to its author (`skillExamples.ts`'s messages). Recursion for the
* `all` arm happens here so the escaping stays a single outer pass.
*/
export function plainMatchCaption(m) {
return m.kind === 'all'
? m.parts.map(plainMatchCaption).join(' AND ')
: m.kind === 'regex'
? `/${m.source}/${m.flags}`
: m.kind === 'intent'
? `intent: ${m.intent.length > 40 ? `${m.intent.slice(0, 40)}…` : m.intent}`
: m.keywords.length > 3
? `${m.keywords.slice(0, 3).join(', ')}, +${m.keywords.length - 3} more`
: m.keywords.join(', ');
}
/** `"a"`, `"a" and "b"`, `"a", "b" and "c"` — local twin of the check-up's own
* list formatter (module-private in both homes, same three lines). */
function quoteList(ids) {
const quoted = ids.map((id) => `"${id}"`);
if (quoted.length <= 1)
return quoted[0] ?? '';
return `${quoted.slice(0, -1).join(', ')} and ${quoted[quoted.length - 1]}`;
}
//# sourceMappingURL=skillMatch.js.map