dirge-agent 0.12.5

Minimalistic coding agent written in Rust, optimized for memory footprint and performance
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
//! Deterministic session ground-truth digest (dirge-a62g).
//!
//! Memory formation (`agent::review`) forks an LLM over the transcript and
//! asks it to NOTICE durable facts — "what build/test commands ran", "what
//! files were touched". That is lossy (the model omits non-salient facts),
//! rate-limited (the 15-min `claim_review_slot` throttle can skip a session
//! entirely), and spends tokens rediscovering facts the session already
//! records verbatim.
//!
//! This module pulls that ground-truth straight from the session and git with
//! NO model call: the opening goal, files touched, commands run, the live todo
//! list, where we stopped, and `git diff --stat`. It is a deterministic floor
//! UNDER the LLM review, not a replacement.
//!
//! Two consumers:
//! - the background review prepends the digest (via [`assemble_review_transcript`])
//!   to the transcript so the model ranks/classifies KNOWN facts instead of
//!   hunting for them;
//! - the throttled/errored-review fallback (dirge-a62g 1b) persists the digest
//!   so a session's ground-truth is never fully lost (sibling: dirge-hcv8's
//!   open-threads carry-over builds on the same extraction).
//!
//! Files/todos come from [`crate::session::rehydrate::selected_panel_state`],
//! which prefers the persisted snapshot and so survives a destructive
//! compaction. Goal / last-state / commands are read from `messages` and are
//! best-effort: a compaction that drained the originating turns simply yields
//! less here, never wrong data.

use std::path::{Path, PathBuf};
use std::process::Command;

use crate::agent::tools::todo::TodoItem;
use crate::session::rehydrate::selected_panel_state;
use crate::session::{MessageRole, Session, ToolCallState};

/// Cap on files listed in the digest — enough to characterize a session
/// without flooding the review prompt.
const MAX_FILES: usize = 30;
/// Cap on distinct commands listed.
const MAX_COMMANDS: usize = 20;
/// Truncation for the opening goal line.
const GOAL_MAX_CHARS: usize = 500;
/// Truncation for the where-we-stopped line.
const STATE_MAX_CHARS: usize = 600;
/// Truncation for the `git diff --stat` block.
const GIT_STAT_MAX_CHARS: usize = 2000;

/// Deterministic, reproducible facts about a session — no model involved.
#[derive(Debug, Default, Clone)]
pub struct SessionDigest {
    /// First substantive user message — what the session set out to do.
    pub goal: String,
    /// Last substantive assistant message — where we stopped.
    pub last_state: String,
    /// Files written/edited/patched, recency-ordered (freshest last).
    pub files: Vec<PathBuf>,
    /// Distinct bash commands run, in first-seen order.
    pub commands: Vec<String>,
    /// The live todo list at session end.
    pub todos: Vec<TodoItem>,
    /// `git diff --stat` output, attached by the caller (kept out of
    /// [`from_session`] so the extractor stays pure / shell-free / testable).
    pub git_diff_stat: Option<String>,
}

impl SessionDigest {
    /// Build the model-free digest from a session. Pure: reads only in-memory
    /// session state, never shells out. Attach git via [`with_git_diff_stat`].
    pub fn from_session(session: &Session) -> Self {
        let panel = selected_panel_state(session);

        let mut files = panel.modified;
        if files.len() > MAX_FILES {
            // Keep the freshest (panel order is freshest-last).
            files = files.split_off(files.len() - MAX_FILES);
        }

        let goal = session
            .messages
            .iter()
            .find(|m| m.role == MessageRole::User && !m.content.trim().is_empty())
            .map(|m| one_line(&m.content))
            .map(|s| truncate(&s, GOAL_MAX_CHARS))
            .unwrap_or_default();

        let last_state = session
            .messages
            .iter()
            .rev()
            .find(|m| m.role == MessageRole::Assistant && !m.content.trim().is_empty())
            .map(|m| one_line(&m.content))
            .map(|s| truncate(&s, STATE_MAX_CHARS))
            .unwrap_or_default();

        let commands = collect_commands(session);

        Self {
            goal,
            last_state,
            files,
            commands,
            todos: panel.todos,
            git_diff_stat: None,
        }
    }

    /// Attach a `git diff --stat` block (trimmed + capped). Chainable.
    pub fn with_git_diff_stat(mut self, stat: Option<String>) -> Self {
        self.git_diff_stat = stat.and_then(|s| {
            let t = s.trim();
            if t.is_empty() {
                None
            } else {
                Some(truncate(t, GIT_STAT_MAX_CHARS))
            }
        });
        self
    }

    /// True when nothing was captured — caller should inject no preamble.
    pub fn is_empty(&self) -> bool {
        self.goal.is_empty()
            && self.last_state.is_empty()
            && self.files.is_empty()
            && self.commands.is_empty()
            && self.todos.is_empty()
            && self.git_diff_stat.is_none()
    }

