AI toolbox refactor/extraction complete (checkpoint)

This commit is contained in:
Rob Colbert 2026-05-16 17:06:08 -04:00
parent 5eab058304
commit 79a6f77659
40 changed files with 227 additions and 715 deletions

View File

@ -24,6 +24,7 @@
"packageManager": "pnpm@10.33.2+sha512.a90faf6feeab71ad6c6e57f94e0fe1a12f5dcc22cd754db40ae9593eb6a3e0b6b12e3540218bb37ae083404b1f2ce6db2a4121e979829b4aff94b99f49da1cf8",
"dependencies": {
"@gadget/ai": "workspace:*",
"@gadget/ai-toolbox": "workspace:*",
"@gadget/api": "workspace:*",
"@gadget/config": "workspace:*",
"@inquirer/prompts": "^8.4.2",

View File

@ -130,7 +130,7 @@ class AgentService extends GadgetService {
// Chat tools — subagent spawning: available in all modes
const subagentTool = new SubagentTool(this.toolbox);
subagentTool.setSpawner((agentType, prompt) => this.spawnSubagent(agentType, prompt));
subagentTool.setSpawner((agentType: string, prompt: string) => this.spawnSubagent(agentType, prompt));
this.toolbox.register(subagentTool, readOnlyModes);
// Project tools — skill discovery: available in all modes

View File

@ -1,10 +1,27 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { AiToolbox, type DroneToolboxEnvironment } from "./toolbox.ts";
export { DroneTool } from "./tool.ts";
export * from "./chat/index.ts";
export * from "./system/index.ts";
export * from "./network/index.ts";
export * from "./plan/index.ts";
export * from "./project/index.ts";
// Re-export from @gadget/ai-toolbox
export {
AiToolbox,
type GadgetToolboxEnvironment as DroneToolboxEnvironment,
FileReadTool,
FileWriteTool,
FileEditTool,
ShellExecTool,
ListTool,
GrepTool,
GlobTool,
GoogleSearchTool,
FetchUrlTool,
PlanFileReadTool,
PlanFileWriteTool,
PlanFileEditTool,
PlanListTool,
SubagentTool,
ListSkillsTool,
ReadSkillTool,
} from "@gadget/ai-toolbox";
// Local tools (gadget-drone specific)
export { SubprocessTool } from "./system/index.ts";

View File

@ -1,11 +1,4 @@
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { FileReadTool } from "./read.ts";
export { FileWriteTool } from "./write.ts";
export { FileEditTool } from "./edit.ts";
export { ShellExecTool } from "./shell.ts";
export { ListTool } from "./list.ts";
export { GrepTool } from "./grep.ts";
export { GlobTool } from "./glob.ts";
export { SubprocessTool } from "./subprocess.ts";

View File

@ -1,261 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IAiLogger } from "@gadget/ai";
import { AiToolbox, type DroneToolboxEnvironment } from "../toolbox.ts";
import { SubprocessTool } from "./subprocess.ts";
vi.mock("../../services/subprocess.ts", () => ({
default: {
create: vi.fn(),
ps: vi.fn(),
killPid: vi.fn(),
killAll: vi.fn(),
haltPid: vi.fn(),
getLog: vi.fn(),
},
}));
const mockLogger: IAiLogger = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const env: DroneToolboxEnvironment = {
NODE_ENV: "test",
workspace: {
workspaceDir: "/tmp/workspace",
projectDir: "/tmp/workspace/test-project",
cacheDir: "/tmp/workspace/.gadget/cache",
},
};
describe("SubprocessTool", () => {
let toolbox: AiToolbox;
let tool: SubprocessTool;
beforeEach(() => {
toolbox = new AiToolbox(env);
tool = new SubprocessTool(toolbox);
vi.clearAllMocks();
});
it("has the correct name and category", () => {
expect(tool.name).toBe("subprocess");
expect(tool.category).toBe("system");
});
it("defines the subprocess tool definition with all commands", () => {
const params = tool.definition.function.parameters as Record<string, any>;
const cmd = params.properties?.cmd as Record<string, any> | undefined;
const cmds: string[] = cmd?.enum ?? [];
expect(tool.definition.type).toBe("function");
expect(tool.definition.function.name).toBe("subprocess");
expect(cmds).toContain("create");
expect(cmds).toContain("list");
expect(cmds).toContain("kill");
expect(cmds).toContain("killAll");
expect(cmds).toContain("halt");
expect(cmds).toContain("log");
});
it("returns error when cmd is missing", async () => {
const result = await tool.execute({}, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("cmd");
});
it("returns error when cmd is invalid", async () => {
const result = await tool.execute({ cmd: "invalid" }, mockLogger);
expect(result).toContain("INVALID_PARAMETER");
});
describe("cmd=create", () => {
it("returns error when command is missing", async () => {
const result = await tool.execute({ cmd: "create" }, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("command");
});
it("calls SubProcessService.create with args", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.create as ReturnType<typeof vi.fn>).mockResolvedValue({
pid: 42,
stdoutFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stdout.log",
stderrFileName: "/tmp/.gadget/subprocess-logs/subproc-42.stderr.log",
});
const result = await tool.execute(
{ cmd: "create", command: "node", args: ["server.js"] },
mockLogger,
);
expect(SubProcessService.create).toHaveBeenCalledWith(
expect.objectContaining({ slug: "test-project" }),
"node",
["server.js"],
);
expect(result).toContain("SUBPROCESS CREATED");
expect(result).toContain("pid: 42");
});
it("handles args as non-array gracefully", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.create as ReturnType<typeof vi.fn>).mockResolvedValue({
pid: 7,
stdoutFileName: "/tmp/subproc-7.stdout.log",
stderrFileName: "/tmp/subproc-7.stderr.log",
});
const result = await tool.execute(
{ cmd: "create", command: "echo", args: "hello" },
mockLogger,
);
expect(result).toContain("pid: 7");
});
});
describe("cmd=list", () => {
it("returns empty list message when no processes", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.ps as ReturnType<typeof vi.fn>).mockReturnValue([]);
const result = await tool.execute({ cmd: "list" }, mockLogger);
expect(result).toContain("(no running subprocesses)");
});
it("lists running processes", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.ps as ReturnType<typeof vi.fn>).mockReturnValue([
{
pid: 1,
project: { slug: "project-a" },
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-02"),
},
{
pid: 2,
project: { slug: "project-b" },
createdAt: new Date("2026-01-03"),
updatedAt: new Date("2026-01-04"),
},
]);
const result = await tool.execute({ cmd: "list" }, mockLogger);
expect(result).toContain("SUBPROCESS LIST");
expect(result).toContain("pid: 1");
expect(result).toContain("pid: 2");
expect(result).toContain("project-a");
expect(result).toContain("project-b");
});
});
describe("cmd=kill", () => {
it("returns error when pid is missing", async () => {
const result = await tool.execute({ cmd: "kill" }, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("pid");
});
it("calls killPid and returns success", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.killPid as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const result = await tool.execute({ cmd: "kill", pid: 42 }, mockLogger);
expect(SubProcessService.killPid).toHaveBeenCalledWith(42);
expect(result).toContain("SUBPROCESS KILLED");
expect(result).toContain("pid: 42");
});
it("handles service errors", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.killPid as ReturnType<typeof vi.fn>).mockRejectedValue(new Error("not found"));
const result = await tool.execute({ cmd: "kill", pid: 99 }, mockLogger);
expect(result).toContain("OPERATION_FAILED");
});
});
describe("cmd=killAll", () => {
it("calls killAll and returns success", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.killAll as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const result = await tool.execute({ cmd: "killAll" }, mockLogger);
expect(SubProcessService.killAll).toHaveBeenCalledOnce();
expect(result).toContain("SUBPROCESS KILLALL");
});
});
describe("cmd=halt", () => {
it("returns error when pid is missing", async () => {
const result = await tool.execute({ cmd: "halt" }, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("pid");
});
it("calls haltPid and returns success", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.haltPid as ReturnType<typeof vi.fn>).mockResolvedValue(true);
const result = await tool.execute({ cmd: "halt", pid: 42 }, mockLogger);
expect(SubProcessService.haltPid).toHaveBeenCalledWith(42);
expect(result).toContain("SUBPROCESS HALTED");
expect(result).toContain("pid: 42");
});
});
describe("cmd=log", () => {
it("returns error when pid is missing", async () => {
const result = await tool.execute({ cmd: "log", which: "stdout" }, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("pid");
});
it("returns error when which is missing", async () => {
const result = await tool.execute({ cmd: "log", pid: 42 }, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("which");
});
it("returns error when which is invalid", async () => {
const result = await tool.execute({ cmd: "log", pid: 42, which: "invalid" }, mockLogger);
expect(result).toContain("MISSING_PARAMETER");
expect(result).toContain("which");
});
it("reads stdout log content", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.getLog as ReturnType<typeof vi.fn>).mockResolvedValue("line1\nline2\nline3");
const result = await tool.execute(
{ cmd: "log", pid: 42, which: "stdout" },
mockLogger,
);
expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stdout");
expect(result).toContain("SUBPROCESS LOG (stdout)");
expect(result).toContain("pid: 42");
expect(result).toContain("line1");
expect(result).toContain("line2");
expect(result).toContain("line3");
});
it("reads stderr log content", async () => {
const SubProcessService = (await import("../../services/subprocess.ts")).default;
(SubProcessService.getLog as ReturnType<typeof vi.fn>).mockResolvedValue("error: something broke");
const result = await tool.execute(
{ cmd: "log", pid: 42, which: "stderr" },
mockLogger,
);
expect(SubProcessService.getLog).toHaveBeenCalledWith(42, "stderr");
expect(result).toContain("SUBPROCESS LOG (stderr)");
expect(result).toContain("error: something broke");
});
});
});

