@diegopetrucci/pi-extensions 0.1.17 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions I made:
|
|
4
4
|
|
|
5
|
-
- [`quiet-tools`](./extensions/quiet-tools): Renders collapsed built-in tool rows as quiet one-line previews without changing model-visible tool results; toggle temporarily with `/quiet-tools`.
|
|
6
5
|
- [`confirm-destructive`](./extensions/confirm-destructive): Confirms before destructive session actions like clear, switch, and fork.
|
|
7
6
|
- [`context-cap`](./extensions/context-cap): Caps effective model context windows at 200k tokens by default so pi avoids the `dumb zone`; toggle temporarily with `/context-cap`.
|
|
7
|
+
- [`librarian`](./extensions/librarian): Adds a GitHub research scout that asks whether to use an opt-in local repo checkout cache under the OS user cache directory, with cached repos expiring after 30 days of non-use.
|
|
8
8
|
- [`minimal-footer`](./extensions/minimal-footer): Replaces pi's built-in footer with a minimal configurable two-line layout: branch/repo on the first line, context/model on the second, optional `DUMB ZONE`, plus OpenAI Codex 5-hour and 7-day usage when available.
|
|
9
9
|
- [`notify`](./extensions/notify): Sends configurable terminal, desktop, bell, and sound notifications when pi finishes and is ready for input.
|
|
10
10
|
- [`oracle`](./extensions/oracle): Adds an Amp-style read-only oracle tool that auto-selects the strongest reasoning model on the current provider/subscription, covers pi’s built-in providers with hardcoded rankings, sets reasoning to xhigh by default, and shows live status while running.
|
|
11
11
|
- [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash commands like `rm -rf`, `sudo`, and `chmod 777`.
|
|
12
|
+
- [`quiet-tools`](./extensions/quiet-tools): Renders collapsed built-in tool rows as quiet one-line previews without changing model-visible tool results; toggle temporarily with `/quiet-tools`.
|
|
12
13
|
|
|
13
14
|
(For the full list of pi extensions I use, [check out my dotfiles](https://github.com/diegopetrucci/dot/blob/main/.pi/agent/settings.json).)
|
|
14
15
|
|
|
@@ -23,7 +24,7 @@ pi install npm:@diegopetrucci/pi-extensions
|
|
|
23
24
|
Or pin the GitHub package to this release:
|
|
24
25
|
|
|
25
26
|
```bash
|
|
26
|
-
pi install git:github.com/diegopetrucci/pi-extensions@v0.1.
|
|
27
|
+
pi install git:github.com/diegopetrucci/pi-extensions@v0.1.18
|
|
27
28
|
```
|
|
28
29
|
|
|
29
30
|
Or a specific extension:
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# librarian
|
|
2
|
+
|
|
3
|
+
A pi GitHub research scout inspired by `pi-librarian`, with an opt-in local checkout cache.
|
|
4
|
+
|
|
5
|
+
When the `librarian` tool runs, it asks whether to cache/reuse repository checkouts locally. If you say no, cancel, time out, or run without UI, it uses GitHub API/search and temporary fetched files only.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
### Standalone npm package
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
pi install npm:@diegopetrucci/pi-librarian
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
### Collection package
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:@diegopetrucci/pi-extensions
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
### GitHub package
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install git:github.com/diegopetrucci/pi-extensions
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Then reload pi:
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
/reload
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Behavior
|
|
34
|
+
|
|
35
|
+
- Tool name: `librarian`
|
|
36
|
+
- Uses a restricted subagent with `bash` and `read`
|
|
37
|
+
- Uses `gh` for GitHub search/API access
|
|
38
|
+
- Asks on each call whether to use cached local checkouts
|
|
39
|
+
- Defaults to no checkout/cache
|
|
40
|
+
- Cached repos are removed lazily after 30 days without use
|
|
41
|
+
|
|
42
|
+
## Cache location
|
|
43
|
+
|
|
44
|
+
macOS:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
~/Library/Caches/pi-librarian/repos/github.com/<owner>/<repo>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Linux:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
${XDG_CACHE_HOME:-~/.cache}/pi-librarian/repos/github.com/<owner>/<repo>
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Windows:
|
|
57
|
+
|
|
58
|
+
```text
|
|
59
|
+
%LOCALAPPDATA%\pi-librarian\repos\github.com\<owner>\<repo>
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Override the cache root if needed:
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export PI_LIBRARIAN_CACHE_ROOT="$HOME/Library/Caches/pi-librarian/repos"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Requirements
|
|
69
|
+
|
|
70
|
+
- GitHub CLI (`gh`) installed and authenticated for private repositories you want to inspect
|
|
71
|
+
- `git` for local checkout caching
|
|
72
|
+
- common shell tools such as `rg`, `jq`, and `base64` for best results
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
Do not install this alongside another extension that registers a `librarian` tool unless you intentionally want duplicate/conflicting tool names.
|
|
@@ -0,0 +1,727 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
import type { FileHandle } from "node:fs/promises";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as os from "node:os";
|
|
5
|
+
import * as path from "node:path";
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import {
|
|
9
|
+
DefaultResourceLoader,
|
|
10
|
+
SessionManager,
|
|
11
|
+
createAgentSession,
|
|
12
|
+
getAgentDir,
|
|
13
|
+
getMarkdownTheme,
|
|
14
|
+
} from "@earendil-works/pi-coding-agent";
|
|
15
|
+
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
16
|
+
import { Type } from "typebox";
|
|
17
|
+
|
|
18
|
+
const DEFAULT_MAX_SEARCH_RESULTS = 30;
|
|
19
|
+
const MAX_SEARCH_RESULTS = 100;
|
|
20
|
+
const MAX_TURNS = 10;
|
|
21
|
+
const MAX_TOOL_CALLS_TO_KEEP = 80;
|
|
22
|
+
const DEFAULT_BASH_TIMEOUT_SECONDS = 60;
|
|
23
|
+
const MAX_RUN_MS = 10 * 60 * 1000;
|
|
24
|
+
const CACHE_TTL_DAYS = 30;
|
|
25
|
+
const CACHE_TTL_MS = CACHE_TTL_DAYS * 24 * 60 * 60 * 1000;
|
|
26
|
+
const CACHE_METADATA_FILE = ".pi-librarian-cache.json";
|
|
27
|
+
const CACHE_MARKER_FILE = ".pi-librarian-cache-used";
|
|
28
|
+
|
|
29
|
+
type LibrarianStatus = "running" | "done" | "error" | "aborted";
|
|
30
|
+
|
|
31
|
+
type CacheMode = "disabled" | "enabled";
|
|
32
|
+
|
|
33
|
+
type ToolCall = {
|
|
34
|
+
id: string;
|
|
35
|
+
name: string;
|
|
36
|
+
args: unknown;
|
|
37
|
+
startedAt: number;
|
|
38
|
+
endedAt?: number;
|
|
39
|
+
isError?: boolean;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type CacheDetails = {
|
|
43
|
+
mode: CacheMode;
|
|
44
|
+
root: string;
|
|
45
|
+
ttlDays: number;
|
|
46
|
+
cleanupDeleted: number;
|
|
47
|
+
cleanupErrors: string[];
|
|
48
|
+
decisionReason: string;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
type LibrarianDetails = {
|
|
52
|
+
status: LibrarianStatus;
|
|
53
|
+
workspace: string;
|
|
54
|
+
cache: CacheDetails;
|
|
55
|
+
turns: number;
|
|
56
|
+
toolCalls: ToolCall[];
|
|
57
|
+
startedAt: number;
|
|
58
|
+
endedAt?: number;
|
|
59
|
+
error?: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const LibrarianParams = Type.Object({
|
|
63
|
+
query: Type.String({
|
|
64
|
+
description: [
|
|
65
|
+
"Describe exactly what to find in GitHub code.",
|
|
66
|
+
"Include known context in the query when you have it (symbols/behavior, repo or owner hints, refs/branches, paths, and desired output).",
|
|
67
|
+
"Do not guess unknown details; if scope is uncertain, say that explicitly and let Librarian discover it.",
|
|
68
|
+
"Librarian returns concise path-first findings with line-ranged evidence from GitHub files.",
|
|
69
|
+
].join("\n"),
|
|
70
|
+
}),
|
|
71
|
+
repos: Type.Optional(
|
|
72
|
+
Type.Array(Type.String({ description: "Optional owner/repo filters (e.g. octocat/hello-world)" }), {
|
|
73
|
+
description: "Optional explicit repository scope.",
|
|
74
|
+
maxItems: 30,
|
|
75
|
+
}),
|
|
76
|
+
),
|
|
77
|
+
owners: Type.Optional(
|
|
78
|
+
Type.Array(Type.String({ description: "Optional owner/org filters" }), {
|
|
79
|
+
description: "Optional owner/org scope.",
|
|
80
|
+
maxItems: 30,
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
maxSearchResults: Type.Optional(
|
|
84
|
+
Type.Number({
|
|
85
|
+
description: `Maximum GitHub search hits per query (1-${MAX_SEARCH_RESULTS}, default ${DEFAULT_MAX_SEARCH_RESULTS})`,
|
|
86
|
+
minimum: 1,
|
|
87
|
+
maximum: MAX_SEARCH_RESULTS,
|
|
88
|
+
default: DEFAULT_MAX_SEARCH_RESULTS,
|
|
89
|
+
}),
|
|
90
|
+
),
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
function asStringArray(value: unknown, maxItems = 30): string[] {
|
|
94
|
+
if (!Array.isArray(value)) return [];
|
|
95
|
+
const out: string[] = [];
|
|
96
|
+
for (const item of value) {
|
|
97
|
+
if (typeof item !== "string") continue;
|
|
98
|
+
const trimmed = item.trim();
|
|
99
|
+
if (!trimmed) continue;
|
|
100
|
+
out.push(trimmed);
|
|
101
|
+
if (out.length >= maxItems) break;
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function clampNumber(value: unknown, min: number, max: number, fallback: number): number {
|
|
107
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
108
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
109
|
+
return Math.min(max, Math.max(min, Math.floor(parsed)));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function shorten(text: string, max: number): string {
|
|
113
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
114
|
+
if (oneLine.length <= max) return oneLine;
|
|
115
|
+
return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getUserCacheReposRoot(): string {
|
|
119
|
+
if (process.env.PI_LIBRARIAN_CACHE_ROOT?.trim()) {
|
|
120
|
+
return path.resolve(expandHome(process.env.PI_LIBRARIAN_CACHE_ROOT.trim()));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (process.platform === "darwin") {
|
|
124
|
+
return path.join(os.homedir(), "Library", "Caches", "pi-librarian", "repos");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (process.platform === "win32") {
|
|
128
|
+
const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local");
|
|
129
|
+
return path.join(localAppData, "pi-librarian", "repos");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const xdgCacheHome = process.env.XDG_CACHE_HOME?.trim() || path.join(os.homedir(), ".cache");
|
|
133
|
+
return path.join(xdgCacheHome, "pi-librarian", "repos");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function expandHome(value: string): string {
|
|
137
|
+
if (value === "~") return os.homedir();
|
|
138
|
+
if (value.startsWith(`~${path.sep}`)) return path.join(os.homedir(), value.slice(2));
|
|
139
|
+
if (path.sep === "/" && value.startsWith("~/")) return path.join(os.homedir(), value.slice(2));
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function pathExists(filePath: string): Promise<boolean> {
|
|
144
|
+
try {
|
|
145
|
+
await fs.access(filePath);
|
|
146
|
+
return true;
|
|
147
|
+
} catch {
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function safeReadDir(dir: string): Promise<Dirent[]> {
|
|
153
|
+
try {
|
|
154
|
+
return await fs.readdir(dir, { withFileTypes: true });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
157
|
+
throw error;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function isInside(parent: string, child: string): boolean {
|
|
162
|
+
const parentResolved = path.resolve(parent);
|
|
163
|
+
const childResolved = path.resolve(child);
|
|
164
|
+
return childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function parseLastUsedAt(value: unknown): number | undefined {
|
|
168
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
169
|
+
if (typeof value === "string") {
|
|
170
|
+
const numeric = Number(value);
|
|
171
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
172
|
+
const parsed = Date.parse(value);
|
|
173
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
174
|
+
}
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function getRepoLastUsedAt(repoDir: string): Promise<number | undefined> {
|
|
179
|
+
const metadataPath = path.join(repoDir, CACHE_METADATA_FILE);
|
|
180
|
+
try {
|
|
181
|
+
const raw = await fs.readFile(metadataPath, "utf8");
|
|
182
|
+
const parsed = JSON.parse(raw) as { lastUsedAt?: unknown; updatedAt?: unknown; createdAt?: unknown };
|
|
183
|
+
const fromMetadata =
|
|
184
|
+
parseLastUsedAt(parsed.lastUsedAt) ?? parseLastUsedAt(parsed.updatedAt) ?? parseLastUsedAt(parsed.createdAt);
|
|
185
|
+
if (fromMetadata !== undefined) return fromMetadata;
|
|
186
|
+
} catch {
|
|
187
|
+
// Fall through to marker mtime for older managed caches.
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const markerPath = path.join(repoDir, CACHE_MARKER_FILE);
|
|
191
|
+
try {
|
|
192
|
+
return (await fs.stat(markerPath)).mtimeMs;
|
|
193
|
+
} catch {
|
|
194
|
+
return undefined;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
async function isManagedRepoCache(repoDir: string): Promise<boolean> {
|
|
199
|
+
return (await pathExists(path.join(repoDir, ".git"))) &&
|
|
200
|
+
((await pathExists(path.join(repoDir, CACHE_METADATA_FILE))) ||
|
|
201
|
+
(await pathExists(path.join(repoDir, CACHE_MARKER_FILE))));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function cleanupExpiredCache(cacheRoot: string): Promise<{ deleted: number; errors: string[] }> {
|
|
205
|
+
const errors: string[] = [];
|
|
206
|
+
let deleted = 0;
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
const root = path.resolve(cacheRoot);
|
|
209
|
+
const lockPath = path.join(root, ".cleanup.lock");
|
|
210
|
+
let lock: FileHandle | undefined;
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
lock = await fs.open(lockPath, "wx");
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if ((error as NodeJS.ErrnoException).code === "EEXIST") return { deleted, errors };
|
|
216
|
+
throw error;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
try {
|
|
220
|
+
const hosts = await safeReadDir(root);
|
|
221
|
+
for (const host of hosts) {
|
|
222
|
+
if (!host.isDirectory()) continue;
|
|
223
|
+
const hostDir = path.join(root, host.name);
|
|
224
|
+
const owners = await safeReadDir(hostDir);
|
|
225
|
+
for (const owner of owners) {
|
|
226
|
+
if (!owner.isDirectory()) continue;
|
|
227
|
+
const ownerDir = path.join(hostDir, owner.name);
|
|
228
|
+
const repos = await safeReadDir(ownerDir);
|
|
229
|
+
for (const repo of repos) {
|
|
230
|
+
if (!repo.isDirectory()) continue;
|
|
231
|
+
const repoDir = path.join(ownerDir, repo.name);
|
|
232
|
+
if (!isInside(root, repoDir)) continue;
|
|
233
|
+
|
|
234
|
+
try {
|
|
235
|
+
if (!(await isManagedRepoCache(repoDir))) continue;
|
|
236
|
+
const lastUsedAt = await getRepoLastUsedAt(repoDir);
|
|
237
|
+
if (lastUsedAt === undefined || now - lastUsedAt <= CACHE_TTL_MS) continue;
|
|
238
|
+
await fs.rm(repoDir, { recursive: true, force: true });
|
|
239
|
+
deleted += 1;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
242
|
+
errors.push(`${repoDir}: ${message}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return { deleted, errors };
|
|
249
|
+
} finally {
|
|
250
|
+
await lock?.close().catch(() => undefined);
|
|
251
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async function askForCache(ctx: ExtensionContext, cacheRoot: string): Promise<{ enabled: boolean; reason: string }> {
|
|
256
|
+
if (!ctx.hasUI) return { enabled: false, reason: "no UI available; using GitHub API/temp files only" };
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const enabled = await ctx.ui.confirm(
|
|
260
|
+
"Librarian repo cache",
|
|
261
|
+
[
|
|
262
|
+
"Cache/reuse local GitHub repo checkouts for this Librarian call?",
|
|
263
|
+
"",
|
|
264
|
+
`Cache directory: ${cacheRoot}`,
|
|
265
|
+
`Repos unused for ${CACHE_TTL_DAYS} days are removed lazily on future Librarian calls.`,
|
|
266
|
+
"",
|
|
267
|
+
"Choose No to use GitHub API and temporary fetched files only.",
|
|
268
|
+
].join("\n"),
|
|
269
|
+
{ timeout: 30_000 },
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
return enabled
|
|
273
|
+
? { enabled: true, reason: "user opted into cached local checkouts" }
|
|
274
|
+
: { enabled: false, reason: "user declined or prompt timed out" };
|
|
275
|
+
} catch (error) {
|
|
276
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
+
return { enabled: false, reason: `cache prompt failed (${message}); using GitHub API/temp files only` };
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function resolveToolPath(cwd: string, rawPath: string): string {
|
|
282
|
+
const normalized = rawPath.startsWith("@") ? rawPath.slice(1) : rawPath;
|
|
283
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function getBlockedBashReason(command: string, options: { workspace: string; cacheRoot: string; cacheEnabled: boolean }): string | undefined {
|
|
287
|
+
const scan = command.split(options.workspace).join("<WORKSPACE>").split(options.cacheRoot).join("<CACHE>");
|
|
288
|
+
if (/(^|[\n;&|()])\s*\//.test(scan)) return "Librarian bash blocks absolute-path executables.";
|
|
289
|
+
|
|
290
|
+
const destructiveLocal = /(^|[\n;&|()])\s*(?:sudo|su|rm|rmdir|mv|cp|chmod|chown|dd|truncate|killall|pkill|launchctl|osascript|pbcopy|pbpaste|eval|exec|xargs)(?=$|[\s;&|()])/;
|
|
291
|
+
if (destructiveLocal.test(scan)) return "Librarian bash blocks destructive/local side-effect commands.";
|
|
292
|
+
|
|
293
|
+
const extraNetworkOrShell = /(^|[\n;&|()])\s*(?:curl|wget|nc|netcat|ssh|scp|sftp|rsync|bash|sh|zsh|fish|python|python3|perl|ruby|node|deno)(?=$|[\s;&|()])/;
|
|
294
|
+
if (extraNetworkOrShell.test(scan)) return "Librarian bash is limited to gh/git and simple local inspection commands.";
|
|
295
|
+
|
|
296
|
+
if (/(^|[\s"'=])(?:~\/|\$HOME\b|\/etc\/|\/var\/|\/private\/|\/root\/|\/Users\/|\/home\/|\/opt\/)/.test(scan)) {
|
|
297
|
+
return "Librarian bash may only access its workspace and approved cache root.";
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
if (/\bgh\s+auth\s+token\b/.test(scan) || /(^|[\n;&|()])\s*(?:env|printenv|set|export|declare|echo|printf)(?=$|[\s;&|()])/.test(scan)) {
|
|
301
|
+
return "Librarian bash blocks credential/environment inspection.";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (/\bgh\s+api\b(?=.*(?:(?:-X|--method)\s*(?:POST|PUT|PATCH|DELETE)\b|(?:-f|--field|-F|--raw-field|--input)\b))/i.test(scan)) {
|
|
305
|
+
return "Librarian bash allows read-only GitHub API calls only.";
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (/\bgh\s+(?:repo\s+(?:delete|edit|archive|fork|create)|pr\s+(?:merge|close|edit)|issue\s+(?:close|edit|delete)|release\s+(?:create|delete|upload)|workflow\s+run|gist\s+(?:create|delete|edit))\b/.test(scan)) {
|
|
309
|
+
return "Librarian bash blocks mutating gh commands.";
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (/\bgit\b[^\n;&|]*\b(?:push|commit|merge|rebase|clean|reset|tag)\b/.test(scan)) {
|
|
313
|
+
return "Librarian bash blocks mutating git commands except local checkout/fetch for cache use.";
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return undefined;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function createLibrarianRuntimeGuardExtension(options: {
|
|
320
|
+
maxTurns: number;
|
|
321
|
+
workspace: string;
|
|
322
|
+
cacheRoot: string;
|
|
323
|
+
cacheEnabled: boolean;
|
|
324
|
+
}): ExtensionFactory {
|
|
325
|
+
return (pi) => {
|
|
326
|
+
let currentTurn = 0;
|
|
327
|
+
|
|
328
|
+
pi.on("turn_start", async (event) => {
|
|
329
|
+
currentTurn = event.turnIndex;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
pi.on("tool_call", async (event) => {
|
|
333
|
+
if (currentTurn >= options.maxTurns - 1) {
|
|
334
|
+
return {
|
|
335
|
+
block: true,
|
|
336
|
+
reason: `Tool use is disabled on final Librarian turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (event.toolName === "read") {
|
|
341
|
+
const input = event.input as { path?: unknown };
|
|
342
|
+
if (typeof input.path !== "string") return undefined;
|
|
343
|
+
const resolved = resolveToolPath(options.workspace, input.path);
|
|
344
|
+
const realPath = await fs.realpath(resolved).catch(() => resolved);
|
|
345
|
+
const allowed =
|
|
346
|
+
isInside(options.workspace, realPath) ||
|
|
347
|
+
(options.cacheEnabled && isInside(options.cacheRoot, realPath));
|
|
348
|
+
if (!allowed) return { block: true, reason: `Librarian read is limited to its workspace/cache: ${realPath}` };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (event.toolName === "bash") {
|
|
352
|
+
const input = event.input as { command?: unknown; timeout?: unknown };
|
|
353
|
+
if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
|
|
354
|
+
|
|
355
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
356
|
+
if (!options.cacheEnabled && /\b(?:git\s+clone|gh\s+repo\s+clone)\b/.test(command)) {
|
|
357
|
+
return { block: true, reason: "Local repo checkout cache is disabled for this Librarian call." };
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const blockedReason = getBlockedBashReason(command, options);
|
|
361
|
+
if (blockedReason) return { block: true, reason: blockedReason };
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return undefined;
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
pi.on("tool_result", async (event) => ({
|
|
368
|
+
content: [
|
|
369
|
+
...(event.content ?? []),
|
|
370
|
+
{
|
|
371
|
+
type: "text",
|
|
372
|
+
text: `\n\n[librarian turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
|
|
373
|
+
},
|
|
374
|
+
],
|
|
375
|
+
}));
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function buildSystemPrompt(options: {
|
|
380
|
+
workspace: string;
|
|
381
|
+
maxSearchResults: number;
|
|
382
|
+
cacheEnabled: boolean;
|
|
383
|
+
cacheRoot: string;
|
|
384
|
+
}): string {
|
|
385
|
+
const cacheSection = options.cacheEnabled
|
|
386
|
+
? `\nLocal checkout cache is ENABLED for this call.\n- Cache root: ${options.cacheRoot}\n- Use checkout path pattern: ${options.cacheRoot}/github.com/<owner>/<repo>\n- Reuse an existing checkout when it has a .git directory. Fetch/prune before relying on it: git -C "$DIR" fetch --all --prune --tags --quiet\n- If missing, clone with gh repo clone "$REPO" "$DIR" (or git clone https://github.com/$REPO.git "$DIR").\n- If a ref/branch/SHA is requested, fetch it and check it out locally before citing files from that ref.\n- After using a checkout, update its cache marker: touch "$DIR/${CACHE_MARKER_FILE}"\n- Prefer local rg/read inside cached checkouts once a repo is cloned, and cite absolute cached paths with line ranges.\n- Clone only repositories that are relevant to the query; do not bulk-clone broad owner/org scopes unless necessary.`
|
|
387
|
+
: `\nLocal checkout cache is DISABLED for this call.\n- Do not clone repositories.\n- Use gh search/API/tree/contents calls and cache only necessary proof files under ${options.workspace}/repos/<owner>/<repo>/<path>.`;
|
|
388
|
+
|
|
389
|
+
return `You are Librarian, an evidence-first GitHub code scout running inside pi.\n\nUse only the available bash/read tools. Use gh, jq, rg, find/fd, ls, stat, mkdir, base64, and nl -ba for GitHub reconnaissance and numbered evidence. Use read for focused local file inspection.\n\nWorkspace: ${options.workspace}\nDefault gh search limit: ${options.maxSearchResults}\nTurn budget: ${MAX_TURNS} turns total, including your final answer. Stop searching once you have enough evidence.\n${cacheSection}\n\nNon-negotiable constraints:\n- Never treat gh search snippets as proof by themselves. Use fetched files or local checkouts for code-content claims.\n- Keep temporary workspace writes under ${options.workspace}/repos unless local checkout cache is enabled, in which case writes under the cache root are also allowed.
|
|
390
|
+
- A runtime guard blocks destructive shell commands, credential/environment inspection, and reads outside the workspace/cache.\n- Never paste whole files. Use short snippets only when they clarify the evidence.\n- If evidence is partial or access fails (404/403), state the limitation clearly.\n- Do not present anything as fact unless it appeared in tool output or in a file you read.\n\nRecommended search flow:\n1. If symbols/text are known, start with gh search code and the provided repo/owner filters.\n2. If a repo is known but paths are unclear, resolve the default branch and inspect the git tree or contents API.\n3. Fetch or clone only the files/repos required to prove the answer.\n4. Use rg/read/nl -ba locally to produce stable path and line evidence.\n\nUseful gh patterns:\n- gh repo view "$REPO" --json defaultBranchRef --jq '.defaultBranchRef.name'\n- gh search code '<terms>' --json path,repository,sha,url,textMatches --limit ${options.maxSearchResults}\n- gh api "repos/$REPO/git/trees/$REF?recursive=1" > tree.json\n- gh api "repos/$REPO/contents/$FILE?ref=$REF" --jq .content | tr -d '\\n' | base64 --decode > "repos/$REPO/$FILE"\n- rg -n '<pattern>' '<local path>'\n- nl -ba '<local file>' | sed -n '10,30p'\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences.\n## Locations\n- \`path\` or \`path:lineStart-lineEnd\` — what is here and why it matters; include GitHub URL when useful. If nothing relevant is found, write \`- (none)\`.\n## Evidence\n- \`path\` or \`path:lineStart-lineEnd\` — what this proves. Include concise snippets only if useful.\n## Searched\nOnly include when incomplete/not found or when the search path matters. List queries, filters, and probes used.\n## Next steps\nOptional: 1-3 narrow follow-up checks for remaining ambiguity.`;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function buildUserPrompt(query: string, repos: string[], owners: string[], maxSearchResults: number, cache: CacheDetails): string {
|
|
394
|
+
return `Task: locate and cite exact GitHub code locations that answer the query.\n\nQuery: ${query}\nRepository filters: ${repos.length ? repos.join(", ") : "(none)"}\nOwner filters: ${owners.length ? owners.join(", ") : "(none)"}\nMax search results per gh search call: ${maxSearchResults}\nLocal checkout cache: ${cache.mode === "enabled" ? `enabled at ${cache.root}` : "disabled"}\nCache decision: ${cache.decisionReason}\n\nRespond directly with concise, citation-heavy findings. Always pass --limit ${maxSearchResults} to gh search code unless a narrower command is clearly better.`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function extractLastAssistantText(messages: unknown[]): string {
|
|
398
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
399
|
+
const message = messages[i] as { role?: string; content?: unknown };
|
|
400
|
+
if (message?.role !== "assistant" || !Array.isArray(message.content)) continue;
|
|
401
|
+
const parts: string[] = [];
|
|
402
|
+
for (const part of message.content) {
|
|
403
|
+
if (part && typeof part === "object" && (part as { type?: string }).type === "text") {
|
|
404
|
+
const text = (part as { text?: unknown }).text;
|
|
405
|
+
if (typeof text === "string") parts.push(text);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (parts.length) return parts.join("").trim();
|
|
409
|
+
}
|
|
410
|
+
return "";
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function formatToolCall(call: ToolCall): string {
|
|
414
|
+
const args = call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
|
|
415
|
+
if (call.name === "read") {
|
|
416
|
+
const readPath = typeof args.path === "string" ? args.path : "";
|
|
417
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
418
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
419
|
+
const range = offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
|
|
420
|
+
return `read ${readPath}${range}`.trim();
|
|
421
|
+
}
|
|
422
|
+
if (call.name === "bash") {
|
|
423
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
424
|
+
return `bash ${shorten(command, 120)}`.trim();
|
|
425
|
+
}
|
|
426
|
+
return call.name;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function renderAnswer(details: LibrarianDetails): string {
|
|
430
|
+
if (details.error) return details.error;
|
|
431
|
+
return details.status === "running" ? "(searching GitHub...)" : "(no output)";
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
function isAbortLikeError(error: unknown): boolean {
|
|
435
|
+
if (error && typeof error === "object" && (error as { name?: unknown }).name === "AbortError") return true;
|
|
436
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
437
|
+
return /aborted|cancelled|canceled/i.test(message);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export default function librarianExtension(pi: ExtensionAPI) {
|
|
441
|
+
pi.registerTool({
|
|
442
|
+
name: "librarian",
|
|
443
|
+
label: "Librarian",
|
|
444
|
+
description:
|
|
445
|
+
"GitHub research scout for coding and personal-assistant tasks. Use when the answer likely lives in GitHub repos, exact repo/path locations are unknown, or you'd otherwise do exploratory gh search/tree probes plus local rg/read inspection. Librarian asks whether to use an optional 30-day local checkout cache, otherwise it behaves like API-only pi-librarian.",
|
|
446
|
+
promptSnippet:
|
|
447
|
+
"Research GitHub repositories with evidence-first path and line citations; optionally ask the user to cache local repo checkouts.",
|
|
448
|
+
promptGuidelines: [
|
|
449
|
+
"Use librarian when the answer likely requires exploratory GitHub repository search or line-cited evidence from external repos.",
|
|
450
|
+
"Do not use librarian for files already present in the current workspace unless the user asks for external GitHub research.",
|
|
451
|
+
],
|
|
452
|
+
parameters: LibrarianParams,
|
|
453
|
+
|
|
454
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
455
|
+
const rawQuery = (params as { query?: unknown }).query;
|
|
456
|
+
const query = typeof rawQuery === "string" ? rawQuery.trim() : "";
|
|
457
|
+
if (!query) throw new Error("Invalid parameters: expected query to be a non-empty string.");
|
|
458
|
+
if (!ctx.model) throw new Error("Librarian needs an active pi model, but ctx.model is unavailable.");
|
|
459
|
+
|
|
460
|
+
const repos = asStringArray((params as { repos?: unknown }).repos);
|
|
461
|
+
const owners = asStringArray((params as { owners?: unknown }).owners);
|
|
462
|
+
const maxSearchResults = clampNumber(
|
|
463
|
+
(params as { maxSearchResults?: unknown }).maxSearchResults,
|
|
464
|
+
1,
|
|
465
|
+
MAX_SEARCH_RESULTS,
|
|
466
|
+
DEFAULT_MAX_SEARCH_RESULTS,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
const workspaceBase = path.join(os.tmpdir(), "pi-librarian");
|
|
470
|
+
await fs.mkdir(workspaceBase, { recursive: true });
|
|
471
|
+
const workspace = await fs.mkdtemp(path.join(workspaceBase, "run-"));
|
|
472
|
+
await fs.mkdir(path.join(workspace, "repos"), { recursive: true });
|
|
473
|
+
|
|
474
|
+
const cacheRoot = getUserCacheReposRoot();
|
|
475
|
+
const cacheDecision = await askForCache(ctx, cacheRoot);
|
|
476
|
+
if (cacheDecision.enabled) await fs.mkdir(cacheRoot, { recursive: true });
|
|
477
|
+
const cleanup = cacheDecision.enabled ? await cleanupExpiredCache(cacheRoot) : { deleted: 0, errors: [] };
|
|
478
|
+
|
|
479
|
+
const details: LibrarianDetails = {
|
|
480
|
+
status: "running",
|
|
481
|
+
workspace,
|
|
482
|
+
cache: {
|
|
483
|
+
mode: cacheDecision.enabled ? "enabled" : "disabled",
|
|
484
|
+
root: cacheRoot,
|
|
485
|
+
ttlDays: CACHE_TTL_DAYS,
|
|
486
|
+
cleanupDeleted: cleanup.deleted,
|
|
487
|
+
cleanupErrors: cleanup.errors,
|
|
488
|
+
decisionReason: cacheDecision.reason,
|
|
489
|
+
},
|
|
490
|
+
turns: 0,
|
|
491
|
+
toolCalls: [],
|
|
492
|
+
startedAt: Date.now(),
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
let lastContent = "(searching GitHub...)";
|
|
496
|
+
let session: { abort: () => Promise<void>; dispose: () => void; state: { messages: unknown[] }; prompt: Function } | undefined;
|
|
497
|
+
let unsubscribe: (() => void) | undefined;
|
|
498
|
+
let runTimeout: NodeJS.Timeout | undefined;
|
|
499
|
+
let abortListenerAdded = false;
|
|
500
|
+
let aborted = Boolean(signal?.aborted);
|
|
501
|
+
|
|
502
|
+
const emit = (force = false) => {
|
|
503
|
+
void force;
|
|
504
|
+
onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
const abort = () => {
|
|
508
|
+
aborted = true;
|
|
509
|
+
details.status = "aborted";
|
|
510
|
+
details.endedAt = Date.now();
|
|
511
|
+
lastContent = "Aborted";
|
|
512
|
+
emit(true);
|
|
513
|
+
void session?.abort();
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
if (signal?.aborted) abort();
|
|
517
|
+
if (signal && !signal.aborted) {
|
|
518
|
+
signal.addEventListener("abort", abort);
|
|
519
|
+
abortListenerAdded = true;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
try {
|
|
523
|
+
emit(true);
|
|
524
|
+
|
|
525
|
+
const systemPrompt = buildSystemPrompt({
|
|
526
|
+
workspace,
|
|
527
|
+
maxSearchResults,
|
|
528
|
+
cacheEnabled: cacheDecision.enabled,
|
|
529
|
+
cacheRoot,
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
533
|
+
cwd: workspace,
|
|
534
|
+
agentDir: getAgentDir(),
|
|
535
|
+
noExtensions: true,
|
|
536
|
+
noSkills: true,
|
|
537
|
+
noPromptTemplates: true,
|
|
538
|
+
noThemes: true,
|
|
539
|
+
noContextFiles: true,
|
|
540
|
+
extensionFactories: [
|
|
541
|
+
createLibrarianRuntimeGuardExtension({
|
|
542
|
+
maxTurns: MAX_TURNS,
|
|
543
|
+
workspace,
|
|
544
|
+
cacheRoot,
|
|
545
|
+
cacheEnabled: cacheDecision.enabled,
|
|
546
|
+
}),
|
|
547
|
+
],
|
|
548
|
+
systemPromptOverride: () => systemPrompt,
|
|
549
|
+
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
550
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
await resourceLoader.reload();
|
|
554
|
+
|
|
555
|
+
const created = await createAgentSession({
|
|
556
|
+
cwd: workspace,
|
|
557
|
+
modelRegistry: ctx.modelRegistry,
|
|
558
|
+
resourceLoader,
|
|
559
|
+
sessionManager: SessionManager.inMemory(workspace),
|
|
560
|
+
model: ctx.model,
|
|
561
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
562
|
+
tools: ["read", "bash"],
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
session = created.session as typeof session;
|
|
566
|
+
unsubscribe = (created.session as any).subscribe((event: any) => {
|
|
567
|
+
switch (event.type) {
|
|
568
|
+
case "turn_end":
|
|
569
|
+
details.turns += 1;
|
|
570
|
+
emit();
|
|
571
|
+
break;
|
|
572
|
+
case "tool_execution_start":
|
|
573
|
+
details.toolCalls.push({
|
|
574
|
+
id: event.toolCallId,
|
|
575
|
+
name: event.toolName,
|
|
576
|
+
args: event.args,
|
|
577
|
+
startedAt: Date.now(),
|
|
578
|
+
});
|
|
579
|
+
if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
|
|
580
|
+
details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
|
|
581
|
+
}
|
|
582
|
+
emit(true);
|
|
583
|
+
break;
|
|
584
|
+
case "tool_execution_end": {
|
|
585
|
+
const call = details.toolCalls.find((item) => item.id === event.toolCallId);
|
|
586
|
+
if (call) {
|
|
587
|
+
call.endedAt = Date.now();
|
|
588
|
+
call.isError = event.isError;
|
|
589
|
+
}
|
|
590
|
+
emit(true);
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
});
|
|
595
|
+
|
|
596
|
+
if (!aborted) {
|
|
597
|
+
const promptPromise = created.session.prompt(buildUserPrompt(query, repos, owners, maxSearchResults, details.cache), {
|
|
598
|
+
expandPromptTemplates: false,
|
|
599
|
+
});
|
|
600
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
601
|
+
runTimeout = setTimeout(() => {
|
|
602
|
+
abort();
|
|
603
|
+
reject(new Error(`Librarian timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`));
|
|
604
|
+
}, MAX_RUN_MS);
|
|
605
|
+
});
|
|
606
|
+
await Promise.race([promptPromise, timeoutPromise]);
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
const answer = session ? extractLastAssistantText(session.state.messages) : "";
|
|
610
|
+
lastContent = answer || (aborted ? "Aborted" : "(no output)");
|
|
611
|
+
details.status = aborted ? "aborted" : "done";
|
|
612
|
+
details.endedAt = Date.now();
|
|
613
|
+
emit(true);
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
content: [{ type: "text", text: lastContent }],
|
|
617
|
+
details,
|
|
618
|
+
};
|
|
619
|
+
} catch (error) {
|
|
620
|
+
const wasAbort = aborted || isAbortLikeError(error);
|
|
621
|
+
const message = wasAbort ? "Aborted" : error instanceof Error ? error.message : String(error);
|
|
622
|
+
details.status = wasAbort ? "aborted" : "error";
|
|
623
|
+
details.error = wasAbort ? undefined : message;
|
|
624
|
+
details.endedAt = Date.now();
|
|
625
|
+
lastContent = message;
|
|
626
|
+
emit(true);
|
|
627
|
+
|
|
628
|
+
return {
|
|
629
|
+
content: [{ type: "text", text: message }],
|
|
630
|
+
details,
|
|
631
|
+
};
|
|
632
|
+
} finally {
|
|
633
|
+
if (runTimeout) clearTimeout(runTimeout);
|
|
634
|
+
if (signal && abortListenerAdded) signal.removeEventListener("abort", abort);
|
|
635
|
+
unsubscribe?.();
|
|
636
|
+
session?.dispose();
|
|
637
|
+
}
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
renderCall(args, theme) {
|
|
641
|
+
const query = typeof (args as { query?: unknown })?.query === "string" ? (args as { query: string }).query : "";
|
|
642
|
+
const repos = Array.isArray((args as { repos?: unknown })?.repos) ? (args as { repos: unknown[] }).repos.length : 0;
|
|
643
|
+
const owners = Array.isArray((args as { owners?: unknown })?.owners)
|
|
644
|
+
? (args as { owners: unknown[] }).owners.length
|
|
645
|
+
: 0;
|
|
646
|
+
return new Text(
|
|
647
|
+
`${theme.fg("muted", `repos:${repos} owners:${owners}`)} · ${theme.fg("toolOutput", shorten(query, 80))}`,
|
|
648
|
+
0,
|
|
649
|
+
0,
|
|
650
|
+
);
|
|
651
|
+
},
|
|
652
|
+
|
|
653
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
654
|
+
const details = result.details as LibrarianDetails | undefined;
|
|
655
|
+
if (!details) {
|
|
656
|
+
const first = result.content[0];
|
|
657
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const status = isPartial ? "running" : details.status;
|
|
661
|
+
const icon =
|
|
662
|
+
status === "done"
|
|
663
|
+
? theme.fg("success", "✓")
|
|
664
|
+
: status === "error"
|
|
665
|
+
? theme.fg("error", "✗")
|
|
666
|
+
: status === "aborted"
|
|
667
|
+
? theme.fg("warning", "◼")
|
|
668
|
+
: theme.fg("warning", "⏳");
|
|
669
|
+
|
|
670
|
+
const cacheLabel =
|
|
671
|
+
details.cache.mode === "enabled"
|
|
672
|
+
? theme.fg("success", "cache:on")
|
|
673
|
+
: theme.fg("muted", "cache:off");
|
|
674
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold("librarian "))}${theme.fg(
|
|
675
|
+
"dim",
|
|
676
|
+
`${details.turns} turns • ${details.toolCalls.length} tools • `,
|
|
677
|
+
)}${cacheLabel}`;
|
|
678
|
+
|
|
679
|
+
const workspaceLine = `${theme.fg("muted", "workspace: ")}${theme.fg("toolOutput", details.workspace)}`;
|
|
680
|
+
const cacheLine = `${theme.fg("muted", "cache: ")}${theme.fg("toolOutput", details.cache.root)} ${theme.fg(
|
|
681
|
+
"dim",
|
|
682
|
+
`(${details.cache.mode}, ${details.cache.ttlDays}d TTL, cleaned ${details.cache.cleanupDeleted})`,
|
|
683
|
+
)}`;
|
|
684
|
+
|
|
685
|
+
const answer =
|
|
686
|
+
(result.content[0]?.type === "text" ? result.content[0].text : renderAnswer(details)).trim() || "(no output)";
|
|
687
|
+
|
|
688
|
+
const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
|
|
689
|
+
const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
|
|
690
|
+
return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
|
|
691
|
+
});
|
|
692
|
+
if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
|
|
693
|
+
|
|
694
|
+
if (status === "running") {
|
|
695
|
+
const parts = [header, workspaceLine, cacheLine];
|
|
696
|
+
if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
|
|
697
|
+
parts.push("", theme.fg("muted", "Searching GitHub…"));
|
|
698
|
+
return new Text(parts.join("\n"), 0, 0);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if (!expanded) {
|
|
702
|
+
const previewLines = answer.split("\n").slice(0, 18);
|
|
703
|
+
const parts = [header, workspaceLine, cacheLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
|
|
704
|
+
if (answer.split("\n").length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
|
|
705
|
+
if (toolLines.length) parts.push("", theme.fg("muted", "Tools:"), ...toolLines);
|
|
706
|
+
return new Text(parts.join("\n"), 0, 0);
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const container = new Container();
|
|
710
|
+
container.addChild(new Text(header, 0, 0));
|
|
711
|
+
container.addChild(new Text(workspaceLine, 0, 0));
|
|
712
|
+
container.addChild(new Text(cacheLine, 0, 0));
|
|
713
|
+
if (details.cache.cleanupErrors.length) {
|
|
714
|
+
container.addChild(
|
|
715
|
+
new Text(theme.fg("warning", `cache cleanup warnings: ${details.cache.cleanupErrors.length}`), 0, 0),
|
|
716
|
+
);
|
|
717
|
+
}
|
|
718
|
+
if (toolLines.length) {
|
|
719
|
+
container.addChild(new Spacer(1));
|
|
720
|
+
container.addChild(new Text([theme.fg("muted", "Tools:"), ...toolLines].join("\n"), 0, 0));
|
|
721
|
+
}
|
|
722
|
+
container.addChild(new Spacer(1));
|
|
723
|
+
container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
|
|
724
|
+
return container;
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@diegopetrucci/pi-librarian",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A pi GitHub research scout that can optionally cache local repo checkouts under the user's OS cache directory.",
|
|
5
|
+
"keywords": ["pi-package", "pi", "github", "research", "subagent", "cache"],
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/diegopetrucci/pi-extensions.git",
|
|
10
|
+
"directory": "extensions/librarian"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"*.ts",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"pi": {
|
|
20
|
+
"extensions": [
|
|
21
|
+
"index.ts"
|
|
22
|
+
]
|
|
23
|
+
},
|
|
24
|
+
"peerDependencies": {
|
|
25
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
26
|
+
"@earendil-works/pi-tui": "*",
|
|
27
|
+
"typebox": "*"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "A collection of pi extensions, including a minimal custom footer, an Amp-style oracle, a 200k context cap for auto-compaction, quiet one-line collapsed tool previews, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
|
|
3
|
+
"version": "0.1.18",
|
|
4
|
+
"description": "A collection of pi extensions, including a GitHub librarian with opt-in local repo checkout caching, a minimal custom footer, an Amp-style oracle, a 200k context cap for auto-compaction, quiet one-line collapsed tool previews, a permission gate for dangerous bash commands, confirm-before-destructive session actions, and terminal notifications when pi is ready for input.",
|
|
5
5
|
"keywords": ["pi-package", "pi", "terminal", "agent"],
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"./extensions/minimal-footer/index.ts",
|
|
32
32
|
"./extensions/oracle/index.ts",
|
|
33
33
|
"./extensions/context-cap/index.ts",
|
|
34
|
+
"./extensions/librarian/index.ts",
|
|
34
35
|
"./extensions/quiet-tools/index.ts",
|
|
35
36
|
"./extensions/permission-gate/index.ts",
|
|
36
37
|
"./extensions/confirm-destructive/index.ts",
|