Skip to content

Commit f734c69

Browse files
authored
feat(parser): add aider session parser (#740)
## Summary Adds an `aider` session parser. aider stores per-repo chat history as Markdown at `<repo>/.aider.chat.history.md`, where each file accumulates multiple `# aider chat started at ...` runs. ## What it does - Splits a history file into one session per run, using the existing virtual-path fan-out (`<historyFile>#<runIdx>`) already used by the Shelley and Zed parsers, so a physical file maps to multiple sessions rather than one flattened blob. - Parses `####` user prompts, assistant prose, and `> ` tool/edit lines. aider has no tool role in agentsview, so tool/edit lines surface as assistant transcript content (the `> Applied edit to ...` lines are preserved verbatim), mirroring the gptme parser. - Discovers history files with a bounded, time-budgeted (2s) rootless walk under a configurable root — default `$HOME`, override via `AIDER_DIR` / `aider_dirs` — with a skip-set and a depth cap. The agent uses a shallow file watch so it does not recursively watch the whole home directory; new runs are picked up by the periodic sync. ## Identity and limitations - Session IDs derive from the history-file path, the run's header timestamp, and an ordinal among same-header runs, so appends and different-header edits never re-key existing sessions. Residual: runs that share a byte-identical header timestamp (same repo, same second) disambiguate by position, so removing an earlier same-header run re-keys its later same-header siblings — rare given aider's 1-second header resolution, and pinned by a test. - Per the multi-session-per-file model (same as Shelley/Zed), a run removed from a still-present file is reconciled on the next full resync rather than incrementally. ## Where to look `internal/parser/aider.go` (parser + discovery), `internal/sync/engine.go` (`processAider` fan-out and the virtual-path stat resolution that lets a single session re-sync via the live watcher), `internal/parser/types.go` (registry entry, shallow watch). Co-authored-by: KBS <youdie006@users.noreply.github.com>
1 parent 11a5e97 commit f734c69

14 files changed

Lines changed: 2289 additions & 23 deletions

File tree

README.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,7 @@ agentsview auto-discovers sessions from all of these:
270270

271271
| Agent | Session Directory |
272272
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
273+
| Aider | `<repo>/.aider.chat.history.md` (per repo; bounded scan of `~`, set `AIDER_DIR` to scope) |
273274
| Amp | `~/.local/share/amp/threads/` |
274275
| Antigravity | `~/.gemini/antigravity/` |
275276
| Antigravity CLI | `~/.gemini/antigravity-cli/` (see note below) |
@@ -310,6 +311,29 @@ agentsview auto-discovers sessions from all of these:
310311
Each directory can be overridden with an environment variable. See the
311312
[configuration docs](https://agentsview.io/configuration/) for details.
312313

314+
### Aider: per-repo Markdown logs
315+
316+
Aider has no central session store; it writes one `.aider.chat.history.md`
317+
Markdown log per repository, and one log accumulates many runs (one per `aider`
318+
launch, delimited by `# aider chat started at ...` headers). agentsview indexes
319+
**each run as its own session**.
320+
321+
Discovery is a bounded, symlink-safe walk of your home directory: it descends at
322+
most four levels below `~`, skips vendor/build/VCS directories by name
323+
(`node_modules`, `target`, `.git`, `Library`, `go`, `.cargo`, and similar), and
324+
stops after a two-second wall-clock budget so a large home tree cannot stall the
325+
scan. **A repository whose `.aider.chat.history.md` sits more than four levels
326+
under `~`, or outside your home directory, will not be found by the default
327+
scan.** Point `AIDER_DIR` (or the `aider_dirs` config key) at that code root to
328+
index it and to scope and speed up the walk. The live file watcher only watches
329+
the home root shallowly (registering it recursively would inotify the entire
330+
home tree); new repos are picked up by the periodic sync, which runs every 15
331+
minutes.
332+
333+
Because the format is Markdown-derived, roles are reconstructed from line
334+
prefixes and there are no per-message timestamps; a run's start time comes from
335+
its `# aider chat started at ...` header (written in local time, assumed UTC).
336+
313337
### Antigravity CLI: high-resolution transcripts
314338

315339
Antigravity CLI sessions now appear in two on-disk formats. Newer releases store

cmd/agentsview/session_export.go

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"fmt"
99
"io"
1010
"os"
11+
"strings"
1112

1213
"github.com/spf13/cobra"
1314
"go.kenn.io/agentsview/internal/config"
@@ -63,6 +64,49 @@ func newSessionExportCommand() *cobra.Command {
6364
"source file not found for session %s", id,
6465
)
6566
}
67+
// Aider stores many repo runs in one Markdown history file,
68+
// with sessions keyed by a <history>#<idx> virtual path.
69+
// Export only the selected run, not sibling runs from the
70+
// same repository.
71+
if historyPath, idx, ok :=
72+
parser.ParseAiderVirtualPath(storedPath); ok {
73+
rawID, ok := rawAiderSessionID(id)
74+
if !ok {
75+
return fmt.Errorf(
76+
"stale aider source for session %s: invalid aider session id",
77+
id,
78+
)
79+
}
80+
if got, ok := parser.AiderRawIDAt(historyPath, idx); !ok || got != rawID {
81+
if _, statErr := os.Stat(historyPath); statErr != nil {
82+
if os.IsNotExist(statErr) {
83+
return fmt.Errorf(
84+
"source file not found: %s", historyPath,
85+
)
86+
}
87+
return statErr
88+
}
89+
resolved, found := parser.AiderVirtualPathForRawID(
90+
historyPath, rawID,
91+
)
92+
if !found {
93+
return fmt.Errorf(
94+
"stale aider source for session %s: %s no longer contains the archived run",
95+
id, historyPath,
96+
)
97+
}
98+
historyPath, idx, _ = parser.ParseAiderVirtualPath(resolved)
99+
}
100+
err := parser.WriteAiderRunMarkdown(
101+
cmd.OutOrStdout(), historyPath, idx,
102+
)
103+
if errors.Is(err, os.ErrNotExist) {
104+
return fmt.Errorf(
105+
"source file not found: %s", historyPath,
106+
)
107+
}
108+
return err
109+
}
66110
// A Visual Studio Copilot trace file holds spans for several
67111
// conversations, so streaming the whole file would disclose
68112
// unrelated conversations. Filter to the requested conversation.
@@ -94,3 +138,13 @@ func newSessionExportCommand() *cobra.Command {
94138
},
95139
}
96140
}
141+
142+
func rawAiderSessionID(sessionID string) (string, bool) {
143+
def, ok := parser.AgentByPrefix(sessionID)
144+
if !ok || def.Type != parser.AgentAider {
145+
return "", false
146+
}
147+
_, rawID := parser.StripHostPrefix(sessionID)
148+
rawID = strings.TrimPrefix(rawID, def.IDPrefix)
149+
return rawID, rawID != ""
150+
}

