@ikarmic/forge-core 5.3.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 (113) hide show
  1. package/dist/agent/agent.js +132 -70
  2. package/dist/agent/cache-hints.d.ts +2 -0
  3. package/dist/agent/cache-hints.js +18 -3
  4. package/dist/agent/history-adapt.d.ts +23 -0
  5. package/dist/agent/history-adapt.js +114 -0
  6. package/dist/agent/prompts.d.ts +4 -2
  7. package/dist/agent/prompts.js +10 -2
  8. package/dist/agent/tool-repair.d.ts +8 -0
  9. package/dist/agent/tool-repair.js +16 -0
  10. package/dist/catalog/models-dev.d.ts +10 -0
  11. package/dist/catalog/models-dev.js +12 -0
  12. package/dist/checkpoints/store.js +3 -1
  13. package/dist/context/compaction.d.ts +21 -6
  14. package/dist/context/compaction.js +86 -26
  15. package/dist/context/mask.d.ts +9 -9
  16. package/dist/context/mask.js +30 -1
  17. package/dist/format/auto-format.d.ts +22 -0
  18. package/dist/format/auto-format.js +39 -0
  19. package/dist/github/github-tools.d.ts +1 -0
  20. package/dist/github/github-tools.js +9 -9
  21. package/dist/github/pr.d.ts +34 -0
  22. package/dist/github/pr.js +70 -0
  23. package/dist/human-text.d.ts +6 -0
  24. package/dist/human-text.js +37 -0
  25. package/dist/index.d.ts +13 -8
  26. package/dist/index.js +10 -6
  27. package/dist/journey/answers-block.d.ts +2 -4
  28. package/dist/lsp/diagnostics.d.ts +4 -0
  29. package/dist/lsp/diagnostics.js +8 -2
  30. package/dist/lsp/index.d.ts +1 -1
  31. package/dist/lsp/index.js +1 -1
  32. package/dist/mcp/index.d.ts +1 -1
  33. package/dist/mcp/index.js +1 -1
  34. package/dist/mcp/json-schema-to-zod.js +2 -4
  35. package/dist/mcp/mcp-client.d.ts +14 -0
  36. package/dist/mcp/mcp-client.js +102 -18
  37. package/dist/orchestrator/specialists-registry.js +14 -1
  38. package/dist/orchestrator/specialists.js +3 -3
  39. package/dist/semantic/embedder.d.ts +11 -0
  40. package/dist/semantic/embedder.js +16 -0
  41. package/dist/semantic/semantic-index.d.ts +39 -1
  42. package/dist/semantic/semantic-index.js +43 -13
  43. package/dist/semantic/vector-store.d.ts +2 -0
  44. package/dist/semantic/vector-store.js +13 -1
  45. package/dist/sessions/index.d.ts +2 -2
  46. package/dist/sessions/index.js +1 -1
  47. package/dist/sessions/search.js +2 -2
  48. package/dist/sessions/session-export.d.ts +1 -1
  49. package/dist/sessions/session-export.js +41 -33
  50. package/dist/sessions/session-store.d.ts +20 -1
  51. package/dist/sessions/session-store.js +7 -0
  52. package/dist/sessions/watch.js +4 -4
  53. package/dist/skills/activator.d.ts +6 -0
  54. package/dist/skills/activator.js +17 -0
  55. package/dist/skills/skill-tool.d.ts +12 -0
  56. package/dist/skills/skill-tool.js +28 -0
  57. package/dist/tools/background-process.d.ts +63 -0
  58. package/dist/tools/background-process.js +150 -0
  59. package/dist/tools/edit-file.js +2 -27
  60. package/dist/tools/edit-match.d.ts +16 -0
  61. package/dist/tools/edit-match.js +115 -0
  62. package/dist/tools/fetch-url.d.ts +15 -1
  63. package/dist/tools/fetch-url.js +7 -47
  64. package/dist/tools/get-diagnostics.d.ts +14 -0
  65. package/dist/tools/get-diagnostics.js +44 -0
  66. package/dist/tools/glob-filter.d.ts +7 -0
  67. package/dist/tools/glob-filter.js +12 -0
  68. package/dist/tools/html-to-markdown.d.ts +13 -0
  69. package/dist/tools/html-to-markdown.js +507 -0
  70. package/dist/tools/index.d.ts +5 -1
  71. package/dist/tools/index.js +11 -1
  72. package/dist/tools/list-files.d.ts +1 -0
  73. package/dist/tools/list-files.js +17 -5
  74. package/dist/tools/list-symbols.js +2 -2
  75. package/dist/tools/network-policy.d.ts +5 -0
  76. package/dist/tools/network-policy.js +77 -0
  77. package/dist/tools/office-docs.d.ts +6 -0
  78. package/dist/tools/office-docs.js +393 -0
  79. package/dist/tools/permission-rules.js +55 -5
  80. package/dist/tools/read-file.d.ts +2 -0
  81. package/dist/tools/read-file.js +47 -10
  82. package/dist/tools/read-image.js +2 -2
  83. package/dist/tools/read-only.d.ts +1 -1
  84. package/dist/tools/read-only.js +4 -0
  85. package/dist/tools/repo-clone.d.ts +40 -0
  86. package/dist/tools/repo-clone.js +218 -0
  87. package/dist/tools/result-cap.d.ts +8 -0
  88. package/dist/tools/result-cap.js +44 -0
  89. package/dist/tools/run-command.d.ts +18 -1
  90. package/dist/tools/run-command.js +41 -16
  91. package/dist/tools/search-files.js +5 -5
  92. package/dist/tools/search-worker.js +11 -2
  93. package/dist/tools/shared.d.ts +14 -1
  94. package/dist/tools/shared.js +76 -4
  95. package/dist/tools/shell.d.ts +27 -0
  96. package/dist/tools/shell.js +80 -0
  97. package/dist/tools/sub-agent.js +3 -1
  98. package/dist/tools/suggest-plan.d.ts +18 -0
  99. package/dist/tools/suggest-plan.js +28 -0
  100. package/dist/types.d.ts +74 -0
  101. package/dist/wakeup/wakeup-tools.d.ts +22 -0
  102. package/dist/wakeup/wakeup-tools.js +34 -0
  103. package/dist/wakeup/wakeup.d.ts +75 -0
  104. package/dist/wakeup/wakeup.js +151 -0
  105. package/dist/worktree/health.d.ts +51 -0
  106. package/dist/worktree/health.js +186 -0
  107. package/dist/worktree/index.d.ts +4 -0
  108. package/dist/worktree/index.js +2 -0
  109. package/dist/worktree/setup.d.ts +51 -0
  110. package/dist/worktree/setup.js +173 -0
  111. package/dist/worktree/worktree.d.ts +7 -0
  112. package/dist/worktree/worktree.js +9 -4
  113. 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
