mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-19 18:35:18 +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
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue