@ikarmic/forge-core 5.0.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +1 -1
  2. package/dist/agent/agent.d.ts +8 -0
  3. package/dist/agent/agent.js +148 -24
  4. package/dist/agent/background.d.ts +58 -0
  5. package/dist/agent/background.js +139 -0
  6. package/dist/agent/enhance-prompt.d.ts +3 -1
  7. package/dist/agent/enhance-prompt.js +6 -2
  8. package/dist/agent/goal.d.ts +54 -0
  9. package/dist/agent/goal.js +65 -0
  10. package/dist/agent/prompts.d.ts +25 -1
  11. package/dist/agent/prompts.js +61 -17
  12. package/dist/agents/agent-files.d.ts +43 -0
  13. package/dist/agents/agent-files.js +109 -0
  14. package/dist/checkpoints/store.js +5 -1
  15. package/dist/commands/custom-commands.d.ts +9 -4
  16. package/dist/commands/custom-commands.js +13 -2
  17. package/dist/config-file.d.ts +43 -0
  18. package/dist/config-file.js +249 -5
  19. package/dist/config-merge.d.ts +13 -0
  20. package/dist/config-merge.js +67 -0
  21. package/dist/config-schema.d.ts +340 -0
  22. package/dist/config-schema.js +126 -0
  23. package/dist/config.js +2 -1
  24. package/dist/context/compaction.js +4 -0
  25. package/dist/context/mask.js +1 -1
  26. package/dist/custom-providers.d.ts +95 -0
  27. package/dist/custom-providers.js +120 -0
  28. package/dist/git/auto-commit.d.ts +2 -2
  29. package/dist/git/auto-commit.js +5 -4
  30. package/dist/github/github-tools.d.ts +2 -2
  31. package/dist/hooks/runner.js +3 -2
  32. package/dist/import/claude-code.d.ts +39 -0
  33. package/dist/import/claude-code.js +254 -0
  34. package/dist/index.d.ts +34 -5
  35. package/dist/index.js +27 -4
  36. package/dist/journey/answers-block.d.ts +17 -0
  37. package/dist/journey/answers-block.js +15 -0
  38. package/dist/journey/index.d.ts +9 -0
  39. package/dist/journey/index.js +8 -0
  40. package/dist/journey/journey.d.ts +13 -0
  41. package/dist/journey/journey.js +40 -0
  42. package/dist/journey/offer.d.ts +25 -0
  43. package/dist/journey/offer.js +48 -0
  44. package/dist/journey/questions.d.ts +23 -0
  45. package/dist/journey/questions.js +49 -0
  46. package/dist/journey/stages.d.ts +13 -0
  47. package/dist/journey/stages.js +107 -0
  48. package/dist/journey/survey.d.ts +10 -0
  49. package/dist/journey/survey.js +203 -0
  50. package/dist/journey/trigger.d.ts +19 -0
  51. package/dist/journey/trigger.js +79 -0
  52. package/dist/journey/types.d.ts +72 -0
  53. package/dist/journey/types.js +1 -0
  54. package/dist/lsp/client.d.ts +5 -0
  55. package/dist/lsp/client.js +39 -3
  56. package/dist/mcp/index.d.ts +5 -1
  57. package/dist/mcp/index.js +3 -1
  58. package/dist/mcp/manager.d.ts +17 -3
  59. package/dist/mcp/manager.js +27 -2
  60. package/dist/mcp/marketplace.d.ts +2 -0
  61. package/dist/mcp/marketplace.js +10 -4
  62. package/dist/mcp/mcp-client.d.ts +12 -2
  63. package/dist/mcp/mcp-client.js +74 -17
  64. package/dist/mcp/mcp-config.d.ts +2 -0
  65. package/dist/mcp/mcp-config.js +1 -0
  66. package/dist/mcp/oauth.d.ts +55 -0
  67. package/dist/mcp/oauth.js +197 -0
  68. package/dist/mcp/resource-tools.d.ts +8 -0
  69. package/dist/mcp/resource-tools.js +56 -0
  70. package/dist/memory/session-digest.d.ts +12 -0
  71. package/dist/memory/session-digest.js +47 -0
  72. package/dist/memory/stale.js +2 -1
  73. package/dist/model-alias.d.ts +18 -2
  74. package/dist/model-alias.js +27 -3
  75. package/dist/model-catalog.js +0 -1
  76. package/dist/model-list.js +2 -1
  77. package/dist/model-overrides.d.ts +30 -0
  78. package/dist/model-overrides.js +48 -0
  79. package/dist/orchestrator/specialists-registry.js +2 -3
  80. package/dist/orchestrator/specialists.js +4 -4
  81. package/dist/policy/ca-certs.d.ts +8 -0
  82. package/dist/policy/ca-certs.js +30 -0
  83. package/dist/policy/proxy.d.ts +10 -3
  84. package/dist/policy/proxy.js +33 -6
  85. package/dist/pricing.js +4 -0
  86. package/dist/providers.d.ts +7 -0
  87. package/dist/providers.js +39 -5
  88. package/dist/semantic/embedder.d.ts +15 -0
  89. package/dist/semantic/embedder.js +25 -0
  90. package/dist/semantic/semantic-index.d.ts +10 -2
  91. package/dist/semantic/semantic-index.js +16 -7
  92. package/dist/sessions/session-store.js +2 -1
  93. package/dist/skills/builtin.d.ts +2 -0
  94. package/dist/skills/builtin.js +68 -0
  95. package/dist/skills/index.d.ts +2 -0
  96. package/dist/skills/index.js +2 -0
  97. package/dist/skills/loader.d.ts +10 -3
  98. package/dist/skills/loader.js +20 -7
  99. package/dist/skills/marketplace.js +2 -1
  100. package/dist/skills/remote.d.ts +14 -0
  101. package/dist/skills/remote.js +131 -0
  102. package/dist/skills/types.d.ts +10 -0
  103. package/dist/specs/generate.d.ts +18 -9
  104. package/dist/specs/generate.js +174 -14
  105. package/dist/specs/index.d.ts +2 -2
  106. package/dist/specs/index.js +1 -1
  107. package/dist/specs/store.d.ts +14 -2
  108. package/dist/specs/store.js +26 -1
  109. package/dist/specs/types.d.ts +11 -0
  110. package/dist/steering/extra-instructions.d.ts +8 -0
  111. package/dist/steering/extra-instructions.js +104 -0
  112. package/dist/steering/folder-instructions.d.ts +8 -0
  113. package/dist/steering/folder-instructions.js +28 -0
  114. package/dist/steering/select.js +2 -1
  115. package/dist/timed-fetch.d.ts +12 -0
  116. package/dist/timed-fetch.js +83 -0
  117. package/dist/tools/apply-patch.d.ts +52 -0
  118. package/dist/tools/apply-patch.js +197 -0
  119. package/dist/tools/ask-user.d.ts +27 -0
  120. package/dist/tools/ask-user.js +60 -0
  121. package/dist/tools/browser-tools.d.ts +6 -6
  122. package/dist/tools/browser-tools.js +3 -13
  123. package/dist/tools/codebase-search.d.ts +4 -2
  124. package/dist/tools/codebase-search.js +2 -2
  125. package/dist/tools/edit-file.js +5 -3
  126. package/dist/tools/fetch-url.d.ts +7 -0
  127. package/dist/tools/fetch-url.js +52 -19
  128. package/dist/tools/find-symbol.d.ts +2 -2
  129. package/dist/tools/ignore-rules.js +2 -1
  130. package/dist/tools/index.d.ts +5 -1
  131. package/dist/tools/index.js +10 -1
  132. package/dist/tools/memory.d.ts +6 -6
  133. package/dist/tools/notebook.d.ts +2 -2
  134. package/dist/tools/permission-rules.d.ts +32 -0
  135. package/dist/tools/permission-rules.js +182 -0
  136. package/dist/tools/read-file.d.ts +5 -0
  137. package/dist/tools/read-file.js +12 -2
  138. package/dist/tools/read-only.d.ts +1 -1
  139. package/dist/tools/read-only.js +2 -0
  140. package/dist/tools/run-command.d.ts +4 -2
  141. package/dist/tools/run-command.js +12 -8
  142. package/dist/tools/sandbox-loader.js +3 -13
  143. package/dist/tools/search-files.d.ts +8 -2
  144. package/dist/tools/search-files.js +43 -0
  145. package/dist/tools/shared.d.ts +5 -0
  146. package/dist/tools/shared.js +30 -2
  147. package/dist/tools/shell-env.d.ts +7 -0
  148. package/dist/tools/shell-env.js +9 -0
  149. package/dist/tools/sub-agent-wait.d.ts +29 -0
  150. package/dist/tools/sub-agent-wait.js +60 -0
  151. package/dist/tools/sub-agent.d.ts +50 -4
  152. package/dist/tools/sub-agent.js +213 -125
  153. package/dist/tools/text-encoding.d.ts +10 -0
  154. package/dist/tools/text-encoding.js +40 -0
  155. package/dist/tools/todo.d.ts +36 -0
  156. package/dist/tools/todo.js +42 -0
  157. package/dist/tools/write-file.js +15 -9
  158. package/dist/tools/write-tools.d.ts +12 -0
  159. package/dist/tools/write-tools.js +18 -0
  160. package/dist/types.d.ts +98 -0
  161. package/dist/worktree/worktree.d.ts +3 -0
  162. package/dist/worktree/worktree.js +22 -0
  163. package/package.json +1 -1
