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 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 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 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 let config_exists = config_path.exists();
922 println!(
923 " config: {} {}",
924 if config_exists { "✓" } else { "✗" },
925 config_path.display()
926 );
927
928 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 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 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 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}