cmd/agentsview/session_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import (
1818
"github.com/stretchr/testify/require"
1919
"go.kenn.io/agentsview/internal/config"
2020
"go.kenn.io/agentsview/internal/db"
21+
"go.kenn.io/agentsview/internal/parser"
2122
)
2223

2324
func TestSessionHelp_ShowsSubcommands(t *testing.T) {
@@ -673,6 +674,76 @@ func TestSessionExport_StreamsFromDisk(t *testing.T) {
673674
assert.Equal(t, body, out)
674675
}
675676

677+
func TestSessionExport_AiderVirtualPathStreamsOnlySelectedRun(t *testing.T) {
678+
dataDir := t.TempDir()
679+
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
680+
681+
repo := filepath.Join(t.TempDir(), "repo")
682+
require.NoError(t, os.MkdirAll(repo, 0o755))
683+
history := filepath.Join(repo, parser.AiderHistoryFileName())
684+
run0 := "# aider chat started at 2026-06-09 14:01:00\n" +
685+
"#### first prompt\nanswer one\n"
686+
run1 := "# aider chat started at 2026-06-09 15:30:00\n" +
687+
"#### second prompt\nanswer two\n"
688+
run2 := "# aider chat started at 2026-06-09 16:45:00\n" +
689+
"#### third prompt\nanswer three\n"
690+
require.NoError(t, os.WriteFile(
691+
history, []byte("ignored preamble\n"+run0+run1+run2), 0o600,
692+
))
693+
rawID, ok := parser.AiderRawIDAt(history, 1)
694+
require.True(t, ok, "run 1 raw ID")
695+
696+
seedSessionWithOpts(t, dataDir, "aider:"+rawID, "repo",
697+
func(s *db.Session) {
698+
s.Agent = string(parser.AgentAider)
699+
vp := parser.AiderVirtualPath(history, 1)
700+
s.FilePath = &vp
701+
})
702+
703+
out, err := executeCommand(newRootCommand(),
704+
"session", "export", "aider:"+rawID)
705+
require.NoError(t, err)
706+
assert.Equal(t, run1, out)
707+
assert.NotContains(t, out, "first prompt")
708+
assert.NotContains(t, out, "third prompt")
709+
}
710+
711+
func TestSessionExport_AiderStaleIndexReResolvesBySessionID(t *testing.T) {
712+
dataDir := t.TempDir()
713+
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)
714+
715+
repo := filepath.Join(t.TempDir(), "repo")
716+
require.NoError(t, os.MkdirAll(repo, 0o755))
717+
history := filepath.Join(repo, parser.AiderHistoryFileName())
718+
run0 := "# aider chat started at 2026-06-09 14:01:00\n" +
719+
"#### first prompt\nanswer one\n"
720+
run1 := "# aider chat started at 2026-06-09 15:30:00\n" +
721+
"#### second prompt\nanswer two\n"
722+
require.NoError(t, os.WriteFile(history, []byte(run0+run1), 0o600))
723+
rawID, ok := parser.AiderRawIDAt(history, 1)
724+
require.True(t, ok, "run 1 raw ID")
725+
726+
seedSessionWithOpts(t, dataDir, "aider:"+rawID, "repo",
727+
func(s *db.Session) {
728+
s.Agent = string(parser.AgentAider)
729+
vp := parser.AiderVirtualPath(history, 1)
730+
s.FilePath = &vp
731+
})
732+
733+
inserted := "# aider chat started at 2026-06-09 13:00:00\n" +
734+
"#### inserted prompt\ninserted answer\n"
735+
require.NoError(t, os.WriteFile(
736+
history, []byte(inserted+run0+run1), 0o600,
737+
))
738+
739+
out, err := executeCommand(newRootCommand(),
740+
"session", "export", "aider:"+rawID)
741+
require.NoError(t, err)
742+
assert.Equal(t, run1, out)
743+
assert.NotContains(t, out, "inserted prompt")
744+
assert.NotContains(t, out, "first prompt")
745+
}
746+
676747
func TestSessionExport_FailsWhenSourceMissing(t *testing.T) {
677748
dataDir := t.TempDir()
678749
t.Setenv("AGENTSVIEW_DATA_DIR", dataDir)

0 commit comments

Comments
 (0)