Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/codeaf-demo-home/seed.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func seedDemoHome(dir string, now time.Time) (builtHome, error) {
}
built.Standing = orders

memories, err := writeMemories(brain)
memories, err := writeMemories(brain, projects[firstProjectName].dir)
if err != nil {
return built, err
}
Expand Down
24 changes: 21 additions & 3 deletions cmd/codeaf-demo-home/seed_memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ package main
import (
"fmt"

"github.com/Agent-Field/codeaf/internal/gitidentity"
"github.com/Agent-Field/codeaf/internal/store"
)

Expand Down Expand Up @@ -73,12 +74,29 @@ var demoMemories = []demoMemory{
}

// writeMemories puts every memory into the store and answers how many it wrote.
func writeMemories(brain *store.Store) (int, error) {
//
// THE PROJECT ROWS CARRY A REAL OWNER. A scope word alone would land them in
// the quarantine ([store.OwnerLegacyProject]) — the honest answer for a row
// whose project nobody proved, and the wrong one for a fixture whose project
// is sitting right there: the demo home would show its project shelf all
// quarantined rows and never a live project memory. The key is minted the way
// every session mints one, from the workspace itself, so the demo's memory is
// visible exactly where the demo's conversations run.
func writeMemories(brain *store.Store, projectDir string) (int, error) {
key, err := gitidentity.ProjectKey(projectDir)
if err != nil || key == "" {
return 0, fmt.Errorf("mint the demo project key: %w", err)
}
projectOwner := store.OwnerProject(key)
written := 0
for _, held := range demoMemories {
made, err := brain.AddMemory(held.memory)
memory := held.memory
if memory.Scope == store.MemoryScopeProject {
memory.Owner = projectOwner
}
made, err := brain.AddMemory(memory)
if err != nil {
return written, fmt.Errorf("remember %q: %w", held.memory.Title, err)
return written, fmt.Errorf("remember %q: %w", memory.Title, err)
}
// One call per count: the two counters are written together from one
// confirmation after a turn, and there is no door that sets them to a
Expand Down
67 changes: 66 additions & 1 deletion cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"github.com/Agent-Field/codeaf/internal/effort"
"github.com/Agent-Field/codeaf/internal/enginehost"
"github.com/Agent-Field/codeaf/internal/env"
"github.com/Agent-Field/codeaf/internal/gitidentity"
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/home"
"github.com/Agent-Field/codeaf/internal/leave"
Expand Down Expand Up @@ -980,6 +981,20 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// memory row is on, which is what makes "memory off makes no calls" a
// fact about the wiring instead of a branch every caller has to keep.
Memory: proc.Memory,
// AND THE CONVERSATION INDEX, WHICH IS NOT THE MEMORY ANY MORE. It is
// the process's search store — the same handle as Memory when memory
// is on, and its own handle of the same file when memory is off — so
// the search verb survives the memory row. The chat journal indexes
// the conversation into it either way; the memory row now decides
// only what is REMEMBERED (internal/session's memory.go).
MemoryIndex: proc.Search,
// AND THE PROJECT THIS SESSION CAN PROVE IT IS IN. The key is minted
// from the workspace's origin remote, or its canonical path when there
// is no remote, and it is what turns a `project` scope word into the
// owner a memory really belongs to. Empty (an error, or a workspace
// that could not be resolved) quarantines project writes rather than
// guessing.
MemoryProjectKey: v3ProjectKey(workspace),
// AND THE SKILL SHELF, which is the line above when memory is on and a
// shelf of the skill folders alone when it is off ([v3SkillShelf]):
// the skills a person installed for another harness are not memory,
Expand Down Expand Up @@ -2100,6 +2115,35 @@ func v3BuiltinApprovals() map[string]any {
// made by [store.Open] itself, which is the one door every caller goes through,
// and what reaches the line below is only ever a real reason: it names the file
// and says what the disk said about it.
// v3SearchStore opens the conversation index with the memory row OFF. It is
// the same file the memory store is ([defaultChatDB]), opened when the memory
// store was not — and its failures are the same non-fatal shape: a person who
// wanted a conversation gets one, and the search verb answers what a missing
// index honestly answers.
func v3SearchStore(profileDir string) *store.Store {
brain, err := store.Open(defaultChatDB())
if err != nil {
fmt.Fprintln(os.Stderr, "conversation search is off for this session: "+err.Error())
return nil
}
return brain
}

// v3ProjectKey mints the key this launch's workspace can prove it is. It is
// the origin remote's normalized identity when the workspace is a git
// repository with one, and the canonical path's hash when there is none —
// never a guess. An error or an empty workspace answers "", and a session
// carrying "" quarantines its project writes rather than attributing them
// (internal/gitidentity's project.go states the rule; internal/session's
// memory.go states where the empty key lands).
func v3ProjectKey(workspace string) string {
key, err := gitidentity.ProjectKey(workspace)
if err != nil || strings.TrimSpace(key) == "" {
return ""
}
return key
}

func v3Memory(profileDir string) *store.Store {
if !config.MemoryEnabledAt(profileDir) {
return nil
Expand Down Expand Up @@ -2137,8 +2181,29 @@ func (s v3Brain) ChangedSince(t time.Time) (int, int, error) {
return s.brain.MemoryChangedSince(t)
}

// ListMemories answers the surface's scope word as the OWNERS it means. The
// person's overview reads the whole store (the snapshot above does), so an
// empty scope stays "every owner"; `project` is the word the surface spells
// for "the project rows", and it answers every project's rows — this is the
// person's own audit page, not a session's retrieval, and the session-scoped
// reads live in internal/session. A scope this build does not spell answers
// nothing rather than everything.
func (s v3Brain) ListMemories(scope string, limit int) ([]store.Memory, error) {
return s.brain.ListMemories(scope, limit)
switch strings.TrimSpace(scope) {
case "":
return s.brain.ListMemories(nil, limit)
case store.MemoryScopeUser:
return s.brain.ListMemories([]string{store.OwnerUser}, limit)
case store.MemoryScopeEnv:
return s.brain.ListMemories([]string{store.OwnerMachine}, limit)
case store.MemoryScopeProject:
// EVERY PROJECT'S ROWS, INCLUDING THE QUARANTINE: this is the person's
// audit shelf, not a session's retrieval — a project-scoped listing
// that answered only the quarantine would hide every real project's
// memories the day the first one was written with an owner.
return s.brain.ListProjectMemories(limit)
}
return nil, nil
}

func (s v3Brain) UpdateMemory(id, title, text string, tags []string) error {
Expand Down
8 changes: 8 additions & 0 deletions cmd/codeaf/chatv3_layout.go
Original file line number Diff line number Diff line change
Expand Up @@ -415,12 +415,20 @@ func v3PointAt(cfg session.Config, place session.Place) (session.Config, error)
if workspace := strings.TrimSpace(place.Workspace); workspace != "" {
cfg.Workspace = workspace
}
cfg.MemoryProjectKey = v3ProjectKey(cfg.Workspace)
return cfg, nil
}
if err := prepareOwnedWorkspace(place); err != nil {
return cfg, err
}
cfg.Workspace = place.Work()
// AND THE PROJECT KEY FOLLOWS THE WORKSPACE, because the key IS the
// workspace's provable identity: a conversation moved to another session
// folder — /new, a resume, a picker — is a conversation that may be in a
// different project, and a memory scoped to the wrong key is a memory in
// the wrong place. Recomputed here, where the workspace is settled, so
// every door through this function agrees about whose project this is.
cfg.MemoryProjectKey = v3ProjectKey(cfg.Workspace)
return cfg, nil
}

Expand Down
81 changes: 80 additions & 1 deletion cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import (
"context"
"errors"
"fmt"
"github.com/Agent-Field/codeaf/internal/gitidentity"
"os"
"path/filepath"
"strings"
Expand Down Expand Up @@ -92,6 +93,17 @@ type v3Process struct {
// answer to give. Each conversation still gets its own memory pass and its
// own context, which is per-agent already.
Memory *store.Store
// Search is the store the CONVERSATION INDEX lives in — the same file as
// Memory when memory is on, and the same file opened alone when it is off.
// THAT IS THE WHOLE DECOUPLING: the memory row turns off what the session
// REMEMBERS, never what the next conversation can SEARCH. It used to be
// one field and one decision, so turning memory off also closed the
// conversation index and the search verb with it — a person who turned
// memory off lost a feature they never asked to lose.
//
// Search is legitimately PROFILE-scoped, for Memory's reason (one handle
// per file per process; two pools on one file arbitrate through a timeout).
Search *store.Store
// Skills is the skill shelf every conversation this process opens reads:
// the Memory store itself when memory is on, and otherwise a store of its
// own that holds nothing but the skills the folders on disk hold
Expand Down Expand Up @@ -256,6 +268,16 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) {
wirePoolIndex(settings.ProfileDir)
shelf := newV3ModelShelf(models, discovery)
shelf.setSources(settings.Sources)
memory := v3Memory(settings.ProfileDir)
// THE SEARCH STORE IS THE MEMORY STORE WHEN MEMORY IS ON — one handle, one
// file — and its own handle of the same file when memory is off. Never two
// handles at once: [v3Process.Memory]'s law is about one file, one pool.
var search *store.Store
if memory != nil {
search = memory
} else {
search = v3SearchStore(settings.ProfileDir)
}
process := &v3Process{
Settings: settings,
ProfileDir: settings.ProfileDir,
Expand All @@ -265,12 +287,20 @@ func openV3ProcessWith(door string, askKey bool) (*v3Process, error) {
processStop: processStop,
Shelf: shelf,
Harnesses: subharness.Default(),
Memory: v3Memory(settings.ProfileDir),
Memory: memory,
Search: search,
Artifacts: artifactsIndexPath(),
Conns: v3Connect(settings.ProfileDir),
LaunchDir: launchDir,
}
process.Skills, process.skillsDir = v3SkillShelf(process.Memory)
// AND THE QUARANTINE IS GIVEN ITS OWNERS BACK, once per process, only when
// there is something to give: rows the migration could not attribute are
// re-homed to the project their source session provably ran in. This is
// the "recover authoritative source ownership" half of the migration — the
// store quarantined rather than guessed, and the disk (where a session's
// workspace is a fact) is what does the proving.
v3RehomeLegacyMemories(memory)
process.creditWatcher = newV3CreditWatcher(process)
process.startPlaceSweep()
return process, nil
Expand Down Expand Up @@ -650,6 +680,14 @@ func (p *v3Process) closeAll() {
if p.Memory != nil {
_ = p.Memory.Close()
}
// THE SEARCH HANDLE CLOSES ONLY WHEN IT IS NOT THE MEMORY'S. When memory
// is on the two fields are one handle and the close above already took
// it; when memory is off the search store was opened on its own, and a
// handle left open here is a WAL that never checkpoints and a file the
// next process reads mid-write.
if p.Search != nil && p.Search != p.Memory {
_ = p.Search.Close()
}
// The memory-off shelf goes with the process that built it: it was only
// ever a reading of the skill folders, and the next launch reads them
// again.
Expand Down Expand Up @@ -948,3 +986,44 @@ func (p *v3Process) liveSettings(base config.Config) func() config.Config {
return live
}
}

// v3RehomeLegacyMemories moves every quarantined memory whose source session
// can be proven to a workspace into that workspace's project owner, and leaves
// the rest in quarantine — a row whose session cannot be found has an owner
// nobody can name, and attributing it anyway is the guess the migration
// refused to make.
//
// It runs at process open, after the store is open and before the first
// conversation builds, and only when the quarantine holds rows: one pass, a
// bounded read of the places root, and nothing when the migration's work was
// already done.
func v3RehomeLegacyMemories(brain *store.Store) {
if brain == nil {
return
}
rows, err := brain.QuarantinedMemories(500)
if err != nil || len(rows) == 0 {
return
}
bySession := session.SessionWorkspaces()
owners := map[string][]string{}
for _, row := range rows {
workspace := bySession[row.SourceSession]
if workspace == "" {
continue
}
key, err := gitidentity.ProjectKey(workspace)
if err != nil || strings.TrimSpace(key) == "" {
continue
}
owner := store.OwnerProject(key)
owners[owner] = append(owners[owner], row.ID)
}
for owner, ids := range owners {
if _, err := brain.RehomeMemories(ids, owner); err != nil {
// A failed re-home is not fatal and not silent: the rows stay in
// quarantine, and the complaint names where.
fmt.Fprintln(os.Stderr, "memory: could not re-home quarantined rows: "+err.Error())
}
}
}
9 changes: 6 additions & 3 deletions cmd/codeaf/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -1098,10 +1098,13 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote.
return remote.LedgerReading{Lines: lines, Held: held}
},
Search: func(terms string, limit int) ([]store.ConversationHit, error) {
if proc.Memory == nil {
return nil, errors.New("memory is off")
// THE SEARCH STORE, NOT THE MEMORY STORE. Memory off used to take
// this down with it; the process keeps the index open either way
// ([v3Process.Search]).
if proc.Search == nil {
return nil, errors.New("conversation search is unavailable")
}
return proc.Memory.SearchConversations(terms, limit)
return proc.Search.SearchConversations(terms, limit)
},
Memory: v3MemorySeam(proc.Memory),
Archive: session.SetArchived,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
kind: added
title: memories belong to an owner, every write walks one door, and failure is visible
pr: 1745
surface: [chat, engine, docs]
invalidates:
- "The memory `scope` column was a label, not an owner: the router's shortlist, the dedup search and a forget query were unfiltered, so a project's memory could surface — and be updated — in an unrelated project. Every memory now carries an `owner` (`user`, `machine`, `project:<key>`), every read that can put a memory in front of a model filters on it, and the reads without one are refused rather than allowed to widen."
- "A project's identity was the word `project` and nothing else. It is now proved from the workspace: the git origin's normalized URL (an SSH clone and an HTTPS clone of one repository are one project), or the canonical path when there is no remote. A memory whose project cannot be proved is quarantined, never re-attributed to the person at large; rows the migration can prove — through their source session's own workspace record — are re-homed to that project."
- "The routed `remember…` command wrote through AddMemory directly, the one door with no duplicate check, so the same preference said three ways through three mouths could be three rows. Every mouth — /remember, the remember tool, the routed command, the post-turn extraction and the legacy import — now walks one door with one dedup rule, and both outcomes (kept, skipped) are journaled."
- "Memory writes failed silently: an extraction the settle could not finish, a store refusal, an import that stopped halfway — nothing anywhere recorded it. Failures are now journaled (`memory_write_failed`, with the door and the reason), said once per five-minute window as a dim line, and fall back to `v3/memory-failures.log` when the store itself is what failed."
- "The legacy memory.md import renamed the file whether or not the rows landed, and ignored the scanner's own error. The rename now happens only after every row landed; a scan error or a failed row leaves the file in place and retries later, and the retry is idempotent — kept lines are skipped, never duplicated."
- "An explicit save needed the small memory model: when the decider could not answer, /remember lost the words. The decider's failure is journaled and the save still happens through the store's own dedup door, which needs no model."
- "Turning memory off also closed the conversation index, so `search_conversations` disappeared with it — a feature nobody asked to lose. The conversation index is now its own handle on the same file: the memory row decides what is REMEMBERED, never what is SEARCHABLE, and a memory-off conversation still indexes itself and still carries the search verb."
- "The FTS5 index was assumed. It is now proved by a probe at open: a build whose SQLite has no FTS5 opens with memory intact and only the lexical tier quiet, with no install step and no fallback data store."
- "The memory listing could not page: a caller asked for the first N and stopped. The store has keyset pagination (`MemoryPage`) on the same newest-touched ordering, with a cursor that survives writes landing between pages."
- "Sync was promised by the schema and delivered by nothing. The store now exports memory events after a cursor (`ExportMemoryEvents`) and folds a batch idempotently (`ApplyMemoryEvents`) with a receiver's owner policy: adds re-deliver as skips, tombstones never resurrect, first-applied wins on concurrent supersession, and refused owners are skipped, never widened. No transport ships with this — PR #1738's pairing carries it later."
---

The memory architecture review (docs/memory-architecture.md) found four
structural defects in the pipeline — scope leak, split write doors, silent
failure, lossy import — and this change lands the substrate that closes them.
The architecture document is updated in the same change to say what shipped
and what is deliberately deferred (embedding vectors, team owners, the sync
transport).
Loading