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 | |
| 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
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, |
