import { useEffect, useState, useCallback } from "react"; import { API_BASE } from "./config"; const DEFAULT_SECTIONS = ["Instructions", "Identity", "Career", "Projects", "Preferences"]; function groupBySection(items) { const map = {}; for (const item of items) { const sec = item.section || "General"; if (!map[sec]) map[sec] = []; map[sec].push(item); } return map; } function indentLevelOf(text) { const m = text.match(/^( *)/); return Math.floor((m ? m[0].length : 0) / 2); } // Collapse a section's items into top-level "groups": each group is a parent // (indent 0) plus its consecutive indented children. function computeGroups(sectionItems) { const groups = []; const groupIndexById = {}; for (const item of sectionItems) { const lvl = indentLevelOf(item.text); if (lvl === 0 || groups.length === 0) { groups.push([item]); } else { groups[groups.length - 1].push(item); } groupIndexById[item.id] = groups.length - 1; } return { groups, groupIndexById }; } export function Memory() { const [items, setItems] = useState([]); const [editing, setEditing] = useState(null); // { id, section, text } or null const [adding, setAdding] = useState(null); // { section } for inline new-item form const [newSection, setNewSection] = useState(""); const [showNewSection, setShowNewSection] = useState(false); const [message, setMessage] = useState(""); const [draggingId, setDraggingId] = useState(null); const [dragOver, setDragOver] = useState(null); // { id, position: "before"|"after" } const loadItems = useCallback(async () => { try { const res = await fetch(`${API_BASE}/memory`); const data = res.ok ? await res.json() : { items: [] }; setItems(data.items || []); } catch { setItems([]); } }, []); useEffect(() => { loadItems(); }, [loadItems]); const flash = (msg) => { setMessage(msg); setTimeout(() => setMessage(""), 3000); }; const saveNew = async (section, text) => { // Split on newlines so a pasted list becomes one memory per line (leading // spaces kept for nesting). Direct write — no LLM curator, saves every line. const lines = text.split("\n").map(l => l.replace(/\s+$/, "")).filter(l => l.trim()); if (!lines.length) return; let ok = 0; for (const line of lines) { const res = await fetch(`${API_BASE}/memory`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ section, text: line }), }); if (res.ok) ok++; } if (ok) { loadItems(); setAdding(null); flash(ok === 1 ? "Saved." : `Saved ${ok} items.`); } else flash("Save failed."); }; const saveEdit = async (id, section, text) => { const cleaned = text.replace(/\s+$/, ""); if (!cleaned.trim()) return; const res = await fetch(`${API_BASE}/memory/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ section, text: cleaned }), }); if (res.ok) { loadItems(); setEditing(null); flash("Updated."); } else flash("Update failed."); }; const deleteItem = async (id) => { if (!window.confirm("Delete this item?")) return; const res = await fetch(`${API_BASE}/memory/${id}`, { method: "DELETE" }); if (res.ok) { loadItems(); flash("Deleted."); } else flash("Delete failed."); }; const reorderSection = async (section, ids) => { try { const res = await fetch(`${API_BASE}/memory/reorder`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ section, ids }), }); const result = await res.json(); if (!res.ok || result.ok === false) { throw new Error("Memory reorder failed"); } loadItems(); } catch (err) { setMessage(err.message); } }; const moveItem = (id, direction) => { const item = items.find(i => i.id === id); if (!item) return; const section = item.section || "General"; const sectionItems = items.filter(i => (i.section || "General") === section); const { groups, groupIndexById } = computeGroups(sectionItems); const lvl = indentLevelOf(item.text); if (lvl === 0) { // Swap whole group with previous/next group const gIdx = groupIndexById[id]; const tIdx = direction === "up" ? gIdx - 1 : gIdx + 1; if (tIdx < 0 || tIdx >= groups.length) return; const newGroups = [...groups]; [newGroups[gIdx], newGroups[tIdx]] = [newGroups[tIdx], newGroups[gIdx]]; reorderSection(section, newGroups.flat().map(i => i.id)); } else { // Swap child with previous/next sibling within same group; never past the parent const gIdx = groupIndexById[id]; const group = groups[gIdx]; const localIdx = group.findIndex(i => i.id === id); const tLocal = direction === "up" ? localIdx - 1 : localIdx + 1; if (tLocal <= 0 || tLocal >= group.length) return; // tLocal === 0 is the parent const newGroup = [...group]; [newGroup[localIdx], newGroup[tLocal]] = [newGroup[tLocal], newGroup[localIdx]]; const newGroups = [...groups]; newGroups[gIdx] = newGroup; reorderSection(section, newGroups.flat().map(i => i.id)); } }; const onItemDragStart = (e, item) => { setDraggingId(item.id); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", item.id); } catch { /* some browsers restrict */ } }; const onItemDragOver = (e, targetItem, section) => { if (!draggingId || draggingId === targetItem.id) return; const dragged = items.find(i => i.id === draggingId); if (!dragged || (dragged.section || "General") !== section) return; const sectionItems = items.filter(i => (i.section || "General") === section); const { groups, groupIndexById } = computeGroups(sectionItems); const draggedLevel = indentLevelOf(dragged.text); const targetGroupIdx = groupIndexById[targetItem.id]; const draggedGroupIdx = groupIndexById[dragged.id]; const rect = e.currentTarget.getBoundingClientRect(); const inUpperHalf = e.clientY < rect.top + rect.height / 2; if (draggedLevel === 0) { // Top-level drag: snap to group boundaries; ignore hover over own group if (targetGroupIdx === draggedGroupIdx) { if (dragOver) setDragOver(null); return; } e.preventDefault(); e.dataTransfer.dropEffect = "move"; const targetGroup = groups[targetGroupIdx]; const isTargetParent = targetItem.id === targetGroup[0].id; const position = (isTargetParent && inUpperHalf) ? "before" : "after"; if (dragOver?.kind !== "group" || dragOver?.section !== section || dragOver?.groupIdx !== targetGroupIdx || dragOver?.position !== position) { setDragOver({ kind: "group", section, groupIdx: targetGroupIdx, position }); } } else { // Child drag: confined to its own parent group if (targetGroupIdx !== draggedGroupIdx) { if (dragOver) setDragOver(null); return; } e.preventDefault(); e.dataTransfer.dropEffect = "move"; const targetGroup = groups[targetGroupIdx]; const isTargetParent = targetItem.id === targetGroup[0].id; let position = inUpperHalf ? "before" : "after"; if (isTargetParent) position = "after"; // child can't go before its parent if (dragOver?.kind !== "item" || dragOver?.section !== section || dragOver?.id !== targetItem.id || dragOver?.position !== position) { setDragOver({ kind: "item", section, id: targetItem.id, position }); } } }; const onItemDrop = (e, targetItem, section) => { e.preventDefault(); const dragged = items.find(i => i.id === draggingId); if (!dragged || dragged.id === targetItem.id || (dragged.section || "General") !== section || !dragOver) { setDraggingId(null); setDragOver(null); return; } const sectionItems = items.filter(i => (i.section || "General") === section); const { groups, groupIndexById } = computeGroups(sectionItems); const draggedLevel = indentLevelOf(dragged.text); let block, anchorId, after; if (draggedLevel === 0 && dragOver.kind === "group") { block = groups[groupIndexById[dragged.id]]; const targetGroup = groups[dragOver.groupIdx]; anchorId = dragOver.position === "before" ? targetGroup[0].id : targetGroup[targetGroup.length - 1].id; after = dragOver.position === "after"; } else if (draggedLevel > 0 && dragOver.kind === "item") { block = [dragged]; anchorId = dragOver.id; after = dragOver.position === "after"; } else { setDraggingId(null); setDragOver(null); return; } const blockIds = new Set(block.map(b => b.id)); const remainder = sectionItems.filter(i => !blockIds.has(i.id)); let insertIdx = remainder.findIndex(i => i.id === anchorId); if (insertIdx < 0) { setDraggingId(null); setDragOver(null); return; } if (after) insertIdx += 1; const newOrder = [...remainder.slice(0, insertIdx), ...block, ...remainder.slice(insertIdx)]; setDraggingId(null); setDragOver(null); reorderSection(section, newOrder.map(i => i.id)); }; const onItemDragEnd = () => { setDraggingId(null); setDragOver(null); }; const addSection = () => { const sec = newSection.trim(); if (!sec) return; // Create a placeholder item to establish the section, then let user fill it setAdding({ section: sec }); setNewSection(""); setShowNewSection(false); }; const grouped = groupBySection(items); const sections = Object.keys(grouped); const inputStyle = { background: "#1a1a1a", color: "#eee", border: "1px solid #444", borderRadius: "6px", padding: "0.4rem 0.6rem", fontSize: "0.9rem", width: "100%", boxSizing: "border-box", }; const btnStyle = (color = "#007acc") => ({ padding: "0.3rem 0.7rem", background: color, color: "#fff", border: "none", borderRadius: "6px", cursor: "pointer", fontSize: "0.8rem", }); return (
Facts injected into every conversation. Format mirrors your claude.md.