Refactor builtin file tools beyond workspace scope

Replace workspace-scoped builtin file tools with general-purpose file-* tools that accept relative, absolute, and ~/ paths. Relative paths still resolve against the configured workdir.

File operations within the workdir are auto-approved. File operations outside the workdir now emit file permission metadata and require user approval, with support for once, session, and persistent grants.

Add a shared filesystem layer for text-focused read/write/edit/list/search operations, including binary-file safeguards for text reads. parseFile and LLMParse continue to read file buffers for document/image parsing.

Update copilot prompts, background/live-note agents, knowledge workflows, and renderer labels/UI to use the new file-* tool surface and permission details.

Add package-local Vitest setup for @x/core with colocated filesystem unit tests covering path resolution, canonical permission paths, binary detection, read/write/edit behavior, glob, and grep.
This commit is contained in:
Ramnique Singh 2026-05-25 16:21:40 +05:30
parent f1d3b7b825
commit 31e35e00b8
41 changed files with 1777 additions and 615 deletions

View file

@ -70,7 +70,7 @@ The `once` trigger from the prior model has been **dropped** — it didn't fit t
Two paths, both producing identical on-disk YAML: Two paths, both producing identical on-disk YAML:
1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick. 1. **Hand-written** — type the `live:` block directly into the note's frontmatter. The scheduler picks it up on its next 15-second tick.
2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `workspace-edit`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet. 2. **Sidebar chat** — mention a note (or have it attached) and ask Copilot for something dynamic. Copilot is tuned to recognize a wide range of phrasings beyond "live" or "track" (see "Prompts Catalog → Copilot trigger paragraph"); it loads the `live-note` skill, edits the frontmatter via `file-editText`, then **runs the agent once by default** so the user immediately sees content. The auto-run is skipped only when the user explicitly says not to run yet.
When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block. When the note is **already live** and the user asks to track something new, Copilot extends the existing `live.objective` in natural-language prose. It does not create a second `live:` block.
@ -92,8 +92,8 @@ When a trigger fires, the live-note agent receives a short message:
- For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it"). - For event runs only: the matching `eventMatchCriteria` text and the event payload, with a Pass-2 decision directive ("only edit if the event genuinely warrants it").
The agent's system prompt tells it to: The agent's system prompt tells it to:
1. Call `workspace-readFile` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh). 1. Call `file-readText` to read the current note (the body may be long; no body snapshot is passed in the message — fetch fresh).
2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites. 2. Make small, **patch-style** edits with `file-editText` — change one region, re-read, change the next region — rather than one-shot rewrites.
3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first. 3. Follow default body structure unless the objective overrides: H1 stays the title; a 1-3 sentence rolling summary at the top; H2 sub-topic sections below, freshest first.
4. Never modify YAML frontmatter — that's owned by the user and the runtime. 4. Never modify YAML frontmatter — that's owned by the user and the runtime.
5. End with a 1-2 sentence summary stored as `lastRunSummary`. 5. End with a 1-2 sentence summary stored as `lastRunSummary`.
@ -115,7 +115,7 @@ Backend (main process)
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent ├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
└─ Builtin tool │ │ └─ Builtin tool │ │
run-live-note-agent ────┘ ▼ run-live-note-agent ────┘ ▼
workspace-readFile / -edit file-readText / -edit
body region(s) rewritten on disk body region(s) rewritten on disk
@ -249,7 +249,7 @@ The contract (defined in the run-agent system prompt — `packages/core/src/know
- Then content organized by sub-topic under H2 headings, freshest/most-important first. - Then content organized by sub-topic under H2 headings, freshest/most-important first.
- Tightness over decoration. - Tightness over decoration.
- **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly. - **Override** — if the objective specifies a different layout (e.g. "show the top 5 stories at the top, with a one-paragraph summary above them"), follow that exactly.
- **Patch-style updates** — make small, incremental `workspace-edit` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable. - **Patch-style updates** — make small, incremental `file-editText` calls (read → edit one region → re-read → next), not one-shot whole-body rewrites. This preserves user-added content the agent didn't account for and keeps diffs reviewable.
- **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1. - **Boundaries**: never modify the frontmatter; the agent is the sole writer of the body below the H1.
--- ---
@ -316,7 +316,7 @@ Every LLM-facing prompt in the feature, with file pointers. After any edit: `cd
- **Purpose**: the user message seeded into each agent run. - **Purpose**: the user message seeded into each agent run.
- **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`). - **File**: `packages/core/src/knowledge/live-note/runner.ts` (`buildMessage`).
- **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`. - **Inputs**: `filePath` (presented as `knowledge/${filePath}` in the message), `live.objective`, `live.triggers?.eventMatchCriteria` (only on event runs), `trigger`, optional `context`, plus `localNow` / `tz`.
- **Behavior**: tells the agent to call `workspace-readFile` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits. - **Behavior**: tells the agent to call `file-readText` itself (no body snapshot included, since the body can be long and may have been edited by a concurrent run) and to make patch-style edits.
Three branches by `trigger`: Three branches by `trigger`:
- **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills. - **`manual`** — base message. If `context` is passed, it's appended as a `**Context:**` section. The `run-live-note-agent` tool uses this path for both plain refreshes and context-biased backfills.

View file

@ -5677,6 +5677,7 @@ function App() {
{rendered} {rendered}
<PermissionRequest <PermissionRequest
toolCall={permRequest.toolCall} toolCall={permRequest.toolCall}
permission={permRequest.permission}
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')} onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')} onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')} onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}

View file

