@giovannijecha/jecode 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,344 @@
1
+ // Strict codecs for session files. Disk is an untrusted boundary even when
2
+ // the directory is owner-only: every value is bounded and re-owned before it
3
+ // can become conversation or provider input.
4
+ export const SESSION_SCHEMA = 1;
5
+ export const SESSION_FILE_LIMITS = Object.freeze({
6
+ text: 1_048_576,
7
+ jsonDepth: 24,
8
+ jsonNodes: 32_768,
9
+ blocks: 8_192,
10
+ details: 8_192,
11
+ });
12
+ export function encodeMeta(meta) {
13
+ return line(meta);
14
+ }
15
+ export function decodeMeta(value) {
16
+ if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
17
+ throw invalid();
18
+ }
19
+ if (value["version"] !== SESSION_SCHEMA ||
20
+ !identifier(value["id"]) ||
21
+ !bounded(value["workspaceRoot"], 32_768) ||
22
+ !digest(value["workspaceDigest"]) ||
23
+ !timestamp(value["createdAt"]))
24
+ throw invalid();
25
+ return Object.freeze({
26
+ version: 1,
27
+ id: value["id"],
28
+ workspaceRoot: value["workspaceRoot"],
29
+ workspaceDigest: value["workspaceDigest"],
30
+ createdAt: value["createdAt"],
31
+ });
32
+ }
33
+ export function encodeHead(head) {
34
+ return line(head);
35
+ }
36
+ export function decodeHead(value) {
37
+ if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
38
+ throw invalid();
39
+ }
40
+ if (value["version"] !== SESSION_SCHEMA ||
41
+ !integer(value["sequence"], 0) ||
42
+ !integer(value["nodeId"], 1) ||
43
+ !integer(value["parentId"], 0) || value["parentId"] >= value["nodeId"] ||
44
+ !integer(value["revision"], 1) ||
45
+ !timestamp(value["updatedAt"]))
46
+ throw invalid();
47
+ return Object.freeze({
48
+ version: 1,
49
+ sequence: value["sequence"],
50
+ nodeId: value["nodeId"],
51
+ parentId: value["parentId"],
52
+ revision: value["revision"],
53
+ updatedAt: value["updatedAt"],
54
+ });
55
+ }
56
+ export function encodeNode(node, sequence, updatedAt) {
57
+ return line({
58
+ version: SESSION_SCHEMA,
59
+ sequence,
60
+ updatedAt,
61
+ node: {
62
+ id: node.id,
63
+ parentId: node.parentId,
64
+ revision: node.revision,
65
+ createdAt: node.createdAt,
66
+ settlement: node.settlement,
67
+ identity: node.identity,
68
+ messages: node.messages.map(messageRecord),
69
+ blocks: node.blocks.flatMap(blockRecord),
70
+ },
71
+ });
72
+ }
73
+ export function decodeNode(value) {
74
+ if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
75
+ throw invalid();
76
+ if (value["version"] !== SESSION_SCHEMA || !integer(value["sequence"], 1) ||
77
+ !timestamp(value["updatedAt"]))
78
+ throw invalid();
79
+ const raw = value["node"];
80
+ if (!record(raw) || !keys(raw, "blocks,createdAt,id,identity,messages,parentId,revision,settlement")) {
81
+ throw invalid();
82
+ }
83
+ const identity = raw["identity"];
84
+ const messages = raw["messages"];
85
+ const blocks = raw["blocks"];
86
+ if (!integer(raw["id"], 1) ||
87
+ !integer(raw["parentId"], 0) ||
88
+ !integer(raw["revision"], 1) ||
89
+ !timestamp(raw["createdAt"]) ||
90
+ (raw["settlement"] !== "checkpointed" && raw["settlement"] !== "completed") ||
91
+ !record(identity) || !keys(identity, "effort,model,providerId") ||
92
+ !bounded(identity["providerId"], 128) || !bounded(identity["model"], 512) ||
93
+ !bounded(identity["effort"], 32) ||
94
+ !Array.isArray(messages) || messages.length > SESSION_FILE_LIMITS.blocks ||
95
+ !Array.isArray(blocks) || blocks.length > SESSION_FILE_LIMITS.blocks)
96
+ throw invalid();
97
+ const node = Object.freeze({
98
+ id: raw["id"],
99
+ parentId: raw["parentId"],
100
+ revision: raw["revision"],
101
+ createdAt: raw["createdAt"],
102
+ settlement: raw["settlement"],
103
+ identity: Object.freeze({
104
+ providerId: identity["providerId"],
105
+ model: identity["model"],
106
+ effort: identity["effort"],
107
+ }),
108
+ messages: Object.freeze(messages.map(messageFromRecord)),
109
+ blocks: Object.freeze(blocks.map(blockFromRecord)),
110
+ });
111
+ return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
112
+ }
113
+ function messageRecord(message) {
114
+ return {
115
+ role: message.role,
116
+ content: message.content.map(contentRecord),
117
+ usage: message.usage ?? null,
118
+ };
119
+ }
120
+ function messageFromRecord(value) {
121
+ if (!record(value) || !keys(value, "content,role,usage"))
122
+ throw invalid();
123
+ if ((value["role"] !== "user" && value["role"] !== "assistant") ||
124
+ !Array.isArray(value["content"]) || value["content"].length > SESSION_FILE_LIMITS.blocks)
125
+ throw invalid();
126
+ const usage = value["usage"] === null ? undefined : usageFromRecord(value["usage"]);
127
+ return {
128
+ role: value["role"],
129
+ content: value["content"].map(contentFromRecord),
130
+ ...(usage === undefined ? {} : { usage }),
131
+ };
132
+ }
133
+ function contentRecord(block) {
134
+ if (block.kind === "text")
135
+ return { kind: block.kind, text: block.text };
136
+ if (block.kind === "tool_call") {
137
+ return { kind: block.kind, id: block.id, name: block.name, input: block.input };
138
+ }
139
+ return {
140
+ kind: block.kind,
141
+ id: block.id,
142
+ output: block.output,
143
+ isError: block.isError,
144
+ };
145
+ }
146
+ function contentFromRecord(value) {
147
+ if (!record(value) || typeof value["kind"] !== "string")
148
+ throw invalid();
149
+ if (value["kind"] === "text" && keys(value, "kind,text") && boundedText(value["text"])) {
150
+ return { kind: "text", text: value["text"] };
151
+ }
152
+ if (value["kind"] === "tool_call" && keys(value, "id,input,kind,name") &&
153
+ bounded(value["id"], 512) && bounded(value["name"], 256)) {
154
+ const input = jsonObject(value["input"]);
155
+ return { kind: "tool_call", id: value["id"], name: value["name"], input };
156
+ }
157
+ if (value["kind"] === "tool_result" && keys(value, "id,isError,kind,output") &&
158
+ bounded(value["id"], 512) && boundedText(value["output"]) &&
159
+ typeof value["isError"] === "boolean") {
160
+ return {
161
+ kind: "tool_result",
162
+ id: value["id"],
163
+ output: value["output"],
164
+ isError: value["isError"],
165
+ };
166
+ }
167
+ throw invalid();
168
+ }
169
+ function usageFromRecord(value) {
170
+ if (!record(value) || !keys(value, "cacheWriteInputTokens,cachedInputTokens,inputTokens,outputTokens,reasoningTokens"))
171
+ throw invalid();
172
+ const inputTokens = value["inputTokens"];
173
+ const outputTokens = value["outputTokens"];
174
+ const cachedInputTokens = value["cachedInputTokens"];
175
+ const cacheWriteInputTokens = value["cacheWriteInputTokens"];
176
+ const reasoningTokens = value["reasoningTokens"];
177
+ if (!integer(inputTokens, 0) || !integer(outputTokens, 0) ||
178
+ !integer(cachedInputTokens, 0) || !integer(cacheWriteInputTokens, 0) ||
179
+ !integer(reasoningTokens, 0))
180
+ throw invalid();
181
+ return {
182
+ inputTokens,
183
+ outputTokens,
184
+ cachedInputTokens,
185
+ cacheWriteInputTokens,
186
+ reasoningTokens,
187
+ };
188
+ }
189
+ function blockRecord(block) {
190
+ if (block.kind === "notice")
191
+ return [];
192
+ if (block.kind === "user" || block.kind === "answer" || block.kind === "reasoning") {
193
+ return [{ kind: block.kind, text: block.text }];
194
+ }
195
+ if (block.tone === "pending")
196
+ return [];
197
+ return [{
198
+ kind: block.kind,
199
+ name: block.name,
200
+ target: block.target,
201
+ right: block.right,
202
+ tone: block.tone,
203
+ body: block.body?.map(detailRecord) ?? null,
204
+ }];
205
+ }
206
+ function blockFromRecord(value) {
207
+ if (!record(value) || typeof value["kind"] !== "string")
208
+ throw invalid();
209
+ if ((value["kind"] === "user" || value["kind"] === "answer" || value["kind"] === "reasoning") &&
210
+ keys(value, "kind,text") && boundedText(value["text"]))
211
+ return { kind: value["kind"], text: value["text"] };
212
+ if (value["kind"] === "tool" && keys(value, "body,kind,name,right,target,tone") &&
213
+ bounded(value["name"], 256) && boundedText(value["target"]) &&
214
+ boundedText(value["right"], 1_024) &&
215
+ (value["tone"] === "ok" || value["tone"] === "fail" || value["tone"] === "deny") &&
216
+ (value["body"] === null ||
217
+ (Array.isArray(value["body"]) && value["body"].length <= SESSION_FILE_LIMITS.details))) {
218
+ const body = value["body"] === null
219
+ ? undefined
220
+ : value["body"].map(detailFromRecord);
221
+ return {
222
+ kind: "tool",
223
+ name: value["name"],
224
+ target: value["target"],
225
+ right: value["right"],
226
+ tone: value["tone"],
227
+ ...(body === undefined ? {} : { body }),
228
+ };
229
+ }
230
+ throw invalid();
231
+ }
232
+ function detailRecord(detail) {
233
+ if (detail.kind === "out" || detail.kind === "gap")
234
+ return { kind: detail.kind, text: detail.text };
235
+ return {
236
+ kind: detail.kind,
237
+ text: detail.text,
238
+ oldLine: detail.oldLine ?? null,
239
+ newLine: detail.newLine ?? null,
240
+ emphasis: detail.emphasis ?? null,
241
+ };
242
+ }
243
+ function detailFromRecord(value) {
244
+ if (!record(value) || typeof value["kind"] !== "string")
245
+ throw invalid();
246
+ if ((value["kind"] === "out" || value["kind"] === "gap") &&
247
+ keys(value, "kind,text") && boundedText(value["text"]))
248
+ return { kind: value["kind"], text: value["text"] };
249
+ if ((value["kind"] === "keep" || value["kind"] === "add" || value["kind"] === "del") &&
250
+ keys(value, "emphasis,kind,newLine,oldLine,text") && boundedText(value["text"]) &&
251
+ nullableInteger(value["oldLine"], 1) && nullableInteger(value["newLine"], 1)) {
252
+ const emphasis = emphasisFromRecord(value["emphasis"]);
253
+ return {
254
+ kind: value["kind"],
255
+ text: value["text"],
256
+ ...(value["oldLine"] === null ? {} : { oldLine: value["oldLine"] }),
257
+ ...(value["newLine"] === null ? {} : { newLine: value["newLine"] }),
258
+ ...(emphasis === undefined ? {} : { emphasis }),
259
+ };
260
+ }
261
+ throw invalid();
262
+ }
263
+ function emphasisFromRecord(value) {
264
+ if (value === null)
265
+ return undefined;
266
+ if (!record(value) || !keys(value, "length,start"))
267
+ throw invalid();
268
+ if (!integer(value["start"], 0) || !integer(value["length"], 1))
269
+ throw invalid();
270
+ return { start: value["start"], length: value["length"] };
271
+ }
272
+ function jsonObject(value) {
273
+ const budget = { nodes: 0 };
274
+ const safe = jsonValue(value, budget, 0);
275
+ if (!record(safe))
276
+ throw invalid();
277
+ return safe;
278
+ }
279
+ function jsonValue(value, budget, depth) {
280
+ budget.nodes++;
281
+ if (budget.nodes > SESSION_FILE_LIMITS.jsonNodes || depth > SESSION_FILE_LIMITS.jsonDepth) {
282
+ throw invalid();
283
+ }
284
+ if (value === null || typeof value === "boolean")
285
+ return value;
286
+ if (typeof value === "string") {
287
+ if (!boundedText(value))
288
+ throw invalid();
289
+ return value;
290
+ }
291
+ if (typeof value === "number") {
292
+ if (!Number.isFinite(value))
293
+ throw invalid();
294
+ return value;
295
+ }
296
+ if (Array.isArray(value)) {
297
+ if (value.length > SESSION_FILE_LIMITS.blocks)
298
+ throw invalid();
299
+ return value.map((item) => jsonValue(item, budget, depth + 1));
300
+ }
301
+ if (!record(value) || Object.keys(value).length > SESSION_FILE_LIMITS.blocks)
302
+ throw invalid();
303
+ const safe = {};
304
+ for (const [name, child] of Object.entries(value)) {
305
+ if (!boundedText(name, 1_024))
306
+ throw invalid();
307
+ safe[name] = jsonValue(child, budget, depth + 1);
308
+ }
309
+ return safe;
310
+ }
311
+ function line(value) {
312
+ return `${JSON.stringify(value, null, 2)}\n`;
313
+ }
314
+ function keys(value, expected) {
315
+ return Object.keys(value).sort().join(",") === expected;
316
+ }
317
+ function record(value) {
318
+ return typeof value === "object" && value !== null && !Array.isArray(value);
319
+ }
320
+ function bounded(value, limit = SESSION_FILE_LIMITS.text) {
321
+ return typeof value === "string" && value.length > 0 && value.length <= limit;
322
+ }
323
+ function boundedText(value, limit = SESSION_FILE_LIMITS.text) {
324
+ return typeof value === "string" && value.length <= limit;
325
+ }
326
+ function integer(value, minimum) {
327
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
328
+ }
329
+ function nullableInteger(value, minimum) {
330
+ return value === null || integer(value, minimum);
331
+ }
332
+ function identifier(value) {
333
+ return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
334
+ }
335
+ function digest(value) {
336
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
337
+ }
338
+ function timestamp(value) {
339
+ return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
340
+ Number.isFinite(Date.parse(value));
341
+ }
342
+ function invalid() {
343
+ return new Error("session data is invalid or unsupported");
344
+ }
@@ -0,0 +1,76 @@
1
+ // A tiny, bounded process lease for one durable conversation.
2
+ import { randomUUID } from "node:crypto";
3
+ import { lstat, open, unlink } from "node:fs/promises";
4
+ const MAX_LEASE_BYTES = 256;
5
+ export function leaseToken() {
6
+ return `${process.pid}:${randomUUID()}`;
7
+ }
8
+ export function sessionLease(id, file, token) {
9
+ let closed = false;
10
+ return Object.freeze({
11
+ id,
12
+ close: async () => {
13
+ if (closed)
14
+ return;
15
+ closed = true;
16
+ try {
17
+ await removeLease(file, token);
18
+ }
19
+ catch {
20
+ // A recovered, replaced, or already-closed lease is no longer ours.
21
+ }
22
+ },
23
+ });
24
+ }
25
+ export async function leaseOwner(file) {
26
+ try {
27
+ const token = await readLease(file);
28
+ const match = /^([1-9]\d*):/.exec(token.trim());
29
+ if (match === null)
30
+ return { pid: 0, token };
31
+ const pid = Number(match[1]);
32
+ return { pid: Number.isSafeInteger(pid) ? pid : 0, token };
33
+ }
34
+ catch (error) {
35
+ if (error.code === "ENOENT")
36
+ return undefined;
37
+ throw error;
38
+ }
39
+ }
40
+ export async function removeLease(file, token) {
41
+ const current = await readLease(file).catch(() => undefined);
42
+ if (current === token)
43
+ await unlink(file).catch(() => undefined);
44
+ }
45
+ export function pidIsAlive(pid) {
46
+ if (!Number.isSafeInteger(pid) || pid < 1 || pid > 0x7fff_ffff)
47
+ return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ }
52
+ catch (error) {
53
+ return error.code !== "ESRCH";
54
+ }
55
+ }
56
+ async function readLease(file) {
57
+ const before = await lstat(file);
58
+ if (before.isSymbolicLink() || !before.isFile() || before.size > MAX_LEASE_BYTES) {
59
+ throw new Error("session lease is unsafe or too large");
60
+ }
61
+ const handle = await open(file, "r");
62
+ try {
63
+ const opened = await handle.stat();
64
+ if (!opened.isFile() || opened.size > MAX_LEASE_BYTES ||
65
+ opened.dev !== before.dev || opened.ino !== before.ino)
66
+ throw new Error("session lease changed while opening");
67
+ const bytes = Buffer.alloc(MAX_LEASE_BYTES + 1);
68
+ const { bytesRead } = await handle.read(bytes, 0, bytes.length, 0);
69
+ if (bytesRead > MAX_LEASE_BYTES)
70
+ throw new Error("session lease is too large");
71
+ return bytes.subarray(0, bytesRead).toString("utf8");
72
+ }
73
+ finally {
74
+ await handle.close();
75
+ }
76
+ }
@@ -0,0 +1,73 @@
1
+ // One process-local owner for durable session state.
2
+ //
3
+ // One logical conversation keeps one stable session id across every resume.
4
+ // New turns advance that session's tree head; /new is the boundary that starts
5
+ // another durable session.
6
+ import { DurableSessionStore } from "./store.js";
7
+ export class SessionPersistence {
8
+ #store;
9
+ #sessionId;
10
+ #lease;
11
+ #failure;
12
+ constructor(store, sessionId, lease) {
13
+ this.#store = store;
14
+ this.#sessionId = sessionId;
15
+ this.#lease = lease;
16
+ }
17
+ static fresh(store) {
18
+ return new SessionPersistence(store, null);
19
+ }
20
+ static async resume(store, id) {
21
+ const lease = await store.claim(id);
22
+ try {
23
+ const snapshot = await store.load(id);
24
+ const conversation = snapshot.conversation.latestCompleted();
25
+ if (conversation === undefined)
26
+ throw new Error("session has no completed turn to resume");
27
+ return Object.freeze({
28
+ conversation,
29
+ persistence: new SessionPersistence(store, id, lease),
30
+ });
31
+ }
32
+ catch (error) {
33
+ await lease.close();
34
+ throw error;
35
+ }
36
+ }
37
+ static async candidates(store) {
38
+ return (await store.list()).filter((entry) => !entry.active);
39
+ }
40
+ get failure() {
41
+ return this.#failure;
42
+ }
43
+ get sessionId() {
44
+ return this.#sessionId;
45
+ }
46
+ async checkpoint(conversation) {
47
+ if (this.#failure !== undefined)
48
+ throw this.#failure;
49
+ try {
50
+ if (this.#sessionId === null) {
51
+ const published = await this.#store.publish(conversation, true);
52
+ this.#lease = published.lease;
53
+ this.#sessionId = published.meta.id;
54
+ return;
55
+ }
56
+ await this.#store.checkpoint(this.#sessionId, conversation);
57
+ }
58
+ catch (error) {
59
+ this.#failure = error;
60
+ throw error;
61
+ }
62
+ }
63
+ async reset() {
64
+ await this.#lease?.close();
65
+ this.#lease = undefined;
66
+ this.#sessionId = null;
67
+ this.#failure = undefined;
68
+ }
69
+ async close() {
70
+ await this.#lease?.close();
71
+ this.#lease = undefined;
72
+ }
73
+ }