memory: resolve repo memory by memory marker

This commit is contained in:
Keisuke Hirata 2026-06-07 16:52:19 +09:00
parent afd683ac06
commit 9ed6613a94
No known key found for this signature in database
2 changed files with 80 additions and 26 deletions

View File

@ -165,17 +165,19 @@ pub struct WebFetchConfig {
} }
/// Memory subsystem configuration. Presence in the manifest enables /// Memory subsystem configuration. Presence in the manifest enables
/// memory; the workspace root defaults to the Pod's pwd unless an /// memory; `workspace_root` pins the memory workspace explicitly. When it
/// explicit override is given. /// is absent, memory resolution searches upward from the Pod's pwd for a
/// `.yoi/memory` marker rather than treating `.yoi` project records alone
/// as a memory root.
/// ///
/// All fields are `Option`; defaults are applied at the consumer /// All fields are `Option`; defaults are applied at the consumer
/// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple /// (`.unwrap_or(defaults::...)`). This keeps cascade `merge` simple
/// (`upper.x.or(self.x)`) without a separate partial/resolved split. /// (`upper.x.or(self.x)`) without a separate partial/resolved split.
#[derive(Debug, Clone, Default, Serialize, Deserialize)] #[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MemoryConfig { pub struct MemoryConfig {
/// Override for the workspace root. When `None`, the Pod's pwd /// Override for the memory workspace root. When `None`, consumers resolve
/// (resolved at construction time) is used. When set, must be an /// the root from their default path and ancestor `.yoi/memory` markers.
/// absolute path. /// When set, must be an absolute path.
#[serde(default)] #[serde(default)]
pub workspace_root: Option<PathBuf>, pub workspace_root: Option<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` / /// Maximum number of records returned by `MemoryQuery` /

View File

@ -1,10 +1,9 @@
//! Workspace-level path layout for the memory subsystem. //! Workspace-level path layout for the memory subsystem.
//! //!
//! `WorkspaceLayout` carries the workspace root (typically the Pod's //! `WorkspaceLayout` carries the root used by the memory subsystem.
//! pwd). All yoi-managed content lives under the conventional //! All yoi-managed memory content lives under the conventional
//! `<root>/.yoi/` subdirectory — the same place that holds //! `<root>/.yoi/` subdirectory — alongside workspace project records
//! `profiles.toml`, `prompts/`, workflow, knowledge, and generated //! such as workflow and generated durable memory. The trees inside it:
//! memory. The trees inside it:
//! //!
//! - `<root>/.yoi/workflow/<slug>.md` //! - `<root>/.yoi/workflow/<slug>.md`
//! - `<root>/.yoi/knowledge/<slug>.md` //! - `<root>/.yoi/knowledge/<slug>.md`
@ -18,9 +17,9 @@
//! Workflows are human-managed and live one level up under //! Workflows are human-managed and live one level up under
//! `.yoi/workflow/`. //! `.yoi/workflow/`.
//! //!
//! Configuring `[memory]` with an empty body is therefore sufficient //! `memory.workspace_root` pins this root explicitly. Without an explicit
//! for any workspace that already uses the `.yoi/` convention; no //! root, resolution searches upward from the Pod pwd for a `.yoi/memory`
//! `workspace_root` override is needed. //! marker; `.yoi` project records alone are not a memory marker.
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -82,17 +81,26 @@ impl WorkspaceLayout {
Self { root: root.into() } Self { root: root.into() }
} }
/// Resolve a layout from a `MemoryConfig`, falling back to /// Resolve a layout from a `MemoryConfig`.
/// `default_root` (typically the Pod's pwd) when the manifest does ///
/// not pin `workspace_root` explicitly. Single source of truth for /// An explicit `memory.workspace_root` is honored exactly. Without an
/// the `workspace_root.unwrap_or(pwd)` convention used across the /// explicit root, resolution searches `default_root` and its ancestors for
/// codebase (controller wiring, scope-deny build, system-prompt /// the nearest `.yoi/memory` directory. This keeps child worktrees that
/// resident-injection). /// contain `.yoi` project records such as tickets or workflows from
/// becoming independent memory roots merely because they contain `.yoi`.
///
/// If no memory marker exists, this falls back to `default_root` because
/// existing call sites require a concrete layout. That fallback is a
/// no-marker compatibility path, not a `.yoi` marker interpretation; it
/// must not be used as evidence that `.yoi` alone enables repo-local
/// memory.
pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self { pub fn resolve(cfg: &manifest::MemoryConfig, default_root: &Path) -> Self {
let root = cfg if let Some(root) = &cfg.workspace_root {
.workspace_root return Self::new(root.clone());
.clone() }
.unwrap_or_else(|| default_root.to_path_buf());
let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root) Self::new(root)
} }
@ -225,6 +233,13 @@ impl WorkspaceLayout {
} }
} }
fn find_memory_marker_root(default_root: &Path) -> Option<PathBuf> {
default_root
.ancestors()
.find(|ancestor| ancestor.join(YOI_DIR).join(MEMORY_DIR).is_dir())
.map(Path::to_path_buf)
}
fn classify_kinded_md( fn classify_kinded_md(
rel: &Path, rel: &Path,
kind: RecordKind, kind: RecordKind,
@ -257,6 +272,7 @@ fn classify_kinded_md(
mod tests { mod tests {
use super::*; use super::*;
use std::path::PathBuf; use std::path::PathBuf;
use tempfile::TempDir;
fn layout() -> WorkspaceLayout { fn layout() -> WorkspaceLayout {
WorkspaceLayout::new(PathBuf::from("/ws")) WorkspaceLayout::new(PathBuf::from("/ws"))
@ -379,9 +395,45 @@ mod tests {
} }
#[test] #[test]
fn resolve_falls_back_to_default_when_workspace_root_missing() { fn resolve_selects_nearest_ancestor_memory_marker_when_workspace_root_missing() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let child = workspace.join(".worktree/child");
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(&child).unwrap();
let cfg = manifest::MemoryConfig::default(); let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, Path::new("/fallback")); let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), Path::new("/fallback")); assert_eq!(layout.root(), workspace.as_path());
}
#[test]
fn resolve_ignores_child_project_records_without_memory_marker() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let child = workspace.join(".worktree/child");
std::fs::create_dir_all(workspace.join(".yoi/memory")).unwrap();
std::fs::create_dir_all(child.join(".yoi/tickets")).unwrap();
std::fs::create_dir_all(child.join(".yoi/workflow")).unwrap();
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), workspace.as_path());
}
#[test]
fn yoi_project_records_alone_do_not_define_memory_marker_root() {
let tmp = TempDir::new().unwrap();
let workspace = tmp.path().join("workspace");
let child = workspace.join("child");
std::fs::create_dir_all(workspace.join(".yoi/tickets")).unwrap();
std::fs::create_dir_all(workspace.join(".yoi/workflow")).unwrap();
std::fs::create_dir_all(&child).unwrap();
assert_eq!(find_memory_marker_root(&child), None);
let cfg = manifest::MemoryConfig::default();
let layout = WorkspaceLayout::resolve(&cfg, &child);
assert_eq!(layout.root(), child.as_path());
} }
} }