@the-librarian/pi-extension 1.0.0-rc.2 → 1.4.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/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
package/README.md CHANGED
@@ -19,6 +19,17 @@ Librarian primer into the system prompt. One install, zero config files.
19
19
  - **Primer injection** — the operator-editable `vault/primer.md` (≤2KB) is
20
20
  fetched once per process from `GET <server>/primer.md` and appended to the
21
21
  system prompt via `before_agent_start`.
22
+ - **Automatic per-turn capture** — after every completed turn Pi fires
23
+ `agent_end` with the turn's messages in-payload, and the adapter ships the
24
+ non-private prose as a delta to the server's `POST /transcript` door for the
25
+ curator to mine for durable memories. Default-on; opt out with
26
+ `LIBRARIAN_AUTO_SAVE=false`. Forward-only private mode is honoured (a
27
+ `[librarian:private=on]` turn and every turn until `[librarian:private=off]`
28
+ is never shipped, and never retroactively sent), the conversation is keyed by
29
+ Pi's own session id (`getSessionId()` — concurrent sessions never collide,
30
+ never `$USER`/cwd), the seq advances only on a server 2xx ack, the bearer
31
+ token travels in the header only (`redirect:"error"`), and it is fully
32
+ fail-soft — a Librarian outage never blocks a turn or leaks a stack trace.
22
33
  - **Four slash commands** (optional sugar): `/handoff`, `/takeover`, `/learn`,
23
34
  `/toggle-private` — thin prompt templates that drive the corresponding tool
24
35
  flows. See [`docs/slash-commands.md`](../../docs/slash-commands.md).
@@ -28,6 +39,20 @@ Librarian primer into the system prompt. One install, zero config files.
28
39
 
29
40
  ## Install
30
41
 
42
+ ### The easy way — the Librarian CLI (recommended)
43
+
44
+ ```sh
45
+ npx @the-librarian/cli install # choose Pi; paste your MCP URL + token
46
+ npx @the-librarian/cli update # later: pull the latest extension
47
+ ```
48
+
49
+ `install` runs Pi's native `pi install` for you and persists your server URL +
50
+ token. Run it with `npx`, or `npm i -g @the-librarian/cli` once and call
51
+ `librarian install` / `librarian update` directly. Prefer to wire it by hand?
52
+ See below.
53
+
54
+ ### Manual setup
55
+
31
56
  From npm (once published — see "Publishing" below):
32
57
 
33
58
  ```sh
@@ -50,9 +75,11 @@ Set two environment variables in the shell that launches `pi`:
50
75
 
51
76
  | Variable | Meaning |
52
77
  | --- | --- |
53
- | `LIBRARIAN_MCP_URL` | The server's MCP endpoint, e.g. `https://your-librarian/mcp` |
78
+ | `LIBRARIAN_MCP_URL` | The server's MCP endpoint, e.g. `https://your-librarian/mcp` (auto-capture posts to `/transcript` on the same origin) |
54
79
  | `LIBRARIAN_AGENT_TOKEN` | A per-agent bearer token issued by your Librarian |
55
- | `LIBRARIAN_TIMEOUT_MS` | *(optional)* per-call timeout, default `15000` |
80
+ | `LIBRARIAN_TIMEOUT_MS` | *(optional)* per-call timeout for the tool proxies, default `15000` |
81
+ | `LIBRARIAN_AUTO_SAVE` | *(optional)* set to `false` to disable automatic capture on this machine (default-on). Anything else (unset, `true`, …) leaves capture on. |
82
+ | `LIBRARIAN_PI_DATA` | *(optional)* where the per-session capture state lives, default `$HOME/.librarian/pi-extension-data` |
56
83
 
57
84
  Without both required variables the extension stays **dormant**: no tools, no
58
85
  network calls — only the four slash commands register, and they explain what's
