ability to symlink a vault

This commit is contained in:
Arjun 2026-02-17 22:27:16 +05:30
parent b238089e2d
commit 63cbe83e3e
11 changed files with 518 additions and 19 deletions

View file

@ -1,4 +1,4 @@
import { ipcMain, BrowserWindow, shell } from 'electron'; import { ipcMain, BrowserWindow, shell, dialog } from 'electron';
import { ipc } from '@x/shared'; import { ipc } from '@x/shared';
import path from 'node:path'; import path from 'node:path';
import os from 'node:os'; import os from 'node:os';
@ -26,6 +26,11 @@ import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js'; import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js'; import { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.js';
import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js'; import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/config/note_creation_config.js';
import {
addKnowledgeVault,
listKnowledgeVaults,
removeKnowledgeVault,
} from '@x/core/dist/config/knowledge_vaults.js';
import * as composioHandler from './composio-handler.js'; import * as composioHandler from './composio-handler.js';
import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js'; import { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/repo.js';
import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js'; import { IAgentScheduleStateRepo } from '@x/core/dist/agent-schedule/state-repo.js';
@ -318,6 +323,30 @@ export function setupIpcHandlers() {
'workspace:remove': async (_event, args) => { 'workspace:remove': async (_event, args) => {
return workspace.remove(args.path, args.opts); return workspace.remove(args.path, args.opts);
}, },
'knowledge:listVaults': async () => {
return { vaults: listKnowledgeVaults() };
},
'knowledge:pickVaultDirectory': async () => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
});
if (result.canceled || result.filePaths.length === 0) {
return { path: null };
}
return { path: result.filePaths[0] };
},
'knowledge:addVault': async (_event, args) => {
const vault = addKnowledgeVault({
name: args.name,
path: args.path,
readOnly: args.readOnly,
});
return { vault };
},
'knowledge:removeVault': async (_event, args) => {
const removed = removeKnowledgeVault(args.nameOrMountPath);
return { removed };
},
'mcp:listTools': async (_event, args) => { 'mcp:listTools': async (_event, args) => {
return mcpCore.listTools(args.serverName, args.cursor); return mcpCore.listTools(args.serverName, args.cursor);
}, },

View file

