mop-agent 0.1.14 โ†’ 0.1.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -5,17 +5,25 @@ through MOP-FLOW. It stores project memory, performs semantic recall and
5
5
  consolidation, serves grounded chat, and can request approved actions from a
6
6
  linked FLOW node.
7
7
 
8
- > **Release status:** release candidate `mop-agent@0.1.14` contains the corrected VPS
9
- > installer, one-time Admin setup/login flow, and simplified shared application shell
10
- > with centered page titles and ChatGPT-inspired navigation.
8
+ > **Release status:** release candidate `mop-agent@0.1.16` contains the corrected VPS
9
+ > installer, one-time Admin setup/login flow, rich Assistant composer, Main Brain
10
+ > workspace, Obsidian-inspired Graph View, and encrypted Apps settings.
11
11
  > The canonical installation command is exactly `npx mop-agent`.
12
12
 
13
13
  ## Current status
14
14
 
15
15
  The application core through Fasa 7 foundation is implemented: reverse-WSS
16
16
  project links, SQLite + sqlite-vec storage, Better Auth, semantic recall,
17
- admin-only provider/user settings, consolidation, approval-based write-back,
18
- Telegram and Discord adapters, skills, graph UI, execution backends, and user invites.
17
+ admin-only provider/user/app settings, consolidation, approval-based write-back,
18
+ Telegram and Discord adapters, skills, graph UI, execution backends, and user accounts.
19
+
20
+ The Assistant supports an autosizing prompt, image attachment/preview, voice input
21
+ (when the browser exposes Web Speech), and focused tool modes. Anthropic and
22
+ OpenRouter receive attached images as multimodal input. Brain treats Main Brain as
23
+ the primary knowledge layer and provides an interactive, searchable Graph View.
24
+ Telegram and Discord credentials can be stored encrypted under **Settings โ†’ Apps**;
25
+ their adapters become active after the service restarts. WhatsApp, Slack, and generic
26
+ webhook configuration can be stored there now while their runtime adapters remain planned.
19
27
 
20
28
  The npm bootstrap stages the packaged application durably at `/opt/mop-agent`,
21
29
  uses the proven SQLite + sqlite-vec backend, and asks for sudo only for specific
@@ -0,0 +1,23 @@
1
+ import { requireRole } from "@/lib/authz";
2
+ import { listAppConfigs, saveAppConfig, type AppId } from "@/lib/channels/config";
3
+
4
+ const APP_IDS = new Set<AppId>(["telegram", "discord", "whatsapp", "slack", "webhook"]);
5
+
6
+ export async function GET(req: Request): Promise<Response> {
7
+ const auth = await requireRole(req, ["owner"]);
8
+ if (!auth.ok) return auth.response;
9
+ return Response.json({ apps: listAppConfigs(auth.userId) });
10
+ }
11
+
12
+ export async function POST(req: Request): Promise<Response> {
13
+ const auth = await requireRole(req, ["owner"]);
14
+ if (!auth.ok) return auth.response;
15
+ const body = (await req.json()) as { appId?: AppId; secret?: string; fields?: Record<string, string>; enabled?: boolean };
16
+ if (!body.appId || !APP_IDS.has(body.appId)) return Response.json({ error: "unknown_app" }, { status: 400 });
17
+ try {
18
+ saveAppConfig(auth.userId, { appId: body.appId, secret: body.secret, fields: body.fields, enabled: body.enabled !== false });
19
+ return Response.json({ apps: listAppConfigs(auth.userId), restartRequired: true });
20
+ } catch (error) {
21
+ return Response.json({ error: error instanceof Error ? error.message : "save_failed" }, { status: 400 });
22
+ }
23
+ }
@@ -6,23 +6,44 @@
6
6
  import { auth } from "@/lib/auth";
7
7
  import { recall } from "@/lib/brain/broker";
8
8
  import { resolveProvider } from "@/lib/providers";
