@ikarmic/forge-core 5.0.0 → 5.1.1

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 (163) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent.d.ts +8 -0
  3. package/dist/agent/agent.js +148 -24
  4. package/dist/agent/background.d.ts +58 -0
  5. package/dist/agent/background.js +139 -0
  6. package/dist/agent/enhance-prompt.d.ts +3 -1
  7. package/dist/agent/enhance-prompt.js +6 -2
  8. package/dist/agent/goal.d.ts +54 -0
  9. package/dist/agent/goal.js +65 -0
  10. package/dist/agent/prompts.d.ts +25 -1
  11. package/dist/agent/prompts.js +61 -17
  12. package/dist/agents/agent-files.d.ts +43 -0
  13. package/dist/agents/agent-files.js +109 -0
  14. package/dist/checkpoints/store.js +5 -1
  15. package/dist/commands/custom-commands.d.ts +9 -4
  16. package/dist/commands/custom-commands.js +13 -2
  17. package/dist/config-file.d.ts +43 -0
  18. package/dist/config-file.js +249 -5
  19. package/dist/config-merge.d.ts +13 -0
  20. package/dist/config-merge.js +67 -0
  21. package/dist/config-schema.d.ts +340 -0
  22. package/dist/config-schema.js +126 -0
  23. package/dist/config.js +2 -1
  24. package/dist/context/compaction.js +4 -0
  25. package/dist/context/mask.js +1 -1
  26. package/dist/custom-providers.d.ts +95 -0
  27. package/dist/custom-providers.js +120 -0
  28. package/dist/git/auto-commit.d.ts +2 -2
  29. package/dist/git/auto-commit.js +5 -4
  30. package/dist/github/github-tools.d.ts +2 -2
  31. package/dist/hooks/runner.js +3 -2
  32. package/dist/import/claude-code.d.ts +39 -0
  33. package/dist/import/claude-code.js +254 -0
  34. package/dist/index.d.ts +34 -5
  35. package/dist/index.js +27 -4
  36. package/dist/journey/answers-block.d.ts +17 -0
  37. package/dist/journey/answers-block.js +15 -0
  38. package/dist/journey/index.d.ts +9 -0
  39. package/dist/journey/index.js +8 -0
  40. package/dist/journey/journey.d.ts +13 -0
  41. package/dist/journey/journey.js +40 -0
  42. package/dist/journey/offer.d.ts +25 -0
  43. package/dist/journey/offer.js +48 -0
  44. package/dist/journey/questions.d.ts +23 -0
  45. package/dist/journey/questions.js +49 -0
  46. package/dist/journey/stages.d.ts +13 -0
  47. package/dist/journey/stages.js +107 -0
  48. package/dist/journey/survey.d.ts +10 -0
  49. package/dist/journey/survey.js +203 -0
  50. package/dist/journey/trigger.d.ts +19 -0
  51. package/dist/journey/trigger.js +79 -0
  52. package/dist/journey/types.d.ts +72 -0
  53. package/dist/journey/types.js +1 -0
  54. package/dist/lsp/client.d.ts +5 -0
  55. package/dist/lsp/client.js +39 -3
  56. package/dist/mcp/index.d.ts +5 -1
  57. package/dist/mcp/index.js +3 -1
  58. package/dist/mcp/manager.d.ts +17 -3
  59. package/dist/mcp/manager.js +27 -2
  60. package/dist/mcp/marketplace.d.ts +2 -0
  61. package/dist/mcp/marketplace.js +10 -4
  62. package/dist/mcp/mcp-client.d.ts +12 -2
  63. package/dist/mcp/mcp-client.js +74 -17
  64. package/dist/mcp/mcp-config.d.ts +2 -0
  65. package/dist/mcp/mcp-config.js +1 -0
  66. package/dist/mcp/oauth.d.ts +55 -0
  67. package/dist/mcp/oauth.js +197 -0
  68. package/dist/mcp/resource-tools.d.ts +8 -0
  69. package/dist/mcp/resource-tools.js +56 -0
  70. package/dist/memory/session-digest.d.ts +12 -0
  71. package/dist/memory/session-digest.js +47 -0
  72. package/dist/memory/stale.js +2 -1
  73. package/dist/model-alias.d.ts +18 -2
  74. package/dist/model-alias.js +27 -3
  75. package/dist/model-catalog.js +0 -1
  76. package/dist/model-list.js +2 -1
  77. package/dist/model-overrides.d.ts +30 -0
  78. package/dist/model-overrides.js +48 -0
  79. package/dist/orchestrator/specialists-registry.js +2 -3
  80. package/dist/orchestrator/specialists.js +4 -4
  81. package/dist/policy/ca-certs.d.ts +8 -0
  82. package/dist/policy/ca-certs.js +30 -0
  83. package/dist/policy/proxy.d.ts +10 -3
  84. package/dist/policy/proxy.js +33 -6
  85. package/dist/pricing.js +4 -0
  86. package/dist/providers.d.ts +7 -0
  87. package/dist/providers.js +39 -5
  88. package/dist/semantic/embedder.d.ts +15 -0
  89. package/dist/semantic/embedder.js +25 -0
  90. package/dist/semantic/semantic-index.d.ts +10 -2
  91. package/dist/semantic/semantic-index.js +16 -7
  92. package/dist/sessions/session-store.js +2 -1
  93. package/dist/skills/builtin.d.ts +2 -0
  94. package/dist/skills/builtin.js +68 -0
  95. package/dist/skills/index.d.ts +2 -0
  96. package/dist/skills/index.js +2 -0
  97. package/dist/skills/loader.d.ts +10 -3
  98. package/dist/skills/loader.js +20 -7
  99. package/dist/skills/marketplace.js +2 -1
  100. package/dist/skills/remote.d.ts +14 -0
  101. package/dist/skills/remote.js +131 -0
  102. package/dist/skills/types.d.ts +10 -0
  103. package/dist/specs/generate.d.ts +18 -9
  104. package/dist/specs/generate.js +174 -14
  105. package/dist/specs/index.d.ts +2 -2
  106. package/dist/specs/index.js +1 -1
  107. package/dist/specs/store.d.ts +14 -2
  108. package/dist/specs/store.js +26 -1
  109. package/dist/specs/types.d.ts +11 -0
  110. package/dist/steering/extra-instructions.d.ts +8 -0
  111. package/dist/steering/extra-instructions.js +104 -0
  112. package/dist/steering/folder-instructions.d.ts +8 -0
  113. package/dist/steering/folder-instructions.js +28 -0
  114. package/dist/steering/select.js +2 -1
  115. package/dist/timed-fetch.d.ts +12 -0
  116. package/dist/timed-fetch.js +83 -0
  117. package/dist/tools/apply-patch.d.ts +52 -0
  118. package/dist/tools/apply-patch.js +197 -0
  119. package/dist/tools/ask-user.d.ts +27 -0
  120. package/dist/tools/ask-user.js +60 -0
  121. package/dist/tools/browser-tools.d.ts +6 -6
  122. package/dist/tools/browser-tools.js +3 -13
  123. package/dist/tools/codebase-search.d.ts +4 -2
  124. package/dist/tools/codebase-search.js +2 -2
  125. package/dist/tools/edit-file.js +5 -3
  126. package/dist/tools/fetch-url.d.ts +7 -0
  127. package/dist/tools/fetch-url.js +52 -19
  128. package/dist/tools/find-symbol.d.ts +2 -2
  129. package/dist/tools/ignore-rules.js +2 -1
  130. package/dist/tools/index.d.ts +5 -1
  131. package/dist/tools/index.js +10 -1
  132. package/dist/tools/memory.d.ts +6 -6
  133. package/dist/tools/notebook.d.ts +2 -2
  134. package/dist/tools/permission-rules.d.ts +32 -0
  135. package/dist/tools/permission-rules.js +182 -0
  136. package/dist/tools/read-file.d.ts +5 -0
  137. package/dist/tools/read-file.js +12 -2
  138. package/dist/tools/read-only.d.ts +1 -1
  139. package/dist/tools/read-only.js +2 -0
  140. package/dist/tools/run-command.d.ts +4 -2
  141. package/dist/tools/run-command.js +12 -8
  142. package/dist/tools/sandbox-loader.js +3 -13
  143. package/dist/tools/search-files.d.ts +8 -2
  144. package/dist/tools/search-files.js +43 -0
  145. package/dist/tools/shared.d.ts +5 -0
  146. package/dist/tools/shared.js +30 -2
  147. package/dist/tools/shell-env.d.ts +7 -0
  148. package/dist/tools/shell-env.js +9 -0
  149. package/dist/tools/sub-agent-wait.d.ts +29 -0
  150. package/dist/tools/sub-agent-wait.js +60 -0
  151. package/dist/tools/sub-agent.d.ts +50 -4
  152. package/dist/tools/sub-agent.js +213 -125
  153. package/dist/tools/text-encoding.d.ts +10 -0
  154. package/dist/tools/text-encoding.js +40 -0
  155. package/dist/tools/todo.d.ts +36 -0
  156. package/dist/tools/todo.js +42 -0
  157. package/dist/tools/write-file.js +15 -9
  158. package/dist/tools/write-tools.d.ts +12 -0
  159. package/dist/tools/write-tools.js +18 -0
  160. package/dist/types.d.ts +98 -0
  161. package/dist/worktree/worktree.d.ts +3 -0
  162. package/dist/worktree/worktree.js +22 -0
  163. package/package.json +1 -1
