Memory¶
A persistent, cross-session memory for the agent — markdown files under
~/.kin/memory/ that survive past any one session, plus a ranked recall layer
that surfaces the relevant ones automatically. The model reads and curates the
store with the memory tool; a reflection pass at session end extracts
durable knowledge for it.
What it looks like¶
Memory is a directory of small markdown files, addressed under a virtual
/memories prefix (the same shape as Anthropic's memory tool, so models
trained against it drive it natively):
~/.kin/memory/
├── MEMORY.md # optional curated index — inlined into the system prompt
├── prefs/
│ └── reviews.md # category: preference
└── facts/
└── deploy-topology.md # category: project-fact
Each file carries fenced category: frontmatter that drives indexing and
expiry (the fences are required — a bare category: first line is treated
as content):
| category | meaning | expiry |
|---|---|---|
project-fact |
durable facts about a project/system | never — flagged stale after ~6 months untouched so it gets reviewed |
preference |
how you like things done | never — recency-weighted in recall so it isn't outranked |
episodic |
what happened (outcomes, incidents) | swept after ~6 weeks |
session |
scratch for the current session | never indexed; swept after ~24 h |
A file without frontmatter counts as project-fact — the never-auto-deleted
category, so an uncategorized memory can never be silently swept. At write
time create rewrites a missing or unrecognized category to episodic,
so machine-written content with no declared category expires by default
(~6 weeks) rather than accreting as immortal project-fact; the read-time
project-fact fallback above only matters for legacy files already on disk.
How recall reaches the model¶
Two seams, chosen so recall never busts the prompt cache:
- A stable index, once per session — the memory file listing (plus a
curated
MEMORY.md, if you keep one) is composed into the cached system prompt at session start (capped ~200 lines / 25 KB)./reloadre-composes it. - Per-turn top-5 recall — each user turn is matched against a dedicated
FTS5 index over the memory corpus and the best 5 snippets ride the volatile
<system-reminder>channel (the same one the date/git block uses), so the cached prefix stays byte-identical. Anything past a snippet, the modelmemory views on demand.
The recall index is deliberately separate from the workspace search index (its own DB inside the memory root) — a casual preference must never outrank authoritative repo docs, and vice versa.
The memory tool¶
One tool, six commands (view / create / str_replace / insert /
delete / rename):
viewlists a directory (2 levels) or shows a file with line numbers (view_range: [start, end]paginates).createwrites a file — and overwrites an existing one.delete/renamerefuse to touch the memory root itself.- Every path is canonicalized and confined to the memory root — traversal
(
../, encoded, absolute, symlink escapes) is rejected outright.
Permissions: mutations are writes (edit kind — auto-applied in auto, ASK
in strict, denied while planning),
while view is auto-allowed in both modes. The
mode gate is per-command, so strict only prompts when the model actually
changes a memory.
Workspace scoping¶
Memory is workspace-scoped by default — a session in repo A does not see repo B's facts, and the reflection pass cannot overwrite or delete another workspace's memories. The mechanism is one stamped frontmatter key, one read-side filter.
- Auto-stamp on
create.project-factandepisodicfiles gain aworkspace: <realpath-of-workdir>frontmatter line at write time.preferencestays global by design (a "how you like things" fact should surface everywhere). - Hard filter on read. The cached
# Memoryblock lists only the current workspace's files (plus global/legacy) — foreign-workspace files collapse to one summary line. Per-turn recall and dashboard search apply the same filter.memory viewis the deliberate cross-workspace surface (it goes through the same containment guard as every other read). - Opt out per file. Add an explicit
workspace: global(or any custom label) frontmatter line and the stamp is honored verbatim — the file surfaces in every workspace whose filter matches. - Legacy files (no
workspace:key) = global. They decay naturally; no migration script runs. To make a legacy file workspace-specific, add an explicitworkspace:value viastr_replace. - Moved repos go quiet. The stamp is
os.path.realpath(workdir), so a repo moved to a new path keeps its memories but they stop surfacing — usestr_replaceto re-stamp if that's what you want. One carve-out: a linkedgit worktreecheckout stamps its main working tree's path, so a memory written by a worktree-isolated subagent stays reachable after the worktree is deleted. - Dashboard search is unfiltered.
memory_api._searchpassesworkdir=None, so the operator's view sees everything (falsy workdir is the deliberate operator-unfiltered surface). - The dashboard's per-file list (
GET /api/memory) now includes aworkspacefield per file, so operators can see at a glance which workspace a given memory is scoped to.
See DR 0088 for the
full design and the accepted edge cases (worktree-isolated subagent
sessions, hostile workspace: values, the frontmatter whitespace-
collapsing mismatch, str_replace opt-out, dashboard unfiltered).
Memory capture at session end¶
When a session ends, kin runs one bounded memory reflection pass — a short
extra agent turn over the live memory tool with the full transcript in
context, so durable facts (stable preferences, project facts, notable
outcomes) persist across sessions. It prefers updating an existing file over
creating a near-duplicate, and writes nothing when nothing durable emerged.
Every write goes through the same guarded store as an in-conversation edit.
Reflection fires automatically on every client, not just the TUI:
- TUI — at quit, via a two-phase quit (see
DR 0089). The first ctrl+c (or
ctrl+q, the command palette, or
/exit//quit) flips the status bar tocurating memory… · ctrl+c to skipand runs the bounded ~20 s reflection pass while the UI is still alive. A second ctrl+c (or esc) skips the wait and exits immediately. Exit paths that bypassaction_quit(signal-killed runs, headlessapp.exit()calls) keep a guarded fallback reflection at unmount — same pre-Workstream-B UX on those paths, just no visible curation window. kin -p/ scheduled jobs — after a run that ends cleanly (done_reason: stop), bounded to ~90 s. The pass is best-effort and can never change the run's outcome:done_reason/final_text/needs_humanare snapshotted before it runs, so a job that succeeded is never recorded aserrorbecause of reflection. Runs that end any other way (error,token_budget,turn_cap,loop_detected) skip it.- On demand —
/memoryruns the same reflection pass mid-conversation, and/memory initbootstraps an initial memory set from the repo'sAGENTS.md/STATUS.md/README/docs/.
Disable the automatic pass with memory_reflect = false /
KIN_MEMORY_REFLECT=0 (the /memory command still works either way).
Settings¶
# ~/.kin/settings.toml
memory_enabled = true # default; false unregisters the tool + recall (project-ok)
memory_reflect = true # default; false skips the end-of-run reflection pass (project-ok)
memory_dir = "~/.kin/memory" # default; GLOBAL-ONLY (see below)
memory_dir is global-only: memory writes auto-apply in auto mode
because they're contained to the root, so a cloned repo's project file must
never be able to repoint that contained write surface at an arbitrary
directory. For a workspace-local store, set KIN_MEMORY_DIR (or the global
key) yourself.
On Outpost¶
The dashboard has a Memory card: the on/off toggle, the
root path, and per-category stats with a file list (names and titles only —
content stays in the store; read it in a session with memory view). The
store lives in the outpost-kin named volume, so memories survive redeploys.
The run page (a separate document — GET /runs/<job>/<run>) also has a
Memory pane (WP8 — docs/decisions/0031-run-inspection.md).
This is the FIRST body-crossing exception to the memory stats-only posture,
intentionally narrow:
- Search (
GET /api/memory/search?q=) — wrapsMemoryIndex.recall(q, k=10). Body NEVER crosses the wire; only(path, category, title, snippet)tuples surface. - Body reads (
GET /api/memory/body?path=) — through the SAMEstore.resolve_pathcontainment guard thememorytool uses (backslash, URL-encoded,.., dot-leading, symlink escape all refused at the seam). 64 KB cap on body size; bytes NEVER cross for oversized content. Sanitized viasanitize_model_id(REFERENCE.md § Display sinks) before render.
Every body read writes one audit_log row (action body_read, target
the memory rel-path). The audit module is best-effort — an audit failure
MUST NOT break the read. See container/dashboard/audit.py.
The pane carries a persistent "Agent-written content — treat as data" banner
to frame the body content as untrusted input (a defense-in-depth reminder
even after server-side sanitization). Both endpoints are human-door only
(under /api/, behind the SSO gate via the dashboard's /api/* path
prefix) — the v1 machine door /api/v1/ gains nothing and loses nothing,
so the PROTOCOL wire contract stays unchanged.
Why this exception exists¶
Memory bodies are agent-written text — exactly the kind of content that's
useful to inspect from the browser but is also exactly the kind that can
smuggle prompt-injection vectors. The compromise: a single browser-only
surface, audited, escaped, capped, with a framed-untrusted banner so the
operator never forgets the threat model. If you need raw memory reads for
debugging, the session's memory view tool is still the canonical path.