@thurstonsand/pi-librarian 0.2.1 → 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/RELEASE.md CHANGED
@@ -2,6 +2,15 @@
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
+
5
14
  ## 0.2.1
6
15
 
7
16
  Fixes TUI rendering corruption.
@@ -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 = await pathExists(path.join(dest, ".git"));
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 type GitHubClientProvider = () => Promise<GitHubClient>;
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();
@@ -249,7 +256,7 @@ function buildGitHubCodeQuery(params: SearchGitHubCodeParams): string {
249
256
  return parts.join(" ");
250
257
  }
251
258
 
252
- export class GitHubClient {
259
+ export class GitHubClient implements GitHubClientApi {
253
260
  constructor(private readonly octokit: Octokit) {}
254
261
 
255
262
  async searchRepositories(params: SearchRepositoriesParams): Promise<RepoSearchResult> {
@@ -1,10 +1,13 @@
1
- import { defineTool } from "@earendil-works/pi-coding-agent";
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({ description: "File or directory path within the repository." }),
23
+ path: Type.String({
24
+ description: "File or directory path within the repository.",
25
+ }),
21
26
  ref: Type.Optional(
22
- Type.String({ description: "Branch, tag, or commit sha. Default: the default branch." }),
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
- range: Type.Optional(
25
- Type.Array(Type.Number({ minimum: 1 }), {
26
- description: "[startLine, endLine] (1-based, inclusive) for large files.",
27
- minItems: 2,
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 range = params.range ? (params.range as [number, number]) : undefined;
48
- if (range && range[1] < range[0]) {
49
- throw new Error("range end must be greater than or equal to range start.");
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
- let start = 1;
79
- let end = allLines.length;
80
- let clipNote = "";
81
-
82
- if (range) {
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 width = String(end).length;
91
- const numbered = allLines
92
- .slice(start - 1, end)
93
- .map((line, index) => `${String(start + index).padStart(width)}\t${line}`)
94
- .join("\n");
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: `${numbered}${clipNote}` }],
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: end - start + 1,
138
+ lineCount,
104
139
  isDirectory: false,
105
140
  },
106
141
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thurstonsand/pi-librarian",
3
- "version": "0.2.1",
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",