Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 85 additions & 2 deletions src/agent_cmd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,49 @@ pub(crate) async fn migrate_legacy_hermes_data(home: &Path) -> tracedecay::error
})
}

/// Automated upgrade reinstalls must not remain wedged on preserved legacy
/// stores whose project evidence no longer resolves. Genuine read, integrity,
/// or copy failures still block the reinstall; an explicit Hermes install
/// continues to use the strict cutover above.
async fn migrate_legacy_hermes_data_for_reinstall(home: &Path) -> tracedecay::errors::Result<()> {
let report = tracedecay::migrate::hermes::migrate_legacy_hermes_stores(home).await;
finish_legacy_hermes_reinstall_migration(report)
}

fn finish_legacy_hermes_reinstall_migration(
report: tracedecay::migrate::hermes::LegacyHermesMigrationReport,
) -> tracedecay::errors::Result<()> {
for migration in report.migrated {
eprintln!(
" \x1b[32m✔\x1b[0m Migrated legacy Hermes session store {} -> {} ({} rows)",
migration.source_db.display(),
migration.target_project.display(),
migration.rows_copied
);
}
for issue in report.unresolved {
eprintln!(
" \x1b[33mwarning:\x1b[0m preserving unresolved legacy Hermes session store {}: {}",
issue.source_db.display(),
issue.reason
);
}
if report.failed.is_empty() {
return Ok(());
}
let issues = report
.failed
.into_iter()
.map(|issue| format!("{}: {}", issue.source_db.display(), issue.reason))
.collect::<Vec<_>>()
.join("; ");
Err(tracedecay::errors::TraceDecayError::Config {
message: format!(
"legacy Hermes session data migration failed; source data and project pins were preserved: {issues}"
),
})
}