    /// Render the digest as a markdown preamble for the review prompt. Returns
    /// an empty string when [`is_empty`] — the caller skips injection then.
    /// Only non-empty sections are emitted.
    pub fn render_for_review(&self) -> String {
        if self.is_empty() {
            return String::new();
        }
        let mut out = String::new();
        out.push_str(
            "## Session ground-truth (deterministic — extracted without a model)\n\
             These facts were pulled directly from the session and git. Treat them as the \
             authoritative record of WHAT happened this session; your job is to decide what is \
             worth remembering, not to rediscover them.\n",
        );

        if !self.goal.is_empty() {
            out.push_str(&format!("\n**Goal:** {}\n", self.goal));
        }
        if !self.files.is_empty() {
            out.push_str(&format!("\n**Files touched ({}):**\n", self.files.len()));
            for f in &self.files {
                out.push_str(&format!("- {}\n", f.display()));
            }
        }
        if !self.commands.is_empty() {
            out.push_str(&format!("\n**Commands run ({}):**\n", self.commands.len()));
            for c in &self.commands {
                out.push_str(&format!("- `{c}`\n"));
            }
        }
        if !self.todos.is_empty() {
            out.push_str("\n**Todos at session end:**\n");
            for t in &self.todos {
                out.push_str(&format!("- [{}] {}\n", t.status, t.content));
            }
        }
        if !self.last_state.is_empty() {
            out.push_str(&format!("\n**Where we stopped:** {}\n", self.last_state));
        }
        if let Some(stat) = &self.git_diff_stat {
            out.push_str(&format!("\n**git diff --stat:**\n```\n{stat}\n```\n"));
        }
        out
    }
}

/// The transcript handed to the background review: the deterministic digest
/// preamble (when any) followed by the conversation `base`. Single owner of the
/// preamble-vs-conversation layout so both post-session entry points agree.
///
/// Takes an already-built [`SessionDigest`] and attaches git (this shells out
/// `git diff --stat`) before rendering. The caller builds the session-derived
/// digest on the UI thread (cheap, shell-free) and defers this — the git
/// subprocess — to the post-session task (dirge-6rtt), so the event loop never
/// shells out on a turn end.
pub fn assemble_review_transcript(digest: SessionDigest, repo_root: &Path, base: String) -> String {
    let preamble = digest
        .with_git_diff_stat(git_diff_stat(repo_root))
        .render_for_review();
    if preamble.is_empty() {
        base
    } else {
        format!("{preamble}\n\n{base}")
    }
}

/// Distinct bash commands from completed `bash` tool calls, first-seen order,
/// capped at [`MAX_COMMANDS`].
fn collect_commands(session: &Session) -> Vec<String> {
    let mut out: Vec<String> = Vec::new();
    for msg in &session.messages {
        for tc in &msg.tool_calls {
            if tc.name != "bash" {
                continue;
            }
            if !matches!(tc.state, ToolCallState::Completed { .. }) {
                continue;
            }
            if let Some(cmd) = tc.args.get("command").and_then(|v| v.as_str()) {
                let cmd = one_line(cmd);
                if !cmd.is_empty() && !out.iter().any(|c| c == &cmd) {
                    out.push(cmd);
                    if out.len() >= MAX_COMMANDS {
                        return out;
                    }
                }
            }
        }
    }
    out
}

