@thurstonsand/pi-librarian 0.2.0 → 0.3.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/README.md +13 -7
- package/RELEASE.md +22 -0
- package/docs/designs/03-librarian-research-tool-hardening.md +136 -0
- package/extensions/librarian/checkout.ts +21 -1
- package/extensions/librarian/github.ts +23 -8
- package/extensions/librarian/grep-app.ts +88 -5
- package/extensions/librarian/run.ts +10 -1
- package/extensions/librarian/settings.ts +11 -0
- package/extensions/librarian/tools/read-github-file.ts +67 -32
- package/extensions/librarian/tools/search-code.ts +3 -3
- package/extensions/librarian/view.ts +55 -49
- package/extensions/librarian.ts +11 -0
- package/images/librarian-results.png +0 -0
- package/images/librarian-run.png +0 -0
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
A GitHub research subagent for the [pi coding agent](https://github.com/badlogic/pi-mono) inspired by [Amp](https://ampcode.com/): deep-dive questions about specific repos ("how does drizzle-orm implement prepared statements?") and discovery across the ecosystem ("compare the most popular TypeScript SQL ORMs").
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+

|
|
8
|
+
|
|
5
9
|
## How it works
|
|
6
10
|
|
|
7
11
|
The `librarian` tool spawns a nested research agent with purpose-built tools:
|
|
@@ -39,17 +43,19 @@ In pi's global `settings.json`:
|
|
|
39
43
|
"tools": ["search_web", "fetch_web"],
|
|
40
44
|
"extensions": ["~/.pi/agent/extensions/parallel-web-tools"],
|
|
41
45
|
"cacheDir": "/tmp/pi-librarian",
|
|
46
|
+
"debug": { "persistRuns": false },
|
|
42
47
|
},
|
|
43
48
|
}
|
|
44
49
|
```
|
|
45
50
|
|
|
46
|
-
| Setting
|
|
47
|
-
|
|
|
48
|
-
| `model`
|
|
49
|
-
| `thinkingLevel`
|
|
50
|
-
| `tools`
|
|
51
|
-
| `extensions`
|
|
52
|
-
| `cacheDir`
|
|
51
|
+
| Setting | Recommended | Default |
|
|
52
|
+
| ------------------- | ----------------------------------------------- | ------------------------------ |
|
|
53
|
+
| `model` | `openai-codex/gpt-5.5` | current session model |
|
|
54
|
+
| `thinkingLevel` | `off` | current session thinking level |
|
|
55
|
+
| `tools` | names of extra tools to activate, when needed | `[]` |
|
|
56
|
+
| `extensions` | escape hatch paths for tools not loaded in pi | `[]` |
|
|
57
|
+
| `cacheDir` | `/tmp/pi-librarian` | `/tmp/pi-librarian` |
|
|
58
|
+
| `debug.persistRuns` | persist nested session file paths for debugging | `false` |
|
|
53
59
|
|
|
54
60
|
`librarian.tools` is the activation gate for extra tools. `librarian.extensions` only adds extension paths to the search space when a named tool is not already loaded in the main pi session; listing an extension path does not activate every tool in that bundle. Librarian runs exclude `write` and `edit`.
|
|
55
61
|
|
package/RELEASE.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
# Release notes
|
|
4
4
|
|
|
5
|
+
## 0.3.0
|
|
6
|
+
|
|
7
|
+
Hardens librarian research tools and aligns GitHub file reads with pi's native `read` tool.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Changed `read_github_file` to use `offset` and `limit` instead of `range`, matching pi's native `read` tool semantics.
|
|
12
|
+
- Hardened `checkout_repo` cache reuse so mismatched or malformed cached paths are discarded, while reused checkouts are reset and cleaned before research.
|
|
13
|
+
|
|
14
|
+
## 0.2.1
|
|
15
|
+
|
|
16
|
+
Fixes TUI rendering corruption.
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added `librarian.debug.persistRuns` to keep nested run session files around for debugging.
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- Fixed librarian trace rendering leaving stale "N earlier calls" duplicates in the TUI, caused by unsilenced `@octokit/request` deprecation warnings writing to the console.
|
|
25
|
+
- Fixed `search_code` retries riding out a fixed 30s-per-attempt budget; retries now use an escalating timeout schedule so a stalled request recovers in seconds instead of up to a minute.
|
|
26
|
+
|
|
5
27
|
## 0.2.0
|
|
6
28
|
|
|
7
29
|
Adds continuable librarian runs and opt-in for extra tools.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# Librarian research-tool hardening
|
|
2
|
+
|
|
3
|
+
## Status
|
|
4
|
+
|
|
5
|
+
Partially implemented
|
|
6
|
+
|
|
7
|
+
## Decision Summary
|
|
8
|
+
|
|
9
|
+
Capture the hardening work implemented for librarian runs: disposable checkout-cache mechanics and remote file reads that behave like pi's native `read` tool. AST-aware local-code tools and heavier result metadata remain deferred ideas, not active implementation pressure.
|
|
10
|
+
|
|
11
|
+
## Problem Statement / Background
|
|
12
|
+
|
|
13
|
+
The current librarian design already makes the main reliability move: within-repo research happens from local blob-less clones, while search APIs are candidate finders. A survey of adjacent implementations suggested several refinements worth preserving before the details are lost:
|
|
14
|
+
|
|
15
|
+
- Mitsuhiko's librarian skill treats checkouts as stable cached infrastructure: repo references normalize to a predictable cache path, clones are reused, and refreshes are throttled.
|
|
16
|
+
- Code-research tools such as Octocode and codebase-research-agent emphasize exact regions, line anchors, and smaller useful code chunks rather than raw search dumps.
|
|
17
|
+
- This project should borrow those patterns only where they simplify the librarian's operating surface. It should not become an API-first search tool, a heavy MCP service, or a semantic index whose snippets are mistaken for evidence.
|
|
18
|
+
|
|
19
|
+
The useful scenario is a repeated question about the same repo: the librarian should reuse the existing clone, force it back to the requested upstream state subject to fetch debounce, clean away any cache-local debris, and let the agent read GitHub files with the same mental model it uses for local files.
|
|
20
|
+
|
|
21
|
+
## Goals
|
|
22
|
+
|
|
23
|
+
- Reuse cached clones safely while preserving the librarian's read-only research contract.
|
|
24
|
+
- Treat checkout-cache contents as disposable infrastructure, not user-owned worktrees.
|
|
25
|
+
- Make `read_github_file` operationally match pi's native `read` tool where possible.
|
|
26
|
+
|
|
27
|
+
## Non-Goals
|
|
28
|
+
|
|
29
|
+
- No dependency on Octocode or any external MCP service.
|
|
30
|
+
- No shift away from clone-first within-repo research.
|
|
31
|
+
- No semantic/vector index as part of this design.
|
|
32
|
+
- No AST-aware local-code primitive for now; useful, but not part of this hardening pass.
|
|
33
|
+
- No broad tool-result metadata expansion unless a concrete enforcement feature needs it.
|
|
34
|
+
- No repo-profile cache for now; the value is less clear than the hardening work captured here.
|
|
35
|
+
|
|
36
|
+
## Exposed Shape
|
|
37
|
+
|
|
38
|
+
These additions would preserve the current user-facing `librarian` tool and `/librarian` attach command. The exposed changes would be inside the repo-tool surface:
|
|
39
|
+
|
|
40
|
+
- `checkout_repo` becomes stricter about cache reuse by resetting and cleaning cache entries deterministically.
|
|
41
|
+
- `read_github_file` uses `offset`/`limit` semantics and output conventions that closely mirror pi's native `read` tool.
|
|
42
|
+
- Existing `provide_results.locations` remains the final citation surface. Search results are still candidates by prompt discipline, not by a new metadata framework.
|
|
43
|
+
|
|
44
|
+
## Design Decisions
|
|
45
|
+
|
|
46
|
+
### 1. Cached checkouts are disposable infrastructure, not worktrees
|
|
47
|
+
|
|
48
|
+
A reused checkout should be forced back to the requested upstream state, subject only to the fetch debounce. Unlike a human working clone, librarian checkouts are cache infrastructure. If a previous run, aborted process, or manual edit left local changes, generated files, or an old branch behind, `checkout_repo` should not preserve them. Manual edits inside the checkout cache are undefined behavior.
|
|
49
|
+
|
|
50
|
+
For default-branch checkouts, the desired shape is:
|
|
51
|
+
|
|
52
|
+
1. verify the cache path is a git repo for the requested normalized remote;
|
|
53
|
+
2. fetch from origin unless the fetch debounce says the clone is fresh enough;
|
|
54
|
+
3. checkout the default branch;
|
|
55
|
+
4. hard reset to `origin/<defaultBranch>`;
|
|
56
|
+
5. remove untracked files with `git clean -fdx`;
|
|
57
|
+
6. return the pinned HEAD sha and cache status.
|
|
58
|
+
|
|
59
|
+
For explicit refs, the tool should fetch the requested ref when needed, force checkout/detach to the resolved commit, hard reset to that commit, and clean the tree. If the cached remote does not match the requested repo, or if the cache path is malformed, the safe behavior is to discard the cache path and clone again.
|
|
60
|
+
|
|
61
|
+
**Tradeoff:** this destroys any local modifications inside the checkout cache. That is acceptable because librarian runs are read-only by contract; cache contents are not user-owned work.
|
|
62
|
+
|
|
63
|
+
### 2. Remote file reads should behave like local reads
|
|
64
|
+
|
|
65
|
+
`read_github_file` is most useful when it feels like pi's native `read` tool pointed at GitHub. The agent should not need one mental model for local checkout files and another for remote GitHub files.
|
|
66
|
+
|
|
67
|
+
The tool should use `offset` and `limit` parameters rather than a bespoke `[startLine, endLine]` range:
|
|
68
|
+
|
|
69
|
+
- `offset` is a 1-indexed starting line;
|
|
70
|
+
- `limit` is the maximum number of lines to return;
|
|
71
|
+
- default behavior should match native `read` as closely as practical;
|
|
72
|
+
- clipped output should tell the agent how to continue with `offset`/`limit`;
|
|
73
|
+
- file output should be directly returned as text, not written to disk;
|
|
74
|
+
|
|
75
|
+
Directory listings may remain GitHub-specific, but file reads should be operationally interchangeable with local `read` output. This is more useful than extra metadata because it changes the agent's actual operating surface.
|
|
76
|
+
|
|
77
|
+
**Tradeoff:** changing from `range` to `offset`/`limit` breaks the current tool schema. That is acceptable if it makes the tool more pi-native and easier for agents to use consistently.
|
|
78
|
+
|
|
79
|
+
### 3. AST and metadata expansion remain deferred
|
|
80
|
+
|
|
81
|
+
Plain grep plus `read` remains the right baseline because it is universal, fast, and transparent. A future AST-aware addition could still be useful, probably as `read_enclosing_node(path, line, language?)` for TypeScript/JavaScript first, but it should wait until the simpler read and checkout semantics are solid.
|
|
82
|
+
|
|
83
|
+
Likewise, broad result metadata such as `evidenceKind`, `complete`, `truncated`, `continuation`, and `recoveryHint` should not be added speculatively. Agents primarily use tool text and prompt instructions; hidden metadata only matters when a renderer or validator consumes it. If citation honesty needs more structure later, the better first step is validating `provide_results.locations` against the run trace: files checked out, files read, line ranges observed, and search hits that were only candidates.
|
|
84
|
+
|
|
85
|
+
**Tradeoff:** this leaves candidate-vs-proof enforcement mostly in the prompt for now. That is acceptable under KISS until there is a concrete validation feature to support.
|
|
86
|
+
|
|
87
|
+
## Edge Cases & Failure Modes
|
|
88
|
+
|
|
89
|
+
- **Cached repo remote mismatch:** discard and reclone rather than trying to repair an ambiguous cache entry.
|
|
90
|
+
- **Dirty cached checkout:** hard reset and `git clean -fdx`; do not preserve changes.
|
|
91
|
+
- **Fetch fails but stale clone exists:** report a structured error instead of silently researching stale code, unless a future explicit offline mode exists.
|
|
92
|
+
- **Explicit commit not reachable after fetch:** return a ref-resolution error with a recovery hint to verify the ref or repo.
|
|
93
|
+
- **Large GitHub file:** return a clipped line window using native-read-like semantics and tell the agent the next `offset`/`limit` to request.
|
|
94
|
+
- **Search result clipped by limits:** keep the text honest about limits; do not add metadata unless a concrete consumer needs it.
|
|
95
|
+
|
|
96
|
+
## Alternatives
|
|
97
|
+
|
|
98
|
+
### Keep checkout cache reuse as-is
|
|
99
|
+
|
|
100
|
+
- **Status:** Rejected
|
|
101
|
+
- **Decision:** The current implementation already does a force checkout/reset for the default branch and force checkout for explicit refs, but it does not fully encode the disposable-cache policy described here.
|
|
102
|
+
- **Discussion:** The stricter policy is easier to reason about and matches the contract: checkout-cache contents are not user-owned work.
|
|
103
|
+
|
|
104
|
+
### Add Octocode as a dependency
|
|
105
|
+
|
|
106
|
+
- **Status:** Rejected
|
|
107
|
+
- **Decision**: Octocode is an external code-research/MCP-style system, not a pi-native tool. Its useful contribution here is contract design: exact regions, line anchors, pagination, bulk/error semantics, and candidate-vs-proof discipline.
|
|
108
|
+
- **Discussion:** Borrow only the interaction ideas that make pi-native tools easier to use. Do not import the infrastructure or add metadata for its own sake.
|
|
109
|
+
|
|
110
|
+
### Add repo profiles
|
|
111
|
+
|
|
112
|
+
- **Status:** Rejected
|
|
113
|
+
- **Decision**: Repo profiles are less compelling for now than cache correctness and native-read-like remote file access.
|
|
114
|
+
- **Discussion:** A profile cache could store package manager, source roots, manifests, and test commands, but it risks becoming stale orientation data that agents over-trust. Evidence should continue to come from actual file reads.
|
|
115
|
+
|
|
116
|
+
## Implementation Plan
|
|
117
|
+
|
|
118
|
+
- [x] Phase 1: Checkout cache hardening
|
|
119
|
+
- Goal: Make reused clones deterministic disposable infrastructure that match the requested upstream ref, subject to fetch debounce.
|
|
120
|
+
- Files: `extensions/librarian/checkout.ts`, `extensions/librarian/tools/checkout-repo.ts`, checkout tests.
|
|
121
|
+
- Work: Verify cached remote identity; discard/reclone on mismatch or malformed cache; fetch with existing debounce; hard reset and `git clean -fdx` for default branch and explicit refs.
|
|
122
|
+
- Validation: Unit tests for dirty checkout cleanup, untracked file cleanup, remote mismatch reclone, explicit ref checkout, malformed cache replacement, and fetch failure behavior; `npm run check`.
|
|
123
|
+
|
|
124
|
+
- [x] Phase 2: Native-read-like `read_github_file`
|
|
125
|
+
- Goal: Make remote GitHub file reads operationally match pi's native `read` tool.
|
|
126
|
+
- Files: `extensions/librarian/tools/read-github-file.ts`, prompt/tests.
|
|
127
|
+
- Work: Replace `range` with `offset`/`limit`; match native read truncation and continuation language as closely as practical; keep direct text output and directory listing behavior.
|
|
128
|
+
- Validation: Tool tests for default truncation, offset/limit reads, clipped continuation text, invalid ranges, directory listings, and pinned `ref` reads; `npm run check`.
|
|
129
|
+
|
|
130
|
+
- [ ] Deferred: citation validation
|
|
131
|
+
- Goal: If citation honesty needs structural enforcement, validate `provide_results.locations` against the run trace instead of adding broad metadata first.
|
|
132
|
+
- Work: Track which checked-out or GitHub files were actually read, including covered line ranges; reject or warn on locations sourced only from search candidates.
|
|
133
|
+
|
|
134
|
+
- [ ] Deferred: AST-aware local-code primitive
|
|
135
|
+
- Goal: Provide a syntactic-region helper only if grep/read chains prove too wasteful.
|
|
136
|
+
- Work: Consider `read_enclosing_node(path, line, language?)`, likely TypeScript/JavaScript first.
|
|
@@ -148,6 +148,17 @@ async function pathExists(target: string): Promise<boolean> {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
async function currentRemoteKey(
|
|
152
|
+
dest: string,
|
|
153
|
+
signal: AbortSignal | undefined,
|
|
154
|
+
): Promise<string | undefined> {
|
|
155
|
+
try {
|
|
156
|
+
return parseRepoReference(await git(["remote", "get-url", "origin"], dest, signal)).key;
|
|
157
|
+
} catch {
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
151
162
|
async function clone(
|
|
152
163
|
reference: RepoReference,
|
|
153
164
|
dest: string,
|
|
@@ -207,7 +218,13 @@ export async function checkoutRepo(
|
|
|
207
218
|
const repo = parseRepoReference(repoInput);
|
|
208
219
|
const dest = repoCachePath(cacheDir, repo.key);
|
|
209
220
|
|
|
210
|
-
const reusedClone =
|
|
221
|
+
const reusedClone =
|
|
222
|
+
(await pathExists(path.join(dest, ".git"))) &&
|
|
223
|
+
(await currentRemoteKey(dest, signal)) === repo.key;
|
|
224
|
+
if (!reusedClone) {
|
|
225
|
+
await fs.rm(dest, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
|
|
211
228
|
if (reusedClone) {
|
|
212
229
|
await fetchIfStale(dest, signal);
|
|
213
230
|
} else {
|
|
@@ -223,6 +240,7 @@ export async function checkoutRepo(
|
|
|
223
240
|
// Refs already fetched (e.g. a sha reachable from an earlier fetch) check out directly.
|
|
224
241
|
await git(["checkout", "--force", "--detach", ref], dest, signal);
|
|
225
242
|
}
|
|
243
|
+
await git(["reset", "--hard", "HEAD"], dest, signal);
|
|
226
244
|
checkedOutRef = ref;
|
|
227
245
|
} else {
|
|
228
246
|
const branch = await defaultBranch(dest, signal);
|
|
@@ -231,6 +249,8 @@ export async function checkoutRepo(
|
|
|
231
249
|
checkedOutRef = branch;
|
|
232
250
|
}
|
|
233
251
|
|
|
252
|
+
await git(["clean", "-fdx"], dest, signal);
|
|
253
|
+
|
|
234
254
|
const headSha = await git(["rev-parse", "HEAD"], dest, signal);
|
|
235
255
|
const files = await git(["ls-files"], dest, signal);
|
|
236
256
|
const fileCount = files.length === 0 ? 0 : files.split("\n").length;
|
|
@@ -28,7 +28,14 @@ function repoFullName(repo: GitHubRepoSlug): string {
|
|
|
28
28
|
return `${repo.owner}/${repo.repo}`;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
-
export
|
|
31
|
+
export interface GitHubClientApi {
|
|
32
|
+
searchRepositories(params: SearchRepositoriesParams): Promise<RepoSearchResult>;
|
|
33
|
+
searchCode(params: SearchGitHubCodeParams): Promise<GitHubCodeSearchResult>;
|
|
34
|
+
readContents(params: ReadContentsParams): Promise<FileContents | DirectoryContents>;
|
|
35
|
+
getRepo(repo: GitHubRepoSlug): Promise<RepoMeta>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type GitHubClientProvider = () => Promise<GitHubClientApi>;
|
|
32
39
|
|
|
33
40
|
export async function resolveGitHubToken(): Promise<string | undefined> {
|
|
34
41
|
const envToken = process.env.GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim();
|
|
@@ -45,18 +52,26 @@ export async function resolveGitHubToken(): Promise<string | undefined> {
|
|
|
45
52
|
}
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
// Any console output corrupts pi's interactive TUI (its differential renderer
|
|
56
|
+
// cannot recover from writes it did not make), so octokit must never log.
|
|
57
|
+
// The top-level `log` alone is not enough: @octokit/request resolves its logger
|
|
58
|
+
// from the per-request options (`request.log || console`) when emitting API
|
|
59
|
+
// deprecation warnings, so the silent logger has to be passed at both levels.
|
|
60
|
+
const SILENT_OCTOKIT_LOG = {
|
|
61
|
+
debug: () => {},
|
|
62
|
+
info: () => {},
|
|
63
|
+
warn: () => {},
|
|
64
|
+
error: () => {},
|
|
65
|
+
};
|
|
66
|
+
|
|
48
67
|
export function createGitHubClient(token: string | undefined): GitHubClient {
|
|
49
68
|
return new GitHubClient(
|
|
50
69
|
new Octokit({
|
|
51
70
|
...(token ? { auth: token } : {}),
|
|
52
71
|
userAgent: "pi-librarian",
|
|
53
|
-
log:
|
|
54
|
-
debug: () => {},
|
|
55
|
-
info: () => {},
|
|
56
|
-
warn: () => {},
|
|
57
|
-
error: () => {},
|
|
58
|
-
},
|
|
72
|
+
log: SILENT_OCTOKIT_LOG,
|
|
59
73
|
request: {
|
|
74
|
+
log: SILENT_OCTOKIT_LOG,
|
|
60
75
|
headers: {
|
|
61
76
|
"x-github-api-version": "2022-11-28",
|
|
62
77
|
},
|
|
@@ -241,7 +256,7 @@ function buildGitHubCodeQuery(params: SearchGitHubCodeParams): string {
|
|
|
241
256
|
return parts.join(" ");
|
|
242
257
|
}
|
|
243
258
|
|
|
244
|
-
export class GitHubClient {
|
|
259
|
+
export class GitHubClient implements GitHubClientApi {
|
|
245
260
|
constructor(private readonly octokit: Octokit) {}
|
|
246
261
|
|
|
247
262
|
async searchRepositories(params: SearchRepositoriesParams): Promise<RepoSearchResult> {
|
|
@@ -2,7 +2,10 @@ import { Type } from "typebox";
|
|
|
2
2
|
import { safeParseTypeBoxValue } from "../shared/typebox.ts";
|
|
3
3
|
|
|
4
4
|
const GREP_MCP_URL = "https://mcp.grep.app";
|
|
5
|
-
|
|
5
|
+
// Escalating per-attempt budgets: the upstream gateway kills stalled requests at ~15s, so
|
|
6
|
+
// early attempts abort fast (p90 success is ~500ms) instead of waiting out the full timeout.
|
|
7
|
+
const GREP_ATTEMPT_TIMEOUTS_MS = [3_000, 5_000, 7_000];
|
|
8
|
+
const GREP_RETRY_DELAY_MS = 300;
|
|
6
9
|
|
|
7
10
|
export class GrepError extends Error {
|
|
8
11
|
constructor(message: string) {
|
|
@@ -159,7 +162,12 @@ export function parseGrepMcpEvents(body: string): GrepCodeSearchResult {
|
|
|
159
162
|
continue;
|
|
160
163
|
}
|
|
161
164
|
if (result.isError) {
|
|
162
|
-
|
|
165
|
+
const message = (result.content ?? [])
|
|
166
|
+
.map((rawContent) => safeParseTypeBoxValue(MCP_TEXT_CONTENT_SCHEMA, rawContent)?.text)
|
|
167
|
+
.filter((text): text is string => text !== undefined)
|
|
168
|
+
.join("\n")
|
|
169
|
+
.trim();
|
|
170
|
+
throw new GrepError(message || "Grep MCP returned an error result.");
|
|
163
171
|
}
|
|
164
172
|
|
|
165
173
|
for (const rawContent of result.content ?? []) {
|
|
@@ -203,11 +211,45 @@ function buildGrepArguments(params: GrepCodeSearchParams): Record<string, unknow
|
|
|
203
211
|
return args;
|
|
204
212
|
}
|
|
205
213
|
|
|
206
|
-
|
|
214
|
+
function extractHtmlTitle(body: string): string | undefined {
|
|
215
|
+
const match = /<title[^>]*>(.*?)<\/title>/is.exec(body);
|
|
216
|
+
return match?.[1]?.replace(/\s+/g, " ").trim();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function formatHttpError(status: number, body: string): string {
|
|
220
|
+
return `Grep MCP returned ${status}: ${extractHtmlTitle(body) ?? body.slice(0, 200)}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function formatTransientError(error: unknown): string {
|
|
224
|
+
const cause =
|
|
225
|
+
error instanceof GrepTransientHttpError ? `returned ${error.status}` : "request failed";
|
|
226
|
+
return `Grep MCP ${cause} after retry (transient backend error). Retry, or simplify the query.`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function delay(milliseconds: number, signal: AbortSignal | undefined): Promise<void> {
|
|
230
|
+
if (signal?.aborted) {
|
|
231
|
+
return Promise.reject(signal.reason);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return new Promise((resolve, reject) => {
|
|
235
|
+
const timeout = setTimeout(resolve, milliseconds);
|
|
236
|
+
signal?.addEventListener(
|
|
237
|
+
"abort",
|
|
238
|
+
() => {
|
|
239
|
+
clearTimeout(timeout);
|
|
240
|
+
reject(signal.reason);
|
|
241
|
+
},
|
|
242
|
+
{ once: true },
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function searchCodeGrepAttempt(
|
|
207
248
|
params: GrepCodeSearchParams,
|
|
208
249
|
signal: AbortSignal | undefined,
|
|
250
|
+
timeoutMs: number,
|
|
209
251
|
): Promise<GrepCodeSearchResult> {
|
|
210
|
-
const timeoutSignal = AbortSignal.timeout(
|
|
252
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
211
253
|
const requestSignal = signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal;
|
|
212
254
|
const response = await fetch(GREP_MCP_URL, {
|
|
213
255
|
method: "POST",
|
|
@@ -230,8 +272,49 @@ export async function searchCodeGrep(
|
|
|
230
272
|
|
|
231
273
|
const body = await response.text();
|
|
232
274
|
if (!response.ok) {
|
|
233
|
-
|
|
275
|
+
if (response.status >= 500) {
|
|
276
|
+
throw new GrepTransientHttpError(response.status, body);
|
|
277
|
+
}
|
|
278
|
+
throw new GrepError(formatHttpError(response.status, body));
|
|
234
279
|
}
|
|
235
280
|
|
|
236
281
|
return parseGrepMcpEvents(body);
|
|
237
282
|
}
|
|
283
|
+
|
|
284
|
+
// Do not extend GrepError: GrepError is the deterministic no-retry signal.
|
|
285
|
+
class GrepTransientHttpError extends Error {
|
|
286
|
+
readonly status: number;
|
|
287
|
+
|
|
288
|
+
constructor(status: number, body: string) {
|
|
289
|
+
super(formatHttpError(status, body));
|
|
290
|
+
this.name = "GrepTransientHttpError";
|
|
291
|
+
this.status = status;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export async function searchCodeGrep(
|
|
296
|
+
params: GrepCodeSearchParams,
|
|
297
|
+
signal: AbortSignal | undefined,
|
|
298
|
+
): Promise<GrepCodeSearchResult> {
|
|
299
|
+
if (signal?.aborted) {
|
|
300
|
+
throw signal.reason;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let transientError: unknown;
|
|
304
|
+
for (const [attempt, timeoutMs] of GREP_ATTEMPT_TIMEOUTS_MS.entries()) {
|
|
305
|
+
if (attempt > 0) {
|
|
306
|
+
await delay(GREP_RETRY_DELAY_MS, signal);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
return await searchCodeGrepAttempt(params, signal, timeoutMs);
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (error instanceof GrepError || signal?.aborted) {
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
transientError = error;
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
throw new GrepError(formatTransientError(transientError));
|
|
320
|
+
}
|
|
@@ -52,6 +52,7 @@ export interface LibrarianRunDetails {
|
|
|
52
52
|
checkouts: Record<string, string>;
|
|
53
53
|
error?: string;
|
|
54
54
|
runId?: string;
|
|
55
|
+
debugSessionPath?: string;
|
|
55
56
|
startedAt: number;
|
|
56
57
|
endedAt?: number;
|
|
57
58
|
}
|
|
@@ -108,7 +109,11 @@ export async function runLibrarian(
|
|
|
108
109
|
};
|
|
109
110
|
|
|
110
111
|
const finalizeDetails = (status: LibrarianRunStatus, content: string): string => {
|
|
111
|
-
const
|
|
112
|
+
const suffixes = [
|
|
113
|
+
...(details.runId ? [`run: ${details.runId}`] : []),
|
|
114
|
+
...(details.debugSessionPath ? [`debug_session: ${details.debugSessionPath}`] : []),
|
|
115
|
+
];
|
|
116
|
+
const resultContent = suffixes.length > 0 ? `${content}\n\n${suffixes.join("\n")}` : content;
|
|
112
117
|
details.status = status;
|
|
113
118
|
details.endedAt = Date.now();
|
|
114
119
|
if (status !== "done") {
|
|
@@ -137,6 +142,10 @@ export async function runLibrarian(
|
|
|
137
142
|
? await openContinuedSession(options.continueFrom, sessionsDir, options.settings.cacheDir, fail)
|
|
138
143
|
: SessionManager.create(options.settings.cacheDir, sessionsDir);
|
|
139
144
|
details.runId = sessionManager.getSessionId();
|
|
145
|
+
const debugSessionPath = sessionManager.getSessionFile();
|
|
146
|
+
if (options.settings.debug.persistRuns && debugSessionPath) {
|
|
147
|
+
details.debugSessionPath = debugSessionPath;
|
|
148
|
+
}
|
|
140
149
|
|
|
141
150
|
let findings: Findings | undefined;
|
|
142
151
|
const repoTools = [
|
|
@@ -20,6 +20,11 @@ const LIBRARIAN_FILE_SETTINGS_SCHEMA = Type.Object({
|
|
|
20
20
|
extensions: Type.Optional(Type.Array(Type.String())),
|
|
21
21
|
tools: Type.Optional(Type.Array(Type.String())),
|
|
22
22
|
cacheDir: Type.Optional(Type.String()),
|
|
23
|
+
debug: Type.Optional(
|
|
24
|
+
Type.Object({
|
|
25
|
+
persistRuns: Type.Optional(Type.Boolean()),
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
23
28
|
});
|
|
24
29
|
|
|
25
30
|
const ROOT_SETTINGS_SCHEMA = Type.Object({
|
|
@@ -45,6 +50,9 @@ export interface LibrarianSettings {
|
|
|
45
50
|
extensions: string[];
|
|
46
51
|
tools: string[];
|
|
47
52
|
cacheDir: string;
|
|
53
|
+
debug: {
|
|
54
|
+
persistRuns: boolean;
|
|
55
|
+
};
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
export function getDefaultCacheDir(): string {
|
|
@@ -117,6 +125,9 @@ export function resolveLibrarianSettings(fileSettings: LibrarianFileSettings): L
|
|
|
117
125
|
extensions: normalizeExtensionPaths(fileSettings.extensions),
|
|
118
126
|
tools: normalizeToolNames(fileSettings.tools),
|
|
119
127
|
cacheDir: normalizeCacheDir(fileSettings.cacheDir),
|
|
128
|
+
debug: {
|
|
129
|
+
persistRuns: fileSettings.debug?.persistRuns ?? false,
|
|
130
|
+
},
|
|
120
131
|
};
|
|
121
132
|
}
|
|
122
133
|
|
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
DEFAULT_MAX_BYTES,
|
|
3
|
+
defineTool,
|
|
4
|
+
formatSize,
|
|
5
|
+
truncateHead,
|
|
6
|
+
} from "@earendil-works/pi-coding-agent";
|
|
2
7
|
import { Type } from "typebox";
|
|
3
8
|
import { GitHubApiError, type GitHubClientProvider } from "../github.ts";
|
|
4
9
|
import { LIBRARIAN_TOOL_NAMES } from "./names.ts";
|
|
5
10
|
|
|
6
|
-
const MAX_LINES_WITHOUT_RANGE = 1200;
|
|
7
|
-
|
|
8
11
|
export interface ReadGitHubFileDetails {
|
|
9
12
|
kind: "read_github_file";
|
|
10
13
|
owner: string;
|
|
@@ -17,15 +20,24 @@ export interface ReadGitHubFileDetails {
|
|
|
17
20
|
const ReadGitHubFileParams = Type.Object({
|
|
18
21
|
owner: Type.String({ description: "Repository owner or organization." }),
|
|
19
22
|
repo: Type.String({ description: "Repository name." }),
|
|
20
|
-
path: Type.String({
|
|
23
|
+
path: Type.String({
|
|
24
|
+
description: "File or directory path within the repository.",
|
|
25
|
+
}),
|
|
21
26
|
ref: Type.Optional(
|
|
22
|
-
Type.String({
|
|
27
|
+
Type.String({
|
|
28
|
+
description: "Branch, tag, or commit sha. Default: the default branch.",
|
|
29
|
+
}),
|
|
30
|
+
),
|
|
31
|
+
offset: Type.Optional(
|
|
32
|
+
Type.Number({
|
|
33
|
+
minimum: 1,
|
|
34
|
+
description: "Line number to start reading from (1-indexed).",
|
|
35
|
+
}),
|
|
23
36
|
),
|
|
24
|
-
|
|
25
|
-
Type.
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
maxItems: 2,
|
|
37
|
+
limit: Type.Optional(
|
|
38
|
+
Type.Number({
|
|
39
|
+
minimum: 1,
|
|
40
|
+
description: "Maximum number of lines to read.",
|
|
29
41
|
}),
|
|
30
42
|
),
|
|
31
43
|
});
|
|
@@ -34,19 +46,22 @@ export function createReadGitHubFileTool(githubClient: GitHubClientProvider) {
|
|
|
34
46
|
return defineTool<typeof ReadGitHubFileParams, ReadGitHubFileDetails>({
|
|
35
47
|
name: LIBRARIAN_TOOL_NAMES.readGitHubFile,
|
|
36
48
|
label: "Read GitHub file",
|
|
37
|
-
description:
|
|
38
|
-
"Read a single file (or list a directory) from a GitHub repo via the API, without cloning.",
|
|
49
|
+
description: `Read a single file (or list a directory) from a GitHub repo via the API, without cloning.`,
|
|
39
50
|
promptSnippet: "Read Github file",
|
|
40
51
|
promptGuidelines: [
|
|
41
52
|
"For quick peeks — package.json, a README, one source file.",
|
|
42
53
|
"For multi-file exploration prefer checkout_repo.",
|
|
54
|
+
"Use offset/limit for larger files.",
|
|
43
55
|
],
|
|
44
56
|
parameters: ReadGitHubFileParams,
|
|
45
57
|
|
|
46
58
|
async execute(_toolCallId, params) {
|
|
47
|
-
const
|
|
48
|
-
if (
|
|
49
|
-
throw new Error("
|
|
59
|
+
const offset = params.offset ?? 1;
|
|
60
|
+
if (offset < 1) {
|
|
61
|
+
throw new Error("offset must be greater than or equal to 1.");
|
|
62
|
+
}
|
|
63
|
+
if (params.limit !== undefined && params.limit < 1) {
|
|
64
|
+
throw new Error("limit must be greater than or equal to 1.");
|
|
50
65
|
}
|
|
51
66
|
|
|
52
67
|
try {
|
|
@@ -75,32 +90,52 @@ export function createReadGitHubFileTool(githubClient: GitHubClientProvider) {
|
|
|
75
90
|
}
|
|
76
91
|
|
|
77
92
|
const allLines = contents.text.split("\n");
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
start = Math.min(range[0], allLines.length);
|
|
84
|
-
end = Math.min(range[1], allLines.length);
|
|
85
|
-
} else if (allLines.length > MAX_LINES_WITHOUT_RANGE) {
|
|
86
|
-
end = MAX_LINES_WITHOUT_RANGE;
|
|
87
|
-
clipNote = `\n... clipped at line ${MAX_LINES_WITHOUT_RANGE} of ${allLines.length}; pass range to read further.`;
|
|
93
|
+
const startLine = offset - 1;
|
|
94
|
+
if (startLine >= allLines.length) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`Offset ${offset} is beyond end of file (${allLines.length} lines total)`,
|
|
97
|
+
);
|
|
88
98
|
}
|
|
89
99
|
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
100
|
+
const selectedLines =
|
|
101
|
+
params.limit === undefined
|
|
102
|
+
? allLines.slice(startLine)
|
|
103
|
+
: allLines.slice(startLine, Math.min(startLine + params.limit, allLines.length));
|
|
104
|
+
const selectedContent = selectedLines.join("\n");
|
|
105
|
+
const truncated = truncateHead(selectedContent);
|
|
106
|
+
let text: string;
|
|
107
|
+
let lineCount = truncated.outputLines;
|
|
108
|
+
|
|
109
|
+
if (truncated.firstLineExceedsLimit) {
|
|
110
|
+
const firstLineSize = formatSize(Buffer.byteLength(allLines[startLine] ?? "", "utf-8"));
|
|
111
|
+
text = `[Line ${offset} is ${firstLineSize}, exceeds ${formatSize(DEFAULT_MAX_BYTES)} limit.]`;
|
|
112
|
+
} else if (truncated.truncated) {
|
|
113
|
+
const endLine = offset + truncated.outputLines - 1;
|
|
114
|
+
const nextOffset = endLine + 1;
|
|
115
|
+
const limitNote =
|
|
116
|
+
truncated.truncatedBy === "lines" ? "" : ` (${formatSize(DEFAULT_MAX_BYTES)} limit)`;
|
|
117
|
+
text = `${truncated.content}\n\n[Showing lines ${offset}-${endLine} of ${allLines.length}${limitNote}. Use offset=${nextOffset} to continue.]`;
|
|
118
|
+
} else if (
|
|
119
|
+
params.limit !== undefined &&
|
|
120
|
+
startLine + selectedLines.length < allLines.length
|
|
121
|
+
) {
|
|
122
|
+
const remaining = allLines.length - (startLine + selectedLines.length);
|
|
123
|
+
const nextOffset = startLine + selectedLines.length + 1;
|
|
124
|
+
text = `${truncated.content}\n\n[${remaining} more lines in file. Use offset=${nextOffset} to continue.]`;
|
|
125
|
+
lineCount = selectedLines.length;
|
|
126
|
+
} else {
|
|
127
|
+
text = truncated.content;
|
|
128
|
+
lineCount = selectedLines.length;
|
|
129
|
+
}
|
|
95
130
|
|
|
96
131
|
return {
|
|
97
|
-
content: [{ type: "text", text
|
|
132
|
+
content: [{ type: "text", text }],
|
|
98
133
|
details: {
|
|
99
134
|
kind: "read_github_file",
|
|
100
135
|
owner: params.owner,
|
|
101
136
|
repo: params.repo,
|
|
102
137
|
path: params.path,
|
|
103
|
-
lineCount
|
|
138
|
+
lineCount,
|
|
104
139
|
isDirectory: false,
|
|
105
140
|
},
|
|
106
141
|
};
|
|
@@ -14,12 +14,11 @@ export interface SearchCodeDetails {
|
|
|
14
14
|
const SearchCodeParams = Type.Object({
|
|
15
15
|
pattern: Type.String({
|
|
16
16
|
description:
|
|
17
|
-
"Public code search pattern.
|
|
17
|
+
"Public code search pattern. Use literal code patterns as they appear in files, not keywords: e.g. `useState(` rather than `react hook state`.",
|
|
18
18
|
}),
|
|
19
19
|
regex: Type.Optional(
|
|
20
20
|
Type.Boolean({
|
|
21
|
-
description:
|
|
22
|
-
"Treat pattern as a regular expression. Grep supports multi-line regex with (?s).",
|
|
21
|
+
description: "Treat `pattern` as a regular expression instead of literals.",
|
|
23
22
|
}),
|
|
24
23
|
),
|
|
25
24
|
repo: Type.Optional(
|
|
@@ -62,6 +61,7 @@ export const searchCodeTool = defineTool<typeof SearchCodeParams, SearchCodeDeta
|
|
|
62
61
|
description: "Cross-repo code search over public source code.",
|
|
63
62
|
promptSnippet: "Search code across repos",
|
|
64
63
|
promptGuidelines: [
|
|
64
|
+
"When `regex` is true, `pattern` can also use `(?s)` for multi-line patterns.",
|
|
65
65
|
"Results are CANDIDATES to verify via checkout_repo/read_github_file — never cite them directly.",
|
|
66
66
|
"This searches public GitHub code only. Use search_github_code when private repository access matters.",
|
|
67
67
|
],
|
|
@@ -1,21 +1,18 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
3
3
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
4
|
-
import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import { getMarkdownTheme, keyHint } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { type Component, Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
6
6
|
import type { LibrarianRunDetails, TraceCall } from "./run.ts";
|
|
7
7
|
import { LIBRARIAN_TOOL_NAMES } from "./tools/names.ts";
|
|
8
8
|
|
|
9
9
|
const COLLAPSED_TRACE_CALLS = 3;
|
|
10
|
+
// Same frames and cadence as pi's Working... loader (pi-tui loader.ts defaults).
|
|
10
11
|
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
11
|
-
const SPINNER_INTERVAL_MS = 80;
|
|
12
|
-
|
|
13
|
-
interface
|
|
14
|
-
|
|
15
|
-
state: {
|
|
16
|
-
librarianSpinnerFrame?: number;
|
|
17
|
-
librarianSpinnerInterval?: ReturnType<typeof setInterval>;
|
|
18
|
-
};
|
|
12
|
+
export const SPINNER_INTERVAL_MS = 80;
|
|
13
|
+
|
|
14
|
+
interface LibrarianRenderContext {
|
|
15
|
+
lastComponent: Component | undefined;
|
|
19
16
|
}
|
|
20
17
|
|
|
21
18
|
export function formatDuration(milliseconds: number): string {
|
|
@@ -153,18 +150,17 @@ export function formatTraceLine(call: TraceCall, cacheDir: string): TraceLine {
|
|
|
153
150
|
}
|
|
154
151
|
}
|
|
155
152
|
|
|
156
|
-
function
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
): string {
|
|
153
|
+
function currentSpinnerFrame(): string {
|
|
154
|
+
return SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length] ?? "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderTraceCallText(call: TraceCall, cacheDir: string, theme: Theme): string {
|
|
162
158
|
const { verb, subject } = formatTraceLine(call, cacheDir);
|
|
163
159
|
const running = call.endedAt === undefined;
|
|
164
160
|
const icon = call.isError
|
|
165
161
|
? theme.fg("error", "✗")
|
|
166
162
|
: running
|
|
167
|
-
? theme.fg("
|
|
163
|
+
? theme.fg("accent", currentSpinnerFrame())
|
|
168
164
|
: theme.fg("success", "✓");
|
|
169
165
|
const duration = formatDuration((call.endedAt ?? Date.now()) - call.startedAt);
|
|
170
166
|
const summary = call.resultSummary
|
|
@@ -189,6 +185,25 @@ function renderFooter(details: LibrarianRunDetails, theme: Theme): string {
|
|
|
189
185
|
);
|
|
190
186
|
}
|
|
191
187
|
|
|
188
|
+
function collapsedFindingsHiddenText(details: LibrarianRunDetails): string | undefined {
|
|
189
|
+
const findings = details.findings;
|
|
190
|
+
if (!findings) {
|
|
191
|
+
return undefined;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const hiddenParts: string[] = [];
|
|
195
|
+
if (findings.locations.length > 0) {
|
|
196
|
+
hiddenParts.push(
|
|
197
|
+
`${findings.locations.length} location${findings.locations.length === 1 ? "" : "s"}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
if (findings.description?.trim()) {
|
|
201
|
+
hiddenParts.push("details");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return hiddenParts.length > 0 ? `${hiddenParts.join(" and ")} hidden` : undefined;
|
|
205
|
+
}
|
|
206
|
+
|
|
192
207
|
function isLibrarianRunDetails(value: unknown): value is LibrarianRunDetails {
|
|
193
208
|
return (
|
|
194
209
|
value !== null &&
|
|
@@ -205,55 +220,33 @@ function renderTrace(
|
|
|
205
220
|
expanded: boolean,
|
|
206
221
|
cacheDir: string,
|
|
207
222
|
theme: Theme,
|
|
208
|
-
spinnerFrame: string,
|
|
209
223
|
): string[] {
|
|
210
224
|
const lines: string[] = [];
|
|
211
225
|
const calls = expanded ? details.trace : details.trace.slice(-COLLAPSED_TRACE_CALLS);
|
|
212
226
|
const hiddenCount = details.trace.length - calls.length;
|
|
213
227
|
if (hiddenCount > 0) {
|
|
214
228
|
lines.push(
|
|
215
|
-
theme.fg(
|
|
216
|
-
"
|
|
217
|
-
|
|
218
|
-
),
|
|
229
|
+
theme.fg("muted", ` … ${hiddenCount} earlier call${hiddenCount === 1 ? "" : "s"} (`) +
|
|
230
|
+
keyHint("app.tools.expand", "to expand") +
|
|
231
|
+
theme.fg("muted", ")"),
|
|
219
232
|
);
|
|
220
233
|
}
|
|
221
234
|
for (const call of calls) {
|
|
222
|
-
lines.push(renderTraceCallText(call, cacheDir, theme
|
|
235
|
+
lines.push(renderTraceCallText(call, cacheDir, theme));
|
|
223
236
|
}
|
|
224
237
|
return lines;
|
|
225
238
|
}
|
|
226
239
|
|
|
227
|
-
function updateLiveRender(context: LiveRenderContext | undefined, running: boolean): string {
|
|
228
|
-
if (!context) {
|
|
229
|
-
return (
|
|
230
|
-
SPINNER_FRAMES[Math.floor(Date.now() / SPINNER_INTERVAL_MS) % SPINNER_FRAMES.length] ?? ""
|
|
231
|
-
);
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
context.state.librarianSpinnerFrame ??= 0;
|
|
235
|
-
if (running && !context.state.librarianSpinnerInterval) {
|
|
236
|
-
context.state.librarianSpinnerInterval = setInterval(() => {
|
|
237
|
-
context.state.librarianSpinnerFrame =
|
|
238
|
-
((context.state.librarianSpinnerFrame ?? 0) + 1) % SPINNER_FRAMES.length;
|
|
239
|
-
context.invalidate();
|
|
240
|
-
}, SPINNER_INTERVAL_MS);
|
|
241
|
-
} else if (!running && context.state.librarianSpinnerInterval) {
|
|
242
|
-
clearInterval(context.state.librarianSpinnerInterval);
|
|
243
|
-
delete context.state.librarianSpinnerInterval;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return SPINNER_FRAMES[context.state.librarianSpinnerFrame] ?? "";
|
|
247
|
-
}
|
|
248
|
-
|
|
249
240
|
export function renderLibrarianResult(
|
|
250
241
|
result: AgentToolResult<LibrarianRunDetails>,
|
|
251
242
|
options: { expanded: boolean; isPartial: boolean },
|
|
252
243
|
theme: Theme,
|
|
253
244
|
cacheDir: string,
|
|
254
|
-
context?:
|
|
245
|
+
context?: LibrarianRenderContext,
|
|
255
246
|
): Container {
|
|
256
|
-
const container =
|
|
247
|
+
const container =
|
|
248
|
+
context?.lastComponent instanceof Container ? context.lastComponent : new Container();
|
|
249
|
+
container.clear();
|
|
257
250
|
const details = result.details;
|
|
258
251
|
|
|
259
252
|
if (!isLibrarianRunDetails(details)) {
|
|
@@ -261,22 +254,34 @@ export function renderLibrarianResult(
|
|
|
261
254
|
container.addChild(
|
|
262
255
|
new Text(firstText && "text" in firstText ? firstText.text : "(no output)", 0, 0),
|
|
263
256
|
);
|
|
257
|
+
container.invalidate();
|
|
264
258
|
return container;
|
|
265
259
|
}
|
|
266
260
|
|
|
267
261
|
const running = options.isPartial || details.status === "running";
|
|
268
|
-
const spinnerFrame = updateLiveRender(context, running);
|
|
269
262
|
container.addChild(new Text(renderQuestion(details, options.expanded, theme), 0, 0));
|
|
270
263
|
container.addChild(new Spacer(1));
|
|
271
264
|
|
|
272
265
|
if (running || !details.findings) {
|
|
273
|
-
for (const line of renderTrace(details, options.expanded, cacheDir, theme
|
|
266
|
+
for (const line of renderTrace(details, options.expanded, cacheDir, theme)) {
|
|
274
267
|
container.addChild(new Text(line, 0, 0));
|
|
275
268
|
}
|
|
276
269
|
}
|
|
277
270
|
|
|
278
271
|
if (!running) {
|
|
279
272
|
if (details.findings) {
|
|
273
|
+
const hiddenText = options.expanded ? undefined : collapsedFindingsHiddenText(details);
|
|
274
|
+
if (hiddenText) {
|
|
275
|
+
container.addChild(
|
|
276
|
+
new Text(
|
|
277
|
+
theme.fg("muted", `(${hiddenText}, `) +
|
|
278
|
+
keyHint("app.tools.expand", "to expand") +
|
|
279
|
+
theme.fg("muted", ")"),
|
|
280
|
+
0,
|
|
281
|
+
0,
|
|
282
|
+
),
|
|
283
|
+
);
|
|
284
|
+
}
|
|
280
285
|
const markdown = buildFindingsMarkdown(details, options.expanded);
|
|
281
286
|
container.addChild(new Markdown(markdown, 0, 0, getMarkdownTheme()));
|
|
282
287
|
} else if (details.error) {
|
|
@@ -290,6 +295,7 @@ export function renderLibrarianResult(
|
|
|
290
295
|
container.addChild(new Text(theme.fg("muted", `run ${details.runId}`), 0, 0));
|
|
291
296
|
}
|
|
292
297
|
container.addChild(new Text(renderFooter(details, theme), 0, 0));
|
|
298
|
+
container.invalidate();
|
|
293
299
|
return container;
|
|
294
300
|
}
|
|
295
301
|
|
package/extensions/librarian.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
formatTraceLine,
|
|
18
18
|
renderLibrarianCall,
|
|
19
19
|
renderLibrarianResult,
|
|
20
|
+
SPINNER_INTERVAL_MS,
|
|
20
21
|
shorten,
|
|
21
22
|
} from "./librarian/view.ts";
|
|
22
23
|
|
|
@@ -130,6 +131,16 @@ export default function librarianExtension(pi: ExtensionAPI): void {
|
|
|
130
131
|
},
|
|
131
132
|
|
|
132
133
|
renderResult(result, options, theme, context) {
|
|
134
|
+
// Live spinner/elapsed ticking, same pattern and cadence as pi's bash
|
|
135
|
+
// tool and Working... loader: re-render on an interval while streaming.
|
|
136
|
+
const state = context.state as { interval: NodeJS.Timeout | undefined };
|
|
137
|
+
if (options.isPartial && !state.interval) {
|
|
138
|
+
state.interval = setInterval(() => context.invalidate(), SPINNER_INTERVAL_MS);
|
|
139
|
+
}
|
|
140
|
+
if ((!options.isPartial || context.isError) && state.interval) {
|
|
141
|
+
clearInterval(state.interval);
|
|
142
|
+
state.interval = undefined;
|
|
143
|
+
}
|
|
133
144
|
return renderLibrarianResult(result, options, theme, settings.cacheDir, context);
|
|
134
145
|
},
|
|
135
146
|
});
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thurstonsand/pi-librarian",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "GitHub research subagent for pi: deep-dive specific repos, discover across the ecosystem",
|
|
6
6
|
"license": "MIT",
|
|
@@ -25,7 +25,8 @@
|
|
|
25
25
|
"CONTEXT.md",
|
|
26
26
|
"DEV.md",
|
|
27
27
|
"AGENTS.md",
|
|
28
|
-
"docs"
|
|
28
|
+
"docs",
|
|
29
|
+
"images"
|
|
29
30
|
],
|
|
30
31
|
"pi": {
|
|
31
32
|
"extensions": [
|