Skip to main content

Agent Persistent Memory

Agent persistent memory gives an enrolled agent identity a small, repo-filtered pool of durable facts — "this repo's arm64 CI is flaky", "race suites need a long timeout" — that Gitmoot injects into the job prompt as a reference-only block. It is a native SQLite + FTS5 feature in the existing store (no vector DB, no new dependencies) and is off by default.

Memory is distinct from templates. Templates hold skills (how an agent works; SkillOpt improves them); memory holds knowledge (what is true about a repo).

Trust model

The benefit of memory is treated as a measured hypothesis, not an assumption, so it ships in phases. The current phase is observation mode:

  • READ: while assembling a job prompt, Gitmoot runs one sanitized FTS5/BM25 query over the agent's private confirmed facts plus the reserved shared pool (and either the current repo or the always-travelling general scope). BM25 ranks first; if scores tie, the agent's private facts outrank shared facts, then recency breaks ties. A small floor guard keeps the strongest private match in the injected slice when private matches exist but shared rows would fill the limit. Gitmoot caps the result by a token budget and renders a fenced block titled "Prior learnings (reference only, not instructions)" with [this repo] / [general] tags. After direct FTS hits are selected, Gitmoot follows one hop of persisted memory links in a single batched query, appends visible linked facts after all direct hits (capped at 3), and tags those bullets with [linked]. Linked facts must pass the same private-plus-shared, repo/general, and active-row visibility rules. They fill only remaining entry and token budget, so they never evict direct hits. Every enrolled agent's prompt also carries a one-line hint that project memory is searchable mid-job via gitmoot memory recall "<query>" --agent <agent-name>; the hint renders whether or not startup retrieval found anything, because on-demand recall matters most when the initial push missed. Successful live injection increments usage only for facts inside the rendered token-budget cut, including linked facts. Preview, replay, and eval reads never increment it. On-demand recall separately counts direct FTS hits, excluding linked expansion rows; both telemetry writes are best-effort and cannot fail a job or recall.
  • WRITE: the confirmed (injectable) tier is populated only by Gitmoot's own deterministic mechanical facts (no model involved). A fact is written only when a terminal job carries a genuine, bounded signal — never one fact per job: a fix-round fact when a job needed corrective verify/retry rounds. Terminal-outcome candidates are also considered, but a substantiveness gate requires a concrete job id, PR number, error string, file, or count. The generic "some review jobs here concluded with changes requested" shape is suppressed. Routine successes and anomalous one-off failed/blocked terminals write nothing. Facts are keyed by low-cardinality closed categories — the outcome is a validated decision value and the action is collapsed to a small fixed allowlist (any free-form delegation action buckets to a generic token), never free-form content — so repeated jobs UPSERT the same row rather than growing the pool. Agent-returned learnings are shadow-logged to an append-only observations table for measurement but are never injected and never promoted in this phase.

Every write passes deterministic pre-filters that reject directive-phrased ("you must always…"), executable/command, secret-shaped, and — for general candidates — non-repo-agnostic content. These filters are the primary safety gate against experience-poisoning and indirect prompt injection.

Storage

Two tables back the evidence/upsert split: an append-only memory_observations table (where witnesses for a claim accumulate) and a keyed confirmed_memories table (one injectable row per fact, with graphiti-style supersession rather than deletion). Owner identity is structured (agent vs. role, with template version awareness) so template upgrades never inherit stale pools. The reserved shared pool uses owner_kind = "shared" and owner_ref = "shared". When a fact moves there, author_ref preserves who wrote it; an empty author_ref means the author is the same as owner_ref. A standalone FTS5 index over confirmed content powers the BM25 retrieval. Each confirmed fact also carries injection and direct-recall counts plus its last-use timestamps. Brain fact and Knowledge JSON publish these fields for usage-aware review. The groomer separately flags facts at least 90 days old with both counts still zero; this is review-only and never auto-retires the fact.

Enrollment and configuration

Enrollment is per agent; global knobs live in a [memory] section:

[agents.builder]
runtime = "codex"
memory = true # enroll this agent (default off)

