src / tools / workspaceTool.ts
src / tools / workspaceTool.ts
import { lstat } from "node:fs/promises";
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { boundedPreview, errorResult, okResult } from "../core/result";
import type { ApprovalRecord } from "../policy/approvalStore";
import { describePermissionMode, permissionLevel } from "../policy/permissions";
import type { PlanDocument } from "../policy/planDocument";
import type { PluginRuntime } from "../runtime";
import { transactionListEntry } from "./editTool";
import { searchWorkspace } from "../workspace/search";
import { findWorkspacePaths, semanticSearchWorkspace } from "../workspace/discovery";
import { numberedRange, readTextSnapshot } from "../workspace/text";
import { walkWorkspace } from "../workspace/walk";
function planRef(record: ApprovalRecord | undefined): { id: string; title: string } | null {
return record ? { id: record.id, title: record.title } : null;
}
/**
* How many lines `read` returns when `end_line` is omitted. The window is
* anchored to `start_line` (`start .. start + N - 1`) rather than to the top of
* the file: `numberedRange` clamps `end` up to `start`, so an absolute default
* such as `min(lineCount, 600)` silently returned a single line for any read
* starting past line 600. `numberedRange` still clamps the window to the file.
*/
const READ_WINDOW_LINES = 600;
export function createWorkspaceTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_inspect",
description:
"Inspect the workspace. Actions: overview, list, read, search, semantic_search, find, stat, changes, capabilities. Start every task here: overview, then workspace_notes memory=true (.agentic/MEMORY.md), then read/search before editing. read is line-numbered (`12 | code`); strip that prefix before the text goes into workspace_edit. capabilities shows mode, plan and approvals; changes relists transaction, run, job, board, research, plan, approval ids after compression.",
parameters: {
action: z.enum(["overview", "list", "read", "search", "semantic_search", "find", "stat", "changes", "capabilities"]),
path: z.string().optional(),
start_line: z.number().int().min(1).optional(),
end_line: z.number().int().min(1).optional(),
query: z.string().optional(),
regex: z.boolean().optional(),
case_sensitive: z.boolean().optional(),
max_results: z.number().int().min(1).max(100).optional(),
max_depth: z.number().int().min(0).max(12).optional(),
max_entries: z.number().int().min(1).max(1000).optional(),
include_hidden: z.boolean().optional(),
include_directories: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action:
| "overview"
| "list"
| "read"
| "search"
| "semantic_search"
| "find"
| "stat"
| "changes"
| "capabilities";
path?: string;
start_line?: number;
end_line?: number;
query?: string;
regex?: boolean;
case_sensitive?: boolean;
max_results?: number;
max_depth?: number;
max_entries?: number;
include_hidden?: boolean;
include_directories?: boolean;
limit?: number;
}) => {
try {
if (input.action === "capabilities") {
const [approvals, notes, memory] = await Promise.all([
runtime.approvals.snapshot(),
runtime.notes.list(),
runtime.notes.readMemory(),
]);
return okResult({
operation: "workspace.capabilities",
summary: "Agentic Workspace is active with transactional edits and compact artifact-backed results.",
data: {
workspace: runtime.boundary.root,
permissionMode: runtime.settings.permissionMode,
permissionLevel: permissionLevel(runtime.settings.permissionMode),
activePlan: planRef(approvals.activePlan),
pendingPlan: planRef(approvals.pendingPlan),
pendingApprovals: approvals.pendingApprovals.length,
notes: {
count: notes.length,
memoryExists: memory.exists,
memoryPath: runtime.notes.memoryPath(),
},
destructiveCommits: runtime.settings.allowDestructiveEdits,
processExecution: runtime.settings.processExecutionEnabled,
durableAgents: runtime.settings.internalAgentsEnabled,
taskBoards: runtime.settings.taskBoardsEnabled,
webResearch: runtime.settings.webResearchEnabled,
webSearchProvider: runtime.settings.webSearchProvider,
agentWebAccess:
runtime.settings.internalAgentsEnabled &&
runtime.settings.webResearchEnabled &&
runtime.settings.agentAllowWeb,
gitAndGitHub:
runtime.settings.processExecutionEnabled && runtime.settings.vcsToolsEnabled,
browserAutomation: runtime.settings.browserEnabled,
tools: [
"workspace_inspect",
"workspace_edit",
...(runtime.settings.permissionMode !== "auto" ? ["workspace_plan"] : []),
"workspace_notes",
...(runtime.settings.taskBoardsEnabled ? ["workspace_tasks"] : []),
...(runtime.settings.webResearchEnabled ? ["workspace_research"] : []),
...(runtime.settings.processExecutionEnabled ? ["workspace_command"] : []),
...(runtime.settings.processExecutionEnabled && runtime.settings.vcsToolsEnabled
? ["workspace_vcs"]
: []),
...(runtime.settings.browserEnabled ? ["workspace_browser"] : []),
...(runtime.settings.internalAgentsEnabled ? ["workspace_agent"] : []),
],
contextCompressorCompatibility:
"Native agentic-workspace/v1 compaction contract; this plugin remains a tools provider and does not claim the prediction loop.",
resultProtocol: "agentic-workspace/v1",
storage: {
root: ".agentic/",
transactions: ".agentic/transactions/",
jobs: ".agentic/jobs/",
runs: ".agentic/runs/",
taskBoards: ".agentic/tasks/",
research: ".agentic/research/",
artifacts: ".agentic/artifacts/",
approvals: ".agentic/approvals/",
plans: ".agentic/plans/",
notes: ".agentic/notes/",
memory: ".agentic/MEMORY.md",
},
editVisibility: {
diff: ".agentic/transactions/<id>/changes.diff",
reviewReceipt: ".agentic/transactions/<id>/review.md",
snapshots: ".agentic/transactions/<id>/snapshots/",
},
},
importance: "high",
facts: [
`workspace ${runtime.boundary.root}`,
`permission mode ${describePermissionMode(runtime.settings.permissionMode)}`,
"context-compressor compatible through agentic-workspace/v1 retention contracts",
],
});
}
if (input.action === "overview" || input.action === "list") {
const listing = await walkWorkspace(runtime.boundary, input.path ?? ".", {
maxDepth: input.max_depth ?? (input.action === "overview" ? 3 : 5),
maxEntries: input.max_entries ?? (input.action === "overview" ? 200 : 500),
includeHidden: input.include_hidden ?? false,
});
if (input.action === "list") {
const serialized = JSON.stringify(listing);
const listingWasCompacted =
serialized.length > runtime.settings.maxToolResultChars;
const artifacts = listingWasCompacted
? [
await runtime.artifacts.writeJson(
listing,
`Full workspace listing for ${input.path ?? "."}`,
),
]
: undefined;
const data = listingWasCompacted
? {
...listing,
entries: listing.entries.slice(0, 100),
truncated: true,
omittedEntries: Math.max(0, listing.entries.length - 100),
}
: listing;
return okResult({
operation: "workspace.list",
summary: `Listed ${listing.entries.length} workspace entries${
listing.truncated || listingWasCompacted ? " (truncated)" : ""
}.`,
data,
artifacts,
omit: ["data.entries"],
});
}
const [transactions, runs, taskBoards, researchProjects, approvals, notes, memory] =
await Promise.all([
runtime.transactions.list(5),
runtime.runStore.list(5),
runtime.settings.taskBoardsEnabled ? runtime.tasks.list(5) : Promise.resolve([]),
runtime.settings.webResearchEnabled
? runtime.research.list(5)
: Promise.resolve([]),
runtime.approvals.snapshot(),
runtime.notes.list(),
runtime.notes.readMemory(),
]);
const fullData = {
root: runtime.boundary.root,
tree: listing,
activePlan: planRef(approvals.activePlan),
pendingPlan: planRef(approvals.pendingPlan),
pendingApprovals: approvals.pendingApprovals.length,
memory: { exists: memory.exists, path: memory.path, bytes: memory.bytes },
noteCount: notes.length,
recentTransactions: transactions.map((plan) => ({
id: plan.id,
status: plan.status,
summary: plan.summary,
updatedAt: plan.updatedAt,
reviewPath: plan.reviewPath,
})),
recentRuns: runs.map((run) => ({
id: run.id,
status: run.status,
mode: run.mode,
objective: run.objective.slice(0, 240),
taskBoardId: run.taskBoardId,
researchProjectId: run.researchProjectId,
updatedAt: run.updatedAt,
})),
recentTaskBoards: taskBoards.map((board) => ({
id: board.id,
status: board.status,
title: board.title,
itemCount: board.items.length,
completedCount: board.items.filter((item) => item.status === "completed").length,
nextActionable: runtime.tasks.nextActionable(board).slice(0, 5).map((item) => ({
id: item.id,
text: item.text,
priority: item.priority,
})),
updatedAt: board.updatedAt,
statePath: runtime.tasks.stateRelativePath(board.id),
})),
recentResearchProjects: researchProjects.map((project) => ({
id: project.id,
status: project.status,
title: project.title,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
reportPath: project.reportPath,
updatedAt: project.updatedAt,
statePath: runtime.research.stateRelativePath(project.id),
})),
};
const overviewWasCompacted =
JSON.stringify(fullData).length > runtime.settings.maxToolResultChars;
const artifacts = overviewWasCompacted
? [
await runtime.artifacts.writeJson(
fullData,
"Full workspace overview",
),
]
: undefined;
const data = overviewWasCompacted
? {
...fullData,
tree: {
...listing,
entries: listing.entries.slice(0, 100),
truncated: true,
omittedEntries: Math.max(0, listing.entries.length - 100),
},
}
: fullData;
return okResult({
operation: "workspace.overview",
summary: `Workspace overview: ${listing.entries.length} entries, ${transactions.length} transaction(s), ${runs.length} agent run(s), ${taskBoards.length} task board(s), and ${researchProjects.length} research project(s).`,
data,
artifacts,
importance: "high",
facts: [`workspace root ${runtime.boundary.root}`],
omit: ["data.tree.entries"],
});
}
if (input.action === "read") {
if (!input.path) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect read requires path.");
}
const absolute = await runtime.boundary.resolveRead(input.path);
const snapshot = await readTextSnapshot(absolute, runtime.settings.maxReadBytes);
const startLine = input.start_line ?? 1;
const range = numberedRange(
snapshot.content,
startLine,
input.end_line ?? startLine + READ_WINDOW_LINES - 1,
);
const bounded = boundedPreview(range.text, runtime.settings.maxToolResultChars);
const artifacts = bounded.truncated
? [
await runtime.artifacts.writeText(
"text",
range.text,
"txt",
`Requested line range from ${runtime.boundary.relativePath(absolute)}`,
),
]
: undefined;
const path = runtime.boundary.relativePath(absolute);
return okResult({
operation: "workspace.read",
summary: `Read ${path} lines ${range.startLine}-${range.endLine} of ${range.totalLines}.`,
data: {
path,
sha256: snapshot.sha256,
bytes: snapshot.bytes,
totalLines: range.totalLines,
startLine: range.startLine,
endLine: range.endLine,
content: bounded.preview,
truncated: bounded.truncated,
omittedChars: bounded.omittedChars,
},
artifacts,
importance: "high",
facts: [`${path} sha256 ${snapshot.sha256}`],
omit: ["data.content"],
});
}
if (input.action === "search") {
if (!input.query) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect search requires query.");
}
const result = await searchWorkspace(runtime.boundary, {
query: input.query,
path: input.path,
regex: input.regex,
caseSensitive: input.case_sensitive,
maxResults: input.max_results ?? 50,
maxFiles: 1000,
maxFileBytes: Math.min(runtime.settings.maxReadBytes, 2_000_000),
contextLines: 1,
});
const serialized = JSON.stringify(result);
let data = result;
let artifacts;
if (serialized.length > runtime.settings.maxToolResultChars) {
artifacts = [
await runtime.artifacts.writeJson(result, `Full search result for '${input.query}'`),
];
data = { ...result, matches: result.matches.slice(0, 20), truncated: true };
}
return okResult({
operation: "workspace.search",
summary: `Found ${result.matches.length} match(es) across ${result.filesScanned} scanned file(s).`,
data,
artifacts,
facts: result.matches.slice(0, 10).map((match) => `${match.path}:${match.line}`),
omit: ["data.matches"],
});
}
if (input.action === "find") {
if (!input.query) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect find requires query.");
}
const result = await findWorkspacePaths(runtime.boundary, {
query: input.query,
path: input.path,
maxResults: input.max_results ?? 50,
includeDirectories: input.include_directories ?? false,
includeHidden: input.include_hidden ?? false,
});
return okResult({
operation: "workspace.find",
summary: `Found ${result.matches.length} matching path(s) across ${result.scanned} entries.`,
data: result,
facts: result.matches.slice(0, 20).map((match) => match.path),
omit: ["data.matches"],
});
}
if (input.action === "semantic_search") {
if (!input.query) {
throw new AgenticError(
"INVALID_INPUT",
"workspace_inspect semantic_search requires query.",
);
}
const result = await semanticSearchWorkspace(runtime.boundary, {
query: input.query,
path: input.path,
maxResults: input.max_results ?? 20,
maxFiles: 500,
maxFileBytes: Math.min(runtime.settings.maxReadBytes, 2_000_000),
});
const serialized = JSON.stringify(result);
const compacted = serialized.length > runtime.settings.maxToolResultChars;
const artifacts = compacted
? [await runtime.artifacts.writeJson(result, `Semantic search for '${input.query}'`)]
: undefined;
const data = compacted
? { ...result, matches: result.matches.slice(0, 8), truncated: true }
: result;
return okResult({
operation: "workspace.semantic_search",
summary: `Ranked ${result.matches.length} relevant code chunk(s) from ${result.filesScanned} file(s).`,
data,
artifacts,
importance: "high",
facts: result.matches.slice(0, 10).map((match) => `${match.path}:${match.startLine}-${match.endLine}`),
omit: ["data.matches"],
});
}
if (input.action === "stat") {
if (!input.path) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect stat requires path.");
}
const absolute = await runtime.boundary.resolveRead(input.path);
const info = await lstat(absolute);
const path = runtime.boundary.relativePath(absolute);
let textMetadata:
| { sha256: string; lineCount: number; newline: string }
| undefined;
if (info.isFile() && info.size <= runtime.settings.maxReadBytes) {
try {
const snapshot = await readTextSnapshot(absolute, runtime.settings.maxReadBytes);
textMetadata = {
sha256: snapshot.sha256,
lineCount: snapshot.lineCount,
newline: snapshot.newline,
};
} catch {
textMetadata = undefined;
}
}
return okResult({
operation: "workspace.stat",
summary: `${path}: ${info.isDirectory() ? "directory" : info.isFile() ? "file" : "other"}, ${info.size.toLocaleString()} bytes.`,
data: {
path,
type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
bytes: info.size,
modifiedAt: info.mtime.toISOString(),
createdAt: info.birthtime.toISOString(),
mode: info.mode,
...textMetadata,
},
facts: textMetadata ? [`${path} sha256 ${textMetadata.sha256}`] : [path],
});
}
const limit = input.limit ?? 20;
const [transactions, runs, jobs, taskBoards, researchProjects, approvalRecords] = await Promise.all([
runtime.transactions.list(limit),
runtime.runStore.list(limit),
runtime.settings.processExecutionEnabled
? runtime.processes.list(limit)
: Promise.resolve([]),
runtime.settings.taskBoardsEnabled
? runtime.tasks.list(limit)
: Promise.resolve([]),
runtime.settings.webResearchEnabled
? runtime.research.list(limit)
: Promise.resolve([]),
runtime.approvals.list(),
]);
const fullData = {
transactions: transactions.map((plan) => ({
id: plan.id,
...transactionListEntry(plan),
})),
runs: runs.map((run) => ({
id: run.id,
status: run.status,
mode: run.mode,
objective: run.objective.slice(0, 300),
taskBoardId: run.taskBoardId,
researchProjectId: run.researchProjectId,
fileCount: run.filesChanged.length,
filesChanged: run.filesChanged.slice(0, 20),
filesTruncated: run.filesChanged.length > 20,
updatedAt: run.updatedAt,
})),
// Background jobs are recovery-index data, not bulk: five short
// fields each, capped at `limit`, and the only way a model can get a
// job id back after compaction (the workflow hint promises exactly
// that). Deliberately NOT in omit_when_summarizing below.
jobs,
taskBoards: taskBoards.map((board) => ({
id: board.id,
status: board.status,
title: board.title,
itemCount: board.items.length,
nextActionable: runtime.tasks.nextActionable(board).slice(0, 10).map((item) => ({
id: item.id,
text: item.text,
priority: item.priority,
})),
updatedAt: board.updatedAt,
statePath: runtime.tasks.stateRelativePath(board.id),
})),
researchProjects: researchProjects.map((project) => ({
id: project.id,
status: project.status,
title: project.title,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
reportPath: project.reportPath,
updatedAt: project.updatedAt,
statePath: runtime.research.stateRelativePath(project.id),
})),
approvals: approvalRecords
.filter((record) => record.kind !== "plan" && (record.status === "pending" || record.status === "approved"))
.slice(0, limit)
.map((record) => ({ id: record.id, kind: record.kind, status: record.status, title: record.title, updatedAt: record.updatedAt })),
plans: approvalRecords.filter((record) => record.kind === "plan").slice(0, limit).map((record) => ({
id: record.id,
status: record.status,
title: record.title,
revision: (record.plan as PlanDocument).revision,
planPath: runtime.approvals.planPath(record.id),
})),
};
const changesWereCompacted =
JSON.stringify(fullData).length > runtime.settings.maxToolResultChars;
const artifacts = changesWereCompacted
? [await runtime.artifacts.writeJson(fullData, "Full durable changes index")]
: undefined;
const data = changesWereCompacted
? {
transactions: fullData.transactions.slice(0, 20),
runs: fullData.runs.slice(0, 20),
jobs: fullData.jobs.slice(0, 20),
taskBoards: fullData.taskBoards.slice(0, 20),
researchProjects: fullData.researchProjects.slice(0, 20),
approvals: fullData.approvals.slice(0, 20),
plans: fullData.plans.slice(0, 20),
truncated: true,
}
: fullData;
return okResult({
operation: "workspace.changes",
summary: `Found ${transactions.length} transaction(s), ${runs.length} agent run(s), ${jobs.length} command job(s), ${taskBoards.length} task board(s), ${researchProjects.length} research project(s), ${fullData.approvals.length} approval(s), and ${fullData.plans.length} plan(s).`,
data,
artifacts,
importance: "high",
facts: [
...transactions.slice(0, 5).map((plan) => `${plan.id} ${plan.status}`),
...runs.slice(0, 5).map((run) => `${run.id} ${run.status}`),
...jobs.slice(0, 5).map((job) => `${job.id} ${job.status} ${job.executable}`),
...taskBoards.slice(0, 5).map((board) => `${board.id} ${board.status}`),
...researchProjects
.slice(0, 5)
.map((project) => `${project.id} ${project.status}`),
...fullData.approvals.slice(0, 5).map((approval) => `${approval.id} ${approval.status}`),
...fullData.plans.slice(0, 5).map((plan) => `${plan.id} ${plan.status}`),
],
// approvals and plans are deliberately NOT omitted: they are the
// post-compaction recovery index for the permission gate (id, status,
// title, planPath), they are small (limit defaults to 20 entries), and
// a model that lost context must be able to act on a pending approval
// without first re-issuing this call.
omit: [
"data.transactions",
"data.runs",
"data.taskBoards",
"data.researchProjects",
],
});
} catch (error) {
return errorResult(`workspace.${input.action}`, error);
}
},
});
}
import { lstat } from "node:fs/promises";
import { tool, type Tool } from "@lmstudio/sdk";
import { z } from "zod";
import { AgenticError } from "../core/errors";
import { boundedPreview, errorResult, okResult } from "../core/result";
import type { ApprovalRecord } from "../policy/approvalStore";
import { describePermissionMode, permissionLevel } from "../policy/permissions";
import type { PlanDocument } from "../policy/planDocument";
import type { PluginRuntime } from "../runtime";
import { transactionListEntry } from "./editTool";
import { searchWorkspace } from "../workspace/search";
import { findWorkspacePaths, semanticSearchWorkspace } from "../workspace/discovery";
import { numberedRange, readTextSnapshot } from "../workspace/text";
import { walkWorkspace } from "../workspace/walk";
function planRef(record: ApprovalRecord | undefined): { id: string; title: string } | null {
return record ? { id: record.id, title: record.title } : null;
}
/**
* How many lines `read` returns when `end_line` is omitted. The window is
* anchored to `start_line` (`start .. start + N - 1`) rather than to the top of
* the file: `numberedRange` clamps `end` up to `start`, so an absolute default
* such as `min(lineCount, 600)` silently returned a single line for any read
* starting past line 600. `numberedRange` still clamps the window to the file.
*/
const READ_WINDOW_LINES = 600;
export function createWorkspaceTool(runtime: PluginRuntime): Tool {
return tool({
name: "workspace_inspect",
description:
"Inspect the workspace. Actions: overview, list, read, search, semantic_search, find, stat, changes, capabilities. Start every task here: overview, then workspace_notes memory=true (.agentic/MEMORY.md), then read/search before editing. read is line-numbered (`12 | code`); strip that prefix before the text goes into workspace_edit. capabilities shows mode, plan and approvals; changes relists transaction, run, job, board, research, plan, approval ids after compression.",
parameters: {
action: z.enum(["overview", "list", "read", "search", "semantic_search", "find", "stat", "changes", "capabilities"]),
path: z.string().optional(),
start_line: z.number().int().min(1).optional(),
end_line: z.number().int().min(1).optional(),
query: z.string().optional(),
regex: z.boolean().optional(),
case_sensitive: z.boolean().optional(),
max_results: z.number().int().min(1).max(100).optional(),
max_depth: z.number().int().min(0).max(12).optional(),
max_entries: z.number().int().min(1).max(1000).optional(),
include_hidden: z.boolean().optional(),
include_directories: z.boolean().optional(),
limit: z.number().int().min(1).max(100).optional(),
},
implementation: async (input: {
action:
| "overview"
| "list"
| "read"
| "search"
| "semantic_search"
| "find"
| "stat"
| "changes"
| "capabilities";
path?: string;
start_line?: number;
end_line?: number;
query?: string;
regex?: boolean;
case_sensitive?: boolean;
max_results?: number;
max_depth?: number;
max_entries?: number;
include_hidden?: boolean;
include_directories?: boolean;
limit?: number;
}) => {
try {
if (input.action === "capabilities") {
const [approvals, notes, memory] = await Promise.all([
runtime.approvals.snapshot(),
runtime.notes.list(),
runtime.notes.readMemory(),
]);
return okResult({
operation: "workspace.capabilities",
summary: "Agentic Workspace is active with transactional edits and compact artifact-backed results.",
data: {
workspace: runtime.boundary.root,
permissionMode: runtime.settings.permissionMode,
permissionLevel: permissionLevel(runtime.settings.permissionMode),
activePlan: planRef(approvals.activePlan),
pendingPlan: planRef(approvals.pendingPlan),
pendingApprovals: approvals.pendingApprovals.length,
notes: {
count: notes.length,
memoryExists: memory.exists,
memoryPath: runtime.notes.memoryPath(),
},
destructiveCommits: runtime.settings.allowDestructiveEdits,
processExecution: runtime.settings.processExecutionEnabled,
durableAgents: runtime.settings.internalAgentsEnabled,
taskBoards: runtime.settings.taskBoardsEnabled,
webResearch: runtime.settings.webResearchEnabled,
webSearchProvider: runtime.settings.webSearchProvider,
agentWebAccess:
runtime.settings.internalAgentsEnabled &&
runtime.settings.webResearchEnabled &&
runtime.settings.agentAllowWeb,
gitAndGitHub:
runtime.settings.processExecutionEnabled && runtime.settings.vcsToolsEnabled,
browserAutomation: runtime.settings.browserEnabled,
tools: [
"workspace_inspect",
"workspace_edit",
...(runtime.settings.permissionMode !== "auto" ? ["workspace_plan"] : []),
"workspace_notes",
...(runtime.settings.taskBoardsEnabled ? ["workspace_tasks"] : []),
...(runtime.settings.webResearchEnabled ? ["workspace_research"] : []),
...(runtime.settings.processExecutionEnabled ? ["workspace_command"] : []),
...(runtime.settings.processExecutionEnabled && runtime.settings.vcsToolsEnabled
? ["workspace_vcs"]
: []),
...(runtime.settings.browserEnabled ? ["workspace_browser"] : []),
...(runtime.settings.internalAgentsEnabled ? ["workspace_agent"] : []),
],
contextCompressorCompatibility:
"Native agentic-workspace/v1 compaction contract; this plugin remains a tools provider and does not claim the prediction loop.",
resultProtocol: "agentic-workspace/v1",
storage: {
root: ".agentic/",
transactions: ".agentic/transactions/",
jobs: ".agentic/jobs/",
runs: ".agentic/runs/",
taskBoards: ".agentic/tasks/",
research: ".agentic/research/",
artifacts: ".agentic/artifacts/",
approvals: ".agentic/approvals/",
plans: ".agentic/plans/",
notes: ".agentic/notes/",
memory: ".agentic/MEMORY.md",
},
editVisibility: {
diff: ".agentic/transactions/<id>/changes.diff",
reviewReceipt: ".agentic/transactions/<id>/review.md",
snapshots: ".agentic/transactions/<id>/snapshots/",
},
},
importance: "high",
facts: [
`workspace ${runtime.boundary.root}`,
`permission mode ${describePermissionMode(runtime.settings.permissionMode)}`,
"context-compressor compatible through agentic-workspace/v1 retention contracts",
],
});
}
if (input.action === "overview" || input.action === "list") {
const listing = await walkWorkspace(runtime.boundary, input.path ?? ".", {
maxDepth: input.max_depth ?? (input.action === "overview" ? 3 : 5),
maxEntries: input.max_entries ?? (input.action === "overview" ? 200 : 500),
includeHidden: input.include_hidden ?? false,
});
if (input.action === "list") {
const serialized = JSON.stringify(listing);
const listingWasCompacted =
serialized.length > runtime.settings.maxToolResultChars;
const artifacts = listingWasCompacted
? [
await runtime.artifacts.writeJson(
listing,
`Full workspace listing for ${input.path ?? "."}`,
),
]
: undefined;
const data = listingWasCompacted
? {
...listing,
entries: listing.entries.slice(0, 100),
truncated: true,
omittedEntries: Math.max(0, listing.entries.length - 100),
}
: listing;
return okResult({
operation: "workspace.list",
summary: `Listed ${listing.entries.length} workspace entries${
listing.truncated || listingWasCompacted ? " (truncated)" : ""
}.`,
data,
artifacts,
omit: ["data.entries"],
});
}
const [transactions, runs, taskBoards, researchProjects, approvals, notes, memory] =
await Promise.all([
runtime.transactions.list(5),
runtime.runStore.list(5),
runtime.settings.taskBoardsEnabled ? runtime.tasks.list(5) : Promise.resolve([]),
runtime.settings.webResearchEnabled
? runtime.research.list(5)
: Promise.resolve([]),
runtime.approvals.snapshot(),
runtime.notes.list(),
runtime.notes.readMemory(),
]);
const fullData = {
root: runtime.boundary.root,
tree: listing,
activePlan: planRef(approvals.activePlan),
pendingPlan: planRef(approvals.pendingPlan),
pendingApprovals: approvals.pendingApprovals.length,
memory: { exists: memory.exists, path: memory.path, bytes: memory.bytes },
noteCount: notes.length,
recentTransactions: transactions.map((plan) => ({
id: plan.id,
status: plan.status,
summary: plan.summary,
updatedAt: plan.updatedAt,
reviewPath: plan.reviewPath,
})),
recentRuns: runs.map((run) => ({
id: run.id,
status: run.status,
mode: run.mode,
objective: run.objective.slice(0, 240),
taskBoardId: run.taskBoardId,
researchProjectId: run.researchProjectId,
updatedAt: run.updatedAt,
})),
recentTaskBoards: taskBoards.map((board) => ({
id: board.id,
status: board.status,
title: board.title,
itemCount: board.items.length,
completedCount: board.items.filter((item) => item.status === "completed").length,
nextActionable: runtime.tasks.nextActionable(board).slice(0, 5).map((item) => ({
id: item.id,
text: item.text,
priority: item.priority,
})),
updatedAt: board.updatedAt,
statePath: runtime.tasks.stateRelativePath(board.id),
})),
recentResearchProjects: researchProjects.map((project) => ({
id: project.id,
status: project.status,
title: project.title,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
reportPath: project.reportPath,
updatedAt: project.updatedAt,
statePath: runtime.research.stateRelativePath(project.id),
})),
};
const overviewWasCompacted =
JSON.stringify(fullData).length > runtime.settings.maxToolResultChars;
const artifacts = overviewWasCompacted
? [
await runtime.artifacts.writeJson(
fullData,
"Full workspace overview",
),
]
: undefined;
const data = overviewWasCompacted
? {
...fullData,
tree: {
...listing,
entries: listing.entries.slice(0, 100),
truncated: true,
omittedEntries: Math.max(0, listing.entries.length - 100),
},
}
: fullData;
return okResult({
operation: "workspace.overview",
summary: `Workspace overview: ${listing.entries.length} entries, ${transactions.length} transaction(s), ${runs.length} agent run(s), ${taskBoards.length} task board(s), and ${researchProjects.length} research project(s).`,
data,
artifacts,
importance: "high",
facts: [`workspace root ${runtime.boundary.root}`],
omit: ["data.tree.entries"],
});
}
if (input.action === "read") {
if (!input.path) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect read requires path.");
}
const absolute = await runtime.boundary.resolveRead(input.path);
const snapshot = await readTextSnapshot(absolute, runtime.settings.maxReadBytes);
const startLine = input.start_line ?? 1;
const range = numberedRange(
snapshot.content,
startLine,
input.end_line ?? startLine + READ_WINDOW_LINES - 1,
);
const bounded = boundedPreview(range.text, runtime.settings.maxToolResultChars);
const artifacts = bounded.truncated
? [
await runtime.artifacts.writeText(
"text",
range.text,
"txt",
`Requested line range from ${runtime.boundary.relativePath(absolute)}`,
),
]
: undefined;
const path = runtime.boundary.relativePath(absolute);
return okResult({
operation: "workspace.read",
summary: `Read ${path} lines ${range.startLine}-${range.endLine} of ${range.totalLines}.`,
data: {
path,
sha256: snapshot.sha256,
bytes: snapshot.bytes,
totalLines: range.totalLines,
startLine: range.startLine,
endLine: range.endLine,
content: bounded.preview,
truncated: bounded.truncated,
omittedChars: bounded.omittedChars,
},
artifacts,
importance: "high",
facts: [`${path} sha256 ${snapshot.sha256}`],
omit: ["data.content"],
});
}
if (input.action === "search") {
if (!input.query) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect search requires query.");
}
const result = await searchWorkspace(runtime.boundary, {
query: input.query,
path: input.path,
regex: input.regex,
caseSensitive: input.case_sensitive,
maxResults: input.max_results ?? 50,
maxFiles: 1000,
maxFileBytes: Math.min(runtime.settings.maxReadBytes, 2_000_000),
contextLines: 1,
});
const serialized = JSON.stringify(result);
let data = result;
let artifacts;
if (serialized.length > runtime.settings.maxToolResultChars) {
artifacts = [
await runtime.artifacts.writeJson(result, `Full search result for '${input.query}'`),
];
data = { ...result, matches: result.matches.slice(0, 20), truncated: true };
}
return okResult({
operation: "workspace.search",
summary: `Found ${result.matches.length} match(es) across ${result.filesScanned} scanned file(s).`,
data,
artifacts,
facts: result.matches.slice(0, 10).map((match) => `${match.path}:${match.line}`),
omit: ["data.matches"],
});
}
if (input.action === "find") {
if (!input.query) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect find requires query.");
}
const result = await findWorkspacePaths(runtime.boundary, {
query: input.query,
path: input.path,
maxResults: input.max_results ?? 50,
includeDirectories: input.include_directories ?? false,
includeHidden: input.include_hidden ?? false,
});
return okResult({
operation: "workspace.find",
summary: `Found ${result.matches.length} matching path(s) across ${result.scanned} entries.`,
data: result,
facts: result.matches.slice(0, 20).map((match) => match.path),
omit: ["data.matches"],
});
}
if (input.action === "semantic_search") {
if (!input.query) {
throw new AgenticError(
"INVALID_INPUT",
"workspace_inspect semantic_search requires query.",
);
}
const result = await semanticSearchWorkspace(runtime.boundary, {
query: input.query,
path: input.path,
maxResults: input.max_results ?? 20,
maxFiles: 500,
maxFileBytes: Math.min(runtime.settings.maxReadBytes, 2_000_000),
});
const serialized = JSON.stringify(result);
const compacted = serialized.length > runtime.settings.maxToolResultChars;
const artifacts = compacted
? [await runtime.artifacts.writeJson(result, `Semantic search for '${input.query}'`)]
: undefined;
const data = compacted
? { ...result, matches: result.matches.slice(0, 8), truncated: true }
: result;
return okResult({
operation: "workspace.semantic_search",
summary: `Ranked ${result.matches.length} relevant code chunk(s) from ${result.filesScanned} file(s).`,
data,
artifacts,
importance: "high",
facts: result.matches.slice(0, 10).map((match) => `${match.path}:${match.startLine}-${match.endLine}`),
omit: ["data.matches"],
});
}
if (input.action === "stat") {
if (!input.path) {
throw new AgenticError("INVALID_INPUT", "workspace_inspect stat requires path.");
}
const absolute = await runtime.boundary.resolveRead(input.path);
const info = await lstat(absolute);
const path = runtime.boundary.relativePath(absolute);
let textMetadata:
| { sha256: string; lineCount: number; newline: string }
| undefined;
if (info.isFile() && info.size <= runtime.settings.maxReadBytes) {
try {
const snapshot = await readTextSnapshot(absolute, runtime.settings.maxReadBytes);
textMetadata = {
sha256: snapshot.sha256,
lineCount: snapshot.lineCount,
newline: snapshot.newline,
};
} catch {
textMetadata = undefined;
}
}
return okResult({
operation: "workspace.stat",
summary: `${path}: ${info.isDirectory() ? "directory" : info.isFile() ? "file" : "other"}, ${info.size.toLocaleString()} bytes.`,
data: {
path,
type: info.isDirectory() ? "directory" : info.isFile() ? "file" : "other",
bytes: info.size,
modifiedAt: info.mtime.toISOString(),
createdAt: info.birthtime.toISOString(),
mode: info.mode,
...textMetadata,
},
facts: textMetadata ? [`${path} sha256 ${textMetadata.sha256}`] : [path],
});
}
const limit = input.limit ?? 20;
const [transactions, runs, jobs, taskBoards, researchProjects, approvalRecords] = await Promise.all([
runtime.transactions.list(limit),
runtime.runStore.list(limit),
runtime.settings.processExecutionEnabled
? runtime.processes.list(limit)
: Promise.resolve([]),
runtime.settings.taskBoardsEnabled
? runtime.tasks.list(limit)
: Promise.resolve([]),
runtime.settings.webResearchEnabled
? runtime.research.list(limit)
: Promise.resolve([]),
runtime.approvals.list(),
]);
const fullData = {
transactions: transactions.map((plan) => ({
id: plan.id,
...transactionListEntry(plan),
})),
runs: runs.map((run) => ({
id: run.id,
status: run.status,
mode: run.mode,
objective: run.objective.slice(0, 300),
taskBoardId: run.taskBoardId,
researchProjectId: run.researchProjectId,
fileCount: run.filesChanged.length,
filesChanged: run.filesChanged.slice(0, 20),
filesTruncated: run.filesChanged.length > 20,
updatedAt: run.updatedAt,
})),
// Background jobs are recovery-index data, not bulk: five short
// fields each, capped at `limit`, and the only way a model can get a
// job id back after compaction (the workflow hint promises exactly
// that). Deliberately NOT in omit_when_summarizing below.
jobs,
taskBoards: taskBoards.map((board) => ({
id: board.id,
status: board.status,
title: board.title,
itemCount: board.items.length,
nextActionable: runtime.tasks.nextActionable(board).slice(0, 10).map((item) => ({
id: item.id,
text: item.text,
priority: item.priority,
})),
updatedAt: board.updatedAt,
statePath: runtime.tasks.stateRelativePath(board.id),
})),
researchProjects: researchProjects.map((project) => ({
id: project.id,
status: project.status,
title: project.title,
agentRunId: project.agentRunId,
queryCount: project.queries.length,
sourceCount: project.sources.length,
reportPath: project.reportPath,
updatedAt: project.updatedAt,
statePath: runtime.research.stateRelativePath(project.id),
})),
approvals: approvalRecords
.filter((record) => record.kind !== "plan" && (record.status === "pending" || record.status === "approved"))
.slice(0, limit)
.map((record) => ({ id: record.id, kind: record.kind, status: record.status, title: record.title, updatedAt: record.updatedAt })),
plans: approvalRecords.filter((record) => record.kind === "plan").slice(0, limit).map((record) => ({
id: record.id,
status: record.status,
title: record.title,
revision: (record.plan as PlanDocument).revision,
planPath: runtime.approvals.planPath(record.id),
})),
};
const changesWereCompacted =
JSON.stringify(fullData).length > runtime.settings.maxToolResultChars;
const artifacts = changesWereCompacted
? [await runtime.artifacts.writeJson(fullData, "Full durable changes index")]
: undefined;
const data = changesWereCompacted
? {
transactions: fullData.transactions.slice(0, 20),
runs: fullData.runs.slice(0, 20),
jobs: fullData.jobs.slice(0, 20),
taskBoards: fullData.taskBoards.slice(0, 20),
researchProjects: fullData.researchProjects.slice(0, 20),
approvals: fullData.approvals.slice(0, 20),
plans: fullData.plans.slice(0, 20),
truncated: true,
}
: fullData;
return okResult({
operation: "workspace.changes",
summary: `Found ${transactions.length} transaction(s), ${runs.length} agent run(s), ${jobs.length} command job(s), ${taskBoards.length} task board(s), ${researchProjects.length} research project(s), ${fullData.approvals.length} approval(s), and ${fullData.plans.length} plan(s).`,
data,
artifacts,
importance: "high",
facts: [
...transactions.slice(0, 5).map((plan) => `${plan.id} ${plan.status}`),
...runs.slice(0, 5).map((run) => `${run.id} ${run.status}`),
...jobs.slice(0, 5).map((job) => `${job.id} ${job.status} ${job.executable}`),
...taskBoards.slice(0, 5).map((board) => `${board.id} ${board.status}`),
...researchProjects
.slice(0, 5)
.map((project) => `${project.id} ${project.status}`),
...fullData.approvals.slice(0, 5).map((approval) => `${approval.id} ${approval.status}`),
...fullData.plans.slice(0, 5).map((plan) => `${plan.id} ${plan.status}`),
],
// approvals and plans are deliberately NOT omitted: they are the
// post-compaction recovery index for the permission gate (id, status,
// title, planPath), they are small (limit defaults to 20 entries), and
// a model that lost context must be able to act on a pending approval
// without first re-issuing this call.
omit: [
"data.transactions",
"data.runs",
"data.taskBoards",
"data.researchProjects",
],
});
} catch (error) {
return errorResult(`workspace.${input.action}`, error);
}
},
});
}