@@ -0,0 +1,125 @@
1
+ // Per-session auto-capture state — the adapter's only durable capture state.
2
+ // Phase 2B / T-Pi. Records, PER SESSION (keyed by Pi's getSessionId()):
3
+ // - `seq` : the adapter's monotonic delta counter for the /transcript
4
+ // contract payload.
5
+ // - `private` : whether the previous delta ended inside an open `[private=on]`
6
+ // span (carry-forward, so an unterminated span stays private into
7
+ // the next delta — even across a process restart within a session).
8
+ //
9
+ // There is NO byte offset (unlike the Claude cursor): the `agent_end` hook hands
10
+ // the adapter the completed turn IN-PAYLOAD, so there is nothing to re-read. The
11
+ // state is NON-CRITICAL: if lost, the next delta restarts at seq 0 / public and
12
+ // idempotency rests on the server/curator's fact-level dedup + advance-on-ack. So
13
+ // every read is fail-soft (a missing/corrupt file reads as a fresh start).
14
+ //
15
+ // Concurrency (spec §4.11 / SC5): keyed by the SESSION ID ONLY (never `$USER`/
16
+ // cwd), so N concurrent same-machine sessions get N distinct files. Pruning is
17
+ // AGE-BASED — never "clear all" (that would clobber a live sibling session).
18
+ //
19
+ // RUNTIME-IMPORT NOTE: only `node:` builtins + relative paths here, so the module
20
+ // loads in a git/local Pi install with no node_modules (tests/runtime-imports).
21
+
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ /** A session's durable capture state. */
26
+ export interface CaptureState {
27
+ seq: number;
28
+ private: boolean;
29
+ }
30
+
31
+ /** The capture-state directory under the plugin data dir. */
32
+ export function captureDir(dataDir: string): string {
33
+ return path.join(dataDir, "capture");
34
+ }
35
+
36
+ /**
37
+ * Reduce a session id to a single safe path segment so a hostile/odd id can't
38
+ * traverse out of `capture/`. Mirrors the server's `sanitizeConvId`, the Claude
39
+ * cursor's `safeSegment`, and the Hermes `_safe_segment`.
40
+ */
41
+ function safeSegment(sessionId: string): string {
42
+ const cleaned = String(sessionId ?? "")
43
+ .replace(/[^a-zA-Z0-9._-]/g, "_")
44
+ .replace(/\.\.+/g, "_") // collapse any `..` run — never a parent-dir reference
45
+ .replace(/^\.+/, "_") // never a leading dot (no `.` / dotfiles)
46
+ .slice(0, 200);
47
+ return cleaned || "unknown";
48
+ }
49
+
50
+ /** Absolute path of a session's capture-state file. */
51
+ export function captureStatePath(dataDir: string, sessionId: string): string {
52
+ return path.join(captureDir(dataDir), `${safeSegment(sessionId)}.json`);
53
+ }
54
+
55
+ /**
56
+ * Read a session's capture state. Fail-soft: a missing or unparseable file reads
57
+ * as a fresh start (`{seq:0, private:false}`) — re-shipping is safe (the
58
+ * server/curator dedup), so this never throws.
59
+ */
60
+ export function readCaptureState(dataDir: string, sessionId: string): CaptureState {
61
+ const fresh: CaptureState = { seq: 0, private: false };
62
+ try {
63
+ const raw = fs.readFileSync(captureStatePath(dataDir, sessionId), "utf8");
64
+ const parsed: unknown = JSON.parse(raw);
65
+ if (typeof parsed !== "object" || parsed === null) return fresh;
66
+ const obj = parsed as { seq?: unknown; private?: unknown };
67
+ return {
68
+ seq: Number.isInteger(obj.seq) && (obj.seq as number) >= 0 ? (obj.seq as number) : 0,
69
+ private: obj.private === true,
70
+ };
71
+ } catch {
72
+ return fresh;
73
+ }
74
+ }
75
+
76
+ /**
77
+ * Persist a session's capture state. Written atomically (tmp + rename) so a crash
78
+ * mid-write can never leave a torn file a concurrent run misreads. Fail-soft: a
79
+ * write failure is swallowed (the caller already shipped; a lost state file only
80
+ * costs an idempotent re-ship next delta).
81
+ */
82
+ export function writeCaptureState(dataDir: string, sessionId: string, state: CaptureState): void {
83
+ try {
84
+ const dir = captureDir(dataDir);
85
+ fs.mkdirSync(dir, { recursive: true });
86
+ const target = captureStatePath(dataDir, sessionId);
87
+ const tmp = `${target}.tmp-${process.pid}`;
88
+ fs.writeFileSync(
89
+ tmp,
90
+ JSON.stringify({ seq: state.seq, private: Boolean(state.private) }),
91
+ "utf8",
92
+ );
93
+ fs.renameSync(tmp, target);
94
+ } catch {
95
+ // Non-critical state; a lost file self-heals via re-ship + dedup.
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Age-based pruning (SC5-adjacent housekeeping): drop state files whose mtime is
101
+ * older than `maxAgeMs`. NEVER "clear all" — a fresh sibling file (a concurrently
102
+ * running session) must survive. Fail-soft: a missing dir or an un-stat-able file
103
+ * is skipped, never thrown.
104
+ */
105
+ export function pruneOldState(dataDir: string, maxAgeMs = 7 * 24 * 60 * 60 * 1000): void {
106
+ const dir = captureDir(dataDir);
107
+ let names: string[];
108
+ try {
109
+ names = fs.readdirSync(dir);
110
+ } catch {
111
+ return; // no capture dir yet — nothing to prune
112
+ }
113
+ const cutoff = Date.now() - maxAgeMs;
114
+ for (const name of names) {
115
+ const file = path.join(dir, name);
116
+ try {
117
+ const stat = fs.statSync(file);
118
+ if (stat.isFile() && stat.mtimeMs < cutoff) {
119
+ fs.rmSync(file, { force: true });
120
+ }
121
+ } catch {
122
+ // Race with a concurrent session writing/rotating — skip it.
123
+ }
124
+ }
125
+ }
@@ -0,0 +1,237 @@
1
+ // Pi auto-capture adapter — the orchestrator + the `agent_end` hook wiring.
2
+ // Spec 2026-06-16-harness-auto-capture, Phase 2B (Pi / T-Pi).
3
+ //
4
+ // Wires the pure transforms (transcript-capture.ts), the durable per-session
5
+ // state (capture-state.ts), and the network ship (transcript-post.ts) into the
6
+ // one decision the per-turn-end handler makes: ship this completed turn's
7
+ // NON-PRIVATE prose to POST /transcript and advance seq ONLY on a server 2xx ack.
8
+ // Everything is fail-soft (AGENTS.md: a Librarian/network/parse failure must
9
+ // never throw out of a harness hook, never block the user's turn, never leak a
10
+ // stack trace) — every path resolves; an error logs to the injected logger and
11
+ // seq stays put so the delta re-ships next turn (idempotent; server/curator dedup).
12
+ //
13
+ // ─── The Pi event/session API: CONFIRMED vs ASSUMED ───────────────────────────
14
+ // Confirmed against the pinned @earendil-works/pi-coding-agent@0.75.5 SDK types
15
+ // (node_modules/.../dist/core/extensions/types.d.ts) and the bundled
16
+ // docs/extensions.md, plus the build-pi-extension skill:
17
+ // - CONFIRMED `pi.on("agent_end", handler)` exists; AgentEndEvent =
18
+ // `{ type:"agent_end", messages: AgentMessage[] }`, fired ONCE per user
19
+ // prompt with the completed turn's messages IN-PAYLOAD. This is the §11.2
20
+ // "per-turn-end event handing the completed turn in-payload" — so the delta
21
+ // is O(1) (no cursor, no transcript re-read).
22
+ // - CONFIRMED `ctx.sessionManager.getSessionId(): string` is the stable session
23
+ // id. The spec's "conv_id = Pi's getSessionId()" maps to THIS. conv_id is the
24
+ // session id ONLY — NEVER $USER/cwd (concurrent same-machine sessions must
25
+ // not collide, spec §4.11 / SC5). No stable id → fail soft to a no-op.
26
+ // - ASSUMED (OPTIMISTIC — there is no `pi` CLI on this machine, so SC1 e2e is
27
+ // DEFERRED): that `agent_end` (the whole-prompt boundary) is the right hook
28
+ // vs `turn_end` (which fires per LLM-response loop within one prompt and
29
+ // carries a single `message`, not the full exchange). We pick `agent_end` per
30
+ // the §11.2 expectation and make a wrong shape fail SAFE: messagesToTurns
31
+ // skips any unexpected message/block shape → a wrong event or payload is a
32
+ // clean no-op, never a throw or garbage capture. The hook also guards its
33
+ // whole body so a thrown getSessionId()/handler error can never escape.
34
+ //
35
+ // The network `post` + the dataDir are INJECTED so the orchestration is
36
+ // unit-testable without a running Pi or a socket; registerCaptureHook passes the
37
+ // real postDelta + the resolved data dir.
38
+
39
+ import path from "node:path";
40
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
41
+ import { pruneOldState, readCaptureState, writeCaptureState } from "./capture-state.js";
42
+ import { buildPayload, filterPrivateSpans, messagesToTurns } from "./transcript-capture.js";
43
+ import { type CaptureAck, deriveTranscriptUrl, postDelta } from "./transcript-post.js";
44
+
45
+ // Age-based pruning of stale per-session capture state (~7 days), opportunistic
46
+ // and fail-soft — NEVER clear-all (a fresh sibling is a live concurrent session).
47
+ const PRUNE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
48
+
49
+ /** A fail-soft logger; capture never logs turn text or the token. */
50
+ export type CaptureLogger = (message: string) => void;
51
+
52
+ /** The injected dependencies the orchestration needs (testable). */
53
+ export interface CaptureDeps {
54
+ /** Plugin data dir (per-session state home). */
55
+ dataDir: string;
56
+ /** Injectable network ship (tests pass a fake; the hook passes postDelta). */
57
+ post?: typeof postDelta;
58
+ /** Fail-soft logger (defaults to a no-op). */
59
+ log?: CaptureLogger;
60
+ }
61
+
62
+ /** The fire context the handler hands the orchestrator. */
63
+ export interface CaptureInput {
64
+ /** Pi's getSessionId() — the conv_id. Empty/missing → a clean no-op. */
65
+ convId: string;
66
+ /** The completed turn's AgentMessage[] (agent_end's in-payload turn). */
67
+ messages: unknown[];
68
+ /** Process env: the URL + token + the LIBRARIAN_AUTO_SAVE kill-switch. */
69
+ env: Record<string, string | undefined>;
70
+ /** Explicit-end hint (the session is ending) — the settle-sweep accelerator. */
71
+ ended?: boolean;
72
+ }
73
+
74
+ /** The orchestration outcome (mostly for tests / diagnostics). */
75
+ export interface CaptureResult {
76
+ posted: boolean;
77
+ skipped?: string;
78
+ ack?: CaptureAck;
79
+ }
80
+
81
+ /**
82
+ * Is the local auto-save kill-switch OFF? True ONLY for the exact string "false"
83
+ * (case-insensitive); anything else (unset, "", "true", …) is default-ON
84
+ * (slash-commands.md "two gates"). Pure + total.
85
+ */
86
+ export function isAutoSaveOff(env: Record<string, string | undefined>): boolean {
87
+ const v = env?.LIBRARIAN_AUTO_SAVE;
88
+ return typeof v === "string" && v.trim().toLowerCase() === "false";
89
+ }
90
+
91
+ /**
92
+ * Resolve the plugin data dir (per-session state home). Default:
93
+ * `${LIBRARIAN_PI_DATA:-$HOME/.librarian/pi-extension-data}` — mirrors the
94
+ * Claude adapter's `CLAUDE_PLUGIN_DATA → $HOME/.librarian/claude-plugin-data`.
95
+ */
96
+ export function resolveDataDir(env: Record<string, string | undefined>): string {
97
+ const explicit = env.LIBRARIAN_PI_DATA;
98
+ if (explicit && explicit.trim()) return explicit;
99
+ const home = env.HOME || env.USERPROFILE || ".";
100
+ return path.join(home, ".librarian", "pi-extension-data");
101
+ }
102
+
103
+ /**
104
+ * Run one capture pass for a completed turn (`agent_end`). Ships the new
105
+ * NON-PRIVATE prose and advances seq ONLY on a 2xx ack. Never throws — every
106
+ * failure path resolves to a clean no-op and (best-effort) logs.
107
+ */
108
+ export async function runCapture(input: CaptureInput, deps: CaptureDeps): Promise<CaptureResult> {
109
+ const post = deps.post ?? postDelta;
110
+ const log: CaptureLogger = typeof deps.log === "function" ? deps.log : () => {};
111
+ const dataDir = deps.dataDir;
112
+ const env = input.env ?? {};
113
+
114
+ // KILL-SWITCH (slash-commands.md, SC4): LIBRARIAN_AUTO_SAVE=false ships nothing
115
+ // and advances nothing on this machine. Hard gate, checked before any work.
116
+ if (isAutoSaveOff(env)) {
117
+ return { posted: false, skipped: "auto-save-off" };
118
+ }
119
+
120
+ try {
121
+ // conv_id keys the per-session state + the server buffer; a missing id is a
122
+ // clean no-op (NEVER cwd/$USER, so concurrent sessions never collide).
123
+ const convId = input.convId;
124
+ if (!convId) {
125
+ log("skip: no stable conv_id (getSessionId() empty)");
126
+ return { posted: false, skipped: "no-conv-id" };
127
+ }
128
+
129
+ // CONFIG (fail-soft): without a URL + token there is nowhere to ship — a clean
130
+ // no-op, state untouched, re-ships once configured.
131
+ const url = deriveTranscriptUrl(env.LIBRARIAN_MCP_URL);
132
+ const token = env.LIBRARIAN_AGENT_TOKEN;
133
+ if (!url || !token) {
134
+ log("skip: LIBRARIAN_MCP_URL / LIBRARIAN_AGENT_TOKEN not set");
135
+ return { posted: false, skipped: "not-configured" };
136
+ }
137
+
138
+ // Build this turn's prose turns and apply the forward-only private-span filter,
139
+ // carrying the open-span state forward across turns. A garbage payload yields
140
+ // [] (messagesToTurns is fail-soft) → a clean no-op below.
141
+ const prior = readCaptureState(dataDir, convId);
142
+ const allTurns = messagesToTurns(input.messages);
143
+ const { kept, endPrivate } = filterPrivateSpans(allTurns, { startPrivate: prior.private });
144
+
145
+ const ended = input.ended === true;
146
+
147
+ // Nothing public to ship. If the conversation ended we still send an
148
+ // ended-only delta (a private-only / empty tail shouldn't strand the server
149
+ // buffer). Otherwise persist any changed private state (so the next turn stays
150
+ // private) and no-op — a private turn is now behind us, NEVER retroactively shipped.
151
+ if (kept.length === 0 && !ended) {
152
+ if (endPrivate !== prior.private) {
153
+ writeCaptureState(dataDir, convId, { seq: prior.seq, private: endPrivate });
154
+ }
155
+ return { posted: false, skipped: "no-new-turns" };
156
+ }
157
+
158
+ const seq = prior.seq + 1;
159
+ const payload = buildPayload({ convId, seq, turns: kept, ended });
160
+
161
+ // SHIP. A non-2xx (`ok:false`) or a thrown network error → DO NOT advance seq:
162
+ // the delta re-ships next turn (idempotent; server/curator dedup). A 2xx →
163
+ // advance seq + persist the carried private state.
164
+ let ack: CaptureAck;
165
+ try {
166
+ ack = await post(url, payload, token);
167
+ } catch (error) {
168
+ // Transient/infra: log + hold seq. Never leak the token (postDelta guarantees
169
+ // it stays in the header) or a stack trace into the model's context.
170
+ log(`ship failed (transient, will retry): ${errMsg(error)}`);
171
+ return { posted: false, skipped: "post-failed" };
172
+ }
173
+
174
+ if (!ack || !ack.ok) {
175
+ log(`ship not acked (status ${ack ? ack.status : "?"}); seq held, will retry`);
176
+ return { posted: false, skipped: "not-acked", ack };
177
+ }
178
+
179
+ writeCaptureState(dataDir, convId, { seq, private: endPrivate });
180
+ return { posted: true, ack };
181
+ } catch (error) {
182
+ // Last-resort fail-soft: any unexpected error logs + exits a no-op. State is
183
+ // untouched on this path (no write reached), so the delta re-ships next turn.
184
+ log(`unexpected error (no-op, fail-soft): ${errMsg(error)}`);
185
+ return { posted: false, skipped: "error" };
186
+ }
187
+ }
188
+
189
+ function errMsg(error: unknown): string {
190
+ return error instanceof Error ? error.message : "unknown error";
191
+ }
192
+
193
+ /**
194
+ * Read Pi's stable session id from the handler ctx, fail-soft. The spec's
195
+ * "conv_id = getSessionId()" maps to `ctx.sessionManager.getSessionId()`. Any
196
+ * throw / non-string → "" so the orchestrator no-ops (NEVER cwd/$USER).
197
+ */
198
+ function readSessionId(ctx: unknown): string {
199
+ try {
200
+ const sm = (ctx as { sessionManager?: { getSessionId?: () => unknown } })?.sessionManager;
201
+ const id = sm?.getSessionId?.();
202
+ return typeof id === "string" ? id : "";
203
+ } catch {
204
+ return "";
205
+ }
206
+ }
207
+
208
+ /**
209
+ * Wire auto-capture into Pi's `agent_end` event — fired ONCE per completed user
210
+ * prompt with the turn's messages in-payload (the §11.2 per-turn-end hook). The
211
+ * whole body is guarded so a Librarian/network/parse failure can NEVER throw out
212
+ * of the handler or block the turn (AGENTS.md fail-soft). conv_id is the stable
213
+ * getSessionId(); the delta re-ships next turn on any non-2xx.
214
+ *
215
+ * @param dataDir - the resolved plugin data dir (per-session state home).
216
+ */
217
+ export function registerCaptureHook(pi: ExtensionAPI, dataDir: string): void {
218
+ // Opportunistic, fail-soft pruning of stale sibling state (never clear-all).
219
+ try {
220
+ pruneOldState(dataDir, PRUNE_MAX_AGE_MS);
221
+ } catch {
222
+ // pruning is best-effort; a failure must not break extension load.
223
+ }
224
+
225
+ pi.on("agent_end", async (event, ctx) => {
226
+ try {
227
+ const convId = readSessionId(ctx);
228
+ const messages = Array.isArray((event as { messages?: unknown }).messages)
229
+ ? (event as { messages: unknown[] }).messages
230
+ : [];
231
+ await runCapture({ convId, messages, env: process.env }, { dataDir });
232
+ } catch {
233
+ // Defensive net — runCapture already swallows its own errors, but a
234
+ // top-level throw must never break the user's turn or leak a stack trace.
235
+ }
236
+ });
237
+ }
@@ -17,6 +17,7 @@
17
17
  // privacy hook here.
18
18
 
19
19
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { registerCaptureHook, resolveDataDir } from "./capture.js";
20
21
  import { registerCommands } from "./commands.js";
21
22
  import { readConfig } from "./config.js";
22
23
  import { createMcpClient } from "./mcp-client.js";
@@ -59,6 +60,16 @@ export default function librarian(pi: ExtensionAPI): void {
59
60
  // Primer → system prompt, once per turn, cached per process, fail-soft.
60
61
  registerPrimerHook(pi, createPrimerSource({ endpoint: config.endpoint }));
61
62
 
63
+ // Auto-capture (Phase 2B, spec 2026-06-16-harness-auto-capture): wire the
64
+ // `agent_end` hook so each completed turn's NON-PRIVATE prose ships as a
65
+ // per-turn delta to POST /transcript. Default-on; the LIBRARIAN_AUTO_SAVE=false
66
+ // kill-switch + forward-only private mode are enforced at fire time inside the
67
+ // handler (so a mid-session env change takes effect without a re-install), and
68
+ // it stays inert when the server's curator.intake.enabled gate is off. The hook
69
+ // is registered only when the extension is configured (the dormant branch above
70
+ // returns before reaching here, so an unconfigured install captures nothing).
71
+ registerCaptureHook(pi, resolveDataDir(process.env));
72
+
62
73
  // The four user-facing slash commands (optional sugar, D9).
63
74
  registerCommands(pi);
64
75
  }
@@ -63,7 +63,6 @@ export function librarianToolSpecs(): LibrarianToolSpec[] {
63
63
  parameters: Type.Object({
64
64
  query: Type.Optional(Type.String()),
65
65
  tags: Type.Optional(Type.Array(Type.String())),
66
- project_key: Type.Optional(Type.String()),
67
66
  include_ids: Type.Optional(Type.Boolean()),
68
67
  limit: Type.Optional(Type.Number()),
69
68
  }),
@@ -75,15 +74,13 @@ export function librarianToolSpecs(): LibrarianToolSpec[] {
75
74
  "Save a durable fact, preference, or decision the moment you learn it — " +
76
75
  "not transient chatter. Fire-and-forget: submit and move on; the curator " +
77
76
  "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 " +
77
+ "Give it a short `title` and a self-contained `body`; add `tags` so it " +
78
+ "surfaces in the right context. Caller-supplied " +
80
79
  "`is_global` / `requires_approval` are ignored.",
81
80
  parameters: Type.Object({
82
81
  title: Type.String(),
83
82
  body: Type.String(),
84
- project_key: Type.Optional(Type.String()),
85
83
  applies_to: Type.Optional(Type.Array(Type.String())),
86
- priority: Type.Optional(Type.String()),
87
84
  confidence: Type.Optional(Type.String()),
88
85
  tags: Type.Optional(Type.Array(Type.String())),
89
86
  }),
@@ -0,0 +1,204 @@
1
+ // Auto-capture pure transforms (no IO, no network) — Phase 2B / T-Pi.
2
+ //
3
+ // The Pi `agent_end` hook hands the adapter the completed turn's messages
4
+ // IN-PAYLOAD (`event.messages: AgentMessage[]`) plus a stable session id from
5
+ // `ctx.sessionManager.getSessionId()`. That is the §11.2 expectation: the delta
6
+ // is built O(1) from the in-payload turn — there is NO cursor and NO transcript
7
+ // re-read (unlike the Claude `Stop` adapter, which byte-slices a JSONL file).
8
+ //
9
+ // CONFIRMED vs ASSUMED (build-pi-extension + the pinned SDK @0.75.5 types in
10
+ // node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/types.d.ts):
11
+ // - CONFIRMED: `agent_end` fires once per user prompt with `messages:
12
+ // AgentMessage[]` (the completed turn in-payload). `AgentMessage = Message |
13
+ // custom`; `Message` (from @earendil-works/pi-ai) is UserMessage /
14
+ // AssistantMessage / ToolResultMessage. UserMessage.content is `string |
15
+ // (TextContent|ImageContent)[]`; AssistantMessage.content is
16
+ // `(TextContent|ThinkingContent|ToolCall)[]`; every message carries a numeric
17
+ // epoch-ms `timestamp`. We capture PROSE ONLY (text blocks), mirroring the
18
+ // Claude adapter — dropping thinking, toolCall, images, and toolResult.
19
+ // - ASSUMED (optimistic, no live `pi` to confirm e2e): that `agent_end` is the
20
+ // right per-turn-end boundary to capture a whole exchange (vs `turn_end`,
21
+ // which fires per LLM-response loop within one prompt and carries only one
22
+ // `message`). The extraction here is structurally defensive: any unexpected
23
+ // shape (a custom message, a non-text block, a missing field) yields a clean
24
+ // skip, NEVER a throw or garbage capture — so a wrong assumption fails SAFE.
25
+ //
26
+ // This module owns the three pure transforms the hook drives; the orchestrator
27
+ // (capture.ts) wires the session state, the network ship, and the env gates
28
+ // around them. Everything here is trivially unit-testable and fail-soft.
29
+ //
30
+ // RUNTIME-IMPORT NOTE: a git/local-installed Pi package is not `npm install`ed,
31
+ // so `extensions/` may only value-import `typebox`, `node:` builtins, or relative
32
+ // paths (tests/runtime-imports.test.ts pins this). The Pi/AI types below are
33
+ // `import type` (erased at runtime); the rest is plain TS over globals.
34
+
35
+ // The private-mode markers (AGENTS.md: never bypass private mode). Same literals
36
+ // the harness toggles in-conversation and the server backstops on intake.
37
+ export const PRIVATE_ON = "[librarian:private=on]";
38
+ export const PRIVATE_OFF = "[librarian:private=off]";
39
+
40
+ export const HARNESS = "pi" as const;
41
+
42
+ /** A single already-non-private turn — the /transcript contract turn shape. */
43
+ export interface CaptureTurn {
44
+ role: "user" | "assistant";
45
+ text: string;
46
+ ts?: string;
47
+ }
48
+
49
+ /** The uniform, harness-agnostic delta payload the server contract expects. */
50
+ export interface CapturePayload {
51
+ conv_id: string;
52
+ harness: typeof HARNESS;
53
+ seq: number;
54
+ turns: CaptureTurn[];
55
+ ended?: true;
56
+ }
57
+
58
+ function isRecord(value: unknown): value is Record<string, unknown> {
59
+ return typeof value === "object" && value !== null;
60
+ }
61
+
62
+ /**
63
+ * Flatten an assistant content array to its prose, dropping `thinking`,
64
+ * `toolCall`, and any non-text block. Thinking is private reasoning and toolCall
65
+ * is machine plumbing — neither is durable conversational fact. A plain string
66
+ * (defensive: some message shapes carry a string) is returned as-is.
67
+ */
68
+ function assistantText(content: unknown): string {
69
+ if (typeof content === "string") return content.trim();
70
+ if (!Array.isArray(content)) return "";
71
+ return content
72
+ .filter(
73
+ (b): b is { type: "text"; text: string } =>
74
+ isRecord(b) && b.type === "text" && typeof b.text === "string",
75
+ )
76
+ .map((b) => b.text)
77
+ .join("\n")
78
+ .trim();
79
+ }
80
+
81
+ /**
82
+ * Extract real user prose. A string is the prompt. An array is content blocks —
83
+ * we keep only `text` blocks (an `image` block is not prose, and a `toolResult`
84
+ * never appears as a user message here).
85
+ */
86
+ function userText(content: unknown): string {
87
+ if (typeof content === "string") return content.trim();
88
+ if (!Array.isArray(content)) return "";
89
+ return content
90
+ .filter(
91
+ (b): b is { type: "text"; text: string } =>
92
+ isRecord(b) && b.type === "text" && typeof b.text === "string",
93
+ )
94
+ .map((b) => b.text)
95
+ .join("\n")
96
+ .trim();
97
+ }
98
+
99
+ /**
100
+ * Convert a numeric epoch-ms `timestamp` to an ISO-8601 string for the contract
101
+ * `ts` field (which is an optional string). Returns undefined for anything that
102
+ * is not a finite number (so a missing/garbage stamp simply omits `ts`).
103
+ */
104
+ function toIsoTs(timestamp: unknown): string | undefined {
105
+ if (typeof timestamp !== "number" || !Number.isFinite(timestamp)) return undefined;
106
+ try {
107
+ return new Date(timestamp).toISOString();
108
+ } catch {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Map the `agent_end` payload's AgentMessage[] to ordered user/assistant turns.
115
+ * Forward-only and fail-soft: a non-array input, a null/garbage entry, a
116
+ * toolResult message, a custom (roleless) message, or a turn that is empty after
117
+ * prose extraction is silently skipped rather than thrown. We accept `unknown[]`
118
+ * so a wrong-shape payload (the optimistic assumption being off) is a clean
119
+ * no-op, never garbage capture.
120
+ *
121
+ * @param messages - `event.messages` from the `agent_end` hook (the completed turn).
122
+ */
123
+ export function messagesToTurns(messages: unknown[]): CaptureTurn[] {
124
+ if (!Array.isArray(messages)) return [];
125
+ const turns: CaptureTurn[] = [];
126
+ for (const m of messages) {
127
+ if (!isRecord(m)) continue;
128
+ const role = m.role;
129
+ // Only top-level conversational messages. `toolResult` is plumbing; a custom
130
+ // AgentMessage has no LLM role at all — both skipped.
131
+ if (role !== "user" && role !== "assistant") continue;
132
+
133
+ const text = role === "user" ? userText(m.content) : assistantText(m.content);
134
+ if (!text) continue;
135
+
136
+ const turn: CaptureTurn = { role, text };
137
+ const ts = toIsoTs(m.timestamp);
138
+ if (ts) turn.ts = ts;
139
+ turns.push(turn);
140
+ }
141
+ return turns;
142
+ }
143
+
144
+ /**
145
+ * Per-turn private-span filter (mirrors the Claude/Hermes adapters). Tracks the
146
+ * `[private=on]/[private=off]` marker state across the turns in this delta AND
147
+ * across deltas via `startPrivate` (whether the previous delta left an open
148
+ * span). Any turn while the span is open is SKIPPED; the marker-toggle turns
149
+ * themselves are skipped too (they carry no durable fact). Forward-only: the
150
+ * orchestrator advances past skipped turns so a private turn is NEVER
151
+ * retroactively shipped.
152
+ *
153
+ * Within a single turn's text the LAST-occurring marker wins, so a turn that
154
+ * re-opens privacy stays private into the next turn.
155
+ *
156
+ * @returns the non-private turns + the span state to carry into the next delta.
157
+ */
158
+ export function filterPrivateSpans(
159
+ turns: CaptureTurn[],
160
+ { startPrivate }: { startPrivate: boolean },
161
+ ): { kept: CaptureTurn[]; endPrivate: boolean } {
162
+ let priv = Boolean(startPrivate);
163
+ const kept: CaptureTurn[] = [];
164
+ for (const turn of turns) {
165
+ const text = turn.text;
166
+ const hasOn = text.includes(PRIVATE_ON);
167
+ const hasOff = text.includes(PRIVATE_OFF);
168
+ const isMarkerTurn = hasOn || hasOff;
169
+
170
+ // Resolve the new state from this turn's last-occurring marker.
171
+ if (isMarkerTurn) {
172
+ const onAt = hasOn ? text.lastIndexOf(PRIVATE_ON) : -1;
173
+ const offAt = hasOff ? text.lastIndexOf(PRIVATE_OFF) : -1;
174
+ priv = onAt > offAt;
175
+ }
176
+
177
+ // Skip if we're (or just entered) a private span, OR this is itself a
178
+ // boundary marker turn (it may carry private text alongside the marker).
179
+ if (priv || isMarkerTurn) continue;
180
+ kept.push(turn);
181
+ }
182
+ return { kept, endPrivate: priv };
183
+ }
184
+
185
+ /**
186
+ * 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
+ */
190
+ export function buildPayload({
191
+ convId,
192
+ seq,
193
+ turns,
194
+ ended = false,
195
+ }: {
196
+ convId: string;
197
+ seq: number;
198
+ turns: CaptureTurn[];
199
+ ended?: boolean;
200
+ }): CapturePayload {
201
+ const payload: CapturePayload = { conv_id: convId, harness: HARNESS, seq, turns };
202
+ if (ended) payload.ended = true;
203
+ return payload;
204
+ }
@@ -0,0 +1,88 @@
1
+ // Auto-capture POST-URL derivation + the network ship (Phase 2B / T-Pi).
2
+ //
3
+ // The endpoint is derived from the SAME public listener the MCP config already
4
+ // points at: `LIBRARIAN_MCP_URL` is `<origin>/mcp`, the transcript intake is
5
+ // `<origin>/transcript`. We rewrite the path on the same origin so a deployment
6
+ // configures one URL, not two (and a hostile redirect can't move it cross-origin
7
+ // — `redirect:"error"` below).
8
+ //
9
+ // Auth is `Authorization: Bearer ${LIBRARIAN_AGENT_TOKEN}` — in the HEADER only,
10
+ // never the URL or a log (AGENTS.md: privacy is the product, tokens never leak).
11
+ // Mirrors the Claude adapter's post.mjs and the Pi mcp-client's security posture.
12
+ //
13
+ // RUNTIME-IMPORT NOTE: uses only the global `fetch` / `URL` / `AbortController`
14
+ // (no imports at all), so it loads in a git/local Pi install with no node_modules.
15
+
16
+ const SHIP_TIMEOUT_MS = 10_000;
17
+
18
+ /** The small ack the orchestrator folds into its advance-on-2xx decision. */
19
+ export interface CaptureAck {
20
+ ok: boolean;
21
+ status: number;
22
+ buffered?: number;
23
+ }
24
+
25
+ /**
26
+ * Derive the transcript-intake URL from `LIBRARIAN_MCP_URL`. Rewrites the path to
27
+ * `/transcript` on the same origin, dropping any `/mcp` suffix, query, or hash.
28
+ * Returns `null` for an unusable / non-http(s) URL so the caller can fail-soft
29
+ * (skip, no throw).
30
+ */
31
+ export function deriveTranscriptUrl(mcpUrl: string | undefined): string | null {
32
+ if (!mcpUrl || typeof mcpUrl !== "string") return null;
33
+ let parsed: URL;
34
+ try {
35
+ parsed = new URL(mcpUrl);
36
+ } catch {
37
+ return null;
38
+ }
39
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
40
+ return `${parsed.origin}/transcript`;
41
+ }
42
+
43
+ /**
44
+ * POST a delta payload to the transcript endpoint. Returns a small ack —
45
+ * `{ ok, status, buffered? }` — never throws on an HTTP error status (a non-2xx
46
+ * is `ok:false`, which the caller treats as "do not advance seq"). A
47
+ * network/transport failure (DNS, ECONNREFUSED, timeout, a refused redirect) DOES
48
+ * reject so the orchestrator's try/catch logs it and skips — both are "do not
49
+ * advance".
50
+ *
51
+ * `redirect:"error"` (AGENTS.md): a 3xx must never bounce the Bearer token to a
52
+ * different origin. A 10s timeout bounds a hung server so the hook can't hang.
53
+ */
54
+ export async function postDelta(url: string, payload: unknown, token: string): Promise<CaptureAck> {
55
+ const controller = new AbortController();
56
+ const timer = setTimeout(() => controller.abort(), SHIP_TIMEOUT_MS);
57
+ try {
58
+ const response = await fetch(url, {
59
+ method: "POST",
60
+ headers: {
61
+ "content-type": "application/json",
62
+ // The token lives ONLY here — never in the URL or any logged string.
63
+ authorization: `Bearer ${token}`,
64
+ },
65
+ body: JSON.stringify(payload),
66
+ redirect: "error",
67
+ signal: controller.signal,
68
+ });
69
+ let buffered: number | undefined;
70
+ try {
71
+ const json: unknown = await response.json();
72
+ if (
73
+ json &&
74
+ typeof json === "object" &&
75
+ typeof (json as { buffered?: unknown }).buffered === "number"
76
+ ) {
77
+ buffered = (json as { buffered: number }).buffered;
78
+ }
79
+ } catch {
80
+ // Non-JSON / empty body — the status alone decides ok/not-ok.
81
+ }
82
+ const ack: CaptureAck = { ok: response.ok, status: response.status };
83
+ if (buffered !== undefined) ack.buffered = buffered;
84
+ return ack;
85
+ } finally {
86
+ clearTimeout(timer);
87
+ }
88
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@the-librarian/pi-extension",
3
- "version": "1.0.0-rc.2",
3
+ "version": "1.4.0",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -15,7 +15,7 @@
15
15
  "homepage": "https://github.com/JimJafar/the-librarian/tree/main/integrations/pi",
16
16
  "repository": {
17
17
  "type": "git",
18
- "url": "https://github.com/JimJafar/the-librarian",
18
+ "url": "git+https://github.com/JimJafar/the-librarian.git",
19
19
  "directory": "integrations/pi"
20
20
  },
21
21
  "keywords": [
@@ -37,21 +37,21 @@
37
37
  "extensions",
38
38
  "README.md"
39
39
  ],
40
- "scripts": {
41
- "test:vitest": "vitest run",
42
- "test": "pnpm run test:vitest",
43
- "typecheck": "tsc --noEmit"
44
- },
45
40
  "peerDependencies": {
46
41
  "@earendil-works/pi-coding-agent": "*",
47
42
  "typebox": "*"
48
43
  },
49
44
  "devDependencies": {
50
45
  "@earendil-works/pi-coding-agent": "^0.75.5",
51
- "@librarian/mcp-server": "workspace:*",
52
46
  "@types/node": "^22.10.5",
53
47
  "typebox": "^1.1.38",
54
- "typescript": "catalog:",
55
- "vitest": "catalog:"
48
+ "typescript": "^5.7.2",
49
+ "vitest": "^2.1.8",
50
+ "@librarian/mcp-server": "0.0.0"
51
+ },
52
+ "scripts": {
53
+ "test:vitest": "vitest run",
54
+ "test": "pnpm run test:vitest",
55
+ "typecheck": "tsc --noEmit"
56
56
  }
57
- }
57
+ }