@the-librarian/pi-extension 1.37.0 → 1.38.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 CHANGED
@@ -22,6 +22,12 @@ Librarian primer into the system prompt. One install, zero config files.
22
22
  - **Primer injection** — the operator-editable `vault/primer.md` (≤2KB) is
23
23
  fetched once per process from `GET <server>/primer.md` and appended to the
24
24
  system prompt via `before_agent_start`.
25
+ - **The journal at session start** — the recent window of the
26
+ [journal](https://librarian-docs.codeministry.net/guides/journal/)
27
+ (`GET <server>/journal.md?max_chars=9500`) follows the primer in the system
28
+ prompt, fetched once per Pi session and then reused so the prompt stays the same
29
+ every turn. Captured turns are labelled with their project (the git repository
30
+ name, else the folder name) once `/healthz` shows the server accepts it.
25
31
  - **Automatic per-turn capture** — after every completed turn Pi fires
26
32
  `agent_end` with the turn's messages in-payload, and the adapter ships the
27
33
  non-private prose as a delta to the server's `POST /transcript` door for the
@@ -84,6 +90,7 @@ Set two environment variables in the shell that launches `pi`:
84
90
  | `LIBRARIAN_AGENT_TOKEN` | A per-agent bearer token issued by your Librarian |
85
91
  | `LIBRARIAN_TIMEOUT_MS` | *(optional)* per-call timeout for the tool proxies, default `15000` |
86
92
  | `LIBRARIAN_AUTO_SAVE` | *(optional)* set to `false` to disable automatic capture on this machine (default-on). Anything else (unset, `true`, …) leaves capture on. |
93
+ | `LIBRARIAN_JOURNAL` | *(optional)* set to `off` (or `false` / `0` / `no`) to stop loading the journal at session start on this machine. The `journal` tool stays available. |
87
94
  | `LIBRARIAN_PI_DATA` | *(optional)* where the per-session capture state lives, default `$HOME/.librarian/pi-extension-data` |
88
95
 
89
96
  Without both required variables the extension stays **dormant**: no tools, no
@@ -69,6 +69,8 @@ export interface CaptureInput {
69
69
  env: Record<string, string | undefined>;
70
70
  /** Explicit-end hint (the session is ending) — the settle-sweep accelerator. */
71
71
  ended?: boolean;
72
+ /** Spec 078: the journal's project label, or null when the server can't take it. */
73
+ project?: string | null;
72
74
  }
73
75
 
74
76
  /** The orchestration outcome (mostly for tests / diagnostics). */
@@ -156,7 +158,13 @@ export async function runCapture(input: CaptureInput, deps: CaptureDeps): Promis
156
158
  }
157
159
 
158
160
  const seq = prior.seq + 1;
159
- const payload = buildPayload({ convId, seq, turns: kept, ended });
161
+ const payload = buildPayload({
162
+ convId,
163
+ seq,
164
+ turns: kept,
165
+ ended,
166
+ project: input.project ?? null,
167
+ });
160
168
 
161
169
  // SHIP. A non-2xx (`ok:false`) or a thrown network error → DO NOT advance seq:
162
170
  // the delta re-ships next turn (idempotent; server/curator dedup). A 2xx →
