paperclip-plugin-supermemory-otomatik 0.2.13 → 0.2.15

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
@@ -6,7 +6,7 @@ Paperclip plugin for Supermemory Local. It adds a native **Memory** sidebar page
6
6
 
7
7
  - **Plugin settings:** chooses Paperclip agents from the active organization list and binds one individual ACL secret to each selected agent. The user no longer copies agent UUIDs.
8
8
  - **Memory page:** adds a native sidebar entry, lets an Owner assign one isolated Supermemory namespace to an agent or project, and manages text, URLs, and supported files.
9
- - **Memory graph:** adds a **Grafo** submenu. An Owner chooses one active namespace assignment, searches for a topic, then explores the related memories returned by Supermemory. Selecting a node lets the Owner search from that fact to expand another neighborhood. This is a navigable search neighborhood, not a claim that every memory in the namespace has been enumerated.
9
+ - **Memory graph:** adds a **Graph** submenu. An Owner chooses one active namespace assignment, searches for a topic, then explores the related memories returned by Supermemory in an interactive force-directed view inspired by Obsidian. Drag to pan; use the wheel or zoom controls to navigate. Selecting a node lets the Owner search from that fact to expand another neighborhood. This is a navigable search neighborhood, not a claim that every memory in the namespace has been enumerated.
10
10
  - **Agent search:** checks the trusted Paperclip agent, company, run and project context. It searches the agent's company/role ACL compartments and only the agent/project namespaces explicitly assigned to that identity.
11
11
  - **Candidate saves:** sends candidate facts to the existing role staging compartment for human review. It does not publish them as shared memory.
12
12
 
@@ -14,14 +14,14 @@ The plugin uses Paperclip's standard plugin slots (`settingsPage`, `page`, `side
14
14
 
15
15
  ## Install from Paperclip
16
16
 
