Skip to content
Merged
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
53 changes: 28 additions & 25 deletions PERF.md
Original file line number Diff line number Diff line change
Expand Up @@ -2910,31 +2910,34 @@ paths. No saved conversation is resumed to display or select its card.

## The memory block

Every active memory line in a conversation's scope — for the person everywhere, for
this machine, for this project — rides one `<memory>` block in message[0], in a
stable order (person, machine, project; oldest first) and with no age or other
clock-derived byte, so it is byte-identical from turn to turn until a line changes,
which is what keeps the cached prefix warm (`prefixcache_test.go`). The block is
bounded at **4,800 runes** of lines (`store.MemoryBlockRunes`,
`internal/store/memory_room.go`), about 1,200 tokens. **The store refuses any change
that would carry any conversation's block past it** (`store.ErrMemoryFull`) — a new
line, a replacement, a reworded line, a move or a put-back — measured inside the write
transaction, against every block the line's owner reaches (a line for the person or this
machine is in every project's block). Only a store an earlier build filled with no bound
can hold more than fits; then `store.FitMemories` lets that build's rules, then the
project's lines, then the machine's, then the person's ride, newest first within each,
and the conversation says once how many did not; the Memories tab counts them. Each line
is at most **512 runes** (`store.MemoryTextRunes`) with an 80-rune title. The room check
reads every active line once per write; the store's lines are bounded by the same rule,
so that read is a few hundred rows at most.

The read is **one indexed statement per turn start** (`Store.MemoriesFor`, over the
owners the conversation sees); a failed read keeps the last block rather than
re-pricing the prefix to say nothing. A task worker's brief closes on the same section,
read once per run (`TaskGraph.memoryWorld`). No retrieval,
evidence projection, binding read or background tidy exists: the store's work is
bounded by the scope's own line count, and the event journal continues to grow as the
durable record.
Memory storage is independent of the prompt budget. Writes, edits, moves and
restores are not refused because a conversation is full. Each line remains
bounded at **512 runes** (`store.MemoryTextRunes`), with an **80-rune** title.

The ordinary working set has a **4,800-rune** line budget
(`store.MemoryBlockRunes`). Explicit person pins and legacy rules ride first
and may exceed this soft budget. Other in-scope memories are selected locally
with a durable-rule floor of **40%** of the remaining budget and
specificity-first spending within the durable and contextual shares. Derived
semantic kinds keep legacy status paragraphs out of the rule share. Activation
remains the admission threshold and tie-break. The block ends with
`· N more in memory` when some stored lines do not ride. The Memories tab
shows each visible line's admission status and reason. Archived admission is
separate from forgotten or superseded storage status.

Selection makes no model or embedding calls. Only used, confirmed and merged
events reinforce practice; surfaced and
retrieved events remain neutral, and contradicted events provide negative
feedback. Admitted rune totals and shares by derived semantic kind are logged
as aggregate `memory_admission` JSON, with pin runes separated from ordinary
budgeted runes and an additional per-kind breakdown excluding pins; no memory text, IDs or owners are logged. Conversation-stage
records describe the final set after dwell, and the Memories tab shows that
set's composition. A failed read retains the previous block; selection keeps
admitted lines while conversation context is unchanged to preserve the cached prompt prefix.
Daily local maintenance refreshes traces and associations while the clock
runs, with catch-up on reopen. Semantic status consolidation and optional
write-time semantic embeddings are not implemented by this change. Performance
targets in a design proposal are not measurements; the focused store benchmarks are the evidence for this path.

## Conversation deletion completion

Expand Down
10 changes: 5 additions & 5 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -1224,7 +1224,7 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio
// which is the same absence at a finer grain.
if activeListsModels {
cfg.Media = v3MediaClient(mediaSettings)
cfg.MediaModel = v3MediaModel(activeModels, settings.ProfileDir, cfg.RolesSource)
cfg.MediaModel = v3MediaModel(activeModels, settings.ProfileDir, cfg.RolesSource, cfg.OneModel)
cfg.MediaPick = v3MediaPick(activeModels)
}

