pi-retrospect 0.1.0 → 0.3.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.
@@ -5,22 +5,69 @@ Verified against `@earendil-works/pi-coding-agent` **0.99.2** and
5
5
  **1.0.0** on 2026-10-02 — the shipped `dist/core/session-manager.js` and `pi-ai`
6
6
  `dist/types.d.ts` are byte-identical across those releases, so nothing here changed.
7
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.
8
+ Measurements come from one-off scripts kept in `scratch/` (not committed, not part of the suite)
9
+ run against one developer machine's session store, and are quoted as ratios or timings, never as
10
+ store totals; a store grows daily.
10
11
 
11
12
  ## Maintainer reference
12
13
 
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.
14
+ The sections below are the implementation contract for `listSessions` and `readSessionEntries` (the
15
+ exported functions behind the registered `list_sessions` and `session_entries` tools). Each states
16
+ *how* a guarantee from the caller half is produced; the caller half states *what* is guaranteed.
17
+ Sections that name neither operation describe `listSessions`.
16
18
 
17
19
  ### TypeBox schemas
18
20
 
19
21
  ```ts
20
22
  import { Type, type Static } from "@earendil-works/pi-ai";
21
23
 
24
+ export const CwdMatchSchema = Type.Union([Type.Literal("exact"), Type.Literal("sibling-prefix")], {
25
+ default: "exact",
26
+ /* description: what sibling-prefix is for, and that it reads no git metadata */
27
+ });
28
+
29
+ export const SessionSortFieldSchema = Type.Union(
30
+ [Type.Literal("timestamp"), Type.Literal("cwd"), Type.Literal("path"), Type.Literal("id")],
31
+ { default: "timestamp" },
32
+ );
33
+
34
+ export const SortDirectionSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")], {
35
+ default: "asc",
36
+ });
37
+
22
38
  export const ListSessionsParamsSchema = Type.Object(
23
- {},
39
+ {
40
+ cwds: Type.Optional(Type.Array(Type.String({ minLength: 1 }), { minItems: 1 })),
41
+ startTimestamp: Type.Optional(Type.String()),
42
+ endTimestamp: Type.Optional(Type.String()),
43
+ cwdMatch: Type.Optional(CwdMatchSchema),
44
+ includeCurrentSession: Type.Optional(Type.Boolean({ default: false })),
45
+ sortBy: Type.Optional(SessionSortFieldSchema),
46
+ sortDirection: Type.Optional(SortDirectionSchema),
47
+ limit: Type.Optional(Type.Integer({ minimum: 1 })),
48
+ },
49
+ { additionalProperties: false },
50
+ );
51
+
52
+ // `session_entries` repeats the same two shapes for its own rows, through one local helper so the
53
+ // four set filters cannot be written four different ways:
54
+ const ValueSet = (item: string, description: string) =>
55
+ Type.Array(Type.String({ minLength: 1, description: item }), { minItems: 1, description });
56
+
57
+ export const SessionEntriesParamsSchema = Type.Object(
58
+ {
59
+ sessionPath: Type.String({ minLength: 1 }),
60
+ startLineNo: Type.Optional(Type.Integer({ minimum: 1 })),
61
+ endLineNo: Type.Optional(Type.Integer({ minimum: 1 })),
62
+ ids: Type.Optional(ValueSet(/* … */)),
63
+ parentIds: Type.Optional(ValueSet(/* … */)),
64
+ startTimestamp: Type.Optional(Type.String()),
65
+ endTimestamp: Type.Optional(Type.String()),
66
+ types: Type.Optional(ValueSet(/* … */)),
67
+ messageRoles: Type.Optional(ValueSet(/* … */)),
68
+ search: Type.Optional(SearchFilter), // `{ terms: ValueSet(/* … */), caseSensitive?: boolean }`
69
+ limit: Type.Optional(Type.Integer({ minimum: 1 })),
70
+ },
24
71
  { additionalProperties: false },
25
72
  );
26
73
 
@@ -32,7 +79,7 @@ export const SessionMetadataSchema = Type.Cyclic(
32
79
  path: Type.String({ description: "Absolute path to the session .jsonl file" }),
33
80
  timestamp: Type.String({
34
81
  format: "date-time",
35
- description: "Header timestamp, ISO 8601, validated before the row is returned",
82
+ description: "Header timestamp as stored; a row is returned only when the session parser can read it",
36
83
  }),
37
84
  cwd: Type.String({ description: "Absolute working directory from the header" }),
38
85
  parentSessionPath: Type.Optional(
@@ -67,6 +114,9 @@ export const ListSessionsOutputSchema = Type.Object(
67
114
  );
68
115
 
69
116
  export type ListSessionsParams = Static<typeof ListSessionsParamsSchema>;
117
+ export type CwdMatch = Static<typeof CwdMatchSchema>;
118
+ export type SessionSortField = Static<typeof SessionSortFieldSchema>;
119
+ export type SortDirection = Static<typeof SortDirectionSchema>;
70
120
  export type SessionMetadata = Static<typeof SessionMetadataSchema>;
71
121
  export type ListSessionsWarning = Static<typeof ListSessionsWarningSchema>;
72
122
  export type ListSessionsOutput = Static<typeof ListSessionsOutputSchema>;
@@ -80,19 +130,60 @@ Recursive schemas use `Type.Cyclic` + `Type.Ref`; this TypeBox release has no
80
130
  `assertValidSessionId` only constrains the character set, so a custom id is valid Pi
81
131
  data and must not be rejected here.
82
132
 
83
- `format: "date-time"` is a real guarantee rather than documentation, because timestamps are
84
- validated before a row is returned (see Validation rules).
133
+ `format: "date-time"` is a real guarantee rather than documentation, because a row is returned only
134
+ when `src/timestamps.ts` can read its timestamp (see Validation rules).
135
+
136
+ Filter parameters are `Type.String()`, **not** `format: "date"`/`"date-time"`: this TypeBox
137
+ release does not enforce formats, so both shapes are gated in `src/timestamps.ts` instead.
138
+ `default` annotations on the enums document behavior; the code still applies the defaults
139
+ itself (`params.cwdMatch ?? "exact"`, `params.sortBy ?? "timestamp"`,
140
+ `params.sortDirection === "desc"`) because nothing guarantees the schema fills them in.
141
+ `Type.Integer({ minimum: 1 })` is a documentation-and-validation hint, and `buildQuery`
142
+ throws for a bad `limit` as well, so the exported function is safe when called directly.
143
+ `filters.ts` owns that check now, so a bad `limit` means the same thing in both tools; the entry
144
+ bounds and their reversals go through `entry-query.ts` for the same reason. The schema states the
145
+ requirement — positive integers, at least one value per set — and nothing more: how a host validator
146
+ gets a caller there is not this package's contract.
147
+
148
+ `minItems: 1` on a filter array is the only place an empty set is refused: the schemas reject it, and
149
+ the readers treat `ids: []` or `cwds: []` as selecting nothing rather than throwing, so a direct call
150
+ keeps the same meaning the tool boundary enforces. There is no `offset` on `session_entries` and
151
+ there must not be one added casually — a `startLineNo` test in `session-entries-tool.test.ts` asserts
152
+ its absence, because an offset counts filtered rows while a line bound counts physical ones, and only
153
+ one of the two survives a changed filter.
154
+
155
+ TypeBox **1.3.27** has no `Type.Nullable` and no `Type.Object` open-shape helper that keeps its
156
+ TypeScript type. Two idioms cover both gaps, and both appear in `src/schemas.ts`:
157
+
158
+ ```ts
159
+ // nullable field: a two-member union
160
+ const NullableString = (description: string) =>
161
+ Type.Union([Type.String(), Type.Null()], { description });
162
+
163
+ // arbitrary JSON: `Type.Unsafe`, because TypeBox infers `{}` from empty `properties`
164
+ export const JsonObjectSchema = Type.Unsafe<JsonObject>({
165
+ type: "object",
166
+ additionalProperties: true,
167
+ });
168
+ ```
169
+
170
+ `Type.Unsafe<JsonObject>` is not decoration: Pi types `structuredContent` as `JsonValue`, and a
171
+ `Record<string, unknown>` is not assignable to `JsonObject` (`unknown` is not a `JsonValue`), so the
172
+ weaker type fails `tsc` on the tool's return value. `JsonObject` is exported by `pi-ai`, which is
173
+ already a peer dependency.
85
174
 
86
175
  ### Implementation boundary
87
176
 
88
- The tool takes no parameters; the implementation takes the root, so tests can point it at a
89
- fixture tree.
177
+ The tool takes optional filter parameters; the implementation also takes the root, so tests
178
+ can point it at a fixture tree.
90
179
 
91
180
  ```ts
