a pile of small fixes
This commit is contained in:
parent
0482dfbace
commit
d7f694fa8c
2
.gitignore
vendored
2
.gitignore
vendored
@ -1,5 +1,5 @@
|
||||
logfetch
|
||||
gadget*log
|
||||
logfetch
|
||||
|
||||
docs/archive
|
||||
node_modules
|
||||
|
||||
@ -15,6 +15,8 @@ That is what makes it obvious that a drone's specific runtime instance is never
|
||||
|
||||
## Workspace Philosophy Change
|
||||
|
||||
Reference: [Gadget Code Workspace](../gadget-workspace.md)
|
||||
|
||||
The new [Workspace](../../gadget-code/src/models/workspace.ts) mode and [IWorkSpace](../../packages/api/src/interfaces/workspace.ts) interface have been created to provide the backing for a workspace instance on a host.
|
||||
|
||||
A drone runs in a directory on a host. We need to make sure only one drone is ever running in the same directory on the same host at the same time.
|
||||
@ -73,9 +75,13 @@ Route: /api/v1/workspace
|
||||
|
||||
Endpoints:
|
||||
|
||||
- POST /
|
||||
- GET /
|
||||
- GET /:workspaceId
|
||||
- PUT /:workspaceId
|
||||
- PUT /:workspaceId
|
||||
- DELETE /:workspaceId
|
||||
- POST /:workspaceId/lock a drone is requesting the workspace lock
|
||||
- POST / create a workspace and have an \_id assigned
|
||||
|
||||
- GET / fetch a list of workspaces owned by the authenticated User
|
||||
- GET /:workspaceId fetch a specific workspace by \_id
|
||||
|
||||
- PUT /:workspaceId update a workspace by \_id
|
||||
|
||||
- DELETE /:workspaceId/lock a drone is releasing the workspace lock
|
||||
- DELETE /:workspaceId the entire workspace is being deleted
|
||||
|
||||
@ -28,12 +28,10 @@ You can't expect to observe the results of changes you make within the current t
|
||||
|
||||
Use your tools proactively. When working on the Gadget Code codebase, immediately read files, search for patterns, run tests, and execute commands to accomplish tasks. Do not announce tool usage or ask permission. Just execute and explain results afterward.
|
||||
|
||||
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||
{{tool_block}}
|
||||
|
||||
NOTICE: IF YOU EXPERIENCE DIFFICULTY USING ANY TOOLS OR RECEIVE A RESPONSE THAT IS UNEXPECTED OR SEEMS ERRONEOUS (TOOL MALFUNCTION), PLEASE **IMMEDIATELY** DOCUMENT WHAT THE TOOL DID THAT YOU DIDN'T EXPECT - AND STOP. WHEN IN THE DEVELOP MODE, YOU ARE WORKING WITH A DEVELOPER (THE USER) DIRECTLY ON THIS AGENTIC HARNESS (GADGET CODE). WE MAY NEED TO DEBUG OR DIAGNOSE A PROBLEM WITH A TOOL AS WE WORK.
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
Work in a loop through the User's request for this turn, resolving the work items that need done until finished, explaining your thinking and reasoning while calling tools and doing your work.
|
||||
|
||||
@ -6,13 +6,23 @@ You are Gadget. You are a software agent working as a software developer attendi
|
||||
|
||||
{{scope_block}}
|
||||
|
||||
We are currently in Plan mode, which means you are working back & forth with the User to define the work that will be done next. The user could be asking you to help them plan for actions to be taken in any other session mode. Do your best to help them break problems down into solveable chunks, write plan and TODO documents, and perform the research required to gather the knowledge required to make good decisions and build good plans.
|
||||
We are currently in Plan mode, which means you are working back & forth with the User to define the work that will be done next. Do your best to help the User break problems down into solveable chunks, write plan documents and TODO checklists, and perform the research required to gather the knowledge required to make good decisions and build good plans.
|
||||
|
||||
The User will ask you to analyze documents and code, hunt for defects/bugs, recommend improvements, create documentation, and related tasks. You respond by answering their questions, performing the requested research and analysis, writing text responses, and writing or updating documents in the project directories as needed, etc.
|
||||
The User will ask you work with them to:
|
||||
|
||||
Prefer doing your own research in the code over asking the user basic/starter questions. Delegate research tasks to the Explore subagent, let it go learn about the project details that you want to know, and submit a report back to you. You can spawn more than one subagent at a time to explore multiple topics simultaneously. The subagent(s) will then use their tools to explore the project and report back to you as requested. This is better than asking the User about the project, and gives you a chance to detect problems that should to be fixed.
|
||||
- Analyze documents and code
|
||||
- Create or refine existing documents
|
||||
- Research API/SDK documentation (Google search, fetch_url, etc.)
|
||||
- Hunt for defects/bugs (desk check the code with them, etc.)
|
||||
- Recommend improvements
|
||||
|
||||
While in Plan mode, you're NOT actively writing source code, making changes to code, or making changes to features. You are doing work, you will make git commits in Plan mode, but you're not actively working on features. You're describing the work to be done next together with the User in chat, creating and updating documents, splitting large tasks into workable phases and steps, and delegating tasks and work to subagents.
|
||||
You respond by answering their questions, performing the requested research and analysis, writing text responses, and writing or updating documents in the project directories as needed, etc.
|
||||
|
||||
Prefer doing your own research in the project documentation and code over asking the user basic/starter questions. You prefer to look things up online and research them as needed instead of asking the user questions about things you can answer yourself online.
|
||||
|
||||
Delegate research tasks to the Explore subagent. Let it go learn about the project details that you want to know, and submit a report back to you. You can spawn more than one subagent at a time to explore multiple topics simultaneously. The subagent(s) will then use their tools (same as yours) to explore the project and report back to you as requested. This is better than asking the User about the project, keeps YOUR context un-cluttered with irrelevant details, and gives you a chance to detect problems that should to be fixed.
|
||||
|
||||
While in Plan mode, you're NOT actively writing source code, making changes to code, or making changes to features. You are thinking about those things with the User. You are doing work, but you're not actively working on features. You're describing the work to be done next together with the User, creating and updating documents, splitting large tasks into workable phases and steps (when necessary), and delegating tasks to subagents.
|
||||
|
||||
### CHAT SESSION
|
||||
|
||||
@ -24,18 +34,18 @@ While in Plan mode, you're NOT actively writing source code, making changes to c
|
||||
|
||||
## TOOL USAGE
|
||||
|
||||
Use your tools to research and gather information. Read code, search for patterns, and explore the codebase to understand context before asking questions. Use subagents to delegate research tasks. Do not announce tool usage, just execute and use findings to inform your planning.
|
||||
Use your tools to research and gather information. Read code, search for patterns, and explore the codebase for context before asking questions. Use explore subagents to delegate research tasks.
|
||||
|
||||
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||
Don't announce tool usage, just execute and use findings to inform your planning. You can use your search_google and fetch_url tools to retrieve documentation in a readable Markdown format.
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||
{{tool_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
When the user sends you a prompt:
|
||||
|
||||
1. Reason about what they're asking for (or asking you to do).
|
||||
2. Plan out what you're going to do, the actions you're going to take, to satisfy the request and resolve the user's needs.
|
||||
2. Plan out what you're going to do in Build mode later, the actions you want to take in Build mode, to satisfy the request and resolve the user's needs.
|
||||
|
||||
a. Use the `ask_questions` tool to present the user with questions and up to 3 answers they can choose from (they will have a 4th: Type your own answer, provided by the system).
|
||||
|
||||
@ -44,13 +54,17 @@ When the user sends you a prompt:
|
||||
5. Create or update the plan documents (designs, plans, TODO lists, etc.) as needed.
|
||||
6. Repeat until the User accepts the plan and switches to Build mode.
|
||||
|
||||
You always end a turn by summarizing what you did to the User for their review and convenience.
|
||||
You always end a turn by summarizing what you did in report form for the User's review and convenience.
|
||||
|
||||
{{subagent_section}}
|
||||
|
||||
## EXITING PLAN MODE
|
||||
|
||||
You do not control the modes, the User does. Please don't end a turn by using the `ask_questions` tool to ask, "Are you ready to switch to build mode?" The User can't switch modes while using the questions tool.
|
||||
|
||||
Instead, say you are done planning and ready to switch to Build mode when the User is ready. Write that in your response, and let the turn end (stop). The User will then either switch to a new mode and proceed, or continue the Plan dialog with you. The User is in control of the session.
|
||||
|
||||
## CONSTRAINTS
|
||||
|
||||
- DO NOT work on code, edit code, create new code - those are Build mode tasks.
|
||||
- DO NOT add/remove dependencies or make changes to the project files while in Plan mode - those are Build mode tasks.
|
||||
|
||||
NOTE: DO NOT end a turn by using the ask_questions tool to ask, "Are you ready to switch to build mode?" - the User can't switch modes while using the questions tool. If you want to ask this question, write it in your response and let the turn end (stop).
|
||||
- DO NOT work on code, edit code, create new code - those are Build mode tasks. Just say you _want to_ do those things, and seek the User's approval.
|
||||
- DO NOT add/remove dependencies or make changes to the project files while in Plan mode. Those are Build mode tasks.
|
||||
|
||||
@ -20,9 +20,7 @@ We are currently in Ship mode, which means you are preparing code for deployment
|
||||
|
||||
Use your tools decisively. Run tests, check builds, verify deployments, and execute release commands. Do not announce tool usage or ask permission. Just execute and report results.
|
||||
|
||||
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||
{{tool_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
|
||||
@ -20,9 +20,7 @@ We are currently in Test mode, which means you are focused on writing tests, run
|
||||
|
||||
Use your tools aggressively for testing. Run tests frequently, read test files, search for test patterns, and execute test commands. Do not announce tool usage or ask permission. Just run the tests and report results.
|
||||
|
||||
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory. You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior, then stop. Let the User help determine what to do next.
|
||||
{{tool_block}}
|
||||
|
||||
## INSTRUCTIONS
|
||||
|
||||
|
||||
5
gadget-code/data/prompts/common/tool-block.md
Normal file
5
gadget-code/data/prompts/common/tool-block.md
Normal file
@ -0,0 +1,5 @@
|
||||
You must remain within the project directory, which is the current working directory. You cannot access files and data outside of the project directory.
|
||||
|
||||
You WILL NOT author scripts to work around the limitations of your tools. If you genuinely need something from outside the current working directory, ask the User to provide it for you, or for guidance on an alternate approach. Don't get stuck in a loop trying to figure out how to get something you can't have - ask the User to provide it (or an alternate approach) and stop.
|
||||
|
||||
**DO NOT** spawn a subagent as a workaround for lacking tool features, tool failures, and tool errors. Instead, please respond by writing out what you were trying to do, the parameters you used when calling the tool, and the full response you received. Don't work around tool errors. Report faulty tool performance and behavior to the User, then stop. Let the User help determine what to do next.
|
||||
@ -173,7 +173,7 @@ export default function ChatSessionView() {
|
||||
setSessionLocked(true);
|
||||
|
||||
const allProviders = await providerApi.getAll();
|
||||
setProviders(allProviders);
|
||||
setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
|
||||
const providerId = typeof sessionData.provider === 'string'
|
||||
? sessionData.provider
|
||||
|
||||
@ -378,7 +378,7 @@ function RightSidebar({
|
||||
|
||||
// Load providers for new chat session
|
||||
const allProviders = await providerApi.getAll();
|
||||
setProviders(allProviders);
|
||||
setProviders(allProviders.sort((a, b) => a.name.localeCompare(b.name)));
|
||||
} catch (err) {
|
||||
console.error("Failed to load sidebar data", err);
|
||||
} finally {
|
||||
@ -714,7 +714,7 @@ function NewChatSessionModal({
|
||||
disabled={selectedProvider.models.length === 0}
|
||||
>
|
||||
<option value="">Select a model</option>
|
||||
{selectedProvider.models.map((model) => (
|
||||
{[...selectedProvider.models].sort((a, b) => a.name.localeCompare(b.name)).map((model) => (
|
||||
<option key={model.id} value={model.id}>
|
||||
{model.name}{" "}
|
||||
{model.parameterLabel ? `(${model.parameterLabel})` : ""}
|
||||
|
||||
@ -110,7 +110,7 @@ class ChatSessionController extends DtpController {
|
||||
}
|
||||
|
||||
const sessionMode = mode
|
||||
? ChatSessionMode[mode as keyof typeof ChatSessionMode]
|
||||
? (mode as ChatSessionMode)
|
||||
: ChatSessionMode.Build;
|
||||
|
||||
const session = await ChatSessionService.create(
|
||||
|
||||
@ -321,6 +321,10 @@ class ChatSessionService extends DtpService {
|
||||
path.join(commonDir, "scope-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
toolsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "tools-block.md"),
|
||||
"utf-8",
|
||||
),
|
||||
subagentsBlock: await fs.promises.readFile(
|
||||
path.join(commonDir, "subagents.md"),
|
||||
"utf-8",
|
||||
@ -362,6 +366,7 @@ class ChatSessionService extends DtpService {
|
||||
|
||||
let prompt = promptTemplate
|
||||
.replace("{{scope_block}}", common.scopeBlock)
|
||||
.replace("{{tools}}", common.toolsBlock)
|
||||
.replace("{{subagent_section}}", common.subagentsBlock)
|
||||
.replace("{{session_block}}", sessionBlock)
|
||||
.replace("{{persona_block}}", personaBlock);
|
||||
|
||||
@ -326,35 +326,35 @@ class GadgetDrone extends GadgetProcess {
|
||||
this.socket.emit("status", "session lock granted");
|
||||
|
||||
/*
|
||||
* Add the project to the workspace, lock to it, and deploy it.
|
||||
* Check if this project is already deployed (by ID, not slug).
|
||||
*/
|
||||
const haveProjectInWorkspace = WorkspaceService.hasProjectById(
|
||||
project._id,
|
||||
);
|
||||
if (!haveProjectInWorkspace) {
|
||||
this.socket.emit("status", `deploying project [slug=${project.slug}]`);
|
||||
await WorkspaceService.deployProject(project);
|
||||
}
|
||||
|
||||
/*
|
||||
* Add/update the project in workspace data and lock to it.
|
||||
*/
|
||||
WorkspaceService.addProject({
|
||||
_id: project._id,
|
||||
slug: project.slug,
|
||||
gitUrl: project.gitUrl,
|
||||
});
|
||||
|
||||
if (!haveProjectInWorkspace) {
|
||||
WorkspaceService.markProjectDeployed(project.slug);
|
||||
}
|
||||
|
||||
WorkspaceService.updateLockedProject({
|
||||
_id: project._id,
|
||||
slug: project.slug,
|
||||
gitUrl: project.gitUrl,
|
||||
});
|
||||
|
||||
const projectDir = WorkspaceService.getProjectDirectory(project.slug);
|
||||
|
||||
let haveProjectDir = await WorkspaceService.exists(projectDir);
|
||||
if (!haveProjectDir) {
|
||||
this.socket.emit("status", `deploying project [slug=${project.slug}]`);
|
||||
await WorkspaceService.deployProject(project);
|
||||
|
||||
/*
|
||||
* Make sure a project directory got deployed
|
||||
*/
|
||||
haveProjectDir = await WorkspaceService.exists(projectDir);
|
||||
if (!haveProjectDir) {
|
||||
return cb(false, "failed to deploy project directory");
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Commit the changes to the workspace.
|
||||
*/
|
||||
|
||||
@ -125,6 +125,12 @@ describe("AgentService", () => {
|
||||
expect(toolNames).toContain("search_google");
|
||||
expect(toolNames).not.toContain("file_write");
|
||||
expect(toolNames).not.toContain("file_edit");
|
||||
|
||||
// Plan mode has .gadget tools for its own storage
|
||||
expect(toolNames).toContain("plan_file_read");
|
||||
expect(toolNames).toContain("plan_file_write");
|
||||
expect(toolNames).toContain("plan_file_edit");
|
||||
expect(toolNames).toContain("plan_list");
|
||||
});
|
||||
|
||||
it("exposes mutating file tools in build mode", () => {
|
||||
@ -132,5 +138,11 @@ describe("AgentService", () => {
|
||||
|
||||
expect(toolNames).toContain("file_write");
|
||||
expect(toolNames).toContain("file_edit");
|
||||
|
||||
// Build mode should not have Plan-only .gadget tools
|
||||
expect(toolNames).not.toContain("plan_file_read");
|
||||
expect(toolNames).not.toContain("plan_file_write");
|
||||
expect(toolNames).not.toContain("plan_file_edit");
|
||||
expect(toolNames).not.toContain("plan_list");
|
||||
});
|
||||
});
|
||||
|
||||
@ -35,6 +35,10 @@ import {
|
||||
GoogleSearchTool,
|
||||
GrepTool,
|
||||
ListTool,
|
||||
PlanFileEditTool,
|
||||
PlanFileReadTool,
|
||||
PlanFileWriteTool,
|
||||
PlanListTool,
|
||||
ShellExecTool,
|
||||
type DroneToolboxEnvironment,
|
||||
} from "../tools/index.ts";
|
||||
@ -99,6 +103,12 @@ class AgentService extends GadgetService {
|
||||
this.toolbox.register(new FileEditTool(this.toolbox), writeModes);
|
||||
this.toolbox.register(new ShellExecTool(this.toolbox), writeModes);
|
||||
|
||||
// Plan tools — Gadget's own .gadget directory: only available in Plan mode
|
||||
this.toolbox.register(new PlanFileReadTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
this.toolbox.register(new PlanFileWriteTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
this.toolbox.register(new PlanFileEditTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
this.toolbox.register(new PlanListTool(this.toolbox), [ChatSessionMode.Plan]);
|
||||
|
||||
this.log.info("started");
|
||||
}
|
||||
|
||||
|
||||
@ -250,32 +250,75 @@ class WorkspaceService extends GadgetService {
|
||||
|
||||
async deployProject(project: IProject): Promise<void> {
|
||||
assert(this.workspaceData, "workspace is uninitialized");
|
||||
assert(this.workspaceGit, "workspace git interface is uninitialized");
|
||||
|
||||
const projectDir = this.getProjectDirectory(project.slug);
|
||||
const dirExists = await this.exists(projectDir);
|
||||
|
||||
if (project.gitUrl) {
|
||||
const oldDir = process.cwd();
|
||||
assert(this.workspaceGit, "workspace git interface is uninitialized");
|
||||
|
||||
process.chdir(this.workspaceData.workspaceDir);
|
||||
this.log.info("cloning into git repo", {
|
||||
url: project.gitUrl,
|
||||
if (dirExists) {
|
||||
/*
|
||||
* If .git exists, it's already cloned — nothing to do.
|
||||
* If .git is missing, the directory is stale (partial clone, mkdir
|
||||
* from a previous non-git deploy, etc.). Remove it so we can clone
|
||||
* fresh.
|
||||
*/
|
||||
const gitDir = path.join(projectDir, ".git");
|
||||
if (await this.exists(gitDir)) {
|
||||
this.log.info("project already cloned, skipping deploy", {
|
||||
projectDir,
|
||||
});
|
||||
this.workspaceGit.clone(project.gitUrl, projectDir);
|
||||
|
||||
process.chdir(projectDir);
|
||||
const git: SimpleGit = this.getGitRepo(project.slug);
|
||||
const status = await git.status();
|
||||
this.log.info("project deployed", {
|
||||
projectDir,
|
||||
isClean: status.isClean(),
|
||||
branch: status.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.log.warn("removing stale project directory for fresh clone", {
|
||||
projectDir,
|
||||
});
|
||||
await fs.promises.rm(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
this.log.info("cloning into git repo", {
|
||||
url: project.gitUrl,
|
||||
projectDir,
|
||||
});
|
||||
await this.workspaceGit.clone(project.gitUrl, projectDir);
|
||||
this.log.info("project deployed via git clone", { projectDir });
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
* Non-git project: only create the directory if it doesn't exist.
|
||||
*/
|
||||
if (!dirExists) {
|
||||
this.log.info("creating project directory", { projectDir });
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
} else {
|
||||
this.log.info("project directory already exists", { projectDir });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a project ID is already tracked in the workspace.
|
||||
*/
|
||||
hasProjectById(id: string): boolean {
|
||||
if (!this._workspaceData) return false;
|
||||
return this._workspaceData.projects.some((p) => p._id === id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks a project as deployed by updating its clonedAt and lastSyncAt timestamps.
|
||||
*/
|
||||
markProjectDeployed(slug: string): void {
|
||||
if (!this._workspaceData) return;
|
||||
const project = this._workspaceData.projects.find(
|
||||
(p) => p.slug === slug,
|
||||
);
|
||||
if (project) {
|
||||
const now = new Date().toISOString();
|
||||
project.clonedAt = now;
|
||||
project.lastSyncAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -5,3 +5,4 @@ export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts";
|
||||
export { DroneTool } from "./tool.ts";
|
||||
export * from "./system/index.ts";
|
||||
export * from "./network/index.ts";
|
||||
export * from "./plan/index.ts";
|
||||
|
||||
64
gadget-drone/src/tools/plan/common.ts
Normal file
64
gadget-drone/src/tools/plan/common.ts
Normal file
@ -0,0 +1,64 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import path from "node:path";
|
||||
|
||||
import { formatError, type IToolError } from "@gadget/ai";
|
||||
import type { AiToolbox } from "../toolbox.ts";
|
||||
import { getProjectRoot, toolError } from "../system/common.ts";
|
||||
|
||||
export interface ResolvedPlanPath {
|
||||
inputPath: string;
|
||||
absolutePath: string;
|
||||
displayPath: string;
|
||||
}
|
||||
|
||||
export function getGadgetDir(toolbox: AiToolbox): string | undefined {
|
||||
const projectRoot = getProjectRoot(toolbox);
|
||||
if (!projectRoot) return undefined;
|
||||
return path.resolve(projectRoot, ".gadget");
|
||||
}
|
||||
|
||||
export function resolvePlanPath(
|
||||
toolbox: AiToolbox,
|
||||
inputPath: string,
|
||||
): ResolvedPlanPath | string {
|
||||
const gadgetDir = getGadgetDir(toolbox);
|
||||
if (!gadgetDir) {
|
||||
return toolError({
|
||||
code: "OPERATION_NOT_ALLOWED",
|
||||
message: "No active project workspace is configured for plan file tools.",
|
||||
recoveryHint: "Run this tool during an active Agent work order in Plan mode.",
|
||||
});
|
||||
}
|
||||
|
||||
const trimmedPath = inputPath.trim();
|
||||
if (!trimmedPath) {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "File path must not be empty.",
|
||||
parameter: "path",
|
||||
recoveryHint: "Provide a valid file path within the .gadget directory.",
|
||||
});
|
||||
}
|
||||
|
||||
const absolutePath = path.isAbsolute(trimmedPath)
|
||||
? path.resolve(trimmedPath)
|
||||
: path.resolve(gadgetDir, trimmedPath);
|
||||
const relative = path.relative(gadgetDir, absolutePath);
|
||||
|
||||
if (relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))) {
|
||||
return {
|
||||
inputPath: trimmedPath,
|
||||
absolutePath,
|
||||
displayPath: relative || ".",
|
||||
};
|
||||
}
|
||||
|
||||
return toolError({
|
||||
code: "SECURITY_VIOLATION",
|
||||
message: `Path is outside the .gadget directory: ${trimmedPath}`,
|
||||
parameter: "path",
|
||||
recoveryHint: "Use a relative path inside the project's .gadget directory.",
|
||||
});
|
||||
}
|
||||
222
gadget-drone/src/tools/plan/edit.ts
Normal file
222
gadget-drone/src/tools/plan/edit.ts
Normal file
@ -0,0 +1,222 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { toolError } from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanFileEditTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_file_edit";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "plan";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Perform an exact search-and-replace edit on a file in Gadget's .gadget directory. Replaces the first occurrence only and returns changed-line context. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and updating plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file to edit, relative to the .gadget directory." },
|
||||
search: { type: "string", description: "Exact text to search for. It must match whitespace and line endings exactly." },
|
||||
replace: { type: "string", description: "Replacement text. Empty string is allowed to delete the match." },
|
||||
},
|
||||
required: ["path", "search", "replace"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||
const filePath = args.path;
|
||||
const search = args.search;
|
||||
const replace = args.replace;
|
||||
|
||||
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "File path must not be empty.",
|
||||
parameter: "path",
|
||||
recoveryHint: "Provide a valid file path within the .gadget directory.",
|
||||
});
|
||||
}
|
||||
if (typeof search !== "string" || search.length === 0) {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "Search string must not be empty.",
|
||||
parameter: "search",
|
||||
recoveryHint: "Provide the exact text to search for.",
|
||||
});
|
||||
}
|
||||
if (typeof replace !== "string") {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "Replace string must be a string and must not be undefined.",
|
||||
parameter: "replace",
|
||||
recoveryHint: "Provide the replacement text. Use an empty string to delete the match.",
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = resolvePlanPath(this.toolbox, filePath);
|
||||
if (typeof resolved === "string") return resolved;
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(resolved.absolutePath, "utf-8");
|
||||
const searchIdx = content.indexOf(search);
|
||||
if (searchIdx === -1) {
|
||||
const contextInfo = this.buildNotFoundContext(content, search);
|
||||
return toolError({
|
||||
code: "NOT_FOUND",
|
||||
message: `Search string not found in ${resolved.displayPath}.${contextInfo}`,
|
||||
parameter: "search",
|
||||
recoveryHint: "Verify your search string matches exactly, including whitespace and line endings.",
|
||||
});
|
||||
}
|
||||
|
||||
const newContent = content.replace(search, replace);
|
||||
await fs.writeFile(resolved.absolutePath, newContent, "utf-8");
|
||||
const diffContext = this.buildDiffContext(content, searchIdx, search, newContent);
|
||||
|
||||
return [
|
||||
`PATH: ${resolved.displayPath}`,
|
||||
"FILE OPERATION: edit",
|
||||
"SEARCH FOUND: true",
|
||||
"---",
|
||||
`File edited: ${resolved.displayPath}`,
|
||||
"",
|
||||
diffContext,
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes("ENOENT")) {
|
||||
const gadgetDir = getGadgetDir(this.toolbox);
|
||||
return toolError({
|
||||
code: "NOT_FOUND",
|
||||
message: `File not found in .gadget: ${resolved.displayPath}${gadgetDir ? `. The .gadget directory exists but this file does not.` : ". Plan storage is empty. Create a file with plan_file_write first."}`,
|
||||
parameter: "path",
|
||||
recoveryHint: "Check the file path. Use plan_list to see available files, or plan_file_write to create the file first.",
|
||||
});
|
||||
}
|
||||
logger.error("failed to edit plan file", { path: resolved.displayPath, error: errorMessage });
|
||||
return toolError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to edit file: ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private buildNotFoundContext(content: string, search: string): string {
|
||||
const lines = content.split("\n");
|
||||
const searchWords = search.toLowerCase().split(/\s+/).filter((word) => word.length > 2);
|
||||
let bestMatchLine = -1;
|
||||
let bestMatchScore = 0;
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
if (line === undefined) continue;
|
||||
const lineLower = line.toLowerCase();
|
||||
let score = 0;
|
||||
for (const word of searchWords) {
|
||||
if (lineLower.includes(word)) score++;
|
||||
}
|
||||
if (score > bestMatchScore) {
|
||||
bestMatchScore = score;
|
||||
bestMatchLine = i;
|
||||
}
|
||||
}
|
||||
|
||||
if (bestMatchLine === -1) {
|
||||
const preview = lines.slice(0, 5).map((line, index) => ` ${index + 1}: ${line}`).join("\n");
|
||||
return `\n\nFile content (first 5 lines):\n${preview}`;
|
||||
}
|
||||
|
||||
const contextStart = Math.max(0, bestMatchLine - 2);
|
||||
const contextEnd = Math.min(lines.length, bestMatchLine + 3);
|
||||
const context = lines
|
||||
.slice(contextStart, contextEnd)
|
||||
.map((line, index) => ` ${contextStart + index + 1}: ${line}`)
|
||||
.join("\n");
|
||||
|
||||
return `\n\nFile content around line ${bestMatchLine + 1} (possible match location):\n${context}`;
|
||||
}
|
||||
|
||||
private buildDiffContext(
|
||||
original: string,
|
||||
matchStart: number,
|
||||
search: string,
|
||||
newContent: string,
|
||||
): string {
|
||||
const contextLines = 2;
|
||||
const oldLines = original.split("\n");
|
||||
const newLines = newContent.split("\n");
|
||||
let charOffset = 0;
|
||||
let matchStartLine = 0;
|
||||
|
||||
for (let i = 0; i < oldLines.length; i++) {
|
||||
const line = oldLines[i];
|
||||
if (line === undefined) break;
|
||||
const lineLen = line.length + 1;
|
||||
if (charOffset + lineLen > matchStart) {
|
||||
matchStartLine = i;
|
||||
break;
|
||||
}
|
||||
charOffset += lineLen;
|
||||
}
|
||||
|
||||
const searchLines = search.split("\n");
|
||||
const matchEndLine = matchStartLine + searchLines.length - 1;
|
||||
const affectedStartLine = Math.max(0, matchStartLine - contextLines);
|
||||
const diffLines: string[] = [];
|
||||
const numChangedLines = matchEndLine - matchStartLine + 1;
|
||||
|
||||
if (numChangedLines === 1) {
|
||||
const lineNum = matchStartLine + 1;
|
||||
const oldLineText = oldLines[matchStartLine] ?? "";
|
||||
const newLineText = newLines[matchStartLine] ?? "";
|
||||
diffLines.push(`Changed line ${lineNum}:`);
|
||||
diffLines.push(` Removed (${oldLineText.length} chars): ${oldLineText}`);
|
||||
diffLines.push(` Added (${newLineText.length} chars): ${newLineText}`);
|
||||
} else {
|
||||
diffLines.push(`Changed lines ${matchStartLine + 1}-${matchEndLine + 1}:`);
|
||||
diffLines.push(` Search spanned ${numChangedLines} lines`);
|
||||
diffLines.push(" --- Old:");
|
||||
for (let i = matchStartLine; i <= matchEndLine; i++) {
|
||||
const oldLine = oldLines[i];
|
||||
if (oldLine !== undefined) diffLines.push(` ${i + 1}: ${oldLine}`);
|
||||
}
|
||||
diffLines.push(" --- New:");
|
||||
for (let i = 0; i < searchLines.length; i++) {
|
||||
const newLineIdx = matchStartLine + i;
|
||||
const newLine = newLines[newLineIdx];
|
||||
if (newLine !== undefined) diffLines.push(` ${newLineIdx + 1}: ${newLine}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (matchStartLine > affectedStartLine) {
|
||||
diffLines.push("", "Context before:");
|
||||
for (let i = affectedStartLine; i < matchStartLine; i++) {
|
||||
const ctxLine = oldLines[i];
|
||||
if (ctxLine !== undefined) diffLines.push(` ${i + 1}: ${ctxLine}`);
|
||||
}
|
||||
}
|
||||
|
||||
const actualEndLine = Math.min(newLines.length, matchEndLine + contextLines + 1);
|
||||
if (matchEndLine + 1 < actualEndLine) {
|
||||
diffLines.push("", "Context after:");
|
||||
for (let i = matchEndLine + 1; i < actualEndLine; i++) {
|
||||
const ctxLine = newLines[i];
|
||||
if (ctxLine !== undefined) diffLines.push(` ${i + 1}: ${ctxLine}`);
|
||||
}
|
||||
}
|
||||
|
||||
return diffLines.join("\n");
|
||||
}
|
||||
}
|
||||
7
gadget-drone/src/tools/plan/index.ts
Normal file
7
gadget-drone/src/tools/plan/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
export { PlanFileReadTool } from "./read.ts";
|
||||
export { PlanFileWriteTool } from "./write.ts";
|
||||
export { PlanFileEditTool } from "./edit.ts";
|
||||
export { PlanListTool } from "./list.ts";
|
||||
114
gadget-drone/src/tools/plan/list.ts
Normal file
114
gadget-drone/src/tools/plan/list.ts
Normal file
@ -0,0 +1,114 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { toolError } from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanListTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_list";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "plan";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"List contents of Gadget's .gadget directory. This Plan-mode tool works in Gadget's own directory (.gadget) for exploring stored plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: {
|
||||
type: "string",
|
||||
description: "Subdirectory path to list within .gadget, relative to the .gadget directory.",
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||
const gadgetDir = getGadgetDir(this.toolbox);
|
||||
if (!gadgetDir) {
|
||||
return toolError({
|
||||
code: "OPERATION_NOT_ALLOWED",
|
||||
message: "No active project workspace is configured for plan file tools.",
|
||||
recoveryHint: "Run this tool during an active Agent work order in Plan mode.",
|
||||
});
|
||||
}
|
||||
|
||||
let targetPath: string;
|
||||
let displayPath: string;
|
||||
|
||||
if (args.path !== undefined && typeof args.path === "string") {
|
||||
const resolved = resolvePlanPath(this.toolbox, args.path);
|
||||
if (typeof resolved === "string") return resolved;
|
||||
targetPath = resolved.absolutePath;
|
||||
displayPath = resolved.displayPath;
|
||||
} else {
|
||||
targetPath = path.resolve(gadgetDir);
|
||||
displayPath = ".";
|
||||
}
|
||||
|
||||
try {
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.stat(targetPath);
|
||||
} catch {
|
||||
return "Plan storage is empty. The .gadget directory has not been created yet. Use plan_file_write to store your first file.";
|
||||
}
|
||||
|
||||
if (!stat.isDirectory()) {
|
||||
return toolError({
|
||||
code: "INVALID_PARAMETER",
|
||||
message: `"${displayPath}" is not a directory.`,
|
||||
parameter: "path",
|
||||
recoveryHint: "Provide a directory path to list.",
|
||||
});
|
||||
}
|
||||
|
||||
const entries = await fs.readdir(targetPath, { withFileTypes: true });
|
||||
if (entries.length === 0) {
|
||||
return `The .gadget directory "${displayPath}" is empty.`;
|
||||
}
|
||||
|
||||
const lines: string[] = [
|
||||
`Contents of ".gadget${displayPath === "." ? "" : `/${displayPath}`}" (${entries.length} entries):`,
|
||||
"",
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
const typeIndicator = entry.isDirectory() ? "d" : entry.isSymbolicLink() ? "l" : "-";
|
||||
let size = "-";
|
||||
let modified = "-";
|
||||
try {
|
||||
const entryStat = await fs.stat(path.join(targetPath, entry.name));
|
||||
if (entry.isFile()) size = entryStat.size.toString();
|
||||
modified = entryStat.mtime.toISOString().split("T")[0];
|
||||
} catch {
|
||||
// stat unavailable — skip size and date
|
||||
}
|
||||
lines.push(`${typeIndicator} ${size.padStart(10)} ${modified} ${entry.name}`);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to list .gadget directory", { error: errorMessage });
|
||||
return toolError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to list directory: ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
139
gadget-drone/src/tools/plan/read.ts
Normal file
139
gadget-drone/src/tools/plan/read.ts
Normal file
@ -0,0 +1,139 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import {
|
||||
asPositiveInteger,
|
||||
formatNumberedLines,
|
||||
isBinaryBuffer,
|
||||
toolError,
|
||||
} from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanFileReadTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_file_read";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "plan";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Read a file from Gadget's .gadget directory with line numbers. This Plan-mode tool works in Gadget's own directory (.gadget) for storing and reading plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to the file to read, relative to the .gadget directory." },
|
||||
startLine: { type: "number", description: "Starting line number (1-indexed). Defaults to 1." },
|
||||
endLine: { type: "number", description: "Ending line number (inclusive). Defaults to end of file." },
|
||||
},
|
||||
required: ["path"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||
const filePath = args.path;
|
||||
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "File path must not be empty.",
|
||||
parameter: "path",
|
||||
recoveryHint: "Provide a valid file path within the .gadget directory.",
|
||||
});
|
||||
}
|
||||
|
||||
const startLine = asPositiveInteger(args.startLine) ?? 1;
|
||||
const endLine = args.endLine === undefined ? undefined : asPositiveInteger(args.endLine);
|
||||
|
||||
if (startLine < 1) {
|
||||
return toolError({
|
||||
code: "INVALID_PARAMETER",
|
||||
message: "startLine must be >= 1.",
|
||||
parameter: "startLine",
|
||||
expected: "A positive integer >= 1",
|
||||
});
|
||||
}
|
||||
if (endLine !== undefined && endLine < startLine) {
|
||||
return toolError({
|
||||
code: "INVALID_PARAMETER",
|
||||
message: "endLine must be >= startLine.",
|
||||
parameter: "endLine",
|
||||
expected: "An integer >= startLine",
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = resolvePlanPath(this.toolbox, filePath);
|
||||
if (typeof resolved === "string") return resolved;
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(resolved.absolutePath);
|
||||
if (!stat.isFile()) {
|
||||
return toolError({
|
||||
code: "INVALID_PARAMETER",
|
||||
message: `"${resolved.displayPath}" is not a file.`,
|
||||
parameter: "path",
|
||||
recoveryHint: "Provide a path to a regular file, not a directory.",
|
||||
});
|
||||
}
|
||||
|
||||
const raw = await fs.readFile(resolved.absolutePath);
|
||||
if (isBinaryBuffer(raw)) {
|
||||
return [
|
||||
`PATH: ${resolved.displayPath}`,
|
||||
"TOTAL LINES: 0",
|
||||
"LINES SHOWN: 0",
|
||||
"FILE OPERATION: read",
|
||||
"---",
|
||||
`Binary file, cannot display: ${resolved.displayPath}`,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const content = raw.toString("utf-8");
|
||||
const lines = content.split("\n");
|
||||
const startIdx = Math.max(0, startLine - 1);
|
||||
const endIdx = endLine !== undefined ? Math.min(endLine, lines.length) : lines.length;
|
||||
const selectedLines = lines.slice(startIdx, endIdx);
|
||||
const numberedLines = formatNumberedLines(selectedLines, startIdx);
|
||||
const totalLines = lines.length;
|
||||
const rangeLabel = endLine !== undefined || startLine > 1
|
||||
? `lines ${startIdx + 1}-${endIdx} of ${totalLines}`
|
||||
: `${totalLines} lines`;
|
||||
|
||||
return [
|
||||
`PATH: ${resolved.displayPath}`,
|
||||
`TOTAL LINES: ${totalLines}`,
|
||||
`LINES SHOWN: ${selectedLines.length}`,
|
||||
"FILE OPERATION: read",
|
||||
"---",
|
||||
`File: ${resolved.displayPath} (${rangeLabel})`,
|
||||
"",
|
||||
numberedLines,
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
if (errorMessage.includes("ENOENT")) {
|
||||
const gadgetDir = getGadgetDir(this.toolbox);
|
||||
return toolError({
|
||||
code: "NOT_FOUND",
|
||||
message: `File not found in .gadget: ${resolved.displayPath}${gadgetDir ? `. The .gadget directory exists but this file does not.` : ". Plan storage is empty. Create a file with plan_file_write first."}`,
|
||||
parameter: "path",
|
||||
recoveryHint: "Check the file path. Use plan_list to see available files.",
|
||||
});
|
||||
}
|
||||
logger.error("failed to read plan file", { path: resolved.displayPath, error: errorMessage });
|
||||
return toolError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to read file: ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
99
gadget-drone/src/tools/plan/write.ts
Normal file
99
gadget-drone/src/tools/plan/write.ts
Normal file
@ -0,0 +1,99 @@
|
||||
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
|
||||
import { DroneTool } from "../tool.ts";
|
||||
import { toolError } from "../system/common.ts";
|
||||
import { getGadgetDir, resolvePlanPath } from "./common.ts";
|
||||
|
||||
export class PlanFileWriteTool extends DroneTool {
|
||||
get name(): string {
|
||||
return "plan_file_write";
|
||||
}
|
||||
|
||||
get category(): string {
|
||||
return "plan";
|
||||
}
|
||||
|
||||
public definition: IToolDefinition = {
|
||||
type: "function",
|
||||
function: {
|
||||
name: this.name,
|
||||
description:
|
||||
"Create or overwrite a file in Gadget's .gadget directory. The .gadget directory is created automatically if it doesn't exist. This Plan-mode tool works in Gadget's own directory (.gadget) for storing plans, todos, and other knowledge. It does not affect project files.",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
path: { type: "string", description: "Path to create or overwrite, relative to the .gadget directory." },
|
||||
content: { type: "string", description: "The content to write to the file. Empty string is allowed." },
|
||||
},
|
||||
required: ["path", "content"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
public async execute(args: IToolArguments, logger: IAiLogger): Promise<string> {
|
||||
const filePath = args.path;
|
||||
const content = args.content;
|
||||
|
||||
if (typeof filePath !== "string" || filePath.trim().length === 0) {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "File path must not be empty.",
|
||||
parameter: "path",
|
||||
recoveryHint: "Provide a valid file path within the .gadget directory.",
|
||||
});
|
||||
}
|
||||
if (content === undefined || typeof content !== "string") {
|
||||
return toolError({
|
||||
code: "MISSING_PARAMETER",
|
||||
message: "Content must be a string and must not be undefined.",
|
||||
parameter: "content",
|
||||
recoveryHint: "Provide the content to write to the file. Use an empty string to create an empty file.",
|
||||
});
|
||||
}
|
||||
|
||||
// Ensure .gadget directory exists before resolving — write tools create the dir
|
||||
const gadgetDir = getGadgetDir(this.toolbox);
|
||||
if (gadgetDir) {
|
||||
await fs.mkdir(gadgetDir, { recursive: true });
|
||||
}
|
||||
|
||||
const resolved = resolvePlanPath(this.toolbox, filePath);
|
||||
if (typeof resolved === "string") return resolved;
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(resolved.absolutePath), { recursive: true });
|
||||
|
||||
let created = true;
|
||||
try {
|
||||
const stat = await fs.stat(resolved.absolutePath);
|
||||
created = !stat.isFile();
|
||||
} catch {
|
||||
created = true;
|
||||
}
|
||||
|
||||
await fs.writeFile(resolved.absolutePath, content, "utf-8");
|
||||
const byteCount = Buffer.byteLength(content, "utf-8");
|
||||
|
||||
return [
|
||||
`PATH: ${resolved.displayPath}`,
|
||||
"FILE OPERATION: write",
|
||||
`CREATED: ${created ? "true" : "false"}`,
|
||||
`BYTES WRITTEN: ${byteCount}`,
|
||||
"---",
|
||||
`File written: ${resolved.displayPath} (${byteCount} bytes)`,
|
||||
].join("\n");
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error("failed to write plan file", { path: resolved.displayPath, error: errorMessage });
|
||||
return toolError({
|
||||
code: "OPERATION_FAILED",
|
||||
message: `Failed to write file: ${errorMessage}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
2
terms-of-service.md
Normal file
2
terms-of-service.md
Normal file
@ -0,0 +1,2 @@
|
||||
This document intentionally left blank. YOU host this.
|
||||
Do whatever you want while accepting responsibility for that.
|
||||
Loading…
Reference in New Issue
Block a user