+ };
@@ -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({
@@ -47,7 +48,7 @@ export function applyTextEdit(original, oldTextIn, newTextIn, replaceAll, file)
47
48
  const occurrences = countOccurrences(content, oldText);
48
49
  let updated;
49
50
  if (occurrences === 0) {
50
- const span = replaceAll ? undefined : trimmedLineMatch(content, oldText, file);
51
+ const span = replaceAll ? undefined : fallbackMatch(content, oldText, file);
51
52
  if (!span) {
52
53
  throw new Error(`old_text not found in ${file}. Re-read the file (it may have changed) and try again.`);
53
54
  }
@@ -69,32 +70,6 @@ export function applyTextEdit(original, oldTextIn, newTextIn, replaceAll, file)
69
70
  function toLf(text) {
70
71
  return text.replace(/\r\n/g, '\n');
71
72
  }
72
- /**
73
- * Fallback when the exact text is absent: the block of whole lines whose trimmed text equals old_text's trimmed
74
- * lines (a model often gets indentation or trailing spaces wrong). Only a unique block counts.
75
- */
76
- function trimmedLineMatch(content, oldText, file) {
77
- const want = oldText.split('\n').map((l) => l.trim());
78
- if (want.length > 1 && want[want.length - 1] === '')
79
- want.pop();
80
- if (want.every((l) => l === ''))
81
- return undefined;
82
- const lines = content.split('\n');
83
- const starts = [];
84
- for (let i = 0; i + want.length <= lines.length; i++) {
85
- if (want.every((w, j) => lines[i + j].trim() === w))
86
- starts.push(i);
87
- }
88
- if (starts.length === 0)
89
- return undefined;
90
- if (starts.length > 1) {
91
- throw new Error(`old_text matches more than one place in ${file} once leading and trailing spaces are ignored. Pass more context to make it unique.`);
92
- }
93
- const offsetOf = (line) => lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0);
94
- const first = starts[0];
95
- const last = first + want.length - 1;
96
- return { start: offsetOf(first), end: offsetOf(last) + lines[last].length };
97
- }
98
73
  function countOccurrences(haystack, needle) {
99
74
  if (needle.length === 0)
100
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;
@@ -0,0 +1,12 @@
1
+ import { globToRegex } from './policy.js';
2
+ /**
3
+ * A file-name filter for tools that take a glob from the model (`search_files` include, `list_files` pattern).
4
+ * A slash-less glob such as "*.ts" names files at any depth, as in ripgrep -g; a glob with a slash is matched
5
+ * against the path relative to the searched directory. Do not use `globToRegex` directly for this: it anchors a
6
+ * slash-less pattern to the top level on purpose, for permission rules (RB-64).
7
+ */
8
+ export function fileGlobMatcher(glob) {
9
+ const re = globToRegex(glob);
10
+ const byPath = glob.includes('/');
11
+ return (rel, name) => re.test(byPath ? rel : name);
12
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * HTML to Markdown for `fetch_url` (G19). The old converter replaced every tag with a space, so a page became one long line:
3
+ * no headings, no link targets, no list or code structure. This keeps what a model needs to read documentation.
4
+ *
5
+ * It is one linear scan with no parser dependency and no regex that can backtrack over the page: the input is text a stranger
6
+ * wrote and it runs on the host's only thread (RB-22). Output is read by a model, so it is conservative about what a page can
7
+ * make appear in it (RB-81): only http, https and mailto link targets, images as their description only, hidden elements dropped, and a code
8
+ * fence longer than any backtick run inside it, so page text cannot close the fence early.
9
+ */
10
+ /** Decode HTML entities in one pass, so `&amp;lt;` stays `&lt;` instead of becoming `<`. */
11
+ export declare function decodeEntities(s: string): string;
12
+ /** Convert a page. Only the main content is kept when the page marks one and it holds real text; otherwise the whole page. */
13
+ export declare function htmlToMarkdown(html: string, baseUrl?: string): string;