diff options
| author | soryu <soryu@soryu.co> | 2026-02-10 23:41:33 +0000 |
|---|---|---|
| committer | soryu <soryu@soryu.co> | 2026-02-10 23:41:33 +0000 |
| commit | 48cb5f2f9917fbf70ed9f342e2bbb52c7200f16d (patch) | |
| tree | 045474029d9cf3075d69a9c381efd792a4b8b67c | |
| parent | 339c1769379a851c4126021132573bd4b7994cf2 (diff) | |
| download | soryu-makima/makima--add-an-optional-memory-system-for-directiv-c8298c6c.tar.gz soryu-makima/makima--add-an-optional-memory-system-for-directiv-c8298c6c.zip | |
feat: makima: Add an optional memory system for directives: Add memory panel to frontend DirectiveDetail componentmakima/makima--add-an-optional-memory-system-for-directiv-c8298c6c
| -rw-r--r-- | makima/frontend/package-lock.json | 15 | ||||
| -rw-r--r-- | makima/frontend/src/components/directives/DirectiveDetail.tsx | 261 | ||||
| -rw-r--r-- | makima/frontend/src/hooks/useDirectiveMemories.ts | 119 | ||||
| -rw-r--r-- | makima/frontend/src/lib/api.ts | 67 |
4 files changed, 447 insertions, 15 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 1340482..e9e739b 100644 --- a/makima/frontend/src/components/directives/DirectiveDetail.tsx +++ b/makima/frontend/src/components/directives/DirectiveDetail.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; -import type { DirectiveWithSteps, DirectiveStatus } from "../../lib/api"; +import type { DirectiveWithSteps, DirectiveStatus, MemoryCategory } from "../../lib/api"; import { DirectiveDAG } from "./DirectiveDAG"; +import { useDirectiveMemories } from "../../hooks/useDirectiveMemories"; const STATUS_BADGE: Record<DirectiveStatus, { color: string; label: string }> = { draft: { color: "text-[#7788aa] border-[#2a3a5a]", label: "DRAFT" }, @@ -10,6 +11,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; @@ -43,6 +54,29 @@ export function DirectiveDetail({ 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); + const handleGoalSave = () => { if (goalText.trim() && goalText !== directive.goal) { onUpdateGoal(goalText.trim()); @@ -50,6 +84,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 */} @@ -215,6 +266,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"> 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/lib/api.ts b/makima/frontend/src/lib/api.ts index b1422df..e6868fc 100644 --- a/makima/frontend/src/lib/api.ts +++ b/makima/frontend/src/lib/api.ts @@ -3225,4 +3225,71 @@ export async function updateDirectiveGoal(id: string, goal: string): Promise<Dir return res.json(); } +// --- Directive Memory --- + +export type MemoryCategory = "decision" | "context" | "preference" | "learning" | "other"; + +export interface DirectiveMemoryEntry { + id: string; + directiveId: string; + category: MemoryCategory; + content: string; + source: string | null; + createdAt: string; +} + +export interface DirectiveMemoryConfig { + directiveId: string; + enabled: boolean; + updatedAt: string; +} + +export interface CreateDirectiveMemoryRequest { + category: MemoryCategory; + content: string; + source?: string; +} + +export async function getDirectiveMemoryConfig(directiveId: string): Promise<DirectiveMemoryConfig> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memory/config`); + if (!res.ok) throw new Error(`Failed to get memory config: ${res.statusText}`); + return res.json(); +} + +export async function setDirectiveMemoryEnabled(directiveId: string, enabled: boolean): Promise<DirectiveMemoryConfig> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memory/config`, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + if (!res.ok) throw new Error(`Failed to update memory config: ${res.statusText}`); + return res.json(); +} + +export async function listDirectiveMemories(directiveId: string): Promise<DirectiveMemoryEntry[]> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memory`); + if (!res.ok) throw new Error(`Failed to list memories: ${res.statusText}`); + return res.json(); +} + +export async function addDirectiveMemory(directiveId: string, req: CreateDirectiveMemoryRequest): Promise<DirectiveMemoryEntry> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memory`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(req), + }); + if (!res.ok) throw new Error(`Failed to add memory: ${res.statusText}`); + return res.json(); +} + +export async function deleteDirectiveMemory(directiveId: string, memoryId: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memory/${memoryId}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to delete memory: ${res.statusText}`); +} + +export async function clearDirectiveMemories(directiveId: string): Promise<void> { + const res = await authFetch(`${API_BASE}/api/v1/directives/${directiveId}/memory`, { method: "DELETE" }); + if (!res.ok) throw new Error(`Failed to clear memories: ${res.statusText}`); +} + |
