@nghyane/arcane 0.1.26 → 0.1.28
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/CHANGELOG.md +22 -0
- package/package.json +1 -1
- package/src/exec/bash-executor.ts +8 -8
- package/src/patch/edit-tool.ts +12 -3
- package/src/patch/hashline.ts +11 -9
- package/src/prompts/system/system-prompt.md +10 -0
- package/src/tools/bash-interactive.ts +23 -9
- package/src/tools/bash-interceptor.ts +0 -1
- package/src/tools/bash-normalize.ts +0 -71
- package/src/tools/bash-skill-urls.ts +0 -20
- package/src/tools/bash.ts +4 -20
- package/src/tools/fetch.ts +13 -19
- package/src/tools/github.ts +130 -28
- package/src/tools/index.ts +0 -1
- package/src/web/github-client.ts +17 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.1.28] - 2026-03-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fix edit tool: hashline delete missing `saveForUndo` causing unrecoverable file deletion
|
|
10
|
+
- Fix `any` types on `EditTool` class — replaced with `EditToolDetails`
|
|
11
|
+
- Fix `applyHashlineEdits` mutating caller's input array via splice
|
|
12
|
+
|
|
13
|
+
## [0.1.27] - 2026-03-08
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fix GitHub tool: typed API interfaces replacing `any`, cache key collision with different media types, 403 rate limit not retried, `retry-after` NaN crash, raw file detection returning `[object Object]` for JSON files
|
|
18
|
+
- Fix bash timeout misreported as "Command aborted" instead of "Command timed out"
|
|
19
|
+
- Fix unhandled promise rejection crash in interactive bash PTY finalization
|
|
20
|
+
- Reorder HTML render pipeline to native-first, avoiding unnecessary network calls to jina.ai
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Add GitHub tool guidance to system prompt
|
|
25
|
+
- Remove dead code: `normalizeBashCommand`, `expandSkillUrls`, `BashToolOptions`, `isInteractiveResult`
|
|
26
|
+
|
|
5
27
|
## [0.1.26] - 2026-03-08
|
|
6
28
|
|
|
7
29
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@nghyane/arcane",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.28",
|
|
5
5
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
6
6
|
"homepage": "https://github.com/nghyane/arcane",
|
|
7
7
|
"author": "Can Bölük",
|
|
@@ -27,6 +27,7 @@ export interface BashResult {
|
|
|
27
27
|
output: string;
|
|
28
28
|
exitCode: number | undefined;
|
|
29
29
|
cancelled: boolean;
|
|
30
|
+
timedOut: boolean;
|
|
30
31
|
truncated: boolean;
|
|
31
32
|
totalLines: number;
|
|
32
33
|
totalBytes: number;
|
|
@@ -44,11 +45,8 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
44
45
|
const { shell, env: shellEnv, prefix } = settings.getShellConfig();
|
|
45
46
|
const snapshotPath = shell.includes("bash") ? await getOrCreateSnapshot(shell, shellEnv) : null;
|
|
46
47
|
|
|
47
|
-
// Apply command prefix if configured
|
|
48
48
|
const prefixedCommand = prefix ? `${prefix} ${command}` : command;
|
|
49
|
-
const finalCommand = prefixedCommand;
|
|
50
49
|
|
|
51
|
-
// Create output sink for truncation and artifact handling
|
|
52
50
|
const sink = new OutputSink({
|
|
53
51
|
onChunk: options?.onChunk,
|
|
54
52
|
artifactPath: options?.artifactPath,
|
|
@@ -64,6 +62,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
64
62
|
return {
|
|
65
63
|
exitCode: undefined,
|
|
66
64
|
cancelled: true,
|
|
65
|
+
timedOut: false,
|
|
67
66
|
...(await sink.dump("Command cancelled")),
|
|
68
67
|
};
|
|
69
68
|
}
|
|
@@ -96,7 +95,7 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
96
95
|
try {
|
|
97
96
|
const runPromise = shellSession.run(
|
|
98
97
|
{
|
|
99
|
-
command:
|
|
98
|
+
command: prefixedCommand,
|
|
100
99
|
cwd: options?.cwd,
|
|
101
100
|
env: options?.env ? { ...NON_INTERACTIVE_ENV, ...options.env } : NON_INTERACTIVE_ENV,
|
|
102
101
|
timeoutMs: options?.timeout,
|
|
@@ -121,11 +120,11 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
121
120
|
return {
|
|
122
121
|
exitCode: undefined,
|
|
123
122
|
cancelled: true,
|
|
123
|
+
timedOut: true,
|
|
124
124
|
...(await sink.dump(`Command exceeded hard timeout after ${Math.round(hardTimeoutMs / 1000)} seconds`)),
|
|
125
125
|
};
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
// Handle timeout
|
|
129
128
|
if (winner.result.timedOut) {
|
|
130
129
|
const annotation = options?.timeout
|
|
131
130
|
? `Command timed out after ${Math.round(options.timeout / 1000)} seconds`
|
|
@@ -133,25 +132,26 @@ export async function executeBash(command: string, options?: BashExecutorOptions
|
|
|
133
132
|
resetSession = true;
|
|
134
133
|
return {
|
|
135
134
|
exitCode: undefined,
|
|
136
|
-
cancelled:
|
|
135
|
+
cancelled: false,
|
|
136
|
+
timedOut: true,
|
|
137
137
|
...(await sink.dump(annotation)),
|
|
138
138
|
};
|
|
139
139
|
}
|
|
140
140
|
|
|
141
|
-
// Handle cancellation
|
|
142
141
|
if (winner.result.cancelled) {
|
|
143
142
|
resetSession = true;
|
|
144
143
|
return {
|
|
145
144
|
exitCode: undefined,
|
|
146
145
|
cancelled: true,
|
|
146
|
+
timedOut: false,
|
|
147
147
|
...(await sink.dump("Command cancelled")),
|
|
148
148
|
};
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
-
// Normal completion
|
|
152
151
|
return {
|
|
153
152
|
exitCode: winner.result.exitCode,
|
|
154
153
|
cancelled: false,
|
|
154
|
+
timedOut: false,
|
|
155
155
|
...(await sink.dump()),
|
|
156
156
|
};
|
|
157
157
|
} catch (err) {
|
package/src/patch/edit-tool.ts
CHANGED
|
@@ -134,13 +134,21 @@ function mergeDiagnosticsWithWarnings(
|
|
|
134
134
|
};
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
-
export class EditTool implements AgentTool<TInput,
|
|
137
|
+
export class EditTool implements AgentTool<TInput, EditToolDetails, Theme> {
|
|
138
138
|
readonly name = "edit";
|
|
139
139
|
readonly label = "Edit";
|
|
140
140
|
readonly nonAbortable = true;
|
|
141
141
|
readonly concurrency = "exclusive";
|
|
142
|
-
readonly renderCall = editToolRenderer.renderCall as unknown as AgentTool<
|
|
143
|
-
|
|
142
|
+
readonly renderCall = editToolRenderer.renderCall as unknown as AgentTool<
|
|
143
|
+
TInput,
|
|
144
|
+
EditToolDetails,
|
|
145
|
+
Theme
|
|
146
|
+
>["renderCall"];
|
|
147
|
+
readonly renderResult = editToolRenderer.renderResult as unknown as AgentTool<
|
|
148
|
+
TInput,
|
|
149
|
+
EditToolDetails,
|
|
150
|
+
Theme
|
|
151
|
+
>["renderResult"];
|
|
144
152
|
|
|
145
153
|
readonly #allowFuzzy: boolean;
|
|
146
154
|
readonly #fuzzyThreshold: number;
|
|
@@ -252,6 +260,7 @@ export class EditTool implements AgentTool<TInput, any, Theme> {
|
|
|
252
260
|
|
|
253
261
|
if (deleteFile) {
|
|
254
262
|
if (await file.exists()) {
|
|
263
|
+
saveForUndo(absolutePath, await file.text());
|
|
255
264
|
await file.unlink();
|
|
256
265
|
}
|
|
257
266
|
invalidateFsScanAfterDelete(absolutePath);
|
package/src/patch/hashline.ts
CHANGED
|
@@ -592,7 +592,7 @@ function autocorrectEscapedTabs(lines: string[]): string[] {
|
|
|
592
592
|
*/
|
|
593
593
|
export function applyHashlineEdits(
|
|
594
594
|
content: string,
|
|
595
|
-
edits: HashlineEdit[],
|
|
595
|
+
edits: readonly HashlineEdit[],
|
|
596
596
|
): {
|
|
597
597
|
content: string;
|
|
598
598
|
firstChangedLine: number | undefined;
|
|
@@ -603,6 +603,8 @@ export function applyHashlineEdits(
|
|
|
603
603
|
return { content, firstChangedLine: undefined };
|
|
604
604
|
}
|
|
605
605
|
|
|
606
|
+
const mutableEdits: HashlineEdit[] = edits.map(e => ({ ...e, content: [...e.content] }));
|
|
607
|
+
|
|
606
608
|
const fileLines = content.split("\n");
|
|
607
609
|
const hadFinalNewline = content.endsWith("\n");
|
|
608
610
|
const originalFileLines = [...fileLines];
|
|
@@ -612,7 +614,7 @@ export function applyHashlineEdits(
|
|
|
612
614
|
const autocorrect = Bun.env.ARCANE_HL_AUTOCORRECT === "1";
|
|
613
615
|
|
|
614
616
|
const warnings: string[] = [];
|
|
615
|
-
for (const edit of
|
|
617
|
+
for (const edit of mutableEdits) {
|
|
616
618
|
const unicodeWarning = detectUnicodeEscapePlaceholders(edit.content);
|
|
617
619
|
if (unicodeWarning && !warnings.includes(unicodeWarning)) {
|
|
618
620
|
warnings.push(unicodeWarning);
|
|
@@ -622,7 +624,7 @@ export function applyHashlineEdits(
|
|
|
622
624
|
|
|
623
625
|
function collectExplicitlyTouchedLines(): Set<number> {
|
|
624
626
|
const touched = new Set<number>();
|
|
625
|
-
for (const edit of
|
|
627
|
+
for (const edit of mutableEdits) {
|
|
626
628
|
switch (edit.op) {
|
|
627
629
|
case "replace":
|
|
628
630
|
touched.add(edit.target.line);
|
|
@@ -652,7 +654,7 @@ export function applyHashlineEdits(
|
|
|
652
654
|
mismatches.push({ line: ref.line, expected: ref.hash, actual: actualHash });
|
|
653
655
|
return false;
|
|
654
656
|
}
|
|
655
|
-
for (const edit of
|
|
657
|
+
for (const edit of mutableEdits) {
|
|
656
658
|
switch (edit.op) {
|
|
657
659
|
case "replace": {
|
|
658
660
|
if (!validateRef(edit.target)) continue;
|
|
@@ -678,8 +680,8 @@ export function applyHashlineEdits(
|
|
|
678
680
|
}
|
|
679
681
|
const seenEditKeys = new Map<string, number>();
|
|
680
682
|
const dedupIndices = new Set<number>();
|
|
681
|
-
for (let i = 0; i <
|
|
682
|
-
const edit =
|
|
683
|
+
for (let i = 0; i < mutableEdits.length; i++) {
|
|
684
|
+
const edit = mutableEdits[i];
|
|
683
685
|
let lineKey: string;
|
|
684
686
|
switch (edit.op) {
|
|
685
687
|
case "replace":
|
|
@@ -697,13 +699,13 @@ export function applyHashlineEdits(
|
|
|
697
699
|
}
|
|
698
700
|
}
|
|
699
701
|
if (dedupIndices.size > 0) {
|
|
700
|
-
for (let i =
|
|
701
|
-
if (dedupIndices.has(i))
|
|
702
|
+
for (let i = mutableEdits.length - 1; i >= 0; i--) {
|
|
703
|
+
if (dedupIndices.has(i)) mutableEdits.splice(i, 1);
|
|
702
704
|
}
|
|
703
705
|
}
|
|
704
706
|
|
|
705
707
|
// Compute sort key (descending) — bottom-up application
|
|
706
|
-
const annotated =
|
|
708
|
+
const annotated = mutableEdits.map((edit, idx) => {
|
|
707
709
|
let sortLine: number;
|
|
708
710
|
let precedence: number;
|
|
709
711
|
switch (edit.op) {
|
|
@@ -182,6 +182,16 @@ Tools: `find_thread`, `read_thread`
|
|
|
182
182
|
- Generic coding question with no project-specific history
|
|
183
183
|
- User explicitly provides all needed context
|
|
184
184
|
|
|
185
|
+
{{#has tools "github"}}
|
|
186
|
+
### GitHub
|
|
187
|
+
|
|
188
|
+
Tool: `github`
|
|
189
|
+
Use for **remote** GitHub API calls — repos, issues, PRs, commits, file contents, tree listings. Never use for the local repo; use read/grep/explore for local files.
|
|
190
|
+
- **Use `github`** for: reading issues/PRs, listing commits, fetching files from remote repos, searching repos, getting repo metadata.
|
|
191
|
+
- **Use `librarian`** for: cross-repo exploration, understanding architectural patterns, tracing how other projects solve problems.
|
|
192
|
+
- **Avoid `bash gh`** when `github` covers the action — the tool has caching, pagination, and structured output.
|
|
193
|
+
{{/has}}
|
|
194
|
+
|
|
185
195
|
### Verification
|
|
186
196
|
Work incrementally. Make a small change, verify it works, then continue. Prefer a sequence of small, validated edits over one large change. Use commands from AGENTS.md or the project's config to verify. Address all errors caused by your changes before yielding.
|
|
187
197
|
|
|
@@ -303,15 +303,29 @@ export async function runInteractiveBashPty(
|
|
|
303
303
|
component.setComplete({ exitCode: run.exitCode, cancelled: run.cancelled, timedOut: run.timedOut });
|
|
304
304
|
tui.requestRender();
|
|
305
305
|
void (async () => {
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
306
|
+
try {
|
|
307
|
+
await component.flushOutput();
|
|
308
|
+
await pendingChunks;
|
|
309
|
+
const summary = await sink.dump();
|
|
310
|
+
done({
|
|
311
|
+
exitCode: run.exitCode,
|
|
312
|
+
cancelled: run.cancelled,
|
|
313
|
+
timedOut: run.timedOut,
|
|
314
|
+
...summary,
|
|
315
|
+
});
|
|
316
|
+
} catch {
|
|
317
|
+
done({
|
|
318
|
+
exitCode: run.exitCode,
|
|
319
|
+
cancelled: run.cancelled,
|
|
320
|
+
timedOut: run.timedOut,
|
|
321
|
+
output: "",
|
|
322
|
+
truncated: false,
|
|
323
|
+
totalLines: 0,
|
|
324
|
+
totalBytes: 0,
|
|
325
|
+
outputLines: 0,
|
|
326
|
+
outputBytes: 0,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
315
329
|
})();
|
|
316
330
|
};
|
|
317
331
|
const cols = Math.max(20, tui.terminal.columns - 2);
|
|
@@ -82,7 +82,6 @@ export function checkBashInterception(
|
|
|
82
82
|
availableTools: string[],
|
|
83
83
|
rules: BashInterceptorRule[] = DEFAULT_BASH_INTERCEPTOR_RULES,
|
|
84
84
|
): InterceptionResult {
|
|
85
|
-
// Normalize command for pattern matching
|
|
86
85
|
const normalizedCommand = command.trim();
|
|
87
86
|
const compiled = compileRules(rules);
|
|
88
87
|
|
|
@@ -1,72 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Bash command normalizer - extracts patterns that are better handled natively.
|
|
3
|
-
*
|
|
4
|
-
* Detects and extracts:
|
|
5
|
-
* - `| head -n N` / `| head -N` - extracted to headLines
|
|
6
|
-
* - `| tail -n N` / `| tail -N` - extracted to tailLines
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
interface NormalizedCommand {
|
|
10
|
-
/** Cleaned command with patterns stripped */
|
|
11
|
-
command: string;
|
|
12
|
-
/** Extracted head line count, if any */
|
|
13
|
-
headLines?: number;
|
|
14
|
-
/** Extracted tail line count, if any */
|
|
15
|
-
tailLines?: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
/**
|
|
19
|
-
* Pattern to match trailing pipe to head/tail.
|
|
20
|
-
* Captures: full match, command (head/tail), line count
|
|
21
|
-
*
|
|
22
|
-
* Matches:
|
|
23
|
-
* - `| head -n 50`
|
|
24
|
-
* - `| head -50`
|
|
25
|
-
* - `| tail -n 100`
|
|
26
|
-
* - `| tail -100`
|
|
27
|
-
*
|
|
28
|
-
* Does NOT match head/tail with other flags or without line count.
|
|
29
|
-
*/
|
|
30
|
-
const TRAILING_HEAD_TAIL_PATTERN = /\|\s*(head|tail)\s+(?:-n\s*(\d+)|(-\d+))\s*$/;
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Normalize a bash command by stripping patterns better handled natively.
|
|
34
|
-
*
|
|
35
|
-
* Extracts `| head -n N` and `| tail -n N` suffixes into separate fields
|
|
36
|
-
* so they can be applied post-execution without breaking streaming.
|
|
37
|
-
*
|
|
38
|
-
* Strips `2>&1` since we already merge stdout/stderr.
|
|
39
|
-
*/
|
|
40
|
-
export function normalizeBashCommand(command: string): NormalizedCommand {
|
|
41
|
-
let normalized = command;
|
|
42
|
-
let headLines: number | undefined;
|
|
43
|
-
let tailLines: number | undefined;
|
|
44
|
-
|
|
45
|
-
// Extract trailing head/tail
|
|
46
|
-
const match = normalized.match(TRAILING_HEAD_TAIL_PATTERN);
|
|
47
|
-
if (match) {
|
|
48
|
-
const [fullMatch, cmd, nValue, dashValue] = match;
|
|
49
|
-
const lineCount = nValue ? Number.parseInt(nValue, 10) : Number.parseInt(dashValue.slice(1), 10);
|
|
50
|
-
|
|
51
|
-
if (cmd === "head") {
|
|
52
|
-
headLines = lineCount;
|
|
53
|
-
} else {
|
|
54
|
-
tailLines = lineCount;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
normalized = normalized.slice(0, -fullMatch.length);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// Preserve internal whitespace (important for heredocs / indentation-sensitive scripts)
|
|
61
|
-
normalized = normalized.trim();
|
|
62
|
-
|
|
63
|
-
return {
|
|
64
|
-
command: normalized,
|
|
65
|
-
headLines,
|
|
66
|
-
tailLines,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
1
|
/**
|
|
71
2
|
* Apply head/tail limits to output text.
|
|
72
3
|
*
|
|
@@ -86,13 +17,11 @@ export function applyHeadTail(
|
|
|
86
17
|
let headApplied: number | undefined;
|
|
87
18
|
let tailApplied: number | undefined;
|
|
88
19
|
|
|
89
|
-
// Apply head first (keep first N lines)
|
|
90
20
|
if (headLines !== undefined && headLines > 0 && lines.length > headLines) {
|
|
91
21
|
lines = lines.slice(0, headLines);
|
|
92
22
|
headApplied = headLines;
|
|
93
23
|
}
|
|
94
24
|
|
|
95
|
-
// Then apply tail (keep last N lines)
|
|
96
25
|
if (tailLines !== undefined && tailLines > 0 && lines.length > tailLines) {
|
|
97
26
|
lines = lines.slice(-tailLines);
|
|
98
27
|
tailApplied = tailLines;
|
|
@@ -4,9 +4,6 @@ import { validateRelativePath } from "../internal-urls/skill-protocol";
|
|
|
4
4
|
import type { InternalResource } from "../internal-urls/types";
|
|
5
5
|
import { ToolError } from "./tool-errors";
|
|
6
6
|
|
|
7
|
-
/** Regex to find skill:// tokens in command text. */
|
|
8
|
-
const SKILL_URL_PATTERN = /'skill:\/\/[^'\s")`\\]+'|"skill:\/\/[^"\s')`\\]+"|skill:\/\/[^\s'")`\\]+/g;
|
|
9
|
-
|
|
10
7
|
/** Regex to find supported internal URL tokens in command text. */
|
|
11
8
|
const INTERNAL_URL_PATTERN =
|
|
12
9
|
/'(?:skill|agent|artifact|plan|rule):\/\/[^'\s")`\\]+'|"(?:skill|agent|artifact|plan|rule):\/\/[^"\s')`\\]+"|(?:skill|agent|artifact|plan|rule):\/\/[^\s'")`\\]+/g;
|
|
@@ -133,23 +130,6 @@ async function resolveInternalUrlToPath(
|
|
|
133
130
|
return path.resolve(resource.sourcePath);
|
|
134
131
|
}
|
|
135
132
|
|
|
136
|
-
/**
|
|
137
|
-
* Expand all skill:// URIs in a bash command string.
|
|
138
|
-
* Returns the command with URIs replaced by shell-escaped absolute paths.
|
|
139
|
-
* Throws ToolError if any URI cannot be resolved.
|
|
140
|
-
*/
|
|
141
|
-
export function expandSkillUrls(command: string, skills: readonly Skill[]): string {
|
|
142
|
-
if (skills.length === 0 || !command.includes("skill://")) {
|
|
143
|
-
return command;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
return command.replace(SKILL_URL_PATTERN, token => {
|
|
147
|
-
const url = unquoteToken(token);
|
|
148
|
-
const resolvedPath = resolveSkillUrlToPath(url, skills);
|
|
149
|
-
return shellEscape(resolvedPath);
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
|
|
153
133
|
/**
|
|
154
134
|
* Expand supported internal URLs in a bash command string to shell-escaped absolute paths.
|
|
155
135
|
* Supported schemes: skill://, agent://, artifact://, plan://, rule://
|
package/src/tools/bash.ts
CHANGED
|
@@ -38,20 +38,10 @@ export interface BashToolDetails {
|
|
|
38
38
|
meta?: OutputMeta;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export interface BashToolOptions {}
|
|
42
|
-
|
|
43
41
|
function normalizeResultOutput(result: BashResult | BashInteractiveResult): string {
|
|
44
42
|
return result.output || "";
|
|
45
43
|
}
|
|
46
44
|
|
|
47
|
-
function isInteractiveResult(result: BashResult | BashInteractiveResult): result is BashInteractiveResult {
|
|
48
|
-
return "timedOut" in result;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Bash tool implementation.
|
|
52
|
-
*
|
|
53
|
-
* Executes bash commands with optional timeout and working directory.
|
|
54
|
-
*/
|
|
55
45
|
export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, Theme> {
|
|
56
46
|
readonly name = "bash";
|
|
57
47
|
readonly label = "Bash";
|
|
@@ -71,11 +61,9 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
|
|
|
71
61
|
): Promise<AgentToolResult<BashToolDetails>> {
|
|
72
62
|
let command = rawCommand;
|
|
73
63
|
|
|
74
|
-
// Only apply explicit head/tail params from tool input.
|
|
75
64
|
const headLines = head;
|
|
76
65
|
const tailLines = tail;
|
|
77
66
|
|
|
78
|
-
// Check interception if enabled and available tools are known
|
|
79
67
|
if (this.session.settings.get("bashInterceptor.enabled")) {
|
|
80
68
|
const rules = this.session.settings.getBashInterceptorRules();
|
|
81
69
|
const interception = checkBashInterception(command, ctx?.toolNames ?? [], rules);
|
|
@@ -103,14 +91,11 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
|
|
|
103
91
|
throw new ToolError(`Working directory is not a directory: ${commandCwd}`);
|
|
104
92
|
}
|
|
105
93
|
|
|
106
|
-
// Clamp to reasonable range: 1s - 3600s (1 hour)
|
|
107
94
|
const timeoutSec = Math.max(1, Math.min(3600, rawTimeout));
|
|
108
95
|
const timeoutMs = timeoutSec * 1000;
|
|
109
96
|
|
|
110
|
-
// Track output for streaming updates (tail only)
|
|
111
97
|
const tailBuffer = createTailBuffer(DEFAULT_MAX_BYTES);
|
|
112
98
|
|
|
113
|
-
// Set up artifacts environment and allocation
|
|
114
99
|
const artifactsDir = this.session.getArtifactsDir?.();
|
|
115
100
|
const extraEnv = artifactsDir ? { ARTIFACTS: artifactsDir } : undefined;
|
|
116
101
|
const { artifactPath, artifactId } = await allocateOutputArtifact(this.session, "bash");
|
|
@@ -148,16 +133,15 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
|
|
|
148
133
|
}
|
|
149
134
|
},
|
|
150
135
|
});
|
|
136
|
+
if (result.timedOut) {
|
|
137
|
+
throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
|
|
138
|
+
}
|
|
151
139
|
if (result.cancelled) {
|
|
152
140
|
if (signal?.aborted) {
|
|
153
141
|
throw new ToolAbortError(normalizeResultOutput(result) || "Command aborted");
|
|
154
142
|
}
|
|
155
143
|
throw new ToolError(normalizeResultOutput(result) || "Command aborted");
|
|
156
144
|
}
|
|
157
|
-
if (isInteractiveResult(result) && result.timedOut) {
|
|
158
|
-
throw new ToolError(normalizeResultOutput(result) || `Command timed out after ${timeoutSec} seconds`);
|
|
159
|
-
}
|
|
160
|
-
// Apply head/tail filtering if specified
|
|
161
145
|
let outputText = normalizeResultOutput(result);
|
|
162
146
|
const headTailResult = applyHeadTail(outputText, headLines, tailLines);
|
|
163
147
|
if (headTailResult.applied) {
|
|
@@ -171,7 +155,7 @@ export class BashTool implements AgentTool<typeof bashSchema, BashToolDetails, T
|
|
|
171
155
|
if (result.exitCode === undefined) {
|
|
172
156
|
throw new ToolError(`${outputText}\n\nCommand failed: missing exit status`);
|
|
173
157
|
}
|
|
174
|
-
if (result.exitCode !== 0
|
|
158
|
+
if (result.exitCode !== 0) {
|
|
175
159
|
throw new ToolError(`${outputText}\n\nCommand exited with code ${result.exitCode}`);
|
|
176
160
|
}
|
|
177
161
|
|
package/src/tools/fetch.ts
CHANGED
|
@@ -399,25 +399,15 @@ async function renderHtmlToText(
|
|
|
399
399
|
signal,
|
|
400
400
|
};
|
|
401
401
|
|
|
402
|
-
// Try jina first (reader API)
|
|
403
402
|
try {
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
signal,
|
|
408
|
-
});
|
|
409
|
-
if (response.ok) {
|
|
410
|
-
const content = await response.text();
|
|
411
|
-
if (content.trim().length > 100 && !isLowQualityOutput(content)) {
|
|
412
|
-
return { content, ok: true, method: "jina" };
|
|
413
|
-
}
|
|
403
|
+
const content = await htmlToMarkdown(html, { cleanContent: true });
|
|
404
|
+
if (content.trim().length > 100 && !isLowQualityOutput(content)) {
|
|
405
|
+
return { content, ok: true, method: "native" };
|
|
414
406
|
}
|
|
415
407
|
} catch {
|
|
416
|
-
// Jina failed, continue to next method
|
|
417
408
|
signal?.throwIfAborted();
|
|
418
409
|
}
|
|
419
410
|
|
|
420
|
-
// Try trafilatura (auto-install via uv/pip)
|
|
421
411
|
const trafilatura = await ensureTool("trafilatura", { signal, silent: true });
|
|
422
412
|
if (trafilatura) {
|
|
423
413
|
const result = await ptree.exec([trafilatura, "-u", url, "--output-format", "markdown"], execOptions);
|
|
@@ -426,7 +416,6 @@ async function renderHtmlToText(
|
|
|
426
416
|
}
|
|
427
417
|
}
|
|
428
418
|
|
|
429
|
-
// Try lynx (can't auto-install, system package)
|
|
430
419
|
const lynx = hasCommand("lynx");
|
|
431
420
|
if (lynx) {
|
|
432
421
|
const result = await ptree.exec(["lynx", "-dump", "-nolist", "-width", "250", url], execOptions);
|
|
@@ -435,14 +424,19 @@ async function renderHtmlToText(
|
|
|
435
424
|
}
|
|
436
425
|
}
|
|
437
426
|
|
|
438
|
-
// Fall back to native converter (fastest, no network/subprocess)
|
|
439
427
|
try {
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
428
|
+
const jinaUrl = `https://r.jina.ai/${url}`;
|
|
429
|
+
const response = await fetch(jinaUrl, {
|
|
430
|
+
headers: { Accept: "text/markdown" },
|
|
431
|
+
signal,
|
|
432
|
+
});
|
|
433
|
+
if (response.ok) {
|
|
434
|
+
const content = await response.text();
|
|
435
|
+
if (content.trim().length > 100 && !isLowQualityOutput(content)) {
|
|
436
|
+
return { content, ok: true, method: "jina" };
|
|
437
|
+
}
|
|
443
438
|
}
|
|
444
439
|
} catch {
|
|
445
|
-
// Native converter failed, continue to next method
|
|
446
440
|
signal?.throwIfAborted();
|
|
447
441
|
}
|
|
448
442
|
return { content: "", ok: false, method: "none" };
|
package/src/tools/github.ts
CHANGED
|
@@ -6,9 +6,107 @@ import type { ToolSession } from ".";
|
|
|
6
6
|
import { type OutputMeta, toolResult } from "./output-meta";
|
|
7
7
|
|
|
8
8
|
// =============================================================================
|
|
9
|
-
//
|
|
9
|
+
// GitHub API Types
|
|
10
10
|
// =============================================================================
|
|
11
11
|
|
|
12
|
+
interface GitHubUser {
|
|
13
|
+
login: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface GitHubLabel {
|
|
17
|
+
name: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface GitHubLicense {
|
|
21
|
+
spdx_id: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface GitHubRepo {
|
|
25
|
+
full_name: string;
|
|
26
|
+
description: string | null;
|
|
27
|
+
default_branch: string;
|
|
28
|
+
language: string | null;
|
|
29
|
+
stargazers_count: number;
|
|
30
|
+
forks_count: number;
|
|
31
|
+
topics: string[];
|
|
32
|
+
license: GitHubLicense | null;
|
|
33
|
+
created_at: string;
|
|
34
|
+
updated_at: string;
|
|
35
|
+
homepage: string | null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface GitHubTreeEntry {
|
|
39
|
+
type: string;
|
|
40
|
+
path?: string;
|
|
41
|
+
name?: string;
|
|
42
|
+
size?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface GitHubIssue {
|
|
46
|
+
number: number;
|
|
47
|
+
title: string;
|
|
48
|
+
state: string;
|
|
49
|
+
user: GitHubUser | null;
|
|
50
|
+
labels: GitHubLabel[];
|
|
51
|
+
assignees: GitHubUser[];
|
|
52
|
+
body: string | null;
|
|
53
|
+
created_at: string;
|
|
54
|
+
pull_request?: unknown;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
interface GitHubComment {
|
|
58
|
+
user: GitHubUser | null;
|
|
59
|
+
created_at: string;
|
|
60
|
+
body: string | null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface GitHubPR {
|
|
64
|
+
number: number;
|
|
65
|
+
title: string;
|
|
66
|
+
state: string;
|
|
67
|
+
merged: boolean;
|
|
68
|
+
merged_at: string | null;
|
|
69
|
+
user: GitHubUser | null;
|
|
70
|
+
base: { ref: string };
|
|
71
|
+
head: { ref: string };
|
|
72
|
+
changed_files: number | null;
|
|
73
|
+
additions: number;
|
|
74
|
+
deletions: number;
|
|
75
|
+
body: string | null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface GitHubCommitAuthor {
|
|
79
|
+
name: string;
|
|
80
|
+
email: string;
|
|
81
|
+
date: string;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface GitHubCommitFile {
|
|
85
|
+
status: string;
|
|
86
|
+
filename: string;
|
|
87
|
+
additions: number;
|
|
88
|
+
deletions: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface GitHubCommit {
|
|
92
|
+
sha: string;
|
|
93
|
+
commit: {
|
|
94
|
+
author: GitHubCommitAuthor;
|
|
95
|
+
message: string;
|
|
96
|
+
};
|
|
97
|
+
author: GitHubUser | null;
|
|
98
|
+
stats?: { total: number; additions: number; deletions: number };
|
|
99
|
+
files?: GitHubCommitFile[];
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
interface GitHubSearchResult<T> {
|
|
103
|
+
total_count: number;
|
|
104
|
+
items: T[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// =============================================================================
|
|
108
|
+
// Schema
|
|
109
|
+
// =============================================================================
|
|
12
110
|
const ActionEnum = Type.Union([
|
|
13
111
|
Type.Literal("get_repo"),
|
|
14
112
|
Type.Literal("get_file"),
|
|
@@ -55,7 +153,7 @@ export interface GitHubToolDetails {
|
|
|
55
153
|
// Response Formatters
|
|
56
154
|
// =============================================================================
|
|
57
155
|
|
|
58
|
-
function formatRepo(data:
|
|
156
|
+
function formatRepo(data: GitHubRepo): string {
|
|
59
157
|
return [
|
|
60
158
|
`# ${data.full_name}`,
|
|
61
159
|
data.description ? `${data.description}` : "",
|
|
@@ -72,7 +170,7 @@ function formatRepo(data: any): string {
|
|
|
72
170
|
.join("\n");
|
|
73
171
|
}
|
|
74
172
|
|
|
75
|
-
function formatTreeEntry(entry:
|
|
173
|
+
function formatTreeEntry(entry: GitHubTreeEntry): string {
|
|
76
174
|
const icon = entry.type === "dir" || entry.type === "tree" ? "dir" : "file";
|
|
77
175
|
const size = entry.size ? ` (${formatSize(entry.size)})` : "";
|
|
78
176
|
const name = entry.path ?? entry.name;
|
|
@@ -85,12 +183,12 @@ function formatSize(bytes: number): string {
|
|
|
85
183
|
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
86
184
|
}
|
|
87
185
|
|
|
88
|
-
function formatIssue(data:
|
|
186
|
+
function formatIssue(data: GitHubIssue, comments: GitHubComment[] = []): string {
|
|
89
187
|
const lines = [
|
|
90
188
|
`# #${data.number}: ${data.title}`,
|
|
91
189
|
`State: ${data.state} | Author: @${data.user?.login} | Created: ${data.created_at}`,
|
|
92
|
-
data.labels?.length ? `Labels: ${data.labels.map(
|
|
93
|
-
data.assignees?.length ? `Assignees: ${data.assignees.map(
|
|
190
|
+
data.labels?.length ? `Labels: ${data.labels.map(l => l.name).join(", ")}` : "",
|
|
191
|
+
data.assignees?.length ? `Assignees: ${data.assignees.map(a => `@${a.login}`).join(", ")}` : "",
|
|
94
192
|
"",
|
|
95
193
|
data.body ?? "(no description)",
|
|
96
194
|
].filter(l => l !== "");
|
|
@@ -102,12 +200,12 @@ function formatIssue(data: any, comments: any[] = []): string {
|
|
|
102
200
|
return lines.join("\n");
|
|
103
201
|
}
|
|
104
202
|
|
|
105
|
-
function formatIssueMinimal(issue:
|
|
106
|
-
const labels = issue.labels?.length ? ` [${issue.labels.map(
|
|
203
|
+
function formatIssueMinimal(issue: GitHubIssue): string {
|
|
204
|
+
const labels = issue.labels?.length ? ` [${issue.labels.map(l => l.name).join(", ")}]` : "";
|
|
107
205
|
return `#${issue.number} [${issue.state}] ${issue.title}${labels} (@${issue.user?.login}, ${issue.created_at})`;
|
|
108
206
|
}
|
|
109
207
|
|
|
110
|
-
function formatPR(data:
|
|
208
|
+
function formatPR(data: GitHubPR, diff?: string): string {
|
|
111
209
|
const lines = [
|
|
112
210
|
`# PR #${data.number}: ${data.title}`,
|
|
113
211
|
`State: ${data.state}${data.merged ? " (merged)" : ""} | Author: @${data.user?.login}`,
|
|
@@ -124,12 +222,12 @@ function formatPR(data: any, diff?: string): string {
|
|
|
124
222
|
return lines.join("\n");
|
|
125
223
|
}
|
|
126
224
|
|
|
127
|
-
function formatPRMinimal(pr:
|
|
225
|
+
function formatPRMinimal(pr: GitHubPR): string {
|
|
128
226
|
const merged = pr.merged_at ? " (merged)" : "";
|
|
129
227
|
return `#${pr.number} [${pr.state}${merged}] ${pr.title} (@${pr.user?.login}, ${pr.base?.ref} <- ${pr.head?.ref})`;
|
|
130
228
|
}
|
|
131
229
|
|
|
132
|
-
function formatCommit(data:
|
|
230
|
+
function formatCommit(data: GitHubCommit, diff?: string): string {
|
|
133
231
|
const lines = [
|
|
134
232
|
`Commit: ${data.sha}`,
|
|
135
233
|
`Author: ${data.commit?.author?.name} <${data.commit?.author?.email}>`,
|
|
@@ -156,7 +254,7 @@ function formatCommit(data: any, diff?: string): string {
|
|
|
156
254
|
return lines.join("\n");
|
|
157
255
|
}
|
|
158
256
|
|
|
159
|
-
function formatCommitMinimal(c:
|
|
257
|
+
function formatCommitMinimal(c: GitHubCommit): string {
|
|
160
258
|
const sha = (c.sha ?? "").slice(0, 7);
|
|
161
259
|
const msg = (c.commit?.message ?? "").split("\n")[0];
|
|
162
260
|
const author = c.commit?.author?.name ?? c.author?.login ?? "?";
|
|
@@ -164,7 +262,7 @@ function formatCommitMinimal(c: any): string {
|
|
|
164
262
|
return `${sha} ${msg} (${author}, ${date})`;
|
|
165
263
|
}
|
|
166
264
|
|
|
167
|
-
function formatSearchReposResult(data:
|
|
265
|
+
function formatSearchReposResult(data: GitHubSearchResult<GitHubRepo>): string {
|
|
168
266
|
const items = data.items ?? [];
|
|
169
267
|
const lines = [`Found ${data.total_count} repositories (showing ${items.length}):`, ""];
|
|
170
268
|
for (const item of items) {
|
|
@@ -188,7 +286,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
188
286
|
|
|
189
287
|
switch (action) {
|
|
190
288
|
case "get_repo": {
|
|
191
|
-
const res = await githubClient.request(base, opts);
|
|
289
|
+
const res = await githubClient.request<GitHubRepo>(base, opts);
|
|
192
290
|
if (!res.ok) return error(res, "repository");
|
|
193
291
|
return { text: formatRepo(res.data), url: `https://github.com/${owner}/${repo}` };
|
|
194
292
|
}
|
|
@@ -216,7 +314,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
216
314
|
blobRes.data.encoding === "base64"
|
|
217
315
|
? Buffer.from(blobRes.data.content, "base64").toString("utf-8")
|
|
218
316
|
: blobRes.data.content;
|
|
219
|
-
res = { data: decoded as
|
|
317
|
+
res = { data: decoded as string, ok: true, status: 200 };
|
|
220
318
|
}
|
|
221
319
|
}
|
|
222
320
|
}
|
|
@@ -236,10 +334,13 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
236
334
|
const treePath = input.path ?? "";
|
|
237
335
|
if (input.recursive) {
|
|
238
336
|
const ref = input.ref ?? "HEAD";
|
|
239
|
-
const res = await githubClient.request<
|
|
337
|
+
const res = await githubClient.request<{ tree: GitHubTreeEntry[]; truncated: boolean }>(
|
|
338
|
+
`${base}/git/trees/${ref}?recursive=1`,
|
|
339
|
+
opts,
|
|
340
|
+
);
|
|
240
341
|
if (!res.ok) return error(res, "tree");
|
|
241
342
|
const entries = (res.data.tree ?? [])
|
|
242
|
-
.filter(
|
|
343
|
+
.filter(e => !treePath || (e.path ?? "").startsWith(treePath))
|
|
243
344
|
.slice(0, 500);
|
|
244
345
|
return {
|
|
245
346
|
text: `# Tree: ${owner}/${repo}${treePath ? `/${treePath}` : ""} (recursive)\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
@@ -247,7 +348,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
247
348
|
}
|
|
248
349
|
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
249
350
|
const endpoint = treePath ? `${base}/contents/${treePath}${ref}` : `${base}/contents${ref}`;
|
|
250
|
-
const res = await githubClient.request<
|
|
351
|
+
const res = await githubClient.request<GitHubTreeEntry[]>(endpoint, opts);
|
|
251
352
|
if (!res.ok) return error(res, "directory");
|
|
252
353
|
const entries = Array.isArray(res.data) ? res.data : [res.data];
|
|
253
354
|
return {
|
|
@@ -258,7 +359,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
258
359
|
case "search_repos": {
|
|
259
360
|
const q = input.query ?? `${owner}/${repo}`;
|
|
260
361
|
const perPage = Math.min(input.limit ?? 30, 100);
|
|
261
|
-
const res = await githubClient.request<
|
|
362
|
+
const res = await githubClient.request<GitHubSearchResult<GitHubRepo>>(
|
|
262
363
|
`/search/repositories?q=${encodeURIComponent(q)}&per_page=${perPage}`,
|
|
263
364
|
opts,
|
|
264
365
|
);
|
|
@@ -270,8 +371,8 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
270
371
|
const num = input.number;
|
|
271
372
|
if (!num) return { text: "Error: 'number' is required for get_issue" };
|
|
272
373
|
const [issueRes, commentsRes] = await Promise.all([
|
|
273
|
-
githubClient.request<
|
|
274
|
-
githubClient.requestPaginated<
|
|
374
|
+
githubClient.request<GitHubIssue>(`${base}/issues/${num}`, opts),
|
|
375
|
+
githubClient.requestPaginated<GitHubComment>(`${base}/issues/${num}/comments`, {
|
|
275
376
|
...opts,
|
|
276
377
|
perPage: 100,
|
|
277
378
|
maxPages: MAX_COMMENTS_PAGES,
|
|
@@ -291,13 +392,13 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
291
392
|
const limit = Math.min(input.limit ?? 100, 500);
|
|
292
393
|
const perPage = Math.min(limit, 100);
|
|
293
394
|
const maxPages = Math.ceil(limit / perPage);
|
|
294
|
-
const res = await githubClient.requestPaginated<
|
|
395
|
+
const res = await githubClient.requestPaginated<GitHubIssue>(`${base}/issues?${params}`, {
|
|
295
396
|
...opts,
|
|
296
397
|
perPage,
|
|
297
398
|
maxPages,
|
|
298
399
|
});
|
|
299
400
|
if (!res.ok) return error(res, "issues");
|
|
300
|
-
const issues = (res.data ?? []).filter(
|
|
401
|
+
const issues = (res.data ?? []).filter(i => !i.pull_request).slice(0, limit);
|
|
301
402
|
const header = `${issues.length} issue(s)${issues.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
302
403
|
return {
|
|
303
404
|
text: issues.length ? `${header}\n${issues.map(formatIssueMinimal).join("\n")}` : "No issues found.",
|
|
@@ -307,7 +408,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
307
408
|
case "get_pull": {
|
|
308
409
|
const num = input.number;
|
|
309
410
|
if (!num) return { text: "Error: 'number' is required for get_pull" };
|
|
310
|
-
const prRes = await githubClient.request<
|
|
411
|
+
const prRes = await githubClient.request<GitHubPR>(`${base}/pulls/${num}`, opts);
|
|
311
412
|
if (!prRes.ok) return error(prRes, `PR #${num}`);
|
|
312
413
|
|
|
313
414
|
let diff: string | undefined;
|
|
@@ -336,7 +437,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
336
437
|
const limit = Math.min(input.limit ?? 100, 500);
|
|
337
438
|
const perPage = Math.min(limit, 100);
|
|
338
439
|
const maxPages = Math.ceil(limit / perPage);
|
|
339
|
-
const res = await githubClient.requestPaginated<
|
|
440
|
+
const res = await githubClient.requestPaginated<GitHubPR>(`${base}/pulls?${params}`, {
|
|
340
441
|
...opts,
|
|
341
442
|
perPage,
|
|
342
443
|
maxPages,
|
|
@@ -356,7 +457,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
356
457
|
const limit = Math.min(input.limit ?? 100, 500);
|
|
357
458
|
const perPage = Math.min(limit, 100);
|
|
358
459
|
const maxPages = Math.ceil(limit / perPage);
|
|
359
|
-
const res = await githubClient.requestPaginated<
|
|
460
|
+
const res = await githubClient.requestPaginated<GitHubCommit>(`${base}/commits?${params}`, {
|
|
360
461
|
...opts,
|
|
361
462
|
perPage,
|
|
362
463
|
maxPages,
|
|
@@ -372,7 +473,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
372
473
|
case "get_commit": {
|
|
373
474
|
const sha = input.sha;
|
|
374
475
|
if (!sha) return { text: "Error: 'sha' is required for get_commit" };
|
|
375
|
-
const res = await githubClient.request<
|
|
476
|
+
const res = await githubClient.request<GitHubCommit>(`${base}/commits/${sha}`, opts);
|
|
376
477
|
if (!res.ok) return error(res, `commit ${sha}`);
|
|
377
478
|
|
|
378
479
|
let diff: string | undefined;
|
|
@@ -423,7 +524,8 @@ export class GitHubTool implements AgentTool<typeof schema, GitHubToolDetails, T
|
|
|
423
524
|
readonly name = "github";
|
|
424
525
|
readonly label = "GitHub";
|
|
425
526
|
readonly parameters = schema;
|
|
426
|
-
description =
|
|
527
|
+
description =
|
|
528
|
+
"Interact with GitHub API: repos, issues, PRs, commits. For remote repositories only — use read/grep for local files.";
|
|
427
529
|
|
|
428
530
|
constructor(readonly _session: ToolSession) {}
|
|
429
531
|
|
package/src/tools/index.ts
CHANGED
package/src/web/github-client.ts
CHANGED
|
@@ -76,8 +76,8 @@ const etagCache = new Map<string, CacheEntry>();
|
|
|
76
76
|
const CACHE_MAX_SIZE = 200;
|
|
77
77
|
const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
78
78
|
|
|
79
|
-
function getCacheKey(endpoint: string): string {
|
|
80
|
-
return endpoint;
|
|
79
|
+
function getCacheKey(endpoint: string, mediaType?: string): string {
|
|
80
|
+
return mediaType ? `${endpoint}::${mediaType}` : endpoint;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
83
|
function pruneCache(): void {
|
|
@@ -122,8 +122,7 @@ async function request<T = unknown>(endpoint: string, options: RequestOptions =
|
|
|
122
122
|
headers.Accept = options.mediaType;
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
const cacheKey = getCacheKey(endpoint);
|
|
125
|
+
const cacheKey = getCacheKey(endpoint, options.mediaType);
|
|
127
126
|
const cached = etagCache.get(cacheKey);
|
|
128
127
|
if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) {
|
|
129
128
|
headers["If-None-Match"] = cached.etag;
|
|
@@ -177,11 +176,20 @@ async function request<T = unknown>(endpoint: string, options: RequestOptions =
|
|
|
177
176
|
}
|
|
178
177
|
|
|
179
178
|
// Retry on transient errors
|
|
180
|
-
|
|
179
|
+
const isRateLimited =
|
|
180
|
+
response.status === 429 ||
|
|
181
|
+
(response.status === 403 && (rateLimit.remaining === 0 || response.headers.has("retry-after")));
|
|
182
|
+
const isTransient = RETRY_STATUS_CODES.has(response.status) || isRateLimited;
|
|
183
|
+
|
|
184
|
+
if (isTransient && attempt < MAX_RETRIES) {
|
|
185
|
+
await response.text().catch(() => {});
|
|
181
186
|
const retryAfter = response.headers.get("retry-after");
|
|
182
|
-
const
|
|
187
|
+
const retrySeconds = retryAfter ? parseInt(retryAfter, 10) : Number.NaN;
|
|
188
|
+
const waitMs = Number.isFinite(retrySeconds)
|
|
189
|
+
? retrySeconds * 1000
|
|
190
|
+
: Math.min(1000 * 2 ** attempt, 10_000);
|
|
183
191
|
|
|
184
|
-
if (
|
|
192
|
+
if (isRateLimited && rateLimit.remaining === 0) {
|
|
185
193
|
const resetMs = rateLimit.reset * 1000 - Date.now();
|
|
186
194
|
if (resetMs > 30_000) {
|
|
187
195
|
return { data: null as T, ok: false, status: response.status, rateLimit };
|
|
@@ -197,8 +205,8 @@ async function request<T = unknown>(endpoint: string, options: RequestOptions =
|
|
|
197
205
|
return { data: null as T, ok: false, status: response.status, rateLimit };
|
|
198
206
|
}
|
|
199
207
|
|
|
200
|
-
const
|
|
201
|
-
const data = (
|
|
208
|
+
const wantsRaw = options.mediaType !== undefined;
|
|
209
|
+
const data = (wantsRaw ? await response.text() : await response.json()) as T;
|
|
202
210
|
|
|
203
211
|
// Cache with ETag
|
|
204
212
|
const etag = response.headers.get("etag");
|