@ -45,6 +45,15 @@ import {
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { Toaster } from "@/components/ui/sonner" import { Toaster } from "@/components/ui/sonner"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links' import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
import { OnboardingModal } from '@/components/onboarding-modal' import { OnboardingModal } from '@/components/onboarding-modal'
import { SearchDialog } from '@/components/search-dialog' import { SearchDialog } from '@/components/search-dialog'
@ -356,6 +365,10 @@ function App() {
const [tree, setTree] = useState<TreeNode[]>([]) const [tree, setTree] = useState<TreeNode[]>([])
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set()) const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([]) const [recentWikiFiles, setRecentWikiFiles] = useState<string[]>([])
const [isAddVaultOpen, setIsAddVaultOpen] = useState(false)
const [pendingVaultPath, setPendingVaultPath] = useState<string | null>(null)
const [pendingVaultName, setPendingVaultName] = useState<string>('')
const [isAddingVault, setIsAddingVault] = useState(false)
const [isGraphOpen, setIsGraphOpen] = useState(false) const [isGraphOpen, setIsGraphOpen] = useState(false)
const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null)
const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({
@ -2186,6 +2199,62 @@ function App() {
setExpandedPaths(newExpanded) setExpandedPaths(newExpanded)
} }
// Handle sidebar section changes - switch to chat view for tasks
const handleSectionChange = useCallback((section: ActiveSection) => {
if (section === 'tasks') {
if (selectedBackgroundTask) return
if (selectedPath || isGraphOpen) {
void navigateToView({ type: 'chat', runId })
}
}
}, [isGraphOpen, navigateToView, runId, selectedBackgroundTask, selectedPath])
const deriveVaultName = useCallback((vaultPath: string) => {
const trimmed = vaultPath.replace(/[\\/]+$/, '')
const parts = trimmed.split(/[/\\]/)
return parts[parts.length - 1] || 'Folder'
}, [])
const handleAddVault = useCallback(async () => {
try {
const result = await window.ipc.invoke('knowledge:pickVaultDirectory', null)
if (!result.path) return
const defaultName = deriveVaultName(result.path)
setPendingVaultPath(result.path)
setPendingVaultName(defaultName)
setIsAddVaultOpen(true)
} catch (err) {
console.error('Failed to pick vault directory:', err)
toast('Failed to open folder picker')
}
}, [deriveVaultName])
const handleConfirmAddVault = useCallback(async () => {
if (!pendingVaultPath) return
const name = pendingVaultName.trim()
if (!name) {
toast('Folder name is required')
return
}
setIsAddingVault(true)
try {
await window.ipc.invoke('knowledge:addVault', {
path: pendingVaultPath,
name,
readOnly: false,
})
setIsAddVaultOpen(false)
setPendingVaultPath(null)
setPendingVaultName('')
loadDirectory().then(setTree)
toast(`Added knowledge folder "${name}"`)
} catch (err) {
console.error('Failed to add vault:', err)
toast('Failed to add knowledge folder')
} finally {
setIsAddingVault(false)
}
}, [loadDirectory, pendingVaultName, pendingVaultPath])
// Knowledge quick actions // Knowledge quick actions
const knowledgeFiles = React.useMemo(() => { const knowledgeFiles = React.useMemo(() => {
const files = collectFilePaths(tree).filter((path) => path.endsWith('.md')) const files = collectFilePaths(tree).filter((path) => path.endsWith('.md'))
@ -2278,6 +2347,9 @@ function App() {
throw err throw err
} }
}, },
addVault: () => {
void handleAddVault()
},
createFolder: async (parentPath: string = 'knowledge') => { createFolder: async (parentPath: string = 'knowledge') => {
try { try {
await window.ipc.invoke('workspace:mkdir', { await window.ipc.invoke('workspace:mkdir', {
@ -2289,6 +2361,17 @@ function App() {
throw err throw err
} }
}, },
unlinkVault: async (mountPath: string) => {
try {
await window.ipc.invoke('knowledge:removeVault', { nameOrMountPath: mountPath })
if (selectedPath && (selectedPath === mountPath || selectedPath.startsWith(`${mountPath}/`))) {
setSelectedPath(null)
}
} catch (err) {
console.error('Failed to unlink knowledge folder:', err)
throw err
}
},
openGraph: () => { openGraph: () => {
void navigateToView({ type: 'graph' }) void navigateToView({ type: 'graph' })
}, },
@ -2355,7 +2438,19 @@ function App() {
onOpenInNewTab: (path: string) => { onOpenInNewTab: (path: string) => {
openFileInNewTab(path) openFileInNewTab(path)
}, },
}), [tree, selectedPath, workspaceRoot, navigateToFile, navigateToView, openFileInNewTab, fileTabs, closeFileTab, removeEditorCacheForPath]) }), [
tree,
selectedPath,
workspaceRoot,
collectDirPaths,
navigateToFile,
navigateToView,
handleAddVault,
openFileInNewTab,
fileTabs,
closeFileTab,
removeEditorCacheForPath,
])
// Handler for when a voice note is created/updated // Handler for when a voice note is created/updated
const handleVoiceNoteCreated = useCallback(async (notePath: string) => { const handleVoiceNoteCreated = useCallback(async (notePath: string) => {
@ -3089,6 +3184,50 @@ function App() {
onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }} onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }}
/> />
</SidebarSectionProvider> </SidebarSectionProvider>
<Dialog
open={isAddVaultOpen}
onOpenChange={(open) => {
if (!open) {
setIsAddVaultOpen(false)
setPendingVaultPath(null)
setPendingVaultName('')
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Knowledge Folder</DialogTitle>
<DialogDescription>
Link an existing folder into knowledge.
</DialogDescription>
</DialogHeader>
<div className="space-y-3">
<div className="text-xs text-muted-foreground break-all">
{pendingVaultPath ?? 'No folder selected'}
</div>
<Input
value={pendingVaultName}
onChange={(event) => setPendingVaultName(event.target.value)}
placeholder="Folder name"
/>
</div>
<DialogFooter>
<Button
variant="ghost"
onClick={() => {
setIsAddVaultOpen(false)
setPendingVaultPath(null)
setPendingVaultName('')
}}
>
Cancel
</Button>
<Button onClick={handleConfirmAddVault} disabled={isAddingVault}>
{isAddingVault ? 'Adding...' : 'Add Folder'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Toaster /> <Toaster />
<OnboardingModal <OnboardingModal
open={showOnboarding} open={showOnboarding}

View file

@ -18,6 +18,7 @@ import {
Pencil, Pencil,
Plug, Plug,
LoaderIcon, LoaderIcon,
PlusCircle,
Settings, Settings,
Square, Square,
Trash2, Trash2,
@ -100,6 +101,8 @@ interface TreeNode {
type KnowledgeActions = { type KnowledgeActions = {
createNote: (parentPath?: string) => void createNote: (parentPath?: string) => void
createFolder: (parentPath?: string) => void createFolder: (parentPath?: string) => void
addVault: () => void
unlinkVault: (mountPath: string) => Promise<void>
openGraph: () => void openGraph: () => void
expandAll: () => void expandAll: () => void
collapseAll: () => void collapseAll: () => void
@ -824,6 +827,7 @@ function KnowledgeSection({
const quickActions = [ const quickActions = [
{ icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { icon: FilePlus, label: "New Note", action: () => actions.createNote() },
{ icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() }, { icon: FolderPlus, label: "New Folder", action: () => actions.createFolder() },
{ icon: PlusCircle, label: "Add Knowledge Folder", action: () => actions.addVault() },
{ icon: Network, label: "Graph View", action: () => actions.openGraph() }, { icon: Network, label: "Graph View", action: () => actions.openGraph() },
] ]
@ -911,6 +915,8 @@ function Tree({
const isDir = item.kind === 'dir' const isDir = item.kind === 'dir'
const isExpanded = expandedPaths.has(item.path) const isExpanded = expandedPaths.has(item.path)
const isSelected = selectedPath === item.path const isSelected = selectedPath === item.path
const [isSymlinkMount, setIsSymlinkMount] = useState(false)
const [symlinkChecked, setSymlinkChecked] = useState(false)
const [isRenaming, setIsRenaming] = useState(false) const [isRenaming, setIsRenaming] = useState(false)
const isSubmittingRef = React.useRef(false) const isSubmittingRef = React.useRef(false)
@ -925,6 +931,20 @@ function Tree({
setNewName(baseName) setNewName(baseName)
}, [baseName]) }, [baseName])
const ensureSymlinkStatus = async () => {
if (symlinkChecked || !isDir) return isSymlinkMount
try {
const stat = await window.ipc.invoke('workspace:stat', { path: item.path })
const isLink = Boolean(stat.isSymlink)
setIsSymlinkMount(isLink)
setSymlinkChecked(true)
return isLink
} catch {
setSymlinkChecked(true)
return false
}
}
const handleRename = async () => { const handleRename = async () => {
// Prevent double submission // Prevent double submission
if (isSubmittingRef.current) return if (isSubmittingRef.current) return
@ -933,6 +953,11 @@ function Tree({
const trimmedName = newName.trim() const trimmedName = newName.trim()
if (trimmedName && trimmedName !== baseName) { if (trimmedName && trimmedName !== baseName) {
try { try {
if (await ensureSymlinkStatus()) {
toast('Linked folders cannot be renamed here', 'error')
setIsRenaming(false)
return
}
await actions.rename(item.path, trimmedName, isDir) await actions.rename(item.path, trimmedName, isDir)
toast('Renamed successfully', 'success') toast('Renamed successfully', 'success')
} catch (err) { } catch (err) {
@ -947,11 +972,18 @@ function Tree({
} }
const handleDelete = async () => { const handleDelete = async () => {
let isLink = false
try { try {
await actions.remove(item.path) isLink = await ensureSymlinkStatus()
toast('Moved to trash', 'success') if (isLink) {
await actions.unlinkVault(item.path)
toast('Unlinked knowledge folder', 'success')
} else {
await actions.remove(item.path)
toast('Moved to trash', 'success')
}
} catch (err) { } catch (err) {
toast('Failed to delete', 'error') toast(isLink ? 'Failed to unlink' : 'Failed to delete', 'error')
} }
} }
@ -998,17 +1030,32 @@ function Tree({
Copy Path Copy Path
</ContextMenuItem> </ContextMenuItem>
<ContextMenuSeparator /> <ContextMenuSeparator />
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}> {!isSymlinkMount && (
<Pencil className="mr-2 size-4" /> <ContextMenuItem onClick={async () => {
Rename if (await ensureSymlinkStatus()) {
</ContextMenuItem> toast('Linked folders cannot be renamed here', 'error')
return
}
setNewName(baseName)
isSubmittingRef.current = false
setIsRenaming(true)
}}>
<Pencil className="mr-2 size-4" />
Rename
</ContextMenuItem>
)}
<ContextMenuItem variant="destructive" onClick={handleDelete}> <ContextMenuItem variant="destructive" onClick={handleDelete}>
<Trash2 className="mr-2 size-4" /> <Trash2 className="mr-2 size-4" />
Delete {isSymlinkMount ? 'Unlink Knowledge Folder' : 'Delete'}
</ContextMenuItem> </ContextMenuItem>
</ContextMenuContent> </ContextMenuContent>
) )
const handleContextMenuOpenChange = (open: boolean) => {
if (!open || symlinkChecked || !isDir) return
void ensureSymlinkStatus()
}
// Inline rename input // Inline rename input
if (isRenaming) { if (isRenaming) {
return ( return (
@ -1043,7 +1090,7 @@ function Tree({
if (!isDir) { if (!isDir) {
return ( return (
<ContextMenu> <ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem className="group/file-item"> <SidebarMenuItem className="group/file-item">
<SidebarMenuButton <SidebarMenuButton
@ -1068,7 +1115,7 @@ function Tree({
} }
return ( return (
<ContextMenu> <ContextMenu onOpenChange={handleContextMenuOpenChange}>
<ContextMenuTrigger asChild> <ContextMenuTrigger asChild>
<SidebarMenuItem> <SidebarMenuItem>
<Collapsible <Collapsible

View file

@ -582,6 +582,8 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
execute: async ({ pattern, cwd }: { pattern: string; cwd?: string }) => { execute: async ({ pattern, cwd }: { pattern: string; cwd?: string }) => {
try { try {
const searchDir = cwd ? path.join(WorkDir, cwd) : WorkDir; const searchDir = cwd ? path.join(WorkDir, cwd) : WorkDir;
const normalizedCwd = (cwd ?? '').split(path.sep).join('/');
const followSymlinks = normalizedCwd === 'knowledge' || normalizedCwd.startsWith('knowledge/');
// Ensure search directory is within workspace // Ensure search directory is within workspace
const resolvedSearchDir = path.resolve(searchDir); const resolvedSearchDir = path.resolve(searchDir);
@ -593,6 +595,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
cwd: searchDir, cwd: searchDir,
nodir: true, nodir: true,
ignore: ['node_modules/**', '.git/**'], ignore: ['node_modules/**', '.git/**'],
follow: followSymlinks,
}); });
return { return {
@ -630,6 +633,8 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
maxResults?: number; maxResults?: number;
}) => { }) => {
try { try {
const normalizedSearchPath = (searchPath ?? '').split(path.sep).join('/');
const followSymlinks = normalizedSearchPath === 'knowledge' || normalizedSearchPath.startsWith('knowledge/');
const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir; const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir;
// Ensure target path is within workspace // Ensure target path is within workspace
@ -641,6 +646,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// Try ripgrep first // Try ripgrep first
try { try {
const rgArgs = [ const rgArgs = [
followSymlinks ? '--follow' : '',
'--json', '--json',
'-e', JSON.stringify(pattern), '-e', JSON.stringify(pattern),
contextLines > 0 ? `-C ${contextLines}` : '', contextLines > 0 ? `-C ${contextLines}` : '',
@ -679,7 +685,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
} catch (rgError) { } catch (rgError) {
// Fallback to basic grep if ripgrep not available or failed // Fallback to basic grep if ripgrep not available or failed
const grepArgs = [ const grepArgs = [
'-rn', followSymlinks ? '-Rn' : '-rn',
fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '', fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '',
JSON.stringify(pattern), JSON.stringify(pattern),
JSON.stringify(resolvedTargetPath), JSON.stringify(resolvedTargetPath),

View file

@ -4,6 +4,7 @@ import type { IMcpConfigRepo } from "../mcp/repo.js";
import type { IAgentScheduleRepo } from "../agent-schedule/repo.js"; import type { IAgentScheduleRepo } from "../agent-schedule/repo.js";
import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { ensureSecurityConfig } from "./security.js"; import { ensureSecurityConfig } from "./security.js";
import { ensureKnowledgeVaultsConfig } from "./knowledge_vaults.js";
/** /**
* Initialize all config files at app startup. * Initialize all config files at app startup.
@ -22,5 +23,6 @@ export async function initConfigs(): Promise<void> {
agentScheduleRepo.ensureConfig(), agentScheduleRepo.ensureConfig(),
agentScheduleStateRepo.ensureState(), agentScheduleStateRepo.ensureState(),
ensureSecurityConfig(), ensureSecurityConfig(),
Promise.resolve(ensureKnowledgeVaultsConfig()),
]); ]);
} }

View file

@ -0,0 +1,159 @@
import fs from 'fs';
import path from 'path';
import { WorkDir } from './config.js';
export interface KnowledgeVault {
name: string;
path: string;
mountPath: string;
readOnly: boolean;
addedAt: string;
}
interface KnowledgeVaultsConfig {
vaults: KnowledgeVault[];
}
const CONFIG_FILE = path.join(WorkDir, 'config', 'knowledge_vaults.json');
const RESERVED_NAMES = new Set([
'People',
'Organizations',
'Projects',
'Topics',
'.assets',
'.trash',
]);
function normalizeVaultName(input: string): string {
return input
.trim()
.replace(/[\\/]/g, '-')
.replace(/\s+/g, ' ')
.trim();
}
function normalizeRelPath(relPath: string): string {
return relPath.split(path.sep).join('/');
}
function readConfig(): KnowledgeVaultsConfig {
try {
if (!fs.existsSync(CONFIG_FILE)) {
return { vaults: [] };
}
const raw = fs.readFileSync(CONFIG_FILE, 'utf-8');
const parsed = JSON.parse(raw) as KnowledgeVaultsConfig;
if (!parsed || !Array.isArray(parsed.vaults)) {
return { vaults: [] };
}
return {
vaults: parsed.vaults.filter((vault) => typeof vault?.mountPath === 'string'),
};
} catch {
return { vaults: [] };
}
}
function writeConfig(config: KnowledgeVaultsConfig): void {
const dir = path.dirname(CONFIG_FILE);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2));
}
export function ensureKnowledgeVaultsConfig(): void {
if (!fs.existsSync(CONFIG_FILE)) {
writeConfig({ vaults: [] });
}
}
export function listKnowledgeVaults(): KnowledgeVault[] {
return readConfig().vaults;
}
export function getKnowledgeVaultMountPaths(): string[] {
return readConfig().vaults.map((vault) => normalizeRelPath(vault.mountPath));
}
export function isKnowledgeVaultMountPath(relPath: string): boolean {
const normalized = normalizeRelPath(relPath);
return getKnowledgeVaultMountPaths().includes(normalized);
}
export function addKnowledgeVault({
name,
path: vaultPath,
readOnly = false,
}: {
name: string;
path: string;
readOnly?: boolean;
}): KnowledgeVault {
const normalizedName = normalizeVaultName(name);
if (!normalizedName) {
throw new Error('Vault name is required');
}
if (RESERVED_NAMES.has(normalizedName)) {
throw new Error('Vault name is reserved');
}
const stats = fs.statSync(vaultPath);
if (!stats.isDirectory()) {
throw new Error('Vault path must be a directory');
}
const config = readConfig();
const mountPath = `knowledge/${normalizedName}`;
if (config.vaults.some((vault) => vault.name.toLowerCase() === normalizedName.toLowerCase())) {
throw new Error('A vault with that name already exists');
}
if (config.vaults.some((vault) => vault.path === vaultPath)) {
throw new Error('That vault is already added');
}
const mountAbsPath = path.join(WorkDir, mountPath);
if (fs.existsSync(mountAbsPath)) {
throw new Error(`Mount path already exists: ${mountPath}`);
}
const linkType = process.platform === 'win32' ? 'junction' : 'dir';
fs.symlinkSync(vaultPath, mountAbsPath, linkType);
const vault: KnowledgeVault = {
name: normalizedName,
path: vaultPath,
mountPath,
readOnly: readOnly === true,
addedAt: new Date().toISOString(),
};
config.vaults.push(vault);
writeConfig(config);
return vault;
}
export function removeKnowledgeVault(nameOrMountPath: string): KnowledgeVault | null {
const config = readConfig();
const normalizedInput = nameOrMountPath.trim();
const mountPath = normalizedInput.startsWith('knowledge/')
? normalizedInput
: `knowledge/${normalizeVaultName(normalizedInput)}`;
const idx = config.vaults.findIndex((vault) => vault.mountPath === mountPath);
if (idx === -1) {
return null;
}
const [removed] = config.vaults.splice(idx, 1);
writeConfig(config);
const mountAbsPath = path.join(WorkDir, mountPath);
try {
const stats = fs.lstatSync(mountAbsPath);
if (stats.isSymbolicLink()) {
fs.unlinkSync(mountAbsPath);
}
} catch {
// Ignore missing or invalid mount path
}
return removed;
}

View file

@ -180,6 +180,7 @@ async function createNotesFromBatch(
message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`; message += `- Use the KNOWLEDGE BASE INDEX below to resolve entities - DO NOT grep/search for existing notes\n`;
message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`; message += `- Extract entities (people, organizations, projects, topics) from ALL files below\n`;
message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`; message += `- Create or update notes in "knowledge" directory (workspace-relative paths like "knowledge/People/Name.md")\n`;
message += `- For NEW notes, always write to the base knowledge folders (knowledge/People, knowledge/Organizations, knowledge/Projects, knowledge/Topics), not mounted vaults\n`;
message += `- If the same entity appears in multiple files, merge the information into a single note\n`; message += `- If the same entity appears in multiple files, merge the information into a single note\n`;
message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`; message += `- Use workspace tools to read existing notes (when you need full content) and write updates\n`;
message += `- Follow the note templates and guidelines in your instructions\n\n`; message += `- Follow the note templates and guidelines in your instructions\n\n`;

View file

@ -222,7 +222,19 @@ function getFolderType(filePath: string): string {
return 'root'; return 'root';
} }
// Return the first folder name const categoryFolders = new Set(['People', 'Organizations', 'Projects', 'Topics']);
// Standard layout: knowledge/<Category>/...
if (categoryFolders.has(parts[0])) {
return parts[0];
}
// Vault layout: knowledge/<VaultName>/<Category>/...
if (parts.length > 1 && categoryFolders.has(parts[1])) {
return parts[1];
}
// Return the first folder name for non-standard layouts
return parts[0]; return parts[0];
} }

View file

@ -23,6 +23,7 @@ export async function createWorkspaceWatcher(
const watcher = chokidar.watch(WorkDir, { const watcher = chokidar.watch(WorkDir, {
ignoreInitial: true, ignoreInitial: true,
followSymlinks: true,
awaitWriteFinish: { awaitWriteFinish: {
stabilityThreshold: 150, stabilityThreshold: 150,
pollInterval: 50, pollInterval: 50,
@ -74,4 +75,3 @@ export async function createWorkspaceWatcher(
return watcher; return watcher;
} }

View file

@ -5,6 +5,7 @@ import { workspace } from '@x/shared';
import { z } from 'zod'; import { z } from 'zod';
import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js'; import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js';
import { WorkDir } from '../config/config.js'; import { WorkDir } from '../config/config.js';
import { getKnowledgeVaultMountPaths } from '../config/knowledge_vaults.js';
// ============================================================================ // ============================================================================
// Path Utilities // Path Utilities
@ -82,6 +83,15 @@ export function statToSchema(stats: Stats, kind: z.infer<typeof workspace.NodeKi
}; };
} }
function normalizeRelPath(relPath: string): string {
return relPath.split(path.sep).join('/');
}
function isAllowedVaultSymlink(relPath: string, vaultMounts: Set<string>): boolean {
const normalized = normalizeRelPath(relPath);
return vaultMounts.has(normalized);
}
/** /**
* Ensure workspace root exists * Ensure workspace root exists
*/ */
@ -111,6 +121,17 @@ export async function exists(relPath: string): Promise<{ exists: boolean }> {
export async function stat(relPath: string): Promise<z.infer<typeof workspace.Stat>> { export async function stat(relPath: string): Promise<z.infer<typeof workspace.Stat>> {
const filePath = resolveWorkspacePath(relPath); const filePath = resolveWorkspacePath(relPath);
const stats = await fs.lstat(filePath); const stats = await fs.lstat(filePath);
if (stats.isSymbolicLink()) {
const targetStats = await fs.stat(filePath);
const kind = targetStats.isDirectory() ? 'dir' : 'file';
return {
kind,
size: targetStats.size,
mtimeMs: targetStats.mtimeMs,
ctimeMs: targetStats.ctimeMs,
isSymlink: true,
};
}
const kind = stats.isDirectory() ? 'dir' : 'file'; const kind = stats.isDirectory() ? 'dir' : 'file';
return statToSchema(stats, kind); return statToSchema(stats, kind);
} }
@ -121,6 +142,8 @@ export async function readdir(
): Promise<Array<z.infer<typeof workspace.DirEntry>>> { ): Promise<Array<z.infer<typeof workspace.DirEntry>>> {
const dirPath = resolveWorkspacePath(relPath); const dirPath = resolveWorkspacePath(relPath);
const entries: Array<z.infer<typeof workspace.DirEntry>> = []; const entries: Array<z.infer<typeof workspace.DirEntry>> = [];
const vaultMounts = new Set(getKnowledgeVaultMountPaths());
const visitedRealPaths = new Set<string>();
async function readDir(currentPath: string, currentRelPath: string): Promise<void> { async function readDir(currentPath: string, currentRelPath: string): Promise<void> {
const items = await fs.readdir(currentPath, { withFileTypes: true }); const items = await fs.readdir(currentPath, { withFileTypes: true });
@ -145,7 +168,42 @@ export async function readdir(
let itemKind: z.infer<typeof workspace.NodeKind>; let itemKind: z.infer<typeof workspace.NodeKind>;
let itemStat: { size: number; mtimeMs: number } | undefined; let itemStat: { size: number; mtimeMs: number } | undefined;
if (item.isDirectory()) { if (item.isSymbolicLink()) {
if (!isAllowedVaultSymlink(itemRelPath, vaultMounts)) {
continue;
}
let targetStats: Stats;
try {
targetStats = await fs.stat(itemPath);
} catch {
continue;
}
const targetIsDir = targetStats.isDirectory();
const targetIsFile = targetStats.isFile();
if (!targetIsDir && !targetIsFile) {
continue;
}
if (targetIsFile && opts?.allowedExtensions && opts.allowedExtensions.length > 0) {
const ext = path.extname(item.name);
if (!opts.allowedExtensions.includes(ext)) {
continue;
}
}
if (opts?.includeStats) {
itemStat = { size: targetStats.size, mtimeMs: targetStats.mtimeMs };
}
itemKind = targetIsDir ? 'dir' : 'file';
entries.push({ name: item.name, path: itemRelPath, kind: itemKind, stat: itemStat });
if (targetIsDir && opts?.recursive) {
const realPath = await fs.realpath(itemPath).catch(() => null);
if (realPath && !visitedRealPaths.has(realPath)) {
visitedRealPaths.add(realPath);
await readDir(itemPath, itemRelPath);
}
}
} else if (item.isDirectory()) {
itemKind = 'dir'; itemKind = 'dir';
if (opts?.includeStats) { if (opts?.includeStats) {
const stats = await fs.lstat(itemPath); const stats = await fs.lstat(itemPath);
@ -187,6 +245,8 @@ export async function readFile(
): Promise<z.infer<typeof workspace.ReadFileResult>> { ): Promise<z.infer<typeof workspace.ReadFileResult>> {
const filePath = resolveWorkspacePath(relPath); const filePath = resolveWorkspacePath(relPath);
const stats = await fs.lstat(filePath); const stats = await fs.lstat(filePath);
const isSymlink = stats.isSymbolicLink();
const targetStats = isSymlink ? await fs.stat(filePath) : stats;
let data: string; let data: string;
if (encoding === 'utf8') { if (encoding === 'utf8') {
@ -200,8 +260,14 @@ export async function readFile(
data = buffer.toString('base64'); data = buffer.toString('base64');
} }
const stat = statToSchema(stats, 'file'); const stat: z.infer<typeof workspace.Stat> = {
const etag = computeEtag(stats.size, stats.mtimeMs); kind: 'file',
size: targetStats.size,
mtimeMs: targetStats.mtimeMs,
ctimeMs: targetStats.ctimeMs,
isSymlink: isSymlink ? true : undefined,
};
const etag = computeEtag(targetStats.size, targetStats.mtimeMs);
return { return {
path: relPath, path: relPath,
@ -383,4 +449,4 @@ export async function remove(
} }
return { ok: true as const }; return { ok: true as const };
} }

View file

@ -11,6 +11,14 @@ import { ServiceEvent } from './service-events.js';
// Runtime Validation Schemas (Single Source of Truth) // Runtime Validation Schemas (Single Source of Truth)
// ============================================================================ // ============================================================================
const KnowledgeVault = z.object({
name: z.string(),
path: z.string(),
mountPath: z.string(),
readOnly: z.boolean(),
addedAt: z.string(),
});
const ipcSchemas = { const ipcSchemas = {
'app:getVersions': { 'app:getVersions': {
req: z.null(), req: z.null(),
@ -104,6 +112,36 @@ const ipcSchemas = {
req: WorkspaceChangeEvent, req: WorkspaceChangeEvent,
res: z.null(), res: z.null(),
}, },
'knowledge:listVaults': {
req: z.null(),
res: z.object({
vaults: z.array(KnowledgeVault),
}),
},
'knowledge:pickVaultDirectory': {
req: z.null(),
res: z.object({
path: z.string().nullable(),
}),
},
'knowledge:addVault': {
req: z.object({
path: z.string(),
name: z.string(),
readOnly: z.boolean().optional(),
}),
res: z.object({
vault: KnowledgeVault,
}),
},
'knowledge:removeVault': {
req: z.object({
nameOrMountPath: z.string(),
}),
res: z.object({
removed: KnowledgeVault.nullable(),
}),
},
'mcp:listTools': { 'mcp:listTools': {
req: z.object({ req: z.object({
serverName: z.string(), serverName: z.string(),