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 -1
View File
@@ -98,5 +98,5 @@ pub const COMPACT_DEFAULT_REFERENCE_COUNT: usize = 5;
pub const MEMORY_EXTRACT_WORKER_MAX_TURNS: Option<u32> = Some(8);
/// Default language used by memory extraction / consolidation workers for
/// durable memory and knowledge text. See [`crate::MemoryConfig::language`].
/// durable memory text. See [`crate::MemoryConfig::language`].
pub const MEMORY_LANGUAGE: &str = "English";
+2 -2
View File
@@ -379,7 +379,7 @@ pub struct MemoryConfig {
#[serde(default)]
pub workspace_root: Option<PathBuf>,
/// Maximum number of records returned by `MemoryQuery` /
/// `KnowledgeQuery` per call. `None` ⇒ tool default (20).
/// `MemoryQuery` per call. `None` ⇒ tool default (20).
#[serde(default)]
pub query_result_limit: Option<usize>,
/// Lines of context before and after each match in query excerpts.
@@ -391,7 +391,7 @@ pub struct MemoryConfig {
#[serde(default)]
pub inject_summary: Option<bool>,
/// Language used by memory extraction / consolidation workers for durable
/// memory and knowledge text. Free-form so workspaces can use names like
/// memory text. Free-form so workspaces can use names like
/// `English`, `Japanese`, or locale tags. `None` ⇒
/// [`defaults::MEMORY_LANGUAGE`].
#[serde(default)]
+2 -2
View File
@@ -2,13 +2,13 @@
## Role
`memory` owns generated memory, Knowledge records, staging/consolidation mechanics, linting, and audit observations.
`memory` owns generated memory, records, staging/consolidation mechanics, linting, and audit observations.
## Boundaries
Owns:
- memory/Knowledge record parsing and validation
- memory/record parsing and validation
- memory lint subcommand backend behavior
- staging and consolidation file mechanics
- audit log observation writes
-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"))
+11 -25
View File
@@ -171,7 +171,7 @@ impl WorkerEvent {
/// `Method::Run` and `Event::UserMessage` carry `Vec<Segment>`. Dumb
/// clients (CLI piping, scripts) only need to produce a single
/// `Segment::Text`; richer clients (TUI / GUI) construct typed atoms
/// (paste chips, file refs, knowledge refs, knowledge refs) and
/// (paste chips, file refs) and
/// send them through directly so the Worker side never has to re-parse a
/// flattened string.
///
@@ -201,8 +201,6 @@ pub enum Segment {
/// `[Dir: <path>]` listings; the flattened user text keeps the literal
/// `@<path>` placeholder either way.
FileRef { path: String },
/// `#<slug>` Knowledge reference (see `docs/plan/memory.md`).
KnowledgeRef { slug: String },
/// Unknown variant from a newer client. Worker treats this as an
/// unresolved input — surfaces an alert and inserts a placeholder.
/// Round-trip is lossy: re-serializing yields `{"kind":"unknown"}`.
@@ -221,9 +219,8 @@ impl Segment {
/// to surface user-visible alerts for unresolved refs should do so
/// alongside this call (Worker does so at submit time).
///
/// Sigil-prefixed variants (`FileRef` / `KnowledgeRef`)
/// flatten back to their literal sigil form (`@<path>`, `#<slug>`,
/// ) — matching what the user originally typed. Resolved
/// Sigil-prefixed variants (`FileRef`) flatten back to their literal
/// sigil form (`@<path>`) when converted to text.
/// content (e.g. file body or shallow directory listing for `FileRef`) is
/// delivered as separate `Item::system_message`s adjacent to the user
/// message; the resolution itself is the caller's job. `Unknown` falls back to
@@ -238,13 +235,7 @@ impl Segment {
out.push('@');
out.push_str(path);
}
Segment::KnowledgeRef { slug } => {
out.push('#');
out.push_str(slug);
}
Segment::Unknown => {
out.push_str("[unknown input segment]");
}
Segment::Unknown => {}
}
}
out
@@ -287,8 +278,7 @@ pub enum Event {
///
/// Carries the JSON form of `session_store::SystemItem`. Covers
/// `Method::Notify` echoes, child-Worker lifecycle events from
/// `Method::WorkerEvent`, `@<path>` / `#<slug>` /
/// resolution payloads, and any future agent-side injection kind.
/// `Method::WorkerEvent`, `@<path>` resolution payloads, and any future
/// Clients dispatch on the `kind` tag for typed rendering instead
/// of parsing free-text prefixes like `[Notification] …` or
/// `[File: …]`.
@@ -599,21 +589,17 @@ pub enum AlertSource {
/// Kind of completion requested by `Method::ListCompletions`.
///
/// Mirrors the completion prefix sigils: `@` → `File`, `#` → `Knowledge`.
/// Mirrors the completion prefix sigil: `@` → `File`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum CompletionKind {
File,
Knowledge,
}
/// One candidate returned in `Event::Completions::entries`.
/// One completion candidate for a prefix query.
///
/// `value` is a path (file kind) or a Knowledge slug.
/// `is_dir` is meaningful only for the file kind — it lets the TUI
/// keep a trailing `/` after a directory selection so the user can
/// drill in without re-typing the prefix.
/// `value` is a path (file kind).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
pub struct CompletionEntry {
@@ -1177,16 +1163,16 @@ mod tests {
#[test]
fn event_completions_format_and_default_is_dir() {
let event = Event::Completions {
kind: CompletionKind::Knowledge,
kind: CompletionKind::File,
entries: vec![CompletionEntry {
value: "clear".into(),
value: "src/main.rs".into(),
is_dir: false,
}],
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "completions");
assert_eq!(parsed["data"]["kind"], "knowledge");
assert_eq!(parsed["data"]["kind"], "file");
assert_eq!(parsed["data"]["entries"][0]["value"], "clear");
// is_dir defaults to false on inbound payloads that omit it.
+3 -3
View File
@@ -74,7 +74,7 @@ pub enum LogEntry {
/// User input accepted at submit time. Carries the original typed
/// `Vec<Segment>` so clients can re-render typed atoms (paste chips,
/// file/knowledge refs) on segment restore.
/// file refs) on segment restore.
/// Replay flattens these into a `Item::user_message` for the worker
/// history; the worker layer never sees segments directly.
UserInput { ts: u64, segments: Vec<Segment> },
@@ -88,8 +88,8 @@ pub enum LogEntry {
ToolResult { ts: u64, item: LoggedItem },
/// One typed agent-injected system item: notification, child-Worker
/// lifecycle event, `@<path>` / `#<slug>` / `/<slug>` resolution
/// payload. Each `SystemItem` carries kind metadata that the LLM
/// lifecycle event, `@<path>` / `/<slug>` resolution payload. Each
/// `SystemItem` carries kind metadata that the LLM
/// itself never sees (the LLM gets `Item::system_message` with the
/// item's denormalised `body`), but live clients and replay paths
/// dispatch on `kind` for typed rendering.
+9 -8
View File
@@ -2,7 +2,7 @@
//!
//! Items in worker history with `role:system` are never produced by the
//! LLM — they are always inserted by the Worker itself (notifications,
//! file/knowledge ref resolutions, child-worker lifecycle events,
//! file ref resolutions, child-worker lifecycle events,
//! future `<system-reminder>` tags, …). [`SystemItem`] carries the
//! typed shape of each such injection so clients can dispatch on
//! `kind` instead of parsing text prefixes like `[Notification] …` or
@@ -106,7 +106,7 @@ fn render_system_reminder(body: &str) -> String {
///
/// Each variant carries the kind-specific raw data clients use for
/// typed rendering (`Notification.message`, `WorkerEvent.event`, file
/// path / knowledge slug / etc.), plus a pre-rendered
/// path / identifier / etc.), plus a pre-rendered
/// `body` (where applicable) that is the exact `role:system` text the
/// LLM actually saw at commit time. `body` is denormalised so that
/// segment log replay reconstructs worker history byte-identical to
@@ -139,10 +139,11 @@ pub enum SystemItem {
/// byte-identical to what was sent.
FileAttachment { path: String, body: String },
/// `#<slug>` Knowledge reference resolution. `body` is the
/// rendered text the LLM saw (Worker composes the `[Knowledge: …]`
/// header + body).
Knowledge { slug: String, body: String },
/// Historical persisted Knowledge reference resolution. Knowledge is no
/// longer active, so restored sessions ignore this item instead of replaying
/// archived record text into model context.
#[serde(rename = "knowledge")]
LegacyKnowledgeIgnored { slug: String, body: String },
/// Compatibility sink for pre-removal persisted `kind: "workflow"`
/// system items. These entries are intentionally not replayed as
@@ -173,7 +174,7 @@ impl SystemItem {
SystemItem::Notification { body, .. } => body.clone(),
SystemItem::WorkerEvent { body, .. } => body.clone(),
SystemItem::FileAttachment { body, .. } => body.clone(),
SystemItem::Knowledge { body, .. } => body.clone(),
SystemItem::LegacyKnowledgeIgnored { .. } => String::new(),
SystemItem::LegacyIgnored { slug } => {
format!("Ignored legacy procedure item: /{slug}")
}
@@ -195,7 +196,7 @@ impl SystemItem {
SystemItem::Notification { .. } => "notification",
SystemItem::WorkerEvent { .. } => "worker_event",
SystemItem::FileAttachment { .. } => "file_attachment",
SystemItem::Knowledge { .. } => "knowledge",
SystemItem::LegacyKnowledgeIgnored { .. } => "legacy_knowledge_ignored",
SystemItem::LegacyIgnored { .. } => "legacy_ignored",
SystemItem::TaskReminder { .. } => "task_reminder",
SystemItem::Interrupt { .. } => "interrupt",
+1 -25
View File
@@ -595,7 +595,6 @@ impl App {
self.input_history.cancel_browse();
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
}
self.completion = None;
true
@@ -607,7 +606,6 @@ impl App {
/// suggestion" — partial typing like `@README.` followed by
/// Enter should chip when the popup is on `README.md`.
///
/// Files and Knowledge entries chipify here. Directory file entries return `false`
/// so the caller can fall through to `apply_completion_text`
/// for drill-in — chip-ifying a directory on Enter would strand
/// the user with no way to inspect children.
@@ -628,7 +626,6 @@ impl App {
self.input_history.cancel_browse();
match kind {
CompletionKind::File => self.input.replace_with_file_ref(start, value),
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
}
self.completion = None;
true
@@ -2166,13 +2163,13 @@ impl App {
self.blocks.push(Block::WorkerEvent { event });
}
session_store::SystemItem::FileAttachment { body, .. }
| session_store::SystemItem::Knowledge { body, .. }
| session_store::SystemItem::TaskReminder { body, .. }
| session_store::SystemItem::Interrupt { body } => {
self.task_store.apply_system_message_text(&body);
self.blocks.push(Block::SystemMessage { text: body });
}
session_store::SystemItem::LegacyIgnored { .. } => {}
session_store::SystemItem::LegacyKnowledgeIgnored { .. } => {}
}
}
@@ -2969,24 +2966,6 @@ mod completion_flow_tests {
assert!(app.completion.is_some());
}
#[test]
fn outdated_completions_event_is_dropped() {
let mut app = App::new("test".into());
for c in "@x".chars() {
app.insert_char(c);
}
let _ = app.refresh_completion();
// Reply for a different kind shouldn't overwrite state.
app.handle_worker_event(Event::Completions {
kind: CompletionKind::Knowledge,
entries: vec![CompletionEntry {
value: "stale".into(),
is_dir: false,
}],
});
assert!(app.completion.as_ref().unwrap().entries.is_empty());
}
#[test]
fn committed_user_message_survives_fresh_segment_rotation() {
let mut app = App::new("test".into());
@@ -3697,9 +3676,6 @@ mod completion_flow_tests {
Segment::Text {
content: " and ".into(),
},
Segment::KnowledgeRef {
slug: "design-note".into(),
},
Segment::Paste {
id: 1,
chars: 13,
+6 -46
View File
@@ -46,24 +46,11 @@ impl FileRefAtom {
}
}
/// `#<slug>` chip — confirmed completion of a Knowledge reference.
#[derive(Debug, Clone)]
pub struct KnowledgeRefAtom {
pub slug: String,
}
impl KnowledgeRefAtom {
pub fn label(&self) -> String {
format!("#{}", self.slug)
}
}
#[derive(Debug, Clone)]
pub enum Atom {
Char(char),
Paste(PasteRef),
FileRef(FileRefAtom),
KnowledgeRef(KnowledgeRefAtom),
}
impl Atom {
@@ -74,7 +61,6 @@ impl Atom {
Atom::Char(_) => None,
Atom::Paste(p) => Some((Style::default().fg(Color::Magenta), p.label())),
Atom::FileRef(r) => Some((Style::default().fg(Color::Cyan), r.label())),
Atom::KnowledgeRef(r) => Some((Style::default().fg(Color::Green), r.label())),
}
}
}
@@ -83,7 +69,7 @@ impl Atom {
enum AtomClass {
Word(WordKind),
Sep,
/// Indivisible chip — paste / file ref / knowledge ref. Word motion treats one chip as one unit; deletion
/// Indivisible chip — paste / file ref. Word motion treats one chip as one unit; deletion
/// removes the whole atom.
Chip,
}
@@ -103,7 +89,7 @@ enum WordKind {
fn atom_class(atom: &Atom) -> AtomClass {
match atom {
Atom::Char(c) => char_class(*c),
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) => AtomClass::Chip,
Atom::Paste(_) | Atom::FileRef(_) => AtomClass::Chip,
}
}
@@ -195,10 +181,6 @@ impl InputBuffer {
self.atoms
.push(Atom::FileRef(FileRefAtom { path: path.clone() }));
}
protocol::Segment::KnowledgeRef { slug } => {
self.atoms
.push(Atom::KnowledgeRef(KnowledgeRefAtom { slug: slug.clone() }));
}
protocol::Segment::Unknown => {
self.atoms
.extend("[unknown input segment]".chars().map(Atom::Char));
@@ -226,7 +208,6 @@ impl InputBuffer {
Atom::Char(c) => text.push(*c),
Atom::Paste(paste) => text.push_str(&paste.content),
Atom::FileRef(file) => text.push_str(&file.path),
Atom::KnowledgeRef(knowledge) => text.push_str(&knowledge.slug),
}
}
text
@@ -264,13 +245,6 @@ impl InputBuffer {
self.cursor = start + 1;
}
pub fn replace_with_knowledge_ref(&mut self, start: usize, slug: String) {
self.atoms.drain(start..self.cursor);
self.atoms
.insert(start, Atom::KnowledgeRef(KnowledgeRefAtom { slug }));
self.cursor = start + 1;
}
/// Replace `atoms[start..self.cursor]` with the chars of `text`,
/// leaving cursor at the end of the inserted run. Used by the Tab
/// completion path: the popup-selected entry is inserted as raw
@@ -286,7 +260,7 @@ impl InputBuffer {
self.cursor = idx;
}
/// If the cursor is currently inside a `@<typed>` / `#<typed>` /
/// If the cursor is currently inside a `@<typed>` /
/// `/<typed>` token that satisfies the trigger rules, return the
/// kind, the index of the leading sigil atom, and the typed text
/// after the sigil (sigil itself excluded).
@@ -311,7 +285,7 @@ impl InputBuffer {
}
let kind = match c {
'@' => Some(protocol::CompletionKind::File),
'#' => Some(protocol::CompletionKind::Knowledge),
_ => None,
};
if let Some(k) = kind {
@@ -480,7 +454,7 @@ impl InputBuffer {
/// Build the typed `Vec<Segment>` sent over the protocol. Adjacent
/// `Atom::Char`s are concatenated into a single `Segment::Text`; each
/// chip atom (`Paste` / `FileRef` / `KnowledgeRef` )
/// chip atom (`Paste` / `FileRef`)
/// becomes a standalone `Segment` so that clients re-rendering an
/// `Event::UserMessage` see the same indivisible chip rather than a
/// flattened string.
@@ -510,12 +484,6 @@ impl InputBuffer {
path: r.path.clone(),
});
}
Atom::KnowledgeRef(r) => {
flush_text(&mut buf, &mut out);
out.push(protocol::Segment::KnowledgeRef {
slug: r.slug.clone(),
});
}
}
}
if !buf.is_empty() {
@@ -1028,14 +996,6 @@ mod completion_prefix_tests {
assert_eq!(prefix, "sr");
}
#[test]
fn hash_sigil_triggers_knowledge_completion() {
let buf = buf_from("#abc");
let (kind, _, prefix) = buf.pending_completion_prefix().unwrap();
assert_eq!(kind, CompletionKind::Knowledge);
assert_eq!(prefix, "abc");
}
#[test]
fn newline_before_cursor_invalidates_trigger() {
let buf = buf_from("@a\nbc");
@@ -1234,7 +1194,7 @@ mod word_motion_tests {
for a in &buf.atoms {
match a {
Atom::Char(c) => out.push(*c),
Atom::Paste(_) | Atom::FileRef(_) | Atom::KnowledgeRef(_) => out.push_str("<P>"),
Atom::Paste(_) | Atom::FileRef(_) => out.push_str("<P>"),
}
}
out
+1 -3
View File
@@ -965,7 +965,7 @@ fn push_padded_lines(lines: &mut Vec<Line<'static>>, text: &str, kind: MessageKi
/// Render `Block::UserMessage` from typed segments. Each non-text
/// segment renders as a one-piece chip whose colour matches the input
/// area's chip presentation (paste = magenta, `@` file = cyan,
/// `#` knowledge = green, `/` workflow = yellow), so the user
/// `/` workflow = yellow), so the user
/// recognises their own typed atoms in the scrollback.
fn render_user_message(
lines: &mut Vec<Line<'static>>,
@@ -1097,7 +1097,6 @@ fn chip_span_for(seg: &Segment, fallback: Style) -> (Style, String) {
format!("[Clipboard #{id} | {chars} chars, {line_count} lines]"),
),
Segment::FileRef { path } => (Style::default().fg(Color::Cyan), format!("@{path}")),
Segment::KnowledgeRef { slug } => (Style::default().fg(Color::Green), format!("#{slug}")),
Segment::Unknown => (fallback, "[unknown segment]".to_owned()),
}
}
@@ -1112,7 +1111,6 @@ fn segment_display_text(seg: &Segment) -> String {
id, chars, lines, ..
} => format!("[Clipboard #{id} | {chars} chars, {lines} lines]"),
Segment::FileRef { path } => format!("@{path}"),
Segment::KnowledgeRef { slug } => format!("#{slug}"),
Segment::Unknown => "[unknown segment]".to_owned(),
}
}
+2 -19
View File
@@ -108,15 +108,6 @@ impl WorkerHandle {
is_dir: c.is_dir,
})
.collect(),
protocol::CompletionKind::Knowledge => self
.shared_state
.list_knowledge_completions(prefix)
.into_iter()
.map(|c| protocol::CompletionEntry {
value: c.slug,
is_dir: false,
})
.collect(),
}
}
@@ -331,13 +322,6 @@ impl WorkerController {
if let Some(fs_for_view) = fs_for_view {
shared_state.set_fs_view(crate::fs_view::WorkerFsView::new(fs_for_view));
}
shared_state.set_knowledge(
worker
.knowledge_completions()
.into_iter()
.map(|slug| crate::shared_state::KnowledgeCandidate { slug })
.collect(),
);
runtime_dir.write_manifest(&manifest_toml).await?;
runtime_dir.write_status(&shared_state).await?;
@@ -752,7 +736,7 @@ where
// Memory tools require both explicit feature exposure and memory storage
// configuration. This keeps resident-memory config separate from the
// model-visible Memory*/Knowledge* tool surface.
// model-visible Memory* tool surface.
if feature_config.memory.enabled {
let mem = memory_config.as_ref().ok_or_else(|| {
std::io::Error::new(
@@ -775,8 +759,7 @@ where
worker.register_tool(memory::tool::write_tool(layout.clone()));
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(layout, query_cfg));
worker.register_tool(memory::tool::memory_query_tool(layout, query_cfg));
}
// Worker-orchestration tools (SpawnWorker + the four comm tools) share
-28
View File
@@ -83,11 +83,6 @@ pub enum WorkerPrompt {
/// AGENTS.md section when memory is enabled, summary injection is enabled,
/// and `memory/summary.md` has a valid non-empty body.
ResidentMemorySummarySection,
/// Trailing `## Resident knowledge` section, appended after the
/// resident memory summary when memory is enabled, Knowledge resident
/// injection is enabled, and at least one `knowledge/*` record advertises
/// `model_invokation: true`.
ResidentKnowledgeSection,
/// Trailing Worker orchestration guidance, appended when registered tools
/// include Worker-management capabilities.
WorkerOrchestrationGuidanceSection,
@@ -110,7 +105,6 @@ impl WorkerPrompt {
Self::WorkingBoundariesSection => "working_boundaries_section",
Self::AgentsMdSection => "agents_md_section",
Self::ResidentMemorySummarySection => "resident_memory_summary_section",
Self::ResidentKnowledgeSection => "resident_knowledge_section",
Self::WorkerOrchestrationGuidanceSection => "worker_orchestration_guidance_section",
Self::TicketEventCompanionNotice => "ticket_event_companion_notice",
Self::SpawnWorkerToolDescription => "spawn_worker_tool_description",
@@ -130,7 +124,6 @@ impl WorkerPrompt {
WorkerPrompt::WorkingBoundariesSection,
WorkerPrompt::AgentsMdSection,
WorkerPrompt::ResidentMemorySummarySection,
WorkerPrompt::ResidentKnowledgeSection,
WorkerPrompt::WorkerOrchestrationGuidanceSection,
WorkerPrompt::TicketEventCompanionNotice,
WorkerPrompt::SpawnWorkerToolDescription,
@@ -146,7 +139,6 @@ impl WorkerPrompt {
"working_boundaries_section",
"agents_md_section",
"resident_memory_summary_section",
"resident_knowledge_section",
"worker_orchestration_guidance_section",
"ticket_event_companion_notice",
"spawn_worker_tool_description",
@@ -384,25 +376,6 @@ impl PromptCatalog {
)
}
/// Render `WorkerPrompt::ResidentKnowledgeSection` with `{{ entries }}`
/// (a pre-formatted list block authored by the caller).
pub fn resident_knowledge_section(
&self,
entries: &str,
knowledge_query_available: bool,
memory_read_available: bool,
) -> Result<String, CatalogError> {
use std::collections::BTreeMap;
let mut m: BTreeMap<&'static str, Value> = BTreeMap::new();
m.insert("entries", Value::from(entries));
m.insert(
"knowledge_query_available",
Value::from(knowledge_query_available),
);
m.insert("memory_read_available", Value::from(memory_read_available));
self.render(WorkerPrompt::ResidentKnowledgeSection, Value::from(m))
}
/// Render `WorkerPrompt::WorkerOrchestrationGuidanceSection` (no inputs).
pub fn worker_orchestration_guidance_section(&self) -> Result<String, CatalogError> {
self.render(
@@ -554,7 +527,6 @@ mod tests {
let extract = cat.memory_extract_system("Japanese").unwrap();
let consolidate = cat.memory_consolidation_system("Japanese").unwrap();
for rendered in [compact, extract, consolidate] {
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("Do not query memory every turn"));
assert!(!rendered.contains("Strong lookup triggers include"));
}
+6 -141
View File
@@ -21,7 +21,6 @@ use std::sync::Arc;
use chrono::{DateTime, SecondsFormat, Utc};
use manifest::Scope;
use memory::ResidentKnowledgeEntry;
use minijinja::value::Value;
use minijinja::{Environment, ErrorKind, UndefinedBehavior};
use thiserror::Error;
@@ -125,7 +124,6 @@ impl SystemPromptTemplate {
ctx.scope,
ctx.agents_md.as_deref(),
ctx.resident_summary,
ctx.resident_knowledge,
ToolCapabilities::from_tool_names(&ctx.tool_names),
)
}
@@ -159,11 +157,6 @@ pub struct SystemPromptContext<'a> {
/// frontmatter stripped. `None` disables the resident summary section;
/// empty strings are ignored by the trailing-section formatter.
pub resident_summary: Option<&'a str>,
/// Resident-injection candidates from `<workspace>/knowledge/*` whose
/// frontmatter has `model_invokation: true`. `None` disables the
/// section entirely (memory disabled, or a consolidation worker that opts
/// out); `Some(&[])` also yields no section.
pub resident_knowledge: Option<&'a [ResidentKnowledgeEntry]>,
/// Catalog used to render the fixed trailing section headers.
/// Passed by reference so callers do not give up ownership across
/// the short-lived render borrow.
@@ -208,7 +201,6 @@ impl<'a> SystemPromptContext<'a> {
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
struct ToolCapabilities {
memory_query: bool,
knowledge_query: bool,
memory_read: bool,
memory_write: bool,
memory_edit: bool,
@@ -227,7 +219,6 @@ impl ToolCapabilities {
for name in names {
match name.as_str() {
"MemoryQuery" => capabilities.memory_query = true,
"KnowledgeQuery" => capabilities.knowledge_query = true,
"MemoryRead" => capabilities.memory_read = true,
"MemoryWrite" => capabilities.memory_write = true,
"MemoryEdit" => capabilities.memory_edit = true,
@@ -253,7 +244,7 @@ impl ToolCapabilities {
}
fn memory_any(self) -> bool {
self.memory_records() || self.knowledge_query
self.memory_records()
}
fn memory_mutation(self) -> bool {
@@ -274,7 +265,6 @@ impl ToolCapabilities {
map.insert("memory_any", Value::from(self.memory_any()));
map.insert("memory_records", Value::from(self.memory_records()));
map.insert("memory_query", Value::from(self.memory_query));
map.insert("knowledge_query", Value::from(self.knowledge_query));
map.insert("memory_read", Value::from(self.memory_read));
map.insert("memory_write", Value::from(self.memory_write));
map.insert("memory_edit", Value::from(self.memory_edit));
@@ -297,7 +287,6 @@ fn append_trailing_section(
scope: &Scope,
agents_md: Option<&str>,
resident_summary: Option<&str>,
resident_knowledge: Option<&[ResidentKnowledgeEntry]>,
tool_capabilities: ToolCapabilities,
) -> Result<String, SystemPromptError> {
let mut out = String::with_capacity(body.len() + 256);
@@ -325,19 +314,6 @@ fn append_trailing_section(
out.push('\n');
}
}
if let Some(entries) = resident_knowledge {
if !entries.is_empty() {
out.push('\n');
let formatted = format_resident_knowledge_entries(entries);
let section = prompts.resident_knowledge_section(
&formatted,
tool_capabilities.knowledge_query,
tool_capabilities.memory_read,
)?;
out.push_str(section.trim_end_matches(&['\n', ' '][..]));
out.push('\n');
}
}
if tool_capabilities.worker_management() {
out.push('\n');
let section = prompts.worker_orchestration_guidance_section()?;
@@ -352,36 +328,6 @@ fn append_trailing_section(
Ok(out)
}
/// `- <slug>: <description>` per line. Description newlines are folded
/// to spaces so a single entry stays on one row in the rendered prompt.
fn format_resident_knowledge_entries(entries: &[ResidentKnowledgeEntry]) -> String {
format_resident_entries(
entries
.iter()
.map(|e| (e.slug.as_str(), e.description.as_str())),
)
}
fn format_resident_entries<'a>(entries: impl Iterator<Item = (&'a str, &'a str)>) -> String {
let mut out = String::new();
for (i, (slug, description)) in entries.enumerate() {
if i > 0 {
out.push('\n');
}
out.push_str("- ");
out.push_str(slug);
out.push_str(": ");
for ch in description.chars() {
if ch == '\n' || ch == '\r' {
out.push(' ');
} else {
out.push(ch);
}
}
}
out
}
/// Bridge used by [`Worker::ensure_system_prompt_materialized`] so tests
/// can construct a synthetic context without going through a full Worker.
#[doc(hidden)]
@@ -426,7 +372,6 @@ mod tests {
tool_names: tools,
agents_md,
resident_summary: None,
resident_knowledge: None,
prompts: test_prompts(),
}
}
@@ -444,16 +389,11 @@ mod tests {
tool_names: Vec::new(),
agents_md: None,
resident_summary: summary,
resident_knowledge: None,
prompts: test_prompts(),
}
}
fn ctx_with_resident<'a>(
cwd: &'a Path,
scope: &'a Scope,
resident: &'a [ResidentKnowledgeEntry],
) -> SystemPromptContext<'a> {
fn ctx_with_resident<'a>(cwd: &'a Path, scope: &'a Scope) -> SystemPromptContext<'a> {
SystemPromptContext {
now: fixed_now(),
cwd: cwd.display().to_string().into(),
@@ -462,7 +402,6 @@ mod tests {
tool_names: Vec::new(),
agents_md: None,
resident_summary: None,
resident_knowledge: Some(resident),
prompts: test_prompts(),
}
}
@@ -470,7 +409,6 @@ mod tests {
fn memory_tool_names() -> Vec<String> {
[
"MemoryQuery",
"KnowledgeQuery",
"MemoryRead",
"MemoryWrite",
"MemoryEdit",
@@ -522,8 +460,8 @@ mod tests {
.render(&ctx(dir.path(), &scope, memory_tool_names(), None))
.unwrap();
// Builtin default body must expose the tool and language policies.
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("small targeted `MemoryQuery` / `KnowledgeQuery`"));
assert!(rendered.contains("### Memory"));
assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("Strong lookup triggers include"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(rendered.contains("Do not query memory every turn"));
@@ -550,9 +488,8 @@ mod tests {
))
.unwrap();
assert!(!rendered.contains("### Memory and knowledge"));
assert!(!rendered.contains("### Memory"));
assert!(!rendered.contains("MemoryQuery"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
@@ -576,10 +513,9 @@ mod tests {
))
.unwrap();
assert!(rendered.contains("### Memory and knowledge"));
assert!(rendered.contains("### Memory"));
assert!(rendered.contains("small targeted `MemoryQuery`"));
assert!(rendered.contains("MemoryRead(kind=summary)"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryWrite"));
assert!(!rendered.contains("MemoryEdit"));
assert!(!rendered.contains("MemoryDelete"));
@@ -838,75 +774,4 @@ mod tests {
.unwrap();
assert!(!rendered.contains("Resident memory summary"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_none() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl.render(&ctx(dir.path(), &scope, vec![], None)).unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_omits_resident_knowledge_when_empty_slice() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let rendered = tmpl
.render(&ctx_with_resident(dir.path(), &scope, &[]))
.unwrap();
assert!(!rendered.contains("Resident knowledge"));
}
#[test]
fn trailing_section_renders_resident_knowledge_entries() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let entries = vec![
ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
},
ResidentKnowledgeEntry {
slug: "beta".into(),
description: "second record\nwith newline".into(),
},
];
let rendered = tmpl
.render(&ctx_with_resident(dir.path(), &scope, &entries))
.unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("- alpha: first record"));
// Newline in description is folded to a space (one entry per line).
assert!(rendered.contains("- beta: second record with newline"));
assert!(!rendered.contains("KnowledgeQuery"));
assert!(!rendered.contains("MemoryRead"));
// Resident section sits *after* the working-boundaries header.
let pos_boundaries = rendered.find("## Working boundaries").unwrap();
let pos_resident = rendered.find("## Resident knowledge").unwrap();
assert!(pos_resident > pos_boundaries);
}
#[test]
fn trailing_section_mentions_resident_knowledge_tools_when_available() {
let (_tmp, loader) = user_loader_with("body.md", "BODY");
let tmpl = SystemPromptTemplate::parse("$user/body", loader).unwrap();
let dir = TempDir::new().unwrap();
let scope = build_scope(dir.path());
let entries = [ResidentKnowledgeEntry {
slug: "alpha".into(),
description: "first record".into(),
}];
let mut context = ctx_with_resident(dir.path(), &scope, &entries);
context.tool_names = memory_tool_names();
let rendered = tmpl.render(&context).unwrap();
assert!(rendered.contains("## Resident knowledge"));
assert!(rendered.contains("KnowledgeQuery / MemoryRead"));
}
}
+1 -56
View File
@@ -6,11 +6,6 @@ use session_store::SegmentId;
use crate::fs_view::WorkerFsView;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct KnowledgeCandidate {
pub slug: String,
}
/// Shared state between WorkerController and runtime directory.
///
/// Controller updates this in-memory; RuntimeDir writes the status
@@ -20,7 +15,7 @@ pub struct KnowledgeCandidate {
/// IPC layer could answer `Method::GetHistory`. Those reads now go
/// directly through the session-log sink (`Event::Snapshot` +
/// live events), so this struct holds only status, identity,
/// greeting, and completion lookup hubs.
/// greeting, and filesystem completion lookup hubs.
pub struct WorkerSharedState {
pub worker_name: String,
pub segment_id: SegmentId,
@@ -34,7 +29,6 @@ pub struct WorkerSharedState {
/// (only relevant for unit tests that build a `WorkerSharedState`
/// directly without spinning up a controller).
fs_view: OnceLock<WorkerFsView>,
knowledge: OnceLock<Vec<KnowledgeCandidate>>,
}
impl WorkerSharedState {
@@ -51,7 +45,6 @@ impl WorkerSharedState {
greeting,
status: RwLock::new(WorkerStatus::Idle),
fs_view: OnceLock::new(),
knowledge: OnceLock::new(),
}
}
@@ -67,23 +60,6 @@ impl WorkerSharedState {
self.fs_view.get()
}
pub fn set_knowledge(&self, knowledge: Vec<KnowledgeCandidate>) {
let _ = self.knowledge.set(knowledge);
}
pub fn list_knowledge_completions(&self, prefix: &str) -> Vec<KnowledgeCandidate> {
self.knowledge
.get()
.map(|items| {
items
.iter()
.filter(|candidate| candidate.slug.starts_with(prefix))
.cloned()
.collect()
})
.unwrap_or_default()
}
pub fn set_status(&self, status: WorkerStatus) {
if let Ok(mut s) = self.status.write() {
*s = status;
@@ -165,35 +141,4 @@ mod tests {
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["state"], "running");
}
#[test]
fn knowledge_completions_empty_when_unset() {
let state = test_state();
assert!(state.list_knowledge_completions("").is_empty());
assert!(state.list_knowledge_completions("foo").is_empty());
}
#[test]
fn knowledge_completions_filter_by_prefix() {
let state = test_state();
state.set_knowledge(vec![
KnowledgeCandidate {
slug: "alpha".into(),
},
KnowledgeCandidate {
slug: "alphabet".into(),
},
KnowledgeCandidate {
slug: "beta".into(),
},
]);
let all = state.list_knowledge_completions("");
assert_eq!(all.len(), 3);
let alpha = state.list_knowledge_completions("alpha");
assert_eq!(
alpha.iter().map(|c| c.slug.as_str()).collect::<Vec<_>>(),
vec!["alpha", "alphabet"]
);
assert!(state.list_knowledge_completions("zzz").is_empty());
}
}
+14 -253
View File
@@ -502,16 +502,15 @@ pub struct Worker<C: LlmClient, St: Store> {
/// [`Self::from_manifest`], or defaults to the builtin pack when a
/// Worker is constructed through lower-level paths that have no loader.
prompts: Arc<PromptCatalog>,
/// Memory workspace layout used for Memory/Knowledge record operations.
/// Memory workspace layout used for Memory record operations.
memory_layout: Option<memory::WorkspaceLayout>,
/// When true (default), the system-prompt assembler may append the
/// workspace memory summary (`memory/summary.md`). Internal disposable
/// workers disable this so resident memory exposure is opt-in per Worker.
inject_resident_summary: bool,
/// When true (default), the system-prompt assembler may append resident
/// Knowledge descriptions. This is intentionally independent from
/// resident context. This is intentionally independent from
/// summary residency: each section has its own gate.
inject_resident_knowledge: bool,
/// extract (memory.extract) reentry guard. `true` while an extract
/// worker is running; subsequent triggers are skipped per spec
/// (`docs/plan/memory.md` §Extract 並走防止). `Arc<AtomicBool>` so
@@ -617,7 +616,6 @@ impl<C: LlmClient + Clone + 'static, St: Store + Clone + 'static> Worker<C, St>
runtime_ticket_role: None,
prompts: self.prompts.clone(),
inject_resident_summary: self.inject_resident_summary,
inject_resident_knowledge: self.inject_resident_knowledge,
extract_in_flight: self.extract_in_flight.clone(),
consolidation_in_flight: self.consolidation_in_flight.clone(),
extract_pointer: self.extract_pointer.clone(),
@@ -806,7 +804,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
prompts,
memory_layout: None,
inject_resident_summary: true,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(None)),
@@ -833,11 +830,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
///
/// Default `true`: normal Workers may expose each resident section according
/// to its own gate and manifest settings. Internal disposable workers set
/// this to `false` so summary and Knowledge residency are both
/// suppressed while explicit tools remain available.
pub fn set_resident_injection(&mut self, enabled: bool) {
pub fn set_resident_memory_injection(&mut self, enabled: bool) {
self.inject_resident_summary = enabled;
self.inject_resident_knowledge = enabled;
}
/// Toggle `memory/summary.md` resident injection in the system prompt.
@@ -845,14 +840,8 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
self.inject_resident_summary = enabled;
}
/// Toggle resident Knowledge injection in the system prompt.
pub fn set_resident_knowledge_injection(&mut self, enabled: bool) {
self.inject_resident_knowledge = enabled;
}
/// Shared handle to the prompt catalog. Cheap to clone (`Arc`).
pub fn prompts(&self) -> &Arc<PromptCatalog> {
&self.prompts
pub fn prompts(&self) -> Arc<PromptCatalog> {
Arc::clone(&self.prompts)
}
/// The current segment ID. Read lock-free from the shared session
@@ -1450,9 +1439,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
let alerter = self.alerter.clone();
let tool_names: Vec<String> = {
let worker = self.engine.as_mut().expect("worker present");
// Materialise any pending tool factories so the template sees the
// full list of tool names. Redundant with the flush inside
// `Engine::lock()`; safe because `flush_pending` is idempotent.
worker.tool_server_handle().flush_pending();
worker
.tool_server_handle()
@@ -1472,11 +1458,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
}
}
}
// Resident-injection collection. Each resident section has its own
// gate so summary and Knowledge residency remain conceptually independent.
// Internal workers can still opt out of both resident sections.
// Owned values live for the duration of `render` below; the
// context borrows from them.
let memory_layout = self.memory_layout.as_ref();
let inject_summary = self.inject_resident_summary
&& memory_layout.is_some()
@@ -1491,21 +1472,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
} else {
None
};
let inject_resident_knowledge = self.inject_resident_knowledge && memory_layout.is_some();
let resident: Vec<memory::ResidentKnowledgeEntry> = if inject_resident_knowledge {
memory_layout
.map(memory::collect_resident_knowledge)
.unwrap_or_default()
} else {
Vec::new()
};
let resident_slice: Option<&[memory::ResidentKnowledgeEntry]> = if inject_resident_knowledge
{
Some(&resident)
} else {
None
};
let resident_exposure_snapshots = self.resident_exposure_snapshots(&resident);
let worker_language = worker_language(&self.manifest.engine);
let scope_snapshot = self.scope.snapshot();
let cwd_for_prompt = self
@@ -1520,7 +1486,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
tool_names,
agents_md: agents_md_read.and_then(|read| read.body),
resident_summary: resident_summary.as_deref(),
resident_knowledge: resident_slice,
prompts: &self.prompts,
};
let rendered = template
@@ -1530,7 +1495,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
.as_mut()
.expect("worker present")
.set_system_prompt(rendered);
self.append_resident_exposure_event(resident_exposure_snapshots);
Ok(())
}
@@ -1700,11 +1664,10 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
})?;
self.user_segments.push(input.clone());
// Resolve `@<path>` file refs and `#<slug>` Knowledge refs to system
// messages stashed for the WorkerInterceptor to attach right after the
// user message. Resolution failures are non-fatal alerts.
let mut attachments = self.resolve_file_refs(&input);
attachments.extend(self.resolve_knowledge_refs(&input));
// Resolve `@<path>` file refs to system messages stashed for the
// WorkerInterceptor to attach right after the user message. Resolution
// failures are non-fatal alerts.
let attachments = self.resolve_file_refs(&input);
let flattened = self.flatten_segments(&input);
if !attachments.is_empty() {
*self
@@ -1783,113 +1746,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
out
}
fn resolve_knowledge_refs(&self, segments: &[Segment]) -> Vec<SystemItem> {
let Some(layout) = self.memory_layout.as_ref() else {
return Vec::new();
};
let mut out = Vec::new();
for seg in segments {
let Segment::KnowledgeRef { slug } = seg else {
continue;
};
let parsed = match memory::Slug::parse(slug.clone()) {
Ok(slug) => slug,
Err(e) => {
self.alert(
AlertLevel::Warn,
AlertSource::Worker,
format!("knowledge ref #{slug} has invalid slug: {e}"),
);
continue;
}
};
let path = layout.knowledge_path(&parsed);
let bytes = match std::fs::read(&path) {
Ok(bytes) => bytes,
Err(e) => {
self.alert(
AlertLevel::Warn,
AlertSource::Worker,
format!("knowledge ref #{slug} could not be read: {e}"),
);
continue;
}
};
let raw = String::from_utf8_lossy(&bytes).into_owned();
let body_text = match memory::schema::split_frontmatter(&raw) {
Ok((_yaml, body)) => body,
Err(e) => {
self.alert(
AlertLevel::Warn,
AlertSource::Worker,
format!("knowledge ref #{slug} has invalid frontmatter: {e}"),
);
continue;
}
};
let snapshot = memory::snapshot_record_from_bytes(
memory::workspace::RecordKind::Knowledge,
slug.clone(),
&bytes,
);
self.append_memory_use_event(memory::UsageSource::KnowledgeRef, vec![snapshot]);
let body = format!("[Knowledge #{}]\n{}", slug, body_text.trim_end());
out.push(SystemItem::Knowledge {
slug: slug.clone(),
body,
});
}
out
}
fn resident_exposure_snapshots(
&self,
knowledge: &[memory::ResidentKnowledgeEntry],
) -> Vec<memory::UsageRecordSnapshot> {
let Some(layout) = self.memory_layout.as_ref() else {
return Vec::new();
};
let mut snapshots = Vec::new();
for entry in knowledge {
match memory::snapshot_record_from_layout(
layout,
memory::workspace::RecordKind::Knowledge,
&entry.slug,
) {
Ok(snapshot) => snapshots.push(snapshot),
Err(err) => {
warn!(knowledge = %entry.slug, error = %err, "failed to snapshot resident knowledge exposure")
}
}
}
snapshots
}
fn append_memory_use_event(
&self,
source: memory::UsageSource,
records: Vec<memory::UsageRecordSnapshot>,
) {
let Some(layout) = self.memory_layout.as_ref() else {
return;
};
if let Err(err) =
memory::append_use_event(layout, self.segment_id().to_string(), source, records)
{
warn!(error = %err, "failed to append memory usage event");
}
}
fn append_resident_exposure_event(&self, records: Vec<memory::UsageRecordSnapshot>) {
let Some(layout) = self.memory_layout.as_ref() else {
return;
};
if let Err(err) =
memory::append_resident_exposure_event(layout, self.segment_id().to_string(), records)
{
warn!(error = %err, "failed to append resident exposure event");
}
}
/// Stage the post-interruption cleanup at the front of worker
/// history: close every unanswered `Item::ToolCall` with a synthetic
/// `Item::ToolResult` (Anthropic wire-validity), then append a
@@ -1947,16 +1803,9 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
Ok(())
}
pub fn knowledge_completions(&self) -> Vec<String> {
self.memory_layout
.as_ref()
.map(memory::list_knowledge_slugs)
.unwrap_or_default()
}
/// Flatten a typed segment list into the single string the Engine
/// receives as the user message, and emit user-facing alerts for
/// segments that fall through to placeholder (Knowledge refs without a resolver, or unknown variants from a newer client).
/// segments that fall through to placeholder (unknown variants from a newer client).
/// `FileRef` is handled separately by `resolve_file_refs`. The text
/// reconstruction itself comes from `Segment::flatten_to_text`,
/// shared with replay paths that should not re-alert.
@@ -1964,18 +1813,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
for seg in segments {
match seg {
Segment::Text { .. } | Segment::Paste { .. } | Segment::FileRef { .. } => {}
Segment::KnowledgeRef { slug } => {
if self.memory_layout.is_none() {
self.alert(
AlertLevel::Warn,
AlertSource::Worker,
format!(
"knowledge ref #{slug} cannot be resolved \
because memory is disabled; passed to LLM as placeholder"
),
);
}
}
Segment::Unknown => {
self.alert(
AlertLevel::Warn,
@@ -3678,8 +3515,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
// directly under the workspace via WorkspaceLayout. Resident section
// injection is a Worker-level concern; this disposable Engine is built
// without it by construction, in keeping with `docs/plan/memory.md`
// §Consolidation のKnowledgeアクセス (agent pulls knowledge through
// the search tool instead of via system-prompt residency).
let query_cfg = memory::tool::QueryConfig::from(memory_cfg);
worker.register_tool(memory::tool::read_tool_with_usage(
layout.clone(),
@@ -3689,10 +3524,6 @@ impl<C: LlmClient, St: Store> Worker<C, St> {
worker.register_tool(memory::tool::edit_tool(layout.clone()));
worker.register_tool(memory::tool::delete_tool(layout.clone()));
worker.register_tool(memory::tool::memory_query_tool(layout.clone(), query_cfg));
worker.register_tool(memory::tool::knowledge_query_tool(
layout.clone(),
query_cfg,
));
let tidy = consolidate::collect_tidy_hints(&layout);
let usage_report = match memory::build_usage_report(&layout) {
@@ -4020,7 +3851,6 @@ where
prompts: common.prompts,
memory_layout: common.memory_layout,
inject_resident_summary: true,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(None)),
@@ -4127,7 +3957,6 @@ where
prompts: common.prompts,
memory_layout: common.memory_layout,
inject_resident_summary: true,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(None)),
@@ -4360,7 +4189,6 @@ where
prompts: common.prompts,
memory_layout: common.memory_layout,
inject_resident_summary: true,
inject_resident_knowledge: true,
extract_in_flight: Arc::new(AtomicBool::new(false)),
consolidation_in_flight: Arc::new(AtomicBool::new(false)),
extract_pointer: Arc::new(Mutex::new(extract_pointer)),
@@ -4876,10 +4704,6 @@ fn preview_segments(segments: &[Segment]) -> String {
preview.push('@');
preview.push_str(path);
}
Segment::KnowledgeRef { slug } => {
preview.push('#');
preview.push_str(slug);
}
Segment::Unknown => preview.push_str("[unknown input segment]"),
}
}
@@ -5972,15 +5796,11 @@ mod build_summary_prompt_tests {
#[derive(Clone, Copy)]
struct ResidentInjectionGates {
summary: bool,
knowledge: bool,
}
impl ResidentInjectionGates {
fn all(enabled: bool) -> Self {
Self {
summary: enabled,
knowledge: enabled,
}
Self { summary: enabled }
}
}
@@ -6002,7 +5822,7 @@ mod build_summary_prompt_tests {
summary_doc: Option<&str>,
memory_config: Option<manifest::MemoryConfig>,
gates: ResidentInjectionGates,
include_knowledge: bool,
_unused: bool,
) -> String {
let dir = tempfile::tempdir().unwrap();
let store = session_store::FsStore::new(dir.path().join("sessions")).unwrap();
@@ -6012,14 +5832,6 @@ mod build_summary_prompt_tests {
std::fs::create_dir_all(cwd.join(".yoi/memory")).unwrap();
std::fs::write(cwd.join(".yoi/memory/summary.md"), doc).unwrap();
}
if include_knowledge {
std::fs::create_dir_all(cwd.join(".yoi/knowledge")).unwrap();
std::fs::write(
cwd.join(".yoi/knowledge/resident-policy.md"),
knowledge_doc("knowledge resident desc"),
)
.unwrap();
}
let mut manifest = minimal_manifest();
manifest.memory = memory_config;
let scope = Scope::writable(&cwd).unwrap();
@@ -6039,12 +5851,7 @@ mod build_summary_prompt_tests {
.memory
.as_ref()
.map(|mem| memory::WorkspaceLayout::resolve(mem, &cwd));
if gates.summary == gates.knowledge {
worker.set_resident_injection(gates.summary);
} else {
worker.set_resident_summary_injection(gates.summary);
worker.set_resident_knowledge_injection(gates.knowledge);
}
worker.set_resident_memory_injection(gates.summary);
let template = SystemPromptTemplate::parse(
"$yoi/default",
crate::prompt::loader::PromptLoader::builtins_only(),
@@ -6059,12 +5866,6 @@ mod build_summary_prompt_tests {
format!("---\nupdated_at: 2026-01-01T00:00:00Z\n---\n{body}")
}
fn knowledge_doc(description: &str) -> String {
format!(
"---\ncreated_at: 2026-01-01T00:00:00Z\nupdated_at: 2026-01-01T00:00:00Z\nkind: policy\ndescription: \"{description}\"\nmodel_invokation: true\nuser_invocable: true\nlast_sources: []\n---\nbody\n",
)
}
#[tokio::test]
async fn resident_summary_body_is_injected_without_frontmatter() {
let rendered = render_system_prompt_with_summary(
@@ -6129,53 +5930,13 @@ mod build_summary_prompt_tests {
let prompt = render_system_prompt_with_resident_sections(
Some(&summary_doc("resident summary marker")),
Some(manifest::MemoryConfig::default()),
ResidentInjectionGates {
summary: false,
knowledge: true,
},
ResidentInjectionGates { summary: false },
true,
)
.await;
assert!(!prompt.contains("Resident memory summary"));
assert!(!prompt.contains("resident summary marker"));
assert!(prompt.contains("Resident knowledge"));
assert!(prompt.contains("knowledge resident desc"));
}
#[tokio::test]
async fn knowledge_gate_false_keeps_resident_summary() {
let prompt = render_system_prompt_with_resident_sections(
Some(&summary_doc("resident summary marker")),
Some(manifest::MemoryConfig::default()),
ResidentInjectionGates {
summary: true,
knowledge: false,
},
true,
)
.await;
assert!(prompt.contains("Resident memory summary"));
assert!(prompt.contains("resident summary marker"));
assert!(!prompt.contains("Resident knowledge"));
assert!(!prompt.contains("knowledge resident desc"));
}
#[tokio::test]
async fn resident_injection_opt_out_omits_all_resident_sections() {
let prompt = render_system_prompt_with_resident_sections(
Some(&summary_doc("resident summary marker")),
Some(manifest::MemoryConfig::default()),
ResidentInjectionGates::all(false),
true,
)
.await;
assert!(!prompt.contains("Resident memory summary"));
assert!(!prompt.contains("resident summary marker"));
assert!(!prompt.contains("Resident knowledge"));
assert!(!prompt.contains("knowledge resident desc"));
}
fn minimal_manifest() -> WorkerManifest {
+1 -2
View File
@@ -217,7 +217,6 @@ fn collect_record_paths(layout: &WorkspaceLayout) -> Result<Vec<PathBuf>, LintCl
collect_md_files(&layout.decisions_dir(), &mut paths)?;
collect_md_files(&layout.requests_dir(), &mut paths)?;
collect_md_files(&layout.knowledge_dir(), &mut paths)?;
Ok(paths)
}
@@ -413,7 +412,7 @@ mod tests {
}
#[test]
fn lints_only_workspace_memory_and_knowledge_records() {
fn lints_only_workspace_memory_records() {
let dir = TempDir::new().unwrap();
let root = dir.path();
write(&root.join(".yoi/memory/summary.md"), valid_summary());