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": 2,
"lessons": { "<lesson-id>": { rule, topics, triggers, evidence, status, createdAt, … } },
"topics": { "<topic-id>": { summary } },
"triggers": { "<trigger-id>": { kind, pattern } }
}

A version 1 graph is still read and is upgraded to version 2 on its next write. A graph with a higher version than your agentsmesh supports is not read; validate reports it as NEWER_GRAPH_VERSION.

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.
scope"always"noMarks an always-on lesson (--scope always): delivered on every task instead of matched by triggers. Added in version 2.

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 a safe glob subset, matched in linear time: literals, *, ?, ** as a whole path segment, classes like [a-z] and [^x], {a,b} groups (nested groups work), one leading !, and a leading ./, up to 256 characters. Inside that subset, results are the same as picomatch with dot: true. Anything else is rejected: extglobs, (, ) and | outside a class, + after ] or }, POSIX classes, [!x] (use [^x]), ranges like {1..5}, a group without a comma like {a}, ** inside a segment, and !!. validate reports such a glob as UNSAFE_GLOB_PATTERN, capture refuses it, and recall treats it as a non-match. Capture turns \ into /; a backslash already stored in the graph is reported as BACKSLASH_GLOB_PATTERN and never matches. 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 with a linear-time glob matcher (same results as picomatch with dot: true for the supported subset above; paths and globs are compared in composed Unicode form, so a decomposed macOS path matches), bounded by a work budget, so no glob can hang recall. An unsafe glob never matches.

  • --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 (a \u{…} code point escape is rejected: without the u flag it would match the literal text u{…}); . (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 (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)). Such a pattern, like an invalid regex, can never fire. At capture it is dropped with a DEAD_COMMAND_PATTERN warning when the lesson has another live trigger, and the capture fails as UNRECALLABLE_LESSON when it has none. validate reports one already stored as UNSAFE_TRIGGER_PATTERN (invalid syntax: 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> — the pattern’s tokens must appear as a contiguous run of whole tokens in the keyword text (never a mid-word substring: art does not fire on start), 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 (how narrow the matched trigger is, divided by its fanout; highest weight, and greater than the other signals combined, because a discriminating trigger beats a topic-wide one. An exact path outranks a file class, which outranks a subtree glob, which outranks a keyword that matched only through the path’s own tokens, which outranks a repo-wide glob), 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, 1200) 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. Separately, CLI plain/md output and every MCP answer carry at most 32,000 characters of rule text (MAX_RECALL_PAYLOAD_CHARS); the first rule is always kept, and --format json is never cut. 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).

Lexical retrieval (keyword-only queries). When a query carries task text and no file or command — the UserPromptSubmit hook and the task-start lessons query --keyword / MCP lessons_query keyword recall — recall also reaches lessons by the wording of their rule: every active, non-always rule is scored with the ranker’s own BM25 against the task text, and up to three rules that share at least two distinct, non-generic terms with it join the candidate set. This is how a conceptual lesson fires when the prompt says the same thing in different words than its keyword trigger. It never runs for a file or command query, where a wording match would be weaker evidence than the trigger that fired. Wording matches carry lexical: true in --json output and in the recall-log row’s matchedByKind.text count, match no trigger, and so rank below every triggered lesson under the specificity signal; the usual caps still apply. Zero dependencies and about a millisecond per call.

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": 1200,
"autoPrune": false,
"telemetry": false,
"outcomeLog": true
}

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 = 1200). 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 (a non-positive or non-integer number, or a switch that is not true/false, such as "outcomeLog": "no"), uses the default for that field — recall, a blocking hot path, never throws on config. lessons query prints a warning that names each invalid field, and one when the file is not a JSON object (for example []). config.json is committed, so a cloned repo could set huge values; recallLimit is therefore clamped to at most 50 (MAX_RECALL_LIMIT) and recallMaxTokens to at most 8000 (MAX_RECALL_MAX_TOKENS), and lessons query prints a warning when it clamps. The per-call --top / --max-tokens flags are not clamped, but the 32,000-character output cap still applies.

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.

telemetry (boolean, default false) opts the project into the recall and capture logs that stats and the NEVER_RECALLED check read. It lives in the config, not only in the environment, because the hooks are the most important writer and a hook spawned by a desktop app inherits none of your shell exports — an env-only gate leaves every hook blind while the CLI in a terminal keeps logging. AGENTSMESH_LESSONS_TELEMETRY still overrides per process: 1, true, yes or on turns it on, and 0, false, no or off turns it off (any case). Any other value leaves the config in charge.

outcomeLog (boolean, default true) controls the outcome log, which is separate from telemetry and on by default. It feeds effectiveness ranking, the INEFFECTIVE_LESSON and UNCOVERED_FAILURE checks, the stats effectiveness block, and the hook’s recurrence gate. Set "outcomeLog": false to turn it off. AGENTSMESH_LESSONS_OUTCOME_LOG overrides per process with the same values (1/true/yes/on, 0/false/no/off).