pub(crate) async fn handle_install_command(
agent: Option<String>,
local: bool,
Expand Down Expand Up @@ -384,7 +427,9 @@ pub(crate) async fn handle_reinstall_command() -> tracedecay::errors::Result<()>
/// loop forever — `migrate_installed_agents` only ever adds ids, never prunes,
/// so a stale id would never resolve and the markers would never advance. Only
/// genuine `install()` failures are reported as `Err` so they still gate
/// markers.
/// markers. Unresolved legacy Hermes project evidence is preserved and warned
/// during automated reinstall; actual source-integrity or copy failures still
/// gate Hermes so corrupted or partially copied data cannot be hidden.
pub(crate) async fn reinstall_agent_integrations(
agent_ids: &[String],
home: &Path,
Expand All @@ -393,7 +438,7 @@ pub(crate) async fn reinstall_agent_integrations(
let project_path = std::env::current_dir().ok();
let mut results = Vec::new();
let hermes_migration_error = if agent_ids.iter().any(|id| id == "hermes") {
migrate_legacy_hermes_data(home)
migrate_legacy_hermes_data_for_reinstall(home)
.await
.err()
.map(|error| error.to_string())
Expand Down Expand Up @@ -497,3 +542,41 @@ pub(crate) async fn handle_uninstall_command(
}
Ok(())
}

#[cfg(test)]
mod tests {
use std::path::PathBuf;

use tracedecay::migrate::hermes::{LegacyHermesMigrationIssue, LegacyHermesMigrationReport};

use super::finish_legacy_hermes_reinstall_migration;

#[test]
fn automated_reinstall_preserves_unresolved_legacy_store_without_gating() {
let report = LegacyHermesMigrationReport {
unresolved: vec![LegacyHermesMigrationIssue {
source_db: PathBuf::from("legacy-sessions.db"),
reason: "project evidence is unresolved".to_string(),
}],
..LegacyHermesMigrationReport::default()
};

assert!(finish_legacy_hermes_reinstall_migration(report).is_ok());
}

#[test]
fn automated_reinstall_gates_legacy_store_failures() {
let report = LegacyHermesMigrationReport {
failed: vec![LegacyHermesMigrationIssue {
source_db: PathBuf::from("legacy-sessions.db"),
reason: "integrity check failed".to_string(),
}],
..LegacyHermesMigrationReport::default()
};

let error = finish_legacy_hermes_reinstall_migration(report)
.unwrap_err()
.to_string();
assert!(error.contains("integrity check failed"));
}
}
107 changes: 67 additions & 40 deletions src/automation/memory_digest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,12 @@ use crate::user_config::UserConfig;

pub const MEMORY_DIGEST_START: &str = "<!-- TRACEDECAY MEMORY DIGEST START -->";
pub const MEMORY_DIGEST_END: &str = "<!-- TRACEDECAY MEMORY DIGEST END -->";
const MEMORY_DIGEST_HEADING: &str = "## TraceDecay memory digest";
const MEMORY_DIGEST_BODY_PREAMBLE: &str = "Curated durable facts from TraceDecay project memory (trust-ranked, newest first). \
For deeper recall call MCP tool `tracedecay_recall`.\n\
Rate facts you rely on with `tracedecay_fact_feedback` (fact_id, helpful/unhelpful) \
— flagging a wrong or misleading fact unhelpful matters as much as confirming a \
helpful one; trust is earned only from this feedback.\n";

/// Minimum trust score for a fact to enter the digest.
pub const DEFAULT_DIGEST_MIN_TRUST: f64 = 0.6;
Expand Down Expand Up @@ -285,13 +291,7 @@ pub fn compose_digest_body(snapshot: &MemoryDigestSnapshot, char_budget: usize)
.then(left.project_key.cmp(&right.project_key))
});

let mut body = String::from(
"Curated durable facts from TraceDecay project memory (trust-ranked, newest first). \
For deeper recall call MCP tool `tracedecay_recall`.\n\
Rate facts you rely on with `tracedecay_fact_feedback` (fact_id, helpful/unhelpful) \
— flagging a wrong or misleading fact unhelpful matters as much as confirming a \
helpful one; trust is earned only from this feedback.\n",
);
let mut body = String::from(MEMORY_DIGEST_BODY_PREAMBLE);
let mut budget_hit = false;
for section in sections {
let header = format!("\n## {}\n\n", section.project_label);
Expand Down Expand Up @@ -569,59 +569,86 @@ fn remove_native_digest(target: SkillInstallTarget, plugin_root: &Path) -> Resul
}

fn render_prompt_digest_block(body: &str) -> String {
format!("{MEMORY_DIGEST_START}\n## TraceDecay memory digest\n\n{body}{MEMORY_DIGEST_END}\n")
format!("{MEMORY_DIGEST_START}\n{MEMORY_DIGEST_HEADING}\n\n{body}{MEMORY_DIGEST_END}\n")
}

fn replace_or_append_digest_block(existing: &str, block: &str) -> Result<String> {
match (
existing.find(MEMORY_DIGEST_START),
existing.find(MEMORY_DIGEST_END),
) {
(Some(start), Some(end)) if start <= end => {
let end = end + MEMORY_DIGEST_END.len();
if let Some((start, end)) = digest_block_range(existing)? {
let mut updated = String::new();
updated.push_str(existing[..start].trim_end());
if !updated.is_empty() {
updated.push_str("\n\n");
}
updated.push_str(block.trim_end());
updated.push_str("\n\n");
updated.push_str(existing[end..].trim_start());
let trimmed = updated.trim_end().to_string();
Ok(trimmed + "\n")
} else {
let mut updated = String::new();
updated.push_str(existing.trim_end());
if !updated.is_empty() {
updated.push_str("\n\n");
}
updated.push_str(block);
Ok(updated)
}
}

fn remove_digest_block(existing: &str) -> Result<String> {
match digest_block_range(existing)? {
Some((start, end)) => {
let mut updated = String::new();
updated.push_str(existing[..start].trim_end());
if !updated.is_empty() {
updated.push_str("\n\n");
}
updated.push_str(block.trim_end());
updated.push_str("\n\n");
updated.push_str(existing[end..].trim_start());
let trimmed = updated.trim_end().to_string();
Ok(trimmed + "\n")
}
(None, None) => {
let mut updated = String::new();
updated.push_str(existing.trim_end());
if !updated.is_empty() {
updated.push_str("\n\n");
if !updated.trim().is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(block);
Ok(updated)
}
_ => Err(config_error(
"memory digest prompt markers are unbalanced".to_string(),
)),
None => Ok(existing.to_string()),
}
}

fn remove_digest_block(existing: &str) -> Result<String> {
fn digest_block_range(existing: &str) -> Result<Option<(usize, usize)>> {
match (
existing.find(MEMORY_DIGEST_START),
existing.find(MEMORY_DIGEST_END),
) {
(Some(start), Some(end)) if start <= end => {
let end = end + MEMORY_DIGEST_END.len();
let mut updated = String::new();
updated.push_str(existing[..start].trim_end());
updated.push_str("\n\n");
updated.push_str(existing[end..].trim_start());
if !updated.trim().is_empty() && !updated.ends_with('\n') {
updated.push('\n');
if existing.match_indices(MEMORY_DIGEST_START).count() != 1
|| existing.match_indices(MEMORY_DIGEST_END).count() != 1
{
return Err(config_error(
"memory digest prompt markers are ambiguous".to_string(),
));
}
Ok(updated)
Ok(Some((start, end + MEMORY_DIGEST_END.len())))
}
(None, Some(end)) => {
if existing.match_indices(MEMORY_DIGEST_END).count() != 1 {
return Err(config_error(
"memory digest prompt markers are unbalanced".to_string(),
));
}
let preamble = format!("{MEMORY_DIGEST_HEADING}\n\n{MEMORY_DIGEST_BODY_PREAMBLE}");
let mut matches = existing[..end].match_indices(&preamble);
let Some((start, _)) = matches.next() else {
return Err(config_error(
"memory digest prompt markers are unbalanced".to_string(),
));
};
if matches.next().is_some()
|| existing[end + MEMORY_DIGEST_END.len()..].contains(&preamble)
{
return Err(config_error(
"memory digest prompt markers are unbalanced".to_string(),
));
}
Ok(Some((start, end + MEMORY_DIGEST_END.len())))
}
(None, None) => Ok(existing.to_string()),
(None, None) => Ok(None),
_ => Err(config_error(
"memory digest prompt markers are unbalanced".to_string(),
)),
Expand Down
Loading
Loading