tokenmaw 0.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/README.md +150 -0
- package/agents/coordinator.md +13 -0
- package/agents/explorer.md +21 -0
- package/agents/implement.md +23 -0
- package/agents/main.md +25 -0
- package/agents/review.md +21 -0
- package/dist/backend.js +595 -0
- package/dist/cli.js +101 -0
- package/dist/config.js +155 -0
- package/dist/diff.js +45 -0
- package/dist/domain/agent.js +1 -0
- package/dist/fetch.js +110 -0
- package/dist/infra/file-snapshot.js +54 -0
- package/dist/infra/tools.js +1300 -0
- package/dist/markdown.js +274 -0
- package/dist/model-config.js +48 -0
- package/dist/policy.js +80 -0
- package/dist/responses.js +81 -0
- package/dist/runtime/agent-registry.js +139 -0
- package/dist/runtime/agent-runtime.js +993 -0
- package/dist/runtime/agent-store.js +152 -0
- package/dist/runtime/locks.js +46 -0
- package/dist/runtime/session-timeline.js +92 -0
- package/dist/tools/index.js +4 -0
- package/dist/tools/registry.js +51 -0
- package/dist/tools/types.js +1 -0
- package/dist/ui/clipboard.js +24 -0
- package/dist/ui/commands.js +20 -0
- package/dist/ui/composer-layout.js +31 -0
- package/dist/ui/fullscreen-tui.js +1405 -0
- package/dist/ui/markdown.js +81 -0
- package/dist/ui/syntax.js +17 -0
- package/dist/ui/tui-design.js +94 -0
- package/dist/ui/welcome.js +24 -0
- package/dist/version.js +4 -0
- package/docs/architecture-revision.md +281 -0
- package/package.json +47 -0
- package/skills/debugging.md +18 -0
- package/skills/git-workflow.md +14 -0
- package/skills/node-express.md +27 -0
- package/skills/python-flask.md +22 -0
- package/skills/react-component.md +24 -0
- package/skills/sql-database.md +18 -0
- package/skills/testing.md +12 -0
package/dist/markdown.js
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* markdown.ts — Lightweight terminal markdown renderer.
|
|
3
|
+
*
|
|
4
|
+
* Exported so it can be unit-tested independently of the TUI.
|
|
5
|
+
* No external dependencies — pure string transformation.
|
|
6
|
+
*/
|
|
7
|
+
import chalk from 'chalk';
|
|
8
|
+
const MARKDOWN_THEME = {
|
|
9
|
+
text: '#d7e0ea',
|
|
10
|
+
muted: '#7f92a6',
|
|
11
|
+
accent: '#6fb1d6',
|
|
12
|
+
accentStrong: '#8ac3e6',
|
|
13
|
+
codeBg: '#16212d',
|
|
14
|
+
codeText: '#c7d7e6',
|
|
15
|
+
codeFence: '#5f7388',
|
|
16
|
+
codeGreen: '#8ebd93',
|
|
17
|
+
codeRed: '#c97c7c',
|
|
18
|
+
};
|
|
19
|
+
function isDiffLanguage(lang) {
|
|
20
|
+
return lang.toLowerCase() === 'diff' || lang.toLowerCase() === 'patch';
|
|
21
|
+
}
|
|
22
|
+
export function diffKind(line) {
|
|
23
|
+
if (/^(diff --git|index |--- |\+\+\+ )/.test(line))
|
|
24
|
+
return 'file';
|
|
25
|
+
if (line.startsWith('@@'))
|
|
26
|
+
return 'hunk';
|
|
27
|
+
if (line.startsWith('+') && !line.startsWith('+++'))
|
|
28
|
+
return 'add';
|
|
29
|
+
if (line.startsWith('-') && !line.startsWith('---'))
|
|
30
|
+
return 'del';
|
|
31
|
+
return 'context';
|
|
32
|
+
}
|
|
33
|
+
export function renderDiffLine(line) {
|
|
34
|
+
switch (diffKind(line)) {
|
|
35
|
+
case 'add':
|
|
36
|
+
return chalk.hex(MARKDOWN_THEME.codeGreen)(line);
|
|
37
|
+
case 'del':
|
|
38
|
+
return chalk.hex(MARKDOWN_THEME.codeRed)(line);
|
|
39
|
+
case 'hunk':
|
|
40
|
+
return chalk.hex(MARKDOWN_THEME.accent)(line);
|
|
41
|
+
case 'file':
|
|
42
|
+
return chalk.bold.hex(MARKDOWN_THEME.text)(line);
|
|
43
|
+
case 'context':
|
|
44
|
+
default:
|
|
45
|
+
return chalk.hex(MARKDOWN_THEME.muted)(line);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function renderCodeLine(lang, line) {
|
|
49
|
+
return isDiffLanguage(lang) ? renderDiffLine(line) : chalk.hex(MARKDOWN_THEME.codeText)(line);
|
|
50
|
+
}
|
|
51
|
+
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]/g;
|
|
52
|
+
/** Display width ignoring ANSI escapes, counting East-Asian wide chars as 2 columns. */
|
|
53
|
+
export function displayWidth(text) {
|
|
54
|
+
const clean = text.replace(ANSI_PATTERN, '');
|
|
55
|
+
let width = 0;
|
|
56
|
+
for (const ch of clean) {
|
|
57
|
+
const code = ch.codePointAt(0);
|
|
58
|
+
const wide = (code >= 0x1100 && code <= 0x115F)
|
|
59
|
+
|| (code >= 0x2E80 && code <= 0x303E)
|
|
60
|
+
|| (code >= 0x3130 && code <= 0x4DBF)
|
|
61
|
+
|| (code >= 0x4E00 && code <= 0x9FFF)
|
|
62
|
+
|| (code >= 0xA000 && code <= 0xA4CF)
|
|
63
|
+
|| (code >= 0xAC00 && code <= 0xD7A3)
|
|
64
|
+
|| (code >= 0xF900 && code <= 0xFAFF)
|
|
65
|
+
|| (code >= 0xFE30 && code <= 0xFE4F)
|
|
66
|
+
|| (code >= 0xFF00 && code <= 0xFF60)
|
|
67
|
+
|| (code >= 0xFFE0 && code <= 0xFFE6)
|
|
68
|
+
|| (code >= 0x1F300 && code <= 0x1FAFF)
|
|
69
|
+
|| (code >= 0x20000 && code <= 0x3FFFD);
|
|
70
|
+
width += wide ? 2 : 1;
|
|
71
|
+
}
|
|
72
|
+
return width;
|
|
73
|
+
}
|
|
74
|
+
function splitCells(line) {
|
|
75
|
+
let trimmed = line.trim();
|
|
76
|
+
if (trimmed.startsWith('|'))
|
|
77
|
+
trimmed = trimmed.slice(1);
|
|
78
|
+
if (trimmed.endsWith('|') && !trimmed.endsWith('\\|'))
|
|
79
|
+
trimmed = trimmed.slice(0, -1);
|
|
80
|
+
return trimmed
|
|
81
|
+
.split(/(?<!\\)\|/)
|
|
82
|
+
.map((cell) => cell.trim().replace(/\\\|/g, '|'));
|
|
83
|
+
}
|
|
84
|
+
function delimiterCells(line) {
|
|
85
|
+
const trimmed = line.trim();
|
|
86
|
+
if (!trimmed.includes('|'))
|
|
87
|
+
return null;
|
|
88
|
+
const cells = splitCells(trimmed);
|
|
89
|
+
if (!cells.length || !cells.every((cell) => /^:?-+:?$/.test(cell)))
|
|
90
|
+
return null;
|
|
91
|
+
return cells;
|
|
92
|
+
}
|
|
93
|
+
/** Match a GFM pipe table starting at lines[start]. Returns null when absent. */
|
|
94
|
+
export function matchGfmTable(lines, start) {
|
|
95
|
+
const headerLine = lines[start];
|
|
96
|
+
const delimiterLine = lines[start + 1];
|
|
97
|
+
if (!headerLine || !delimiterLine)
|
|
98
|
+
return null;
|
|
99
|
+
if (!headerLine.includes('|'))
|
|
100
|
+
return null;
|
|
101
|
+
const alignCells = delimiterCells(delimiterLine);
|
|
102
|
+
if (!alignCells)
|
|
103
|
+
return null;
|
|
104
|
+
const header = splitCells(headerLine);
|
|
105
|
+
if (header.length !== alignCells.length)
|
|
106
|
+
return null;
|
|
107
|
+
const aligns = alignCells.map((cell) => (cell.startsWith(':') && cell.endsWith(':') ? 'center' : cell.endsWith(':') ? 'right' : 'left'));
|
|
108
|
+
const rows = [];
|
|
109
|
+
let end = start + 2;
|
|
110
|
+
while (end < lines.length) {
|
|
111
|
+
const line = lines[end];
|
|
112
|
+
if (!line.includes('|') || !line.trim() || /^```/.test(line) || /^#{1,6} /.test(line))
|
|
113
|
+
break;
|
|
114
|
+
const cells = splitCells(line);
|
|
115
|
+
while (cells.length < header.length)
|
|
116
|
+
cells.push('');
|
|
117
|
+
rows.push(cells.slice(0, header.length));
|
|
118
|
+
end += 1;
|
|
119
|
+
}
|
|
120
|
+
return { table: { header, aligns, rows }, end };
|
|
121
|
+
}
|
|
122
|
+
function truncateToWidth(text, maxWidth) {
|
|
123
|
+
if (displayWidth(text) <= maxWidth)
|
|
124
|
+
return text;
|
|
125
|
+
let out = '';
|
|
126
|
+
let width = 0;
|
|
127
|
+
for (const ch of text) {
|
|
128
|
+
const w = displayWidth(ch);
|
|
129
|
+
if (width + w > maxWidth - 1)
|
|
130
|
+
break;
|
|
131
|
+
out += ch;
|
|
132
|
+
width += w;
|
|
133
|
+
}
|
|
134
|
+
return `${out}…`;
|
|
135
|
+
}
|
|
136
|
+
/** Render a parsed table as aligned monospace lines that fit within maxWidth columns. */
|
|
137
|
+
export function renderGfmTable(table, maxWidth) {
|
|
138
|
+
const columns = table.header.length;
|
|
139
|
+
const gap = ' │ ';
|
|
140
|
+
const gapWidth = displayWidth(gap);
|
|
141
|
+
const minWidth = 4;
|
|
142
|
+
const widths = table.header.map((cell, index) => Math.max(displayWidth(cell), ...table.rows.map((row) => displayWidth(row[index] ?? '')), minWidth));
|
|
143
|
+
const totalWidth = () => widths.reduce((sum, width) => sum + width, 0) + gapWidth * (columns - 1);
|
|
144
|
+
if (totalWidth() > maxWidth) {
|
|
145
|
+
const shrinkable = widths.map((_, index) => index).filter((index) => widths[index] > minWidth);
|
|
146
|
+
shrinkable.sort((a, b) => widths[b] - widths[a]);
|
|
147
|
+
let overflow = totalWidth() - maxWidth;
|
|
148
|
+
for (const index of shrinkable) {
|
|
149
|
+
if (overflow <= 0)
|
|
150
|
+
break;
|
|
151
|
+
const reduce = Math.min(overflow, widths[index] - minWidth);
|
|
152
|
+
widths[index] = widths[index] - reduce;
|
|
153
|
+
overflow -= reduce;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const padCell = (rendered, width, align) => {
|
|
157
|
+
const padding = Math.max(0, width - displayWidth(rendered));
|
|
158
|
+
if (align === 'right')
|
|
159
|
+
return ' '.repeat(padding) + rendered;
|
|
160
|
+
if (align === 'center') {
|
|
161
|
+
const left = Math.floor(padding / 2);
|
|
162
|
+
return ' '.repeat(left) + rendered + ' '.repeat(padding - left);
|
|
163
|
+
}
|
|
164
|
+
return rendered + ' '.repeat(padding);
|
|
165
|
+
};
|
|
166
|
+
const renderRow = (cells, style) => cells
|
|
167
|
+
.map((cell, index) => {
|
|
168
|
+
const width = widths[index];
|
|
169
|
+
const plain = displayWidth(cell) > width ? truncateToWidth(cell, width) : cell;
|
|
170
|
+
return padCell(style(plain), width, table.aligns[index]);
|
|
171
|
+
})
|
|
172
|
+
.join(chalk.hex(MARKDOWN_THEME.muted)(gap));
|
|
173
|
+
const header = renderRow(table.header, (cell) => chalk.bold.hex(MARKDOWN_THEME.accent)(inlineMarkdown(cell)));
|
|
174
|
+
// One uniform border color for every structural character (│, ─, ┼).
|
|
175
|
+
const separator = chalk.hex(MARKDOWN_THEME.muted)(widths.map((width) => '─'.repeat(width)).join('─┼─'));
|
|
176
|
+
const body = table.rows.map((row) => renderRow(row, (cell) => inlineMarkdown(cell)));
|
|
177
|
+
return [header, chalk.hex(MARKDOWN_THEME.muted)(separator), ...body];
|
|
178
|
+
}
|
|
179
|
+
export function inlineMarkdown(text) {
|
|
180
|
+
return text
|
|
181
|
+
.replace(/\*\*\*(.+?)\*\*\*/g, (_m, t) => chalk.bold.italic(t))
|
|
182
|
+
.replace(/\*\*(.+?)\*\*/g, (_m, t) => chalk.bold(t))
|
|
183
|
+
.replace(/__(.+?)__/g, (_m, t) => chalk.bold(t))
|
|
184
|
+
.replace(/\*(.+?)\*/g, (_m, t) => chalk.italic(t))
|
|
185
|
+
.replace(/_(.+?)_/g, (_m, t) => chalk.italic(t))
|
|
186
|
+
.replace(/`([^`]+)`/g, (_m, t) => chalk.bgHex(MARKDOWN_THEME.codeBg).hex(MARKDOWN_THEME.codeText)(` ${t} `))
|
|
187
|
+
.replace(/~~(.+?)~~/g, (_m, t) => chalk.strikethrough(t));
|
|
188
|
+
}
|
|
189
|
+
export function renderMarkdown(text, cols = 80) {
|
|
190
|
+
const lines = text.split('\n');
|
|
191
|
+
const out = [];
|
|
192
|
+
let inCodeBlock = false;
|
|
193
|
+
let codeLang = '';
|
|
194
|
+
let codeLines = [];
|
|
195
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
196
|
+
const raw = lines[index];
|
|
197
|
+
const fenceMatch = raw.match(/^```(\w*)$/);
|
|
198
|
+
if (fenceMatch) {
|
|
199
|
+
if (!inCodeBlock) {
|
|
200
|
+
inCodeBlock = true;
|
|
201
|
+
codeLang = fenceMatch[1] ?? '';
|
|
202
|
+
codeLines = [];
|
|
203
|
+
}
|
|
204
|
+
else {
|
|
205
|
+
inCodeBlock = false;
|
|
206
|
+
const langLabel = codeLang ? chalk.hex(MARKDOWN_THEME.muted).italic(` ${codeLang}`) : '';
|
|
207
|
+
out.push(chalk.hex(MARKDOWN_THEME.codeFence)('┌' + '─'.repeat(Math.max(2, cols - 2))) + langLabel);
|
|
208
|
+
for (const cl of codeLines) {
|
|
209
|
+
out.push(chalk.hex(MARKDOWN_THEME.codeFence)('│ ') + renderCodeLine(codeLang, cl));
|
|
210
|
+
}
|
|
211
|
+
out.push(chalk.hex(MARKDOWN_THEME.codeFence)('└' + '─'.repeat(Math.max(2, cols - 2))));
|
|
212
|
+
codeLang = '';
|
|
213
|
+
codeLines = [];
|
|
214
|
+
}
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
if (inCodeBlock) {
|
|
218
|
+
codeLines.push(raw);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const h1 = raw.match(/^# (.+)/);
|
|
222
|
+
const h2 = raw.match(/^## (.+)/);
|
|
223
|
+
const h3 = raw.match(/^### (.+)/);
|
|
224
|
+
if (h1) {
|
|
225
|
+
out.push('\n' + chalk.bold.hex(MARKDOWN_THEME.accentStrong)(h1[1]));
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
if (h2) {
|
|
229
|
+
out.push('\n' + chalk.bold.hex(MARKDOWN_THEME.text)(h2[1]));
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (h3) {
|
|
233
|
+
out.push(chalk.bold(h3[1]));
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
if (/^---+$/.test(raw) || /^\*\*\*+$/.test(raw)) {
|
|
237
|
+
out.push(chalk.hex(MARKDOWN_THEME.muted)('─'.repeat(cols)));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const tableMatch = matchGfmTable(lines, index);
|
|
241
|
+
if (tableMatch) {
|
|
242
|
+
out.push(...renderGfmTable(tableMatch.table, cols));
|
|
243
|
+
index = tableMatch.end - 1;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const bullet = raw.match(/^(\s*)[*\-+] (.+)/);
|
|
247
|
+
if (bullet) {
|
|
248
|
+
out.push((bullet[1] ?? '') + chalk.hex(MARKDOWN_THEME.accent)('•') + ' ' + inlineMarkdown(bullet[2] ?? ''));
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const numbered = raw.match(/^(\s*)(\d+)\. (.+)/);
|
|
252
|
+
if (numbered) {
|
|
253
|
+
out.push((numbered[1] ?? '') +
|
|
254
|
+
chalk.hex(MARKDOWN_THEME.accent)(numbered[2] + '.') +
|
|
255
|
+
' ' +
|
|
256
|
+
inlineMarkdown(numbered[3] ?? ''));
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const bq = raw.match(/^> (.+)/);
|
|
260
|
+
if (bq) {
|
|
261
|
+
out.push(chalk.hex(MARKDOWN_THEME.muted)('│ ') + chalk.italic.hex(MARKDOWN_THEME.muted)(bq[1]));
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
out.push(inlineMarkdown(raw));
|
|
265
|
+
}
|
|
266
|
+
if (inCodeBlock && codeLines.length > 0) {
|
|
267
|
+
out.push(chalk.hex(MARKDOWN_THEME.codeFence)('┌─'));
|
|
268
|
+
for (const cl of codeLines) {
|
|
269
|
+
out.push(chalk.hex(MARKDOWN_THEME.codeFence)('│ ') + renderCodeLine(codeLang, cl));
|
|
270
|
+
}
|
|
271
|
+
out.push(chalk.hex(MARKDOWN_THEME.codeFence)('└─'));
|
|
272
|
+
}
|
|
273
|
+
return out.join('\n');
|
|
274
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { detectBackend } from './backend.js';
|
|
2
|
+
export function defaultBaseUrlForBackend(backend) {
|
|
3
|
+
if (backend === 'anthropic')
|
|
4
|
+
return 'https://api.anthropic.com';
|
|
5
|
+
if (backend === 'openai')
|
|
6
|
+
return 'https://api.openai.com';
|
|
7
|
+
return 'http://localhost:11434';
|
|
8
|
+
}
|
|
9
|
+
export function resolveModelConfig(fileConfig, requestedModel) {
|
|
10
|
+
const defaultModel = requestedModel ?? process.env.AGENT_MODEL ?? fileConfig.model;
|
|
11
|
+
if (!defaultModel) {
|
|
12
|
+
return {
|
|
13
|
+
name: '(unconfigured)',
|
|
14
|
+
config: {
|
|
15
|
+
type: 'ollama',
|
|
16
|
+
baseUrl: 'http://localhost:11434',
|
|
17
|
+
model: '',
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const aliasConfig = fileConfig.models?.[defaultModel];
|
|
22
|
+
const modelConfig = aliasConfig ?? { model: defaultModel };
|
|
23
|
+
const requestedBackend = (process.env.LLM_BACKEND
|
|
24
|
+
?? modelConfig.backend
|
|
25
|
+
?? fileConfig.backend);
|
|
26
|
+
const baseUrl = process.env.LLM_BASE_URL
|
|
27
|
+
?? process.env.OLLAMA_BASE_URL
|
|
28
|
+
?? modelConfig.baseUrl
|
|
29
|
+
?? fileConfig.baseUrl
|
|
30
|
+
?? (requestedBackend ? defaultBaseUrlForBackend(requestedBackend) : undefined);
|
|
31
|
+
if (!baseUrl) {
|
|
32
|
+
throw new Error('No base URL specified. Set "baseUrl" in .agentrc or set LLM_BASE_URL env var.');
|
|
33
|
+
}
|
|
34
|
+
const backend = (requestedBackend ?? detectBackend(baseUrl));
|
|
35
|
+
const apiKey = process.env.LLM_API_KEY ?? modelConfig.apiKey ?? fileConfig.apiKey;
|
|
36
|
+
return {
|
|
37
|
+
name: aliasConfig ? defaultModel : modelConfig.model,
|
|
38
|
+
config: {
|
|
39
|
+
type: backend,
|
|
40
|
+
baseUrl,
|
|
41
|
+
model: modelConfig.model,
|
|
42
|
+
...(modelConfig.wireApi ? { wireApi: modelConfig.wireApi } : {}),
|
|
43
|
+
...(apiKey ? { apiKey } : {}),
|
|
44
|
+
...(modelConfig.requestOptions ? { requestOptions: modelConfig.requestOptions } : {}),
|
|
45
|
+
...(modelConfig.contextWindow ? { contextWindow: modelConfig.contextWindow } : {}),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/dist/policy.js
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { resolve, isAbsolute, relative } from 'node:path';
|
|
2
|
+
export function clonePolicy(policy) {
|
|
3
|
+
return {
|
|
4
|
+
level: policy.level,
|
|
5
|
+
workspaceRoot: policy.workspaceRoot,
|
|
6
|
+
allowedReadRoots: [...policy.allowedReadRoots],
|
|
7
|
+
allowedWriteRoots: [...policy.allowedWriteRoots],
|
|
8
|
+
bashAllowlist: [...policy.bashAllowlist],
|
|
9
|
+
bashDenylist: [...policy.bashDenylist],
|
|
10
|
+
};
|
|
11
|
+
}
|
|
12
|
+
export function defaultPolicy(level = 'off', workspaceRoot = process.cwd()) {
|
|
13
|
+
return {
|
|
14
|
+
level,
|
|
15
|
+
workspaceRoot,
|
|
16
|
+
allowedReadRoots: [workspaceRoot],
|
|
17
|
+
allowedWriteRoots: [workspaceRoot],
|
|
18
|
+
bashAllowlist: ['npm test', 'npm run typecheck', 'npm run build', 'node --version', 'echo '],
|
|
19
|
+
bashDenylist: [
|
|
20
|
+
'rm -rf /', 'curl | sh', 'wget | sh', 'mkfs', ':(){:|:&};:',
|
|
21
|
+
'Remove-Item -Recurse C:\\', 'format C:', 'diskpart',
|
|
22
|
+
],
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export function readOnlyPolicy(base) {
|
|
26
|
+
const policy = clonePolicy(base);
|
|
27
|
+
policy.allowedWriteRoots = [];
|
|
28
|
+
return policy;
|
|
29
|
+
}
|
|
30
|
+
function withinRoots(target, roots) {
|
|
31
|
+
const norm = resolve(target);
|
|
32
|
+
return roots.some((r) => {
|
|
33
|
+
const root = resolve(r);
|
|
34
|
+
if (norm === root)
|
|
35
|
+
return true;
|
|
36
|
+
// Use path.relative for cross-platform safety: on Windows, resolve()
|
|
37
|
+
// returns backslash-separated paths, so a naive `startsWith(root + '/')`
|
|
38
|
+
// check fails and wrongly rejects in-workspace files.
|
|
39
|
+
const rel = relative(root, norm);
|
|
40
|
+
return rel !== '' && !rel.startsWith('..') && !isAbsolute(rel);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export function authorizeToolCall(policy, name, args) {
|
|
44
|
+
if (policy.level === 'off')
|
|
45
|
+
return { ok: true };
|
|
46
|
+
const readPathTools = new Set([
|
|
47
|
+
'read_file', 'read_files', 'file_info', 'list_dir', 'search_text', 'search_files',
|
|
48
|
+
'repo_map', 'git_diff', 'git_log',
|
|
49
|
+
]);
|
|
50
|
+
const writePathTools = new Set(['write_file', 'edit_file']);
|
|
51
|
+
if (readPathTools.has(name) || writePathTools.has(name)) {
|
|
52
|
+
const key = name === 'repo_map' ? 'root' : 'path';
|
|
53
|
+
const path = typeof args[key] === 'string' ? args[key] : '.';
|
|
54
|
+
const target = isAbsolute(path) ? path : resolve(policy.workspaceRoot, path);
|
|
55
|
+
const roots = writePathTools.has(name) ? policy.allowedWriteRoots : policy.allowedReadRoots;
|
|
56
|
+
if (!withinRoots(target, roots)) {
|
|
57
|
+
return { ok: false, ruleId: 'path_outside_workspace', reason: `Path not allowed: ${path}` };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (name === 'bash') {
|
|
61
|
+
const cmd = typeof args['command'] === 'string' ? args['command'] : '';
|
|
62
|
+
if (!cmd)
|
|
63
|
+
return { ok: false, ruleId: 'missing_command', reason: 'bash command is required' };
|
|
64
|
+
if (policy.bashDenylist.some((bad) => cmd.includes(bad))) {
|
|
65
|
+
return { ok: false, ruleId: 'bash_denylist', reason: `Command blocked by denylist: ${cmd}` };
|
|
66
|
+
}
|
|
67
|
+
const cwdArg = typeof args['cwd'] === 'string' ? args['cwd'] : policy.workspaceRoot;
|
|
68
|
+
const cwd = isAbsolute(cwdArg) ? cwdArg : resolve(policy.workspaceRoot, cwdArg);
|
|
69
|
+
if (!withinRoots(cwd, policy.allowedReadRoots)) {
|
|
70
|
+
return { ok: false, ruleId: 'cwd_outside_workspace', reason: `Working directory not allowed: ${cwdArg}` };
|
|
71
|
+
}
|
|
72
|
+
if (policy.level === 'strict' && !policy.bashAllowlist.some((ok) => cmd.startsWith(ok))) {
|
|
73
|
+
return { ok: false, ruleId: 'bash_not_allowlisted', reason: `Command not allowlisted: ${cmd}` };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return { ok: true };
|
|
77
|
+
}
|
|
78
|
+
export function formatPolicyError(name, decision) {
|
|
79
|
+
return `PolicyError: tool=${name}; rule=${decision.ruleId ?? 'unknown'}; reason=${decision.reason ?? 'blocked'}`;
|
|
80
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { transportHeaders } from './backend.js';
|
|
2
|
+
import { resilientFetch, FetchError } from './fetch.js';
|
|
3
|
+
export async function* responsesStream(config, instructions, messages, tools, signal) {
|
|
4
|
+
const input = [];
|
|
5
|
+
for (const message of messages) {
|
|
6
|
+
if (message.responseItems)
|
|
7
|
+
input.push(...message.responseItems);
|
|
8
|
+
if (message.role === 'tool')
|
|
9
|
+
input.push({ type: 'function_call_output', call_id: message.tool_use_id, output: message.content ?? '' });
|
|
10
|
+
else {
|
|
11
|
+
if (message.content)
|
|
12
|
+
input.push({ role: message.role, content: message.content });
|
|
13
|
+
for (const call of message.tool_calls ?? []) {
|
|
14
|
+
input.push({ type: 'function_call', call_id: call.id, name: call.function.name, arguments: JSON.stringify(call.function.arguments) });
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const options = config.requestOptions;
|
|
19
|
+
const body = {
|
|
20
|
+
...(options?.temperature !== undefined ? { temperature: options.temperature } : {}),
|
|
21
|
+
...(options?.topP !== undefined ? { top_p: options.topP } : {}),
|
|
22
|
+
...(options?.maxTokens !== undefined ? { max_output_tokens: options.maxTokens } : {}),
|
|
23
|
+
...options?.extraBody,
|
|
24
|
+
model: config.model, instructions, input, stream: true, store: false,
|
|
25
|
+
include: ['reasoning.encrypted_content'],
|
|
26
|
+
...(tools?.length ? { tools: tools.map(({ function: fn }) => ({ type: 'function', name: fn.name, description: fn.description, parameters: fn.parameters, strict: false })) } : {}),
|
|
27
|
+
};
|
|
28
|
+
const base = config.baseUrl.replace(/\/+$/, '').replace(/\/v1$/, '');
|
|
29
|
+
const response = await resilientFetch(`${base}/v1/responses`, {
|
|
30
|
+
method: 'POST', headers: { 'content-type': 'application/json', ...(config.apiKey ? { authorization: `Bearer ${config.apiKey}` } : {}), ...transportHeaders(config) },
|
|
31
|
+
body: JSON.stringify(body), signal, timeout: 120_000, retries: 2,
|
|
32
|
+
});
|
|
33
|
+
if (!response.ok)
|
|
34
|
+
throw new FetchError(`Responses HTTP ${response.status}: ${await response.text()}`, response.status, false);
|
|
35
|
+
if (!response.body)
|
|
36
|
+
throw new Error('Responses returned no stream');
|
|
37
|
+
const reader = response.body.getReader();
|
|
38
|
+
const decoder = new TextDecoder();
|
|
39
|
+
let buffer = '';
|
|
40
|
+
try {
|
|
41
|
+
while (true) {
|
|
42
|
+
const { value, done } = await reader.read();
|
|
43
|
+
buffer += done ? decoder.decode() + '\n\n' : decoder.decode(value, { stream: true });
|
|
44
|
+
buffer = buffer.replace(/\r\n/g, '\n');
|
|
45
|
+
const frames = buffer.split('\n\n');
|
|
46
|
+
buffer = frames.pop() ?? '';
|
|
47
|
+
for (const frame of frames) {
|
|
48
|
+
const data = frame.split('\n').filter((line) => line.startsWith('data:')).map((line) => line.slice(5).trimStart()).join('\n');
|
|
49
|
+
if (!data || data === '[DONE]')
|
|
50
|
+
continue;
|
|
51
|
+
const event = JSON.parse(data);
|
|
52
|
+
if (event.type === 'response.output_text.delta' && event.delta)
|
|
53
|
+
yield { content: event.delta, done: false };
|
|
54
|
+
if (event.type === 'response.reasoning_summary_text.delta' && event.delta)
|
|
55
|
+
yield { content: null, thinking: event.delta, done: false };
|
|
56
|
+
if (event.type === 'response.output_item.done' && event.item?.type === 'reasoning') {
|
|
57
|
+
yield { content: null, responseItems: [event.item], done: false };
|
|
58
|
+
}
|
|
59
|
+
if (event.type === 'response.output_item.done' && event.item?.type === 'function_call') {
|
|
60
|
+
const item = event.item;
|
|
61
|
+
if (!item.call_id || !item.name)
|
|
62
|
+
throw new Error('Responses returned an incomplete tool call');
|
|
63
|
+
yield { content: null, toolCalls: [{ id: item.call_id, function: { name: item.name, arguments: JSON.parse(item.arguments ?? '{}') } }], done: false };
|
|
64
|
+
}
|
|
65
|
+
if (['error', 'response.failed', 'response.incomplete'].includes(event.type)) {
|
|
66
|
+
throw new Error(event.response?.error?.message ?? event.message ?? event.response?.incomplete_details?.reason ?? `Responses: ${event.type}`);
|
|
67
|
+
}
|
|
68
|
+
if (event.type === 'response.completed') {
|
|
69
|
+
yield { content: null, done: true };
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (done)
|
|
74
|
+
throw new Error('Responses stream ended before completion');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
await reader.cancel().catch(() => undefined);
|
|
79
|
+
reader.releaseLock();
|
|
80
|
+
}
|
|
81
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { relative, resolve, sep } from 'node:path';
|
|
4
|
+
function unquote(value) {
|
|
5
|
+
const trimmed = value.trim();
|
|
6
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
|
|
7
|
+
return trimmed.slice(1, -1);
|
|
8
|
+
}
|
|
9
|
+
return trimmed;
|
|
10
|
+
}
|
|
11
|
+
function parseInlineList(value) {
|
|
12
|
+
const trimmed = value.trim();
|
|
13
|
+
if (trimmed === '[]')
|
|
14
|
+
return [];
|
|
15
|
+
if (!trimmed.startsWith('[') || !trimmed.endsWith(']'))
|
|
16
|
+
return [unquote(trimmed)].filter(Boolean);
|
|
17
|
+
return trimmed.slice(1, -1).split(',').map((item) => unquote(item)).filter(Boolean);
|
|
18
|
+
}
|
|
19
|
+
function parseDocument(raw, source, id, scope) {
|
|
20
|
+
const normalized = raw.replace(/^\uFEFF/, '').replace(/\r\n/g, '\n');
|
|
21
|
+
if (!normalized.startsWith('---\n'))
|
|
22
|
+
throw new Error(`Agent spec ${source} must start with YAML frontmatter`);
|
|
23
|
+
const end = normalized.indexOf('\n---\n', 4);
|
|
24
|
+
if (end < 0)
|
|
25
|
+
throw new Error(`Agent spec ${source} has no closing frontmatter delimiter`);
|
|
26
|
+
const header = normalized.slice(4, end);
|
|
27
|
+
const instructions = normalized.slice(end + 5).trim();
|
|
28
|
+
const data = {};
|
|
29
|
+
let activeList;
|
|
30
|
+
for (const rawLine of header.split('\n')) {
|
|
31
|
+
const line = rawLine.replace(/\s+#.*$/, '');
|
|
32
|
+
const listItem = line.match(/^\s+-\s+(.+)$/);
|
|
33
|
+
if (listItem && activeList) {
|
|
34
|
+
const values = Array.isArray(data[activeList]) ? data[activeList] : [];
|
|
35
|
+
values.push(unquote(listItem[1]));
|
|
36
|
+
data[activeList] = values;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
const field = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/);
|
|
40
|
+
if (!field) {
|
|
41
|
+
if (line.trim())
|
|
42
|
+
throw new Error(`Invalid frontmatter line in ${source}: ${rawLine}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const [, key, value] = field;
|
|
46
|
+
if (value) {
|
|
47
|
+
data[key] = (value.startsWith('[') && value.endsWith(']')) ? parseInlineList(value) : unquote(value);
|
|
48
|
+
activeList = undefined;
|
|
49
|
+
}
|
|
50
|
+
else {
|
|
51
|
+
data[key] = [];
|
|
52
|
+
activeList = key;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const description = typeof data.description === 'string' ? data.description.trim() : '';
|
|
56
|
+
if (!description)
|
|
57
|
+
throw new Error(`Agent spec ${source} requires a description`);
|
|
58
|
+
if (!instructions)
|
|
59
|
+
throw new Error(`Agent spec ${source} requires instructions`);
|
|
60
|
+
const tools = Array.isArray(data.tools) ? data.tools : data.tools ? [String(data.tools)] : [];
|
|
61
|
+
const agents = Array.isArray(data.agents) ? data.agents : data.agents ? [String(data.agents)] : [];
|
|
62
|
+
const model = typeof data.model === 'string' && data.model !== 'inherit' ? data.model : undefined;
|
|
63
|
+
return { id, description, model, tools, agents, instructions, source, scope };
|
|
64
|
+
}
|
|
65
|
+
async function markdownFiles(root) {
|
|
66
|
+
const files = [];
|
|
67
|
+
const visit = async (dir) => {
|
|
68
|
+
let entries;
|
|
69
|
+
try {
|
|
70
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
for (const entry of entries) {
|
|
76
|
+
const path = resolve(dir, entry.name);
|
|
77
|
+
if (entry.isDirectory())
|
|
78
|
+
await visit(path);
|
|
79
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md'))
|
|
80
|
+
files.push(path);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
await visit(root);
|
|
84
|
+
return files.sort();
|
|
85
|
+
}
|
|
86
|
+
function specId(root, file) {
|
|
87
|
+
return relative(root, file).split(sep).join('/').replace(/\.md$/i, '');
|
|
88
|
+
}
|
|
89
|
+
function matchesSelector(id, selector) {
|
|
90
|
+
if (selector === '*')
|
|
91
|
+
return true;
|
|
92
|
+
if (selector.endsWith('/*'))
|
|
93
|
+
return id.startsWith(selector.slice(0, -1));
|
|
94
|
+
return id === selector;
|
|
95
|
+
}
|
|
96
|
+
export class AgentRegistry {
|
|
97
|
+
specs = new Map();
|
|
98
|
+
roots;
|
|
99
|
+
constructor(options = {}) {
|
|
100
|
+
const workspaceRoot = resolve(options.workspaceRoot ?? process.cwd());
|
|
101
|
+
const builtinDir = resolve(options.builtinDir ?? resolve(import.meta.dirname, '..', '..', 'agents'));
|
|
102
|
+
this.roots = [
|
|
103
|
+
{ path: builtinDir, scope: 'builtin' },
|
|
104
|
+
{ path: resolve(options.userDir ?? resolve(homedir(), '.coder', 'agents')), scope: 'user' },
|
|
105
|
+
{ path: resolve(options.projectDir ?? resolve(workspaceRoot, '.coder', 'agents')), scope: 'project' },
|
|
106
|
+
];
|
|
107
|
+
}
|
|
108
|
+
async load() {
|
|
109
|
+
this.specs.clear();
|
|
110
|
+
for (const root of this.roots) {
|
|
111
|
+
for (const file of await markdownFiles(root.path)) {
|
|
112
|
+
const id = specId(root.path, file);
|
|
113
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(id) || id.includes('..')) {
|
|
114
|
+
throw new Error(`Invalid agent id derived from ${file}`);
|
|
115
|
+
}
|
|
116
|
+
this.specs.set(id, parseDocument(await readFile(file, 'utf8'), file, id, root.scope));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (!this.specs.has('main'))
|
|
120
|
+
throw new Error('No main agent spec found');
|
|
121
|
+
}
|
|
122
|
+
get(id) {
|
|
123
|
+
const spec = this.specs.get(id);
|
|
124
|
+
return spec ? { ...spec, tools: [...spec.tools], agents: [...spec.agents] } : undefined;
|
|
125
|
+
}
|
|
126
|
+
list() {
|
|
127
|
+
return [...this.specs.values()].sort((a, b) => a.id.localeCompare(b.id)).map((spec) => this.get(spec.id));
|
|
128
|
+
}
|
|
129
|
+
allowedAgents(specOrId) {
|
|
130
|
+
const spec = typeof specOrId === 'string' ? this.specs.get(specOrId) : specOrId;
|
|
131
|
+
if (!spec)
|
|
132
|
+
return [];
|
|
133
|
+
return this.list().filter((candidate) => (candidate.id !== spec.id && spec.agents.some((selector) => matchesSelector(candidate.id, selector))));
|
|
134
|
+
}
|
|
135
|
+
canCall(from, targetId) {
|
|
136
|
+
return this.allowedAgents(from).some((candidate) => candidate.id === targetId);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
export { matchesSelector as matchesAgentSelector, parseDocument as parseAgentSpec };
|