17
- Package: [`paperclip-plugin-supermemory-otomatik`](https://www.npmjs.com/package/paperclip-plugin-supermemory-otomatik) **0.2.13**.
17
+ Package: [`paperclip-plugin-supermemory-otomatik`](https://www.npmjs.com/package/paperclip-plugin-supermemory-otomatik) **0.2.15**.
18
18
 
19
19
  1. In Paperclip, open **Settings → Plugins → Install Plugin**.
20
20
  2. Enter `paperclip-plugin-supermemory-otomatik` and install it for the instance.
21
21
  3. Open the plugin configuration for the target organization.
22
22
  4. Select each authorized agent from **Agente Paperclip** and choose that agent's individual ACL secret.
23
23
  5. Select the Paperclip secret containing the Supermemory Local master API key. This is required for the Memory page, URL/file ingestion, and explicitly assigned namespaces.
24
- 6. Save. **Memory** appears in the organization sidebar with the **Grafo** submenu.
24
+ 6. Save. **Memory** appears in the organization sidebar with the **Graph** submenu. The Paperclip in Italiano Chrome extension translates that label to **Grafo**.
25
25
 
26
26
  The settings form uses the active Paperclip organization to load agents and company secrets. It persists secret references only; it never reads or renders the secret values. The configuration save endpoint requires a Paperclip instance administrator. The settings page also has **Aggiorna all’ultima versione**: only an instance admin can request an upgrade, and Paperclip pauses an update for approval if the new release adds capabilities.
27
27
 
package/dist/manifest.js CHANGED
@@ -11,7 +11,7 @@ var MEMORY_GRAPH_ROUTE = "memory-graph";
11
11
  var manifest = {
12
12
  id: PLUGIN_ID,
13
13
  apiVersion: 1,
14
- version: "0.2.13",
14
+ version: "0.2.15",
15
15
  minimumHostVersion: "2026.1023.0",
16
16
  displayName: "Supermemory (self-hosted)",
17
17
  description: "Memory tools and an Owner-managed memory library for self-hosted Supermemory, with company-, agent-, and project-scoped access.",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/manifest.ts"],
4
- "sourcesContent": ["import type { PaperclipPluginManifestV1 } from \"@paperclipai/plugin-sdk\";\n\nexport const PLUGIN_ID = \"paperclipai.supermemory\";\nexport const SEARCH_TOOL = \"search-approved-memory\";\nexport const SAVE_TOOL = \"submit-memory-candidate\";\nexport const MEMORY_SETTINGS_PAGE = \"supermemory-settings\";\nexport const MEMORY_PAGE = \"supermemory-memory\";\nexport const MEMORY_GRAPH_PAGE = \"supermemory-memory-graph\";\nexport const MEMORY_SIDEBAR = \"supermemory-sidebar\";\nexport const MEMORY_ROUTE = \"memory\";\nexport const MEMORY_GRAPH_ROUTE = \"memory-graph\";\n\nconst manifest: PaperclipPluginManifestV1 = {\n id: PLUGIN_ID,\n apiVersion: 1,\n version: \"0.2.13\",\n minimumHostVersion: \"2026.1023.0\",\n displayName: \"Supermemory (self-hosted)\",\n description: \"Memory tools and an Owner-managed memory library for self-hosted Supermemory, with company-, agent-, and project-scoped access.\",\n author: \"Otomatik\",\n categories: [\"connector\", \"automation\", \"ui\"],\n capabilities: [\n \"agent.tools.register\",\n \"agents.read\",\n \"projects.read\",\n \"access.members.read\",\n \"http.outbound\",\n \"instance.settings.register\",\n \"secrets.read-ref\",\n \"ui.page.register\",\n \"ui.sidebar.register\",\n ],\n entrypoints: {\n worker: \"./dist/worker.js\",\n ui: \"./dist/ui\",\n },\n instanceConfigSchema: {\n type: \"object\",\n properties: {\n gatewayUrl: {\n type: \"string\",\n title: \"Gateway Supermemory su Tailscale HTTPS\",\n description: \"URL HTTPS tailnet del gateway ACL e delle operazioni Owner. Esempio OTOMATIK: https://otomatik.tail77a9ab.ts.net:18443/mcp. Supermemory Local resta privato sul server.\",\n default: \"https://otomatik.tail77a9ab.ts.net:18443/mcp\",\n },\n localApiUrl: {\n type: \"string\",\n title: \"Legacy Supermemory Local API URL\",\n description: \"Legacy value retained for existing configurations; ignored since plugin 0.2.3. The Local API is reached only by the server-side gateway.\",\n },\n adminApiKeyRef: {\n // Paperclip persists a secret-ref format as an object binding, even\n // though the format marker historically describes a string picker.\n type: [\"string\", \"object\"],\n format: \"secret-ref\",\n title: \"Secret con la chiave master Supermemory Local\",\n description: \"Usata come credenziale per le route Owner del gateway Tailscale. Viene risolta solo dopo il controllo Owner o per una ricerca con namespace assegnato esplicitamente; non viene esposta nei prompt.\",\n },\n agentGrants: {\n type: \"array\",\n title: \"Authorized agents\",\n description: \"Only listed Paperclip agents can use the memory tools. Each one uses its own ACL gateway token secret, already bound to the same agent, company, and role.\",\n items: {\n type: \"object\",\n title: \"Agent grant\",\n required: [\"agentId\", \"tokenRef\"],\n properties: {\n agentId: {\n type: \"string\",\n title: \"Paperclip agent\",\n minLength: 1,\n },\n tokenRef: {\n type: [\"string\", \"object\"],\n format: \"secret-ref\",\n title: \"This agent's ACL token secret\",\n description: \"Select the Paperclip company secret containing only this agent's Supermemory ACL token.\",\n },\n },\n },\n default: [],\n },\n },\n },\n tools: [\n {\n name: SEARCH_TOOL,\n displayName: \"Search approved Supermemory\",\n description: \"Search this agent's explicitly authorized company/team compartments and any assigned agent/project namespaces. Project memory is available only when the Paperclip run is in that project and the agent is on its explicit allowlist. Do not search for credentials or personal records.\",\n parametersSchema: {\n type: \"object\",\n properties: {\n query: { type: \"string\", minLength: 1, maxLength: 300 },\n searchMode: { type: \"string\", enum: [\"hybrid\", \"memories\", \"chunks\"], default: \"hybrid\" },\n limit: { type: \"integer\", minimum: 1, maximum: 5, default: 3 },\n },\n required: [\"query\"],\n additionalProperties: false,\n },\n },\n {\n name: SAVE_TOOL,\n displayName: \"Submit a Supermemory candidate\",\n description: \"Submit one durable, non-sensitive fact or decision to this agent's role staging compartment for human review. This does not publish it to shared approved memory. Never submit credentials, personal data, customer cases, legal/health/workplace records, or full chat transcripts.\",\n parametersSchema: {\n type: \"object\",\n properties: {\n content: { type: \"string\", minLength: 20, maxLength: 1200 },\n },\n required: [\"content\"],\n additionalProperties: false,\n },\n },\n ],\n ui: {\n slots: [\n {\n type: \"settingsPage\",\n id: MEMORY_SETTINGS_PAGE,\n displayName: \"Supermemory settings\",\n exportName: \"SupermemorySettingsPage\",\n },\n {\n type: \"page\",\n id: MEMORY_PAGE,\n displayName: \"Memory\",\n exportName: \"MemoryPage\",\n routePath: MEMORY_ROUTE,\n },\n {\n type: \"page\",\n id: MEMORY_GRAPH_PAGE,\n displayName: \"Memory graph\",\n exportName: \"MemoryGraphPage\",\n routePath: MEMORY_GRAPH_ROUTE,\n },\n {\n type: \"sidebar\",\n id: MEMORY_SIDEBAR,\n displayName: \"Memory\",\n exportName: \"MemorySidebarLink\",\n },\n ],\n },\n};\n\nexport default manifest;\n"],
4
+ "sourcesContent": ["import type { PaperclipPluginManifestV1 } from \"@paperclipai/plugin-sdk\";\n\nexport const PLUGIN_ID = \"paperclipai.supermemory\";\nexport const SEARCH_TOOL = \"search-approved-memory\";\nexport const SAVE_TOOL = \"submit-memory-candidate\";\nexport const MEMORY_SETTINGS_PAGE = \"supermemory-settings\";\nexport const MEMORY_PAGE = \"supermemory-memory\";\nexport const MEMORY_GRAPH_PAGE = \"supermemory-memory-graph\";\nexport const MEMORY_SIDEBAR = \"supermemory-sidebar\";\nexport const MEMORY_ROUTE = \"memory\";\nexport const MEMORY_GRAPH_ROUTE = \"memory-graph\";\n\nconst manifest: PaperclipPluginManifestV1 = {\n id: PLUGIN_ID,\n apiVersion: 1,\n version: \"0.2.15\",\n minimumHostVersion: \"2026.1023.0\",\n displayName: \"Supermemory (self-hosted)\",\n description: \"Memory tools and an Owner-managed memory library for self-hosted Supermemory, with company-, agent-, and project-scoped access.\",\n author: \"Otomatik\",\n categories: [\"connector\", \"automation\", \"ui\"],\n capabilities: [\n \"agent.tools.register\",\n \"agents.read\",\n \"projects.read\",\n \"access.members.read\",\n \"http.outbound\",\n \"instance.settings.register\",\n \"secrets.read-ref\",\n \"ui.page.register\",\n \"ui.sidebar.register\",\n ],\n entrypoints: {\n worker: \"./dist/worker.js\",\n ui: \"./dist/ui\",\n },\n instanceConfigSchema: {\n type: \"object\",\n properties: {\n gatewayUrl: {\n type: \"string\",\n title: \"Gateway Supermemory su Tailscale HTTPS\",\n description: \"URL HTTPS tailnet del gateway ACL e delle operazioni Owner. Esempio OTOMATIK: https://otomatik.tail77a9ab.ts.net:18443/mcp. Supermemory Local resta privato sul server.\",\n default: \"https://otomatik.tail77a9ab.ts.net:18443/mcp\",\n },\n localApiUrl: {\n type: \"string\",\n title: \"Legacy Supermemory Local API URL\",\n description: \"Legacy value retained for existing configurations; ignored since plugin 0.2.3. The Local API is reached only by the server-side gateway.\",\n },\n adminApiKeyRef: {\n // Paperclip persists a secret-ref format as an object binding, even\n // though the format marker historically describes a string picker.\n type: [\"string\", \"object\"],\n format: \"secret-ref\",\n title: \"Secret con la chiave master Supermemory Local\",\n description: \"Usata come credenziale per le route Owner del gateway Tailscale. Viene risolta solo dopo il controllo Owner o per una ricerca con namespace assegnato esplicitamente; non viene esposta nei prompt.\",\n },\n agentGrants: {\n type: \"array\",\n title: \"Authorized agents\",\n description: \"Only listed Paperclip agents can use the memory tools. Each one uses its own ACL gateway token secret, already bound to the same agent, company, and role.\",\n items: {\n type: \"object\",\n title: \"Agent grant\",\n required: [\"agentId\", \"tokenRef\"],\n properties: {\n agentId: {\n type: \"string\",\n title: \"Paperclip agent\",\n minLength: 1,\n },\n tokenRef: {\n type: [\"string\", \"object\"],\n format: \"secret-ref\",\n title: \"This agent's ACL token secret\",\n description: \"Select the Paperclip company secret containing only this agent's Supermemory ACL token.\",\n },\n },\n },\n default: [],\n },\n },\n },\n tools: [\n {\n name: SEARCH_TOOL,\n displayName: \"Search approved Supermemory\",\n description: \"Search this agent's explicitly authorized company/team compartments and any assigned agent/project namespaces. Project memory is available only when the Paperclip run is in that project and the agent is on its explicit allowlist. Do not search for credentials or personal records.\",\n parametersSchema: {\n type: \"object\",\n properties: {\n query: { type: \"string\", minLength: 1, maxLength: 300 },\n searchMode: { type: \"string\", enum: [\"hybrid\", \"memories\", \"chunks\"], default: \"hybrid\" },\n limit: { type: \"integer\", minimum: 1, maximum: 5, default: 3 },\n },\n required: [\"query\"],\n additionalProperties: false,\n },\n },\n {\n name: SAVE_TOOL,\n displayName: \"Submit a Supermemory candidate\",\n description: \"Submit one durable, non-sensitive fact or decision to this agent's role staging compartment for human review. This does not publish it to shared approved memory. Never submit credentials, personal data, customer cases, legal/health/workplace records, or full chat transcripts.\",\n parametersSchema: {\n type: \"object\",\n properties: {\n content: { type: \"string\", minLength: 20, maxLength: 1200 },\n },\n required: [\"content\"],\n additionalProperties: false,\n },\n },\n ],\n ui: {\n slots: [\n {\n type: \"settingsPage\",\n id: MEMORY_SETTINGS_PAGE,\n displayName: \"Supermemory settings\",\n exportName: \"SupermemorySettingsPage\",\n },\n {\n type: \"page\",\n id: MEMORY_PAGE,\n displayName: \"Memory\",\n exportName: \"MemoryPage\",\n routePath: MEMORY_ROUTE,\n },\n {\n type: \"page\",\n id: MEMORY_GRAPH_PAGE,\n displayName: \"Memory graph\",\n exportName: \"MemoryGraphPage\",\n routePath: MEMORY_GRAPH_ROUTE,\n },\n {\n type: \"sidebar\",\n id: MEMORY_SIDEBAR,\n displayName: \"Memory\",\n exportName: \"MemorySidebarLink\",\n },\n ],\n },\n};\n\nexport default manifest;\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
package/dist/ui/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // src/ui/index.tsx
2
- import { useCallback, useEffect as useEffect2, useLayoutEffect, useRef, useState as useState2 } from "react";
2
+ import { useCallback, useEffect as useEffect2, useLayoutEffect, useMemo, useRef, useState as useState2 } from "react";
3
3
  import {
4
4
  MarkdownBlock,
5
5
  useHostContext,
@@ -368,7 +368,7 @@ function MemorySidebarLink({ context }) {
368
368
  {
369
369
  ...navigation.linkProps("/memory-graph"),
370
370
  "aria-current": graphActive ? "page" : void 0,
371
- className: `ml-7 flex items-center gap-2 rounded-lg px-2 py-1.5 text-(length:--text-compact) transition-colors ${graphActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"}`,
371
+ className: `ml-4 flex items-center gap-2 rounded-lg px-2 py-1.5 text-(length:--text-compact) transition-colors ${graphActive ? "bg-sidebar-accent text-sidebar-accent-foreground" : "text-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"}`,
372
372
  children: [
373
373
  /* @__PURE__ */ jsx2("span", { "aria-hidden": "true", className: "flex h-4 w-4 shrink-0 items-center justify-center", children: /* @__PURE__ */ jsxs2("svg", { width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.8", strokeLinecap: "round", strokeLinejoin: "round", children: [
374
374
  /* @__PURE__ */ jsx2("circle", { cx: "5", cy: "12", r: "2" }),
@@ -376,7 +376,7 @@ function MemorySidebarLink({ context }) {
376
376
  /* @__PURE__ */ jsx2("circle", { cx: "18", cy: "19", r: "2" }),
377
377
  /* @__PURE__ */ jsx2("path", { d: "m7 11 9-5M7 13l9 5" })
378
378
  ] }) }),
379
- /* @__PURE__ */ jsx2("span", { className: "flex-1 truncate", children: "Grafo" })
379
+ /* @__PURE__ */ jsx2("span", { "data-plugin-sidebar-label": "true", className: "flex-1 truncate", children: "Graph" })
380
380
  ]
381
381
  }
382
382
  )
@@ -939,6 +939,77 @@ function MemoryPage() {
939
939
  ] })
940
940
  ] });
