@ikarmic/forge-core 5.2.0 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/agent.js +324 -122
- package/dist/agent/cache-hints.d.ts +2 -0
- package/dist/agent/cache-hints.js +18 -3
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/history-adapt.d.ts +23 -0
- package/dist/agent/history-adapt.js +114 -0
- package/dist/agent/prompts.d.ts +19 -2
- package/dist/agent/prompts.js +44 -2
- 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/agent/tool-repair.d.ts +8 -0
- package/dist/agent/tool-repair.js +16 -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 +45 -0
- package/dist/catalog/models-dev.js +155 -0
- package/dist/checkpoints/store.js +3 -1
- package/dist/context/compaction.d.ts +21 -6
- package/dist/context/compaction.js +94 -28
- package/dist/context/mask.d.ts +9 -9
- package/dist/context/mask.js +30 -1
- package/dist/format/auto-format.d.ts +22 -0
- package/dist/format/auto-format.js +39 -0
- package/dist/git/auto-commit.d.ts +4 -2
- package/dist/git/auto-commit.js +16 -5
- package/dist/github/github-tools.d.ts +1 -0
- package/dist/github/github-tools.js +9 -9
- package/dist/github/pr.d.ts +34 -0
- package/dist/github/pr.js +70 -0
- package/dist/human-text.d.ts +6 -0
- package/dist/human-text.js +37 -0
- package/dist/index.d.ts +19 -9
- package/dist/index.js +14 -6
- package/dist/journey/answers-block.d.ts +2 -4
- package/dist/lsp/diagnostics.d.ts +4 -0
- package/dist/lsp/diagnostics.js +8 -2
- package/dist/lsp/index.d.ts +1 -1
- package/dist/lsp/index.js +1 -1
- package/dist/mcp/index.d.ts +1 -1
- package/dist/mcp/index.js +1 -1
- package/dist/mcp/json-schema-to-zod.js +2 -4
- package/dist/mcp/mcp-client.d.ts +14 -0
- package/dist/mcp/mcp-client.js +102 -18
- package/dist/memory/session-digest.js +2 -1
- package/dist/orchestrator/specialists-registry.js +14 -1
- package/dist/orchestrator/specialists.js +3 -3
- package/dist/pricing.js +12 -0
- package/dist/semantic/embedder.d.ts +11 -0
- package/dist/semantic/embedder.js +16 -0
- package/dist/semantic/semantic-index.d.ts +39 -1
- package/dist/semantic/semantic-index.js +43 -13
- package/dist/semantic/vector-store.d.ts +2 -0
- package/dist/semantic/vector-store.js +13 -1
- package/dist/sessions/index.d.ts +2 -2
- package/dist/sessions/index.js +1 -1
- package/dist/sessions/search.js +2 -2
- package/dist/sessions/session-export.d.ts +1 -1
- package/dist/sessions/session-export.js +41 -33
- package/dist/sessions/session-store.d.ts +20 -1
- package/dist/sessions/session-store.js +7 -0
- package/dist/sessions/watch.js +4 -4
- package/dist/skills/activator.d.ts +6 -0
- package/dist/skills/activator.js +17 -0
- package/dist/skills/skill-tool.d.ts +12 -0
- package/dist/skills/skill-tool.js +28 -0
- package/dist/tools/background-process.d.ts +63 -0
- package/dist/tools/background-process.js +150 -0
- package/dist/tools/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 +39 -16
- package/dist/tools/edit-match.d.ts +16 -0
- package/dist/tools/edit-match.js +115 -0
- package/dist/tools/fetch-url.d.ts +15 -1
- package/dist/tools/fetch-url.js +7 -47
- package/dist/tools/get-diagnostics.d.ts +14 -0
- package/dist/tools/get-diagnostics.js +44 -0
- package/dist/tools/glob-filter.d.ts +7 -0
- package/dist/tools/glob-filter.js +12 -0
- package/dist/tools/html-to-markdown.d.ts +13 -0
- package/dist/tools/html-to-markdown.js +507 -0
- package/dist/tools/index.d.ts +5 -1
- package/dist/tools/index.js +11 -1
- package/dist/tools/list-files.d.ts +1 -0
- package/dist/tools/list-files.js +17 -5
- package/dist/tools/list-symbols.js +2 -2
- package/dist/tools/network-policy.d.ts +5 -0
- package/dist/tools/network-policy.js +77 -0
- package/dist/tools/office-docs.d.ts +6 -0
- package/dist/tools/office-docs.js +393 -0
- package/dist/tools/permission-rules.js +55 -5
- package/dist/tools/read-file.d.ts +2 -0
- package/dist/tools/read-file.js +47 -10
- package/dist/tools/read-image.js +2 -2
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +4 -0
- package/dist/tools/repo-clone.d.ts +40 -0
- package/dist/tools/repo-clone.js +218 -0
- package/dist/tools/result-cap.d.ts +8 -0
- package/dist/tools/result-cap.js +44 -0
- package/dist/tools/run-command.d.ts +18 -1
- package/dist/tools/run-command.js +44 -9
- package/dist/tools/search-files.js +12 -53
- package/dist/tools/search-worker.d.ts +23 -0
- package/dist/tools/search-worker.js +86 -0
- package/dist/tools/shared.d.ts +14 -1
- package/dist/tools/shared.js +76 -4
- package/dist/tools/shell.d.ts +27 -0
- package/dist/tools/shell.js +80 -0
- package/dist/tools/sub-agent.d.ts +1 -1
- package/dist/tools/sub-agent.js +4 -1
- package/dist/tools/suggest-plan.d.ts +18 -0
- package/dist/tools/suggest-plan.js +28 -0
- package/dist/types.d.ts +132 -1
- package/dist/wakeup/wakeup-tools.d.ts +22 -0
- package/dist/wakeup/wakeup-tools.js +34 -0
- package/dist/wakeup/wakeup.d.ts +75 -0
- package/dist/wakeup/wakeup.js +151 -0
- package/dist/worktree/health.d.ts +51 -0
- package/dist/worktree/health.js +186 -0
- package/dist/worktree/index.d.ts +4 -0
- package/dist/worktree/index.js +2 -0
- package/dist/worktree/setup.d.ts +51 -0
- package/dist/worktree/setup.js +173 -0
- package/dist/worktree/worktree.d.ts +7 -0
- package/dist/worktree/worktree.js +9 -4
- package/package.json +1 -1
|
@@ -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';
|
|
@@ -22,6 +24,12 @@ import { createLanguageModel } from '../providers.js';
|
|
|
22
24
|
* `DEFAULT_WINDOW_TOKENS`.
|
|
23
25
|
*/
|
|
24
26
|
export const DEFAULT_WINDOW_TOKENS = 32_000;
|
|
27
|
+
/**
|
|
28
|
+
* For a hosted model nothing lists: most current ones hold 128k or more, so 32k would condense a long chat at once
|
|
29
|
+
* and the model would forget without an error (F16c). Local servers keep 32k, where the real window is usually small.
|
|
30
|
+
*/
|
|
31
|
+
export const HOSTED_FALLBACK_WINDOW_TOKENS = 128_000;
|
|
32
|
+
const LOCAL_WINDOW_PROVIDERS = new Set(['ollama', 'lmstudio']);
|
|
25
33
|
const WINDOW_SIZES = {
|
|
26
34
|
// Anthropic
|
|
27
35
|
'claude-3-5-sonnet': 200_000,
|
|
@@ -84,10 +92,22 @@ const WINDOW_SIZES = {
|
|
|
84
92
|
'minimax-m3': 1_000_000,
|
|
85
93
|
'minimax-m2': 204_800,
|
|
86
94
|
};
|
|
95
|
+
/** True when no override, catalog entry or table knows this model's window: the figure used is only a guess. */
|
|
96
|
+
export function windowIsGuess(model) {
|
|
97
|
+
if (overrideContextTokens(model))
|
|
98
|
+
return false;
|
|
99
|
+
if (typeof model !== 'string' && catalogEntry(model)?.context)
|
|
100
|
+
return false;
|
|
101
|
+
const id = (typeof model === 'string' ? model : model.model).toLowerCase().replace(/^models\//, '');
|
|
102
|
+
return !Object.keys(WINDOW_SIZES).some((key) => id === key || id.includes(key));
|
|
103
|
+
}
|
|
87
104
|
export function getModelWindowTokens(model) {
|
|
88
105
|
const overridden = overrideContextTokens(model);
|
|
89
106
|
if (overridden)
|
|
90
107
|
return overridden;
|
|
108
|
+
const listed = typeof model === 'string' ? undefined : catalogEntry(model)?.context;
|
|
109
|
+
if (listed)
|
|
110
|
+
return listed;
|
|
91
111
|
const id = typeof model === 'string' ? model : model.model;
|
|
92
112
|
const stripped = id.toLowerCase().replace(/^models\//, '');
|
|
93
113
|
// Direct hit first.
|
|
@@ -99,7 +119,7 @@ export function getModelWindowTokens(model) {
|
|
|
99
119
|
if (stripped.includes(key))
|
|
100
120
|
return WINDOW_SIZES[key];
|
|
101
121
|
}
|
|
102
|
-
return DEFAULT_WINDOW_TOKENS;
|
|
122
|
+
return typeof model !== 'string' && !LOCAL_WINDOW_PROVIDERS.has(model.provider) ? HOSTED_FALLBACK_WINDOW_TOKENS : DEFAULT_WINDOW_TOKENS;
|
|
103
123
|
}
|
|
104
124
|
/** JSON.stringify replacer that keeps image and file bytes out of a summary prompt. */
|
|
105
125
|
function withoutMedia(_key, value) {
|
|
@@ -121,18 +141,48 @@ export function estimateMessageTokens(messages) {
|
|
|
121
141
|
if (typeof m.content === 'string')
|
|
122
142
|
total += estimateTokens(m.content);
|
|
123
143
|
else if (Array.isArray(m.content)) {
|
|
124
|
-
for (const part of m.content)
|
|
125
|
-
|
|
126
|
-
total += estimateTokens(String(part.text ?? ''));
|
|
127
|
-
}
|
|
128
|
-
else {
|
|
129
|
-
total += mediaPartTokens(part);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
144
|
+
for (const part of m.content)
|
|
145
|
+
total += partTokens(part);
|
|
132
146
|
}
|
|
133
147
|
}
|
|
134
148
|
return total;
|
|
135
149
|
}
|
|
150
|
+
/** One content part. Tool calls and tool results are most of an agent's context, so they count (RB-56). */
|
|
151
|
+
function partTokens(part) {
|
|
152
|
+
if (!part || typeof part !== 'object')
|
|
153
|
+
return 0;
|
|
154
|
+
const p = part;
|
|
155
|
+
if (typeof p.text === 'string')
|
|
156
|
+
return estimateTokens(p.text);
|
|
157
|
+
if (p.type === 'tool-call')
|
|
158
|
+
return estimateTokens(jsonText(p.input));
|
|
159
|
+
if (p.type === 'tool-result')
|
|
160
|
+
return estimateTokens(jsonText(p.output?.value));
|
|
161
|
+
return mediaPartTokens(part);
|
|
162
|
+
}
|
|
163
|
+
function jsonText(value) {
|
|
164
|
+
if (typeof value === 'string')
|
|
165
|
+
return value;
|
|
166
|
+
try {
|
|
167
|
+
return JSON.stringify(value, withoutMedia) ?? '';
|
|
168
|
+
}
|
|
169
|
+
catch {
|
|
170
|
+
return '';
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/** Tool output in the summarizer's transcript is cut to this, so a few file reads cannot crowd out the decisions. */
|
|
174
|
+
export const SUMMARY_TOOL_OUTPUT_CHARS = 2_000;
|
|
175
|
+
function clipForSummary(text) {
|
|
176
|
+
if (text.length <= SUMMARY_TOOL_OUTPUT_CHARS)
|
|
177
|
+
return text;
|
|
178
|
+
return `${text.slice(0, SUMMARY_TOOL_OUTPUT_CHARS)}\n… [${text.length - SUMMARY_TOOL_OUTPUT_CHARS} more characters of tool output]`;
|
|
179
|
+
}
|
|
180
|
+
/** The first words of a compaction summary message; how the chat and exports tell it from what the person wrote. */
|
|
181
|
+
export const COMPACTION_SUMMARY_PREFIX = '[Summary of earlier conversation';
|
|
182
|
+
/** Whether a message is the summary that compaction put in place of older turns. */
|
|
183
|
+
export function isCompactionSummary(message) {
|
|
184
|
+
return !!message && message.role === 'user' && typeof message.content === 'string' && message.content.startsWith(COMPACTION_SUMMARY_PREFIX);
|
|
185
|
+
}
|
|
136
186
|
/**
|
|
137
187
|
* Default summarizer: calls the LLM with a structured prompt asking for
|
|
138
188
|
* a Goal / Key decisions / Files touched / Open questions breakdown.
|
|
@@ -156,7 +206,10 @@ export function buildLlmSummarizer(model, modelFactory) {
|
|
|
156
206
|
return p;
|
|
157
207
|
if (p && typeof p === 'object' && 'text' in p)
|
|
158
208
|
return String(p.text);
|
|
159
|
-
|
|
209
|
+
const part = p;
|
|
210
|
+
if (part.type === 'tool-result')
|
|
211
|
+
return `[${String(part.toolName)} result]\n${clipForSummary(jsonText(part.output?.value))}`;
|
|
212
|
+
return mediaPartLabel(p) ?? clipForSummary(JSON.stringify(p, withoutMedia));
|
|
160
213
|
})
|
|
161
214
|
.join('\n');
|
|
162
215
|
}
|
|
@@ -201,11 +254,12 @@ Here is the conversation to summarize:
|
|
|
201
254
|
|
|
202
255
|
${truncateToBudget(transcript, 60_000)}`;
|
|
203
256
|
const llm = factory(model);
|
|
204
|
-
const result = await generateText({
|
|
257
|
+
const result = await withModelRetry(() => generateText({
|
|
205
258
|
model: llm,
|
|
206
259
|
prompt,
|
|
207
260
|
maxOutputTokens: 2000,
|
|
208
|
-
|
|
261
|
+
maxRetries: 0,
|
|
262
|
+
}));
|
|
209
263
|
// Reasoning models put an inline <think> block in the reply; it must not be stored as the summary.
|
|
210
264
|
return stripThinkTags(result.text).trim();
|
|
211
265
|
};
|
|
@@ -239,30 +293,41 @@ export function shouldCompactForBudget(nextPromptTokens, model, thresholdFractio
|
|
|
239
293
|
return nextPromptTokens >= effectiveLimitTokens(model, thresholdFraction, budgetTokens);
|
|
240
294
|
}
|
|
241
295
|
/**
|
|
242
|
-
* Compact the message history
|
|
243
|
-
* inserts a single `[Compacted summary of N earlier turns]` system message
|
|
244
|
-
* followed by the structured summary.
|
|
296
|
+
* Compact the message history. Replaces older messages with one summary message.
|
|
245
297
|
*
|
|
246
298
|
* Keeps:
|
|
247
299
|
* - the original system prompt (first message, if it's a system one)
|
|
248
|
-
* - the
|
|
300
|
+
* - the user's first request, word for word
|
|
301
|
+
* - the most recent messages: at least `keepRecent` of them and at least `keepRecentTokens` worth, cut where a
|
|
302
|
+
* step starts, so a tool result never loses the call that made it (RB-56)
|
|
249
303
|
*
|
|
250
|
-
*
|
|
304
|
+
* The summary is a user message (`COMPACTION_SUMMARY_PREFIX`): Gemini and Bedrock refuse a system message after the
|
|
305
|
+
* conversation has started.
|
|
251
306
|
*/
|
|
252
307
|
export async function compactMessages(allMessages, summarizer, options = {}) {
|
|
253
308
|
const keepRecent = options.keepRecent ?? 4;
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
309
|
+
const noChange = { messages: allMessages, droppedCount: 0, savedTokens: 0, summary: '' };
|
|
310
|
+
if (allMessages.length <= keepRecent + 1)
|
|
311
|
+
return noChange;
|
|
257
312
|
const systemMessage = allMessages[0]?.role === 'system' ? allMessages[0] : undefined;
|
|
258
313
|
const startIndex = systemMessage ? 1 : 0;
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
314
|
+
let cut = allMessages.length - keepRecent;
|
|
315
|
+
// The token floor applies once the conversation is more than twice its size, so compaction always frees most of it.
|
|
316
|
+
const want = options.keepRecentTokens ?? 0;
|
|
317
|
+
const floor = want > 0 && estimateMessageTokens(allMessages.slice(startIndex)) > 2 * want ? want : 0;
|
|
318
|
+
while (cut > startIndex + 1 && floor > 0 && estimateMessageTokens(allMessages.slice(cut)) < floor)
|
|
319
|
+
cut--;
|
|
320
|
+
// A tool message answers the assistant message just before it: never start the kept part on one.
|
|
321
|
+
while (cut > startIndex && allMessages[cut]?.role === 'tool')
|
|
322
|
+
cut--;
|
|
323
|
+
const recent = allMessages.slice(cut);
|
|
324
|
+
const toSummarize = allMessages.slice(startIndex, cut);
|
|
325
|
+
if (toSummarize.length === 0)
|
|
326
|
+
return noChange;
|
|
264
327
|
// The user's original request is never summarized away: it stays verbatim right after the system prompt.
|
|
265
|
-
const pinned = toSummarize[0]?.role === 'user' ? toSummarize[0] : undefined;
|
|
328
|
+
const pinned = toSummarize[0]?.role === 'user' && !isCompactionSummary(toSummarize[0]) ? toSummarize[0] : undefined;
|
|
329
|
+
if (pinned && toSummarize.length === 1)
|
|
330
|
+
return noChange;
|
|
266
331
|
const beforeTokens = estimateMessageTokens(toSummarize) - (pinned ? estimateMessageTokens([pinned]) : 0);
|
|
267
332
|
const summary = await summarizer(toSummarize);
|
|
268
333
|
// An empty summary (a failed or reasoning-only reply) must not replace the dropped turns with nothing.
|
|
@@ -270,8 +335,9 @@ export async function compactMessages(allMessages, summarizer, options = {}) {
|
|
|
270
335
|
return { messages: allMessages, droppedCount: 0, savedTokens: 0, summary: '' };
|
|
271
336
|
const summaryTokens = estimateTokens(summary);
|
|
272
337
|
const separator = {
|
|
273
|
-
role: '
|
|
274
|
-
content:
|
|
338
|
+
role: 'user',
|
|
339
|
+
content: `${COMPACTION_SUMMARY_PREFIX} (${toSummarize.length} message${toSummarize.length === 1 ? '' : 's'}), written by Forge to save context. ` +
|
|
340
|
+
`It is not a new request.]\n\n` +
|
|
275
341
|
summary,
|
|
276
342
|
};
|
|
277
343
|
const next = [];
|
package/dist/context/mask.d.ts
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
* Mask old tool results in the prompt that is sent to the model.
|
|
3
3
|
*
|
|
4
4
|
* Most of an agent run's tokens are old tool output (whole files read once, command logs) that is resent on every
|
|
5
|
-
* step. This
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* step. This replaces such results with a one-line stub that says the tool can be called again. The agent stores the
|
|
6
|
+
* stubs in the prompt history (RB-60) so a new turn starts from them; what the person sees comes from the transcript,
|
|
7
|
+
* which keeps the full results (RB-58). Provider-independent: it only looks at the message shapes of the `ai` SDK.
|
|
8
8
|
* Spec: docs/superpowers/specs/2026-10-19-context-economy-design.md
|
|
9
9
|
*/
|
|
10
10
|
import type { ModelMessage } from 'ai';
|
|
@@ -28,7 +28,13 @@ export declare function maskOldToolResults(messages: ModelMessage[], opts: MaskO
|
|
|
28
28
|
export interface StepMaskerOptions extends MaskOptions {
|
|
29
29
|
/** Start masking when the next prompt is at least this many tokens. */
|
|
30
30
|
softBudgetTokens: number;
|
|
31
|
+
/** The newest tool output, up to this many tokens, is never masked (Kilo protects 40k). Default 40,000. */
|
|
32
|
+
protectTokens?: number;
|
|
31
33
|
}
|
|
34
|
+
/** Default for {@link StepMaskerOptions.protectTokens}. */
|
|
35
|
+
export declare const MASK_PROTECT_TOKENS = 40000;
|
|
36
|
+
/** Index from which the newest `protectTokens` of tool output starts: results at or after it are kept. */
|
|
37
|
+
export declare function protectedFrom(messages: readonly ModelMessage[], protectTokens: number): number;
|
|
32
38
|
export interface StepMasker {
|
|
33
39
|
apply(messages: ModelMessage[], nextPromptTokens: number): MaskResult & {
|
|
34
40
|
stepped: boolean;
|
|
@@ -36,10 +42,4 @@ export interface StepMasker {
|
|
|
36
42
|
/** Forget the frozen boundary, for example after compaction replaced the history. */
|
|
37
43
|
reset(): void;
|
|
38
44
|
}
|
|
39
|
-
/**
|
|
40
|
-
* Masks in steps, not on every call. Many providers reuse a cached prompt prefix only while it is byte-identical, so
|
|
41
|
-
* a boundary that slid forward on every turn would change the prefix on every call. Once the prompt reaches the soft
|
|
42
|
-
* budget the boundary is set at the start of the recent window and held; it moves again only after at least
|
|
43
|
-
* `keepRecentTurns` more turns have been added.
|
|
44
|
-
*/
|
|
45
45
|
export declare function createStepMasker(opts: StepMaskerOptions): StepMasker;
|
package/dist/context/mask.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { estimateMessageTokens } from './compaction.js';
|
|
1
2
|
/** Results that say what a change did are kept: they are small and the model must not forget them. */
|
|
2
3
|
export const NEVER_MASK_TOOLS = new Set(['write_file', 'edit_file', 'edit_notebook', 'apply_patch', 'run_command', 'sub_agent']);
|
|
3
4
|
const STUB_MARK = 'result not shown to save context';
|
|
@@ -53,18 +54,46 @@ export function maskOldToolResults(messages, opts, upTo) {
|
|
|
53
54
|
});
|
|
54
55
|
return { messages: next, maskedCount, savedChars: Math.max(0, savedChars) };
|
|
55
56
|
}
|
|
57
|
+
/** Default for {@link StepMaskerOptions.protectTokens}. */
|
|
58
|
+
export const MASK_PROTECT_TOKENS = 40_000;
|
|
59
|
+
/** Index from which the newest `protectTokens` of tool output starts: results at or after it are kept. */
|
|
60
|
+
export function protectedFrom(messages, protectTokens) {
|
|
61
|
+
let total = 0;
|
|
62
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
63
|
+
if (messages[i].role !== 'tool')
|
|
64
|
+
continue;
|
|
65
|
+
total += estimateMessageTokens([messages[i]]);
|
|
66
|
+
if (total > protectTokens)
|
|
67
|
+
return i + 1;
|
|
68
|
+
}
|
|
69
|
+
return 0;
|
|
70
|
+
}
|
|
56
71
|
/**
|
|
57
72
|
* Masks in steps, not on every call. Many providers reuse a cached prompt prefix only while it is byte-identical, so
|
|
58
73
|
* a boundary that slid forward on every turn would change the prefix on every call. Once the prompt reaches the soft
|
|
59
74
|
* budget the boundary is set at the start of the recent window and held; it moves again only after at least
|
|
60
75
|
* `keepRecentTurns` more turns have been added.
|
|
61
76
|
*/
|
|
77
|
+
/** Index just after the last message that holds a stub (0 when none). */
|
|
78
|
+
function afterLastStub(messages) {
|
|
79
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
80
|
+
const m = messages[i];
|
|
81
|
+
if (m.role !== 'tool' || !Array.isArray(m.content))
|
|
82
|
+
continue;
|
|
83
|
+
if (m.content.some((p) => p?.output && isAlreadyStub(p.output)))
|
|
84
|
+
return i + 1;
|
|
85
|
+
}
|
|
86
|
+
return 0;
|
|
87
|
+
}
|
|
62
88
|
export function createStepMasker(opts) {
|
|
63
89
|
let frozenUpTo = 0;
|
|
64
90
|
const minAdvance = opts.keepRecentTurns * 2;
|
|
65
91
|
return {
|
|
66
92
|
apply(messages, nextPromptTokens) {
|
|
67
|
-
|
|
93
|
+
// A history masked on an earlier turn: start from its stubs, so this turn does not move the boundary at once.
|
|
94
|
+
if (frozenUpTo === 0)
|
|
95
|
+
frozenUpTo = afterLastStub(messages);
|
|
96
|
+
const boundary = Math.min(maskBoundary(messages, opts.keepRecentTurns), protectedFrom(messages, opts.protectTokens ?? MASK_PROTECT_TOKENS));
|
|
68
97
|
const stepped = nextPromptTokens >= opts.softBudgetTokens && boundary > frozenUpTo && (frozenUpTo === 0 || boundary - frozenUpTo >= minAdvance);
|
|
69
98
|
if (stepped)
|
|
70
99
|
frozenUpTo = boundary;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ToolContext } from '../types.js';
|
|
2
|
+
export interface AutoFormatConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Formatter commands by file extension (`"ts"`, no dot) with `"*"` for any other file. The edited file's path
|
|
5
|
+
* is appended and the command must rewrite the file in place (`prettier --write`, `gofmt -w`).
|
|
6
|
+
*/
|
|
7
|
+
commands: Record<string, string>;
|
|
8
|
+
/** Per-command timeout. Default 60s. */
|
|
9
|
+
timeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
export interface FormatReport {
|
|
12
|
+
/** A command was configured for this file and was started. */
|
|
13
|
+
ran: boolean;
|
|
14
|
+
/** The file's content differs from before the command. */
|
|
15
|
+
changed: boolean;
|
|
16
|
+
/** The command's own words when it failed or timed out. */
|
|
17
|
+
failed?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Format one edited file. Never throws: a formatter that cannot run is reported in `failed`. */
|
|
20
|
+
export declare function formatFile(relPath: string, config: AutoFormatConfig, ctx: ToolContext): Promise<FormatReport>;
|
|
21
|
+
/** The note appended to the tool result, or '' when there is nothing to say. */
|
|
22
|
+
export declare function formatMessage(relPath: string, report: FormatReport): string;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auto-format: after the agent writes a file, run the formatter the person configured for that file type, so the
|
|
3
|
+
* project's style is kept without spending a step on it. The formatter rewrites the file, so the tool result tells
|
|
4
|
+
* the model (`formatMessage`): the text it just wrote is no longer what is on disk.
|
|
5
|
+
*/
|
|
6
|
+
import { readFile } from 'node:fs/promises';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { commandFor, shellQuote } from '../lint/auto-lint.js';
|
|
9
|
+
import { runCommandTool } from '../tools/run-command.js';
|
|
10
|
+
const DEFAULT_TIMEOUT_MS = 60_000;
|
|
11
|
+
const MAX_FAILURE_CHARS = 500;
|
|
12
|
+
/** Format one edited file. Never throws: a formatter that cannot run is reported in `failed`. */
|
|
13
|
+
export async function formatFile(relPath, config, ctx) {
|
|
14
|
+
const command = commandFor(relPath, config.commands);
|
|
15
|
+
if (!command)
|
|
16
|
+
return { ran: false, changed: false };
|
|
17
|
+
const abs = path.resolve(ctx.cwd, relPath);
|
|
18
|
+
const before = await readFile(abs).catch(() => null);
|
|
19
|
+
try {
|
|
20
|
+
const res = await runCommandTool.execute({ command: `${command} ${shellQuote(relPath)}`, timeout_ms: config.timeoutMs ?? DEFAULT_TIMEOUT_MS }, ctx);
|
|
21
|
+
if (res.exitCode !== 0 || res.timedOut) {
|
|
22
|
+
const out = [res.stdout, res.stderr].filter(Boolean).join('\n').trim();
|
|
23
|
+
return { ran: true, changed: false, failed: `${res.timedOut ? '(timed out) ' : ''}${out.slice(-MAX_FAILURE_CHARS) || `exit code ${res.exitCode}`}` };
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
return { ran: true, changed: false, failed: err.message.slice(0, MAX_FAILURE_CHARS) };
|
|
28
|
+
}
|
|
29
|
+
const after = await readFile(abs).catch(() => null);
|
|
30
|
+
return { ran: true, changed: before !== null && after !== null && !before.equals(after) };
|
|
31
|
+
}
|
|
32
|
+
/** The note appended to the tool result, or '' when there is nothing to say. */
|
|
33
|
+
export function formatMessage(relPath, report) {
|
|
34
|
+
if (report.failed)
|
|
35
|
+
return `[auto-format] The formatter failed on ${relPath}, so it was left as written:\n${report.failed}`;
|
|
36
|
+
if (report.changed)
|
|
37
|
+
return `[auto-format] The formatter reformatted ${relPath} after your edit. Read it again before editing it with edit_file, because the text on disk may differ from what you wrote.`;
|
|
38
|
+
return '';
|
|
39
|
+
}
|
|
@@ -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
|
}
|
|
@@ -17,6 +17,7 @@ export declare function githubFromEnv(env?: NodeJS.ProcessEnv): GitHubConfig | u
|
|
|
17
17
|
export declare function parseRemote(url: string): RepoRef | null;
|
|
18
18
|
export declare function repoFor(cwd: string): Promise<RepoRef>;
|
|
19
19
|
export declare function currentBranch(cwd: string): Promise<string>;
|
|
20
|
+
export declare function githubApi(cfg: GitHubConfig, repo: RepoRef, method: string, path: string, body?: unknown, signal?: AbortSignal): Promise<unknown>;
|
|
20
21
|
export interface PullRequestReport {
|
|
21
22
|
number: number;
|
|
22
23
|
title: string;
|
|
@@ -59,7 +59,7 @@ function clip(text, max) {
|
|
|
59
59
|
const s = typeof text === 'string' ? text : '';
|
|
60
60
|
return s.length > max ? `${s.slice(0, max)}… (${s.length - max} more characters)` : s;
|
|
61
61
|
}
|
|
62
|
-
async function
|
|
62
|
+
export async function githubApi(cfg, repo, method, path, body, signal) {
|
|
63
63
|
const res = await (cfg.fetch ?? fetch)(`${repo.apiBase}${path}`, {
|
|
64
64
|
method,
|
|
65
65
|
headers: {
|
|
@@ -95,20 +95,20 @@ export function createGitHubReadTool(cfg) {
|
|
|
95
95
|
let number = args.number;
|
|
96
96
|
if (number === undefined) {
|
|
97
97
|
const branch = await currentBranch(ctx.cwd);
|
|
98
|
-
const open = (await
|
|
98
|
+
const open = (await githubApi(cfg, repo, 'GET', `${base}/pulls?state=open&head=${encodeURIComponent(`${repo.owner}:${branch}`)}`, undefined, ctx.abortSignal));
|
|
99
99
|
if (!Array.isArray(open) || open.length === 0)
|
|
100
100
|
throw new Error(`no open pull request for branch ${branch}`);
|
|
101
101
|
number = open[0].number;
|
|
102
102
|
}
|
|
103
|
-
const pr = (await
|
|
103
|
+
const pr = (await githubApi(cfg, repo, 'GET', `${base}/pulls/${number}`, undefined, ctx.abortSignal));
|
|
104
104
|
const [reviews, comments, discussion] = (await Promise.all([
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
githubApi(cfg, repo, 'GET', `${base}/pulls/${number}/reviews?per_page=${LIST_MAX}`, undefined, ctx.abortSignal),
|
|
106
|
+
githubApi(cfg, repo, 'GET', `${base}/pulls/${number}/comments?per_page=${LIST_MAX}`, undefined, ctx.abortSignal),
|
|
107
|
+
githubApi(cfg, repo, 'GET', `${base}/issues/${number}/comments?per_page=${LIST_MAX}`, undefined, ctx.abortSignal),
|
|
108
108
|
]));
|
|
109
109
|
const sha = pr.head?.sha;
|
|
110
110
|
const checks = sha
|
|
111
|
-
? (await
|
|
111
|
+
? (await githubApi(cfg, repo, 'GET', `${base}/commits/${sha}/check-runs?per_page=${LIST_MAX}`, undefined, ctx.abortSignal).catch(() => ({ check_runs: [] })))
|
|
112
112
|
.check_runs
|
|
113
113
|
: [];
|
|
114
114
|
return {
|
|
@@ -153,11 +153,11 @@ export function createGitHubCreateTool(cfg) {
|
|
|
153
153
|
const repo = await repoFor(ctx.cwd);
|
|
154
154
|
const base = `/repos/${repo.owner}/${repo.repo}`;
|
|
155
155
|
const head = args.head ?? (await currentBranch(ctx.cwd));
|
|
156
|
-
const into = args.base ?? (await
|
|
156
|
+
const into = args.base ?? (await githubApi(cfg, repo, 'GET', base, undefined, ctx.abortSignal)).default_branch;
|
|
157
157
|
if (head === into)
|
|
158
158
|
throw new Error(`the head branch is the base branch (${head}); work on a separate branch`);
|
|
159
159
|
try {
|
|
160
|
-
const pr = (await
|
|
160
|
+
const pr = (await githubApi(cfg, repo, 'POST', `${base}/pulls`, { title: args.title, body: args.body, head, base: into, draft: args.draft ?? true }, ctx.abortSignal));
|
|
161
161
|
return { number: pr.number, url: String(pr.html_url), draft: pr.draft === true };
|
|
162
162
|
}
|
|
163
163
|
catch (err) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-request status and creation for a branch (F8), on the REST layer the `github_pr_*` tools use. The host supplies the
|
|
3
|
+
* token (an environment variable or VS Code's GitHub sign-in); it goes in the Authorization header and nowhere else, so
|
|
4
|
+
* no error text built here may contain it.
|
|
5
|
+
*/
|
|
6
|
+
import { type GitHubConfig, type RepoRef } from './github-tools.js';
|
|
7
|
+
export interface PullRequestSummary {
|
|
8
|
+
number: number;
|
|
9
|
+
url: string;
|
|
10
|
+
state: 'open' | 'merged' | 'closed';
|
|
11
|
+
draft: boolean;
|
|
12
|
+
/** All checks passed, one failed, some still run, or there are none. */
|
|
13
|
+
checks: 'passing' | 'failing' | 'pending' | 'none';
|
|
14
|
+
/** The latest review of each reviewer decides: any "changes requested" wins over an approval. */
|
|
15
|
+
review: 'approved' | 'changes requested' | 'none';
|
|
16
|
+
}
|
|
17
|
+
/** The newest pull request from `branch` (any state), summarized, or null when there is none. */
|
|
18
|
+
export declare function findPullRequest(cfg: GitHubConfig, repo: RepoRef, branch: string, signal?: AbortSignal): Promise<PullRequestSummary | null>;
|
|
19
|
+
/** The repository's default branch, the usual base for a pull request. */
|
|
20
|
+
export declare function defaultBranch(cfg: GitHubConfig, repo: RepoRef, signal?: AbortSignal): Promise<string>;
|
|
21
|
+
export interface NewPullRequest {
|
|
22
|
+
title: string;
|
|
23
|
+
body: string;
|
|
24
|
+
/** The branch with the changes; it must already be pushed. */
|
|
25
|
+
head: string;
|
|
26
|
+
base: string;
|
|
27
|
+
/** Default true: nobody is asked to review until the author says so. */
|
|
28
|
+
draft?: boolean;
|
|
29
|
+
}
|
|
30
|
+
export declare function openPullRequest(cfg: GitHubConfig, repo: RepoRef, input: NewPullRequest, signal?: AbortSignal): Promise<{
|
|
31
|
+
number: number;
|
|
32
|
+
url: string;
|
|
33
|
+
draft: boolean;
|
|
34
|
+
}>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pull-request status and creation for a branch (F8), on the REST layer the `github_pr_*` tools use. The host supplies the
|
|
3
|
+
* token (an environment variable or VS Code's GitHub sign-in); it goes in the Authorization header and nowhere else, so
|
|
4
|
+
* no error text built here may contain it.
|
|
5
|
+
*/
|
|
6
|
+
import { githubApi } from './github-tools.js';
|
|
7
|
+
const FAILED = new Set(['failure', 'timed_out', 'cancelled', 'action_required', 'startup_failure']);
|
|
8
|
+
function checksOf(runs) {
|
|
9
|
+
if (runs.length === 0)
|
|
10
|
+
return 'none';
|
|
11
|
+
if (runs.some((r) => r.status !== 'completed'))
|
|
12
|
+
return runs.some((r) => FAILED.has(String(r.conclusion))) ? 'failing' : 'pending';
|
|
13
|
+
return runs.some((r) => FAILED.has(String(r.conclusion))) ? 'failing' : 'passing';
|
|
14
|
+
}
|
|
15
|
+
function reviewOf(reviews) {
|
|
16
|
+
const latest = new Map();
|
|
17
|
+
for (const r of reviews) {
|
|
18
|
+
const state = String(r.state);
|
|
19
|
+
// A comment does not replace an earlier verdict.
|
|
20
|
+
if (state !== 'APPROVED' && state !== 'CHANGES_REQUESTED')
|
|
21
|
+
continue;
|
|
22
|
+
latest.set(String(r.user?.login ?? 'unknown'), state);
|
|
23
|
+
}
|
|
24
|
+
const states = [...latest.values()];
|
|
25
|
+
if (states.includes('CHANGES_REQUESTED'))
|
|
26
|
+
return 'changes requested';
|
|
27
|
+
return states.includes('APPROVED') ? 'approved' : 'none';
|
|
28
|
+
}
|
|
29
|
+
/** The newest pull request from `branch` (any state), summarized, or null when there is none. */
|
|
30
|
+
export async function findPullRequest(cfg, repo, branch, signal) {
|
|
31
|
+
const base = `/repos/${repo.owner}/${repo.repo}`;
|
|
32
|
+
const found = (await githubApi(cfg, repo, 'GET', `${base}/pulls?state=all&head=${encodeURIComponent(`${repo.owner}:${branch}`)}&per_page=1`, undefined, signal));
|
|
33
|
+
const pr = Array.isArray(found) ? found[0] : undefined;
|
|
34
|
+
if (!pr)
|
|
35
|
+
return null;
|
|
36
|
+
const number = pr.number;
|
|
37
|
+
const sha = pr.head?.sha;
|
|
38
|
+
const [runs, reviews] = await Promise.all([
|
|
39
|
+
sha ? githubApi(cfg, repo, 'GET', `${base}/commits/${sha}/check-runs?per_page=50`, undefined, signal).catch(() => ({ check_runs: [] })) : { check_runs: [] },
|
|
40
|
+
githubApi(cfg, repo, 'GET', `${base}/pulls/${number}/reviews?per_page=50`, undefined, signal).catch(() => []),
|
|
41
|
+
]);
|
|
42
|
+
return {
|
|
43
|
+
number,
|
|
44
|
+
url: String(pr.html_url ?? ''),
|
|
45
|
+
state: pr.merged_at ? 'merged' : pr.state === 'closed' ? 'closed' : 'open',
|
|
46
|
+
draft: pr.draft === true,
|
|
47
|
+
checks: checksOf(runs.check_runs ?? []),
|
|
48
|
+
review: reviewOf(Array.isArray(reviews) ? reviews : []),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** The repository's default branch, the usual base for a pull request. */
|
|
52
|
+
export async function defaultBranch(cfg, repo, signal) {
|
|
53
|
+
const data = (await githubApi(cfg, repo, 'GET', `/repos/${repo.owner}/${repo.repo}`, undefined, signal));
|
|
54
|
+
return String(data.default_branch ?? 'main');
|
|
55
|
+
}
|
|
56
|
+
export async function openPullRequest(cfg, repo, input, signal) {
|
|
57
|
+
if (input.head === input.base)
|
|
58
|
+
throw new Error(`the head branch is the base branch (${input.head}); work on a separate branch`);
|
|
59
|
+
try {
|
|
60
|
+
const pr = (await githubApi(cfg, repo, 'POST', `/repos/${repo.owner}/${repo.repo}/pulls`, { title: input.title, body: input.body, head: input.head, base: input.base, draft: input.draft ?? true }, signal));
|
|
61
|
+
return { number: pr.number, url: String(pr.html_url), draft: pr.draft === true };
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
const msg = err.message;
|
|
65
|
+
if (/HTTP 422/.test(msg) && /head|not.*found|No commits|invalid/i.test(msg)) {
|
|
66
|
+
throw new Error(`${msg}. Is ${input.head} pushed and ahead of ${input.base}? Push it with: git push -u origin ${input.head}`);
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** `max_iterations`, `perChat` and `on-network` become "Max iterations", "Per chat" and "On network". */
|
|
2
|
+
export declare function humanize(id: string): string;
|
|
3
|
+
/** The name of a tool as a person would say it. */
|
|
4
|
+
export declare function toolLabel(name: string): string;
|
|
5
|
+
/** Why a run ended, for a line a person reads. */
|
|
6
|
+
export declare function runReasonLabel(reason: string): string;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
const ACRONYMS = { mcp: 'MCP', api: 'API', url: 'URL', lsp: 'LSP', ai: 'AI', pr: 'PR', os: 'OS' };
|
|
2
|
+
/** `max_iterations`, `perChat` and `on-network` become "Max iterations", "Per chat" and "On network". */
|
|
3
|
+
export function humanize(id) {
|
|
4
|
+
const words = id
|
|
5
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
6
|
+
.split(/[\s_-]+/)
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.map((w) => w.toLowerCase());
|
|
9
|
+
if (words.length === 0)
|
|
10
|
+
return id;
|
|
11
|
+
const first = words[0];
|
|
12
|
+
words[0] = ACRONYMS[first] ?? first.charAt(0).toUpperCase() + first.slice(1);
|
|
13
|
+
for (let i = 1; i < words.length; i++)
|
|
14
|
+
words[i] = ACRONYMS[words[i]] ?? words[i];
|
|
15
|
+
return words.join(' ');
|
|
16
|
+
}
|
|
17
|
+
/** The name of a tool as a person would say it. */
|
|
18
|
+
export function toolLabel(name) {
|
|
19
|
+
return humanize(name);
|
|
20
|
+
}
|
|
21
|
+
// A Record over the reason type: a new way for a run to end is a compile error until it has words here.
|
|
22
|
+
const RUN_REASONS = {
|
|
23
|
+
completed: 'Completed',
|
|
24
|
+
max_iterations: 'Step limit reached',
|
|
25
|
+
max_duration: 'Time limit reached',
|
|
26
|
+
budget_exceeded: 'Cost limit reached',
|
|
27
|
+
loop_detected: 'Stopped: repeating the same step',
|
|
28
|
+
aborted: 'Stopped',
|
|
29
|
+
error: 'Failed',
|
|
30
|
+
no_provider_key: 'No API key',
|
|
31
|
+
plan: 'Plan ready',
|
|
32
|
+
spec: 'Spec ready',
|
|
33
|
+
};
|
|
34
|
+
/** Why a run ended, for a line a person reads. */
|
|
35
|
+
export function runReasonLabel(reason) {
|
|
36
|
+
return RUN_REASONS[reason] ?? humanize(reason);
|
|
37
|
+
}
|