@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react"; import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { ToolCallPart } from "@x/shared/dist/message.js"; import { ToolCallPart } from "@x/shared/dist/message.js";
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
import z from "zod"; import z from "zod";
export type PermissionRequestProps = ComponentProps<"div"> & { export type PermissionRequestProps = ComponentProps<"div"> & {
@ -22,6 +23,15 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
onDeny?: () => void; onDeny?: () => void;
isProcessing?: boolean; isProcessing?: boolean;
response?: 'approve' | 'deny' | null; response?: 'approve' | 'deny' | null;
permission?: z.infer<typeof ToolPermissionMetadata>;
};
const fileActionLabels: Record<string, string> = {
read: "Read file",
list: "List folder",
search: "Search files",
write: "Write files",
delete: "Delete path",
}; };
export const PermissionRequest = ({ export const PermissionRequest = ({
@ -33,14 +43,16 @@ export const PermissionRequest = ({
onDeny, onDeny,
isProcessing = false, isProcessing = false,
response = null, response = null,
permission,
...props ...props
}: PermissionRequestProps) => { }: PermissionRequestProps) => {
// Extract command from arguments if it's executeCommand // Extract command from arguments if it's executeCommand
const command = toolCall.toolName === "executeCommand" const command = permission?.kind === "command" || toolCall.toolName === "executeCommand"
? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments ? (typeof toolCall.arguments === "object" && toolCall.arguments !== null && "command" in toolCall.arguments
? String(toolCall.arguments.command) ? String(toolCall.arguments.command)
: JSON.stringify(toolCall.arguments)) : JSON.stringify(toolCall.arguments))
: null; : null;
const filePermission = permission?.kind === "file" ? permission : null;
const isResponded = response !== null; const isResponded = response !== null;
const isApproved = response === 'approve'; const isApproved = response === 'approve';
@ -113,7 +125,35 @@ export const PermissionRequest = ({
</pre> </pre>
</div> </div>
)} )}
{!command && toolCall.arguments && ( {filePermission && (
<div className="rounded-md border bg-background/50 p-3 mt-3 space-y-3">
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Action
</p>
<p className="text-xs font-medium text-foreground">
{fileActionLabels[filePermission.operation] ?? filePermission.operation}
</p>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Path{filePermission.paths.length === 1 ? "" : "s"}
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{filePermission.paths.join("\n")}
</pre>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Approval Scope
</p>
<pre className="whitespace-pre-wrap text-xs font-mono text-foreground break-all">
{filePermission.pathPrefix}
</pre>
</div>
</div>
)}
{!command && !filePermission && toolCall.arguments && (
<div className="rounded-md border bg-background/50 p-3 mt-3"> <div className="rounded-md border bg-background/50 p-3 mt-3">
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide"> <p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
Arguments Arguments
@ -133,12 +173,12 @@ export const PermissionRequest = ({
size="sm" size="sm"
onClick={onApprove} onClick={onApprove}
disabled={isProcessing} disabled={isProcessing}
className={cn("flex-1", command && "rounded-r-none")} className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
> >
<CheckIcon className="size-4" /> <CheckIcon className="size-4" />
Approve Approve
</Button> </Button>
{command && ( {(command || filePermission) && (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button

View file

@ -479,19 +479,19 @@ export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardD
// Human-friendly display names for builtin tools // Human-friendly display names for builtin tools
const TOOL_DISPLAY_NAMES: Record<string, string> = { const TOOL_DISPLAY_NAMES: Record<string, string> = {
'workspace-readFile': 'Reading file', 'file-readText': 'Reading file',
'workspace-writeFile': 'Writing file', 'file-writeText': 'Writing file',
'workspace-edit': 'Editing file', 'file-editText': 'Editing file',
'workspace-readdir': 'Reading directory', 'file-list': 'Reading directory',
'workspace-exists': 'Checking path', 'file-exists': 'Checking path',
'workspace-stat': 'Getting file info', 'file-stat': 'Getting file info',
'workspace-glob': 'Finding files', 'file-glob': 'Finding files',
'workspace-grep': 'Searching files', 'file-grep': 'Searching files',
'workspace-mkdir': 'Creating directory', 'file-mkdir': 'Creating directory',
'workspace-rename': 'Renaming', 'file-rename': 'Renaming',
'workspace-copy': 'Copying file', 'file-copy': 'Copying file',
'workspace-remove': 'Removing', 'file-remove': 'Removing',
'workspace-getRoot': 'Getting workspace root', 'file-getRoot': 'Getting file root',
'loadSkill': 'Loading skill', 'loadSkill': 'Loading skill',
'parseFile': 'Parsing file', 'parseFile': 'Parsing file',
'LLMParse': 'Extracting content', 'LLMParse': 'Extracting content',

View file

@ -5,8 +5,10 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"scripts": { "scripts": {
"build": "rm -rf dist && tsc", "build": "rm -rf dist && tsc -p tsconfig.build.json",
"dev": "tsc -w" "dev": "tsc -w -p tsconfig.build.json",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@ai-sdk/anthropic": "^2.0.63", "@ai-sdk/anthropic": "^2.0.63",
@ -29,8 +31,8 @@
"express": "^5.2.1", "express": "^5.2.1",
"glob": "^13.0.0", "glob": "^13.0.0",
"google-auth-library": "^10.5.0", "google-auth-library": "^10.5.0",
"isomorphic-git": "^1.29.0",
"googleapis": "^169.0.0", "googleapis": "^169.0.0",
"isomorphic-git": "^1.29.0",
"mammoth": "^1.11.0", "mammoth": "^1.11.0",
"node-html-markdown": "^2.0.0", "node-html-markdown": "^2.0.0",
"ollama-ai-provider-v2": "^1.5.4", "ollama-ai-provider-v2": "^1.5.4",
@ -48,6 +50,7 @@
"@types/express": "^5.0.6", "@types/express": "^5.0.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.3",
"@types/papaparse": "^5.5.2", "@types/papaparse": "^5.5.2",
"@types/pdf-parse": "^1.1.5" "@types/pdf-parse": "^1.1.5",
"vitest": "catalog:"
} }
} }

View file

@ -8,12 +8,14 @@ import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"
import { z } from "zod"; import { z } from "zod";
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js"; import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.js";
import { execTool } from "../application/lib/exec-tool.js"; import { execTool } from "../application/lib/exec-tool.js";
import { AskHumanRequestEvent, RunEvent, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js"; import { AskHumanRequestEvent, RunEvent, ToolPermissionMetadata, ToolPermissionRequestEvent } from "@x/shared/dist/runs.js";
import { BuiltinTools } from "../application/lib/builtin-tools.js"; import { BuiltinTools } from "../application/lib/builtin-tools.js";
import { buildCopilotAgent } from "../application/assistant/agent.js"; import { buildCopilotAgent } from "../application/assistant/agent.js";
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js"; import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
import { buildBackgroundTaskAgent } from "../background-tasks/agent.js"; import { buildBackgroundTaskAgent } from "../background-tasks/agent.js";
import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js"; import { isBlocked, extractCommandNames } from "../application/lib/command-executor.js";
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
import container from "../di/container.js"; import container from "../di/container.js";
import { IModelConfigRepo } from "../models/repo.js"; import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js"; import { createProvider } from "../models/models.js";
@ -38,6 +40,131 @@ import { getRaw as getAgentNotesAgentRaw } from "../knowledge/agent_notes_agent.
const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes'); const AGENT_NOTES_DIR = path.join(WorkDir, 'knowledge', 'Agent Notes');
const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json'); const WORKDIR_CONFIG_FILE = path.join(WorkDir, 'config', 'workdir.json');
type ToolPermissionMetadataValue = z.infer<typeof ToolPermissionMetadata>;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function fileGrantCoversPath(grant: FileAccessGrant, operation: FileAccessOperation, resolvedPath: string): boolean {
return grant.operation === operation && isPathInside(path.resolve(grant.pathPrefix), path.resolve(resolvedPath));
}
function commonPathPrefix(paths: string[]): string {
if (!paths.length) return path.resolve(WorkDir);
const split = paths.map(p => path.resolve(p).split(path.sep).filter(Boolean));
const first = split[0];
const common: string[] = [];
for (let i = 0; i < first.length; i++) {
if (split.every(parts => parts[i] === first[i])) {
common.push(first[i]);
} else {
break;
}
}
const prefix = `${path.sep}${common.join(path.sep)}`;
return prefix === path.sep ? prefix : path.resolve(prefix);
}
function grantPrefixForTool(toolName: string, resolvedPaths: string[]): string {
if (toolName === 'file-list' || toolName === 'file-glob' || toolName === 'file-grep' || toolName === 'file-mkdir') {
return commonPathPrefix(resolvedPaths);
}
const parentPaths = resolvedPaths.map(p => path.dirname(p));
return commonPathPrefix(parentPaths);
}
function filePermissionTargets(toolName: string, args: Record<string, unknown>): { operation: FileAccessOperation; paths: string[] } | null {
const pathArg = typeof args.path === 'string' ? args.path : undefined;
switch (toolName) {
case 'file-readText':
case 'parseFile':
case 'LLMParse':
case 'file-exists':
case 'file-stat':
return pathArg ? { operation: 'read', paths: [pathArg] } : null;
case 'file-list':
return pathArg ? { operation: 'list', paths: [pathArg || '.'] } : null;
case 'file-glob':
return { operation: 'search', paths: [typeof args.cwd === 'string' && args.cwd ? args.cwd : '.'] };
case 'file-grep':
return { operation: 'search', paths: [typeof args.searchPath === 'string' && args.searchPath ? args.searchPath : '.'] };
case 'file-writeText':
case 'file-editText':
case 'file-mkdir':
return pathArg ? { operation: 'write', paths: [pathArg] } : null;
case 'file-copy':
case 'file-rename': {
const from = typeof args.from === 'string' ? args.from : undefined;
const to = typeof args.to === 'string' ? args.to : undefined;
return from && to ? { operation: 'write', paths: [from, to] } : null;
}
case 'file-remove':
return pathArg ? { operation: 'delete', paths: [pathArg] } : null;
default:
return null;
}
}
async function getToolPermissionMetadata(
toolCall: z.infer<typeof ToolCallPart>,
underlyingTool: z.infer<typeof ToolAttachment>,
sessionAllowedCommands: Set<string>,
sessionAllowedFileAccess: FileAccessGrant[],
): Promise<ToolPermissionMetadataValue | null> {
if (underlyingTool.type !== 'builtin') {
return null;
}
if (underlyingTool.name === 'executeCommand') {
const args = toolCall.arguments;
if (!args || typeof args !== 'object' || !('command' in args)) {
return null;
}
const command = String((args as { command: unknown }).command);
if (!isBlocked(command, sessionAllowedCommands)) {
return null;
}
return {
kind: 'command',
commandNames: extractCommandNames(command),
};
}
const args = toolCall.arguments && typeof toolCall.arguments === 'object'
? toolCall.arguments as Record<string, unknown>
: {};
const targets = filePermissionTargets(underlyingTool.name, args);
if (!targets) {
return null;
}
const resolvedTargets = await Promise.all(targets.paths.map(p => resolveFilePathForPermission(p)));
const outsideWorkspacePaths = resolvedTargets
.filter(target => !target.isInsideWorkspace)
.map(target => target.canonicalPath);
if (!outsideWorkspacePaths.length) {
return null;
}
const persistentGrants = getFileAccessAllowList();
const allGrants = [...persistentGrants, ...sessionAllowedFileAccess];
const uncovered = outsideWorkspacePaths.filter(resolvedPath =>
!allGrants.some(grant => fileGrantCoversPath(grant, targets.operation, resolvedPath))
);
if (!uncovered.length) {
return null;
}
return {
kind: 'file',
operation: targets.operation,
paths: uncovered,
pathPrefix: grantPrefixForTool(underlyingTool.name, uncovered),
};
}
function loadUserWorkDir(): string | null { function loadUserWorkDir(): string | null {
try { try {
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null; if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
@ -95,7 +222,7 @@ function loadAgentNotesContext(): string | null {
} catch { /* ignore */ } } catch { /* ignore */ }
if (otherFiles.length > 0) { if (otherFiles.length > 0) {
sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using workspace-readFile. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`); sections.push(`## More Specific Preferences\nFor more specific preferences, you can read these files using file-readText. Only read them when relevant to the current task.\n\n${otherFiles.map(f => `- knowledge/Agent Notes/${f}`).join('\n')}`);
} }
if (sections.length === 0) return null; if (sections.length === 0) return null;
@ -683,6 +810,7 @@ export class AgentState {
allowedToolCallIds: Record<string, true> = {}; allowedToolCallIds: Record<string, true> = {};
deniedToolCallIds: Record<string, true> = {}; deniedToolCallIds: Record<string, true> = {};
sessionAllowedCommands: Set<string> = new Set(); sessionAllowedCommands: Set<string> = new Set();
sessionAllowedFileAccess: FileAccessGrant[] = [];
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] { getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
const response: z.infer<typeof ToolPermissionRequestEvent>[] = []; const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
@ -828,6 +956,15 @@ export class AgentState {
switch (event.response) { switch (event.response) {
case "approve": case "approve":
this.allowedToolCallIds[event.toolCallId] = true; this.allowedToolCallIds[event.toolCallId] = true;
{
const permissionRequest = this.pendingToolPermissionRequests[event.toolCallId];
if (event.scope === "session" && permissionRequest?.permission?.kind === "file") {
this.sessionAllowedFileAccess.push({
operation: permissionRequest.permission.operation,
pathPrefix: permissionRequest.permission.pathPrefix,
});
}
}
// For session scope, extract command names and add to session allowlist // For session scope, extract command names and add to session allowlist
if (event.scope === "session") { if (event.scope === "session") {
const toolCall = this.toolCallIdMap[event.toolCallId]; const toolCall = this.toolCallIdMap[event.toolCallId];
@ -1135,10 +1272,10 @@ Treat this as the **default location** for file operations whenever the user ref
- "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location. - "save this", "export it", "write that to a file" write the output into the work directory unless the user names another location.
- "open the file I was just working on", "the doc from earlier" assume the work directory first. - "open the file I was just working on", "the doc from earlier" assume the work directory first.
Use absolute paths rooted at this directory. On macOS/Linux call \`executeCommand\` with POSIX commands (\`ls\`, \`cat\`, \`cp\`, etc.) operating on \`${userWorkDir}\`. On Windows use the equivalent cmd syntax. For reading file contents use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first. Use absolute paths rooted at this directory with the \`file-*\` tools. For example, list with \`file-list({ path: "${userWorkDir}" })\`, read text with \`file-readText\`, and write text with \`file-writeText\`. For PDFs, Office docs, images, scanned docs, and other non-text files, use \`parseFile\` or \`LLMParse\` with the absolute path; you do NOT need to copy the file into the workspace first.
**Exceptions these ALWAYS take precedence over the work directory default:** **Exceptions these ALWAYS take precedence over the work directory default:**
1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use the workspace tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory. 1. **Knowledge base questions.** If the user asks about anything in the knowledge graph (notes, people, organizations, projects, topics) or paths starting with \`knowledge/\`, use file tools against \`knowledge/\` as documented above. Do NOT redirect those into the work directory.
2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request. 2. **Explicit paths.** If the user names a different directory or gives an absolute/relative path (e.g. "in ~/Downloads", "from /tmp/foo", "the Desktop"), honor that path exactly and ignore the work-directory default for that request.
3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory. 3. **Workspace-specific operations.** Anything that obviously belongs in the Rowboat workspace (config files, MCP servers, agent schedules, etc.) stays in the workspace, not the work directory.
@ -1236,17 +1373,21 @@ Do not announce the work directory unless it's relevant. Just use it.`;
subflow: [], subflow: [],
}); });
} }
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") { const permission = await getToolPermissionMetadata(
// if command is blocked, then seek permission part,
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) { underlyingTool,
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId); state.sessionAllowedCommands,
yield* processEvent({ state.sessionAllowedFileAccess,
runId, );
type: "tool-permission-request", if (permission) {
toolCall: part, loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
subflow: [], yield* processEvent({
}); runId,
} type: "tool-permission-request",
toolCall: part,
permission,
subflow: [],
});
} }
if (underlyingTool.type === "agent" && underlyingTool.name) { if (underlyingTool.type === "agent" && underlyingTool.name) {
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId); loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);

View file

@ -140,39 +140,39 @@ Users can interact with the knowledge graph through you, open it directly in Obs
**CRITICAL PATH REQUIREMENT:** **CRITICAL PATH REQUIREMENT:**
- The workspace root is the configured workdir - The workspace root is the configured workdir
- The knowledge base is in the \`knowledge/\` subfolder - The knowledge base is in the \`knowledge/\` subfolder
- When using workspace tools, ALWAYS include \`knowledge/\` in the path - When searching knowledge, ALWAYS include \`knowledge/\` in the search path
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root - **WRONG:** \`file-grep({ pattern: "John", searchPath: "" })\` or \`searchPath: "."\` or any absolute path to the workspace root
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\` - **CORRECT:** \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
Use the builtin workspace tools to search and read the knowledge base: Use the builtin file tools to search and read the knowledge base:
**Finding notes:** **Finding notes:**
\`\`\` \`\`\`
# List all people notes # List all people notes
workspace-readdir("knowledge/People") file-list("knowledge/People")
# Search for a person by name - MUST include knowledge/ in path # Search for a person by name - MUST include knowledge/ in path
workspace-grep({ pattern: "Sarah Chen", path: "knowledge/" }) file-grep({ pattern: "Sarah Chen", searchPath: "knowledge/" })
# Find notes mentioning a company - MUST include knowledge/ in path # Find notes mentioning a company - MUST include knowledge/ in path
workspace-grep({ pattern: "Acme Corp", path: "knowledge/" }) file-grep({ pattern: "Acme Corp", searchPath: "knowledge/" })
\`\`\` \`\`\`
**Reading notes:** **Reading notes:**
\`\`\` \`\`\`
# Read a specific person's note # Read a specific person's note
workspace-readFile("knowledge/People/Sarah Chen.md") file-readText("knowledge/People/Sarah Chen.md")
# Read an organization note # Read an organization note
workspace-readFile("knowledge/Organizations/Acme Corp.md") file-readText("knowledge/Organizations/Acme Corp.md")
\`\`\` \`\`\`
**When a user mentions someone by name:** **When a user mentions someone by name:**
1. First, search for them: \`workspace-grep({ pattern: "John", path: "knowledge/" })\` 1. First, search for them: \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
2. Read their note to get full context: \`workspace-readFile("knowledge/People/John Smith.md")\` 2. Read their note to get full context: \`file-readText("knowledge/People/John Smith.md")\`
3. Use the context (role, organization, past interactions, commitments) in your response 3. Use the context (role, organization, past interactions, commitments) in your response
**NEVER use an empty path or root path. ALWAYS set path to \`knowledge/\` or a subfolder like \`knowledge/People/\`.** **NEVER use an empty search path or root path for knowledge lookup. ALWAYS set searchPath to \`knowledge/\` or a subfolder like \`knowledge/People/\`.**
## When to Access the Knowledge Graph ## When to Access the Knowledge Graph
@ -237,29 +237,23 @@ ${toolPriority}
${runtimeContextPrompt} ${runtimeContextPrompt}
## Workspace Access & Scope ## File Access & Scope
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval. - Use builtin file tools (\`file-readText\`, \`file-writeText\`, \`file-editText\`, etc.) for normal file work anywhere on the user's machine.
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands. - Relative paths resolve against the Rowboat workspace root. Use paths like \`knowledge/People/Ada.md\` for knowledge files.
- **IMPORTANT:** Do NOT access files outside the workspace root unless the user explicitly asks you to (e.g., "organize my Desktop", "find a file in Downloads"). - Use absolute paths or \`~/...\` paths when the user refers to Desktop, Downloads, Documents, the injected work directory, or any other location outside the Rowboat workspace.
- File operations inside the Rowboat workspace normally run without approval. File operations outside the workspace may trigger a permission prompt; this is expected.
**CRITICAL - When the user asks you to work with files outside the workspace root:** - Do NOT use \`executeCommand\` just to read, write, edit, list, search, move, copy, or remove files. Use file tools and let the permission system handle access.
- Follow the detected runtime platform above for shell syntax and filesystem path style. - Do NOT read binary files as text. Use \`parseFile\` or \`LLMParse\` for PDFs, Office docs, images, scanned docs, presentations, and other non-text formats.
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS). - Do NOT access files outside the workspace unless the user explicitly asks you to or the current task clearly requires it.
- On Windows, use cmd-compatible commands and Windows paths (e.g., \`C:\\Users\\<name>\\Desktop\`).
- You CAN access the user's full filesystem via \`executeCommand\` - there is no sandbox restriction on paths.
- NEVER say "I can only run commands inside the workspace root" or "I don't have access to your Desktop" - just use \`executeCommand\`.
- NEVER offer commands for the user to run manually - run them yourself with \`executeCommand\`.
- NEVER say "I'll run shell commands equivalent to..." - just describe what you'll do in plain language (e.g., "I'll move 12 screenshots to a new Screenshots folder").
- NEVER ask what OS the user is on if runtime platform is already available.
- Load the \`organize-files\` skill for guidance on file organization tasks. - Load the \`organize-files\` skill for guidance on file organization tasks.
## Builtin Tools vs Shell Commands ## Builtin Tools vs Shell Commands
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval: **IMPORTANT**: Rowboat provides builtin tools:
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations - \`file-readText\`, \`file-writeText\`, \`file-editText\`, \`file-remove\` - File operations
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search - \`file-list\`, \`file-exists\`, \`file-stat\`, \`file-glob\`, \`file-grep\` - Directory exploration and file search
- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-copy\` - File/directory management - \`file-mkdir\`, \`file-rename\`, \`file-copy\` - File/directory management
- \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute paths or workspace-relative paths — no need to copy files into the workspace first. Best for well-structured digital documents. - \`parseFile\` - Parse and extract text from files (PDF, Excel, CSV, Word .docx). Accepts absolute, ~/..., or relative paths — no need to copy files into the workspace first. Best for well-structured digital documents.
- \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images. - \`LLMParse\` - Send a file to the configured LLM as a multimodal attachment to extract content as markdown. Use this instead of \`parseFile\` for scanned PDFs, images with text, complex layouts, presentations, or any format where local parsing falls short. Supports documents and images.
- \`analyzeAgent\` - Agent analysis - \`analyzeAgent\` - Agent analysis
- \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution - \`addMcpServer\`, \`listMcpServers\`, \`listMcpTools\`, \`executeMcpTool\` - MCP server management and execution
@ -270,23 +264,21 @@ ${slackToolsLine}- \`web-search\` - Search the web. Returns rich results with fu
- \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations. - \`save-to-memory\` - Save observations about the user to the agent memory system. Use this proactively during conversations.
${composioToolsLine} ${composioToolsLine}
**Prefer these tools whenever possible** they work instantly with zero friction. For file operations inside the workspace root, always use these instead of \`executeCommand\`. **Prefer these tools whenever possible.** For file operations anywhere on the machine, use file tools instead of \`executeCommand\`.
**Shell commands via \`executeCommand\`:** **Shell commands via \`executeCommand\`:**
- You can run ANY shell command via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately. - You can run shell commands via \`executeCommand\`. Some commands are pre-approved in \`config/security.json\` within the workspace root and run immediately.
- Commands not on the pre-approved list will trigger a one-time approval prompt for the user this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need. - Commands not on the pre-approved list will trigger a one-time approval prompt for the user this is fine and expected, just a minor friction. Do NOT let this stop you from running commands you need.
- **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it. - **Never say "I can't run this command"** or ask the user to run something manually. Just call \`executeCommand\` and let the approval flow handle it.
- When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root. - When calling \`executeCommand\`, do NOT provide the \`cwd\` parameter unless absolutely necessary. The default working directory is already set to the workspace root.
- Always confirm with the user before executing commands that modify files outside the workspace root (e.g., "I'll move 12 screenshots to ~/Desktop/Screenshots. Proceed?"). - Always confirm with the user before executing commands that modify files outside the workspace root. Prefer file tools for file changes.
**CRITICAL: MCP Server Configuration** **CRITICAL: MCP Server Configuration**
- ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving - ALWAYS use the \`addMcpServer\` builtin tool to add or update MCP servers—it validates the configuration before saving
- NEVER manually edit \`config/mcp.json\` using \`workspace-writeFile\` for MCP servers - NEVER manually edit \`config/mcp.json\` using \`file-writeText\` for MCP servers
- Invalid MCP configs will prevent the agent from starting with validation errors - Invalid MCP configs will prevent the agent from starting with validation errors
**Only \`executeCommand\` (shell/bash commands) goes through the approval flow.** If you need to delete a file, use the \`workspace-remove\` builtin tool, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`workspace-writeFile\`, not \`executeCommand\` with \`touch\` or \`echo >\`. File tools and \`executeCommand\` can both go through the approval flow depending on the path or command. If you need to delete a file, use \`file-remove\`, not \`executeCommand\` with \`rm\`. If you need to create a file, use \`file-writeText\`, not \`executeCommand\` with \`touch\` or \`echo >\`.
Rowboat's internal builtin tools never require approval only shell commands via \`executeCommand\` do.
## File Path References ## File Path References

View file

@ -14,7 +14,7 @@ Open a specific knowledge file in the editor pane.
- ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `) - ` + "`path`" + `: Full workspace-relative path (e.g., ` + "`knowledge/People/John Smith.md`" + `)
**Tips:** **Tips:**
- Use ` + "`workspace-grep`" + ` first to find the exact path if you're unsure of the filename. - Use ` + "`file-grep`" + ` first to find the exact path if you're unsure of the filename.
- Always pass the full ` + "`knowledge/...`" + ` path, not just the filename. - Always pass the full ` + "`knowledge/...`" + ` path, not just the filename.
### open-view ### open-view

View file

@ -25,11 +25,11 @@ Mixed instructions ("summarize and email it") trigger both.
You have three dedicated builtin tools for this skill: You have three dedicated builtin tools for this skill:
- \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`workspace-edit\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here. - \`create-background-task\` — materializes a new task on disk. **Use this. Do not write \`task.yaml\` yourself with \`file-editText\`, and do not search the codebase for IPC channels like \`bg-task:create\`** — they're renderer-side and not callable from here.
- \`patch-background-task\` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case. - \`patch-background-task\` — updates an existing task (instructions / triggers / active / model). Use this for the extend-don't-fork case.
- \`run-background-task-agent\` — manually fires a task to run now. Always call this immediately after \`create-background-task\` so the user sees content. - \`run-background-task-agent\` — manually fires a task to run now. Always call this immediately after \`create-background-task\` so the user sees content.
To inspect what tasks already exist, use \`workspace-glob\` on \`bg-tasks/*/task.yaml\` and \`workspace-readFile\` on candidates. The user's bg-tasks folder is workspace-relative. To inspect what tasks already exist, use \`file-glob\` on \`bg-tasks/*/task.yaml\` and \`file-readText\` on candidates. The user's bg-tasks folder is workspace-relative.
## Mode: act-first ## Mode: act-first

View file

@ -158,26 +158,26 @@ Pass the paper URL to the summariser. Don't ask for human input.
## Additional Builtin Tools ## Additional Builtin Tools
While \`executeCommand\` is the most versatile, other builtin tools exist for specific Rowboat operations (file management, agent inspection, etc.). These are primarily used by the Rowboat copilot itself and are not typically needed in user agents. If you need file operations, consider using bash commands like \`cat\`, \`echo\`, \`tee\`, etc. through \`executeCommand\`. While \`executeCommand\` is useful for CLI tools and shell workflows, builtin file tools exist for normal file management. Use \`file-*\` tools for reading, writing, editing, listing, searching, moving, copying, and removing files instead of shell commands.
### Copilot-Specific Builtin Tools ### Copilot-Specific Builtin Tools
The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with workspace management and MCP integration: The Rowboat copilot has access to special builtin tools that regular agents don't typically use. These tools help the copilot assist users with file management, app workflows, and MCP integration:
#### File & Directory Operations #### File & Directory Operations
- \`workspace-readdir\` - List directory contents (supports recursive exploration) - \`file-list\` - List directory contents (supports recursive exploration)
- \`workspace-readFile\` - Read file contents - \`file-readText\` - Read file contents
- \`workspace-writeFile\` - Create or update file contents - \`file-writeText\` - Create or update file contents
- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites) - \`file-editText\` - Make precise edits by replacing specific text (safer than full rewrites)
- \`workspace-remove\` - Remove files or directories - \`file-remove\` - Remove files or directories
- \`workspace-exists\` - Check if a file or directory exists - \`file-exists\` - Check if a file or directory exists
- \`workspace-stat\` - Get file/directory statistics - \`file-stat\` - Get file/directory statistics
- \`workspace-mkdir\` - Create directories - \`file-mkdir\` - Create directories
- \`workspace-rename\` - Rename or move files/directories - \`file-rename\` - Rename or move files/directories
- \`workspace-copy\` - Copy files - \`file-copy\` - Copy files
- \`workspace-getRoot\` - Get workspace root directory path - \`file-getRoot\` - Get the default root for relative file paths
- \`workspace-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md") - \`file-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
- \`workspace-grep\` - Search file contents using regex, returns matching files and lines - \`file-grep\` - Search file contents using regex, returns matching files and lines
#### Agent Operations #### Agent Operations
- \`analyzeAgent\` - Read and analyze an agent file structure - \`analyzeAgent\` - Read and analyze an agent file structure

View file

@ -78,19 +78,19 @@ Map each point to a slide layout from the Available Layout Types below. For a ty
## Workflow ## Workflow
1. Use workspace-readFile to check knowledge/ for relevant context about the company, product, team, etc. 1. Use file-readText to check knowledge/ for relevant context about the company, product, team, etc.
2. Ensure Playwright is installed: \`npm install playwright && npx playwright install chromium\` 2. Ensure Playwright is installed: \`npm install playwright && npx playwright install chromium\`
3. Use workspace-getRoot to get the workspace root path. 3. Use file-getRoot to get the workspace root path.
4. Plan the narrative arc and slide outline (see Content Planning above). 4. Plan the narrative arc and slide outline (see Content Planning above).
5. Use workspace-writeFile to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each). 5. Use file-writeText to create the HTML file at tmp/presentation.html (workspace-relative) with slides (1280x720px each).
6. **Perform the Post-Generation Validation (see below). Fix any issues before proceeding.** 6. **Perform the Post-Generation Validation (see below). Fix any issues before proceeding.**
7. Use workspace-writeFile to create the conversion script at tmp/convert.js (workspace-relative) see Playwright Export section. 7. Use file-writeText to create the conversion script at tmp/convert.js (workspace-relative) see Playwright Export section.
8. Run it: \`node <WORKSPACE_ROOT>/tmp/convert.js\` 8. Run it: \`node <WORKSPACE_ROOT>/tmp/convert.js\`
9. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" and note the theme used. 9. Tell the user: "Your presentation is ready at ~/Desktop/presentation.pdf" and note the theme used.
**Critical**: Never show HTML code to the user. Never ask the user to run commands, install packages, or make technical decisions. The entire pipeline from content to PDF must be invisible to the user. **Critical**: Never show HTML code to the user. Never ask the user to run commands, install packages, or make technical decisions. The entire pipeline from content to PDF must be invisible to the user.
Use workspace-writeFile and workspace-readFile for ALL file operations. Do NOT use executeCommand to write or read files. Use file-writeText and file-readText for ALL file operations. Do NOT use executeCommand to write or read files.
## Post-Generation Validation (REQUIRED) ## Post-Generation Validation (REQUIRED)
@ -142,14 +142,14 @@ html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !
## Playwright Export ## Playwright Export
\`\`\`javascript \`\`\`javascript
// save as tmp/convert.js via workspace-writeFile // save as tmp/convert.js via file-writeText
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const path = require('path'); const path = require('path');
(async () => { (async () => {
const browser = await chromium.launch(); const browser = await chromium.launch();
const page = await browser.newPage(); const page = await browser.newPage();
// Replace <WORKSPACE_ROOT> with the actual absolute path from workspace-getRoot // Replace <WORKSPACE_ROOT> with the actual absolute path from file-getRoot
await page.goto('file://<WORKSPACE_ROOT>/tmp/presentation.html', { waitUntil: 'networkidle' }); await page.goto('file://<WORKSPACE_ROOT>/tmp/presentation.html', { waitUntil: 'networkidle' });
await page.pdf({ await page.pdf({
path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'), path: path.join(process.env.HOME, 'Desktop', 'presentation.pdf'),
@ -162,7 +162,7 @@ const path = require('path');
})(); })();
\`\`\` \`\`\`
Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by workspace-getRoot. Replace \`<WORKSPACE_ROOT>\` with the actual absolute path returned by file-getRoot.
## Available Layout Types (35 Templates) ## Available Layout Types (35 Templates)

View file

@ -22,7 +22,7 @@ You are an expert document assistant helping the user create, edit, and refine d
## CRITICAL: Re-read Before Every Response ## CRITICAL: Re-read Before Every Response
**Before every response, you MUST use workspace-readFile to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version. **Before every response, you MUST use file-readText to re-read the current document.** The user may have edited the file manually outside of this conversation. Always work with the latest version of the file, never rely on a cached or previous version.
## Core Principles ## Core Principles
@ -55,12 +55,12 @@ When the user mentions a document name, search for it using multiple approaches:
1. **Search by name pattern** (handles partial matches, different cases): 1. **Search by name pattern** (handles partial matches, different cases):
\`\`\` \`\`\`
workspace-glob({ pattern: "knowledge/**/*[name]*", path: "knowledge/" }) file-glob({ pattern: "**/*[name]*", cwd: "knowledge/" })
\`\`\` \`\`\`
2. **Search by content** (finds docs that mention the topic): 2. **Search by content** (finds docs that mention the topic):
\`\`\` \`\`\`
workspace-grep({ pattern: "[name]", path: "knowledge/" }) file-grep({ pattern: "[name]", searchPath: "knowledge/" })
\`\`\` \`\`\`
3. **Try common variations:** 3. **Try common variations:**
@ -106,7 +106,7 @@ workspace-createFile({
**Types of requests:** **Types of requests:**
1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section" 1. **Direct edits** - "Change the title to X", "Add a bullet point about Y", "Remove the pricing section"
Make the edit immediately using workspace-editFile Make the edit immediately using file-editText
2. **Content generation** - "Write an intro", "Draft the executive summary", "Add a section about our approach" 2. **Content generation** - "Write an intro", "Draft the executive summary", "Add a section about our approach"
Generate the content and add it to the document Generate the content and add it to the document
@ -122,21 +122,21 @@ workspace-createFile({
### Step 3: Execute Changes ### Step 3: Execute Changes
**For edits, use workspace-editFile:** **For edits, use file-editText:**
\`\`\` \`\`\`
workspace-editFile({ file-editText({
path: "knowledge/[path].md", path: "knowledge/[path].md",
old_string: "[exact text to replace]", oldString: "[exact text to replace]",
new_string: "[new text]" newString: "[new text]"
}) })
\`\`\` \`\`\`
**For additions at the end:** **For additions at the end:**
\`\`\` \`\`\`
workspace-editFile({ file-editText({
path: "knowledge/[path].md", path: "knowledge/[path].md",
old_string: "[last line or section]", oldString: "[last line or section]",
new_string: "[last line or section]\n\n[new content]" newString: "[last line or section]\n\n[new content]"
}) })
\`\`\` \`\`\`
@ -156,14 +156,14 @@ When the user mentions people, companies, or projects:
**Search for relevant notes:** **Search for relevant notes:**
\`\`\` \`\`\`
workspace-grep({ pattern: "[Name]", path: "knowledge/" }) file-grep({ pattern: "[Name]", searchPath: "knowledge/" })
\`\`\` \`\`\`
**Read relevant notes:** **Read relevant notes:**
\`\`\` \`\`\`
workspace-readFile("knowledge/People/[Person].md") file-readText("knowledge/People/[Person].md")
workspace-readFile("knowledge/Organizations/[Company].md") file-readText("knowledge/Organizations/[Company].md")
workspace-readFile("knowledge/Projects/[Project].md") file-readText("knowledge/Projects/[Project].md")
\`\`\` \`\`\`
**Use the context:** **Use the context:**
@ -237,7 +237,7 @@ Renders a styled table from structured data.
### Block Guidelines ### Block Guidelines
- The JSON must be valid and on a single line (no pretty-printing) - The JSON must be valid and on a single line (no pretty-printing)
- Insert blocks using \`workspace-editFile\` just like any other content - Insert blocks using \`file-editText\` just like any other content
- When the user asks for a chart, table, embed, or live dashboard use blocks rather than plain Markdown tables or image links - When the user asks for a chart, table, embed, or live dashboard use blocks rather than plain Markdown tables or image links
- When editing a note that already contains blocks, preserve them unless the user asks to change them - When editing a note that already contains blocks, preserve them unless the user asks to change them
- For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\` - For local dashboards and mini apps, put the site files in \`sites/<slug>/\` and point an \`iframe\` block at \`http://localhost:3210/sites/<slug>/\`

View file

@ -16,11 +16,11 @@ When the user says "draft an email to Monica" or mentions ANY person, organizati
1. **STOP** - Do not draft anything yet 1. **STOP** - Do not draft anything yet
2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`): 2. **SEARCH** - Look them up in the knowledge base (path MUST be \`knowledge/\`):
\`\`\` \`\`\`
workspace-grep({ pattern: "Monica", path: "knowledge/" }) file-grep({ pattern: "Monica", searchPath: "knowledge/" })
\`\`\` \`\`\`
3. **READ** - Read their note to understand who they are: 3. **READ** - Read their note to understand who they are:
\`\`\` \`\`\`
workspace-readFile("knowledge/People/Monica Smith.md") file-readText("knowledge/People/Monica Smith.md")
\`\`\` \`\`\`
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items 4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN DRAFT** - Only now draft the email, using this context 5. **THEN DRAFT** - Only now draft the email, using this context
@ -133,19 +133,19 @@ Before drafting, gather relevant context. **Always check the knowledge base firs
First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`): First, search for the sender and any mentioned entities (path MUST be \`knowledge/\`):
\`\`\` \`\`\`
# Search for the sender by name or email # Search for the sender by name or email
workspace-grep({ pattern: "sender_name_or_email", path: "knowledge/" }) file-grep({ pattern: "sender_name_or_email", searchPath: "knowledge/" })
# List all people to find potential matches # List all people to find potential matches
workspace-readdir("knowledge/People") file-list("knowledge/People")
\`\`\` \`\`\`
Then read the relevant notes: Then read the relevant notes:
\`\`\` \`\`\`
# Read the sender's note # Read the sender's note
workspace-readFile("knowledge/People/Sender Name.md") file-readText("knowledge/People/Sender Name.md")
# Read their organization's note # Read their organization's note
workspace-readFile("knowledge/Organizations/Company Name.md") file-readText("knowledge/Organizations/Company Name.md")
\`\`\` \`\`\`
Extract from these notes: Extract from these notes:

View file

@ -34,7 +34,7 @@ When this skill is loaded, your job is: make a passive note live (or extend the
## Mode: act-first (non-negotiable on strong signals) ## Mode: act-first (non-negotiable on strong signals)
Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`workspace-edit\`, run the agent once, and confirm in one line at the end. Past tense, not future tense. Live-note creation and editing are **action-first**. Strong-signal asks (see below) get *executed*, not discussed. Read the file, write the \`live:\` block via \`file-editText\`, run the agent once, and confirm in one line at the end. Past tense, not future tense.
What you must NOT do on a strong-signal ask: What you must NOT do on a strong-signal ask:
- Don't ask "Should I make edits directly, or show changes first for approval?" that prompt belongs to generic doc editing, not live notes. - Don't ask "Should I make edits directly, or show changes first for approval?" that prompt belongs to generic doc editing, not live notes.
@ -77,7 +77,7 @@ When a strong signal lands without a specific note attached, pick the folder by
**Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`). **Filename**: derive from the topic in title-case (\`News Feed.md\`, \`Coinbase News.md\`, \`SFO Weather.md\`).
**Before creating**: \`workspace-grep\` and \`workspace-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found. **Before creating**: \`file-grep\` and \`file-glob\` the chosen folder for an existing note that already covers the topic. If one exists with a \`live:\` block, **extend its objective** (see "Already-live notes — extend, don't fork"). If one exists without a \`live:\` block, **make that note live** (don't create a duplicate). Only create a new file when no match is found.
### Default cadence picker (when the user didn't specify timing) ### Default cadence picker (when the user didn't specify timing)
@ -165,8 +165,8 @@ When skipping a re-run (because the user said not to or "later"):
**User:** "i want to set up a news feed to track news for India and the world." **User:** "i want to set up a news feed to track news for India and the world."
**Right behaviour** (one turn): **Right behaviour** (one turn):
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match. 1. \`file-grep({ pattern: "News Feed", searchPath: "knowledge/Notes/" })\` — search for an existing match.
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants. 2. \`file-grep({ pattern: "news", searchPath: "knowledge/Notes/" })\` — broader search to catch variants.
3. No match found create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data). 3. No match found create \`knowledge/Notes/News Feed.md\` with a sensible \`live:\` block (objective covering India + world headlines, a windows trigger for "every morning"-style refresh, plus an \`eventMatchCriteria\` if news might come from synced data).
4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty. 4. Call \`run-live-note-agent\` with a backfill \`context\` so the body isn't empty.
5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view." 5. Reply: "Done — created \`knowledge/Notes/News Feed.md\` and made it live, refreshing every morning. Running it once now so you see content right away. Manage it from the Live notes view."
@ -460,16 +460,16 @@ live:
### Making a passive note live (no \`live:\` block yet) ### Making a passive note live (no \`live:\` block yet)
1. \`workspace-readFile({ path })\` — re-read fresh. 1. \`file-readText({ path })\` — re-read fresh.
2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any). 2. Inspect existing frontmatter (the ` + "`" + `---` + "`" + `-fenced block at the top, if any).
3. \`workspace-edit\`: 3. \`file-editText\`:
- **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it. - **If the note has frontmatter without a \`live:\` block**: anchor on the closing \`---\` of the frontmatter and insert the \`live:\` block just before it.
- **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line). - **If the note has no frontmatter at all**: anchor on the very first line of the file. Replace it with a new frontmatter block (\`---\\n\` ... \`\\n---\\n\` followed by the original first line).
### Extending an already-live note ### Extending an already-live note
1. \`workspace-readFile({ path })\` — fetch the current \`live.objective\`. 1. \`file-readText({ path })\` — fetch the current \`live.objective\`.
2. Edit the \`objective\` value via \`workspace-edit\` to absorb the new ask in natural language. Keep the \`|\` block scalar style. 2. Edit the \`objective\` value via \`file-editText\` to absorb the new ask in natural language. Keep the \`|\` block scalar style.
3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`). 3. Don't touch other \`live:\` fields unless the user explicitly asked (e.g. "also run this hourly" → add/edit \`triggers.cronExpr\`).
### Sidebar chat with a specific note ### Sidebar chat with a specific note
@ -606,17 +606,17 @@ The tool returns ` + "`" + `{ success, runId, action, summary, contentAfter, err
- **Don't add \`triggers\`** if the user explicitly wants manual-only. - **Don't add \`triggers\`** if the user explicitly wants manual-only.
- **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed. - **Don't write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
- **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks. - **Don't schedule** with ` + "`" + `"* * * * *"` + "`" + ` (every minute) unless the user explicitly asks.
- **Don't use \`workspace-writeFile\`** to rewrite the whole file — always \`workspace-edit\` with a unique anchor. - **Don't use \`file-writeText\`** to rewrite the whole file — always \`file-editText\` with a unique anchor.
## Editing or Removing an Existing Live Note ## Editing or Removing an Existing Live Note
**Change the objective:** \`workspace-edit\` the \`objective\` value (use \`|\` block scalar). **Change the objective:** \`file-editText\` the \`objective\` value (use \`|\` block scalar).
**Change triggers:** \`workspace-edit\` the relevant sub-field of the \`triggers\` object. **Change triggers:** \`file-editText\` the relevant sub-field of the \`triggers\` object.
**Pause without removing:** flip \`active: false\`. **Pause without removing:** flip \`active: false\`.
**Make passive (remove the \`live:\` block):** \`workspace-edit\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit. **Make passive (remove the \`live:\` block):** \`file-editText\` with \`oldString\` = the entire \`live:\` block (from the \`live:\` line down to the next top-level key or the closing \`---\`), \`newString\` = empty. The note body is left alone — if you want to clear leftover agent output, do that as a separate edit.
## Quick Reference ## Quick Reference

View file

@ -38,7 +38,7 @@ export const skill = String.raw`
**ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors. **ALWAYS use the \`addMcpServer\` builtin tool** to add or update MCP server configurations. This tool validates the configuration before saving and prevents startup errors.
**NEVER manually create or edit \`config/mcp.json\`** using \`workspace-writeFile\` for MCP servers—this bypasses validation and will cause errors. **NEVER manually create or edit \`config/mcp.json\`** using \`file-writeText\` for MCP servers—this bypasses validation and will cause errors.
### MCP Server Configuration Schema ### MCP Server Configuration Schema

View file

@ -16,12 +16,12 @@ When the user asks to prep for a meeting or mentions attendees:
1. **STOP** - Do not create a generic brief 1. **STOP** - Do not create a generic brief
2. **SEARCH** - Look up each attendee in the knowledge base: 2. **SEARCH** - Look up each attendee in the knowledge base:
\`\`\` \`\`\`
workspace-grep({ pattern: "Attendee Name", path: "knowledge/" }) file-grep({ pattern: "Attendee Name", searchPath: "knowledge/" })
\`\`\` \`\`\`
3. **READ** - Read their notes to understand who they are: 3. **READ** - Read their notes to understand who they are:
\`\`\` \`\`\`
workspace-readFile("knowledge/People/Attendee Name.md") file-readText("knowledge/People/Attendee Name.md")
workspace-readFile("knowledge/Organizations/Their Company.md") file-readText("knowledge/Organizations/Their Company.md")
\`\`\` \`\`\`
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items 4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
5. **THEN BRIEF** - Only now create the meeting brief, using this context 5. **THEN BRIEF** - Only now create the meeting brief, using this context
@ -68,13 +68,13 @@ For each attendee, search the knowledge base (path MUST be \`knowledge/\`):
**Search People notes:** **Search People notes:**
\`\`\` \`\`\`
workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" }) file-grep({ pattern: "attendee_name", searchPath: "knowledge/People/" })
workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" }) file-grep({ pattern: "attendee_email", searchPath: "knowledge/People/" })
\`\`\` \`\`\`
If a person note exists, read it: If a person note exists, read it:
\`\`\` \`\`\`
workspace-readFile("knowledge/People/Attendee Name.md") file-readText("knowledge/People/Attendee Name.md")
\`\`\` \`\`\`
Extract: Extract:
@ -86,13 +86,13 @@ Extract:
**Search Organization notes:** **Search Organization notes:**
\`\`\` \`\`\`
workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" }) file-grep({ pattern: "company_name", searchPath: "knowledge/Organizations/" })
\`\`\` \`\`\`
**Search Projects:** **Search Projects:**
\`\`\` \`\`\`
workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" }) file-grep({ pattern: "attendee_name", searchPath: "knowledge/Projects/" })
workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" }) file-grep({ pattern: "company_name", searchPath: "knowledge/Projects/" })
\`\`\` \`\`\`
### Step 4: Create Meeting Brief ### Step 4: Create Meeting Brief

View file

@ -58,7 +58,7 @@ Use these as the \`link\` parameter to land the user on a specific view in Rowbo
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` | | Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — | | Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`). The \`type=file\` path is workspace-relative (the same path you'd pass to \`file-readText\`).
## Anti-patterns ## Anti-patterns
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress. - **Don't notify per step** of a multi-step task. Notify on completion, not on progress.

View file

@ -1,17 +1,14 @@
import { z, ZodType } from "zod"; import { z, ZodType } from "zod";
import * as path from "path"; import * as path from "path";
import * as fs from "fs/promises"; import * as fs from "fs/promises";
import { createReadStream, existsSync, readFileSync } from "fs"; import { existsSync, readFileSync } from "fs";
import { createInterface } from "readline";
import { execSync } from "child_process";
import { glob } from "glob";
import { executeCommand, executeCommandAbortable } from "./command-executor.js"; import { executeCommand, executeCommandAbortable } from "./command-executor.js";
import { resolveSkill, availableSkills } from "../assistant/skills/index.js"; import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
import { executeTool, listServers, listTools } from "../../mcp/mcp.js"; import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
import container from "../../di/container.js"; import container from "../../di/container.js";
import { IMcpConfigRepo } from "../..//mcp/repo.js"; import { IMcpConfigRepo } from "../..//mcp/repo.js";
import { McpServerDefinition } from "@x/shared/dist/mcp.js"; import { McpServerDefinition } from "@x/shared/dist/mcp.js";
import * as workspace from "../../workspace/workspace.js"; import * as files from "../../filesystem/files.js";
import { IAgentsRepo } from "../../agents/repo.js"; import { IAgentsRepo } from "../../agents/repo.js";
import { WorkDir } from "../../config/config.js"; import { WorkDir } from "../../config/config.js";
import { composioAccountsRepo } from "../../composio/repo.js"; import { composioAccountsRepo } from "../../composio/repo.js";
@ -156,12 +153,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-getRoot': { 'file-getRoot': {
description: 'Get the workspace root directory path', description: 'Get the default root directory for relative file paths. Relative paths passed to file tools resolve against this directory.',
inputSchema: z.object({}), inputSchema: z.object({}),
execute: async () => { execute: async () => {
try { try {
return await workspace.getRoot(); return { root: WorkDir };
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -170,14 +167,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-exists': { 'file-exists': {
description: 'Check if a file or directory exists in the workspace', description: 'Check if a file or directory exists. Accepts absolute paths, ~/ paths, or paths relative to the default root.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative path to check'), path: z.string().min(1).describe('File or directory path to check'),
}), }),
execute: async ({ path: relPath }: { path: string }) => { execute: async ({ path: filePath }: { path: string }) => {
try { try {
return await workspace.exists(relPath); return await files.exists(filePath);
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -186,14 +183,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-stat': { 'file-stat': {
description: 'Get file or directory statistics (size, modification time, etc.)', description: 'Get file or directory statistics (size, modification time, etc.)',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative path to stat'), path: z.string().min(1).describe('File or directory path to stat'),
}), }),
execute: async ({ path: relPath }: { path: string }) => { execute: async ({ path: filePath }: { path: string }) => {
try { try {
return await workspace.stat(relPath); return await files.stat(filePath);
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -202,22 +199,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-readdir': { 'file-list': {
description: 'List directory contents. Can recursively explore directory structure with options.', description: 'List directory contents. Can recursively explore directory structure with options.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().describe('Workspace-relative directory path (empty string for root)'), path: z.string().describe('Directory path to list. Use "." for the default root.'),
recursive: z.boolean().optional().describe('Recursively list all subdirectories (default: false)'), recursive: z.boolean().optional().describe('Recursively list all subdirectories (default: false)'),
includeStats: z.boolean().optional().describe('Include file stats like size and modification time (default: false)'), includeStats: z.boolean().optional().describe('Include file stats like size and modification time (default: false)'),
includeHidden: z.boolean().optional().describe('Include hidden files starting with . (default: false)'), includeHidden: z.boolean().optional().describe('Include hidden files starting with . (default: false)'),
allowedExtensions: z.array(z.string()).optional().describe('Filter by file extensions (e.g., [".json", ".ts"])'), allowedExtensions: z.array(z.string()).optional().describe('Filter by file extensions (e.g., [".json", ".ts"])'),
}), }),
execute: async ({ execute: async ({
path: relPath, path: filePath,
recursive, recursive,
includeStats, includeStats,
includeHidden, includeHidden,
allowedExtensions allowedExtensions
}: { }: {
path: string; path: string;
recursive?: boolean; recursive?: boolean;
includeStats?: boolean; includeStats?: boolean;
@ -225,13 +222,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
allowedExtensions?: string[]; allowedExtensions?: string[];
}) => { }) => {
try { try {
const entries = await workspace.readdir(relPath || '', { return await files.list(filePath || '.', {
recursive, recursive,
includeStats, includeStats,
includeHidden, includeHidden,
allowedExtensions, allowedExtensions,
}); });
return entries;
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -240,120 +236,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-readFile': { 'file-readText': {
description: 'Read a file from the workspace. For text files (utf8, the default), returns the content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use the `offset` and `limit` parameters to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers in the output are display-only — do NOT include them when later writing or editing the file. For `base64` / `binary` encodings, returns the raw bytes as a string and ignores `offset` / `limit`.', description: 'Read a UTF-8 text file. Returns content with each line prefixed by its 1-indexed line number (e.g. `12: some text`). Use `offset` and `limit` to page through large files; defaults read up to 2000 lines starting at line 1. Output is wrapped in `<path>`, `<resolvedPath>`, `<type>`, `<content>` tags and ends with a footer indicating whether the read reached end-of-file or was truncated. Line numbers are display-only — do NOT include them when later writing or editing the file. Refuses binary files; use parseFile or LLMParse for documents, PDFs, images, and other non-text formats.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'), path: z.string().min(1).describe('Text file path to read'),
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'), offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1).'),
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'), limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000).'),
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'),
}), }),
execute: async ({ execute: async ({
path: relPath, path: filePath,
offset, offset,
limit, limit,
encoding = 'utf8',
}: { }: {
path: string; path: string;
offset?: number; offset?: number;
limit?: number; limit?: number;
encoding?: 'utf8' | 'base64' | 'binary';
}) => { }) => {
try { try {
if (encoding !== 'utf8') { return await files.readText(filePath, offset, limit);
return await workspace.readFile(relPath, encoding);
}
const DEFAULT_READ_LIMIT = 2000;
const MAX_LINE_LENGTH = 2000;
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
const MAX_BYTES = 50 * 1024;
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
const absPath = workspace.resolveWorkspacePath(relPath);
const stats = await fs.lstat(absPath);
const stat = workspace.statToSchema(stats, 'file');
const etag = workspace.computeEtag(stats.size, stats.mtimeMs);
const effectiveOffset = offset ?? 1;
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
const start = effectiveOffset - 1;
const stream = createReadStream(absPath, { encoding: 'utf8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
const collected: string[] = [];
let totalLines = 0;
let bytes = 0;
let truncatedByBytes = false;
let hasMoreLines = false;
try {
for await (const text of rl) {
totalLines += 1;
if (totalLines <= start) continue;
if (collected.length >= effectiveLimit) {
hasMoreLines = true;
continue;
}
const line = text.length > MAX_LINE_LENGTH
? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
: text;
const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0);
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true;
hasMoreLines = true;
break;
}
collected.push(line);
bytes += size;
}
} finally {
rl.close();
stream.destroy();
}
if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) {
return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` };
}
const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`);
const lastReadLine = effectiveOffset + collected.length - 1;
const nextOffset = lastReadLine + 1;
let footer: string;
if (truncatedByBytes) {
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`;
} else if (hasMoreLines) {
footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`;
} else {
footer = `(End of file - total ${totalLines} lines)`;
}
const content = [
`<path>${relPath}</path>`,
`<type>file</type>`,
`<content>`,
prefixed.join('\n'),
'',
footer,
`</content>`,
].join('\n');
return {
path: relPath,
encoding: 'utf8' as const,
content,
stat,
etag,
offset: effectiveOffset,
limit: effectiveLimit,
totalLines,
hasMore: hasMoreLines || truncatedByBytes,
};
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -362,34 +262,30 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-writeFile': { 'file-writeText': {
description: 'Write or update file contents in the workspace. Automatically creates parent directories and supports atomic writes.', description: 'Write or update UTF-8 text file contents. Automatically creates parent directories and supports atomic writes.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'), path: z.string().min(1).describe('Text file path to write'),
data: z.string().describe('File content to write'), data: z.string().describe('UTF-8 text content to write'),
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('Data encoding (default: utf8)'),
atomic: z.boolean().optional().describe('Use atomic write (default: true)'), atomic: z.boolean().optional().describe('Use atomic write (default: true)'),
mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'), mkdirp: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'), expectedEtag: z.string().optional().describe('ETag to check for concurrent modifications (conflict detection)'),
}), }),
execute: async ({ execute: async ({
path: relPath, path: filePath,
data, data,
encoding,
atomic, atomic,
mkdirp, mkdirp,
expectedEtag expectedEtag
}: { }: {
path: string; path: string;
data: string; data: string;
encoding?: 'utf8' | 'base64' | 'binary';
atomic?: boolean; atomic?: boolean;
mkdirp?: boolean; mkdirp?: boolean;
expectedEtag?: string; expectedEtag?: string;
}) => { }) => {
try { try {
return await workspace.writeFile(relPath, data, { return await files.writeText(filePath, data, {
encoding,
atomic, atomic,
mkdirp, mkdirp,
expectedEtag, expectedEtag,
@ -402,16 +298,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-edit': { 'file-editText': {
description: 'Make precise edits to a file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss.', description: 'Make precise edits to a UTF-8 text file by replacing specific text. Safer than rewriting entire files - produces smaller diffs and reduces risk of data loss. Refuses binary files.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative file path'), path: z.string().min(1).describe('Text file path to edit'),
oldString: z.string().describe('Exact text to find and replace'), oldString: z.string().describe('Exact text to find and replace'),
newString: z.string().describe('Replacement text'), newString: z.string().describe('Replacement text'),
replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'), replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'),
}), }),
execute: async ({ execute: async ({
path: relPath, path: filePath,
oldString, oldString,
newString, newString,
replaceAll = false replaceAll = false
@ -422,46 +318,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
replaceAll?: boolean; replaceAll?: boolean;
}) => { }) => {
try { try {
const result = await workspace.readFile(relPath, 'utf8'); return await files.editText(filePath, oldString, newString, replaceAll);
const content = result.data;
const occurrences = content.split(oldString).length - 1;
if (occurrences === 0) {
return { error: 'oldString not found in file' };
}
if (occurrences > 1 && !replaceAll) {
return {
error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.`
};
}
const newContent = replaceAll
? content.replaceAll(oldString, newString)
: content.replace(oldString, newString);
await workspace.writeFile(relPath, newContent, { encoding: 'utf8' });
return {
success: true,
replacements: replaceAll ? occurrences : 1
};
} catch (error) { } catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' }; return { error: error instanceof Error ? error.message : 'Unknown error' };
} }
}, },
}, },
'workspace-mkdir': { 'file-mkdir': {
description: 'Create a directory in the workspace', description: 'Create a directory',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative directory path'), path: z.string().min(1).describe('Directory path to create'),
recursive: z.boolean().optional().describe('Create parent directories if needed (default: true)'), recursive: z.boolean().optional().describe('Create parent directories if needed (default: true)'),
}), }),
execute: async ({ path: relPath, recursive = true }: { path: string; recursive?: boolean }) => { execute: async ({ path: filePath, recursive = true }: { path: string; recursive?: boolean }) => {
try { try {
return await workspace.mkdir(relPath, recursive); return await files.mkdir(filePath, recursive);
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -470,16 +342,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-rename': { 'file-rename': {
description: 'Rename or move a file or directory in the workspace', description: 'Rename or move a file or directory',
inputSchema: z.object({ inputSchema: z.object({
from: z.string().min(1).describe('Source workspace-relative path'), from: z.string().min(1).describe('Source path'),
to: z.string().min(1).describe('Destination workspace-relative path'), to: z.string().min(1).describe('Destination path'),
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'), overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
}), }),
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => { execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
try { try {
return await workspace.rename(from, to, overwrite); return await files.rename(from, to, overwrite);
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -488,16 +360,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-copy': { 'file-copy': {
description: 'Copy a file in the workspace (directories not supported)', description: 'Copy a file (directories not supported)',
inputSchema: z.object({ inputSchema: z.object({
from: z.string().min(1).describe('Source workspace-relative file path'), from: z.string().min(1).describe('Source file path'),
to: z.string().min(1).describe('Destination workspace-relative file path'), to: z.string().min(1).describe('Destination file path'),
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'), overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
}), }),
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => { execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
try { try {
return await workspace.copy(from, to, overwrite); return await files.copy(from, to, overwrite);
} catch (error) { } catch (error) {
return { return {
error: error instanceof Error ? error.message : 'Unknown error', error: error instanceof Error ? error.message : 'Unknown error',
@ -506,16 +378,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-remove': { 'file-remove': {
description: 'Remove a file or directory from the workspace. Files are moved to trash by default for safety.', description: 'Remove a file or directory. Files are moved to the Rowboat trash by default for safety.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('Workspace-relative path to remove'), path: z.string().min(1).describe('Path to remove'),
recursive: z.boolean().optional().describe('Required for directories (default: false)'), recursive: z.boolean().optional().describe('Required for directories (default: false)'),
trash: z.boolean().optional().describe('Move to trash instead of permanent delete (default: true)'), trash: z.boolean().optional().describe('Move to trash instead of permanent delete (default: true)'),
}), }),
execute: async ({ path: relPath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => { execute: async ({ path: filePath, recursive, trash }: { path: string; recursive?: boolean; trash?: boolean }) => {
try { try {
return await workspace.remove(relPath, { return await files.remove(filePath, {
recursive, recursive,
trash, trash,
}); });
@ -527,45 +399,26 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
}, },
'workspace-glob': { 'file-glob': {
description: 'Find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.json"). Much faster than recursive readdir for finding files.', description: 'Find files matching a glob pattern (e.g., "**/*.ts", "src/**/*.json"). Much faster than recursive readdir for finding files.',
inputSchema: z.object({ inputSchema: z.object({
pattern: z.string().describe('Glob pattern to match files'), pattern: z.string().describe('Glob pattern to match files'),
cwd: z.string().optional().describe('Subdirectory to search in, relative to workspace root (default: workspace root)'), cwd: z.string().optional().describe('Directory to search in (default: default root)'),
}), }),
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; return await files.glob(pattern, cwd);
// Ensure search directory is within workspace
const resolvedSearchDir = path.resolve(searchDir);
if (!resolvedSearchDir.startsWith(WorkDir)) {
return { error: 'Search directory must be within workspace' };
}
const files = await glob(pattern, {
cwd: searchDir,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
});
return {
files,
count: files.length,
pattern,
cwd: cwd || '.',
};
} catch (error) { } catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' }; return { error: error instanceof Error ? error.message : 'Unknown error' };
} }
}, },
}, },
'workspace-grep': { 'file-grep': {
description: 'Search file contents using regex. Returns matching files and lines. Uses ripgrep if available, falls back to grep.', description: 'Search text file contents using regex. Returns matching files and lines. Skips binary files.',
inputSchema: z.object({ inputSchema: z.object({
pattern: z.string().describe('Regex pattern to search for'), pattern: z.string().describe('Regex pattern to search for'),
searchPath: z.string().optional().describe('Directory or file to search, relative to workspace root (default: workspace root)'), searchPath: z.string().optional().describe('Directory or file to search (default: default root)'),
fileGlob: z.string().optional().describe('File pattern filter (e.g., "*.ts", "*.md")'), fileGlob: z.string().optional().describe('File pattern filter (e.g., "*.ts", "*.md")'),
contextLines: z.number().optional().describe('Lines of context around matches (default: 0)'), contextLines: z.number().optional().describe('Lines of context around matches (default: 0)'),
maxResults: z.number().optional().describe('Maximum results to return (default: 100)'), maxResults: z.number().optional().describe('Maximum results to return (default: 100)'),
@ -584,90 +437,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
maxResults?: number; maxResults?: number;
}) => { }) => {
try { try {
const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir; return await files.grep({ pattern, searchPath, fileGlob, contextLines, maxResults });
// Ensure target path is within workspace
const resolvedTargetPath = path.resolve(targetPath);
if (!resolvedTargetPath.startsWith(WorkDir)) {
return { error: 'Search path must be within workspace' };
}
// Try ripgrep first
try {
const rgArgs = [
'--json',
'-e', JSON.stringify(pattern),
contextLines > 0 ? `-C ${contextLines}` : '',
fileGlob ? `--glob ${JSON.stringify(fileGlob)}` : '',
`--max-count ${maxResults}`,
'--ignore-case',
JSON.stringify(resolvedTargetPath),
].filter(Boolean).join(' ');
const output = execSync(`rg ${rgArgs}`, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
cwd: WorkDir,
});
const matches = output.trim().split('\n')
.filter(Boolean)
.map(line => {
try {
return JSON.parse(line);
} catch {
return null;
}
})
.filter(m => m && m.type === 'match');
return {
matches: matches.map(m => ({
file: path.relative(WorkDir, m.data.path.text),
line: m.data.line_number,
content: m.data.lines.text.trim(),
})),
count: matches.length,
tool: 'ripgrep',
};
} catch {
// Fallback to basic grep if ripgrep not available or failed
const grepArgs = [
'-rn',
fileGlob ? `--include=${JSON.stringify(fileGlob)}` : '',
JSON.stringify(pattern),
JSON.stringify(resolvedTargetPath),
`| head -${maxResults}`,
].filter(Boolean).join(' ');
try {
const output = execSync(`grep ${grepArgs}`, {
encoding: 'utf8',
maxBuffer: 10 * 1024 * 1024,
shell: '/bin/sh',
});
const lines = output.trim().split('\n').filter(Boolean);
return {
matches: lines.map(line => {
const match = line.match(/^(.+?):(\d+):(.*)$/);
if (match) {
return {
file: path.relative(WorkDir, match[1]),
line: parseInt(match[2], 10),
content: match[3].trim(),
};
}
return { file: '', line: 0, content: line };
}),
count: lines.length,
tool: 'grep',
};
} catch {
// No matches found (grep returns non-zero on no matches)
return { matches: [], count: 0, tool: 'grep' };
}
}
} catch (error) { } catch (error) {
return { error: error instanceof Error ? error.message : 'Unknown error' }; return { error: error instanceof Error ? error.message : 'Unknown error' };
} }
@ -677,7 +447,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
'parseFile': { 'parseFile': {
description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.', description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'), path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'),
}), }),
execute: async ({ path: filePath }: { path: string }) => { execute: async ({ path: filePath }: { path: string }) => {
try { try {
@ -692,14 +462,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}; };
} }
// Read file as buffer — support both absolute and workspace-relative paths const { buffer, resolvedPath } = await files.readBuffer(filePath);
let buffer: Buffer;
if (path.isAbsolute(filePath)) {
buffer = await fs.readFile(filePath);
} else {
const result = await workspace.readFile(filePath, 'base64');
buffer = Buffer.from(result.data, 'base64');
}
if (ext === '.pdf') { if (ext === '.pdf') {
const { PDFParse } = await _importDynamic("pdf-parse"); const { PDFParse } = await _importDynamic("pdf-parse");
@ -716,6 +479,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
pages: textResult.total, pages: textResult.total,
title: infoResult.info?.Title || undefined, title: infoResult.info?.Title || undefined,
author: infoResult.info?.Author || undefined, author: infoResult.info?.Author || undefined,
resolvedPath,
}, },
}; };
} finally { } finally {
@ -785,7 +549,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
'LLMParse': { 'LLMParse': {
description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).', description: 'Send a file to the configured LLM as a multimodal attachment and ask it to extract content as markdown. Best for scanned PDFs, images with text, complex layouts, or any format where local parsing falls short. Supports documents (PDF, Word, Excel, PowerPoint, CSV, TXT, HTML) and images (PNG, JPG, GIF, WebP, SVG, BMP, TIFF).',
inputSchema: z.object({ inputSchema: z.object({
path: z.string().min(1).describe('File path to parse. Can be an absolute path or a workspace-relative path.'), path: z.string().min(1).describe('File path to parse. Can be absolute, ~/..., or relative to the default root.'),
prompt: z.string().optional().describe('Custom instruction for the LLM (defaults to "Convert this file to well-structured markdown.")'), prompt: z.string().optional().describe('Custom instruction for the LLM (defaults to "Convert this file to well-structured markdown.")'),
}), }),
execute: async ({ path: filePath, prompt }: { path: string; prompt?: string }) => { execute: async ({ path: filePath, prompt }: { path: string; prompt?: string }) => {
@ -801,14 +565,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}; };
} }
// Read file as buffer — support both absolute and workspace-relative paths const { buffer } = await files.readBuffer(filePath);
let buffer: Buffer;
if (path.isAbsolute(filePath)) {
buffer = await fs.readFile(filePath);
} else {
const result = await workspace.readFile(filePath, 'base64');
buffer = Buffer.from(result.data, 'base64');
}
const base64 = buffer.toString('base64'); const base64 = buffer.toString('base64');
@ -1228,7 +985,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
case 'open-note': { case 'open-note': {
const filePath = input.path as string; const filePath = input.path as string;
try { try {
const result = await workspace.exists(filePath); const result = await files.exists(filePath);
if (!result.exists) { if (!result.exists) {
return { success: false, error: `File not found: ${filePath}` }; return { success: false, error: `File not found: ${filePath}` };
} }
@ -1256,15 +1013,15 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
// Scan knowledge/ files and extract frontmatter properties // Scan knowledge/ files and extract frontmatter properties
try { try {
const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js"); const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js");
const entries = await workspace.readdir("knowledge", { recursive: true, allowedExtensions: [".md"] }); const entries = await files.list("knowledge", { recursive: true, allowedExtensions: [".md"] });
const files = entries.filter(e => e.kind === 'file'); const noteFiles = entries.filter(e => e.kind === 'file');
const properties = new Map<string, Set<string>>(); const properties = new Map<string, Set<string>>();
let noteCount = 0; let noteCount = 0;
for (const file of files) { for (const file of noteFiles) {
try { try {
const { data } = await workspace.readFile(file.path); const result = await fs.readFile(file.resolvedPath, 'utf8');
const { fields } = parseFrontmatter(data); const { fields } = parseFrontmatter(result);
noteCount++; noteCount++;
for (const [key, value] of Object.entries(fields)) { for (const [key, value] of Object.entries(fields)) {
if (!value) continue; if (!value) continue;
@ -1309,7 +1066,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
const basePath = `bases/${safeName}.base`; const basePath = `bases/${safeName}.base`;
try { try {
const config = { name: safeName, filters: [], columns: [] }; const config = { name: safeName, filters: [], columns: [] };
await workspace.writeFile(basePath, JSON.stringify(config, null, 2), { mkdirp: true }); await files.writeText(basePath, JSON.stringify(config, null, 2), { mkdirp: true });
return { success: true, action: 'create-base', name: safeName, path: basePath }; return { success: true, action: 'create-base', name: safeName, path: basePath };
} catch (error) { } catch (error) {
return { return {
@ -1655,7 +1412,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
'create-background-task': { 'create-background-task': {
description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with workspace-edit, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks/<slug>/{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.", description: "Create a new background task on disk. This is the tool you call to materialize a bg-task — do NOT try to write `task.yaml` yourself with file-editText, and do NOT search the codebase for IPC channels like `bg-task:create`. The framework slugifies the name and lays out `bg-tasks/<slug>/{task.yaml,index.md,runs/}`. After this returns, immediately call `run-background-task-agent` with the returned slug so the user sees content right away.",
inputSchema: CreateBackgroundTaskInput, inputSchema: CreateBackgroundTaskInput,
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => { execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
try { try {
@ -1675,7 +1432,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}, },
'patch-background-task': { 'patch-background-task': {
description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `workspace-glob` on `bg-tasks/*/task.yaml` and `workspace-readFile` on the candidates first.", description: "Update an existing background task — instructions, triggers, active, or model/provider. Use this when the user's new ask overlaps with an existing task (extend-don't-fork): rewrite the instructions in full to absorb the new ask rather than creating a duplicate sibling task. Look up existing tasks with `file-glob` on `bg-tasks/*/task.yaml` and `file-readText` on the candidates first.",
inputSchema: PatchBackgroundTaskInput, inputSchema: PatchBackgroundTaskInput,
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => { execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
try { try {

View file

@ -15,7 +15,7 @@ You are running with **no user present** to clarify, approve, or watch.
Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains: Your task folder is \`bg-tasks/<slug>/\` (the path is given in the run message). It contains:
- \`task.yaml\` — the spec. **Never touch this.** The runtime owns it. - \`task.yaml\` — the spec. **Never touch this.** The runtime owns it.
- \`index.md\` — agent-owned. You read and write this freely via \`workspace-readFile\` / \`workspace-edit\`. - \`index.md\` — agent-owned. You read and write this freely via \`file-readText\` / \`file-editText\`.
- \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does. - \`runs/\` — your own run logs (jsonl). You don't write to it directly; the runtime does.
You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it. You can also read and write anywhere else under the workspace (\`knowledge/\`, etc.) when your instructions call for it.
@ -26,7 +26,7 @@ OUTPUT MODE — keep \`index.md\` aligned to the instructions.
Use when instructions imply a **current state** artifact: Use when instructions imply a **current state** artifact:
- "Maintain / show / summarize / track / digest of / dashboard for / brief on …" - "Maintain / show / summarize / track / digest of / dashboard for / brief on …"
- "Keep me posted on …" / "What's the latest on …" - "Keep me posted on …" / "What's the latest on …"
On every run: \`workspace-readFile\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`workspace-edit\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites. On every run: \`file-readText\` \`index.md\`, decide the smallest patch that brings it into alignment with the instructions, apply with \`file-editText\`. Patch-style discipline: edit one region, re-read, then edit the next. Avoid one-shot rewrites.
ACTION MODE perform a side-effect, append a journal entry. ACTION MODE perform a side-effect, append a journal entry.
Use when instructions imply a **recurring action**: Use when instructions imply a **recurring action**:

View file

@ -50,7 +50,7 @@ function buildMessage(
**Instructions:** **Instructions:**
${task.instructions} ${task.instructions}
Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`workspace-readFile\` and update it with \`workspace-edit\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`; Your task folder is \`${wsFolder}\`. The user-visible artifact is \`${wsFolder}index.md\` — read it with \`file-readText\` and update it with \`file-editText\` per the OUTPUT / ACTION mode rule. Do not touch \`${wsFolder}task.yaml\` (the runtime owns it).`;
return baseMessage + buildTriggerBlock({ return baseMessage + buildTriggerBlock({
trigger, trigger,

View file

@ -42,26 +42,61 @@ const DEFAULT_ALLOW_LIST = [
"yq" "yq"
] ]
export type FileAccessOperation = "read" | "list" | "search" | "write" | "delete";
export type FileAccessGrant = {
operation: FileAccessOperation;
pathPrefix: string;
};
let cachedAllowList: string[] | null = null; let cachedAllowList: string[] | null = null;
let cachedFileAccessAllowList: FileAccessGrant[] | null = null;
let cachedMtimeMs: number | null = null; let cachedMtimeMs: number | null = null;
export async function addToSecurityConfig(commands: string[]): Promise<void> { export async function addToSecurityConfig(commands: string[]): Promise<void> {
ensureSecurityConfigSync(); ensureSecurityConfigSync();
const current = readAllowList(); const current = readSecurityConfig();
const merged = new Set(current); const merged = new Set(current.allowedCommands);
for (const cmd of commands) { for (const cmd of commands) {
const normalized = cmd.trim().toLowerCase(); const normalized = cmd.trim().toLowerCase();
if (normalized) merged.add(normalized); if (normalized) merged.add(normalized);
} }
await fsPromises.writeFile( await fsPromises.writeFile(
SECURITY_CONFIG_PATH, SECURITY_CONFIG_PATH,
JSON.stringify(Array.from(merged).sort(), null, 2) + "\n", JSON.stringify({
allowedCommands: Array.from(merged).sort(),
allowedFileAccess: current.allowedFileAccess,
}, null, 2) + "\n",
"utf8", "utf8",
); );
// Reset cache so next read picks up the new file // Reset cache so next read picks up the new file
resetSecurityAllowListCache(); resetSecurityAllowListCache();
} }
export async function addFileAccessGrant(grant: FileAccessGrant): Promise<void> {
ensureSecurityConfigSync();
const current = readSecurityConfig();
const normalizedGrant = normalizeFileAccessGrant(grant);
const exists = current.allowedFileAccess.some(existing =>
existing.operation === normalizedGrant.operation
&& existing.pathPrefix === normalizedGrant.pathPrefix
);
const allowedFileAccess = exists
? current.allowedFileAccess
: [...current.allowedFileAccess, normalizedGrant].sort((a, b) =>
`${a.operation}:${a.pathPrefix}`.localeCompare(`${b.operation}:${b.pathPrefix}`)
);
await fsPromises.writeFile(
SECURITY_CONFIG_PATH,
JSON.stringify({
allowedCommands: current.allowedCommands,
allowedFileAccess,
}, null, 2) + "\n",
"utf8",
);
resetSecurityAllowListCache();
}
/** /**
* Async function to ensure security config file exists. * Async function to ensure security config file exists.
* Called explicitly at app startup via initConfigs(). * Called explicitly at app startup via initConfigs().
@ -103,28 +138,74 @@ function normalizeList(commands: unknown[]): string[] {
return Array.from(seen); return Array.from(seen);
} }
function parseSecurityPayload(payload: unknown): string[] { function normalizeFileAccessGrant(grant: FileAccessGrant): FileAccessGrant {
return {
operation: grant.operation,
pathPrefix: path.resolve(grant.pathPrefix),
};
}
function normalizeFileAccessList(grants: unknown[]): FileAccessGrant[] {
const seen = new Set<string>();
const normalized: FileAccessGrant[] = [];
for (const entry of grants) {
if (!entry || typeof entry !== "object") continue;
const maybeGrant = entry as Record<string, unknown>;
const operation = maybeGrant.operation;
const pathPrefix = maybeGrant.pathPrefix;
if (
operation !== "read"
&& operation !== "list"
&& operation !== "search"
&& operation !== "write"
&& operation !== "delete"
) {
continue;
}
if (typeof pathPrefix !== "string" || !pathPrefix.trim()) continue;
const grant = normalizeFileAccessGrant({ operation, pathPrefix });
const key = `${grant.operation}:${grant.pathPrefix}`;
if (seen.has(key)) continue;
seen.add(key);
normalized.push(grant);
}
return normalized;
}
function parseSecurityPayload(payload: unknown): { allowedCommands: string[]; allowedFileAccess: FileAccessGrant[] } {
if (Array.isArray(payload)) { if (Array.isArray(payload)) {
return normalizeList(payload); return { allowedCommands: normalizeList(payload), allowedFileAccess: [] };
} }
if (payload && typeof payload === "object") { if (payload && typeof payload === "object") {
const maybeObject = payload as Record<string, unknown>; const maybeObject = payload as Record<string, unknown>;
if (Array.isArray(maybeObject.allowedCommands)) { const allowedFileAccess = Array.isArray(maybeObject.allowedFileAccess)
return normalizeList(maybeObject.allowedCommands); ? normalizeFileAccessList(maybeObject.allowedFileAccess)
: [];
if (Array.isArray(maybeObject.allowedCommands) || Array.isArray(maybeObject.allowedFileAccess)) {
return {
allowedCommands: Array.isArray(maybeObject.allowedCommands)
? normalizeList(maybeObject.allowedCommands)
: [],
allowedFileAccess,
};
} }
const dynamicList = Object.entries(maybeObject) const dynamicList = Object.entries(maybeObject)
.filter(([, value]) => Boolean(value)) .filter(([, value]) => Boolean(value))
.map(([key]) => key); .map(([key]) => key);
return normalizeList(dynamicList); return {
allowedCommands: normalizeList(dynamicList),
allowedFileAccess,
};
} }
return []; return { allowedCommands: [], allowedFileAccess: [] };
} }
function readAllowList(): string[] { function readSecurityConfig(): { allowedCommands: string[]; allowedFileAccess: FileAccessGrant[] } {
ensureSecurityConfigSync(); ensureSecurityConfigSync();
try { try {
@ -133,10 +214,14 @@ function readAllowList(): string[] {
return parseSecurityPayload(parsed); return parseSecurityPayload(parsed);
} catch (error) { } catch (error) {
console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`); console.warn(`Failed to read security config at ${SECURITY_CONFIG_PATH}: ${error instanceof Error ? error.message : error}`);
return DEFAULT_ALLOW_LIST; return { allowedCommands: DEFAULT_ALLOW_LIST, allowedFileAccess: [] };
} }
} }
function readAllowList(): string[] {
return readSecurityConfig().allowedCommands;
}
export function getSecurityAllowList(): string[] { export function getSecurityAllowList(): string[] {
ensureSecurityConfigSync(); ensureSecurityConfigSync();
try { try {
@ -149,12 +234,32 @@ export function getSecurityAllowList(): string[] {
return cachedAllowList; return cachedAllowList;
} catch { } catch {
cachedAllowList = null; cachedAllowList = null;
cachedFileAccessAllowList = null;
cachedMtimeMs = null; cachedMtimeMs = null;
return readAllowList(); return readAllowList();
} }
} }
export function getFileAccessAllowList(): FileAccessGrant[] {
ensureSecurityConfigSync();
try {
const stats = fs.statSync(SECURITY_CONFIG_PATH);
if (cachedFileAccessAllowList && cachedMtimeMs === stats.mtimeMs) {
return cachedFileAccessAllowList;
}
cachedFileAccessAllowList = readSecurityConfig().allowedFileAccess;
cachedMtimeMs = stats.mtimeMs;
return cachedFileAccessAllowList;
} catch {
cachedAllowList = null;
cachedFileAccessAllowList = null;
cachedMtimeMs = null;
return readSecurityConfig().allowedFileAccess;
}
}
export function resetSecurityAllowListCache() { export function resetSecurityAllowListCache() {
cachedAllowList = null; cachedAllowList = null;
cachedFileAccessAllowList = null;
cachedMtimeMs = null; cachedMtimeMs = null;
} }

View file

@ -0,0 +1,204 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let tmpDir: string;
let workspaceDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "rowboat-files-test-"));
workspaceDir = path.join(tmpDir, "workspace");
process.env.ROWBOAT_WORKDIR = workspaceDir;
vi.resetModules();
vi.doMock("../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
});
afterEach(async () => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../knowledge/version_history.js");
vi.doUnmock("../knowledge/deprecate_today_note.js");
vi.resetModules();
await fs.rm(tmpDir, { recursive: true, force: true });
});
async function loadFiles() {
return import("./files.js");
}
describe("filesystem files", () => {
it("resolves relative paths inside ROWBOAT_WORKDIR", async () => {
const files = await loadFiles();
const resolved = files.resolveFilePath("notes/example.md");
expect(resolved.originalPath).toBe("notes/example.md");
expect(resolved.resolvedPath).toBe(path.join(workspaceDir, "notes", "example.md"));
expect(resolved.isInsideWorkspace).toBe(true);
expect(resolved.workspaceRelPath).toBe("notes/example.md");
});
it("keeps absolute paths outside the workspace absolute", async () => {
const files = await loadFiles();
const absolutePath = path.join(tmpDir, "outside.txt");
const resolved = files.resolveFilePath(absolutePath);
expect(resolved.resolvedPath).toBe(absolutePath);
expect(resolved.isInsideWorkspace).toBe(false);
expect(resolved.workspaceRelPath).toBeNull();
});
it("expands home-relative paths", async () => {
const files = await loadFiles();
const resolved = files.resolveFilePath("~/rowboat-test.txt");
expect(resolved.resolvedPath).toBe(path.join(os.homedir(), "rowboat-test.txt"));
expect(resolved.isInsideWorkspace).toBe(false);
});
it("canonicalizes symlinked paths for permission checks", async () => {
const files = await loadFiles();
const externalDir = path.join(tmpDir, "external");
const linkPath = path.join(workspaceDir, "linked");
await fs.mkdir(externalDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
try {
await fs.symlink(externalDir, linkPath, "dir");
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "EPERM") {
return;
}
throw error;
}
const canonicalExternalDir = await fs.realpath(externalDir);
const resolved = await files.resolveFilePathForPermission("linked/new-file.txt");
expect(resolved.resolvedPath).toBe(path.join(workspaceDir, "linked", "new-file.txt"));
expect(resolved.canonicalPath).toBe(path.join(canonicalExternalDir, "new-file.txt"));
expect(resolved.isInsideWorkspace).toBe(false);
expect(resolved.workspaceRelPath).toBeNull();
});
it("reads text with line numbers and pagination metadata", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "readme.txt"), "alpha\nbeta\ngamma\n", "utf8");
const result = await files.readText("readme.txt", 2, 1);
expect(result.path).toBe("readme.txt");
expect(result.encoding).toBe("utf8");
expect(result.offset).toBe(2);
expect(result.limit).toBe(1);
expect(result.totalLines).toBe(3);
expect(result.hasMore).toBe(true);
expect(result.content).toContain("2: beta");
});
it("rejects files containing NUL bytes as binary", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "binary.dat"), Buffer.from([0x61, 0x00, 0x62]));
await expect(files.readText("binary.dat")).rejects.toThrow("binary file");
});
it("rejects files with a high non-printable byte ratio", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "control.dat"), Buffer.from([0x01, 0x02, 0x03, 0x41]));
await expect(files.readText("control.dat")).rejects.toThrow("binary file");
});
it("rejects files that decode with many UTF-8 replacement characters", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "invalid-utf8.txt"), Buffer.alloc(512, 0xff));
await expect(files.readText("invalid-utf8.txt")).rejects.toThrow("binary file");
});
it("accepts normal text control characters", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "tabs.txt"), "one\ttwo\nthree\rfour\r\n", "utf8");
const result = await files.readText("tabs.txt");
expect(result.content).toContain("1: one\ttwo");
expect(result.content).toContain("2: three");
});
it("writes text and creates parent directories", async () => {
const files = await loadFiles();
await files.writeText("nested/dir/file.txt", "hello");
await expect(fs.readFile(path.join(workspaceDir, "nested", "dir", "file.txt"), "utf8")).resolves.toBe("hello");
});
it("rejects stale expectedEtag writes", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "etag.txt"), "first", "utf8");
const initial = await files.stat("etag.txt");
await files.writeText("etag.txt", "second");
await expect(files.writeText("etag.txt", "third", { expectedEtag: initial.etag })).rejects.toThrow("ETag mismatch");
});
it("requires unique editText matches unless replaceAll is true", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "edit.txt"), "one two one", "utf8");
const ambiguous = await files.editText("edit.txt", "one", "ONE");
expect(ambiguous).toEqual({ error: "oldString found 2 times. Use replaceAll: true or provide more context to make it unique." });
const replaced = await files.editText("edit.txt", "one", "ONE", true);
expect(replaced).toMatchObject({ success: true, replacements: 2 });
await expect(fs.readFile(path.join(workspaceDir, "edit.txt"), "utf8")).resolves.toBe("ONE two ONE");
});
it("runs glob relative to the requested cwd", async () => {
const files = await loadFiles();
await fs.mkdir(path.join(workspaceDir, "src"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "src", "a.ts"), "export {};", "utf8");
await fs.writeFile(path.join(workspaceDir, "src", "b.md"), "# b", "utf8");
await fs.writeFile(path.join(workspaceDir, "c.ts"), "export {};", "utf8");
const result = await files.glob("*.ts", "src");
expect(result.files).toEqual(["a.ts"]);
expect(result.resolvedFiles).toEqual([path.join(workspaceDir, "src", "a.ts")]);
expect(result.resolvedCwd).toBe(path.join(workspaceDir, "src"));
});
it("greps text files and skips binary files", async () => {
const files = await loadFiles();
await fs.mkdir(workspaceDir, { recursive: true });
await fs.writeFile(path.join(workspaceDir, "match.txt"), "needle\n", "utf8");
await fs.writeFile(path.join(workspaceDir, "binary.dat"), Buffer.from([0x6e, 0x65, 0x65, 0x64, 0x6c, 0x65, 0x00]));
const result = await files.grep({ pattern: "needle", searchPath: "." });
expect(result.count).toBe(1);
expect(result.matches).toEqual([
expect.objectContaining({
file: "match.txt",
line: 1,
content: "needle",
}),
]);
});
});

View file

@ -0,0 +1,641 @@
import fs from 'node:fs/promises';
import { createReadStream, type Stats } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { createInterface } from 'node:readline';
import { glob as globFiles } from 'glob';
import { WorkDir } from '../config/config.js';
import { withFileLock } from '../knowledge/file-lock.js';
import { commitAll } from '../knowledge/version_history.js';
import { rewriteWikiLinksForRenamedKnowledgeFile } from '../workspace/wiki-link-rewrite.js';
export type FileOperation = 'read' | 'list' | 'search' | 'write' | 'delete';
export type ResolvedFilePath = {
originalPath: string;
resolvedPath: string;
isInsideWorkspace: boolean;
workspaceRelPath: string | null;
};
export type CanonicalFilePath = ResolvedFilePath & {
canonicalPath: string;
};
export type FileStat = {
kind: 'file' | 'dir';
size: number;
mtimeMs: number;
ctimeMs: number;
isSymlink?: boolean;
};
export type DirEntry = {
name: string;
path: string;
resolvedPath: string;
kind: 'file' | 'dir';
stat?: {
size: number;
mtimeMs: number;
};
};
export type ReaddirOptions = {
recursive?: boolean;
includeStats?: boolean;
includeHidden?: boolean;
allowedExtensions?: string[];
};
export type WriteTextOptions = {
atomic?: boolean;
mkdirp?: boolean;
expectedEtag?: string;
};
export type RemoveOptions = {
recursive?: boolean;
trash?: boolean;
};
const DEFAULT_READ_LIMIT = 2000;
const MAX_LINE_LENGTH = 2000;
const MAX_BYTES = 50 * 1024;
const MAX_LINE_SUFFIX = `... (line truncated to ${MAX_LINE_LENGTH} chars)`;
const MAX_BYTES_LABEL = `${MAX_BYTES / 1024} KB`;
let knowledgeCommitTimer: ReturnType<typeof setTimeout> | null = null;
let canonicalWorkspaceRoot: string | null = null;
function isPathInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative === '' || (!!relative && !relative.startsWith('..') && !path.isAbsolute(relative));
}
function expandHomePath(inputPath: string): string {
const trimmed = inputPath.trim();
if (!trimmed) {
throw new Error('Path is required');
}
if (trimmed === '~') {
return os.homedir();
}
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith('~/')) {
return path.join(os.homedir(), trimmed.slice(2));
}
return trimmed;
}
export function resolveFilePath(inputPath: string): ResolvedFilePath {
const originalPath = inputPath;
const expandedPath = expandHomePath(inputPath);
const resolvedPath = path.resolve(path.isAbsolute(expandedPath) ? expandedPath : path.join(WorkDir, expandedPath));
const workspaceRoot = path.resolve(WorkDir);
const isInsideWorkspace = isPathInside(workspaceRoot, resolvedPath);
const workspaceRelPath = isInsideWorkspace
? path.relative(workspaceRoot, resolvedPath).split(path.sep).join('/')
: null;
return { originalPath, resolvedPath, isInsideWorkspace, workspaceRelPath };
}
async function getCanonicalWorkspaceRoot(): Promise<string> {
if (canonicalWorkspaceRoot) return canonicalWorkspaceRoot;
try {
canonicalWorkspaceRoot = await fs.realpath(WorkDir);
} catch {
canonicalWorkspaceRoot = path.resolve(WorkDir);
}
return canonicalWorkspaceRoot;
}
async function canonicalizePathForPermission(resolvedPath: string): Promise<string> {
try {
return await fs.realpath(resolvedPath);
} catch {
const parsed = path.parse(resolvedPath);
const missingParts: string[] = [];
let current = resolvedPath;
while (current !== parsed.root) {
try {
const canonicalParent = await fs.realpath(current);
return path.join(canonicalParent, ...missingParts.reverse());
} catch {
missingParts.push(path.basename(current));
current = path.dirname(current);
}
}
return path.resolve(resolvedPath);
}
}
export async function resolveFilePathForPermission(inputPath: string): Promise<CanonicalFilePath> {
const resolved = resolveFilePath(inputPath);
const [canonicalPath, workspaceRoot] = await Promise.all([
canonicalizePathForPermission(resolved.resolvedPath),
getCanonicalWorkspaceRoot(),
]);
const isInsideWorkspace = isPathInside(workspaceRoot, canonicalPath);
const workspaceRelPath = isInsideWorkspace
? path.relative(workspaceRoot, canonicalPath).split(path.sep).join('/')
: null;
return {
...resolved,
canonicalPath,
isInsideWorkspace,
workspaceRelPath,
};
}
export function computeEtag(size: number, mtimeMs: number): string {
return `${size}:${mtimeMs}`;
}
function statToSchema(stats: Stats): FileStat {
return {
kind: stats.isDirectory() ? 'dir' : 'file',
size: stats.size,
mtimeMs: stats.mtimeMs,
ctimeMs: stats.ctimeMs,
isSymlink: stats.isSymbolicLink() ? true : undefined,
};
}
function scheduleKnowledgeCommit(filename: string): void {
if (knowledgeCommitTimer) {
clearTimeout(knowledgeCommitTimer);
}
knowledgeCommitTimer = setTimeout(() => {
knowledgeCommitTimer = null;
commitAll(`Edit ${filename}`, 'You').catch(err => {
console.error('[VersionHistory] Failed to commit after edit:', err);
});
}, 3 * 60 * 1000);
}
function isKnowledgeMarkdownPath(resolved: ResolvedFilePath): boolean {
return !!resolved.workspaceRelPath
&& resolved.workspaceRelPath.startsWith('knowledge/')
&& resolved.workspaceRelPath.endsWith('.md');
}
function scheduleKnowledgeCommitIfNeeded(resolved: ResolvedFilePath): void {
if (isKnowledgeMarkdownPath(resolved)) {
scheduleKnowledgeCommit(path.basename(resolved.resolvedPath));
}
}
async function assertTextFile(resolvedPath: string): Promise<void> {
const stats = await fs.lstat(resolvedPath);
if (!stats.isFile()) {
throw new Error('Path is not a file');
}
if (stats.size === 0) return;
const handle = await fs.open(resolvedPath, 'r');
try {
const buffer = Buffer.alloc(Math.min(stats.size, 8192));
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0);
const sample = buffer.subarray(0, bytesRead);
let nonPrintableCount = 0;
for (let index = 0; index < sample.length; index++) {
const byte = sample[index];
if (byte === 0) {
throw new Error('Refusing to read binary file as text');
}
if (byte < 9 || (byte > 13 && byte < 32)) {
nonPrintableCount++;
}
}
if (sample.length > 0 && nonPrintableCount / sample.length > 0.3) {
throw new Error('Refusing to read binary file as text');
}
const decoded = sample.toString('utf8');
const replacementChars = (decoded.match(/\uFFFD/g) || []).length;
if (replacementChars > Math.max(3, decoded.length * 0.01)) {
throw new Error('Refusing to read binary file as text');
}
} finally {
await handle.close();
}
}
export async function exists(inputPath: string): Promise<{ exists: boolean; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
const resolved = resolveFilePath(inputPath);
try {
await fs.access(resolved.resolvedPath);
return { exists: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
} catch {
return { exists: false, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
}
}
export async function stat(inputPath: string): Promise<FileStat & { path: string; resolvedPath: string; isInsideWorkspace: boolean; etag: string }> {
const resolved = resolveFilePath(inputPath);
const stats = await fs.lstat(resolved.resolvedPath);
return {
...statToSchema(stats),
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
etag: computeEtag(stats.size, stats.mtimeMs),
};
}
export async function list(inputPath: string, opts?: ReaddirOptions): Promise<Array<DirEntry>> {
const root = resolveFilePath(inputPath || '.');
const entries: Array<DirEntry> = [];
async function readDir(currentPath: string, currentDisplayPath: string): Promise<void> {
const items = await fs.readdir(currentPath, { withFileTypes: true });
for (const item of items) {
if (!opts?.includeHidden && item.name.startsWith('.')) {
continue;
}
const itemPath = path.join(currentPath, item.name);
const displayPath = path.posix.join(currentDisplayPath.split(path.sep).join('/'), item.name);
const itemKind = item.isDirectory() ? 'dir' : item.isFile() ? 'file' : null;
if (!itemKind) continue;
if (itemKind === 'file' && opts?.allowedExtensions?.length) {
const ext = path.extname(item.name);
if (!opts.allowedExtensions.includes(ext)) continue;
}
let itemStat: { size: number; mtimeMs: number } | undefined;
if (opts?.includeStats) {
const stats = await fs.lstat(itemPath);
itemStat = { size: stats.size, mtimeMs: stats.mtimeMs };
}
entries.push({
name: item.name,
path: displayPath,
resolvedPath: itemPath,
kind: itemKind,
stat: itemStat,
});
if (itemKind === 'dir' && opts?.recursive) {
await readDir(itemPath, displayPath);
}
}
}
await readDir(root.resolvedPath, root.originalPath || '.');
entries.sort((a, b) => {
if (a.kind !== b.kind) return a.kind === 'dir' ? -1 : 1;
return a.name.localeCompare(b.name);
});
return entries;
}
export async function readText(inputPath: string, offset?: number, limit?: number) {
const resolved = resolveFilePath(inputPath);
await assertTextFile(resolved.resolvedPath);
const stats = await fs.lstat(resolved.resolvedPath);
const stat = statToSchema(stats);
const etag = computeEtag(stats.size, stats.mtimeMs);
const effectiveOffset = offset ?? 1;
const effectiveLimit = limit ?? DEFAULT_READ_LIMIT;
const start = effectiveOffset - 1;
const stream = createReadStream(resolved.resolvedPath, { encoding: 'utf8' });
const rl = createInterface({ input: stream, crlfDelay: Infinity });
const collected: string[] = [];
let totalLines = 0;
let bytes = 0;
let truncatedByBytes = false;
let hasMoreLines = false;
try {
for await (const text of rl) {
totalLines += 1;
if (totalLines <= start) continue;
if (collected.length >= effectiveLimit) {
hasMoreLines = true;
continue;
}
const line = text.length > MAX_LINE_LENGTH
? text.substring(0, MAX_LINE_LENGTH) + MAX_LINE_SUFFIX
: text;
const size = Buffer.byteLength(line, 'utf-8') + (collected.length > 0 ? 1 : 0);
if (bytes + size > MAX_BYTES) {
truncatedByBytes = true;
hasMoreLines = true;
break;
}
collected.push(line);
bytes += size;
}
} finally {
rl.close();
stream.destroy();
}
if (totalLines < effectiveOffset && !(totalLines === 0 && effectiveOffset === 1)) {
return { error: `Offset ${effectiveOffset} is out of range for this file (${totalLines} lines)` };
}
const prefixed = collected.map((line, index) => `${index + effectiveOffset}: ${line}`);
const lastReadLine = effectiveOffset + collected.length - 1;
const nextOffset = lastReadLine + 1;
let footer: string;
if (truncatedByBytes) {
footer = `(Output capped at ${MAX_BYTES_LABEL}. Showing lines ${effectiveOffset}-${lastReadLine}. Use offset=${nextOffset} to continue.)`;
} else if (hasMoreLines) {
footer = `(Showing lines ${effectiveOffset}-${lastReadLine} of ${totalLines}. Use offset=${nextOffset} to continue.)`;
} else {
footer = `(End of file - total ${totalLines} lines)`;
}
const content = [
`<path>${resolved.originalPath}</path>`,
`<resolvedPath>${resolved.resolvedPath}</resolvedPath>`,
`<type>file</type>`,
`<content>`,
prefixed.join('\n'),
'',
footer,
`</content>`,
].join('\n');
return {
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
encoding: 'utf8' as const,
content,
stat,
etag,
offset: effectiveOffset,
limit: effectiveLimit,
totalLines,
hasMore: hasMoreLines || truncatedByBytes,
};
}
export async function readBuffer(inputPath: string): Promise<{ buffer: Buffer; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
const resolved = resolveFilePath(inputPath);
const buffer = await fs.readFile(resolved.resolvedPath);
return {
buffer,
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
};
}
export async function writeText(inputPath: string, data: string, opts?: WriteTextOptions) {
const resolved = resolveFilePath(inputPath);
const atomic = opts?.atomic !== false;
const mkdirp = opts?.mkdirp !== false;
if (mkdirp) {
await fs.mkdir(path.dirname(resolved.resolvedPath), { recursive: true });
}
const result = await withFileLock(resolved.resolvedPath, async () => {
if (opts?.expectedEtag) {
const existingStats = await fs.lstat(resolved.resolvedPath);
const existingEtag = computeEtag(existingStats.size, existingStats.mtimeMs);
if (existingEtag !== opts.expectedEtag) {
throw new Error('File was modified (ETag mismatch)');
}
}
const buffer = Buffer.from(data, 'utf8');
if (atomic) {
const tempPath = `${resolved.resolvedPath}.tmp.${Date.now()}${Math.random().toString(36).slice(2)}`;
await fs.writeFile(tempPath, buffer);
await fs.rename(tempPath, resolved.resolvedPath);
} else {
await fs.writeFile(resolved.resolvedPath, buffer);
}
const stats = await fs.lstat(resolved.resolvedPath);
return { stat: statToSchema(stats), etag: computeEtag(stats.size, stats.mtimeMs) };
});
scheduleKnowledgeCommitIfNeeded(resolved);
return {
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
stat: result.stat,
etag: result.etag,
};
}
export async function editText(inputPath: string, oldString: string, newString: string, replaceAll = false) {
const resolved = resolveFilePath(inputPath);
await assertTextFile(resolved.resolvedPath);
const content = await fs.readFile(resolved.resolvedPath, 'utf8');
const occurrences = content.split(oldString).length - 1;
if (occurrences === 0) {
return { error: 'oldString not found in file' };
}
if (occurrences > 1 && !replaceAll) {
return { error: `oldString found ${occurrences} times. Use replaceAll: true or provide more context to make it unique.` };
}
const newContent = replaceAll
? content.replaceAll(oldString, newString)
: content.replace(oldString, newString);
await writeText(inputPath, newContent, { atomic: true, mkdirp: true });
return {
success: true,
path: resolved.originalPath,
resolvedPath: resolved.resolvedPath,
isInsideWorkspace: resolved.isInsideWorkspace,
replacements: replaceAll ? occurrences : 1,
};
}
export async function mkdir(inputPath: string, recursive = true): Promise<{ ok: true; path: string; resolvedPath: string; isInsideWorkspace: boolean }> {
const resolved = resolveFilePath(inputPath);
await fs.mkdir(resolved.resolvedPath, { recursive });
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, isInsideWorkspace: resolved.isInsideWorkspace };
}
export async function rename(from: string, to: string, overwrite = false): Promise<{ ok: true; from: string; to: string; resolvedFrom: string; resolvedTo: string }> {
const source = resolveFilePath(from);
const dest = resolveFilePath(to);
await fs.access(source.resolvedPath);
const fromStats = await fs.lstat(source.resolvedPath);
if (!overwrite) {
try {
await fs.access(dest.resolvedPath);
throw new Error('Destination already exists');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
// destination does not exist
} else {
throw err;
}
}
}
await fs.mkdir(path.dirname(dest.resolvedPath), { recursive: true });
await fs.rename(source.resolvedPath, dest.resolvedPath);
if (fromStats.isFile() && isKnowledgeMarkdownPath(source) && isKnowledgeMarkdownPath(dest) && source.workspaceRelPath && dest.workspaceRelPath) {
try {
await rewriteWikiLinksForRenamedKnowledgeFile(WorkDir, source.workspaceRelPath, dest.workspaceRelPath);
} catch (error) {
console.error('Failed to rewrite wiki backlinks after file rename:', error);
}
}
return { ok: true, from, to, resolvedFrom: source.resolvedPath, resolvedTo: dest.resolvedPath };
}
export async function copy(from: string, to: string, overwrite = false): Promise<{ ok: true; from: string; to: string; resolvedFrom: string; resolvedTo: string }> {
const source = resolveFilePath(from);
const dest = resolveFilePath(to);
const fromStats = await fs.lstat(source.resolvedPath);
if (fromStats.isDirectory()) {
throw new Error('Copying directories is not supported');
}
if (!overwrite) {
try {
await fs.access(dest.resolvedPath);
throw new Error('Destination already exists');
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && err.code === 'ENOENT') {
// destination does not exist
} else {
throw err;
}
}
}
await fs.mkdir(path.dirname(dest.resolvedPath), { recursive: true });
await fs.copyFile(source.resolvedPath, dest.resolvedPath);
return { ok: true, from, to, resolvedFrom: source.resolvedPath, resolvedTo: dest.resolvedPath };
}
export async function remove(inputPath: string, opts?: RemoveOptions): Promise<{ ok: true; path: string; resolvedPath: string; trashed?: string }> {
const resolved = resolveFilePath(inputPath);
const stats = await fs.lstat(resolved.resolvedPath);
const trash = opts?.trash !== false;
if (trash) {
const trashDir = path.join(WorkDir, '.trash');
await fs.mkdir(trashDir, { recursive: true });
const timestamp = Date.now();
const basename = path.basename(resolved.resolvedPath);
let finalTrashPath = path.join(trashDir, `${timestamp}-${basename}`);
let counter = 1;
while (true) {
try {
await fs.access(finalTrashPath);
finalTrashPath = path.join(trashDir, `${timestamp}-${counter}-${basename}`);
counter++;
} catch {
break;
}
}
await fs.rename(resolved.resolvedPath, finalTrashPath);
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath, trashed: finalTrashPath };
}
if (stats.isDirectory()) {
if (!opts?.recursive) {
throw new Error('Cannot remove directory without recursive=true');
}
await fs.rm(resolved.resolvedPath, { recursive: true });
} else {
await fs.unlink(resolved.resolvedPath);
}
return { ok: true, path: resolved.originalPath, resolvedPath: resolved.resolvedPath };
}
export async function glob(pattern: string, cwd?: string): Promise<{ files: string[]; resolvedFiles: string[]; count: number; pattern: string; cwd: string; resolvedCwd: string }> {
const root = resolveFilePath(cwd || '.');
const files = await globFiles(pattern, {
cwd: root.resolvedPath,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
});
const resolvedFiles = files.map(file => path.resolve(root.resolvedPath, file));
return {
files,
resolvedFiles,
count: files.length,
pattern,
cwd: cwd || '.',
resolvedCwd: root.resolvedPath,
};
}
export async function grep({
pattern,
searchPath,
fileGlob,
contextLines = 0,
maxResults = 100,
}: {
pattern: string;
searchPath?: string;
fileGlob?: string;
contextLines?: number;
maxResults?: number;
}) {
const root = resolveFilePath(searchPath || '.');
const stats = await fs.lstat(root.resolvedPath);
const candidates = stats.isDirectory()
? await globFiles(fileGlob || '**/*', {
cwd: root.resolvedPath,
nodir: true,
ignore: ['node_modules/**', '.git/**'],
dot: false,
})
: [path.basename(root.resolvedPath)];
const baseDir = stats.isDirectory() ? root.resolvedPath : path.dirname(root.resolvedPath);
const regex = new RegExp(pattern, 'i');
const matches: Array<{ file: string; resolvedPath: string; line: number; content: string; before?: string[]; after?: string[] }> = [];
for (const candidate of candidates) {
if (matches.length >= maxResults) break;
const resolvedPath = stats.isDirectory() ? path.resolve(baseDir, candidate) : root.resolvedPath;
try {
await assertTextFile(resolvedPath);
const text = await fs.readFile(resolvedPath, 'utf8');
const lines = text.split(/\r?\n/);
for (let index = 0; index < lines.length; index++) {
if (!regex.test(lines[index])) continue;
const before = contextLines > 0 ? lines.slice(Math.max(0, index - contextLines), index) : undefined;
const after = contextLines > 0 ? lines.slice(index + 1, Math.min(lines.length, index + 1 + contextLines)) : undefined;
matches.push({
file: stats.isDirectory() ? candidate : root.originalPath,
resolvedPath,
line: index + 1,
content: lines[index].trim(),
before,
after,
});
if (matches.length >= maxResults) break;
}
} catch {
// Skip unreadable and binary files.
}
}
return {
matches,
count: matches.length,
tool: 'internal-grep',
searchPath: searchPath || '.',
resolvedSearchPath: root.resolvedPath,
};
}

View file

@ -1,21 +1,21 @@
export function getRaw(): string { export function getRaw(): string {
return `--- return `---
tools: tools:
workspace-writeFile: file-writeText:
type: builtin type: builtin
name: workspace-writeFile name: file-writeText
workspace-readFile: file-readText:
type: builtin type: builtin
name: workspace-readFile name: file-readText
workspace-edit: file-editText:
type: builtin type: builtin
name: workspace-edit name: file-editText
workspace-readdir: file-list:
type: builtin type: builtin
name: workspace-readdir name: file-list
workspace-mkdir: file-mkdir:
type: builtin type: builtin
name: workspace-mkdir name: file-mkdir
--- ---
# Agent Notes # Agent Notes

View file

@ -267,7 +267,7 @@ async function createNotesFromBatch(
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 += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\n`; message += `- You may also create or update "${SUGGESTED_TOPICS_REL_PATH}" to maintain curated suggested-topic cards\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 or "${SUGGESTED_TOPICS_REL_PATH}" (when you need full content) and write updates\n`; message += `- Use file tools to read existing notes or "${SUGGESTED_TOPICS_REL_PATH}" (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`;
// Add the knowledge base index // Add the knowledge base index
@ -297,16 +297,16 @@ async function createNotesFromBatch(
if (event.type !== "tool-invocation") { if (event.type !== "tool-invocation") {
return; return;
} }
if (event.toolName !== "workspace-writeFile" && event.toolName !== "workspace-edit") { if (event.toolName !== "file-writeText" && event.toolName !== "file-editText") {
return; return;
} }
const toolPath = extractPathFromToolInput(event.input); const toolPath = extractPathFromToolInput(event.input);
if (!toolPath) { if (!toolPath) {
return; return;
} }
if (event.toolName === "workspace-writeFile") { if (event.toolName === "file-writeText") {
notesCreated.add(toolPath); notesCreated.add(toolPath);
} else if (event.toolName === "workspace-edit") { } else if (event.toolName === "file-editText") {
notesModified.add(toolPath); notesModified.add(toolPath);
} }
}); });

View file

@ -98,7 +98,7 @@ This brief refreshes every 15 minutes, so it should always reflect the **current
## Technical Instructions ## Technical Instructions
**IMPORTANT:** All workspace tools (workspace-readdir, workspace-readFile, workspace-grep, etc.) take paths **relative to the workspace root**. Use paths like \`calendar_sync/\`, \`gmail_sync/\`, \`knowledge/\` — NOT absolute paths. **IMPORTANT:** File tools accept relative paths that resolve against the Rowboat workspace root. For workspace data, use paths like \`calendar_sync/\`, \`gmail_sync/\`, \`knowledge/\` — NOT absolute paths.
**IMPORTANT:** Check the current date. If the date has changed since the content was last generated, clear everything and start fresh for the new day. **IMPORTANT:** Check the current date. If the date has changed since the content was last generated, clear everything and start fresh for the new day.
@ -136,8 +136,8 @@ This is the most time-sensitive section — it orients the user on what's coming
6. **IMPORTANT:** Do NOT say "nothing in the next X hours" if there IS an event within that window. Always compute the actual time difference between now and the next event's start time before writing this section. 6. **IMPORTANT:** Do NOT say "nothing in the next X hours" if there IS an event within that window. Always compute the actual time difference between now and the next event's start time before writing this section.
### Calendar ### Calendar
1. Use \`workspace-readdir\` with path \`calendar_sync\` to list files 1. Use \`file-list\` with path \`calendar_sync\` to list files
2. Use \`workspace-readFile\` to read each \`.json\` event file (e.g. \`calendar_sync/eventid123.json\`) 2. Use \`file-readText\` to read each \`.json\` event file (e.g. \`calendar_sync/eventid123.json\`)
3. Filter for events happening **today** (compare the event's start dateTime or date to the current date) 3. Filter for events happening **today** (compare the event's start dateTime or date to the current date)
4. **After morning:** Only include events that **haven't ended yet**. Don't show meetings that already happened the user was there. If it's afternoon and all meetings are done, show an empty calendar block. 4. **After morning:** Only include events that **haven't ended yet**. Don't show meetings that already happened the user was there. If it's afternoon and all meetings are done, show an empty calendar block.
5. **Always** output a \\\`\\\`\\\`calendar block — even if there are no events today. If no events, output an empty events array: 5. **Always** output a \\\`\\\`\\\`calendar block — even if there are no events today. If no events, output an empty events array:
@ -160,8 +160,8 @@ If there are events, include them:
7. If there are no remaining events, don't add filler text the empty calendar block speaks for itself. 7. If there are no remaining events, don't add filler text the empty calendar block speaks for itself.
### Emails ### Emails
1. Use \`workspace-readdir\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`) 1. Use \`file-list\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
2. Use \`workspace-readFile\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`) 2. Use \`file-readText\` to read the email markdown files (e.g. \`gmail_sync/threadid123.md\`)
3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response 3. Check the frontmatter \`action\` field — emails with \`action: reply\` or \`action: respond\` need a response
4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example: 4. Output ALL emails (both action items and FYI) in a single \\\`\\\`\\\`emails block as a JSON array. Emails needing a response get a \`draft_response\`. Write drafts in the user's voice — direct, informal, no fluff. Example:
@ -180,7 +180,7 @@ If there are events, include them:
This section is about things the user might not be aware of from yesterday. Think of it as: "Here's what happened while you were away." This section is about things the user might not be aware of from yesterday. Think of it as: "Here's what happened while you were away."
- **Skip recurring/routine events entirely.** The user knows they have standup every day. Don't mention it unless something unusual happened during it. - **Skip recurring/routine events entirely.** The user knows they have standup every day. Don't mention it unless something unusual happened during it.
- **Read yesterday's meeting notes** from \`knowledge/Meetings/\`. The directory structure is nested: \`knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md\` (e.g. \`knowledge/Meetings/rowboat/2026-03-30/meeting-2026-03-30T13-49-27.md\`). Use \`workspace-readdir\` with \`recursive: true\` on \`knowledge/Meetings\` to find all files, then filter for files in a folder matching yesterday's date. Read the matching files with \`workspace-readFile\`. Summarize key outcomes: decisions made, action items assigned, blockers raised, anything that changes priorities. - **Read yesterday's meeting notes** from \`knowledge/Meetings/\`. The directory structure is nested: \`knowledge/Meetings/<source>/<YYYY-MM-DD>/meeting-<timestamp>.md\` (e.g. \`knowledge/Meetings/rowboat/2026-03-30/meeting-2026-03-30T13-49-27.md\`). Use \`file-list\` with \`recursive: true\` on \`knowledge/Meetings\` to find all files, then filter for files in a folder matching yesterday's date. Read the matching files with \`file-readText\`. Summarize key outcomes: decisions made, action items assigned, blockers raised, anything that changes priorities.
- Check yesterday's emails in \`gmail_sync/\` for anything that went unresolved. - Check yesterday's emails in \`gmail_sync/\` for anything that went unresolved.
- Surface things that matter: commitments made, deadlines mentioned, important updates. - Surface things that matter: commitments made, deadlines mentioned, important updates.
- **If nothing notable happened, say "Quiet day yesterday — nothing to flag." and move on.** Don't manufacture content. - **If nothing notable happened, say "Quiet day yesterday — nothing to flag." and move on.** Don't manufacture content.
@ -192,7 +192,7 @@ This is NOT a generic task list. These are the things the user should actually f
- **Do NOT list calendar events as tasks.** They're already in the Calendar section. - **Do NOT list calendar events as tasks.** They're already in the Calendar section.
- **Do NOT list trivial admin** (filing small invoices, archiving spam, etc.) the user can handle that in 30 seconds without being told to. - **Do NOT list trivial admin** (filing small invoices, archiving spam, etc.) the user can handle that in 30 seconds without being told to.
- **Pull action items from yesterday's meeting notes** in \`knowledge/Meetings/<source>/<YYYY-MM-DD>/\` — these are often the most important source of real tasks. - **Pull action items from yesterday's meeting notes** in \`knowledge/Meetings/<source>/<YYYY-MM-DD>/\` — these are often the most important source of real tasks.
- Search through \`knowledge/\` using \`workspace-grep\` and \`workspace-readdir\` for checkbox items (\`- [ ]\`), explicit action items, deadlines, or follow-ups. - Search through \`knowledge/\` using \`file-grep\` and \`file-list\` for checkbox items (\`- [ ]\`), explicit action items, deadlines, or follow-ups.
- **Rank by importance.** Lead with the most critical item. If something is time-sensitive, say when it needs to happen by. - **Rank by importance.** Lead with the most critical item. If something is time-sensitive, say when it needs to happen by.
- Add brief context for why each item matters if it's not obvious. - Add brief context for why each item matters if it's not obvious.
- **If there are no real tasks, say "No pressing tasks today — good day to make progress on bigger items." Don't invent busywork.** - **If there are no real tasks, say "No pressing tasks today — good day to make progress on bigger items." Don't invent busywork.**
@ -221,4 +221,4 @@ When you see a target region associated with your task (during a scheduled run),
- Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate) - Use the existing content as context (e.g., to update rather than regenerate from scratch if appropriate)
- Do NOT include the target tags themselves in your response - Do NOT include the target tags themselves in your response
`; `;
} }

View file

@ -78,13 +78,13 @@ async function labelEmailBatch(
}); });
let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`; let message = `Label the following ${files.length} email files by prepending YAML frontmatter.\n\n`;
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`; message += `**Important:** Use workspace-relative paths with file-editText (e.g. "gmail_sync/email.md", NOT absolute paths).\n\n`;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const relativePath = path.relative(WorkDir, file.path); const relativePath = path.relative(WorkDir, file.path);
const truncated = file.content.length > MAX_CONTENT_LENGTH const truncated = file.content.length > MAX_CONTENT_LENGTH
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]' ? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use file-readText for full content ...]'
: file.content; : file.content;
message += `## File ${i + 1}: ${relativePath}\n\n`; message += `## File ${i + 1}: ${relativePath}\n\n`;
@ -98,7 +98,7 @@ async function labelEmailBatch(
if (event.type !== 'tool-invocation') { if (event.type !== 'tool-invocation') {
return; return;
} }
if (event.toolName !== 'workspace-edit') { if (event.toolName !== 'file-editText') {
return; return;
} }
try { try {

View file

@ -3,15 +3,15 @@ import { renderTagSystemForEmails } from './tag_system.js';
export function getRaw(): string { export function getRaw(): string {
return `--- return `---
tools: tools:
workspace-readFile: file-readText:
type: builtin type: builtin
name: workspace-readFile name: file-readText
workspace-edit: file-editText:
type: builtin type: builtin
name: workspace-edit name: file-editText
workspace-readdir: file-list:
type: builtin type: builtin
name: workspace-readdir name: file-list
--- ---
# Task # Task
@ -70,7 +70,7 @@ ${renderTagSystemForEmails()}
- **Action**: Does this need a response (\`action-required\`), is it time-sensitive (\`urgent\`), or are you waiting on them (\`waiting\`)? Use \`""\` if none apply. **Do NOT use \`fyi\` as an action value** — it is not a valid action tag. - **Action**: Does this need a response (\`action-required\`), is it time-sensitive (\`urgent\`), or are you waiting on them (\`waiting\`)? Use \`""\` if none apply. **Do NOT use \`fyi\` as an action value** — it is not a valid action tag.
3. **Apply noise tags aggressively.** Noise tags can and should coexist with relationship and topic tags. A salary confirmation from your finance team should have BOTH \`relationship: ['team']\` AND \`filter: ['receipt']\`. The noise tag determines whether a note is created — it overrides relationship and topic signals. 3. **Apply noise tags aggressively.** Noise tags can and should coexist with relationship and topic tags. A salary confirmation from your finance team should have BOTH \`relationship: ['team']\` AND \`filter: ['receipt']\`. The noise tag determines whether a note is created — it overrides relationship and topic signals.
4. Be accurate only apply labels that clearly fit. But when an email IS noise, always add the noise tag even when other tags are present. 4. Be accurate only apply labels that clearly fit. But when an email IS noise, always add the noise tag even when other tags are present.
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line. 5. Use \`file-editText\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Subject\` heading), and the newString should be the frontmatter followed by that same first line.
6. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp. 6. Always include \`processed: true\` and \`labeled_at\` with the current ISO timestamp.
7. If the email already has frontmatter (starts with \`---\`), skip it. 7. If the email already has frontmatter (starts with \`---\`), skip it.

View file

@ -26,7 +26,7 @@ Every run message has this shape:
**Objective:** **Objective:**
<the user-authored objective usually 1-3 sentences describing what the note should keep being> <the user-authored objective usually 1-3 sentences describing what the note should keep being>
Start by calling \`workspace-readFile\` on \`<filePath>\` ... patch-style edits ... Start by calling \`file-readText\` on \`<filePath>\` ... patch-style edits ...
For **manual** runs, an optional trailing block may appear: For **manual** runs, an optional trailing block may appear:
@ -51,10 +51,10 @@ You own the **entire body below the H1** — you may freely add, edit, reorganiz
**Make incremental, patch-style edits not one-shot rewrites.** **Make incremental, patch-style edits not one-shot rewrites.**
The right pattern on every run: The right pattern on every run:
1. \`workspace-readFile\` to fetch the current note. 1. \`file-readText\` to fetch the current note.
2. Decide on the *first* change you need to make (add a section, replace a stale figure, dedupe entries, fix an out-of-date paragraph). 2. Decide on the *first* change you need to make (add a section, replace a stale figure, dedupe entries, fix an out-of-date paragraph).
3. \`workspace-edit\` to make that one change. 3. \`file-editText\` to make that one change.
4. \`workspace-readFile\` again to confirm the result. 4. \`file-readText\` again to confirm the result.
5. Decide the *next* change. Repeat. 5. Decide the *next* change. Repeat.
Why patch-style: Why patch-style:
@ -63,8 +63,8 @@ Why patch-style:
- It lets you abort partway if a tool call fails, leaving the note in a consistent partial state instead of a clobbered one. - It lets you abort partway if a tool call fails, leaving the note in a consistent partial state instead of a clobbered one.
Avoid: Avoid:
- Calling \`workspace-writeFile\` to replace the entire body. That's the no-go path. - Calling \`file-writeText\` to replace the entire body. That's the no-go path.
- Building up the entire new body in your head and emitting it in a single \`workspace-edit\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps. - Building up the entire new body in your head and emitting it in a single \`file-editText\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps.
# Body Structure (defaults) # Body Structure (defaults)
@ -105,9 +105,9 @@ When skipping, still end with a summary line (see "Final Summary" below) so the
You have the full workspace toolkit. Quick reference for common cases: You have the full workspace toolkit. Quick reference for common cases:
- **\`workspace-readFile\`, \`workspace-edit\`, \`workspace-writeFile\`** — read and modify the note's body. Frontmatter is hands-off. Prefer many small \`workspace-edit\` calls over one giant \`workspace-writeFile\`. - **\`file-readText\`, \`file-editText\`, \`file-writeText\`** — read and modify the note's body. Frontmatter is hands-off. Prefer many small \`file-editText\` calls over one giant \`file-writeText\`.
- **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the objective needs information beyond the workspace. - **\`web-search\`** — the public web (news, prices, status pages, documentation). Use when the objective needs information beyond the workspace.
- **\`workspace-grep\`, \`workspace-glob\`, \`workspace-readdir\`** — search the user's knowledge graph and synced data. - **\`file-grep\`, \`file-glob\`, \`file-list\`** — search the user's knowledge graph and synced data.
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if the objective references attached files. - **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if the objective references attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when the objective needs structured data from a connected service the user has authorized. - **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when the objective needs structured data from a connected service the user has authorized.
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering. - **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
@ -124,9 +124,9 @@ The user's knowledge graph is plain markdown in \`${WorkDir}/knowledge/\`, organ
Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when the objective references emails, meetings, or calendar events. Synced external data often sits alongside under \`gmail_sync/\`, \`calendar_sync/\`, \`granola_sync/\`, \`fireflies_sync/\` — consult these when the objective references emails, meetings, or calendar events.
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root. **CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\` - \`file-grep({ pattern: "Acme", searchPath: "knowledge/" })\`
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\` - \`file-readText("knowledge/People/Sarah Chen.md")\`
- \`workspace-readdir("gmail_sync/")\` - \`file-list("gmail_sync/")\`
# Failure & Fallback # Failure & Fallback

View file

@ -30,7 +30,7 @@ function truncate(s: string | null | undefined, n = SUMMARY_LOG_LIMIT): string {
// Agent run message // Agent run message
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const LIVE_NOTE_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call `workspace-edit`. Only edit the file if the event provides new or changed information that the objective implies should be reflected.'; const LIVE_NOTE_EVENT_DECISION_DIRECTIVE = '**Decision:** Determine whether this event genuinely warrants updating the note. If the event is not meaningfully relevant on closer inspection, skip the update — do not call `file-editText`. Only edit the file if the event provides new or changed information that the objective implies should be reflected.';
const LIVE_NOTE_MANUAL_PAREN = 'user-triggered — either the Run button in the Live Note panel or the `run-live-note-agent` tool'; const LIVE_NOTE_MANUAL_PAREN = 'user-triggered — either the Run button in the Live Note panel or the `run-live-note-agent` tool';
@ -44,8 +44,8 @@ function buildMessage(
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' }); const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone; const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
// Workspace-relative path the agent's tools (workspace-readFile, // Workspace-relative path the agent's tools (file-readText,
// workspace-edit) expect. Internal storage is knowledge/-relative. // file-editText) expect. Internal storage is knowledge/-relative.
const wsPath = `knowledge/${filePath}`; const wsPath = `knowledge/${filePath}`;
const baseMessage = `Update the live note at \`${wsPath}\`. const baseMessage = `Update the live note at \`${wsPath}\`.
@ -55,7 +55,7 @@ function buildMessage(
**Objective:** **Objective:**
${live.objective} ${live.objective}
Start by calling \`workspace-readFile\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then make small, incremental edits with \`workspace-edit\` to bring the body in line with the objective: edit one region, re-read to verify, then edit the next region. Avoid one-shot rewrites of the whole body. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`; Start by calling \`file-readText\` on \`${wsPath}\` to read the current note (frontmatter + body) — the body may be long and you should fetch it yourself rather than rely on a snapshot. Then make small, incremental edits with \`file-editText\` to bring the body in line with the objective: edit one region, re-read to verify, then edit the next region. Avoid one-shot rewrites of the whole body. Do not modify the YAML frontmatter at the top of the file — that block is owned by the user and the runtime.`;
return baseMessage + buildTriggerBlock({ return baseMessage + buildTriggerBlock({
trigger, trigger,

View file

@ -4,27 +4,27 @@ import { renderNoteEffectRules } from './tag_system.js';
export function getRaw(): string { export function getRaw(): string {
return `--- return `---
tools: tools:
workspace-writeFile: file-writeText:
type: builtin type: builtin
name: workspace-writeFile name: file-writeText
workspace-readFile: file-readText:
type: builtin type: builtin
name: workspace-readFile name: file-readText
workspace-edit: file-editText:
type: builtin type: builtin
name: workspace-edit name: file-editText
workspace-readdir: file-list:
type: builtin type: builtin
name: workspace-readdir name: file-list
workspace-mkdir: file-mkdir:
type: builtin type: builtin
name: workspace-mkdir name: file-mkdir
workspace-grep: file-grep:
type: builtin type: builtin
name: workspace-grep name: file-grep
workspace-glob: file-glob:
type: builtin type: builtin
name: workspace-glob name: file-glob
--- ---
# Context # Context
@ -92,17 +92,17 @@ You have access to these tools:
**For reading files:** **For reading files:**
\`\`\` \`\`\`
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" }) file-readText({ path: "knowledge/People/Sarah Chen.md" })
\`\`\` \`\`\`
**For creating NEW files:** **For creating NEW files:**
\`\`\` \`\`\`
workspace-writeFile({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." }) file-writeText({ path: "knowledge/People/Sarah Chen.md", data: "# Sarah Chen\\n\\n..." })
\`\`\` \`\`\`
**For editing EXISTING files (preferred for updates):** **For editing EXISTING files (preferred for updates):**
\`\`\` \`\`\`
workspace-edit({ file-editText({
path: "knowledge/People/Sarah Chen.md", path: "knowledge/People/Sarah Chen.md",
oldString: "## Activity\\n", oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n" newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
@ -111,27 +111,27 @@ workspace-edit({
**For listing directories:** **For listing directories:**
\`\`\` \`\`\`
workspace-readdir({ path: "knowledge/People" }) file-list({ path: "knowledge/People" })
\`\`\` \`\`\`
**For creating directories:** **For creating directories:**
\`\`\` \`\`\`
workspace-mkdir({ path: "knowledge/Projects", recursive: true }) file-mkdir({ path: "knowledge/Projects", recursive: true })
\`\`\` \`\`\`
**For searching files:** **For searching files:**
\`\`\` \`\`\`
workspace-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" }) file-grep({ pattern: "Acme Corp", searchPath: "knowledge", fileGlob: "*.md" })
\`\`\` \`\`\`
**For finding files by pattern:** **For finding files by pattern:**
\`\`\` \`\`\`
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" }) file-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
\`\`\` \`\`\`
**IMPORTANT:** **IMPORTANT:**
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields) - Use \`file-editText\` for updating existing notes (adding activity, updating fields)
- Use \`workspace-writeFile\` only for creating new notes - Use \`file-writeText\` only for creating new notes
- Prefer the knowledge_index for entity resolution (it's faster than grep) - Prefer the knowledge_index for entity resolution (it's faster than grep)
# Output # Output
@ -158,7 +158,7 @@ ${renderNoteEffectRules()}
Read the source file and determine if it's a meeting or email. Read the source file and determine if it's a meeting or email.
\`\`\` \`\`\`
workspace-readFile({ path: "{source_file}" }) file-readText({ path: "{source_file}" })
\`\`\` \`\`\`
**Meeting indicators:** **Meeting indicators:**
@ -262,7 +262,7 @@ If processing, continue to Step 2.
# Step 2: Read and Parse Source File # Step 2: Read and Parse Source File
\`\`\` \`\`\`
workspace-readFile({ path: "{source_file}" }) file-readText({ path: "{source_file}" })
\`\`\` \`\`\`
Extract metadata: Extract metadata:
@ -359,7 +359,7 @@ From index, find matches for:
Only read the full note content when you need details not in the index (e.g., activity logs, open items): Only read the full note content when you need details not in the index (e.g., activity logs, open items):
\`\`\`bash \`\`\`bash
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" }) file-readText({ path: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\` \`\`\`
**Why read these notes:** **Why read these notes:**
@ -445,10 +445,10 @@ When multiple candidates match a variant, disambiguate:
**By organization (strongest signal):** **By organization (strongest signal):**
\`\`\` \`\`\`
# "David" could be David Kim or David Chen # "David" could be David Kim or David Chen
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" }) file-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Output: **Organization:** [[Acme Corp]] # Output: **Organization:** [[Acme Corp]]
workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" }) file-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David Chen.md" })
# Output: **Organization:** [[Other Corp]] # Output: **Organization:** [[Other Corp]]
# Source is from Acme context "David" = "David Kim" # Source is from Acme context "David" = "David Kim"
@ -456,14 +456,14 @@ workspace-grep({ pattern: "Acme", searchPath: "{knowledge_folder}/People/David C
**By email (definitive):** **By email (definitive):**
\`\`\` \`\`\`
workspace-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" }) file-grep({ pattern: "david@acme.com", searchPath: "{knowledge_folder}/People/David Kim.md" })
# Exact email match is definitive # Exact email match is definitive
\`\`\` \`\`\`
**By role:** **By role:**
\`\`\` \`\`\`
# Source mentions "their CTO" # Source mentions "their CTO"
workspace-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" }) file-grep({ pattern: "Role.*CTO", searchPath: "{knowledge_folder}/People" })
# Filter results by organization context # Filter results by organization context
\`\`\` \`\`\`
@ -959,7 +959,7 @@ Before writing, compare extracted content against existing notes.
## Check Activity Log ## Check Activity Log
\`\`\` \`\`\`
workspace-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" }) file-grep({ pattern: "2025-01-15", searchPath: "{knowledge_folder}/People/Sarah Chen.md" })
\`\`\` \`\`\`
If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction. If an entry for this date/source already exists, this may have been processed. Skip or verify different interaction.
@ -993,20 +993,20 @@ If new info contradicts existing:
- Wait for the tool to return before generating the next note. - Wait for the tool to return before generating the next note.
- Do NOT batch multiple write commands in a single response. - Do NOT batch multiple write commands in a single response.
**For NEW entities (use workspace-writeFile):** **For NEW entities (use file-writeText):**
\`\`\` \`\`\`
workspace-writeFile({ file-writeText({
path: "{knowledge_folder}/People/Jennifer.md", path: "{knowledge_folder}/People/Jennifer.md",
data: "# Jennifer\\n\\n## Summary\\n..." data: "# Jennifer\\n\\n## Summary\\n..."
}) })
\`\`\` \`\`\`
**For EXISTING entities (use workspace-edit):** **For EXISTING entities (use file-editText):**
- Read current content first with workspace-readFile - Read current content first with file-readText
- Use workspace-edit to add activity entry at TOP (reverse chronological) - Use file-editText to add activity entry at TOP (reverse chronological)
- Update fields using targeted edits - Update fields using targeted edits
\`\`\` \`\`\`
workspace-edit({ file-editText({
path: "{knowledge_folder}/People/Sarah Chen.md", path: "{knowledge_folder}/People/Sarah Chen.md",
oldString: "## Activity\\n", oldString: "## Activity\\n",
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n" newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
@ -1016,8 +1016,8 @@ workspace-edit({
**For \`suggested-topics.md\`:** **For \`suggested-topics.md\`:**
- Use workspace-relative path \`suggested-topics.md\` - Use workspace-relative path \`suggested-topics.md\`
- Read the current file if you need the latest content - Read the current file if you need the latest content
- Use \`workspace-writeFile\` to create or rewrite the file when that is simpler and cleaner - Use \`file-writeText\` to create or rewrite the file when that is simpler and cleaner
- Use \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable - Use \`file-editText\` for small targeted edits only if that keeps the file deduped and readable
## 9b: Apply State Changes ## 9b: Apply State Changes

View file

@ -3,15 +3,15 @@ import { renderTagSystemForNotes } from './tag_system.js';
export function getRaw(): string { export function getRaw(): string {
return `--- return `---
tools: tools:
workspace-readFile: file-readText:
type: builtin type: builtin
name: workspace-readFile name: file-readText
workspace-edit: file-editText:
type: builtin type: builtin
name: workspace-edit name: file-editText
workspace-readdir: file-list:
type: builtin type: builtin
name: workspace-readdir name: file-list
--- ---
# Task # Task
@ -23,7 +23,7 @@ You are a note tagging agent. Given a batch of knowledge notes (People, Organiza
2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/, Meetings/). 2. Determine the note type from its folder path (People/, Organizations/, Projects/, Topics/, Meetings/).
3. Classify the note using the Rowboat Tag System (Note Tags section) appended below. 3. Classify the note using the Rowboat Tag System (Note Tags section) appended below.
4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). For Meetings, extract metadata from the note content and file path (see Meeting extraction rules below). 4. Extract attributes from the note's \`## Info\` section (or \`## About\` for Topics). For Meetings, extract metadata from the note content and file path (see Meeting extraction rules below).
5. Use \`workspace-edit\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line. 5. Use \`file-editText\` to prepend YAML frontmatter to the file. The oldString should be the first line of the file (the \`# Title\` heading), and the newString should be the frontmatter followed by that same first line.
6. If the note already has frontmatter (starts with \`---\`), skip it. 6. If the note already has frontmatter (starts with \`---\`), skip it.
# Frontmatter Format # Frontmatter Format

View file

@ -91,13 +91,13 @@ async function tagNoteBatch(
}); });
let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`; let message = `Tag the following ${files.length} knowledge notes by prepending YAML frontmatter with appropriate tags.\n\n`;
message += `**Important:** Use workspace-relative paths with workspace-edit (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`; message += `**Important:** Use workspace-relative paths with file-editText (e.g. "knowledge/People/Sarah Chen.md", NOT absolute paths).\n\n`;
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files[i]; const file = files[i];
const relativePath = path.relative(WorkDir, file.path); const relativePath = path.relative(WorkDir, file.path);
const truncated = file.content.length > MAX_CONTENT_LENGTH const truncated = file.content.length > MAX_CONTENT_LENGTH
? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use workspace-readFile for full content ...]' ? file.content.slice(0, MAX_CONTENT_LENGTH) + '\n\n[... content truncated, use file-readText for full content ...]'
: file.content; : file.content;
message += `## File ${i + 1}: ${relativePath}\n\n`; message += `## File ${i + 1}: ${relativePath}\n\n`;
@ -111,7 +111,7 @@ async function tagNoteBatch(
if (event.type !== 'tool-invocation') { if (event.type !== 'tool-invocation') {
return; return;
} }
if (event.toolName !== 'workspace-edit') { if (event.toolName !== 'file-editText') {
return; return;
} }
try { try {

View file

@ -1,20 +1,20 @@
--- ---
tools: tools:
workspace-readFile: file-readText:
type: builtin type: builtin
name: workspace-readFile name: file-readText
workspace-writeFile: file-writeText:
type: builtin type: builtin
name: workspace-writeFile name: file-writeText
workspace-readdir: file-list:
type: builtin type: builtin
name: workspace-readdir name: file-list
workspace-mkdir: file-mkdir:
type: builtin type: builtin
name: workspace-mkdir name: file-mkdir
workspace-exists: file-exists:
type: builtin type: builtin
name: workspace-exists name: file-exists
executeCommand: executeCommand:
type: builtin type: builtin
name: executeCommand name: executeCommand
@ -41,8 +41,8 @@ All state is stored in `pre-built/email-draft/`:
On first run, check if state exists. If not, create it: On first run, check if state exists. If not, create it:
1. Use `workspace-exists` to check if `pre-built/email-draft/state.json` exists 1. Use `file-exists` to check if `pre-built/email-draft/state.json` exists
2. If not, use `workspace-mkdir` to create `pre-built/email-draft/` and `pre-built/email-draft/drafts/` 2. If not, use `file-mkdir` to create `pre-built/email-draft/` and `pre-built/email-draft/drafts/`
3. Initialize `state.json` with empty arrays and a timestamp of "1970-01-01T00:00:00Z" 3. Initialize `state.json` with empty arrays and a timestamp of "1970-01-01T00:00:00Z"
## Processing Flow ## Processing Flow
@ -56,7 +56,7 @@ Read `pre-built/email-draft/state.json` to get:
### Step 2: Scan for New Emails ### Step 2: Scan for New Emails
List emails in `gmail_sync/` folder using `workspace-readdir`. List emails in `gmail_sync/` folder using `file-list`.
For each email file: For each email file:
1. Extract the email ID from filename (e.g., `19048cf9c0317981.md``19048cf9c0317981`) 1. Extract the email ID from filename (e.g., `19048cf9c0317981.md``19048cf9c0317981`)

View file

@ -1,20 +1,20 @@
--- ---
tools: tools:
workspace-readFile: file-readText:
type: builtin type: builtin
name: workspace-readFile name: file-readText
workspace-writeFile: file-writeText:
type: builtin type: builtin
name: workspace-writeFile name: file-writeText
workspace-readdir: file-list:
type: builtin type: builtin
name: workspace-readdir name: file-list
workspace-mkdir: file-mkdir:
type: builtin type: builtin
name: workspace-mkdir name: file-mkdir
workspace-exists: file-exists:
type: builtin type: builtin
name: workspace-exists name: file-exists
executeCommand: executeCommand:
type: builtin type: builtin
name: executeCommand name: executeCommand
@ -40,8 +40,8 @@ All state is stored in `pre-built/meeting-prep/`:
On first run, check if state exists. If not, create it: On first run, check if state exists. If not, create it:
1. Use `workspace-exists` to check if `pre-built/meeting-prep/state.json` exists 1. Use `file-exists` to check if `pre-built/meeting-prep/state.json` exists
2. If not, use `workspace-mkdir` to create `pre-built/meeting-prep/` and `pre-built/meeting-prep/briefs/` 2. If not, use `file-mkdir` to create `pre-built/meeting-prep/` and `pre-built/meeting-prep/briefs/`
3. Initialize `state.json` with empty `prepared` array and current timestamp 3. Initialize `state.json` with empty `prepared` array and current timestamp
## Processing Flow ## Processing Flow
@ -54,7 +54,7 @@ Read `pre-built/meeting-prep/state.json` to get:
### Step 2: Scan for Upcoming Meetings ### Step 2: Scan for Upcoming Meetings
List calendar events in `calendar_sync/` folder using `workspace-readdir`. List calendar events in `calendar_sync/` folder using `file-list`.
For each event file: For each event file:
1. Read the JSON content 1. Read the JSON content

View file

@ -9,7 +9,7 @@ import { IAbortRegistry } from "./abort-registry.js";
import { IRunsLock } from "./lock.js"; import { IRunsLock } from "./lock.js";
import { forceCloseAllMcpClients } from "../mcp/mcp.js"; import { forceCloseAllMcpClients } from "../mcp/mcp.js";
import { extractCommandNames } from "../application/lib/command-executor.js"; import { extractCommandNames } from "../application/lib/command-executor.js";
import { addToSecurityConfig } from "../config/security.js"; import { addFileAccessGrant, addToSecurityConfig } from "../config/security.js";
import { loadAgent } from "../agents/runtime.js"; import { loadAgent } from "../agents/runtime.js";
import { getDefaultModelAndProvider } from "../models/defaults.js"; import { getDefaultModelAndProvider } from "../models/defaults.js";
@ -60,7 +60,12 @@ export async function authorizePermission(runId: string, ev: z.infer<typeof Tool
&& e.toolCall.toolCallId === rest.toolCallId && e.toolCall.toolCallId === rest.toolCallId
&& JSON.stringify(e.subflow) === JSON.stringify(rest.subflow) && JSON.stringify(e.subflow) === JSON.stringify(rest.subflow)
); );
if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) { if (permReqEvent?.permission?.kind === "file") {
await addFileAccessGrant({
operation: permReqEvent.permission.operation,
pathPrefix: permReqEvent.permission.pathPrefix,
});
} else if (permReqEvent && typeof permReqEvent.toolCall.arguments === 'object' && permReqEvent.toolCall.arguments !== null && 'command' in permReqEvent.toolCall.arguments) {
const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command)); const commandNames = extractCommandNames(String(permReqEvent.toolCall.arguments.command));
if (commandNames.length > 0) { if (commandNames.length > 0) {
await addToSecurityConfig(commandNames); await addToSecurityConfig(commandNames);

View file

@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"exclude": [
"src/**/*.test.ts",
"src/**/*.spec.ts"
]
}

View file

@ -0,0 +1,11 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
environment: "node",
include: ["src/**/*.test.ts", "src/**/*.spec.ts"],
globals: false,
clearMocks: true,
restoreMocks: true,
},
});

View file

@ -83,9 +83,23 @@ export const AskHumanResponseEvent = BaseRunEvent.extend({
response: z.string(), response: z.string(),
}); });
export const ToolPermissionMetadata = z.discriminatedUnion("kind", [
z.object({
kind: z.literal("command"),
commandNames: z.array(z.string()),
}),
z.object({
kind: z.literal("file"),
operation: z.enum(["read", "list", "search", "write", "delete"]),
paths: z.array(z.string()),
pathPrefix: z.string(),
}),
]);
export const ToolPermissionRequestEvent = BaseRunEvent.extend({ export const ToolPermissionRequestEvent = BaseRunEvent.extend({
type: z.literal("tool-permission-request"), type: z.literal("tool-permission-request"),
toolCall: ToolCallPart, toolCall: ToolCallPart,
permission: ToolPermissionMetadata.optional(),
}); });
export const ToolPermissionResponseEvent = BaseRunEvent.extend({ export const ToolPermissionResponseEvent = BaseRunEvent.extend({

238
apps/x/pnpm-lock.yaml generated
View file

@ -441,6 +441,9 @@ importers:
'@types/pdf-parse': '@types/pdf-parse':
specifier: ^1.1.5 specifier: ^1.1.5
version: 1.1.5 version: 1.1.5
vitest:
specifier: 'catalog:'
version: 4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
packages/shared: packages/shared:
dependencies: dependencies:
@ -3263,6 +3266,9 @@ packages:
'@types/cacheable-request@6.0.3': '@types/cacheable-request@6.0.3':
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/connect@3.4.38': '@types/connect@3.4.38':
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
@ -3365,6 +3371,9 @@ packages:
'@types/debug@4.1.12': '@types/debug@4.1.12':
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/electron-squirrel-startup@1.0.2': '@types/electron-squirrel-startup@1.0.2':
resolution: {integrity: sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==} resolution: {integrity: sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==}
@ -3572,6 +3581,35 @@ packages:
peerDependencies: peerDependencies:
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@vitest/expect@4.1.7':
resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
'@vitest/mocker@4.1.7':
resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.7':
resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
'@vitest/runner@4.1.7':
resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==}
'@vitest/snapshot@4.1.7':
resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==}
'@vitest/spy@4.1.7':
resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==}
'@vitest/utils@4.1.7':
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
'@vscode/sudo-prompt@9.3.2': '@vscode/sudo-prompt@9.3.2':
resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==} resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==}
@ -3764,6 +3802,10 @@ packages:
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==} resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
engines: {node: '>=8'} engines: {node: '>=8'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
async-lock@1.4.1: async-lock@1.4.1:
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==} resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
@ -3940,6 +3982,10 @@ packages:
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==} resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2: chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'} engines: {node: '>=10'}
@ -4683,6 +4729,9 @@ packages:
estree-util-is-identifier-name@3.0.0: estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3: esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
@ -4717,6 +4766,10 @@ packages:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==} resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'} engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
exponential-backoff@3.1.3: exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
@ -6218,6 +6271,9 @@ packages:
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
ollama-ai-provider-v2@1.5.5: ollama-ai-provider-v2@1.5.5:
resolution: {integrity: sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==} resolution: {integrity: sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -7021,6 +7077,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'} engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@3.0.7: signal-exit@3.0.7:
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
@ -7099,10 +7158,16 @@ packages:
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==} resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0} engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
statuses@2.0.2: statuses@2.0.2:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'} engines: {node: '>= 0.8'}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stream-browserify@3.0.0: stream-browserify@3.0.0:
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==} resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
@ -7244,6 +7309,9 @@ packages:
tiny-invariant@1.3.3: tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.0.2: tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'} engines: {node: '>=18'}
@ -7252,6 +7320,10 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
tiptap-markdown@0.9.0: tiptap-markdown@0.9.0:
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==} resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
peerDependencies: peerDependencies:
@ -7497,6 +7569,7 @@ packages:
uuid@9.0.1: uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
hasBin: true hasBin: true
validate-npm-package-license@3.0.4: validate-npm-package-license@3.0.4:
@ -7565,6 +7638,47 @@ packages:
yaml: yaml:
optional: true optional: true
vitest@4.1.7:
resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.7
'@vitest/browser-preview': 4.1.7
'@vitest/browser-webdriverio': 4.1.7
'@vitest/coverage-istanbul': 4.1.7
'@vitest/coverage-v8': 4.1.7
'@vitest/ui': 4.1.7
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
vscode-jsonrpc@8.2.0: vscode-jsonrpc@8.2.0:
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
engines: {node: '>=14.0.0'} engines: {node: '>=14.0.0'}
@ -7643,6 +7757,11 @@ packages:
engines: {node: '>= 8'} engines: {node: '>= 8'}
hasBin: true hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
wmf@1.0.2: wmf@1.0.2:
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==} resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
engines: {node: '>=0.8'} engines: {node: '>=0.8'}
@ -11306,6 +11425,11 @@ snapshots:
'@types/node': 25.0.3 '@types/node': 25.0.3
'@types/responselike': 1.0.3 '@types/responselike': 1.0.3
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/connect@3.4.38': '@types/connect@3.4.38':
dependencies: dependencies:
'@types/node': 25.0.3 '@types/node': 25.0.3
@ -11435,6 +11559,8 @@ snapshots:
dependencies: dependencies:
'@types/ms': 2.1.0 '@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/electron-squirrel-startup@1.0.2': {} '@types/electron-squirrel-startup@1.0.2': {}
'@types/eslint-scope@3.7.7': '@types/eslint-scope@3.7.7':
@ -11692,6 +11818,47 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - supports-color
'@vitest/expect@4.1.7':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.1.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
'@vitest/pretty-format@4.1.7':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.7':
dependencies:
'@vitest/utils': 4.1.7
pathe: 2.0.3
'@vitest/snapshot@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
'@vitest/utils': 4.1.7
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.7': {}
'@vitest/utils@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@vscode/sudo-prompt@9.3.2': {} '@vscode/sudo-prompt@9.3.2': {}
'@webassemblyjs/ast@1.14.1': '@webassemblyjs/ast@1.14.1':
@ -11906,6 +12073,8 @@ snapshots:
arrify@2.0.1: {} arrify@2.0.1: {}
assertion-error@2.0.1: {}
async-lock@1.4.1: {} async-lock@1.4.1: {}
async@1.5.2: async@1.5.2:
@ -12119,6 +12288,8 @@ snapshots:
adler-32: 1.3.1 adler-32: 1.3.1
crc-32: 1.2.2 crc-32: 1.2.2
chai@6.2.2: {}
chalk@4.1.2: chalk@4.1.2:
dependencies: dependencies:
ansi-styles: 4.3.0 ansi-styles: 4.3.0
@ -12974,6 +13145,10 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {} estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
esutils@2.0.3: {} esutils@2.0.3: {}
etag@1.8.1: {} etag@1.8.1: {}
@ -13004,6 +13179,8 @@ snapshots:
signal-exit: 3.0.7 signal-exit: 3.0.7
strip-eof: 1.0.0 strip-eof: 1.0.0
expect-type@1.3.0: {}
exponential-backoff@3.1.3: {} exponential-backoff@3.1.3: {}
express-rate-limit@7.5.1(express@5.2.1): express-rate-limit@7.5.1(express@5.2.1):
@ -14943,6 +15120,8 @@ snapshots:
object-keys@1.1.1: object-keys@1.1.1:
optional: true optional: true
obug@2.1.1: {}
ollama-ai-provider-v2@1.5.5(zod@4.2.1): ollama-ai-provider-v2@1.5.5(zod@4.2.1):
dependencies: dependencies:
'@ai-sdk/provider': 2.0.1 '@ai-sdk/provider': 2.0.1
@ -15935,6 +16114,8 @@ snapshots:
side-channel-map: 1.0.1 side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2 side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@3.0.7: {} signal-exit@3.0.7: {}
signal-exit@4.1.0: {} signal-exit@4.1.0: {}
@ -16014,8 +16195,12 @@ snapshots:
dependencies: dependencies:
minipass: 3.3.6 minipass: 3.3.6
stackback@0.0.2: {}
statuses@2.0.2: {} statuses@2.0.2: {}
std-env@4.1.0: {}
stream-browserify@3.0.0: stream-browserify@3.0.0:
dependencies: dependencies:
inherits: 2.0.4 inherits: 2.0.4
@ -16182,6 +16367,8 @@ snapshots:
tiny-invariant@1.3.3: {} tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.0.2: {} tinyexec@1.0.2: {}
tinyglobby@0.2.15: tinyglobby@0.2.15:
@ -16189,6 +16376,8 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3) fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3 picomatch: 4.0.3
tinyrainbow@3.1.0: {}
tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)): tiptap-markdown@0.9.0(@tiptap/core@3.22.4(@tiptap/pm@3.22.4)):
dependencies: dependencies:
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4) '@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
@ -16503,6 +16692,50 @@ snapshots:
terser: 5.46.0 terser: 5.46.0
yaml: 2.8.2 yaml: 2.8.2
vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.2
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
postcss: 8.5.6
rollup: 4.54.0
tinyglobby: 0.2.15
optionalDependencies:
'@types/node': 25.0.3
fsevents: 2.3.3
jiti: 2.6.1
lightningcss: 1.30.2
terser: 5.46.0
yaml: 2.8.2
vitest@4.1.7(@opentelemetry/api@1.9.0)(@types/node@25.0.3)(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)):
dependencies:
'@vitest/expect': 4.1.7
'@vitest/mocker': 4.1.7(vite@7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.1.7
'@vitest/runner': 4.1.7
'@vitest/snapshot': 4.1.7
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
es-module-lexer: 2.0.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.3
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.1.0
vite: 7.3.0(@types/node@25.0.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.46.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@opentelemetry/api': 1.9.0
'@types/node': 25.0.3
transitivePeerDependencies:
- msw
vscode-jsonrpc@8.2.0: {} vscode-jsonrpc@8.2.0: {}
vscode-languageserver-protocol@3.17.5: vscode-languageserver-protocol@3.17.5:
@ -16606,6 +16839,11 @@ snapshots:
dependencies: dependencies:
isexe: 2.0.0 isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
wmf@1.0.2: {} wmf@1.0.2: {}
word-wrap@1.2.5: {} word-wrap@1.2.5: {}

View file

@ -2,6 +2,9 @@ packages:
- apps/* - apps/*
- packages/* - packages/*
catalog:
vitest: 4.1.7
onlyBuiltDependencies: onlyBuiltDependencies:
- core-js - core-js
- electron - electron