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
82 changes: 55 additions & 27 deletions src/automation/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ pub struct SkillWriterAutomationOptions {
pub trigger: AutomationTrigger,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub run_id: Option<String>,
#[serde(default = "default_lcm_storage_scope")]
pub storage_scope: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes_home: Option<PathBuf>,
#[serde(default = "default_skill_writer_provider")]
pub provider: String,
#[serde(default = "default_skill_writer_query")]
Expand All @@ -123,6 +127,8 @@ impl Default for SkillWriterAutomationOptions {
Self {
trigger: AutomationTrigger::ManualCli,
run_id: None,
storage_scope: default_lcm_storage_scope(),
hermes_home: None,
provider: default_skill_writer_provider(),
query: default_skill_writer_query(),
evidence_limit: default_skill_writer_evidence_limit(),
Expand Down Expand Up @@ -154,7 +160,7 @@ enum SkillWriterEvidenceOutcome {
},
}

enum SessionReflectorLcmStore {
enum LcmAutomationStore {
Available(PathBuf),
NotIngested,
}
Expand Down Expand Up @@ -190,15 +196,17 @@ pub async fn run_session_reflector_with_backend(
}
};

let sessions_db_path = match session_reflector_lcm_db_path(cg, &storage_scope, &options)? {
SessionReflectorLcmStore::Available(path) => path,
SessionReflectorLcmStore::NotIngested => {
let sessions_db_path = match automation_lcm_db_path(
cg,
&storage_scope,
options.hermes_home.as_ref(),
"session_reflector",
)? {
LcmAutomationStore::Available(path) => path,
LcmAutomationStore::NotIngested => {
return skipped_session_reflector_run(&run, "lcm_not_ingested", None).await;
}
};
if !sessions_db_path.is_file() {
return skipped_session_reflector_run(&run, "lcm_not_ingested", None).await;
}
let Some(lcm_db) = GlobalDb::open_read_only_at(&sessions_db_path).await else {
return skipped_session_reflector_run(&run, "lcm_unavailable", None).await;
};
Expand Down Expand Up @@ -500,18 +508,27 @@ async fn build_skill_writer_evidence(
Some(path) => path,
None => crate::storage::default_profile_root()?,
};
let storage_scope =
normalized_non_empty(&options.storage_scope).unwrap_or_else(default_lcm_storage_scope);
let provider =
normalized_non_empty(&options.provider).unwrap_or_else(default_skill_writer_provider);
let query = normalized_non_empty(&options.query).unwrap_or_else(default_skill_writer_query);
let evidence_limit = options.evidence_limit.clamp(1, 50);

let sessions_db_path = cg.store_layout().sessions_db_path.clone();
if !sessions_db_path.is_file() {
return Ok(SkillWriterEvidenceOutcome::Skipped {
reason: "lcm_not_ingested",
evidence_hash: None,
});
}
let sessions_db_path = match automation_lcm_db_path(
cg,
&storage_scope,
options.hermes_home.as_ref(),
"skill_writer",
)? {
LcmAutomationStore::Available(path) => path,
LcmAutomationStore::NotIngested => {
return Ok(SkillWriterEvidenceOutcome::Skipped {
reason: "lcm_not_ingested",
evidence_hash: None,
});
}
};
let Some(lcm_db) = GlobalDb::open_read_only_at(&sessions_db_path).await else {
return Ok(SkillWriterEvidenceOutcome::Skipped {
reason: "lcm_unavailable",
Expand Down Expand Up @@ -569,6 +586,8 @@ async fn build_skill_writer_evidence(
&underused_tool_families,
);
let evidence = json!({
"storage_scope": storage_scope,
"hermes_home": options.hermes_home.as_ref().map(|path| path.display().to_string()),
"provider": provider,
"query": query,
"hits": hits,
Expand Down Expand Up @@ -650,7 +669,7 @@ async fn skipped_skill_writer_run(

fn build_session_reflector_prompt(evidence: &Value) -> String {
format!(
"Review these bounded TraceDecay session snippets and propose only durable memory facts. Return only JSON with a facts array. Each fact must include content, category, optional tags, optional entities, trust, source_span, and reason. Category must be one of general, user_pref, project, tool, decision, or code_area. Use trust, not confidence. source_span must cite one bounded evidence hit by session_id plus message_id for raw messages, by store_id for raw messages, or by node_id for summaries. Do not include secrets or ephemeral status.\n{}",
"Review these bounded TraceDecay session snippets and propose only durable memory facts. Return only JSON with a facts array. Each fact must include content, category, optional tags, optional entities, trust, source_span, and reason. Category must be one of general, user_pref, project, tool, decision, or code_area. Use trust, not confidence; trust must be a JSON number from 0.0 to 1.0. Do not use string labels like high, medium, or low. source_span must cite one bounded evidence hit by session_id plus message_id for raw messages, by store_id for raw messages, or by node_id for summaries. Do not include secrets or ephemeral status.\n{}",
serde_json::to_string_pretty(evidence).unwrap_or_else(|_| "{}".to_string())
)
}
Expand All @@ -671,30 +690,39 @@ fn normalized_non_empty(value: &str) -> Option<String> {
}
}

fn session_reflector_lcm_db_path(
/// Resolves the LCM sessions database for an automation task, reporting
/// `NotIngested` when the store does not exist yet so callers can skip
/// without re-checking the path.
fn automation_lcm_db_path(
cg: &TraceDecay,
storage_scope: &str,
options: &SessionReflectorAutomationOptions,
) -> Result<SessionReflectorLcmStore> {
hermes_home: Option<&PathBuf>,
task_name: &str,
) -> Result<LcmAutomationStore> {
match storage_scope {
"project_local" => Ok(SessionReflectorLcmStore::Available(
cg.store_layout().sessions_db_path.clone(),
)),
"project_local" => {
let path = cg.store_layout().sessions_db_path.clone();
if path.is_file() {
Ok(LcmAutomationStore::Available(path))
} else {
Ok(LcmAutomationStore::NotIngested)
}
}
"hermes_profile" => {
let hermes_home = options.hermes_home.as_ref().ok_or_else(|| TraceDecayError::Config {
message: "session_reflector hermes_profile storage requires hermes_home".to_string(),
let hermes_home = hermes_home.ok_or_else(|| TraceDecayError::Config {
message: format!("{task_name} hermes_profile storage requires hermes_home"),
})?;
match resolve_hermes_profile_session_db_readonly(hermes_home) {
HermesProfileDbReadOnly::Exists(path) => Ok(SessionReflectorLcmStore::Available(path)),
HermesProfileDbReadOnly::NotIngested(_) => Ok(SessionReflectorLcmStore::NotIngested),
HermesProfileDbReadOnly::Exists(path) => Ok(LcmAutomationStore::Available(path)),
HermesProfileDbReadOnly::NotIngested(_) => Ok(LcmAutomationStore::NotIngested),
HermesProfileDbReadOnly::ConfigError(message) => Err(TraceDecayError::Config {
message: format!("invalid session_reflector hermes_home: {message}"),
message: format!("invalid {task_name} hermes_home: {message}"),
}),
}
}
other => Err(TraceDecayError::Config {
message: format!(
"unknown session_reflector storage_scope '{other}'; expected project_local or hermes_profile"
"unknown {task_name} storage_scope '{other}'; expected project_local or hermes_profile"
),
}),
}
Expand Down
6 changes: 5 additions & 1 deletion src/automation_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,7 @@ async fn handle_automation_run_command(
trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli,
run_id: None,
storage_scope,
hermes_home: hermes_home.map(Into::into),
hermes_home,
provider,
query,
scope: lcm_scope,
Expand All @@ -522,6 +522,8 @@ async fn handle_automation_run_command(
provider,
query,
evidence_limit,
storage_scope,
hermes_home,
path,
} => {
if !dry_run {
Expand Down Expand Up @@ -550,6 +552,8 @@ async fn handle_automation_run_command(
SkillWriterAutomationOptions {
trigger: tracedecay::automation::run_ledger::AutomationTrigger::ManualCli,
run_id: None,
storage_scope,
hermes_home,
provider,
query,
evidence_limit,
Expand Down
14 changes: 11 additions & 3 deletions src/cli/automation.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::PathBuf;

use clap::{Subcommand, ValueEnum};

#[allow(clippy::large_enum_variant)]
Expand Down Expand Up @@ -215,7 +217,7 @@ pub enum AutomationRunAction {
storage_scope: String,
/// Absolute Hermes profile home directory when --storage-scope hermes_profile.
#[arg(long)]
hermes_home: Option<String>,
hermes_home: Option<PathBuf>,
/// LCM grep scope: all, session, or current.
#[arg(long, default_value = "all")]
scope: String,
Expand Down Expand Up @@ -250,8 +252,8 @@ pub enum AutomationRunAction {
/// Keep the run non-mutating. This is currently the only supported mode.
#[arg(long, default_value_t = true, action = clap::ArgAction::Set)]
dry_run: bool,
/// LCM provider to inspect.
#[arg(long, default_value = "cursor")]
/// LCM provider to inspect. Use all for unified cross-provider evidence.
#[arg(long, default_value = "all")]
provider: String,
/// LCM grep query used to collect bounded evidence.
#[arg(
Expand All @@ -262,6 +264,12 @@ pub enum AutomationRunAction {
/// Maximum LCM evidence snippets included in the backend review request.
#[arg(long, default_value_t = 20)]
evidence_limit: usize,
/// LCM storage scope: project_local or hermes_profile.
#[arg(long, default_value = "project_local")]
storage_scope: String,
/// Absolute Hermes profile home directory when --storage-scope hermes_profile.
#[arg(long)]
hermes_home: Option<PathBuf>,
/// Project path (default: current directory, with discovery).
#[arg(short, long)]
path: Option<String>,
Expand Down
30 changes: 29 additions & 1 deletion src/cli/parse_tests.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
use std::path::Path;

use super::{
AutomationAction, AutomationConfigAction, AutomationConfigScope, AutomationRunAction,
AutomationRunsAction, AutomationSkillsAction, AutomationSkillsInstallTarget, BranchAction, Cli,
Expand Down Expand Up @@ -695,7 +697,7 @@ fn automation_run_session_reflection_parses_manual_dry_run_flags() {
&& query == "remember decisions"
&& evidence_limit == 12
&& storage_scope == "hermes_profile"
&& hermes_home.as_deref() == Some("/tmp/hermes-profile")
&& hermes_home.as_deref() == Some(Path::new("/tmp/hermes-profile"))
&& scope == "session"
&& session_id.as_deref() == Some("session-123")
&& !include_summaries
Expand Down Expand Up @@ -739,17 +741,43 @@ fn automation_run_skill_writing_parses_manual_dry_run_flags() {
provider,
query,
evidence_limit,
storage_scope,
hermes_home,
path,
}
}
}) if dry_run
&& provider == "cursor"
&& query == "workflow corrections"
&& evidence_limit == 9
&& storage_scope == "project_local"
&& hermes_home.is_none()
&& path.as_deref() == Some("/tmp/project")
));
}

#[test]
fn automation_run_skill_writing_defaults_to_all_providers() {
let cli = Cli::try_parse_from(["tracedecay", "automation", "run", "skill-writing"])
.expect("automation skill-writing run should parse with defaults");

assert!(matches!(
cli.command,
Some(Commands::Automation {
action:
AutomationAction::Run {
action:
AutomationRunAction::SkillWriting {
provider,
storage_scope,
hermes_home,
..
}
}
}) if provider == "all" && storage_scope == "project_local" && hermes_home.is_none()
));
}

#[test]
fn automation_runs_commands_parse_history_flags() {
let list = Cli::try_parse_from([
Expand Down
4 changes: 4 additions & 0 deletions src/dashboard/automation_run_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ pub(crate) struct SkillWritingRunBody {
provider: Option<String>,
query: Option<String>,
evidence_limit: Option<usize>,
storage_scope: Option<String>,
hermes_home: Option<PathBuf>,
}

impl From<SkillWritingRunBody> for SkillWritingRunRequest {
Expand All @@ -114,6 +116,8 @@ impl From<SkillWritingRunBody> for SkillWritingRunRequest {
provider: body.provider,
query: body.query,
evidence_limit: body.evidence_limit,
storage_scope: body.storage_scope,
hermes_home: body.hermes_home,
}
}
}
Expand Down
8 changes: 8 additions & 0 deletions src/dashboard/automation_run_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,8 @@ pub(crate) struct SkillWritingRunRequest {
pub provider: Option<String>,
pub query: Option<String>,
pub evidence_limit: Option<usize>,
pub storage_scope: Option<String>,
pub hermes_home: Option<PathBuf>,
}

pub(crate) async fn session_reflection_run_payload_with_run_id(
Expand Down Expand Up @@ -395,6 +397,12 @@ pub(crate) async fn skill_writing_run_payload_with_run_id(
if let Some(evidence_limit) = request.evidence_limit {
options.evidence_limit = evidence_limit;
}
if let Some(storage_scope) = request.storage_scope {
options.storage_scope = storage_scope;
}
if let Some(hermes_home) = request.hermes_home {
options.hermes_home = Some(hermes_home);
}
let run = match run_skill_writer_with_backend(
&run_context.cg,
&run_context.config,
Expand Down
31 changes: 31 additions & 0 deletions tests/automation_runner_support/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -752,6 +752,37 @@ pub(crate) async fn read_artifact(
.unwrap()
}

/// Standalone automation config with only the skill writer task enabled on a
/// manual schedule; override fields with struct update syntax where needed.
pub(crate) fn enabled_skill_writer_config() -> AutomationConfig {
AutomationConfig {
enabled: true,
backend: AutomationBackend::CodexAppServer,
host_mode: AutomationHostMode::Standalone,
tasks: AutomationTaskSet {
skill_writer: AutomationTaskConfig {
enabled: true,
schedule: Some("manual".to_string()),
..AutomationTaskConfig::default()
},
..AutomationTaskSet::default()
},
..AutomationConfig::default()
}
}

/// Manual-trigger skill writer options matching the seeded "automation" fixture
/// evidence, rooted at the given managed-skill profile directory.
pub(crate) fn manual_skill_writer_options(profile_root: &Path) -> SkillWriterAutomationOptions {
SkillWriterAutomationOptions {
provider: "cursor".to_string(),
query: "automation".to_string(),
evidence_limit: 5,
profile_root: Some(profile_root.to_path_buf()),
..SkillWriterAutomationOptions::default()
}
}

pub(crate) fn scheduler_config(
interval_secs: Option<u64>,
cooldown_secs: Option<u64>,
Expand Down
Loading
Loading