Skip to main content

spool/cli/
commands.rs

1use crate::cli::args::{
2    Cli, Command, CommonRouteArgs, ExplainArgs, GetArgs, HookArgs, InitArgs, McpArgs, McpCommand,
3    MemoryActionArgs, MemoryArgs, MemoryCommand, MemoryConsolidateArgs, MemoryDedupArgs,
4    MemoryFormatValue, MemoryImportArgs, MemoryImportGitArgs, MemoryLintArgs, MemoryListArgs,
5    MemoryListViewValue, MemoryPruneArgs, MemoryRecordArgs, MemoryShowArgs, MemoryStatsArgs,
6    MemorySyncIndexArgs, MemorySyncVaultArgs, StatusArgs, WakeupArgs,
7};
8use crate::cli::{hook, mcp_install};
9use crate::daemon::{LifecycleReadOptions, read_history, read_record, read_workbench};
10use crate::domain::{MemoryLifecycleState, OutputFormat, RouteInput};
11use crate::lifecycle_service::{LifecycleAction, LifecycleService};
12use crate::lifecycle_store::{
13    LedgerEntry, LifecycleStore, ProposeMemoryRequest, RecordMemoryRequest, TransitionMetadata,
14    latest_state_entries, lifecycle_root_from_config,
15};
16use crate::lifecycle_summary;
17use crate::memory_gateway::{self, context_request, wakeup_request};
18use crate::output;
19use crate::vault_writer;
20use clap::Parser;
21use std::path::{Path, PathBuf};
22
23pub fn run() -> anyhow::Result<()> {
24    let cli = Cli::parse();
25    match cli.command {
26        Command::Get(args) => execute_get(args),
27        Command::Explain(args) => execute_explain(args),
28        Command::Wakeup(args) => execute_wakeup(args),
29        Command::Memory(args) => execute_memory(args),
30        Command::Mcp(args) => execute_mcp(args),
31        Command::Hook(args) => execute_hook(args),
32        Command::Init(args) => execute_init(args),
33        Command::Status(args) => execute_status(args),
34        #[cfg(feature = "embedding")]
35        Command::Embedding(args) => execute_embedding(args),
36        Command::Knowledge(args) => execute_knowledge(args),
37    }
38}
39
40fn execute_hook(args: HookArgs) -> anyhow::Result<()> {
41    hook::execute(args)
42}
43
44fn execute_mcp(args: McpArgs) -> anyhow::Result<()> {
45    match args.command {
46        McpCommand::Install(a) => mcp_install::execute_install(a),
47        McpCommand::Update(a) => mcp_install::execute_update(a),
48        McpCommand::Uninstall(a) => mcp_install::execute_uninstall(a),
49        McpCommand::Doctor(a) => mcp_install::execute_doctor(a),
50    }
51}
52
53fn execute_get(args: GetArgs) -> anyhow::Result<()> {
54    let config_path = args.common.config.clone();
55    let config = memory_gateway::load_config(&config_path)?;
56    let requested_format = args.format.map(Into::into);
57    let format = requested_format.unwrap_or_else(|| app_format(&config));
58    let input = to_route_input(args.common, format);
59    let response = memory_gateway::execute(&config_path, context_request(input), None)?;
60    println!(
61        "{}",
62        output::render(&response.bundle, config.output.max_chars, format)
63    );
64    Ok(())
65}
66
67fn execute_explain(args: ExplainArgs) -> anyhow::Result<()> {
68    let config_path = args.common.config.clone();
69    let input = to_route_input(args.common, OutputFormat::Markdown);
70    let response = memory_gateway::execute(&config_path, context_request(input), None)?;
71    println!("{}", output::explain(&response.bundle));
72    Ok(())
73}
74
75fn execute_wakeup(args: WakeupArgs) -> anyhow::Result<()> {
76    let config_path = args.common.config.clone();
77    let format = args.format.map(Into::into).unwrap_or(OutputFormat::Json);
78    let input = to_route_input(args.common, format);
79    let response = memory_gateway::execute(
80        &config_path,
81        wakeup_request(input, args.profile.into()),
82        None,
83    )?;
84    println!(
85        "{}",
86        output::wakeup::render(response.wakeup_packet().unwrap(), format)
87    );
88    Ok(())
89}
90
91fn execute_memory(args: MemoryArgs) -> anyhow::Result<()> {
92    match args.command {
93        MemoryCommand::List(args) => execute_memory_list(args),
94        MemoryCommand::Show(args) => execute_memory_show(args),
95        MemoryCommand::History(args) => execute_memory_history(args),
96        MemoryCommand::RecordManual(args) => execute_memory_record_manual(args),
97        MemoryCommand::Propose(args) => execute_memory_propose(args),
98        MemoryCommand::Accept(args) => execute_memory_action(args, LifecycleAction::Accept),
99        MemoryCommand::Promote(args) => {
100            execute_memory_action(args, LifecycleAction::PromoteToCanonical)
101        }
102        MemoryCommand::Archive(args) => execute_memory_action(args, LifecycleAction::Archive),
103        MemoryCommand::Import(args) => execute_memory_import(args),
104        MemoryCommand::ImportGit(args) => execute_memory_import_git(args),
105        MemoryCommand::SyncVault(args) => execute_memory_sync_vault(args),
106        MemoryCommand::Dedup(args) => execute_memory_dedup(args),
107        MemoryCommand::Consolidate(args) => execute_memory_consolidate(args),
108        MemoryCommand::Prune(args) => execute_memory_prune(args),
109        MemoryCommand::Lint(args) => execute_memory_lint(args),
110        MemoryCommand::SyncIndex(args) => execute_memory_sync_index(args),
111        MemoryCommand::Stats(args) => execute_memory_stats(args),
112    }
113}
114
115fn execute_memory_list(args: MemoryListArgs) -> anyhow::Result<()> {
116    let snapshot = read_workbench(
117        args.config.as_path(),
118        &lifecycle_read_options(args.daemon_bin.as_deref()),
119    )?;
120    let entries = match args.view {
121        MemoryListViewValue::PendingReview => snapshot.pending_review,
122        MemoryListViewValue::WakeupReady => snapshot.wakeup_ready,
123    };
124    let heading = match args.view {
125        MemoryListViewValue::PendingReview => "Pending review",
126        MemoryListViewValue::WakeupReady => "Wakeup-ready",
127    };
128
129    match args.format.unwrap_or(MemoryFormatValue::Markdown) {
130        MemoryFormatValue::Markdown => println!("{}", render_lifecycle_list(heading, &entries)),
131        MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&entries)?),
132    }
133    Ok(())
134}
135
136fn execute_memory_show(args: MemoryShowArgs) -> anyhow::Result<()> {
137    let entry = read_record(
138        args.config.as_path(),
139        &args.record_id,
140        &lifecycle_read_options(args.daemon_bin.as_deref()),
141    )?
142    .ok_or_else(|| anyhow::anyhow!("memory record not found: {}", args.record_id))?;
143
144    match args.format.unwrap_or(MemoryFormatValue::Markdown) {
145        MemoryFormatValue::Markdown => println!("{}", render_lifecycle_detail(&entry)),
146        MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&entry)?),
147    }
148    Ok(())
149}
150
151fn execute_memory_history(args: MemoryShowArgs) -> anyhow::Result<()> {
152    let history = read_history(
153        args.config.as_path(),
154        &args.record_id,
155        &lifecycle_read_options(args.daemon_bin.as_deref()),
156    )?;
157
158    // Every record has at least one create event (record_manual /
159    // propose_ai), so an empty history list is equivalent to "record
160    // doesn't exist". Aligning behavior with `memory show` and the
161    // MCP `memory_history` tool keeps the three surfaces consistent.
162    if history.is_empty() {
163        anyhow::bail!("memory record not found: {}", args.record_id);
164    }
165
166    match args.format.unwrap_or(MemoryFormatValue::Markdown) {
167        MemoryFormatValue::Markdown => {
168            println!("{}", render_lifecycle_history(&args.record_id, &history))
169        }
170        MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&history)?),
171    }
172    Ok(())
173}
174
175fn lifecycle_read_options(daemon_bin: Option<&std::path::Path>) -> LifecycleReadOptions {
176    daemon_bin
177        .map(LifecycleReadOptions::with_daemon)
178        .unwrap_or_default()
179}
180
181fn execute_memory_record_manual(args: MemoryRecordArgs) -> anyhow::Result<()> {
182    let service = LifecycleService::new();
183    let config_path = args.config.clone();
184    let result = service.record_manual(config_path.as_path(), to_record_request(args))?;
185    crate::vault_writer::writeback_from_config(config_path.as_path(), &result.entry);
186    try_embedding_auto_append(&config_path, &result.entry);
187    println!("{}", render_create_result("record_manual", &result.entry));
188    Ok(())
189}
190
191fn execute_memory_propose(args: MemoryRecordArgs) -> anyhow::Result<()> {
192    let service = LifecycleService::new();
193    let config_path = args.config.clone();
194    let result = service.propose_ai(config_path.as_path(), to_propose_request(args))?;
195    crate::vault_writer::writeback_from_config(config_path.as_path(), &result.entry);
196    println!("{}", render_create_result("propose", &result.entry));
197    Ok(())
198}
199
200fn execute_memory_action(args: MemoryActionArgs, action: LifecycleAction) -> anyhow::Result<()> {
201    let service = LifecycleService::new();
202    let result = service.apply_action_with_metadata(
203        args.config.as_path(),
204        &args.record_id,
205        action,
206        transition_metadata(
207            args.actor.clone(),
208            args.reason.clone(),
209            args.evidence_refs.clone(),
210        ),
211    )?;
212    crate::vault_writer::writeback_from_config(args.config.as_path(), &result.entry);
213    try_embedding_auto_append(&args.config, &result.entry);
214    println!("{}", render_action_result(action, &result.entry));
215    Ok(())
216}
217
218fn try_embedding_auto_append(config_path: &Path, entry: &LedgerEntry) {
219    #[cfg(feature = "embedding")]
220    {
221        if !matches!(
222            entry.record.state,
223            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
224        ) {
225            return;
226        }
227        if let Ok(config) = crate::config::load_from_path(config_path) {
228            crate::engine::embedding::try_append_record(
229                &config.embedding,
230                &entry.record_id,
231                &entry.record,
232            );
233        }
234    }
235    #[cfg(not(feature = "embedding"))]
236    {
237        let _ = (config_path, entry);
238    }
239}
240
241fn app_format(config: &crate::config::AppConfig) -> OutputFormat {
242    crate::app::resolve_format(config, None)
243}
244
245fn to_route_input(args: CommonRouteArgs, format: OutputFormat) -> RouteInput {
246    RouteInput {
247        task: args.task,
248        cwd: args.cwd,
249        files: args.files,
250        target: args.target.into(),
251        format,
252    }
253}
254
255fn render_lifecycle_list(title: &str, entries: &[LedgerEntry]) -> String {
256    lifecycle_summary::render_queue_text(title, entries, true, true)
257}
258
259fn render_lifecycle_detail(entry: &LedgerEntry) -> String {
260    lifecycle_summary::render_record_text(entry, true, true)
261}
262
263fn render_action_result(action: LifecycleAction, entry: &LedgerEntry) -> String {
264    lifecycle_summary::render_action_text(action, entry)
265}
266
267fn render_lifecycle_history(record_id: &str, entries: &[LedgerEntry]) -> String {
268    lifecycle_summary::render_history_text(record_id, entries, true)
269}
270
271fn render_create_result(kind: &str, entry: &LedgerEntry) -> String {
272    lifecycle_summary::render_create_text(kind, entry)
273}
274
275fn to_record_request(args: MemoryRecordArgs) -> RecordMemoryRequest {
276    RecordMemoryRequest {
277        title: args.title,
278        summary: args.summary,
279        memory_type: args.memory_type,
280        scope: args.scope.into(),
281        source_ref: args.source_ref,
282        project_id: args.project_id,
283        user_id: args.user_id,
284        sensitivity: args.sensitivity,
285        metadata: transition_metadata(args.actor, args.reason, args.evidence_refs),
286        entities: Vec::new(),
287        tags: Vec::new(),
288        triggers: Vec::new(),
289        related_files: Vec::new(),
290        related_records: Vec::new(),
291        supersedes: None,
292        applies_to: Vec::new(),
293        valid_until: None,
294    }
295}
296
297fn to_propose_request(args: MemoryRecordArgs) -> ProposeMemoryRequest {
298    ProposeMemoryRequest {
299        title: args.title,
300        summary: args.summary,
301        memory_type: args.memory_type,
302        scope: args.scope.into(),
303        source_ref: args.source_ref,
304        project_id: args.project_id,
305        user_id: args.user_id,
306        sensitivity: args.sensitivity,
307        metadata: transition_metadata(args.actor, args.reason, args.evidence_refs),
308        entities: Vec::new(),
309        tags: Vec::new(),
310        triggers: Vec::new(),
311        related_files: Vec::new(),
312        related_records: Vec::new(),
313        supersedes: None,
314        applies_to: Vec::new(),
315        valid_until: None,
316    }
317}
318
319fn transition_metadata(
320    actor: Option<String>,
321    reason: Option<String>,
322    evidence_refs: Vec<String>,
323) -> TransitionMetadata {
324    TransitionMetadata {
325        actor,
326        reason,
327        evidence_refs,
328    }
329}
330
331#[allow(dead_code)]
332fn _config_path(path: &std::path::Path) -> &std::path::Path {
333    path
334}
335
336fn execute_memory_import(args: MemoryImportArgs) -> anyhow::Result<()> {
337    use crate::memory_importer::{ImportProvider, import_session};
338
339    let provider = ImportProvider::parse(args.provider.as_str())?;
340    let response = import_session(
341        args.config.as_path(),
342        provider,
343        &args.session_id,
344        args.apply,
345        Some(args.actor.clone()),
346    )?;
347
348    match args.format {
349        MemoryFormatValue::Json => println!("{}", serde_json::to_string_pretty(&response)?),
350        MemoryFormatValue::Markdown => {
351            println!("# Import preview · {}\n", response.session_ref);
352            println!("- total messages scanned: {}", response.total_messages);
353            println!("- candidates: {}", response.candidate_count);
354            println!("- apply: {}", response.applied);
355            if response.applied {
356                println!(
357                    "- applied record ids: {}",
358                    response.applied_record_ids.join(", ")
359                );
360            }
361            println!();
362            for (idx, c) in response.candidates.iter().enumerate() {
363                println!("## {}. [{}] {}", idx + 1, c.memory_type, c.title);
364                println!("- scope: {:?}", c.scope);
365                println!("- evidence: {}", c.evidence_refs.join(", "));
366                println!();
367                println!("{}", c.summary);
368                println!();
369            }
370        }
371    }
372
373    if response.applied {
374        eprintln!(
375            "applied {} AI proposals to ledger",
376            response.applied_record_ids.len()
377        );
378    }
379
380    Ok(())
381}
382
383#[derive(Debug, Default)]
384struct VaultSyncStats {
385    created: usize,
386    updated_all: usize,
387    updated_preserve_body: usize,
388    unchanged: usize,
389    archived: usize,
390    would_create: usize,
391    would_update: usize,
392    would_archive: usize,
393    skipped_missing: usize,
394    skipped_draft_or_candidate: usize,
395    errors: Vec<(String, String)>,
396}
397
398impl VaultSyncStats {
399    fn record_write_status(&mut self, status: crate::vault_writer::WriteStatus) {
400        use crate::vault_writer::WriteStatus;
401        match status {
402            WriteStatus::Created => self.created += 1,
403            WriteStatus::UpdatedAll => self.updated_all += 1,
404            WriteStatus::UpdatedPreserveBody => self.updated_preserve_body += 1,
405            WriteStatus::Unchanged => self.unchanged += 1,
406        }
407    }
408}
409
410fn execute_memory_import_git(args: MemoryImportGitArgs) -> anyhow::Result<()> {
411    let repo_path = args
412        .repo
413        .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
414    let report = crate::git_importer::import_git_activity(
415        &args.config,
416        &repo_path,
417        args.limit,
418        args.dry_run,
419    )?;
420
421    println!(
422        "# Git Import{}",
423        if args.dry_run { " (dry-run)" } else { "" }
424    );
425    println!();
426    println!("- commits scanned: {}", report.commits_scanned);
427    println!("- candidates found: {}", report.candidates_found);
428    println!("- persisted: {}", report.candidates_persisted.len());
429    println!(
430        "- duplicates dropped: {}",
431        report.candidates_duplicate_dropped
432    );
433    if !report.candidates_persisted.is_empty() {
434        println!();
435        for id in &report.candidates_persisted {
436            println!("  - `{id}`");
437        }
438    }
439    Ok(())
440}
441
442fn execute_memory_dedup(args: MemoryDedupArgs) -> anyhow::Result<()> {
443    let config_dir = args.config.parent().unwrap_or_else(|| Path::new("."));
444    let lifecycle_root = lifecycle_root_from_config(config_dir);
445    let store = LifecycleStore::new(&lifecycle_root);
446    let entries = crate::lifecycle_store::wakeup_ready_entries(&store)?;
447    let records: Vec<(String, crate::domain::MemoryRecord)> = entries
448        .into_iter()
449        .map(|e| (e.record_id, e.record))
450        .collect();
451
452    let suggestions = crate::contradiction::find_duplicates(&records, 0.5);
453
454    if suggestions.is_empty() {
455        println!("# 去重检查");
456        println!();
457        println!("未发现相似记忆对(阈值 50%)。");
458        return Ok(());
459    }
460
461    println!("# 去重建议");
462    println!();
463    println!("发现 {} 对相似记忆:", suggestions.len());
464    println!();
465    for s in &suggestions {
466        println!("  {}% 相似:", s.similarity);
467        println!("    A: {} (`{}`)", s.title_a, s.record_id_a);
468        println!("    B: {} (`{}`)", s.title_b, s.record_id_b);
469        println!();
470    }
471    println!("使用 `spool memory archive --record-id <id>` 归档重复项。");
472    Ok(())
473}
474
475fn execute_memory_consolidate(args: MemoryConsolidateArgs) -> anyhow::Result<()> {
476    use crate::knowledge::cluster as consolidation;
477
478    let entries = consolidation::load_entries(args.config.as_path())?;
479    let suggestions = consolidation::detect_consolidation_candidates(&entries);
480
481    if suggestions.is_empty() {
482        println!("# 知识整合检查");
483        println!();
484        println!("未发现可合并的碎片记忆聚类(需要 3+ 条相关记录)。");
485        return Ok(());
486    }
487
488    if !args.apply {
489        println!("# 知识整合建议 (dry-run)");
490        println!();
491        println!("发现 {} 个可合并聚类:", suggestions.len());
492        println!();
493        for (idx, s) in suggestions.iter().enumerate() {
494            println!("## 聚类 {}", idx + 1);
495            println!("  建议标题: {}", s.suggested_title);
496            println!("  共同 entities: {}", s.shared_entities.join(", "));
497            println!("  共同 tags: {}", s.shared_tags.join(", "));
498            println!("  包含记录 ({}):", s.cluster_records.len());
499            for rid in &s.cluster_records {
500                let title = entries
501                    .iter()
502                    .find(|e| &e.record_id == rid)
503                    .map(|e| e.record.title.as_str())
504                    .unwrap_or("?");
505                println!("    - `{rid}` ({title})");
506            }
507            println!();
508        }
509        println!("使用 `--apply` 执行合并。");
510        return Ok(());
511    }
512
513    println!("# 知识整合执行");
514    println!();
515    for (idx, s) in suggestions.iter().enumerate() {
516        let result = consolidation::apply_consolidation(args.config.as_path(), s, &entries)?;
517        println!("## 聚类 {} → 合并为 `{}`", idx + 1, result.merged_record_id);
518        println!("  归档了 {} 条碎片记录", result.archived_record_ids.len());
519        println!();
520    }
521    Ok(())
522}
523
524fn execute_memory_prune(args: MemoryPruneArgs) -> anyhow::Result<()> {
525    use crate::knowledge::cluster as consolidation;
526
527    let entries = consolidation::load_entries(args.config.as_path())?;
528    let lifecycle_root = consolidation::resolve_lifecycle_root(args.config.as_path());
529    let suggestions = consolidation::detect_prune_candidates(&entries, &lifecycle_root);
530
531    if suggestions.is_empty() {
532        println!("# 过时检测");
533        println!();
534        println!("未发现需要归档的过时记录。");
535        return Ok(());
536    }
537
538    if !args.apply {
539        println!("# 过时记录建议 (dry-run)");
540        println!();
541        println!("发现 {} 条待归档记录:", suggestions.len());
542        println!();
543        for s in &suggestions {
544            let reason_text = match &s.reason {
545                consolidation::PruneReason::Superseded { by } => {
546                    format!("被 `{by}` 替代")
547                }
548                consolidation::PruneReason::Expired { valid_until } => {
549                    format!("已过期 (valid_until: {valid_until})")
550                }
551                consolidation::PruneReason::Stale {
552                    days_since_reference,
553                } => {
554                    format!("长期未引用 ({days_since_reference} 天)")
555                }
556            };
557            println!("  - `{}` ({}) — {}", s.record_id, s.title, reason_text);
558        }
559        println!();
560        println!("使用 `--apply` 执行归档。");
561        return Ok(());
562    }
563
564    println!("# 过时记录归档");
565    println!();
566    let result = consolidation::apply_prune(args.config.as_path(), &suggestions)?;
567    println!("已归档 {} 条记录:", result.archived_record_ids.len());
568    for id in &result.archived_record_ids {
569        println!("  - `{id}`");
570    }
571    Ok(())
572}
573
574fn execute_memory_lint(args: MemoryLintArgs) -> anyhow::Result<()> {
575    let report = crate::wiki_lint::run_lint_from_config(args.config.as_path())?;
576    if args.json {
577        println!("{}", serde_json::to_string_pretty(&report)?);
578    } else {
579        println!("{}", crate::wiki_lint::render_lint_markdown(&report));
580    }
581    Ok(())
582}
583
584fn execute_memory_sync_index(args: MemorySyncIndexArgs) -> anyhow::Result<()> {
585    if !args.apply {
586        let preview = crate::wiki_index::render_index_from_config(args.config.as_path())?;
587        println!("# sync-index (dry-run)\n");
588        println!("{preview}");
589        println!("Re-run with `--apply` to write the file.");
590        return Ok(());
591    }
592    let result = crate::wiki_index::refresh_index_result(args.config.as_path())?;
593    println!("# sync-index");
594    println!();
595    println!("- path: {}", result.path.display());
596    println!("- status: {:?}", result.status);
597    println!("- user_entries: {}", result.user_entries);
598    println!("- project_entries: {}", result.project_entries);
599    Ok(())
600}
601
602fn execute_memory_stats(args: MemoryStatsArgs) -> anyhow::Result<()> {
603    let config_dir = args
604        .config
605        .parent()
606        .map(Path::to_path_buf)
607        .unwrap_or_else(|| PathBuf::from("."));
608    let lifecycle_root = lifecycle_root_from_config(config_dir.as_path());
609    let store = LifecycleStore::new(lifecycle_root.as_path());
610    let entries = latest_state_entries(&store)?;
611
612    let mut by_state: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
613    let mut by_type: std::collections::HashMap<&str, usize> = std::collections::HashMap::new();
614
615    for entry in &entries {
616        *by_state
617            .entry(crate::lifecycle_format::state_label(entry))
618            .or_default() += 1;
619        *by_type
620            .entry(entry.record.memory_type.as_str())
621            .or_default() += 1;
622    }
623
624    println!("# memory stats");
625    println!();
626    println!("total: {}", entries.len());
627    println!();
628    println!("## by state");
629    let mut states: Vec<_> = by_state.iter().collect();
630    states.sort_by(|a, b| b.1.cmp(a.1));
631    for (state, count) in states {
632        println!("  {state}: {count}");
633    }
634    println!();
635    println!("## by type");
636    let mut types: Vec<_> = by_type.iter().collect();
637    types.sort_by(|a, b| b.1.cmp(a.1));
638    for (memory_type, count) in types {
639        println!("  {memory_type}: {count}");
640    }
641    Ok(())
642}
643
644fn execute_memory_sync_vault(args: MemorySyncVaultArgs) -> anyhow::Result<()> {
645    let config = crate::app::load(args.config.as_path())?;
646    let vault_root = crate::app::resolve_override_path(&config.vault.root, args.config.as_path())?;
647    let config_dir = args
648        .config
649        .parent()
650        .map(Path::to_path_buf)
651        .unwrap_or_else(|| PathBuf::from("."));
652    let lifecycle_root = lifecycle_root_from_config(config_dir.as_path());
653    let store = LifecycleStore::new(lifecycle_root.as_path());
654    let entries = latest_state_entries(&store)?;
655
656    if args.enrich {
657        return execute_enrich_pass(&entries, vault_root.as_path(), args.dry_run);
658    }
659
660    let mut stats = VaultSyncStats::default();
661    for entry in &entries {
662        match entry.record.state {
663            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical => {
664                if args.dry_run {
665                    let path =
666                        vault_writer::memory_note_path(vault_root.as_path(), &entry.record_id);
667                    if path.exists() {
668                        stats.would_update += 1;
669                    } else {
670                        stats.would_create += 1;
671                    }
672                    continue;
673                }
674                match vault_writer::write_memory_note(
675                    vault_root.as_path(),
676                    &entry.record_id,
677                    &entry.record,
678                ) {
679                    Ok(result) => stats.record_write_status(result.status),
680                    Err(error) => stats
681                        .errors
682                        .push((entry.record_id.clone(), error.to_string())),
683                }
684            }
685            MemoryLifecycleState::Archived => {
686                if args.dry_run {
687                    let path =
688                        vault_writer::memory_note_path(vault_root.as_path(), &entry.record_id);
689                    if path.exists() {
690                        stats.would_archive += 1;
691                    } else {
692                        stats.skipped_missing += 1;
693                    }
694                    continue;
695                }
696                match vault_writer::archive_memory_note(vault_root.as_path(), &entry.record_id) {
697                    Ok(Some(result)) => match result.status {
698                        crate::vault_writer::WriteStatus::Unchanged => stats.unchanged += 1,
699                        _ => stats.archived += 1,
700                    },
701                    Ok(None) => stats.skipped_missing += 1,
702                    Err(error) => stats
703                        .errors
704                        .push((entry.record_id.clone(), error.to_string())),
705                }
706            }
707            MemoryLifecycleState::Draft | MemoryLifecycleState::Candidate => {
708                stats.skipped_draft_or_candidate += 1;
709            }
710        }
711    }
712
713    println!(
714        "{}",
715        render_vault_sync_summary(&stats, entries.len(), args.dry_run, vault_root.as_path())
716    );
717    Ok(())
718}
719
720fn execute_enrich_pass(
721    entries: &[LedgerEntry],
722    vault_root: &Path,
723    dry_run: bool,
724) -> anyhow::Result<()> {
725    use crate::enrich;
726
727    let mut enriched_count = 0_usize;
728    let mut skipped_count = 0_usize;
729
730    for entry in entries {
731        // Only enrich accepted/canonical records
732        if !matches!(
733            entry.record.state,
734            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
735        ) {
736            continue;
737        }
738
739        let patch = enrich::enrich_record(&entry.record);
740        if patch.is_empty() {
741            skipped_count += 1;
742            continue;
743        }
744
745        if dry_run {
746            println!(
747                "would enrich `{}` ({}):",
748                entry.record_id, entry.record.title
749            );
750            if !patch.entities.is_empty() {
751                println!("  entities: {}", patch.entities.join(", "));
752            }
753            if !patch.tags.is_empty() {
754                println!("  tags: {}", patch.tags.join(", "));
755            }
756            if !patch.triggers.is_empty() {
757                println!("  triggers: {}", patch.triggers.join(", "));
758            }
759            println!();
760            enriched_count += 1;
761            continue;
762        }
763
764        // Apply enrichment: update vault note frontmatter directly
765        let mut enriched_record = entry.record.clone();
766        if enriched_record.entities.is_empty() {
767            enriched_record.entities = patch.entities;
768        }
769        if enriched_record.tags.is_empty() {
770            enriched_record.tags = patch.tags;
771        }
772        if enriched_record.triggers.is_empty() {
773            enriched_record.triggers = patch.triggers;
774        }
775
776        match vault_writer::write_memory_note(vault_root, &entry.record_id, &enriched_record) {
777            Ok(_) => enriched_count += 1,
778            Err(error) => {
779                eprintln!(
780                    "[spool] enrich writeback failed for {}: {error}",
781                    entry.record_id
782                );
783            }
784        }
785    }
786
787    let mode = if dry_run { " (dry-run)" } else { "" };
788    println!("# enrich summary{mode}");
789    println!("enriched: {enriched_count}");
790    println!("skipped (already has fields): {skipped_count}");
791    Ok(())
792}
793
794fn render_vault_sync_summary(
795    stats: &VaultSyncStats,
796    total: usize,
797    dry_run: bool,
798    vault_root: &Path,
799) -> String {
800    let mode = if dry_run { " (dry-run)" } else { "" };
801    let mut lines = Vec::new();
802    lines.push(format!("# vault sync summary{mode}"));
803    lines.push(format!("vault_root: {}", vault_root.display()));
804    lines.push(format!("ledger_records: {total}"));
805    if dry_run {
806        lines.push(format!("would_create: {}", stats.would_create));
807        lines.push(format!("would_update: {}", stats.would_update));
808        lines.push(format!("would_archive: {}", stats.would_archive));
809    } else {
810        lines.push(format!("created: {}", stats.created));
811        lines.push(format!("updated_all: {}", stats.updated_all));
812        lines.push(format!(
813            "updated_preserve_body: {}",
814            stats.updated_preserve_body
815        ));
816        lines.push(format!("unchanged: {}", stats.unchanged));
817        lines.push(format!("archived: {}", stats.archived));
818    }
819    lines.push(format!(
820        "skipped_draft_or_candidate: {}",
821        stats.skipped_draft_or_candidate
822    ));
823    lines.push(format!(
824        "skipped_missing_archive_target: {}",
825        stats.skipped_missing
826    ));
827    if !stats.errors.is_empty() {
828        lines.push(format!("errors: {}", stats.errors.len()));
829        for (record_id, msg) in &stats.errors {
830            lines.push(format!("  - {record_id}: {msg}"));
831        }
832    }
833    lines.join("\n")
834}
835
836fn execute_init(args: InitArgs) -> anyhow::Result<()> {
837    let home = crate::support::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
838    let config_dir = home.join(".spool");
839    let config_path = config_dir.join("config.toml");
840
841    if config_path.exists() {
842        println!("配置文件已存在: {}", config_path.display());
843        println!("如需重新初始化,请先删除该文件。");
844        return Ok(());
845    }
846
847    std::fs::create_dir_all(&config_dir)?;
848
849    let vault = args
850        .vault
851        .map(|p| p.display().to_string())
852        .unwrap_or_default();
853    let repo = args
854        .repo
855        .or_else(|| std::env::current_dir().ok())
856        .map(|p| p.display().to_string())
857        .unwrap_or_default();
858    let project_id = args.project_id.unwrap_or_else(|| {
859        std::path::Path::new(&repo)
860            .file_name()
861            .and_then(|n| n.to_str())
862            .unwrap_or("my-project")
863            .to_string()
864    });
865
866    let config = format!(
867        r#"[vault]
868root = "{vault}"
869
870[output]
871default_format = "prompt"
872max_chars = 12000
873max_notes = 8
874max_lifecycle = 5
875
876[[projects]]
877id = "{project_id}"
878name = "{project_id}"
879repo_paths = ["{repo}"]
880note_roots = ["10-Projects", "20-Areas"]
881"#
882    );
883
884    std::fs::write(&config_path, &config)?;
885
886    println!("# spool 初始化完成");
887    println!();
888    println!("配置文件: {}", config_path.display());
889    println!(
890        "知识库路径: {}",
891        if vault.is_empty() {
892            "(未设置,请编辑 config.toml)"
893        } else {
894            &vault
895        }
896    );
897    println!("项目: {project_id}");
898    println!("仓库: {repo}");
899    println!();
900    println!("下一步:");
901    println!("  1. 编辑 {} 设置 vault.root", config_path.display());
902    println!(
903        "  2. spool mcp install --client claude --config {}",
904        config_path.display()
905    );
906    println!("  3. 开始新的 AI 会话,记忆将自动注入");
907
908    Ok(())
909}
910
911fn execute_status(args: StatusArgs) -> anyhow::Result<()> {
912    let home = crate::support::home_dir().unwrap_or_else(|| std::path::PathBuf::from("/tmp"));
913    let config_path = args
914        .config
915        .unwrap_or_else(|| home.join(".spool/config.toml"));
916
917    println!("# spool status");
918    println!();
919
920    // Config
921    let config_exists = config_path.exists();
922    println!(
923        "  config: {} {}",
924        if config_exists { "✓" } else { "✗" },
925        config_path.display()
926    );
927
928    // Vault
929    if config_exists {
930        match crate::app::load(&config_path) {
931            Ok(config) => {
932                let vault_exists = config.vault.root.exists();
933                println!(
934                    "  vault:  {} {}",
935                    if vault_exists { "✓" } else { "✗" },
936                    config.vault.root.display()
937                );
938            }
939            Err(e) => println!("  vault:  ✗ (config parse error: {e})"),
940        }
941    }
942
943    // Lifecycle
944    let lifecycle_root =
945        lifecycle_root_from_config(config_path.parent().unwrap_or_else(|| Path::new(".")));
946    let store = LifecycleStore::new(&lifecycle_root);
947    let entries = latest_state_entries(&store).unwrap_or_default();
948    let wakeup_ready = entries
949        .iter()
950        .filter(|e| {
951            matches!(
952                e.record.state,
953                MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
954            )
955        })
956        .count();
957    let pending = entries
958        .iter()
959        .filter(|e| e.record.state == MemoryLifecycleState::Candidate)
960        .count();
961    println!(
962        "  memories: {} active, {} pending review, {} total",
963        wakeup_ready,
964        pending,
965        entries.len()
966    );
967
968    // AI tools
969    let tools = [
970        ("claude", home.join(".claude")),
971        ("codex", home.join(".codex")),
972        ("cursor", home.join(".cursor")),
973        ("opencode", home.join(".opencode")),
974    ];
975    let mut injected: Vec<&str> = Vec::new();
976    for (name, dir) in &tools {
977        if dir.is_dir() {
978            let check_file = match *name {
979                "claude" => dir.join("settings.json"),
980                _ => dir.join("config.json"),
981            };
982            if std::fs::read_to_string(&check_file)
983                .map(|c| c.contains("spool"))
984                .unwrap_or(false)
985            {
986                injected.push(name);
987            }
988        }
989    }
990    if injected.is_empty() {
991        println!("  clients: (none injected)");
992    } else {
993        println!("  clients: {}", injected.join(", "));
994    }
995
996    // Rules
997    let rules = crate::rules::load(&lifecycle_root);
998    let rule_count = rules.extraction.len() + rules.suppress.len();
999    if rule_count > 0 {
1000        println!(
1001            "  rules:  {} extraction, {} suppress",
1002            rules.extraction.len(),
1003            rules.suppress.len()
1004        );
1005    }
1006
1007    println!();
1008    Ok(())
1009}
1010
1011#[cfg(feature = "embedding")]
1012fn execute_embedding(args: crate::cli::args::EmbeddingArgs) -> anyhow::Result<()> {
1013    use crate::cli::args::EmbeddingCommand;
1014    match args.command {
1015        EmbeddingCommand::Build(a) => execute_embedding_build(a),
1016        EmbeddingCommand::Status(a) => execute_embedding_status(a),
1017    }
1018}
1019
1020#[cfg(feature = "embedding")]
1021fn execute_embedding_build(args: crate::cli::args::EmbeddingBuildArgs) -> anyhow::Result<()> {
1022    use crate::engine::embedding::{EmbeddingIndex, resolve_model_variant};
1023
1024    let config = crate::config::load_from_path(&args.config)?;
1025    if !config.embedding.enabled {
1026        anyhow::bail!("embedding is disabled in config");
1027    }
1028
1029    let config_dir = args.config.parent().unwrap_or_else(|| Path::new("."));
1030    let lifecycle_root = lifecycle_root_from_config(config_dir);
1031    let store = LifecycleStore::new(&lifecycle_root);
1032    let entries = latest_state_entries(&store)?;
1033    let wakeup_eligible: Vec<(String, &crate::domain::MemoryRecord)> = entries
1034        .iter()
1035        .filter(|e| {
1036            matches!(
1037                e.record.state,
1038                MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
1039            )
1040        })
1041        .map(|e| (e.record_id.clone(), &e.record))
1042        .collect();
1043
1044    let model_id = config.embedding.model_id.as_deref();
1045    let variant = resolve_model_variant(model_id);
1046    println!(
1047        "Building embedding index for {} records (model: {:?})...",
1048        wakeup_eligible.len(),
1049        model_id.unwrap_or("bge-small-zh-v1.5")
1050    );
1051
1052    let model = fastembed::TextEmbedding::try_new(
1053        fastembed::InitOptions::new(variant).with_show_download_progress(true),
1054    )?;
1055
1056    let index = EmbeddingIndex::build_from_records_with_model(&wakeup_eligible, &model)?;
1057    let index_path = config.embedding.resolved_index_path();
1058    index.save(&index_path)?;
1059
1060    println!(
1061        "Done. {} records indexed ({} dim), saved to {}",
1062        index.len(),
1063        index.dim(),
1064        index_path.display()
1065    );
1066    Ok(())
1067}
1068
1069#[cfg(feature = "embedding")]
1070fn execute_embedding_status(args: crate::cli::args::EmbeddingStatusArgs) -> anyhow::Result<()> {
1071    use crate::engine::embedding::EmbeddingIndex;
1072
1073    let config = crate::config::load_from_path(&args.config)?;
1074    let index_path = config.embedding.resolved_index_path();
1075
1076    println!("Embedding configuration:");
1077    println!("  enabled:    {}", config.embedding.enabled);
1078    println!(
1079        "  model_id:   {}",
1080        config
1081            .embedding
1082            .model_id
1083            .as_deref()
1084            .unwrap_or("(default: bge-small-zh-v1.5)")
1085    );
1086    println!("  index_path: {}", index_path.display());
1087    println!("  auto_index: {}", config.embedding.auto_index);
1088    println!();
1089
1090    if index_path.exists() {
1091        match EmbeddingIndex::load(&index_path) {
1092            Ok(index) => {
1093                let meta = std::fs::metadata(&index_path)?;
1094                println!("Index status: BUILT");
1095                println!("  records:    {}", index.len());
1096                println!("  dimensions: {}", index.dim());
1097                println!("  file size:  {:.1} KB", meta.len() as f64 / 1024.0);
1098
1099                let config_dir = args.config.parent().unwrap_or_else(|| Path::new("."));
1100                let lifecycle_root = lifecycle_root_from_config(config_dir);
1101                let store = LifecycleStore::new(&lifecycle_root);
1102                if let Ok(entries) = latest_state_entries(&store) {
1103                    let eligible = entries
1104                        .iter()
1105                        .filter(|e| {
1106                            matches!(
1107                                e.record.state,
1108                                MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
1109                            )
1110                        })
1111                        .count();
1112                    let coverage = if eligible > 0 {
1113                        (index.len() as f64 / eligible as f64 * 100.0).min(100.0)
1114                    } else {
1115                        100.0
1116                    };
1117                    println!(
1118                        "  coverage:   {}/{} ({:.0}%)",
1119                        index.len(),
1120                        eligible,
1121                        coverage
1122                    );
1123                    if coverage < 90.0 {
1124                        println!();
1125                        println!(
1126                            "  Hint: coverage below 90%. Run `spool embedding build` to rebuild."
1127                        );
1128                    }
1129                }
1130            }
1131            Err(e) => {
1132                println!("Index status: CORRUPT ({})", e);
1133            }
1134        }
1135    } else {
1136        println!("Index status: NOT BUILT");
1137        println!("  Run `spool embedding build --config ...` to create the index.");
1138    }
1139    Ok(())
1140}
1141
1142fn execute_knowledge(args: crate::cli::args::KnowledgeArgs) -> anyhow::Result<()> {
1143    use crate::cli::args::KnowledgeCommand;
1144    match args.command {
1145        KnowledgeCommand::Distill(a) => execute_knowledge_distill(a),
1146    }
1147}
1148
1149fn execute_knowledge_distill(args: crate::cli::args::KnowledgeDistillArgs) -> anyhow::Result<()> {
1150    let drafts = crate::knowledge::detect_knowledge_clusters(&args.config)?;
1151
1152    if drafts.is_empty() {
1153        println!("No knowledge clusters detected (need 3+ related fragments).");
1154        return Ok(());
1155    }
1156
1157    println!("# Knowledge distillation\n");
1158    println!("Detected {} knowledge page draft(s):\n", drafts.len());
1159
1160    for (i, draft) in drafts.iter().enumerate() {
1161        println!("## Draft {} — {}\n", i + 1, draft.title);
1162        println!("- domain: {}", draft.domain);
1163        println!("- tags: {}", draft.tags.join(", "));
1164        println!("- sources: {} fragments", draft.source_record_ids.len());
1165        if !draft.related_notes.is_empty() {
1166            println!("- related: {}", draft.related_notes.join(", "));
1167        }
1168        println!("\n### Preview:\n");
1169        for line in draft.summary.lines().take(20) {
1170            println!("  {}", line);
1171        }
1172        if draft.summary.lines().count() > 20 {
1173            println!("  ...(truncated)");
1174        }
1175        println!();
1176    }
1177
1178    if args.apply {
1179        let ids = crate::knowledge::apply_distill(&args.config, &drafts, &args.actor)?;
1180        println!("Created {} knowledge page candidate(s):", ids.len());
1181        for id in &ids {
1182            println!("  - {}", id);
1183        }
1184        println!("\nUse `spool memory accept --record-id <id>` to promote to accepted.");
1185    } else if !args.dry_run {
1186        println!("Add --apply to create knowledge page candidates in the ledger.");
1187    }
1188
1189    Ok(())
1190}