@@ -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 mkdir(configDir, { recursive: true });
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
+ }
@@ -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
- * Route all `fetch` traffic through the proxy in `settings` (default: the environment).
18
- * Idempotent. Returns whether a proxy was installed.
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 installProxy(settings?: ProxySettings): Promise<boolean>;
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>;
@@ -5,6 +5,8 @@
5
5
  * well-known global symbol, so installing it from the npm `undici` package also
6
6
  * routes Node's bundled fetch.
7
7
  */
8
+ import https from 'node:https';
9
+ import { rootCertificates } from 'node:tls';
8
10
  /** Proxy settings from the standard environment variables (either case). */
9
11
  export function proxyFromEnv(env = process.env) {
10
12
  const pick = (a, b) => env[a] || env[b] || undefined;
@@ -17,15 +19,40 @@ export function hasProxy(p) {
17
19
  return Boolean(p.https || p.http);
18
20
  }
19
21
  let installed = false;
22
+ let installedFetchFn;
20
23
  /**
21
- * Route all `fetch` traffic through the proxy in `settings` (default: the environment).
22
- * Idempotent. Returns whether a proxy was installed.
24
+ * A fetch that goes through the dispatcher installed by {@link installProxy} (the proxy and the extra CA roots), or
25
+ * undefined when none is installed. Needed because a host can replace the global `fetch` with one that ignores the
26
+ * global dispatcher: VS Code does in its extension host. Calls that must honour the proxy and the roots pass this one
27
+ * explicitly instead of relying on the global.
23
28
  */
24
- export async function installProxy(settings = proxyFromEnv()) {
25
- if (installed || !hasProxy(settings))
29
+ export function installedFetch() {
30
+ return installedFetchFn;
31
+ }
32
+ /**
33
+ * Route all `fetch` traffic through the proxy in `settings` (default: the environment) and trust `extraCa`
34
+ * (PEM text) on top of the built-in roots. Idempotent. Returns whether anything was installed.
35
+ */
36
+ export async function installProxy(settings = proxyFromEnv(), extraCa = []) {
37
+ if (installed || (!hasProxy(settings) && extraCa.length === 0))
26
38
  return false;
27
- const { EnvHttpProxyAgent, setGlobalDispatcher } = await import('undici');
28
- setGlobalDispatcher(new EnvHttpProxyAgent({
39
+ const { Agent, EnvHttpProxyAgent, fetch: undiciFetch, setGlobalDispatcher } = await import('undici');
40
+ const useDispatcher = (dispatcher) => {
41
+ setGlobalDispatcher(dispatcher);
42
+ installedFetchFn = ((input, init) => undiciFetch(input, { ...init, dispatcher }));
43
+ };
44
+ const tls = extraCa.length > 0 ? { ca: [...rootCertificates, ...extraCa] } : undefined;
45
+ // Not everything goes through undici: VS Code replaces the extension host's fetch with one built on node:https, which
46
+ // reads the global agent. Trust the roots there too.
47
+ if (tls)
48
+ https.globalAgent.options.ca = tls.ca;
49
+ if (!hasProxy(settings)) {
50
+ useDispatcher(new Agent({ connect: tls }));
51
+ installed = true;
52
+ return true;
53
+ }
54
+ useDispatcher(new EnvHttpProxyAgent({
55
+ ...(tls ? { requestTls: tls, proxyTls: tls } : {}),
29
56
  // Plain-http targets are forwarded as absolute-URI requests (the classic proxy protocol);
30
57
  // https targets always use CONNECT tunnels.
31
58
  proxyTunnel: false,
package/dist/pricing.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { overrideCost } from './model-overrides.js';
1
2
  /**
2
3
  * Pricing table. Keys are matched with case-insensitive substring matching
3
4
  * against the model id (after stripping any "models/" / "claude-" /
@@ -99,6 +100,9 @@ export function lookupPricing(model, override) {
99
100
  const cache = typeof model === 'string' ? {} : cacheMultipliersFor(model);
100
101
  if (override)
101
102
  return { ...cache, ...override, known: true };
103
+ const configured = overrideCost(model);
104
+ if (configured)
105
+ return { ...cache, ...configured, known: true };
102
106
  const id = (typeof model === 'string' ? model : model.model).toLowerCase();
103
107
  // Strip only the OpenRouter-style 'models/' prefix so 'models/gemini-1.5-pro'
104
108
  // resolves the same way as 'gemini-1.5-pro'. We do NOT strip provider prefixes
@@ -1,4 +1,5 @@
1
1
  import type { LanguageModel } from 'ai';
2
+ import type { ProviderEndpoint } from './custom-providers.js';
2
3
  import type { ModelConfig, ProviderName } from './types.js';
3
4
  /**
4
5
  * Build a Vercel AI SDK `LanguageModel` from a `ModelConfig`.
@@ -14,6 +15,12 @@ import type { ModelConfig, ProviderName } from './types.js';
14
15
  * dependency footprint small (no separate `ollama-ai-provider` or
15
16
  * `openrouter-ai-provider` packages).
16
17
  */
18
+ /**
19
+ * A model on a named provider: the OpenAI chat protocol (default) or the Anthropic message protocol, at the
20
+ * endpoint's URL with its key and headers. `timeoutMs` and `chunkTimeoutMs` wrap the HTTP calls.
21
+ * `fetchImpl` exists for tests.
22
+ */
23
+ export declare function createEndpointModel(endpoint: ProviderEndpoint, model: string, fetchImpl?: typeof fetch): LanguageModel;
17
24
  export declare function createLanguageModel(config: ModelConfig): LanguageModel;
18
25
  /**
19
26
  * Resolve a model from the environment when no config is supplied. Throws a
package/dist/providers.js CHANGED
@@ -1,7 +1,10 @@
1
1
  import { anthropic } from '@ai-sdk/anthropic';
2
- import { google } from '@ai-sdk/google';
2
+ import { createGoogleGenerativeAI, google } from '@ai-sdk/google';
3
+ import { createAnthropic } from '@ai-sdk/anthropic';
3
4
  import { createOpenAI, openai } from '@ai-sdk/openai';
4
5
  import { defaultModelFor, hasProviderKey } from './config.js';
6
+ import { installedFetch } from './policy/proxy.js';
7
+ import { withTimeouts } from './timed-fetch.js';
5
8
  import { lazyLanguageModel } from './lazy-model.js';
6
9
  /**
7
10
  * Default Ollama base URL. Ollama serves an OpenAI-compatible API at
@@ -33,25 +36,51 @@ const MINIMAX_BASE_URL = 'https://api.minimax.io/v1';
33
36
  * dependency footprint small (no separate `ollama-ai-provider` or
34
37
  * `openrouter-ai-provider` packages).
35
38
  */
39
+ /**
40
+ * A model on a named provider: the OpenAI chat protocol (default) or the Anthropic message protocol, at the
41
+ * endpoint's URL with its key and headers. `timeoutMs` and `chunkTimeoutMs` wrap the HTTP calls.
42
+ * `fetchImpl` exists for tests.
43
+ */
44
+ export function createEndpointModel(endpoint, model, fetchImpl = installedFetch() ?? fetch) {
45
+ const timed = withTimeouts(fetchImpl, { ...(endpoint.timeoutMs ? { timeoutMs: endpoint.timeoutMs } : {}), ...(endpoint.chunkTimeoutMs ? { chunkTimeoutMs: endpoint.chunkTimeoutMs } : {}) });
46
+ const common = {
47
+ baseURL: endpoint.baseUrl,
48
+ ...(endpoint.headers ? { headers: endpoint.headers } : {}),
49
+ fetch: timed,
50
+ };
51
+ if (endpoint.api === 'anthropic')
52
+ return createAnthropic({ ...common, apiKey: endpoint.apiKey ?? '' })(model);
53
+ return createOpenAI({ ...common, apiKey: endpoint.apiKey ?? 'none' }).chat(model);
54
+ }
55
+ /** `{ fetch }` when a proxy or extra CA roots are installed (see installedFetch), else nothing: the SDK then uses the global one. */
56
+ function viaInstalled() {
57
+ const f = installedFetch();
58
+ return f ? { fetch: f } : {};
59
+ }
36
60
  export function createLanguageModel(config) {
61
+ if (config.endpoint)
62
+ return createEndpointModel(config.endpoint, config.model);
63
+ const net = viaInstalled();
37
64
  switch (config.provider) {
38
65
  case 'openai':
39
- return openai(config.model);
66
+ return net.fetch ? createOpenAI(net)(config.model) : openai(config.model);
40
67
  case 'anthropic':
41
- return anthropic(config.model);
68
+ return net.fetch ? createAnthropic(net)(config.model) : anthropic(config.model);
42
69
  case 'google':
43
- return google(config.model);
70
+ return net.fetch ? createGoogleGenerativeAI(net)(config.model) : google(config.model);
44
71
  case 'ollama':
45
72
  return createOpenAI({
46
73
  baseURL: process.env.OLLAMA_BASE_URL ?? DEFAULT_OLLAMA_BASE_URL,
47
74
  // Ollama doesn't require an API key: pass a placeholder so the
48
75
  // OpenAI SDK's auth header doesn't trip on undefined.
49
76
  apiKey: process.env.OLLAMA_API_KEY ?? 'ollama',
77
+ ...net,
50
78
  }).chat(config.model);
51
79
  case 'openrouter':
52
80
  return createOpenAI({
53
81
  baseURL: OPENROUTER_BASE_URL,
54
82
  apiKey: process.env.OPENROUTER_API_KEY ?? '',
83
+ ...net,
55
84
  headers: {
56
85
  // OpenRouter recommends an app-name header so they can attribute
57
86
  // traffic; helps if you ever need to reach support.
@@ -64,18 +93,20 @@ export function createLanguageModel(config) {
64
93
  return createOpenAI({
65
94
  baseURL: process.env.MINIMAX_BASE_URL ?? MINIMAX_BASE_URL,
66
95
  apiKey: process.env.MINIMAX_API_KEY ?? '',
96
+ ...net,
67
97
  }).chat(config.model);
68
98
  case 'lmstudio':
69
99
  return createOpenAI({
70
100
  baseURL: process.env.LMSTUDIO_BASE_URL ?? DEFAULT_LMSTUDIO_BASE_URL,
71
101
  apiKey: process.env.LMSTUDIO_API_KEY ?? 'lm-studio',
102
+ ...net,
72
103
  }).chat(config.model);
73
104
  case 'openai-compatible': {
74
105
  const baseURL = process.env.OPENAI_COMPATIBLE_BASE_URL;
75
106
  if (!baseURL) {
76
107
  throw new Error('Provider "openai-compatible" needs OPENAI_COMPATIBLE_BASE_URL (e.g. http://localhost:8000/v1).');
77
108
  }
78
- return createOpenAI({ baseURL, apiKey: process.env.OPENAI_COMPATIBLE_API_KEY ?? 'none' }).chat(config.model);
109
+ return createOpenAI({ baseURL, apiKey: process.env.OPENAI_COMPATIBLE_API_KEY ?? 'none', ...net }).chat(config.model);
79
110
  }
80
111
  // The three cloud-account SDKs are heavy (~1.2 s to import together), so they load on first use.
81
112
  case 'bedrock':
@@ -83,6 +114,7 @@ export function createLanguageModel(config) {
83
114
  const { createAmazonBedrock } = await import('@ai-sdk/amazon-bedrock');
84
115
  return createAmazonBedrock({
85
116
  region: process.env.AWS_REGION ?? 'us-east-1',
117
+ ...net,
86
118
  ...(process.env.AWS_BEARER_TOKEN_BEDROCK ? { apiKey: process.env.AWS_BEARER_TOKEN_BEDROCK } : {}),
87
119
  ...(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY
88
120
  ? {
@@ -99,12 +131,14 @@ export function createLanguageModel(config) {
99
131
  return createVertex({
100
132
  ...(process.env.GOOGLE_VERTEX_PROJECT ? { project: process.env.GOOGLE_VERTEX_PROJECT } : {}),
101
133
  location: process.env.GOOGLE_VERTEX_LOCATION ?? 'us-central1',
134
+ ...net,
102
135
  })(config.model);
103
136
  });
104
137
  case 'azure':
105
138
  return lazyLanguageModel({ provider: 'azure', modelId: config.model }, async () => {
106
139
  const { createAzure } = await import('@ai-sdk/azure');
107
140
  return createAzure({
141
+ ...net,
108
142
  ...(process.env.AZURE_API_KEY ? { apiKey: process.env.AZURE_API_KEY } : {}),
109
143
  ...(process.env.AZURE_RESOURCE_NAME ? { resourceName: process.env.AZURE_RESOURCE_NAME } : {}),
110
144
  ...(process.env.AZURE_OPENAI_BASE_URL ? { baseURL: process.env.AZURE_OPENAI_BASE_URL } : {}),
@@ -26,6 +26,21 @@ export interface OpenAiCompatibleEmbedderOptions {
26
26
  fetchImpl?: typeof fetch;
27
27
  }
28
28
  export declare function openAiCompatibleEmbedder(options: OpenAiCompatibleEmbedderOptions): Embedder;
29
+ export interface EmbedderSpec {
30
+ kind: 'hashing' | 'openai' | 'ollama' | 'custom';
31
+ model?: string;
32
+ /** For `custom`: an OpenAI-compatible `/embeddings` endpoint, up to and including `/v1`. */
33
+ baseUrl?: string;
34
+ /** For `custom`: the name of the environment variable holding the key (never the key itself). */
35
+ apiKeyEnv?: string;
36
+ batchSize?: number;
37
+ fetchImpl?: typeof fetch;
38
+ }
39
+ /**
40
+ * An embedder from explicit settings. A choice that lacks what it needs (no key, no endpoint) gives the offline
41
+ * hashing embedder rather than an embedder that fails every search.
42
+ */
43
+ export declare function buildEmbedder(spec: EmbedderSpec, env?: NodeJS.ProcessEnv): Embedder;
29
44
  /**
30
45
  * `FORGE_EMBED_PROVIDER` = `hashing` (default) | `openai` | `ollama`.
31
46
  * `FORGE_EMBED_MODEL` overrides the model. Falls back to hashing when the
@@ -85,6 +85,31 @@ export function openAiCompatibleEmbedder(options) {
85
85
  },
86
86
  };
87
87
  }
88
+ /**
89
+ * An embedder from explicit settings. A choice that lacks what it needs (no key, no endpoint) gives the offline
90
+ * hashing embedder rather than an embedder that fails every search.
91
+ */
92
+ export function buildEmbedder(spec, env = process.env) {
93
+ const common = { ...(spec.batchSize ? { batchSize: spec.batchSize } : {}), ...(spec.fetchImpl ? { fetchImpl: spec.fetchImpl } : {}) };
94
+ switch (spec.kind) {
95
+ case 'openai':
96
+ if (!env.OPENAI_API_KEY)
97
+ return hashingEmbedder();
98
+ return openAiCompatibleEmbedder({ baseUrl: env.OPENAI_BASE_URL ?? 'https://api.openai.com/v1', model: spec.model || 'text-embedding-3-small', apiKey: env.OPENAI_API_KEY, ...common });
99
+ case 'ollama': {
100
+ const host = (env.OLLAMA_BASE_URL ?? 'http://localhost:11434').replace(/\/+$/, '').replace(/\/v1$/, '');
101
+ return openAiCompatibleEmbedder({ baseUrl: `${host}/v1`, model: spec.model || 'nomic-embed-text', ...common });
102
+ }
103
+ case 'custom': {
104
+ if (!spec.baseUrl || !spec.model)
105
+ return hashingEmbedder();
106
+ const apiKey = spec.apiKeyEnv ? env[spec.apiKeyEnv] : undefined;
107
+ return openAiCompatibleEmbedder({ baseUrl: spec.baseUrl, model: spec.model, ...(apiKey ? { apiKey } : {}), ...common });
108
+ }
109
+ default:
110
+ return hashingEmbedder();
111
+ }
112
+ }
88
113
  /**
89
114
  * `FORGE_EMBED_PROVIDER` = `hashing` (default) | `openai` | `ollama`.
90
115
  * `FORGE_EMBED_MODEL` overrides the model. Falls back to hashing when the
@@ -1,5 +1,13 @@
1
1
  import { type Embedder } from './embedder.js';
2
2
  import { type SemanticManifest } from './vector-store.js';
3
+ export interface SemanticOptions {
4
+ /** File extensions to index, with the dot. Default: the common source and Markdown ones. */
5
+ extensions?: readonly string[];
6
+ /** Hits scoring below this (cosine similarity) are left out. Default: none cut. */
7
+ minScore?: number;
8
+ }
9
+ /** `"ts, .py,md"` as `['.ts', '.py', '.md']`; undefined (use the defaults) when nothing usable is left. */
10
+ export declare function parseExtensions(text: string | undefined): string[] | undefined;
3
11
  export interface SemanticHit {
4
12
  path: string;
5
13
  startLine: number;
@@ -8,5 +16,5 @@ export interface SemanticHit {
8
16
  snippet: string;
9
17
  }
10
18
  /** Bring `.forge/index/embeddings.json` up to date: re-embed only changed files, drop deleted ones. */
11
- export declare function ensureSemanticFresh(cwd: string, embedder: Embedder): Promise<SemanticManifest>;
12
- export declare function searchSemantic(cwd: string, embedder: Embedder, query: string, k?: number): Promise<SemanticHit[]>;
19
+ export declare function ensureSemanticFresh(cwd: string, embedder: Embedder, options?: SemanticOptions): Promise<SemanticManifest>;
20
+ export declare function searchSemantic(cwd: string, embedder: Embedder, query: string, k?: number, options?: SemanticOptions): Promise<SemanticHit[]>;
@@ -7,9 +7,17 @@ import { chunkSource } from './chunker.js';
7
7
  import { cosine } from './embedder.js';
8
8
  import { decodeVector, encodeVector, loadSemanticManifest, saveSemanticManifest } from './vector-store.js';
9
9
  const EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.py', '.go', '.rs', '.java', '.md']);
10
+ /** `"ts, .py,md"` as `['.ts', '.py', '.md']`; undefined (use the defaults) when nothing usable is left. */
11
+ export function parseExtensions(text) {
12
+ const list = (text ?? '')
13
+ .split(',')
14
+ .map((e) => e.trim().toLowerCase().replace(/^\.?/, '.'))
15
+ .filter((e) => /^\.[a-z0-9]{1,12}$/.test(e));
16
+ return list.length ? [...new Set(list)] : undefined;
17
+ }
10
18
  const MAX_FILE_BYTES = 512 * 1024;
11
19
  const SNIPPET_LINES = 40;
12
- async function collect(cwd) {
20
+ async function collect(cwd, extensions) {
13
21
  const out = [];
14
22
  const rules = IgnoreRules.forRoot(cwd);
15
23
  async function walk(dir) {
@@ -29,7 +37,7 @@ async function collect(cwd) {
29
37
  if (entry.isDirectory()) {
30
38
  await walk(abs);
31
39
  }
32
- else if (entry.isFile() && EXTENSIONS.has(path.extname(entry.name))) {
40
+ else if (entry.isFile() && extensions.has(path.extname(entry.name).toLowerCase())) {
33
41
  const s = await stat(abs);
34
42
  if (s.size <= MAX_FILE_BYTES) {
35
43
  out.push({ rel: path.relative(cwd, abs).split(path.sep).join('/'), abs, ext: path.extname(entry.name), mtimeMs: s.mtimeMs });
@@ -41,9 +49,9 @@ async function collect(cwd) {
41
49
  return out;
42
50
  }
43
51
  /** Bring `.forge/index/embeddings.json` up to date: re-embed only changed files, drop deleted ones. */
44
- export async function ensureSemanticFresh(cwd, embedder) {
52
+ export async function ensureSemanticFresh(cwd, embedder, options = {}) {
45
53
  const manifest = await loadSemanticManifest(cwd, embedder.id);
46
- const files = await collect(cwd);
54
+ const files = await collect(cwd, options.extensions ? new Set(options.extensions) : EXTENSIONS);
47
55
  const seen = new Set(files.map((f) => f.rel));
48
56
  let changed = false;
49
57
  for (const f of files) {
@@ -68,8 +76,8 @@ export async function ensureSemanticFresh(cwd, embedder) {
68
76
  await saveSemanticManifest(cwd, manifest);
69
77
  return manifest;
70
78
  }
71
- export async function searchSemantic(cwd, embedder, query, k = 8) {
72
- const manifest = await ensureSemanticFresh(cwd, embedder);
79
+ export async function searchSemantic(cwd, embedder, query, k = 8, options = {}) {
80
+ const manifest = await ensureSemanticFresh(cwd, embedder, options);
73
81
  const [q] = await embedder.embed([query]);
74
82
  if (!q)
75
83
  return [];
@@ -80,7 +88,8 @@ export async function searchSemantic(cwd, embedder, query, k = 8) {
80
88
  }
81
89
  scored.sort((a, b) => b.score - a.score);
82
90
  const hits = [];
83
- for (const top of scored.slice(0, k)) {
91
+ const floor = options.minScore;
92
+ for (const top of scored.filter((c) => floor === undefined || c.score >= floor).slice(0, k)) {
84
93
  let text;
85
94
  try {
86
95
  const lines = (await readFile(path.join(cwd, top.rel), 'utf8')).split(/\r?\n/);
@@ -155,7 +155,8 @@ function extractPreview(messages) {
155
155
  if (!firstUser)
156
156
  return undefined;
157
157
  const rawText = typeof firstUser.content === 'string' ? firstUser.content : extractText(firstUser.content);
158
- const text = rawText === undefined ? undefined : stripSteering(rawText);
158
+ // The `<context ...>` blocks an @mention appends are attachments, not the user's words.
159
+ const text = rawText === undefined ? undefined : stripSteering(rawText).replace(/\n\n<context [^>]*>[\s\S]*?<\/context>/g, '');
159
160
  if (!text)
160
161
  return undefined;
161
162
  const oneLine = text.replace(/\s+/g, ' ').trim();
@@ -0,0 +1,2 @@
1
+ import type { Skill } from './types.js';
2
+ export declare function builtinSkills(): Skill[];
@@ -0,0 +1,68 @@
1
+ import { USER_ONLY_CONFIG_KEYS } from '../config-merge.js';
2
+ /** One line per key of `~/.forge/config.json`. Typed against the file's keys: a new key does not compile until it has a line. */
3
+ const KEY_HELP = {
4
+ provider: 'default provider id',
5
+ model: 'default model id, alias or provider:model',
6
+ completionModel: 'model for inline completion',
7
+ compactionModel: 'model that writes the summary when a long chat is condensed',
8
+ disabledProviders: 'providers hidden from the model picker',
9
+ reasoningEffort: 'none, minimal, low, medium, high or xhigh',
10
+ autoApproveTools: 'tools that run without asking',
11
+ autoApproveCommands: 'commands you chose "Always allow"; written by Forge, do not hand-edit',
12
+ permission: 'ordered rules per tool kind (read, edit, bash, webfetch, websearch, task, external_directory), each allow, ask or deny; the last match wins',
13
+ customProviders: 'providers beyond the built-in ones: id to { name, baseUrl, apiKeyEnv or apiKey, headers, api, timeoutMs, models }',
14
+ modelOverrides: 'context and output limits and prices per model',
15
+ modelFilters: 'per provider whitelist and blacklist of model ids',
16
+ subagentModel: 'model sub-agents use when none is named',
17
+ instructions: 'extra instruction files, globs and http(s) URLs added to the system prompt',
18
+ skillPaths: 'extra folders, or http(s) URLs of a skill file or a skill index ({ "skills": [{ "url": "..." }] }), to read skills from',
19
+ caCertificates: 'PEM files of extra certificate authorities to trust; needs a reload',
20
+ aliases: 'short names for models',
21
+ modeModels: 'model per chat mode',
22
+ sessionRetentionDays: 'delete saved chats older than this many days',
23
+ display: 'blockDefault (collapsed or expanded), detail (compact or full), showReasoning (true or false), and blocks: per kind of block, reasoning (hidden, collapsed or expanded) and terminal, edit, mcp (collapsed or expanded). The kinds are exactly reasoning, terminal, edit and mcp; use no other name',
24
+ };
25
+ function configBody() {
26
+ const userOnly = USER_ONLY_CONFIG_KEYS.join(', ');
27
+ const keys = Object.keys(KEY_HELP).map((k) => `- \`${k}\`: ${KEY_HELP[k]}`).join('\n');
28
+ return `# Changing Forge's settings
29
+
30
+ Use this when the user asks to change how Forge behaves: a model, a provider, permissions, MCP servers, agents, commands, skills.
31
+
32
+ ## Where things live
33
+
34
+ - \`~/.forge/config.json\`: the user's own settings. Applies in every project.
35
+ - \`<workspace>/.forge/config.json\`: the project's settings, read only when the workspace is trusted. It can set provider, model, completionModel, compactionModel, reasoningEffort, subagentModel, modelOverrides, aliases, modeModels and display, and add stricter permission rules (ask, deny). It can never loosen anything.
36
+ - User-only keys (a project file cannot set them, Forge ignores them there with a warning): ${userOnly}.
37
+ - \`~/.forge/mcp.json\` and \`<workspace>/.forge/mcp.json\`: MCP servers, \`{ "mcpServers": { "name": { "command", "args", "env" } or { "url", "headers" } } }\`.
38
+ - \`<workspace>/.forge/agents/*.md\` and \`~/.forge/agents/*.md\`: agents; front matter (description, mode, model, tools, deny, steps, temperature) then the system prompt.
39
+ - \`<workspace>/.forge/commands/*.md\` and \`~/.forge/commands/*.md\`: slash commands; \`$ARGUMENTS\` is replaced by what the user types after the name.
40
+ - \`<workspace>/.forge/skills/\`, \`<workspace>/skills/\`, \`~/.forge/skills/\`: skills.
41
+ - \`AGENTS.md\` (or \`CLAUDE.md\`) in the workspace or a folder: instructions for the model.
42
+ - In VS Code the same choices also exist as \`forge.*\` settings, edited on the Forge Settings page or in settings.json.
43
+
44
+ ## Keys of config.json
45
+
46
+ ${keys}
47
+
48
+ ## How to change them
49
+
50
+ 0. Your file tools only reach the workspace, so you cannot edit \`~/.forge/config.json\`. For a key the project file may set (see above), edit \`<workspace>/.forge/config.json\` and say plainly that the change is workspace-scoped (only for this workspace, and only when it is trusted). For a user-only key, or when the person wants it everywhere, show the exact JSON to add and tell them to paste it into \`~/.forge/config.json\` or use the Forge Settings page; do not write it into the project file.
51
+ 1. Read the file first. If it does not exist, create it with only the keys you are setting.
52
+ 2. Change only what was asked. Keep every other key and keep the file valid JSON (double quotes, no trailing commas, no comments).
53
+ 3. A secret never goes in a project file. Name an environment variable (\`apiKeyEnv\`) instead of pasting a key.
54
+ 4. Do not widen permissions (\`autoApproveTools\`, \`permission\` allow rules, \`autoApproveCommands\`) unless the user named what to allow. A general request such as "make the prompts go away" or "allow everything" names nothing: do not write it, and never grant \`run_command\`, \`write_file\`, \`edit_file\`, \`apply_patch\` or \`sub_agent\` because of it. Say in one sentence why, then offer the narrow forms: allow one named tool, or choose "Always allow" on a specific command when its card appears. When you do widen, say what the change allows.
55
+ 5. After saving, say what changed and where. Most changes apply on the next message; \`caCertificates\` and the language need a window reload, MCP servers and skills need \`/reload\`.
56
+ 6. Do not run Forge commands that write to the real \`~/.forge\` to test a change. Edit the file and let the user check it.
57
+ `;
58
+ }
59
+ export function builtinSkills() {
60
+ return [
61
+ {
62
+ name: 'forge-config',
63
+ description: "Change Forge's own settings: models, providers, permissions, MCP servers, agents, commands, skills and instructions. Use when the user asks to configure or set up Forge itself.",
64
+ body: configBody(),
65
+ source: { kind: 'builtin', path: '(built into Forge)' },
66
+ },
67
+ ];
68
+ }