Skip to main content

spool/
wiki_index.rs

1//! Wiki INDEX.md 自动生成。
2//!
3//! 从 ledger projection 生成 `<vault_root>/INDEX.md`,按 scope (user / project) 分节
4//! 列出所有 accepted/canonical 记录。该文件作为 LLM 在 wakeup 时的知识导航入口,
5//! 类比 Karpathy LLM Wiki 的 index 层。
6//!
7//! ## 不变量
8//! - 只列 Accepted / Canonical 记录 (candidate 在 review queue,archived 不导航)
9//! - 按 scope 分段:User-Level (always loaded) → Project: `<id>` 若干段
10//! - 段内按 recorded_at 倒序,最新优先
11//! - 幂等:内容不变不写盘,避免 mtime 抖动
12//! - 失败降级:错误日志 stderr,不阻断 lifecycle 主路径
13
14use crate::domain::{MemoryLifecycleState, MemoryScope};
15use crate::lifecycle_store::{LedgerEntry, LifecycleStore, latest_state_entries};
16use anyhow::{Context, Result};
17use std::collections::BTreeMap;
18use std::fs;
19use std::path::{Path, PathBuf};
20
21pub const INDEX_FILE_NAME: &str = "INDEX.md";
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum IndexWriteStatus {
25    Created,
26    Updated,
27    Unchanged,
28}
29
30#[derive(Debug, Clone)]
31pub struct IndexWriteResult {
32    pub path: PathBuf,
33    pub status: IndexWriteStatus,
34    pub user_entries: usize,
35    pub project_entries: usize,
36}
37
38pub fn index_path(vault_root: &Path) -> PathBuf {
39    vault_root.join(INDEX_FILE_NAME)
40}
41
42/// 把 ledger 条目渲染为 markdown 导航索引。
43///
44/// 输出结构:
45/// ```text
46/// # Spool Knowledge Index
47///
48/// ## User-Level (always loaded)
49/// - [title] type · record_id
50///
51/// ## Project: <project-id>
52/// - [title] type · record_id
53/// ```
54pub fn render_index(entries: &[LedgerEntry]) -> String {
55    let mut user_entries: Vec<&LedgerEntry> = Vec::new();
56    let mut project_entries: BTreeMap<String, Vec<&LedgerEntry>> = BTreeMap::new();
57    let mut other_entries: Vec<&LedgerEntry> = Vec::new();
58
59    for entry in entries {
60        if !matches!(
61            entry.record.state,
62            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
63        ) {
64            continue;
65        }
66        match &entry.record.scope {
67            MemoryScope::User => user_entries.push(entry),
68            MemoryScope::Project => {
69                let project_id = entry
70                    .record
71                    .project_id
72                    .clone()
73                    .unwrap_or_else(|| "unknown".to_string());
74                project_entries.entry(project_id).or_default().push(entry);
75            }
76            MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {
77                other_entries.push(entry);
78            }
79        }
80    }
81
82    sort_desc_by_recorded_at(&mut user_entries);
83    for entries in project_entries.values_mut() {
84        sort_desc_by_recorded_at(entries);
85    }
86    sort_desc_by_recorded_at(&mut other_entries);
87
88    let mut out = String::new();
89    out.push_str("# Spool Knowledge Index\n\n");
90    out.push_str(
91        "> 自动生成的知识导航。仅列 accepted / canonical 记录。由 spool lifecycle \
92         writes 自动刷新,不要手动编辑。\n\n",
93    );
94
95    out.push_str("## User-Level (always loaded)\n\n");
96    if user_entries.is_empty() {
97        out.push_str("*(尚无用户级记忆)*\n\n");
98    } else {
99        for entry in &user_entries {
100            out.push_str(&render_entry_line(entry));
101        }
102        out.push('\n');
103    }
104
105    if project_entries.is_empty() {
106        out.push_str("## Projects\n\n*(尚无 project-scoped 记忆)*\n\n");
107    } else {
108        for (project_id, entries) in &project_entries {
109            out.push_str(&format!("## Project: {}\n\n", project_id));
110            for entry in entries {
111                out.push_str(&render_entry_line(entry));
112            }
113            out.push('\n');
114        }
115    }
116
117    if !other_entries.is_empty() {
118        out.push_str("## Shared (workspace / team / agent)\n\n");
119        for entry in &other_entries {
120            out.push_str(&render_entry_line(entry));
121        }
122        out.push('\n');
123    }
124
125    out
126}
127
128fn sort_desc_by_recorded_at(entries: &mut [&LedgerEntry]) {
129    entries.sort_by(|a, b| b.recorded_at.cmp(&a.recorded_at));
130}
131
132fn render_entry_line(entry: &LedgerEntry) -> String {
133    let title = if entry.record.title.trim().is_empty() {
134        "(untitled)"
135    } else {
136        entry.record.title.as_str()
137    };
138    let memory_type = &entry.record.memory_type;
139    let state_marker = match entry.record.state {
140        MemoryLifecycleState::Canonical => "★",
141        _ => "·",
142    };
143    format!(
144        "- {} [{}] {} — `{}`\n",
145        state_marker, memory_type, title, entry.record_id
146    )
147}
148
149/// 把 `entries` 渲染成 INDEX.md 并写入 `<vault_root>/INDEX.md`,幂等。
150pub fn write_index(vault_root: &Path, entries: &[LedgerEntry]) -> Result<IndexWriteResult> {
151    let path = index_path(vault_root);
152    let desired = render_index(entries);
153
154    let (status, _) = match fs::read_to_string(&path) {
155        Ok(existing) if existing == desired => (IndexWriteStatus::Unchanged, existing),
156        Ok(existing) => (IndexWriteStatus::Updated, existing),
157        Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
158            (IndexWriteStatus::Created, String::new())
159        }
160        Err(err) => {
161            return Err(anyhow::Error::new(err).context(format!(
162                "failed to read existing INDEX.md at {}",
163                path.display()
164            )));
165        }
166    };
167
168    if !matches!(status, IndexWriteStatus::Unchanged) {
169        if let Some(parent) = path.parent() {
170            fs::create_dir_all(parent).with_context(|| {
171                format!("failed to create INDEX.md parent dir {}", parent.display())
172            })?;
173        }
174        fs::write(&path, &desired)
175            .with_context(|| format!("failed to write INDEX.md at {}", path.display()))?;
176    }
177
178    let (user_entries, project_entries) = count_active_entries(entries);
179    Ok(IndexWriteResult {
180        path,
181        status,
182        user_entries,
183        project_entries,
184    })
185}
186
187fn count_active_entries(entries: &[LedgerEntry]) -> (usize, usize) {
188    let mut user_count = 0;
189    let mut project_count = 0;
190    for entry in entries {
191        if !matches!(
192            entry.record.state,
193            MemoryLifecycleState::Accepted | MemoryLifecycleState::Canonical
194        ) {
195            continue;
196        }
197        match &entry.record.scope {
198            MemoryScope::User => user_count += 1,
199            MemoryScope::Project => project_count += 1,
200            MemoryScope::Workspace | MemoryScope::Team | MemoryScope::Agent => {}
201        }
202    }
203    (user_count, project_count)
204}
205
206/// 从 config_path 加载 AppConfig,resolve vault_root 和 lifecycle_root,
207/// 读 latest_state 条目并刷写 INDEX.md。失败降级为 stderr warn 并返回 None,
208/// 不阻断调用方的 lifecycle 主路径。
209pub fn refresh_index_from_config(config_path: &Path) -> Option<IndexWriteResult> {
210    match refresh_index_inner(config_path) {
211        Ok(result) => Some(result),
212        Err(error) => {
213            eprintln!("[spool] wiki index refresh failed: {error:#}");
214            None
215        }
216    }
217}
218
219fn refresh_index_inner(config_path: &Path) -> Result<IndexWriteResult> {
220    let config = crate::app::load(config_path)
221        .with_context(|| format!("failed to load config {}", config_path.display()))?;
222    let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
223        .context("failed to resolve vault root")?;
224    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
225    let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
226    let store = LifecycleStore::new(&lifecycle_root);
227    let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
228    write_index(&vault_root, &entries)
229}
230
231/// CLI-facing variant of `refresh_index_from_config` that propagates errors
232/// instead of swallowing them. Used by `memory sync-index --apply`.
233pub fn refresh_index_result(config_path: &Path) -> Result<IndexWriteResult> {
234    refresh_index_inner(config_path)
235}
236
237/// Render the INDEX markdown without writing to disk (dry-run preview).
238pub fn render_index_from_config(config_path: &Path) -> Result<String> {
239    let config = crate::app::load(config_path)
240        .with_context(|| format!("failed to load config {}", config_path.display()))?;
241    let vault_root = crate::app::resolve_override_path(&config.vault.root, config_path)
242        .context("failed to resolve vault root")?;
243    let config_dir = config_path.parent().unwrap_or_else(|| Path::new("."));
244    let lifecycle_root = crate::lifecycle_store::lifecycle_root_from_config(config_dir);
245    let store = LifecycleStore::new(&lifecycle_root);
246    let entries = latest_state_entries(&store).context("failed to read ledger entries")?;
247    let index_path = index_path(&vault_root);
248    let desired = render_index(&entries);
249    let status_hint = match fs::read_to_string(&index_path) {
250        Ok(existing) if existing == desired => "unchanged",
251        Ok(_) => "would update",
252        Err(_) => "would create",
253    };
254    Ok(format!(
255        "{desired}\n---\nTarget: {}\nStatus: {status_hint}\n",
256        index_path.display()
257    ))
258}
259
260/// 读 `<vault_root>/INDEX.md`,按当前 scope 过滤出相关 section:
261/// - User-Level 段始终保留
262/// - 若 `current_project_id = Some(id)`,额外保留 `## Project: <id>` 段
263/// - 其他 project 段丢弃,避免 wakeup 随记忆总量线性膨胀
264/// - INDEX 缺失或读失败时返回 None,不抛错
265///
266/// 返回 Some(markdown_fragment) 表示有内容可注入 wakeup,None 表示跳过。
267pub fn load_index_section(vault_root: &Path, current_project_id: Option<&str>) -> Option<String> {
268    let path = index_path(vault_root);
269    let raw = fs::read_to_string(&path).ok()?;
270    let filtered = filter_index_sections(&raw, current_project_id);
271    if filtered.trim().is_empty() {
272        None
273    } else {
274        Some(filtered)
275    }
276}
277
278/// 纯函数:按 `## ` 大标题切段,保留 User-Level + 当前 project 段。
279/// 暴露方便单元测试。
280pub fn filter_index_sections(raw: &str, current_project_id: Option<&str>) -> String {
281    let wanted_project_heading = current_project_id.map(|id| format!("## Project: {}", id.trim()));
282
283    let mut out = String::new();
284    let mut include = false;
285    let mut first_line = true;
286
287    for line in raw.lines() {
288        if first_line && line.starts_with("# ") {
289            out.push_str(line);
290            out.push('\n');
291            first_line = false;
292            continue;
293        }
294        first_line = false;
295
296        if let Some(stripped) = line.strip_prefix("## ") {
297            // Start of a new H2 section — decide whether to include it.
298            include = stripped.starts_with("User-Level")
299                || wanted_project_heading
300                    .as_deref()
301                    .is_some_and(|want| line == want);
302            if include {
303                out.push_str(line);
304                out.push('\n');
305            }
306            continue;
307        }
308
309        if include {
310            out.push_str(line);
311            out.push('\n');
312        }
313    }
314
315    // Trim trailing blank lines.
316    while out.ends_with("\n\n") {
317        out.pop();
318    }
319    out
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325    use crate::domain::{
326        MemoryLedgerAction, MemoryLifecycleState, MemoryOrigin, MemoryRecord, MemoryScope,
327        MemorySourceKind,
328    };
329    use crate::lifecycle_store::TransitionMetadata;
330    use tempfile::tempdir;
331
332    fn make_entry(
333        record_id: &str,
334        title: &str,
335        memory_type: &str,
336        state: MemoryLifecycleState,
337        scope: MemoryScope,
338        project_id: Option<&str>,
339        recorded_at: &str,
340    ) -> LedgerEntry {
341        LedgerEntry {
342            schema_version: "memory-ledger.v1".to_string(),
343            recorded_at: recorded_at.to_string(),
344            record_id: record_id.to_string(),
345            scope_key: match &scope {
346                MemoryScope::User => "user:long".to_string(),
347                MemoryScope::Project => {
348                    format!("project:{}", project_id.unwrap_or("unknown"))
349                }
350                MemoryScope::Workspace => "workspace:shared".to_string(),
351                MemoryScope::Team => "team:shared".to_string(),
352                MemoryScope::Agent => "agent:shared".to_string(),
353            },
354            action: MemoryLedgerAction::RecordManual,
355            source_kind: MemorySourceKind::Manual,
356            metadata: TransitionMetadata::default(),
357            record: MemoryRecord {
358                title: title.to_string(),
359                summary: "summary".to_string(),
360                memory_type: memory_type.to_string(),
361                scope,
362                state,
363                origin: MemoryOrigin {
364                    source_kind: MemorySourceKind::Manual,
365                    source_ref: "manual:test".to_string(),
366                },
367                project_id: project_id.map(ToString::to_string),
368                user_id: Some("long".to_string()),
369                sensitivity: None,
370                entities: Vec::new(),
371                tags: Vec::new(),
372                triggers: Vec::new(),
373                related_files: Vec::new(),
374                related_records: Vec::new(),
375                supersedes: None,
376                applies_to: Vec::new(),
377                valid_until: None,
378            },
379        }
380    }
381
382    #[test]
383    fn render_index_should_group_by_scope_and_skip_non_active_states() {
384        let entries = vec![
385            make_entry(
386                "mem-a",
387                "User preference",
388                "preference",
389                MemoryLifecycleState::Accepted,
390                MemoryScope::User,
391                None,
392                "2026-05-10T00:00:00Z",
393            ),
394            make_entry(
395                "mem-b",
396                "Project decision",
397                "decision",
398                MemoryLifecycleState::Canonical,
399                MemoryScope::Project,
400                Some("spool"),
401                "2026-05-11T00:00:00Z",
402            ),
403            make_entry(
404                "mem-c",
405                "Candidate to skip",
406                "workflow",
407                MemoryLifecycleState::Candidate,
408                MemoryScope::User,
409                None,
410                "2026-05-12T00:00:00Z",
411            ),
412            make_entry(
413                "mem-d",
414                "Archived to skip",
415                "incident",
416                MemoryLifecycleState::Archived,
417                MemoryScope::Project,
418                Some("spool"),
419                "2026-05-09T00:00:00Z",
420            ),
421        ];
422
423        let out = render_index(&entries);
424
425        assert!(out.contains("User-Level"));
426        assert!(out.contains("Project: spool"));
427        assert!(out.contains("User preference"));
428        assert!(out.contains("Project decision"));
429        assert!(!out.contains("Candidate to skip"));
430        assert!(!out.contains("Archived to skip"));
431        // Canonical gets star marker
432        assert!(out.contains("★ [decision] Project decision"));
433        // Accepted gets bullet marker
434        assert!(out.contains("· [preference] User preference"));
435    }
436
437    #[test]
438    fn render_index_should_sort_desc_by_recorded_at_within_scope() {
439        let entries = vec![
440            make_entry(
441                "mem-old",
442                "Old entry",
443                "preference",
444                MemoryLifecycleState::Accepted,
445                MemoryScope::User,
446                None,
447                "2026-05-01T00:00:00Z",
448            ),
449            make_entry(
450                "mem-new",
451                "New entry",
452                "preference",
453                MemoryLifecycleState::Accepted,
454                MemoryScope::User,
455                None,
456                "2026-05-10T00:00:00Z",
457            ),
458        ];
459        let out = render_index(&entries);
460        let new_pos = out.find("New entry").unwrap();
461        let old_pos = out.find("Old entry").unwrap();
462        assert!(new_pos < old_pos, "newer entries must appear first");
463    }
464
465    #[test]
466    fn render_index_should_show_placeholders_when_scope_empty() {
467        let entries: Vec<LedgerEntry> = Vec::new();
468        let out = render_index(&entries);
469        assert!(out.contains("尚无用户级记忆"));
470        assert!(out.contains("尚无 project-scoped 记忆"));
471    }
472
473    #[test]
474    fn write_index_should_create_file_and_be_idempotent() {
475        let dir = tempdir().unwrap();
476        let entries = vec![make_entry(
477            "mem-a",
478            "User preference",
479            "preference",
480            MemoryLifecycleState::Accepted,
481            MemoryScope::User,
482            None,
483            "2026-05-10T00:00:00Z",
484        )];
485
486        let first = write_index(dir.path(), &entries).unwrap();
487        assert_eq!(first.status, IndexWriteStatus::Created);
488        assert_eq!(first.user_entries, 1);
489        assert_eq!(first.project_entries, 0);
490        assert!(first.path.exists());
491
492        let second = write_index(dir.path(), &entries).unwrap();
493        assert_eq!(second.status, IndexWriteStatus::Unchanged);
494    }
495
496    #[test]
497    fn write_index_should_mark_updated_when_content_changes() {
498        let dir = tempdir().unwrap();
499        let entries_v1 = vec![make_entry(
500            "mem-a",
501            "Original title",
502            "preference",
503            MemoryLifecycleState::Accepted,
504            MemoryScope::User,
505            None,
506            "2026-05-10T00:00:00Z",
507        )];
508        let entries_v2 = vec![make_entry(
509            "mem-a",
510            "Updated title",
511            "preference",
512            MemoryLifecycleState::Accepted,
513            MemoryScope::User,
514            None,
515            "2026-05-10T00:00:00Z",
516        )];
517
518        write_index(dir.path(), &entries_v1).unwrap();
519        let second = write_index(dir.path(), &entries_v2).unwrap();
520        assert_eq!(second.status, IndexWriteStatus::Updated);
521        let body = fs::read_to_string(&second.path).unwrap();
522        assert!(body.contains("Updated title"));
523        assert!(!body.contains("Original title"));
524    }
525
526    #[test]
527    fn filter_index_sections_should_keep_user_level_and_requested_project() {
528        let raw = "# Spool Knowledge Index\n\n\
529                   > preamble\n\n\
530                   ## User-Level (always loaded)\n\n\
531                   - · [preference] stuff — `mem-u1`\n\n\
532                   ## Project: spool\n\n\
533                   - · [decision] chose X — `mem-p1`\n\n\
534                   ## Project: other\n\n\
535                   - · [note] irrelevant — `mem-o1`\n";
536        let filtered = filter_index_sections(raw, Some("spool"));
537        assert!(filtered.contains("# Spool Knowledge Index"));
538        assert!(filtered.contains("User-Level"));
539        assert!(filtered.contains("mem-u1"));
540        assert!(filtered.contains("## Project: spool"));
541        assert!(filtered.contains("mem-p1"));
542        assert!(!filtered.contains("Project: other"));
543        assert!(!filtered.contains("mem-o1"));
544        // Preamble (between # heading and first ## section) should be dropped.
545        assert!(!filtered.contains("preamble"));
546    }
547
548    #[test]
549    fn filter_index_sections_should_keep_only_user_level_when_no_project() {
550        let raw = "# Spool Knowledge Index\n\n\
551                   ## User-Level (always loaded)\n\n\
552                   - · [preference] stuff — `mem-u1`\n\n\
553                   ## Project: spool\n\n\
554                   - · [decision] chose X — `mem-p1`\n";
555        let filtered = filter_index_sections(raw, None);
556        assert!(filtered.contains("User-Level"));
557        assert!(filtered.contains("mem-u1"));
558        assert!(!filtered.contains("Project: spool"));
559        assert!(!filtered.contains("mem-p1"));
560    }
561
562    #[test]
563    fn load_index_section_should_return_none_when_file_missing() {
564        let dir = tempdir().unwrap();
565        assert!(load_index_section(dir.path(), Some("spool")).is_none());
566    }
567
568    #[test]
569    fn load_index_section_should_load_and_filter_written_index() {
570        let dir = tempdir().unwrap();
571        let entries = vec![
572            make_entry(
573                "mem-u",
574                "User pref",
575                "preference",
576                MemoryLifecycleState::Accepted,
577                MemoryScope::User,
578                None,
579                "2026-05-10T00:00:00Z",
580            ),
581            make_entry(
582                "mem-p",
583                "Project decision",
584                "decision",
585                MemoryLifecycleState::Accepted,
586                MemoryScope::Project,
587                Some("spool"),
588                "2026-05-11T00:00:00Z",
589            ),
590            make_entry(
591                "mem-x",
592                "Other project",
593                "note",
594                MemoryLifecycleState::Accepted,
595                MemoryScope::Project,
596                Some("other"),
597                "2026-05-12T00:00:00Z",
598            ),
599        ];
600        write_index(dir.path(), &entries).unwrap();
601
602        let filtered = load_index_section(dir.path(), Some("spool"))
603            .expect("index file exists and should filter cleanly");
604        assert!(filtered.contains("User pref"));
605        assert!(filtered.contains("Project decision"));
606        assert!(!filtered.contains("Other project"));
607    }
608}