@nghyane/arcane 0.1.28 → 0.1.30
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 +7 -0
- package/package.json +4 -4
- package/src/cli/config-cli.ts +1 -1
- package/src/config/settings-schema.ts +19 -27
- package/src/config/settings.ts +3 -4
- package/src/extensibility/custom-tools/types.ts +0 -12
- package/src/extensibility/extensions/index.ts +0 -5
- package/src/extensibility/extensions/runner.ts +6 -26
- package/src/extensibility/extensions/types.ts +1 -77
- package/src/extensibility/hooks/runner.ts +5 -24
- package/src/extensibility/hooks/types.ts +1 -77
- package/src/index.ts +2 -13
- package/src/modes/components/footer.ts +4 -11
- package/src/modes/components/index.ts +0 -1
- package/src/modes/components/status-line/segments.ts +1 -2
- package/src/modes/components/status-line/types.ts +0 -1
- package/src/modes/components/status-line.ts +0 -6
- package/src/modes/components/tree-selector.ts +0 -8
- package/src/modes/controllers/command-controller.ts +2 -98
- package/src/modes/controllers/event-controller.ts +46 -52
- package/src/modes/controllers/extension-ui-controller.ts +0 -42
- package/src/modes/controllers/input-controller.ts +0 -23
- package/src/modes/controllers/selector-controller.ts +0 -5
- package/src/modes/interactive-mode.ts +3 -24
- package/src/modes/print-mode.ts +0 -16
- package/src/modes/rpc/rpc-client.ts +0 -16
- package/src/modes/rpc/rpc-mode.ts +0 -32
- package/src/modes/rpc/rpc-types.ts +0 -9
- package/src/modes/types.ts +1 -13
- package/src/modes/utils/ui-helpers.ts +2 -118
- package/src/prompts/agents/librarian.md +7 -12
- package/src/sdk.ts +0 -15
- package/src/session/agent-session.ts +89 -650
- package/src/session/compaction/branch-summarization.ts +5 -13
- package/src/session/compaction/index.ts +0 -1
- package/src/session/compaction/utils.ts +94 -2
- package/src/session/messages.ts +0 -37
- package/src/session/retry-utils.ts +1 -1
- package/src/session/session-manager.ts +8 -108
- package/src/session/session-types.ts +4 -25
- package/src/session/stats.ts +2 -39
- package/src/slash-commands/builtin-registry.ts +0 -11
- package/src/task/executor.ts +0 -8
- package/src/tools/create-tools.ts +3 -0
- package/src/tools/github-fs.ts +195 -0
- package/src/tools/github-utils.ts +35 -0
- package/src/tools/github.ts +35 -123
- package/src/tools/index.ts +1 -0
- package/examples/hooks/custom-compaction.ts +0 -116
- package/src/modes/components/compaction-summary-message.ts +0 -59
- package/src/prompts/compaction/compaction-short-summary.md +0 -9
- package/src/prompts/compaction/compaction-summary-context.md +0 -5
- package/src/prompts/compaction/compaction-summary.md +0 -41
- package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
- package/src/prompts/compaction/compaction-update-summary.md +0 -45
- package/src/session/compaction/compaction.ts +0 -864
- package/src/session/compaction/pruning.ts +0 -91
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { AgentTool, AgentToolResult } from "@nghyane/arcane-agent";
|
|
2
|
+
import { type Static, Type } from "@sinclair/typebox";
|
|
3
|
+
import type { Theme } from "../theme/theme";
|
|
4
|
+
import { githubClient } from "../web/github-client";
|
|
5
|
+
import type { ToolSession } from ".";
|
|
6
|
+
import { formatGitHubError, resolveOwnerRepo } from "./github-utils";
|
|
7
|
+
import { type OutputMeta, toolResult } from "./output-meta";
|
|
8
|
+
|
|
9
|
+
// =============================================================================
|
|
10
|
+
// GitHub API Types
|
|
11
|
+
// =============================================================================
|
|
12
|
+
|
|
13
|
+
interface GitHubTreeEntry {
|
|
14
|
+
type: string;
|
|
15
|
+
path?: string;
|
|
16
|
+
name?: string;
|
|
17
|
+
size?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// Schema
|
|
22
|
+
// =============================================================================
|
|
23
|
+
const ActionEnum = Type.Union([Type.Literal("get_file"), Type.Literal("get_tree")]);
|
|
24
|
+
|
|
25
|
+
const schema = Type.Object({
|
|
26
|
+
action: ActionEnum,
|
|
27
|
+
owner: Type.Optional(
|
|
28
|
+
Type.String({ description: "Repository owner (user or org). Auto-detected from git remote if omitted." }),
|
|
29
|
+
),
|
|
30
|
+
repo: Type.Optional(Type.String({ description: "Repository name. Auto-detected from git remote if omitted." })),
|
|
31
|
+
path: Type.Optional(Type.String({ description: "File or directory path within the repo" })),
|
|
32
|
+
ref: Type.Optional(Type.String({ description: "Branch, tag, or commit SHA" })),
|
|
33
|
+
recursive: Type.Optional(Type.Boolean({ description: "Recursively list tree contents" })),
|
|
34
|
+
limit: Type.Optional(Type.Number({ description: "Max number of results" })),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
type GitHubFsInput = Static<typeof schema>;
|
|
38
|
+
|
|
39
|
+
// =============================================================================
|
|
40
|
+
// Details
|
|
41
|
+
// =============================================================================
|
|
42
|
+
|
|
43
|
+
export interface GitHubFsToolDetails {
|
|
44
|
+
action: string;
|
|
45
|
+
owner: string;
|
|
46
|
+
repo: string;
|
|
47
|
+
meta?: OutputMeta;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// =============================================================================
|
|
51
|
+
// Helpers
|
|
52
|
+
// =============================================================================
|
|
53
|
+
|
|
54
|
+
function formatTreeEntry(entry: GitHubTreeEntry): string {
|
|
55
|
+
const icon = entry.type === "dir" || entry.type === "tree" ? "dir" : "file";
|
|
56
|
+
const size = entry.size ? ` (${formatSize(entry.size)})` : "";
|
|
57
|
+
const name = entry.path ?? entry.name;
|
|
58
|
+
return `[${icon}] ${name}${size}`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function formatSize(bytes: number): string {
|
|
62
|
+
if (bytes < 1024) return `${bytes}B`;
|
|
63
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
|
64
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// =============================================================================
|
|
68
|
+
// Action Handlers
|
|
69
|
+
// =============================================================================
|
|
70
|
+
|
|
71
|
+
const MAX_FILE_LINES = 500;
|
|
72
|
+
|
|
73
|
+
async function handleAction(
|
|
74
|
+
input: GitHubFsInput,
|
|
75
|
+
owner: string,
|
|
76
|
+
repo: string,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
): Promise<{ text: string; url?: string }> {
|
|
79
|
+
const { action } = input;
|
|
80
|
+
const opts = { signal };
|
|
81
|
+
const base = `/repos/${owner}/${repo}`;
|
|
82
|
+
|
|
83
|
+
switch (action) {
|
|
84
|
+
case "get_file": {
|
|
85
|
+
const filePath = input.path ?? "README.md";
|
|
86
|
+
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
87
|
+
let res = await githubClient.request<string>(`${base}/contents/${filePath}${ref}`, {
|
|
88
|
+
...opts,
|
|
89
|
+
mediaType: "application/vnd.github.v3.raw",
|
|
90
|
+
});
|
|
91
|
+
// Fallback to Blob API for files >1MB (raw mediaType returns 403)
|
|
92
|
+
if (!res.ok && res.status === 403) {
|
|
93
|
+
const metaRes = await githubClient.request<{ sha: string; size: number }>(
|
|
94
|
+
`${base}/contents/${filePath}${ref}`,
|
|
95
|
+
opts,
|
|
96
|
+
);
|
|
97
|
+
if (metaRes.ok && metaRes.data?.sha) {
|
|
98
|
+
const blobRes = await githubClient.request<{ content: string; encoding: string }>(
|
|
99
|
+
`${base}/git/blobs/${metaRes.data.sha}`,
|
|
100
|
+
opts,
|
|
101
|
+
);
|
|
102
|
+
if (blobRes.ok && blobRes.data?.content) {
|
|
103
|
+
const decoded =
|
|
104
|
+
blobRes.data.encoding === "base64"
|
|
105
|
+
? Buffer.from(blobRes.data.content, "base64").toString("utf-8")
|
|
106
|
+
: blobRes.data.content;
|
|
107
|
+
res = { data: decoded as string, ok: true, status: 200 };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) return formatGitHubError(res, `file ${filePath}`);
|
|
112
|
+
const content = String(res.data);
|
|
113
|
+
const lines = content.split("\n");
|
|
114
|
+
const truncated = lines.length > MAX_FILE_LINES;
|
|
115
|
+
const output = truncated ? lines.slice(0, MAX_FILE_LINES).join("\n") : content;
|
|
116
|
+
const note = truncated ? `\n\n[Truncated: showing ${MAX_FILE_LINES}/${lines.length} lines]` : "";
|
|
117
|
+
return {
|
|
118
|
+
text: `# ${owner}/${repo}:${filePath}${input.ref ? ` @${input.ref}` : ""}\n\n${output}${note}`,
|
|
119
|
+
url: `https://github.com/${owner}/${repo}/blob/${input.ref ?? "HEAD"}/${filePath}`,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
case "get_tree": {
|
|
124
|
+
const treePath = input.path ?? "";
|
|
125
|
+
if (input.recursive) {
|
|
126
|
+
const ref = input.ref ?? "HEAD";
|
|
127
|
+
const res = await githubClient.request<{ tree: GitHubTreeEntry[]; truncated: boolean }>(
|
|
128
|
+
`${base}/git/trees/${ref}?recursive=1`,
|
|
129
|
+
opts,
|
|
130
|
+
);
|
|
131
|
+
if (!res.ok) return formatGitHubError(res, "tree");
|
|
132
|
+
const entries = (res.data.tree ?? [])
|
|
133
|
+
.filter(e => !treePath || (e.path ?? "").startsWith(treePath))
|
|
134
|
+
.slice(0, 500);
|
|
135
|
+
return {
|
|
136
|
+
text: `# Tree: ${owner}/${repo}${treePath ? `/${treePath}` : ""} (recursive)\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
140
|
+
const endpoint = treePath ? `${base}/contents/${treePath}${ref}` : `${base}/contents${ref}`;
|
|
141
|
+
const res = await githubClient.request<GitHubTreeEntry[]>(endpoint, opts);
|
|
142
|
+
if (!res.ok) return formatGitHubError(res, "directory");
|
|
143
|
+
const entries = Array.isArray(res.data) ? res.data : [res.data];
|
|
144
|
+
return {
|
|
145
|
+
text: `# ${owner}/${repo}/${treePath}\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
default:
|
|
150
|
+
return { text: `Unknown action: ${action}` };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// =============================================================================
|
|
155
|
+
// Tool Class
|
|
156
|
+
// =============================================================================
|
|
157
|
+
|
|
158
|
+
export class GitHubFsTool implements AgentTool<typeof schema, GitHubFsToolDetails, Theme> {
|
|
159
|
+
readonly name = "github_fs";
|
|
160
|
+
readonly label = "GitHub FS";
|
|
161
|
+
readonly parameters = schema;
|
|
162
|
+
description = "Browse remote GitHub repository contents: read files and list directory trees.";
|
|
163
|
+
|
|
164
|
+
constructor(readonly _session: ToolSession) {}
|
|
165
|
+
|
|
166
|
+
async execute(
|
|
167
|
+
_toolCallId: string,
|
|
168
|
+
params: GitHubFsInput,
|
|
169
|
+
signal?: AbortSignal,
|
|
170
|
+
): Promise<AgentToolResult<GitHubFsToolDetails>> {
|
|
171
|
+
const resolved = await resolveOwnerRepo(params, this._session.cwd);
|
|
172
|
+
if (!resolved) {
|
|
173
|
+
return toolResult({ action: params.action, owner: "", repo: "" } as GitHubFsToolDetails)
|
|
174
|
+
.text(
|
|
175
|
+
"Error: owner and repo are required. Provide them explicitly or run from a git repo with a GitHub remote.",
|
|
176
|
+
)
|
|
177
|
+
.done();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { owner, repo } = resolved;
|
|
181
|
+
const details: GitHubFsToolDetails = {
|
|
182
|
+
action: params.action,
|
|
183
|
+
owner,
|
|
184
|
+
repo,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const result = await handleAction(params, owner, repo, signal);
|
|
188
|
+
|
|
189
|
+
const builder = toolResult(details).text(result.text);
|
|
190
|
+
if (result.url) {
|
|
191
|
+
builder.sourceUrl(result.url);
|
|
192
|
+
}
|
|
193
|
+
return builder.done();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { $ } from "bun";
|
|
2
|
+
import type { GitHubResponse } from "../web/github-client";
|
|
3
|
+
|
|
4
|
+
export async function resolveOwnerRepo(
|
|
5
|
+
input: { owner?: string; repo?: string },
|
|
6
|
+
cwd: string,
|
|
7
|
+
): Promise<{ owner: string; repo: string } | null> {
|
|
8
|
+
if (input.owner && input.repo) return { owner: input.owner, repo: input.repo };
|
|
9
|
+
try {
|
|
10
|
+
const result = await $`git remote get-url origin`.cwd(cwd).quiet().nothrow();
|
|
11
|
+
if (result.exitCode !== 0) return null;
|
|
12
|
+
const url = result.text().trim();
|
|
13
|
+
// https://github.com/owner/repo.git or git@github.com:owner/repo.git
|
|
14
|
+
const match = url.match(/github\.com[/:]([^/]+)\/([^/.]+)/);
|
|
15
|
+
if (!match) return null;
|
|
16
|
+
return { owner: match[1], repo: match[2] };
|
|
17
|
+
} catch {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function formatGitHubError(res: GitHubResponse, resource: string): { text: string } {
|
|
23
|
+
const status = res.status;
|
|
24
|
+
if (status === 404) return { text: `Error: ${resource} not found (404)` };
|
|
25
|
+
if (status === 403) {
|
|
26
|
+
const rl = res.rateLimit;
|
|
27
|
+
if (rl && rl.remaining === 0) {
|
|
28
|
+
return { text: `Error: GitHub API rate limit exceeded. Resets at ${new Date(rl.reset * 1000).toISOString()}` };
|
|
29
|
+
}
|
|
30
|
+
return { text: `Error: Access denied to ${resource} (403). Check token permissions.` };
|
|
31
|
+
}
|
|
32
|
+
if (status === 401)
|
|
33
|
+
return { text: `Error: Authentication failed (401). Check GITHUB_TOKEN or run 'gh auth login'.` };
|
|
34
|
+
return { text: `Error: Failed to fetch ${resource} (HTTP ${status})` };
|
|
35
|
+
}
|
package/src/tools/github.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { AgentTool, AgentToolResult } from "@nghyane/arcane-agent";
|
|
2
2
|
import { type Static, Type } from "@sinclair/typebox";
|
|
3
3
|
import type { Theme } from "../theme/theme";
|
|
4
|
-
import {
|
|
4
|
+
import { githubClient } from "../web/github-client";
|
|
5
5
|
import type { ToolSession } from ".";
|
|
6
|
+
import { formatGitHubError, resolveOwnerRepo } from "./github-utils";
|
|
6
7
|
import { type OutputMeta, toolResult } from "./output-meta";
|
|
7
8
|
|
|
8
9
|
// =============================================================================
|
|
@@ -35,13 +36,6 @@ interface GitHubRepo {
|
|
|
35
36
|
homepage: string | null;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
interface GitHubTreeEntry {
|
|
39
|
-
type: string;
|
|
40
|
-
path?: string;
|
|
41
|
-
name?: string;
|
|
42
|
-
size?: number;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
39
|
interface GitHubIssue {
|
|
46
40
|
number: number;
|
|
47
41
|
title: string;
|
|
@@ -109,8 +103,6 @@ interface GitHubSearchResult<T> {
|
|
|
109
103
|
// =============================================================================
|
|
110
104
|
const ActionEnum = Type.Union([
|
|
111
105
|
Type.Literal("get_repo"),
|
|
112
|
-
Type.Literal("get_file"),
|
|
113
|
-
Type.Literal("get_tree"),
|
|
114
106
|
Type.Literal("search_repos"),
|
|
115
107
|
Type.Literal("get_issue"),
|
|
116
108
|
Type.Literal("list_issues"),
|
|
@@ -122,8 +114,10 @@ const ActionEnum = Type.Union([
|
|
|
122
114
|
|
|
123
115
|
const schema = Type.Object({
|
|
124
116
|
action: ActionEnum,
|
|
125
|
-
owner: Type.
|
|
126
|
-
|
|
117
|
+
owner: Type.Optional(
|
|
118
|
+
Type.String({ description: "Repository owner (user or org). Auto-detected from git remote if omitted." }),
|
|
119
|
+
),
|
|
120
|
+
repo: Type.Optional(Type.String({ description: "Repository name. Auto-detected from git remote if omitted." })),
|
|
127
121
|
path: Type.Optional(Type.String({ description: "File or directory path within the repo" })),
|
|
128
122
|
ref: Type.Optional(Type.String({ description: "Branch, tag, or commit SHA" })),
|
|
129
123
|
number: Type.Optional(Type.Number({ description: "Issue or PR number" })),
|
|
@@ -132,7 +126,6 @@ const schema = Type.Object({
|
|
|
132
126
|
labels: Type.Optional(Type.String({ description: "Comma-separated label filter" })),
|
|
133
127
|
sha: Type.Optional(Type.String({ description: "Commit SHA" })),
|
|
134
128
|
include_diff: Type.Optional(Type.Boolean({ description: "Include diff in commit details" })),
|
|
135
|
-
recursive: Type.Optional(Type.Boolean({ description: "Recursively list tree contents" })),
|
|
136
129
|
limit: Type.Optional(Type.Number({ description: "Max number of results" })),
|
|
137
130
|
});
|
|
138
131
|
|
|
@@ -170,19 +163,6 @@ function formatRepo(data: GitHubRepo): string {
|
|
|
170
163
|
.join("\n");
|
|
171
164
|
}
|
|
172
165
|
|
|
173
|
-
function formatTreeEntry(entry: GitHubTreeEntry): string {
|
|
174
|
-
const icon = entry.type === "dir" || entry.type === "tree" ? "dir" : "file";
|
|
175
|
-
const size = entry.size ? ` (${formatSize(entry.size)})` : "";
|
|
176
|
-
const name = entry.path ?? entry.name;
|
|
177
|
-
return `[${icon}] ${name}${size}`;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function formatSize(bytes: number): string {
|
|
181
|
-
if (bytes < 1024) return `${bytes}B`;
|
|
182
|
-
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}K`;
|
|
183
|
-
return `${(bytes / (1024 * 1024)).toFixed(1)}M`;
|
|
184
|
-
}
|
|
185
|
-
|
|
186
166
|
function formatIssue(data: GitHubIssue, comments: GitHubComment[] = []): string {
|
|
187
167
|
const lines = [
|
|
188
168
|
`# #${data.number}: ${data.title}`,
|
|
@@ -275,87 +255,26 @@ function formatSearchReposResult(data: GitHubSearchResult<GitHubRepo>): string {
|
|
|
275
255
|
// Action Handlers
|
|
276
256
|
// =============================================================================
|
|
277
257
|
|
|
278
|
-
const MAX_FILE_LINES = 500;
|
|
279
258
|
const MAX_DIFF_CHARS = 50_000;
|
|
280
259
|
const MAX_COMMENTS_PAGES = 5;
|
|
281
260
|
|
|
282
|
-
async function handleAction(
|
|
283
|
-
|
|
261
|
+
async function handleAction(
|
|
262
|
+
input: GitHubInput,
|
|
263
|
+
owner: string,
|
|
264
|
+
repo: string,
|
|
265
|
+
signal?: AbortSignal,
|
|
266
|
+
): Promise<{ text: string; url?: string }> {
|
|
267
|
+
const { action } = input;
|
|
284
268
|
const opts = { signal };
|
|
285
269
|
const base = `/repos/${owner}/${repo}`;
|
|
286
270
|
|
|
287
271
|
switch (action) {
|
|
288
272
|
case "get_repo": {
|
|
289
273
|
const res = await githubClient.request<GitHubRepo>(base, opts);
|
|
290
|
-
if (!res.ok) return
|
|
274
|
+
if (!res.ok) return formatGitHubError(res, "repository");
|
|
291
275
|
return { text: formatRepo(res.data), url: `https://github.com/${owner}/${repo}` };
|
|
292
276
|
}
|
|
293
277
|
|
|
294
|
-
case "get_file": {
|
|
295
|
-
const filePath = input.path ?? "README.md";
|
|
296
|
-
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
297
|
-
let res = await githubClient.request<string>(`${base}/contents/${filePath}${ref}`, {
|
|
298
|
-
...opts,
|
|
299
|
-
mediaType: "application/vnd.github.v3.raw",
|
|
300
|
-
});
|
|
301
|
-
// Fallback to Blob API for files >1MB (raw mediaType returns 403)
|
|
302
|
-
if (!res.ok && res.status === 403) {
|
|
303
|
-
const metaRes = await githubClient.request<{ sha: string; size: number }>(
|
|
304
|
-
`${base}/contents/${filePath}${ref}`,
|
|
305
|
-
opts,
|
|
306
|
-
);
|
|
307
|
-
if (metaRes.ok && metaRes.data?.sha) {
|
|
308
|
-
const blobRes = await githubClient.request<{ content: string; encoding: string }>(
|
|
309
|
-
`${base}/git/blobs/${metaRes.data.sha}`,
|
|
310
|
-
opts,
|
|
311
|
-
);
|
|
312
|
-
if (blobRes.ok && blobRes.data?.content) {
|
|
313
|
-
const decoded =
|
|
314
|
-
blobRes.data.encoding === "base64"
|
|
315
|
-
? Buffer.from(blobRes.data.content, "base64").toString("utf-8")
|
|
316
|
-
: blobRes.data.content;
|
|
317
|
-
res = { data: decoded as string, ok: true, status: 200 };
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
if (!res.ok) return error(res, `file ${filePath}`);
|
|
322
|
-
const content = String(res.data);
|
|
323
|
-
const lines = content.split("\n");
|
|
324
|
-
const truncated = lines.length > MAX_FILE_LINES;
|
|
325
|
-
const output = truncated ? lines.slice(0, MAX_FILE_LINES).join("\n") : content;
|
|
326
|
-
const note = truncated ? `\n\n[Truncated: showing ${MAX_FILE_LINES}/${lines.length} lines]` : "";
|
|
327
|
-
return {
|
|
328
|
-
text: `# ${owner}/${repo}:${filePath}${input.ref ? ` @${input.ref}` : ""}\n\n${output}${note}`,
|
|
329
|
-
url: `https://github.com/${owner}/${repo}/blob/${input.ref ?? "HEAD"}/${filePath}`,
|
|
330
|
-
};
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
case "get_tree": {
|
|
334
|
-
const treePath = input.path ?? "";
|
|
335
|
-
if (input.recursive) {
|
|
336
|
-
const ref = input.ref ?? "HEAD";
|
|
337
|
-
const res = await githubClient.request<{ tree: GitHubTreeEntry[]; truncated: boolean }>(
|
|
338
|
-
`${base}/git/trees/${ref}?recursive=1`,
|
|
339
|
-
opts,
|
|
340
|
-
);
|
|
341
|
-
if (!res.ok) return error(res, "tree");
|
|
342
|
-
const entries = (res.data.tree ?? [])
|
|
343
|
-
.filter(e => !treePath || (e.path ?? "").startsWith(treePath))
|
|
344
|
-
.slice(0, 500);
|
|
345
|
-
return {
|
|
346
|
-
text: `# Tree: ${owner}/${repo}${treePath ? `/${treePath}` : ""} (recursive)\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
347
|
-
};
|
|
348
|
-
}
|
|
349
|
-
const ref = input.ref ? `?ref=${input.ref}` : "";
|
|
350
|
-
const endpoint = treePath ? `${base}/contents/${treePath}${ref}` : `${base}/contents${ref}`;
|
|
351
|
-
const res = await githubClient.request<GitHubTreeEntry[]>(endpoint, opts);
|
|
352
|
-
if (!res.ok) return error(res, "directory");
|
|
353
|
-
const entries = Array.isArray(res.data) ? res.data : [res.data];
|
|
354
|
-
return {
|
|
355
|
-
text: `# ${owner}/${repo}/${treePath}\n\n${entries.map(formatTreeEntry).join("\n")}`,
|
|
356
|
-
};
|
|
357
|
-
}
|
|
358
|
-
|
|
359
278
|
case "search_repos": {
|
|
360
279
|
const q = input.query ?? `${owner}/${repo}`;
|
|
361
280
|
const perPage = Math.min(input.limit ?? 30, 100);
|
|
@@ -363,7 +282,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
363
282
|
`/search/repositories?q=${encodeURIComponent(q)}&per_page=${perPage}`,
|
|
364
283
|
opts,
|
|
365
284
|
);
|
|
366
|
-
if (!res.ok) return
|
|
285
|
+
if (!res.ok) return formatGitHubError(res, "repository search");
|
|
367
286
|
return { text: formatSearchReposResult(res.data) };
|
|
368
287
|
}
|
|
369
288
|
|
|
@@ -378,7 +297,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
378
297
|
maxPages: MAX_COMMENTS_PAGES,
|
|
379
298
|
}),
|
|
380
299
|
]);
|
|
381
|
-
if (!issueRes.ok) return
|
|
300
|
+
if (!issueRes.ok) return formatGitHubError(issueRes, `issue #${num}`);
|
|
382
301
|
return {
|
|
383
302
|
text: formatIssue(issueRes.data, commentsRes.ok ? commentsRes.data : []),
|
|
384
303
|
url: `https://github.com/${owner}/${repo}/issues/${num}`,
|
|
@@ -397,7 +316,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
397
316
|
perPage,
|
|
398
317
|
maxPages,
|
|
399
318
|
});
|
|
400
|
-
if (!res.ok) return
|
|
319
|
+
if (!res.ok) return formatGitHubError(res, "issues");
|
|
401
320
|
const issues = (res.data ?? []).filter(i => !i.pull_request).slice(0, limit);
|
|
402
321
|
const header = `${issues.length} issue(s)${issues.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
403
322
|
return {
|
|
@@ -409,7 +328,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
409
328
|
const num = input.number;
|
|
410
329
|
if (!num) return { text: "Error: 'number' is required for get_pull" };
|
|
411
330
|
const prRes = await githubClient.request<GitHubPR>(`${base}/pulls/${num}`, opts);
|
|
412
|
-
if (!prRes.ok) return
|
|
331
|
+
if (!prRes.ok) return formatGitHubError(prRes, `PR #${num}`);
|
|
413
332
|
|
|
414
333
|
let diff: string | undefined;
|
|
415
334
|
if (input.include_diff) {
|
|
@@ -442,7 +361,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
442
361
|
perPage,
|
|
443
362
|
maxPages,
|
|
444
363
|
});
|
|
445
|
-
if (!res.ok) return
|
|
364
|
+
if (!res.ok) return formatGitHubError(res, "pull requests");
|
|
446
365
|
const pulls = (res.data ?? []).slice(0, limit);
|
|
447
366
|
const header = `${pulls.length} PR(s)${pulls.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
448
367
|
return {
|
|
@@ -462,7 +381,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
462
381
|
perPage,
|
|
463
382
|
maxPages,
|
|
464
383
|
});
|
|
465
|
-
if (!res.ok) return
|
|
384
|
+
if (!res.ok) return formatGitHubError(res, "commits");
|
|
466
385
|
const commits = (res.data ?? []).slice(0, limit);
|
|
467
386
|
const header = `${commits.length} commit(s)${commits.length >= limit ? " (limit reached, increase limit for more)" : ""}`;
|
|
468
387
|
return {
|
|
@@ -474,7 +393,7 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
474
393
|
const sha = input.sha;
|
|
475
394
|
if (!sha) return { text: "Error: 'sha' is required for get_commit" };
|
|
476
395
|
const res = await githubClient.request<GitHubCommit>(`${base}/commits/${sha}`, opts);
|
|
477
|
-
if (!res.ok) return
|
|
396
|
+
if (!res.ok) return formatGitHubError(res, `commit ${sha}`);
|
|
478
397
|
|
|
479
398
|
let diff: string | undefined;
|
|
480
399
|
if (input.include_diff) {
|
|
@@ -500,22 +419,6 @@ async function handleAction(input: GitHubInput, signal?: AbortSignal): Promise<{
|
|
|
500
419
|
return { text: `Unknown action: ${action}` };
|
|
501
420
|
}
|
|
502
421
|
}
|
|
503
|
-
|
|
504
|
-
function error(res: GitHubResponse, resource: string): { text: string } {
|
|
505
|
-
const status = res.status;
|
|
506
|
-
if (status === 404) return { text: `Error: ${resource} not found (404)` };
|
|
507
|
-
if (status === 403) {
|
|
508
|
-
const rl = res.rateLimit;
|
|
509
|
-
if (rl && rl.remaining === 0) {
|
|
510
|
-
return { text: `Error: GitHub API rate limit exceeded. Resets at ${new Date(rl.reset * 1000).toISOString()}` };
|
|
511
|
-
}
|
|
512
|
-
return { text: `Error: Access denied to ${resource} (403). Check token permissions.` };
|
|
513
|
-
}
|
|
514
|
-
if (status === 401)
|
|
515
|
-
return { text: `Error: Authentication failed (401). Check GITHUB_TOKEN or run 'gh auth login'.` };
|
|
516
|
-
return { text: `Error: Failed to fetch ${resource} (HTTP ${status})` };
|
|
517
|
-
}
|
|
518
|
-
|
|
519
422
|
// =============================================================================
|
|
520
423
|
// Tool Class
|
|
521
424
|
// =============================================================================
|
|
@@ -534,14 +437,23 @@ export class GitHubTool implements AgentTool<typeof schema, GitHubToolDetails, T
|
|
|
534
437
|
params: GitHubInput,
|
|
535
438
|
signal?: AbortSignal,
|
|
536
439
|
): Promise<AgentToolResult<GitHubToolDetails>> {
|
|
537
|
-
const
|
|
440
|
+
const resolved = await resolveOwnerRepo(params, this._session.cwd);
|
|
441
|
+
if (!resolved) {
|
|
442
|
+
return toolResult({ action: params.action, owner: "", repo: "" } as GitHubToolDetails)
|
|
443
|
+
.text(
|
|
444
|
+
"Error: owner and repo are required. Provide them explicitly or run from a git repo with a GitHub remote.",
|
|
445
|
+
)
|
|
446
|
+
.done();
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const { owner, repo } = resolved;
|
|
538
450
|
const details: GitHubToolDetails = {
|
|
539
|
-
action:
|
|
540
|
-
owner
|
|
541
|
-
repo
|
|
451
|
+
action: params.action,
|
|
452
|
+
owner,
|
|
453
|
+
repo,
|
|
542
454
|
};
|
|
543
455
|
|
|
544
|
-
const result = await handleAction(
|
|
456
|
+
const result = await handleAction(params, owner, repo, signal);
|
|
545
457
|
|
|
546
458
|
const builder = toolResult(details).text(result.text);
|
|
547
459
|
if (result.url) {
|
package/src/tools/index.ts
CHANGED
|
@@ -72,6 +72,7 @@ export {
|
|
|
72
72
|
} from "./find";
|
|
73
73
|
export { FindThreadTool, type FindThreadToolDetails } from "./find-thread";
|
|
74
74
|
export { GitHubTool, type GitHubToolDetails } from "./github";
|
|
75
|
+
export { GitHubFsTool, type GitHubFsToolDetails } from "./github-fs";
|
|
75
76
|
export { GrepTool, type GrepToolDetails, type GrepToolInput } from "./grep";
|
|
76
77
|
export { librarianConfig } from "./librarian";
|
|
77
78
|
export { NotebookTool, type NotebookToolDetails } from "./notebook";
|
|
@@ -1,116 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Custom Compaction Hook
|
|
3
|
-
*
|
|
4
|
-
* Replaces the default compaction behavior with a full summary of the entire context.
|
|
5
|
-
* Instead of keeping the last 20k tokens of conversation turns, this hook:
|
|
6
|
-
* 1. Summarizes ALL messages (messagesToSummarize + turnPrefixMessages)
|
|
7
|
-
* 2. Discards all old turns completely, keeping only the summary
|
|
8
|
-
*
|
|
9
|
-
* This example also demonstrates using a different model (Gemini Flash) for summarization,
|
|
10
|
-
* which can be cheaper/faster than the main conversation model.
|
|
11
|
-
*
|
|
12
|
-
* Usage:
|
|
13
|
-
* arcane --hook examples/hooks/custom-compaction.ts
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import type { HookAPI } from "@nghyane/arcane";
|
|
17
|
-
import { convertToLlm, serializeConversation } from "@nghyane/arcane";
|
|
18
|
-
import { complete, getModel } from "@nghyane/arcane-ai";
|
|
19
|
-
|
|
20
|
-
export default function (pi: HookAPI) {
|
|
21
|
-
pi.on("session_before_compact", async (event, ctx) => {
|
|
22
|
-
ctx.ui.notify("Custom compaction hook triggered", "info");
|
|
23
|
-
|
|
24
|
-
const { preparation, branchEntries: _, signal } = event;
|
|
25
|
-
const { messagesToSummarize, turnPrefixMessages, tokensBefore, firstKeptEntryId, previousSummary } = preparation;
|
|
26
|
-
|
|
27
|
-
// Use Gemini Flash for summarization (cheaper/faster than most conversation models)
|
|
28
|
-
const model = getModel("google", "gemini-2.5-flash");
|
|
29
|
-
if (!model) {
|
|
30
|
-
ctx.ui.notify(`Could not find Gemini Flash model, using default compaction`, "warning");
|
|
31
|
-
return;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Resolve API key for the summarization model
|
|
35
|
-
const apiKey = await ctx.modelRegistry.getApiKey(model);
|
|
36
|
-
if (!apiKey) {
|
|
37
|
-
ctx.ui.notify(`No API key for ${model.provider}, using default compaction`, "warning");
|
|
38
|
-
return;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
// Combine all messages for full summary
|
|
42
|
-
const allMessages = [...messagesToSummarize, ...turnPrefixMessages];
|
|
43
|
-
|
|
44
|
-
ctx.ui.notify(
|
|
45
|
-
`Custom compaction: summarizing ${allMessages.length} messages (${tokensBefore.toLocaleString()} tokens) with ${
|
|
46
|
-
model.id
|
|
47
|
-
}...`,
|
|
48
|
-
"info",
|
|
49
|
-
);
|
|
50
|
-
|
|
51
|
-
// Convert messages to readable text format
|
|
52
|
-
const conversationText = serializeConversation(convertToLlm(allMessages));
|
|
53
|
-
|
|
54
|
-
// Include previous summary context if available
|
|
55
|
-
const previousContext = previousSummary ? `\n\nPrevious session summary for context:\n${previousSummary}` : "";
|
|
56
|
-
|
|
57
|
-
// Build messages that ask for a comprehensive summary
|
|
58
|
-
const summaryMessages = [
|
|
59
|
-
{
|
|
60
|
-
role: "user" as const,
|
|
61
|
-
content: [
|
|
62
|
-
{
|
|
63
|
-
type: "text" as const,
|
|
64
|
-
text: `You are a conversation summarizer. Create a comprehensive summary of this conversation that captures:${previousContext}
|
|
65
|
-
|
|
66
|
-
1. The main goals and objectives discussed
|
|
67
|
-
2. Key decisions made and their rationale
|
|
68
|
-
3. Important code changes, file modifications, or technical details
|
|
69
|
-
4. Current state of any ongoing work
|
|
70
|
-
5. Any blockers, issues, or open questions
|
|
71
|
-
6. Next steps that were planned or suggested
|
|
72
|
-
|
|
73
|
-
Be thorough but concise. The summary will replace the ENTIRE conversation history, so include all information needed to continue the work effectively.
|
|
74
|
-
|
|
75
|
-
Format the summary as structured markdown with clear sections.
|
|
76
|
-
|
|
77
|
-
<conversation>
|
|
78
|
-
${conversationText}
|
|
79
|
-
</conversation>`,
|
|
80
|
-
},
|
|
81
|
-
],
|
|
82
|
-
timestamp: Date.now(),
|
|
83
|
-
},
|
|
84
|
-
];
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
// Pass signal to honor abort requests (e.g., user cancels compaction)
|
|
88
|
-
const response = await complete(model, { messages: summaryMessages }, { apiKey, maxTokens: 8192, signal });
|
|
89
|
-
|
|
90
|
-
const summary = response.content
|
|
91
|
-
.filter((c): c is { type: "text"; text: string } => c.type === "text")
|
|
92
|
-
.map(c => c.text)
|
|
93
|
-
.join("\n");
|
|
94
|
-
|
|
95
|
-
if (!summary.trim()) {
|
|
96
|
-
if (!signal.aborted) ctx.ui.notify("Compaction summary was empty, using default compaction", "warning");
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Return compaction content - SessionManager adds id/parentId
|
|
101
|
-
// Use firstKeptEntryId from preparation to keep recent messages
|
|
102
|
-
return {
|
|
103
|
-
compaction: {
|
|
104
|
-
summary,
|
|
105
|
-
firstKeptEntryId,
|
|
106
|
-
tokensBefore,
|
|
107
|
-
},
|
|
108
|
-
};
|
|
109
|
-
} catch (error) {
|
|
110
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
-
ctx.ui.notify(`Compaction failed: ${message}`, "error");
|
|
112
|
-
// Fall back to default compaction on error
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
});
|
|
116
|
-
}
|