feat: remove active knowledge support
This commit is contained in:
@@ -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"))
|
||||
|
||||
Reference in New Issue
Block a user