feat: remove active knowledge support

This commit is contained in:
2026-07-16 07:30:00 +09:00
parent f2106407be
commit f786e01997
54 changed files with 197 additions and 1591 deletions
-1
View File
@@ -321,7 +321,6 @@ pub fn snapshot_records(layout: &WorkspaceLayout) -> BTreeMap<String, RecordSnap
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());
snapshot_dir(&mut out, "knowledge", layout.knowledge_dir());
out
}
+5 -7
View File
@@ -9,8 +9,6 @@
//! 3. Usage evidence report(明示使用回数 + resident exposure cost
//! 4. 整理材料(Linter Warn ベース、hard protection 判定はしない)
//!
//! 既存 `knowledge/*` 本文は埋めず、agent に `KnowledgeQuery` 経由で引かせる
//! 設計(`docs/plan/memory.md` §retrieval 経路 / §Consolidation の Knowledge アクセス)。
use std::fmt::Write;
@@ -29,7 +27,7 @@ pub fn build_consolidate_input(
let mut out = String::new();
out.push_str(
"consolidation input. Run the integration step first \
(fold the staging activity logs into memory and knowledge), then the \
(fold the staging activity logs into memory), then the \
tidy step (clean up existing records). Use the memory tools for \
every write — direct file writes are denied by the worker scope.\n\n",
);
@@ -100,7 +98,7 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
let dir = match kind {
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),
RecordKind::Knowledge | RecordKind::Summary => return,
RecordKind::Summary => return,
};
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
@@ -135,13 +133,13 @@ fn push_kind_records(out: &mut String, layout: &WorkspaceLayout, kind: RecordKin
fn render_usage_report(report: &UsageReport) -> String {
if report.is_empty() {
return "(empty — no explicit memory/knowledge usage events recorded yet. \
return "(empty — no explicit memory usage events recorded yet. \
Treat this as lack of evidence, not proof that records are unused.)\n"
.to_string();
}
let json = serde_json::to_string_pretty(report).unwrap_or_else(|_| "{}".to_string());
format!(
"This report is evidence only. Do not make hard Knowledge-creation or tidy-protection decisions from it alone.\n\n```json\n{json}\n```\n"
"This report is evidence only. Do not make hard tidy-protection decisions from it alone.\n\n```json\n{json}\n```\n"
)
}
@@ -276,7 +274,7 @@ mod tests {
assert!(out.contains("Replaced decisions"));
assert!(out.contains("Sources overflow"));
assert!(out.contains("Similar slug clusters"));
assert!(out.contains("no explicit memory/knowledge usage events"));
assert!(out.contains("no explicit memory usage events"));
}
#[test]
+3 -3
View File
@@ -1,19 +1,19 @@
//! consolidation: 統合 + 整理。
//!
//! extract が staging に残した活動ログを `memory/*` / `knowledge/*`
//! extract が staging に残した活動ログを `memory/*` に
//! 統合し、続けて既存 record を `outdated | superseded | unused | noisy`
//! の観点で整理する disposable Engine を、Worker 側が組み立てるための
//! ヘルパー群を提供する。Worker は次の手順で sub-Engine を構築する:
//!
//! - [`build_consolidate_input`] を sub-Engine の最初の user 入力に
//! - memory 専用 Tool (read / write / edit) と Knowledge / memory 検索ツールを登録
//! - memory 専用 Tool (read / write / edit) と memory 検索ツールを登録
//! - [`StagingLock::acquire`] で並走防止 + consumed ID 確定
//! - sub-Engine run 完了後、[`StagingLock::release_with_cleanup`] で
//! consumed ID 分の staging のみ削除し、占有ファイルを解放
//!
//! system prompt は Worker の `PromptCatalog`
//! (`WorkerPrompt::MemoryConsolidationSystem`) で管理される。Usage report は
//! 判断材料として渡すだけで、ここでは Knowledge 化や protection の hard decision はしない
//! 判断材料として渡すだけで、ここでは protection の hard decision はしない
//! `docs/plan/memory.md` §Consolidation / 整理材料)。
mod input;
+2 -22
View File
@@ -14,9 +14,7 @@
use std::collections::{BTreeMap, BTreeSet};
use crate::Slug;
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
};
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
use crate::workspace::{RecordKind, WorkspaceLayout};
/// `sources` overflow を flag する閾値。`linter::warnings::SOURCES_OVERFLOW_THRESHOLD`
@@ -69,7 +67,6 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
let decisions = read_kind_records(layout, RecordKind::Decision);
let requests = read_kind_records(layout, RecordKind::Request);
let knowledge = read_kind_records(layout, RecordKind::Knowledge);
for (slug, content) in &decisions {
let fm = parse_yaml::<DecisionFrontmatter>(content);
@@ -99,33 +96,18 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
}
}
}
for (slug, content) in &knowledge {
if let Some(fm) = parse_yaml::<KnowledgeFrontmatter>(content) {
if fm.last_sources.len() > SOURCES_OVERFLOW_THRESHOLD {
hints.sources_overflow.push(SourcesOverflow {
kind: RecordKind::Knowledge,
slug: slug.clone(),
count: fm.last_sources.len(),
});
}
}
}
hints.sources_overflow.sort_by(|a, b| {
(a.kind.as_str(), a.slug.as_str()).cmp(&(b.kind.as_str(), b.slug.as_str()))
});
let decision_slugs: Vec<&str> = decisions.keys().map(|s| s.as_str()).collect();
let request_slugs: Vec<&str> = requests.keys().map(|s| s.as_str()).collect();
let knowledge_slugs: Vec<&str> = knowledge.keys().map(|s| s.as_str()).collect();
if let Some(c) = cluster_similar(&decision_slugs, RecordKind::Decision) {
hints.similar_slug_clusters.extend(c);
}
if let Some(c) = cluster_similar(&request_slugs, RecordKind::Request) {
hints.similar_slug_clusters.extend(c);
}
if let Some(c) = cluster_similar(&knowledge_slugs, RecordKind::Knowledge) {
hints.similar_slug_clusters.extend(c);
}
hints
.similar_slug_clusters
.sort_by(|a, b| (a.kind.as_str(), &a.slugs).cmp(&(b.kind.as_str(), &b.slugs)));
@@ -133,14 +115,12 @@ pub fn collect_tidy_hints(layout: &WorkspaceLayout) -> TidyHints {
hints
}
/// `<root>/.yoi/memory/<kind>/*.md` (Knowledge は
/// `<root>/.yoi/knowledge/*.md`) を slug ごとに `(slug, full content)`
/// `<root>/.yoi/memory/<kind>/*.md` を slug ごとに `(slug, full content)`
/// 化して返す。
fn read_kind_records(layout: &WorkspaceLayout, kind: RecordKind) -> BTreeMap<String, String> {
let dir = match kind {
RecordKind::Decision => layout.decisions_dir(),
RecordKind::Request => layout.requests_dir(),
RecordKind::Knowledge => layout.knowledge_dir(),
RecordKind::Summary => return BTreeMap::new(),
};
let mut out: BTreeMap<String, String> = BTreeMap::new();
+1 -6
View File
@@ -8,7 +8,7 @@ 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 or knowledge tree: {}", .0.display())]
#[error("path is not under the memory tree: {}", .0.display())]
OutsideMemoryTree(PathBuf),
#[error("path is not absolute: {}", .0.display())]
RelativePath(PathBuf),
@@ -56,11 +56,6 @@ pub enum LintError {
#[error("Decisions `status` must be one of open|resolved|replaced (got `{0}`)")]
InvalidStatus(String),
#[error(
"Knowledge with model_invokation: true cannot have description longer than {limit} chars (got {actual})"
)]
DescriptionTooLong { actual: usize, limit: usize },
#[error("body exceeds the size limit for this record kind: {actual} chars > {limit}")]
BodyTooLong { actual: usize, limit: usize },
+5 -9
View File
@@ -1,10 +1,9 @@
//! Memory subsystem: persistence layer for `memory/*` and `knowledge/*` records.
//! Memory subsystem: persistence layer for `memory/*` records.
//!
//! Self-contained: provides its own Tool implementations (read/write/edit)
//! that target `<workspace>/memory/` and `<workspace>/knowledge/` only,
//! with a pre-write Linter built in. Generic CRUD tools (in the `tools`
//! crate) must not touch these directories — Worker is responsible for
//! denying them at the Scope level when memory is enabled.
//! that target `<workspace>/memory/` only, with a pre-write Linter built in.
//! Generic CRUD tools (in the `tools` crate) must not touch this directory —
//! Worker is responsible for denying it at the Scope level when memory is enabled.
pub mod audit;
pub mod consolidate;
@@ -22,10 +21,7 @@ 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::{
ResidentKnowledgeEntry, collect_resident_knowledge, collect_resident_summary,
list_knowledge_slugs,
};
pub use resident::collect_resident_summary;
pub use scope::deny_write_rules;
pub use usage::{
UsageEvent, UsageEventKind, UsageRecordSnapshot, UsageReport, UsageReportRecord, UsageSource,
+2 -11
View File
@@ -1,4 +1,4 @@
//! Walks `<workspace>/memory/{decisions,requests}/` and `<workspace>/knowledge/` to collect
//! Walks `<workspace>/memory/{decisions,requests}/` to collect
//! the slug set the linter needs for reference-integrity and
//! same-slug-duplication checks.
//!
@@ -10,9 +10,7 @@ use std::io;
use std::path::Path;
use crate::Slug;
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, split_frontmatter,
};
use crate::schema::{DecisionFrontmatter, RequestFrontmatter, split_frontmatter};
use crate::workspace::{RecordKind, WorkspaceLayout};
/// Snapshot of every record currently on disk under the workspace.
@@ -25,7 +23,6 @@ use crate::workspace::{RecordKind, WorkspaceLayout};
pub struct ExistingRecords {
decisions: HashMap<Slug, DecisionMeta>,
requests: HashSet<Slug>,
knowledge: HashSet<Slug>,
}
#[derive(Debug, Clone)]
@@ -38,7 +35,6 @@ impl ExistingRecords {
match kind {
RecordKind::Decision => self.decisions.contains_key(slug),
RecordKind::Request => self.requests.contains(slug),
RecordKind::Knowledge => self.knowledge.contains(slug),
RecordKind::Summary => false,
}
}
@@ -51,7 +47,6 @@ impl ExistingRecords {
match kind {
RecordKind::Decision => self.decisions.keys().collect(),
RecordKind::Request => self.requests.iter().collect(),
RecordKind::Knowledge => self.knowledge.iter().collect(),
RecordKind::Summary => Vec::new(),
}
}
@@ -73,10 +68,6 @@ pub fn scan_existing(layout: &WorkspaceLayout) -> io::Result<ExistingRecords> {
let _ = parse_silent::<RequestFrontmatter>(path);
out.requests.insert(slug);
})?;
scan_dir(&layout.knowledge_dir(), |path, slug| {
let _ = parse_silent::<KnowledgeFrontmatter>(path);
out.knowledge.insert(slug);
})?;
Ok(out)
}
-3
View File
@@ -42,9 +42,6 @@ fn parse_missing_field(msg: &str) -> Option<&'static str> {
"status",
"kind",
"description",
"model_invokation",
"user_invocable",
"last_sources",
"requires",
];
FIELDS.iter().copied().find(|n| *n == field_name)
+2 -61
View File
@@ -7,7 +7,7 @@
//! collection back to the LLM as `ToolError::InvalidArgument`.
//!
//! Reference-integrity checks (`replaced_by` / `requires` existence,
//! cycle detection) walk the whole `memory/` and `knowledge/` trees
//! cycle detection) walk the whole `memory/` tree
//! each call. No caching; the trees are expected to be small.
mod existing;
@@ -23,8 +23,7 @@ use serde::de::DeserializeOwned;
use crate::error::{LintError, LintWarning};
use crate::schema::{
DecisionFrontmatter, KnowledgeFrontmatter, RequestFrontmatter, SummaryFrontmatter,
split_frontmatter,
DecisionFrontmatter, RequestFrontmatter, SummaryFrontmatter, split_frontmatter,
};
use crate::workspace::{ClassifiedPath, RecordKind, WorkspaceLayout};
@@ -134,9 +133,6 @@ impl Linter {
RecordKind::Request => {
self.check_request(content, &classified, &mut report);
}
RecordKind::Knowledge => {
self.check_knowledge(content, &classified, &mut report);
}
RecordKind::Summary => {
self.check_kind::<SummaryFrontmatter>(content, &classified, &mut report);
}
@@ -207,30 +203,6 @@ impl Linter {
warnings::check_warnings_with_sources(parsed.body, fm.sources.len(), report);
}
fn check_knowledge(&self, content: &str, cp: &ClassifiedPath, report: &mut LintReport) {
let parsed = match parse_frontmatter::<KnowledgeFrontmatter>(content) {
Ok(p) => p,
Err(e) => {
report.push_error(e);
return;
}
};
let fm = parsed.frontmatter;
size::check_body::<KnowledgeFrontmatter>(parsed.body, report);
if fm.model_invokation
&& fm.description.chars().count() > crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP
{
report.push_error(LintError::DescriptionTooLong {
actual: fm.description.chars().count(),
limit: crate::schema::KNOWLEDGE_DESCRIPTION_HARD_CAP,
});
}
warnings::check_warnings_with_sources(parsed.body, fm.last_sources.len(), report);
let _ = cp;
}
}
struct Parsed<'a, F> {
@@ -357,37 +329,6 @@ mod tests {
)));
}
#[test]
fn knowledge_long_description_with_model_invokation_errors() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/knowledge/foo.md");
let big_desc = "x".repeat(2000);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: true\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(
report
.errors
.iter()
.any(|e| matches!(e, LintError::DescriptionTooLong { .. }))
);
}
#[test]
fn knowledge_long_description_without_model_invokation_ok() {
let (dir, linter) = workspace();
let path = dir.path().join(".yoi/knowledge/foo.md");
let big_desc = "x".repeat(2000);
let content = format!(
"---\ncreated_at: {now}\nupdated_at: {now}\nkind: rule\ndescription: {big_desc}\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
now = iso_now()
);
let report = linter.lint(&path, &content, WriteMode::Create);
assert!(!report.has_errors(), "got errors: {:?}", report.errors);
}
#[test]
fn summary_path_accepted() {
let (dir, linter) = workspace();
+1 -1
View File
@@ -26,7 +26,7 @@ pub fn check_warnings_kindless(_cp: &ClassifiedPath, body: &str, _report: &mut L
// size:importance heuristic doesn't apply to a single rolling file.
}
/// For kinds with `sources` (Decisions / Requests / Knowledge), consult
/// 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();
+1 -187
View File
@@ -2,46 +2,17 @@
//!
//! Surfaces used by the Worker system-prompt assembler:
//!
//! - [`collect_resident_knowledge`] — resident-injection candidates
//! (`model_invokation: true`) returned as `(slug, description)` pairs.
//! - [`collect_resident_summary`] — the body of
//! `<workspace>/.yoi/memory/summary.md` when it parses as a summary
//! record and has non-empty body.
//! - [`list_knowledge_slugs`] — every slug whose file parses, regardless
//! of `model_invokation`. Used by the Worker IPC layer to answer TUI `#`
//! completion (`model_invokation` is a resident-injection flag, not a
//! user-visibility flag).
//!
//! 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::{KnowledgeFrontmatter, SummaryFrontmatter, split_frontmatter};
use crate::schema::{SummaryFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResidentKnowledgeEntry {
pub slug: String,
pub description: String,
}
/// Walk `<workspace>/.yoi/knowledge/*.md` and return entries whose
/// frontmatter has `model_invokation: true`, sorted by slug. A missing
/// directory yields an empty vec.
pub fn collect_resident_knowledge(layout: &WorkspaceLayout) -> Vec<ResidentKnowledgeEntry> {
let mut out: Vec<ResidentKnowledgeEntry> = Vec::new();
walk_knowledge(layout, |slug, fm| {
if fm.model_invokation {
out.push(ResidentKnowledgeEntry {
slug,
description: fm.description,
});
}
});
out.sort_by(|a, b| a.slug.cmp(&b.slug));
out
}
/// 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.
@@ -57,52 +28,6 @@ pub fn collect_resident_summary(layout: &WorkspaceLayout) -> Option<String> {
}
}
/// Walk `<workspace>/knowledge/*.md` and return every slug whose
/// frontmatter parses, sorted ascending. Does not filter on
/// `model_invokation`. A missing `knowledge/` directory yields an empty
/// vec.
pub fn list_knowledge_slugs(layout: &WorkspaceLayout) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
walk_knowledge(layout, |slug, _fm| out.push(slug));
out.sort();
out
}
fn walk_knowledge(layout: &WorkspaceLayout, mut visit: impl FnMut(String, KnowledgeFrontmatter)) {
let dir = layout.knowledge_dir();
let entries = match std::fs::read_dir(&dir) {
Ok(it) => it,
Err(_) => return,
};
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n,
None => continue,
};
let slug = match name.strip_suffix(".md") {
Some(s) => s.to_string(),
None => continue,
};
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
let (yaml, _body) = match split_frontmatter(&raw) {
Ok(v) => v,
Err(_) => continue,
};
let fm: KnowledgeFrontmatter = match serde_yaml::from_str(yaml) {
Ok(f) => f,
Err(_) => continue,
};
visit(slug, fm);
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -120,25 +45,8 @@ mod tests {
std::fs::write(path, content).unwrap();
}
fn write_knowledge(
dir: &Path,
slug: &str,
description: &str,
model_invokation: bool,
body: &str,
) {
let path = dir.join(".yoi/knowledge").join(format!("{slug}.md"));
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: policy\ndescription: \"{description}\"\nmodel_invokation: {flag}\nuser_invocable: true\nlast_sources: []\n---\n{body}",
n = now(),
flag = model_invokation,
);
std::fs::write(path, content).unwrap();
}
fn setup() -> (TempDir, WorkspaceLayout) {
let dir = TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/knowledge")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory")).unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
(dir, layout)
@@ -180,98 +88,4 @@ mod tests {
write_summary(dir.path(), " \n");
assert!(collect_resident_summary(&layout).is_none());
}
#[test]
fn missing_knowledge_dir_returns_empty() {
let dir = TempDir::new().unwrap();
// No knowledge/ directory at all.
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(collect_resident_knowledge(&layout).is_empty());
}
#[test]
fn picks_only_model_invokation_true() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "alpha", "alpha desc", true, "body\n");
write_knowledge(dir.path(), "beta", "beta desc", false, "body\n");
write_knowledge(dir.path(), "gamma", "gamma desc", true, "body\n");
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 2);
assert_eq!(got[0].slug, "alpha");
assert_eq!(got[0].description, "alpha desc");
assert_eq!(got[1].slug, "gamma");
assert_eq!(got[1].description, "gamma desc");
}
#[test]
fn entries_are_sorted_by_slug() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "zeta", "z", true, "");
write_knowledge(dir.path(), "alpha", "a", true, "");
write_knowledge(dir.path(), "mu", "m", true, "");
let got = collect_resident_knowledge(&layout);
let slugs: Vec<&str> = got.iter().map(|e| e.slug.as_str()).collect();
assert_eq!(slugs, vec!["alpha", "mu", "zeta"]);
}
#[test]
fn malformed_frontmatter_is_skipped() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
// Garbage in frontmatter — must be skipped, not panic.
std::fs::write(
dir.path().join(".yoi/knowledge/bad.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 1);
assert_eq!(got[0].slug, "good");
}
#[test]
fn non_md_files_ignored() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
std::fs::write(dir.path().join(".yoi/knowledge/note.txt"), "not markdown\n").unwrap();
let got = collect_resident_knowledge(&layout);
assert_eq!(got.len(), 1);
}
#[test]
fn list_slugs_missing_dir_returns_empty() {
let dir = TempDir::new().unwrap();
let layout = WorkspaceLayout::new(dir.path().to_path_buf());
assert!(list_knowledge_slugs(&layout).is_empty());
}
#[test]
fn list_slugs_returns_all_regardless_of_model_invokation() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "alpha", "a", true, "");
write_knowledge(dir.path(), "beta", "b", false, "");
write_knowledge(dir.path(), "gamma", "g", true, "");
let got = list_knowledge_slugs(&layout);
assert_eq!(got, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn list_slugs_skips_malformed_and_non_md() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "good", "ok", true, "");
std::fs::write(
dir.path().join(".yoi/knowledge/bad.md"),
"---\nthis is not yaml: : :\n---\nbody\n",
)
.unwrap();
std::fs::write(dir.path().join(".yoi/knowledge/note.txt"), "not markdown\n").unwrap();
let got = list_knowledge_slugs(&layout);
assert_eq!(got, vec!["good"]);
}
}
-33
View File
@@ -1,33 +0,0 @@
//! Knowledge frontmatter schema.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use crate::schema::common::{Frontmatter, SourceRef};
/// Hard cap on `description` length when `model_invokation: true`.
/// Mirrors the agent-skills 1024-char rule for description that lives
/// in resident system-prompt budget.
pub const KNOWLEDGE_DESCRIPTION_HARD_CAP: usize = 1024;
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct KnowledgeFrontmatter {
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
pub kind: String,
pub description: String,
pub model_invokation: bool,
pub user_invocable: bool,
pub last_sources: Vec<SourceRef>,
}
impl Frontmatter for KnowledgeFrontmatter {
const BODY_LIMIT: usize = 8000;
fn created_at(&self) -> Option<DateTime<Utc>> {
Some(self.created_at)
}
fn updated_at(&self) -> Option<DateTime<Utc>> {
Some(self.updated_at)
}
}
-2
View File
@@ -7,12 +7,10 @@
mod common;
mod decision;
mod knowledge;
mod request;
mod summary;
pub use common::{Frontmatter, SourceRef, split_frontmatter};
pub use decision::{DecisionFrontmatter, DecisionStatus};
pub use knowledge::{KNOWLEDGE_DESCRIPTION_HARD_CAP, KnowledgeFrontmatter};
pub use request::RequestFrontmatter;
pub use summary::SummaryFrontmatter;
+4 -10
View File
@@ -13,14 +13,9 @@ use manifest::{Permission, ScopeRule};
use crate::workspace::WorkspaceLayout;
/// Build deny rules that strip Write permission from `<workspace>/memory/`
/// and `<workspace>/knowledge/`. Recursive — every descendant is capped at
/// Read for the generic tools.
/// 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()),
deny_write(layout.knowledge_dir().as_path()),
]
vec![deny_write(layout.memory_dir().as_path())]
}
fn deny_write(target: &Path) -> ScopeRule {
@@ -37,13 +32,12 @@ mod tests {
use std::path::PathBuf;
#[test]
fn deny_targets_memory_and_knowledge() {
fn deny_targets_memory() {
let layout = WorkspaceLayout::new(PathBuf::from("/ws"));
let rules = deny_write_rules(&layout);
assert_eq!(rules.len(), 2);
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);
assert_eq!(rules[1].target, PathBuf::from("/ws/.yoi/knowledge"));
}
}
+2 -2
View File
@@ -1,4 +1,4 @@
//! `MemoryDelete` tool for removing memory / knowledge records with audit logging.
//! `MemoryDelete` tool for removing memory records with audit logging.
use std::sync::Arc;
@@ -10,7 +10,7 @@ use crate::audit::{AuditStatus, RecordOperationAudit, append_record_operation, f
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Delete an existing memory or knowledge record selected by `kind` + `slug`. \
const DESCRIPTION: &str = "Delete an existing memory record selected by `kind` + `slug`. \
For `summary` omit `slug`; for the others `slug` is required. The delete is audited and cannot target \
workflow or staging/log files.";
+2 -2
View File
@@ -19,14 +19,14 @@ use crate::linter::{LintReport, Linter, WriteMode};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Replace a substring in an existing memory or knowledge \
const DESCRIPTION: &str = "Replace a substring in an existing memory \
record selected by `kind` + `slug`. By default `old_string` must be unique in the \
file; set `replace_all: true` to replace every occurrence. The resulting content \
is re-validated by the memory linter; failure leaves the file untouched.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct EditParams {
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
/// Record kind: `summary` | `decision` | `request`.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]
+1 -6
View File
@@ -22,7 +22,7 @@ use crate::Slug;
use crate::workspace::{RecordKind, WorkspaceLayout};
pub use edit::edit_tool;
pub use query::{QueryConfig, knowledge_query_tool, memory_query_tool};
pub use query::{QueryConfig, memory_query_tool};
pub use read::{read_tool, read_tool_with_usage};
pub use write::write_tool;
@@ -34,7 +34,6 @@ pub enum MemoryToolKind {
Summary,
Decision,
Request,
Knowledge,
}
impl std::fmt::Display for MemoryToolKind {
@@ -43,7 +42,6 @@ impl std::fmt::Display for MemoryToolKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Knowledge => "knowledge",
})
}
}
@@ -54,7 +52,6 @@ impl MemoryToolKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Knowledge => "knowledge",
}
}
@@ -63,7 +60,6 @@ impl MemoryToolKind {
Self::Summary => RecordKind::Summary,
Self::Decision => RecordKind::Decision,
Self::Request => RecordKind::Request,
Self::Knowledge => RecordKind::Knowledge,
}
}
@@ -92,7 +88,6 @@ impl MemoryToolKind {
Ok(match other {
Self::Decision => layout.decision_path(&parsed),
Self::Request => layout.request_path(&parsed),
Self::Knowledge => layout.knowledge_path(&parsed),
Self::Summary => unreachable!(),
})
}
+5 -327
View File
@@ -1,6 +1,6 @@
//! `MemoryQuery` / `KnowledgeQuery` tools.
//! `MemoryQuery` tool.
//!
//! Both perform a case-insensitive substring scan over markdown record
//! Performs a case-insensitive substring scan over markdown record
//! files. With a `query` set, returns `{slug, kind, ..., excerpt}` hits
//! with `excerpt_lines` lines of context around each match. With `query`
//! omitted, returns one entry per file (no excerpt) so the agent can
@@ -10,8 +10,6 @@
//! requests/}`. `.yoi/memory/_staging/`,
//! `.yoi/memory/_usage/`, and `.yoi/memory/_logs/` are excluded
//! by construction.
//! - `KnowledgeQuery` walks `.yoi/knowledge/*.md` and supports a
//! `kind` filter against the Knowledge frontmatter's `kind` field.
//!
//! No derived index — the file tree is the source of truth and is
//! re-scanned per call. 出現順: within a file by line order, across
@@ -25,7 +23,6 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput};
use serde::{Deserialize, Serialize};
use crate::audit::{AuditStatus, RecordUsageAudit, append_record_usage};
use crate::schema::{KnowledgeFrontmatter, split_frontmatter};
use crate::workspace::WorkspaceLayout;
const DEFAULT_RESULT_LIMIT: usize = 20;
@@ -38,14 +35,6 @@ when you don't yet know what's in there. Result count is capped (configurable vi
manifest's `[memory]` section). Use the returned `slug` + `kind` with MemoryRead to fetch \
the full record. Workflow and staging directories are not visible.";
const KNOWLEDGE_QUERY_DESCRIPTION: &str = "Inspect knowledge records. With `query` set, \
returns substring hits with line context; omit `query` to list every record (one entry \
per file, no excerpt). Optional `kind` filters by the Knowledge frontmatter's `kind` \
field; records whose frontmatter fails to parse are skipped when `kind` is given. Result \
count is capped (configurable via the manifest's `[memory]` section). Returns \
`{slug, kind, description, model_invokation, excerpt}` entries. Use the returned `slug` \
with MemoryRead (kind=knowledge) for the full record.";
/// Tunables passed in from the manifest.
#[derive(Debug, Clone, Copy)]
pub struct QueryConfig {
@@ -85,17 +74,6 @@ struct MemoryQueryParams {
query: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct KnowledgeQueryParams {
/// Optional substring filter. Case-insensitive. Omit to list every
/// knowledge record under the query scope.
#[serde(default)]
query: Option<String>,
/// Optional filter on the Knowledge frontmatter's `kind` field.
#[serde(default)]
kind: Option<String>,
}
#[derive(Debug, Serialize)]
struct MemoryRecord {
slug: String,
@@ -104,26 +82,11 @@ struct MemoryRecord {
excerpt: Option<String>,
}
#[derive(Debug, Serialize)]
struct KnowledgeRecord {
slug: String,
kind: Option<String>,
description: Option<String>,
model_invokation: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
excerpt: Option<String>,
}
struct MemoryQueryTool {
layout: WorkspaceLayout,
config: QueryConfig,
}
struct KnowledgeQueryTool {
layout: WorkspaceLayout,
config: QueryConfig,
}
#[async_trait]
impl Tool for MemoryQueryTool {
async fn execute(
@@ -242,123 +205,6 @@ impl Tool for MemoryQueryTool {
}
}
#[async_trait]
impl Tool for KnowledgeQueryTool {
async fn execute(
&self,
input_json: &str,
_ctx: llm_engine::tool::ToolExecutionContext,
) -> Result<ToolOutput, ToolError> {
let params: KnowledgeQueryParams = serde_json::from_str(input_json).map_err(|e| {
ToolError::InvalidArgument(format!("invalid KnowledgeQuery input: {e}"))
})?;
let needle = match params.query.as_deref() {
Some(q) => match validate_query(q) {
Ok(q) => Some(q),
Err(err) => {
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Failed,
kind: "knowledge".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: None,
reason: Some(err.to_string()),
},
);
return Err(err);
}
},
None => None,
};
let kind_filter = params.kind.as_deref();
let mut records: Vec<KnowledgeRecord> = Vec::new();
let limit = self.config.result_limit;
let ctx = self.config.excerpt_lines;
for (path, slug) in list_md_files(&self.layout.knowledge_dir()) {
if records.len() >= limit {
break;
}
let raw = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => continue,
};
let fm = parse_knowledge_frontmatter(&raw);
// kind filter applies to the frontmatter's kind field.
if let Some(filter) = kind_filter {
let matches = fm
.as_ref()
.map(|f| f.kind.as_str() == filter)
.unwrap_or(false);
if !matches {
continue;
}
}
let kind = fm.as_ref().map(|f| f.kind.clone());
let description = fm.as_ref().map(|f| f.description.clone());
let model_invokation = fm.as_ref().map(|f| f.model_invokation);
match needle.as_deref() {
Some(n) => {
scan_text(&raw, n, ctx, limit - records.len(), |excerpt| {
records.push(KnowledgeRecord {
slug: slug.clone(),
kind: kind.clone(),
description: description.clone(),
model_invokation,
excerpt: Some(excerpt),
});
});
}
None => {
records.push(KnowledgeRecord {
slug: slug.clone(),
kind,
description,
model_invokation,
excerpt: None,
});
}
}
}
let body = serde_json::to_string_pretty(&records)
.map_err(|e| ToolError::ExecutionFailed(format!("serialize records: {e}")))?;
let summary = match params.query.as_deref() {
Some(q) => format!("{} hit(s) for {q:?}", records.len()),
None => format!("{} record(s)", records.len()),
};
let _ = append_record_usage(
&self.layout,
RecordUsageAudit {
op: "query".to_string(),
status: AuditStatus::Success,
kind: "knowledge".to_string(),
slug: None,
path: None,
query: params.query.clone(),
result_count: Some(records.len()),
reason: if records.len() >= limit {
Some("result_limit_reached".to_string())
} else {
None
},
},
);
Ok(ToolOutput {
summary,
content: Some(body),
})
}
}
fn collect_memory_records(
path: &Path,
slug: &str,
@@ -470,14 +316,6 @@ fn scan_text(
}
}
/// Best-effort frontmatter parse. Returns `None` if missing/malformed
/// — query still finds matches in the body even when the header is
/// broken.
fn parse_knowledge_frontmatter(raw: &str) -> Option<KnowledgeFrontmatter> {
let (yaml, _body) = split_frontmatter(raw).ok()?;
serde_yaml::from_str::<KnowledgeFrontmatter>(yaml).ok()
}
pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(MemoryQueryParams);
@@ -493,21 +331,6 @@ pub fn memory_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDe
})
}
pub fn knowledge_query_tool(layout: WorkspaceLayout, config: QueryConfig) -> ToolDefinition {
Arc::new(move || {
let schema = schemars::schema_for!(KnowledgeQueryParams);
let schema_value = serde_json::to_value(schema).unwrap_or(serde_json::json!({}));
let meta = ToolMeta::new("KnowledgeQuery")
.description(KNOWLEDGE_QUERY_DESCRIPTION)
.input_schema(schema_value);
let tool: Arc<dyn Tool> = Arc::new(KnowledgeQueryTool {
layout: layout.clone(),
config,
});
(meta, tool)
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -524,7 +347,6 @@ mod tests {
std::fs::create_dir_all(dir.path().join(".yoi/memory/decisions")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory/requests")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/memory/_staging")).unwrap();
std::fs::create_dir_all(dir.path().join(".yoi/knowledge")).unwrap();
(dir, layout)
}
@@ -537,19 +359,6 @@ mod tests {
std::fs::write(path, content).unwrap();
}
fn write_knowledge(dir: &Path, slug: &str, kind: &str, description: &str, body: &str) {
let path = dir.join(".yoi/knowledge").join(format!("{slug}.md"));
let content = format!(
"---\ncreated_at: {n}\nupdated_at: {n}\nkind: {kind}\ndescription: \"{description}\"\nmodel_invokation: false\nuser_invocable: true\nlast_sources: []\n---\n{body}",
n = now()
);
std::fs::write(path, content).unwrap();
}
fn parse_records<T: for<'de> serde::Deserialize<'de>>(out: &ToolOutput) -> Vec<T> {
serde_json::from_str(out.content.as_ref().unwrap()).unwrap()
}
#[derive(Deserialize)]
struct OwnedMemoryRecord {
slug: String,
@@ -558,16 +367,10 @@ mod tests {
excerpt: Option<String>,
}
#[derive(Deserialize)]
struct OwnedKnowledgeRecord {
slug: String,
kind: Option<String>,
description: Option<String>,
model_invokation: Option<bool>,
#[serde(default)]
excerpt: Option<String>,
fn parse_records(out: &ToolOutput) -> Vec<OwnedMemoryRecord> {
let text = out.content.as_ref().unwrap_or(&out.summary);
serde_json::from_str(text).unwrap()
}
#[tokio::test]
async fn memory_query_finds_decision_body() {
let (dir, layout) = setup();
@@ -640,25 +443,12 @@ mod tests {
async fn query_hits_do_not_log_usage() {
let (dir, layout) = setup();
write_decision(dir.path(), "alpha", "needle line\n");
write_knowledge(
dir.path(),
"policy",
"policy",
"needle desc",
"needle body\n",
);
let (_, memory_tool) = memory_query_tool(layout.clone(), QueryConfig::default())();
let (_, knowledge_tool) = knowledge_query_tool(layout.clone(), QueryConfig::default())();
let inp = serde_json::json!({ "query": "needle" });
memory_tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
knowledge_tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let report = crate::usage::build_usage_report(&layout).unwrap();
assert!(report.records.is_empty());
@@ -724,116 +514,4 @@ mod tests {
.unwrap_err();
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn knowledge_query_returns_frontmatter_fields() {
let (dir, layout) = setup();
write_knowledge(
dir.path(),
"policy",
"policy",
"the policy doc",
"Ollama first\n",
);
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "ollama" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "policy");
assert_eq!(records[0].kind.as_deref(), Some("policy"));
assert_eq!(records[0].description.as_deref(), Some("the policy doc"));
assert_eq!(records[0].model_invokation, Some(false));
assert!(
records[0]
.excerpt
.as_deref()
.unwrap()
.to_lowercase()
.contains("ollama")
);
}
#[tokio::test]
async fn knowledge_query_without_query_lists_all_records() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p1", "policy", "d1", "body\n");
write_knowledge(dir.path(), "h1", "howto", "d2", "body\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let out = tool.execute("{}", Default::default()).await.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
let mut slugs: Vec<&str> = records.iter().map(|r| r.slug.as_str()).collect();
slugs.sort();
assert_eq!(slugs, vec!["h1", "p1"]);
assert!(records.iter().all(|r| r.excerpt.is_none()));
}
#[tokio::test]
async fn knowledge_query_kind_filter() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p1", "policy", "d1", "needle\n");
write_knowledge(dir.path(), "h1", "howto", "d2", "needle\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "needle", "kind": "howto" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "h1");
}
#[tokio::test]
async fn knowledge_query_kind_filter_works_without_query() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p1", "policy", "d1", "body\n");
write_knowledge(dir.path(), "h1", "howto", "d2", "body\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "kind": "howto" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "h1");
assert!(records[0].excerpt.is_none());
}
#[tokio::test]
async fn knowledge_query_searches_frontmatter_too() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p", "policy", "mentions xyzzy here", "body\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "xyzzy" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert_eq!(records.len(), 1);
assert_eq!(records[0].slug, "p");
}
#[tokio::test]
async fn knowledge_query_no_matches_returns_empty() {
let (dir, layout) = setup();
write_knowledge(dir.path(), "p", "policy", "d", "no match\n");
let (_, tool) = knowledge_query_tool(layout, QueryConfig::default())();
let inp = serde_json::json!({ "query": "absent" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
let records: Vec<OwnedKnowledgeRecord> = parse_records(&out);
assert!(records.is_empty());
}
}
+5 -21
View File
@@ -1,8 +1,8 @@
//! `MemoryRead` tool.
//!
//! Reads a memory or knowledge record by `(kind, slug)`. Returns
//! Reads a memory record by `(kind, slug)`. Returns
//! line-numbered content (1-based), like the generic Read tool. The
//! agent never names a path — `Search` returns `{kind, slug, ...}`
//! agent never names a path — `MemoryQuery` returns `{kind, slug, ...}`
//! and that pair feeds straight into Read.
use std::sync::Arc;
@@ -16,8 +16,8 @@ use crate::tool::MemoryToolKind;
use crate::usage::{self, UsageSource};
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Read a memory or knowledge record by `kind` + `slug`. \
`kind` is one of: summary, decision, request, knowledge. \
const DESCRIPTION: &str = "Read a memory record by `kind` + `slug`. \
`kind` is one of: summary, decision, request. \
For `summary` omit `slug`; for the others `slug` is required. \
Returns line-numbered output (1-based).";
@@ -25,7 +25,7 @@ const DEFAULT_LIMIT: usize = 2000;
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct ReadParams {
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
/// Record kind: `summary` | `decision` | `request`.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]
@@ -290,22 +290,6 @@ mod tests {
assert!(matches!(err, ToolError::InvalidArgument(_)));
}
#[tokio::test]
async fn knowledge_path_resolution() {
let (dir, layout) = setup();
let path = dir.path().join(".yoi/knowledge/policy.md");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "k\n").unwrap();
let (_, tool) = read_tool(layout)();
let inp = serde_json::json!({ "kind": "knowledge", "slug": "policy" });
let out = tool
.execute(&inp.to_string(), Default::default())
.await
.unwrap();
assert!(out.content.unwrap().contains("k"));
}
#[tokio::test]
async fn read_logs_explicit_use_when_usage_session_is_set() {
let (dir, layout) = setup();
+4 -4
View File
@@ -1,6 +1,6 @@
//! `MemoryWrite` tool.
//!
//! Creates or overwrites a memory or knowledge record by `(kind, slug)`.
//! Creates or overwrites a memory record by `(kind, slug)`.
//! Pre-write Linter validates frontmatter, slug uniqueness (Create only),
//! reference integrity, size limits. On any
//! Linter error the tool returns `ToolError::InvalidArgument` with all
@@ -19,14 +19,14 @@ use crate::linter::{LintReport, Linter, WriteMode};
use crate::tool::MemoryToolKind;
use crate::workspace::WorkspaceLayout;
const DESCRIPTION: &str = "Create or overwrite a memory or knowledge record by \
`kind` + `slug`. `kind`: summary | decision | request | knowledge. For `summary` \
const DESCRIPTION: &str = "Create or overwrite a memory record by \
`kind` + `slug`. `kind`: summary | decision | request. For `summary` \
omit `slug`. Frontmatter is validated before write; on validation failure no \
write occurs and every violation is returned in the error message.";
#[derive(Debug, Deserialize, schemars::JsonSchema)]
struct WriteParams {
/// Record kind: `summary` | `decision` | `request` | `knowledge`.
/// Record kind: `summary` | `decision` | `request`.
kind: MemoryToolKind,
/// Slug. Required for everything except `summary`; forbidden for `summary`.
#[serde(default)]
+16 -22
View File
@@ -1,9 +1,9 @@
//! Workspace-local usage event log for memory / knowledge records.
//! 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
//! Knowledge candidates or tidy-protected records.
//! tidy-protected records.
use std::collections::{BTreeMap, HashMap};
use std::fs::{self, OpenOptions};
@@ -25,7 +25,6 @@ pub enum UsageEventKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum UsageSource {
MemoryRead,
KnowledgeRef,
ResidentInjection,
}
@@ -33,7 +32,6 @@ impl UsageSource {
pub fn as_str(self) -> &'static str {
match self {
Self::MemoryRead => "MemoryRead",
Self::KnowledgeRef => "KnowledgeRef",
Self::ResidentInjection => "ResidentInjection",
}
}
@@ -214,10 +212,6 @@ fn record_path(
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.request_path(&slug))
}
RecordKind::Knowledge => {
let slug = crate::Slug::parse(slug.to_string()).map_err(invalid_slug_error)?;
Ok(layout.knowledge_path(&slug))
}
}
}
@@ -313,7 +307,7 @@ mod tests {
fn aggregates_use_and_resident_exposure_separately() {
let (_dir, layout) = setup();
let decision = snapshot_record_from_bytes(RecordKind::Decision, "alpha", b"abcd");
let knowledge = snapshot_record_from_bytes(RecordKind::Knowledge, "policy", b"abcdefgh");
let request = snapshot_record_from_bytes(RecordKind::Request, "policy", b"abcdefgh");
append_use_event(
&layout,
@@ -325,18 +319,18 @@ mod tests {
append_use_event(
&layout,
"session-a",
UsageSource::KnowledgeRef,
vec![knowledge.clone()],
UsageSource::MemoryRead,
vec![request.clone()],
)
.unwrap();
append_use_event(
&layout,
"session-b",
UsageSource::KnowledgeRef,
vec![knowledge.clone()],
UsageSource::MemoryRead,
vec![request.clone()],
)
.unwrap();
append_resident_exposure_event(&layout, "session-b", vec![knowledge]).unwrap();
append_resident_exposure_event(&layout, "session-b", vec![request]).unwrap();
let report = build_usage_report(&layout).unwrap();
let decision = report
@@ -349,22 +343,22 @@ mod tests {
assert_eq!(decision.resident_exposure_count, 0);
assert!(decision.last_used_at.is_some());
let knowledge = report
let request = report
.records
.iter()
.find(|r| r.kind == "knowledge" && r.slug == "policy")
.find(|r| r.kind == "request" && r.slug == "policy")
.unwrap();
assert_eq!(knowledge.use_count, 2);
assert_eq!(knowledge.source_breakdown["KnowledgeRef"], 2);
assert_eq!(knowledge.resident_exposure_count, 1);
assert_eq!(knowledge.estimated_tokens_per_injection, 2);
assert_eq!(knowledge.estimated_total_resident_exposure_tokens, 2);
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::Knowledge, "policy", b"abcdefgh");
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();
+2 -27
View File
@@ -5,7 +5,6 @@
//! `<root>/.yoi/` subdirectory — alongside workspace project records
//! generated durable memory. The trees inside it:
//!
//! - `<root>/.yoi/knowledge/<slug>.md`
//! - `<root>/.yoi/memory/summary.md`
//! - `<root>/.yoi/memory/decisions/<slug>.md`
//! - `<root>/.yoi/memory/requests/<slug>.md`
@@ -26,7 +25,6 @@ use lint_common::RecordLintError;
const YOI_DIR: &str = ".yoi";
const MEMORY_DIR: &str = "memory";
const KNOWLEDGE_DIR: &str = "knowledge";
const SUMMARY_FILE: &str = "summary.md";
const DECISIONS_DIR: &str = "decisions";
const REQUESTS_DIR: &str = "requests";
@@ -42,7 +40,6 @@ pub enum RecordKind {
Summary,
Decision,
Request,
Knowledge,
}
impl RecordKind {
@@ -51,7 +48,6 @@ impl RecordKind {
Self::Summary => "summary",
Self::Decision => "decision",
Self::Request => "request",
Self::Knowledge => "knowledge",
}
}
}
@@ -110,10 +106,6 @@ impl WorkspaceLayout {
self.yoi_dir().join(MEMORY_DIR)
}
pub fn knowledge_dir(&self) -> PathBuf {
self.yoi_dir().join(KNOWLEDGE_DIR)
}
pub fn summary_path(&self) -> PathBuf {
self.memory_dir().join(SUMMARY_FILE)
}
@@ -158,12 +150,8 @@ impl WorkspaceLayout {
self.requests_dir().join(format!("{slug}.md"))
}
pub fn knowledge_path(&self, slug: &Slug) -> PathBuf {
self.knowledge_dir().join(format!("{slug}.md"))
}
/// Classify a path under the memory tree. Returns `None` if the
/// path is not under `.yoi/memory/` or `.yoi/knowledge/`
/// path is not under `.yoi/memory/`
/// of this workspace, or if it lives in
/// `_staging/` / `_usage/` / `_logs/` (opaque subsystem-owned trees).
///
@@ -173,11 +161,7 @@ impl WorkspaceLayout {
/// can surface it as a write violation.
pub fn classify(&self, path: &Path) -> Result<Option<ClassifiedPath>, LintError> {
let memory = self.memory_dir();
let knowledge = self.knowledge_dir();
if let Ok(rel) = path.strip_prefix(&knowledge) {
return Ok(Some(classify_kinded_md(rel, RecordKind::Knowledge, path)?));
}
let rel = match path.strip_prefix(&memory) {
Ok(r) => r,
Err(_) => return Ok(None),
@@ -283,16 +267,7 @@ mod tests {
}
#[test]
fn classifies_knowledge() {
let cp = layout()
.classify(&PathBuf::from("/ws/.yoi/knowledge/x.md"))
.unwrap()
.unwrap();
assert_eq!(cp.kind, RecordKind::Knowledge);
}
#[test]
fn staging_returns_none() {
fn staging_tree_is_opaque_to_classifier() {
assert!(
layout()
.classify(&PathBuf::from("/ws/.yoi/memory/_staging/abc.json"))