@@ -134,6 +134,12 @@ export class SpecStore {
134
134
  draftDir(id) {
135
135
  return path.join(this.root, DRAFTS_DIR, id);
136
136
  }
137
+ /**
138
+ * A draft may be created before its tasks exist: the journey's Spec stage
139
+ * writes requirements and design, and `tasks.md` arrives from the ImplPlan
140
+ * stage behind its own gate (`writeDraftTasks`). The one-shot `/spec` path
141
+ * passes all three and is unaffected.
142
+ */
137
143
  async createDraft(id, task, sections) {
138
144
  assertSafeSpecId(id);
139
145
  const dir = this.draftDir(id);
@@ -142,11 +148,24 @@ export class SpecStore {
142
148
  await mkdir(dir, { recursive: true });
143
149
  await writeFile(path.join(dir, 'requirements.md'), sections.requirements, 'utf8');
144
150
  await writeFile(path.join(dir, 'design.md'), sections.design, 'utf8');
145
- await writeFile(path.join(dir, 'tasks.md'), sections.tasks, 'utf8');
151
+ if (sections.tasks !== undefined)
152
+ await writeFile(path.join(dir, 'tasks.md'), sections.tasks, 'utf8');
146
153
  const createdAt = new Date().toISOString();
147
154
  await writeFile(path.join(dir, DRAFT_FILENAME), JSON.stringify({ id, task, createdAt }, null, 2), 'utf8');
148
155
  return { id, task, createdAt, dir };
149
156
  }
157
+ /**
158
+ * Write (or replace) a draft's `tasks.md`, which is the ImplPlan stage's
159
+ * artefact. `writeFile` truncates, so running the stage again after the person
160
+ * chose "Revise them" replaces the list rather than appending to it.
161
+ */
162
+ async writeDraftTasks(id, tasks) {
163
+ assertSafeSpecId(id);
164
+ const draft = await this.getDraft(id);
165
+ if (!draft)
166
+ throw new Error(`no draft ${id}`);
167
+ await writeFile(path.join(draft.dir, 'tasks.md'), tasks, 'utf8');
168
+ }
150
169
  async getDraft(id) {
151
170
  if (!isSafeSpecId(id))
152
171
  return null;
@@ -187,6 +206,12 @@ export class SpecStore {
187
206
  // Finish the spec inside the draft folder, then move it into place with one rename,
188
207
  // so a spec directory never exists half-written.
189
208
  const tasksPath = path.join(draft.dir, 'tasks.md');
209
+ // A journey draft has no tasks until the ImplPlan stage has run. Approving
210
+ // then would produce a spec the Specs tree shows with nothing to run, so say
211
+ // what is missing instead.
212
+ if (!existsSync(tasksPath)) {
213
+ throw new Error(`draft ${id} has no tasks.md: the implementation plan stage has not run yet`);
214
+ }
190
215
  await writeFile(tasksPath, convertToCheckboxes(await readFile(tasksPath, 'utf8')), 'utf8');
191
216
  const summary = { id, task: draft.task, approvedAt: new Date().toISOString(), dir: specDir };
192
217
  await writeFile(path.join(draft.dir, MANIFEST_FILENAME), JSON.stringify(summary, null, 2), 'utf8');
@@ -4,6 +4,17 @@ export interface SpecSections {
4
4
  design: string;
5
5
  tasks: string;
6
6
  }
7
+ /**
8
+ * A draft mid-journey. The Spec stage writes requirements and design; `tasks`
9
+ * arrives later, from the ImplPlan stage, behind its own gate. `SpecSections`
10
+ * deliberately stays all-required: the one-shot `/spec` path produces all three
11
+ * at once and nothing about it changes.
12
+ */
13
+ export interface SpecDraftSections {
14
+ requirements: string;
15
+ design: string;
16
+ tasks?: string;
17
+ }
7
18
  /** An unapproved spec in `.forge/specs/.drafts/<id>/`. */
8
19
  export interface SpecDraft {
9
20
  id: string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The `instructions` setting: extra files, globs (relative to the workspace) and http(s) URLs whose text is added to
3
+ * the system prompt. Anything unreadable is skipped. Only the user's own config supplies these (a project file
4
+ * could otherwise point the model at text from anywhere), and the caller enforces that.
5
+ */
6
+ export declare function buildExtraInstructionsBlock(cwd: string, entries: readonly string[], deps?: {
7
+ fetch?: typeof fetch;
8
+ }): Promise<string>;
@@ -0,0 +1,104 @@
1
+ import { readFile, readdir } from 'node:fs/promises';
2
+ import { homedir } from 'node:os';
3
+ import path from 'node:path';
4
+ import { pathMatchesGlob } from './glob.js';
5
+ const PER_SOURCE_BYTES = 12_000;
6
+ const TOTAL_BYTES = 48_000;
7
+ const MAX_WALK_FILES = 5_000;
8
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'out', 'build', '.next', 'target']);
9
+ const URL_TIMEOUT_MS = 10_000;
10
+ const isGlob = (s) => /[*?[]/.test(s);
11
+ const isUrl = (s) => /^[a-z][a-z0-9+.-]*:\/\//i.test(s);
12
+ async function walk(root) {
13
+ const out = [];
14
+ async function visit(dir, rel, depth) {
15
+ if (depth > 8 || out.length >= MAX_WALK_FILES)
16
+ return;
17
+ let entries;
18
+ try {
19
+ entries = await readdir(dir, { withFileTypes: true });
20
+ }
21
+ catch {
22
+ return;
23
+ }
24
+ entries.sort((a, b) => a.name.localeCompare(b.name));
25
+ for (const e of entries) {
26
+ if (out.length >= MAX_WALK_FILES)
27
+ return;
28
+ const r = rel ? `${rel}/${e.name}` : e.name;
29
+ if (e.isDirectory()) {
30
+ if (!SKIP_DIRS.has(e.name))
31
+ await visit(path.join(dir, e.name), r, depth + 1);
32
+ }
33
+ else if (e.isFile())
34
+ out.push(r);
35
+ }
36
+ }
37
+ await visit(root, '', 0);
38
+ return out;
39
+ }
40
+ const clip = (text) => (text.length > PER_SOURCE_BYTES ? `${text.slice(0, PER_SOURCE_BYTES)}\n…(truncated)` : text);
41
+ /**
42
+ * The `instructions` setting: extra files, globs (relative to the workspace) and http(s) URLs whose text is added to
43
+ * the system prompt. Anything unreadable is skipped. Only the user's own config supplies these (a project file
44
+ * could otherwise point the model at text from anywhere), and the caller enforces that.
45
+ */
46
+ export async function buildExtraInstructionsBlock(cwd, entries, deps = {}) {
47
+ const sources = [];
48
+ const seen = new Set();
49
+ const addFile = (label, abs) => {
50
+ const key = path.resolve(abs);
51
+ if (seen.has(key))
52
+ return;
53
+ seen.add(key);
54
+ sources.push({ label, read: () => readFile(abs, 'utf8') });
55
+ };
56
+ let walked;
57
+ for (const raw of entries) {
58
+ const entry = raw.trim();
59
+ if (!entry)
60
+ continue;
61
+ if (isUrl(entry)) {
62
+ if (!/^https?:\/\//i.test(entry) || seen.has(entry))
63
+ continue;
64
+ seen.add(entry);
65
+ sources.push({
66
+ label: entry,
67
+ read: async () => {
68
+ const res = await (deps.fetch ?? fetch)(entry, { signal: AbortSignal.timeout(URL_TIMEOUT_MS) });
69
+ if (!res.ok)
70
+ throw new Error(`HTTP ${res.status}`);
71
+ return (await res.text()).slice(0, PER_SOURCE_BYTES * 2);
72
+ },
73
+ });
74
+ }
75
+ else if (isGlob(entry) && !path.isAbsolute(entry) && !entry.startsWith('~')) {
76
+ walked ??= await walk(cwd);
77
+ for (const rel of walked.filter((f) => pathMatchesGlob(entry, f)))
78
+ addFile(rel, path.join(cwd, rel));
79
+ }
80
+ else {
81
+ const abs = entry.startsWith('~') ? path.join(homedir(), entry.slice(1)) : path.resolve(cwd, entry);
82
+ addFile(entry, abs);
83
+ }
84
+ }
85
+ const blocks = [];
86
+ let total = 0;
87
+ for (const s of sources) {
88
+ if (total >= TOTAL_BYTES)
89
+ break;
90
+ let text;
91
+ try {
92
+ text = (await s.read()).trim();
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ if (!text)
98
+ continue;
99
+ const body = clip(text);
100
+ total += body.length;
101
+ blocks.push(`<instructions source="${s.label.replace(/"/g, '')}">\n${body}\n</instructions>`);
102
+ }
103
+ return blocks.join('\n\n');
104
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The `AGENTS.md` of every folder between the workspace root and the file just read (nearest first), each shown
3
+ * once per run. The root one is already in the system prompt, so it is never repeated here.
4
+ */
5
+ export declare function folderInstructionsFor(filePath: string, cwd: string, shown: Set<string>): Promise<Array<{
6
+ file: string;
7
+ text: string;
8
+ }> | undefined>;
@@ -0,0 +1,28 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ const MAX_BYTES = 8_000;
4
+ /**
5
+ * The `AGENTS.md` of every folder between the workspace root and the file just read (nearest first), each shown
6
+ * once per run. The root one is already in the system prompt, so it is never repeated here.
7
+ */
8
+ export async function folderInstructionsFor(filePath, cwd, shown) {
9
+ const root = path.resolve(cwd);
10
+ let dir = path.dirname(path.resolve(filePath));
11
+ const out = [];
12
+ while (dir !== root && dir.startsWith(root + path.sep)) {
13
+ const abs = path.join(dir, 'AGENTS.md');
14
+ if (!shown.has(abs)) {
15
+ try {
16
+ const text = (await readFile(abs, 'utf8')).trim();
17
+ shown.add(abs);
18
+ if (text)
19
+ out.push({ file: path.relative(root, abs), text: text.length > MAX_BYTES ? `${text.slice(0, MAX_BYTES)}\n…(truncated)` : text });
20
+ }
21
+ catch {
22
+ shown.add(abs);
23
+ }
24
+ }
25
+ dir = path.dirname(dir);
26
+ }
27
+ return out.length ? out : undefined;
28
+ }
@@ -4,6 +4,7 @@
4
4
  */
5
5
  import path from 'node:path';
6
6
  import { pathMatchesGlob } from './glob.js';
7
+ import { isOutsideRel } from '../tools/shared.js';
7
8
  export const START_BUDGET_BYTES = 12_000;
8
9
  export const TOOL_DOC_BYTES = 8_000;
9
10
  export const MAX_TOOL_INJECTIONS = 3;
@@ -19,7 +20,7 @@ export function stripSteering(text) {
19
20
  export function toWorkspaceRelative(cwd, p) {
20
21
  const abs = path.resolve(cwd, p);
21
22
  const rel = path.relative(cwd, abs);
22
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel))
23
+ if (!rel || isOutsideRel(rel))
23
24
  return null;
24
25
  return rel.split(path.sep).join('/');
25
26
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Wraps `fetch` with the two timeouts Kilo calls `timeout` and `chunkTimeout`:
3
+ * - `timeoutMs`: how long to wait for the response to start (headers). It stops counting once the answer begins,
4
+ * so a slow stream is never cut short by it.
5
+ * - `chunkTimeoutMs`: how long a streamed answer may stay silent. A stalled stream is aborted with a clear error
6
+ * instead of hanging forever (a connection can stay open while the provider has stopped sending).
7
+ */
8
+ export interface FetchTimeouts {
9
+ timeoutMs?: number;
10
+ chunkTimeoutMs?: number;
11
+ }
12
+ export declare function withTimeouts(base: typeof fetch, opts: FetchTimeouts): typeof fetch;
@@ -0,0 +1,83 @@
1
+ export function withTimeouts(base, opts) {
2
+ const { timeoutMs, chunkTimeoutMs } = opts;
3
+ if (!timeoutMs && !chunkTimeoutMs)
4
+ return base;
5
+ return (async (input, init) => {
6
+ const controller = new AbortController();
7
+ const outer = init?.signal;
8
+ if (outer?.aborted)
9
+ controller.abort();
10
+ else
11
+ outer?.addEventListener('abort', () => controller.abort(), { once: true });
12
+ let reason;
13
+ const host = (() => {
14
+ try {
15
+ return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).host;
16
+ }
17
+ catch {
18
+ return 'the provider';
19
+ }
20
+ })();
21
+ const fail = (message) => {
22
+ reason = new Error(message);
23
+ controller.abort();
24
+ };
25
+ const headerTimer = timeoutMs ? setTimeout(() => fail(`The request to ${host} timed out after ${timeoutMs} ms.`), timeoutMs) : undefined;
26
+ let res;
27
+ try {
28
+ res = await base(input, { ...init, signal: controller.signal });
29
+ }
30
+ catch (err) {
31
+ throw reason ?? err;
32
+ }
33
+ finally {
34
+ if (headerTimer)
35
+ clearTimeout(headerTimer);
36
+ }
37
+ if (!chunkTimeoutMs || !res.body)
38
+ return res;
39
+ const reader = res.body.getReader();
40
+ let idle;
41
+ const arm = (onIdle) => {
42
+ if (idle)
43
+ clearTimeout(idle);
44
+ idle = setTimeout(onIdle, chunkTimeoutMs);
45
+ };
46
+ const body = new ReadableStream({
47
+ start(stream) {
48
+ arm(() => {
49
+ fail(`No data for ${chunkTimeoutMs} ms from ${host}.`);
50
+ stream.error(reason);
51
+ });
52
+ },
53
+ async pull(stream) {
54
+ try {
55
+ const { done, value } = await reader.read();
56
+ if (done) {
57
+ if (idle)
58
+ clearTimeout(idle);
59
+ stream.close();
60
+ return;
61
+ }
62
+ arm(() => {
63
+ fail(`No data for ${chunkTimeoutMs} ms from ${host}.`);
64
+ stream.error(reason);
65
+ void reader.cancel().catch(() => undefined);
66
+ });
67
+ stream.enqueue(value);
68
+ }
69
+ catch (err) {
70
+ if (idle)
71
+ clearTimeout(idle);
72
+ stream.error(reason ?? err);
73
+ }
74
+ },
75
+ cancel(why) {
76
+ if (idle)
77
+ clearTimeout(idle);
78
+ return reader.cancel(why);
79
+ },
80
+ });
81
+ return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
82
+ });
83
+ }
@@ -0,0 +1,52 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ declare const params: z.ZodObject<{
4
+ patch: z.ZodString;
5
+ }, "strip", z.ZodTypeAny, {
6
+ patch: string;
7
+ }, {
8
+ patch: string;
9
+ }>;
10
+ export type PatchOp = {
11
+ kind: 'add';
12
+ path: string;
13
+ content: string;
14
+ } | {
15
+ kind: 'delete';
16
+ path: string;
17
+ } | {
18
+ kind: 'update';
19
+ path: string;
20
+ hunks: Array<{
21
+ lines: Array<{
22
+ tag: ' ' | '-' | '+';
23
+ text: string;
24
+ }>;
25
+ }>;
26
+ };
27
+ /** Parse the patch text into operations. Throws on a malformed patch. */
28
+ export declare function parsePatch(text: string): PatchOp[];
29
+ /** Every path a patch touches, once each; empty when the text is not a valid patch (callers then fall back to the broadest target). */
30
+ export declare function patchedPaths(patch: string): string[];
31
+ export interface PatchPreview {
32
+ path: string;
33
+ /** Text before the patch; null when the file does not exist yet. */
34
+ before: string | null;
35
+ /** Text after the patch; null when the patch deletes the file. */
36
+ after: string | null;
37
+ /** Why this file's change could not be worked out (a hunk that does not match). */
38
+ error?: string;
39
+ }
40
+ /**
41
+ * What an `apply_patch` call would leave in each file, worked out in memory so it can be shown before it is approved.
42
+ * Never writes. Throws only for a patch that cannot be parsed at all.
43
+ */
44
+ export declare function previewPatch(patch: string, read: (path: string) => Promise<string | null>): Promise<PatchPreview[]>;
45
+ export interface ApplyPatchResult {
46
+ files: Array<{
47
+ action: 'add' | 'update' | 'delete';
48
+ path: string;
49
+ }>;
50
+ }
51
+ export declare const applyPatchTool: ToolDefinition<z.infer<typeof params>, ApplyPatchResult>;
52
+ export {};
@@ -0,0 +1,197 @@
1
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { resolveAndCheckPath } from './shared.js';
5
+ import { decodeText, encodeText } from './text-encoding.js';
6
+ const params = z.object({
7
+ patch: z
8
+ .string()
9
+ .min(1)
10
+ .describe('The patch. Starts with "*** Begin Patch", ends with "*** End Patch". Between them, per file: "*** Add File: path" followed by lines starting with +; "*** Delete File: path"; or "*** Update File: path" followed by hunks that begin with "@@" and whose lines start with a space (kept), - (removed) or + (added).'),
11
+ });
12
+ /** Parse the patch text into operations. Throws on a malformed patch. */
13
+ export function parsePatch(text) {
14
+ const lines = text.replace(/\r\n/g, '\n').split('\n');
15
+ while (lines.length && lines[0].trim() === '')
16
+ lines.shift();
17
+ while (lines.length && lines[lines.length - 1].trim() === '')
18
+ lines.pop();
19
+ if (lines[0]?.trim() !== '*** Begin Patch' || lines[lines.length - 1]?.trim() !== '*** End Patch') {
20
+ throw new Error('A patch must start with "*** Begin Patch" and end with "*** End Patch".');
21
+ }
22
+ const body = lines.slice(1, -1);
23
+ const ops = [];
24
+ let i = 0;
25
+ while (i < body.length) {
26
+ const line = body[i];
27
+ if (line.trim() === '') {
28
+ i++;
29
+ continue;
30
+ }
31
+ const add = /^\*\*\* Add File: (.+)$/.exec(line);
32
+ const del = /^\*\*\* Delete File: (.+)$/.exec(line);
33
+ const upd = /^\*\*\* Update File: (.+)$/.exec(line);
34
+ if (add) {
35
+ const content = [];
36
+ i++;
37
+ while (i < body.length && !body[i].startsWith('*** ')) {
38
+ const l = body[i];
39
+ if (!l.startsWith('+'))
40
+ throw new Error(`Add File ${add[1]}: every line must start with + (got "${l.slice(0, 40)}").`);
41
+ content.push(l.slice(1));
42
+ i++;
43
+ }
44
+ ops.push({ kind: 'add', path: add[1].trim(), content: content.length ? `${content.join('\n')}\n` : '' });
45
+ }
46
+ else if (del) {
47
+ ops.push({ kind: 'delete', path: del[1].trim() });
48
+ i++;
49
+ }
50
+ else if (upd) {
51
+ const hunks = [];
52
+ i++;
53
+ while (i < body.length && !body[i].startsWith('*** ')) {
54
+ if (body[i].startsWith('@@')) {
55
+ hunks.push({ lines: [] });
56
+ i++;
57
+ continue;
58
+ }
59
+ const l = body[i];
60
+ const tag = l[0];
61
+ if (tag !== ' ' && tag !== '-' && tag !== '+')
62
+ throw new Error(`Update File ${upd[1]}: bad hunk line "${l.slice(0, 40)}" (start with space, - or +).`);
63
+ if (hunks.length === 0)
64
+ hunks.push({ lines: [] });
65
+ hunks[hunks.length - 1].lines.push({ tag, text: l.slice(1) });
66
+ i++;
67
+ }
68
+ if (hunks.length === 0)
69
+ throw new Error(`Update File ${upd[1]} has no hunks.`);
70
+ ops.push({ kind: 'update', path: upd[1].trim(), hunks });
71
+ }
72
+ else {
73
+ throw new Error(`Unknown patch directive: "${line.slice(0, 60)}"`);
74
+ }
75
+ }
76
+ if (ops.length === 0)
77
+ throw new Error('The patch has no operations.');
78
+ return ops;
79
+ }
80
+ /** Every path a patch touches, once each; empty when the text is not a valid patch (callers then fall back to the broadest target). */
81
+ export function patchedPaths(patch) {
82
+ try {
83
+ return [...new Set(parsePatch(patch).map((o) => o.path))];
84
+ }
85
+ catch {
86
+ return [];
87
+ }
88
+ }
89
+ function indexOfSeq(hay, needle, from, norm) {
90
+ if (needle.length === 0)
91
+ return from;
92
+ outer: for (let s = from; s <= hay.length - needle.length; s++) {
93
+ for (let k = 0; k < needle.length; k++)
94
+ if (norm(hay[s + k]) !== norm(needle[k]))
95
+ continue outer;
96
+ return s;
97
+ }
98
+ return -1;
99
+ }
100
+ /** Apply update hunks to file text, in order, each searching after the previous one. Exact match first, then ignoring trailing whitespace. */
101
+ function applyHunks(file, original, hunks) {
102
+ const eol = original.includes('\r\n') ? '\r\n' : '\n';
103
+ const endsWithNewline = original.endsWith('\n');
104
+ const lines = original.replace(/\r\n/g, '\n').split('\n');
105
+ if (endsWithNewline)
106
+ lines.pop();
107
+ let cursor = 0;
108
+ for (const [n, hunk] of hunks.entries()) {
109
+ const oldSeq = hunk.lines.filter((l) => l.tag !== '+').map((l) => l.text);
110
+ const newSeq = hunk.lines.filter((l) => l.tag !== '-').map((l) => l.text);
111
+ let at = indexOfSeq(lines, oldSeq, cursor, (s) => s);
112
+ if (at === -1)
113
+ at = indexOfSeq(lines, oldSeq, cursor, (s) => s.trimEnd());
114
+ if (at === -1)
115
+ throw new Error(`Hunk ${n + 1} of ${file} did not match the file. Re-read it and make the context lines exact.`);
116
+ lines.splice(at, oldSeq.length, ...newSeq);
117
+ cursor = at + newSeq.length;
118
+ }
119
+ return lines.join(eol) + (endsWithNewline ? eol : '');
120
+ }
121
+ /**
122
+ * What an `apply_patch` call would leave in each file, worked out in memory so it can be shown before it is approved.
123
+ * Never writes. Throws only for a patch that cannot be parsed at all.
124
+ */
125
+ export async function previewPatch(patch, read) {
126
+ const out = [];
127
+ for (const op of parsePatch(patch)) {
128
+ const before = await read(op.path);
129
+ if (op.kind === 'add')
130
+ out.push({ path: op.path, before, after: op.content });
131
+ else if (op.kind === 'delete')
132
+ out.push({ path: op.path, before, after: null });
133
+ else if (before === null)
134
+ out.push({ path: op.path, before, after: null, error: `File not found: ${op.path}` });
135
+ else {
136
+ try {
137
+ out.push({ path: op.path, before, after: applyHunks(op.path, before, op.hunks) });
138
+ }
139
+ catch (err) {
140
+ out.push({ path: op.path, before, after: null, error: err.message });
141
+ }
142
+ }
143
+ }
144
+ return out;
145
+ }
146
+ export const applyPatchTool = {
147
+ name: 'apply_patch',
148
+ description: 'Change several files in one step with a patch: add files, delete files, or update files by hunks of context and changed lines. All or nothing: if one hunk does not match, no file is touched. Prefer edit_file for a single small change.',
149
+ parameters: params,
150
+ destructive: false,
151
+ execute: async (args, ctx) => {
152
+ const ops = parsePatch(args.patch);
153
+ // Plan every change in memory first, so a bad hunk leaves the workspace as it was.
154
+ const writes = new Map();
155
+ const encodings = new Map();
156
+ const deletes = new Set();
157
+ const files = [];
158
+ for (const op of ops) {
159
+ const abs = resolveAndCheckPath(op.path, ctx);
160
+ const rel = op.path.split(String.fromCharCode(92)).join('/');
161
+ if (op.kind === 'add') {
162
+ const exists = await readFile(abs, 'utf8').then(() => true, () => false);
163
+ if ((exists && !deletes.has(abs)) || writes.has(abs))
164
+ throw new Error(`Cannot add ${op.path}: it already exists.`);
165
+ writes.set(abs, op.content);
166
+ files.push({ action: 'add', path: rel });
167
+ }
168
+ else if (op.kind === 'delete') {
169
+ await readFile(abs, 'utf8').catch(() => {
170
+ throw new Error(`Cannot delete ${op.path}: file not found.`);
171
+ });
172
+ deletes.add(abs);
173
+ writes.delete(abs);
174
+ files.push({ action: 'delete', path: rel });
175
+ }
176
+ else {
177
+ let current = writes.get(abs);
178
+ if (current === undefined) {
179
+ const decoded = await readFile(abs).then(decodeText, () => {
180
+ throw new Error(`Cannot update ${op.path}: file not found.`);
181
+ });
182
+ current = decoded.text;
183
+ encodings.set(abs, decoded.encoding);
184
+ }
185
+ writes.set(abs, applyHunks(op.path, current, op.hunks));
186
+ files.push({ action: 'update', path: rel });
187
+ }
188
+ }
189
+ for (const abs of deletes)
190
+ await rm(abs);
191
+ for (const [abs, content] of writes) {
192
+ await mkdir(path.dirname(abs), { recursive: true });
193
+ await writeFile(abs, encodeText(content, encodings.get(abs) ?? 'utf8'));
194
+ }
195
+ return { files };
196
+ },
197
+ };
@@ -0,0 +1,27 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ /**
4
+ * Ask the person a question with optional choices. The agent only offers this tool when a surface can answer
5
+ * (AgentConfig.askUser) and the run is not autonomous; if it is called anyway, or the run is stopped while the
6
+ * question is open, it returns a result that tells the model to carry on with the safest option.
7
+ */
8
+ declare const params: z.ZodObject<{
9
+ question: z.ZodString;
10
+ options: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
11
+ allowCustom: z.ZodOptional<z.ZodBoolean>;
12
+ }, "strip", z.ZodTypeAny, {
13
+ question: string;
14
+ options?: string[] | undefined;
15
+ allowCustom?: boolean | undefined;
16
+ }, {
17
+ question: string;
18
+ options?: string[] | undefined;
19
+ allowCustom?: boolean | undefined;
20
+ }>;
21
+ export interface AskUserResult {
22
+ answer: string;
23
+ unavailable?: boolean;
24
+ note?: string;
25
+ }
26
+ export declare const askUserTool: ToolDefinition<z.infer<typeof params>, AskUserResult>;
27
+ export {};
@@ -0,0 +1,60 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Ask the person a question with optional choices. The agent only offers this tool when a surface can answer
4
+ * (AgentConfig.askUser) and the run is not autonomous; if it is called anyway, or the run is stopped while the
5
+ * question is open, it returns a result that tells the model to carry on with the safest option.
6
+ */
7
+ const params = z.object({
8
+ question: z.string().min(1).max(1000).describe('One clear question.'),
9
+ options: z.array(z.string().min(1).max(200)).max(8).optional().describe('Suggested answers to pick from.'),
10
+ allowCustom: z.boolean().optional().describe('Let the person type their own answer. Default true.'),
11
+ });
12
+ const UNAVAILABLE = {
13
+ answer: '',
14
+ unavailable: true,
15
+ note: 'No user is available. Choose the safest reasonable option and say what you assumed.',
16
+ };
17
+ /**
18
+ * A guided journey asks at most four questions. The budget is enforced here
19
+ * rather than in prompt text for the same reason stage gates live in the tool
20
+ * sandbox: a limit the model is merely told about is a limit it will exceed,
21
+ * and one bad extra question is what makes an interview feel like an
22
+ * interrogation (spec anti-goal 3).
23
+ */
24
+ const BUDGET_SPENT = {
25
+ answer: '',
26
+ unavailable: true,
27
+ note: 'The question budget for this journey is spent: no more questions. Proceed with what you have and say what you assumed.',
28
+ };
29
+ export const askUserTool = {
30
+ name: 'ask_user',
31
+ description: 'Ask the user a clarifying question when you cannot proceed safely without their answer. Give a few options when you can. Do not ask for permission to run tools; approvals are handled separately.',
32
+ parameters: params,
33
+ noApproval: true,
34
+ execute: async (args, ctx) => {
35
+ if (!ctx.askUser || ctx.auto)
36
+ return UNAVAILABLE;
37
+ const signal = ctx.abortSignal;
38
+ if (signal?.aborted)
39
+ return UNAVAILABLE;
40
+ // Counted only once the question is actually put to a surface, so a refused
41
+ // or impossible question never spends part of the budget.
42
+ if (ctx.askBudget) {
43
+ if (ctx.askBudget.used >= ctx.askBudget.max)
44
+ return BUDGET_SPENT;
45
+ ctx.askBudget.used += 1;
46
+ }
47
+ const budget = ctx.askBudget;
48
+ const answered = ctx.askUser(args, signal).then((answer) => {
49
+ // A question the person dismissed resolves to an empty answer and must not
50
+ // cost part of the budget: four dismissals would otherwise end an interview
51
+ // without a single question being answered. Refunding keeps the budget a
52
+ // count of questions actually answered.
53
+ if (budget && answer.trim() === '')
54
+ budget.used = Math.max(0, budget.used - 1);
55
+ return { answer };
56
+ });
57
+ const aborted = new Promise((resolve) => signal?.addEventListener('abort', () => resolve(UNAVAILABLE), { once: true }));
58
+ return Promise.race([answered, aborted]);
59
+ },
60
+ };