/// Run `git -C <root> diff --stat HEAD` and return its trimmed stdout, or
/// `None` if git is absent, the repo has no HEAD, or there is no diff. Bare
/// `Command` (no shell) matching the existing `git_worktree` helpers.
pub fn git_diff_stat(root: &Path) -> Option<String> {
    let output = Command::new("git")
        .arg("-C")
        .arg(root)
        .args(["--no-optional-locks", "diff", "--stat", "HEAD"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let stat = String::from_utf8_lossy(&output.stdout).trim().to_string();
    if stat.is_empty() { None } else { Some(stat) }
}

/// Collapse all whitespace runs (incl. newlines) into single spaces and trim.
fn one_line(s: &str) -> String {
    s.split_whitespace().collect::<Vec<_>>().join(" ")
}

/// Truncate to at most `max` chars (char-boundary safe), appending `…` when cut.
fn truncate(s: &str, max: usize) -> String {
    if s.chars().count() <= max {
        return s.to_string();
    }
    let cut: String = s.chars().take(max).collect();
    format!("{cut}")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::session::{Session, SessionMessage, ToolCallEntry, ToolCallState};
    use compact_str::CompactString;

    fn msg(role: MessageRole, content: &str, calls: Vec<ToolCallEntry>) -> SessionMessage {
        SessionMessage {
            role,
            content: CompactString::from(content),
            estimated_tokens: 0,
            id: crate::session::new_message_id(),
            timestamp: 0,
            tool_calls: calls,
        }
    }

    fn completed(name: &str, args: serde_json::Value) -> ToolCallEntry {
        ToolCallEntry {
            id: "tc".to_string(),
            name: name.to_string(),
            args,
            state: ToolCallState::Completed {
                result: String::new(),
            },
        }
    }

    fn session_with(messages: Vec<SessionMessage>) -> Session {
        let mut s = Session::new("test", "test-model", 1000);
        s.messages = messages;
        s
    }

    #[test]
    fn extracts_goal_last_state_files_commands() {
        let s = session_with(vec![
            msg(MessageRole::User, "Fix the failing build", vec![]),
            msg(
                MessageRole::Assistant,
                "Looking into it",
                vec![
                    completed("bash", serde_json::json!({"command": "cargo build"})),
                    completed("write", serde_json::json!({"path": "/proj/a.rs"})),
                ],
            ),
            msg(
                MessageRole::Assistant,
                "Build is green now",
                vec![completed(
                    "bash",
                    serde_json::json!({"command": "cargo test"}),
                )],
            ),
        ]);
        let d = SessionDigest::from_session(&s);
        assert_eq!(d.goal, "Fix the failing build");
        assert_eq!(d.last_state, "Build is green now");
        assert_eq!(d.commands, vec!["cargo build", "cargo test"]);
        assert_eq!(d.files.len(), 1);
        assert!(d.files[0].ends_with("a.rs"));
        assert!(!d.is_empty());
    }

    #[test]
    fn commands_are_deduped_in_first_seen_order() {
        let s = session_with(vec![msg(
            MessageRole::Assistant,
            "",
            vec![
                completed("bash", serde_json::json!({"command": "cargo build"})),
                completed("bash", serde_json::json!({"command": "cargo test"})),
                completed("bash", serde_json::json!({"command": "cargo build"})),
            ],
        )]);
        let d = SessionDigest::from_session(&s);
        assert_eq!(d.commands, vec!["cargo build", "cargo test"]);
    }

    #[test]
    fn ignores_non_completed_bash_calls() {
        let interrupted = ToolCallEntry {
            id: "x".into(),
            name: "bash".into(),
            args: serde_json::json!({"command": "rm -rf /"}),
            state: ToolCallState::Interrupted,
        };
        let s = session_with(vec![msg(MessageRole::Assistant, "", vec![interrupted])]);
        let d = SessionDigest::from_session(&s);
        assert!(d.commands.is_empty());
    }

    #[test]
    fn goal_skips_empty_user_messages_and_collapses_whitespace() {
        let s = session_with(vec![
            msg(MessageRole::User, "   ", vec![]),
            msg(MessageRole::User, "do\n\n  the   thing", vec![]),
        ]);
        let d = SessionDigest::from_session(&s);
        assert_eq!(d.goal, "do the thing");
    }

    #[test]
    fn empty_session_is_empty_and_renders_nothing() {
        let d = SessionDigest::from_session(&session_with(vec![]));
        assert!(d.is_empty());
        assert_eq!(d.render_for_review(), "");
    }

    #[test]
    fn with_git_diff_stat_drops_blank_and_caps() {
        let d = SessionDigest::default().with_git_diff_stat(Some("   \n  ".into()));
        assert!(d.git_diff_stat.is_none());

        let long = "x".repeat(GIT_STAT_MAX_CHARS + 50);
        let d = SessionDigest::default().with_git_diff_stat(Some(long));
        let stat = d.git_diff_stat.unwrap();
        assert!(stat.ends_with(''));
        assert_eq!(stat.chars().count(), GIT_STAT_MAX_CHARS + 1);
    }

    #[test]
    fn render_includes_sections_and_is_skippable_when_empty() {
        let mut d = SessionDigest {
            goal: "Add a feature".into(),
            last_state: "Done".into(),
            files: vec![PathBuf::from("/proj/a.rs")],
            commands: vec!["cargo build".into()],
            todos: vec![],
            git_diff_stat: Some("1 file changed".into()),
        };
        let r = d.render_for_review();
        assert!(r.contains("Session ground-truth"));
        assert!(r.contains("**Goal:** Add a feature"));
        assert!(r.contains("**Files touched (1):**"));
        assert!(r.contains("- `cargo build`"));
        assert!(r.contains("**Where we stopped:** Done"));
        assert!(r.contains("git diff --stat"));
        // No todos section when empty.
        assert!(!r.contains("Todos at session end"));

        d = SessionDigest::default();
        assert_eq!(d.render_for_review(), "");
    }

    #[test]
    fn files_capped_to_freshest() {
        let mut calls = Vec::new();
        for i in 0..(MAX_FILES + 5) {
            calls.push(completed(
                "write",
                serde_json::json!({ "path": format!("/proj/f{i}.rs") }),
            ));
        }
        let s = session_with(vec![msg(MessageRole::Assistant, "", calls)]);
        let d = SessionDigest::from_session(&s);
        assert_eq!(d.files.len(), MAX_FILES);
        // Freshest kept: the very last write must be present.
        let last = format!("f{}.rs", MAX_FILES + 4);
        assert!(d.files.last().unwrap().ends_with(&last));
    }
}