@agentprojectcontext/apx 1.68.0 → 1.70.0

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.
Files changed (44) hide show
  1. package/package.json +1 -1
  2. package/src/core/agent/tools/handlers/_obsidian.js +28 -0
  3. package/src/core/agent/tools/handlers/obsidian-list-notes.js +28 -0
  4. package/src/core/agent/tools/handlers/obsidian-read-note.js +31 -0
  5. package/src/core/agent/tools/handlers/obsidian-search-notes.js +30 -0
  6. package/src/core/agent/tools/handlers/obsidian-write-note.js +38 -0
  7. package/src/core/agent/tools/names.js +10 -0
  8. package/src/core/agent/tools/registry.js +8 -0
  9. package/src/core/integrations/catalog.js +6 -3
  10. package/src/core/integrations/index.js +2 -0
  11. package/src/core/integrations/mcp-sync.js +71 -0
  12. package/src/core/integrations/obsidian-memory.js +108 -0
  13. package/src/core/integrations/plugins/obsidian.js +299 -0
  14. package/src/core/mcp/runner.js +54 -20
  15. package/src/core/memory/broker.js +4 -1
  16. package/src/core/memory/index.js +29 -2
  17. package/src/core/memory/indexer.js +88 -1
  18. package/src/core/memory/store.js +23 -3
  19. package/src/host/daemon/api/agents.js +37 -1
  20. package/src/host/daemon/api/integrations.js +27 -2
  21. package/src/host/daemon/api/mcps.js +2 -2
  22. package/src/host/daemon/index.js +7 -1
  23. package/src/interfaces/cli/commands/obsidian.js +79 -0
  24. package/src/interfaces/cli/index.js +48 -1
  25. package/src/interfaces/web/dist/assets/index-CDz9OwCP.css +1 -0
  26. package/src/interfaces/web/dist/assets/index-YFsZFhM6.js +798 -0
  27. package/src/interfaces/web/dist/assets/index-YFsZFhM6.js.map +1 -0
  28. package/src/interfaces/web/dist/index.html +2 -2
  29. package/src/interfaces/web/src/components/integrations/BrandLogos.tsx +29 -0
  30. package/src/interfaces/web/src/components/integrations/ComingSoonPlugin.tsx +5 -4
  31. package/src/interfaces/web/src/components/integrations/FolderInput.tsx +137 -0
  32. package/src/interfaces/web/src/components/integrations/PluginConnect.tsx +129 -10
  33. package/src/interfaces/web/src/i18n/en.ts +25 -0
  34. package/src/interfaces/web/src/i18n/es.ts +25 -0
  35. package/src/interfaces/web/src/lib/api/agents.ts +2 -1
  36. package/src/interfaces/web/src/lib/api/integrations.ts +10 -1
  37. package/src/interfaces/web/src/screens/project/AgentBrainGraph.tsx +169 -47
  38. package/src/interfaces/web/src/screens/project/AgentDetailScreen.tsx +108 -34
  39. package/src/interfaces/web/src/screens/project/AgentsTab.tsx +72 -16
  40. package/src/interfaces/web/src/screens/project/Overview.tsx +90 -1
  41. package/src/interfaces/web/src/types/daemon.ts +10 -0
  42. package/src/interfaces/web/dist/assets/index-D4BmWoDM.css +0 -1
  43. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js +0 -803
  44. package/src/interfaces/web/dist/assets/index-vwd6yQVw.js.map +0 -1
@@ -1,21 +1,34 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import {
3
- forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide,
3
+ forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide, forceX, forceY,
4
4
  type Simulation,
5
5
  } from "d3-force";
6
6
  import { t } from "../../i18n";
7
7
 
8
- // Animated relational "brain" graph (d3-force + SVG), inspired by panda's
9
- // AgentBrainGraphCanvas. Center = agent; items are real project data
10
- // (memory facts, threads, tasks, heartbeats, hierarchy) with semantic edges.
8
+ // Generic animated "brain" graph (d3-force + SVG). It takes an explicit node +
9
+ // edge set so callers can model *any* topology — a single agent's hubbed brain
10
+ // (Memory / Threads / Tasks / Routines / Team + cross-links) or the whole
11
+ // project's agent map (orchestrators with their specialists as satellites).
12
+ //
13
+ // Motion is SMIL/CSS driven (breathing halos, a pulsing core, energy beads
14
+ // flowing down the edges) so the graph stays alive without React re-renders;
15
+ // d3 only drives layout.
16
+
17
+ export type BrainKind =
18
+ | "agent" | "memory" | "thread" | "task" | "routine" | "agentlink" | "hub";
19
+ export type BrainRole = "core" | "hub" | "leaf";
11
20
 
