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
generalscope). 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 viagitmoot 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/blockedterminals 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 whosesource_jobbelongs to the same task lineage as the successful job, using matchingtask_idwhen 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_attargets 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_reasoncolumns plus FTS removal stop it being injected or exported, while the row is kept for audit (retirement is distinct fromsuperseded_byreplacement and never hard-deletes); - a new
.mdfile (nomemory_id) stages a pending observation (provenance=vault-import:<file>, trustnormal— 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 & deployedphrases, ISO-date-led lines, or Markdown link-list index entries (short notes under 3 lines also need a strongSTATUS:/… & deployedmarker, so a lone date-led orSHIPPED-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 byupdated_at), proposes rewriting its key to the stable form, and proposes retiring the older siblings with reasonrekey: 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_linksedge (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 reasoncross-pool: superseded by promoted edition. - general quality risk: facts younger than
groom_quality_min_age(default24h) 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_staledetector requires an uppercase in-flight/status verb in a##orSTATUS:header, tracker-id or dated-header corroboration, and a newest in-content ISO date older thangroom_stale_age(default336h, 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 0unclustered. - 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.
recomputeis a human-gated propose → review → apply round-trip likememory groom:--proposewrites 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 --planre-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 barerecompute --applyis 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 renamesets 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
learningsin 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.