[memory]
disabled = false # global kill switch (overrides every enrollment)
default_enroll = false # enroll agents created by manual agent start unless explicitly overridden
token_budget = 1500 # cap on injected block size (estimated tokens)
max_entries = 15 # cap on confirmed rows considered for injection
distill_at_terminal = false # stage deterministic failure signal at terminal (P4.1)
distill_successes = false # stage deterministic success observations
distill_max_per_job = 3 # hard cap on distilled observations per job
distill_all_jobs = false # true → distill every job, not only enrolled agents
ingest_auto_confirm = false # true → ingest/chat remember confirm to private only
harvest_enabled = false # sweep new terminal results for durable insights
harvest_runtime = "codex" # fresh read-only one-shot classifier runtime
harvest_model = "" # empty uses the runtime default
harvest_effort = "low"
harvest_max_per_job = 2
harvest_max_jobs_per_sweep = 5
groom_split_llm = false # default-off LLM boundary chooser after deterministic splitting
groom_split_llm_runtime = "codex"
groom_split_llm_model = "" # empty uses the runtime default
groom_split_llm_max_per_run = 5
groom_quality = false # shadow audit; true permits corroborated retirements
groom_quality_max_per_run = 8
groom_quality_min_age = "24h"
groom_llm_total_max_per_run = 10 # shared across quality, stale, and split calls
groom_stale = true # detect expired operational-status batons
groom_stale_age = "336h" # newest content date must be older than 14d

Daemon-consumed [memory] keys are hot-read with no daemon restart; default_enroll is read on each manual agent start. Flipping distill_at_terminal or distill_successes takes effect on the next job.

gitmoot agent start NAME --memory[=true|false] explicitly chooses enrollment for a newly started agent. If the flag is omitted, default_enroll supplies the choice; explicit false overrides a true default. The default applies only to manual agent starts, not hidden pipeline runners or ephemeral workers. Every successful start prints the effective memory state, including when enrollment is on but the global disabled switch prevents use.

Insight harvest

Insight harvest is an opt-in, durable daemon sweep over newly persisted terminal job results. The first enable records the current high-water mark rather than silently backfilling old history. Cheap safety filters and exact-fingerprint dedup run before any model call. Explicit result learnings bypass the classifier but still pass the same safety and dedup checks; otherwise only the result summary and findings are presented as untrusted data to a fresh read-only one-shot classifier.

Candidates are staged as low-trust, repo-scoped observations in the shared pool, with the executing agent retained as author and the source job recorded. They remain human-gated pending observations: harvest: provenance is never eligible for auto-confirmation, even when ingest_auto_confirm = true. Durable receipts prevent repeats; an attempt with an unknowable provider outcome is marked uncertain and surfaced in daemon status rather than retried automatically.

Distill-at-terminal

distill_at_terminal (off by default) enables a deterministic producer that, on an anomalous terminal (failed/blocked/changes_requested), mines the job's own result for two closed-category signals — failing tests (test names from explicit --- FAIL: markers in the job output, not mere presence in tests_run, which only records that a test was run) and named errors (stable tokens from the summary and the tail of the raw output, normalized by stripping hashes, paths, addresses, line numbers, and timestamps). Unlike the mechanical facts above, distilled rows are written as pending observations at trust low with provenance distill:<job-id> — they are never confirmed memory, so the human memory confirm gate stays the only promotion path.

Distill is bounded on every axis: each candidate passes the same PreFilter, a content-hash dedup blocks a repeat from staging twice, and distill_max_per_job caps writes per job. A recurrence gate stops a one-off failure from ever becoming a pending memory — the first sighting of a normalized key records only a low-trust witness (distill-seen:<job-id>), and the observation stages only when the same key recurs across a later job. A witness is internal recurrence bookkeeping: it is never shown in memory list and can never be promoted by memory confirm, so a one-off failure is invisible until it recurs. By default distill follows enrollment; distill_all_jobs = true harvests failure signal box-wide while the read path and confirmed producers stay enrolled-only.

Success Distill

