diff --git a/.gitignore b/.gitignore
index c53c2817..ed5349ee 100644
--- a/.gitignore
+++ b/.gitignore
@@ -117,3 +117,6 @@ node_modules/
# Frontend build artifacts (generated by Vite — do not track in git)
semantica/static/
+
+# Local graph explorer test datasets
+demo_out/
diff --git a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
index df30b0e5..e512e4bd 100644
--- a/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
+++ b/explorer/src/workspaces/GraphWorkspace/GraphInspectorPanel.tsx
@@ -173,6 +173,14 @@ export function GraphInspectorPanel({
);
}
+ if (!graph.hasNode(nodeId)) {
+ return (
+
+ Selected item is not available for inspection in the current graph.
+
+ );
+ }
+
const attributes = graph.getNodeAttributes(nodeId) as {
color?: string;
content?: string;
diff --git a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
index b8e9c6b8..06183c7d 100644
--- a/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
+++ b/explorer/src/workspaces/GraphWorkspace/GraphWorkspace.tsx
@@ -13,6 +13,7 @@ import { GraphLoadingOverlay } from "./GraphLoadingOverlay";
import { createGraphLoadProgress, getGraphLoadTitle } from "./graphLoading";
import { GRAPH_THEME, withAlpha } from "./graphTheme";
import { resolveDisplayGraph, resolveDisplayStateSnapshot } from "./graphSceneState";
+import { computeGraphAnalyticsBase } from "./graphAnalytics";
import {
type GraphPlugin,
type GraphPluginActionRequest,
@@ -844,9 +845,17 @@ export function GraphWorkspace() {
};
}, [debouncedTime, isLoading]);
- const focusNode = useCallback((nodeId: string) => {
+ const resolveNodeIdForFocusedMode = useCallback((nodeId: string): string | null => {
+ if (!nodeId) {
+ return null;
+ }
+
+ if (graph.hasNode(nodeId)) {
+ return nodeId;
+ }
+
const currentDisplayGraph = pluginRuntimeRef.current?.displayGraph ?? graph;
- if (viewMode === "grouped" && currentDisplayGraph.hasNode(nodeId)) {
+ if (currentDisplayGraph.hasNode(nodeId)) {
const displayAttrs = currentDisplayGraph.getNodeAttributes(nodeId) as NodeAttributes;
const communityGroup = displayAttrs.properties?.__communityGroup as
| {
@@ -854,19 +863,37 @@ export function GraphWorkspace() {
sampleNodeIds?: string[];
}
| undefined;
+
const anchorNodeId = communityGroup?.anchorNodeId || communityGroup?.sampleNodeIds?.[0] || "";
if (anchorNodeId && graph.hasNode(anchorNodeId)) {
- setViewMode("focused");
- setSelectedNodeId(anchorNodeId);
- setSelectedEdgeId("");
- setPathResult(null);
- setSearchResults([]);
- setSearchError("");
- setIsLayoutRunning(false);
- return;
+ return anchorNodeId;
}
}
+ return null;
+ }, []);
+
+ const activateFocusedMode = useCallback(() => {
+ const canonicalNodeId = resolveNodeIdForFocusedMode(selectedNodeId);
+ if (!canonicalNodeId) {
+ return;
+ }
+
+ if (canonicalNodeId !== selectedNodeId) {
+ setSelectedNodeId(canonicalNodeId);
+ }
+ setViewMode("focused");
+ }, [resolveNodeIdForFocusedMode, selectedNodeId]);
+ const [canActivateFocusedMode, setCanActivateFocusedMode] = useState(false);
+ useEffect(() => {
+ setCanActivateFocusedMode(Boolean(resolveNodeIdForFocusedMode(selectedNodeId)));
+ }, [pluginRuntimeVersion, resolveNodeIdForFocusedMode, selectedNodeId]);
+
+ const focusNode = useCallback((nodeId: string) => {
+ if (!nodeId) {
+ return;
+ }
+
setSelectedNodeId(nodeId);
setSelectedEdgeId("");
setPathResult(null);
@@ -1041,15 +1068,21 @@ export function GraphWorkspace() {
}, [collapsedNeighborhoodNodeIds, selectedNodeId, viewMode]);
const structuralActivePath = structuralSelectedNodeId ? activePath : EMPTY_PATH;
const structuralActivePathEdgeIds = structuralSelectedNodeId ? activePathEdgeIds : EMPTY_PATH;
+ const groupedViewAvailable = useMemo(
+ () => computeGraphAnalyticsBase(graph, { computeCommunities: true, computeCentrality: false }).communitiesByNode.size > 0,
+ [graphVersion],
+ );
const displayResult = useMemo(
() => resolveDisplayGraph(structuralSelectedNodeId, structuralActivePath, structuralActivePathEdgeIds, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
+ groupedViewAvailable,
}),
[
aggregationEnabled,
collapsedNeighborhoodNodeIds,
graphVersion,
+ groupedViewAvailable,
structuralActivePath,
structuralActivePathEdgeIds,
structuralSelectedNodeId,
@@ -1060,8 +1093,9 @@ export function GraphWorkspace() {
() => resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
+ groupedViewAvailable,
}),
- [activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, graphVersion, selectedNodeId, viewMode],
+ [activePath, aggregationEnabled, collapsedNeighborhoodNodeIds, groupedViewAvailable, selectedNodeId, viewMode],
);
const displayMeta = displayResult.meta;
const previousDisplayGraphRef = useRef(displayResult.graph);
@@ -1228,6 +1262,10 @@ export function GraphWorkspace() {
focusNode(action.nodeId);
return;
case "setViewMode":
+ if (action.viewMode === "focused") {
+ activateFocusedMode();
+ return;
+ }
setViewMode(action.viewMode);
return;
case "collapseNeighborhood":
@@ -1280,7 +1318,7 @@ export function GraphWorkspace() {
setActiveDockPanelId((previous) => (previous === action.panelId ? null : previous));
return;
}
- }, [focusNode, selectedNodeId, setEffectToggle]);
+ }, [activateFocusedMode, focusNode, selectedNodeId, setEffectToggle]);
const diagnosticsSnapshot = useMemo(() => {
if (!GRAPH_THEME.effects.diagnostics.enabledInDev || !graphDiagnosticsState) {
@@ -1476,8 +1514,8 @@ export function GraphWorkspace() {
label: "Focused",
title: "Inspect the selected node in a focused local graph",
active: viewMode === "focused",
- disabled: !selectedNodeId,
- onClick: () => setViewMode("focused"),
+ disabled: viewMode !== "focused" && !canActivateFocusedMode,
+ onClick: activateFocusedMode,
},
],
});
@@ -1567,6 +1605,8 @@ export function GraphWorkspace() {
return groups;
}, [
+ activateFocusedMode,
+ canActivateFocusedMode,
displayState.groupedViewAvailable,
handlePluginAction,
hasGraphContent,
diff --git a/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
index bea7f64c..c70bb734 100644
--- a/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
+++ b/explorer/src/workspaces/GraphWorkspace/graphSceneState.ts
@@ -1242,6 +1242,7 @@ export function resolveDisplayStateSnapshot(
options?: {
aggregationEnabled?: boolean;
collapsedNeighborhoodNodeIds?: Iterable;
+ groupedViewAvailable?: boolean;
},
): GraphDisplayStateSnapshot {
const aggregationEnabled = options?.aggregationEnabled ?? true;
@@ -1249,7 +1250,7 @@ export function resolveDisplayStateSnapshot(
Array.from(options?.collapsedNeighborhoodNodeIds ?? []).filter((nodeId) => typeof nodeId === "string"),
);
const displayState = createEmptyDisplayState(selectedNodeId, aggregationEnabled);
- displayState.groupedViewAvailable = computeGraphAnalyticsBase(graph, {
+ displayState.groupedViewAvailable = options?.groupedViewAvailable ?? computeGraphAnalyticsBase(graph, {
computeCommunities: true,
computeCentrality: false,
}).communitiesByNode.size > 0;
@@ -1395,6 +1396,7 @@ export function resolveDisplayGraph(
options?: {
aggregationEnabled?: boolean;
collapsedNeighborhoodNodeIds?: Iterable;
+ groupedViewAvailable?: boolean;
},
): GraphDisplayResult {
const aggregationEnabled = options?.aggregationEnabled ?? true;
@@ -1404,6 +1406,7 @@ export function resolveDisplayGraph(
const displayState = resolveDisplayStateSnapshot(selectedNodeId, activePath, viewMode, {
aggregationEnabled,
collapsedNeighborhoodNodeIds,
+ groupedViewAvailable: options?.groupedViewAvailable,
});
const isFocusedView = viewMode === "focused" && Boolean(selectedNodeId) && graph.hasNode(selectedNodeId);
const isGroupedView = viewMode === "grouped";
diff --git a/plugins/.claude-plugin/README.md b/plugins/.claude-plugin/README.md
index bdf90fcb..6fa56eef 100644
--- a/plugins/.claude-plugin/README.md
+++ b/plugins/.claude-plugin/README.md
@@ -91,7 +91,8 @@ claude --plugin-dir ./plugins
Or inside a session:
```bash
-/plugin install ./plugins
+/plugin marketplace add ./plugins
+/plugin install semantica@semantica-local
```
Verify:
diff --git a/plugins/.claude-plugin/marketplace.json b/plugins/.claude-plugin/marketplace.json
index a4e48a56..cbe8b924 100644
--- a/plugins/.claude-plugin/marketplace.json
+++ b/plugins/.claude-plugin/marketplace.json
@@ -1,5 +1,9 @@
{
"name": "semantica-local",
+ "owner": {
+ "name": "Hawksight AI",
+ "url": "https://github.com/Hawksight-AI/semantica"
+ },
"plugins": [
{
"name": "semantica",
diff --git a/plugins/.claude-plugin/plugin.json b/plugins/.claude-plugin/plugin.json
index 2f21a77d..fd5d35e7 100644
--- a/plugins/.claude-plugin/plugin.json
+++ b/plugins/.claude-plugin/plugin.json
@@ -25,6 +25,5 @@
"mcp"
],
"skills": "./skills",
- "agents": "./agents",
- "hooks": "./hooks/hooks.json"
+ "agents": "./agents"
}
diff --git a/plugins/agents/decision-advisor/AGENT.md b/plugins/agents/decision-advisor.md
similarity index 100%
rename from plugins/agents/decision-advisor/AGENT.md
rename to plugins/agents/decision-advisor.md
diff --git a/plugins/agents/explainability/AGENT.md b/plugins/agents/explainability.md
similarity index 100%
rename from plugins/agents/explainability/AGENT.md
rename to plugins/agents/explainability.md
diff --git a/plugins/agents/kg-assistant/AGENT.md b/plugins/agents/kg-assistant.md
similarity index 100%
rename from plugins/agents/kg-assistant/AGENT.md
rename to plugins/agents/kg-assistant.md