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.
- package/README.md +232 -15
- package/docs/maintainer-reference.md +558 -43
- package/docs/tool-api.md +626 -141
- package/package.json +15 -4
- package/skills/pi-retrospect/SKILL.md +106 -0
- package/src/content.ts +40 -0
- package/src/entry-query.ts +147 -0
- package/src/entry-text.ts +200 -0
- package/src/filters.ts +100 -0
- package/src/index.ts +7 -0
- package/src/list-sessions-tool.ts +36 -5
- package/src/list-sessions.ts +44 -16
- package/src/query.ts +171 -0
- package/src/schemas.ts +295 -6
- package/src/session-entries-tool.ts +95 -0
- package/src/session-entries.ts +374 -0
- package/src/session-metadata.ts +4 -39
- package/src/steering-messages.ts +85 -0
- package/src/timestamps.ts +96 -0
|
@@ -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
|
|
9
|
-
|
|
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
|
|
14
|
-
|
|
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
|
|
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
|
|
84
|
-
|
|
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
|
|
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 —
|
|
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
|
|
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
|
|
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`:
|
|
150
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
output as
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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,
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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
|
|
291
|
-
|
|
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
|
|
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
|
-
- **
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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.
|