@celestea/session 2.7.1

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.
@@ -0,0 +1,292 @@
1
+ /**
2
+ * `checkpoint.json` — the per-session crash / shutdown sidecar (iteration E §1.2).
3
+ *
4
+ * WHY this file exists at all: `cli-main.jsonl` is the ONLY source of truth for
5
+ * the conversation (K4), and everything derivable from it (turn counter,
6
+ * history, last outcome) is deliberately NOT persisted twice. What the log can
7
+ * NOT express is the difference between
8
+ *
9
+ * - "a turn is open because this process is running it right now" and
10
+ * - "a turn is open because the process died mid-turn"
11
+ *
12
+ * so exactly those two facts are written here: the process identity
13
+ * (`pid`/`boot_id`/`clean_shutdown`) and the open turn (`open_turn`).
14
+ *
15
+ * Failure discipline (fail-safe, §1.2.2 / R1-3):
16
+ * - missing file -> "no checkpoint" (never repair, never invent);
17
+ * - unparsable / unknown `version` / `session` mismatch -> the WHOLE file is
18
+ * ignored and warned about, and the caller degrades to "no checkpoint" — a
19
+ * corrupt sidecar must never read as "the last exit was clean";
20
+ * - a failing write is reported and swallowed: checkpointing is observation,
21
+ * so it can never fail a turn (the session log's own degradation model).
22
+ *
23
+ * Write discipline: `<path>.tmp-<pid>` -> `rename` (atomic), mode `0600`,
24
+ * pretty-printed JSON — the same rules as every other data file
25
+ * (`contracts/data-files/index.json` durability map).
26
+ */
27
+ import { chmodSync, existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
28
+ import { randomBytes } from "node:crypto";
29
+ import { join } from "node:path";
30
+ import { outcomePhase } from "@celestea/core";
31
+ /** File name inside a session directory (contract). */
32
+ export const CHECKPOINT_FILE_NAME = "checkpoint.json";
33
+ /** The only accepted `version`; anything else is "unknown schema" -> ignored. */
34
+ export const CHECKPOINT_VERSION = 1;
35
+ /** `b-<8 hex>` — short, greppable, unique per process start. */
36
+ export function newBootId() {
37
+ return `b-${randomBytes(4).toString("hex")}`;
38
+ }
39
+ const PROCESS_BOOT_ID = newBootId();
40
+ /** The identity of THIS process (stable for its whole life). */
41
+ export function currentProcessIdentity() {
42
+ return { boot_id: PROCESS_BOOT_ID, pid: process.pid };
43
+ }
44
+ export function checkpointPathFor(dir) {
45
+ return join(dir, CHECKPOINT_FILE_NAME);
46
+ }
47
+ /** A fresh, empty checkpoint of the current process. */
48
+ export function freshCheckpoint(session, identity, now) {
49
+ return {
50
+ version: CHECKPOINT_VERSION,
51
+ session,
52
+ pid: identity.pid,
53
+ boot_id: identity.boot_id,
54
+ updated_at: now,
55
+ clean_shutdown: false,
56
+ open_turn: null,
57
+ last_outcome: null,
58
+ degraded: { log_write_errors: 0 },
59
+ lanes: { next_turn: [], next_step: [] },
60
+ delivered_ids: [],
61
+ repaired: [],
62
+ };
63
+ }
64
+ /** Atomic write: tmp file in the same directory -> rename, mode 0600. */
65
+ export function writeCheckpointFile(path, value) {
66
+ const tmp = `${path}.tmp-${process.pid}`;
67
+ writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
68
+ chmodSync(tmp, 0o600);
69
+ renameSync(tmp, path);
70
+ }
71
+ /** Read + validate; missing / corrupt / foreign files are never an exception. */
72
+ export function readCheckpointFile(path, session) {
73
+ if (!existsSync(path))
74
+ return { kind: "missing" };
75
+ let text;
76
+ try {
77
+ text = readFileSync(path, "utf8");
78
+ }
79
+ catch (e) {
80
+ return { kind: "invalid", error: messageOf(e) };
81
+ }
82
+ if (text.trim() === "")
83
+ return { kind: "invalid", error: "empty file" };
84
+ let raw;
85
+ try {
86
+ raw = JSON.parse(text);
87
+ }
88
+ catch (e) {
89
+ return { kind: "invalid", error: messageOf(e) };
90
+ }
91
+ return validateCheckpoint(raw, session);
92
+ }
93
+ /** Shape gate: version, self-description, open_turn and the repair list. */
94
+ export function validateCheckpoint(raw, session) {
95
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
96
+ return { kind: "invalid", error: "not a JSON object" };
97
+ const value = raw;
98
+ if (value.version !== CHECKPOINT_VERSION)
99
+ return { kind: "invalid", error: `unknown version: ${String(value.version)}` };
100
+ if (typeof value.session !== "string" || value.session !== session) {
101
+ return { kind: "invalid", error: `session mismatch: ${String(value.session)} != ${session}` };
102
+ }
103
+ if (!(value.open_turn === null || value.open_turn === undefined || isOpenTurn(value.open_turn))) {
104
+ return { kind: "invalid", error: "malformed open_turn" };
105
+ }
106
+ if (value.repaired !== undefined && !Array.isArray(value.repaired))
107
+ return { kind: "invalid", error: "malformed repaired[]" };
108
+ return { kind: "ok", value: normalize(value, session) };
109
+ }
110
+ function isOpenTurn(value) {
111
+ if (value === null || typeof value !== "object")
112
+ return false;
113
+ const turn = value;
114
+ return typeof turn.id === "string" && typeof turn.started_at === "number";
115
+ }
116
+ /** Fill every optional key so consumers never read `undefined`. */
117
+ function normalize(value, session) {
118
+ const lanes = value.lanes ?? { next_turn: [], next_step: [] };
119
+ return {
120
+ version: CHECKPOINT_VERSION,
121
+ session,
122
+ pid: typeof value.pid === "number" ? value.pid : 0,
123
+ boot_id: typeof value.boot_id === "string" ? value.boot_id : "",
124
+ updated_at: typeof value.updated_at === "number" ? value.updated_at : 0,
125
+ clean_shutdown: value.clean_shutdown === true,
126
+ open_turn: value.open_turn ?? null,
127
+ last_outcome: typeof value.last_outcome === "string" ? value.last_outcome : null,
128
+ degraded: { log_write_errors: Math.max(0, Math.trunc(value.degraded?.log_write_errors ?? 0)) },
129
+ lanes: { next_turn: laneOf(lanes.next_turn), next_step: laneOf(lanes.next_step) },
130
+ delivered_ids: Array.isArray(value.delivered_ids) ? value.delivered_ids.filter((id) => typeof id === "string") : [],
131
+ repaired: (value.repaired ?? []).filter(isRepair),
132
+ };
133
+ }
134
+ /** A persisted lane is a list of messages; anything else is dropped, never fatal. */
135
+ function laneOf(value) {
136
+ if (!Array.isArray(value))
137
+ return [];
138
+ return value.filter((m) => m !== null && typeof m === "object" && typeof m.id === "string");
139
+ }
140
+ function isRepair(value) {
141
+ if (value === null || typeof value !== "object")
142
+ return false;
143
+ const repair = value;
144
+ return typeof repair.turn_id === "string" && typeof repair.at === "number";
145
+ }
146
+ function messageOf(e) {
147
+ return e instanceof Error ? e.message : String(e);
148
+ }
149
+ /**
150
+ * ONE session's checkpoint: in memory + on disk. Mutations are the write
151
+ * timings of §1.2.2 (turn start, turn end, clean shutdown). Any activity marks
152
+ * the process alive again (`clean_shutdown: false`); `markCleanShutdown` is the
153
+ * only transition to `true`.
154
+ */
155
+ export class CheckpointStore {
156
+ path;
157
+ session;
158
+ identity;
159
+ now;
160
+ warn;
161
+ sample;
162
+ loaded = null;
163
+ state = null;
164
+ observations = [];
165
+ degradedReported = false;
166
+ onDegraded;
167
+ constructor(opts) {
168
+ this.session = opts.session;
169
+ this.identity = opts.identity ?? currentProcessIdentity();
170
+ this.now = opts.now ?? Date.now;
171
+ this.warn = opts.warn ?? ((message) => process.stderr.write(`[celestea-session] ${message}\n`));
172
+ this.sample = opts.logWriteErrors ?? (() => 0);
173
+ this.onDegraded = opts.onDegraded ?? null;
174
+ this.path = checkpointPathFor(opts.dir);
175
+ }
176
+ /** The sidecar as found on disk (cached; never throws). */
177
+ load() {
178
+ if (this.loaded === null) {
179
+ const read = readCheckpointFile(this.path, this.session);
180
+ if (read.kind === "invalid")
181
+ this.observe(`checkpoint ignored (${this.path}): ${read.error}`);
182
+ this.loaded = read;
183
+ }
184
+ return this.loaded;
185
+ }
186
+ /** The current state (a fresh, empty checkpoint when missing / ignored). */
187
+ get current() {
188
+ if (this.state !== null)
189
+ return this.state;
190
+ const read = this.load();
191
+ if (read.kind !== "ok") {
192
+ this.state = freshCheckpoint(this.session, this.identity, this.now());
193
+ return this.state;
194
+ }
195
+ const value = { ...read.value, degraded: { log_write_errors: Math.max(read.value.degraded.log_write_errors, this.sample()) } };
196
+ this.state = value;
197
+ return value;
198
+ }
199
+ /** Observations gathered so far (ignored files, failed writes). */
200
+ warnings() {
201
+ return [...this.observations];
202
+ }
203
+ /** A turn is open from now on (written RIGHT AFTER `turn_start`). */
204
+ turnStarted(id) {
205
+ this.persist({ open_turn: { id, started_at: this.now() }, last_outcome: null });
206
+ }
207
+ /** The turn closed normally: no open turn, outcome recorded (§1.2.2). */
208
+ turnEnded(outcome) {
209
+ this.persist({ open_turn: null, last_outcome: outcomePhase(outcome) });
210
+ }
211
+ /** Clear a stale open turn WITHOUT touching the log (already-closed case). */
212
+ clearOpenTurn() {
213
+ this.persist({ open_turn: null });
214
+ }
215
+ /**
216
+ * The ONE crash repair: the log just got `turn_end: interrupted`, so the
217
+ * record goes into `repaired[]` — the log row itself stays indistinguishable
218
+ * from engine output (§1.2.3 幂等边界 4).
219
+ */
220
+ recordSynthesizedTurnEnd(turnId) {
221
+ const repaired = [...this.current.repaired, { at: this.now(), action: "synthesize_turn_end", turn_id: turnId }];
222
+ this.persist({ open_turn: null, last_outcome: "interrupted", repaired });
223
+ }
224
+ /** Graceful exit: `true` forbids any repair on the next boot. */
225
+ markCleanShutdown() {
226
+ this.persist({ open_turn: null, clean_shutdown: true });
227
+ }
228
+ /** Sample the log's degradation counter (sticky: a fork must stay visible). */
229
+ noteLogWriteErrors() {
230
+ if (this.sample() > 0)
231
+ this.persist({});
232
+ }
233
+ /**
234
+ * E §1.3 P1 ①: the two lanes + the delivered-id ledger, written by the SAME
235
+ * atomic path as every other field. This is the third (and last) write timing
236
+ * the sidecar has, so a crash can lose at most the newest queue change.
237
+ */
238
+ lanesChanged(nextTurn, nextStep, deliveredIds) {
239
+ this.persist({
240
+ lanes: { next_turn: [...nextTurn], next_step: [...nextStep] },
241
+ delivered_ids: [...deliveredIds],
242
+ });
243
+ }
244
+ /** The persisted lanes + delivered ledger of the file on disk (null = unusable). */
245
+ persistedQueues() {
246
+ const read = this.load();
247
+ return read.kind === "ok" ? { lanes: read.value.lanes, delivered_ids: read.value.delivered_ids } : null;
248
+ }
249
+ persist(patch) {
250
+ const base = this.current;
251
+ const next = {
252
+ ...base,
253
+ ...patch,
254
+ version: CHECKPOINT_VERSION,
255
+ session: this.session,
256
+ pid: this.identity.pid,
257
+ boot_id: this.identity.boot_id,
258
+ updated_at: this.now(),
259
+ // Any write means "this process is running this session": only an explicit
260
+ // markCleanShutdown may claim a graceful exit.
261
+ clean_shutdown: patch.clean_shutdown ?? false,
262
+ degraded: { log_write_errors: Math.max(base.degraded.log_write_errors, this.sample()) },
263
+ };
264
+ this.state = next;
265
+ this.loaded = { kind: "ok", value: next };
266
+ this.reportDegraded(next.degraded.log_write_errors);
267
+ try {
268
+ writeCheckpointFile(this.path, next);
269
+ }
270
+ catch (e) {
271
+ // Observation only: a sidecar that cannot be written must never fail a
272
+ // turn — the next boot simply sees "no checkpoint" and stays fail-safe.
273
+ this.observe(`checkpoint write failed (${this.path}): ${messageOf(e)}`);
274
+ }
275
+ }
276
+ /** §5.2③: a silent disk/memory fork is exactly what must never stay silent. */
277
+ reportDegraded(count) {
278
+ if (count <= 0 || this.degradedReported || this.onDegraded === null)
279
+ return;
280
+ this.degradedReported = true;
281
+ try {
282
+ this.onDegraded({ session: this.session, count });
283
+ }
284
+ catch (e) {
285
+ this.observe(`degraded audit hook failed: ${messageOf(e)}`);
286
+ }
287
+ }
288
+ observe(message) {
289
+ this.observations.push(message);
290
+ this.warn(message);
291
+ }
292
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `@celestea/session` — the SessionLog implementations (plugin form).
3
+ *
4
+ * Responsibility: record SessionEvents in insertion order (in memory and as an
5
+ * append-only JSONL file), replay/repair a persisted log, and project the two
6
+ * message views. A2 (W746): the projection algorithm and the turn-id math are
7
+ * CORE's (`deriveMessagesFrom` / `formatTurnId`), re-exported here — this package
8
+ * owns storage, not seam semantics:
9
+ * - the Studio projection (`projectMessages`, per-event, golden vs frozen HTTP);
10
+ * - the engine model-visible history (`deriveMessages`, derive_messages).
11
+ *
12
+ * This package depends only on `@celestea/core` and is consumed by mounting
13
+ * `inMemorySessionLogPlugin` / `persistentSessionLogPlugin` into a Context.
14
+ *
15
+ * Module map:
16
+ * log/derive.ts derive_messages + balance_tool_calls (re-export of core, A2)
17
+ * log/memory.ts InMemorySessionLog (session/log.rs)
18
+ * log/file.ts JSONL file replay / naming (session/persistent.rs)
19
+ * log/persistent.ts PersistentSessionLog (session/persistent.rs)
20
+ * plugin.ts Context registration (SESSION_LOG_SERVICE)
21
+ * jsonl.ts file-level parse/serialize + codec re-exports
22
+ * messages.ts Studio projection + deriveMessages facade
23
+ * turn-id.ts turn id math + audit (re-export of core, A2)
24
+ * replay.ts replay analysis + SSE transcript derivation
25
+ * checkpoint.ts checkpoint.json sidecar: shape + atomic read/write
26
+ * checkpoint-log.ts SessionLog decorator: turn boundary -> checkpoint
27
+ * checkpoint-recovery.ts boot decision table (§1.2.3), append-only repair
28
+ */
29
+ export * from "./log/derive.js";
30
+ export * from "./log/memory.js";
31
+ export * from "./log/file.js";
32
+ export * from "./log/persistent.js";
33
+ export * from "./plugin.js";
34
+ export * from "./jsonl.js";
35
+ export * from "./messages.js";
36
+ export * from "./turn-id.js";
37
+ export * from "./replay.js";
38
+ export * from "./checkpoint.js";
39
+ export * from "./checkpoint-log.js";
40
+ export * from "./checkpoint-recovery.js";
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ /**
2
+ * `@celestea/session` — the SessionLog implementations (plugin form).
3
+ *
4
+ * Responsibility: record SessionEvents in insertion order (in memory and as an
5
+ * append-only JSONL file), replay/repair a persisted log, and project the two
6
+ * message views. A2 (W746): the projection algorithm and the turn-id math are
7
+ * CORE's (`deriveMessagesFrom` / `formatTurnId`), re-exported here — this package
8
+ * owns storage, not seam semantics:
9
+ * - the Studio projection (`projectMessages`, per-event, golden vs frozen HTTP);
10
+ * - the engine model-visible history (`deriveMessages`, derive_messages).
11
+ *
12
+ * This package depends only on `@celestea/core` and is consumed by mounting
13
+ * `inMemorySessionLogPlugin` / `persistentSessionLogPlugin` into a Context.
14
+ *
15
+ * Module map:
16
+ * log/derive.ts derive_messages + balance_tool_calls (re-export of core, A2)
17
+ * log/memory.ts InMemorySessionLog (session/log.rs)
18
+ * log/file.ts JSONL file replay / naming (session/persistent.rs)
19
+ * log/persistent.ts PersistentSessionLog (session/persistent.rs)
20
+ * plugin.ts Context registration (SESSION_LOG_SERVICE)
21
+ * jsonl.ts file-level parse/serialize + codec re-exports
22
+ * messages.ts Studio projection + deriveMessages facade
23
+ * turn-id.ts turn id math + audit (re-export of core, A2)
24
+ * replay.ts replay analysis + SSE transcript derivation
25
+ * checkpoint.ts checkpoint.json sidecar: shape + atomic read/write
26
+ * checkpoint-log.ts SessionLog decorator: turn boundary -> checkpoint
27
+ * checkpoint-recovery.ts boot decision table (§1.2.3), append-only repair
28
+ */
29
+ export * from "./log/derive.js";
30
+ export * from "./log/memory.js";
31
+ export * from "./log/file.js";
32
+ export * from "./log/persistent.js";
33
+ export * from "./plugin.js";
34
+ export * from "./jsonl.js";
35
+ export * from "./messages.js";
36
+ export * from "./turn-id.js";
37
+ export * from "./replay.js";
38
+ export * from "./checkpoint.js";
39
+ export * from "./checkpoint-log.js";
40
+ export * from "./checkpoint-recovery.js";
@@ -0,0 +1,33 @@
1
+ /**
2
+ * cli-main.jsonl parsing/serialization (file level).
3
+ *
4
+ * Contract: one SessionEvent per line; blank lines are padding; parsing STOPS at
5
+ * the first unparsable line (a torn tail is never treated as content) —
6
+ * src/api.rs:141-153. The per-row codec (validate / serde-exact serialize) and
7
+ * the TurnOutcome helpers live in `@celestea/core` (`session-event.ts`) and are
8
+ * re-exported here so existing imports keep working.
9
+ *
10
+ * "Blank" is a truly empty line (or a lone `\r`), matching the legacy replay
11
+ * (`record.is_empty()`); a whitespace-only line is an unparsable record.
12
+ */
13
+ import { type SessionEvent } from "@celestea/core";
14
+ export { DEFAULT_TURN_OUTCOME, effectiveOutcome, isSessionEventType, isTurnOutcome, outcomeError, outcomeErrorParts, outcomePhase, parseSessionEvent, serializeSessionEvent, validateSessionEvent, type ValidateResult, } from "@celestea/core";
15
+ export interface TornTail {
16
+ /** 1-based line number of the first unparsable line. */
17
+ line: number;
18
+ raw: string;
19
+ error: string;
20
+ }
21
+ export interface ParseJsonlResult {
22
+ events: SessionEvent[];
23
+ /** Total physical lines in the input. */
24
+ lines: string[];
25
+ physicalLines: number;
26
+ blankLines: number;
27
+ /** Lines successfully parsed into events. */
28
+ parsedLines: number;
29
+ /** Everything from the first unparsable line onwards is ignored. */
30
+ tornTail: TornTail | null;
31
+ }
32
+ export declare function parseSessionJsonl(text: string): ParseJsonlResult;
33
+ export declare function serializeSessionJsonl(events: readonly SessionEvent[]): string;
package/dist/jsonl.js ADDED
@@ -0,0 +1,44 @@
1
+ /**
2
+ * cli-main.jsonl parsing/serialization (file level).
3
+ *
4
+ * Contract: one SessionEvent per line; blank lines are padding; parsing STOPS at
5
+ * the first unparsable line (a torn tail is never treated as content) —
6
+ * src/api.rs:141-153. The per-row codec (validate / serde-exact serialize) and
7
+ * the TurnOutcome helpers live in `@celestea/core` (`session-event.ts`) and are
8
+ * re-exported here so existing imports keep working.
9
+ *
10
+ * "Blank" is a truly empty line (or a lone `\r`), matching the legacy replay
11
+ * (`record.is_empty()`); a whitespace-only line is an unparsable record.
12
+ */
13
+ import { parseSessionEvent, serializeSessionEvent } from "@celestea/core";
14
+ export { DEFAULT_TURN_OUTCOME, effectiveOutcome, isSessionEventType, isTurnOutcome, outcomeError, outcomeErrorParts, outcomePhase, parseSessionEvent, serializeSessionEvent, validateSessionEvent, } from "@celestea/core";
15
+ /** Strip one trailing `\r` (`trim_line_end`); the `\n` is already consumed. */
16
+ function stripCr(line) {
17
+ return line.endsWith("\r") ? line.slice(0, -1) : line;
18
+ }
19
+ export function parseSessionJsonl(text) {
20
+ const lines = text.split("\n");
21
+ // A trailing newline produces a final empty element; it is not a line.
22
+ if (lines.length > 0 && lines[lines.length - 1] === "")
23
+ lines.pop();
24
+ const events = [];
25
+ let blankLines = 0;
26
+ let tornTail = null;
27
+ for (let i = 0; i < lines.length; i++) {
28
+ const raw = stripCr(lines[i] ?? "");
29
+ if (raw === "") {
30
+ blankLines += 1;
31
+ continue;
32
+ }
33
+ const parsed = parseSessionEvent(raw);
34
+ if (!parsed.ok) {
35
+ tornTail = { line: i + 1, raw, error: parsed.errors.join("; ") };
36
+ break;
37
+ }
38
+ events.push(parsed.event);
39
+ }
40
+ return { events, lines, physicalLines: lines.length, blankLines, parsedLines: events.length, tornTail };
41
+ }
42
+ export function serializeSessionJsonl(events) {
43
+ return events.map((ev) => serializeSessionEvent(ev)).join("\n") + (events.length > 0 ? "\n" : "");
44
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The engine's `derive_messages` projection — A2 (W746): the algorithm moved
3
+ * to `@celestea/core` (`core/src/projection.ts`, the same 1:1 port of
4
+ * `crates/session/src/log.rs:73-204`), because it is seam behaviour, not
5
+ * implementation behaviour: every `SessionLog` must project the same history,
6
+ * and `SessionLog.deriveMessages()` is a seam method.
7
+ *
8
+ * This module stays as the stable import path inside the package (and for
9
+ * callers of `@celestea/session`) so nothing else has to move; the rules,
10
+ * parity notes and the W267 cursor quirk are documented where the code is.
11
+ */
12
+ export { CANCELLED_TOOL_CALL_TEXT, balanceToolCalls, deriveMessagesFrom, flushToolCalls, projectEvent, toolResultText, } from "@celestea/core";
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The engine's `derive_messages` projection — A2 (W746): the algorithm moved
3
+ * to `@celestea/core` (`core/src/projection.ts`, the same 1:1 port of
4
+ * `crates/session/src/log.rs:73-204`), because it is seam behaviour, not
5
+ * implementation behaviour: every `SessionLog` must project the same history,
6
+ * and `SessionLog.deriveMessages()` is a seam method.
7
+ *
8
+ * This module stays as the stable import path inside the package (and for
9
+ * callers of `@celestea/session`) so nothing else has to move; the rules,
10
+ * parity notes and the W267 cursor quirk are documented where the code is.
11
+ */
12
+ export { CANCELLED_TOOL_CALL_TEXT, balanceToolCalls, deriveMessagesFrom, flushToolCalls, projectEvent, toolResultText, } from "@celestea/core";
@@ -0,0 +1,43 @@
1
+ /**
2
+ * cli-main.jsonl file-level replay — port of
3
+ * `crates/session/src/persistent.rs:282-402`.
4
+ *
5
+ * Contract:
6
+ * - `file_name_for` sanitizes a session id (only `[A-Za-z0-9._-]` survive) so
7
+ * a caller-supplied id can never escape the session directory;
8
+ * - replay keeps the LONGEST VALID PREFIX: blank lines are harmless padding,
9
+ * and everything from the first unparsable record (a torn tail left by a
10
+ * crash mid-write) is truncated away — a half record is never replayed;
11
+ * - a file whose last record has no terminating newline is repaired before
12
+ * the next append, otherwise the next record would merge into it.
13
+ */
14
+ import { type SessionEvent } from "@celestea/core";
15
+ export interface TornRecord {
16
+ /** 1-based line number of the first unparsable record. */
17
+ line: number;
18
+ /** Byte offset the record starts at. */
19
+ offset: number;
20
+ raw: string;
21
+ error: string;
22
+ }
23
+ export interface ReplayFileResult {
24
+ events: SessionEvent[];
25
+ /** Byte length of the valid prefix (what the caller truncates to). */
26
+ validBytes: number;
27
+ /** True when an unparsable record followed the valid prefix. */
28
+ truncated: boolean;
29
+ torn: TornRecord | null;
30
+ }
31
+ /** Map a session id to a safe file name (`file_name_for`). */
32
+ export declare function fileNameFor(sessionId: string): string;
33
+ /** The JSONL path used for a session id under a directory (`file_path`). */
34
+ export declare function filePathFor(dir: string, sessionId: string): string;
35
+ /** Strip one trailing `\n` and an optional `\r` (`trim_line_end`). */
36
+ export declare function trimLineEnd(line: Buffer): Buffer;
37
+ /**
38
+ * True when the file is non-empty and its last byte is not a newline — only a
39
+ * hand-crafted/corrupt file can be in this state (`file_lacks_final_newline`).
40
+ */
41
+ export declare function fileLacksFinalNewline(path: string): boolean;
42
+ /** Replay a JSONL file line by line, keeping the longest valid prefix. */
43
+ export declare function replayFile(path: string): ReplayFileResult;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * cli-main.jsonl file-level replay — port of
3
+ * `crates/session/src/persistent.rs:282-402`.
4
+ *
5
+ * Contract:
6
+ * - `file_name_for` sanitizes a session id (only `[A-Za-z0-9._-]` survive) so
7
+ * a caller-supplied id can never escape the session directory;
8
+ * - replay keeps the LONGEST VALID PREFIX: blank lines are harmless padding,
9
+ * and everything from the first unparsable record (a torn tail left by a
10
+ * crash mid-write) is truncated away — a half record is never replayed;
11
+ * - a file whose last record has no terminating newline is repaired before
12
+ * the next append, otherwise the next record would merge into it.
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { parseSessionEvent } from "@celestea/core";
17
+ /** Map a session id to a safe file name (`file_name_for`). */
18
+ export function fileNameFor(sessionId) {
19
+ let name = "";
20
+ for (const ch of sessionId)
21
+ name += /^[A-Za-z0-9._-]$/.test(ch) ? ch : "_";
22
+ if (name === "")
23
+ name = "session";
24
+ return `${name}.jsonl`;
25
+ }
26
+ /** The JSONL path used for a session id under a directory (`file_path`). */
27
+ export function filePathFor(dir, sessionId) {
28
+ return join(dir, fileNameFor(sessionId));
29
+ }
30
+ /** Strip one trailing `\n` and an optional `\r` (`trim_line_end`). */
31
+ export function trimLineEnd(line) {
32
+ let end = line.length;
33
+ if (end > 0 && line[end - 1] === 0x0a)
34
+ end -= 1;
35
+ if (end > 0 && line[end - 1] === 0x0d)
36
+ end -= 1;
37
+ return line.subarray(0, end);
38
+ }
39
+ /**
40
+ * True when the file is non-empty and its last byte is not a newline — only a
41
+ * hand-crafted/corrupt file can be in this state (`file_lacks_final_newline`).
42
+ */
43
+ export function fileLacksFinalNewline(path) {
44
+ if (!existsSync(path))
45
+ return false;
46
+ const buf = readFileSync(path);
47
+ if (buf.length === 0)
48
+ return false;
49
+ return buf[buf.length - 1] !== 0x0a;
50
+ }
51
+ /** Replay a JSONL file line by line, keeping the longest valid prefix. */
52
+ export function replayFile(path) {
53
+ const events = [];
54
+ if (!existsSync(path))
55
+ return { events, validBytes: 0, truncated: false, torn: null };
56
+ const buf = readFileSync(path);
57
+ let offset = 0;
58
+ let validBytes = 0;
59
+ let lineNumber = 0;
60
+ while (offset < buf.length) {
61
+ const newline = buf.indexOf(0x0a, offset);
62
+ const end = newline === -1 ? buf.length : newline + 1;
63
+ const start = offset;
64
+ offset = end;
65
+ lineNumber += 1;
66
+ const record = trimLineEnd(buf.subarray(start, end));
67
+ if (record.length === 0) {
68
+ // Blank line: harmless padding, part of the valid region.
69
+ validBytes = offset;
70
+ continue;
71
+ }
72
+ const parsed = parseSessionEvent(record.toString("utf8"));
73
+ if (!parsed.ok) {
74
+ return {
75
+ events,
76
+ validBytes,
77
+ truncated: true,
78
+ torn: { line: lineNumber, offset: start, raw: record.toString("utf8"), error: parsed.errors.join("; ") },
79
+ };
80
+ }
81
+ events.push(parsed.event);
82
+ validBytes = offset;
83
+ }
84
+ return { events, validBytes, truncated: false, torn: null };
85
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * InMemorySessionLog — port of `crates/session/src/log.rs:16-66`.
3
+ *
4
+ * An in-memory, append-only session log: insertion order is the single source
5
+ * of truth and the model history is always derived from it, never stored
6
+ * separately. The turn-id counter is owned by the LOG (not the agent loop) and
7
+ * never resets — not even on `clear` — so ids never repeat across loop
8
+ * instances (P0-A unique turn identity).
9
+ */
10
+ import { type Message, type SessionEvent, type SessionLog } from "@celestea/core";
11
+ export declare class InMemorySessionLog implements SessionLog {
12
+ private recorded;
13
+ private turnCounter;
14
+ /** Create an empty session log. */
15
+ static create(): InMemorySessionLog;
16
+ append(event: SessionEvent): void;
17
+ /** A copy of the recorded events (`events()` clones the Vec). */
18
+ events(): SessionEvent[];
19
+ deriveMessages(): Message[];
20
+ /** Drop every event; the id counter deliberately survives (`clear`). */
21
+ clear(): void;
22
+ nextTurnId(): string;
23
+ /** The next number the counter would hand out (diagnostics / recovery). */
24
+ peekTurnNumber(): number;
25
+ /** Restore the counter after a replay (`PersistentSessionLog` recovery). */
26
+ restoreTurnCounter(next: number): void;
27
+ }