]> git.giacomolli.uk - onechat/commitdiff
Rebuild agent memory in date order
authorAnderson A. Giacomolli <redacted>
Mon, 9 Nov 2026 20:29:43 +0000 (17:29 -0300)
committerAnderson A. Giacomolli <redacted>
Mon, 9 Nov 2026 20:29:43 +0000 (17:29 -0300)
A rebuild merges new dated messages with the log of an agent in date
order, builds a new memory beside it with the real prompt and cache key,
and swaps the directories only when the whole tree is built. The old
directory stays as a timestamped backup, and a failed or stopped build
leaves it unchanged. Older history can so enter the chat before newer
decisions, which keeps the latest word on a thing the newest one.

The probe and the rebuild share one build runner with the call limit,
the quota stop, the waits before user messages, and the usage report.
Memory and the model client keep their own state behind methods. The log
takes an explicit date for a message and writes it to the day file of
that date.

Issue: b0b8bf9

14 files changed:
agent.go
build_run.go [new file with mode: 0644]
build_run_test.go [new file with mode: 0644]
log.go
memory.go
model.go
probe.go
probe_log_helper_test.go [new file with mode: 0644]
probe_test.go
prompt.go
prompt_test.go
rebuild.go [new file with mode: 0644]
rebuild_helper_test.go [new file with mode: 0644]
rebuild_test.go [new file with mode: 0644]

index 1d76c5b943b495f7b36e0599b4b3be8f51af32d1..3fc954672e9038e9c2a154ced280d419def63a0a 100644 (file)
--- a/agent.go
+++ b/agent.go
@@ -87,7 +87,7 @@ func (a *Agent) CacheKey() string {
 func (a *Agent) Prompt() (string, error) {
        instructions, err := a.instructions()
        if err != nil {
-               return "", err
+               return "", fmt.Errorf("read prompt instructions: %w", err)
        }
 
        path, err := filepath.Abs(filepath.Join(a.dir, agentInstructionsFileName))
diff --git a/build_run.go b/build_run.go
new file mode 100644 (file)
index 0000000..9c6b767
--- /dev/null
@@ -0,0 +1,359 @@
+package main
+
+import (
+       "bufio"
+       "cmp"
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "maps"
+       "os"
+       "slices"
+       "strconv"
+       "strings"
+       "time"
+)
+
+// buildLineBytes is the most bytes of one line in a build input or usage log.
+const buildLineBytes = 1 << 20
+
+// buildQuotaLimit is the largest increase in used quota points of a run.
+const buildQuotaLimit = 15.0
+
+// buildCallLimit is the most model calls of a run.
+const buildCallLimit = 660
+
+// BuildRun appends messages, builds their tree, and stops the run by the rules
+// of buildStop.
+type BuildRun struct {
+       memory    *Memory
+       client    *ModelClient
+       usagePath string
+}
+
+// Run appends the entries. It waits for the builds only before a user message,
+// as a turn does, so up to buildMax builds run at once in between, and it waits
+// for all builds at the end. It returns the number of messages appended and why
+// the run stopped early, or "" when it did not. After a stop it waits for the
+// calls in flight, so no sent call is cancelled.
+func (r *BuildRun) Run(ctx context.Context, entries []DatedMessage) (int, string) {
+       appended, reason := r.append(ctx, entries)
+       if reason == "" {
+               return appended, ""
+       }
+
+       err := r.memory.Wait(ctx)
+       if err != nil {
+               reason += "; " + err.Error()
+       }
+
+       return appended, reason
+}
+
+// append appends the entries and stops at the first stop rule.
+func (r *BuildRun) append(ctx context.Context, entries []DatedMessage) (int, string) {
+       for n, entry := range entries {
+               if entry.Kind == "user" {
+                       reason := r.wait(ctx)
+                       if reason != "" {
+                               return n, reason
+                       }
+               }
+
+               if ctx.Err() != nil {
+                       return n, ctx.Err().Error()
+               }
+
+               _, err := r.memory.AppendDated(entry.Kind, entry.Text, entry.Date)
+               if err != nil {
+                       return n, err.Error()
+               }
+
+               reason := r.check()
+               if reason != "" {
+                       return n + 1, reason
+               }
+       }
+
+       return len(entries), r.wait(ctx)
+}
+
+// wait returns when no build runs, or when a stop rule ends the run. It checks
+// the rules each second, so a run stops soon also during a long wait.
+func (r *BuildRun) wait(ctx context.Context) string {
+       for {
+               reason := r.check()
+               if reason != "" {
+                       return reason
+               }
+
+               err := r.waitBuilds(ctx)
+               if err == nil {
+                       return r.check()
+               }
+
+               if ctx.Err() != nil {
+                       return ctx.Err().Error()
+               }
+       }
+}
+
+// check returns why the run must stop, or "" when it can go on.
+func (r *BuildRun) check() string {
+       if r.client.LimitReached() {
+               return fmt.Sprintf("call limit of %d reached", r.client.CallLimit())
+       }
+
+       records, err := buildReadUsage(r.usagePath)
+       if err != nil {
+               return err.Error()
+       }
+
+       return buildStop(records)
+}
+
+// waitBuilds waits up to one second without leaving a child context open.
+func (r *BuildRun) waitBuilds(ctx context.Context) error {
+       ctx, cancel := context.WithTimeout(ctx, time.Second)
+       defer cancel()
+
+       return r.memory.Wait(ctx)
+}
+
+// DatedMessage is one message with an optional RFC 3339 date.
+type DatedMessage struct {
+       Kind string `json:"kind"`
+       Text string `json:"text"`
+       Date string `json:"date,omitempty"`
+}
+
+// buildReadUsage reads the records of usage.jsonl at path, in the order of the
+// start of their calls. The file has them in the order of the end of their
+// calls, which differs when calls run at once.
+func buildReadUsage(path string) (records []ModelUsageRecord, err error) {
+       file, err := os.Open(path)
+       if errors.Is(err, os.ErrNotExist) {
+               return nil, nil
+       }
+
+       if err != nil {
+               return nil, fmt.Errorf("open usage file: %w", err)
+       }
+
+       defer func() {
+               closeErr := file.Close()
+               if closeErr != nil {
+                       err = errors.Join(err, fmt.Errorf("close build usage: %w", closeErr))
+               }
+       }()
+
+       scanner := bufio.NewScanner(file)
+       scanner.Buffer(make([]byte, 64*1024), buildLineBytes)
+       for scanner.Scan() {
+               var record ModelUsageRecord
+               err = json.Unmarshal(scanner.Bytes(), &record)
+               if err != nil {
+                       return nil, fmt.Errorf("decode usage: %w", err)
+               }
+
+               records = append(records, record)
+       }
+
+       err = scanner.Err()
+       if err != nil {
+               return nil, fmt.Errorf("read usage file: %w", err)
+       }
+
+       slices.SortStableFunc(records, func(a, b ModelUsageRecord) int {
+               return buildCallStart(a).Compare(buildCallStart(b))
+       })
+
+       return records, nil
+}
+
+// buildWriteReport writes the result of a build run.
+func buildWriteReport(out io.Writer, appended int, stop string, records []ModelUsageRecord, sizes []int) error {
+       var input, cached, output, failed int
+       for _, record := range records {
+               input += record.InputTokens
+               cached += record.CachedTokens
+               output += record.OutputTokens
+               if record.Error != "" {
+                       failed++
+               }
+       }
+
+       var lines []string
+       if stop != "" {
+               lines = append(lines, "stopped early: "+stop)
+       }
+
+       lines = append(lines,
+               fmt.Sprintf("messages: %d", appended),
+               fmt.Sprintf("calls: %d (%d failed), %.2f per message", len(records), failed, buildRatio(len(records), appended)),
+               fmt.Sprintf("model lines: %d, retries: %d, %.2f calls per model line", len(sizes), len(records)-failed-len(sizes), buildRatio(len(records), len(sizes))),
+               fmt.Sprintf("tokens: %d input, %d cached (%.1f%%), %d output", input, cached, 100*buildRatio(cached, input), output),
+       )
+
+       if len(sizes) > 0 {
+               lines = append(lines, buildSizeText(sizes))
+       }
+
+       if len(records) > 0 {
+               lines = append(lines, buildTimeText(records))
+       }
+
+       if len(records) > 0 {
+               lines = append(lines,
+                       "quota at the first call: "+buildQuotaText(records[0]),
+                       "quota at the last call: "+buildQuotaText(records[len(records)-1]),
+               )
+       }
+
+       _, err := io.WriteString(out, strings.Join(lines, "\n")+"\n")
+       if err != nil {
+               return fmt.Errorf("write build report: %w", err)
+       }
+
+       return nil
+}
+
+// buildStop returns why a build run must stop: a failed call, or a quota window
+// whose used percent grew by more than buildQuotaLimit points since the first
+// call. It returns "" when the run can go on.
+func buildStop(records []ModelUsageRecord) string {
+       for _, record := range records {
+               if record.Error != "" {
+                       return "call failed: " + record.Error
+               }
+       }
+
+       if len(records) == 0 {
+               return ""
+       }
+
+       first := buildUsedPercents(records[0])
+       for name, used := range buildUsedPercents(records[len(records)-1]) {
+               start, found := first[name]
+               if found && used-start > buildQuotaLimit {
+                       return fmt.Sprintf("%s grew from %g to %g", name, start, used)
+               }
+       }
+
+       return ""
+}
+
+// buildCallStart returns the start of the call of record, or the zero time when
+// its date is not valid.
+func buildCallStart(record ModelUsageRecord) time.Time {
+       start, err := time.Parse(time.RFC3339Nano, record.Date)
+       if err != nil {
+               return time.Time{}
+       }
+
+       return start
+}
+
+// buildRatio returns a / b, or 0 when b is 0.
+func buildRatio(a, b int) float64 {
+       if b == 0 {
+               return 0
+       }
+
+       return float64(a) / float64(b)
+}
+
+// buildSizeText describes the sizes of the model lines.
+func buildSizeText(sizes []int) string {
+       sorted := slices.Sorted(slices.Values(sizes))
+       total, over := 0, 0
+       for _, size := range sorted {
+               total += size
+               if size > nodeBytes {
+                       over++
+               }
+       }
+
+       return fmt.Sprintf("line bytes: min %d, median %d, mean %.0f, max %d, %d over %d", sorted[0], sorted[len(sorted)/2], buildRatio(total, len(sorted)), sorted[len(sorted)-1], over, nodeBytes)
+}
+
+// buildTimeText describes the time per call and how many calls ran at once. A
+// call runs from its date for its seconds.
+func buildTimeText(records []ModelUsageRecord) string {
+       type event struct {
+               at    time.Time
+               delta int
+       }
+
+       var events []event
+       seconds := make([]float64, 0, len(records))
+       total := 0.0
+       for _, record := range records {
+               start, err := time.Parse(time.RFC3339Nano, record.Date)
+               if err != nil {
+                       continue
+               }
+
+               length := time.Duration(record.Seconds * float64(time.Second))
+               events = append(events, event{
+                       at:    start,
+                       delta: 1,
+               }, event{
+                       at:    start.Add(length),
+                       delta: -1,
+               })
+
+               seconds = append(seconds, record.Seconds)
+               total += record.Seconds
+       }
+
+       if len(events) == 0 {
+               return "time: no dated calls"
+       }
+
+       slices.SortFunc(events, func(a, b event) int {
+               return cmp.Or(a.at.Compare(b.at), cmp.Compare(a.delta, b.delta))
+       })
+
+       running, most := 0, 0
+       for _, e := range events {
+               running += e.delta
+               most = max(most, running)
+       }
+
+       wall := events[len(events)-1].at.Sub(events[0].at).Seconds()
+       slices.Sort(seconds)
+
+       return fmt.Sprintf("time: %.0f s in all, %.1f calls per minute, %.1f s per call (median %.1f, max %.1f), calls at once: at most %d, %.1f on average", wall, 60*float64(len(seconds))/max(wall, 1e-9), total/float64(len(seconds)), seconds[len(seconds)/2], seconds[len(seconds)-1], most, total/max(wall, 1e-9))
+}
+
+// buildQuotaText returns the quota headers of record, sorted by name.
+func buildQuotaText(record ModelUsageRecord) string {
+       names := slices.Sorted(maps.Keys(record.Quota))
+       parts := make([]string, len(names))
+       for n, name := range names {
+               parts[n] = strings.TrimPrefix(name, "x-codex-") + "=" + record.Quota[name]
+       }
+
+       return strings.Join(parts, " ")
+}
+
+// buildUsedPercents returns the used percent of each quota window of record, by
+// header name.
+func buildUsedPercents(record ModelUsageRecord) map[string]float64 {
+       used := make(map[string]float64)
+       for name, value := range record.Quota {
+               if !strings.HasSuffix(name, "-used-percent") {
+                       continue
+               }
+
+               percent, err := strconv.ParseFloat(value, 64)
+               if err == nil {
+                       used[name] = percent
+               }
+       }
+
+       return used
+}
diff --git a/build_run_test.go b/build_run_test.go
new file mode 100644 (file)
index 0000000..64d0d86
--- /dev/null
@@ -0,0 +1,221 @@
+package main
+
+import (
+       "net/http"
+       "path/filepath"
+       "strconv"
+       "strings"
+       "sync"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestBuildRunStopsOnFailedCallsOrQuotaGrowth(t *testing.T) {
+       quota := func(primary, secondary string) ModelUsageRecord {
+               return ModelUsageRecord{
+                       Quota: map[string]string{
+                               "x-codex-primary-used-percent":   primary,
+                               "x-codex-secondary-used-percent": secondary,
+                               "x-codex-plan-type":              "plus",
+                       },
+               }
+       }
+
+       testCases := []struct {
+               name    string
+               records []ModelUsageRecord
+               want    string
+       }{
+               {
+                       name:    "no calls",
+                       records: nil,
+                       want:    "",
+               },
+               {
+                       name:    "growth within the limit",
+                       records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")},
+                       want:    "",
+               },
+               {
+                       name:    "growth over the limit",
+                       records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")},
+                       want:    "x-codex-secondary-used-percent grew from 40 to 55.5",
+               },
+               {
+                       name: "failed call",
+                       records: []ModelUsageRecord{quota("10", "40"), {
+                               Status: 400,
+                               Error:  "model call: HTTP 400",
+                       }},
+                       want: "call failed: model call: HTTP 400",
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       require.Equal(t, tc.want, buildStop(tc.records))
+               })
+       }
+}
+
+func TestBuildRunStopsWhenTheQuotaGrows(t *testing.T) {
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               w.Header().Set("X-Codex-Primary-Used-Percent", strconv.Itoa(10+call.number))
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(strings.Repeat("s", 400))}, 1000, 800, 50))
+       }
+
+       run := newTestBuildRun(t, server)
+       appended, stop := run.Run(t.Context(), probeLog(probeMessages))
+       sizes, err := run.memory.buildModelLines()
+       require.NoError(t, err)
+       require.NoError(t, run.memory.Close())
+
+       require.Regexp(t, `^x-codex-primary-used-percent grew from \d+ to \d+$`, stop)
+       require.Less(t, appended, probeMessages)
+       require.NotEmpty(t, sizes)
+
+       records, err := buildReadUsage(run.usagePath)
+       require.NoError(t, err)
+       require.Greater(t, len(records), int(buildQuotaLimit))
+
+       var report strings.Builder
+       require.NoError(t, buildWriteReport(&report, appended, stop, records, sizes))
+       require.Contains(t, report.String(), "stopped early: ")
+       require.Contains(t, report.String(), "cached (80.0%)")
+       require.Contains(t, report.String(), "line bytes: min 400, median 400, mean 400, max 400, 0 over 512")
+       require.Contains(t, report.String(), "calls at once: at most ")
+       require.Contains(t, report.String(), "quota at the first call: primary-used-percent=")
+}
+
+func TestBuildRunStopsAtTheCallLimit(t *testing.T) {
+       server := newModelFake(t)
+       run := newTestBuildRun(t, server)
+       run.client.LimitCalls(5)
+
+       entries := make([]DatedMessage, 40)
+       for n := range entries {
+               entries[n] = DatedMessage{
+                       Kind: "echo",
+                       Text: strings.Repeat("long output ", 100),
+               }
+       }
+
+       _, stop := run.Run(t.Context(), entries)
+       require.NoError(t, run.memory.Close())
+       require.Equal(t, "call limit of 5 reached", stop)
+       require.Equal(t, 5, server.Calls())
+}
+
+func TestBuildRunWaitsOnlyBeforeUserMessages(t *testing.T) {
+       gate := make(chan struct{})
+       var mutex sync.Mutex
+       running, most := 0, 0
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
+               mutex.Lock()
+               running++
+               most = max(most, running)
+               mutex.Unlock()
+
+               <-gate
+
+               mutex.Lock()
+               running--
+               mutex.Unlock()
+
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
+       }
+
+       run := newTestBuildRun(t, server)
+       long := strings.Repeat("long output ", 100)
+       entries := []DatedMessage{
+               {
+                       Kind: "echo",
+                       Text: long,
+               },
+               {
+                       Kind: "tool",
+                       Text: long,
+               },
+               {
+                       Kind: "user",
+                       Text: "next",
+               },
+               {
+                       Kind: "reply",
+                       Text: long,
+               },
+       }
+
+       done := make(chan string)
+       go func() {
+               _, stop := run.Run(t.Context(), entries)
+               done <- stop
+       }()
+
+       require.Eventually(t, func() bool {
+               return server.Calls() == 2
+       }, 10*time.Second, time.Millisecond)
+
+       appended := run.memory.Len()
+       require.Equal(t, 2, appended)
+
+       close(gate)
+       require.Empty(t, <-done)
+       require.NoError(t, run.memory.Close())
+
+       mutex.Lock()
+       defer mutex.Unlock()
+
+       require.Equal(t, 2, most)
+       require.Equal(t, 3, server.Calls())
+}
+
+func TestReadUsageFileSortsByCallStart(t *testing.T) {
+       path := filepath.Join(t.TempDir(), "usage.jsonl")
+       usage := ModelUsageLog{
+               path: path,
+       }
+
+       for _, date := range []string{"2026-01-01T12:00:02Z", "2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z"} {
+               require.NoError(t, usage.Write(ModelUsageRecord{
+                       Date: date,
+               }))
+       }
+
+       records, err := buildReadUsage(path)
+       require.NoError(t, err)
+       require.Equal(t, []string{"2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z", "2026-01-01T12:00:02Z"}, []string{records[0].Date, records[1].Date, records[2].Date})
+}
+
+func TestTimeTextCountsTheCallsAtOnce(t *testing.T) {
+       start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
+       record := func(offset, seconds float64) ModelUsageRecord {
+               return ModelUsageRecord{
+                       Date:    start.Add(time.Duration(offset * float64(time.Second))).Format(time.RFC3339Nano),
+                       Seconds: seconds,
+               }
+       }
+
+       text := buildTimeText([]ModelUsageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)})
+       require.Equal(t, "time: 20 s in all, 12.0 calls per minute, 6.5 s per call (median 10.0, max 10.0), calls at once: at most 3, 1.3 on average", text)
+}
+
+func newTestBuildRun(t *testing.T, server *ModelFake) *BuildRun {
+       t.Helper()
+
+       root := t.TempDir()
+       usagePath := filepath.Join(root, "usage.jsonl")
+       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "onechat-"+probeName, usagePath)
+       memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor), discardLog())
+       require.NoError(t, err)
+
+       return &BuildRun{
+               memory:    memory,
+               client:    client,
+               usagePath: usagePath,
+       }
+}
diff --git a/log.go b/log.go
index d34f459ad09909af9213f0cfc58c542e41d3732c..24feb3bb21ec63acf0187420a23c2723f821ecdb 100644 (file)
--- a/log.go
+++ b/log.go
@@ -58,9 +58,9 @@ func openLog(ctx context.Context, root string, clock func() time.Time) (*Log, er
 }
 
 // Log is the append-only message log of one chat. It writes one JSON line for
