diff options
| author | soryu <soryu@soryu.co> | 2026-02-12 02:29:45 +0000 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-12 02:29:45 +0000 |
| commit | 355f10964c4dbec24a244a00caba5c17ed23fc65 (patch) | |
| tree | 6fdc998e6b95948e80a87a962acd58acf79d5b98 /makima/src | |
| parent | 9bd6eacaa9ebe860842b5d5cfbf2b7d2d0293ab1 (diff) | |
| download | soryu-355f10964c4dbec24a244a00caba5c17ed23fc65.tar.gz soryu-355f10964c4dbec24a244a00caba5c17ed23fc65.zip | |
makima: Add an optional memory system for directives (#59)
* feat: makima: Add an optional memory system for directives: Add directive_memories database table and migration
* feat: makima: Add an optional memory system for directives: Update directive skill documentation with memory commands
* feat: makima: Add an optional memory system for directives: Add repository functions for directive memory CRUD
* feat: makima: Add an optional memory system for directives: Add frontend API functions and types for directive memory
* feat: makima: Add an optional memory system for directives: Add Rust models for directive memory
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* WIP: heartbeat checkpoint
* feat: makima: Add an optional memory system for directives: Add memory panel to frontend DirectiveDetail component
* Merge remote-tracking branch 'origin/makima/makima--add-an-optional-memory-system-for-directiv-5de1e06d' into combined branch
* Merge remote-tracking branch 'origin/makima/makima--add-an-optional-memory-system-for-directiv-c8298c6c' into combined branch
* feat: makima: Add an optional memory system for directives: Create useMultiTaskSubscription hook for multi-output WebSocket streaming
* feat: makima: Add an optional memory system for directives: Create DirectiveLogStream component for stern-like multi-task output viewing
* feat: makima: Add an optional memory system for directives: Integrate log stream panel into directive detail page
Diffstat (limited to 'makima/src')
| -rw-r--r-- | makima/src/bin/makima.rs | 44 | ||||
| -rw-r--r-- | makima/src/daemon/api/directive.rs | 200 | ||||
| -rw-r--r-- | makima/src/daemon/cli/directive.rs | 48 | ||||
| -rw-r--r-- | makima/src/daemon/cli/mod.rs | 18 | ||||
| -rw-r--r-- | makima/src/daemon/skills/directive.md | 80 | ||||
| -rw-r--r-- | makima/src/db/models.rs | 47 | ||||
| -rw-r--r-- | makima/src/db/repository.rs | 146 | ||||
| -rw-r--r-- | makima/src/orchestration/directive.rs | 85 | ||||
| -rw-r--r-- | makima/src/server/handlers/directives.rs | 395 | ||||
| -rw-r--r-- | makima/src/server/mod.rs | 4 | ||||
| -rw-r--r-- | makima/src/server/openapi.rs | 21 |
11 files changed, 1068 insertions, 20 deletions
diff --git a/makima/src/bin/makima.rs b/makima/src/bin/makima.rs index c2c9beb..d4af878 100644 --- a/makima/src/bin/makima.rs +++ b/makima/src/bin/makima.rs @@ -825,6 +825,50 @@ async fn run_directive( .await?; println!("{}", serde_json::to_string(&result.0)?); } + DirectiveCommand::MemorySet(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_memory_set(args.common.directive_id, &args.key, &args.value) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::MemoryGet(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let result = client + .directive_memory_get(args.common.directive_id, &args.key) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::MemoryList(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + let result = client + .directive_memory_list(args.directive_id) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } + DirectiveCommand::MemoryDelete(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + client + .directive_memory_delete(args.common.directive_id, &args.key) + .await?; + println!(r#"{{"success": true}}"#); + } + DirectiveCommand::MemoryClear(args) => { + let client = ApiClient::new(args.api_url, args.api_key)?; + client + .directive_memory_clear(args.directive_id) + .await?; + println!(r#"{{"success": true}}"#); + } + DirectiveCommand::MemoryBatchSet(args) => { + let client = ApiClient::new(args.common.api_url, args.common.api_key)?; + let entries: serde_json::Value = serde_json::from_str(&args.json) + .map_err(|e| format!("Invalid JSON: {}", e))?; + let result = client + .directive_memory_batch_set(args.common.directive_id, entries) + .await?; + println!("{}", serde_json::to_string(&result.0)?); + } } Ok(()) diff --git a/makima/src/daemon/api/directive.rs b/makima/src/daemon/api/directive.rs index 5886766..fcc2ca5 100644 --- a/makima/src/daemon/api/directive.rs +++ b/makima/src/daemon/api/directive.rs @@ -30,6 +30,54 @@ pub struct UpdateStepDepsRequest { pub depends_on: Vec<Uuid>, } +/// Percent-encode a string for use as a URL path segment. +/// +/// Encodes all characters except unreserved characters (alphanumeric, `-`, `.`, `_`, `~`). +fn percent_encode_path(s: &str) -> String { + let mut encoded = String::with_capacity(s.len()); + for byte in s.bytes() { + match byte { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => { + encoded.push(byte as char); + } + _ => { + encoded.push_str(&format!("%{:02X}", byte)); + } + } + } + encoded +} + +/// Request body for setting a single memory entry. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetMemoryRequest { + pub key: String, + pub value: String, +} + +/// A single entry within a batch set request. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchMemoryEntry { + pub key: String, + pub value: String, +} + +/// Request body for setting multiple memory entries at once. +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct BatchSetMemoryRequest { + pub entries: Vec<BatchMemoryEntry>, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MemorySetRequest { + pub value: String, +} + + impl ApiClient { /// List all directives. pub async fn list_directives(&self) -> Result<JsonValue, ApiError> { @@ -145,6 +193,158 @@ impl ApiClient { let req = UpdateDirectiveMetadataRequest { pr_url, pr_branch }; self.put(&format!("/api/v1/directives/{}", directive_id), &req).await } + + // ── Directive Memory ────────────────────────────────────────────── + + /// List all memory entries for a directive. + pub async fn list_memories(&self, directive_id: Uuid) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}/memory", directive_id)) + .await + } + + /// Get a single memory entry by key. + pub async fn get_memory( + &self, + directive_id: Uuid, + key: &str, + ) -> Result<JsonValue, ApiError> { + self.get(&format!( + "/api/v1/directives/{}/memory/{}", + directive_id, + percent_encode_path(key) + )) + .await + } + + /// Set (create or update) a single memory entry. + pub async fn set_memory( + &self, + directive_id: Uuid, + key: &str, + value: &str, + ) -> Result<JsonValue, ApiError> { + let req = SetMemoryRequest { + key: key.to_string(), + value: value.to_string(), + }; + self.put(&format!("/api/v1/directives/{}/memory", directive_id), &req) + .await + } + + /// Set multiple memory entries in a single request. + pub async fn batch_set_memories( + &self, + directive_id: Uuid, + entries: Vec<(String, String)>, + ) -> Result<JsonValue, ApiError> { + let req = BatchSetMemoryRequest { + entries: entries + .into_iter() + .map(|(key, value)| BatchMemoryEntry { key, value }) + .collect(), + }; + self.post( + &format!("/api/v1/directives/{}/memory/batch", directive_id), + &req, + ) + .await + } + + /// Delete a single memory entry by key. + pub async fn delete_memory( + &self, + directive_id: Uuid, + key: &str, + ) -> Result<(), ApiError> { + self.delete(&format!( + "/api/v1/directives/{}/memory/{}", + directive_id, + percent_encode_path(key) + )) + .await + } + + /// Clear all memory entries for a directive. + pub async fn clear_memories(&self, directive_id: Uuid) -> Result<(), ApiError> { + self.delete(&format!("/api/v1/directives/{}/memory", directive_id)) + .await + } + + // ── CLI-facing Directive Memory aliases ────────────────────────── + + /// Set a memory key-value pair for a directive (CLI-facing). + pub async fn directive_memory_set( + &self, + directive_id: Uuid, + key: &str, + value: &str, + ) -> Result<JsonValue, ApiError> { + let req = MemorySetRequest { + value: value.to_string(), + }; + self.put( + &format!("/api/v1/directives/{}/memory/{}", directive_id, key), + &req, + ) + .await + } + + /// Get a memory value by key for a directive (CLI-facing). + pub async fn directive_memory_get( + &self, + directive_id: Uuid, + key: &str, + ) -> Result<JsonValue, ApiError> { + self.get(&format!( + "/api/v1/directives/{}/memory/{}", + directive_id, key + )) + .await + } + + /// List all memory key-value pairs for a directive (CLI-facing). + pub async fn directive_memory_list( + &self, + directive_id: Uuid, + ) -> Result<JsonValue, ApiError> { + self.get(&format!("/api/v1/directives/{}/memory", directive_id)) + .await + } + + /// Delete a memory key for a directive (CLI-facing). + pub async fn directive_memory_delete( + &self, + directive_id: Uuid, + key: &str, + ) -> Result<(), ApiError> { + self.delete(&format!( + "/api/v1/directives/{}/memory/{}", + directive_id, key + )) + .await + } + + /// Clear all memory for a directive (CLI-facing). + pub async fn directive_memory_clear( + &self, + directive_id: Uuid, + ) -> Result<(), ApiError> { + self.delete(&format!("/api/v1/directives/{}/memory", directive_id)) + .await + } + + /// Batch set multiple memory key-value pairs for a directive (CLI-facing). + pub async fn directive_memory_batch_set( + &self, + directive_id: Uuid, + entries: serde_json::Value, + ) -> Result<JsonValue, ApiError> { + self.post( + &format!("/api/v1/directives/{}/memory/batch", directive_id), + &entries, + ) + .await + } } #[derive(Serialize)] diff --git a/makima/src/daemon/cli/directive.rs b/makima/src/daemon/cli/directive.rs index 2e6ac1d..8eded77 100644 --- a/makima/src/daemon/cli/directive.rs +++ b/makima/src/daemon/cli/directive.rs @@ -125,3 +125,51 @@ pub struct UpdateArgs { #[arg(long)] pub pr_branch: Option<String>, } + +/// Arguments for memory-set command. +#[derive(Args, Debug)] +pub struct MemorySetArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Memory key + pub key: String, + + /// Memory value + pub value: String, +} + +/// Arguments for memory-get command. +#[derive(Args, Debug)] +pub struct MemoryGetArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Memory key + pub key: String, +} + +/// Arguments for memory-list command (uses DirectiveArgs directly). + +/// Arguments for memory-delete command. +#[derive(Args, Debug)] +pub struct MemoryDeleteArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// Memory key to delete + pub key: String, +} + +/// Arguments for memory-clear command (uses DirectiveArgs directly). + +/// Arguments for memory-batch-set command. +#[derive(Args, Debug)] +pub struct MemoryBatchSetArgs { + #[command(flatten)] + pub common: DirectiveArgs, + + /// JSON object of key-value pairs: {"key1":"value1","key2":"value2"} + #[arg(long)] + pub json: String, +} diff --git a/makima/src/daemon/cli/mod.rs b/makima/src/daemon/cli/mod.rs index bcaaa70..a78e5f8 100644 --- a/makima/src/daemon/cli/mod.rs +++ b/makima/src/daemon/cli/mod.rs @@ -249,6 +249,24 @@ pub enum DirectiveCommand { /// Update directive metadata (PR URL, etc.) Update(directive::UpdateArgs), + + /// Set a memory key-value pair for the directive + MemorySet(directive::MemorySetArgs), + + /// Get a memory value by key + MemoryGet(directive::MemoryGetArgs), + + /// List all memory key-value pairs + MemoryList(DirectiveArgs), + + /// Delete a memory key + MemoryDelete(directive::MemoryDeleteArgs), + + /// Clear all memory for the directive + MemoryClear(DirectiveArgs), + + /// Batch set multiple memory key-value pairs from JSON + MemoryBatchSet(directive::MemoryBatchSetArgs), } impl Cli { diff --git a/makima/src/daemon/skills/directive.md b/makima/src/daemon/skills/directive.md index 7c55cf8..68d9277 100644 --- a/makima/src/daemon/skills/directive.md +++ b/makima/src/daemon/skills/directive.md @@ -76,6 +76,86 @@ Updates the goal and bumps `goalUpdatedAt`. If the directive is `idle`, it react makima directive pause ``` +## Memory Commands + +Directives have an optional key-value memory system that persists across steps and planning cycles. Use memory to share context, decisions, and learned information between steps — so downstream tasks don't need to re-discover what earlier steps already figured out. + +### Set a Memory Entry +```bash +makima directive memory-set <key> <value> +``` +Stores a key-value pair in the directive's memory. If the key already exists, the value is overwritten. Keys are strings; values are strings (use JSON encoding for structured data). + +**Example:** +```bash +makima directive memory-set "db_schema_version" "3" +makima directive memory-set "auth_pattern" "JWT with refresh tokens stored in httpOnly cookies" +makima directive memory-set "api_base_path" "/api/v2" +``` + +### Get a Memory Entry +```bash +makima directive memory-get <key> +``` +Retrieves the value for a specific key. Returns the value if found, or an error if the key does not exist. + +**Example:** +```bash +makima directive memory-get "db_schema_version" +``` + +### List All Memory Entries +```bash +makima directive memory-list +``` +Returns all key-value pairs stored in the directive's memory. Useful for understanding what context is available before starting work on a step. + +### Delete a Memory Entry +```bash +makima directive memory-delete <key> +``` +Removes a single key-value pair from memory. + +**Example:** +```bash +makima directive memory-delete "deprecated_config_key" +``` + +### Clear All Memory +```bash +makima directive memory-clear +``` +Removes **all** key-value pairs from the directive's memory. Use with caution — this is irreversible. + +### Batch Set Memory Entries +```bash +makima directive memory-batch-set --json '{"key1": "value1", "key2": "value2"}' +``` +Sets multiple key-value pairs in a single operation. Existing keys are overwritten; keys not mentioned are left unchanged. + +**Example:** +```bash +makima directive memory-batch-set --json '{"framework": "axum", "orm": "sqlx", "test_runner": "cargo test"}' +``` + +## Using Memory Effectively + +### When to Write Memory +- **During planning**: Record architectural decisions, technology choices, and file layout patterns +- **After step completion**: Save discovered information (e.g., generated IDs, API endpoints, schema details) +- **When context matters**: Store anything a downstream step would need to avoid re-exploring the codebase + +### When to Read Memory +- **At step start**: Check `memory-list` to see what context previous steps have provided +- **Before making decisions**: Check if an earlier step already made a relevant architectural choice +- **During re-planning**: Read memory to understand what was learned in previous iterations + +### Best Practices +- Use descriptive, namespaced keys (e.g., `auth.strategy`, `db.migration_count`, `api.base_url`) +- Store concise but complete values — enough for another task to act on without guessing +- Clean up stale entries when the directive's goal changes significantly +- Use `memory-batch-set` when recording multiple related decisions at once + ## Orchestration Workflow ### Initial Setup diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 542339f..169f468 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -2714,8 +2714,10 @@ pub struct Directive { pub pr_url: Option<String>, pub pr_branch: Option<String>, pub completion_task_id: Option<Uuid>, + pub memory_enabled: bool, pub goal_updated_at: DateTime<Utc>, pub started_at: Option<DateTime<Utc>>, + pub memory_enabled: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2763,6 +2765,7 @@ pub struct DirectiveSummary { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub completion_task_id: Option<Uuid>, + pub memory_enabled: bool, pub version: i32, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, @@ -2789,6 +2792,8 @@ pub struct CreateDirectiveRequest { pub repository_url: Option<String>, pub local_path: Option<String>, pub base_branch: Option<String>, + #[serde(default)] + pub memory_enabled: bool, } /// Request to update a directive. @@ -2804,6 +2809,7 @@ pub struct UpdateDirectiveRequest { pub orchestrator_task_id: Option<Uuid>, pub pr_url: Option<String>, pub pr_branch: Option<String>, + pub memory_enabled: Option<bool>, pub version: Option<i32>, } @@ -2840,3 +2846,44 @@ pub struct UpdateDirectiveStepRequest { pub task_id: Option<Uuid>, pub order_index: Option<i32>, } + +// ============================================================================= +// Directive Memory Types +// ============================================================================= + +/// A memory entry for a directive — key-value context that persists across tasks. +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveMemory { + pub id: Uuid, + pub directive_id: Uuid, + pub key: String, + pub value: String, + pub category: Option<String>, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request to set a memory entry (upsert by key). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SetDirectiveMemoryRequest { + pub key: String, + pub value: String, + pub category: Option<String>, +} + +/// Request to batch set memory entries. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct BatchSetDirectiveMemoryRequest { + pub entries: Vec<SetDirectiveMemoryRequest>, +} + +/// Response for listing memories. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DirectiveMemoryListResponse { + pub memories: Vec<DirectiveMemory>, + pub total: i64, +} diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 7afbeea..95460f7 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -11,9 +11,10 @@ use super::models::{ ContractTypeTemplateRecord, ConversationMessage, ConversationSnapshot, CreateContractRequest, CreateFileRequest, CreateTaskRequest, CreateTemplateRequest, Daemon, DaemonTaskAssignment, DaemonWithCapacity, - DeliverableDefinition, Directive, DirectiveStep, DirectiveSummary, + DeliverableDefinition, Directive, DirectiveMemory, DirectiveStep, DirectiveSummary, CreateDirectiveRequest, CreateDirectiveStepRequest, UpdateDirectiveRequest, - UpdateDirectiveStepRequest, + UpdateDirectiveStepRequest, SetDirectiveMemoryRequest, + BatchSetDirectiveMemoryRequest, DirectiveMemoryListResponse, File, FileSummary, FileVersion, HistoryEvent, HistoryQueryFilters, MeshChatConversation, MeshChatMessageRecord, PhaseChangeResult, PhaseConfig, PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, @@ -4929,8 +4930,8 @@ pub async fn create_directive_for_owner( ) -> Result<Directive, sqlx::Error> { sqlx::query_as::<_, Directive>( r#" - INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch) - VALUES ($1, $2, $3, $4, $5, $6) + INSERT INTO directives (owner_id, title, goal, repository_url, local_path, base_branch, memory_enabled) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) @@ -4940,6 +4941,7 @@ pub async fn create_directive_for_owner( .bind(&req.repository_url) .bind(&req.local_path) .bind(&req.base_branch) + .bind(req.memory_enabled) .fetch_one(pool) .await } @@ -4992,7 +4994,7 @@ pub async fn list_directives_for_owner( SELECT d.id, d.owner_id, d.title, d.goal, d.status, d.repository_url, d.orchestrator_task_id, d.pr_url, d.completion_task_id, - d.version, d.created_at, d.updated_at, + d.memory_enabled, d.version, d.created_at, d.updated_at, COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id), 0) as total_steps, COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'completed'), 0) as completed_steps, COALESCE((SELECT COUNT(*) FROM directive_steps WHERE directive_id = d.id AND status = 'running'), 0) as running_steps, @@ -5046,12 +5048,13 @@ pub async fn update_directive_for_owner( let orchestrator_task_id = req.orchestrator_task_id.or(current.orchestrator_task_id); let pr_url = req.pr_url.as_deref().or(current.pr_url.as_deref()); let pr_branch = req.pr_branch.as_deref().or(current.pr_branch.as_deref()); + let memory_enabled = req.memory_enabled.unwrap_or(current.memory_enabled); let result = sqlx::query_as::<_, Directive>( r#" UPDATE directives SET title = $3, goal = $4, status = $5, repository_url = $6, local_path = $7, - base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11, + base_branch = $8, orchestrator_task_id = $9, pr_url = $10, pr_branch = $11, memory_enabled = $12, version = version + 1, updated_at = NOW() WHERE id = $1 AND owner_id = $2 RETURNING * @@ -5068,6 +5071,7 @@ pub async fn update_directive_for_owner( .bind(orchestrator_task_id) .bind(pr_url) .bind(pr_branch) + .bind(memory_enabled) .fetch_optional(pool) .await .map_err(RepositoryError::Database)?; @@ -5500,6 +5504,7 @@ pub struct StepForDispatch { pub directive_title: String, pub repository_url: Option<String>, pub base_branch: Option<String>, + pub memory_enabled: bool, } /// Get ready steps that need task dispatch. @@ -5519,7 +5524,8 @@ pub async fn get_ready_steps_for_dispatch( d.owner_id, d.title AS directive_title, d.repository_url, - d.base_branch + d.base_branch, + d.memory_enabled FROM directive_steps ds JOIN directives d ON d.id = ds.directive_id WHERE ds.status = 'ready' @@ -5740,3 +5746,129 @@ pub async fn get_directive_max_generation( .await?; Ok(row.0.unwrap_or(0)) } + +// ============================================================================= +// Directive Memory CRUD +// ============================================================================= + +/// List all memories for a directive, optionally filtered by category. +pub async fn list_directive_memories( + pool: &PgPool, + directive_id: Uuid, + category: Option<&str>, +) -> Result<Vec<DirectiveMemory>, sqlx::Error> { + match category { + Some(cat) => { + sqlx::query_as::<_, DirectiveMemory>( + r#" + SELECT * FROM directive_memories + WHERE directive_id = $1 AND category = $2 + ORDER BY key + "#, + ) + .bind(directive_id) + .bind(cat) + .fetch_all(pool) + .await + } + None => { + sqlx::query_as::<_, DirectiveMemory>( + r#" + SELECT * FROM directive_memories + WHERE directive_id = $1 + ORDER BY key + "#, + ) + .bind(directive_id) + .fetch_all(pool) + .await + } + } +} + +/// Get a single memory entry by directive ID and key. +pub async fn get_directive_memory( + pool: &PgPool, + directive_id: Uuid, + key: &str, +) -> Result<Option<DirectiveMemory>, sqlx::Error> { + sqlx::query_as::<_, DirectiveMemory>( + r#" + SELECT * FROM directive_memories + WHERE directive_id = $1 AND key = $2 + "#, + ) + .bind(directive_id) + .bind(key) + .fetch_optional(pool) + .await +} + +/// Set (upsert) a memory entry for a directive. +pub async fn set_directive_memory( + pool: &PgPool, + directive_id: Uuid, + req: &SetDirectiveMemoryRequest, +) -> Result<DirectiveMemory, sqlx::Error> { + sqlx::query_as::<_, DirectiveMemory>( + r#" + INSERT INTO directive_memories (directive_id, key, value, category) + VALUES ($1, $2, $3, $4) + ON CONFLICT (directive_id, key) + DO UPDATE SET value = EXCLUDED.value, + category = EXCLUDED.category, + updated_at = NOW() + RETURNING * + "#, + ) + .bind(directive_id) + .bind(&req.key) + .bind(&req.value) + .bind(&req.category) + .fetch_one(pool) + .await +} + +/// Batch set memory entries for a directive. +pub async fn batch_set_directive_memories( + pool: &PgPool, + directive_id: Uuid, + memories: &[SetDirectiveMemoryRequest], +) -> Result<Vec<DirectiveMemory>, sqlx::Error> { + let mut results = Vec::with_capacity(memories.len()); + for mem in memories { + let result = set_directive_memory(pool, directive_id, mem).await?; + results.push(result); + } + Ok(results) +} + +/// Delete a single memory entry by key. +pub async fn delete_directive_memory( + pool: &PgPool, + directive_id: Uuid, + key: &str, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM directive_memories WHERE directive_id = $1 AND key = $2"#, + ) + .bind(directive_id) + .bind(key) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Delete all memory entries for a directive. +pub async fn clear_directive_memories( + pool: &PgPool, + directive_id: Uuid, +) -> Result<u64, sqlx::Error> { + let result = sqlx::query( + r#"DELETE FROM directive_memories WHERE directive_id = $1"#, + ) + .bind(directive_id) + .execute(pool) + .await?; + Ok(result.rows_affected()) +} diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 15cc7ed..cb3983a 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -9,7 +9,7 @@ use sqlx::PgPool; use uuid::Uuid; -use crate::db::models::{CreateTaskRequest, UpdateTaskRequest}; +use crate::db::models::{CreateTaskRequest, DirectiveMemory, UpdateTaskRequest}; use crate::db::repository; use crate::server::state::{DaemonCommand, SharedState}; @@ -44,7 +44,24 @@ impl DirectiveOrchestrator { "Directive needs planning — spawning planning task" ); - let plan = build_planning_prompt(&directive, &[], 1); + // Load memories if memory is enabled for this directive + let memories = if directive.memory_enabled { + match repository::list_directive_memories(&self.pool, directive.id).await { + Ok(m) => m, + Err(e) => { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to load directive memories for planning — continuing without" + ); + vec![] + } + } + } else { + vec![] + }; + + let plan = build_planning_prompt(&directive, &[], 1, &memories); if let Err(e) = self .spawn_orchestrator_task( @@ -86,17 +103,40 @@ impl DirectiveOrchestrator { .as_deref() .unwrap_or("Execute the step described below."); + // Load memories if memory is enabled for this directive + let memory_context = if step.memory_enabled { + match repository::list_directive_memories(&self.pool, step.directive_id).await { + Ok(memories) if !memories.is_empty() => { + format!("\n\nMEMORY CONTEXT (from previous planning/execution cycles):\n{}\n", + format_memories_for_prompt(&memories)) + } + Ok(_) => String::new(), + Err(e) => { + tracing::warn!( + directive_id = %step.directive_id, + error = %e, + "Failed to load directive memories for execution — continuing without" + ); + String::new() + } + } + } else { + String::new() + }; + let plan = format!( "You are executing a step in directive \"{directive_title}\".\n\n\ STEP: {step_name}\n\ DESCRIPTION: {description}\n\n\ - INSTRUCTIONS:\n{task_plan}\n\n\ + INSTRUCTIONS:\n{task_plan}\n\ + {memory_context}\ When done, the system will automatically mark this step as completed.\n\ If you cannot complete the task, report the failure clearly.", directive_title = step.directive_title, step_name = step.step_name, description = step.step_description.as_deref().unwrap_or("(none)"), task_plan = task_plan, + memory_context = memory_context, ); match self @@ -239,7 +279,24 @@ impl DirectiveOrchestrator { let generation = repository::get_directive_max_generation(&self.pool, directive.id).await? + 1; - let plan = build_planning_prompt(&directive, &existing_steps, generation); + // Load memories if memory is enabled for this directive + let memories = if directive.memory_enabled { + match repository::list_directive_memories(&self.pool, directive.id).await { + Ok(m) => m, + Err(e) => { + tracing::warn!( + directive_id = %directive.id, + error = %e, + "Failed to load directive memories for re-planning — continuing without" + ); + vec![] + } + } + } else { + vec![] + }; + + let plan = build_planning_prompt(&directive, &existing_steps, generation, &memories); if let Err(e) = self .spawn_orchestrator_task( @@ -597,14 +654,34 @@ impl DirectiveOrchestrator { } } +/// Format memory entries into a readable prompt section. +fn format_memories_for_prompt(memories: &[DirectiveMemory]) -> String { + let mut out = String::new(); + for memory in memories { + out.push_str(&format!( + "- [{}] ({}): {}\n", + memory.category, memory.source, memory.content + )); + } + out +} + /// Build the planning prompt for a directive. fn build_planning_prompt( directive: &crate::db::models::Directive, existing_steps: &[crate::db::models::DirectiveStep], generation: i32, + memories: &[DirectiveMemory], ) -> String { let mut prompt = String::new(); + // Include memory context if available + if !memories.is_empty() { + prompt.push_str("MEMORY CONTEXT (insights and decisions from previous cycles):\n"); + prompt.push_str(&format_memories_for_prompt(memories)); + prompt.push_str("\nUse these memories to inform your planning. Avoid repeating past mistakes and build on prior insights.\n\n"); + } + if !existing_steps.is_empty() { prompt.push_str(&format!( "EXISTING STEPS (generation {}):\n", diff --git a/makima/src/server/handlers/directives.rs b/makima/src/server/handlers/directives.rs index d48ff74..f624d82 100644 --- a/makima/src/server/handlers/directives.rs +++ b/makima/src/server/handlers/directives.rs @@ -1,23 +1,31 @@ //! HTTP handlers for directive CRUD and DAG progression. use axum::{ - extract::{Path, State}, + extract::{Path, Query, State}, http::StatusCode, response::IntoResponse, Json, }; +use serde::Deserialize; use uuid::Uuid; use crate::db::models::{ - CreateDirectiveRequest, CreateDirectiveStepRequest, Directive, DirectiveListResponse, - DirectiveStep, DirectiveWithSteps, UpdateDirectiveRequest, UpdateDirectiveStepRequest, - UpdateGoalRequest, + BatchSetDirectiveMemoryRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, + Directive, DirectiveListResponse, DirectiveMemory, DirectiveMemoryListResponse, + DirectiveStep, DirectiveWithSteps, SetDirectiveMemoryRequest, UpdateDirectiveRequest, + UpdateDirectiveStepRequest, UpdateGoalRequest, }; use crate::db::repository; use crate::server::auth::Authenticated; use crate::server::messages::ApiError; use crate::server::state::SharedState; +/// Query parameters for the memory list endpoint. +#[derive(Debug, Deserialize)] +pub struct MemoryListQuery { + pub category: Option<String>, +} + // ============================================================================= // Directive CRUD // ============================================================================= @@ -839,3 +847,382 @@ pub async fn update_goal( } } } + +// ============================================================================= +// Directive Memory CRUD +// ============================================================================= + +/// List all memories for a directive, optionally filtered by category. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/memories", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("category" = Option<String>, Query, description = "Filter by category"), + ), + responses( + (status = 200, description = "List of memories", body = DirectiveMemoryListResponse), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn list_memories( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Query(query): Query<MemoryListQuery>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::list_directive_memories(pool, id, query.category.as_deref()).await { + Ok(memories) => { + let total = memories.len() as i64; + Json(DirectiveMemoryListResponse { memories, total }).into_response() + } + Err(e) => { + tracing::error!("Failed to list memories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("LIST_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Get a single memory entry by key. +#[utoipa::path( + get, + path = "/api/v1/directives/{id}/memories/{key}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("key" = String, Path, description = "Memory key"), + ), + responses( + (status = 200, description = "Memory entry", body = DirectiveMemory), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn get_memory( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, key)): Path<(Uuid, String)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::get_directive_memory(pool, id, &key).await { + Ok(Some(memory)) => Json(memory).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Memory entry not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get memory: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Set (upsert) a single memory entry. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/memories", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = SetDirectiveMemoryRequest, + responses( + (status = 200, description = "Memory entry set", body = DirectiveMemory), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn set_memory( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<SetDirectiveMemoryRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::set_directive_memory(pool, id, &req).await { + Ok(memory) => Json(memory).into_response(), + Err(e) => { + tracing::error!("Failed to set memory: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("SET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Batch set multiple memory entries. +#[utoipa::path( + post, + path = "/api/v1/directives/{id}/memories/batch", + params(("id" = Uuid, Path, description = "Directive ID")), + request_body = BatchSetDirectiveMemoryRequest, + responses( + (status = 200, description = "Memory entries set", body = Vec<DirectiveMemory>), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn batch_set_memories( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, + Json(req): Json<BatchSetDirectiveMemoryRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::batch_set_directive_memories(pool, id, &req.memories).await { + Ok(memories) => Json(memories).into_response(), + Err(e) => { + tracing::error!("Failed to batch set memories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("SET_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a single memory entry by key. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/memories/{key}", + params( + ("id" = Uuid, Path, description = "Directive ID"), + ("key" = String, Path, description = "Memory key"), + ), + responses( + (status = 204, description = "Deleted"), + (status = 404, description = "Not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn delete_memory( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path((id, key)): Path<(Uuid, String)>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::delete_directive_memory(pool, id, &key).await { + Ok(true) => StatusCode::NO_CONTENT.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Memory entry not found")), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete memory: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DELETE_FAILED", &e.to_string())), + ) + .into_response() + } + } +} + +/// Clear all memories for a directive. +#[utoipa::path( + delete, + path = "/api/v1/directives/{id}/memories", + params(("id" = Uuid, Path, description = "Directive ID")), + responses( + (status = 204, description = "All memories cleared"), + (status = 404, description = "Directive not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + ), + security(("bearer_auth" = []), ("api_key" = [])), + tag = "Directives" +)] +pub async fn clear_memories( + State(state): State<SharedState>, + Authenticated(auth): Authenticated, + Path(id): Path<Uuid>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + // Verify directive ownership + match repository::get_directive_for_owner(pool, auth.owner_id, id).await { + Ok(Some(_)) => {} + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", "Directive not found")), + ) + .into_response(); + } + Err(e) => { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("GET_FAILED", &e.to_string())), + ) + .into_response(); + } + } + + match repository::clear_directive_memories(pool, id).await { + Ok(_) => StatusCode::NO_CONTENT.into_response(), + Err(e) => { + tracing::error!("Failed to clear memories: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("CLEAR_FAILED", &e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index 4cb4296..b380508 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -237,6 +237,10 @@ pub fn make_router(state: SharedState) -> Router { .route("/directives/{id}/steps/{step_id}/fail", post(directives::fail_step)) .route("/directives/{id}/steps/{step_id}/skip", post(directives::skip_step)) .route("/directives/{id}/goal", put(directives::update_goal)) + // Directive memory endpoints + .route("/directives/{id}/memories", get(directives::list_memories).post(directives::set_memory).delete(directives::clear_memories)) + .route("/directives/{id}/memories/batch", post(directives::batch_set_memories)) + .route("/directives/{id}/memories/{key}", get(directives::get_memory).delete(directives::delete_memory)) // Timeline endpoint (unified history for user) .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs index ddc2db5..f049759 100644 --- a/makima/src/server/openapi.rs +++ b/makima/src/server/openapi.rs @@ -3,21 +3,21 @@ use utoipa::OpenApi; use crate::db::models::{ - AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BranchInfo, BranchListResponse, - BranchTaskRequest, BranchTaskResponse, + AddLocalRepositoryRequest, AddRemoteRepositoryRequest, BatchSetDirectiveMemoryRequest, + BranchInfo, BranchListResponse, BranchTaskRequest, BranchTaskResponse, ChangePhaseRequest, Contract, ContractChatHistoryResponse, ContractChatMessageRecord, ContractEvent, ContractListResponse, ContractRepository, ContractSummary, ContractWithRelations, CreateContractRequest, CreateDirectiveRequest, CreateDirectiveStepRequest, CreateFileRequest, CreateManagedRepositoryRequest, CreateTaskRequest, Daemon, DaemonDirectoriesResponse, - DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveStep, - DirectiveSummary, DirectiveWithSteps, + DaemonDirectory, DaemonListResponse, Directive, DirectiveListResponse, DirectiveMemory, + DirectiveMemoryListResponse, DirectiveStep, DirectiveSummary, DirectiveWithSteps, File, FileListResponse, FileSummary, MergeCommitRequest, MergeCompleteCheckResponse, MergeResolveRequest, MergeResultResponse, MergeSkipRequest, MergeStartRequest, MergeStatusResponse, MeshChatConversation, MeshChatHistoryResponse, MeshChatMessageRecord, RepositoryHistoryEntry, RepositoryHistoryListResponse, RepositorySuggestionsQuery, SendMessageRequest, - Task, + SetDirectiveMemoryRequest, Task, TaskEventListResponse, TaskListResponse, TaskSummary, TaskWithSubtasks, TranscriptEntry, UpdateContractRequest, UpdateDirectiveRequest, UpdateDirectiveStepRequest, UpdateFileRequest, UpdateGoalRequest, UpdateTaskRequest, @@ -123,6 +123,13 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage directives::fail_step, directives::skip_step, directives::update_goal, + // Directive memory endpoints + directives::list_memories, + directives::get_memory, + directives::set_memory, + directives::batch_set_memories, + directives::delete_memory, + directives::clear_memories, // Repository history/settings endpoints repository_history::list_repository_history, repository_history::get_repository_suggestions, @@ -219,6 +226,10 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage UpdateGoalRequest, CreateDirectiveStepRequest, UpdateDirectiveStepRequest, + DirectiveMemory, + DirectiveMemoryListResponse, + SetDirectiveMemoryRequest, + BatchSetDirectiveMemoryRequest, // Repository history schemas RepositoryHistoryEntry, RepositoryHistoryListResponse, |
