pi-retrospect 0.1.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,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 nietaki
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,70 @@
1
+ # pi-retrospect
2
+
3
+ A [Pi](https://github.com/earendil-works/pi) package that gives an agent tools for
4
+ exploring past Pi sessions and the messages they contain — the read side of harness
5
+ self-improvement. An agent that can look back over its own previous sessions can find
6
+ the conversation where it hit a given error, recover a decision it made, and audit
7
+ what actually happened before repeating it.
8
+
9
+ **Status: 0.x, early.** One operation is implemented — `listSessions` (see
10
+ [`docs/tool-api.md`](docs/tool-api.md) for its contract), registered as one
11
+ codemode-callable tool. Exploring the *messages* inside a session is not built yet;
12
+ that product scope stays open until it is discussed.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ pi install npm:pi-retrospect
18
+ ```
19
+
20
+ Or try it for a single invocation without adding it to settings:
21
+
22
+ ```sh
23
+ pi -e npm:pi-retrospect
24
+ ```
25
+
26
+ `pi list` shows configured packages, `pi remove pi-retrospect` removes it.
27
+
28
+ **Requires Pi 0.99.0 or newer.** The tool registration uses `exposure`, `annotations`,
29
+ and `outputSchema`, which were added in Pi 0.99.0 (2026-09-29). Verified against
30
+ 0.99.2 and 1.0.0. The peer ranges stay `"*"` because that is the convention Pi
31
+ prescribes for host-provided packages — Pi does not resolve them for managed installs,
32
+ so the minimum is stated here rather than in `package.json`.
33
+
34
+ ## Using it
35
+
36
+ The registered tool is `list_sessions`. It walks the Pi sessions root, reads only
37
+ each file's header line, and returns session metadata — id, absolute path, absolute
38
+ `cwd`, timestamp, fork lineage (`parentSessionPath`) — with subagent transcripts
39
+ nested under the session that launched them, plus a warning for every file it had to
40
+ skip.
41
+
42
+ **It is exposed to codemode, not to the model.** The tool registers with
43
+ `exposure: "codemode"`, so it is never declared in the model's tool list and is not
44
+ activated on registration. Call it from a codemode script:
45
+
46
+ ```js
47
+ // in a codemode script
48
+ const { sessions, warnings } = await tools.list_sessions({});
49
+ ```
50
+
51
+ A plain "list my sessions" prompt will not reach it unless your own settings declare
52
+ the tool directly. This is deliberate — the result is structured JSON that a script
53
+ can filter before it costs context — but it does mean the tool is invisible to a
54
+ session running without codemode.
55
+
56
+ ## Reference
57
+
58
+ - [`docs/tool-api.md`](docs/tool-api.md) — the contract for the operations this
59
+ package exposes, including `listSessions`, its discovery rules, and its guarantees.
60
+ - `test/fixtures/generate.mjs` (source repository, not in the npm tarball) — rebuilds
61
+ the synthetic session tree the tests run against.
62
+
63
+ The deeper reference on Pi's session *format* — the measurements and Pi-internals
64
+ analysis this contract was derived from — lives in the author's Obsidian vault rather
65
+ than in the published package, because it documents Pi's schema (which changes with
66
+ Pi, not with this package) and quotes counts from one developer's local session store.
67
+
68
+ ## License
69
+
70
+ MIT — see [`LICENSE`](LICENSE).
@@ -0,0 +1,323 @@
1
+ ## Compatibility
2
+
3
+ Verified against `@earendil-works/pi-coding-agent` **0.99.2** and
4
+ `@earendil-works/pi-ai` **0.99.2** (TypeBox **1.3.27**), and re-checked against Pi
5
+ **1.0.0** on 2026-10-02 — the shipped `dist/core/session-manager.js` and `pi-ai`
6
+ `dist/types.d.ts` are byte-identical across those releases, so nothing here changed.
7
+ Subagent child layout is a `pi-subagents` convention, observed against **0.74.0**.
8
+ Measurements are snapshots of one developer machine's session store and are quoted as
9
+ ratios or timings, never as store totals; a store grows daily.
10
+
11
+ ## Maintainer reference
12
+
13
+ The sections below are the implementation contract for `listSessions` (the exported
14
+ function behind the registered `list_sessions` tool). Each states *how* a guarantee from the
15
+ caller half is produced; the caller half states *what* is guaranteed.
16
+
17
+ ### TypeBox schemas
18
+
19
+ ```ts
20
+ import { Type, type Static } from "@earendil-works/pi-ai";
21
+
22
+ export const ListSessionsParamsSchema = Type.Object(
23
+ {},
24
+ { additionalProperties: false },
25
+ );
26
+
27
+ export const SessionMetadataSchema = Type.Cyclic(
28
+ {
29
+ SessionMetadata: Type.Object(
30
+ {
31
+ id: Type.String({ description: "Session id from the file header" }),
32
+ path: Type.String({ description: "Absolute path to the session .jsonl file" }),
33
+ timestamp: Type.String({
34
+ format: "date-time",
35
+ description: "Header timestamp, ISO 8601, validated before the row is returned",
36
+ }),
37
+ cwd: Type.String({ description: "Absolute working directory from the header" }),
38
+ parentSessionPath: Type.Optional(
39
+ Type.String({
40
+ description: "Header parentSession value, copied verbatim (fork/clone lineage)",
41
+ }),
42
+ ),
43
+ subagentSessions: Type.Array(Type.Ref("SessionMetadata"), {
44
+ description: "Subagent transcripts launched from this session, in timestamp order",
45
+ }),
46
+ },
47
+ { additionalProperties: false },
48
+ ),
49
+ },
50
+ "SessionMetadata",
51
+ );
52
+
53
+ export const ListSessionsWarningSchema = Type.Object(
54
+ {
55
+ path: Type.String({ description: "Absolute path of the file or directory that was skipped" }),
56
+ reason: Type.String({ description: "Why the file was not returned as a session" }),
57
+ },
58
+ { additionalProperties: false },
59
+ );
60
+
61
+ export const ListSessionsOutputSchema = Type.Object(
62
+ {
63
+ sessions: Type.Array(SessionMetadataSchema),
64
+ warnings: Type.Array(ListSessionsWarningSchema),
65
+ },
66
+ { additionalProperties: false },
67
+ );
68
+
69
+ export type ListSessionsParams = Static<typeof ListSessionsParamsSchema>;
70
+ export type SessionMetadata = Static<typeof SessionMetadataSchema>;
71
+ export type ListSessionsWarning = Static<typeof ListSessionsWarningSchema>;
72
+ export type ListSessionsOutput = Static<typeof ListSessionsOutputSchema>;
73
+ ```
74
+
75
+ Recursive schemas use `Type.Cyclic` + `Type.Ref`; this TypeBox release has no
76
+ `Type.Recursive`. `Static` resolves the `$ref` back to the interface, so
77
+ `SessionMetadata` is a genuinely recursive TypeScript type.
78
+
79
+ `id` stays `Type.String()`, **not** `format: "uuid"`. Pi mints UUIDv7 by default but
80
+ `assertValidSessionId` only constrains the character set, so a custom id is valid Pi
81
+ data and must not be rejected here.
82
+
83
+ `format: "date-time"` is a real guarantee rather than documentation, because timestamps are
84
+ validated before a row is returned (see Validation rules).
85
+
86
+ ### Implementation boundary
87
+
88
+ The tool takes no parameters; the implementation takes the root, so tests can point it at a
89
+ fixture tree.
90
+
91
+ ```ts
92
+ export interface ListSessionsOptions {
93
+ sessionsRoot: string;
94
+ /** Aborted between filesystem operations. */
95
+ signal?: AbortSignal;
96
+ }
97
+
98
+ export async function listSessions(
99
+ params: ListSessionsParams,
100
+ options: ListSessionsOptions,
101
+ ): Promise<ListSessionsOutput>;
102
+ ```
103
+
104
+ The registered wrapper supplies the root. Pi's `getSessionsDir()` is **not** re-exported
105
+ from the package root; `getAgentDir()` is, and it honors the agent-dir environment
106
+ override. So `src/index.ts` computes:
107
+
108
+ ```ts
109
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
110
+ import { join } from "node:path";
111
+
112
+ const sessionsRoot = join(getAgentDir(), "sessions");
113
+ ```
114
+
115
+ The `"sessions"` literal is the only path component we hardcode. The tool itself is built
116
+ by a factory — `createListSessionsTool({ sessionsRoot })` in `src/list-sessions-tool.ts` —
117
+ so tests can construct the registered shape against a fixture root instead of the live store.
118
+
119
+ `execute()` checks the `AbortSignal` between file reads and stops early; `details`
120
+ mirrors `structuredContent`; `outputSchema` is declared so codemode callers receive JSON
121
+ instead of prose.
122
+
123
+ ### Discovery rules
124
+
125
+ 1. **Project directories:** only immediate subdirectories of the root whose name matches
126
+ `--<slug>--`. Other entries — measured here: `permission-forwarding/` — are ignored by
127
+ rule, not by accident.
128
+ 2. **Top-level sessions:** `*.jsonl` files *directly* inside a project directory. Not a
129
+ recursive search, which keeps `subagent-artifacts/*_transcript.jsonl` copies (only a
130
+ handful exist where this was measured, but they duplicate real children) out of the result
131
+ set: they are neither roots (not directly in a project dir) nor children (not under a
132
+ parent-stem directory).
133
+ 3. **Children of a session:** every `session.jsonl` at any depth under a directory named
134
+ after the session's own stem — `<file-timestamp>_<session-id>`, the parent filename with
135
+ `.jsonl` removed. Today the observed shape is exactly
136
+ `<slug>/<parent-stem>/<launch-uuid>/run-<n>/session.jsonl`.
137
+ 4. **One entry per run directory.** A launch slot holding `run-0`, `run-1`, … produces one
138
+ `SessionMetadata` per transcript; we do not collapse a resumed child into a single entry.
139
+ 5. **Grouping by deepest container.** A session's container directory is
140
+ `dirname(path)/basename-without-.jsonl`. Every transcript found under a root's container
141
+ is attached to the session whose container is the deepest ancestor of its path, so a
142
+ transcript is never reported under two parents. When a nested launch does not sit under
143
+ its parent's container, it stays attached to the nearest session that does — the
144
+ documented fallback for the unverified grandchild convention. Recursion is implemented
145
+ generically and unverified: the store measured here holds no grandchild transcripts at
146
+ all, and every child transcript observed sits at depth `run-0`. See TODOs.
147
+ 6. **Header read:** only line 1 of each file is parsed, bounded to **4 KiB**
148
+ (`MAX_HEADER_LINE_BYTES = 4096`). A longer first line is skipped with a warning. This is
149
+ deliberately tighter than Pi's own 1 MiB `MAX_SESSION_HEADER_SCAN_BYTES`: real headers
150
+ measured here are ~150 bytes, and a header over 4 KiB is not a session we can trust.
151
+ A line of exactly 4096 bytes is accepted; a file whose last line has no terminating
152
+ newline is read normally.
153
+ 7. **Symlinks are not followed.** Discovery stays inside `sessionsRoot`. Implemented by
154
+ `Dirent.isDirectory()` / `isFile()`, which are false for symlinks.
155
+
156
+ ### Validation rules
157
+
158
+ A file yields a session row only when all of the following hold; otherwise it is skipped
159
+ and one warning `{ path, reason }` is emitted. The reason vocabulary is free-form text, not
160
+ an enum.
161
+
162
+ | Condition | Outcome |
163
+ | --- | --- |
164
+ | Line 1 missing, empty, or unreadable | skip + warn |
165
+ | Line 1 not valid JSON | skip + warn |
166
+ | Line 1 JSON is not an object, or `type !== "session"` | skip + warn |
167
+ | Missing/invalid `id` | skip + warn |
168
+ | Missing or empty `cwd` (Pi writes `""` in old sessions) | skip + warn |
169
+ | `timestamp` absent, wrong type, ISO-shaped but not a real date, or not parseable | skip + warn |
170
+ | `parentSessionPath` present but not a string | skip + warn |
171
+ | First line longer than 4 KiB | skip + warn |
172
+
173
+ Consequence: every returned row is fully trustworthy — `id`, absolute `path`, valid `cwd`,
174
+ and a parseable `timestamp` are guaranteed, so callers never handle a partial row and
175
+ sorting never meets an `Invalid Date`. Nothing the walk reached is ever silently dropped; a
176
+ skipped file costs one warning entry. Files the discovery rules exclude (non-slug
177
+ directories, `subagent-artifacts/` copies, orphaned stems, symlinks) are not skipped and
178
+ therefore not warned about — they are out of scope.
179
+
180
+ A missing subagent directory is the normal case and is **not** warned about; an existing
181
+ directory that cannot be read is. `parentSessionPath` is copied verbatim with no
182
+ resolution, normalization, or existence check. A `parentSession` present but not a string
183
+ is a skip-plus-warning; an empty-string `parentSession` is treated as absent and omitted
184
+ from the row. `path` is absolute because `sessionsRoot` is resolved with `path.resolve()`
185
+ before the walk and every row path is built from it; `cwd` is absolute because we require
186
+ it. Timestamp validation includes a calendar check `Date.parse` does not provide —
187
+ `"2026-02-30T08:00:00.000Z"` rolls over instead of failing, and would otherwise reach the
188
+ output as an untrustworthy string.
189
+
190
+ ### Ordering rules
191
+
192
+ `timestamp` **ascending**, oldest first, at both levels, with ties broken by `path`. The
193
+ sessions root is resolved to an absolute path before the walk, so `path` is absolute even
194
+ when a caller passes a relative `sessionsRoot`. `warnings` are sorted by `path` (then
195
+ `reason`) so a run is reproducible regardless of `readdir` order. Ascending was chosen so
196
+ that a parent's subagent children appear in launch order, which mirrors how the runs were
197
+ started. The tool description is where "the sessions a caller usually wants are at the
198
+ tail" has to be spelled out, because ascending order is the counter-intuitive half.
199
+
200
+ ### Missing or empty root
201
+
202
+ Two cases, verified against the implementation, and they differ:
203
+
204
+ - **Root not readable** (does not exist, or `scandir` fails for any other reason): not an
205
+ error. Returns `{ sessions: [], warnings: [ … ] }` with one warning naming the resolved
206
+ attempted path, whose reason begins `sessions root not readable:`.
207
+ - **Root exists but holds no `--<slug>--` directory** (or holds only directories with no
208
+ readable session headers directly inside them): returns `{ sessions: [], warnings: [] }`.
209
+ No warning is emitted, because walking an empty directory is not a failure.
210
+
211
+ A fresh install has no history yet and must not look broken, which is why neither case
212
+ throws. The distinction a caller has to remember: `sessions: []` plus empty `warnings` is
213
+ "found nothing", while `sessions: []` plus a warning naming the root is "could not read
214
+ the store".
215
+
216
+ ### Tool registration
217
+
218
+ ```ts
219
+ defineTool({
220
+ name: "list_sessions",
221
+ label: "List Sessions",
222
+ description: "...",
223
+ parameters: ListSessionsParamsSchema,
224
+ outputSchema: ListSessionsOutputSchema,
225
+ exposure: "codemode",
226
+ annotations: { readOnlyHint: true },
227
+ });
228
+ ```
229
+
230
+ The registered `description` must keep telling the caller what it cannot infer from the
231
+ shape: that results are oldest-first so the newest session is last, that nesting is
232
+ expressed by `subagentSessions` while `parentSessionPath` is fork lineage, and that an
233
+ empty list plus warnings means unreadable rather than absent history. It currently does not
234
+ say that `path` is the handle to key on — see TODOs.
235
+
236
+ `exposure`, `annotations`, and `outputSchema` require **Pi >= 0.99.0** (added
237
+ 2026-09-29). Peer ranges stay `"*"` because that is Pi's stated convention for
238
+ host-provided packages and Pi does not resolve peer ranges for managed installs
239
+ (`--omit=peer`), so this minimum is recorded in prose rather than in `package.json`.
240
+
241
+ `exposure: "codemode"` means the tool is callable from codemode scripts and listed by the
242
+ `codemode` tool, but is never declared to the model and is not activated on registration.
243
+ The two consequences in the caller half (structured JSON to scripts; `content` visible only
244
+ in transcripts, logs, and UI) follow from this.
245
+
246
+ ### Rendered `content` text format
247
+
248
+ Model/UI-facing text is an index of paths, not a re-encoding of the data:
249
+
250
+ ```
251
+ Sessions (N)
252
+ - /Users/<user>/.pi/agent/sessions/--Users-<user>-repos-app--/2026-09-…_01a0….jsonl
253
+ - /Users/<user>/.pi/agent/sessions/--Users-<user>-repos-tool--/2026-10-…_01a0….jsonl
254
+ - /Users/…/2026-10-…_01a0….jsonl/<launch-uuid>/run-0/session.jsonl
255
+
256
+ Warnings (2)
257
+ - /Users/…/broken.jsonl: first line is not a session header
258
+ ```
259
+
260
+ - `Sessions (N)` header, then one bullet per root session, children indented two spaces per
261
+ nesting level, in the same order as `sessions`.
262
+ - `Warnings (N)` section only when there is at least one warning.
263
+ - No row cap and no truncation: the list is bounded by the number of sessions, and the JSON
264
+ is available to scripts regardless.
265
+
266
+ ### Testing policy
267
+
268
+ Committed synthetic fixtures under `test/fixtures/sessions/`, generated by
269
+ `test/fixtures/generate.mjs` (`node test/fixtures/generate.mjs` rebuilds the tree; both are
270
+ in the source repository, not in the npm tarball). The layout reproduces the real one —
271
+ `--<slug>--` project directories, parent `<stem>.jsonl` files,
272
+ `<stem>/<launch-uuid>/run-<n>/session.jsonl` children, a `subagent-artifacts/` dump, a
273
+ non-slug directory, a stray root-level `.jsonl`, two symlinks (a project-dir link and a
274
+ session-file link), an orphan stem directory with no matching parent file, a header of
275
+ exactly 4096 bytes and one of 4097, a header with no terminating newline, and one
276
+ deliberately broken file per warning branch. Fixture headers are hand-written; no real
277
+ session data is committed.
278
+
279
+ Assertions target the contract, not Pi's format: ordering, absolute paths, nesting by
280
+ containment, one row per run directory, symlink and non-slug exclusion, and warning
281
+ coverage.
282
+
283
+ Measured against the **live** store on this machine: every parent and child transcript
284
+ discovered, 0 warnings, unique paths and ids, ordering correct, and the whole walk
285
+ completes in roughly 200 ms. Running subagent workflows here is still the way to grow real
286
+ parent/child trees for spot-checks — see TODOs.
287
+
288
+ ### Implementation guarantees
289
+
290
+ Restated from the caller half as properties a change must not break: total ascending
291
+ ordering with `path` tie-break at every level; `subagentSessions` always present; each file
292
+ reported at most once; containment-only child linkage with no structural field connecting
293
+ parents to children (Pi's `SessionHeader.parentSession` means fork/clone lineage, verified
294
+ against `pi-subagents` 0.74.0); `path` as the stable handle because `id` may repeat across
295
+ `run-<n>` directories; no use of Pi's `SessionManager.open()`, which can migrate or repair
296
+ history; and orphaned `--<slug>--` stems left unvisited, so only files reachable from a
297
+ discovered session are considered.
298
+
299
+ ---
300
+
301
+ ## TODOs
302
+
303
+ - **Verify grandchild nesting.** Every child transcript in the store measured here sits at
304
+ `<slug>/<parent-stem>/<launch-uuid>/run-0/session.jsonl`; none launched their own
305
+ subagent, so the recursive case is unexercised. Once a nested launch exists, confirm the
306
+ convention — whether a child's own stem directory appears as `…/run-0/<child-stem>/…` —
307
+ and that containment-based grouping does not attach a deep transcript to two parents.
308
+ - **Generate real fixture data.** Run subagent workflows in this project's `cwd` so that
309
+ project's `--<slug>--/` session directory grows actual parent/child trees, then use them
310
+ to check the synthetic layout against reality. Do not commit the output.
311
+ - **Filtering parameters.** `params` is an empty object by design. Deferred for later:
312
+ by project/`cwd`, by time range, by presence of subagents, `includeCurrentSession`
313
+ (the currently running session is included today; an exclusion parameter is the agreed
314
+ future direction), depth limit for `subagentSessions`.
315
+ - **Ordering choice review.** Ascending order follows launch chronology; if callers keep
316
+ needing the newest session, add a parameter rather than flipping the default.
317
+ - **Orphaned child trees.** Today they are reported neither as a row nor as a warning. If
318
+ that ever needs to be visible, it should become an explicit warning branch rather than a
319
+ silent rule.
320
+ - **Tool description should name `path` as the handle.** The caller half says to key on
321
+ `path` rather than `id`, but `description` in `src/list-sessions-tool.ts` lists the fields
322
+ without saying which identifies a transcript. Worth one clause, since a caller reading
323
+ only the description has no other way to learn it.
@@ -0,0 +1,197 @@
1
+ # Tool API
2
+
3
+ The contract for the operations `pi-retrospect` exposes.
4
+
5
+ ## Calling `list_sessions`
6
+
7
+ ### Purpose
8
+
9
+ `list_sessions` returns metadata for every discoverable Pi session, with subagent
10
+ transcripts nested under the session that launched them. It is the entry point for
11
+ "which past session do I want to look at" — it names sessions and locates them on disk;
12
+ it does not read the conversation inside them.
13
+
14
+ The call is **read-only**: it opens each file's first line directly and never goes through
15
+ Pi's `SessionManager.open()`, which can migrate or repair history. Nothing is written.
16
+
17
+ ### Availability and invocation
18
+
19
+ The tool registers with `exposure: "codemode"`. That has three consequences a caller must
20
+ know:
21
+
22
+ - It is **callable from codemode scripts and listed by the `codemode` tool**, but it is
23
+ never declared to the model and is not activated on registration. A plain "list my
24
+ sessions" prompt does not reach it unless your own settings declare the tool directly.
25
+ - A script receives the **structured JSON result**, so filtering costs no context before
26
+ you decide what to look at.
27
+ - The rendered text (`content`) is visible only in transcripts, logs, and UI rendering,
28
+ and nested tool results do not become transcript entries — the JSON is reachable only
29
+ from inside a script that asked for it.
30
+
31
+ It takes no parameters: pass an empty object.
32
+
33
+ ```js
34
+ // in a codemode script
35
+ const { sessions, warnings } = await tools.list_sessions({});
36
+ ```
37
+
38
+ Every example below is a codemode script body: top-level `await` and a top-level `return`
39
+ work, and the returned value is what the calling agent sees.
40
+
41
+ ### Quick examples
42
+
43
+ Newest recorded session — arrays are oldest-first, so the entry you usually want is last:
44
+
45
+ ```js
46
+ const { sessions, warnings } = await tools.list_sessions({});
47
+ const newest = sessions.at(-1);
48
+
49
+ return newest
50
+ ? { path: newest.path, cwd: newest.cwd, timestamp: newest.timestamp }
51
+ : { path: null, warnings };
52
+ ```
53
+
54
+ Sessions for one project:
55
+
56
+ ```js
57
+ const { sessions } = await tools.list_sessions({});
58
+ const project = "/Users/me/repos/my-app";
59
+
60
+ return sessions
61
+ .filter((session) => session.cwd === project)
62
+ .map((session) => ({ path: session.path, timestamp: session.timestamp }));
63
+ ```
64
+
65
+ Every transcript, parents and children flattened, newest last:
66
+
67
+ ```js
68
+ function flatten(sessions, depth = 0) {
69
+ return sessions.flatMap((session) => [
70
+ { depth, path: session.path, cwd: session.cwd, timestamp: session.timestamp },
71
+ ...flatten(session.subagentSessions, depth + 1),
72
+ ]);
73
+ }
74
+
75
+ const { sessions } = await tools.list_sessions({});
76
+ return flatten(sessions).sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
77
+ ```
78
+
79
+ Delegated work: root sessions that have subagent children, with their run count:
80
+
81
+ ```js
82
+ const { sessions } = await tools.list_sessions({});
83
+
84
+ return sessions
85
+ .filter((session) => session.subagentSessions.length > 0)
86
+ .map((session) => ({
87
+ path: session.path,
88
+ cwd: session.cwd,
89
+ runs: session.subagentSessions.length,
90
+ }));
91
+ ```
92
+
93
+ The result is an index of transcript files. To read one, pass its `path` to a file-reading
94
+ tool; a `.jsonl` transcript is one JSON object per line, which `read` handles directly.
95
+
96
+ ### Result fields
97
+
98
+ ```ts
99
+ type ListSessionsOutput = {
100
+ sessions: SessionMetadata[];
101
+ warnings: ListSessionsWarning[];
102
+ };
103
+ ```
104
+
105
+ | Field | Caller meaning |
106
+ | --- | --- |
107
+ | `id` | Session id from the file header. Not a safe key — see Limitations. |
108
+ | `path` | Absolute path to the session `.jsonl` file. **The handle to key on**: unique among returned rows, and what you hand to a file-reading tool. |
109
+ | `timestamp` | Valid ISO 8601 header timestamp; always parseable, so it is safe to sort and compare. |
110
+ | `cwd` | Absolute working directory of the session. Empty `cwd` never appears: such files are skipped. |
111
+ | `parentSessionPath` | Optional. Fork/clone lineage copied verbatim from the header. **Not** the nesting relationship. |
112
+ | `subagentSessions` | Transcripts launched under this session, nested recursively. Always present; `[]` when there are none. |
113
+
114
+ ```ts
115
+ type ListSessionsWarning = {
116
+ path: string; // absolute path of the skipped file or directory
117
+ reason: string; // free-form text, not an enum
118
+ };
119
+ ```
120
+
121
+ ### Interpreting the hierarchy
122
+
123
+ Two different "parent" concepts exist and are unrelated:
124
+
125
+ - **Nesting** is expressed only by `subagentSessions`. Each transcript is attached to the
126
+ discovered session whose directory is the closest containing directory of its path, so a
127
+ transcript never appears under two parents.
128
+ - **`parentSessionPath`** is Pi's header `parentSession` value: fork/clone lineage between
129
+ top-level sessions. It is copied verbatim with no resolution, normalization, or existence
130
+ check, and it does not point at the entry that nests the transcript. Do not use it to
131
+ walk `subagentSessions`.
132
+
133
+ Every discovered transcript is reported **at most once**, either as a row or as a warning.
134
+
135
+ ### Ordering
136
+
137
+ Arrays are **oldest first** — `timestamp` ascending, ties broken by `path`, at every
138
+ nesting level. `warnings` are sorted by `path`, then `reason`.
139
+
140
+ **Caller rule:** the sessions you usually want are at the tail. Use `sessions.at(-1)` for
141
+ the newest; never assume the first element is the most recent. Ascending is deliberate at
142
+ the child level because it puts a parent's subagent runs in launch order. If you keep
143
+ wanting newest-first at the root, that is a parameter request, not a default to flip — see
144
+ TODOs.
145
+
146
+ ### Warnings and empty results
147
+
148
+ How to read the shape of a response:
149
+
150
+ | `sessions` | `warnings` | What it means |
151
+ | --- | --- | --- |
152
+ | non-empty | empty | Clean read of the store. |
153
+ | non-empty | non-empty | Usable sessions, but some files were skipped. Say so if completeness matters. |
154
+ | `[]` | empty | Nothing discoverable: no `--<slug>--` project directory held a readable session header. An existing-but-empty root looks exactly like this — no warning. |
155
+ | `[]` | non-empty | **Unreadable history, not absence of history.** Inspect the reasons before concluding there are no sessions. |
156
+
157
+ Neither case is an error. A root that cannot be read returns one warning naming the
158
+ resolved path; an existing root with nothing discoverable returns
159
+ `{ sessions: [], warnings: [] }`. A fresh install therefore never looks broken — see
160
+ Missing or empty root in the maintainer half.
161
+
162
+ The `warnings` array is flat: it does not say which nesting level the failure happened at.
163
+ A failed child is simply absent from its parent's `subagentSessions` list, plus one warning
164
+ naming the child's path — so a parent can look childless while its child tree was
165
+ partially unreadable. A child directory that does not exist at all is the normal case and
166
+ adds no warning.
167
+
168
+ ### Caller guarantees
169
+
170
+ 1. Every row has `id`, absolute `path`, absolute non-empty `cwd`, and a parseable ISO 8601
171
+ `timestamp`. There are no partial rows and no `Invalid Date`.
172
+ 2. `subagentSessions` is always present; `[]` for a session with no children.
173
+ 3. Ordering is total and reproducible (timestamp ascending, ties by `path`).
174
+ 4. A file is reported at most once, as a row or as a warning. Every file the walk
175
+ *considered* costs exactly one warning if it is skipped — but the walk does not consider
176
+ everything on disk, so "no warnings" does not mean "the store is complete". See
177
+ Limitations.
178
+ 5. The call is read-only and cannot repair or migrate history.
179
+
180
+ ### Limitations
181
+
182
+ - **`id` is not guaranteed unique across rows.** `path` is the handle to key on. A session
183
+ id could repeat across `run-<n>` directories if Pi ever reuses one; today's store has only
184
+ `run-0`, so this is unverifiable rather than known-safe.
185
+ - **The hierarchy is inferred from on-disk layout**, not from a structural field. It is a
186
+ `pi-subagents` storage convention, not Pi's own model.
187
+ - **Orphaned child trees are invisible.** A `--<slug>--` directory whose stem has no
188
+ matching parent `.jsonl` is never visited, so its transcripts are reported neither as a
189
+ row nor as a warning. The same is true of everything else the discovery rules exclude.
190
+ "No warnings" means "nothing I reached was unreadable", not "the store is complete".
191
+ - **Grandchild nesting is unverified against real data.** See TODOs.
192
+ - **No filtering, no pagination.** `params` is an empty object by design; every filter is
193
+ yours to write in the script. The currently running session **is** included — verified
194
+ 2026-10-02, where `sessions.at(-1)` matched `$PI_SESSION_FILE`. Exclude it yourself by
195
+ comparing against that variable if you mean "previous sessions only".
196
+ - **Sessions only.** Message content is out of scope for this operation.
197
+
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "pi-retrospect",
3
+ "version": "0.1.0",
4
+ "description": "Tools to explore past Pi sessions to facilitate harness self-improvement",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "nietaki",
8
+ "keywords": [
9
+ "pi-package",
10
+ "pi",
11
+ "pi-coding-agent",
12
+ "sessions",
13
+ "history",
14
+ "retrospect"
15
+ ],
16
+ "repository": {
17
+ "type": "git",
18
+ "url": "git+https://github.com/nietaki/pi-retrospect.git"
19
+ },
20
+ "bugs": {
21
+ "url": "https://github.com/nietaki/pi-retrospect/issues"
22
+ },
23
+ "homepage": "https://github.com/nietaki/pi-retrospect#readme",
24
+ "scripts": {
25
+ "test": "node --experimental-strip-types --test test/*.test.mjs",
26
+ "typecheck": "tsc --noEmit",
27
+ "check": "npm run test && npm run typecheck",
28
+ "prepublishOnly": "npm run check"
29
+ },
30
+ "files": [
31
+ "src",
32
+ "docs",
33
+ "README.md"
34
+ ],
35
+ "pi": {
36
+ "extensions": [
37
+ "./src/index.ts"
38
+ ]
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-ai": "*",
42
+ "@earendil-works/pi-coding-agent": "*"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.0.0",
46
+ "typescript": "^5.9.3"
47
+ }
48
+ }
package/src/content.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Model/UI-facing text for `listSessions`.
3
+ *
4
+ * An index of paths, not a re-encoding of the data: `Sessions (N)` and one bullet per
5
+ * session, subagent transcripts indented under their parent, then `Warnings (N)` and
6
+ * `path: reason` bullets. No row cap and no truncation.
7
+ *
8
+ * Contract: docs/tool-api.md
9
+ */
10
+
11
+ import type { ListSessionsOutput, SessionMetadata } from "./schemas.ts";
12
+
13
+ function sessionLines(sessions: SessionMetadata[], depth: number): string[] {
14
+ const indent = " ".repeat(depth);
15
+
16
+ return sessions.flatMap((session) => [
17
+ `${indent}- ${session.path}`,
18
+ ...sessionLines(session.subagentSessions, depth + 1),
19
+ ]);
20
+ }
21
+
22
+ export function renderListSessionsContent(output: ListSessionsOutput): string {
23
+ const lines = [`Sessions (${output.sessions.length})`, ...sessionLines(output.sessions, 0)];
24
+
25
+ if (output.warnings.length > 0) {
26
+ lines.push("", `Warnings (${output.warnings.length})`);
27
+ for (const warning of output.warnings) {
28
+ lines.push(`- ${warning.path}: ${warning.reason}`);
29
+ }
30
+ }
31
+
32
+ return lines.join("\n");
33
+ }
package/src/index.ts ADDED
@@ -0,0 +1,11 @@
1
+ import { join } from "node:path";
2
+ import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+
4
+ import { createListSessionsTool } from "./list-sessions-tool.ts";
5
+
6
+ export default function (pi: ExtensionAPI) {
7
+ // Pi exports getAgentDir() but not getSessionsDir(), so the "sessions" segment is ours.
8
+ const sessionsRoot = join(getAgentDir(), "sessions");
9
+
10
+ pi.registerTool(createListSessionsTool({ sessionsRoot }));
11
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The `list_sessions` tool.
3
+ *
4
+ * Built by a factory so tests can register it against a fixture root instead of the live
5
+ * session store. `src/index.ts` supplies the real one.
6
+ *
7
+ * Contract: docs/tool-api.md
8
+ */
9
+
10
+ import { defineTool } from "@earendil-works/pi-coding-agent";
11
+
12
+ import { renderListSessionsContent } from "./content.ts";
13
+ import { listSessions } from "./list-sessions.ts";
14
+ import { ListSessionsOutputSchema, ListSessionsParamsSchema } from "./schemas.ts";
15
+
16
+ export interface ListSessionsToolOptions {
17
+ sessionsRoot: string;
18
+ }
19
+
20
+ export function createListSessionsTool(options: ListSessionsToolOptions) {
21
+ return defineTool({
22
+ name: "list_sessions",
23
+ label: "List Sessions",
24
+ description: [
25
+ "List every recorded Pi session under the sessions root, newest last.",
26
+ "Returns { sessions, warnings }: each session carries its id, absolute file path, absolute cwd,",
27
+ "ISO 8601 timestamp, an optional parentSessionPath for forked sessions, and subagentSessions",
28
+ "holding the subagent transcripts launched from it (nested the same way).",
29
+ "Both levels are ordered by timestamp ascending, so the most recent session is the last entry.",
30
+ "Sessions whose file cannot be read or has no valid session header on line 1 are not returned;",
31
+ "they appear in warnings with the reason, so an empty list plus warnings means unreadable history",
32
+ "rather than no history.",
33
+ ].join(" "),
34
+ parameters: ListSessionsParamsSchema,
35
+ outputSchema: ListSessionsOutputSchema,
36
+ exposure: "codemode",
37
+ annotations: { readOnlyHint: true },
38
+
39
+ async execute(_toolCallId, params, signal) {
40
+ const output = await listSessions(params, { sessionsRoot: options.sessionsRoot, signal });
41
+
42
+ return {
43
+ content: [{ type: "text", text: renderListSessionsContent(output) }],
44
+ details: output,
45
+ structuredContent: output,
46
+ };
47
+ },
48
+ });
49
+ }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * Walk the Pi sessions directory and return session metadata, with subagent transcripts
3
+ * nested under the session that launched them.
4
+ *
5
+ * Contract: docs/tool-api.md
6
+ */
7
+
8
+ import { readdir } from "node:fs/promises";
9
+ import { basename, dirname, join, resolve } from "node:path";
10
+
11
+ import { readSessionHeader } from "./session-metadata.ts";
12
+ import type {
13
+ ListSessionsOutput,
14
+ ListSessionsParams,
15
+ ListSessionsWarning,
16
+ SessionMetadata,
17
+ } from "./schemas.ts";
18
+
19
+ /** Pi encodes a cwd into `--<slug>--`; anything else at that level is not a project. */
20
+ const PROJECT_DIRECTORY = /^--.*--$/;
21
+
22
+ /** Extension-written name of a subagent transcript. Top-level sessions are `<stem>.jsonl`. */
23
+ const SUBAGENT_FILE_NAME = "session.jsonl";
24
+
25
+ const SESSION_EXTENSION = ".jsonl";
26
+
27
+ export interface ListSessionsOptions {
28
+ /**
29
+ * Sessions root, normally `join(getAgentDir(), "sessions")`. Injectable for tests.
30
+ * Resolved to an absolute path, because every returned `path` must be absolute.
31
+ */
32
+ sessionsRoot: string;
33
+ /** Aborted between filesystem operations. */
34
+ signal?: AbortSignal;
35
+ }
36
+
37
+ type ParsedSession = {
38
+ path: string;
39
+ id: string;
40
+ timestamp: string;
41
+ cwd: string;
42
+ parentSessionPath?: string;
43
+ };
44
+
45
+ function throwIfAborted(signal?: AbortSignal): void {
46
+ if (signal?.aborted) {
47
+ throw signal.reason instanceof Error ? signal.reason : new Error("listSessions was aborted");
48
+ }
49
+ }
50
+
51
+ /** `dir/file.jsonl` -> `dir/file`, the directory that holds this session's subagent trees. */
52
+ function containerOf(path: string): string {
53
+ return join(dirname(path), basename(path, SESSION_EXTENSION));
54
+ }
55
+
56
+ function containsPath(container: string, path: string): boolean {
57
+ const prefix = container.endsWith("/") ? container : `${container}/`;
58
+ return path.startsWith(prefix);
59
+ }
60
+
61
+ /** Timestamp ascending, oldest first; ties broken by path so the order is total. */
62
+ function compareSessions(a: SessionMetadata, b: SessionMetadata): number {
63
+ const left = Date.parse(a.timestamp);
64
+ const right = Date.parse(b.timestamp);
65
+
66
+ if (left !== right) return left - right;
67
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
68
+ return 0;
69
+ }
70
+
71
+ /** Warnings are sorted by path so a run is reproducible despite readdir order. */
72
+ function compareWarnings(a: ListSessionsWarning, b: ListSessionsWarning): number {
73
+ if (a.path !== b.path) return a.path < b.path ? -1 : 1;
74
+ return a.reason < b.reason ? -1 : a.reason > b.reason ? 1 : 0;
75
+ }
76
+
77
+ function toMetadata(session: ParsedSession, children: SessionMetadata[]): SessionMetadata {
78
+ const metadata: SessionMetadata = {
79
+ id: session.id,
80
+ path: session.path,
81
+ timestamp: session.timestamp,
82
+ cwd: session.cwd,
83
+ subagentSessions: children,
84
+ };
85
+
86
+ if (session.parentSessionPath !== undefined) {
87
+ metadata.parentSessionPath = session.parentSessionPath;
88
+ }
89
+
90
+ return metadata;
91
+ }
92
+
93
+ /**
94
+ * Read headers of every `session.jsonl` found at any depth under `directory`.
95
+ *
96
+ * Unreadable files and invalid headers are skipped and warned about, and never returned.
97
+ * Symlinks are not followed, so the walk stays inside the supplied root.
98
+ */
99
+ async function collectSubagentTranscripts(
100
+ directory: string,
101
+ warnings: ListSessionsWarning[],
102
+ signal?: AbortSignal,
103
+ ): Promise<ParsedSession[]> {
104
+ throwIfAborted(signal);
105
+
106
+ let entries;
107
+ try {
108
+ entries = await readdir(directory, { withFileTypes: true });
109
+ } catch (error) {
110
+ // No subagent directory is the normal case, not a failure worth warning about.
111
+ if ((error as { code?: string }).code === "ENOENT") return [];
112
+ warnings.push({
113
+ path: directory,
114
+ reason: `subagent directory not readable: ${error instanceof Error ? error.message : String(error)}`,
115
+ });
116
+ return [];
117
+ }
118
+
119
+ const found: ParsedSession[] = [];
120
+
121
+ for (const entry of entries) {
122
+ throwIfAborted(signal);
123
+ const path = join(directory, entry.name);
124
+
125
+ if (entry.isDirectory()) {
126
+ found.push(...(await collectSubagentTranscripts(path, warnings, signal)));
127
+ continue;
128
+ }
129
+
130
+ if (!entry.isFile() || entry.name !== SUBAGENT_FILE_NAME) continue;
131
+
132
+ const header = await readSessionHeader(path);
133
+ if (!header.ok) {
134
+ warnings.push({ path, reason: header.reason });
135
+ continue;
136
+ }
137
+
138
+ found.push({ path, ...header.values });
139
+ }
140
+
141
+ return found;
142
+ }
143
+
144
+ /**
145
+ * Attach transcripts to the session whose container directory is their deepest ancestor,
146
+ * so a transcript is never reported under two parents.
147
+ *
148
+ * A session's container is `dirname(path)/basename-without-.jsonl`. When a nested launch
149
+ * does not sit under its parent's container, it stays attached to the nearest session that
150
+ * does — the documented fallback for the unverified grandchild convention.
151
+ */
152
+ function nestTranscripts(
153
+ rootContainer: string,
154
+ transcripts: ParsedSession[],
155
+ signal?: AbortSignal,
156
+ ): SessionMetadata[] {
157
+ const parentOf = new Map<string, string | null>();
158
+
159
+ for (const transcript of transcripts) {
160
+ throwIfAborted(signal);
161
+
162
+ let best: { key: string; depth: number } | null = { key: rootContainer, depth: rootContainer.length };
163
+
164
+ for (const candidate of transcripts) {
165
+ if (candidate.path === transcript.path) continue;
166
+
167
+ const container = containerOf(candidate.path);
168
+ if (!containsPath(container, transcript.path)) continue;
169
+ if (container.length > best.depth) best = { key: candidate.path, depth: container.length };
170
+ }
171
+
172
+ parentOf.set(transcript.path, best.key === rootContainer ? null : best.key);
173
+ }
174
+
175
+ const build = (key: string | null): SessionMetadata[] => {
176
+ const children = transcripts
177
+ .filter((transcript) => parentOf.get(transcript.path) === key)
178
+ .map((transcript) => toMetadata(transcript, build(transcript.path)))
179
+ .sort(compareSessions);
180
+
181
+ return children;
182
+ };
183
+
184
+ return build(null);
185
+ }
186
+
187
+ /**
188
+ * List every discoverable session under `options.sessionsRoot`.
189
+ *
190
+ * Sessions are ordered by `timestamp` ascending — oldest first, so a parent's subagent
191
+ * children appear in launch order. Files that cannot be read or whose header is invalid are
192
+ * skipped and reported in `warnings` instead.
193
+ */
194
+ export async function listSessions(
195
+ _params: ListSessionsParams,
196
+ options: ListSessionsOptions,
197
+ ): Promise<ListSessionsOutput> {
198
+ const sessionsRoot = resolve(options.sessionsRoot);
199
+ const { signal } = options;
200
+ const warnings: ListSessionsWarning[] = [];
201
+ const sessions: SessionMetadata[] = [];
202
+
203
+ throwIfAborted(signal);
204
+
205
+ let rootEntries;
206
+ try {
207
+ rootEntries = await readdir(sessionsRoot, { withFileTypes: true });
208
+ } catch (error) {
209
+ // A fresh install has no history yet: empty plus one warning, not an error.
210
+ return {
211
+ sessions: [],
212
+ warnings: [
213
+ {
214
+ path: sessionsRoot,
215
+ reason: `sessions root not readable: ${error instanceof Error ? error.message : String(error)}`,
216
+ },
217
+ ],
218
+ };
219
+ }
220
+
221
+ for (const entry of rootEntries) {
222
+ throwIfAborted(signal);
223
+
224
+ // `isDirectory()` is false for symlinks, so links are never followed.
225
+ if (!entry.isDirectory() || !PROJECT_DIRECTORY.test(entry.name)) continue;
226
+
227
+ const projectDir = join(sessionsRoot, entry.name);
228
+
229
+ let projectEntries;
230
+ try {
231
+ projectEntries = await readdir(projectDir, { withFileTypes: true });
232
+ } catch (error) {
233
+ warnings.push({
234
+ path: projectDir,
235
+ reason: `project directory not readable: ${error instanceof Error ? error.message : String(error)}`,
236
+ });
237
+ continue;
238
+ }
239
+
240
+ for (const file of projectEntries) {
241
+ throwIfAborted(signal);
242
+ if (!file.isFile() || !file.name.endsWith(SESSION_EXTENSION)) continue;
243
+
244
+ const path = join(projectDir, file.name);
245
+ const header = await readSessionHeader(path);
246
+ if (!header.ok) {
247
+ warnings.push({ path, reason: header.reason });
248
+ continue;
249
+ }
250
+
251
+ const transcripts = await collectSubagentTranscripts(containerOf(path), warnings, signal);
252
+ sessions.push(toMetadata({ path, ...header.values }, nestTranscripts(containerOf(path), transcripts, signal)));
253
+ }
254
+ }
255
+
256
+ sessions.sort(compareSessions);
257
+ warnings.sort(compareWarnings);
258
+
259
+ return { sessions, warnings };
260
+ }
package/src/schemas.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * TypeBox schemas and derived TypeScript types for the operations this package exposes.
3
+ *
4
+ * Contract: docs/tool-api.md
5
+ */
6
+
7
+ import { Type, type Static } from "@earendil-works/pi-ai";
8
+
9
+ /**
10
+ * Parameters for `listSessions`. Empty by design; filtering arguments are deferred.
11
+ */
12
+ export const ListSessionsParamsSchema = Type.Object({}, { additionalProperties: false });
13
+
14
+ /**
15
+ * Metadata for one session file, with its subagent children nested underneath.
16
+ *
17
+ * Recursive schemas in this TypeBox release use `Type.Cyclic` + `Type.Ref`. `id` stays an
18
+ * unformatted string on purpose: Pi mints UUIDv7 by default, but only constrains the id
19
+ * character set, so a custom session id is valid data and must not be rejected here.
20
+ */
21
+ export const SessionMetadataSchema = Type.Cyclic(
22
+ {
23
+ SessionMetadata: Type.Object(
24
+ {
25
+ id: Type.String({ description: "Session id from the file header" }),
26
+ path: Type.String({ description: "Absolute path to the session .jsonl file" }),
27
+ timestamp: Type.String({
28
+ format: "date-time",
29
+ description: "Header timestamp, ISO 8601, validated before the row is returned",
30
+ }),
31
+ cwd: Type.String({ description: "Absolute working directory from the header" }),
32
+ parentSessionPath: Type.Optional(
33
+ Type.String({
34
+ description: "Header parentSession value, copied verbatim (fork/clone lineage)",
35
+ }),
36
+ ),
37
+ subagentSessions: Type.Array(Type.Ref("SessionMetadata"), {
38
+ description: "Subagent transcripts launched from this session, in timestamp order",
39
+ }),
40
+ },
41
+ { additionalProperties: false },
42
+ ),
43
+ },
44
+ "SessionMetadata",
45
+ );
46
+
47
+ /** One skipped file: its path and a free-form reason. Not an enum. */
48
+ export const ListSessionsWarningSchema = Type.Object(
49
+ {
50
+ path: Type.String({ description: "Absolute path of the file or directory that was skipped" }),
51
+ reason: Type.String({ description: "Why the file was not returned as a session" }),
52
+ },
53
+ { additionalProperties: false },
54
+ );
55
+
56
+ /**
57
+ * Result of `listSessions`. `sessions` is ordered by timestamp ascending, oldest first,
58
+ * at every nesting level, with ties broken by path.
59
+ */
60
+ export const ListSessionsOutputSchema = Type.Object(
61
+ {
62
+ sessions: Type.Array(SessionMetadataSchema),
63
+ warnings: Type.Array(ListSessionsWarningSchema),
64
+ },
65
+ { additionalProperties: false },
66
+ );
67
+
68
+ export type ListSessionsParams = Static<typeof ListSessionsParamsSchema>;
69
+ export type SessionMetadata = Static<typeof SessionMetadataSchema>;
70
+ export type ListSessionsWarning = Static<typeof ListSessionsWarningSchema>;
71
+ export type ListSessionsOutput = Static<typeof ListSessionsOutputSchema>;
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Read and validate the session header (line 1) of a Pi session file.
3
+ *
4
+ * Only the first line is ever read, bounded to MAX_HEADER_LINE_BYTES. This module never
5
+ * opens a file through Pi's `SessionManager.open()`, which can migrate or repair history.
6
+ *
7
+ * Contract: docs/tool-api.md
8
+ */
9
+
10
+ import { open } from "node:fs/promises";
11
+
12
+ /** Maximum accepted length, in bytes, of a session header line. */
13
+ export const MAX_HEADER_LINE_BYTES = 4096;
14
+
15
+ /** RFC 3339 / ISO 8601 date-time, the shape Pi writes in session headers. */
16
+ const ISO_8601_TIMESTAMP =
17
+ /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})$/;
18
+
19
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
20
+
21
+ function isLeapYear(year: number): boolean {
22
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0;
23
+ }
24
+
25
+ /**
26
+ * Calendar check that `Date.parse` does not provide.
27
+ *
28
+ * `Date.parse("2026-02-30T08:00:00.000Z")` rolls over to March 2 instead of failing, so an
29
+ * impossible date would otherwise pass validation and reach the output as an untrustworthy
30
+ * timestamp string.
31
+ */
32
+ function isRealTimestamp(timestamp: string): boolean {
33
+ const parts = ISO_8601_TIMESTAMP.exec(timestamp);
34
+ if (!parts) return false;
35
+
36
+ const year = Number(parts[1]);
37
+ const month = Number(parts[2]);
38
+ const day = Number(parts[3]);
39
+ const hour = Number(parts[4]);
40
+ const minute = Number(parts[5]);
41
+ const second = Number(parts[6]);
42
+
43
+ if (month < 1 || month > 12) return false;
44
+
45
+ const daysInMonth = month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
46
+ if (day < 1 || day > daysInMonth) return false;
47
+
48
+ // Second 60 is allowed for a positive leap second; hour and minute are strict.
49
+ return hour <= 23 && minute <= 59 && second <= 60;
50
+ }
51
+
52
+ export type HeaderFailure = { ok: false; reason: string };
53
+
54
+ /** Field values taken from a validated header, before the caller adds `path`. */
55
+ export type SessionHeaderValues = {
56
+ id: string;
57
+ timestamp: string;
58
+ cwd: string;
59
+ parentSessionPath?: string;
60
+ };
61
+
62
+ export type HeaderResult = { ok: true; values: SessionHeaderValues } | HeaderFailure;
63
+
64
+ function describeError(error: unknown): string {
65
+ return error instanceof Error ? error.message : String(error);
66
+ }
67
+
68
+ /**
69
+ * Read the first line of a file, at most MAX_HEADER_LINE_BYTES long.
70
+ *
71
+ * A final line without a terminating newline is still returned. A first line longer than
72
+ * the bound fails rather than being truncated, so an over-long header can never be
73
+ * mistaken for a valid one.
74
+ */
75
+ export async function readFirstLine(path: string): Promise<{ ok: true; line: string } | HeaderFailure> {
76
+ let handle;
77
+ try {
78
+ handle = await open(path);
79
+ } catch (error) {
80
+ return { ok: false, reason: `could not open file: ${describeError(error)}` };
81
+ }
82
+
83
+ try {
84
+ const limit = MAX_HEADER_LINE_BYTES + 1;
85
+ const buffer = Buffer.allocUnsafe(limit);
86
+ let filled = 0;
87
+
88
+ while (filled < limit) {
89
+ const { bytesRead } = await handle.read(buffer, filled, limit - filled, filled);
90
+ if (bytesRead === 0) break;
91
+ filled += bytesRead;
92
+ }
93
+
94
+ const data = buffer.subarray(0, filled);
95
+
96
+ if (filled === 0) {
97
+ return { ok: false, reason: "file is empty" };
98
+ }
99
+
100
+ // The read buffer holds one byte past the bound, so a first line that reaches the end
101
+ // of it without a newline is too long. A newline at index 4096 means a 4096-byte line,
102
+ // which is exactly at the bound and accepted.
103
+ const newline = data.indexOf(0x0a);
104
+
105
+ if (newline === -1 && filled === limit) {
106
+ return { ok: false, reason: `first line is longer than ${MAX_HEADER_LINE_BYTES} bytes` };
107
+ }
108
+
109
+ // A file whose only line has no trailing newline: `filled` is within the bound here.
110
+ const line = newline === -1 ? data : data.subarray(0, newline);
111
+ return { ok: true, line: line.toString("utf8").replace(/^\uFEFF/, "") };
112
+ } catch (error) {
113
+ return { ok: false, reason: `could not read file: ${describeError(error)}` };
114
+ } finally {
115
+ await handle.close().catch(() => {});
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Turn a header line into session field values.
121
+ *
122
+ * Every returned row is trustworthy: `id`, `cwd`, and `timestamp` must be present and
123
+ * well-formed, and `parentSessionPath` must be a string when present. Anything else is a
124
+ * skip-with-warning, so callers never handle a partial row and sorting never meets an
125
+ * invalid date.
126
+ */
127
+ export function validateHeaderLine(line: string): HeaderResult {
128
+ if (line.trim() === "") {
129
+ return { ok: false, reason: "first line is empty" };
130
+ }
131
+
132
+ let header: unknown;
133
+ try {
134
+ header = JSON.parse(line);
135
+ } catch (error) {
136
+ return { ok: false, reason: `first line is not valid JSON: ${describeError(error)}` };
137
+ }
138
+
139
+ if (typeof header !== "object" || header === null || Array.isArray(header)) {
140
+ return { ok: false, reason: "first line is not a JSON object" };
141
+ }
142
+
143
+ const record = header as Record<string, unknown>;
144
+
145
+ if (record.type !== "session") {
146
+ return { ok: false, reason: `first line is not a session header (type: ${String(record.type)})` };
147
+ }
148
+
149
+ if (typeof record.id !== "string" || record.id === "") {
150
+ return { ok: false, reason: "session header has no id" };
151
+ }
152
+
153
+ if (typeof record.cwd !== "string" || record.cwd === "") {
154
+ return { ok: false, reason: "session header has no cwd" };
155
+ }
156
+
157
+ if (typeof record.timestamp !== "string" || !isRealTimestamp(record.timestamp)) {
158
+ return { ok: false, reason: "session header has no valid ISO 8601 timestamp" };
159
+ }
160
+
161
+ if ("parentSession" in record && typeof record.parentSession !== "string") {
162
+ return { ok: false, reason: "session header parentSession is not a string" };
163
+ }
164
+
165
+ const values: SessionHeaderValues = {
166
+ id: record.id,
167
+ timestamp: record.timestamp,
168
+ cwd: record.cwd,
169
+ };
170
+
171
+ if (typeof record.parentSession === "string" && record.parentSession !== "") {
172
+ values.parentSessionPath = record.parentSession;
173
+ }
174
+
175
+ return { ok: true, values };
176
+ }
177
+
178
+ /** Read line 1 of a session file and validate it as a session header. */
179
+ export async function readSessionHeader(path: string): Promise<HeaderResult> {
180
+ const line = await readFirstLine(path);
181
+ if (!line.ok) return line;
182
+ return validateHeaderLine(line.line);
183
+ }