@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.
Files changed (57) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +4 -4
  3. package/src/cli/config-cli.ts +1 -1
  4. package/src/config/settings-schema.ts +19 -27
  5. package/src/config/settings.ts +3 -4
  6. package/src/extensibility/custom-tools/types.ts +0 -12
  7. package/src/extensibility/extensions/index.ts +0 -5
  8. package/src/extensibility/extensions/runner.ts +6 -26
  9. package/src/extensibility/extensions/types.ts +1 -77
  10. package/src/extensibility/hooks/runner.ts +5 -24
  11. package/src/extensibility/hooks/types.ts +1 -77
  12. package/src/index.ts +2 -13
  13. package/src/modes/components/footer.ts +4 -11
  14. package/src/modes/components/index.ts +0 -1
  15. package/src/modes/components/status-line/segments.ts +1 -2
  16. package/src/modes/components/status-line/types.ts +0 -1
  17. package/src/modes/components/status-line.ts +0 -6
  18. package/src/modes/components/tree-selector.ts +0 -8
  19. package/src/modes/controllers/command-controller.ts +2 -98
  20. package/src/modes/controllers/event-controller.ts +46 -52
  21. package/src/modes/controllers/extension-ui-controller.ts +0 -42
  22. package/src/modes/controllers/input-controller.ts +0 -23
  23. package/src/modes/controllers/selector-controller.ts +0 -5
  24. package/src/modes/interactive-mode.ts +3 -24
  25. package/src/modes/print-mode.ts +0 -16
  26. package/src/modes/rpc/rpc-client.ts +0 -16
  27. package/src/modes/rpc/rpc-mode.ts +0 -32
  28. package/src/modes/rpc/rpc-types.ts +0 -9
  29. package/src/modes/types.ts +1 -13
  30. package/src/modes/utils/ui-helpers.ts +2 -118
  31. package/src/prompts/agents/librarian.md +7 -12
  32. package/src/sdk.ts +0 -15
  33. package/src/session/agent-session.ts +89 -650
  34. package/src/session/compaction/branch-summarization.ts +5 -13
  35. package/src/session/compaction/index.ts +0 -1
  36. package/src/session/compaction/utils.ts +94 -2
  37. package/src/session/messages.ts +0 -37
  38. package/src/session/retry-utils.ts +1 -1
  39. package/src/session/session-manager.ts +8 -108
  40. package/src/session/session-types.ts +4 -25
  41. package/src/session/stats.ts +2 -39
  42. package/src/slash-commands/builtin-registry.ts +0 -11
  43. package/src/task/executor.ts +0 -8
  44. package/src/tools/create-tools.ts +3 -0
  45. package/src/tools/github-fs.ts +195 -0
  46. package/src/tools/github-utils.ts +35 -0
  47. package/src/tools/github.ts +35 -123
  48. package/src/tools/index.ts +1 -0
  49. package/examples/hooks/custom-compaction.ts +0 -116
  50. package/src/modes/components/compaction-summary-message.ts +0 -59
  51. package/src/prompts/compaction/compaction-short-summary.md +0 -9
  52. package/src/prompts/compaction/compaction-summary-context.md +0 -5
  53. package/src/prompts/compaction/compaction-summary.md +0 -41
  54. package/src/prompts/compaction/compaction-turn-prefix.md +0 -17
  55. package/src/prompts/compaction/compaction-update-summary.md +0 -45
  56. package/src/session/compaction/compaction.ts +0 -864
  57. 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
+ }
@@ -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 { type GitHubResponse, githubClient } from "../web/github-client";
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.String({ description: "Repository owner (user or org)" }),
126
- repo: Type.String({ description: "Repository name" }),
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(input: GitHubInput, signal?: AbortSignal): Promise<{ text: string; url?: string }> {
283
- const { action, owner, repo } = input;
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 error(res, "repository");
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 error(res, "repository search");
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 error(issueRes, `issue #${num}`);
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 error(res, "issues");
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 error(prRes, `PR #${num}`);
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 error(res, "pull requests");
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 error(res, "commits");
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 error(res, `commit ${sha}`);
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 input = params;
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: input.action,
540
- owner: input.owner,
541
- repo: input.repo,
451
+ action: params.action,
452
+ owner,
453
+ repo,
542
454
  };
543
455
 
544
- const result = await handleAction(input, signal);
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) {
@@ -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
- }