92
181
  export interface ListSessionsOptions {
93
182
  sessionsRoot: string;
94
183
  /** Aborted between filesystem operations. */
95
184
  signal?: AbortSignal;
185
+ /** Absolute session file of the session the caller runs inside, from the host. */
186
+ currentSessionPath?: string;
96
187
  }
97
188
 
98
189
  export async function listSessions(
@@ -101,6 +192,39 @@ export async function listSessions(
101
192
  ): Promise<ListSessionsOutput>;
102
193
  ```
103
194
 
195
+ Three modules split the work:
196
+
197
+ - `src/list-sessions.ts` — discovery. Walks the root, reads and validates headers, nests
198
+ transcripts, sorts warnings, and then excludes the current session and applies the query. It
199
+ never decides what "current" means: the tool layer reads it from `ctx.sessionManager` and hands
200
+ it over as `options.currentSessionPath`, so this module has no Pi context to depend on and the
201
+ rule is testable through a plain function call.
202
+ - `src/query.ts` — the query. `buildQuery(params)` returns `{ matchesRoot, compareRoots,
203
+ limit }` and throws on bad parameters; `excludeSessionTree(rows, path)` drops the row whose
204
+ resolved `path` matches, with its subtree; `applyQuery(roots, query)` filters, sorts, and
205
+ slices the **top level only**. It receives already-validated rows, so every parameter rule
206
+ is testable through `listSessions` without touching disk.
207
+ - `src/timestamps.ts` — what a time string means, and the only module that says so. No fs, no
208
+ schema, no error strings: `parseSessionInstant` is the shared parser and validator for headers
209
+ and entries, and `parseTimeBoundary` gates and classifies a filter bound into a day or an
210
+ instant.
211
+
212
+ Two more modules serve the parameters that both tools share, so one caller does not meet two
213
+ dialects of the same word:
214
+
215
+ - `src/filters.ts` — `timeWindowOf({ startTimestamp, endTimestamp })` builds the window on top of
216
+ `timestamps.ts`, `withinWindow(window, at)` asks the one comparison question, and
217
+ `requireLimit(value)` checks the cap. It owns the user-facing messages (`query.ts` used to
218
+ build them alone) because a bound that reads wrong in one tool must read wrong in the other.
219
+ It knows nothing about rows.
220
+ - `src/entry-query.ts` — the `session_entries` sibling of `query.ts`. `buildEntryQuery(params)`
221
+ returns `{ matches, limit }` and throws on a bad parameter. Like `query.ts` it sees only rows
222
+ the reader already built, so every filter rule is testable through `readSessionEntries`.
223
+
224
+ `buildQuery` runs before the first `readdir`, which is why a malformed timestamp or a
225
+ reversed range fails on an unreadable root rather than returning `sessions: []` plus a
226
+ warning: a caller's typo must not look like absent history.
227
+
104
228
  The registered wrapper supplies the root. Pi's `getSessionsDir()` is **not** re-exported
105
229
  from the package root; `getAgentDir()` is, and it honors the agent-dir environment
106
230
  override. So `src/index.ts` computes:
@@ -120,14 +244,153 @@ so tests can construct the registered shape against a fixture root instead of th
120
244
  mirrors `structuredContent`; `outputSchema` is declared so codemode callers receive JSON
121
245
  instead of prose.
122
246
 
247
+ The listing tool also reads the fifth `execute()` argument, Pi's tool context, and asks
248
+ `ctx.sessionManager.getSessionFile()` for the current session file. It asks per call and caches
249
+ nothing: `/new`, `/resume`, and `/fork` change which file is current inside one process, and a stale
250
+ path would exclude the wrong transcript. The access chain is written `ctx?.sessionManager?.getSessionFile()`
251
+ even though Pi types all three as present, because the factory-built tool is also executed by tests
252
+ and by direct SDK calls that hand over no session context — and "no context" must mean "no current
253
+ session to exclude", not a crash inside a read-only tool. The tool never interprets the value: it
254
+ passes it straight to `listSessions` as `options.currentSessionPath`, so the rule lives in the query
255
+ layer with the other row rules.
256
+
257
+ ### `session_entries` boundary
258
+
259
+ `src/session-entries.ts` exports `readSessionEntries(params, { sessionsRoot, signal })`;
260
+ `src/session-entries-tool.ts` is the factory-wrapped tool, same shape as the listing. Confinement,
261
+ header checks, and line mapping live in that one module; the filter and cap parameters live in
262
+ `src/entry-query.ts`, which the reader calls **before** it resolves the path — a nonsense bound is
263
+ the caller's mistake and must outrank both a confinement failure and a missing file. The `text`
264
+ projection lives on its own in `src/entry-text.ts`: it is a pure function of the parsed line, so every
265
+ mapping rule is testable without a file, and the reader cannot accidentally make a row's *shape*
266
+ depend on whether it had text.
267
+
268
+ `search` is compiled by `requireSearch` in `src/entry-query.ts`, beside the other entry filters,
269
+ because it is a filter over a returned field and not a second projection: it validates the term set,
270
+ folds the terms **once** per call rather than once per row, and returns a predicate over `text`. It is
271
+ the only filter that is not exact, which is why the terms of a `search` and the values of an `ids` set
272
+ are allowed to mean different things about case. Keeping the fold in the query layer is also what
273
+ keeps the empty-term refusal on the same footing as the line and timestamp bounds: all of them throw
274
+ before any file is opened.
275
+
276
+ Pi validates the parameter schema with Ajv *before* `execute()` and coerces toward the declared type on
277
+ the way (verified 2026-10-06 on the live store): a scalar where an array is declared becomes a
278
+ one-item array, and `null` becomes the string `"null"` rather than an absent value. So
279
+ `requireSearch`'s own `Array.isArray` and per-term `typeof` guards are unreachable through the tool —
280
+ the validator has already shaped the value — and exist for `readSessionEntries` as the library entry
281
+ point, which no validator sits in front of. `test/session-entries-filters.test.ts` reaches them that
282
+ way: through the library surface, with a term set that is not an array. What the validator does *not*
283
+ undo is `minItems` and `minLength`, so both refusals still land on the tool path, and the
284
+ coercion means a caller who writes `search: { terms: null }` searches for `null` instead of turning
285
+ search off. That is a documented sharp edge, not a guard to add: absence is spelled by omitting the
286
+ parameter, and the schema cannot tell a coerced `null` from a term the caller meant.
287
+
288
+ **Filters narrow the result, never the scan.** The loop builds every row `toEntry` accepts and then
289
+ drops the ones `query.matches` rejects or `query.limit` has already filled, so a filtered row costs
290
+ no warning and a limited read still walks to the last line. That is deliberate: `warnings` is a
291
+ statement about the file, and a read that stopped at `limit` would quietly make it a statement about
292
+ the page. What the drop does save is memory — a discarded row's `raw` is never retained, so the
293
+ result is bounded by the rows it keeps rather than by the file's size, which is the half of the
294
+ `raw` problem this feature answers. The other half (a 2 MB single entry) is not answered: there is
295
+ no per-row budget. See TODOs.
296
+
297
+ **Confinement** (`resolveSessionPath`) is the only security-relevant rule:
298
+
299
+ 1. `sessionPath` must be a non-empty string and `isAbsolute`. Nothing is resolved against the
300
+ process cwd, so the same call means the same file from any directory a script happens to sit in.
301
+ 2. `realpath(sessionsRoot)` runs first: a missing root is `sessions root is not readable`, matching
302
+ the way the listing reports it.
303
+ 3. `realpath(sessionPath)` runs second. If it throws, the path is checked **lexically**
304
+ (`within(root, resolve(sessionPath))`) purely to choose the message: outside the root is a
305
+ confinement error, inside it is `could not read session file`. A missing file must not look like
306
+ a policy violation.
307
+ 4. If it resolves, `within(root, file)` decides. This is the pass a symlink cannot fake: containment
308
+ is computed on resolved targets, so a link planted inside the root and aimed at `/etc/passwd`
309
+ resolves outside and throws. `within` treats the root itself as outside — a directory is not a
310
+ session file.
311
+ 5. The **resolved** path is what `open()` opens, so replacing `sessionPath` with a different symlink
312
+ after step 3 cannot redirect this read. A target swapped after resolution is not defended (no
313
+ `O_NOFOLLOW`), which is the residual window; it only matters against a concurrent writer inside the
314
+ operator's own sessions root, which is not the threat this confinement exists for.
315
+
316
+ **Header check** (`inspectHeaderLine`) is deliberately weaker than `validateHeaderLine` in
317
+ `src/session-metadata.ts`, which the listing uses. The listing returns `id`, `cwd`, and `timestamp`,
318
+ so it requires them; this operation returns none of them and reads only two facts from line 1 — that
319
+ `type === "session"` and what `version` says. Reusing the strict validator would make an old header
320
+ with an empty `cwd` hide a file whose entries are perfectly readable. What it does reject: a blank or
321
+ unparseable line 1, a line that is not an object, a `type` that is not `"session"`, and a `version`
322
+ that is present but not a positive integer.
323
+
324
+ **Line reading** streams instead of slurping: `FileHandle#readLines()` (Node's readline) yields one
325
+ line at a time, so the scan costs the entries it produces rather than the file's size in memory — on a
326
+ 47 MB / 12k-entry session, measured on Node 22.22.1, peak RSS 246 MB → 148 MB and wall time
327
+ 70 ms → 106 ms. The saving is roughly two copies of the file (the whole string plus the lines array),
328
+ not the file: `raw` retains every parsed line whatever the reader does. That makes it a stress-case
329
+ number — on a real 1.5 MB / 424-entry session the same measurement is 80 MB → 73 MB and 8 ms → 14 ms,
330
+ so ordinary sessions feel no difference either way, and the live files checked read
331
+ byte-identically. Node decides where a
332
+ line ends, and it counts `\n`, `\r\n`, **and a lone `\r`** as breaks; `crlfDelay` does not change that,
333
+ only whether a `\r\n` pair is one break or two. Pi writes `\n`, so this is `\n` counting for every file
334
+ Pi produces, and the `lineNo` of a hand-edited file with a stray `\r` follows readline rather than
335
+ `wc -l`. Deliberate trade: keeping the old LF-only rule meant re-implementing the split over chunk
336
+ streams, and the two lines that ever disagreed are exactly the ones the reader would only warn on
337
+ anyway. A terminating break produces no final line, a blank line does yield, and the counter is
338
+ incremented per line rather than derived from an index, so `lineNo` stays physical across skipped rows.
339
+ The BOM is stripped from line 1 only, where a whole-file strip would have put it, and an empty file
340
+ yields **no** lines — the loop's `headerRead` flag is what turns "there was no line 1" into
341
+ `not a Pi session file: first line is empty`, the message the old `splitLines` produced directly.
342
+ The header is consumed as line 1, so its `version` gates `addressable` before any later line is read;
343
+ `legacy_version` is pushed then, which keeps it first in `warnings`. The handle is closed in a
344
+ `finally`, and a read failure is reported as `could not read session file`, which is what a directory
345
+ name costs (Node opens a directory, then fails the first read with `EISDIR`).
346
+
347
+ The switch was checked by running the whole-file reader and this one over a 26-file corpus — blank
348
+ lines anywhere, CRLF, LF-only tails, BOM on line 1 and on a later line, a lone CR, unparseable and
349
+ non-object lines, a stray header row, v1/v2/versionless headers, tabs, multi-byte content, a 200 KB
350
+ line — and diffing `entries` plus `warnings`. 25 files matched exactly; the lone CR was the only
351
+ difference, and it is the accepted rule above, not an accident.
352
+
353
+ **Entry acceptance** (`toEntry`) separates "addressable" from "describable":
354
+
355
+ | Field | Rule |
356
+ | --- | --- |
357
+ | `type` | must be a non-empty string and not `"session"`, else `invalid_entry` — a row nobody can name, or a header that has drifted off line 1 |
358
+ | `timestamp` | must be a string `parseSessionInstant` can read, else `invalid_entry` — a row nobody can place in time |
359
+ | `id` | non-empty string **and** a header `version` of at least 2, else `null`. Never rejects a line |
360
+ | `parentId` | same rule as `id`: non-empty string in a v2+ file, else `null` (a root, an absent field, or a non-string all read as null) |
361
+ | `messageRole` | the `message.role` string when `type === "message"`, else `null` |
362
+ | `text` | `entryText(raw)` from `src/entry-text.ts` — the entry's primary human-readable body, or `null`; never a rejection reason, since a row with no text is still a row. The `system` role is the one case that reads two fields (`message.content`, then the non-`null` values of `message.sections`), because Pi stores the prompt in the sections map with `content: ""` |
363
+ | `raw` | the parsed line, cast to `JsonObject` — safe by construction, it came from `JSON.parse` |
364
+
365
+ `id` and `parentId` cannot be acceptance criteria — a missing one is a null, not a rejected row — and
366
+ in a version 1 file neither is citable, so `addressable` (header `version >= 2`) gates both. An
367
+ extension-written or partially migrated entry that lacks them in a v2+ file also simply reads as null.
368
+ `parseSessionInstant` is both the acceptance check and the instant the sorters compare, shared by
369
+ the header check in `session-metadata.ts` and the entry check in `session-entries.ts`, so an
370
+ accepted string can never produce a `NaN` comparison. It reads `2026-02-30` as March 2 and lets that
371
+ timestamp reach the output, on purpose: refusing a rollover would mean keeping a second grammar
372
+ beside the one that orders the rows, and the two would drift.
373
+
374
+ **Version 1 is reported, never migrated.** `migrateSessionEntries` mints fresh random ids on every
375
+ call (`generateId` is collision-checked within one pass only), so a migrated id would look like a
376
+ durable citation and resolve to nothing on the next read. The reader therefore reports `id: null` and
377
+ `parentId: null` for every version 1 row **even when the line stores them** — `migrateV1ToV2` assigns
378
+ `entry.id = generateId(ids)` unconditionally, so Pi replaces a stored v1 id the next time it opens the
379
+ file — plus one `legacy_version` warning with `lineNo: null`. `raw` keeps what was actually written,
380
+ so nothing is hidden; the null is about citability, not about the bytes. `SessionManager.open()` is
381
+ never used for the same reason it is never used in the listing: it rewrites.
382
+
383
+ **Abort** is checked before resolving the path and again per line, so a huge file can be abandoned
384
+ mid-map rather than only before or after it.
385
+
123
386
  ### Discovery rules
124
387
 
125
388
  1. **Project directories:** only immediate subdirectories of the root whose name matches
126
- `--<slug>--`. Other entries — measured here: `permission-forwarding/` — are ignored by
389
+ `--<slug>--`. Other entries — the live store holds `permission-forwarding/` — are ignored by
127
390
  rule, not by accident.
128
391
  2. **Top-level sessions:** `*.jsonl` files *directly* inside a project directory. Not a
129
392
  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
393
+ handful exist in the live store, but they duplicate real children) out of the result
131
394
  set: they are neither roots (not directly in a project dir) nor children (not under a
132
395
  parent-stem directory).
