@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.
- package/LICENSE +21 -0
- package/README.md +121 -0
- package/dist/checkpoint-log.d.ts +30 -0
- package/dist/checkpoint-log.js +78 -0
- package/dist/checkpoint-recovery.d.ts +39 -0
- package/dist/checkpoint-recovery.js +69 -0
- package/dist/checkpoint.d.ts +194 -0
- package/dist/checkpoint.js +292 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +40 -0
- package/dist/jsonl.d.ts +33 -0
- package/dist/jsonl.js +44 -0
- package/dist/log/derive.d.ts +12 -0
- package/dist/log/derive.js +12 -0
- package/dist/log/file.d.ts +43 -0
- package/dist/log/file.js +85 -0
- package/dist/log/memory.d.ts +27 -0
- package/dist/log/memory.js +43 -0
- package/dist/log/persistent.d.ts +58 -0
- package/dist/log/persistent.js +118 -0
- package/dist/messages.d.ts +27 -0
- package/dist/messages.js +128 -0
- package/dist/plugin.d.ts +20 -0
- package/dist/plugin.js +24 -0
- package/dist/replay.d.ts +59 -0
- package/dist/replay.js +145 -0
- package/dist/turn-id.d.ts +8 -0
- package/dist/turn-id.js +7 -0
- package/package.json +28 -0
|
@@ -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[];
|
package/dist/messages.js
ADDED
|
@@ -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
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/replay.d.ts
ADDED
|
@@ -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";
|
package/dist/turn-id.js
ADDED
|
@@ -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
|
+
}
|