@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.
- package/dist/agent/agent.js +132 -70
- package/dist/agent/cache-hints.d.ts +2 -0
- package/dist/agent/cache-hints.js +18 -3
- package/dist/agent/history-adapt.d.ts +23 -0
- package/dist/agent/history-adapt.js +114 -0
- package/dist/agent/prompts.d.ts +4 -2
- package/dist/agent/prompts.js +10 -2
- package/dist/agent/tool-repair.d.ts +8 -0
- package/dist/agent/tool-repair.js +16 -0
- package/dist/catalog/models-dev.d.ts +10 -0
- package/dist/catalog/models-dev.js +12 -0
- package/dist/checkpoints/store.js +3 -1
- package/dist/context/compaction.d.ts +21 -6
- package/dist/context/compaction.js +86 -26
- package/dist/context/mask.d.ts +9 -9
- package/dist/context/mask.js +30 -1
- package/dist/format/auto-format.d.ts +22 -0
- package/dist/format/auto-format.js +39 -0
- package/dist/github/github-tools.d.ts +1 -0
- package/dist/github/github-tools.js +9 -9
- package/dist/github/pr.d.ts +34 -0
- package/dist/github/pr.js +70 -0
- package/dist/human-text.d.ts +6 -0
- package/dist/human-text.js +37 -0
- package/dist/index.d.ts +13 -8
- package/dist/index.js +10 -6
- package/dist/journey/answers-block.d.ts +2 -4
- package/dist/lsp/diagnostics.d.ts +4 -0
- package/dist/lsp/diagnostics.js +8 -2
- package/dist/lsp/index.d.ts +1 -1
- package/dist/lsp/index.js +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/json-schema-to-zod.js +2 -4
- package/dist/mcp/mcp-client.d.ts +14 -0
- package/dist/mcp/mcp-client.js +102 -18
- package/dist/orchestrator/specialists-registry.js +14 -1
- package/dist/orchestrator/specialists.js +3 -3
- package/dist/semantic/embedder.d.ts +11 -0
- package/dist/semantic/embedder.js +16 -0
- package/dist/semantic/semantic-index.d.ts +39 -1
- package/dist/semantic/semantic-index.js +43 -13
- package/dist/semantic/vector-store.d.ts +2 -0
- package/dist/semantic/vector-store.js +13 -1
- package/dist/sessions/index.d.ts +2 -2
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/search.js +2 -2
- package/dist/sessions/session-export.d.ts +1 -1
- package/dist/sessions/session-export.js +41 -33
- package/dist/sessions/session-store.d.ts +20 -1
- package/dist/sessions/session-store.js +7 -0
- package/dist/sessions/watch.js +4 -4
- package/dist/skills/activator.d.ts +6 -0
- package/dist/skills/activator.js +17 -0
- package/dist/skills/skill-tool.d.ts +12 -0
- package/dist/skills/skill-tool.js +28 -0
- package/dist/tools/background-process.d.ts +63 -0
- package/dist/tools/background-process.js +150 -0
- package/dist/tools/edit-file.js +2 -27
- package/dist/tools/edit-match.d.ts +16 -0
- package/dist/tools/edit-match.js +115 -0
- package/dist/tools/fetch-url.d.ts +15 -1
- package/dist/tools/fetch-url.js +7 -47
- package/dist/tools/get-diagnostics.d.ts +14 -0
- package/dist/tools/get-diagnostics.js +44 -0
- package/dist/tools/glob-filter.d.ts +7 -0
- package/dist/tools/glob-filter.js +12 -0
- package/dist/tools/html-to-markdown.d.ts +13 -0
- package/dist/tools/html-to-markdown.js +507 -0
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/list-files.d.ts +1 -0
- package/dist/tools/list-files.js +17 -5
- package/dist/tools/list-symbols.js +2 -2
- package/dist/tools/network-policy.d.ts +5 -0
- package/dist/tools/network-policy.js +77 -0
- package/dist/tools/office-docs.d.ts +6 -0
- package/dist/tools/office-docs.js +393 -0
- package/dist/tools/permission-rules.js +55 -5
- package/dist/tools/read-file.d.ts +2 -0
- package/dist/tools/read-file.js +47 -10
- package/dist/tools/read-image.js +2 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +4 -0
- package/dist/tools/repo-clone.d.ts +40 -0
- package/dist/tools/repo-clone.js +218 -0
- package/dist/tools/result-cap.d.ts +8 -0
- package/dist/tools/result-cap.js +44 -0
- package/dist/tools/run-command.d.ts +18 -1
- package/dist/tools/run-command.js +41 -16
- package/dist/tools/search-files.js +5 -5
- package/dist/tools/search-worker.js +11 -2
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +76 -4
- package/dist/tools/shell.d.ts +27 -0
- package/dist/tools/shell.js +80 -0
- package/dist/tools/sub-agent.js +3 -1
- package/dist/tools/suggest-plan.d.ts +18 -0
- package/dist/tools/suggest-plan.js +28 -0
- package/dist/types.d.ts +74 -0
- package/dist/wakeup/wakeup-tools.d.ts +22 -0
- package/dist/wakeup/wakeup-tools.js +34 -0
- package/dist/wakeup/wakeup.d.ts +75 -0
- package/dist/wakeup/wakeup.js +151 -0
- package/dist/worktree/health.d.ts +51 -0
- package/dist/worktree/health.js +186 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/setup.d.ts +51 -0
- package/dist/worktree/setup.js +173 -0
- package/dist/worktree/worktree.d.ts +7 -0
- package/dist/worktree/worktree.js +9 -4
- package/package.json +1 -1
|
@@ -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 (
|
|
121
|
+
if (writesFile(command))
|
|
122
122
|
extra.push(`redirect: ${command.trim()}`);
|
|
123
|
-
|
|
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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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;
|
package/dist/tools/read-file.js
CHANGED
|
@@ -1,10 +1,14 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { isNotebookPath, parseNotebook, renderNotebook } from './notebook.js';
|
|
4
|
+
import { officeKind, renderDocx, renderXlsx } from './office-docs.js';
|
|
4
5
|
import { isPdf, MAX_ATTACHMENT_BYTES } from '../attachments.js';
|
|
5
6
|
import { folderInstructionsFor } from '../steering/folder-instructions.js';
|
|
6
|
-
import {
|
|
7
|
+
import { resolveReadPath } from './shared.js';
|
|
7
8
|
import { decodeText } from './text-encoding.js';
|
|
9
|
+
const MAX_LINE_CHARS = 2000;
|
|
10
|
+
/** About 50 KB of text per call: enough to page through a file, small enough to leave room for the rest of the chat. */
|
|
11
|
+
const MAX_READ_CHARS = 50_000;
|
|
8
12
|
const params = z.object({
|
|
9
13
|
path: z.string().describe('Path to the file to read, relative to the working directory or absolute.'),
|
|
10
14
|
/** 1-based line number to start reading from. Omit to start at the beginning. */
|
|
@@ -14,10 +18,10 @@ const params = z.object({
|
|
|
14
18
|
});
|
|
15
19
|
export const readFileTool = {
|
|
16
20
|
name: 'read_file',
|
|
17
|
-
description: 'Read the contents of a file. Returns text with line numbers; supports an optional offset and max_lines to page through large files. A Jupyter notebook (.ipynb) is shown as numbered cells with their outputs. A PDF is attached whole so you can read it (text, tables and figures). Other binary files are rejected.',
|
|
21
|
+
description: 'Read the contents of a file. Returns text with line numbers; supports an optional offset and max_lines to page through large files. A Jupyter notebook (.ipynb) is shown as numbered cells with their outputs. A Word document (.docx) is shown as text with its headings, lists and tables; an Excel workbook (.xlsx) as one table per sheet with column letters and row numbers. A PDF is attached whole so you can read it (text, tables and figures). Other binary files, including the older .doc and .xls, are rejected.',
|
|
18
22
|
parameters: params,
|
|
19
23
|
execute: async (args, ctx) => {
|
|
20
|
-
const resolved =
|
|
24
|
+
const resolved = await resolveReadPath(args.path, ctx);
|
|
21
25
|
let raw;
|
|
22
26
|
try {
|
|
23
27
|
raw = await readFile(resolved);
|
|
@@ -48,12 +52,25 @@ export const readFileTool = {
|
|
|
48
52
|
}
|
|
49
53
|
return { path: resolved, mime: 'application/pdf', bytes: raw.length, dataUrl: `data:application/pdf;base64,${raw.toString('base64')}` };
|
|
50
54
|
}
|
|
51
|
-
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
54
|
-
|
|
55
|
+
let text;
|
|
56
|
+
const office = officeKind(resolved);
|
|
57
|
+
if (office) {
|
|
58
|
+
// A Word or Excel file is a zip of XML: show its text, then page through it like any other file (G20).
|
|
59
|
+
try {
|
|
60
|
+
text = office === 'docx' ? renderDocx(raw) : renderXlsx(raw);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
throw new Error(`Could not read ${args.path}: ${err.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// Reject obvious binary content.
|
|
68
|
+
const decoded = decodeText(raw);
|
|
69
|
+
if (decoded.encoding === 'utf8' && looksBinary(raw)) {
|
|
70
|
+
throw new Error(`Refusing to read binary file: ${args.path} (${raw.length} bytes)`);
|
|
71
|
+
}
|
|
72
|
+
text = decoded.text;
|
|
55
73
|
}
|
|
56
|
-
const text = decoded.text;
|
|
57
74
|
const lines = text.split(/\r?\n/);
|
|
58
75
|
// A file that ends with a newline splits into one extra, empty piece. It is not a line. Numbering it told the model a
|
|
59
76
|
// 37-line file had 38 lines and that the last was empty (a live spec caught it), and made a file of exactly max_lines
|
|
@@ -64,14 +81,34 @@ export const readFileTool = {
|
|
|
64
81
|
const maxLines = args.max_lines ?? 2000;
|
|
65
82
|
const slice = lines.slice(offset - 1, offset - 1 + maxLines);
|
|
66
83
|
const endIdx = offset - 1 + maxLines;
|
|
67
|
-
|
|
84
|
+
// A minified file is one line of hundreds of kilobytes, and a log can be megabytes: cut long lines and stop at a byte
|
|
85
|
+
// budget, and say where to carry on (G4). The line limit alone did neither.
|
|
86
|
+
const shown = [];
|
|
87
|
+
let used = 0;
|
|
88
|
+
let nextOffset;
|
|
89
|
+
for (let i = 0; i < slice.length; i++) {
|
|
90
|
+
const line = slice[i];
|
|
91
|
+
const cut = line.length > MAX_LINE_CHARS ? `${line.slice(0, MAX_LINE_CHARS)}... (${line.length - MAX_LINE_CHARS} more characters)` : line;
|
|
92
|
+
const row = `${String(offset + i).padStart(6)}\t${cut}`;
|
|
93
|
+
if (shown.length > 0 && used + row.length + 1 > MAX_READ_CHARS) {
|
|
94
|
+
nextOffset = offset + i;
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
shown.push(row);
|
|
98
|
+
used += row.length + 1;
|
|
99
|
+
}
|
|
100
|
+
if (nextOffset === undefined && endIdx < lines.length)
|
|
101
|
+
nextOffset = endIdx + 1;
|
|
102
|
+
const truncated = nextOffset !== undefined;
|
|
103
|
+
const note = nextOffset !== undefined ? `\n\n... (${lines.length - nextOffset + 1} more lines; continue with offset ${nextOffset})` : '';
|
|
68
104
|
const folderInstructions = ctx.shownFolderInstructions ? await folderInstructionsFor(resolved, ctx.cwd, ctx.shownFolderInstructions) : undefined;
|
|
69
105
|
return {
|
|
70
106
|
path: resolved,
|
|
71
107
|
totalLines: lines.length,
|
|
72
108
|
offset,
|
|
73
|
-
content:
|
|
109
|
+
content: shown.join('\n') + note,
|
|
74
110
|
truncated,
|
|
111
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
75
112
|
...(folderInstructions ? { folderInstructions } : {}),
|
|
76
113
|
};
|
|
77
114
|
},
|