Skip to content

Lessons graph reference

.agentsmesh/lessons/lessons.json is the single source of truth for the recall/capture subsystem. This page documents its schema, the validation codes returned by agentsmesh lessons validate, and the programmatic API for consumers building on top.

For the CLI surface, see agentsmesh lessons.

Graph shape

The graph has four top-level fields:

{
"version": 1,
"lessons": { "<lesson-id>": { rule, topics, triggers, evidence, status, createdAt, } },
"topics": { "<topic-id>": { summary } },
"triggers": { "<trigger-id>": { kind, pattern } }
}

All ids are kebab-case (^[a-z0-9-]+$). The file is deterministically sorted (alphabetical keys at every depth) and always ends with a single trailing newline, so diffs reflect content changes rather than insertion order.

Lesson

FieldTypeRequiredDescription
rulestringyesImperative rule text. Non-empty.
rationalestringnoOne-line “why” behind the rule.
topicsstring[]yesAt least one topic id. Every entry must resolve in topics.
triggersstring[]yesTrigger ids. Each must resolve in triggers. May be empty (the lesson is then never recalled).
evidencestring[]yesFree-form references (commit:SHA, legacy:.agentsmesh/lessons/topics/foo.md#rule-3, lesson:other-id).
status"active" | "deprecated" | "superseded"yesRecall only returns active lessons.
supersededBystringconditionalRequired when status is superseded; forbidden when status is active.
createdAtISO date or datetimeyesWhen the lesson was first added. Determines journal ordering.

Topic

FieldTypeRequiredDescription
summarystringyesOne-line description. Non-empty.

Trigger

FieldTypeRequiredDescription
kind"file_glob" | "command_pattern" | "keyword"yesTrigger kind.
patternstringyesPattern body. Non-empty.

file_glob patterns use the picomatch syntax. command_pattern patterns are a linear-engine subset of JavaScript regex (no flags) — see Recall semantics for the exact supported/unsupported constructs. keyword patterns are case-insensitive substrings of --keyword, and also match against --file/--cmd on token boundaries (see Recall semantics).

Triggers are content-addressed: the migrator and agentsmesh lessons add both dedupe by (kind, pattern), so two lessons targeting the same glob share one trigger node.

Recall semantics

In plain terms: a lesson comes back from recall when any of its triggers matches the file, command, or keyword you passed. The rest of this section specifies exactly how each trigger kind matches — read it when you need the precise contract; to get started, see the lessons guide instead.

agentsmesh lessons query returns every active lesson whose triggers match any supplied field:

  • --file <p> matches file_glob triggers via picomatch with dot: true.

  • --cmd <c> matches command_pattern triggers. Mandatory recall executes these on every command, and a backtracking RegExp cannot be proven linear by inspection (even a+b is quadratic on a long non-matching input). So command patterns are matched by an in-repo non-backtracking engine (a Thompson NFA): matching is linear in the input length for any pattern it can compile, so no pattern — (a+)+, a+a+, or a 30k-char adversarial command — can hang recall.

    Supported subset (semantics match new RegExp(pattern) without any flags): literals; escapes \d \D \w \W \s \S, \t \n \r \f \v \0, \xHH, \uHHHH, \cX; . (matches any char except the line terminators \n, \r, U+2028, U+2029 — no dotAll); character classes (ranges, negation, \b = backspace inside a class); anchors ^ $ (end/start of input — no m/multiline, so $ does not match before a final newline) and \b \B; groups ( ) (?: ) (?<name> ) (no capture is needed for matching); alternation |; and quantifiers * + ? {n} {n,} {n,m} (lazy variants accepted, same match result). Not supported (and rejected at capture as UNSAFE_TRIGGER_PATTERN, skipped at read time): backreferences, lookaround, \u{…} (a u-flag-only form), and patterns that expand to too large an NFA (e.g. a{1000} ×10 — match cost is O(states × input), not O(pattern length)). Invalid syntax is INVALID_TRIGGER_PATTERN.

    Matching is iterative (no recursion → no stack overflow on long ε-chains), and a query-wide work budget bounds the total matching work across all triggers in a recall (not just per pattern), with no input truncation — so neither a deep pattern, many near-cap patterns, nor a huge command can exhaust the stack or blow the time budget, and a budget-exhausted match degrades to a safe non-match (never a false positive). See isSafeRegexPattern in the programmatic API.

  • keyword triggers match in two ways: (1) the explicit --keyword <t> via keyword.toLowerCase().includes(pattern.toLowerCase()) (substring), and (2) the --file path and --cmd command — the pattern’s tokens must appear as a contiguous run in the path/command tokens (lowercase, split on non-alphanumerics; the pattern drops single-character tokens and stopwords). This lets a keyword-only (conceptual) lesson surface on mandatory --file/--cmd recall without a hand-crafted --keyword, while token-boundary matching keeps cat from firing on category.ts. Matching is on whole tokens, so joined identifiers (readOnly, readonly) are not split.

Combination is OR across triggers per lesson: a lesson is a candidate if any of its triggers fire from any supplied field. Candidates are then relevance-ranked by a weighted reciprocal-rank fusion (RRF) of three signals — trigger specificity (inverse fanout; highest weight, because a discriminating trigger beats a topic-wide one), per-query topic coherence (a lesson in the topic that dominates this query’s matched set is boosted; middle weight), and BM25 over the rule text (lowest weight; on --file/--cmd recall the rule prose rarely contains a path, so it only breaks ties the structural signals cannot) — with ties broken by recency then id. The CLI/MCP return the top 10 by default, bounded by a default token budget (DEFAULT_RECALL_MAX_TOKENS, 400) so mandatory recall stays lean. Override with --top <n> / --max-tokens <n> (MCP limit/max_tokens); --all bypasses both caps. The single most-relevant result is always returned even if it alone exceeds the budget. The low-level queryLessons export returns the raw unranked candidate set; rankLessons applies the ranking + caps; the migration-aware recallLessons application API wraps load → query → rank (and migrates a legacy store first).

Recall tuning

Per-project tuning lives in .agentsmesh/lessons/config.json, which agentsmesh init --lessons writes with every field at its default so the tunables are discoverable in one place (writing the defaults out is behaviour-neutral — a missing field already falls back to the same value):

{ "recallLimit": 10, "recallMaxTokens": 400, "autoPrune": false }

recallLimit / recallMaxTokens are the canonical names for the two recall caps; the per-call --top / --max-tokens flags are their invocation-time overrides for the same limits (and --all disables both). Both fields are optional and independently fall back to the built-ins (DEFAULT_RECALL_LIMIT = 10, DEFAULT_RECALL_MAX_TOKENS = 400). recallMaxTokens is approximate — per-rule cost is estimated as rule.length / 4, not a real tokenizer, so treat the budget as a soft bound. Lowering them keeps mandatory --file/--cmd recall lean on a large, high-fanout graph where recall otherwise returns many lessons per call (see the stats break-even / match-count histogram). Reading is fail-safe: a missing or malformed file, or an invalid field (non-positive, non-integer), silently uses the default for that field — recall, a blocking hot path, never throws on config.

autoPrune (boolean, default false) opts into automatic graph hygiene: after every successful capture, the GC-only half of prune runs — orphan triggers/topics are removed and non-stranding dead file_glob triggers are detached, reusing the working-tree walk the capture already did. It never trims a within-cap lesson, drops an active lesson, or strands one (a lesson keeps ≥ 1 trigger), and every change is git-reversible. The capture reports what it cleaned (auto-pruned: N orphan triggers, M orphan topics, K dead globs detached). Over-cap trigger trimming stays exclusive to the manual lessons prune --apply, which remains the deliberate, reviewed curation path.

Validation codes

agentsmesh lessons validate returns a list of findings with these codes:

CodeLevelMeaning
SCHEMA_INVALIDerrorThe graph fails the Zod schema. Other checks are skipped.
DANGLING_TOPICerrorA lesson references an unknown topic id.
DANGLING_TRIGGERerrorA lesson references an unknown trigger id.
DANGLING_SUPERSEDERerrorsupersededBy points to an unknown lesson id.
DUPLICATE_RULEerrorTwo or more lessons share the same normalized rule text (whitespace-collapsed, lowercased).
SUPERSEDED_WITHOUT_TARGETerrorstatus: superseded without a supersededBy field.
ACTIVE_WITH_SUPERSEDERerrorstatus: active with a supersededBy field.
SELF_SUPERSEDEDerrorA lesson’s supersededBy points at itself.
SUPERSEDE_CYCLEerrorsupersededBy links form a cycle.
INACTIVE_SUPERSEDERerrorsupersededBy points at a non-active lesson — the chain dead-ends with no live replacement.
INVALID_TRIGGER_PATTERNerrorA command_pattern trigger is not a valid regex (would be silently unreachable).
UNSAFE_TRIGGER_PATTERNerrorA command_pattern the linear matcher cannot evaluate — a backreference, lookaround, \u{…}, or a pattern that expands to too large an NFA (e.g. a{1000} ×10). Backtracking-prone shapes like (a+)+ are NOT flagged: the engine runs them in linear time. Rejected at capture, skipped at read time.
DUPLICATE_TRIGGERerrorTwo or more trigger ids share the same (kind, pattern). Trigger ids are content-addressed, so add cannot create this — it only arises from a low-level mutation or hand-edit, and it would distort dedup, fanout, and ranking specificity.
DUPLICATE_TOPIC_REFerrorA lesson references the same topic id more than once. add unions (dedups); a repeat only arises from a raw mutation and skews accounting.
DUPLICATE_TRIGGER_REFerrorA lesson references the same trigger id more than once — double-counts in fanout and skews ranking specificity.
UNREACHABLE_LESSONwarningAn active lesson has zero triggers and can never be recalled.
HIGH_FANOUT_TRIGGERSwarningOne summary finding: N triggers each match more than 10 active lessons (recall leans on ranking).
ORPHAN_TOPICwarningA topic node is not referenced by any lesson.
ORPHAN_TRIGGERwarningA trigger node is not referenced by any lesson.
LOW_SIGNAL_KEYWORDwarningA keyword trigger on an active lesson carries more than 5 tokens. Recall matches a keyword only as a substring of --keyword or a contiguous token-run in the file/command, so a long descriptive pattern almost never fires. Use a short distinctive phrase (fix in place with untrigger + re-add).
DEAD_FILE_GLOBwarningA file_glob trigger on an active lesson matches no file in the working tree — almost always because a refactor renamed the path it pointed at, so the lesson is silently unreachable via that trigger. This is a liveness check, not a breadth one: a narrow glob matching even one file is fine; only a glob matching zero is flagged. Re-point it at the current path, or detach it with untrigger. Requires a working tree to check, so it runs in validate/lint (which know the project root) but never in the add write barrier.
RUNNER_ANCHORED_PATTERNwarningA command_pattern on an active lesson is anchored to a single package runner (e.g. ^pnpm test, ^npx vitest). It won’t fire for the same task run another way — an agent that types npx vitest gets nothing from a ^pnpm lesson. A scope-match gap, not a breadth one: drop the ^<runner> anchor and key on the task verb (e.g. \bvitest\b).
STOPWORD_KEYWORDwarningA keyword trigger on an active lesson has a needle that loses all tokens to stopword filtering (e.g. “state of the art” collapses to tokens [state, art] which cannot match the contiguous run “state of the art”). The trigger cannot fire on the mandatory --file/--cmd recall path. Drop the stopwords or replace the trigger. Curation counterpart to the capture-time STOPWORD_KEYWORD guardrail warning; does not fail validation.
CORRUPT_GRAPHerrorlessons.json could not be parsed at all (bad JSON — e.g. a botched merge resolution). Recall degrades to empty while this stands; the graph is git-tracked, so restore it from git or repair the JSON. Emitted by validate itself, since recall’s corrupt-graph warning routes you here.

Exit code is non-zero when any error-level finding exists. Warnings do not affect the exit code. DUPLICATE_RULE considers active lessons only, so superseding or deprecating one copy clears it — that is how merge repairs a duplicate.

Maintenance cadence

Triggers rot as the codebase moves under them — a rename silently turns a good lesson into a DEAD_FILE_GLOB. A light cadence keeps the graph reachable:

  • Every PR (CI): agentsmesh lint already runs the graph through validate, so dead globs, runner-anchored patterns, and integrity errors surface in the same gate that checks generated drift. Errors fail the build; the liveness/precision findings are warnings you triage.
  • Occasionally (e.g. monthly, or after a big refactor): run agentsmesh lessons validate and act on the warnings — re-point or untrigger a DEAD_FILE_GLOB, de-anchor a RUNNER_ANCHORED_PATTERN, shorten a LOW_SIGNAL_KEYWORD, then agentsmesh lessons prune --apply to GC orphan triggers/topics and trim over-capped lessons.
  • On a wrong/contradicted lesson: deprecate it (the recall that surfaces it is the signal). A deprecated lesson stops being recalled immediately.

Capture guardrails

agentsmesh lessons add (and the lessons_add MCP tool) returns non-blocking warnings on the resulting lesson — these steer authors toward a few specific triggers, because over-triggering is the main thing that erodes recall precision. Capture is not rejected for any of these warnings, since losing a lesson is worse than an over-broad one.

The one exception — UNRECALLABLE_LESSON (blocking, exit 2). Capture IS rejected when every trigger on the resulting lesson is dead on the mandatory --file/--cmd recall path. A trigger is dead when: (a) it is a keyword whose needle loses all tokens to stopword filtering (e.g. “state of the art” → tokens [state, art] cannot fire as a contiguous run — the phrase “cannot fire on the mandatory —file/—cmd recall path”); or (b) it is a command_pattern rejected by the linear write barrier as INVALID_TRIGGER_PATTERN or UNSAFE_TRIGGER_PATTERN. A lesson with a mix — one live file_glob and one dead keyword — is NOT rejected; the dead keyword surfaces as a non-blocking STOPWORD_KEYWORD warning. Note that a dead keyword can still match via an explicit --keyword substring call; the rejection applies only when the mandatory file/command path is the sole remaining route.

A second blocking case — OVERSIZED_RULE (exit 2). Capture is rejected when the rule text exceeds 2000 characters. A rule is one imperative sentence; a far longer one is a malformed capture (a pasted log or diff) that would bloat every recall surfacing it. Trim it, or split it into separate lessons. The recall hook also truncates any rule over that bound before injecting it into agent context, so a graph from an untrusted cloned repo cannot flood the context with one giant rule (see Trust model).

The following are non-blocking warnings. The CLI prints them to stderr (stdout stays paste-clean); MCP returns them in the warnings array.

CodeMeaning
OVERSIZED_LESSON_TRIGGERSThe lesson carries more than the recommended cap (8) of triggers — it fires on too many edits and dilutes recall.
BROAD_GLOB_TRIGGERA file_glob matches large swaths of the tree (a bare star/globstar, or a globstar with a wildcard basename). Prefer a path specific to the lesson.
KEYWORD_ONLY_LESSONEvery trigger is a keyword. Mandatory --file/--cmd recall surfaces these only when the keyword appears as a path/command token, so it fires less reliably — add a file_glob or command_pattern trigger for precise recall.
LOW_SIGNAL_KEYWORDA keyword trigger carries more than 5 tokens. Recall matches a keyword only as a substring of --keyword or a contiguous token-run in the file/command, so a long descriptive pattern almost never fires and the lesson silently falls back to its file_glob. Use a short distinctive phrase.
STOPWORD_KEYWORDA multi-word keyword pattern contains stopwords/short words (e.g. “state of the art”). Recall filters them from the pattern but not from the file/command text, so the phrase cannot fire as a contiguous run on the --file/--cmd path — the trigger cannot fire on the mandatory —file/—cmd recall path. Drop the stopwords (“state art”). When this is the ONLY trigger kind, the capture is rejected as UNRECALLABLE_LESSON (see above).
DEAD_GLOBA file_glob trigger on the captured lesson matches no file in the working tree — likely a rename or typo. Re-point it or the lesson will be unreachable via that glob. (Distinct from the DEAD_FILE_GLOB validation code, which is the curation counterpart emitted by validate.)
NEAR_DUPLICATE_LESSONThe new lesson closely paraphrases an existing active lesson (token-Jaccard ≥ 0.6 over rule text). Suggests updating lesson X instead of adding a paraphrase. Not emitted on an exact re-capture (which upserts) or on any upsert path.

Curation (prune)

agentsmesh lessons prune curates the graph in two safe, deterministic, git-reversible steps. It is dry-run by default (prints the plan, writes nothing); pass --apply to write through the transactional path, and --cap <n> to override the per-lesson trigger cap (default 8):

  1. Trim over-cap active lessons down to cap triggers, dropping the highest-fanout (least specific) triggers first — those are the topic-wide broadcasters that dilute recall — never below one trigger.
  2. Remove dead triggers — those no active lesson references (orphans, or referenced only by superseded/deprecated lessons) — from the table, stripping their now non-recall-bearing references everywhere so nothing dangles.

HIGH_FANOUT_TRIGGERS from validate tells you when a prune would help.

Recall telemetry (stats)

Recall runs before each edit and each state-changing command (pure-read commands and the recall query itself are exempt), so its frequency — not its per-call payload — is the dominant token cost. Telemetry is opt-in and off by default: set AGENTSMESH_LESSONS_TELEMETRY=1 and each recallLessons call appends one JSON row to .agentsmesh/lessons/recall-log.jsonl. Rows carry field-presence booleans (hasFile/hasCommand/hasKeyword) plus match counts, returned-token cost, per-trigger-kind provenance, the ids of the returned lessons, a bypassed flag for --all diagnostic dumps, and an optional AGENTSMESH_SESSION_ID correlator — never the raw file / command / keyword text. stats groups rows into sessions (by the session id, or a >30-minute idle gap when none is set) and reports the honest per-session break-even: preloading the active set costs wholeActiveSetTokens once per session, so the comparison multiplies preload by the session count and excludes --all dumps from the mandatory-recall side, rather than pitting a whole multi-session window of recalls against a single preload. It also reports the intra-session redundancy rate — the share of delivered rule-tokens that re-deliver a lesson already shown earlier in the same session (the dedup opportunity), with a coverage figure since rows predating the lesson-id field can’t be measured. The log is telemetry, not the canonical graph; the recall hot path computes and writes nothing when telemetry is disabled. It is also size-capped — once it grows past the cap it self-truncates to the most recent records on the next write, so it cannot accumulate unbounded even if tracked. agentsmesh init --lessons additionally adds both .agentsmesh/lessons/recall-log.jsonl and .agentsmesh/lessons/capture-log.jsonl to your project’s .gitignore, keeping runtime logs out of git entirely (git’s .gitignore, not the generation-level .agentsmesh/ignore).

agentsmesh lessons stats (programmatic: summarizeRecall(records, graph)) aggregates the log into: no-match rate, returned-token p50/p90/max, cumulative recall cost vs. the whole-active-set preload baseline (the break-even that answers “is per-action recall cheaper than loading every active rule once per session?”), and reachability — the share of recalls that fired only via keyword, and the count of active lessons whose every trigger is a keyword (invisible to mandatory --file/--cmd recall).

Capture telemetry

AGENTSMESH_LESSONS_TELEMETRY=1 also enables a symmetric capture log at .agentsmesh/lessons/capture-log.jsonl — the counterpart to recall-log.jsonl. Each captureLesson / agentsmesh lessons add call appends one row recording presence/count fields only (never rule text): isNewLesson, isNewTopic, newTriggerCount, triggerKinds ({file, command, keyword} counts), blocked (boolean — true when the capture was rejected as UNRECALLABLE_LESSON), warningCodes (array of non-blocking warning codes), and optional session and lessonId.

agentsmesh lessons stats prints a Capture block (included under a capture key in --json output) even when only a capture log exists and no recall log is present. The capture block reports: total captures, blocked count, new-lesson vs. upsert split, new-topics count, warned count, trigger-kind breakdown (file / command / keyword shares), and a recall:capture ratio (recalls per capture — a health signal for the capture discipline).

Trust model

lessons.json is committed to the repository, so its rules are project content — trusted at the same level as the source code, CLAUDE.md, and the rest of .agentsmesh/. The recall hook injects matching rule text into the agent’s context, which makes the graph an input that influences agent behavior.

The practical consequence is for cloned third-party repositories: a lesson graph you did not author is untrusted input, exactly like the code in that repo. Review it before relying on its rules, the same way you would read code before running it. agentsmesh does not sanitize rule text — a rule is free prose by design — but it bounds the blast radius:

  • Length cap. Capture rejects a rule over 2000 characters (OVERSIZED_RULE), and the recall hook truncates any rule over that bound before injecting it, so a malformed or hostile graph cannot flood the agent’s context with one giant rule.
  • Hook input bound. agentsmesh lessons hook caps the stdin payload it reads, so a runaway producer cannot exhaust memory.
  • No code execution. Recall matches command_pattern triggers with a non-backtracking linear engine (no native RegExp, so no ReDoS), and never executes a trigger or rule — it only matches and prints.
  • Telemetry stays local. The opt-in logs record presence/count fields only (never the file/command/rule text) and never leave the machine.

Concurrency

Every mutation routes through one transactional write path (mutateLessonsGraph): acquire the lock → load → mutate → validate → atomic temp-write + rename. This includes add, merge, deprecate, untrigger, strip-markers, prune, migration, and scaffolding — none write the graph directly. The lock at .agentsmesh/lessons/.lessons.lock (mkdir-based, cross-platform) reuses the same primitive as .generate.lock/.install.lock (stale-eviction by PID, signal cleanup, retry budget). Concurrent writers serialize cleanly, an invalid mutation is never persisted, and a crash mid-write cannot truncate the graph.

One-shot upgrade migration

Earlier releases stored lessons as .agentsmesh/lessons/index.yaml plus per-topic Markdown files plus an append-only journal.md. On upgrade, the migrator collapses all three into lessons.json and deletes the legacy files:

Terminal window
agentsmesh lessons import-md

It also runs lazily on the first lessons subcommand when lessons.json is absent and index.yaml exists. New projects skip this entirely.

Migrated lessons carry their legacy provenance in evidence[0] as legacy:.agentsmesh/lessons/topics/<topic>.md#rule-N, plus any (Evidence L<n>) references parsed verbatim from the original bullets (preserved as legacy:L<n>).

Programmatic API

The new graph types and helpers are exported from agentsmesh/lessons:

import {
// Blessed, migration-aware application APIs — prefer these.
recallLessons, // migrate → load → query → rank (+ default token budget)
captureLesson, // migrate → add (transactional)
// Write path — migrates a legacy store first (safe to use directly).
mutateLessonsGraph, // migrate → lock → load → mutate → validate → atomic save
addLesson,
mergeLessons,
// Low-level READ primitives — do NOT migrate; see note below.
loadLessonsGraph,
tryLoadLessonsGraph,
maybeAutoMigrateLessons, // run before a read primitive on a possibly-legacy project
queryLessons, // raw candidate matcher (no migration, no ranking)
rankLessons, // relevance ranking + caps over candidates
isSafeRegexPattern, // true if the linear engine can match the command_pattern
validateLessonsGraph,
importLegacyLessons,
DEFAULT_RECALL_MAX_TOKENS,
type LessonsGraph,
type Lesson,
type Topic,
type Trigger,
type LessonsQuery,
type ValidationReport,
} from 'agentsmesh/lessons';
// NOTE: raw saveLessonsGraph is intentionally not exported — it bypasses
// locking + validation. `mutateLessonsGraph` (and add/merge/deprecate built on
// it) MIGRATE a legacy index.yaml before writing, so a first write never strands
// it. Only the low-level READ primitives (tryLoadLessonsGraph/loadLessonsGraph/
// queryLessons) skip migration — use recallLessons, or maybeAutoMigrateLessons.
// Migration-aware recall (default token budget applied unless overridden):
const { lessons, totalMatches } = await recallLessons(process.cwd(), {
file: 'src/cli/x.ts',
});
console.log(`${lessons.length} of ${totalMatches} shown`);
await captureLesson(process.cwd(), {
rule: 'Normalize CLI display paths.',
topic: 'windows-paths',
triggers: { files: ['src/cli/**/*.ts'] },
evidence: ['commit:abc1234'],
});

Token-cost comparison

The recall flow saves tokens by returning only the lessons that match — agents never read the whole graph.

StepLegacy YAML + MDJSON graph
RecallRead 400+ line index.yaml + matching topic Markdown files. ~2–5k tokens per session.One query call returning only the relevance-ranked top matches (default top 10 and a ~400-token budget; --top/--max-tokens to adjust, --all to bypass). Typically a few hundred tokens; the caps keep a broad trigger from returning a whole topic.
CaptureEdit journal.md + matching topics/<topic>.md + reconcile index.yaml triggers. Three file ops, often skipped.One add call. Atomic.

See also