@the-librarian/pi-extension 1.0.0-rc.2
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 +85 -0
- package/extensions/librarian/commands.ts +77 -0
- package/extensions/librarian/config.ts +38 -0
- package/extensions/librarian/index.ts +64 -0
- package/extensions/librarian/mcp-client.ts +228 -0
- package/extensions/librarian/primer.ts +100 -0
- package/extensions/librarian/tools.ts +223 -0
- package/package.json +57 -0
package/README.md
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# The Librarian — Pi extension
|
|
2
|
+
|
|
3
|
+
A [Pi](https://pi.dev) package that gives the Pi coding agent durable memory and
|
|
4
|
+
cross-harness handoffs, backed by a remote
|
|
5
|
+
[Librarian](https://github.com/JimJafar/the-librarian) MCP server.
|
|
6
|
+
|
|
7
|
+
Pi's core has **no MCP support**, so this extension does the wiring itself: it
|
|
8
|
+
registers the Librarian's 7 agent verbs as native Pi tools (each one a thin,
|
|
9
|
+
fail-soft proxy over the server's stateless `/mcp` endpoint) and injects the
|
|
10
|
+
Librarian primer into the system prompt. One install, zero config files.
|
|
11
|
+
|
|
12
|
+
## What you get
|
|
13
|
+
|
|
14
|
+
- **7 tools, identical to every other harness** — `recall`, `remember`,
|
|
15
|
+
`flag_memory`, `store_handoff`, `list_handoffs`, `claim_handoff`,
|
|
16
|
+
`search_references`. Descriptions and schemas mirror the server's
|
|
17
|
+
(a drift-guard test pins them), so the model is taught the same protocol in
|
|
18
|
+
Pi as in Claude Code, Codex, OpenCode, and Hermes.
|
|
19
|
+
- **Primer injection** — the operator-editable `vault/primer.md` (≤2KB) is
|
|
20
|
+
fetched once per process from `GET <server>/primer.md` and appended to the
|
|
21
|
+
system prompt via `before_agent_start`.
|
|
22
|
+
- **Four slash commands** (optional sugar): `/handoff`, `/takeover`, `/learn`,
|
|
23
|
+
`/toggle-private` — thin prompt templates that drive the corresponding tool
|
|
24
|
+
flows. See [`docs/slash-commands.md`](../../docs/slash-commands.md).
|
|
25
|
+
- **Fail-soft everywhere** — if the Librarian is down, tools return a short
|
|
26
|
+
error string (never a thrown harness error), the primer is skipped, and the
|
|
27
|
+
user's turn is never blocked.
|
|
28
|
+
|
|
29
|
+
## Install
|
|
30
|
+
|
|
31
|
+
From npm (once published — see "Publishing" below):
|
|
32
|
+
|
|
33
|
+
```sh
|
|
34
|
+
pi install npm:@the-librarian/pi-extension
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
From source (works today):
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
git clone https://github.com/JimJafar/the-librarian
|
|
41
|
+
pi install /path/to/the-librarian/integrations/pi
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
(`pi install git:…` of the monorepo root won't work — the package lives in the
|
|
45
|
+
`integrations/pi` subdirectory, so install from a local clone path.)
|
|
46
|
+
|
|
47
|
+
## Configure
|
|
48
|
+
|
|
49
|
+
Set two environment variables in the shell that launches `pi`:
|
|
50
|
+
|
|
51
|
+
| Variable | Meaning |
|
|
52
|
+
| --- | --- |
|
|
53
|
+
| `LIBRARIAN_MCP_URL` | The server's MCP endpoint, e.g. `https://your-librarian/mcp` |
|
|
54
|
+
| `LIBRARIAN_AGENT_TOKEN` | A per-agent bearer token issued by your Librarian |
|
|
55
|
+
| `LIBRARIAN_TIMEOUT_MS` | *(optional)* per-call timeout, default `15000` |
|
|
56
|
+
|
|
57
|
+
Without both required variables the extension stays **dormant**: no tools, no
|
|
58
|
+
network calls — only the four slash commands register, and they explain what's
|
|
59
|
+
missing.
|
|
60
|
+
|
|
61
|
+
Security posture (inherited from the security-reviewed clients in this family):
|
|
62
|
+
the bearer token travels only in the `Authorization` header, redirects are
|
|
63
|
+
refused so a 3xx can't carry the token cross-origin, the endpoint scheme is
|
|
64
|
+
allowlisted to http(s), and response bodies are size-capped.
|
|
65
|
+
|
|
66
|
+
## Develop
|
|
67
|
+
|
|
68
|
+
This package is part of the `the-librarian` pnpm workspace:
|
|
69
|
+
|
|
70
|
+
```sh
|
|
71
|
+
pnpm install
|
|
72
|
+
pnpm --filter @the-librarian/pi-extension test # vitest
|
|
73
|
+
pnpm --filter @the-librarian/pi-extension typecheck # tsc --noEmit
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The schema-parity suite imports the compiled `@librarian/mcp-server`, which is
|
|
77
|
+
built automatically on `pnpm install` (its `prepare` script).
|
|
78
|
+
|
|
79
|
+
## Publishing
|
|
80
|
+
|
|
81
|
+
The package is publishable (`"private": false`) and carries the `pi-package`
|
|
82
|
+
keyword, so `npm publish` from `integrations/pi/` both releases it and lists it
|
|
83
|
+
in Pi's gallery at `pi.dev/packages`. **Publishing is a repo-owner action** —
|
|
84
|
+
it is deliberately not part of any automated flow here. Until it's published,
|
|
85
|
+
use the "from source" install above.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Native Pi slash commands for The Librarian — optional sugar (rethink D9).
|
|
2
|
+
//
|
|
3
|
+
// Four thin prompt templates aligned with docs/slash-commands.md and the
|
|
4
|
+
// 7-verb surface. Each handler injects a user message that drives the LLM
|
|
5
|
+
// through the corresponding tool flow; the tools (and the primer) carry the
|
|
6
|
+
// actual protocol, so these stay deliberately skeletal. Pi's `registerCommand`
|
|
7
|
+
// is one call per verb — cheap enough to keep.
|
|
8
|
+
|
|
9
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
|
|
11
|
+
const HANDOFF_PROMPT =
|
|
12
|
+
"Author a five-section handoff document with these exact headings: " +
|
|
13
|
+
"`## Start & intent`, `## Journey`, `## Current state`, `## What's left`, " +
|
|
14
|
+
"`## Open questions`. Then call `store_handoff` with the document (plus " +
|
|
15
|
+
"project_key / cwd / harness when known) and report the returned handoff_id.";
|
|
16
|
+
|
|
17
|
+
const TAKEOVER_PROMPT =
|
|
18
|
+
"Call `list_handoffs` scoped to the current project_key + cwd (drop filters " +
|
|
19
|
+
"to broaden when nothing matches), present the candidates to the user — " +
|
|
20
|
+
"never auto-select — then `claim_handoff` the chosen id and continue the " +
|
|
21
|
+
"work its document describes. If the user supplied a handoff id, claim it directly.";
|
|
22
|
+
|
|
23
|
+
const LEARN_PROMPT =
|
|
24
|
+
"Extract durable lessons from this conversation and feed user-approved ones " +
|
|
25
|
+
"to `remember`, one call per lesson — fire-and-forget: the user picking a " +
|
|
26
|
+
"lesson is the review, so submit and move on; the curator dedupes, merges, " +
|
|
27
|
+
"and files each one asynchronously.";
|
|
28
|
+
|
|
29
|
+
const TOGGLE_ON =
|
|
30
|
+
"Private mode is ON. `[librarian:private=on]` — do not call `remember`, " +
|
|
31
|
+
"`store_handoff`, or `flag_memory` until told otherwise. `recall` and " +
|
|
32
|
+
"`search_references` stay allowed; note that read queries still reach the " +
|
|
33
|
+
"Librarian server's logs. Remain in this state until explicitly toggled off.";
|
|
34
|
+
|
|
35
|
+
const TOGGLE_OFF = "Private mode is OFF. `[librarian:private=off]` — normal operation resumed.";
|
|
36
|
+
|
|
37
|
+
const TOGGLE_PROMPT =
|
|
38
|
+
"Toggle in-conversation private mode. Inject the inverse of the most recent " +
|
|
39
|
+
`\`[librarian:private=on|off]\` marker. If ON: emit \`${TOGGLE_OFF}\`. If ` +
|
|
40
|
+
`OFF or no marker: emit \`${TOGGLE_ON}\`.`;
|
|
41
|
+
|
|
42
|
+
export const COMMAND_SPECS = [
|
|
43
|
+
{
|
|
44
|
+
name: "handoff",
|
|
45
|
+
description: "Author and persist a cross-harness handoff document",
|
|
46
|
+
prompt: HANDOFF_PROMPT,
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: "takeover",
|
|
50
|
+
description: "Pick up a handoff from another agent / harness",
|
|
51
|
+
prompt: TAKEOVER_PROMPT,
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
name: "learn",
|
|
55
|
+
description: "Extract durable lessons from this conversation into durable memory",
|
|
56
|
+
prompt: LEARN_PROMPT,
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "toggle-private",
|
|
60
|
+
description: "Toggle in-conversation private mode (no server state, no hook)",
|
|
61
|
+
prompt: TOGGLE_PROMPT,
|
|
62
|
+
},
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
export function registerCommands(pi: ExtensionAPI): void {
|
|
66
|
+
for (const spec of COMMAND_SPECS) {
|
|
67
|
+
pi.registerCommand(spec.name, {
|
|
68
|
+
description: spec.description,
|
|
69
|
+
handler: async (args: string, _ctx: ExtensionCommandContext) => {
|
|
70
|
+
// `sendUserMessage` always triggers a turn, so the template actually
|
|
71
|
+
// drives the flow (a UI notify would only reach the human).
|
|
72
|
+
const input = args.trim();
|
|
73
|
+
pi.sendUserMessage(input ? `${spec.prompt}\n\nUser input: ${input}` : spec.prompt);
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Environment-driven configuration for the Pi ↔ Librarian integration.
|
|
2
|
+
//
|
|
3
|
+
// The extension stays DORMANT unless a remote Librarian is configured
|
|
4
|
+
// (LIBRARIAN_MCP_URL + LIBRARIAN_AGENT_TOKEN). This mirrors the other harness
|
|
5
|
+
// integrations: no endpoint/token → no automatic calls, no surprises.
|
|
6
|
+
//
|
|
7
|
+
// 2026-06-12 rethink (spec §7, D14) — the session-era `projectKey` /
|
|
8
|
+
// `deviceId` fields are retired with the session subsystem; the surviving
|
|
9
|
+
// config is exactly what the 7 tool proxies + primer fetch need.
|
|
10
|
+
|
|
11
|
+
export const HARNESS = "pi" as const;
|
|
12
|
+
|
|
13
|
+
export interface LibrarianConfig {
|
|
14
|
+
endpoint: string;
|
|
15
|
+
token: string;
|
|
16
|
+
timeoutMs?: number | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function parsePositiveInt(value: string | undefined): number | undefined {
|
|
20
|
+
if (value === undefined) return undefined;
|
|
21
|
+
const n = Number(value);
|
|
22
|
+
return Number.isFinite(n) && n > 0 ? n : undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Read config from the environment. Returns null when the remote Librarian is
|
|
27
|
+
* not configured — the caller treats that as "extension dormant".
|
|
28
|
+
*/
|
|
29
|
+
export function readConfig(env: NodeJS.ProcessEnv = process.env): LibrarianConfig | null {
|
|
30
|
+
const endpoint = env.LIBRARIAN_MCP_URL?.trim();
|
|
31
|
+
const token = env.LIBRARIAN_AGENT_TOKEN?.trim();
|
|
32
|
+
if (!endpoint || !token) return null;
|
|
33
|
+
|
|
34
|
+
const config: LibrarianConfig = { endpoint, token };
|
|
35
|
+
const timeoutMs = parsePositiveInt(env.LIBRARIAN_TIMEOUT_MS);
|
|
36
|
+
if (timeoutMs !== undefined) config.timeoutMs = timeoutMs;
|
|
37
|
+
return config;
|
|
38
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// The Librarian — Pi coding-agent extension (rethink spec §7, D14).
|
|
2
|
+
//
|
|
3
|
+
// Ported from the standalone the-librarian-pi-extension repo. The 2026-06-12
|
|
4
|
+
// rethink retires the conv-state injection, the per-turn conv_state fetch, and
|
|
5
|
+
// the 3-tool limitation; what survives is exactly:
|
|
6
|
+
//
|
|
7
|
+
// - the 7 agent verbs (recall / remember / flag_memory / store_handoff /
|
|
8
|
+
// list_handoffs / claim_handoff / search_references) as native Pi tool
|
|
9
|
+
// proxies over the Librarian's stateless /mcp endpoint — see `tools.ts`;
|
|
10
|
+
// - the ≤2KB primer, fetched from `GET /primer.md` once per process and
|
|
11
|
+
// appended to the system prompt via `before_agent_start` — see `primer.ts`;
|
|
12
|
+
// - four user-facing slash commands (`/handoff`, `/takeover`, `/learn`,
|
|
13
|
+
// `/toggle-private`) as thin prompt templates — see `commands.ts`.
|
|
14
|
+
//
|
|
15
|
+
// Private mode is purely in-conversation: an `[librarian:private=on|off]`
|
|
16
|
+
// marker the LLM owns. There is no server flag, no on-disk state, and no
|
|
17
|
+
// privacy hook here.
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { registerCommands } from "./commands.js";
|
|
21
|
+
import { readConfig } from "./config.js";
|
|
22
|
+
import { createMcpClient } from "./mcp-client.js";
|
|
23
|
+
import { createPrimerSource, registerPrimerHook } from "./primer.js";
|
|
24
|
+
import { registerLibrarianTools } from "./tools.js";
|
|
25
|
+
|
|
26
|
+
const CONFIG_HINT =
|
|
27
|
+
"The Librarian is not configured. Set LIBRARIAN_MCP_URL and LIBRARIAN_AGENT_TOKEN.";
|
|
28
|
+
|
|
29
|
+
const COMMAND_VERBS = ["handoff", "takeover", "learn", "toggle-private"] as const;
|
|
30
|
+
|
|
31
|
+
export default function librarian(pi: ExtensionAPI): void {
|
|
32
|
+
const config = readConfig();
|
|
33
|
+
|
|
34
|
+
if (!config) {
|
|
35
|
+
// Dormant: no endpoint/token → no tools, no hook, no automatic calls.
|
|
36
|
+
// Still register the commands so they explain the missing configuration
|
|
37
|
+
// instead of being "unknown command".
|
|
38
|
+
for (const verb of COMMAND_VERBS) {
|
|
39
|
+
pi.registerCommand(verb, {
|
|
40
|
+
description: `${verb} (Librarian not configured)`,
|
|
41
|
+
handler: async (_args, ctx) => {
|
|
42
|
+
ctx.ui.notify(CONFIG_HINT, "warning");
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// One shared MCP client for all 7 tool proxies.
|
|
50
|
+
const mcp = createMcpClient({
|
|
51
|
+
endpoint: config.endpoint,
|
|
52
|
+
token: config.token,
|
|
53
|
+
...(config.timeoutMs !== undefined ? { timeoutMs: config.timeoutMs } : {}),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Expose the Librarian's 7 verbs to the model directly (no mcp.json needed).
|
|
57
|
+
registerLibrarianTools(pi, mcp);
|
|
58
|
+
|
|
59
|
+
// Primer → system prompt, once per turn, cached per process, fail-soft.
|
|
60
|
+
registerPrimerHook(pi, createPrimerSource({ endpoint: config.endpoint }));
|
|
61
|
+
|
|
62
|
+
// The four user-facing slash commands (optional sugar, D9).
|
|
63
|
+
registerCommands(pi);
|
|
64
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Minimal MCP client for the Librarian HTTP server.
|
|
2
|
+
//
|
|
3
|
+
// The Librarian's `/mcp` is a STATELESS JSON-RPC 2.0 endpoint: no `initialize`
|
|
4
|
+
// handshake and no session id — a `tools/call` is POSTed directly with a Bearer
|
|
5
|
+
// token. So this is a single-request client: build the envelope, POST it, map
|
|
6
|
+
// every failure onto a typed `McpClientError`, and return the tool's text.
|
|
7
|
+
//
|
|
8
|
+
// Ported from the standalone the-librarian-pi-extension repo (2026-06-12
|
|
9
|
+
// rethink, D14) minus the retired session-prose parsers. Mirrors the security
|
|
10
|
+
// posture of the Hermes integration's `client.py` (which a security review
|
|
11
|
+
// hardened): the bearer token lives ONLY in the Authorization header and is
|
|
12
|
+
// never put into an error message; 3xx redirects are refused so a redirect
|
|
13
|
+
// can't carry the token cross-origin; the endpoint scheme is allowlisted to
|
|
14
|
+
// http(s); and the response body is capped so a runaway endpoint cannot
|
|
15
|
+
// exhaust memory. The transport is injectable so tests never touch the
|
|
16
|
+
// network.
|
|
17
|
+
|
|
18
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
19
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
20
|
+
const MAX_RPC_MESSAGE_CHARS = 200;
|
|
21
|
+
|
|
22
|
+
export type McpClientErrorKind = "config" | "network" | "timeout" | "http" | "rpc" | "malformed";
|
|
23
|
+
|
|
24
|
+
export class McpClientError extends Error {
|
|
25
|
+
override readonly name = "McpClientError";
|
|
26
|
+
readonly kind: McpClientErrorKind;
|
|
27
|
+
readonly status?: number | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(kind: McpClientErrorKind, message: string, extra: { status?: number } = {}) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.kind = kind;
|
|
32
|
+
this.status = extra.status;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface McpRequest {
|
|
37
|
+
url: string;
|
|
38
|
+
body: string;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
timeoutMs: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface McpResponse {
|
|
44
|
+
status: number;
|
|
45
|
+
body: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** POST and return `(status, body)`. Throw a TimeoutError-named error on timeout. */
|
|
49
|
+
export type McpTransport = (req: McpRequest) => Promise<McpResponse>;
|
|
50
|
+
|
|
51
|
+
export interface McpClientConfig {
|
|
52
|
+
endpoint: string;
|
|
53
|
+
token: string;
|
|
54
|
+
timeoutMs?: number;
|
|
55
|
+
/** Cap on the buffered response body (default 8 MiB). */
|
|
56
|
+
maxResponseBytes?: number;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface McpClient {
|
|
60
|
+
callTool(name: string, args: Record<string, unknown>): Promise<string>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createMcpClient(config: McpClientConfig, transport?: McpTransport): McpClient {
|
|
64
|
+
let url: URL;
|
|
65
|
+
try {
|
|
66
|
+
url = new URL(config.endpoint);
|
|
67
|
+
} catch {
|
|
68
|
+
throw new McpClientError("config", "Librarian endpoint is not a valid URL");
|
|
69
|
+
}
|
|
70
|
+
// Allowlist the scheme so a mistemplated endpoint can't reach a file:/data:
|
|
71
|
+
// handler (config-driven SSRF / local file read).
|
|
72
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
73
|
+
throw new McpClientError(
|
|
74
|
+
"config",
|
|
75
|
+
`Librarian endpoint must be http(s), got ${url.protocol.replace(/:$/, "") || "(none)"}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
// Reject HTTP basic-auth userinfo in the URL: the token is the auth mechanism,
|
|
79
|
+
// and an embedded password is a second secret that would otherwise leak into
|
|
80
|
+
// the network error message below (same leak class as the bearer token).
|
|
81
|
+
if (url.username || url.password) {
|
|
82
|
+
throw new McpClientError(
|
|
83
|
+
"config",
|
|
84
|
+
"Librarian endpoint must not embed credentials; authenticate with the token instead",
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
const endpoint = config.endpoint;
|
|
88
|
+
// A credential-free, query-free rendering used in error messages so nothing
|
|
89
|
+
// secret-bearing in the endpoint can leak into logs.
|
|
90
|
+
const safeEndpoint = `${url.protocol}//${url.host}${url.pathname}`;
|
|
91
|
+
const token = config.token;
|
|
92
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
93
|
+
const maxResponseBytes = config.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
94
|
+
const send = transport ?? defaultTransport(maxResponseBytes);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
async callTool(name, args) {
|
|
98
|
+
const body = JSON.stringify({
|
|
99
|
+
jsonrpc: "2.0",
|
|
100
|
+
id: 1,
|
|
101
|
+
method: "tools/call",
|
|
102
|
+
params: { name, arguments: args },
|
|
103
|
+
});
|
|
104
|
+
// The token lives ONLY here — never in args, the URL, or any error text.
|
|
105
|
+
const headers = {
|
|
106
|
+
"Content-Type": "application/json",
|
|
107
|
+
Accept: "application/json",
|
|
108
|
+
Authorization: `Bearer ${token}`,
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
let response: McpResponse;
|
|
112
|
+
try {
|
|
113
|
+
response = await send({ url: endpoint, body, headers, timeoutMs });
|
|
114
|
+
} catch (err) {
|
|
115
|
+
if (err instanceof McpClientError) throw err;
|
|
116
|
+
if (isTimeoutError(err)) {
|
|
117
|
+
throw new McpClientError("timeout", `${name} timed out after ${timeoutMs}ms`);
|
|
118
|
+
}
|
|
119
|
+
// Don't interpolate the underlying error (it may echo the request) —
|
|
120
|
+
// keep the token-bearing call strictly out of anything we render.
|
|
121
|
+
throw new McpClientError(
|
|
122
|
+
"network",
|
|
123
|
+
`${name} could not reach the Librarian at ${safeEndpoint}`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (response.status !== 200) {
|
|
128
|
+
throw new McpClientError("http", `${name} returned HTTP ${response.status}`, {
|
|
129
|
+
status: response.status,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let payload: unknown;
|
|
134
|
+
try {
|
|
135
|
+
payload = JSON.parse(response.body);
|
|
136
|
+
} catch {
|
|
137
|
+
throw new McpClientError("malformed", `${name} returned non-JSON`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// `!= null` (not `"error" in payload`) so a spec-tolerant `error: null`
|
|
141
|
+
// alongside a result is treated as success, not a phantom rpc failure.
|
|
142
|
+
if (isRecord(payload) && payload.error != null) {
|
|
143
|
+
const rpc = payload.error;
|
|
144
|
+
const code = isRecord(rpc) ? rpc.code : undefined;
|
|
145
|
+
// Truncate the server-controlled message so it can't bloat logs.
|
|
146
|
+
const msg = isRecord(rpc) ? String(rpc.message ?? "").slice(0, MAX_RPC_MESSAGE_CHARS) : "";
|
|
147
|
+
throw new McpClientError("rpc", `${name} failed: ${msg} (code ${String(code)})`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const text = extractText(payload);
|
|
151
|
+
if (text === null) {
|
|
152
|
+
throw new McpClientError("malformed", `${name} response had no text content`);
|
|
153
|
+
}
|
|
154
|
+
return text;
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
160
|
+
return typeof value === "object" && value !== null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function isTimeoutError(err: unknown): boolean {
|
|
164
|
+
const name = (err as { name?: string } | null)?.name;
|
|
165
|
+
const code = (err as { code?: string } | null)?.code;
|
|
166
|
+
return name === "AbortError" || name === "TimeoutError" || code === "ETIMEDOUT";
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Pull `result.content[0].text` from an MCP tool response, or null. */
|
|
170
|
+
function extractText(payload: unknown): string | null {
|
|
171
|
+
if (!isRecord(payload)) return null;
|
|
172
|
+
const result = payload.result;
|
|
173
|
+
if (!isRecord(result)) return null;
|
|
174
|
+
const content = result.content;
|
|
175
|
+
if (!Array.isArray(content) || content.length === 0) return null;
|
|
176
|
+
const first: unknown = content[0];
|
|
177
|
+
if (!isRecord(first)) return null;
|
|
178
|
+
return typeof first.text === "string" ? first.text : null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function defaultTransport(maxResponseBytes: number): McpTransport {
|
|
182
|
+
return async ({ url, body, headers, timeoutMs }) => {
|
|
183
|
+
const controller = new AbortController();
|
|
184
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
185
|
+
try {
|
|
186
|
+
const response = await fetch(url, {
|
|
187
|
+
method: "POST",
|
|
188
|
+
body,
|
|
189
|
+
headers,
|
|
190
|
+
// A 3xx must NEVER be followed: fetch would carry the Authorization
|
|
191
|
+
// header to the redirect target and leak the bearer token cross-origin.
|
|
192
|
+
// The Librarian /mcp is a single stateless POST with no legitimate 3xx.
|
|
193
|
+
redirect: "error",
|
|
194
|
+
signal: controller.signal,
|
|
195
|
+
});
|
|
196
|
+
return { status: response.status, body: await readCapped(response, maxResponseBytes) };
|
|
197
|
+
} finally {
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function readCapped(response: Response, cap: number): Promise<string> {
|
|
204
|
+
const reader = response.body?.getReader();
|
|
205
|
+
if (!reader) {
|
|
206
|
+
// No readable stream (e.g. an empty body). arrayBuffer buffers fully, but
|
|
207
|
+
// we still enforce the BYTE cap before decoding so the protection holds.
|
|
208
|
+
const buffer = Buffer.from(await response.arrayBuffer());
|
|
209
|
+
if (buffer.byteLength > cap) {
|
|
210
|
+
throw new McpClientError("malformed", "Librarian response exceeded the size cap");
|
|
211
|
+
}
|
|
212
|
+
return buffer.toString("utf8");
|
|
213
|
+
}
|
|
214
|
+
const chunks: Uint8Array[] = [];
|
|
215
|
+
let total = 0;
|
|
216
|
+
for (;;) {
|
|
217
|
+
const { done, value } = await reader.read();
|
|
218
|
+
if (done) break;
|
|
219
|
+
if (!value) continue;
|
|
220
|
+
total += value.byteLength;
|
|
221
|
+
if (total > cap) {
|
|
222
|
+
await reader.cancel();
|
|
223
|
+
throw new McpClientError("malformed", "Librarian response exceeded the size cap");
|
|
224
|
+
}
|
|
225
|
+
chunks.push(value);
|
|
226
|
+
}
|
|
227
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
228
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Primer injection via `before_agent_start` (rethink spec §5.2, D9).
|
|
2
|
+
//
|
|
3
|
+
// The Librarian teaches agents through one ≤2KB primer served unauthenticated
|
|
4
|
+
// at `GET /primer.md` on the server root (the same document every other
|
|
5
|
+
// harness sees). This module fetches it once per process, caches it, and
|
|
6
|
+
// appends it to Pi's system prompt — the thinnest native channel Pi offers.
|
|
7
|
+
//
|
|
8
|
+
// Fail-soft contract (AGENTS.md §2): every failure path yields an empty primer
|
|
9
|
+
// and an unchanged system prompt. The user's turn is never blocked; a fetch
|
|
10
|
+
// failure is retried on the next turn rather than cached.
|
|
11
|
+
|
|
12
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
15
|
+
// The server enforces ≤2KB on save; 64KB of headroom keeps the cap a pure
|
|
16
|
+
// runaway-endpoint guard rather than a contract we could trip on first.
|
|
17
|
+
const DEFAULT_MAX_BYTES = 64 * 1024;
|
|
18
|
+
|
|
19
|
+
export interface PrimerSourceConfig {
|
|
20
|
+
/** The configured LIBRARIAN_MCP_URL (usually `https://host/mcp`). */
|
|
21
|
+
endpoint: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
maxBytes?: number;
|
|
24
|
+
/** Injectable for tests. */
|
|
25
|
+
fetchImpl?: typeof fetch;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the primer URL from the MCP endpoint. The primer is served at the
|
|
30
|
+
* SERVER ROOT (`/primer.md`), not under `/mcp` — root-relative resolution
|
|
31
|
+
* strips the endpoint's path. Returns null for an unparseable endpoint.
|
|
32
|
+
*/
|
|
33
|
+
export function primerUrl(endpoint: string): string | null {
|
|
34
|
+
try {
|
|
35
|
+
return new URL("/primer.md", endpoint).toString();
|
|
36
|
+
} catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build a per-process primer source: the first successful fetch is cached for
|
|
43
|
+
* the lifetime of the process; failures return "" WITHOUT caching so a later
|
|
44
|
+
* turn can retry once the server is back.
|
|
45
|
+
*/
|
|
46
|
+
export function createPrimerSource(config: PrimerSourceConfig): () => Promise<string> {
|
|
47
|
+
const url = primerUrl(config.endpoint);
|
|
48
|
+
const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
49
|
+
const maxBytes = config.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
50
|
+
const fetchImpl = config.fetchImpl ?? fetch;
|
|
51
|
+
let cached: string | null = null;
|
|
52
|
+
|
|
53
|
+
return async function getPrimer(): Promise<string> {
|
|
54
|
+
if (cached !== null) return cached;
|
|
55
|
+
if (!url) return "";
|
|
56
|
+
const controller = new AbortController();
|
|
57
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
58
|
+
try {
|
|
59
|
+
// No Authorization header: /primer.md is unauthenticated by design, so
|
|
60
|
+
// the bearer token has no business on this request. Redirects are
|
|
61
|
+
// refused to match the MCP client's posture (and a primer has no
|
|
62
|
+
// legitimate 3xx).
|
|
63
|
+
const response = await fetchImpl(url, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
redirect: "error",
|
|
66
|
+
signal: controller.signal,
|
|
67
|
+
});
|
|
68
|
+
if (response.status !== 200) return "";
|
|
69
|
+
const text = await response.text();
|
|
70
|
+
if (Buffer.byteLength(text, "utf8") > maxBytes) return "";
|
|
71
|
+
cached = text;
|
|
72
|
+
return cached;
|
|
73
|
+
} catch {
|
|
74
|
+
return "";
|
|
75
|
+
} finally {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Wire the primer into Pi's system prompt. `before_agent_start` fires once per
|
|
83
|
+
* turn after Pi assembles the system prompt; the SDK chains multiple
|
|
84
|
+
* extensions' `systemPrompt` returns, so this append cooperates with any other
|
|
85
|
+
* extension that also augments the prompt. Empty primer → no return value →
|
|
86
|
+
* Pi leaves the prompt untouched.
|
|
87
|
+
*/
|
|
88
|
+
export function registerPrimerHook(pi: ExtensionAPI, getPrimer: () => Promise<string>): void {
|
|
89
|
+
pi.on("before_agent_start", async (event) => {
|
|
90
|
+
try {
|
|
91
|
+
const primer = await getPrimer();
|
|
92
|
+
if (!primer.trim()) return;
|
|
93
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${primer}` };
|
|
94
|
+
} catch {
|
|
95
|
+
// Defensive net — getPrimer already swallows its own errors, but a
|
|
96
|
+
// top-level throw must never break the user's turn.
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
}
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// The Librarian's 7 agent verbs, registered natively in the extension.
|
|
2
|
+
//
|
|
3
|
+
// Pi's CORE has no MCP support (the `mcp.json` / mcpServers feature lives in
|
|
4
|
+
// third-party adapter extensions, not the runtime). So rather than ask every
|
|
5
|
+
// user to install an MCP adapter and hand-place a config file, the extension
|
|
6
|
+
// exposes the Librarian's tools to the model itself via `pi.registerTool`,
|
|
7
|
+
// proxying each call as a `tools/call` over HTTP. One `pi install`, zero config.
|
|
8
|
+
//
|
|
9
|
+
// Tool descriptions are the teaching surface (rethink spec §5.1): each one is
|
|
10
|
+
// copied VERBATIM from `@librarian/mcp-server/src/mcp/tools/<verb>.ts` so Pi
|
|
11
|
+
// users see exactly what every other harness sees. Schemas mirror the server's
|
|
12
|
+
// `inputSchema`s minus `agent_id` (the server resolves the caller from the
|
|
13
|
+
// bearer token) and the retired `conv_id`. Server fields typed
|
|
14
|
+
// `["string","null"]` are exposed here as optional strings — omission and null
|
|
15
|
+
// are equivalent for those filters, and a plain `string` type survives every
|
|
16
|
+
// provider's schema translation (Google's rejects type arrays). The
|
|
17
|
+
// schema-parity drift guard in `tests/schema-parity.test.ts` pins all of this
|
|
18
|
+
// against the server package mechanically.
|
|
19
|
+
//
|
|
20
|
+
// Fail-soft (AGENTS.md §2): a proxy NEVER throws into the harness. A network /
|
|
21
|
+
// server failure comes back as a short error string the model can read and
|
|
22
|
+
// route around — the user's turn is never broken by a Librarian outage.
|
|
23
|
+
|
|
24
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { Type, type TSchema } from "typebox";
|
|
26
|
+
import { McpClientError, type McpClient } from "./mcp-client.js";
|
|
27
|
+
|
|
28
|
+
// Drop undefined values so optional args aren't sent as JSON nulls.
|
|
29
|
+
function compact(obj: Record<string, unknown>): Record<string, unknown> {
|
|
30
|
+
const out: Record<string, unknown> = {};
|
|
31
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
32
|
+
if (value !== undefined) out[key] = value;
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface LibrarianToolSpec {
|
|
38
|
+
name: string;
|
|
39
|
+
label: string;
|
|
40
|
+
description: string;
|
|
41
|
+
parameters: TSchema;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The 7-verb agent surface (rethink spec §5.1), one spec per server tool.
|
|
46
|
+
* Exposed as a function (fresh schema objects per call) so tests can inspect
|
|
47
|
+
* the specs without registering anything.
|
|
48
|
+
*/
|
|
49
|
+
export function librarianToolSpecs(): LibrarianToolSpec[] {
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
name: "recall",
|
|
53
|
+
label: "Recall",
|
|
54
|
+
description:
|
|
55
|
+
"Search the owner's durable memories. Call this before answering anything " +
|
|
56
|
+
"that may have prior context — at task start, and whenever a stored fact, " +
|
|
57
|
+
"preference, or past decision could change your answer. Memories only: " +
|
|
58
|
+
"long-form reference docs are NOT here — search those with " +
|
|
59
|
+
"`search_references`. Query by free text; `tags` narrows to memories " +
|
|
60
|
+
"carrying any of the supplied tags. Pass `include_ids: true` to prefix " +
|
|
61
|
+
"each result with its memory id, so a memory that turns out to be wrong " +
|
|
62
|
+
"can be passed straight to `flag_memory`.",
|
|
63
|
+
parameters: Type.Object({
|
|
64
|
+
query: Type.Optional(Type.String()),
|
|
65
|
+
tags: Type.Optional(Type.Array(Type.String())),
|
|
66
|
+
project_key: Type.Optional(Type.String()),
|
|
67
|
+
include_ids: Type.Optional(Type.Boolean()),
|
|
68
|
+
limit: Type.Optional(Type.Number()),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
name: "remember",
|
|
73
|
+
label: "Remember",
|
|
74
|
+
description:
|
|
75
|
+
"Save a durable fact, preference, or decision the moment you learn it — " +
|
|
76
|
+
"not transient chatter. Fire-and-forget: submit and move on; the curator " +
|
|
77
|
+
"files it asynchronously (dedupe, merge, link — no need to check first). " +
|
|
78
|
+
"Give it a short `title` and a self-contained `body`; add `tags` and a " +
|
|
79
|
+
"`project_key` so it surfaces in the right context. Caller-supplied " +
|
|
80
|
+
"`is_global` / `requires_approval` are ignored.",
|
|
81
|
+
parameters: Type.Object({
|
|
82
|
+
title: Type.String(),
|
|
83
|
+
body: Type.String(),
|
|
84
|
+
project_key: Type.Optional(Type.String()),
|
|
85
|
+
applies_to: Type.Optional(Type.Array(Type.String())),
|
|
86
|
+
priority: Type.Optional(Type.String()),
|
|
87
|
+
confidence: Type.Optional(Type.String()),
|
|
88
|
+
tags: Type.Optional(Type.Array(Type.String())),
|
|
89
|
+
}),
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
name: "flag_memory",
|
|
93
|
+
label: "Flag memory",
|
|
94
|
+
description:
|
|
95
|
+
"A recalled memory is wrong, misleading, or outdated — flag it with a short " +
|
|
96
|
+
"free-text `reason` (required: say why). The flag routes the memory to human " +
|
|
97
|
+
"review and demotes it below unflagged matches in recall; it never edits, " +
|
|
98
|
+
"archives, or deletes, and there is no 'this was useful' counterpart. Use it " +
|
|
99
|
+
"sparingly, only when a memory actively led you astray.",
|
|
100
|
+
parameters: Type.Object({
|
|
101
|
+
memory_id: Type.String(),
|
|
102
|
+
reason: Type.String({ minLength: 1, maxLength: 2000 }),
|
|
103
|
+
}),
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
name: "store_handoff",
|
|
107
|
+
label: "Store handoff",
|
|
108
|
+
description:
|
|
109
|
+
"Hand work off: persist a handoff document so another agent — on any " +
|
|
110
|
+
"harness — can resume your work later. Call it when pausing mid-task or " +
|
|
111
|
+
"ending a session that isn't finished. The document must carry exactly " +
|
|
112
|
+
"these five sections — Start & intent, Journey, Current state, What's " +
|
|
113
|
+
"left, Open questions — or it is rejected. The other side picks it up " +
|
|
114
|
+
"with `list_handoffs` then `claim_handoff`.",
|
|
115
|
+
parameters: Type.Object({
|
|
116
|
+
title: Type.String({ minLength: 5, maxLength: 120 }),
|
|
117
|
+
document_md: Type.String({ minLength: 100, maxLength: 50000 }),
|
|
118
|
+
project_key: Type.Optional(Type.String()),
|
|
119
|
+
source_ref: Type.Optional(Type.String()),
|
|
120
|
+
cwd: Type.Optional(Type.String()),
|
|
121
|
+
harness: Type.Optional(Type.String()),
|
|
122
|
+
tags: Type.Optional(Type.Array(Type.String(), { maxItems: 10 })),
|
|
123
|
+
}),
|
|
124
|
+
},
|
|
125
|
+
{
|
|
126
|
+
name: "list_handoffs",
|
|
127
|
+
label: "List handoffs",
|
|
128
|
+
description:
|
|
129
|
+
"Take over work, step 1: list the unclaimed handoffs waiting to be picked " +
|
|
130
|
+
"up, then `claim_handoff` the one you want. Default scope is the caller's " +
|
|
131
|
+
"current project_key + cwd when both are supplied; drop either filter to " +
|
|
132
|
+
"broaden when nothing matches.",
|
|
133
|
+
parameters: Type.Object({
|
|
134
|
+
project_key: Type.Optional(Type.String()),
|
|
135
|
+
cwd: Type.Optional(Type.String()),
|
|
136
|
+
harness: Type.Optional(Type.String()),
|
|
137
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
138
|
+
}),
|
|
139
|
+
},
|
|
140
|
+
{
|
|
141
|
+
name: "claim_handoff",
|
|
142
|
+
label: "Claim handoff",
|
|
143
|
+
description:
|
|
144
|
+
"Take over work, step 2 (after `list_handoffs`): atomically claim a " +
|
|
145
|
+
"handoff and receive its document to resume from. Claims race — 404 if " +
|
|
146
|
+
"the id is unknown; 409 if another agent claimed first (the existing " +
|
|
147
|
+
"claim is included so you can say who has it and since when).",
|
|
148
|
+
parameters: Type.Object({
|
|
149
|
+
handoff_id: Type.String({ minLength: 1 }),
|
|
150
|
+
claiming_agent_id: Type.Optional(Type.String()),
|
|
151
|
+
claiming_harness: Type.Optional(Type.String()),
|
|
152
|
+
claiming_source_ref: Type.Optional(Type.String()),
|
|
153
|
+
claiming_cwd: Type.Optional(Type.String()),
|
|
154
|
+
}),
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
name: "search_references",
|
|
158
|
+
label: "Search references",
|
|
159
|
+
description:
|
|
160
|
+
"Search the long-form reference documents (background material the " +
|
|
161
|
+
"operator filed under references/ — specs, manuals, design notes). " +
|
|
162
|
+
"References are deliberately NOT auto-recalled and never appear in " +
|
|
163
|
+
"`recall` results, so search here when the task needs that depth. " +
|
|
164
|
+
"Returns each match's vault path + the query-relevant section.",
|
|
165
|
+
parameters: Type.Object({
|
|
166
|
+
query: Type.String({ description: "What to look up in the references." }),
|
|
167
|
+
limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 100 })),
|
|
168
|
+
}),
|
|
169
|
+
},
|
|
170
|
+
];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** The 7 tool names this extension registers (for tests / smoke). */
|
|
174
|
+
export const LIBRARIAN_TOOL_NAMES = [
|
|
175
|
+
"recall",
|
|
176
|
+
"remember",
|
|
177
|
+
"flag_memory",
|
|
178
|
+
"store_handoff",
|
|
179
|
+
"list_handoffs",
|
|
180
|
+
"claim_handoff",
|
|
181
|
+
"search_references",
|
|
182
|
+
] as const;
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Register the 7 Librarian verbs as native Pi tools. Each is a thin proxy:
|
|
186
|
+
* forward to the remote MCP `tools/call`, return the server's prose. Failures
|
|
187
|
+
* come back as an error STRING (never a throw): the model reads it, tells the
|
|
188
|
+
* user, and keeps working — a Librarian outage must not break the turn.
|
|
189
|
+
*/
|
|
190
|
+
export function registerLibrarianTools(pi: ExtensionAPI, client: McpClient): void {
|
|
191
|
+
for (const spec of librarianToolSpecs()) {
|
|
192
|
+
pi.registerTool({
|
|
193
|
+
name: spec.name,
|
|
194
|
+
label: spec.label,
|
|
195
|
+
description: spec.description,
|
|
196
|
+
parameters: spec.parameters,
|
|
197
|
+
execute: async (_toolCallId: string, params: Record<string, unknown>) => {
|
|
198
|
+
try {
|
|
199
|
+
const text = await client.callTool(spec.name, compact(params));
|
|
200
|
+
return { content: [{ type: "text" as const, text }], details: {} };
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// McpClientError messages are credential-free by construction; any
|
|
203
|
+
// other throw gets a generic line so nothing unexpected leaks.
|
|
204
|
+
const reason =
|
|
205
|
+
err instanceof McpClientError
|
|
206
|
+
? err.message
|
|
207
|
+
: `${spec.name} failed before reaching the Librarian`;
|
|
208
|
+
return {
|
|
209
|
+
content: [
|
|
210
|
+
{
|
|
211
|
+
type: "text" as const,
|
|
212
|
+
text:
|
|
213
|
+
`Librarian unavailable — ${reason}. ` +
|
|
214
|
+
"Continue the user's work without it; do not block on memory.",
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
details: {},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@the-librarian/pi-extension",
|
|
3
|
+
"version": "1.0.0-rc.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"description": "Pi coding-agent package for The Librarian: the 7 memory/handoff tools + primer injection, backed by a remote Librarian MCP server.",
|
|
9
|
+
"license": "Apache-2.0",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"author": {
|
|
12
|
+
"name": "Jim Sangwine",
|
|
13
|
+
"email": "jim@sangwine.net"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://github.com/JimJafar/the-librarian/tree/main/integrations/pi",
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/JimJafar/the-librarian",
|
|
19
|
+
"directory": "integrations/pi"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"pi-package",
|
|
23
|
+
"pi",
|
|
24
|
+
"memory",
|
|
25
|
+
"handoff",
|
|
26
|
+
"mcp",
|
|
27
|
+
"librarian",
|
|
28
|
+
"recall",
|
|
29
|
+
"cross-harness"
|
|
30
|
+
],
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"extensions/librarian/index.ts"
|
|
34
|
+
]
|
|
35
|
+
},
|
|
36
|
+
"files": [
|
|
37
|
+
"extensions",
|
|
38
|
+
"README.md"
|
|
39
|
+
],
|
|
40
|
+
"scripts": {
|
|
41
|
+
"test:vitest": "vitest run",
|
|
42
|
+
"test": "pnpm run test:vitest",
|
|
43
|
+
"typecheck": "tsc --noEmit"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
},
|
|
49
|
+
"devDependencies": {
|
|
50
|
+
"@earendil-works/pi-coding-agent": "^0.75.5",
|
|
51
|
+
"@librarian/mcp-server": "workspace:*",
|
|
52
|
+
"@types/node": "^22.10.5",
|
|
53
|
+
"typebox": "^1.1.38",
|
|
54
|
+
"typescript": "catalog:",
|
|
55
|
+
"vitest": "catalog:"
|
|
56
|
+
}
|
|
57
|
+
}
|