@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
@@ -1,8 +1,9 @@
1
1
  import { readdir, stat } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
3
  import { z } from 'zod';
4
+ import { fileGlobMatcher } from './glob-filter.js';
4
5
  import { IgnoreRules } from './ignore-rules.js';
5
- import { resolveAndCheckPath, DEFAULT_IGNORED_DIRS } from './shared.js';
6
+ import { resolveReadPath, DEFAULT_IGNORED_DIRS } from './shared.js';
6
7
  const params = z.object({
7
8
  path: z
8
9
  .string()
@@ -16,6 +17,10 @@ const params = z.object({
16
17
  .boolean()
17
18
  .optional()
18
19
  .describe('Also list paths matched by .gitignore / .forgeignore (skipped by default).'),
20
+ pattern: z
21
+ .string()
22
+ .optional()
23
+ .describe('Only list entries whose name matches this glob, searching subdirectories too (for example "*.test.ts" or "src/**/*.json"). A pattern without a slash is matched against the name at any depth.'),
19
24
  /** Recurse into subdirectories. Default false. */
20
25
  recursive: z.boolean().optional().describe('Recurse into subdirectories.'),
21
26
  /** Max recursion depth when recursive=true. Default 4. */
@@ -26,12 +31,14 @@ export const listFilesTool = {
26
31
  description: 'List files and directories at a path. By default, hidden files and heavy build/cache directories (node_modules, .git, dist, ...) are skipped. Set recursive=true to walk subdirectories.',
27
32
  parameters: params,
28
33
  execute: async (args, ctx) => {
29
- const root = resolveAndCheckPath(args.path ?? '.', ctx);
34
+ const root = await resolveReadPath(args.path ?? '.', ctx);
30
35
  const maxEntries = args.max_entries ?? 500;
31
36
  const includeHidden = args.include_hidden ?? false;
32
37
  const rules = args.include_ignored ? null : IgnoreRules.forRoot(ctx.cwd);
33
- const recursive = args.recursive ?? false;
34
- const maxDepth = args.max_depth ?? 4;
38
+ const matches = args.pattern ? fileGlobMatcher(args.pattern) : null;
39
+ // A pattern is a search by name, so it walks the tree unless told otherwise.
40
+ const recursive = args.recursive ?? matches !== null;
41
+ const maxDepth = args.max_depth ?? (matches ? 10 : 4);
35
42
  const out = [];
36
43
  let truncated = false;
37
44
  async function walk(dir, depth) {
@@ -61,12 +68,17 @@ export const listFilesTool = {
61
68
  const abs = path.join(dir, entry.name);
62
69
  if (rules?.isIgnored(abs, entry.isDirectory()))
63
70
  continue;
71
+ const listed = !matches || matches(path.relative(root, abs).split(path.sep).join('/'), entry.name);
64
72
  if (entry.isDirectory()) {
65
- out.push({ path: abs, type: 'directory' });
73
+ if (listed)
74
+ out.push({ path: abs, type: 'directory' });
66
75
  if (recursive && depth < maxDepth) {
67
76
  await walk(abs, depth + 1);
68
77
  }
69
78
  }
79
+ else if (!listed) {
80
+ continue;
81
+ }
70
82
  else if (entry.isFile()) {
71
83
  try {
72
84
  const s = await stat(abs);
@@ -1,7 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { z } from 'zod';
3
3
  import { ensureFresh } from '../code-index/builder.js';
4
- import { resolveAndCheckPath } from './shared.js';
4
+ import { resolveReadPath } from './shared.js';
5
5
  const params = z.object({
6
6
  path: z.string().describe('File to outline. Must be inside the project root.'),
7
7
  });
@@ -10,7 +10,7 @@ export const listSymbolsTool = {
10
10
  description: 'List the top-level symbols (functions, classes, interfaces, types, enums, exported consts) defined in a TypeScript, JavaScript, Python, Go, Rust or Java file, in source order: an outline view. Returns an empty list (not an error) for other file types; use search_files for those.',
11
11
  parameters: params,
12
12
  execute: async (args, ctx) => {
13
- const abs = resolveAndCheckPath(args.path, ctx);
13
+ const abs = await resolveReadPath(args.path, ctx);
14
14
  const manifest = await ensureFresh(ctx.cwd);
15
15
  const rel = path.relative(ctx.cwd, abs).split(path.sep).join('/');
16
16
  const entry = manifest.files[rel];
@@ -0,0 +1,5 @@
1
+ import type { CommandSandbox } from '../types.js';
2
+ /** A host pattern matches exactly, as `*.example.com` (a subdomain, not the apex), or as `*` (anything). */
3
+ export declare function hostMatches(host: string, patterns: readonly string[]): boolean;
4
+ /** Why a tool call may not go ahead under this sandbox, or undefined. */
5
+ export declare function networkDenial(toolName: string, args: unknown, sandbox: CommandSandbox | undefined): string | undefined;
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The command sandbox's network setting, applied to Forge's own network tools (F13). With the network off, `run_command` is
3
+ * cut off by the OS sandbox, but fetch_url, web_search, repo_clone, the browser and GitHub run in Forge's process: they are
4
+ * held to the sandbox's allowed hosts here, from one place in the agent loop, so a new network tool is covered by adding it to
5
+ * `networkTarget` and cannot forget the check. An allowed-hosts list cannot be enforced for shell commands (they are all or
6
+ * nothing), so it applies to these tools only.
7
+ */
8
+ import net from 'node:net';
9
+ import { parseRepoSpec } from './repo-clone.js';
10
+ /** A host pattern matches exactly, as `*.example.com` (a subdomain, not the apex), or as `*` (anything). */
11
+ export function hostMatches(host, patterns) {
12
+ const h = host.toLowerCase();
13
+ return patterns.some((raw) => {
14
+ const p = raw.trim().toLowerCase();
15
+ if (p === '*')
16
+ return true;
17
+ if (p.startsWith('*.'))
18
+ return h.endsWith(p.slice(1)) && h.length > p.length - 1;
19
+ return h === p;
20
+ });
21
+ }
22
+ function isLocal(host) {
23
+ const h = host.replace(/^\[|\]$/g, '').toLowerCase();
24
+ return h === 'localhost' || h.endsWith('.localhost') || h === '::1' || (net.isIPv4(h) && h.startsWith('127.'));
25
+ }
26
+ /** What a network tool is about to reach, or undefined for a tool or action that does not use the network. */
27
+ function networkTarget(toolName, args) {
28
+ const a = (args && typeof args === 'object' ? args : {});
29
+ const url = (label) => {
30
+ try {
31
+ return { host: new URL(String(a.url)).hostname, label };
32
+ }
33
+ catch {
34
+ return { unreadable: 'The address could not be read' };
35
+ }
36
+ };
37
+ switch (toolName) {
38
+ case 'fetch_url':
39
+ return url('fetch a page');
40
+ case 'browser':
41
+ return a.action === 'open' ? url('open a page in the browser') : undefined;
42
+ case 'repo_clone':
43
+ try {
44
+ return { host: parseRepoSpec(String(a.repo ?? '')).host, label: 'download a repository' };
45
+ }
46
+ catch {
47
+ return { unreadable: 'The repository address could not be read' };
48
+ }
49
+ case 'web_search':
50
+ return { host: '*search*', label: 'search the web' };
51
+ case 'github_pr_read':
52
+ case 'github_pr_create':
53
+ return { host: 'api.github.com', label: 'use GitHub' };
54
+ default:
55
+ return undefined;
56
+ }
57
+ }
58
+ /** Why a tool call may not go ahead under this sandbox, or undefined. */
59
+ export function networkDenial(toolName, args, sandbox) {
60
+ if (!sandbox || sandbox.network)
61
+ return undefined;
62
+ const target = networkTarget(toolName, args);
63
+ if (!target)
64
+ return undefined;
65
+ if ('unreadable' in target)
66
+ return `The command sandbox's network is off, and ${target.unreadable.toLowerCase()}, so it was not tried.`;
67
+ const allowed = sandbox.allowedHosts ?? [];
68
+ if (target.host === '*search*') {
69
+ // A search goes to whichever service is set up; only an "any host" entry opens it.
70
+ return hostMatches('x', allowed.filter((p) => p.trim() === '*'))
71
+ ? undefined
72
+ : "The command sandbox's network is off, so Forge may not search the web. Allow all hosts in the sandbox's allowed hosts, or turn the network on.";
73
+ }
74
+ if (isLocal(target.host) || hostMatches(target.host, allowed))
75
+ return undefined;
76
+ return `The command sandbox's network is off, so Forge may not ${target.label} at ${target.host}. Add the host to the sandbox's allowed hosts, or turn the network on.`;
77
+ }
@@ -0,0 +1,6 @@
1
+ export type OfficeKind = 'docx' | 'xlsx';
2
+ export declare function officeKind(filePath: string): OfficeKind | undefined;
3
+ /** The text of the wanted entries of a zip archive. Throws a sentence a person can act on. */
4
+ export declare function readZip(buf: Buffer, want: (name: string) => boolean): Map<string, string>;
5
+ export declare function renderDocx(buf: Buffer): string;
6
+ export declare function renderXlsx(buf: Buffer): string;
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Read Word (.docx) and Excel (.xlsx) files as text for `read_file` (G20). Both are zip archives of XML. The archive is read
3
+ * with Node's zlib only (no dependency in the extension bundle) and never extracted to disk; what an entry may expand to is
4
+ * capped, and the XML is scanned with `indexOf` rather than a parser or a regex that can backtrack, because the file is
5
+ * whatever a repository or an email attachment contains (RB-22, RB-82).
6
+ */
7
+ import { inflateRawSync } from 'node:zlib';
8
+ import { decodeEntities } from './html-to-markdown.js';
9
+ export function officeKind(filePath) {
10
+ const m = /\.(docx|xlsx)$/i.exec(filePath);
11
+ return m ? m[1].toLowerCase() : undefined;
12
+ }
13
+ const MAX_ENTRY_BYTES = 20 * 1024 * 1024;
14
+ const MAX_ENTRIES = 10_000;
15
+ const MAX_SHEETS = 20;
16
+ const MAX_ROWS = 2_000;
17
+ const MAX_COLS = 100;
18
+ /** The text of the wanted entries of a zip archive. Throws a sentence a person can act on. */
19
+ export function readZip(buf, want) {
20
+ try {
21
+ return readZipUnchecked(buf, want);
22
+ }
23
+ catch (err) {
24
+ if (err instanceof RangeError)
25
+ throw new Error('The zip archive is damaged or cut short');
26
+ throw err;
27
+ }
28
+ }
29
+ function readZipUnchecked(buf, want) {
30
+ let eocd = -1;
31
+ for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 65_535); i--) {
32
+ if (buf.readUInt32LE(i) === 0x06054b50) {
33
+ eocd = i;
34
+ break;
35
+ }
36
+ }
37
+ if (eocd < 0)
38
+ throw new Error('This is not a zip archive. A password-protected Office file, or an older .doc or .xls saved with the wrong extension, looks like this');
39
+ const count = buf.readUInt16LE(eocd + 10);
40
+ const cdOffset = buf.readUInt32LE(eocd + 16);
41
+ if (count === 0xffff || cdOffset === 0xffffffff)
42
+ throw new Error('Zip64 archives are not supported');
43
+ if (count > MAX_ENTRIES)
44
+ throw new Error('The zip archive holds too many files');
45
+ const out = new Map();
46
+ let p = cdOffset;
47
+ for (let n = 0; n < count; n++) {
48
+ if (p + 46 > buf.length || buf.readUInt32LE(p) !== 0x02014b50)
49
+ throw new Error('The zip archive is damaged (its file list is unreadable)');
50
+ const flags = buf.readUInt16LE(p + 8);
51
+ const method = buf.readUInt16LE(p + 10);
52
+ const compressed = buf.readUInt32LE(p + 20);
53
+ const size = buf.readUInt32LE(p + 24);
54
+ const nameLen = buf.readUInt16LE(p + 28);
55
+ const extraLen = buf.readUInt16LE(p + 30);
56
+ const commentLen = buf.readUInt16LE(p + 32);
57
+ const local = buf.readUInt32LE(p + 42);
58
+ const name = buf.toString('utf8', p + 46, p + 46 + nameLen);
59
+ p += 46 + nameLen + extraLen + commentLen;
60
+ if (!want(name))
61
+ continue;
62
+ if (flags & 1)
63
+ throw new Error('The file is password-protected (encrypted)');
64
+ if (size > MAX_ENTRY_BYTES)
65
+ throw new Error(`${name} is too large to read (over ${MAX_ENTRY_BYTES / 1024 / 1024} MB expanded)`);
66
+ if (local + 30 > buf.length || buf.readUInt32LE(local) !== 0x04034b50)
67
+ throw new Error('The zip archive is damaged (a file header is missing)');
68
+ const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28);
69
+ const data = buf.subarray(start, start + compressed);
70
+ if (data.length !== compressed)
71
+ throw new Error('The zip archive is damaged (a file is cut short)');
72
+ let raw;
73
+ if (method === 0)
74
+ raw = data;
75
+ else if (method === 8) {
76
+ try {
77
+ raw = inflateRawSync(data, { maxOutputLength: MAX_ENTRY_BYTES });
78
+ }
79
+ catch (err) {
80
+ const code = err.code;
81
+ throw new Error(code === 'ERR_BUFFER_TOO_LARGE' ? `${name} is too large to read (over ${MAX_ENTRY_BYTES / 1024 / 1024} MB expanded)` : `${name} is damaged inside the zip archive`);
82
+ }
83
+ }
84
+ else
85
+ throw new Error(`${name} uses a zip compression method that is not supported`);
86
+ if (raw.length > MAX_ENTRY_BYTES)
87
+ throw new Error(`${name} is too large to read`);
88
+ out.set(name, raw.toString('utf8'));
89
+ }
90
+ return out;
91
+ }
92
+ /** Index of the next `<name` that is really that element (`<w:p>` or `<w:p ...>`, not `<w:pPr>`), or -1. */
93
+ function findOpen(s, name, from) {
94
+ const open = `<${name}`;
95
+ let i = s.indexOf(open, from);
96
+ while (i !== -1) {
97
+ const c = s.charAt(i + open.length);
98
+ if (c === '>' || c === ' ' || c === '/' || c === '\n' || c === '\t' || c === '\r')
99
+ return i;
100
+ i = s.indexOf(open, i + 1);
101
+ }
102
+ return -1;
103
+ }
104
+ /** Every element with one of `names`, in document order, each consumed whole (a nested element of the same name stays inside). */
105
+ function* elements(s, names) {
106
+ const next = {};
107
+ let from = 0;
108
+ for (;;) {
109
+ let best = '';
110
+ let bestPos = -1;
111
+ for (const nm of names) {
112
+ let pos = next[nm];
113
+ if (pos === undefined || (pos !== -1 && pos < from)) {
114
+ pos = findOpen(s, nm, from);
115
+ next[nm] = pos;
116
+ }
117
+ if (pos !== -1 && (bestPos === -1 || pos < bestPos)) {
118
+ bestPos = pos;
119
+ best = nm;
120
+ }
121
+ }
122
+ if (bestPos === -1)
123
+ return;
124
+ const gt = s.indexOf('>', bestPos);
125
+ if (gt === -1)
126
+ return;
127
+ const attrs = s.slice(bestPos + best.length + 1, gt);
128
+ if (s.charAt(gt - 1) === '/') {
129
+ yield { name: best, attrs, inner: '' };
130
+ from = gt + 1;
131
+ continue;
132
+ }
133
+ const close = `</${best}>`;
134
+ const end = s.indexOf(close, gt);
135
+ if (end === -1)
136
+ return;
137
+ yield { name: best, attrs, inner: s.slice(gt + 1, end) };
138
+ from = end + close.length;
139
+ }
140
+ }
141
+ function attrOf(attrs, name) {
142
+ const m = new RegExp(`(?:^|\\s)${name}\\s*=\\s*(?:"([^"]*)"|'([^']*)')`).exec(attrs);
143
+ return m ? decodeEntities(m[1] ?? m[2] ?? '') : undefined;
144
+ }
145
+ /** The text of every `<tag>` in `s` (`<w:t>`, `<t>`): the characters between the tags, entities decoded. */
146
+ function textsOf(s, tag) {
147
+ let out = '';
148
+ for (const el of elements(s, [tag]))
149
+ out += decodeEntities(el.inner);
150
+ return out;
151
+ }
152
+ function escapeCell(s) {
153
+ return s.replace(/\s+/g, ' ').trim().replace(/\|/g, '\\|');
154
+ }
155
+ function markdownTable(rows) {
156
+ const width = Math.max(...rows.map((r) => r.length));
157
+ const pad = (r) => [...r, ...Array(width - r.length).fill('')];
158
+ const line = (r) => `| ${pad(r).join(' | ')} |`;
159
+ return [line(rows[0]), line(Array(width).fill('---')), ...rows.slice(1).map(line)].join('\n');
160
+ }
161
+ // ---- Word ----
162
+ const NOT_WORD = 'This is not a Word document (word/document.xml is missing). It may be an older .doc file saved with the wrong extension.';
163
+ function docxParagraph(inner) {
164
+ const style = /<w:pStyle\s[^>]*w:val="([^"]*)"/.exec(inner)?.[1] ?? '';
165
+ let text = '';
166
+ const tokens = /<w:t(?:\s[^>]*)?>([^<]*)<\/w:t>|<w:tab\s*\/>|<w:br\b[^>]*>/g;
167
+ for (const m of inner.matchAll(tokens)) {
168
+ if (m[1] !== undefined)
169
+ text += decodeEntities(m[1]);
170
+ else
171
+ text += m[0].startsWith('<w:tab') ? '\t' : '\n';
172
+ }
173
+ const h = /^heading\s*(\d)$/i.exec(style);
174
+ const heading = h ? Number(h[1]) : /^title$/i.test(style) ? 1 : 0;
175
+ const numbered = inner.includes('<w:numPr') || /^list/i.test(style);
176
+ const level = Number(/<w:ilvl\s[^>]*w:val="(\d+)"/.exec(inner)?.[1] ?? 0);
177
+ return { text, heading, list: numbered && !heading ? Math.min(level, 8) : undefined };
178
+ }
179
+ function docxTable(inner) {
180
+ const rows = [];
181
+ for (const tr of elements(inner, ['w:tr'])) {
182
+ const cells = [];
183
+ for (const tc of elements(tr.inner, ['w:tc'])) {
184
+ const parts = [];
185
+ for (const p of elements(tc.inner, ['w:p']))
186
+ parts.push(docxParagraph(p.inner).text);
187
+ cells.push(escapeCell(parts.join(' ')));
188
+ }
189
+ if (cells.length > 0)
190
+ rows.push(cells);
191
+ }
192
+ return rows.length > 0 ? markdownTable(rows) : '';
193
+ }
194
+ export function renderDocx(buf) {
195
+ const xml = readZip(buf, (n) => n === 'word/document.xml').get('word/document.xml');
196
+ if (xml === undefined)
197
+ throw new Error(NOT_WORD);
198
+ const lines = [];
199
+ let inList = false;
200
+ const blank = () => {
201
+ if (lines.length > 0 && lines[lines.length - 1] !== '')
202
+ lines.push('');
203
+ };
204
+ for (const el of elements(xml, ['w:tbl', 'w:p'])) {
205
+ if (el.name === 'w:tbl') {
206
+ const table = docxTable(el.inner);
207
+ if (table) {
208
+ blank();
209
+ lines.push(table);
210
+ inList = false;
211
+ }
212
+ continue;
213
+ }
214
+ const para = docxParagraph(el.inner);
215
+ if (!para.text.trim())
216
+ continue;
217
+ if (para.list !== undefined) {
218
+ if (!inList)
219
+ blank();
220
+ lines.push(`${' '.repeat(para.list)}- ${para.text.trim()}`);
221
+ inList = true;
222
+ continue;
223
+ }
224
+ inList = false;
225
+ blank();
226
+ lines.push(para.heading > 0 ? `${'#'.repeat(Math.min(para.heading, 6))} ${para.text.trim()}` : para.text);
227
+ }
228
+ const out = lines.join('\n').trim();
229
+ return out || '(the document has no text)';
230
+ }
231
+ // ---- Excel ----
232
+ const NOT_EXCEL = 'This is not an Excel workbook (xl/workbook.xml is missing). It may be an older .xls file saved with the wrong extension.';
233
+ function columnIndex(ref) {
234
+ let n = 0;
235
+ for (let i = 0; i < ref.length; i++) {
236
+ const c = ref.charCodeAt(i);
237
+ if (c < 65 || c > 90)
238
+ break;
239
+ n = n * 26 + (c - 64);
240
+ }
241
+ return n;
242
+ }
243
+ function columnLetters(index) {
244
+ let s = '';
245
+ for (let n = index; n > 0; n = Math.floor((n - 1) / 26))
246
+ s = String.fromCharCode(65 + ((n - 1) % 26)) + s;
247
+ return s;
248
+ }
249
+ /** Number formats that show a date or time: the built-in ids and any custom code that has date or time letters. */
250
+ function isDateFormat(id, code) {
251
+ if ((id >= 14 && id <= 22) || (id >= 27 && id <= 36) || (id >= 45 && id <= 47) || (id >= 50 && id <= 58))
252
+ return true;
253
+ if (code === undefined)
254
+ return false;
255
+ const bare = code.replace(/"[^"]*"/g, '').replace(/\\./g, '').replace(/\[[^\]]*\]/g, '');
256
+ return /[dyhms]/i.test(bare);
257
+ }
258
+ function dateStyles(styles) {
259
+ const out = new Set();
260
+ if (!styles)
261
+ return out;
262
+ const codes = new Map();
263
+ for (const el of elements(styles, ['numFmt'])) {
264
+ const id = Number(attrOf(el.attrs, 'numFmtId'));
265
+ const code = attrOf(el.attrs, 'formatCode');
266
+ if (Number.isFinite(id) && code !== undefined)
267
+ codes.set(id, code);
268
+ }
269
+ for (const xfs of elements(styles, ['cellXfs'])) {
270
+ let index = 0;
271
+ for (const xf of elements(xfs.inner, ['xf'])) {
272
+ const id = Number(attrOf(xf.attrs, 'numFmtId') ?? 0);
273
+ if (isDateFormat(id, codes.get(id)))
274
+ out.add(index);
275
+ index++;
276
+ }
277
+ break;
278
+ }
279
+ return out;
280
+ }
281
+ function serialToText(serial, epoch1904) {
282
+ const days = serial - (epoch1904 ? 24107 : 25569);
283
+ const iso = new Date(Math.round(days * 86_400_000)).toISOString(); // 2023-03-15T12:00:00.000Z
284
+ const date = iso.slice(0, 10);
285
+ const time = iso.slice(11, 19);
286
+ if (serial < 1)
287
+ return time;
288
+ if (time === '00:00:00')
289
+ return date;
290
+ return `${date} ${time.endsWith(':00') ? time.slice(0, 5) : time}`;
291
+ }
292
+ function sharedStrings(xml) {
293
+ if (!xml)
294
+ return [];
295
+ const out = [];
296
+ for (const si of elements(xml, ['si'])) {
297
+ // Phonetic guides (<rPh>) repeat the text in another script: leave them out.
298
+ let inner = si.inner;
299
+ for (let a = inner.indexOf('<rPh'); a !== -1; a = inner.indexOf('<rPh')) {
300
+ const b = inner.indexOf('</rPh>', a);
301
+ inner = b === -1 ? inner.slice(0, a) : inner.slice(0, a) + inner.slice(b + 6);
302
+ }
303
+ out.push(textsOf(inner, 't'));
304
+ }
305
+ return out;
306
+ }
307
+ export function renderXlsx(buf) {
308
+ const files = readZip(buf, (n) => n === 'xl/workbook.xml' || n === 'xl/_rels/workbook.xml.rels' || n === 'xl/sharedStrings.xml' || n === 'xl/styles.xml' || /^xl\/worksheets\/[^/]+\.xml$/.test(n));
309
+ const workbook = files.get('xl/workbook.xml');
310
+ if (workbook === undefined)
311
+ throw new Error(NOT_EXCEL);
312
+ const epoch1904 = /<workbookPr\b[^>]*date1904="(?:1|true)"/.test(workbook);
313
+ const targets = new Map();
314
+ for (const rel of elements(files.get('xl/_rels/workbook.xml.rels') ?? '', ['Relationship'])) {
315
+ const id = attrOf(rel.attrs, 'Id');
316
+ const target = attrOf(rel.attrs, 'Target');
317
+ if (id && target)
318
+ targets.set(id, target.startsWith('/') ? target.slice(1) : `xl/${target.replace(/^\.\//, '')}`);
319
+ }
320
+ const strings = sharedStrings(files.get('xl/sharedStrings.xml'));
321
+ const dates = dateStyles(files.get('xl/styles.xml'));
322
+ const sections = [];
323
+ let shown = 0;
324
+ for (const sheet of elements(workbook, ['sheet'])) {
325
+ if (shown++ >= MAX_SHEETS) {
326
+ sections.push(`(more sheets not shown; ${MAX_SHEETS} at most)`);
327
+ break;
328
+ }
329
+ const name = attrOf(sheet.attrs, 'name') ?? 'Sheet';
330
+ const hidden = (attrOf(sheet.attrs, 'state') ?? 'visible') !== 'visible';
331
+ const heading = `## ${name}${hidden ? ' (hidden)' : ''}`;
332
+ const xml = files.get(targets.get(attrOf(sheet.attrs, 'r:id') ?? '') ?? '');
333
+ sections.push(`${heading}\n\n${xml === undefined ? '(this sheet could not be found in the file)' : renderSheet(xml, strings, dates, epoch1904)}`);
334
+ }
335
+ return sections.join('\n\n') || '(the workbook has no sheets)';
336
+ }
337
+ function renderSheet(xml, strings, dates, epoch1904) {
338
+ const rows = new Map();
339
+ let maxCol = 0;
340
+ let seq = 0;
341
+ let more = false;
342
+ for (const row of elements(xml, ['row'])) {
343
+ seq = Number(attrOf(row.attrs, 'r')) || seq + 1;
344
+ if (rows.size >= MAX_ROWS) {
345
+ more = true;
346
+ break;
347
+ }
348
+ const cells = new Map();
349
+ let nextCol = 0;
350
+ for (const c of elements(row.inner, ['c'])) {
351
+ const ref = attrOf(c.attrs, 'r') ?? '';
352
+ const col = ref ? columnIndex(ref) : nextCol + 1;
353
+ nextCol = col;
354
+ if (col < 1 || col > MAX_COLS)
355
+ continue;
356
+ const text = cellText(c, strings, dates, epoch1904);
357
+ if (text !== '') {
358
+ cells.set(col, escapeCell(text));
359
+ if (col > maxCol)
360
+ maxCol = col;
361
+ }
362
+ }
363
+ if (cells.size > 0)
364
+ rows.set(seq, cells);
365
+ }
366
+ if (rows.size === 0)
367
+ return '(empty)';
368
+ const header = ['', ...Array.from({ length: maxCol }, (_, i) => columnLetters(i + 1))];
369
+ const body = [...rows.entries()].map(([r, cells]) => [String(r), ...Array.from({ length: maxCol }, (_, i) => cells.get(i + 1) ?? '')]);
370
+ const table = markdownTable([header, ...body]);
371
+ return more ? `${table}\n\n(more rows not shown; the first ${MAX_ROWS} rows with content are listed)` : table;
372
+ }
373
+ function cellText(c, strings, dates, epoch1904) {
374
+ const type = attrOf(c.attrs, 't') ?? 'n';
375
+ const v = textsOf(c.inner, 'v');
376
+ if (type === 'inlineStr')
377
+ return textsOf(c.inner, 't');
378
+ if (type === 's')
379
+ return strings[Number(v)] ?? '';
380
+ if (type === 'b')
381
+ return v === '1' ? 'TRUE' : v === '0' ? 'FALSE' : v;
382
+ if (v === '') {
383
+ const f = textsOf(c.inner, 'f');
384
+ return f ? `=${f}` : '';
385
+ }
386
+ if (type === 'n') {
387
+ const style = Number(attrOf(c.attrs, 's') ?? 0);
388
+ const n = Number(v);
389
+ if (dates.has(style) && Number.isFinite(n) && n >= 0 && n < 2_958_466)
390
+ return serialToText(n, epoch1904);
391
+ }
392
+ return v;
393
+ }
@@ -118,12 +118,58 @@ export function splitShellCommands(command) {
118
118
  for (const inner of splitShellCommands(m[1] ?? m[2] ?? ''))
119
119
  extra.push(inner);
120
120
  }
121
- if (/(^|[^>&0-9])>{1,2}(?!&)/.test(stripQuoted(command)))
121
+ if (writesFile(command))
122
122
  extra.push(`redirect: ${command.trim()}`);
123
- return [...out, ...extra];
123
+ // A pipe into PowerShell's Out-Null only throws output away: it is not a command that needs its own rule (G21).
124
+ return [...out.filter((c) => !/^out-null$/i.test(c)), ...extra];
124
125
  }
125
- function stripQuoted(s) {
126
- return s.replace(/"[^"]*"|'[^']*'/g, '""');
126
+ /** Where output is thrown away, so redirecting to it writes nothing (G21). */
127
+ const NULL_SINKS = /^(?:\/dev\/null|nul|\$null)$/i;
128
+ /**
129
+ * Does an unquoted output redirect write somewhere real? `> file`, `>> file`, `2>file`, `&>file` and `>&file` do; a
130
+ * redirect to `/dev/null`, `nul` or `$null` and a copy of one stream onto another (`2>&1`, `>&2`) do not.
131
+ * The old test skipped any `>` after a digit or an ampersand, which let `2>out.txt` and `&>out.txt` write a file unseen.
132
+ */
133
+ function writesFile(command) {
134
+ let quote = null;
135
+ for (let i = 0; i < command.length; i++) {
136
+ const c = command[i];
137
+ if (quote) {
138
+ if (c === quote)
139
+ quote = null;
140
+ continue;
141
+ }
142
+ if (c === '"' || c === "'") {
143
+ quote = c;
144
+ continue;
145
+ }
146
+ if (c !== '>')
147
+ continue;
148
+ let j = i + 1;
149
+ if (command[j] === '>')
150
+ j++; // >>
151
+ // >& : a copy of one stream onto another when a stream number or - follows; otherwise both streams go to the named file
152
+ if (command[j] === '&') {
153
+ const dup = /^&(?:\d+|-)(?![\w./\\-])/.exec(command.slice(j));
154
+ if (dup) {
155
+ i = j + dup[0].length - 1;
156
+ continue;
157
+ }
158
+ j++;
159
+ }
160
+ while (command[j] === ' ' || command[j] === '\t')
161
+ j++;
162
+ if (command[j] === '(')
163
+ return true; // >(...) process substitution
164
+ let k = j;
165
+ while (k < command.length && !/[\s;|&<>()]/.test(command[k]))
166
+ k++;
167
+ const target = command.slice(j, k).replace(/^["']|["']$/g, '');
168
+ if (!NULL_SINKS.test(target))
169
+ return true;
170
+ i = Math.max(i, k - 1);
171
+ }
172
+ return false;
127
173
  }
128
174
  /** `.env` and `.env.*` hold secrets; `.env.example` and `.env.sample` are documentation. */
129
175
  export function isSensitiveFile(relPath) {
@@ -170,10 +216,14 @@ export function permissionFor(toolName, args, cwd) {
170
216
  }
171
217
  if (EDIT_TOOLS.has(toolName))
172
218
  return { permission: 'edit', targets: [relTarget(a.path, cwd)] };
173
- if (toolName === 'run_command')
219
+ // bg_start starts the same kind of thing as run_command, so a rule about commands covers it.
220
+ if (toolName === 'run_command' || toolName === 'bg_start')
174
221
  return { permission: 'bash', targets: splitShellCommands(typeof a.command === 'string' ? a.command : '') };
175
222
  if (toolName === 'fetch_url')
176
223
  return { permission: 'webfetch', targets: [typeof a.url === 'string' ? a.url : ''] };
224
+ // A clone reaches a host over the network, so a rule about fetching covers it.
225
+ if (toolName === 'repo_clone')
226
+ return { permission: 'webfetch', targets: [typeof a.repo === 'string' ? a.repo : ''] };
177
227
  if (toolName === 'web_search')
178
228
  return { permission: 'websearch', targets: [typeof a.query === 'string' ? a.query : ''] };
179
229
  if (toolName === 'sub_agent')
@@ -11,6 +11,8 @@ export interface ReadFileResult {
11
11
  offset: number;
12
12
  content: string;
13
13
  truncated: boolean;
14
+ /** Where to carry on reading when the result stopped early (`offset` for the next call). */
15
+ nextOffset?: number;
14
16
  /** `AGENTS.md` files of the folders this file sits in, shown the first time a file there is read. */
15
17
  folderInstructions?: Array<{
16
18
  file: string;