diff --git a/apps/x/apps/main/src/ipc.ts b/apps/x/apps/main/src/ipc.ts index b0757881..b0867a46 100644 --- a/apps/x/apps/main/src/ipc.ts +++ b/apps/x/apps/main/src/ipc.ts @@ -1,4 +1,4 @@ -import { ipcMain, BrowserWindow, shell } from 'electron'; +import { ipcMain, BrowserWindow, shell, dialog } from 'electron'; import { ipc } from '@x/shared'; import path from 'node:path'; 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 { triggerSync as triggerGranolaSync } from '@x/core/dist/knowledge/granola/sync.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 { IAgentScheduleRepo } from '@x/core/dist/agent-schedule/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) => { 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) => { return mcpCore.listTools(args.serverName, args.cursor); }, diff --git a/apps/x/apps/renderer/src/App.tsx b/apps/x/apps/renderer/src/App.tsx index 9cc66879..f0e950e8 100644 --- a/apps/x/apps/renderer/src/App.tsx +++ b/apps/x/apps/renderer/src/App.tsx @@ -45,6 +45,15 @@ import { } from "@/components/ui/sidebar" import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip" 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 { OnboardingModal } from '@/components/onboarding-modal' import { SearchDialog } from '@/components/search-dialog' @@ -356,6 +365,10 @@ function App() { const [tree, setTree] = useState([]) const [expandedPaths, setExpandedPaths] = useState>(new Set()) const [recentWikiFiles, setRecentWikiFiles] = useState([]) + const [isAddVaultOpen, setIsAddVaultOpen] = useState(false) + const [pendingVaultPath, setPendingVaultPath] = useState(null) + const [pendingVaultName, setPendingVaultName] = useState('') + const [isAddingVault, setIsAddingVault] = useState(false) const [isGraphOpen, setIsGraphOpen] = useState(false) const [expandedFrom, setExpandedFrom] = useState<{ path: string | null; graph: boolean } | null>(null) const [graphData, setGraphData] = useState<{ nodes: GraphNode[]; edges: GraphEdge[] }>({ @@ -2186,6 +2199,62 @@ function App() { 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 const knowledgeFiles = React.useMemo(() => { const files = collectFilePaths(tree).filter((path) => path.endsWith('.md')) @@ -2278,6 +2347,9 @@ function App() { throw err } }, + addVault: () => { + void handleAddVault() + }, createFolder: async (parentPath: string = 'knowledge') => { try { await window.ipc.invoke('workspace:mkdir', { @@ -2289,6 +2361,17 @@ function App() { 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: () => { void navigateToView({ type: 'graph' }) }, @@ -2355,7 +2438,19 @@ function App() { onOpenInNewTab: (path: string) => { 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 const handleVoiceNoteCreated = useCallback(async (notePath: string) => { @@ -3089,6 +3184,50 @@ function App() { onSelectRun={(id) => { void navigateToView({ type: 'chat', runId: id }) }} /> + { + if (!open) { + setIsAddVaultOpen(false) + setPendingVaultPath(null) + setPendingVaultName('') + } + }} + > + + + Add Knowledge Folder + + Link an existing folder into knowledge. + + +
+
+ {pendingVaultPath ?? 'No folder selected'} +
+ setPendingVaultName(event.target.value)} + placeholder="Folder name" + /> +
+ + + + +
+
void createFolder: (parentPath?: string) => void + addVault: () => void + unlinkVault: (mountPath: string) => Promise openGraph: () => void expandAll: () => void collapseAll: () => void @@ -824,6 +827,7 @@ function KnowledgeSection({ const quickActions = [ { icon: FilePlus, label: "New Note", action: () => actions.createNote() }, { 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() }, ] @@ -911,6 +915,8 @@ function Tree({ const isDir = item.kind === 'dir' const isExpanded = expandedPaths.has(item.path) const isSelected = selectedPath === item.path + const [isSymlinkMount, setIsSymlinkMount] = useState(false) + const [symlinkChecked, setSymlinkChecked] = useState(false) const [isRenaming, setIsRenaming] = useState(false) const isSubmittingRef = React.useRef(false) @@ -925,6 +931,20 @@ function Tree({ setNewName(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 () => { // Prevent double submission if (isSubmittingRef.current) return @@ -933,6 +953,11 @@ function Tree({ const trimmedName = newName.trim() if (trimmedName && trimmedName !== baseName) { try { + if (await ensureSymlinkStatus()) { + toast('Linked folders cannot be renamed here', 'error') + setIsRenaming(false) + return + } await actions.rename(item.path, trimmedName, isDir) toast('Renamed successfully', 'success') } catch (err) { @@ -947,11 +972,18 @@ function Tree({ } const handleDelete = async () => { + let isLink = false try { - await actions.remove(item.path) - toast('Moved to trash', 'success') + isLink = await ensureSymlinkStatus() + 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) { - toast('Failed to delete', 'error') + toast(isLink ? 'Failed to unlink' : 'Failed to delete', 'error') } } @@ -998,17 +1030,32 @@ function Tree({ Copy Path - { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}> - - Rename - + {!isSymlinkMount && ( + { + if (await ensureSymlinkStatus()) { + toast('Linked folders cannot be renamed here', 'error') + return + } + setNewName(baseName) + isSubmittingRef.current = false + setIsRenaming(true) + }}> + + Rename + + )} - Delete + {isSymlinkMount ? 'Unlink Knowledge Folder' : 'Delete'} ) + const handleContextMenuOpenChange = (open: boolean) => { + if (!open || symlinkChecked || !isDir) return + void ensureSymlinkStatus() + } + // Inline rename input if (isRenaming) { return ( @@ -1043,7 +1090,7 @@ function Tree({ if (!isDir) { return ( - + + = { execute: async ({ pattern, cwd }: { pattern: string; cwd?: string }) => { try { 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 const resolvedSearchDir = path.resolve(searchDir); @@ -593,6 +595,7 @@ export const BuiltinTools: z.infer = { cwd: searchDir, nodir: true, ignore: ['node_modules/**', '.git/**'], + follow: followSymlinks, }); return { @@ -630,6 +633,8 @@ export const BuiltinTools: z.infer = { maxResults?: number; }) => { try { + const normalizedSearchPath = (searchPath ?? '').split(path.sep).join('/'); + const followSymlinks = normalizedSearchPath === 'knowledge' || normalizedSearchPath.startsWith('knowledge/'); const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir; // Ensure target path is within workspace @@ -641,6 +646,7 @@ export const BuiltinTools: z.infer = { // Try ripgrep first try { const rgArgs = [ + followSymlinks ? '--follow' : '', '--json', '-e', JSON.stringify(pattern), contextLines > 0 ? `-C ${contextLines}` : '', @@ -679,7 +685,7 @@ export const BuiltinTools: z.infer = { } catch (rgError) { // Fallback to basic grep if ripgrep not available or failed const grepArgs = [ - '-rn', + followSymlinks ? '-Rn' : '-rn', fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '', JSON.stringify(pattern), JSON.stringify(resolvedTargetPath), diff --git a/apps/x/packages/core/src/config/initConfigs.ts b/apps/x/packages/core/src/config/initConfigs.ts index adfb8b24..47f08fd7 100644 --- a/apps/x/packages/core/src/config/initConfigs.ts +++ b/apps/x/packages/core/src/config/initConfigs.ts @@ -4,6 +4,7 @@ import type { IMcpConfigRepo } from "../mcp/repo.js"; import type { IAgentScheduleRepo } from "../agent-schedule/repo.js"; import type { IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js"; import { ensureSecurityConfig } from "./security.js"; +import { ensureKnowledgeVaultsConfig } from "./knowledge_vaults.js"; /** * Initialize all config files at app startup. @@ -22,5 +23,6 @@ export async function initConfigs(): Promise { agentScheduleRepo.ensureConfig(), agentScheduleStateRepo.ensureState(), ensureSecurityConfig(), + Promise.resolve(ensureKnowledgeVaultsConfig()), ]); } diff --git a/apps/x/packages/core/src/config/knowledge_vaults.ts b/apps/x/packages/core/src/config/knowledge_vaults.ts new file mode 100644 index 00000000..dd3e5ea8 --- /dev/null +++ b/apps/x/packages/core/src/config/knowledge_vaults.ts @@ -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; +} diff --git a/apps/x/packages/core/src/knowledge/build_graph.ts b/apps/x/packages/core/src/knowledge/build_graph.ts index a1b7e135..592fa6ea 100644 --- a/apps/x/packages/core/src/knowledge/build_graph.ts +++ b/apps/x/packages/core/src/knowledge/build_graph.ts @@ -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 += `- 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 += `- 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 += `- 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`; diff --git a/apps/x/packages/core/src/knowledge/knowledge_index.ts b/apps/x/packages/core/src/knowledge/knowledge_index.ts index 2df46ca3..d0252a10 100644 --- a/apps/x/packages/core/src/knowledge/knowledge_index.ts +++ b/apps/x/packages/core/src/knowledge/knowledge_index.ts @@ -222,7 +222,19 @@ function getFolderType(filePath: string): string { return 'root'; } - // Return the first folder name + const categoryFolders = new Set(['People', 'Organizations', 'Projects', 'Topics']); + + // Standard layout: knowledge//... + if (categoryFolders.has(parts[0])) { + return parts[0]; + } + + // Vault layout: knowledge///... + if (parts.length > 1 && categoryFolders.has(parts[1])) { + return parts[1]; + } + + // Return the first folder name for non-standard layouts return parts[0]; } diff --git a/apps/x/packages/core/src/workspace/watcher.ts b/apps/x/packages/core/src/workspace/watcher.ts index 7d59331d..197f1a3b 100644 --- a/apps/x/packages/core/src/workspace/watcher.ts +++ b/apps/x/packages/core/src/workspace/watcher.ts @@ -23,6 +23,7 @@ export async function createWorkspaceWatcher( const watcher = chokidar.watch(WorkDir, { ignoreInitial: true, + followSymlinks: true, awaitWriteFinish: { stabilityThreshold: 150, pollInterval: 50, @@ -74,4 +75,3 @@ export async function createWorkspaceWatcher( return watcher; } - diff --git a/apps/x/packages/core/src/workspace/workspace.ts b/apps/x/packages/core/src/workspace/workspace.ts index 59910bdb..3849ff0f 100644 --- a/apps/x/packages/core/src/workspace/workspace.ts +++ b/apps/x/packages/core/src/workspace/workspace.ts @@ -5,6 +5,7 @@ import { workspace } from '@x/shared'; import { z } from 'zod'; import { RemoveOptions, WriteFileOptions, WriteFileResult } from 'packages/shared/dist/workspace.js'; import { WorkDir } from '../config/config.js'; +import { getKnowledgeVaultMountPaths } from '../config/knowledge_vaults.js'; // ============================================================================ // Path Utilities @@ -82,6 +83,15 @@ export function statToSchema(stats: Stats, kind: z.infer): boolean { + const normalized = normalizeRelPath(relPath); + return vaultMounts.has(normalized); +} + /** * Ensure workspace root exists */ @@ -111,6 +121,17 @@ export async function exists(relPath: string): Promise<{ exists: boolean }> { export async function stat(relPath: string): Promise> { const filePath = resolveWorkspacePath(relPath); 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'; return statToSchema(stats, kind); } @@ -121,6 +142,8 @@ export async function readdir( ): Promise>> { const dirPath = resolveWorkspacePath(relPath); const entries: Array> = []; + const vaultMounts = new Set(getKnowledgeVaultMountPaths()); + const visitedRealPaths = new Set(); async function readDir(currentPath: string, currentRelPath: string): Promise { const items = await fs.readdir(currentPath, { withFileTypes: true }); @@ -145,7 +168,42 @@ export async function readdir( let itemKind: z.infer; 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'; if (opts?.includeStats) { const stats = await fs.lstat(itemPath); @@ -187,6 +245,8 @@ export async function readFile( ): Promise> { const filePath = resolveWorkspacePath(relPath); const stats = await fs.lstat(filePath); + const isSymlink = stats.isSymbolicLink(); + const targetStats = isSymlink ? await fs.stat(filePath) : stats; let data: string; if (encoding === 'utf8') { @@ -200,8 +260,14 @@ export async function readFile( data = buffer.toString('base64'); } - const stat = statToSchema(stats, 'file'); - const etag = computeEtag(stats.size, stats.mtimeMs); + const stat: z.infer = { + kind: 'file', + size: targetStats.size, + mtimeMs: targetStats.mtimeMs, + ctimeMs: targetStats.ctimeMs, + isSymlink: isSymlink ? true : undefined, + }; + const etag = computeEtag(targetStats.size, targetStats.mtimeMs); return { path: relPath, @@ -383,4 +449,4 @@ export async function remove( } return { ok: true as const }; -} \ No newline at end of file +} diff --git a/apps/x/packages/shared/src/ipc.ts b/apps/x/packages/shared/src/ipc.ts index 1fa9b423..af28fec9 100644 --- a/apps/x/packages/shared/src/ipc.ts +++ b/apps/x/packages/shared/src/ipc.ts @@ -11,6 +11,14 @@ import { ServiceEvent } from './service-events.js'; // 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 = { 'app:getVersions': { req: z.null(), @@ -104,6 +112,36 @@ const ipcSchemas = { req: WorkspaceChangeEvent, 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': { req: z.object({ serverName: z.string(),