@giovannijecha/jecode 0.8.4 → 0.8.6

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.
Files changed (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -10,6 +10,9 @@ export async function* readSseJson(body, maximumChars, idle) {
10
10
  const parser = new SseEventParser();
11
11
  let finished = false;
12
12
  let total = 0;
13
+ const progressDeadline = idle?.progress === undefined
14
+ ? undefined
15
+ : eventDeadline(idle.progress);
13
16
  try {
14
17
  let ended = false;
15
18
  while (!ended) {
@@ -20,7 +23,11 @@ export async function* readSseJson(body, maximumChars, idle) {
20
23
  const payloads = [];
21
24
  try {
22
25
  while (payloads.length === 0 && !ended) {
23
- const { done, value } = await deadline.wait(reader.read());
26
+ const read = reader.read();
27
+ const pending = progressDeadline === undefined
28
+ ? read
29
+ : progressDeadline.wait(read);
30
+ const { done, value } = await deadline.wait(pending);
24
31
  if (done) {
25
32
  ended = true;
26
33
  const text = decoder.decode();
@@ -39,29 +46,66 @@ export async function* readSseJson(body, maximumChars, idle) {
39
46
  finally {
40
47
  deadline.clear();
41
48
  }
42
- for (const payload of payloads)
49
+ for (const payload of payloads) {
50
+ if (idle?.progress?.observed(payload) === true)
51
+ progressDeadline?.reset();
43
52
  yield payload;
53
+ }
44
54
  }
45
55
  finished = true;
46
56
  }
47
57
  finally {
58
+ progressDeadline?.clear();
48
59
  if (!finished)
49
60
  await reader.cancel().catch(() => undefined);
50
61
  reader.releaseLock();
51
62
  }
52
63
  }
53
64
  function eventDeadline(idle) {
54
- if (idle === undefined)
55
- return { wait: (pending) => pending, clear: () => undefined };
65
+ if (idle === undefined) {
66
+ return {
67
+ wait: (pending) => pending,
68
+ reset: () => undefined,
69
+ clear: () => undefined,
70
+ };
71
+ }
56
72
  let timer;
57
- const expired = new Promise((_resolve, reject) => {
58
- timer = setTimeout(() => reject(idle.error()), idle.milliseconds);
59
- });
73
+ let expired;
74
+ let rejectWait;
75
+ const arm = () => {
76
+ if (timer !== undefined)
77
+ clearTimeout(timer);
78
+ expired = undefined;
79
+ timer = setTimeout(() => {
80
+ expired = idle.error();
81
+ const reject = rejectWait;
82
+ rejectWait = undefined;
83
+ reject?.(expired);
84
+ }, idle.milliseconds);
85
+ };
86
+ arm();
60
87
  return {
61
- wait: (pending) => Promise.race([pending, expired]),
88
+ wait: (pending) => {
89
+ if (expired !== undefined)
90
+ return Promise.reject(expired);
91
+ return new Promise((resolve, reject) => {
92
+ rejectWait = reject;
93
+ pending.then((value) => {
94
+ if (rejectWait === reject)
95
+ rejectWait = undefined;
96
+ resolve(value);
97
+ }, (error) => {
98
+ if (rejectWait === reject)
99
+ rejectWait = undefined;
100
+ reject(error);
101
+ });
102
+ });
103
+ },
104
+ reset: arm,
62
105
  clear: () => {
63
106
  if (timer !== undefined)
64
107
  clearTimeout(timer);
108
+ rejectWait = undefined;
65
109
  },
66
110
  };
67
111
  }
@@ -0,0 +1,32 @@
1
+ // Stable provider-facing conversation identity. It is routing metadata only,
2
+ // never authorization, persistence authority, or a user-visible identifier.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ const ephemeralSeeds = new WeakMap();
5
+ export function requestIdentityForSession(session) {
6
+ let conversation = session.persistence?.conversationId;
7
+ if (conversation === undefined) {
8
+ conversation = ephemeralSeeds.get(session);
9
+ if (conversation === undefined) {
10
+ conversation = randomUUID();
11
+ ephemeralSeeds.set(session, conversation);
12
+ }
13
+ }
14
+ return identityFromSeed(`${session.provider.id}\0${conversation}`);
15
+ }
16
+ export function resetRequestIdentity(session) {
17
+ ephemeralSeeds.delete(session);
18
+ }
19
+ function identityFromSeed(seed) {
20
+ const digest = createHash("sha256").update(seed).digest("hex");
21
+ const conversationId = [
22
+ digest.slice(0, 8),
23
+ digest.slice(8, 12),
24
+ `5${digest.slice(13, 16)}`,
25
+ `${variant(digest[16])}${digest.slice(17, 20)}`,
26
+ digest.slice(20, 32),
27
+ ].join("-");
28
+ return Object.freeze({ conversationId, cacheKey: `jecode-${digest.slice(0, 32)}` });
29
+ }
30
+ function variant(value) {
31
+ return ((Number.parseInt(value, 16) & 0x3) | 0x8).toString(16);
32
+ }
@@ -0,0 +1,55 @@
1
+ // Stable workspace identity and directory anchors shared by session operations.
2
+ import { createHash } from "node:crypto";
3
+ import { realpath } from "node:fs/promises";
4
+ import * as path from "node:path";
5
+ import { assertDirectoryAnchor, captureDirectDirectory, preparePrivateDirectory, } from "../directory-anchor.js";
6
+ import { DIRECTORY_MODE, SESSION_NAME } from "./files.js";
7
+ export class SessionBucket {
8
+ workspaceRoot;
9
+ workspaceDigest;
10
+ anchor;
11
+ #root;
12
+ constructor(workspaceRoot, root, anchor) {
13
+ this.workspaceRoot = workspaceRoot;
14
+ this.workspaceDigest = digestWorkspace(workspaceRoot);
15
+ this.#root = root;
16
+ this.anchor = anchor;
17
+ Object.freeze(this);
18
+ }
19
+ static async open(workspaceRoot, sessionsRoot) {
20
+ const canonical = await realpath(path.resolve(workspaceRoot));
21
+ const digest = digestWorkspace(canonical);
22
+ const root = await preparePrivateDirectory(path.resolve(sessionsRoot), "session storage root", DIRECTORY_MODE);
23
+ const anchor = await preparePrivateDirectory(path.join(root.path, digest), "workspace session directory", DIRECTORY_MODE);
24
+ return new SessionBucket(canonical, root, anchor);
25
+ }
26
+ directory(id) {
27
+ return path.join(this.anchor.path, id);
28
+ }
29
+ async captureSession(id) {
30
+ await this.assert();
31
+ return captureDirectDirectory(this.directory(id), "session directory");
32
+ }
33
+ async assertSession(anchor) {
34
+ await Promise.all([this.assert(), assertDirectoryAnchor(anchor)]);
35
+ }
36
+ async assert() {
37
+ await Promise.all([assertDirectoryAnchor(this.#root), assertDirectoryAnchor(this.anchor)]);
38
+ }
39
+ assertWorkspace(meta, id) {
40
+ if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
41
+ workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
42
+ throw new Error("session belongs to a different workspace");
43
+ }
44
+ }
45
+ function digestWorkspace(workspaceRoot) {
46
+ return createHash("sha256").update(workspaceKey(workspaceRoot)).digest("hex");
47
+ }
48
+ export function workspaceKey(workspaceRoot) {
49
+ const normalized = path.normalize(workspaceRoot);
50
+ return process.platform === "win32" ? normalized.toLocaleLowerCase("en-US") : normalized;
51
+ }
52
+ export function assertSessionId(id) {
53
+ if (!SESSION_NAME.test(id))
54
+ throw new Error("session id is invalid");
55
+ }
@@ -0,0 +1,162 @@
1
+ // Head-tied resume listing and repairable catalogue-file IO.
2
+ import * as path from "node:path";
3
+ import { atomicWrite } from "../atomic.js";
4
+ import { assertDirectoryAnchor } from "../directory-anchor.js";
5
+ import { readStableDirectory } from "../stable-directory.js";
6
+ import { catalogMatches, decodeSessionCatalog, encodeSessionCatalog, sameSessionHead, SESSION_CATALOG_BYTES, SESSION_CATALOG_FILE, SESSION_CHECKPOINT_FILE, } from "./catalog.js";
7
+ import { decodeHead, decodeMeta, SESSION_FILE_LIMITS } from "./codec.js";
8
+ import { FILE_MODE, readJson, SESSION_NAME } from "./files.js";
9
+ import { leaseOwner, pidIsAlive, removeLease } from "./lease.js";
10
+ const MAX_CATALOG_ENTRIES = 4_096;
11
+ const CATALOG_READ_CONCURRENCY = 8;
12
+ export class SessionCatalogIO {
13
+ #bucket;
14
+ #reader;
15
+ constructor(bucket, reader) {
16
+ this.#bucket = bucket;
17
+ this.#reader = reader;
18
+ }
19
+ async list(limit = 32) {
20
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
21
+ throw new Error("session catalogue limit is invalid");
22
+ }
23
+ await this.#bucket.assert();
24
+ const names = await catalogNames(this.#bucket.anchor);
25
+ const catalog = [];
26
+ for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
27
+ const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
28
+ .map(async (id) => {
29
+ return await this.#catalogEntry(id);
30
+ }));
31
+ catalog.push(...batch.filter((entry) => entry !== undefined));
32
+ }
33
+ return catalog
34
+ .sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
35
+ .slice(0, limit);
36
+ }
37
+ async write(id, catalog, checkpointToken) {
38
+ const directory = this.#bucket.directory(id);
39
+ const directoryAnchor = await this.#bucket.captureSession(id);
40
+ const validateDirectory = async () => {
41
+ await this.#bucket.assertSession(directoryAnchor);
42
+ };
43
+ const validate = async () => {
44
+ await validateDirectory();
45
+ const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
46
+ if (!sameSessionHead(currentHead, catalog.head)) {
47
+ throw new Error("session head changed while updating its catalogue");
48
+ }
49
+ };
50
+ await atomicWrite(path.join(directory, SESSION_CATALOG_FILE), encodeSessionCatalog(catalog), { mode: FILE_MODE, validate });
51
+ if (checkpointToken !== undefined) {
52
+ await validateDirectory();
53
+ await removeLease(path.join(directory, SESSION_CHECKPOINT_FILE), checkpointToken);
54
+ await validateDirectory();
55
+ }
56
+ }
57
+ async #leaseIsActive(id, directory) {
58
+ if (directory !== undefined)
59
+ await this.#bucket.assertSession(directory);
60
+ else
61
+ await this.#bucket.assert();
62
+ const owner = await leaseOwner(path.join(this.#bucket.directory(id), "active"));
63
+ if (directory !== undefined)
64
+ await this.#bucket.assertSession(directory);
65
+ return owner !== undefined && pidIsAlive(owner.pid);
66
+ }
67
+ async #catalogEntry(id) {
68
+ try {
69
+ const directory = this.#bucket.directory(id);
70
+ const directoryAnchor = await this.#bucket.captureSession(id);
71
+ const validateDirectory = async () => {
72
+ await this.#bucket.assertSession(directoryAnchor);
73
+ };
74
+ const checkpointFile = path.join(directory, SESSION_CHECKPOINT_FILE);
75
+ // A second head read closes the only useful race: a checkpoint landing
76
+ // between the small record reads. A changing marker gets one retry.
77
+ for (let attempt = 0; attempt < 2; attempt++) {
78
+ await validateDirectory();
79
+ const checkpointBefore = await leaseOwner(checkpointFile);
80
+ try {
81
+ const [metaValue, headValue, catalogValue] = await Promise.all([
82
+ readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
83
+ readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory),
84
+ readJson(path.join(directory, SESSION_CATALOG_FILE), SESSION_CATALOG_BYTES, undefined, validateDirectory),
85
+ ]);
86
+ const meta = decodeMeta(metaValue);
87
+ const head = decodeHead(headValue);
88
+ const storedCatalog = decodeSessionCatalog(catalogValue);
89
+ this.#bucket.assertWorkspace(meta, id);
90
+ const confirmedHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes, undefined, validateDirectory));
91
+ await validateDirectory();
92
+ const checkpointAfter = await leaseOwner(checkpointFile);
93
+ if (!sameLease(checkpointBefore, checkpointAfter))
94
+ continue;
95
+ if (!sameSessionHead(head, confirmedHead) ||
96
+ !catalogMatches(storedCatalog, meta, head))
97
+ break;
98
+ if (checkpointAfter !== undefined && !pidIsAlive(checkpointAfter.pid))
99
+ break;
100
+ const active = await this.#leaseIsActive(id, directoryAnchor) ||
101
+ checkpointAfter !== undefined;
102
+ return catalogEntry(storedCatalog, active);
103
+ }
104
+ catch {
105
+ break;
106
+ }
107
+ }
108
+ // Missing, stale, or malformed summaries are rebuilt only while the
109
+ // session is idle. Selecting a session still performs this strict load.
110
+ const checkpoint = await leaseOwner(checkpointFile);
111
+ if (await this.#leaseIsActive(id, directoryAnchor) ||
112
+ (checkpoint !== undefined && pidIsAlive(checkpoint.pid)))
113
+ return undefined;
114
+ const repairLease = await this.#reader.claim(id);
115
+ let snapshot;
116
+ try {
117
+ snapshot = await this.#reader.load(id, repairLease);
118
+ await this.write(id, snapshot.catalog, checkpoint?.legacy === true ? undefined : checkpoint?.token).catch(() => undefined);
119
+ }
120
+ finally {
121
+ await repairLease.close();
122
+ }
123
+ const currentCheckpoint = await leaseOwner(checkpointFile);
124
+ const active = await this.#leaseIsActive(id, directoryAnchor) ||
125
+ (currentCheckpoint !== undefined && pidIsAlive(currentCheckpoint.pid));
126
+ return catalogEntry(snapshot.catalog, active);
127
+ }
128
+ catch {
129
+ // Corrupt, unsafe, active-without-a-summary, or foreign data never
130
+ // becomes a resume candidate.
131
+ return undefined;
132
+ }
133
+ }
134
+ }
135
+ async function catalogNames(directory) {
136
+ await assertDirectoryAnchor(directory);
137
+ const inspected = await readStableDirectory(directory.path, directory.path, {
138
+ maxEntries: MAX_CATALOG_ENTRIES + 1,
139
+ });
140
+ if (inspected.capped || inspected.entries.length > MAX_CATALOG_ENTRIES) {
141
+ throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
142
+ }
143
+ return inspected.entries
144
+ .filter((entry) => entry.kind === "directory" && SESSION_NAME.test(entry.name))
145
+ .map((entry) => entry.name)
146
+ .sort((left, right) => right.localeCompare(left));
147
+ }
148
+ function catalogEntry(catalog, active) {
149
+ if (catalog.resumeNodeId === 0)
150
+ return undefined;
151
+ return Object.freeze({
152
+ id: catalog.id,
153
+ createdAt: catalog.createdAt,
154
+ updatedAt: catalog.head.updatedAt,
155
+ turns: catalog.turns,
156
+ preview: catalog.preview,
157
+ active,
158
+ });
159
+ }
160
+ function sameLease(left, right) {
161
+ return left?.token === right?.token && left?.legacy === right?.legacy;
162
+ }
@@ -11,7 +11,9 @@ import { decodeHead, encodeHead } from "./codec.js";
11
11
  export const SESSION_CATALOG_FILE = "catalog.json";
12
12
  export const SESSION_CHECKPOINT_FILE = ".checkpoint";
13
13
  export const SESSION_CATALOG_BYTES = 4 * 1_024;
14
- const CATALOG_SCHEMA = 1;
14
+ // Older indexes can hide a node committed before a failed head write. Rebuild
15
+ // them through strict loading instead of trusting their apparently current head.
16
+ const CATALOG_SCHEMA = 2;
15
17
  const PREVIEW_CODE_UNITS = 160;
16
18
  export function sessionCatalog(meta, head, conversation) {
17
19
  assertActiveHead(head, conversation);
@@ -0,0 +1,122 @@
1
+ // Normalized provider-message records; opaque provider data never persists.
2
+ import { bounded, boundedText, integer, invalid, keys, record, SESSION_FILE_LIMITS, } from "./codec-values.js";
3
+ export function messageRecord(message) {
4
+ return {
5
+ role: message.role,
6
+ content: message.content.map(contentRecord),
7
+ usage: message.usage ?? null,
8
+ };
9
+ }
10
+ export function messageFromRecord(value) {
11
+ if (!record(value) || !keys(value, "content,role,usage"))
12
+ throw invalid();
13
+ if ((value["role"] !== "user" && value["role"] !== "assistant") ||
14
+ !Array.isArray(value["content"]) || value["content"].length > SESSION_FILE_LIMITS.blocks)
15
+ throw invalid();
16
+ const usage = value["usage"] === null ? undefined : usageFromRecord(value["usage"]);
17
+ return {
18
+ role: value["role"],
19
+ content: value["content"].map(contentFromRecord),
20
+ ...(usage === undefined ? {} : { usage }),
21
+ };
22
+ }
23
+ function contentRecord(block) {
24
+ if (block.kind === "text")
25
+ return { kind: block.kind, text: block.text };
26
+ if (block.kind === "tool_call") {
27
+ return { kind: block.kind, id: block.id, name: block.name, input: block.input };
28
+ }
29
+ return {
30
+ kind: block.kind,
31
+ id: block.id,
32
+ output: block.output,
33
+ isError: block.isError,
34
+ };
35
+ }
36
+ function contentFromRecord(value) {
37
+ if (!record(value) || typeof value["kind"] !== "string")
38
+ throw invalid();
39
+ if (value["kind"] === "text" && keys(value, "kind,text") && boundedText(value["text"])) {
40
+ return { kind: "text", text: value["text"] };
41
+ }
42
+ if (value["kind"] === "tool_call" && keys(value, "id,input,kind,name") &&
43
+ bounded(value["id"], 512) && bounded(value["name"], 256)) {
44
+ const input = jsonObject(value["input"]);
45
+ return { kind: "tool_call", id: value["id"], name: value["name"], input };
46
+ }
47
+ if (value["kind"] === "tool_result" && keys(value, "id,isError,kind,output") &&
48
+ bounded(value["id"], 512) && boundedText(value["output"]) &&
49
+ typeof value["isError"] === "boolean") {
50
+ return {
51
+ kind: "tool_result",
52
+ id: value["id"],
53
+ output: value["output"],
54
+ isError: value["isError"],
55
+ };
56
+ }
57
+ throw invalid();
58
+ }
59
+ function usageFromRecord(value) {
60
+ if (!record(value) || !keys(value, "cacheWriteInputTokens,cachedInputTokens,inputTokens,outputTokens,reasoningTokens"))
61
+ throw invalid();
62
+ const inputTokens = value["inputTokens"];
63
+ const outputTokens = value["outputTokens"];
64
+ const cachedInputTokens = value["cachedInputTokens"];
65
+ const cacheWriteInputTokens = value["cacheWriteInputTokens"];
66
+ const reasoningTokens = value["reasoningTokens"];
67
+ if (!integer(inputTokens, 0) || !integer(outputTokens, 0) ||
68
+ !integer(cachedInputTokens, 0) || !integer(cacheWriteInputTokens, 0) ||
69
+ !integer(reasoningTokens, 0))
70
+ throw invalid();
71
+ return {
72
+ inputTokens,
73
+ outputTokens,
74
+ cachedInputTokens,
75
+ cacheWriteInputTokens,
76
+ reasoningTokens,
77
+ };
78
+ }
79
+ function jsonObject(value) {
80
+ const budget = { nodes: 0 };
81
+ const safe = jsonValue(value, budget, 0);
82
+ if (!record(safe))
83
+ throw invalid();
84
+ return safe;
85
+ }
86
+ function jsonValue(value, budget, depth) {
87
+ budget.nodes++;
88
+ if (budget.nodes > SESSION_FILE_LIMITS.jsonNodes || depth > SESSION_FILE_LIMITS.jsonDepth) {
89
+ throw invalid();
90
+ }
91
+ if (value === null || typeof value === "boolean")
92
+ return value;
93
+ if (typeof value === "string") {
94
+ if (!boundedText(value))
95
+ throw invalid();
96
+ return value;
97
+ }
98
+ if (typeof value === "number") {
99
+ if (!Number.isFinite(value))
100
+ throw invalid();
101
+ return value;
102
+ }
103
+ if (Array.isArray(value)) {
104
+ if (value.length > SESSION_FILE_LIMITS.blocks)
105
+ throw invalid();
106
+ return value.map((item) => jsonValue(item, budget, depth + 1));
107
+ }
108
+ if (!record(value) || Object.keys(value).length > SESSION_FILE_LIMITS.blocks)
109
+ throw invalid();
110
+ const safe = {};
111
+ for (const [name, child] of Object.entries(value)) {
112
+ if (!boundedText(name, 1_024))
113
+ throw invalid();
114
+ Object.defineProperty(safe, name, {
115
+ value: jsonValue(child, budget, depth + 1),
116
+ enumerable: true,
117
+ configurable: true,
118
+ writable: true,
119
+ });
120
+ }
121
+ return safe;
122
+ }
@@ -0,0 +1,93 @@
1
+ // Settled transcript evidence across supported session schema versions.
2
+ import { bounded, boundedText, integer, invalid, keys, nullableInteger, record, SESSION_FILE_LIMITS, } from "./codec-values.js";
3
+ export function blockRecord(block) {
4
+ if (block.kind === "notice")
5
+ return [];
6
+ if (block.kind === "user" || block.kind === "answer" || block.kind === "reasoning") {
7
+ return [{ kind: block.kind, text: block.text }];
8
+ }
9
+ if (block.tone === "pending")
10
+ return [];
11
+ return [{
12
+ kind: block.kind,
13
+ name: block.name,
14
+ target: block.target,
15
+ right: block.right,
16
+ tone: block.tone,
17
+ body: block.body?.map(detailRecord) ?? null,
18
+ durationMs: block.durationMs ?? null,
19
+ }];
20
+ }
21
+ export function blockFromRecord(value, version) {
22
+ if (!record(value) || typeof value["kind"] !== "string")
23
+ throw invalid();
24
+ if ((value["kind"] === "user" || value["kind"] === "answer" || value["kind"] === "reasoning") &&
25
+ keys(value, "kind,text") && boundedText(value["text"]))
26
+ return { kind: value["kind"], text: value["text"] };
27
+ const toolKeys = version >= 4
28
+ ? "body,durationMs,kind,name,right,target,tone"
29
+ : "body,kind,name,right,target,tone";
30
+ if (value["kind"] === "tool" && keys(value, toolKeys) &&
31
+ bounded(value["name"], 256) && boundedText(value["target"]) &&
32
+ boundedText(value["right"], 1_024) &&
33
+ (value["tone"] === "ok" || value["tone"] === "fail" || value["tone"] === "deny") &&
34
+ (version < 4 || nullableInteger(value["durationMs"], 0)) &&
35
+ (value["body"] === null ||
36
+ (Array.isArray(value["body"]) && value["body"].length <= SESSION_FILE_LIMITS.details))) {
37
+ const body = value["body"] === null
38
+ ? undefined
39
+ : value["body"].map(detailFromRecord);
40
+ return {
41
+ kind: "tool",
42
+ name: value["name"],
43
+ target: value["target"],
44
+ right: value["right"],
45
+ tone: value["tone"],
46
+ ...(body === undefined ? {} : { body }),
47
+ ...(version < 4 || value["durationMs"] === null
48
+ ? {}
49
+ : { durationMs: value["durationMs"] }),
50
+ };
51
+ }
52
+ throw invalid();
53
+ }
54
+ function detailRecord(detail) {
55
+ if (detail.kind === "out" || detail.kind === "gap")
56
+ return { kind: detail.kind, text: detail.text };
57
+ return {
58
+ kind: detail.kind,
59
+ text: detail.text,
60
+ oldLine: detail.oldLine ?? null,
61
+ newLine: detail.newLine ?? null,
62
+ emphasis: detail.emphasis ?? null,
63
+ };
64
+ }
65
+ function detailFromRecord(value) {
66
+ if (!record(value) || typeof value["kind"] !== "string")
67
+ throw invalid();
68
+ if ((value["kind"] === "out" || value["kind"] === "gap") &&
69
+ keys(value, "kind,text") && boundedText(value["text"]))
70
+ return { kind: value["kind"], text: value["text"] };
71
+ if ((value["kind"] === "keep" || value["kind"] === "add" || value["kind"] === "del") &&
72
+ keys(value, "emphasis,kind,newLine,oldLine,text") && boundedText(value["text"]) &&
73
+ nullableInteger(value["oldLine"], 1) && nullableInteger(value["newLine"], 1)) {
74
+ const emphasis = emphasisFromRecord(value["emphasis"]);
75
+ return {
76
+ kind: value["kind"],
77
+ text: value["text"],
78
+ ...(value["oldLine"] === null ? {} : { oldLine: value["oldLine"] }),
79
+ ...(value["newLine"] === null ? {} : { newLine: value["newLine"] }),
80
+ ...(emphasis === undefined ? {} : { emphasis }),
81
+ };
82
+ }
83
+ throw invalid();
84
+ }
85
+ function emphasisFromRecord(value) {
86
+ if (value === null)
87
+ return undefined;
88
+ if (!record(value) || !keys(value, "length,start"))
89
+ throw invalid();
90
+ if (!integer(value["start"], 0) || !integer(value["length"], 1))
91
+ throw invalid();
92
+ return { start: value["start"], length: value["length"] };
93
+ }
@@ -0,0 +1,52 @@
1
+ // Shared strict value checks and limits for the canonical session codec.
2
+ import { Buffer } from "node:buffer";
3
+ import { MAX_TEXT_CODE_UNITS } from "../text-boundary.js";
4
+ export const SESSION_FILE_LIMITS = Object.freeze({
5
+ text: MAX_TEXT_CODE_UNITS,
6
+ metadataBytes: 64 * 1_024,
7
+ nodeBytes: 20 * 1_024 * 1_024,
8
+ jsonDepth: 24,
9
+ jsonNodes: 32_768,
10
+ blocks: 8_192,
11
+ details: 8_192,
12
+ });
13
+ function line(value) {
14
+ return `${JSON.stringify(value, null, 2)}\n`;
15
+ }
16
+ export function boundedLine(value, maxBytes) {
17
+ const encoded = line(value);
18
+ if (Buffer.byteLength(encoded, "utf8") > maxBytes)
19
+ throw invalid();
20
+ return encoded;
21
+ }
22
+ export function keys(value, expected) {
23
+ return Object.keys(value).sort().join(",") === expected;
24
+ }
25
+ export function record(value) {
26
+ return typeof value === "object" && value !== null && !Array.isArray(value);
27
+ }
28
+ export function bounded(value, limit = SESSION_FILE_LIMITS.text) {
29
+ return typeof value === "string" && value.length > 0 && value.length <= limit;
30
+ }
31
+ export function boundedText(value, limit = SESSION_FILE_LIMITS.text) {
32
+ return typeof value === "string" && value.length <= limit;
33
+ }
34
+ export function integer(value, minimum) {
35
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
36
+ }
37
+ export function nullableInteger(value, minimum) {
38
+ return value === null || integer(value, minimum);
39
+ }
40
+ export function identifier(value) {
41
+ return typeof value === "string" && /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/.test(value);
42
+ }
43
+ export function digest(value) {
44
+ return typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
45
+ }
46
+ export function timestamp(value) {
47
+ return typeof value === "string" && value.length >= 20 && value.length <= 64 &&
48
+ Number.isFinite(Date.parse(value));
49
+ }
50
+ export function invalid() {
51
+ return new Error("session data is invalid or unsupported");
52
+ }