@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 +201 -0
- package/README.md +29 -2
- package/extensions/librarian/capture-state.ts +125 -0
- package/extensions/librarian/capture.ts +237 -0
- package/extensions/librarian/index.ts +11 -0
- package/extensions/librarian/tools.ts +2 -5
- package/extensions/librarian/transcript-capture.ts +204 -0
- package/extensions/librarian/transcript-post.ts +88 -0
- package/package.json +11 -11
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`
|
|
79
|
-
"
|
|
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.
|
|
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": "
|
|
55
|
-
"vitest": "
|
|
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
|
+
}
|