9
+ import type { ChatImage } from "@/lib/providers/types";
10
+
11
+ type ChatTool = "image" | "web" | "code" | "research" | "think";
12
+
13
+ const TOOL_INSTRUCTIONS: Record<ChatTool, string> = {
14
+ image: "The user selected image creation. Produce a precise image-generation brief or image-oriented response.",
15
+ web: "The user selected web search. Clearly separate recalled knowledge from facts that require a live web source; never invent browsing results.",
16
+ code: "The user selected writing/code. Give implementation-ready, technically precise output.",
17
+ research: "The user selected deep research. Analyze methodically, compare alternatives, and state uncertainties.",
18
+ think: "The user selected extended thinking. Check assumptions carefully before answering.",
19
+ };
9
20
 
10
21
  export async function POST(req: Request): Promise<Response> {
11
22
  const session = await auth.api.getSession({ headers: req.headers });
12
23
  if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
13
24
 
14
- const { projectId, message, allowCrossProject } = (await req.json()) as {
25
+ const { projectId, message, allowCrossProject, tool, image } = (await req.json()) as {
15
26
  projectId?: string;
16
27
  message: string;
17
28
  allowCrossProject?: boolean;
29
+ tool?: ChatTool | null;
30
+ image?: ChatImage | null;
18
31
  };
19
- if (typeof message !== "string" || !message.trim()) {
32
+ if ((typeof message !== "string" || !message.trim()) && !image) {
20
33
  return Response.json({ error: "missing_message" }, { status: 400 });
21
34
  }
35
+ if (tool && !Object.hasOwn(TOOL_INSTRUCTIONS, tool)) {
36
+ return Response.json({ error: "unknown_tool" }, { status: 400 });
37
+ }
38
+ if (image && (!image.dataUrl?.startsWith("data:image/") || image.dataUrl.length > 7_000_000)) {
39
+ return Response.json({ error: "invalid_or_oversized_image" }, { status: 400 });
40
+ }
41
+
42
+ const userMessage = message.trim() || "Help me understand this attached image.";
22
43
 
23
44
  const centralAssistant = !projectId;
24
45
  const pack = await recall({
25
- query: message.trim(),
46
+ query: userMessage,
26
47
  projectId,
27
48
  // The main assistant is the authenticated, cross-project surface.
28
49
  allowCrossProject: centralAssistant || !!allowCrossProject,
@@ -34,6 +55,7 @@ export async function POST(req: Request): Promise<Response> {
34
55
  centralAssistant
35
56
  ? "Help the user directly. Use the available cross-project memory when relevant; a linked project is not required."
36
57
  : "Help the user with the selected project and use its memory when relevant.",
58
+ tool ? TOOL_INSTRUCTIONS[tool] : "",
37
59
  "",
38
60
  pack.toPromptString(),
39
61
  ].join("\n");
@@ -42,7 +64,7 @@ export async function POST(req: Request): Promise<Response> {
42
64
  const stream = new ReadableStream<Uint8Array>({
43
65
  async start(controller) {
44
66
  try {
45
- for await (const delta of provider.chat({ system, messages: [{ role: "user", content: message.trim() }] })) {
67
+ for await (const delta of provider.chat({ system, messages: [{ role: "user", content: userMessage, image: image ?? undefined }] })) {
46
68
  controller.enqueue(encoder.encode(delta));
47
69
  }
48
70
  } catch (e) {
@@ -8,25 +8,30 @@ import { listProjects } from "@/lib/link/store";
8
8
  import { listSemanticNotes } from "@/lib/brain/consolidate";
9
9
  import { listSkills } from "@/lib/brain/skills";
10
10
 
11
- export type GraphNode = { id: string; label: string; type: "project" | "pattern" | "skill" };
11
+ export type GraphNode = { id: string; label: string; type: "main" | "project" | "pattern" | "skill" };
12
12
  export type GraphEdge = { from: string; to: string };
13
13
 
14
14
  export async function GET(req: Request): Promise<Response> {
15
15
  const session = await auth.api.getSession({ headers: req.headers });
16
16
  if (!session) return Response.json({ error: "unauthorized" }, { status: 401 });
17
17
 
18
- const nodes: GraphNode[] = [];
18
+ const nodes: GraphNode[] = [{ id: "main-brain", label: "Main Brain", type: "main" }];
19
19
  const edges: GraphEdge[] = [];
20
20
 
21
- for (const p of listProjects()) nodes.push({ id: `project:${p.id}`, label: p.name, type: "project" });
21
+ for (const p of listProjects()) {
22
+ nodes.push({ id: `project:${p.id}`, label: p.name, type: "project" });
23
+ edges.push({ from: "main-brain", to: `project:${p.id}` });
24
+ }
22
25
 
23
26
  for (const n of listSemanticNotes()) {
24
27
  nodes.push({ id: `pattern:${n.id}`, label: n.title, type: "pattern" });
28
+ edges.push({ from: "main-brain", to: `pattern:${n.id}` });
25
29
  for (const pid of n.sourceProjects ?? []) edges.push({ from: `pattern:${n.id}`, to: `project:${pid}` });
26
30
  }
27
31
 
28
32
  for (const s of listSkills()) {
29
33
  nodes.push({ id: `skill:${s.id}`, label: s.name, type: "skill" });
34
+ edges.push({ from: "main-brain", to: `skill:${s.id}` });
30
35
  for (const pid of s.sourceProjects ?? []) edges.push({ from: `skill:${s.id}`, to: `project:${pid}` });
31
36
  }
32
37
 
@@ -3,6 +3,7 @@
3
3
  import type { CSSProperties } from "react";
4
4
  import { useEffect, useRef, useState } from "react";
5
5
  import { useMemoryCore } from "@/components/AppShell";
6
+ import { PromptBox, type PromptSubmit } from "@/components/ui/chatgpt-prompt-input";
6
7
 
7
8
  type Turn = { role: "user" | "assistant"; content: string };
8
9
  type SavedChat = { id: string; title: string; turns: Turn[]; updatedAt: number };
@@ -19,6 +20,7 @@ export default function AssistantPage() {
19
20
  const [history, setHistory] = useState<SavedChat[]>([]);
20
21
  const [historyReady, setHistoryReady] = useState(false);
21
22
  const [activeChatId, setActiveChatId] = useState("");
23
+ const [historyCollapsed, setHistoryCollapsed] = useState(false);
22
24
  const endRef = useRef<HTMLDivElement>(null);
23
25
 
24
26
  useEffect(() => {
@@ -31,6 +33,7 @@ export default function AssistantPage() {
31
33
  try {
32
34
  const stored = JSON.parse(window.localStorage.getItem(CHAT_HISTORY_KEY) ?? "[]");
33
35
  if (Array.isArray(stored)) setHistory(stored.slice(0, 30));
36
+ setHistoryCollapsed(window.localStorage.getItem("mop-agent-history-collapsed") === "1");
34
37
  } catch {
35
38
  window.localStorage.removeItem(CHAT_HISTORY_KEY);
36
39
  } finally {
@@ -78,13 +81,24 @@ export default function AssistantPage() {
78
81
  if (activeChatId === id) startNewChat();
79
82
  }
80
83
 
81
- async function send(prefill?: string) {
82
- const message = (prefill ?? input).trim();
83
- if (!message || busy) return;
84
+ function toggleHistory() {
85
+ setHistoryCollapsed((collapsed) => {
86
+ window.localStorage.setItem("mop-agent-history-collapsed", collapsed ? "0" : "1");
87
+ return !collapsed;
88
+ });
89
+ }
90
+
91
+ async function send(request?: string | PromptSubmit) {
92
+ const payload: PromptSubmit = typeof request === "string"
93
+ ? { message: request, tool: null, image: null }
94
+ : request ?? { message: input, tool: null, image: null };
95
+ const message = payload.message.trim();
96
+ if ((!message && !payload.image) || busy) return false;
97
+ const visibleMessage = [message, payload.image ? `๐Ÿ“Ž ${payload.image.name}` : ""].filter(Boolean).join("\n");
84
98
  if (!activeChatId) setActiveChatId(`chat-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
85
99
  setInput("");
86
100
  setBusy(true);
87
- setTurns((current) => [...current, { role: "user", content: message }, { role: "assistant", content: "" }]);
101
+ setTurns((current) => [...current, { role: "user", content: visibleMessage }, { role: "assistant", content: "" }]);
88
102
 
89
103
  const res = await fetch("/api/chat", {
90
104
  method: "POST",
@@ -92,6 +106,8 @@ export default function AssistantPage() {
92
106
  body: JSON.stringify({
93
107
  message,
94
108
  allowCrossProject: true,
109
+ tool: payload.tool,
110
+ image: payload.image,
95
111
  }),
96
112
  });
97
113
 
@@ -103,7 +119,7 @@ export default function AssistantPage() {
103
119
  return next;
104
120
  });
105
121
  setBusy(false);
106
- return;
122
+ return true;
107
123
  }
108
124
 
109
125
  const reader = res.body.getReader();
@@ -121,10 +137,11 @@ export default function AssistantPage() {
121
137
  endRef.current?.scrollIntoView({ behavior: "smooth" });
122
138
  }
123
139
  setBusy(false);
140
+ return true;
124
141
  }
125
142
 
126
143
  return (
127
- <section className="mop-assistant-page">
144
+ <section className={`mop-assistant-page${historyCollapsed ? " is-history-collapsed" : ""}`}>
128
145
  <div className="mop-assistant-workspace">
129
146
  <div className="mop-assistant-conversation">
130
147
  {turns.length === 0 ? (
@@ -165,17 +182,7 @@ export default function AssistantPage() {
165
182
  </div>
166
183
 
167
184
  <div className="mop-assistant-composer-wrap">
168
- <div style={composer}>
169
- <textarea
170
- value={input}
171
- onChange={(e) => setInput(e.target.value)}
172
- onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); send(); } }}
173
- placeholder="Message MOP-AGENTโ€ฆ"
174
- rows={1}
175
- style={textarea}
176
- />
177
- <button onClick={() => send()} disabled={busy || !input.trim()} style={{ ...sendButton, opacity: busy || !input.trim() ? .45 : 1 }}>โ†‘</button>
178
- </div>
185
+ <PromptBox value={input} onValueChange={setInput} onSubmit={send} busy={busy} />
179
186
  <div style={{ textAlign: "center", color: "rgba(45,74,62,.62)", fontSize: 11, marginTop: 8 }}>
180
187
  {providerUsed ? `Answered by ${providerUsed} ยท ` : ""}Cross-project memory
181
188
  </div>
@@ -184,11 +191,20 @@ export default function AssistantPage() {
184
191
 
185
192
  <aside className="mop-chat-history" aria-label="Chat history">
186
193
  <div className="mop-chat-history-header">
187
- <div>
188
- <span>MEMORY LOG</span>
189
- <strong>Chat history</strong>
194
+ <strong>Chat history</strong>
195
+ <div className="mop-chat-history-actions">
196
+ <button
197
+ className="mop-chat-history-collapse"
198
+ type="button"
199
+ onClick={toggleHistory}
200
+ aria-label={historyCollapsed ? "Expand chat history" : "Collapse chat history"}
201
+ aria-expanded={!historyCollapsed}
202
+ title={historyCollapsed ? "Expand chat history" : "Collapse chat history"}
203
+ >
204
+ {historyCollapsed ? "โ€น" : "โ€บ"}
205
+ </button>
206
+ <button className="mop-chat-history-new" type="button" onClick={startNewChat} disabled={busy} title="Start a new chat">๏ผ‹</button>
190
207
  </div>
191
- <button type="button" onClick={startNewChat} disabled={busy} title="Start a new chat">๏ผ‹</button>
192
208
  </div>
193
209
  <div className="mop-chat-history-list">
194
210
  {history.length === 0 ? (
@@ -219,6 +235,3 @@ const promptGrid: CSSProperties = { width: "min(100%, 650px)", display: "grid",
219
235
  const promptCard: CSSProperties = { display: "flex", justifyContent: "space-between", padding: "14px 15px", border: "1px solid rgba(45,74,62,.38)", borderBottomWidth: 3, background: "#fffdf2", color: "#2d4a3e", cursor: "pointer", textAlign: "left" };
220
236
  const botAvatar: CSSProperties = { width: 32, height: 32, display: "grid", placeItems: "center", background: "#742220", color: "#fef9e1" };
221
237
  const userAvatar: CSSProperties = { ...botAvatar, background: "#2d4a3e", fontSize: 12 };
222
- const composer: CSSProperties = { width: "min(calc(100% - 32px), 800px)", margin: "0 auto", display: "flex", alignItems: "flex-end", gap: 10, padding: "10px 10px 10px 15px", border: "1px solid rgba(45,74,62,.48)", borderBottomWidth: 4, background: "#fffdf2", boxShadow: "4px 4px 0 rgba(45,74,62,.13)" };
223
- const textarea: CSSProperties = { flex: 1, resize: "none", border: 0, outline: 0, boxShadow: "none", background: "transparent", color: "#2d4a3e", font: "inherit", lineHeight: 1.55, padding: "5px 0" };
224
- const sendButton: CSSProperties = { width: 34, height: 34, border: 0, background: "#742220", color: "#fef9e1", fontSize: 18, cursor: "pointer" };
@@ -1,54 +1,170 @@
1
1
  "use client";
2
- /** Brain knowledge graph (Fasa 5) โ€” projects โŸท patterns โŸท skills, via React Flow. */
2
+
3
3
  import { useEffect, useMemo, useState } from "react";
4
- import { ReactFlow, Background, Controls, type Edge, type Node } from "reactflow";
4
+ import {
5
+ Background,
6
+ Controls,
7
+ Handle,
8
+ MiniMap,
9
+ Position,
10
+ ReactFlow,
11
+ type Edge,
12
+ type Node,
13
+ type NodeMouseHandler,
14
+ type NodeProps,
15
+ } from "reactflow";
5
16
  import "reactflow/dist/style.css";
6
17
 
7
- type GNode = { id: string; label: string; type: "project" | "pattern" | "skill" };
18
+ type MemoryNodeType = "main" | "project" | "pattern" | "skill";
19
+ type GNode = { id: string; label: string; type: MemoryNodeType };
8
20
  type GEdge = { from: string; to: string };
21
+ type MemoryNodeData = { label: string; memoryType: MemoryNodeType };
22
+
23
+ const COLORS: Record<MemoryNodeType, string> = {
24
+ main: "#e4b83f",
25
+ project: "#d74c45",
26
+ pattern: "#4fa676",
27
+ skill: "#4d91c9",
28
+ };
9
29
 
10
- const COLOR = { project: "#742220", pattern: "#2d4a3e", skill: "#9a6738" };
30
+ const nodeTypes = { memory: MemoryGraphNode };
11
31
 
12
32
  export default function GraphPage() {
13
33
  const [data, setData] = useState<{ nodes: GNode[]; edges: GEdge[] }>({ nodes: [], edges: [] });
34
+ const [query, setQuery] = useState("");
35
+ const [filters, setFilters] = useState<Record<MemoryNodeType, boolean>>({ main: true, project: true, pattern: true, skill: true });
36
+ const [selected, setSelected] = useState<GNode | null>(null);
37
+ const [labelsVisible, setLabelsVisible] = useState(true);
14
38
 
15
39
  useEffect(() => {
16
- fetch("/api/graph").then((r) => r.json()).then((d) => setData({ nodes: d.nodes ?? [], edges: d.edges ?? [] })).catch(() => {});
40
+ fetch("/api/graph")
41
+ .then((response) => response.json())
42
+ .then((result) => setData({ nodes: result.nodes ?? [], edges: result.edges ?? [] }))
43
+ .catch(() => {});
17
44
  }, []);
18
45
 
19
- const nodes: Node[] = useMemo(() => {
20
- const cols: Record<GNode["type"], number> = { project: 0, pattern: 1, skill: 2 };
21
- const counts: Record<string, number> = {};
22
- return data.nodes.map((n) => {
23
- const col = cols[n.type];
24
- const row = (counts[n.type] = (counts[n.type] ?? 0) + 1);
46
+ const visibleData = useMemo(() => {
47
+ const normalizedQuery = query.trim().toLowerCase();
48
+ const directMatches = new Set(
49
+ data.nodes
50
+ .filter((node) => filters[node.type] && (!normalizedQuery || node.label.toLowerCase().includes(normalizedQuery)))
51
+ .map((node) => node.id),
52
+ );
53
+ if (normalizedQuery && directMatches.size) directMatches.add("main-brain");
54
+ return data.nodes.filter((node) => filters[node.type] && (!normalizedQuery || directMatches.has(node.id)));
55
+ }, [data.nodes, filters, query]);
56
+
57
+ const nodes: Node<MemoryNodeData>[] = useMemo(() => {
58
+ const grouped = new Map<MemoryNodeType, GNode[]>();
59
+ for (const type of ["main", "project", "pattern", "skill"] as const) {
60
+ grouped.set(type, visibleData.filter((node) => node.type === type));
61
+ }
62
+ return visibleData.map((node) => {
63
+ if (node.type === "main") {
64
+ return { id: node.id, type: "memory", position: { x: 0, y: 0 }, data: { label: node.label, memoryType: node.type } };
65
+ }
66
+ const peers = grouped.get(node.type) ?? [];
67
+ const index = peers.findIndex((peer) => peer.id === node.id);
68
+ const radius = node.type === "project" ? 250 : node.type === "pattern" ? 430 : 590;
69
+ const phase = node.type === "project" ? 0 : node.type === "pattern" ? 0.35 : 0.7;
70
+ const angle = phase + (Math.PI * 2 * Math.max(index, 0)) / Math.max(peers.length, 1);
25
71
  return {
26
- id: n.id,
27
- position: { x: col * 280, y: row * 90 },
28
- data: { label: `${n.type === "project" ? "๐Ÿ“ฆ" : n.type === "pattern" ? "๐ŸŒ" : "๐Ÿ› "} ${n.label}` },
29
- style: { border: `1px solid ${COLOR[n.type]}`, borderRadius: 8, background: "#fffdf2", color: "#2d4a3e", fontSize: 12, width: 240 },
72
+ id: node.id,
73
+ type: "memory",
74
+ position: { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius },
75
+ data: { label: node.label, memoryType: node.type },
30
76
  };
31
77
  });
32
- }, [data.nodes]);
78
+ }, [visibleData]);
33
79
 
80
+ const nodeIds = useMemo(() => new Set(nodes.map((node) => node.id)), [nodes]);
34
81
  const edges: Edge[] = useMemo(
35
- () => data.edges.map((e, i) => ({ id: `e${i}`, source: e.from, target: e.to, style: { stroke: "#2a3a4f" } })),
36
- [data.edges],
82
+ () => data.edges
83
+ .filter((edge) => nodeIds.has(edge.from) && nodeIds.has(edge.to))
84
+ .map((edge, index) => ({
85
+ id: `edge-${index}-${edge.from}-${edge.to}`,
86
+ source: edge.from,
87
+ target: edge.to,
88
+ style: { stroke: "rgba(215, 210, 190, .24)", strokeWidth: edge.from === "main-brain" ? 1.25 : .75 },
89
+ })),
90
+ [data.edges, nodeIds],
37
91
  );
38
92
 
93
+ const onNodeClick: NodeMouseHandler = (_, node) => {
94
+ setSelected(data.nodes.find((item) => item.id === node.id) ?? null);
95
+ };
96
+
39
97
  return (
40
- <main style={{ height: "calc(100vh - 70px)", display: "flex", flexDirection: "column" }}>
41
- <div style={{ padding: "12px 16px" }}>
42
- <a href="/brain" style={{ color: "#742220" }}>โ† Brain</a>{" "}
43
- <strong>Knowledge Graph</strong>{" "}
44
- <span style={{ opacity: 0.6, fontSize: 13 }}>{data.nodes.length} nodes ยท {data.edges.length} edges</span>
45
- </div>
46
- <div style={{ flex: 1 }}>
47
- <ReactFlow nodes={nodes} edges={edges} fitView>
48
- <Background color="#b8b49f" />
49
- <Controls />
98
+ <main className={`mop-graph-view${labelsVisible ? " show-labels" : " hide-labels"}`}>
99
+ <header className="mop-graph-toolbar">
100
+ <div className="mop-graph-toolbar-left">
101
+ <a href="/brain" aria-label="Back to Brain">โ†</a>
102
+ <strong>Graph view</strong>
103
+ <span>{nodes.length} nodes ยท {edges.length} links</span>
104
+ </div>
105
+ <label className="mop-graph-search">
106
+ <span>โŒ•</span>
107
+ <input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search memory graph" />
108
+ </label>
109
+ <button type="button" onClick={() => setLabelsVisible((visible) => !visible)}>{labelsVisible ? "Hide labels" : "Show labels"}</button>
110
+ </header>
111
+
112
+ <div className="mop-graph-canvas">
113
+ <ReactFlow
114
+ nodes={nodes}
115
+ edges={edges}
116
+ nodeTypes={nodeTypes}
117
+ onNodeClick={onNodeClick}
118
+ fitView
119
+ fitViewOptions={{ padding: .22 }}
120
+ minZoom={0.08}
121
+ maxZoom={2.4}
122
+ nodesConnectable={false}
123
+ proOptions={{ hideAttribution: true }}
124
+ >
125
+ <Background color="rgba(255,255,255,.08)" gap={28} size={1} />
126
+ <MiniMap
127
+ pannable
128
+ zoomable
129
+ nodeColor={(node) => COLORS[(node.data as MemoryNodeData).memoryType]}
130
+ maskColor="rgba(14,15,15,.72)"
131
+ style={{ background: "#171918", border: "1px solid rgba(255,255,255,.12)" }}
132
+ />
133
+ <Controls showInteractive={false} />
50
134
  </ReactFlow>
135
+
136
+ <aside className="mop-graph-filters">
137
+ <strong>Graph filters</strong>
138
+ {(["main", "project", "pattern", "skill"] as const).map((type) => (
139
+ <label key={type}>
140
+ <input type="checkbox" checked={filters[type]} onChange={() => setFilters((current) => ({ ...current, [type]: !current[type] }))} />
141
+ <span style={{ background: COLORS[type] }} />
142
+ {type === "main" ? "Main Brain" : `${type.charAt(0).toUpperCase()}${type.slice(1)}s`}
143
+ </label>
144
+ ))}
145
+ </aside>
146
+
147
+ {selected && (
148
+ <aside className="mop-graph-inspector">
149
+ <button type="button" aria-label="Close inspector" onClick={() => setSelected(null)}>ร—</button>
150
+ <span style={{ background: COLORS[selected.type] }} />
151
+ <small>{selected.type === "main" ? "CENTRAL MEMORY" : selected.type.toUpperCase()}</small>
152
+ <strong>{selected.label}</strong>
153
+ <p>{selected.type === "main" ? "Shared semantic memory and the root of every linked project." : "Connected knowledge within MOP MemoryCore."}</p>
154
+ </aside>
155
+ )}
51
156
  </div>
52
157
  </main>
53
158
  );
54
159
  }
160
+
161
+ function MemoryGraphNode({ data, selected }: NodeProps<MemoryNodeData>) {
162
+ return (
163
+ <div className={`mop-memory-graph-node is-${data.memoryType}${selected ? " is-selected" : ""}`}>
164
+ <Handle type="target" position={Position.Top} className="mop-graph-handle" />
165
+ <span className="mop-memory-node-dot" style={{ background: COLORS[data.memoryType] }} />
166
+ <span className="mop-memory-node-label">{data.label}</span>
167
+ <Handle type="source" position={Position.Bottom} className="mop-graph-handle" />
168
+ </div>
169
+ );
170
+ }