@ikarmic/forge-core 5.2.0 → 5.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent.js +195 -55
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/prompts.d.ts +15 -0
- package/dist/agent/prompts.js +34 -0
- package/dist/agent/retry.d.ts +50 -0
- package/dist/agent/retry.js +192 -0
- package/dist/agent/shell-command.d.ts +23 -0
- package/dist/agent/shell-command.js +93 -0
- package/dist/catalog/models-dev-snapshot.d.ts +1 -0
- package/dist/catalog/models-dev-snapshot.js +3 -0
- package/dist/catalog/models-dev.d.ts +35 -0
- package/dist/catalog/models-dev.js +143 -0
- package/dist/context/compaction.js +8 -2
- package/dist/git/auto-commit.d.ts +4 -2
- package/dist/git/auto-commit.js +16 -5
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/memory/session-digest.js +2 -1
- package/dist/pricing.js +12 -0
- package/dist/tools/command-sandbox.d.ts +24 -0
- package/dist/tools/command-sandbox.js +73 -0
- package/dist/tools/edit-file.d.ts +8 -0
- package/dist/tools/edit-file.js +64 -16
- package/dist/tools/run-command.js +12 -2
- package/dist/tools/search-files.js +8 -49
- package/dist/tools/search-worker.d.ts +23 -0
- package/dist/tools/search-worker.js +77 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +1 -0
- package/dist/types.d.ts +58 -1
- package/package.json +1 -1
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model limits and prices from models.dev, the catalog Kilo and OpenCode read at runtime. A surface calls
|
|
3
|
+
* loadModelCatalog() once at startup: it uses a cached copy (24 h), refreshes it from models.dev, and falls back to
|
|
4
|
+
* the snapshot bundled with this release. Until then the catalog is empty and the hand-written tables answer.
|
|
5
|
+
* Lookups match an exact model id only: a newer model is never priced like an older one (RB-34).
|
|
6
|
+
*/
|
|
7
|
+
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
|
8
|
+
import os from 'node:os';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { installedFetch } from '../policy/proxy.js';
|
|
11
|
+
import { MODELS_DEV_SNAPSHOT } from './models-dev-snapshot.js';
|
|
12
|
+
export const MODELS_DEV_URL = 'https://models.dev/api.json';
|
|
13
|
+
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
const FETCH_TIMEOUT_MS = 10_000;
|
|
15
|
+
/** models.dev provider keys Forge can reach (keep in step with scripts/models-dev-snapshot.mjs). */
|
|
16
|
+
const CATALOG_PROVIDERS = [
|
|
17
|
+
'anthropic', 'openai', 'google', 'openrouter', 'minimax', 'amazon-bedrock', 'google-vertex', 'azure',
|
|
18
|
+
'deepseek', 'moonshotai', 'xai', 'mistral', 'alibaba', 'zhipuai', 'groq',
|
|
19
|
+
];
|
|
20
|
+
/** Forge provider -> the models.dev provider whose ids it uses. Others (openai-compatible…) match by id across all. */
|
|
21
|
+
const PROVIDER_KEY = {
|
|
22
|
+
anthropic: 'anthropic',
|
|
23
|
+
openai: 'openai',
|
|
24
|
+
google: 'google',
|
|
25
|
+
openrouter: 'openrouter',
|
|
26
|
+
minimax: 'minimax',
|
|
27
|
+
bedrock: 'amazon-bedrock',
|
|
28
|
+
vertex: 'google-vertex',
|
|
29
|
+
azure: 'azure',
|
|
30
|
+
};
|
|
31
|
+
/** Lower-cased id -> row, per provider key. */
|
|
32
|
+
let index;
|
|
33
|
+
function build(data) {
|
|
34
|
+
index = new Map();
|
|
35
|
+
for (const [provider, models] of Object.entries(data)) {
|
|
36
|
+
const byId = new Map();
|
|
37
|
+
for (const [id, row] of Object.entries(models))
|
|
38
|
+
byId.set(id.toLowerCase(), row);
|
|
39
|
+
index.set(provider, byId);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The fields Forge uses from a models.dev api.json, for the providers it can reach. */
|
|
43
|
+
export function trimModelsDev(api) {
|
|
44
|
+
const out = {};
|
|
45
|
+
const root = (api ?? {});
|
|
46
|
+
for (const key of CATALOG_PROVIDERS) {
|
|
47
|
+
const models = root[key]?.models;
|
|
48
|
+
if (!models || typeof models !== 'object')
|
|
49
|
+
continue;
|
|
50
|
+
out[key] = {};
|
|
51
|
+
for (const [id, m] of Object.entries(models)) {
|
|
52
|
+
const c = m?.cost ?? {};
|
|
53
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
|
|
54
|
+
const row = [num(m?.limit?.context), num(m?.limit?.output), num(c.input), num(c.output), num(c.cache_read), num(c.cache_write)];
|
|
55
|
+
while (row.length > 2 && row[row.length - 1] === null)
|
|
56
|
+
row.pop();
|
|
57
|
+
out[key][id] = row;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
function entryOf(row) {
|
|
63
|
+
const at = (i) => (typeof row[i] === 'number' && row[i] > 0 ? row[i] : undefined);
|
|
64
|
+
const entry = {};
|
|
65
|
+
if (at(0))
|
|
66
|
+
entry.context = at(0);
|
|
67
|
+
if (at(1))
|
|
68
|
+
entry.output = at(1);
|
|
69
|
+
const input = row[2];
|
|
70
|
+
const output = row[3];
|
|
71
|
+
if (typeof input === 'number' && typeof output === 'number') {
|
|
72
|
+
entry.cost = { input, output, ...(typeof row[4] === 'number' ? { cacheRead: row[4] } : {}), ...(typeof row[5] === 'number' ? { cacheWrite: row[5] } : {}) };
|
|
73
|
+
}
|
|
74
|
+
return entry;
|
|
75
|
+
}
|
|
76
|
+
/** The catalog's entry for a model, or undefined (no catalog loaded, or an id it does not list exactly). */
|
|
77
|
+
export function catalogEntry(model) {
|
|
78
|
+
if (!index)
|
|
79
|
+
return undefined;
|
|
80
|
+
const id = model.model.toLowerCase().replace(/^models\//, '');
|
|
81
|
+
const key = PROVIDER_KEY[model.provider];
|
|
82
|
+
if (key) {
|
|
83
|
+
const row = index.get(key)?.get(id);
|
|
84
|
+
return row ? entryOf(row) : undefined;
|
|
85
|
+
}
|
|
86
|
+
for (const byId of index.values()) {
|
|
87
|
+
const row = byId.get(id);
|
|
88
|
+
if (row)
|
|
89
|
+
return entryOf(row);
|
|
90
|
+
}
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
/** Forget the loaded catalog (tests). */
|
|
94
|
+
export function resetModelCatalog() {
|
|
95
|
+
index = undefined;
|
|
96
|
+
}
|
|
97
|
+
export function defaultCatalogCacheFile() {
|
|
98
|
+
return path.join(os.homedir(), '.forge', 'cache', 'models-dev.json');
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Load the catalog: a fresh cache, else models.dev (written back to the cache), else a stale cache, else the
|
|
102
|
+
* bundled snapshot. Never throws: a broken network or cache only means older data.
|
|
103
|
+
*/
|
|
104
|
+
export async function loadModelCatalog(opts = {}) {
|
|
105
|
+
const cacheFile = opts.cacheFile ?? defaultCatalogCacheFile();
|
|
106
|
+
const now = opts.now ?? Date.now();
|
|
107
|
+
let cached;
|
|
108
|
+
try {
|
|
109
|
+
const parsed = JSON.parse(await readFile(cacheFile, 'utf8'));
|
|
110
|
+
if (typeof parsed.fetchedAt === 'number' && parsed.data && typeof parsed.data === 'object')
|
|
111
|
+
cached = parsed;
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// No cache yet, or an unreadable one: fetch.
|
|
115
|
+
}
|
|
116
|
+
if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
117
|
+
build(cached.data);
|
|
118
|
+
return 'cache';
|
|
119
|
+
}
|
|
120
|
+
try {
|
|
121
|
+
const doFetch = opts.fetchImpl ?? installedFetch() ?? fetch;
|
|
122
|
+
const res = await doFetch(MODELS_DEV_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) });
|
|
123
|
+
if (!res.ok)
|
|
124
|
+
throw new Error(`models.dev answered ${res.status}`);
|
|
125
|
+
const data = trimModelsDev(await res.json());
|
|
126
|
+
if (Object.keys(data).length === 0)
|
|
127
|
+
throw new Error('models.dev returned no known provider');
|
|
128
|
+
build(data);
|
|
129
|
+
await mkdir(path.dirname(cacheFile), { recursive: true });
|
|
130
|
+
const tmpFile = `${cacheFile}.${process.pid}.tmp`;
|
|
131
|
+
await writeFile(tmpFile, JSON.stringify({ fetchedAt: now, data }));
|
|
132
|
+
await rename(tmpFile, cacheFile).catch(() => undefined);
|
|
133
|
+
return 'fetched';
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
if (cached) {
|
|
137
|
+
build(cached.data);
|
|
138
|
+
return 'stale-cache';
|
|
139
|
+
}
|
|
140
|
+
build(MODELS_DEV_SNAPSHOT);
|
|
141
|
+
return 'snapshot';
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* dedicated cheaper model or even a deterministic extractive summarizer.
|
|
13
13
|
*/
|
|
14
14
|
import { generateText } from 'ai';
|
|
15
|
+
import { withModelRetry } from '../agent/retry.js';
|
|
16
|
+
import { catalogEntry } from '../catalog/models-dev.js';
|
|
15
17
|
import { mediaPartLabel, mediaPartTokens } from '../attachments.js';
|
|
16
18
|
import { stripThinkTags } from '../agent/think-tags.js';
|
|
17
19
|
import { overrideContextTokens } from '../model-overrides.js';
|
|
@@ -88,6 +90,9 @@ export function getModelWindowTokens(model) {
|
|
|
88
90
|
const overridden = overrideContextTokens(model);
|
|
89
91
|
if (overridden)
|
|
90
92
|
return overridden;
|
|
93
|
+
const listed = typeof model === 'string' ? undefined : catalogEntry(model)?.context;
|
|
94
|
+
if (listed)
|
|
95
|
+
return listed;
|
|
91
96
|
const id = typeof model === 'string' ? model : model.model;
|
|
92
97
|
const stripped = id.toLowerCase().replace(/^models\//, '');
|
|
93
98
|
// Direct hit first.
|
|
@@ -201,11 +206,12 @@ Here is the conversation to summarize:
|
|
|
201
206
|
|
|
202
207
|
${truncateToBudget(transcript, 60_000)}`;
|
|
203
208
|
const llm = factory(model);
|
|
204
|
-
const result = await generateText({
|
|
209
|
+
const result = await withModelRetry(() => generateText({
|
|
205
210
|
model: llm,
|
|
206
211
|
prompt,
|
|
207
212
|
maxOutputTokens: 2000,
|
|
208
|
-
|
|
213
|
+
maxRetries: 0,
|
|
214
|
+
}));
|
|
209
215
|
// Reasoning models put an inline <think> block in the reply; it must not be stored as the summary.
|
|
210
216
|
return stripThinkTags(result.text).trim();
|
|
211
217
|
};
|
|
@@ -27,11 +27,11 @@ export declare function commitDirty(cwd: string, options?: GitCommitOptions): Pr
|
|
|
27
27
|
export declare function diffForFiles(cwd: string, files: string[]): Promise<string>;
|
|
28
28
|
/** Deterministic message used when no model is available or it fails. */
|
|
29
29
|
export declare function fallbackCommitMessage(files: string[]): string;
|
|
30
|
-
export declare function commitMessagePrompt(diff: string, task?: string, instruction?: string): string;
|
|
30
|
+
export declare function commitMessagePrompt(diff: string, task?: string, instruction?: string, language?: string): string;
|
|
31
31
|
/** Strip an inline `<think>` block (MiniMax and other reasoning models) and the fences/quotes a model may wrap around the message. */
|
|
32
32
|
export declare function cleanCommitMessage(text: string): string;
|
|
33
33
|
/** Draft a commit message with `model` (typically the cheap model); never throws. */
|
|
34
|
-
export declare function generateCommitMessage(diff: string, files: string[], model: ModelConfig | undefined, task?: string, modelFactory?: (cfg: ModelConfig) => LanguageModel, instruction?: string): Promise<string>;
|
|
34
|
+
export declare function generateCommitMessage(diff: string, files: string[], model: ModelConfig | undefined, task?: string, modelFactory?: (cfg: ModelConfig) => LanguageModel, instruction?: string, language?: string): Promise<string>;
|
|
35
35
|
export type UndoResult = {
|
|
36
36
|
ok: true;
|
|
37
37
|
commit: string;
|
|
@@ -60,4 +60,6 @@ export declare function afterRun(cwd: string, state: {
|
|
|
60
60
|
model?: ModelConfig;
|
|
61
61
|
task?: string;
|
|
62
62
|
modelFactory?: (cfg: ModelConfig) => LanguageModel;
|
|
63
|
+
instruction?: string;
|
|
64
|
+
language?: string;
|
|
63
65
|
}): Promise<ForgeCommit | null>;
|
package/dist/git/auto-commit.js
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { execFile } from 'node:child_process';
|
|
7
7
|
import { promisify } from 'node:util';
|
|
8
8
|
import { generateText } from 'ai';
|
|
9
|
+
import { withModelRetry } from '../agent/retry.js';
|
|
9
10
|
import { stripThinkTags } from '../agent/think-tags.js';
|
|
10
11
|
import { createLanguageModel } from '../providers.js';
|
|
11
12
|
const execFileAsync = promisify(execFile);
|
|
@@ -112,10 +113,20 @@ export function fallbackCommitMessage(files) {
|
|
|
112
113
|
return `forge: update ${shown}${more}`;
|
|
113
114
|
}
|
|
114
115
|
const MAX_DIFF_CHARS = 12_000;
|
|
115
|
-
|
|
116
|
+
const MAX_LANGUAGE_CHARS = 40;
|
|
117
|
+
/** One short line for the prompt, or '' when English or unset. The value is typed by a person and goes into a model prompt. */
|
|
118
|
+
function languageLine(language) {
|
|
119
|
+
const lang = (language ?? '').replace(/\s+/g, ' ').trim().slice(0, MAX_LANGUAGE_CHARS).trim();
|
|
120
|
+
if (!lang || /^(english|en)$/i.test(lang))
|
|
121
|
+
return '';
|
|
122
|
+
return `
|
|
123
|
+
|
|
124
|
+
Write the commit message in ${lang}.`;
|
|
125
|
+
}
|
|
126
|
+
export function commitMessagePrompt(diff, task, instruction, language) {
|
|
116
127
|
const clipped = diff.length > MAX_DIFF_CHARS ? `${diff.slice(0, MAX_DIFF_CHARS)}\n[diff truncated]` : diff;
|
|
117
128
|
const lead = instruction?.trim() || 'Write a git commit message for this diff in conventional-commit style (type(scope): subject, imperative mood, under 72 characters). Add a short body only if the change needs explaining. Reply with the message only, no quotes or code fences.';
|
|
118
|
-
return `${lead}${task ? `\n\nThe change was made for this request: ${task}` : ''}\n\n${clipped}`;
|
|
129
|
+
return `${lead}${languageLine(language)}${task ? `\n\nThe change was made for this request: ${task}` : ''}\n\n${clipped}`;
|
|
119
130
|
}
|
|
120
131
|
/** Strip an inline `<think>` block (MiniMax and other reasoning models) and the fences/quotes a model may wrap around the message. */
|
|
121
132
|
export function cleanCommitMessage(text) {
|
|
@@ -126,11 +137,11 @@ export function cleanCommitMessage(text) {
|
|
|
126
137
|
.trim();
|
|
127
138
|
}
|
|
128
139
|
/** Draft a commit message with `model` (typically the cheap model); never throws. */
|
|
129
|
-
export async function generateCommitMessage(diff, files, model, task, modelFactory = createLanguageModel, instruction) {
|
|
140
|
+
export async function generateCommitMessage(diff, files, model, task, modelFactory = createLanguageModel, instruction, language) {
|
|
130
141
|
if (!model || !diff.trim())
|
|
131
142
|
return fallbackCommitMessage(files);
|
|
132
143
|
try {
|
|
133
|
-
const result = await generateText({ model: modelFactory(model), prompt: commitMessagePrompt(diff, task, instruction) });
|
|
144
|
+
const result = await withModelRetry(() => generateText({ model: modelFactory(model), prompt: commitMessagePrompt(diff, task, instruction, language), maxRetries: 0 }), { maxRetries: 2 });
|
|
134
145
|
const message = cleanCommitMessage(result.text);
|
|
135
146
|
return message || fallbackCommitMessage(files);
|
|
136
147
|
}
|
|
@@ -181,6 +192,6 @@ export async function afterRun(cwd, state, options) {
|
|
|
181
192
|
if (files.length === 0)
|
|
182
193
|
return null;
|
|
183
194
|
const diff = await diffForFiles(cwd, files);
|
|
184
|
-
const message = await generateCommitMessage(diff, files, options.model, options.task, options.modelFactory);
|
|
195
|
+
const message = await generateCommitMessage(diff, files, options.model, options.task, options.modelFactory, options.instruction, options.language);
|
|
185
196
|
return commitFiles(cwd, files, message, options);
|
|
186
197
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export { Agent, applySandbox } from './agent/agent.js';
|
|
2
2
|
export { LoopGuard } from './agent/loop-guard.js';
|
|
3
3
|
export { enhancePrompt } from './agent/enhance-prompt.js';
|
|
4
|
+
export { generateShellCommand, shellKindOf, commandRisk } from './agent/shell-command.js';
|
|
5
|
+
export type { ShellKind } from './agent/shell-command.js';
|
|
4
6
|
export { stripThinkTags } from './agent/think-tags.js';
|
|
5
7
|
export { createGitHubCreateTool, createGitHubReadTool, createGitHubTools, githubFromEnv, parseRemote, type GitHubConfig, type PullRequestReport, type RepoRef, } from './github/github-tools.js';
|
|
6
8
|
export { createWebSearchTool, searchWeb, WEB_SEARCH_PROVIDERS, webSearchFromEnv, webSearchProblem, type WebSearchConfig, type WebSearchProvider, type WebSearchResult, } from './tools/web-search.js';
|
|
@@ -43,7 +45,7 @@ export { defaultSessionDir, generateSessionId, saveSession, loadSession, deleteS
|
|
|
43
45
|
export type { Session, ListEntry, FormatOptions, SessionUpdate, WatchSessionOptions, SessionMatch, SearchSessionsOptions, SessionPruneOptions, SessionPruneResult } from './sessions/index.js';
|
|
44
46
|
export { commandDirs, expandCustomCommand, formatCustomCommands, loadCustomCommands, parseCommandFile, type CustomCommand, } from './commands/custom-commands.js';
|
|
45
47
|
export { BUILTIN_TOOLS, editFileTool, fetchUrlTool, findSymbolTool, gitDiffTool, dependencyAuditTool, listFilesTool, memoryReadTool, memoryForgetTool, memoryWriteTool, readFileTool, runCommandTool, searchFilesTool, subAgentTool, writeFileTool, checkPolicyViolation, globToRegex, mergeToolSandbox, normalizePathPattern, pathMatchesAny, redact, countRedactions, buildRules, ToolResultCache, buildCacheKey, cachedExecute, } from './tools/index.js';
|
|
46
|
-
export type { AgentConfig, AgentEvent, AgentRunOptions, AgentRunReason, AgentRunResult, AnyLanguageModel, ApprovalDecision, CommandRunner, ModelRouter, VerifyConfig, ApprovalRequest, LanguageModelFactory, ModelConfig, PricePerMillionTokens, ProviderName, RedactOptions, TodoItem, ToolContext, UserQuestion, ToolDefinition, ToolPolicy, ToolSandbox, } from './types.js';
|
|
48
|
+
export type { AgentConfig, AgentEvent, ApprovalSource, AgentRunOptions, AgentRunReason, AgentRunResult, AnyLanguageModel, ApprovalDecision, CommandRunner, ModelRouter, VerifyConfig, ApprovalRequest, LanguageModelFactory, ModelConfig, PricePerMillionTokens, ProviderName, RedactOptions, TodoItem, ToolContext, UserQuestion, ToolDefinition, ToolPolicy, ToolSandbox, } from './types.js';
|
|
47
49
|
export type { CacheKeyParts, CacheStats } from './tools/index.js';
|
|
48
50
|
export type { RedactionRule, RedactionResult } from './tools/index.js';
|
|
49
51
|
export { buildSkillsBlock, builtinSkills, composeSkillsPrompt, discoverSkills, fetchSkillsCatalog, installSkillFromCatalog, loadSkillsRegistry, loadSkillsRegistryAsync, parseSkill, parseSkillsDir, resolveSkillRoots, scoreSkill, searchSkills, selectSkills, skillsRegistryFromList, DEFAULT_SKILL_CATALOG_URL, GLOBAL_SKILLS_DIR, LOCAL_SKILLS_DIR, PROJECT_SKILLS_DIR, SKILL_CATALOG_VERSION, } from './skills/index.js';
|
|
@@ -100,6 +102,9 @@ export { detectStackFacts, stackBlock, MAX_STACK_LIBRARIES, type StackFacts } fr
|
|
|
100
102
|
export { pickAuditors, type AuditFinding, type AuditorPlan } from './tools/dependency-audit.js';
|
|
101
103
|
export { shellEnv } from './tools/shell-env.js';
|
|
102
104
|
export { previewPatch } from './tools/apply-patch.js';
|
|
105
|
+
export { applyTextEdit } from './tools/edit-file.js';
|
|
106
|
+
export { catalogEntry, loadModelCatalog, type CatalogEntry, type CatalogSource } from './catalog/models-dev.js';
|
|
107
|
+
export { isContextOverflowError, retryDecision, withModelRetry } from './agent/retry.js';
|
|
103
108
|
export type { PatchPreview } from './tools/apply-patch.js';
|
|
104
109
|
export { applyPatchTool, parsePatch, patchedPaths } from './tools/apply-patch.js';
|
|
105
110
|
export { buildEmbedder, type EmbedderSpec } from './semantic/embedder.js';
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
export { Agent, applySandbox } from './agent/agent.js';
|
|
2
2
|
export { LoopGuard } from './agent/loop-guard.js';
|
|
3
3
|
export { enhancePrompt } from './agent/enhance-prompt.js';
|
|
4
|
+
export { generateShellCommand, shellKindOf, commandRisk } from './agent/shell-command.js';
|
|
4
5
|
export { stripThinkTags } from './agent/think-tags.js';
|
|
5
6
|
export { createGitHubCreateTool, createGitHubReadTool, createGitHubTools, githubFromEnv, parseRemote, } from './github/github-tools.js';
|
|
6
7
|
export { createWebSearchTool, searchWeb, WEB_SEARCH_PROVIDERS, webSearchFromEnv, webSearchProblem, } from './tools/web-search.js';
|
|
@@ -80,6 +81,9 @@ export { detectStackFacts, stackBlock, MAX_STACK_LIBRARIES } from './agent/stack
|
|
|
80
81
|
export { pickAuditors } from './tools/dependency-audit.js';
|
|
81
82
|
export { shellEnv } from './tools/shell-env.js';
|
|
82
83
|
export { previewPatch } from './tools/apply-patch.js';
|
|
84
|
+
export { applyTextEdit } from './tools/edit-file.js';
|
|
85
|
+
export { catalogEntry, loadModelCatalog } from './catalog/models-dev.js';
|
|
86
|
+
export { isContextOverflowError, retryDecision, withModelRetry } from './agent/retry.js';
|
|
83
87
|
export { applyPatchTool, parsePatch, patchedPaths } from './tools/apply-patch.js';
|
|
84
88
|
export { buildEmbedder } from './semantic/embedder.js';
|
|
85
89
|
export { parseExtensions } from './semantic/semantic-index.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { generateText } from 'ai';
|
|
2
|
+
import { withModelRetry } from '../agent/retry.js';
|
|
2
3
|
import { stripThinkTags } from '../agent/think-tags.js';
|
|
3
4
|
import { createLanguageModel } from '../providers.js';
|
|
4
5
|
const MAX_DIGEST_CHARS = 600;
|
|
@@ -38,7 +39,7 @@ export async function generateSessionDigest(items, model, modelFactory = createL
|
|
|
38
39
|
if (!model || !shouldDigest(items))
|
|
39
40
|
return undefined;
|
|
40
41
|
try {
|
|
41
|
-
const result = await generateText({ model: modelFactory(model), prompt: `${INSTRUCTION}\n\nConversation:\n${transcript(items)}
|
|
42
|
+
const result = await withModelRetry(() => generateText({ model: modelFactory(model), prompt: `${INSTRUCTION}\n\nConversation:\n${transcript(items)}`, maxRetries: 0 }));
|
|
42
43
|
return cleanDigest(result.text);
|
|
43
44
|
}
|
|
44
45
|
catch {
|
package/dist/pricing.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { catalogEntry } from './catalog/models-dev.js';
|
|
1
2
|
import { overrideCost } from './model-overrides.js';
|
|
2
3
|
/**
|
|
3
4
|
* Pricing table. Keys are matched with case-insensitive substring matching
|
|
@@ -120,6 +121,17 @@ export function lookupPricing(model, override) {
|
|
|
120
121
|
return { ...cache, ...configured, known: true };
|
|
121
122
|
if (typeof model !== 'string' && LOCAL_PROVIDERS.has(model.provider))
|
|
122
123
|
return { input: 0, output: 0, known: true };
|
|
124
|
+
const listed = typeof model === 'string' ? undefined : catalogEntry(model)?.cost;
|
|
125
|
+
if (listed && listed.input > 0) {
|
|
126
|
+
return {
|
|
127
|
+
...cache,
|
|
128
|
+
input: listed.input,
|
|
129
|
+
output: listed.output,
|
|
130
|
+
...(listed.cacheRead !== undefined ? { cacheReadMultiplier: listed.cacheRead / listed.input } : {}),
|
|
131
|
+
...(listed.cacheWrite !== undefined ? { cacheWriteMultiplier: listed.cacheWrite / listed.input } : {}),
|
|
132
|
+
known: true,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
123
135
|
const id = (typeof model === 'string' ? model : model.model).toLowerCase();
|
|
124
136
|
// Strip only the OpenRouter-style 'models/' prefix so 'models/gemini-1.5-pro'
|
|
125
137
|
// resolves the same way as 'gemini-1.5-pro'. We do NOT strip provider prefixes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { CommandSandbox } from '../types.js';
|
|
2
|
+
export type SandboxedCommand = {
|
|
3
|
+
kind: 'wrapped';
|
|
4
|
+
command: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: 'unavailable';
|
|
7
|
+
command: string;
|
|
8
|
+
} | {
|
|
9
|
+
kind: 'refused';
|
|
10
|
+
reason: string;
|
|
11
|
+
};
|
|
12
|
+
/** Quote one argument for /bin/sh. */
|
|
13
|
+
export declare function shellQuote(arg: string): string;
|
|
14
|
+
interface Options {
|
|
15
|
+
cwd: string;
|
|
16
|
+
sandbox: CommandSandbox;
|
|
17
|
+
platform: NodeJS.Platform;
|
|
18
|
+
tmpDir: string;
|
|
19
|
+
findBinary?: (name: string) => string | undefined;
|
|
20
|
+
realpath?: (p: string) => string;
|
|
21
|
+
}
|
|
22
|
+
/** The command line that runs `command` inside the sandbox, or why it cannot be sandboxed. */
|
|
23
|
+
export declare function sandboxCommand(command: string, opts: Options): SandboxedCommand;
|
|
24
|
+
export {};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OS sandbox for run_command (after Kilo's kilo-sandbox): bubblewrap on Linux, sandbox-exec (Seatbelt) on macOS.
|
|
3
|
+
* Writes are confined to the workspace, the temp folder and any extra folders the user named; the workspace's .git
|
|
4
|
+
* stays read-only, since a hook written there would later run outside the sandbox; the network is cut unless
|
|
5
|
+
* allowed. Windows has no comparable tool, so there the command runs as before (approval still applies).
|
|
6
|
+
*/
|
|
7
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import { resolveOnPath } from '../lsp/client.js';
|
|
10
|
+
/** Quote one argument for /bin/sh. */
|
|
11
|
+
export function shellQuote(arg) {
|
|
12
|
+
return `'${arg.replace(/'/g, `'\\''`)}'`;
|
|
13
|
+
}
|
|
14
|
+
function defaultFind(name) {
|
|
15
|
+
for (const dir of ['/usr/bin', '/bin', '/usr/local/bin']) {
|
|
16
|
+
const full = path.posix.join(dir, name);
|
|
17
|
+
if (existsSync(full))
|
|
18
|
+
return full;
|
|
19
|
+
}
|
|
20
|
+
return resolveOnPath(name, process.env, process.platform);
|
|
21
|
+
}
|
|
22
|
+
function safeRealpath(p) {
|
|
23
|
+
try {
|
|
24
|
+
return realpathSync(p);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return p;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** The command line that runs `command` inside the sandbox, or why it cannot be sandboxed. */
|
|
31
|
+
export function sandboxCommand(command, opts) {
|
|
32
|
+
const find = opts.findBinary ?? defaultFind;
|
|
33
|
+
const real = opts.realpath ?? safeRealpath;
|
|
34
|
+
if (opts.platform === 'linux') {
|
|
35
|
+
const bwrap = find('bwrap');
|
|
36
|
+
if (!bwrap)
|
|
37
|
+
return { kind: 'refused', reason: 'The command sandbox is on, but bubblewrap (bwrap) is not installed. Install it (apt install bubblewrap) or turn the sandbox off.' };
|
|
38
|
+
const writable = [opts.cwd, opts.tmpDir, ...(opts.sandbox.extraWritable ?? [])];
|
|
39
|
+
const gitDir = path.join(opts.cwd, '.git');
|
|
40
|
+
const args = [
|
|
41
|
+
bwrap,
|
|
42
|
+
'--die-with-parent',
|
|
43
|
+
'--new-session',
|
|
44
|
+
'--unshare-pid',
|
|
45
|
+
...(opts.sandbox.network ? [] : ['--unshare-net']),
|
|
46
|
+
'--ro-bind', '/', '/',
|
|
47
|
+
'--dev', '/dev',
|
|
48
|
+
'--proc', '/proc',
|
|
49
|
+
...writable.flatMap((p) => ['--bind', p, p]),
|
|
50
|
+
...(existsSync(gitDir) ? ['--ro-bind', gitDir, gitDir] : []),
|
|
51
|
+
'--chdir', opts.cwd,
|
|
52
|
+
'--', '/bin/sh', '-c', command,
|
|
53
|
+
];
|
|
54
|
+
return { kind: 'wrapped', command: args.map(shellQuote).join(' ') };
|
|
55
|
+
}
|
|
56
|
+
if (opts.platform === 'darwin') {
|
|
57
|
+
const exec = find('sandbox-exec');
|
|
58
|
+
if (!exec)
|
|
59
|
+
return { kind: 'refused', reason: 'The command sandbox is on, but sandbox-exec was not found. Turn the sandbox off to run commands.' };
|
|
60
|
+
const quote = (p) => JSON.stringify(real(p));
|
|
61
|
+
const writable = [opts.cwd, opts.tmpDir, ...(opts.sandbox.extraWritable ?? [])].map((p) => `(subpath ${quote(p)})`);
|
|
62
|
+
const profile = [
|
|
63
|
+
'(version 1)',
|
|
64
|
+
'(allow default)',
|
|
65
|
+
'(deny file-write*)',
|
|
66
|
+
`(allow file-write* ${writable.join(' ')} (subpath "/private/var/folders") (literal "/dev/null") (literal "/dev/stdout") (literal "/dev/stderr") (regex #"^/dev/tty"))`,
|
|
67
|
+
`(deny file-write* (subpath ${quote(path.posix.join(opts.cwd, '.git'))}))`,
|
|
68
|
+
...(opts.sandbox.network ? [] : ['(deny network* (remote ip))']),
|
|
69
|
+
].join('\n');
|
|
70
|
+
return { kind: 'wrapped', command: [exec, '-p', profile, '/bin/sh', '-c', command].map(shellQuote).join(' ') };
|
|
71
|
+
}
|
|
72
|
+
return { kind: 'unavailable', command };
|
|
73
|
+
}
|
|
@@ -12,4 +12,12 @@ export interface EditFileResult {
|
|
|
12
12
|
bytes: number;
|
|
13
13
|
}
|
|
14
14
|
export declare const editFileTool: ToolDefinition<z.infer<typeof params>, EditFileResult>;
|
|
15
|
+
/**
|
|
16
|
+
* What edit_file writes for `original`; throws the message the model sees when old_text cannot be placed.
|
|
17
|
+
* Shared with the chat's diff preview so the two never disagree.
|
|
18
|
+
*/
|
|
19
|
+
export declare function applyTextEdit(original: string, oldTextIn: string, newTextIn: string, replaceAll: boolean, file: string): {
|
|
20
|
+
content: string;
|
|
21
|
+
replacements: number;
|
|
22
|
+
};
|
|
15
23
|
export {};
|
package/dist/tools/edit-file.js
CHANGED
|
@@ -24,29 +24,77 @@ export const editFileTool = {
|
|
|
24
24
|
throw new Error(`File not found: ${args.path}`);
|
|
25
25
|
throw err;
|
|
26
26
|
});
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
throw new Error(`old_text not found in ${args.path}. Re-read the file (it may have changed) and try again.`);
|
|
30
|
-
}
|
|
31
|
-
const replaceAll = args.replace_all === true;
|
|
32
|
-
if (!replaceAll && occurrences > 1) {
|
|
33
|
-
throw new Error(`old_text matches ${occurrences} times in ${args.path}. Pass more context to make it unique, or set replace_all=true to replace all occurrences.`);
|
|
34
|
-
}
|
|
35
|
-
const updated = replaceAll
|
|
36
|
-
? original.split(args.old_text).join(args.new_text)
|
|
37
|
-
// Use a replacer FUNCTION so new_text is inserted literally: a plain
|
|
38
|
-
// string 2nd arg to String.prototype.replace still honors special
|
|
39
|
-
// patterns like $&, $1, $$ even though old_text isn't a regex.
|
|
40
|
-
: original.replace(args.old_text, () => args.new_text);
|
|
41
|
-
const bytes = encodeText(updated, encoding);
|
|
27
|
+
const { content, replacements } = applyTextEdit(original, args.old_text, args.new_text, args.replace_all === true, args.path);
|
|
28
|
+
const bytes = encodeText(content, encoding);
|
|
42
29
|
await writeFile(resolved, bytes);
|
|
43
30
|
return {
|
|
44
31
|
path: resolved,
|
|
45
|
-
replacements
|
|
32
|
+
replacements,
|
|
46
33
|
bytes: bytes.length,
|
|
47
34
|
};
|
|
48
35
|
},
|
|
49
36
|
};
|
|
37
|
+
/**
|
|
38
|
+
* What edit_file writes for `original`; throws the message the model sees when old_text cannot be placed.
|
|
39
|
+
* Shared with the chat's diff preview so the two never disagree.
|
|
40
|
+
*/
|
|
41
|
+
export function applyTextEdit(original, oldTextIn, newTextIn, replaceAll, file) {
|
|
42
|
+
// read_file shows a CRLF file without its \r, so the model sends LF text: match on LF, write back the file's own ending.
|
|
43
|
+
const crlf = original.includes('\r\n');
|
|
44
|
+
const content = crlf ? toLf(original) : original;
|
|
45
|
+
const oldText = toLf(oldTextIn);
|
|
46
|
+
const newText = toLf(newTextIn);
|
|
47
|
+
const occurrences = countOccurrences(content, oldText);
|
|
48
|
+
let updated;
|
|
49
|
+
if (occurrences === 0) {
|
|
50
|
+
const span = replaceAll ? undefined : trimmedLineMatch(content, oldText, file);
|
|
51
|
+
if (!span) {
|
|
52
|
+
throw new Error(`old_text not found in ${file}. Re-read the file (it may have changed) and try again.`);
|
|
53
|
+
}
|
|
54
|
+
updated = content.slice(0, span.start) + newText + content.slice(span.end);
|
|
55
|
+
}
|
|
56
|
+
else {
|
|
57
|
+
if (!replaceAll && occurrences > 1) {
|
|
58
|
+
throw new Error(`old_text matches ${occurrences} times in ${file}. Pass more context to make it unique, or set replace_all=true to replace all occurrences.`);
|
|
59
|
+
}
|
|
60
|
+
updated = replaceAll
|
|
61
|
+
? content.split(oldText).join(newText)
|
|
62
|
+
// Use a replacer FUNCTION so new_text is inserted literally: a plain
|
|
63
|
+
// string 2nd arg to String.prototype.replace still honors special
|
|
64
|
+
// patterns like $&, $1, $$ even though old_text isn't a regex.
|
|
65
|
+
: content.replace(oldText, () => newText);
|
|
66
|
+
}
|
|
67
|
+
return { content: crlf ? updated.replace(/\n/g, '\r\n') : updated, replacements: replaceAll ? occurrences : 1 };
|
|
68
|
+
}
|
|
69
|
+
function toLf(text) {
|
|
70
|
+
return text.replace(/\r\n/g, '\n');
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Fallback when the exact text is absent: the block of whole lines whose trimmed text equals old_text's trimmed
|
|
74
|
+
* lines (a model often gets indentation or trailing spaces wrong). Only a unique block counts.
|
|
75
|
+
*/
|
|
76
|
+
function trimmedLineMatch(content, oldText, file) {
|
|
77
|
+
const want = oldText.split('\n').map((l) => l.trim());
|
|
78
|
+
if (want.length > 1 && want[want.length - 1] === '')
|
|
79
|
+
want.pop();
|
|
80
|
+
if (want.every((l) => l === ''))
|
|
81
|
+
return undefined;
|
|
82
|
+
const lines = content.split('\n');
|
|
83
|
+
const starts = [];
|
|
84
|
+
for (let i = 0; i + want.length <= lines.length; i++) {
|
|
85
|
+
if (want.every((w, j) => lines[i + j].trim() === w))
|
|
86
|
+
starts.push(i);
|
|
87
|
+
}
|
|
88
|
+
if (starts.length === 0)
|
|
89
|
+
return undefined;
|
|
90
|
+
if (starts.length > 1) {
|
|
91
|
+
throw new Error(`old_text matches more than one place in ${file} once leading and trailing spaces are ignored. Pass more context to make it unique.`);
|
|
92
|
+
}
|
|
93
|
+
const offsetOf = (line) => lines.slice(0, line).reduce((n, l) => n + l.length + 1, 0);
|
|
94
|
+
const first = starts[0];
|
|
95
|
+
const last = first + want.length - 1;
|
|
96
|
+
return { start: offsetOf(first), end: offsetOf(last) + lines[last].length };
|
|
97
|
+
}
|
|
50
98
|
function countOccurrences(haystack, needle) {
|
|
51
99
|
if (needle.length === 0)
|
|
52
100
|
return 0;
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
2
3
|
import { z } from 'zod';
|
|
4
|
+
import { sandboxCommand } from './command-sandbox.js';
|
|
3
5
|
import { checkPolicyViolation } from './policy.js';
|
|
4
6
|
import { resolveAndCheckPath } from './shared.js';
|
|
5
7
|
import { shellEnv } from './shell-env.js';
|
|
@@ -64,10 +66,18 @@ export const runCommandTool = {
|
|
|
64
66
|
if (violation)
|
|
65
67
|
throw new Error(`[sandbox] ${violation}`);
|
|
66
68
|
const timeoutMs = args.timeout_ms ?? DEFAULT_TIMEOUT_MS;
|
|
69
|
+
// The OS sandbox wraps the command line; the result still reports the command the model asked for.
|
|
70
|
+
let commandLine = args.command;
|
|
71
|
+
if (ctx.commandSandbox) {
|
|
72
|
+
const boxed = sandboxCommand(args.command, { cwd, sandbox: ctx.commandSandbox, platform: process.platform, tmpDir: tmpdir() });
|
|
73
|
+
if (boxed.kind === 'refused')
|
|
74
|
+
throw new Error(`[sandbox] ${boxed.reason}`);
|
|
75
|
+
commandLine = boxed.command;
|
|
76
|
+
}
|
|
67
77
|
if (ctx.commandRunner) {
|
|
68
78
|
const started = Date.now();
|
|
69
79
|
const out = await ctx.commandRunner({
|
|
70
|
-
command:
|
|
80
|
+
command: commandLine,
|
|
71
81
|
cwd,
|
|
72
82
|
timeoutMs,
|
|
73
83
|
...(ctx.abortSignal ? { signal: ctx.abortSignal } : {}),
|
|
@@ -91,7 +101,7 @@ export const runCommandTool = {
|
|
|
91
101
|
}
|
|
92
102
|
return new Promise((resolve, reject) => {
|
|
93
103
|
const started = Date.now();
|
|
94
|
-
const child = spawn(
|
|
104
|
+
const child = spawn(commandLine, {
|
|
95
105
|
shell: true,
|
|
96
106
|
cwd,
|
|
97
107
|
// Windows: never run a same-named program the repository ships in its root (see shell-env.ts).
|