diff --git a/AGENTS.md b/AGENTS.md index e01dc12..5486e56 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ Skill Runtime 全生命周期已落地(含依赖管理 Schema v15),Schema - **Workspace**:`%LOCALAPPDATA%\devbase\workspace/` —— 文件系统 = source of truth - `vault/` —— PARA 结构:00-Inbox, 01-Projects, 02-Areas, 03-Resources, 04-Archives, 99-Meta - `assets/` —— 二进制资源 -- **MCP Server**:stdio only,**49 个 tools**(含 5 个 vault tools + 8 个代码分析工具 + 4 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 2 个 Agent 状态工具 + 1 个 streaming index 工具 + 1 个 oplog 工具);配置见 `mcp.json` +- **MCP Server**:stdio only,**57 个 tools**(含 5 个 vault tools + 8 个代码分析工具 + 4 个 embedding/搜索工具 + 4 个 Skill Runtime tools + 3 个 Workflow/评分 tools + 1 个报告工具 + 1 个 arXiv 工具 + 2 个 KnownLimit tools + 3 个 Relation tools + 2 个 Agent 状态工具 + 8 个 Agent Context tools + 1 个 streaming index 工具 + 1 个 oplog 工具);配置见 `mcp.json` - **Kimi CLI 集成**:MCP server 已通过 `kimi mcp add` 注册,端到端验证通过(`kimi --print` 成功调用 `devkit_health`);项目级 skill 位于 `.kimi/skills/devbase-project/SKILL.md` - **统一节点模型**:`core::node::{Node, NodeType, Edge}` —— GitRepo / VaultNote / Asset / ExternalLink - **当前测试**:490+ workspace passed / 0 failed / 4 ignored(主 crate 390 + symbol-links 4 + sync-protocol 12 + core-types 3 + syncthing-client 2 + vault-frontmatter 5 + vault-wikilink 5 + workflow-interpolate 9 + workflow-model 2 + registry-health 3 + registry-metrics 4 + registry-workspace 5 + embedding 5 + skill-runtime-types 7 + skill-runtime-parser 3 + 其他 crates ~30);11/11 passed(integration `tests/cli.rs`) diff --git a/crates/devbase-registry-workspace/src/lib.rs b/crates/devbase-registry-workspace/src/lib.rs index fe6b9a2..496a093 100644 --- a/crates/devbase-registry-workspace/src/lib.rs +++ b/crates/devbase-registry-workspace/src/lib.rs @@ -18,6 +18,7 @@ pub enum OplogEventType { Index, HealthCheck, KnownLimit, + AgentContext, } impl OplogEventType { @@ -28,6 +29,7 @@ impl OplogEventType { OplogEventType::Index => "index", OplogEventType::HealthCheck => "health_check", OplogEventType::KnownLimit => "known_limit", + OplogEventType::AgentContext => "agent_context", } } } @@ -42,6 +44,7 @@ impl std::str::FromStr for OplogEventType { "health_check" => Ok(OplogEventType::HealthCheck), "health" => Ok(OplogEventType::HealthCheck), "known_limit" => Ok(OplogEventType::KnownLimit), + "agent_context" => Ok(OplogEventType::AgentContext), _ => Err(()), } } diff --git a/src/mcp/mod.rs b/src/mcp/mod.rs index dcc6785..b49b91e 100644 --- a/src/mcp/mod.rs +++ b/src/mcp/mod.rs @@ -100,6 +100,14 @@ pub enum McpToolEnum { RelationStore(DevkitRelationStoreTool), RelationQuery(DevkitRelationQueryTool), RelationDelete(DevkitRelationDeleteTool), + SessionSave(DevkitSessionSaveTool), + SessionList(DevkitSessionListTool), + SessionResume(DevkitSessionResumeTool), + SessionAttach(DevkitSessionAttachTool), + SessionDetach(DevkitSessionDetachTool), + SessionActivate(DevkitSessionActivateTool), + SessionSearch(DevkitSessionSearchTool), + SessionCapture(DevkitSessionCaptureTool), WorkflowList(DevkitWorkflowListTool), WorkflowRun(DevkitWorkflowRunTool), WorkflowStatus(DevkitWorkflowStatusTool), @@ -177,6 +185,14 @@ impl McpToolEnum { McpToolEnum::RelationStore(_) => ToolTier::Beta, McpToolEnum::RelationQuery(_) => ToolTier::Beta, McpToolEnum::RelationDelete(_) => ToolTier::Beta, + McpToolEnum::SessionSave(_) => ToolTier::Beta, + McpToolEnum::SessionList(_) => ToolTier::Beta, + McpToolEnum::SessionResume(_) => ToolTier::Beta, + McpToolEnum::SessionAttach(_) => ToolTier::Beta, + McpToolEnum::SessionDetach(_) => ToolTier::Beta, + McpToolEnum::SessionActivate(_) => ToolTier::Beta, + McpToolEnum::SessionSearch(_) => ToolTier::Beta, + McpToolEnum::SessionCapture(_) => ToolTier::Beta, McpToolEnum::WorkflowList(_) => ToolTier::Beta, McpToolEnum::WorkflowRun(_) => ToolTier::Beta, McpToolEnum::WorkflowStatus(_) => ToolTier::Beta, @@ -233,6 +249,14 @@ impl McpTool for McpToolEnum { McpToolEnum::RelationStore(t) => t.name(), McpToolEnum::RelationQuery(t) => t.name(), McpToolEnum::RelationDelete(t) => t.name(), + McpToolEnum::SessionSave(t) => t.name(), + McpToolEnum::SessionList(t) => t.name(), + McpToolEnum::SessionResume(t) => t.name(), + McpToolEnum::SessionAttach(t) => t.name(), + McpToolEnum::SessionDetach(t) => t.name(), + McpToolEnum::SessionActivate(t) => t.name(), + McpToolEnum::SessionSearch(t) => t.name(), + McpToolEnum::SessionCapture(t) => t.name(), McpToolEnum::WorkflowList(t) => t.name(), McpToolEnum::WorkflowRun(t) => t.name(), McpToolEnum::WorkflowStatus(t) => t.name(), @@ -287,6 +311,14 @@ impl McpTool for McpToolEnum { McpToolEnum::RelationStore(t) => t.schema(), McpToolEnum::RelationQuery(t) => t.schema(), McpToolEnum::RelationDelete(t) => t.schema(), + McpToolEnum::SessionSave(t) => t.schema(), + McpToolEnum::SessionList(t) => t.schema(), + McpToolEnum::SessionResume(t) => t.schema(), + McpToolEnum::SessionAttach(t) => t.schema(), + McpToolEnum::SessionDetach(t) => t.schema(), + McpToolEnum::SessionActivate(t) => t.schema(), + McpToolEnum::SessionSearch(t) => t.schema(), + McpToolEnum::SessionCapture(t) => t.schema(), McpToolEnum::WorkflowList(t) => t.schema(), McpToolEnum::WorkflowRun(t) => t.schema(), McpToolEnum::WorkflowStatus(t) => t.schema(), @@ -345,6 +377,14 @@ impl McpTool for McpToolEnum { McpToolEnum::RelationStore(t) => t.invoke(args, ctx).await, McpToolEnum::RelationQuery(t) => t.invoke(args, ctx).await, McpToolEnum::RelationDelete(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionSave(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionList(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionResume(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionAttach(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionDetach(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionActivate(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionSearch(t) => t.invoke(args, ctx).await, + McpToolEnum::SessionCapture(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowList(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowRun(t) => t.invoke(args, ctx).await, McpToolEnum::WorkflowStatus(t) => t.invoke(args, ctx).await, @@ -593,6 +633,14 @@ pub fn build_server_with_tiers(tiers: Option<&HashSet>) -> McpServer { McpToolEnum::RelationStore(DevkitRelationStoreTool), McpToolEnum::RelationQuery(DevkitRelationQueryTool), McpToolEnum::RelationDelete(DevkitRelationDeleteTool), + McpToolEnum::SessionSave(DevkitSessionSaveTool), + McpToolEnum::SessionList(DevkitSessionListTool), + McpToolEnum::SessionResume(DevkitSessionResumeTool), + McpToolEnum::SessionAttach(DevkitSessionAttachTool), + McpToolEnum::SessionDetach(DevkitSessionDetachTool), + McpToolEnum::SessionActivate(DevkitSessionActivateTool), + McpToolEnum::SessionSearch(DevkitSessionSearchTool), + McpToolEnum::SessionCapture(DevkitSessionCaptureTool), McpToolEnum::WorkflowList(DevkitWorkflowListTool), McpToolEnum::WorkflowRun(DevkitWorkflowRunTool), McpToolEnum::WorkflowStatus(DevkitWorkflowStatusTool), diff --git a/src/mcp/tests.rs b/src/mcp/tests.rs index 2d199ba..e925136 100644 --- a/src/mcp/tests.rs +++ b/src/mcp/tests.rs @@ -39,8 +39,11 @@ async fn test_tools_list() { let (mut ctx, _tmp) = test_ctx(); let resp = server.handle_request(req, &mut ctx).await.unwrap(); let tools = resp.get("result").unwrap().get("tools").unwrap().as_array().unwrap(); - assert_eq!(tools.len(), 49); + assert_eq!(tools.len(), 57); let names: Vec<&str> = tools.iter().map(|t| t.get("name").unwrap().as_str().unwrap()).collect(); + assert!(names.contains(&"devkit_session_save")); + assert!(names.contains(&"devkit_session_list")); + assert!(names.contains(&"devkit_session_resume")); assert!(names.contains(&"devkit_evaluate")); assert!(names.contains(&"devkit_scan")); assert!(names.contains(&"devkit_health")); diff --git a/src/mcp/tools/mod.rs b/src/mcp/tools/mod.rs index 264a7de..9f5ccc5 100644 --- a/src/mcp/tools/mod.rs +++ b/src/mcp/tools/mod.rs @@ -7,6 +7,7 @@ pub mod oplog; pub mod query; pub mod relations; pub mod repo; +pub mod session; pub mod skill; pub mod status; pub mod vault; @@ -23,6 +24,7 @@ pub use oplog::*; pub use query::*; pub use relations::*; pub use repo::*; +pub use session::*; pub use skill::*; pub use status::*; pub use vault::*; @@ -50,5 +52,13 @@ mod tests { let _ = super::vault::DevkitVaultDailyTool; let _ = super::vault::DevkitVaultGraphTool; let _ = super::workflow::DevkitWorkflowListTool; + let _ = super::session::DevkitSessionSaveTool; + let _ = super::session::DevkitSessionListTool; + let _ = super::session::DevkitSessionResumeTool; + let _ = super::session::DevkitSessionAttachTool; + let _ = super::session::DevkitSessionDetachTool; + let _ = super::session::DevkitSessionActivateTool; + let _ = super::session::DevkitSessionSearchTool; + let _ = super::session::DevkitSessionCaptureTool; } } diff --git a/src/mcp/tools/session.rs b/src/mcp/tools/session.rs new file mode 100644 index 0000000..3638cb2 --- /dev/null +++ b/src/mcp/tools/session.rs @@ -0,0 +1,617 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +//! MCP tools for Agent Context management (P1: Claude Projects inspired sessions). + +use crate::mcp::McpTool; +use crate::storage::AppContext; +use serde_json::json; + +#[derive(Clone)] +pub struct DevkitSessionSaveTool; + +impl McpTool for DevkitSessionSaveTool { + fn name(&self) -> &'static str { + "devkit_session_save" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Save or update an AI agent session context with optional memories. + +Use this when the user wants to: +- Create a new persistent project context +- Update an existing session's name, intent, or append memories +- Checkpoint current conversation state for later resumption + +Parameters: +- context_id: Unique session identifier (e.g., "project-alpha", "sprint-29"). +- name: Human-readable session name. +- intent: Optional high-level goal or project description. +- memories: Optional array of {type, content} objects to append. Types: decision, constraint, note, discovery, error."#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string", "description": "Unique session ID" }, + "name": { "type": "string", "description": "Human-readable name" }, + "intent": { "type": "string", "description": "High-level goal / project description" }, + "memories": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { "type": "string", "description": "Memory type: decision, constraint, note, discovery, error" }, + "content": { "type": "string", "description": "Memory content" } + }, + "required": ["type", "content"] + } + } + }, + "required": ["context_id", "name"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + let name = args.get("name").and_then(|v| v.as_str()).unwrap_or(""); + let intent = args.get("intent").and_then(|v| v.as_str()); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + if name.is_empty() { + anyhow::bail!("Missing required argument: name"); + } + + let mut conn = ctx.conn_mut()?; + crate::registry::agent_context::upsert_context(&mut conn, context_id, name, intent)?; + + let mut memory_count = 0; + if let Some(memories) = args.get("memories").and_then(|v| v.as_array()) { + for mem in memories { + let ty = mem.get("type").and_then(|v| v.as_str()).unwrap_or("note"); + let content = mem.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if !content.is_empty() { + crate::registry::agent_context::insert_memory( + &mut conn, context_id, ty, content, + )?; + memory_count += 1; + } + } + } + + Ok(json!({ + "success": true, + "context_id": context_id, + "name": name, + "memories_added": memory_count + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionListTool; + +impl McpTool for DevkitSessionListTool { + fn name(&self) -> &'static str { + "devkit_session_list" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"List persisted AI agent sessions (contexts). + +Use this when the user wants to: +- See all active or archived sessions +- Find a session to resume +- Audit past project contexts"#, + "inputSchema": { + "type": "object", + "properties": { + "status_filter": { + "type": "string", + "enum": ["active", "archived"], + "description": "Filter by status. Omit for all." + }, + "limit": { + "type": "integer", + "description": "Maximum results", + "default": 50 + } + } + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let status_filter = args.get("status_filter").and_then(|v| v.as_str()); + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(50) as usize; + + let conn = ctx.conn()?; + let contexts = crate::registry::agent_context::list_contexts(&conn)?; + let results: Vec = contexts + .into_iter() + .filter(|c| status_filter.is_none_or(|f| c.status == f)) + .take(limit) + .map(|c| { + json!({ + "context_id": c.id, + "name": c.name, + "intent": c.intent, + "status": c.status, + "updated_at": c.updated_at.to_rfc3339(), + }) + }) + .collect(); + + Ok(json!({ + "success": true, + "count": results.len(), + "contexts": results + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionResumeTool; + +impl McpTool for DevkitSessionResumeTool { + fn name(&self) -> &'static str { + "devkit_session_resume" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Resume a persisted AI agent session, returning its metadata and memories. + +Use this when the user wants to: +- Restore a previous project context +- Continue work from a checkpointed session +- Review all decisions and constraints stored in a session"#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { + "type": "string", + "description": "Session ID to resume" + }, + "include_memories": { + "type": "boolean", + "description": "Include associated memories", + "default": true + }, + "memory_types": { + "type": "array", + "items": { "type": "string" }, + "description": "Optional filter for memory types (e.g. [\"decision\", \"constraint\"])" + } + }, + "required": ["context_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + let include_memories = + args.get("include_memories").and_then(|v| v.as_bool()).unwrap_or(true); + let memory_types: Option> = args + .get("memory_types") + .and_then(|v| v.as_array()) + .map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect()); + + let conn = ctx.conn()?; + match crate::registry::agent_context::get_context_with_memories(&conn, context_id)? { + Some((ctx_info, mut memories)) => { + if let Some(types) = memory_types { + let type_set: std::collections::HashSet = types.into_iter().collect(); + memories.retain(|m| type_set.contains(&m.memory_type)); + } + let memory_json: Vec = if include_memories { + memories + .into_iter() + .map(|m| { + json!({ + "id": m.id, + "type": m.memory_type, + "content": m.content, + "created_at": m.created_at.to_rfc3339(), + }) + }) + .collect() + } else { + vec![] + }; + + let linked = + crate::registry::agent_context::list_linked_entities(&conn, context_id)?; + let linked_json: Vec = linked + .into_iter() + .map(|(eid, ltype, _cat)| { + json!({ + "entity_id": eid, + "link_type": ltype, + }) + }) + .collect(); + + Ok(json!({ + "success": true, + "context": { + "context_id": ctx_info.id, + "name": ctx_info.name, + "intent": ctx_info.intent, + "status": ctx_info.status, + "created_at": ctx_info.created_at.to_rfc3339(), + "updated_at": ctx_info.updated_at.to_rfc3339(), + }, + "memories": memory_json, + "memory_count": memory_json.len(), + "linked_entities": linked_json, + "linked_count": linked_json.len(), + })) + } + None => Ok(json!({ + "success": false, + "error": format!("Session '{}' not found", context_id) + })), + } + } +} + +#[derive(Clone)] +pub struct DevkitSessionAttachTool; + +impl McpTool for DevkitSessionAttachTool { + fn name(&self) -> &'static str { + "devkit_session_attach" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Attach an entity (repo, vault note, skill, etc.) to an agent session. + +Use this when the user wants to: +- Link a repository to a project session +- Associate a skill or vault note with the current context +- Build a project workspace by connecting relevant resources"#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string", "description": "Session ID" }, + "entity_id": { "type": "string", "description": "Entity ID (repo_id, vault path, skill_id, etc.)" }, + "link_type": { + "type": "string", + "enum": ["linked_repo", "linked_vault", "linked_skill", "linked_paper", "linked"], + "default": "linked", + "description": "Type of relationship" + } + }, + "required": ["context_id", "entity_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + let entity_id = args.get("entity_id").and_then(|v| v.as_str()).unwrap_or(""); + let link_type = args.get("link_type").and_then(|v| v.as_str()).unwrap_or("linked"); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + if entity_id.is_empty() { + anyhow::bail!("Missing required argument: entity_id"); + } + + let mut conn = ctx.conn_mut()?; + crate::registry::agent_context::attach_entity(&mut conn, context_id, entity_id, link_type)?; + Ok(json!({ + "success": true, + "context_id": context_id, + "entity_id": entity_id, + "link_type": link_type, + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionDetachTool; + +impl McpTool for DevkitSessionDetachTool { + fn name(&self) -> &'static str { + "devkit_session_detach" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Detach an entity from an agent session. + +Use this when the user wants to: +- Remove a stale repository link +- Unlink a skill that is no longer relevant to the project"#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string", "description": "Session ID" }, + "entity_id": { "type": "string", "description": "Entity ID to remove" }, + "link_type": { + "type": "string", + "description": "Specific link type to remove. Omit to remove all links to this entity." + } + }, + "required": ["context_id", "entity_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + let entity_id = args.get("entity_id").and_then(|v| v.as_str()).unwrap_or(""); + let link_type = args.get("link_type").and_then(|v| v.as_str()); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + if entity_id.is_empty() { + anyhow::bail!("Missing required argument: entity_id"); + } + + let mut conn = ctx.conn_mut()?; + let removed = crate::registry::agent_context::detach_entity( + &mut conn, context_id, entity_id, link_type, + )?; + Ok(json!({ + "success": true, + "removed": removed, + "context_id": context_id, + "entity_id": entity_id, + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionActivateTool; + +impl McpTool for DevkitSessionActivateTool { + fn name(&self) -> &'static str { + "devkit_session_activate" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Activate a session so that subsequent skill executions automatically receive its memories and linked entities. + +Use this when the user wants to: +- Set a default project context for the current workspace +- Make all future skill runs context-aware without manual memory passing +- Switch between projects"#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { "type": "string", "description": "Session ID to activate" } + }, + "required": ["context_id"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + _ctx: &mut AppContext, + ) -> anyhow::Result { + let context_id = args.get("context_id").and_then(|v| v.as_str()).unwrap_or(""); + if context_id.is_empty() { + anyhow::bail!("Missing required argument: context_id"); + } + + let state_file = + crate::registry::WorkspaceRegistry::workspace_dir()?.join(".active_context"); + std::fs::write(&state_file, context_id)?; + + Ok(json!({ + "success": true, + "context_id": context_id, + "state_file": state_file.to_string_lossy().to_string(), + "tip": format!("Set DEVBASE_ACTIVE_CONTEXT={} in your environment to make this persistent across shell sessions.", context_id), + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionSearchTool; + +impl McpTool for DevkitSessionSearchTool { + fn name(&self) -> &'static str { + "devkit_session_search" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Search memories by keyword across all sessions or within a specific session. + +Use this when the user wants to: +- Find a past decision or constraint mentioned in memories +- Recall what was discussed in a previous project session +- Audit all sessions for a specific topic"#, + "inputSchema": { + "type": "object", + "properties": { + "query": { "type": "string", "description": "Keyword to search for" }, + "context_id": { + "type": "string", + "description": "Restrict search to a specific session. Omit for global search." + }, + "limit": { + "type": "integer", + "description": "Maximum results", + "default": 20 + } + }, + "required": ["query"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let query = args.get("query").and_then(|v| v.as_str()).unwrap_or(""); + let context_id = args.get("context_id").and_then(|v| v.as_str()); + let limit = args.get("limit").and_then(|v| v.as_u64()).unwrap_or(20) as usize; + if query.is_empty() { + anyhow::bail!("Missing required argument: query"); + } + + let conn = ctx.conn()?; + let memories = + crate::registry::agent_context::search_memories(&conn, context_id, query, limit)?; + let results: Vec = memories + .into_iter() + .map(|m| { + json!({ + "id": m.id, + "context_id": m.context_id, + "type": m.memory_type, + "content": m.content, + "created_at": m.created_at.to_rfc3339(), + }) + }) + .collect(); + + Ok(json!({ + "success": true, + "query": query, + "count": results.len(), + "memories": results, + })) + } +} + +#[derive(Clone)] +pub struct DevkitSessionCaptureTool; + +impl McpTool for DevkitSessionCaptureTool { + fn name(&self) -> &'static str { + "devkit_session_capture" + } + + fn schema(&self) -> serde_json::Value { + json!({ + "description": r#"Capture a decision, constraint, or observation into the active session's memory. + +Use this when the AI (or user) wants to: +- Record an architectural decision made during the conversation +- Save a constraint discovered while debugging +- Checkpoint a key insight before moving to another topic + +This is a lightweight append-only operation. No validation is performed on content."#, + "inputSchema": { + "type": "object", + "properties": { + "context_id": { + "type": "string", + "description": "Session ID. Omit to use the currently activated session (via devkit_session_activate)." + }, + "type": { + "type": "string", + "enum": ["decision", "constraint", "note", "discovery", "error", "action"], + "default": "note", + "description": "Memory classification" + }, + "content": { "type": "string", "description": "Memory content" } + }, + "required": ["content"] + } + }) + } + + async fn invoke( + &self, + args: serde_json::Value, + ctx: &mut AppContext, + ) -> anyhow::Result { + let content = args.get("content").and_then(|v| v.as_str()).unwrap_or(""); + if content.is_empty() { + anyhow::bail!("Missing required argument: content"); + } + let memory_type = args.get("type").and_then(|v| v.as_str()).unwrap_or("note"); + + let context_id = match args.get("context_id").and_then(|v| v.as_str()) { + Some(cid) if !cid.is_empty() => cid.to_string(), + _ => { + // Fallback: read activated session from state file + let state_file = + crate::registry::WorkspaceRegistry::workspace_dir()?.join(".active_context"); + std::fs::read_to_string(state_file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .ok_or_else(|| anyhow::anyhow!("No active session. Use context_id argument or devkit_session_activate first."))? + } + }; + + let mut conn = ctx.conn_mut()?; + let id = crate::registry::agent_context::insert_memory( + &mut conn, + &context_id, + memory_type, + content, + )?; + + Ok(json!({ + "success": true, + "memory_id": id, + "context_id": context_id, + "type": memory_type, + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::mcp::McpTool; + + #[test] + fn test_session_tool_names() { + assert_eq!(DevkitSessionSaveTool.name(), "devkit_session_save"); + assert_eq!(DevkitSessionListTool.name(), "devkit_session_list"); + assert_eq!(DevkitSessionResumeTool.name(), "devkit_session_resume"); + assert_eq!(DevkitSessionAttachTool.name(), "devkit_session_attach"); + assert_eq!(DevkitSessionDetachTool.name(), "devkit_session_detach"); + assert_eq!(DevkitSessionActivateTool.name(), "devkit_session_activate"); + assert_eq!(DevkitSessionSearchTool.name(), "devkit_session_search"); + assert_eq!(DevkitSessionCaptureTool.name(), "devkit_session_capture"); + } + + #[test] + fn test_schemas_are_objects() { + assert!(DevkitSessionSaveTool.schema().is_object()); + assert!(DevkitSessionListTool.schema().is_object()); + assert!(DevkitSessionResumeTool.schema().is_object()); + } +} diff --git a/src/registry.rs b/src/registry.rs index 971e610..4f9371b 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -111,6 +111,7 @@ pub use entity::{ ENTITY_TYPE_WORKFLOW, upsert_entity, }; +pub mod agent_context; pub mod call_graph; pub mod code_symbols; pub mod dead_code; diff --git a/src/registry/agent_context.rs b/src/registry/agent_context.rs new file mode 100644 index 0000000..2a716df --- /dev/null +++ b/src/registry/agent_context.rs @@ -0,0 +1,485 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +//! Agent Context registry: CRUD for agent_contexts and agent_memories tables. +//! +//! Provides persistent AI session contexts (Claude Projects inspired) with +//! associated typed memories. All operations are transactional where needed. + +use chrono::{DateTime, Utc}; +use rusqlite::{Connection, OptionalExtension}; + +/// A persisted AI agent context (session / project scope). +#[derive(Debug, Clone, PartialEq)] +pub struct AgentContext { + pub id: String, + pub name: String, + pub intent: Option, + pub status: String, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// A typed memory entry attached to an AgentContext. +#[derive(Debug, Clone, PartialEq)] +pub struct AgentMemory { + pub id: i64, + pub context_id: String, + pub memory_type: String, + pub content: String, + pub created_at: DateTime, +} + +// --------------------------------------------------------------------------- +// Context CRUD +// --------------------------------------------------------------------------- + +/// Insert or replace an agent context. +pub fn upsert_context( + conn: &mut Connection, + id: &str, + name: &str, + intent: Option<&str>, +) -> anyhow::Result<()> { + let tx = conn.transaction()?; + let now = Utc::now().to_rfc3339(); + tx.execute( + "INSERT INTO agent_contexts (id, name, intent, status, created_at, updated_at) + VALUES (?1, ?2, ?3, 'active', ?4, ?4) + ON CONFLICT(id) DO UPDATE SET + name = excluded.name, + intent = excluded.intent, + status = 'active', + updated_at = excluded.updated_at", + rusqlite::params![id, name, intent, now], + )?; + tx.commit()?; + log_op(conn, "upsert_context", id, intent, "ok"); + Ok(()) +} + +/// List all contexts ordered by most recently updated. +pub fn list_contexts(conn: &Connection) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT id, name, intent, status, created_at, updated_at + FROM agent_contexts + ORDER BY updated_at DESC", + )?; + let rows = stmt.query_map([], |row| { + let created_at = parse_datetime(row.get(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + let updated_at = parse_datetime(row.get(5)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + Ok(AgentContext { + id: row.get(0)?, + name: row.get(1)?, + intent: row.get(2)?, + status: row.get(3)?, + created_at, + updated_at, + }) + })?; + rows.collect::, _>>().map_err(Into::into) +} + +/// Get a single context by ID. +pub fn get_context(conn: &Connection, id: &str) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT id, name, intent, status, created_at, updated_at + FROM agent_contexts + WHERE id = ?1", + )?; + stmt.query_row([id], |row| { + let created_at = parse_datetime(row.get(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + let updated_at = parse_datetime(row.get(5)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 5, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + Ok(AgentContext { + id: row.get(0)?, + name: row.get(1)?, + intent: row.get(2)?, + status: row.get(3)?, + created_at, + updated_at, + }) + }) + .optional() + .map_err(Into::into) +} + +/// Get a context together with all its memories. +pub fn get_context_with_memories( + conn: &Connection, + id: &str, +) -> anyhow::Result)>> { + let ctx = match get_context(conn, id)? { + Some(c) => c, + None => return Ok(None), + }; + let memories = list_memories(conn, id)?; + Ok(Some((ctx, memories))) +} + +/// Archive a context (soft-delete via status change). +pub fn archive_context(conn: &mut Connection, id: &str) -> anyhow::Result { + let tx = conn.transaction()?; + let now = Utc::now().to_rfc3339(); + let rows = tx.execute( + "UPDATE agent_contexts SET status = 'archived', updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, id], + )?; + tx.commit()?; + log_op(conn, "archive_context", id, None, "ok"); + Ok(rows > 0) +} + +/// Hard-delete a context and cascade-delete its memories. +pub fn delete_context(conn: &mut Connection, id: &str) -> anyhow::Result { + let tx = conn.transaction()?; + let rows = tx.execute("DELETE FROM agent_contexts WHERE id = ?1", [id])?; + tx.commit()?; + log_op(conn, "delete_context", id, None, "ok"); + Ok(rows > 0) +} + +// --------------------------------------------------------------------------- +// Memory CRUD +// --------------------------------------------------------------------------- + +/// Insert a memory and return its auto-generated row id. +pub fn insert_memory( + conn: &mut Connection, + context_id: &str, + memory_type: &str, + content: &str, +) -> anyhow::Result { + let tx = conn.transaction()?; + tx.execute( + "INSERT INTO agent_memories (context_id, memory_type, content, created_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![context_id, memory_type, content, Utc::now().to_rfc3339()], + )?; + let id = tx.last_insert_rowid(); + tx.commit()?; + log_op(conn, "insert_memory", context_id, Some(content), "ok"); + Ok(id) +} + +/// List memories for a context, newest first. +pub fn list_memories(conn: &Connection, context_id: &str) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT id, context_id, memory_type, content, created_at + FROM agent_memories + WHERE context_id = ?1 + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([context_id], |row| { + let created_at = parse_datetime(row.get(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + Ok(AgentMemory { + id: row.get(0)?, + context_id: row.get(1)?, + memory_type: row.get(2)?, + content: row.get(3)?, + created_at, + }) + })?; + rows.collect::, _>>().map_err(Into::into) +} + +// --------------------------------------------------------------------------- +// Context → Entity Links +// --------------------------------------------------------------------------- + +/// Link an entity (repo, vault, skill, etc.) to a context. +pub fn attach_entity( + conn: &mut Connection, + context_id: &str, + entity_id: &str, + link_type: &str, +) -> anyhow::Result<()> { + let tx = conn.transaction()?; + tx.execute( + "INSERT OR REPLACE INTO context_entity_links (context_id, entity_id, link_type, created_at) + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![context_id, entity_id, link_type, Utc::now().to_rfc3339()], + )?; + tx.commit()?; + log_op(conn, "attach_entity", context_id, Some(entity_id), "ok"); + Ok(()) +} + +/// Remove a link between a context and an entity. +pub fn detach_entity( + conn: &mut Connection, + context_id: &str, + entity_id: &str, + link_type: Option<&str>, +) -> anyhow::Result { + let tx = conn.transaction()?; + let rows = match link_type { + Some(lt) => tx.execute( + "DELETE FROM context_entity_links + WHERE context_id = ?1 AND entity_id = ?2 AND link_type = ?3", + rusqlite::params![context_id, entity_id, lt], + )?, + None => tx.execute( + "DELETE FROM context_entity_links + WHERE context_id = ?1 AND entity_id = ?2", + rusqlite::params![context_id, entity_id], + )?, + }; + tx.commit()?; + log_op(conn, "detach_entity", context_id, Some(entity_id), "ok"); + Ok(rows > 0) +} + +/// List all entities linked to a context. +pub fn list_linked_entities( + conn: &Connection, + context_id: &str, +) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT entity_id, link_type, created_at + FROM context_entity_links + WHERE context_id = ?1 + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([context_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + })?; + rows.collect::, _>>().map_err(Into::into) +} + +/// List all contexts linked to an entity. +pub fn list_linking_contexts( + conn: &Connection, + entity_id: &str, +) -> anyhow::Result> { + let mut stmt = conn.prepare( + "SELECT context_id, link_type, created_at + FROM context_entity_links + WHERE entity_id = ?1 + ORDER BY created_at DESC", + )?; + let rows = stmt.query_map([entity_id], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?)) + })?; + rows.collect::, _>>().map_err(Into::into) +} + +/// Search memories by keyword (LIKE query) within a context or globally. +pub fn search_memories( + conn: &Connection, + context_id: Option<&str>, + query: &str, + limit: usize, +) -> anyhow::Result> { + let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_")); + let row_mapper = |row: &rusqlite::Row| { + let created_at = parse_datetime(row.get(4)?).map_err(|e| { + rusqlite::Error::FromSqlConversionFailure( + 4, + rusqlite::types::Type::Text, + Box::new(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string())), + ) + })?; + Ok(AgentMemory { + id: row.get(0)?, + context_id: row.get(1)?, + memory_type: row.get(2)?, + content: row.get(3)?, + created_at, + }) + }; + if let Some(cid) = context_id { + let mut stmt = conn.prepare( + "SELECT id, context_id, memory_type, content, created_at + FROM agent_memories + WHERE context_id = ?1 AND content LIKE ?2 + ORDER BY created_at DESC + LIMIT ?3", + )?; + let rows = stmt.query_map(rusqlite::params![cid, &pattern, limit as i64], row_mapper)?; + return rows.collect::, _>>().map_err(Into::into); + } + let mut stmt = conn.prepare( + "SELECT id, context_id, memory_type, content, created_at + FROM agent_memories + WHERE content LIKE ?1 + ORDER BY created_at DESC + LIMIT ?2", + )?; + let rows = stmt.query_map(rusqlite::params![&pattern, limit as i64], row_mapper)?; + rows.collect::, _>>().map_err(Into::into) +} + +/// Write an agent-context operation to the oplog for audit/compensation. +fn log_op( + conn: &rusqlite::Connection, + _operation: &str, + context_id: &str, + details: Option<&str>, + status: &str, +) { + let _ = crate::registry::workspace::save_oplog( + conn, + &crate::registry::OplogEntry { + id: None, + event_type: crate::registry::OplogEventType::AgentContext, + repo_id: Some(context_id.to_string()), + details: details.map(|s| s.to_string()), + status: status.to_string(), + timestamp: chrono::Utc::now(), + duration_ms: None, + event_version: 1, + }, + ); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +fn parse_datetime(s: String) -> anyhow::Result> { + DateTime::parse_from_rfc3339(&s) + .map(|dt| dt.with_timezone(&Utc)) + .or_else(|_| { + chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S") + .map(|ndt| DateTime::from_naive_utc_and_offset(ndt, Utc)) + }) + .map_err(|e| anyhow::anyhow!("Invalid datetime '{}': {}", s, e)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::registry::test_helpers::WorkspaceRegistry; + + #[test] + fn test_context_crud() { + let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); + + // Create + upsert_context(&mut conn, "ctx-1", "Project Alpha", Some("Rewrite auth layer")).unwrap(); + let ctx = get_context(&conn, "ctx-1").unwrap().expect("context exists"); + assert_eq!(ctx.name, "Project Alpha"); + assert_eq!(ctx.intent.as_deref(), Some("Rewrite auth layer")); + assert_eq!(ctx.status, "active"); + + // Update + upsert_context(&mut conn, "ctx-1", "Project Alpha+", Some("Rewrite auth + RBAC")).unwrap(); + let ctx2 = get_context(&conn, "ctx-1").unwrap().expect("context still exists"); + assert_eq!(ctx2.name, "Project Alpha+"); + + // List + let list = list_contexts(&conn).unwrap(); + assert_eq!(list.len(), 1); + + // Archive + assert!(archive_context(&mut conn, "ctx-1").unwrap()); + let archived = get_context(&conn, "ctx-1").unwrap().expect("context not deleted"); + assert_eq!(archived.status, "archived"); + + // Delete + assert!(delete_context(&mut conn, "ctx-1").unwrap()); + assert!(get_context(&conn, "ctx-1").unwrap().is_none()); + } + + #[test] + fn test_memory_crud() { + let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); + upsert_context(&mut conn, "ctx-mem", "Test", None).unwrap(); + + let id1 = insert_memory(&mut conn, "ctx-mem", "decision", "Use SQLite").unwrap(); + let id2 = insert_memory(&mut conn, "ctx-mem", "constraint", "Must be <50ms").unwrap(); + assert!(id1 > 0); + assert!(id2 > 0); + + let memories = list_memories(&conn, "ctx-mem").unwrap(); + assert_eq!(memories.len(), 2); + // Newest first + assert_eq!(memories[0].memory_type, "constraint"); + assert_eq!(memories[1].memory_type, "decision"); + } + + #[test] + fn test_get_context_with_memories() { + let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); + upsert_context(&mut conn, "ctx-full", "Full", Some("intent")).unwrap(); + insert_memory(&mut conn, "ctx-full", "note", "content").unwrap(); + + let result = get_context_with_memories(&conn, "ctx-full").unwrap(); + assert!(result.is_some()); + let (ctx, mems) = result.unwrap(); + assert_eq!(ctx.name, "Full"); + assert_eq!(mems.len(), 1); + assert_eq!(mems[0].content, "content"); + } + + #[test] + fn test_missing_context() { + let conn = WorkspaceRegistry::init_in_memory().unwrap(); + assert!(get_context(&conn, "nope").unwrap().is_none()); + assert!(get_context_with_memories(&conn, "nope").unwrap().is_none()); + } + + #[test] + fn test_cascade_delete() { + let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); + upsert_context(&mut conn, "ctx-cascade", "Cascade", None).unwrap(); + insert_memory(&mut conn, "ctx-cascade", "t", "data").unwrap(); + + delete_context(&mut conn, "ctx-cascade").unwrap(); + let mems = list_memories(&conn, "ctx-cascade").unwrap(); + assert!(mems.is_empty()); + } + + #[test] + fn test_entity_links() { + let mut conn = WorkspaceRegistry::init_in_memory().unwrap(); + upsert_context(&mut conn, "ctx-links", "Links", None).unwrap(); + + attach_entity(&mut conn, "ctx-links", "repo-1", "linked_repo").unwrap(); + attach_entity(&mut conn, "ctx-links", "skill-1", "linked_skill").unwrap(); + attach_entity(&mut conn, "ctx-links", "repo-1", "linked_repo").unwrap(); // idempotent + + let linked = list_linked_entities(&conn, "ctx-links").unwrap(); + assert_eq!(linked.len(), 2); + + let contexts = list_linking_contexts(&conn, "repo-1").unwrap(); + assert_eq!(contexts.len(), 1); + assert_eq!(contexts[0].0, "ctx-links"); + + assert!(detach_entity(&mut conn, "ctx-links", "repo-1", Some("linked_repo")).unwrap()); + let linked2 = list_linked_entities(&conn, "ctx-links").unwrap(); + assert_eq!(linked2.len(), 1); + + assert!(detach_entity(&mut conn, "ctx-links", "skill-1", None).unwrap()); + assert!(list_linked_entities(&conn, "ctx-links").unwrap().is_empty()); + } +} diff --git a/src/registry/migrate.rs b/src/registry/migrate.rs index 1a9dc4c..f22b5d6 100644 --- a/src/registry/migrate.rs +++ b/src/registry/migrate.rs @@ -4,7 +4,7 @@ use super::*; use crate::storage::StorageBackend; use std::path::PathBuf; -pub const CURRENT_SCHEMA_VERSION: i32 = 30; +pub const CURRENT_SCHEMA_VERSION: i32 = 32; impl WorkspaceRegistry { pub fn db_path() -> anyhow::Result { diff --git a/src/registry/migrations/mod.rs b/src/registry/migrations/mod.rs index f922d11..450949a 100644 --- a/src/registry/migrations/mod.rs +++ b/src/registry/migrations/mod.rs @@ -32,6 +32,8 @@ pub mod v27_repo_index_state; pub mod v28_embedding_precision; pub mod v29_compensation_log; pub mod v30_code_symbol_attributes; +pub mod v31_agent_contexts; +pub mod v32_context_links; pub fn run_all(conn: &mut Connection) -> anyhow::Result<()> { let user_version: i32 = conn.query_row("PRAGMA user_version", [], |row| row.get(0))?; @@ -126,6 +128,12 @@ pub fn run_all(conn: &mut Connection) -> anyhow::Result<()> { if user_version < 30 { v30_code_symbol_attributes::run(conn)?; } + if user_version < 31 { + v31_agent_contexts::run(conn)?; + } + if user_version < 32 { + v32_context_links::run(conn)?; + } Ok(()) } diff --git a/src/registry/migrations/v31_agent_contexts.rs b/src/registry/migrations/v31_agent_contexts.rs new file mode 100644 index 0000000..bbb4ccd --- /dev/null +++ b/src/registry/migrations/v31_agent_contexts.rs @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +use rusqlite::Connection; + +pub fn run(conn: &Connection) -> anyhow::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS agent_contexts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + intent TEXT, + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT current_timestamp, + updated_at DATETIME DEFAULT current_timestamp + )", + [], + )?; + + conn.execute( + "CREATE TABLE IF NOT EXISTS agent_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + context_id TEXT NOT NULL, + memory_type TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT current_timestamp, + FOREIGN KEY (context_id) REFERENCES agent_contexts(id) ON DELETE CASCADE + )", + [], + )?; + + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_agent_memories_context ON agent_memories(context_id)", + [], + )?; + + conn.execute("PRAGMA user_version = 31", [])?; + Ok(()) +} diff --git a/src/registry/migrations/v32_context_links.rs b/src/registry/migrations/v32_context_links.rs new file mode 100644 index 0000000..e939a68 --- /dev/null +++ b/src/registry/migrations/v32_context_links.rs @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 juice094 +use rusqlite::Connection; + +pub fn run(conn: &Connection) -> anyhow::Result<()> { + conn.execute( + "CREATE TABLE IF NOT EXISTS context_entity_links ( + context_id TEXT NOT NULL, + entity_id TEXT NOT NULL, + link_type TEXT NOT NULL DEFAULT 'linked', + created_at TEXT NOT NULL, + PRIMARY KEY (context_id, entity_id, link_type) + )", + [], + )?; + conn.execute( + "CREATE INDEX IF NOT EXISTS idx_context_links_entity ON context_entity_links(entity_id)", + [], + )?; + conn.execute("PRAGMA user_version = 32", [])?; + Ok(()) +} diff --git a/src/registry/test_helpers.rs b/src/registry/test_helpers.rs index ec57e55..b20d239 100644 --- a/src/registry/test_helpers.rs +++ b/src/registry/test_helpers.rs @@ -362,6 +362,34 @@ CREATE TABLE IF NOT EXISTS repo_index_state ( last_commit_hash TEXT, indexed_at DATETIME DEFAULT current_timestamp ); + +CREATE TABLE IF NOT EXISTS agent_contexts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + intent TEXT, + status TEXT DEFAULT 'active', + created_at DATETIME DEFAULT current_timestamp, + updated_at DATETIME DEFAULT current_timestamp +); + +CREATE TABLE IF NOT EXISTS agent_memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + context_id TEXT NOT NULL, + memory_type TEXT NOT NULL, + content TEXT NOT NULL, + created_at DATETIME DEFAULT current_timestamp, + FOREIGN KEY (context_id) REFERENCES agent_contexts(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS idx_agent_memories_context ON agent_memories(context_id); + +CREATE TABLE IF NOT EXISTS context_entity_links ( + context_id TEXT NOT NULL, + entity_id TEXT NOT NULL, + link_type TEXT NOT NULL DEFAULT 'linked', + created_at TEXT NOT NULL, + PRIMARY KEY (context_id, entity_id, link_type) +); +CREATE INDEX IF NOT EXISTS idx_context_links_entity ON context_entity_links(entity_id); "#; #[cfg(test)] @@ -413,4 +441,30 @@ mod tests { .unwrap_or(false); assert!(exists, "knowledge_meta table must exist in current schema"); } + + #[test] + fn test_agent_contexts_table_exists() { + let conn = WorkspaceRegistry::init_in_memory().unwrap(); + let exists: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_contexts'", + [], + |_| Ok(true), + ) + .unwrap_or(false); + assert!(exists, "agent_contexts table must exist in current schema"); + } + + #[test] + fn test_agent_memories_table_exists() { + let conn = WorkspaceRegistry::init_in_memory().unwrap(); + let exists: bool = conn + .query_row( + "SELECT 1 FROM sqlite_master WHERE type='table' AND name='agent_memories'", + [], + |_| Ok(true), + ) + .unwrap_or(false); + assert!(exists, "agent_memories table must exist in current schema"); + } } diff --git a/src/skill_runtime/executor.rs b/src/skill_runtime/executor.rs index 68b6fba..b20af52 100644 --- a/src/skill_runtime/executor.rs +++ b/src/skill_runtime/executor.rs @@ -65,6 +65,45 @@ pub fn run_skill( cmd.env("DEVBASE_SKILL_ID", &skill.id); cmd.env("DEVBASE_HOME", devbase_home()?); + // P2-B: Inject active session context memories if available + if let Some(ctx_id) = resolve_active_context() { + if let Ok(memories) = crate::registry::agent_context::list_memories(conn, &ctx_id) + && !memories.is_empty() + { + let mem_json: Vec = memories + .iter() + .map(|m| { + serde_json::json!({ + "type": m.memory_type, + "content": m.content, + }) + }) + .collect(); + cmd.env("DEVBASE_ACTIVE_CONTEXT", &ctx_id); + cmd.env( + "DEVBASE_CONTEXT_MEMORIES", + serde_json::to_string(&mem_json).unwrap_or_default(), + ); + } + if let Ok(linked) = crate::registry::agent_context::list_linked_entities(conn, &ctx_id) + && !linked.is_empty() + { + let links_json: Vec = linked + .iter() + .map(|(eid, ltype, _)| { + serde_json::json!({ + "entity_id": eid, + "link_type": ltype, + }) + }) + .collect(); + cmd.env( + "DEVBASE_CONTEXT_LINKS", + serde_json::to_string(&links_json).unwrap_or_default(), + ); + } + } + // Build JSON input from key=value args and pass via stdin let mut json_args = serde_json::Map::new(); for arg in args { @@ -215,6 +254,22 @@ pub(crate) fn check_hard_vetoes_for_skill( )) } +/// Resolve the active agent context ID from environment or state file. +fn resolve_active_context() -> Option { + if let Ok(ctx) = std::env::var("DEVBASE_ACTIVE_CONTEXT") + && !ctx.is_empty() + { + return Some(ctx); + } + let state_file = crate::registry::WorkspaceRegistry::workspace_dir() + .ok()? + .join(".active_context"); + std::fs::read_to_string(state_file) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn resolve_interpreter(path: &std::path::Path) -> (Option, String) { let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let path_str = path.to_string_lossy().to_string();