summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2026-02-12 02:29:45 +0000
committerGitHub <noreply@github.com>2026-02-12 02:29:45 +0000
commit355f10964c4dbec24a244a00caba5c17ed23fc65 (patch)
tree6fdc998e6b95948e80a87a962acd58acf79d5b98
parent9bd6eacaa9ebe860842b5d5cfbf2b7d2d0293ab1 (diff)
downloadsoryu-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
-rw-r--r--makima/frontend/package-lock.json15
-rw-r--r--makima/frontend/src/components/directives/DirectiveDetail.tsx318
-rw-r--r--makima/frontend/src/components/directives/DirectiveLogStream.tsx367
-rw-r--r--makima/frontend/src/hooks/useDirectiveMemories.ts119
-rw-r--r--makima/frontend/src/hooks/useMultiTaskSubscription.ts191
-rw-r--r--makima/frontend/src/lib/api.ts183
-rw-r--r--makima/migrations/20260211000000_add_directive_memories.sql20
-rw-r--r--makima/migrations/20260211000000_create_directive_memories.sql16
-rw-r--r--makima/src/bin/makima.rs44
-rw-r--r--makima/src/daemon/api/directive.rs200
-rw-r--r--makima/src/daemon/cli/directive.rs48
-rw-r--r--makima/src/daemon/cli/mod.rs18
-rw-r--r--makima/src/daemon/skills/directive.md80
-rw-r--r--makima/src/db/models.rs47
-rw-r--r--makima/src/db/repository.rs146
-rw-r--r--makima/src/orchestration/directive.rs85
-rw-r--r--makima/src/server/handlers/directives.rs395
-rw-r--r--makima/src/server/mod.rs4
-rw-r--r--makima/src/server/openapi.rs21
19 files changed, 2281 insertions, 36 deletions
diff --git a/makima/frontend/package-lock.json b/makima/frontend/package-lock.json
index 38adfc4..f1d54d6 100644
--- a/makima/frontend/package-lock.json
+++ b/makima/frontend/package-lock.json
@@ -55,7 +55,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
- "peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -962,7 +961,6 @@
"resolved": "https://registry.npmjs.org/@react-router/dev/-/dev-7.11.0.tgz",
"integrity": "sha512-g1ou5Zw3r4mCU0L+EXH4vRtAiyt8qz1JOvL1k+PW4rZ4+71h5nBy/fLgD7cg5BnzQZmjRO1PzCgpF5BIrlKYxQ==",
"dev": true,
- "peer": true,
"dependencies": {
"@babel/core": "^7.27.7",
"@babel/generator": "^7.27.5",
@@ -1894,7 +1892,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"devOptional": true,
- "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2043,7 +2040,6 @@
"url": "https://github.com/sponsors/ai"
}
],
- "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2242,7 +2238,6 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
- "peer": true,
"engines": {
"node": ">=12"
}
@@ -2937,7 +2932,6 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
- "peer": true,
"engines": {
"node": ">=12"
},
@@ -3009,7 +3003,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
- "peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -3018,7 +3011,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
- "peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -3036,7 +3028,6 @@
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
- "peer": true,
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
@@ -3068,7 +3059,6 @@
"version": "7.11.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.11.0.tgz",
"integrity": "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ==",
- "peer": true,
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
@@ -3128,8 +3118,7 @@
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
- "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
- "peer": true
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
},
"node_modules/redux-thunk": {
"version": "3.1.0",
@@ -3266,7 +3255,6 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
- "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3358,7 +3346,6 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
- "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
diff --git a/makima/frontend/src/components/directives/DirectiveDetail.tsx b/makima/frontend/src/components/directives/DirectiveDetail.tsx
index 616c5d2..ab6ddbb 100644
--- a/makima/frontend/src/components/directives/DirectiveDetail.tsx
+++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx
@@ -1,6 +1,9 @@
-import { useState } from "react";
-import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api";
+import { useState, useMemo, useEffect, useRef } from "react";
+import type { DirectiveWithSteps, DirectiveStatus, MemoryCategory } from "../../lib/api";
import { DirectiveDAG } from "./DirectiveDAG";
+import { DirectiveLogStream } from "./DirectiveLogStream";
+import { useDirectiveMemories } from "../../hooks/useDirectiveMemories";
+import { useMultiTaskSubscription } from "../../hooks/useMultiTaskSubscription";
const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = {
draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" },
@@ -10,6 +13,16 @@ const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> =
archived: { color: "text-[#556677] border-[#2a3a5a]", label: "ARCHIVED" },
};
+const CATEGORY_COLORS: Record<MemoryCategory, { text: string; border: string; bg: string; label: string }> = {
+ decision: { text: "text-amber-400", border: "border-amber-800", bg: "bg-amber-900/20", label: "Decision" },
+ context: { text: "text-cyan-400", border: "border-cyan-800", bg: "bg-cyan-900/20", label: "Context" },
+ preference: { text: "text-violet-400", border: "border-violet-800", bg: "bg-violet-900/20", label: "Preference" },
+ learning: { text: "text-emerald-400", border: "border-emerald-800", bg: "bg-emerald-900/20", label: "Learning" },
+ other: { text: "text-[#7788aa]", border: "border-[#2a3a5a]", bg: "bg-[#1a2540]", label: "Other" },
+};
+
+const ALL_CATEGORIES: MemoryCategory[] = ["decision", "context", "preference", "learning", "other"];
+
interface DirectiveDetailProps {
directive: DirectiveWithSteps;
onStart: () => void;
@@ -37,12 +50,70 @@ export function DirectiveDetail({
}: DirectiveDetailProps) {
const [editingGoal, setEditingGoal] = useState(false);
const [goalText, setGoalText] = useState(directive.goal);
+ const [visibleTaskIds, setVisibleTaskIds] = useState<Set<string> | null>(null);
+ const [searchQuery, setSearchQuery] = useState("");
+ const [isLogCollapsed, setIsLogCollapsed] = useState(true);
+ const prevHadRunningRef = useRef(false);
const badge = STATUS_BADGE[directive.status] || STATUS_BADGE.draft;
const completedSteps = directive.steps.filter((s) => s.status === "completed").length;
const totalSteps = directive.steps.length;
const progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
+ // Memory panel state
+ const [memoryOpen, setMemoryOpen] = useState(false);
+ const [addingMemory, setAddingMemory] = useState(false);
+ const [newCategory, setNewCategory] = useState<MemoryCategory>("context");
+ const [newContent, setNewContent] = useState("");
+ const [newSource, setNewSource] = useState("");
+ const [confirmClear, setConfirmClear] = useState(false);
+
+ const {
+ grouped,
+ config: memoryConfig,
+ loading: memoryLoading,
+ error: memoryError,
+ toggleEnabled,
+ add: addMemory,
+ remove: removeMemory,
+ clearAll: clearMemories,
+ refresh: refreshMemories,
+ } = useDirectiveMemories(directive.id);
+
+ const memoryEnabled = memoryConfig?.enabled ?? false;
+ const totalMemories = Object.values(grouped).reduce((sum, arr) => sum + arr.length, 0);
+
+ // Build task map from directive steps and orchestrator
+ const taskMap = useMemo(() => {
+ const map = new Map<string, string>();
+ if (directive.orchestratorTaskId) {
+ map.set(directive.orchestratorTaskId, "Orchestrator");
+ }
+ for (const step of directive.steps) {
+ if (step.taskId) {
+ map.set(step.taskId, step.name);
+ }
+ }
+ return map;
+ }, [directive.orchestratorTaskId, directive.steps]);
+
+ // Subscribe to all task outputs
+ const { connected, entries, clearEntries } = useMultiTaskSubscription({
+ taskMap,
+ enabled: taskMap.size > 0,
+ });
+
+ // Auto-expand log panel when tasks start running
+ const hasRunningTasks = directive.steps.some((s) => s.status === "running") ||
+ !!directive.orchestratorTaskId;
+
+ useEffect(() => {
+ if (hasRunningTasks && !prevHadRunningRef.current) {
+ setIsLogCollapsed(false);
+ }
+ prevHadRunningRef.current = hasRunningTasks;
+ }, [hasRunningTasks]);
+
const handleGoalSave = () => {
if (goalText.trim() && goalText !== directive.goal) {
onUpdateGoal(goalText.trim());
@@ -50,6 +121,23 @@ export function DirectiveDetail({
setEditingGoal(false);
};
+ const handleAddMemory = async () => {
+ if (!newContent.trim()) return;
+ await addMemory({
+ category: newCategory,
+ content: newContent.trim(),
+ source: newSource.trim() || undefined,
+ });
+ setNewContent("");
+ setNewSource("");
+ setAddingMemory(false);
+ };
+
+ const handleClearAll = async () => {
+ await clearMemories();
+ setConfirmClear(false);
+ };
+
return (
<div className="flex flex-col h-full overflow-y-auto">
{/* Header */}
@@ -249,6 +337,214 @@ export function DirectiveDetail({
)}
</div>
+ {/* Memory Panel */}
+ <div className="px-4 py-3 border-b border-[rgba(117,170,252,0.1)]">
+ {/* Memory header — always visible */}
+ <div className="flex items-center justify-between">
+ <button
+ type="button"
+ onClick={() => setMemoryOpen((v) => !v)}
+ className="flex items-center gap-1.5 group"
+ >
+ <span className="text-[10px] font-mono text-[#556677] group-hover:text-[#9bc3ff] transition-colors">
+ {memoryOpen ? "\u25BC" : "\u25B6"}
+ </span>
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Memory
+ </span>
+ {totalMemories > 0 && (
+ <span className="text-[9px] font-mono text-[#556677] ml-1">
+ ({totalMemories})
+ </span>
+ )}
+ </button>
+ <div className="flex items-center gap-2">
+ {/* Enable/disable toggle */}
+ <button
+ type="button"
+ onClick={() => toggleEnabled(!memoryEnabled)}
+ className={`text-[9px] font-mono border rounded px-1.5 py-0.5 transition-colors ${
+ memoryEnabled
+ ? "text-emerald-400 border-emerald-800 hover:text-emerald-300"
+ : "text-[#556677] border-[#2a3a5a] hover:text-[#7788aa]"
+ }`}
+ title={memoryEnabled ? "Disable memory" : "Enable memory"}
+ >
+ {memoryEnabled ? "ON" : "OFF"}
+ </button>
+ </div>
+ </div>
+
+ {/* Collapsible content */}
+ {memoryOpen && (
+ <div className="mt-2">
+ {memoryError && (
+ <div className="text-[10px] font-mono text-red-400 mb-2 px-2 py-1 bg-red-900/10 border border-red-800/30 rounded">
+ {memoryError}
+ </div>
+ )}
+
+ {memoryLoading ? (
+ <div className="text-[10px] font-mono text-[#556677] py-2">Loading...</div>
+ ) : totalMemories === 0 ? (
+ <div className="text-[10px] font-mono text-[#556677] py-2">
+ No memory entries yet.
+ {!memoryEnabled && " Enable memory to start capturing entries."}
+ </div>
+ ) : (
+ /* Grouped entries */
+ <div className="flex flex-col gap-2">
+ {ALL_CATEGORIES.map((cat) => {
+ const entries = grouped[cat];
+ if (entries.length === 0) return null;
+ const style = CATEGORY_COLORS[cat];
+ return (
+ <div key={cat}>
+ <div className="flex items-center gap-1.5 mb-1">
+ <span className={`text-[9px] font-mono ${style.text} uppercase tracking-wider`}>
+ {style.label}
+ </span>
+ <span className="text-[9px] font-mono text-[#556677]">
+ ({entries.length})
+ </span>
+ </div>
+ <div className="flex flex-col gap-1">
+ {entries.map((entry) => (
+ <div
+ key={entry.id}
+ className={`flex items-start gap-2 px-2 py-1.5 rounded border ${style.border} ${style.bg}`}
+ >
+ <div className="flex-1 min-w-0">
+ <p className="text-[10px] font-mono text-[#c0d0e0] whitespace-pre-wrap break-words">
+ {entry.content}
+ </p>
+ {entry.source && (
+ <span className="text-[9px] font-mono text-[#556677] mt-0.5 block">
+ src: {entry.source}
+ </span>
+ )}
+ </div>
+ <button
+ type="button"
+ onClick={() => removeMemory(entry.id)}
+ className="text-[9px] font-mono text-[#556677] hover:text-red-400 shrink-0 mt-0.5"
+ title="Delete entry"
+ >
+ x
+ </button>
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+ })}
+ </div>
+ )}
+
+ {/* Action bar: Add + Clear */}
+ <div className="flex items-center gap-2 mt-2 pt-2 border-t border-[rgba(117,170,252,0.1)]">
+ <button
+ type="button"
+ onClick={() => setAddingMemory((v) => !v)}
+ className="text-[10px] font-mono text-[#75aafc] hover:text-white border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5"
+ >
+ {addingMemory ? "Cancel" : "+ Add"}
+ </button>
+ {totalMemories > 0 && (
+ <>
+ {confirmClear ? (
+ <div className="flex items-center gap-1.5 ml-auto">
+ <span className="text-[9px] font-mono text-red-400">Clear all?</span>
+ <button
+ type="button"
+ onClick={handleClearAll}
+ className="text-[9px] font-mono text-red-400 hover:text-red-300 border border-red-800 rounded px-1.5 py-0.5"
+ >
+ Yes
+ </button>
+ <button
+ type="button"
+ onClick={() => setConfirmClear(false)}
+ className="text-[9px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-1.5 py-0.5"
+ >
+ No
+ </button>
+ </div>
+ ) : (
+ <button
+ type="button"
+ onClick={() => setConfirmClear(true)}
+ className="text-[10px] font-mono text-[#556677] hover:text-red-400 ml-auto"
+ >
+ Clear all
+ </button>
+ )}
+ </>
+ )}
+ <button
+ type="button"
+ onClick={refreshMemories}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ title="Refresh memories"
+ >
+ [refresh]
+ </button>
+ </div>
+
+ {/* Add form */}
+ {addingMemory && (
+ <div className="mt-2 p-2 bg-[#0a1628] border border-[rgba(117,170,252,0.15)] rounded flex flex-col gap-2">
+ <div className="flex items-center gap-2">
+ <label className="text-[9px] font-mono text-[#7788aa] shrink-0">Category</label>
+ <select
+ value={newCategory}
+ onChange={(e) => setNewCategory(e.target.value as MemoryCategory)}
+ className="bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-1.5 py-0.5 text-[10px] font-mono text-white flex-1"
+ >
+ {ALL_CATEGORIES.map((c) => (
+ <option key={c} value={c}>
+ {CATEGORY_COLORS[c].label}
+ </option>
+ ))}
+ </select>
+ </div>
+ <textarea
+ value={newContent}
+ onChange={(e) => setNewContent(e.target.value)}
+ placeholder="Memory content..."
+ className="w-full bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1.5 text-[10px] font-mono text-white resize-y min-h-[40px] placeholder:text-[#556677]"
+ rows={2}
+ />
+ <input
+ type="text"
+ value={newSource}
+ onChange={(e) => setNewSource(e.target.value)}
+ placeholder="Source (optional)"
+ className="w-full bg-[#1a2540] border border-[rgba(117,170,252,0.2)] rounded px-2 py-1 text-[10px] font-mono text-white placeholder:text-[#556677]"
+ />
+ <div className="flex gap-1.5">
+ <button
+ type="button"
+ onClick={handleAddMemory}
+ disabled={!newContent.trim()}
+ className="text-[10px] font-mono text-emerald-400 hover:text-emerald-300 border border-emerald-800 rounded px-2 py-0.5 disabled:opacity-40 disabled:cursor-not-allowed"
+ >
+ Save
+ </button>
+ <button
+ type="button"
+ onClick={() => { setAddingMemory(false); setNewContent(""); setNewSource(""); }}
+ className="text-[10px] font-mono text-[#7788aa] hover:text-white border border-[#2a3a5a] rounded px-2 py-0.5"
+ >
+ Cancel
+ </button>
+ </div>
+ </div>
+ )}
+ </div>
+ )}
+ </div>
+
{/* DAG */}
<div className="px-4 py-3 flex-1">
<span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide block mb-2">
@@ -261,6 +557,24 @@ export function DirectiveDetail({
onSkip={onSkipStep}
/>
</div>
+
+ {/* Log Stream */}
+ {taskMap.size > 0 && (
+ <div className="px-4 py-3 border-t border-[rgba(117,170,252,0.1)]">
+ <DirectiveLogStream
+ entries={entries}
+ taskMap={taskMap}
+ connected={connected}
+ visibleTaskIds={visibleTaskIds}
+ searchQuery={searchQuery}
+ isCollapsed={isLogCollapsed}
+ onToggleCollapse={() => setIsLogCollapsed((prev) => !prev)}
+ onSetVisibleTaskIds={setVisibleTaskIds}
+ onSetSearchQuery={setSearchQuery}
+ onClear={clearEntries}
+ />
+ </div>
+ )}
</div>
);
}
diff --git a/makima/frontend/src/components/directives/DirectiveLogStream.tsx b/makima/frontend/src/components/directives/DirectiveLogStream.tsx
new file mode 100644
index 0000000..d457fe3
--- /dev/null
+++ b/makima/frontend/src/components/directives/DirectiveLogStream.tsx
@@ -0,0 +1,367 @@
+import { useRef, useEffect, useState, useCallback } from "react";
+import { SimpleMarkdown } from "../SimpleMarkdown";
+import type { MultiTaskOutputEntry } from "../../hooks/useMultiTaskSubscription";
+
+interface DirectiveLogStreamProps {
+ entries: MultiTaskOutputEntry[];
+ /** Map of taskId -> label for display */
+ taskMap: Map<string, string>;
+ /** Whether the WebSocket is connected */
+ connected: boolean;
+ /** Filter: set of visible task IDs (null = show all) */
+ visibleTaskIds: Set<string> | null;
+ /** Current search query */
+ searchQuery: string;
+ /** Whether the panel is collapsed */
+ isCollapsed: boolean;
+ /** Toggle collapse state */
+ onToggleCollapse: () => void;
+ /** Update visible task filter */
+ onSetVisibleTaskIds: (ids: Set<string> | null) => void;
+ /** Update search query */
+ onSetSearchQuery: (query: string) => void;
+ /** Clear all entries */
+ onClear: () => void;
+}
+
+// Assign stable colors to tasks
+const TASK_COLORS = [
+ "#75aafc", // blue
+ "#4ade80", // green
+ "#f59e0b", // amber
+ "#a78bfa", // violet
+ "#f472b6", // pink
+ "#22d3ee", // cyan
+ "#fb923c", // orange
+ "#34d399", // emerald
+];
+
+function getTaskColor(index: number): string {
+ return TASK_COLORS[index % TASK_COLORS.length];
+}
+
+export function DirectiveLogStream({
+ entries,
+ taskMap,
+ connected,
+ visibleTaskIds,
+ searchQuery,
+ isCollapsed,
+ onToggleCollapse,
+ onSetVisibleTaskIds,
+ onSetSearchQuery,
+ onClear,
+}: DirectiveLogStreamProps) {
+ const containerRef = useRef<HTMLDivElement>(null);
+ const [autoScroll, setAutoScroll] = useState(true);
+ const [showFilters, setShowFilters] = useState(false);
+
+ // Build task color map
+ const taskColorMap = new Map<string, string>();
+ let colorIdx = 0;
+ for (const [taskId] of taskMap) {
+ taskColorMap.set(taskId, getTaskColor(colorIdx++));
+ }
+
+ // Filter entries
+ const filteredEntries = entries.filter((entry) => {
+ // Filter by visible task IDs
+ if (visibleTaskIds && !visibleTaskIds.has(entry.taskId)) return false;
+ // Filter by search query
+ if (searchQuery) {
+ const q = searchQuery.toLowerCase();
+ const matchesContent = entry.content?.toLowerCase().includes(q);
+ const matchesLabel = entry.taskLabel?.toLowerCase().includes(q);
+ const matchesTool = entry.toolName?.toLowerCase().includes(q);
+ if (!matchesContent && !matchesLabel && !matchesTool) return false;
+ }
+ return true;
+ });
+
+ // Handle scroll
+ const handleScroll = useCallback(() => {
+ if (!containerRef.current) return;
+ const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
+ const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
+ setAutoScroll(isAtBottom);
+ }, []);
+
+ // Auto-scroll when entries change
+ useEffect(() => {
+ if (autoScroll && containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }, [filteredEntries.length, autoScroll]);
+
+ // Count active (running) tasks
+ const activeTaskCount = Array.from(taskMap.keys()).length;
+
+ if (isCollapsed) {
+ return (
+ <button
+ type="button"
+ onClick={onToggleCollapse}
+ className="flex items-center gap-2 w-full text-left"
+ >
+ <span className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide">
+ Log Stream
+ </span>
+ <span className="text-[10px] font-mono text-[#556677]">
+ [{activeTaskCount} task{activeTaskCount !== 1 ? "s" : ""}]
+ </span>
+ {connected && entries.length > 0 && (
+ <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/20 rounded">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ <span className="text-[9px] font-mono text-green-400">{entries.length}</span>
+ </span>
+ )}
+ <span className="text-[10px] font-mono text-[#556677] ml-auto">[expand]</span>
+ </button>
+ );
+ }
+
+ return (
+ <div className="flex flex-col" style={{ maxHeight: "400px" }}>
+ {/* Header */}
+ <div className="flex items-center justify-between mb-2">
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={onToggleCollapse}
+ className="text-[10px] font-mono text-[#9bc3ff] uppercase tracking-wide hover:text-white"
+ >
+ Log Stream
+ </button>
+ {connected && (
+ <span className="flex items-center gap-1 px-1.5 py-0.5 bg-green-400/10 border border-green-400/20 rounded">
+ <span className="w-1.5 h-1.5 bg-green-400 rounded-full animate-pulse" />
+ <span className="text-[9px] font-mono text-green-400">Live</span>
+ </span>
+ )}
+ <span className="text-[10px] font-mono text-[#556677]">
+ {filteredEntries.length} entries
+ </span>
+ </div>
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() => setShowFilters(!showFilters)}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [filter]
+ </button>
+ {entries.length > 0 && (
+ <button
+ type="button"
+ onClick={onClear}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [clear]
+ </button>
+ )}
+ {!autoScroll && (
+ <button
+ type="button"
+ onClick={() => {
+ setAutoScroll(true);
+ if (containerRef.current) {
+ containerRef.current.scrollTop = containerRef.current.scrollHeight;
+ }
+ }}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [scroll to bottom]
+ </button>
+ )}
+ <button
+ type="button"
+ onClick={onToggleCollapse}
+ className="text-[9px] font-mono text-[#556677] hover:text-[#75aafc]"
+ >
+ [collapse]
+ </button>
+ </div>
+ </div>
+
+ {/* Filters */}
+ {showFilters && (
+ <div className="flex flex-wrap items-center gap-2 mb-2 pb-2 border-b border-[rgba(117,170,252,0.1)]">
+ {/* Search */}
+ <input
+ type="text"
+ value={searchQuery}
+ onChange={(e) => onSetSearchQuery(e.target.value)}
+ placeholder="Search logs..."
+ className="bg-[#0a1628] border border-[rgba(117,170,252,0.2)] rounded px-2 py-0.5 text-[10px] font-mono text-white w-[160px] placeholder-[#556677]"
+ />
+ {/* Task filter buttons */}
+ <button
+ type="button"
+ onClick={() => onSetVisibleTaskIds(null)}
+ className={`text-[9px] font-mono px-1.5 py-0.5 rounded border ${
+ visibleTaskIds === null
+ ? "text-white border-[#75aafc] bg-[rgba(117,170,252,0.15)]"
+ : "text-[#556677] border-[#2a3a5a] hover:text-white"
+ }`}
+ >
+ All
+ </button>
+ {Array.from(taskMap.entries()).map(([taskId, label]) => {
+ const isVisible = visibleTaskIds === null || visibleTaskIds.has(taskId);
+ const color = taskColorMap.get(taskId) || "#75aafc";
+ return (
+ <button
+ key={taskId}
+ type="button"
+ onClick={() => {
+ if (visibleTaskIds === null) {
+ // Switch from "all" to just this task
+ onSetVisibleTaskIds(new Set([taskId]));
+ } else if (visibleTaskIds.has(taskId)) {
+ const next = new Set(visibleTaskIds);
+ next.delete(taskId);
+ if (next.size === 0) {
+ onSetVisibleTaskIds(null); // back to all
+ } else {
+ onSetVisibleTaskIds(next);
+ }
+ } else {
+ const next = new Set(visibleTaskIds);
+ next.add(taskId);
+ if (next.size === taskMap.size) {
+ onSetVisibleTaskIds(null); // all selected = show all
+ } else {
+ onSetVisibleTaskIds(next);
+ }
+ }
+ }}
+ className={`text-[9px] font-mono px-1.5 py-0.5 rounded border transition-colors ${
+ isVisible
+ ? "border-current bg-[rgba(117,170,252,0.1)]"
+ : "border-[#2a3a5a] opacity-50 hover:opacity-75"
+ }`}
+ style={{ color: isVisible ? color : "#556677" }}
+ >
+ {label}
+ </button>
+ );
+ })}
+ </div>
+ )}
+
+ {/* Log output */}
+ <div
+ ref={containerRef}
+ onScroll={handleScroll}
+ className="flex-1 overflow-auto bg-[#0a0f18] rounded p-2 font-mono text-xs min-h-0"
+ style={{ minHeight: "120px" }}
+ >
+ {filteredEntries.length === 0 ? (
+ <div className="text-[#555] italic text-[10px]">
+ {entries.length === 0
+ ? connected
+ ? "Waiting for output..."
+ : "No tasks subscribed"
+ : "No entries match filter"}
+ </div>
+ ) : (
+ <div className="space-y-1">
+ {filteredEntries.map((entry, idx) => (
+ <LogEntry
+ key={idx}
+ entry={entry}
+ color={taskColorMap.get(entry.taskId) || "#75aafc"}
+ />
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
+
+function LogEntry({
+ entry,
+ color,
+}: {
+ entry: MultiTaskOutputEntry;
+ color: string;
+}) {
+ const [expanded, setExpanded] = useState(false);
+
+ // Skip empty content for tool results
+ if (entry.messageType === "tool_result" && !entry.content) return null;
+
+ return (
+ <div className="flex gap-2 items-start py-0.5">
+ {/* Task label */}
+ <span
+ className="text-[9px] shrink-0 font-semibold uppercase tracking-wide w-[80px] truncate text-right"
+ style={{ color }}
+ title={entry.taskLabel}
+ >
+ {entry.taskLabel}
+ </span>
+ <span className="text-[#2a3a5a] shrink-0">|</span>
+
+ {/* Content */}
+ <div className="flex-1 min-w-0">
+ {entry.messageType === "assistant" && (
+ <SimpleMarkdown content={entry.content} className="text-[#9bc3ff] text-[10px]" />
+ )}
+ {entry.messageType === "tool_use" && (
+ <div className="flex items-center gap-1">
+ <span className="text-yellow-500 text-[10px]">*</span>
+ <span className="text-[#75aafc] text-[10px]">{entry.toolName || "unknown"}</span>
+ {entry.toolInput && Object.keys(entry.toolInput).length > 0 && (
+ <button
+ onClick={() => setExpanded(!expanded)}
+ className="text-[#555] hover:text-[#9bc3ff] text-[9px]"
+ >
+ {expanded ? "[-]" : "[+]"}
+ </button>
+ )}
+ {expanded && entry.toolInput && (
+ <pre className="text-[9px] text-[#555] bg-[#0a1525] p-1 overflow-x-auto mt-0.5 block w-full">
+ {JSON.stringify(entry.toolInput, null, 2)}
+ </pre>
+ )}
+ </div>
+ )}
+ {entry.messageType === "tool_result" && (
+ <div className="text-[10px]">
+ <span className={entry.isError ? "text-red-400" : "text-green-500"}>
+ {entry.isError ? "x" : "+"}
+ </span>{" "}
+ <span className="text-[#555]">
+ {entry.content.split("\n")[0]}
+ {entry.content.includes("\n") && "..."}
+ </span>
+ </div>
+ )}
+ {entry.messageType === "result" && (
+ <div className="text-[10px]">
+ <span className="text-green-500 font-semibold">Done</span>
+ {entry.costUsd !== undefined && (
+ <span className="text-[#555] ml-2">${entry.costUsd.toFixed(4)}</span>
+ )}
+ {entry.durationMs !== undefined && (
+ <span className="text-[#555] ml-2">{(entry.durationMs / 1000).toFixed(1)}s</span>
+ )}
+ </div>
+ )}
+ {entry.messageType === "error" && (
+ <span className="text-red-400 text-[10px]">{entry.content}</span>
+ )}
+ {entry.messageType === "system" && (
+ <span className="text-[#555] text-[9px] uppercase">{entry.content}</span>
+ )}
+ {!["assistant", "tool_use", "tool_result", "result", "error", "system"].includes(
+ entry.messageType
+ ) && (
+ <span className="text-[#555] text-[10px]">{entry.content}</span>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/hooks/useDirectiveMemories.ts b/makima/frontend/src/hooks/useDirectiveMemories.ts
new file mode 100644
index 0000000..3844c44
--- /dev/null
+++ b/makima/frontend/src/hooks/useDirectiveMemories.ts
@@ -0,0 +1,119 @@
+import { useState, useEffect, useCallback } from "react";
+import {
+ type DirectiveMemoryEntry,
+ type DirectiveMemoryConfig,
+ type MemoryCategory,
+ type CreateDirectiveMemoryRequest,
+ getDirectiveMemoryConfig,
+ setDirectiveMemoryEnabled,
+ listDirectiveMemories,
+ addDirectiveMemory,
+ deleteDirectiveMemory,
+ clearDirectiveMemories,
+} from "../lib/api";
+
+export function useDirectiveMemories(directiveId: string | undefined) {
+ const [memories, setMemories] = useState<DirectiveMemoryEntry[]>([]);
+ const [config, setConfig] = useState<DirectiveMemoryConfig | null>(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const refreshConfig = useCallback(async () => {
+ if (!directiveId) return;
+ try {
+ const c = await getDirectiveMemoryConfig(directiveId);
+ setConfig(c);
+ } catch (e) {
+ // Config may not exist yet — treat as disabled
+ setConfig({ directiveId, enabled: false, updatedAt: new Date().toISOString() });
+ }
+ }, [directiveId]);
+
+ const refreshMemories = useCallback(async () => {
+ if (!directiveId) return;
+ try {
+ setLoading(true);
+ setError(null);
+ const entries = await listDirectiveMemories(directiveId);
+ setMemories(entries);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to load memories");
+ } finally {
+ setLoading(false);
+ }
+ }, [directiveId]);
+
+ const refresh = useCallback(async () => {
+ await Promise.all([refreshConfig(), refreshMemories()]);
+ }, [refreshConfig, refreshMemories]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const toggleEnabled = useCallback(async (enabled: boolean) => {
+ if (!directiveId) return;
+ try {
+ setError(null);
+ const c = await setDirectiveMemoryEnabled(directiveId, enabled);
+ setConfig(c);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to toggle memory");
+ }
+ }, [directiveId]);
+
+ const add = useCallback(async (req: CreateDirectiveMemoryRequest) => {
+ if (!directiveId) return;
+ try {
+ setError(null);
+ await addDirectiveMemory(directiveId, req);
+ await refreshMemories();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to add memory");
+ }
+ }, [directiveId, refreshMemories]);
+
+ const remove = useCallback(async (memoryId: string) => {
+ if (!directiveId) return;
+ try {
+ setError(null);
+ await deleteDirectiveMemory(directiveId, memoryId);
+ await refreshMemories();
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete memory");
+ }
+ }, [directiveId, refreshMemories]);
+
+ const clearAll = useCallback(async () => {
+ if (!directiveId) return;
+ try {
+ setError(null);
+ await clearDirectiveMemories(directiveId);
+ setMemories([]);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to clear memories");
+ }
+ }, [directiveId]);
+
+ /** Group entries by category */
+ const grouped = memories.reduce<Record<MemoryCategory, DirectiveMemoryEntry[]>>(
+ (acc, entry) => {
+ acc[entry.category].push(entry);
+ return acc;
+ },
+ { decision: [], context: [], preference: [], learning: [], other: [] },
+ );
+
+ return {
+ memories,
+ grouped,
+ config,
+ loading,
+ error,
+ refresh,
+ toggleEnabled,
+ add,
+ remove,
+ clearAll,
+ };
+}
diff --git a/makima/frontend/src/hooks/useMultiTaskSubscription.ts b/makima/frontend/src/hooks/useMultiTaskSubscription.ts
new file mode 100644
index 0000000..19d6dea
--- /dev/null
+++ b/makima/frontend/src/hooks/useMultiTaskSubscription.ts
@@ -0,0 +1,191 @@
+import { useState, useCallback, useRef, useEffect, useMemo } from "react";
+import { TASK_SUBSCRIBE_ENDPOINT } from "../lib/api";
+import type { TaskOutputEvent } from "./useTaskSubscription";
+
+export interface MultiTaskOutputEntry extends TaskOutputEvent {
+ /** Label for the task (e.g. step name or "Orchestrator") */
+ taskLabel: string;
+ /** Timestamp when the entry was received */
+ receivedAt: number;
+}
+
+interface UseMultiTaskSubscriptionOptions {
+ /** Map of taskId -> label */
+ taskMap: Map<string, string>;
+ /** Whether to actively subscribe */
+ enabled?: boolean;
+ /** Max entries to keep in buffer */
+ maxEntries?: number;
+}
+
+export function useMultiTaskSubscription(options: UseMultiTaskSubscriptionOptions) {
+ const { taskMap, enabled = true, maxEntries = 2000 } = options;
+
+ const [connected, setConnected] = useState(false);
+ const [entries, setEntries] = useState<MultiTaskOutputEntry[]>([]);
+ const wsRef = useRef<WebSocket | null>(null);
+ const reconnectTimeoutRef = useRef<number | null>(null);
+ const subscribedTasksRef = useRef<Set<string>>(new Set());
+ const taskMapRef = useRef(taskMap);
+ const enabledRef = useRef(enabled);
+
+ // Keep refs in sync
+ useEffect(() => {
+ taskMapRef.current = taskMap;
+ }, [taskMap]);
+
+ useEffect(() => {
+ enabledRef.current = enabled;
+ }, [enabled]);
+
+ // Derive task IDs from the map
+ const taskIds = useMemo(() => Array.from(taskMap.keys()), [taskMap]);
+
+ const subscribeToTask = useCallback((ws: WebSocket, taskId: string) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "subscribeOutput", taskId }));
+ subscribedTasksRef.current.add(taskId);
+ }
+ }, []);
+
+ const unsubscribeFromTask = useCallback((ws: WebSocket, taskId: string) => {
+ if (ws.readyState === WebSocket.OPEN) {
+ ws.send(JSON.stringify({ type: "unsubscribeOutput", taskId }));
+ subscribedTasksRef.current.delete(taskId);
+ }
+ }, []);
+
+ const connect = useCallback(() => {
+ const currentState = wsRef.current?.readyState;
+ if (currentState === WebSocket.OPEN || currentState === WebSocket.CONNECTING) {
+ return;
+ }
+
+ if (wsRef.current && currentState === WebSocket.CLOSING) {
+ wsRef.current = null;
+ }
+
+ try {
+ const ws = new WebSocket(TASK_SUBSCRIBE_ENDPOINT);
+ wsRef.current = ws;
+
+ ws.onopen = () => {
+ setConnected(true);
+ // Re-subscribe to all tasks
+ for (const taskId of subscribedTasksRef.current) {
+ ws.send(JSON.stringify({ type: "subscribeOutput", taskId }));
+ }
+ };
+
+ ws.onmessage = (event) => {
+ try {
+ const message = JSON.parse(event.data);
+
+ if (message.type === "taskOutput") {
+ const label = taskMapRef.current.get(message.taskId) || message.taskId;
+ const entry: MultiTaskOutputEntry = {
+ taskId: message.taskId,
+ messageType: message.messageType,
+ content: message.content,
+ toolName: message.toolName,
+ toolInput: message.toolInput,
+ isError: message.isError,
+ costUsd: message.costUsd,
+ durationMs: message.durationMs,
+ isPartial: message.isPartial,
+ taskLabel: label,
+ receivedAt: Date.now(),
+ };
+
+ setEntries((prev) => {
+ const next = [...prev, entry];
+ if (next.length > maxEntries) {
+ return next.slice(next.length - maxEntries);
+ }
+ return next;
+ });
+ }
+ } catch (e) {
+ console.error("Failed to parse multi-task subscription message:", e);
+ }
+ };
+
+ ws.onerror = () => {
+ console.error("Multi-task WebSocket connection error");
+ };
+
+ ws.onclose = () => {
+ setConnected(false);
+ wsRef.current = null;
+
+ // Reconnect if we still have subscriptions
+ if (subscribedTasksRef.current.size > 0 && enabledRef.current) {
+ reconnectTimeoutRef.current = window.setTimeout(() => {
+ connect();
+ }, 3000);
+ }
+ };
+ } catch (e) {
+ console.error("Failed to connect multi-task subscription:", e);
+ }
+ }, [maxEntries]);
+
+ // Manage subscriptions when task IDs change
+ useEffect(() => {
+ if (!enabled || taskIds.length === 0) {
+ // Close connection if no tasks
+ if (wsRef.current) {
+ subscribedTasksRef.current.clear();
+ wsRef.current.close();
+ wsRef.current = null;
+ }
+ return;
+ }
+
+ const newTaskIds = new Set(taskIds);
+ const ws = wsRef.current;
+
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ // Set desired subscriptions and connect
+ subscribedTasksRef.current = newTaskIds;
+ connect();
+ return;
+ }
+
+ // Unsubscribe from removed tasks
+ for (const existingId of subscribedTasksRef.current) {
+ if (!newTaskIds.has(existingId)) {
+ unsubscribeFromTask(ws, existingId);
+ }
+ }
+
+ // Subscribe to new tasks
+ for (const newId of newTaskIds) {
+ if (!subscribedTasksRef.current.has(newId)) {
+ subscribeToTask(ws, newId);
+ }
+ }
+ }, [taskIds, enabled, connect, subscribeToTask, unsubscribeFromTask]);
+
+ // Cleanup on unmount
+ useEffect(() => {
+ return () => {
+ if (reconnectTimeoutRef.current) {
+ clearTimeout(reconnectTimeoutRef.current);
+ }
+ if (wsRef.current) {
+ wsRef.current.close();
+ }
+ };
+ }, []);
+
+ const clearEntries = useCallback(() => {
+ setEntries([]);
+ }, []);
+
+ return {
+ connected,
+ entries,
+ clearEntries,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index 40e160e..552829a 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -3023,6 +3023,8 @@ export interface Directive {
prUrl: string | null;
prBranch: string | null;
completionTaskId: string | null;
+ /** Whether the memory system is enabled for this directive */
+ memoryEnabled: boolean;
goalUpdatedAt: string;
startedAt: string | null;
version: number;
@@ -3060,6 +3062,8 @@ export interface DirectiveSummary {
orchestratorTaskId: string | null;
prUrl: string | null;
completionTaskId: string | null;
+ /** Whether the memory system is enabled for this directive */
+ memoryEnabled: boolean;
version: number;
createdAt: string;
updatedAt: string;
@@ -3080,6 +3084,8 @@ export interface CreateDirectiveRequest {
repositoryUrl?: string;
localPath?: string;
baseBranch?: string;
+ /** Enable the memory system for this directive (default: false) */
+ memoryEnabled?: boolean;
}
export interface UpdateDirectiveRequest {
@@ -3090,6 +3096,8 @@ export interface UpdateDirectiveRequest {
localPath?: string;
baseBranch?: string;
orchestratorTaskId?: string;
+ /** Enable or disable the memory system for this directive */
+ memoryEnabled?: boolean;
version?: number;
}
@@ -3230,4 +3238,179 @@ export async function updateDirectiveGoal(id: string, goal: string): Promise<Dir
return res.json();
}
+// =============================================================================
+// Directive Memory Types & API
+// =============================================================================
+
+/** Category of a directive memory entry */
+export type MemoryCategory =
+ | "decision"
+ | "learning"
+ | "context"
+ | "preference"
+ | "issue"
+ | "progress"
+ | "other";
+
+/** A single memory entry associated with a directive */
+export interface DirectiveMemory {
+ id: string;
+ directiveId: string;
+ /** The memory content text */
+ content: string;
+ /** Category for organizing memories */
+ category: MemoryCategory;
+ /** Which step created this memory (null if directive-level) */
+ stepId: string | null;
+ /** Which task created this memory (null if manually added) */
+ taskId: string | null;
+ /** Importance score (1-10, higher = more important) */
+ importance: number;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/** Response from listing directive memories */
+export interface DirectiveMemoryListResponse {
+ memories: DirectiveMemory[];
+ total: number;
+}
+
+/** Request to create a new directive memory */
+export interface CreateDirectiveMemoryRequest {
+ content: string;
+ category?: MemoryCategory;
+ stepId?: string;
+ taskId?: string;
+ importance?: number;
+}
+
+/** Request to update a directive memory */
+export interface UpdateDirectiveMemoryRequest {
+ content?: string;
+ category?: MemoryCategory;
+ importance?: number;
+}
+// Directive Memory API functions
+
+/**
+ * List all memories for a directive.
+ * Optionally filter by category or step.
+ */
+export async function listDirectiveMemories(
+ directiveId: string,
+ params?: { category?: MemoryCategory; stepId?: string }
+): Promise<DirectiveMemoryListResponse> {
+ const searchParams = new URLSearchParams();
+ if (params?.category) searchParams.set("category", params.category);
+ if (params?.stepId) searchParams.set("stepId", params.stepId);
+ const query = searchParams.toString();
+ const url = `${API_BASE}/api/v1/directives/${directiveId}/memories${query ? `?${query}` : ""}`;
+ const res = await authFetch(url);
+ if (!res.ok) throw new Error(`Failed to list directive memories: ${res.statusText}`);
+ return res.json();
+}
+
+/**
+ * Get a single memory entry by ID.
+ */
+export async function getDirectiveMemory(
+ directiveId: string,
+ memoryId: string
+): Promise<DirectiveMemory> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/directives/${directiveId}/memories/${memoryId}`
+ );
+ if (!res.ok) throw new Error(`Failed to get directive memory: ${res.statusText}`);
+ return res.json();
+}
+
+/**
+ * Create a new memory entry for a directive.
+ */
+export async function createDirectiveMemory(
+ directiveId: string,
+ req: CreateDirectiveMemoryRequest
+): Promise<DirectiveMemory> {
+ const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memories`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ });
+ if (!res.ok) throw new Error(`Failed to create directive memory: ${res.statusText}`);
+ return res.json();
+}
+
+/**
+ * Update an existing memory entry.
+ */
+export async function updateDirectiveMemory(
+ directiveId: string,
+ memoryId: string,
+ req: UpdateDirectiveMemoryRequest
+): Promise<DirectiveMemory> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/directives/${directiveId}/memories/${memoryId}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(req),
+ }
+ );
+ if (!res.ok) throw new Error(`Failed to update directive memory: ${res.statusText}`);
+ return res.json();
+}
+
+/**
+ * Delete a memory entry.
+ */
+export async function deleteDirectiveMemory(
+ directiveId: string,
+ memoryId: string
+): Promise<void> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/directives/${directiveId}/memories/${memoryId}`,
+ { method: "DELETE" }
+ );
+ if (!res.ok) throw new Error(`Failed to delete directive memory: ${res.statusText}`);
+}
+
+/**
+ * Batch create multiple memory entries for a directive.
+ * Useful when a task completes and wants to store multiple learnings at once.
+ */
+export async function batchCreateDirectiveMemories(
+ directiveId: string,
+ memories: CreateDirectiveMemoryRequest[]
+): Promise<DirectiveMemory[]> {
+ const res = await authFetch(
+ `${API_BASE}/api/v1/directives/${directiveId}/memories/batch`,
+ {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(memories),
+ }
+ );
+ if (!res.ok) throw new Error(`Failed to batch create directive memories: ${res.statusText}`);
+ return res.json();
+}
+
+/**
+ * Get a formatted memory context string for a directive.
+ * This returns memories formatted for injection into task prompts.
+ * Optionally filter by category or limit the number of memories returned.
+ */
+export async function getDirectiveMemoryContext(
+ directiveId: string,
+ params?: { category?: MemoryCategory; limit?: number }
+): Promise<{ context: string; memoryCount: number }> {
+ const searchParams = new URLSearchParams();
+ if (params?.category) searchParams.set("category", params.category);
+ if (params?.limit) searchParams.set("limit", params.limit.toString());
+ const query = searchParams.toString();
+ const url = `${API_BASE}/api/v1/directives/${directiveId}/memories/context${query ? `?${query}` : ""}`;
+ const res = await authFetch(url);
+ if (!res.ok) throw new Error(`Failed to get directive memory context: ${res.statusText}`);
+ return res.json();
+}
diff --git a/makima/migrations/20260211000000_add_directive_memories.sql b/makima/migrations/20260211000000_add_directive_memories.sql
new file mode 100644
index 0000000..69d00bf
--- /dev/null
+++ b/makima/migrations/20260211000000_add_directive_memories.sql
@@ -0,0 +1,20 @@
+-- Directive memory system: optional key-value storage scoped to a directive.
+-- Allows directives to persist learnings, decisions, and context across steps.
+
+-- Add memory_enabled flag to directives
+ALTER TABLE directives ADD COLUMN memory_enabled BOOLEAN NOT NULL DEFAULT false;
+
+-- Key-value memory entries scoped to a directive
+CREATE TABLE directive_memories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE,
+ key VARCHAR(255) NOT NULL,
+ value TEXT NOT NULL,
+ category VARCHAR(100),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE(directive_id, key)
+);
+
+CREATE INDEX idx_directive_memories_directive_id ON directive_memories(directive_id);
+CREATE INDEX idx_directive_memories_category ON directive_memories(directive_id, category);
diff --git a/makima/migrations/20260211000000_create_directive_memories.sql b/makima/migrations/20260211000000_create_directive_memories.sql
new file mode 100644
index 0000000..5aae339
--- /dev/null
+++ b/makima/migrations/20260211000000_create_directive_memories.sql
@@ -0,0 +1,16 @@
+-- Directive memory system: persistent key-value storage for directives.
+-- Allows directives to store and retrieve context across sessions.
+
+CREATE TABLE directive_memories (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ directive_id UUID NOT NULL REFERENCES directives(id) ON DELETE CASCADE,
+ key VARCHAR(255) NOT NULL,
+ value TEXT NOT NULL,
+ category VARCHAR(100),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (directive_id, key)
+);
+
+CREATE INDEX idx_directive_memories_directive_id ON directive_memories(directive_id);
+CREATE INDEX idx_directive_memories_category ON directive_memories(directive_id, category);
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,