@thurstonsand/pi-librarian 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +21 -0
- package/CONTEXT.md +32 -0
- package/DEV.md +41 -0
- package/README.md +43 -0
- package/RELEASE.md +13 -0
- package/docs/designs/01-librarian.md +215 -0
- package/docs/designs/02-continuable-runs.md +101 -0
- package/docs/designs/03-librarian-research-tool-hardening.md +141 -0
- package/docs/release.md +22 -0
- package/extensions/librarian/attach.ts +41 -0
- package/extensions/librarian/checkout.ts +239 -0
- package/extensions/librarian/github.ts +364 -0
- package/extensions/librarian/grep-app.ts +237 -0
- package/extensions/librarian/model.ts +59 -0
- package/extensions/librarian/prompt.ts +54 -0
- package/extensions/librarian/results.ts +63 -0
- package/extensions/librarian/run.ts +248 -0
- package/extensions/librarian/settings.ts +119 -0
- package/extensions/librarian/tools/checkout-repo.ts +88 -0
- package/extensions/librarian/tools/names.ts +21 -0
- package/extensions/librarian/tools/provide-results.ts +60 -0
- package/extensions/librarian/tools/read-github-file.ts +122 -0
- package/extensions/librarian/tools/search-code.ts +99 -0
- package/extensions/librarian/tools/search-github-code.ts +125 -0
- package/extensions/librarian/tools/search-repos.ts +101 -0
- package/extensions/librarian/trace.ts +76 -0
- package/extensions/librarian/view.ts +307 -0
- package/extensions/librarian.ts +181 -0
- package/extensions/shared/typebox.ts +31 -0
- package/package.json +73 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
|
|
4
|
+
import { SettingsManager } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { type Static, Type } from "typebox";
|
|
6
|
+
import { parseTypeBoxValue } from "../shared/typebox.ts";
|
|
7
|
+
|
|
8
|
+
const THINKING_LEVEL_SCHEMA = Type.Union([
|
|
9
|
+
Type.Literal("off"),
|
|
10
|
+
Type.Literal("minimal"),
|
|
11
|
+
Type.Literal("low"),
|
|
12
|
+
Type.Literal("medium"),
|
|
13
|
+
Type.Literal("high"),
|
|
14
|
+
Type.Literal("xhigh"),
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const LIBRARIAN_FILE_SETTINGS_SCHEMA = Type.Object({
|
|
18
|
+
model: Type.Optional(Type.String()),
|
|
19
|
+
thinkingLevel: Type.Optional(THINKING_LEVEL_SCHEMA),
|
|
20
|
+
extensions: Type.Optional(Type.Array(Type.String())),
|
|
21
|
+
disabledTools: Type.Optional(Type.Array(Type.String())),
|
|
22
|
+
cacheDir: Type.Optional(Type.String()),
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const ROOT_SETTINGS_SCHEMA = Type.Object({
|
|
26
|
+
librarian: Type.Optional(LIBRARIAN_FILE_SETTINGS_SCHEMA),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
type LibrarianFileSettings = Static<typeof LIBRARIAN_FILE_SETTINGS_SCHEMA>;
|
|
30
|
+
|
|
31
|
+
export class ModelReference {
|
|
32
|
+
constructor(
|
|
33
|
+
readonly provider: string,
|
|
34
|
+
readonly modelId: string,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
toString(): string {
|
|
38
|
+
return `${this.provider}/${this.modelId}`;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface LibrarianSettings {
|
|
43
|
+
model: ModelReference | undefined;
|
|
44
|
+
thinkingLevel: ThinkingLevel | undefined;
|
|
45
|
+
extensions: string[];
|
|
46
|
+
disabledTools: string[];
|
|
47
|
+
cacheDir: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function getDefaultCacheDir(): string {
|
|
51
|
+
return path.join(os.tmpdir(), "pi-librarian");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expandHome(rawPath: string): string {
|
|
55
|
+
if (rawPath === "~") {
|
|
56
|
+
return os.homedir();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (rawPath.startsWith(`~${path.sep}`) || rawPath.startsWith("~/")) {
|
|
60
|
+
return path.join(os.homedir(), rawPath.slice(2));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return rawPath;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeCacheDir(value: string | undefined): string {
|
|
67
|
+
const trimmed = value?.trim();
|
|
68
|
+
if (!trimmed) {
|
|
69
|
+
return getDefaultCacheDir();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const expanded = expandHome(trimmed);
|
|
73
|
+
if (!path.isAbsolute(expanded)) {
|
|
74
|
+
throw new Error('librarian.cacheDir must be an absolute path or start with "~/".');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return path.normalize(expanded);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function parseModelReference(value: string | undefined): ModelReference | undefined {
|
|
81
|
+
const trimmed = value?.trim();
|
|
82
|
+
if (!trimmed) {
|
|
83
|
+
return undefined;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const slashIndex = trimmed.indexOf("/");
|
|
87
|
+
if (slashIndex <= 0 || slashIndex === trimmed.length - 1) {
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return new ModelReference(trimmed.slice(0, slashIndex), trimmed.slice(slashIndex + 1));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function normalizeExtensionPaths(values: string[] | undefined): string[] {
|
|
95
|
+
if (!values) {
|
|
96
|
+
return [];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return values
|
|
100
|
+
.map((value) => value.trim())
|
|
101
|
+
.filter((value) => value.length > 0)
|
|
102
|
+
.map(expandHome);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function resolveLibrarianSettings(fileSettings: LibrarianFileSettings): LibrarianSettings {
|
|
106
|
+
return {
|
|
107
|
+
model: parseModelReference(fileSettings.model),
|
|
108
|
+
thinkingLevel: fileSettings.thinkingLevel,
|
|
109
|
+
extensions: normalizeExtensionPaths(fileSettings.extensions),
|
|
110
|
+
disabledTools: fileSettings.disabledTools ?? [],
|
|
111
|
+
cacheDir: normalizeCacheDir(fileSettings.cacheDir),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function loadSettings(): LibrarianSettings {
|
|
116
|
+
const globalSettings = SettingsManager.create(process.cwd()).getGlobalSettings();
|
|
117
|
+
const parsed = parseTypeBoxValue(ROOT_SETTINGS_SCHEMA, globalSettings, "Invalid settings");
|
|
118
|
+
return resolveLibrarianSettings(parsed.librarian ?? {});
|
|
119
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { CheckoutError, checkoutRepo } from "../checkout.ts";
|
|
4
|
+
import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
|
|
5
|
+
|
|
6
|
+
export interface CheckoutRepoDetails {
|
|
7
|
+
kind: "checkout_repo";
|
|
8
|
+
repo: string;
|
|
9
|
+
path: string;
|
|
10
|
+
headSha: string;
|
|
11
|
+
ref: string;
|
|
12
|
+
fileCount: number;
|
|
13
|
+
reusedClone: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const CheckoutRepoParams = Type.Object({
|
|
17
|
+
repo: Type.String({
|
|
18
|
+
description: "Repository as owner/repo (for Github) or an HTTPS/SSH repository URL.",
|
|
19
|
+
}),
|
|
20
|
+
ref: Type.Optional(
|
|
21
|
+
Type.String({
|
|
22
|
+
description: "Branch, tag, or commit sha. Default: the default branch.",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
export function createCheckoutRepoTool(cacheDir: string) {
|
|
28
|
+
return defineTool<typeof CheckoutRepoParams, CheckoutRepoDetails>({
|
|
29
|
+
name: LIBRARIAN_TOOL_NAMES.checkoutRepo,
|
|
30
|
+
label: "Checkout repo",
|
|
31
|
+
description: "Clone a repo (blob-less partial clone, cached locally).",
|
|
32
|
+
promptSnippet: "Clone a repo",
|
|
33
|
+
promptGuidelines: [
|
|
34
|
+
"Use when you want to deep dive on a specific repo. Follow up using grep/read/find/ls on the returned path, and `git -C <path> log/blame/diff` for history.",
|
|
35
|
+
"Do not checkout repos directly using `git`. Always use this tool instead.",
|
|
36
|
+
],
|
|
37
|
+
parameters: CheckoutRepoParams,
|
|
38
|
+
|
|
39
|
+
async execute(_toolCallId, params, signal) {
|
|
40
|
+
try {
|
|
41
|
+
const result = await checkoutRepo(params.repo, params.ref, cacheDir, signal);
|
|
42
|
+
const text = [
|
|
43
|
+
`Checked out ${result.repo}@${result.ref} (${result.headSha.slice(0, 12)})`,
|
|
44
|
+
`Local path: ${result.path}`,
|
|
45
|
+
`${result.fileCount} files.`,
|
|
46
|
+
].join("\n");
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
content: [{ type: "text", text }],
|
|
50
|
+
details: {
|
|
51
|
+
kind: "checkout_repo",
|
|
52
|
+
repo: result.repo,
|
|
53
|
+
path: result.path,
|
|
54
|
+
headSha: result.headSha,
|
|
55
|
+
ref: result.ref,
|
|
56
|
+
fileCount: result.fileCount,
|
|
57
|
+
reusedClone: result.reusedClone,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const message =
|
|
62
|
+
error instanceof CheckoutError
|
|
63
|
+
? error.message
|
|
64
|
+
: error instanceof Error
|
|
65
|
+
? error.message
|
|
66
|
+
: String(error);
|
|
67
|
+
return {
|
|
68
|
+
content: [
|
|
69
|
+
{
|
|
70
|
+
type: "text",
|
|
71
|
+
text: `${message}\nIf the repo name is uncertain, resolve it with search_repos first.`,
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
details: {
|
|
75
|
+
kind: "checkout_repo",
|
|
76
|
+
repo: params.repo,
|
|
77
|
+
path: "",
|
|
78
|
+
headSha: "",
|
|
79
|
+
ref: params.ref ?? "",
|
|
80
|
+
fileCount: 0,
|
|
81
|
+
reusedClone: false,
|
|
82
|
+
},
|
|
83
|
+
isError: true,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export const LIBRARIAN_TOOL_NAMES = {
|
|
2
|
+
searchRepos: "search_repos",
|
|
3
|
+
searchCode: "search_code",
|
|
4
|
+
searchGitHubCode: "search_github_code",
|
|
5
|
+
checkoutRepo: "checkout_repo",
|
|
6
|
+
readGitHubFile: "read_github_file",
|
|
7
|
+
provideResults: "provide_results",
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export const ATTACHABLE_TOOL_NAMES = [
|
|
11
|
+
LIBRARIAN_TOOL_NAMES.searchRepos,
|
|
12
|
+
LIBRARIAN_TOOL_NAMES.searchCode,
|
|
13
|
+
LIBRARIAN_TOOL_NAMES.searchGitHubCode,
|
|
14
|
+
LIBRARIAN_TOOL_NAMES.checkoutRepo,
|
|
15
|
+
LIBRARIAN_TOOL_NAMES.readGitHubFile,
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export const LIBRARIAN_RUN_TOOL_NAMES = [
|
|
19
|
+
...ATTACHABLE_TOOL_NAMES,
|
|
20
|
+
LIBRARIAN_TOOL_NAMES.provideResults,
|
|
21
|
+
] as const;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { type Static, Type } from "typebox";
|
|
3
|
+
import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
|
|
4
|
+
|
|
5
|
+
export const LocationSchema = Type.Object({
|
|
6
|
+
repo: Type.String({ description: "Repository as owner/repo or a repository URL." }),
|
|
7
|
+
file: Type.String({ description: "File path within the repository." }),
|
|
8
|
+
lines: Type.Optional(
|
|
9
|
+
Type.String({ description: 'Line range like "80-140" or a single line like "42".' }),
|
|
10
|
+
),
|
|
11
|
+
note: Type.String({ description: "Relevance of the file." }),
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
export const FindingsSchema = Type.Object({
|
|
15
|
+
summary: Type.String({
|
|
16
|
+
description: "1-3 sentence direct answer to the query. No preamble.",
|
|
17
|
+
}),
|
|
18
|
+
locations: Type.Array(LocationSchema, {
|
|
19
|
+
description: "Evidence locations.",
|
|
20
|
+
}),
|
|
21
|
+
description: Type.Optional(
|
|
22
|
+
Type.String({
|
|
23
|
+
description:
|
|
24
|
+
"Optional extended findings in markdown (e.g. step-by-step flow tracing, comparisons).",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export type Findings = Static<typeof FindingsSchema>;
|
|
30
|
+
export type FindingsLocation = Static<typeof LocationSchema>;
|
|
31
|
+
|
|
32
|
+
export interface ProvideResultsDetails {
|
|
33
|
+
kind: "provide_results";
|
|
34
|
+
locationCount: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function createProvideResultsTool(onFindings: (findings: Findings) => void) {
|
|
38
|
+
return defineTool<typeof FindingsSchema, ProvideResultsDetails>({
|
|
39
|
+
name: LIBRARIAN_TOOL_NAMES.provideResults,
|
|
40
|
+
label: "Provide results",
|
|
41
|
+
description: "Report your findings in structured form.",
|
|
42
|
+
promptSnippet: "Provide results",
|
|
43
|
+
promptGuidelines: [
|
|
44
|
+
"Tool must be called before the turn ends.",
|
|
45
|
+
"After calling this tool, the turn will end.",
|
|
46
|
+
],
|
|
47
|
+
parameters: FindingsSchema,
|
|
48
|
+
|
|
49
|
+
async execute(_toolCallId, params) {
|
|
50
|
+
onFindings(params);
|
|
51
|
+
return {
|
|
52
|
+
content: [
|
|
53
|
+
{ type: "text", text: "Findings recorded. You are done; do not call more tools." },
|
|
54
|
+
],
|
|
55
|
+
details: { kind: "provide_results", locationCount: params.locations.length },
|
|
56
|
+
terminate: true,
|
|
57
|
+
};
|
|
58
|
+
},
|
|
59
|
+
});
|
|
60
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { GitHubApiError, type GitHubClientProvider } from "../github.ts";
|
|
4
|
+
import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
|
|
5
|
+
|
|
6
|
+
const MAX_LINES_WITHOUT_RANGE = 1200;
|
|
7
|
+
|
|
8
|
+
export interface ReadGitHubFileDetails {
|
|
9
|
+
kind: "read_github_file";
|
|
10
|
+
owner: string;
|
|
11
|
+
repo: string;
|
|
12
|
+
path: string;
|
|
13
|
+
lineCount: number;
|
|
14
|
+
isDirectory: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const ReadGitHubFileParams = Type.Object({
|
|
18
|
+
owner: Type.String({ description: "Repository owner or organization." }),
|
|
19
|
+
repo: Type.String({ description: "Repository name." }),
|
|
20
|
+
path: Type.String({ description: "File or directory path within the repository." }),
|
|
21
|
+
ref: Type.Optional(
|
|
22
|
+
Type.String({ description: "Branch, tag, or commit sha. Default: the default branch." }),
|
|
23
|
+
),
|
|
24
|
+
range: Type.Optional(
|
|
25
|
+
Type.Tuple([Type.Number({ minimum: 1 }), Type.Number({ minimum: 1 })], {
|
|
26
|
+
description: "[startLine, endLine] (1-based, inclusive) for large files.",
|
|
27
|
+
}),
|
|
28
|
+
),
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export function createReadGitHubFileTool(githubClient: GitHubClientProvider) {
|
|
32
|
+
return defineTool<typeof ReadGitHubFileParams, ReadGitHubFileDetails>({
|
|
33
|
+
name: LIBRARIAN_TOOL_NAMES.readGitHubFile,
|
|
34
|
+
label: "Read GitHub file",
|
|
35
|
+
description:
|
|
36
|
+
"Read a single file (or list a directory) from a GitHub repo via the API, without cloning.",
|
|
37
|
+
promptSnippet: "Read Github file",
|
|
38
|
+
promptGuidelines: [
|
|
39
|
+
"For quick peeks — package.json, a README, one source file.",
|
|
40
|
+
"For multi-file exploration prefer checkout_repo.",
|
|
41
|
+
],
|
|
42
|
+
parameters: ReadGitHubFileParams,
|
|
43
|
+
|
|
44
|
+
async execute(_toolCallId, params) {
|
|
45
|
+
try {
|
|
46
|
+
const github = await githubClient();
|
|
47
|
+
const contents = await github.readContents({
|
|
48
|
+
repo: { owner: params.owner, repo: params.repo },
|
|
49
|
+
path: params.path,
|
|
50
|
+
ref: params.ref,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
if (contents.kind === "directory") {
|
|
54
|
+
const listing = contents.entries
|
|
55
|
+
.map((entry) => `${entry.path}${entry.type === "dir" ? "/" : ""}`)
|
|
56
|
+
.join("\n");
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: "text", text: listing || "(empty directory)" }],
|
|
59
|
+
details: {
|
|
60
|
+
kind: "read_github_file",
|
|
61
|
+
owner: params.owner,
|
|
62
|
+
repo: params.repo,
|
|
63
|
+
path: params.path,
|
|
64
|
+
lineCount: contents.entries.length,
|
|
65
|
+
isDirectory: true,
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const allLines = contents.text.split("\n");
|
|
71
|
+
let start = 1;
|
|
72
|
+
let end = allLines.length;
|
|
73
|
+
let clipNote = "";
|
|
74
|
+
|
|
75
|
+
if (params.range) {
|
|
76
|
+
start = Math.min(params.range[0], allLines.length);
|
|
77
|
+
end = Math.min(params.range[1], allLines.length);
|
|
78
|
+
} else if (allLines.length > MAX_LINES_WITHOUT_RANGE) {
|
|
79
|
+
end = MAX_LINES_WITHOUT_RANGE;
|
|
80
|
+
clipNote = `\n... clipped at line ${MAX_LINES_WITHOUT_RANGE} of ${allLines.length}; pass range to read further.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const width = String(end).length;
|
|
84
|
+
const numbered = allLines
|
|
85
|
+
.slice(start - 1, end)
|
|
86
|
+
.map((line, index) => `${String(start + index).padStart(width)}\t${line}`)
|
|
87
|
+
.join("\n");
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
content: [{ type: "text", text: `${numbered}${clipNote}` }],
|
|
91
|
+
details: {
|
|
92
|
+
kind: "read_github_file",
|
|
93
|
+
owner: params.owner,
|
|
94
|
+
repo: params.repo,
|
|
95
|
+
path: params.path,
|
|
96
|
+
lineCount: end - start + 1,
|
|
97
|
+
isDirectory: false,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const message =
|
|
102
|
+
error instanceof GitHubApiError && error.status === 404
|
|
103
|
+
? `${params.owner}/${params.repo}:${params.path} not found (check the path and ref; private repos need gh auth).`
|
|
104
|
+
: error instanceof Error
|
|
105
|
+
? error.message
|
|
106
|
+
: String(error);
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: "text", text: message }],
|
|
109
|
+
details: {
|
|
110
|
+
kind: "read_github_file",
|
|
111
|
+
owner: params.owner,
|
|
112
|
+
repo: params.repo,
|
|
113
|
+
path: params.path,
|
|
114
|
+
lineCount: 0,
|
|
115
|
+
isDirectory: false,
|
|
116
|
+
},
|
|
117
|
+
isError: true,
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import { type GrepCodeMatch, searchCodeGrep } from "../grep-app.ts";
|
|
4
|
+
import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
|
|
5
|
+
|
|
6
|
+
export interface SearchCodeDetails {
|
|
7
|
+
kind: "search_code";
|
|
8
|
+
pattern: string;
|
|
9
|
+
backend: "grep.app";
|
|
10
|
+
matchCount: number;
|
|
11
|
+
repoCount: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const SearchCodeParams = Type.Object({
|
|
15
|
+
pattern: Type.String({
|
|
16
|
+
description:
|
|
17
|
+
"Public code search pattern. Literal by default; set regex: true to treat it as a regular expression.",
|
|
18
|
+
}),
|
|
19
|
+
regex: Type.Optional(
|
|
20
|
+
Type.Boolean({
|
|
21
|
+
description:
|
|
22
|
+
"Treat pattern as a regular expression. Grep supports multi-line regex with (?s).",
|
|
23
|
+
}),
|
|
24
|
+
),
|
|
25
|
+
repo: Type.Optional(
|
|
26
|
+
Type.String({
|
|
27
|
+
description:
|
|
28
|
+
"Restrict to a repository or repository prefix, e.g. drizzle-team/drizzle-orm or vercel.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
language: Type.Optional(Type.String({ description: "Restrict to a language, e.g. TypeScript." })),
|
|
32
|
+
path: Type.Optional(
|
|
33
|
+
Type.String({
|
|
34
|
+
description: "Restrict to file paths matching this pattern.",
|
|
35
|
+
}),
|
|
36
|
+
),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
function formatGrepMatches(matches: GrepCodeMatch[]): string {
|
|
40
|
+
return matches
|
|
41
|
+
.map((match) => {
|
|
42
|
+
const snippets = match.snippets
|
|
43
|
+
.slice(0, 2)
|
|
44
|
+
.map((snippet) => {
|
|
45
|
+
const lines = snippet.content
|
|
46
|
+
.split("\n")
|
|
47
|
+
.slice(0, 8)
|
|
48
|
+
.map((line, offset) => ` ${snippet.lineNumber + offset}: ${line.trimEnd()}`)
|
|
49
|
+
.join("\n");
|
|
50
|
+
return lines;
|
|
51
|
+
})
|
|
52
|
+
.join("\n");
|
|
53
|
+
const url = match.url ? `\n ${match.url}` : "";
|
|
54
|
+
return ` ${match.repo}:${match.path}${url}${snippets ? `\n${snippets}` : ""}`;
|
|
55
|
+
})
|
|
56
|
+
.join("\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const searchCodeTool = defineTool<typeof SearchCodeParams, SearchCodeDetails>({
|
|
60
|
+
name: LIBRARIAN_TOOL_NAMES.searchCode,
|
|
61
|
+
label: "Search code across repos",
|
|
62
|
+
description: "Cross-repo code search over public source code.",
|
|
63
|
+
promptSnippet: "Search code across repos",
|
|
64
|
+
promptGuidelines: [
|
|
65
|
+
"Results are CANDIDATES to verify via checkout_repo/read_github_file — never cite them directly.",
|
|
66
|
+
"This searches public GitHub code only. Use search_github_code when private repository access matters.",
|
|
67
|
+
],
|
|
68
|
+
parameters: SearchCodeParams,
|
|
69
|
+
|
|
70
|
+
async execute(_toolCallId, params, signal) {
|
|
71
|
+
const result = await searchCodeGrep(
|
|
72
|
+
{
|
|
73
|
+
query: params.pattern,
|
|
74
|
+
...(params.regex ? { useRegexp: true } : {}),
|
|
75
|
+
...(params.repo ? { repo: params.repo } : {}),
|
|
76
|
+
...(params.language ? { language: [params.language] } : {}),
|
|
77
|
+
...(params.path ? { path: params.path } : {}),
|
|
78
|
+
},
|
|
79
|
+
signal,
|
|
80
|
+
);
|
|
81
|
+
const repoCount = new Set(result.matches.map((match) => match.repo)).size;
|
|
82
|
+
const body =
|
|
83
|
+
result.matches.length === 0
|
|
84
|
+
? "No matches. Note: private repositories need search_github_code or checkout_repo + grep."
|
|
85
|
+
: formatGrepMatches(result.matches);
|
|
86
|
+
const header = `${result.matches.length} matches across ${repoCount} repos (grep.app).`;
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
content: [{ type: "text", text: `${header}\n\n${body}` }],
|
|
90
|
+
details: {
|
|
91
|
+
kind: "search_code",
|
|
92
|
+
pattern: params.pattern,
|
|
93
|
+
backend: "grep.app",
|
|
94
|
+
matchCount: result.matches.length,
|
|
95
|
+
repoCount,
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { defineTool } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { Type } from "typebox";
|
|
3
|
+
import type { GitHubClientProvider, GitHubCodeSearchResult, GitHubRepoSlug } from "../github.ts";
|
|
4
|
+
import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
|
|
5
|
+
|
|
6
|
+
export interface SearchGitHubCodeDetails {
|
|
7
|
+
kind: "search_github_code";
|
|
8
|
+
pattern: string;
|
|
9
|
+
matchCount: number;
|
|
10
|
+
repoCount: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const GitHubRepoScopeParams = Type.Object({
|
|
14
|
+
owner: Type.String({ description: "Repository owner or organization." }),
|
|
15
|
+
repo: Type.String({ description: "Repository name." }),
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const SearchGitHubCodeParams = Type.Object({
|
|
19
|
+
pattern: Type.String({
|
|
20
|
+
description: "GitHub REST code search pattern.",
|
|
21
|
+
}),
|
|
22
|
+
repos: Type.Optional(
|
|
23
|
+
Type.Array(GitHubRepoScopeParams, {
|
|
24
|
+
description: "Restrict to these repositories.",
|
|
25
|
+
maxItems: 20,
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
owners: Type.Optional(
|
|
29
|
+
Type.Array(Type.String(), {
|
|
30
|
+
description: "Restrict to these owners/orgs.",
|
|
31
|
+
maxItems: 20,
|
|
32
|
+
}),
|
|
33
|
+
),
|
|
34
|
+
language: Type.Optional(Type.String({ description: "Restrict to a language, e.g. typescript." })),
|
|
35
|
+
path: Type.Optional(
|
|
36
|
+
Type.String({
|
|
37
|
+
description: "Restrict to file paths matching this pattern.",
|
|
38
|
+
}),
|
|
39
|
+
),
|
|
40
|
+
limit: Type.Optional(
|
|
41
|
+
Type.Number({
|
|
42
|
+
minimum: 1,
|
|
43
|
+
maximum: 100,
|
|
44
|
+
description: "Max results. Default: 30.",
|
|
45
|
+
}),
|
|
46
|
+
),
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export function buildGitHubCodeQuery(params: {
|
|
50
|
+
pattern: string;
|
|
51
|
+
repos?: GitHubRepoSlug[];
|
|
52
|
+
owners?: string[];
|
|
53
|
+
language?: string;
|
|
54
|
+
path?: string;
|
|
55
|
+
}): string {
|
|
56
|
+
const parts: string[] = [params.pattern.replace(/^\/|\/$/g, "")];
|
|
57
|
+
|
|
58
|
+
for (const repo of params.repos ?? []) {
|
|
59
|
+
parts.push(`repo:${repo.owner}/${repo.repo}`);
|
|
60
|
+
}
|
|
61
|
+
for (const owner of params.owners ?? []) {
|
|
62
|
+
parts.push(`user:${owner}`);
|
|
63
|
+
}
|
|
64
|
+
if (params.language) {
|
|
65
|
+
parts.push(`language:${params.language}`);
|
|
66
|
+
}
|
|
67
|
+
if (params.path) {
|
|
68
|
+
parts.push(`path:${params.path}`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return parts.join(" ");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function formatGitHubMatches(result: GitHubCodeSearchResult): string {
|
|
75
|
+
return result.hits
|
|
76
|
+
.map((hit) => {
|
|
77
|
+
const fragments = hit.fragments
|
|
78
|
+
.slice(0, 2)
|
|
79
|
+
.map((fragment) => ` ${fragment.split("\n").slice(0, 3).join("\n ")}`)
|
|
80
|
+
.join("\n");
|
|
81
|
+
return ` ${hit.repo}:${hit.path}${fragments ? `\n${fragments}` : ""}`;
|
|
82
|
+
})
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function createSearchGitHubCodeTool(githubClient: GitHubClientProvider) {
|
|
87
|
+
return defineTool<typeof SearchGitHubCodeParams, SearchGitHubCodeDetails>({
|
|
88
|
+
name: LIBRARIAN_TOOL_NAMES.searchGitHubCode,
|
|
89
|
+
label: "Search GitHub code",
|
|
90
|
+
description:
|
|
91
|
+
"GitHub REST code search over public code and private repositories your configured GitHub auth can access.",
|
|
92
|
+
promptSnippet: "Search GitHub code",
|
|
93
|
+
promptGuidelines: [
|
|
94
|
+
"Results are CANDIDATES to verify via checkout_repo/read_github_file — never cite them directly.",
|
|
95
|
+
"GitHub REST code search is literal/tokenized and does not support regex; use search_code for public regex/global code search.",
|
|
96
|
+
],
|
|
97
|
+
parameters: SearchGitHubCodeParams,
|
|
98
|
+
|
|
99
|
+
async execute(_toolCallId, params) {
|
|
100
|
+
const limit = params.limit ?? 30;
|
|
101
|
+
const github = await githubClient();
|
|
102
|
+
const result = await github.searchCode({
|
|
103
|
+
pattern: params.pattern,
|
|
104
|
+
...(params.repos ? { repos: params.repos } : {}),
|
|
105
|
+
...(params.owners ? { owners: params.owners } : {}),
|
|
106
|
+
...(params.language ? { language: params.language } : {}),
|
|
107
|
+
...(params.path ? { path: params.path } : {}),
|
|
108
|
+
limit,
|
|
109
|
+
});
|
|
110
|
+
const repoCount = new Set(result.hits.map((hit) => hit.repo)).size;
|
|
111
|
+
const body = result.hits.length === 0 ? "No matches." : formatGitHubMatches(result);
|
|
112
|
+
const header = `${result.hits.length} of ${result.totalCount} matches across ${repoCount} repos (github).`;
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
content: [{ type: "text", text: `${header}\n\n${body}` }],
|
|
116
|
+
details: {
|
|
117
|
+
kind: "search_github_code",
|
|
118
|
+
pattern: params.pattern,
|
|
119
|
+
matchCount: result.hits.length,
|
|
120
|
+
repoCount,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|