@giovannijecha/jecode 0.8.5 → 0.8.7
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/README.md +36 -9
- package/assets/tokenizers/LICENSE +21 -0
- package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
- package/dist/cli-info.js +8 -14
- package/dist/commands.js +7 -3
- package/dist/config.js +47 -53
- package/dist/context/automatic.js +16 -1
- package/dist/context/budget.js +5 -2
- package/dist/context/compactor.js +91 -37
- package/dist/context/diagnostics.js +108 -0
- package/dist/context/lifetime.js +18 -0
- package/dist/context/manager.js +67 -0
- package/dist/context/manual.js +11 -10
- package/dist/context/measurement.js +75 -0
- package/dist/context/policy.js +13 -10
- package/dist/context/request-observation.js +46 -0
- package/dist/context/request-projection.js +8 -34
- package/dist/context/request.js +22 -0
- package/dist/context/tokenizer/bpe.js +68 -0
- package/dist/context/tokenizer/o200k.js +54 -0
- package/dist/context/tokenizer/vocabulary.js +34 -0
- package/dist/controller-request.js +47 -45
- package/dist/controller.js +13 -2
- package/dist/credential-commands.js +3 -3
- package/dist/input-boundary.js +0 -62
- package/dist/launch.js +13 -9
- package/dist/model-command.js +7 -17
- package/dist/oauth-result-page.js +68 -0
- package/dist/openai-account-command.js +14 -12
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +15 -54
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +9 -24
- package/dist/permissions.js +10 -8
- package/dist/provider-commands.js +1 -33
- package/dist/provider-errors.js +1 -10
- package/dist/provider-label.js +3 -10
- package/dist/providers/anthropic-wire.js +1 -1
- package/dist/providers/anthropic.js +3 -2
- package/dist/providers/index.js +1 -5
- package/dist/providers/input-measurement.js +85 -0
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama-wire.js +1 -1
- package/dist/providers/ollama.js +15 -147
- package/dist/providers/openai-codex.js +7 -4
- package/dist/providers/openai-stream.js +20 -8
- package/dist/providers/openai-summary.js +37 -0
- package/dist/providers/openai-wire.js +1 -1
- package/dist/providers/openai.js +3 -1
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +42 -461
- package/dist/settings.js +17 -16
- package/dist/start.js +36 -53
- package/dist/timeline.js +2 -0
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -193
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +1 -105
- package/dist/tui/activity.js +1 -1
- package/dist/tui/app-input.js +34 -17
- package/dist/tui/app-workflows.js +13 -361
- package/dist/tui/app.js +55 -28
- package/dist/tui/approve.js +3 -2
- package/dist/tui/blocks.js +1 -2
- package/dist/tui/command-workflow.js +112 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/menu.js +82 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/status.js +1 -1
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/frame.js +8 -3
- package/dist/tui/help.js +3 -2
- package/dist/tui/picker-layout.js +44 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/screen.js +7 -0
- package/dist/tui/session-view.js +0 -1
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +1 -1
- package/dist/tui/transcript-view.js +30 -112
- package/dist/tui/turn-workflow.js +235 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/view.js +4 -4
- package/dist/tui/workflow-types.js +2 -0
- package/dist/ui/render.js +0 -4
- package/package.json +16 -12
- package/dist/batch-view.js +0 -17
- package/dist/batch.js +0 -267
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tui/motion.js +0 -32
|
@@ -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
|
+
}
|
package/dist/sessions/catalog.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|