xcoder-jarvis-agent 0.0.0-stage → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +224 -2
  3. package/dist/index.d.ts +27 -0
  4. package/dist/index.js +28 -0
  5. package/dist/jarvis/agent.d.ts +57 -0
  6. package/dist/jarvis/agent.js +306 -0
  7. package/dist/jarvis/argv.d.ts +30 -0
  8. package/dist/jarvis/argv.js +138 -0
  9. package/dist/jarvis/budget.d.ts +49 -0
  10. package/dist/jarvis/budget.js +83 -0
  11. package/dist/jarvis/chat/confirm.d.ts +23 -0
  12. package/dist/jarvis/chat/confirm.js +37 -0
  13. package/dist/jarvis/chat/lines.d.ts +41 -0
  14. package/dist/jarvis/chat/lines.js +93 -0
  15. package/dist/jarvis/chat/prompt.d.ts +10 -0
  16. package/dist/jarvis/chat/prompt.js +18 -0
  17. package/dist/jarvis/chat/session.d.ts +38 -0
  18. package/dist/jarvis/chat/session.js +122 -0
  19. package/dist/jarvis/chat/terminator.d.ts +14 -0
  20. package/dist/jarvis/chat/terminator.js +19 -0
  21. package/dist/jarvis/decision.d.ts +64 -0
  22. package/dist/jarvis/decision.js +116 -0
  23. package/dist/jarvis/memory/store.d.ts +88 -0
  24. package/dist/jarvis/memory/store.js +189 -0
  25. package/dist/jarvis/prompt/assemble.d.ts +61 -0
  26. package/dist/jarvis/prompt/assemble.js +118 -0
  27. package/dist/jarvis/prompt/policy.d.ts +22 -0
  28. package/dist/jarvis/prompt/policy.js +80 -0
  29. package/dist/jarvis/prompt/skills.d.ts +69 -0
  30. package/dist/jarvis/prompt/skills.js +257 -0
  31. package/dist/jarvis/react-loop.d.ts +51 -0
  32. package/dist/jarvis/react-loop.js +160 -0
  33. package/dist/jarvis/telemetry/provenance.d.ts +54 -0
  34. package/dist/jarvis/telemetry/provenance.js +56 -0
  35. package/dist/jarvis/test-helpers.d.ts +14 -0
  36. package/dist/jarvis/test-helpers.js +69 -0
  37. package/dist/jarvis/tools/fs-tools.d.ts +28 -0
  38. package/dist/jarvis/tools/fs-tools.js +292 -0
  39. package/dist/jarvis/tools/memory-tools.d.ts +27 -0
  40. package/dist/jarvis/tools/memory-tools.js +83 -0
  41. package/dist/jarvis/tools/registry.d.ts +54 -0
  42. package/dist/jarvis/tools/registry.js +49 -0
  43. package/dist/jarvis/util/confirm.d.ts +28 -0
  44. package/dist/jarvis/util/confirm.js +54 -0
  45. package/dist/jarvis/util/kill.d.ts +10 -0
  46. package/dist/jarvis/util/kill.js +39 -0
  47. package/dist/jarvis/util/text.d.ts +74 -0
  48. package/dist/jarvis/util/text.js +190 -0
  49. package/dist/program.d.ts +2 -0
  50. package/dist/program.js +12 -0
  51. package/package.json +35 -3