12
21
  export interface BrainNode {
13
22
  id: string;
14
23
  label: string;
15
- kind: "agent" | "memory" | "thread" | "task" | "routine" | "agentlink";
16
- relation: string;
24
+ kind: BrainKind;
25
+ role?: BrainRole; // visual weight; defaults to "leaf"
26
+ relation?: string;
17
27
  detail?: string;
28
+ emoji?: string;
29
+ slug?: string; // for navigation (project map)
18
30
  }
31
+ export interface BrainEdge { source: string; target: string; }
19
32
 
20
33
  interface SimNode extends BrainNode {
21
34
  x?: number; y?: number; vx?: number; vy?: number;
@@ -23,20 +36,32 @@ interface SimNode extends BrainNode {
23
36
  }
24
37
  interface SimLink { source: SimNode; target: SimNode; }
25
38
 
26
- const KIND_COLOR: Record<BrainNode["kind"], string> = {
39
+ const KIND_COLOR: Record<BrainKind, string> = {
27
40
  agent: "#a78bfa", memory: "#38bdf8", thread: "#34d399",
28
- task: "#fbbf24", routine: "#f472b6", agentlink: "#c084fc",
41
+ task: "#fbbf24", routine: "#f472b6", agentlink: "#c084fc", hub: "#94a3b8",
29
42
  };
30
- function kindLabels(): Record<BrainNode["kind"], string> {
31
- return {
43
+ function kindLabel(k: BrainKind): string {
44
+ const m: Record<string, string> = {
32
45
  agent: t("agents_ui.kind_agent"), memory: t("agents_ui.kind_memory"), thread: t("agents_ui.kind_thread"),
33
46
  task: t("agents_ui.kind_task"), routine: t("agents_ui.kind_routine"), agentlink: t("agents_ui.kind_hierarchy"),
34
47
  };
48
+ return m[k] ?? k;
35
49
  }
36
50
 
37
- const W = 760, H = 460;
51
+ const RADIUS: Record<BrainRole, number> = { core: 24, hub: 12, leaf: 6 };
52
+ const roleOf = (n: BrainNode): BrainRole => n.role ?? "leaf";
53
+
54
+ export function BrainGraph({
55
+ nodes, edges, height = 460, onNodeClick,
56
+ }: {
57
+ nodes: BrainNode[];
58
+ edges: BrainEdge[];
59
+ height?: number;
60
+ onNodeClick?: (n: BrainNode) => void;
61
+ }) {
62
+ const W = 760, H = height;
63
+ const CX = W / 2, CY = H / 2;
38
64
 
39
- export function AgentBrainGraph({ center, nodes }: { center: string; nodes: BrainNode[] }) {
40
65
  const svgRef = useRef<SVGSVGElement | null>(null);
41
66
  const simRef = useRef<Simulation<SimNode, SimLink> | null>(null);
42
67
  const nodesRef = useRef<SimNode[]>([]);
@@ -45,22 +70,56 @@ export function AgentBrainGraph({ center, nodes }: { center: string; nodes: Brai
45
70
  const [, setVersion] = useState(0);
46
71
  const [selected, setSelected] = useState<BrainNode | null>(null);
47
72
 
73
+ const hideLeafLabels = nodes.length > 44;
74
+
48
75
  useEffect(() => {
49
- const centerNode: SimNode = { id: "__center", label: center, kind: "agent", relation: "self", x: W / 2, y: H / 2, fx: W / 2, fy: H / 2 };
50
- const simNodes: SimNode[] = [centerNode, ...nodes.map((n) => ({ ...n }))];
51
- const links: SimLink[] = simNodes.slice(1).map((n) => ({ source: centerNode, target: n }));
76
+ // Seed positions so the first paint already looks like a graph (no blank
77
+ // flash, and a sane starting layout for d3).
78
+ const coreR = Math.min(W, H) * 0.30;
79
+ const hubs = nodes.filter((n) => roleOf(n) === "hub");
80
+ const hubAngle = new Map<string, number>();
81
+ hubs.forEach((h, i) => hubAngle.set(h.id, (i / Math.max(1, hubs.length)) * Math.PI * 2 - Math.PI / 2));
82
+
83
+ const simNodes: SimNode[] = nodes.map((n, i) => {
84
+ const role = roleOf(n);
85
+ if (role === "core") return { ...n, x: CX, y: CY, fx: CX, fy: CY };
86
+ const a = hubAngle.get(n.id) ?? (i / Math.max(1, nodes.length)) * Math.PI * 2;
87
+ const r = role === "hub" ? coreR : coreR * 1.7;
88
+ return { ...n, x: CX + Math.cos(a) * r, y: CY + Math.sin(a) * r };
89
+ });
90
+ const byId = new Map(simNodes.map((n) => [n.id, n]));
91
+ const links: SimLink[] = edges
92
+ .map((e) => ({ source: byId.get(e.source), target: byId.get(e.target) }))
93
+ .filter((l): l is SimLink => !!l.source && !!l.target);
94
+
52
95
  nodesRef.current = simNodes;
53
96
  linksRef.current = links;
97
+ setVersion((v) => v + 1); // commit the seeded layout even if the tick timer is throttled
98
+
99
+ const linkDist = (l: SimLink) => {
100
+ const ra = roleOf(l.source), rb = roleOf(l.target);
101
+ if (ra === "core" || rb === "core") return 150;
102
+ if (ra === "hub" && rb === "hub") return 120;
103
+ return 62;
104
+ };
105
+ const charge = (n: SimNode) => {
106
+ const r = roleOf(n);
107
+ return r === "core" ? -620 : r === "hub" ? -320 : -110;
108
+ };
54
109
 
55
110
  const sim = forceSimulation<SimNode>(simNodes)
56
- .force("link", forceLink<SimNode, SimLink>(links).distance(120).strength(0.5))
57
- .force("charge", forceManyBody().strength(-220))
58
- .force("center", forceCenter(W / 2, H / 2).strength(0.05))
59
- .force("collide", forceCollide(26))
111
+ .force("link", forceLink<SimNode, SimLink>(links).distance(linkDist).strength(0.55))
112
+ .force("charge", forceManyBody<SimNode>().strength(charge))
113
+ .force("center", forceCenter(CX, CY).strength(0.04))
114
+ .force("x", forceX(CX).strength(0.03))
115
+ .force("y", forceY(CY).strength(0.03))
116
+ .force("collide", forceCollide<SimNode>((n) => RADIUS[roleOf(n)] + 8))
117
+ .alphaDecay(0.028)
60
118
  .on("tick", () => setVersion((v) => v + 1));
61
119
  simRef.current = sim;
62
120
  return () => { sim.stop(); };
63
- }, [center, nodes]);
121
+ // eslint-disable-next-line react-hooks/exhaustive-deps
122
+ }, [nodes, edges, height]);
64
123
 
65
124
  const toSvg = (e: React.PointerEvent) => {
66
125
  const rect = svgRef.current!.getBoundingClientRect();
@@ -70,7 +129,7 @@ export function AgentBrainGraph({ center, nodes }: { center: string; nodes: Brai
70
129
  };
71
130
  };
72
131
  const onDown = (n: SimNode) => (e: React.PointerEvent) => {
73
- if (n.id === "__center") return;
132
+ if (roleOf(n) === "core") return;
74
133
  dragRef.current = n;
75
134
  (e.target as Element).setPointerCapture?.(e.pointerId);
76
135
  simRef.current?.alphaTarget(0.3).restart();
@@ -91,41 +150,106 @@ export function AgentBrainGraph({ center, nodes }: { center: string; nodes: Brai
91
150
  const simNodes = nodesRef.current;
92
151
  const links = linksRef.current;
93
152
 
153
+ // Legend: the item kinds actually present (exclude the core agent + structural hubs).
154
+ const legendKinds = [...new Set(nodes.map((n) => n.kind))].filter((k) => k !== "agent" && k !== "hub");
155
+
156
+ const pick = (n: SimNode) => { setSelected(n); onNodeClick?.(n); };
157
+
94
158
  return (
95
159
  <div className="space-y-3">
96
- <div className="overflow-hidden rounded-xl border border-border bg-muted/10">
160
+ <div className="overflow-hidden rounded-xl border border-border bg-gradient-to-b from-background to-muted/20">
97
161
  <svg
98
162
  ref={svgRef}
99
163
  viewBox={`0 0 ${W} ${H}`}
100
- className="h-[460px] w-full touch-none select-none"
164
+ style={{ height }}
165
+ className="w-full touch-none select-none"
101
166
  onPointerMove={onMove}
102
167
  onPointerUp={onUp}
103
168
  onPointerLeave={onUp}
104
169
  >
105
- {links.map((l, i) => (
106
- <line key={i} x1={l.source.x} y1={l.source.y} x2={l.target.x} y2={l.target.y}
107
- stroke={KIND_COLOR[l.target.kind]} strokeOpacity={0.22} strokeWidth={1.5} />
108
- ))}
109
- {simNodes.map((n) => {
110
- if (n.id === "__center") {
170
+ <defs>
171
+ <filter id="brain-glow" x="-80%" y="-80%" width="260%" height="260%">
172
+ <feGaussianBlur stdDeviation="3" result="blur" />
173
+ <feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge>
174
+ </filter>
175
+ <radialGradient id="brain-core" cx="50%" cy="50%" r="50%">
176
+ <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.9" />
177
+ <stop offset="55%" stopColor={KIND_COLOR.agent} stopOpacity="0.35" />
178
+ <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
179
+ </radialGradient>
180
+ <radialGradient id="brain-bg" cx="50%" cy="50%" r="60%">
181
+ <stop offset="0%" stopColor={KIND_COLOR.agent} stopOpacity="0.10" />
182
+ <stop offset="100%" stopColor={KIND_COLOR.agent} stopOpacity="0" />
183
+ </radialGradient>
184
+ </defs>
185
+
186
+ <rect x={0} y={0} width={W} height={H} fill="url(#brain-bg)" />
187
+
188
+ {/* Edges: faint spine + a bead of energy flowing along it */}
189
+ {links.map((l, i) => {
190
+ const color = KIND_COLOR[l.target.kind === "hub" ? l.source.kind : l.target.kind];
191
+ const dur = 1.4 + (i % 5) * 0.35;
192
+ return (
193
+ <g key={i}>
194
+ <line x1={l.source.x} y1={l.source.y} x2={l.target.x} y2={l.target.y}
195
+ stroke={color} strokeOpacity={0.16} strokeWidth={1.4} />
196
+ <line x1={l.target.x} y1={l.target.y} x2={l.source.x} y2={l.source.y}
197
+ stroke={color} strokeOpacity={0.5} strokeWidth={2}
198
+ strokeLinecap="round" strokeDasharray="1 14">
199
+ <animate attributeName="stroke-dashoffset" values="15;0" dur={`${dur}s`} repeatCount="indefinite" />
200
+ </line>
201
+ </g>
202
+ );
203
+ })}
204
+
205
+ {/* Nodes */}
206
+ {simNodes.map((n, idx) => {
207
+ const role = roleOf(n);
208
+ const color = KIND_COLOR[n.kind];
209
+ if (role === "core") {
210
+ const display = (n.emoji && n.emoji.trim()) || n.label.slice(0, 2).toUpperCase();
111
211
  return (
112
212
  <g key={n.id} transform={`translate(${n.x},${n.y})`}>
113
- <circle r={22} fill={KIND_COLOR.agent} />
114
- <text textAnchor="middle" y={4} fontSize={11} fontWeight={700} fill="#1a1a1a">
115
- {center.length > 8 ? center.slice(0, 8) : center}
213
+ <circle r={54} fill="url(#brain-core)">
214
+ <animate attributeName="r" values="50;58;50" dur="4s" repeatCount="indefinite" />
215
+ <animate attributeName="opacity" values="0.85;1;0.85" dur="4s" repeatCount="indefinite" />
216
+ </circle>
217
+ <circle r={26} fill="none" stroke={KIND_COLOR.agent} strokeWidth={1.5} opacity={0.5}>
218
+ <animate attributeName="r" values="26;48" dur="3.2s" repeatCount="indefinite" />
219
+ <animate attributeName="opacity" values="0.5;0" dur="3.2s" repeatCount="indefinite" />
220
+ </circle>
221
+ <circle r={24} fill={KIND_COLOR.agent} filter="url(#brain-glow)" />
222
+ <circle r={24} fill="none" stroke="#ffffff" strokeOpacity={0.35} strokeWidth={1} />
223
+ <text textAnchor="middle" dominantBaseline="central" fontSize={display.length <= 2 ? 20 : 11} fontWeight={700} fill="#1a1030">
224
+ {display.length > 8 ? display.slice(0, 8) : display}
116
225
  </text>
117
226
  </g>
118
227
  );
119
228
  }
120
229
  const isSel = selected?.id === n.id;
230
+ const r = RADIUS[role] + (isSel ? 3 : 0);
231
+ const beat = 2.4 + (idx % 6) * 0.4;
232
+ const begin = `${(idx % 6) * 0.3}s`;
233
+ const isHub = role === "hub";
234
+ const showLabel = isHub || isSel || !hideLeafLabels;
121
235
  return (
122
236
  <g key={n.id} transform={`translate(${n.x},${n.y})`} className="cursor-grab active:cursor-grabbing"
123
- onPointerDown={onDown(n)} onClick={() => setSelected(n)}>
124
- <circle r={isSel ? 9 : 6} fill={KIND_COLOR[n.kind]} fillOpacity={isSel ? 1 : 0.9}
125
- stroke={isSel ? "#fff" : "none"} strokeWidth={isSel ? 2 : 0} />
126
- <text x={10} y={4} fontSize={10} className="fill-foreground/80">
127
- {n.label.length > 22 ? `${n.label.slice(0, 22)}…` : n.label}
128
- </text>
237
+ onPointerDown={onDown(n)} onClick={() => pick(n)}>
238
+ <circle r={r} fill={color} filter="url(#brain-glow)" opacity={0.3}>
239
+ <animate attributeName="r" values={`${r};${r + 6};${r}`} dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
240
+ <animate attributeName="opacity" values="0.32;0.08;0.32" dur={`${beat}s`} begin={begin} repeatCount="indefinite" />
241
+ </circle>
242
+ <circle r={r} fill={color} fillOpacity={isSel ? 1 : 0.95}
243
+ stroke={isSel ? "#fff" : "#ffffff"} strokeOpacity={isSel ? 1 : 0.25} strokeWidth={isSel ? 2 : 1} />
244
+ {n.emoji && isHub && (
245
+ <text textAnchor="middle" dominantBaseline="central" fontSize={11} style={{ pointerEvents: "none" }}>{n.emoji}</text>
246
+ )}
247
+ {showLabel && (
248
+ <text x={r + 4} y={4} fontSize={isHub ? 11 : 10}
249
+ className={isHub ? "fill-foreground font-medium" : "fill-foreground/80"} style={{ pointerEvents: "none" }}>
250
+ {n.label.length > 22 ? `${n.label.slice(0, 22)}…` : n.label}
251
+ </text>
252
+ )}
129
253
  </g>
130
254
  );
131
255
  })}
@@ -133,22 +257,20 @@ export function AgentBrainGraph({ center, nodes }: { center: string; nodes: Brai
133
257
  </div>
134
258
 
135
259
  <div className="flex flex-wrap items-center gap-3 text-[11px] text-muted-fg">
136
- {(() => {
137
- const labels = kindLabels();
138
- return (Object.keys(labels) as BrainNode["kind"][]).filter((k) => k !== "agent").map((k) => (
139
- <span key={k} className="inline-flex items-center gap-1">
140
- <span className="size-2 rounded-full" style={{ background: KIND_COLOR[k] }} /> {labels[k]}
141
- </span>
142
- ));
143
- })()}
260
+ {legendKinds.map((k) => (
261
+ <span key={k} className="inline-flex items-center gap-1">
262
+ <span className="size-2 rounded-full" style={{ background: KIND_COLOR[k] }} /> {kindLabel(k)}
263
+ </span>
264
+ ))}
144
265
  <span className="ml-auto">{t("agents_ui.nodes_drag_hint", { n: String(nodes.length) })}</span>
145
266
  </div>
146
267
  {selected && (
147
268
  <div className="rounded-lg border border-border bg-card p-3 text-xs">
148
269
  <div className="flex items-center gap-2">
149
270
  <span className="size-2 rounded-full" style={{ background: KIND_COLOR[selected.kind] }} />
271
+ {selected.emoji && <span>{selected.emoji}</span>}
150
272
  <span className="font-medium">{selected.label}</span>
151
- <span className="text-muted-fg">· {selected.relation}</span>
273
+ {selected.relation && <span className="text-muted-fg">· {selected.relation}</span>}
152
274
  </div>
153
275
  {selected.detail && <p className="mt-1 whitespace-pre-wrap text-muted-fg">{selected.detail}</p>}
154
276
  </div>
@@ -1,4 +1,4 @@
1
- import { useEffect, useMemo, useState } from "react";
1
+ import { useMemo, useState } from "react";
2
2
  import { useNavigate, useParams } from "react-router-dom";
3
3
  import useSWR from "swr";
4
4
  import {
@@ -6,7 +6,7 @@ import {
6
6
  Heart, MessagesSquare, Save, Send, Settings, Sparkles, Trash2, Wrench, Activity,
7
7
  } from "lucide-react";
8
8
  import { Agents, Conversations, Messages, Routines, Tasks, Tools } from "../../lib/api";
9
- import type { AgentDetail, AgentEntry, MessageEntry, RoutineEntry } from "../../types/daemon";
9
+ import type { AgentDetail, AgentEntry, FileContent, MessageEntry, RoutineEntry } from "../../types/daemon";
10
10
  import { Section } from "../../components/Section";
11
11
  import { Badge, Button, Field, Input, Loading, Switch, Textarea } from "../../components/ui";
12
12
  import { Tip } from "../../components/ui/tip";
@@ -14,10 +14,11 @@ import { UiSelect } from "../../components/UiSelect";
14
14
  import { useToast } from "../../components/Toast";
15
15
  import { ConfirmDialog } from "../../components/common/ConfirmDialog";
16
16
  import { EmojiInput, AutonomyPicker, AreaRoleFields } from "../../components/agents/AgentFormFields";
17
+ import { FileViewer } from "../../components/files/FileViewer";
17
18
  import { cn } from "../../lib/cn";
18
19
  import { t } from "../../i18n";
19
20
  import type { AgentAutonomy } from "../../types/daemon";
20
- import { AgentBrainGraph, type BrainNode } from "./AgentBrainGraph";
21
+ import { BrainGraph, type BrainNode, type BrainEdge } from "./AgentBrainGraph";
21
22
 
22
23
  type TabKey = "overview" | "memories" | "records" | "sleep" | "brain" | "config";
23
24
  function buildTabs(): { key: TabKey; label: string; icon: typeof Bot }[] {
@@ -167,7 +168,7 @@ export function AgentDetailScreen({ pid }: { pid: string }) {
167
168
  </div>
168
169
  )}
169
170
 
170
- {tab === "memories" && <MemoryEditor pid={pid} slug={slug} initial={a.memory || ""} onSaved={() => detail.mutate()} />}
171
+ {tab === "memories" && <MemoryEditor pid={pid} slug={slug} onSaved={() => detail.mutate()} />}
171
172
 
172
173
  {tab === "records" && <RecordsList records={records.data || []} loading={records.isLoading} />}
173
174
 
@@ -176,6 +177,7 @@ export function AgentDetailScreen({ pid }: { pid: string }) {
176
177
  {tab === "brain" && (
177
178
  <BrainTab
178
179
  slug={slug}
180
+ emoji={a.emoji || undefined}
179
181
  memory={a.memory || ""}
180
182
  threads={(threads.data || []).map((t) => ({ id: t.id, label: t.title || t.filename }))}
181
183
  tasks={myTasks.map((t) => ({ id: t.id, label: t.title, detail: t.body || undefined }))}
@@ -311,26 +313,38 @@ function Stat({ label, value, icon: I }: { label: string; value: number; icon: t
311
313
  );
312
314
  }
313
315
 
314
- function MemoryEditor({ pid, slug, initial, onSaved }: { pid: string; slug: string; initial: string; onSaved: () => void }) {
316
+ // Durable memory for a single agent, using the same docs-style editor as the
317
+ // project /memories surface (markdown edit / split-preview / save) instead of a
318
+ // bare textarea.
319
+ function MemoryEditor({ pid, slug, onSaved }: { pid: string; slug: string; onSaved: () => void }) {
315
320
  const toast = useToast();
316
- const [value, setValue] = useState(initial);
317
- const [busy, setBusy] = useState(false);
318
- useEffect(() => { setValue(initial); }, [initial]);
319
- const dirty = value !== initial;
320
- const save = async () => {
321
- setBusy(true);
322
- try { await Agents.memory.put(pid, slug, value); toast.success(t("project.agent_detail.memory_saved")); onSaved(); }
323
- catch (e) { toast.error((e as Error).message); }
324
- finally { setBusy(false); }
321
+ const body = useSWR(`/memory/${pid}/agent:${slug}`, () => Agents.memory.get(pid, slug).then((r) => r.body));
322
+
323
+ const file = useMemo<FileContent | null>(() => {
324
+ if (body.data === undefined) return null;
325
+ const content = body.data ?? "";
326
+ return {
327
+ path: `agents/${slug}/memory.md`,
328
+ name: "memory.md",
329
+ kind: "markdown",
330
+ size: content.length,
331
+ modified: "",
332
+ encoding: "utf8",
333
+ content,
334
+ };
335
+ }, [body.data, slug]);
336
+
337
+ const onSave = async (content: string) => {
338
+ await Agents.memory.put(pid, slug, content);
339
+ toast.success(t("project.agent_detail.memory_saved"));
340
+ void body.mutate(content, { revalidate: false });
341
+ onSaved();
325
342
  };
343
+
326
344
  return (
327
- <Section title={t("project.agent_detail.memory_title")} description={`~/.apx/projects/<id>/agents/${slug}/memory.md ${t("agents_ui.memory_durable_desc")}`}>
328
- <Textarea rows={16} className="font-mono text-xs" value={value} onChange={(e) => setValue(e.target.value)} placeholder={t("project.agent_detail.memory_empty")} />
329
- <div className="mt-2 flex items-center justify-between">
330
- <span className="text-[11px] text-muted-fg">{value.length} {t("project.memories.chars")}</span>
331
- <Button size="sm" variant="primary" loading={busy} disabled={!dirty} onClick={save}><Save size={12} /> {t("project.memories.save_btn")}</Button>
332
- </div>
333
- </Section>
345
+ <div className="flex h-[65vh] min-h-[420px] flex-col overflow-hidden rounded-xl border border-border bg-card">
346
+ <FileViewer file={file} loading={body.isLoading} onSave={onSave} />
347
+ </div>
334
348
  );
335
349
  }
336
350
 
@@ -447,10 +461,28 @@ function ToolsPicker({ value, onChange }: { value: string; onChange: (v: string)
447
461
  );
448
462
  }
449
463
 
464
+ // Cross-link heuristic: two items are "related" when their titles share a
465
+ // meaningful word (ignoring short/stop words). Used to wire tasks↔threads so
466
+ // the brain reads as a web, not a wheel.
467
+ const STOP = new Set([
468
+ "the", "and", "for", "with", "from", "into", "your", "that", "this", "una", "las", "los",
469
+ "del", "por", "con", "para", "post", "posts", "demo", "week", "weekly",
470
+ ]);
471
+ function keywords(s: string): Set<string> {
472
+ return new Set(
473
+ s.toLowerCase().split(/[^a-záéíóúñ0-9]+/).filter((w) => w.length > 3 && !STOP.has(w)),
474
+ );
475
+ }
476
+ function shareKeyword(a: Set<string>, b: Set<string>): boolean {
477
+ for (const w of a) if (b.has(w)) return true;
478
+ return false;
479
+ }
480
+
450
481
  function BrainTab({
451
- slug, memory, threads, tasks, routines, parent, children,
482
+ slug, emoji, memory, threads, tasks, routines, parent, children,
452
483
  }: {
453
484
  slug: string;
485
+ emoji?: string;
454
486
  memory: string;
455
487
  threads: { id: string; label: string }[];
456
488
  tasks: { id: string; label: string; detail?: string }[];
@@ -458,22 +490,64 @@ function BrainTab({
458
490
  parent: string | null;
459
491
  children: string[];
460
492
  }) {
461
- const nodes: BrainNode[] = useMemo(() => {
462
- const out: BrainNode[] = [];
463
- memoryFacts(memory).forEach((f, i) => out.push({ id: `m${i}`, label: f, kind: "memory", relation: "knows", detail: f }));
464
- threads.slice(0, 8).forEach((t) => out.push({ id: `th-${t.id}`, label: t.label, kind: "thread", relation: "in_thread" }));
465
- tasks.slice(0, 8).forEach((t) => out.push({ id: `ts-${t.id}`, label: t.label, kind: "task", relation: "handles_task", detail: t.detail }));
466
- routines.forEach((r) => out.push({ id: `rt-${r.name}`, label: r.name, kind: "routine", relation: "ticks", detail: `schedule: ${r.schedule}` }));
467
- if (parent) out.push({ id: `p-${parent}`, label: parent, kind: "agentlink", relation: "reports_to" });
468
- children.forEach((c) => out.push({ id: `c-${c}`, label: c, kind: "agentlink", relation: "orchestrates" }));
469
- return out;
470
- }, [memory, threads, tasks, routines, parent, children]);
493
+ const { nodes, edges } = useMemo(() => {
494
+ const nodes: BrainNode[] = [];
495
+ const edges: BrainEdge[] = [];
496
+ const CORE = "__core";
497
+ nodes.push({ id: CORE, label: slug, kind: "agent", role: "core", emoji, relation: "self" });
498
+
499
+ // A category hub groups its items so items hang off the hub (a two-level
500
+ // tree) instead of all wiring straight to the core.
501
+ const hub = (id: string, label: string, kind: BrainNode["kind"]) => {
502
+ nodes.push({ id, label, kind, role: "hub", relation: "cluster" });
503
+ edges.push({ source: CORE, target: id });
504
+ };
505
+
506
+ const mem = memoryFacts(memory);
507
+ const th = threads.slice(0, 8);
508
+ const ts = tasks.slice(0, 8);
509
+
510
+ if (mem.length) {
511
+ hub("hub-mem", t("agents_ui.kind_memory"), "memory");
512
+ mem.forEach((f, i) => { nodes.push({ id: `m${i}`, label: f, kind: "memory", relation: "knows", detail: f }); edges.push({ source: "hub-mem", target: `m${i}` }); });
513
+ }
514
+ if (th.length) {
515
+ hub("hub-thread", t("agents_ui.kind_thread"), "thread");
516
+ th.forEach((x) => { nodes.push({ id: `th-${x.id}`, label: x.label, kind: "thread", relation: "in_thread" }); edges.push({ source: "hub-thread", target: `th-${x.id}` }); });
517
+ }
518
+ if (ts.length) {
519
+ hub("hub-task", t("agents_ui.kind_task"), "task");
520
+ ts.forEach((x) => { nodes.push({ id: `ts-${x.id}`, label: x.label, kind: "task", relation: "handles_task", detail: x.detail }); edges.push({ source: "hub-task", target: `ts-${x.id}` }); });
521
+ }
522
+ if (routines.length) {
523
+ hub("hub-routine", t("agents_ui.kind_routine"), "routine");
524
+ routines.forEach((r) => { nodes.push({ id: `rt-${r.name}`, label: r.name, kind: "routine", relation: "ticks", detail: `schedule: ${r.schedule}` }); edges.push({ source: "hub-routine", target: `rt-${r.name}` }); });
525
+ }
526
+ if (children.length) {
527
+ hub("hub-team", t("agents_ui.kind_hierarchy"), "agentlink");
528
+ children.forEach((c) => { nodes.push({ id: `c-${c}`, label: c, kind: "agentlink", role: "hub", relation: "orchestrates", slug: c }); edges.push({ source: "hub-team", target: `c-${c}` }); });
529
+ }
530
+ if (parent) {
531
+ nodes.push({ id: `p-${parent}`, label: parent, kind: "agentlink", role: "hub", relation: "reports_to", slug: parent });
532
+ edges.push({ source: `p-${parent}`, target: CORE });
533
+ }
534
+
535
+ // Cross-links: wire a task to a thread that shares a keyword (first match).
536
+ const thKw = th.map((x) => ({ id: `th-${x.id}`, kw: keywords(x.label) }));
537
+ ts.forEach((x) => {
538
+ const kw = keywords(x.label);
539
+ const hit = thKw.find((tk) => shareKeyword(kw, tk.kw));
540
+ if (hit) edges.push({ source: `ts-${x.id}`, target: hit.id });
541
+ });
542
+
543
+ return { nodes, edges };
544
+ }, [slug, emoji, memory, threads, tasks, routines, parent, children]);
471
545
 
472
546
  return (
473
547
  <Section title={t("project.agent_detail.brain_title")} description={t("project.agent_detail.brain_desc")}>
474
- {nodes.length === 0
548
+ {nodes.length <= 1
475
549
  ? <p className="text-xs text-muted-fg">{t("project.agent_detail.brain_empty")}</p>
476
- : <AgentBrainGraph center={slug} nodes={nodes} />}
550
+ : <BrainGraph nodes={nodes} edges={edges} />}
477
551
  </Section>
478
552
  );
479
553
  }