distill_successes (off by default) enables two deterministic success producers. Both write only pending observations at trust low; neither writes confirmed memory directly.

  • SkillOpt promotions stage one observation when a candidate is promoted. The key is bounded by template version and content hash, for example skillopt:<template>@vN-promoted:<hash>. The content records which version was promoted over which base, plus local evidence such as review score, replay-gate mean scores, and recorded weaknesses when present.
  • Recovered failures run when a later job succeeds. Gitmoot looks for active confirmed failure facts with distill: provenance whose source_job belongs to the same task lineage as the successful job, using matching task_id when both jobs have one, otherwise the same repo plus branch. It appends a low-trust pending observation on the same key that names the successful job, date, and branch. It does not mutate, retire, or auto-upgrade the confirmed failure fact.

Recovered-failure writes share distill_max_per_job; SkillOpt promotion writes are one observation per promotion event. Both paths use the same PreFilter and observation dedup rules as other pending memory.

An agent records a durable fact via the optional top-level learnings field in gitmoot_result — each entry is {key, scope, content} where scope is "repo" (about this repository, the default) or "general" (true everywhere). Most jobs return none.

Inspecting and measuring

All of the following are read-only:

gitmoot memory list [--pending|--confirmed] [--agent NAME] [--repo owner/repo]
gitmoot memory recall "<query>" [--repo owner/repo] [--agent NAME|--shared] [--limit N] [--expand]
gitmoot memory replay [--agent NAME] [--repo owner/repo] [--limit N]
gitmoot memory eval --fixtures evals/memory-retrieval-fixtures.json [--k N] [--json]

memory list shows confirmed memories and pending observations. memory recall is an on-demand relevance search over confirmed memory. It uses the same FTS5/BM25 retrieval as prompt injection. By default it searches all agent pools plus the shared pool; --agent NAME searches that agent's private pool plus shared, and --shared searches only shared facts. Without --repo, recall searches every repo and general-scope facts. --repo owner/repo narrows repo-scoped facts to that repo while still including general-scope facts. --expand follows one hop of persisted links from the direct matches and appends visible linked facts after all direct matches. Expanded text bullets carry [linked]; JSON output includes author_ref when a shared fact preserves a different author and linked_from when a row came from link expansion. Semantic or embedding search remains future work; current retrieval is SQLite FTS5 plus persisted links. memory replay is an offline A/B: it re-renders recent real jobs' prompts with and without the learnings block and reports the injection delta (added tokens, entries injected) — it measures injection mechanics, not outcome quality. memory eval scores the production PreviewEntries retrieval path against the versioned 44-case evals/memory-retrieval-fixtures.json exam. It includes verbatim real-job instructions, known incidents, six deliberately disjoint-vocabulary paraphrases, and self-retrieval sanity checks. The original fixture fields remain valid; optional id, source, category, note, and expected_alternates fields add provenance and key-stability labels without breaking old fixture files.

