@ikarmic/forge-core 5.0.0 → 5.1.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.d.ts +8 -0
- package/dist/agent/agent.js +123 -22
- package/dist/agent/background.d.ts +58 -0
- package/dist/agent/background.js +139 -0
- package/dist/agent/enhance-prompt.d.ts +3 -1
- package/dist/agent/enhance-prompt.js +6 -2
- package/dist/agent/goal.d.ts +54 -0
- package/dist/agent/goal.js +65 -0
- package/dist/agents/agent-files.d.ts +43 -0
- package/dist/agents/agent-files.js +109 -0
- package/dist/checkpoints/store.js +5 -1
- package/dist/commands/custom-commands.d.ts +9 -4
- package/dist/commands/custom-commands.js +13 -2
- package/dist/config-file.d.ts +43 -0
- package/dist/config-file.js +249 -5
- package/dist/config-merge.d.ts +13 -0
- package/dist/config-merge.js +67 -0
- package/dist/config-schema.d.ts +340 -0
- package/dist/config-schema.js +126 -0
- package/dist/config.js +2 -1
- package/dist/context/compaction.js +4 -0
- package/dist/context/mask.js +1 -1
- package/dist/custom-providers.d.ts +95 -0
- package/dist/custom-providers.js +120 -0
- package/dist/git/auto-commit.d.ts +2 -2
- package/dist/git/auto-commit.js +5 -4
- package/dist/github/github-tools.d.ts +2 -2
- package/dist/hooks/runner.js +3 -2
- package/dist/import/claude-code.d.ts +39 -0
- package/dist/import/claude-code.js +254 -0
- package/dist/index.d.ts +32 -4
- package/dist/index.js +24 -3
- package/dist/lsp/client.d.ts +5 -0
- package/dist/lsp/client.js +39 -3
- package/dist/mcp/index.d.ts +5 -1
- package/dist/mcp/index.js +3 -1
- package/dist/mcp/manager.d.ts +17 -3
- package/dist/mcp/manager.js +27 -2
- package/dist/mcp/marketplace.d.ts +2 -0
- package/dist/mcp/marketplace.js +10 -4
- package/dist/mcp/mcp-client.d.ts +12 -2
- package/dist/mcp/mcp-client.js +74 -17
- package/dist/mcp/mcp-config.d.ts +2 -0
- package/dist/mcp/mcp-config.js +1 -0
- package/dist/mcp/oauth.d.ts +55 -0
- package/dist/mcp/oauth.js +197 -0
- package/dist/mcp/resource-tools.d.ts +8 -0
- package/dist/mcp/resource-tools.js +56 -0
- package/dist/memory/session-digest.d.ts +12 -0
- package/dist/memory/session-digest.js +47 -0
- package/dist/memory/stale.js +2 -1
- package/dist/model-alias.d.ts +18 -2
- package/dist/model-alias.js +27 -3
- package/dist/model-catalog.js +0 -1
- package/dist/model-list.js +2 -1
- package/dist/model-overrides.d.ts +30 -0
- package/dist/model-overrides.js +48 -0
- package/dist/orchestrator/specialists-registry.js +2 -3
- package/dist/orchestrator/specialists.js +4 -4
- package/dist/policy/ca-certs.d.ts +8 -0
- package/dist/policy/ca-certs.js +30 -0
- package/dist/policy/proxy.d.ts +10 -3
- package/dist/policy/proxy.js +33 -6
- package/dist/pricing.js +4 -0
- package/dist/providers.d.ts +7 -0
- package/dist/providers.js +39 -5
- package/dist/semantic/embedder.d.ts +15 -0
- package/dist/semantic/embedder.js +25 -0
- package/dist/semantic/semantic-index.d.ts +10 -2
- package/dist/semantic/semantic-index.js +16 -7
- package/dist/sessions/session-store.js +2 -1
- package/dist/skills/builtin.d.ts +2 -0
- package/dist/skills/builtin.js +68 -0
- package/dist/skills/index.d.ts +2 -0
- package/dist/skills/index.js +2 -0
- package/dist/skills/loader.d.ts +10 -3
- package/dist/skills/loader.js +20 -7
- package/dist/skills/marketplace.js +2 -1
- package/dist/skills/remote.d.ts +14 -0
- package/dist/skills/remote.js +131 -0
- package/dist/skills/types.d.ts +10 -0
- package/dist/steering/extra-instructions.d.ts +8 -0
- package/dist/steering/extra-instructions.js +104 -0
- package/dist/steering/folder-instructions.d.ts +8 -0
- package/dist/steering/folder-instructions.js +28 -0
- package/dist/steering/select.js +2 -1
- package/dist/timed-fetch.d.ts +12 -0
- package/dist/timed-fetch.js +83 -0
- package/dist/tools/apply-patch.d.ts +52 -0
- package/dist/tools/apply-patch.js +197 -0
- package/dist/tools/ask-user.d.ts +27 -0
- package/dist/tools/ask-user.js +32 -0
- package/dist/tools/browser-tools.d.ts +6 -6
- package/dist/tools/browser-tools.js +3 -13
- package/dist/tools/codebase-search.d.ts +4 -2
- package/dist/tools/codebase-search.js +2 -2
- package/dist/tools/edit-file.js +5 -3
- package/dist/tools/fetch-url.d.ts +7 -0
- package/dist/tools/fetch-url.js +52 -19
- package/dist/tools/find-symbol.d.ts +2 -2
- package/dist/tools/ignore-rules.js +2 -1
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +10 -1
- package/dist/tools/memory.d.ts +6 -6
- package/dist/tools/notebook.d.ts +2 -2
- package/dist/tools/permission-rules.d.ts +32 -0
- package/dist/tools/permission-rules.js +182 -0
- package/dist/tools/read-file.d.ts +5 -0
- package/dist/tools/read-file.js +7 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +2 -0
- package/dist/tools/run-command.d.ts +4 -2
- package/dist/tools/run-command.js +12 -8
- package/dist/tools/sandbox-loader.js +3 -13
- package/dist/tools/search-files.d.ts +8 -2
- package/dist/tools/search-files.js +43 -0
- package/dist/tools/shared.d.ts +5 -0
- package/dist/tools/shared.js +30 -2
- package/dist/tools/shell-env.d.ts +7 -0
- package/dist/tools/shell-env.js +9 -0
- package/dist/tools/sub-agent-wait.d.ts +29 -0
- package/dist/tools/sub-agent-wait.js +60 -0
- package/dist/tools/sub-agent.d.ts +50 -4
- package/dist/tools/sub-agent.js +210 -125
- package/dist/tools/text-encoding.d.ts +10 -0
- package/dist/tools/text-encoding.js +40 -0
- package/dist/tools/todo.d.ts +36 -0
- package/dist/tools/todo.js +42 -0
- package/dist/tools/write-file.js +15 -9
- package/dist/tools/write-tools.d.ts +12 -0
- package/dist/tools/write-tools.js +18 -0
- package/dist/types.d.ts +73 -0
- package/dist/worktree/worktree.d.ts +3 -0
- package/dist/worktree/worktree.js +22 -0
- package/package.json +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skills from a URL: either one skill file (Markdown with front matter) or an index,
|
|
3
|
+
* `{ "skills": [{ "url": "a.md" }, ...] }`, whose links may be relative to the index.
|
|
4
|
+
* Responses are cached on disk so a skill still works offline and a slow host is not asked on every run:
|
|
5
|
+
* a copy younger than the TTL is used as is; an older one is refreshed, and kept if the refresh fails.
|
|
6
|
+
* Anything that times out or is not a skill is skipped with one warning. Only http and https are fetched.
|
|
7
|
+
* The `skillPaths` setting that lists URLs is read from the user's config only.
|
|
8
|
+
*/
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { parseSkill } from './parser.js';
|
|
14
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
15
|
+
const MAX_CHARS = 200_000;
|
|
16
|
+
const MAX_INDEX_ENTRIES = 20;
|
|
17
|
+
const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
18
|
+
/** A host that failed is not asked again for this long, so a dead URL does not add its timeout to every message. */
|
|
19
|
+
const RETRY_AFTER_FAILURE_MS = 5 * 60 * 1000;
|
|
20
|
+
const failures = new Map();
|
|
21
|
+
export const isHttpUrl = (s) => /^https?:\/\//i.test(s);
|
|
22
|
+
function cacheFile(dir, url) {
|
|
23
|
+
return path.join(dir, `${createHash('sha256').update(url).digest('hex').slice(0, 32)}.json`);
|
|
24
|
+
}
|
|
25
|
+
async function readCache(dir, url) {
|
|
26
|
+
try {
|
|
27
|
+
const entry = JSON.parse(await readFile(cacheFile(dir, url), 'utf8'));
|
|
28
|
+
return typeof entry.text === 'string' && typeof entry.fetchedAt === 'number' ? entry : undefined;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/** The text at `url`: a fresh cached copy, else the network, else a stale cached copy; throws if there is none. */
|
|
35
|
+
async function fetchText(url, opts) {
|
|
36
|
+
const cached = await readCache(opts.cacheDir, url);
|
|
37
|
+
if (cached && opts.now() - cached.fetchedAt < opts.ttlMs)
|
|
38
|
+
return cached.text;
|
|
39
|
+
const failKey = `${opts.cacheDir}|${url}`;
|
|
40
|
+
const failed = failures.get(failKey);
|
|
41
|
+
const since = failed ? opts.now() - failed.at : 0;
|
|
42
|
+
if (failed && since >= 0 && since < RETRY_AFTER_FAILURE_MS) {
|
|
43
|
+
if (cached)
|
|
44
|
+
return cached.text;
|
|
45
|
+
throw new Error(failed.message);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const res = await opts.fetch(url, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
49
|
+
if (!res.ok)
|
|
50
|
+
throw new Error(`HTTP ${res.status}`);
|
|
51
|
+
const text = (await res.text()).slice(0, MAX_CHARS);
|
|
52
|
+
try {
|
|
53
|
+
await mkdir(opts.cacheDir, { recursive: true });
|
|
54
|
+
await writeFile(cacheFile(opts.cacheDir, url), JSON.stringify({ url, fetchedAt: opts.now(), text }), 'utf8');
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// a cache that cannot be written only costs a fetch next time
|
|
58
|
+
}
|
|
59
|
+
failures.delete(failKey);
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
failures.set(failKey, { at: opts.now(), message: err.message });
|
|
64
|
+
if (cached)
|
|
65
|
+
return cached.text;
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function fallbackName(url) {
|
|
70
|
+
const last = new URL(url).pathname.split('/').filter(Boolean).pop() ?? 'remote-skill';
|
|
71
|
+
return last.replace(/\.md$/i, '') || 'remote-skill';
|
|
72
|
+
}
|
|
73
|
+
function asSkill(url, text) {
|
|
74
|
+
const head = text.trimStart();
|
|
75
|
+
if (head.startsWith('<') || !head.startsWith('---'))
|
|
76
|
+
return undefined;
|
|
77
|
+
const skill = parseSkill(text, { kind: 'remote', path: url }, fallbackName(url));
|
|
78
|
+
return skill.description.trim() && skill.body ? skill : undefined;
|
|
79
|
+
}
|
|
80
|
+
export async function loadRemoteSkills(url, options = {}) {
|
|
81
|
+
const warnings = [];
|
|
82
|
+
if (!isHttpUrl(url))
|
|
83
|
+
return { skills: [], warnings: [`${url}: only http and https skill URLs are read; skipped`] };
|
|
84
|
+
const opts = {
|
|
85
|
+
fetch: options.fetch ?? fetch,
|
|
86
|
+
cacheDir: options.cacheDir ?? path.join(homedir(), '.forge', 'skill-cache'),
|
|
87
|
+
ttlMs: options.ttlMs ?? DEFAULT_TTL_MS,
|
|
88
|
+
now: options.now ?? Date.now,
|
|
89
|
+
};
|
|
90
|
+
const loadOne = async (target) => {
|
|
91
|
+
try {
|
|
92
|
+
const skill = asSkill(target, await fetchText(target, opts));
|
|
93
|
+
if (!skill)
|
|
94
|
+
warnings.push(`${target}: not a skill (needs front matter with a description); skipped`);
|
|
95
|
+
return skill;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
warnings.push(`${target}: ${err.message}; skipped`);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
let text;
|
|
103
|
+
try {
|
|
104
|
+
text = await fetchText(url, opts);
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return { skills: [], warnings: [`${url}: ${err.message}; skipped`] };
|
|
108
|
+
}
|
|
109
|
+
const trimmed = text.trimStart();
|
|
110
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
111
|
+
let index;
|
|
112
|
+
try {
|
|
113
|
+
index = JSON.parse(text);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { skills: [], warnings: [`${url}: not a skill or a skill index; skipped`] };
|
|
117
|
+
}
|
|
118
|
+
const list = Array.isArray(index) ? index : index?.skills;
|
|
119
|
+
if (!Array.isArray(list))
|
|
120
|
+
return { skills: [], warnings: [`${url}: not a skill or a skill index; skipped`] };
|
|
121
|
+
const targets = list
|
|
122
|
+
.map((e) => (typeof e === 'string' ? e : e?.url))
|
|
123
|
+
.filter((u) => typeof u === 'string' && u.length > 0)
|
|
124
|
+
.slice(0, MAX_INDEX_ENTRIES)
|
|
125
|
+
.map((u) => new URL(u, url).href);
|
|
126
|
+
const skills = (await Promise.all(targets.map(loadOne))).filter((s) => s !== undefined);
|
|
127
|
+
return { skills, warnings };
|
|
128
|
+
}
|
|
129
|
+
const skill = asSkill(url, text);
|
|
130
|
+
return skill ? { skills: [skill], warnings } : { skills: [], warnings: [`${url}: not a skill (needs front matter with a description); skipped`] };
|
|
131
|
+
}
|
package/dist/skills/types.d.ts
CHANGED
|
@@ -38,6 +38,16 @@ export type SkillSource = {
|
|
|
38
38
|
} | {
|
|
39
39
|
kind: 'global';
|
|
40
40
|
path: string;
|
|
41
|
+
}
|
|
42
|
+
/** Ships inside Forge; there is no file, `path` is a label. */
|
|
43
|
+
| {
|
|
44
|
+
kind: 'builtin';
|
|
45
|
+
path: string;
|
|
46
|
+
}
|
|
47
|
+
/** Fetched from a URL listed in `skillPaths`; `path` is the URL. */
|
|
48
|
+
| {
|
|
49
|
+
kind: 'remote';
|
|
50
|
+
path: string;
|
|
41
51
|
};
|
|
42
52
|
/** A parsed skill, ready for activation. */
|
|
43
53
|
export interface Skill {
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `instructions` setting: extra files, globs (relative to the workspace) and http(s) URLs whose text is added to
|
|
3
|
+
* the system prompt. Anything unreadable is skipped. Only the user's own config supplies these (a project file
|
|
4
|
+
* could otherwise point the model at text from anywhere), and the caller enforces that.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildExtraInstructionsBlock(cwd: string, entries: readonly string[], deps?: {
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
}): Promise<string>;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { pathMatchesGlob } from './glob.js';
|
|
5
|
+
const PER_SOURCE_BYTES = 12_000;
|
|
6
|
+
const TOTAL_BYTES = 48_000;
|
|
7
|
+
const MAX_WALK_FILES = 5_000;
|
|
8
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'out', 'build', '.next', 'target']);
|
|
9
|
+
const URL_TIMEOUT_MS = 10_000;
|
|
10
|
+
const isGlob = (s) => /[*?[]/.test(s);
|
|
11
|
+
const isUrl = (s) => /^[a-z][a-z0-9+.-]*:\/\//i.test(s);
|
|
12
|
+
async function walk(root) {
|
|
13
|
+
const out = [];
|
|
14
|
+
async function visit(dir, rel, depth) {
|
|
15
|
+
if (depth > 8 || out.length >= MAX_WALK_FILES)
|
|
16
|
+
return;
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
25
|
+
for (const e of entries) {
|
|
26
|
+
if (out.length >= MAX_WALK_FILES)
|
|
27
|
+
return;
|
|
28
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
29
|
+
if (e.isDirectory()) {
|
|
30
|
+
if (!SKIP_DIRS.has(e.name))
|
|
31
|
+
await visit(path.join(dir, e.name), r, depth + 1);
|
|
32
|
+
}
|
|
33
|
+
else if (e.isFile())
|
|
34
|
+
out.push(r);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
await visit(root, '', 0);
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
const clip = (text) => (text.length > PER_SOURCE_BYTES ? `${text.slice(0, PER_SOURCE_BYTES)}\n…(truncated)` : text);
|
|
41
|
+
/**
|
|
42
|
+
* The `instructions` setting: extra files, globs (relative to the workspace) and http(s) URLs whose text is added to
|
|
43
|
+
* the system prompt. Anything unreadable is skipped. Only the user's own config supplies these (a project file
|
|
44
|
+
* could otherwise point the model at text from anywhere), and the caller enforces that.
|
|
45
|
+
*/
|
|
46
|
+
export async function buildExtraInstructionsBlock(cwd, entries, deps = {}) {
|
|
47
|
+
const sources = [];
|
|
48
|
+
const seen = new Set();
|
|
49
|
+
const addFile = (label, abs) => {
|
|
50
|
+
const key = path.resolve(abs);
|
|
51
|
+
if (seen.has(key))
|
|
52
|
+
return;
|
|
53
|
+
seen.add(key);
|
|
54
|
+
sources.push({ label, read: () => readFile(abs, 'utf8') });
|
|
55
|
+
};
|
|
56
|
+
let walked;
|
|
57
|
+
for (const raw of entries) {
|
|
58
|
+
const entry = raw.trim();
|
|
59
|
+
if (!entry)
|
|
60
|
+
continue;
|
|
61
|
+
if (isUrl(entry)) {
|
|
62
|
+
if (!/^https?:\/\//i.test(entry) || seen.has(entry))
|
|
63
|
+
continue;
|
|
64
|
+
seen.add(entry);
|
|
65
|
+
sources.push({
|
|
66
|
+
label: entry,
|
|
67
|
+
read: async () => {
|
|
68
|
+
const res = await (deps.fetch ?? fetch)(entry, { signal: AbortSignal.timeout(URL_TIMEOUT_MS) });
|
|
69
|
+
if (!res.ok)
|
|
70
|
+
throw new Error(`HTTP ${res.status}`);
|
|
71
|
+
return (await res.text()).slice(0, PER_SOURCE_BYTES * 2);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
else if (isGlob(entry) && !path.isAbsolute(entry) && !entry.startsWith('~')) {
|
|
76
|
+
walked ??= await walk(cwd);
|
|
77
|
+
for (const rel of walked.filter((f) => pathMatchesGlob(entry, f)))
|
|
78
|
+
addFile(rel, path.join(cwd, rel));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
const abs = entry.startsWith('~') ? path.join(homedir(), entry.slice(1)) : path.resolve(cwd, entry);
|
|
82
|
+
addFile(entry, abs);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
const blocks = [];
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (const s of sources) {
|
|
88
|
+
if (total >= TOTAL_BYTES)
|
|
89
|
+
break;
|
|
90
|
+
let text;
|
|
91
|
+
try {
|
|
92
|
+
text = (await s.read()).trim();
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (!text)
|
|
98
|
+
continue;
|
|
99
|
+
const body = clip(text);
|
|
100
|
+
total += body.length;
|
|
101
|
+
blocks.push(`<instructions source="${s.label.replace(/"/g, '')}">\n${body}\n</instructions>`);
|
|
102
|
+
}
|
|
103
|
+
return blocks.join('\n\n');
|
|
104
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `AGENTS.md` of every folder between the workspace root and the file just read (nearest first), each shown
|
|
3
|
+
* once per run. The root one is already in the system prompt, so it is never repeated here.
|
|
4
|
+
*/
|
|
5
|
+
export declare function folderInstructionsFor(filePath: string, cwd: string, shown: Set<string>): Promise<Array<{
|
|
6
|
+
file: string;
|
|
7
|
+
text: string;
|
|
8
|
+
}> | undefined>;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
const MAX_BYTES = 8_000;
|
|
4
|
+
/**
|
|
5
|
+
* The `AGENTS.md` of every folder between the workspace root and the file just read (nearest first), each shown
|
|
6
|
+
* once per run. The root one is already in the system prompt, so it is never repeated here.
|
|
7
|
+
*/
|
|
8
|
+
export async function folderInstructionsFor(filePath, cwd, shown) {
|
|
9
|
+
const root = path.resolve(cwd);
|
|
10
|
+
let dir = path.dirname(path.resolve(filePath));
|
|
11
|
+
const out = [];
|
|
12
|
+
while (dir !== root && dir.startsWith(root + path.sep)) {
|
|
13
|
+
const abs = path.join(dir, 'AGENTS.md');
|
|
14
|
+
if (!shown.has(abs)) {
|
|
15
|
+
try {
|
|
16
|
+
const text = (await readFile(abs, 'utf8')).trim();
|
|
17
|
+
shown.add(abs);
|
|
18
|
+
if (text)
|
|
19
|
+
out.push({ file: path.relative(root, abs), text: text.length > MAX_BYTES ? `${text.slice(0, MAX_BYTES)}\n…(truncated)` : text });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
shown.add(abs);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
dir = path.dirname(dir);
|
|
26
|
+
}
|
|
27
|
+
return out.length ? out : undefined;
|
|
28
|
+
}
|
package/dist/steering/select.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { pathMatchesGlob } from './glob.js';
|
|
7
|
+
import { isOutsideRel } from '../tools/shared.js';
|
|
7
8
|
export const START_BUDGET_BYTES = 12_000;
|
|
8
9
|
export const TOOL_DOC_BYTES = 8_000;
|
|
9
10
|
export const MAX_TOOL_INJECTIONS = 3;
|
|
@@ -19,7 +20,7 @@ export function stripSteering(text) {
|
|
|
19
20
|
export function toWorkspaceRelative(cwd, p) {
|
|
20
21
|
const abs = path.resolve(cwd, p);
|
|
21
22
|
const rel = path.relative(cwd, abs);
|
|
22
|
-
if (!rel ||
|
|
23
|
+
if (!rel || isOutsideRel(rel))
|
|
23
24
|
return null;
|
|
24
25
|
return rel.split(path.sep).join('/');
|
|
25
26
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wraps `fetch` with the two timeouts Kilo calls `timeout` and `chunkTimeout`:
|
|
3
|
+
* - `timeoutMs`: how long to wait for the response to start (headers). It stops counting once the answer begins,
|
|
4
|
+
* so a slow stream is never cut short by it.
|
|
5
|
+
* - `chunkTimeoutMs`: how long a streamed answer may stay silent. A stalled stream is aborted with a clear error
|
|
6
|
+
* instead of hanging forever (a connection can stay open while the provider has stopped sending).
|
|
7
|
+
*/
|
|
8
|
+
export interface FetchTimeouts {
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
chunkTimeoutMs?: number;
|
|
11
|
+
}
|
|
12
|
+
export declare function withTimeouts(base: typeof fetch, opts: FetchTimeouts): typeof fetch;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
export function withTimeouts(base, opts) {
|
|
2
|
+
const { timeoutMs, chunkTimeoutMs } = opts;
|
|
3
|
+
if (!timeoutMs && !chunkTimeoutMs)
|
|
4
|
+
return base;
|
|
5
|
+
return (async (input, init) => {
|
|
6
|
+
const controller = new AbortController();
|
|
7
|
+
const outer = init?.signal;
|
|
8
|
+
if (outer?.aborted)
|
|
9
|
+
controller.abort();
|
|
10
|
+
else
|
|
11
|
+
outer?.addEventListener('abort', () => controller.abort(), { once: true });
|
|
12
|
+
let reason;
|
|
13
|
+
const host = (() => {
|
|
14
|
+
try {
|
|
15
|
+
return new URL(typeof input === 'string' ? input : input instanceof URL ? input.href : input.url).host;
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return 'the provider';
|
|
19
|
+
}
|
|
20
|
+
})();
|
|
21
|
+
const fail = (message) => {
|
|
22
|
+
reason = new Error(message);
|
|
23
|
+
controller.abort();
|
|
24
|
+
};
|
|
25
|
+
const headerTimer = timeoutMs ? setTimeout(() => fail(`The request to ${host} timed out after ${timeoutMs} ms.`), timeoutMs) : undefined;
|
|
26
|
+
let res;
|
|
27
|
+
try {
|
|
28
|
+
res = await base(input, { ...init, signal: controller.signal });
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
throw reason ?? err;
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
if (headerTimer)
|
|
35
|
+
clearTimeout(headerTimer);
|
|
36
|
+
}
|
|
37
|
+
if (!chunkTimeoutMs || !res.body)
|
|
38
|
+
return res;
|
|
39
|
+
const reader = res.body.getReader();
|
|
40
|
+
let idle;
|
|
41
|
+
const arm = (onIdle) => {
|
|
42
|
+
if (idle)
|
|
43
|
+
clearTimeout(idle);
|
|
44
|
+
idle = setTimeout(onIdle, chunkTimeoutMs);
|
|
45
|
+
};
|
|
46
|
+
const body = new ReadableStream({
|
|
47
|
+
start(stream) {
|
|
48
|
+
arm(() => {
|
|
49
|
+
fail(`No data for ${chunkTimeoutMs} ms from ${host}.`);
|
|
50
|
+
stream.error(reason);
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
async pull(stream) {
|
|
54
|
+
try {
|
|
55
|
+
const { done, value } = await reader.read();
|
|
56
|
+
if (done) {
|
|
57
|
+
if (idle)
|
|
58
|
+
clearTimeout(idle);
|
|
59
|
+
stream.close();
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
arm(() => {
|
|
63
|
+
fail(`No data for ${chunkTimeoutMs} ms from ${host}.`);
|
|
64
|
+
stream.error(reason);
|
|
65
|
+
void reader.cancel().catch(() => undefined);
|
|
66
|
+
});
|
|
67
|
+
stream.enqueue(value);
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
if (idle)
|
|
71
|
+
clearTimeout(idle);
|
|
72
|
+
stream.error(reason ?? err);
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
cancel(why) {
|
|
76
|
+
if (idle)
|
|
77
|
+
clearTimeout(idle);
|
|
78
|
+
return reader.cancel(why);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
return new Response(body, { status: res.status, statusText: res.statusText, headers: res.headers });
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import type { ToolDefinition } from '../types.js';
|
|
3
|
+
declare const params: z.ZodObject<{
|
|
4
|
+
patch: z.ZodString;
|
|
5
|
+
}, "strip", z.ZodTypeAny, {
|
|
6
|
+
patch: string;
|
|
7
|
+
}, {
|
|
8
|
+
patch: string;
|
|
9
|
+
}>;
|
|
10
|
+
export type PatchOp = {
|
|
11
|
+
kind: 'add';
|
|
12
|
+
path: string;
|
|
13
|
+
content: string;
|
|
14
|
+
} | {
|
|
15
|
+
kind: 'delete';
|
|
16
|
+
path: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'update';
|
|
19
|
+
path: string;
|
|
20
|
+
hunks: Array<{
|
|
21
|
+
lines: Array<{
|
|
22
|
+
tag: ' ' | '-' | '+';
|
|
23
|
+
text: string;
|
|
24
|
+
}>;
|
|
25
|
+
}>;
|
|
26
|
+
};
|
|
27
|
+
/** Parse the patch text into operations. Throws on a malformed patch. */
|
|
28
|
+
export declare function parsePatch(text: string): PatchOp[];
|
|
29
|
+
/** Every path a patch touches, once each; empty when the text is not a valid patch (callers then fall back to the broadest target). */
|
|
30
|
+
export declare function patchedPaths(patch: string): string[];
|
|
31
|
+
export interface PatchPreview {
|
|
32
|
+
path: string;
|
|
33
|
+
/** Text before the patch; null when the file does not exist yet. */
|
|
34
|
+
before: string | null;
|
|
35
|
+
/** Text after the patch; null when the patch deletes the file. */
|
|
36
|
+
after: string | null;
|
|
37
|
+
/** Why this file's change could not be worked out (a hunk that does not match). */
|
|
38
|
+
error?: string;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What an `apply_patch` call would leave in each file, worked out in memory so it can be shown before it is approved.
|
|
42
|
+
* Never writes. Throws only for a patch that cannot be parsed at all.
|
|
43
|
+
*/
|
|
44
|
+
export declare function previewPatch(patch: string, read: (path: string) => Promise<string | null>): Promise<PatchPreview[]>;
|
|
45
|
+
export interface ApplyPatchResult {
|
|
46
|
+
files: Array<{
|
|
47
|
+
action: 'add' | 'update' | 'delete';
|
|
48
|
+
path: string;
|
|
49
|
+
}>;
|
|
50
|
+
}
|
|
51
|
+
export declare const applyPatchTool: ToolDefinition<z.infer<typeof params>, ApplyPatchResult>;
|
|
52
|
+
export {};
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { resolveAndCheckPath } from './shared.js';
|
|
5
|
+
import { decodeText, encodeText } from './text-encoding.js';
|
|
6
|
+
const params = z.object({
|
|
7
|
+
patch: z
|
|
8
|
+
.string()
|
|
9
|
+
.min(1)
|
|
10
|
+
.describe('The patch. Starts with "*** Begin Patch", ends with "*** End Patch". Between them, per file: "*** Add File: path" followed by lines starting with +; "*** Delete File: path"; or "*** Update File: path" followed by hunks that begin with "@@" and whose lines start with a space (kept), - (removed) or + (added).'),
|
|
11
|
+
});
|
|
12
|
+
/** Parse the patch text into operations. Throws on a malformed patch. */
|
|
13
|
+
export function parsePatch(text) {
|
|
14
|
+
const lines = text.replace(/\r\n/g, '\n').split('\n');
|
|
15
|
+
while (lines.length && lines[0].trim() === '')
|
|
16
|
+
lines.shift();
|
|
17
|
+
while (lines.length && lines[lines.length - 1].trim() === '')
|
|
18
|
+
lines.pop();
|
|
19
|
+
if (lines[0]?.trim() !== '*** Begin Patch' || lines[lines.length - 1]?.trim() !== '*** End Patch') {
|
|
20
|
+
throw new Error('A patch must start with "*** Begin Patch" and end with "*** End Patch".');
|
|
21
|
+
}
|
|
22
|
+
const body = lines.slice(1, -1);
|
|
23
|
+
const ops = [];
|
|
24
|
+
let i = 0;
|
|
25
|
+
while (i < body.length) {
|
|
26
|
+
const line = body[i];
|
|
27
|
+
if (line.trim() === '') {
|
|
28
|
+
i++;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
const add = /^\*\*\* Add File: (.+)$/.exec(line);
|
|
32
|
+
const del = /^\*\*\* Delete File: (.+)$/.exec(line);
|
|
33
|
+
const upd = /^\*\*\* Update File: (.+)$/.exec(line);
|
|
34
|
+
if (add) {
|
|
35
|
+
const content = [];
|
|
36
|
+
i++;
|
|
37
|
+
while (i < body.length && !body[i].startsWith('*** ')) {
|
|
38
|
+
const l = body[i];
|
|
39
|
+
if (!l.startsWith('+'))
|
|
40
|
+
throw new Error(`Add File ${add[1]}: every line must start with + (got "${l.slice(0, 40)}").`);
|
|
41
|
+
content.push(l.slice(1));
|
|
42
|
+
i++;
|
|
43
|
+
}
|
|
44
|
+
ops.push({ kind: 'add', path: add[1].trim(), content: content.length ? `${content.join('\n')}\n` : '' });
|
|
45
|
+
}
|
|
46
|
+
else if (del) {
|
|
47
|
+
ops.push({ kind: 'delete', path: del[1].trim() });
|
|
48
|
+
i++;
|
|
49
|
+
}
|
|
50
|
+
else if (upd) {
|
|
51
|
+
const hunks = [];
|
|
52
|
+
i++;
|
|
53
|
+
while (i < body.length && !body[i].startsWith('*** ')) {
|
|
54
|
+
if (body[i].startsWith('@@')) {
|
|
55
|
+
hunks.push({ lines: [] });
|
|
56
|
+
i++;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
const l = body[i];
|
|
60
|
+
const tag = l[0];
|
|
61
|
+
if (tag !== ' ' && tag !== '-' && tag !== '+')
|
|
62
|
+
throw new Error(`Update File ${upd[1]}: bad hunk line "${l.slice(0, 40)}" (start with space, - or +).`);
|
|
63
|
+
if (hunks.length === 0)
|
|
64
|
+
hunks.push({ lines: [] });
|
|
65
|
+
hunks[hunks.length - 1].lines.push({ tag, text: l.slice(1) });
|
|
66
|
+
i++;
|
|
67
|
+
}
|
|
68
|
+
if (hunks.length === 0)
|
|
69
|
+
throw new Error(`Update File ${upd[1]} has no hunks.`);
|
|
70
|
+
ops.push({ kind: 'update', path: upd[1].trim(), hunks });
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
throw new Error(`Unknown patch directive: "${line.slice(0, 60)}"`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (ops.length === 0)
|
|
77
|
+
throw new Error('The patch has no operations.');
|
|
78
|
+
return ops;
|
|
79
|
+
}
|
|
80
|
+
/** Every path a patch touches, once each; empty when the text is not a valid patch (callers then fall back to the broadest target). */
|
|
81
|
+
export function patchedPaths(patch) {
|
|
82
|
+
try {
|
|
83
|
+
return [...new Set(parsePatch(patch).map((o) => o.path))];
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
function indexOfSeq(hay, needle, from, norm) {
|
|
90
|
+
if (needle.length === 0)
|
|
91
|
+
return from;
|
|
92
|
+
outer: for (let s = from; s <= hay.length - needle.length; s++) {
|
|
93
|
+
for (let k = 0; k < needle.length; k++)
|
|
94
|
+
if (norm(hay[s + k]) !== norm(needle[k]))
|
|
95
|
+
continue outer;
|
|
96
|
+
return s;
|
|
97
|
+
}
|
|
98
|
+
return -1;
|
|
99
|
+
}
|
|
100
|
+
/** Apply update hunks to file text, in order, each searching after the previous one. Exact match first, then ignoring trailing whitespace. */
|
|
101
|
+
function applyHunks(file, original, hunks) {
|
|
102
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
103
|
+
const endsWithNewline = original.endsWith('\n');
|
|
104
|
+
const lines = original.replace(/\r\n/g, '\n').split('\n');
|
|
105
|
+
if (endsWithNewline)
|
|
106
|
+
lines.pop();
|
|
107
|
+
let cursor = 0;
|
|
108
|
+
for (const [n, hunk] of hunks.entries()) {
|
|
109
|
+
const oldSeq = hunk.lines.filter((l) => l.tag !== '+').map((l) => l.text);
|
|
110
|
+
const newSeq = hunk.lines.filter((l) => l.tag !== '-').map((l) => l.text);
|
|
111
|
+
let at = indexOfSeq(lines, oldSeq, cursor, (s) => s);
|
|
112
|
+
if (at === -1)
|
|
113
|
+
at = indexOfSeq(lines, oldSeq, cursor, (s) => s.trimEnd());
|
|
114
|
+
if (at === -1)
|
|
115
|
+
throw new Error(`Hunk ${n + 1} of ${file} did not match the file. Re-read it and make the context lines exact.`);
|
|
116
|
+
lines.splice(at, oldSeq.length, ...newSeq);
|
|
117
|
+
cursor = at + newSeq.length;
|
|
118
|
+
}
|
|
119
|
+
return lines.join(eol) + (endsWithNewline ? eol : '');
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* What an `apply_patch` call would leave in each file, worked out in memory so it can be shown before it is approved.
|
|
123
|
+
* Never writes. Throws only for a patch that cannot be parsed at all.
|
|
124
|
+
*/
|
|
125
|
+
export async function previewPatch(patch, read) {
|
|
126
|
+
const out = [];
|
|
127
|
+
for (const op of parsePatch(patch)) {
|
|
128
|
+
const before = await read(op.path);
|
|
129
|
+
if (op.kind === 'add')
|
|
130
|
+
out.push({ path: op.path, before, after: op.content });
|
|
131
|
+
else if (op.kind === 'delete')
|
|
132
|
+
out.push({ path: op.path, before, after: null });
|
|
133
|
+
else if (before === null)
|
|
134
|
+
out.push({ path: op.path, before, after: null, error: `File not found: ${op.path}` });
|
|
135
|
+
else {
|
|
136
|
+
try {
|
|
137
|
+
out.push({ path: op.path, before, after: applyHunks(op.path, before, op.hunks) });
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
out.push({ path: op.path, before, after: null, error: err.message });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
export const applyPatchTool = {
|
|
147
|
+
name: 'apply_patch',
|
|
148
|
+
description: 'Change several files in one step with a patch: add files, delete files, or update files by hunks of context and changed lines. All or nothing: if one hunk does not match, no file is touched. Prefer edit_file for a single small change.',
|
|
149
|
+
parameters: params,
|
|
150
|
+
destructive: false,
|
|
151
|
+
execute: async (args, ctx) => {
|
|
152
|
+
const ops = parsePatch(args.patch);
|
|
153
|
+
// Plan every change in memory first, so a bad hunk leaves the workspace as it was.
|
|
154
|
+
const writes = new Map();
|
|
155
|
+
const encodings = new Map();
|
|
156
|
+
const deletes = new Set();
|
|
157
|
+
const files = [];
|
|
158
|
+
for (const op of ops) {
|
|
159
|
+
const abs = resolveAndCheckPath(op.path, ctx);
|
|
160
|
+
const rel = op.path.split(String.fromCharCode(92)).join('/');
|
|
161
|
+
if (op.kind === 'add') {
|
|
162
|
+
const exists = await readFile(abs, 'utf8').then(() => true, () => false);
|
|
163
|
+
if ((exists && !deletes.has(abs)) || writes.has(abs))
|
|
164
|
+
throw new Error(`Cannot add ${op.path}: it already exists.`);
|
|
165
|
+
writes.set(abs, op.content);
|
|
166
|
+
files.push({ action: 'add', path: rel });
|
|
167
|
+
}
|
|
168
|
+
else if (op.kind === 'delete') {
|
|
169
|
+
await readFile(abs, 'utf8').catch(() => {
|
|
170
|
+
throw new Error(`Cannot delete ${op.path}: file not found.`);
|
|
171
|
+
});
|
|
172
|
+
deletes.add(abs);
|
|
173
|
+
writes.delete(abs);
|
|
174
|
+
files.push({ action: 'delete', path: rel });
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
let current = writes.get(abs);
|
|
178
|
+
if (current === undefined) {
|
|
179
|
+
const decoded = await readFile(abs).then(decodeText, () => {
|
|
180
|
+
throw new Error(`Cannot update ${op.path}: file not found.`);
|
|
181
|
+
});
|
|
182
|
+
current = decoded.text;
|
|
183
|
+
encodings.set(abs, decoded.encoding);
|
|
184
|
+
}
|
|
185
|
+
writes.set(abs, applyHunks(op.path, current, op.hunks));
|
|
186
|
+
files.push({ action: 'update', path: rel });
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
for (const abs of deletes)
|
|
190
|
+
await rm(abs);
|
|
191
|
+
for (const [abs, content] of writes) {
|
|
192
|
+
await mkdir(path.dirname(abs), { recursive: true });
|
|
193
|
+
await writeFile(abs, encodeText(content, encodings.get(abs) ?? 'utf8'));
|
|
194
|
+
}
|
|
195
|
+
return { files };
|
|
196
|
+
},
|
|
197
|
+
};
|