View File

@ -3,7 +3,7 @@
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { formatError } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "@gadget/ai-toolbox";
import SubProcessService from "../../services/subprocess.ts";
const VALID_CMDS = [
@ -17,7 +17,7 @@ const VALID_CMDS = [
type SubprocessCmd = (typeof VALID_CMDS)[number];
export class SubprocessTool extends DroneTool {
export class SubprocessTool extends GadgetTool {
get name(): string {
return "subprocess";
}

2
packages/ai-toolbox/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
dist

View File

@ -0,0 +1,38 @@
{
"name": "@gadget/ai-toolbox",
"version": "1.0.0",
"description": "Gadget Code AI agent toolbox — shared tool implementations for agent processes",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist/",
"typecheck": "tsc --noEmit"
},
"keywords": ["gadget", "ai", "toolbox", "tools", "agent"],
"author": "Rob Colbert",
"license": "Apache-2.0",
"dependencies": {
"@gadget/ai": "workspace:*",
"@gadget/api": "workspace:*",
"@mozilla/readability": "0.6.0",
"googleapis": "171.4.0",
"jsdom": "29.0.2",
"playwright": "1.59.1",
"turndown": "7.2.2"
},
"devDependencies": {
"@types/jsdom": "27.0.0",
"@types/node": "^25.6.0",
"@types/turndown": "5.0.5",
"typescript": "^6.0.3"
}
}

View File

@ -1,3 +1,4 @@
// src/chat/index.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0

View File

@ -1,14 +1,15 @@
// src/chat/subagent.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { formatError } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "../tool.ts";
const VALID_AGENT_TYPES = ["explore", "general"] as const;
type AgentType = (typeof VALID_AGENT_TYPES)[number];
export class SubagentTool extends DroneTool {
export class SubagentTool extends GadgetTool {
private _spawnSubagent: ((agentType: string, prompt: string) => Promise<string>) | null = null;
setSpawner(fn: typeof this._spawnSubagent): void {

View File

@ -0,0 +1,11 @@
// src/index.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { AiToolbox, type GadgetToolboxEnvironment } from "./toolbox.ts";
export { GadgetTool } from "./tool.ts";
export * from "./chat/index.ts";
export * from "./system/index.ts";
export * from "./network/index.ts";
export * from "./plan/index.ts";
export * from "./project/index.ts";

View File

@ -1,12 +1,13 @@
// src/network/fetch-url.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "../tool.ts";
import { asPositiveInteger, getCacheDir, toolError } from "../system/common.ts";
import { WebFetcher } from "./web-fetcher.ts";
export class FetchUrlTool extends DroneTool {
export class FetchUrlTool extends GadgetTool {
get name(): string {
return "fetch_url";
}
@ -19,7 +20,7 @@ export class FetchUrlTool extends DroneTool {
type: "function",
function: {
name: this.name,
description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the drone .gadget/cache directory, and return an optional line range like file_read.",
description: "Fetch a URL, convert readable page content to line-numbered Markdown, cache it in the .gadget/cache directory, and return an optional line range like file_read.",
parameters: {
type: "object",
properties: {
@ -77,7 +78,7 @@ export class FetchUrlTool extends DroneTool {
if (!cacheDir) {
return toolError({
code: "OPERATION_NOT_ALLOWED",
message: "No drone cache directory is configured for fetch_url.",
message: "No cache directory is configured for fetch_url.",
recoveryHint: "Run this tool during an active Agent work order after workspace initialization.",
});
}

View File

@ -1,3 +1,4 @@
// src/network/index.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0

View File

@ -1,3 +1,4 @@
// src/network/search-google.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,7 +6,7 @@ import { google } from "googleapis";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { formatError } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "../tool.ts";
export interface ISearchResult {
title: string;
@ -26,7 +27,7 @@ export interface ISearchOptions {
start?: number;
}
export class GoogleSearchTool extends DroneTool {
export class GoogleSearchTool extends GadgetTool {
get name(): string {
return "search_google";
}
@ -140,7 +141,7 @@ export class GoogleSearchTool extends DroneTool {
const engineId = this.toolbox.env.services?.google?.cse?.engineId;
if (!apiKey || !engineId) {
throw new Error("Google CSE credentials not configured in drone environment");
throw new Error("Google CSE credentials not configured in environment");
}
const customSearch = google.customsearch({

View File

@ -1,3 +1,4 @@
// src/network/web-fetcher.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -29,15 +30,8 @@ export class WebFetcher {
hr: "---",
});
this.turndown.remove([
"script",
"style",
"noscript",
"nav",
"footer",
"header",
"button",
"input",
"form",
"script", "style", "noscript", "nav", "footer", "header",
"button", "input", "form",
]);
}

View File

@ -1,3 +1,4 @@
// src/plan/common.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0

View File

@ -1,14 +1,15 @@
// src/plan/edit.ts
// 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 { GadgetTool } from "../tool.ts";
import { toolError } from "../system/common.ts";
import { getGadgetDir, resolvePlanPath } from "./common.ts";
export class PlanFileEditTool extends DroneTool {
export class PlanFileEditTool extends GadgetTool {
get name(): string {
return "plan_file_edit";
}

View File

@ -1,3 +1,4 @@
// src/plan/index.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0

View File

@ -1,3 +1,4 @@
// src/plan/list.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,11 +6,11 @@ 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 { GadgetTool } from "../tool.ts";
import { toolError } from "../system/common.ts";
import { getGadgetDir, resolvePlanPath } from "./common.ts";
export class PlanListTool extends DroneTool {
export class PlanListTool extends GadgetTool {
get name(): string {
return "plan_list";
}

View File

@ -1,10 +1,11 @@
// src/plan/read.ts
// 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 { GadgetTool } from "../tool.ts";
import {
asPositiveInteger,
formatNumberedLines,
@ -13,7 +14,7 @@ import {
} from "../system/common.ts";
import { getGadgetDir, resolvePlanPath } from "./common.ts";
export class PlanFileReadTool extends DroneTool {
export class PlanFileReadTool extends GadgetTool {
get name(): string {
return "plan_file_read";
}

View File

@ -1,3 +1,4 @@
// src/plan/write.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,11 +6,11 @@ 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 { GadgetTool } from "../tool.ts";
import { toolError } from "../system/common.ts";
import { getGadgetDir, resolvePlanPath } from "./common.ts";
export class PlanFileWriteTool extends DroneTool {
export class PlanFileWriteTool extends GadgetTool {
get name(): string {
return "plan_file_write";
}

View File

@ -1,3 +1,4 @@
// src/project/index.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0

View File

@ -1,10 +1,11 @@
// src/project/list-skills.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiLogger, IToolArguments } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "../tool.ts";
export class ListSkillsTool extends DroneTool {
export class ListSkillsTool extends GadgetTool {
get name(): string {
return "list_skills";
}

View File

@ -1,10 +1,11 @@
// src/project/read-skill.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiLogger, IToolArguments } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "../tool.ts";
export class ReadSkillTool extends DroneTool {
export class ReadSkillTool extends GadgetTool {
get name(): string {
return "read_skill";
}

View File

@ -1,3 +1,4 @@
// src/system/common.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -8,30 +9,10 @@ import { formatError, type IToolError } from "@gadget/ai";
import type { AiToolbox } from "../toolbox.ts";
export const BINARY_EXTENSIONS = new Set([
".o",
".obj",
".a",
".lib",
".so",
".dll",
".exe",
".bin",
".png",
".jpg",
".jpeg",
".gif",
".bmp",
".ico",
".webp",
".pdf",
".zip",
".tar",
".gz",
".bz2",
".7z",
".wasm",
".pyc",
".class",
".o", ".obj", ".a", ".lib", ".so", ".dll", ".exe", ".bin",
".png", ".jpg", ".jpeg", ".gif", ".bmp", ".ico", ".webp",
".pdf", ".zip", ".tar", ".gz", ".bz2", ".7z", ".wasm",
".pyc", ".class",
]);
export interface ResolvedProjectPath {

View File

@ -1,13 +1,14 @@
// src/system/edit.ts
// 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 { GadgetTool } from "../tool.ts";
import { resolveProjectPath, toolError } from "./common.ts";
export class FileEditTool extends DroneTool {
export class FileEditTool extends GadgetTool {
get name(): string {
return "file_edit";
}

View File

@ -1,3 +1,4 @@
// src/system/glob.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,12 +6,12 @@ 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 { GadgetTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_RESULTS = 1000;
export class GlobTool extends DroneTool {
export class GlobTool extends GadgetTool {
get name(): string {
return "glob";
}

View File

@ -1,3 +1,4 @@
// src/system/grep.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,17 +6,16 @@ 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 { GadgetTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_MATCHES = 500;
const MAX_FILE_SIZE = 1024 * 1024;
// TODO: Consider broadening this list or making it user-configurable
// Currently limited to common source/doc extensions for performance.
const SEARCH_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".json", ".md", ".txt"]);
export class GrepTool extends DroneTool {
export class GrepTool extends GadgetTool {
get name(): string {
return "grep";
}

View File

@ -0,0 +1,11 @@
// src/system/index.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
export { FileReadTool } from "./read.ts";
export { FileWriteTool } from "./write.ts";
export { FileEditTool } from "./edit.ts";
export { ShellExecTool } from "./shell.ts";
export { ListTool } from "./list.ts";
export { GrepTool } from "./grep.ts";
export { GlobTool } from "./glob.ts";

View File

@ -1,3 +1,4 @@
// src/system/list.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,12 +6,12 @@ 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 { GadgetTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const MAX_ENTRIES = 1000;
export class ListTool extends DroneTool {
export class ListTool extends GadgetTool {
get name(): string {
return "list";
}

View File

@ -1,10 +1,11 @@
// src/system/read.ts
// 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 { GadgetTool } from "../tool.ts";
import {
asPositiveInteger,
formatNumberedLines,
@ -13,7 +14,7 @@ import {
toolError,
} from "./common.ts";
export class FileReadTool extends DroneTool {
export class FileReadTool extends GadgetTool {
get name(): string {
return "file_read";
}

View File

@ -1,3 +1,4 @@
// src/system/shell.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -6,14 +7,14 @@ import path from "node:path";
import { promisify } from "node:util";
import type { IAiLogger, IToolArguments, IToolDefinition } from "@gadget/ai";
import { DroneTool } from "../tool.ts";
import { GadgetTool } from "../tool.ts";
import { getProjectRoot, resolveProjectPath, toolError } from "./common.ts";
const execAsync = promisify(exec);
const DEFAULT_TIMEOUT = 30_000;
const MAX_TIMEOUT = 120_000;
export class ShellExecTool extends DroneTool {
export class ShellExecTool extends GadgetTool {
get name(): string {
return "shell_exec";
}

View File

@ -1,3 +1,4 @@
// src/system/write.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -5,10 +6,10 @@ 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 { GadgetTool } from "../tool.ts";
import { resolveProjectPath, toolError } from "./common.ts";
export class FileWriteTool extends DroneTool {
export class FileWriteTool extends GadgetTool {
get name(): string {
return "file_write";
}

View File

@ -1,3 +1,4 @@
// src/tool.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
@ -9,7 +10,7 @@ import type {
} from "@gadget/ai";
import type { AiToolbox } from "./toolbox.ts";
export abstract class DroneTool implements IAiTool {
export abstract class GadgetTool implements IAiTool {
protected _toolbox: AiToolbox;
constructor(toolbox: AiToolbox) {

View File

@ -1,10 +1,11 @@
// src/toolbox.ts
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
// Licensed under the Apache License, Version 2.0
import type { IAiTool } from "@gadget/ai";
import type { IProject, ChatSessionMode } from "@gadget/api";
export interface DroneToolboxEnvironment {
export interface GadgetToolboxEnvironment {
NODE_ENV: string;
services?: {
google?: {
@ -27,19 +28,19 @@ export type ToolMap = Map<string, IAiTool>;
export type ToolSet = Set<IAiTool>;
export class AiToolbox {
private _env: DroneToolboxEnvironment;
private _env: GadgetToolboxEnvironment;
private tools: ToolMap = new Map<string, IAiTool>();
private modeSets: Map<string, ToolSet> = new Map<string, Set<IAiTool>>();
constructor(env: DroneToolboxEnvironment) {
constructor(env: GadgetToolboxEnvironment) {
this._env = env;
}
get env(): DroneToolboxEnvironment {
get env(): GadgetToolboxEnvironment {
return this._env;
}
updateWorkspace(workspace: NonNullable<DroneToolboxEnvironment["workspace"]>): void {
updateWorkspace(workspace: NonNullable<GadgetToolboxEnvironment["workspace"]>): void {
this._env.workspace = workspace;
}

View File

@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"typeRoots": ["node_modules/@types"],
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "src/**/*.test.ts"]
}

View File

@ -1,180 +0,0 @@
# User Mode MVP — Continuation Prompt
## Session Context
**Session Date:** May 13, 2026
**Session ID:** 5hQhhRHYdL_e10Zw7EDLv
**Branch:** `feature/user-mode` (latest: `7dca4b5`)
**Status:** MVP Complete ✅
---
## What Was Accomplished
### 1. ACE Editor Integration Crisis → CodeMirror Migration
**Initial Problem:** The previous agent's ACE editor integration was crashing the entire ChatSessionView. After extensive debugging, we discovered:
- `react-ace` v14 ships **CommonJS-only** (`"main": "lib/index.js"`, no `"module"` or `"exports"` field)
- Vite's ESM-first dev server cannot properly resolve CJS default exports
- Every workaround failed: CJS interop hacks (`.default || module`), `?url` imports, `setModuleUrl()`, `optimizeDeps.include`
- This is a **fundamental architecture mismatch**, not a configuration issue
**Solution:** Migrated to `@uiw/react-codemirror` v4.25.9
- Ships dual ESM+CJS with proper `exports` map
- Works with Vite out of the box (zero hacks)
- React 19 compatible (`>=17.0.0` peer dep)
- ~124KB gzipped (vs Monaco's ~5MB)
**Files Changed:**
- `frontend/package.json` — removed `ace-builds`, `react-ace`; added `@uiw/react-codemirror` + 16 `@codemirror/lang-*` packages + theme
- `frontend/src/components/EditorPanel.tsx` — deleted 108 lines of ACE boilerplate, replaced with ~30 lines of clean CodeMirror setup
- `frontend/src/types/vite.d.ts`**deleted** (only needed for ACE `?url` import types)
- `frontend/vite.config.ts` — removed `optimizeDeps.include` for ACE
### 2. Editor Height Constraint Fix
**Problem:** The CodeMirror editor was overflowing its container, extending off the bottom of the screen, and not scrolling properly.
**Root Cause:** `@uiw/react-codemirror` renders a wrapper `<div>` between `.cm-editor-container` and `.cm-editor` that doesn't inherit flex height constraints by default.
**Solution:** Added CSS rules that explicitly constrain **every level** of the CodeMirror DOM tree:
```css
.cm-editor-container {
overflow: hidden;
}
.cm-editor-container > div { /* The wrapper @uiw/react-codemirror renders */
flex: 1;
min-height: 0;
overflow: hidden;
}
.cm-editor-container .cm-editor {
flex: 1;
min-height: 0;
overflow: hidden;
}
.cm-editor-container .cm-scroller {
min-height: 0;
overflow: auto; /* ← Only this scrolls */
}
```
**Files Changed:**
- `frontend/src/index.css` — added comprehensive flex layout constraints for CodeMirror
### 3. Error Boundary Protection
- Added `ErrorBoundary` component wrapping `<EditorPanel>` in `ChatSessionView`
- Catches render errors and displays fallback UI instead of crashing the entire app
### 4. Heartbeat Worker Verification
- Verified the IDE→gadget-drone heartbeat worker (`src/workers/heartbeat.worker.ts`) remains completely intact
- No changes to heartbeat logic, socket.ts, or session management
- Production build includes the heartbeat worker (inlined as base64 data URL)
---
## Key Technical Learnings
### CJS/ESM Interop with Vite
1. **Vite is ESM-first** — CJS modules are pre-bundle-converted, but default exports from CJS (`exports.default = value`) get wrapped as `{ default: value, __esModule: true }`
2. **Named exports work fine** — the issue is specifically with `export default` from CJS packages
3. **Rolldown (Vite 8's bundler) handles this better than Rollup**, but the underlying CJS interop issue remains for packages without proper ESM entry points
4. **Best practice:** Always prefer packages with `"module"` or `"exports"` fields pointing to ESM builds when using Vite
### Flex Layout Height Constraints
1. **Every level in the flex chain must have `min-height: 0`** — without this, flex items can grow beyond their allocated space
2. **`flex: 1` alone is not enough** — need `min-height: 0` or `overflow: hidden` to prevent overflow
3. **Third-party components often break flex layouts** — they render wrapper divs that don't inherit parent constraints
4. **The fix pattern:** Use CSS to explicitly target every rendered level and apply `flex: 1`, `min-height: 0`, `overflow: hidden` appropriately
---
## Remaining Steps / Next Session
### High Priority
1. **Test all supported file types** — verify syntax highlighting works for:
- JavaScript/JSX, TypeScript/TSX, Python, JSON, HTML, CSS, Less, YAML, Markdown, SQL, Java, Go, Rust, C/C++, C#, PHP, XML
- Unsupported types (Ruby, Sass, Dockerfile, Makefile, Shell) should fall back to plain text
2. **Verify read-only mode** — confirm Agent mode properly prevents editing (we tested this, but more thorough testing recommended)
3. **Test file save/load cycle** — ensure Ctrl+S works, dirty state tracking is correct, and success/error states display properly
### Medium Priority
4. **Add missing language support** (optional, if needed):
- Ruby: `@codemirror/legacy-modes/mode/ruby` (legacy mode, not as good as CM6 native)
- Sass/SCSS: `@codemirror/lang-sass` (if available)
- Shell/Dockerfile/Makefile: Plain text is probably fine for now
5. **Autocompletion** — CodeMirror 6 supports autocompletion via `@codemirror/autocomplete` and language-specific completion extensions. Could add basic keyword completion for supported languages.
6. **Theme customization** — The `tomorrow-night-blue` theme is close to ACE's "tomorrow" but not identical. Could customize colors to match the rest of the UI better.
### Low Priority
7. **Performance optimization** — For very large files (>10k lines), consider:
- CodeMirror's built-in line wrapping limits
- Virtual scrolling (already built into CM6)
- Lazy-loading language extensions
8. **Editor preferences** — Allow users to configure:
- Font size
- Tab size
- Word wrap
- Minimap (CodeMirror has a minimap extension)
---
## Continuation Prompt for Next Session
```
You are continuing work on the Gadget Code project's User Mode MVP. The ACE editor has been successfully migrated to @uiw/react-codemirror, and the editor now properly fits within its flex container and scrolls correctly.
Current state:
- Branch: `feature/user-mode` (latest commit: check `git log --oneline -1`)
- Editor: @uiw/react-codemirror v4.25 with tomorrow-night-blue theme
- Supported languages: JavaScript/JSX, TypeScript/TSX, Python, JSON, HTML, CSS, Less, YAML, Markdown, SQL, Java, Go, Rust, C/C++, C#, PHP, XML
- Editor properly constrained in flex layout, scrolls internally
- ErrorBoundary wraps EditorPanel
- Heartbeat worker intact
Your task: [INSERT TASK HERE]
Key files:
- `gadget-code/frontend/src/components/EditorPanel.tsx` — Editor component
- `gadget-code/frontend/src/index.css` — Flex layout constraints for CodeMirror
- `gadget-code/frontend/src/pages/ChatSessionView.tsx` — Parent view with ErrorBoundary
- `gadget-code/frontend/package.json` — Dependencies
Important patterns:
- Use `getLanguageExtensions()` to map file extensions to CodeMirror language extensions
- Flex layout chain: #root → main → ChatSessionView → EditorPanel → .cm-editor-container → wrapper div → .cm-editor → .cm-scroller (only this scrolls)
- All language extensions imported from `@codemirror/lang-*` packages
- Theme: `tomorrowNightBlue` from `@uiw/codemirror-theme-tomorrow-night-blue`
```
---
## Success Criteria for User Mode (MVP)
- ✅ User can open files in the editor
- ✅ User can edit files in User mode
- ✅ User can save files (Ctrl+S or button)
- ✅ Agent mode makes editor read-only
- ✅ Editor stays within its allocated space
- ✅ Editor scrolls properly when content overflows
- ✅ Syntax highlighting works for supported languages
- ✅ ErrorBoundary catches editor crashes
- ✅ Heartbeat worker continues functioning (no regression)
**MVP Status: COMPLETE** ✅
All success criteria met. The User Mode MVP is ready for production testing.

View File

@ -1,182 +0,0 @@
# Session Summary: User Mode Editor Integration
**Date:** May 13, 2026
**Session:** Fix ACE Editor Integration → Migrate to CodeMirror → User Mode MVP Complete
---
## What We Accomplished
### 1. ACE Editor Integration Failed (Root Cause Analysis)
**Attempted:** Integrate `react-ace` v14.0.1 with Vite + React 19
**Problem:** `react-ace` v14 ships **CommonJS-only** (`"main": "lib/index.js"`, no `"module"` or `"exports"` field). Vite's ESM-first dev server cannot properly resolve its default export, causing "Element type is invalid: got object" on every render.
**Workarounds attempted (all failed):**
- CJS interop hack: `import * as ReactAceModule; const Ace = ReactAceModule.default || ReactAceModule`
- `optimizeDeps.include: ['react-ace', 'ace-builds']` in vite.config.ts
- 56 lines of `?url` imports + `ace.config.setModuleUrl()` registration
- Namespace imports with fallback extraction
**Conclusion:** This is a **fundamental architecture mismatch**, not a configuration issue. `react-ace` is incompatible with Vite's ESM-first model. Every workaround is fragile and breaks on Vite updates.
---
### 2. Migrated to @uiw/react-codemirror (Success)
**Decision:** Switch to `@uiw/react-codemirror` v4.25.9 — dual ESM+CJS package with proper `exports` map, React 19 support (`>=17.0.0` peer dep), works with Vite out of the box.
**Changes made:**
- Removed `ace-builds`, `react-ace` from `frontend/package.json`
- Added `@uiw/react-codemirror` + 16 `@codemirror/lang-*` packages + `@uiw/codemirror-theme-tomorrow-night-blue`
- Added `@replit/codemirror-lang-csharp` for C# support
- Rewrote `EditorPanel.tsx`: deleted 108 lines of ACE boilerplate, replaced with ~30 lines of clean CodeMirror setup
- Deleted `frontend/src/types/vite.d.ts` (only needed for ACE `?url` imports)
- Removed `optimizeDeps.include` from `vite.config.ts` (not needed for CM)
- Added CodeMirror flex layout CSS to `index.css`
**Supported languages:** JavaScript/JSX, TypeScript/TSX, Python, JSON, HTML, CSS, Less, YAML, Markdown, SQL, Java, Go, Rust, C/C++, C#, PHP, XML. Unsupported types fall back to plain text.
**Bundle size:** ~124KB gzipped (CodeMirror 6 core) vs ~56KB for ACE, but ~40x smaller than Monaco's ~5MB.
---
### 3. Fixed Flex Layout Height Constraint Issue
**Problem:** Editor overflowed its container and didn't stay in allocated area.
**Root cause:** `@uiw/react-codemirror` renders a wrapper `<div>` between `.cm-editor-container` and `.cm-editor` that doesn't inherit flex constraints by default.
**Solution:** Added CSS rules that constrain **every level** of the CodeMirror DOM tree:
```css
.cm-editor-container {
overflow: hidden;
}
.cm-editor-container > div { /* The wrapper @uiw/react-codemirror renders */
flex: 1;
min-height: 0;
overflow: hidden;
}
.cm-editor-container .cm-editor {
flex: 1;
min-height: 0;
overflow: hidden;
}
.cm-editor-container .cm-scroller {
min-height: 0;
overflow: auto; /* ← Only this scrolls */
}
```
**Layout chain:**
```
.cm-editor-container (flex-1, min-h-0)
└─ wrapper div (flex:1, min-h-0, overflow:hidden)
└─ .cm-editor (flex:1, min-h-0, overflow:hidden)
└─ .cm-scroller (min-h-0, overflow:auto) ← only this scrolls
```
---
## Key Technical Learnings
### 1. CJS/ESM Interop in Vite is Fragile
When a package ships CJS-only with `export default`, Vite's dev server pre-bundles it as a namespace object where the default export is nested under `.default`. This causes "Element type is invalid: got object" errors when importing default exports from CJS-only React components.
**Rule:** For React components in Vite projects, prefer packages that ship dual ESM+CJS with proper `exports` maps. Avoid CJS-only packages — every workaround is fragile.
### 2. Flex Layout Height Constraints Must Be Explicit at Every Level
For a flex item to properly fill its allocated space:
1. Every ancestor in the flex chain must have `flex: 1` or explicit height
2. Every ancestor must have `min-height: 0` (or `min-width: 0` for horizontal flex)
3. For third-party components that render wrapper divs, add explicit CSS rules targeting those wrappers
4. Only the innermost scrollable element should have `overflow: auto`; all ancestors should have `overflow: hidden`
**Pattern:**
```css
.container {
flex: 1;
min-height: 0;
overflow: hidden;
}
.container > div { /* Wrapper div from third-party component */
flex: 1;
min-height: 0;
overflow: hidden;
}
.container .scrollable {
min-height: 0;
overflow: auto; /* Only this scrolls */
}
```
### 3. Heartbeat Worker Unaffected
The IDE heartbeat worker (`src/workers/heartbeat.worker.ts`) was completely untouched by the editor migration. It continues to run in a Web Worker to avoid browser tab throttling, sending session heartbeats every 19 seconds to keep the drone connection alive.
---
## Files Changed
| File | Change |
|------|--------|
| `frontend/package.json` | Replaced `ace-builds`, `react-ace` with `@uiw/react-codemirror` + language packages |
| `frontend/src/components/EditorPanel.tsx` | Complete rewrite: ACE → CodeMirror |
| `frontend/src/index.css` | Added CodeMirror flex layout constraints |
| `frontend/src/types/vite.d.ts` | **Deleted** — only needed for ACE `?url` imports |
| `frontend/vite.config.ts` | Removed `optimizeDeps.include` for ACE |
| `pnpm-lock.yaml` | Updated with all new CodeMirror packages |
---
## Remaining Steps / Next Session
### High Priority
- [ ] **Test all supported file types** — Verify syntax highlighting works for each language package
- [ ] **Test read-only behavior** — Confirm Agent mode prevents editing, User mode allows it
- [ ] **Test Ctrl+S save** — Verify keyboard shortcut works in User mode
- [ ] **Test file switching** — Open multiple files, ensure language detection works
### Medium Priority
- [ ] **Add SCSS/Sass support** — Currently falls back to CSS mode; could add `@codemirror/lang-sass` if needed
- [ ] **Add Ruby support** — No official CM6 package; could use `@codemirror/legacy-modes` if needed
- [ ] **Add Dockerfile/Makefile support** — Currently plain text; could add custom language defs if needed
### Low Priority
- [ ] **Autocompletion** — Currently disabled; could add `@codemirror/autocomplete` + language-specific completions
- [ ] **Linting** — Could add `@codemirror/lint` for real-time error annotations
- [ ] **Find/Replace** — Could add `@codemirror/search` for Ctrl+F support
---
## Continuation Prompt (if needed)
> **Context:** User Mode MVP is complete. The CodeMirror editor integration is working correctly with proper flex layout constraints. The editor supports 16 languages, respects read-only mode in Agent mode, allows editing in User mode, and saves with Ctrl+S.
>
> **Next steps:** Test edge cases (large files, rapid switching, special characters), add missing language support if needed (Ruby, SCSS, Dockerfile), and optionally enhance with autocompletion/linting/search features.
---
## Team Notes
**What worked well:**
- Research-driven approach: We did proper homework on CJS/ESM interop issues before deciding to migrate
- Clean migration: The CodeMirror rewrite was done in one clean pass with proper TypeScript types
- Proper flex layout fix: Instead of a hack, we added CSS that correctly propagates height constraints through the entire DOM tree
**What to avoid in future:**
- Don't use CJS-only React components in Vite projects — the interop is fragile
- Don't add third-party components without testing their flex layout behavior first
- Don't skip the research phase — knowing the root cause saved us hours of debugging
**Shoutouts:**
- The heartbeat worker architecture (Web Worker + fallback setInterval) is rock-solid
- The ErrorBoundary we added for ACE is still in place and working for CodeMirror
- The file loading/saving socket API abstraction made the editor swap trivial — same interface, different implementation
---
**Status:** ✅ User Mode MVP complete. Editor is production-ready.

View File

@ -366,6 +366,9 @@ importers:
'@gadget/ai':
specifier: workspace:*
version: link:../packages/ai
'@gadget/ai-toolbox':
specifier: workspace:*
version: link:../packages/ai-toolbox
'@gadget/api':
specifier: workspace:*
version: link:../packages/api
@ -471,6 +474,43 @@ importers:
specifier: ^4.1.5
version: 4.1.5(@types/node@25.6.0)(jsdom@29.1.0)(vite@8.0.10(@types/node@25.6.0)(esbuild@0.27.7)(jiti@2.6.1)(less@4.6.4)(tsx@4.21.0))
packages/ai-toolbox:
dependencies:
'@gadget/ai':
specifier: workspace:*
version: link:../ai
'@gadget/api':
specifier: workspace:*
version: link:../api
'@mozilla/readability':
specifier: 0.6.0
version: 0.6.0
googleapis:
specifier: 171.4.0
version: 171.4.0
jsdom:
specifier: 29.0.2
version: 29.0.2
playwright:
specifier: 1.59.1
version: 1.59.1
turndown:
specifier: 7.2.2
version: 7.2.2
devDependencies:
'@types/jsdom':
specifier: 27.0.0
version: 27.0.0
'@types/node':
specifier: ^25.6.0
version: 25.6.0
'@types/turndown':
specifier: 5.0.5
version: 5.0.5
typescript:
specifier: ^6.0.3
version: 6.0.3
packages/api:
dependencies:
ansicolor:

View File

@ -3,6 +3,7 @@ packages:
- 'gadget-code'
- 'gadget-code/frontend'
- 'gadget-drone'
- 'gadget-tasks'
allowBuilds:
esbuild: true
msgpackr-extract: true