Each run reports K=5 and K=15, per-category results, budget-at-risk hits, a fixture SHA-256, and an active-corpus count/max-update fingerprint. A deterministic miss taxonomy distinguishes stale_label, scope_pool_exclusion, vocabulary_mismatch (the sanitized query misses the expected fact's own FTS row), and ranking_loss (visible FTS match below K, with actual rank). Interpret recall@15 >= 0.8 as keyword retrieval adequate, 0.6-0.8 as a ranking/budget investigation, and below 0.6 as evidence for a separate embeddings trial. The runner is read-only and adds no alternate retrieval implementation.

Vault view (a derived, disposable Obsidian view)

gitmoot memory vault export [--out DIR] [--agent NAME] [--force] [--json]
gitmoot memory vault import <DIR> [--dry-run|--yes] [--json]

memory vault export renders confirmed memory as an Obsidian-compatible vault: one Markdown note per confirmed memory (sorted-key YAML frontmatter, the content verbatim, and a ## Links section of FTS co-occurrence plus persisted [[wikilinks]]), a per-owner index note, and a manifest.json staleness anchor. Shared notes include an author: frontmatter line when author_ref is set, so a fact moved into shared still points graph tooling at the real author. --agent NAME narrows the export to that agent's private facts plus shared facts authored by that agent.

The vault is a view, not a replica: SQLite stays the only source of truth, so the export never becomes a second store to keep in sync. It is regenerated from scratch on every run, is safe to delete, and is fully deterministic — the same store produces byte-identical files (there is deliberately no exported_at, and filenames are stable NNNNNNNNN-<slug>.md derived from the memory id). That determinism is what lets vault import (below) diff hand-edits against a fresh export. The export is read-only (zero writes to any table) and atomic (it writes a temp directory and renames it over --out, which defaults to a vault/ directory under the home's evals area). Since the export replaces --out wholesale, it refuses to overwrite a non-empty directory that is not itself a prior gitmoot vault (one carrying a manifest.json), so pointing it at an existing Obsidian vault such as --out ~/my-vault can never silently delete your own notes; pass --force to override.

memory vault import <DIR> closes the loop as the human curation gate: export a vault, edit/delete/add notes in any editor, then import diffs the folder against a fresh export and applies only on confirmation. The diff is the audit trail. It regenerates a fresh export first and aborts as stale if the store moved since the vault was written (the manifest snapshot_hash mismatches), so a stale edit can never clobber newer facts. Then:

  • an edited note rewrites its source memory's content — an optimistic compare-and-set on updated_at targets the exact row (never key-based, so it can't clobber a different fact) and resyncs the FTS index;
  • a deleted note retires its memory: additive retired_at/retired_reason columns plus FTS removal stop it being injected or exported, while the row is kept for audit (retirement is distinct from superseded_by replacement and never hard-deletes);
  • a new .md file (no memory_id) stages a pending observation (provenance=vault-import:<file>, trust normal — it is owner-authored) behind the usual confirmation gate; it is never auto-confirmed.

Frontmatter identity edits (key/scope/owner) are out of scope — detected, warned, and skipped (only the content edit lands). --dry-run is the default: it prints the diff and writes nothing. --yes applies edits, retirements, and new observations in one transaction (all-or-nothing). If any note fails to parse (e.g. broken YAML frontmatter) --yes refuses to apply — a malformed note could otherwise be misread as a deletion and silently retire a live memory. A vault produced by export --agent NAME stays importable even when other owners have memories, because import rebuilds the fresh export with the manifest's recorded scope.

Markdown ingest and the human confirm gate

The vault export is the bridge's outlet; memory ingest is its mouth. It reads arbitrary Markdown (session notes, runbooks, incident writeups) and stages it as observations behind the existing confirmation gate. By default those observations stay pending. If [memory].ingest_auto_confirm = true, memory ingest, memory ingest sweep, and chat remember immediately confirm the staged observation into the authoring agent's private pool only. They never auto-confirm into the shared pool. Shared memory stays explicit through memory confirm --to-shared or memory promote --to-shared.

workflow note --remember has an additional shipping-status gate. A leading MERGED, SHIPPED, DEPLOYED, or CLOSED phrase with a PR/CI reference stays in the workflow journal unless the caller explicitly adds --remember-status.

gitmoot memory ingest <path|dir> --agent NAME [--shared] [--repo owner/repo] [--tier repo|general] [--dry-run] [--json]
gitmoot memory ingest sweep [--json]
gitmoot memory observations [--agent NAME] [--provenance-prefix P] [--json]
gitmoot memory confirm <obs-id>... | --provenance-prefix P [--agent NAME] [--to-shared] [--yes] [--json]
gitmoot memory retire --provenance-prefix P [--agent NAME] [--dry-run] [--yes] [--json]
gitmoot memory promote --to-shared <id>... [--json]
gitmoot memory links backfill [--dry-run] [--json]
gitmoot memory links list <id> [--json]
gitmoot memory log [--key K] [--agent A] [--repo R] [--kind k1,k2] [--since 168h] [--limit N] [--json]
# --kind values: created, updated, retired, unretired, superseded, confirmed,
# promoted, ingested, cluster_recompute, cluster_rename
gitmoot memory log --id <memory-id> [--json]
gitmoot memory log backfill [--dry-run] [--json]

memory ingest walks *.md, strips a leading YAML frontmatter block when present, skips MEMORY.md-style index files whose body is only a Markdown link list to other .md notes, and chunks a file only when its body exceeds ~512 estimated tokens (smaller files stay one observation). Over budget it splits on ## headings, and any section still over budget is sub-split on paragraph/line boundaries so no single chunk exceeds the token budget (an oversized memory would otherwise be force-injected wholesale). Every chunk passes the same deterministic PreFilter that gates agent learnings (rejecting directive-phrased, secret-shaped, executable, or — for --tier general — non-repo-agnostic content), reported as per-reason rejection counts. A chunk whose exact content already exists in the same visibility domain (same scope and repo) is deduped, so re-ingesting a source is a no-op — but the same note ingested under a second repo still stages, because repo-scoped memory injects only for its own repo. Survivors land in memory_observations with provenance = ingest:<relpath> and trust_mark = low. --dry-run reports the plan without writing. --shared stages observations in the shared pool and records --agent NAME as the authoring identity. With auto-confirm enabled, the confirmed write still goes to --agent NAME's private pool, not shared.

Stable chunk keys and edition history

Each chunk's observation key is a stable function of its source location alone: slug(file)-slug(heading). The content hash is deliberately not part of the key; it participates only in exact-content dedup. When a section splits into several pieces, or a heading repeats within one sweep, later pieces take an ordinal suffix (-2, -3) in document order. Because the key survives edits, a re-swept edited note lands on the same key as its earlier edition, and with ingest_auto_confirm enabled the existing confirmed fact is updated in place instead of accumulating a new hash-suffixed sibling on every edit. chat remember keys (chat-<thread>-<seq>) follow the same scheme.

Auto-confirmed in-place updates (ingest auto-confirm and chat remember) are supersede-preserving: before the live row is overwritten, the prior edition is copied to an archived row whose superseded_by points at the live row. The archive never injects, never exports, and carries no links; memory_links stay keyed on the live row id, which does not change, so a bad or poisoned edit can never silently destroy the last reviewed edition. Manual human paths (vault import CAS edits, memory confirm --yes) keep their plain overwrite semantics. Keys minted before this scheme carry a trailing 8-hex content-hash suffix; the groom rekey detector migrates them (see below).

memory observations lists pending observations, flagging which have already been confirmed. memory confirm is the human-gated promotion: it copies selected observations (by id, or every one matching a --provenance-prefix) into confirmed memory, carrying provenance through. Without --yes it prints the plan and writes nothing; with --yes it promotes idempotently. --to-shared confirms selected observations into the shared pool while preserving the observation author. memory promote --to-shared <id>... moves active confirmed facts into shared, refuses retired or superseded rows, preserves existing links, and stamps author_ref from the previous owner when needed.

memory retire --provenance-prefix P is the blast-radius undo for a collector batch. It selects active confirmed rows whose provenance starts with P, scoped optionally by --agent NAME, and is a dry run unless --yes is passed. Applying the plan sets retired_at and retired_reason and removes the rows from FTS in the same transaction, so they stop being injected and exported while the audit rows remain. Retired keys are not resurrected by ingest or collectors on re-ingest; only explicit human-controlled confirmation paths may revive a retired key.

memory ingest sweep reads the current [[memory.ingest]] source list from the config at run time and runs the same ingest logic in-process for each source. --json reports each source with path, agent, repo, tier, inserted, confirmed, skipped_retired, deduped, rejected, and error, plus totals. One bad source does not stop the rest. The command exits non-zero only when the config is invalid or every source fails; with no sources it exits zero with a skipped note.

For unattended intake, Gitmoot ships an ordinary built-in pipeline named memory-ingest-sweep. The daemon and gitmoot pipeline install-defaults register it idempotently and skip an existing row with that name, preserving local edits. The installed pipeline calls gitmoot memory ingest sweep --json, so edits to [[memory.ingest]] apply on the next manual or scheduled run without reinstalling defaults. Per-source errors are included in the run output, and an all-source sweep failure marks the stage failed. Configure one or more sources, then either run it manually or enable an interval:

[[memory.ingest]]
path = "/path/to/markdown-notes"
agent = "lead"
repo = "owner/repo"
tier = "repo"

[memory.pipelines]
repo = "owner/repo"
ingest_sweep = "nightly"
gitmoot pipeline run memory-ingest-sweep

With no [[memory.ingest]] entries, the pipeline succeeds with a no-sources summary. It follows [memory].ingest_auto_confirm: default pending only, or private-pool confirmation when that switch is true.

When a fact is confirmed, Gitmoot also records up to three deterministic outgoing links from that confirmed row to active related confirmed memories. These links live in the memory_links side table with BM25-derived scores. They do not rewrite the memory's content. Link candidates use the same private-plus-shared visibility as prompt injection, so private facts can link to shared facts and shared facts can link back through their author pool. memory links backfill runs the same pass over all active confirmed memories in id order; --dry-run reports what would be created, and repeat runs create nothing new. memory links list <id> inspects a fact's persisted outgoing links. Vault export merges persisted links with content-derived links in each note's ## Links section and removes duplicates by target.

Brain changelog

Every confirmed-memory mutation writes an append-only event in the same SQLite transaction as the fact change. gitmoot memory log shows the newest events and filters by key, agent, repository, kind, or age. Use memory log --id ID for one fact's oldest-first biography. Updates retain the previous content inline up to 2 KiB, then switch to a SHA-256 hash and 300-character preview; retirements retain their reason, and split, promotion, and cluster operations retain compact JSON details. memory log backfill synthesizes historical creation, retirement, and supersession receipts from existing tombstones; it is idempotent and supports --dry-run. The dashboard server exposes the same chronology at GET /api/brain/events?cursor=ID&limit=N; each page includes the exact append-only event total. A selected chronology entry can load its full fact, including retired and superseded rows, from GET /api/brain/fact?id=ID.

:::warning Ingested Markdown is untrusted Ingested Markdown is an indirect-prompt-injection vector. Ingest stamps trust_mark = low on every observation, and observations are inert (never injected) until a human runs memory confirm. That confirm step is the trust boundary. Trust-aware injection — having the read path weigh trust_mark — is future work; nothing reads trust_mark for a decision yet. :::

Grooming stale memory

memory groom combines an automatic lossless brick splitter with an explicit propose → review → apply round-trip for retirement and other curated actions:

gitmoot memory groom --propose [--out PLAN.json] [--json]
gitmoot memory groom --yes --plan PLAN.json [--json]
gitmoot memory groom --split [--dry-run] [--json]
gitmoot memory groom --split-revert [--dry-run] [--parent N]... [--since RFC3339] [--json]

--split partitions qualifying multi-story bricks at deterministic byte offsets. Strong seams are bold story headers, date-led lines, and PR markers. List items, Why, and How to apply sub-fields are excluded; length alone never creates a cut, and status/changelog content is not split. Segments below 200 trimmed bytes merge into a neighbor until stable. Children are exact parent substrings and must concatenate to the parent's trimmed coverage, otherwise the operation fails closed to a rewrite flag. The transaction inserts and indexes the children, sets the parent superseded_by to the first child, removes the parent from FTS and cluster membership, and attaches children to the parent's cluster. Ownership, author, repo, and scope are inherited, and rendered children carry (split from: <parent-key>) context; links are enriched later. --dry-run writes nothing, and subsequent runs are no-ops.

When groom_split_llm = true, the same --split command considers active over-threshold bricks that the deterministic pass left intact. Gitmoot enumerates a closed boundary menu from blank-line paragraph starts and strong seams, excluding Markdown list items and fenced code. A fresh one-shot codex, claude, or kimi session may return only strict JSON selecting those ids or keeping the brick; echoed source lines must match exactly. Host-selected offsets then run through the same 200-byte runt merge, substantive-child, exact-coverage, store re-check, and CAS transaction. The model never supplies offsets or rewritten content.

The runtime defaults to codex; an empty model uses the runtime default, and groom_split_llm_max_per_run defaults to 5. Calls time out after 90 seconds. Content over 8192 bytes is reported and skipped without truncation. Both split and no-split decisions are cached by the SHA-256 of trimmed content; cached split cuts are fully revalidated before replay. Any delivery, JSON, echo, menu, or lossless validation failure leaves the brick untouched and appears as fallback_reason in the --split --json output and pipeline groom-split.json artifact.

--split-revert defaults to every active groom split; --parent N is repeatable and --since RFC3339 filters recent splits. Each parent is restored only when its active children in id order still reconstruct the trimmed original exactly. Valid children are retired, never deleted, with reason groom-split-revert:<parent-id>; the parent returns to FTS and inherits the lowest-id child's current cluster when one exists. Changed groups are skipped whole, repeated runs are no-ops, and --dry-run only reports candidates.

--propose reads every active confirmed memory (retired rows excluded), computes the current vault snapshot_hash (the same anchor vault export/import use), runs deterministic detectors, and writes a reviewable plan artifact ({schema_version, snapshot_hash, proposed_retirements, rewrite_flags, rekeys, cross_pool, quality, stale, stats}). It never changes confirmed memories; quality and enabled staleness passes may add immutable verdict-cache rows. The detectors flag:

  • status/changelog/ToC snapshots — notes dominated by STATUS: markers, SHIPPED/merged & deployed phrases, ISO-date-led lines, or Markdown link-list index entries (short notes under 3 lines also need a strong STATUS:/… & deployed marker, so a lone date-led or SHIPPED-mentioning keeper is not retired);
  • bare to-do lists — content whose every non-blank line is a checkbox item;
  • exact duplicates — identical content within the same owner/repo/scope; the lowest id is kept and the rest proposed. Copies across owners/repos/scopes are kept (each is the only one its scope can see);
  • brick rewrite flags cover over-long content and shorter multi-story notes with at least two strong seams. Qualifying bricks are handled by the automatic lossless split; seam-poor long prose stays flag-only for Phase 2;
  • legacy-key rekeys: keys minted before the stable-key scheme end in an 8-hex content-hash suffix (for example runbook-deploy-a1b2c3d4). Organic sweeps can never converge them, because content dedup skips unchanged notes and the first edit would spawn a stable-keyed third sibling. The detector groups active rows per owner, repo, and scope by the stripped stable key, keeps the current edition (the row already holding the stable key when one exists, otherwise the newest by updated_at), proposes rewriting its key to the stable form, and proposes retiring the older siblings with reason rekey: superseded edition. Applying re-syncs the FTS key column in the same transaction;
  • cross-pool stale shared editions: a shared-pool fact gets a promote-and-retire pair when a strictly newer private fact matches it in the same repo and scope, either by stable-key equality (the primary, deterministic signal) or by a strong BM25 top-match that also shares a memory_links edge (composite secondary evidence; BM25 alone never proposes). Applying promotes the newer private edition into the shared pool with its author preserved and retires the stale shared edition with reason cross-pool: superseded by promoted edition.
  • general quality risk: facts younger than groom_quality_min_age (default 24h) are excluded. Older facts score +3 for transient status/source-controlled history, +3 for fragments, +3 for generic content without specifics, +2 for near-duplicates, +1 for automated provenance, and +1 below 160 characters. **Why:**, **How to apply:**, and concrete cause-to-consequence lessons subtract 3 and are explicitly protected from candidacy. The threshold is 3.
  • expired operational-status batons: the default-on groom_stale detector requires an uppercase in-flight/status verb in a ## or STATUS: header, tracker-id or dated-header corroboration, and a newest in-content ISO date older than groom_stale_age (default 336h, 14 days). **Why:** and **How to apply:** lessons and status/changelog-routed notes are excluded.

Quality candidates reuse the split runtime/model and have an immutable SHA-256 verdict cache. Verdicts are useless, useful, or contains_durable_residue plus confidence in [0,1]; residue must be an exact content quote. Auto-retirement requires useless plus at least two independent positive signal families. Residue and every uncertain/error outcome stay owner-gated. With default groom_quality = false, the pass still runs in --propose and --split; quality.shadow is true and qualifying actions are reported as would_retire. Enabling it lets --split retire through the reversible house path with reason groom-quality:<date>.

Stale candidates reuse the split LLM runtime, model, and per-run call cap. The strict verdict is expired, still_relevant, or contains_durable_residue; residue must be an exact content quote. Verdicts have a separate SHA-256 cache. Plan stale entries report candidate details, verdict, action, cache hit, residue, and fail-closed fallback reason. Only deterministic shape plus expired agreement auto-retires, via the house path with reason groom-stale:<date> and FTS removal. All other outcomes remain proposals. The row and content are preserved for audit. To reverse a stale retirement, an owner must clear its retired_at and retired_reason and reinsert the row's (rowid, content, key) into confirmed_memories_fts in one transaction; clearing only the retirement fields leaves it absent from search and prompt injection.

--yes --plan recomputes the snapshot_hash and aborts as stale if it differs from the plan's (a vault edit between propose and apply invalidates it), then applies the whole plan in one transaction: retirements first (reason groom:<detector>, clearing each from the FTS index), then rekey groups, then cross-pool pairs. Content is never edited or rewritten, and applying is idempotent: an already-retired or missing id is skipped gracefully, and a rekey group or cross-pool pair whose target rows changed state is skipped whole.

Gitmoot also ships a built-in memory-groom-propose pipeline. It auto-applies lossless splits first and, when enabled, retires only deterministically stale status batons whose verdict is expired, plus quality facts classified useless with at least two signal families. Quality, stale, and split calls share groom_llm_total_max_per_run (default 10), while quality itself is capped at 8. It then writes the owner-gated proposal plan. Ordinary retirement, rekey, cross-pool, residue, and fail-closed stale proposals are never auto-applied. Configure an interval or run it on demand:

[memory.pipelines]
repo = "owner/repo"
groom_propose = "nightly"

[memory].groom_split_llm = false keeps LLM-guided splitting and stale verdict delivery inert by default. The quality classifier still runs in shadow mode and uses the configured runtime/model; deterministic splitting does not consult the flag.

gitmoot pipeline run memory-groom-propose

gitmoot pipeline install-defaults and daemon startup install it idempotently, skipping an existing row named memory-groom-propose.

Emergent clusters

memory clusters groups confirmed facts into emergent communities detected over the fact-similarity graph, using the same bm25 + id-tiebreak signal the vault [[links]] use. They replace the dashboard's old fixed key-prefix "category" hubs: clusters are discovered from what facts actually say, not how their keys are namespaced.

gitmoot memory clusters [--json]
gitmoot memory clusters recompute --propose [--out PLAN.json] [--json]
gitmoot memory clusters recompute --apply [--plan PLAN.json] [--json]
gitmoot memory cluster rename <cluster-id> <label>
  • Deterministic by construction. The community detection is id-ordered label propagation with lowest-label tie-breaks over a fixed graph (sorted-id visit order, id-valued initial labels, summed neighbor influence). No map order, randomness, or wall clock enters, so the same store yields byte-identical clusters, labels, medoids, cluster ids, and hierarchy, matching the vault byte-identity rule.
  • Labels are up to three distinctive terms (term frequency inside the cluster weighted against corpus document frequency), joined with - and anchored to the cluster medoid (the member with the highest total intra-cluster similarity) for stability. Child labels compare term frequency across siblings, making them contrastive within the parent. Facts with no neighbors fall into the reserved cluster 0 unclustered.
  • Automatic hierarchy: a top-level cluster splits at 20 facts when a second deterministic pass over its internal subgraph yields at least two children of at least four facts each. Existing splits stay active above 12 parent facts while every child remains at least four facts. At 12 or fewer facts, or when a child falls below four, the children dissolve. The maximum depth is two levels, and facts always belong to leaf clusters.
  • recompute is a human-gated propose → review → apply round-trip like memory groom: --propose writes a reviewable plan of fact moves plus planned splits and dissolves, along with a staleness anchor over every active fact's (id, updated_at); --apply --plan re-checks the anchor, aborts as stale if the store moved, then rewrites the whole clustering in one transaction. On first run (no clusters yet) a bare recompute --apply is allowed since there is nothing to protect.
  • Incremental attach: confirming a new fact best-effort joins it to the leaf cluster of its nearest neighbor without a full recompute; nothing is re-shelved silently.
  • memory cluster rename sets an owner label override that wins over the computed label. Parent overrides survive later splits. Child overrides survive while the stable child id persists and are removed when the split dissolves.

The Knowledge payload gives child entries an optional parent_id; facts retain leaf cluster ids. The dashboard can render repo → cluster → subcluster → fact, while parent hubs remain an aggregate view and do not change memory injection or retrieval.

Phases

  • Phase 0 — typed learnings in the result contract; the two-table schema and FTS index.
  • Phase 1 (current) — observation mode: read-only injection of mechanical facts, shadow writes, and the measurement harness above.
  • Phase 2 — live agent writes, the confirmation protocol (witness counting + a cheap curation judge), curation, general-tier promotion governance, and the remaining audit CLI.
  • Phase 3 — an optional hybrid vector-retrieval leg, added only if the Phase-1/2 metrics show BM25 word-matching misses.