941
941
  }
942
+ function layoutMemoryGraph(graph) {
943
+ const nodes = graph?.nodes ?? [];
944
+ const points = /* @__PURE__ */ new Map();
945
+ if (!nodes.length) return points;
946
+ const width = 1e3;
947
+ const height = 720;
948
+ const centerX = width / 2;
949
+ const centerY = height / 2;
950
+ const hash = (value) => [...value].reduce((sum, char) => sum * 31 + char.charCodeAt(0) >>> 0, 7);
951
+ nodes.forEach((node, index) => {
952
+ const angle = index * 2.399963229728653 + hash(node.id) % 1e3 / 1e3;
953
+ const radius = node.matched ? 48 + Math.sqrt(index) * 25 : 145 + Math.sqrt(index) * 34;
954
+ points.set(node.id, {
955
+ x: centerX + Math.cos(angle) * radius,
956
+ y: centerY + Math.sin(angle) * radius * 0.72,
957
+ vx: 0,
958
+ vy: 0
959
+ });
960
+ });
961
+ const edges = (graph?.edges ?? []).flatMap((edge) => {
962
+ const source = points.get(edge.source);
963
+ const target = points.get(edge.target);
964
+ return source && target ? [{ source, target }] : [];
965
+ });
966
+ for (let step = 0; step < 180; step += 1) {
967
+ const cooling = 1 - step / 210;
968
+ const forces = nodes.map(() => ({ x: 0, y: 0 }));
969
+ for (let left = 0; left < nodes.length; left += 1) {
970
+ const a = points.get(nodes[left].id);
971
+ for (let right = left + 1; right < nodes.length; right += 1) {
972
+ const b = points.get(nodes[right].id);
973
+ const dx = a.x - b.x;
974
+ const dy = a.y - b.y;
975
+ const distanceSquared = Math.max(36, dx * dx + dy * dy);
976
+ const distance = Math.sqrt(distanceSquared);
977
+ const force = 2500 / distanceSquared * cooling;
978
+ const fx = dx / distance * force;
979
+ const fy = dy / distance * force;
980
+ forces[left].x += fx;
981
+ forces[left].y += fy;
982
+ forces[right].x -= fx;
983
+ forces[right].y -= fy;
984
+ }
985
+ }
986
+ for (const edge of edges) {
987
+ const dx = edge.target.x - edge.source.x;
988
+ const dy = edge.target.y - edge.source.y;
989
+ const distance = Math.max(1, Math.hypot(dx, dy));
990
+ const force = (distance - 132) * 6e-3 * cooling;
991
+ const fx = dx / distance * force;
992
+ const fy = dy / distance * force;
993
+ const sourceIndex = nodes.findIndex((node) => points.get(node.id) === edge.source);
994
+ const targetIndex = nodes.findIndex((node) => points.get(node.id) === edge.target);
995
+ if (sourceIndex >= 0 && targetIndex >= 0) {
996
+ forces[sourceIndex].x += fx;
997
+ forces[sourceIndex].y += fy;
998
+ forces[targetIndex].x -= fx;
999
+ forces[targetIndex].y -= fy;
1000
+ }
1001
+ }
1002
+ nodes.forEach((node, index) => {
1003
+ const point = points.get(node.id);
1004
+ const pull = node.matched ? 18e-4 : 1e-3;
1005
+ point.vx = (point.vx + forces[index].x + (centerX - point.x) * pull) * 0.82;
1006
+ point.vy = (point.vy + forces[index].y + (centerY - point.y) * pull) * 0.82;
1007
+ point.x = Math.max(45, Math.min(width - 45, point.x + point.vx));
1008
+ point.y = Math.max(45, Math.min(height - 45, point.y + point.vy));
1009
+ });
1010
+ }
1011
+ return points;
1012
+ }
942
1013
  function MemoryGraphPage() {
943
1014
  const { companyId } = useHostContext();
944
1015
  const navigation = useHostNavigation();
@@ -953,6 +1024,8 @@ function MemoryGraphPage() {
953
1024
  const [loading, setLoading] = useState2(true);
954
1025
  const [searching, setSearching] = useState2(false);
955
1026
  const [error, setError] = useState2(null);
1027
+ const [graphViewport, setGraphViewport] = useState2({ x: 0, y: 0, scale: 1 });
1028
+ const graphDrag = useRef(null);
956
1029
  useEffect2(() => {
957
1030
  let cancelled = false;
958
1031
  if (!companyId) {
@@ -984,8 +1057,9 @@ function MemoryGraphPage() {
984
1057
  setSearching(true);
985
1058
  setError(null);
986
1059
  try {
987
- const result = await searchGraph({ companyId, assignmentId, query: clean, limit: 8 });
1060
+ const result = await searchGraph({ companyId, assignmentId, query: clean, limit: 12 });
988
1061
  setGraph(result);
1062
+ setGraphViewport({ x: 0, y: 0, scale: 1 });
989
1063
  setSelectedNodeId(result.nodes[0]?.id ?? "");
990
1064
  setQuery(clean);
991
1065
  } catch (cause) {
@@ -996,18 +1070,8 @@ function MemoryGraphPage() {
996
1070
  setSearching(false);
997
1071
  }
998
1072
  }
999
- const visibleNodes = graph?.nodes.slice(0, 13) ?? [];
1000
- const center = visibleNodes[0] ?? null;
1001
- const points = /* @__PURE__ */ new Map();
1002
- visibleNodes.forEach((node, index) => {
1003
- if (index === 0) points.set(node.id, { x: 450, y: 270 });
1004
- else {
1005
- const ringIndex = index - 1;
1006
- const angle = Math.PI * 2 * ringIndex / Math.max(1, visibleNodes.length - 1) - Math.PI / 2;
1007
- const radius = ringIndex < 8 ? 190 : 250;
1008
- points.set(node.id, { x: 450 + Math.cos(angle) * radius, y: 270 + Math.sin(angle) * radius });
1009
- }
1010
- });
1073
+ const visibleNodes = graph?.nodes ?? [];
1074
+ const points = useMemo(() => layoutMemoryGraph(graph), [graph]);
1011
1075
  const visibleIds = new Set(visibleNodes.map((node) => node.id));
1012
1076
  const visibleEdges = (graph?.edges ?? []).filter((edge) => visibleIds.has(edge.source) && visibleIds.has(edge.target));
1013
1077
  if (loading) return /* @__PURE__ */ jsx2("main", { style: pageStyle, children: /* @__PURE__ */ jsx2("p", { children: "Caricamento namespace\u2026" }) });
@@ -1081,34 +1145,110 @@ function MemoryGraphPage() {
1081
1145
  graph.truncated ? /* @__PURE__ */ jsx2("span", { children: "Vista parziale; affina la ricerca per esplorare altri rami." }) : null
1082
1146
  ] }),
1083
1147
  /* @__PURE__ */ jsxs2("div", { style: { display: "grid", gridTemplateColumns: "minmax(0, 1fr) minmax(230px, 310px)", flex: 1, minHeight: 0 }, children: [
1084
- /* @__PURE__ */ jsx2("div", { style: { minWidth: 0, minHeight: 0, overflow: "auto", background: "radial-gradient(circle at center, var(--accent, rgba(127,127,127,.08)), transparent 58%)" }, children: visibleNodes.length ? /* @__PURE__ */ jsxs2("svg", { role: "img", "aria-label": `Grafo di ${visibleNodes.length} memorie`, viewBox: "0 0 900 540", style: { width: "100%", minWidth: 620, height: "100%", minHeight: 480 }, children: [
1085
- visibleEdges.map((edge, index) => {
1086
- const a = points.get(edge.source);
1087
- const b = points.get(edge.target);
1088
- if (!a || !b) return null;
1089
- return /* @__PURE__ */ jsxs2("g", { children: [
1090
- /* @__PURE__ */ jsx2("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: "var(--border)", strokeWidth: "2", strokeDasharray: edge.relation === "sibling" ? "5 5" : void 0 }),
1091
- /* @__PURE__ */ jsx2("title", { children: edge.relation === "parent" ? "Memoria padre" : edge.relation === "child" ? "Memoria figlia" : "Memoria correlata" })
1092
- ] }, `${edge.source}-${edge.target}-${edge.relation}-${index}`);
1093
- }),
1094
- visibleNodes.map((node) => {
1095
- const point = points.get(node.id);
1096
- const active = node.id === selectedNode?.id;
1097
- const lines = node.label.match(/.{1,25}(?:\s|$)/g)?.slice(0, 3).map((line) => line.trim()) ?? [node.label.slice(0, 25)];
1098
- return /* @__PURE__ */ jsxs2("g", { role: "button", tabIndex: 0, "aria-label": `Seleziona memoria: ${node.label}`, onClick: () => setSelectedNodeId(node.id), onKeyDown: (event) => {
1099
- if (event.key === "Enter" || event.key === " ") {
1148
+ /* @__PURE__ */ jsx2("div", { style: { position: "relative", minWidth: 0, minHeight: 0, overflow: "hidden", background: "radial-gradient(ellipse at center, rgba(87, 113, 132, .13), transparent 68%), #111315" }, children: visibleNodes.length ? /* @__PURE__ */ jsxs2(Fragment, { children: [
1149
+ /* @__PURE__ */ jsxs2("div", { style: { position: "absolute", zIndex: 1, top: 12, right: 12, display: "flex", gap: 6, padding: 5, border: "1px solid var(--border)", borderRadius: 9, background: "color-mix(in srgb, var(--background, #151719) 88%, transparent)" }, children: [
1150
+ /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Zoom indietro", title: "Zoom indietro", style: { ...secondaryButtonStyle, padding: "5px 10px" }, onPointerDown: (event) => event.stopPropagation(), onClick: () => setGraphViewport((view) => ({ ...view, scale: Math.max(0.55, view.scale / 1.2) })), children: "\u2212" }),
1151
+ /* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Zoom avanti", title: "Zoom avanti", style: { ...secondaryButtonStyle, padding: "5px 10px" }, onPointerDown: (event) => event.stopPropagation(), onClick: () => setGraphViewport((view) => ({ ...view, scale: Math.min(2.8, view.scale * 1.2) })), children: "\uFF0B" }),
1152
+ /* @__PURE__ */ jsx2("button", { type: "button", style: { ...secondaryButtonStyle, padding: "5px 9px", fontSize: 12 }, onPointerDown: (event) => event.stopPropagation(), onClick: () => setGraphViewport({ x: 0, y: 0, scale: 1 }), children: "Centra" })
1153
+ ] }),
1154
+ /* @__PURE__ */ jsxs2(
1155
+ "svg",
1156
+ {
1157
+ role: "application",
1158
+ "aria-label": `Grafo interattivo di ${visibleNodes.length} memorie. Trascina per spostare, usa la rotella per ingrandire.`,
1159
+ viewBox: "0 0 1000 720",
1160
+ style: { display: "block", width: "100%", height: "100%", minHeight: 480, touchAction: "none", cursor: graphDrag.current ? "grabbing" : "grab" },
1161
+ onWheel: (event) => {
1100
1162
  event.preventDefault();
1101
- setSelectedNodeId(node.id);
1102
- }
1103
- }, style: { cursor: "pointer" }, children: [
1104
- /* @__PURE__ */ jsx2("rect", { x: point.x - 83, y: point.y - 34, width: "166", height: "68", rx: "12", fill: active ? "var(--primary)" : "var(--card, #1f1f1f)", stroke: active ? "var(--primary)" : node.matched ? "var(--ring, var(--primary))" : "var(--border)", strokeWidth: node.matched ? 2 : 1.5 }),
1105
- lines.map((line, index) => /* @__PURE__ */ jsx2("text", { x: point.x, y: point.y - 5 + index * 14, textAnchor: "middle", fontSize: "11", fill: active ? "var(--primary-foreground, white)" : "var(--foreground)", children: line.slice(0, 26) }, `${node.id}-${index}`)),
1106
- node.similarity !== null ? /* @__PURE__ */ jsxs2("text", { x: point.x, y: point.y + 25, textAnchor: "middle", fontSize: "9", fill: active ? "var(--primary-foreground, white)" : "var(--muted-foreground)", children: [
1107
- Math.round(node.similarity * 100),
1108
- "% pertinenza"
1109
- ] }) : null
1110
- ] }, node.id);
1111
- })
1163
+ const rect = event.currentTarget.getBoundingClientRect();
1164
+ const fitScale = Math.min(rect.width / 1e3, rect.height / 720);
1165
+ if (!fitScale) return;
1166
+ const offsetX = (rect.width - 1e3 * fitScale) / 2;
1167
+ const offsetY = (rect.height - 720 * fitScale) / 2;
1168
+ const localX = (event.clientX - rect.left - offsetX) / fitScale;
1169
+ const localY = (event.clientY - rect.top - offsetY) / fitScale;
1170
+ const multiplier = event.deltaY < 0 ? 1.12 : 1 / 1.12;
1171
+ setGraphViewport((view) => {
1172
+ const scale = Math.max(0.55, Math.min(2.8, view.scale * multiplier));
1173
+ const ratio = scale / view.scale;
1174
+ return { scale, x: localX - (localX - view.x) * ratio, y: localY - (localY - view.y) * ratio };
1175
+ });
1176
+ },
1177
+ onPointerDown: (event) => {
1178
+ if (event.button !== 0) return;
1179
+ graphDrag.current = { startX: event.clientX, startY: event.clientY, originX: graphViewport.x, originY: graphViewport.y };
1180
+ event.currentTarget.setPointerCapture(event.pointerId);
1181
+ },
1182
+ onPointerMove: (event) => {
1183
+ const drag = graphDrag.current;
1184
+ if (!drag) return;
1185
+ const rect = event.currentTarget.getBoundingClientRect();
1186
+ const fitScale = Math.min(rect.width / 1e3, rect.height / 720);
1187
+ if (!fitScale) return;
1188
+ setGraphViewport((view) => ({
1189
+ ...view,
1190
+ x: drag.originX + (event.clientX - drag.startX) / (fitScale * graphViewport.scale),
1191
+ y: drag.originY + (event.clientY - drag.startY) / (fitScale * graphViewport.scale)
1192
+ }));
1193
+ },
1194
+ onPointerUp: () => {
1195
+ graphDrag.current = null;
1196
+ },
1197
+ onPointerCancel: () => {
1198
+ graphDrag.current = null;
1199
+ },
1200
+ children: [
1201
+ /* @__PURE__ */ jsx2("defs", { children: /* @__PURE__ */ jsx2("pattern", { id: "memory-graph-dots", width: "28", height: "28", patternUnits: "userSpaceOnUse", children: /* @__PURE__ */ jsx2("circle", { cx: "1", cy: "1", r: "1", fill: "rgba(180, 196, 205, .12)" }) }) }),
1202
+ /* @__PURE__ */ jsx2("rect", { width: "1000", height: "720", fill: "url(#memory-graph-dots)" }),
1203
+ /* @__PURE__ */ jsxs2("g", { transform: `translate(${graphViewport.x} ${graphViewport.y}) scale(${graphViewport.scale})`, children: [
1204
+ visibleEdges.map((edge, index) => {
1205
+ const a = points.get(edge.source);
1206
+ const b = points.get(edge.target);
1207
+ if (!a || !b) return null;
1208
+ return /* @__PURE__ */ jsxs2("g", { children: [
1209
+ /* @__PURE__ */ jsx2("line", { x1: a.x, y1: a.y, x2: b.x, y2: b.y, stroke: edge.relation === "sibling" ? "rgba(145, 164, 177, .38)" : "rgba(173, 194, 207, .62)", strokeWidth: edge.relation === "sibling" ? 1 : 1.25, strokeDasharray: edge.relation === "sibling" ? "4 6" : void 0 }),
1210
+ /* @__PURE__ */ jsx2("title", { children: edge.relation === "parent" ? "Memoria padre" : edge.relation === "child" ? "Memoria figlia" : "Memoria correlata" })
1211
+ ] }, `${edge.source}-${edge.target}-${edge.relation}-${index}`);
1212
+ }),
1213
+ visibleNodes.map((node) => {
1214
+ const point = points.get(node.id);
1215
+ if (!point) return null;
1216
+ const active = node.id === selectedNode?.id;
1217
+ const label = node.label.length > 38 ? `${node.label.slice(0, 36)}\u2026` : node.label;
1218
+ const fill = active ? "var(--primary, #e7c46a)" : node.matched ? "#b8d77a" : "#8cbad0";
1219
+ return /* @__PURE__ */ jsxs2(
1220
+ "g",
1221
+ {
1222
+ role: "button",
1223
+ tabIndex: 0,
1224
+ "aria-pressed": active,
1225
+ "aria-label": `Seleziona memoria: ${node.label}`,
1226
+ onPointerDown: (event) => event.stopPropagation(),
1227
+ onClick: () => setSelectedNodeId(node.id),
1228
+ onKeyDown: (event) => {
1229
+ if (event.key === "Enter" || event.key === " ") {
1230
+ event.preventDefault();
1231
+ setSelectedNodeId(node.id);
1232
+ }
1233
+ },
1234
+ style: { cursor: "pointer" },
1235
+ children: [
1236
+ /* @__PURE__ */ jsxs2("title", { children: [
1237
+ node.label,
1238
+ node.similarity !== null ? ` \xB7 ${Math.round(node.similarity * 100)}%` : ""
1239
+ ] }),
1240
+ active ? /* @__PURE__ */ jsx2("circle", { cx: point.x, cy: point.y, r: "15", fill, opacity: ".16" }) : null,
1241
+ /* @__PURE__ */ jsx2("circle", { cx: point.x, cy: point.y, r: active ? 7 : node.matched ? 6 : 4.5, fill, stroke: active ? "#f6e8be" : "rgba(12, 16, 18, .9)", strokeWidth: active ? 2 : 1.5 }),
1242
+ /* @__PURE__ */ jsx2("text", { x: point.x + 12, y: point.y + 4, fontSize: "11", fill: active ? "#fff4d2" : "#c6d0d5", stroke: "#111315", strokeWidth: "3", strokeLinejoin: "round", paintOrder: "stroke", children: label })
1243
+ ]
1244
+ },
1245
+ node.id
1246
+ );
1247
+ })
1248
+ ] })
1249
+ ]
1250
+ }
1251
+ )
1112
1252
  ] }) : /* @__PURE__ */ jsx2("div", { style: { ...mutedStyle, padding: 32, textAlign: "center" }, children: "Nessuna memoria trovata nel namespace per questa ricerca." }) }),
1113
1253
  /* @__PURE__ */ jsxs2("aside", { style: { borderLeft: "1px solid var(--border)", padding: 14, display: "flex", flexDirection: "column", minWidth: 0, minHeight: 0, gap: 12 }, children: [
1114
1254
  /* @__PURE__ */ jsxs2("div", { style: { display: "flex", justifyContent: "space-between", gap: 8, alignItems: "start" }, children: [