Base de connaissance persistante par projet, indépendante de tout modèle/CLU
et de git. Cadrage archi en §14.5 (ARCHITECTURE.md), cycle Archi→Dev→Test.
LOT A — étage 1 (.md, source de vérité)
- domaine: entité Memory (+ MemorySlug, MemoryType, MemoryFrontmatter,
MemoryLink, MemoryIndexEntry), liens [[slug]], index MEMORY.md dérivé
- port MemoryStore + MemoryError, adapter FsMemoryStore (.ideai/memory/)
- application: 7 use cases (Create/Update/List/Get/Delete/ReadIndex/
ResolveLinks), From<MemoryError> for AppError
- app-tauri: commandes + DTO, events MemorySaved/MemoryDeleted
- suppression de la variante morte DomainError::MalformedFrontmatter
LOT B — rappel adaptatif (étage 1)
- port MemoryRecall + MemoryQuery, adapter NaiveMemoryRecall (troncature
au budget de tokens, court-circuit budget-0), use case RecallMemory
LOT C — étage 2 vectoriel (structure complète, zéro dépendance lourde)
- port Embedder + EmbedderError, profils déclaratifs EmbedderProfile/
EmbedderStrategy (embedder.json)
- VectorMemoryRecall (cosinus, cache .ideai/memory/.index/ gitignoré)
- AdaptiveMemoryRecall (bascule pure should_use_vector), défaut none
- HashEmbedder (déterministe, tests), StubEmbedder (onnx/server/api)
Tests: 57 binaires verts, build + clippy --workspace sans warning.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
493 lines
17 KiB
Rust
493 lines
17 KiB
Rust
//! [`FsMemoryStore`] — file implementation of the [`MemoryStore`] port
|
|
//! (LOT A, étage 1).
|
|
//!
|
|
//! Memory notes are the project's persistent, model-agnostic knowledge base. Each
|
|
//! note is a single Markdown file with a YAML frontmatter header, stored under the
|
|
//! project's `.ideai/memory/`:
|
|
//!
|
|
//! ```text
|
|
//! <project_root>/.ideai/memory/
|
|
//! ├── MEMORY.md # aggregated index: one `- [Title](slug.md) — hook` line per note
|
|
//! └── <slug>.md # a note: YAML frontmatter + Markdown body
|
|
//! ```
|
|
//!
|
|
//! A note file looks like:
|
|
//!
|
|
//! ```text
|
|
//! ---
|
|
//! name: my-note
|
|
//! description: A one-line hook
|
|
//! metadata:
|
|
//! type: project
|
|
//! ---
|
|
//! # Body
|
|
//! ...
|
|
//! ```
|
|
//!
|
|
//! The `.md` files are the **single source of truth**; `MEMORY.md` is derived and
|
|
//! kept in sync on every [`save`](MemoryStore::save)/[`delete`](MemoryStore::delete)
|
|
//! (idempotent upsert / removal of the note's line). All I/O goes through the
|
|
//! [`FileSystem`] port, so the adapter is location-neutral (SSH/WSL work unchanged)
|
|
//! and Tauri-agnostic.
|
|
//!
|
|
//! Like the sibling stores, [`delete`](MemoryStore::delete) drops the note's line
|
|
//! from `MEMORY.md` and leaves the orphaned `<slug>.md` on disk (the [`FileSystem`]
|
|
//! port exposes no remove); since listing is index-driven, the note is effectively
|
|
//! gone.
|
|
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
|
|
use domain::markdown::MarkdownDoc;
|
|
use domain::memory::{
|
|
Memory, MemoryFrontmatter, MemoryIndexEntry, MemoryLink, MemorySlug, MemoryType,
|
|
};
|
|
use domain::ports::{
|
|
FileSystem, FsError, MemoryError, MemoryQuery, MemoryRecall, MemoryStore, RemotePath,
|
|
};
|
|
use domain::project::ProjectPath;
|
|
|
|
/// The `.ideai/` directory name inside a project root.
|
|
const IDEAI_DIR: &str = ".ideai";
|
|
|
|
/// Sub-path of the memory store inside `.ideai/`.
|
|
const MEMORY_DIR: &str = "memory";
|
|
|
|
/// Aggregated index file name inside the memory dir.
|
|
const INDEX_FILE: &str = "MEMORY.md";
|
|
|
|
/// First line of the aggregated index.
|
|
const INDEX_HEADER: &str = "# Memory Index";
|
|
|
|
/// File-backed [`MemoryStore`], composing a [`FileSystem`] port. The project root
|
|
/// is supplied **per call**, so a single instance serves every open project
|
|
/// (mirroring [`crate::store::FsSkillStore`]).
|
|
#[derive(Clone)]
|
|
pub struct FsMemoryStore {
|
|
fs: Arc<dyn FileSystem>,
|
|
}
|
|
|
|
impl FsMemoryStore {
|
|
/// Builds the store from an injected [`FileSystem`]. Directories are created
|
|
/// on first write.
|
|
#[must_use]
|
|
pub fn new(fs: Arc<dyn FileSystem>) -> Self {
|
|
Self { fs }
|
|
}
|
|
|
|
/// `<root>/.ideai/memory`.
|
|
fn dir(&self, root: &ProjectPath) -> String {
|
|
let base = root.as_str().trim_end_matches(['/', '\\']);
|
|
format!("{base}/{IDEAI_DIR}/{MEMORY_DIR}")
|
|
}
|
|
|
|
/// `<memory-dir>/<slug>.md`.
|
|
fn md_path(&self, root: &ProjectPath, slug: &MemorySlug) -> RemotePath {
|
|
RemotePath::new(format!("{}/{}.md", self.dir(root), slug.as_str()))
|
|
}
|
|
|
|
/// `<memory-dir>/MEMORY.md`.
|
|
fn index_path(&self, root: &ProjectPath) -> RemotePath {
|
|
RemotePath::new(format!("{}/{INDEX_FILE}", self.dir(root)))
|
|
}
|
|
|
|
/// Reads and parses a note by slug.
|
|
async fn load(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
|
let bytes = self
|
|
.fs
|
|
.read(&self.md_path(root, slug))
|
|
.await
|
|
.map_err(|e| match e {
|
|
FsError::NotFound(_) => MemoryError::NotFound,
|
|
other => MemoryError::Io(other.to_string()),
|
|
})?;
|
|
let text = String::from_utf8(bytes).map_err(|e| MemoryError::Io(e.to_string()))?;
|
|
parse_note(&text)
|
|
}
|
|
|
|
/// Reads the raw `MEMORY.md` text, or `None` if it does not exist yet.
|
|
async fn read_index_text(&self, root: &ProjectPath) -> Result<Option<String>, MemoryError> {
|
|
match self.fs.read(&self.index_path(root)).await {
|
|
Ok(bytes) => String::from_utf8(bytes)
|
|
.map(Some)
|
|
.map_err(|e| MemoryError::Io(e.to_string())),
|
|
Err(FsError::NotFound(_)) => Ok(None),
|
|
Err(e) => Err(MemoryError::Io(e.to_string())),
|
|
}
|
|
}
|
|
|
|
/// Rewrites `MEMORY.md` from the given entries, ensuring the dir exists.
|
|
async fn write_index(
|
|
&self,
|
|
root: &ProjectPath,
|
|
entries: &[MemoryIndexEntry],
|
|
) -> Result<(), MemoryError> {
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(self.dir(root)))
|
|
.await
|
|
.map_err(|e| MemoryError::Io(e.to_string()))?;
|
|
let text = render_index(entries);
|
|
self.fs
|
|
.write(&self.index_path(root), text.as_bytes())
|
|
.await
|
|
.map_err(|e| MemoryError::Io(e.to_string()))
|
|
}
|
|
|
|
/// Lists the slugs known to the index (index-driven listing).
|
|
async fn index_slugs(&self, root: &ProjectPath) -> Result<Vec<MemorySlug>, MemoryError> {
|
|
Ok(self
|
|
.read_index(root)
|
|
.await?
|
|
.into_iter()
|
|
.map(|e| e.slug)
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MemoryStore for FsMemoryStore {
|
|
async fn list(&self, root: &ProjectPath) -> Result<Vec<Memory>, MemoryError> {
|
|
let slugs = self.index_slugs(root).await?;
|
|
let mut out = Vec::with_capacity(slugs.len());
|
|
for slug in &slugs {
|
|
out.push(self.load(root, slug).await?);
|
|
}
|
|
Ok(out)
|
|
}
|
|
|
|
async fn get(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<Memory, MemoryError> {
|
|
self.load(root, slug).await
|
|
}
|
|
|
|
async fn save(&self, root: &ProjectPath, memory: &Memory) -> Result<(), MemoryError> {
|
|
// (1) Write the note file.
|
|
self.fs
|
|
.create_dir_all(&RemotePath::new(self.dir(root)))
|
|
.await
|
|
.map_err(|e| MemoryError::Io(e.to_string()))?;
|
|
let text = render_note(memory);
|
|
self.fs
|
|
.write(&self.md_path(root, memory.slug()), text.as_bytes())
|
|
.await
|
|
.map_err(|e| MemoryError::Io(e.to_string()))?;
|
|
|
|
// (2) Upsert the index line idempotently (same slug => one line).
|
|
let mut entries = self.read_index(root).await?;
|
|
let row = memory.index_entry();
|
|
if let Some(slot) = entries.iter_mut().find(|e| e.slug == row.slug) {
|
|
*slot = row;
|
|
} else {
|
|
entries.push(row);
|
|
}
|
|
self.write_index(root, &entries).await
|
|
}
|
|
|
|
async fn delete(&self, root: &ProjectPath, slug: &MemorySlug) -> Result<(), MemoryError> {
|
|
let mut entries = self.read_index(root).await?;
|
|
let before = entries.len();
|
|
entries.retain(|e| &e.slug != slug);
|
|
if entries.len() == before {
|
|
return Err(MemoryError::NotFound);
|
|
}
|
|
// The orphaned `<slug>.md` is left on disk (no FileSystem delete); the
|
|
// index no longer references it, so it is effectively gone.
|
|
self.write_index(root, &entries).await
|
|
}
|
|
|
|
async fn read_index(&self, root: &ProjectPath) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
|
match self.read_index_text(root).await? {
|
|
Some(text) => Ok(parse_index(&text)),
|
|
None => Ok(Vec::new()),
|
|
}
|
|
}
|
|
|
|
async fn resolve_links(
|
|
&self,
|
|
root: &ProjectPath,
|
|
slug: &MemorySlug,
|
|
) -> Result<Vec<MemoryLink>, MemoryError> {
|
|
let memory = self.load(root, slug).await?;
|
|
let known = self.index_slugs(root).await?;
|
|
// Keep only links whose target resolves to a known note (ignore broken).
|
|
Ok(memory
|
|
.outgoing_links()
|
|
.into_iter()
|
|
.filter(|link| known.contains(&link.target))
|
|
.collect())
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// NaiveMemoryRecall — the default, dependency-free MemoryRecall (LOT B).
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Heuristic divisor turning a character count into an approximate token count
|
|
/// (~4 characters per token, the usual rule of thumb). Shared by every
|
|
/// [`MemoryRecall`] adapter so the budget semantics stay identical (DRY).
|
|
pub(crate) const CHARS_PER_TOKEN: usize = 4;
|
|
|
|
/// Approximate token cost of an index entry's textual payload — the single
|
|
/// budget-cost function shared by every [`MemoryRecall`] adapter (naïve, vector,
|
|
/// adaptive), so truncation semantics are identical across them (Liskov / DRY).
|
|
pub(crate) fn entry_cost(entry: &MemoryIndexEntry) -> usize {
|
|
let chars = entry.title.len() + entry.hook.len();
|
|
chars.div_ceil(CHARS_PER_TOKEN)
|
|
}
|
|
|
|
/// Greedily takes entries in the given order while their accumulated
|
|
/// [`entry_cost`] stays within `budget`; stops at the first entry that would
|
|
/// exceed it (and drops every entry after). A `budget` of `0` yields an empty
|
|
/// vec. This is the shared truncation used by every recall adapter.
|
|
pub(crate) fn truncate_to_budget(
|
|
entries: impl IntoIterator<Item = MemoryIndexEntry>,
|
|
budget: usize,
|
|
) -> Vec<MemoryIndexEntry> {
|
|
if budget == 0 {
|
|
return Vec::new();
|
|
}
|
|
let mut spent = 0usize;
|
|
let mut out = Vec::new();
|
|
for entry in entries {
|
|
let cost = entry_cost(&entry);
|
|
if spent + cost > budget {
|
|
break;
|
|
}
|
|
spent += cost;
|
|
out.push(entry);
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Total approximate token cost of an index — a **pure** function of the entries,
|
|
/// used by [`crate::store::AdaptiveMemoryRecall`] to decide naïve vs. vector
|
|
/// recall without any I/O.
|
|
#[must_use]
|
|
pub fn index_token_size(entries: &[MemoryIndexEntry]) -> usize {
|
|
entries.iter().map(entry_cost).sum()
|
|
}
|
|
|
|
/// The default [`MemoryRecall`]: dependency-free, ignores semantic relevance.
|
|
///
|
|
/// It composes an [`Arc<dyn MemoryStore>`], reads the aggregated index via
|
|
/// [`MemoryStore::read_index`], and returns the entries **in index order**,
|
|
/// truncated to fit the query's token budget. It is the baseline against which a
|
|
/// future `VectorMemoryRecall` (LOT C) is substitutable.
|
|
///
|
|
/// ## Budget semantics
|
|
/// `token_budget` is an *approximate* budget. Each entry's cost is estimated as
|
|
/// `ceil((title.len() + hook.len()) / 4)` tokens (≈ 4 chars/token, counting only
|
|
/// the index line's textual payload). Entries are taken in order, accumulating
|
|
/// their cost; the first entry whose inclusion would exceed the budget — and every
|
|
/// entry after it — is dropped. A budget of `0` therefore yields an empty list;
|
|
/// an empty or missing memory yields an empty list without error.
|
|
#[derive(Clone)]
|
|
pub struct NaiveMemoryRecall {
|
|
store: Arc<dyn MemoryStore>,
|
|
}
|
|
|
|
impl NaiveMemoryRecall {
|
|
/// Builds the recall adapter from a composed [`MemoryStore`].
|
|
#[must_use]
|
|
pub fn new(store: Arc<dyn MemoryStore>) -> Self {
|
|
Self { store }
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl MemoryRecall for NaiveMemoryRecall {
|
|
async fn recall(
|
|
&self,
|
|
root: &ProjectPath,
|
|
query: &MemoryQuery,
|
|
) -> Result<Vec<MemoryIndexEntry>, MemoryError> {
|
|
// Budget-0 short-circuits before any I/O: a zero budget can hold no entry,
|
|
// so there is nothing to read (homogeneous with every recall adapter).
|
|
if query.token_budget == 0 {
|
|
return Ok(Vec::new());
|
|
}
|
|
let entries = self.store.read_index(root).await?;
|
|
Ok(truncate_to_budget(entries, query.token_budget))
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// On-disk format: YAML frontmatter + body, and the MEMORY.md index.
|
|
//
|
|
// We hand-roll a tiny, well-scoped YAML reader/writer for exactly the frontmatter
|
|
// shape we own (`name`, `description`, `metadata.type`). This keeps the crate free
|
|
// of a YAML dependency for a fixed, simple schema; any deviation surfaces as a
|
|
// `MemoryError::Frontmatter`.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Renders a note to its on-disk `---`-fenced frontmatter + body form.
|
|
fn render_note(memory: &Memory) -> String {
|
|
let fm = &memory.frontmatter;
|
|
format!(
|
|
"---\nname: {}\ndescription: {}\nmetadata:\n type: {}\n---\n{}",
|
|
fm.name.as_str(),
|
|
fm.description,
|
|
type_to_str(fm.r#type),
|
|
memory.body.as_str(),
|
|
)
|
|
}
|
|
|
|
/// Parses a note from its on-disk form.
|
|
fn parse_note(text: &str) -> Result<Memory, MemoryError> {
|
|
let rest = text
|
|
.strip_prefix("---\n")
|
|
.or_else(|| text.strip_prefix("---\r\n"))
|
|
.ok_or_else(|| MemoryError::Frontmatter("missing opening `---` fence".to_string()))?;
|
|
|
|
// Find the closing `---` fence at the start of a line.
|
|
let (fm_block, body) = split_frontmatter(rest)
|
|
.ok_or_else(|| MemoryError::Frontmatter("missing closing `---` fence".to_string()))?;
|
|
|
|
let frontmatter = parse_frontmatter(fm_block)?;
|
|
Memory::new(frontmatter, MarkdownDoc::new(body))
|
|
.map_err(|e| MemoryError::Frontmatter(e.to_string()))
|
|
}
|
|
|
|
/// Splits the post-opening-fence text into `(frontmatter_block, body)` at the
|
|
/// closing `---` line. Returns `None` if no closing fence is present.
|
|
fn split_frontmatter(rest: &str) -> Option<(&str, &str)> {
|
|
let mut offset = 0;
|
|
for line in rest.split_inclusive('\n') {
|
|
let trimmed = line.trim_end_matches(['\n', '\r']);
|
|
if trimmed == "---" {
|
|
let fm = &rest[..offset];
|
|
let body = &rest[offset + line.len()..];
|
|
return Some((fm, body));
|
|
}
|
|
offset += line.len();
|
|
}
|
|
None
|
|
}
|
|
|
|
/// Parses the frontmatter key/values into a validated [`MemoryFrontmatter`].
|
|
fn parse_frontmatter(block: &str) -> Result<MemoryFrontmatter, MemoryError> {
|
|
let err = |reason: &str| MemoryError::Frontmatter(reason.to_string());
|
|
|
|
let mut name: Option<String> = None;
|
|
let mut description: Option<String> = None;
|
|
let mut type_str: Option<String> = None;
|
|
let mut in_metadata = false;
|
|
|
|
for raw in block.lines() {
|
|
if raw.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let indented = raw.starts_with(' ') || raw.starts_with('\t');
|
|
let (key, value) = raw
|
|
.split_once(':')
|
|
.ok_or_else(|| err("frontmatter line missing `:`"))?;
|
|
let key = key.trim();
|
|
let value = value.trim();
|
|
|
|
if !indented {
|
|
in_metadata = false;
|
|
match key {
|
|
"name" => name = Some(value.to_string()),
|
|
"description" => description = Some(value.to_string()),
|
|
"metadata" => {
|
|
in_metadata = true;
|
|
if !value.is_empty() {
|
|
return Err(err("`metadata` must be a nested block"));
|
|
}
|
|
}
|
|
_ => return Err(err("unknown frontmatter key")),
|
|
}
|
|
} else if in_metadata && key == "type" {
|
|
type_str = Some(value.to_string());
|
|
} else {
|
|
return Err(err("unexpected indented frontmatter line"));
|
|
}
|
|
}
|
|
|
|
let name = name.ok_or_else(|| err("missing `name`"))?;
|
|
let description = description.ok_or_else(|| err("missing `description`"))?;
|
|
let type_str = type_str.ok_or_else(|| err("missing `metadata.type`"))?;
|
|
|
|
let name = MemorySlug::new(name).map_err(|e| MemoryError::Frontmatter(e.to_string()))?;
|
|
let r#type = str_to_type(&type_str).ok_or_else(|| err("unknown `metadata.type` value"))?;
|
|
|
|
Ok(MemoryFrontmatter {
|
|
name,
|
|
description,
|
|
r#type,
|
|
})
|
|
}
|
|
|
|
/// Renders the aggregated `MEMORY.md` index.
|
|
fn render_index(entries: &[MemoryIndexEntry]) -> String {
|
|
let mut out = String::from(INDEX_HEADER);
|
|
out.push('\n');
|
|
if !entries.is_empty() {
|
|
out.push('\n');
|
|
for e in entries {
|
|
out.push_str(&format!(
|
|
"- [{}]({}.md) — {}\n",
|
|
e.title,
|
|
e.slug.as_str(),
|
|
e.hook
|
|
));
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parses the `MEMORY.md` index lines back into structured entries. Lines that do
|
|
/// not match the `- [Title](slug.md) — hook` shape are skipped (tolerant read);
|
|
/// the `type` is not stored in the index line and defaults to
|
|
/// [`MemoryType::Reference`].
|
|
fn parse_index(text: &str) -> Vec<MemoryIndexEntry> {
|
|
let mut out = Vec::new();
|
|
for line in text.lines() {
|
|
let line = line.trim();
|
|
if !line.starts_with("- [") {
|
|
continue;
|
|
}
|
|
if let Some(entry) = parse_index_line(line) {
|
|
out.push(entry);
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Parses one `- [Title](slug.md) — hook` line.
|
|
fn parse_index_line(line: &str) -> Option<MemoryIndexEntry> {
|
|
let rest = line.strip_prefix("- [")?;
|
|
let (title, rest) = rest.split_once("](")?;
|
|
let (target, rest) = rest.split_once(')')?;
|
|
let slug_str = target.strip_suffix(".md").unwrap_or(target);
|
|
let slug = MemorySlug::new(slug_str).ok()?;
|
|
let hook = rest.trim_start().strip_prefix('—').unwrap_or(rest).trim();
|
|
Some(MemoryIndexEntry {
|
|
slug,
|
|
title: title.to_string(),
|
|
hook: hook.to_string(),
|
|
r#type: MemoryType::Reference,
|
|
})
|
|
}
|
|
|
|
/// Maps a [`MemoryType`] to its YAML/string form (matches serde camelCase).
|
|
fn type_to_str(t: MemoryType) -> &'static str {
|
|
match t {
|
|
MemoryType::User => "user",
|
|
MemoryType::Feedback => "feedback",
|
|
MemoryType::Project => "project",
|
|
MemoryType::Reference => "reference",
|
|
}
|
|
}
|
|
|
|
/// Parses a [`MemoryType`] from its string form.
|
|
fn str_to_type(s: &str) -> Option<MemoryType> {
|
|
match s {
|
|
"user" => Some(MemoryType::User),
|
|
"feedback" => Some(MemoryType::Feedback),
|
|
"project" => Some(MemoryType::Project),
|
|
"reference" => Some(MemoryType::Reference),
|
|
_ => None,
|
|
}
|
|
}
|