508 lines
18 KiB
Rust
508 lines
18 KiB
Rust
use memory::backend::{
|
|
MemoryBackendAckOutput, MemoryBackendOperation, MemoryBackendOperationResult,
|
|
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryQueryOperation,
|
|
MemoryStagingCloseAction, MemoryStagingCloseOperation, MemoryStagingListOperation,
|
|
MemoryStagingReadOperation, MemoryStagingWriteOutput, MemoryToolOutput,
|
|
};
|
|
use memory::extract::{ExtractedCandidate, StagingEvidence, StagingRecord};
|
|
use memory::schema::{SourceEvidenceRef, SourceRef};
|
|
use serde_json::json;
|
|
use uuid::Uuid;
|
|
|
|
use crate::authority::MemoryAuthority;
|
|
use crate::{Error, Result};
|
|
|
|
const DEFAULT_READ_LIMIT: usize = 2000;
|
|
const MAX_STAGING_LIST_LIMIT: usize = 100;
|
|
|
|
pub fn execute_memory_backend_operation_with_authority<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryBackendOperation,
|
|
) -> Result<MemoryBackendOperationResult> {
|
|
match operation {
|
|
MemoryBackendOperation::Query(operation) => {
|
|
execute_query(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
}
|
|
MemoryBackendOperation::ReadDocument(operation) => {
|
|
execute_read_document(authority, operation)
|
|
.map(MemoryBackendOperationResult::ToolOutput)
|
|
}
|
|
MemoryBackendOperation::UpdateDocument(operation) => {
|
|
execute_update_document(authority, operation)
|
|
.map(MemoryBackendOperationResult::ToolOutput)
|
|
}
|
|
MemoryBackendOperation::ResidentSummary(_operation) => Ok(
|
|
MemoryBackendOperationResult::ToolOutput(execute_resident_summary(authority)?),
|
|
),
|
|
MemoryBackendOperation::AppendAudit(_operation) => Ok(
|
|
MemoryBackendOperationResult::Acknowledged(MemoryBackendAckOutput {
|
|
summary: "memory audit event accepted by Workspace authority".to_string(),
|
|
}),
|
|
),
|
|
MemoryBackendOperation::StageCandidate(operation) => {
|
|
let id = stage_candidate(
|
|
authority,
|
|
operation.source,
|
|
operation.extract_run_id,
|
|
operation.candidate,
|
|
operation.evidence,
|
|
operation.source_refs,
|
|
)?;
|
|
Ok(MemoryBackendOperationResult::StagingWritten(
|
|
MemoryStagingWriteOutput {
|
|
staging_count: 1,
|
|
staging_ids: vec![id],
|
|
},
|
|
))
|
|
}
|
|
MemoryBackendOperation::StageExtracted(operation) => {
|
|
let extract_run_id = Uuid::now_v7().to_string();
|
|
let mut ids = Vec::with_capacity(operation.payload.candidates.len());
|
|
for candidate in operation.payload.candidates {
|
|
ids.push(stage_candidate(
|
|
authority,
|
|
operation.source.clone(),
|
|
extract_run_id.clone(),
|
|
candidate,
|
|
Vec::new(),
|
|
Vec::new(),
|
|
)?);
|
|
}
|
|
Ok(MemoryBackendOperationResult::StagingWritten(
|
|
MemoryStagingWriteOutput {
|
|
staging_count: ids.len(),
|
|
staging_ids: ids,
|
|
},
|
|
))
|
|
}
|
|
MemoryBackendOperation::StagingList(operation) => {
|
|
execute_staging_list(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
}
|
|
MemoryBackendOperation::StagingRead(operation) => {
|
|
execute_staging_read(authority, operation).map(MemoryBackendOperationResult::ToolOutput)
|
|
}
|
|
MemoryBackendOperation::StagingClose(operation) => {
|
|
execute_staging_close(authority, operation)
|
|
.map(MemoryBackendOperationResult::ToolOutput)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn execute_resident_summary<A: MemoryAuthority>(authority: &A) -> Result<MemoryToolOutput> {
|
|
let document = authority.ensure_memory_document()?;
|
|
if document.body_md.trim().is_empty() {
|
|
Ok(MemoryToolOutput {
|
|
summary: "resident memory summary unavailable".to_string(),
|
|
content: None,
|
|
})
|
|
} else {
|
|
Ok(MemoryToolOutput {
|
|
summary: "resident memory document collected".to_string(),
|
|
content: Some(document.body_md),
|
|
})
|
|
}
|
|
}
|
|
|
|
fn execute_query<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryQueryOperation,
|
|
) -> Result<MemoryToolOutput> {
|
|
let document = authority.ensure_memory_document()?;
|
|
let query = operation
|
|
.query
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|query| !query.is_empty());
|
|
match query {
|
|
Some(query) => {
|
|
if let Some(body) = excerpt_matching(&document.body_md, query) {
|
|
Ok(MemoryToolOutput {
|
|
summary: format!("Memory document matched {query:?}"),
|
|
content: Some(body),
|
|
})
|
|
} else {
|
|
Ok(MemoryToolOutput {
|
|
summary: format!("No memory document content matched {query:?}"),
|
|
content: None,
|
|
})
|
|
}
|
|
}
|
|
None => Ok(MemoryToolOutput {
|
|
summary: "Memory document overview".to_string(),
|
|
content: Some(excerpt_head(&document.body_md, 120)),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn execute_read_document<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryDocumentReadOperation,
|
|
) -> Result<MemoryToolOutput> {
|
|
let document = authority.ensure_memory_document()?;
|
|
let offset = operation.offset.unwrap_or(0);
|
|
let limit = operation.limit.unwrap_or(DEFAULT_READ_LIMIT);
|
|
let lines: Vec<&str> = document.body_md.lines().collect();
|
|
let selected = lines
|
|
.iter()
|
|
.enumerate()
|
|
.skip(offset)
|
|
.take(limit)
|
|
.map(|(index, line)| format!("{:>6}\t{}", index + 1, line))
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
let total = lines.len();
|
|
Ok(MemoryToolOutput {
|
|
summary: format!(
|
|
"Read {} lines from Workspace memory document",
|
|
selected.lines().count()
|
|
),
|
|
content: Some(format!(
|
|
"total_lines={total} offset={offset} limit={limit}\n{selected}"
|
|
)),
|
|
})
|
|
}
|
|
|
|
fn execute_update_document<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryDocumentUpdateOperation,
|
|
) -> Result<MemoryToolOutput> {
|
|
if operation.old_string == operation.new_string {
|
|
return Err(Error::Store(
|
|
"memory document old_string and new_string must differ".to_string(),
|
|
));
|
|
}
|
|
if operation.old_string.is_empty() {
|
|
return Err(Error::Store(
|
|
"memory document old_string must not be empty".to_string(),
|
|
));
|
|
}
|
|
let document = authority.ensure_memory_document()?;
|
|
let match_count = document.body_md.matches(&operation.old_string).count();
|
|
if match_count == 0 {
|
|
return Err(Error::Store(
|
|
"memory document old_string was not found".to_string(),
|
|
));
|
|
}
|
|
if match_count > 1 && !operation.replace_all {
|
|
return Err(Error::Store(
|
|
"memory document old_string matched more than once; set replace_all=true to replace every occurrence".to_string(),
|
|
));
|
|
}
|
|
let updated_body = if operation.replace_all {
|
|
document
|
|
.body_md
|
|
.replace(&operation.old_string, &operation.new_string)
|
|
} else {
|
|
document
|
|
.body_md
|
|
.replacen(&operation.old_string, &operation.new_string, 1)
|
|
};
|
|
if updated_body.trim().is_empty() {
|
|
return Err(Error::Store(
|
|
"memory document body must not become empty".to_string(),
|
|
));
|
|
}
|
|
let updated = authority.update_memory_document(&updated_body)?;
|
|
Ok(MemoryToolOutput {
|
|
summary: format!(
|
|
"Edited {} occurrence(s) in Workspace memory document",
|
|
if operation.replace_all {
|
|
match_count
|
|
} else {
|
|
1
|
|
}
|
|
),
|
|
content: Some(format!(
|
|
"updated_at={} bytes={}",
|
|
updated.updated_at,
|
|
updated.body_md.len()
|
|
)),
|
|
})
|
|
}
|
|
|
|
fn stage_candidate<A: MemoryAuthority>(
|
|
authority: &A,
|
|
source: SourceRef,
|
|
extract_run_id: String,
|
|
candidate: ExtractedCandidate,
|
|
evidence: Vec<StagingEvidence>,
|
|
source_refs: Vec<SourceEvidenceRef>,
|
|
) -> Result<String> {
|
|
let candidate_id = Uuid::now_v7().to_string();
|
|
let record = StagingRecord::from_candidate(
|
|
candidate_id.clone(),
|
|
extract_run_id,
|
|
source,
|
|
candidate,
|
|
evidence,
|
|
source_refs,
|
|
);
|
|
let raw_json = serde_json::to_string_pretty(&record)
|
|
.map_err(|err| Error::Store(format!("serialize memory staging record: {err}")))?;
|
|
authority.upsert_memory_staging_record(&candidate_id, &raw_json, None)?;
|
|
Ok(candidate_id)
|
|
}
|
|
|
|
fn execute_staging_list<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryStagingListOperation,
|
|
) -> Result<MemoryToolOutput> {
|
|
let limit = operation.limit.unwrap_or(20).min(MAX_STAGING_LIST_LIMIT);
|
|
let entries = authority.list_memory_staging_records(limit)?;
|
|
let records = entries
|
|
.iter()
|
|
.map(|entry| {
|
|
staging_summary_json(
|
|
&entry.candidate_id,
|
|
&entry.raw_json,
|
|
entry.source_path.as_deref(),
|
|
)
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
Ok(MemoryToolOutput {
|
|
summary: format!(
|
|
"Listed {} memory staging candidate(s) from Workspace authority",
|
|
records.len()
|
|
),
|
|
content: Some(
|
|
serde_json::to_string_pretty(&records)
|
|
.map_err(|err| Error::Store(format!("serialize memory staging list: {err}")))?,
|
|
),
|
|
})
|
|
}
|
|
|
|
fn execute_staging_read<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryStagingReadOperation,
|
|
) -> Result<MemoryToolOutput> {
|
|
let entry = authority.memory_staging_record(&operation.candidate_id)?;
|
|
let raw: serde_json::Value = serde_json::from_str(&entry.raw_json)
|
|
.map_err(|err| Error::Store(format!("decode memory staging record: {err}")))?;
|
|
Ok(MemoryToolOutput {
|
|
summary: format!("Read memory staging candidate {}", entry.candidate_id),
|
|
content: Some(
|
|
serde_json::to_string_pretty(&raw)
|
|
.map_err(|err| Error::Store(format!("serialize memory staging record: {err}")))?,
|
|
),
|
|
})
|
|
}
|
|
|
|
fn execute_staging_close<A: MemoryAuthority>(
|
|
authority: &A,
|
|
operation: MemoryStagingCloseOperation,
|
|
) -> Result<MemoryToolOutput> {
|
|
if operation.reason.trim().is_empty() {
|
|
return Err(Error::Store("reason is required".to_string()));
|
|
}
|
|
let affected_refs_json = serde_json::to_string(&operation.affected_memory)
|
|
.map_err(|err| Error::Store(format!("serialize affected memory refs: {err}")))?;
|
|
let action = staging_close_action_name(&operation.action);
|
|
let resolution = authority.close_memory_staging_record(
|
|
&operation.candidate_id,
|
|
action,
|
|
&operation.reason,
|
|
&affected_refs_json,
|
|
)?;
|
|
Ok(MemoryToolOutput {
|
|
summary: format!("Closed memory staging candidate {}", resolution.candidate_id),
|
|
content: Some(
|
|
serde_json::to_string_pretty(&json!({
|
|
"candidate_id": resolution.candidate_id,
|
|
"action": resolution.action,
|
|
"reason": resolution.reason,
|
|
"affected_refs": serde_json::from_str::<serde_json::Value>(&resolution.affected_refs_json).unwrap_or_else(|_| json!([])),
|
|
"resolved_at": resolution.resolved_at,
|
|
"record_authority": resolution.record_source,
|
|
}))
|
|
.map_err(|err| Error::Store(format!("serialize memory staging resolution: {err}")))?,
|
|
),
|
|
})
|
|
}
|
|
|
|
fn staging_close_action_name(action: &MemoryStagingCloseAction) -> &'static str {
|
|
match action {
|
|
MemoryStagingCloseAction::Applied => "applied",
|
|
MemoryStagingCloseAction::Discarded => "discarded",
|
|
MemoryStagingCloseAction::Invalid => "invalid",
|
|
MemoryStagingCloseAction::Duplicate => "duplicate",
|
|
MemoryStagingCloseAction::AlreadyCovered => "already_covered",
|
|
}
|
|
}
|
|
|
|
fn staging_summary_json(
|
|
candidate_id: &str,
|
|
raw_json: &str,
|
|
source_path: Option<&str>,
|
|
) -> Result<serde_json::Value> {
|
|
let raw: serde_json::Value = serde_json::from_str(raw_json).map_err(|err| {
|
|
Error::Store(format!(
|
|
"decode memory staging record {candidate_id}: {err}"
|
|
))
|
|
})?;
|
|
Ok(json!({
|
|
"candidate_id": candidate_id,
|
|
"bytes": raw_json.len(),
|
|
"source_path": source_path,
|
|
"source": raw.get("source").cloned().unwrap_or(serde_json::Value::Null),
|
|
"kind": raw.get("kind").cloned().unwrap_or(serde_json::Value::Null),
|
|
"claim": raw.get("claim").cloned().unwrap_or(serde_json::Value::Null),
|
|
"why_useful": raw.get("why_useful").cloned().unwrap_or(serde_json::Value::Null),
|
|
"staleness": raw.get("staleness").cloned().unwrap_or(serde_json::Value::Null),
|
|
"evidence_count": raw.get("evidence").and_then(|value| value.as_array()).map_or(0, Vec::len),
|
|
"source_ref_count": raw.get("source_refs").and_then(|value| value.as_array()).map_or(0, Vec::len),
|
|
}))
|
|
}
|
|
|
|
fn excerpt_head(body: &str, max_lines: usize) -> String {
|
|
let mut lines = body.lines().take(max_lines).collect::<Vec<_>>().join("\n");
|
|
if body.lines().count() > max_lines {
|
|
lines.push_str("\n... [truncated]");
|
|
}
|
|
lines
|
|
}
|
|
|
|
fn excerpt_matching(body: &str, query: &str) -> Option<String> {
|
|
let query_lower = query.to_lowercase();
|
|
let lines = body.lines().collect::<Vec<_>>();
|
|
let index = lines
|
|
.iter()
|
|
.position(|line| line.to_lowercase().contains(&query_lower))?;
|
|
let start = index.saturating_sub(3);
|
|
let end = (index + 4).min(lines.len());
|
|
Some(
|
|
lines[start..end]
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(offset, line)| format!("{:>6}\t{}", start + offset + 1, line))
|
|
.collect::<Vec<_>>()
|
|
.join("\n"),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::authority::SqliteWorkspaceAuthority;
|
|
use memory::backend::{
|
|
MemoryDocumentReadOperation, MemoryDocumentUpdateOperation, MemoryStagingCloseAction,
|
|
MemoryStagingCloseOperation, MemoryStagingListOperation,
|
|
};
|
|
use memory::extract::{CandidateKind, ExtractedCandidate};
|
|
use memory::schema::SourceRef;
|
|
use tempfile::TempDir;
|
|
|
|
fn authority(temp: &TempDir) -> SqliteWorkspaceAuthority {
|
|
let db_path = temp.path().join("workspace.sqlite3");
|
|
let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace").unwrap();
|
|
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
|
conn.execute(
|
|
"INSERT INTO workspaces (
|
|
workspace_id, owner_account_id, display_name, state, created_at, updated_at
|
|
) VALUES (?1, NULL, ?2, ?3, ?4, ?4)",
|
|
rusqlite::params!["workspace", "Workspace", "active", "2026-01-01T00:00:00Z"],
|
|
)
|
|
.unwrap();
|
|
authority
|
|
}
|
|
|
|
fn source() -> SourceRef {
|
|
SourceRef {
|
|
segment_id: "segment-1".into(),
|
|
range: [1, 2],
|
|
}
|
|
}
|
|
|
|
fn candidate() -> ExtractedCandidate {
|
|
ExtractedCandidate {
|
|
kind: CandidateKind::Decision,
|
|
claim: "Use SQLite memory authority".into(),
|
|
why_useful: "Keeps Worker tools away from .yoi/memory filesystem authority".into(),
|
|
staleness: None,
|
|
evidence_ids: Vec::new(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn document_operations_use_workspace_authority() {
|
|
let temp = TempDir::new().unwrap();
|
|
let authority = authority(&temp);
|
|
execute_memory_backend_operation_with_authority(
|
|
&authority,
|
|
MemoryBackendOperation::UpdateDocument(MemoryDocumentUpdateOperation {
|
|
old_string: "# Memory\n\n".into(),
|
|
new_string: "# Memory\n\nSQLite backed body".into(),
|
|
replace_all: false,
|
|
}),
|
|
)
|
|
.unwrap();
|
|
|
|
let result = execute_memory_backend_operation_with_authority(
|
|
&authority,
|
|
MemoryBackendOperation::ReadDocument(MemoryDocumentReadOperation {
|
|
offset: None,
|
|
limit: None,
|
|
}),
|
|
)
|
|
.unwrap();
|
|
|
|
let MemoryBackendOperationResult::ToolOutput(output) = result else {
|
|
panic!("expected tool output")
|
|
};
|
|
assert!(output.content.unwrap().contains("SQLite backed body"));
|
|
}
|
|
|
|
#[test]
|
|
fn staging_close_moves_record_to_workspace_resolution() {
|
|
let temp = TempDir::new().unwrap();
|
|
let authority = authority(&temp);
|
|
let result = execute_memory_backend_operation_with_authority(
|
|
&authority,
|
|
MemoryBackendOperation::StageCandidate(
|
|
memory::backend::MemoryStageCandidateOperation {
|
|
source: source(),
|
|
extract_run_id: "run-1".into(),
|
|
candidate: candidate(),
|
|
evidence: Vec::new(),
|
|
source_refs: Vec::new(),
|
|
},
|
|
),
|
|
)
|
|
.unwrap();
|
|
let MemoryBackendOperationResult::StagingWritten(written) = result else {
|
|
panic!("expected staging write")
|
|
};
|
|
assert_eq!(written.staging_count, 1);
|
|
|
|
let list = execute_memory_backend_operation_with_authority(
|
|
&authority,
|
|
MemoryBackendOperation::StagingList(MemoryStagingListOperation { limit: None }),
|
|
)
|
|
.unwrap();
|
|
let MemoryBackendOperationResult::ToolOutput(list) = list else {
|
|
panic!("expected list")
|
|
};
|
|
assert!(
|
|
list.content
|
|
.unwrap()
|
|
.contains("Use SQLite memory authority")
|
|
);
|
|
|
|
execute_memory_backend_operation_with_authority(
|
|
&authority,
|
|
MemoryBackendOperation::StagingClose(MemoryStagingCloseOperation {
|
|
candidate_id: written.staging_ids[0].clone(),
|
|
action: MemoryStagingCloseAction::Applied,
|
|
reason: "merged into document".into(),
|
|
affected_memory: Vec::new(),
|
|
}),
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(authority.list_memory_staging_records(10).unwrap().len(), 0);
|
|
assert_eq!(
|
|
authority.list_memory_staging_resolutions(10).unwrap().len(),
|
|
1
|
|
);
|
|
}
|
|
}
|