@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,43 @@
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 { deriveMessagesFrom, formatTurnId } from "@celestea/core";
11
+ export class InMemorySessionLog {
12
+ recorded = [];
13
+ turnCounter = 0;
14
+ /** Create an empty session log. */
15
+ static create() {
16
+ return new InMemorySessionLog();
17
+ }
18
+ append(event) {
19
+ this.recorded.push(event);
20
+ }
21
+ /** A copy of the recorded events (`events()` clones the Vec). */
22
+ events() {
23
+ return [...this.recorded];
24
+ }
25
+ deriveMessages() {
26
+ return deriveMessagesFrom(this.recorded);
27
+ }
28
+ /** Drop every event; the id counter deliberately survives (`clear`). */
29
+ clear() {
30
+ this.recorded = [];
31
+ }
32
+ nextTurnId() {
33
+ return formatTurnId(this.turnCounter++);
34
+ }
35
+ /** The next number the counter would hand out (diagnostics / recovery). */
36
+ peekTurnNumber() {
37
+ return this.turnCounter;
38
+ }
39
+ /** Restore the counter after a replay (`PersistentSessionLog` recovery). */
40
+ restoreTurnCounter(next) {
41
+ this.turnCounter = next;
42
+ }
43
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * PersistentSessionLog — port of `crates/session/src/persistent.rs:86-280`.
3
+ *
4
+ * A drop-in [SessionLog] whose every appended event is mirrored to disk as one
5
+ * JSON line in a per-session append-only file, while the in-memory semantics of
6
+ * [InMemorySessionLog] (insertion order + the shared `derive_messages`
7
+ * projection) are preserved. `open` replays the file, so a restart reconstructs
8
+ * the same model-visible history and the same turn counter.
9
+ *
10
+ * Failure model: the log stays usable if a disk write fails — the event remains
11
+ * in the in-memory view (derive_messages keeps working), the failure is counted
12
+ * ([writeErrorCount]) and warned on stderr. Only open/replay/sync surface
13
+ * errors to the caller.
14
+ *
15
+ * Deviation from the legacy implementation (documented, safe direction): it buffers
16
+ * through a `BufWriter`, so `flushEachAppend=false` batches records and a crash can lose
17
+ * them. `fs.writeSync` is unbuffered, so every append already reaches the OS;
18
+ * `flush()` is therefore a no-op and `flushEachAppend=false` cannot lose data.
19
+ * `sync()` is the real durability point (fsync), matching `sync_each_append`.
20
+ */
21
+ import type { Message, SessionEvent, SessionLog } from "@celestea/core";
22
+ import { type TornRecord } from "./file.js";
23
+ export interface PersistentOptions {
24
+ /** Accepted for parity; writes are unbuffered so no record can be lost. */
25
+ flushEachAppend: boolean;
26
+ /** fsync after every appended record (survives power loss too). */
27
+ syncEachAppend: boolean;
28
+ }
29
+ export declare function defaultPersistentOptions(): PersistentOptions;
30
+ export declare class PersistentSessionLog implements SessionLog {
31
+ readonly path: string;
32
+ /** The torn tail found on open (null when the file replayed clean). */
33
+ readonly tornTail: TornRecord | null;
34
+ private recorded;
35
+ private turnCounter;
36
+ private fd;
37
+ private writeErrors;
38
+ private readonly opts;
39
+ private constructor();
40
+ /** Open (and replay) the append-only log for `sessionId` under `dir`. */
41
+ static open(dir: string, sessionId: string, opts?: PersistentOptions): PersistentSessionLog;
42
+ append(event: SessionEvent): void;
43
+ events(): SessionEvent[];
44
+ deriveMessages(): Message[];
45
+ nextTurnId(): string;
46
+ /** The next number the counter would hand out. */
47
+ peekTurnNumber(): number;
48
+ clear(): void;
49
+ /** No-op: `writeSync` is unbuffered, so records already reached the OS. */
50
+ flush(): void;
51
+ /** fsync the file so buffered records survive power loss. */
52
+ sync(): void;
53
+ /** How many append-path write failures were recorded (degraded mode). */
54
+ writeErrorCount(): number;
55
+ /** Flush + release the descriptor (`Drop`). Idempotent. */
56
+ close(): void;
57
+ private closeFd;
58
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * PersistentSessionLog — port of `crates/session/src/persistent.rs:86-280`.
3
+ *
4
+ * A drop-in [SessionLog] whose every appended event is mirrored to disk as one
5
+ * JSON line in a per-session append-only file, while the in-memory semantics of
6
+ * [InMemorySessionLog] (insertion order + the shared `derive_messages`
7
+ * projection) are preserved. `open` replays the file, so a restart reconstructs
8
+ * the same model-visible history and the same turn counter.
9
+ *
10
+ * Failure model: the log stays usable if a disk write fails — the event remains
11
+ * in the in-memory view (derive_messages keeps working), the failure is counted
12
+ * ([writeErrorCount]) and warned on stderr. Only open/replay/sync surface
13
+ * errors to the caller.
14
+ *
15
+ * Deviation from the legacy implementation (documented, safe direction): it buffers
16
+ * through a `BufWriter`, so `flushEachAppend=false` batches records and a crash can lose
17
+ * them. `fs.writeSync` is unbuffered, so every append already reaches the OS;
18
+ * `flush()` is therefore a no-op and `flushEachAppend=false` cannot lose data.
19
+ * `sync()` is the real durability point (fsync), matching `sync_each_append`.
20
+ */
21
+ import { appendFileSync, closeSync, fsyncSync, mkdirSync, openSync, truncateSync, writeSync } from "node:fs";
22
+ import { deriveMessagesFrom, formatTurnId, nextTurnNumber, serializeSessionEvent } from "@celestea/core";
23
+ import { fileLacksFinalNewline, filePathFor, replayFile } from "./file.js";
24
+ export function defaultPersistentOptions() {
25
+ return { flushEachAppend: true, syncEachAppend: false };
26
+ }
27
+ export class PersistentSessionLog {
28
+ path;
29
+ /** The torn tail found on open (null when the file replayed clean). */
30
+ tornTail;
31
+ recorded = [];
32
+ turnCounter = 0;
33
+ fd = null;
34
+ writeErrors = 0;
35
+ opts;
36
+ constructor(path, opts, tornTail) {
37
+ this.path = path;
38
+ this.opts = opts;
39
+ this.tornTail = tornTail;
40
+ }
41
+ /** Open (and replay) the append-only log for `sessionId` under `dir`. */
42
+ static open(dir, sessionId, opts = defaultPersistentOptions()) {
43
+ mkdirSync(dir, { recursive: true });
44
+ const path = filePathFor(dir, sessionId);
45
+ // The longest valid prefix wins; a torn tail is truncated away.
46
+ const replay = replayFile(path);
47
+ if (replay.truncated)
48
+ truncateSync(path, replay.validBytes);
49
+ if (fileLacksFinalNewline(path))
50
+ appendFileSync(path, "\n");
51
+ const log = new PersistentSessionLog(path, opts, replay.torn);
52
+ log.recorded = replay.events;
53
+ // P0-A: restore the counter from the replayed file (max id on disk + 1).
54
+ log.turnCounter = nextTurnNumber(replay.events);
55
+ log.fd = openSync(path, "a");
56
+ return log;
57
+ }
58
+ append(event) {
59
+ const line = serializeSessionEvent(event);
60
+ try {
61
+ if (this.fd === null)
62
+ throw new Error("session log is closed");
63
+ writeSync(this.fd, Buffer.from(`${line}\n`, "utf8"));
64
+ if (this.opts.syncEachAppend)
65
+ fsyncSync(this.fd);
66
+ }
67
+ catch (e) {
68
+ this.writeErrors += 1;
69
+ process.stderr.write(`[celestea-session] append not persisted to ${this.path} (writeErrorCount=${this.writeErrors}); kept in memory only: ${String(e)}\n`);
70
+ }
71
+ // The in-memory view is the source of truth for derive_messages: keep the
72
+ // event even when the disk path failed (graceful degradation).
73
+ this.recorded.push(event);
74
+ }
75
+ events() {
76
+ return [...this.recorded];
77
+ }
78
+ deriveMessages() {
79
+ return deriveMessagesFrom(this.recorded);
80
+ }
81
+ nextTurnId() {
82
+ return formatTurnId(this.turnCounter++);
83
+ }
84
+ /** The next number the counter would hand out. */
85
+ peekTurnNumber() {
86
+ return this.turnCounter;
87
+ }
88
+ clear() {
89
+ this.closeFd();
90
+ this.fd = openSync(this.path, "w"); // truncates
91
+ this.recorded = [];
92
+ // The emptied file replays to counter 0; keep the live counter in sync.
93
+ this.turnCounter = 0;
94
+ }
95
+ /** No-op: `writeSync` is unbuffered, so records already reached the OS. */
96
+ flush() {
97
+ // Intentionally empty — see the module header.
98
+ }
99
+ /** fsync the file so buffered records survive power loss. */
100
+ sync() {
101
+ if (this.fd !== null)
102
+ fsyncSync(this.fd);
103
+ }
104
+ /** How many append-path write failures were recorded (degraded mode). */
105
+ writeErrorCount() {
106
+ return this.writeErrors;
107
+ }
108
+ /** Flush + release the descriptor (`Drop`). Idempotent. */
109
+ close() {
110
+ this.closeFd();
111
+ }
112
+ closeFd() {
113
+ if (this.fd !== null) {
114
+ closeSync(this.fd);
115
+ this.fd = null;
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * The two projections of the session log.
3
+ *
4
+ * 1. Studio projection (GET /api/sessions/{id}/messages) — src/api.rs:94-135.
5
+ * Per-event, independent, NO pairing/dropping; thinking rows are included;
6
+ * orphan tool_result rows are emitted; tool rows carry tool_parent_id.
7
+ * 2. Engine `derive_messages` — the model-visible history (see ./log/derive.ts):
8
+ * turn markers and thinking are skipped, run_code sub-call rows
9
+ * (parent_id present) are skipped, consecutive tool calls merge into ONE
10
+ * assistant message, and unanswered calls are balanced with a synthetic
11
+ * cancelled result.
12
+ *
13
+ * Keeping both explicit is the whole point: they differ, and the difference is
14
+ * contract.
15
+ */
16
+ import type { Message, SessionEvent, SessionEventOrigin, StudioMessage } from "@celestea/core";
17
+ /** The label of a non-user origin ('user' never reaches here). */
18
+ export declare function originLabel(origin: SessionEventOrigin): string;
19
+ /** Studio projection of a single event; null for structural markers. */
20
+ export declare function sessionEventToMessage(ev: SessionEvent): StudioMessage | null;
21
+ /** The Studio message list for a whole log (golden-compared against HTTP). */
22
+ export declare function projectMessages(events: readonly SessionEvent[]): StudioMessage[];
23
+ /**
24
+ * Engine model-visible projection (`derive_messages`). Returns the engine's
25
+ * `Message` shape (`role` / `content[]` / `tool_call_id`), not the Studio shape.
26
+ */
27
+ export declare function deriveMessages(events: readonly SessionEvent[]): Message[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The two projections of the session log.
3
+ *
4
+ * 1. Studio projection (GET /api/sessions/{id}/messages) — src/api.rs:94-135.
5
+ * Per-event, independent, NO pairing/dropping; thinking rows are included;
6
+ * orphan tool_result rows are emitted; tool rows carry tool_parent_id.
7
+ * 2. Engine `derive_messages` — the model-visible history (see ./log/derive.ts):
8
+ * turn markers and thinking are skipped, run_code sub-call rows
9
+ * (parent_id present) are skipped, consecutive tool calls merge into ONE
10
+ * assistant message, and unanswered calls are balanced with a synthetic
11
+ * cancelled result.
12
+ *
13
+ * Keeping both explicit is the whole point: they differ, and the difference is
14
+ * contract.
15
+ */
16
+ import { deriveMessagesFrom, toolSurfaceValue } from "@celestea/core";
17
+ /** W888: the human-readable label each non-user origin shows in the block. */
18
+ const ORIGIN_LABEL = {
19
+ skill: "技能目录",
20
+ memory: "记忆 · 每轮注入",
21
+ receipt: "回执",
22
+ steering: "插话",
23
+ compact: "压缩摘要",
24
+ };
25
+ /** The label of a non-user origin ('user' never reaches here). */
26
+ export function originLabel(origin) {
27
+ return origin === "user" ? "用户" : ORIGIN_LABEL[origin];
28
+ }
29
+ /** Studio projection of a single event; null for structural markers. */
30
+ export function sessionEventToMessage(ev) {
31
+ switch (ev.type) {
32
+ case "turn_start":
33
+ case "turn_end":
34
+ return null;
35
+ case "user_message": {
36
+ // W888: a non-user ORIGIN projects to an inbox row (the UI's existing
37
+ // renderInboxMessage branch). Absent/'user' keeps the exact pre-W888 bytes.
38
+ const origin = ev.origin;
39
+ if (origin !== undefined && origin !== "user") {
40
+ const inbox = { role: "inbox", kind: origin, content: ev.text, source: originLabel(origin) };
41
+ if (ev.attachments !== undefined && ev.attachments.length > 0)
42
+ inbox.attachments = ev.attachments;
43
+ return inbox;
44
+ }
45
+ // W804 §4.2D: the Studio projection carries the attachment references (the
46
+ // bytes stay on disk); no attachments => the pre-W804 object byte for byte.
47
+ const out = { role: "user", content: ev.text };
48
+ if (ev.attachments !== undefined && ev.attachments.length > 0)
49
+ out.attachments = ev.attachments;
50
+ return out;
51
+ }
52
+ case "assistant_message":
53
+ return { role: "assistant", content: ev.text };
54
+ case "thinking_delta":
55
+ return { role: "thinking", content: ev.text };
56
+ case "tool_call": {
57
+ const out = {
58
+ role: "tool",
59
+ kind: "call",
60
+ tool_call_id: ev.id,
61
+ tool_name: ev.name,
62
+ tool_args: ev.args,
63
+ };
64
+ if (ev.parent_id !== undefined)
65
+ out.tool_parent_id = ev.parent_id;
66
+ return out;
67
+ }
68
+ case "tool_result": {
69
+ // W855 (B6): the log stores the ORIGINAL value; the transcript shows the
70
+ // bounded/annotated FACE (the original can be arbitrarily large) plus the
71
+ // descriptor so a card can badge "N bytes omitted -> locator".
72
+ const out = {
73
+ role: "tool",
74
+ kind: "result",
75
+ tool_call_id: ev.id,
76
+ tool_value: toolSurfaceValue(ev.value, ev.surface),
77
+ tool_error: ev.error,
78
+ };
79
+ if (ev.parent_id !== undefined)
80
+ out.tool_parent_id = ev.parent_id;
81
+ if (ev.surface !== undefined)
82
+ out.tool_surface = ev.surface;
83
+ return out;
84
+ }
85
+ // W783 §7: the two host-side question rows. The Studio projection is the
86
+ // per-event transcript surface the UI replays, so a parked question and its
87
+ // answer stay visible there (an unanswered row is how a restart looks).
88
+ case "user_question": {
89
+ const out = {
90
+ role: "question",
91
+ kind: "question",
92
+ question_id: ev.id,
93
+ content: ev.questions,
94
+ };
95
+ if (ev.expires_at !== undefined)
96
+ out.question_expires_at = ev.expires_at;
97
+ return out;
98
+ }
99
+ case "user_answer": {
100
+ const out = {
101
+ role: "question",
102
+ kind: "answer",
103
+ question_id: ev.id,
104
+ content: ev.answers,
105
+ };
106
+ if (ev.timed_out !== undefined)
107
+ out.question_timed_out = ev.timed_out;
108
+ return out;
109
+ }
110
+ }
111
+ }
112
+ /** The Studio message list for a whole log (golden-compared against HTTP). */
113
+ export function projectMessages(events) {
114
+ const out = [];
115
+ for (const ev of events) {
116
+ const m = sessionEventToMessage(ev);
117
+ if (m !== null)
118
+ out.push(m);
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * Engine model-visible projection (`derive_messages`). Returns the engine's
124
+ * `Message` shape (`role` / `content[]` / `tool_call_id`), not the Studio shape.
125
+ */
126
+ export function deriveMessages(events) {
127
+ return deriveMessagesFrom(events);
128
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The session package as a PLUGIN (rule 3: everything is a plugin).
3
+ *
4
+ * `packages/session` never gets imported by `core`: it only provides a
5
+ * [SessionLog] implementation into a [Context] under the well-known
6
+ * `SESSION_LOG_SERVICE` token, exactly like `celestea-session` is plugged into
7
+ * the harness at compose time. A later mount of the same token wins, so a
8
+ * test can swap in an in-memory log over a persistent one.
9
+ */
10
+ import { type Plugin, type SessionLog } from "@celestea/core";
11
+ import { PersistentSessionLog, type PersistentOptions } from "./log/persistent.js";
12
+ /** Provide an (optionally pre-populated) in-memory log. */
13
+ export declare function inMemorySessionLogPlugin(name?: string, log?: SessionLog): Plugin;
14
+ export interface PersistentSessionLogOptions {
15
+ dir: string;
16
+ sessionId: string;
17
+ options?: PersistentOptions;
18
+ }
19
+ /** Open a JSONL-backed log and provide it (the log is returned via `onOpen`). */
20
+ export declare function persistentSessionLogPlugin(cfg: PersistentSessionLogOptions, onOpen?: (log: PersistentSessionLog) => void, name?: string): Plugin;
package/dist/plugin.js ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The session package as a PLUGIN (rule 3: everything is a plugin).
3
+ *
4
+ * `packages/session` never gets imported by `core`: it only provides a
5
+ * [SessionLog] implementation into a [Context] under the well-known
6
+ * `SESSION_LOG_SERVICE` token, exactly like `celestea-session` is plugged into
7
+ * the harness at compose time. A later mount of the same token wins, so a
8
+ * test can swap in an in-memory log over a persistent one.
9
+ */
10
+ import { definePlugin, SESSION_LOG_SERVICE } from "@celestea/core";
11
+ import { InMemorySessionLog } from "./log/memory.js";
12
+ import { PersistentSessionLog } from "./log/persistent.js";
13
+ /** Provide an (optionally pre-populated) in-memory log. */
14
+ export function inMemorySessionLogPlugin(name = "celestea.session.InMemorySessionLog", log = new InMemorySessionLog()) {
15
+ return definePlugin(name, (ctx) => ctx.provide(SESSION_LOG_SERVICE, log));
16
+ }
17
+ /** Open a JSONL-backed log and provide it (the log is returned via `onOpen`). */
18
+ export function persistentSessionLogPlugin(cfg, onOpen, name = "celestea.session.PersistentSessionLog") {
19
+ return definePlugin(name, (ctx) => {
20
+ const log = PersistentSessionLog.open(cfg.dir, cfg.sessionId, cfg.options);
21
+ onOpen?.(log);
22
+ ctx.provide(SESSION_LOG_SERVICE, log);
23
+ });
24
+ }
@@ -0,0 +1,59 @@
1
+ /** Structural analysis of a replayed session log (P0 replay comparison). */
2
+ import { type ParseJsonlResult } from "./jsonl.js";
3
+ import { type TurnIdAudit } from "./turn-id.js";
4
+ import { type SessionEvent } from "@celestea/core";
5
+ export interface ReplayStats {
6
+ physicalLines: number;
7
+ parsedEvents: number;
8
+ blankLines: number;
9
+ tornTail: ParseJsonlResult["tornTail"];
10
+ turnStarts: number;
11
+ turnEnds: number;
12
+ /** turn_start rows with no matching turn_end (a killed/crashed turn). */
13
+ danglingTurns: number;
14
+ toolCalls: number;
15
+ toolResults: number;
16
+ /** tool_call rows with no matching tool_result id. */
17
+ danglingToolCalls: Array<{
18
+ id: string;
19
+ name: string;
20
+ }>;
21
+ /** tool_result rows with no matching tool_call id. */
22
+ orphanToolResults: string[];
23
+ /** W255 run_code sub-calls (parent_id present). */
24
+ subCalls: number;
25
+ subCallParents: string[];
26
+ thinkingEvents: number;
27
+ userMessages: number;
28
+ assistantMessages: number;
29
+ outcomes: Record<string, number>;
30
+ turnIds: TurnIdAudit;
31
+ }
32
+ export declare function analyzeReplay(parsed: ParseJsonlResult): ReplayStats;
33
+ /**
34
+ * Derive the SSE transcript a client would have observed for this log.
35
+ * `seq` is synthetic (the real counter is process-global and not recoverable
36
+ * from the log); `turn` is the engine turn number.
37
+ *
38
+ * W834 F08 — what is reconstructable (and what is not):
39
+ * turn_start -> status{phase:"start"}
40
+ * thinking_delta -> thinking
41
+ * assistant_message -> text
42
+ * tool_call -> tool
43
+ * tool_result -> tool_result
44
+ * turn_end -> turn_end + status
45
+ * user_question -> question (payload mirrors runtime `questionFrame`)
46
+ * Deliberately NOT reconstructable: `user_message` (never an SSE frame),
47
+ * `user_answer` (the product emits no frame — the answer resolves the parked
48
+ * promise in-process, `apps/studio/src/runtime/question-view.ts`), and the
49
+ * `done`/`compact` frames (they have no session-log row).
50
+ */
51
+ export interface DerivedSseFrame {
52
+ event: string;
53
+ data: {
54
+ turn: number;
55
+ seq: number;
56
+ payload: Record<string, unknown>;
57
+ };
58
+ }
59
+ export declare function deriveSseTranscript(events: readonly SessionEvent[], startTurn?: number): DerivedSseFrame[];
package/dist/replay.js ADDED
@@ -0,0 +1,145 @@
1
+ /** Structural analysis of a replayed session log (P0 replay comparison). */
2
+ import { outcomePhase } from "./jsonl.js";
3
+ import { auditTurnIds } from "./turn-id.js";
4
+ import { toolSurfaceValue } from "@celestea/core";
5
+ export function analyzeReplay(parsed) {
6
+ const events = parsed.events;
7
+ const calls = new Map();
8
+ const results = new Set();
9
+ const subCallParents = new Set();
10
+ const outcomes = {};
11
+ let turnStarts = 0;
12
+ let turnEnds = 0;
13
+ let thinkingEvents = 0;
14
+ let userMessages = 0;
15
+ let assistantMessages = 0;
16
+ let subCalls = 0;
17
+ for (const ev of events) {
18
+ switch (ev.type) {
19
+ case "turn_start":
20
+ turnStarts += 1;
21
+ break;
22
+ case "turn_end":
23
+ turnEnds += 1;
24
+ outcomes[outcomePhase(ev.outcome)] = (outcomes[outcomePhase(ev.outcome)] ?? 0) + 1;
25
+ break;
26
+ case "thinking_delta":
27
+ thinkingEvents += 1;
28
+ break;
29
+ case "user_message":
30
+ userMessages += 1;
31
+ break;
32
+ case "assistant_message":
33
+ assistantMessages += 1;
34
+ break;
35
+ case "tool_call":
36
+ calls.set(ev.id, ev.name);
37
+ if (ev.parent_id !== undefined) {
38
+ subCalls += 1;
39
+ subCallParents.add(ev.parent_id);
40
+ }
41
+ break;
42
+ case "tool_result":
43
+ results.add(ev.id);
44
+ break;
45
+ }
46
+ }
47
+ const danglingToolCalls = [];
48
+ for (const [id, name] of calls)
49
+ if (!results.has(id))
50
+ danglingToolCalls.push({ id, name });
51
+ const orphanToolResults = [];
52
+ for (const id of results)
53
+ if (!calls.has(id))
54
+ orphanToolResults.push(id);
55
+ return {
56
+ physicalLines: parsed.physicalLines,
57
+ parsedEvents: parsed.events.length,
58
+ blankLines: parsed.blankLines,
59
+ tornTail: parsed.tornTail,
60
+ turnStarts,
61
+ turnEnds,
62
+ danglingTurns: Math.max(0, turnStarts - turnEnds),
63
+ toolCalls: calls.size,
64
+ toolResults: results.size,
65
+ danglingToolCalls,
66
+ orphanToolResults,
67
+ subCalls,
68
+ subCallParents: [...subCallParents].sort(),
69
+ thinkingEvents,
70
+ userMessages,
71
+ assistantMessages,
72
+ outcomes,
73
+ turnIds: auditTurnIds(events),
74
+ };
75
+ }
76
+ export function deriveSseTranscript(events, startTurn = 0) {
77
+ const frames = [];
78
+ let seq = 0;
79
+ let turn = startTurn;
80
+ let sawTurnStart = false;
81
+ const push = (event, payload) => {
82
+ frames.push({ event, data: { turn, seq: seq++, payload } });
83
+ };
84
+ for (const ev of events) {
85
+ switch (ev.type) {
86
+ case "turn_start":
87
+ turn += 1;
88
+ sawTurnStart = true;
89
+ push("status", { phase: "start" });
90
+ break;
91
+ case "user_message":
92
+ break; // not an SSE event
93
+ case "user_question":
94
+ // W834 F08: the only frame a client can have seen for a question,
95
+ // rebuilt from the row's own fields. `session` is null because the
96
+ // derived envelope carries no session identity (the log does not
97
+ // either); the other four keys mirror `questionFrame` exactly.
98
+ push("question", {
99
+ session: null,
100
+ id: ev.id,
101
+ questions: [...ev.questions],
102
+ expires_at: ev.expires_at,
103
+ timeout_ms: ev.timeout_ms,
104
+ });
105
+ break;
106
+ case "user_answer":
107
+ // The product emits NO frame when a question is answered: POST
108
+ // /api/questions/{id}/answer resolves the parked promise directly
109
+ // (question-view.ts). The row is the durable record; there is no
110
+ // client-visible frame to rebuild, so it is explicitly skipped.
111
+ break;
112
+ case "thinking_delta":
113
+ push("thinking", { delta: ev.text });
114
+ break;
115
+ case "assistant_message":
116
+ push("text", { delta: ev.text });
117
+ break;
118
+ case "tool_call":
119
+ push("tool", { id: ev.id, name: ev.name, args: ev.args });
120
+ break;
121
+ case "tool_result":
122
+ // W855 (B6): the log stores the original; the frame a client saw carried
123
+ // the FACE, so replay applies the same surface the live loop emitted.
124
+ push("tool_result", {
125
+ id: ev.id,
126
+ ok: ev.error === null,
127
+ value: toolSurfaceValue(ev.value, ev.surface),
128
+ render: null,
129
+ error: ev.error,
130
+ decision: null,
131
+ });
132
+ break;
133
+ case "turn_end": {
134
+ const phase = outcomePhase(ev.outcome);
135
+ const error = ev.outcome !== undefined && typeof ev.outcome === "object" ? `${ev.outcome.error.kind}: ${ev.outcome.error.message}` : null;
136
+ push("turn_end", { outcome: phase, error });
137
+ push("status", { phase, error });
138
+ break;
139
+ }
140
+ }
141
+ }
142
+ if (!sawTurnStart && frames.length > 0)
143
+ turn = startTurn;
144
+ return frames;
145
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Turn id math — A2 (W746): moved to `@celestea/core` (`core/src/turn-id.ts`)
3
+ * because `SessionLog.nextTurnId()` is a seam method: the log owns the counter
4
+ * and every implementation must mint the same `turn-<n>` ids from the same
5
+ * arithmetic. This module stays as the stable import path inside the package.
6
+ */
7
+ export { auditTurnIds, formatTurnId, maxTurnNumber, nextTurnId, nextTurnNumber, parseTurnNumber, } from "@celestea/core";
8
+ export type { TurnIdAudit } from "@celestea/core";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Turn id math — A2 (W746): moved to `@celestea/core` (`core/src/turn-id.ts`)
3
+ * because `SessionLog.nextTurnId()` is a seam method: the log owns the counter
4
+ * and every implementation must mint the same `turn-<n>` ids from the same
5
+ * arithmetic. This module stays as the stable import path inside the package.
6
+ */
7
+ export { auditTurnIds, formatTurnId, maxTurnNumber, nextTurnId, nextTurnNumber, parseTurnNumber, } from "@celestea/core";
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@celestea/session",
3
+ "version": "2.7.1",
4
+ "private": false,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "types": "./dist/index.d.ts",
9
+ "default": "./dist/index.js"
10
+ }
11
+ },
12
+ "dependencies": {
13
+ "@celestea/core": "2.7.1"
14
+ },
15
+ "license": "MIT",
16
+ "files": [
17
+ "dist"
18
+ ],
19
+ "main": "./dist/index.js",
20
+ "types": "./dist/index.d.ts",
21
+ "publishConfig": {
22
+ "access": "public"
23
+ },
24
+ "scripts": {
25
+ "typecheck": "tsc --noEmit -p tsconfig.json",
26
+ "build": "tsc -p tsconfig.build.json"
27
+ }
28
+ }