From bcd4be616c8f9c301ac2eaf3d8b3418b9e577b1e Mon Sep 17 00:00:00 2001 From: Aashir Athar Date: Fri, 29 May 2026 22:33:28 +0500 Subject: [PATCH] feat(intelligence): add Namespace Overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new read-only "Namespaces" tab — the first lens whose primary axis is the namespace dimension. Shows how the user's knowledge is distributed across contexts: the distinct facts and entities recorded in each namespace, so lopsided or empty contexts are visible at a glance. - Pure deterministic engine (lib/memory/namespaceOverview.ts): groups relations by their namespace field (null = un-namespaced), counting distinct triples and distinct entities per namespace plus a global distinct-entity total. No clock, no RNG; sorted by factCount desc, namespace asc, with the un-namespaced bucket last. Collision-free triple keys. - Zero new core surface: reuses ONLY memoryGraphQuery (one all-namespaces call, grouped client-side). Read-only. No namespace selector — this view shows them all — so it needs no per-namespace re-query. - Container guards the load with a request token; summary tiles (namespaces / facts / entities) + a ranked per-namespace bar list, capped with a truncation note. i18n across all 13 locales. Co-Authored-By: Claude Opus 4.7 --- .../NamespaceOverviewPanel.test.tsx | 57 ++++++ .../intelligence/NamespaceOverviewPanel.tsx | 186 ++++++++++++++++++ .../NamespaceOverviewTab.test.tsx | 49 +++++ .../intelligence/NamespaceOverviewTab.tsx | 49 +++++ app/src/lib/i18n/chunks/ar-1.ts | 19 ++ app/src/lib/i18n/chunks/bn-1.ts | 19 ++ app/src/lib/i18n/chunks/de-1.ts | 19 ++ app/src/lib/i18n/chunks/en-1.ts | 19 ++ app/src/lib/i18n/chunks/es-1.ts | 19 ++ app/src/lib/i18n/chunks/fr-1.ts | 19 ++ app/src/lib/i18n/chunks/hi-1.ts | 19 ++ app/src/lib/i18n/chunks/id-1.ts | 19 ++ app/src/lib/i18n/chunks/it-1.ts | 19 ++ app/src/lib/i18n/chunks/ko-1.ts | 19 ++ app/src/lib/i18n/chunks/pt-1.ts | 19 ++ app/src/lib/i18n/chunks/ru-1.ts | 19 ++ app/src/lib/i18n/chunks/zh-CN-1.ts | 19 ++ app/src/lib/i18n/en.ts | 19 ++ app/src/lib/memory/namespaceOverview.test.ts | 80 ++++++++ app/src/lib/memory/namespaceOverview.ts | 108 ++++++++++ app/src/pages/Intelligence.tsx | 6 +- .../services/api/namespaceOverviewApi.test.ts | 57 ++++++ app/src/services/api/namespaceOverviewApi.ts | 26 +++ 23 files changed, 883 insertions(+), 1 deletion(-) create mode 100644 app/src/components/intelligence/NamespaceOverviewPanel.test.tsx create mode 100644 app/src/components/intelligence/NamespaceOverviewPanel.tsx create mode 100644 app/src/components/intelligence/NamespaceOverviewTab.test.tsx create mode 100644 app/src/components/intelligence/NamespaceOverviewTab.tsx create mode 100644 app/src/lib/memory/namespaceOverview.test.ts create mode 100644 app/src/lib/memory/namespaceOverview.ts create mode 100644 app/src/services/api/namespaceOverviewApi.test.ts create mode 100644 app/src/services/api/namespaceOverviewApi.ts diff --git a/app/src/components/intelligence/NamespaceOverviewPanel.test.tsx b/app/src/components/intelligence/NamespaceOverviewPanel.test.tsx new file mode 100644 index 0000000000..9d91dcb8f4 --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewPanel.test.tsx @@ -0,0 +1,57 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import NamespaceOverviewPanel from './NamespaceOverviewPanel'; + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeNamespaceOverview([ + rel('work', 'A', 'B'), + rel('work', 'B', 'C'), + rel(null, 'P', 'Q'), +]); + +describe('', () => { + it('renders the loading skeleton', () => { + render(); + expect(screen.getByTestId('namespace-overview-loading')).toBeInTheDocument(); + }); + + it('renders the empty state when there are no namespaces', () => { + render(); + expect(screen.getByText('No knowledge graph yet.')).toBeInTheDocument(); + }); + + it('renders an error with a working retry button', () => { + const onRetry = vi.fn(); + render(); + expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/); + fireEvent.click(screen.getByRole('button', { name: 'Retry' })); + expect(onRetry).toHaveBeenCalledTimes(1); + }); + + it('renders summary tiles and the per-namespace list (un-namespaced labeled)', () => { + render(); + expect(screen.getByText('Namespaces')).toBeInTheDocument(); + expect(screen.getByText('Facts')).toBeInTheDocument(); + expect(screen.getByText('By namespace')).toBeInTheDocument(); + expect(screen.getByText('work')).toBeInTheDocument(); + // the null namespace renders with the "un-namespaced" label + expect(screen.getByText('(un-namespaced)')).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/intelligence/NamespaceOverviewPanel.tsx b/app/src/components/intelligence/NamespaceOverviewPanel.tsx new file mode 100644 index 0000000000..a9a98a6a6d --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewPanel.tsx @@ -0,0 +1,186 @@ +/** + * Namespace Overview — presentational view. Pure: renders per-namespace fact / + * entity counts as a ranked bar list + summary tiles. No data fetching, no + * clock, no RNG. + */ +import { useT } from '../../lib/i18n/I18nContext'; +import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview'; + +const MAX_ROWS = 50; + +interface NamespaceOverviewPanelProps { + report: NamespaceOverviewReport | null; + loading?: boolean; + error?: string | null; + onRetry?: () => void; +} + +const NamespaceOverviewPanel = ({ + report, + loading, + error, + onRetry, +}: NamespaceOverviewPanelProps) => { + const { t } = useT(); + + const intro = ( +
+

{t('namespaceOverview.title')}

+

{t('namespaceOverview.intro')}

+
+ ); + + if (loading) { + return ( +
+ {intro} +
+
+ {[0, 1, 2].map(i => ( +
+ ))} +
+ {[0, 1, 2].map(i => ( +
+ ))} +
+
+ ); + } + + if (error) { + return ( +
+ {intro} +
+

+ {t('namespaceOverview.errorPrefix')} {error} +

+ {onRetry && ( + + )} +
+
+ ); + } + + if (!report || report.namespaceCount === 0) { + return ( +
+ {intro} +
+

+ {t('namespaceOverview.empty')} +

+

+ {t('namespaceOverview.emptyHint')} +

+
+
+ ); + } + + const maxFacts = report.namespaces[0]?.factCount || 1; + const rows = report.namespaces.slice(0, MAX_ROWS); + const truncated = report.namespaces.length > MAX_ROWS; + + return ( +
+ {intro} + + {/* Summary tiles */} +
+ {[ + { label: t('namespaceOverview.metricNamespaces'), value: report.namespaceCount }, + { label: t('namespaceOverview.metricFacts'), value: report.totalFacts }, + { label: t('namespaceOverview.metricEntities'), value: report.totalEntities }, + ].map(tile => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ ))} +
+ + {/* Ranked namespace list */} +
+

+ {t('namespaceOverview.heading')} +

+
    + {rows.map(stat => ( +
  • + + {stat.namespace ?? t('namespaceOverview.unnamespaced')} + +
    +
    +
    + + {stat.factCount} + + + {t('namespaceOverview.entitiesShort').replace('{count}', String(stat.entityCount))} + +
  • + ))} +
+ {truncated && ( +

+ {t('namespaceOverview.truncated') + .replace('{shown}', String(rows.length)) + .replace('{total}', String(report.namespaces.length))} +

+ )} +
+
+ ); +}; + +export default NamespaceOverviewPanel; diff --git a/app/src/components/intelligence/NamespaceOverviewTab.test.tsx b/app/src/components/intelligence/NamespaceOverviewTab.test.tsx new file mode 100644 index 0000000000..c82b7da49c --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewTab.test.tsx @@ -0,0 +1,49 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import NamespaceOverviewTab from './NamespaceOverviewTab'; + +const mockLoad = vi.fn(); + +vi.mock('../../services/api/namespaceOverviewApi', () => ({ + loadNamespaceOverview: (...args: unknown[]) => mockLoad(...args), +})); + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +const report = computeNamespaceOverview([rel('work', 'A', 'B')]); + +describe('', () => { + beforeEach(() => { + mockLoad.mockReset(); + mockLoad.mockResolvedValue(report); + }); + + it('loads on mount and renders the per-namespace list', async () => { + render(); + await waitFor(() => expect(screen.getByText('By namespace')).toBeInTheDocument()); + expect(mockLoad).toHaveBeenCalledTimes(1); + }); + + it('surfaces an error when the load fails', async () => { + mockLoad.mockReset(); + mockLoad.mockRejectedValueOnce(new Error('graph unavailable')); + render(); + await waitFor(() => expect(screen.getByRole('alert').textContent).toMatch(/graph unavailable/)); + }); +}); diff --git a/app/src/components/intelligence/NamespaceOverviewTab.tsx b/app/src/components/intelligence/NamespaceOverviewTab.tsx new file mode 100644 index 0000000000..1a7adb5e3c --- /dev/null +++ b/app/src/components/intelligence/NamespaceOverviewTab.tsx @@ -0,0 +1,49 @@ +/** + * Namespace Overview tab (container). Loads the whole graph on mount and + * delegates rendering to the pure . Read-only. No + * namespace selector — this view's axis IS the namespace, so it shows them all. + */ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import type { NamespaceOverviewReport } from '../../lib/memory/namespaceOverview'; +import { loadNamespaceOverview } from '../../services/api/namespaceOverviewApi'; +import NamespaceOverviewPanel from './NamespaceOverviewPanel'; + +const NamespaceOverviewTab = () => { + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + // Monotonic token: ignore a response if a newer load has since started. + const latestRequestId = useRef(0); + + const load = useCallback(async () => { + const requestId = (latestRequestId.current += 1); + setLoading(true); + setError(null); + try { + const next = await loadNamespaceOverview(); + if (requestId !== latestRequestId.current) return; + setReport(next); + } catch (err) { + if (requestId !== latestRequestId.current) return; + setError(err instanceof Error ? err.message : String(err)); + } finally { + if (requestId === latestRequestId.current) setLoading(false); + } + }, []); + + useEffect(() => { + void load(); + }, [load]); + + return ( + void load()} + /> + ); +}; + +export default NamespaceOverviewTab; diff --git a/app/src/lib/i18n/chunks/ar-1.ts b/app/src/lib/i18n/chunks/ar-1.ts index 9474534baf..5439156321 100644 --- a/app/src/lib/i18n/chunks/ar-1.ts +++ b/app/src/lib/i18n/chunks/ar-1.ts @@ -1603,6 +1603,25 @@ const ar1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default ar1; diff --git a/app/src/lib/i18n/chunks/bn-1.ts b/app/src/lib/i18n/chunks/bn-1.ts index 3395f87a79..c4b23b5f81 100644 --- a/app/src/lib/i18n/chunks/bn-1.ts +++ b/app/src/lib/i18n/chunks/bn-1.ts @@ -1612,6 +1612,25 @@ const bn1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default bn1; diff --git a/app/src/lib/i18n/chunks/de-1.ts b/app/src/lib/i18n/chunks/de-1.ts index f6fd0d87f0..34745127d3 100644 --- a/app/src/lib/i18n/chunks/de-1.ts +++ b/app/src/lib/i18n/chunks/de-1.ts @@ -1630,6 +1630,25 @@ const de1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default de1; diff --git a/app/src/lib/i18n/chunks/en-1.ts b/app/src/lib/i18n/chunks/en-1.ts index f0421e4fa6..d89babb6a7 100644 --- a/app/src/lib/i18n/chunks/en-1.ts +++ b/app/src/lib/i18n/chunks/en-1.ts @@ -492,8 +492,27 @@ const en1: TranslationMap = { 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Namespaces', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', 'graphCentrality.title': 'Knowledge Graph Centrality', 'graphCentrality.intro': 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', diff --git a/app/src/lib/i18n/chunks/es-1.ts b/app/src/lib/i18n/chunks/es-1.ts index 5a93a235c3..35c21096a3 100644 --- a/app/src/lib/i18n/chunks/es-1.ts +++ b/app/src/lib/i18n/chunks/es-1.ts @@ -1627,6 +1627,25 @@ const es1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default es1; diff --git a/app/src/lib/i18n/chunks/fr-1.ts b/app/src/lib/i18n/chunks/fr-1.ts index 8072348586..b6fac1f8dc 100644 --- a/app/src/lib/i18n/chunks/fr-1.ts +++ b/app/src/lib/i18n/chunks/fr-1.ts @@ -1632,6 +1632,25 @@ const fr1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default fr1; diff --git a/app/src/lib/i18n/chunks/hi-1.ts b/app/src/lib/i18n/chunks/hi-1.ts index 3394cdeb25..f15f5e3843 100644 --- a/app/src/lib/i18n/chunks/hi-1.ts +++ b/app/src/lib/i18n/chunks/hi-1.ts @@ -1611,6 +1611,25 @@ const hi1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default hi1; diff --git a/app/src/lib/i18n/chunks/id-1.ts b/app/src/lib/i18n/chunks/id-1.ts index f384fad0b7..ab4ed0af68 100644 --- a/app/src/lib/i18n/chunks/id-1.ts +++ b/app/src/lib/i18n/chunks/id-1.ts @@ -1615,6 +1615,25 @@ const id1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default id1; diff --git a/app/src/lib/i18n/chunks/it-1.ts b/app/src/lib/i18n/chunks/it-1.ts index 4e4aa6ce18..0f4848e423 100644 --- a/app/src/lib/i18n/chunks/it-1.ts +++ b/app/src/lib/i18n/chunks/it-1.ts @@ -1620,6 +1620,25 @@ const it1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default it1; diff --git a/app/src/lib/i18n/chunks/ko-1.ts b/app/src/lib/i18n/chunks/ko-1.ts index 48f50f677b..fb997c8ba8 100644 --- a/app/src/lib/i18n/chunks/ko-1.ts +++ b/app/src/lib/i18n/chunks/ko-1.ts @@ -1612,5 +1612,24 @@ const ko1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default ko1; diff --git a/app/src/lib/i18n/chunks/pt-1.ts b/app/src/lib/i18n/chunks/pt-1.ts index 7d4c4d85e2..d602fc4297 100644 --- a/app/src/lib/i18n/chunks/pt-1.ts +++ b/app/src/lib/i18n/chunks/pt-1.ts @@ -1625,6 +1625,25 @@ const pt1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default pt1; diff --git a/app/src/lib/i18n/chunks/ru-1.ts b/app/src/lib/i18n/chunks/ru-1.ts index 6f6ebc7bc9..7dfed2f883 100644 --- a/app/src/lib/i18n/chunks/ru-1.ts +++ b/app/src/lib/i18n/chunks/ru-1.ts @@ -1615,6 +1615,25 @@ const ru1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default ru1; diff --git a/app/src/lib/i18n/chunks/zh-CN-1.ts b/app/src/lib/i18n/chunks/zh-CN-1.ts index 4bf16155d8..9413df8f7e 100644 --- a/app/src/lib/i18n/chunks/zh-CN-1.ts +++ b/app/src/lib/i18n/chunks/zh-CN-1.ts @@ -1596,6 +1596,25 @@ const zhCN1: TranslationMap = { 'graphCentrality.bridgeBadge': 'connector', 'graphCentrality.bridgeTitle': 'Connector — more influential than its link count suggests', 'graphCentrality.degreeTitle': '{in} in · {out} out', + 'memory.tab.namespaces': 'Namespaces', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', }; export default zhCN1; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 1c3efbb1e5..ec911c6767 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -300,8 +300,27 @@ const en: TranslationMap = { 'memory.tab.calls': 'Calls', 'memory.tab.diagram': 'Diagram', 'memory.tab.centrality': 'Centrality', + 'memory.tab.namespaces': 'Namespaces', 'memory.tab.settings': 'Settings', 'memory.analyzeNow': 'Analyze Now', + 'namespaceOverview.title': 'Namespace Overview', + 'namespaceOverview.intro': + 'How your knowledge is distributed across contexts — the number of facts and distinct entities recorded in each namespace.', + 'namespaceOverview.loading': 'Aggregating namespaces…', + 'namespaceOverview.errorPrefix': 'Could not load the graph:', + 'namespaceOverview.retry': 'Retry', + 'namespaceOverview.empty': 'No knowledge graph yet.', + 'namespaceOverview.emptyHint': + 'As the assistant records facts across contexts, each namespace will appear here.', + 'namespaceOverview.metricNamespaces': 'Namespaces', + 'namespaceOverview.metricFacts': 'Facts', + 'namespaceOverview.metricEntities': 'Entities', + 'namespaceOverview.heading': 'By namespace', + 'namespaceOverview.unnamespaced': '(un-namespaced)', + 'namespaceOverview.factsLabel': '{count} facts', + 'namespaceOverview.entitiesLabel': '{count} entities', + 'namespaceOverview.entitiesShort': '{count} ent.', + 'namespaceOverview.truncated': 'Showing the top {shown} of {total} namespaces.', 'graphCentrality.title': 'Knowledge Graph Centrality', 'graphCentrality.intro': 'PageRank over your memory graph surfaces the load-bearing hubs — and the connector entities that link otherwise-separate clusters, which a raw frequency count cannot reveal.', diff --git a/app/src/lib/memory/namespaceOverview.test.ts b/app/src/lib/memory/namespaceOverview.test.ts new file mode 100644 index 0000000000..9472daf8d1 --- /dev/null +++ b/app/src/lib/memory/namespaceOverview.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { computeNamespaceOverview } from './namespaceOverview'; + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('computeNamespaceOverview', () => { + it('returns an empty report for no relations', () => { + expect(computeNamespaceOverview([])).toEqual({ + namespaces: [], + namespaceCount: 0, + totalFacts: 0, + totalEntities: 0, + }); + }); + + it('aggregates distinct facts and entities per namespace', () => { + const r = computeNamespaceOverview([ + rel('work', 'A', 'B'), + rel('work', 'B', 'C'), + rel('personal', 'X', 'Y'), + rel(null, 'P', 'Q'), + ]); + expect(r.namespaceCount).toBe(3); + expect(r.totalFacts).toBe(4); + expect(r.totalEntities).toBe(7); // A,B,C,X,Y,P,Q + // Sorted by factCount desc; ties by namespace asc with null last. + expect(r.namespaces).toEqual([ + { namespace: 'work', factCount: 2, entityCount: 3 }, + { namespace: 'personal', factCount: 1, entityCount: 2 }, + { namespace: null, factCount: 1, entityCount: 2 }, + ]); + }); + + it('de-duplicates repeated triples within a namespace', () => { + const r = computeNamespaceOverview([rel('work', 'A', 'B'), rel('work', 'A', 'B')]); + expect(r.namespaces[0].factCount).toBe(1); + expect(r.totalFacts).toBe(1); + }); + + it('counts a shared entity per-namespace but once globally', () => { + const r = computeNamespaceOverview([rel('work', 'A', 'B'), rel('personal', 'A', 'C')]); + const byNs = Object.fromEntries(r.namespaces.map(s => [s.namespace, s])); + expect(byNs.work.entityCount).toBe(2); // A, B + expect(byNs.personal.entityCount).toBe(2); // A, C + expect(r.totalEntities).toBe(3); // A counted once globally + }); + + it('sorts the un-namespaced (null) bucket last on a tie', () => { + const r = computeNamespaceOverview([rel(null, 'A', 'B'), rel('aaa', 'C', 'D')]); + expect(r.namespaces.map(s => s.namespace)).toEqual(['aaa', null]); + }); + + it('drops malformed relations with a non-string field', () => { + const malformed = { ...rel('work', 'A', 'B'), object: null as unknown as string }; + const r = computeNamespaceOverview([rel('work', 'A', 'B'), malformed, rel('work', 'C', 'D')]); + expect(r.totalFacts).toBe(2); + }); + + it('is invariant to relation order', () => { + const triples = [rel('work', 'A', 'B'), rel('personal', 'X', 'Y'), rel('work', 'B', 'C')]; + const forward = computeNamespaceOverview(triples); + const reversed = computeNamespaceOverview([...triples].reverse()); + expect(reversed).toEqual(forward); + }); +}); diff --git a/app/src/lib/memory/namespaceOverview.ts b/app/src/lib/memory/namespaceOverview.ts new file mode 100644 index 0000000000..ffd384a6f0 --- /dev/null +++ b/app/src/lib/memory/namespaceOverview.ts @@ -0,0 +1,108 @@ +/** + * Namespace Overview — pure per-namespace aggregation engine. + * + * Every memory fact carries a `namespace` (e.g. "work", "personal", or null for + * un-namespaced). This is the only lens that uses the NAMESPACE as its primary + * axis: it shows how the user's knowledge is distributed across contexts — how + * many facts and distinct entities live in each — so lopsided or empty contexts + * are visible at a glance. + * + * Everything here is PURE and DETERMINISTIC: no React, no RPC, no clock, no + * randomness. Output depends only on the relations, never on insertion order. + * Facts are de-duplicated per namespace by their (subject, predicate, object) + * triple; an entity counts once per namespace it appears in. + */ +import type { GraphRelation } from '../../utils/tauriCommands/memory'; + +export interface NamespaceStat { + namespace: string | null; // raw namespace; null means un-namespaced + factCount: number; // distinct (subject, predicate, object) triples in this namespace + entityCount: number; // distinct entities (subject or object) in this namespace +} + +export interface NamespaceOverviewReport { + namespaces: NamespaceStat[]; // sorted by factCount desc, then namespace asc (null last) + namespaceCount: number; + totalFacts: number; // distinct triples summed across namespaces + totalEntities: number; // distinct entities across the WHOLE graph (deduped globally) +} + +const EMPTY_REPORT: NamespaceOverviewReport = { + namespaces: [], + namespaceCount: 0, + totalFacts: 0, + totalEntities: 0, +}; + +/** Canonical, collision-free key for a directed triple. */ +function tripleKey(subject: string, predicate: string, object: string): string { + return JSON.stringify([subject, predicate, object]); +} + +function compareIds(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +interface Bucket { + facts: Set; // distinct triple keys + entities: Set; // distinct entity ids +} + +/** + * Compute the per-namespace overview. Pure function of `relations`. + */ +export function computeNamespaceOverview(relations: GraphRelation[]): NamespaceOverviewReport { + const buckets = new Map(); + const allEntities = new Set(); + const ensure = (ns: string | null): Bucket => { + let bucket = buckets.get(ns); + if (!bucket) { + bucket = { facts: new Set(), entities: new Set() }; + buckets.set(ns, bucket); + } + return bucket; + }; + for (const relation of relations) { + const { subject, predicate, object } = relation; + if ( + typeof subject !== 'string' || + typeof predicate !== 'string' || + typeof object !== 'string' + ) { + continue; + } + const ns = typeof relation.namespace === 'string' ? relation.namespace : null; + const bucket = ensure(ns); + bucket.facts.add(tripleKey(subject, predicate, object)); + bucket.entities.add(subject); + bucket.entities.add(object); + allEntities.add(subject); + allEntities.add(object); + } + + if (buckets.size === 0) return EMPTY_REPORT; + + const namespaces: NamespaceStat[] = [...buckets.entries()].map(([namespace, bucket]) => ({ + namespace, + factCount: bucket.facts.size, + entityCount: bucket.entities.size, + })); + + // Sort by factCount desc, then namespace asc with null (un-namespaced) last. + namespaces.sort((a, b) => { + if (b.factCount !== a.factCount) return b.factCount - a.factCount; + if (a.namespace === null) return b.namespace === null ? 0 : 1; + if (b.namespace === null) return -1; + return compareIds(a.namespace, b.namespace); + }); + + let totalFacts = 0; + for (const stat of namespaces) totalFacts += stat.factCount; + + return { + namespaces, + namespaceCount: namespaces.length, + totalFacts, + totalEntities: allEntities.size, + }; +} diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index fd185aeb9d..2695b67f46 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; import DiagramViewerTab from '../components/intelligence/DiagramViewerTab'; import GraphCentralityTab from '../components/intelligence/GraphCentralityTab'; +import NamespaceOverviewTab from '../components/intelligence/NamespaceOverviewTab'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab'; import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace'; @@ -19,7 +20,7 @@ import type { ToastNotification, } from '../types/intelligence'; -type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'diagram' | 'centrality'; +type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'diagram' | 'centrality' | 'namespaces'; export default function Intelligence() { const { t } = useT(); @@ -94,6 +95,7 @@ export default function Intelligence() { { id: 'subconscious', label: t('memory.tab.subconscious') }, { id: 'diagram', label: t('memory.tab.diagram') }, { id: 'centrality', label: t('memory.tab.centrality') }, + { id: 'namespaces', label: t('memory.tab.namespaces') }, ]; const activeTabDef = tabs.find(tab => tab.id === activeTab); @@ -182,6 +184,8 @@ export default function Intelligence() { {activeTab === 'diagram' && } {activeTab === 'centrality' && } + + {activeTab === 'namespaces' && }
diff --git a/app/src/services/api/namespaceOverviewApi.test.ts b/app/src/services/api/namespaceOverviewApi.test.ts new file mode 100644 index 0000000000..a2310585de --- /dev/null +++ b/app/src/services/api/namespaceOverviewApi.test.ts @@ -0,0 +1,57 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { computeNamespaceOverview } from '../../lib/memory/namespaceOverview'; +import type { GraphRelation } from '../../utils/tauriCommands/memory'; +import { loadNamespaceOverview, namespaceOverviewApi } from './namespaceOverviewApi'; + +const mockGraphQuery = vi.fn(); + +vi.mock('../../utils/tauriCommands/memory', () => ({ + memoryGraphQuery: (...args: unknown[]) => mockGraphQuery(...args), +})); + +function rel(namespace: string | null, subject: string, object: string): GraphRelation { + return { + namespace, + subject, + predicate: 'p', + object, + attrs: {}, + updatedAt: 0, + evidenceCount: 1, + orderIndex: null, + documentIds: [], + chunkIds: [], + }; +} + +describe('namespaceOverviewApi.loadNamespaceOverview', () => { + beforeEach(() => { + mockGraphQuery.mockReset(); + }); + + it('fetches the whole graph (no namespace arg) and returns the engine report', async () => { + const triples = [rel('work', 'A', 'B'), rel('personal', 'X', 'Y')]; + mockGraphQuery.mockResolvedValueOnce(triples); + const out = await loadNamespaceOverview(); + expect(mockGraphQuery).toHaveBeenCalledWith(); + expect(out).toEqual(computeNamespaceOverview(triples)); + }); + + it('returns an empty report when the graph is empty', async () => { + mockGraphQuery.mockResolvedValueOnce([]); + const out = await loadNamespaceOverview(); + expect(out.namespaceCount).toBe(0); + }); + + it('propagates query errors', async () => { + mockGraphQuery.mockRejectedValueOnce(new Error('graph unavailable')); + await expect(loadNamespaceOverview()).rejects.toThrow('graph unavailable'); + }); +}); + +describe('namespaceOverviewApi object', () => { + it('exposes the public surface', () => { + expect(typeof namespaceOverviewApi.loadNamespaceOverview).toBe('function'); + }); +}); diff --git a/app/src/services/api/namespaceOverviewApi.ts b/app/src/services/api/namespaceOverviewApi.ts new file mode 100644 index 0000000000..7755f4b9a6 --- /dev/null +++ b/app/src/services/api/namespaceOverviewApi.ts @@ -0,0 +1,26 @@ +/** + * RPC facade for Namespace Overview. + * + * Adds ZERO new core surface. Reuses ONE already-shipped JSON-RPC wrapper — + * memoryGraphQuery (openhuman.memory_graph_query) — fetching ALL namespaces in + * one call (no namespace arg) and grouping by each relation's `namespace` + * field in the pure engine. Read-only — nothing is persisted. + */ +import debug from 'debug'; + +import { + computeNamespaceOverview, + type NamespaceOverviewReport, +} from '../../lib/memory/namespaceOverview'; +import { memoryGraphQuery } from '../../utils/tauriCommands/memory'; + +const log = debug('namespace-overview:api'); + +/** Fetch the whole graph and aggregate per-namespace stats. */ +export async function loadNamespaceOverview(): Promise { + const relations = await memoryGraphQuery(); + log('loadNamespaceOverview relations=%d', relations.length); + return computeNamespaceOverview(relations); +} + +export const namespaceOverviewApi = { loadNamespaceOverview };