mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
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:
parent
f1d3b7b825
commit
31e35e00b8
41 changed files with 1777 additions and 615 deletions
|
|
@ -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:
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -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").
|
||||
|
||||
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).
|
||||
2. Make small, **patch-style** edits with `workspace-edit` — change one region, re-read, change the next region — rather than one-shot rewrites.
|
||||
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 `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.
|
||||
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`.
|
||||
|
|
@ -115,7 +115,7 @@ Backend (main process)
|
|||
├─ Event processor (5 s) ──┼──► runLiveNoteAgent() ──► live-note-agent
|
||||
└─ Builtin tool │ │
|
||||
run-live-note-agent ────┘ ▼
|
||||
workspace-readFile / -edit
|
||||
file-readText / -edit
|
||||
│
|
||||
▼
|
||||
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.
|
||||
- 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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
|
@ -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.
|
||||
- **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`.
|
||||
- **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`:
|
||||
- **`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.
|
||||
|
|
|
|||
|
|
@ -5677,6 +5677,7 @@ function App() {
|
|||
{rendered}
|
||||
<PermissionRequest
|
||||
toolCall={permRequest.toolCall}
|
||||
permission={permRequest.permission}
|
||||
onApprove={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve')}
|
||||
onApproveSession={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'session')}
|
||||
onApproveAlways={() => handlePermissionResponse(permRequest.toolCall.toolCallId, permRequest.subflow, 'approve', 'always')}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { cn } from "@/lib/utils";
|
|||
import { AlertTriangleIcon, CheckCircleIcon, CheckIcon, ChevronDownIcon, XCircleIcon, XIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { ToolCallPart } from "@x/shared/dist/message.js";
|
||||
import { ToolPermissionMetadata } from "@x/shared/dist/runs.js";
|
||||
import z from "zod";
|
||||
|
||||
export type PermissionRequestProps = ComponentProps<"div"> & {
|
||||
|
|
@ -22,6 +23,15 @@ export type PermissionRequestProps = ComponentProps<"div"> & {
|
|||
onDeny?: () => void;
|
||||
isProcessing?: boolean;
|
||||
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 = ({
|
||||
|
|
@ -33,14 +43,16 @@ export const PermissionRequest = ({
|
|||
onDeny,
|
||||
isProcessing = false,
|
||||
response = null,
|
||||
permission,
|
||||
...props
|
||||
}: PermissionRequestProps) => {
|
||||
// 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
|
||||
? String(toolCall.arguments.command)
|
||||
: JSON.stringify(toolCall.arguments))
|
||||
: null;
|
||||
const filePermission = permission?.kind === "file" ? permission : null;
|
||||
|
||||
const isResponded = response !== null;
|
||||
const isApproved = response === 'approve';
|
||||
|
|
@ -113,7 +125,35 @@ export const PermissionRequest = ({
|
|||
</pre>
|
||||
</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">
|
||||
<p className="text-xs font-medium text-muted-foreground mb-1.5 uppercase tracking-wide">
|
||||
Arguments
|
||||
|
|
@ -133,12 +173,12 @@ export const PermissionRequest = ({
|
|||
size="sm"
|
||||
onClick={onApprove}
|
||||
disabled={isProcessing}
|
||||
className={cn("flex-1", command && "rounded-r-none")}
|
||||
className={cn("flex-1", (command || filePermission) && "rounded-r-none")}
|
||||
>
|
||||
<CheckIcon className="size-4" />
|
||||
Approve
|
||||
</Button>
|
||||
{command && (
|
||||
{(command || filePermission) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -479,19 +479,19 @@ export const getComposioConnectCardData = (tool: ToolCall): ComposioConnectCardD
|
|||
|
||||
// Human-friendly display names for builtin tools
|
||||
const TOOL_DISPLAY_NAMES: Record<string, string> = {
|
||||
'workspace-readFile': 'Reading file',
|
||||
'workspace-writeFile': 'Writing file',
|
||||
'workspace-edit': 'Editing file',
|
||||
'workspace-readdir': 'Reading directory',
|
||||
'workspace-exists': 'Checking path',
|
||||
'workspace-stat': 'Getting file info',
|
||||
'workspace-glob': 'Finding files',
|
||||
'workspace-grep': 'Searching files',
|
||||
'workspace-mkdir': 'Creating directory',
|
||||
'workspace-rename': 'Renaming',
|
||||
'workspace-copy': 'Copying file',
|
||||
'workspace-remove': 'Removing',
|
||||
'workspace-getRoot': 'Getting workspace root',
|
||||
'file-readText': 'Reading file',
|
||||
'file-writeText': 'Writing file',
|
||||
'file-editText': 'Editing file',
|
||||
'file-list': 'Reading directory',
|
||||
'file-exists': 'Checking path',
|
||||
'file-stat': 'Getting file info',
|
||||
'file-glob': 'Finding files',
|
||||
'file-grep': 'Searching files',
|
||||
'file-mkdir': 'Creating directory',
|
||||
'file-rename': 'Renaming',
|
||||
'file-copy': 'Copying file',
|
||||
'file-remove': 'Removing',
|
||||
'file-getRoot': 'Getting file root',
|
||||
'loadSkill': 'Loading skill',
|
||||
'parseFile': 'Parsing file',
|
||||
'LLMParse': 'Extracting content',
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@
|
|||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "rm -rf dist && tsc",
|
||||
"dev": "tsc -w"
|
||||
"build": "rm -rf dist && tsc -p tsconfig.build.json",
|
||||
"dev": "tsc -w -p tsconfig.build.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^2.0.63",
|
||||
|
|
@ -29,8 +31,8 @@
|
|||
"express": "^5.2.1",
|
||||
"glob": "^13.0.0",
|
||||
"google-auth-library": "^10.5.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
"googleapis": "^169.0.0",
|
||||
"isomorphic-git": "^1.29.0",
|
||||
"mammoth": "^1.11.0",
|
||||
"node-html-markdown": "^2.0.0",
|
||||
"ollama-ai-provider-v2": "^1.5.4",
|
||||
|
|
@ -48,6 +50,7 @@
|
|||
"@types/express": "^5.0.6",
|
||||
"@types/node": "^25.0.3",
|
||||
"@types/papaparse": "^5.5.2",
|
||||
"@types/pdf-parse": "^1.1.5"
|
||||
"@types/pdf-parse": "^1.1.5",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,12 +8,14 @@ import { LanguageModel, stepCountIs, streamText, tool, Tool, ToolSet } from "ai"
|
|||
import { z } from "zod";
|
||||
import { LlmStepStreamEvent } from "@x/shared/dist/llm-step-events.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 { buildCopilotAgent } from "../application/assistant/agent.js";
|
||||
import { buildLiveNoteAgent } from "../knowledge/live-note/agent.js";
|
||||
import { buildBackgroundTaskAgent } from "../background-tasks/agent.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 { IModelConfigRepo } from "../models/repo.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 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 {
|
||||
try {
|
||||
if (!fs.existsSync(WORKDIR_CONFIG_FILE)) return null;
|
||||
|
|
@ -95,7 +222,7 @@ function loadAgentNotesContext(): string | null {
|
|||
} catch { /* ignore */ }
|
||||
|
||||
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;
|
||||
|
|
@ -683,6 +810,7 @@ export class AgentState {
|
|||
allowedToolCallIds: Record<string, true> = {};
|
||||
deniedToolCallIds: Record<string, true> = {};
|
||||
sessionAllowedCommands: Set<string> = new Set();
|
||||
sessionAllowedFileAccess: FileAccessGrant[] = [];
|
||||
|
||||
getPendingPermissions(): z.infer<typeof ToolPermissionRequestEvent>[] {
|
||||
const response: z.infer<typeof ToolPermissionRequestEvent>[] = [];
|
||||
|
|
@ -828,6 +956,15 @@ export class AgentState {
|
|||
switch (event.response) {
|
||||
case "approve":
|
||||
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
|
||||
if (event.scope === "session") {
|
||||
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.
|
||||
- "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:**
|
||||
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.
|
||||
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,18 +1373,22 @@ Do not announce the work directory unless it's relevant. Just use it.`;
|
|||
subflow: [],
|
||||
});
|
||||
}
|
||||
if (underlyingTool.type === "builtin" && underlyingTool.name === "executeCommand") {
|
||||
// if command is blocked, then seek permission
|
||||
if (isBlocked(part.arguments.command, state.sessionAllowedCommands)) {
|
||||
const permission = await getToolPermissionMetadata(
|
||||
part,
|
||||
underlyingTool,
|
||||
state.sessionAllowedCommands,
|
||||
state.sessionAllowedFileAccess,
|
||||
);
|
||||
if (permission) {
|
||||
loopLogger.log('emitting tool-permission-request, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
runId,
|
||||
type: "tool-permission-request",
|
||||
toolCall: part,
|
||||
permission,
|
||||
subflow: [],
|
||||
});
|
||||
}
|
||||
}
|
||||
if (underlyingTool.type === "agent" && underlyingTool.name) {
|
||||
loopLogger.log('emitting spawn-subflow, toolCallId:', part.toolCallId);
|
||||
yield* processEvent({
|
||||
|
|
|
|||
|
|
@ -140,39 +140,39 @@ Users can interact with the knowledge graph through you, open it directly in Obs
|
|||
**CRITICAL PATH REQUIREMENT:**
|
||||
- The workspace root is the configured workdir
|
||||
- The knowledge base is in the \`knowledge/\` subfolder
|
||||
- When using workspace tools, ALWAYS include \`knowledge/\` in the path
|
||||
- **WRONG:** \`workspace-grep({ pattern: "John", path: "" })\` or \`path: "."\` or any absolute path to the workspace root
|
||||
- **CORRECT:** \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
|
||||
- When searching knowledge, ALWAYS include \`knowledge/\` in the search path
|
||||
- **WRONG:** \`file-grep({ pattern: "John", searchPath: "" })\` or \`searchPath: "."\` or any absolute path to the workspace root
|
||||
- **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:**
|
||||
\`\`\`
|
||||
# List all people notes
|
||||
workspace-readdir("knowledge/People")
|
||||
file-list("knowledge/People")
|
||||
|
||||
# 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
|
||||
workspace-grep({ pattern: "Acme Corp", path: "knowledge/" })
|
||||
file-grep({ pattern: "Acme Corp", searchPath: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
**Reading notes:**
|
||||
\`\`\`
|
||||
# Read a specific person's note
|
||||
workspace-readFile("knowledge/People/Sarah Chen.md")
|
||||
file-readText("knowledge/People/Sarah Chen.md")
|
||||
|
||||
# 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:**
|
||||
1. First, search for them: \`workspace-grep({ pattern: "John", path: "knowledge/" })\`
|
||||
2. Read their note to get full context: \`workspace-readFile("knowledge/People/John Smith.md")\`
|
||||
1. First, search for them: \`file-grep({ pattern: "John", searchPath: "knowledge/" })\`
|
||||
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
|
||||
|
||||
**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
|
||||
|
||||
|
|
@ -237,29 +237,23 @@ ${toolPriority}
|
|||
|
||||
${runtimeContextPrompt}
|
||||
|
||||
## Workspace Access & Scope
|
||||
- **Inside the workspace root:** Use builtin workspace tools (\`workspace-readFile\`, \`workspace-writeFile\`, etc.). These don't require security approval.
|
||||
- **Outside the workspace root (Desktop, Downloads, Documents, etc.):** Use \`executeCommand\` to run shell commands.
|
||||
- **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").
|
||||
|
||||
**CRITICAL - When the user asks you to work with files outside the workspace root:**
|
||||
- Follow the detected runtime platform above for shell syntax and filesystem path style.
|
||||
- On macOS/Linux, use POSIX-style commands and paths (e.g., \`~/Desktop\`, \`~/Downloads\`, \`open\` on macOS).
|
||||
- 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.
|
||||
## File Access & Scope
|
||||
- Use builtin file tools (\`file-readText\`, \`file-writeText\`, \`file-editText\`, etc.) for normal file work anywhere on the user's machine.
|
||||
- Relative paths resolve against the Rowboat workspace root. Use paths like \`knowledge/People/Ada.md\` for knowledge files.
|
||||
- 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.
|
||||
- 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.
|
||||
- Do NOT read binary files as text. Use \`parseFile\` or \`LLMParse\` for PDFs, Office docs, images, scanned docs, presentations, and other non-text formats.
|
||||
- Do NOT access files outside the workspace unless the user explicitly asks you to or the current task clearly requires it.
|
||||
- Load the \`organize-files\` skill for guidance on file organization tasks.
|
||||
|
||||
## Builtin Tools vs Shell Commands
|
||||
|
||||
**IMPORTANT**: Rowboat provides builtin tools that are internal and do NOT require any user approval:
|
||||
- \`workspace-readFile\`, \`workspace-writeFile\`, \`workspace-edit\`, \`workspace-remove\` - File operations
|
||||
- \`workspace-readdir\`, \`workspace-exists\`, \`workspace-stat\`, \`workspace-glob\`, \`workspace-grep\` - Directory exploration and file search
|
||||
- \`workspace-mkdir\`, \`workspace-rename\`, \`workspace-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.
|
||||
**IMPORTANT**: Rowboat provides builtin tools:
|
||||
- \`file-readText\`, \`file-writeText\`, \`file-editText\`, \`file-remove\` - File operations
|
||||
- \`file-list\`, \`file-exists\`, \`file-stat\`, \`file-glob\`, \`file-grep\` - Directory exploration and file search
|
||||
- \`file-mkdir\`, \`file-rename\`, \`file-copy\` - File/directory management
|
||||
- \`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.
|
||||
- \`analyzeAgent\` - Agent analysis
|
||||
- \`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.
|
||||
${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\`:**
|
||||
- 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.
|
||||
- **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.
|
||||
- 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**
|
||||
- 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
|
||||
|
||||
**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 >\`.
|
||||
|
||||
Rowboat's internal builtin tools never require approval — only shell commands via \`executeCommand\` do.
|
||||
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 >\`.
|
||||
|
||||
## File Path References
|
||||
|
||||
|
|
|
|||
|
|
@ -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`" + `)
|
||||
|
||||
**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.
|
||||
|
||||
### open-view
|
||||
|
|
|
|||
|
|
@ -25,11 +25,11 @@ Mixed instructions ("summarize and email it") trigger both.
|
|||
|
||||
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.
|
||||
- \`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
|
||||
|
||||
|
|
|
|||
|
|
@ -158,26 +158,26 @@ Pass the paper URL to the summariser. Don't ask for human input.
|
|||
|
||||
## 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
|
||||
|
||||
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
|
||||
- \`workspace-readdir\` - List directory contents (supports recursive exploration)
|
||||
- \`workspace-readFile\` - Read file contents
|
||||
- \`workspace-writeFile\` - Create or update file contents
|
||||
- \`workspace-edit\` - Make precise edits by replacing specific text (safer than full rewrites)
|
||||
- \`workspace-remove\` - Remove files or directories
|
||||
- \`workspace-exists\` - Check if a file or directory exists
|
||||
- \`workspace-stat\` - Get file/directory statistics
|
||||
- \`workspace-mkdir\` - Create directories
|
||||
- \`workspace-rename\` - Rename or move files/directories
|
||||
- \`workspace-copy\` - Copy files
|
||||
- \`workspace-getRoot\` - Get workspace root directory path
|
||||
- \`workspace-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-list\` - List directory contents (supports recursive exploration)
|
||||
- \`file-readText\` - Read file contents
|
||||
- \`file-writeText\` - Create or update file contents
|
||||
- \`file-editText\` - Make precise edits by replacing specific text (safer than full rewrites)
|
||||
- \`file-remove\` - Remove files or directories
|
||||
- \`file-exists\` - Check if a file or directory exists
|
||||
- \`file-stat\` - Get file/directory statistics
|
||||
- \`file-mkdir\` - Create directories
|
||||
- \`file-rename\` - Rename or move files/directories
|
||||
- \`file-copy\` - Copy files
|
||||
- \`file-getRoot\` - Get the default root for relative file paths
|
||||
- \`file-glob\` - Find files matching a glob pattern (e.g., "**/*.ts", "agents/*.md")
|
||||
- \`file-grep\` - Search file contents using regex, returns matching files and lines
|
||||
|
||||
#### Agent Operations
|
||||
- \`analyzeAgent\` - Read and analyze an agent file structure
|
||||
|
|
|
|||
|
|
@ -78,19 +78,19 @@ Map each point to a slide layout from the Available Layout Types below. For a ty
|
|||
|
||||
## 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\`
|
||||
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).
|
||||
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.**
|
||||
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\`
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
|
|
@ -142,14 +142,14 @@ html { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !
|
|||
## Playwright Export
|
||||
|
||||
\`\`\`javascript
|
||||
// save as tmp/convert.js via workspace-writeFile
|
||||
// save as tmp/convert.js via file-writeText
|
||||
const { chromium } = require('playwright');
|
||||
const path = require('path');
|
||||
|
||||
(async () => {
|
||||
const browser = await chromium.launch();
|
||||
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.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)
|
||||
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ You are an expert document assistant helping the user create, edit, and refine d
|
|||
|
||||
## 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
|
||||
|
||||
|
|
@ -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):
|
||||
\`\`\`
|
||||
workspace-glob({ pattern: "knowledge/**/*[name]*", path: "knowledge/" })
|
||||
file-glob({ pattern: "**/*[name]*", cwd: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
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:**
|
||||
|
|
@ -106,7 +106,7 @@ workspace-createFile({
|
|||
**Types of requests:**
|
||||
|
||||
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"
|
||||
→ Generate the content and add it to the document
|
||||
|
|
@ -122,21 +122,21 @@ workspace-createFile({
|
|||
|
||||
### Step 3: Execute Changes
|
||||
|
||||
**For edits, use workspace-editFile:**
|
||||
**For edits, use file-editText:**
|
||||
\`\`\`
|
||||
workspace-editFile({
|
||||
file-editText({
|
||||
path: "knowledge/[path].md",
|
||||
old_string: "[exact text to replace]",
|
||||
new_string: "[new text]"
|
||||
oldString: "[exact text to replace]",
|
||||
newString: "[new text]"
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For additions at the end:**
|
||||
\`\`\`
|
||||
workspace-editFile({
|
||||
file-editText({
|
||||
path: "knowledge/[path].md",
|
||||
old_string: "[last line or section]",
|
||||
new_string: "[last line or section]\n\n[new content]"
|
||||
oldString: "[last line or section]",
|
||||
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:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "[Name]", path: "knowledge/" })
|
||||
file-grep({ pattern: "[Name]", searchPath: "knowledge/" })
|
||||
\`\`\`
|
||||
|
||||
**Read relevant notes:**
|
||||
\`\`\`
|
||||
workspace-readFile("knowledge/People/[Person].md")
|
||||
workspace-readFile("knowledge/Organizations/[Company].md")
|
||||
workspace-readFile("knowledge/Projects/[Project].md")
|
||||
file-readText("knowledge/People/[Person].md")
|
||||
file-readText("knowledge/Organizations/[Company].md")
|
||||
file-readText("knowledge/Projects/[Project].md")
|
||||
\`\`\`
|
||||
|
||||
**Use the context:**
|
||||
|
|
@ -237,7 +237,7 @@ Renders a styled table from structured data.
|
|||
|
||||
### Block Guidelines
|
||||
- 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 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>/\`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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:
|
||||
\`\`\`
|
||||
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
|
||||
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/\`):
|
||||
\`\`\`
|
||||
# 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
|
||||
workspace-readdir("knowledge/People")
|
||||
file-list("knowledge/People")
|
||||
\`\`\`
|
||||
|
||||
Then read the relevant notes:
|
||||
\`\`\`
|
||||
# Read the sender's note
|
||||
workspace-readFile("knowledge/People/Sender Name.md")
|
||||
file-readText("knowledge/People/Sender Name.md")
|
||||
|
||||
# Read their organization's note
|
||||
workspace-readFile("knowledge/Organizations/Company Name.md")
|
||||
file-readText("knowledge/Organizations/Company Name.md")
|
||||
\`\`\`
|
||||
|
||||
Extract from these notes:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
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:
|
||||
- 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\`).
|
||||
|
||||
**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)
|
||||
|
||||
|
|
@ -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."
|
||||
|
||||
**Right behaviour** (one turn):
|
||||
1. \`workspace-grep({ pattern: "News Feed", path: "knowledge/Notes/" })\` — search for an existing match.
|
||||
2. \`workspace-grep({ pattern: "news", path: "knowledge/Notes/" })\` — broader search to catch variants.
|
||||
1. \`file-grep({ pattern: "News Feed", searchPath: "knowledge/Notes/" })\` — search for an existing match.
|
||||
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).
|
||||
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."
|
||||
|
|
@ -460,16 +460,16 @@ live:
|
|||
|
||||
### 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).
|
||||
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 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
|
||||
|
||||
1. \`workspace-readFile({ 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.
|
||||
1. \`file-readText({ path })\` — fetch the current \`live.objective\`.
|
||||
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\`).
|
||||
|
||||
### 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 write** \`lastRunAt\`, \`lastRunId\`, or \`lastRunSummary\` — runtime-managed.
|
||||
- **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
|
||||
|
||||
**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\`.
|
||||
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
**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
|
||||
|
||||
|
|
|
|||
|
|
@ -16,12 +16,12 @@ When the user asks to prep for a meeting or mentions attendees:
|
|||
1. **STOP** - Do not create a generic brief
|
||||
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:
|
||||
\`\`\`
|
||||
workspace-readFile("knowledge/People/Attendee Name.md")
|
||||
workspace-readFile("knowledge/Organizations/Their Company.md")
|
||||
file-readText("knowledge/People/Attendee Name.md")
|
||||
file-readText("knowledge/Organizations/Their Company.md")
|
||||
\`\`\`
|
||||
4. **UNDERSTAND** - Extract their role, organization, relationship history, past interactions, open items
|
||||
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:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "attendee_name", path: "knowledge/People/" })
|
||||
workspace-grep({ pattern: "attendee_email", path: "knowledge/People/" })
|
||||
file-grep({ pattern: "attendee_name", searchPath: "knowledge/People/" })
|
||||
file-grep({ pattern: "attendee_email", searchPath: "knowledge/People/" })
|
||||
\`\`\`
|
||||
|
||||
If a person note exists, read it:
|
||||
\`\`\`
|
||||
workspace-readFile("knowledge/People/Attendee Name.md")
|
||||
file-readText("knowledge/People/Attendee Name.md")
|
||||
\`\`\`
|
||||
|
||||
Extract:
|
||||
|
|
@ -86,13 +86,13 @@ Extract:
|
|||
|
||||
**Search Organization notes:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "company_name", path: "knowledge/Organizations/" })
|
||||
file-grep({ pattern: "company_name", searchPath: "knowledge/Organizations/" })
|
||||
\`\`\`
|
||||
|
||||
**Search Projects:**
|
||||
\`\`\`
|
||||
workspace-grep({ pattern: "attendee_name", path: "knowledge/Projects/" })
|
||||
workspace-grep({ pattern: "company_name", path: "knowledge/Projects/" })
|
||||
file-grep({ pattern: "attendee_name", searchPath: "knowledge/Projects/" })
|
||||
file-grep({ pattern: "company_name", searchPath: "knowledge/Projects/" })
|
||||
\`\`\`
|
||||
|
||||
### Step 4: Create Meeting Brief
|
||||
|
|
|
|||
|
|
@ -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\` |
|
||||
| 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
|
||||
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
|
||||
|
|
|
|||
|
|
@ -1,17 +1,14 @@
|
|||
import { z, ZodType } from "zod";
|
||||
import * as path from "path";
|
||||
import * as fs from "fs/promises";
|
||||
import { createReadStream, existsSync, readFileSync } from "fs";
|
||||
import { createInterface } from "readline";
|
||||
import { execSync } from "child_process";
|
||||
import { glob } from "glob";
|
||||
import { existsSync, readFileSync } from "fs";
|
||||
import { executeCommand, executeCommandAbortable } from "./command-executor.js";
|
||||
import { resolveSkill, availableSkills } from "../assistant/skills/index.js";
|
||||
import { executeTool, listServers, listTools } from "../../mcp/mcp.js";
|
||||
import container from "../../di/container.js";
|
||||
import { IMcpConfigRepo } from "../..//mcp/repo.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 { WorkDir } from "../../config/config.js";
|
||||
import { composioAccountsRepo } from "../../composio/repo.js";
|
||||
|
|
@ -156,12 +153,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-getRoot': {
|
||||
description: 'Get the workspace root directory path',
|
||||
'file-getRoot': {
|
||||
description: 'Get the default root directory for relative file paths. Relative paths passed to file tools resolve against this directory.',
|
||||
inputSchema: z.object({}),
|
||||
execute: async () => {
|
||||
try {
|
||||
return await workspace.getRoot();
|
||||
return { root: WorkDir };
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -170,14 +167,14 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-exists': {
|
||||
description: 'Check if a file or directory exists in the workspace',
|
||||
'file-exists': {
|
||||
description: 'Check if a file or directory exists. Accepts absolute paths, ~/ paths, or paths relative to the default root.',
|
||||
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 {
|
||||
return await workspace.exists(relPath);
|
||||
return await files.exists(filePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
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.)',
|
||||
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 {
|
||||
return await workspace.stat(relPath);
|
||||
return await files.stat(filePath);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -202,17 +199,17 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-readdir': {
|
||||
'file-list': {
|
||||
description: 'List directory contents. Can recursively explore directory structure with options.',
|
||||
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)'),
|
||||
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)'),
|
||||
allowedExtensions: z.array(z.string()).optional().describe('Filter by file extensions (e.g., [".json", ".ts"])'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
path: filePath,
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
|
|
@ -225,13 +222,12 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
allowedExtensions?: string[];
|
||||
}) => {
|
||||
try {
|
||||
const entries = await workspace.readdir(relPath || '', {
|
||||
return await files.list(filePath || '.', {
|
||||
recursive,
|
||||
includeStats,
|
||||
includeHidden,
|
||||
allowedExtensions,
|
||||
});
|
||||
return entries;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -240,120 +236,24 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-readFile': {
|
||||
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`.',
|
||||
'file-readText': {
|
||||
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({
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
offset: z.coerce.number().int().min(1).optional().describe('1-indexed line to start reading from (default: 1). Utf8 only.'),
|
||||
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000). Utf8 only.'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('File encoding (default: utf8)'),
|
||||
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).'),
|
||||
limit: z.coerce.number().int().min(1).optional().describe('Maximum number of lines to read (default: 2000).'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
path: filePath,
|
||||
offset,
|
||||
limit,
|
||||
encoding = 'utf8',
|
||||
}: {
|
||||
path: string;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
encoding?: 'utf8' | 'base64' | 'binary';
|
||||
}) => {
|
||||
try {
|
||||
if (encoding !== 'utf8') {
|
||||
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,
|
||||
};
|
||||
return await files.readText(filePath, offset, limit);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -362,34 +262,30 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-writeFile': {
|
||||
description: 'Write or update file contents in the workspace. Automatically creates parent directories and supports atomic writes.',
|
||||
'file-writeText': {
|
||||
description: 'Write or update UTF-8 text file contents. Automatically creates parent directories and supports atomic writes.',
|
||||
inputSchema: z.object({
|
||||
path: z.string().min(1).describe('Workspace-relative file path'),
|
||||
data: z.string().describe('File content to write'),
|
||||
encoding: z.enum(['utf8', 'base64', 'binary']).optional().describe('Data encoding (default: utf8)'),
|
||||
path: z.string().min(1).describe('Text file path to write'),
|
||||
data: z.string().describe('UTF-8 text content to write'),
|
||||
atomic: z.boolean().optional().describe('Use atomic write (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)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
path: filePath,
|
||||
data,
|
||||
encoding,
|
||||
atomic,
|
||||
mkdirp,
|
||||
expectedEtag
|
||||
}: {
|
||||
path: string;
|
||||
data: string;
|
||||
encoding?: 'utf8' | 'base64' | 'binary';
|
||||
atomic?: boolean;
|
||||
mkdirp?: boolean;
|
||||
expectedEtag?: string;
|
||||
}) => {
|
||||
try {
|
||||
return await workspace.writeFile(relPath, data, {
|
||||
encoding,
|
||||
return await files.writeText(filePath, data, {
|
||||
atomic,
|
||||
mkdirp,
|
||||
expectedEtag,
|
||||
|
|
@ -402,16 +298,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-edit': {
|
||||
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.',
|
||||
'file-editText': {
|
||||
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({
|
||||
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'),
|
||||
newString: z.string().describe('Replacement text'),
|
||||
replaceAll: z.boolean().optional().describe('Replace all occurrences (default: false, fails if not unique)'),
|
||||
}),
|
||||
execute: async ({
|
||||
path: relPath,
|
||||
path: filePath,
|
||||
oldString,
|
||||
newString,
|
||||
replaceAll = false
|
||||
|
|
@ -422,46 +318,22 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
replaceAll?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
const result = await workspace.readFile(relPath, 'utf8');
|
||||
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
|
||||
};
|
||||
return await files.editText(filePath, oldString, newString, replaceAll);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace-mkdir': {
|
||||
description: 'Create a directory in the workspace',
|
||||
'file-mkdir': {
|
||||
description: 'Create a directory',
|
||||
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)'),
|
||||
}),
|
||||
execute: async ({ path: relPath, recursive = true }: { path: string; recursive?: boolean }) => {
|
||||
execute: async ({ path: filePath, recursive = true }: { path: string; recursive?: boolean }) => {
|
||||
try {
|
||||
return await workspace.mkdir(relPath, recursive);
|
||||
return await files.mkdir(filePath, recursive);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -470,16 +342,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-rename': {
|
||||
description: 'Rename or move a file or directory in the workspace',
|
||||
'file-rename': {
|
||||
description: 'Rename or move a file or directory',
|
||||
inputSchema: z.object({
|
||||
from: z.string().min(1).describe('Source workspace-relative path'),
|
||||
to: z.string().min(1).describe('Destination workspace-relative path'),
|
||||
from: z.string().min(1).describe('Source path'),
|
||||
to: z.string().min(1).describe('Destination path'),
|
||||
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
|
||||
}),
|
||||
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
|
||||
try {
|
||||
return await workspace.rename(from, to, overwrite);
|
||||
return await files.rename(from, to, overwrite);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -488,16 +360,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-copy': {
|
||||
description: 'Copy a file in the workspace (directories not supported)',
|
||||
'file-copy': {
|
||||
description: 'Copy a file (directories not supported)',
|
||||
inputSchema: z.object({
|
||||
from: z.string().min(1).describe('Source workspace-relative file path'),
|
||||
to: z.string().min(1).describe('Destination workspace-relative file path'),
|
||||
from: z.string().min(1).describe('Source file path'),
|
||||
to: z.string().min(1).describe('Destination file path'),
|
||||
overwrite: z.boolean().optional().describe('Overwrite destination if it exists (default: false)'),
|
||||
}),
|
||||
execute: async ({ from, to, overwrite = false }: { from: string; to: string; overwrite?: boolean }) => {
|
||||
try {
|
||||
return await workspace.copy(from, to, overwrite);
|
||||
return await files.copy(from, to, overwrite);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
|
|
@ -506,16 +378,16 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
},
|
||||
|
||||
'workspace-remove': {
|
||||
description: 'Remove a file or directory from the workspace. Files are moved to trash by default for safety.',
|
||||
'file-remove': {
|
||||
description: 'Remove a file or directory. Files are moved to the Rowboat trash by default for safety.',
|
||||
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)'),
|
||||
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 {
|
||||
return await workspace.remove(relPath, {
|
||||
return await files.remove(filePath, {
|
||||
recursive,
|
||||
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.',
|
||||
inputSchema: z.object({
|
||||
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 }) => {
|
||||
try {
|
||||
const searchDir = cwd ? path.join(WorkDir, cwd) : WorkDir;
|
||||
|
||||
// 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 || '.',
|
||||
};
|
||||
return await files.glob(pattern, cwd);
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
'workspace-grep': {
|
||||
description: 'Search file contents using regex. Returns matching files and lines. Uses ripgrep if available, falls back to grep.',
|
||||
'file-grep': {
|
||||
description: 'Search text file contents using regex. Returns matching files and lines. Skips binary files.',
|
||||
inputSchema: z.object({
|
||||
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")'),
|
||||
contextLines: z.number().optional().describe('Lines of context around matches (default: 0)'),
|
||||
maxResults: z.number().optional().describe('Maximum results to return (default: 100)'),
|
||||
|
|
@ -584,90 +437,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
maxResults?: number;
|
||||
}) => {
|
||||
try {
|
||||
const targetPath = searchPath ? path.join(WorkDir, searchPath) : WorkDir;
|
||||
|
||||
// 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' };
|
||||
}
|
||||
}
|
||||
return await files.grep({ pattern, searchPath, fileGlob, contextLines, maxResults });
|
||||
} catch (error) {
|
||||
return { error: error instanceof Error ? error.message : 'Unknown error' };
|
||||
}
|
||||
|
|
@ -677,7 +447,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
'parseFile': {
|
||||
description: 'Parse and extract text content from files (PDF, Excel, CSV, Word .docx). Auto-detects format from file extension.',
|
||||
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 }) => {
|
||||
try {
|
||||
|
|
@ -692,14 +462,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
};
|
||||
}
|
||||
|
||||
// Read file as buffer — support both absolute and workspace-relative paths
|
||||
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 { buffer, resolvedPath } = await files.readBuffer(filePath);
|
||||
|
||||
if (ext === '.pdf') {
|
||||
const { PDFParse } = await _importDynamic("pdf-parse");
|
||||
|
|
@ -716,6 +479,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
pages: textResult.total,
|
||||
title: infoResult.info?.Title || undefined,
|
||||
author: infoResult.info?.Author || undefined,
|
||||
resolvedPath,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
|
|
@ -785,7 +549,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
'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).',
|
||||
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.")'),
|
||||
}),
|
||||
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
|
||||
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 { buffer } = await files.readBuffer(filePath);
|
||||
|
||||
const base64 = buffer.toString('base64');
|
||||
|
||||
|
|
@ -1228,7 +985,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
case 'open-note': {
|
||||
const filePath = input.path as string;
|
||||
try {
|
||||
const result = await workspace.exists(filePath);
|
||||
const result = await files.exists(filePath);
|
||||
if (!result.exists) {
|
||||
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
|
||||
try {
|
||||
const { parseFrontmatter } = await import("@x/shared/dist/frontmatter.js");
|
||||
const entries = await workspace.readdir("knowledge", { recursive: true, allowedExtensions: [".md"] });
|
||||
const files = entries.filter(e => e.kind === 'file');
|
||||
const entries = await files.list("knowledge", { recursive: true, allowedExtensions: [".md"] });
|
||||
const noteFiles = entries.filter(e => e.kind === 'file');
|
||||
const properties = new Map<string, Set<string>>();
|
||||
let noteCount = 0;
|
||||
|
||||
for (const file of files) {
|
||||
for (const file of noteFiles) {
|
||||
try {
|
||||
const { data } = await workspace.readFile(file.path);
|
||||
const { fields } = parseFrontmatter(data);
|
||||
const result = await fs.readFile(file.resolvedPath, 'utf8');
|
||||
const { fields } = parseFrontmatter(result);
|
||||
noteCount++;
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (!value) continue;
|
||||
|
|
@ -1309,7 +1066,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
const basePath = `bases/${safeName}.base`;
|
||||
try {
|
||||
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 };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
|
@ -1655,7 +1412,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
'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,
|
||||
execute: async (input: z.infer<typeof CreateBackgroundTaskInput>) => {
|
||||
try {
|
||||
|
|
@ -1675,7 +1432,7 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
|
|||
},
|
||||
|
||||
'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,
|
||||
execute: async (input: z.infer<typeof PatchBackgroundTaskInput>) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
- \`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.
|
||||
|
||||
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:
|
||||
- "Maintain / show / summarize / track / digest of / dashboard for / brief 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.
|
||||
Use when instructions imply a **recurring action**:
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ function buildMessage(
|
|||
**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({
|
||||
trigger,
|
||||
|
|
|
|||
|
|
@ -42,26 +42,61 @@ const DEFAULT_ALLOW_LIST = [
|
|||
"yq"
|
||||
]
|
||||
|
||||
export type FileAccessOperation = "read" | "list" | "search" | "write" | "delete";
|
||||
|
||||
export type FileAccessGrant = {
|
||||
operation: FileAccessOperation;
|
||||
pathPrefix: string;
|
||||
};
|
||||
|
||||
let cachedAllowList: string[] | null = null;
|
||||
let cachedFileAccessAllowList: FileAccessGrant[] | null = null;
|
||||
let cachedMtimeMs: number | null = null;
|
||||
|
||||
export async function addToSecurityConfig(commands: string[]): Promise<void> {
|
||||
ensureSecurityConfigSync();
|
||||
const current = readAllowList();
|
||||
const merged = new Set(current);
|
||||
const current = readSecurityConfig();
|
||||
const merged = new Set(current.allowedCommands);
|
||||
for (const cmd of commands) {
|
||||
const normalized = cmd.trim().toLowerCase();
|
||||
if (normalized) merged.add(normalized);
|
||||
}
|
||||
await fsPromises.writeFile(
|
||||
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",
|
||||
);
|
||||
// Reset cache so next read picks up the new file
|
||||
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.
|
||||
* Called explicitly at app startup via initConfigs().
|
||||
|
|
@ -103,28 +138,74 @@ function normalizeList(commands: unknown[]): string[] {
|
|||
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)) {
|
||||
return normalizeList(payload);
|
||||
return { allowedCommands: normalizeList(payload), allowedFileAccess: [] };
|
||||
}
|
||||
|
||||
if (payload && typeof payload === "object") {
|
||||
const maybeObject = payload as Record<string, unknown>;
|
||||
if (Array.isArray(maybeObject.allowedCommands)) {
|
||||
return normalizeList(maybeObject.allowedCommands);
|
||||
const allowedFileAccess = Array.isArray(maybeObject.allowedFileAccess)
|
||||
? 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)
|
||||
.filter(([, value]) => Boolean(value))
|
||||
.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();
|
||||
|
||||
try {
|
||||
|
|
@ -133,10 +214,14 @@ function readAllowList(): string[] {
|
|||
return parseSecurityPayload(parsed);
|
||||
} catch (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[] {
|
||||
ensureSecurityConfigSync();
|
||||
try {
|
||||
|
|
@ -149,12 +234,32 @@ export function getSecurityAllowList(): string[] {
|
|||
return cachedAllowList;
|
||||
} catch {
|
||||
cachedAllowList = null;
|
||||
cachedFileAccessAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
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() {
|
||||
cachedAllowList = null;
|
||||
cachedFileAccessAllowList = null;
|
||||
cachedMtimeMs = null;
|
||||
}
|
||||
|
|
|
|||
204
apps/x/packages/core/src/filesystem/files.test.ts
Normal file
204
apps/x/packages/core/src/filesystem/files.test.ts
Normal 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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
641
apps/x/packages/core/src/filesystem/files.ts
Normal file
641
apps/x/packages/core/src/filesystem/files.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,21 +1,21 @@
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
file-writeText:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
workspace-readFile:
|
||||
name: file-writeText
|
||||
file-readText:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
name: file-readText
|
||||
file-editText:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
name: file-editText
|
||||
file-list:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
name: file-list
|
||||
file-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
name: file-mkdir
|
||||
---
|
||||
# Agent Notes
|
||||
|
||||
|
|
|
|||
|
|
@ -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 += `- 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 += `- 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`;
|
||||
|
||||
// Add the knowledge base index
|
||||
|
|
@ -297,16 +297,16 @@ async function createNotesFromBatch(
|
|||
if (event.type !== "tool-invocation") {
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== "workspace-writeFile" && event.toolName !== "workspace-edit") {
|
||||
if (event.toolName !== "file-writeText" && event.toolName !== "file-editText") {
|
||||
return;
|
||||
}
|
||||
const toolPath = extractPathFromToolInput(event.input);
|
||||
if (!toolPath) {
|
||||
return;
|
||||
}
|
||||
if (event.toolName === "workspace-writeFile") {
|
||||
if (event.toolName === "file-writeText") {
|
||||
notesCreated.add(toolPath);
|
||||
} else if (event.toolName === "workspace-edit") {
|
||||
} else if (event.toolName === "file-editText") {
|
||||
notesModified.add(toolPath);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ This brief refreshes every 15 minutes, so it should always reflect the **current
|
|||
|
||||
## 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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
### Calendar
|
||||
1. Use \`workspace-readdir\` with path \`calendar_sync\` to list files
|
||||
2. Use \`workspace-readFile\` to read each \`.json\` event file (e.g. \`calendar_sync/eventid123.json\`)
|
||||
1. Use \`file-list\` with path \`calendar_sync\` to list files
|
||||
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)
|
||||
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:
|
||||
|
|
@ -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.
|
||||
|
||||
### Emails
|
||||
1. Use \`workspace-readdir\` 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\`)
|
||||
1. Use \`file-list\` with path \`gmail_sync\` to list files (skip \`sync_state.json\` and \`attachments/\`)
|
||||
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
|
||||
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."
|
||||
|
||||
- **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.
|
||||
- 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.
|
||||
|
|
@ -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 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.
|
||||
- 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.
|
||||
- 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.**
|
||||
|
|
|
|||
|
|
@ -78,13 +78,13 @@ async function labelEmailBatch(
|
|||
});
|
||||
|
||||
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++) {
|
||||
const file = files[i];
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
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;
|
||||
|
||||
message += `## File ${i + 1}: ${relativePath}\n\n`;
|
||||
|
|
@ -98,7 +98,7 @@ async function labelEmailBatch(
|
|||
if (event.type !== 'tool-invocation') {
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== 'workspace-edit') {
|
||||
if (event.toolName !== 'file-editText') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import { renderTagSystemForEmails } from './tag_system.js';
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
tools:
|
||||
workspace-readFile:
|
||||
file-readText:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
name: file-readText
|
||||
file-editText:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
name: file-editText
|
||||
file-list:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
name: file-list
|
||||
---
|
||||
# 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.
|
||||
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.
|
||||
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.
|
||||
7. If the email already has frontmatter (starts with \`---\`), skip it.
|
||||
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ Every run message has this shape:
|
|||
**Objective:**
|
||||
<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:
|
||||
|
||||
|
|
@ -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.**
|
||||
|
||||
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).
|
||||
3. \`workspace-edit\` to make that one change.
|
||||
4. \`workspace-readFile\` again to confirm the result.
|
||||
3. \`file-editText\` to make that one change.
|
||||
4. \`file-readText\` again to confirm the result.
|
||||
5. Decide the *next* change. Repeat.
|
||||
|
||||
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.
|
||||
|
||||
Avoid:
|
||||
- Calling \`workspace-writeFile\` 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.
|
||||
- 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 \`file-editText\` call with a giant \`oldString\` / \`newString\`. Smaller anchors, more steps.
|
||||
|
||||
# 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:
|
||||
|
||||
- **\`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.
|
||||
- **\`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.
|
||||
- **\`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.
|
||||
|
|
@ -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.
|
||||
|
||||
**CRITICAL:** Always include the folder prefix in paths. Never pass an empty path or the workspace root.
|
||||
- \`workspace-grep({ pattern: "Acme", path: "knowledge/" })\`
|
||||
- \`workspace-readFile("knowledge/People/Sarah Chen.md")\`
|
||||
- \`workspace-readdir("gmail_sync/")\`
|
||||
- \`file-grep({ pattern: "Acme", searchPath: "knowledge/" })\`
|
||||
- \`file-readText("knowledge/People/Sarah Chen.md")\`
|
||||
- \`file-list("gmail_sync/")\`
|
||||
|
||||
# Failure & Fallback
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ function truncate(s: string | null | undefined, n = SUMMARY_LOG_LIMIT): string {
|
|||
// 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';
|
||||
|
||||
|
|
@ -44,8 +44,8 @@ function buildMessage(
|
|||
const localNow = now.toLocaleString('en-US', { dateStyle: 'full', timeStyle: 'long' });
|
||||
const tz = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
|
||||
// Workspace-relative path the agent's tools (workspace-readFile,
|
||||
// workspace-edit) expect. Internal storage is knowledge/-relative.
|
||||
// Workspace-relative path the agent's tools (file-readText,
|
||||
// file-editText) expect. Internal storage is knowledge/-relative.
|
||||
const wsPath = `knowledge/${filePath}`;
|
||||
|
||||
const baseMessage = `Update the live note at \`${wsPath}\`.
|
||||
|
|
@ -55,7 +55,7 @@ function buildMessage(
|
|||
**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({
|
||||
trigger,
|
||||
|
|
|
|||
|
|
@ -4,27 +4,27 @@ import { renderNoteEffectRules } from './tag_system.js';
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
tools:
|
||||
workspace-writeFile:
|
||||
file-writeText:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
workspace-readFile:
|
||||
name: file-writeText
|
||||
file-readText:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
name: file-readText
|
||||
file-editText:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
name: file-editText
|
||||
file-list:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
name: file-list
|
||||
file-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
workspace-grep:
|
||||
name: file-mkdir
|
||||
file-grep:
|
||||
type: builtin
|
||||
name: workspace-grep
|
||||
workspace-glob:
|
||||
name: file-grep
|
||||
file-glob:
|
||||
type: builtin
|
||||
name: workspace-glob
|
||||
name: file-glob
|
||||
---
|
||||
# Context
|
||||
|
||||
|
|
@ -92,17 +92,17 @@ You have access to these tools:
|
|||
|
||||
**For reading files:**
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "knowledge/People/Sarah Chen.md" })
|
||||
file-readText({ path: "knowledge/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**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):**
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
file-editText({
|
||||
path: "knowledge/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): New activity entry\\n"
|
||||
|
|
@ -111,27 +111,27 @@ workspace-edit({
|
|||
|
||||
**For listing directories:**
|
||||
\`\`\`
|
||||
workspace-readdir({ path: "knowledge/People" })
|
||||
file-list({ path: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**For creating directories:**
|
||||
\`\`\`
|
||||
workspace-mkdir({ path: "knowledge/Projects", recursive: true })
|
||||
file-mkdir({ path: "knowledge/Projects", recursive: true })
|
||||
\`\`\`
|
||||
|
||||
**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:**
|
||||
\`\`\`
|
||||
workspace-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
||||
file-glob({ pattern: "**/*.md", cwd: "knowledge/People" })
|
||||
\`\`\`
|
||||
|
||||
**IMPORTANT:**
|
||||
- Use \`workspace-edit\` for updating existing notes (adding activity, updating fields)
|
||||
- Use \`workspace-writeFile\` only for creating new notes
|
||||
- Use \`file-editText\` for updating existing notes (adding activity, updating fields)
|
||||
- Use \`file-writeText\` only for creating new notes
|
||||
- Prefer the knowledge_index for entity resolution (it's faster than grep)
|
||||
|
||||
# Output
|
||||
|
|
@ -158,7 +158,7 @@ ${renderNoteEffectRules()}
|
|||
|
||||
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:**
|
||||
|
|
@ -262,7 +262,7 @@ If processing, continue to Step 2.
|
|||
|
||||
# Step 2: Read and Parse Source File
|
||||
\`\`\`
|
||||
workspace-readFile({ path: "{source_file}" })
|
||||
file-readText({ path: "{source_file}" })
|
||||
\`\`\`
|
||||
|
||||
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):
|
||||
\`\`\`bash
|
||||
workspace-readFile({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
file-readText({ path: "{knowledge_folder}/People/Sarah Chen.md" })
|
||||
\`\`\`
|
||||
|
||||
**Why read these notes:**
|
||||
|
|
@ -445,10 +445,10 @@ When multiple candidates match a variant, disambiguate:
|
|||
**By organization (strongest signal):**
|
||||
\`\`\`
|
||||
# "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]]
|
||||
|
||||
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]]
|
||||
|
||||
# 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):**
|
||||
\`\`\`
|
||||
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
|
||||
\`\`\`
|
||||
|
||||
**By role:**
|
||||
\`\`\`
|
||||
# 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
|
||||
\`\`\`
|
||||
|
||||
|
|
@ -959,7 +959,7 @@ Before writing, compare extracted content against existing notes.
|
|||
|
||||
## 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.
|
||||
|
|
@ -993,20 +993,20 @@ If new info contradicts existing:
|
|||
- Wait for the tool to return before generating the next note.
|
||||
- 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",
|
||||
data: "# Jennifer\\n\\n## Summary\\n..."
|
||||
})
|
||||
\`\`\`
|
||||
|
||||
**For EXISTING entities (use workspace-edit):**
|
||||
- Read current content first with workspace-readFile
|
||||
- Use workspace-edit to add activity entry at TOP (reverse chronological)
|
||||
**For EXISTING entities (use file-editText):**
|
||||
- Read current content first with file-readText
|
||||
- Use file-editText to add activity entry at TOP (reverse chronological)
|
||||
- Update fields using targeted edits
|
||||
\`\`\`
|
||||
workspace-edit({
|
||||
file-editText({
|
||||
path: "{knowledge_folder}/People/Sarah Chen.md",
|
||||
oldString: "## Activity\\n",
|
||||
newString: "## Activity\\n- **2026-02-03** (meeting): Met to discuss project timeline\\n"
|
||||
|
|
@ -1016,8 +1016,8 @@ workspace-edit({
|
|||
**For \`suggested-topics.md\`:**
|
||||
- Use workspace-relative path \`suggested-topics.md\`
|
||||
- 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 \`workspace-edit\` for small targeted edits only if that keeps the file deduped and readable
|
||||
- Use \`file-writeText\` to create or rewrite the file when that is simpler and cleaner
|
||||
- Use \`file-editText\` for small targeted edits only if that keeps the file deduped and readable
|
||||
|
||||
## 9b: Apply State Changes
|
||||
|
||||
|
|
|
|||
|
|
@ -3,15 +3,15 @@ import { renderTagSystemForNotes } from './tag_system.js';
|
|||
export function getRaw(): string {
|
||||
return `---
|
||||
tools:
|
||||
workspace-readFile:
|
||||
file-readText:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-edit:
|
||||
name: file-readText
|
||||
file-editText:
|
||||
type: builtin
|
||||
name: workspace-edit
|
||||
workspace-readdir:
|
||||
name: file-editText
|
||||
file-list:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
name: file-list
|
||||
---
|
||||
# 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/).
|
||||
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).
|
||||
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.
|
||||
|
||||
# Frontmatter Format
|
||||
|
|
|
|||
|
|
@ -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`;
|
||||
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++) {
|
||||
const file = files[i];
|
||||
const relativePath = path.relative(WorkDir, file.path);
|
||||
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;
|
||||
|
||||
message += `## File ${i + 1}: ${relativePath}\n\n`;
|
||||
|
|
@ -111,7 +111,7 @@ async function tagNoteBatch(
|
|||
if (event.type !== 'tool-invocation') {
|
||||
return;
|
||||
}
|
||||
if (event.toolName !== 'workspace-edit') {
|
||||
if (event.toolName !== 'file-editText') {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
---
|
||||
tools:
|
||||
workspace-readFile:
|
||||
file-readText:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-writeFile:
|
||||
name: file-readText
|
||||
file-writeText:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
workspace-readdir:
|
||||
name: file-writeText
|
||||
file-list:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
name: file-list
|
||||
file-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
workspace-exists:
|
||||
name: file-mkdir
|
||||
file-exists:
|
||||
type: builtin
|
||||
name: workspace-exists
|
||||
name: file-exists
|
||||
executeCommand:
|
||||
type: builtin
|
||||
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:
|
||||
|
||||
1. Use `workspace-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/`
|
||||
1. Use `file-exists` to check if `pre-built/email-draft/state.json` exists
|
||||
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"
|
||||
|
||||
## Processing Flow
|
||||
|
|
@ -56,7 +56,7 @@ Read `pre-built/email-draft/state.json` to get:
|
|||
|
||||
### 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:
|
||||
1. Extract the email ID from filename (e.g., `19048cf9c0317981.md` → `19048cf9c0317981`)
|
||||
|
|
|
|||
|
|
@ -1,20 +1,20 @@
|
|||
---
|
||||
tools:
|
||||
workspace-readFile:
|
||||
file-readText:
|
||||
type: builtin
|
||||
name: workspace-readFile
|
||||
workspace-writeFile:
|
||||
name: file-readText
|
||||
file-writeText:
|
||||
type: builtin
|
||||
name: workspace-writeFile
|
||||
workspace-readdir:
|
||||
name: file-writeText
|
||||
file-list:
|
||||
type: builtin
|
||||
name: workspace-readdir
|
||||
workspace-mkdir:
|
||||
name: file-list
|
||||
file-mkdir:
|
||||
type: builtin
|
||||
name: workspace-mkdir
|
||||
workspace-exists:
|
||||
name: file-mkdir
|
||||
file-exists:
|
||||
type: builtin
|
||||
name: workspace-exists
|
||||
name: file-exists
|
||||
executeCommand:
|
||||
type: builtin
|
||||
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:
|
||||
|
||||
1. Use `workspace-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/`
|
||||
1. Use `file-exists` to check if `pre-built/meeting-prep/state.json` exists
|
||||
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
|
||||
|
||||
## Processing Flow
|
||||
|
|
@ -54,7 +54,7 @@ Read `pre-built/meeting-prep/state.json` to get:
|
|||
|
||||
### 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:
|
||||
1. Read the JSON content
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { IAbortRegistry } from "./abort-registry.js";
|
|||
import { IRunsLock } from "./lock.js";
|
||||
import { forceCloseAllMcpClients } from "../mcp/mcp.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 { 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
|
||||
&& 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));
|
||||
if (commandNames.length > 0) {
|
||||
await addToSecurityConfig(commandNames);
|
||||
|
|
|
|||
7
apps/x/packages/core/tsconfig.build.json
Normal file
7
apps/x/packages/core/tsconfig.build.json
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
11
apps/x/packages/core/vitest.config.ts
Normal file
11
apps/x/packages/core/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
|
|
@ -83,9 +83,23 @@ export const AskHumanResponseEvent = BaseRunEvent.extend({
|
|||
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({
|
||||
type: z.literal("tool-permission-request"),
|
||||
toolCall: ToolCallPart,
|
||||
permission: ToolPermissionMetadata.optional(),
|
||||
});
|
||||
|
||||
export const ToolPermissionResponseEvent = BaseRunEvent.extend({
|
||||
|
|
|
|||
238
apps/x/pnpm-lock.yaml
generated
238
apps/x/pnpm-lock.yaml
generated
|
|
@ -441,6 +441,9 @@ importers:
|
|||
'@types/pdf-parse':
|
||||
specifier: ^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:
|
||||
dependencies:
|
||||
|
|
@ -3263,6 +3266,9 @@ packages:
|
|||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
'@types/connect@3.4.38':
|
||||
resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
|
||||
|
||||
|
|
@ -3365,6 +3371,9 @@ packages:
|
|||
'@types/debug@4.1.12':
|
||||
resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==}
|
||||
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/electron-squirrel-startup@1.0.2':
|
||||
resolution: {integrity: sha512-AzxnvBzNh8K/0SmxMmZtpJf1/IWoGXLP+pQDuUaVkPyotI8ryvAtBSqgxR/qOSvxWHYWrxkeNsJ+Ca5xOuUxJQ==}
|
||||
|
||||
|
|
@ -3572,6 +3581,35 @@ packages:
|
|||
peerDependencies:
|
||||
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':
|
||||
resolution: {integrity: sha512-gcXoCN00METUNFeQOFJ+C9xUI0DKB+0EGMVg7wbVYRHBw2Eq3fKisDZOkRdOz3kqXRKOENMfShPOmypw1/8nOw==}
|
||||
|
||||
|
|
@ -3764,6 +3802,10 @@ packages:
|
|||
resolution: {integrity: sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
async-lock@1.4.1:
|
||||
resolution: {integrity: sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ==}
|
||||
|
||||
|
|
@ -3940,6 +3982,10 @@ packages:
|
|||
resolution: {integrity: sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
chai@6.2.2:
|
||||
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
chalk@4.1.2:
|
||||
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -4683,6 +4729,9 @@ packages:
|
|||
estree-util-is-identifier-name@3.0.0:
|
||||
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
|
||||
|
||||
esutils@2.0.3:
|
||||
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
|
@ -4717,6 +4766,10 @@ packages:
|
|||
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
|
||||
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:
|
||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
||||
|
||||
|
|
@ -6218,6 +6271,9 @@ packages:
|
|||
resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
ollama-ai-provider-v2@1.5.5:
|
||||
resolution: {integrity: sha512-1YwTFdPjhPNHny/DrOHO+s8oVGGIE5Jib61/KnnjPRNWQhVVimrJJdaAX3e6nNRRDXrY5zbb9cfm2+yVvgsrqw==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -7021,6 +7077,9 @@ packages:
|
|||
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
siginfo@2.0.0:
|
||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||
|
||||
signal-exit@3.0.7:
|
||||
resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
|
||||
|
||||
|
|
@ -7099,10 +7158,16 @@ packages:
|
|||
resolution: {integrity: sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==}
|
||||
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:
|
||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
std-env@4.1.0:
|
||||
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
|
||||
|
||||
stream-browserify@3.0.0:
|
||||
resolution: {integrity: sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==}
|
||||
|
||||
|
|
@ -7244,6 +7309,9 @@ packages:
|
|||
tiny-invariant@1.3.3:
|
||||
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
|
||||
|
||||
tinybench@2.9.0:
|
||||
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
|
||||
|
||||
tinyexec@1.0.2:
|
||||
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
|
||||
engines: {node: '>=18'}
|
||||
|
|
@ -7252,6 +7320,10 @@ packages:
|
|||
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
|
||||
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:
|
||||
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
|
||||
peerDependencies:
|
||||
|
|
@ -7497,6 +7569,7 @@ packages:
|
|||
|
||||
uuid@9.0.1:
|
||||
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
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
|
|
@ -7565,6 +7638,47 @@ packages:
|
|||
yaml:
|
||||
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:
|
||||
resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -7643,6 +7757,11 @@ packages:
|
|||
engines: {node: '>= 8'}
|
||||
hasBin: true
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
wmf@1.0.2:
|
||||
resolution: {integrity: sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
|
@ -11306,6 +11425,11 @@ snapshots:
|
|||
'@types/node': 25.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':
|
||||
dependencies:
|
||||
'@types/node': 25.0.3
|
||||
|
|
@ -11435,6 +11559,8 @@ snapshots:
|
|||
dependencies:
|
||||
'@types/ms': 2.1.0
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/electron-squirrel-startup@1.0.2': {}
|
||||
|
||||
'@types/eslint-scope@3.7.7':
|
||||
|
|
@ -11692,6 +11818,47 @@ snapshots:
|
|||
transitivePeerDependencies:
|
||||
- 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': {}
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
|
|
@ -11906,6 +12073,8 @@ snapshots:
|
|||
|
||||
arrify@2.0.1: {}
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
async-lock@1.4.1: {}
|
||||
|
||||
async@1.5.2:
|
||||
|
|
@ -12119,6 +12288,8 @@ snapshots:
|
|||
adler-32: 1.3.1
|
||||
crc-32: 1.2.2
|
||||
|
||||
chai@6.2.2: {}
|
||||
|
||||
chalk@4.1.2:
|
||||
dependencies:
|
||||
ansi-styles: 4.3.0
|
||||
|
|
@ -12974,6 +13145,10 @@ snapshots:
|
|||
|
||||
estree-util-is-identifier-name@3.0.0: {}
|
||||
|
||||
estree-walker@3.0.3:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
||||
esutils@2.0.3: {}
|
||||
|
||||
etag@1.8.1: {}
|
||||
|
|
@ -13004,6 +13179,8 @@ snapshots:
|
|||
signal-exit: 3.0.7
|
||||
strip-eof: 1.0.0
|
||||
|
||||
expect-type@1.3.0: {}
|
||||
|
||||
exponential-backoff@3.1.3: {}
|
||||
|
||||
express-rate-limit@7.5.1(express@5.2.1):
|
||||
|
|
@ -14943,6 +15120,8 @@ snapshots:
|
|||
object-keys@1.1.1:
|
||||
optional: true
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
ollama-ai-provider-v2@1.5.5(zod@4.2.1):
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.1
|
||||
|
|
@ -15935,6 +16114,8 @@ snapshots:
|
|||
side-channel-map: 1.0.1
|
||||
side-channel-weakmap: 1.0.2
|
||||
|
||||
siginfo@2.0.0: {}
|
||||
|
||||
signal-exit@3.0.7: {}
|
||||
|
||||
signal-exit@4.1.0: {}
|
||||
|
|
@ -16014,8 +16195,12 @@ snapshots:
|
|||
dependencies:
|
||||
minipass: 3.3.6
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
statuses@2.0.2: {}
|
||||
|
||||
std-env@4.1.0: {}
|
||||
|
||||
stream-browserify@3.0.0:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
|
|
@ -16182,6 +16367,8 @@ snapshots:
|
|||
|
||||
tiny-invariant@1.3.3: {}
|
||||
|
||||
tinybench@2.9.0: {}
|
||||
|
||||
tinyexec@1.0.2: {}
|
||||
|
||||
tinyglobby@0.2.15:
|
||||
|
|
@ -16189,6 +16376,8 @@ snapshots:
|
|||
fdir: 6.5.0(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)):
|
||||
dependencies:
|
||||
'@tiptap/core': 3.22.4(@tiptap/pm@3.22.4)
|
||||
|
|
@ -16503,6 +16692,50 @@ snapshots:
|
|||
terser: 5.46.0
|
||||
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-languageserver-protocol@3.17.5:
|
||||
|
|
@ -16606,6 +16839,11 @@ snapshots:
|
|||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
wmf@1.0.2: {}
|
||||
|
||||
word-wrap@1.2.5: {}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ packages:
|
|||
- apps/*
|
||||
- packages/*
|
||||
|
||||
catalog:
|
||||
vitest: 4.1.7
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- core-js
|
||||
- electron
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue