Skip to content

Commit 92934ac

Browse files
authored
Merge pull request #143 from dirge-code/phase-8-integration
Phase 8: Integration — Wire Memory, Skills, Session DB, Curator
2 parents 5c415ce + 805dd52 commit 92934ac

14 files changed

Lines changed: 776 additions & 257 deletions

File tree

.beads/issues.jsonl

Lines changed: 18 additions & 0 deletions
Large diffs are not rendered by default.

src/agent/builder.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,28 @@ pub async fn build_agent_inner<M: CompletionModel + 'static>(
138138
preamble.push_str(&format!("\nGit branch: {}", branch));
139139
}
140140

141+
// Phase 8: inject per-project memory + skills into the system
142+
// prompt. Frozen snapshots of MEMORY.md and PITFALLS.md become
143+
// reference material for every turn. Skills from .dirge/skills/
144+
// and global dirs are listed so the model knows what procedural
145+
// knowledge is available (it loads them on demand via the
146+
// `skill` tool).
147+
if let Ok(cwd) = std::env::current_dir() {
148+
let paths = crate::extras::dirge_paths::ProjectPaths::new(&cwd);
149+
if let Ok(mem) = crate::extras::memory_store::MemoryStore::load_memory(&paths) {
150+
let mem_text = mem.format_for_system_prompt();
151+
if !mem_text.is_empty() {
152+
preamble.push_str(&mem_text);
153+
}
154+
}
155+
if let Ok(pit) = crate::extras::memory_store::MemoryStore::load_pitfalls(&paths) {
156+
let pit_text = pit.format_for_system_prompt();
157+
if !pit_text.is_empty() {
158+
preamble.push_str(&pit_text);
159+
}
160+
}
161+
}
162+
141163
// Inject mode-specific reminders
142164
if let Some(prompt_name) = &context.current_prompt_name {
143165
let plan_exists = std::env::current_dir()

src/agent/review.rs

Lines changed: 172 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,18 @@
1616
//! - Frozen conversation snapshot
1717
//! - Fire-and-forget (daemon thread pattern)
1818
19+
use std::sync::atomic::{AtomicU64, Ordering};
20+
use std::time::{Duration, SystemTime, UNIX_EPOCH};
21+
1922
use crate::extras::dirge_paths::ProjectPaths;
2023
use crate::provider::AnyAgent;
2124

25+
/// Minimum interval between background reviews (seconds).
26+
const MIN_REVIEW_INTERVAL_SECS: u64 = 900; // 15 minutes
27+
28+
/// Last review timestamp (Unix seconds).
29+
static LAST_REVIEW: AtomicU64 = AtomicU64::new(0);
30+
2231
/// Review prompt focused on project memory and pitfalls.
2332
/// Port of Hermes's `_MEMORY_REVIEW_PROMPT` adapted for coding context.
2433
const MEMORY_REVIEW_PROMPT: &str = r#"Review the conversation above and update project memory.
@@ -90,17 +99,29 @@ Be specific and actionable. Future sessions should benefit from what you learned
9099
/// This is fire-and-forget — it runs in a `tokio::spawn` task and
91100
/// returns immediately. Failures are logged to stderr and never
92101
/// block the user.
93-
pub fn spawn_background_review(
94-
agent: AnyAgent,
95-
_paths: ProjectPaths,
96-
transcript: String,
97-
) {
102+
pub fn spawn_background_review(agent: AnyAgent, _paths: ProjectPaths, transcript: String) {
103+
// Rate-limit: skip if a review ran recently. Uses atomic
104+
// compare-and-swap so concurrent Done events from different
105+
// sessions don't race — only the first one wins.
106+
let now = SystemTime::now()
107+
.duration_since(UNIX_EPOCH)
108+
.map(|d| d.as_secs())
109+
.unwrap_or(0);
110+
let last = LAST_REVIEW.load(Ordering::Relaxed);
111+
if now.saturating_sub(last) < MIN_REVIEW_INTERVAL_SECS {
112+
tracing::debug!(
113+
target: "dirge::review",
114+
elapsed_secs = %(now - last),
115+
"Skipping background review — last review was too recent"
116+
);
117+
return;
118+
}
119+
LAST_REVIEW.store(now, Ordering::Relaxed);
120+
98121
tokio::spawn(async move {
99122
// Build a review runner with only memory + skill tools.
100-
let review_runner = agent.spawn_review_runner(
101-
COMBINED_REVIEW_PROMPT.to_string(),
102-
transcript,
103-
);
123+
let review_runner =
124+
agent.spawn_review_runner(COMBINED_REVIEW_PROMPT.to_string(), transcript);
104125

105126
// Drain events. We don't render them — the review runs
106127
// silently in the background.
@@ -134,3 +155,145 @@ pub fn spawn_background_review(
134155
}
135156
});
136157
}
158+
159+
/// Build a human-readable transcript from session messages for
160+
/// background review. Includes user text, assistant text, tool
161+
/// call names+args, and tool results. Compaction summaries are
162+
/// included as system context.
163+
pub fn build_transcript(session: &crate::session::Session) -> String {
164+
let mut out = String::new();
165+
for msg in &session.messages {
166+
match msg.role {
167+
crate::session::MessageRole::User => {
168+
out.push_str(&format!("User: {}\n\n", msg.content));
169+
}
170+
crate::session::MessageRole::Assistant => {
171+
if !msg.content.is_empty() {
172+
out.push_str(&format!("Assistant: {}\n", msg.content));
173+
}
174+
for tc in &msg.tool_calls {
175+
let args_str =
176+
serde_json::to_string(&tc.args).unwrap_or_else(|_| "{}".to_string());
177+
out.push_str(&format!(" [Tool: {}({})]\n", tc.name, args_str));
178+
match &tc.state {
179+
crate::session::ToolCallState::Completed { result } => {
180+
let truncated = truncate_tool_result(result);
181+
out.push_str(&format!(" [Result: {}]\n", truncated));
182+
}
183+
crate::session::ToolCallState::Interrupted => {
184+
out.push_str(" [Result: <interrupted>]\n");
185+
}
186+
crate::session::ToolCallState::Failed { error } => {
187+
out.push_str(&format!(" [Result: <failed: {}>]\n", error));
188+
}
189+
}
190+
}
191+
if !msg.content.is_empty() || !msg.tool_calls.is_empty() {
192+
out.push('\n');
193+
}
194+
}
195+
crate::session::MessageRole::System => {
196+
out.push_str(&format!("[System: {}]\n\n", msg.content));
197+
}
198+
}
199+
}
200+
out
201+
}
202+
203+
fn truncate_tool_result(result: &str) -> String {
204+
const MAX_TOOL_RESULT: usize = 2000;
205+
if result.len() <= MAX_TOOL_RESULT {
206+
result.to_string()
207+
} else {
208+
let truncated: String = result.chars().take(MAX_TOOL_RESULT).collect();
209+
format!("{}… (truncated, {} bytes total)", truncated, result.len())
210+
}
211+
}
212+
213+
#[cfg(test)]
214+
mod tests {
215+
use super::*;
216+
use crate::session::{MessageRole, Session, ToolCallEntry, ToolCallState};
217+
218+
fn make_session() -> Session {
219+
Session::new("test-provider", "test-model", 128_000)
220+
}
221+
222+
#[test]
223+
fn transcript_includes_user_and_assistant() {
224+
let mut s = make_session();
225+
s.add_message(MessageRole::User, "how do I build this?");
226+
s.add_message(MessageRole::Assistant, "Run cargo build");
227+
228+
let t = build_transcript(&s);
229+
assert!(t.contains("User: how do I build this?"));
230+
assert!(t.contains("Assistant: Run cargo build"));
231+
}
232+
233+
#[test]
234+
fn transcript_includes_tool_calls_and_results() {
235+
let mut s = make_session();
236+
s.add_message(MessageRole::User, "read the file");
237+
let tc = ToolCallEntry {
238+
id: "call-1".to_string(),
239+
name: "read".to_string(),
240+
args: serde_json::json!({"path": "/tmp/x"}),
241+
state: ToolCallState::Completed {
242+
result: "file contents here".to_string(),
243+
},
244+
};
245+
s.add_message_with_tool_calls(MessageRole::Assistant, "Let me read that.", vec![tc]);
246+
247+
let t = build_transcript(&s);
248+
assert!(t.contains("[Tool: read("));
249+
assert!(t.contains("[Result: file contents here]"));
250+
}
251+
252+
#[test]
253+
fn transcript_truncates_large_tool_results() {
254+
let mut s = make_session();
255+
let big = "x".repeat(3000);
256+
let tc = ToolCallEntry {
257+
id: "c1".to_string(),
258+
name: "bash".to_string(),
259+
args: serde_json::json!({"cmd": "cat big.txt"}),
260+
state: ToolCallState::Completed {
261+
result: big.clone(),
262+
},
263+
};
264+
s.add_message_with_tool_calls(MessageRole::Assistant, "", vec![tc]);
265+
266+
let t = build_transcript(&s);
267+
assert!(t.contains("truncated"));
268+
assert!(!t.contains(&big));
269+
}
270+
271+
#[test]
272+
fn transcript_includes_system_messages() {
273+
let mut s = make_session();
274+
s.add_message(
275+
MessageRole::System,
276+
"compaction summary: previous work on auth module",
277+
);
278+
s.add_message(MessageRole::User, "continue");
279+
280+
let t = build_transcript(&s);
281+
assert!(t.contains("[System: compaction summary"));
282+
assert!(t.contains("User: continue"));
283+
}
284+
285+
#[test]
286+
fn transcript_handles_interrupted_tool() {
287+
let mut s = make_session();
288+
let tc = ToolCallEntry {
289+
id: "ci".to_string(),
290+
name: "bash".to_string(),
291+
args: serde_json::json!({}),
292+
state: ToolCallState::Interrupted,
293+
};
294+
s.add_message_with_tool_calls(MessageRole::Assistant, "", vec![tc]);
295+
296+
let t = build_transcript(&s);
297+
assert!(t.contains("<interrupted>"));
298+
}
299+
}

src/extras/dirge_paths.rs

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,48 @@
1414
1515
use std::path::{Path, PathBuf};
1616

17-
/// Walk up from `cwd` until a `.git/` directory is found.
18-
/// Returns `cwd` unchanged if no git root is found (user may
19-
/// be outside a repo — per-project features degrade gracefully).
17+
/// Check if a path is a git reference (either a `.git` directory
18+
/// or a `.git` file referencing the real git dir, as used by
19+
/// `git worktree`).
20+
fn is_git_root_marker(path: &Path) -> bool {
21+
let git = path.join(".git");
22+
if git.is_dir() {
23+
return true;
24+
}
25+
// Git worktrees: .git is a file containing "gitdir: <path>".
26+
if git.is_file() {
27+
if let Ok(content) = std::fs::read_to_string(&git) {
28+
return content.starts_with("gitdir:");
29+
}
30+
}
31+
false
32+
}
33+
34+
/// Walk up from `cwd` until a `.git/` directory or worktree
35+
/// `.git` file is found. Returns `cwd` unchanged if no git root
36+
/// is found (user may be outside a repo — per-project features
37+
/// degrade gracefully).
2038
pub fn find_git_root(cwd: &Path) -> PathBuf {
21-
let mut current = cwd.to_path_buf();
39+
// Canonicalize to resolve symlinks in the path chain.
40+
let cwd = if let Ok(canon) = cwd.canonicalize() {
41+
canon
42+
} else {
43+
cwd.to_path_buf()
44+
};
45+
46+
let mut current = cwd.clone();
2247
loop {
23-
if current.join(".git").is_dir() {
48+
if is_git_root_marker(&current) {
2449
return current;
2550
}
26-
if !current.pop() {
27-
return cwd.to_path_buf();
51+
let parent = match current.parent() {
52+
Some(p) => p.to_path_buf(),
53+
None => return cwd.clone(),
54+
};
55+
if parent == current {
56+
return cwd.clone();
2857
}
58+
current = parent;
2959
}
3060
}
3161

@@ -117,12 +147,14 @@ mod tests {
117147
);
118148
}
119149

120-
/// `/tmp` has no `.git/` — should return `/tmp` unchanged.
150+
/// `/tmp` has no `.git/` — should return `/tmp` unchanged
151+
/// (canonicalized if possible).
121152
#[test]
122153
fn find_git_root_falls_back_to_cwd_outside_repo() {
123154
let tmp = std::env::temp_dir();
155+
let expected = tmp.canonicalize().unwrap_or_else(|_| tmp.clone());
124156
let root = find_git_root(&tmp);
125-
assert_eq!(root, tmp);
157+
assert_eq!(root, expected);
126158
}
127159

128160
/// `DIRGE_PROJECT_ROOT` wins over auto-detection.
@@ -181,4 +213,43 @@ mod tests {
181213
assert_eq!(f.file_name().unwrap(), "MEMORY.md");
182214
assert!(f.starts_with(paths.memory_dir()));
183215
}
216+
217+
/// Git worktrees use a `.git` file (not directory) containing
218+
/// `gitdir: <path>`. find_git_root should recognise this as a
219+
/// git root marker and stop walking.
220+
#[test]
221+
fn find_git_root_recognises_worktree_marker() {
222+
let dir = std::env::temp_dir().join(format!("dirge-worktree-test-{}", std::process::id()));
223+
let _ = std::fs::remove_dir_all(&dir);
224+
std::fs::create_dir_all(&dir).unwrap();
225+
// Create a worktree-style .git file.
226+
std::fs::write(
227+
dir.join(".git"),
228+
"gitdir: /some/real/path/.git/worktrees/foo\n",
229+
)
230+
.unwrap();
231+
232+
let root = find_git_root(&dir);
233+
let expected = dir.canonicalize().unwrap_or_else(|_| dir.clone());
234+
assert_eq!(root, expected, "should stop at worktree .git file");
235+
236+
let _ = std::fs::remove_dir_all(&dir);
237+
}
238+
239+
/// Paths with symlinks should still find the correct git root.
240+
#[test]
241+
fn find_git_root_with_symlinks() {
242+
let cwd = std::env::current_dir().unwrap();
243+
let root = find_git_root(&cwd);
244+
assert!(
245+
root.join(".git").is_dir() || {
246+
let git_file = root.join(".git");
247+
git_file.is_file()
248+
&& std::fs::read_to_string(&git_file)
249+
.map(|c| c.starts_with("gitdir:"))
250+
.unwrap_or(false)
251+
},
252+
"expected {root:?} to be a git root"
253+
);
254+
}
184255
}

0 commit comments

Comments
 (0)