Skip to content

[memory] Local store, wired recall, and one explicit writer #293

Closed

Description

@yusiwen

Context

Roadmap B1 and B2 — see docs/roadmap.md
and the anchor issue #168. The capability exists and is inert: agent/memory.go implements
Remember/Recall/Forget/List against a remote HTTP service, Agent.Memory is declared and read
at the top of Run (agent/agent.go:457), and nothing anywhere calls it — no caller of Remember,
no assignment of Agent.Memory, no memory key in any config file.

docs/memory-decision.md (merged) fixes the v1 write path: one writer, a /remember command the user
types; automatic end-of-turn extraction waits for B3. This issue is the work that decision unblocks.

The two roadmap entries are one unit of delivery on purpose: wiring recall without a store that can hold
anything ships a feature that recalls an empty list forever, and a store without a caller ships a
backend nobody reads. Splitting them creates that in-between state.

What to build

S1 — config and the layer gate.
config.MemoryConfig (enabled, backend, dir, max_entries, max_bytes, recall_limit,
base_url, bank) with defaults enabled: true, backend: "local"; a MemorySpec() resolver in the
shape of TrajectorySpec(); and UserLayer.ResolveMemory, which honours an egress request (a
network backend, or any base_url) only when the user's own ~/.tinycode/config.json asked for it —
a project-local .tinycode/config.json that tries is ignored and the ignore is logged, exactly as
UserLayer.ResolveTrajectoryCapture does for capture: full. config.example.json gains the section
with a $comment saying what leaves the machine.

S2 — the local store.
agent/memory_local.go implementing types.MemoryStore over a JSONL file at
<dir>/<project-hash>.jsonl (default dir ~/.tinycode/memory, 0600), one line per entry:

  • id = first 16 hex of sha256(key NUL value), so Remember is idempotent;
  • types.Memory gains ID, Source and CreatedAt; MemoryStore.Forget takes the id List
    returns (the parameter is renamed; there are no callers to migrate);
  • bounds: max_entries (default 2000) and max_bytes (default 8 MiB), oldest dropped first by
    rewriting the file through a temp file and a rename;
  • recall is lexical — lowercase tokenisation, a small stopword list, tf-idf over the store plus a
    recency tiebreak, top recall_limit (default 5) above a minimum score. No new dependency, and no
    pretence of semantics: that is B3;
  • Forget really deletes; the Hindsight no-op must not be the model for this backend;
  • in-process sync.Mutex serialises writers. Cross-process safety is a single-line append and nothing
    more — a compaction that races another process can lose a queued line. That limit is recorded here,
    not silently assumed away.

S2b — the store is not a tool path.
tool.SetMemoryRoot plus a refusal in CheckPathAccess, mirroring tool/trajectory_store.go: a
memory the agent can rewrite is not a memory. The default dir lives outside the project root, and a
tool that tries to write it is refused with the same wording as the trajectory and checkpoint stores.

S3 — wiring and commands (its own PR, after this one). S4 (Hindsight contract) and S5 (docs) are
listed in the slices above this issue's first PR and are not part of it.

Acceptance

  • UserLayer.ResolveMemory returns ignored=true for a project-local egress request, and the merged
    config still resolves to the local backend.
  • The store survives a reopen: write two facts, construct a second instance over the same directory,
    List returns both.
  • Remembering the same (key, value) twice leaves one entry.
  • Past max_entries, the oldest entry is gone and the newest are all present.
  • Forget(id) removes exactly one entry, and a reopened store does not return it.
  • Eight concurrent Remember calls all land (no lost line).
  • tool.CheckPathAccess refuses a write beneath the memory root with the reserved-store wording.
  • go test ./... -count=1, -race, gofmt -l, go vet, staticcheck clean.

Out of scope

Automatic summarisation (see docs/memory-decision.md for the reopen condition), embeddings and vector
search (B3), memory as a model-callable tool (#188), an approval queue, cross-project memory sharing,
and cross-process locking.

Activity

  1. added
    featureA new capability rather than an improvement to existing behaviour
    on Oct 25, 2026
  2. yusiwen commented on Oct 25, 2026

    @yusiwen
    OwnerAuthor

    S1–S3 landed; S4 is what remains.

    • S1 + S2 — #294 (merged as 3986849): the
      memory config section with the user-layer egress gate, LocalMemory (JSONL per project, bounded,
      id-deduplicated, lexical recall, real delete), types.Memory gaining ID/Source/CreatedAt, and
      tool.SetMemoryRoot making the store unreachable from the tool layer.
    • S3 — #296 (merged as 4eeac93): the composition
      root, /remember, /memory, /memory forget <id>, the capped and logged recall step, and the
      injected block worded as data rather than instructions. docs/roadmap.md now records B1 and B2 as
      Delivered — [memory] Local store, wired recall, and one explicit writer #293
      .
    • S4 (open) — the Hindsight adapter is reachable by configuration but unverified: its recall/list
      decoders read content/memory_units where the current API returns text/items, and Forget
      reports ErrMemoryDeleteUnsupported. The next increment is a probe against a real server, not a
      decoder change made from the docs alone.

    Measurements: go test ./... -count=1 and -race → 16 packages ok; 1208 test functions in the main
    module (find . -name '*_test.go' -not -path './tuiprobe/*' -print0 | xargs -0 grep -h '^func Test' | wc -l). Recall quality is unmeasured — it is lexical until B3.

    What v1 deliberately does not do is in docs/memory-decision.md: no automatic summarisation, so no
    second model call and no store that fills itself.

  3. yusiwen commented on Oct 27, 2026

    @yusiwen
    OwnerAuthor

    Closing as delivered. The slices:

    • S1 + S2 — #294 (3986849): the memory config
      section with the user-layer egress gate, LocalMemory (JSONL, one shard per project, bounded by
      entries and bytes, id-deduplicated, lexical recall, real delete), types.Memory gaining
      ID/Source/CreatedAt, and tool.SetMemoryRoot making the store unreachable from the tool layer.
    • S3 — #296 (4eeac93): the composition root,
      /remember, /memory, /memory forget <id>, the capped and logged recall step, and the injected
      block worded as data rather than instructions.
    • Endpoint fix — #301 (10d1627): the hindsight
      backend now requires base_url, the bank comes from config, and the hard-coded default endpoint is
      gone from the tree.
    • S5 (docs) — carried by the same PRs: roadmap B1/B2 delivered, CODEBASE's memory section rewritten,
      README bullet, example config.

    S4 moved out, not dropped: #302 owns the Hindsight
    adapter's contract (probe against a real deployment, decoder alignment, ids, Forget, and the
    OPENAI_API_KEY fallback). The remote backend is reachable by configuration today, but it has never
    been verified against a served response — that is #302's whole subject.

    Measurements, for the record: 1230 test functions in the main module; go test ./... -count=1 and
    -race green at the merge of each PR; recall quality measured separately in #297 (the lexical ranker
    answers 0 of 6 paraphrases, the embedding path answers 4–5, and the cross-lingual column is decided by
    the model).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    featureA new capability rather than an improvement to existing behaviour

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions