agent-trace 0.1.0

Git-backed document memory, trace continuity, and permissioned writes for agent workflows
Documentation
use crate::llm::{Llm, TraceDocument};
use crate::manifest::Manifest;
use crate::types::DocType;
use anyhow::Result;
use chrono::Utc;
use std::collections::HashSet;
use std::path::{Path, PathBuf};

/// A pending user update to be incorporated into context.md.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct ContextUpdate {
    pub timestamp: String,
    pub update: String,
    pub incorporated: bool,
}

/// Load pending (unincorporated) context updates from the JSONL file.
pub fn load_pending_updates(store_root: &Path) -> Result<Vec<ContextUpdate>> {
    let path = store_root
        .join(".agent-trace")
        .join("context_updates.jsonl");
    if !path.exists() {
        return Ok(Vec::new());
    }
    let content = std::fs::read_to_string(&path)?;
    let updates: Vec<ContextUpdate> = content
        .lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str(l).ok())
        .filter(|u: &ContextUpdate| !u.incorporated)
        .collect();
    Ok(updates)
}

/// Mark all pending updates as incorporated.
pub fn mark_updates_incorporated(store_root: &Path) -> Result<()> {
    let path = store_root
        .join(".agent-trace")
        .join("context_updates.jsonl");
    if !path.exists() {
        return Ok(());
    }
    let content = std::fs::read_to_string(&path)?;
    let updated: String = content
        .lines()
        .filter(|l| !l.trim().is_empty())
        .filter_map(|l| serde_json::from_str::<serde_json::Value>(l).ok())
        .map(|mut v| {
            v["incorporated"] = serde_json::Value::Bool(true);
            v.to_string()
        })
        .collect::<Vec<_>>()
        .join("\n");
    std::fs::write(&path, updated + "\n")?;
    Ok(())
}

/// Synthesize context.md without an LLM — produces a structured template.
pub fn synthesize_no_llm(store_root: &Path, manifest: &Manifest) -> Result<String> {
    let plans = manifest.list(Some(&DocType::Plan));
    let refs = manifest.list(Some(&DocType::Reference));
    let scratches = manifest.list(Some(&DocType::Scratch));
    let pending = load_pending_updates(store_root)?;

    let mut out = String::from("# Project Context\n\n");
    out.push_str(&format!(
        "*Auto-generated by agent-trace on {}. Use `agent-trace context refresh` to update.*\n\n",
        Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
    ));

    if !pending.is_empty() {
        out.push_str("## Recent Updates\n\n");
        for u in &pending {
            out.push_str(&format!("- {}\n", u.update));
        }
        out.push('\n');
    }

    out.push_str("## Plans\n\n");
    if plans.is_empty() {
        out.push_str("*(no plan documents)*\n");
    } else {
        for p in &plans {
            let desc = if p.description.is_empty() {
                "(no description)"
            } else {
                &p.description
            };
            out.push_str(&format!("- **{}** — {}\n", p.path.display(), desc));
        }
    }
    out.push('\n');

    out.push_str("## Reference\n\n");
    if refs.is_empty() {
        out.push_str("*(no reference documents)*\n");
    } else {
        for r in &refs {
            let desc = if r.description.is_empty() {
                "(no description)"
            } else {
                &r.description
            };
            out.push_str(&format!("- **{}** — {}\n", r.path.display(), desc));
        }
    }
    out.push('\n');

    if !scratches.is_empty() {
        out.push_str("## Scratch / Working Documents\n\n");
        for s in &scratches {
            let body = std::fs::read_to_string(store_root.join(&s.path)).unwrap_or_default();
            let snippet: String = body.chars().take(500).collect();
            if snippet.trim().is_empty() {
                out.push_str(&format!("- {}\n", s.path.display()));
            } else {
                out.push_str(&format!(
                    "- [scratch] {}: {}\n",
                    s.path.display(),
                    snippet.trim().replace('\n', " ")
                ));
            }
        }
        out.push('\n');
    }

    // Include recent file activity (including unmanaged source files such as
    // `.py` edits) so template mode still reflects real work.
    if let Ok(events) = crate::running_summary::load_recent_events(store_root, 15) {
        if !events.is_empty() {
            out.push_str("## Recent File Activity\n\n");
            for e in events.iter().rev() {
                let summary = e.summary.trim().replace('\n', " ");
                if summary.is_empty() {
                    out.push_str(&format!("- {}\n", e.path));
                } else {
                    out.push_str(&format!("- {} — {}\n", e.path, summary));
                }
            }
            out.push('\n');
        }
    }

    Ok(out)
}

/// Build trace documents for LLM context synthesis from manifest entries and
/// recently changed paths (including unmanifested source files).
pub fn build_trace_documents(
    store_root: &Path,
    manifest: &Manifest,
    changed_paths: &[PathBuf],
) -> Vec<TraceDocument> {
    let mut seen: HashSet<PathBuf> = HashSet::new();
    let mut docs: Vec<TraceDocument> = manifest
        .documents()
        .iter()
        .filter(|d| {
            matches!(
                d.doc_type,
                DocType::Plan | DocType::Reference | DocType::Scratch
            )
        })
        .map(|d| {
            seen.insert(d.path.clone());
            let content = std::fs::read_to_string(store_root.join(&d.path)).unwrap_or_default();
            let snippet: String = content.chars().take(2000).collect();
            TraceDocument {
                path: d.path.display().to_string(),
                doc_type: d.doc_type.clone(),
                content_snippet: snippet,
            }
        })
        .collect();

    for path in changed_paths {
        if seen.contains(path) || !crate::git_store::should_track_activity(path) {
            continue;
        }
        let full = store_root.join(path);
        if !full.is_file() {
            continue;
        }
        seen.insert(path.clone());
        let content = std::fs::read_to_string(&full).unwrap_or_default();
        let snippet: String = content.chars().take(2000).collect();
        docs.push(TraceDocument {
            path: path.display().to_string(),
            doc_type: DocType::Scratch,
            content_snippet: snippet,
        });
    }

    docs
}

/// Synthesize context.md body using the same policy as the post-write pipeline.
/// Returns `(content, commit_label)` where `commit_label` is `template` or `llm: <backend>`.
pub fn synthesize_context_content(
    store_root: &Path,
    manifest: &Manifest,
    trace_insights: &Llm,
    changed_paths: &[PathBuf],
) -> Result<(String, String)> {
    if trace_insights.is_degraded() {
        return Ok((
            synthesize_no_llm(store_root, manifest)?,
            "template".to_string(),
        ));
    }

    let docs = build_trace_documents(store_root, manifest, changed_paths);
    let updates = load_pending_updates(store_root)?
        .into_iter()
        .map(|u| u.update)
        .collect::<Vec<_>>();
    let start = std::time::Instant::now();
    match trace_insights.synthesize_context(&docs, &updates) {
        Ok(s) => {
            tracing::info!(
                "LLM context synthesis succeeded (backend={}, latency_ms={})",
                trace_insights.backend_label,
                start.elapsed().as_millis()
            );
            Ok((s, format!("llm: {}", trace_insights.backend_label)))
        }
        Err(e) => {
            tracing::warn!("LLM synthesize_context failed, using template: {e}");
            Ok((
                synthesize_no_llm(store_root, manifest)?,
                "template".to_string(),
            ))
        }
    }
}

