@theokit/sdk 5.2.1 → 5.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/CHANGELOG.md +48 -0
- package/dist/internal/persistence/list-sessions.d.cts +98 -0
- package/dist/internal/persistence/list-sessions.d.ts +98 -0
- package/dist/persistence.cjs +65 -0
- package/dist/persistence.cjs.map +1 -1
- package/dist/persistence.d.cts +1 -0
- package/dist/persistence.d.ts +1 -0
- package/dist/persistence.js +67 -2
- package/dist/persistence.js.map +1 -1
- package/docs/harness-capability-map.md +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,53 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 5.3.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- [#607](https://github.com/usetheokit/theokit-sdk/pull/607) [`a7ed8e0`](https://github.com/usetheokit/theokit-sdk/commit/a7ed8e0c37105375e439ef567a51c7643ffc7754) Thanks [@usetheodev](https://github.com/usetheodev)! - `listSessions` — enumerate sessions, and say where each id came from ([#598](https://github.com/usetheokit/theokit-sdk/issues/598))
|
|
8
|
+
|
|
9
|
+
Every transcript helper this package published mapped **forward** — `sessionUuidFor`,
|
|
10
|
+
`transcriptPath`, `legacyTranscriptPath`, `encodeProjectDir`, `transcriptRoot`. **None enumerated.**
|
|
11
|
+
So a consumer that needed the list rebuilt it, and two independent ones did, in opposite directions,
|
|
12
|
+
and both derived the session id from the **filename**:
|
|
13
|
+
|
|
14
|
+
| consumer | direction | what broke |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `@theokit/agents` | file → id | `sessionIdOf` returned the file stem |
|
|
17
|
+
| a downstream agent runtime | id → file | compared session ids against filenames |
|
|
18
|
+
|
|
19
|
+
The second was measured against `5.0.1`: the protected set never matched, so **neither the registered
|
|
20
|
+
sessions nor the live one were protected and everything classified as an orphan** — a garbage
|
|
21
|
+
collector that would delete the session in use.
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
import { listSessions } from "@theokit/sdk/persistence";
|
|
25
|
+
|
|
26
|
+
for (const s of await listSessions(process.cwd())) {
|
|
27
|
+
if (s.idSource === "unavailable") continue; // do not guess, and do not delete
|
|
28
|
+
…
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
**Documentation was not the fix, and that is the argument for the primitive.** The rename was
|
|
33
|
+
documented thoroughly. One of those two consumers had *read* it and broke anyway; the other had not
|
|
34
|
+
and broke identically. Two samples, one informed and one not, the same defect — so the cause is the
|
|
35
|
+
shape of the surface rather than the reader. Nor can it be closed with an inverse: the filename is a
|
|
36
|
+
UUIDv8 over SHA-256, which has none.
|
|
37
|
+
|
|
38
|
+
**Every entry carries `idSource`, and an unreadable id is `undefined` rather than guessed.** An
|
|
39
|
+
`id: string` that is sometimes read from the transcript and sometimes inferred is the same defect one
|
|
40
|
+
layer up — a value that reads as authoritative and occasionally is not, which is exactly what
|
|
41
|
+
produced the garbage-collector failure. A caller deciding what to *delete* needs to tell "not
|
|
42
|
+
registered" from "I could not read this file"; in a plain list those look identical and mean opposite
|
|
43
|
+
things.
|
|
44
|
+
|
|
45
|
+
Reading is capped (64KB by default, `idScanBytes`) because the id lives in the first record and a
|
|
46
|
+
transcript grows without bound: bounded work with a declared outcome beats an unbounded read.
|
|
47
|
+
|
|
48
|
+
Evidence gathered by the `theocode` session, which measured its own failure and obtained the
|
|
49
|
+
sibling's `file:line` rather than paraphrasing it.
|
|
50
|
+
|
|
3
51
|
## 5.2.1
|
|
4
52
|
|
|
5
53
|
### Patch Changes
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #598 — enumerate the sessions on disk, saying where each id came from.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Every transcript helper this package published mapped FORWARD — `sessionUuidFor`,
|
|
7
|
+
* `transcriptPath`, `legacyTranscriptPath`, `encodeProjectDir`, `transcriptRoot`. **None enumerated.**
|
|
8
|
+
* So a consumer that needed the list rebuilt it, and two independent ones did, in opposite
|
|
9
|
+
* directions, and both got it wrong on the first attempt:
|
|
10
|
+
*
|
|
11
|
+
* | consumer | direction | what broke |
|
|
12
|
+
* |---|---|---|
|
|
13
|
+
* | `@theokit/agents` | file → id | derived the id from the file STEM |
|
|
14
|
+
* | a downstream agent runtime | id → file | compared session ids against filenames |
|
|
15
|
+
*
|
|
16
|
+
* The second measured, against 5.0.1: the protected set never matched, **so neither the registered
|
|
17
|
+
* sessions nor the live one were protected and everything classified as an orphan** — a garbage
|
|
18
|
+
* collector that would delete the session in use.
|
|
19
|
+
*
|
|
20
|
+
* ## Why documentation was not the fix
|
|
21
|
+
*
|
|
22
|
+
* `b85dab4` documented the rename thoroughly. One of those two consumers had READ it and broke
|
|
23
|
+
* anyway; the other had not and broke identically. Two samples, one informed and one not, same
|
|
24
|
+
* defect. If reading were sufficient the informed sample would have survived — so the cause is the
|
|
25
|
+
* shape of the surface, not the reader.
|
|
26
|
+
*
|
|
27
|
+
* And it cannot be closed by publishing an inverse: the filename is a UUIDv8 over SHA-256, which has
|
|
28
|
+
* none. Whoever holds the file must read the id from INSIDE it, and that the id lives in the first
|
|
29
|
+
* record is this package's knowledge. Both consumers had to discover it by reading bytes.
|
|
30
|
+
*
|
|
31
|
+
* ## Why the id is not just a string
|
|
32
|
+
*
|
|
33
|
+
* An `id: string` that is sometimes read from the transcript and sometimes inferred from the
|
|
34
|
+
* filename is the same defect one layer up: a value that reads as authoritative and occasionally is
|
|
35
|
+
* not. That is precisely what produced the garbage-collector failure above.
|
|
36
|
+
*
|
|
37
|
+
* So every entry carries {@link SessionListing.idSource}, and an entry whose id could not be
|
|
38
|
+
* determined is `undefined` rather than guessed. A caller deciding what to DELETE needs to tell
|
|
39
|
+
* "this session is not registered" from "I could not read this file" — those look identical in a
|
|
40
|
+
* plain list and mean opposite things.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
/** How the `id` on a {@link SessionListing} was obtained. */
|
|
45
|
+
export type SessionIdSource =
|
|
46
|
+
/** Read from the `sessionId` of the transcript's first well-formed record. Authoritative. */
|
|
47
|
+
"transcript"
|
|
48
|
+
/**
|
|
49
|
+
* Not determined: the file was unreadable, empty, or its first records carried no `sessionId`.
|
|
50
|
+
* `id` is `undefined` — deliberately not the filename, which is a hash and not an id.
|
|
51
|
+
*/
|
|
52
|
+
| "unavailable";
|
|
53
|
+
/** One session found on disk. */
|
|
54
|
+
export interface SessionListing {
|
|
55
|
+
/**
|
|
56
|
+
* The session id, or `undefined` when {@link idSource} is `"unavailable"`.
|
|
57
|
+
*
|
|
58
|
+
* Never derived from the filename. The filename is a UUIDv8 over SHA-256 of the id, so treating
|
|
59
|
+
* it as the id is the exact defect this function exists to prevent.
|
|
60
|
+
*/
|
|
61
|
+
readonly id: string | undefined;
|
|
62
|
+
/** Where {@link id} came from. Check this before acting on `id`. */
|
|
63
|
+
readonly idSource: SessionIdSource;
|
|
64
|
+
/** Absolute path to the `.jsonl` transcript. */
|
|
65
|
+
readonly transcript: string;
|
|
66
|
+
/** Last modification time of the transcript. */
|
|
67
|
+
readonly modifiedAt: Date;
|
|
68
|
+
}
|
|
69
|
+
/** Options for {@link listSessions}. */
|
|
70
|
+
export interface ListSessionsOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Root under which `projects/<encoded-cwd>/` lives. Defaults to {@link transcriptRoot}, which
|
|
73
|
+
* honours `THEOKIT_HOME`.
|
|
74
|
+
*/
|
|
75
|
+
readonly baseDir?: string;
|
|
76
|
+
/**
|
|
77
|
+
* How many bytes of each transcript to read looking for the id. Default 65536.
|
|
78
|
+
*
|
|
79
|
+
* A cap rather than a full read because a transcript grows without bound and the id is in the
|
|
80
|
+
* first record. A session whose id is not in the first 64KB reports `"unavailable"` rather than
|
|
81
|
+
* being read to the end — bounded work with a declared outcome beats an unbounded read.
|
|
82
|
+
*/
|
|
83
|
+
readonly idScanBytes?: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every session transcript under `<baseDir>/projects/<encoded-cwd>/`, with the id read from inside
|
|
87
|
+
* each file.
|
|
88
|
+
*
|
|
89
|
+
* An absent directory yields `[]` — a cwd with no sessions is the common case, not an error.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* for (const s of await listSessions(process.cwd())) {
|
|
93
|
+
* if (s.idSource === "unavailable") continue; // do not guess, and do not delete
|
|
94
|
+
* …
|
|
95
|
+
* }
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export declare function listSessions(cwd: string, options?: ListSessionsOptions): Promise<readonly SessionListing[]>;
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #598 — enumerate the sessions on disk, saying where each id came from.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this exists
|
|
5
|
+
*
|
|
6
|
+
* Every transcript helper this package published mapped FORWARD — `sessionUuidFor`,
|
|
7
|
+
* `transcriptPath`, `legacyTranscriptPath`, `encodeProjectDir`, `transcriptRoot`. **None enumerated.**
|
|
8
|
+
* So a consumer that needed the list rebuilt it, and two independent ones did, in opposite
|
|
9
|
+
* directions, and both got it wrong on the first attempt:
|
|
10
|
+
*
|
|
11
|
+
* | consumer | direction | what broke |
|
|
12
|
+
* |---|---|---|
|
|
13
|
+
* | `@theokit/agents` | file → id | derived the id from the file STEM |
|
|
14
|
+
* | a downstream agent runtime | id → file | compared session ids against filenames |
|
|
15
|
+
*
|
|
16
|
+
* The second measured, against 5.0.1: the protected set never matched, **so neither the registered
|
|
17
|
+
* sessions nor the live one were protected and everything classified as an orphan** — a garbage
|
|
18
|
+
* collector that would delete the session in use.
|
|
19
|
+
*
|
|
20
|
+
* ## Why documentation was not the fix
|
|
21
|
+
*
|
|
22
|
+
* `b85dab4` documented the rename thoroughly. One of those two consumers had READ it and broke
|
|
23
|
+
* anyway; the other had not and broke identically. Two samples, one informed and one not, same
|
|
24
|
+
* defect. If reading were sufficient the informed sample would have survived — so the cause is the
|
|
25
|
+
* shape of the surface, not the reader.
|
|
26
|
+
*
|
|
27
|
+
* And it cannot be closed by publishing an inverse: the filename is a UUIDv8 over SHA-256, which has
|
|
28
|
+
* none. Whoever holds the file must read the id from INSIDE it, and that the id lives in the first
|
|
29
|
+
* record is this package's knowledge. Both consumers had to discover it by reading bytes.
|
|
30
|
+
*
|
|
31
|
+
* ## Why the id is not just a string
|
|
32
|
+
*
|
|
33
|
+
* An `id: string` that is sometimes read from the transcript and sometimes inferred from the
|
|
34
|
+
* filename is the same defect one layer up: a value that reads as authoritative and occasionally is
|
|
35
|
+
* not. That is precisely what produced the garbage-collector failure above.
|
|
36
|
+
*
|
|
37
|
+
* So every entry carries {@link SessionListing.idSource}, and an entry whose id could not be
|
|
38
|
+
* determined is `undefined` rather than guessed. A caller deciding what to DELETE needs to tell
|
|
39
|
+
* "this session is not registered" from "I could not read this file" — those look identical in a
|
|
40
|
+
* plain list and mean opposite things.
|
|
41
|
+
*
|
|
42
|
+
* @public
|
|
43
|
+
*/
|
|
44
|
+
/** How the `id` on a {@link SessionListing} was obtained. */
|
|
45
|
+
export type SessionIdSource =
|
|
46
|
+
/** Read from the `sessionId` of the transcript's first well-formed record. Authoritative. */
|
|
47
|
+
"transcript"
|
|
48
|
+
/**
|
|
49
|
+
* Not determined: the file was unreadable, empty, or its first records carried no `sessionId`.
|
|
50
|
+
* `id` is `undefined` — deliberately not the filename, which is a hash and not an id.
|
|
51
|
+
*/
|
|
52
|
+
| "unavailable";
|
|
53
|
+
/** One session found on disk. */
|
|
54
|
+
export interface SessionListing {
|
|
55
|
+
/**
|
|
56
|
+
* The session id, or `undefined` when {@link idSource} is `"unavailable"`.
|
|
57
|
+
*
|
|
58
|
+
* Never derived from the filename. The filename is a UUIDv8 over SHA-256 of the id, so treating
|
|
59
|
+
* it as the id is the exact defect this function exists to prevent.
|
|
60
|
+
*/
|
|
61
|
+
readonly id: string | undefined;
|
|
62
|
+
/** Where {@link id} came from. Check this before acting on `id`. */
|
|
63
|
+
readonly idSource: SessionIdSource;
|
|
64
|
+
/** Absolute path to the `.jsonl` transcript. */
|
|
65
|
+
readonly transcript: string;
|
|
66
|
+
/** Last modification time of the transcript. */
|
|
67
|
+
readonly modifiedAt: Date;
|
|
68
|
+
}
|
|
69
|
+
/** Options for {@link listSessions}. */
|
|
70
|
+
export interface ListSessionsOptions {
|
|
71
|
+
/**
|
|
72
|
+
* Root under which `projects/<encoded-cwd>/` lives. Defaults to {@link transcriptRoot}, which
|
|
73
|
+
* honours `THEOKIT_HOME`.
|
|
74
|
+
*/
|
|
75
|
+
readonly baseDir?: string;
|
|
76
|
+
/**
|
|
77
|
+
* How many bytes of each transcript to read looking for the id. Default 65536.
|
|
78
|
+
*
|
|
79
|
+
* A cap rather than a full read because a transcript grows without bound and the id is in the
|
|
80
|
+
* first record. A session whose id is not in the first 64KB reports `"unavailable"` rather than
|
|
81
|
+
* being read to the end — bounded work with a declared outcome beats an unbounded read.
|
|
82
|
+
*/
|
|
83
|
+
readonly idScanBytes?: number;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Every session transcript under `<baseDir>/projects/<encoded-cwd>/`, with the id read from inside
|
|
87
|
+
* each file.
|
|
88
|
+
*
|
|
89
|
+
* An absent directory yields `[]` — a cwd with no sessions is the common case, not an error.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* for (const s of await listSessions(process.cwd())) {
|
|
93
|
+
* if (s.idSource === "unavailable") continue; // do not guess, and do not delete
|
|
94
|
+
* …
|
|
95
|
+
* }
|
|
96
|
+
* ```
|
|
97
|
+
*/
|
|
98
|
+
export declare function listSessions(cwd: string, options?: ListSessionsOptions): Promise<readonly SessionListing[]>;
|
package/dist/persistence.cjs
CHANGED
|
@@ -11,6 +11,70 @@ var chunkJLRLCBJ4_cjs = require('./chunk-JLRLCBJ4.cjs');
|
|
|
11
11
|
var chunkJ7J7J2GN_cjs = require('./chunk-J7J7J2GN.cjs');
|
|
12
12
|
require('./chunk-6LHQPOMI.cjs');
|
|
13
13
|
var fs = require('fs');
|
|
14
|
+
var promises = require('fs/promises');
|
|
15
|
+
var path = require('path');
|
|
16
|
+
|
|
17
|
+
var DEFAULT_ID_SCAN_BYTES = 64 * 1024;
|
|
18
|
+
async function listSessions(cwd, options = {}) {
|
|
19
|
+
const dir = path.join(options.baseDir ?? chunkGZV6AVRL_cjs.transcriptRoot(), "projects", chunkGZV6AVRL_cjs.encodeProjectDir(cwd));
|
|
20
|
+
let names;
|
|
21
|
+
try {
|
|
22
|
+
names = await promises.readdir(dir);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
29
|
+
const transcript = path.join(dir, name);
|
|
30
|
+
let modifiedAt;
|
|
31
|
+
try {
|
|
32
|
+
modifiedAt = (await promises.stat(transcript)).mtime;
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const id = await readSessionId(transcript, options.idScanBytes ?? DEFAULT_ID_SCAN_BYTES);
|
|
37
|
+
out.push({
|
|
38
|
+
id,
|
|
39
|
+
idSource: id === void 0 ? "unavailable" : "transcript",
|
|
40
|
+
transcript,
|
|
41
|
+
modifiedAt
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
async function readSessionId(path, maxBytes) {
|
|
47
|
+
let buffered = "";
|
|
48
|
+
try {
|
|
49
|
+
const stream = fs.createReadStream(path, { encoding: "utf8", end: maxBytes - 1 });
|
|
50
|
+
for await (const chunk of stream) {
|
|
51
|
+
buffered += chunk;
|
|
52
|
+
const lines = buffered.split("\n");
|
|
53
|
+
buffered = lines.pop() ?? "";
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const id = sessionIdOfLine(line);
|
|
56
|
+
if (id !== void 0) {
|
|
57
|
+
stream.destroy();
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
return sessionIdOfLine(buffered);
|
|
66
|
+
}
|
|
67
|
+
function sessionIdOfLine(line) {
|
|
68
|
+
if (line.trim() === "") return void 0;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(line);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
72
|
+
const id = parsed.sessionId;
|
|
73
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
74
|
+
} catch {
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
14
78
|
|
|
15
79
|
// src/internal/persistence/session-artifacts.ts
|
|
16
80
|
function classifySessionArtifact(name, isDirectory) {
|
|
@@ -192,6 +256,7 @@ exports.LiveSessionError = LiveTranscriptError;
|
|
|
192
256
|
exports.LiveTranscriptError = LiveTranscriptError;
|
|
193
257
|
exports.classifySessionArtifact = classifySessionArtifact;
|
|
194
258
|
exports.forkTranscript = forkTranscript;
|
|
259
|
+
exports.listSessions = listSessions;
|
|
195
260
|
exports.readJsonlTail = readJsonlTail;
|
|
196
261
|
//# sourceMappingURL=persistence.cjs.map
|
|
197
262
|
//# sourceMappingURL=persistence.cjs.map
|
package/dist/persistence.cjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/internal/persistence/session-artifacts.ts","../src/internal/persistence/transcript-ops.ts"],"names":["atomicWriteTempTarget","TheokitAgentError","readFileSync","openSync","writeSync","closeSync","fstatSync","readSync"],"mappings":"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","file":"persistence.cjs","sourcesContent":["import { atomicWriteTempTarget } from \"./atomic-write.js\";\n\n/**\n * The kinds of file this SDK leaves in a project's transcript directory.\n *\n * - `transcript` — the session itself (`transcriptPath`).\n * - `writer-lock` — the cross-process writer lease (`session-writer.ts`, `<file>.writer.lock`).\n * - `lock-directory` — `withFileLock`'s companion, taken by `mkdir`, so it is a DIRECTORY.\n * - `temp` — what `replaceFileAtomic` leaves when a process dies between the open and the rename.\n */\nexport type SessionArtifact = \"transcript\" | \"writer-lock\" | \"lock-directory\" | \"temp\";\n\n/**\n * U-1 — what is this entry, if it is one of ours?\n *\n * The SDK writes four kinds of file into a project directory and reasons about none of them\n * afterwards: there is no retention, no collector, and there was no way even to ask what an entry\n * IS. A consumer wanting to reclaim disk had to re-derive the suffixes by reading this source — and\n * one did, which means a suffix changing here would have left its classifier silently mislabelling\n * files on a path that deletes them.\n *\n * This is deliberately NOT a garbage collector. Retention is policy — how many days, how many to\n * keep, which session is live, whether to delete at all — and policy belongs to the application,\n * which is the only one that can know. What belongs here is the half only the SDK can answer.\n *\n * `undefined` means \"not written by this SDK\", and it is the answer that matters most: a caller\n * deleting what it does not recognise is how an editor's swap file gets collected. The `temp` case\n * defers to {@link atomicWriteTempTarget} rather than matching `.tmp`, for exactly that reason.\n */\nexport function classifySessionArtifact(\n name: string,\n isDirectory: boolean,\n): SessionArtifact | undefined {\n // `withFileLock` takes its lock by `mkdir`, so the same name as a plain file is not ours.\n if (name.endsWith(\".jsonl.lock\")) return isDirectory ? \"lock-directory\" : undefined;\n if (isDirectory) return undefined;\n if (name.endsWith(\".jsonl\")) return \"transcript\";\n if (name.endsWith(\".writer.lock\")) return \"writer-lock\";\n if (atomicWriteTempTarget(name) !== undefined) return \"temp\";\n return undefined;\n}\n","/**\n * M81 — transcript operations the consumer was doing by hand INSIDE the framework's own store.\n *\n * ## What this replaces\n *\n * `agents/lib/session/backtrack.ts:188` (agent-builder) wrote straight into the session store with a\n * bare `writeFileSync` — no atomicity, no lock, no API. 243 lines re-implementing parse, cut and\n * write for a format the framework owns. The consumer is not at fault: nothing here was reachable.\n *\n * ## The rule that travels WITH the operation\n *\n * `rules/audit-trail-rotation.md § Session transcripts (M60)` defines a NEVER-delete list — the live\n * pointer, the most recent transcript, and any active registry entry. That rule lived in the\n * CONSUMER. Moving the operation here without moving the rule would ship an API able to destroy\n * exactly what the rule protects — the same shape of defect as M80's `reconcileUpdateGoalStatus`:\n * critical knowledge outside the module that needs it, applied by convention.\n *\n * So `forkTranscript` takes `liveSessionPaths` and refuses, with a TYPED error, to write over any of\n * them. The caller supplies the list because only the caller knows which session is live; the\n * enforcement lives here because that is where the write happens.\n *\n * @internal\n */\n\nimport { closeSync, fstatSync, openSync, readFileSync, readSync, writeSync } from \"node:fs\";\n\nimport { TheokitAgentError } from \"../../errors.js\";\n\n/**\n * M81 — the target is a protected session (live pointer / most-recent transcript / active entry).\n *\n * Typed rather than a bare `Error` because the caller must distinguish \"this session is protected\"\n * from \"the disk is full\": the first is a correct refusal, the second is an incident.\n *\n * RENAMED from `LiveSessionError` (2026-09-01). `session-guard.ts` exports a DIFFERENT class of that\n * name from the root barrel, with an incompatible shape — `(sessionId, reason)` and a `reason`\n * field, against this one's `(path)` and `code: \"live_session_protected\"` — and `.` and\n * `./persistence` are both declared subpaths, so one consumer can hold both. `instanceof` never\n * crossed the pair, so a `catch` checking the root import silently missed this one and ran its\n * fallback for a condition it believed it handled; `err.name` matched BOTH, so a name check looked\n * right and then read `err.reason`, which only the other one has. The names now say what each\n * refusal is about: destroying a SESSION, versus overwriting a TRANSCRIPT file.\n */\nexport class LiveTranscriptError extends TheokitAgentError {\n override readonly name = \"LiveTranscriptError\";\n\n constructor(readonly path: string) {\n super(\n `refusing to write over a live session transcript: ${path}. ` +\n \"Fork to a new id instead — the live pointer, the most recent transcript and any active \" +\n \"registry entry are never overwritten (audit-trail rotation, M60).\",\n { code: \"live_session_protected\", isRetryable: false },\n );\n }\n}\n\n/** Options for {@link forkTranscript}. */\nexport interface ForkTranscriptOptions {\n /** Keep records `[0, beforeRecordIndex)`. Omit to copy the whole transcript. */\n readonly beforeRecordIndex?: number;\n /**\n * Paths that must never be written over — the live pointer, the most recent transcript, any active\n * registry entry. The caller supplies them because only the caller knows which session is live.\n */\n readonly liveSessionPaths?: readonly string[];\n /**\n * M107 — permission bits for the created destination. Default: `0o600`.\n *\n * A transcript carries the conversation. Before M107 no mode was passed at all, so the file was\n * born `0o666 & ~umask` — measured `0o664` (group-WRITABLE) on a `umask 002` machine, `0o644` on\n * `umask 022`, `0o466` on `umask 0200`. This is a DEFAULT and not a required knob on purpose: a\n * knob would reach zero consumers by omission, which is the failure mode that matters.\n *\n * As with any `open` mode, the `umask` may still CLEAR bits — under `umask 0200` the result is\n * `0o400`. That is accepted: the invariant bought here is \"neither group nor others\", and `0o400`\n * satisfies it more strictly. The SDK deliberately does not `fchmod` the default back, because\n * that would hand back a bit the operator asked to remove.\n */\n readonly mode?: number;\n}\n\n/**\n * Copy `src` into `dst`, keeping the first `beforeRecordIndex` records. The SOURCE is never touched.\n *\n * Atomicity comes from `wx` (exclusive create): two concurrent forks to the same destination cannot\n * both succeed — the loser gets `EEXIST` rather than writing over a half-written file. That is also\n * why an existing destination is a refusal, not a silent overwrite: losing a transcript without an\n * error is the worst failure mode for an operation that touches user sessions.\n */\nexport function forkTranscript(\n src: string,\n dst: string,\n options: ForkTranscriptOptions = {},\n): void {\n for (const live of options.liveSessionPaths ?? []) {\n if (live === dst) throw new LiveTranscriptError(dst);\n }\n\n const lines = readFileSync(src, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.trim().length > 0);\n const kept =\n options.beforeRecordIndex === undefined ? lines : lines.slice(0, options.beforeRecordIndex);\n const body = kept.length > 0 ? `${kept.join(\"\\n\")}\\n` : \"\";\n\n // `wx` — fails with EEXIST instead of truncating. The exclusivity IS the concurrency guarantee,\n // and M107 only added the third argument: the mode. See `ForkTranscriptOptions.mode`.\n const fd = openSync(dst, \"wx\", options.mode ?? 0o600);\n try {\n writeSync(fd, body);\n } finally {\n closeSync(fd);\n }\n}\n\n/** Options for {@link readJsonlTail}. */\nexport interface ReadJsonlTailOptions {\n /** Maximum records to return, counted from the END. */\n readonly maxRecords?: number;\n /**\n * Start the window AFTER the last record whose `subtype` (or `type`) equals this.\n *\n * Matched STRUCTURALLY since T2.5. It used to be `line.includes(marker)`, so any message\n * mentioning the marker in its text truncated the read — silently, with a successful return.\n */\n readonly sinceMarker?: string;\n /** Test-only: also report how many bytes were read, to prove the read is not whole-file. */\n readonly _stats?: boolean;\n}\n\nconst TAIL_CHUNK = 64 * 1024;\n\n/**\n * Reads chunks backwards until enough complete lines have accumulated.\n *\n * Extracted from `readJsonlTail` because the read loop and the record selection are two\n * responsibilities — and together they exceeded the complexity ceiling. The buffer's first line may\n * be cut in half when the read stopped before the start of the file; that is why it is discarded.\n */\nfunction readRawTail(path: string, want: number): { lines: string[]; bytesRead: number } {\n // Opened FIRST, then sized through the descriptor. `statSync(path)` followed by\n // `openSync(path)` resolves the name twice, and `size` is what drives every read offset below —\n // so a path that changed between the two calls would have the loop seeking by one file's length\n // inside another (CodeQL js/file-system-race #19). `fstat` on the open fd describes the file\n // being read, by construction.\n const fd = openSync(path, \"r\");\n const size = fstatSync(fd).size;\n let bytesRead = 0;\n let tail = \"\";\n let pos = size;\n try {\n while (pos > 0) {\n const len = Math.min(TAIL_CHUNK, pos);\n pos -= len;\n const buf = Buffer.alloc(len);\n readSync(fd, buf, 0, len, pos);\n bytesRead += len;\n tail = buf.toString(\"utf8\") + tail;\n if (nonEmptyLines(tail).length > want) break;\n }\n } finally {\n closeSync(fd);\n }\n const lines = nonEmptyLines(tail);\n return { lines: pos > 0 ? lines.slice(1) : lines, bytesRead };\n}\n\n/** Non-empty lines, in file order. */\nfunction nonEmptyLines(text: string): string[] {\n return text.split(\"\\n\").filter((l) => l.trim().length > 0);\n}\n\n/**\n * Whether a raw JSONL line IS the marker record, rather than a line that talks about it.\n *\n * Matches on the record's own discriminants (`subtype`, then `type`) — the fields that identify\n * what a record *is*. Free text is never consulted, which is the whole point: content is the user's\n * and must not steer the reader.\n *\n * A line that does not parse is not a marker. Deciding a window boundary from bytes that are not a\n * record would be guessing, and this function exists because guessing is what it replaced.\n */\nfunction isMarkerRecord(line: string, marker: string): boolean {\n let record: { type?: unknown; subtype?: unknown };\n try {\n record = JSON.parse(line) as { type?: unknown; subtype?: unknown };\n } catch {\n return false;\n }\n return record.subtype === marker || record.type === marker;\n}\n\n/**\n * Read the LAST records of a JSONL file without loading the whole thing.\n *\n * Reads fixed-size chunks backwards from EOF until enough newlines have been seen. A session\n * transcript grows without bound; loading megabytes to show the last three turns is the cost this\n * exists to avoid — and a `slice` over a full read would be that same cost with a better name.\n */\nexport function readJsonlTail<T = Record<string, unknown>>(\n path: string,\n options: ReadJsonlTailOptions = {},\n): T[] {\n const want = options.maxRecords ?? Number.POSITIVE_INFINITY;\n const { lines, bytesRead } = readRawTail(path, want);\n\n let sel = lines;\n if (options.sinceMarker !== undefined) {\n const marker = options.sinceMarker;\n // STRUCTURAL, not `line.includes(marker)`.\n //\n // A raw substring match is true for any line that merely MENTIONS the marker — a user asking\n // \"how does compact_boundary work?\" silently truncated the window to start at their question.\n // The read then succeeded, returned fewer records than exist, and said nothing. That is the\n // measured reason the only would-be consumer kept its own reader instead of this one.\n const idx = sel.findLastIndex((l) => isMarkerRecord(l, marker));\n if (idx >= 0) sel = sel.slice(idx + 1);\n }\n if (Number.isFinite(want)) sel = sel.slice(-want);\n\n const out = sel.map((l) => JSON.parse(l) as T);\n return options._stats === true ? (Object.assign(out, { bytesRead }) as T[]) : out;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/internal/persistence/list-sessions.ts","../src/internal/persistence/session-artifacts.ts","../src/internal/persistence/transcript-ops.ts"],"names":["join","transcriptRoot","encodeProjectDir","readdir","stat","createReadStream","atomicWriteTempTarget","TheokitAgentError","readFileSync","openSync","writeSync","closeSync","fstatSync","readSync"],"mappings":"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","file":"persistence.cjs","sourcesContent":["/**\n * #598 — enumerate the sessions on disk, saying where each id came from.\n *\n * ## Why this exists\n *\n * Every transcript helper this package published mapped FORWARD — `sessionUuidFor`,\n * `transcriptPath`, `legacyTranscriptPath`, `encodeProjectDir`, `transcriptRoot`. **None enumerated.**\n * So a consumer that needed the list rebuilt it, and two independent ones did, in opposite\n * directions, and both got it wrong on the first attempt:\n *\n * | consumer | direction | what broke |\n * |---|---|---|\n * | `@theokit/agents` | file → id | derived the id from the file STEM |\n * | a downstream agent runtime | id → file | compared session ids against filenames |\n *\n * The second measured, against 5.0.1: the protected set never matched, **so neither the registered\n * sessions nor the live one were protected and everything classified as an orphan** — a garbage\n * collector that would delete the session in use.\n *\n * ## Why documentation was not the fix\n *\n * `b85dab4` documented the rename thoroughly. One of those two consumers had READ it and broke\n * anyway; the other had not and broke identically. Two samples, one informed and one not, same\n * defect. If reading were sufficient the informed sample would have survived — so the cause is the\n * shape of the surface, not the reader.\n *\n * And it cannot be closed by publishing an inverse: the filename is a UUIDv8 over SHA-256, which has\n * none. Whoever holds the file must read the id from INSIDE it, and that the id lives in the first\n * record is this package's knowledge. Both consumers had to discover it by reading bytes.\n *\n * ## Why the id is not just a string\n *\n * An `id: string` that is sometimes read from the transcript and sometimes inferred from the\n * filename is the same defect one layer up: a value that reads as authoritative and occasionally is\n * not. That is precisely what produced the garbage-collector failure above.\n *\n * So every entry carries {@link SessionListing.idSource}, and an entry whose id could not be\n * determined is `undefined` rather than guessed. A caller deciding what to DELETE needs to tell\n * \"this session is not registered\" from \"I could not read this file\" — those look identical in a\n * plain list and mean opposite things.\n *\n * @public\n */\n\nimport { createReadStream } from \"node:fs\";\nimport { readdir, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { encodeProjectDir, transcriptRoot } from \"./session-transcript.js\";\n\n/** How the `id` on a {@link SessionListing} was obtained. */\nexport type SessionIdSource =\n /** Read from the `sessionId` of the transcript's first well-formed record. Authoritative. */\n | \"transcript\"\n /**\n * Not determined: the file was unreadable, empty, or its first records carried no `sessionId`.\n * `id` is `undefined` — deliberately not the filename, which is a hash and not an id.\n */\n | \"unavailable\";\n\n/** One session found on disk. */\nexport interface SessionListing {\n /**\n * The session id, or `undefined` when {@link idSource} is `\"unavailable\"`.\n *\n * Never derived from the filename. The filename is a UUIDv8 over SHA-256 of the id, so treating\n * it as the id is the exact defect this function exists to prevent.\n */\n readonly id: string | undefined;\n /** Where {@link id} came from. Check this before acting on `id`. */\n readonly idSource: SessionIdSource;\n /** Absolute path to the `.jsonl` transcript. */\n readonly transcript: string;\n /** Last modification time of the transcript. */\n readonly modifiedAt: Date;\n}\n\n/** Options for {@link listSessions}. */\nexport interface ListSessionsOptions {\n /**\n * Root under which `projects/<encoded-cwd>/` lives. Defaults to {@link transcriptRoot}, which\n * honours `THEOKIT_HOME`.\n */\n readonly baseDir?: string;\n /**\n * How many bytes of each transcript to read looking for the id. Default 65536.\n *\n * A cap rather than a full read because a transcript grows without bound and the id is in the\n * first record. A session whose id is not in the first 64KB reports `\"unavailable\"` rather than\n * being read to the end — bounded work with a declared outcome beats an unbounded read.\n */\n readonly idScanBytes?: number;\n}\n\nconst DEFAULT_ID_SCAN_BYTES = 64 * 1024;\n\n/**\n * Every session transcript under `<baseDir>/projects/<encoded-cwd>/`, with the id read from inside\n * each file.\n *\n * An absent directory yields `[]` — a cwd with no sessions is the common case, not an error.\n *\n * ```ts\n * for (const s of await listSessions(process.cwd())) {\n * if (s.idSource === \"unavailable\") continue; // do not guess, and do not delete\n * …\n * }\n * ```\n */\nexport async function listSessions(\n cwd: string,\n options: ListSessionsOptions = {},\n): Promise<readonly SessionListing[]> {\n const dir = join(options.baseDir ?? transcriptRoot(), \"projects\", encodeProjectDir(cwd));\n let names: string[];\n try {\n names = await readdir(dir);\n } catch {\n return [];\n }\n\n const out: SessionListing[] = [];\n for (const name of names) {\n if (!name.endsWith(\".jsonl\")) continue;\n const transcript = join(dir, name);\n let modifiedAt: Date;\n try {\n modifiedAt = (await stat(transcript)).mtime;\n } catch {\n // Vanished between readdir and stat — a live session being rotated. Skipping is correct:\n // reporting a file that no longer exists would be worse than omitting it.\n continue;\n }\n const id = await readSessionId(transcript, options.idScanBytes ?? DEFAULT_ID_SCAN_BYTES);\n out.push({\n id,\n idSource: id === undefined ? \"unavailable\" : \"transcript\",\n transcript,\n modifiedAt,\n });\n }\n return out;\n}\n\n/**\n * The `sessionId` of the first well-formed record, or `undefined`.\n *\n * Reads at most `maxBytes` and stops at the first record that yields one. Tolerant of malformed\n * lines for the same reason `readTranscript` is: a truncated final line in a live transcript is\n * normal, and failing the whole listing over it would make the function useless exactly when it\n * matters.\n */\nasync function readSessionId(path: string, maxBytes: number): Promise<string | undefined> {\n let buffered = \"\";\n try {\n const stream = createReadStream(path, { encoding: \"utf8\", end: maxBytes - 1 });\n for await (const chunk of stream) {\n buffered += chunk as string;\n const lines = buffered.split(\"\\n\");\n buffered = lines.pop() ?? \"\";\n for (const line of lines) {\n const id = sessionIdOfLine(line);\n if (id !== undefined) {\n stream.destroy();\n return id;\n }\n }\n }\n } catch {\n return undefined;\n }\n return sessionIdOfLine(buffered);\n}\n\nfunction sessionIdOfLine(line: string): string | undefined {\n if (line.trim() === \"\") return undefined;\n try {\n const parsed: unknown = JSON.parse(line);\n if (typeof parsed !== \"object\" || parsed === null) return undefined;\n const id = (parsed as { sessionId?: unknown }).sessionId;\n return typeof id === \"string\" && id !== \"\" ? id : undefined;\n } catch {\n return undefined;\n }\n}\n","import { atomicWriteTempTarget } from \"./atomic-write.js\";\n\n/**\n * The kinds of file this SDK leaves in a project's transcript directory.\n *\n * - `transcript` — the session itself (`transcriptPath`).\n * - `writer-lock` — the cross-process writer lease (`session-writer.ts`, `<file>.writer.lock`).\n * - `lock-directory` — `withFileLock`'s companion, taken by `mkdir`, so it is a DIRECTORY.\n * - `temp` — what `replaceFileAtomic` leaves when a process dies between the open and the rename.\n */\nexport type SessionArtifact = \"transcript\" | \"writer-lock\" | \"lock-directory\" | \"temp\";\n\n/**\n * U-1 — what is this entry, if it is one of ours?\n *\n * The SDK writes four kinds of file into a project directory and reasons about none of them\n * afterwards: there is no retention, no collector, and there was no way even to ask what an entry\n * IS. A consumer wanting to reclaim disk had to re-derive the suffixes by reading this source — and\n * one did, which means a suffix changing here would have left its classifier silently mislabelling\n * files on a path that deletes them.\n *\n * This is deliberately NOT a garbage collector. Retention is policy — how many days, how many to\n * keep, which session is live, whether to delete at all — and policy belongs to the application,\n * which is the only one that can know. What belongs here is the half only the SDK can answer.\n *\n * `undefined` means \"not written by this SDK\", and it is the answer that matters most: a caller\n * deleting what it does not recognise is how an editor's swap file gets collected. The `temp` case\n * defers to {@link atomicWriteTempTarget} rather than matching `.tmp`, for exactly that reason.\n */\nexport function classifySessionArtifact(\n name: string,\n isDirectory: boolean,\n): SessionArtifact | undefined {\n // `withFileLock` takes its lock by `mkdir`, so the same name as a plain file is not ours.\n if (name.endsWith(\".jsonl.lock\")) return isDirectory ? \"lock-directory\" : undefined;\n if (isDirectory) return undefined;\n if (name.endsWith(\".jsonl\")) return \"transcript\";\n if (name.endsWith(\".writer.lock\")) return \"writer-lock\";\n if (atomicWriteTempTarget(name) !== undefined) return \"temp\";\n return undefined;\n}\n","/**\n * M81 — transcript operations the consumer was doing by hand INSIDE the framework's own store.\n *\n * ## What this replaces\n *\n * `agents/lib/session/backtrack.ts:188` (agent-builder) wrote straight into the session store with a\n * bare `writeFileSync` — no atomicity, no lock, no API. 243 lines re-implementing parse, cut and\n * write for a format the framework owns. The consumer is not at fault: nothing here was reachable.\n *\n * ## The rule that travels WITH the operation\n *\n * `rules/audit-trail-rotation.md § Session transcripts (M60)` defines a NEVER-delete list — the live\n * pointer, the most recent transcript, and any active registry entry. That rule lived in the\n * CONSUMER. Moving the operation here without moving the rule would ship an API able to destroy\n * exactly what the rule protects — the same shape of defect as M80's `reconcileUpdateGoalStatus`:\n * critical knowledge outside the module that needs it, applied by convention.\n *\n * So `forkTranscript` takes `liveSessionPaths` and refuses, with a TYPED error, to write over any of\n * them. The caller supplies the list because only the caller knows which session is live; the\n * enforcement lives here because that is where the write happens.\n *\n * @internal\n */\n\nimport { closeSync, fstatSync, openSync, readFileSync, readSync, writeSync } from \"node:fs\";\n\nimport { TheokitAgentError } from \"../../errors.js\";\n\n/**\n * M81 — the target is a protected session (live pointer / most-recent transcript / active entry).\n *\n * Typed rather than a bare `Error` because the caller must distinguish \"this session is protected\"\n * from \"the disk is full\": the first is a correct refusal, the second is an incident.\n *\n * RENAMED from `LiveSessionError` (2026-09-01). `session-guard.ts` exports a DIFFERENT class of that\n * name from the root barrel, with an incompatible shape — `(sessionId, reason)` and a `reason`\n * field, against this one's `(path)` and `code: \"live_session_protected\"` — and `.` and\n * `./persistence` are both declared subpaths, so one consumer can hold both. `instanceof` never\n * crossed the pair, so a `catch` checking the root import silently missed this one and ran its\n * fallback for a condition it believed it handled; `err.name` matched BOTH, so a name check looked\n * right and then read `err.reason`, which only the other one has. The names now say what each\n * refusal is about: destroying a SESSION, versus overwriting a TRANSCRIPT file.\n */\nexport class LiveTranscriptError extends TheokitAgentError {\n override readonly name = \"LiveTranscriptError\";\n\n constructor(readonly path: string) {\n super(\n `refusing to write over a live session transcript: ${path}. ` +\n \"Fork to a new id instead — the live pointer, the most recent transcript and any active \" +\n \"registry entry are never overwritten (audit-trail rotation, M60).\",\n { code: \"live_session_protected\", isRetryable: false },\n );\n }\n}\n\n/** Options for {@link forkTranscript}. */\nexport interface ForkTranscriptOptions {\n /** Keep records `[0, beforeRecordIndex)`. Omit to copy the whole transcript. */\n readonly beforeRecordIndex?: number;\n /**\n * Paths that must never be written over — the live pointer, the most recent transcript, any active\n * registry entry. The caller supplies them because only the caller knows which session is live.\n */\n readonly liveSessionPaths?: readonly string[];\n /**\n * M107 — permission bits for the created destination. Default: `0o600`.\n *\n * A transcript carries the conversation. Before M107 no mode was passed at all, so the file was\n * born `0o666 & ~umask` — measured `0o664` (group-WRITABLE) on a `umask 002` machine, `0o644` on\n * `umask 022`, `0o466` on `umask 0200`. This is a DEFAULT and not a required knob on purpose: a\n * knob would reach zero consumers by omission, which is the failure mode that matters.\n *\n * As with any `open` mode, the `umask` may still CLEAR bits — under `umask 0200` the result is\n * `0o400`. That is accepted: the invariant bought here is \"neither group nor others\", and `0o400`\n * satisfies it more strictly. The SDK deliberately does not `fchmod` the default back, because\n * that would hand back a bit the operator asked to remove.\n */\n readonly mode?: number;\n}\n\n/**\n * Copy `src` into `dst`, keeping the first `beforeRecordIndex` records. The SOURCE is never touched.\n *\n * Atomicity comes from `wx` (exclusive create): two concurrent forks to the same destination cannot\n * both succeed — the loser gets `EEXIST` rather than writing over a half-written file. That is also\n * why an existing destination is a refusal, not a silent overwrite: losing a transcript without an\n * error is the worst failure mode for an operation that touches user sessions.\n */\nexport function forkTranscript(\n src: string,\n dst: string,\n options: ForkTranscriptOptions = {},\n): void {\n for (const live of options.liveSessionPaths ?? []) {\n if (live === dst) throw new LiveTranscriptError(dst);\n }\n\n const lines = readFileSync(src, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.trim().length > 0);\n const kept =\n options.beforeRecordIndex === undefined ? lines : lines.slice(0, options.beforeRecordIndex);\n const body = kept.length > 0 ? `${kept.join(\"\\n\")}\\n` : \"\";\n\n // `wx` — fails with EEXIST instead of truncating. The exclusivity IS the concurrency guarantee,\n // and M107 only added the third argument: the mode. See `ForkTranscriptOptions.mode`.\n const fd = openSync(dst, \"wx\", options.mode ?? 0o600);\n try {\n writeSync(fd, body);\n } finally {\n closeSync(fd);\n }\n}\n\n/** Options for {@link readJsonlTail}. */\nexport interface ReadJsonlTailOptions {\n /** Maximum records to return, counted from the END. */\n readonly maxRecords?: number;\n /**\n * Start the window AFTER the last record whose `subtype` (or `type`) equals this.\n *\n * Matched STRUCTURALLY since T2.5. It used to be `line.includes(marker)`, so any message\n * mentioning the marker in its text truncated the read — silently, with a successful return.\n */\n readonly sinceMarker?: string;\n /** Test-only: also report how many bytes were read, to prove the read is not whole-file. */\n readonly _stats?: boolean;\n}\n\nconst TAIL_CHUNK = 64 * 1024;\n\n/**\n * Reads chunks backwards until enough complete lines have accumulated.\n *\n * Extracted from `readJsonlTail` because the read loop and the record selection are two\n * responsibilities — and together they exceeded the complexity ceiling. The buffer's first line may\n * be cut in half when the read stopped before the start of the file; that is why it is discarded.\n */\nfunction readRawTail(path: string, want: number): { lines: string[]; bytesRead: number } {\n // Opened FIRST, then sized through the descriptor. `statSync(path)` followed by\n // `openSync(path)` resolves the name twice, and `size` is what drives every read offset below —\n // so a path that changed between the two calls would have the loop seeking by one file's length\n // inside another (CodeQL js/file-system-race #19). `fstat` on the open fd describes the file\n // being read, by construction.\n const fd = openSync(path, \"r\");\n const size = fstatSync(fd).size;\n let bytesRead = 0;\n let tail = \"\";\n let pos = size;\n try {\n while (pos > 0) {\n const len = Math.min(TAIL_CHUNK, pos);\n pos -= len;\n const buf = Buffer.alloc(len);\n readSync(fd, buf, 0, len, pos);\n bytesRead += len;\n tail = buf.toString(\"utf8\") + tail;\n if (nonEmptyLines(tail).length > want) break;\n }\n } finally {\n closeSync(fd);\n }\n const lines = nonEmptyLines(tail);\n return { lines: pos > 0 ? lines.slice(1) : lines, bytesRead };\n}\n\n/** Non-empty lines, in file order. */\nfunction nonEmptyLines(text: string): string[] {\n return text.split(\"\\n\").filter((l) => l.trim().length > 0);\n}\n\n/**\n * Whether a raw JSONL line IS the marker record, rather than a line that talks about it.\n *\n * Matches on the record's own discriminants (`subtype`, then `type`) — the fields that identify\n * what a record *is*. Free text is never consulted, which is the whole point: content is the user's\n * and must not steer the reader.\n *\n * A line that does not parse is not a marker. Deciding a window boundary from bytes that are not a\n * record would be guessing, and this function exists because guessing is what it replaced.\n */\nfunction isMarkerRecord(line: string, marker: string): boolean {\n let record: { type?: unknown; subtype?: unknown };\n try {\n record = JSON.parse(line) as { type?: unknown; subtype?: unknown };\n } catch {\n return false;\n }\n return record.subtype === marker || record.type === marker;\n}\n\n/**\n * Read the LAST records of a JSONL file without loading the whole thing.\n *\n * Reads fixed-size chunks backwards from EOF until enough newlines have been seen. A session\n * transcript grows without bound; loading megabytes to show the last three turns is the cost this\n * exists to avoid — and a `slice` over a full read would be that same cost with a better name.\n */\nexport function readJsonlTail<T = Record<string, unknown>>(\n path: string,\n options: ReadJsonlTailOptions = {},\n): T[] {\n const want = options.maxRecords ?? Number.POSITIVE_INFINITY;\n const { lines, bytesRead } = readRawTail(path, want);\n\n let sel = lines;\n if (options.sinceMarker !== undefined) {\n const marker = options.sinceMarker;\n // STRUCTURAL, not `line.includes(marker)`.\n //\n // A raw substring match is true for any line that merely MENTIONS the marker — a user asking\n // \"how does compact_boundary work?\" silently truncated the window to start at their question.\n // The read then succeeded, returned fewer records than exist, and said nothing. That is the\n // measured reason the only would-be consumer kept its own reader instead of this one.\n const idx = sel.findLastIndex((l) => isMarkerRecord(l, marker));\n if (idx >= 0) sel = sel.slice(idx + 1);\n }\n if (Number.isFinite(want)) sel = sel.slice(-want);\n\n const out = sel.map((l) => JSON.parse(l) as T);\n return options._stats === true ? (Object.assign(out, { bytesRead }) as T[]) : out;\n}\n"]}
|
package/dist/persistence.d.cts
CHANGED
|
@@ -22,6 +22,7 @@ export type { FileLockOptions } from "./internal/persistence/file-lock.js";
|
|
|
22
22
|
export { withFileLock } from "./internal/persistence/file-lock.js";
|
|
23
23
|
export { sanitizeFts5Query } from "./internal/persistence/fts5-sanitize.js";
|
|
24
24
|
export { appendJsonl, JsonlParseError, loadJsonl, readJsonlIds, } from "./internal/persistence/jsonl.js";
|
|
25
|
+
export { type ListSessionsOptions, listSessions, type SessionIdSource, type SessionListing, } from "./internal/persistence/list-sessions.js";
|
|
25
26
|
export { PersistenceSchema } from "./internal/persistence/persistence-schema.js";
|
|
26
27
|
export { classifySessionArtifact, type SessionArtifact, } from "./internal/persistence/session-artifacts.js";
|
|
27
28
|
export { encodeProjectDir, legacyTranscriptPath, sessionUuidFor, transcriptPath, transcriptRoot, } from "./internal/persistence/session-transcript.js";
|
package/dist/persistence.d.ts
CHANGED
|
@@ -22,6 +22,7 @@ export type { FileLockOptions } from "./internal/persistence/file-lock.js";
|
|
|
22
22
|
export { withFileLock } from "./internal/persistence/file-lock.js";
|
|
23
23
|
export { sanitizeFts5Query } from "./internal/persistence/fts5-sanitize.js";
|
|
24
24
|
export { appendJsonl, JsonlParseError, loadJsonl, readJsonlIds, } from "./internal/persistence/jsonl.js";
|
|
25
|
+
export { type ListSessionsOptions, listSessions, type SessionIdSource, type SessionListing, } from "./internal/persistence/list-sessions.js";
|
|
25
26
|
export { PersistenceSchema } from "./internal/persistence/persistence-schema.js";
|
|
26
27
|
export { classifySessionArtifact, type SessionArtifact, } from "./internal/persistence/session-artifacts.js";
|
|
27
28
|
export { encodeProjectDir, legacyTranscriptPath, sessionUuidFor, transcriptPath, transcriptRoot, } from "./internal/persistence/session-transcript.js";
|
package/dist/persistence.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export { PersistenceSchema } from './chunk-HY66GLM6.js';
|
|
2
2
|
export { SessionBusyError, acquireSessionWriter, sessionHasWriter } from './chunk-VE6DFSKU.js';
|
|
3
|
+
import { transcriptRoot, encodeProjectDir } from './chunk-JOFVLOFY.js';
|
|
3
4
|
export { encodeProjectDir, legacyTranscriptPath, sessionUuidFor, transcriptPath, transcriptRoot } from './chunk-JOFVLOFY.js';
|
|
4
5
|
export { sanitizeFts5Query } from './chunk-WE22OXQA.js';
|
|
5
6
|
export { applyWalWithFallback, isCorruptionError, openSqliteResilient } from './chunk-6PWOWXMC.js';
|
|
@@ -9,7 +10,71 @@ import { atomicWriteTempTarget } from './chunk-3JHIFQ4I.js';
|
|
|
9
10
|
export { atomicWriteJson, atomicWriteTempTarget, atomicWriteText, replaceFileAtomic } from './chunk-3JHIFQ4I.js';
|
|
10
11
|
import { TheokitAgentError } from './chunk-ALUN2B4W.js';
|
|
11
12
|
import './chunk-CZJ6Q7CW.js';
|
|
12
|
-
import { readFileSync, openSync, writeSync, closeSync, fstatSync, readSync } from 'fs';
|
|
13
|
+
import { createReadStream, readFileSync, openSync, writeSync, closeSync, fstatSync, readSync } from 'fs';
|
|
14
|
+
import { readdir, stat } from 'fs/promises';
|
|
15
|
+
import { join } from 'path';
|
|
16
|
+
|
|
17
|
+
var DEFAULT_ID_SCAN_BYTES = 64 * 1024;
|
|
18
|
+
async function listSessions(cwd, options = {}) {
|
|
19
|
+
const dir = join(options.baseDir ?? transcriptRoot(), "projects", encodeProjectDir(cwd));
|
|
20
|
+
let names;
|
|
21
|
+
try {
|
|
22
|
+
names = await readdir(dir);
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const name of names) {
|
|
28
|
+
if (!name.endsWith(".jsonl")) continue;
|
|
29
|
+
const transcript = join(dir, name);
|
|
30
|
+
let modifiedAt;
|
|
31
|
+
try {
|
|
32
|
+
modifiedAt = (await stat(transcript)).mtime;
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const id = await readSessionId(transcript, options.idScanBytes ?? DEFAULT_ID_SCAN_BYTES);
|
|
37
|
+
out.push({
|
|
38
|
+
id,
|
|
39
|
+
idSource: id === void 0 ? "unavailable" : "transcript",
|
|
40
|
+
transcript,
|
|
41
|
+
modifiedAt
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
async function readSessionId(path, maxBytes) {
|
|
47
|
+
let buffered = "";
|
|
48
|
+
try {
|
|
49
|
+
const stream = createReadStream(path, { encoding: "utf8", end: maxBytes - 1 });
|
|
50
|
+
for await (const chunk of stream) {
|
|
51
|
+
buffered += chunk;
|
|
52
|
+
const lines = buffered.split("\n");
|
|
53
|
+
buffered = lines.pop() ?? "";
|
|
54
|
+
for (const line of lines) {
|
|
55
|
+
const id = sessionIdOfLine(line);
|
|
56
|
+
if (id !== void 0) {
|
|
57
|
+
stream.destroy();
|
|
58
|
+
return id;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
return void 0;
|
|
64
|
+
}
|
|
65
|
+
return sessionIdOfLine(buffered);
|
|
66
|
+
}
|
|
67
|
+
function sessionIdOfLine(line) {
|
|
68
|
+
if (line.trim() === "") return void 0;
|
|
69
|
+
try {
|
|
70
|
+
const parsed = JSON.parse(line);
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null) return void 0;
|
|
72
|
+
const id = parsed.sessionId;
|
|
73
|
+
return typeof id === "string" && id !== "" ? id : void 0;
|
|
74
|
+
} catch {
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
13
78
|
|
|
14
79
|
// src/internal/persistence/session-artifacts.ts
|
|
15
80
|
function classifySessionArtifact(name, isDirectory) {
|
|
@@ -95,6 +160,6 @@ function readJsonlTail(path, options = {}) {
|
|
|
95
160
|
return options._stats === true ? Object.assign(out, { bytesRead }) : out;
|
|
96
161
|
}
|
|
97
162
|
|
|
98
|
-
export { LiveTranscriptError as LiveSessionError, LiveTranscriptError, classifySessionArtifact, forkTranscript, readJsonlTail };
|
|
163
|
+
export { LiveTranscriptError as LiveSessionError, LiveTranscriptError, classifySessionArtifact, forkTranscript, listSessions, readJsonlTail };
|
|
99
164
|
//# sourceMappingURL=persistence.js.map
|
|
100
165
|
//# sourceMappingURL=persistence.js.map
|
package/dist/persistence.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/internal/persistence/session-artifacts.ts","../src/internal/persistence/transcript-ops.ts"],"names":[],"mappings":"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","file":"persistence.js","sourcesContent":["import { atomicWriteTempTarget } from \"./atomic-write.js\";\n\n/**\n * The kinds of file this SDK leaves in a project's transcript directory.\n *\n * - `transcript` — the session itself (`transcriptPath`).\n * - `writer-lock` — the cross-process writer lease (`session-writer.ts`, `<file>.writer.lock`).\n * - `lock-directory` — `withFileLock`'s companion, taken by `mkdir`, so it is a DIRECTORY.\n * - `temp` — what `replaceFileAtomic` leaves when a process dies between the open and the rename.\n */\nexport type SessionArtifact = \"transcript\" | \"writer-lock\" | \"lock-directory\" | \"temp\";\n\n/**\n * U-1 — what is this entry, if it is one of ours?\n *\n * The SDK writes four kinds of file into a project directory and reasons about none of them\n * afterwards: there is no retention, no collector, and there was no way even to ask what an entry\n * IS. A consumer wanting to reclaim disk had to re-derive the suffixes by reading this source — and\n * one did, which means a suffix changing here would have left its classifier silently mislabelling\n * files on a path that deletes them.\n *\n * This is deliberately NOT a garbage collector. Retention is policy — how many days, how many to\n * keep, which session is live, whether to delete at all — and policy belongs to the application,\n * which is the only one that can know. What belongs here is the half only the SDK can answer.\n *\n * `undefined` means \"not written by this SDK\", and it is the answer that matters most: a caller\n * deleting what it does not recognise is how an editor's swap file gets collected. The `temp` case\n * defers to {@link atomicWriteTempTarget} rather than matching `.tmp`, for exactly that reason.\n */\nexport function classifySessionArtifact(\n name: string,\n isDirectory: boolean,\n): SessionArtifact | undefined {\n // `withFileLock` takes its lock by `mkdir`, so the same name as a plain file is not ours.\n if (name.endsWith(\".jsonl.lock\")) return isDirectory ? \"lock-directory\" : undefined;\n if (isDirectory) return undefined;\n if (name.endsWith(\".jsonl\")) return \"transcript\";\n if (name.endsWith(\".writer.lock\")) return \"writer-lock\";\n if (atomicWriteTempTarget(name) !== undefined) return \"temp\";\n return undefined;\n}\n","/**\n * M81 — transcript operations the consumer was doing by hand INSIDE the framework's own store.\n *\n * ## What this replaces\n *\n * `agents/lib/session/backtrack.ts:188` (agent-builder) wrote straight into the session store with a\n * bare `writeFileSync` — no atomicity, no lock, no API. 243 lines re-implementing parse, cut and\n * write for a format the framework owns. The consumer is not at fault: nothing here was reachable.\n *\n * ## The rule that travels WITH the operation\n *\n * `rules/audit-trail-rotation.md § Session transcripts (M60)` defines a NEVER-delete list — the live\n * pointer, the most recent transcript, and any active registry entry. That rule lived in the\n * CONSUMER. Moving the operation here without moving the rule would ship an API able to destroy\n * exactly what the rule protects — the same shape of defect as M80's `reconcileUpdateGoalStatus`:\n * critical knowledge outside the module that needs it, applied by convention.\n *\n * So `forkTranscript` takes `liveSessionPaths` and refuses, with a TYPED error, to write over any of\n * them. The caller supplies the list because only the caller knows which session is live; the\n * enforcement lives here because that is where the write happens.\n *\n * @internal\n */\n\nimport { closeSync, fstatSync, openSync, readFileSync, readSync, writeSync } from \"node:fs\";\n\nimport { TheokitAgentError } from \"../../errors.js\";\n\n/**\n * M81 — the target is a protected session (live pointer / most-recent transcript / active entry).\n *\n * Typed rather than a bare `Error` because the caller must distinguish \"this session is protected\"\n * from \"the disk is full\": the first is a correct refusal, the second is an incident.\n *\n * RENAMED from `LiveSessionError` (2026-09-01). `session-guard.ts` exports a DIFFERENT class of that\n * name from the root barrel, with an incompatible shape — `(sessionId, reason)` and a `reason`\n * field, against this one's `(path)` and `code: \"live_session_protected\"` — and `.` and\n * `./persistence` are both declared subpaths, so one consumer can hold both. `instanceof` never\n * crossed the pair, so a `catch` checking the root import silently missed this one and ran its\n * fallback for a condition it believed it handled; `err.name` matched BOTH, so a name check looked\n * right and then read `err.reason`, which only the other one has. The names now say what each\n * refusal is about: destroying a SESSION, versus overwriting a TRANSCRIPT file.\n */\nexport class LiveTranscriptError extends TheokitAgentError {\n override readonly name = \"LiveTranscriptError\";\n\n constructor(readonly path: string) {\n super(\n `refusing to write over a live session transcript: ${path}. ` +\n \"Fork to a new id instead — the live pointer, the most recent transcript and any active \" +\n \"registry entry are never overwritten (audit-trail rotation, M60).\",\n { code: \"live_session_protected\", isRetryable: false },\n );\n }\n}\n\n/** Options for {@link forkTranscript}. */\nexport interface ForkTranscriptOptions {\n /** Keep records `[0, beforeRecordIndex)`. Omit to copy the whole transcript. */\n readonly beforeRecordIndex?: number;\n /**\n * Paths that must never be written over — the live pointer, the most recent transcript, any active\n * registry entry. The caller supplies them because only the caller knows which session is live.\n */\n readonly liveSessionPaths?: readonly string[];\n /**\n * M107 — permission bits for the created destination. Default: `0o600`.\n *\n * A transcript carries the conversation. Before M107 no mode was passed at all, so the file was\n * born `0o666 & ~umask` — measured `0o664` (group-WRITABLE) on a `umask 002` machine, `0o644` on\n * `umask 022`, `0o466` on `umask 0200`. This is a DEFAULT and not a required knob on purpose: a\n * knob would reach zero consumers by omission, which is the failure mode that matters.\n *\n * As with any `open` mode, the `umask` may still CLEAR bits — under `umask 0200` the result is\n * `0o400`. That is accepted: the invariant bought here is \"neither group nor others\", and `0o400`\n * satisfies it more strictly. The SDK deliberately does not `fchmod` the default back, because\n * that would hand back a bit the operator asked to remove.\n */\n readonly mode?: number;\n}\n\n/**\n * Copy `src` into `dst`, keeping the first `beforeRecordIndex` records. The SOURCE is never touched.\n *\n * Atomicity comes from `wx` (exclusive create): two concurrent forks to the same destination cannot\n * both succeed — the loser gets `EEXIST` rather than writing over a half-written file. That is also\n * why an existing destination is a refusal, not a silent overwrite: losing a transcript without an\n * error is the worst failure mode for an operation that touches user sessions.\n */\nexport function forkTranscript(\n src: string,\n dst: string,\n options: ForkTranscriptOptions = {},\n): void {\n for (const live of options.liveSessionPaths ?? []) {\n if (live === dst) throw new LiveTranscriptError(dst);\n }\n\n const lines = readFileSync(src, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.trim().length > 0);\n const kept =\n options.beforeRecordIndex === undefined ? lines : lines.slice(0, options.beforeRecordIndex);\n const body = kept.length > 0 ? `${kept.join(\"\\n\")}\\n` : \"\";\n\n // `wx` — fails with EEXIST instead of truncating. The exclusivity IS the concurrency guarantee,\n // and M107 only added the third argument: the mode. See `ForkTranscriptOptions.mode`.\n const fd = openSync(dst, \"wx\", options.mode ?? 0o600);\n try {\n writeSync(fd, body);\n } finally {\n closeSync(fd);\n }\n}\n\n/** Options for {@link readJsonlTail}. */\nexport interface ReadJsonlTailOptions {\n /** Maximum records to return, counted from the END. */\n readonly maxRecords?: number;\n /**\n * Start the window AFTER the last record whose `subtype` (or `type`) equals this.\n *\n * Matched STRUCTURALLY since T2.5. It used to be `line.includes(marker)`, so any message\n * mentioning the marker in its text truncated the read — silently, with a successful return.\n */\n readonly sinceMarker?: string;\n /** Test-only: also report how many bytes were read, to prove the read is not whole-file. */\n readonly _stats?: boolean;\n}\n\nconst TAIL_CHUNK = 64 * 1024;\n\n/**\n * Reads chunks backwards until enough complete lines have accumulated.\n *\n * Extracted from `readJsonlTail` because the read loop and the record selection are two\n * responsibilities — and together they exceeded the complexity ceiling. The buffer's first line may\n * be cut in half when the read stopped before the start of the file; that is why it is discarded.\n */\nfunction readRawTail(path: string, want: number): { lines: string[]; bytesRead: number } {\n // Opened FIRST, then sized through the descriptor. `statSync(path)` followed by\n // `openSync(path)` resolves the name twice, and `size` is what drives every read offset below —\n // so a path that changed between the two calls would have the loop seeking by one file's length\n // inside another (CodeQL js/file-system-race #19). `fstat` on the open fd describes the file\n // being read, by construction.\n const fd = openSync(path, \"r\");\n const size = fstatSync(fd).size;\n let bytesRead = 0;\n let tail = \"\";\n let pos = size;\n try {\n while (pos > 0) {\n const len = Math.min(TAIL_CHUNK, pos);\n pos -= len;\n const buf = Buffer.alloc(len);\n readSync(fd, buf, 0, len, pos);\n bytesRead += len;\n tail = buf.toString(\"utf8\") + tail;\n if (nonEmptyLines(tail).length > want) break;\n }\n } finally {\n closeSync(fd);\n }\n const lines = nonEmptyLines(tail);\n return { lines: pos > 0 ? lines.slice(1) : lines, bytesRead };\n}\n\n/** Non-empty lines, in file order. */\nfunction nonEmptyLines(text: string): string[] {\n return text.split(\"\\n\").filter((l) => l.trim().length > 0);\n}\n\n/**\n * Whether a raw JSONL line IS the marker record, rather than a line that talks about it.\n *\n * Matches on the record's own discriminants (`subtype`, then `type`) — the fields that identify\n * what a record *is*. Free text is never consulted, which is the whole point: content is the user's\n * and must not steer the reader.\n *\n * A line that does not parse is not a marker. Deciding a window boundary from bytes that are not a\n * record would be guessing, and this function exists because guessing is what it replaced.\n */\nfunction isMarkerRecord(line: string, marker: string): boolean {\n let record: { type?: unknown; subtype?: unknown };\n try {\n record = JSON.parse(line) as { type?: unknown; subtype?: unknown };\n } catch {\n return false;\n }\n return record.subtype === marker || record.type === marker;\n}\n\n/**\n * Read the LAST records of a JSONL file without loading the whole thing.\n *\n * Reads fixed-size chunks backwards from EOF until enough newlines have been seen. A session\n * transcript grows without bound; loading megabytes to show the last three turns is the cost this\n * exists to avoid — and a `slice` over a full read would be that same cost with a better name.\n */\nexport function readJsonlTail<T = Record<string, unknown>>(\n path: string,\n options: ReadJsonlTailOptions = {},\n): T[] {\n const want = options.maxRecords ?? Number.POSITIVE_INFINITY;\n const { lines, bytesRead } = readRawTail(path, want);\n\n let sel = lines;\n if (options.sinceMarker !== undefined) {\n const marker = options.sinceMarker;\n // STRUCTURAL, not `line.includes(marker)`.\n //\n // A raw substring match is true for any line that merely MENTIONS the marker — a user asking\n // \"how does compact_boundary work?\" silently truncated the window to start at their question.\n // The read then succeeded, returned fewer records than exist, and said nothing. That is the\n // measured reason the only would-be consumer kept its own reader instead of this one.\n const idx = sel.findLastIndex((l) => isMarkerRecord(l, marker));\n if (idx >= 0) sel = sel.slice(idx + 1);\n }\n if (Number.isFinite(want)) sel = sel.slice(-want);\n\n const out = sel.map((l) => JSON.parse(l) as T);\n return options._stats === true ? (Object.assign(out, { bytesRead }) as T[]) : out;\n}\n"]}
|
|
1
|
+
{"version":3,"sources":["../src/internal/persistence/list-sessions.ts","../src/internal/persistence/session-artifacts.ts","../src/internal/persistence/transcript-ops.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;AA8FA,IAAM,wBAAwB,EAAA,GAAK,IAAA;AAenC,eAAsB,YAAA,CACpB,GAAA,EACA,OAAA,GAA+B,EAAC,EACI;AACpC,EAAA,MAAM,GAAA,GAAM,KAAK,OAAA,CAAQ,OAAA,IAAW,gBAAe,EAAG,UAAA,EAAY,gBAAA,CAAiB,GAAG,CAAC,CAAA;AACvF,EAAA,IAAI,KAAA;AACJ,EAAA,IAAI;AACF,IAAA,KAAA,GAAQ,MAAM,QAAQ,GAAG,CAAA;AAAA,EAC3B,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,EAAC;AAAA,EACV;AAEA,EAAA,MAAM,MAAwB,EAAC;AAC/B,EAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,IAAA,IAAI,CAAC,IAAA,CAAK,QAAA,CAAS,QAAQ,CAAA,EAAG;AAC9B,IAAA,MAAM,UAAA,GAAa,IAAA,CAAK,GAAA,EAAK,IAAI,CAAA;AACjC,IAAA,IAAI,UAAA;AACJ,IAAA,IAAI;AACF,MAAA,UAAA,GAAA,CAAc,MAAM,IAAA,CAAK,UAAU,CAAA,EAAG,KAAA;AAAA,IACxC,CAAA,CAAA,MAAQ;AAGN,MAAA;AAAA,IACF;AACA,IAAA,MAAM,KAAK,MAAM,aAAA,CAAc,UAAA,EAAY,OAAA,CAAQ,eAAe,qBAAqB,CAAA;AACvF,IAAA,GAAA,CAAI,IAAA,CAAK;AAAA,MACP,EAAA;AAAA,MACA,QAAA,EAAU,EAAA,KAAO,MAAA,GAAY,aAAA,GAAgB,YAAA;AAAA,MAC7C,UAAA;AAAA,MACA;AAAA,KACD,CAAA;AAAA,EACH;AACA,EAAA,OAAO,GAAA;AACT;AAUA,eAAe,aAAA,CAAc,MAAc,QAAA,EAA+C;AACxF,EAAA,IAAI,QAAA,GAAW,EAAA;AACf,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,GAAS,iBAAiB,IAAA,EAAM,EAAE,UAAU,MAAA,EAAQ,GAAA,EAAK,QAAA,GAAW,CAAA,EAAG,CAAA;AAC7E,IAAA,WAAA,MAAiB,SAAS,MAAA,EAAQ;AAChC,MAAA,QAAA,IAAY,KAAA;AACZ,MAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,KAAA,CAAM,IAAI,CAAA;AACjC,MAAA,QAAA,GAAW,KAAA,CAAM,KAAI,IAAK,EAAA;AAC1B,MAAA,KAAA,MAAW,QAAQ,KAAA,EAAO;AACxB,QAAA,MAAM,EAAA,GAAK,gBAAgB,IAAI,CAAA;AAC/B,QAAA,IAAI,OAAO,KAAA,CAAA,EAAW;AACpB,UAAA,MAAA,CAAO,OAAA,EAAQ;AACf,UAAA,OAAO,EAAA;AAAA,QACT;AAAA,MACF;AAAA,IACF;AAAA,EACF,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,MAAA;AAAA,EACT;AACA,EAAA,OAAO,gBAAgB,QAAQ,CAAA;AACjC;AAEA,SAAS,gBAAgB,IAAA,EAAkC;AACzD,EAAA,IAAI,IAAA,CAAK,IAAA,EAAK,KAAM,EAAA,EAAI,OAAO,MAAA;AAC/B,EAAA,IAAI;AACF,IAAA,MAAM,MAAA,GAAkB,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA;AACvC,IAAA,IAAI,OAAO,MAAA,KAAW,QAAA,IAAY,MAAA,KAAW,MAAM,OAAO,KAAA,CAAA;AAC1D,IAAA,MAAM,KAAM,MAAA,CAAmC,SAAA;AAC/C,IAAA,OAAO,OAAO,EAAA,KAAO,QAAA,IAAY,EAAA,KAAO,KAAK,EAAA,GAAK,KAAA,CAAA;AAAA,EACpD,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,MAAA;AAAA,EACT;AACF;;;AC3JO,SAAS,uBAAA,CACd,MACA,WAAA,EAC6B;AAE7B,EAAA,IAAI,KAAK,QAAA,CAAS,aAAa,CAAA,EAAG,OAAO,cAAc,gBAAA,GAAmB,MAAA;AAC1E,EAAA,IAAI,aAAa,OAAO,MAAA;AACxB,EAAA,IAAI,IAAA,CAAK,QAAA,CAAS,QAAQ,CAAA,EAAG,OAAO,YAAA;AACpC,EAAA,IAAI,IAAA,CAAK,QAAA,CAAS,cAAc,CAAA,EAAG,OAAO,aAAA;AAC1C,EAAA,IAAI,qBAAA,CAAsB,IAAI,CAAA,KAAM,MAAA,EAAW,OAAO,MAAA;AACtD,EAAA,OAAO,MAAA;AACT;ACGO,IAAM,mBAAA,GAAN,cAAkC,iBAAA,CAAkB;AAAA,EAGzD,YAAqB,IAAA,EAAc;AACjC,IAAA,KAAA;AAAA,MACE,qDAAqD,IAAI,CAAA,+JAAA,CAAA;AAAA,MAGzD,EAAE,IAAA,EAAM,wBAAA,EAA0B,WAAA,EAAa,KAAA;AAAM,KACvD;AANmB,IAAA,IAAA,CAAA,IAAA,GAAA,IAAA;AAAA,EAOrB;AAAA,EAPqB,IAAA;AAAA,EAFH,IAAA,GAAO,qBAAA;AAU3B;AAmCO,SAAS,cAAA,CACd,GAAA,EACA,GAAA,EACA,OAAA,GAAiC,EAAC,EAC5B;AACN,EAAA,KAAA,MAAW,IAAA,IAAQ,OAAA,CAAQ,gBAAA,IAAoB,EAAC,EAAG;AACjD,IAAA,IAAI,IAAA,KAAS,GAAA,EAAK,MAAM,IAAI,oBAAoB,GAAG,CAAA;AAAA,EACrD;AAEA,EAAA,MAAM,KAAA,GAAQ,YAAA,CAAa,GAAA,EAAK,MAAM,EACnC,KAAA,CAAM,IAAI,CAAA,CACV,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,IAAA,EAAK,CAAE,SAAS,CAAC,CAAA;AACpC,EAAA,MAAM,IAAA,GACJ,QAAQ,iBAAA,KAAsB,MAAA,GAAY,QAAQ,KAAA,CAAM,KAAA,CAAM,CAAA,EAAG,OAAA,CAAQ,iBAAiB,CAAA;AAC5F,EAAA,MAAM,IAAA,GAAO,KAAK,MAAA,GAAS,CAAA,GAAI,GAAG,IAAA,CAAK,IAAA,CAAK,IAAI,CAAC;AAAA,CAAA,GAAO,EAAA;AAIxD,EAAA,MAAM,KAAK,QAAA,CAAS,GAAA,EAAK,IAAA,EAAM,OAAA,CAAQ,QAAQ,GAAK,CAAA;AACpD,EAAA,IAAI;AACF,IAAA,SAAA,CAAU,IAAI,IAAI,CAAA;AAAA,EACpB,CAAA,SAAE;AACA,IAAA,SAAA,CAAU,EAAE,CAAA;AAAA,EACd;AACF;AAiBA,IAAM,aAAa,EAAA,GAAK,IAAA;AASxB,SAAS,WAAA,CAAY,MAAc,IAAA,EAAsD;AAMvF,EAAA,MAAM,EAAA,GAAK,QAAA,CAAS,IAAA,EAAM,GAAG,CAAA;AAC7B,EAAA,MAAM,IAAA,GAAO,SAAA,CAAU,EAAE,CAAA,CAAE,IAAA;AAC3B,EAAA,IAAI,SAAA,GAAY,CAAA;AAChB,EAAA,IAAI,IAAA,GAAO,EAAA;AACX,EAAA,IAAI,GAAA,GAAM,IAAA;AACV,EAAA,IAAI;AACF,IAAA,OAAO,MAAM,CAAA,EAAG;AACd,MAAA,MAAM,GAAA,GAAM,IAAA,CAAK,GAAA,CAAI,UAAA,EAAY,GAAG,CAAA;AACpC,MAAA,GAAA,IAAO,GAAA;AACP,MAAA,MAAM,GAAA,GAAM,MAAA,CAAO,KAAA,CAAM,GAAG,CAAA;AAC5B,MAAA,QAAA,CAAS,EAAA,EAAI,GAAA,EAAK,CAAA,EAAG,GAAA,EAAK,GAAG,CAAA;AAC7B,MAAA,SAAA,IAAa,GAAA;AACb,MAAA,IAAA,GAAO,GAAA,CAAI,QAAA,CAAS,MAAM,CAAA,GAAI,IAAA;AAC9B,MAAA,IAAI,aAAA,CAAc,IAAI,CAAA,CAAE,MAAA,GAAS,IAAA,EAAM;AAAA,IACzC;AAAA,EACF,CAAA,SAAE;AACA,IAAA,SAAA,CAAU,EAAE,CAAA;AAAA,EACd;AACA,EAAA,MAAM,KAAA,GAAQ,cAAc,IAAI,CAAA;AAChC,EAAA,OAAO,EAAE,OAAO,GAAA,GAAM,CAAA,GAAI,MAAM,KAAA,CAAM,CAAC,CAAA,GAAI,KAAA,EAAO,SAAA,EAAU;AAC9D;AAGA,SAAS,cAAc,IAAA,EAAwB;AAC7C,EAAA,OAAO,IAAA,CAAK,KAAA,CAAM,IAAI,CAAA,CAAE,MAAA,CAAO,CAAC,CAAA,KAAM,CAAA,CAAE,IAAA,EAAK,CAAE,MAAA,GAAS,CAAC,CAAA;AAC3D;AAYA,SAAS,cAAA,CAAe,MAAc,MAAA,EAAyB;AAC7D,EAAA,IAAI,MAAA;AACJ,EAAA,IAAI;AACF,IAAA,MAAA,GAAS,IAAA,CAAK,MAAM,IAAI,CAAA;AAAA,EAC1B,CAAA,CAAA,MAAQ;AACN,IAAA,OAAO,KAAA;AAAA,EACT;AACA,EAAA,OAAO,MAAA,CAAO,OAAA,KAAY,MAAA,IAAU,MAAA,CAAO,IAAA,KAAS,MAAA;AACtD;AASO,SAAS,aAAA,CACd,IAAA,EACA,OAAA,GAAgC,EAAC,EAC5B;AACL,EAAA,MAAM,IAAA,GAAO,OAAA,CAAQ,UAAA,IAAc,MAAA,CAAO,iBAAA;AAC1C,EAAA,MAAM,EAAE,KAAA,EAAO,SAAA,EAAU,GAAI,WAAA,CAAY,MAAM,IAAI,CAAA;AAEnD,EAAA,IAAI,GAAA,GAAM,KAAA;AACV,EAAA,IAAI,OAAA,CAAQ,gBAAgB,MAAA,EAAW;AACrC,IAAA,MAAM,SAAS,OAAA,CAAQ,WAAA;AAOvB,IAAA,MAAM,GAAA,GAAM,IAAI,aAAA,CAAc,CAAC,MAAM,cAAA,CAAe,CAAA,EAAG,MAAM,CAAC,CAAA;AAC9D,IAAA,IAAI,OAAO,CAAA,EAAG,GAAA,GAAM,GAAA,CAAI,KAAA,CAAM,MAAM,CAAC,CAAA;AAAA,EACvC;AACA,EAAA,IAAI,MAAA,CAAO,SAAS,IAAI,CAAA,QAAS,GAAA,CAAI,KAAA,CAAM,CAAC,IAAI,CAAA;AAEhD,EAAA,MAAM,GAAA,GAAM,IAAI,GAAA,CAAI,CAAC,MAAM,IAAA,CAAK,KAAA,CAAM,CAAC,CAAM,CAAA;AAC7C,EAAA,OAAO,OAAA,CAAQ,WAAW,IAAA,GAAQ,MAAA,CAAO,OAAO,GAAA,EAAK,EAAE,SAAA,EAAW,CAAA,GAAY,GAAA;AAChF","file":"persistence.js","sourcesContent":["/**\n * #598 — enumerate the sessions on disk, saying where each id came from.\n *\n * ## Why this exists\n *\n * Every transcript helper this package published mapped FORWARD — `sessionUuidFor`,\n * `transcriptPath`, `legacyTranscriptPath`, `encodeProjectDir`, `transcriptRoot`. **None enumerated.**\n * So a consumer that needed the list rebuilt it, and two independent ones did, in opposite\n * directions, and both got it wrong on the first attempt:\n *\n * | consumer | direction | what broke |\n * |---|---|---|\n * | `@theokit/agents` | file → id | derived the id from the file STEM |\n * | a downstream agent runtime | id → file | compared session ids against filenames |\n *\n * The second measured, against 5.0.1: the protected set never matched, **so neither the registered\n * sessions nor the live one were protected and everything classified as an orphan** — a garbage\n * collector that would delete the session in use.\n *\n * ## Why documentation was not the fix\n *\n * `b85dab4` documented the rename thoroughly. One of those two consumers had READ it and broke\n * anyway; the other had not and broke identically. Two samples, one informed and one not, same\n * defect. If reading were sufficient the informed sample would have survived — so the cause is the\n * shape of the surface, not the reader.\n *\n * And it cannot be closed by publishing an inverse: the filename is a UUIDv8 over SHA-256, which has\n * none. Whoever holds the file must read the id from INSIDE it, and that the id lives in the first\n * record is this package's knowledge. Both consumers had to discover it by reading bytes.\n *\n * ## Why the id is not just a string\n *\n * An `id: string` that is sometimes read from the transcript and sometimes inferred from the\n * filename is the same defect one layer up: a value that reads as authoritative and occasionally is\n * not. That is precisely what produced the garbage-collector failure above.\n *\n * So every entry carries {@link SessionListing.idSource}, and an entry whose id could not be\n * determined is `undefined` rather than guessed. A caller deciding what to DELETE needs to tell\n * \"this session is not registered\" from \"I could not read this file\" — those look identical in a\n * plain list and mean opposite things.\n *\n * @public\n */\n\nimport { createReadStream } from \"node:fs\";\nimport { readdir, stat } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { encodeProjectDir, transcriptRoot } from \"./session-transcript.js\";\n\n/** How the `id` on a {@link SessionListing} was obtained. */\nexport type SessionIdSource =\n /** Read from the `sessionId` of the transcript's first well-formed record. Authoritative. */\n | \"transcript\"\n /**\n * Not determined: the file was unreadable, empty, or its first records carried no `sessionId`.\n * `id` is `undefined` — deliberately not the filename, which is a hash and not an id.\n */\n | \"unavailable\";\n\n/** One session found on disk. */\nexport interface SessionListing {\n /**\n * The session id, or `undefined` when {@link idSource} is `\"unavailable\"`.\n *\n * Never derived from the filename. The filename is a UUIDv8 over SHA-256 of the id, so treating\n * it as the id is the exact defect this function exists to prevent.\n */\n readonly id: string | undefined;\n /** Where {@link id} came from. Check this before acting on `id`. */\n readonly idSource: SessionIdSource;\n /** Absolute path to the `.jsonl` transcript. */\n readonly transcript: string;\n /** Last modification time of the transcript. */\n readonly modifiedAt: Date;\n}\n\n/** Options for {@link listSessions}. */\nexport interface ListSessionsOptions {\n /**\n * Root under which `projects/<encoded-cwd>/` lives. Defaults to {@link transcriptRoot}, which\n * honours `THEOKIT_HOME`.\n */\n readonly baseDir?: string;\n /**\n * How many bytes of each transcript to read looking for the id. Default 65536.\n *\n * A cap rather than a full read because a transcript grows without bound and the id is in the\n * first record. A session whose id is not in the first 64KB reports `\"unavailable\"` rather than\n * being read to the end — bounded work with a declared outcome beats an unbounded read.\n */\n readonly idScanBytes?: number;\n}\n\nconst DEFAULT_ID_SCAN_BYTES = 64 * 1024;\n\n/**\n * Every session transcript under `<baseDir>/projects/<encoded-cwd>/`, with the id read from inside\n * each file.\n *\n * An absent directory yields `[]` — a cwd with no sessions is the common case, not an error.\n *\n * ```ts\n * for (const s of await listSessions(process.cwd())) {\n * if (s.idSource === \"unavailable\") continue; // do not guess, and do not delete\n * …\n * }\n * ```\n */\nexport async function listSessions(\n cwd: string,\n options: ListSessionsOptions = {},\n): Promise<readonly SessionListing[]> {\n const dir = join(options.baseDir ?? transcriptRoot(), \"projects\", encodeProjectDir(cwd));\n let names: string[];\n try {\n names = await readdir(dir);\n } catch {\n return [];\n }\n\n const out: SessionListing[] = [];\n for (const name of names) {\n if (!name.endsWith(\".jsonl\")) continue;\n const transcript = join(dir, name);\n let modifiedAt: Date;\n try {\n modifiedAt = (await stat(transcript)).mtime;\n } catch {\n // Vanished between readdir and stat — a live session being rotated. Skipping is correct:\n // reporting a file that no longer exists would be worse than omitting it.\n continue;\n }\n const id = await readSessionId(transcript, options.idScanBytes ?? DEFAULT_ID_SCAN_BYTES);\n out.push({\n id,\n idSource: id === undefined ? \"unavailable\" : \"transcript\",\n transcript,\n modifiedAt,\n });\n }\n return out;\n}\n\n/**\n * The `sessionId` of the first well-formed record, or `undefined`.\n *\n * Reads at most `maxBytes` and stops at the first record that yields one. Tolerant of malformed\n * lines for the same reason `readTranscript` is: a truncated final line in a live transcript is\n * normal, and failing the whole listing over it would make the function useless exactly when it\n * matters.\n */\nasync function readSessionId(path: string, maxBytes: number): Promise<string | undefined> {\n let buffered = \"\";\n try {\n const stream = createReadStream(path, { encoding: \"utf8\", end: maxBytes - 1 });\n for await (const chunk of stream) {\n buffered += chunk as string;\n const lines = buffered.split(\"\\n\");\n buffered = lines.pop() ?? \"\";\n for (const line of lines) {\n const id = sessionIdOfLine(line);\n if (id !== undefined) {\n stream.destroy();\n return id;\n }\n }\n }\n } catch {\n return undefined;\n }\n return sessionIdOfLine(buffered);\n}\n\nfunction sessionIdOfLine(line: string): string | undefined {\n if (line.trim() === \"\") return undefined;\n try {\n const parsed: unknown = JSON.parse(line);\n if (typeof parsed !== \"object\" || parsed === null) return undefined;\n const id = (parsed as { sessionId?: unknown }).sessionId;\n return typeof id === \"string\" && id !== \"\" ? id : undefined;\n } catch {\n return undefined;\n }\n}\n","import { atomicWriteTempTarget } from \"./atomic-write.js\";\n\n/**\n * The kinds of file this SDK leaves in a project's transcript directory.\n *\n * - `transcript` — the session itself (`transcriptPath`).\n * - `writer-lock` — the cross-process writer lease (`session-writer.ts`, `<file>.writer.lock`).\n * - `lock-directory` — `withFileLock`'s companion, taken by `mkdir`, so it is a DIRECTORY.\n * - `temp` — what `replaceFileAtomic` leaves when a process dies between the open and the rename.\n */\nexport type SessionArtifact = \"transcript\" | \"writer-lock\" | \"lock-directory\" | \"temp\";\n\n/**\n * U-1 — what is this entry, if it is one of ours?\n *\n * The SDK writes four kinds of file into a project directory and reasons about none of them\n * afterwards: there is no retention, no collector, and there was no way even to ask what an entry\n * IS. A consumer wanting to reclaim disk had to re-derive the suffixes by reading this source — and\n * one did, which means a suffix changing here would have left its classifier silently mislabelling\n * files on a path that deletes them.\n *\n * This is deliberately NOT a garbage collector. Retention is policy — how many days, how many to\n * keep, which session is live, whether to delete at all — and policy belongs to the application,\n * which is the only one that can know. What belongs here is the half only the SDK can answer.\n *\n * `undefined` means \"not written by this SDK\", and it is the answer that matters most: a caller\n * deleting what it does not recognise is how an editor's swap file gets collected. The `temp` case\n * defers to {@link atomicWriteTempTarget} rather than matching `.tmp`, for exactly that reason.\n */\nexport function classifySessionArtifact(\n name: string,\n isDirectory: boolean,\n): SessionArtifact | undefined {\n // `withFileLock` takes its lock by `mkdir`, so the same name as a plain file is not ours.\n if (name.endsWith(\".jsonl.lock\")) return isDirectory ? \"lock-directory\" : undefined;\n if (isDirectory) return undefined;\n if (name.endsWith(\".jsonl\")) return \"transcript\";\n if (name.endsWith(\".writer.lock\")) return \"writer-lock\";\n if (atomicWriteTempTarget(name) !== undefined) return \"temp\";\n return undefined;\n}\n","/**\n * M81 — transcript operations the consumer was doing by hand INSIDE the framework's own store.\n *\n * ## What this replaces\n *\n * `agents/lib/session/backtrack.ts:188` (agent-builder) wrote straight into the session store with a\n * bare `writeFileSync` — no atomicity, no lock, no API. 243 lines re-implementing parse, cut and\n * write for a format the framework owns. The consumer is not at fault: nothing here was reachable.\n *\n * ## The rule that travels WITH the operation\n *\n * `rules/audit-trail-rotation.md § Session transcripts (M60)` defines a NEVER-delete list — the live\n * pointer, the most recent transcript, and any active registry entry. That rule lived in the\n * CONSUMER. Moving the operation here without moving the rule would ship an API able to destroy\n * exactly what the rule protects — the same shape of defect as M80's `reconcileUpdateGoalStatus`:\n * critical knowledge outside the module that needs it, applied by convention.\n *\n * So `forkTranscript` takes `liveSessionPaths` and refuses, with a TYPED error, to write over any of\n * them. The caller supplies the list because only the caller knows which session is live; the\n * enforcement lives here because that is where the write happens.\n *\n * @internal\n */\n\nimport { closeSync, fstatSync, openSync, readFileSync, readSync, writeSync } from \"node:fs\";\n\nimport { TheokitAgentError } from \"../../errors.js\";\n\n/**\n * M81 — the target is a protected session (live pointer / most-recent transcript / active entry).\n *\n * Typed rather than a bare `Error` because the caller must distinguish \"this session is protected\"\n * from \"the disk is full\": the first is a correct refusal, the second is an incident.\n *\n * RENAMED from `LiveSessionError` (2026-09-01). `session-guard.ts` exports a DIFFERENT class of that\n * name from the root barrel, with an incompatible shape — `(sessionId, reason)` and a `reason`\n * field, against this one's `(path)` and `code: \"live_session_protected\"` — and `.` and\n * `./persistence` are both declared subpaths, so one consumer can hold both. `instanceof` never\n * crossed the pair, so a `catch` checking the root import silently missed this one and ran its\n * fallback for a condition it believed it handled; `err.name` matched BOTH, so a name check looked\n * right and then read `err.reason`, which only the other one has. The names now say what each\n * refusal is about: destroying a SESSION, versus overwriting a TRANSCRIPT file.\n */\nexport class LiveTranscriptError extends TheokitAgentError {\n override readonly name = \"LiveTranscriptError\";\n\n constructor(readonly path: string) {\n super(\n `refusing to write over a live session transcript: ${path}. ` +\n \"Fork to a new id instead — the live pointer, the most recent transcript and any active \" +\n \"registry entry are never overwritten (audit-trail rotation, M60).\",\n { code: \"live_session_protected\", isRetryable: false },\n );\n }\n}\n\n/** Options for {@link forkTranscript}. */\nexport interface ForkTranscriptOptions {\n /** Keep records `[0, beforeRecordIndex)`. Omit to copy the whole transcript. */\n readonly beforeRecordIndex?: number;\n /**\n * Paths that must never be written over — the live pointer, the most recent transcript, any active\n * registry entry. The caller supplies them because only the caller knows which session is live.\n */\n readonly liveSessionPaths?: readonly string[];\n /**\n * M107 — permission bits for the created destination. Default: `0o600`.\n *\n * A transcript carries the conversation. Before M107 no mode was passed at all, so the file was\n * born `0o666 & ~umask` — measured `0o664` (group-WRITABLE) on a `umask 002` machine, `0o644` on\n * `umask 022`, `0o466` on `umask 0200`. This is a DEFAULT and not a required knob on purpose: a\n * knob would reach zero consumers by omission, which is the failure mode that matters.\n *\n * As with any `open` mode, the `umask` may still CLEAR bits — under `umask 0200` the result is\n * `0o400`. That is accepted: the invariant bought here is \"neither group nor others\", and `0o400`\n * satisfies it more strictly. The SDK deliberately does not `fchmod` the default back, because\n * that would hand back a bit the operator asked to remove.\n */\n readonly mode?: number;\n}\n\n/**\n * Copy `src` into `dst`, keeping the first `beforeRecordIndex` records. The SOURCE is never touched.\n *\n * Atomicity comes from `wx` (exclusive create): two concurrent forks to the same destination cannot\n * both succeed — the loser gets `EEXIST` rather than writing over a half-written file. That is also\n * why an existing destination is a refusal, not a silent overwrite: losing a transcript without an\n * error is the worst failure mode for an operation that touches user sessions.\n */\nexport function forkTranscript(\n src: string,\n dst: string,\n options: ForkTranscriptOptions = {},\n): void {\n for (const live of options.liveSessionPaths ?? []) {\n if (live === dst) throw new LiveTranscriptError(dst);\n }\n\n const lines = readFileSync(src, \"utf8\")\n .split(\"\\n\")\n .filter((l) => l.trim().length > 0);\n const kept =\n options.beforeRecordIndex === undefined ? lines : lines.slice(0, options.beforeRecordIndex);\n const body = kept.length > 0 ? `${kept.join(\"\\n\")}\\n` : \"\";\n\n // `wx` — fails with EEXIST instead of truncating. The exclusivity IS the concurrency guarantee,\n // and M107 only added the third argument: the mode. See `ForkTranscriptOptions.mode`.\n const fd = openSync(dst, \"wx\", options.mode ?? 0o600);\n try {\n writeSync(fd, body);\n } finally {\n closeSync(fd);\n }\n}\n\n/** Options for {@link readJsonlTail}. */\nexport interface ReadJsonlTailOptions {\n /** Maximum records to return, counted from the END. */\n readonly maxRecords?: number;\n /**\n * Start the window AFTER the last record whose `subtype` (or `type`) equals this.\n *\n * Matched STRUCTURALLY since T2.5. It used to be `line.includes(marker)`, so any message\n * mentioning the marker in its text truncated the read — silently, with a successful return.\n */\n readonly sinceMarker?: string;\n /** Test-only: also report how many bytes were read, to prove the read is not whole-file. */\n readonly _stats?: boolean;\n}\n\nconst TAIL_CHUNK = 64 * 1024;\n\n/**\n * Reads chunks backwards until enough complete lines have accumulated.\n *\n * Extracted from `readJsonlTail` because the read loop and the record selection are two\n * responsibilities — and together they exceeded the complexity ceiling. The buffer's first line may\n * be cut in half when the read stopped before the start of the file; that is why it is discarded.\n */\nfunction readRawTail(path: string, want: number): { lines: string[]; bytesRead: number } {\n // Opened FIRST, then sized through the descriptor. `statSync(path)` followed by\n // `openSync(path)` resolves the name twice, and `size` is what drives every read offset below —\n // so a path that changed between the two calls would have the loop seeking by one file's length\n // inside another (CodeQL js/file-system-race #19). `fstat` on the open fd describes the file\n // being read, by construction.\n const fd = openSync(path, \"r\");\n const size = fstatSync(fd).size;\n let bytesRead = 0;\n let tail = \"\";\n let pos = size;\n try {\n while (pos > 0) {\n const len = Math.min(TAIL_CHUNK, pos);\n pos -= len;\n const buf = Buffer.alloc(len);\n readSync(fd, buf, 0, len, pos);\n bytesRead += len;\n tail = buf.toString(\"utf8\") + tail;\n if (nonEmptyLines(tail).length > want) break;\n }\n } finally {\n closeSync(fd);\n }\n const lines = nonEmptyLines(tail);\n return { lines: pos > 0 ? lines.slice(1) : lines, bytesRead };\n}\n\n/** Non-empty lines, in file order. */\nfunction nonEmptyLines(text: string): string[] {\n return text.split(\"\\n\").filter((l) => l.trim().length > 0);\n}\n\n/**\n * Whether a raw JSONL line IS the marker record, rather than a line that talks about it.\n *\n * Matches on the record's own discriminants (`subtype`, then `type`) — the fields that identify\n * what a record *is*. Free text is never consulted, which is the whole point: content is the user's\n * and must not steer the reader.\n *\n * A line that does not parse is not a marker. Deciding a window boundary from bytes that are not a\n * record would be guessing, and this function exists because guessing is what it replaced.\n */\nfunction isMarkerRecord(line: string, marker: string): boolean {\n let record: { type?: unknown; subtype?: unknown };\n try {\n record = JSON.parse(line) as { type?: unknown; subtype?: unknown };\n } catch {\n return false;\n }\n return record.subtype === marker || record.type === marker;\n}\n\n/**\n * Read the LAST records of a JSONL file without loading the whole thing.\n *\n * Reads fixed-size chunks backwards from EOF until enough newlines have been seen. A session\n * transcript grows without bound; loading megabytes to show the last three turns is the cost this\n * exists to avoid — and a `slice` over a full read would be that same cost with a better name.\n */\nexport function readJsonlTail<T = Record<string, unknown>>(\n path: string,\n options: ReadJsonlTailOptions = {},\n): T[] {\n const want = options.maxRecords ?? Number.POSITIVE_INFINITY;\n const { lines, bytesRead } = readRawTail(path, want);\n\n let sel = lines;\n if (options.sinceMarker !== undefined) {\n const marker = options.sinceMarker;\n // STRUCTURAL, not `line.includes(marker)`.\n //\n // A raw substring match is true for any line that merely MENTIONS the marker — a user asking\n // \"how does compact_boundary work?\" silently truncated the window to start at their question.\n // The read then succeeded, returned fewer records than exist, and said nothing. That is the\n // measured reason the only would-be consumer kept its own reader instead of this one.\n const idx = sel.findLastIndex((l) => isMarkerRecord(l, marker));\n if (idx >= 0) sel = sel.slice(idx + 1);\n }\n if (Number.isFinite(want)) sel = sel.slice(-want);\n\n const out = sel.map((l) => JSON.parse(l) as T);\n return options._stats === true ? (Object.assign(out, { bytesRead }) as T[]) : out;\n}\n"]}
|
|
@@ -4,7 +4,7 @@ Every public symbol the TheoKit workspace publishes, and the exact specifier to
|
|
|
4
4
|
|
|
5
5
|
A symbol listed under two specifiers is reachable from both, but that does NOT make the two interchangeable: a class emitted separately into a subpath entry is a distinct nominal type from the one in the root bundle, so passing one where the other is expected fails on a private field. When a symbol appears twice, import it and everything it is passed to from the SAME specifier.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
1204 export(s) across 46 entry point(s).
|
|
8
8
|
|
|
9
9
|
## `@theokit/acp`
|
|
10
10
|
|
|
@@ -1202,6 +1202,8 @@ A symbol listed under two specifiers is reachable from both, but that does NOT m
|
|
|
1202
1202
|
| `isCorruptionError` | function | True when an open error indicates an unreadable / corrupt database file. |
|
|
1203
1203
|
| `JsonlParseError` | class | Raised when a JSONL line is not valid JSON or is not a JSON object. |
|
|
1204
1204
|
| `legacyTranscriptPath` | function | The path this session used BEFORE #400 made transcript filenames UUIDs. |
|
|
1205
|
+
| `listSessions` | function | Every session transcript under `<baseDir>/projects/<encoded-cwd>/`, with the id read from inside each file. |
|
|
1206
|
+
| `ListSessionsOptions` | interface | Options for {@link listSessions } . |
|
|
1205
1207
|
| `LiveSessionError` | class | M81 — the target is a protected session (live pointer / most-recent transcript / active entry). |
|
|
1206
1208
|
| `LiveTranscriptError` | class | M81 — the target is a protected session (live pointer / most-recent transcript / active entry). |
|
|
1207
1209
|
| `loadJsonl` | function | Parse a JSONL file into rows. |
|
|
@@ -1217,6 +1219,8 @@ A symbol listed under two specifiers is reachable from both, but that does NOT m
|
|
|
1217
1219
|
| `SessionArtifact` | type | The kinds of file this SDK leaves in a project's transcript directory. |
|
|
1218
1220
|
| `SessionBusyError` | class | M81 — another process already holds the writer lease for this session. |
|
|
1219
1221
|
| `sessionHasWriter` | function | Does the session have a writer **right now**? |
|
|
1222
|
+
| `SessionIdSource` | type | How the `id` on a {@link SessionListing } was obtained. |
|
|
1223
|
+
| `SessionListing` | interface | One session found on disk. |
|
|
1220
1224
|
| `sessionUuidFor` | function | The transcript filename for an agent id — always a UUID. |
|
|
1221
1225
|
| `SessionWriterLease` | interface | A held writer lease. |
|
|
1222
1226
|
| `TranscriptBlock` | type | A content block inside {@link TranscriptMessage } . |
|
package/package.json
CHANGED