@celestea/core 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 +95 -0
- package/contracts/data-files/checkpoint.schema.json +111 -0
- package/contracts/data-files/cli-main-jsonl-precompact.schema.json +27 -0
- package/contracts/data-files/cli-main-jsonl.schema.json +22 -0
- package/contracts/data-files/fallbacks.schema.json +71 -0
- package/contracts/data-files/index.json +124 -0
- package/contracts/data-files/pricing.schema.json +65 -0
- package/contracts/data-files/prompts.schema.json +130 -0
- package/contracts/data-files/providers.schema.json +177 -0
- package/contracts/data-files/registry-tsv.schema.json +74 -0
- package/contracts/data-files/session.schema.json +51 -0
- package/contracts/data-files/usage-ledger.schema.json +112 -0
- package/contracts/data-files/workspaces.schema.json +63 -0
- package/contracts/endpoints.json +4390 -0
- package/contracts/probe-evidence.json +219 -0
- package/contracts/route-table.snapshot.json +377 -0
- package/contracts/scope-hash-vectors.json +273 -0
- package/contracts/session-event.schema.json +441 -0
- package/contracts/sse-events.json +202 -0
- package/contracts/tools.json +730 -0
- package/dist/agent.d.ts +65 -0
- package/dist/agent.js +36 -0
- package/dist/celestea-home.d.ts +63 -0
- package/dist/celestea-home.js +96 -0
- package/dist/celestea-sources.d.ts +53 -0
- package/dist/celestea-sources.js +61 -0
- package/dist/context.d.ts +33 -0
- package/dist/context.js +55 -0
- package/dist/contracts/index.d.ts +234 -0
- package/dist/contracts/index.js +159 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +22 -0
- package/dist/event-bus.d.ts +60 -0
- package/dist/event-bus.js +100 -0
- package/dist/index.d.ts +66 -0
- package/dist/index.js +66 -0
- package/dist/injection.d.ts +61 -0
- package/dist/injection.js +27 -0
- package/dist/json.d.ts +34 -0
- package/dist/json.js +127 -0
- package/dist/llm.d.ts +34 -0
- package/dist/llm.js +41 -0
- package/dist/memory.d.ts +72 -0
- package/dist/memory.js +123 -0
- package/dist/message.d.ts +189 -0
- package/dist/message.js +252 -0
- package/dist/plugin.d.ts +38 -0
- package/dist/plugin.js +49 -0
- package/dist/projection.d.ts +67 -0
- package/dist/projection.js +168 -0
- package/dist/question.d.ts +154 -0
- package/dist/question.js +82 -0
- package/dist/redact.d.ts +40 -0
- package/dist/redact.js +185 -0
- package/dist/repo.d.ts +14 -0
- package/dist/repo.js +87 -0
- package/dist/sandbox.d.ts +182 -0
- package/dist/sandbox.js +78 -0
- package/dist/session-event.d.ts +57 -0
- package/dist/session-event.js +425 -0
- package/dist/session-log.d.ts +71 -0
- package/dist/session-log.js +66 -0
- package/dist/skill-catalog.d.ts +29 -0
- package/dist/skill-catalog.js +52 -0
- package/dist/skills.d.ts +116 -0
- package/dist/skills.js +273 -0
- package/dist/sse-bus.d.ts +40 -0
- package/dist/sse-bus.js +105 -0
- package/dist/stream.d.ts +115 -0
- package/dist/stream.js +52 -0
- package/dist/tool-surface.d.ts +45 -0
- package/dist/tool-surface.js +98 -0
- package/dist/tool.d.ts +77 -0
- package/dist/tool.js +15 -0
- package/dist/turn-id.d.ts +37 -0
- package/dist/turn-id.js +76 -0
- package/dist/types.d.ts +396 -0
- package/dist/types.js +58 -0
- package/package.json +27 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionEvent codec — the serde-exact JSONL row contract of
|
|
3
|
+
* `crates/core/src/session_log.rs:44-85`.
|
|
4
|
+
*
|
|
5
|
+
* Wire rules (all of them are contract):
|
|
6
|
+
* - internally tagged enum: the `type` key comes FIRST, then the fields in
|
|
7
|
+
* declaration order (`id`, `outcome` / `id`, `name`, `args`, `parent_id`);
|
|
8
|
+
* - `parent_id` is `Option<String>` with `skip_serializing_if = "Option::is_none"`,
|
|
9
|
+
* so it is omitted when absent (pre-W255 byte shape) — but a JSON `null`
|
|
10
|
+
* also deserializes to `None`;
|
|
11
|
+
* - `value` / `error` are `Option<…>` without skip: they are ALWAYS written
|
|
12
|
+
* and become `null` when absent;
|
|
13
|
+
* - `TurnEnd.outcome` has `#[serde(default)]`: a legacy row without it reads
|
|
14
|
+
* as `"completed"` and is re-written WITH the field;
|
|
15
|
+
* - unknown fields are ignored; a missing required field or an unknown
|
|
16
|
+
* `type` is a parse error (the caller then treats the row as a torn tail);
|
|
17
|
+
* - `args` / `value` ride through `serde_json::Value`, so their object keys
|
|
18
|
+
* are re-serialized in sorted order (see `serdeJsonString`).
|
|
19
|
+
*/
|
|
20
|
+
import { isRecord, serdeJsonString } from "./json.js";
|
|
21
|
+
import { isImageRef, normalizeImageRef } from "./message.js";
|
|
22
|
+
import { SESSION_EVENT_ORIGINS, SESSION_EVENT_TYPES } from "./types.js";
|
|
23
|
+
/** `TurnOutcome::default()` — legacy `turn_end` rows read as completed. */
|
|
24
|
+
export const DEFAULT_TURN_OUTCOME = "completed";
|
|
25
|
+
/** The outcome of a row, with the legacy default applied. */
|
|
26
|
+
export function effectiveOutcome(o) {
|
|
27
|
+
return o === undefined ? DEFAULT_TURN_OUTCOME : o;
|
|
28
|
+
}
|
|
29
|
+
/** Normalized outcome label (TurnOutcome -> the 5 SSE/statusline phases). */
|
|
30
|
+
export function outcomePhase(o) {
|
|
31
|
+
const e = effectiveOutcome(o);
|
|
32
|
+
return typeof e === "string" ? e : "error";
|
|
33
|
+
}
|
|
34
|
+
/** `"{kind}: {message}"` for the error variant, null otherwise. */
|
|
35
|
+
export function outcomeError(o) {
|
|
36
|
+
const e = effectiveOutcome(o);
|
|
37
|
+
return typeof e === "string" ? null : `${e.error.kind}: ${e.error.message}`;
|
|
38
|
+
}
|
|
39
|
+
/** The error payload of the error variant, null otherwise. */
|
|
40
|
+
export function outcomeErrorParts(o) {
|
|
41
|
+
const e = effectiveOutcome(o);
|
|
42
|
+
return typeof e === "string" ? null : e.error;
|
|
43
|
+
}
|
|
44
|
+
export function isTurnOutcome(v) {
|
|
45
|
+
if (typeof v === "string")
|
|
46
|
+
return ["completed", "cancelled", "step_limit", "interrupted"].includes(v);
|
|
47
|
+
if (isRecord(v) && isRecord(v["error"])) {
|
|
48
|
+
const e = v["error"];
|
|
49
|
+
return typeof e["kind"] === "string" && typeof e["message"] === "string";
|
|
50
|
+
}
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
export function isSessionEventType(t) {
|
|
54
|
+
return SESSION_EVENT_TYPES.includes(t);
|
|
55
|
+
}
|
|
56
|
+
/** Validate one decoded JSON value against the SessionEvent contract. */
|
|
57
|
+
export function validateSessionEvent(raw) {
|
|
58
|
+
if (!isRecord(raw))
|
|
59
|
+
return { ok: false, errors: ["event is not a JSON object"] };
|
|
60
|
+
const type = raw["type"];
|
|
61
|
+
if (typeof type !== "string")
|
|
62
|
+
return { ok: false, errors: ["missing string field `type`"] };
|
|
63
|
+
if (!isSessionEventType(type))
|
|
64
|
+
return { ok: false, errors: [`unknown event type '${type}'`] };
|
|
65
|
+
const errors = [];
|
|
66
|
+
switch (type) {
|
|
67
|
+
case "turn_start":
|
|
68
|
+
requireString(raw, "id", errors);
|
|
69
|
+
break;
|
|
70
|
+
case "turn_end":
|
|
71
|
+
requireString(raw, "id", errors);
|
|
72
|
+
if (raw["outcome"] !== undefined && raw["outcome"] !== null && !isTurnOutcome(raw["outcome"])) {
|
|
73
|
+
errors.push("field 'outcome' is not a valid TurnOutcome");
|
|
74
|
+
}
|
|
75
|
+
break;
|
|
76
|
+
case "user_message":
|
|
77
|
+
requireString(raw, "text", errors);
|
|
78
|
+
// W804 §4.2C: the ONLY extra field, optional and serde-shaped (null == absent).
|
|
79
|
+
optionalAttachments(raw, "attachments", errors);
|
|
80
|
+
// W888: a closed origin whitelist. An UNKNOWN value is a hard error (never a
|
|
81
|
+
// silent fallback to 'user'): the projection branches on it, so a typo would
|
|
82
|
+
// otherwise smuggle an injected row through as if the human had typed it.
|
|
83
|
+
optionalOrigin(raw, "origin", errors);
|
|
84
|
+
break;
|
|
85
|
+
case "assistant_message":
|
|
86
|
+
case "thinking_delta":
|
|
87
|
+
requireString(raw, "text", errors);
|
|
88
|
+
break;
|
|
89
|
+
case "tool_call":
|
|
90
|
+
requireString(raw, "id", errors);
|
|
91
|
+
requireString(raw, "name", errors);
|
|
92
|
+
requirePresent(raw, "args", errors);
|
|
93
|
+
optionalString(raw, "parent_id", errors);
|
|
94
|
+
break;
|
|
95
|
+
case "tool_result":
|
|
96
|
+
requireString(raw, "id", errors);
|
|
97
|
+
// value / error are `Option<_>`: absent == null (serde treats Option
|
|
98
|
+
// fields as optional), so only their type is checked when present.
|
|
99
|
+
if (raw["error"] !== undefined && raw["error"] !== null && typeof raw["error"] !== "string") {
|
|
100
|
+
errors.push("field 'error' must be string|null");
|
|
101
|
+
}
|
|
102
|
+
optionalString(raw, "parent_id", errors);
|
|
103
|
+
// W855 (B6): the model-face descriptor is optional; a malformed one is an
|
|
104
|
+
// error (it would silently change the model context on replay).
|
|
105
|
+
optionalSurface(raw, "surface", errors);
|
|
106
|
+
break;
|
|
107
|
+
// W783: the two host-side user-question rows. `questions` / `answers` are
|
|
108
|
+
// required and must be arrays; the timing fields are optional numbers.
|
|
109
|
+
case "user_question":
|
|
110
|
+
requireString(raw, "id", errors);
|
|
111
|
+
requireArray(raw, "questions", errors);
|
|
112
|
+
optionalNumber(raw, "expires_at", errors);
|
|
113
|
+
optionalNumber(raw, "timeout_ms", errors);
|
|
114
|
+
break;
|
|
115
|
+
case "user_answer":
|
|
116
|
+
requireString(raw, "id", errors);
|
|
117
|
+
requireArray(raw, "answers", errors);
|
|
118
|
+
optionalBoolean(raw, "timed_out", errors);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
if (errors.length > 0)
|
|
122
|
+
return { ok: false, errors };
|
|
123
|
+
return { ok: true, event: normalizeSessionEvent(raw, type) };
|
|
124
|
+
}
|
|
125
|
+
/** Parse one JSONL row; a failure is the caller's torn-tail signal. */
|
|
126
|
+
export function parseSessionEvent(line) {
|
|
127
|
+
let decoded;
|
|
128
|
+
try {
|
|
129
|
+
decoded = JSON.parse(line);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { ok: false, errors: [e instanceof Error ? e.message : String(e)] };
|
|
133
|
+
}
|
|
134
|
+
return validateSessionEvent(decoded);
|
|
135
|
+
}
|
|
136
|
+
/** `Option<T>` normalisation: a JSON `null` is `None`, i.e. absent. */
|
|
137
|
+
function normalizeSessionEvent(raw, type) {
|
|
138
|
+
if (type === "tool_call") {
|
|
139
|
+
const ev = {
|
|
140
|
+
type,
|
|
141
|
+
id: raw["id"],
|
|
142
|
+
name: raw["name"],
|
|
143
|
+
args: raw["args"],
|
|
144
|
+
};
|
|
145
|
+
const parent = nullableString(raw["parent_id"]);
|
|
146
|
+
if (parent !== undefined)
|
|
147
|
+
ev.parent_id = parent;
|
|
148
|
+
return ev;
|
|
149
|
+
}
|
|
150
|
+
if (type === "tool_result") {
|
|
151
|
+
const ev = {
|
|
152
|
+
type,
|
|
153
|
+
id: raw["id"],
|
|
154
|
+
value: raw["value"],
|
|
155
|
+
error: nullableString(raw["error"]) ?? null,
|
|
156
|
+
};
|
|
157
|
+
const parent = nullableString(raw["parent_id"]);
|
|
158
|
+
if (parent !== undefined)
|
|
159
|
+
ev.parent_id = parent;
|
|
160
|
+
// W855 (B6): `null` normalises to absent, like `parent_id`.
|
|
161
|
+
const surface = raw["surface"];
|
|
162
|
+
if (surface !== undefined && surface !== null)
|
|
163
|
+
ev.surface = surface;
|
|
164
|
+
return ev;
|
|
165
|
+
}
|
|
166
|
+
if (type === "user_question") {
|
|
167
|
+
// Optional timing fields are OMITTED when absent, exactly like `parent_id`
|
|
168
|
+
// (the row is host-written, so its own writer defines the byte shape).
|
|
169
|
+
const ev = { type, id: raw["id"], questions: raw["questions"] };
|
|
170
|
+
const expires = optionalNumberValue(raw["expires_at"]);
|
|
171
|
+
if (expires !== undefined)
|
|
172
|
+
ev.expires_at = expires;
|
|
173
|
+
const timeout = optionalNumberValue(raw["timeout_ms"]);
|
|
174
|
+
if (timeout !== undefined)
|
|
175
|
+
ev.timeout_ms = timeout;
|
|
176
|
+
return ev;
|
|
177
|
+
}
|
|
178
|
+
if (type === "user_answer") {
|
|
179
|
+
const ev = { type, id: raw["id"], answers: raw["answers"] };
|
|
180
|
+
if (typeof raw["timed_out"] === "boolean")
|
|
181
|
+
ev.timed_out = raw["timed_out"];
|
|
182
|
+
return ev;
|
|
183
|
+
}
|
|
184
|
+
if (type === "turn_end") {
|
|
185
|
+
// `#[serde(default)]` fills the missing outcome, so the in-memory event
|
|
186
|
+
// ALWAYS carries one (a legacy row reads as completed).
|
|
187
|
+
return { type, id: raw["id"], outcome: effectiveOutcome(raw["outcome"]) };
|
|
188
|
+
}
|
|
189
|
+
// W804 §4.2C: an EXPLICIT user_message branch. Without it the generic
|
|
190
|
+
// fallthrough would outlive this change, but the moment the row gains a field
|
|
191
|
+
// the codec must own its normalization (field whitelist, null -> omitted).
|
|
192
|
+
if (type === "user_message") {
|
|
193
|
+
const ev = { type, text: raw["text"] };
|
|
194
|
+
const refs = attachmentList(raw["attachments"]);
|
|
195
|
+
if (refs.length > 0)
|
|
196
|
+
ev.attachments = refs;
|
|
197
|
+
// W888: null normalises to absent (like parent_id); 'user' is the default
|
|
198
|
+
// and is OMITTED so the common row stays byte-identical to pre-W888.
|
|
199
|
+
const origin = originValue(raw["origin"]);
|
|
200
|
+
if (origin !== undefined && origin !== "user")
|
|
201
|
+
ev.origin = origin;
|
|
202
|
+
return ev;
|
|
203
|
+
}
|
|
204
|
+
return raw;
|
|
205
|
+
}
|
|
206
|
+
/** Attachment array validation: null == absent; each item must be an ImageRef. */
|
|
207
|
+
function optionalAttachments(raw, name, errors) {
|
|
208
|
+
const v = raw[name];
|
|
209
|
+
if (v === undefined || v === null)
|
|
210
|
+
return;
|
|
211
|
+
if (!Array.isArray(v)) {
|
|
212
|
+
errors.push(`field '${name}' must be an array when present`);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
for (const item of v) {
|
|
216
|
+
if (!isImageRef(item))
|
|
217
|
+
errors.push(`field '${name}' items must be ImageRef objects`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Normalize a decoded attachment list: invalid items are dropped; [] means absent. */
|
|
221
|
+
function attachmentList(v) {
|
|
222
|
+
if (!Array.isArray(v))
|
|
223
|
+
return [];
|
|
224
|
+
const out = [];
|
|
225
|
+
for (const item of v) {
|
|
226
|
+
const ref = normalizeImageRef(item);
|
|
227
|
+
if (ref !== null)
|
|
228
|
+
out.push(ref);
|
|
229
|
+
}
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* serde-exact ImageRef writer: field order attachment_id, media_type, width,
|
|
234
|
+
* height, name?, original?; a None optional is omitted, never written as null.
|
|
235
|
+
*/
|
|
236
|
+
function serializeAttachmentRef(ref) {
|
|
237
|
+
const parts = [
|
|
238
|
+
`"attachment_id":${JSON.stringify(ref.attachment_id)}`,
|
|
239
|
+
`"media_type":${JSON.stringify(ref.media_type)}`,
|
|
240
|
+
`"width":${JSON.stringify(ref.width)}`,
|
|
241
|
+
`"height":${JSON.stringify(ref.height)}`,
|
|
242
|
+
];
|
|
243
|
+
if (ref.name !== undefined)
|
|
244
|
+
parts.push(`"name":${JSON.stringify(ref.name)}`);
|
|
245
|
+
if (ref.original !== undefined) {
|
|
246
|
+
const o = ref.original;
|
|
247
|
+
parts.push(`"original":{"width":${JSON.stringify(o.width)},"height":${JSON.stringify(o.height)},"bytes":${JSON.stringify(o.bytes)},"media_type":${JSON.stringify(o.media_type)}}`);
|
|
248
|
+
}
|
|
249
|
+
return `{${parts.join(",")}}`;
|
|
250
|
+
}
|
|
251
|
+
function nullableString(v) {
|
|
252
|
+
return typeof v === "string" ? v : undefined;
|
|
253
|
+
}
|
|
254
|
+
function requireString(raw, name, errors) {
|
|
255
|
+
if (typeof raw[name] !== "string")
|
|
256
|
+
errors.push(`field '${name}' must be a string`);
|
|
257
|
+
}
|
|
258
|
+
function requirePresent(raw, name, errors) {
|
|
259
|
+
if (!(name in raw))
|
|
260
|
+
errors.push(`field '${name}' is required`);
|
|
261
|
+
}
|
|
262
|
+
function requireArray(raw, name, errors) {
|
|
263
|
+
if (!Array.isArray(raw[name]))
|
|
264
|
+
errors.push(`field '${name}' must be an array`);
|
|
265
|
+
}
|
|
266
|
+
function optionalNumber(raw, name, errors) {
|
|
267
|
+
const v = raw[name];
|
|
268
|
+
if (v !== undefined && v !== null && (typeof v !== "number" || !Number.isFinite(v))) {
|
|
269
|
+
errors.push(`field '${name}' must be a number when present`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function optionalNumberValue(v) {
|
|
273
|
+
return typeof v === "number" && Number.isFinite(v) ? v : undefined;
|
|
274
|
+
}
|
|
275
|
+
function optionalBoolean(raw, name, errors) {
|
|
276
|
+
const v = raw[name];
|
|
277
|
+
if (v !== undefined && v !== null && typeof v !== "boolean") {
|
|
278
|
+
errors.push(`field '${name}' must be a boolean when present`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
function optionalString(raw, name, errors) {
|
|
282
|
+
const v = raw[name];
|
|
283
|
+
if (v !== undefined && v !== null && typeof v !== "string") {
|
|
284
|
+
errors.push(`field '${name}' must be a string when present`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
/** W888: validate the optional `origin` against the closed whitelist. */
|
|
288
|
+
function optionalOrigin(raw, name, errors) {
|
|
289
|
+
const v = raw[name];
|
|
290
|
+
if (v === undefined || v === null)
|
|
291
|
+
return;
|
|
292
|
+
if (typeof v !== "string" || !SESSION_EVENT_ORIGINS.includes(v)) {
|
|
293
|
+
errors.push("field '" + name + "' must be one of: " + SESSION_EVENT_ORIGINS.join(", "));
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
/** W888: a valid origin, or undefined (absent/null/invalid -> omitted). */
|
|
297
|
+
function originValue(v) {
|
|
298
|
+
return typeof v === "string" && SESSION_EVENT_ORIGINS.includes(v) ? v : undefined;
|
|
299
|
+
}
|
|
300
|
+
/** W855 (B6): the optional model-face descriptor on a `tool_result` row. */
|
|
301
|
+
function optionalSurface(raw, name, errors) {
|
|
302
|
+
const v = raw[name];
|
|
303
|
+
if (v === undefined || v === null)
|
|
304
|
+
return;
|
|
305
|
+
if (!isRecord(v)) {
|
|
306
|
+
errors.push(`field '${name}' must be an object when present`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (v["kind"] === "omitted") {
|
|
310
|
+
for (const n of ["omitted_bytes", "total_bytes", "head_bytes", "tail_bytes"]) {
|
|
311
|
+
requirePresent(v, n, errors);
|
|
312
|
+
optionalNumber(v, n, errors);
|
|
313
|
+
}
|
|
314
|
+
for (const s of ["locator", "retrieval_hint"]) {
|
|
315
|
+
requirePresent(v, s, errors);
|
|
316
|
+
optionalString(v, s, errors);
|
|
317
|
+
}
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
if (v["kind"] === "truncation") {
|
|
321
|
+
requirePresent(v, "note", errors);
|
|
322
|
+
optionalString(v, "note", errors);
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
errors.push(`field '${name}.kind' must be "omitted" or "truncation"`);
|
|
326
|
+
}
|
|
327
|
+
/**
|
|
328
|
+
* Serialize one event exactly like `serde_json::to_string(&SessionEvent)`:
|
|
329
|
+
* the `type` tag first, then the fields in declaration order, `parent_id`
|
|
330
|
+
* omitted when None, `value` / `error` always present (null when None), and
|
|
331
|
+
* `outcome` always present (legacy rows are normalised to `"completed"`).
|
|
332
|
+
*
|
|
333
|
+
* The event struct order is preserved (serde writes struct fields in
|
|
334
|
+
* declaration order); only the `Value`-typed fields (`args`, `value`) go
|
|
335
|
+
* through the sorted-key `serdeJsonString`, exactly like serde_json's BTreeMap.
|
|
336
|
+
*/
|
|
337
|
+
export function serializeSessionEvent(ev) {
|
|
338
|
+
const parts = [`"type":${JSON.stringify(ev.type)}`];
|
|
339
|
+
switch (ev.type) {
|
|
340
|
+
case "turn_start":
|
|
341
|
+
parts.push(`"id":${JSON.stringify(ev.id)}`);
|
|
342
|
+
break;
|
|
343
|
+
case "turn_end":
|
|
344
|
+
parts.push(`"id":${JSON.stringify(ev.id)}`);
|
|
345
|
+
parts.push(`"outcome":${serializeOutcome(ev.outcome)}`);
|
|
346
|
+
break;
|
|
347
|
+
case "user_message":
|
|
348
|
+
parts.push(`"text":${JSON.stringify(ev.text)}`);
|
|
349
|
+
// W804 §4.2C (highest-risk site): serializeSessionEvent is hand-written
|
|
350
|
+
// per field. attachments MUST have an explicit branch or /compact's atomic
|
|
351
|
+
// log rewrite silently drops every image reference.
|
|
352
|
+
if (ev.attachments !== undefined && ev.attachments.length > 0) {
|
|
353
|
+
parts.push(`"attachments":[${ev.attachments.map(serializeAttachmentRef).join(",")}]`);
|
|
354
|
+
}
|
|
355
|
+
// W888: omit 'user' (the default) so a pre-W888 row keeps its exact bytes.
|
|
356
|
+
if (ev.origin !== undefined && ev.origin !== "user")
|
|
357
|
+
parts.push(`"origin":${JSON.stringify(ev.origin)}`);
|
|
358
|
+
break;
|
|
359
|
+
case "assistant_message":
|
|
360
|
+
case "thinking_delta":
|
|
361
|
+
parts.push(`"text":${JSON.stringify(ev.text)}`);
|
|
362
|
+
break;
|
|
363
|
+
case "tool_call":
|
|
364
|
+
parts.push(`"id":${JSON.stringify(ev.id)}`);
|
|
365
|
+
parts.push(`"name":${JSON.stringify(ev.name)}`);
|
|
366
|
+
parts.push(`"args":${serdeJsonString(ev.args)}`);
|
|
367
|
+
if (ev.parent_id !== undefined && ev.parent_id !== null)
|
|
368
|
+
parts.push(`"parent_id":${JSON.stringify(ev.parent_id)}`);
|
|
369
|
+
break;
|
|
370
|
+
case "tool_result":
|
|
371
|
+
parts.push(`"id":${JSON.stringify(ev.id)}`);
|
|
372
|
+
parts.push(`"value":${serdeJsonString(ev.value === undefined ? null : ev.value)}`);
|
|
373
|
+
parts.push(`"error":${ev.error === undefined || ev.error === null ? "null" : JSON.stringify(ev.error)}`);
|
|
374
|
+
if (ev.parent_id !== undefined && ev.parent_id !== null)
|
|
375
|
+
parts.push(`"parent_id":${JSON.stringify(ev.parent_id)}`);
|
|
376
|
+
// W855 (B6): the model-face descriptor, omitted when absent (old rows keep
|
|
377
|
+
// their byte shape).
|
|
378
|
+
if (ev.surface !== undefined)
|
|
379
|
+
parts.push(`"surface":${serializeToolSurface(ev.surface)}`);
|
|
380
|
+
break;
|
|
381
|
+
// W783: tag first, then the fields in declaration order; the optional ones
|
|
382
|
+
// are omitted when absent (never written as null).
|
|
383
|
+
case "user_question":
|
|
384
|
+
parts.push(`"id":${JSON.stringify(ev.id)}`);
|
|
385
|
+
parts.push(`"questions":${serdeJsonString(ev.questions)}`);
|
|
386
|
+
if (ev.expires_at !== undefined)
|
|
387
|
+
parts.push(`"expires_at":${JSON.stringify(ev.expires_at)}`);
|
|
388
|
+
if (ev.timeout_ms !== undefined)
|
|
389
|
+
parts.push(`"timeout_ms":${JSON.stringify(ev.timeout_ms)}`);
|
|
390
|
+
break;
|
|
391
|
+
case "user_answer":
|
|
392
|
+
parts.push(`"id":${JSON.stringify(ev.id)}`);
|
|
393
|
+
parts.push(`"answers":${serdeJsonString(ev.answers)}`);
|
|
394
|
+
if (ev.timed_out !== undefined)
|
|
395
|
+
parts.push(`"timed_out":${JSON.stringify(ev.timed_out)}`);
|
|
396
|
+
break;
|
|
397
|
+
}
|
|
398
|
+
return `{${parts.join(",")}}`;
|
|
399
|
+
}
|
|
400
|
+
/** W855 (B6): the `surface` object, field order fixed (declaration order). */
|
|
401
|
+
function serializeToolSurface(surface) {
|
|
402
|
+
if (surface.kind === "omitted") {
|
|
403
|
+
return ('{"kind":"omitted","omitted_bytes":' +
|
|
404
|
+
JSON.stringify(surface.omitted_bytes) +
|
|
405
|
+
',"total_bytes":' +
|
|
406
|
+
JSON.stringify(surface.total_bytes) +
|
|
407
|
+
',"locator":' +
|
|
408
|
+
JSON.stringify(surface.locator) +
|
|
409
|
+
',"retrieval_hint":' +
|
|
410
|
+
JSON.stringify(surface.retrieval_hint) +
|
|
411
|
+
',"head_bytes":' +
|
|
412
|
+
JSON.stringify(surface.head_bytes) +
|
|
413
|
+
',"tail_bytes":' +
|
|
414
|
+
JSON.stringify(surface.tail_bytes) +
|
|
415
|
+
"}");
|
|
416
|
+
}
|
|
417
|
+
return '{"kind":"truncation","note":' + JSON.stringify(surface.note) + "}";
|
|
418
|
+
}
|
|
419
|
+
/** `TurnOutcome` serde shape: `"completed"` | … | `{"error":{"kind","message"}}`. */
|
|
420
|
+
function serializeOutcome(o) {
|
|
421
|
+
const e = effectiveOutcome(o);
|
|
422
|
+
if (typeof e === "string")
|
|
423
|
+
return JSON.stringify(e);
|
|
424
|
+
return `{"error":{"kind":${JSON.stringify(e.error.kind)},"message":${JSON.stringify(e.error.message)}}}`;
|
|
425
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionLog seam — the `SessionLog` trait of
|
|
3
|
+
* `crates/core/src/session_log.rs:87-99`.
|
|
4
|
+
*
|
|
5
|
+
* The log is the single source of truth for a conversation: it records
|
|
6
|
+
* SessionEvents in insertion order and derives the model-visible history on
|
|
7
|
+
* demand. `packages/session` provides the in-memory and JSONL-backed
|
|
8
|
+
* implementations and registers one as a plugin; `core` never imports them
|
|
9
|
+
* (dependency direction: session -> core).
|
|
10
|
+
*
|
|
11
|
+
* A2 (W746): the seam now also owns the two things every implementation used to
|
|
12
|
+
* have to re-implement — the projection ([deriveMessagesFrom]) and the turn-id
|
|
13
|
+
* math ([formatTurnId] / [maxTurnNumber]). A backend only owns STORAGE
|
|
14
|
+
* ([EventStore]) and hands it to [projectingSessionLog]; the store type rejects a
|
|
15
|
+
* backend that ships its own `deriveMessages`, so the "legal-looking log with an
|
|
16
|
+
* empty history" that `packages/workers/src/log.ts` used to be is no longer
|
|
17
|
+
* expressible.
|
|
18
|
+
*/
|
|
19
|
+
import type { Message } from "./message.js";
|
|
20
|
+
import type { SessionEvent } from "./types.js";
|
|
21
|
+
export interface SessionLog {
|
|
22
|
+
/** Append one event (insertion order is the contract). */
|
|
23
|
+
append(event: SessionEvent): void;
|
|
24
|
+
/** A copy of the recorded events, in insertion order. */
|
|
25
|
+
events(): SessionEvent[];
|
|
26
|
+
/** The model-visible projection of [events]. */
|
|
27
|
+
deriveMessages(): Message[];
|
|
28
|
+
/** Drop every event (the turn-id counter never resets in the in-memory log). */
|
|
29
|
+
clear(): void;
|
|
30
|
+
/**
|
|
31
|
+
* Allocate the next unique turn id (`"turn-<n>"`, monotonic). The LOG owns
|
|
32
|
+
* the counter — not the agent loop — so ids never repeat across loop
|
|
33
|
+
* instances, and a persistent log restores its counter from the max turn id
|
|
34
|
+
* replayed from disk.
|
|
35
|
+
*/
|
|
36
|
+
nextTurnId(): string;
|
|
37
|
+
}
|
|
38
|
+
/** Well-known token for the session log service in a Context. */
|
|
39
|
+
export declare const SESSION_LOG_SERVICE = "celestea.core.SessionLog";
|
|
40
|
+
/**
|
|
41
|
+
* The storage backend a SessionLog is built on: events in, events out. Neither
|
|
42
|
+
* the projection nor the turn ids are the store's business.
|
|
43
|
+
*
|
|
44
|
+
* `deriveMessages?: never` is the point of the type. The projection is core's
|
|
45
|
+
* ([deriveMessagesFrom]), so a store that ships its own `deriveMessages` — above
|
|
46
|
+
* all one that returns `[]` — is not assignable to [projectingSessionLog]. That
|
|
47
|
+
* is what makes the empty-history log `packages/workers/src/log.ts` used to be a
|
|
48
|
+
* type error instead of a plausible-looking alternative implementation.
|
|
49
|
+
*/
|
|
50
|
+
export interface EventStore {
|
|
51
|
+
/** Append one event (insertion order is the contract). */
|
|
52
|
+
append(event: SessionEvent): void;
|
|
53
|
+
/** A copy of the recorded events, in insertion order. */
|
|
54
|
+
events(): SessionEvent[];
|
|
55
|
+
/** Drop every event. */
|
|
56
|
+
clear(): void;
|
|
57
|
+
/** Refused: the projection is the seam's, never the store's (A2). */
|
|
58
|
+
deriveMessages?: never;
|
|
59
|
+
}
|
|
60
|
+
/** An in-memory event store (the trivial backend, and the default one). */
|
|
61
|
+
export declare function memoryEventStore(): EventStore;
|
|
62
|
+
/**
|
|
63
|
+
* Build a `SessionLog` over a store: core owns the projection and the ids, the
|
|
64
|
+
* store owns persistence.
|
|
65
|
+
*
|
|
66
|
+
* The counter starts at `maxTurnNumber(store.events()) + 1` — 0 for an empty
|
|
67
|
+
* store, and beyond every id already on disk for a replayed one (so a restart
|
|
68
|
+
* never reuses an id) — and never resets, not even on `clear()` (ids stay
|
|
69
|
+
* unique for the life of the log).
|
|
70
|
+
*/
|
|
71
|
+
export declare function projectingSessionLog(store: EventStore): SessionLog;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SessionLog seam — the `SessionLog` trait of
|
|
3
|
+
* `crates/core/src/session_log.rs:87-99`.
|
|
4
|
+
*
|
|
5
|
+
* The log is the single source of truth for a conversation: it records
|
|
6
|
+
* SessionEvents in insertion order and derives the model-visible history on
|
|
7
|
+
* demand. `packages/session` provides the in-memory and JSONL-backed
|
|
8
|
+
* implementations and registers one as a plugin; `core` never imports them
|
|
9
|
+
* (dependency direction: session -> core).
|
|
10
|
+
*
|
|
11
|
+
* A2 (W746): the seam now also owns the two things every implementation used to
|
|
12
|
+
* have to re-implement — the projection ([deriveMessagesFrom]) and the turn-id
|
|
13
|
+
* math ([formatTurnId] / [maxTurnNumber]). A backend only owns STORAGE
|
|
14
|
+
* ([EventStore]) and hands it to [projectingSessionLog]; the store type rejects a
|
|
15
|
+
* backend that ships its own `deriveMessages`, so the "legal-looking log with an
|
|
16
|
+
* empty history" that `packages/workers/src/log.ts` used to be is no longer
|
|
17
|
+
* expressible.
|
|
18
|
+
*/
|
|
19
|
+
import { deriveMessagesFrom } from "./projection.js";
|
|
20
|
+
import { formatTurnId, maxTurnNumber } from "./turn-id.js";
|
|
21
|
+
/** Well-known token for the session log service in a Context. */
|
|
22
|
+
export const SESSION_LOG_SERVICE = "celestea.core.SessionLog";
|
|
23
|
+
/** An in-memory event store (the trivial backend, and the default one). */
|
|
24
|
+
export function memoryEventStore() {
|
|
25
|
+
let recorded = [];
|
|
26
|
+
return {
|
|
27
|
+
append(event) {
|
|
28
|
+
recorded.push(event);
|
|
29
|
+
},
|
|
30
|
+
events() {
|
|
31
|
+
return [...recorded];
|
|
32
|
+
},
|
|
33
|
+
clear() {
|
|
34
|
+
recorded = [];
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Build a `SessionLog` over a store: core owns the projection and the ids, the
|
|
40
|
+
* store owns persistence.
|
|
41
|
+
*
|
|
42
|
+
* The counter starts at `maxTurnNumber(store.events()) + 1` — 0 for an empty
|
|
43
|
+
* store, and beyond every id already on disk for a replayed one (so a restart
|
|
44
|
+
* never reuses an id) — and never resets, not even on `clear()` (ids stay
|
|
45
|
+
* unique for the life of the log).
|
|
46
|
+
*/
|
|
47
|
+
export function projectingSessionLog(store) {
|
|
48
|
+
let turnCounter = maxTurnNumber(store.events()) + 1;
|
|
49
|
+
return {
|
|
50
|
+
append(event) {
|
|
51
|
+
store.append(event);
|
|
52
|
+
},
|
|
53
|
+
events() {
|
|
54
|
+
return store.events();
|
|
55
|
+
},
|
|
56
|
+
deriveMessages() {
|
|
57
|
+
return deriveMessagesFrom(store.events());
|
|
58
|
+
},
|
|
59
|
+
clear() {
|
|
60
|
+
store.clear();
|
|
61
|
+
},
|
|
62
|
+
nextTurnId() {
|
|
63
|
+
return formatTurnId(turnCounter++);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W884 — the skill CATALOG: the "resident" half of progressive disclosure.
|
|
3
|
+
*
|
|
4
|
+
* W882 owns discovery + the frontmatter contract; the tool (`load_skill`) owns
|
|
5
|
+
* the body-on-demand half. This module owns the third piece: the compact,
|
|
6
|
+
* name + description ONLY listing that is injected into the conversation at
|
|
7
|
+
* every turn start. The body is NEVER here.
|
|
8
|
+
*
|
|
9
|
+
* Cost rules (the whole point of the split):
|
|
10
|
+
* - ZERO rows when the workspace declares no skill (a workspace without
|
|
11
|
+
* skills pays nothing);
|
|
12
|
+
* - a description longer than [SKILL_CATALOG_DESCRIPTION_MAX] is truncated
|
|
13
|
+
* (W879);
|
|
14
|
+
* - at most [SKILL_CATALOG_MAX] entries, name-sorted (the listing already is),
|
|
15
|
+
* with the omitted count stated so a truncation is never silent.
|
|
16
|
+
*/
|
|
17
|
+
import type { CelesteaHomeInput } from "./celestea-home.js";
|
|
18
|
+
import { type SkillIo, type SkillListing } from "./skills.js";
|
|
19
|
+
/** Maximum number of skills the injected catalog lists. */
|
|
20
|
+
export declare const SKILL_CATALOG_MAX = 32;
|
|
21
|
+
/** Maximum description length inside the catalog, in characters (W879). */
|
|
22
|
+
export declare const SKILL_CATALOG_DESCRIPTION_MAX = 200;
|
|
23
|
+
/**
|
|
24
|
+
* Render the catalog text of one discovery listing. PURE. Returns `null` when
|
|
25
|
+
* there is nothing to announce, so the caller injects NOTHING (zero cost).
|
|
26
|
+
*/
|
|
27
|
+
export declare function renderSkillCatalog(listing: SkillListing): string | null;
|
|
28
|
+
/** Discover the two layers of `wsPath` and render their catalog (or null). */
|
|
29
|
+
export declare function skillCatalogOf(wsPath: string, input?: CelesteaHomeInput, io?: SkillIo): string | null;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* W884 — the skill CATALOG: the "resident" half of progressive disclosure.
|
|
3
|
+
*
|
|
4
|
+
* W882 owns discovery + the frontmatter contract; the tool (`load_skill`) owns
|
|
5
|
+
* the body-on-demand half. This module owns the third piece: the compact,
|
|
6
|
+
* name + description ONLY listing that is injected into the conversation at
|
|
7
|
+
* every turn start. The body is NEVER here.
|
|
8
|
+
*
|
|
9
|
+
* Cost rules (the whole point of the split):
|
|
10
|
+
* - ZERO rows when the workspace declares no skill (a workspace without
|
|
11
|
+
* skills pays nothing);
|
|
12
|
+
* - a description longer than [SKILL_CATALOG_DESCRIPTION_MAX] is truncated
|
|
13
|
+
* (W879);
|
|
14
|
+
* - at most [SKILL_CATALOG_MAX] entries, name-sorted (the listing already is),
|
|
15
|
+
* with the omitted count stated so a truncation is never silent.
|
|
16
|
+
*/
|
|
17
|
+
import { readLayers } from "./celestea-sources.js";
|
|
18
|
+
import { listSkills, nodeSkillIo } from "./skills.js";
|
|
19
|
+
/** Maximum number of skills the injected catalog lists. */
|
|
20
|
+
export const SKILL_CATALOG_MAX = 32;
|
|
21
|
+
/** Maximum description length inside the catalog, in characters (W879). */
|
|
22
|
+
export const SKILL_CATALOG_DESCRIPTION_MAX = 200;
|
|
23
|
+
/** The catalog header: how to load a body, and what is deliberately absent. */
|
|
24
|
+
const CATALOG_HEADER = "Skills available in this workspace (call the load_skill tool with a name to load one's full instructions before doing a task it covers; a skill's own files — references/, scripts/ — are NOT inlined, read them yourself with read_file):";
|
|
25
|
+
/** Truncate on a code-point boundary and mark the cut. */
|
|
26
|
+
function clip(text) {
|
|
27
|
+
const chars = [...text];
|
|
28
|
+
if (chars.length <= SKILL_CATALOG_DESCRIPTION_MAX)
|
|
29
|
+
return text;
|
|
30
|
+
return chars.slice(0, SKILL_CATALOG_DESCRIPTION_MAX).join("") + "…";
|
|
31
|
+
}
|
|
32
|
+
function catalogLine(skill) {
|
|
33
|
+
return "- " + skill.name + ": " + clip(skill.description);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Render the catalog text of one discovery listing. PURE. Returns `null` when
|
|
37
|
+
* there is nothing to announce, so the caller injects NOTHING (zero cost).
|
|
38
|
+
*/
|
|
39
|
+
export function renderSkillCatalog(listing) {
|
|
40
|
+
if (listing.skills.length === 0)
|
|
41
|
+
return null;
|
|
42
|
+
const shown = listing.skills.slice(0, SKILL_CATALOG_MAX);
|
|
43
|
+
const lines = [CATALOG_HEADER, ...shown.map(catalogLine)];
|
|
44
|
+
const omitted = listing.skills.length - shown.length;
|
|
45
|
+
if (omitted > 0)
|
|
46
|
+
lines.push("- (+" + omitted + " more skills not listed; the listing is name-sorted)");
|
|
47
|
+
return lines.join("\n");
|
|
48
|
+
}
|
|
49
|
+
/** Discover the two layers of `wsPath` and render their catalog (or null). */
|
|
50
|
+
export function skillCatalogOf(wsPath, input = {}, io = nodeSkillIo) {
|
|
51
|
+
return renderSkillCatalog(listSkills(readLayers(wsPath, input), io));
|
|
52
|
+
}
|