133
396
  3. **Children of a session:** every `session.jsonl` at any depth under a directory named
@@ -142,12 +405,12 @@ instead of prose.
142
405
  transcript is never reported under two parents. When a nested launch does not sit under
143
406
  its parent's container, it stays attached to the nearest session that does — the
144
407
  documented fallback for the unverified grandchild convention. Recursion is implemented
145
- generically and unverified: the store measured here holds no grandchild transcripts at
408
+ generically and unverified: the live store holds no grandchild transcripts at
146
409
  all, and every child transcript observed sits at depth `run-0`. See TODOs.
147
410
  6. **Header read:** only line 1 of each file is parsed, bounded to **4 KiB**
148
411
  (`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.
412
+ deliberately tighter than Pi's own 1 MiB `MAX_SESSION_HEADER_SCAN_BYTES`: live-store
413
+ headers are ~150 bytes, and a header over 4 KiB is not a session we can trust.
151
414
  A line of exactly 4096 bytes is accepted; a file whose last line has no terminating
152
415
  newline is read normally.
153
416
  7. **Symlinks are not followed.** Discovery stays inside `sessionsRoot`. Implemented by
@@ -183,19 +446,73 @@ resolution, normalization, or existence check. A `parentSession` present but not
183
446
  is a skip-plus-warning; an empty-string `parentSession` is treated as absent and omitted
184
447
  from the row. `path` is absolute because `sessionsRoot` is resolved with `path.resolve()`
185
448
  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.
449
+ it. Timestamp acceptance is `parseSessionInstant` in `src/timestamps.ts`, which is the same call the
450
+ sorters use, so an accepted timestamp cannot compare as `NaN`. It does not check the calendar:
451
+ `"2026-02-30T08:00:00.000Z"` rolls over to March 2 and reaches the output as the string the file
452
+ stored. That is the deliberate cost of one grammar — the check and the parser used to disagree about
453
+ `":60"`, `"+99:99"`, and `24:00`.
189
454
 
190
455
  ### Ordering rules
191
456
 
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.
457
+ The **top level** follows `sortBy` + `sortDirection`, defaulting to `timestamp` ascending.
458
+ A non-`timestamp` field falls back to `timestamp` and then to `path`; a `timestamp` sort
459
+ falls back straight to `path`, which is the documented unique handle, so the comparison is
460
+ total and a run is reproducible. `desc` negates the finished comparison — tie-breaks flip
461
+ with it, which is what "reverse the order" means to a caller. String comparisons are
462
+ codepoint-order `<`/`>`, never `localeCompare`, so ordering does not change with the
463
+ machine's locale.
464
+
465
+ **Children are exempt**: `subagentSessions` is always timestamp ascending, ties by `path`,
466
+ because a parent's runs must read in launch order. Sorting therefore happens twice — inside
467
+ `nestTranscripts` for children, inside `applyQuery` for roots — and `path` is absolute in
468
+ both because the sessions root is resolved with `path.resolve()` before the walk.
469
+
470
+ `warnings` are sorted by `path` (then `reason`) so a run is reproducible regardless of
471
+ `readdir` order, and they are never filtered: they describe the scan, not the kept subset.
472
+
473
+ ### Filter rules
474
+
475
+ - **Scope.** `matchesRoot` is applied to top-level rows only, so a kept parent always
476
+ arrives with its full tree. This is the alternative to pruning children, which would make
477
+ "sessions in this range" unspeakable whenever a child's own `cwd` or timestamp differs.
478
+ - **Dates are calendar days in the host timezone.** A date-only bound becomes the two local
479
+ midnights around it — `new Date(y, m-1, d)` and `new Date(y, m-1, d+1)` — and the day is then
480
+ compared with `startMs <= at && at < endMs`, so the day is included whole. `23:59:59.999` was
481
+ rejected as a cut because a header carrying more precision would silently vanish, and adding
482
+ 86 400 000 ms to the start was rejected because a daylight-saving day is 23 or 25 hours long and
483
+ the window would stop short of the date the caller named. Pi writes header timestamps with
484
+ `new Date().toISOString()` (196 of 196 timestamps in the live store end in `Z`), so the
485
+ zone can move a bound but never a stored instant.
486
+ - **Date-times are gated by shape, not by calendar.** `ISO_BOUND` in `src/timestamps.ts` admits an
487
+ ISO 8601 date, optionally with a time, optional seconds and fraction, and an optional offset, so
488
+ `1/2/2026`, `Jan 2 2026`, and `12345` throw instead of resolving to dates nobody meant. A naive
489
+ `"2026-02-01T12:30:00"` is then read in the local zone, which is the operator's chosen trade: a
490
+ caller in a timezone asking about its own afternoon, accepted at the price of a bound that is not
491
+ portable between machines. Values carrying `Z` or `±HH:MM` are one instant everywhere. Nothing
492
+ checks the calendar any more — `2026-02-30` rolls over, and `2026-13-01` has no instant for
493
+ `Date.parse` to find and so still throws.
494
+ - **`sibling-prefix` is lexical.** Trailing separators are stripped (`/repo/app` equals
495
+ `/repo/app/`), then a candidate matches if it equals the requested path or shares its
496
+ `dirname()` and has a basename starting with `<requested-basename>-`. No `git` call, no
497
+ `realpath`, no case folding: path comparisons stay exactly as headers recorded them.
498
+ - **`limit` caps output, not work.** Headers are all read first; there is no index.
499
+ - **Current-session exclusion is a path rule, applied first.** `listSessions` calls it only when
500
+ `params.includeCurrentSession !== true` and `options.currentSessionPath` is a non-empty string;
501
+ `excludeSessionTree` then compares `resolve()` of both sides and copies rows rather than mutating
502
+ them. The host fact is deliberately not a parameter: a `currentSessionPath` in the schema would let
503
+ a model exclude any session it can name, and `"this one, by the way"` is a fact only Pi has. A miss
504
+ is silence, not a fallback — there is no match by `id`, by `cwd`, or by recency, because dropping an
505
+ unrelated transcript on a shared id is worse than leaving the current one in. Like `sibling-prefix`,
506
+ the comparison is lexical: no `realpath`, no case folding. A hard link or a differently cased
507
+ spelling of the same file on a case-insensitive volume is not detected, and a copy under a second
508
+ path survives — which is the point of matching the file rather than the id.
509
+ - **The tree is pruned, not reparented.** A matched node's `subagentSessions` go with it. Children
510
+ describe who launched them, so promoting a grandchild would claim a delegation that never happened,
511
+ and leaving it at the top level would claim it was a parent session.
512
+ - **Errors are throws.** A bound that fails the ISO shape gate, one with no instant behind it,
513
+ a range ending before it starts, and `limit < 1` each throw, which the tool layer turns into a
514
+ failed tool result. A rollover (`"2026-02-30"`) and a naive date-time do not throw — they mean
515
+ March 2 and the host zone. Matching nothing is not an error.
199
516
 
200
517
  ### Missing or empty root
201
518
 
@@ -225,13 +542,43 @@ defineTool({
225
542
  exposure: "codemode",
226
543
  annotations: { readOnlyHint: true },
227
544
  });
545
+
546
+ defineTool({
547
+ name: "session_entries",
548
+ label: "Session Entries",
549
+ description: "...",
550
+ parameters: SessionEntriesParamsSchema,
551
+ outputSchema: SessionEntriesOutputSchema,
552
+ exposure: "codemode",
553
+ annotations: { readOnlyHint: true },
554
+ });
228
555
  ```
229
556
 
557
+ `src/index.ts` registers both against the same `join(getAgentDir(), "sessions")` root. The
558
+ `session_entries` description must keep stating what a caller cannot infer from the parameter
559
+ names alone: that the path is confined to the sessions root, that line 1 is the header and is never
560
+ returned, that `raw` is the whole line and can be megabytes, that `text` is a projection rather than a
561
+ copy of it and what it deliberately leaves out, that entries arrive in file order with
562
+ every branch included and order is not configurable, that filters are ANDed with OR inside one array
563
+ and match the returned field exactly, that a `null` field matches no array value, that warnings
564
+ describe the whole file whatever the filters say, and that version 1 rows come back with null ids. For
565
+ `search` it must also say what the field cannot reach (`raw`, so thinking, tool calls, images, and a
566
+ bash run's output), that a null `text` is never a hit, and that a `system` row's `text` is a rendered
567
+ prompt — the hits a caller does not expect from a word like "tool".
568
+
230
569
  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.
570
+ shape: that the default order is oldest-first so `sessions.at(-1)` is the newest, that
571
+ filters and ordering act on top-level sessions while children arrive whole and in launch
572
+ order, that a date-only `endTimestamp` means the whole day in the host timezone, that `sibling-prefix` is a
573
+ path heuristic rather than git detection, that nesting is expressed by `subagentSessions`
574
+ while `parentSessionPath` is fork lineage, that the current session is dropped by default and
575
+ `includeCurrentSession: true` is what asks for it back, and that an empty list plus warnings means
576
+ unreadable rather than absent history. It currently does not say that `path` is the handle
577
+ to key on — see TODOs.
578
+
579
+ The exclusion belongs in the description precisely because the default is invisible: a caller who
580
+ does not know it reads `{ sortDirection: "desc", limit: 1 }` as "this conversation" when it means
581
+ "the one before this", and the difference is the whole point of a retrospective.
235
582
 
236
583
  `exposure`, `annotations`, and `outputSchema` require **Pi >= 0.99.0** (added
237
584
  2026-09-29). Peer ranges stay `"*"` because that is Pi's stated convention for
@@ -263,8 +610,32 @@ Warnings (2)
263
610
  - No row cap and no truncation: the list is bounded by the number of sessions, and the JSON
264
611
  is available to scripts regardless.
265
612
 
613
+ `session_entries` renders an index of lines and never a payload — one `lineNo type role id` bullet
614
+ per row, with `raw` left out entirely because a single entry can exceed the context window, and
615
+ `text` left out for the same reason in miniature: it is bounded only by the entry it came from
616
+ (51 KB max, and a `system` row averages 16.7 KB), so a row's rendered length must not depend
617
+ on its payload:
618
+
619
+ ```
620
+ Entries (2)
621
+ - 2 message user aaaa1111
622
+ - 3 model_change
623
+
624
+ Warnings (2)
625
+ - file legacy_version: session version 1: entry ids are not durable, so id and parentId are null
626
+ - line 7 invalid_json: line is not valid JSON: Unexpected end of JSON input
627
+ ```
628
+
629
+ A null `messageRole` or null `id` simply leaves that column out rather than printing `null`. The
630
+ warning prefix is `file` or `line N`, matching the `lineNo: null` / `lineNo: n` split in the JSON.
631
+
266
632
  ### Testing policy
267
633
 
634
+ The suite runs on Vitest (`npm run test`, `npm run coverage` for the v8 report over every
635
+ `src` module); `npm run check` is the gate that runs both the suite and `tsc --noEmit`. Tests
636
+ are TypeScript and live in `test/*.test.ts`, and `tsconfig.json` includes `test/**/*.ts` so
637
+ the suite is type-checked with the source.
638
+
268
639
  Committed synthetic fixtures under `test/fixtures/sessions/`, generated by
269
640
  `test/fixtures/generate.mjs` (`node test/fixtures/generate.mjs` rebuilds the tree; both are
270
641
  in the source repository, not in the npm tarball). The layout reproduces the real one —
@@ -277,18 +648,80 @@ deliberately broken file per warning branch. Fixture headers are hand-written; n
277
648
  session data is committed.
278
649
 
279
650
  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
651
+ containment, one row per run directory, symlink and non-slug exclusion, warning coverage,
652
+ and the parameter rules.
653
+
654
+ `test/list-sessions-filters.test.ts` covers parameters. It reads the committed fixtures for
655
+ shapes the walk already produces, and builds throwaway trees under `test/tmp/filters/`
656
+ (gitignored, written by the test) for grids the committed fixtures deliberately do not hold:
657
+ worktree-named siblings, timestamp boundaries at midnight and at `23:59:59.999`, and parent
658
+ rows with children whose `cwd` and timestamp fall outside the filter. Keeping those grids
659
+ out of `test/fixtures/sessions/` protects the whole-store assertions in
660
+ `test/list-sessions.test.ts`, which count rows and warnings exactly.
661
+
662
+ `test/tmp/` is generated state: nothing in it is committed, and no run may depend on what an
663
+ earlier run left behind. Three mechanisms enforce that, from strongest to weakest. **Every
664
+ scratch consumer owns its precondition** — `makeStore` removes its tree before rebuilding it,
665
+ `session-metadata.test.ts` truncates each numbered file as it writes it, and the empty-root
666
+ and absent-store cases in `test/list-sessions.test.ts` purge the path they assert on before
667
+ asserting, so the guarantee holds per test and survives a crashed run or a single-file run.
668
+ **`test/global-setup.ts`**, registered as Vitest `globalSetup`, removes
669
+ `test/tmp/` once before any test file is collected, which extends the fresh-tmp guarantee to
670
+ `npm test`, `npm run coverage`, `vitest run <file>`, and CI rather than only to the
671
+ `npm run check` entry point. **`npm run clean`** — the first step of `npm run check` — removes
672
+ `test/tmp/` for humans; it touches nothing else, so `coverage/` and `dist/` survive it, and
673
+ there is no teardown purge so a failing run's artifacts stay on disk to inspect. Verified both
674
+ ways: the suite is green with `test/tmp/` absent, as on a fresh clone, and green with stale
675
+ session trees planted under `test/tmp/`.
676
+
677
+ Caveat, and the reason the per-test purge is the primary mechanism rather than the hook: a
678
+ start-of-run purge of a shared root is unsafe against **two Vitest runs in the same checkout**
679
+ — the later run wipes the earlier one's scratch mid-flight. Run one suite at a time per worktree.
680
+
681
+ Confirmed against the **live** store with a one-off `scratch/` script: every parent and child transcript
284
682
  discovered, 0 warnings, unique paths and ids, ordering correct, and the whole walk
285
683
  completes in roughly 200 ms. Running subagent workflows here is still the way to grow real
286
684
  parent/child trees for spot-checks — see TODOs.
287
685
 
686
+ `session_entries` is covered by `test/session-entries.test.ts` (confinement, header rules, physical
687
+ line numbers, arbitrary `raw`, the three warning codes, v1 nulls, abort),
688
+ `test/entry-text.test.ts` (the whole `text` mapping: each qualifying type and role, the content-array
689
+ join, what is excluded — thinking, tool calls, images, a bash execution's output, a system message's
690
+ tool loadout, state-only and unknown kinds — and the null-instead-of-empty-string rule; the `system`
691
+ rule on its own, including a `null` removal marker and a patch row; and a describe block that pins
692
+ the `system` projection against `getSystemMessageText` from `@earendil-works/pi-ai` on well-formed
693
+ messages, so the mirror cannot drift silently and neither `raw` nor a live store is needed to catch it),
694
+ `test/session-entries-filters.test.ts` (every filter parameter: inclusive line bounds, exact and
695
+ case-sensitive sets, null fields that match nothing, the literal `search` over `text` with its
696
+ case-insensitive default and its case-sensitive opt-in and its refusal of an empty term set, the shared
697
+ timestamp window, a `limit` that does not shorten the scan, AND across categories with OR inside one
698
+ array, and pagination by `startLineNo`), and
699
+ `test/session-entries-tool.test.ts` (registration shape, the filter parameter declarations, open
700
+ `raw` subschema, the required nullable `text` declaration, structured output equality, filters passed
701
+ through `execute`, rendered rows, entry
702
+ point registering both tools), plus a describe block in
703
+ `test/content.test.ts` for `renderSessionEntriesContent` (row columns, no `raw` and no `text` leak, the
704
+ empty header, and the `file` versus `line N` warning prefixes). Its fixtures are throwaway files under
705
+ `test/tmp/session-entries*/` written by the tests themselves, not entries in
706
+ `test/fixtures/sessions/`: adding a multi-entry file there would change the row and warning counts
707
+ that `test/list-sessions.test.ts` asserts exactly.
708
+
709
+ Live-store spot-checks are one-off scripts under `scratch/` — outside the suite, outside the
710
+ published tarball, never asserted on, and not committed. They read the real session store, which is
711
+ where every live-store number in these docs comes from. What they established: a recursive walk of
712
+ the root found 215 `.jsonl` files, of which 204 read with **0 warnings** and the other 11 — every
713
+ `subagent-artifacts/*_transcript.jsonl` dump in the store, counted independently — refused by the
714
+ header check, with no other error class appearing. Reading every parent session together: 145 files,
715
+ 15,928 entries, 0 warnings, ~250 ms. `raw` came back within a hair of the file size (2.46 MB from a
716
+ 2.4 MB session), which is the number behind the unbounded-`raw` limitation, and the `text` coverage
717
+ ratios in "The `text` projection" come from the same kind of run. These counts drift while the
718
+ operator works, so re-measure rather than trusting the totals.
719
+
288
720
  ### Implementation guarantees
289
721
 
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
722
+ Restated from the caller half as properties a change must not break: a total, reproducible
723
+ order (top level per `sortBy`/`sortDirection` with `timestamp` then `path` tie-breaks,
724
+ children always timestamp ascending); `subagentSessions` always present; each file
292
725
  reported at most once; containment-only child linkage with no structural field connecting
293
726
  parents to children (Pi's `SessionHeader.parentSession` means fork/clone lineage, verified
294
727
  against `pi-subagents` 0.74.0); `path` as the stable handle because `id` may repeat across
@@ -296,24 +729,92 @@ against `pi-subagents` 0.74.0); `path` as the stable handle because `id` may rep
296
729
  history; and orphaned `--<slug>--` stems left unvisited, so only files reachable from a
297
730
  discovered session are considered.
298
731
 
732
+ For `session_entries`: `sessionPath` is confined to the sessions root by `realpath` containment on
733
+ both sides, so no spelling or symlink route reaches a file outside it; line 1 is validated as a
734
+ session header and never returned; `lineNo` is the physical line and survives skipped lines (a line
735
+ break is LF, CRLF, or a lone CR — Node's readline rule, which is `\n` counting for every file Pi
736
+ writes); nothing is migrated, repaired, or written; `id` and `parentId` are null rather than fabricated whenever
737
+ the file cannot supply a citable one — an absent id, and *any* id in a file older than version 2, which
738
+ Pi replaces on migration; every returned row has a real `type` and a validated `timestamp`;
739
+ unknown types, unknown roles, and unknown fields pass through `raw` untouched; one skipped line
740
+ produces exactly one warning; and **the filters cannot change either the numbering or the warning
741
+ set** — they select rows from the read the reader already made, so a line bound, a set filter, a
742
+ text search, a
743
+ timestamp window, and a `limit` all read the same file and report the same skips.
744
+
299
745
  ---
300
746
 
301
747
  ## TODOs
302
748
 
303
- - **Verify grandchild nesting.** Every child transcript in the store measured here sits at
749
+ - **Verify grandchild nesting.** Every child transcript in the live store sits at
304
750
  `<slug>/<parent-stem>/<launch-uuid>/run-0/session.jsonl`; none launched their own
305
751
  subagent, so the recursive case is unexercised. Once a nested launch exists, confirm the
306
752
  convention — whether a child's own stem directory appears as `…/run-0/<child-stem>/…` —
307
753
  and that containment-based grouping does not attach a deep transcript to two parents.
754
+ - **Bound `session_entries` `raw` per row.** The row range and the cap exist now — `startLineNo` /
755
+ `endLineNo`, the set filters, `limit` — and a dropped row's `raw` is not retained, so a filtered
756
+ read is bounded by the rows it keeps. What is not bounded is one row: 2.46 MB of `raw` came back
757
+ from a 2.4 MB session, and a single entry can exceed the context window on its own. Deferred, not
758
+ chosen: a byte budget with a continuation, or a `raw` that is omitted unless asked for. The
759
+ decision that follows a cap: a capped read reported as a warning, as a `complete: false` field, or
760
+ as nothing at all.
761
+ - **Decide whether a bounded read should say so.** `limit` and `endLineNo` silently return a prefix,
762
+ which is the behavior a caller chose, but nothing in the result distinguishes "the whole file" from
763
+ "the first 200 rows of it". Whole-file `warnings` is what forces the full scan; a `complete` or
764
+ `hasMore` flag is the other half of that trade and is not taken yet.
308
765
  - **Generate real fixture data.** Run subagent workflows in this project's `cwd` so that
309
766
  project's `--<slug>--/` session directory grows actual parent/child trees, then use them
310
767
  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.
768
+ - **Remaining filter parameters.** Time range, `cwd`, ordering, and a row cap exist now. For
769
+ `session_entries` the line range, `ids`, `parentIds`, `types`, `messageRoles`, the timestamp
770
+ window, a literal `search` over `text`, and `limit` exist now, and so does current-session
771
+ exclusion (`includeCurrentSession`, default `false`, on the file Pi reports for the call). Still
772
+ deferred: by presence or count
773
+ of subagents, a general `excludePaths` for sessions other than the running one, and a depth limit
774
+ for `subagentSessions`. Entry-level
775
+ searches over `raw` — a field path, a JSON shape, thinking, tool-call arguments — are deliberately
776
+ absent: they would need an index to be worth the scan, and none exists.
777
+ - **The search surface over `text` is decided; its extensions are not.** Chosen 2026-10-06: literal
778
+ substrings over `text`, any-term OR, case-insensitive by default with `caseSensitive: true` as the
779
+ opt-in, inside this reader, with no index. Regex was rejected as the *only* interface — escaping cost
780
+ in a codemode string, ambiguous flags if pattern and flags share one field, pathological backtracking
781
+ on a 50 KB row, and the common query is a name or an error fragment — and a textual DSL (Google-like
782
+ or Lucene-like) was rejected because a parser, precedence, and escaping rules buy nothing an agent
783
+ cannot spell in JSON. Deferred, in order of likely need:
784
+ - **A pattern filter**, if literal terms keep failing to express boundaries or alternatives. The
785
+ shape under discussion is a separate `textRegex: { pattern, flags }` object using ECMAScript
786
+ semantics, with `g`/`y` refused because `lastIndex` makes a repeated `.test()` stateful, compiled
787
+ before the file is opened like every other parameter. It stays its own category rather than
788
+ overloading `search` strings with implicit regex.
789
+ - **Reaching thinking.** Decided once already, and the decision holds: thinking is never folded into
790
+ `text` (4,378 of 6,867 assistant rows in the live store have thinking and no visible text, so
791
+ folding it would make `text` mostly reasoning) and is therefore unreachable from `search`. If
792
+ searching it is ever wanted it needs its own opt-in field or scope, not a default.
793
+ - **Ranked, indexed, or multi-file search.** This is a boolean per row over one file. Match counts,
794
+ excerpts, offsets, and a score are not on offer, and a `search` costs the same walk as an
795
+ unfiltered read; the index question belongs to a separate operation, decided against measurements
796
+ of a store that would need one.
797
+ - **Prompt rows.** `text` search matches a `system` row's rendered prompt — the preamble, tool
798
+ rules, every `AGENTS.md`, skill descriptions, about 2.7 MB across 155 parent sessions — and the
799
+ answer taken is "in scope, and the caller narrows with `types`/`messageRoles`" rather than a
800
+ default exclusion. If prompt hits turn out to dominate real searches, revisit that instead of
801
+ adding a scope parameter to `search`.
802
+ - **Resolve or reject relative `cwds`.** Header `cwd` is always absolute, so a relative entry
803
+ (`repos/app`) or a shell tilde (`~/repos/app`) matches nothing and looks like "no history for
804
+ this project" rather than like a mistake. Two ways out, not chosen yet: reject a
805
+ non-absolute entry in `buildQuery` — cheap, and it keeps the listing independent of where the
806
+ caller sits, which matters because a codemode script may be asking about another checkout
807
+ than its own session; or resolve before comparing, which needs the caller's working directory
808
+ threaded in from `ExtensionContext.cwd`, a separate decision about `~` expansion, and accepts
809
+ that the same parameters can select different sessions in two sessions. Nothing relative is
810
+ pinned by a test today — `test/list-sessions-filters.test.ts` uses absolute entries, plus
811
+ the non-matching `cwds: ["/repo/never"]` case that keeps `warnings` whole — so whichever way
812
+ this goes, the test comes with it.
813
+ - **Pagination.** For `session_entries` it is answered: `startLineNo` is a physical bound, so pages
814
+ are stable against both appends and filters, and `offset` was rejected on purpose (an offset counts
815
+ matching rows, so a page moves when the filter changes). For `list_sessions`, `limit` caps rows but
816
+ does not cursor. If a store ever needs more, a cursor carrying the sort value plus `path` beats an
817
+ `offset`, which shifts as history grows.
317
818
  - **Orphaned child trees.** Today they are reported neither as a row nor as a warning. If
318
819
  that ever needs to be visible, it should become an explicit warning branch rather than a
319
820
  silent rule.
@@ -321,3 +822,17 @@ discovered session are considered.
321
822
  `path` rather than `id`, but `description` in `src/list-sessions-tool.ts` lists the fields
322
823
  without saying which identifies a transcript. Worth one clause, since a caller reading
323
824
  only the description has no other way to learn it.
825
+ - **A context view, if ever wanted.** `session_entries` returns stored history: every branch, no
826
+ compaction, `context_edit` unreplaced. The other question — what the model actually saw — needs
827
+ Pi's `buildContextEntries` / `buildSessionProjection` over the same entries, which those exported
828
+ free functions allow without `SessionManager.open()`. That is a second operation with its own
829
+ contract, not a parameter on this one, and it stays unbuilt until it is discussed.
830
+ - **A prompt replay, if ever wanted.** One row's `text` is that message's own rendered state, so the
831
+ prompt a session actually ran is a replay: fold the `system` rows in file order by section name,
832
+ where a `null` value removes a section. Pi exports `getCurrentSystemPrompt` in
833
+ `@earendil-works/pi-ai` for exactly that over a message list. Whether it belongs here (a third
834
+ operation, or a projection of `session_entries` output in the caller's script) is undecided; the
835
+ caller-side recipe needs no new API.
836
+ - **Entry-id addressing.** `parentId` comes back but no tree is computed, so a caller that wants one
837
+ branch root→leaf either walks ids in a script or waits for a `fromId`-style parameter. If
838
+ citations by entry id become the norm, decide whether the reader or a future view owns that.