use crate::cli::args::{
Cli, Command, CommonRouteArgs, ExplainArgs, GetArgs, HookArgs, InitArgs, McpArgs, McpCommand,
MemoryActionArgs, MemoryArgs, MemoryCommand, MemoryConsolidateArgs, MemoryDedupArgs,
MemoryFormatValue, MemoryImportArgs, MemoryImportGitArgs, MemoryLintArgs, MemoryListArgs,
MemoryListViewValue, MemoryPruneArgs, MemoryRecordArgs, MemoryShowArgs, MemoryStatsArgs,
MemorySyncIndexArgs, MemorySyncVaultArgs, StatusArgs, WakeupArgs,
};
use crate::cli::{hook, mcp_install};
use crate::daemon::{LifecycleReadOptions, read_history, read_record, read_workbench};
use crate::domain::{MemoryLifecycleState, OutputFormat, RouteInput};
use crate::lifecycle_service::{LifecycleAction, LifecycleService};
use crate::lifecycle_store::{
LedgerEntry, LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
latest_state_entries, lifecycle_root_from_config,
};
use crate::lifecycle_summary;
use crate::memory_gateway::{self, context_request, wakeup_request};
use crate::output;
use crate::vault_writer;
use clap::Parser;
use std::path::{Path, PathBuf};
pub fn run() -> anyhow::Result<()> {
let cli = Cli::parse();
match cli.command {
Command::Get(args) => execute_get(args),
Command::Explain(args) => execute_explain(args),
Command::Wakeup(args) => execute_wakeup(args),
Command::Memory(args) => execute_memory(args),
Command::Mcp(args) => execute_mcp(args),
Command::Hook(args) => execute_hook(args),
Command::Init(args) => execute_init(args),
Command::Status(args) => execute_status(args),
#[cfg(feature = "embedding")]
Command::Embedding(args) => execute_embedding(args),
Command::Knowledge(args) => execute_knowledge(args),
}
}
fn execute_hook(args: HookArgs) -> anyhow::Result<()> {
hook::execute(args)
}
fn execute_mcp(args: McpArgs) -> anyhow::Result<()> {
match args.command {
McpCommand::Install(a) => mcp_install::execute_install(a),
McpCommand::Update(a) => mcp_install::execute_update(a),
McpCommand::Uninstall(a) => mcp_install::execute_uninstall(a),
McpCommand::Doctor(a) => mcp_install::execute_doctor(a),
}
}
fn execute_get(args: GetArgs) -> anyhow::Result<()> {
let config_path = args.common.config.clone();
let config = memory_gateway::load_config(&config_path)?;
let requested_format = args.format.map(Into::into);
let format = requested_format.unwrap_or_else(|| app_format(&config));
let input = to_route_input(args.common, format);
let response = memory_gateway::execute(&config_path, context_request(input), None)?;
println!(
"{}",
output::render(&response.bundle, config.output.max_chars, format)
);
Ok(())
}
fn execute_explain(args: ExplainArgs) -> anyhow::Result<()> {
let config_path = args.common.config.clone();
let input = to_route_input(args.common, OutputFormat::Markdown);
let response = memory_gateway::execute(&config_path, context_request(input), None)?;
println!("{}", output::explain(&response.bundle));
Ok(())
}
fn execute_wakeup(args: WakeupArgs) -> anyhow::Result<()> {
let config_path = args.common.config.clone();
let format = args.format.map(Into::into).unwrap_or(OutputFormat::Json);
let input = to_route_input(args.common, format);
let response = memory_gateway::execute(
&config_path,
wakeup_request(input, args.profile.into()),
None,
)?;
println!(
"{}",
output::wakeup::render(response.wakeup_packet().unwrap(), format)
);
Ok(())
}
fn execute_memory(args: MemoryArgs) -> anyhow::Result<()> {
match args.command {
MemoryCommand::List(args) => execute_memory_list(args),
MemoryCommand::Show(args) => execute_memory_show(args),
MemoryCommand::History(args) => execute_memory_history(args),
MemoryCommand::RecordManual(args) => execute_memory_record_manual(args),
MemoryCommand::Propose(args) => execute_memory_propose(args),
MemoryCommand::Accept(args) => execute_memory_action(args, LifecycleAction::Accept),
MemoryCommand::Promote(args) => {
execute_memory_action(args, LifecycleAction::PromoteToCanonical)
}
MemoryCommand::Archive(args) => execute_memory_action(args, LifecycleAction::Archive),
MemoryCommand::Import(args) => execute_memory_import(args),
MemoryCommand::ImportGit(args) => execute_memory_import_git(args),
MemoryCommand::SyncVault(args) => execute_memory_sync_vault(args),
MemoryCommand::Dedup(args) => execute_memory_dedup(args),
MemoryCommand::Consolidate(args) => execute_memory_consolidate(args),
MemoryCommand::Prune(args) => execute_memory_prune(args),
MemoryCommand::Lint(args) => execute_memory_lint(args),
MemoryCommand::SyncIndex(args) => execute_memory_sync_index(args),
MemoryCommand::Stats(args) => execute_memory_stats(args),
}
}
fn execute_memory_list(args: MemoryListArgs) -> anyhow::Result<()> {
let snapshot = read_workbench(
args.config.as_path(),
&lifecycle_read_options(args.daemon_bin.as_deref()),
)?;
let entries = match args.view {
MemoryListViewValue::PendingReview => snapshot.pending_review,
MemoryListViewValue::WakeupReady => snapshot.wakeup_ready,
};
let heading = match args.view {
MemoryListViewValue::PendingReview => "Pending review",
MemoryListViewValue::WakeupReady => "Wakeup-ready",
};
match args.format.unwrap_or(MemoryFormatValue::Markdown) {
MemoryFormatValue::Markdown => println!("{}", render_lifecycle_list(heading, &entries)),
MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&entries)?),
}
Ok(())
}
fn execute_memory_show(args: MemoryShowArgs) -> anyhow::Result<()> {
let entry = read_record(
args.config.as_path(),
&args.record_id,
&lifecycle_read_options(args.daemon_bin.as_deref()),
)?
.ok_or_else(|| anyhow::anyhow!("memory record not found: {}", args.record_id))?;
match args.format.unwrap_or(MemoryFormatValue::Markdown) {
MemoryFormatValue::Markdown => println!("{}", render_lifecycle_detail(&entry)),
MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&entry)?),
}
Ok(())
}
fn execute_memory_history(args: MemoryShowArgs) -> anyhow::Result<()> {
let history = read_history(
args.config.as_path(),
&args.record_id,
&lifecycle_read_options(args.daemon_bin.as_deref()),
)?;
if history.is_empty() {
anyhow::bail!("memory record not found: {}", args.record_id);
}
match args.format.unwrap_or(MemoryFormatValue::Markdown) {
MemoryFormatValue::Markdown => {
println!("{}", render_lifecycle_history(&args.record_id, &history))
}
MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&history)?),
}
Ok(())
}
fn lifecycle_read_options(daemon_bin: Option<&std::path::Path>) -> LifecycleReadOptions {
daemon_bin
.map(LifecycleReadOptions::with_daemon)
.unwrap_or_default()
}
fn execute_memory_record_manual(args: MemoryRecordArgs) -> anyhow::Result<()> {
let service = LifecycleService::new();
let config_path = args.config.clone();
let result = service.record_manual(config_path.as_path(), to_record_request(args))?;
crate::vault_writer::writeback_from_config(config_path.as_path(), &result.entry);
try_embedding_auto_append(&config_path, &result.entry);
println!("{}", render_create_result("record_manual", &result.entry));
Ok(())
}
fn execute_memory_propose(args: MemoryRecordArgs) -> anyhow::Result<()> {
let service = LifecycleService::new();
let config_path = args.config.clone();
let result = service.propose_ai(config_path.as_path(), to_propose_request(args))?;
crate::vault_writer::writeback_from_config(config_path.as_path(), &result.entry);
println!("{}", render_create_result("propose", &result.entry));
Ok(())
}
fn execute_memory_action(args: MemoryActionArgs, action: LifecycleAction) -> anyhow::Result<()> {
let service = LifecycleService::new();
let result = service.apply_action_with_metadata(
args.config.as_path(),
&args.record_id,
action,
transition_metadata(
args.actor.clone(),
args.reason.clone(),
args.evidence_refs.clone(),
),
)?;
crate::vault_writer::writeback_from_config(args.config.as_path(), &result.entry);
try_embedding_auto_append(&args.config, &result.entry);
println!("{}", render_action_result(action, &result.entry));
Ok(())
}
fn try_embedding_auto_append(config_path: &Path, entry: &LedgerEntry) {
#[cfg(feature = "embedding")]
{
if !matches!(
entry.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
) {
return;
}
if let Ok(config) = crate::config::load_from_path(config_path) {
crate::engine::embedding::try_append_record(
&config.embedding,
&entry.record_id,
&entry.record,
);
}
}
#[cfg(not(feature = "embedding"))]
{
let _ = (config_path, entry);
}
}
fn app_format(config: &crate::config::AppConfig) -> OutputFormat {
crate::app::resolve_format(config, None)
}
fn to_route_input(args: CommonRouteArgs, format: OutputFormat) -> RouteInput {
RouteInput {
task: args.task,
cwd: args.cwd,
files: args.files,
target: args.target.into(),
format,
}
}
fn render_lifecycle_list(title: &str, entries: &[LedgerEntry]) -> String {
lifecycle_summary::render_queue_text(title, entries, true, true)
}
fn render_lifecycle_detail(entry: &LedgerEntry) -> String {
lifecycle_summary::render_record_text(entry, true, true)
}
fn render_action_result(action: LifecycleAction, entry: &LedgerEntry) -> String {
lifecycle_summary::render_action_text(action, entry)
}
fn render_lifecycle_history(record_id: &str, entries: &[LedgerEntry]) -> String {
lifecycle_summary::render_history_text(record_id, entries, true)
}
fn render_create_result(kind: &str, entry: &LedgerEntry) -> String {
lifecycle_summary::render_create_text(kind, entry)
}
fn to_record_request(args: MemoryRecordArgs) -> RecordMemoryRequest {
RecordMemoryRequest {
title: args.title,
summary: args.summary,
memory_type: args.memory_type,
scope: args.scope.into(),
source_ref: args.source_ref,
project_id: args.project_id,
user_id: args.user_id,
sensitivity: args.sensitivity,
metadata: transition_metadata(args.actor, args.reason, args.evidence_refs),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
}
}
fn to_propose_request(args: MemoryRecordArgs) -> ProposeMemoryRequest {
ProposeMemoryRequest {
title: args.title,
summary: args.summary,
memory_type: args.memory_type,
scope: args.scope.into(),
source_ref: args.source_ref,
project_id: args.project_id,
user_id: args.user_id,
sensitivity: args.sensitivity,
metadata: transition_metadata(args.actor, args.reason, args.evidence_refs),
entities: Vec::new(),
tags: Vec::new(),
triggers: Vec::new(),
related_files: Vec::new(),
related_records: Vec::new(),
supersedes: None,
applies_to: Vec::new(),
valid_until: None,
}
}
fn transition_metadata(
actor: Option<String>,
reason: Option<String>,
evidence_refs: Vec<String>,
) -> TransitionMetadata {
TransitionMetadata {
actor,
reason,
evidence_refs,
}
}
#[allow(dead_code)]
fn _config_path(path: &std::path::Path) -> &std::path::Path {
path
}
fn execute_memory_import(args: MemoryImportArgs) -> anyhow::Result<()> {
use crate::memory_importer::{ImportProvider, import_session};
let provider = ImportProvider::parse(args.provider.as_str())?;
let response = import_session(
args.config.as_path(),
provider,
&args.session_id,
args.apply,
Some(args.actor.clone()),
)?;
match args.format {
MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&response)?),
MemoryFormatValue::Markdown => {
println!("# Import preview · {}\n", response.session_ref);
println!("- total messages scanned: {}", response.total_messages);
println!("- candidates: {}", response.candidate_count);
println!("- apply: {}", response.applied);
if response.applied {
println!(
"- applied record ids: {}",
response.applied_record_ids.join(", ")
);
}
println!();
for (idx, c) in response.candidates.iter().enumerate() {
println!("## {}. [{}] {}", idx + 1, c.memory_type, c.title);
println!("- scope: {:?}", c.scope);
println!("- evidence: {}", c.evidence_refs.join(", "));
println!();
println!("{}", c.summary);
println!();
}
}
}
if response.applied {
eprintln!(
"applied {} AI proposals to ledger",
response.applied_record_ids.len()
);
}
Ok(())
}
#[derive(Debug, Default)]
struct VaultSyncStats {
created: usize,
updated_all: usize,
updated_preserve_body: usize,
unchanged: usize,
archived: usize,
would_create: usize,
would_update: usize,
would_archive: usize,
skipped_missing: usize,
skipped_draft_or_candidate: usize,
errors: Vec<(String, String)>,
}
impl VaultSyncStats {
fn record_write_status(&mut self, status: crate::vault_writer::WriteStatus) {
use crate::vault_writer::WriteStatus;
match status {
WriteStatus::Created => self.created += 1,
WriteStatus::UpdatedAll => self.updated_all += 1,
WriteStatus::UpdatedPreserveBody => self.updated_preserve_body += 1,
WriteStatus::Unchanged => self.unchanged += 1,
}
}
}
fn execute_memory_import_git(args: MemoryImportGitArgs) -> anyhow::Result<()> {
let repo_path = args
.repo
.unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
let report = crate::git_importer::import_git_activity(
&args.config,
&repo_path,
args.limit,
args.dry_run,
)?;
println!(
"# Git Import{}",
if args.dry_run { " (dry-run)" } else { "" }
);
println!();
println!("- commits scanned: {}", report.commits_scanned);
println!("- candidates found: {}", report.candidates_found);
println!("- persisted: {}", report.candidates_persisted.len());
println!(
"- duplicates dropped: {}",
report.candidates_duplicate_dropped
);
if !report.candidates_persisted.is_empty() {
println!();
for id in &report.candidates_persisted {
println!(" - `{id}`");
}
}
Ok(())
}
fn execute_memory_dedup(args: MemoryDedupArgs) -> anyhow::Result<()> {
let config_dir = args.config.parent().unwrap_or_else(|| Path::new("."));
let lifecycle_root = lifecycle_root_from_config(config_dir);
let store = LifecycleStore::new(&lifecycle_root);
let entries = crate::lifecycle_store::wakeup_ready_entries(&store)?;
let records: Vec<(String, crate::domain::MemoryRecord)> = entries
.into_iter()
.map(|e| (e.record_id, e.record))
.collect();
let suggestions = crate::contradiction::find_duplicates(&records, 0.5);
if suggestions.is_empty() {
println!("# 去重检查");
println!();
println!("未发现相似记忆对(阈值 50%)。");
return Ok(());
}
println!("# 去重建议");
println!();
println!("发现 {} 对相似记忆:", suggestions.len());
println!();
for s in &suggestions {
println!(" {}% 相似:", s.similarity);
println!(" A: {} (`{}`)", s.title_a, s.record_id_a);
println!(" B: {} (`{}`)", s.title_b, s.record_id_b);
println!();
}
println!("使用 `spool memory archive --record-id <id>` 归档重复项。");
Ok(())
}
fn execute_memory_consolidate(args: MemoryConsolidateArgs) -> anyhow::Result<()> {
use crate::knowledge::cluster as consolidation;
let entries = consolidation::load_entries(args.config.as_path())?;
let suggestions = consolidation::detect_consolidation_candidates(&entries);
if suggestions.is_empty() {
println!("# 知识整合检查");
println!();
println!("未发现可合并的碎片记忆聚类(需要 3+ 条相关记录)。");
return Ok(());
}
if !args.apply {
println!("# 知识整合建议 (dry-run)");
println!();
println!("发现 {} 个可合并聚类:", suggestions.len());
println!();
for (idx, s) in suggestions.iter().enumerate() {
println!("## 聚类 {}", idx + 1);
println!(" 建议标题: {}", s.suggested_title);
println!(" 共同 entities: {}", s.shared_entities.join(", "));
println!(" 共同 tags: {}", s.shared_tags.join(", "));
println!(" 包含记录 ({}):", s.cluster_records.len());
for rid in &s.cluster_records {
let title = entries
.iter()
.find(|e| &e.record_id == rid)
.map(|e| e.record.title.as_str())
.unwrap_or("?");
println!(" - `{rid}` ({title})");
}
println!();
}
println!("使用 `--apply` 执行合并。");
return Ok(());
}
println!("# 知识整合执行");
println!();
for (idx, s) in suggestions.iter().enumerate() {
let result = consolidation::apply_consolidation(args.config.as_path(), s, &entries)?;
println!("## 聚类 {} → 合并为 `{}`", idx + 1, result.merged_record_id);
println!(" 归档了 {} 条碎片记录", result.archived_record_ids.len());
println!();
}
Ok(())
}
fn execute_memory_prune(args: MemoryPruneArgs) -> anyhow::Result<()> {
use crate::knowledge::cluster as consolidation;
let entries = consolidation::load_entries(args.config.as_path())?;
let lifecycle_root = consolidation::resolve_lifecycle_root(args.config.as_path());
let suggestions = consolidation::detect_prune_candidates(&entries, &lifecycle_root);
if suggestions.is_empty() {
println!("# 过时检测");
println!();
println!("未发现需要归档的过时记录。");
return Ok(());
}
if !args.apply {
println!("# 过时记录建议 (dry-run)");
println!();
println!("发现 {} 条待归档记录:", suggestions.len());
println!();
for s in &suggestions {
let reason_text = match &s.reason {
consolidation::PruneReason::Superseded { by } => {
format!("被 `{by}` 替代")
}
consolidation::PruneReason::Expired { valid_until } => {
format!("已过期 (valid_until: {valid_until})")
}
consolidation::PruneReason::Stale {
days_since_reference,
} => {
format!("长期未引用 ({days_since_reference} 天)")
}
};
println!(" - `{}` ({}) — {}", s.record_id, s.title, reason_text);
}
println!();
println!("使用 `--apply` 执行归档。");
return Ok(());
}
println!("# 过时记录归档");
println!();
let result = consolidation::apply_prune(args.config.as_path(), &suggestions)?;
println!("已归档 {} 条记录:", result.archived_record_ids.len());
for id in &result.archived_record_ids {
println!(" - `{id}`");
}
Ok(())
}
fn execute_memory_lint(args: MemoryLintArgs) -> anyhow::Result<()> {
let report = crate::wiki_lint::run_lint_from_config(args.config.as_path())?;
if args.json {
println!("{}", serde_json::to_string_pretty(&report)?);
} else {
println!("{}", crate::wiki_lint::render_lint_markdown(&report));
}
Ok(())
}
fn execute_memory_sync_index(args: MemorySyncIndexArgs) -> anyhow::Result<()> {
if !args.apply {
let preview = crate::wiki_index::render_index_from_config(args.config.as_path())?;
println!("# sync-index (dry-run)\n");
println!("{preview}");
println!("Re-run with `--apply` to write the file.");
return Ok(());
}
let result = crate::wiki_index::refresh_index_result(args.config.as_path())?;
println!("# sync-index");
println!();
println!("- path: {}", result.path.display());
println!("- status: {:?}", result.status);
println!("- user_entries: {}", result.user_entries);
println!("- project_entries: {}", result.project_entries);
Ok(())
}
fn execute_memory_stats(args: MemoryStatsArgs) -> anyhow::Result<()> {
let config_dir = args
.config
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let lifecycle_root = lifecycle_root_from_config(config_dir.as_path());
let store = LifecycleStore::new(lifecycle_root.as_path());
let entries = latest_state_entries(&store)?;
let mut by_state: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
let mut by_type: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
for entry in &entries {
*by_state
.entry(crate::lifecycle_format::state_label(entry))
.or_default() += 1;
*by_type
.entry(entry.record.memory_type.as_str())
.or_default() += 1;
}
println!("# memory stats");
println!();
println!("total: {}", entries.len());
println!();
println!("## by state");
let mut states: Vec<_> = by_state.iter().collect();
states.sort_by(|a, b| b.1.cmp(a.1));
for (state, count) in states {
println!(" {state}: {count}");
}
println!();
println!("## by type");
let mut types: Vec<_> = by_type.iter().collect();
types.sort_by(|a, b| b.1.cmp(a.1));
for (memory_type, count) in types {
println!(" {memory_type}: {count}");
}
Ok(())
}
fn execute_memory_sync_vault(args: MemorySyncVaultArgs) -> anyhow::Result<()> {
let config = crate::app::load(args.config.as_path())?;
let vault_root = crate::app::resolve_override_path(&config.vault.root, args.config.as_path())?;
let config_dir = args
.config
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from("."));
let lifecycle_root = lifecycle_root_from_config(config_dir.as_path());
let store = LifecycleStore::new(lifecycle_root.as_path());
let entries = latest_state_entries(&store)?;
if args.enrich {
return execute_enrich_pass(&entries, vault_root.as_path(), args.dry_run);
}
let mut stats = VaultSyncStats::default();
for entry in &entries {
match entry.record.state {
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical => {
if args.dry_run {
let path =
vault_writer::memory_note_path(vault_root.as_path(), &entry.record_id);
if path.exists() {
stats.would_update += 1;
} else {
stats.would_create += 1;
}
continue;
}
match vault_writer::write_memory_note(
vault_root.as_path(),
&entry.record_id,
&entry.record,
) {
Ok(result) => stats.record_write_status(result.status),
Err(error) => stats
.errors
.push((entry.record_id.clone(), error.to_string())),
}
}
MemoryLifecycleState::Archived => {
if args.dry_run {
let path =
vault_writer::memory_note_path(vault_root.as_path(), &entry.record_id);
if path.exists() {
stats.would_archive += 1;
} else {
stats.skipped_missing += 1;
}
continue;
}
match vault_writer::archive_memory_note(vault_root.as_path(), &entry.record_id) {
Ok(Some(result)) => match result.status {
crate::vault_writer::WriteStatus::Unchanged => stats.unchanged += 1,
_ => stats.archived += 1,
},
Ok(None) => stats.skipped_missing += 1,
Err(error) => stats
.errors
.push((entry.record_id.clone(), error.to_string())),
}
}
MemoryLifecycleState::Draft | MemoryLifecycleState::Candidate => {
stats.skipped_draft_or_candidate += 1;
}
}
}
println!(
"{}",
render_vault_sync_summary(&stats, entries.len(), args.dry_run, vault_root.as_path())
);
Ok(())
}
fn execute_enrich_pass(
entries: &[LedgerEntry],
vault_root: &Path,
dry_run: bool,
) -> anyhow::Result<()> {
use crate::enrich;
let mut enriched_count = 0_usize;
let mut skipped_count = 0_usize;
for entry in entries {
if !matches!(
entry.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
) {
continue;
}
let patch = enrich::enrich_record(&entry.record);
if patch.is_empty() {
skipped_count += 1;
continue;
}
if dry_run {
println!(
"would enrich `{}` ({}):",
entry.record_id, entry.record.title
);
if !patch.entities.is_empty() {
println!(" entities: {}", patch.entities.join(", "));
}
if !patch.tags.is_empty() {
println!(" tags: {}", patch.tags.join(", "));
}
if !patch.triggers.is_empty() {
println!(" triggers: {}", patch.triggers.join(", "));
}
println!();
enriched_count += 1;
continue;
}
let mut enriched_record = entry.record.clone();
if enriched_record.entities.is_empty() {
enriched_record.entities = patch.entities;
}
if enriched_record.tags.is_empty() {
enriched_record.tags = patch.tags;
}
if enriched_record.triggers.is_empty() {
enriched_record.triggers = patch.triggers;
}
match vault_writer::write_memory_note(vault_root, &entry.record_id, &enriched_record) {
Ok(_) => enriched_count += 1,
Err(error) => {
eprintln!(
"[spool] enrich writeback failed for {}: {error}",
entry.record_id
);
}
}
}
let mode = if dry_run { " (dry-run)" } else { "" };
println!("# enrich summary{mode}");
println!("enriched: {enriched_count}");
println!("skipped (already has fields): {skipped_count}");
Ok(())
}
fn render_vault_sync_summary(
stats: &VaultSyncStats,
total: usize,
dry_run: bool,
vault_root: &Path,
) -> String {
let mode = if dry_run { " (dry-run)" } else { "" };
let mut lines = Vec::new();
lines.push(format!("# vault sync summary{mode}"));
lines.push(format!("vault_root: {}", vault_root.display()));
lines.push(format!("ledger_records: {total}"));
if dry_run {
lines.push(format!("would_create: {}", stats.would_create));
lines.push(format!("would_update: {}", stats.would_update));
lines.push(format!("would_archive: {}", stats.would_archive));
} else {
lines.push(format!("created: {}", stats.created));
lines.push(format!("updated_all: {}", stats.updated_all));
lines.push(format!(
"updated_preserve_body: {}",
stats.updated_preserve_body
));
lines.push(format!("unchanged: {}", stats.unchanged));
lines.push(format!("archived: {}", stats.archived));
}
lines.push(format!(
"skipped_draft_or_candidate: {}",
stats.skipped_draft_or_candidate
));
lines.push(format!(
"skipped_missing_archive_target: {}",
stats.skipped_missing
));
if !stats.errors.is_empty() {
lines.push(format!("errors: {}", stats.errors.len()));
for (record_id, msg) in &stats.errors {
lines.push(format!(" - {record_id}: {msg}"));
}
}
lines.join("\n")
}
fn execute_init(args: InitArgs) -> anyhow::Result<()> {
let home = crate::support::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let config_dir = home.join(".spool");
let config_path = config_dir.join("config.toml");
if config_path.exists() {
println!("配置文件已存在: {}", config_path.display());
println!("如需重新初始化,请先删除该文件。");
return Ok(());
}
std::fs::create_dir_all(&config_dir)?;
let vault = args
.vault
.map(|p| p.display().to_string())
.unwrap_or_default();
let repo = args
.repo
.or_else(|| std::env::current_dir().ok())
.map(|p| p.display().to_string())
.unwrap_or_default();
let project_id = args.project_id.unwrap_or_else(|| {
std::path::Path::new(&repo)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("my-project")
.to_string()
});
let config = format!(
r#"[vault]
root = "{vault}"
[output]
default_format = "prompt"
max_chars = 12000
max_notes = 8
max_lifecycle = 5
[[projects]]
id = "{project_id}"
name = "{project_id}"
repo_paths = ["{repo}"]
note_roots = ["10-Projects", "20-Areas"]
"#
);
std::fs::write(&config_path, &config)?;
println!("# spool 初始化完成");
println!();
println!("配置文件: {}", config_path.display());
println!(
"知识库路径: {}",
if vault.is_empty() {
"(未设置,请编辑 config.toml)"
} else {
&vault
}
);
println!("项目: {project_id}");
println!("仓库: {repo}");
println!();
println!("下一步:");
println!(" 1. 编辑 {} 设置 vault.root", config_path.display());
println!(
" 2. spool mcp install --client claude --config {}",
config_path.display()
);
println!(" 3. 开始新的 AI 会话,记忆将自动注入");
Ok(())
}
fn execute_status(args: StatusArgs) -> anyhow::Result<()> {
let home = crate::support::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
let config_path = args
.config
.unwrap_or_else(|| home.join(".spool/config.toml"));
println!("# spool status");
println!();
let config_exists = config_path.exists();
println!(
" config: {} {}",
if config_exists { "✓" } else { "✗" },
config_path.display()
);
if config_exists {
match crate::app::load(&config_path) {
Ok(config) => {
let vault_exists = config.vault.root.exists();
println!(
" vault: {} {}",
if vault_exists { "✓" } else { "✗" },
config.vault.root.display()
);
}
Err(e) => println!(" vault: ✗ (config parse error: {e})"),
}
}
let lifecycle_root =
lifecycle_root_from_config(config_path.parent().unwrap_or_else(|| Path::new(".")));
let store = LifecycleStore::new(&lifecycle_root);
let entries = latest_state_entries(&store).unwrap_or_default();
let wakeup_ready = entries
.iter()
.filter(|e| {
matches!(
e.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
)
})
.count();
let pending = entries
.iter()
.filter(|e| e.record.state == MemoryLifecycleState::Candidate)
.count();
println!(
" memories: {} active, {} pending review, {} total",
wakeup_ready,
pending,
entries.len()
);
let tools = [
("claude", home.join(".claude")),
("codex", home.join(".codex")),
("cursor", home.join(".cursor")),
("opencode", home.join(".opencode")),
];
let mut injected: Vec<&str> = Vec::new();
for (name, dir) in &tools {
if dir.is_dir() {
let check_file = match *name {
"claude" => dir.join("settings.json"),
_ => dir.join("config.json"),
};
if std::fs::read_to_string(&check_file)
.map(|c| c.contains("spool"))
.unwrap_or(false)
{
injected.push(name);
}
}
}
if injected.is_empty() {
println!(" clients: (none injected)");
} else {
println!(" clients: {}", injected.join(", "));
}
let rules = crate::rules::load(&lifecycle_root);
let rule_count = rules.extraction.len() + rules.suppress.len();
if rule_count > 0 {
println!(
" rules: {} extraction, {} suppress",
rules.extraction.len(),
rules.suppress.len()
);
}
println!();
Ok(())
}
#[cfg(feature = "embedding")]
fn execute_embedding(args: crate::cli::args::EmbeddingArgs) -> anyhow::Result<()> {
use crate::cli::args::EmbeddingCommand;
match args.command {
EmbeddingCommand::Build(a) => execute_embedding_build(a),
EmbeddingCommand::Status(a) => execute_embedding_status(a),
}
}
#[cfg(feature = "embedding")]
fn execute_embedding_build(args: crate::cli::args::EmbeddingBuildArgs) -> anyhow::Result<()> {
use crate::engine::embedding::{EmbeddingIndex, resolve_model_variant};
let config = crate::config::load_from_path(&args.config)?;
if !config.embedding.enabled {
anyhow::bail!("embedding is disabled in config");
}
let config_dir = args.config.parent().unwrap_or_else(|| Path::new("."));
let lifecycle_root = lifecycle_root_from_config(config_dir);
let store = LifecycleStore::new(&lifecycle_root);
let entries = latest_state_entries(&store)?;
let wakeup_eligible: Vec<(String, &crate::domain::MemoryRecord)> = entries
.iter()
.filter(|e| {
matches!(
e.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
)
})
.map(|e| (e.record_id.clone(), &e.record))
.collect();
let model_id = config.embedding.model_id.as_deref();
let variant = resolve_model_variant(model_id);
println!(
"Building embedding index for {} records (model: {:?})...",
wakeup_eligible.len(),
model_id.unwrap_or("bge-small-zh-v1.5")
);
let model = fastembed::TextEmbedding::try_new(
fastembed::InitOptions::new(variant).with_show_download_progress(true),
)?;
let index = EmbeddingIndex::build_from_records_with_model(&wakeup_eligible, &model)?;
let index_path = config.embedding.resolved_index_path();
index.save(&index_path)?;
println!(
"Done. {} records indexed ({} dim), saved to {}",
index.len(),
index.dim(),
index_path.display()
);
Ok(())
}
#[cfg(feature = "embedding")]
fn execute_embedding_status(args: crate::cli::args::EmbeddingStatusArgs) -> anyhow::Result<()> {
use crate::engine::embedding::EmbeddingIndex;
let config = crate::config::load_from_path(&args.config)?;
let index_path = config.embedding.resolved_index_path();
println!("Embedding configuration:");
println!(" enabled: {}", config.embedding.enabled);
println!(
" model_id: {}",
config
.embedding
.model_id
.as_deref()
.unwrap_or("(default: bge-small-zh-v1.5)")
);
println!(" index_path: {}", index_path.display());
println!(" auto_index: {}", config.embedding.auto_index);
println!();
if index_path.exists() {
match EmbeddingIndex::load(&index_path) {
Ok(index) => {
let meta = std::fs::metadata(&index_path)?;
println!("Index status: BUILT");
println!(" records: {}", index.len());
println!(" dimensions: {}", index.dim());
println!(" file size: {:.1} KB", meta.len() as f64 / 1024.0);
let config_dir = args.config.parent().unwrap_or_else(|| Path::new("."));
let lifecycle_root = lifecycle_root_from_config(config_dir);
let store = LifecycleStore::new(&lifecycle_root);
if let Ok(entries) = latest_state_entries(&store) {
let eligible = entries
.iter()
.filter(|e| {
matches!(
e.record.state,
MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
)
})
.count();
let coverage = if eligible > 0 {
(index.len() as f64 / eligible as f64 * 100.0).min(100.0)
} else {
100.0
};
println!(
" coverage: {}/{} ({:.0}%)",
index.len(),
eligible,
coverage
);
if coverage < 90.0 {
println!();
println!(
" Hint: coverage below 90%. Run `spool embedding build` to rebuild."
);
}
}
}
Err(e) => {
println!("Index status: CORRUPT ({})", e);
}
}
} else {
println!("Index status: NOT BUILT");
println!(" Run `spool embedding build --config ...` to create the index.");
}
Ok(())
}
fn execute_knowledge(args: crate::cli::args::KnowledgeArgs) -> anyhow::Result<()> {
use crate::cli::args::KnowledgeCommand;
match args.command {
KnowledgeCommand::Distill(a) => execute_knowledge_distill(a),
}
}
fn execute_knowledge_distill(args: crate::cli::args::KnowledgeDistillArgs) -> anyhow::Result<()> {
let drafts = crate::knowledge::detect_knowledge_clusters(&args.config)?;
if drafts.is_empty() {
println!("No knowledge clusters detected (need 3+ related fragments).");
return Ok(());
}
println!("# Knowledge distillation\n");
println!("Detected {} knowledge page draft(s):\n", drafts.len());
for (i, draft) in drafts.iter().enumerate() {
println!("## Draft {} — {}\n", i + 1, draft.title);
println!("- domain: {}", draft.domain);
println!("- tags: {}", draft.tags.join(", "));
println!("- sources: {} fragments", draft.source_record_ids.len());
if !draft.related_notes.is_empty() {
println!("- related: {}", draft.related_notes.join(", "));
}
println!("\n### Preview:\n");
for line in draft.summary.lines().take(20) {
println!(" {}", line);
}
if draft.summary.lines().count() > 20 {
println!(" ...(truncated)");
}
println!();
}
if args.apply {
let ids = crate::knowledge::apply_distill(&args.config, &drafts, &args.actor)?;
println!("Created {} knowledge page candidate(s):", ids.len());
for id in &ids {
println!(" - {}", id);
}
println!("\nUse `spool memory accept --record-id <id>` to promote to accepted.");
} else if !args.dry_run {
println!("Add --apply to create knowledge page candidates in the ledger.");
}
Ok(())
}