refactor: remove repository-local yoi authority

This commit is contained in:
2026-09-22 22:52:41 +09:00
parent 043cb7bf22
commit 5fb3cb20cd
45 changed files with 487 additions and 9151 deletions
-7
View File
@@ -7,19 +7,12 @@ license.workspace = true
[dependencies]
async-trait = { workspace = true }
chrono = { version = "0.4", features = ["serde"] }
libc = { workspace = true }
lint-common = { workspace = true }
agen = { workspace = true }
manifest = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
serde_yaml = "0.9.34"
thiserror = { workspace = true }
tracing = { workspace = true }
uuid = { workspace = true, features = ["v7", "serde"] }
[dev-dependencies]
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
+9 -17
View File
@@ -2,28 +2,20 @@
## Role
`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations.
`memory` owns the shared types used by Workspace-backed Memory operations:
- Memory API request/response DTOs
- extraction candidate and evidence schemas
- audit event schemas
- pure Memory document frontmatter schemas and parsing helpers
## Boundaries
Owns:
The crate does **not** own persistence. Workspace Memory records, staging candidates, and audit observations are stored by the Workspace Server control plane and accessed by Workers through typed API operations.
- memory/record parsing and validation
- memory lint subcommand backend behavior
- staging and consolidation file mechanics
- audit log observation writes
- generated memory prompt support where it belongs below the CLI
The crate intentionally contains no repository-local layout, cwd/ancestor discovery, resident-file reader, staging writer, audit/usage log writer, generic filesystem scope helper, or local backend executor. The product CLI does not expose `yoi memory lint`.
Does not own:
- authoritative project records (`.yoi/tickets/`, git history)
- normal Worker turn orchestration (`agen`)
- product CLI command shape (`yoi`)
- curated workflow definitions (`workflow`)
## Design notes
Memory is useful context, not authority. It should preserve small durable preferences and rationale without duplicating tickets, docs, or implementation reports.
Memory is useful context, not implementation authority. Tickets, Objectives, repository files, Git history, and append-only session records remain the exact evidence surfaces for implementation state.
## See also
+3 -214
View File
@@ -1,26 +1,12 @@
//! Append-only JSONL audit log for memory workers and tools.
//! Typed Memory audit events sent to the Workspace authority.
//!
//! The log is evidence-only observability data under
//! `.yoi/memory/_logs/current.log`. It is intentionally separate from
//! `_staging` and `_usage`, and consolidation never consumes it. Operators can
//! follow the latest stream with:
//!
//! ```text
//! tail -f .yoi/memory/_logs/current.log
//! ```
use std::collections::BTreeMap;
use std::fs::{self, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
//! Runtime Workers construct these DTOs and submit them through the Memory
//! backend operation boundary. Repository-local audit logs are not an authority.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use uuid::Uuid;
use crate::workspace::WorkspaceLayout;
fn is_zero_usize(value: &usize) -> bool {
*value == 0
}
@@ -260,200 +246,3 @@ impl AuditEvent {
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordSnapshot {
pub kind: String,
pub slug: String,
pub path: PathBuf,
pub hash: String,
}
/// Append one audit event to `.yoi/memory/_logs/current.log`.
pub fn append_audit_event(layout: &WorkspaceLayout, event: &AuditEvent) -> io::Result<()> {
let path = layout.audit_current_log_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(event)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
writeln!(file, "{line}")?;
Ok(())
}
pub fn append_worker_lifecycle(
layout: &WorkspaceLayout,
audit: WorkerLifecycleAudit,
) -> io::Result<()> {
append_audit_event(
layout,
&AuditEvent::new(AuditPayload::WorkerLifecycle(audit)),
)
}
pub fn append_record_operation(
layout: &WorkspaceLayout,
audit: RecordOperationAudit,
) -> io::Result<()> {
append_audit_event(
layout,
&AuditEvent::new(AuditPayload::RecordOperation(audit)),
)
}
pub fn append_record_usage(layout: &WorkspaceLayout, audit: RecordUsageAudit) -> io::Result<()> {
append_audit_event(layout, &AuditEvent::new(AuditPayload::RecordUsage(audit)))
}
pub fn file_hash(path: &Path) -> io::Result<Option<String>> {
match fs::read(path) {
Ok(bytes) => Ok(Some(hash_bytes(&bytes))),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
pub fn hash_bytes(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity("sha256:".len() + digest.len() * 2);
out.push_str("sha256:");
for byte in digest {
use std::fmt::Write as _;
let _ = write!(&mut out, "{byte:02x}");
}
out
}
pub fn snapshot_records(layout: &WorkspaceLayout) -> BTreeMap<String, RecordSnapshot> {
let mut out = BTreeMap::new();
snapshot_one(&mut out, "summary", "summary", layout.summary_path());
snapshot_dir(&mut out, "decision", layout.decisions_dir());
snapshot_dir(&mut out, "request", layout.requests_dir());
out
}
pub fn operation_counts_from_snapshots(
before: &BTreeMap<String, RecordSnapshot>,
after: &BTreeMap<String, RecordSnapshot>,
) -> OperationCounts {
let mut counts = OperationCounts::default();
for (key, after_record) in after {
match before.get(key) {
None => counts.write += 1,
Some(before_record) if before_record.hash != after_record.hash => counts.edit += 1,
Some(_) => {}
}
}
for key in before.keys() {
if !after.contains_key(key) {
counts.delete += 1;
}
}
counts
}
fn snapshot_dir(out: &mut BTreeMap<String, RecordSnapshot>, kind: &str, dir: PathBuf) {
let entries = match fs::read_dir(dir) {
Ok(entries) => entries,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
continue;
};
let Some(slug) = name.strip_suffix(".md").map(str::to_string) else {
continue;
};
snapshot_one(out, kind, &slug, path);
}
}
fn snapshot_one(out: &mut BTreeMap<String, RecordSnapshot>, kind: &str, slug: &str, path: PathBuf) {
if !path.is_file() {
return;
}
let Ok(Some(hash)) = file_hash(&path) else {
return;
};
out.insert(
format!("{kind}/{slug}"),
RecordSnapshot {
kind: kind.to_string(),
slug: slug.to_string(),
path,
hash,
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn appends_jsonl_to_current_log() {
let (_dir, layout) = setup();
let run_id = Uuid::now_v7();
append_worker_lifecycle(
&layout,
WorkerLifecycleAudit {
run_id,
worker: AuditWorker::MemoryExtract,
status: WorkerLifecycleStatus::Started,
trigger: AuditTrigger::TokenThreshold,
reason: "tokens_threshold_reached".to_string(),
memory_settings: None,
model: None,
usage: None,
extract: None,
consolidation: None,
},
)
.unwrap();
let text = fs::read_to_string(layout.audit_current_log_path()).unwrap();
let value: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
assert_eq!(value["event"], "worker_lifecycle");
assert_eq!(value["worker"], "memory_extract");
assert_eq!(value["status"], "started");
assert_eq!(value["run_id"], run_id.to_string());
}
#[test]
fn counts_created_edited_deleted_records() {
let (dir, layout) = setup();
let decision_dir = dir.path().join(".yoi/memory/decisions");
fs::create_dir_all(&decision_dir).unwrap();
fs::write(decision_dir.join("a.md"), "old").unwrap();
fs::write(decision_dir.join("gone.md"), "old").unwrap();
let before = snapshot_records(&layout);
fs::write(decision_dir.join("a.md"), "new").unwrap();
fs::remove_file(decision_dir.join("gone.md")).unwrap();
fs::write(decision_dir.join("created.md"), "new").unwrap();
let after = snapshot_records(&layout);
let counts = operation_counts_from_snapshots(&before, &after);
assert_eq!(counts.write, 1);
assert_eq!(counts.edit, 1);
assert_eq!(counts.delete, 1);
}
#[test]
fn hash_has_sha256_prefix() {
assert_eq!(hash_bytes(b"abc").len(), "sha256:".len() + 64);
assert!(hash_bytes(b"abc").starts_with("sha256:"));
}
}
+6 -372
View File
@@ -1,26 +1,15 @@
//! Workspace-backend operations for Memory tools.
//! Typed transport boundary for Workspace-backed Memory operations.
//!
//! These types are the typed HTTP boundary used by runtime workers that have
//! Workspace authority but no direct local filesystem authority. The local
//! executor intentionally lives in the memory crate so the workspace backend and
//! future non-HTTP hosts share validation and path semantics.
//! Runtime Workers serialize these DTOs to the Workspace API. Persistence and
//! validation are owned by the Workspace authority; this crate deliberately has
//! no repository-local filesystem executor.
use std::fs;
use std::io;
use std::io::Write;
use chrono::Utc;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::audit::{AuditEvent, append_audit_event};
use crate::consolidate::list_staging_entries_snapshot;
use crate::extract::{
ExtractedCandidate, ExtractedPayload, StagingEvidence, write_staging, write_staging_candidate,
};
use crate::audit::AuditEvent;
use crate::extract::{ExtractedCandidate, ExtractedPayload, StagingEvidence};
use crate::schema::{SourceEvidenceRef, SourceRef};
use crate::workspace::WorkspaceLayout;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "operation", rename_all = "snake_case")]
@@ -166,19 +155,6 @@ pub struct MemoryConsolidationOutput {
pub total_bytes: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct MemoryStagingCloseDispositionRecord {
schema_version: u32,
candidate_id: String,
staging_path: String,
recorded_at: String,
action: MemoryStagingCloseAction,
reason: String,
affected_memory: Vec<MemoryStagingAffectedMemory>,
}
const STAGING_RESOLUTIONS_FILE: &str = "_resolutions.jsonl";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryBackendAckOutput {
pub summary: String,
@@ -189,345 +165,3 @@ pub struct MemoryStagingWriteOutput {
pub staging_count: usize,
pub staging_ids: Vec<String>,
}
pub fn execute_memory_backend_operation(
layout: &WorkspaceLayout,
operation: MemoryBackendOperation,
) -> io::Result<MemoryBackendOperationResult> {
match operation {
MemoryBackendOperation::Query(operation) => Ok(MemoryBackendOperationResult::ToolOutput(
execute_query(layout, operation),
)),
MemoryBackendOperation::ReadDocument(_operation) => Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory document operations require WorkspaceAuthority-backed executor",
)),
MemoryBackendOperation::UpdateDocument(_operation) => Err(io::Error::new(
io::ErrorKind::Unsupported,
"Memory document operations require WorkspaceAuthority-backed executor",
)),
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(layout)),
),
MemoryBackendOperation::AppendAudit(operation) => {
append_audit_event(layout, &operation.event)?;
Ok(MemoryBackendOperationResult::Acknowledged(
MemoryBackendAckOutput {
summary: "memory audit event appended".to_string(),
},
))
}
MemoryBackendOperation::StageCandidate(operation) => {
let written = write_staging_candidate(
layout,
operation.source,
&operation.extract_run_id,
operation.candidate,
operation.evidence,
operation.source_refs,
)?;
Ok(MemoryBackendOperationResult::StagingWritten(
MemoryStagingWriteOutput {
staging_count: 1,
staging_ids: vec![written.id.to_string()],
},
))
}
MemoryBackendOperation::StageExtracted(operation) => {
let written = write_staging(layout, operation.source, operation.payload)?;
Ok(MemoryBackendOperationResult::StagingWritten(
MemoryStagingWriteOutput {
staging_count: written.len(),
staging_ids: written.iter().map(|item| item.id.to_string()).collect(),
},
))
}
MemoryBackendOperation::StagingList(operation) => {
execute_staging_list(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::StagingRead(operation) => {
execute_staging_read(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
}
MemoryBackendOperation::StagingClose(operation) => {
execute_staging_close(layout, operation).map(MemoryBackendOperationResult::ToolOutput)
}
}
}
fn execute_resident_summary(layout: &WorkspaceLayout) -> MemoryToolOutput {
match crate::collect_resident_summary(layout) {
Some(summary) => MemoryToolOutput {
summary: "resident memory summary collected".to_string(),
content: Some(summary),
},
None => MemoryToolOutput {
summary: "resident memory summary unavailable".to_string(),
content: None,
},
}
}
fn execute_query(layout: &WorkspaceLayout, operation: MemoryQueryOperation) -> MemoryToolOutput {
let Some(document) = crate::collect_resident_summary(layout) else {
let suffix = operation
.query
.as_deref()
.map(|query| format!(" matching {query:?}"))
.unwrap_or_default();
return MemoryToolOutput {
summary: format!("No memory document content found{suffix}"),
content: None,
};
};
if !matches_query(&document, operation.query.as_deref()) {
let suffix = operation
.query
.as_deref()
.map(|query| format!(" matching {query:?}"))
.unwrap_or_default();
return MemoryToolOutput {
summary: format!("No memory document content found{suffix}"),
content: None,
};
}
tool_output_from_string(excerpt(&document, operation.query.as_deref()))
}
fn execute_staging_list(
layout: &WorkspaceLayout,
operation: MemoryStagingListOperation,
) -> io::Result<MemoryToolOutput> {
let limit = operation.limit.unwrap_or(20).min(100);
let snapshot = list_staging_entries_snapshot(layout);
let total = snapshot.entries.len();
let invalid_count = snapshot.invalid_count;
let records = snapshot
.entries
.into_iter()
.take(limit)
.map(|entry| {
serde_json::json!({
"candidate_id": entry.id.to_string(),
"bytes": entry.bytes,
"path": entry.path.display().to_string(),
"source": entry.record.source,
"kind": entry.record.kind,
"claim": entry.record.claim,
"why_useful": entry.record.why_useful,
"staleness": entry.record.staleness,
"evidence_count": entry.record.evidence.len(),
"source_ref_count": entry.record.source_refs.len(),
})
})
.collect::<Vec<_>>();
Ok(MemoryToolOutput {
summary: format!(
"Listed {} of {total} staging candidate(s); invalid_count={invalid_count}",
records.len()
),
content: Some(serde_json::to_string_pretty(&records).map_err(io::Error::other)?),
})
}
fn execute_staging_read(
layout: &WorkspaceLayout,
operation: MemoryStagingReadOperation,
) -> io::Result<MemoryToolOutput> {
let entry = find_staging_entry(layout, &operation.candidate_id)?;
Ok(MemoryToolOutput {
summary: format!("Read staging candidate {}", entry.id),
content: Some(serde_json::to_string_pretty(&entry.record).map_err(io::Error::other)?),
})
}
fn execute_staging_close(
layout: &WorkspaceLayout,
operation: MemoryStagingCloseOperation,
) -> io::Result<MemoryToolOutput> {
if operation.reason.trim().is_empty() {
return Err(invalid_input("reason is required"));
}
let entry = find_staging_entry(layout, &operation.candidate_id)?;
let disposition = MemoryStagingCloseDispositionRecord {
schema_version: 1,
candidate_id: entry.id.to_string(),
staging_path: entry.path.display().to_string(),
recorded_at: Utc::now().to_rfc3339(),
action: operation.action,
reason: operation.reason,
affected_memory: operation.affected_memory,
};
let resolutions_path = layout.memory_dir().join(STAGING_RESOLUTIONS_FILE);
if let Some(parent) = resolutions_path.parent() {
fs::create_dir_all(parent)?;
}
let mut line = serde_json::to_string(&disposition).map_err(io::Error::other)?;
line.push('\n');
fs::OpenOptions::new()
.create(true)
.append(true)
.open(&resolutions_path)?
.write_all(line.as_bytes())?;
fs::remove_file(&entry.path)?;
Ok(MemoryToolOutput {
summary: format!("Closed staging candidate {}", entry.id),
content: Some(serde_json::to_string_pretty(&disposition).map_err(io::Error::other)?),
})
}
fn find_staging_entry(
layout: &WorkspaceLayout,
candidate_id: &str,
) -> io::Result<crate::consolidate::StagingEntry> {
let candidate_id = Uuid::parse_str(candidate_id)
.map_err(|err| invalid_input(format!("invalid candidate_id: {err}")))?;
list_staging_entries_snapshot(layout)
.entries
.into_iter()
.find(|entry| entry.id == candidate_id)
.ok_or_else(|| invalid_input(format!("staging candidate not found: {candidate_id}")))
}
fn matches_query(content: &str, query: Option<&str>) -> bool {
match query.map(str::trim).filter(|query| !query.is_empty()) {
Some(query) => content.to_lowercase().contains(&query.to_lowercase()),
None => true,
}
}
fn excerpt(content: &str, query: Option<&str>) -> String {
let lines: Vec<&str> = content.lines().collect();
let Some(query) = query.map(str::trim).filter(|query| !query.is_empty()) else {
return lines
.iter()
.take(20)
.copied()
.collect::<Vec<_>>()
.join("\n");
};
let query_lower = query.to_lowercase();
let hit = lines
.iter()
.position(|line| line.to_lowercase().contains(&query_lower))
.unwrap_or(0);
let start = hit.saturating_sub(2);
let end = (hit + 3).min(lines.len());
lines[start..end].join("\n")
}
fn tool_output_from_string(value: String) -> MemoryToolOutput {
const SUMMARY_THRESHOLD: usize = 200;
if value.len() <= SUMMARY_THRESHOLD {
MemoryToolOutput {
summary: value,
content: None,
}
} else {
let lines = value.lines().count();
let first_line: String = value
.lines()
.next()
.unwrap_or("")
.chars()
.take(80)
.collect();
MemoryToolOutput {
summary: format!("{lines} lines | {first_line}…"),
content: Some(value),
}
}
}
fn invalid_input(message: impl Into<String>) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message.into())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate};
#[test]
fn consolidation_operation_rejects_caller_owned_thresholds() {
let error =
serde_json::from_value::<MemoryConsolidateStagingOperation>(serde_json::json!({
"force": false,
"threshold_files": 1,
}))
.unwrap_err();
assert!(error.to_string().contains("threshold_files"));
}
#[test]
fn staging_list_read_close_records_reason_and_deletes_candidate() {
let temp = tempfile::tempdir().unwrap();
let layout = WorkspaceLayout::resolve(temp.path());
let source = SourceRef {
segment_id: "segment-1".into(),
range: [0, 1],
};
let payload = ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Preference,
claim: "User prefers short reviews".into(),
why_useful: "Review style preference".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
};
let result = execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StageExtracted(MemoryStageExtractedOperation {
source,
payload,
}),
)
.unwrap();
let candidate_id = match result {
MemoryBackendOperationResult::StagingWritten(output) => output.staging_ids[0].clone(),
_ => panic!("expected staging write output"),
};
let list = execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StagingList(MemoryStagingListOperation { limit: Some(10) }),
)
.unwrap();
let MemoryBackendOperationResult::ToolOutput(list) = list else {
panic!("expected list tool output")
};
assert!(list.content.unwrap().contains(&candidate_id));
let read = execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StagingRead(MemoryStagingReadOperation {
candidate_id: candidate_id.clone(),
}),
)
.unwrap();
let MemoryBackendOperationResult::ToolOutput(read) = read else {
panic!("expected read tool output")
};
assert!(read.content.unwrap().contains("short reviews"));
execute_memory_backend_operation(
&layout,
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
candidate_id: candidate_id.clone(),
action: MemoryStagingCloseAction::Applied,
reason: "Merged into the Memory document.".into(),
affected_memory: vec![MemoryStagingAffectedMemory {
operation: MemoryStagingAffectedMemoryOperation::Edit,
}],
}),
)
.unwrap();
let snapshot = list_staging_entries_snapshot(&layout);
assert!(snapshot.entries.is_empty());
let resolutions =
fs::read_to_string(layout.memory_dir().join(STAGING_RESOLUTIONS_FILE)).unwrap();
assert!(resolutions.contains(&candidate_id));
assert!(resolutions.contains("Merged into the Memory document."));
}
}
-12
View File
@@ -1,12 +0,0 @@
//! Memory staging queue helpers.
//!
//! Staging candidates are consumed by the backend-managed memory-consolidation
//! Worker through MemoryStaging tools. This module only exposes bounded staging
//! listing/read support; consolidation decisions are recorded by the backend close
//! operation before a staging file is deleted.
mod staging;
pub use staging::{
StagingEntriesSnapshot, StagingEntry, list_staging_entries, list_staging_entries_snapshot,
};
-210
View File
@@ -1,210 +0,0 @@
//! `_staging/*.json` を列挙して [`StagingRecord`] に展開する読み込みヘルパー。
//!
//! consolidation 起動時のスナップショット(consumed ID list 確定)と、整理 step
//! が終わった後の cleanup の双方で使う。`.consolidation.lock` のような
//! 占有ファイルは UUIDv7 として parse できないので自然に除外される。
//!
//! [`StagingRecord`] のスキーマは extract が書き出す側 (`crate::extract`)
//! と単一の真実源 — ここでは読み出す側だけを担当する。
use std::path::PathBuf;
use uuid::Uuid;
use crate::extract::StagingRecord;
use crate::workspace::WorkspaceLayout;
/// staging に積まれている 1 件分のエントリ。`id` は UUIDv7 で、ファイル名
/// `<id>.json` を逆引きしたもの。
#[derive(Debug, Clone)]
pub struct StagingEntry {
pub id: Uuid,
pub path: PathBuf,
pub record: StagingRecord,
/// このファイルのバイト長。Backendのconsolidation閾値判定に使用する。
pub bytes: u64,
}
/// staging directory の検査結果。`entries` は current schema として読めた
/// staging のみで、`invalid_count` は `.json` だが staging として採用できなかった
/// ファイル数。
#[derive(Debug, Clone, Default)]
pub struct StagingEntriesSnapshot {
pub entries: Vec<StagingEntry>,
pub invalid_count: usize,
}
/// `<staging_dir>/*.json` を読んで UUIDv7 順に並べた [`StagingEntry`]
/// 配列を返す。staging_dir が存在しなければ空配列。読めないファイルや
/// JSON parse 失敗は `tracing::warn!` してスキップ(壊れた個別ファイルが
/// consolidation 全体を止めないように)。
pub fn list_staging_entries(layout: &WorkspaceLayout) -> Vec<StagingEntry> {
list_staging_entries_snapshot(layout).entries
}
/// `<staging_dir>/*.json` を読んで valid staging と invalid staging 件数を返す。
/// invalid は自動 migration / 削除 / archive せず、観測可能にするための件数だけを
/// 呼び出し側へ渡す。
pub fn list_staging_entries_snapshot(layout: &WorkspaceLayout) -> StagingEntriesSnapshot {
let dir = layout.staging_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => return StagingEntriesSnapshot::default(),
};
let mut out: Vec<StagingEntry> = Vec::new();
let mut invalid_count = 0;
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext != "json" {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => {
invalid_count += 1;
continue;
}
};
let id = match Uuid::parse_str(stem) {
Ok(u) => u,
Err(e) => {
invalid_count += 1;
tracing::warn!(path = %path.display(), error = %e, "failed to parse staging entry id");
continue;
}
};
let bytes = match std::fs::metadata(&path) {
Ok(m) => m.len(),
Err(_) => 0,
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
invalid_count += 1;
tracing::warn!(path = %path.display(), error = %e, "failed to read staging entry");
continue;
}
};
let record = match serde_json::from_str::<StagingRecord>(&raw) {
Ok(r) => r,
Err(e) => {
invalid_count += 1;
tracing::warn!(path = %path.display(), error = %e, "failed to parse staging entry");
continue;
}
};
out.push(StagingEntry {
id,
path,
record,
bytes,
});
}
out.sort_by_key(|e| e.id);
StagingEntriesSnapshot {
entries: out,
invalid_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::{CandidateKind, ExtractedCandidate, ExtractedPayload, write_staging};
use crate::schema::SourceRef;
fn candidate_payload(claim: &str) -> ExtractedPayload {
ExtractedPayload {
candidates: vec![ExtractedCandidate {
kind: CandidateKind::Lesson,
claim: claim.into(),
why_useful: "useful for later consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}],
}
}
fn source(segment_id: &str, range: [u64; 2]) -> SourceRef {
SourceRef {
segment_id: segment_id.into(),
range,
}
}
#[test]
fn lists_in_uuidv7_order() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let id1 = write_staging(&layout, source("s", [0, 1]), candidate_payload("one"))
.unwrap()
.remove(0)
.id;
let id2 = write_staging(&layout, source("s", [2, 3]), candidate_payload("two"))
.unwrap()
.remove(0)
.id;
let id3 = write_staging(&layout, source("s", [4, 5]), candidate_payload("three"))
.unwrap()
.remove(0)
.id;
let entries = list_staging_entries(&layout);
let ids: Vec<Uuid> = entries.iter().map(|e| e.id).collect();
assert_eq!(ids, vec![id1, id2, id3]);
}
#[test]
fn skips_lock_file_and_counts_invalid_json() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
let _id = write_staging(&layout, source("s", [0, 1]), candidate_payload("kept"))
.unwrap()
.remove(0)
.id;
// Drop a non-UUID json file, an unparsable UUID-named json file, an
// old batch-schema UUID-named json file, and a bare lock file alongside.
// Lock files are not `.json`; invalid `.json` files are surfaced
// separately instead of being mistaken for an empty staging directory.
std::fs::write(layout.staging_dir().join("not-a-uuid.json"), "{}").unwrap();
let bad_id = Uuid::now_v7();
std::fs::write(layout.staging_dir().join(format!("{bad_id}.json")), "{").unwrap();
let old_schema_id = Uuid::now_v7();
std::fs::write(
layout.staging_dir().join(format!("{old_schema_id}.json")),
serde_json::json!({
"source": {
"session_id": "legacy-session",
"range": [0, 1]
},
"requests": []
})
.to_string(),
)
.unwrap();
std::fs::write(layout.staging_dir().join(".consolidation.lock"), "{}").unwrap();
let entries = list_staging_entries(&layout);
assert_eq!(entries.len(), 1);
let snapshot = list_staging_entries_snapshot(&layout);
assert_eq!(snapshot.entries.len(), 1);
assert_eq!(snapshot.invalid_count, 3);
assert_eq!(snapshot.entries[0].record.claim, "kept");
}
#[test]
fn missing_dir_returns_empty() {
let tmp = tempfile::TempDir::new().unwrap();
let layout = WorkspaceLayout::new(tmp.path().to_path_buf());
// No staging dir at all.
assert!(list_staging_entries(&layout).is_empty());
}
}
-116
View File
@@ -1,116 +0,0 @@
//! Errors raised by the memory subsystem.
use std::path::PathBuf;
use lint_common::RecordLintError;
use thiserror::Error;
/// Top-level error for memory operations that don't fit the lint flow.
#[derive(Debug, Error)]
pub enum MemoryError {
#[error("path is not under the memory tree: {}", .0.display())]
OutsideMemoryTree(PathBuf),
#[error("path is not absolute: {}", .0.display())]
RelativePath(PathBuf),
#[error("io error at {}: {source}", .path.display())]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
impl MemoryError {
pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
Self::Io {
path: path.into(),
source,
}
}
}
/// A single Linter violation. Multiple are aggregated in a [`LintReport`].
///
/// `Display` produces a one-line message used directly in the `ToolError`
/// payload returned to the LLM.
#[derive(Debug, Clone, Error, PartialEq, Eq)]
pub enum LintError {
#[error("path is not a valid memory record location: {}", .0.display())]
InvalidPath(PathBuf),
#[error("path is for a different record kind than expected at this location: {}", .0.display())]
WrongRecordKind(PathBuf),
#[error(transparent)]
Record(#[from] RecordLintError),
#[error("missing required frontmatter field: `{0}`")]
MissingField(&'static str),
#[error("invalid value for `{field}`: {message}")]
InvalidField {
field: &'static str,
message: String,
},
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
InvalidStatus(String),
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize },
#[error("slug `{0}` already exists; use the edit tool instead of creating a new record")]
SlugAlreadyExists(String),
#[error("`{field}` references unknown {kind} slug `{slug}`")]
UnknownReference {
field: &'static str,
kind: &'static str,
slug: String,
},
#[error("`replaced_by` chain forms a cycle: {chain}")]
ReplacedByCycle { chain: String },
#[error("`replaced_by` must point to a different slug than the record itself")]
ReplacedBySelf,
}
/// A single Linter warning (non-blocking).
///
/// Warnings ride along in the `ToolOutput.summary` so the agent can act
/// on them when convenient; they never abort the write.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LintWarning {
/// Single-source record exceeds the importance/size threshold.
LowImportanceLargeRecord { chars: usize },
/// `sources` array has grown past the soft cap.
SourcesOverflow { count: usize },
/// Multiple slugs in the same kind are within Levenshtein distance 2.
SimilarSlugs(Vec<String>),
}
impl std::fmt::Display for LintWarning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::LowImportanceLargeRecord { chars } => write!(
f,
"record is large ({chars} chars) but only has 1 source — consider splitting or trimming"
),
Self::SourcesOverflow { count } => write!(
f,
"`sources` has {count} entries — consider keeping only the most recent and relying on git log for the rest"
),
Self::SimilarSlugs(slugs) => {
write!(f, "similar slugs detected (consider merging): ")?;
for (i, s) in slugs.iter().enumerate() {
if i > 0 {
write!(f, ", ")?;
}
write!(f, "{s}")?;
}
Ok(())
}
}
}
}
+4 -18
View File
@@ -1,25 +1,12 @@
//! extract: memory candidate extraction.
//! Memory candidate extraction domain types and Worker tool boundary.
//!
//! 通常 Worker の post-run hook で発火する disposable Engine と、その
//! 出力を `<workspace>/.yoi/memory/_staging/<id>.json` に書き出す
//! ヘルパーを提供する。Worker 側はこのモジュールから:
//!
//! - [`build_extract_input`] を sub-Engine の最初の user 入力に
//! - [`write_extracted_tool`] を唯一のツールとして
//! - [`write_staging`] で受け取った JSON を staging に書き出し
//!
//! の順で組み立てる。system prompt は Worker の `PromptCatalog`
//! (`WorkerPrompt::MemoryExtractSystem`) で管理される。pointer 永続化
//! (session-store の `LogEntry::Extension`、domain `"memory.extract"`)は
//! Worker 側が責務を持つ。
//!
//! 出力 JSON の wrap は [`write_staging`] が `source: { segment_id, range }`
//! を機械付与する形で担当し、LLM には source を推論させない。
//! Worker-side extraction produces typed candidates that are submitted to the
//! Workspace backend. Staging persistence is owned by that backend; this module
//! does not write repository-local files.
mod input;
mod payload;
mod pointer;
mod staging;
mod tool;
pub use input::build_extract_input;
@@ -28,7 +15,6 @@ pub use payload::{
StagingRecord,
};
pub use pointer::{ExtractPointerPayload, fold_pointer};
pub use staging::{StagingWriteResult, write_staging, write_staging_candidate};
pub use tool::{ExtractWorkerContext, write_extracted_tool};
/// session-store `LogEntry::Extension` で使う domain 名。
-142
View File
@@ -1,142 +0,0 @@
//! extract staging writer.
//!
//! Staging is flat: one file is one candidate and one consolidation decision
//! unit. The transitional extract tool still submits `ExtractedPayload` with
//! `candidates[]`; this writer expands it into one [`StagingRecord`] per
//! candidate.
use std::fs;
use std::io;
use std::path::PathBuf;
use uuid::Uuid;
use crate::extract::payload::{
ExtractedCandidate, ExtractedPayload, StagingEvidence, StagingRecord,
};
use crate::schema::{SourceEvidenceRef, SourceRef};
use crate::workspace::WorkspaceLayout;
/// Filesystem result for a single staged candidate.
#[derive(Debug, Clone)]
pub struct StagingWriteResult {
pub id: Uuid,
pub path: PathBuf,
}
/// Write one flat staging JSON file per extracted candidate.
///
/// Returns an empty vector when `payload` has no candidates.
pub fn write_staging(
layout: &WorkspaceLayout,
source: SourceRef,
payload: ExtractedPayload,
) -> io::Result<Vec<StagingWriteResult>> {
if payload.candidates.is_empty() {
return Ok(Vec::new());
}
let extract_run_id = Uuid::now_v7().to_string();
let mut written = Vec::with_capacity(payload.candidates.len());
for candidate in payload.candidates {
written.push(write_staging_candidate(
layout,
source.clone(),
&extract_run_id,
candidate,
Vec::new(),
Vec::new(),
)?);
}
Ok(written)
}
pub fn write_staging_candidate(
layout: &WorkspaceLayout,
source: SourceRef,
extract_run_id: &str,
candidate: ExtractedCandidate,
evidence: Vec<StagingEvidence>,
source_refs: Vec<SourceEvidenceRef>,
) -> io::Result<StagingWriteResult> {
let dir = layout.staging_dir();
fs::create_dir_all(&dir)?;
let id = Uuid::now_v7();
let record = StagingRecord::from_candidate(
id.to_string(),
extract_run_id.to_string(),
source,
candidate,
evidence,
source_refs,
);
let path = dir.join(format!("{}.json", id));
let bytes = serde_json::to_vec_pretty(&record).map_err(io::Error::other)?;
fs::write(&path, bytes)?;
Ok(StagingWriteResult { id, path })
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extract::payload::{CandidateKind, ExtractedCandidate};
fn layout() -> WorkspaceLayout {
let dir = tempfile::tempdir().unwrap();
// leak tempdir for the duration of the test process; sufficient for unit tests
let path = dir.keep();
WorkspaceLayout::new(path)
}
fn source() -> SourceRef {
SourceRef {
segment_id: "segment-1".into(),
range: [1, 3],
}
}
fn candidate(kind: CandidateKind, claim: &str) -> ExtractedCandidate {
ExtractedCandidate {
kind,
claim: claim.into(),
why_useful: "useful for future consolidation".into(),
staleness: None,
evidence_ids: Vec::new(),
}
}
#[test]
fn writes_one_file_per_candidate() {
let layout = layout();
let payload = ExtractedPayload {
candidates: vec![
candidate(CandidateKind::Preference, "Prefer implementation tickets"),
candidate(CandidateKind::Decision, "Use flat staging records"),
],
};
let results = write_staging(&layout, source(), payload).unwrap();
assert_eq!(results.len(), 2);
assert_ne!(results[0].id, results[1].id);
let first = fs::read_to_string(&results[0].path).unwrap();
let second = fs::read_to_string(&results[1].path).unwrap();
let first_record: StagingRecord = serde_json::from_str(&first).unwrap();
let second_record: StagingRecord = serde_json::from_str(&second).unwrap();
assert_eq!(first_record.kind, CandidateKind::Preference);
assert_eq!(second_record.kind, CandidateKind::Decision);
assert_eq!(first_record.extract_run_id, second_record.extract_run_id);
assert_eq!(first_record.source.segment_id, "segment-1");
}
#[test]
fn empty_payload_writes_nothing() {
let layout = layout();
let results = write_staging(&layout, source(), ExtractedPayload::default()).unwrap();
assert!(results.is_empty());
assert!(!layout.staging_dir().exists());
}
}
+38 -21
View File
@@ -1,30 +1,47 @@
//! Memory subsystem support shared by Worker and Workspace authority paths.
//! Shared Memory domain and Workspace API transport types.
//!
//! Normal runtime Memory access is mediated through Workspace-backed backend
//! operations. Generic filesystem tools must not touch local memory paths; Worker
//! is responsible for denying them at the Scope level when memory is enabled.
//! Normal Memory persistence is owned by the Workspace control plane. This crate
//! intentionally contains no repository-local `.yoi` layout, discovery, reader,
//! writer, or fallback implementation.
pub mod audit;
pub mod backend;
pub mod consolidate;
pub mod error;
pub mod extract;
pub mod linter;
pub mod resident;
pub mod schema;
pub mod scope;
pub mod usage;
pub mod workspace;
pub use error::{LintError, LintWarning, MemoryError};
pub use extract::ExtractPointerPayload;
pub use lint_common::{RecordLintError, Slug, is_valid_slug};
pub use linter::{LintReport, Linter};
pub use resident::collect_resident_summary;
pub use scope::deny_write_rules;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
append_resident_exposure_event, append_usage_event, append_use_event, build_usage_report,
snapshot_record_from_bytes, snapshot_record_from_layout,
};
pub use workspace::WorkspaceLayout;
#[cfg(test)]
mod authority_tests {
#[test]
fn production_source_has_no_repository_local_memory_provider() {
let production = [
include_str!("lib.rs")
.split_once("#[cfg(test)]\nmod authority_tests")
.map(|(production, _)| production)
.expect("Memory authority test module marker"),
include_str!("backend.rs"),
include_str!("audit.rs"),
include_str!("extract/mod.rs"),
include_str!("extract/input.rs"),
include_str!("extract/payload.rs"),
include_str!("extract/pointer.rs"),
include_str!("extract/tool.rs"),
]
.join("\n");
for forbidden in [
"WorkspaceLayout",
"execute_memory_backend_operation",
"collect_resident_summary",
"deny_write_rules",
"write_staging_candidate",
".yoi/memory",
] {
assert!(
!production.contains(forbidden),
"repository-local Memory provider returned through {forbidden}"
);
}
}
}
-121
View File
@@ -1,121 +0,0 @@
//! Walks `<workspace>/memory/{decisions,requests}/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
//! No caching: each lint call walks fresh. Tree size is expected to
//! stay small (hundreds of files, not thousands).
use std::collections::{HashMap, HashSet};
use std::io;
use std::path::Path;
use crate::Slug;
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
use crate::workspace::{RecordKind, WorkspaceLayout};
/// Snapshot of every record currently on disk under the workspace.
///
/// Carries enough metadata to answer:
/// - "does slug X of kind K exist?" (same-slug duplication, reference checks)
/// - "what is X's `replaced_by`?" (cycle detection)
/// - "what other slugs of kind K exist?" (similar-slug warning)
#[derive(Debug, Default, Clone)]
pub struct ExistingRecords {
decisions: HashMap<Slug, DecisionMeta>,
requests: HashSet<Slug>,
}
#[derive(Debug, Clone)]
pub struct DecisionMeta {
pub replaced_by: Option<Slug>,
}
impl ExistingRecords {
pub fn contains(&self, kind: RecordKind, slug: &Slug) -> bool {
match kind {
RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug),
RecordKind::Summary => false,
}
}
pub fn decision(&self, slug: &Slug) -> Option<&DecisionMeta> {
self.decisions.get(slug)
}
pub fn slugs(&self, kind: RecordKind) -> Vec<&Slug> {
match kind {
RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(),
RecordKind::Summary => Vec::new(),
}
}
}
/// Walk the workspace and collect every record.
pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
let mut out = ExistingRecords::default();
scan_dir(&layout.decisions_dir(), |path, slug| {
let meta = read_decision_meta(path);
out.decisions.insert(slug, meta);
})?;
scan_dir(&layout.requests_dir(), |path, slug| {
// Parse to validate but discard contents — only slug existence
// matters for reference checks. Parse failure is silently
// ignored: existing record corruption isn't this write's
// responsibility to fix.
let _ = parse_silent::<RequestFrontmatter>(path);
out.requests.insert(slug);
})?;
Ok(out)
}
fn scan_dir<F>(dir: &Path, mut visit: F) -> io::Result<()>
where
F: FnMut(&Path, Slug),
{
let entries = match std::fs::read_dir(dir) {
Ok(e) => e,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()),
Err(err) => return Err(err),
};
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let stem = match path.file_stem().and_then(|s| s.to_str()) {
Some(s) => s,
None => continue,
};
let ext = path.extension().and_then(|s| s.to_str()).unwrap_or("");
if ext != "md" {
continue;
}
if let Ok(slug) = Slug::parse(stem) {
visit(&path, slug);
}
}
Ok(())
}
fn read_decision_meta(path: &Path) -> DecisionMeta {
match parse_silent::<DecisionFrontmatter>(path) {
Some(fm) => DecisionMeta {
replaced_by: fm.replaced_by,
},
None => DecisionMeta { replaced_by: None },
}
}
fn parse_silent<F>(path: &Path) -> Option<F>
where
F: serde::de::DeserializeOwned,
{
let content = std::fs::read_to_string(path).ok()?;
let (yaml, _) = split_frontmatter(&content).ok()?;
serde_yaml::from_str::<F>(yaml).ok()
}
-64
View File
@@ -1,64 +0,0 @@
//! YAML frontmatter parsing helpers shared by every kind.
use lint_common::RecordLintError;
use serde::de::DeserializeOwned;
use crate::error::LintError;
/// Strict YAML deserialization that maps serde errors into the linter's
/// `MissingField` / `InvalidField` / `MalformedFrontmatter` taxonomy
/// when possible.
pub fn deserialize_strict<F: DeserializeOwned>(yaml: &str) -> Result<F, LintError> {
serde_yaml::from_str::<F>(yaml).map_err(map_serde_error)
}
fn map_serde_error(err: serde_yaml::Error) -> LintError {
let msg = err.to_string();
// `missing field \`X\`` is the exact pattern serde uses for missing
// required fields. Hoist into the typed variant so the LLM sees a
// crisp message it can act on.
if let Some(field) = parse_missing_field(&msg) {
return LintError::MissingField(field);
}
if let Some((field, message)) = parse_invalid_status(&msg) {
if field == "status" {
return LintError::InvalidStatus(message);
}
return LintError::InvalidField { field, message };
}
LintError::Record(RecordLintError::MalformedFrontmatter(msg))
}
fn parse_missing_field(msg: &str) -> Option<&'static str> {
let needle = "missing field `";
let start = msg.find(needle)? + needle.len();
let end = msg[start..].find('`')? + start;
let field_name = &msg[start..end];
static FIELDS: &[&str] = &[
"created_at",
"updated_at",
"sources",
"status",
"kind",
"description",
"requires",
];
FIELDS.iter().copied().find(|n| *n == field_name)
}
fn parse_invalid_status(msg: &str) -> Option<(&'static str, String)> {
// serde renders enum failures as: "unknown variant `Foo`, expected one of ..."
// We can't reliably attribute it to a specific field from the message
// alone, so we conservatively label it as `status` only when the
// message mentions one of the DecisionStatus variants in the
// expected set.
if msg.contains("unknown variant") && msg.contains("`open`") {
let needle = "unknown variant `";
let start = msg.find(needle)? + needle.len();
let end = msg[start..].find('`')? + start;
let bad = msg[start..end].to_string();
return Some(("status", bad));
}
None
}
-447
View File
@@ -1,447 +0,0 @@
//! Pre-write Linter for the memory subsystem.
//!
//! The linter is pure: given a [`WorkspaceLayout`], a target path, and
//! the proposed file content (raw bytes), it returns a [`LintReport`]
//! aggregating every applicable rule violation. The memory tool calls
//! this *before* committing to fs and surfaces a non-empty `errors`
//! collection back to the LLM as `ToolError::InvalidArgument`.
//!
//! Reference-integrity checks (`replaced_by` / `requires` existence,
//! cycle detection) walk the whole `memory/` tree
//! each call. No caching; the trees are expected to be small.
mod existing;
mod frontmatter;
mod references;
mod size;
mod warnings;
use std::path::Path;
use lint_common::RecordLintError;
use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning};
use crate::schema::{
DecisionFrontmatter, RequestFrontmatter, SummaryFrontmatter, split_frontmatter,
};
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
pub use existing::{ExistingRecords, scan_existing};
/// Aggregated linter result. `errors` empty ⇒ write proceeds.
#[derive(Debug, Default, Clone)]
pub struct LintReport {
pub errors: Vec<LintError>,
pub warnings: Vec<LintWarning>,
}
impl LintReport {
pub fn has_errors(&self) -> bool {
!self.errors.is_empty()
}
pub fn extend_errors(&mut self, more: impl IntoIterator<Item = LintError>) {
self.errors.extend(more);
}
pub fn push_error(&mut self, err: LintError) {
self.errors.push(err);
}
pub fn push_warning(&mut self, w: LintWarning) {
self.warnings.push(w);
}
}
/// Operation context: is this a brand-new file or an update of an
/// existing one? Affects same-slug duplication check.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WriteMode {
Create,
Update,
}
/// Stateless entry point holding the workspace layout. Cheap to clone.
#[derive(Debug, Clone)]
pub struct Linter {
layout: WorkspaceLayout,
}
impl Linter {
pub fn new(layout: WorkspaceLayout) -> Self {
Self { layout }
}
pub fn layout(&self) -> &WorkspaceLayout {
&self.layout
}
/// Lint a proposed write to `path` with the given UTF-8 `content`.
///
/// `mode` tells the linter whether the path already existed at the
/// moment of write — Create triggers same-slug duplication checks,
/// Update doesn't.
pub fn lint(&self, path: &Path, content: &str, mode: WriteMode) -> LintReport {
let mut report = LintReport::default();
// 1. Path classification.
let classified = match self.layout.classify(path) {
Ok(Some(cp)) => cp,
Ok(None) => {
report.push_error(LintError::InvalidPath(path.to_path_buf()));
return report;
}
Err(e) => {
report.push_error(e);
return report;
}
};
// 3. Frontmatter parse + kind-specific structural checks +
// size limits. Reference-integrity needs the existing
// record set, fetched once below.
let existing = match existing::scan_existing(&self.layout) {
Ok(e) => e,
Err(e) => {
report.push_error(LintError::Record(RecordLintError::MalformedFrontmatter(
format!("failed to scan existing records: {e}"),
)));
return report;
}
};
// Same-slug check on Create.
if mode == WriteMode::Create {
if let Some(slug) = &classified.slug {
if existing.contains(classified.kind, slug) {
report.push_error(LintError::SlugAlreadyExists(slug.to_string()));
}
}
}
// Similar-slug clustering warning. Skipped for Summary (no slug).
if let Some(slug) = &classified.slug {
warnings::check_similar_slugs(slug, classified.kind, &existing, &mut report);
}
// Frontmatter parse dispatch by kind.
match classified.kind {
RecordKind::Decision => {
self.check_decision(content, &classified, &existing, &mut report);
}
RecordKind::Request => {
self.check_request(content, &classified, &mut report);
}
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
}
report
}
fn check_kind<F>(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport)
where
F: DeserializeOwned + crate::schema::Frontmatter,
{
let parsed = match parse_frontmatter::<F>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
let body = parsed.body;
size::check_body::<F>(body, report);
warnings::check_warnings_kindless(cp, body, report);
let _ = parsed.frontmatter; // discarded after structural checks
}
fn check_request(&self, content: &str, _cp: &ClassifiedPath, report: &mut LintReport) {
let parsed = match parse_frontmatter::<RequestFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
size::check_body::<RequestFrontmatter>(parsed.body, report);
warnings::check_warnings_with_sources(
parsed.body,
parsed.frontmatter.sources.len(),
report,
);
}
fn check_decision(
&self,
content: &str,
cp: &ClassifiedPath,
existing: &ExistingRecords,
report: &mut LintReport,
) {
let parsed = match parse_frontmatter::<DecisionFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
let fm = parsed.frontmatter;
size::check_body::<DecisionFrontmatter>(parsed.body, report);
// replaced_by structural rules.
if let Some(target) = &fm.replaced_by {
if let Some(self_slug) = &cp.slug {
if target == self_slug {
report.push_error(LintError::ReplacedBySelf);
}
}
references::check_replaced_by(cp.slug.as_ref(), target, existing, report);
}
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
}
}
struct Parsed<'a, F> {
frontmatter: F,
body: &'a str,
}
fn parse_frontmatter<F: DeserializeOwned>(content: &str) -> Result<Parsed<'_, F>, LintError> {
let (yaml, body) = split_frontmatter(content)?;
let fm = frontmatter::deserialize_strict::<F>(yaml)?;
Ok(Parsed {
frontmatter: fm,
body,
})
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::PathBuf;
use tempfile::TempDir;
fn write(p: &std::path::Path, content: &str) {
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(p, content).unwrap();
}
fn iso_now() -> String {
Utc::now().to_rfc3339()
}
fn workspace() -> (TempDir, Linter) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
let linter = Linter::new(layout);
(dir, linter)
}
#[test]
fn outside_memory_tree_rejected() {
let (dir, linter) = workspace();
let path = dir.path().join("src/main.rs");
let report = linter.lint(&path, "ignored", WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::InvalidPath(_)))
);
}
#[test]
fn decision_with_unknown_replaced_by_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: ghost\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::UnknownReference { .. }))
);
}
#[test]
fn decision_replaced_by_self_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: foo\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Update);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::ReplacedBySelf))
);
}
#[test]
fn decision_replaced_by_existing_ok() {
let (dir, linter) = workspace();
// Pre-create the target.
let target = dir.path().join(".yoi/memory/decisions/bar.md");
write(
&target,
&format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nbar body\n",
now = iso_now()
),
);
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: replaced\nreplaced_by: bar\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn missing_required_field_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
// Missing `status`.
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(report.errors.iter().any(|e| matches!(
e,
LintError::MissingField(_)
| LintError::Record(RecordLintError::MalformedFrontmatter(_))
)));
}
#[test]
fn summary_path_accepted() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/summary.md");
let content = format!(
"---\nupdated_at: {now}\n---\nsummary body\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Update);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn create_when_existing_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
write(
&path,
&format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nold\n",
now = iso_now()
),
);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\nnew\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::SlugAlreadyExists(_)))
);
}
#[test]
fn similar_slugs_warns_on_cluster() {
let (dir, linter) = workspace();
// Two existing decisions within Levenshtein 2 of `db-pool`:
// `db-pol` (1 deletion), `db-pools` (1 insertion).
for slug in ["db-pol", "db-pools"] {
write(
&dir.path().join(format!(".yoi/memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join(".yoi/memory/decisions/db-pool.md");
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\nbody\n",
n = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
let warned = report
.warnings
.iter()
.any(|w| matches!(w, LintWarning::SimilarSlugs(slugs) if slugs.len() >= 3));
assert!(
warned,
"expected SimilarSlugs warning, got {:?}",
report.warnings
);
}
#[test]
fn similar_slugs_silent_when_distant() {
let (dir, linter) = workspace();
for slug in ["alpha", "bravo"] {
write(
&dir.path().join(format!(".yoi/memory/decisions/{slug}.md")),
&format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
),
);
}
let path = dir.path().join(".yoi/memory/decisions/charlie.md");
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nsources: []\nstatus: open\n---\n",
n = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
!report
.warnings
.iter()
.any(|w| matches!(w, LintWarning::SimilarSlugs(_))),
"unexpected SimilarSlugs warning: {:?}",
report.warnings
);
}
#[test]
fn body_size_limit_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/memory/decisions/foo.md");
let big_body = "x".repeat(8001);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nsources: []\nstatus: open\n---\n{body}",
now = iso_now(),
body = big_body
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::BodyTooLong { .. }))
);
// Sanity: ensure path was treated as PathBuf consistently.
let _ = PathBuf::from(path);
}
}
-67
View File
@@ -1,67 +0,0 @@
//! Reference-integrity checks: `replaced_by` existence + cycle detection.
use std::collections::HashSet;
use crate::Slug;
use crate::error::LintError;
use crate::linter::ExistingRecords;
use crate::linter::LintReport;
use crate::workspace::RecordKind;
/// Validate a Decision's `replaced_by` against the existing record set.
///
/// `self_slug` is the slug of the record currently being written (None
/// only when the path was malformed and we shouldn't even reach here).
pub fn check_replaced_by(
self_slug: Option<&Slug>,
target: &Slug,
existing: &ExistingRecords,
report: &mut LintReport,
) {
// Existence: target must already be a Decision on disk.
if !existing.contains(RecordKind::Decision, target) {
report.push_error(LintError::UnknownReference {
field: "replaced_by",
kind: "decision",
slug: target.to_string(),
});
return;
}
// Cycle: walk the chain target → target.replaced_by → ... and
// ensure we never revisit `self_slug` or any node twice.
let mut visited = HashSet::new();
if let Some(s) = self_slug {
visited.insert(s.clone());
}
let mut cursor = Some(target.clone());
let mut chain: Vec<String> = Vec::new();
while let Some(node) = cursor {
if !visited.insert(node.clone()) {
chain.push(node.to_string());
report.push_error(LintError::ReplacedByCycle {
chain: chain.join(" -> "),
});
return;
}
chain.push(node.to_string());
cursor = existing.decision(&node).and_then(|m| m.replaced_by.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
// Smoke test: cycle detection terminates on a 2-node loop where the
// existing tree already contains A↔B and the new write would close
// the loop. A direct unit test against `check_replaced_by` is
// exercised by linter::tests; here we just guard the loop bound.
#[test]
fn empty_chain_terminates() {
let mut report = LintReport::default();
let existing = ExistingRecords::default();
let target = Slug::parse("foo").unwrap();
check_replaced_by(None, &target, &existing, &mut report);
assert_eq!(report.errors.len(), 1);
}
}
-15
View File
@@ -1,15 +0,0 @@
//! Body size limit checks.
use crate::error::LintError;
use crate::linter::LintReport;
use crate::schema::Frontmatter;
pub fn check_body<F: Frontmatter>(body: &str, report: &mut LintReport) {
let chars = body.chars().count();
if chars > F::BODY_LIMIT {
report.push_error(LintError::BodyTooLong {
actual: chars,
limit: F::BODY_LIMIT,
});
}
}
-106
View File
@@ -1,106 +0,0 @@
//! Soft warnings: low-importance large records, sources accumulation.
//!
//! Similar-slug warnings need the existing record set and are
//! integrated into the main linter pass when implemented; this file
//! covers per-write checks that only need the proposed content.
use crate::Slug;
use crate::error::LintWarning;
use crate::linter::LintReport;
use crate::linter::existing::ExistingRecords;
use crate::workspace::{ClassifiedPath, RecordKind};
const LARGE_BODY_THRESHOLD: usize = 1500;
const SOURCES_OVERFLOW_THRESHOLD: usize = 10;
const SIMILAR_SLUG_DISTANCE: usize = 2;
/// Cluster size (including the new slug) at which the similar-slug
/// warning fires. 3 follows `docs/plan/memory.md` §Linter (`類似 slug
/// 乱立`) — two existing close neighbours plus the new write.
const SIMILAR_SLUG_CLUSTER_MIN: usize = 3;
/// For kinds that don't carry a `sources` array (Summary), emit only
/// the body-size warning.
pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut LintReport) {
let _ = body;
// Summary intentionally has no warning band — the per-record
// size:importance heuristic doesn't apply to a single rolling file.
}
/// For kinds with `sources`, consult
/// both the body length and the sources count.
pub fn check_warnings_with_sources(body: &str, source_count: usize, report: &mut LintReport) {
let chars = body.chars().count();
if source_count <= 1 && chars >= LARGE_BODY_THRESHOLD {
report.push_warning(LintWarning::LowImportanceLargeRecord { chars });
}
if source_count > SOURCES_OVERFLOW_THRESHOLD {
report.push_warning(LintWarning::SourcesOverflow {
count: source_count,
});
}
}
/// Emit a `SimilarSlugs` warning when the proposed slug joins a cluster
/// of `SIMILAR_SLUG_CLUSTER_MIN` or more slugs in the same kind that
/// are pairwise within `SIMILAR_SLUG_DISTANCE` Levenshtein steps of the
/// new one. The reported list includes the new slug, sorted to keep
/// the warning text deterministic.
pub fn check_similar_slugs(
new_slug: &Slug,
kind: RecordKind,
existing: &ExistingRecords,
report: &mut LintReport,
) {
let mut neighbours: Vec<String> = existing
.slugs(kind)
.into_iter()
.filter(|s| *s != new_slug)
.filter(|s| levenshtein(new_slug.as_str(), s.as_str()) <= SIMILAR_SLUG_DISTANCE)
.map(|s| s.to_string())
.collect();
if neighbours.len() + 1 < SIMILAR_SLUG_CLUSTER_MIN {
return;
}
neighbours.push(new_slug.to_string());
neighbours.sort();
report.push_warning(LintWarning::SimilarSlugs(neighbours));
}
/// Iterative two-row Levenshtein distance over chars.
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
if b.is_empty() {
return a.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut curr: Vec<usize> = vec![0; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
curr[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = if ca == cb { 0 } else { 1 };
curr[j + 1] = (curr[j] + 1).min(prev[j + 1] + 1).min(prev[j] + cost);
}
std::mem::swap(&mut prev, &mut curr);
}
prev[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn levenshtein_basics() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("a", ""), 1);
assert_eq!(levenshtein("", "ab"), 2);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("foo", "foo"), 0);
assert_eq!(levenshtein("abc", "abd"), 1);
assert_eq!(levenshtein("abcd", "acbd"), 2);
}
}
-91
View File
@@ -1,91 +0,0 @@
//! Workspace memory resident-enumeration helpers.
//!
//! Surfaces used by the Worker system-prompt assembler:
//!
//! - [`collect_resident_summary`] — the body of
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
//! record and has non-empty body.
//!
//! Files that fail to read or parse are skipped silently — the Linter
//! enforces shape on write, so a malformed file here means external
//! tampering and we'd rather degrade than panic.
use crate::schema::{SummaryFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
/// Read `<workspace>/.yoi/memory/summary.md` for resident prompt
/// injection. Returns only the markdown body (frontmatter stripped), and
/// degrades to `None` for missing, unreadable, malformed, or empty records.
pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option<String> {
let raw = std::fs::read_to_string(layout.summary_path()).ok()?;
let (yaml, body) = split_frontmatter(&raw).ok()?;
let _fm: SummaryFrontmatter = serde_yaml::from_str(yaml).ok()?;
let body = body.trim_matches(&['\n', '\r'][..]);
if body.trim().is_empty() {
None
} else {
Some(body.to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use std::path::Path;
use tempfile::TempDir;
fn now() -> String {
Utc::now().to_rfc3339()
}
fn write_summary(dir: &Path, body: &str) {
let path = dir.join(".yoi/memory/summary.md");
let content = format!("---\nupdated_at: {n}\n---\n{body}", n = now());
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn missing_summary_returns_none() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn summary_returns_body_without_frontmatter() {
let (dir, layout) = setup();
write_summary(dir.path(), "remember this\n");
let got = collect_resident_summary(&layout).unwrap();
assert_eq!(got, "remember this");
assert!(!got.contains("updated_at"));
assert!(!got.contains("---"));
}
#[test]
fn malformed_summary_returns_none() {
let (dir, layout) = setup();
std::fs::write(
dir.path().join(".yoi/memory/summary.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn empty_summary_body_returns_none() {
let (dir, layout) = setup();
write_summary(dir.path(), " \n");
assert!(collect_resident_summary(&layout).is_none());
}
}
+5 -13
View File
@@ -1,10 +1,9 @@
//! Common frontmatter helpers and shared types.
use lint_common::RecordLintError;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::error::LintError;
pub use lint_common::Frontmatter;
/// Reference to a session-store entry range. Stored in `sources` /
@@ -138,14 +137,13 @@ pub struct SourceEvidenceRef {
}
/// Split a markdown document into `(yaml_frontmatter, body)`.
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), LintError> {
lint_common::split_frontmatter(content).map_err(Into::into)
pub fn split_frontmatter(content: &str) -> Result<(&str, &str), RecordLintError> {
lint_common::split_frontmatter(content)
}
#[cfg(test)]
mod tests {
use super::*;
use lint_common::RecordLintError;
#[test]
fn splits_simple() {
@@ -158,19 +156,13 @@ mod tests {
#[test]
fn no_leading_delim_errors() {
let err = split_frontmatter("hello").unwrap_err();
assert!(matches!(
err,
LintError::Record(RecordLintError::MissingFrontmatter)
));
assert!(matches!(err, RecordLintError::MissingFrontmatter));
}
#[test]
fn no_closing_delim_errors() {
let err = split_frontmatter("---\nfoo: 1\nno close\n").unwrap_err();
assert!(matches!(
err,
LintError::Record(RecordLintError::MalformedFrontmatter(_))
));
assert!(matches!(err, RecordLintError::MalformedFrontmatter(_)));
}
#[test]
+3 -5
View File
@@ -1,9 +1,7 @@
//! Frontmatter schemas for memory records.
//! Typed frontmatter schemas for Memory documents.
//!
//! Each record kind has its own typed `*Frontmatter` struct. The linter
//! deserializes the YAML between the leading `---` markers into the
//! kind-appropriate struct; field-level errors are surfaced as
//! [`LintError::MissingField`] / [`LintError::InvalidField`].
//! These structures and pure parsing helpers are shared by Workspace-backed
//! validation. They do not select or traverse a repository-local Memory tree.
mod common;
mod decision;
-44
View File
@@ -1,44 +0,0 @@
//! Helpers for constructing `ScopeRule` entries that exclude the
//! memory tree from the generic CRUD tools' write surface.
//!
//! Worker is expected to call [`deny_write_rules`] when memory is enabled
//! and append the result to the manifest's `scope.deny` list before
//! constructing the [`Scope`] passed to the local WorkdirSession provider. The
//! memory tools themselves bypass generic WorkdirSession filesystem operations and
//! write directly under the workspace root, so this deny does not affect them.
use std::path::Path;
use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build a deny rule that strips Write permission from `<workspace>/.yoi/memory/`.
pub fn deny_write_rules(layout: &WorkspaceLayout) -> Vec<ScopeRule> {
vec![deny_write(layout.memory_dir().as_path())]
}
fn deny_write(target: &Path) -> ScopeRule {
ScopeRule {
target: target.to_path_buf(),
permission: Permission::Write,
recursive: true,
symlink_policy: Default::default(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn deny_targets_memory() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 1);
assert_eq!(rules[0].target, PathBuf::from("/ws/.yoi/memory"));
assert_eq!(rules[0].permission, Permission::Write);
assert!(rules[0].recursive);
}
}
-371
View File
@@ -1,371 +0,0 @@
//! Workspace-local usage event log for memory records.
//!
//! The log is append-only JSONL under the workspace's `.yoi/` tree. It is
//! intentionally evidence-only: aggregation reports explicit context reads and
//! resident exposure cost telemetry, but it does not classify records as
//! tidy-protected records.
use std::collections::{BTreeMap, HashMap};
use std::fs::{self, OpenOptions};
use std::io::{self, BufRead, Write};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::workspace::{RecordKind, WorkspaceLayout};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UsageEventKind {
Use,
ResidentExposure,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum UsageSource {
MemoryRead,
ResidentInjection,
}
impl UsageSource {
pub fn as_str(self) -> &'static str {
match self {
Self::MemoryRead => "MemoryRead",
Self::ResidentInjection => "ResidentInjection",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageRecordSnapshot {
pub kind: String,
pub slug: String,
pub file_bytes: u64,
pub file_tokens_estimate: u64,
}
impl UsageRecordSnapshot {
pub fn from_bytes(kind: RecordKind, slug: impl Into<String>, bytes: &[u8]) -> Self {
Self {
kind: kind.as_str().to_string(),
slug: slug.into(),
file_bytes: bytes.len() as u64,
file_tokens_estimate: estimate_tokens(bytes.len()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageEvent {
pub id: Uuid,
pub occurred_at: DateTime<Utc>,
pub segment_id: String,
pub event: UsageEventKind,
pub source: UsageSource,
pub records: Vec<UsageRecordSnapshot>,
}
impl UsageEvent {
pub fn new(
segment_id: impl Into<String>,
event: UsageEventKind,
source: UsageSource,
records: Vec<UsageRecordSnapshot>,
) -> Self {
Self {
id: Uuid::now_v7(),
occurred_at: Utc::now(),
segment_id: segment_id.into(),
event,
source,
records,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReport {
pub records: Vec<UsageReportRecord>,
}
impl UsageReport {
pub fn empty() -> Self {
Self {
records: Vec::new(),
}
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UsageReportRecord {
pub kind: String,
pub slug: String,
pub use_count: u64,
pub last_used_at: Option<DateTime<Utc>>,
pub source_breakdown: BTreeMap<String, u64>,
pub resident_exposure_count: u64,
pub estimated_tokens_per_injection: u64,
pub estimated_total_resident_exposure_tokens: u64,
}
#[derive(Debug, Default)]
struct Accumulator {
kind: String,
slug: String,
use_count: u64,
last_used_at: Option<DateTime<Utc>>,
source_breakdown: BTreeMap<String, u64>,
resident_exposure_count: u64,
last_resident_tokens: u64,
}
/// Append one usage event to the workspace-local JSONL log.
pub fn append_usage_event(layout: &WorkspaceLayout, event: &UsageEvent) -> io::Result<()> {
let path = layout.usage_events_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let line = serde_json::to_string(event)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
let mut file = OpenOptions::new().create(true).append(true).open(path)?;
writeln!(file, "{line}")?;
Ok(())
}
/// Convenience for a successful explicit record read.
pub fn append_use_event(
layout: &WorkspaceLayout,
segment_id: impl Into<String>,
source: UsageSource,
records: Vec<UsageRecordSnapshot>,
) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
append_usage_event(
layout,
&UsageEvent::new(segment_id, UsageEventKind::Use, source, records),
)
}
/// Convenience for resident model-invocation exposure cost telemetry.
pub fn append_resident_exposure_event(
layout: &WorkspaceLayout,
segment_id: impl Into<String>,
records: Vec<UsageRecordSnapshot>,
) -> io::Result<()> {
if records.is_empty() {
return Ok(());
}
append_usage_event(
layout,
&UsageEvent::new(
segment_id,
UsageEventKind::ResidentExposure,
UsageSource::ResidentInjection,
records,
),
)
}
/// Read a record from the workspace and build the snapshot stored in usage
/// events. `slug` should be `"summary"` for [`RecordKind::Summary`].
pub fn snapshot_record_from_layout(
layout: &WorkspaceLayout,
kind: RecordKind,
slug: &str,
) -> io::Result<UsageRecordSnapshot> {
let path = record_path(layout, kind, slug)?;
let bytes = fs::read(path)?;
Ok(UsageRecordSnapshot::from_bytes(
kind,
slug.to_string(),
&bytes,
))
}
pub fn snapshot_record_from_bytes(
kind: RecordKind,
slug: impl Into<String>,
bytes: &[u8],
) -> UsageRecordSnapshot {
UsageRecordSnapshot::from_bytes(kind, slug, bytes)
}
fn record_path(
layout: &WorkspaceLayout,
kind: RecordKind,
slug: &str,
) -> io::Result<std::path::PathBuf> {
match kind {
RecordKind::Summary => Ok(layout.summary_path()),
RecordKind::Decision => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.decision_path(&slug))
}
RecordKind::Request => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.request_path(&slug))
}
}
}
fn invalid_slug_error(err: lint_common::RecordLintError) -> io::Error {
io::Error::new(io::ErrorKind::InvalidInput, err)
}
/// Aggregate the append-only usage log into per-record evidence.
pub fn build_usage_report(layout: &WorkspaceLayout) -> io::Result<UsageReport> {
let path = layout.usage_events_path();
if !path.exists() {
return Ok(UsageReport::empty());
}
let file = fs::File::open(path)?;
let reader = io::BufReader::new(file);
let mut acc: HashMap<(String, String), Accumulator> = HashMap::new();
for line in reader.lines() {
let line = line?;
if line.trim().is_empty() {
continue;
}
let event: UsageEvent = match serde_json::from_str(&line) {
Ok(event) => event,
Err(err) => {
tracing::warn!(error = %err, "Skipping malformed memory usage event log line");
continue;
}
};
for record in event.records {
let key = (record.kind.clone(), record.slug.clone());
let entry = acc.entry(key).or_insert_with(|| Accumulator {
kind: record.kind.clone(),
slug: record.slug.clone(),
..Accumulator::default()
});
match event.event {
UsageEventKind::Use => {
entry.use_count += 1;
let source = event.source.as_str().to_string();
*entry.source_breakdown.entry(source).or_insert(0) += 1;
entry.last_used_at = Some(
entry
.last_used_at
.map(|prev| prev.max(event.occurred_at))
.unwrap_or(event.occurred_at),
);
}
UsageEventKind::ResidentExposure => {
entry.resident_exposure_count += 1;
entry.last_resident_tokens = record.file_tokens_estimate;
}
}
}
}
let mut records: Vec<UsageReportRecord> = acc
.into_values()
.map(|a| UsageReportRecord {
kind: a.kind,
slug: a.slug,
use_count: a.use_count,
last_used_at: a.last_used_at,
source_breakdown: a.source_breakdown,
resident_exposure_count: a.resident_exposure_count,
estimated_tokens_per_injection: a.last_resident_tokens,
estimated_total_resident_exposure_tokens: a
.last_resident_tokens
.saturating_mul(a.resident_exposure_count),
})
.collect();
records.sort_by(|a, b| (&a.kind, &a.slug).cmp(&(&b.kind, &b.slug)));
Ok(UsageReport { records })
}
fn estimate_tokens(bytes: usize) -> u64 {
(bytes as u64).div_ceil(4)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
}
#[test]
fn aggregates_use_and_resident_exposure_separately() {
let (_dir, layout) = setup();
let decision = snapshot_record_from_bytes(RecordKind::Decision, "alpha", b"abcd");
let request = snapshot_record_from_bytes(RecordKind::Request, "policy", b"abcdefgh");
append_use_event(
&layout,
"session-a",
UsageSource::MemoryRead,
vec![decision.clone()],
)
.unwrap();
append_use_event(
&layout,
"session-a",
UsageSource::MemoryRead,
vec![request.clone()],
)
.unwrap();
append_use_event(
&layout,
"session-b",
UsageSource::MemoryRead,
vec![request.clone()],
)
.unwrap();
append_resident_exposure_event(&layout, "session-b", vec![request]).unwrap();
let report = build_usage_report(&layout).unwrap();
let decision = report
.records
.iter()
.find(|r| r.kind == "decision" && r.slug == "alpha")
.unwrap();
assert_eq!(decision.use_count, 1);
assert_eq!(decision.source_breakdown["MemoryRead"], 1);
assert_eq!(decision.resident_exposure_count, 0);
assert!(decision.last_used_at.is_some());
let request = report
.records
.iter()
.find(|r| r.kind == "request" && r.slug == "policy")
.unwrap();
assert_eq!(request.use_count, 2);
assert_eq!(request.source_breakdown["MemoryRead"], 2);
assert_eq!(request.resident_exposure_count, 1);
assert_eq!(request.estimated_tokens_per_injection, 2);
assert_eq!(request.estimated_total_resident_exposure_tokens, 2);
}
#[test]
fn resident_only_record_does_not_increment_use_count() {
let (_dir, layout) = setup();
let snapshot = snapshot_record_from_bytes(RecordKind::Decision, "policy", b"abcdefgh");
append_resident_exposure_event(&layout, "session", vec![snapshot]).unwrap();
let report = build_usage_report(&layout).unwrap();
let record = &report.records[0];
assert_eq!(record.use_count, 0);
assert!(record.last_used_at.is_none());
assert!(record.source_breakdown.is_empty());
assert_eq!(record.resident_exposure_count, 1);
}
}
-363
View File
@@ -1,363 +0,0 @@
//! Workspace-level path layout for the memory subsystem.
//!
//! `WorkspaceLayout` carries the root used by the memory subsystem.
//! All yoi-managed memory content lives under the conventional
//! `<root>/.yoi/` subdirectory — alongside workspace project records
//! generated durable memory. The trees inside it:
//!
//! - `<root>/.yoi/memory/summary.md`
//! - `<root>/.yoi/memory/decisions/<slug>.md`
//! - `<root>/.yoi/memory/requests/<slug>.md`
//! - `<root>/.yoi/memory/_staging/<id>.json`
//! - `<root>/.yoi/memory/_logs/current.log` (append-only audit log)
//!
//! `memory/` is reserved for session-derived / generated state;
//! `memory.workspace_root` pins this root explicitly. Without an explicit
//! root, resolution searches upward from the Worker pwd for a `.yoi/memory`
//! marker; `.yoi` project records alone are not a memory marker.
use std::path::{Path, PathBuf};
use crate::Slug;
use crate::error::LintError;
#[cfg(test)]
use lint_common::RecordLintError;
const YOI_DIR: &str = ".yoi";
const MEMORY_DIR: &str = "memory";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
const STAGING_DIR: &str = "_staging";
const USAGE_DIR: &str = "_usage";
const LOGS_DIR: &str = "_logs";
const USAGE_EVENTS_FILE: &str = "events.jsonl";
const AUDIT_CURRENT_LOG_FILE: &str = "current.log";
/// What kind of record a path under the memory tree represents.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordKind {
Summary,
Decision,
Request,
}
impl RecordKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
}
}
}
/// A path classified into a kind and (where applicable) a slug.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ClassifiedPath {
pub kind: RecordKind,
pub slug: Option<Slug>,
}
/// Workspace-rooted layout. Cheap to clone.
#[derive(Debug, Clone)]
pub struct WorkspaceLayout {
root: PathBuf,
}
impl WorkspaceLayout {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
/// Resolve a layout from the nearest Memory marker.
///
/// Resolution searches `default_root` and its ancestors for the nearest
/// `.yoi/memory` directory. This legacy local-storage helper owns its path
/// policy directly; resolved Worker Manifests do not carry storage paths.
pub fn resolve(default_root: &Path) -> Self {
let root =
find_memory_marker_root(default_root).unwrap_or_else(|| default_root.to_path_buf());
Self::new(root)
}
pub fn root(&self) -> &Path {
&self.root
}
/// `<root>/.yoi/`. The base of every other memory path.
pub fn yoi_dir(&self) -> PathBuf {
self.root.join(YOI_DIR)
}
pub fn memory_dir(&self) -> PathBuf {
self.yoi_dir().join(MEMORY_DIR)
}
pub fn summary_path(&self) -> PathBuf {
self.memory_dir().join(SUMMARY_FILE)
}
pub fn decisions_dir(&self) -> PathBuf {
self.memory_dir().join(DECISIONS_DIR)
}
pub fn requests_dir(&self) -> PathBuf {
self.memory_dir().join(REQUESTS_DIR)
}
pub fn staging_dir(&self) -> PathBuf {
self.memory_dir().join(STAGING_DIR)
}
pub fn usage_dir(&self) -> PathBuf {
self.memory_dir().join(USAGE_DIR)
}
pub fn usage_events_path(&self) -> PathBuf {
self.usage_dir().join(USAGE_EVENTS_FILE)
}
pub fn audit_logs_dir(&self) -> PathBuf {
self.memory_dir().join(LOGS_DIR)
}
/// Tail-friendly latest memory audit log path.
///
/// Operators can inspect live memory worker and tool events with:
/// `tail -f .yoi/memory/_logs/current.log`.
pub fn audit_current_log_path(&self) -> PathBuf {
self.audit_logs_dir().join(AUDIT_CURRENT_LOG_FILE)
}
pub fn decision_path(&self, slug: &Slug) -> PathBuf {
self.decisions_dir().join(format!("{slug}.md"))
}
pub fn request_path(&self, slug: &Slug) -> PathBuf {
self.requests_dir().join(format!("{slug}.md"))
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.yoi/memory/`
/// of this workspace, or if it lives in
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
///
/// On a conventional path that's *almost* a record but malformed
/// (e.g. `.yoi/memory/decisions/Foo.md` with an invalid slug),
/// returns `Err(LintError::Record(InvalidSlug) | InvalidPath)` so the caller
/// can surface it as a write violation.
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
};
let mut comps = rel.components();
let first = match comps.next() {
Some(c) => c.as_os_str(),
None => return Err(LintError::InvalidPath(path.to_path_buf())),
};
if first == SUMMARY_FILE {
if comps.next().is_some() {
return Err(LintError::InvalidPath(path.to_path_buf()));
}
return Ok(Some(ClassifiedPath {
kind: RecordKind::Summary,
slug: None,
}));
}
if first == STAGING_DIR || first == USAGE_DIR || first == LOGS_DIR {
// Linter opts out of subsystem-owned opaque trees.
return Ok(None);
}
let kind = if first == DECISIONS_DIR {
RecordKind::Decision
} else if first == REQUESTS_DIR {
RecordKind::Request
} else {
return Err(LintError::InvalidPath(path.to_path_buf()));
};
let rest: PathBuf = comps.collect();
let cp = classify_kinded_md(&rest, kind, path)?;
Ok(Some(cp))
}
}
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(
rel: &Path,
kind: RecordKind,
full_path: &Path,
) -> Result<ClassifiedPath, LintError> {
let mut comps = rel.components();
let first = match comps.next() {
Some(c) => c,
None => return Err(LintError::InvalidPath(full_path.to_path_buf())),
};
if comps.next().is_some() {
// Subdirectories under the record kind aren't allowed.
return Err(LintError::InvalidPath(full_path.to_path_buf()));
}
let name = first.as_os_str();
let s = name
.to_str()
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
let stem = s
.strip_suffix(".md")
.ok_or_else(|| LintError::InvalidPath(full_path.to_path_buf()))?;
let slug = Slug::parse(stem)?;
Ok(ClassifiedPath {
kind,
slug: Some(slug),
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
fn layout() -> WorkspaceLayout {
WorkspaceLayout::new(PathBuf::from("/ws"))
}
#[test]
fn classifies_summary() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/summary.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Summary);
assert!(cp.slug.is_none());
}
#[test]
fn classifies_decision_with_slug() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/foo-bar.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Decision);
assert_eq!(cp.slug.unwrap().as_str(), "foo-bar");
}
#[test]
fn staging_tree_is_opaque_to_classifier() {
assert!(
layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))
.unwrap()
.is_none()
);
}
#[test]
fn usage_tree_is_opaque_to_classifier() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_usage/events.jsonl"))
.unwrap();
assert!(cp.is_none());
}
#[test]
fn logs_tree_is_opaque_to_classifier() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_logs/current.log"))
.unwrap();
assert!(cp.is_none());
}
#[test]
fn outside_returns_none() {
assert!(
layout()
.classify(&PathBuf::from("/elsewhere/file.md"))
.unwrap()
.is_none()
);
assert!(
layout()
.classify(&PathBuf::from("/ws/src/main.rs"))
.unwrap()
.is_none()
);
}
#[test]
fn invalid_slug_rejected() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/Foo.md"))
.unwrap_err();
assert!(matches!(
err,
LintError::Record(RecordLintError::InvalidSlug(_))
));
}
#[test]
fn nested_under_record_dir_rejected() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/decisions/sub/foo.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
fn unknown_top_level_dir_rejected() {
let err = layout()
.classify(&PathBuf::from("/ws/.yoi/memory/something/foo.md"))
.unwrap_err();
assert!(matches!(err, LintError::InvalidPath(_)));
}
#[test]
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 layout = WorkspaceLayout::resolve(&child);
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();
let layout = WorkspaceLayout::resolve(&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(&child).unwrap();
assert_eq!(find_memory_marker_root(&child), None);
let layout = WorkspaceLayout::resolve(&child);
assert_eq!(layout.root(), child.as_path());
}
}