@ikarmic/forge-core 5.1.1 → 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 +208 -57
- package/dist/agent/enhance-prompt.js +2 -1
- package/dist/agent/goal.d.ts +5 -8
- package/dist/agent/prompts.d.ts +15 -0
- package/dist/agent/prompts.js +34 -0
- package/dist/agent/race-abort.d.ts +8 -0
- package/dist/agent/race-abort.js +24 -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/agent/stack-facts.d.ts +23 -0
- package/dist/agent/stack-facts.js +150 -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/code-index/parser.js +48 -3
- 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/github/github-tools.d.ts +2 -18
- package/dist/index.d.ts +11 -3
- package/dist/index.js +9 -2
- package/dist/journey/answers-block.js +14 -1
- package/dist/journey/offer.js +2 -1
- package/dist/mcp/json-schema-to-zod.js +12 -1
- package/dist/memory/session-digest.js +2 -1
- package/dist/orchestrator/index.d.ts +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/orchestrator.d.ts +45 -6
- package/dist/orchestrator/orchestrator.js +62 -12
- package/dist/orchestrator/router.d.ts +1 -1
- package/dist/orchestrator/router.js +4 -1
- package/dist/orchestrator/specialists.d.ts +6 -1
- package/dist/orchestrator/specialists.js +165 -68
- package/dist/orchestrator/types.d.ts +1 -1
- package/dist/policy/policy.d.ts +6 -59
- package/dist/pricing.d.ts +2 -0
- package/dist/pricing.js +47 -16
- package/dist/structured/schema.js +9 -12
- package/dist/tools/apply-patch.d.ts +1 -5
- package/dist/tools/ask-user.d.ts +2 -10
- package/dist/tools/ask-user.js +12 -4
- package/dist/tools/browser-tools.d.ts +15 -31
- package/dist/tools/codebase-search.d.ts +1 -7
- package/dist/tools/command-sandbox.d.ts +24 -0
- package/dist/tools/command-sandbox.js +73 -0
- package/dist/tools/dependency-audit.d.ts +30 -0
- package/dist/tools/dependency-audit.js +197 -0
- package/dist/tools/edit-file.d.ts +9 -15
- package/dist/tools/edit-file.js +64 -16
- package/dist/tools/fetch-url.d.ts +1 -18
- package/dist/tools/find-references.d.ts +1 -5
- package/dist/tools/find-symbol.d.ts +10 -8
- package/dist/tools/git-diff.d.ts +13 -0
- package/dist/tools/git-diff.js +85 -0
- package/dist/tools/index.d.ts +3 -1
- package/dist/tools/index.js +5 -1
- package/dist/tools/list-files.d.ts +1 -19
- package/dist/tools/list-symbols.d.ts +1 -5
- package/dist/tools/memory.d.ts +23 -43
- package/dist/tools/notebook.d.ts +11 -17
- package/dist/tools/read-file.d.ts +1 -11
- package/dist/tools/read-image.d.ts +1 -12
- package/dist/tools/read-only.d.ts +1 -1
- package/dist/tools/read-only.js +2 -0
- package/dist/tools/run-command.d.ts +1 -17
- package/dist/tools/run-command.js +12 -2
- package/dist/tools/search-files.d.ts +1 -17
- 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-wait.d.ts +2 -8
- package/dist/tools/sub-agent.d.ts +2 -70
- package/dist/tools/sub-agent.js +1 -0
- package/dist/tools/todo.d.ts +8 -20
- package/dist/tools/web-search.d.ts +1 -7
- package/dist/tools/write-file.d.ts +1 -7
- package/dist/types.d.ts +60 -1
- package/package.json +3 -3
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* When a failed model call is worth repeating, and how long to wait first. Patterns follow Kilo's
|
|
3
|
+
* session/retry.ts: status codes, the provider's own retry-after headers, and the wordings providers and
|
|
4
|
+
* Node use for overloads and dropped connections.
|
|
5
|
+
*/
|
|
6
|
+
export const DEFAULT_PROVIDER_RETRIES = 5;
|
|
7
|
+
export const DEFAULT_RETRY_BASE_MS = 2000;
|
|
8
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
9
|
+
/** A provider asking for a longer wait than this means a quota, not a hiccup: report it instead. */
|
|
10
|
+
const MAX_HEADER_WAIT_MS = 5 * 60_000;
|
|
11
|
+
const RETRYABLE_PATTERNS = [
|
|
12
|
+
/(?:^|\b(?:status|http|code|error)\b[^\d\n]{0,3})(?:408|409|425|429|500|502|503|504|520|522|524|529)\b/i,
|
|
13
|
+
/rate[ _-]?limit|too many requests|rate increased too quickly/i,
|
|
14
|
+
/overloaded|service[ _-]unavailable|internal[ _-](?:server[ _-])?error|server[ _-]error|bad gateway|gateway time-?out|provider returned error/i,
|
|
15
|
+
/fetch failed|failed to fetch|network[ _-]?error|socket hang up|socket connection was closed|connection (?:error|refused|reset|lost)|terminated|other side closed|upstream connect/i,
|
|
16
|
+
/econnreset|econnrefused|etimedout|enotfound|eai_again|epipe|und_err/i,
|
|
17
|
+
/\b(?:request|response|connection|network|stream|read) (?:timeout|timed out)\b/i,
|
|
18
|
+
/try (?:your request )?again|resource[ _-]exhausted|temporarily (?:unavailable|at capacity)/i,
|
|
19
|
+
];
|
|
20
|
+
const OVERFLOW_PATTERNS = [
|
|
21
|
+
/context[ _-]length[ _-]exceeded/i,
|
|
22
|
+
/maximum context length/i,
|
|
23
|
+
/prompt is too long/i,
|
|
24
|
+
/context window/i,
|
|
25
|
+
/too many (?:input )?tokens/i,
|
|
26
|
+
/input (?:is )?too long/i,
|
|
27
|
+
/exceeds? (?:the )?(?:model'?s? )?(?:maximum|max) (?:context|input|token)/i,
|
|
28
|
+
];
|
|
29
|
+
/** Status, headers and every message in the cause chain (an AI SDK RetryError's lastError included). */
|
|
30
|
+
function factsOf(err) {
|
|
31
|
+
const facts = { status: undefined, sdkRetryable: undefined, headers: {}, text: '', aborted: false };
|
|
32
|
+
const texts = [];
|
|
33
|
+
let cur = err;
|
|
34
|
+
for (let depth = 0; cur && depth < 6; depth++) {
|
|
35
|
+
if (typeof cur !== 'object') {
|
|
36
|
+
texts.push(String(cur));
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
const e = cur;
|
|
40
|
+
if (e.name === 'AbortError')
|
|
41
|
+
facts.aborted = true;
|
|
42
|
+
if (facts.status === undefined && typeof e.statusCode === 'number')
|
|
43
|
+
facts.status = e.statusCode;
|
|
44
|
+
if (facts.sdkRetryable === undefined && typeof e.isRetryable === 'boolean')
|
|
45
|
+
facts.sdkRetryable = e.isRetryable;
|
|
46
|
+
if (e.responseHeaders && typeof e.responseHeaders === 'object' && Object.keys(facts.headers).length === 0) {
|
|
47
|
+
for (const [k, v] of Object.entries(e.responseHeaders))
|
|
48
|
+
if (typeof v === 'string')
|
|
49
|
+
facts.headers[k.toLowerCase()] = v;
|
|
50
|
+
}
|
|
51
|
+
for (const key of ['message', 'code', 'responseBody'])
|
|
52
|
+
if (typeof e[key] === 'string')
|
|
53
|
+
texts.push(e[key]);
|
|
54
|
+
cur = e.lastError ?? e.cause;
|
|
55
|
+
}
|
|
56
|
+
facts.text = texts.join('\n');
|
|
57
|
+
return facts;
|
|
58
|
+
}
|
|
59
|
+
/** The first line of the error a person should see. */
|
|
60
|
+
export function errorSummary(err) {
|
|
61
|
+
return factsOf(err).text.split('\n')[0] || 'Unknown error';
|
|
62
|
+
}
|
|
63
|
+
export function isContextOverflowError(err) {
|
|
64
|
+
const f = factsOf(err);
|
|
65
|
+
if (f.status === 413)
|
|
66
|
+
return true;
|
|
67
|
+
return OVERFLOW_PATTERNS.some((p) => p.test(f.text));
|
|
68
|
+
}
|
|
69
|
+
function headerWaitMs(headers) {
|
|
70
|
+
const ms = Number.parseFloat(headers['retry-after-ms'] ?? '');
|
|
71
|
+
if (Number.isFinite(ms) && ms >= 0)
|
|
72
|
+
return Math.ceil(ms);
|
|
73
|
+
const raw = headers['retry-after'];
|
|
74
|
+
if (!raw)
|
|
75
|
+
return undefined;
|
|
76
|
+
const seconds = Number.parseFloat(raw);
|
|
77
|
+
if (Number.isFinite(seconds) && seconds >= 0)
|
|
78
|
+
return Math.ceil(seconds * 1000);
|
|
79
|
+
const at = Date.parse(raw);
|
|
80
|
+
return Number.isNaN(at) ? undefined : Math.max(0, at - Date.now());
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Whether to retry after failed attempt number `attempt` (1-based) and how long to wait.
|
|
84
|
+
* `random` in [0, 1) adds up to 25% jitter to the back-off.
|
|
85
|
+
*/
|
|
86
|
+
export function retryDecision(err, attempt, baseDelayMs = DEFAULT_RETRY_BASE_MS, random = Math.random()) {
|
|
87
|
+
const f = factsOf(err);
|
|
88
|
+
if (f.aborted || isContextOverflowError(err))
|
|
89
|
+
return { retry: false };
|
|
90
|
+
const serverSide = f.status !== undefined && (f.status >= 500 || f.status === 408 || f.status === 409 || f.status === 425 || f.status === 429);
|
|
91
|
+
const clientSide = f.status !== undefined && f.status >= 400 && !serverSide;
|
|
92
|
+
if (clientSide)
|
|
93
|
+
return { retry: false };
|
|
94
|
+
if (!serverSide && f.sdkRetryable !== true && !RETRYABLE_PATTERNS.some((p) => p.test(f.text)))
|
|
95
|
+
return { retry: false };
|
|
96
|
+
const message = errorSummary(err);
|
|
97
|
+
const asked = headerWaitMs(f.headers);
|
|
98
|
+
if (asked !== undefined)
|
|
99
|
+
return asked > MAX_HEADER_WAIT_MS ? { retry: false } : { retry: true, delayMs: asked, message };
|
|
100
|
+
const base = baseDelayMs * 2 ** (attempt - 1);
|
|
101
|
+
return { retry: true, delayMs: Math.min(MAX_BACKOFF_MS, Math.ceil(base + base * 0.25 * random)), message };
|
|
102
|
+
}
|
|
103
|
+
/** Error codes that mean this machine cannot reach the network at all (Kilo session/network.ts, minus resets). */
|
|
104
|
+
const OFFLINE_CODES = new Set(['ENOTFOUND', 'EAI_AGAIN', 'ENETUNREACH', 'EHOSTUNREACH', 'ENETDOWN']);
|
|
105
|
+
/** True when the failure looks like no network (DNS or route gone), not a busy or resetting server. */
|
|
106
|
+
export function isDisconnected(err) {
|
|
107
|
+
let cur = err;
|
|
108
|
+
for (let depth = 0; cur && typeof cur === 'object' && depth < 6; depth++) {
|
|
109
|
+
const e = cur;
|
|
110
|
+
if (typeof e.code === 'string' && OFFLINE_CODES.has(e.code))
|
|
111
|
+
return true;
|
|
112
|
+
if (typeof e.message === 'string' && /\b(?:ENOTFOUND|EAI_AGAIN|ENETUNREACH|EHOSTUNREACH|ENETDOWN)\b|network is unreachable/i.test(e.message))
|
|
113
|
+
return true;
|
|
114
|
+
cur = e.lastError ?? e.cause;
|
|
115
|
+
}
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
/** Opens a TCP connection to a few well-known hosts by address (no DNS needed); true when any of them answers. */
|
|
119
|
+
export async function defaultNetworkProbe() {
|
|
120
|
+
const { connect } = await import('node:net');
|
|
121
|
+
const dial = (host) => new Promise((resolve) => {
|
|
122
|
+
const socket = connect({ host, port: 443 });
|
|
123
|
+
const timer = setTimeout(() => {
|
|
124
|
+
socket.destroy();
|
|
125
|
+
resolve(false);
|
|
126
|
+
}, 5000);
|
|
127
|
+
socket.once('connect', () => {
|
|
128
|
+
clearTimeout(timer);
|
|
129
|
+
socket.destroy();
|
|
130
|
+
resolve(true);
|
|
131
|
+
});
|
|
132
|
+
socket.once('error', () => {
|
|
133
|
+
clearTimeout(timer);
|
|
134
|
+
resolve(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
const results = await Promise.all(['1.1.1.1', '8.8.8.8', '9.9.9.9'].map(dial));
|
|
138
|
+
return results.some(Boolean);
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Poll `probe` every `pollMs` until it reports a network, the signal fires, or `budgetMs` runs out.
|
|
142
|
+
* Returns how long it waited and why it stopped.
|
|
143
|
+
*/
|
|
144
|
+
export async function waitForNetwork(probe, opts) {
|
|
145
|
+
const started = Date.now();
|
|
146
|
+
for (;;) {
|
|
147
|
+
if ((await waitOrAbort(opts.pollMs, opts.abortSignal)) === 'aborted')
|
|
148
|
+
return { outcome: 'aborted', waitedMs: Date.now() - started };
|
|
149
|
+
if (await probe().catch(() => false))
|
|
150
|
+
return { outcome: 'online', waitedMs: Date.now() - started };
|
|
151
|
+
if (Date.now() - started >= opts.budgetMs)
|
|
152
|
+
return { outcome: 'gave_up', waitedMs: Date.now() - started };
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Run a one-shot model call (generateText with `maxRetries: 0`) under the agent loop's retry policy.
|
|
157
|
+
* Throws the failure itself when it is not worth retrying, when the retries are used up, or on abort.
|
|
158
|
+
*/
|
|
159
|
+
export async function withModelRetry(call, opts = {}) {
|
|
160
|
+
const maxRetries = opts.maxRetries ?? DEFAULT_PROVIDER_RETRIES;
|
|
161
|
+
for (let attempt = 1;; attempt++) {
|
|
162
|
+
try {
|
|
163
|
+
return await call();
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
if (opts.abortSignal?.aborted || attempt > maxRetries)
|
|
167
|
+
throw err;
|
|
168
|
+
const decision = retryDecision(err, attempt, opts.baseDelayMs ?? DEFAULT_RETRY_BASE_MS);
|
|
169
|
+
if (!decision.retry)
|
|
170
|
+
throw err;
|
|
171
|
+
opts.onRetry?.(attempt, decision.delayMs, decision.message);
|
|
172
|
+
if ((await waitOrAbort(decision.delayMs, opts.abortSignal)) === 'aborted')
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
/** Resolves after `ms`, or early (to 'aborted') when the signal fires; leaves no listener behind (RB-36). */
|
|
178
|
+
export function waitOrAbort(ms, signal) {
|
|
179
|
+
if (signal?.aborted)
|
|
180
|
+
return Promise.resolve('aborted');
|
|
181
|
+
return new Promise((resolve) => {
|
|
182
|
+
const onAbort = () => {
|
|
183
|
+
clearTimeout(timer);
|
|
184
|
+
resolve('aborted');
|
|
185
|
+
};
|
|
186
|
+
const timer = setTimeout(() => {
|
|
187
|
+
signal?.removeEventListener('abort', onAbort);
|
|
188
|
+
resolve('waited');
|
|
189
|
+
}, ms);
|
|
190
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
191
|
+
});
|
|
192
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { LanguageModel } from 'ai';
|
|
2
|
+
import type { ModelConfig } from '../types.js';
|
|
3
|
+
export type ShellKind = 'powershell' | 'cmd' | 'bash' | 'zsh' | 'fish' | 'sh';
|
|
4
|
+
/** Which shell an executable path is, by its file name; an unknown one is PowerShell on Windows and bash elsewhere. */
|
|
5
|
+
export declare function shellKindOf(shellPath: string | undefined, platform: NodeJS.Platform): ShellKind;
|
|
6
|
+
/**
|
|
7
|
+
* Reduce a model reply to the bare command: no think block (RB-1), no fence, quotes, prompt symbol or explanation.
|
|
8
|
+
* Returns '' when nothing is left.
|
|
9
|
+
*/
|
|
10
|
+
export declare function cleanShellCommand(text: string): string;
|
|
11
|
+
/** Turn a plain request into one command for `shell`. Never throws: any failure or an unusable reply is `{ error: 'empty' }`. */
|
|
12
|
+
export declare function generateShellCommand(request: string, opts: {
|
|
13
|
+
model: ModelConfig;
|
|
14
|
+
shell: ShellKind;
|
|
15
|
+
platform: NodeJS.Platform;
|
|
16
|
+
modelFactory?: (cfg: ModelConfig) => LanguageModel;
|
|
17
|
+
}): Promise<{
|
|
18
|
+
command: string;
|
|
19
|
+
} | {
|
|
20
|
+
error: 'empty';
|
|
21
|
+
}>;
|
|
22
|
+
/** Why a command deserves a second look, or undefined. The first matching reason wins, in the order of RISKS. */
|
|
23
|
+
export declare function commandRisk(command: string): string | undefined;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { generateText } from 'ai';
|
|
2
|
+
import { createLanguageModel } from '../providers.js';
|
|
3
|
+
import { withModelRetry } from './retry.js';
|
|
4
|
+
import { stripThinkTags } from './think-tags.js';
|
|
5
|
+
/** Which shell an executable path is, by its file name; an unknown one is PowerShell on Windows and bash elsewhere. */
|
|
6
|
+
export function shellKindOf(shellPath, platform) {
|
|
7
|
+
const base = (shellPath ?? '').split(/[\\/]/).pop()?.toLowerCase().replace(/\.(exe|cmd|bat)$/, '') ?? '';
|
|
8
|
+
if (base === 'pwsh' || base === 'powershell')
|
|
9
|
+
return 'powershell';
|
|
10
|
+
if (base === 'cmd')
|
|
11
|
+
return 'cmd';
|
|
12
|
+
if (base === 'bash' || base === 'zsh' || base === 'fish' || base === 'sh')
|
|
13
|
+
return base;
|
|
14
|
+
return platform === 'win32' ? 'powershell' : 'bash';
|
|
15
|
+
}
|
|
16
|
+
const SHELL_NAMES = {
|
|
17
|
+
powershell: 'PowerShell',
|
|
18
|
+
cmd: 'cmd.exe (Windows Command Prompt)',
|
|
19
|
+
bash: 'bash',
|
|
20
|
+
zsh: 'zsh',
|
|
21
|
+
fish: 'fish',
|
|
22
|
+
sh: 'POSIX sh',
|
|
23
|
+
};
|
|
24
|
+
function osName(platform) {
|
|
25
|
+
if (platform === 'win32')
|
|
26
|
+
return 'Windows';
|
|
27
|
+
if (platform === 'darwin')
|
|
28
|
+
return 'macOS';
|
|
29
|
+
return 'Linux';
|
|
30
|
+
}
|
|
31
|
+
function shellCommandPrompt(request, shell, platform) {
|
|
32
|
+
return (`Write exactly one command for ${SHELL_NAMES[shell]} on ${osName(platform)} that does what the request below asks. ` +
|
|
33
|
+
'Use only tools that ship with that shell and system. Reply with the command only: no explanation, no quotes, no code fences, no prompt symbol.\n\n' +
|
|
34
|
+
`Request:\n${request}`);
|
|
35
|
+
}
|
|
36
|
+
const SHELL_OPERATORS = /[|&;<>$()=\\/]|\s-{1,2}[A-Za-z]/;
|
|
37
|
+
/** A line of explanation rather than a command: it ends in a colon, or reads as a sentence with no shell syntax in it. */
|
|
38
|
+
function isProse(line) {
|
|
39
|
+
const t = line.trim();
|
|
40
|
+
if (t.endsWith(':'))
|
|
41
|
+
return true;
|
|
42
|
+
const words = t.split(/\s+/);
|
|
43
|
+
return /^[A-Z][a-z']*[,!.]?$/.test(words[0] ?? '') && words.length > 6 && !SHELL_OPERATORS.test(t);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Reduce a model reply to the bare command: no think block (RB-1), no fence, quotes, prompt symbol or explanation.
|
|
47
|
+
* Returns '' when nothing is left.
|
|
48
|
+
*/
|
|
49
|
+
export function cleanShellCommand(text) {
|
|
50
|
+
let body = stripThinkTags(text).trim();
|
|
51
|
+
const fence = /```[^\n]*\n?([\s\S]*?)(?:```|$)/.exec(body);
|
|
52
|
+
if (fence)
|
|
53
|
+
body = fence[1].trim();
|
|
54
|
+
const lines = body.split(/\r?\n/).filter((l) => l.trim() && !isProse(l));
|
|
55
|
+
if (lines.length === 0)
|
|
56
|
+
return '';
|
|
57
|
+
lines[0] = lines[0].replace(/^\s*(?:PS [^>\n]*>|PS>|\$|>)\s+/, '');
|
|
58
|
+
let command = lines.join('\n').trim();
|
|
59
|
+
for (const q of ['"', "'", '`']) {
|
|
60
|
+
if (command.length > 1 && command.startsWith(q) && command.endsWith(q) && !command.slice(1, -1).includes(q)) {
|
|
61
|
+
command = command.slice(1, -1).trim();
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return command;
|
|
66
|
+
}
|
|
67
|
+
/** Turn a plain request into one command for `shell`. Never throws: any failure or an unusable reply is `{ error: 'empty' }`. */
|
|
68
|
+
export async function generateShellCommand(request, opts) {
|
|
69
|
+
try {
|
|
70
|
+
const factory = opts.modelFactory ?? createLanguageModel;
|
|
71
|
+
const result = await withModelRetry(() => generateText({ model: factory(opts.model), prompt: shellCommandPrompt(request, opts.shell, opts.platform), maxRetries: 0 }), { maxRetries: 2 });
|
|
72
|
+
const command = cleanShellCommand(result.text);
|
|
73
|
+
return command ? { command } : { error: 'empty' };
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return { error: 'empty' };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const RISKS = [
|
|
80
|
+
[
|
|
81
|
+
'deletes files',
|
|
82
|
+
/\brm\s+(?:-\w+\s+)*-(?:\w*[rR]\w*|-recursive)\b|\b(?:remove-item|ri)\b[^|;&\n]*\s-r\w*\b|\bdel\b[^|;&\n]*\/s\b|\b(?:rmdir|rd)\b[^|;&\n]*\/s\b|\bfind\b[^|;&\n]*\s-delete\b/i,
|
|
83
|
+
],
|
|
84
|
+
['forces', /(?:^|\s)--force\b|\bpush\b[^|;&\n]*\s-f\b/i],
|
|
85
|
+
['runs as administrator', /(?:^|[\s;&|(])sudo\b|\brunas\b|\bstart-process\b[^|;&\n]*-verb\s+runas\b/i],
|
|
86
|
+
['rewrites git history', /\bgit\s+(?:-\S+\s+)*(?:reset\b[^|;&\n]*--hard\b|rebase\b|push\b[^|;&\n]*--force\b)/i],
|
|
87
|
+
['drops data', /\bdrop\s+table\b|\btruncate\b/i],
|
|
88
|
+
['formats a disk', /\bmkfs(?:\.\w+)?\b|(?:^|[;&|]\s*)format(?:\.com|\.exe)?\s+[a-z]:|\bdiskpart\b/i],
|
|
89
|
+
];
|
|
90
|
+
/** Why a command deserves a second look, or undefined. The first matching reason wins, in the order of RISKS. */
|
|
91
|
+
export function commandRisk(command) {
|
|
92
|
+
return RISKS.find(([, re]) => re.test(command))?.[0];
|
|
93
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare const MAX_STACK_LIBRARIES = 25;
|
|
2
|
+
export interface StackFacts {
|
|
3
|
+
runtimes: {
|
|
4
|
+
name: string;
|
|
5
|
+
version: string;
|
|
6
|
+
source: string;
|
|
7
|
+
}[];
|
|
8
|
+
libraries: {
|
|
9
|
+
name: string;
|
|
10
|
+
version: string;
|
|
11
|
+
range: boolean;
|
|
12
|
+
}[];
|
|
13
|
+
tools: {
|
|
14
|
+
test?: string;
|
|
15
|
+
lint?: string;
|
|
16
|
+
format?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
/** JSON with comments and trailing commas (tsconfig.json): comments outside strings removed, then trailing commas. */
|
|
20
|
+
export declare function parseJsonc(text: string): Record<string, unknown> | undefined;
|
|
21
|
+
export declare function detectStackFacts(cwd: string): Promise<StackFacts>;
|
|
22
|
+
/** The facts as a block for the system prompt; '' when there are none. */
|
|
23
|
+
export declare function stackBlock(facts: StackFacts): string;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `<stack>` block: the language, runtime and library versions the workspace's own files state, so an
|
|
3
|
+
* agent writes code for the versions in use instead of the ones it saw most in training. File reads only,
|
|
4
|
+
* no model and no tree walk (the journey survey's rule: a missing fact costs less than a wrong one). Every
|
|
5
|
+
* source is read on its own; one that is missing or unreadable leaves its facts out and never fails a run.
|
|
6
|
+
* Only the workspace root's files are read.
|
|
7
|
+
*/
|
|
8
|
+
import { readFile } from 'node:fs/promises';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
export const MAX_STACK_LIBRARIES = 25;
|
|
11
|
+
const TEST_TOOLS = { vitest: 'vitest', jest: 'jest', mocha: 'mocha', ava: 'ava', pytest: 'pytest' };
|
|
12
|
+
const LINT_TOOLS = { eslint: 'eslint', '@biomejs/biome': 'biome', ruff: 'ruff', flake8: 'flake8', pylint: 'pylint' };
|
|
13
|
+
const FORMAT_TOOLS = { prettier: 'prettier', '@biomejs/biome': 'biome', black: 'black', ruff: 'ruff' };
|
|
14
|
+
async function read(cwd, rel) {
|
|
15
|
+
try {
|
|
16
|
+
return await readFile(path.join(cwd, rel), 'utf8');
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function json(text) {
|
|
23
|
+
if (text === undefined)
|
|
24
|
+
return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const v = JSON.parse(text);
|
|
27
|
+
return v && typeof v === 'object' && !Array.isArray(v) ? v : undefined;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
/** JSON with comments and trailing commas (tsconfig.json): comments outside strings removed, then trailing commas. */
|
|
34
|
+
export function parseJsonc(text) {
|
|
35
|
+
let out = '';
|
|
36
|
+
let inString = false;
|
|
37
|
+
for (let i = 0; i < text.length; i++) {
|
|
38
|
+
const c = text[i];
|
|
39
|
+
if (inString) {
|
|
40
|
+
out += c;
|
|
41
|
+
if (c === '\\')
|
|
42
|
+
out += text[++i] ?? '';
|
|
43
|
+
else if (c === '"')
|
|
44
|
+
inString = false;
|
|
45
|
+
}
|
|
46
|
+
else if (c === '"') {
|
|
47
|
+
inString = true;
|
|
48
|
+
out += c;
|
|
49
|
+
}
|
|
50
|
+
else if (c === '/' && text[i + 1] === '/') {
|
|
51
|
+
while (i < text.length && text[i] !== '\n')
|
|
52
|
+
i++;
|
|
53
|
+
out += '\n';
|
|
54
|
+
}
|
|
55
|
+
else if (c === '/' && text[i + 1] === '*') {
|
|
56
|
+
const end = text.indexOf('*/', i + 2);
|
|
57
|
+
i = end < 0 ? text.length : end + 1;
|
|
58
|
+
}
|
|
59
|
+
else
|
|
60
|
+
out += c;
|
|
61
|
+
}
|
|
62
|
+
return json(out.replace(/,(\s*[}\]])/g, '$1'));
|
|
63
|
+
}
|
|
64
|
+
const strings = (v) => Object.fromEntries(Object.entries(v && typeof v === 'object' ? v : {}).filter((e) => typeof e[1] === 'string'));
|
|
65
|
+
async function installedVersion(cwd, name, lock) {
|
|
66
|
+
const own = json(await read(cwd, path.join('node_modules', name, 'package.json')));
|
|
67
|
+
if (typeof own?.version === 'string')
|
|
68
|
+
return own.version;
|
|
69
|
+
const entry = lock?.packages?.[`node_modules/${name}`];
|
|
70
|
+
if (typeof entry?.version === 'string')
|
|
71
|
+
return entry.version;
|
|
72
|
+
const legacy = lock?.dependencies?.[name];
|
|
73
|
+
return typeof legacy?.version === 'string' ? legacy.version : undefined;
|
|
74
|
+
}
|
|
75
|
+
function firstMatch(text, re) {
|
|
76
|
+
return text?.match(re)?.[1]?.trim() || undefined;
|
|
77
|
+
}
|
|
78
|
+
export async function detectStackFacts(cwd) {
|
|
79
|
+
const facts = { runtimes: [], libraries: [], tools: {} };
|
|
80
|
+
const runtime = (name, version, source) => {
|
|
81
|
+
if (version)
|
|
82
|
+
facts.runtimes.push({ name, version, source });
|
|
83
|
+
};
|
|
84
|
+
const noteTool = (dep) => {
|
|
85
|
+
facts.tools.test ??= TEST_TOOLS[dep];
|
|
86
|
+
facts.tools.lint ??= LINT_TOOLS[dep];
|
|
87
|
+
facts.tools.format ??= FORMAT_TOOLS[dep];
|
|
88
|
+
};
|
|
89
|
+
// Node and npm-style libraries.
|
|
90
|
+
const manifest = json(await read(cwd, 'package.json'));
|
|
91
|
+
const engines = strings(manifest?.engines);
|
|
92
|
+
runtime('node', engines.node, 'package.json engines');
|
|
93
|
+
runtime('node', firstMatch(await read(cwd, '.nvmrc'), /^\s*(\S+)/), '.nvmrc');
|
|
94
|
+
runtime('node', firstMatch(await read(cwd, '.node-version'), /^\s*(\S+)/), '.node-version');
|
|
95
|
+
if (manifest) {
|
|
96
|
+
const lock = json(await read(cwd, 'package-lock.json'));
|
|
97
|
+
const deps = Object.entries({ ...strings(manifest.dependencies) });
|
|
98
|
+
const dev = Object.entries(strings(manifest.devDependencies)).filter(([n]) => !deps.some(([d]) => d === n));
|
|
99
|
+
for (const [name] of [...deps, ...dev])
|
|
100
|
+
noteTool(name);
|
|
101
|
+
for (const [name, range] of [...deps, ...dev].slice(0, MAX_STACK_LIBRARIES)) {
|
|
102
|
+
const exact = await installedVersion(cwd, name, lock);
|
|
103
|
+
facts.libraries.push(exact ? { name, version: exact, range: false } : { name, version: range, range: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const tsconfig = await read(cwd, 'tsconfig.json');
|
|
107
|
+
const target = tsconfig === undefined ? undefined : parseJsonc(tsconfig)?.compilerOptions?.target;
|
|
108
|
+
if (typeof target === 'string')
|
|
109
|
+
runtime('tsconfig target', target, 'tsconfig.json');
|
|
110
|
+
// Python.
|
|
111
|
+
runtime('python', firstMatch(await read(cwd, 'pyproject.toml'), /^\s*requires-python\s*=\s*["']([^"']+)["']/m), 'pyproject.toml');
|
|
112
|
+
runtime('python', firstMatch(await read(cwd, '.python-version'), /^\s*(\S+)/), '.python-version');
|
|
113
|
+
const requirements = await read(cwd, 'requirements.txt');
|
|
114
|
+
for (const line of requirements?.split(/\r?\n/) ?? []) {
|
|
115
|
+
const m = line.match(/^\s*([A-Za-z0-9._-]+)\s*==\s*([^\s;#]+)/);
|
|
116
|
+
if (!m)
|
|
117
|
+
continue;
|
|
118
|
+
noteTool(m[1].toLowerCase());
|
|
119
|
+
if (facts.libraries.length < MAX_STACK_LIBRARIES)
|
|
120
|
+
facts.libraries.push({ name: m[1], version: m[2], range: false });
|
|
121
|
+
}
|
|
122
|
+
// Go, Rust, Java.
|
|
123
|
+
runtime('go', firstMatch(await read(cwd, 'go.mod'), /^\s*go\s+(\d[\w.]*)\s*$/m), 'go.mod');
|
|
124
|
+
runtime('rust edition', firstMatch(await read(cwd, 'Cargo.toml'), /^\s*edition\s*=\s*["']([^"']+)["']/m), 'Cargo.toml');
|
|
125
|
+
runtime('rust', firstMatch(await read(cwd, 'rust-toolchain.toml'), /^\s*channel\s*=\s*["']([^"']+)["']/m), 'rust-toolchain.toml');
|
|
126
|
+
runtime('rust', firstMatch(await read(cwd, 'rust-toolchain'), /^\s*(\S+)/), 'rust-toolchain');
|
|
127
|
+
const pom = await read(cwd, 'pom.xml');
|
|
128
|
+
runtime('java', firstMatch(pom, /<maven\.compiler\.release>\s*([^<\s]+)\s*</) ?? firstMatch(pom, /<java\.version>\s*([^<\s]+)\s*</), 'pom.xml');
|
|
129
|
+
for (const gradle of ['build.gradle.kts', 'build.gradle']) {
|
|
130
|
+
const text = await read(cwd, gradle);
|
|
131
|
+
runtime('java', firstMatch(text, /JavaLanguageVersion\.of\(\s*(\d+)\s*\)/) ?? firstMatch(text, /JavaVersion\.VERSION_(\d+(?:_\d+)?)/), gradle);
|
|
132
|
+
}
|
|
133
|
+
for (const k of Object.keys(facts.tools))
|
|
134
|
+
if (facts.tools[k] === undefined)
|
|
135
|
+
delete facts.tools[k];
|
|
136
|
+
return facts;
|
|
137
|
+
}
|
|
138
|
+
/** The facts as a block for the system prompt; '' when there are none. */
|
|
139
|
+
export function stackBlock(facts) {
|
|
140
|
+
const lines = [
|
|
141
|
+
...facts.runtimes.map((r) => `${r.name} ${r.version} (${r.source})`),
|
|
142
|
+
...facts.libraries.map((l) => (l.range ? `${l.name} ${l.version} (range in the manifest; installed version unknown)` : `${l.name} ${l.version}`)),
|
|
143
|
+
...(facts.tools.test ? [`test runner: ${facts.tools.test}`] : []),
|
|
144
|
+
...(facts.tools.lint ? [`linter: ${facts.tools.lint}`] : []),
|
|
145
|
+
...(facts.tools.format ? [`formatter: ${facts.tools.format}`] : []),
|
|
146
|
+
];
|
|
147
|
+
if (lines.length === 0)
|
|
148
|
+
return '';
|
|
149
|
+
return ['<stack>', "Versions this workspace's files state (read from the files, not guessed):", ...lines, '</stack>'].join('\n');
|
|
150
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const MODELS_DEV_SNAPSHOT: Record<string, Record<string, ReadonlyArray<number | null>>>;
|