Validation codes

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

CodeLevelMeaning
SCHEMA_INVALIDerrorThe graph fails the Zod schema, also when lessons.json is valid JSON that does not match it (including null or an array). The finding lists up to 3 problems as path: message, with the recovery steps. 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). Capture drops such a pattern (DEAD_COMMAND_PATTERN); this code reports one already stored.
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. Dropped at capture (DEAD_COMMAND_PATTERN), skipped at read time; this code reports one already stored.
UNSAFE_GLOB_PATTERNerrorA file_glob outside the safe glob subset (see Trigger) — for example an extglob, a (…) group, [!x], or a range like {1..5}. Rejected at capture; at recall it never matches.
BACKSLASH_GLOB_PATTERNerrorA file_glob contains a backslash. Recall compares forward-slash paths, so it never fires. Replace \ with /.
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).
TIED_TRIGGER_SETSwarningOne summary finding: N trigger sets are each shared by more than 5 active lessons. Identical triggers score identically on trigger specificity, so when more match than recall delivers, the choice falls to weaker signals and which lessons an agent sees is close to arbitrary. Split the triggers so the lesson written about a given file can win. Derived from the graph alone, so it needs no telemetry.
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 contiguous token-run in --keyword or 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 on disk, and git history (HEAD) shows its path was deleted or renamed — so the lesson is silently unreachable via that trigger. A wildcard glob counts as dead only when its paths were renamed away, since files of a class come and go. A glob with no such proof (the path does not exist yet, lives on another branch, or there is no git history) is pending: it is kept and not reported. This is a liveness check, not a breadth one: a narrow glob matching even one file is fine. 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).
BROAD_COMMAND_PATTERNwarningA command_pattern on an active lesson matches the empty string or most of a fixed probe corpus of unrelated commands (.*, , \w), so the lesson fires on every recall. add rejects a new one outright; this surfaces ones captured before that guardrail. Key it on the action (e.g. \bgit commit\b) or untrigger it.
BROAD_FILE_GLOBwarningA file_glob on an active lesson matches most of the repository (**, src/**), or is a negated glob (!path), which matches every other path, so it fires on nearly every edit and crowds the recall budget ahead of the rule written about the file being touched. Narrow it to the directory or file class the rule is really about, or untrigger it. Advisory only: a deliberate file-CLASS trigger is still the documented way to state general behaviour.
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.
MERGE_CONFLICTerrorlessons.json holds unresolved git conflict markers, so no lesson can be read. Run agentsmesh lessons resolve to combine the lessons from both branches, then git add the file. Also emitted when git still holds lessons.json unmerged and the file lacks lessons that resolve would keep (for example, a merge driver git could not start); run resolve before git add. This clears once the file holds both sides, even before git add.
CORRUPT_GRAPHerrorlessons.json is not valid JSON (and has no conflict markers). Recall degrades to empty while this stands. Keep a copy first, then repair the JSON by hand or restore the last committed graph from git (that drops lessons not committed yet). Recall’s warning prints the same recovery steps.
NEWER_GRAPH_VERSIONerrorlessons.json has a higher version than this agentsmesh supports. Upgrade agentsmesh to read it.

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 that is not a valid regex or that the linear engine cannot run (what validate reports 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, and a dead command pattern is dropped (not saved) with a DEAD_COMMAND_PATTERN 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 (counted as characters, so an emoji counts once). 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).

Two more blocking cases (exit 2). EMPTY_RULE — the rule is empty or whitespace-only, rejected before any write; BROAD_COMMAND_PATTERN — a --trigger-cmd that matches the empty string or nearly every command (.*, , \w, (git)?) would fire on every recall, so it is rejected before any trigger node is created (word-bound the class instead: \bgit commit\b).

Topic checks (exit 2). INVALID_TOPIC_ID — the topic id is not kebab-case (lowercase letters, digits and -); the error suggests one, e.g. build for Build. TOPIC_SUMMARY_REQUIRED — a new topic has no summary, or only spaces. An unknown topic without --new-topic is exit 1 (NOT_FOUND over MCP), like an unknown lesson id.

File trigger checks. Recall matches file_glob triggers against project-relative paths, so a --trigger-file is stored in that one form: surrounding spaces, a leading ./ and a/../ steps are removed, and an absolute path inside the project becomes relative. ./src/a.ts therefore reuses the src/a.ts trigger. A glob that could never fire is rejected before any trigger node exists (exit 2; the MCP lessons_add tool rejects it too, as VALIDATION_FAILED with the code): TRIGGER_FILE_OUTSIDE_PROJECT (an absolute path outside the project, or a relative one that climbs out with ../), TRIGGER_FILE_IS_PROJECT_ROOT (the project root itself), TRIGGER_FILE_IS_DIRECTORY (an existing folder, or a path ending in /; a folder never matches a file, so use src/cli/**), and UNSAFE_GLOB_PATTERN (a glob outside the safe subset, see Trigger).

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, a globstar with a wildcard basename, or a negated glob (!path), which matches every other path). 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 contiguous token-run in --keyword or 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_COMMAND_PATTERNA command_pattern trigger is not a valid regex, or the linear engine cannot run it (a backreference, a lookaround, or a pattern too large), so it could never fire. It was dropped and not saved; the lesson keeps its other triggers. When no live trigger is left, the capture is rejected as UNRECALLABLE_LESSON instead (see above).
DEAD_GLOBA file_glob trigger on the captured lesson matches no file, and git history shows its path was renamed or deleted — likely a rename. 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.)
PENDING_GLOBA file_glob trigger matches no file yet, and git history does not show a rename or delete — the path may not exist yet. The trigger is kept and fires once the path exists. If the path is a typo, re-point it.
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 "telemetry": true in .agentsmesh/lessons/config.json (or export AGENTSMESH_LESSONS_TELEMETRY=1; the env var wins in both directions, 0 forcing it off) 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 2 MB it self-truncates on the next write to the most recent records (at most 5,000) that fit in 1 MB, dropping any single oversized line, and readers read only the newest 2 MB, so it cannot accumulate unbounded even if tracked. An append after a line that was cut off mid-write starts on a new line, so the next record is not glued to the broken one. agentsmesh init --lessons additionally adds the lessons runtime files to your project’s .gitignore: the recall-log.jsonl, capture-log.jsonl, and outcome-log.jsonl logs, the .lessons.lock/ directory, and *.tmp files (all under .agentsmesh/lessons/), keeping them 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). It is self-diagnosing: when the profile matches a known pathology (high redundancy on session-less recalls = inert dedup; no-matches dominated by command-only recalls against few command_pattern triggers = command-trigger starvation), an advice line names the cause and the exact fix — silence is the default.

Capture telemetry

The same opt-in 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).

Effectiveness (outcome log)

The outcome log at .agentsmesh/lessons/outcome-log.jsonl is the base for lesson effectiveness: did a delivered lesson actually prevent the repeat? Unlike the recall and capture logs it is on by default; turn it off with "outcomeLog": false in config.json or AGENTSMESH_LESSONS_OUTCOME_LOG=0. It appends two event kinds — delivered (a lesson injected for an action, stamped with its per-batch relevance rank) and failure (a failure observed for an action) — both keyed by the normalized action (file:<path> or cmd:<class>, where the class is the command that really ran, after skipping cd, env assignments and known global flags), never raw error text, plus the session correlator (the harness hook session_id, or AGENTSMESH_SESSION_ID for shell-driven recalls). A user interrupt (or a Cursor permission_denied) is not recorded as a failure. Effectiveness is derived at read time: a delivery is a miss only when a failure follows it in the same session, within 30 minutes, on an action that matches one of that lesson’s own triggers. Events without a session are never counted. Four existing outputs use the same rule — no new command or flag:

  • Recall down-ranking. A lesson that fired but never helped sinks in the ranking (a low, tie-breaking weight; neutral when there is no data, so ranking is unchanged by default).
  • validate health view. validate adds warning-level findings. From the outcome log: INEFFECTIVE_LESSON (delivered at least 3 times, and every delivery was a miss — review the lesson with show; the rule may be wrong, too vague, or mis-triggered) and UNCOVERED_FAILURE (a file action that failed twice or more with no lesson to prevent it — consider add). From the recall log (needs telemetry): NEVER_RECALLED (one aggregate finding, with every id on lessonIds, once the log holds at least 500 recalls — active lessons older than the log whose triggers matched actions touched in that window, yet were never delivered because other lessons outranked them under the caps; review each with show, then sharpen the rule or narrow the trigger). They are advisory and never change the exit code.
  • stats effectiveness block. lessons stats adds a benefit summary alongside its cost (break-even) and activity (capture) blocks: total deliveries, misses and the distinct failing actions behind them, the coarse held rate (share of deliveries that were not a miss — a weak upper bound, never presented as proof of prevention), the ineffective-lesson count, and failures observed — with a pointer to validate for the actionable list.
  • The recall hook’s recurrence gate. On a PreToolUse first touch of an action that has already failed with the same error up to the recurrence threshold within the last 24 hours and that a captured lesson covers, the hook escalates: the covering rule is re-injected above the regular recall bullets with the failure count, cutting through session dedup — advisory context, once per action per session. A tool call gets one merged warning at most, even for a patch that touches several recurring files, and each covering rule appears in it once. See hook mode.

The signal is deliberately coarse and labeled as such — an honest weak signal beats a precise-looking fake one. Like the other logs it is size-capped, local, and gitignored by init --lessons. It is also per-machine: the graph (your rules) is shared, but effectiveness reflects your own recall/failure history, so the health view and ranking can differ between developers — see Sharing and cross-agent use.

All three logs are best-effort. A log that cannot be written never breaks the recall hook or a command, and lines that are not valid records are skipped when a log is read.

Sharing and cross-agent use

The graph .agentsmesh/lessons/lessons.json is one committed file, shared by every agent and every teammate. A lesson captured by one agent (Cursor, Aider, Claude Code, …) is immediately recalled by any other — the graph is target-neutral, and recall/capture are reachable two ways from any agent: the CLI (agentsmesh lessons query / add) for shell-capable agents, or the MCP tools (lessons_query / lessons_add) for agents without a shell. On the MCP path session dedup is on by default — the server process is stable for the client session, so its id is the natural correlator; a lesson already delivered this session is suppressed (and counted in suppressed), with no_dedup: true as the per-call escape hatch and session to control the scope explicitly. Recall itself reaches every target through one of two channels: a hook on hook-capable targets (deterministic, no extra model turn) and the always-on lessons paragraph in the root instructions everywhere else — so no agent is left without recall. When two branches both change the graph, a git merge driver combines them field by field, and agentsmesh lessons resolve repairs a conflict that got through — see Team workflow.

Effectiveness (above) is the one part that stays per-machine by design: sharing a hot append-only log would leak timing/behavior and create merge noise. Teams share what matters — the rules — and each machine measures effectiveness against its own history. Pooling effectiveness across a team is a possible future opt-in; the event keys are already machine-independent, so the seam exists.

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). Every recall output (hook, CLI plain/md, MCP) cuts a longer rule to that bound, and CLI and MCP answers carry at most 32,000 characters in total, so a malformed or hostile graph cannot flood the agent’s context.
  • One line per rule. The hook injects rules inside a fenced <recalled-lessons> … </recalled-lessons> block, one - [id] rule line each. Line breaks and control characters in a rule become spaces, and invisible format characters (zero-width spaces and joiners, bidi controls) are dropped. A rule cannot spell the closing tag either: a < or a look-alike (the full-width < or the small ﹤) that starts the tag name becomes ‹, even when slash look-alikes or styled or accented letters spell the name. So a rule cannot end the block early and fake text after it. The CLI also prints each rule on one line.
  • 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), and file_glob triggers with a linear glob matcher (no backtracking regex), so no ReDoS. It never executes a trigger or rule — it only matches and prints.
  • Logs stay local. The logs record presence/count fields and normalized action keys (a file path or a command class such as git commit), never rule text, raw commands, or raw error output, and never leave the machine.
  • Bounded config. The committed config.json cannot raise the recall caps past 50 lessons / 8000 tokens (see Recall tuning).

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 (signal cleanup, retry budget). Each holder gets a random owner token, so a release or a stale eviction can never remove a lock that already passed to another process. A dead holder on the same host is evicted at once, and any lessons lock older than 60 seconds is treated as abandoned, as is a lock dated more than 5 minutes in the future (a clock that is off); many parallel writers wait their turn with jittered back-off. A write that was paused longer than the 60-second window may have lost its lock to the next writer, so it refuses to save (exit 1, lost the lessons lock while writing … retry the command) and nothing is written; run the command again. Concurrent writers serialize cleanly, an invalid mutation is never persisted, and a crash mid-write cannot truncate the graph. A writer that waits more than 2 seconds for a lock held by a live process says once who holds it. Each write also removes temp files (*.<pid>.tmp) and set-aside lock folders (*.stale) older than a minute that a crashed writer left in .agentsmesh/lessons/. A read-only lessons.json (for example after chmod 444) is not written over: the write fails with a message and nothing is saved; otherwise a save keeps the file’s mode. When .lessons.lock is a file rather than the lock folder, the command says so instead of failing with a raw ENOTDIR. lessons.json and config.json may start with a UTF-8 BOM, as Windows editors often save them; it is ignored. A change the validator refuses exits 2 with Refused to save the lessons graph: this change would add <CODE>: <reason>. Nothing was written.; errors the graph already had do not block it.

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 turns every list item under a ## Rules or ## Lessons heading of a topic file into a lesson in lessons.json (numbered or bullet, with the lines that wrap onto it) and deletes index.yaml and topics/. A list item under any other heading stops the migration with nothing changed. journal.md is kept, because its notes are not rules:

Terminal window
agentsmesh lessons import-md

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

Every topic file path in the legacy index must stay inside .agentsmesh/lessons/. If one points anywhere else, the migration is refused and the legacy files are left intact.

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 ~1200-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