f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
837 lines
27 KiB
Go
837 lines
27 KiB
Go
package parser
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Aider stores one Markdown chat log per repository at
|
|
// <repo>/.aider.chat.history.md. A single file accumulates many runs
|
|
// (one per aider process launch), each delimited by a
|
|
// "# aider chat started at <ts>" header (the only single-"#" line aider
|
|
// writes). The format is Markdown-derived, so roles are reconstructed
|
|
// from line prefixes (lower fidelity than the JSONL agents):
|
|
//
|
|
// - "#### <text>" -> a user prompt (Markdown h4).
|
|
// - plain text after a turn -> assistant response.
|
|
// - "> <text>" -> aider tool/edit output (every aider
|
|
// tool/warning/error line is blockquoted).
|
|
//
|
|
// aider records no per-message timestamps, so Message.Timestamp is the
|
|
// zero time for every message; a run's StartedAt is its own header
|
|
// (local naive time, assumed UTC, so it may be offset by the user's
|
|
// timezone) and there is no reliable EndedAt, so StartedAt is reused.
|
|
//
|
|
// Each run is indexed as its own session (mirroring the upstream
|
|
// sessiondex Rust adapter, which emits one session per run via a
|
|
// path#idx key). agentsview already supports multiple sessions per
|
|
// physical file via the virtual-path fan-out pattern used by Shelley and
|
|
// Zed, so aider reuses it: discoverAiderSessions returns the single
|
|
// physical file and the sync engine fans it out to one ParseResult per
|
|
// run. A run with no parseable turns (e.g. a header-only run) yields no
|
|
// session. Edited files are best-effort, taken from aider's own
|
|
// "Applied edit to" / "Creating empty file" lines. A leading "> "
|
|
// blockquote inside assistant prose is a known, rare misclassification,
|
|
// accepted and documented (mirrors the upstream adapter).
|
|
|
|
const (
|
|
aiderIDPrefix = "aider:"
|
|
aiderHistoryFile = ".aider.chat.history.md"
|
|
aiderHeaderPrefix = "# aider chat started at "
|
|
aiderTimeLayout = "2006-01-02 15:04:05"
|
|
aiderVPathSep = "#"
|
|
|
|
// aiderMaxWalkDepth bounds the rootless discovery walk. aider history
|
|
// files sit at repository roots, rarely more than a few levels deep.
|
|
aiderMaxWalkDepth = 4
|
|
// aiderMaxFiles and aiderMaxDirs cap the walk so a pathological tree
|
|
// cannot stall discovery.
|
|
aiderMaxFiles = 5000
|
|
aiderMaxDirs = 50000
|
|
// aiderWalkBudget bounds the wall-clock cost of the rootless discovery
|
|
// walk, mirroring the upstream Rust adapter's WALK_BUDGET_SECS. A walk
|
|
// that exceeds it stops early and returns whatever it found so far.
|
|
aiderWalkBudget = 2 * time.Second
|
|
)
|
|
|
|
// aiderSkipDirs are directory names never descended into during
|
|
// discovery. Matched by exact name, never "all dotdirs", mirroring the
|
|
// upstream Rust adapter's skip-set.
|
|
var aiderSkipDirs = map[string]struct{}{
|
|
".git": {},
|
|
"node_modules": {},
|
|
"target": {},
|
|
".cache": {},
|
|
"Library": {},
|
|
"go": {},
|
|
".cargo": {},
|
|
".rustup": {},
|
|
".npm": {},
|
|
".pnpm-store": {},
|
|
".gradle": {},
|
|
".m2": {},
|
|
"vendor": {},
|
|
"dist": {},
|
|
"build": {},
|
|
".venv": {},
|
|
"venv": {},
|
|
"__pycache__": {},
|
|
".svn": {},
|
|
".hg": {},
|
|
}
|
|
|
|
// aiderProtectedHomeDirs are first-level home folders that trigger macOS
|
|
// privacy prompts when a desktop app enumerates them. Aider discovery is
|
|
// opt-in, but a user-configured broad root such as $HOME still must not enter
|
|
// these folders unless the user explicitly configures one of them as the Aider
|
|
// root.
|
|
var aiderProtectedHomeDirs = map[string]struct{}{
|
|
"Desktop": {},
|
|
"Documents": {},
|
|
"Downloads": {},
|
|
"Movies": {},
|
|
"Music": {},
|
|
"Photos": {},
|
|
"Pictures": {},
|
|
}
|
|
|
|
// AiderDiscoverySkipDirNames returns the directory basenames pruned by Aider
|
|
// discovery. Remote SSH discovery uses this to mirror local discovery semantics.
|
|
func AiderDiscoverySkipDirNames() []string {
|
|
names := make([]string, 0, len(aiderSkipDirs))
|
|
for name := range aiderSkipDirs {
|
|
names = append(names, name)
|
|
}
|
|
sort.Strings(names)
|
|
return names
|
|
}
|
|
|
|
// AiderDiscoveryMaxWalkDepth returns the maximum directory depth local Aider
|
|
// discovery descends below the configured root.
|
|
func AiderDiscoveryMaxWalkDepth() int { return aiderMaxWalkDepth }
|
|
|
|
// AiderDiscoveryMaxFiles returns the maximum number of Aider history files
|
|
// local discovery returns from one configured root.
|
|
func AiderDiscoveryMaxFiles() int { return aiderMaxFiles }
|
|
|
|
// AiderDiscoveryMaxDirs returns the maximum number of directories local Aider
|
|
// discovery visits below one configured root.
|
|
func AiderDiscoveryMaxDirs() int { return aiderMaxDirs }
|
|
|
|
// AiderHistoryFileName returns the fixed Markdown filename aider writes
|
|
// per repo (".aider.chat.history.md"). The sync engine uses it to match
|
|
// watched files back to the aider agent.
|
|
func AiderHistoryFileName() string { return aiderHistoryFile }
|
|
|
|
// aiderRun is one aider launch within a history file: a run header
|
|
// timestamp plus the raw body lines that follow it (until the next
|
|
// header). rawHeader is the verbatim timestamp string from the header,
|
|
// retained so the session ID can hash the header even when it is
|
|
// unparseable.
|
|
type aiderRun struct {
|
|
started time.Time
|
|
hasTime bool
|
|
rawHeader string
|
|
body string
|
|
}
|
|
|
|
// parseAiderTimestamp parses aider's header timestamp
|
|
// "%Y-%m-%d %H:%M:%S" (local naive, no timezone) and assumes UTC. The
|
|
// shared parseTimestamp is RFC3339-oriented and would reject these, so
|
|
// aider gets its own narrow parser. ok is false for empty or unparseable
|
|
// input.
|
|
func parseAiderTimestamp(s string) (time.Time, bool) {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return time.Time{}, false
|
|
}
|
|
t, err := time.Parse(aiderTimeLayout, s)
|
|
if err != nil {
|
|
return time.Time{}, false
|
|
}
|
|
return t.UTC(), true
|
|
}
|
|
|
|
// splitAiderRuns splits a history file into runs on the
|
|
// "# aider chat started at " header (the only single-"#" line aider
|
|
// writes). Bytes before the first header belong to no run and are
|
|
// dropped. A header with no body keeps its slot, so positional run order
|
|
// never shifts when new runs are appended. CRLF endings are tolerated.
|
|
func splitAiderRuns(content string) []aiderRun {
|
|
var runs []aiderRun
|
|
var cur *aiderRun
|
|
// Accumulate the current run's body in a Builder rather than repeatedly
|
|
// reallocating cur.body, so a large history file stays linear, not
|
|
// quadratic, in copy cost.
|
|
var body strings.Builder
|
|
flush := func() {
|
|
if cur != nil {
|
|
cur.body = body.String()
|
|
runs = append(runs, *cur)
|
|
}
|
|
}
|
|
for raw := range strings.SplitSeq(content, "\n") {
|
|
line := strings.TrimSuffix(raw, "\r")
|
|
if ts, ok := strings.CutPrefix(line, aiderHeaderPrefix); ok {
|
|
flush()
|
|
body.Reset()
|
|
started, hasTime := parseAiderTimestamp(ts)
|
|
cur = &aiderRun{
|
|
started: started,
|
|
hasTime: hasTime,
|
|
rawHeader: strings.TrimSpace(ts),
|
|
}
|
|
continue
|
|
}
|
|
if cur != nil {
|
|
body.WriteString(line)
|
|
body.WriteByte('\n')
|
|
}
|
|
// lines before the first header (cur == nil) are dropped.
|
|
}
|
|
flush()
|
|
return runs
|
|
}
|
|
|
|
func aiderRunSourceRange(content string, idx int) (int, int, bool) {
|
|
if idx < 0 {
|
|
return 0, 0, false
|
|
}
|
|
runIdx := -1
|
|
start := -1
|
|
lineStart := 0
|
|
for lineStart <= len(content) {
|
|
lineEnd := len(content)
|
|
if rel := strings.IndexByte(content[lineStart:], '\n'); rel >= 0 {
|
|
lineEnd = lineStart + rel
|
|
}
|
|
if strings.HasPrefix(content[lineStart:lineEnd], aiderHeaderPrefix) {
|
|
runIdx++
|
|
if runIdx == idx {
|
|
start = lineStart
|
|
} else if runIdx == idx+1 && start >= 0 {
|
|
return start, lineStart, true
|
|
}
|
|
}
|
|
if lineEnd == len(content) {
|
|
break
|
|
}
|
|
lineStart = lineEnd + 1
|
|
}
|
|
if start >= 0 {
|
|
return start, len(content), true
|
|
}
|
|
return 0, 0, false
|
|
}
|
|
|
|
// WriteAiderRunMarkdown streams the raw Markdown source for one run in a shared
|
|
// aider history file. It preserves the selected run's header and body, drops any
|
|
// preamble before the first run, and never emits sibling runs from the same
|
|
// repository history.
|
|
func WriteAiderRunMarkdown(w io.Writer, historyPath string, idx int) error {
|
|
data, err := os.ReadFile(historyPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read %s: %w", historyPath, err)
|
|
}
|
|
start, end, ok := aiderRunSourceRange(string(data), idx)
|
|
if !ok {
|
|
return fmt.Errorf(
|
|
"aider run %d not found in %s: %w",
|
|
idx, historyPath, os.ErrNotExist,
|
|
)
|
|
}
|
|
_, err = w.Write(data[start:end])
|
|
return err
|
|
}
|
|
|
|
// pushUniqueAider appends the trimmed path to v if it is non-empty and
|
|
// not already present (preserves first-seen order, de-duplicates).
|
|
func pushUniqueAider(v []string, s string) []string {
|
|
s = strings.TrimSpace(s)
|
|
if s == "" {
|
|
return v
|
|
}
|
|
if slices.Contains(v, s) {
|
|
return v
|
|
}
|
|
return append(v, s)
|
|
}
|
|
|
|
// parseAiderTurns reconstructs turns from a single run body. It is an
|
|
// anchored state machine, not per-line role guessing:
|
|
//
|
|
// - "#### " (or a bare "####") -> user channel.
|
|
// - "> " (or a bare ">") -> tool channel.
|
|
// - blank lines -> continue the current channel.
|
|
// - everything else -> assistant channel (the default).
|
|
//
|
|
// A message is emitted whenever the channel switches. Tool output is
|
|
// surfaced as assistant transcript content because agentsview has no
|
|
// dedicated tool role. Edited files come from "Applied edit to" and
|
|
// "Creating empty file" tool lines (relative paths), de-duplicated;
|
|
// "Did not apply edit ... (--dry-run)" and "Skipping edits to ..."
|
|
// contribute nothing.
|
|
func parseAiderTurns(body string) ([]ParsedMessage, []string) {
|
|
const (
|
|
chanNone = iota
|
|
chanUser
|
|
chanAssistant
|
|
chanTool
|
|
)
|
|
|
|
var (
|
|
messages []ParsedMessage
|
|
touched []string
|
|
curChan = chanNone
|
|
buf []string
|
|
ordinal int
|
|
)
|
|
|
|
flush := func() {
|
|
if curChan == chanNone {
|
|
buf = buf[:0]
|
|
return
|
|
}
|
|
text := strings.TrimSpace(strings.Join(buf, "\n"))
|
|
buf = buf[:0]
|
|
// Keep empty user turns (aider writes "#### " for empty input);
|
|
// drop empty assistant/tool noise.
|
|
if text == "" && curChan != chanUser {
|
|
return
|
|
}
|
|
role := RoleAssistant
|
|
if curChan == chanUser {
|
|
role = RoleUser
|
|
}
|
|
messages = append(messages, ParsedMessage{
|
|
Ordinal: ordinal,
|
|
Role: role,
|
|
Content: text,
|
|
ContentLength: len(text),
|
|
})
|
|
ordinal++
|
|
}
|
|
|
|
for raw := range strings.SplitSeq(body, "\n") {
|
|
line := strings.TrimSuffix(raw, "\r")
|
|
|
|
var lineChan int
|
|
var content string
|
|
switch {
|
|
case strings.HasPrefix(line, "#### "):
|
|
lineChan = chanUser
|
|
content = strings.TrimRight(line[len("#### "):], " \t")
|
|
case line == "####":
|
|
lineChan = chanUser
|
|
case strings.HasPrefix(line, "> "):
|
|
lineChan = chanTool
|
|
content = line[len("> "):]
|
|
if p, ok := strings.CutPrefix(content, "Applied edit to "); ok {
|
|
touched = pushUniqueAider(touched, p)
|
|
} else if p, ok := strings.CutPrefix(content, "Creating empty file "); ok {
|
|
touched = pushUniqueAider(touched, p)
|
|
}
|
|
case line == ">":
|
|
lineChan = chanTool
|
|
case strings.TrimSpace(line) == "":
|
|
// blank: continue the current channel.
|
|
if curChan != chanNone {
|
|
buf = append(buf, "")
|
|
}
|
|
continue
|
|
default:
|
|
lineChan = chanAssistant
|
|
content = line
|
|
}
|
|
|
|
if lineChan != curChan {
|
|
flush()
|
|
curChan = lineChan
|
|
}
|
|
buf = append(buf, content)
|
|
}
|
|
flush()
|
|
return messages, touched
|
|
}
|
|
|
|
// AiderVirtualPath gives a single run within a history file a stable
|
|
// source identity for the AgentsView archive: "<historyPath>#<idx>",
|
|
// where idx is the run's positional index in the file. Mirrors
|
|
// ShelleyVirtualPath.
|
|
func AiderVirtualPath(path string, idx int) string {
|
|
return path + aiderVPathSep + strconv.Itoa(idx)
|
|
}
|
|
|
|
// ParseAiderVirtualPath splits a virtual aider source path back into its
|
|
// physical history-file path and run index. It validates that the base
|
|
// name is the aider history file and the index is a non-negative
|
|
// integer, so a real path (no "#") or any other "#"-bearing path is
|
|
// rejected. Mirrors ParseShelleyVirtualPath.
|
|
func ParseAiderVirtualPath(path string) (string, int, bool) {
|
|
sep := strings.LastIndex(path, aiderVPathSep)
|
|
if sep < 0 {
|
|
return "", 0, false
|
|
}
|
|
physical, idxStr := path[:sep], path[sep+1:]
|
|
if filepath.Base(physical) != aiderHistoryFile || idxStr == "" {
|
|
return "", 0, false
|
|
}
|
|
idx, err := strconv.Atoi(idxStr)
|
|
if err != nil || idx < 0 {
|
|
return "", 0, false
|
|
}
|
|
return physical, idx, true
|
|
}
|
|
|
|
// aiderAbsPath returns the absolute form of path, falling back to the
|
|
// input when it cannot be resolved. Used so the session ID hashes a
|
|
// stable absolute identity.
|
|
func aiderAbsPath(path string) string {
|
|
if a, err := filepath.Abs(path); err == nil {
|
|
return a
|
|
}
|
|
return path
|
|
}
|
|
|
|
// aiderRawID derives a stable session raw ID for one run. aider has no
|
|
// session id of its own, so the identity is the absolute history-file
|
|
// path plus the run's header-timestamp string plus an ordinal among the
|
|
// runs in that file that share the same header string. Hashing the header
|
|
// rather than the bare run index keeps IDs stable across the common
|
|
// mutations: appending a run, or removing/inserting a run with a DIFFERENT
|
|
// header, never re-keys other runs. The ordinal (not the absolute run
|
|
// index) is what is hashed, so a fresh-timestamp append leaves every
|
|
// earlier run's ID untouched.
|
|
//
|
|
// Residual limitation: when two or more runs in one file share a
|
|
// byte-identical header timestamp (aider headers have 1-second
|
|
// resolution, so this requires same-second runs in the same repo -- e.g.
|
|
// scripted/parallel invocations or a restart within the same second), the
|
|
// ordinal disambiguates them by position. Removing or inserting-before an
|
|
// earlier same-header run therefore re-keys its later same-header
|
|
// siblings. This is an accepted residual given its rarity; it is pinned by
|
|
// TestAiderSameHeaderEarlyRemovalRekeysSiblings. The "aider:" prefix is
|
|
// added by the session ID.
|
|
func aiderRawID(absPath, rawHeader string, equalHeaderOrdinal int) string {
|
|
var b strings.Builder
|
|
b.WriteString(absPath)
|
|
b.WriteByte(0)
|
|
b.WriteString(rawHeader)
|
|
b.WriteByte(0)
|
|
b.WriteString(strconv.Itoa(equalHeaderOrdinal))
|
|
sum := sha256.Sum256([]byte(b.String()))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// aiderEqualHeaderOrdinals returns, for each run, its ordinal among the
|
|
// runs that share its exact header string. The first run with a given
|
|
// header gets 0, the second 1, and so on. This is what keeps IDs stable
|
|
// across appends: only equal-header runs disambiguate by position, so a
|
|
// new run with a fresh timestamp leaves every earlier run's ordinal
|
|
// untouched.
|
|
func aiderEqualHeaderOrdinals(runs []aiderRun) []int {
|
|
ordinals := make([]int, len(runs))
|
|
seen := make(map[string]int, len(runs))
|
|
for i, run := range runs {
|
|
ordinals[i] = seen[run.rawHeader]
|
|
seen[run.rawHeader]++
|
|
}
|
|
return ordinals
|
|
}
|
|
|
|
// AiderRawIDAt recomputes the raw session ID (the per-run hash, without the
|
|
// "aider:" prefix) of the run at positional index idx in a history file. It
|
|
// returns ("", false) when the file is unreadable or idx is out of range.
|
|
// Callers use it to validate that a stored "<historyPath>#<idx>" virtual
|
|
// path still points at the run they expect: the index is positional, so an
|
|
// inserted or removed earlier run can shift it onto a different session.
|
|
func AiderRawIDAt(historyPath string, idx int) (string, bool) {
|
|
data, err := os.ReadFile(historyPath)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
runs := splitAiderRuns(string(data))
|
|
if idx < 0 || idx >= len(runs) {
|
|
return "", false
|
|
}
|
|
ordinals := aiderEqualHeaderOrdinals(runs)
|
|
return aiderRawID(aiderAbsPath(historyPath), runs[idx].rawHeader, ordinals[idx]), true
|
|
}
|
|
|
|
// AiderVirtualPathForRawID resolves rawID to its current positional virtual
|
|
// path within one physical history file. This is used when a previously stored
|
|
// "<history>#<idx>" path has gone stale because an earlier run was inserted or
|
|
// removed after the last sync.
|
|
func AiderVirtualPathForRawID(historyPath, rawID string) (string, bool) {
|
|
if historyPath == "" || rawID == "" {
|
|
return "", false
|
|
}
|
|
data, err := os.ReadFile(historyPath)
|
|
if err != nil {
|
|
return "", false
|
|
}
|
|
runs := splitAiderRuns(string(data))
|
|
if len(runs) == 0 {
|
|
return "", false
|
|
}
|
|
absPath := aiderAbsPath(historyPath)
|
|
ordinals := aiderEqualHeaderOrdinals(runs)
|
|
for idx, run := range runs {
|
|
if aiderRawID(absPath, run.rawHeader, ordinals[idx]) == rawID {
|
|
return AiderVirtualPath(historyPath, idx), true
|
|
}
|
|
}
|
|
return "", false
|
|
}
|
|
|
|
// aiderIdentityPath returns the path whose absolute form seeds the run's
|
|
// session ID hash. When idPath is non-empty it is used verbatim (it is
|
|
// already a canonical identity, e.g. the remote physical history path), so
|
|
// the ID stays stable across syncs that read the file from a different
|
|
// location. When idPath is empty the on-disk path's absolute form is used,
|
|
// preserving the original local behavior exactly.
|
|
func aiderIdentityPath(path, idPath string) string {
|
|
if idPath != "" {
|
|
return idPath
|
|
}
|
|
return aiderAbsPath(path)
|
|
}
|
|
|
|
// buildAiderRunSession builds a single per-run session from one run's
|
|
// body and metadata. Returns (nil, nil) when the run has no parseable
|
|
// turns so the caller skips it cleanly. idPath, when non-empty, is the
|
|
// canonical identity path used to derive the stable session ID (see
|
|
// aiderIdentityPath); the on-disk path is still used for the stored virtual
|
|
// File.Path and for reading the file.
|
|
func buildAiderRunSession(
|
|
path, idPath, machine string,
|
|
info os.FileInfo,
|
|
run aiderRun,
|
|
idx, equalHeaderOrdinal int,
|
|
) (*ParsedSession, []ParsedMessage) {
|
|
messages, _ := parseAiderTurns(run.body)
|
|
// Edited files (parseAiderTurns' second return) are not surfaced as a
|
|
// structured session field: agentsview has no per-session edited-files
|
|
// column, and aider's own "Applied edit to ..." lines are preserved
|
|
// verbatim in the assistant transcript content. The extraction is kept
|
|
// and unit-tested at the parseAiderTurns level for parity with the
|
|
// upstream adapter.
|
|
if len(messages) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
var (
|
|
firstMsg string
|
|
userCount int
|
|
)
|
|
for _, m := range messages {
|
|
if m.Role != RoleUser || m.Content == "" {
|
|
continue
|
|
}
|
|
userCount++
|
|
if firstMsg == "" {
|
|
firstMsg = truncate(strings.ReplaceAll(m.Content, "\n", " "), 300)
|
|
}
|
|
}
|
|
|
|
project := GetProjectName(filepath.Base(filepath.Dir(path)))
|
|
if project == "" {
|
|
project = "unknown"
|
|
}
|
|
|
|
identity := aiderIdentityPath(path, idPath)
|
|
// A run has no reliable end time (aider writes no per-message
|
|
// timestamps and no run-end marker), so EndedAt mirrors StartedAt.
|
|
sess := &ParsedSession{
|
|
ID: aiderIDPrefix +
|
|
aiderRawID(identity, run.rawHeader, equalHeaderOrdinal),
|
|
Project: project,
|
|
Machine: machine,
|
|
Agent: AgentAider,
|
|
FirstMessage: firstMsg,
|
|
StartedAt: run.started,
|
|
EndedAt: run.started,
|
|
MessageCount: len(messages),
|
|
UserMessageCount: userCount,
|
|
File: FileInfo{
|
|
Path: AiderVirtualPath(path, idx),
|
|
Size: info.Size(),
|
|
Mtime: info.ModTime().UnixNano(),
|
|
},
|
|
}
|
|
accumulateMessageTokenUsage(sess, messages)
|
|
return sess, messages
|
|
}
|
|
|
|
// parseAiderRun parses a single run (by positional index) out of a
|
|
// history file into one session. The physical file is read and split on
|
|
// every call; callers parsing every run of a file should prefer
|
|
// parseAiderRuns, which reads the file once. Returns (nil, nil, nil)
|
|
// when the run does not exist or has no parseable turns.
|
|
func parseAiderRun(
|
|
path string, idx int, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
return parseAiderRunWithID(path, "", idx, machine)
|
|
}
|
|
|
|
// parseAiderRunWithID is parseAiderRun with an explicit canonical identity
|
|
// path used to derive the stable session ID. idPath should be the run's
|
|
// canonical physical history path (e.g. the remote path during SSH sync);
|
|
// pass "" to fall back to the on-disk path, which is the local behavior.
|
|
// The file is always read from path; only the ID hash uses idPath.
|
|
func parseAiderRunWithID(
|
|
path, idPath string, idx int, machine string,
|
|
) (*ParsedSession, []ParsedMessage, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
runs := splitAiderRuns(string(data))
|
|
if idx < 0 || idx >= len(runs) {
|
|
return nil, nil, nil
|
|
}
|
|
ordinals := aiderEqualHeaderOrdinals(runs)
|
|
sess, msgs := buildAiderRunSession(
|
|
path, idPath, machine, info, runs[idx], idx, ordinals[idx],
|
|
)
|
|
if sess == nil {
|
|
return nil, nil, nil
|
|
}
|
|
return sess, msgs, nil
|
|
}
|
|
|
|
// parseAiderRuns reads a history file once and parses every run into its
|
|
// own ParseResult, in file order. Runs with no parseable turns are
|
|
// dropped. Returns nil for an unreadable or run-less file. This is the
|
|
// fan-out entry point used by the sync engine; parseAiderRun is the
|
|
// single-run lookup used when resolving one virtual path.
|
|
func parseAiderRuns(path, machine string) ([]ParseResult, error) {
|
|
return parseAiderRunsWithID(path, "", machine)
|
|
}
|
|
|
|
// parseAiderRunsWithID is parseAiderRuns with an explicit canonical
|
|
// identity path used to derive stable session IDs for every run. idPath
|
|
// should be the file's canonical physical history path (e.g. the remote
|
|
// path during SSH sync, where path is a random temp extraction dir); pass
|
|
// "" to fall back to the on-disk path, which is the local behavior. The
|
|
// file is always read from path; only the per-run ID hash uses idPath, so
|
|
// the IDs stay stable across syncs that extract the file to a different
|
|
// temp location.
|
|
func parseAiderRunsWithID(path, idPath, machine string) ([]ParseResult, error) {
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("stat %s: %w", path, err)
|
|
}
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", path, err)
|
|
}
|
|
runs := splitAiderRuns(string(data))
|
|
if len(runs) == 0 {
|
|
return nil, nil
|
|
}
|
|
ordinals := aiderEqualHeaderOrdinals(runs)
|
|
var results []ParseResult
|
|
for idx, run := range runs {
|
|
sess, msgs := buildAiderRunSession(
|
|
path, idPath, machine, info, run, idx, ordinals[idx],
|
|
)
|
|
if sess == nil {
|
|
continue
|
|
}
|
|
results = append(results, ParseResult{Session: *sess, Messages: msgs})
|
|
}
|
|
return results, nil
|
|
}
|
|
|
|
// discoverAiderSessions walks root looking for .aider.chat.history.md
|
|
// files. aider is rootless (no central store), so this is a bounded,
|
|
// depth-capped, symlink-safe walk: it descends at most aiderMaxWalkDepth
|
|
// levels, never follows symlinks, skips a fixed set of large vendor /
|
|
// build / VCS directories by exact name, and opens only the exact
|
|
// filename. A wall-clock budget (aiderWalkBudget) stops the walk early
|
|
// on a pathological tree. The walk is best-effort and silent: permission
|
|
// errors, over-cap trees, and a blown budget are skipped rather than
|
|
// surfaced, so a partial scan still indexes whatever it found. Each
|
|
// discovered physical file is fanned out into one session per run by the
|
|
// sync engine.
|
|
func discoverAiderSessions(root string) []DiscoveredFile {
|
|
if root == "" {
|
|
return nil
|
|
}
|
|
home := aiderHomeDir()
|
|
skipProtectedHomeDirs := aiderShouldSkipProtectedHomeDirs(root, home, runtime.GOOS)
|
|
rootDepth := strings.Count(filepath.Clean(root), string(os.PathSeparator))
|
|
walkRoots := aiderDiscoveryWalkRoots(root, home, runtime.GOOS)
|
|
|
|
var files []DiscoveredFile
|
|
if skipProtectedHomeDirs {
|
|
files = appendAiderHistoryFile(files, filepath.Join(root, aiderHistoryFile))
|
|
}
|
|
dirCount := 0
|
|
start := time.Now()
|
|
stopAll := false
|
|
for _, walkRoot := range walkRoots {
|
|
if stopAll {
|
|
break
|
|
}
|
|
_ = filepath.WalkDir(walkRoot, func(
|
|
path string, d os.DirEntry, err error,
|
|
) error {
|
|
// Wall-clock budget: stop the whole walk once it is exceeded,
|
|
// returning whatever was found so far. Mirrors the upstream Rust
|
|
// adapter's WALK_BUDGET_SECS.
|
|
if time.Since(start) >= aiderWalkBudget {
|
|
stopAll = true
|
|
return filepath.SkipAll
|
|
}
|
|
if err != nil {
|
|
// Unreadable entry: skip it (and its subtree if a dir) but
|
|
// keep walking the rest of the tree.
|
|
if d != nil && d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.IsDir() {
|
|
// Never follow symlinked directories.
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return filepath.SkipDir
|
|
}
|
|
depth := strings.Count(
|
|
filepath.Clean(path), string(os.PathSeparator),
|
|
) - rootDepth
|
|
if skipProtectedHomeDirs && depth == 1 {
|
|
if _, skip := aiderProtectedHomeDirs[d.Name()]; skip {
|
|
return filepath.SkipDir
|
|
}
|
|
}
|
|
if path != root {
|
|
if _, skip := aiderSkipDirs[d.Name()]; skip {
|
|
return filepath.SkipDir
|
|
}
|
|
}
|
|
// Skip descent BELOW the cap, but still visit files in a
|
|
// directory AT the cap, so a history file exactly
|
|
// aiderMaxWalkDepth levels under the root is discovered (the
|
|
// documented N-level scan). A `>=` test would skip the
|
|
// max-depth directory before its files were seen.
|
|
if depth > aiderMaxWalkDepth {
|
|
return filepath.SkipDir
|
|
}
|
|
dirCount++
|
|
if dirCount > aiderMaxDirs {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if d.Name() != aiderHistoryFile {
|
|
return nil
|
|
}
|
|
// Skip symlinked files; only index real history files.
|
|
if d.Type()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
files = append(files, DiscoveredFile{
|
|
Path: path,
|
|
Agent: AgentAider,
|
|
})
|
|
if len(files) >= aiderMaxFiles {
|
|
stopAll = true
|
|
return filepath.SkipAll
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
sort.Slice(files, func(i, j int) bool {
|
|
return files[i].Path < files[j].Path
|
|
})
|
|
return files
|
|
}
|
|
|
|
func aiderDiscoveryWalkRoots(root, home, goos string) []string {
|
|
if !aiderShouldSkipProtectedHomeDirs(root, home, goos) {
|
|
return []string{root}
|
|
}
|
|
|
|
entries, err := os.ReadDir(root)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
roots := make([]string, 0, len(entries))
|
|
for _, entry := range entries {
|
|
name := entry.Name()
|
|
if _, skip := aiderProtectedHomeDirs[name]; skip {
|
|
continue
|
|
}
|
|
if _, skip := aiderSkipDirs[name]; skip {
|
|
continue
|
|
}
|
|
if entry.Type()&os.ModeSymlink != 0 || !entry.IsDir() {
|
|
continue
|
|
}
|
|
roots = append(roots, filepath.Join(root, name))
|
|
}
|
|
return roots
|
|
}
|
|
|
|
func appendAiderHistoryFile(files []DiscoveredFile, path string) []DiscoveredFile {
|
|
info, err := os.Lstat(path)
|
|
if err != nil || info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
|
|
return files
|
|
}
|
|
return append(files, DiscoveredFile{
|
|
Path: path,
|
|
Agent: AgentAider,
|
|
})
|
|
}
|
|
|
|
func aiderHomeDir() string {
|
|
home, err := os.UserHomeDir()
|
|
if err != nil || home == "" {
|
|
return ""
|
|
}
|
|
return home
|
|
}
|
|
|
|
func aiderShouldSkipProtectedHomeDirs(root, home, goos string) bool {
|
|
if goos != "darwin" || root == "" || home == "" {
|
|
return false
|
|
}
|
|
return filepath.Clean(root) == filepath.Clean(home)
|
|
}
|
|
|
|
// findAiderSourceFile resolves a single aider run's virtual source path
|
|
// ("<historyPath>#<idx>") from a root directory and a raw session ID (the
|
|
// per-run hash). It re-runs the bounded discovery walk to find candidate
|
|
// history files, then, for each, reads and splits it once to recompute
|
|
// the per-run IDs and match rawID. It returns the matching virtual path,
|
|
// or "" when nothing under root produces rawID. The physical file is
|
|
// stat-ed via os.Stat (not re-walked) for the per-run parse downstream.
|
|
func findAiderSourceFile(root, rawID string) string {
|
|
if root == "" || rawID == "" {
|
|
return ""
|
|
}
|
|
for _, f := range discoverAiderSessions(root) {
|
|
if path, ok := AiderVirtualPathForRawID(f.Path, rawID); ok {
|
|
return path
|
|
}
|
|
}
|
|
return ""
|
|
}
|