diff --git a/app/package.json b/app/package.json index ca4ecff06..aa19e8611 100644 --- a/app/package.json +++ b/app/package.json @@ -92,10 +92,12 @@ "@types/three": "^0.183.1", "buffer": "^6.0.3", "cmdk": "^1.1.1", + "d3-force": "^3.0.0", "debug": "^4.4.3", "katex": "^0.16.47", "lottie-react": "^2.4.1", "os-browserify": "^0.3.0", + "pixi.js": "^8.18.1", "process": "^0.11.10", "qrcode.react": "^4.2.0", "react": "^19.1.0", @@ -129,6 +131,7 @@ "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", "@trivago/prettier-plugin-sort-imports": "^6.0.2", + "@types/d3-force": "^3.0.10", "@types/debug": "^4.1.12", "@types/node": "^25.0.10", "@types/react": "^19.1.8", diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 61b6511a5..b7e057f03 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -324,9 +324,11 @@ function ModePicker({ onConfirm }: PickerProps) { OPENHUMAN_CORE_TOKEN) { diff --git a/app/src/components/intelligence/MemoryGraph.tsx b/app/src/components/intelligence/MemoryGraph.tsx index 6f0144db7..1cadfb1ca 100644 --- a/app/src/components/intelligence/MemoryGraph.tsx +++ b/app/src/components/intelligence/MemoryGraph.tsx @@ -10,19 +10,52 @@ * - all-pairs Coulomb repulsion pushes overlapping nodes apart * - centring force keeps the cloud anchored in the viewport * - * Click a summary node → opens the matching `.md` file through the - * shared workspace path command. This keeps Memory graph file actions on - * the same guarded contract as chat workspace links. + * Colour: in tree mode each level lights up in its own hue (mirroring the + * Obsidian `path:L{n}` groups) with a soft glow on summary nodes; leaves + * stay a quiet slate. * - * Pure SVG, no external graph dep — keeps the bundle small and the - * rendering deterministic for tests/screenshots. + * Interaction: drag a node to reposition it, drag the background to pan, + * scroll to zoom, and "Reset view" recentres. Click a summary node → + * opens the matching `.md` file through the shared workspace path + * command (skipped when the pointer was dragging). This keeps Memory + * graph file actions on the same guarded contract as chat workspace links. + * + * Rendering: where WebGL is available we use a Pixi.js + d3-force canvas + * ({@link PixiGraph}) — the same stack Obsidian's graph runs on, smooth + * well past the 1000-node cap. Without WebGL (e.g. jsdom under test) it + * falls back to a deterministic pure-SVG renderer with the same colours, + * interactions and click/preview behaviour. */ -import { useCallback, useMemo, useRef, useState } from 'react'; +import { + type PointerEvent as ReactPointerEvent, + type WheelEvent as ReactWheelEvent, + useCallback, + useMemo, + useReducer, + useRef, + useState, +} from 'react'; import { useT } from '../../lib/i18n/I18nContext'; import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands'; import { openWorkspacePath, previewWorkspaceText } from '../../utils/tauriCommands/workspacePaths'; +import { + CONTACT_COLOR, + LEAF_COLOR, + levelColor, + nodeColor, + nodeRadius, + supportsWebGL, + VIEWPORT_H, + VIEWPORT_W, + ZOOM_MAX, + ZOOM_MIN, +} from './memoryGraphLayout'; import { summaryWorkspacePath } from './memoryWorkspacePaths'; +import { PixiGraph } from './PixiGraph'; + +/** Detected once — WebGL availability decides Pixi vs the SVG fallback. */ +const HAS_WEBGL = supportsWebGL(); interface SimNode extends GraphNode { x: number; @@ -49,33 +82,24 @@ interface SummaryPreviewState { error: string | null; } -/** Per-node-kind palette. Source/topic/global preserved for tree mode. */ -const SUMMARY_TREE_COLOR: Record = { - source: '#4A83DD', - topic: '#E8A653', - global: '#7BB489', -}; -const NODE_COLOR: Record = { - chunk: '#4A83DD', - contact: '#A78BFA', // violet — matches the Obsidian button accent -}; - -const VIEWPORT_W = 1100; -const VIEWPORT_H = 640; - -function nodeColor(node: GraphNode): string { - if (node.kind === 'summary') { - return SUMMARY_TREE_COLOR[node.tree_kind ?? ''] ?? '#94a3b8'; - } - return NODE_COLOR[node.kind] ?? '#94a3b8'; -} - -function nodeRadius(node: GraphNode): number { - if (node.kind === 'summary') { - return Math.max(4, 10 - (node.level ?? 0) * 0.8); - } - if (node.kind === 'contact') return 9; - return 4; // chunk +/** + * Map a pointer's client coords into the SVG's viewBox coordinate space + * (SVG fallback only). Returns null without a live CTM (e.g. jsdom) so the + * pan/zoom handlers degrade to no-ops under test. + */ +function clientToViewBox( + svg: SVGSVGElement | null, + clientX: number, + clientY: number +): { x: number; y: number } | null { + if (!svg || typeof svg.getScreenCTM !== 'function') return null; + const ctm = svg.getScreenCTM(); + if (!ctm) return null; + const inv = ctm.inverse(); + return { + x: inv.a * clientX + inv.c * clientY + inv.e, + y: inv.b * clientX + inv.d * clientY + inv.f, + }; } /** @@ -141,6 +165,109 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) const [previewingPath, setPreviewingPath] = useState(null); const svgRef = useRef(null); + // Pan / zoom transform applied to the graph group, plus the live drag + // state. Node positions live in the memoised `sim` buffer and are + // mutated in place during a node drag; `bumpTick` forces a re-render so + // the moved node repaints without re-running the physics. + const [view, setView] = useState({ tx: 0, ty: 0, scale: 1 }); + const [, bumpTick] = useReducer((c: number) => c + 1, 0); + const [grabbing, setGrabbing] = useState(false); + // Bumped by "Reset view" — the Pixi renderer watches it to recentre. + const [resetSignal, bumpReset] = useReducer((c: number) => c + 1, 0); + // Flips true if Pixi fails to init at runtime → fall back to SVG even + // though supportsWebGL() was true at module load. + const [pixiFailed, setPixiFailed] = useState(false); + const useWebGL = HAS_WEBGL && !pixiFailed; + const dragRef = useRef< + | { kind: 'node'; node: SimNode; dx: number; dy: number } + | { kind: 'pan'; vbStartX: number; vbStartY: number; tx0: number; ty0: number } + | null + >(null); + // True once the pointer moved during the current gesture — guards the + // node click so a drag doesn't also open the summary file. + const movedRef = useRef(false); + + const clientToGraph = useCallback( + (clientX: number, clientY: number) => { + const vb = clientToViewBox(svgRef.current, clientX, clientY); + if (!vb) return null; + return { x: (vb.x - view.tx) / view.scale, y: (vb.y - view.ty) / view.scale }; + }, + [view] + ); + + const onNodePointerDown = useCallback( + (e: ReactPointerEvent, n: SimNode) => { + // Stop the background pan from also starting on this pointer down. + e.stopPropagation(); + movedRef.current = false; + const g = clientToGraph(e.clientX, e.clientY); + if (!g) return; + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + dragRef.current = { kind: 'node', node: n, dx: g.x - n.x, dy: g.y - n.y }; + setGrabbing(true); + }, + [clientToGraph] + ); + + const onBackgroundPointerDown = useCallback( + (e: ReactPointerEvent) => { + movedRef.current = false; + const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY); + if (!vb) return; + (e.currentTarget as Element).setPointerCapture?.(e.pointerId); + dragRef.current = { kind: 'pan', vbStartX: vb.x, vbStartY: vb.y, tx0: view.tx, ty0: view.ty }; + setGrabbing(true); + }, + [view] + ); + + const onPointerMove = useCallback( + (e: ReactPointerEvent) => { + const d = dragRef.current; + if (!d) return; + if (d.kind === 'node') { + const g = clientToGraph(e.clientX, e.clientY); + if (!g) return; + d.node.x = g.x - d.dx; + d.node.y = g.y - d.dy; + movedRef.current = true; + bumpTick(); + } else { + const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY); + if (!vb) return; + movedRef.current = true; + setView(v => ({ ...v, tx: d.tx0 + (vb.x - d.vbStartX), ty: d.ty0 + (vb.y - d.vbStartY) })); + } + }, + [clientToGraph] + ); + + const endDrag = useCallback(() => { + dragRef.current = null; + setGrabbing(false); + }, []); + + const onWheelZoom = useCallback((e: ReactWheelEvent) => { + const vb = clientToViewBox(svgRef.current, e.clientX, e.clientY); + if (!vb) return; + setView(v => { + const factor = Math.exp(-e.deltaY * 0.0015); + const scale = Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v.scale * factor)); + // Keep the graph point under the cursor fixed while zooming. + const gx = (vb.x - v.tx) / v.scale; + const gy = (vb.y - v.ty) / v.scale; + return { scale, tx: vb.x - gx * scale, ty: vb.y - gy * scale }; + }); + }, []); + + const resetView = useCallback(() => { + // SVG fallback resets its transform; the Pixi canvas listens on the + // reset signal. Both are bumped so the button works in either path. + setView({ tx: 0, ty: 0, scale: 1 }); + bumpReset(); + }, []); + const openSummary = useCallback(async (node: GraphNode) => { const path = summaryWorkspacePath(node); if (!path) return; @@ -172,8 +299,9 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) } }, []); - // Run the force simulation once when nodes arrive. Memoised so panning / - // zooming the SVG doesn't re-run physics. + // Build edges and (for the SVG fallback) seed positions + relax. The + // O(n²) relax only runs when WebGL is unavailable; the Pixi path runs + // its own d3-force simulation instead. const sim = useMemo(() => { if (!nodes || nodes.length === 0) return null; const idIndex = new Map(); @@ -207,9 +335,9 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) edgeIndices.push([a, b]); } } - relaxLayout(sim, edgeIndices); + if (!useWebGL) relaxLayout(sim, edgeIndices); return { sim, edges: edgeIndices }; - }, [nodes, edges, mode]); + }, [nodes, edges, mode, useWebGL]); if (nodes.length === 0) { return ( @@ -223,23 +351,22 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) if (!sim) return null; - // Distinct legend rows for the active mode. + // Distinct legend rows for the active mode. Tree mode lists the levels + // actually present (each lit in its own colour) plus a leaf row when + // chunks are shown. const legend = mode === 'tree' - ? Array.from(new Set(nodes.map(n => n.tree_kind ?? ''))) - .filter(Boolean) - .map(kind => ({ - label: - kind === 'source' - ? t('graph.source') - : kind === 'topic' - ? t('graph.topic') - : t('graph.global'), - color: SUMMARY_TREE_COLOR[kind] ?? '#94a3b8', - })) + ? [ + ...Array.from(new Set(nodes.filter(n => n.kind === 'summary').map(n => n.level ?? 0))) + .sort((a, b) => a - b) + .map(lvl => ({ label: `L${lvl}`, color: levelColor(lvl) })), + ...(nodes.some(n => n.kind === 'chunk') + ? [{ label: t('graph.document'), color: LEAF_COLOR }] + : []), + ] : [ - { label: t('graph.document'), color: NODE_COLOR.chunk }, - { label: t('graph.contact'), color: NODE_COLOR.contact }, + { label: t('graph.document'), color: LEAF_COLOR }, + { label: t('graph.contact'), color: CONTACT_COLOR }, ]; const hoveredSummaryPath = hovered?.kind === 'summary' ? summaryWorkspacePath(hovered) : null; @@ -271,50 +398,93 @@ export function MemoryGraph({ nodes, edges, mode, emptyHint }: MemoryGraphProps) {item.label} ))} + - - - {sim.edges.map(([ai, bi], idx) => { - const a = sim.sim[ai]; - const b = sim.sim[bi]; - return ; - })} - - - {sim.sim.map(n => { - const r = nodeRadius(n); - const fill = nodeColor(n); - const isHover = hovered?.id === n.id; - return ( - setHovered(n)} - onClick={() => { - if (n.kind === 'summary') void openSummary(n); - }} - data-testid={`memory-graph-node-${n.id}`}> - {tooltipFor(n, t)} - - ); - })} - - + {useWebGL ? ( + { + if (n.kind === 'summary') void openSummary(n); + }} + onError={() => setPixiFailed(true)} + /> + ) : ( + + {/* Pan / zoom group — drag the background to pan, scroll to zoom. */} + + + {sim.edges.map(([ai, bi], idx) => { + const a = sim.sim[ai]; + const b = sim.sim[bi]; + return ; + })} + + + {sim.sim.map(n => { + const r = nodeRadius(n); + const fill = nodeColor(n); + const isHover = hovered?.id === n.id; + // Leaves stay flat; summary / contact nodes glow in their + // own colour so the tree levels "light up". + const glow = + n.kind === 'chunk' ? undefined : `drop-shadow(0 0 ${isHover ? 7 : 4}px ${fill})`; + return ( + onNodePointerDown(e, n)} + onMouseEnter={() => setHovered(n)} + onClick={() => { + // A drag ends with a click event too — skip the open + // when the pointer actually moved. + if (movedRef.current) return; + if (n.kind === 'summary') void openSummary(n); + }} + data-testid={`memory-graph-node-${n.id}`}> + {tooltipFor(n, t)} + + ); + })} + + + + )} {hovered && (
{hovered.kind === 'summary' ? ( <> diff --git a/app/src/components/intelligence/MemorySection.tsx b/app/src/components/intelligence/MemorySection.tsx new file mode 100644 index 000000000..345b8499a --- /dev/null +++ b/app/src/components/intelligence/MemorySection.tsx @@ -0,0 +1,74 @@ +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import type { ToastNotification } from '../../types/intelligence'; +import PillTabBar from '../PillTabBar'; +import ConnectionPathTab from './ConnectionPathTab'; +import DiagramViewerTab from './DiagramViewerTab'; +import EntityAssociationsTab from './EntityAssociationsTab'; +import GraphCentralityTab from './GraphCentralityTab'; +import GraphCohesionTab from './GraphCohesionTab'; +import MemoryFreshnessTab from './MemoryFreshnessTab'; +import MemoryTimelineTab from './MemoryTimelineTab'; +import { MemoryWorkspace } from './MemoryWorkspace'; +import NamespaceOverviewTab from './NamespaceOverviewTab'; + +/** + * Memory sub-tabs. + * + * All graph/memory-analysis surfaces that previously lived as top-level + * Intelligence tabs are nested here under the "Memory" tab. The first sub-tab + * (`memoryTree`) is the former top-level "Memory" tab (the MemoryWorkspace). + */ +type MemorySubTab = + | 'memoryTree' + | 'diagram' + | 'centrality' + | 'cohesion' + | 'associations' + | 'freshness' + | 'timeline' + | 'paths' + | 'namespaces'; + +interface MemorySectionProps { + onToast: (toast: Omit) => void; +} + +export default function MemorySection({ onToast }: MemorySectionProps) { + const { t } = useT(); + const [activeSubTab, setActiveSubTab] = useState('memoryTree'); + + const subTabs: { id: MemorySubTab; label: string }[] = [ + { id: 'memoryTree', label: t('memory.tab.memoryTree') }, + { id: 'diagram', label: t('memory.tab.diagram') }, + { id: 'centrality', label: t('memory.tab.centrality') }, + { id: 'cohesion', label: t('memory.tab.cohesion') }, + { id: 'associations', label: t('memory.tab.associations') }, + { id: 'freshness', label: t('memory.tab.freshness') }, + { id: 'timeline', label: t('memory.tab.timeline') }, + { id: 'paths', label: t('memory.tab.path') }, + { id: 'namespaces', label: t('memory.tab.namespaces') }, + ]; + + return ( +
+ ({ label: tab.label, value: tab.id }))} + selected={activeSubTab} + onChange={setActiveSubTab} + containerClassName="flex flex-wrap gap-2 pb-1" + /> + + {activeSubTab === 'memoryTree' && } + {activeSubTab === 'diagram' && } + {activeSubTab === 'centrality' && } + {activeSubTab === 'cohesion' && } + {activeSubTab === 'associations' && } + {activeSubTab === 'freshness' && } + {activeSubTab === 'timeline' && } + {activeSubTab === 'paths' && } + {activeSubTab === 'namespaces' && } +
+ ); +} diff --git a/app/src/components/intelligence/MemoryWorkspace.tsx b/app/src/components/intelligence/MemoryWorkspace.tsx index bf05bdf81..54ce8cfa6 100644 --- a/app/src/components/intelligence/MemoryWorkspace.tsx +++ b/app/src/components/intelligence/MemoryWorkspace.tsx @@ -11,9 +11,6 @@ * │ Sync button, status chip, chunk count, freshness) │ * └───────────────────────────────────────────────────────┘ * ┌───────────────────────────────────────────────────────┐ - * │ VaultPanel — Obsidian vault link / folder picker │ - * └───────────────────────────────────────────────────────┘ - * ┌───────────────────────────────────────────────────────┐ * │ WhatsAppMemorySection │ * └───────────────────────────────────────────────────────┘ * ┌───────────────────────────────────────────────────────┐ @@ -51,7 +48,6 @@ import { MemoryGraph } from './MemoryGraph'; import { MemorySourcesRegistry } from './MemorySourcesRegistry'; import { MemoryTreeStatusPanel } from './MemoryTreeStatusPanel'; import { ObsidianVaultSection } from './ObsidianVaultSection'; -import { VaultPanel } from './VaultPanel'; import { WhatsAppMemorySection } from './WhatsAppMemorySection'; interface MemoryWorkspaceProps { @@ -214,7 +210,6 @@ export function MemoryWorkspace({ onToast }: MemoryWorkspaceProps) {
-
({ mountPixiGraph: vi.fn() })); + +vi.mock('./pixiGraphRenderer', () => ({ + mountPixiGraph: (...args: unknown[]) => mocks.mountPixiGraph(...args), +})); + +const NODES: GraphNode[] = [ + { kind: 'summary', id: 'root', label: 'R', level: 0, parent_id: null }, + { kind: 'chunk', id: 'leaf', label: 'L', parent_id: 'root' }, +]; + +describe('', () => { + beforeEach(() => mocks.mountPixiGraph.mockReset()); + afterEach(() => vi.restoreAllMocks()); + + it('mounts the renderer with built graph data', async () => { + const handle = { resetView: vi.fn(), setTheme: vi.fn(), destroy: vi.fn() }; + mocks.mountPixiGraph.mockResolvedValue(handle); + const { getByTestId } = render( + + ); + expect(getByTestId('memory-graph-canvas')).toBeInTheDocument(); + await waitFor(() => expect(mocks.mountPixiGraph).toHaveBeenCalledTimes(1)); + const [, opts] = mocks.mountPixiGraph.mock.calls[0] as [ + HTMLElement, + { simNodes: unknown[]; links: unknown[] }, + ]; + expect(opts.simNodes).toHaveLength(2); + expect(opts.links).toHaveLength(1); // leaf -> root + }); + + it('destroys the renderer on unmount', async () => { + const handle = { resetView: vi.fn(), setTheme: vi.fn(), destroy: vi.fn() }; + mocks.mountPixiGraph.mockResolvedValue(handle); + const { unmount } = render( + + ); + await waitFor(() => expect(mocks.mountPixiGraph).toHaveBeenCalled()); + unmount(); + await waitFor(() => expect(handle.destroy).toHaveBeenCalled()); + }); +}); diff --git a/app/src/components/intelligence/PixiGraph.tsx b/app/src/components/intelligence/PixiGraph.tsx new file mode 100644 index 000000000..958a79032 --- /dev/null +++ b/app/src/components/intelligence/PixiGraph.tsx @@ -0,0 +1,101 @@ +/** + * Thin React host for the imperative Pixi + d3-force renderer. + * + * Mounts the WebGL graph into a div and forwards hover/open back to the + * parent `MemoryGraph` chrome (footer + preview). Pixi owns all canvas + * interaction; React only manages the lifecycle. Callbacks are held in + * refs so changing them never tears down and re-creates the GPU context. + */ +import { useEffect, useRef } from 'react'; + +import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands'; +import { buildGraph } from './memoryGraphLayout'; +import { mountPixiGraph, type PixiGraphHandle } from './pixiGraphRenderer'; + +interface PixiGraphProps { + nodes: GraphNode[]; + edges: GraphEdge[]; + mode: GraphMode; + dark: boolean; + /** Bump to recentre the view (Reset view button). */ + resetSignal: number; + onHover: (node: GraphNode | null) => void; + onOpen: (node: GraphNode) => void; + /** Called if Pixi fails to initialise at runtime so the parent can + * fall back to the SVG renderer. */ + onError?: () => void; +} + +export function PixiGraph({ + nodes, + edges, + mode, + dark, + resetSignal, + onHover, + onOpen, + onError, +}: PixiGraphProps) { + const hostRef = useRef(null); + const handleRef = useRef(null); + const onHoverRef = useRef(onHover); + const onOpenRef = useRef(onOpen); + const onErrorRef = useRef(onError); + const darkRef = useRef(dark); + onHoverRef.current = onHover; + onOpenRef.current = onOpen; + onErrorRef.current = onError; + darkRef.current = dark; + + // (Re)mount the renderer whenever the graph data or mode changes. + useEffect(() => { + const host = hostRef.current; + if (!host) return; + let cancelled = false; + const { simNodes, links } = buildGraph(nodes, edges, mode); + const pending = mountPixiGraph(host, { + simNodes, + links, + dark: darkRef.current, + onHover: n => onHoverRef.current(n), + onOpen: n => onOpenRef.current(n), + }) + .then(handle => { + if (cancelled) { + handle.destroy(); + return null; + } + handleRef.current = handle; + return handle; + }) + .catch(err => { + // Runtime WebGL failure (driver / lost context) even though + // supportsWebGL() was true — let the parent fall back to SVG. + console.error('[memory-graph] Pixi init failed; falling back to SVG', err); + if (!cancelled) onErrorRef.current?.(); + return null; + }); + return () => { + cancelled = true; + handleRef.current = null; + void pending.then(handle => handle?.destroy()); + }; + }, [nodes, edges, mode]); + + useEffect(() => { + handleRef.current?.setTheme(dark); + }, [dark]); + + useEffect(() => { + if (resetSignal > 0) handleRef.current?.resetView(); + }, [resetSignal]); + + return ( +
+ ); +} diff --git a/app/src/components/intelligence/VaultPanel.test.tsx b/app/src/components/intelligence/VaultPanel.test.tsx deleted file mode 100644 index 96e63c2b2..000000000 --- a/app/src/components/intelligence/VaultPanel.test.tsx +++ /dev/null @@ -1,500 +0,0 @@ -/** - * Vitest for ``. Covers: load/empty/error states, the add- - * vault form happy + error paths, per-row sync (success + failed-files - * branch), and remove with both purge=true and purge=false flows. - */ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; - -import { VaultPanel } from './VaultPanel'; - -const mockList = vi.fn(); -const mockCreate = vi.fn(); -const mockSync = vi.fn(); -const mockSyncStatus = vi.fn(); -const mockRemove = vi.fn(); - -const mockOpenUrl = vi.fn(); -const mockRevealPath = vi.fn(); - -vi.mock('../../utils/tauriCommands/vault', () => ({ - openhumanVaultList: (...args: unknown[]) => mockList(...args), - openhumanVaultCreate: (...args: unknown[]) => mockCreate(...args), - openhumanVaultSync: (...args: unknown[]) => mockSync(...args), - openhumanVaultSyncStatus: (...args: unknown[]) => mockSyncStatus(...args), - openhumanVaultRemove: (...args: unknown[]) => mockRemove(...args), -})); - -vi.mock('../../utils/openUrl', () => ({ - openUrl: (...args: unknown[]) => mockOpenUrl(...args), - revealPath: (...args: unknown[]) => mockRevealPath(...args), -})); - -function vault(overrides: Record = {}) { - return { - id: 'v-1', - name: 'Notes', - root_path: '/Users/me/notes', - namespace: 'vault:v-1', - include_globs: [], - exclude_globs: [], - created_at: '2026-05-17T10:00:00Z', - last_synced_at: null, - file_count: 0, - write_state: 'writable', - write_state_reason: 'writable', - ...overrides, - }; -} - -/** Build a completed `CoreVaultSyncState` payload for mockSyncStatus. */ -function syncState(overrides: Record = {}) { - return { - result: { - vault_id: 'v-1', - status: 'completed', - scanned: 4, - ingested: 3, - unchanged: 1, - removed: 0, - failed: 0, - skipped_unsupported: 0, - total: 4, - started_at_ms: 1_000, - finished_at_ms: 2_200, - duration_ms: 1_200, - errors: [], - ...overrides, - }, - logs: [], - }; -} - -describe('', () => { - beforeEach(() => { - mockList.mockReset(); - mockCreate.mockReset(); - mockSync.mockReset(); - mockSyncStatus.mockReset(); - mockRemove.mockReset(); - mockOpenUrl.mockReset(); - mockRevealPath.mockReset(); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it('shows loading then empty state when list returns no vaults', async () => { - mockList.mockResolvedValueOnce({ result: [], logs: [] }); - render(); - expect(screen.getByText(/Loading vaults/)).toBeTruthy(); - await waitFor(() => screen.getByText(/No vaults yet/)); - expect(mockList).toHaveBeenCalledTimes(1); - }); - - it('renders an error banner when list fails', async () => { - mockList.mockRejectedValueOnce(new Error('rpc down')); - render(); - await waitFor(() => screen.getByText(/Failed to load vaults/)); - expect(screen.getByText(/rpc down/)).toBeTruthy(); - }); - - it('lists vaults with file count + relative last-synced label', async () => { - vi.spyOn(Date, 'now').mockReturnValue(new Date('2026-05-17T10:05:00Z').getTime()); - mockList.mockResolvedValueOnce({ - result: [ - vault({ id: 'v-A', name: 'A', file_count: 42, last_synced_at: '2026-05-17T10:04:30Z' }), - ], - logs: [], - }); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - expect(screen.getByText('A')).toBeTruthy(); - expect(screen.getByText(/42 file/)).toBeTruthy(); - expect(screen.getByText(/synced 30s ago/)).toBeTruthy(); - expect(screen.getByTestId('vault-write-state-v-A')).toHaveTextContent('Writable'); - expect(screen.getByText(/Approved markdown/)).toBeTruthy(); - }); - - it('shows read-only and unavailable vault write states with reasons', async () => { - mockList.mockResolvedValueOnce({ - result: [ - vault({ - id: 'v-readonly', - name: 'Read only', - write_state: 'read_only', - write_state_reason: 'read_only', - }), - vault({ - id: 'v-missing', - name: 'Missing', - write_state: 'unavailable', - write_state_reason: 'unavailable', - }), - ], - logs: [], - }); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - expect(screen.getByTestId('vault-write-state-v-readonly')).toHaveTextContent('Read-only'); - expect(screen.getByText('Vault folder is read-only on this device.')).toBeTruthy(); - expect(screen.getByTestId('vault-write-state-v-missing')).toHaveTextContent('Unavailable'); - expect(screen.getByText('Vault folder is not available on this device.')).toBeTruthy(); - }); - - it('toggles the add form and creates a vault on submit', async () => { - mockList - .mockResolvedValueOnce({ result: [], logs: [] }) - .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockCreate.mockResolvedValueOnce({ result: vault(), logs: [] }); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByText(/No vaults yet/)); - - fireEvent.click(screen.getByTestId('vault-add-toggle')); - const form = screen.getByTestId('vault-add-form'); - const inputs = form.querySelectorAll('input'); - fireEvent.change(inputs[0], { target: { value: 'My notes' } }); - fireEvent.change(inputs[1], { target: { value: '/Users/me/notes' } }); - fireEvent.change(inputs[2], { target: { value: 'drafts, .secret' } }); - fireEvent.submit(form); - - await waitFor(() => - expect(mockCreate).toHaveBeenCalledWith({ - name: 'My notes', - rootPath: '/Users/me/notes', - excludeGlobs: ['drafts', '.secret'], - }) - ); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'success', title: 'Vault added' }) - ); - // Reload happens after create — list called twice (initial + post-create). - expect(mockList).toHaveBeenCalledTimes(2); - }); - - it('emits an error toast when create throws', async () => { - mockList.mockResolvedValueOnce({ result: [], logs: [] }); - mockCreate.mockRejectedValueOnce(new Error('disk full')); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByText(/No vaults yet/)); - - fireEvent.click(screen.getByTestId('vault-add-toggle')); - const form = screen.getByTestId('vault-add-form'); - const inputs = form.querySelectorAll('input'); - fireEvent.change(inputs[0], { target: { value: 'n' } }); - fireEvent.change(inputs[1], { target: { value: '/x' } }); - fireEvent.submit(form); - - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', title: 'Could not add vault' }) - ) - ); - }); - - it('syncs a vault and reports counts via toast', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [vault()], logs: [] }); - // vault_sync returns immediately with "started" - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - // vault_sync_status returns "completed" on first poll - mockSyncStatus.mockResolvedValueOnce(syncState()); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => expect(mockSync).toHaveBeenCalledWith('v-1')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'success', - title: expect.stringContaining('Synced'), - message: expect.stringContaining('Ingested 3'), - }) - ) - ); - }); - - it('uses info toast when sync reports failed files', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - mockSyncStatus.mockResolvedValueOnce( - syncState({ - ingested: 1, - unchanged: 0, - failed: 1, - duration_ms: 50, - errors: ['x.md: read failed'], - }) - ); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'info', message: expect.stringContaining('failed 1') }) - ) - ); - }); - - it('emits error toast when sync RPC fails', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockRejectedValueOnce(new Error('boom')); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', title: 'Sync failed' }) - ) - ); - }); - - it('emits error toast when sync status returns failed', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - mockSyncStatus.mockResolvedValueOnce( - syncState({ status: 'failed', failed: 0, errors: ['disk full'] }) - ); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - title: expect.stringContaining('Sync failed'), - message: expect.stringContaining('disk full'), - }) - ) - ); - }); - - it('emits error toast when status poll RPC throws', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - mockSyncStatus.mockRejectedValueOnce(new Error('poll error')); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', title: 'Sync failed', message: 'poll error' }) - ) - ); - }); - - it('uses fallback failed-file count message when errors array is empty', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - mockSyncStatus.mockResolvedValueOnce(syncState({ status: 'failed', failed: 3, errors: [] })); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - message: expect.stringContaining('Failed 3 file(s)'), - }) - ) - ); - }); - - it('includes skipped_unsupported count in completed toast message', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - mockSyncStatus.mockResolvedValueOnce( - syncState({ ingested: 2, skipped_unsupported: 5, duration_ms: 0 }) - ); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('skipped 5') }) - ) - ); - }); - - it('cancels pending poll timer on unmount', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockSync.mockResolvedValueOnce({ result: { status: 'started', vault_id: 'v-1' }, logs: [] }); - // Always running — the 1 500 ms re-poll timer stays live after poll #1. - mockSyncStatus.mockResolvedValue(syncState({ status: 'running', ingested: 1, total: 4 })); - - const { unmount } = render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Sync')); - - // Wait until the first poll fires (0 ms timer) so the 1 500 ms next-poll - // timer is scheduled in pollTimers.current. - await waitFor(() => expect(mockSyncStatus).toHaveBeenCalledTimes(1)); - - const clearSpy = vi.spyOn(globalThis, 'clearTimeout'); - unmount(); - - // useEffect cleanup must have called clearTimeout for the pending timer. - expect(clearSpy).toHaveBeenCalled(); - clearSpy.mockRestore(); - }); - - it('removes a vault with purge=true when both confirms accepted', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [], logs: [] }); - mockRemove.mockResolvedValueOnce({ - result: { vault_id: 'v-1', removed: true, purged: true }, - logs: [], - }); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Remove')); - await waitFor(() => expect(mockRemove).toHaveBeenCalledWith('v-1', true)); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'success', message: expect.stringContaining('purged') }) - ); - confirmSpy.mockRestore(); - }); - - it('removes a vault with purge=false when first confirm denied', async () => { - mockList - .mockResolvedValueOnce({ result: [vault()], logs: [] }) - .mockResolvedValueOnce({ result: [], logs: [] }); - mockRemove.mockResolvedValueOnce({ - result: { vault_id: 'v-1', removed: true, purged: false }, - logs: [], - }); - // First confirm (purge?) → no; second confirm (really remove?) → yes. - const confirmSpy = vi - .spyOn(window, 'confirm') - .mockReturnValueOnce(false) - .mockReturnValueOnce(true); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Remove')); - await waitFor(() => expect(mockRemove).toHaveBeenCalledWith('v-1', false)); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ message: expect.stringContaining('Documents kept') }) - ); - confirmSpy.mockRestore(); - }); - - it('aborts remove when second confirm is denied', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - const confirmSpy = vi - .spyOn(window, 'confirm') - .mockReturnValueOnce(true) - .mockReturnValueOnce(false); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Remove')); - // Allow microtasks to settle so any (incorrect) RPC dispatch would land. - await Promise.resolve(); - expect(mockRemove).not.toHaveBeenCalled(); - confirmSpy.mockRestore(); - }); - - it('emits error toast when remove RPC fails', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockRemove.mockRejectedValueOnce(new Error('locked')); - const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByText('Remove')); - await waitFor(() => - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', title: 'Could not remove vault' }) - ) - ); - confirmSpy.mockRestore(); - }); - - it('open vault fires obsidian deep link and shows success toast', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockOpenUrl.mockResolvedValueOnce(undefined); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByTestId('vault-open')); - await waitFor(() => - expect(mockOpenUrl).toHaveBeenCalledWith( - 'obsidian://open?path=' + encodeURIComponent('/Users/me/notes') - ) - ); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'info', title: 'Opened in Obsidian' }) - ); - }); - - it('open vault falls back to revealPath when obsidian deep link fails', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockOpenUrl.mockRejectedValueOnce(new Error('scheme not handled')); - mockRevealPath.mockResolvedValueOnce(undefined); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByTestId('vault-open')); - await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); - await waitFor(() => expect(mockRevealPath).toHaveBeenCalledWith('/Users/me/notes')); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'info', - title: 'Obsidian not found — opened in file manager', - }) - ); - }); - - it('open vault shows error toast when both obsidian and reveal fail', async () => { - mockList.mockResolvedValueOnce({ result: [vault()], logs: [] }); - mockOpenUrl.mockRejectedValueOnce(new Error('scheme not handled')); - mockRevealPath.mockRejectedValueOnce(new Error('permission denied')); - const onToast = vi.fn(); - render(); - await waitFor(() => screen.getByTestId('vault-list')); - - fireEvent.click(screen.getByTestId('vault-open')); - await waitFor(() => expect(mockOpenUrl).toHaveBeenCalled()); - await waitFor(() => expect(mockRevealPath).toHaveBeenCalled()); - expect(onToast).toHaveBeenCalledWith( - expect.objectContaining({ type: 'error', title: "Couldn't open vault" }) - ); - }); -}); diff --git a/app/src/components/intelligence/VaultPanel.tsx b/app/src/components/intelligence/VaultPanel.tsx deleted file mode 100644 index e85b8d064..000000000 --- a/app/src/components/intelligence/VaultPanel.tsx +++ /dev/null @@ -1,490 +0,0 @@ -/** - * Knowledge vaults — point the assistant at a local folder and have its - * files mirrored into memory under namespace `vault:`. Sits inside - * the Intelligence ▸ Memory tab. - */ -import { useCallback, useEffect, useRef, useState } from 'react'; - -import { useT } from '../../lib/i18n/I18nContext'; -import type { ToastNotification } from '../../types/intelligence'; -import { openUrl, revealPath } from '../../utils/openUrl'; -import { - type CoreVault, - type CoreVaultSyncState, - openhumanVaultCreate, - openhumanVaultList, - openhumanVaultRemove, - openhumanVaultSync, - openhumanVaultSyncStatus, -} from '../../utils/tauriCommands/vault'; - -/** How often the UI re-polls for sync progress while a sync is running (ms). */ -const SYNC_POLL_INTERVAL_MS = 1_500; - -interface VaultPanelProps { - onToast?: (toast: Omit) => void; -} - -export function VaultPanel({ onToast }: VaultPanelProps) { - const { t } = useT(); - const [vaults, setVaults] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [busy, setBusy] = useState>({}); - const [syncProgress, setSyncProgress] = useState< - Record - >({}); - const [creating, setCreating] = useState(false); - const [showForm, setShowForm] = useState(false); - const [newName, setNewName] = useState(''); - const [newPath, setNewPath] = useState(''); - const [newExcludes, setNewExcludes] = useState(''); - - // Track active polling timers so we can cancel them on unmount. - const pollTimers = useRef>>({}); - - // Cancel all active poll timers on unmount. - useEffect(() => { - const timers = pollTimers.current; - return () => { - for (const t of Object.values(timers)) { - clearTimeout(t); - } - }; - }, []); - - const reload = useCallback(async () => { - setLoading(true); - setLoadError(null); - try { - const resp = await openhumanVaultList(); - setVaults(resp.result); - } catch (err) { - console.error('[ui-flow][vault-panel] list failed', err); - setLoadError(err instanceof Error ? err.message : String(err)); - } finally { - setLoading(false); - } - }, []); - - useEffect(() => { - void reload(); - }, [reload]); - - const handleCreate = useCallback( - async (event: React.FormEvent) => { - event.preventDefault(); - const name = newName.trim(); - const rootPath = newPath.trim(); - if (!name || !rootPath) return; - const excludeGlobs = newExcludes - .split(',') - .map(s => s.trim()) - .filter(Boolean); - setCreating(true); - try { - const resp = await openhumanVaultCreate({ name, rootPath, excludeGlobs }); - onToast?.({ - type: 'success', - title: t('vault.added'), - message: t('vault.createdMessage') - .replace('{name}', resp.result.name) - .replace('{sync}', t('sync.sync')), - }); - setNewName(''); - setNewPath(''); - setNewExcludes(''); - setShowForm(false); - await reload(); - } catch (err) { - console.error('[ui-flow][vault-panel] create failed', err); - onToast?.({ - type: 'error', - title: t('vault.couldNotAdd'), - message: err instanceof Error ? err.message : String(err), - }); - } finally { - setCreating(false); - } - }, - [newExcludes, newName, newPath, onToast, reload, t] - ); - - const handleSync = useCallback( - async (vault: CoreVault) => { - setBusy(b => ({ ...b, [vault.id]: 'sync' })); - setSyncProgress(p => ({ ...p, [vault.id]: undefined })); - - // Start the background sync. - try { - await openhumanVaultSync(vault.id); - } catch (err) { - console.error('[ui-flow][vault-panel] sync start failed', err); - onToast?.({ - type: 'error', - title: t('vault.syncFailed'), - message: err instanceof Error ? err.message : String(err), - }); - setBusy(b => ({ ...b, [vault.id]: undefined })); - return; - } - - console.debug('[ui-flow][vault-panel] sync started, polling for status', { - vaultId: vault.id, - }); - - // Poll until the background task finishes. - const vaultId = vault.id; - const vaultName = vault.name; - - const poll = async () => { - let st: CoreVaultSyncState; - try { - const resp = await openhumanVaultSyncStatus(vaultId); - st = resp.result; - } catch (err) { - console.error('[ui-flow][vault-panel] sync status poll failed', err); - setBusy(b => ({ ...b, [vaultId]: undefined })); - setSyncProgress(p => ({ ...p, [vaultId]: undefined })); - onToast?.({ - type: 'error', - title: t('vault.syncFailed'), - message: err instanceof Error ? err.message : String(err), - }); - return; - } - - // Update progress indicator while running. - if (st.total > 0) { - setSyncProgress(p => ({ ...p, [vaultId]: { ingested: st.ingested, total: st.total } })); - } - - console.debug('[ui-flow][vault-panel] sync poll', { - vaultId, - status: st.status, - ingested: st.ingested, - total: st.total, - }); - - if (st.status === 'completed' || st.status === 'failed') { - // Clear polling state and show final toast. - delete pollTimers.current[vaultId]; - setBusy(b => ({ ...b, [vaultId]: undefined })); - setSyncProgress(p => ({ ...p, [vaultId]: undefined })); - - if (st.status === 'failed') { - onToast?.({ - type: 'error', - title: t('vault.syncFailedFor').replace('{name}', vaultName), - message: - st.errors.length > 0 - ? st.errors.slice(0, 3).join('; ') - : t('vault.syncFailedFiles').replace('{count}', String(st.failed)), - }); - } else { - onToast?.({ - type: st.failed > 0 ? 'info' : 'success', - title: t('vault.syncedTitle').replace('{name}', vaultName), - message: formatSyncSummary(st, t), - }); - } - await reload(); - return; - } - - // Still running — schedule the next poll. - pollTimers.current[vaultId] = setTimeout(() => { - void poll(); - }, SYNC_POLL_INTERVAL_MS); - }; - - // First poll fires immediately (0 ms delay) so tests don't need fake timers. - pollTimers.current[vaultId] = setTimeout(() => { - void poll(); - }, 0); - }, - [onToast, reload, t] - ); - - const handleRemove = useCallback( - async (vault: CoreVault) => { - const purge = window.confirm( - t('vault.confirmRemovePurge') - .replace('{name}', vault.name) - .replace('{count}', String(vault.file_count)) - ); - // Confirm step #2: ensure the user actually meant to remove the vault row. - const ok = window.confirm(t('vault.confirmRemove').replace('{name}', vault.name)); - if (!ok) return; - setBusy(b => ({ ...b, [vault.id]: 'remove' })); - try { - await openhumanVaultRemove(vault.id, purge); - onToast?.({ - type: 'success', - title: t('vault.removed'), - message: purge - ? t('vault.removedPurgedMessage').replace('{name}', vault.name) - : t('vault.removedKeptMessage').replace('{name}', vault.name), - }); - await reload(); - } catch (err) { - console.error('[ui-flow][vault-panel] remove failed', err); - onToast?.({ - type: 'error', - title: t('vault.couldNotRemove'), - message: err instanceof Error ? err.message : String(err), - }); - } finally { - setBusy(b => ({ ...b, [vault.id]: undefined })); - } - }, - [onToast, reload, t] - ); - - const handleOpenVault = useCallback( - async (rootPath: string) => { - try { - await openUrl(`obsidian://open?path=${encodeURIComponent(rootPath)}`); - onToast?.({ type: 'info', title: t('vault.openSuccess'), message: rootPath }); - return; - } catch (err) { - console.error('[ui-flow][vault-panel] obsidian deep link failed', err); - } - try { - await revealPath(rootPath); - onToast?.({ type: 'info', title: t('vault.openFallback'), message: rootPath }); - } catch (err) { - console.error('[ui-flow][vault-panel] reveal vault failed', err); - onToast?.({ type: 'error', title: t('vault.openError'), message: String(err) }); - } - }, - [onToast, t] - ); - - return ( -
-
-
-

- {t('vault.title')} -

-

{t('vault.description')}

-
- -
- - {showForm ? ( -
- - - -
- -
-
- ) : null} - - {loading ? ( -
- {t('vault.loading')} -
- ) : loadError ? ( -
- {t('vault.failedToLoad').replace('{error}', loadError)} -
- ) : vaults.length === 0 ? ( -
- {t('vault.empty')} -
- ) : ( -
    - {vaults.map(v => { - const state = busy[v.id]; - const writeStateReason = v.write_state_reason - ? t( - `vault.writeState.reasons.${v.write_state_reason}`, - t('vault.writeState.unknownReason') - ) - : t('vault.writeState.unknownReason'); - return ( -
  • -
    -
    - {v.name} -
    -
    - {v.root_path} -
    -
    - {t('vault.fileCount').replace('{count}', v.file_count.toLocaleString())} ·{' '} - {v.last_synced_at - ? t('vault.syncedRelative').replace( - '{time}', - formatRelative(v.last_synced_at, t) - ) - : t('vault.neverSynced')} -
    -
    - - {t(`vault.writeState.${v.write_state}`)} - - - {writeStateReason} - -
    -
    -
    - - - -
    -
  • - ); - })} -
- )} -
- ); -} - -function writeStateBadgeClass(state: CoreVault['write_state']): string { - const base = 'inline-flex h-5 items-center rounded px-1.5 text-[10px] font-semibold'; - switch (state) { - case 'writable': - return `${base} bg-sage-50 text-sage-700 dark:bg-sage-500/10 dark:text-sage-300`; - case 'read_only': - return `${base} bg-amber-50 text-amber-700 dark:bg-amber-500/10 dark:text-amber-300`; - case 'unavailable': - default: - return `${base} bg-coral-50 text-coral-700 dark:bg-coral-500/10 dark:text-coral-300`; - } -} - -function formatRelative(iso: string, translate: (key: string) => string): string { - const timestamp = new Date(iso).getTime(); - if (Number.isNaN(timestamp)) return iso; - const diff = Math.max(0, Date.now() - timestamp); - const sec = Math.floor(diff / 1000); - if (sec < 60) return translate('vault.relative.sec').replace('{count}', String(sec)); - const min = Math.floor(sec / 60); - if (min < 60) return translate('vault.relative.min').replace('{count}', String(min)); - const hr = Math.floor(min / 60); - if (hr < 24) return translate('vault.relative.hr').replace('{count}', String(hr)); - const day = Math.floor(hr / 24); - return translate('vault.relative.day').replace('{count}', String(day)); -} - -function formatSyncSummary(state: CoreVaultSyncState, t: (key: string) => string): string { - let summary = t('vault.syncSummary') - .replace('{ingested}', String(state.ingested)) - .replace('{unchanged}', String(state.unchanged)) - .replace('{removed}', String(state.removed)); - if (state.failed > 0) { - summary += t('vault.syncSummaryFailed').replace('{count}', String(state.failed)); - } - if (state.skipped_unsupported > 0) { - summary += t('vault.syncSummarySkipped').replace('{count}', String(state.skipped_unsupported)); - } - if (state.duration_ms > 0) { - summary += t('vault.syncSummaryDuration').replace( - '{seconds}', - (state.duration_ms / 1000).toFixed(1) - ); - } - return summary; -} diff --git a/app/src/components/intelligence/memoryGraphLayout.test.ts b/app/src/components/intelligence/memoryGraphLayout.test.ts new file mode 100644 index 000000000..4b8adf7de --- /dev/null +++ b/app/src/components/intelligence/memoryGraphLayout.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import type { GraphEdge, GraphNode } from '../../utils/tauriCommands'; +import { + buildGraph, + CONTACT_COLOR, + createSimulation, + LEAF_COLOR, + LEVEL_COLOR, + levelColor, + nodeColor, + nodeGlows, + nodeRadius, + pickNode, + type SimNode, + supportsWebGL, +} from './memoryGraphLayout'; + +function summary(overrides: Partial = {}): GraphNode { + return { kind: 'summary', id: 's', label: 'S', level: 0, parent_id: null, ...overrides }; +} +function chunk(overrides: Partial = {}): GraphNode { + return { kind: 'chunk', id: 'c', label: 'C', ...overrides }; +} +function contact(overrides: Partial = {}): GraphNode { + return { kind: 'contact', id: 'p', label: 'P', entity_kind: 'person', ...overrides }; +} + +describe('memoryGraphLayout', () => { + it('colours summaries by level, wrapping the palette', () => { + expect(levelColor(0)).toBe(LEVEL_COLOR[0]); + expect(levelColor(2)).toBe(LEVEL_COLOR[2]); + expect(levelColor(LEVEL_COLOR.length)).toBe(LEVEL_COLOR[0]); // wraps + expect(levelColor(null)).toBe(LEAF_COLOR); + expect(levelColor(-5)).toBe(LEVEL_COLOR[0]); // clamped to 0 + }); + + it('nodeColor branches on kind', () => { + expect(nodeColor(summary({ level: 1 }))).toBe(LEVEL_COLOR[1]); + expect(nodeColor(chunk())).toBe(LEAF_COLOR); + expect(nodeColor(contact())).toBe(CONTACT_COLOR); + }); + + it('nodeRadius shrinks with level and is fixed for chunk/contact', () => { + expect(nodeRadius(summary({ level: 0 }))).toBe(10); + expect(nodeRadius(summary({ level: 3 }))).toBeCloseTo(7.6); + expect(nodeRadius(summary({ level: 99 }))).toBe(4); // floored + expect(nodeRadius(contact())).toBe(9); + expect(nodeRadius(chunk())).toBe(4); + }); + + it('only summary/contact nodes glow', () => { + expect(nodeGlows(summary())).toBe(true); + expect(nodeGlows(contact())).toBe(true); + expect(nodeGlows(chunk())).toBe(false); + }); + + it('buildGraph derives parent_id edges in tree mode and drops danglers', () => { + const nodes = [ + summary({ id: 'root', parent_id: null }), + summary({ id: 'child', level: 1, parent_id: 'root' }), + chunk({ id: 'leaf', parent_id: 'child' }), + chunk({ id: 'orphan', parent_id: 'missing' }), // dangling → dropped + ]; + const { simNodes, links } = buildGraph(nodes, [], 'tree'); + expect(simNodes).toHaveLength(4); + expect(simNodes.every(n => typeof n.x === 'number' && typeof n.y === 'number')).toBe(true); + const pairs = links.map(l => `${String(l.source)}->${String(l.target)}`); + expect(pairs).toContain('child->root'); + expect(pairs).toContain('leaf->child'); + expect(pairs).not.toContain('orphan->missing'); + }); + + it('buildGraph uses explicit edges in contacts mode and drops danglers', () => { + const nodes = [chunk({ id: 'c1' }), contact({ id: 'p1' })]; + const edges: GraphEdge[] = [ + { from: 'c1', to: 'p1' }, + { from: 'c1', to: 'ghost' }, // dangling endpoint → dropped + ]; + const { links } = buildGraph(nodes, edges, 'contacts'); + expect(links).toHaveLength(1); + expect(String(links[0].source)).toBe('c1'); + expect(String(links[0].target)).toBe('p1'); + }); + + it('createSimulation resolves link ids to node objects and converges', () => { + const nodes = [summary({ id: 'root' }), summary({ id: 'child', parent_id: 'root' })]; + const { simNodes, links } = buildGraph(nodes, [], 'tree'); + const sim = createSimulation(simNodes, links); + for (let i = 0; i < 50; i++) sim.tick(); + // forceLink replaces string ids with the actual node objects. + expect((links[0].source as SimNode).id).toBe('child'); + expect((links[0].target as SimNode).id).toBe('root'); + expect(Number.isFinite(simNodes[0].x)).toBe(true); + sim.stop(); + }); + + it('pickNode returns the nearest node within its disc, else null', () => { + const a: SimNode = { ...summary({ id: 'a', level: 0 }), x: 0, y: 0 }; + const b: SimNode = { ...summary({ id: 'b', level: 0 }), x: 100, y: 0 }; + expect(pickNode([a, b], 1, 1)?.id).toBe('a'); + expect(pickNode([a, b], 99, 0)?.id).toBe('b'); + expect(pickNode([a, b], 50, 50)).toBeNull(); // outside both discs + }); + + it('supportsWebGL is false under jsdom (no GL context)', () => { + expect(supportsWebGL()).toBe(false); + }); +}); diff --git a/app/src/components/intelligence/memoryGraphLayout.ts b/app/src/components/intelligence/memoryGraphLayout.ts new file mode 100644 index 000000000..a9c5e2fd2 --- /dev/null +++ b/app/src/components/intelligence/memoryGraphLayout.ts @@ -0,0 +1,166 @@ +/** + * Shared, render-agnostic layout + palette helpers for the memory graph. + * + * Physics is d3-force (Barnes–Hut quadtree charge, O(n log n)) so the + * 1000-node cap settles smoothly — the same model Obsidian's graph is + * built on. Both the WebGL (Pixi) renderer and the SVG fallback consume + * these helpers so colours, radii, edge derivation and hit-testing stay + * identical across paths. + */ +import { + forceCenter, + forceCollide, + forceLink, + forceManyBody, + forceSimulation, + type Simulation, + type SimulationLinkDatum, + type SimulationNodeDatum, +} from 'd3-force'; + +import { type GraphEdge, type GraphMode, type GraphNode } from '../../utils/tauriCommands'; + +/** + * Per-level palette — each tree level "lights up" in its own hue + * (mirrors the Obsidian `path:L{n}` colour groups). + */ +export const LEVEL_COLOR = [ + '#7C3AED', // L0 + '#4A83DD', // L1 + '#1FB6C7', // L2 + '#34C77B', // L3 + '#E8A653', // L4 + '#E0654A', // L5 + '#C026D3', // L6+ +]; +export const LEAF_COLOR = '#94A3B8'; // raw chunks / leaves (no level) +export const CONTACT_COLOR = '#A78BFA'; // person entities (contacts mode) + +/** Layout is computed in this fixed coordinate space; the renderer pans/zooms it. */ +export const VIEWPORT_W = 1100; +export const VIEWPORT_H = 640; +export const ZOOM_MIN = 0.3; +export const ZOOM_MAX = 4; + +export function levelColor(level: number | null | undefined): string { + if (level == null) return LEAF_COLOR; + return LEVEL_COLOR[Math.max(0, level) % LEVEL_COLOR.length]; +} + +export function nodeColor(node: GraphNode): string { + if (node.kind === 'summary') return levelColor(node.level); + if (node.kind === 'contact') return CONTACT_COLOR; + return LEAF_COLOR; // chunk +} + +export function nodeRadius(node: GraphNode): number { + if (node.kind === 'summary') return Math.max(4, 10 - (node.level ?? 0) * 0.8); + if (node.kind === 'contact') return 9; + return 4; // chunk +} + +/** Summary / contact nodes glow; leaves stay flat so the structure pops. */ +export function nodeGlows(node: GraphNode): boolean { + return node.kind !== 'chunk'; +} + +/** A graph node carrying mutable physics state (x/y/vx/vy populated by d3-force). */ +export interface SimNode extends GraphNode, SimulationNodeDatum { + x: number; + y: number; +} + +export type SimLink = SimulationLinkDatum; + +/** + * Seed node positions on a ring centred on the origin and derive links. + * Tree mode draws an edge from each node to its `parent_id`; contacts mode + * uses the explicit `edges`. Dangling endpoints are dropped. + */ +export function buildGraph( + nodes: GraphNode[], + edges: GraphEdge[], + mode: GraphMode +): { simNodes: SimNode[]; links: SimLink[] } { + const ids = new Set(nodes.map(n => n.id)); + const simNodes: SimNode[] = nodes.map((n, i) => { + const angle = (i / Math.max(1, nodes.length)) * Math.PI * 2; + const r = 180 + (i % 7) * 14; + return { ...n, x: Math.cos(angle) * r, y: Math.sin(angle) * r }; + }); + const links: SimLink[] = []; + if (mode === 'tree') { + for (const n of nodes) { + if (!n.parent_id || !ids.has(n.parent_id) || !ids.has(n.id)) continue; + links.push({ source: n.id, target: n.parent_id }); + } + } else { + for (const e of edges) { + if (!ids.has(e.from) || !ids.has(e.to)) continue; + links.push({ source: e.from, target: e.to }); + } + } + return { simNodes, links }; +} + +/** + * A cooled d3-force simulation (call `.tick()` from the render loop). Charge + * = Coulomb repulsion (Barnes–Hut), link = Hooke spring, plus centring and + * a soft collide so nodes don't stack. + */ +export function createSimulation( + simNodes: SimNode[], + links: SimLink[] +): Simulation { + return forceSimulation(simNodes) + .force('charge', forceManyBody().strength(-140).distanceMax(420)) + .force( + 'link', + forceLink(links) + .id(d => d.id) + .distance(58) + .strength(0.35) + ) + .force('center', forceCenter(0, 0).strength(0.04)) + .force( + 'collide', + forceCollide().radius(n => nodeRadius(n) + 2) + ) + .stop(); +} + +/** + * Nearest node whose disc (radius + slop) contains the point, or null. + * Linear scan — trivial at the 1000-node cap and only runs on pointer + * events, never per frame. + */ +export function pickNode(simNodes: SimNode[], x: number, y: number, slop = 4): SimNode | null { + let best: SimNode | null = null; + let bestD = Infinity; + for (const n of simNodes) { + const r = nodeRadius(n) + slop; + const dx = n.x - x; + const dy = n.y - y; + const d = dx * dx + dy * dy; + if (d <= r * r && d < bestD) { + bestD = d; + best = n; + } + } + return best; +} + +/** Does the renderer have a usable WebGL context? Drives Pixi-vs-SVG. */ +export function supportsWebGL(): boolean { + if (typeof document === 'undefined') return false; + try { + const canvas = document.createElement('canvas'); + return !!( + canvas.getContext('webgl2') || + canvas.getContext('webgl') || + canvas.getContext('experimental-webgl') + ); + } catch { + return false; + } +} diff --git a/app/src/components/intelligence/pixiGraphRenderer.test.ts b/app/src/components/intelligence/pixiGraphRenderer.test.ts new file mode 100644 index 000000000..2790e2434 --- /dev/null +++ b/app/src/components/intelligence/pixiGraphRenderer.test.ts @@ -0,0 +1,202 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { SimLink, SimNode } from './memoryGraphLayout'; +import { mountPixiGraph } from './pixiGraphRenderer'; + +// Minimal Pixi stubs that record the handlers the renderer wires up so we +// can drive them from the test. Shared with the mock via vi.hoisted. +const h = vi.hoisted(() => { + const state: { + stageHandlers: Record void>; + canvasListeners: Record void>; + rendererHandlers: Record void>; + tickerCb: (() => void) | null; + destroyed: boolean; + } = { + stageHandlers: {}, + canvasListeners: {}, + rendererHandlers: {}, + tickerCb: null, + destroyed: false, + }; + class Graphics { + clear() { + return this; + } + circle() { + return this; + } + fill() { + return this; + } + moveTo() { + return this; + } + lineTo() { + return this; + } + stroke() { + return this; + } + } + class Container { + position = { + x: 0, + y: 0, + set(x: number, y: number) { + this.x = x; + this.y = y; + }, + }; + scale = { + x: 1, + y: 1, + set(s: number) { + this.x = s; + this.y = s; + }, + }; + children: unknown[] = []; + eventMode = ''; + hitArea: unknown = null; + addChild(c: unknown) { + this.children.push(c); + } + toLocal(p: { x: number; y: number }) { + return { + x: (p.x - this.position.x) / this.scale.x, + y: (p.y - this.position.y) / this.scale.y, + }; + } + on(ev: string, cb: (e: unknown) => void) { + state.stageHandlers[ev] = cb; + } + } + class Application { + canvas = { + style: {} as Record, + addEventListener: (ev: string, cb: (e: unknown) => void) => { + state.canvasListeners[ev] = cb; + }, + removeEventListener: vi.fn(), + }; + stage = new Container(); + screen = { width: 800, height: 600 }; + ticker = { + add: (cb: () => void) => { + state.tickerCb = cb; + }, + }; + renderer = { + on: (ev: string, cb: () => void) => { + state.rendererHandlers[ev] = cb; + }, + }; + init = vi.fn().mockResolvedValue(undefined); + destroy = vi.fn(() => { + state.destroyed = true; + }); + } + return { state, Graphics, Container, Application }; +}); + +vi.mock('pixi.js', () => ({ + Application: h.Application, + Container: h.Container, + Graphics: h.Graphics, +})); + +function makeNodes(): SimNode[] { + return [ + { kind: 'summary', id: 'root', label: 'R', level: 0, parent_id: null, x: 0, y: 0 }, + { kind: 'chunk', id: 'leaf', label: 'L', x: 200, y: 0 }, + ]; +} + +describe('mountPixiGraph', () => { + beforeEach(() => { + h.state.stageHandlers = {}; + h.state.canvasListeners = {}; + h.state.rendererHandlers = {}; + h.state.tickerCb = null; + h.state.destroyed = false; + }); + + async function mount() { + const simNodes = makeNodes(); + const links: SimLink[] = [{ source: simNodes[1], target: simNodes[0] }]; + const onHover = vi.fn(); + const onOpen = vi.fn(); + const host = { appendChild: vi.fn() } as unknown as HTMLElement; + const handle = await mountPixiGraph(host, { simNodes, links, dark: false, onHover, onOpen }); + return { handle, simNodes, onHover, onOpen, host }; + } + + it('wires interaction handlers and a render loop', async () => { + const { host } = await mount(); + expect(host.appendChild).toHaveBeenCalled(); + expect(h.state.tickerCb).toBeTypeOf('function'); + expect(h.state.stageHandlers.pointerdown).toBeTypeOf('function'); + // Render loop draws while the simulation is warm. + expect(() => h.state.tickerCb?.()).not.toThrow(); + }); + + it('opens a node on a click without movement', async () => { + const { onOpen } = await mount(); + // World is centred at (400,300); a click there maps to graph (0,0) = "root". + h.state.stageHandlers.pointerdown({ global: { x: 400, y: 300 } }); + h.state.stageHandlers.pointerup({}); + expect(onOpen).toHaveBeenCalledWith(expect.objectContaining({ id: 'root' })); + }); + + it('drags a node instead of opening it once the pointer moves', async () => { + const { onOpen, simNodes } = await mount(); + h.state.stageHandlers.pointerdown({ global: { x: 400, y: 300 } }); + h.state.stageHandlers.pointermove({ global: { x: 450, y: 320 } }); + h.state.stageHandlers.pointerup({}); + expect(onOpen).not.toHaveBeenCalled(); + // Pin released after the drag so physics can resume. + expect(simNodes[0].fx ?? null).toBeNull(); + }); + + it('emits hover when the pointer is over a node and clears it off-node', async () => { + const { onHover } = await mount(); + h.state.stageHandlers.pointermove({ global: { x: 400, y: 300 } }); // over root + expect(onHover).toHaveBeenLastCalledWith(expect.objectContaining({ id: 'root' })); + h.state.stageHandlers.pointermove({ global: { x: 10, y: 10 } }); // empty space + expect(onHover).toHaveBeenLastCalledWith(null); + }); + + it('pans on background drag', async () => { + await mount(); + // Pointer down on empty space starts a pan, not a node drag. + expect(() => { + h.state.stageHandlers.pointerdown({ global: { x: 10, y: 10 } }); + h.state.stageHandlers.pointermove({ global: { x: 60, y: 40 } }); + h.state.stageHandlers.pointerup({}); + }).not.toThrow(); + }); + + it('zooms on wheel without throwing and prevents default scroll', async () => { + await mount(); + const preventDefault = vi.fn(); + h.state.canvasListeners.wheel({ offsetX: 400, offsetY: 300, deltaY: -120, preventDefault }); + expect(preventDefault).toHaveBeenCalled(); + }); + + it('resetView, setTheme and resize redraw without error', async () => { + const { handle } = await mount(); + expect(() => { + handle.setTheme(true); + handle.resetView(); + h.state.rendererHandlers.resize?.(); + h.state.tickerCb?.(); + }).not.toThrow(); + }); + + it('destroy tears down the Pixi application', async () => { + const { handle } = await mount(); + handle.destroy(); + expect(h.state.destroyed).toBe(true); + }); +}); diff --git a/app/src/components/intelligence/pixiGraphRenderer.ts b/app/src/components/intelligence/pixiGraphRenderer.ts new file mode 100644 index 000000000..2141e8631 --- /dev/null +++ b/app/src/components/intelligence/pixiGraphRenderer.ts @@ -0,0 +1,276 @@ +/** + * WebGL memory-graph renderer — Pixi.js draw loop driven by a d3-force + * simulation. This is the same stack Obsidian's graph view uses (Pixi for + * GPU rendering, force-directed physics) so it stays smooth well past the + * 1000-node cap. + * + * The renderer is fully imperative: a React wrapper mounts it into a host + * element and feeds hover/open back through callbacks. All interaction + * (drag a node, drag the background to pan, wheel to zoom) is hit-tested + * against the simulation positions in `memoryGraphLayout`, so there are no + * per-node DOM objects — the whole graph is a single canvas. + * + * Drawing is dirty-flagged: while the simulation is warm (or the user is + * interacting) we redraw each frame; once it cools the loop idles. + */ +import { Application, Container, type FederatedPointerEvent, Graphics } from 'pixi.js'; + +import { + createSimulation, + nodeColor, + nodeGlows, + nodeRadius, + pickNode, + type SimLink, + type SimNode, + ZOOM_MAX, + ZOOM_MIN, +} from './memoryGraphLayout'; + +export interface PixiGraphOptions { + simNodes: SimNode[]; + links: SimLink[]; + dark: boolean; + onHover: (node: SimNode | null) => void; + onOpen: (node: SimNode) => void; +} + +export interface PixiGraphHandle { + resetView(): void; + setTheme(dark: boolean): void; + destroy(): void; +} + +function colorNum(hex: string): number { + return parseInt(hex.replace('#', ''), 16); +} + +/** + * Mount a Pixi graph into `host`. Resolves once the WebGL context is live; + * rejects/throws if Pixi can't initialise (caller falls back to SVG). + */ +export async function mountPixiGraph( + host: HTMLElement, + opts: PixiGraphOptions +): Promise { + const app = new Application(); + await app.init({ + resizeTo: host, + backgroundAlpha: 0, + antialias: true, + autoDensity: true, + resolution: typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, + // Match Obsidian — force the WebGL backend rather than letting Pixi + // probe WebGPU, which is uneven across the CEF runtime. + preference: 'webgl', + }); + host.appendChild(app.canvas); + app.canvas.style.width = '100%'; + app.canvas.style.height = '100%'; + app.canvas.style.display = 'block'; + + const world = new Container(); + const edgeG = new Graphics(); + const nodeG = new Graphics(); + world.addChild(edgeG); + world.addChild(nodeG); + app.stage.addChild(world); + + const recenter = () => world.position.set(app.screen.width / 2, app.screen.height / 2); + recenter(); + + const sim = createSimulation(opts.simNodes, opts.links); + sim.alpha(1); + + let dark = opts.dark; + let dirty = true; + let hoveredId: string | null = null; + // Auto-fit the whole graph into view until the user pans/zooms/drags, + // so the initial frame is zoomed out to show as much as possible. + let userInteracted = false; + + /** Scale + centre the world so every node's disc fits the viewport. */ + const fitToView = () => { + if (opts.simNodes.length === 0) return; + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (const n of opts.simNodes) { + const r = nodeRadius(n) + 6; + if (n.x - r < minX) minX = n.x - r; + if (n.y - r < minY) minY = n.y - r; + if (n.x + r > maxX) maxX = n.x + r; + if (n.y + r > maxY) maxY = n.y + r; + } + if (!Number.isFinite(minX)) return; + const pad = 48; + const w = Math.max(1, maxX - minX); + const h = Math.max(1, maxY - minY); + const scale = Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, Math.min((app.screen.width - pad) / w, (app.screen.height - pad) / h)) + ); + world.scale.set(scale); + const cx = (minX + maxX) / 2; + const cy = (minY + maxY) / 2; + world.position.set(app.screen.width / 2 - cx * scale, app.screen.height / 2 - cy * scale); + }; + + const draw = () => { + edgeG.clear(); + for (const l of opts.links) { + const s = l.source as SimNode; + const t = l.target as SimNode; + if (!s || !t || typeof s.x !== 'number' || typeof t.x !== 'number') continue; + edgeG.moveTo(s.x, s.y); + edgeG.lineTo(t.x, t.y); + } + edgeG.stroke({ width: 0.8, color: dark ? 0x475569 : 0xcbd5e1, alpha: 0.7 }); + + nodeG.clear(); + // Halos first so the structural levels "light up" beneath the discs. + for (const n of opts.simNodes) { + if (!nodeGlows(n)) continue; + nodeG + .circle(n.x, n.y, nodeRadius(n) + 5) + .fill({ color: colorNum(nodeColor(n)), alpha: 0.18 }); + } + for (const n of opts.simNodes) { + const hover = n.id === hoveredId; + const r = nodeRadius(n) + (hover ? 2 : 0); + nodeG.circle(n.x, n.y, r).fill({ color: colorNum(nodeColor(n)), alpha: 1 }); + if (hover) nodeG.circle(n.x, n.y, r).stroke({ width: 1.4, color: 0x0f172a, alpha: 0.9 }); + } + }; + + app.ticker.add(() => { + let changed = dirty; + if (sim.alpha() > sim.alphaMin()) { + sim.tick(); + changed = true; + } + if (changed) { + // Keep the whole graph framed while it settles, until the user + // takes over the camera. + if (!userInteracted) fitToView(); + draw(); + dirty = false; + } + }); + + // ── interaction ──────────────────────────────────────────────────── + app.stage.eventMode = 'static'; + app.stage.hitArea = app.screen; + let drag: + | { node: SimNode; moved: boolean } + | { panX: number; panY: number; px: number; py: number; moved: boolean } + | null = null; + + const setCursor = (c: string) => { + app.canvas.style.cursor = c; + }; + + app.stage.on('pointerdown', (e: FederatedPointerEvent) => { + userInteracted = true; // hand the camera to the user + const p = world.toLocal(e.global); + const node = pickNode(opts.simNodes, p.x, p.y); + if (node) { + sim.alpha(0.3); + node.fx = node.x; + node.fy = node.y; + drag = { node, moved: false }; + setCursor('grabbing'); + } else { + drag = { + panX: world.position.x, + panY: world.position.y, + px: e.global.x, + py: e.global.y, + moved: false, + }; + setCursor('grabbing'); + } + }); + + app.stage.on('pointermove', (e: FederatedPointerEvent) => { + if (drag) { + if ('node' in drag) { + const p = world.toLocal(e.global); + drag.node.fx = p.x; + drag.node.fy = p.y; + drag.moved = true; + if (sim.alpha() < 0.1) sim.alpha(0.1); + } else { + world.position.set(drag.panX + (e.global.x - drag.px), drag.panY + (e.global.y - drag.py)); + drag.moved = true; + } + dirty = true; + return; + } + const p = world.toLocal(e.global); + const node = pickNode(opts.simNodes, p.x, p.y); + const id = node ? node.id : null; + setCursor(node ? 'pointer' : 'grab'); + if (id !== hoveredId) { + hoveredId = id; + dirty = true; + opts.onHover(node ?? null); + } + }); + + const endDrag = (open: boolean) => { + const d = drag; + if (d && 'node' in d) { + // Release the pin so physics resumes for that node. + d.node.fx = null; + d.node.fy = null; + if (open && !d.moved) opts.onOpen(d.node); + } + drag = null; + setCursor('grab'); + }; + app.stage.on('pointerup', () => endDrag(true)); + app.stage.on('pointerupoutside', () => endDrag(false)); + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + userInteracted = true; + const gx = e.offsetX; + const gy = e.offsetY; + // Graph point under the cursor, kept fixed across the zoom. + const lx = (gx - world.position.x) / world.scale.x; + const ly = (gy - world.position.y) / world.scale.y; + const next = Math.min( + ZOOM_MAX, + Math.max(ZOOM_MIN, world.scale.x * Math.exp(-e.deltaY * 0.0015)) + ); + world.scale.set(next); + world.position.set(gx - lx * next, gy - ly * next); + dirty = true; + }; + app.canvas.addEventListener('wheel', onWheel, { passive: false }); + + app.renderer.on('resize', () => { + dirty = true; + }); + + return { + resetView() { + // Re-enable auto-fit and reheat so the graph re-frames itself. + userInteracted = false; + sim.alpha(0.3); + dirty = true; + }, + setTheme(next: boolean) { + dark = next; + dirty = true; + }, + destroy() { + sim.stop(); + app.canvas.removeEventListener('wheel', onWheel); + // destroy(true) tears down the canvas + GPU resources. + app.destroy(true, { children: true }); + }, + }; +} diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index be57d0c4c..c89c01610 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -264,6 +264,7 @@ const messages: TranslationMap = { 'memory.noResults': 'لم يتم العثور على ذكريات', 'memory.empty': 'لا توجد ذكريات بعد. تُنشأ الذكريات تلقائيًا أثناء تفاعلك.', 'memory.tab.memory': 'الذاكرة', + 'memory.tab.memoryTree': 'شجرة الذاكرة', 'memory.tab.tasks': 'مهام الوكيل', 'memory.tab.tasksDescription': 'أنشئ المهام وتتبعها — مهامك الخاصة بالإضافة إلى اللوحات التي يبنيها وكلاؤك عبر المحادثات.', @@ -1865,6 +1866,7 @@ const messages: TranslationMap = { 'graph.children': 'أبناء', 'graph.clickToOpenObsidian': 'انقر للفتح في Obsidian', 'graph.person': 'شخص', + 'graph.resetView': 'إعادة ضبط العرض', 'modal.dontShowAgain': 'لا تظهر اقتراحات مماثلة', 'reflections.loading': 'جارٍ تحميل التأملات...', 'reflections.empty': 'لا توجد تأملات بعد', @@ -4026,61 +4028,6 @@ const messages: TranslationMap = { 'تكوين إعدادات فرز الذكاء الاصطناعي لمشغلات التكامل Composio', 'memory.sourceFilterAria': 'التصفية حسب المصدر', 'calls.comingSoonDescription': 'المكالمات بمساعدة الذكاء الاصطناعي قادمة قريباً. ابقَ على اطلاع.', - 'vault.title': 'خزائن المعرفة', - 'vault.description': 'أشر إلى مجلد محلي؛ يتم تقسيم الملفات وعكسها في الذاكرة.', - 'vault.add': 'إضافة مخزن', - 'vault.added': 'تمت إضافة المخزن', - 'vault.createdMessage': 'تم إنشاء "{name}". انقر فوق {sync} لاستيعابها.', - 'vault.couldNotAdd': 'تعذر إضافة مخزن', - 'vault.syncFailed': 'فشلت المزامنة', - 'vault.syncFailedFor': 'فشلت المزامنة لـ "{name}"', - 'vault.syncFailedFiles': 'فشل ملف (ملفات) {count}', - 'vault.syncedTitle': 'تمت مزامنة "{name}"', - 'vault.syncSummary': 'تم استيعابها {ingested}، دون تغيير {unchanged}، تمت إزالة {removed}', - 'vault.syncSummaryFailed': ', فشل {count}', - 'vault.syncSummarySkipped': '، تم تخطي {count}', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - 'إزالة الخزنة "{name}"؟\n\nانقر «موافق» لحذف ذاكرتها أيضاً (حذف جميع {count} مستند/مستندات مستوعبة).\nانقر «إلغاء» للاحتفاظ بالمستندات في الذاكرة.', - 'vault.confirmRemove': 'هل تريد حقًا إزالة المخزن "{name}"؟', - 'vault.removed': 'تمت إزالة المخزن', - 'vault.removedPurgedMessage': 'تمت إزالة "{name}" وتطهير ذاكرته.', - 'vault.removedKeptMessage': 'تمت إزالة "{name}". الوثائق المحفوظة في الذاكرة.', - 'vault.couldNotRemove': 'تعذرت إزالة المخزن', - 'vault.name': 'الاسم', - 'vault.namePlaceholder': 'ملاحظاتي البحثية', - 'vault.folderPath': 'مسار المجلد (مطلق)', - 'vault.folderPathPlaceholder': '/Users/you/Documents/notes', - 'vault.excludes': 'باستثناء (سلاسل فرعية مفصولة بفواصل، اختيارية)', - 'vault.excludesPlaceholder': 'المسودات/، .secret', - 'vault.creating': 'إنشاء...', - 'vault.create': 'إنشاء مخزن', - 'vault.loading': 'تحميل الخزائن...', - 'vault.failedToLoad': 'فشل تحميل الخزائن: {error}', - 'vault.empty': 'لا توجد خزائن حتى الآن. أضف واحدًا أعلاه لبدء استيعاب مجلد.', - 'vault.fileCount': '{count} ملف (ملفات)', - 'vault.syncedRelative': 'تمت مزامنته {time}', - 'vault.neverSynced': 'لم تتم مزامنته أبدًا', - 'vault.writeState.writable': 'قابل للكتابة', - 'vault.writeState.read_only': 'للقراءة فقط', - 'vault.writeState.unavailable': 'غير متاح', - 'vault.writeState.unknownReason': 'حالة الكتابة غير معروفة.', - 'vault.writeState.reasons.writable': - 'يمكن حفظ عمليات كتابة Markdown/wiki الموافق عليها في هذا المخزن.', - 'vault.writeState.reasons.read_only': 'مجلد المخزن للقراءة فقط على هذا الجهاز.', - 'vault.writeState.reasons.unavailable': 'مجلد المخزن غير متاح على هذا الجهاز.', - 'vault.writeState.reasons.not_directory': 'مسار المخزن ليس دليلاً.', - 'vault.writeState.reasons.empty_path': 'مسار مجلد المخزن فارغ.', - 'vault.syncingProgress': 'جارٍ المزامنة… {ingested}/{total}', - 'vault.removing': 'جارٍ الإزالة...', - 'vault.relative.sec': 'قبل {count}s', - 'vault.relative.min': 'قبل {count}m', - 'vault.relative.hr': 'قبل {count} قبل', - 'vault.relative.day': 'قبل {count}d', - 'vault.openButton': 'فتح', - 'vault.openSuccess': 'تم الفتح في Obsidian', - 'vault.openFallback': 'لم يتم العثور على Obsidian — تم الفتح في مدير الملفات', - 'vault.openError': 'تعذر فتح المخزن', 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 دقائق', 'subconscious.interval.tenMinutes': '10 دقائق', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 9ec2d1481..694a2b66c 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -267,6 +267,7 @@ const messages: TranslationMap = { 'memory.empty': 'এখনো কোনো মেমোরি নেই। আপনি যত ইন্টারঅ্যাক্ট করবেন, মেমোরি স্বয়ংক্রিয়ভাবে তৈরি হবে।', 'memory.tab.memory': 'মেমোরি', + 'memory.tab.memoryTree': 'মেমোরি ট্রি', 'memory.tab.tasks': 'এজেন্ট টাস্ক', 'memory.tab.tasksDescription': 'টাস্ক তৈরি করুন এবং ট্র্যাক করুন — আপনার নিজের কাজের তালিকা এবং এজেন্টরা কথোপকথন জুড়ে যে বোর্ডগুলি তৈরি করে।', @@ -1902,6 +1903,7 @@ const messages: TranslationMap = { 'graph.children': 'চাইল্ড', 'graph.clickToOpenObsidian': 'Obsidian-এ খুলতে ক্লিক করুন', 'graph.person': 'ব্যক্তি', + 'graph.resetView': 'ভিউ রিসেট করুন', 'modal.dontShowAgain': 'একই ধরনের পরামর্শ আর দেখাবেন না', 'reflections.loading': 'প্রতিফলন লোড হচ্ছে...', 'reflections.empty': 'এখনো কোনো প্রতিফলন নেই', @@ -4092,61 +4094,6 @@ const messages: TranslationMap = { 'Composio ইন্টিগ্রেশন ট্রিগারের জন্য AI ট্রাইজ সেটিংস কনফিগার করুন', 'memory.sourceFilterAria': 'উত্স দ্বারা ফিল্টার', 'calls.comingSoonDescription': 'AI-সহায়তা কলগুলি শীঘ্রই আসছে৷ সাথে থাকুন।', - 'vault.title': 'নলেজ ভল্ট', - 'vault.description': - 'একটি স্থানীয় ফোল্ডার নির্দেশ করুন; ফাইলগুলি চাঙ্ক করা হয় এবং মেমোরিতে মিরর করা হয়।', - 'vault.add': 'ভল্ট যোগ করুন', - 'vault.added': 'ভল্ট যোগ করা হয়েছে', - 'vault.createdMessage': 'তৈরি করা হয়েছে "{name}"। ইনজেস্ট করতে {sync} এ ক্লিক করুন।', - 'vault.couldNotAdd': 'ভল্ট যোগ করা যায়নি', - 'vault.syncFailed': 'সিঙ্ক ব্যর্থ হয়েছে', - 'vault.syncFailedFor': '"{name}"-এর জন্য সিঙ্ক ব্যর্থ হয়েছে', - 'vault.syncFailedFiles': 'ফাইল __000 এর জন্য সিঙ্ক ব্যর্থ হয়েছে', - 'vault.syncedTitle': 'সিঙ্ক হয়েছে "{name}"', - 'vault.syncSummary': 'ইনজেস্ট {ingested}, অপরিবর্তিত {unchanged}, সরানো {removed}', - 'vault.syncSummaryFailed': ', ব্যর্থ হয়েছে {count}', - 'vault.syncSummarySkipped': '__PH এড়িয়ে গেছে', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - 'ভল্ট "{name}" সরিয়ে দেবেন?\n\nOK ক্লিক করুন এর মেমোরিও মুছে ফেলতে (সমস্ত {count}টি ইনজেস্ট করা ডকুমেন্ট মুছুন)।\nডকুমেন্টগুলি মেমোরিতে রাখতে Cancel ক্লিক করুন।', - 'vault.confirmRemove': 'সত্যিই ভল্ট "{name}" সরান?', - 'vault.removed': 'ভল্ট সরানো হয়েছে', - 'vault.removedPurgedMessage': '"{name}" সরানো হয়েছে এবং এর মেমরি পরিষ্কার করেছে৷', - 'vault.removedKeptMessage': '"{name}" সরানো হয়েছে। নথি মেমরি রাখা.', - 'vault.couldNotRemove': 'ভল্ট সরানো যায়নি', - 'vault.name': 'নাম', - 'vault.namePlaceholder': 'আমার গবেষণা নোট', - 'vault.folderPath': 'ফোল্ডার পাথ (পরম)', - 'vault.folderPathPlaceholder': '/Users/you/Documents/notes', - 'vault.excludes': 'বাদ দেয় (কমা দ্বারা পৃথক করা সাবস্ট্রিং, ঐচ্ছিক)', - 'vault.excludesPlaceholder': 'drafts/, .secret', - 'vault.creating': 'তৈরি করা হচ্ছে...', - 'vault.create': 'ভল্ট তৈরি করুন', - 'vault.loading': 'ভল্ট লোড হচ্ছে...', - 'vault.failedToLoad': 'ভল্ট লোড করতে ব্যর্থ হয়েছে: {error}', - 'vault.empty': 'এখনো কোনো ভল্ট নেই। একটি ফোল্ডার ইনজেস্ট করা শুরু করতে উপরে একটি যোগ করুন।', - 'vault.fileCount': '{count} ফাইল(গুলি)', - 'vault.syncedRelative': 'সিঙ্ক করা হয়েছে {time}', - 'vault.neverSynced': 'কখনই সিঙ্ক করা হয়নি', - 'vault.writeState.writable': 'লেখার যোগ্য', - 'vault.writeState.read_only': 'শুধু-পঠন', - 'vault.writeState.unavailable': 'অনুপলব্ধ', - 'vault.writeState.unknownReason': 'লেখার অবস্থা অজানা।', - 'vault.writeState.reasons.writable': 'অনুমোদিত Markdown/wiki লেখা এই ভল্টে সংরক্ষণ করা যাবে।', - 'vault.writeState.reasons.read_only': 'এই ডিভাইসে ভল্ট ফোল্ডারটি শুধু-পঠন।', - 'vault.writeState.reasons.unavailable': 'এই ডিভাইসে ভল্ট ফোল্ডারটি উপলব্ধ নয়।', - 'vault.writeState.reasons.not_directory': 'ভল্ট পাথটি কোনো ফোল্ডার নয়।', - 'vault.writeState.reasons.empty_path': 'ভল্ট ফোল্ডার পাথ খালি।', - 'vault.syncingProgress': 'সিঙ্ক হচ্ছে... {ingested}/{total}', - 'vault.removing': 'সরানো হচ্ছে...', - 'vault.relative.sec': '{count}সেকেন্ড আগে', - 'vault.relative.min': '{count}মি আগে', - 'vault.relative.hr': '{count}সেকেন্ড আগে', - 'vault.relative.day': '{count}দিন আগে', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 মিনিট', 'subconscious.interval.tenMinutes': '10 মিনিট', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b3ae7764b..5b2907a8e 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -276,6 +276,7 @@ const messages: TranslationMap = { 'memory.empty': 'Noch keine Erinnerungen. Erinnerungen werden automatisch erstellt, während du interagierst.', 'memory.tab.memory': 'Erinnerung', + 'memory.tab.memoryTree': 'Erinnerungsbaum', 'memory.tab.tasks': 'Agent-Aufgaben', 'memory.tab.tasksDescription': 'Aufgaben erstellen und verfolgen – eigene To-dos sowie die Boards, die Agenten in Gesprächen anlegen.', @@ -1950,6 +1951,7 @@ const messages: TranslationMap = { 'graph.children': 'Kinder', 'graph.clickToOpenObsidian': 'Klicke hier, um in Obsidian zu öffnen', 'graph.person': 'Person', + 'graph.resetView': 'Ansicht zurücksetzen', 'modal.dontShowAgain': 'Ähnliche Vorschläge nicht anzeigen', 'reflections.loading': 'Reflexionen werden geladen...', 'reflections.empty': 'Noch keine Überlegungen', @@ -4205,62 +4207,6 @@ const messages: TranslationMap = { 'Konfiguriere KI-Triage-Einstellungen für Composio-Integrationsauslöser', 'memory.sourceFilterAria': 'Nach Quelle filtern', 'calls.comingSoonDescription': 'KI-unterstützte Anrufe folgen in Kürze. Bleiben Sie dran.', - 'vault.title': 'Wissensdepots', - 'vault.description': - 'Lokalen Ordner angeben; Dateien werden in Fragmente aufgeteilt und in den Speicher gespiegelt.', - 'vault.add': 'Tresor hinzufügen', - 'vault.added': 'Tresor hinzugefügt', - 'vault.createdMessage': 'Erstellt „{name}“. Klicken Sie zum Aufnehmen auf {sync}.', - 'vault.couldNotAdd': 'Tresor konnte nicht hinzugefügt werden', - 'vault.syncFailed': 'Synchronisierung fehlgeschlagen', - 'vault.syncFailedFor': 'Synchronisierung für „{name}“ fehlgeschlagen', - 'vault.syncFailedFiles': '{count}-Datei(en) fehlgeschlagen', - 'vault.syncedTitle': 'Synchronisierung „{name}“ fehlgeschlagen', - 'vault.syncSummary': 'Aufgenommen {ingested}, unverändert {unchanged}, entfernt {removed}', - 'vault.syncSummaryFailed': ', fehlgeschlagen {count}', - 'vault.syncSummarySkipped': ', übersprungen {count}', - 'vault.syncSummaryDuration': '· {seconds}s', - 'vault.confirmRemovePurge': - 'Vault "{name}" entfernen?\n\nOK klicken, um auch den Speicher zu löschen (alle {count} aufgenommenen Dokument(e) entfernen).\nAbbrechen klicken, um die Dokumente im Speicher zu behalten.', - 'vault.confirmRemove': 'Tresor „{name}“ wirklich entfernen?', - 'vault.removed': 'Tresor entfernt', - 'vault.removedPurgedMessage': '„{name}“ entfernt und seinen Speicher geleert.', - 'vault.removedKeptMessage': '„{name}“ entfernt. Dokumente im Gedächtnis behalten.', - 'vault.couldNotRemove': 'Tresor konnte nicht entfernt werden.', - 'vault.name': 'Name', - 'vault.namePlaceholder': 'Meine Forschungsnotizen', - 'vault.folderPath': 'Ordnerpfad (absolut)', - 'vault.folderPathPlaceholder': '/Benutzer/Sie/Dokumente/Notizen', - 'vault.excludes': 'Schließt aus (durch Kommas getrennte Teilzeichenfolgen, optional)', - 'vault.excludesPlaceholder': 'drafts/, .secret', - 'vault.creating': 'Wird erstellt…', - 'vault.create': 'Tresor erstellen', - 'vault.loading': 'Wird geladen Tresore…', - 'vault.failedToLoad': 'Tresore konnten nicht geladen werden: {error}', - 'vault.empty': 'Noch keine Vaults. Oben einen hinzufügen, um einen Ordner aufzunehmen.', - 'vault.fileCount': '{count} Datei(en)', - 'vault.syncedRelative': 'synchronisiert {time}', - 'vault.neverSynced': 'nie synchronisiert', - 'vault.writeState.writable': 'Beschreibbar', - 'vault.writeState.read_only': 'Schreibgeschützt', - 'vault.writeState.unavailable': 'Nicht verfügbar', - 'vault.writeState.unknownReason': 'Schreibstatus ist unbekannt.', - 'vault.writeState.reasons.writable': - 'Genehmigte Markdown-/Wiki-Schreibvorgänge können in diesem Tresor gespeichert werden.', - 'vault.writeState.reasons.read_only': 'Der Tresorordner ist auf diesem Gerät schreibgeschützt.', - 'vault.writeState.reasons.unavailable': 'Der Tresorordner ist auf diesem Gerät nicht verfügbar.', - 'vault.writeState.reasons.not_directory': 'Der Tresorpfad ist kein Ordner.', - 'vault.writeState.reasons.empty_path': 'Der Tresorordnerpfad ist leer.', - 'vault.syncingProgress': 'Synchronisierung… {ingested}/{total}', - 'vault.removing': 'Entfernen…', - 'vault.relative.sec': 'vor {count}s', - 'vault.relative.min': 'vor {count}m', - 'vault.relative.hr': 'vor {count}h', - 'vault.relative.day': 'vor {count}d', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 Min.', 'subconscious.interval.tenMinutes': '10 Min.', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 2a61c636f..a5e3ad69f 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -292,6 +292,7 @@ const en: TranslationMap = { 'memory.noResults': 'No memories found', 'memory.empty': 'No memories yet. Memories are created automatically as you interact.', 'memory.tab.memory': 'Memory', + 'memory.tab.memoryTree': 'Memory Tree', 'memory.tab.tasks': 'Agent Tasks', 'memory.tab.tasksDescription': 'Create and track tasks — your own to-dos plus the boards your agents build across conversations.', @@ -2099,6 +2100,7 @@ const en: TranslationMap = { 'graph.children': 'children', 'graph.clickToOpenObsidian': 'Click to open in Obsidian', 'graph.person': 'Person', + 'graph.resetView': 'Reset view', // Modal 'modal.dontShowAgain': "Don't show similar suggestions", @@ -4362,60 +4364,6 @@ const en: TranslationMap = { 'Configure AI triage settings for Composio integration triggers', 'memory.sourceFilterAria': 'Filter by source', 'calls.comingSoonDescription': 'AI-assisted calls are coming soon. Stay tuned.', - 'vault.title': 'Knowledge vaults (Experimental)', - 'vault.description': 'Point at a local folder; files are chunked and mirrored into memory.', - 'vault.add': 'Add vault', - 'vault.added': 'Vault added', - 'vault.createdMessage': 'Created "{name}". Click {sync} to ingest.', - 'vault.couldNotAdd': 'Could not add vault', - 'vault.syncFailed': 'Sync failed', - 'vault.syncFailedFor': 'Sync failed for "{name}"', - 'vault.syncFailedFiles': 'Failed {count} file(s)', - 'vault.syncedTitle': 'Synced "{name}"', - 'vault.syncSummary': 'Ingested {ingested}, unchanged {unchanged}, removed {removed}', - 'vault.syncSummaryFailed': ', failed {count}', - 'vault.syncSummarySkipped': ', skipped {count}', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - 'Remove vault "{name}"?\n\nClick OK to also purge its memory (delete all {count} ingested document(s)).\nClick Cancel to keep the documents in memory.', - 'vault.confirmRemove': 'Really remove vault "{name}"?', - 'vault.removed': 'Vault removed', - 'vault.removedPurgedMessage': 'Removed "{name}" and purged its memory.', - 'vault.removedKeptMessage': 'Removed "{name}". Documents kept in memory.', - 'vault.couldNotRemove': 'Could not remove vault', - 'vault.name': 'Name', - 'vault.namePlaceholder': 'My research notes', - 'vault.folderPath': 'Folder path (absolute)', - 'vault.folderPathPlaceholder': '/Users/you/Documents/notes', - 'vault.excludes': 'Excludes (comma-separated substrings, optional)', - 'vault.excludesPlaceholder': 'drafts/, .secret', - 'vault.creating': 'Creating…', - 'vault.create': 'Create vault', - 'vault.loading': 'Loading vaults…', - 'vault.failedToLoad': 'Failed to load vaults: {error}', - 'vault.empty': 'No vaults yet. Add one above to start ingesting a folder.', - 'vault.fileCount': '{count} file(s)', - 'vault.syncedRelative': 'synced {time}', - 'vault.neverSynced': 'never synced', - 'vault.writeState.writable': 'Writable', - 'vault.writeState.read_only': 'Read-only', - 'vault.writeState.unavailable': 'Unavailable', - 'vault.writeState.unknownReason': 'Write state is unknown.', - 'vault.writeState.reasons.writable': 'Approved markdown/wiki writes can be saved in this vault.', - 'vault.writeState.reasons.read_only': 'Vault folder is read-only on this device.', - 'vault.writeState.reasons.unavailable': 'Vault folder is not available on this device.', - 'vault.writeState.reasons.not_directory': 'Vault path is not a directory.', - 'vault.writeState.reasons.empty_path': 'Vault folder path is empty.', - 'vault.syncingProgress': 'Syncing… {ingested}/{total}', - 'vault.removing': 'Removing…', - 'vault.relative.sec': '{count}s ago', - 'vault.relative.min': '{count}m ago', - 'vault.relative.hr': '{count}h ago', - 'vault.relative.day': '{count}d ago', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 min', 'subconscious.interval.tenMinutes': '10 min', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 42d9faf05..67113cb24 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -276,6 +276,7 @@ const messages: TranslationMap = { 'memory.noResults': 'No se encontraron recuerdos', 'memory.empty': 'Sin recuerdos aún. Los recuerdos se crean automáticamente mientras interactúas.', 'memory.tab.memory': 'Memoria', + 'memory.tab.memoryTree': 'Árbol de memoria', 'memory.tab.tasks': 'Tareas del agente', 'memory.tab.tasksDescription': 'Crea y realiza un seguimiento de tareas: tus propios pendientes más los tableros que tus agentes crean a lo largo de las conversaciones.', @@ -1942,6 +1943,7 @@ const messages: TranslationMap = { 'graph.children': 'hijos', 'graph.clickToOpenObsidian': 'Clic para abrir en Obsidian', 'graph.person': 'Persona', + 'graph.resetView': 'Restablecer vista', 'modal.dontShowAgain': 'No mostrar sugerencias similares', 'reflections.loading': 'Cargando reflexiones...', 'reflections.empty': 'Sin reflexiones aún', @@ -4171,64 +4173,6 @@ const messages: TranslationMap = { 'Configurar los ajustes de clasificación de IA para los activadores de integración Composio', 'memory.sourceFilterAria': 'Filtrar por fuente', 'calls.comingSoonDescription': 'Las llamadas asistidas por IA llegarán pronto. Mantente atento.', - 'vault.title': 'Bóvedas de conocimiento', - 'vault.description': - 'Apunta a una carpeta local; los archivos se fragmentan y se reflejan en la memoria.', - 'vault.add': 'Agregar bóveda', - 'vault.added': 'Bóveda agregada', - 'vault.createdMessage': 'Creado "{name}". Haga clic en {sync} para ingerir.', - 'vault.couldNotAdd': 'No se pudo agregar la bóveda', - 'vault.syncFailed': 'Error de sincronización', - 'vault.syncFailedFor': 'Error de sincronización para "{name}"', - 'vault.syncFailedFiles': 'Archivo(s) {count} fallidos', - 'vault.syncedTitle': 'Sincronizado "{name}"', - 'vault.syncSummary': 'Ingerido {ingested}, sin cambios {unchanged}, eliminado {removed}', - 'vault.syncSummaryFailed': ', falló {count}', - 'vault.syncSummarySkipped': ', omitido {count}', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - '¿Eliminar el almacén «{name}»?\n\nHaz clic en Aceptar para purgar también su memoria (eliminar todos los {count} documento(s) procesados).\nHaz clic en Cancelar para conservar los documentos en memoria.', - 'vault.confirmRemove': '¿Realmente eliminar la bóveda "{name}"?', - 'vault.removed': 'Bóveda eliminada', - 'vault.removedPurgedMessage': 'Se eliminó "{name}" y se borró la memoria.', - 'vault.removedKeptMessage': 'Se eliminó "{name}". Documentos guardados en la memoria.', - 'vault.couldNotRemove': 'No se pudo eliminar la bóveda', - 'vault.name': 'Nombre', - 'vault.namePlaceholder': 'mis notas de investigacion', - 'vault.folderPath': 'Ruta de la carpeta (absoluta)', - 'vault.folderPathPlaceholder': '/Usuarios/usted/Documentos/notas', - 'vault.excludes': 'Excluye (subcadenas separadas por comas, opcional)', - 'vault.excludesPlaceholder': 'borradores/, .secreto', - 'vault.creating': 'Creando…', - 'vault.create': 'Crear bóveda', - 'vault.loading': 'Cargando bóvedas…', - 'vault.failedToLoad': 'No se pudieron cargar las bóvedas: {error}', - 'vault.empty': 'Aún no hay bóvedas. Agregue uno arriba para comenzar a ingerir una carpeta.', - 'vault.fileCount': '{count} archivo(s)', - 'vault.syncedRelative': 'sincronizado {time}', - 'vault.neverSynced': 'nunca sincronizado', - 'vault.writeState.writable': 'Editable', - 'vault.writeState.read_only': 'Solo lectura', - 'vault.writeState.unavailable': 'No disponible', - 'vault.writeState.unknownReason': 'Se desconoce el estado de escritura.', - 'vault.writeState.reasons.writable': - 'Las escrituras Markdown/wiki aprobadas se pueden guardar en esta bóveda.', - 'vault.writeState.reasons.read_only': - 'La carpeta de la bóveda es de solo lectura en este dispositivo.', - 'vault.writeState.reasons.unavailable': - 'La carpeta de la bóveda no está disponible en este dispositivo.', - 'vault.writeState.reasons.not_directory': 'La ruta de la bóveda no es una carpeta.', - 'vault.writeState.reasons.empty_path': 'La ruta de la carpeta de la bóveda está vacía.', - 'vault.syncingProgress': 'Sincronizando... {ingested}/{total}', - 'vault.removing': 'Eliminando…', - 'vault.relative.sec': 'Hace {count}s', - 'vault.relative.min': 'Hace {count}m', - 'vault.relative.hr': 'Hace {count}h', - 'vault.relative.day': 'Hace {count}d', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 minutos', 'subconscious.interval.tenMinutes': '10 minutos', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index d76e393c0..4012a3f3f 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -275,6 +275,7 @@ const messages: TranslationMap = { 'memory.empty': "Aucun souvenir pour l'instant. Les souvenirs sont créés automatiquement au fil de tes interactions.", 'memory.tab.memory': 'Mémoire', + 'memory.tab.memoryTree': 'Arbre de mémoire', 'memory.tab.tasks': "Tâches de l'agent", 'memory.tab.tasksDescription': 'Créez et suivez des tâches — vos propres listes de choses à faire ainsi que les tableaux que vos agents construisent au fil des conversations.', @@ -1948,6 +1949,7 @@ const messages: TranslationMap = { 'graph.children': 'enfants', 'graph.clickToOpenObsidian': 'Clique pour ouvrir dans Obsidian', 'graph.person': 'Personne', + 'graph.resetView': 'Réinitialiser la vue', 'modal.dontShowAgain': 'Ne plus afficher de suggestions similaires', 'reflections.loading': 'Chargement des réflexions…', 'reflections.empty': 'Pas encore de réflexions', @@ -4186,65 +4188,6 @@ const messages: TranslationMap = { "Configurez les paramètres de triage IA pour les déclencheurs d'intégration Composio", 'memory.sourceFilterAria': 'Filtrer par source', 'calls.comingSoonDescription': "Les appels assistés par IA arrivent bientôt. Restez à l'écoute.", - 'vault.title': 'Coffres de connaissances', - 'vault.description': - 'Pointez vers un dossier local ; les fichiers sont découpés et mis en miroir dans la mémoire.', - 'vault.add': 'Ajouter un coffre-fort', - 'vault.added': 'Coffre-fort ajouté', - 'vault.createdMessage': 'Création de "{name}". Cliquez sur {sync} pour ingérer.', - 'vault.couldNotAdd': "Impossible d'ajouter le coffre", - 'vault.syncFailed': 'Échec de la synchronisation', - 'vault.syncFailedFor': 'Échec de la synchronisation pour « {name} »', - 'vault.syncFailedFiles': 'Échec de {count} fichier(s)', - 'vault.syncedTitle': '"{name}" synchronisé', - 'vault.syncSummary': '{ingested} ingéré, {unchanged} inchangé, supprimé {removed}', - 'vault.syncSummaryFailed': ', échec {count}', - 'vault.syncSummarySkipped': ', ignoré {count}', - 'vault.syncSummaryDuration': '· {seconds}s', - 'vault.confirmRemovePurge': - 'Supprimer le coffre « {name} » ?\n\nCliquez sur OK pour purger également sa mémoire (supprimer les {count} document(s) ingéré(s)).\nCliquez sur Annuler pour conserver les documents en mémoire.', - 'vault.confirmRemove': 'Voulez-vous vraiment supprimer le coffre-fort « {name} » ?', - 'vault.removed': 'Vault supprimé', - 'vault.removedPurgedMessage': 'Supprimé "{name}" et purgé sa mémoire.', - 'vault.removedKeptMessage': 'Suppression de "{name}". Documents conservés en mémoire.', - 'vault.couldNotRemove': 'Impossible de supprimer le coffre-fort', - 'vault.name': 'Nom', - 'vault.namePlaceholder': 'Mes notes de recherche', - 'vault.folderPath': 'Chemin du dossier (absolu)', - 'vault.folderPathPlaceholder': '/Utilisateurs/vous/Documents/notes', - 'vault.excludes': 'Exclut (sous-chaînes séparées par des virgules, facultatif)', - 'vault.excludesPlaceholder': 'drafts/, .secret', - 'vault.creating': 'Création…', - 'vault.create': 'Créer un coffre-fort', - 'vault.loading': 'Chargement coffres-forts…', - 'vault.failedToLoad': 'Échec du chargement des coffres-forts : {error}', - 'vault.empty': - "Aucun coffre pour l'instant. Ajoutez-en un ci-dessus pour commencer à ingérer un dossier.", - 'vault.fileCount': '{count} fichier(s)', - 'vault.syncedRelative': 'synchronisés {time}', - 'vault.neverSynced': 'jamais synchronisés', - 'vault.writeState.writable': 'Modifiable', - 'vault.writeState.read_only': 'Lecture seule', - 'vault.writeState.unavailable': 'Indisponible', - 'vault.writeState.unknownReason': "L'état d'écriture est inconnu.", - 'vault.writeState.reasons.writable': - 'Les écritures Markdown/wiki approuvées peuvent être enregistrées dans ce coffre.', - 'vault.writeState.reasons.read_only': - 'Le dossier du coffre est en lecture seule sur cet appareil.', - 'vault.writeState.reasons.unavailable': - "Le dossier du coffre n'est pas disponible sur cet appareil.", - 'vault.writeState.reasons.not_directory': "Le chemin du coffre n'est pas un dossier.", - 'vault.writeState.reasons.empty_path': 'Le chemin du dossier du coffre est vide.', - 'vault.syncingProgress': 'Synchronisation… {ingested}/{total}', - 'vault.removing': 'Suppression…', - 'vault.relative.sec': 'il y a {count}s', - 'vault.relative.min': 'il y a {count}m', - 'vault.relative.hr': 'il y a {count}h', - 'vault.relative.day': '{count}d', - 'vault.openButton': 'Ouvrir', - 'vault.openSuccess': 'Ouvert dans Obsidian', - 'vault.openFallback': 'Obsidian introuvable — ouvert dans le gestionnaire de fichiers', - 'vault.openError': 'Impossible d\u0027ouvrir le coffre', 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 min', 'subconscious.interval.tenMinutes': '10 min', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 5c149a403..36d8e1c85 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -266,6 +266,7 @@ const messages: TranslationMap = { 'memory.noResults': 'कोई मेमोरी नहीं मिली', 'memory.empty': 'अभी कोई मेमोरी नहीं है। बातचीत के दौरान मेमोरी अपने आप बनती है।', 'memory.tab.memory': 'मेमोरी', + 'memory.tab.memoryTree': 'मेमोरी ट्री', 'memory.tab.tasks': 'एजेंट कार्य', 'memory.tab.tasksDescription': 'कार्य बनाएं और ट्रैक करें — आपके अपने टू-डू और साथ ही वे बोर्ड जो आपके एजेंट वार्तालापों में बनाते हैं।', @@ -1902,6 +1903,7 @@ const messages: TranslationMap = { 'graph.children': 'चाइल्ड', 'graph.clickToOpenObsidian': 'Obsidian में खोलने के लिए क्लिक करें', 'graph.person': 'व्यक्ति', + 'graph.resetView': 'व्यू रीसेट करें', 'modal.dontShowAgain': 'ऐसे सुझाव फिर न दिखाएं', 'reflections.loading': 'रिफ्लेक्शन लोड हो रहे हैं...', 'reflections.empty': 'अभी कोई रिफ्लेक्शन नहीं', @@ -4101,61 +4103,6 @@ const messages: TranslationMap = { 'Composio एकीकरण ट्रिगर के लिए AI ट्राइएज सेटिंग्स कॉन्फ़िगर करें', 'memory.sourceFilterAria': 'स्रोत के अनुसार फ़िल्टर करें', 'calls.comingSoonDescription': 'एआई-सहायक कॉल जल्द ही आ रही हैं। बने रहें।', - 'vault.title': 'ज्ञान भंडार', - 'vault.description': - 'कोई स्थानीय फ़ोल्डर चुनें; फ़ाइलें खंडों में विभाजित होकर मेमोरी में मिरर हो जाती हैं।', - 'vault.add': 'तिजोरी जोड़ें', - 'vault.added': 'तिजोरी जोड़ी गई', - 'vault.createdMessage': '"{name}" बनाया गया। निगलने के लिए {sync} पर क्लिक करें।', - 'vault.couldNotAdd': 'वॉल्ट नहीं जोड़ा जा सका', - 'vault.syncFailed': 'समन्वयन विफल', - 'vault.syncFailedFor': '"{name}" के लिए समन्वयन विफल', - 'vault.syncFailedFiles': 'विफल {count} फ़ाइल(फ़ाइलें)', - 'vault.syncedTitle': 'समन्वयित "{name}"', - 'vault.syncSummary': 'अंतर्ग्रहण {ingested}, अपरिवर्तित {unchanged}, हटाया गया {removed}', - 'vault.syncSummaryFailed': ', विफल {count}', - 'vault.syncSummarySkipped': ', छोड़ दिया गया {count}', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - 'वॉल्ट "{name}" हटाएं?\n\nOK क्लिक करें तो इसकी मेमोरी भी साफ़ हो जाएगी (सभी {count} इंजेस्टेड दस्तावेज़ हटा दिए जाएंगे)।\nCancel क्लिक करें तो दस्तावेज़ मेमोरी में रहेंगे।', - 'vault.confirmRemove': 'वास्तव में वॉल्ट "{name}" को हटा दें?', - 'vault.removed': 'तिजोरी हटा दी गई', - 'vault.removedPurgedMessage': '"{name}" को हटा दिया गया और इसकी मेमोरी को शुद्ध कर दिया गया।', - 'vault.removedKeptMessage': '"{name}" हटा दिया गया। दस्तावेज़ स्मृति में रखे गए.', - 'vault.couldNotRemove': 'तिजोरी नहीं निकाली जा सकी', - 'vault.name': 'नाम', - 'vault.namePlaceholder': 'मेरे शोध नोट्स', - 'vault.folderPath': 'फ़ोल्डर पथ (पूर्ण)', - 'vault.folderPathPlaceholder': '/उपयोगकर्ता/आप/दस्तावेज़/नोट्स', - 'vault.excludes': 'बहिष्कृत (अल्पविराम से अलग किए गए सबस्ट्रिंग, वैकल्पिक)', - 'vault.excludesPlaceholder': 'ड्राफ्ट/, .गुप्त', - 'vault.creating': 'बनाया जा रहा है...', - 'vault.create': 'तिजोरी बनाएं', - 'vault.loading': 'तिजोरी लोड हो रही है...', - 'vault.failedToLoad': 'वॉल्ट लोड करने में विफल: {error}', - 'vault.empty': 'अभी कोई वॉल्ट नहीं है। कोई फ़ोल्डर इंजेस्ट करना शुरू करने के लिए ऊपर एक जोड़ें।', - 'vault.fileCount': '{count}फ़ाइलें', - 'vault.syncedRelative': 'समन्वयित {time}', - 'vault.neverSynced': 'कभी समन्वयित नहीं किया गया', - 'vault.writeState.writable': 'लिखने योग्य', - 'vault.writeState.read_only': 'केवल पढ़ने योग्य', - 'vault.writeState.unavailable': 'उपलब्ध नहीं', - 'vault.writeState.unknownReason': 'लिखने की स्थिति अज्ञात है.', - 'vault.writeState.reasons.writable': 'स्वीकृत Markdown/wiki लेखन इस वॉल्ट में सहेजे जा सकते हैं।', - 'vault.writeState.reasons.read_only': 'इस डिवाइस पर वॉल्ट फ़ोल्डर केवल पढ़ने योग्य है।', - 'vault.writeState.reasons.unavailable': 'इस डिवाइस पर वॉल्ट फ़ोल्डर उपलब्ध नहीं है।', - 'vault.writeState.reasons.not_directory': 'वॉल्ट पथ कोई फ़ोल्डर नहीं है।', - 'vault.writeState.reasons.empty_path': 'वॉल्ट फ़ोल्डर पथ खाली है।', - 'vault.syncingProgress': 'सिंक हो रहा है... {ingested}/{total}', - 'vault.removing': 'हटाया जा रहा है...', - 'vault.relative.sec': '{count}s पहले', - 'vault.relative.min': '{count}m पहले', - 'vault.relative.hr': '{count}h पहले', - 'vault.relative.day': '{count}d पहले', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 मिनट', 'subconscious.interval.tenMinutes': '10 मिनट', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index c8542b0c4..46819010a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -268,6 +268,7 @@ const messages: TranslationMap = { 'memory.noResults': 'Memori tidak ditemukan', 'memory.empty': 'Belum ada memori. Memori dibuat otomatis saat Anda berinteraksi.', 'memory.tab.memory': 'Memori', + 'memory.tab.memoryTree': 'Pohon Memori', 'memory.tab.tasks': 'Tugas Agen', 'memory.tab.tasksDescription': 'Buat dan lacak tugas — to-do Anda sendiri beserta papan yang dibangun agen Anda di berbagai percakapan.', @@ -1905,6 +1906,7 @@ const messages: TranslationMap = { 'graph.children': 'anak', 'graph.clickToOpenObsidian': 'Klik untuk membuka di Obsidian', 'graph.person': 'Orang', + 'graph.resetView': 'Atur ulang tampilan', 'modal.dontShowAgain': 'Jangan tampilkan saran serupa', 'reflections.loading': 'Memuat refleksi...', 'reflections.empty': 'Belum ada refleksi', @@ -4111,61 +4113,6 @@ const messages: TranslationMap = { 'Konfigurasikan pengaturan triase AI untuk pemicu integrasi Composio', 'memory.sourceFilterAria': 'Filter berdasarkan sumber', 'calls.comingSoonDescription': 'Panggilan dengan bantuan AI akan segera hadir. Pantau terus.', - 'vault.title': 'Gudang pengetahuan', - 'vault.description': 'Arahkan ke folder lokal; file dipotong dan dicerminkan ke dalam memori.', - 'vault.add': 'Tambahkan brankas', - 'vault.added': 'Vault ditambahkan', - 'vault.createdMessage': 'Membuat "{name}". Klik {sync} untuk menyerap.', - 'vault.couldNotAdd': 'Tidak dapat menambahkan vault', - 'vault.syncFailed': 'Sinkronisasi gagal', - 'vault.syncFailedFor': 'Sinkronisasi gagal untuk "{name}"', - 'vault.syncFailedFiles': 'Gagal {count} file', - 'vault.syncedTitle': 'Disinkronkan "{name}"', - 'vault.syncSummary': 'Diserap {ingested}, tidak diubah {unchanged}, dihapus {removed}', - 'vault.syncSummaryFailed': ', gagal {count}', - 'vault.syncSummarySkipped': ', dilewati {count}', - 'vault.syncSummaryDuration': '· {seconds}s', - 'vault.confirmRemovePurge': - 'Hapus vault "{name}"?\n\nKlik OK untuk juga menghapus memorinya (hapus semua {count} dokumen yang telah dimasukkan).\nKlik Batal untuk menyimpan dokumen di memori.', - 'vault.confirmRemove': 'Benar-benar menghapus brankas "{name}"?', - 'vault.removed': 'Vault dihapus', - 'vault.removedPurgedMessage': 'Menghapus "{name}" dan menghapus memorinya.', - 'vault.removedKeptMessage': 'Menghapus "{name}". Dokumen disimpan dalam memori.', - 'vault.couldNotRemove': 'Tidak dapat menghapus vault', - 'vault.name': 'Nama', - 'vault.namePlaceholder': 'Catatan penelitian saya', - 'vault.folderPath': 'Jalur folder (mutlak)', - 'vault.folderPathPlaceholder': '/Pengguna/Anda/Dokumen/catatan', - 'vault.excludes': 'Tidak termasuk (substring yang dipisahkan koma, opsional)', - 'vault.excludesPlaceholder': 'draft/, .secret', - 'vault.creating': 'Membuat…', - 'vault.create': 'Membuat vault', - 'vault.loading': 'Memuat vault…', - 'vault.failedToLoad': 'Gagal memuat brankas: {error}', - 'vault.empty': 'Belum ada brankas. Tambahkan satu di atas untuk mulai menyerap folder.', - 'vault.fileCount': '{count} file', - 'vault.syncedRelative': 'disinkronkan {time}', - 'vault.neverSynced': 'tidak pernah disinkronkan', - 'vault.writeState.writable': 'Dapat ditulis', - 'vault.writeState.read_only': 'Hanya baca', - 'vault.writeState.unavailable': 'Tidak tersedia', - 'vault.writeState.unknownReason': 'Status tulis tidak diketahui.', - 'vault.writeState.reasons.writable': - 'Penulisan Markdown/wiki yang disetujui dapat disimpan di vault ini.', - 'vault.writeState.reasons.read_only': 'Folder vault hanya baca di perangkat ini.', - 'vault.writeState.reasons.unavailable': 'Folder vault tidak tersedia di perangkat ini.', - 'vault.writeState.reasons.not_directory': 'Jalur vault bukan folder.', - 'vault.writeState.reasons.empty_path': 'Jalur folder vault kosong.', - 'vault.syncingProgress': 'Menyinkronkan… {ingested}/{total}', - 'vault.removing': 'Menghapus…', - 'vault.relative.sec': '{count}s yang lalu', - 'vault.relative.min': '{count}m yang lalu', - 'vault.relative.hr': '{count}h yang lalu', - 'vault.relative.day': '{count}d yang lalu', - 'vault.openButton': 'Buka', - 'vault.openSuccess': 'Dibuka di Obsidian', - 'vault.openFallback': 'Obsidian tidak ditemukan — dibuka di pengelola file', - 'vault.openError': 'Tidak dapat membuka vault', 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 menit', 'subconscious.interval.tenMinutes': '10 menit', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index abe89fd4c..0b2b68ab2 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -273,6 +273,7 @@ const messages: TranslationMap = { 'memory.empty': 'Nessuna memoria ancora. Le memorie vengono create automaticamente mentre interagisci.', 'memory.tab.memory': 'Memoria', + 'memory.tab.memoryTree': 'Albero della memoria', 'memory.tab.tasks': 'Attività agente', 'memory.tab.tasksDescription': 'Crea e monitora le attività — i tuoi to-do personali e le board create dagli agenti nelle conversazioni.', @@ -1933,6 +1934,7 @@ const messages: TranslationMap = { 'graph.children': 'figli', 'graph.clickToOpenObsidian': 'Clic per aprire in Obsidian', 'graph.person': 'Persona', + 'graph.resetView': 'Reimposta vista', 'modal.dontShowAgain': 'Non mostrare suggerimenti simili', 'reflections.loading': 'Caricamento riflessioni...', 'reflections.empty': 'Nessuna riflessione', @@ -4163,64 +4165,6 @@ const messages: TranslationMap = { 'memory.sourceFilterAria': 'Filtra per origine', 'calls.comingSoonDescription': "Le chiamate assistite dall'IA sono in arrivo. Resta sintonizzato.", - 'vault.title': 'Depositi di conoscenza', - 'vault.description': - 'Punta a una cartella locale; i file vengono suddivisi in blocchi e copiati nella memoria.', - 'vault.add': 'Aggiungi deposito', - 'vault.added': 'Deposito aggiunto', - 'vault.createdMessage': 'Creato "{name}". Fai clic su {sync} per importare.', - 'vault.couldNotAdd': 'Impossibile aggiungere il deposito', - 'vault.syncFailed': 'Sincronizzazione non riuscita', - 'vault.syncFailedFor': 'Sincronizzazione non riuscita per "{name}"', - 'vault.syncFailedFiles': 'Impossibile {count} file', - 'vault.syncedTitle': 'Sincronizzato "{name}"', - 'vault.syncSummary': 'Importato {ingested}, invariato {unchanged}, rimosso {removed}', - 'vault.syncSummaryFailed': ', non riuscito {count}', - 'vault.syncSummarySkipped': ', saltato {count}', - 'vault.syncSummaryDuration': '· {seconds}s', - 'vault.confirmRemovePurge': - 'Rimuovere il vault "{name}"?\n\nFai clic su OK per eliminare anche la sua memoria (elimina tutti i {count} documento/i caricati).\nFai clic su Annulla per mantenere i documenti in memoria.', - 'vault.confirmRemove': 'Rimuovere davvero il vault "{name}"?', - 'vault.removed': 'Vault rimosso', - 'vault.removedPurgedMessage': 'Rimosso "{name}" e cancellata la sua memoria.', - 'vault.removedKeptMessage': 'Rimosso "{name}". Documenti conservati in memoria.', - 'vault.couldNotRemove': 'Impossibile rimuovere il deposito', - 'vault.name': 'Nome', - 'vault.namePlaceholder': 'Le mie note di ricerca', - 'vault.folderPath': 'Percorso della cartella (assoluto)', - 'vault.folderPathPlaceholder': '/Utenti/tu/Documenti/note', - 'vault.excludes': 'Esclude (sottostringhe separate da virgole, facoltativo)', - 'vault.excludesPlaceholder': 'bozze/, .secret', - 'vault.creating': 'Creazione…', - 'vault.create': 'Crea deposito', - 'vault.loading': 'Caricamento depositi…', - 'vault.failedToLoad': 'Impossibile caricare i depositi: {error}', - 'vault.empty': 'Nessun vault ancora. Aggiungine uno sopra per iniziare a caricare una cartella.', - 'vault.fileCount': '{count} file', - 'vault.syncedRelative': 'sincronizzato {time}', - 'vault.neverSynced': 'mai sincronizzato', - 'vault.writeState.writable': 'Scrivibile', - 'vault.writeState.read_only': 'Sola lettura', - 'vault.writeState.unavailable': 'Non disponibile', - 'vault.writeState.unknownReason': 'Stato di scrittura sconosciuto.', - 'vault.writeState.reasons.writable': - 'Le scritture Markdown/wiki approvate possono essere salvate in questo deposito.', - 'vault.writeState.reasons.read_only': - 'La cartella del deposito è in sola lettura su questo dispositivo.', - 'vault.writeState.reasons.unavailable': - 'La cartella del deposito non è disponibile su questo dispositivo.', - 'vault.writeState.reasons.not_directory': 'Il percorso del deposito non è una cartella.', - 'vault.writeState.reasons.empty_path': 'Il percorso della cartella del deposito è vuoto.', - 'vault.syncingProgress': 'Sincronizzazione… {ingested}/{total}', - 'vault.removing': 'Rimozione in corso…', - 'vault.relative.sec': '{count}s fa', - 'vault.relative.min': '{count}m fa', - 'vault.relative.hr': '{count}h fa', - 'vault.relative.day': '{count}d fa', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 minuti', 'subconscious.interval.tenMinutes': '10 minuti', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 855b0f4c4..da303403b 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -266,6 +266,7 @@ const messages: TranslationMap = { 'memory.noResults': '메모리를 찾을 수 없습니다', 'memory.empty': '아직 메모리가 없습니다. 메모리는 상호작용하면서 자동으로 생성됩니다.', 'memory.tab.memory': '메모리', + 'memory.tab.memoryTree': '메모리 트리', 'memory.tab.tasks': '에이전트 작업', 'memory.tab.tasksDescription': '작업을 만들고 추적하세요 — 본인의 할 일 목록과 에이전트가 대화를 통해 구성한 보드가 모두 포함됩니다.', @@ -1883,6 +1884,7 @@ const messages: TranslationMap = { 'graph.children': '자식', 'graph.clickToOpenObsidian': '클릭하여 Obsidian에서 열기', 'graph.person': '사람', + 'graph.resetView': '보기 초기화', 'modal.dontShowAgain': '비슷한 제안을 다시 표시하지 않기', 'reflections.loading': '반영을 불러오는 중...', 'reflections.empty': '아직 반영이 없습니다', @@ -4062,60 +4064,6 @@ const messages: TranslationMap = { 'devOptions.menuComposioTriggersDesc': 'Composio 통합 트리거에 대한 AI 심사 설정 구성', 'memory.sourceFilterAria': '소스별 필터링', 'calls.comingSoonDescription': 'AI 지원 통화가 곧 제공됩니다. 기대해 주세요.', - 'vault.title': '지식 보관소', - 'vault.description': '로컬 폴더를 가리킵니다. 파일은 청크로 분할되어 메모리에 미러링됩니다.', - 'vault.add': '저장소 추가', - 'vault.added': '저장소 추가', - 'vault.createdMessage': '"{name}"을(를) 생성했습니다. 수집하려면 {sync}을 클릭하세요.', - 'vault.couldNotAdd': '볼트를 추가할 수 없습니다.', - 'vault.syncFailed': '동기화 실패', - 'vault.syncFailedFor': '"{name}"에 대한 동기화 실패', - 'vault.syncFailedFiles': '{count} 파일 실패', - 'vault.syncedTitle': '동기화됨 "{name}"', - 'vault.syncSummary': '{ingested} 수집, {unchanged} 변경, {removed}', - 'vault.syncSummaryFailed': '제거, {count}', - 'vault.syncSummarySkipped': '실패, {count}', - 'vault.syncSummaryDuration': '건너뛰었습니다. {seconds}s', - 'vault.confirmRemovePurge': - '볼트 "{name}"을(를) 제거하시겠습니까?\n\n확인을 클릭하면 수집된 문서 {count}개도 함께 삭제됩니다.\n취소를 클릭하면 문서가 메모리에 유지됩니다.', - 'vault.confirmRemove': '"{name}" 볼트를 제거하시겠습니까?', - 'vault.removed': 'Vault가 제거되었습니다.', - 'vault.removedPurgedMessage': '"{name}"을(를) 제거하고 해당 메모리를 삭제했습니다.', - 'vault.removedKeptMessage': '"{name}"을(를) 제거했습니다. 문서는 메모리에 보관됩니다.', - 'vault.couldNotRemove': '볼트를 제거할 수 없습니다.', - 'vault.name': '이름', - 'vault.namePlaceholder': '내 연구 노트', - 'vault.folderPath': '폴더 경로(절대)', - 'vault.folderPathPlaceholder': '/사용자/당신/문서/메모', - 'vault.excludes': '제외(쉼표로 구분된 하위 문자열, 선택 사항)', - 'vault.excludesPlaceholder': '초안/, .secret', - 'vault.creating': '생성 중…', - 'vault.create': '볼트 생성', - 'vault.loading': '볼트 로드 중…', - 'vault.failedToLoad': '볼트 로드 실패: {error}', - 'vault.empty': '아직 Vault가 없습니다. 폴더 수집을 시작하려면 위에 하나를 추가하세요.', - 'vault.fileCount': '{count} 파일', - 'vault.syncedRelative': '동기화됨 {time}', - 'vault.neverSynced': '동기화되지 않음', - 'vault.writeState.writable': '쓰기 가능', - 'vault.writeState.read_only': '읽기 전용', - 'vault.writeState.unavailable': '사용할 수 없음', - 'vault.writeState.unknownReason': '쓰기 상태를 알 수 없습니다.', - 'vault.writeState.reasons.writable': '승인된 Markdown/wiki 쓰기를 이 볼트에 저장할 수 있습니다.', - 'vault.writeState.reasons.read_only': '이 장치에서 볼트 폴더는 읽기 전용입니다.', - 'vault.writeState.reasons.unavailable': '이 장치에서 볼트 폴더를 사용할 수 없습니다.', - 'vault.writeState.reasons.not_directory': '볼트 경로가 폴더가 아닙니다.', - 'vault.writeState.reasons.empty_path': '볼트 폴더 경로가 비어 있습니다.', - 'vault.syncingProgress': '동기화 중… {ingested}/{total}', - 'vault.removing': '제거 중…', - 'vault.relative.sec': '{count}초 전', - 'vault.relative.min': '{count}분 전', - 'vault.relative.hr': '{count}시간 전', - 'vault.relative.day': '{count}일 전', - 'vault.openButton': '열기', - 'vault.openSuccess': 'Obsidian에서 열림', - 'vault.openFallback': 'Obsidian을 찾을 수 없음 — 파일 관리자에서 열림', - 'vault.openError': '볼트를 열 수 없습니다', 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5분', 'subconscious.interval.tenMinutes': '10분', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 27c9d9435..621d08fdc 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -271,6 +271,7 @@ const messages: TranslationMap = { 'memory.empty': 'Brak wspomnień. Wspomnienia powstają automatycznie podczas korzystania z aplikacji.', 'memory.tab.memory': 'Pamięć', + 'memory.tab.memoryTree': 'Drzewo pamięci', 'memory.tab.tasks': 'Zadania agenta', 'memory.tab.tasksDescription': 'Twórz i śledź zadania — własne listy do zrobienia oraz tablice, które Twoi agenci budują w rozmowach.', @@ -1923,6 +1924,7 @@ const messages: TranslationMap = { 'graph.children': 'dzieci', 'graph.clickToOpenObsidian': 'Kliknij, aby otworzyć w Obsidianie', 'graph.person': 'Osoba', + 'graph.resetView': 'Resetuj widok', 'modal.dontShowAgain': 'Nie pokazuj podobnych sugestii', 'reflections.loading': 'Wczytywanie refleksji...', 'reflections.empty': 'Brak refleksji', @@ -4165,62 +4167,6 @@ const messages: TranslationMap = { 'Konfiguruj ustawienia klasyfikacji AI dla wyzwalaczy integracji Composio', 'memory.sourceFilterAria': 'Filtruj po źródle', 'calls.comingSoonDescription': 'Połączenia wspierane AI pojawią się wkrótce. Bądź na bieżąco.', - 'vault.title': 'Skarbce wiedzy (Eksperymentalne)', - 'vault.description': - 'Wskaż lokalny folder; pliki zostaną podzielone i odzwierciedlone w pamięci.', - 'vault.add': 'Dodaj skarbiec', - 'vault.added': 'Skarbiec dodany', - 'vault.createdMessage': 'Utworzono „{name}”. Kliknij {sync}, aby zaindeksować.', - 'vault.couldNotAdd': 'Nie udało się dodać skarbca', - 'vault.syncFailed': 'Synchronizacja nieudana', - 'vault.syncFailedFor': 'Synchronizacja „{name}” nieudana', - 'vault.syncFailedFiles': 'Nieudane pliki: {count}', - 'vault.syncedTitle': 'Zsynchronizowano „{name}”', - 'vault.syncSummary': 'Zaindeksowano: {ingested}, bez zmian: {unchanged}, usunięto: {removed}', - 'vault.syncSummaryFailed': ', nieudane: {count}', - 'vault.syncSummarySkipped': ', pominięte: {count}', - 'vault.syncSummaryDuration': ' · {seconds} s', - 'vault.confirmRemovePurge': - 'Usunąć skarbiec „{name}”?\n\nKliknij OK, aby również wyczyścić jego pamięć (usunąć wszystkie {count} zaindeksowane dokumenty).\nKliknij Anuluj, aby zachować dokumenty w pamięci.', - 'vault.confirmRemove': 'Na pewno usunąć skarbiec „{name}”?', - 'vault.removed': 'Skarbiec usunięty', - 'vault.removedPurgedMessage': 'Usunięto „{name}” i wyczyszczono pamięć.', - 'vault.removedKeptMessage': 'Usunięto „{name}”. Dokumenty pozostają w pamięci.', - 'vault.couldNotRemove': 'Nie udało się usunąć skarbca', - 'vault.name': 'Nazwa', - 'vault.namePlaceholder': 'Moje notatki badawcze', - 'vault.folderPath': 'Ścieżka folderu (bezwzględna)', - 'vault.folderPathPlaceholder': '/Users/ty/Documents/notatki', - 'vault.excludes': 'Wykluczenia (po przecinku, opcjonalnie)', - 'vault.excludesPlaceholder': 'wersje robocze/, .sekret', - 'vault.creating': 'Tworzenie…', - 'vault.create': 'Utwórz skarbiec', - 'vault.loading': 'Ładowanie skarbców…', - 'vault.failedToLoad': 'Nie udało się załadować skarbców: {error}', - 'vault.empty': 'Brak skarbców. Dodaj jeden powyżej, aby zacząć indeksować folder.', - 'vault.fileCount': 'Plików: {count}', - 'vault.syncedRelative': 'zsynchronizowano {time}', - 'vault.neverSynced': 'nigdy nie synchronizowano', - 'vault.writeState.writable': 'Zapisywalny', - 'vault.writeState.read_only': 'Tylko do odczytu', - 'vault.writeState.unavailable': 'Niedostępny', - 'vault.writeState.unknownReason': 'Stan zapisu jest nieznany.', - 'vault.writeState.reasons.writable': - 'Zatwierdzone zapisy Markdown/wiki mogą być zapisane w tym skarbcu.', - 'vault.writeState.reasons.read_only': 'Folder skarbca jest tylko do odczytu na tym urządzeniu.', - 'vault.writeState.reasons.unavailable': 'Folder skarbca jest niedostępny na tym urządzeniu.', - 'vault.writeState.reasons.not_directory': 'Ścieżka skarbca nie jest folderem.', - 'vault.writeState.reasons.empty_path': 'Ścieżka folderu skarbca jest pusta.', - 'vault.syncingProgress': 'Synchronizowanie… {ingested}/{total}', - 'vault.removing': 'Usuwanie…', - 'vault.relative.sec': '{count} s temu', - 'vault.relative.min': '{count} min temu', - 'vault.relative.hr': '{count} godz. temu', - 'vault.relative.day': '{count} dni temu', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 min', 'subconscious.interval.tenMinutes': '10 min', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index e0d058d99..1c32f79ab 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -275,6 +275,7 @@ const messages: TranslationMap = { 'memory.empty': 'Nenhuma memória ainda. As memórias são criadas automaticamente conforme você interage.', 'memory.tab.memory': 'Memória', + 'memory.tab.memoryTree': 'Árvore de memória', 'memory.tab.tasks': 'Tarefas do Agente', 'memory.tab.tasksDescription': 'Crie e acompanhe tarefas — seus próprios afazeres e os painéis que seus agentes constroem ao longo das conversas.', @@ -1940,6 +1941,7 @@ const messages: TranslationMap = { 'graph.children': 'filhos', 'graph.clickToOpenObsidian': 'Clique para abrir no Obsidian', 'graph.person': 'Pessoa', + 'graph.resetView': 'Redefinir visualização', 'modal.dontShowAgain': 'Não mostrar sugestões semelhantes', 'reflections.loading': 'Carregando reflexões...', 'reflections.empty': 'Nenhuma reflexão ainda', @@ -4164,62 +4166,6 @@ const messages: TranslationMap = { 'Definir configurações de triagem de IA para gatilhos de integração Composio', 'memory.sourceFilterAria': 'Filtrar por origem', 'calls.comingSoonDescription': 'Chamadas assistidas por IA chegam em breve. Fique ligado.', - 'vault.title': 'Cofres de conhecimento', - 'vault.description': - 'Aponte para uma pasta local; os arquivos são fragmentados e espelhados na memória.', - 'vault.add': 'Adicionar cofre', - 'vault.added': 'Cofre adicionado', - 'vault.createdMessage': 'Criado "{name}". Clique em {sync} para ingerir.', - 'vault.couldNotAdd': 'Não foi possível adicionar o cofre', - 'vault.syncFailed': 'Falha na sincronização', - 'vault.syncFailedFor': 'Falha na sincronização para "{name}"', - 'vault.syncFailedFiles': 'Falha em {count} arquivo(s)', - 'vault.syncedTitle': 'Sincronizado "{name}"', - 'vault.syncSummary': 'Ingerido {ingested}, inalterado {unchanged}, removido {removed}', - 'vault.syncSummaryFailed': ', falhou {count}', - 'vault.syncSummarySkipped': ', ignorado {count}', - 'vault.syncSummaryDuration': '· {seconds}s', - 'vault.confirmRemovePurge': - 'Remover vault "{name}"?\n\nClique em OK para também purgar sua memória (excluir todos os {count} documento(s) ingerido(s)).\nClique em Cancelar para manter os documentos na memória.', - 'vault.confirmRemove': 'Realmente remover o cofre "{name}"?', - 'vault.removed': 'Vault removido', - 'vault.removedPurgedMessage': 'Removido "{name}" e limpou sua memória.', - 'vault.removedKeptMessage': 'Removido "{name}". Documentos guardados na memória.', - 'vault.couldNotRemove': 'Não foi possível remover o cofre', - 'vault.name': 'Nome', - 'vault.namePlaceholder': 'Minhas notas de pesquisa', - 'vault.folderPath': 'Caminho da pasta (absoluto)', - 'vault.folderPathPlaceholder': '/Users/you/Documents/notes', - 'vault.excludes': 'Exclui (substrings separadas por vírgula, opcional)', - 'vault.excludesPlaceholder': 'rascunhos/, .secret', - 'vault.creating': 'Criando…', - 'vault.create': 'Criar cofre', - 'vault.loading': 'Carregando cofres…', - 'vault.failedToLoad': 'Falha ao carregar cofres: {error}', - 'vault.empty': 'Ainda não há cofres. Adicione um acima para começar a assimilar uma pasta.', - 'vault.fileCount': '{count} arquivo(s)', - 'vault.syncedRelative': 'sincronizado {time}', - 'vault.neverSynced': 'nunca sincronizado', - 'vault.writeState.writable': 'Gravável', - 'vault.writeState.read_only': 'Somente leitura', - 'vault.writeState.unavailable': 'Indisponível', - 'vault.writeState.unknownReason': 'Estado de gravação desconhecido.', - 'vault.writeState.reasons.writable': - 'Gravações Markdown/wiki aprovadas podem ser salvas neste cofre.', - 'vault.writeState.reasons.read_only': 'A pasta do cofre é somente leitura neste dispositivo.', - 'vault.writeState.reasons.unavailable': 'A pasta do cofre não está disponível neste dispositivo.', - 'vault.writeState.reasons.not_directory': 'O caminho do cofre não é uma pasta.', - 'vault.writeState.reasons.empty_path': 'O caminho da pasta do cofre está vazio.', - 'vault.syncingProgress': 'Sincronizando… {ingested}/{total}', - 'vault.removing': 'Removendo…', - 'vault.relative.sec': '{count}s atrás', - 'vault.relative.min': '{count}m atrás', - 'vault.relative.hr': '{count}h atrás', - 'vault.relative.day': '{count}d atrás', - 'vault.openButton': 'Open', - 'vault.openSuccess': 'Opened in Obsidian', - 'vault.openFallback': 'Obsidian not found — opened in file manager', - 'vault.openError': "Couldn't open vault", 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 minutos', 'subconscious.interval.tenMinutes': '10 minutos', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 4082908b7..c00692899 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -268,6 +268,7 @@ const messages: TranslationMap = { 'memory.noResults': 'Воспоминания не найдены', 'memory.empty': 'Воспоминаний пока нет. Они создаются автоматически в процессе общения.', 'memory.tab.memory': 'Память', + 'memory.tab.memoryTree': 'Дерево памяти', 'memory.tab.tasks': 'Задачи агента', 'memory.tab.tasksDescription': 'Создавайте и отслеживайте задачи — ваши личные дела и доски, которые агенты формируют в ходе разговоров.', @@ -1915,6 +1916,7 @@ const messages: TranslationMap = { 'graph.children': 'потомки', 'graph.clickToOpenObsidian': 'Нажми, чтобы открыть в Obsidian', 'graph.person': 'Человек', + 'graph.resetView': 'Сбросить вид', 'modal.dontShowAgain': 'Не показывать похожие предложения', 'reflections.loading': 'Загрузка рефлексий...', 'reflections.empty': 'Рефлексий пока нет', @@ -4131,63 +4133,6 @@ const messages: TranslationMap = { 'Настройка параметров сортировки AI для триггеров интеграции Composio', 'memory.sourceFilterAria': 'Фильтровать по источнику', 'calls.comingSoonDescription': 'Звонки с поддержкой ИИ скоро появятся. Следите за обновлениями.', - 'vault.title': 'Хранилища знаний.', - 'vault.description': - 'Укажите локальную папку; файлы разбиваются на части и зеркалируются в память.', - 'vault.add': 'Добавить хранилище.', - 'vault.added': 'Хранилище добавлено.', - 'vault.createdMessage': 'Создано «{name}». Нажмите {sync}, чтобы принять.', - 'vault.couldNotAdd': 'Не удалось добавить хранилище.', - 'vault.syncFailed': 'Не удалось синхронизировать', - 'vault.syncFailedFor': 'Не удалось синхронизировать «{name}»', - 'vault.syncFailedFiles': 'Не удалось {count} файлов', - 'vault.syncedTitle': 'Синхронизировано "{name}"', - 'vault.syncSummary': 'Загружен {ingested}, без изменений {unchanged}, удален {removed}', - 'vault.syncSummaryFailed': ', не удалось {count}', - 'vault.syncSummarySkipped': ', пропущен {count}', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - 'Удалить хранилище «{name}»?\n\nНажмите OK, чтобы также очистить его память (удалить все {count} загруженных документов).\nНажмите Отмена, чтобы сохранить документы в памяти.', - 'vault.confirmRemove': 'Действительно удалить хранилище «{name}»?', - 'vault.removed': 'Хранилище удалено.', - 'vault.removedPurgedMessage': 'Удален «{name}» и очищена его память.', - 'vault.removedKeptMessage': 'Удален «{name}». Документы хранятся в памяти.', - 'vault.couldNotRemove': 'Не удалось удалить хранилище.', - 'vault.name': 'Имя', - 'vault.namePlaceholder': 'Мои исследовательские заметки', - 'vault.folderPath': 'Путь к папке (абсолютный)', - 'vault.folderPathPlaceholder': '/Пользователи/вы/Документы/заметки', - 'vault.excludes': 'Исключает (подстроки, разделенные запятыми, необязательно)', - 'vault.excludesPlaceholder': 'черновики/, .secret', - 'vault.creating': 'Создание…', - 'vault.create': 'Создать хранилище', - 'vault.loading': 'Загрузка хранилища…', - 'vault.failedToLoad': 'Не удалось загрузить хранилища: {error}', - 'vault.empty': 'Хранилищ пока нет. Добавьте одно выше, чтобы начать загрузку папки.', - 'vault.fileCount': '{count} файлов', - 'vault.syncedRelative': 'синхронизировано {time}', - 'vault.neverSynced': 'никогда не синхронизировано', - 'vault.writeState.writable': 'Доступно для записи', - 'vault.writeState.read_only': 'Только чтение', - 'vault.writeState.unavailable': 'Недоступно', - 'vault.writeState.unknownReason': 'Статус записи неизвестен.', - 'vault.writeState.reasons.writable': - 'Одобренные записи Markdown/wiki можно сохранить в этом хранилище.', - 'vault.writeState.reasons.read_only': - 'Папка хранилища доступна только для чтения на этом устройстве.', - 'vault.writeState.reasons.unavailable': 'Папка хранилища недоступна на этом устройстве.', - 'vault.writeState.reasons.not_directory': 'Путь хранилища не является папкой.', - 'vault.writeState.reasons.empty_path': 'Путь к папке хранилища пуст.', - 'vault.syncingProgress': 'Синхронизация… {ingested}/{total}', - 'vault.removing': 'Удаление…', - 'vault.relative.sec': '{count}s назад', - 'vault.relative.min': '{count}m назад', - 'vault.relative.hr': '{count}h назад', - 'vault.relative.day': '{count}d назад', - 'vault.openButton': 'Открыть', - 'vault.openSuccess': 'Открыто в Obsidian', - 'vault.openFallback': 'Obsidian не найден — открыто в файловом менеджере', - 'vault.openError': 'Не удалось открыть хранилище', 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5 минут', 'subconscious.interval.tenMinutes': '10 минут', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index f1f2a1cfa..510f28c9e 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -256,6 +256,7 @@ const messages: TranslationMap = { 'memory.noResults': '未找到记忆', 'memory.empty': '暂无记忆。记忆将在你交互时自动创建。', 'memory.tab.memory': '记忆', + 'memory.tab.memoryTree': '记忆树', 'memory.tab.tasks': '智能体任务', 'memory.tab.tasksDescription': '创建并跟踪任务——包括您自己的待办事项以及智能体在对话中创建的看板。', @@ -1807,6 +1808,7 @@ const messages: TranslationMap = { 'graph.children': '个子节点', 'graph.clickToOpenObsidian': '点击在 Obsidian 中打开', 'graph.person': '人物', + 'graph.resetView': '重置视图', 'modal.dontShowAgain': '不再显示', 'reflections.loading': '正在加载反思...', 'reflections.empty': '暂无反思', @@ -3900,60 +3902,6 @@ const messages: TranslationMap = { 'devOptions.menuComposioTriggersDesc': '为 Composio 集成触发器配置 AI 分级设置', 'memory.sourceFilterAria': '按来源过滤', 'calls.comingSoonDescription': '人工智能辅助通话即将推出。敬请关注。', - 'vault.title': '知识库', - 'vault.description': '指向本地文件夹;文件被分块并镜像到内存中。', - 'vault.add': '添加保险库', - 'vault.added': '添加了保险库', - 'vault.createdMessage': '创建“{name}”。单击 {sync} 进行摄取。', - 'vault.couldNotAdd': '无法添加保管库', - 'vault.syncFailed': '同步失败', - 'vault.syncFailedFor': '“{name}”同步失败', - 'vault.syncFailedFiles': '{count} 文件失败', - 'vault.syncedTitle': '已同步“{name}”', - 'vault.syncSummary': '摄入 {ingested},未改变 {unchanged},移除 {removed}', - 'vault.syncSummaryFailed': ',失败 {count}', - 'vault.syncSummarySkipped': ',跳过 {count}', - 'vault.syncSummaryDuration': ' · {seconds}s', - 'vault.confirmRemovePurge': - '删除存储库「{name}」?\n\n点击确定将同时清除其记忆(删除全部 {count} 个已导入文档)。\n点击取消将保留记忆中的文档。', - 'vault.confirmRemove': '真的删除保险库“{name}”吗?', - 'vault.removed': '保险库已移除', - 'vault.removedPurgedMessage': '删除了“{name}”并清除了其内存。', - 'vault.removedKeptMessage': '删除了“{name}”。保存在内存中的文档。', - 'vault.couldNotRemove': '无法删除保管库', - 'vault.name': '名称', - 'vault.namePlaceholder': '我的研究笔记', - 'vault.folderPath': '文件夹路径(绝对)', - 'vault.folderPathPlaceholder': '/用户/您/文档/注释', - 'vault.excludes': '排除(逗号分隔的子字符串,可选)', - 'vault.excludesPlaceholder': '草稿/,.秘密', - 'vault.creating': '创造……', - 'vault.create': '创建保管库', - 'vault.loading': '正在加载金库...', - 'vault.failedToLoad': '无法加载保管库:{error}', - 'vault.empty': '还没有金库。在上面添加一个即可开始摄取文件夹。', - 'vault.fileCount': '{count} 文件', - 'vault.syncedRelative': '已同步 {time}', - 'vault.neverSynced': '从未同步过', - 'vault.writeState.writable': '可写', - 'vault.writeState.read_only': '只读', - 'vault.writeState.unavailable': '不可用', - 'vault.writeState.unknownReason': '写入状态未知。', - 'vault.writeState.reasons.writable': '可将已批准的 Markdown/wiki 写入保存到此保险库。', - 'vault.writeState.reasons.read_only': '此设备上的保险库文件夹是只读的。', - 'vault.writeState.reasons.unavailable': '此设备上的保险库文件夹不可用。', - 'vault.writeState.reasons.not_directory': '保险库路径不是文件夹。', - 'vault.writeState.reasons.empty_path': '保险库文件夹路径为空。', - 'vault.syncingProgress': '正在同步... {ingested}/{total}', - 'vault.removing': '正在删除...', - 'vault.relative.sec': '{count} 秒前', - 'vault.relative.min': '{count}分钟前', - 'vault.relative.hr': '{count} 小时前', - 'vault.relative.day': '{count} 天前', - 'vault.openButton': '打开', - 'vault.openSuccess': '已在 Obsidian 中打开', - 'vault.openFallback': '未找到 Obsidian — 已在文件管理器中打开', - 'vault.openError': '无法打开保管库', 'whatsapp.title': 'WhatsApp', 'subconscious.interval.fiveMinutes': '5分钟', 'subconscious.interval.tenMinutes': '10分钟', diff --git a/app/src/pages/Intelligence.tsx b/app/src/pages/Intelligence.tsx index 923564621..8e4fdc4e5 100644 --- a/app/src/pages/Intelligence.tsx +++ b/app/src/pages/Intelligence.tsx @@ -1,18 +1,10 @@ import { useCallback, useEffect, useState } from 'react'; import { ConfirmationModal } from '../components/intelligence/ConfirmationModal'; -import ConnectionPathTab from '../components/intelligence/ConnectionPathTab'; -import DiagramViewerTab from '../components/intelligence/DiagramViewerTab'; -import EntityAssociationsTab from '../components/intelligence/EntityAssociationsTab'; -import GraphCentralityTab from '../components/intelligence/GraphCentralityTab'; -import GraphCohesionTab from '../components/intelligence/GraphCohesionTab'; import IntelligenceSubconsciousTab from '../components/intelligence/IntelligenceSubconsciousTab'; import IntelligenceTasksTab from '../components/intelligence/IntelligenceTasksTab'; -import MemoryFreshnessTab from '../components/intelligence/MemoryFreshnessTab'; -import MemoryTimelineTab from '../components/intelligence/MemoryTimelineTab'; -import { MemoryWorkspace } from '../components/intelligence/MemoryWorkspace'; +import MemorySection from '../components/intelligence/MemorySection'; import ModelCouncilTab from '../components/intelligence/ModelCouncilTab'; -import NamespaceOverviewTab from '../components/intelligence/NamespaceOverviewTab'; import { ToastContainer } from '../components/intelligence/Toast'; import PillTabBar from '../components/PillTabBar'; import { @@ -27,20 +19,7 @@ import type { } from '../types/intelligence'; import AgentWorkflows from './AgentWorkflows'; -type IntelligenceTab = - | 'memory' - | 'subconscious' - | 'tasks' - | 'workflows' - | 'diagram' - | 'centrality' - | 'cohesion' - | 'associations' - | 'freshness' - | 'timeline' - | 'path' - | 'namespaces' - | 'council'; +type IntelligenceTab = 'memory' | 'subconscious' | 'tasks' | 'workflows' | 'council'; export default function Intelligence() { const { t } = useT(); @@ -118,14 +97,6 @@ export default function Intelligence() { label: t('memory.tab.workflows'), description: t('memory.tab.workflowsDescription'), }, - { id: 'diagram', label: t('memory.tab.diagram') }, - { id: 'centrality', label: t('memory.tab.centrality') }, - { id: 'cohesion', label: t('memory.tab.cohesion') }, - { id: 'associations', label: t('memory.tab.associations') }, - { id: 'freshness', label: t('memory.tab.freshness') }, - { id: 'timeline', label: t('memory.tab.timeline') }, - { id: 'path', label: t('memory.tab.path') }, - { id: 'namespaces', label: t('memory.tab.namespaces') }, { id: 'council', label: t('memory.tab.council') }, ]; const activeTabDef = tabs.find(tab => tab.id === activeTab); @@ -187,7 +158,7 @@ export default function Intelligence() {
{/* Tab content */} - {activeTab === 'memory' && } + {activeTab === 'memory' && } {activeTab === 'subconscious' && ( } - {activeTab === 'diagram' && } - - {activeTab === 'centrality' && } - - {activeTab === 'cohesion' && } - - {activeTab === 'associations' && } - - {activeTab === 'freshness' && } - - {activeTab === 'timeline' && } - - {activeTab === 'path' && } - - {activeTab === 'namespaces' && } - {activeTab === 'council' && }
diff --git a/app/src/pages/__tests__/Intelligence.diagram.test.tsx b/app/src/pages/__tests__/Intelligence.diagram.test.tsx deleted file mode 100644 index 7282fcb6c..000000000 --- a/app/src/pages/__tests__/Intelligence.diagram.test.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { fireEvent, screen } from '@testing-library/react'; -import { describe, expect, it, vi } from 'vitest'; - -import { renderWithProviders } from '../../test/test-utils'; -import Intelligence from '../Intelligence'; - -vi.mock('../../components/intelligence/MemoryWorkspace', () => ({ - MemoryWorkspace: () =>
Memory workspace
, -})); - -vi.mock('../../components/intelligence/IntelligenceSubconsciousTab', () => ({ - default: () =>
Subconscious tab
, -})); - -vi.mock('../../components/intelligence/IntelligenceTasksTab', () => ({ - default: () =>
Tasks tab
, -})); - -vi.mock('../../hooks/useConsciousItems', () => ({ - useConsciousItems: () => ({ isRunning: false }), -})); - -vi.mock('../../hooks/useIntelligenceStats', () => ({ - useIntelligenceStats: () => ({ aiStatus: 'ready' }), -})); - -vi.mock('../../hooks/useMemoryIngestionStatus', () => ({ - useMemoryIngestionStatus: () => ({ status: { running: false, queueDepth: 0 } }), -})); - -const connectMock = vi.fn(); - -vi.mock('../../hooks/useIntelligenceSocket', () => ({ - useIntelligenceSocket: () => ({ isConnected: true }), - useIntelligenceSocketManager: () => ({ connect: connectMock }), -})); - -vi.mock('../../hooks/useSubconscious', () => ({ - useSubconscious: () => ({ - tasks: [], - escalations: [], - logEntries: [], - status: 'idle', - loading: false, - triggering: false, - triggerTick: vi.fn(), - addTask: vi.fn(), - removeTask: vi.fn(), - toggleTask: vi.fn(), - approveEscalation: vi.fn(), - dismissEscalation: vi.fn(), - }), -})); - -describe('Intelligence diagram tab', () => { - it('shows an architecture diagram viewer from the Intelligence tabs', () => { - renderWithProviders(); - - fireEvent.click(screen.getByRole('tab', { name: 'Diagram' })); - - expect(screen.getByRole('heading', { name: 'Architecture Diagram' })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: 'Refresh diagram' })).toBeInTheDocument(); - }); -}); diff --git a/app/src/utils/tauriCommands/vault.test.ts b/app/src/utils/tauriCommands/vault.test.ts deleted file mode 100644 index 6fdf52cdc..000000000 --- a/app/src/utils/tauriCommands/vault.test.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * Vitest for the vault tauriCommands surface. Mirrors the pattern used by - * `subconscious.test.ts` — mocks `callCoreRpc` + `isTauri` so the wrappers - * are validated against the live RPC contract without spinning up Tauri. - */ -import { afterEach, beforeEach, describe, expect, type Mock, test, vi } from 'vitest'; - -import { callCoreRpc } from '../../services/coreRpcClient'; -import { isTauri } from './common'; - -vi.mock('./common', async () => { - const actual = await vi.importActual('./common'); - return { ...actual, isTauri: vi.fn() }; -}); -vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); - -describe('tauriCommands/vault', () => { - const mockIsTauri = isTauri as Mock; - const mockCallCoreRpc = callCoreRpc as Mock; - let openhumanVaultList: typeof import('./vault').openhumanVaultList; - let openhumanVaultCreate: typeof import('./vault').openhumanVaultCreate; - let openhumanVaultGet: typeof import('./vault').openhumanVaultGet; - let openhumanVaultFiles: typeof import('./vault').openhumanVaultFiles; - let openhumanVaultWriteMarkdown: typeof import('./vault').openhumanVaultWriteMarkdown; - let openhumanVaultRemove: typeof import('./vault').openhumanVaultRemove; - let openhumanVaultSync: typeof import('./vault').openhumanVaultSync; - let openhumanVaultSyncStatus: typeof import('./vault').openhumanVaultSyncStatus; - - beforeEach(async () => { - vi.clearAllMocks(); - mockIsTauri.mockReturnValue(true); - const actual = await vi.importActual('./vault'); - openhumanVaultList = actual.openhumanVaultList; - openhumanVaultCreate = actual.openhumanVaultCreate; - openhumanVaultGet = actual.openhumanVaultGet; - openhumanVaultFiles = actual.openhumanVaultFiles; - openhumanVaultWriteMarkdown = actual.openhumanVaultWriteMarkdown; - openhumanVaultRemove = actual.openhumanVaultRemove; - openhumanVaultSync = actual.openhumanVaultSync; - openhumanVaultSyncStatus = actual.openhumanVaultSyncStatus; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - describe('openhumanVaultList', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultList()).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('dispatches openhuman.vault_list with no params', async () => { - mockCallCoreRpc.mockResolvedValue({ result: [], logs: [] }); - const resp = await openhumanVaultList(); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.vault_list' }); - expect(resp.result).toEqual([]); - }); - }); - - describe('openhumanVaultCreate', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultCreate({ name: 'n', rootPath: '/x' })).rejects.toThrow( - 'Not running in Tauri' - ); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('forwards optional glob arrays with snake_case keys', async () => { - mockCallCoreRpc.mockResolvedValue({ result: { id: 'v-1' }, logs: [] }); - await openhumanVaultCreate({ - name: 'notes', - rootPath: '/Users/me/notes', - includeGlobs: ['*.md'], - excludeGlobs: ['drafts'], - }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_create', - params: { - name: 'notes', - root_path: '/Users/me/notes', - include_globs: ['*.md'], - exclude_globs: ['drafts'], - }, - }); - }); - - test('defaults include/exclude globs to empty arrays when omitted', async () => { - mockCallCoreRpc.mockResolvedValue({ result: { id: 'v-2' }, logs: [] }); - await openhumanVaultCreate({ name: 'n', rootPath: '/y' }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_create', - params: { name: 'n', root_path: '/y', include_globs: [], exclude_globs: [] }, - }); - }); - }); - - describe('openhumanVaultGet', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultGet('v-1')).rejects.toThrow('Not running in Tauri'); - }); - - test('dispatches openhuman.vault_get with vault_id', async () => { - mockCallCoreRpc.mockResolvedValue({ result: { id: 'v-1' }, logs: [] }); - await openhumanVaultGet('v-1'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_get', - params: { vault_id: 'v-1' }, - }); - }); - }); - - describe('openhumanVaultFiles', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultFiles('v-1')).rejects.toThrow('Not running in Tauri'); - }); - - test('dispatches openhuman.vault_files with vault_id', async () => { - mockCallCoreRpc.mockResolvedValue({ result: [], logs: [] }); - await openhumanVaultFiles('v-1'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_files', - params: { vault_id: 'v-1' }, - }); - }); - }); - - describe('openhumanVaultWriteMarkdown', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect( - openhumanVaultWriteMarkdown({ - vaultId: 'v-1', - relPath: 'wiki/a.md', - content: '# A', - approved: true, - }) - ).rejects.toThrow('Not running in Tauri'); - }); - - test('dispatches openhuman.vault_write_markdown with approval state', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { vault_id: 'v-1', rel_path: 'wiki/a.md', bytes_written: 3, created: true }, - logs: [], - }); - await openhumanVaultWriteMarkdown({ - vaultId: 'v-1', - relPath: 'wiki/a.md', - content: '# A', - overwrite: true, - approved: true, - }); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_write_markdown', - params: { - vault_id: 'v-1', - rel_path: 'wiki/a.md', - content: '# A', - overwrite: true, - approved: true, - }, - }); - }); - }); - - describe('openhumanVaultRemove', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultRemove('v-1', false)).rejects.toThrow('Not running in Tauri'); - }); - - test('forwards purge_memory=true', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { vault_id: 'v-1', removed: true, purged: true }, - logs: [], - }); - await openhumanVaultRemove('v-1', true); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_remove', - params: { vault_id: 'v-1', purge_memory: true }, - }); - }); - - test('forwards purge_memory=false', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { vault_id: 'v-1', removed: true, purged: false }, - logs: [], - }); - await openhumanVaultRemove('v-1', false); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_remove', - params: { vault_id: 'v-1', purge_memory: false }, - }); - }); - }); - - describe('openhumanVaultSync', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultSync('v-1')).rejects.toThrow('Not running in Tauri'); - }); - - test('dispatches openhuman.vault_sync with vault_id and returns started status', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { status: 'started', vault_id: 'v-1' }, - logs: [], - }); - const resp = await openhumanVaultSync('v-1'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_sync', - params: { vault_id: 'v-1' }, - }); - expect(resp.result.status).toBe('started'); - expect(resp.result.vault_id).toBe('v-1'); - }); - }); - - describe('openhumanVaultSyncStatus', () => { - test('throws when not running in Tauri', async () => { - mockIsTauri.mockReturnValue(false); - await expect(openhumanVaultSyncStatus('v-1')).rejects.toThrow('Not running in Tauri'); - expect(mockCallCoreRpc).not.toHaveBeenCalled(); - }); - - test('dispatches openhuman.vault_sync_status with vault_id', async () => { - mockCallCoreRpc.mockResolvedValue({ - result: { - vault_id: 'v-1', - status: 'completed', - scanned: 4, - ingested: 4, - unchanged: 0, - removed: 0, - failed: 0, - skipped_unsupported: 0, - total: 4, - started_at_ms: 1000, - finished_at_ms: 2000, - duration_ms: 1000, - errors: [], - }, - logs: [], - }); - const resp = await openhumanVaultSyncStatus('v-1'); - expect(mockCallCoreRpc).toHaveBeenCalledWith({ - method: 'openhuman.vault_sync_status', - params: { vault_id: 'v-1' }, - }); - expect(resp.result.status).toBe('completed'); - expect(resp.result.vault_id).toBe('v-1'); - }); - }); -}); diff --git a/app/src/utils/tauriCommands/vault.ts b/app/src/utils/tauriCommands/vault.ts deleted file mode 100644 index b6312c9b9..000000000 --- a/app/src/utils/tauriCommands/vault.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * Vault (knowledge vault) commands — folder-of-files ingested into memory. - */ -import { callCoreRpc } from '../../services/coreRpcClient'; -import { CommandResponse, isTauri } from './common'; - -export interface CoreVault { - id: string; - name: string; - root_path: string; - host_os?: string | null; - namespace: string; - include_globs: string[]; - exclude_globs: string[]; - created_at: string; - last_synced_at?: string | null; - file_count: number; - write_state: CoreVaultWriteState; - write_state_reason?: string | null; -} - -export type CoreVaultWriteState = 'writable' | 'read_only' | 'unavailable'; - -export type CoreVaultFileStatus = 'ok' | 'skipped' | 'failed'; - -export interface CoreVaultFile { - vault_id: string; - rel_path: string; - document_id: string; - content_hash: string; - mtime_ms: number; - bytes: number; - ingested_at: string; - status: CoreVaultFileStatus; -} - -export type CoreVaultSyncStatus = 'idle' | 'running' | 'completed' | 'failed'; - -/** Live progress returned by `openhuman.vault_sync_status`. */ -export interface CoreVaultSyncState { - vault_id: string; - status: CoreVaultSyncStatus; - scanned: number; - ingested: number; - unchanged: number; - removed: number; - failed: number; - skipped_unsupported: number; - /** Total files queued for ingestion; 0 while the discovery walk is still running. */ - total: number; - started_at_ms: number; - finished_at_ms: number | null; - /** Wall-clock ms; 0 while running; set on completion. */ - duration_ms: number; - errors: string[]; -} - -function ensureTauri() { - if (!isTauri()) { - throw new Error('Not running in Tauri'); - } -} - -export async function openhumanVaultList(): Promise> { - ensureTauri(); - return await callCoreRpc>({ method: 'openhuman.vault_list' }); -} - -export async function openhumanVaultCreate(params: { - name: string; - rootPath: string; - includeGlobs?: string[]; - excludeGlobs?: string[]; -}): Promise> { - ensureTauri(); - return await callCoreRpc>({ - method: 'openhuman.vault_create', - params: { - name: params.name, - root_path: params.rootPath, - include_globs: params.includeGlobs ?? [], - exclude_globs: params.excludeGlobs ?? [], - }, - }); -} - -export async function openhumanVaultGet(vaultId: string): Promise> { - ensureTauri(); - return await callCoreRpc>({ - method: 'openhuman.vault_get', - params: { vault_id: vaultId }, - }); -} - -export async function openhumanVaultFiles( - vaultId: string -): Promise> { - ensureTauri(); - return await callCoreRpc>({ - method: 'openhuman.vault_files', - params: { vault_id: vaultId }, - }); -} - -export async function openhumanVaultWriteMarkdown(params: { - vaultId: string; - relPath: string; - content: string; - overwrite?: boolean; - approved: boolean; -}): Promise< - CommandResponse<{ vault_id: string; rel_path: string; bytes_written: number; created: boolean }> -> { - ensureTauri(); - return await callCoreRpc< - CommandResponse<{ vault_id: string; rel_path: string; bytes_written: number; created: boolean }> - >({ - method: 'openhuman.vault_write_markdown', - params: { - vault_id: params.vaultId, - rel_path: params.relPath, - content: params.content, - overwrite: params.overwrite ?? false, - approved: params.approved, - }, - }); -} - -export async function openhumanVaultRemove( - vaultId: string, - purgeMemory: boolean -): Promise> { - ensureTauri(); - return await callCoreRpc< - CommandResponse<{ vault_id: string; removed: boolean; purged: boolean }> - >({ method: 'openhuman.vault_remove', params: { vault_id: vaultId, purge_memory: purgeMemory } }); -} - -export async function openhumanVaultSync( - vaultId: string -): Promise> { - ensureTauri(); - return await callCoreRpc>({ - method: 'openhuman.vault_sync', - params: { vault_id: vaultId }, - }); -} - -/** Poll live sync progress for a vault. */ -export async function openhumanVaultSyncStatus( - vaultId: string -): Promise> { - ensureTauri(); - return await callCoreRpc>({ - method: 'openhuman.vault_sync_status', - params: { vault_id: vaultId }, - }); -} diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index cb2076f0b..4c14aa15f 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -422,7 +422,8 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | 11.1.6 | SearXNG MCP search | RU | `src/openhuman/integrations/searxng.rs`, `src/openhuman/mcp_server/tools.rs`, `src/openhuman/tools/schemas.rs` | ✅ | Self-hosted search config, normalized results, MCP argument validation, and mocked HTTP execution | | 11.1.7 | Bundled prompt resources | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/list` catalog + `resources/read` happy path, -32002 unknown URI, -32602 missing param, catalog-mirrors-BUILTINS parity test | | 11.1.8 | Resource templates list | RU | `src/openhuman/mcp_server/resources.rs`, `src/openhuman/mcp_server/protocol.rs` | ✅ | `resources/templates/list` returns `{resourceTemplates: []}` (static catalog), tolerates unknown/cursor params | -| 11.1.9 | Vault Markdown Writes | RU/VU | `src/openhuman/vault/tests.rs`, `app/src/components/intelligence/VaultPanel.test.tsx`, `app/src/utils/tauriCommands/vault.test.ts` | ✅ | User-added vaults expose writable/read-only/unavailable state; approved markdown writes reject missing approval, path escapes, and non-markdown targets | + + ### 11.2 Insights Dashboard diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 04152c806..9503417d5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + d3-force: + specifier: ^3.0.0 + version: 3.0.0 debug: specifier: ^4.4.3 version: 4.4.3(supports-color@8.1.1) @@ -105,6 +108,9 @@ importers: os-browserify: specifier: ^0.3.0 version: 0.3.0 + pixi.js: + specifier: ^8.18.1 + version: 8.18.1 process: specifier: ^0.11.10 version: 0.11.10 @@ -199,6 +205,9 @@ importers: '@trivago/prettier-plugin-sort-imports': specifier: ^6.0.2 version: 6.0.2(prettier@3.8.3) + '@types/d3-force': + specifier: ^3.0.10 + version: 3.0.10 '@types/debug': specifier: ^4.1.12 version: 4.1.13 @@ -270,7 +279,7 @@ importers: version: 28.1.0(@noble/hashes@2.2.0) knip: specifier: ^6.3.1 - version: 6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + version: 6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) postcss: specifier: ^8.5.6 version: 8.5.10 @@ -1155,6 +1164,9 @@ packages: cpu: [x64] os: [win32] + '@pixi/colord@2.9.6': + resolution: {integrity: sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -1928,6 +1940,9 @@ packages: '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + '@types/d3-force@3.0.10': + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + '@types/d3-interpolate@3.0.4': resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} @@ -1952,6 +1967,9 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/earcut@3.0.0': + resolution: {integrity: sha512-k/9fOUGO39yd2sCjrbAJvGDEQvRwRnQIZlBz43roGwUZo5SHAmyVvSFyaVVZkicRVCaDXPKlbxrUcBuJoSWunQ==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -2241,6 +2259,10 @@ packages: '@webgpu/types@0.1.69': resolution: {integrity: sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==} + '@xmldom/xmldom@0.8.13': + resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} + engines: {node: '>=10.0.0'} + '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} @@ -2797,10 +2819,18 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} + d3-force@3.0.0: + resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==} + engines: {node: '>=12'} + d3-format@3.1.2: resolution: {integrity: sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==} engines: {node: '>=12'} @@ -2813,6 +2843,10 @@ packages: resolution: {integrity: sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==} engines: {node: '>=12'} + d3-quadtree@3.0.1: + resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==} + engines: {node: '>=12'} + d3-scale@4.0.2: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} @@ -2993,6 +3027,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3233,6 +3270,9 @@ packages: eventemitter3@4.0.7: resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + events-universal@1.0.1: resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} @@ -3451,6 +3491,9 @@ packages: resolution: {integrity: sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg==} engines: {node: '>= 14'} + gifuct-js@2.1.2: + resolution: {integrity: sha512-rI2asw77u0mGgwhV3qA+OEgYqaDn5UNqgs+Bx0FGwSpuqfYn+Ir6RQY5ENNQ8SbIiG/m5gVa7CD5RriO4f4Lsg==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3866,6 +3909,9 @@ packages: resolution: {integrity: sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==} engines: {node: '>=20'} + ismobilejs@1.1.1: + resolution: {integrity: sha512-VaFW53yt8QO61k2WJui0dHf4SlL8lxBofUuUmwBo0ljPk0Drz2TiuDW4jo3wDcv41qy/SxrJ+VAzJ/qYqsmzRw==} + isomorphic-timers-promises@1.0.1: resolution: {integrity: sha512-u4sej9B1LPSxTGKB/HiuzvEQnXH0ECYkSVQU39koSwmFAxhlEAFl9RdTvLv4TOTQUgBS5O3O5fwUxk6byBZ+IQ==} engines: {node: '>=10'} @@ -3929,6 +3975,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + js-binary-schema-parser@2.0.3: + resolution: {integrity: sha512-xezGJmOb4lk/M1ZZLTR/jaBHQ4gG/lqQnJqdIv4721DMggsa1bDVlHXNeHYogaIEHD9vCRv0fcL4hMA+Coarkg==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -4534,6 +4583,9 @@ packages: parse-statements@1.0.11: resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} + parse-svg-path@0.1.2: + resolution: {integrity: sha512-JyPSBnkTJ0AI8GGJLfMXvKq42cj5c006fnLz6fXy6zfoVjJizi8BNTpu8on8ziI1cKy9d9DGNuY17Ce7wuejpQ==} + parse5-htmlparser2-tree-adapter@7.1.0: resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} @@ -4608,6 +4660,9 @@ packages: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} + pixi.js@8.18.1: + resolution: {integrity: sha512-6LUPWYgulZhp/w4kam2XHXB0QedISZIqrJbRdHLLQ3csn5a38uzKxAp6B5j6s89QFYaIJbg95kvgTRcbgpO1ow==} + pkg-dir@5.0.0: resolution: {integrity: sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==} engines: {node: '>=10'} @@ -5371,6 +5426,10 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tiny-lru@11.4.7: + resolution: {integrity: sha512-w/Te7uMUVeH0CR8vZIjr+XiN41V+30lkDdK+NRIDCUYKKuL9VcmaUEmaPISuwGhLlrTGh5yu18lENtR9axSxYw==} + engines: {node: '>=12'} + tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} @@ -6629,9 +6688,9 @@ snapshots: '@oxc-resolver/binding-openharmony-arm64@11.19.1': optional: true - '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@oxc-resolver/binding-wasm32-wasi@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)': dependencies: - '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' @@ -6646,6 +6705,8 @@ snapshots: '@oxc-resolver/binding-win32-x64-msvc@11.19.1': optional: true + '@pixi/colord@2.9.6': {} + '@pkgjs/parseargs@0.11.0': optional: true @@ -7285,6 +7346,8 @@ snapshots: '@types/d3-ease@3.0.2': {} + '@types/d3-force@3.0.10': {} + '@types/d3-interpolate@3.0.4': dependencies: '@types/d3-color': 3.1.3 @@ -7309,6 +7372,8 @@ snapshots: '@types/deep-eql@4.0.2': {} + '@types/earcut@3.0.0': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -7770,6 +7835,8 @@ snapshots: '@webgpu/types@0.1.69': {} + '@xmldom/xmldom@0.8.13': {} + '@xmldom/xmldom@0.9.10': {} '@zip.js/zip.js@2.8.26': {} @@ -8404,8 +8471,16 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + d3-ease@3.0.1: {} + d3-force@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-quadtree: 3.0.1 + d3-timer: 3.0.1 + d3-format@3.1.2: {} d3-interpolate@3.0.1: @@ -8414,6 +8489,8 @@ snapshots: d3-path@3.1.0: {} + d3-quadtree@3.0.1: {} + d3-scale@4.0.2: dependencies: d3-array: 3.2.4 @@ -8588,6 +8665,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} easy-table@1.2.0: @@ -8983,6 +9062,8 @@ snapshots: eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} + events-universal@1.0.1: dependencies: bare-events: 2.8.2 @@ -9232,6 +9313,10 @@ snapshots: transitivePeerDependencies: - supports-color + gifuct-js@2.1.2: + dependencies: + js-binary-schema-parser: 2.0.3 + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -9685,6 +9770,8 @@ snapshots: isexe@4.0.0: {} + ismobilejs@1.1.1: {} + isomorphic-timers-promises@1.0.1: {} istanbul-lib-coverage@3.2.2: {} @@ -9770,6 +9857,8 @@ snapshots: jiti@2.6.1: {} + js-binary-schema-parser@2.0.3: {} + js-tokens@10.0.0: {} js-tokens@4.0.0: {} @@ -9843,7 +9932,7 @@ snapshots: dependencies: json-buffer: 3.0.1 - knip@6.6.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + knip@6.6.2(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): dependencies: fdir: 6.5.0(picomatch@4.0.4) formatly: 0.3.0 @@ -9851,7 +9940,7 @@ snapshots: jiti: 2.6.1 minimist: 1.2.8 oxc-parser: 0.127.0 - oxc-resolver: 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + oxc-resolver: 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) picomatch: 4.0.4 smol-toml: 1.6.1 strip-json-comments: 5.0.3 @@ -10522,7 +10611,7 @@ snapshots: '@oxc-parser/binding-win32-ia32-msvc': 0.127.0 '@oxc-parser/binding-win32-x64-msvc': 0.127.0 - oxc-resolver@11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0): + oxc-resolver@11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2): optionalDependencies: '@oxc-resolver/binding-android-arm-eabi': 11.19.1 '@oxc-resolver/binding-android-arm64': 11.19.1 @@ -10540,7 +10629,7 @@ snapshots: '@oxc-resolver/binding-linux-x64-gnu': 11.19.1 '@oxc-resolver/binding-linux-x64-musl': 11.19.1 '@oxc-resolver/binding-openharmony-arm64': 11.19.1 - '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@oxc-resolver/binding-wasm32-wasi': 11.19.1(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2) '@oxc-resolver/binding-win32-arm64-msvc': 11.19.1 '@oxc-resolver/binding-win32-ia32-msvc': 11.19.1 '@oxc-resolver/binding-win32-x64-msvc': 11.19.1 @@ -10624,6 +10713,8 @@ snapshots: parse-statements@1.0.11: {} + parse-svg-path@0.1.2: {} + parse5-htmlparser2-tree-adapter@7.1.0: dependencies: domhandler: 5.0.3 @@ -10685,6 +10776,19 @@ snapshots: pirates@4.0.7: {} + pixi.js@8.18.1: + dependencies: + '@pixi/colord': 2.9.6 + '@types/earcut': 3.0.0 + '@webgpu/types': 0.1.69 + '@xmldom/xmldom': 0.8.13 + earcut: 3.0.2 + eventemitter3: 5.0.4 + gifuct-js: 2.1.2 + ismobilejs: 1.1.1 + parse-svg-path: 0.1.2 + tiny-lru: 11.4.7 + pkg-dir@5.0.0: dependencies: find-up: 5.0.0 @@ -11653,6 +11757,8 @@ snapshots: tiny-invariant@1.3.3: {} + tiny-lru@11.4.7: {} + tinybench@2.9.0: {} tinyexec@1.1.1: {} diff --git a/scripts/test-rust-e2e.sh b/scripts/test-rust-e2e.sh index 6afd1e6e2..742e589e4 100755 --- a/scripts/test-rust-e2e.sh +++ b/scripts/test-rust-e2e.sh @@ -56,7 +56,6 @@ ALL_E2E_SUITES=( ollama_embeddings_fallback_e2e screen_intelligence_vision_e2e subconscious_e2e - vault_sync_e2e worker_b_domain_e2e worker_c_modules_e2e ) diff --git a/src/core/all.rs b/src/core/all.rs index 82400c88c..eab00b37c 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -191,8 +191,6 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::skills::all_skills_registered_controllers()); // User workspace and file management controllers.extend(crate::openhuman::workspace::all_workspace_registered_controllers()); - // Knowledge vaults — folder-of-files mirrored into memory - controllers.extend(crate::openhuman::vault::all_vault_registered_controllers()); // Skill tool registry controllers.extend(crate::openhuman::tools::all_tools_registered_controllers()); // Unified read-only registry across MCP stdio tools and controller-backed tools @@ -341,7 +339,6 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::javascript::all_javascript_controller_schemas()); schemas.extend(crate::openhuman::skills::all_skills_controller_schemas()); schemas.extend(crate::openhuman::workspace::all_workspace_controller_schemas()); - schemas.extend(crate::openhuman::vault::all_vault_controller_schemas()); schemas.extend(crate::openhuman::tools::all_tools_controller_schemas()); schemas.extend(crate::openhuman::tool_registry::all_tool_registry_controller_schemas()); schemas.extend(crate::openhuman::memory::all_memory_controller_schemas()); diff --git a/src/core/observability.rs b/src/core/observability.rs index 39322c922..7a264d006 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -1242,9 +1242,10 @@ fn is_prompt_injection_blocked_message(lower: &str) -> bool { /// boundary when a user typed/picked a path that doesn't resolve to an /// existing directory: /// -/// - `"root_path is not a directory: "` — -/// [`crate::openhuman::vault::ops::vault_create`] when the chosen vault -/// folder doesn't exist or points at a file (Sentry TAURI-RUST-4QH). +/// - `"root_path is not a directory: "` — historically emitted by the +/// now-removed knowledge-vault `vault_create` path when the chosen folder +/// didn't exist or pointed at a file (Sentry TAURI-RUST-4QH). Kept as a +/// classifier fixture since the wire shape may recur from other callers. /// - `"hosted path is not a directory: "` — /// [`crate::openhuman::http_host::path_utils`] when an HTTP host config /// references a missing directory. Not yet observed in Sentry but diff --git a/src/core/observability_tests.rs b/src/core/observability_tests.rs index b8ae841fe..1a1b772b0 100644 --- a/src/core/observability_tests.rs +++ b/src/core/observability_tests.rs @@ -352,10 +352,11 @@ fn does_not_classify_unrelated_messages_as_context_window_exceeded() { #[test] fn classifies_vault_create_root_path_not_a_directory_as_filesystem_user_path_invalid() { - // TAURI-RUST-4QH: verbatim wire shape from - // `openhuman::vault::ops::vault_create` line 37 when the - // user-picked vault folder doesn't resolve to an existing - // directory. Bubbles up as the RPC dispatcher's + // TAURI-RUST-4QH: verbatim wire shape historically emitted by the + // now-removed knowledge-vault `vault_create` path when the + // user-picked folder didn't resolve to an existing directory. + // Retained as a classifier fixture in case the shape recurs from + // another caller. Bubbles up as the RPC dispatcher's // `display_message` and reaches `report_error_or_expected` — // must classify so no Sentry event fires. assert_eq!( diff --git a/src/openhuman/about_app/catalog_data.rs b/src/openhuman/about_app/catalog_data.rs index 33152e5b0..4a28b5121 100644 --- a/src/openhuman/about_app/catalog_data.rs +++ b/src/openhuman/about_app/catalog_data.rs @@ -310,16 +310,6 @@ pub(super) const CAPABILITIES: &[Capability] = &[ status: CapabilityStatus::Beta, privacy: LOCAL_RAW, }, - Capability { - id: "intelligence.vault_markdown_writes", - name: "Vault Markdown Writes", - domain: "intelligence", - category: CapabilityCategory::Intelligence, - description: "Show whether a user-added local vault is writable, and write explicitly approved markdown/wiki artifacts back into that vault without leaving the device.", - how_to: "Intelligence > Memory > Knowledge vaults", - status: CapabilityStatus::Beta, - privacy: LOCAL_RAW, - }, Capability { id: "intelligence.embedding_provider_config", name: "Configure Embedding Provider", diff --git a/src/openhuman/about_app/catalog_tests.rs b/src/openhuman/about_app/catalog_tests.rs index 5cc917431..a385f7994 100644 --- a/src/openhuman/about_app/catalog_tests.rs +++ b/src/openhuman/about_app/catalog_tests.rs @@ -103,7 +103,6 @@ fn catalog_includes_additional_user_facing_surfaces() { "intelligence.mcp_server", "intelligence.searxng_search", "intelligence.tool_registry", - "intelligence.vault_markdown_writes", "intelligence.embedding_provider_config", "intelligence.embedding_provider_test", "conversation.subagent_mascots", diff --git a/src/openhuman/memory/read_rpc.rs b/src/openhuman/memory/read_rpc.rs index 5f5e2fb41..939003aab 100644 --- a/src/openhuman/memory/read_rpc.rs +++ b/src/openhuman/memory/read_rpc.rs @@ -879,8 +879,9 @@ pub struct DeleteChunkResponse { /// Which graph the UI is asking for. /// -/// `Tree` returns summary nodes connected by parent_id (current -/// Obsidian-style summary tree). `Contacts` returns raw chunks +/// `Tree` returns the summary tree (summary nodes connected by +/// parent_id) plus the leaf chunks hanging off it, bounded to ~1000 +/// nodes with summaries prioritized. `Contacts` returns raw chunks /// connected to the person entities they mention via the inverted /// `mem_tree_entity_index` — i.e. the document↔contact graph. /// @@ -1071,10 +1072,26 @@ pub async fn obsidian_vault_status_rpc( } /// Tree mode: summary nodes joined to their owning tree for the -/// human-readable scope. Edges are encoded implicitly via -/// `GraphNode.parent_id`. +/// human-readable scope, plus the leaf chunks that hang off them. Edges +/// are encoded implicitly via `GraphNode.parent_id` (a chunk's +/// `parent_id` is its `parent_summary_id`, which matches a summary node's +/// `id`). +/// +/// Budget: summary (tree) nodes are **always kept in full** — they are +/// the skeleton of the graph — then leaf chunks fill the remaining budget +/// up to [`MAX_TREE_NODES`], most-recent first. Without the leaves the UI +/// graph showed only the handful of sealed summaries (e.g. ~20) while +/// Obsidian, which renders every `.md` on disk, showed hundreds; the +/// chunks are the bulk of the tree. Unsealed chunks have a null +/// `parent_summary_id` and render as orphan nodes — matching Obsidian's +/// `showOrphans` view. fn collect_tree_graph(cfg: &Config) -> Result<(Vec, Vec)> { - let nodes = with_connection(cfg, |conn| { + /// Hard cap on total nodes returned in Tree mode. The custom + /// force-simulation in the UI does all-pairs repulsion (O(n²)), so + /// this bounds both the wire payload and the layout cost. + const MAX_TREE_NODES: usize = 1000; + + let mut nodes = with_connection(cfg, |conn| { let mut stmt = conn.prepare( "SELECT s.id, s.tree_id, s.tree_kind, t.scope, s.level, s.parent_id, s.child_ids_json, s.time_range_start_ms, s.time_range_end_ms @@ -1119,6 +1136,62 @@ fn collect_tree_graph(cfg: &Config) -> Result<(Vec, Vec)> .context("collect tree-mode summary rows")?; Ok(rows) })?; + + // Fill the remaining budget with leaf chunks, most-recent first. + // Summaries are kept whole; only the chunk tail is truncated when a + // very large workspace would blow past MAX_TREE_NODES. + let chunk_budget = MAX_TREE_NODES.saturating_sub(nodes.len()); + if chunk_budget == 0 { + return Ok((nodes, Vec::new())); + } + + let chunk_nodes = with_connection(cfg, |conn| { + let mut stmt = conn.prepare( + "SELECT id, parent_summary_id, content, time_range_start_ms, time_range_end_ms + FROM mem_tree_chunks + ORDER BY timestamp_ms DESC + LIMIT ?1", + )?; + let rows = stmt + .query_map(params![chunk_budget as i64], |row| { + let id: String = row.get(0)?; + let parent_id: Option = row.get(1)?; + let content: String = row.get(2)?; + let time_range_start_ms: i64 = row.get(3)?; + let time_range_end_ms: i64 = row.get(4)?; + // First line, trimmed for graph hover legibility — same + // treatment as Contacts mode chunk labels. + let label = content + .lines() + .next() + .unwrap_or("") + .chars() + .take(72) + .collect::(); + Ok(GraphNode { + kind: "chunk".into(), + id, + label, + tree_kind: None, + tree_scope: None, + tree_id: None, + level: None, + // parent_summary_id matches a summary node's `id`, + // so the UI draws the chunk→summary edge directly. + // Null for unsealed chunks → orphan node. + parent_id: parent_id.filter(|s| !s.is_empty()), + child_count: None, + time_range_start_ms: Some(time_range_start_ms), + time_range_end_ms: Some(time_range_end_ms), + file_basename: None, + entity_kind: None, + }) + })? + .collect::>>() + .context("collect tree-mode leaf chunk rows")?; + Ok(rows) + })?; + nodes.extend(chunk_nodes); Ok((nodes, Vec::new())) } diff --git a/src/openhuman/memory/read_rpc_tests.rs b/src/openhuman/memory/read_rpc_tests.rs index 5d4da34f8..ecb9d7bfb 100644 --- a/src/openhuman/memory/read_rpc_tests.rs +++ b/src/openhuman/memory/read_rpc_tests.rs @@ -720,6 +720,118 @@ fn clear_composio_sync_state_removes_only_target_namespace() { assert_eq!(other_count, 1); } +// ── tree-mode graph export (summaries + leaf chunks) ──────────────────── + +/// Insert a tree row and one summary node under it. +fn insert_tree_summary(cfg: &Config, tree_id: &str, scope: &str, summary_id: &str, level: i64) { + with_connection(cfg, |conn| { + conn.execute( + "INSERT OR IGNORE INTO mem_tree_trees (id, kind, scope, created_at_ms) + VALUES (?1, 'source', ?2, 0)", + params![tree_id, scope], + )?; + conn.execute( + "INSERT INTO mem_tree_summaries ( + id, tree_id, tree_kind, level, child_ids_json, content, token_count, + entities_json, topics_json, time_range_start_ms, time_range_end_ms, + score, sealed_at_ms, deleted + ) VALUES (?1, ?2, 'source', ?3, '[]', 'summary body', 1, '[]', '[]', 0, 0, 0.0, 0, 0)", + params![summary_id, tree_id, level], + )?; + Ok(()) + }) + .unwrap(); +} + +/// Insert a leaf chunk, optionally linked to a parent summary. +fn insert_chunk_with_parent( + cfg: &Config, + id: &str, + parent_summary_id: Option<&str>, + timestamp_ms: i64, + content: &str, +) { + with_connection(cfg, |conn| { + conn.execute( + "INSERT INTO mem_tree_chunks ( + id, source_kind, source_id, source_ref, owner, timestamp_ms, + time_range_start_ms, time_range_end_ms, tags_json, content, + token_count, seq_in_source, created_at_ms, lifecycle_status, + content_path, parent_summary_id + ) VALUES (?1, 'chat', 'slack:#eng', NULL, 'tester', ?2, ?2, ?2, '[]', ?3, 1, 0, ?2, 'seeded', NULL, ?4)", + params![id, timestamp_ms, content, parent_summary_id], + )?; + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn tree_graph_includes_leaf_chunks_linked_to_their_summary() { + let (_tmp, cfg) = test_config(); + insert_tree_summary(&cfg, "tree-1", "slack:#eng", "summary:1:L1-aaa", 1); + insert_chunk_with_parent( + &cfg, + "chunk-sealed", + Some("summary:1:L1-aaa"), + 1_700_000_000_000, + "first line of sealed chunk\nmore body", + ); + insert_chunk_with_parent( + &cfg, + "chunk-orphan", + None, + 1_700_000_000_001, + "orphan chunk body", + ); + + let resp = graph_export_rpc(&cfg, GraphMode::Tree).await.unwrap().value; + + // Before this fix tree mode returned only the summary node; the + // leaves were invisible in the UI while Obsidian showed them all. + assert_eq!(resp.nodes.len(), 3, "summary + both leaf chunks"); + + let summary = resp.nodes.iter().find(|n| n.kind == "summary").unwrap(); + assert_eq!(summary.id, "summary:1:L1-aaa"); + + let sealed = resp.nodes.iter().find(|n| n.id == "chunk-sealed").unwrap(); + assert_eq!(sealed.kind, "chunk"); + // A chunk's parent_id == its parent summary's node id, so the UI's + // parent_id edge logic draws the chunk→summary link directly. + assert_eq!(sealed.parent_id.as_deref(), Some("summary:1:L1-aaa")); + // Label is the trimmed first line of the chunk content. + assert_eq!(sealed.label, "first line of sealed chunk"); + + let orphan = resp.nodes.iter().find(|n| n.id == "chunk-orphan").unwrap(); + assert!( + orphan.parent_id.is_none(), + "unsealed chunk has no parent → renders as an orphan node" + ); + + // Tree mode encodes edges via parent_id; the explicit edges array + // stays empty. + assert!(resp.edges.is_empty()); +} + +#[tokio::test] +async fn tree_graph_keeps_summaries_first_then_chunks() { + let (_tmp, cfg) = test_config(); + insert_tree_summary(&cfg, "tree-1", "slack:#eng", "summary:1:L1-aaa", 1); + insert_chunk_with_parent( + &cfg, + "chunk-1", + Some("summary:1:L1-aaa"), + 1_700_000_000_000, + "a chunk", + ); + + let resp = graph_export_rpc(&cfg, GraphMode::Tree).await.unwrap().value; + // Summaries are emitted ahead of chunks so a budget truncation drops + // chunk tails, never the tree skeleton. + assert_eq!(resp.nodes[0].kind, "summary"); + assert!(resp.nodes.iter().any(|n| n.kind == "chunk")); +} + #[tokio::test] async fn obsidian_status_registered_when_override_config_lists_content_root() { let (_tmp, cfg) = test_config(); diff --git a/src/openhuman/memory_store/content/obsidian_defaults/graph.json b/src/openhuman/memory_store/content/obsidian_defaults/graph.json index aa4bbd923..a582d66a3 100644 --- a/src/openhuman/memory_store/content/obsidian_defaults/graph.json +++ b/src/openhuman/memory_store/content/obsidian_defaults/graph.json @@ -8,42 +8,42 @@ "collapse-color-groups": false, "colorGroups": [ { - "query": "file:summary-L1", + "query": "path:L1", "color": { "a": 1, "rgb": 14701138 } }, { - "query": "file:summary-L2", + "query": "path:L2", "color": { "a": 1, "rgb": 14725458 } }, { - "query": "file:summary-L3", + "query": "path:L3", "color": { "a": 1, "rgb": 11657298 } }, { - "query": "file:summary-L4", + "query": "path:L4", "color": { "a": 1, "rgb": 5420768 } }, { - "query": "file:summary-L5", + "query": "path:L5", "color": { "a": 1, "rgb": 5431504 } }, { - "query": "file:summary-L6", + "query": "path:L6", "color": { "a": 1, "rgb": 14701261 diff --git a/src/openhuman/memory_sync/workspace/mod.rs b/src/openhuman/memory_sync/workspace/mod.rs index e3e3359c5..75057717b 100644 --- a/src/openhuman/memory_sync/workspace/mod.rs +++ b/src/openhuman/memory_sync/workspace/mod.rs @@ -5,13 +5,13 @@ //! //! | Submodule | Source | Notes | //! | --- | --- | --- | -//! | `vault` | Files dropped into the Obsidian vault by the user | Watch + diff | +//! | `folder` | Files under a user-added folder memory source | Watch + diff | //! | `harness` | Agent harness turns (memory_archivist's caller side) | Push-based | //! | `dictation` | Local audio capture transcripts | Push-based | //! //! ## Status //! -//! Scaffold only. Today the vault watch lives in `vault/sync.rs`, -//! harness capture in `agent_experience/`, and dictation in -//! `dictation_hotkeys/`. Each will land here as a [`SyncPipeline`] impl -//! in a follow-up. +//! Scaffold only. Today folder ingestion lives in +//! `memory_sources/readers/folder.rs`, harness capture in +//! `agent_experience/`, and dictation in `dictation_hotkeys/`. Each will +//! land here as a [`SyncPipeline`] impl in a follow-up. diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 96d295b71..660078084 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -107,7 +107,6 @@ pub mod tool_timeout; pub mod tools; pub mod update; pub mod util; -pub mod vault; pub mod voice; pub mod wallet; pub mod web3; diff --git a/src/openhuman/tools/impl/filesystem/mod.rs b/src/openhuman/tools/impl/filesystem/mod.rs index 3fe9ec862..aa8e607a5 100644 --- a/src/openhuman/tools/impl/filesystem/mod.rs +++ b/src/openhuman/tools/impl/filesystem/mod.rs @@ -11,7 +11,6 @@ mod read_diff; mod run_linter; mod run_tests; mod update_memory_md; -mod vault_write_markdown; pub use apply_patch::ApplyPatchTool; pub use csv_export::CsvExportTool; @@ -26,4 +25,3 @@ pub use read_diff::ReadDiffTool; pub use run_linter::RunLinterTool; pub use run_tests::RunTestsTool; pub use update_memory_md::UpdateMemoryMdTool; -pub use vault_write_markdown::VaultWriteMarkdownTool; diff --git a/src/openhuman/tools/impl/filesystem/vault_write_markdown.rs b/src/openhuman/tools/impl/filesystem/vault_write_markdown.rs deleted file mode 100644 index b31adbcb6..000000000 --- a/src/openhuman/tools/impl/filesystem/vault_write_markdown.rs +++ /dev/null @@ -1,177 +0,0 @@ -//! Tool: vault_write_markdown — approved markdown/wiki writes into user vaults. - -use std::sync::Arc; - -use async_trait::async_trait; -use serde_json::json; - -use crate::openhuman::config::Config; -use crate::openhuman::security::{CommandClass, GateDecision, SecurityPolicy}; -use crate::openhuman::tools::traits::{PermissionLevel, Tool, ToolResult}; - -pub struct VaultWriteMarkdownTool { - config: Arc, - security: Arc, -} - -impl VaultWriteMarkdownTool { - pub fn new(config: Arc, security: Arc) -> Self { - Self { config, security } - } -} - -#[async_trait] -impl Tool for VaultWriteMarkdownTool { - fn name(&self) -> &str { - "vault_write_markdown" - } - - fn description(&self) -> &str { - "Create or update an approved .md/.markdown wiki note inside a registered knowledge vault. \ - Use only after the user has approved writing generated content into that vault." - } - - fn parameters_schema(&self) -> serde_json::Value { - json!({ - "type": "object", - "required": ["vault_id", "rel_path", "content"], - "properties": { - "vault_id": { - "type": "string", - "description": "Identifier of the registered vault to write into." - }, - "rel_path": { - "type": "string", - "description": "Relative path under the vault root. Must end with .md or .markdown." - }, - "content": { - "type": "string", - "description": "Markdown content to write." - }, - "overwrite": { - "type": "boolean", - "description": "Set true to update an existing markdown file." - } - } - }) - } - - fn permission_level(&self) -> PermissionLevel { - PermissionLevel::Write - } - - fn external_effect_with_args(&self, _args: &serde_json::Value) -> bool { - self.security.gate_decision(CommandClass::Write) == GateDecision::Prompt - } - - async fn execute(&self, args: serde_json::Value) -> anyhow::Result { - if !self.security.can_act() { - return Ok(ToolResult::error( - "[policy-blocked] Action blocked: autonomy is read-only", - )); - } - if self.security.is_rate_limited() { - return Ok(ToolResult::error( - "Rate limit exceeded: too many actions in the last hour", - )); - } - - let vault_id = args - .get("vault_id") - .and_then(|value| value.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'vault_id' parameter"))?; - let rel_path = args - .get("rel_path") - .and_then(|value| value.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'rel_path' parameter"))?; - let content = args - .get("content") - .and_then(|value| value.as_str()) - .ok_or_else(|| anyhow::anyhow!("Missing 'content' parameter"))?; - let overwrite = args - .get("overwrite") - .and_then(|value| value.as_bool()) - .unwrap_or(false); - - if !self.security.record_action() { - return Ok(ToolResult::error( - "Rate limit exceeded: action budget exhausted", - )); - } - - tracing::debug!( - vault_id = %vault_id, - rel_path = %rel_path, - overwrite, - content_bytes = content.len(), - "[vault_write_markdown] execute" - ); - match crate::openhuman::vault::ops::vault_write_markdown( - &self.config, - vault_id, - rel_path, - content, - overwrite, - true, - ) - .await - { - Ok(outcome) => Ok(ToolResult::json(json!(outcome.value))), - Err(err) => Ok(ToolResult::error(err)), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::openhuman::security::AutonomyLevel; - - fn security(workspace: std::path::PathBuf, autonomy: AutonomyLevel) -> Arc { - Arc::new(SecurityPolicy { - autonomy, - workspace_dir: workspace, - ..SecurityPolicy::default() - }) - } - - #[test] - fn vault_write_markdown_tool_schema_requires_core_fields() { - let dir = tempfile::tempdir().unwrap(); - let config = Arc::new(Config::default()); - let tool = VaultWriteMarkdownTool::new( - config, - security(dir.path().to_path_buf(), AutonomyLevel::Supervised), - ); - - assert_eq!(tool.name(), "vault_write_markdown"); - assert_eq!(tool.permission_level(), PermissionLevel::Write); - assert!(tool.external_effect_with_args(&json!({}))); - let schema = tool.parameters_schema(); - let required = schema["required"].as_array().unwrap(); - assert!(required.iter().any(|value| value == "vault_id")); - assert!(required.iter().any(|value| value == "rel_path")); - assert!(required.iter().any(|value| value == "content")); - } - - #[tokio::test] - async fn vault_write_markdown_tool_blocks_read_only_autonomy() { - let dir = tempfile::tempdir().unwrap(); - let config = Arc::new(Config::default()); - let tool = VaultWriteMarkdownTool::new( - config, - security(dir.path().to_path_buf(), AutonomyLevel::ReadOnly), - ); - - let result = tool - .execute(json!({ - "vault_id": "v-1", - "rel_path": "wiki/a.md", - "content": "# A" - })) - .await - .unwrap(); - assert!(result.is_error); - assert!(result.text().contains("read-only")); - } -} diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 3a5fbeb8c..cc6d6400f 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -133,10 +133,6 @@ pub fn all_tools_with_runtime( shell, Box::new(FileReadTool::new(security.clone())), Box::new(FileWriteTool::new(security.clone())), - Box::new(VaultWriteMarkdownTool::new( - config.clone(), - security.clone(), - )), // Coding-harness baseline tools (issue #1205): file navigation // + atomic editing primitives. Use these instead of falling // through to `shell` for grep/find/sed work. diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index d4a79e91a..30e963a0a 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -177,43 +177,6 @@ fn all_tools_includes_spawn_parallel_agents() { ); } -#[test] -fn all_tools_includes_vault_write_markdown() { - let tmp = TempDir::new().unwrap(); - let security = Arc::new(SecurityPolicy::default()); - let mem_cfg = MemoryConfig { - backend: "markdown".into(), - ..MemoryConfig::default() - }; - let mem: Arc = - Arc::from(crate::openhuman::memory_store::create_memory(&mem_cfg, tmp.path()).unwrap()); - let browser = BrowserConfig { - enabled: false, - allowed_domains: vec![], - session_name: None, - ..BrowserConfig::default() - }; - let http = crate::openhuman::config::HttpRequestConfig::default(); - let cfg = test_config(&tmp); - - let tools = all_tools( - Arc::new(cfg.clone()), - &security, - AuditLogger::disabled(), - mem, - &browser, - &http, - tmp.path(), - &HashMap::new(), - &cfg, - ); - let names: Vec<&str> = tools.iter().map(|t| t.name()).collect(); - assert!( - names.contains(&"vault_write_markdown"), - "vault_write_markdown must be registered so agents can write approved markdown into user vaults; got: {names:?}" - ); -} - #[test] fn all_tools_always_registers_curl() { // Regression guard: `curl` is always registered (gated only by @@ -419,7 +382,6 @@ fn all_tools_default_registry_contains_expected_baseline_surface() { "shell", "file_read", "file_write", - "vault_write_markdown", "grep", "glob", "list", diff --git a/src/openhuman/tools/user_filter.rs b/src/openhuman/tools/user_filter.rs index 8b1b2d646..806ac7317 100644 --- a/src/openhuman/tools/user_filter.rs +++ b/src/openhuman/tools/user_filter.rs @@ -9,10 +9,7 @@ const TOOL_ID_TO_RUST_NAMES: &[(&str, &[&str])] = &[ ("install_tool", &["install_tool"]), ("git_operations", &["git_operations"]), ("file_read", &["file_read", "read_diff", "csv_export"]), - ( - "file_write", - &["file_write", "update_memory_md", "vault_write_markdown"], - ), + ("file_write", &["file_write", "update_memory_md"]), ("screenshot", &["screenshot"]), ("image_info", &["image_info"]), ("browser_open", &["browser_open"]), diff --git a/src/openhuman/vault/README.md b/src/openhuman/vault/README.md deleted file mode 100644 index 30feda616..000000000 --- a/src/openhuman/vault/README.md +++ /dev/null @@ -1,95 +0,0 @@ -# vault - -Knowledge vault domain — a NotebookLM-style "folder of files" mirrored into the memory-tree backend. A `Vault` points at a local directory; on sync the module walks that directory, routes supported files to a plain-UTF-8 extractor by extension, and feeds them through the memory ingestion pipeline under a vault-derived namespace. Per-file dedup uses `(rel_path, mtime, content hash)` so re-syncs only touch what actually changed, and vanished files have their memory rows deleted to keep retrieval in sync with disk. - -## Responsibilities - -- Register / list / fetch / remove user-owned local folders as "vaults" backed by SQLite. -- Walk a vault's root directory, prune built-in noise dirs (`.git`, `node_modules`, `target`, …), apply user include/exclude substring patterns, and filter by supported extension + max file size (5 MiB). -- Ingest new/changed files into the **memory-tree** backend (`mem_tree_chunks` / `mem_tree_ingested_sources`) via the ingest pipeline, keyed by a stable `source_id = vault:{vault_id}:{rel_path}`. -- Two-tier dedup: fast-path mtime skip during discovery, secondary content-hash skip during concurrent ingestion. -- For content updates, delete prior chunks before re-ingest (the pipeline's `already_ingested` gate is content-blind on a stable source_id). -- Delete memory rows for files that vanished since the last sync; record per-file outcome in a ledger. -- Run sync as a background tokio task; expose live progress + final outcome via an in-process state registry, polled by the frontend. -- On vault removal, optionally purge all memory rows for the vault (memory-tree prefix delete + best-effort legacy `clear_namespace`). -- Hide vaults that belong to an incompatible host OS / path shape (cross-machine config sharing safety). - -## Key files - -| File | Role | -| --- | --- | -| `src/openhuman/vault/mod.rs` | Export-focused module root: docstring, `mod`/`pub mod` decls, re-exports of types + controller-schema pair. | -| `src/openhuman/vault/types.rs` | Serde domain types: `Vault`, `VaultFile`, `VaultFileStatus`, `VaultSyncState`, `VaultSyncStatus`, `VaultSyncReport`. | -| `src/openhuman/vault/ops.rs` | RPC-facing business logic: `vault_create/list/get/files/remove/sync/sync_status`, namespace derivation, background-task spawn with panic guard, memory purge on remove. | -| `src/openhuman/vault/schemas.rs` | Controller schemas + `handle_*` fns delegating to `ops`; `all_controller_schemas` / `all_registered_controllers`. | -| `src/openhuman/vault/store.rs` | SQLite persistence (`vault.db`): vault + per-file ledger tables, migrations, host-OS compatibility filtering. | -| `src/openhuman/vault/sync.rs` | The directory-walk + 4-phase ingest engine (discovery → concurrent ingest → ledger writes → deletions); supported-extension list; memory-tree source_id helper. Inline `sync_tests`. | -| `src/openhuman/vault/state.rs` | Process-global in-memory sync-progress registry (`once_cell::Lazy` + `parking_lot::RwLock`), keyed by `vault_id`. | -| `src/openhuman/vault/tests.rs` | `#[cfg(test)]` module (additional tests beyond the inline `sync.rs` suite). | - -## Public surface - -Re-exported from `mod.rs`: - -- Types: `Vault`, `VaultFile`, `VaultFileStatus`, `VaultSyncReport`, `VaultSyncState`, `VaultSyncStatus`. -- `all_vault_controller_schemas` / `all_vault_registered_controllers` (the controller-registry pair, wired in `src/core/all.rs`). -- `ops` is `pub mod` — `vault_create`, `vault_list`, `vault_get`, `vault_files`, `vault_remove`, `vault_sync`, `vault_sync_status`. - -## RPC / controllers - -Namespace `vault` (invoked as `openhuman.vault_`): - -| Method | Inputs | Output | -| --- | --- | --- | -| `vault.create` | `name`, `root_path` (absolute dir), `include_globs?`, `exclude_globs?` | `Vault` | -| `vault.list` | — | `Vec` (current-host only) | -| `vault.get` | `vault_id` | `Vault` | -| `vault.files` | `vault_id` | `Vec` (per-file ledger) | -| `vault.remove` | `vault_id`, `purge_memory?` | `{ vault_id, removed, purged, memory_tree_chunks_deleted, purge_error? }` | -| `vault.sync` | `vault_id` | `{ status: "started", vault_id }` (background; rejects if already running) | -| `vault.sync_status` | `vault_id` | `VaultSyncState` (Idle / Running / Completed / Failed) | - -`include_globs` / `exclude_globs` are substring matches (case-insensitive), not true globs despite the name. - -## Agent tools - -None — the module owns no `tools.rs`. - -## Events - -None — no `bus.rs`; the module publishes/subscribes to no `DomainEvent`s. Sync progress is surfaced via the in-memory `state` registry + `vault.sync_status` polling rather than the event bus. - -## Persistence - -- **SQLite** at `{workspace_dir}/vault/vault.db` (`store.rs`): - - `vaults` — id, name, root_path, host_os, namespace (UNIQUE), include/exclude globs (JSON), created_at, last_synced_at. - - `vault_files` — per-file ledger keyed by `(vault_id, rel_path)`, FK→`vaults` with `ON DELETE CASCADE`; stores `document_id` (= memory-tree source_id post-#2705), `content_hash`, `mtime_ms`, `bytes`, `ingested_at`, `status`. The dedup ledger. - - Lazy schema init + an additive `host_os` column migration (cached per DB path). -- **Process-global in-memory** (`state.rs`): `VaultSyncState` per vault, retained after completion until the next sync overwrites it. Lost on process restart. -- **Memory-tree** (owned by the `memory` domain, written via the ingest pipeline): chunks under `source_id = vault:{vault_id}:{rel_path}`. - -## Dependencies - -- `crate::openhuman::config` (`Config`, `config::rpc::load_config_with_timeout`) — workspace dir for the DB, config passed into ingest/delete calls. -- `crate::openhuman::memory::ingest_pipeline` — `ingest_document` / `IngestResult`: the canonical memory-tree ingest path. -- `crate::openhuman::memory::ops` — `clear_namespace` (legacy purge on remove) and `doc_delete` (best-effort legacy UnifiedMemory cleanup for pre-#2705 ledger rows). -- `crate::openhuman::memory_store::chunks` — `delete_chunks_by_source` / `delete_chunks_by_source_prefix` (chunk cleanup on re-ingest, file deletion, and vault purge); `SourceKind::Document`. -- `crate::openhuman::memory_sync::canonicalize::document::DocumentInput` — the document shape handed to the ingest pipeline. -- `crate::core::all` (`ControllerFuture`, `RegisteredController`) + `crate::core::{ControllerSchema, FieldSchema, TypeSchema}` — controller registration plumbing. -- `crate::rpc::RpcOutcome` — RPC return contract. -- External crates: `rusqlite`, `walkdir`, `sha2`, `uuid`, `chrono`, `futures` (`buffer_unordered`), `tokio`, `once_cell`, `parking_lot`. - -## Used by - -- `src/core/all.rs` — registers vault controllers + schemas into the global controller registry (the only wiring point; vault is controller-only, no CLI/JSON-RPC branches). -- `src/core/observability.rs` (+ tests) — references `vault_create` / `openhuman.vault_create` as an example in path-boundary / autonomy-policy diagnostics, not a runtime dependency. - -## Notes / gotchas - -- **Memory-tree, not `memory_docs` (#2705).** Sync ingests via the memory-tree pipeline; the pre-#2705 path wrote to legacy `UnifiedMemory` (`memory_docs`), which the UI reported as "synced" but retrieval never saw. The ledger now stores the memory-tree `source_id` (`vault:{id}:{rel_path}`); deletion/purge logic keys off `document_id.starts_with("vault:")` to distinguish post- vs pre-#2705 rows and run the right cleanup. -- **Namespace is a hashed digest, not the raw UUID.** `vault_namespace_for_id` derives an alphabet-only SHA-256 suffix (`vault-<24 a-z chars>`) because memory writes reject namespace/key values that resemble PII / strict identifier patterns. -- **`include_globs`/`exclude_globs` are substring matches**, lowercased, not real glob patterns — the field name is misleading. -- **Background sync + panic guard.** `vault_sync` spawns a tokio task and returns immediately; the work is wrapped in `catch_unwind` so a panic marks state `Failed` instead of leaving it stuck in `Running` (which would reject every future sync until restart). Concurrency is bounded to 4 (`buffer_unordered`). -- **Host-OS gating.** `list_vaults`/`get_vault` hide vaults whose stamped `host_os` (or, for legacy rows, whose `root_path` shape) doesn't match the current machine — prevents surfacing another machine's folders from shared config. -- **Ledger ↔ memory_tree desync is logged loudly.** If ingest returns `already_ingested && chunks_written == 0` after the delete-first guard, the code emits a `warn!` because it means new content never reached retrieval (the exact false-success class #2705 was meant to kill). -- `vault_create` canonicalizes `root_path` (falling back to the trimmed input if canonicalization fails), so the stored id/path may differ from the literal input. diff --git a/src/openhuman/vault/mod.rs b/src/openhuman/vault/mod.rs deleted file mode 100644 index 3a5bee096..000000000 --- a/src/openhuman/vault/mod.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! Knowledge vault — folder-of-files ingested into memory (NotebookLM-style). -//! -//! A `Vault` points at a local directory; on `vault.sync` we walk it, route -//! files to extractors by extension, and feed them into the memory pipeline -//! under a vault-derived namespace. Per-file dedup uses (path, mtime, content -//! hash) so re-syncs only touch what changed. - -pub mod ops; -mod schemas; -pub(crate) mod state; -mod store; -mod sync; -mod types; - -pub use schemas::{ - all_controller_schemas as all_vault_controller_schemas, - all_registered_controllers as all_vault_registered_controllers, -}; -pub use types::{ - Vault, VaultFile, VaultFileStatus, VaultSyncReport, VaultSyncState, VaultSyncStatus, - VaultWriteMarkdownReport, VaultWriteState, -}; - -#[cfg(test)] -mod tests; diff --git a/src/openhuman/vault/ops.rs b/src/openhuman/vault/ops.rs deleted file mode 100644 index a6b495cbc..000000000 --- a/src/openhuman/vault/ops.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! RPC-facing operations for the vault domain. - -use chrono::Utc; -use futures::FutureExt; -use sha2::{Digest, Sha256}; -use std::path::{Component, Path, PathBuf}; -use uuid::Uuid; - -use crate::openhuman::config::Config; -use crate::openhuman::memory::ops::{clear_namespace, ClearNamespaceParams}; -use crate::openhuman::memory_store::chunks::store::delete_chunks_by_source_prefix; -use crate::openhuman::memory_store::chunks::types::SourceKind; -use crate::rpc::RpcOutcome; - -use super::state; -use super::store; -use super::sync; -use super::types::{ - Vault, VaultFile, VaultSyncState, VaultSyncStatus, VaultWriteMarkdownReport, VaultWriteState, -}; - -/// Derive a stable memory namespace for a vault without embedding the raw UUID. -/// -/// Memory writes reject namespace/key values that resemble PII. Raw UUID hex can -/// occasionally match strict alphanumeric identifier patterns, so vault -/// namespaces use an alphabet-only digest suffix instead. -pub(crate) fn vault_namespace_for_id(id: &str) -> String { - let digest = Sha256::digest(id.as_bytes()); - let suffix: String = digest - .iter() - .take(24) - .map(|byte| char::from(b'a' + (byte % 26))) - .collect(); - format!("vault-{suffix}") -} - -/// Create a new vault pointing at a local folder. -pub async fn vault_create( - config: &Config, - name: &str, - root_path: &str, - include_globs: Vec, - exclude_globs: Vec, -) -> Result, String> { - let trimmed_name = name.trim(); - if trimmed_name.is_empty() { - return Err("vault name must not be empty".to_string()); - } - let trimmed_root = root_path.trim(); - if trimmed_root.is_empty() { - return Err("root_path must not be empty".to_string()); - } - let root = std::path::Path::new(trimmed_root); - if !root.is_absolute() { - return Err(format!("root_path must be absolute: {trimmed_root}")); - } - if !root.is_dir() { - return Err(format!("root_path is not a directory: {trimmed_root}")); - } - - let id = Uuid::new_v4().to_string(); - log::debug!( - "[vault] create: name={trimmed_name:?} root={trimmed_root:?} id={id} \ - include_globs={} exclude_globs={}", - include_globs.len(), - exclude_globs.len(), - ); - let namespace = vault_namespace_for_id(&id); - let canonical_root = root - .canonicalize() - .map(|p| p.to_string_lossy().to_string()) - .unwrap_or_else(|_| trimmed_root.to_string()); - let (write_state, write_state_reason) = store::vault_write_state_for_root_path(&canonical_root); - let vault = Vault { - id: id.clone(), - name: trimmed_name.to_string(), - root_path: canonical_root, - host_os: Some(store::current_host_os().to_string()), - namespace, - include_globs, - exclude_globs, - created_at: Utc::now(), - last_synced_at: None, - file_count: 0, - write_state, - write_state_reason, - }; - - store::insert_vault(config, &vault).map_err(|e| e.to_string())?; - Ok(RpcOutcome::single_log( - vault, - format!("vault created: {id}"), - )) -} - -pub async fn vault_list(config: &Config) -> Result>, String> { - let vaults = store::list_vaults(config).map_err(|e| e.to_string())?; - log::debug!("[vault] list: count={}", vaults.len()); - Ok(RpcOutcome::single_log(vaults, "vaults listed")) -} - -pub async fn vault_get(config: &Config, id: &str) -> Result, String> { - let id = id.trim(); - if id.is_empty() { - return Err("vault_id must not be empty".to_string()); - } - let vault = store::get_vault(config, id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("vault not found: {id}"))?; - log::debug!("[vault] get: id={id} files={}", vault.file_count); - Ok(RpcOutcome::single_log(vault, "vault loaded")) -} - -/// Write an explicitly approved markdown/wiki artifact into a registered vault. -pub async fn vault_write_markdown( - config: &Config, - id: &str, - rel_path: &str, - content: &str, - overwrite: bool, - approved: bool, -) -> Result, String> { - let id = id.trim(); - if id.is_empty() { - return Err("vault_id must not be empty".to_string()); - } - if !approved { - log::debug!("[vault] write_markdown: rejected missing approval id={id}"); - return Err("vault markdown writes require explicit user approval".to_string()); - } - - let vault = store::get_vault(config, id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("vault not found: {id}"))?; - let (write_state, write_reason) = store::vault_write_state_for_root_path(&vault.root_path); - if write_state != VaultWriteState::Writable { - log::debug!( - "[vault] write_markdown: rejected non-writable id={id} state={write_state:?} reason={write_reason:?}" - ); - return Err(store::vault_write_state_reason_message(write_reason.as_deref()).to_string()); - } - - let rel = validate_markdown_rel_path(rel_path)?; - let bytes = content.as_bytes().len() as u64; - let root = std::fs::canonicalize(&vault.root_path) - .map_err(|err| format!("failed to resolve vault folder: {err}"))?; - ensure_existing_ancestors_stay_in_root(&root, &rel)?; - let target = root.join(&rel); - let parent = target - .parent() - .ok_or_else(|| "target path has no parent directory".to_string())?; - std::fs::create_dir_all(parent) - .map_err(|err| format!("failed to create vault note directory: {err}"))?; - let parent_canon = std::fs::canonicalize(parent) - .map_err(|err| format!("failed to resolve vault note directory: {err}"))?; - if !parent_canon.starts_with(&root) { - return Err("vault note path resolves outside the vault folder".to_string()); - } - if let Ok(meta) = std::fs::symlink_metadata(&target) { - if meta.file_type().is_symlink() { - return Err("refusing to write through a symlink inside the vault".to_string()); - } - } - let created = !target.exists(); - if !created && !overwrite { - return Err( - "vault markdown file already exists; set overwrite=true to update it".to_string(), - ); - } - - log::debug!( - "[vault] write_markdown: writing id={id} rel_path={} bytes={bytes} overwrite={overwrite} created={created}", - rel.display() - ); - std::fs::write(&target, content) - .map_err(|err| format!("failed to write vault markdown file: {err}"))?; - - Ok(RpcOutcome::single_log( - VaultWriteMarkdownReport { - vault_id: id.to_string(), - rel_path: rel.to_string_lossy().replace('\\', "/"), - bytes_written: bytes, - created, - }, - format!("vault markdown written: {id}"), - )) -} - -pub async fn vault_files(config: &Config, id: &str) -> Result>, String> { - let id = id.trim(); - if id.is_empty() { - return Err("vault_id must not be empty".to_string()); - } - store::get_vault(config, id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("vault not found: {id}"))?; - let files = store::list_files(config, id).map_err(|e| e.to_string())?; - log::debug!("[vault] files: id={id} count={}", files.len()); - Ok(RpcOutcome::single_log(files, "vault files listed")) -} - -fn validate_markdown_rel_path(raw: &str) -> Result { - let trimmed = raw.trim(); - if trimmed.is_empty() { - return Err("rel_path must not be empty".to_string()); - } - let path = Path::new(trimmed); - if path.is_absolute() { - return Err("rel_path must be relative to the vault folder".to_string()); - } - - let mut clean = PathBuf::new(); - for component in path.components() { - match component { - Component::Normal(part) => clean.push(part), - Component::CurDir => {} - Component::ParentDir => { - return Err("rel_path must not contain '..' segments".to_string()); - } - Component::RootDir | Component::Prefix(_) => { - return Err("rel_path must stay inside the vault folder".to_string()); - } - } - } - if clean.as_os_str().is_empty() { - return Err("rel_path must name a markdown file".to_string()); - } - let ext = clean - .extension() - .and_then(|value| value.to_str()) - .unwrap_or_default(); - if !matches!(ext.to_ascii_lowercase().as_str(), "md" | "markdown") { - return Err("rel_path must end with .md or .markdown".to_string()); - } - Ok(clean) -} - -fn ensure_existing_ancestors_stay_in_root(root: &Path, rel_path: &Path) -> Result<(), String> { - let Some(parent) = rel_path.parent() else { - return Ok(()); - }; - - let mut current = root.to_path_buf(); - for component in parent.components() { - let Component::Normal(part) = component else { - continue; - }; - let next = current.join(part); - match std::fs::symlink_metadata(&next) { - Ok(meta) if meta.file_type().is_symlink() => { - let resolved = std::fs::canonicalize(&next) - .map_err(|err| format!("failed to resolve vault note directory: {err}"))?; - if !resolved.starts_with(root) { - return Err( - "vault note directory resolves outside the vault folder".to_string() - ); - } - current = resolved; - } - Ok(meta) if meta.is_dir() => { - current = next; - } - Ok(_) => { - return Err("vault note parent path is not a directory".to_string()); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => { - break; - } - Err(err) => { - return Err(format!("failed to inspect vault note directory: {err}")); - } - } - } - - Ok(()) -} - -pub async fn vault_remove( - config: &Config, - id: &str, - purge_memory: bool, -) -> Result, String> { - let id = id.trim(); - if id.is_empty() { - return Err("vault_id must not be empty".to_string()); - } - let vault = store::get_vault(config, id).map_err(|e| e.to_string())?; - let removed = store::remove_vault(config, id).map_err(|e| e.to_string())?; - log::debug!("[vault] remove: id={id} removed={removed} purge_memory={purge_memory}"); - - let mut purged = false; - let mut memory_tree_chunks_deleted: usize = 0; - if removed && purge_memory { - if let Some(v) = vault { - // Memory-tree cleanup is the canonical path post-#2705: vault - // sync writes to `mem_tree_chunks` / `mem_tree_ingested_sources` - // keyed by `vault:{id}:{rel_path}`. A prefix delete with - // `vault:{id}:` catches every per-file row for this vault. The - // companion `clear_namespace` call below still drains any - // pre-#2705 ledger rows that landed in the legacy - // `memory_docs` table during the migration window. - let cfg_for_blocking = config.clone(); - let prefix = format!("vault:{}:", v.id); - let tree_result = tokio::task::spawn_blocking(move || { - delete_chunks_by_source_prefix(&cfg_for_blocking, SourceKind::Document, &prefix) - }) - .await; - match tree_result { - Ok(Ok(removed_chunks)) => { - memory_tree_chunks_deleted = removed_chunks; - log::debug!( - "[vault] remove: id={id} memory_tree_chunks_deleted={removed_chunks}" - ); - } - Ok(Err(err)) => { - log::warn!("[vault] remove: id={id} memory_tree_purge_failed err={err}"); - return Ok(RpcOutcome::single_log( - serde_json::json!({ - "vault_id": id, - "removed": removed, - "purged": false, - "purge_error": format!("memory_tree purge failed: {err}"), - }), - format!("vault removed with purge error: {id}"), - )); - } - Err(join_err) => { - log::warn!( - "[vault] remove: id={id} memory_tree_purge_join_failed err={join_err}" - ); - return Ok(RpcOutcome::single_log( - serde_json::json!({ - "vault_id": id, - "removed": removed, - "purged": false, - "purge_error": format!("memory_tree purge join error: {join_err}"), - }), - format!("vault removed with purge error: {id}"), - )); - } - } - - // Best-effort legacy UnifiedMemory purge for pre-#2705 ledger - // rows whose chunks still live in `memory_docs`. Failure here - // doesn't undo the canonical memory_tree cleanup above. - if let Err(err) = clear_namespace(ClearNamespaceParams { - namespace: v.namespace.clone(), - }) - .await - { - log::debug!( - "[vault] remove: id={id} legacy_clear_namespace_failed (best-effort) err={err}" - ); - } - purged = true; - } - } - - Ok(RpcOutcome::single_log( - serde_json::json!({ - "vault_id": id, - "removed": removed, - "purged": purged, - "memory_tree_chunks_deleted": memory_tree_chunks_deleted, - }), - format!("vault removed: {id}"), - )) -} - -/// Trigger a vault sync as a background task and return immediately. -/// -/// The caller should poll `vault_sync_status` to track progress and retrieve -/// the final outcome. Returns an error if a sync is already running for this -/// vault so the caller can surface a user-friendly message instead of silently -/// queuing a duplicate. -pub async fn vault_sync( - config: &Config, - id: &str, -) -> Result, String> { - let id = id.trim(); - if id.is_empty() { - return Err("vault_id must not be empty".to_string()); - } - let vault = store::get_vault(config, id) - .map_err(|e| e.to_string())? - .ok_or_else(|| format!("vault not found: {id}"))?; - - // Register in the state map; returns Err if already running. - let started_at_ms = Utc::now().timestamp_millis(); - state::start(id, started_at_ms).map_err(|e| format!("sync already in progress: {e}"))?; - - log::debug!( - "[vault] sync: background task spawned id={id} root={:?}", - vault.root_path, - ); - - // Clone what the background task needs — Config is Clone (derives it). - let config_clone = config.clone(); - let vault_id = id.to_string(); - - tokio::spawn(async move { - log::debug!("[vault] sync: background task running id={vault_id}"); - - // Wrap the work in catch_unwind so a panic inside sync_vault cannot leave - // the vault state permanently stuck in `Running`. Without this guard a - // panic would unwind the task, the state map entry would never be updated, - // and every subsequent sync attempt would be rejected with "already in progress" - // until the app is restarted. - let result = - std::panic::AssertUnwindSafe(async { sync::sync_vault(&config_clone, &vault).await }) - .catch_unwind() - .await; - - match result { - Ok(report) => { - let success = report.failed == 0; - let finished_at_ms = Utc::now().timestamp_millis(); - - // Write final counters back into the state map. - state::update_progress(&vault_id, |s| { - s.status = if success { - VaultSyncStatus::Completed - } else { - VaultSyncStatus::Failed - }; - s.finished_at_ms = Some(finished_at_ms); - s.ingested = report.ingested; - s.unchanged = report.unchanged; - s.removed = report.removed; - s.failed = report.failed; - s.skipped_unsupported = report.skipped_unsupported; - s.scanned = report.scanned; - s.duration_ms = report.duration_ms; - s.errors = report.errors.clone(); - }); - - log::debug!( - "[vault] sync: background task done id={vault_id} ingested={} failed={} duration_ms={}", - report.ingested, - report.failed, - report.duration_ms, - ); - } - Err(_) => { - log::error!( - "[vault] sync: background task panicked id={vault_id} — marking state as Failed" - ); - state::update_progress(&vault_id, |s| { - s.status = VaultSyncStatus::Failed; - s.errors = vec!["sync task panicked unexpectedly".to_string()]; - }); - } - } - }); - - Ok(RpcOutcome::single_log( - serde_json::json!({ "status": "started", "vault_id": id }), - format!("vault sync started in background: {id}"), - )) -} - -/// Return the current sync progress for a vault. -/// -/// Returns an `Idle` state if no sync has ever run for this vault. -pub async fn vault_sync_status(id: &str) -> Result, String> { - let id = id.trim(); - if id.is_empty() { - return Err("vault_id must not be empty".to_string()); - } - let st = state::get(id).unwrap_or_else(|| VaultSyncState { - vault_id: id.to_string(), - status: VaultSyncStatus::Idle, - scanned: 0, - ingested: 0, - unchanged: 0, - removed: 0, - failed: 0, - skipped_unsupported: 0, - total: 0, - started_at_ms: 0, - finished_at_ms: None, - duration_ms: 0, - errors: vec![], - }); - log::debug!( - "[vault] sync_status: id={id} status={:?} ingested={} total={}", - st.status, - st.ingested, - st.total, - ); - Ok(RpcOutcome::single_log(st, "vault sync status")) -} diff --git a/src/openhuman/vault/schemas.rs b/src/openhuman/vault/schemas.rs deleted file mode 100644 index 4fec3ac5a..000000000 --- a/src/openhuman/vault/schemas.rs +++ /dev/null @@ -1,395 +0,0 @@ -use serde::de::DeserializeOwned; -use serde_json::{Map, Value}; - -use crate::core::all::{ControllerFuture, RegisteredController}; -use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; -use crate::openhuman::config::rpc as config_rpc; -use crate::rpc::RpcOutcome; - -fn vault_id_input(comment: &'static str) -> FieldSchema { - FieldSchema { - name: "vault_id", - ty: TypeSchema::String, - comment, - required: true, - } -} - -pub fn all_controller_schemas() -> Vec { - vec![ - schemas("create"), - schemas("list"), - schemas("get"), - schemas("files"), - schemas("write_markdown"), - schemas("remove"), - schemas("sync"), - schemas("sync_status"), - ] -} - -pub fn all_registered_controllers() -> Vec { - vec![ - RegisteredController { - schema: schemas("create"), - handler: handle_create, - }, - RegisteredController { - schema: schemas("list"), - handler: handle_list, - }, - RegisteredController { - schema: schemas("get"), - handler: handle_get, - }, - RegisteredController { - schema: schemas("files"), - handler: handle_files, - }, - RegisteredController { - schema: schemas("write_markdown"), - handler: handle_write_markdown, - }, - RegisteredController { - schema: schemas("remove"), - handler: handle_remove, - }, - RegisteredController { - schema: schemas("sync"), - handler: handle_sync, - }, - RegisteredController { - schema: schemas("sync_status"), - handler: handle_sync_status, - }, - ] -} - -pub fn schemas(function: &str) -> ControllerSchema { - match function { - "create" => ControllerSchema { - namespace: "vault", - function: "create", - description: "Register a new local folder as a knowledge vault.", - inputs: vec![ - FieldSchema { - name: "name", - ty: TypeSchema::String, - comment: "Display name for the vault.", - required: true, - }, - FieldSchema { - name: "root_path", - ty: TypeSchema::String, - comment: "Absolute path to the folder on disk.", - required: true, - }, - FieldSchema { - name: "include_globs", - ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new( - TypeSchema::String, - )))), - comment: "Optional include patterns (substring match).", - required: false, - }, - FieldSchema { - name: "exclude_globs", - ty: TypeSchema::Option(Box::new(TypeSchema::Array(Box::new( - TypeSchema::String, - )))), - comment: "Optional exclude patterns (substring match, case-insensitive).", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "vault", - ty: TypeSchema::Ref("Vault"), - comment: "The newly created vault.", - required: true, - }], - }, - "list" => ControllerSchema { - namespace: "vault", - function: "list", - description: "List all registered vaults.", - inputs: vec![], - outputs: vec![FieldSchema { - name: "vaults", - ty: TypeSchema::Array(Box::new(TypeSchema::Ref("Vault"))), - comment: "All registered vaults.", - required: true, - }], - }, - "get" => ControllerSchema { - namespace: "vault", - function: "get", - description: "Fetch one vault by id.", - inputs: vec![vault_id_input("Identifier of the vault to fetch.")], - outputs: vec![FieldSchema { - name: "vault", - ty: TypeSchema::Ref("Vault"), - comment: "The requested vault.", - required: true, - }], - }, - "files" => ControllerSchema { - namespace: "vault", - function: "files", - description: "List per-file ledger entries for a vault.", - inputs: vec![vault_id_input("Identifier of the vault.")], - outputs: vec![FieldSchema { - name: "files", - ty: TypeSchema::Array(Box::new(TypeSchema::Ref("VaultFile"))), - comment: "Per-file ledger rows.", - required: true, - }], - }, - "write_markdown" => ControllerSchema { - namespace: "vault", - function: "write_markdown", - description: "Write an explicitly approved markdown/wiki file inside a writable vault.", - inputs: vec![ - vault_id_input("Identifier of the vault to write into."), - FieldSchema { - name: "rel_path", - ty: TypeSchema::String, - comment: "Relative .md/.markdown path inside the vault.", - required: true, - }, - FieldSchema { - name: "content", - ty: TypeSchema::String, - comment: "Markdown content to write.", - required: true, - }, - FieldSchema { - name: "overwrite", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "When true, update an existing markdown file.", - required: false, - }, - FieldSchema { - name: "approved", - ty: TypeSchema::Bool, - comment: "Must be true after explicit user approval for arbitrary vault writes.", - required: true, - }, - ], - outputs: vec![FieldSchema { - name: "report", - ty: TypeSchema::Ref("VaultWriteMarkdownReport"), - comment: "Write result with relative path and byte count.", - required: true, - }], - }, - "remove" => ControllerSchema { - namespace: "vault", - function: "remove", - description: "Remove a vault. Optionally purge its memory namespace.", - inputs: vec![ - vault_id_input("Identifier of the vault to remove."), - FieldSchema { - name: "purge_memory", - ty: TypeSchema::Option(Box::new(TypeSchema::Bool)), - comment: "When true, also clear the vault's memory namespace.", - required: false, - }, - ], - outputs: vec![FieldSchema { - name: "result", - ty: TypeSchema::Object { - fields: vec![ - FieldSchema { - name: "vault_id", - ty: TypeSchema::String, - comment: "Identifier requested for removal.", - required: true, - }, - FieldSchema { - name: "removed", - ty: TypeSchema::Bool, - comment: "True when the vault row was deleted.", - required: true, - }, - FieldSchema { - name: "purged", - ty: TypeSchema::Bool, - comment: "True when the memory namespace was also cleared.", - required: true, - }, - FieldSchema { - name: "purge_error", - ty: TypeSchema::Option(Box::new(TypeSchema::String)), - comment: "Error detail when purge was requested but failed.", - required: false, - }, - ], - }, - comment: "Removal result payload.", - required: true, - }], - }, - "sync" => ControllerSchema { - namespace: "vault", - function: "sync", - description: "Start a background sync of a vault's root folder. Returns immediately. Poll `vault.sync_status` for progress.", - inputs: vec![vault_id_input("Identifier of the vault to sync.")], - outputs: vec![ - FieldSchema { - name: "status", - ty: TypeSchema::String, - comment: "Always `\"started\"` on success.", - required: true, - }, - FieldSchema { - name: "vault_id", - ty: TypeSchema::String, - comment: "The vault that started syncing.", - required: true, - }, - ], - }, - "sync_status" => ControllerSchema { - namespace: "vault", - function: "sync_status", - description: "Return the current sync progress and outcome for a vault.", - inputs: vec![vault_id_input("Identifier of the vault to query.")], - outputs: vec![FieldSchema { - name: "state", - ty: TypeSchema::Ref("VaultSyncState"), - comment: "Current sync state (Idle / Running / Completed / Failed).", - required: true, - }], - }, - _other => ControllerSchema { - namespace: "vault", - function: "unknown", - description: "Unknown vault controller function.", - inputs: vec![FieldSchema { - name: "function", - ty: TypeSchema::String, - comment: "Unknown function requested for schema lookup.", - required: true, - }], - outputs: vec![FieldSchema { - name: "error", - ty: TypeSchema::String, - comment: "Lookup error details.", - required: true, - }], - }, - } -} - -fn handle_create(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let name = read_required::(¶ms, "name")?; - let root_path = read_required::(¶ms, "root_path")?; - let include_globs = - read_optional::>(¶ms, "include_globs")?.unwrap_or_default(); - let exclude_globs = - read_optional::>(¶ms, "exclude_globs")?.unwrap_or_default(); - to_json( - crate::openhuman::vault::ops::vault_create( - &config, - &name, - &root_path, - include_globs, - exclude_globs, - ) - .await?, - ) - }) -} - -fn handle_list(_params: Map) -> ControllerFuture { - Box::pin(async { - let config = config_rpc::load_config_with_timeout().await?; - to_json(crate::openhuman::vault::ops::vault_list(&config).await?) - }) -} - -fn handle_get(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let vault_id = read_required::(¶ms, "vault_id")?; - to_json(crate::openhuman::vault::ops::vault_get(&config, vault_id.trim()).await?) - }) -} - -fn handle_files(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let vault_id = read_required::(¶ms, "vault_id")?; - to_json(crate::openhuman::vault::ops::vault_files(&config, vault_id.trim()).await?) - }) -} - -fn handle_write_markdown(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let vault_id = read_required::(¶ms, "vault_id")?; - let rel_path = read_required::(¶ms, "rel_path")?; - let content = read_required::(¶ms, "content")?; - let overwrite = read_optional::(¶ms, "overwrite")?.unwrap_or(false); - let approved = read_required::(¶ms, "approved")?; - to_json( - crate::openhuman::vault::ops::vault_write_markdown( - &config, &vault_id, &rel_path, &content, overwrite, approved, - ) - .await?, - ) - }) -} - -fn handle_remove(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let vault_id = read_required::(¶ms, "vault_id")?; - let purge_memory = read_optional::(¶ms, "purge_memory")?.unwrap_or(false); - to_json( - crate::openhuman::vault::ops::vault_remove(&config, vault_id.trim(), purge_memory) - .await?, - ) - }) -} - -fn handle_sync(params: Map) -> ControllerFuture { - Box::pin(async move { - let config = config_rpc::load_config_with_timeout().await?; - let vault_id = read_required::(¶ms, "vault_id")?; - to_json(crate::openhuman::vault::ops::vault_sync(&config, vault_id.trim()).await?) - }) -} - -fn handle_sync_status(params: Map) -> ControllerFuture { - Box::pin(async move { - let vault_id = read_required::(¶ms, "vault_id")?; - to_json(crate::openhuman::vault::ops::vault_sync_status(vault_id.trim()).await?) - }) -} - -fn read_required(params: &Map, key: &str) -> Result { - let value = params - .get(key) - .cloned() - .ok_or_else(|| format!("missing required param '{key}'"))?; - serde_json::from_value(value).map_err(|e| format!("invalid '{key}': {e}")) -} - -fn read_optional( - params: &Map, - key: &str, -) -> Result, String> { - match params.get(key) { - None | Some(Value::Null) => Ok(None), - Some(value) => serde_json::from_value(value.clone()) - .map(Some) - .map_err(|e| format!("invalid '{key}': {e}")), - } -} - -fn to_json(outcome: RpcOutcome) -> Result { - outcome.into_cli_compatible_json() -} diff --git a/src/openhuman/vault/state.rs b/src/openhuman/vault/state.rs deleted file mode 100644 index cbec48340..000000000 --- a/src/openhuman/vault/state.rs +++ /dev/null @@ -1,80 +0,0 @@ -//! Global in-memory registry for vault sync progress state. -//! -//! State is keyed by `vault_id` and lives for the lifetime of the process. -//! A completed/failed entry is retained until the next sync for the same -//! vault overwrites it, so the frontend can always read the last outcome. -//! -//! Uses `once_cell::sync::Lazy` + `parking_lot::RwLock` — no heap allocation -//! at import time, no `std::sync::Mutex` poisoning risk. - -use std::collections::HashMap; - -use once_cell::sync::Lazy; -use parking_lot::RwLock; - -use super::types::{VaultSyncState, VaultSyncStatus}; - -static SYNC_STATE: Lazy>> = - Lazy::new(|| RwLock::new(HashMap::new())); - -/// Return the current sync state for a vault, or `None` if no sync has run. -pub fn get(vault_id: &str) -> Option { - SYNC_STATE.read().get(vault_id).cloned() -} - -/// Replace the sync state for a vault (creates or overwrites the entry). -pub fn set(state: VaultSyncState) { - SYNC_STATE.write().insert(state.vault_id.clone(), state); -} - -/// Transition a vault to `Running`. -/// -/// Returns `Err` if a sync for this vault is already in progress so the -/// caller can reject duplicate requests. -pub fn start(vault_id: &str, started_at_ms: i64) -> Result<(), String> { - let mut map = SYNC_STATE.write(); - if let Some(s) = map.get(vault_id) { - if s.status == VaultSyncStatus::Running { - log::debug!( - "[vault][state] start rejected: vault_id={vault_id} already running since={}", - s.started_at_ms - ); - return Err(format!("vault {vault_id} is already syncing")); - } - } - log::debug!("[vault][state] start: vault_id={vault_id} started_at_ms={started_at_ms}"); - map.insert( - vault_id.to_string(), - VaultSyncState { - vault_id: vault_id.to_string(), - status: VaultSyncStatus::Running, - scanned: 0, - ingested: 0, - unchanged: 0, - removed: 0, - failed: 0, - skipped_unsupported: 0, - total: 0, - started_at_ms, - finished_at_ms: None, - duration_ms: 0, - errors: vec![], - }, - ); - Ok(()) -} - -/// Apply `f` to the current `VaultSyncState` for `vault_id` in-place. -/// -/// No-ops silently if no entry exists (e.g. if the registry was cleared or -/// the vault_id is wrong — neither should happen in normal operation). -pub fn update_progress(vault_id: &str, f: impl FnOnce(&mut VaultSyncState)) { - let mut map = SYNC_STATE.write(); - if let Some(s) = map.get_mut(vault_id) { - f(s); - } else { - log::debug!( - "[vault][state] update_progress no-op: vault_id={vault_id} not found in state map" - ); - } -} diff --git a/src/openhuman/vault/store.rs b/src/openhuman/vault/store.rs deleted file mode 100644 index 92bfc0816..000000000 --- a/src/openhuman/vault/store.rs +++ /dev/null @@ -1,404 +0,0 @@ -use std::collections::HashSet; -use std::path::PathBuf; -use std::sync::{Mutex, OnceLock}; - -use anyhow::{anyhow, Context, Result}; -use chrono::{DateTime, Utc}; -use rusqlite::{params, Connection, OptionalExtension}; - -use crate::openhuman::config::Config; - -use super::types::{Vault, VaultFile, VaultFileStatus, VaultWriteState}; - -static MIGRATED_VAULT_DBS: OnceLock>> = OnceLock::new(); - -pub(crate) const VAULT_WRITE_REASON_EMPTY_PATH: &str = "empty_path"; -pub(crate) const VAULT_WRITE_REASON_UNAVAILABLE: &str = "unavailable"; -pub(crate) const VAULT_WRITE_REASON_NOT_DIRECTORY: &str = "not_directory"; -pub(crate) const VAULT_WRITE_REASON_READ_ONLY: &str = "read_only"; -pub(crate) const VAULT_WRITE_REASON_WRITABLE: &str = "writable"; - -pub(crate) fn with_connection( - config: &Config, - f: impl FnOnce(&Connection) -> Result, -) -> Result { - let db_path = config.workspace_dir.join("vault").join("vault.db"); - if let Some(parent) = db_path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("Failed to create vault directory: {}", parent.display()))?; - } - - let conn = Connection::open(&db_path) - .with_context(|| format!("Failed to open vault DB: {}", db_path.display()))?; - - conn.execute_batch( - "PRAGMA foreign_keys = ON; - CREATE TABLE IF NOT EXISTS vaults ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - root_path TEXT NOT NULL, - host_os TEXT, - namespace TEXT NOT NULL UNIQUE, - include_globs TEXT NOT NULL DEFAULT '[]', - exclude_globs TEXT NOT NULL DEFAULT '[]', - created_at TEXT NOT NULL, - last_synced_at TEXT - ); - CREATE TABLE IF NOT EXISTS vault_files ( - vault_id TEXT NOT NULL, - rel_path TEXT NOT NULL, - document_id TEXT NOT NULL, - content_hash TEXT NOT NULL, - mtime_ms INTEGER NOT NULL, - bytes INTEGER NOT NULL, - ingested_at TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'ok', - PRIMARY KEY (vault_id, rel_path), - FOREIGN KEY (vault_id) REFERENCES vaults(id) ON DELETE CASCADE - ); - CREATE INDEX IF NOT EXISTS idx_vault_files_vault ON vault_files(vault_id);", - ) - .context("Failed to initialize vault schema")?; - - let migrated = MIGRATED_VAULT_DBS.get_or_init(|| Mutex::new(HashSet::new())); - let mut migrated_paths = migrated - .lock() - .map_err(|_| anyhow!("Failed to lock vault migration cache"))?; - if !migrated_paths.contains(&db_path) { - ensure_host_os_column(&conn).context("Failed to migrate vault schema")?; - migrated_paths.insert(db_path.clone()); - } - - f(&conn) -} - -pub fn insert_vault(config: &Config, vault: &Vault) -> Result<()> { - insert_vault_inner(config, vault, true) -} - -#[cfg(test)] -pub(crate) fn insert_vault_preserving_host_for_tests(config: &Config, vault: &Vault) -> Result<()> { - insert_vault_inner(config, vault, false) -} - -fn insert_vault_inner(config: &Config, vault: &Vault, stamp_current_host: bool) -> Result<()> { - with_connection(config, |conn| { - let host_os = normalized_host_os(vault.host_os.as_deref()).or_else(|| { - if stamp_current_host { - Some(current_host_os()) - } else { - None - } - }); - conn.execute( - "INSERT INTO vaults (id, name, root_path, host_os, namespace, include_globs, exclude_globs, created_at, last_synced_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - vault.id, - vault.name, - vault.root_path, - host_os, - vault.namespace, - serde_json::to_string(&vault.include_globs)?, - serde_json::to_string(&vault.exclude_globs)?, - vault.created_at.to_rfc3339(), - vault.last_synced_at.map(|t| t.to_rfc3339()), - ], - ) - .context("Failed to insert vault")?; - Ok(()) - }) -} - -pub fn list_vaults(config: &Config) -> Result> { - with_connection(config, |conn| { - let mut stmt = conn.prepare( - "SELECT v.id, v.name, v.root_path, v.host_os, v.namespace, v.include_globs, v.exclude_globs, - v.created_at, v.last_synced_at, - (SELECT COUNT(*) FROM vault_files vf WHERE vf.vault_id = v.id AND vf.status = 'ok') - FROM vaults v - ORDER BY v.created_at DESC", - )?; - let rows = stmt.query_map([], row_to_vault)?; - let mut out = Vec::new(); - for row in rows { - let vault = row?; - if vault_belongs_to_current_host(&vault) { - out.push(vault); - } else { - log::debug!( - "[vault] hiding incompatible vault id={} host_os={:?}", - vault.id, - vault.host_os - ); - } - } - Ok(out) - }) -} - -pub fn get_vault(config: &Config, id: &str) -> Result> { - with_connection(config, |conn| { - let vault = conn - .query_row( - "SELECT v.id, v.name, v.root_path, v.host_os, v.namespace, v.include_globs, v.exclude_globs, - v.created_at, v.last_synced_at, - (SELECT COUNT(*) FROM vault_files vf WHERE vf.vault_id = v.id AND vf.status = 'ok') - FROM vaults v WHERE v.id = ?1", - params![id], - row_to_vault, - ) - .optional() - .context("Failed to read vault")?; - Ok(vault.filter(vault_belongs_to_current_host)) - }) -} - -pub fn remove_vault(config: &Config, id: &str) -> Result { - with_connection(config, |conn| { - let n = conn - .execute("DELETE FROM vaults WHERE id = ?1", params![id]) - .context("Failed to delete vault")?; - Ok(n > 0) - }) -} - -pub fn touch_last_synced(config: &Config, id: &str, when: DateTime) -> Result<()> { - with_connection(config, |conn| { - conn.execute( - "UPDATE vaults SET last_synced_at = ?2 WHERE id = ?1", - params![id, when.to_rfc3339()], - )?; - Ok(()) - }) -} - -pub fn list_files(config: &Config, vault_id: &str) -> Result> { - with_connection(config, |conn| { - let mut stmt = conn.prepare( - "SELECT vault_id, rel_path, document_id, content_hash, mtime_ms, bytes, ingested_at, status - FROM vault_files WHERE vault_id = ?1 ORDER BY rel_path", - )?; - let rows = stmt.query_map(params![vault_id], row_to_file)?; - let mut out = Vec::new(); - for row in rows { - out.push(row?); - } - Ok(out) - }) -} - -pub fn upsert_file(config: &Config, file: &VaultFile) -> Result<()> { - with_connection(config, |conn| { - conn.execute( - "INSERT INTO vault_files (vault_id, rel_path, document_id, content_hash, mtime_ms, bytes, ingested_at, status) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) - ON CONFLICT(vault_id, rel_path) DO UPDATE SET - document_id = excluded.document_id, - content_hash = excluded.content_hash, - mtime_ms = excluded.mtime_ms, - bytes = excluded.bytes, - ingested_at = excluded.ingested_at, - status = excluded.status", - params![ - file.vault_id, - file.rel_path, - file.document_id, - file.content_hash, - file.mtime_ms, - file.bytes as i64, - file.ingested_at.to_rfc3339(), - file.status.as_str(), - ], - )?; - Ok(()) - }) -} - -pub fn delete_file(config: &Config, vault_id: &str, rel_path: &str) -> Result<()> { - with_connection(config, |conn| { - conn.execute( - "DELETE FROM vault_files WHERE vault_id = ?1 AND rel_path = ?2", - params![vault_id, rel_path], - )?; - Ok(()) - }) -} - -fn row_to_vault(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let include_raw: String = row.get(5)?; - let exclude_raw: String = row.get(6)?; - let created_raw: String = row.get(7)?; - let last_raw: Option = row.get(8)?; - let file_count: i64 = row.get(9)?; - let root_path: String = row.get(2)?; - let (write_state, write_state_reason) = vault_write_state_for_root_path(&root_path); - Ok(Vault { - id: row.get(0)?, - name: row.get(1)?, - root_path, - host_os: row.get(3)?, - namespace: row.get(4)?, - include_globs: serde_json::from_str(&include_raw).unwrap_or_default(), - exclude_globs: serde_json::from_str(&exclude_raw).unwrap_or_default(), - created_at: parse_dt(&created_raw), - last_synced_at: last_raw.as_deref().map(parse_dt), - file_count: file_count.max(0) as u64, - write_state, - write_state_reason, - }) -} - -fn row_to_file(row: &rusqlite::Row<'_>) -> rusqlite::Result { - let ingested_raw: String = row.get(6)?; - let status_raw: String = row.get(7)?; - let bytes: i64 = row.get(5)?; - Ok(VaultFile { - vault_id: row.get(0)?, - rel_path: row.get(1)?, - document_id: row.get(2)?, - content_hash: row.get(3)?, - mtime_ms: row.get(4)?, - bytes: bytes.max(0) as u64, - ingested_at: parse_dt(&ingested_raw), - status: VaultFileStatus::parse(&status_raw), - }) -} - -fn parse_dt(raw: &str) -> DateTime { - DateTime::parse_from_rfc3339(raw) - .map(|t| t.with_timezone(&Utc)) - .unwrap_or_else(|_| Utc::now()) -} - -fn ensure_host_os_column(conn: &Connection) -> Result<()> { - let mut stmt = conn.prepare("PRAGMA table_info(vaults)")?; - let columns = stmt.query_map([], |row| row.get::<_, String>(1))?; - let mut has_host_os = false; - for column in columns { - if column?.eq_ignore_ascii_case("host_os") { - has_host_os = true; - break; - } - } - - if !has_host_os { - conn.execute("ALTER TABLE vaults ADD COLUMN host_os TEXT", [])?; - } - Ok(()) -} - -pub(crate) fn current_host_os() -> &'static str { - std::env::consts::OS -} - -pub(crate) fn vault_write_state_for_root_path( - root_path: &str, -) -> (VaultWriteState, Option) { - let trimmed = root_path.trim(); - if trimmed.is_empty() { - return ( - VaultWriteState::Unavailable, - Some(VAULT_WRITE_REASON_EMPTY_PATH.to_string()), - ); - } - - let path = std::path::Path::new(trimmed); - let metadata = match std::fs::metadata(path) { - Ok(metadata) => metadata, - Err(_) => { - return ( - VaultWriteState::Unavailable, - Some(VAULT_WRITE_REASON_UNAVAILABLE.to_string()), - ) - } - }; - - if !metadata.is_dir() { - return ( - VaultWriteState::Unavailable, - Some(VAULT_WRITE_REASON_NOT_DIRECTORY.to_string()), - ); - } - - if metadata.permissions().readonly() { - return ( - VaultWriteState::ReadOnly, - Some(VAULT_WRITE_REASON_READ_ONLY.to_string()), - ); - } - - ( - VaultWriteState::Writable, - Some(VAULT_WRITE_REASON_WRITABLE.to_string()), - ) -} - -pub(crate) fn vault_write_state_reason_message(reason_code: Option<&str>) -> &'static str { - match reason_code { - Some(VAULT_WRITE_REASON_EMPTY_PATH) => "Vault folder path is empty.", - Some(VAULT_WRITE_REASON_UNAVAILABLE) => "Vault folder is not available on this device.", - Some(VAULT_WRITE_REASON_NOT_DIRECTORY) => "Vault path is not a directory.", - Some(VAULT_WRITE_REASON_READ_ONLY) => "Vault folder is read-only on this device.", - Some(VAULT_WRITE_REASON_WRITABLE) => { - "Approved markdown/wiki writes can be saved in this vault." - } - _ => "Vault write state is unknown.", - } -} - -pub(crate) fn path_looks_compatible_with_host_os(raw_path: &str, host_os: &str) -> bool { - let path = raw_path.trim(); - if path.is_empty() { - return false; - } - - if is_windows_host_os(host_os) { - return looks_like_windows_absolute_path(path); - } - - looks_like_unix_absolute_path(path) -} - -fn vault_belongs_to_current_host(vault: &Vault) -> bool { - let current = current_host_os(); - let Some(host_os) = normalized_host_os(vault.host_os.as_deref()) else { - return path_looks_compatible_with_host_os(&vault.root_path, current); - }; - - host_os.eq_ignore_ascii_case(current) - && path_looks_compatible_with_host_os(&vault.root_path, current) -} - -fn normalized_host_os(raw: Option<&str>) -> Option<&str> { - raw.map(str::trim).filter(|host_os| !host_os.is_empty()) -} - -fn is_windows_host_os(host_os: &str) -> bool { - host_os.eq_ignore_ascii_case("windows") || host_os.eq_ignore_ascii_case("win32") -} - -fn looks_like_windows_absolute_path(path: &str) -> bool { - looks_like_windows_drive_path(path) || looks_like_windows_unc_path(path) -} - -fn looks_like_windows_drive_path(path: &str) -> bool { - let bytes = path.as_bytes(); - bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'\\' | b'/') -} - -/// Only backslash-style UNC (`\\server\share`). Forward-slash `//…` is -/// POSIX-legal and must not be classified as Windows. -fn looks_like_windows_unc_path(path: &str) -> bool { - let bytes = path.as_bytes(); - bytes.len() >= 3 && bytes[0] == b'\\' && bytes[1] == b'\\' && !matches!(bytes[2], b'\\' | b'/') -} - -fn looks_like_unix_absolute_path(path: &str) -> bool { - path.starts_with('/') - && !looks_like_windows_drive_path(path) - && !looks_like_windows_unc_path(path) -} diff --git a/src/openhuman/vault/sync.rs b/src/openhuman/vault/sync.rs deleted file mode 100644 index f91c366f4..000000000 --- a/src/openhuman/vault/sync.rs +++ /dev/null @@ -1,1050 +0,0 @@ -//! Walk a vault's root directory and ingest changed/new files into the -//! memory_tree backend. -//! -//! # Memory-tree pipeline (not the legacy `memory_docs` path) -//! -//! Each file is ingested via [`crate::openhuman::memory::ingest_pipeline::ingest_document`] -//! against a stable `source_id` of the form `vault:{vault_id}:{rel_path}`. -//! That populates `mem_tree_chunks` + `mem_tree_ingested_sources` — which is -//! what every modern retrieval surface (`memory.search`, `tree.read_chunk`, -//! `tree.browse`, the agent's recall path, summary trees) reads from. -//! -//! Prior to #2705 this path called `doc_ingest`, which routed through the -//! legacy `UnifiedMemory` backend and wrote to `memory_docs` instead. The -//! UI's "synced" message was technically correct (UnifiedMemory accepted the -//! writes) but invisible to retrieval. Migrating to the memory-tree pipeline -//! closes that silent-failure mode. -//! -//! ## Source-id semantics -//! -//! - `source_id = vault:{vault_id}:{rel_path}` is stable for a given file path -//! in a given vault. The pipeline's `already_ingested(SourceKind::Document, -//! source_id)` gate is content-blind, so for content updates the vault -//! layer must delete the prior chunks (via [`delete_chunks_by_source`]) -//! *before* re-ingesting, otherwise the new content is short-circuited. -//! - The vault's own per-file `content_hash` ledger entry is the authoritative -//! "did the bytes change?" check — when it matches we skip the pipeline -//! entirely (no delete, no re-ingest). -//! - When a previously-ledger'd file disappears from the walk, the same -//! `delete_chunks_by_source` cleans up the orphan rows so memory_tree -//! stays in sync with the on-disk vault. - -use std::collections::HashSet; -use std::path::{Path, PathBuf}; -use std::sync::Arc; - -use chrono::{TimeZone, Utc}; -use futures::StreamExt; -use sha2::{Digest, Sha256}; -use walkdir::WalkDir; - -use crate::openhuman::config::Config; -use crate::openhuman::memory::ingest_pipeline::{self, IngestResult}; -use crate::openhuman::memory::ops::{doc_delete, DeleteDocParams}; -use crate::openhuman::memory_store::chunks::store::delete_chunks_by_source; -use crate::openhuman::memory_store::chunks::types::SourceKind; -use crate::openhuman::memory_sync::canonicalize::document::DocumentInput; - -use super::state; -use super::store; -use super::types::{Vault, VaultFile, VaultFileStatus, VaultSyncReport}; - -/// Build the memory-tree source_id for one file in a vault. Stable across -/// re-syncs of the same `(vault, rel_path)`, so the pipeline's idempotency -/// gate works correctly and the vault ledger can map back to chunks for -/// cleanup. -fn vault_source_id(vault_id: &str, rel_path: &str) -> String { - format!("vault:{vault_id}:{rel_path}") -} - -/// Built-in exclude patterns we never traverse. Kept tiny and obvious. -const BUILTIN_EXCLUDE_DIRS: &[&str] = &[ - ".git", - ".hg", - ".svn", - "node_modules", - "target", - "dist", - "build", - ".next", - ".cache", - ".venv", - "__pycache__", - ".DS_Store", -]; - -/// Max single-file size we read into memory for ingestion (5 MiB). -const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; - -/// Number of files to ingest concurrently. -/// -/// Bounded to avoid overwhelming the embedding API while still parallelising -/// the dominant network cost. Matches the codebase's existing `buffer_unordered` -/// patterns (see `extract_tool.rs` and `cron/scheduler.rs`). -const SYNC_CONCURRENCY: usize = 4; - -/// File extensions we currently extract as plain UTF-8. -pub fn supported_extension(ext: &str) -> bool { - matches!( - ext.to_ascii_lowercase().as_str(), - "md" | "mdx" - | "txt" - | "rst" - | "json" - | "yaml" - | "yml" - | "toml" - | "csv" - | "html" - | "htm" - | "rs" - | "ts" - | "tsx" - | "js" - | "jsx" - | "py" - | "go" - | "java" - | "rb" - | "php" - | "sh" - | "bash" - | "zsh" - | "sql" - | "css" - | "scss" - | "swift" - | "kt" - | "c" - | "cc" - | "cpp" - | "h" - | "hpp" - | "log" - ) -} - -/// A file that survived discovery and needs content read + ingestion. -struct FileToProcess { - rel_path: String, - title: String, - path: PathBuf, - mtime_ms: i64, - bytes: u64, - ext: String, - /// Content hash from the previous successful sync, for secondary dedup. - prev_hash: Option, - /// Vault id for tags and state updates. - vault_id: String, -} - -/// Outcome of attempting to ingest one file. -enum IngestFileResult { - Ingested { - rel_path: String, - document_id: String, - hash: String, - mtime_ms: i64, - bytes: u64, - }, - /// Content was read but the hash matched the previous ingest — skip ledger write. - Unchanged { - rel_path: String, - }, - Failed { - rel_path: String, - error: String, - }, -} - -/// Read `file.path`, hash it, and route it through the memory-tree ingestion -/// pipeline. Skips the pipeline entirely when content is unchanged from the -/// previous successful sync. -/// -/// Runs inside `buffer_unordered` so multiple files are in flight at once. -async fn process_file(config: Arc, file: FileToProcess) -> IngestFileResult { - let content = match tokio::fs::read_to_string(&file.path).await { - Ok(c) => c, - Err(err) => { - return IngestFileResult::Failed { - rel_path: file.rel_path, - error: format!("read failed: {err}"), - }; - } - }; - let hash = sha256_hex(&content); - - // Secondary dedup: content didn't change even if mtime did (e.g. `touch`). - if file.prev_hash.as_deref() == Some(hash.as_str()) { - log::trace!( - "[vault] sync: hash-match skip path={} source_id={}", - file.rel_path, - vault_source_id(&file.vault_id, &file.rel_path), - ); - return IngestFileResult::Unchanged { - rel_path: file.rel_path, - }; - } - - let source_id = vault_source_id(&file.vault_id, &file.rel_path); - - // For content updates (prev_hash was Some but didn't match), the - // memory-tree pipeline's `already_ingested` gate would short-circuit - // the new content because the source_id is stable per file path. Drop - // the old chunks first so the re-ingest actually runs. - if file.prev_hash.is_some() { - let cfg_for_blocking = Arc::clone(&config); - let source_for_blocking = source_id.clone(); - let delete_result = tokio::task::spawn_blocking(move || { - delete_chunks_by_source( - &cfg_for_blocking, - SourceKind::Document, - &source_for_blocking, - ) - }) - .await; - match delete_result { - Ok(Ok(removed)) => { - log::debug!( - "[vault] sync: re-ingest cleanup path={} source_id={} removed_chunks={}", - file.rel_path, - source_id, - removed - ); - } - Ok(Err(err)) => { - // Failing the delete pre-empts a corrupt-state re-ingest — - // surface as Failed instead of silently leaving stale rows - // alongside new ones. - return IngestFileResult::Failed { - rel_path: file.rel_path, - error: format!("delete_chunks_by_source failed before re-ingest: {err}"), - }; - } - Err(join_err) => { - return IngestFileResult::Failed { - rel_path: file.rel_path, - error: format!("delete task join error: {join_err}"), - }; - } - } - } - - // Pipeline modified_at = file mtime, so chunk metadata reflects when - // the user last touched the file rather than when sync ran. - let modified_at = Utc - .timestamp_millis_opt(file.mtime_ms) - .single() - .unwrap_or_else(Utc::now); - - let doc = DocumentInput { - provider: "vault".to_string(), - title: file.title, - body: content, - modified_at, - source_ref: Some(file.rel_path.clone()), - }; - let tags = vec![ - format!("vault:{}", file.vault_id), - format!("ext:{}", file.ext), - ]; - - // `&config` deref-coerces the `Arc` to `&Config`; the pipeline - // owns no Config references beyond this call, so the ref-count survives - // the await without an explicit clone. - match ingest_pipeline::ingest_document(&config, &source_id, "vault", tags, doc).await { - Ok(IngestResult { - chunks_written, - already_ingested, - .. - }) => { - // The delete-first guard above prevents `already_ingested` on the - // normal content-update path. If we still see it here it means - // the vault ledger and `mem_tree_ingested_sources` are out of - // sync (ledger wiped while the memory_tree row survived, or vice - // versa) — the ledger gets a fresh row, but nothing new reaches - // retrieval. That's the exact false-success mode this PR set out - // to kill, so surface it loudly instead of swallowing it. - if already_ingested && chunks_written == 0 { - log::warn!( - "[vault] sync: ledger↔memory_tree desync detected — \ - `already_ingested=true` for source_id={source_id} \ - (path={}) but ledger had no matching row; no new chunks \ - reached retrieval. Manual `delete_chunks_by_source` + \ - resync may be required.", - file.rel_path, - ); - } else { - log::debug!( - "[vault] sync: ingested path={} source_id={} chunks_written={} already_ingested={}", - file.rel_path, - source_id, - chunks_written, - already_ingested, - ); - } - IngestFileResult::Ingested { - rel_path: file.rel_path, - document_id: source_id, - hash, - mtime_ms: file.mtime_ms, - bytes: file.bytes, - } - } - Err(err) => IngestFileResult::Failed { - rel_path: file.rel_path, - error: format!("ingest_document failed: {err}"), - }, - } -} - -/// Walk `vault.root_path`, ingest new/changed files into memory, delete docs -/// whose source files vanished, and record per-file state in the ledger. -pub async fn sync_vault(config: &Config, vault: &Vault) -> VaultSyncReport { - let started = Utc::now(); - let mut report = VaultSyncReport { - vault_id: vault.id.clone(), - ..Default::default() - }; - - let root = PathBuf::from(&vault.root_path); - if !root.is_dir() { - report - .errors - .push(format!("root_path is not a directory: {}", vault.root_path)); - report.duration_ms = (Utc::now() - started).num_milliseconds(); - return report; - } - - // Snapshot existing ledger so we can compute deletions at the end. - let existing = match store::list_files(config, &vault.id) { - Ok(rows) => rows, - Err(err) => { - report.errors.push(format!("ledger read failed: {err}")); - return report; - } - }; - let mut seen: HashSet = HashSet::new(); - let by_path: std::collections::HashMap = existing - .iter() - .map(|f| (f.rel_path.clone(), f.clone())) - .collect(); - - let user_includes: Vec = vault - .include_globs - .iter() - .map(|s| s.to_ascii_lowercase()) - .collect(); - let user_excludes: Vec = vault - .exclude_globs - .iter() - .map(|s| s.to_ascii_lowercase()) - .collect(); - - log::debug!( - "[vault] sync: entry id={} root={:?} ledger_rows={} includes={} excludes={}", - vault.id, - vault.root_path, - existing.len(), - user_includes.len(), - user_excludes.len(), - ); - - // Prune builtin-excluded directory subtrees at traversal time so we never - // descend into node_modules / target / .git etc. - let walker = WalkDir::new(&root) - .follow_links(false) - .into_iter() - .filter_entry(|e| { - if !e.file_type().is_dir() { - return true; - } - e.file_name() - .to_str() - .map(|name| !BUILTIN_EXCLUDE_DIRS.contains(&name)) - .unwrap_or(true) - }); - - // ── Phase 1: Discovery (sequential, no content reads) ─────────────────── - let mut candidates: Vec = Vec::new(); - - for entry in walker { - let entry = match entry { - Ok(e) => e, - Err(err) => { - log::debug!("[vault] sync: walk error err={err}"); - report.errors.push(format!("walk error: {err}")); - continue; - } - }; - - if !entry.file_type().is_file() { - continue; - } - - let path = entry.path(); - let rel_path = match path.strip_prefix(&root) { - Ok(p) => p.to_string_lossy().to_string(), - Err(_) => continue, - }; - let rel_path_lc = rel_path.to_ascii_lowercase(); - - // Defence-in-depth: filter_entry above prunes subtrees, but a future - // refactor that drops it shouldn't silently let excluded files through. - if path_is_inside_excluded_dir(path, &root) { - continue; - } - if !user_includes.is_empty() && !user_includes.iter().any(|pat| rel_path_lc.contains(pat)) { - continue; - } - if user_excludes.iter().any(|pat| rel_path_lc.contains(pat)) { - continue; - } - - report.scanned += 1; - - let ext = path - .extension() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - if !supported_extension(&ext) { - report.skipped_unsupported += 1; - seen.insert(rel_path.clone()); - continue; - } - - let metadata = match std::fs::metadata(path) { - Ok(m) => m, - Err(err) => { - report.failed += 1; - report - .errors - .push(format!("{rel_path}: stat failed: {err}")); - continue; - } - }; - if metadata.len() > MAX_FILE_BYTES { - report.skipped_unsupported += 1; - report.errors.push(format!( - "{rel_path}: skipped — {} bytes exceeds {MAX_FILE_BYTES}", - metadata.len() - )); - seen.insert(rel_path.clone()); - continue; - } - - let mtime_ms = metadata - .modified() - .ok() - .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) - .map(|d| d.as_millis() as i64) - .unwrap_or(0); - - seen.insert(rel_path.clone()); - - // Fast-path mtime dedup: if both mtime and previous hash matched we can - // skip content reads entirely. The concurrent phase does a secondary - // hash-based check for files whose mtime changed but content didn't. - if let Some(prev) = by_path.get(&rel_path) { - if prev.status == VaultFileStatus::Ok && prev.mtime_ms == mtime_ms { - report.unchanged += 1; - continue; - } - } - - let title = path - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(&rel_path) - .to_string(); - let prev = by_path.get(&rel_path); - - candidates.push(FileToProcess { - rel_path, - title, - path: path.to_path_buf(), - mtime_ms, - bytes: metadata.len(), - ext, - prev_hash: prev.map(|p| p.content_hash.clone()), - vault_id: vault.id.clone(), - }); - } - - log::debug!( - "[vault] sync: discovery done id={} scanned={} unchanged={} to_ingest={}", - vault.id, - report.scanned, - report.unchanged, - candidates.len(), - ); - - // Update shared state with total count so the frontend can show progress. - state::update_progress(&vault.id, |s| { - s.scanned = report.scanned; - s.unchanged = report.unchanged; - s.total = candidates.len() as u64; - }); - - // ── Phase 2: Concurrent ingestion ──────────────────────────────────────── - // - // Each task takes an `Arc` so we share one allocation across all - // candidates instead of deep-cloning the config per file. A 5k-file vault - // therefore pays one `Config::clone()` + N atomic ref-count bumps, vs the - // previous N full clones — measurably cheaper on cold backfills. - let config_for_workers = Arc::new(config.clone()); - let results: Vec = futures::stream::iter(candidates) - .map(move |file| process_file(Arc::clone(&config_for_workers), file)) - .buffer_unordered(SYNC_CONCURRENCY) - .collect() - .await; - - // ── Phase 3: Process results (sequential ledger writes) ────────────────── - for result in results { - match result { - IngestFileResult::Ingested { - rel_path, - document_id, - hash, - mtime_ms, - bytes, - } => { - let file = VaultFile { - vault_id: vault.id.clone(), - rel_path: rel_path.clone(), - document_id, - content_hash: hash, - mtime_ms, - bytes, - ingested_at: Utc::now(), - status: VaultFileStatus::Ok, - }; - if let Err(err) = store::upsert_file(config, &file) { - log::debug!("[vault] sync: ledger write failed path={rel_path} err={err}"); - report - .errors - .push(format!("{rel_path}: ledger write failed: {err}")); - } - log::trace!("[vault] sync: ingested path={rel_path}"); - report.ingested += 1; - state::update_progress(&vault.id, |s| s.ingested += 1); - } - IngestFileResult::Unchanged { rel_path } => { - // Hash matched even though mtime changed — still a no-op. - log::trace!("[vault] sync: hash-unchanged path={rel_path}"); - report.unchanged += 1; - } - IngestFileResult::Failed { rel_path, error } => { - log::debug!("[vault] sync: ingest failed path={rel_path} err={error}"); - report.failed += 1; - report - .errors - .push(format!("{rel_path}: ingest failed: {error}")); - state::update_progress(&vault.id, |s| s.failed += 1); - } - } - } - - // ── Phase 4: Deletions ──────────────────────────────────────────────────── - // - // Files that vanished from the vault since the last sync. We drop their - // memory-tree rows so retrieval never resurfaces deleted content. The - // `delete_chunks_by_source` helper handles `mem_tree_chunks`, - // `mem_tree_ingested_sources`, and the on-disk content sidecars in one - // transaction. - // - // Hoist the `Arc` for the blocking deletes once instead of - // re-cloning the full `Config` per vanished file (mirrors the Phase 2 - // worker-pool optimisation). - let config_for_deletes = Arc::new(config.clone()); - for (path, prev) in by_path.iter() { - if seen.contains(path) { - continue; - } - - // Two ledger generations to handle here: - // - // * Post-#2705 rows: `document_id` is already `vault:{id}:{rel_path}`, - // so the memory_tree `delete_chunks_by_source` call below cleans it - // up exactly. - // * Pre-#2705 rows: `document_id` is a legacy UnifiedMemory id - // (`{ts}_{hex}`-shaped) whose chunks live in `memory_docs`, *not* - // in `mem_tree_*`. Recomputing the memory_tree source_id and - // running `delete_chunks_by_source` deletes nothing on those rows; - // without a parallel `doc_delete` the legacy data leaks until - // UnifiedMemory removal lands (#2585 follow-up). We do both during - // the migration window so vanished files actually go away. - let stored_id = prev.document_id.clone(); - let is_legacy_ledger_row = !stored_id.starts_with("vault:"); - let source_id = if is_legacy_ledger_row { - log::debug!( - "[vault] sync: legacy ledger doc_id detected during delete \ - path={path} stored_id={stored_id} — falling back to recomputed \ - source_id + parallel UnifiedMemory doc_delete" - ); - vault_source_id(&vault.id, path) - } else { - stored_id.clone() - }; - - // Legacy UnifiedMemory cleanup. Best-effort: a 404 / missing-doc - // error on the legacy path shouldn't block the memory_tree delete - // below, which is the canonical store going forward. - if is_legacy_ledger_row { - if let Err(err) = doc_delete(DeleteDocParams { - namespace: vault.namespace.clone(), - document_id: stored_id.clone(), - }) - .await - { - log::debug!( - "[vault] sync: legacy doc_delete failed (likely already absent) \ - path={path} document_id={stored_id} err={err} — continuing with memory_tree cleanup" - ); - } - } - - let cfg_for_blocking = Arc::clone(&config_for_deletes); - let source_for_blocking = source_id.clone(); - let path_label = path.clone(); - let delete_result = tokio::task::spawn_blocking(move || { - delete_chunks_by_source( - &cfg_for_blocking, - SourceKind::Document, - &source_for_blocking, - ) - }) - .await; - match delete_result { - Ok(Ok(removed)) => { - log::debug!( - "[vault] sync: deleted vanished file path={} source_id={} removed_chunks={}", - path_label, - source_id, - removed - ); - } - Ok(Err(err)) => { - log::debug!( - "[vault] sync: delete_chunks_by_source failed path={path_label} err={err}" - ); - report - .errors - .push(format!("{path_label}: chunk delete failed: {err}")); - continue; - } - Err(join_err) => { - report - .errors - .push(format!("{path_label}: delete task join error: {join_err}")); - continue; - } - } - if let Err(err) = store::delete_file(config, &vault.id, path) { - log::debug!("[vault] sync: ledger delete failed path={path} err={err}"); - report - .errors - .push(format!("{path}: ledger delete failed: {err}")); - continue; - } - report.removed += 1; - state::update_progress(&vault.id, |s| s.removed += 1); - } - - if let Err(err) = store::touch_last_synced(config, &vault.id, Utc::now()) { - log::debug!("[vault] sync: touch_last_synced failed err={err}"); - } - report.duration_ms = (Utc::now() - started).num_milliseconds(); - log::debug!( - "[vault] sync: exit id={} scanned={} ingested={} unchanged={} removed={} failed={} skipped={} duration_ms={}", - vault.id, - report.scanned, - report.ingested, - report.unchanged, - report.removed, - report.failed, - report.skipped_unsupported, - report.duration_ms, - ); - report -} - -fn path_is_inside_excluded_dir(path: &Path, root: &Path) -> bool { - let Ok(rel) = path.strip_prefix(root) else { - return false; - }; - for component in rel.components() { - if let std::path::Component::Normal(os) = component { - if let Some(name) = os.to_str() { - if BUILTIN_EXCLUDE_DIRS.contains(&name) { - return true; - } - } - } - } - false -} - -fn sha256_hex(content: &str) -> String { - let mut hasher = Sha256::new(); - hasher.update(content.as_bytes()); - let digest = hasher.finalize(); - let mut out = String::with_capacity(digest.len() * 2); - for byte in digest.iter() { - out.push_str(&format!("{byte:02x}")); - } - out -} - -#[cfg(test)] -mod sync_tests { - use super::*; - use crate::openhuman::memory_store::chunks::store::{count_chunks, is_source_ingested}; - use crate::openhuman::vault::ops; - use crate::openhuman::vault::VaultWriteState; - use tempfile::TempDir; - - /// Test-config pattern mirrors `memory::sync_pipeline_e2e_test::test_config`: - /// tempdir-scoped workspace + embeddings disabled so the pipeline doesn't - /// require a live provider. Embedding-strict OFF lets the pipeline accept - /// chunks even without a working embedder. - fn test_config() -> (TempDir, Config) { - let tmp = TempDir::new().expect("tempdir"); - let mut cfg = Config::default(); - cfg.workspace_dir = tmp.path().to_path_buf(); - cfg.memory_tree.embedding_endpoint = None; - cfg.memory_tree.embedding_model = None; - cfg.memory_tree.embedding_strict = false; - (tmp, cfg) - } - - fn sample_vault(id: &str, root: &Path) -> Vault { - Vault { - id: id.to_string(), - name: "Test Vault".to_string(), - root_path: root.to_string_lossy().to_string(), - host_os: None, - namespace: format!("vault:{id}"), - include_globs: vec![], - exclude_globs: vec![], - created_at: Utc::now(), - last_synced_at: None, - file_count: 0, - write_state: VaultWriteState::Writable, - write_state_reason: None, - } - } - - /// **The #2705 regression test.** - /// - /// Before this fix, vault sync routed through `doc_ingest` → - /// `UnifiedMemory::ingest_document` → `memory_docs` table. The - /// `mem_tree_chunks` / `mem_tree_ingested_sources` tables — which every - /// modern retrieval surface reads from — were left empty, and the UI's - /// "synced" message gave users a false-success signal. - /// - /// This test pins the invariant that vault sync must populate the - /// memory-tree backend so the silent-failure mode can't reappear. It - /// asserts both the chunk row count goes up and that each per-file - /// source_id is registered in `mem_tree_ingested_sources`. - #[tokio::test] - async fn sync_writes_to_memory_tree() { - let (_tmp, cfg) = test_config(); - - let vault_root = TempDir::new().expect("vault root"); - let vault = sample_vault("vault-2705", vault_root.path()); - - // Insert the vault row first — the vault_files ledger has a FK to - // vaults, so per-file writes during Phase 3 would silently rollback - // otherwise. - store::insert_vault(&cfg, &vault).expect("insert vault"); - - // Two non-trivial markdown files so the chunker definitely produces - // ≥1 chunk per file (minimal content can otherwise be canonicalised - // into nothing). - std::fs::write( - vault_root.path().join("note-one.md"), - "# Note One\n\nThis is a substantive note about Project Phoenix. \ - It mentions Alice as the owner and contains enough text to ensure \ - the chunker produces at least one chunk. Phoenix ships in Q3.", - ) - .expect("write note-one.md"); - std::fs::write( - vault_root.path().join("note-two.md"), - "# Note Two\n\nDifferent content about Project Atlas. Bob owns this \ - one. The team plans a launch in Q4 after staging review. Atlas is \ - unrelated to Phoenix.", - ) - .expect("write note-two.md"); - - let chunks_before = count_chunks(&cfg).expect("count_chunks before"); - let report = sync_vault(&cfg, &vault).await; - - assert_eq!( - report.failed, 0, - "no files should fail in a clean test setup; errors: {:?}", - report.errors - ); - assert_eq!( - report.ingested, 2, - "both .md files should ingest; report: {report:?}" - ); - - // Core regression assertion: chunks landed in memory_tree (NOT - // memory_docs). Pre-fix, chunks_after would equal chunks_before - // because vault sync wrote to the legacy backend instead. - let chunks_after = count_chunks(&cfg).expect("count_chunks after"); - assert!( - chunks_after > chunks_before, - "vault sync must populate mem_tree_chunks (#2705): {chunks_before} → {chunks_after}" - ); - - // Per-file source registration in mem_tree_ingested_sources. - // `is_source_ingested` returns false on the legacy backend even after - // a "successful" doc_ingest run. - let cfg_for_blocking = cfg.clone(); - let registered = tokio::task::spawn_blocking(move || { - ( - is_source_ingested( - &cfg_for_blocking, - SourceKind::Document, - "vault:vault-2705:note-one.md", - ) - .unwrap_or(false), - is_source_ingested( - &cfg_for_blocking, - SourceKind::Document, - "vault:vault-2705:note-two.md", - ) - .unwrap_or(false), - ) - }) - .await - .expect("source-check task join"); - assert!( - registered.0 && registered.1, - "both source_ids must be registered in mem_tree_ingested_sources (#2705); \ - note-one={} note-two={}", - registered.0, - registered.1 - ); - - // Vault ledger stores the memory-tree source_id, not a legacy - // UnifiedMemory document_id. This is the contract the deletion - // path relies on (`stored_id.starts_with("vault:")`). - let ledger = store::list_files(&cfg, "vault-2705").expect("list_files"); - assert_eq!(ledger.len(), 2, "ledger should have one row per file"); - for entry in &ledger { - assert!( - entry.document_id.starts_with("vault:vault-2705:"), - "ledger document_id must hold the memory-tree source_id (got {})", - entry.document_id - ); - } - } - - /// Stable across re-syncs: a second sync with no content changes leaves - /// the chunk count untouched (idempotency invariant) and reports every - /// file as `unchanged`. - #[tokio::test] - async fn second_sync_with_no_changes_is_idempotent() { - let (_tmp, cfg) = test_config(); - let vault_root = TempDir::new().expect("vault root"); - let vault = sample_vault("vault-idem", vault_root.path()); - - store::insert_vault(&cfg, &vault).expect("insert vault"); - - std::fs::write( - vault_root.path().join("stable.md"), - "# Stable\n\nThis note doesn't change between syncs. Phoenix Q3.", - ) - .expect("write stable.md"); - - let first = sync_vault(&cfg, &vault).await; - assert_eq!(first.ingested, 1, "first sync ingests the file"); - let chunks_after_first = count_chunks(&cfg).expect("count after first"); - - let second = sync_vault(&cfg, &vault).await; - assert_eq!( - second.unchanged, 1, - "second sync should hash-skip the unchanged file" - ); - assert_eq!(second.ingested, 0, "no re-ingest on unchanged content"); - let chunks_after_second = count_chunks(&cfg).expect("count after second"); - assert_eq!( - chunks_after_first, chunks_after_second, - "idempotent re-sync must not duplicate chunks" - ); - } - - /// **Regression: `vault_remove(purge_memory=true)` must clear memory_tree.** - /// - /// Post-#2705, vault content lives in `mem_tree_chunks` / - /// `mem_tree_ingested_sources` keyed by `vault:{id}:{rel_path}`. The - /// pre-fix purge path only called `clear_namespace`, which targets the - /// legacy `memory_docs` table that vault sync no longer writes to — - /// removing a vault with purge would silently orphan every memory-tree - /// row and retrieval would keep surfacing content from a deleted vault. - /// This test pins the prefix-delete contract so the silent-failure mode - /// can't reappear on the removal side. - #[tokio::test] - async fn vault_remove_with_purge_clears_memory_tree() { - let (_tmp, cfg) = test_config(); - - let vault_root = TempDir::new().expect("vault root"); - let vault = sample_vault("vault-remove-2720", vault_root.path()); - - // Use the real `vault_create` op so the row goes through the same - // path production callers exercise (and so namespace/host_os are - // realistic). `vault_create` canonicalises root_path, so the - // returned vault id is the one to operate on below. - let created = - ops::vault_create(&cfg, &vault.name, vault.root_path.as_str(), vec![], vec![]) - .await - .expect("vault_create") - .value; - let vault_id = created.id.clone(); - - std::fs::write( - vault_root.path().join("doomed.md"), - "# Doomed\n\nThis note exists only long enough to be purged \ - with its parent vault. Phoenix Q4.", - ) - .expect("write doomed.md"); - - let report = sync_vault(&cfg, &created).await; - assert_eq!( - report.failed, 0, - "clean ingest; errors: {:?}", - report.errors - ); - assert_eq!(report.ingested, 1); - - let source_id = vault_source_id(&vault_id, "doomed.md"); - let chunks_before = count_chunks(&cfg).expect("count_chunks before remove"); - assert!( - chunks_before > 0, - "sanity: memory_tree should contain the vault chunks before remove" - ); - let registered_before = { - let cfg_clone = cfg.clone(); - let src = source_id.clone(); - tokio::task::spawn_blocking(move || { - is_source_ingested(&cfg_clone, SourceKind::Document, &src).unwrap_or(false) - }) - .await - .expect("source-check join") - }; - assert!( - registered_before, - "sanity: source must be registered pre-remove" - ); - - let outcome = ops::vault_remove(&cfg, &vault_id, true) - .await - .expect("vault_remove"); - let payload = outcome.value; - assert_eq!(payload["removed"], serde_json::json!(true)); - assert_eq!(payload["purged"], serde_json::json!(true)); - let purged_chunks = payload["memory_tree_chunks_deleted"] - .as_u64() - .expect("memory_tree_chunks_deleted field"); - assert!( - purged_chunks > 0, - "purge must report removed chunks; payload={payload}" - ); - - // Core regression assertion: no memory_tree rows survive the purge. - let registered_after = { - let cfg_clone = cfg.clone(); - let src = source_id.clone(); - tokio::task::spawn_blocking(move || { - is_source_ingested(&cfg_clone, SourceKind::Document, &src).unwrap_or(true) - }) - .await - .expect("source-check join") - }; - assert!( - !registered_after, - "vault_remove(purge=true) must clear mem_tree_ingested_sources for source_id={source_id}" - ); - let chunks_after = count_chunks(&cfg).expect("count_chunks after remove"); - assert!( - chunks_after < chunks_before, - "memory_tree chunks must shrink after purge: {chunks_before} → {chunks_after}" - ); - } - - /// `vault_remove(purge_memory=false)` must leave memory_tree rows in - /// place. Guards the boolean from silently flipping to always-purge. - #[tokio::test] - async fn vault_remove_without_purge_leaves_memory_tree_intact() { - let (_tmp, cfg) = test_config(); - let vault_root = TempDir::new().expect("vault root"); - let vault = sample_vault("vault-keep-2720", vault_root.path()); - - let created = - ops::vault_create(&cfg, &vault.name, vault.root_path.as_str(), vec![], vec![]) - .await - .expect("vault_create") - .value; - let vault_id = created.id.clone(); - - std::fs::write( - vault_root.path().join("kept.md"), - "# Kept\n\nThis content should survive a no-purge vault removal. \ - Atlas Q1 plan.", - ) - .expect("write kept.md"); - - let report = sync_vault(&cfg, &created).await; - assert_eq!( - report.failed, 0, - "clean ingest; errors: {:?}", - report.errors - ); - let chunks_before = count_chunks(&cfg).expect("count_chunks before"); - - let outcome = ops::vault_remove(&cfg, &vault_id, false) - .await - .expect("vault_remove"); - let payload = outcome.value; - assert_eq!(payload["removed"], serde_json::json!(true)); - assert_eq!(payload["purged"], serde_json::json!(false)); - assert_eq!( - payload["memory_tree_chunks_deleted"], - serde_json::json!(0), - "no-purge removal must not touch memory_tree" - ); - - let chunks_after = count_chunks(&cfg).expect("count_chunks after"); - assert_eq!( - chunks_before, chunks_after, - "no-purge removal must leave chunk count unchanged" - ); - } - - /// `vault_source_id` is stable across calls — this is what makes the - /// ledger ↔ memory_tree link work for cleanup on file deletion and for - /// the content-update delete-then-reingest dance. - #[test] - fn vault_source_id_is_stable_and_namespaced() { - let a = vault_source_id("v1", "notes/foo.md"); - let b = vault_source_id("v1", "notes/foo.md"); - assert_eq!(a, b, "must be deterministic for the same (vault, path)"); - assert_eq!(a, "vault:v1:notes/foo.md"); - - // Different vaults / paths produce different ids — defends against - // the pipeline's `already_ingested` gate cross-contaminating - // distinct files. - assert_ne!( - vault_source_id("v1", "notes/foo.md"), - vault_source_id("v2", "notes/foo.md") - ); - assert_ne!( - vault_source_id("v1", "notes/foo.md"), - vault_source_id("v1", "notes/bar.md") - ); - } -} diff --git a/src/openhuman/vault/tests.rs b/src/openhuman/vault/tests.rs deleted file mode 100644 index 051702e40..000000000 --- a/src/openhuman/vault/tests.rs +++ /dev/null @@ -1,643 +0,0 @@ -//! Unit tests for the vault domain. Hits a real SQLite db in a tempdir, -//! but skips memory ingestion (covered in higher-level integration tests). - -use std::path::PathBuf; -use tempfile::TempDir; - -use crate::openhuman::config::Config; - -use super::ops; -use super::state; -use super::store; -use super::sync::supported_extension; -use super::types::{ - Vault, VaultFile, VaultFileStatus, VaultSyncState, VaultSyncStatus, VaultWriteState, -}; - -fn make_config(tmp: &TempDir) -> Config { - let mut config = Config::default(); - config.workspace_dir = tmp.path().to_path_buf(); - config -} - -fn sample_vault(root: PathBuf) -> Vault { - Vault { - id: "vault-test-1".to_string(), - name: "Test".to_string(), - root_path: root.to_string_lossy().to_string(), - host_os: None, - namespace: "vault:vault-test-1".to_string(), - include_globs: vec![], - exclude_globs: vec![], - created_at: chrono::Utc::now(), - last_synced_at: None, - file_count: 0, - write_state: VaultWriteState::Writable, - write_state_reason: None, - } -} - -fn incompatible_path_for_current_host() -> &'static str { - if cfg!(windows) { - "/home/leigh/OHvault" - } else { - r"C:\Users\leigh\OHvault" - } -} - -#[test] -fn path_compatibility_rejects_cross_platform_absolute_paths() { - assert!(store::path_looks_compatible_with_host_os( - r"C:\Users\leigh\OHvault", - "windows" - )); - assert!(store::path_looks_compatible_with_host_os( - r"\\server\share\OHvault", - "windows" - )); - // Forward-slash `//…` is POSIX-legal, not Windows UNC. - assert!(!store::path_looks_compatible_with_host_os( - "//server/share/OHvault", - "windows" - )); - assert!(!store::path_looks_compatible_with_host_os( - "/home/leigh/OHvault", - "windows" - )); - - assert!(store::path_looks_compatible_with_host_os( - "/home/leigh/OHvault", - "linux" - )); - assert!(store::path_looks_compatible_with_host_os( - "/Users/leigh/OHvault", - "macos" - )); - assert!(!store::path_looks_compatible_with_host_os( - r"C:\Users\leigh\OHvault", - "linux" - )); - assert!(!store::path_looks_compatible_with_host_os( - r"\\server\share\OHvault", - "macos" - )); - // Forward-slash `//…` is POSIX-legal — compatible with Unix hosts. - assert!(store::path_looks_compatible_with_host_os( - "//server/share/OHvault", - "macos" - )); - assert!(store::path_looks_compatible_with_host_os( - "//server/share/OHvault", - "linux" - )); -} - -#[test] -fn store_stamps_new_vaults_with_current_host_os() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault = sample_vault(tmp.path().to_path_buf()); - - store::insert_vault(&config, &vault).unwrap(); - - let listed = store::list_vaults(&config).unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].host_os.as_deref(), Some(std::env::consts::OS)); - assert_eq!(listed[0].write_state, VaultWriteState::Writable); - assert_eq!( - listed[0].write_state_reason.as_deref(), - Some(store::VAULT_WRITE_REASON_WRITABLE) - ); -} - -#[test] -fn store_marks_missing_vault_folder_unavailable_instead_of_hiding_it() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault_root = tmp.path().join("vault-root"); - std::fs::create_dir_all(&vault_root).unwrap(); - let vault = sample_vault(vault_root.clone()); - - store::insert_vault(&config, &vault).unwrap(); - std::fs::remove_dir_all(&vault_root).unwrap(); - - let listed = store::list_vaults(&config).unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].write_state, VaultWriteState::Unavailable); - assert_eq!( - listed[0].write_state_reason.as_deref(), - Some(store::VAULT_WRITE_REASON_UNAVAILABLE) - ); -} - -#[test] -fn store_filters_legacy_vaults_whose_path_belongs_to_another_host_family() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let mut vault = sample_vault(PathBuf::from(incompatible_path_for_current_host())); - vault.host_os = None; - - store::insert_vault_preserving_host_for_tests(&config, &vault).unwrap(); - - assert!(store::list_vaults(&config).unwrap().is_empty()); - assert!(store::get_vault(&config, &vault.id).unwrap().is_none()); -} - -#[test] -fn store_filters_vaults_created_on_a_different_host_os() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let mut vault = sample_vault(tmp.path().to_path_buf()); - vault.host_os = Some(if cfg!(windows) { "linux" } else { "windows" }.to_string()); - - store::insert_vault_preserving_host_for_tests(&config, &vault).unwrap(); - - assert!(store::list_vaults(&config).unwrap().is_empty()); - assert!(store::get_vault(&config, &vault.id).unwrap().is_none()); -} - -#[test] -fn supported_extension_accepts_md_and_code() { - assert!(supported_extension("md")); - assert!(supported_extension("MD")); - assert!(supported_extension("rs")); - assert!(supported_extension("tsx")); - assert!(!supported_extension("png")); - assert!(!supported_extension("zip")); - assert!(!supported_extension("")); -} - -#[test] -fn store_insert_get_list_remove_roundtrip() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault = sample_vault(tmp.path().to_path_buf()); - - store::insert_vault(&config, &vault).unwrap(); - - let listed = store::list_vaults(&config).unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].id, vault.id); - assert_eq!(listed[0].namespace, vault.namespace); - assert_eq!(listed[0].file_count, 0); - - let fetched = store::get_vault(&config, &vault.id).unwrap(); - assert!(fetched.is_some()); - assert_eq!(fetched.unwrap().name, "Test"); - - let removed = store::remove_vault(&config, &vault.id).unwrap(); - assert!(removed); - assert!(store::list_vaults(&config).unwrap().is_empty()); -} - -#[test] -fn store_files_upsert_and_delete() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault = sample_vault(tmp.path().to_path_buf()); - store::insert_vault(&config, &vault).unwrap(); - - let file = VaultFile { - vault_id: vault.id.clone(), - rel_path: "notes/one.md".to_string(), - document_id: "doc-1".to_string(), - content_hash: "h1".to_string(), - mtime_ms: 100, - bytes: 42, - ingested_at: chrono::Utc::now(), - status: VaultFileStatus::Ok, - }; - store::upsert_file(&config, &file).unwrap(); - - let listed = store::list_files(&config, &vault.id).unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].document_id, "doc-1"); - - // Re-upsert with same key should update, not duplicate. - let mut updated = file.clone(); - updated.content_hash = "h2".to_string(); - updated.mtime_ms = 200; - store::upsert_file(&config, &updated).unwrap(); - let listed = store::list_files(&config, &vault.id).unwrap(); - assert_eq!(listed.len(), 1); - assert_eq!(listed[0].content_hash, "h2"); - assert_eq!(listed[0].mtime_ms, 200); - - // File count on vault list should reflect 1 OK row. - let vaults = store::list_vaults(&config).unwrap(); - assert_eq!(vaults[0].file_count, 1); - - store::delete_file(&config, &vault.id, "notes/one.md").unwrap(); - assert!(store::list_files(&config, &vault.id).unwrap().is_empty()); -} - -#[test] -fn remove_vault_cascades_files() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault = sample_vault(tmp.path().to_path_buf()); - store::insert_vault(&config, &vault).unwrap(); - - let file = VaultFile { - vault_id: vault.id.clone(), - rel_path: "a.md".to_string(), - document_id: "doc-a".to_string(), - content_hash: "h".to_string(), - mtime_ms: 1, - bytes: 1, - ingested_at: chrono::Utc::now(), - status: VaultFileStatus::Ok, - }; - store::upsert_file(&config, &file).unwrap(); - - store::remove_vault(&config, &vault.id).unwrap(); - // Cascade should have wiped vault_files rows for this id. - assert!(store::list_files(&config, &vault.id).unwrap().is_empty()); -} - -// --------------------------------------------------------------------------- -// state.rs — in-memory sync state registry -// --------------------------------------------------------------------------- - -fn make_state(vault_id: &str, status: VaultSyncStatus) -> VaultSyncState { - VaultSyncState { - vault_id: vault_id.to_string(), - status, - scanned: 0, - ingested: 0, - unchanged: 0, - removed: 0, - failed: 0, - skipped_unsupported: 0, - total: 0, - started_at_ms: 100, - finished_at_ms: None, - duration_ms: 0, - errors: vec![], - } -} - -#[test] -fn state_get_returns_none_for_unknown() { - // Use a unique ID so parallel tests can't collide via the global map. - assert!(state::get("__test_unknown_99z__").is_none()); -} - -#[test] -fn state_set_and_get_roundtrip() { - let id = "__test_set_1__"; - state::set(make_state(id, VaultSyncStatus::Completed)); - let st = state::get(id).unwrap(); - assert_eq!(st.status, VaultSyncStatus::Completed); - assert_eq!(st.vault_id, id); -} - -#[test] -fn state_start_creates_running_entry() { - let id = "__test_start_1__"; - state::start(id, 12345).unwrap(); - let st = state::get(id).unwrap(); - assert_eq!(st.status, VaultSyncStatus::Running); - assert_eq!(st.started_at_ms, 12345); - assert_eq!(st.ingested, 0); -} - -#[test] -fn state_start_rejects_duplicate_running() { - let id = "__test_start_dup__"; - state::start(id, 1).unwrap(); - let err = state::start(id, 2).unwrap_err(); - assert!(err.contains("already syncing")); -} - -#[test] -fn state_start_allowed_after_completed() { - let id = "__test_start_after_completed__"; - state::start(id, 1).unwrap(); - // Mark as completed, then start again — must succeed. - state::update_progress(id, |s| s.status = VaultSyncStatus::Completed); - state::start(id, 2).unwrap(); - assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running); -} - -#[test] -fn state_start_allowed_after_failed() { - let id = "__test_start_after_failed__"; - state::start(id, 1).unwrap(); - state::update_progress(id, |s| s.status = VaultSyncStatus::Failed); - state::start(id, 2).unwrap(); - assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running); -} - -#[test] -fn state_update_progress_mutates_entry() { - let id = "__test_update_1__"; - state::start(id, 1).unwrap(); - state::update_progress(id, |s| { - s.ingested = 7; - s.scanned = 10; - s.total = 10; - }); - let st = state::get(id).unwrap(); - assert_eq!(st.ingested, 7); - assert_eq!(st.scanned, 10); -} - -#[test] -fn state_update_progress_noop_on_missing() { - // Must not panic when vault_id is absent from the map. - state::update_progress("__test_noop_xyz__", |s| { - s.ingested = 999; // should never execute - }); -} - -// --------------------------------------------------------------------------- -// ops.rs — vault_sync_status RPC operation -// --------------------------------------------------------------------------- - -#[tokio::test] -async fn vault_create_returns_current_host_os() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - - let outcome = ops::vault_create( - &config, - "Test", - tmp.path().to_str().unwrap(), - vec![], - vec![], - ) - .await - .unwrap(); - - assert_eq!(outcome.value.host_os.as_deref(), Some(std::env::consts::OS)); - assert_eq!(outcome.value.write_state, VaultWriteState::Writable); - assert_eq!( - outcome.value.write_state_reason.as_deref(), - Some(store::VAULT_WRITE_REASON_WRITABLE) - ); -} - -#[tokio::test] -async fn vault_create_uses_pii_safe_memory_namespace() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - - let outcome = ops::vault_create( - &config, - "Test", - tmp.path().to_str().unwrap(), - vec![], - vec![], - ) - .await - .unwrap(); - - let namespace = &outcome.value.namespace; - assert!(namespace.starts_with("vault-")); - assert!(!namespace.contains(&outcome.value.id)); - assert!(!crate::openhuman::memory_store::safety::has_likely_secret( - namespace - )); - assert!(!crate::openhuman::memory_store::safety::pii::has_likely_pii(namespace)); -} - -#[test] -fn vault_namespace_derivation_does_not_embed_pii_like_ids() { - let namespace = ops::vault_namespace_for_id("VECJ880326XK4"); - - assert!(namespace.starts_with("vault-")); - assert!(!namespace.contains("VECJ880326XK4")); - assert!(!crate::openhuman::memory_store::safety::has_likely_secret( - &namespace - )); - assert!(!crate::openhuman::memory_store::safety::pii::has_likely_pii(&namespace)); -} - -#[tokio::test] -async fn vault_sync_status_returns_idle_for_unknown_vault() { - let outcome = ops::vault_sync_status("__ops_status_unknown__") - .await - .unwrap(); - assert_eq!(outcome.value.status, VaultSyncStatus::Idle); - assert_eq!(outcome.value.vault_id, "__ops_status_unknown__"); - assert_eq!(outcome.value.ingested, 0); -} - -#[tokio::test] -async fn vault_write_markdown_requires_explicit_approval() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - - let vault = ops::vault_create( - &config, - "Test", - tmp.path().to_str().unwrap(), - vec![], - vec![], - ) - .await - .unwrap() - .value; - - let err = ops::vault_write_markdown( - &config, - &vault.id, - "wiki/summary.md", - "# Summary\n", - false, - false, - ) - .await - .unwrap_err(); - assert!(err.contains("explicit user approval")); -} - -#[tokio::test] -async fn vault_write_markdown_creates_and_updates_relative_markdown() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault_root = tmp.path().join("vault-root"); - std::fs::create_dir_all(&vault_root).unwrap(); - - let vault = ops::vault_create( - &config, - "Test", - vault_root.to_str().unwrap(), - vec![], - vec![], - ) - .await - .unwrap() - .value; - - let first = ops::vault_write_markdown( - &config, - &vault.id, - "wiki/summary.md", - "# Summary\n\nInitial.", - false, - true, - ) - .await - .unwrap() - .value; - assert!(first.created); - assert_eq!(first.rel_path, "wiki/summary.md"); - assert_eq!(first.bytes_written, "# Summary\n\nInitial.".len() as u64); - assert_eq!( - std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(), - "# Summary\n\nInitial." - ); - - let duplicate = ops::vault_write_markdown( - &config, - &vault.id, - "wiki/summary.md", - "# Summary\n\nUpdated.", - false, - true, - ) - .await - .unwrap_err(); - assert!(duplicate.contains("already exists")); - - let updated = ops::vault_write_markdown( - &config, - &vault.id, - "wiki/summary.md", - "# Summary\n\nUpdated.", - true, - true, - ) - .await - .unwrap() - .value; - assert!(!updated.created); - assert_eq!( - std::fs::read_to_string(vault_root.join("wiki/summary.md")).unwrap(), - "# Summary\n\nUpdated." - ); -} - -#[tokio::test] -async fn vault_write_markdown_rejects_escape_paths_and_non_markdown() { - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault = ops::vault_create( - &config, - "Test", - tmp.path().to_str().unwrap(), - vec![], - vec![], - ) - .await - .unwrap() - .value; - - let traversal = ops::vault_write_markdown(&config, &vault.id, "../x.md", "x", false, true) - .await - .unwrap_err(); - assert!(traversal.contains("..")); - - let non_markdown = - ops::vault_write_markdown(&config, &vault.id, "notes/out.txt", "x", false, true) - .await - .unwrap_err(); - assert!(non_markdown.contains(".md")); -} - -#[cfg(unix)] -#[tokio::test] -async fn vault_write_markdown_rejects_symlink_escape() { - use std::os::unix::fs::symlink; - - let tmp = TempDir::new().unwrap(); - let config = make_config(&tmp); - let vault_root = tmp.path().join("vault-root"); - let outside = tmp.path().join("outside"); - std::fs::create_dir_all(&vault_root).unwrap(); - std::fs::create_dir_all(&outside).unwrap(); - symlink(&outside, vault_root.join("linked")).unwrap(); - - let vault = ops::vault_create( - &config, - "Test", - vault_root.to_str().unwrap(), - vec![], - vec![], - ) - .await - .unwrap() - .value; - - let err = ops::vault_write_markdown(&config, &vault.id, "linked/escape.md", "x", false, true) - .await - .unwrap_err(); - assert!(err.contains("outside the vault")); - assert!(!outside.join("escape.md").exists()); -} - -#[tokio::test] -async fn vault_sync_status_returns_state_when_present() { - let id = "__ops_status_running__"; - let mut st = make_state(id, VaultSyncStatus::Running); - st.scanned = 10; - st.ingested = 5; - st.total = 10; - state::set(st); - - let outcome = ops::vault_sync_status(id).await.unwrap(); - assert_eq!(outcome.value.status, VaultSyncStatus::Running); - assert_eq!(outcome.value.scanned, 10); - assert_eq!(outcome.value.ingested, 5); - assert_eq!(outcome.value.total, 10); -} - -#[tokio::test] -async fn vault_sync_status_returns_completed_state() { - let id = "__ops_status_completed__"; - let mut st = make_state(id, VaultSyncStatus::Completed); - st.ingested = 12; - st.failed = 1; - st.duration_ms = 500; - st.errors = vec!["file.txt: too large".to_string()]; - state::set(st); - - let outcome = ops::vault_sync_status(id).await.unwrap(); - assert_eq!(outcome.value.status, VaultSyncStatus::Completed); - assert_eq!(outcome.value.ingested, 12); - assert_eq!(outcome.value.failed, 1); - assert_eq!(outcome.value.errors.len(), 1); -} - -#[tokio::test] -async fn vault_sync_status_rejects_empty_id() { - let err = ops::vault_sync_status("").await.unwrap_err(); - assert!(err.contains("vault_id must not be empty")); -} - -#[tokio::test] -async fn vault_sync_panic_guard_marks_state_failed_and_allows_retry() { - // Simulate the panic-recovery path that the catch_unwind guard in - // ops::vault_sync triggers: vault goes Running -> Failed (with a panic - // message), then can be restarted. This verifies the invariant that no - // panic can permanently lock the state in `Running`. - let id = "__test_panic_guard_recovery__"; - state::start(id, 1_000).unwrap(); - assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running); - - // Simulate what the Err(_) branch of the catch_unwind match does. - state::update_progress(id, |s| { - s.status = VaultSyncStatus::Failed; - s.errors = vec!["sync task panicked unexpectedly".to_string()]; - }); - - let st = state::get(id).unwrap(); - assert_eq!(st.status, VaultSyncStatus::Failed); - assert!(st.errors[0].contains("panicked")); - - // A subsequent sync attempt must not be blocked by the old Running entry. - state::start(id, 2_000).unwrap(); - assert_eq!(state::get(id).unwrap().status, VaultSyncStatus::Running); -} diff --git a/src/openhuman/vault/types.rs b/src/openhuman/vault/types.rs deleted file mode 100644 index 1e53de56c..000000000 --- a/src/openhuman/vault/types.rs +++ /dev/null @@ -1,138 +0,0 @@ -use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; - -/// Status of a running or completed vault sync operation. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "lowercase")] -pub enum VaultSyncStatus { - #[default] - Idle, - Running, - Completed, - Failed, -} - -/// Live progress for a vault sync operation. -/// -/// Held in the global registry while a sync is in flight, and retained after -/// completion so the frontend can poll the final outcome without timing -/// concerns. The next `vault.sync` call for the same vault overwrites this. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct VaultSyncState { - pub vault_id: String, - pub status: VaultSyncStatus, - /// Files seen by the directory walker (updated after discovery phase). - pub scanned: u64, - /// Files successfully ingested so far. - pub ingested: u64, - /// Files skipped because hash+mtime was unchanged. - pub unchanged: u64, - /// Files removed from the vault (source file gone). - pub removed: u64, - /// Files that failed ingestion. - pub failed: u64, - /// Files skipped (unsupported extension or too large). - pub skipped_unsupported: u64, - /// Total files queued for ingestion (set after discovery; 0 while walking). - pub total: u64, - /// Unix milliseconds when this sync started. - pub started_at_ms: i64, - /// Unix milliseconds when this sync finished; `None` while still running. - pub finished_at_ms: Option, - /// Wall-clock duration in ms; 0 while running; set from VaultSyncReport on completion. - pub duration_ms: i64, - /// Accumulated error strings. - pub errors: Vec, -} - -/// A user-registered local folder whose files are mirrored into memory. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct Vault { - pub id: String, - pub name: String, - pub root_path: String, - #[serde(default)] - pub host_os: Option, - pub namespace: String, - pub include_globs: Vec, - pub exclude_globs: Vec, - pub created_at: DateTime, - pub last_synced_at: Option>, - pub file_count: u64, - #[serde(default)] - pub write_state: VaultWriteState, - #[serde(default)] - pub write_state_reason: Option, -} - -/// Whether OpenHuman can write approved markdown artifacts back into a vault. -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] -#[serde(rename_all = "snake_case")] -pub enum VaultWriteState { - Writable, - ReadOnly, - #[default] - Unavailable, -} - -/// Per-file ledger entry used for dedup on re-sync. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct VaultFile { - pub vault_id: String, - pub rel_path: String, - pub document_id: String, - pub content_hash: String, - pub mtime_ms: i64, - pub bytes: u64, - pub ingested_at: DateTime, - pub status: VaultFileStatus, -} - -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum VaultFileStatus { - Ok, - Skipped, - Failed, -} - -impl VaultFileStatus { - pub(crate) fn as_str(&self) -> &'static str { - match self { - Self::Ok => "ok", - Self::Skipped => "skipped", - Self::Failed => "failed", - } - } - - pub(crate) fn parse(raw: &str) -> Self { - match raw { - "skipped" => Self::Skipped, - "failed" => Self::Failed, - _ => Self::Ok, - } - } -} - -/// Summary returned from `vault.sync`. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] -pub struct VaultSyncReport { - pub vault_id: String, - pub scanned: u64, - pub ingested: u64, - pub unchanged: u64, - pub removed: u64, - pub failed: u64, - pub skipped_unsupported: u64, - pub duration_ms: i64, - pub errors: Vec, -} - -/// Result returned after writing an approved markdown/wiki artifact into a vault. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub struct VaultWriteMarkdownReport { - pub vault_id: String, - pub rel_path: String, - pub bytes_written: u64, - pub created: bool, -} diff --git a/tests/vault_sync_e2e.rs b/tests/vault_sync_e2e.rs deleted file mode 100644 index a72c5c203..000000000 --- a/tests/vault_sync_e2e.rs +++ /dev/null @@ -1,218 +0,0 @@ -//! End-to-end coverage for vault sync lifecycle. -//! -//! Runs the public `vault.*` operations against a real temp workspace: -//! create a vault, sync supported files, verify per-file ledger + memory -//! tree state, then modify/delete/add files and sync again. -//! -//! Memory-side assertions target the **memory_tree backend** (the canonical -//! RAG layer for `memory.search` / `tree.read_chunk` / agent recall). -//! Prior to #2720 vault sync wrote to the legacy `UnifiedMemory.memory_docs` -//! table — silently invisible to retrieval. The fix migrated vault sync to -//! the memory-tree pipeline, so this test now probes `mem_tree_chunks` and -//! `mem_tree_ingested_sources` instead of `list_documents`. - -use std::path::Path; -use std::sync::{Mutex, OnceLock}; -use std::time::Duration; - -use tempfile::tempdir; - -use openhuman_core::openhuman::config::Config; -use openhuman_core::openhuman::memory::global as memory_global; -use openhuman_core::openhuman::memory_store::chunks::store::{count_chunks, is_source_ingested}; -use openhuman_core::openhuman::memory_store::chunks::types::SourceKind; -use openhuman_core::openhuman::vault::ops; -use openhuman_core::openhuman::vault::VaultSyncStatus; - -static TEST_LOCK: OnceLock> = OnceLock::new(); - -fn test_lock() -> std::sync::MutexGuard<'static, ()> { - TEST_LOCK - .get_or_init(|| Mutex::new(())) - .lock() - .expect("test lock poisoned") -} - -fn make_config(workspace_dir: &Path) -> Config { - let mut config = Config::default(); - config.workspace_dir = workspace_dir.to_path_buf(); - config -} - -async fn wait_for_sync(vault_id: &str) -> openhuman_core::openhuman::vault::VaultSyncState { - for _ in 0..100 { - let state = ops::vault_sync_status(vault_id) - .await - .expect("vault_sync_status") - .value; - if state.status != VaultSyncStatus::Running { - return state; - } - tokio::time::sleep(Duration::from_millis(50)).await; - } - panic!("vault sync did not finish within polling window"); -} - -#[tokio::test] -async fn vault_sync_roundtrip_updates_memory_and_ledger() { - let _guard = test_lock(); - let tmp = tempdir().expect("tempdir"); - let workspace_dir = tmp.path().join("workspace"); - let vault_root = tmp.path().join("vault-root"); - std::fs::create_dir_all(&workspace_dir).expect("workspace dir"); - std::fs::create_dir_all(vault_root.join("notes")).expect("notes dir"); - std::fs::create_dir_all(vault_root.join("docs")).expect("docs dir"); - std::fs::create_dir_all(vault_root.join("node_modules")).expect("excluded dir"); - - std::fs::write( - vault_root.join("notes").join("one.md"), - "# One\n\nPhoenix migration checklist.\n", - ) - .expect("write one.md"); - std::fs::write( - vault_root.join("docs").join("two.json"), - "{\"status\":\"green\",\"owner\":\"alice\"}\n", - ) - .expect("write two.json"); - std::fs::write(vault_root.join("image.png"), b"not a real png").expect("write image.png"); - std::fs::write( - vault_root.join("node_modules").join("skip.md"), - "should be excluded", - ) - .expect("write excluded file"); - - memory_global::init(workspace_dir.clone()).expect("init global memory client"); - let config = make_config(&workspace_dir); - - let vault = ops::vault_create( - &config, - "Project Vault", - vault_root.to_str().expect("vault root utf-8"), - vec![], - vec![], - ) - .await - .expect("vault_create") - .value; - - ops::vault_sync(&config, &vault.id) - .await - .expect("vault_sync first"); - let first = wait_for_sync(&vault.id).await; - assert_eq!( - first.status, - VaultSyncStatus::Completed, - "first sync failed with errors: {:?}", - first.errors - ); - assert_eq!(first.ingested, 2); - assert_eq!(first.removed, 0); - assert_eq!(first.failed, 0); - assert_eq!(first.skipped_unsupported, 1); - assert_eq!(first.scanned, 3); - - let files = ops::vault_files(&config, &vault.id) - .await - .expect("vault_files after first sync") - .value; - assert_eq!(files.len(), 2); - assert!(files.iter().any(|file| file.rel_path == "notes/one.md")); - assert!(files.iter().any(|file| file.rel_path == "docs/two.json")); - - // After #2720, vault sync writes to memory_tree (mem_tree_chunks + - // mem_tree_ingested_sources). Both files must register as sources, and - // the chunk count must be > 0 (otherwise the chunker / pipeline silently - // dropped them — the exact failure mode this test guards against). - let chunks_after_first = count_chunks(&config).expect("count_chunks after first sync"); - assert!( - chunks_after_first > 0, - "vault sync must populate mem_tree_chunks; got {chunks_after_first}" - ); - let one_id = format!("vault:{}:notes/one.md", vault.id); - let two_id = format!("vault:{}:docs/two.json", vault.id); - assert!( - is_source_ingested(&config, SourceKind::Document, &one_id).expect("source check one.md"), - "notes/one.md missing from mem_tree_ingested_sources (source_id={one_id})" - ); - assert!( - is_source_ingested(&config, SourceKind::Document, &two_id).expect("source check two.json"), - "docs/two.json missing from mem_tree_ingested_sources (source_id={two_id})" - ); - - // Vault ledger continues to track the per-file row count and rel_paths - // — same contract as before #2720; only the `document_id` semantic - // changed (now holds the memory-tree source_id). - for file in &files { - assert!( - file.document_id.starts_with("vault:"), - "ledger document_id must encode memory-tree source_id, got {}", - file.document_id - ); - } - - let note_ledger = files - .iter() - .find(|file| file.rel_path == "notes/one.md") - .expect("note ledger entry"); - assert!(note_ledger.bytes > 0); - assert_eq!(note_ledger.vault_id, vault.id); - - std::fs::write( - vault_root.join("notes").join("one.md"), - "# One\n\nPhoenix migration checklist updated with rollback steps.\n", - ) - .expect("rewrite one.md"); - std::fs::remove_file(vault_root.join("docs").join("two.json")).expect("remove two.json"); - std::fs::write( - vault_root.join("docs").join("three.toml"), - "status = \"ready\"\nowner = \"bob\"\n", - ) - .expect("write three.toml"); - - ops::vault_sync(&config, &vault.id) - .await - .expect("vault_sync second"); - let second = wait_for_sync(&vault.id).await; - assert_eq!( - second.status, - VaultSyncStatus::Completed, - "second sync failed with errors: {:?}", - second.errors - ); - assert_eq!(second.ingested, 2); - assert_eq!(second.removed, 1); - assert_eq!(second.failed, 0); - assert_eq!(second.skipped_unsupported, 1); - - let files = ops::vault_files(&config, &vault.id) - .await - .expect("vault_files after second sync") - .value; - assert_eq!(files.len(), 2); - assert!(files.iter().any(|file| file.rel_path == "notes/one.md")); - assert!(files.iter().any(|file| file.rel_path == "docs/three.toml")); - assert!(!files.iter().any(|file| file.rel_path == "docs/two.json")); - - // Memory-tree side of the lifecycle (post-#2720): - // - notes/one.md : content updated → delete_chunks_by_source then - // re-ingest; source must still be registered. - // - docs/two.json : file removed → delete_chunks_by_source dropped - // it; source must no longer register as ingested. - // - docs/three.toml: brand-new file → freshly registered. - let three_id = format!("vault:{}:docs/three.toml", vault.id); - assert!( - is_source_ingested(&config, SourceKind::Document, &one_id) - .expect("source check one.md after update"), - "notes/one.md must remain ingested after re-sync of updated content (source_id={one_id})" - ); - assert!( - is_source_ingested(&config, SourceKind::Document, &three_id) - .expect("source check three.toml"), - "docs/three.toml (new file) missing from mem_tree_ingested_sources (source_id={three_id})" - ); - assert!( - !is_source_ingested(&config, SourceKind::Document, &two_id).expect("source check two.json"), - "docs/two.json was deleted on disk; vault sync's Phase 4 must remove \ - it from mem_tree_ingested_sources (source_id={two_id})" - ); -}