@thurstonsand/pi-librarian 0.2.0 → 0.2.1

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,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
+ ![calling the librarian tool](images/librarian-run.png)
6
+
7
+ ![librarian tool results](images/librarian-results.png)
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 | Recommended | Default |
47
- | --------------- | --------------------------------------------- | ------------------------------ |
48
- | `model` | `openai-codex/gpt-5.5` | current session model |
49
- | `thinkingLevel` | `off` | current session thinking level |
50
- | `tools` | names of extra tools to activate, when needed | `[]` |
51
- | `extensions` | escape hatch paths for tools not loaded in pi | `[]` |
52
- | `cacheDir` | `/tmp/pi-librarian` | `/tmp/pi-librarian` |
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,19 @@
2
2
 
3
3
  # Release notes
4
4
 
5
+ ## 0.2.1
6
+
7
+ Fixes TUI rendering corruption.
8
+
9
+ ### Added
10
+
11
+ - Added `librarian.debug.persistRuns` to keep nested run session files around for debugging.
12
+
13
+ ### Fixed
14
+
15
+ - Fixed librarian trace rendering leaving stale "N earlier calls" duplicates in the TUI, caused by unsilenced `@octokit/request` deprecation warnings writing to the console.
16
+ - 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.
17
+
5
18
  ## 0.2.0
6
19
 
7
20
  Adds continuable librarian runs and opt-in for extra tools.
@@ -45,18 +45,26 @@ export async function resolveGitHubToken(): Promise<string | undefined> {
45
45
  }
46
46
  }
47
47
 
48
+ // Any console output corrupts pi's interactive TUI (its differential renderer
49
+ // cannot recover from writes it did not make), so octokit must never log.
50
+ // The top-level `log` alone is not enough: @octokit/request resolves its logger
51
+ // from the per-request options (`request.log || console`) when emitting API
52
+ // deprecation warnings, so the silent logger has to be passed at both levels.
53
+ const SILENT_OCTOKIT_LOG = {
54
+ debug: () => {},
55
+ info: () => {},
56
+ warn: () => {},
57
+ error: () => {},
58
+ };
59
+
48
60
  export function createGitHubClient(token: string | undefined): GitHubClient {
49
61
  return new GitHubClient(
50
62
  new Octokit({
51
63
  ...(token ? { auth: token } : {}),
52
64
  userAgent: "pi-librarian",
53
- log: {
54
- debug: () => {},
55
- info: () => {},
56
- warn: () => {},
57
- error: () => {},
58
- },
65
+ log: SILENT_OCTOKIT_LOG,
59
66
  request: {
67
+ log: SILENT_OCTOKIT_LOG,
60
68
  headers: {
61
69
  "x-github-api-version": "2022-11-28",
62
70
  },
@@ -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
- const GREP_SEARCH_TIMEOUT_MS = 30_000;
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
- throw new GrepError("Grep MCP returned an error result.");
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
- export async function searchCodeGrep(
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(GREP_SEARCH_TIMEOUT_MS);
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
- throw new GrepError(`Grep MCP returned ${response.status}: ${body.slice(0, 200)}`);
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 resultContent = details.runId ? `${content}\n\nrun: ${details.runId}` : content;
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
 
@@ -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. Literal by default; set regex: true to treat it as a regular expression.",
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 LiveRenderContext {
14
- invalidate(): void;
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 renderTraceCallText(
157
- call: TraceCall,
158
- cacheDir: string,
159
- theme: Theme,
160
- spinnerFrame: string,
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("warning", spinnerFrame)
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
- "muted",
217
- ` … ${hiddenCount} earlier call${hiddenCount === 1 ? "" : "s"} (Ctrl+O to expand)`,
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, spinnerFrame));
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?: LiveRenderContext,
245
+ context?: LibrarianRenderContext,
255
246
  ): Container {
256
- const container = new 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, spinnerFrame)) {
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
 
@@ -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.2.0",
3
+ "version": "0.2.1",
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": [