@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,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OAuth sign-in for MCP servers behind it (authorization code with PKCE, dynamic client registration).
|
|
3
|
+
* The SDK does the protocol; this file supplies what it leaves to the application: where tokens are kept,
|
|
4
|
+
* where the browser returns to (a one-shot page on 127.0.0.1), and a state value that must come back unchanged.
|
|
5
|
+
*
|
|
6
|
+
* Tokens live in `~/.forge/mcp-auth/<server>.json`, readable by the user only, one file per server URL.
|
|
7
|
+
* Signing in is always the user's act: a server that needs it is reported as such, and the browser opens only
|
|
8
|
+
* when `interactive` is set. A URL that is not http or https is never opened.
|
|
9
|
+
*/
|
|
10
|
+
import { randomBytes, createHash } from 'node:crypto';
|
|
11
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
12
|
+
import http from 'node:http';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
/** The server needs the user to sign in, and this connection was not allowed to ask. */
|
|
16
|
+
export class McpAuthRequiredError extends Error {
|
|
17
|
+
server;
|
|
18
|
+
constructor(server) {
|
|
19
|
+
super(`MCP server "${server}" needs you to sign in`);
|
|
20
|
+
this.server = server;
|
|
21
|
+
this.name = 'McpAuthRequiredError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export function defaultMcpAuthDir() {
|
|
25
|
+
return path.join(homedir(), '.forge', 'mcp-auth');
|
|
26
|
+
}
|
|
27
|
+
function fileFor(dir, serverUrl) {
|
|
28
|
+
return path.join(dir, `${createHash('sha256').update(serverUrl).digest('hex').slice(0, 32)}.json`);
|
|
29
|
+
}
|
|
30
|
+
export async function readSavedAuth(dir, serverUrl) {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(await readFile(fileFor(dir, serverUrl), 'utf8'));
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return undefined;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
async function writeSavedAuth(dir, auth) {
|
|
39
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
40
|
+
await writeFile(fileFor(dir, auth.url), JSON.stringify(auth, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
41
|
+
}
|
|
42
|
+
export async function forgetSavedAuth(dir, serverUrl) {
|
|
43
|
+
await rm(fileFor(dir, serverUrl), { force: true });
|
|
44
|
+
}
|
|
45
|
+
/** A port on 127.0.0.1: the one used last time if it is free (so a registered redirect URL keeps working), else any. */
|
|
46
|
+
async function freePort(preferred) {
|
|
47
|
+
const tryListen = (port) => new Promise((resolve) => {
|
|
48
|
+
const s = http.createServer();
|
|
49
|
+
s.once('error', () => resolve(undefined));
|
|
50
|
+
s.listen(port, '127.0.0.1', () => {
|
|
51
|
+
const got = s.address().port;
|
|
52
|
+
s.close(() => resolve(got));
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
return (preferred ? await tryListen(preferred) : undefined) ?? (await tryListen(0)) ?? 0;
|
|
56
|
+
}
|
|
57
|
+
/** A one-shot page the browser returns to. Resolves with the code; a wrong `state` is ignored, an `error` rejects. */
|
|
58
|
+
async function receiveCode(port, expectedState, timeoutMs) {
|
|
59
|
+
let resolveCode;
|
|
60
|
+
let rejectCode;
|
|
61
|
+
const code = new Promise((resolve, reject) => {
|
|
62
|
+
resolveCode = resolve;
|
|
63
|
+
rejectCode = reject;
|
|
64
|
+
});
|
|
65
|
+
const page = (title) => `<!doctype html><meta charset="utf-8"><title>${title}</title><body style="font-family:sans-serif;margin:3em"><h2>${title}</h2><p>You can close this tab and go back to your editor.</p>`;
|
|
66
|
+
const server = http.createServer((req, res) => {
|
|
67
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
68
|
+
if (url.pathname !== '/callback') {
|
|
69
|
+
res.writeHead(404).end();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (url.searchParams.get('state') !== expectedState) {
|
|
73
|
+
res.writeHead(400, { 'content-type': 'text/html' }).end(page('This sign-in link is not for this session'));
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const error = url.searchParams.get('error');
|
|
77
|
+
const got = url.searchParams.get('code');
|
|
78
|
+
if (error || !got) {
|
|
79
|
+
res.writeHead(400, { 'content-type': 'text/html' }).end(page('Sign-in was not completed'));
|
|
80
|
+
rejectCode(new Error(`sign-in failed: ${error ?? 'no code returned'}`));
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
res.writeHead(200, { 'content-type': 'text/html' }).end(page('Signed in to Forge'));
|
|
84
|
+
resolveCode(got);
|
|
85
|
+
});
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
server.once('error', reject);
|
|
88
|
+
server.listen(port, '127.0.0.1', resolve);
|
|
89
|
+
});
|
|
90
|
+
const timer = setTimeout(() => rejectCode(new Error('sign-in timed out')), timeoutMs);
|
|
91
|
+
const close = () => {
|
|
92
|
+
clearTimeout(timer);
|
|
93
|
+
server.closeAllConnections();
|
|
94
|
+
server.close();
|
|
95
|
+
};
|
|
96
|
+
code.then(close, close);
|
|
97
|
+
return { code, close };
|
|
98
|
+
}
|
|
99
|
+
export class ForgeOAuthProvider {
|
|
100
|
+
serverName;
|
|
101
|
+
serverUrl;
|
|
102
|
+
dir;
|
|
103
|
+
port;
|
|
104
|
+
options;
|
|
105
|
+
verifier = '';
|
|
106
|
+
expectedState = '';
|
|
107
|
+
receiver;
|
|
108
|
+
constructor(serverName, serverUrl, dir, port, options) {
|
|
109
|
+
this.serverName = serverName;
|
|
110
|
+
this.serverUrl = serverUrl;
|
|
111
|
+
this.dir = dir;
|
|
112
|
+
this.port = port;
|
|
113
|
+
this.options = options;
|
|
114
|
+
}
|
|
115
|
+
static async create(serverName, serverUrl, options) {
|
|
116
|
+
const dir = options.storeDir ?? defaultMcpAuthDir();
|
|
117
|
+
const saved = await readSavedAuth(dir, serverUrl);
|
|
118
|
+
const port = await freePort(saved?.port);
|
|
119
|
+
await writeSavedAuth(dir, { ...(saved ?? { url: serverUrl }), url: serverUrl, port });
|
|
120
|
+
return new ForgeOAuthProvider(serverName, serverUrl, dir, port, options);
|
|
121
|
+
}
|
|
122
|
+
get redirectUrl() {
|
|
123
|
+
return `http://127.0.0.1:${this.port}/callback`;
|
|
124
|
+
}
|
|
125
|
+
get clientMetadata() {
|
|
126
|
+
return {
|
|
127
|
+
client_name: 'Ikarmic Forge',
|
|
128
|
+
redirect_uris: [this.redirectUrl],
|
|
129
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
130
|
+
response_types: ['code'],
|
|
131
|
+
token_endpoint_auth_method: 'none',
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
state() {
|
|
135
|
+
this.expectedState = randomBytes(16).toString('hex');
|
|
136
|
+
return this.expectedState;
|
|
137
|
+
}
|
|
138
|
+
async saved() {
|
|
139
|
+
return (await readSavedAuth(this.dir, this.serverUrl)) ?? { url: this.serverUrl };
|
|
140
|
+
}
|
|
141
|
+
async clientInformation() {
|
|
142
|
+
const info = (await this.saved()).clientInformation;
|
|
143
|
+
// A registration made for another redirect URL cannot be used here: register again.
|
|
144
|
+
if (info?.redirect_uris && !info.redirect_uris.includes(this.redirectUrl))
|
|
145
|
+
return undefined;
|
|
146
|
+
return info;
|
|
147
|
+
}
|
|
148
|
+
async saveClientInformation(info) {
|
|
149
|
+
await writeSavedAuth(this.dir, { ...(await this.saved()), clientInformation: info });
|
|
150
|
+
}
|
|
151
|
+
async tokens() {
|
|
152
|
+
return (await this.saved()).tokens;
|
|
153
|
+
}
|
|
154
|
+
async saveTokens(tokens) {
|
|
155
|
+
await writeSavedAuth(this.dir, { ...(await this.saved()), tokens });
|
|
156
|
+
}
|
|
157
|
+
saveCodeVerifier(v) {
|
|
158
|
+
this.verifier = v;
|
|
159
|
+
}
|
|
160
|
+
codeVerifier() {
|
|
161
|
+
return this.verifier;
|
|
162
|
+
}
|
|
163
|
+
async invalidateCredentials(scope) {
|
|
164
|
+
const s = await this.saved();
|
|
165
|
+
if (scope === 'all' || scope === 'tokens')
|
|
166
|
+
delete s.tokens;
|
|
167
|
+
if (scope === 'all' || scope === 'client')
|
|
168
|
+
delete s.clientInformation;
|
|
169
|
+
await writeSavedAuth(this.dir, s);
|
|
170
|
+
}
|
|
171
|
+
/** Called by the SDK once it has the authorization URL. Without `interactive` this is where the connection stops. */
|
|
172
|
+
async redirectToAuthorization(url) {
|
|
173
|
+
if (!this.options.interactive)
|
|
174
|
+
throw new McpAuthRequiredError(this.serverName);
|
|
175
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
|
|
176
|
+
throw new Error(`MCP server "${this.serverName}" gave a sign-in address that is not a web page (${url.protocol}); refused`);
|
|
177
|
+
}
|
|
178
|
+
this.receiver?.close();
|
|
179
|
+
this.receiver = await receiveCode(this.port, this.expectedState, this.options.signInTimeoutMs ?? 5 * 60_000);
|
|
180
|
+
await this.options.openUrl(url.href);
|
|
181
|
+
}
|
|
182
|
+
/** After the SDK reported the sign-in as started: the code the browser brings back. */
|
|
183
|
+
async waitForCode() {
|
|
184
|
+
if (!this.receiver)
|
|
185
|
+
throw new Error('no sign-in is waiting');
|
|
186
|
+
try {
|
|
187
|
+
return await this.receiver.code;
|
|
188
|
+
}
|
|
189
|
+
finally {
|
|
190
|
+
this.receiver = undefined;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
cancel() {
|
|
194
|
+
this.receiver?.close();
|
|
195
|
+
this.receiver = undefined;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ConnectedMcpServer } from './mcp-client.js';
|
|
2
|
+
import type { ToolDefinition } from '../types.js';
|
|
3
|
+
/**
|
|
4
|
+
* `mcp_list_resources` and `mcp_read_resource`: let the model see and read what connected MCP servers publish
|
|
5
|
+
* (notes, files, schemas). Both only read, so neither asks for approval. `servers` is a function because
|
|
6
|
+
* servers connect, fail and reconnect while a chat is open.
|
|
7
|
+
*/
|
|
8
|
+
export declare function createMcpResourceTools(servers: () => readonly ConnectedMcpServer[]): ToolDefinition[];
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const MAX_RESOURCE_CHARS = 100_000;
|
|
3
|
+
/**
|
|
4
|
+
* `mcp_list_resources` and `mcp_read_resource`: let the model see and read what connected MCP servers publish
|
|
5
|
+
* (notes, files, schemas). Both only read, so neither asks for approval. `servers` is a function because
|
|
6
|
+
* servers connect, fail and reconnect while a chat is open.
|
|
7
|
+
*/
|
|
8
|
+
export function createMcpResourceTools(servers) {
|
|
9
|
+
const listParams = z.object({ server: z.string().optional().describe('Only this MCP server. Omit for every server.') });
|
|
10
|
+
const readParams = z.object({
|
|
11
|
+
server: z.string().describe('MCP server name, as shown by mcp_list_resources.'),
|
|
12
|
+
uri: z.string().describe('Resource URI, as shown by mcp_list_resources.'),
|
|
13
|
+
});
|
|
14
|
+
const list = {
|
|
15
|
+
name: 'mcp_list_resources',
|
|
16
|
+
description: 'List the resources (documents, data, files) that connected MCP servers publish. Returns each resource server, uri, name and description.',
|
|
17
|
+
parameters: listParams,
|
|
18
|
+
execute: async (args) => {
|
|
19
|
+
const out = [];
|
|
20
|
+
for (const s of servers()) {
|
|
21
|
+
if (args.server && s.name !== args.server)
|
|
22
|
+
continue;
|
|
23
|
+
try {
|
|
24
|
+
const { resources } = await s.client.listResources();
|
|
25
|
+
for (const r of resources) {
|
|
26
|
+
out.push({ server: s.name, uri: r.uri, name: r.name, ...(r.description ? { description: r.description } : {}), ...(r.mimeType ? { mimeType: r.mimeType } : {}) });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
// A server without resources support (or one that is down) just contributes nothing.
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return { resources: out };
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const read = {
|
|
37
|
+
name: 'mcp_read_resource',
|
|
38
|
+
description: 'Read one resource from a connected MCP server by its server name and uri (see mcp_list_resources).',
|
|
39
|
+
parameters: readParams,
|
|
40
|
+
execute: async (args) => {
|
|
41
|
+
const server = servers().find((s) => s.name === args.server);
|
|
42
|
+
if (!server)
|
|
43
|
+
throw new Error(`No MCP server "${args.server}" is connected.`);
|
|
44
|
+
const result = await server.client.readResource({ uri: args.uri });
|
|
45
|
+
const text = result.contents
|
|
46
|
+
.map((c) => ('text' in c && typeof c.text === 'string' ? c.text : `[binary content${c.mimeType ? `, ${c.mimeType}` : ''}]`))
|
|
47
|
+
.join('\n');
|
|
48
|
+
return {
|
|
49
|
+
server: args.server,
|
|
50
|
+
uri: args.uri,
|
|
51
|
+
text: text.length > MAX_RESOURCE_CHARS ? `${text.slice(0, MAX_RESOURCE_CHARS)}\n…(truncated)` : text,
|
|
52
|
+
};
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
return [list, read];
|
|
56
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
import type { ModelConfig } from '../types.js';
|
|
3
|
+
export interface DigestItem {
|
|
4
|
+
role: 'user' | 'assistant';
|
|
5
|
+
text: string;
|
|
6
|
+
}
|
|
7
|
+
/** A conversation with fewer than two user messages is a question, not work worth remembering. */
|
|
8
|
+
export declare function shouldDigest(items: readonly DigestItem[]): boolean;
|
|
9
|
+
/** The model's reply as a digest: think block, fences and quotes removed; undefined when nothing usable is left (RB-1). */
|
|
10
|
+
export declare function cleanDigest(reply: string): string | undefined;
|
|
11
|
+
/** Summarise a finished chat for project memory with `model` (usually the cheap model); never throws. */
|
|
12
|
+
export declare function generateSessionDigest(items: readonly DigestItem[], model: ModelConfig | undefined, modelFactory?: (cfg: ModelConfig) => LanguageModel): Promise<string | undefined>;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
import { stripThinkTags } from '../agent/think-tags.js';
|
|
3
|
+
import { createLanguageModel } from '../providers.js';
|
|
4
|
+
const MAX_DIGEST_CHARS = 600;
|
|
5
|
+
const MAX_INPUT_CHARS = 12_000;
|
|
6
|
+
const INSTRUCTION = 'Below is a conversation between a user and a coding agent. Write what a later session needs to know: what was changed or decided, which files were involved, and anything left undone. ' +
|
|
7
|
+
'Two to five short bullet lines, each starting with "- ". Do not copy code. Reply with the bullets only, no preface, no code fences.';
|
|
8
|
+
/** A conversation with fewer than two user messages is a question, not work worth remembering. */
|
|
9
|
+
export function shouldDigest(items) {
|
|
10
|
+
return items.filter((i) => i.role === 'user').length >= 2;
|
|
11
|
+
}
|
|
12
|
+
/** The model's reply as a digest: think block, fences and quotes removed; undefined when nothing usable is left (RB-1). */
|
|
13
|
+
export function cleanDigest(reply) {
|
|
14
|
+
const text = stripThinkTags(reply)
|
|
15
|
+
.trim()
|
|
16
|
+
.replace(/^```[a-z]*\s*\n?/i, '')
|
|
17
|
+
.replace(/\n?```$/, '')
|
|
18
|
+
.trim()
|
|
19
|
+
.replace(/^["'“”](.*)["'“”]$/s, '$1')
|
|
20
|
+
.trim();
|
|
21
|
+
if (!text)
|
|
22
|
+
return undefined;
|
|
23
|
+
return text.length > MAX_DIGEST_CHARS ? `${text.slice(0, MAX_DIGEST_CHARS - 1).trimEnd()}…` : text;
|
|
24
|
+
}
|
|
25
|
+
/** The end of the conversation, which is where its outcome is, within the input budget. */
|
|
26
|
+
function transcript(items) {
|
|
27
|
+
const lines = items.map((i) => `${i.role === 'user' ? 'User' : 'Agent'}: ${i.text.trim()}`);
|
|
28
|
+
let out = '';
|
|
29
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
30
|
+
if (out.length + lines[i].length > MAX_INPUT_CHARS)
|
|
31
|
+
break;
|
|
32
|
+
out = `${lines[i]}\n\n${out}`;
|
|
33
|
+
}
|
|
34
|
+
return out.trim();
|
|
35
|
+
}
|
|
36
|
+
/** Summarise a finished chat for project memory with `model` (usually the cheap model); never throws. */
|
|
37
|
+
export async function generateSessionDigest(items, model, modelFactory = createLanguageModel) {
|
|
38
|
+
if (!model || !shouldDigest(items))
|
|
39
|
+
return undefined;
|
|
40
|
+
try {
|
|
41
|
+
const result = await generateText({ model: modelFactory(model), prompt: `${INSTRUCTION}\n\nConversation:\n${transcript(items)}` });
|
|
42
|
+
return cleanDigest(result.text);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
package/dist/memory/stale.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { stat } from 'node:fs/promises';
|
|
7
7
|
import path from 'node:path';
|
|
8
|
+
import { isOutsideRel } from '../tools/shared.js';
|
|
8
9
|
/** Key a section in the stale map. */
|
|
9
10
|
export function staleKey(s) {
|
|
10
11
|
return `${s.source}#${s.heading}`;
|
|
@@ -23,7 +24,7 @@ export async function checkStale(sections, root, opts = {}) {
|
|
|
23
24
|
const file = hash === -1 ? ref : ref.slice(0, hash);
|
|
24
25
|
const symbol = hash === -1 ? '' : ref.slice(hash + 1);
|
|
25
26
|
const rel = path.relative(root, path.resolve(root, file));
|
|
26
|
-
if (path.isAbsolute(file) ||
|
|
27
|
+
if (path.isAbsolute(file) || isOutsideRel(rel)) {
|
|
27
28
|
reasons.push(`${ref} is outside the project`);
|
|
28
29
|
continue;
|
|
29
30
|
}
|
package/dist/model-alias.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* User-defined model aliases from `~/.forge/config.json`:
|
|
3
|
+
* "aliases": { "fast": "anthropic:claude-haiku-4-5", "big": "opus" }
|
|
4
|
+
* A value is `provider:model` or a bare model id (current provider), and may
|
|
5
|
+
* name another alias.
|
|
6
|
+
*/
|
|
7
|
+
import { type CustomProviderConfig, type ProviderEndpoint } from './custom-providers.js';
|
|
8
|
+
import type { ModelConfig, ProviderName } from './types.js';
|
|
2
9
|
export type ModelAliases = Record<string, string>;
|
|
3
10
|
/** Follow alias links from `name`. Names that are not aliases come back unchanged. */
|
|
4
11
|
export declare function expandModelAlias(name: string, aliases: ModelAliases | undefined): string;
|
|
@@ -7,9 +14,18 @@ export declare function splitModelRef(ref: string): {
|
|
|
7
14
|
provider?: ProviderName;
|
|
8
15
|
model: string;
|
|
9
16
|
};
|
|
10
|
-
|
|
17
|
+
/**
|
|
18
|
+
* `provider:model` for the eleven built-in providers, and `id:model` for a preset or `customProviders` id
|
|
19
|
+
* (`groq:llama-3.3-70b-versatile`), which resolves to an openai-compatible model with its endpoint. Ollama and
|
|
20
|
+
* LM Studio use `name:tag` model ids (`mistral:7b`), so for them only a prefix the user defined counts.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveModelAlias(name: string, aliases: ModelAliases | undefined, defaultProvider: ProviderName, custom?: Record<string, CustomProviderConfig>): {
|
|
11
23
|
provider: ProviderName;
|
|
12
24
|
model: string;
|
|
25
|
+
endpoint?: ProviderEndpoint;
|
|
13
26
|
};
|
|
27
|
+
export type ResolvedModelRef = ReturnType<typeof resolveModelAlias>;
|
|
28
|
+
/** A resolved model reference laid over a base model: the base's other settings stay, its endpoint never leaks into a different provider. */
|
|
29
|
+
export declare function applyResolved(base: ModelConfig, resolved: ResolvedModelRef): ModelConfig;
|
|
14
30
|
/** Human-readable alias list for `forge config`; '' when there are none. */
|
|
15
31
|
export declare function formatAliasTable(aliases: ModelAliases | undefined): string;
|
package/dist/model-alias.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
* A value is `provider:model` or a bare model id (current provider), and may
|
|
5
5
|
* name another alias.
|
|
6
6
|
*/
|
|
7
|
+
import { PROVIDER_PRESETS, apiModelId, registeredCustomProviders, resolveProviderEndpoint } from './custom-providers.js';
|
|
7
8
|
import { MODEL_CATALOG } from './model-catalog.js';
|
|
8
9
|
const MAX_HOPS = 5;
|
|
9
10
|
const PROVIDERS = new Set(Object.keys(MODEL_CATALOG));
|
|
@@ -34,9 +35,32 @@ export function splitModelRef(ref) {
|
|
|
34
35
|
}
|
|
35
36
|
return { model: ref };
|
|
36
37
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* `provider:model` for the eleven built-in providers, and `id:model` for a preset or `customProviders` id
|
|
40
|
+
* (`groq:llama-3.3-70b-versatile`), which resolves to an openai-compatible model with its endpoint. Ollama and
|
|
41
|
+
* LM Studio use `name:tag` model ids (`mistral:7b`), so for them only a prefix the user defined counts.
|
|
42
|
+
*/
|
|
43
|
+
export function resolveModelAlias(name, aliases, defaultProvider, custom) {
|
|
44
|
+
const providers = custom ?? registeredCustomProviders();
|
|
45
|
+
const expanded = expandModelAlias(name, aliases);
|
|
46
|
+
const { provider, model } = splitModelRef(expanded);
|
|
47
|
+
if (provider)
|
|
48
|
+
return { provider, model };
|
|
49
|
+
const idx = expanded.indexOf(':');
|
|
50
|
+
if (idx > 0) {
|
|
51
|
+
const prefix = expanded.slice(0, idx);
|
|
52
|
+
const local = defaultProvider === 'ollama' || defaultProvider === 'lmstudio';
|
|
53
|
+
const known = Object.prototype.hasOwnProperty.call(providers ?? {}, prefix) || (!local && Object.prototype.hasOwnProperty.call(PROVIDER_PRESETS, prefix));
|
|
54
|
+
const endpoint = known ? resolveProviderEndpoint(prefix, providers) : undefined;
|
|
55
|
+
if (endpoint)
|
|
56
|
+
return { provider: 'openai-compatible', model: apiModelId(prefix, expanded.slice(idx + 1), providers), endpoint };
|
|
57
|
+
}
|
|
58
|
+
return { provider: defaultProvider, model };
|
|
59
|
+
}
|
|
60
|
+
/** A resolved model reference laid over a base model: the base's other settings stay, its endpoint never leaks into a different provider. */
|
|
61
|
+
export function applyResolved(base, resolved) {
|
|
62
|
+
const { endpoint: _stale, ...rest } = base;
|
|
63
|
+
return { ...rest, ...resolved };
|
|
40
64
|
}
|
|
41
65
|
/** Human-readable alias list for `forge config`; '' when there are none. */
|
|
42
66
|
export function formatAliasTable(aliases) {
|
package/dist/model-catalog.js
CHANGED
|
@@ -38,7 +38,6 @@ export const MODEL_CATALOG = {
|
|
|
38
38
|
{ id: 'MiniMax-M3', label: 'MiniMax M3' },
|
|
39
39
|
{ id: 'MiniMax-M2.7', label: 'MiniMax M2.7' },
|
|
40
40
|
{ id: 'MiniMax-M2.7-highspeed', label: 'MiniMax M2.7 highspeed' },
|
|
41
|
-
{ id: 'MiniMax-M2', label: 'MiniMax M2' },
|
|
42
41
|
],
|
|
43
42
|
bedrock: [
|
|
44
43
|
{ id: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', label: 'Claude Sonnet 4.5 (Bedrock, US)' },
|
package/dist/model-list.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { installedFetch } from './policy/proxy.js';
|
|
1
2
|
/** Providers whose catalog can be listed over HTTP. Bedrock, Vertex and Azure list per account, not per key. */
|
|
2
3
|
export const LISTABLE_PROVIDERS = ['openai', 'anthropic', 'google', 'openrouter', 'minimax', 'ollama', 'lmstudio', 'openai-compatible'];
|
|
3
4
|
/** OpenAI's `/models` also returns embeddings, speech, images and moderation models; none can chat. */
|
|
@@ -68,7 +69,7 @@ export async function fetchProviderModels(provider, opts = {}) {
|
|
|
68
69
|
if (needsKey && !opts.apiKey)
|
|
69
70
|
throw new Error('Add an API key to see the current models.');
|
|
70
71
|
const { url, headers } = requestFor(provider, opts);
|
|
71
|
-
const doFetch = opts.fetch ?? fetch;
|
|
72
|
+
const doFetch = opts.fetch ?? installedFetch() ?? fetch;
|
|
72
73
|
let res;
|
|
73
74
|
try {
|
|
74
75
|
res = await doFetch(url, { headers, ...(opts.signal ? { signal: opts.signal } : {}) });
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { CustomProviderConfig } from './custom-providers.js';
|
|
2
|
+
import type { ModelConfig } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Per-model numbers the user knows better than the built-in tables: context and output limits and prices.
|
|
5
|
+
* Keys are `provider:model`, `<named provider id>:model`, or a bare model id (any provider). The hosts register
|
|
6
|
+
* the map at the start of every run; the context-window and pricing lookups consult it first.
|
|
7
|
+
*/
|
|
8
|
+
export interface ModelOverride {
|
|
9
|
+
limit?: {
|
|
10
|
+
context?: number;
|
|
11
|
+
output?: number;
|
|
12
|
+
};
|
|
13
|
+
cost?: {
|
|
14
|
+
input?: number;
|
|
15
|
+
output?: number;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
export type ModelOverrides = Record<string, ModelOverride>;
|
|
19
|
+
export declare function registerModelOverrides(map: ModelOverrides | undefined): void;
|
|
20
|
+
/** The override for a model, trying the named provider's key, then `provider:model`, then the bare model id. */
|
|
21
|
+
export declare function getModelOverride(model: string | ModelConfig): ModelOverride | undefined;
|
|
22
|
+
/** The context window an override sets, or undefined (zero, negative and non-numbers are ignored). */
|
|
23
|
+
export declare function overrideContextTokens(model: string | ModelConfig): number | undefined;
|
|
24
|
+
/** The prices an override sets; both rates must be given and not negative. */
|
|
25
|
+
export declare function overrideCost(model: string | ModelConfig): {
|
|
26
|
+
input: number;
|
|
27
|
+
output: number;
|
|
28
|
+
} | undefined;
|
|
29
|
+
/** Overrides from the `limit` and `cost` of each model under `customProviders`, keyed by provider id and API model id. */
|
|
30
|
+
export declare function overridesFromProviders(custom: Record<string, CustomProviderConfig> | undefined): ModelOverrides;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { apiModelId } from './custom-providers.js';
|
|
2
|
+
let current = {};
|
|
3
|
+
export function registerModelOverrides(map) {
|
|
4
|
+
current = map ?? {};
|
|
5
|
+
}
|
|
6
|
+
const lower = (map) => new Map(Object.entries(map).map(([k, v]) => [k.toLowerCase(), v]));
|
|
7
|
+
/** The override for a model, trying the named provider's key, then `provider:model`, then the bare model id. */
|
|
8
|
+
export function getModelOverride(model) {
|
|
9
|
+
if (Object.keys(current).length === 0)
|
|
10
|
+
return undefined;
|
|
11
|
+
const table = lower(current);
|
|
12
|
+
const id = (typeof model === 'string' ? model : model.model).toLowerCase();
|
|
13
|
+
if (typeof model !== 'string') {
|
|
14
|
+
if (model.endpoint) {
|
|
15
|
+
const named = table.get(`${model.endpoint.id}:${id}`.toLowerCase());
|
|
16
|
+
if (named)
|
|
17
|
+
return named;
|
|
18
|
+
}
|
|
19
|
+
const scoped = table.get(`${model.provider}:${id}`);
|
|
20
|
+
if (scoped)
|
|
21
|
+
return scoped;
|
|
22
|
+
}
|
|
23
|
+
return table.get(id);
|
|
24
|
+
}
|
|
25
|
+
const positive = (n) => typeof n === 'number' && Number.isFinite(n) && n > 0;
|
|
26
|
+
const nonNegative = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
27
|
+
/** The context window an override sets, or undefined (zero, negative and non-numbers are ignored). */
|
|
28
|
+
export function overrideContextTokens(model) {
|
|
29
|
+
const c = getModelOverride(model)?.limit?.context;
|
|
30
|
+
return positive(c) ? c : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** The prices an override sets; both rates must be given and not negative. */
|
|
33
|
+
export function overrideCost(model) {
|
|
34
|
+
const c = getModelOverride(model)?.cost;
|
|
35
|
+
return c && nonNegative(c.input) && nonNegative(c.output) ? { input: c.input, output: c.output } : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Overrides from the `limit` and `cost` of each model under `customProviders`, keyed by provider id and API model id. */
|
|
38
|
+
export function overridesFromProviders(custom) {
|
|
39
|
+
const out = {};
|
|
40
|
+
for (const [id, provider] of Object.entries(custom ?? {})) {
|
|
41
|
+
for (const [key, m] of Object.entries(provider.models ?? {})) {
|
|
42
|
+
if (!m.limit && !m.cost)
|
|
43
|
+
continue;
|
|
44
|
+
out[`${id}:${apiModelId(id, key, custom)}`] = { ...(m.limit ? { limit: m.limit } : {}), ...(m.cost ? { cost: m.cost } : {}) };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
@@ -28,11 +28,11 @@
|
|
|
28
28
|
* are fine (Map operations are atomic in V8); we don't need locks.
|
|
29
29
|
*/
|
|
30
30
|
import { readFileSync } from 'node:fs';
|
|
31
|
-
import { mkdir, writeFile } from 'node:fs/promises';
|
|
32
31
|
import { homedir } from 'node:os';
|
|
33
32
|
import { resolve as resolvePath } from 'node:path';
|
|
34
33
|
import { SUPPORTED_PROVIDERS } from '../config.js';
|
|
35
34
|
import { BUILTIN_SPECIALISTS } from './specialists.js';
|
|
35
|
+
import { updateJsonFile } from '../config-file.js';
|
|
36
36
|
/** Sentinel key the module-level Map lives behind. */
|
|
37
37
|
const REGISTRY = new Map();
|
|
38
38
|
/**
|
|
@@ -167,8 +167,7 @@ export async function saveSpecialistsToConfig(entries, configPath) {
|
|
|
167
167
|
validateSpecialistConfig(entry, configDir);
|
|
168
168
|
}
|
|
169
169
|
const file = { version: 1, specialists: entries };
|
|
170
|
-
await
|
|
171
|
-
await writeFile(path, JSON.stringify(file, null, 2) + '\n', 'utf8');
|
|
170
|
+
await updateJsonFile(path, () => file);
|
|
172
171
|
}
|
|
173
172
|
/**
|
|
174
173
|
* Pure validator: returns a normalized {@link SpecialistConfig}.
|
|
@@ -31,7 +31,7 @@ Strict rules:
|
|
|
31
31
|
|
|
32
32
|
Output format: plain markdown. The orchestrator will pass your plan to the CODE specialist.`,
|
|
33
33
|
allowedTools: ['read_file', 'list_files', 'search_files', 'run_command', 'memory_read'],
|
|
34
|
-
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'sub_agent'],
|
|
34
|
+
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'sub_agent'],
|
|
35
35
|
maxIterations: 8,
|
|
36
36
|
auto: false,
|
|
37
37
|
},
|
|
@@ -67,7 +67,7 @@ Strict rules:
|
|
|
67
67
|
|
|
68
68
|
Output format: plain markdown.`,
|
|
69
69
|
allowedTools: ['read_file', 'list_files', 'search_files', 'run_command', 'memory_read'],
|
|
70
|
-
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'sub_agent'],
|
|
70
|
+
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'sub_agent'],
|
|
71
71
|
maxIterations: 15,
|
|
72
72
|
auto: false,
|
|
73
73
|
},
|
|
@@ -84,7 +84,7 @@ Strict rules:
|
|
|
84
84
|
|
|
85
85
|
Output format: plain markdown.`,
|
|
86
86
|
allowedTools: ['read_file', 'list_files', 'search_files', 'fetch_url', 'memory_read'],
|
|
87
|
-
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'run_command', 'sub_agent'],
|
|
87
|
+
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command', 'sub_agent'],
|
|
88
88
|
maxIterations: 5,
|
|
89
89
|
auto: false,
|
|
90
90
|
},
|
|
@@ -102,7 +102,7 @@ Strict rules:
|
|
|
102
102
|
|
|
103
103
|
Output format: plain markdown.`,
|
|
104
104
|
allowedTools: ['read_file', 'list_files', 'search_files', 'memory_read'],
|
|
105
|
-
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'run_command', 'sub_agent'],
|
|
105
|
+
forbiddenTools: ['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command', 'sub_agent'],
|
|
106
106
|
maxIterations: 6,
|
|
107
107
|
auto: false,
|
|
108
108
|
},
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface CaCertificates {
|
|
2
|
+
/** The contents of each readable file that holds at least one PEM certificate. */
|
|
3
|
+
pems: string[];
|
|
4
|
+
warnings: string[];
|
|
5
|
+
}
|
|
6
|
+
export declare function readCaCertificates(files: readonly string[], opts?: {
|
|
7
|
+
home?: string;
|
|
8
|
+
}): CaCertificates;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extra certificate authorities for company networks that inspect TLS with their own root. Node reads
|
|
3
|
+
* `NODE_EXTRA_CA_CERTS` only at process start, which an editor extension cannot set, so the files are read here
|
|
4
|
+
* and handed to the HTTP dispatcher (see installProxy).
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
export function readCaCertificates(files, opts = {}) {
|
|
10
|
+
const home = opts.home ?? os.homedir();
|
|
11
|
+
const pems = [];
|
|
12
|
+
const warnings = [];
|
|
13
|
+
for (const raw of files) {
|
|
14
|
+
const file = raw.trim();
|
|
15
|
+
if (!file)
|
|
16
|
+
continue;
|
|
17
|
+
const resolved = file === '~' || file.startsWith('~/') || file.startsWith('~\\') ? path.join(home, file.slice(1)) : file;
|
|
18
|
+
try {
|
|
19
|
+
const text = readFileSync(resolved, 'utf8');
|
|
20
|
+
if (/-----BEGIN CERTIFICATE-----/.test(text))
|
|
21
|
+
pems.push(text);
|
|
22
|
+
else
|
|
23
|
+
warnings.push(`${file}: no PEM certificate found; ignored`);
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
warnings.push(`${file}: ${err.message}; ignored`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return { pems, warnings };
|
|
30
|
+
}
|
package/dist/policy/proxy.d.ts
CHANGED
|
@@ -14,7 +14,14 @@ export interface ProxySettings {
|
|
|
14
14
|
export declare function proxyFromEnv(env?: NodeJS.ProcessEnv): ProxySettings;
|
|
15
15
|
export declare function hasProxy(p: ProxySettings): boolean;
|
|
16
16
|
/**
|
|
17
|
-
*
|
|
18
|
-
*
|
|
17
|
+
* A fetch that goes through the dispatcher installed by {@link installProxy} (the proxy and the extra CA roots), or
|
|
18
|
+
* undefined when none is installed. Needed because a host can replace the global `fetch` with one that ignores the
|
|
19
|
+
* global dispatcher: VS Code does in its extension host. Calls that must honour the proxy and the roots pass this one
|
|
20
|
+
* explicitly instead of relying on the global.
|
|
19
21
|
*/
|
|
20
|
-
export declare function
|
|
22
|
+
export declare function installedFetch(): typeof fetch | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* Route all `fetch` traffic through the proxy in `settings` (default: the environment) and trust `extraCa`
|
|
25
|
+
* (PEM text) on top of the built-in roots. Idempotent. Returns whether anything was installed.
|
|
26
|
+
*/
|
|
27
|
+
export declare function installProxy(settings?: ProxySettings, extraCa?: readonly string[]): Promise<boolean>;
|