Skip to main content

zig_core/
memory.rs

1//! Memory scratch pad for workflows and steps.
2//!
3//! Memory is a managed folder of files with a `.manifest` JSON index that gets
4//! injected into agent system prompts. It enables agents to accumulate and
5//! search knowledge across workflow runs.
6//!
7//! Storage mirrors the resource tier layout:
8//!
9//! * `~/.zig/memory/_shared/` — global shared tier
10//! * `~/.zig/memory/<workflow>/` — global per-workflow tier
11//! * `<git-root>/.zig/memory/` — project-local tier
12//!
13//! Each tier directory contains a `.manifest` JSON file alongside the actual
14//! memory files.
15
16use std::collections::BTreeMap;
17use std::path::{Path, PathBuf};
18
19use chrono::{DateTime, Utc};
20use serde::{Deserialize, Serialize};
21
22use crate::config::ZigConfig;
23use crate::error::ZigError;
24use crate::paths;
25use crate::workflow::model::MemoryMode;
26
27// =====================================================================
28// Data structures
29// =====================================================================
30
31/// The `.manifest` file contents — an index of all memory entries in a tier.
32#[derive(Debug, Clone, Default, Serialize, Deserialize)]
33pub struct Manifest {
34    /// Next ID to assign when adding a new entry.
35    pub next_id: u64,
36    /// Entries keyed by their string ID ("1", "2", ...).
37    pub entries: BTreeMap<String, MemoryEntry>,
38}
39
40/// A single memory entry in the manifest.
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct MemoryEntry {
43    /// Display name for this memory entry.
44    pub name: String,
45    /// Filename of the memory file within the tier directory.
46    pub file: String,
47    /// Optional human-readable description of the memory contents.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub description: Option<String>,
50    /// Tags for filtering and discovery.
51    #[serde(default, skip_serializing_if = "Vec::is_empty")]
52    pub tags: Vec<String>,
53    /// Optional step name this memory is scoped to.
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub step: Option<String>,
56    /// Original source path the file was added from.
57    pub source: String,
58    /// When the entry was added.
59    pub added: DateTime<Utc>,
60}
61
62/// Where a memory command should target.
63#[derive(Debug, Clone)]
64pub enum MemoryTarget {
65    /// `~/.zig/memory/_shared/`
66    GlobalShared,
67    /// `~/.zig/memory/<workflow>/`
68    GlobalWorkflow(String),
69    /// `<git-root>/.zig/memory/`
70    Cwd,
71}
72
73impl MemoryTarget {
74    /// Resolve from CLI flag combination. Same rules as `ResourceTarget`.
75    pub fn from_flags(workflow: Option<&str>, global: bool, cwd: bool) -> Result<Self, ZigError> {
76        if let Some(name) = workflow {
77            if cwd {
78                return Err(ZigError::Validation(
79                    "--workflow cannot be combined with --cwd".into(),
80                ));
81            }
82            return Ok(MemoryTarget::GlobalWorkflow(name.to_string()));
83        }
84        if cwd {
85            return Ok(MemoryTarget::Cwd);
86        }
87        if global {
88            return Ok(MemoryTarget::GlobalShared);
89        }
90        // Default: project-local.
91        Ok(MemoryTarget::Cwd)
92    }
93
94    /// Resolve to an absolute directory path, creating it if it doesn't exist.
95    pub fn ensure_dir(&self) -> Result<PathBuf, ZigError> {
96        match self {
97            MemoryTarget::GlobalShared => paths::ensure_global_memory_dir(Some("_shared")),
98            MemoryTarget::GlobalWorkflow(name) => paths::ensure_global_memory_dir(Some(name)),
99            MemoryTarget::Cwd => ensure_cwd_memory_dir(),
100        }
101    }
102
103    /// Resolve to an absolute directory path without creating it.
104    pub fn existing_dir(&self) -> Option<PathBuf> {
105        match self {
106            MemoryTarget::GlobalShared => paths::global_shared_memory_dir(),
107            MemoryTarget::GlobalWorkflow(name) => paths::global_memory_for(name),
108            MemoryTarget::Cwd => paths::cwd_memory_dir().or_else(|| {
109                std::env::current_dir()
110                    .ok()
111                    .map(|p| p.join(".zig").join("memory"))
112            }),
113        }
114    }
115
116    /// Short label for diagnostic messages.
117    pub fn label(&self) -> String {
118        match self {
119            MemoryTarget::GlobalShared => "global:_shared".to_string(),
120            MemoryTarget::GlobalWorkflow(n) => format!("global:{n}"),
121            MemoryTarget::Cwd => "cwd".to_string(),
122        }
123    }
124}
125
126/// Search result granularity.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum SearchScope {
129    /// Return the full sentence containing the match.
130    Sentence,
131    /// Return the full paragraph containing the match.
132    Paragraph,
133    /// Return the full h2 section containing the match.
134    Section,
135    /// Return the entire file contents.
136    File,
137}
138
139// =====================================================================
140// Manifest I/O
141// =====================================================================
142
143fn manifest_path(dir: &Path) -> PathBuf {
144    dir.join(".manifest")
145}
146
147/// Load the manifest from a tier directory. Returns an empty manifest if the
148/// file does not exist.
149pub fn load_manifest(dir: &Path) -> Result<Manifest, ZigError> {
150    let path = manifest_path(dir);
151    if !path.exists() {
152        return Ok(Manifest {
153            next_id: 1,
154            entries: BTreeMap::new(),
155        });
156    }
157    let content = std::fs::read_to_string(&path)
158        .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", path.display())))?;
159    serde_json::from_str(&content).map_err(|e| {
160        ZigError::Io(format!(
161            "failed to parse manifest at {}: {e}",
162            path.display()
163        ))
164    })
165}
166
167/// Save the manifest to a tier directory.
168pub fn save_manifest(dir: &Path, manifest: &Manifest) -> Result<(), ZigError> {
169    let path = manifest_path(dir);
170    let content = serde_json::to_string_pretty(manifest)
171        .map_err(|e| ZigError::Serialize(format!("failed to serialize manifest: {e}")))?;
172    std::fs::write(&path, content)
173        .map_err(|e| ZigError::Io(format!("failed to write {}: {e}", path.display())))
174}
175
176fn ensure_cwd_memory_dir() -> Result<PathBuf, ZigError> {
177    if let Some(existing) = paths::cwd_memory_dir() {
178        return Ok(existing);
179    }
180    let cwd = std::env::current_dir()
181        .map_err(|e| ZigError::Io(format!("failed to read current directory: {e}")))?;
182    let dir = cwd.join(".zig").join("memory");
183    std::fs::create_dir_all(&dir)
184        .map_err(|e| ZigError::Io(format!("failed to create {}: {e}", dir.display())))?;
185    Ok(dir)
186}
187
188// =====================================================================
189// Tier traversal helpers
190// =====================================================================
191
192/// Build a list of (label, dir) pairs for all potentially relevant tiers.
193fn candidate_dirs(workflow: Option<&str>) -> Vec<(String, PathBuf)> {
194    let mut out: Vec<(String, PathBuf)> = Vec::new();
195    if let Some(d) = paths::global_shared_memory_dir() {
196        out.push(("global:_shared".into(), d));
197    }
198    if let Some(name) = workflow {
199        if let Some(d) = paths::global_memory_for(name) {
200            out.push((format!("global:{name}"), d));
201        }
202    }
203    if let Some(d) = paths::cwd_memory_dir() {
204        out.push(("cwd".into(), d));
205    } else if let Ok(cwd) = std::env::current_dir() {
206        out.push(("cwd".into(), cwd.join(".zig").join("memory")));
207    }
208    out
209}
210
211/// Search all tier manifests for an entry with the given ID.
212/// Returns (tier_dir, tier_label, manifest, entry_clone).
213fn find_entry_across_tiers(
214    id: u64,
215    workflow: Option<&str>,
216) -> Result<(PathBuf, String, Manifest, MemoryEntry), ZigError> {
217    let id_str = id.to_string();
218    // Search project-local first (most specific), then global.
219    let dirs = candidate_dirs(workflow);
220    for (label, dir) in dirs.iter().rev() {
221        if !dir.is_dir() {
222            continue;
223        }
224        let manifest = load_manifest(dir)?;
225        if let Some(entry) = manifest.entries.get(&id_str).cloned() {
226            return Ok((dir.clone(), label.clone(), manifest, entry));
227        }
228    }
229    Err(ZigError::Io(format!(
230        "memory entry with id {id} not found in any tier"
231    )))
232}
233
234// =====================================================================
235// Public command functions
236// =====================================================================
237
238/// Add a file to the memory scratch pad.
239///
240/// Copies the file into the target tier directory, assigns a numeric ID, and
241/// updates the manifest. Returns the assigned ID.
242pub fn add(
243    file_path: &str,
244    target: MemoryTarget,
245    step: Option<&str>,
246    name: Option<&str>,
247    description: Option<&str>,
248    tags: &[String],
249) -> Result<u64, ZigError> {
250    let src = Path::new(file_path);
251    if !src.exists() {
252        return Err(ZigError::Io(format!("source file not found: {file_path}")));
253    }
254    if !src.is_file() {
255        return Err(ZigError::Io(format!("not a regular file: {file_path}")));
256    }
257
258    let dir = target.ensure_dir()?;
259    let mut manifest = load_manifest(&dir)?;
260
261    let id = manifest.next_id;
262    manifest.next_id += 1;
263
264    let file_name = name
265        .map(str::to_string)
266        .or_else(|| src.file_name().map(|n| n.to_string_lossy().into_owned()))
267        .ok_or_else(|| ZigError::Io(format!("could not derive a name from {}", src.display())))?;
268
269    let dest = dir.join(&file_name);
270    if dest.exists() {
271        return Err(ZigError::Io(format!(
272            "file '{}' already exists in {} — remove it first or use --name to rename",
273            file_name,
274            dir.display()
275        )));
276    }
277
278    std::fs::copy(src, &dest).map_err(|e| {
279        ZigError::Io(format!(
280            "failed to copy {} → {}: {e}",
281            src.display(),
282            dest.display()
283        ))
284    })?;
285
286    let source_abs = std::fs::canonicalize(src)
287        .unwrap_or_else(|_| src.to_path_buf())
288        .display()
289        .to_string();
290
291    let entry = MemoryEntry {
292        name: file_name.clone(),
293        file: file_name,
294        description: description.map(str::to_string),
295        tags: tags.to_vec(),
296        step: step.map(str::to_string),
297        source: source_abs,
298        added: Utc::now(),
299    };
300
301    manifest.entries.insert(id.to_string(), entry);
302    save_manifest(&dir, &manifest)?;
303
304    println!(
305        "added memory entry id={id} '{}' to {}",
306        manifest.entries[&id.to_string()].name,
307        target.label()
308    );
309
310    if description.is_none() {
311        eprintln!("hint: add a description with `zig memory update {id} --description \"...\"`");
312    }
313
314    Ok(id)
315}
316
317/// Update metadata for an existing memory entry.
318pub fn update(
319    id: u64,
320    workflow: Option<&str>,
321    name: Option<&str>,
322    description: Option<&str>,
323    tags: Option<&[String]>,
324) -> Result<(), ZigError> {
325    let (dir, label, mut manifest, _entry) = find_entry_across_tiers(id, workflow)?;
326    let id_str = id.to_string();
327
328    let entry = manifest
329        .entries
330        .get_mut(&id_str)
331        .ok_or_else(|| ZigError::Io(format!("memory entry {id} vanished during update")))?;
332
333    if let Some(n) = name {
334        // Rename the file on disk if the name changed.
335        let old_path = dir.join(&entry.file);
336        let new_path = dir.join(n);
337        if old_path != new_path {
338            if new_path.exists() {
339                return Err(ZigError::Io(format!(
340                    "cannot rename: '{}' already exists in {}",
341                    n,
342                    dir.display()
343                )));
344            }
345            std::fs::rename(&old_path, &new_path).map_err(|e| {
346                ZigError::Io(format!(
347                    "failed to rename {} → {}: {e}",
348                    old_path.display(),
349                    new_path.display()
350                ))
351            })?;
352            entry.file = n.to_string();
353        }
354        entry.name = n.to_string();
355    }
356    if let Some(d) = description {
357        entry.description = Some(d.to_string());
358    }
359    if let Some(t) = tags {
360        entry.tags = t.to_vec();
361    }
362
363    save_manifest(&dir, &manifest)?;
364    println!("updated memory entry id={id} in {label}");
365    Ok(())
366}
367
368/// Delete a memory entry and its file.
369pub fn delete(id: u64, workflow: Option<&str>) -> Result<(), ZigError> {
370    let (dir, label, mut manifest, entry) = find_entry_across_tiers(id, workflow)?;
371    let id_str = id.to_string();
372
373    let file_path = dir.join(&entry.file);
374    if file_path.is_file() {
375        std::fs::remove_file(&file_path)
376            .map_err(|e| ZigError::Io(format!("failed to remove {}: {e}", file_path.display())))?;
377    }
378
379    manifest.entries.remove(&id_str);
380    save_manifest(&dir, &manifest)?;
381    println!("deleted memory entry id={id} '{}' from {label}", entry.name);
382    Ok(())
383}
384
385/// Show metadata and contents of a memory entry.
386pub fn show(id: u64, workflow: Option<&str>) -> Result<(), ZigError> {
387    let (dir, label, _manifest, entry) = find_entry_across_tiers(id, workflow)?;
388    let file_path = dir.join(&entry.file);
389
390    println!("id:          {id}");
391    println!("name:        {}", entry.name);
392    println!("tier:        {label}");
393    println!("source:      {}", entry.source);
394    println!(
395        "added:       {}",
396        entry.added.format("%Y-%m-%d %H:%M:%S UTC")
397    );
398    if let Some(ref desc) = entry.description {
399        println!("description: {desc}");
400    }
401    if !entry.tags.is_empty() {
402        println!("tags:        {}", entry.tags.join(", "));
403    }
404    if let Some(ref step) = entry.step {
405        println!("step:        {step}");
406    }
407
408    if file_path.is_file() {
409        let contents = std::fs::read_to_string(&file_path)
410            .map_err(|e| ZigError::Io(format!("failed to read {}: {e}", file_path.display())))?;
411        println!("\n--- contents ({}) ---", file_path.display());
412        print!("{contents}");
413        if !contents.ends_with('\n') {
414            println!();
415        }
416    } else {
417        println!("\n(file not found: {})", file_path.display());
418    }
419
420    Ok(())
421}
422
423/// List all memory entries across all tiers.
424pub fn list(workflow: Option<&str>) -> Result<(), ZigError> {
425    let mut rows: Vec<(String, String, String, String, String, String)> = Vec::new();
426
427    let dirs = candidate_dirs(workflow);
428    for (label, dir) in &dirs {
429        if !dir.is_dir() {
430            continue;
431        }
432        let manifest = load_manifest(dir)?;
433        for (id_str, entry) in &manifest.entries {
434            let desc = entry
435                .description
436                .as_deref()
437                .unwrap_or("")
438                .chars()
439                .take(50)
440                .collect::<String>();
441            let tags = entry.tags.join(", ");
442            rows.push((
443                id_str.clone(),
444                entry.name.clone(),
445                tags,
446                desc,
447                label.clone(),
448                entry.step.clone().unwrap_or_default(),
449            ));
450        }
451    }
452
453    if rows.is_empty() {
454        println!("No memory entries found.");
455        println!("Hint: add one with `zig memory add <file> [--workflow <name>]`");
456        return Ok(());
457    }
458
459    let id_w = rows.iter().map(|r| r.0.len()).max().unwrap_or(2).max(2);
460    let name_w = rows.iter().map(|r| r.1.len()).max().unwrap_or(4).max(4);
461    let tags_w = rows.iter().map(|r| r.2.len()).max().unwrap_or(4).max(4);
462    let tier_w = rows.iter().map(|r| r.4.len()).max().unwrap_or(4).max(4);
463
464    println!(
465        "{:<id_w$}  {:<name_w$}  {:<tags_w$}  {:<tier_w$}  DESCRIPTION",
466        "ID", "NAME", "TAGS", "TIER",
467    );
468    for (id, name, tags, desc, tier, _step) in &rows {
469        println!(
470            "{:<id_w$}  {:<name_w$}  {:<tags_w$}  {:<tier_w$}  {desc}",
471            id, name, tags, tier,
472        );
473    }
474
475    Ok(())
476}
477
478// =====================================================================
479// Search
480// =====================================================================
481
482/// Full-text search across all memory files.
483pub fn search(query: &str, scope: SearchScope, workflow: Option<&str>) -> Result<(), ZigError> {
484    let query_lower = query.to_lowercase();
485    let mut found = false;
486
487    let dirs = candidate_dirs(workflow);
488    for (label, dir) in &dirs {
489        if !dir.is_dir() {
490            continue;
491        }
492        let manifest = load_manifest(dir)?;
493        for (id_str, entry) in &manifest.entries {
494            let file_path = dir.join(&entry.file);
495            if !file_path.is_file() {
496                continue;
497            }
498            let content = match std::fs::read_to_string(&file_path) {
499                Ok(c) => c,
500                Err(_) => continue,
501            };
502            if !content.to_lowercase().contains(&query_lower) {
503                continue;
504            }
505
506            let matches = extract_matches(&content, &query_lower, scope);
507            for m in matches {
508                if !found {
509                    found = true;
510                }
511                println!(
512                    "[id:{} {} {}:{}]",
513                    id_str,
514                    label,
515                    entry.name,
516                    m.line_number.unwrap_or(0)
517                );
518                println!("{}", m.text);
519                println!();
520            }
521        }
522    }
523
524    if !found {
525        println!("No matches found for '{query}'.");
526    }
527
528    Ok(())
529}
530
531struct MatchFragment {
532    text: String,
533    line_number: Option<usize>,
534}
535
536fn extract_matches(content: &str, query_lower: &str, scope: SearchScope) -> Vec<MatchFragment> {
537    match scope {
538        SearchScope::Sentence => extract_sentences(content, query_lower),
539        SearchScope::Paragraph => extract_paragraphs(content, query_lower),
540        SearchScope::Section => extract_sections(content, query_lower),
541        SearchScope::File => extract_file(content, query_lower),
542    }
543}
544
545fn extract_sentences(content: &str, query_lower: &str) -> Vec<MatchFragment> {
546    let mut results = Vec::new();
547    // Track character offset → line number mapping.
548    let line_starts: Vec<usize> = std::iter::once(0)
549        .chain(content.match_indices('\n').map(|(i, _)| i + 1))
550        .collect();
551
552    let find_line = |byte_offset: usize| -> usize {
553        match line_starts.binary_search(&byte_offset) {
554            Ok(i) => i + 1,
555            Err(i) => i,
556        }
557    };
558
559    // Split on sentence-ending punctuation followed by whitespace or EOF.
560    let chars: Vec<char> = content.chars().collect();
561    let mut byte_pos = 0;
562    let mut sentence_start_byte = 0;
563
564    for (i, &ch) in chars.iter().enumerate() {
565        let ch_len = ch.len_utf8();
566        if (ch == '.' || ch == '!' || ch == '?')
567            && (i + 1 >= chars.len() || chars[i + 1].is_whitespace())
568        {
569            let sentence_end_byte = byte_pos + ch_len;
570            let sentence = &content[sentence_start_byte..sentence_end_byte];
571            if sentence.to_lowercase().contains(query_lower) {
572                results.push(MatchFragment {
573                    text: sentence.trim().to_string(),
574                    line_number: Some(find_line(sentence_start_byte)),
575                });
576            }
577            sentence_start_byte = sentence_end_byte;
578        }
579        byte_pos += ch_len;
580    }
581
582    // Handle trailing text without sentence-ending punctuation.
583    if sentence_start_byte < content.len() {
584        let sentence = &content[sentence_start_byte..];
585        if sentence.to_lowercase().contains(query_lower) {
586            results.push(MatchFragment {
587                text: sentence.trim().to_string(),
588                line_number: Some(find_line(sentence_start_byte)),
589            });
590        }
591    }
592
593    results
594}
595
596fn extract_paragraphs(content: &str, query_lower: &str) -> Vec<MatchFragment> {
597    let mut results = Vec::new();
598    let mut line_num = 1;
599
600    for paragraph in content.split("\n\n") {
601        if paragraph.to_lowercase().contains(query_lower) {
602            results.push(MatchFragment {
603                text: paragraph.trim().to_string(),
604                line_number: Some(line_num),
605            });
606        }
607        // Count lines in this paragraph + the 2 newlines of the separator.
608        line_num += paragraph.matches('\n').count() + 2;
609    }
610
611    results
612}
613
614fn extract_sections(content: &str, query_lower: &str) -> Vec<MatchFragment> {
615    let mut results = Vec::new();
616    let mut sections: Vec<(usize, String)> = Vec::new();
617
618    let mut current_start_line = 1;
619    let mut current_section = String::new();
620    let mut line_num = 0;
621
622    for line in content.lines() {
623        line_num += 1;
624        if line.starts_with("## ") && !current_section.is_empty() {
625            sections.push((current_start_line, current_section.clone()));
626            current_section.clear();
627            current_start_line = line_num;
628        }
629        if !current_section.is_empty() {
630            current_section.push('\n');
631        }
632        current_section.push_str(line);
633    }
634    if !current_section.is_empty() {
635        sections.push((current_start_line, current_section));
636    }
637
638    for (start_line, section) in sections {
639        if section.to_lowercase().contains(query_lower) {
640            results.push(MatchFragment {
641                text: section.trim().to_string(),
642                line_number: Some(start_line),
643            });
644        }
645    }
646
647    results
648}
649
650fn extract_file(content: &str, query_lower: &str) -> Vec<MatchFragment> {
651    if content.to_lowercase().contains(query_lower) {
652        vec![MatchFragment {
653            text: content.trim().to_string(),
654            line_number: Some(1),
655        }]
656    } else {
657        vec![]
658    }
659}
660
661// =====================================================================
662// Memory collector for system prompt injection
663// =====================================================================
664
665/// Run-time collector for memory entries, similar to `ResourceCollector`.
666pub struct MemoryCollector {
667    pub global_shared_dir: Option<PathBuf>,
668    pub global_workflow_dir: Option<PathBuf>,
669    pub cwd_memory_dir: Option<PathBuf>,
670    /// Workflow-level memory mode (from `.zwf` file).
671    pub workflow_mode: MemoryMode,
672    /// Whether project-local memory is enabled globally.
673    pub local_enabled: bool,
674    /// When true, all tiers are skipped (e.g., `--no-memory` flag).
675    pub disabled: bool,
676}
677
678impl MemoryCollector {
679    /// Build a collector from the environment.
680    pub fn from_env(
681        workflow_name: &str,
682        workflow_mode: MemoryMode,
683        config: &ZigConfig,
684        disabled: bool,
685    ) -> Self {
686        Self {
687            global_shared_dir: paths::global_shared_memory_dir(),
688            global_workflow_dir: paths::global_memory_for(workflow_name),
689            cwd_memory_dir: paths::cwd_memory_dir(),
690            workflow_mode,
691            local_enabled: config.memory.local,
692            disabled,
693        }
694    }
695
696    /// Collect memory entries for a specific step, respecting mode overrides.
697    ///
698    /// Returns `(abs_path, id_string, entry)` tuples for rendering.
699    pub fn collect_for_step(
700        &self,
701        step_memory: Option<&str>,
702    ) -> Result<Vec<(PathBuf, String, MemoryEntry)>, ZigError> {
703        if self.disabled {
704            return Ok(Vec::new());
705        }
706
707        // Step mode overrides workflow mode.
708        let effective_mode = if step_memory.is_some() {
709            MemoryMode::from_str_opt(step_memory)
710        } else {
711            self.workflow_mode
712        };
713
714        if effective_mode == MemoryMode::None {
715            return Ok(Vec::new());
716        }
717
718        let mut entries = Vec::new();
719        let include_local = effective_mode == MemoryMode::All && self.local_enabled;
720
721        // Global shared tier.
722        if let Some(dir) = self.global_shared_dir.as_deref() {
723            collect_from_dir(dir, &mut entries)?;
724        }
725
726        // Global per-workflow tier.
727        if let Some(dir) = self.global_workflow_dir.as_deref() {
728            collect_from_dir(dir, &mut entries)?;
729        }
730
731        // Project-local tier.
732        if include_local {
733            if let Some(dir) = self.cwd_memory_dir.as_deref() {
734                collect_from_dir(dir, &mut entries)?;
735            }
736        }
737
738        Ok(entries)
739    }
740}
741
742fn collect_from_dir(
743    dir: &Path,
744    out: &mut Vec<(PathBuf, String, MemoryEntry)>,
745) -> Result<(), ZigError> {
746    if !dir.is_dir() {
747        return Ok(());
748    }
749    let manifest = load_manifest(dir)?;
750    for (id_str, entry) in &manifest.entries {
751        let abs_path = dir.join(&entry.file);
752        if abs_path.is_file() {
753            out.push((abs_path, id_str.clone(), entry.clone()));
754        }
755    }
756    Ok(())
757}
758
759/// Render a `<memory>` block to prepend to a system prompt.
760///
761/// Returns an empty string when there are no entries.
762pub fn render_memory_block(
763    entries: &[(PathBuf, String, MemoryEntry)],
764    workflow_name: &str,
765    step_name: Option<&str>,
766) -> String {
767    if entries.is_empty() {
768        return String::new();
769    }
770
771    let mut out = String::from("<memory>\n");
772    out.push_str(
773        "You have access to the following memory files — a scratch pad of accumulated knowledge. \
774         Read them with your file tools when relevant.\n",
775    );
776
777    // Build the hint command with pre-filled --workflow and optional --step.
778    let step_flag = step_name
779        .map(|s| format!(" --step {s}"))
780        .unwrap_or_default();
781    out.push_str(&format!(
782        "To add new memories: `zig memory add <path> --workflow {workflow_name}{step_flag}`\n"
783    ));
784    out.push_str(
785        "To update metadata: `zig memory update <id> --description \"...\" --tags \"...\"`\n\n",
786    );
787
788    for (path, id, entry) in entries {
789        out.push_str("- ");
790        out.push_str(&path.display().to_string());
791        if let Some(desc) = &entry.description {
792            out.push_str(&format!(" (id: {id}) — {desc}"));
793        } else {
794            out.push_str(&format!(
795                " (id: {id}, no description — run: zig memory update {id} --description \"...\")"
796            ));
797        }
798        if !entry.tags.is_empty() {
799            out.push_str(&format!(" [{}]", entry.tags.join(", ")));
800        }
801        out.push('\n');
802    }
803    out.push_str("</memory>\n\n");
804    out
805}
806
807#[cfg(test)]
808#[path = "memory_tests.rs"]
809mod tests;