@@ -195,7 +203,7 @@ function errMsg(error: unknown): string {
195
203
  * "conv_id = getSessionId()" maps to `ctx.sessionManager.getSessionId()`. Any
196
204
  * throw / non-string → "" so the orchestrator no-ops (NEVER cwd/$USER).
197
205
  */
198
- function readSessionId(ctx: unknown): string {
206
+ export function readSessionId(ctx: unknown): string {
199
207
  try {
200
208
  const sm = (ctx as { sessionManager?: { getSessionId?: () => unknown } })?.sessionManager;
201
209
  const id = sm?.getSessionId?.();
@@ -205,6 +213,16 @@ function readSessionId(ctx: unknown): string {
205
213
  }
206
214
  }
207
215
 
216
+ /** The session's working directory from the handler ctx (`ctx.cwd`), fail-soft. */
217
+ export function readCwd(ctx: unknown): string | undefined {
218
+ try {
219
+ const cwd = (ctx as { cwd?: unknown })?.cwd;
220
+ return typeof cwd === "string" ? cwd : undefined;
221
+ } catch {
222
+ return undefined;
223
+ }
224
+ }
225
+
208
226
  /**
209
227
  * Wire auto-capture into Pi's `agent_end` event — fired ONCE per completed user
210
228
  * prompt with the turn's messages in-payload (the §11.2 per-turn-end hook). The
@@ -213,8 +231,13 @@ function readSessionId(ctx: unknown): string {
213
231
  * getSessionId(); the delta re-ships next turn on any non-2xx.
214
232
  *
215
233
  * @param dataDir - the resolved plugin data dir (per-session state home).
234
+ * @param projectFor - the journal's project label for a cwd, or null (spec 078).
216
235
  */
217
- export function registerCaptureHook(pi: ExtensionAPI, dataDir: string): void {
236
+ export function registerCaptureHook(
237
+ pi: ExtensionAPI,
238
+ dataDir: string,
239
+ projectFor: (cwd: string | undefined) => Promise<string | null> = async () => null,
240
+ ): void {
218
241
  // Opportunistic, fail-soft pruning of stale sibling state (never clear-all).
219
242
  try {
220
243
  pruneOldState(dataDir, PRUNE_MAX_AGE_MS);
@@ -228,7 +251,8 @@ export function registerCaptureHook(pi: ExtensionAPI, dataDir: string): void {
228
251
  const messages = Array.isArray((event as { messages?: unknown }).messages)
229
252
  ? (event as { messages: unknown[] }).messages
230
253
  : [];
231
- await runCapture({ convId, messages, env: process.env }, { dataDir });
254
+ const project = await projectFor(readCwd(ctx));
255
+ await runCapture({ convId, messages, env: process.env, project }, { dataDir });
232
256
  } catch {
233
257
  // Defensive net — runCapture already swallows its own errors, but a
234
258
  // top-level throw must never break the user's turn or leak a stack trace.
@@ -9,6 +9,8 @@
9
9
  // proxies over the Librarian's stateless /mcp endpoint — see `tools.ts`;
10
10
  // - the ≤2KB primer, fetched from `GET /primer.md` once per process and
11
11
  // appended to the system prompt via `before_agent_start` — see `primer.ts`;
12
+ // - the recent window of the conversation journal, fetched once per session and
13
+ // appended after the primer (spec 078) — see `journal.ts`;
12
14
  // - four user-facing slash commands (`/handoff`, `/takeover`, `/learn`,
13
15
  // `/toggle-private`) as thin prompt templates — see `commands.ts`.
14
16
  //
@@ -20,6 +22,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
22
  import { registerCaptureHook, resolveDataDir } from "./capture.js";
21
23
  import { registerCommands } from "./commands.js";
22
24
  import { readConfig } from "./config.js";
25
+ import { createJournalSource, registerJournalHook } from "./journal.js";
23
26
  import { createMcpClient } from "./mcp-client.js";
24
27
  import { createPrimerSource, registerPrimerHook } from "./primer.js";
25
28
  import { registerLibrarianTools } from "./tools.js";
@@ -60,6 +63,13 @@ export default function librarian(pi: ExtensionAPI): void {
60
63
  // Primer → system prompt, once per turn, cached per process, fail-soft.
61
64
  registerPrimerHook(pi, createPrimerSource({ endpoint: config.endpoint }));
62
65
 
66
+ // Spec 078: the recent journal window → system prompt, after the primer; fetched
67
+ // once per Pi session, fail-soft, `LIBRARIAN_JOURNAL=off` skips it. The same source
68
+ // labels captured conversations with their project once /healthz shows the server
69
+ // accepts the field.
70
+ const journal = createJournalSource({ endpoint: config.endpoint, token: config.token });
71
+ registerJournalHook(pi, journal);
72
+
63
73
  // Auto-capture (Phase 2B, spec 2026-06-16-harness-auto-capture): wire the
64
74
  // `agent_end` hook so each completed turn's NON-PRIVATE prose ships as a
65
75
  // per-turn delta to POST /transcript. Default-on; the LIBRARIAN_AUTO_SAVE=false
@@ -68,7 +78,7 @@ export default function librarian(pi: ExtensionAPI): void {
68
78
  // it stays inert when the server's curator.intake.enabled gate is off. The hook
69
79
  // is registered only when the extension is configured (the dormant branch above
70
80
  // returns before reaching here, so an unconfigured install captures nothing).
71
- registerCaptureHook(pi, resolveDataDir(process.env));
81
+ registerCaptureHook(pi, resolveDataDir(process.env), (cwd) => journal.projectFor(cwd));
72
82
 
73
83
  // The four user-facing slash commands (optional sugar, D9).
74
84
  registerCommands(pi);
@@ -0,0 +1,212 @@
1
+ // Journal injection + project label (spec 078) — the Pi port of
2
+ // integrations/claude/scripts/lib/journal.mjs.
3
+ //
4
+ // - The recent window of the conversation journal (`GET /journal.md`) is appended
5
+ // to the system prompt via `before_agent_start`, after the primer. It is fetched
6
+ // ONCE per Pi session and then served from memory: the prompt stays
7
+ // byte-identical every turn (prompt caching stays warm) and the server is asked
8
+ // once. Keyed by session id, not process — one Pi process can outlive several
9
+ // sessions, and each new session gets a fresh window.
10
+ // - The project label for /transcript (git repo name, else folder name), offered
11
+ // only after a `/healthz` probe shows the server accepts it — an older server
12
+ // rejects the unknown key and capture would stall. Probed once per process.
13
+ //
14
+ // Same rules as the Claude adapter: `max_chars=9500` (every harness gets the
15
+ // Claude-sized window), `LIBRARIAN_JOURNAL=off` skips the fetch, the token rides the
16
+ // Authorization header only, redirects are refused, and every failure resolves to
17
+ // "" / null — never a throw into Pi's turn.
18
+
19
+ import fs from "node:fs";
20
+ import path from "node:path";
21
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
22
+ import { readSessionId } from "./capture.js";
23
+
24
+ /** The window size every harness asks for — Claude Code's, under its 10,000-char cap. */
25
+ export const JOURNAL_MAX_CHARS = 9_500;
26
+
27
+ const TRIMMED_NOTE =
28
+ "Older entries were left out to fit — call the `journal` tool to read further back.";
29
+
30
+ const DEFAULT_TIMEOUT_MS = 5_000;
31
+ const PROBE_TIMEOUT_MS = 2_000;
32
+ /** Sessions remembered at once; the oldest is forgotten first. */
33
+ const MAX_SESSIONS = 200;
34
+
35
+ type FetchLike = typeof fetch;
36
+
37
+ /** `<origin>/journal.md?max_chars=N`, or null when the endpoint is unusable. */
38
+ export function journalUrl(endpoint: string, maxChars = JOURNAL_MAX_CHARS): string | null {
39
+ try {
40
+ const parsed = new URL(endpoint);
41
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
42
+ return `${parsed.origin}/journal.md?max_chars=${maxChars}`;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ /** Is the journal injection switched off locally (`LIBRARIAN_JOURNAL=off|false|0|no`)? */
49
+ export function isJournalInjectionOff(env: Record<string, string | undefined>): boolean {
50
+ const raw = env.LIBRARIAN_JOURNAL ?? "";
51
+ return ["off", "false", "0", "no"].includes(raw.trim().toLowerCase());
52
+ }
53
+
54
+ /**
55
+ * Keep the window under `limit` characters by dropping the OLDEST `## ` blocks (the
56
+ * window is oldest-first). A server that honours `max_chars` never needs this.
57
+ */
58
+ export function fitJournal(text: string, limit = JOURNAL_MAX_CHARS): string {
59
+ if (text.length <= limit) return text;
60
+ const parts = text.split(/\n(?=## )/);
61
+ const header = parts.shift() ?? "";
62
+ const blocks = parts.filter((b) => !b.startsWith(TRIMMED_NOTE)).map((b) => b.trimEnd());
63
+ const render = (kept: string[]): string =>
64
+ `${header.trimEnd()}\n\n${kept.join("\n\n")}\n\n${TRIMMED_NOTE}\n`;
65
+ let start = 0;
66
+ while (start < blocks.length - 1 && render(blocks.slice(start)).length > limit) start += 1;
67
+ const out = render(blocks.slice(start));
68
+ return out.length <= limit ? out : out.slice(0, limit);
69
+ }
70
+
71
+ /**
72
+ * The project label: the name of the git repository containing `cwd` (the nearest
73
+ * ancestor holding a `.git` entry), else the name of `cwd`. Null when unusable.
74
+ */
75
+ export function deriveProject(cwd: string | undefined): string | null {
76
+ if (!cwd || !path.isAbsolute(cwd)) return null;
77
+ try {
78
+ let dir = path.resolve(cwd);
79
+ let name: string | null = null;
80
+ for (;;) {
81
+ if (fs.existsSync(path.join(dir, ".git"))) {
82
+ name = path.basename(dir);
83
+ break;
84
+ }
85
+ const parent = path.dirname(dir);
86
+ if (parent === dir) break;
87
+ dir = parent;
88
+ }
89
+ const label = (name ?? path.basename(path.resolve(cwd))).trim().slice(0, 200);
90
+ return label || null;
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ export interface JournalSourceConfig {
97
+ endpoint: string;
98
+ token: string;
99
+ env?: Record<string, string | undefined>;
100
+ fetchImpl?: FetchLike;
101
+ timeoutMs?: number;
102
+ }
103
+
104
+ export interface JournalSource {
105
+ /** The window for this session ("" when there is none), fetched once per session. */
106
+ windowFor(sessionId: string): Promise<string>;
107
+ /** The project label for `cwd`, or null when the server can't take it. */
108
+ projectFor(cwd: string | undefined): Promise<string | null>;
109
+ }
110
+
111
+ export function createJournalSource(config: JournalSourceConfig): JournalSource {
112
+ const env = config.env ?? process.env;
113
+ const fetchImpl = config.fetchImpl ?? fetch;
114
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
115
+ const windows = new Map<string, Promise<string>>();
116
+ let supported: Promise<boolean> | null = null;
117
+
118
+ async function fetchWindow(): Promise<string> {
119
+ if (isJournalInjectionOff(env)) return "";
120
+ const url = journalUrl(config.endpoint);
121
+ if (!url) return "";
122
+ const controller = new AbortController();
123
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
124
+ try {
125
+ const response = await fetchImpl(url, {
126
+ method: "GET",
127
+ headers: { authorization: `Bearer ${config.token}` },
128
+ redirect: "error",
129
+ signal: controller.signal,
130
+ });
131
+ if (!response.ok) return "";
132
+ if (!(response.headers.get("content-type") ?? "").includes("text/markdown")) return "";
133
+ const text = await response.text();
134
+ return text.trim() ? fitJournal(text) : "";
135
+ } catch {
136
+ return "";
137
+ } finally {
138
+ clearTimeout(timer);
139
+ }
140
+ }
141
+
142
+ async function probe(): Promise<boolean> {
143
+ let url: string;
144
+ try {
145
+ const parsed = new URL(config.endpoint);
146
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return false;
147
+ url = `${parsed.origin}/healthz`;
148
+ } catch {
149
+ return false;
150
+ }
151
+ const controller = new AbortController();
152
+ const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
153
+ try {
154
+ // /healthz is unauthenticated, so no token is sent.
155
+ const response = await fetchImpl(url, { redirect: "error", signal: controller.signal });
156
+ if (!response.ok) return false;
157
+ const json: unknown = await response.json();
158
+ return (
159
+ typeof json === "object" &&
160
+ json !== null &&
161
+ !Array.isArray(json) &&
162
+ Object.prototype.hasOwnProperty.call(json, "journal")
163
+ );
164
+ } catch {
165
+ return false;
166
+ } finally {
167
+ clearTimeout(timer);
168
+ }
169
+ }
170
+
171
+ return {
172
+ async windowFor(sessionId) {
173
+ if (!sessionId) return "";
174
+ let pending = windows.get(sessionId);
175
+ if (!pending) {
176
+ pending = fetchWindow().catch(() => "");
177
+ windows.set(sessionId, pending);
178
+ if (windows.size > MAX_SESSIONS) {
179
+ const oldest = windows.keys().next().value;
180
+ if (oldest !== undefined) windows.delete(oldest);
181
+ }
182
+ }
183
+ return pending;
184
+ },
185
+ async projectFor(cwd) {
186
+ try {
187
+ supported ??= probe().catch(() => false);
188
+ return (await supported) ? deriveProject(cwd) : null;
189
+ } catch {
190
+ return null;
191
+ }
192
+ },
193
+ };
194
+ }
195
+
196
+ /**
197
+ * Append the session's journal window to Pi's system prompt. Registered AFTER the
198
+ * primer hook, so the SDK's chained `systemPrompt` returns put it after the primer.
199
+ * Empty window → no return value → Pi leaves the prompt untouched.
200
+ */
201
+ export function registerJournalHook(pi: ExtensionAPI, source: JournalSource): void {
202
+ pi.on("before_agent_start", async (event, ctx) => {
203
+ try {
204
+ const window = await source.windowFor(readSessionId(ctx));
205
+ if (!window.trim()) return;
206
+ return { systemPrompt: `${event.systemPrompt}\n\n${window}` };
207
+ } catch {
208
+ // Defensive net — a top-level throw must never break the user's turn.
209
+ return;
210
+ }
211
+ });
212
+ }
@@ -53,6 +53,8 @@ export interface CapturePayload {
53
53
  seq: number;
54
54
  turns: CaptureTurn[];
55
55
  ended?: true;
56
+ /** Spec 078: the journal's project label — sent only to a server that accepts it. */
57
+ project?: string;
56
58
  }
57
59
 
58
60
  function isRecord(value: unknown): value is Record<string, unknown> {
@@ -184,21 +186,25 @@ export function filterPrivateSpans(
184
186
 
185
187
  /**
186
188
  * Build the uniform, harness-agnostic delta the server contract expects:
187
- * `{ conv_id, harness:"pi", seq, turns[], ended? }`. `ended` is omitted unless
188
- * true (the server treats its mere presence as the explicit-end accelerator).
189
+ * `{ conv_id, harness:"pi", seq, turns[], ended?, project? }`. `ended` is omitted
190
+ * unless true (the server treats its mere presence as the explicit-end accelerator).
191
+ * `project` is set only by a caller that knows the server accepts it (spec 078).
189
192
  */
190
193
  export function buildPayload({
191
194
  convId,
192
195
  seq,
193
196
  turns,
194
197
  ended = false,
198
+ project = null,
195
199
  }: {
196
200
  convId: string;
197
201
  seq: number;
198
202
  turns: CaptureTurn[];
199
203
  ended?: boolean;
204
+ project?: string | null;
200
205
  }): CapturePayload {
201
206
  const payload: CapturePayload = { conv_id: convId, harness: HARNESS, seq, turns };
202
207
  if (ended) payload.ended = true;
208
+ if (project) payload.project = project;
203
209
  return payload;
204
210
  }
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@the-librarian/pi-extension",
3
- "version": "1.37.0",
3
+ "version": "1.38.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
7
7
  },
8
- "description": "Pi coding-agent package for The Librarian: the 8 memory/handoff/journal tools + primer injection, backed by a remote Librarian MCP server.",
8
+ "description": "Pi coding-agent package for The Librarian: the 8 memory/handoff/journal tools + primer and journal injection, backed by a remote Librarian MCP server.",
9
9
  "license": "Apache-2.0",
10
10
  "type": "module",
11
11
  "author": {