feat: remove active knowledge support
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 },
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.";
|
||||
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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!(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user