mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 00:16:29 +02:00
ability to symlink a vault
This commit is contained in:
parent
b238089e2d
commit
63cbe83e3e
11 changed files with 518 additions and 19 deletions
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<TreeNode[]>([])
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set())
|
||||
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 [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 }) }}
|
||||
/>
|
||||
</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 />
|
||||
<OnboardingModal
|
||||
open={showOnboarding}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
Pencil,
|
||||
Plug,
|
||||
LoaderIcon,
|
||||
PlusCircle,
|
||||
Settings,
|
||||
Square,
|
||||
Trash2,
|
||||
|
|
@ -100,6 +101,8 @@ interface TreeNode {
|
|||
type KnowledgeActions = {
|
||||
createNote: (parentPath?: string) => void
|
||||
createFolder: (parentPath?: string) => void
|
||||
addVault: () => void
|
||||
unlinkVault: (mountPath: string) => Promise<void>
|
||||
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
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => { setNewName(baseName); isSubmittingRef.current = false; setIsRenaming(true) }}>
|
||||
<Pencil className="mr-2 size-4" />
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
{!isSymlinkMount && (
|
||||
<ContextMenuItem onClick={async () => {
|
||||
if (await ensureSymlinkStatus()) {
|
||||
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}>
|
||||
<Trash2 className="mr-2 size-4" />
|
||||
Delete
|
||||
{isSymlinkMount ? 'Unlink Knowledge Folder' : 'Delete'}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
)
|
||||
|
||||
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 (
|
||||
<ContextMenu>
|
||||
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem className="group/file-item">
|
||||
<SidebarMenuButton
|
||||
|
|
@ -1068,7 +1115,7 @@ function Tree({
|
|||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenu onOpenChange={handleContextMenuOpenChange}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<SidebarMenuItem>
|
||||
<Collapsible
|
||||
|
|
|
|||
|
|
@ -582,6 +582,8 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
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<typeof BuiltinToolsSchema> = {
|
|||
cwd: searchDir,
|
||||
nodir: true,
|
||||
ignore: ['node_modules/**', '.git/**'],
|
||||
follow: followSymlinks,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -630,6 +633,8 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
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<typeof BuiltinToolsSchema> = {
|
|||
// 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<typeof BuiltinToolsSchema> = {
|
|||
} 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),
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
agentScheduleRepo.ensureConfig(),
|
||||
agentScheduleStateRepo.ensureState(),
|
||||
ensureSecurityConfig(),
|
||||
Promise.resolve(ensureKnowledgeVaultsConfig()),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
159
apps/x/packages/core/src/config/knowledge_vaults.ts
Normal file
159
apps/x/packages/core/src/config/knowledge_vaults.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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`;
|
||||
|
|
|
|||
|
|
@ -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/<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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
*/
|
||||
|
|
@ -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>> {
|
||||
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<Array<z.infer<typeof workspace.DirEntry>>> {
|
||||
const dirPath = resolveWorkspacePath(relPath);
|
||||
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> {
|
||||
const items = await fs.readdir(currentPath, { withFileTypes: true });
|
||||
|
|
@ -145,7 +168,42 @@ export async function readdir(
|
|||
let itemKind: z.infer<typeof workspace.NodeKind>;
|
||||
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<z.infer<typeof workspace.ReadFileResult>> {
|
||||
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<typeof workspace.Stat> = {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue