@ikarmic/forge-core 5.2.0 → 5.4.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 (129) hide show
  1. package/dist/agent/agent.js +324 -122
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/enhance-prompt.js +2 -1
  5. package/dist/agent/history-adapt.d.ts +23 -0
  6. package/dist/agent/history-adapt.js +114 -0
  7. package/dist/agent/prompts.d.ts +19 -2
  8. package/dist/agent/prompts.js +44 -2
  9. package/dist/agent/retry.d.ts +50 -0
  10. package/dist/agent/retry.js +192 -0
  11. package/dist/agent/shell-command.d.ts +23 -0
  12. package/dist/agent/shell-command.js +93 -0
  13. package/dist/agent/tool-repair.d.ts +8 -0
  14. package/dist/agent/tool-repair.js +16 -0
  15. package/dist/catalog/models-dev-snapshot.d.ts +1 -0
  16. package/dist/catalog/models-dev-snapshot.js +3 -0
  17. package/dist/catalog/models-dev.d.ts +45 -0
  18. package/dist/catalog/models-dev.js +155 -0
  19. package/dist/checkpoints/store.js +3 -1
  20. package/dist/context/compaction.d.ts +21 -6
  21. package/dist/context/compaction.js +94 -28
  22. package/dist/context/mask.d.ts +9 -9
  23. package/dist/context/mask.js +30 -1
  24. package/dist/format/auto-format.d.ts +22 -0
  25. package/dist/format/auto-format.js +39 -0
  26. package/dist/git/auto-commit.d.ts +4 -2
  27. package/dist/git/auto-commit.js +16 -5
  28. package/dist/github/github-tools.d.ts +1 -0
  29. package/dist/github/github-tools.js +9 -9
  30. package/dist/github/pr.d.ts +34 -0
  31. package/dist/github/pr.js +70 -0
  32. package/dist/human-text.d.ts +6 -0
  33. package/dist/human-text.js +37 -0
  34. package/dist/index.d.ts +19 -9
  35. package/dist/index.js +14 -6
  36. package/dist/journey/answers-block.d.ts +2 -4
  37. package/dist/lsp/diagnostics.d.ts +4 -0
  38. package/dist/lsp/diagnostics.js +8 -2
  39. package/dist/lsp/index.d.ts +1 -1
  40. package/dist/lsp/index.js +1 -1
  41. package/dist/mcp/index.d.ts +1 -1
  42. package/dist/mcp/index.js +1 -1
  43. package/dist/mcp/json-schema-to-zod.js +2 -4
  44. package/dist/mcp/mcp-client.d.ts +14 -0
  45. package/dist/mcp/mcp-client.js +102 -18
  46. package/dist/memory/session-digest.js +2 -1
  47. package/dist/orchestrator/specialists-registry.js +14 -1
  48. package/dist/orchestrator/specialists.js +3 -3
  49. package/dist/pricing.js +12 -0
  50. package/dist/semantic/embedder.d.ts +11 -0
  51. package/dist/semantic/embedder.js +16 -0
  52. package/dist/semantic/semantic-index.d.ts +39 -1
  53. package/dist/semantic/semantic-index.js +43 -13
  54. package/dist/semantic/vector-store.d.ts +2 -0
  55. package/dist/semantic/vector-store.js +13 -1
  56. package/dist/sessions/index.d.ts +2 -2
  57. package/dist/sessions/index.js +1 -1
  58. package/dist/sessions/search.js +2 -2
  59. package/dist/sessions/session-export.d.ts +1 -1
  60. package/dist/sessions/session-export.js +41 -33
  61. package/dist/sessions/session-store.d.ts +20 -1
  62. package/dist/sessions/session-store.js +7 -0
  63. package/dist/sessions/watch.js +4 -4
  64. package/dist/skills/activator.d.ts +6 -0
  65. package/dist/skills/activator.js +17 -0
  66. package/dist/skills/skill-tool.d.ts +12 -0
  67. package/dist/skills/skill-tool.js +28 -0
  68. package/dist/tools/background-process.d.ts +63 -0
  69. package/dist/tools/background-process.js +150 -0
  70. package/dist/tools/command-sandbox.d.ts +24 -0
  71. package/dist/tools/command-sandbox.js +73 -0
  72. package/dist/tools/edit-file.d.ts +8 -0
  73. package/dist/tools/edit-file.js +39 -16
  74. package/dist/tools/edit-match.d.ts +16 -0
  75. package/dist/tools/edit-match.js +115 -0
  76. package/dist/tools/fetch-url.d.ts +15 -1
  77. package/dist/tools/fetch-url.js +7 -47
  78. package/dist/tools/get-diagnostics.d.ts +14 -0
  79. package/dist/tools/get-diagnostics.js +44 -0
  80. package/dist/tools/glob-filter.d.ts +7 -0
  81. package/dist/tools/glob-filter.js +12 -0
  82. package/dist/tools/html-to-markdown.d.ts +13 -0
  83. package/dist/tools/html-to-markdown.js +507 -0
  84. package/dist/tools/index.d.ts +5 -1
  85. package/dist/tools/index.js +11 -1
  86. package/dist/tools/list-files.d.ts +1 -0
  87. package/dist/tools/list-files.js +17 -5
  88. package/dist/tools/list-symbols.js +2 -2
  89. package/dist/tools/network-policy.d.ts +5 -0
  90. package/dist/tools/network-policy.js +77 -0
  91. package/dist/tools/office-docs.d.ts +6 -0
  92. package/dist/tools/office-docs.js +393 -0
  93. package/dist/tools/permission-rules.js +55 -5
  94. package/dist/tools/read-file.d.ts +2 -0
  95. package/dist/tools/read-file.js +47 -10
  96. package/dist/tools/read-image.js +2 -2
  97. package/dist/tools/read-only.d.ts +1 -1
  98. package/dist/tools/read-only.js +4 -0
  99. package/dist/tools/repo-clone.d.ts +40 -0
  100. package/dist/tools/repo-clone.js +218 -0
  101. package/dist/tools/result-cap.d.ts +8 -0
  102. package/dist/tools/result-cap.js +44 -0
  103. package/dist/tools/run-command.d.ts +18 -1
  104. package/dist/tools/run-command.js +44 -9
  105. package/dist/tools/search-files.js +12 -53
  106. package/dist/tools/search-worker.d.ts +23 -0
  107. package/dist/tools/search-worker.js +86 -0
  108. package/dist/tools/shared.d.ts +14 -1
  109. package/dist/tools/shared.js +76 -4
  110. package/dist/tools/shell.d.ts +27 -0
  111. package/dist/tools/shell.js +80 -0
  112. package/dist/tools/sub-agent.d.ts +1 -1
  113. package/dist/tools/sub-agent.js +4 -1
  114. package/dist/tools/suggest-plan.d.ts +18 -0
  115. package/dist/tools/suggest-plan.js +28 -0
  116. package/dist/types.d.ts +132 -1
  117. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  118. package/dist/wakeup/wakeup-tools.js +34 -0
  119. package/dist/wakeup/wakeup.d.ts +75 -0
  120. package/dist/wakeup/wakeup.js +151 -0
  121. package/dist/worktree/health.d.ts +51 -0
  122. package/dist/worktree/health.js +186 -0
  123. package/dist/worktree/index.d.ts +4 -0
  124. package/dist/worktree/index.js +2 -0
  125. package/dist/worktree/setup.d.ts +51 -0
  126. package/dist/worktree/setup.js +173 -0
  127. package/dist/worktree/worktree.d.ts +7 -0
  128. package/dist/worktree/worktree.js +9 -4
  129. package/package.json +1 -1
@@ -0,0 +1,150 @@
1
+ /**
2
+ * `bg_start`, `bg_read`, `bg_stop`: commands that keep running while the model works (a dev server, a watcher).
3
+ *
4
+ * The run owns a `BackgroundProcesses` (on `ToolContext.processes`) and stops everything in it when the run ends,
5
+ * so nothing outlives the chat that started it. A child agent is never given these tools (sub-agent.ts).
6
+ * Starting needs approval like `run_command` and goes through the same checks (`prepareCommand`); reading and
7
+ * stopping only touch processes this run started. Output is kept in a bounded buffer and redacted when read.
8
+ */
9
+ import { spawn } from 'node:child_process';
10
+ import { z } from 'zod';
11
+ import { redact } from './redact.js';
12
+ import { killTree, prepareCommand } from './run-command.js';
13
+ import { shellEnv } from './shell-env.js';
14
+ import { runnerShell, shellInvocation } from './shell.js';
15
+ const MAX_LIVE = 3;
16
+ const BUFFER_CHARS = 200_000;
17
+ /** The most one read returns; the cursor moves past anything older. */
18
+ const READ_CHARS = 20_000;
19
+ export class BackgroundProcesses {
20
+ entries = new Map();
21
+ nextId = 1;
22
+ maxBufferChars;
23
+ constructor(options = {}) {
24
+ this.maxBufferChars = options.maxBufferChars ?? BUFFER_CHARS;
25
+ }
26
+ /** How many are running now. */
27
+ get liveCount() {
28
+ return [...this.entries.values()].filter((e) => e.running).length;
29
+ }
30
+ start(command, cwd, ctx, cwdOverride) {
31
+ if (this.liveCount >= MAX_LIVE) {
32
+ throw new Error(`${MAX_LIVE} background processes are already running. Stop one with bg_stop before starting another.`);
33
+ }
34
+ const prepared = prepareCommand(command, cwdOverride, { ...ctx, cwd });
35
+ const inv = shellInvocation(runnerShell(ctx.shell), prepared.commandLine);
36
+ const options = {
37
+ cwd: prepared.cwd,
38
+ // Windows: never run a same-named program the repository ships in its root (see shell-env.ts).
39
+ env: shellEnv(process.env),
40
+ stdio: ['ignore', 'pipe', 'pipe'],
41
+ windowsHide: true,
42
+ // POSIX: own process group, so a stop reaches everything the shell started (see killTree).
43
+ detached: process.platform !== 'win32',
44
+ };
45
+ const child = inv.file ? spawn(inv.file, inv.args, options) : spawn(prepared.commandLine, { ...options, shell: true });
46
+ const entry = { id: `bg${this.nextId++}`, command, child, running: true, exitCode: null, buffer: '', total: 0 };
47
+ this.entries.set(entry.id, entry);
48
+ const take = (chunk) => {
49
+ entry.total += chunk.length;
50
+ entry.buffer += chunk;
51
+ if (entry.buffer.length > this.maxBufferChars)
52
+ entry.buffer = entry.buffer.slice(entry.buffer.length - this.maxBufferChars);
53
+ };
54
+ child.stdout.setEncoding('utf8').on('data', take);
55
+ child.stderr.setEncoding('utf8').on('data', take);
56
+ child.on('error', (err) => {
57
+ take(`[could not start: ${err.message}]\n`);
58
+ entry.running = false;
59
+ });
60
+ // Same as run_command: a grandchild that escaped the tree kill must not keep this entry "running".
61
+ child.on('exit', (code) => {
62
+ entry.running = false;
63
+ entry.exitCode = code;
64
+ setTimeout(() => {
65
+ child.stdout.destroy();
66
+ child.stderr.destroy();
67
+ }, 500).unref();
68
+ });
69
+ child.on('close', (code) => {
70
+ entry.running = false;
71
+ entry.exitCode = code;
72
+ });
73
+ return { id: entry.id, pid: child.pid };
74
+ }
75
+ get(id) {
76
+ const entry = this.entries.get(id);
77
+ if (!entry)
78
+ throw new Error(`No background process with id "${id}". Ids come from bg_start.`);
79
+ return entry;
80
+ }
81
+ read(id, since = 0) {
82
+ const e = this.get(id);
83
+ const firstKept = e.total - e.buffer.length;
84
+ const from = Math.max(since, firstKept, e.total - READ_CHARS);
85
+ const raw = e.buffer.slice(from - firstKept);
86
+ return {
87
+ id: e.id,
88
+ command: e.command,
89
+ running: e.running,
90
+ exitCode: e.exitCode,
91
+ output: redact(raw).text,
92
+ nextSince: e.total,
93
+ droppedChars: Math.max(0, from - since),
94
+ };
95
+ }
96
+ stop(id) {
97
+ const e = this.get(id);
98
+ const wasRunning = e.running;
99
+ if (wasRunning)
100
+ killTree(e.child);
101
+ return { id: e.id, wasRunning };
102
+ }
103
+ /** Stop everything still running; returns what was running. */
104
+ stopAll() {
105
+ const live = [...this.entries.values()].filter((e) => e.running);
106
+ for (const e of live)
107
+ killTree(e.child);
108
+ return live.map((e) => ({ id: e.id, command: e.command }));
109
+ }
110
+ }
111
+ function manager(ctx) {
112
+ if (!ctx.processes)
113
+ throw new Error('Background processes are not available in this run. Use run_command instead.');
114
+ return ctx.processes;
115
+ }
116
+ const startParams = z.object({
117
+ command: z.string().describe('The shell command to keep running, for example "npm run dev".'),
118
+ cwd: z.string().optional().describe('Working directory. Defaults to the project root.'),
119
+ });
120
+ export const bgStartTool = {
121
+ name: 'bg_start',
122
+ description: 'Start a command that keeps running (a dev server, a file watcher) and return at once with an id. Read its output with bg_read and end it with bg_stop. ' +
123
+ `At most ${MAX_LIVE} at a time. Everything still running is stopped when this run ends, so it is for use within one task. Needs approval like run_command.`,
124
+ parameters: startParams,
125
+ destructive: true,
126
+ execute: async (args, ctx) => {
127
+ const { id, pid } = manager(ctx).start(args.command, ctx.cwd, ctx, args.cwd);
128
+ return { id, pid, note: `Started ${id}. Use bg_read with this id to see its output, and bg_stop when you are done.` };
129
+ },
130
+ };
131
+ const readParams = z.object({
132
+ id: z.string().describe('The id returned by bg_start.'),
133
+ since: z.number().int().nonnegative().optional().describe('The nextSince value of your last read, to get only new output.'),
134
+ });
135
+ export const bgReadTool = {
136
+ name: 'bg_read',
137
+ description: 'Read the output of a background process started with bg_start, and whether it is still running. Pass nextSince from the last read as since to get only new output.',
138
+ parameters: readParams,
139
+ execute: async (args, ctx) => {
140
+ const r = manager(ctx).read(args.id, args.since ?? 0);
141
+ return r.droppedChars > 0 ? { ...r, note: `${r.droppedChars} characters of older output are not shown.` } : r;
142
+ },
143
+ };
144
+ const stopParams = z.object({ id: z.string().describe('The id returned by bg_start.') });
145
+ export const bgStopTool = {
146
+ name: 'bg_stop',
147
+ description: 'Stop a background process started with bg_start, together with everything it started.',
148
+ parameters: stopParams,
149
+ execute: async (args, ctx) => manager(ctx).stop(args.id),
150
+ };
@@ -0,0 +1,24 @@
1
+ import type { CommandSandbox } from '../types.js';
2
+ export type SandboxedCommand = {
3
+ kind: 'wrapped';
4
+ command: string;
5
+ } | {
6
+ kind: 'unavailable';
7
+ command: string;
8
+ } | {
9
+ kind: 'refused';
10
+ reason: string;
11
+ };
12
+ /** Quote one argument for /bin/sh. */
13
+ export declare function shellQuote(arg: string): string;
14
+ interface Options {
15
+ cwd: string;
16
+ sandbox: CommandSandbox;
17
+ platform: NodeJS.Platform;
18
+ tmpDir: string;
19
+ findBinary?: (name: string) => string | undefined;
20
+ realpath?: (p: string) => string;
21
+ }
22
+ /** The command line that runs `command` inside the sandbox, or why it cannot be sandboxed. */
23
+ export declare function sandboxCommand(command: string, opts: Options): SandboxedCommand;
24
+ export {};
@@ -0,0 +1,73 @@
1
+ /**
2
+ * OS sandbox for run_command (after Kilo's kilo-sandbox): bubblewrap on Linux, sandbox-exec (Seatbelt) on macOS.
3
+ * Writes are confined to the workspace, the temp folder and any extra folders the user named; the workspace's .git
4
+ * stays read-only, since a hook written there would later run outside the sandbox; the network is cut unless
5
+ * allowed. Windows has no comparable tool, so there the command runs as before (approval still applies).
6
+ */
7
+ import { existsSync, realpathSync } from 'node:fs';
8
+ import path from 'node:path';
9
+ import { resolveOnPath } from '../lsp/client.js';
10
+ /** Quote one argument for /bin/sh. */
11
+ export function shellQuote(arg) {
12
+ return `'${arg.replace(/'/g, `'\\''`)}'`;
13
+ }
14
+ function defaultFind(name) {
15
+ for (const dir of ['/usr/bin', '/bin', '/usr/local/bin']) {
16
+ const full = path.posix.join(dir, name);
17
+ if (existsSync(full))
18
+ return full;
19
+ }
20
+ return resolveOnPath(name, process.env, process.platform);
21
+ }
22
+ function safeRealpath(p) {
23
+ try {
24
+ return realpathSync(p);
25
+ }
26
+ catch {
27
+ return p;
28
+ }
29
+ }
30
+ /** The command line that runs `command` inside the sandbox, or why it cannot be sandboxed. */
31
+ export function sandboxCommand(command, opts) {
32
+ const find = opts.findBinary ?? defaultFind;
33
+ const real = opts.realpath ?? safeRealpath;
34
+ if (opts.platform === 'linux') {
35
+ const bwrap = find('bwrap');
36
+ if (!bwrap)
37
+ return { kind: 'refused', reason: 'The command sandbox is on, but bubblewrap (bwrap) is not installed. Install it (apt install bubblewrap) or turn the sandbox off.' };
38
+ const writable = [opts.cwd, opts.tmpDir, ...(opts.sandbox.extraWritable ?? [])];
39
+ const gitDir = path.join(opts.cwd, '.git');
40
+ const args = [
41
+ bwrap,
42
+ '--die-with-parent',
43
+ '--new-session',
44
+ '--unshare-pid',
45
+ ...(opts.sandbox.network ? [] : ['--unshare-net']),
46
+ '--ro-bind', '/', '/',
47
+ '--dev', '/dev',
48
+ '--proc', '/proc',
49
+ ...writable.flatMap((p) => ['--bind', p, p]),
50
+ ...(existsSync(gitDir) ? ['--ro-bind', gitDir, gitDir] : []),
51
+ '--chdir', opts.cwd,
52
+ '--', '/bin/sh', '-c', command,
53
+ ];
54
+ return { kind: 'wrapped', command: args.map(shellQuote).join(' ') };
55
+ }
56
+ if (opts.platform === 'darwin') {
57
+ const exec = find('sandbox-exec');
58
+ if (!exec)
59
+ return { kind: 'refused', reason: 'The command sandbox is on, but sandbox-exec was not found. Turn the sandbox off to run commands.' };
60
+ const quote = (p) => JSON.stringify(real(p));
61
+ const writable = [opts.cwd, opts.tmpDir, ...(opts.sandbox.extraWritable ?? [])].map((p) => `(subpath ${quote(p)})`);
62
+ const profile = [
63
+ '(version 1)',
64
+ '(allow default)',
65
+ '(deny file-write*)',
66
+ `(allow file-write* ${writable.join(' ')} (subpath "/private/var/folders") (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (regex #"^/dev/tty"))`,
67
+ `(deny file-write* (subpath ${quote(path.posix.join(opts.cwd, '.git'))}))`,
68
+ ...(opts.sandbox.network ? [] : ['(deny network* (remote ip))']),
69
+ ].join('\n');
70
+ return { kind: 'wrapped', command: [exec, '-p', profile, '/bin/sh', '-c', command].map(shellQuote).join(' ') };
71
+ }
72
+ return { kind: 'unavailable', command };
73
+ }
@@ -12,4 +12,12 @@ export interface EditFileResult {
12
12
  bytes: number;
13
13
  }
14
14
  export declare const editFileTool: ToolDefinition<z.infer<typeof params>, EditFileResult>;
15
+ /**
16
+ * What edit_file writes for `original`; throws the message the model sees when old_text cannot be placed.
17
+ * Shared with the chat's diff preview so the two never disagree.
18
+ */
19
+ export declare function applyTextEdit(original: string, oldTextIn: string, newTextIn: string, replaceAll: boolean, file: string): {
20
+ content: string;
21
+ replacements: number;
22
+ };
15
23
  export {};
@@ -1,5 +1,6 @@
1
1
  import { readFile, writeFile } from 'node:fs/promises';
2
2
  import { z } from 'zod';
3
+ import { fallbackMatch } from './edit-match.js';
3
4
  import { resolveAndCheckPath } from './shared.js';
4
5
  import { decodeText, encodeText } from './text-encoding.js';
5
6
  const params = z.object({
@@ -24,29 +25,51 @@ export const editFileTool = {
24
25
  throw new Error(`File not found: ${args.path}`);
25
26
  throw err;
26
27
  });
27
- const occurrences = countOccurrences(original, args.old_text);
28
- if (occurrences === 0) {
29
- throw new Error(`old_text not found in ${args.path}. Re-read the file (it may have changed) and try again.`);
30
- }
31
- const replaceAll = args.replace_all === true;
32
- if (!replaceAll && occurrences > 1) {
33
- throw new Error(`old_text matches ${occurrences} times in ${args.path}. Pass more context to make it unique, or set replace_all=true to replace all occurrences.`);
34
- }
35
- const updated = replaceAll
36
- ? original.split(args.old_text).join(args.new_text)
37
- // Use a replacer FUNCTION so new_text is inserted literally: a plain
38
- // string 2nd arg to String.prototype.replace still honors special
39
- // patterns like $&, $1, $$ even though old_text isn't a regex.
40
- : original.replace(args.old_text, () => args.new_text);
41
- const bytes = encodeText(updated, encoding);
28
+ const { content, replacements } = applyTextEdit(original, args.old_text, args.new_text, args.replace_all === true, args.path);
29
+ const bytes = encodeText(content, encoding);
42
30
  await writeFile(resolved, bytes);
43
31
  return {
44
32
  path: resolved,
45
- replacements: replaceAll ? occurrences : 1,
33
+ replacements,
46
34
  bytes: bytes.length,
47
35
  };
48
36
  },
49
37
  };