@@ -0,0 +1,160 @@
1
+ import { BudgetExceeded } from "./budget.js";
2
+ import { parseDecision, ReactParseError } from "./decision.js";
3
+ import { wrapObservation } from "./prompt/assemble.js";
4
+ /**
5
+ * The ReAct loop, owned in-package (design §2.4, tradeoff T1).
6
+ *
7
+ * The shared `GenesisReActOrchestrator` in xcoder-dna could not be reused: its
8
+ * tool registry is internal and non-injectable, which would mean shipping an
9
+ * agent whose tool surface we do not control. Six invariants hold here, each
10
+ * covered by a test:
11
+ * 1. iterations never exceed `maxIterations` (clamped to 8 by the caller);
12
+ * 2. every reply is parsed before any side effect occurs;
13
+ * 3. a tool not in the registry allowlist is refused in code and fed back as
14
+ * an error observation — it is never dispatched;
15
+ * 4. `isKilled()` is checked before each LLM call and each dispatch;
16
+ * 5. budget exhaustion yields `budget_exceeded` with no partial answer;
17
+ * 6. the kill-switch AbortSignal is passed to the tool context.
18
+ */
19
+ export const MAX_ITERATIONS = 8;
20
+ const CORRECTIVE_NUDGE = "Your previous reply could not be used: it was not a single JSON object " +
21
+ "matching the required shape. Reply again with exactly one JSON object and " +
22
+ "no other text. Do not explain, and do not repeat the rejected text.";
23
+ function transcriptWith(transcript, role, content) {
24
+ return [...transcript, { role, content }];
25
+ }
26
+ /**
27
+ * Execute one parsed action. The allowlist is enforced by `registry.get()`,
28
+ * which returns undefined for anything not registered AND allowed — so an
29
+ * off-list name cannot reach `execute()` at all.
30
+ */
31
+ async function dispatch(input, decision) {
32
+ const descriptor = input.registry.get(decision.tool);
33
+ if (!descriptor) {
34
+ return {
35
+ dispatched: false,
36
+ observation: {
37
+ ok: false,
38
+ summary: `refused: tool '${decision.tool}' is not in this run's allowlist (${input.registry
39
+ .allowlist()
40
+ .join(", ")})`,
41
+ bytes: 0,
42
+ truncated: false,
43
+ },
44
+ };
45
+ }
46
+ const parsedArgs = descriptor.argsSchema.safeParse(decision.args);
47
+ if (!parsedArgs.success) {
48
+ const issue = parsedArgs.error.issues[0];
49
+ return {
50
+ dispatched: false,
51
+ observation: {
52
+ ok: false,
53
+ summary: `refused: arguments for '${decision.tool}' did not match its schema (${issue?.path.join(".") || "(root)"}: ${issue?.message ?? "invalid"})`,
54
+ bytes: 0,
55
+ truncated: false,
56
+ },
57
+ };
58
+ }
59
+ try {
60
+ const observation = await descriptor.execute(parsedArgs.data, input.toolContext);
61
+ return { dispatched: true, observation };
62
+ }
63
+ catch {
64
+ return {
65
+ dispatched: true,
66
+ observation: {
67
+ ok: false,
68
+ summary: `tool '${decision.tool}' failed while executing`,
69
+ bytes: 0,
70
+ truncated: false,
71
+ },
72
+ };
73
+ }
74
+ }
75
+ export async function runReactLoop(i) {
76
+ const maxIterations = Math.min(MAX_ITERATIONS, Math.max(1, i.maxIterations));
77
+ let transcript = [{ role: "user", content: i.task }];
78
+ let iterations = 0;
79
+ let consecutiveParseFailures = 0;
80
+ while (iterations < maxIterations) {
81
+ // (4) kill switch, before the LLM call.
82
+ if (i.isKilled()) {
83
+ return { outcome: "aborted", iterations, transcript, error: "kill switch tripped" };
84
+ }
85
+ // (5) budget, before spending anything.
86
+ try {
87
+ i.budget.check();
88
+ }
89
+ catch (err) {
90
+ if (err instanceof BudgetExceeded) {
91
+ return { outcome: "budget_exceeded", iterations, transcript, error: err.message };
92
+ }
93
+ throw err;
94
+ }
95
+ let reply;
96
+ try {
97
+ reply = await i.client.complete(i.systemPrompt.system, transcript);
98
+ }
99
+ catch (err) {
100
+ const message = err instanceof Error ? err.message.slice(0, 300) : "model call failed";
101
+ return { outcome: "error", iterations, transcript, error: message };
102
+ }
103
+ iterations += 1;
104
+ try {
105
+ i.budget.chargeUsage(reply.usage);
106
+ i.budget.countIteration();
107
+ }
108
+ catch (err) {
109
+ if (err instanceof BudgetExceeded) {
110
+ return { outcome: "budget_exceeded", iterations, transcript, error: err.message };
111
+ }
112
+ throw err;
113
+ }
114
+ // (2) parse BEFORE any side effect.
115
+ let decision;
116
+ try {
117
+ decision = parseDecision(reply.text);
118
+ consecutiveParseFailures = 0;
119
+ }
120
+ catch (err) {
121
+ consecutiveParseFailures += 1;
122
+ const detail = err instanceof ReactParseError ? err.message : "unparseable reply";
123
+ if (consecutiveParseFailures >= 2) {
124
+ return {
125
+ outcome: "error",
126
+ iterations,
127
+ transcript,
128
+ error: `model reply could not be parsed twice (${detail})`,
129
+ };
130
+ }
131
+ // Observed as data, never as trusted instruction.
132
+ transcript = transcriptWith(transcriptWith(transcript, "assistant", "[reply rejected: not a valid decision object]"), "user", CORRECTIVE_NUDGE);
133
+ continue;
134
+ }
135
+ transcript = transcriptWith(transcript, "assistant", reply.text);
136
+ if (decision.kind === "final") {
137
+ return {
138
+ outcome: "answered",
139
+ answer: decision.answer,
140
+ iterations,
141
+ transcript,
142
+ };
143
+ }
144
+ // (4) kill switch again, before dispatching a tool.
145
+ if (i.isKilled()) {
146
+ return { outcome: "aborted", iterations, transcript, error: "kill switch tripped" };
147
+ }
148
+ // (3) allowlist + args schema, enforced in code.
149
+ const { observation, dispatched } = await dispatch(i, decision);
150
+ if (dispatched)
151
+ i.onToolUsed?.(decision.tool, observation);
152
+ transcript = transcriptWith(transcript, "user", wrapObservation(decision.tool, observation.summary));
153
+ }
154
+ return {
155
+ outcome: "iteration_limit",
156
+ iterations,
157
+ transcript,
158
+ error: `reached the ${maxIterations}-iteration limit without a final answer`,
159
+ };
160
+ }
@@ -0,0 +1,54 @@
1
+ export type JarvisOutcome = "answered" | "iteration_limit" | "budget_exceeded" | "aborted" | "error";
2
+ export interface ProvenanceRecord {
3
+ v: 1;
4
+ runId: string;
5
+ ts: string;
6
+ agent: string;
7
+ version: string;
8
+ workspaceId: string;
9
+ userId: string;
10
+ session: string;
11
+ policyVersion: string;
12
+ policySha256: string;
13
+ skills: Array<{
14
+ name: string;
15
+ sha256: string;
16
+ }>;
17
+ skippedSkills: Array<{
18
+ name: string;
19
+ reason: string;
20
+ }>;
21
+ toolAllowlist: readonly string[];
22
+ toolsUsed: Array<{
23
+ name: string;
24
+ ok: boolean;
25
+ bytes: number;
26
+ }>;
27
+ iterations: number;
28
+ outcome: JarvisOutcome;
29
+ usage: {
30
+ inputTokens: number;
31
+ outputTokens: number;
32
+ };
33
+ memoryWrites: number;
34
+ redactions: number;
35
+ error?: string;
36
+ }
37
+ export declare const RETENTION_DAYS = 90;
38
+ /**
39
+ * Audit record for one run (CORE-08, PRM-10, AI-12).
40
+ *
41
+ * The schema itself is the privacy control: it has no field that can hold
42
+ * prompt text, model replies, file contents, tokens or keys. What is recorded
43
+ * is identifiers, hashes and counts — enough to reconstruct which prompt and
44
+ * which policy produced which outcome, without retaining user data.
45
+ *
46
+ * The error string is the one free-text field; it is redacted and capped
47
+ * before it is written.
48
+ */
49
+ export declare function writeProvenance(dir: string, r: ProvenanceRecord): Promise<string>;
50
+ /**
51
+ * Delete run logs older than the retention window. Best-effort and never
52
+ * throws: pruning is housekeeping, and a failed prune must not fail a run.
53
+ */
54
+ export declare function pruneProvenance(dir: string, now?: Date): Promise<number>;
@@ -0,0 +1,56 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:47:00.711Z | xcoder:subtask code-st-53c7b5
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { redact, writeFilePrivate } from "../util/text.js";
5
+ export const RETENTION_DAYS = 90;
6
+ /**
7
+ * Audit record for one run (CORE-08, PRM-10, AI-12).
8
+ *
9
+ * The schema itself is the privacy control: it has no field that can hold
10
+ * prompt text, model replies, file contents, tokens or keys. What is recorded
11
+ * is identifiers, hashes and counts — enough to reconstruct which prompt and
12
+ * which policy produced which outcome, without retaining user data.
13
+ *
14
+ * The error string is the one free-text field; it is redacted and capped
15
+ * before it is written.
16
+ */
17
+ export async function writeProvenance(dir, r) {
18
+ const record = {
19
+ ...r,
20
+ error: r.error === undefined ? undefined : redact(r.error).text.slice(0, 500),
21
+ };
22
+ const file = path.join(dir, `${r.runId}.jsonl`);
23
+ await writeFilePrivate(file, JSON.stringify(record) + "\n");
24
+ return file;
25
+ }
26
+ /**
27
+ * Delete run logs older than the retention window. Best-effort and never
28
+ * throws: pruning is housekeeping, and a failed prune must not fail a run.
29
+ */
30
+ export async function pruneProvenance(dir, now = new Date()) {
31
+ const cutoff = now.getTime() - RETENTION_DAYS * 24 * 60 * 60 * 1000;
32
+ let removed = 0;
33
+ let entries;
34
+ try {
35
+ entries = await fsp.readdir(dir, { withFileTypes: true });
36
+ }
37
+ catch {
38
+ return 0;
39
+ }
40
+ for (const e of entries) {
41
+ if (!e.isFile() || !e.name.endsWith(".jsonl"))
42
+ continue;
43
+ const full = path.join(dir, e.name);
44
+ try {
45
+ const st = await fsp.stat(full);
46
+ if (st.mtimeMs < cutoff) {
47
+ await fsp.unlink(full);
48
+ removed += 1;
49
+ }
50
+ }
51
+ catch {
52
+ /* skip unreadable entries */
53
+ }
54
+ }
55
+ return removed;
56
+ }
@@ -0,0 +1,14 @@
1
+ import { MemoryStore } from "./memory/store.js";
2
+ import { assembleSystemPrompt, type AssembledPrompt } from "./prompt/assemble.js";
3
+ import { type ToolDescriptor, type ToolObservation } from "./tools/registry.js";
4
+ import type { LlmCompleter, ReactLoopInput } from "./react-loop.js";
5
+ /** Shared fixtures for the jarvis test suite. Not part of the public barrel. */
6
+ export declare const TEST_WORKSPACE: string;
7
+ export declare function promptFor(overrides?: Partial<Parameters<typeof assembleSystemPrompt>[0]>): AssembledPrompt;
8
+ export declare function makeMemory(rootDir?: string): MemoryStore;
9
+ export declare function stubClient(reply: () => string): LlmCompleter;
10
+ /** A tool with a real zod args schema, so schema validation is genuinely exercised. */
11
+ export declare function fakeTool(name: string, execute: () => Promise<ToolObservation>): ToolDescriptor<Record<string, unknown>>;
12
+ export declare function baseInput(overrides?: Partial<ReactLoopInput>): ReactLoopInput;
13
+ export declare function finalReply(answer: string): string;
14
+ export declare function actionReply(tool: string, args?: Record<string, unknown>): string;
@@ -0,0 +1,69 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:48:23.033Z | xcoder:subtask code-st-53c7b5
2
+ import { z } from "zod";
3
+ import { RunBudget } from "./budget.js";
4
+ import { MemoryStore } from "./memory/store.js";
5
+ import { assembleSystemPrompt } from "./prompt/assemble.js";
6
+ import { ALLOWED_TOOLS, createToolRegistry } from "./tools/registry.js";
7
+ import { alwaysDeny } from "./util/confirm.js";
8
+ /** Shared fixtures for the jarvis test suite. Not part of the public barrel. */
9
+ export const TEST_WORKSPACE = process.cwd();
10
+ export function promptFor(overrides = {}) {
11
+ return assembleSystemPrompt({
12
+ agentName: "jarvis-agent",
13
+ directive: "test task",
14
+ skills: [],
15
+ window: [],
16
+ toolAllowlist: [...ALLOWED_TOOLS],
17
+ toolManifest: "- read_file: read",
18
+ now: new Date("2026-01-01T00:00:00Z"),
19
+ ...overrides,
20
+ });
21
+ }
22
+ export function makeMemory(rootDir = "/tmp/jarvis-test") {
23
+ return new MemoryStore({ rootDir, scope: { workspaceId: "ws-test", userId: "u-test" } });
24
+ }
25
+ export function stubClient(reply) {
26
+ return {
27
+ complete: async () => ({ text: reply(), usage: { inputTokens: 10, outputTokens: 5 } }),
28
+ };
29
+ }
30
+ /** A tool with a real zod args schema, so schema validation is genuinely exercised. */
31
+ export function fakeTool(name, execute) {
32
+ return {
33
+ name,
34
+ description: `fake ${name}`,
35
+ risk: "read",
36
+ argsSchema: z.object({}).passthrough().transform((v) => v),
37
+ execute: () => execute(),
38
+ };
39
+ }
40
+ export function baseInput(overrides = {}) {
41
+ return {
42
+ task: "do something",
43
+ systemPrompt: promptFor(),
44
+ client: stubClient(() => JSON.stringify({ kind: "final", thought: "t", answer: "done" })),
45
+ registry: createToolRegistry({ tools: [] }),
46
+ toolContext: {
47
+ workspaceRoot: TEST_WORKSPACE,
48
+ memory: makeMemory(),
49
+ session: "s1",
50
+ confirm: alwaysDeny,
51
+ limits: { maxReadBytes: 1024, maxResults: 10 },
52
+ },
53
+ maxIterations: 8,
54
+ budget: new RunBudget({
55
+ maxIterations: 8,
56
+ maxInputTokens: 1_000_000,
57
+ maxOutputTokens: 1_000_000,
58
+ maxWallMs: 60_000,
59
+ }),
60
+ isKilled: () => false,
61
+ ...overrides,
62
+ };
63
+ }
64
+ export function finalReply(answer) {
65
+ return JSON.stringify({ kind: "final", thought: "t", answer });
66
+ }
67
+ export function actionReply(tool, args = {}) {
68
+ return JSON.stringify({ kind: "action", thought: "t", tool, args });
69
+ }
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ import type { ToolDescriptor } from "./registry.js";
3
+ export declare function isSecretPath(p: string): boolean;
4
+ /** Convert a glob to a RegExp: `*`, `**`, `?`, `{a,b}` only. */
5
+ export declare function globToRegExp(pattern: string): RegExp;
6
+ declare const ReadFileArgs: z.ZodObject<{
7
+ path: z.ZodString;
8
+ maxBytes: z.ZodOptional<z.ZodNumber>;
9
+ }, z.core.$strip>;
10
+ export declare const readFileTool: ToolDescriptor<z.infer<typeof ReadFileArgs>>;
11
+ declare const GlobArgs: z.ZodObject<{
12
+ pattern: z.ZodString;
13
+ maxResults: z.ZodOptional<z.ZodNumber>;
14
+ }, z.core.$strip>;
15
+ export declare const globTool: ToolDescriptor<z.infer<typeof GlobArgs>>;
16
+ declare const GrepArgs: z.ZodObject<{
17
+ pattern: z.ZodString;
18
+ pathGlob: z.ZodOptional<z.ZodString>;
19
+ maxResults: z.ZodOptional<z.ZodNumber>;
20
+ }, z.core.$strip>;
21
+ export declare const grepTool: ToolDescriptor<z.infer<typeof GrepArgs>>;
22
+ declare const SearchArgs: z.ZodObject<{
23
+ query: z.ZodString;
24
+ maxResults: z.ZodOptional<z.ZodNumber>;
25
+ }, z.core.$strip>;
26
+ export declare const searchWorkspaceTool: ToolDescriptor<z.infer<typeof SearchArgs>>;
27
+ export declare const FS_TOOLS: readonly ToolDescriptor<any>[];
28
+ export {};
@@ -0,0 +1,292 @@
1
+ // xcoder:version 1 | xcoder:task task-f1b5f6 | xcoder:updated 2026-11-05T02:46:50.359Z | xcoder:subtask code-st-53c7b5
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { z } from "zod";
5
+ import { byteLength, normalizeUntrusted, readBounded, redact, resolveInsideRoot, truncate, } from "../util/text.js";
6
+ /**
7
+ * Read-only filesystem tools (design §2.6).
8
+ *
9
+ * Every one of these is confined to the workspace and bounded in both output
10
+ * size and result count. None of them can write, delete, spawn a process or
11
+ * reach the network — those capabilities simply do not exist in this module,
12
+ * so no prompt text can conjure them (PRM-04, LLM08).
13
+ */
14
+ /** Paths whose contents must never enter the model context (PRM-03, LLM06). */
15
+ const SECRET_PATH_PATTERNS = [
16
+ /(^|[\\/])\.env(\.[^.\\/]+)?$/i,
17
+ /(^|[\\/])\.npmrc$/i,
18
+ /(^|[\\/])\.netrc$/i,
19
+ /(^|[\\/])id_(rsa|dsa|ecdsa|ed25519)(\.pub)?$/i,
20
+ /(^|[\\/])[^\\/]*\.(pem|key|p12|pfx|jks)$/i,
21
+ /(^|[\\/])\.ssh([\\/]|$)/i,
22
+ /(^|[\\/])\.aws([\\/]|$)/i,
23
+ /(^|[\\/])\.docker[\\/]config\.json$/i,
24
+ /(^|[\\/])credentials(\.json)?$/i,
25
+ ];
26
+ export function isSecretPath(p) {
27
+ return SECRET_PATH_PATTERNS.some((re) => re.test(p));
28
+ }
29
+ /** Directories never worth walking, and expensive if we did. */
30
+ const SKIP_DIRS = new Set([
31
+ "node_modules",
32
+ ".git",
33
+ "dist",
34
+ "build",
35
+ "coverage",
36
+ ".next",
37
+ ".turbo",
38
+ ".venv",
39
+ ]);
40
+ function aborted(ctx) {
41
+ if (ctx.signal?.aborted) {
42
+ return { ok: false, summary: "aborted by kill switch", bytes: 0, truncated: false };
43
+ }
44
+ return undefined;
45
+ }
46
+ /** Convert a glob to a RegExp: `*`, `**`, `?`, `{a,b}` only. */
47
+ export function globToRegExp(pattern) {
48
+ let out = "";
49
+ let i = 0;
50
+ while (i < pattern.length) {
51
+ const ch = pattern[i];
52
+ if (ch === "*") {
53
+ if (pattern[i + 1] === "*") {
54
+ out += ".*";
55
+ i += 2;
56
+ if (pattern[i] === "/")
57
+ i += 1;
58
+ continue;
59
+ }
60
+ out += "[^/]*";
61
+ i += 1;
62
+ continue;
63
+ }
64
+ if (ch === "?") {
65
+ out += "[^/]";
66
+ i += 1;
67
+ continue;
68
+ }
69
+ if (ch === "{") {
70
+ const close = pattern.indexOf("}", i);
71
+ if (close !== -1) {
72
+ const alts = pattern
73
+ .slice(i + 1, close)
74
+ .split(",")
75
+ .map((a) => a.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
76
+ out += `(?:${alts.join("|")})`;
77
+ i = close + 1;
78
+ continue;
79
+ }
80
+ }
81
+ out += ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
82
+ i += 1;
83
+ }
84
+ return new RegExp(`^${out}$`);
85
+ }
86
+ async function walk(root, maxFiles) {
87
+ const out = [];
88
+ const stack = [root];
89
+ while (stack.length > 0 && out.length < maxFiles) {
90
+ const dir = stack.pop();
91
+ let entries;
92
+ try {
93
+ entries = await fsp.readdir(dir, { withFileTypes: true });
94
+ }
95
+ catch {
96
+ continue;
97
+ }
98
+ for (const e of entries) {
99
+ if (out.length >= maxFiles)
100
+ break;
101
+ const full = path.join(dir, e.name);
102
+ if (e.isDirectory()) {
103
+ if (SKIP_DIRS.has(e.name))
104
+ continue;
105
+ stack.push(full);
106
+ }
107
+ else if (e.isFile() && !isSecretPath(full)) {
108
+ out.push(full);
109
+ }
110
+ }
111
+ }
112
+ return out;
113
+ }
114
+ const ReadFileArgs = z.object({
115
+ path: z.string().min(1).max(1_000),
116
+ maxBytes: z.number().int().positive().max(1_048_576).optional(),
117
+ });
118
+ export const readFileTool = {
119
+ name: "read_file",
120
+ description: "Read a UTF-8 text file inside the workspace. Args: { path }. Secret-bearing paths (dotenv, keys, ssh, npmrc, credentials) are refused.",
121
+ risk: "read",
122
+ argsSchema: ReadFileArgs,
123
+ async execute(args, ctx) {
124
+ const stopped = aborted(ctx);
125
+ if (stopped)
126
+ return stopped;
127
+ if (isSecretPath(args.path)) {
128
+ return {
129
+ ok: false,
130
+ summary: `refused: ${args.path} is on the secret-path denylist`,
131
+ bytes: 0,
132
+ truncated: false,
133
+ };
134
+ }
135
+ const resolved = await resolveInsideRoot(ctx.workspaceRoot, args.path);
136
+ if (!resolved.ok) {
137
+ return { ok: false, summary: `refused: ${resolved.reason}`, bytes: 0, truncated: false };
138
+ }
139
+ if (isSecretPath(resolved.path)) {
140
+ return {
141
+ ok: false,
142
+ summary: "refused: resolved path is on the secret-path denylist",
143
+ bytes: 0,
144
+ truncated: false,
145
+ };
146
+ }
147
+ const cap = Math.min(args.maxBytes ?? ctx.limits.maxReadBytes, ctx.limits.maxReadBytes);
148
+ const read = await readBounded(resolved.path, cap);
149
+ if (!read.ok) {
150
+ return { ok: false, summary: `refused: ${read.reason}`, bytes: 0, truncated: false };
151
+ }
152
+ const cleaned = redact(normalizeUntrusted(read.text)).text;
153
+ const trimmed = truncate(cleaned, 20_000);
154
+ return {
155
+ ok: true,
156
+ summary: trimmed.text,
157
+ bytes: read.bytes,
158
+ truncated: read.truncated || trimmed.truncated,
159
+ data: { path: args.path, bytes: read.bytes },
160
+ };
161
+ },
162
+ };
163
+ const GlobArgs = z.object({
164
+ pattern: z.string().min(1).max(200),
165
+ maxResults: z.number().int().positive().max(200).optional(),
166
+ });
167
+ export const globTool = {
168
+ name: "glob",
169
+ description: "List workspace file paths matching a glob. Supports *, **, ? and {a,b} only. Args: { pattern, maxResults? }.",
170
+ risk: "read",
171
+ argsSchema: GlobArgs,
172
+ async execute(args, ctx) {
173
+ const stopped = aborted(ctx);
174
+ if (stopped)
175
+ return stopped;
176
+ const limit = Math.min(args.maxResults ?? 100, ctx.limits.maxResults);
177
+ const re = globToRegExp(args.pattern);
178
+ const files = await walk(ctx.workspaceRoot, 20_000);
179
+ const matches = [];
180
+ for (const f of files) {
181
+ const rel = path.relative(ctx.workspaceRoot, f).split(path.sep).join("/");
182
+ if (re.test(rel) || re.test(path.basename(rel))) {
183
+ matches.push(rel);
184
+ if (matches.length >= limit)
185
+ break;
186
+ }
187
+ }
188
+ return {
189
+ ok: true,
190
+ summary: matches.length > 0 ? matches.join("\n") : "no files matched",
191
+ bytes: byteLength(matches.join("\n")),
192
+ truncated: matches.length >= limit,
193
+ data: { count: matches.length },
194
+ };
195
+ },
196
+ };
197
+ const GrepArgs = z.object({
198
+ pattern: z.string().min(1).max(200),
199
+ pathGlob: z.string().min(1).max(200).optional(),
200
+ maxResults: z.number().int().positive().max(500).optional(),
201
+ });
202
+ export const grepTool = {
203
+ name: "grep",
204
+ description: "Search workspace file contents for a regular expression. Binary files are skipped. Args: { pattern, pathGlob?, maxResults? }.",
205
+ risk: "read",
206
+ argsSchema: GrepArgs,
207
+ async execute(args, ctx) {
208
+ const stopped = aborted(ctx);
209
+ if (stopped)
210
+ return stopped;
211
+ let re;
212
+ try {
213
+ re = new RegExp(args.pattern, "i");
214
+ }
215
+ catch {
216
+ return { ok: false, summary: "refused: pattern is not a valid regex", bytes: 0, truncated: false };
217
+ }
218
+ const limit = Math.min(args.maxResults ?? 100, ctx.limits.maxResults);
219
+ const pathFilter = args.pathGlob ? globToRegExp(args.pathGlob) : undefined;
220
+ const files = await walk(ctx.workspaceRoot, 20_000);
221
+ const hits = [];
222
+ for (const file of files) {
223
+ if (ctx.signal?.aborted)
224
+ break;
225
+ if (hits.length >= limit)
226
+ break;
227
+ const rel = path.relative(ctx.workspaceRoot, file).split(path.sep).join("/");
228
+ if (pathFilter && !pathFilter.test(rel) && !pathFilter.test(path.basename(rel)))
229
+ continue;
230
+ const read = await readBounded(file, 256_000);
231
+ if (!read.ok)
232
+ continue;
233
+ if (read.text.includes("\u0000"))
234
+ continue; // binary
235
+ const lines = read.text.split(/\r?\n/);
236
+ for (let i = 0; i < lines.length; i += 1) {
237
+ if (!re.test(lines[i]))
238
+ continue;
239
+ hits.push(`${rel}:${i + 1}: ${redact(lines[i]).text.slice(0, 300)}`);
240
+ if (hits.length >= limit)
241
+ break;
242
+ }
243
+ }
244
+ return {
245
+ ok: true,
246
+ summary: hits.length > 0 ? hits.join("\n") : "no matches",
247
+ bytes: byteLength(hits.join("\n")),
248
+ truncated: hits.length >= limit,
249
+ data: { count: hits.length },
250
+ };
251
+ },
252
+ };
253
+ const SearchArgs = z.object({
254
+ query: z.string().min(1).max(200),
255
+ maxResults: z.number().int().positive().max(200).optional(),
256
+ });
257
+ export const searchWorkspaceTool = {
258
+ name: "search_workspace",
259
+ description: "Search workspace file PATHS for a substring (cheap path index lookup, not content). Args: { query, maxResults? }.",
260
+ risk: "read",
261
+ argsSchema: SearchArgs,
262
+ async execute(args, ctx) {
263
+ const stopped = aborted(ctx);
264
+ if (stopped)
265
+ return stopped;
266
+ const limit = Math.min(args.maxResults ?? 100, ctx.limits.maxResults);
267
+ const needle = args.query.toLowerCase();
268
+ const files = await walk(ctx.workspaceRoot, 20_000);
269
+ const matches = [];
270
+ for (const f of files) {
271
+ const rel = path.relative(ctx.workspaceRoot, f).split(path.sep).join("/");
272
+ if (rel.toLowerCase().includes(needle)) {
273
+ matches.push(rel);
274
+ if (matches.length >= limit)
275
+ break;
276
+ }
277
+ }
278
+ return {
279
+ ok: true,
280
+ summary: matches.length > 0 ? matches.join("\n") : "no paths matched",
281
+ bytes: byteLength(matches.join("\n")),
282
+ truncated: matches.length >= limit,
283
+ data: { count: matches.length },
284
+ };
285
+ },
286
+ };
287
+ export const FS_TOOLS = [
288
+ readFileTool,
289
+ globTool,
290
+ grepTool,
291
+ searchWorkspaceTool,
292
+ ];