@giovannijecha/jecode 0.7.0 → 0.7.2
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 +244 -265
- package/dist/config.js +46 -21
- package/dist/context/manual.js +1 -0
- package/dist/conversation.js +41 -4
- package/dist/credential-safety.js +9 -5
- package/dist/providers/anthropic-wire.js +5 -4
- package/dist/providers/ollama-wire.js +3 -2
- package/dist/providers/openai-wire.js +6 -5
- package/dist/providers/wire-usage.js +6 -0
- package/dist/sessions/codec.js +54 -8
- package/dist/sessions/runtime.js +2 -2
- package/dist/sessions/store.js +54 -32
- package/dist/timeline.js +12 -6
- package/dist/tui/app-workflows.js +54 -5
- package/dist/tui/app.js +85 -38
- package/dist/tui/picker.js +2 -2
- package/dist/ui/terminal-text.js +4 -1
- package/package.json +1 -1
package/dist/config.js
CHANGED
|
@@ -3,29 +3,24 @@ import * as path from "node:path";
|
|
|
3
3
|
import { DEFAULT_COMPACTION_PERCENT, MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT, } from "./context/policy.js";
|
|
4
4
|
import { EFFORTS, readSettings } from "./settings.js";
|
|
5
5
|
import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
|
|
6
|
-
const
|
|
6
|
+
const VALUE_FLAGS = [
|
|
7
7
|
"provider",
|
|
8
8
|
"model",
|
|
9
9
|
"ollama-host",
|
|
10
|
-
"reduced-motion",
|
|
11
10
|
"effort",
|
|
12
11
|
"max-tokens",
|
|
13
12
|
"max-steps",
|
|
14
13
|
"compaction-percent",
|
|
15
14
|
"root",
|
|
15
|
+
];
|
|
16
|
+
const BOOLEAN_FLAGS = [
|
|
17
|
+
"reduced-motion",
|
|
16
18
|
"auto-approve",
|
|
17
19
|
"ephemeral",
|
|
18
20
|
];
|
|
21
|
+
const FLAGS = [...VALUE_FLAGS, ...BOOLEAN_FLAGS];
|
|
19
22
|
export function loadConfig(argv, saved = readSettings()) {
|
|
20
23
|
const flags = parseFlags(argv);
|
|
21
|
-
// A flag nobody declared is a typo. Swallowed in silence it becomes a
|
|
22
|
-
// setting the user believes is on, and the run that ignores it looks like
|
|
23
|
-
// the feature is broken rather than misspelled.
|
|
24
|
-
for (const name of Object.keys(flags)) {
|
|
25
|
-
if (!FLAGS.includes(name)) {
|
|
26
|
-
throw new Error(`unknown flag --${name} (known: ${FLAGS.map((f) => `--${f}`).join(", ")})`);
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
24
|
const providerId = pick(flags.provider, process.env.JECODE_PROVIDER, saved.provider ?? "anthropic");
|
|
30
25
|
const ollamaHost = optional(flags["ollama-host"], process.env.OLLAMA_HOST, saved.ollamaHost);
|
|
31
26
|
const effort = pick(flags.effort, process.env.JECODE_EFFORT, saved.effort ?? "high");
|
|
@@ -45,7 +40,9 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
45
40
|
maxSteps: toInt(pick(flags["max-steps"], process.env.JECODE_MAX_STEPS, String(saved.maxSteps ?? 40)), "max-steps"),
|
|
46
41
|
compactionPercent: toPercent(pick(flags["compaction-percent"], process.env.JECODE_COMPACTION_PERCENT, String(saved.compactionPercent ?? DEFAULT_COMPACTION_PERCENT))),
|
|
47
42
|
root: path.resolve(pick(flags.root, undefined, process.cwd())),
|
|
48
|
-
autoApprove: flags["auto-approve"] === "true" ||
|
|
43
|
+
autoApprove: flags["auto-approve"] === "true" ||
|
|
44
|
+
flags["auto-approve"] === "1" ||
|
|
45
|
+
process.env.JECODE_AUTO_APPROVE === "1",
|
|
49
46
|
ephemeral: bool(flags.ephemeral, process.env.JECODE_EPHEMERAL, false),
|
|
50
47
|
};
|
|
51
48
|
}
|
|
@@ -85,27 +82,55 @@ function toPercent(value) {
|
|
|
85
82
|
}
|
|
86
83
|
return percent;
|
|
87
84
|
}
|
|
88
|
-
//
|
|
85
|
+
// Value flags accept --key value and --key=value. Boolean flags are bare or
|
|
86
|
+
// take a real boolean value, so an accidental positional argument is never
|
|
87
|
+
// swallowed as configuration.
|
|
89
88
|
function parseFlags(argv) {
|
|
90
89
|
const flags = {};
|
|
91
90
|
for (let i = 0; i < argv.length; i++) {
|
|
92
91
|
const arg = argv[i];
|
|
93
|
-
if (arg === undefined
|
|
92
|
+
if (arg === undefined)
|
|
94
93
|
continue;
|
|
94
|
+
if (!arg.startsWith("--"))
|
|
95
|
+
throw new Error(`unexpected argument "${arg}"`);
|
|
95
96
|
const body = arg.slice(2);
|
|
96
97
|
const eq = body.indexOf("=");
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
const name = eq === -1 ? body : body.slice(0, eq);
|
|
99
|
+
const inline = eq === -1 ? undefined : body.slice(eq + 1);
|
|
100
|
+
if (!FLAGS.includes(name)) {
|
|
101
|
+
throw new Error(`unknown flag --${name} (known: ${FLAGS.map((flag) => `--${flag}`).join(", ")})`);
|
|
102
|
+
}
|
|
103
|
+
if (BOOLEAN_FLAGS.includes(name)) {
|
|
104
|
+
if (inline === undefined) {
|
|
105
|
+
const next = argv[i + 1];
|
|
106
|
+
if (next !== undefined && ["true", "false", "1", "0"].includes(next)) {
|
|
107
|
+
flags[name] = next;
|
|
108
|
+
i++;
|
|
109
|
+
}
|
|
110
|
+
else {
|
|
111
|
+
flags[name] = "true";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
else if (["true", "false", "1", "0"].includes(inline)) {
|
|
115
|
+
flags[name] = inline;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
throw new Error(`--${name} must be true or false`);
|
|
119
|
+
}
|
|
99
120
|
continue;
|
|
100
121
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
122
|
+
if (inline !== undefined) {
|
|
123
|
+
if (inline === "")
|
|
124
|
+
throw new Error(`--${name} requires a value`);
|
|
125
|
+
flags[name] = inline;
|
|
126
|
+
continue;
|
|
105
127
|
}
|
|
106
|
-
|
|
107
|
-
|
|
128
|
+
const next = argv[i + 1];
|
|
129
|
+
if (next === undefined || next.startsWith("--")) {
|
|
130
|
+
throw new Error(`--${name} requires a value`);
|
|
108
131
|
}
|
|
132
|
+
flags[name] = next;
|
|
133
|
+
i++;
|
|
109
134
|
}
|
|
110
135
|
return flags;
|
|
111
136
|
}
|
package/dist/context/manual.js
CHANGED
|
@@ -62,6 +62,7 @@ export async function compactSession(session, options = {}) {
|
|
|
62
62
|
messages: active.messages,
|
|
63
63
|
blocks: active.blocks,
|
|
64
64
|
context: result.anchor,
|
|
65
|
+
...(active.failure === undefined ? {} : { failure: active.failure }),
|
|
65
66
|
}, active.settlement);
|
|
66
67
|
await session.persistence?.checkpoint(next);
|
|
67
68
|
session.conversation = next;
|
package/dist/conversation.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// project an older prefix before it is sent to a provider. Provider traffic
|
|
6
6
|
// and live screen blocks remain prospective until a consistent checkpoint.
|
|
7
7
|
import { projectContext, validContextAnchor } from "./context/projection.js";
|
|
8
|
+
import { assertPersistableNode } from "./sessions/codec.js";
|
|
8
9
|
export const CONVERSATION_LIMITS = Object.freeze({
|
|
9
10
|
nodes: 1_024,
|
|
10
11
|
messageCodeUnits: 8_388_608,
|
|
@@ -37,6 +38,7 @@ export class ConversationTree {
|
|
|
37
38
|
messages: node.messages,
|
|
38
39
|
blocks: node.blocks,
|
|
39
40
|
...(node.context === undefined ? {} : { context: node.context }),
|
|
41
|
+
...(node.failure === undefined ? {} : { failure: node.failure }),
|
|
40
42
|
}, node.settlement);
|
|
41
43
|
const restored = tree.activeNode;
|
|
42
44
|
if (restored === undefined || restored.id !== node.id) {
|
|
@@ -92,6 +94,19 @@ export class ConversationTree {
|
|
|
92
94
|
}
|
|
93
95
|
return undefined;
|
|
94
96
|
}
|
|
97
|
+
/** Select the newest turn that is safe to continue after a restart. */
|
|
98
|
+
latestResumable() {
|
|
99
|
+
let id = this.#activeNodeId;
|
|
100
|
+
while (id !== 0) {
|
|
101
|
+
const node = this.node(id);
|
|
102
|
+
if (node === undefined)
|
|
103
|
+
throw new Error("conversation path is incomplete");
|
|
104
|
+
if (node.settlement !== "checkpointed")
|
|
105
|
+
return this.select(id);
|
|
106
|
+
id = node.parentId;
|
|
107
|
+
}
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
95
110
|
get history() {
|
|
96
111
|
return this.#path().flatMap((node) => clone(node.messages));
|
|
97
112
|
}
|
|
@@ -99,7 +114,12 @@ export class ConversationTree {
|
|
|
99
114
|
return projectContext(this.#path());
|
|
100
115
|
}
|
|
101
116
|
get transcript() {
|
|
102
|
-
return this.#path().flatMap((node) =>
|
|
117
|
+
return this.#path().flatMap((node) => [
|
|
118
|
+
...clone(node.blocks),
|
|
119
|
+
...(node.failure === undefined
|
|
120
|
+
? []
|
|
121
|
+
: [{ kind: "notice", text: node.failure.text, tone: node.failure.tone }]),
|
|
122
|
+
]);
|
|
103
123
|
}
|
|
104
124
|
#append(draft, settlement) {
|
|
105
125
|
if (this.#nodes.length >= CONVERSATION_LIMITS.nodes) {
|
|
@@ -115,8 +135,10 @@ export class ConversationTree {
|
|
|
115
135
|
messages: draft.messages,
|
|
116
136
|
blocks: settledBlocks(draft.blocks),
|
|
117
137
|
...(draft.context === undefined ? {} : { context: draft.context }),
|
|
138
|
+
...(draft.failure === undefined ? {} : { failure: draft.failure }),
|
|
118
139
|
});
|
|
119
140
|
assertTurn(node);
|
|
141
|
+
assertPersistableNode(node);
|
|
120
142
|
const nodes = [...this.#nodes, node];
|
|
121
143
|
assertBounds(nodes);
|
|
122
144
|
return new ConversationTree(nodes, node.id);
|
|
@@ -135,8 +157,10 @@ export class ConversationTree {
|
|
|
135
157
|
messages: draft.messages,
|
|
136
158
|
blocks: settledBlocks(draft.blocks),
|
|
137
159
|
context: draft.context ?? current.context,
|
|
160
|
+
failure: draft.failure,
|
|
138
161
|
});
|
|
139
162
|
assertTurn(node);
|
|
163
|
+
assertPersistableNode(node);
|
|
140
164
|
const nodes = [...this.#nodes];
|
|
141
165
|
nodes[id - 1] = node;
|
|
142
166
|
assertBounds(nodes);
|
|
@@ -163,6 +187,7 @@ function ownedNode(node) {
|
|
|
163
187
|
messages: Object.freeze(clone(node.messages)),
|
|
164
188
|
blocks: Object.freeze(clone(node.blocks)),
|
|
165
189
|
...(node.context === undefined ? {} : { context: Object.freeze({ ...node.context }) }),
|
|
190
|
+
...(node.failure === undefined ? {} : { failure: Object.freeze({ ...node.failure }) }),
|
|
166
191
|
});
|
|
167
192
|
}
|
|
168
193
|
function settledBlocks(blocks) {
|
|
@@ -174,6 +199,8 @@ function settledBlocks(blocks) {
|
|
|
174
199
|
return [settled];
|
|
175
200
|
}
|
|
176
201
|
if (block.kind === "tool") {
|
|
202
|
+
if (block.tone === "pending")
|
|
203
|
+
return [];
|
|
177
204
|
const { startedAt: _startedAt, expanded: _expanded, ...settled } = block;
|
|
178
205
|
return [settled];
|
|
179
206
|
}
|
|
@@ -185,16 +212,26 @@ function assertTurn(node) {
|
|
|
185
212
|
!validNodeId(node.parentId) || node.parentId >= node.id ||
|
|
186
213
|
!Number.isSafeInteger(node.revision) || node.revision < 1 ||
|
|
187
214
|
node.createdAt.length === 0 || node.createdAt.length > 64 ||
|
|
188
|
-
(node.settlement
|
|
215
|
+
!validSettlement(node.settlement) ||
|
|
189
216
|
node.messages.length < 2 || node.messages[0]?.role !== "user" ||
|
|
190
217
|
node.identity.providerId.length === 0 || node.identity.providerId.length > 128 ||
|
|
191
218
|
node.identity.model.length === 0 || node.identity.model.length > 512 ||
|
|
192
219
|
node.identity.effort.length === 0 || node.identity.effort.length > 32)
|
|
193
220
|
throw new Error("turn checkpoint is invalid");
|
|
194
|
-
if (node.settlement
|
|
195
|
-
throw new Error("a
|
|
221
|
+
if (node.settlement !== "checkpointed" && node.messages.at(-1)?.role !== "assistant") {
|
|
222
|
+
throw new Error("a resumable turn must end with an assistant message");
|
|
223
|
+
}
|
|
224
|
+
const failed = node.settlement === "failed" || node.settlement === "interrupted";
|
|
225
|
+
if (failed !== (node.failure !== undefined) ||
|
|
226
|
+
(node.settlement === "failed" && node.failure?.tone !== "error") ||
|
|
227
|
+
(node.settlement === "interrupted" && node.failure?.tone !== "warn")) {
|
|
228
|
+
throw new Error("turn failure state is invalid");
|
|
196
229
|
}
|
|
197
230
|
}
|
|
231
|
+
function validSettlement(value) {
|
|
232
|
+
return value === "checkpointed" || value === "completed" ||
|
|
233
|
+
value === "failed" || value === "interrupted";
|
|
234
|
+
}
|
|
198
235
|
function assertBounds(nodes) {
|
|
199
236
|
let messageCodeUnits = 0;
|
|
200
237
|
let transcriptCodeUnits = 0;
|
|
@@ -40,17 +40,21 @@ export function credentialRedactor(source = process.env) {
|
|
|
40
40
|
const ready = [];
|
|
41
41
|
let at = 0;
|
|
42
42
|
while (at < combined.length) {
|
|
43
|
+
const rest = combined.slice(at);
|
|
44
|
+
// A complete shorter credential can also be the prefix of a longer
|
|
45
|
+
// one. Hold that ambiguous suffix until the next chunk proves which
|
|
46
|
+
// value arrived, otherwise the longer credential leaks its tail.
|
|
47
|
+
if (rest.length < longest &&
|
|
48
|
+
values.some((value) => value.length > rest.length && value.startsWith(rest))) {
|
|
49
|
+
pending = rest;
|
|
50
|
+
return ready.join("");
|
|
51
|
+
}
|
|
43
52
|
const complete = values.find((value) => combined.startsWith(value, at));
|
|
44
53
|
if (complete !== undefined) {
|
|
45
54
|
ready.push(REDACTED);
|
|
46
55
|
at += complete.length;
|
|
47
56
|
continue;
|
|
48
57
|
}
|
|
49
|
-
const rest = combined.slice(at);
|
|
50
|
-
if (rest.length < longest && values.some((value) => value.startsWith(rest))) {
|
|
51
|
-
pending = rest;
|
|
52
|
-
return ready.join("");
|
|
53
|
-
}
|
|
54
58
|
ready.push(combined[at]);
|
|
55
59
|
at++;
|
|
56
60
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Translation between the normalized vocabulary and the Anthropic wire shape.
|
|
2
2
|
// Pure functions, no I/O — which is what makes them testable without a key.
|
|
3
|
+
import { wireTokenCount } from "./wire-usage.js";
|
|
3
4
|
export function toWireTool(tool) {
|
|
4
5
|
return { name: tool.name, description: tool.description, input_schema: tool.input };
|
|
5
6
|
}
|
|
@@ -70,10 +71,10 @@ function normalizeUsage(data) {
|
|
|
70
71
|
if (usage === undefined)
|
|
71
72
|
return undefined;
|
|
72
73
|
return {
|
|
73
|
-
inputTokens: usage.input_tokens
|
|
74
|
-
outputTokens: usage.output_tokens
|
|
75
|
-
cachedInputTokens: usage.cache_read_input_tokens
|
|
76
|
-
cacheWriteInputTokens: usage.cache_creation_input_tokens
|
|
74
|
+
inputTokens: wireTokenCount(usage.input_tokens),
|
|
75
|
+
outputTokens: wireTokenCount(usage.output_tokens),
|
|
76
|
+
cachedInputTokens: wireTokenCount(usage.cache_read_input_tokens),
|
|
77
|
+
cacheWriteInputTokens: wireTokenCount(usage.cache_creation_input_tokens),
|
|
77
78
|
reasoningTokens: 0,
|
|
78
79
|
};
|
|
79
80
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// Chat Completions differs on two points that matter here — a tool result is a
|
|
7
7
|
// message of its own with role "tool", not a block inside a user turn, and tool
|
|
8
8
|
// arguments travel as a JSON string rather than an object.
|
|
9
|
+
import { wireTokenCount } from "./wire-usage.js";
|
|
9
10
|
export function toWireTool(tool) {
|
|
10
11
|
return {
|
|
11
12
|
type: "function",
|
|
@@ -83,8 +84,8 @@ function normalizeUsage(reply) {
|
|
|
83
84
|
if (reply.usage === undefined)
|
|
84
85
|
return undefined;
|
|
85
86
|
return {
|
|
86
|
-
inputTokens: reply.usage.prompt_tokens
|
|
87
|
-
outputTokens: reply.usage.completion_tokens
|
|
87
|
+
inputTokens: wireTokenCount(reply.usage.prompt_tokens),
|
|
88
|
+
outputTokens: wireTokenCount(reply.usage.completion_tokens),
|
|
88
89
|
cachedInputTokens: 0,
|
|
89
90
|
cacheWriteInputTokens: 0,
|
|
90
91
|
reasoningTokens: 0,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Translation between the normalized vocabulary and the OpenAI Responses wire
|
|
2
2
|
// shape: a flat `input` list where tool calls and their outputs are top-level
|
|
3
3
|
// items keyed by `call_id`, rather than blocks nested inside a message.
|
|
4
|
+
import { wireTokenCount } from "./wire-usage.js";
|
|
4
5
|
export function toWireTool(tool) {
|
|
5
6
|
return {
|
|
6
7
|
type: "function",
|
|
@@ -83,11 +84,11 @@ function normalizeUsage(data) {
|
|
|
83
84
|
if (usage === undefined || usage === null)
|
|
84
85
|
return undefined;
|
|
85
86
|
return {
|
|
86
|
-
inputTokens: usage.input_tokens
|
|
87
|
-
outputTokens: usage.output_tokens
|
|
88
|
-
cachedInputTokens: usage.input_tokens_details?.cached_tokens
|
|
89
|
-
cacheWriteInputTokens: usage.input_tokens_details?.cache_write_tokens
|
|
90
|
-
reasoningTokens: usage.output_tokens_details?.reasoning_tokens
|
|
87
|
+
inputTokens: wireTokenCount(usage.input_tokens),
|
|
88
|
+
outputTokens: wireTokenCount(usage.output_tokens),
|
|
89
|
+
cachedInputTokens: wireTokenCount(usage.input_tokens_details?.cached_tokens),
|
|
90
|
+
cacheWriteInputTokens: wireTokenCount(usage.input_tokens_details?.cache_write_tokens),
|
|
91
|
+
reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
|
|
91
92
|
};
|
|
92
93
|
}
|
|
93
94
|
// Arguments arrive as a JSON string and models vary in how they escape it, so
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// Usage comes from remote JSON and eventually reaches the strict session
|
|
2
|
+
// codec. Normalize it at the wire boundary so one malformed counter cannot
|
|
3
|
+
// make an otherwise valid saved session unreadable.
|
|
4
|
+
export function wireTokenCount(value) {
|
|
5
|
+
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : 0;
|
|
6
|
+
}
|
package/dist/sessions/codec.js
CHANGED
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
// Strict codecs for session files. Disk is an untrusted boundary even when
|
|
2
2
|
// the directory is owner-only: every value is bounded and re-owned before it
|
|
3
3
|
// can become conversation or provider input.
|
|
4
|
+
import { Buffer } from "node:buffer";
|
|
4
5
|
import { CONTEXT_LIMITS } from "../context/projection.js";
|
|
5
|
-
export const SESSION_SCHEMA =
|
|
6
|
+
export const SESSION_SCHEMA = 3;
|
|
6
7
|
export const SESSION_FILE_LIMITS = Object.freeze({
|
|
7
8
|
text: 1_048_576,
|
|
9
|
+
metadataBytes: 64 * 1_024,
|
|
10
|
+
nodeBytes: 20 * 1_024 * 1_024,
|
|
8
11
|
jsonDepth: 24,
|
|
9
12
|
jsonNodes: 32_768,
|
|
10
13
|
blocks: 8_192,
|
|
11
14
|
details: 8_192,
|
|
12
15
|
});
|
|
13
16
|
export function encodeMeta(meta) {
|
|
14
|
-
|
|
17
|
+
decodeMeta(meta);
|
|
18
|
+
return boundedLine(meta, SESSION_FILE_LIMITS.metadataBytes);
|
|
15
19
|
}
|
|
16
20
|
export function decodeMeta(value) {
|
|
17
21
|
if (!record(value) || !keys(value, "createdAt,id,version,workspaceDigest,workspaceRoot")) {
|
|
@@ -32,7 +36,8 @@ export function decodeMeta(value) {
|
|
|
32
36
|
});
|
|
33
37
|
}
|
|
34
38
|
export function encodeHead(head) {
|
|
35
|
-
|
|
39
|
+
decodeHead(head);
|
|
40
|
+
return boundedLine(head, SESSION_FILE_LIMITS.metadataBytes);
|
|
36
41
|
}
|
|
37
42
|
export function decodeHead(value) {
|
|
38
43
|
if (!record(value) || !keys(value, "nodeId,parentId,revision,sequence,updatedAt,version")) {
|
|
@@ -55,7 +60,16 @@ export function decodeHead(value) {
|
|
|
55
60
|
});
|
|
56
61
|
}
|
|
57
62
|
export function encodeNode(node, sequence, updatedAt) {
|
|
58
|
-
|
|
63
|
+
const envelope = nodeEnvelope(node, sequence, updatedAt);
|
|
64
|
+
decodeNode(envelope);
|
|
65
|
+
return boundedLine(envelope, SESSION_FILE_LIMITS.nodeBytes);
|
|
66
|
+
}
|
|
67
|
+
/** Enforce the exact current-disk boundary before a turn enters the tree. */
|
|
68
|
+
export function assertPersistableNode(node) {
|
|
69
|
+
encodeNode(node, 1, node.createdAt);
|
|
70
|
+
}
|
|
71
|
+
function nodeEnvelope(node, sequence, updatedAt) {
|
|
72
|
+
return {
|
|
59
73
|
version: SESSION_SCHEMA,
|
|
60
74
|
sequence,
|
|
61
75
|
updatedAt,
|
|
@@ -69,8 +83,9 @@ export function encodeNode(node, sequence, updatedAt) {
|
|
|
69
83
|
messages: node.messages.map(messageRecord),
|
|
70
84
|
blocks: node.blocks.flatMap(blockRecord),
|
|
71
85
|
context: node.context ?? null,
|
|
86
|
+
failure: node.failure ?? null,
|
|
72
87
|
},
|
|
73
|
-
}
|
|
88
|
+
};
|
|
74
89
|
}
|
|
75
90
|
export function decodeNode(value) {
|
|
76
91
|
if (!record(value) || !keys(value, "node,sequence,updatedAt,version"))
|
|
@@ -82,7 +97,9 @@ export function decodeNode(value) {
|
|
|
82
97
|
const raw = value["node"];
|
|
83
98
|
const nodeKeys = version === 1
|
|
84
99
|
? "blocks,createdAt,id,identity,messages,parentId,revision,settlement"
|
|
85
|
-
:
|
|
100
|
+
: version === 2
|
|
101
|
+
? "blocks,context,createdAt,id,identity,messages,parentId,revision,settlement"
|
|
102
|
+
: "blocks,context,createdAt,failure,id,identity,messages,parentId,revision,settlement";
|
|
86
103
|
if (!record(raw) || !keys(raw, nodeKeys)) {
|
|
87
104
|
throw invalid();
|
|
88
105
|
}
|
|
@@ -93,7 +110,7 @@ export function decodeNode(value) {
|
|
|
93
110
|
!integer(raw["parentId"], 0) ||
|
|
94
111
|
!integer(raw["revision"], 1) ||
|
|
95
112
|
!timestamp(raw["createdAt"]) ||
|
|
96
|
-
(raw["settlement"]
|
|
113
|
+
!settlement(raw["settlement"], version) ||
|
|
97
114
|
!record(identity) || !keys(identity, "effort,model,providerId") ||
|
|
98
115
|
!bounded(identity["providerId"], 128) || !bounded(identity["model"], 512) ||
|
|
99
116
|
!bounded(identity["effort"], 32) ||
|
|
@@ -103,6 +120,9 @@ export function decodeNode(value) {
|
|
|
103
120
|
const context = version === 1
|
|
104
121
|
? undefined
|
|
105
122
|
: contextFromRecord(raw["context"], raw["id"], messages.length);
|
|
123
|
+
const failure = version < 3
|
|
124
|
+
? undefined
|
|
125
|
+
: failureFromRecord(raw["failure"], raw["settlement"]);
|
|
106
126
|
const node = Object.freeze({
|
|
107
127
|
id: raw["id"],
|
|
108
128
|
parentId: raw["parentId"],
|
|
@@ -117,9 +137,25 @@ export function decodeNode(value) {
|
|
|
117
137
|
messages: Object.freeze(messages.map(messageFromRecord)),
|
|
118
138
|
blocks: Object.freeze(blocks.map(blockFromRecord)),
|
|
119
139
|
...(context === undefined ? {} : { context: Object.freeze(context) }),
|
|
140
|
+
...(failure === undefined ? {} : { failure: Object.freeze(failure) }),
|
|
120
141
|
});
|
|
121
142
|
return Object.freeze({ sequence: value["sequence"], updatedAt: value["updatedAt"], node });
|
|
122
143
|
}
|
|
144
|
+
function failureFromRecord(value, settlement) {
|
|
145
|
+
const failed = settlement === "failed" || settlement === "interrupted";
|
|
146
|
+
if (value === null) {
|
|
147
|
+
if (failed)
|
|
148
|
+
throw invalid();
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
if (!failed || !record(value) || !keys(value, "text,tone"))
|
|
152
|
+
throw invalid();
|
|
153
|
+
if (!bounded(value["text"]) ||
|
|
154
|
+
(settlement === "failed" && value["tone"] !== "error") ||
|
|
155
|
+
(settlement === "interrupted" && value["tone"] !== "warn"))
|
|
156
|
+
throw invalid();
|
|
157
|
+
return { text: value["text"], tone: value["tone"] };
|
|
158
|
+
}
|
|
123
159
|
function contextFromRecord(value, ownerId, ownerMessages) {
|
|
124
160
|
if (value === null)
|
|
125
161
|
return undefined;
|
|
@@ -340,6 +376,12 @@ function jsonValue(value, budget, depth) {
|
|
|
340
376
|
function line(value) {
|
|
341
377
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
342
378
|
}
|
|
379
|
+
function boundedLine(value, maxBytes) {
|
|
380
|
+
const encoded = line(value);
|
|
381
|
+
if (Buffer.byteLength(encoded, "utf8") > maxBytes)
|
|
382
|
+
throw invalid();
|
|
383
|
+
return encoded;
|
|
384
|
+
}
|
|
343
385
|
function keys(value, expected) {
|
|
344
386
|
return Object.keys(value).sort().join(",") === expected;
|
|
345
387
|
}
|
|
@@ -356,7 +398,11 @@ function integer(value, minimum) {
|
|
|
356
398
|
return typeof value === "number" && Number.isSafeInteger(value) && value >= minimum;
|
|
357
399
|
}
|
|
358
400
|
function schema(value) {
|
|
359
|
-
return value === 1 || value === SESSION_SCHEMA;
|
|
401
|
+
return value === 1 || value === 2 || value === SESSION_SCHEMA;
|
|
402
|
+
}
|
|
403
|
+
function settlement(value, version) {
|
|
404
|
+
return value === "checkpointed" || value === "completed" ||
|
|
405
|
+
(version >= 3 && (value === "failed" || value === "interrupted"));
|
|
360
406
|
}
|
|
361
407
|
function nullableInteger(value, minimum) {
|
|
362
408
|
return value === null || integer(value, minimum);
|
package/dist/sessions/runtime.js
CHANGED
|
@@ -21,9 +21,9 @@ export class SessionPersistence {
|
|
|
21
21
|
const lease = await store.claim(id);
|
|
22
22
|
try {
|
|
23
23
|
const snapshot = await store.load(id);
|
|
24
|
-
const conversation = snapshot.conversation.
|
|
24
|
+
const conversation = snapshot.conversation.latestResumable();
|
|
25
25
|
if (conversation === undefined)
|
|
26
|
-
throw new Error("session has no
|
|
26
|
+
throw new Error("session has no resumable turn");
|
|
27
27
|
return Object.freeze({
|
|
28
28
|
conversation,
|
|
29
29
|
persistence: new SessionPersistence(store, id, lease),
|
package/dist/sessions/store.js
CHANGED
|
@@ -4,17 +4,17 @@
|
|
|
4
4
|
// after that node is durable, so a crash leaves either the prior checkpoint or
|
|
5
5
|
// one strictly recoverable mutation -- never an ambiguous partial history.
|
|
6
6
|
import { createHash, randomUUID } from "node:crypto";
|
|
7
|
-
import { chmod, lstat, mkdir, open, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
|
|
7
|
+
import { chmod, lstat, mkdir, open, opendir, readFile, readdir, realpath, rename, rm, stat, } from "node:fs/promises";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
import { atomicWrite } from "../atomic.js";
|
|
10
10
|
import { CONVERSATION_LIMITS, ConversationTree } from "../conversation.js";
|
|
11
11
|
import { userDataPath } from "../user-data.js";
|
|
12
|
-
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_SCHEMA, } from "./codec.js";
|
|
12
|
+
import { decodeHead, decodeMeta, decodeNode, encodeHead, encodeMeta, encodeNode, SESSION_FILE_LIMITS, SESSION_SCHEMA, } from "./codec.js";
|
|
13
13
|
import { leaseOwner, leaseToken, pidIsAlive, removeLease, sessionLease, } from "./lease.js";
|
|
14
14
|
const DIRECTORY_MODE = 0o700;
|
|
15
15
|
const FILE_MODE = 0o600;
|
|
16
|
-
const
|
|
17
|
-
const
|
|
16
|
+
const MAX_CATALOG_ENTRIES = 4_096;
|
|
17
|
+
const CATALOG_READ_CONCURRENCY = 8;
|
|
18
18
|
const SESSION_NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
|
|
19
19
|
const NODE_NAME = /^(\d{6})\.json$/;
|
|
20
20
|
const ATOMIC_NODE_TEMP = /^\.\d{6}\.json\.\d+\.[a-f0-9-]+\.tmp$/;
|
|
@@ -37,45 +37,45 @@ export class DurableSessionStore {
|
|
|
37
37
|
if (!Number.isSafeInteger(limit) || limit < 1 || limit > 64) {
|
|
38
38
|
throw new Error("session catalogue limit is invalid");
|
|
39
39
|
}
|
|
40
|
-
const
|
|
41
|
-
const names = entries
|
|
42
|
-
.filter((entry) => entry.isDirectory() && SESSION_NAME.test(entry.name))
|
|
43
|
-
.map((entry) => entry.name)
|
|
44
|
-
.sort((left, right) => right.localeCompare(left))
|
|
45
|
-
.slice(0, MAX_CATALOG_SCAN);
|
|
40
|
+
const names = await catalogNames(this.#bucket);
|
|
46
41
|
const catalog = [];
|
|
47
|
-
for (
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
42
|
+
for (let start = 0; start < names.length; start += CATALOG_READ_CONCURRENCY) {
|
|
43
|
+
const batch = await Promise.all(names.slice(start, start + CATALOG_READ_CONCURRENCY)
|
|
44
|
+
.map(async (id) => {
|
|
45
|
+
try {
|
|
46
|
+
const snapshot = await this.load(id);
|
|
47
|
+
const conversation = snapshot.conversation.latestResumable();
|
|
48
|
+
if (conversation === undefined)
|
|
49
|
+
return undefined;
|
|
50
|
+
return {
|
|
51
|
+
id,
|
|
52
|
+
createdAt: snapshot.meta.createdAt,
|
|
53
|
+
updatedAt: snapshot.head.updatedAt,
|
|
54
|
+
turns: selectedTurnCount(conversation),
|
|
55
|
+
preview: firstUserText(conversation),
|
|
56
|
+
active: await this.#leaseIsActive(id),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// Corrupt or foreign data never becomes a resume candidate.
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
}));
|
|
64
|
+
catalog.push(...batch.filter((entry) => entry !== undefined));
|
|
65
65
|
}
|
|
66
66
|
return catalog
|
|
67
|
-
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt))
|
|
67
|
+
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.id.localeCompare(left.id))
|
|
68
68
|
.slice(0, limit);
|
|
69
69
|
}
|
|
70
70
|
async load(id) {
|
|
71
71
|
assertSessionId(id);
|
|
72
72
|
const directory = this.#sessionDirectory(id);
|
|
73
73
|
await assertDirectory(directory);
|
|
74
|
-
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"),
|
|
74
|
+
const meta = decodeMeta(await readJson(path.join(directory, "meta.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
75
75
|
if (meta.id !== id || meta.workspaceDigest !== this.workspaceDigest ||
|
|
76
76
|
workspaceKey(meta.workspaceRoot) !== workspaceKey(this.workspaceRoot))
|
|
77
77
|
throw new Error("session belongs to a different workspace");
|
|
78
|
-
let head = decodeHead(await readJson(path.join(directory, "head.json"),
|
|
78
|
+
let head = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
79
79
|
const stored = await readNodes(path.join(directory, "nodes"));
|
|
80
80
|
const ahead = stored.filter((entry) => entry.sequence > head.sequence);
|
|
81
81
|
if (ahead.some((entry) => entry.sequence !== head.sequence + 1) || ahead.length > 1) {
|
|
@@ -242,6 +242,28 @@ export class DurableSessionStore {
|
|
|
242
242
|
return owner !== undefined && pidIsAlive(owner.pid);
|
|
243
243
|
}
|
|
244
244
|
}
|
|
245
|
+
async function catalogNames(directory) {
|
|
246
|
+
try {
|
|
247
|
+
await assertDirectory(directory);
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error.code === "ENOENT")
|
|
251
|
+
return [];
|
|
252
|
+
throw error;
|
|
253
|
+
}
|
|
254
|
+
const names = [];
|
|
255
|
+
let entries = 0;
|
|
256
|
+
const handle = await opendir(directory);
|
|
257
|
+
for await (const entry of handle) {
|
|
258
|
+
entries++;
|
|
259
|
+
if (entries > MAX_CATALOG_ENTRIES) {
|
|
260
|
+
throw new Error(`session catalogue exceeds ${MAX_CATALOG_ENTRIES} entries`);
|
|
261
|
+
}
|
|
262
|
+
if (entry.isDirectory() && SESSION_NAME.test(entry.name))
|
|
263
|
+
names.push(entry.name);
|
|
264
|
+
}
|
|
265
|
+
return names.sort((left, right) => right.localeCompare(left));
|
|
266
|
+
}
|
|
245
267
|
function assertSharedNodes(previous, next, replacedId) {
|
|
246
268
|
for (const node of previous.nodes) {
|
|
247
269
|
if (node.id === replacedId)
|
|
@@ -278,7 +300,7 @@ async function readNodes(directory) {
|
|
|
278
300
|
const id = Number(NODE_NAME.exec(name)?.[1]);
|
|
279
301
|
if (id !== index + 1)
|
|
280
302
|
throw new Error("session conversation nodes are not contiguous");
|
|
281
|
-
const decoded = decodeNode(await readJson(path.join(directory, name),
|
|
303
|
+
const decoded = decodeNode(await readJson(path.join(directory, name), SESSION_FILE_LIMITS.nodeBytes));
|
|
282
304
|
if (decoded.node.id !== id || sequences.has(decoded.sequence)) {
|
|
283
305
|
throw new Error("session conversation node identity is invalid");
|
|
284
306
|
}
|