/// Write context.md to the store root and mark updates as incorporated.
pub fn write_context(store_root: &Path, content: &str) -> Result<()> {
    std::fs::write(store_root.join("context.md"), content)?;
    mark_updates_incorporated(store_root)?;
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::StoreInfo;
    use crate::manifest::Manifest;
    use tempfile::TempDir;

    fn setup(tmp: &TempDir) -> (std::path::PathBuf, Manifest) {
        let root = tmp.path().to_path_buf();
        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();
        let info = StoreInfo::new("test".into());
        let manifest = Manifest::create_empty(info, &root).unwrap();
        (root, manifest)
    }

    #[test]
    fn test_no_llm_synthesis_empty_store() {
        let tmp = TempDir::new().unwrap();
        let (root, manifest) = setup(&tmp);
        let ctx = synthesize_no_llm(&root, &manifest).unwrap();
        assert!(ctx.contains("# Project Context"));
        assert!(ctx.contains("no plan documents"));
    }

    #[test]
    fn test_no_llm_synthesis_with_plans() {
        let tmp = TempDir::new().unwrap();
        let (root, mut manifest) = setup(&tmp);
        manifest
            .register(&std::path::PathBuf::from("prd.md"), DocType::Plan, "")
            .unwrap();
        manifest
            .update_description(&std::path::PathBuf::from("prd.md"), "Product requirements")
            .unwrap();
        let ctx = synthesize_no_llm(&root, &manifest).unwrap();
        assert!(ctx.contains("prd.md"));
        assert!(ctx.contains("Product requirements"));
    }

    #[test]
    fn test_pending_updates_loaded() {
        let tmp = TempDir::new().unwrap();
        let root = tmp.path().to_path_buf();
        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();

        let entry = serde_json::json!({
            "timestamp": "2026-04-07T00:00:00Z",
            "update": "We chose PostgreSQL",
            "incorporated": false,
        });
        std::fs::write(
            root.join(".agent-trace").join("context_updates.jsonl"),
            entry.to_string() + "\n",
        )
        .unwrap();

        let pending = load_pending_updates(&root).unwrap();
        assert_eq!(pending.len(), 1);
        assert_eq!(pending[0].update, "We chose PostgreSQL");
    }

    #[test]
    fn test_no_llm_synthesis_includes_scratch_snippets() {
        let tmp = TempDir::new().unwrap();
        let (root, mut manifest) = setup(&tmp);
        let body = "Working notes: implement idempotency for reconnect flow. ".repeat(10);
        std::fs::write(root.join("notes.md"), &body).unwrap();
        manifest
            .register(&std::path::PathBuf::from("notes.md"), DocType::Scratch, "")
            .unwrap();
        let ctx = synthesize_no_llm(&root, &manifest).unwrap();
        assert!(ctx.contains("## Scratch / Working Documents"));
        assert!(ctx.contains("[scratch] notes.md:"));
        assert!(ctx.contains("implement idempotency"));
    }

    #[test]
    fn test_no_llm_synthesis_includes_recent_activity() {
        let tmp = TempDir::new().unwrap();
        let (root, manifest) = setup(&tmp);
        crate::running_summary::append_event(
            &root,
            crate::running_summary::SummaryEvent {
                timestamp: Utc::now().to_rfc3339(),
                session_id: Some("s1".into()),
                agent_name: Some("claude".into()),
                actor: "agent:claude".into(),
                action: "modify".into(),
                change_kind: "modify".into(),
                path: "worker.py".into(),
                doc_type: "scratch".into(),
                summary: "implement retry backoff".into(),
                source: "poll".into(),
                detected_by: "poll".into(),
                lines_added: 4,
                lines_removed: 1,
            },
        )
        .unwrap();

        let ctx = synthesize_no_llm(&root, &manifest).unwrap();
        assert!(ctx.contains("## Recent File Activity"));
        assert!(ctx.contains("worker.py"));
        assert!(ctx.contains("implement retry backoff"));
    }

    #[test]
    fn test_mark_updates_incorporated() {
        let tmp = TempDir::new().unwrap();
        let root = tmp.path().to_path_buf();
        std::fs::create_dir_all(root.join(".agent-trace")).unwrap();

        let entry = serde_json::json!({
            "timestamp": "2026-04-07T00:00:00Z",
            "update": "test update",
            "incorporated": false,
        });
        std::fs::write(
            root.join(".agent-trace").join("context_updates.jsonl"),
            entry.to_string() + "\n",
        )
        .unwrap();

        mark_updates_incorporated(&root).unwrap();

        let pending = load_pending_updates(&root).unwrap();
        assert!(pending.is_empty());
    }
}