Expand Down Expand Up @@ -1660,10 +1660,10 @@ func applyV3Governance(cfg session.Config, profileDir string, yolo, oneModel boo
// once at the seam every errand passes through, which is the only place a
// third crew-only caller can be made right without knowing the flag exists.
//
// The media slots are deliberately untouched. Vision, image, speech and
// video are capability-qualified — a text model cannot answer view_image —
// so settling them on the session model would not make the run single-model,
// it would make it broken. The flag says every TEXT call, and means it.
// Media slots remain capability-qualified rather than following text roles.
// With this flag an explicit media pin is strict: an unsupported pinned
// operation is unavailable instead of falling through to another model.
// Unpinned media keeps its normal discovery and fallback behavior.
// The rows are not even read under the flag. Reading them only to discard
// the answer would make a malformed pins row stop a launch that had already
// said it does not care what the pins say.
Expand Down
2 changes: 2 additions & 0 deletions cmd/codeaf/chatv3_clock.go
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,8 @@ func runClock(args []string) error {
defer cancel()
ctx, retire := retireWhenReplaced(ctx, store, logf)
defer retire()
stopMemoryMaintenance := startClockMemoryMaintenance(ctx, proc.Memory, logf)
defer stopMemoryMaintenance()
err = clock.Run(ctx)
if errors.Is(err, automation.ErrHeld) {
return nil
Expand Down
4 changes: 3 additions & 1 deletion cmd/codeaf/chatv3_guard_class_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,8 @@ import (

// TestV3ProcessGuardGoClass is the inventory law for goroutines started by a
// v3 process or launch. A long-lived writer must identify both its stop and the
// closeAll join. chatv3/models and engine/models name the process waiter, joined
// closeAll join, or the clock-owned equivalent before its store closes.
// chatv3/models and engine/models name the process waiter, joined
// since #1179, and the internal/catalog warm each one starts, which closeAll now
// cancels and joins through Models.Close. chatv3/sweep-home is joined too, by
// stopPlaceSweep since #1276. No row is known-open now; a new one that is must
Expand All @@ -33,6 +34,7 @@ func TestV3ProcessGuardGoClass(t *testing.T) {
"chatv3/host-reap": {kind: "one-shot", stop: "exec.Cmd.Wait returns when the replaced ssh child exits"},
"chatv3/host-follow": {kind: "one-shot", stop: "remote Follow channel closes with the host connection"},
"chatv3/telemetry-events": {kind: "one-shot", stop: "source event channel closes and countedEvents returns"},
"memory/maintenance": {kind: "joined writer", stop: "startClockMemoryMaintenance cancels its child of the clock context", join: "runClock defers stopMemoryMaintenance, which cancels and waits on done before the earlier openV3ClockProcess stop defer closes proc.Memory; TestClockMemoryMaintenanceStopsBeforeStoreCloses proves the join"},
"automations/retire": {kind: "one-shot", stop: "the clock's own context ends when `codeaf clock` leaves, or the watcher cancels it on finding the binary replaced"},
}

Expand Down
21 changes: 20 additions & 1 deletion cmd/codeaf/chatv3_host.go
Original file line number Diff line number Diff line change
Expand Up @@ -1473,6 +1473,7 @@ func (h *hostLedger) start(since time.Time) {
// provenance methods remain synchronous because they are reached only by an
// explicit key and their answer decides what the surface may say happened.
type hostMemory struct {
requestedLimit int
client *remote.Client
duty hostDuty
mu sync.Mutex
Expand All @@ -1487,7 +1488,24 @@ func newHostMemory(far hostFar) *hostMemory {
return h
}
func (h *hostMemory) prime() { h.refresh(500, time.Time{}) }

// requestRows keeps the user's expanded window without holding the lock across
// background scheduling or any network call.
func (h *hostMemory) requestRows(limit int) int {
h.mu.Lock()
defer h.mu.Unlock()
h.requestedLimit = max(h.requestedLimit, limit)
return h.requestedLimit
}

func (h *hostMemory) needsRows(limit int) bool {
h.mu.Lock()
defer h.mu.Unlock()
return limit > h.requestedLimit
}

func (h *hostMemory) refresh(limit int, at time.Time) {
limit = h.requestRows(limit)
h.duty.run("chatv3/host-memory", "", func() {
s, err := h.client.Snapshot(limit)
learned, letGo, changedErr := h.client.ChangedSince(at)
Expand All @@ -1501,7 +1519,8 @@ func (h *hostMemory) refresh(limit int, at time.Time) {
}
func (h *hostMemory) Snapshot(limit int) (store.MemoryShelves, error) {
s, _, _, known := h.snapshot()
if !known {
needsMore := h.needsRows(limit)
if !known || needsMore {
h.refresh(limit, time.Time{})
}
return s, nil
Expand Down
2 changes: 1 addition & 1 deletion cmd/codeaf/chatv3_media.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ var v3MediaVerb = map[string]string{
// are read at CALL time rather than closed over as values: a person who picks a
// drawing model in the settings sheet expects the next picture to come from it,
// not the next launch.
func v3MediaModel(models *catalog.Catalog, profileDir string, source roles.Source) func(string) string {
func v3AutomaticMediaModel(models *catalog.Catalog, profileDir string, source roles.Source) func(string) string {
return func(modality string) string {
modality = strings.ToLower(strings.TrimSpace(modality))
if models == nil || v3MediaVerb[modality] == "" {
Expand Down
68 changes: 68 additions & 0 deletions cmd/codeaf/chatv3_media_exact_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"testing"

"github.com/Agent-Field/codeaf/internal/provider"
"github.com/Agent-Field/codeaf/internal/session"
)

func TestOneModelVerificationProfileHonorsExactCapableVisionPin(t *testing.T) {
mediaEnvOff(t)
const selected = "deepseek/deepseek-v4.1-flash"
// Mirrors the model's published input/output modalities, not a guessed
// text-only capability from an older Flash generation.
models := mediaCatalogFor(t, `{"id":"other/vision-model","architecture":{"input_modalities":["text","image"],"output_modalities":["text"]}},{"id":"deepseek/deepseek-v4.1-flash","architecture":{"input_modalities":["text","image"],"output_modalities":["text"]}}`)
profile := oneModelProfile(t)
if err := writeVisionRow(profile, selected); err != nil {
t.Fatal(err)
}
cfg, err := applyV3Governance(session.Config{Model: selected}, profile, false, true)
if err != nil {
t.Fatal(err)
}
resolve := v3MediaModel(models, profile, cfg.RolesSource, cfg.OneModel)
if got := resolve("vision"); got != selected {
t.Fatalf("explicit vision pin changed to %q despite advertised image input", got)
}
}

func TestOneModelExplicitUnsupportedMediaPinsAreUnavailable(t *testing.T) {
mediaEnvOff(t)
const selected = "deepseek/deepseek-v4.1-flash"
models := mediaCatalogFor(t, mediaFixture+`,{"id":"deepseek/deepseek-v4.1-flash","architecture":{"input_modalities":["text","image"],"output_modalities":["text"]}}`)
profile := oneModelProfile(t)
for _, slot := range []string{"image", "speech", "music", "video", "voice"} {
writeMediaSlot(t, profile, slot, selected)
}
if err := writeVisionRow(profile, selected); err != nil {
t.Fatal(err)
}
resolve := v3MediaModel(models, profile, nil, true)
if got := resolve("vision"); got != selected {
t.Fatalf("capable vision pin unavailable: %q", got)
}
for _, modality := range []string{"image", "speech", "music", "video", "transcribe"} {
if got := resolve(modality); got != "" {
t.Errorf("unsupported explicit %s pin silently replaced by %q", modality, got)
}
}
names := session.HarnessBeltNames(t.TempDir(), session.HarnessBeltSeams{Media: &provider.MediaClient{}, MediaModel: resolve, Seer: &provider.Client{}})
for _, tool := range []string{"generate_image", "speak", "generate_music", "generate_video"} {
if names[tool] {
t.Errorf("unsupported pinned operation still advertises %s", tool)
}
}
if !names["view_image"] {
t.Fatal("image-capable exact pin removed view_image")
}
// An unpinned perception modality preserves the normal capability ladder.
if got := resolve("listen"); got == "" {
t.Fatal("unconfigured media was disabled by the narrow pin rule")
}
// Without --one-model an incapable image pin keeps its previous fallback.
legacy := v3MediaModel(models, profile, nil)
if got := legacy("image"); got == "" || got == selected {
t.Fatalf("ordinary media fallback changed: %q", got)
}
}
50 changes: 50 additions & 0 deletions cmd/codeaf/chatv3_media_pins.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package main

import (
"log"
"strings"

"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/roles"
)

// v3MediaModel retains the ordinary media ladder. During --one-model an
// explicit media choice is also a boundary: an incapable pinned model makes
// that operation unavailable instead of silently spending on a replacement.
// Unpinned media still follows the existing capability-qualified defaults.
func v3MediaModel(models *catalog.Catalog, profileDir string, source roles.Source, oneModel ...bool) func(string) string {
resolve := v3AutomaticMediaModel(models, profileDir, source)
if len(oneModel) == 0 || !oneModel[0] {
return resolve
}
return func(modality string) string {
modality = strings.ToLower(strings.TrimSpace(modality))
if pinned := v3ExplicitMediaPin(profileDir, source, modality); pinned != "" {
if models == nil || !v3MediaCapable(models.SnapshotNow(), modality, pinned) {
log.Printf("media: %s is unavailable: the explicit %s pin cannot %s", modality, pinned, v3MediaVerb[modality])
return ""
}
return pinned
}
return resolve(modality)
}
}
func v3ExplicitMediaPin(profileDir string, source roles.Source, modality string) string {
if slot, ok := v3MediaSlot[modality]; ok {
if pinned := config.MediaSlotModelAt(profileDir, slot); pinned != "" {
return pinned
}
}
if modality == "vision" {
if pinned := config.VisionModelAt(profileDir); pinned != "" {
return pinned
}
}
if role, ok := v3MediaPin[modality]; ok {
if pinned, set := roles.Pinned(source, role); set {
return pinned
}
}
return ""
}
41 changes: 41 additions & 0 deletions cmd/codeaf/chatv3_memory_maintenance.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
package main

import (
"context"
"time"

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

// startClockMemoryMaintenance gives local activation its daily batch refresh
// in the existing clock process. Startup catches up after closed windows; the
// store's transactional due marker makes repeated clock launches harmless.
// Cancellation joins the worker before the clock process closes its store.
func startClockMemoryMaintenance(parent context.Context, memory *store.Store, logf func(string)) func() {
if memory == nil {
return func() {}
}
ctx, cancel := context.WithCancel(parent)
done := make(chan struct{})
guard.Go("memory/maintenance", func() {
defer close(done)
maintain := func(now time.Time) {
if err := memory.RefreshMemoryActivationDue(now); err != nil && logf != nil {
logf("memory maintenance: " + err.Error())
}
}
maintain(time.Now())
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-ticker.C:
maintain(now)
}
}
})
return func() { cancel(); <-done }
}
33 changes: 33 additions & 0 deletions cmd/codeaf/chatv3_memory_maintenance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package main

import (
"context"
"path/filepath"
"testing"
"time"

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

func TestClockMemoryMaintenanceStopsBeforeStoreCloses(t *testing.T) {
memory, err := store.Open(filepath.Join(t.TempDir(), "mock-memory.db"))
if err != nil {
t.Fatal(err)
}
defer memory.Close()
stop := startClockMemoryMaintenance(context.Background(), memory, func(line string) { t.Log(line) })
done := make(chan struct{})
go func() { stop(); close(done) }()
select {
case <-done:
case <-time.After(5 * time.Second):
t.Fatal("memory maintenance did not join on stop")
}
if _, err := memory.ActiveMemoryCount(); err != nil {
t.Fatalf("maintenance closed shared store: %v", err)
}
}

func TestClockMemoryMaintenanceWithoutStoreIsAbsent(t *testing.T) {
startClockMemoryMaintenance(context.Background(), nil, nil)()
}
11 changes: 7 additions & 4 deletions docs/HEADLESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -750,10 +750,13 @@ resolution path, the flag cannot drift from the behaviour it is settling.

Two deliberate limits:

- **Media slots are untouched.** Vision, image, speech and video are
capability-qualified — a text model cannot answer `view_image` — so settling
them on the session model would not make a run single-model, it would make it
broken.
- **Media slots stay separate and capability-qualified.** To measure one model
across text and images, also pin `vision_model` (or `CODEAF_VISION_MODEL`) to
that model. Under `--one-model`, an explicit media pin that cannot perform
its operation makes that tool unavailable; it never falls back to another
model. Unpinned media retains normal discovery. Other explicit media slots
are `model.image`, `model.speech`, `model.music`, `model.video`, and
`model.voice`; pin these too when every model call must stay on one model.
- **It is refused with `--host`.** Over ssh the far machine owns those rows, and
a flag that looked like it applied and did not would be worse than no flag.

Expand Down
5 changes: 5 additions & 0 deletions docs/MULTIMODAL.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,11 @@ log line, never sent to a provider to fail. A renamed slug degrades to the
best available model instead of a 404, and a machine that has never opened
settings still draws, speaks, and films out of the box.

With `--one-model`, an explicit media slot or role pin is strict: if the catalog
does not advertise that model's required capability, the operation is unavailable
rather than replaced by a catalog candidate. Unpinned media and launches without
that flag keep the normal ladder.

### Decision 6 — The door stops starving the pickers

`v3Models` no longer drops non-text-out rows; the full catalog reaches the
Expand Down
Loading
Loading