use std::sync::Arc;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::{Deserialize, Serialize};
use oxios_kernel::memory::{MemoryEntry, MemoryTier, MemoryType, ProtectionLevel};
use oxios_kernel::{SkillEntry, SkillSource, SkillStatus};
use crate::api::error::AppError;
use crate::api::routes::PageParams;
#[cfg(test)]
use crate::api::routes::paginate;
use crate::api::server::AppState;
#[derive(Debug, Deserialize)]
pub(crate) struct TreeQuery {
#[serde(default)]
pub dir: Option<String>,
}
#[derive(Debug, Serialize, Clone)]
pub(crate) struct TreeEntry {
name: String,
is_dir: bool,
size: u64,
}
pub(crate) async fn handle_workspace_tree(
state: State<Arc<AppState>>,
Query(query): Query<TreeQuery>,
) -> Result<Json<Vec<TreeEntry>>, AppError> {
let base = state.kernel.state.workspace_path();
let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
let dir = match &query.dir {
Some(d) => {
let candidate = base.join(d);
let canonical = match candidate.canonicalize() {
Ok(c) => c,
Err(_) => return Err(AppError::NotFound("directory not found".into())),
};
if !canonical.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
canonical
}
None => canonical_base,
};
let mut entries = Vec::new();
if let Ok(mut read_dir) = tokio::fs::read_dir(&dir).await {
while let Ok(Some(entry)) = read_dir.next_entry().await {
let metadata = match entry.metadata().await {
Ok(m) => m,
Err(_) => continue,
};
entries.push(TreeEntry {
name: entry.file_name().to_string_lossy().into_owned(),
is_dir: metadata.is_dir(),
size: metadata.len(),
});
}
}
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then(a.name.cmp(&b.name)));
Ok(Json(entries))
}
pub(crate) async fn handle_workspace_file_get(
state: State<Arc<AppState>>,
Path(path): Path<String>,
) -> Result<impl IntoResponse, AppError> {
let base = state.kernel.state.workspace_path();
let full_path = base.join(&path);
let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
let canonical_file = match full_path.canonicalize() {
Ok(p) => p,
Err(_) => return Err(AppError::NotFound("file not found".into())),
};
if !canonical_file.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
match tokio::fs::read_to_string(&canonical_file).await {
Ok(content) => {
let mime = guess_mime(&path);
Ok((
StatusCode::OK,
[(axum::http::header::CONTENT_TYPE, mime)],
content,
))
}
Err(_) => Err(AppError::NotFound("file not found".into())),
}
}
pub(crate) async fn handle_workspace_file_put(
state: State<Arc<AppState>>,
Path(path): Path<String>,
body: String,
) -> Result<(), AppError> {
const MAX_FILE_SIZE: usize = 1024 * 1024;
if body.len() > MAX_FILE_SIZE {
return Err(AppError::PayloadTooLarge {
size: body.len(),
limit: MAX_FILE_SIZE,
});
}
let base = state.kernel.state.workspace_path();
let full_path = base.join(&path);
let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
if let Some(parent) = full_path.parent() {
if !parent.exists() {
tokio::fs::create_dir_all(parent)
.await
.map_err(|e| AppError::Internal(format!("failed to create directory: {e}")))?;
}
let canonical_parent = parent
.canonicalize()
.map_err(|e| AppError::Internal(format!("failed to resolve path: {e}")))?;
if !canonical_parent.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
}
if let Ok(meta) = tokio::fs::symlink_metadata(&full_path).await
&& meta.file_type().is_symlink()
{
let canonical_full = full_path
.canonicalize()
.map_err(|e| AppError::Internal(format!("failed to resolve path: {e}")))?;
if !canonical_full.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
}
match tokio::fs::write(&full_path, &body).await {
Ok(_) => {
tracing::info!(path = %path, "File written");
Ok(())
}
Err(e) => {
tracing::error!(path = %path, error = %e, "Failed to write file");
Err(AppError::Internal("failed to write file".into()))
}
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct CreateFileRequest {
#[serde(default)]
pub is_dir: bool,
}
pub(crate) async fn handle_workspace_file_create(
state: State<Arc<AppState>>,
Path(path): Path<String>,
Json(body): Json<CreateFileRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let base = state.kernel.state.workspace_path();
let full_path = base.join(&path);
let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
if let Some(parent) = full_path.parent() {
let canonical_parent = parent
.canonicalize()
.map_err(|_| AppError::NotFound("parent directory not found".into()))?;
if !canonical_parent.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
}
if let Ok(meta) = tokio::fs::symlink_metadata(&full_path).await
&& meta.file_type().is_symlink()
{
let canonical_full = full_path
.canonicalize()
.map_err(|_| AppError::NotFound("path not found".into()))?;
if !canonical_full.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
}
if full_path.exists() {
return Err(AppError::BadRequest("file already exists".into()));
}
if body.is_dir {
tokio::fs::create_dir_all(&full_path)
.await
.map_err(|e| AppError::Internal(format!("failed to create directory: {e}")))?;
} else {
if let Some(parent) = full_path.parent() {
tokio::fs::create_dir_all(parent).await.ok();
}
tokio::fs::write(&full_path, "")
.await
.map_err(|e| AppError::Internal(format!("failed to create file: {e}")))?;
}
tracing::info!(path = %path, is_dir = body.is_dir, "File created");
Ok(Json(
serde_json::json!({ "status": "created", "path": path, "is_dir": body.is_dir }),
))
}
pub(crate) async fn handle_workspace_file_delete(
state: State<Arc<AppState>>,
Path(path): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let base = state.kernel.state.workspace_path();
let full_path = base.join(&path);
let canonical_base = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
let canonical = match full_path.canonicalize() {
Ok(c) => c,
Err(_) => return Err(AppError::NotFound("file not found".into())),
};
if !canonical.starts_with(&canonical_base) {
return Err(AppError::Forbidden("path traversal denied".into()));
}
if canonical.is_dir() {
let mut entries = tokio::fs::read_dir(&canonical)
.await
.map_err(|e| AppError::Internal(format!("failed to read directory: {e}")))?;
if entries
.next_entry()
.await
.map(|e| e.is_some())
.unwrap_or(true)
{
return Err(AppError::BadRequest("directory is not empty".into()));
}
tokio::fs::remove_dir(&canonical)
.await
.map_err(|e| AppError::Internal(format!("failed to delete directory: {e}")))?;
} else {
tokio::fs::remove_file(&canonical)
.await
.map_err(|e| AppError::Internal(format!("failed to delete file: {e}")))?;
}
tracing::info!(path = %path, "File deleted");
Ok(Json(
serde_json::json!({ "status": "deleted", "path": path }),
))
}
fn guess_mime(path: &str) -> String {
match path.rsplit('.').next() {
Some("md") => "text/markdown; charset=utf-8".into(),
Some("json") => "application/json".into(),
Some("toml") => "application/toml".into(),
Some("yaml" | "yml") => "application/yaml".into(),
Some("txt") => "text/plain; charset=utf-8".into(),
Some("html") => "text/html".into(),
Some("css") => "text/css".into(),
Some("js") => "application/javascript".into(),
_ => "text/plain; charset=utf-8".into(),
}
}
fn compact_path(path: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir() {
let home_str = home.to_string_lossy();
let path_str = path.to_string_lossy();
if let Some(rest) = path_str.strip_prefix(home_str.as_ref()) {
return format!("~{rest}");
}
}
path.to_string_lossy().into_owned()
}
fn skill_entry_to_json(entry: &SkillEntry) -> serde_json::Value {
let meta = entry.metadata.as_ref();
let source_str = match entry.source {
SkillSource::Bundled => "bundled",
SkillSource::Managed => "managed",
SkillSource::Workspace => "workspace",
};
let status_str = match entry.status {
SkillStatus::Ready => "ready",
SkillStatus::NeedsSetup => "needs_setup",
SkillStatus::Disabled => "disabled",
};
let requirements = meta
.map(|m| {
serde_json::json!({
"bins": m.requires.bins,
"anyBins": m.requires.any_bins,
"env": m.requires.env,
"config": m.requires.config,
})
})
.unwrap_or(serde_json::json!({
"bins": [],
"anyBins": [],
"env": [],
"config": [],
}));
let missing = serde_json::json!({
"bins": entry.eligibility.missing_bins,
"anyBins": entry.eligibility.missing_any_bins,
"env": entry.eligibility.missing_env,
"config": entry.eligibility.missing_config,
});
let install: Vec<serde_json::Value> = meta
.map(|m| {
m.install
.iter()
.map(|spec| {
let label = match spec.kind {
oxios_kernel::InstallKind::Brew => {
let name = spec.formula.as_deref().unwrap_or("unknown");
format!("Install {name} (brew)")
}
oxios_kernel::InstallKind::Node => {
let name = spec.package.as_deref().unwrap_or("unknown");
format!("Install {name} (npm)")
}
oxios_kernel::InstallKind::Go => {
let name = spec.module.as_deref().unwrap_or("unknown");
format!("Install {name} (go)")
}
oxios_kernel::InstallKind::Uv => {
let name = spec.package.as_deref().unwrap_or("unknown");
format!("Install {name} (uv)")
}
oxios_kernel::InstallKind::Download => "Download".to_string(),
};
let bins: Vec<String> = match spec.kind {
oxios_kernel::InstallKind::Brew => spec
.formula
.as_ref()
.map(|f| vec![f.clone()])
.unwrap_or_default(),
oxios_kernel::InstallKind::Node => spec
.package
.as_ref()
.map(|p| vec![p.clone()])
.unwrap_or_default(),
oxios_kernel::InstallKind::Go => spec
.module
.as_ref()
.map(|m| vec![m.clone()])
.unwrap_or_default(),
oxios_kernel::InstallKind::Uv => spec
.package
.as_ref()
.map(|p| vec![p.clone()])
.unwrap_or_default(),
oxios_kernel::InstallKind::Download => vec![],
};
serde_json::json!({
"kind": spec.kind.to_string(),
"label": label,
"bins": bins,
})
})
.collect()
})
.unwrap_or_default();
let os = meta.map(|m| m.os.clone()).unwrap_or_default();
let config_checks: Vec<serde_json::Value> = entry
.eligibility
.config_checks
.iter()
.map(|c| serde_json::json!({ "path": c.path, "satisfied": c.satisfied }))
.collect();
serde_json::json!({
"name": entry.skill.name,
"description": entry.skill.description,
"author": meta.and_then(|m| m.author.clone()).unwrap_or_default(),
"version": meta.and_then(|m| m.version.clone()).unwrap_or_default(),
"emoji": meta.and_then(|m| m.emoji.clone()).unwrap_or_default(),
"homepage": meta.and_then(|m| m.homepage.clone()).unwrap_or_default(),
"source": source_str,
"bundled": entry.bundled,
"status": status_str,
"eligible": entry.eligibility.eligible,
"always": meta.map(|m| m.always).unwrap_or(false),
"user_invocable": entry.invocation.user_invocable,
"file_path": compact_path(&entry.skill.file_path),
"requirements": requirements,
"missing": missing,
"os": os,
"install": install,
"config_checks": config_checks,
"format": entry.format.to_string(),
})
}
pub(crate) async fn handle_skills_list(
state: State<Arc<AppState>>,
Query(_params): Query<PageParams>,
) -> Json<serde_json::Value> {
let entries = state.kernel.extensions.list_skills_entries().await;
let skills: Vec<serde_json::Value> = entries.iter().map(skill_entry_to_json).collect();
Json(serde_json::json!({ "skills": skills }))
}
pub(crate) async fn handle_skill_get(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
match state.kernel.extensions.get_skill_entry(&name).await {
Some(entry) => Ok(Json(skill_entry_to_json(&entry))),
None => Err(AppError::NotFound("skill not found".into())),
}
}
pub(crate) async fn handle_skill_enable(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
state
.kernel
.extensions
.enable_skill(&name)
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?;
tracing::info!(skill = %name, "Skill enabled via API");
Ok(Json(
serde_json::json!({ "status": "enabled", "name": name }),
))
}
pub(crate) async fn handle_skill_disable(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
state
.kernel
.extensions
.disable_skill(&name)
.await
.map_err(|e| AppError::BadRequest(e.to_string()))?;
tracing::info!(skill = %name, "Skill disabled via API");
Ok(Json(
serde_json::json!({ "status": "disabled", "name": name }),
))
}
pub(crate) async fn handle_skill_content(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let content = state
.kernel
.extensions
.skill_manager()
.get_skill_content(&name)
.await;
match content {
Some(md) => Ok(Json(serde_json::json!({
"name": name,
"content": md,
}))),
None => Err(AppError::NotFound("skill not found".into())),
}
}
#[derive(Debug, Deserialize)]
pub(crate) struct SkillCreateRequest {
name: String,
description: String,
#[serde(default)]
content: String,
}
pub(crate) async fn handle_skill_create(
state: State<Arc<AppState>>,
Json(body): Json<SkillCreateRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
const MAX_SKILL_CONTENT: usize = 64 * 1024;
if body.content.len() > MAX_SKILL_CONTENT {
return Err(AppError::PayloadTooLarge {
size: body.content.len(),
limit: MAX_SKILL_CONTENT,
});
}
state
.kernel
.extensions
.create_skill(&body.name, &body.description, &body.content)
.await
.map_err(|e| {
tracing::error!(error = %e, skill = %body.name, "Failed to create skill");
AppError::BadRequest(e.to_string())
})?;
tracing::info!(skill = %body.name, "Skill created via API");
Ok(Json(serde_json::json!({
"status": "created",
"name": body.name,
})))
}
pub(crate) async fn handle_skill_delete(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
state
.kernel
.extensions
.delete_skill(&name)
.await
.map_err(|e| {
tracing::error!(error = %e, skill = %name, "Failed to delete skill");
AppError::BadRequest(e.to_string())
})?;
tracing::info!(skill = %name, "Skill deleted via API");
Ok(Json(serde_json::json!({
"status": "deleted",
"name": name,
})))
}
fn tier_label(t: &MemoryTier) -> &'static str {
match t {
MemoryTier::Hot => "hot",
MemoryTier::Warm => "warm",
MemoryTier::Cold => "cold",
}
}
fn memory_entry_to_detail(e: &MemoryEntry) -> serde_json::Value {
serde_json::json!({
"id": e.id,
"key": e.id,
"tier": tier_label(&e.tier),
"memory_type": e.memory_type.label(),
"content": e.content,
"summary": null,
"project_ids": [],
"created_at": e.created_at.to_rfc3339(),
"updated_at": e.modified_at.to_rfc3339(),
"last_accessed": e.accessed_at.to_rfc3339(),
"access_count": e.access_count,
"pinned": e.pinned,
"protected": !matches!(e.protection, ProtectionLevel::None),
"protection_reason": null,
"tags": e.tags,
"importance": e.importance,
})
}
#[derive(Debug, Deserialize)]
pub(crate) struct MemoryListQuery {
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub page: Option<usize>,
#[serde(default)]
pub limit: Option<usize>,
}
pub(crate) async fn handle_memory_list(
state: State<Arc<AppState>>,
Query(q): Query<MemoryListQuery>,
) -> Json<serde_json::Value> {
let limit = q.limit.unwrap_or(50).clamp(1, 500);
let mut entries = state.kernel.agents.list_all_memories(500).await;
if let Some(t) = &q.r#type {
entries.retain(|e| e.memory_type.label() == t.as_str());
}
if let Some(tier) = &q.tier {
entries.retain(|e| tier_label(&e.tier) == tier.as_str());
}
let total = entries.len();
let page = q.page.unwrap_or(1).max(1);
let start = (page - 1) * limit;
let items: Vec<_> = entries
.iter()
.skip(start)
.take(limit)
.map(memory_entry_to_detail)
.collect();
Json(serde_json::json!({
"items": items,
"total": total,
"page": page,
"limit": limit,
}))
}
pub(crate) async fn handle_memory_get(
state: State<Arc<AppState>>,
Path(name): Path<String>,
) -> Result<impl IntoResponse, AppError> {
match state.kernel.agents.get_memory(&name).await {
Some(entry) => Ok(Json(memory_entry_to_detail(&entry)).into_response()),
None => Err(AppError::NotFound("memory entry not found".into())),
}
}
#[derive(Default, Clone)]
pub struct MemoryMapCache {
inner: std::sync::Arc<std::sync::Mutex<Option<MemoryMapCacheEntry>>>,
}
#[derive(Clone)]
struct MemoryMapCacheEntry {
epoch: u64,
ids: Vec<String>,
content_signature: u64,
entries: Vec<oxios_kernel::memory::MemoryMapEntry>,
}
impl MemoryMapCache {
fn get(
&self,
epoch: u64,
ids: &[String],
content_signature: u64,
) -> Option<Vec<oxios_kernel::memory::MemoryMapEntry>> {
let guard = self.inner.lock().ok()?;
let entry = guard.as_ref()?;
if entry.epoch != epoch {
return None;
}
if entry.ids != ids {
return None;
}
if entry.content_signature != content_signature {
return None;
}
Some(entry.entries.clone())
}
fn put(
&self,
epoch: u64,
ids: Vec<String>,
content_signature: u64,
entries: Vec<oxios_kernel::memory::MemoryMapEntry>,
) {
if let Ok(mut guard) = self.inner.lock() {
*guard = Some(MemoryMapCacheEntry {
epoch,
ids,
content_signature,
entries,
});
}
}
}
fn memory_map_content_signature(entries: &[MemoryEntry]) -> u64 {
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
let mut hasher = DefaultHasher::new();
let mut sorted: Vec<&MemoryEntry> = entries.iter().collect();
sorted.sort_by(|a, b| a.id.cmp(&b.id));
for e in sorted {
e.id.hash(&mut hasher);
e.content.hash(&mut hasher);
let tier_str = match e.tier {
oxios_kernel::memory::MemoryTier::Hot => "hot",
oxios_kernel::memory::MemoryTier::Warm => "warm",
oxios_kernel::memory::MemoryTier::Cold => "cold",
};
tier_str.hash(&mut hasher);
e.memory_type.label().hash(&mut hasher);
}
hasher.finish()
}
#[derive(Debug, Deserialize)]
pub(crate) struct MemoryMapQuery {
#[serde(default)]
pub tier: Option<String>,
#[serde(default)]
pub mem_type: Option<String>,
#[serde(default)]
pub limit: Option<usize>,
}
const MEMORY_MAP_EPOCH_SECS: u64 = 300;
pub(crate) async fn handle_memory_map(
state: State<Arc<AppState>>,
Query(params): Query<MemoryMapQuery>,
) -> Result<Json<serde_json::Value>, AppError> {
let limit = params.limit.unwrap_or(500).clamp(1, 2000);
let mut entries: Vec<MemoryEntry> = Vec::new();
for category in [
"memory/facts",
"memory/episodes",
"memory/knowledge",
"memory/sessions",
"memory/conversations",
"memory/skills",
"memory/preferences",
"memory/decisions",
"memory/profiles",
] {
let Ok(names) = state.kernel.state.list_category(category).await else {
continue;
};
for name in names {
if entries.len() >= limit {
break;
}
let Ok(Some(entry)) = state
.kernel
.state
.load::<MemoryEntry>(category, &name)
.await
else {
continue;
};
if let Some(ref want) = params.mem_type
&& entry.memory_type.label() != want.as_str()
{
continue;
}
if let Some(ref want_tier) = params.tier {
let tier_str = match entry.tier {
oxios_kernel::memory::MemoryTier::Hot => "hot",
oxios_kernel::memory::MemoryTier::Warm => "warm",
oxios_kernel::memory::MemoryTier::Cold => "cold",
};
if tier_str != want_tier.as_str() {
continue;
}
}
entries.push(entry);
}
if entries.len() >= limit {
break;
}
}
entries.truncate(limit);
let map_entries = compute_memory_map_entries(&state, &entries).await;
Ok(Json(serde_json::json!({
"count": map_entries.len(),
"epoch": current_epoch_secs() / MEMORY_MAP_EPOCH_SECS,
"entries": map_entries,
})))
}
async fn compute_memory_map_entries(
state: &Arc<AppState>,
entries: &[MemoryEntry],
) -> Vec<oxios_kernel::memory::MemoryMapEntry> {
use oxios_kernel::embedding::EmbeddingProvider;
use oxios_kernel::memory::{MemoryMapEntry, compute_pca_2d, compute_top_neighbors};
if entries.is_empty() {
return Vec::new();
}
let ids: Vec<String> = entries.iter().map(|e| e.id.clone()).collect();
let epoch = current_epoch_secs() / MEMORY_MAP_EPOCH_SECS;
let content_signature = memory_map_content_signature(entries);
if let Some(cached) = state.memory_map_cache.get(epoch, &ids, content_signature) {
return cached;
}
let provider = oxios_kernel::embedding::TfIdfEmbeddingProvider;
let mut term_index: std::collections::HashMap<String, u32> = std::collections::HashMap::new();
let mut tf_vecs: Vec<Vec<(u32, f32)>> = Vec::with_capacity(entries.len());
for entry in entries {
let Ok(emb) = provider.embed(&entry.content).await else {
tf_vecs.push(Vec::new());
continue;
};
let oxios_kernel::embedding::EmbeddingVector::Sparse(tf) = emb else {
tf_vecs.push(Vec::new());
continue;
};
let mut pairs: Vec<(u32, f32)> = tf
.into_iter()
.map(|(term, w)| {
let next = term_index.len() as u32;
let idx = *term_index.entry(term).or_insert(next);
(idx, w as f32)
})
.collect();
pairs.sort_by_key(|(idx, _)| *idx);
pairs.dedup_by_key(|(idx, _)| *idx);
tf_vecs.push(pairs);
}
let dim = term_index.len();
let dense: Vec<Vec<f32>> = tf_vecs
.iter()
.map(|pairs| {
let mut v = vec![0.0_f32; dim];
for (idx, w) in pairs {
if let Some(slot) = v.get_mut(*idx as usize) {
*slot = *w;
}
}
v
})
.collect();
let coords = compute_pca_2d(&dense);
let top_n = compute_top_neighbors(&dense, &ids, 5, 0.7);
let map_entries: Vec<MemoryMapEntry> = entries
.iter()
.zip(coords.iter().zip(top_n.iter()))
.map(|(entry, (xy, nbrs))| MemoryMapEntry {
id: entry.id.clone(),
tier: match entry.tier {
oxios_kernel::memory::MemoryTier::Hot => "hot".into(),
oxios_kernel::memory::MemoryTier::Warm => "warm".into(),
oxios_kernel::memory::MemoryTier::Cold => "cold".into(),
},
mem_type: entry.memory_type.label().to_string(),
content_preview: content_preview(&entry.content, 120),
created_at: entry.created_at.to_rfc3339(),
access_count: entry.access_count,
coords_2d: *xy,
top_neighbors: nbrs.clone(),
})
.collect();
state
.memory_map_cache
.put(epoch, ids, content_signature, map_entries.clone());
map_entries
}
fn content_preview(content: &str, max_chars: usize) -> String {
let trimmed: String = content.chars().take(max_chars).collect();
if content.chars().count() > max_chars {
format!("{trimmed}\u{2026}")
} else {
trimmed
}
}
fn current_epoch_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[derive(Debug, Deserialize)]
pub(crate) struct MemoryCreateRequest {
content: String,
#[serde(default = "default_memory_type")]
memory_type: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default = "default_importance")]
importance: f32,
}
fn default_memory_type() -> String {
"fact".to_string()
}
fn default_importance() -> f32 {
0.5
}
pub(crate) async fn handle_memory_create(
state: State<Arc<AppState>>,
Json(body): Json<MemoryCreateRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
const MAX_MEMORY_ENTRY: usize = 32 * 1024;
if body.content.len() > MAX_MEMORY_ENTRY {
return Err(AppError::PayloadTooLarge {
size: body.content.len(),
limit: MAX_MEMORY_ENTRY,
});
}
let memory_type = match body.memory_type.as_str() {
"fact" => MemoryType::Fact,
"episode" => MemoryType::Episode,
"knowledge" => MemoryType::Knowledge,
_ => {
return Err(AppError::BadRequest(
"memory_type must be fact, episode, or knowledge".into(),
));
}
};
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
memory_type,
tier: memory_type.initial_tier(),
content: body.content.clone(),
content_hash: oxios_kernel::memory::content_hash(&body.content),
source: "api".to_string(),
session_id: None,
tags: body.tags.clone(),
importance: body.importance,
pinned: false,
protection: oxios_kernel::memory::ProtectionLevel::None,
auto_classified: false,
session_appearances: 0,
user_corrected: false,
seen_in_sessions: vec![],
created_at: chrono::Utc::now(),
accessed_at: chrono::Utc::now(),
modified_at: chrono::Utc::now(),
access_count: 0,
decay_score: 1.0,
compaction_level: 0,
compacted_from: vec![],
related_ids: vec![],
contradicts: None,
};
let id = state
.kernel
.agents
.remember(entry)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
Ok(Json(serde_json::json!({ "id": id, "status": "created" })))
}
#[derive(Debug, Deserialize)]
pub(crate) struct MemorySearchRequest {
query: String,
memory_type: Option<String>,
limit: Option<usize>,
}
pub(crate) async fn handle_memory_search(
state: State<Arc<AppState>>,
Json(body): Json<MemorySearchRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let type_filter = body.memory_type.as_deref().and_then(|s| match s {
"conversation" => Some(MemoryType::Conversation),
"session" => Some(MemoryType::Session),
"fact" => Some(MemoryType::Fact),
"episode" => Some(MemoryType::Episode),
"knowledge" => Some(MemoryType::Knowledge),
_ => None,
});
let limit = body.limit.unwrap_or(10);
let entries = state
.kernel
.agents
.search_memory(&body.query, type_filter, limit)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let results: Vec<serde_json::Value> = entries
.iter()
.map(|e| {
serde_json::json!({
"id": e.id,
"type": e.memory_type.label(),
"content": e.content,
"tags": e.tags,
"importance": e.importance,
"created_at": e.created_at.to_rfc3339(),
})
})
.collect();
Ok(Json(
serde_json::json!({ "count": results.len(), "entries": results }),
))
}
#[derive(Debug, Deserialize)]
pub(crate) struct SemanticSearchRequest {
query: String,
memory_type: Option<String>,
limit: Option<usize>,
}
pub(crate) async fn handle_memory_semantic_search(
state: State<Arc<AppState>>,
Json(body): Json<SemanticSearchRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
let type_filter = body.memory_type.as_deref().and_then(|s| match s {
"conversation" => Some(MemoryType::Conversation),
"session" => Some(MemoryType::Session),
"fact" => Some(MemoryType::Fact),
"episode" => Some(MemoryType::Episode),
"knowledge" => Some(MemoryType::Knowledge),
_ => None,
});
let limit = body.limit.unwrap_or(10);
let engine_label = if state.kernel.agents.has_hnsw_index() {
"hnsw"
} else {
"keyword"
};
let hits = state
.kernel
.agents
.semantic_search_memory(&body.query, type_filter, limit)
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let results: Vec<serde_json::Value> = hits
.iter()
.map(|hit| {
serde_json::json!({
"id": hit.entry.id,
"type": hit.entry.memory_type.label(),
"content": hit.entry.content,
"tags": hit.entry.tags,
"importance": hit.entry.importance,
"similarity": hit.similarity,
"distance": hit.distance,
"created_at": hit.entry.created_at.to_rfc3339(),
})
})
.collect();
Ok(Json(serde_json::json!({
"count": results.len(),
"entries": results,
"engine": engine_label,
})))
}
pub(crate) async fn handle_memory_stats(
state: State<Arc<AppState>>,
) -> Result<Json<serde_json::Value>, AppError> {
let entries = state.kernel.agents.list_all_memories(10_000).await;
let mut by_tier: std::collections::BTreeMap<&str, u64> = std::collections::BTreeMap::new();
let mut by_type: std::collections::BTreeMap<&str, u64> = std::collections::BTreeMap::new();
let mut total_size_bytes = 0usize;
let mut oldest: Option<chrono::DateTime<chrono::Utc>> = None;
let mut newest: Option<chrono::DateTime<chrono::Utc>> = None;
for e in &entries {
*by_tier.entry(tier_label(&e.tier)).or_default() += 1;
*by_type.entry(e.memory_type.label()).or_default() += 1;
total_size_bytes += e.content.len();
oldest = Some(oldest.map_or(e.created_at, |o| o.min(e.created_at)));
newest = Some(newest.map_or(e.created_at, |n| n.max(e.created_at)));
}
let by_tier_json: serde_json::Map<String, serde_json::Value> = by_tier
.into_iter()
.map(|(k, v)| (k.into(), serde_json::Value::from(v)))
.collect();
let by_type_json: serde_json::Map<String, serde_json::Value> = by_type
.into_iter()
.map(|(k, v)| (k.into(), serde_json::Value::from(v)))
.collect();
Ok(Json(serde_json::json!({
"total": entries.len(),
"by_tier": by_tier_json,
"by_type": by_type_json,
"total_size_bytes": total_size_bytes,
"oldest_created": oldest.map(|d| d.to_rfc3339()),
"newest_created": newest.map(|d| d.to_rfc3339()),
})))
}
#[derive(Debug, Deserialize)]
pub(crate) struct PinRequest {
pinned: bool,
}
pub(crate) async fn handle_memory_pin(
state: State<Arc<AppState>>,
Path(id): Path<String>,
Json(body): Json<PinRequest>,
) -> Result<Json<serde_json::Value>, AppError> {
if state
.kernel
.agents
.set_memory_pinned(&id, body.pinned)
.await
{
Ok(Json(serde_json::json!({ "id": id, "pinned": body.pinned })))
} else {
Err(AppError::NotFound("memory entry not found".into()))
}
}
pub(crate) async fn handle_memory_delete(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
if state.kernel.agents.forget_memory(&id).await {
Ok(Json(serde_json::json!({ "id": id, "deleted": true })))
} else {
Err(AppError::NotFound("memory entry not found".into()))
}
}
pub(crate) async fn handle_dream_reports(_state: State<Arc<AppState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({ "reports": [] }))
}
pub(crate) async fn handle_dream_status(_state: State<Arc<AppState>>) -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "idle",
"last_run": null,
"checkpoint_exists": false,
}))
}
#[allow(dead_code)]
pub(crate) async fn handle_seed_agents(
state: State<Arc<AppState>>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, AppError> {
let agents = state
.kernel
.agents
.list()
.await
.map_err(|e| AppError::Internal(e.to_string()))?;
let filtered: Vec<serde_json::Value> = agents
.into_iter()
.filter(|a| a.seed_id.as_ref().map(|s| s.to_string()) == Some(id.clone()))
.map(|a| {
serde_json::json!({
"id": a.id.to_string(),
"name": a.name,
"status": a.status.to_string(),
"steps_completed": 0,
"created_at": a.created_at.to_rfc3339(),
})
})
.collect();
Ok(Json(serde_json::json!({ "agents": filtered })))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_tree_entry_serialization() {
let entry = TreeEntry {
name: "hello.md".into(),
is_dir: false,
size: 1024,
};
let json = serde_json::to_value(&entry).unwrap();
assert_eq!(json["name"], "hello.md");
assert_eq!(json["is_dir"], false);
assert_eq!(json["size"], 1024);
let dir_entry = TreeEntry {
name: "src".into(),
is_dir: true,
size: 0,
};
let json = serde_json::to_value(&dir_entry).unwrap();
assert_eq!(json["is_dir"], true);
assert_eq!(json["size"], 0);
}
#[test]
fn test_pagination_bounds() {
let items: Vec<i32> = (1..=10).collect();
let p1 = PageParams { page: 1, limit: 3 };
let result = paginate(&items, &p1);
assert_eq!(result["total"], 10);
assert_eq!(result["page"], 1);
assert_eq!(result["limit"], 3);
let returned: Vec<i32> = serde_json::from_value(result["items"].clone()).unwrap();
assert_eq!(returned, vec![1, 2, 3]);
let p4 = PageParams { page: 4, limit: 3 };
let result = paginate(&items, &p4);
let returned: Vec<i32> = serde_json::from_value(result["items"].clone()).unwrap();
assert_eq!(returned, vec![10]);
let p0 = PageParams { page: 0, limit: 3 };
let result = paginate(&items, &p0);
let returned: Vec<i32> = serde_json::from_value(result["items"].clone()).unwrap();
assert_eq!(returned, vec![1, 2, 3]);
let big = PageParams {
page: 1,
limit: 9999,
};
let result = paginate(&items, &big);
assert_eq!(result["limit"], 500);
}
#[test]
fn test_guess_mime_common_types() {
assert_eq!(guess_mime("main.rs"), "text/plain; charset=utf-8");
assert_eq!(guess_mime("Cargo.toml"), "application/toml");
assert_eq!(guess_mime("README.md"), "text/markdown; charset=utf-8");
assert_eq!(guess_mime("data.json"), "application/json");
assert_eq!(guess_mime("app.js"), "application/javascript");
assert_eq!(guess_mime("index.html"), "text/html");
assert_eq!(guess_mime("unknown.bin"), "text/plain; charset=utf-8");
}
#[test]
fn test_memory_type_validation() {
let valid = vec!["fact", "episode", "knowledge"];
for t in valid {
let mt = match t {
"fact" => Some(MemoryType::Fact),
"episode" => Some(MemoryType::Episode),
"knowledge" => Some(MemoryType::Knowledge),
_ => None,
};
assert!(mt.is_some(), "expected '{t}' to be a valid memory type");
}
let invalid = vec!["invalid", "", "FACT", "EpIsOdE"];
for t in invalid {
let mt: Option<MemoryType> = match t {
"fact" => Some(MemoryType::Fact),
"episode" => Some(MemoryType::Episode),
"knowledge" => Some(MemoryType::Knowledge),
_ => None,
};
assert!(mt.is_none(), "expected '{t}' to be rejected");
}
}
#[test]
fn test_file_size_limit_enforcement() {
const MAX_FILE_SIZE: usize = 1024 * 1024;
let body_at_limit = "x".repeat(MAX_FILE_SIZE);
assert_eq!(body_at_limit.len(), MAX_FILE_SIZE);
assert!(body_at_limit.len() <= MAX_FILE_SIZE);
let body_over_limit = "x".repeat(MAX_FILE_SIZE + 1);
assert!(body_over_limit.len() > MAX_FILE_SIZE);
assert!(body_over_limit.len() > MAX_FILE_SIZE);
const MAX_SKILL_CONTENT: usize = 64 * 1024;
let big_skill = "a".repeat(MAX_SKILL_CONTENT + 1);
assert!(big_skill.len() > MAX_SKILL_CONTENT);
const MAX_MEMORY_ENTRY: usize = 32 * 1024;
let big_memory = "m".repeat(MAX_MEMORY_ENTRY + 1);
assert!(big_memory.len() > MAX_MEMORY_ENTRY);
}
fn make_entry(id: &str) -> oxios_kernel::memory::MemoryMapEntry {
oxios_kernel::memory::MemoryMapEntry {
id: id.into(),
tier: "hot".into(),
mem_type: "fact".into(),
content_preview: "x".into(),
created_at: "2026-06-04T00:00:00Z".into(),
access_count: 0,
coords_2d: (0.0, 0.0),
top_neighbors: vec![],
}
}
fn make_memory_entry(
id: &str,
content: &str,
tier: oxios_kernel::memory::MemoryTier,
mem_type: oxios_kernel::memory::MemoryType,
) -> MemoryEntry {
MemoryEntry {
id: id.into(),
memory_type: mem_type,
tier,
content: content.into(),
content_hash: oxios_kernel::memory::content_hash(content),
source: "test".into(),
session_id: None,
tags: vec![],
importance: 0.5,
pinned: false,
protection: oxios_kernel::memory::ProtectionLevel::None,
auto_classified: false,
session_appearances: 0,
user_corrected: false,
seen_in_sessions: vec![],
created_at: chrono::Utc::now(),
accessed_at: chrono::Utc::now(),
modified_at: chrono::Utc::now(),
access_count: 0,
decay_score: 1.0,
compaction_level: 0,
compacted_from: vec![],
related_ids: vec![],
contradicts: None,
}
}
#[test]
fn test_memory_map_cache_misses_on_empty() {
let cache = MemoryMapCache::default();
assert!(cache.get(0, &[], 0).is_none());
}
#[test]
fn test_memory_map_cache_round_trip() {
let cache = MemoryMapCache::default();
let ids = vec!["a".to_string(), "b".to_string()];
let entries = vec![
oxios_kernel::memory::MemoryMapEntry {
id: "a".into(),
tier: "hot".into(),
mem_type: "fact".into(),
content_preview: "alpha".into(),
created_at: "2026-06-04T00:00:00Z".into(),
access_count: 1,
coords_2d: (0.0, 0.0),
top_neighbors: vec![],
},
oxios_kernel::memory::MemoryMapEntry {
id: "b".into(),
tier: "warm".into(),
mem_type: "episode".into(),
content_preview: "beta".into(),
created_at: "2026-06-04T00:00:00Z".into(),
access_count: 2,
coords_2d: (0.5, -0.5),
top_neighbors: vec![oxios_kernel::memory::MemoryNeighbor {
id: "a".into(),
similarity: 0.81,
}],
},
];
let entries_for_sig = vec![
make_memory_entry(
"a",
"alpha",
oxios_kernel::memory::MemoryTier::Hot,
oxios_kernel::memory::MemoryType::Fact,
),
make_memory_entry(
"b",
"beta",
oxios_kernel::memory::MemoryTier::Warm,
oxios_kernel::memory::MemoryType::Episode,
),
];
let sig = memory_map_content_signature(&entries_for_sig);
cache.put(42, ids.clone(), sig, entries.clone());
let got = cache.get(42, &ids, sig).expect("hit");
assert_eq!(got.len(), 2);
assert_eq!(got[0].id, "a");
assert_eq!(got[1].top_neighbors[0].similarity, 0.81);
}
#[test]
fn test_memory_map_cache_stale_epoch_misses() {
let cache = MemoryMapCache::default();
let ids = vec!["a".to_string()];
cache.put(1, ids.clone(), 0, vec![make_entry("a")]);
assert!(cache.get(2, &ids, 0).is_none());
}
#[test]
fn test_memory_map_cache_id_change_misses() {
let cache = MemoryMapCache::default();
let ids_a = vec!["a".to_string()];
cache.put(1, ids_a.clone(), 0, vec![make_entry("a")]);
let ids_b = vec!["a".to_string(), "b".to_string()];
assert!(cache.get(1, &ids_b, 0).is_none());
}
#[test]
fn test_memory_map_cache_content_change_misses() {
let cache = MemoryMapCache::default();
let ids = vec!["a".to_string()];
let original = make_memory_entry(
"a",
"original content",
oxios_kernel::memory::MemoryTier::Hot,
oxios_kernel::memory::MemoryType::Fact,
);
let edited = make_memory_entry(
"a",
"edited content",
oxios_kernel::memory::MemoryTier::Hot,
oxios_kernel::memory::MemoryType::Fact,
);
let sig_original = memory_map_content_signature(&[original]);
let sig_edited = memory_map_content_signature(&[edited]);
assert_ne!(
sig_original, sig_edited,
"signature must differ when only the content changes"
);
cache.put(1, ids.clone(), sig_original, vec![make_entry("a")]);
assert!(cache.get(1, &ids, sig_edited).is_none());
assert!(cache.get(1, &ids, sig_original).is_some());
}
#[test]
fn test_memory_map_content_signature_is_stable_under_reorder() {
let a = make_memory_entry(
"a",
"alpha",
oxios_kernel::memory::MemoryTier::Hot,
oxios_kernel::memory::MemoryType::Fact,
);
let b = make_memory_entry(
"b",
"beta",
oxios_kernel::memory::MemoryTier::Warm,
oxios_kernel::memory::MemoryType::Episode,
);
let s1 = memory_map_content_signature(&[a.clone(), b.clone()]);
let s2 = memory_map_content_signature(&[b, a]);
assert_eq!(s1, s2);
}
#[test]
fn test_content_preview_truncates_with_ellipsis() {
let long = "x".repeat(200);
let preview = content_preview(&long, 120);
assert_eq!(preview.chars().count(), 121); assert!(preview.ends_with('\u{2026}'));
}
#[test]
fn test_content_preview_keeps_short_content() {
let preview = content_preview("hello", 120);
assert_eq!(preview, "hello");
}
#[test]
fn test_content_preview_handles_empty() {
let preview = content_preview("", 120);
assert_eq!(preview, "");
}
#[test]
fn test_memory_type_labels_match_filter_strings() {
let cases = [
(MemoryType::Fact, "fact"),
(MemoryType::Episode, "episode"),
(MemoryType::Knowledge, "knowledge"),
(MemoryType::Skill, "skill"),
(MemoryType::Preference, "preference"),
(MemoryType::Decision, "decision"),
(MemoryType::Conversation, "conversation"),
(MemoryType::Session, "session"),
(MemoryType::UserProfile, "user_profile"),
];
for (mt, label) in cases {
assert_eq!(
mt.label(),
label,
"{mt:?} label must be the singular {label} (not the plural category)"
);
let cat_short = mt.category().split('/').nth(1).unwrap_or("");
if mt != MemoryType::Knowledge {
assert_ne!(
cat_short, label,
"category short name ({cat_short}) must not equal label ({label})"
);
}
}
}
}