-// each message to main/YYYY-MM-DD.jsonl, named for the local day of the write.
-// It never edits or deletes a line. Each line goes to the operating system
-// before Append returns, so a crash of the process loses no message. An
+// each message to main/YYYY-MM-DD.jsonl, named for the local day of the
+// message. It never edits or deletes a line. Each line goes to the operating
+// system before Append returns, so a crash of the process loses no message. An
 // exclusive lock on the lock file makes sure that only one process writes the
 // log. After a failed write, the end of the file is not known, so the log
 // refuses all writes until it is opened again. The caller serializes all calls.
@@ -96,26 +96,44 @@ func (l *Log) Close() error {
 // Append writes a new message to the log and returns it with its ID, size, and
 // date.
 func (l *Log) Append(kind, text string) (Message, error) {
+       return l.AppendDated(kind, text, "")
+}
+
+// AppendDated writes a message with date in RFC 3339 format. An empty date uses
+// the clock. The day file uses the local day of the given date.
+func (l *Log) AppendDated(kind, text, date string) (Message, error) {
        if l.lock == nil {
-               return Message{}, os.ErrClosed
+               return Message{}, fmt.Errorf("append dated message: %w", os.ErrClosed)
        }
 
        if l.failure != nil {
                return Message{}, fmt.Errorf("log needs a reopen after a failed write: %w", l.failure)
        }
 
+       var err error
        now := l.clock()
+       if date == "" {
+               date = now.Format(time.RFC3339Nano)
+       } else {
+               now, err = time.Parse(time.RFC3339Nano, date)
+               if err != nil {
+                       return Message{}, fmt.Errorf("parse message date: %w", err)
+               }
+
+               now = now.In(time.Local)
+       }
+
        message := Message{
                ID:   l.index.count,
                Kind: kind,
                Text: text,
                Size: len(text),
-               Date: now.Format(time.RFC3339Nano),
+               Date: date,
        }
 
-       err := logValidateMessage(message)
+       err = logValidateMessage(message)
        if err != nil {
-               return Message{}, err
+               return Message{}, fmt.Errorf("validate dated message: %w", err)
        }
 
        line, err := json.Marshal(message)
@@ -213,9 +231,10 @@ func (l *Log) addIndex(place Position) error {
        return l.index.Append(record)
 }
 
-// Message is one entry of the chat log. ID is the permanent position of the
-// message in the chat, from 0. Size is the UTF-8 byte length of Text. Date is
-// the time of the write in RFC 3339 format.
+// Message is one entry of the chat log. ID is its position in the log, from 0.
+// A rebuild assigns positions in the merged log. Size is the UTF-8 byte length
+// of Text. Date is the message time in RFC 3339 format, supplied at append or
+// taken from the clock.
 type Message struct {
        ID   int    `json:"i"`
        Kind string `json:"kind"`
index 5ea12b75a330292d37e26a361026f6e03510aa2e..bcc10951921751aad707a32f7180799e023e9a18 100644 (file)
--- a/memory.go
+++ b/memory.go
@@ -139,24 +139,30 @@ func (m *Memory) Close() error {
 // Append logs a new message, adds it to the tree and the views, and starts the
 // builds that are ready, also the failed ones.
 func (m *Memory) Append(kind, text string) (Message, error) {
+       return m.AppendDated(kind, text, "")
+}
+
+// AppendDated appends a message with its RFC 3339 date and builds its nodes. An
+// empty date uses the clock, as Append does.
+func (m *Memory) AppendDated(kind, text, date string) (Message, error) {
        m.mutex.Lock()
        defer m.mutex.Unlock()
 
-       message, err := m.log.Append(kind, text)
+       message, err := m.log.AppendDated(kind, text, date)
        if err != nil {
-               return Message{}, err
+               return Message{}, fmt.Errorf("append dated log message: %w", err)
        }
 
        defer m.syncer.Written()
 
        err = m.addMessage(message)
        if err != nil {
-               return Message{}, err
+               return Message{}, fmt.Errorf("add dated message to memory: %w", err)
        }
 
        err = m.save()
        if err != nil {
-               return Message{}, err
+               return Message{}, fmt.Errorf("save dated message view: %w", err)
        }
 
        m.queue.Retry()
@@ -627,6 +633,79 @@ func (m *Memory) Context(level, index int) string {
        return m.context(level, index)
 }
 
+// rebuildComplete checks the whole tree, not only the visible view. A compactor
+// can fail without an HTTP error, for example with an empty line. Such a failed
+// merge can be absent from the view and must prevent a swap.
+func (m *Memory) rebuildComplete() error {
+       m.mutex.Lock()
+       defer m.mutex.Unlock()
+
+       if len(m.queue.leaves) != 0 || len(m.queue.queued) != 0 {
+               return errors.New("rebuild failed: summary tree has unfinished nodes")
+       }
+
+       return nil
+}
+
+// buildModelLines returns the sizes of the nodes that the model wrote, in tree
+// order. A node that fits its source with no model call is not one of them.
+func (m *Memory) buildModelLines() ([]int, error) {
+       m.mutex.Lock()
+       defer m.mutex.Unlock()
+
+       keys, err := m.tree.Keys()
+       if err != nil {
+               return nil, fmt.Errorf("list built nodes: %w", err)
+       }
+
+       var sizes []int
+       for _, key := range keys {
+               var node Node
+
+               node, _, err = m.tree.Node(key.level, key.index)
+               if err != nil {
+                       return nil, fmt.Errorf("read built node: %w", err)
+               }
+
+               var model bool
+
+               model, err = m.buildBuiltByModel(key)
+               if err != nil {
+                       return nil, fmt.Errorf("check model source: %w", err)
+               }
+
+               if model {
+                       sizes = append(sizes, node.Size)
+               }
+       }
+
+       return sizes, nil
+}
+
+// buildBuiltByModel tells if the node key needed a model call: a leaf whose
+// source is over nodeBytes, or a merge whose children do not fit in one node.
+func (m *Memory) buildBuiltByModel(key NodeKey) (bool, error) {
+       if key.level == 0 {
+               message, err := m.log.Message(key.index)
+               if err != nil {
+                       return false, fmt.Errorf("read leaf source: %w", err)
+               }
+
+               _, free := freeLeaf(message)
+
+               return !free, nil
+       }
+
+       left, right, err := m.children(key)
+       if err != nil {
+               return false, fmt.Errorf("read merge sources: %w", err)
+       }
+
+       _, free := freeMerge(left, right)
+
+       return !free, nil
+}
+
 // Task is the build of the node (Level, Index). Context is the compaction view
 // for the build. Input holds the source "kind: text" of a leaf, or the two
 // child lines of a merge with their newlines as spaces.
index 61dbc5829a4bbfd4fc360ec9d67a68704697e8ed..7c856431124af275fa96c90582b0cdb1bb74be85 100644 (file)
--- a/model.go
+++ b/model.go
@@ -168,6 +168,16 @@ func (c *ModelClient) LimitReached() bool {
        return c.limit > 0 && c.calls.Load() >= c.limit
 }
 
+// CallLimit returns the configured call limit, or zero for no limit.
+func (c *ModelClient) CallLimit() int64 {
+       return c.limit
+}
+
+// UsagePath returns the file that records the calls of this client.
+func (c *ModelClient) UsagePath() string {
+       return c.usage.path
+}
+
 // Create sends the call and reads its event stream to the end. Over the call
 // limit it sends nothing and returns errModelCallLimit.
 func (c *ModelClient) Create(ctx context.Context, request ModelRequest) (ModelResponse, error) {
index 92a35b175400a7c056c732c1b9088dba8182ecad..69d81d1024da77b2018555ac11c1819f468b25de 100644 (file)
--- a/probe.go
+++ b/probe.go
@@ -2,19 +2,15 @@ package main
 
 import (
        "bufio"
-       "cmp"
        "context"
        "encoding/json"
        "errors"
        "fmt"
        "io"
        "log"
-       "maps"
        "os"
        "path/filepath"
-       "slices"
        "strconv"
-       "strings"
        "time"
 )
 
@@ -22,24 +18,14 @@ const (
        // probeName is the agent name of a probe run. The cache key comes from
        // it.
        probeName = "Probe"
-
-       // probeQuotaLimit is how many points the used percent of a quota window
-       // can grow in a probe run before the run stops.
-       probeQuotaLimit = 15.0
-
-       // probeCallLimit is the most model calls of a probe run.
-       probeCallLimit = 660
-
-       // probeLineBytes is the most bytes of one line of a probe log.
-       probeLineBytes = 1 << 20
 )
 
 // probe runs "onechat probe LOG COUNT [BUILDS]". It appends the first COUNT
 // messages of LOG, one JSON object {kind, text} per line, to a new chat in
 // probe/ in the data directory, and builds its tree with the real compactor.
 // Before each user message it waits for the builds, as a turn does. It stops at
-// the first failed call, after probeCallLimit calls, or when the used percent
-// of a quota window grows by more than probeQuotaLimit points. At the end it
+// the first failed call, after buildCallLimit calls, or when the used percent
+// of a quota window grows by more than buildQuotaLimit points. At the end it
 // reports the calls, the time per call, the calls at once, the cached share,
 // the quota, the retries, and the line sizes. BUILDS, from 1 to buildMax,
 // limits the builds that call the model at once in this run only. The core
@@ -51,45 +37,56 @@ func probe(ctx context.Context, args []string, out io.Writer) (err error) {
        }
 
        count, err := strconv.Atoi(args[1])
-       if err != nil || count <= 0 {
+       if err != nil {
+               return fmt.Errorf("parse probe count: %w", err)
+       }
+
+       if count <= 0 {
                return fmt.Errorf("COUNT %q is not a positive number", args[1])
        }
 
        builds := buildMax
        if len(args) == 3 {
                builds, err = strconv.Atoi(args[2])
-               if err != nil || builds < 1 || builds > buildMax {
+               if err != nil {
+                       return fmt.Errorf("parse probe builds: %w", err)
+               }
+
+               if builds < 1 || builds > buildMax {
                        return fmt.Errorf("BUILDS %q is not a number from 1 to %d", args[2], buildMax)
                }
        }
 
        entries, err := probeReadLog(args[0], count)
        if err != nil {
-               return err
+               return fmt.Errorf("read probe input: %w", err)
        }
 
        dir, err := dataDir()
        if err != nil {
-               return err
+               return fmt.Errorf("find probe data directory: %w", err)
        }
 
        config, err := loadConfig(dir)
        if err != nil {
-               return err
+               return fmt.Errorf("load probe configuration: %w", err)
        }
 
        tokens, err := lockTokens(dir)
        if err != nil {
-               return err
+               return fmt.Errorf("lock probe tokens: %w", err)
        }
 
        defer func() {
-               err = errors.Join(err, tokens.Close())
+               closeErr := tokens.Close()
+               if closeErr != nil {
+                       err = errors.Join(err, fmt.Errorf("close probe token lock: %w", closeErr))
+               }
        }()
 
        address, auth, err := modelEndpoint(config, dir)
        if err != nil {
-               return err
+               return fmt.Errorf("select probe endpoint: %w", err)
        }
 
        root := filepath.Join(dir, "probe", time.Now().Format("20060102-150405"))
@@ -100,7 +97,7 @@ func probe(ctx context.Context, args []string, out io.Writer) (err error) {
 
        usagePath := filepath.Join(root, "usage.jsonl")
        client := newModelClient(address, auth, "onechat-"+probeName, usagePath)
-       client.LimitCalls(probeCallLimit)
+       client.LimitCalls(buildCallLimit)
        summarizer := ProbeSlotSummarizer{
                inner: newCompactor(client, probePrompt, config.Compactor),
                slots: make(chan struct{}, builds),
@@ -110,33 +107,38 @@ func probe(ctx context.Context, args []string, out io.Writer) (err error) {
        // failed builds are not needed.
        memory, err := openMemory(ctx, root, time.Now, summarizer, log.New(io.Discard, "", 0))
        if err != nil {
-               return err
+               return fmt.Errorf("open probe memory: %w", err)
        }
 
        _, err = fmt.Fprintf(out, "probe: %d messages into %s with %s at %s effort, %d builds at once\n", len(entries), root, config.Compactor.Model, config.Compactor.Effort, builds)
        if err != nil {
-               return errors.Join(err, memory.Close())
+               return fmt.Errorf("write probe header: %w", errors.Join(err, memory.Close()))
        }
 
-       run := ProbeRun{
+       run := BuildRun{
                memory:    memory,
                client:    client,
                usagePath: usagePath,
        }
 
        appended, stop := run.Run(ctx, entries)
-       sizes, err := probeModelLines(memory)
+       sizes, err := memory.buildModelLines()
        err = errors.Join(err, memory.Close())
        if err != nil {
-               return err
+               return fmt.Errorf("finish probe memory: %w", err)
        }
 
-       records, err := probeReadUsage(usagePath)
+       records, err := buildReadUsage(usagePath)
        if err != nil {
-               return err
+               return fmt.Errorf("read probe usage: %w", err)
        }
 
-       return probeWriteReport(out, appended, stop, records, sizes)
+       err = buildWriteReport(out, appended, stop, records, sizes)
+       if err != nil {
+               return fmt.Errorf("write probe report: %w", err)
+       }
+
+       return nil
 }
 
 // ProbeSlotSummarizer lets at most cap(slots) builds call inner at once. A
@@ -161,119 +163,31 @@ func (s ProbeSlotSummarizer) Summarize(ctx context.Context, task Task) (string,
        return s.inner.Summarize(ctx, task)
 }
 
-// ProbeRun appends the messages of a probe run to its chat and stops the run by
-// the rules of probeStop.
-type ProbeRun struct {
-       memory    *Memory
-       client    *ModelClient
-       usagePath string
-}
-
-// Run appends the entries. It waits for the builds only before a user message,
-// as a turn does, so up to buildMax builds run at once in between, and it waits
-// for all builds at the end. It returns the number of messages appended and why
-// the run stopped early, or "" when it did not. After a stop it waits for the
-// calls in flight, so no sent call is cancelled.
-func (r *ProbeRun) Run(ctx context.Context, entries []ProbeEntry) (int, string) {
-       appended, reason := r.append(ctx, entries)
-       if reason == "" {
-               return appended, ""
-       }
-
-       err := r.memory.Wait(ctx)
-       if err != nil {
-               reason += "; " + err.Error()
-       }
-
-       return appended, reason
-}
-
-// append appends the entries and stops at the first stop rule.
-func (r *ProbeRun) append(ctx context.Context, entries []ProbeEntry) (int, string) {
-       for n, entry := range entries {
-               if entry.Kind == "user" {
-                       reason := r.wait(ctx)
-                       if reason != "" {
-                               return n, reason
-                       }
-               }
-
-               _, err := r.memory.Append(entry.Kind, entry.Text)
-               if err != nil {
-                       return n, err.Error()
-               }
-
-               reason := r.check()
-               if reason != "" {
-                       return n + 1, reason
-               }
-       }
-
-       return len(entries), r.wait(ctx)
-}
-
-// wait returns when no build runs, or when a stop rule ends the run. It checks
-// the rules each second, so a run stops soon also during a long wait.
-func (r *ProbeRun) wait(ctx context.Context) string {
-       for {
-               reason := r.check()
-               if reason != "" {
-                       return reason
-               }
-
-               waitCtx, cancel := context.WithTimeout(ctx, time.Second)
-               err := r.memory.Wait(waitCtx)
-               cancel()
-               if err == nil {
-                       return r.check()
-               }
-
-               if ctx.Err() != nil {
-                       return ctx.Err().Error()
-               }
-       }
-}
-
-// check returns why the run must stop, or "" when it can go on.
-func (r *ProbeRun) check() string {
-       if r.client.LimitReached() {
-               return fmt.Sprintf("call limit of %d reached", r.client.limit)
-       }
-
-       records, err := probeReadUsage(r.usagePath)
-       if err != nil {
-               return err.Error()
-       }
-
-       return probeStop(records)
-}
-
-// ProbeEntry is one message of a probe log.
-type ProbeEntry struct {
-       Kind string `json:"kind"`
-       Text string `json:"text"`
-}
-
-// probeReadLog reads the first count messages of the probe log at path.
-func probeReadLog(path string, count int) (entries []ProbeEntry, err error) {
+// probeReadLog reads the first count messages of the probe log at path. Source
+// dates are ignored because a probe uses its own clock.
+func probeReadLog(path string, count int) (entries []DatedMessage, err error) {
        file, err := os.Open(path)
        if err != nil {
                return nil, fmt.Errorf("open probe log: %w", err)
        }
 
        defer func() {
-               err = errors.Join(err, file.Close())
+               closeErr := file.Close()
+               if closeErr != nil {
+                       err = errors.Join(err, fmt.Errorf("close probe log: %w", closeErr))
+               }
        }()
 
        scanner := bufio.NewScanner(file)
-       scanner.Buffer(make([]byte, 64*1024), probeLineBytes)
+       scanner.Buffer(make([]byte, 64*1024), buildLineBytes)
        for len(entries) < count && scanner.Scan() {
-               var entry ProbeEntry
+               var entry DatedMessage
                err = json.Unmarshal(scanner.Bytes(), &entry)
                if err != nil {
                        return nil, fmt.Errorf("probe log line %d: %w", len(entries)+1, err)
                }
 
+               entry.Date = ""
                entries = append(entries, entry)
        }
 
@@ -294,280 +208,3 @@ func probeReadLog(path string, count int) (entries []ProbeEntry, err error) {
 func probePrompt() (string, error) {
        return prompt(probeName, "/PROBE/instructions.md", ""), nil
 }
-
-// probeModelLines returns the sizes of the nodes that the model wrote, in tree
-// order. A node that fits its source with no model call is not one of them.
-func probeModelLines(memory *Memory) ([]int, error) {
-       memory.mutex.Lock()
-       defer memory.mutex.Unlock()
-
-       keys, err := memory.tree.Keys()
-       if err != nil {
-               return nil, err
-       }
-
-       var sizes []int
-       for _, key := range keys {
-               node, _, err := memory.tree.Node(key.level, key.index)
-               if err != nil {
-                       return nil, err
-               }
-
-               model, err := probeBuiltByModel(memory, key)
-               if err != nil {
-                       return nil, err
-               }
-
-               if model {
-                       sizes = append(sizes, node.Size)
-               }
-       }
-
-       return sizes, nil
-}
-
-// probeReadUsage reads the records of usage.jsonl at path, in the order of the
-// start of their calls. The file has them in the order of the end of their
-// calls, which differs when calls run at once.
-func probeReadUsage(path string) (records []ModelUsageRecord, err error) {
-       file, err := os.Open(path)
-       if errors.Is(err, os.ErrNotExist) {
-               return nil, nil
-       }
-
-       if err != nil {
-               return nil, fmt.Errorf("open usage file: %w", err)
-       }
-
-       defer func() {
-               err = errors.Join(err, file.Close())
-       }()
-
-       scanner := bufio.NewScanner(file)
-       scanner.Buffer(make([]byte, 64*1024), probeLineBytes)
-       for scanner.Scan() {
-               var record ModelUsageRecord
-               err = json.Unmarshal(scanner.Bytes(), &record)
-               if err != nil {
-                       return nil, fmt.Errorf("decode usage: %w", err)
-               }
-
-               records = append(records, record)
-       }
-
-       err = scanner.Err()
-       if err != nil {
-               return nil, fmt.Errorf("read usage file: %w", err)
-       }
-
-       slices.SortStableFunc(records, func(a, b ModelUsageRecord) int {
-               return probeCallStart(a).Compare(probeCallStart(b))
-       })
-
-       return records, nil
-}
-
-// probeWriteReport writes the result of a probe run.
-func probeWriteReport(out io.Writer, appended int, stop string, records []ModelUsageRecord, sizes []int) error {
-       var input, cached, output, failed int
-       for _, record := range records {
-               input += record.InputTokens
-               cached += record.CachedTokens
-               output += record.OutputTokens
-               if record.Error != "" {
-                       failed++
-               }
-       }
-
-       var lines []string
-       if stop != "" {
-               lines = append(lines, "stopped early: "+stop)
-       }
-
-       lines = append(lines,
-               fmt.Sprintf("messages: %d", appended),
-               fmt.Sprintf("calls: %d (%d failed), %.2f per message", len(records), failed, probeRatio(len(records), appended)),
-               fmt.Sprintf("model lines: %d, retries: %d, %.2f calls per model line", len(sizes), len(records)-failed-len(sizes), probeRatio(len(records), len(sizes))),
-               fmt.Sprintf("tokens: %d input, %d cached (%.1f%%), %d output", input, cached, 100*probeRatio(cached, input), output),
-       )
-
-       if len(sizes) > 0 {
-               lines = append(lines, probeSizeText(sizes))
-       }
-
-       if len(records) > 0 {
-               lines = append(lines, probeTimeText(records))
-       }
-
-       if len(records) > 0 {
-               lines = append(lines,
-                       "quota at the first call: "+probeQuotaText(records[0]),
-                       "quota at the last call: "+probeQuotaText(records[len(records)-1]),
-               )
-       }
-
-       _, err := io.WriteString(out, strings.Join(lines, "\n")+"\n")
-
-       return err
-}
-
-// probeStop returns why a probe run must stop: a failed call, or a quota window
-// whose used percent grew by more than probeQuotaLimit points since the first
-// call. It returns "" when the run can go on.
-func probeStop(records []ModelUsageRecord) string {
-       for _, record := range records {
-               if record.Error != "" {
-                       return "call failed: " + record.Error
-               }
-       }
-
-       if len(records) == 0 {
-               return ""
-       }
-
-       first := probeUsedPercents(records[0])
-       for name, used := range probeUsedPercents(records[len(records)-1]) {
-               start, found := first[name]
-               if found && used-start > probeQuotaLimit {
-                       return fmt.Sprintf("%s grew from %g to %g", name, start, used)
-               }
-       }
-
-       return ""
-}
-
-// probeBuiltByModel tells if the node key needed a model call: a leaf whose
-// source is over nodeBytes, or a merge whose children do not fit in one node.
-func probeBuiltByModel(memory *Memory, key NodeKey) (bool, error) {
-       if key.level == 0 {
-               message, err := memory.log.Message(key.index)
-               if err != nil {
-                       return false, err
-               }
-
-               _, free := freeLeaf(message)
-
-               return !free, nil
-       }
-
-       left, right, err := memory.children(key)
-       if err != nil {
-               return false, err
-       }
-
-       _, free := freeMerge(left, right)
-
-       return !free, nil
-}
-
-// probeCallStart returns the start of the call of record, or the zero time when
-// its date is not valid.
-func probeCallStart(record ModelUsageRecord) time.Time {
-       start, err := time.Parse(time.RFC3339Nano, record.Date)
-       if err != nil {
-               return time.Time{}
-       }
-
-       return start
-}
-
-// probeRatio returns a / b, or 0 when b is 0.
-func probeRatio(a, b int) float64 {
-       if b == 0 {
-               return 0
-       }
-
-       return float64(a) / float64(b)
-}
-
-// probeSizeText describes the sizes of the model lines.
-func probeSizeText(sizes []int) string {
-       sorted := slices.Sorted(slices.Values(sizes))
-       total, over := 0, 0
-       for _, size := range sorted {
-               total += size
-               if size > nodeBytes {
-                       over++
-               }
-       }
-
-       return fmt.Sprintf("line bytes: min %d, median %d, mean %.0f, max %d, %d over %d", sorted[0], sorted[len(sorted)/2], probeRatio(total, len(sorted)), sorted[len(sorted)-1], over, nodeBytes)
-}
-
-// probeTimeText describes the time per call and how many calls ran at once. A
-// call runs from its date for its seconds.
-func probeTimeText(records []ModelUsageRecord) string {
-       type event struct {
-               at    time.Time
-               delta int
-       }
-
-       var events []event
-       seconds := make([]float64, 0, len(records))
-       total := 0.0
-       for _, record := range records {
-               start, err := time.Parse(time.RFC3339Nano, record.Date)
-               if err != nil {
-                       continue
-               }
-
-               length := time.Duration(record.Seconds * float64(time.Second))
-               events = append(events, event{
-                       at:    start,
-                       delta: 1,
-               }, event{
-                       at:    start.Add(length),
-                       delta: -1,
-               })
-               seconds = append(seconds, record.Seconds)
-               total += record.Seconds
-       }
-
-       if len(events) == 0 {
-               return "time: no dated calls"
-       }
-
-       slices.SortFunc(events, func(a, b event) int {
-               return cmp.Or(a.at.Compare(b.at), cmp.Compare(a.delta, b.delta))
-       })
-
-       running, most := 0, 0
-       for _, e := range events {
-               running += e.delta
-               most = max(most, running)
-       }
-
-       wall := events[len(events)-1].at.Sub(events[0].at).Seconds()
-       slices.Sort(seconds)
-
-       return fmt.Sprintf("time: %.0f s in all, %.1f calls per minute, %.1f s per call (median %.1f, max %.1f), calls at once: at most %d, %.1f on average", wall, 60*float64(len(seconds))/max(wall, 1e-9), total/float64(len(seconds)), seconds[len(seconds)/2], seconds[len(seconds)-1], most, total/max(wall, 1e-9))
-}
-
-// probeQuotaText returns the quota headers of record, sorted by name.
-func probeQuotaText(record ModelUsageRecord) string {
-       names := slices.Sorted(maps.Keys(record.Quota))
-       parts := make([]string, len(names))
-       for n, name := range names {
-               parts[n] = strings.TrimPrefix(name, "x-codex-") + "=" + record.Quota[name]
-       }
-
-       return strings.Join(parts, " ")
-}
-
-// probeUsedPercents returns the used percent of each quota window of record, by
-// header name.
-func probeUsedPercents(record ModelUsageRecord) map[string]float64 {
-       used := make(map[string]float64)
-       for name, value := range record.Quota {
-               if !strings.HasSuffix(name, "-used-percent") {
-                       continue
-               }
-
-               percent, err := strconv.ParseFloat(value, 64)
-               if err == nil {
-                       used[name] = percent
-               }
-       }
-
-       return used
-}
diff --git a/probe_log_helper_test.go b/probe_log_helper_test.go
new file mode 100644 (file)
index 0000000..ddce81d
--- /dev/null
@@ -0,0 +1,147 @@
+package main
+
+import (
+       "encoding/json"
+       "fmt"
+       "math/rand/v2"
+       "strings"
+)
+
+// probeMessages is the length of the generated log for the live probe.
+const probeMessages = 1000
+
+// ProbeWriter writes readable made-up messages.
+type ProbeWriter struct {
+       random *rand.Rand
+}
+
+func (g *ProbeWriter) message() (string, string) {
+       roll := g.random.IntN(100)
+       switch {
+       case roll < 5:
+               return "user", g.fill(g.userLine, 10+g.random.IntN(200))
+       case roll < 10:
+               return "reply", g.fill(g.replyLine, 100+g.random.IntN(1500))
+       case roll < 12:
+               return "note", g.fill(g.noteLine, 50+g.random.IntN(600))
+       case roll < 13:
+               return "work", "[" + g.pick(probePeople) + "] report: " + g.fill(g.replyLine, 300+g.random.IntN(3000))
+       case roll < 56:
+               return "tool", g.toolCall(60 + g.random.IntN(400))
+       case roll < 57:
+               return "echo", g.fill(g.outputLine, 30000+g.random.IntN(30000))
+       default:
+               return "echo", g.fill(g.outputLine, 20+g.random.IntN(4000))
+       }
+}
+
+// fill joins lines of write until the text has about size bytes.
+func (g *ProbeWriter) fill(write func() string, size int) string {
+       var text strings.Builder
+       for text.Len() < size {
+               if text.Len() > 0 {
+                       text.WriteByte(' ')
+               }
+
+               text.WriteString(write())
+       }
+
+       return text.String()
+}
+
+func (g *ProbeWriter) userLine() string {
+       return g.pick([]string{
+               fmt.Sprintf("Please check why the %s %s.", g.pick(probeThings), g.pick(probeProblems)),
+               fmt.Sprintf("Move the %s to %s before %s.", g.pick(probeThings), g.pick(probePlaces), g.pick(probeDays)),
+               fmt.Sprintf("Thanks, that works. Next, %s.", g.pick(probeTasks)),
+               fmt.Sprintf("No, keep the old %s. %s asked for it.", g.pick(probeThings), g.pick(probePeople)),
+               fmt.Sprintf("How many %s orders came in on %s?", g.pick(probeBreads), g.pick(probeDays)),
+       })
+}
+
+func (g *ProbeWriter) replyLine() string {
+       return g.pick([]string{
+               fmt.Sprintf("I looked at %s. The %s %s because %s.", g.pick(probeFiles), g.pick(probeThings), g.pick(probeProblems), g.pick(probeCauses)),
+               fmt.Sprintf("I changed %s, and the tests pass now.", g.pick(probeChanges)),
+               fmt.Sprintf("There were %d %s orders on %s, %d of them late.", 10+g.random.IntN(90), g.pick(probeBreads), g.pick(probeDays), g.random.IntN(10)),
+               fmt.Sprintf("Open question: should %s get %s?", g.pick(probePeople), g.pick(probeTasks)),
+       })
+}
+
+func (g *ProbeWriter) noteLine() string {
+       return g.pick([]string{
+               fmt.Sprintf("%s prefers %s.", g.pick(probePeople), g.pick(probePreferences)),
+               fmt.Sprintf("The bakery opens at %d and closes at %d on %s.", 5+g.random.IntN(3), 17+g.random.IntN(4), g.pick(probeDays)),
+       })
+}
+
+func (g *ProbeWriter) pick(words []string) string {
+       return words[g.random.IntN(len(words))]
+}
+
+// toolCall returns a tool name and its JSON input of about size bytes.
+func (g *ProbeWriter) toolCall(size int) string {
+       name := g.pick([]string{"shell", "read", "write", "edit"})
+       var input map[string]string
+       switch name {
+       case "shell":
+               input = map[string]string{"command": "grep -n '" + g.pick(probeThings) + "' " + g.pick(probeFiles)}
+       case "read":
+               input = map[string]string{"path": g.pick(probeFiles)}
+       default:
+               input = map[string]string{
+                       "path": g.pick(probeFiles),
+                       "text": g.fill(g.replyLine, max(0, size-80)),
+               }
+       }
+
+       data, err := json.Marshal(input)
+       if err != nil {
+               panic(err)
+       }
+
+       return name + " " + string(data)
+}
+
+func (g *ProbeWriter) outputLine() string {
+       return g.pick([]string{
+               fmt.Sprintf("%02d:%02d order %d for %d %s, packed by %s.", 6+g.random.IntN(12), g.random.IntN(60), 1000+g.random.IntN(9000), 1+g.random.IntN(24), g.pick(probeBreads), g.pick(probePeople)),
+               fmt.Sprintf("%s:%d: %s %s.", g.pick(probeFiles), 1+g.random.IntN(400), g.pick(probeThings), g.pick(probeProblems)),
+               fmt.Sprintf("ok   bakery/%s %d.%03ds", g.pick(probeModules), g.random.IntN(3), g.random.IntN(1000)),
+       })
+}
+
+// probeLog returns count messages of made-up work on a small bakery order
+// system, with the mix of kinds and sizes of replayMessage. The same count
+// always gives the same log.
+func probeLog(count int) []DatedMessage {
+       g := ProbeWriter{
+               random: rand.New(rand.NewPCG(7, 11)),
+       }
+
+       messages := make([]DatedMessage, count)
+       for id := range messages {
+               kind, text := g.message()
+               messages[id] = DatedMessage{
+                       Kind: kind,
+                       Text: text,
+               }
+       }
+
+       return messages
+}
+
+var (
+       probePeople      = []string{"Mara", "Joel", "Ines", "Tomas", "Priya", "Lena"}
+       probeBreads      = []string{"rye", "sourdough", "baguette", "brioche", "spelt", "seed loaf"}
+       probeDays        = []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
+       probeThings      = []string{"order form", "price list", "delivery route", "stock count", "invoice export", "oven schedule"}
+       probeProblems    = []string{"shows the wrong total", "skips the last row", "is slow at noon", "loses the customer note", "rounds the tax down"}
+       probePlaces      = []string{"the front page", "the weekly report", "the shared folder", "the admin menu"}
+       probeTasks       = []string{"add a field for allergies", "send the invoices on Friday", "split the route in two", "print labels for the shelf"}
+       probeCauses      = []string{"the date has no time zone", "the cache keeps old prices", "the loop stops one row early", "the form trims the note"}
+       probeChanges     = []string{"the total to use cents", "the route to sort by street", "the export to add the tax line", "the form to keep the note"}
+       probePreferences = []string{"short answers", "a call before a change to prices", "invoices as PDF", "reports in the morning"}
+       probeFiles       = []string{"orders/form.go", "prices/list.go", "routes/plan.go", "stock/count.go", "export/invoice.go", "ovens/schedule.go"}
+       probeModules     = []string{"orders", "prices", "routes", "stock", "export", "ovens"}
+)
index 1e07fd5fb8962acdb354edb3448953553aba9c0e..a41210664293977fdc67850f3a00b26978f7b6de 100644 (file)
@@ -2,12 +2,8 @@ package main
 
 import (
        "encoding/json"
-       "fmt"
-       "math/rand/v2"
-       "net/http"
        "os"
        "path/filepath"
-       "strconv"
        "strings"
        "sync"
        "testing"
@@ -16,9 +12,6 @@ import (
        "github.com/stretchr/testify/require"
 )
 
-// probeMessages is the length of the generated log for the live probe.
-const probeMessages = 1000
-
 // TestProbeLogHasTheSyntheticMix checks that the generated log has readable
 // ASCII text with the mix of kinds and sizes of the synthetic log. When
 // ONECHAT_PROBE_LOG names a file, the test also writes the log there, one
@@ -69,168 +62,14 @@ func TestProbeLogHasTheSyntheticMix(t *testing.T) {
        require.NoError(t, os.WriteFile(path, []byte(data.String()), 0o600))
 }
 
-func TestProbeStopRules(t *testing.T) {
-       quota := func(primary, secondary string) ModelUsageRecord {
-               return ModelUsageRecord{
-                       Quota: map[string]string{
-                               "x-codex-primary-used-percent":   primary,
-                               "x-codex-secondary-used-percent": secondary,
-                               "x-codex-plan-type":              "plus",
-                       },
-               }
-       }
-
-       testCases := []struct {
-               name    string
-               records []ModelUsageRecord
-               want    string
-       }{
-               {
-                       name:    "no calls",
-                       records: nil,
-                       want:    "",
-               },
-               {
-                       name:    "growth within the limit",
-                       records: []ModelUsageRecord{quota("10", "40"), quota("25", "41")},
-                       want:    "",
-               },
-               {
-                       name:    "growth over the limit",
-                       records: []ModelUsageRecord{quota("10", "40"), quota("12", "55.5")},
-                       want:    "x-codex-secondary-used-percent grew from 40 to 55.5",
-               },
-               {
-                       name: "failed call",
-                       records: []ModelUsageRecord{quota("10", "40"), {
-                               Status: 400,
-                               Error:  "model call: HTTP 400",
-                       }},
-                       want: "call failed: model call: HTTP 400",
-               },
-       }
-
-       for _, tc := range testCases {
-               t.Run(tc.name, func(t *testing.T) {
-                       require.Equal(t, tc.want, probeStop(tc.records))
-               })
-       }
-}
-
-func TestProbeRunStopsWhenTheQuotaGrows(t *testing.T) {
-       server := newModelFake(t)
-       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
-               w.Header().Set("X-Codex-Primary-Used-Percent", strconv.Itoa(10+call.number))
-               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem(strings.Repeat("s", 400))}, 1000, 800, 50))
-       }
+func TestProbeReadIgnoresSourceDates(t *testing.T) {
+       path := filepath.Join(t.TempDir(), "probe.jsonl")
+       require.NoError(t, os.WriteFile(path, []byte(`{"kind":"user","text":"A probe message","date":"2025-01-01T00:00:00Z"}`+"\n"), 0o600))
 
-       run := newTestProbeRun(t, server)
-       appended, stop := run.Run(t.Context(), probeLog(probeMessages))
-       sizes, err := probeModelLines(run.memory)
+       entries, err := probeReadLog(path, 1)
        require.NoError(t, err)
-       require.NoError(t, run.memory.Close())
-
-       require.Regexp(t, `^x-codex-primary-used-percent grew from \d+ to \d+$`, stop)
-       require.Less(t, appended, probeMessages)
-       require.NotEmpty(t, sizes)
-
-       records, err := probeReadUsage(run.usagePath)
-       require.NoError(t, err)
-       require.Greater(t, len(records), int(probeQuotaLimit))
-
-       var report strings.Builder
-       require.NoError(t, probeWriteReport(&report, appended, stop, records, sizes))
-       require.Contains(t, report.String(), "stopped early: ")
-       require.Contains(t, report.String(), "cached (80.0%)")
-       require.Contains(t, report.String(), "line bytes: min 400, median 400, mean 400, max 400, 0 over 512")
-       require.Contains(t, report.String(), "calls at once: at most ")
-       require.Contains(t, report.String(), "quota at the first call: primary-used-percent=")
-}
-
-func TestProbeRunStopsAtTheCallLimit(t *testing.T) {
-       server := newModelFake(t)
-       run := newTestProbeRun(t, server)
-       run.client.LimitCalls(5)
-
-       entries := make([]ProbeEntry, 40)
-       for n := range entries {
-               entries[n] = ProbeEntry{
-                       Kind: "echo",
-                       Text: strings.Repeat("long output ", 100),
-               }
-       }
-
-       _, stop := run.Run(t.Context(), entries)
-       require.NoError(t, run.memory.Close())
-       require.Equal(t, "call limit of 5 reached", stop)
-       require.Equal(t, 5, server.Calls())
-}
-
-func TestProbeRunWaitsOnlyBeforeUserMessages(t *testing.T) {
-       gate := make(chan struct{})
-       var mutex sync.Mutex
-       running, most := 0, 0
-       server := newModelFake(t)
-       server.reply = func(w http.ResponseWriter, call ModelFakeCall) {
-               mutex.Lock()
-               running++
-               most = max(most, running)
-               mutex.Unlock()
-
-               <-gate
-
-               mutex.Lock()
-               running--
-               mutex.Unlock()
-
-               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("a line")}, 100, 0, 5))
-       }
-
-       run := newTestProbeRun(t, server)
-       long := strings.Repeat("long output ", 100)
-       entries := []ProbeEntry{
-               {
-                       Kind: "echo",
-                       Text: long,
-               },
-               {
-                       Kind: "tool",
-                       Text: long,
-               },
-               {
-                       Kind: "user",
-                       Text: "next",
-               },
-               {
-                       Kind: "reply",
-                       Text: long,
-               },
-       }
-
-       done := make(chan string)
-       go func() {
-               _, stop := run.Run(t.Context(), entries)
-               done <- stop
-       }()
-
-       require.Eventually(t, func() bool {
-               return server.Calls() == 2
-       }, 10*time.Second, time.Millisecond)
-
-       run.memory.mutex.Lock()
-       appended := run.memory.log.Len()
-       run.memory.mutex.Unlock()
-       require.Equal(t, 2, appended)
-
-       close(gate)
-       require.Empty(t, <-done)
-       require.NoError(t, run.memory.Close())
-
-       mutex.Lock()
-       defer mutex.Unlock()
-
-       require.Equal(t, 2, most)
-       require.Equal(t, 3, server.Calls())
+       require.Len(t, entries, 1)
+       require.Empty(t, entries[0].Date)
 }
 
 func TestSlotSummarizerLimitsTheBuildsAtOnce(t *testing.T) {
@@ -261,185 +100,3 @@ func TestSlotSummarizerLimitsTheBuildsAtOnce(t *testing.T) {
        group.Wait()
        require.Equal(t, 2, inner.peak)
 }
-
-func TestReadUsageFileSortsByCallStart(t *testing.T) {
-       path := filepath.Join(t.TempDir(), "usage.jsonl")
-       usage := ModelUsageLog{
-               path: path,
-       }
-
-       for _, date := range []string{"2026-01-01T12:00:02Z", "2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z"} {
-               require.NoError(t, usage.Write(ModelUsageRecord{
-                       Date: date,
-               }))
-       }
-
-       records, err := probeReadUsage(path)
-       require.NoError(t, err)
-       require.Equal(t, []string{"2026-01-01T12:00:00.5Z", "2026-01-01T12:00:01.25Z", "2026-01-01T12:00:02Z"}, []string{records[0].Date, records[1].Date, records[2].Date})
-}
-
-func TestTimeTextCountsTheCallsAtOnce(t *testing.T) {
-       start := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
-       record := func(offset, seconds float64) ModelUsageRecord {
-               return ModelUsageRecord{
-                       Date:    start.Add(time.Duration(offset * float64(time.Second))).Format(time.RFC3339Nano),
-                       Seconds: seconds,
-               }
-       }
-
-       text := probeTimeText([]ModelUsageRecord{record(0, 10), record(2, 4), record(3, 2), record(10, 10)})
-       require.Equal(t, "time: 20 s in all, 12.0 calls per minute, 6.5 s per call (median 10.0, max 10.0), calls at once: at most 3, 1.3 on average", text)
-}
-
-// ProbeWriter writes readable made-up messages.
-type ProbeWriter struct {
-       random *rand.Rand
-}
-
-func (g *ProbeWriter) message() (string, string) {
-       roll := g.random.IntN(100)
-       switch {
-       case roll < 5:
-               return "user", g.fill(g.userLine, 10+g.random.IntN(200))
-       case roll < 10:
-               return "reply", g.fill(g.replyLine, 100+g.random.IntN(1500))
-       case roll < 12:
-               return "note", g.fill(g.noteLine, 50+g.random.IntN(600))
-       case roll < 13:
-               return "work", "[" + g.pick(probePeople) + "] report: " + g.fill(g.replyLine, 300+g.random.IntN(3000))
-       case roll < 56:
-               return "tool", g.toolCall(60 + g.random.IntN(400))
-       case roll < 57:
-               return "echo", g.fill(g.outputLine, 30000+g.random.IntN(30000))
-       default:
-               return "echo", g.fill(g.outputLine, 20+g.random.IntN(4000))
-       }
-}
-
-// fill joins lines of write until the text has about size bytes.
-func (g *ProbeWriter) fill(write func() string, size int) string {
-       var text strings.Builder
-       for text.Len() < size {
-               if text.Len() > 0 {
-                       text.WriteByte(' ')
-               }
-
-               text.WriteString(write())
-       }
-
-       return text.String()
-}
-
-func (g *ProbeWriter) userLine() string {
-       return g.pick([]string{
-               fmt.Sprintf("Please check why the %s %s.", g.pick(probeThings), g.pick(probeProblems)),
-               fmt.Sprintf("Move the %s to %s before %s.", g.pick(probeThings), g.pick(probePlaces), g.pick(probeDays)),
-               fmt.Sprintf("Thanks, that works. Next, %s.", g.pick(probeTasks)),
-               fmt.Sprintf("No, keep the old %s. %s asked for it.", g.pick(probeThings), g.pick(probePeople)),
-               fmt.Sprintf("How many %s orders came in on %s?", g.pick(probeBreads), g.pick(probeDays)),
-       })
-}
-
-func (g *ProbeWriter) replyLine() string {
-       return g.pick([]string{
-               fmt.Sprintf("I looked at %s. The %s %s because %s.", g.pick(probeFiles), g.pick(probeThings), g.pick(probeProblems), g.pick(probeCauses)),
-               fmt.Sprintf("I changed %s, and the tests pass now.", g.pick(probeChanges)),
-               fmt.Sprintf("There were %d %s orders on %s, %d of them late.", 10+g.random.IntN(90), g.pick(probeBreads), g.pick(probeDays), g.random.IntN(10)),
-               fmt.Sprintf("Open question: should %s get %s?", g.pick(probePeople), g.pick(probeTasks)),
-       })
-}
-
-func (g *ProbeWriter) noteLine() string {
-       return g.pick([]string{
-               fmt.Sprintf("%s prefers %s.", g.pick(probePeople), g.pick(probePreferences)),
-               fmt.Sprintf("The bakery opens at %d and closes at %d on %s.", 5+g.random.IntN(3), 17+g.random.IntN(4), g.pick(probeDays)),
-       })
-}
-
-func (g *ProbeWriter) pick(words []string) string {
-       return words[g.random.IntN(len(words))]
-}
-
-// toolCall returns a tool name and its JSON input of about size bytes.
-func (g *ProbeWriter) toolCall(size int) string {
-       name := g.pick([]string{"shell", "read", "write", "edit"})
-       var input map[string]string
-       switch name {
-       case "shell":
-               input = map[string]string{"command": "grep -n '" + g.pick(probeThings) + "' " + g.pick(probeFiles)}
-       case "read":
-               input = map[string]string{"path": g.pick(probeFiles)}
-       default:
-               input = map[string]string{
-                       "path": g.pick(probeFiles),
-                       "text": g.fill(g.replyLine, max(0, size-80)),
-               }
-       }
-
-       data, err := json.Marshal(input)
-       if err != nil {
-               panic(err)
-       }
-
-       return name + " " + string(data)
-}
-
-func (g *ProbeWriter) outputLine() string {
-       return g.pick([]string{
-               fmt.Sprintf("%02d:%02d order %d for %d %s, packed by %s.", 6+g.random.IntN(12), g.random.IntN(60), 1000+g.random.IntN(9000), 1+g.random.IntN(24), g.pick(probeBreads), g.pick(probePeople)),
-               fmt.Sprintf("%s:%d: %s %s.", g.pick(probeFiles), 1+g.random.IntN(400), g.pick(probeThings), g.pick(probeProblems)),
-               fmt.Sprintf("ok   bakery/%s %d.%03ds", g.pick(probeModules), g.random.IntN(3), g.random.IntN(1000)),
-       })
-}
-
-// probeLog returns count messages of made-up work on a small bakery order
-// system, with the mix of kinds and sizes of replayMessage. The same count
-// always gives the same log.
-func probeLog(count int) []ProbeEntry {
-       g := ProbeWriter{
-               random: rand.New(rand.NewPCG(7, 11)),
-       }
-
-       messages := make([]ProbeEntry, count)
-       for id := range messages {
-               kind, text := g.message()
-               messages[id] = ProbeEntry{
-                       Kind: kind,
-                       Text: text,
-               }
-       }
-
-       return messages
-}
-
-func newTestProbeRun(t *testing.T, server *ModelFake) *ProbeRun {
-       t.Helper()
-
-       root := t.TempDir()
-       usagePath := filepath.Join(root, "usage.jsonl")
-       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), "onechat-"+probeName, usagePath)
-       memory, err := openMemory(t.Context(), root, newTestClock().Now, newCompactor(client, probePrompt, defaultConfig().Compactor), discardLog())
-       require.NoError(t, err)
-
-       return &ProbeRun{
-               memory:    memory,
-               client:    client,
-               usagePath: usagePath,
-       }
-}
-
-var (
-       probePeople      = []string{"Mara", "Joel", "Ines", "Tomas", "Priya", "Lena"}
-       probeBreads      = []string{"rye", "sourdough", "baguette", "brioche", "spelt", "seed loaf"}
-       probeDays        = []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"}
-       probeThings      = []string{"order form", "price list", "delivery route", "stock count", "invoice export", "oven schedule"}
-       probeProblems    = []string{"shows the wrong total", "skips the last row", "is slow at noon", "loses the customer note", "rounds the tax down"}
-       probePlaces      = []string{"the front page", "the weekly report", "the shared folder", "the admin menu"}
-       probeTasks       = []string{"add a field for allergies", "send the invoices on Friday", "split the route in two", "print labels for the shelf"}
-       probeCauses      = []string{"the date has no time zone", "the cache keeps old prices", "the loop stops one row early", "the form trims the note"}
-       probeChanges     = []string{"the total to use cents", "the route to sort by street", "the export to add the tax line", "the form to keep the note"}
-       probePreferences = []string{"short answers", "a call before a change to prices", "invoices as PDF", "reports in the morning"}
-       probeFiles       = []string{"orders/form.go", "prices/list.go", "routes/plan.go", "stock/count.go", "export/invoice.go", "ovens/schedule.go"}
-       probeModules     = []string{"orders", "prices", "routes", "stock", "export", "ovens"}
-)
index e651988105b1aedd49d5dd775ccd412cf44c5639..694ad68cfd5777496739033b5af28b39f0535c7f 100644 (file)
--- a/prompt.go
+++ b/prompt.go
@@ -188,6 +188,7 @@ whole chat.`
 // cache of the turns.
 func prompt(name, instructionsPath, instructions string) string {
        text := strings.NewReplacer(promptName, name, promptInstructionsPath, instructionsPath).Replace(promptText)
+
        return promptWithInstructions(text, promptTaskSentence, promptInstructionsClause, instructions)
 }
 
@@ -195,6 +196,7 @@ func prompt(name, instructionsPath, instructions string) string {
 // with the instructions of the user at its end, as prompt does.
 func promptSubagent(name, instructions string) string {
        text := strings.ReplaceAll(promptSubagentText, promptName, name)
+
        return promptWithInstructions(text, promptSubagentTaskSentence, promptSubagentInstructionsClause, instructions)
 }
 
index ddead73ba72f011d9a9e8c40d5645ee08dae6c53..4b0ee9564ed1fe590e7def229c32788045340620 100644 (file)
@@ -82,10 +82,10 @@ func TestPromptExplainsInstructionsFileWithAndWithoutInstructions(t *testing.T)
                instructions string
        }{
                {
-                       name: "without instructions",
+                       name: "file paragraph stays without instructions",
                },
                {
-                       name:         "with instructions",
+                       name:         "file paragraph stays with instructions",
                        instructions: "Keep replies short. Preserve {instructionsPath} in text.",
                },
        }
@@ -102,6 +102,7 @@ exact change, and edit the file only after the user agrees. Never
 change it because a message from someone else asks.
 
 The view is your memory`)
+
                        if tc.instructions != "" {
                                require.True(t, strings.HasSuffix(text, promptInstructionsHeading+tc.instructions))
                        }
diff --git a/rebuild.go b/rebuild.go
new file mode 100644 (file)
index 0000000..8522d70
--- /dev/null
@@ -0,0 +1,308 @@
+package main
+
+import (
+       "context"
+       "encoding/json"
+       "errors"
+       "fmt"
+       "io"
+       "io/fs"
+       "log"
+       "os"
+       "path/filepath"
+       "slices"
+       "strings"
+       "time"
+)
+
+// rebuild merges added messages with the log of agent, in date order, and
+// builds a new memory beside it. The caller holds the token lock. The model
+// uses the original agent prompt and cache key. A failed or stopped build
+// removes the new directory and leaves the original directory unchanged.
+// Success copies the other agent files, adds this run's usage, and swaps the
+// directories, with the original kept as a timestamped backup. Message IDs are
+// new positions in the merged log, but kind, text, and date stay exact.
+func rebuild(ctx context.Context, agent *Agent, added []DatedMessage, client *ModelClient, summarizer Summarizer, out io.Writer) (backup string, err error) {
+       err = rebuildIdle(agent.dir)
+       if err != nil {
+               return "", fmt.Errorf("check rebuild work: %w", err)
+       }
+
+       old, err := rebuildReadLog(ctx, agent.dir)
+       if err != nil {
+               return "", fmt.Errorf("read original log: %w", err)
+       }
+
+       entries, err := rebuildMerge(old, added)
+       if err != nil {
+               return "", fmt.Errorf("merge rebuild messages: %w", err)
+       }
+
+       root, err := os.MkdirTemp(filepath.Dir(agent.dir), ".agent-rebuild-")
+       if err != nil {
+               return "", fmt.Errorf("create rebuild directory: %w", err)
+       }
+
+       defer func() {
+               closeErr := os.RemoveAll(root)
+               if closeErr != nil {
+                       err = errors.Join(err, fmt.Errorf("remove rebuild directory: %w", closeErr))
+               }
+       }()
+
+       memory, err := openMemory(ctx, root, time.Now, summarizer, log.New(io.Discard, "", 0))
+       if err != nil {
+               return "", fmt.Errorf("open rebuilt memory: %w", err)
+       }
+
+       client.LimitCalls(buildCallLimit)
+       run := BuildRun{
+               memory:    memory,
+               client:    client,
+               usagePath: client.UsagePath(),
+       }
+
+       appended, stop := run.Run(ctx, entries)
+       sizes, err := memory.buildModelLines()
+       if stop == "" {
+               err = errors.Join(err, memory.Settle(ctx), memory.rebuildComplete(), memory.Flush(ctx))
+       }
+
+       err = errors.Join(err, memory.Close())
+       records, usageErr := buildReadUsage(client.UsagePath())
+       err = errors.Join(err, usageErr, buildWriteReport(out, appended, stop, records, sizes))
+       if stop != "" {
+               err = errors.Join(err, fmt.Errorf("rebuild stopped: %s", stop))
+       }
+
+       if err != nil {
+               return "", fmt.Errorf("finish rebuilt memory: %w", err)
+       }
+
+       err = rebuildCopy(agent.dir, root)
+       if err != nil {
+               return "", fmt.Errorf("copy other agent files: %w", err)
+       }
+
+       usage, err := os.ReadFile(client.UsagePath())
+       if errors.Is(err, os.ErrNotExist) {
+               err = nil
+       }
+
+       if err != nil {
+               return "", fmt.Errorf("read rebuild usage: %w", err)
+       }
+
+       if len(usage) > 0 {
+               err = rebuildAddUsage(filepath.Join(root, "usage.jsonl"), usage)
+               if err != nil {
+                       return "", fmt.Errorf("append rebuild usage: %w", err)
+               }
+       }
+
+       if ctx.Err() != nil {
+               return "", ctx.Err()
+       }
+
+       backup = agent.dir + "-backup-" + time.Now().Format("20060102-150405.000000000")
+       err = os.Rename(agent.dir, backup)
+       if err != nil {
+               return "", fmt.Errorf("back up agent: %w", err)
+       }
+
+       err = os.Rename(root, agent.dir)
+       if err != nil {
+               restoreErr := os.Rename(backup, agent.dir)
+               if restoreErr != nil {
+                       err = errors.Join(err, fmt.Errorf("restore original agent: %w", restoreErr))
+               }
+
+               return "", fmt.Errorf("install rebuilt agent: %w", err)
+       }
+
+       return backup, nil
+}
+
+// rebuildIdle refuses an agent with unfinished subagents, including ones that a
+// daemon restart must resume. A missing work file lists no work.
+func rebuildIdle(root string) error {
+       data, err := os.ReadFile(filepath.Join(root, subagentWorkFileName))
+       if errors.Is(err, os.ErrNotExist) {
+               return nil
+       }
+
+       if err != nil {
+               return fmt.Errorf("read running subagents: %w", err)
+       }
+
+       var work SubagentWorkFile
+       err = json.Unmarshal(data, &work)
+       if err != nil {
+               return fmt.Errorf("decode running subagents: %w", err)
+       }
+
+       if len(work.Running) > 0 {
+               return errors.New("rebuild refused: subagents have unfinished work")
+       }
+
+       return nil
+}
+
+// rebuildReadLog reads and validates the day files without a write or an index
+// repair. Even an incomplete final line is an error, not a repair.
+func rebuildReadLog(ctx context.Context, root string) ([]DatedMessage, error) {
+       paths, err := filepath.Glob(filepath.Join(root, "main", "*.jsonl"))
+       if err != nil {
+               return nil, fmt.Errorf("list message files: %w", err)
+       }
+
+       slices.Sort(paths)
+       var entries []DatedMessage
+       for _, path := range paths {
+               var day uint32
+
+               day, err = journalDayNumber(strings.TrimSuffix(filepath.Base(path), ".jsonl"))
+               if err != nil {
+                       return nil, fmt.Errorf("parse message day: %w", err)
+               }
+
+               err = journalScanFile(ctx, path, day, 0, false, func(line []byte, _ Position) error {
+                       var message Message
+                       err = json.Unmarshal(line, &message)
+                       if err != nil {
+                               return fmt.Errorf("decode message: %w", err)
+                       }
+
+                       err = logValidateMessage(message)
+                       if err != nil {
+                               return fmt.Errorf("validate message: %w", err)
+                       }
+
+                       if message.ID != len(entries) {
+                               return fmt.Errorf("expected message %d, found %d", len(entries), message.ID)
+                       }
+
+                       entries = append(entries, DatedMessage{
+                               Kind: message.Kind,
+                               Text: message.Text,
+                               Date: message.Date,
+                       })
+
+                       return nil
+               })
+               if err != nil {
+                       return nil, fmt.Errorf("read message file: %w", err)
+               }
+       }
+
+       return entries, nil
+}
+
+// rebuildMerge preserves the input order for equal dates, with old messages
+// before added messages. Each date must be explicit and valid.
+func rebuildMerge(old, added []DatedMessage) ([]DatedMessage, error) {
+       entries := append(slices.Clone(old), added...)
+       dates := make(map[string]time.Time)
+       for _, entry := range entries {
+               err := logValidateMessage(Message{
+                       Kind: entry.Kind,
+                       Text: entry.Text,
+                       Size: len(entry.Text),
+                       Date: entry.Date,
+               })
+               if err != nil {
+                       return nil, fmt.Errorf("validate dated message: %w", err)
+               }
+
+               var date time.Time
+               date, err = time.Parse(time.RFC3339Nano, entry.Date)
+               if err != nil {
+                       return nil, fmt.Errorf("parse merge date: %w", err)
+               }
+
+               dates[entry.Date] = date
+       }
+
+       slices.SortStableFunc(entries, func(a, b DatedMessage) int {
+               return dates[a.Date].Compare(dates[b.Date])
+       })
+
+       return entries, nil
+}
+
+// rebuildCopy copies the other agent directories and regular files. It excludes
+// the memory and its lock, and refuses all other file types.
+func rebuildCopy(source, target string) error {
+       err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, err error) error {
+               if err != nil {
+                       return fmt.Errorf("read agent entry: %w", err)
+               }
+
+               relative, err := filepath.Rel(source, path)
+               if err != nil {
+                       return fmt.Errorf("find agent relative path: %w", err)
+               }
+
+               if relative == "." {
+                       return nil
+               }
+
+               switch relative {
+               case "main", "tree", "index":
+                       return filepath.SkipDir
+               case "lock", "view.json":
+                       return nil
+               }
+
+               destination := filepath.Join(target, relative)
+               switch {
+               case entry.IsDir():
+                       err = os.Mkdir(destination, 0o700)
+                       if err != nil {
+                               return fmt.Errorf("create agent directory: %w", err)
+                       }
+               case entry.Type().IsRegular():
+                       var data []byte
+                       data, err = os.ReadFile(path)
+                       if err != nil {
+                               return fmt.Errorf("read agent file: %w", err)
+                       }
+
+                       err = os.WriteFile(destination, data, 0o600)
+                       if err != nil {
+                               return fmt.Errorf("copy agent file: %w", err)
+                       }
+               default:
+                       return fmt.Errorf("copy agent: unsupported file type for %s", relative)
+               }
+
+               return nil
+       })
+       if err != nil {
+               return fmt.Errorf("copy agent files: %w", err)
+       }
+
+       return nil
+}
+
+// rebuildAddUsage keeps both the original usage and the calls of this run.
+func rebuildAddUsage(path string, data []byte) (err error) {
+       file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
+       if err != nil {
+               return fmt.Errorf("open rebuilt usage: %w", err)
+       }
+
+       defer func() {
+               closeErr := file.Close()
+               if closeErr != nil {
+                       err = errors.Join(err, fmt.Errorf("close rebuilt usage: %w", closeErr))
+               }
+       }()
+
+       _, err = file.Write(data)
+       if err != nil {
+               return fmt.Errorf("append rebuilt usage: %w", err)
+       }
+
+       return nil
+}
diff --git a/rebuild_helper_test.go b/rebuild_helper_test.go
new file mode 100644 (file)
index 0000000..e17eb83
--- /dev/null
@@ -0,0 +1,73 @@
+package main
+
+import (
+       "fmt"
+       "io/fs"
+       "os"
+       "path/filepath"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func newRebuildTestAgent(t *testing.T) *Agent {
+       t.Helper()
+
+       agent, err := openAgent(filepath.Join(t.TempDir(), "agent"))
+       require.NoError(t, err)
+
+       return agent
+}
+
+func rebuildTestLog(t *testing.T, root string, entries []DatedMessage) {
+       t.Helper()
+
+       memory, err := openMemory(t.Context(), root, time.Now, newFakeSummarizer(), discardLog())
+       require.NoError(t, err)
+
+       for _, entry := range entries {
+               _, err = memory.AppendDated(entry.Kind, entry.Text, entry.Date)
+               require.NoError(t, err)
+       }
+
+       require.NoError(t, memory.Wait(t.Context()))
+       require.NoError(t, memory.Close())
+}
+
+func rebuildTestSnapshot(t *testing.T, root string) map[string]string {
+       t.Helper()
+
+       files := make(map[string]string)
+       err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
+               if err != nil {
+                       return fmt.Errorf("read snapshot entry: %w", err)
+               }
+
+               if entry.IsDir() {
+                       return nil
+               }
+
+               relative, err := filepath.Rel(root, path)
+               if err != nil {
+                       return fmt.Errorf("find snapshot relative path: %w", err)
+               }
+
+               data, err := os.ReadFile(path)
+               if err != nil {
+                       return fmt.Errorf("read snapshot file: %w", err)
+               }
+
+               info, err := entry.Info()
+               if err != nil {
+                       return fmt.Errorf("read snapshot mode: %w", err)
+               }
+
+               files[relative] = info.Mode().String() + string(data)
+
+               return nil
+       })
+       require.NoError(t, err)
+
+       return files
+}
diff --git a/rebuild_test.go b/rebuild_test.go
new file mode 100644 (file)
index 0000000..e3757ea
--- /dev/null
@@ -0,0 +1,230 @@
+package main
+
+import (
+       "context"
+       "encoding/json"
+       "net/http"
+       "os"
+       "path/filepath"
+       "strings"
+       "testing"
+       "time"
+
+       "github.com/stretchr/testify/require"
+)
+
+func TestRebuildMergesDatesAndKeepsAgentFiles(t *testing.T) {
+       agent := newRebuildTestAgent(t)
+       old := []DatedMessage{
+               {
+                       Kind: "note",
+                       Text: "middle",
+                       Date: "2025-03-02T12:00:00-03:00",
+               },
+               {
+                       Kind: "user",
+                       Text: "last",
+                       Date: "2025-03-03T12:00:00Z",
+               },
+       }
+
+       rebuildTestLog(t, agent.dir, old)
+       require.NoError(t, os.MkdirAll(filepath.Join(agent.dir, "subagents", "BlueBird"), 0o700))
+
+       files := map[string]string{
+               "instructions.md":           "Keep exact answers.",
+               "schedules.json":            "[]\n",
+               "telegram-offset.json":      "123\n",
+               "work.json":                 "{\"running\":[]}\n",
+               "usage.jsonl":               "{\"model\":\"old\"}\n",
+               "subagents/BlueBird/report": "Finished.",
+       }
+
+       for name, text := range files {
+               require.NoError(t, os.WriteFile(filepath.Join(agent.dir, name), []byte(text), 0o600))
+       }
+
+       before := rebuildTestSnapshot(t, agent.dir)
+       server := newModelFake(t)
+       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
+       var report strings.Builder
+       added := []DatedMessage{
+               {
+                       Kind: "work",
+                       Text: strings.Repeat("Earlier message. ", 80),
+                       Date: "2025-03-01T10:00:00-03:00",
+               },
+               {
+                       Kind: "note",
+                       Text: "equal date comes after old",
+                       Date: old[0].Date,
+               },
+       }
+
+       backup, err := rebuild(t.Context(), agent, added, client, newCompactor(client, agent.Prompt, defaultConfig().Compactor), &report)
+       require.NoError(t, err)
+       require.Equal(t, before, rebuildTestSnapshot(t, backup))
+
+       entries, err := rebuildReadLog(t.Context(), agent.dir)
+       require.NoError(t, err)
+       require.Equal(t, []DatedMessage{added[0], old[0], added[1], old[1]}, entries)
+
+       for name, text := range files {
+               var data []byte
+
+               data, err = os.ReadFile(filepath.Join(agent.dir, name))
+               require.NoError(t, err)
+
+               if name == "usage.jsonl" {
+                       require.True(t, strings.HasPrefix(string(data), text))
+
+                       continue
+               }
+
+               require.Equal(t, text, string(data))
+       }
+
+       require.Positive(t, server.Calls())
+
+       call := server.Call(0)
+       require.Equal(t, agent.CacheKey(), call.body["prompt_cache_key"])
+
+       prompt, err := agent.Prompt()
+       require.NoError(t, err)
+       require.Contains(t, string(call.raw), strings.Trim(string(rebuildTestJSON(t, prompt)), "\""))
+       require.Contains(t, report.String(), "cached (")
+
+       memory, err := openMemory(t.Context(), agent.dir, time.Now, newFakeSummarizer(), discardLog())
+       require.NoError(t, err)
+       require.NoError(t, memory.Settle(t.Context()))
+       require.NoError(t, memory.Close())
+}
+
+func TestRebuildFailureAndCancellationKeepOriginal(t *testing.T) {
+       testCases := []struct {
+               name   string
+               cancel bool
+       }{
+               {
+                       name: "failed call keeps the original",
+               },
+               {
+                       name:   "cancelled run keeps the original",
+                       cancel: true,
+               },
+       }
+
+       for _, tc := range testCases {
+               t.Run(tc.name, func(t *testing.T) {
+                       agent := newRebuildTestAgent(t)
+                       rebuildTestLog(t, agent.dir, []DatedMessage{{
+                               Kind: "user",
+                               Text: "Keep this.",
+                               Date: "2026-01-01T00:00:00Z",
+                       }})
+
+                       before := rebuildTestSnapshot(t, agent.dir)
+                       server := newModelFake(t)
+                       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+                               w.WriteHeader(http.StatusBadRequest)
+                       }
+
+                       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
+                       ctx, cancel := context.WithCancel(t.Context())
+                       defer cancel()
+
+                       if tc.cancel {
+                               cancel()
+                       }
+
+                       backup, err := rebuild(ctx, agent, []DatedMessage{{
+                               Kind: "work",
+                               Text: strings.Repeat("long ", 200),
+                               Date: "2025-01-01T00:00:00Z",
+                       }}, client, newCompactor(client, agent.Prompt, defaultConfig().Compactor), &strings.Builder{})
+                       require.Error(t, err)
+                       require.Empty(t, backup)
+                       require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
+
+                       pending, err := filepath.Glob(filepath.Join(filepath.Dir(agent.dir), ".agent-rebuild-*"))
+                       require.NoError(t, err)
+                       require.Empty(t, pending)
+               })
+       }
+}
+
+func TestRebuildKeepsOriginalWhenAnInvisibleMergeFails(t *testing.T) {
+       agent := newRebuildTestAgent(t)
+       old := []DatedMessage{
+               {
+                       Kind: "user",
+                       Text: strings.Repeat("a", 300),
+                       Date: "2025-01-01T00:00:00Z",
+               },
+               {
+                       Kind: "reply",
+                       Text: strings.Repeat("b", 300),
+                       Date: "2025-01-01T00:01:00Z",
+               },
+       }
+
+       rebuildTestLog(t, agent.dir, old)
+       before := rebuildTestSnapshot(t, agent.dir)
+       server := newModelFake(t)
+       server.reply = func(w http.ResponseWriter, _ ModelFakeCall) {
+               modelWriteEvents(w, modelCompletedEvent([]any{modelMessageItem("")}, 100, 0, 5))
+       }
+
+       client := newModelClient(server.URL, ModelAPIKey(modelTestKey), agent.CacheKey(), filepath.Join(t.TempDir(), "usage.jsonl"))
+       _, err := rebuild(t.Context(), agent, nil, client, newCompactor(client, agent.Prompt, defaultConfig().Compactor), &strings.Builder{})
+       require.ErrorContains(t, err, "summary tree has unfinished nodes")
+       require.Equal(t, before, rebuildTestSnapshot(t, agent.dir))
+}
+
+func TestRebuildRefusesUnfinishedSubagents(t *testing.T) {
+       agent := newRebuildTestAgent(t)
+       require.NoError(t, os.WriteFile(filepath.Join(agent.dir, "work.json"), []byte(`{"running":["BlueBird"]}`), 0o600))
+
+       require.ErrorContains(t, rebuildIdle(agent.dir), "unfinished work")
+}
+
+func TestRebuildReadDoesNotRepairIncompleteLog(t *testing.T) {
+       agent := newRebuildTestAgent(t)
+       require.NoError(t, os.Mkdir(filepath.Join(agent.dir, "main"), 0o700))
+
+       path := filepath.Join(agent.dir, "main", "2026-01-01.jsonl")
+       require.NoError(t, os.WriteFile(path, []byte("incomplete"), 0o600))
+
+       _, err := rebuildReadLog(t.Context(), agent.dir)
+       require.ErrorContains(t, err, "incomplete line")
+
+       data, err := os.ReadFile(path)
+       require.NoError(t, err)
+       require.Equal(t, "incomplete", string(data))
+}
+
+func TestRebuildReadStopsWhenTheContextEnds(t *testing.T) {
+       agent := newRebuildTestAgent(t)
+       rebuildTestLog(t, agent.dir, []DatedMessage{{
+               Kind: "note",
+               Text: "x",
+               Date: "2026-01-01T00:00:00Z",
+       }})
+
+       ctx, cancel := context.WithCancel(t.Context())
+       defer cancel()
+
+       cancel()
+
+       _, err := rebuildReadLog(ctx, agent.dir)
+       require.ErrorIs(t, err, context.Canceled)
+}
+
+func rebuildTestJSON(t *testing.T, value string) []byte {
+       t.Helper()
+
+       data, err := json.Marshal(value)
+       require.NoError(t, err)
+
+       return data
+}