38
+ /**
39
+ * What edit_file writes for `original`; throws the message the model sees when old_text cannot be placed.
40
+ * Shared with the chat's diff preview so the two never disagree.
41
+ */
42
+ export function applyTextEdit(original, oldTextIn, newTextIn, replaceAll, file) {
43
+ // read_file shows a CRLF file without its \r, so the model sends LF text: match on LF, write back the file's own ending.
44
+ const crlf = original.includes('\r\n');
45
+ const content = crlf ? toLf(original) : original;
46
+ const oldText = toLf(oldTextIn);
47
+ const newText = toLf(newTextIn);
48
+ const occurrences = countOccurrences(content, oldText);
49
+ let updated;
50
+ if (occurrences === 0) {
51
+ const span = replaceAll ? undefined : fallbackMatch(content, oldText, file);
52
+ if (!span) {
53
+ throw new Error(`old_text not found in ${file}. Re-read the file (it may have changed) and try again.`);
54
+ }
55
+ updated = content.slice(0, span.start) + newText + content.slice(span.end);
56
+ }
57
+ else {
58
+ if (!replaceAll && occurrences > 1) {
59
+ throw new Error(`old_text matches ${occurrences} times in ${file}. Pass more context to make it unique, or set replace_all=true to replace all occurrences.`);
60
+ }
61
+ updated = replaceAll
62
+ ? content.split(oldText).join(newText)
63
+ // Use a replacer FUNCTION so new_text is inserted literally: a plain
64
+ // string 2nd arg to String.prototype.replace still honors special
65
+ // patterns like $&, $1, $$ even though old_text isn't a regex.
66
+ : content.replace(oldText, () => newText);
67
+ }
68
+ return { content: crlf ? updated.replace(/\n/g, '\r\n') : updated, replacements: replaceAll ? occurrences : 1 };
69
+ }
70
+ function toLf(text) {
71
+ return text.replace(/\r\n/g, '\n');
72
+ }
50
73
  function countOccurrences(haystack, needle) {
51
74
  if (needle.length === 0)
52
75
  return 0;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Ways to place a model's `old_text` in a file when the exact text is absent. A model often gets indentation, inner
3
+ * spacing, escapes or a middle line slightly wrong. Kilo tries several of these in a chain; so do we (G15).
4
+ *
5
+ * Each finder returns every place that fits. Exactly one place is used; more than one is an error, so a loose match
6
+ * never chooses between two candidates, and a block whose middle is nothing like old_text is never replaced.
7
+ * All text here is LF: `applyTextEdit` normalises line endings before calling in.
8
+ */
9
+ export type Span = {
10
+ start: number;
11
+ end: number;
12
+ };
13
+ /** 1 for equal strings, towards 0 as they differ (edit distance over the longer length). */
14
+ export declare function similarity(a: string, b: string): number;
15
+ /** The one place old_text fits, or undefined when none does. Throws when a finder finds two. */
16
+ export declare function fallbackMatch(content: string, oldText: string, file: string): Span | undefined;
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Ways to place a model's `old_text` in a file when the exact text is absent. A model often gets indentation, inner
3
+ * spacing, escapes or a middle line slightly wrong. Kilo tries several of these in a chain; so do we (G15).
4
+ *
5
+ * Each finder returns every place that fits. Exactly one place is used; more than one is an error, so a loose match
6
+ * never chooses between two candidates, and a block whose middle is nothing like old_text is never replaced.
7
+ * All text here is LF: `applyTextEdit` normalises line endings before calling in.
8
+ */
9
+ const NEWLINE = '\n';
10
+ /** Offset of the start of line `line` (0-based) in text split on newlines. */
11
+ function lineOffset(lines, line) {
12
+ let n = 0;
13
+ for (let i = 0; i < line; i++)
14
+ n += lines[i].length + 1;
15
+ return n;
16
+ }
17
+ /** old_text's lines without a trailing empty one. */
18
+ function wantedLines(oldText) {
19
+ const lines = oldText.split(NEWLINE);
20
+ if (lines.length > 1 && lines[lines.length - 1] === '')
21
+ lines.pop();
22
+ return lines;
23
+ }
24
+ /** The block of whole lines whose trimmed text equals old_text's trimmed lines (wrong indentation, trailing spaces). */
25
+ const trimmedLines = (content, oldText) => {
26
+ const want = wantedLines(oldText).map((l) => l.trim());
27
+ if (want.every((l) => l === ''))
28
+ return [];
29
+ const lines = content.split(NEWLINE);
30
+ const spans = [];
31
+ for (let i = 0; i + want.length <= lines.length; i++) {
32
+ if (!want.every((w, j) => lines[i + j].trim() === w))
33
+ continue;
34
+ const last = i + want.length - 1;
35
+ spans.push({ start: lineOffset(lines, i), end: lineOffset(lines, last) + lines[last].length });
36
+ }
37
+ return spans;
38
+ };
39
+ const REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
40
+ /** The same words in the same order, whatever the spaces and tabs between them. */
41
+ const collapsedSpace = (content, oldText) => {
42
+ const words = oldText.split(/\s+/).filter(Boolean);
43
+ if (words.length < 2)
44
+ return [];
45
+ const re = new RegExp(words.map((w) => w.replace(REGEX_SPECIAL, (c) => `\\${c}`)).join('\\s+'), 'g');
46
+ return [...content.matchAll(re)].map((m) => ({ start: m.index, end: m.index + m[0].length }));
47
+ };
48
+ const ESCAPES = { n: '\n', t: '\t', r: '\r' };
49
+ /** old_text written with escape sequences (a backslash and an n) where the file has the real character. */
50
+ const unescaped = (content, oldText) => {
51
+ const plain = oldText.replace(/\\(n|t|r|"|'|`|\\)/g, (_m, c) => ESCAPES[c] ?? c);
52
+ if (plain === oldText)
53
+ return [];
54
+ const spans = [];
55
+ for (let i = content.indexOf(plain); i !== -1; i = content.indexOf(plain, i + plain.length))
56
+ spans.push({ start: i, end: i + plain.length });
57
+ return spans;
58
+ };
59
+ const ANCHOR_MIN_LINES = 3;
60
+ const ANCHOR_MAX_CHARS = 4000;
61
+ const ANCHOR_MIN_SIMILARITY = 0.6;
62
+ /** A block of the same number of lines whose first and last lines match and whose middle is close to old_text's. */
63
+ const blockAnchor = (content, oldText) => {
64
+ const want = wantedLines(oldText);
65
+ if (want.length < ANCHOR_MIN_LINES)
66
+ return [];
67
+ const first = want[0].trim();
68
+ const last = want[want.length - 1].trim();
69
+ if (!first || !last)
70
+ return [];
71
+ const middle = want.slice(1, -1).join('').replace(/\s+/g, '');
72
+ if (middle.length > ANCHOR_MAX_CHARS)
73
+ return [];
74
+ const lines = content.split(NEWLINE);
75
+ const spans = [];
76
+ for (let i = 0; i + want.length <= lines.length; i++) {
77
+ const end = i + want.length - 1;
78
+ if (lines[i].trim() !== first || lines[end].trim() !== last)
79
+ continue;
80
+ const have = lines.slice(i + 1, end).join('').replace(/\s+/g, '');
81
+ if (similarity(middle, have) >= ANCHOR_MIN_SIMILARITY)
82
+ spans.push({ start: lineOffset(lines, i), end: lineOffset(lines, end) + lines[end].length });
83
+ }
84
+ return spans;
85
+ };
86
+ /** 1 for equal strings, towards 0 as they differ (edit distance over the longer length). */
87
+ export function similarity(a, b) {
88
+ if (a === b)
89
+ return 1;
90
+ const longest = Math.max(a.length, b.length);
91
+ if (longest === 0)
92
+ return 1;
93
+ let prev = Array.from({ length: b.length + 1 }, (_, j) => j);
94
+ for (let i = 1; i <= a.length; i++) {
95
+ const row = [i];
96
+ for (let j = 1; j <= b.length; j++)
97
+ row[j] = Math.min(prev[j] + 1, row[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
98
+ prev = row;
99
+ }
100
+ return 1 - prev[b.length] / longest;
101
+ }
102
+ /** In order of how little each assumes. */
103
+ const FINDERS = [trimmedLines, collapsedSpace, unescaped, blockAnchor];
104
+ /** The one place old_text fits, or undefined when none does. Throws when a finder finds two. */
105
+ export function fallbackMatch(content, oldText, file) {
106
+ for (const find of FINDERS) {
107
+ const spans = find(content, oldText);
108
+ if (spans.length === 1)
109
+ return spans[0];
110
+ if (spans.length > 1) {
111
+ throw new Error(`old_text matches more than one place in ${file} once spacing is ignored. Pass more context to make it unique.`);
112
+ }
113
+ }
114
+ return undefined;
115
+ }
@@ -1,5 +1,5 @@
1
1
  import { z } from 'zod';
2
- import type { ToolDefinition } from '../types.js';
2
+ import type { ToolContext, ToolDefinition } from '../types.js';
3
3
  declare const params: z.ZodObject<{
4
4
  url: z.ZodString;
5
5
  max_bytes: z.ZodOptional<z.ZodNumber>;
@@ -27,5 +27,19 @@ export interface FetchUrlResult {
27
27
  * loopback services or cloud metadata endpoints (e.g. 169.254.169.254).
28
28
  */
29
29
  export declare function isBlockedAddress(ip: string): boolean;
30
+ /**
31
+ * Reject requests targeting loopback / link-local / private addresses:
32
+ * this closes an SSRF gap that otherwise exists with zero configuration
33
+ * (a bare `fetch_url({url: "http://169.254.169.254/..."})` could reach a
34
+ * cloud metadata endpoint). Applies by default, even with no ToolPolicy
35
+ * configured; a `policy.allowedUrls` match is treated as the caller's
36
+ * explicit opt-in and bypasses this check (e.g. a test or internal tool
37
+ * that deliberately targets a local service).
38
+ *
39
+ * Resolves the hostname via DNS (unless it's already an IP literal) so a
40
+ * public-looking hostname that resolves to a private address is caught
41
+ * too, not just literal IPs.
42
+ */
43
+ export declare function assertUrlNotBlocked(rawUrl: string, policy: ToolContext['policy']): Promise<void>;
30
44
  export declare const fetchUrlTool: ToolDefinition<z.infer<typeof params>, FetchUrlResult>;
31
45
  export {};
@@ -2,6 +2,7 @@ import { lookup } from 'node:dns/promises';
2
2
  import net from 'node:net';
3
3
  import { z } from 'zod';
4
4
  import { installedFetch } from '../policy/proxy.js';
5
+ import { decodeEntities, htmlToMarkdown } from './html-to-markdown.js';
5
6
  import { checkPolicyViolation } from './policy.js';
6
7
  const params = z.object({
7
8
  url: z
@@ -34,7 +35,8 @@ const params = z.object({
34
35
  .optional()
35
36
  .describe('Follow 3xx redirects. Default true.'),
36
37
  });
37
- const DEFAULT_MAX_BYTES = 200_000;
38
+ // A page is read once and rarely needs more; the model can ask for a larger max_bytes. Matches read_file's 50 KB (G6).
39
+ const DEFAULT_MAX_BYTES = 50_000;
38
40
  const DEFAULT_TIMEOUT_MS = 10_000;
39
41
  const EXCERPT_MAX_CHARS = 8_000;
40
42
  const USER_AGENT = 'ikarmic-forge/0.1 (fetch_url)';
@@ -104,7 +106,7 @@ export function isBlockedAddress(ip) {
104
106
  * public-looking hostname that resolves to a private address is caught
105
107
  * too, not just literal IPs.
106
108
  */
107
- async function assertUrlNotBlocked(rawUrl, policy) {
109
+ export async function assertUrlNotBlocked(rawUrl, policy) {
108
110
  if (policy?.allowedUrls?.some((re) => re.test(rawUrl)))
109
111
  return;
110
112
  let hostname;
@@ -137,7 +139,7 @@ async function assertUrlNotBlocked(rawUrl, policy) {
137
139
  }
138
140
  export const fetchUrlTool = {
139
141
  name: 'fetch_url',
140
- description: 'Fetch a URL over HTTP(S) and return its body as readable text. HTML is converted to plain text (scripts/styles stripped); JSON is pretty-printed; text is passed through. Follows up to 5 redirects and respects max_bytes/timeout. Does not follow links or execute JavaScript.',
142
+ description: 'Fetch a URL over HTTP(S) and return its body as readable text. HTML is converted to Markdown (headings, links, lists, tables and code kept; scripts, styles and hidden parts dropped); JSON is pretty-printed; text is passed through. Follows up to 5 redirects and respects max_bytes/timeout. Does not follow links or execute JavaScript.',
141
143
  parameters: params,
142
144
  destructive: false,
143
145
  execute: async (args, ctx) => {
@@ -214,7 +216,7 @@ export const fetchUrlTool = {
214
216
  let title;
215
217
  if (isHtml) {
216
218
  title = extractTitle(text);
217
- body = htmlToText(text);
219
+ body = htmlToMarkdown(text, finalUrl);
218
220
  }
219
221
  else if (isJson) {
220
222
  body = prettyJson(text);
@@ -271,54 +273,12 @@ async function readWithCap(response, cap) {
271
273
  chunks.push(decoder.decode());
272
274
  return { text: chunks.join(''), bytes: total, truncated };
273
275
  }
274
- /**
275
- * Tiny HTML → text converter. Good enough for inspection; not a readability
276
- * replacement. Strips scripts, styles, comments, and tags; decodes the most
277
- * common entities; collapses whitespace. This is intentionally simple: we
278
- * want to avoid pulling in a 200KB HTML parser dependency for what's
279
- * essentially a "summarize this page" use case.
280
- */
281
- function htmlToText(html) {
282
- let s = html;
283
- // Drop <script>...</script> and <style>...</style> blocks first (their
284
- // contents aren't part of the visible text).
285
- s = s.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, ' ');
286
- s = s.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, ' ');
287
- // Drop conditional comments and HTML comments.
288
- s = s.replace(/<!--[\s\S]*?-->/g, ' ');
289
- // Drop the tags themselves.
290
- s = s.replace(/<\/?[a-zA-Z][^>]*>/g, ' ');
291
- // Common named entities.
292
- s = s
293
- .replace(/&nbsp;/g, ' ')
294
- .replace(/&amp;/g, '&')
295
- .replace(/&lt;/g, '<')
296
- .replace(/&gt;/g, '>')
297
- .replace(/&quot;/g, '"')
298
- .replace(/&apos;/g, "'")
299
- .replace(/&#39;/g, "'")
300
- .replace(/&hellip;/g, '…')
301
- .replace(/&mdash;/g, '—')
302
- .replace(/&ndash;/g, '–');
303
- // Numeric entities (decimal and hex): limited to the common ones.
304
- s = s.replace(/&#(\d+);/g, (_, code) => {
305
- const n = Number(code);
306
- return n > 0x1f && n < 0x7f ? String.fromCharCode(n) : ' ';
307
- });
308
- s = s.replace(/&#x([0-9a-fA-F]+);/g, (_, hex) => {
309
- const n = parseInt(hex, 16);
310
- return n > 0x1f && n < 0x7f ? String.fromCharCode(n) : ' ';
311
- });
312
- // Collapse runs of whitespace.
313
- s = s.replace(/[ \t\f\v]+/g, ' ').replace(/\s*\n\s*/g, '\n').replace(/\n{2,}/g, '\n\n');
314
- return s.trim();
315
- }
316
276
  /** Pull the `<title>` from the head, if present. */
317
277
  function extractTitle(html) {
318
278
  const m = html.match(/<title[^>]*>([\s\S]*?)<\/title\s*>/i);
319
279
  if (!m)
320
280
  return undefined;
321
- return htmlToText(m[1]).trim() || undefined;
281
+ return decodeEntities(m[1].replace(/<[^>]*>/g, ' ')).replace(/\s+/g, ' ').trim() || undefined;
322
282
  }
323
283
  /** Pretty-print JSON if it parses; otherwise return the raw text. */
324
284
  function prettyJson(text) {
@@ -0,0 +1,14 @@
1
+ import { z } from 'zod';
2
+ import type { ToolDefinition } from '../types.js';
3
+ declare const params: z.ZodObject<{
4
+ paths: z.ZodArray<z.ZodString>;
5
+ include_warnings: z.ZodOptional<z.ZodBoolean>;
6
+ }, z.core.$strip>;
7
+ export interface DiagnosticsResult {
8
+ /** Problems found. */
9
+ problems: number;
10
+ /** One line per problem as file:line:column: message, or a sentence saying why there is nothing to report. */
11
+ text: string;
12
+ }
13
+ export declare const getDiagnosticsTool: ToolDefinition<z.infer<typeof params>, DiagnosticsResult>;
14
+ export {};
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `get_diagnostics`: compiler and linter problems for some files, from the language server for their type.
3
+ *
4
+ * It asks first (RB-41). A language server reads the repository's own configuration when it starts (tsconfig.json,
5
+ * plugins, a rust-analyzer build script), and Forge keeps no server running between calls, so every call starts one.
6
+ */
7
+ import path from 'node:path';
8
+ import { z } from 'zod';
9
+ import { collectErrors, formatErrors, hasServerFor } from '../lsp/index.js';
10
+ import { resolveAndCheckPath } from './shared.js';
11
+ const MAX_FILES = 20;
12
+ const params = z.object({
13
+ paths: z
14
+ .array(z.string())
15
+ .min(1)
16
+ .max(MAX_FILES)
17
+ .describe(`Files to check, relative to the project root (at most ${MAX_FILES}).`),
18
+ include_warnings: z.boolean().optional().describe('Also list warnings. Default: errors only.'),
19
+ });
20
+ export const getDiagnosticsTool = {
21
+ name: 'get_diagnostics',
22
+ description: 'Get compiler and linter problems for some files from their language server (TypeScript, Python, Go, Rust), without running a build. ' +
23
+ "Starting a language server follows the project's own configuration, so it asks first. If no server is installed it says so; then use run_command with the project's own check instead.",
24
+ parameters: params,
25
+ destructive: true,
26
+ execute: async (args, ctx) => {
27
+ const files = args.paths.map((p) => path.relative(ctx.cwd, resolveAndCheckPath(p, ctx)) || p);
28
+ const config = { ...(ctx.lspConfig ?? {}), ...(args.include_warnings ? { includeWarnings: true } : {}) };
29
+ const covered = files.filter((f) => hasServerFor(f, config));
30
+ const notes = [];
31
+ const unsupported = files.filter((f) => !covered.includes(f));
32
+ if (unsupported.length > 0)
33
+ notes.push(`No language server for: ${unsupported.join(', ')}.`);
34
+ if (covered.length === 0)
35
+ return { problems: 0, text: notes.join('\n') };
36
+ const report = await collectErrors(covered, ctx.cwd, config);
37
+ if (report.skipped.length > 0) {
38
+ notes.push(`Could not start: ${[...new Set(report.skipped)].join(', ')} (not installed, or it failed to start). Use run_command with the project's own type-check or lint command instead.`);
39
+ }
40
+ const problems = report.errors.reduce((n, f) => n + f.diagnostics.length, 0);
41
+ const found = problems > 0 ? formatErrors(report.errors) : report.skipped.length > 0 ? '' : 'No problems found.';
42
+ return { problems, text: [found, ...notes].filter(Boolean).join('\n') };
43
+ },
44
+ };
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A file-name filter for tools that take a glob from the model (`search_files` include, `list_files` pattern).
3
+ * A slash-less glob such as "*.ts" names files at any depth, as in ripgrep -g; a glob with a slash is matched
4
+ * against the path relative to the searched directory. Do not use `globToRegex` directly for this: it anchors a
5
+ * slash-less pattern to the top level on purpose, for permission rules (RB-64).
6
+ */
7
+ export declare function fileGlobMatcher(glob: string): (rel: string, name: string) => boolean;