@giovannijecha/jecode 0.8.2 → 0.8.3
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 +19 -278
- package/dist/accounts.js +17 -13
- package/dist/batch.js +54 -6
- package/dist/context/budget.js +13 -1
- package/dist/context/compactor.js +5 -4
- package/dist/context/estimate.js +43 -1
- package/dist/context/manual.js +8 -4
- package/dist/context/policy.js +68 -18
- package/dist/controller-request.js +8 -8
- package/dist/controller.js +6 -1
- package/dist/conversation.js +94 -33
- package/dist/credential-safety.js +56 -9
- package/dist/credentials.js +32 -4
- package/dist/input-boundary.js +80 -0
- package/dist/main.js +4 -1
- package/dist/openai-oauth-callback.js +1 -1
- package/dist/process-shutdown.js +52 -0
- package/dist/providers/anthropic-stream.js +24 -20
- package/dist/providers/anthropic-wire.js +7 -2
- package/dist/providers/ollama-wire.js +7 -15
- package/dist/providers/ollama.js +1 -0
- package/dist/providers/openai-wire.js +2 -16
- package/dist/providers/tool-input.js +17 -0
- package/dist/sessions/codec.js +2 -1
- package/dist/sessions/lease.js +7 -0
- package/dist/sessions/runtime.js +11 -3
- package/dist/sessions/store.js +70 -21
- package/dist/settings.js +10 -5
- package/dist/start.js +12 -2
- package/dist/text-boundary.js +2 -0
- package/dist/tui/app-input.js +40 -5
- package/dist/tui/app-state.js +1 -0
- package/dist/tui/app-workflows.js +1 -0
- package/dist/tui/app.js +11 -3
- package/dist/tui/editor.js +2 -0
- package/dist/tui/keys.js +64 -5
- package/dist/tui/overlay.js +12 -4
- package/dist/tui/picker.js +2 -0
- package/dist/tui/screen.js +5 -17
- package/dist/user-store.js +54 -0
- package/package.json +6 -3
- /package/{docs/assets/brand → assets}/jeco-256.png +0 -0
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// One bounded ingress for text that can become a user prompt.
|
|
2
|
+
import { Buffer } from "node:buffer";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { MAX_TEXT_CODE_UNITS } from "./text-boundary.js";
|
|
5
|
+
export const MAX_PROMPT_CODE_UNITS = MAX_TEXT_CODE_UNITS;
|
|
6
|
+
export const PROMPT_LIMIT_MESSAGE = `Prompt cannot exceed ${MAX_PROMPT_CODE_UNITS.toLocaleString("en-US")} UTF-16 code units`;
|
|
7
|
+
export class PromptLimitError extends Error {
|
|
8
|
+
constructor() {
|
|
9
|
+
super(PROMPT_LIMIT_MESSAGE);
|
|
10
|
+
this.name = "PromptLimitError";
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
export function assertPromptLength(length) {
|
|
14
|
+
if (length > MAX_PROMPT_CODE_UNITS)
|
|
15
|
+
throw new PromptLimitError();
|
|
16
|
+
}
|
|
17
|
+
export function assertPromptAppend(current, added) {
|
|
18
|
+
if (added > MAX_PROMPT_CODE_UNITS - current)
|
|
19
|
+
throw new PromptLimitError();
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Split raw UTF-8 input without letting one unterminated line grow past the
|
|
23
|
+
* prompt boundary. Newline, CRLF, and a final line without a newline match the
|
|
24
|
+
* line semantics used by batch mode.
|
|
25
|
+
*/
|
|
26
|
+
export async function* boundedInputLines(source) {
|
|
27
|
+
const decoder = new StringDecoder("utf8");
|
|
28
|
+
let line = "";
|
|
29
|
+
let pendingCr = false;
|
|
30
|
+
const append = (text, from, to) => {
|
|
31
|
+
const length = to - from;
|
|
32
|
+
assertPromptAppend(line.length, length);
|
|
33
|
+
if (length > 0)
|
|
34
|
+
line += text.slice(from, to);
|
|
35
|
+
};
|
|
36
|
+
const consume = function* (text) {
|
|
37
|
+
if (text === "")
|
|
38
|
+
return;
|
|
39
|
+
let from = 0;
|
|
40
|
+
if (pendingCr) {
|
|
41
|
+
if (text.startsWith("\n"))
|
|
42
|
+
from = 1;
|
|
43
|
+
pendingCr = false;
|
|
44
|
+
yield line;
|
|
45
|
+
line = "";
|
|
46
|
+
}
|
|
47
|
+
while (from < text.length) {
|
|
48
|
+
const cr = text.indexOf("\r", from);
|
|
49
|
+
const lf = text.indexOf("\n", from);
|
|
50
|
+
const newline = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
|
|
51
|
+
if (newline === -1) {
|
|
52
|
+
append(text, from, text.length);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
append(text, from, newline);
|
|
56
|
+
if (text[newline] === "\r" && newline + 1 === text.length) {
|
|
57
|
+
pendingCr = true;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
yield line;
|
|
61
|
+
line = "";
|
|
62
|
+
from = text[newline] === "\r" && text[newline + 1] === "\n"
|
|
63
|
+
? newline + 2
|
|
64
|
+
: newline + 1;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
for await (const chunk of source) {
|
|
68
|
+
const text = typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
|
|
69
|
+
yield* consume(text);
|
|
70
|
+
}
|
|
71
|
+
const tail = decoder.end();
|
|
72
|
+
if (tail !== "")
|
|
73
|
+
yield* consume(tail);
|
|
74
|
+
if (pendingCr) {
|
|
75
|
+
yield line;
|
|
76
|
+
line = "";
|
|
77
|
+
}
|
|
78
|
+
if (line !== "")
|
|
79
|
+
yield line;
|
|
80
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
// Process entry point. The testable bootstrap lives in start.ts.
|
|
2
2
|
import { start } from "./start.js";
|
|
3
|
+
import { isProcessSignalError, withProcessShutdown } from "./process-shutdown.js";
|
|
3
4
|
import { terminalText } from "./ui/terminal-text.js";
|
|
4
|
-
start().catch((error) => {
|
|
5
|
+
withProcessShutdown((signal) => start(process.argv.slice(2), { signal })).catch((error) => {
|
|
6
|
+
if (isProcessSignalError(error))
|
|
7
|
+
return;
|
|
5
8
|
process.stderr.write(`jecode: ${terminalText(error.message)}\n`);
|
|
6
9
|
process.exitCode = 1;
|
|
7
10
|
});
|
|
@@ -173,7 +173,7 @@ function resultPage(success) {
|
|
|
173
173
|
let mascot;
|
|
174
174
|
function mascotDataUri() {
|
|
175
175
|
if (mascot === undefined) {
|
|
176
|
-
const file = new URL("../
|
|
176
|
+
const file = new URL("../assets/jeco-256.png", import.meta.url);
|
|
177
177
|
mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
|
|
178
178
|
}
|
|
179
179
|
return mascot;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// One process-wide cancellation boundary for operating-system signals.
|
|
2
|
+
const SHUTDOWN_GRACE_MS = 2_000;
|
|
3
|
+
const SIGNALS = [
|
|
4
|
+
["SIGINT", 2],
|
|
5
|
+
["SIGHUP", 1],
|
|
6
|
+
["SIGTERM", 15],
|
|
7
|
+
];
|
|
8
|
+
export class ProcessSignalError extends Error {
|
|
9
|
+
signal;
|
|
10
|
+
exitCode;
|
|
11
|
+
constructor(signal, exitCode) {
|
|
12
|
+
super(`received ${signal}`);
|
|
13
|
+
this.name = "ProcessSignalError";
|
|
14
|
+
this.signal = signal;
|
|
15
|
+
this.exitCode = exitCode;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export function isProcessSignalError(error) {
|
|
19
|
+
return error instanceof ProcessSignalError;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Abort foreground work on the first fatal signal and reserve a bounded hard
|
|
23
|
+
* exit for work that ignores cancellation. A second signal exits immediately.
|
|
24
|
+
*/
|
|
25
|
+
export async function withProcessShutdown(work) {
|
|
26
|
+
const control = new AbortController();
|
|
27
|
+
let exitCode;
|
|
28
|
+
let forceTimer;
|
|
29
|
+
const listeners = [];
|
|
30
|
+
for (const [name, number] of SIGNALS) {
|
|
31
|
+
const listener = () => {
|
|
32
|
+
if (control.signal.aborted) {
|
|
33
|
+
process.exit(exitCode ?? 128 + number);
|
|
34
|
+
}
|
|
35
|
+
exitCode = 128 + number;
|
|
36
|
+
process.exitCode = exitCode;
|
|
37
|
+
control.abort(new ProcessSignalError(name, exitCode));
|
|
38
|
+
forceTimer = setTimeout(() => process.exit(exitCode), SHUTDOWN_GRACE_MS);
|
|
39
|
+
};
|
|
40
|
+
listeners.push([name, listener]);
|
|
41
|
+
process.on(name, listener);
|
|
42
|
+
}
|
|
43
|
+
try {
|
|
44
|
+
return await work(control.signal);
|
|
45
|
+
}
|
|
46
|
+
finally {
|
|
47
|
+
if (forceTimer !== undefined)
|
|
48
|
+
clearTimeout(forceTimer);
|
|
49
|
+
for (const [name, listener] of listeners)
|
|
50
|
+
process.off(name, listener);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -6,11 +6,13 @@
|
|
|
6
6
|
// beyond display: thinking blocks carry a signature and must be echoed back
|
|
7
7
|
// byte-for-byte on the next request.
|
|
8
8
|
import { addBounded, MAX_TOOL_ARGUMENT_CHARS } from "./stream-limits.js";
|
|
9
|
+
import { toolInputFromJson } from "./tool-input.js";
|
|
9
10
|
export async function assembleAnthropic(events, onStream) {
|
|
10
11
|
const blocks = new Map();
|
|
11
12
|
const partialJson = new Map();
|
|
12
13
|
const announcedTools = new Set();
|
|
13
14
|
const sizes = { toolArguments: 0 };
|
|
15
|
+
const toolInputErrors = {};
|
|
14
16
|
let stopReason;
|
|
15
17
|
let stopDetails;
|
|
16
18
|
let usage;
|
|
@@ -46,7 +48,10 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
46
48
|
const pending = partialJson.get(event.index);
|
|
47
49
|
const block = blocks.get(event.index);
|
|
48
50
|
if (pending !== undefined && block !== undefined) {
|
|
49
|
-
|
|
51
|
+
const parsed = toolInputFromJson(pending);
|
|
52
|
+
block.input = parsed.input;
|
|
53
|
+
if (parsed.inputError !== undefined)
|
|
54
|
+
toolInputErrors[event.index] = parsed.inputError;
|
|
50
55
|
partialJson.delete(event.index);
|
|
51
56
|
}
|
|
52
57
|
break;
|
|
@@ -69,10 +74,24 @@ export async function assembleAnthropic(events, onStream) {
|
|
|
69
74
|
}
|
|
70
75
|
if (!complete)
|
|
71
76
|
throw new Error("anthropic stream ended before message_stop");
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
77
|
+
const ordered = [...blocks.entries()].sort(([a], [b]) => a - b);
|
|
78
|
+
const content = ordered.map(([, block]) => block);
|
|
79
|
+
const orderedInputErrors = {};
|
|
80
|
+
for (let index = 0; index < ordered.length; index++) {
|
|
81
|
+
const sourceIndex = ordered[index]?.[0];
|
|
82
|
+
if (sourceIndex === undefined || toolInputErrors[sourceIndex] === undefined)
|
|
83
|
+
continue;
|
|
84
|
+
orderedInputErrors[index] = toolInputErrors[sourceIndex];
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
content,
|
|
88
|
+
stop_reason: stopReason,
|
|
89
|
+
stop_details: stopDetails,
|
|
90
|
+
usage,
|
|
91
|
+
...(Object.keys(orderedInputErrors).length === 0
|
|
92
|
+
? {}
|
|
93
|
+
: { toolInputErrors: orderedInputErrors }),
|
|
94
|
+
};
|
|
76
95
|
}
|
|
77
96
|
function mergeUsage(before, after) {
|
|
78
97
|
return after === undefined ? before : { ...before, ...after };
|
|
@@ -108,18 +127,3 @@ function applyDelta(blocks, partialJson, sizes, index, delta, onStream) {
|
|
|
108
127
|
return;
|
|
109
128
|
}
|
|
110
129
|
}
|
|
111
|
-
// Tool arguments arrive as a stream of JSON fragments. An empty accumulation
|
|
112
|
-
// is a call with no arguments, not a malformed one.
|
|
113
|
-
function parseJsonObject(text) {
|
|
114
|
-
if (text.trim() === "")
|
|
115
|
-
return {};
|
|
116
|
-
try {
|
|
117
|
-
const parsed = JSON.parse(text);
|
|
118
|
-
return typeof parsed === "object" && parsed !== null
|
|
119
|
-
? parsed
|
|
120
|
-
: {};
|
|
121
|
-
}
|
|
122
|
-
catch {
|
|
123
|
-
return {};
|
|
124
|
-
}
|
|
125
|
-
}
|
|
@@ -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 { toolInputFromValue } from "./tool-input.js";
|
|
3
4
|
import { wireTokenCount } from "./wire-usage.js";
|
|
4
5
|
export function toWireTool(tool) {
|
|
5
6
|
return { name: tool.name, description: tool.description, input_schema: tool.input };
|
|
@@ -45,7 +46,8 @@ export function fromWireResponse(data) {
|
|
|
45
46
|
const raw = Array.isArray(data.content) ? data.content : [];
|
|
46
47
|
const content = [];
|
|
47
48
|
let suppressedToolCall = false;
|
|
48
|
-
for (
|
|
49
|
+
for (let index = 0; index < raw.length; index++) {
|
|
50
|
+
const item = raw[index];
|
|
49
51
|
const block = item;
|
|
50
52
|
if (block.type === "text" && typeof block.text === "string") {
|
|
51
53
|
content.push({ kind: "text", text: block.text });
|
|
@@ -54,11 +56,14 @@ export function fromWireResponse(data) {
|
|
|
54
56
|
if (data.stop_reason === "tool_use" &&
|
|
55
57
|
typeof block.id === "string" &&
|
|
56
58
|
typeof block.name === "string") {
|
|
59
|
+
const parsed = toolInputFromValue(block.input ?? {});
|
|
60
|
+
const inputError = data.toolInputErrors?.[index] ?? parsed.inputError;
|
|
57
61
|
content.push({
|
|
58
62
|
kind: "tool_call",
|
|
59
63
|
id: block.id,
|
|
60
64
|
name: block.name,
|
|
61
|
-
input:
|
|
65
|
+
input: parsed.input,
|
|
66
|
+
...(inputError === undefined ? {} : { inputError }),
|
|
62
67
|
});
|
|
63
68
|
}
|
|
64
69
|
else {
|
|
@@ -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 { toolInputFromJson } from "./tool-input.js";
|
|
9
10
|
import { wireTokenCount } from "./wire-usage.js";
|
|
10
11
|
export function toWireTool(tool) {
|
|
11
12
|
return {
|
|
@@ -63,7 +64,12 @@ export function fromWireReply(reply) {
|
|
|
63
64
|
const acceptsToolCalls = reply.finishReason === "tool_calls";
|
|
64
65
|
if (acceptsToolCalls) {
|
|
65
66
|
for (const call of reply.toolCalls) {
|
|
66
|
-
content.push({
|
|
67
|
+
content.push({
|
|
68
|
+
kind: "tool_call",
|
|
69
|
+
id: call.id,
|
|
70
|
+
name: call.name,
|
|
71
|
+
...toolInputFromJson(call.args),
|
|
72
|
+
});
|
|
67
73
|
}
|
|
68
74
|
}
|
|
69
75
|
const notice = stopNotice(reply);
|
|
@@ -104,17 +110,3 @@ export function stopNotice(reply) {
|
|
|
104
110
|
? "[truncated: hit the output limit — raise --max-tokens]"
|
|
105
111
|
: undefined;
|
|
106
112
|
}
|
|
107
|
-
// A model that emits malformed JSON gets the empty object, which fails
|
|
108
|
-
// validation in tools/args.ts with a message written for it to read. That is a
|
|
109
|
-
// recoverable turn; throwing here would end the whole thing instead.
|
|
110
|
-
function parseArgs(args) {
|
|
111
|
-
if (args.trim() === "")
|
|
112
|
-
return {};
|
|
113
|
-
try {
|
|
114
|
-
const parsed = JSON.parse(args);
|
|
115
|
-
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
116
|
-
}
|
|
117
|
-
catch {
|
|
118
|
-
return {};
|
|
119
|
-
}
|
|
120
|
-
}
|
package/dist/providers/ollama.js
CHANGED
|
@@ -90,6 +90,7 @@ export const ollama = {
|
|
|
90
90
|
max_tokens: req.maxTokens,
|
|
91
91
|
reasoning_effort: effort,
|
|
92
92
|
stream: true,
|
|
93
|
+
stream_options: { include_usage: true },
|
|
93
94
|
}, req.maxTokens, req.signal, req.onStatus);
|
|
94
95
|
const reply = await assembleOllama(events, req.onStream);
|
|
95
96
|
const notice = stopNotice(reply);
|
|
@@ -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 { toolInputFromJson } from "./tool-input.js";
|
|
4
5
|
import { wireTokenCount } from "./wire-usage.js";
|
|
5
6
|
export function toWireTool(tool) {
|
|
6
7
|
return {
|
|
@@ -73,7 +74,7 @@ export function fromWireResponse(data, providerId = "openai") {
|
|
|
73
74
|
kind: "tool_call",
|
|
74
75
|
id: item.call_id,
|
|
75
76
|
name: item.name,
|
|
76
|
-
|
|
77
|
+
...toolInputFromJson(item.arguments),
|
|
77
78
|
});
|
|
78
79
|
}
|
|
79
80
|
else {
|
|
@@ -106,18 +107,3 @@ function normalizeUsage(data) {
|
|
|
106
107
|
reasoningTokens: wireTokenCount(usage.output_tokens_details?.reasoning_tokens),
|
|
107
108
|
};
|
|
108
109
|
}
|
|
109
|
-
// Arguments arrive as a JSON string and models vary in how they escape it, so
|
|
110
|
-
// this always goes through a real parse — never string matching.
|
|
111
|
-
function parseArguments(text) {
|
|
112
|
-
if (text === undefined || text === "")
|
|
113
|
-
return {};
|
|
114
|
-
try {
|
|
115
|
-
const parsed = JSON.parse(text);
|
|
116
|
-
return typeof parsed === "object" && parsed !== null
|
|
117
|
-
? parsed
|
|
118
|
-
: {};
|
|
119
|
-
}
|
|
120
|
-
catch {
|
|
121
|
-
return {};
|
|
122
|
-
}
|
|
123
|
-
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Provider-neutral validation for tool arguments crossing a wire boundary.
|
|
2
|
+
export function toolInputFromJson(text) {
|
|
3
|
+
if (text === undefined || text.trim() === "")
|
|
4
|
+
return { input: {} };
|
|
5
|
+
try {
|
|
6
|
+
return toolInputFromValue(JSON.parse(text));
|
|
7
|
+
}
|
|
8
|
+
catch {
|
|
9
|
+
return { input: {}, inputError: "tool arguments were not valid JSON" };
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
export function toolInputFromValue(value) {
|
|
13
|
+
if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
|
14
|
+
return { input: value };
|
|
15
|
+
}
|
|
16
|
+
return { input: {}, inputError: "tool arguments must be a JSON object" };
|
|
17
|
+
}
|
package/dist/sessions/codec.js
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
// can become conversation or provider input.
|
|
4
4
|
import { Buffer } from "node:buffer";
|
|
5
5
|
import { CONTEXT_LIMITS } from "../context/projection.js";
|
|
6
|
+
import { MAX_TEXT_CODE_UNITS } from "../text-boundary.js";
|
|
6
7
|
export const SESSION_SCHEMA = 4;
|
|
7
8
|
export const SESSION_FILE_LIMITS = Object.freeze({
|
|
8
|
-
text:
|
|
9
|
+
text: MAX_TEXT_CODE_UNITS,
|
|
9
10
|
metadataBytes: 64 * 1_024,
|
|
10
11
|
nodeBytes: 20 * 1_024 * 1_024,
|
|
11
12
|
jsonDepth: 24,
|
package/dist/sessions/lease.js
CHANGED
|
@@ -9,6 +9,13 @@ export function sessionLease(id, file, token) {
|
|
|
9
9
|
let closed = false;
|
|
10
10
|
return Object.freeze({
|
|
11
11
|
id,
|
|
12
|
+
assertOwned: async () => {
|
|
13
|
+
if (closed)
|
|
14
|
+
throw new Error("session lease is closed");
|
|
15
|
+
const current = await readLease(file);
|
|
16
|
+
if (current !== token)
|
|
17
|
+
throw new Error("session lease is no longer owned by this process");
|
|
18
|
+
},
|
|
12
19
|
close: async () => {
|
|
13
20
|
if (closed)
|
|
14
21
|
return;
|
package/dist/sessions/runtime.js
CHANGED
|
@@ -8,11 +8,13 @@ export class SessionPersistence {
|
|
|
8
8
|
#store;
|
|
9
9
|
#sessionId;
|
|
10
10
|
#lease;
|
|
11
|
+
#snapshot;
|
|
11
12
|
#failure;
|
|
12
|
-
constructor(store, sessionId, lease) {
|
|
13
|
+
constructor(store, sessionId, lease, snapshot) {
|
|
13
14
|
this.#store = store;
|
|
14
15
|
this.#sessionId = sessionId;
|
|
15
16
|
this.#lease = lease;
|
|
17
|
+
this.#snapshot = snapshot;
|
|
16
18
|
}
|
|
17
19
|
static fresh(store) {
|
|
18
20
|
return new SessionPersistence(store, null);
|
|
@@ -26,7 +28,7 @@ export class SessionPersistence {
|
|
|
26
28
|
throw new Error("session has no resumable turn");
|
|
27
29
|
return Object.freeze({
|
|
28
30
|
conversation,
|
|
29
|
-
persistence: new SessionPersistence(store, id, lease),
|
|
31
|
+
persistence: new SessionPersistence(store, id, lease, snapshot),
|
|
30
32
|
});
|
|
31
33
|
}
|
|
32
34
|
catch (error) {
|
|
@@ -51,9 +53,14 @@ export class SessionPersistence {
|
|
|
51
53
|
const published = await this.#store.publish(conversation, true);
|
|
52
54
|
this.#lease = published.lease;
|
|
53
55
|
this.#sessionId = published.meta.id;
|
|
56
|
+
this.#snapshot = published;
|
|
54
57
|
return;
|
|
55
58
|
}
|
|
56
|
-
|
|
59
|
+
if (this.#lease === undefined || this.#snapshot === undefined) {
|
|
60
|
+
throw new Error("session persistence has no verified owner snapshot");
|
|
61
|
+
}
|
|
62
|
+
await this.#lease.assertOwned();
|
|
63
|
+
this.#snapshot = await this.#store.checkpoint(this.#snapshot, conversation);
|
|
57
64
|
}
|
|
58
65
|
catch (error) {
|
|
59
66
|
this.#failure = error;
|
|
@@ -64,6 +71,7 @@ export class SessionPersistence {
|
|
|
64
71
|
await this.#lease?.close();
|
|
65
72
|
this.#lease = undefined;
|
|
66
73
|
this.#sessionId = null;
|
|
74
|
+
this.#snapshot = undefined;
|
|
67
75
|
this.#failure = undefined;
|
|
68
76
|
}
|
|
69
77
|
async close() {
|
package/dist/sessions/store.js
CHANGED
|
@@ -105,15 +105,18 @@ export class DurableSessionStore {
|
|
|
105
105
|
if (!replacesHead && !extendsTree) {
|
|
106
106
|
throw new Error("session checkpoint cannot be recovered safely");
|
|
107
107
|
}
|
|
108
|
-
head = {
|
|
108
|
+
head = Object.freeze({
|
|
109
109
|
version: SESSION_SCHEMA,
|
|
110
110
|
sequence: candidate.sequence,
|
|
111
111
|
nodeId: candidate.node.id,
|
|
112
112
|
parentId: candidate.node.parentId,
|
|
113
113
|
revision: candidate.node.revision,
|
|
114
114
|
updatedAt: candidate.updatedAt,
|
|
115
|
-
};
|
|
116
|
-
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
115
|
+
});
|
|
116
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
117
|
+
mode: FILE_MODE,
|
|
118
|
+
validate: async () => assertDirectory(directory),
|
|
119
|
+
});
|
|
117
120
|
}
|
|
118
121
|
const nodes = stored.map((entry) => entry.node);
|
|
119
122
|
const conversation = ConversationTree.restore(nodes, head.nodeId);
|
|
@@ -129,21 +132,21 @@ export class DurableSessionStore {
|
|
|
129
132
|
const token = claim === true ? leaseToken() : undefined;
|
|
130
133
|
const temporary = path.join(this.#bucket, `.${id}.${randomUUID()}.tmp`);
|
|
131
134
|
const target = this.#sessionDirectory(id);
|
|
132
|
-
const meta = {
|
|
135
|
+
const meta = Object.freeze({
|
|
133
136
|
version: SESSION_SCHEMA,
|
|
134
137
|
id,
|
|
135
138
|
workspaceRoot: this.workspaceRoot,
|
|
136
139
|
workspaceDigest: this.workspaceDigest,
|
|
137
140
|
createdAt: now,
|
|
138
|
-
};
|
|
139
|
-
const head = {
|
|
141
|
+
});
|
|
142
|
+
const head = Object.freeze({
|
|
140
143
|
version: SESSION_SCHEMA,
|
|
141
144
|
sequence: conversation.nodes.length,
|
|
142
145
|
nodeId: active.id,
|
|
143
146
|
parentId: active.parentId,
|
|
144
147
|
revision: active.revision,
|
|
145
148
|
updatedAt: now,
|
|
146
|
-
};
|
|
149
|
+
});
|
|
147
150
|
try {
|
|
148
151
|
await makePrivateDirectory(temporary);
|
|
149
152
|
const nodes = path.join(temporary, "nodes");
|
|
@@ -169,12 +172,26 @@ export class DurableSessionStore {
|
|
|
169
172
|
const lease = sessionLease(id, path.join(target, "active"), token);
|
|
170
173
|
return Object.freeze({ ...snapshot, lease });
|
|
171
174
|
}
|
|
172
|
-
async checkpoint(
|
|
173
|
-
|
|
175
|
+
async checkpoint(previous, conversation) {
|
|
176
|
+
assertSnapshot(previous, this.workspaceRoot, this.workspaceDigest);
|
|
177
|
+
const id = previous.meta.id;
|
|
178
|
+
const directory = this.#sessionDirectory(id);
|
|
179
|
+
await assertDirectory(directory);
|
|
180
|
+
const nodesDirectory = path.join(directory, "nodes");
|
|
181
|
+
const validateNodesDirectory = async () => {
|
|
182
|
+
await assertDirectory(directory);
|
|
183
|
+
await assertDirectory(nodesDirectory);
|
|
184
|
+
};
|
|
185
|
+
await validateNodesDirectory();
|
|
186
|
+
const currentHead = decodeHead(await readJson(path.join(directory, "head.json"), SESSION_FILE_LIMITS.metadataBytes));
|
|
187
|
+
if (!sameHead(currentHead, previous.head)) {
|
|
188
|
+
throw new Error("session head changed after its verified snapshot");
|
|
189
|
+
}
|
|
174
190
|
const active = conversation.activeNode;
|
|
175
191
|
if (active === undefined)
|
|
176
192
|
throw new Error("an empty conversation cannot be checkpointed");
|
|
177
|
-
if (
|
|
193
|
+
if (active === previous.conversation.activeNode &&
|
|
194
|
+
conversation.nodes === previous.conversation.nodes) {
|
|
178
195
|
return Object.freeze({ ...previous, conversation });
|
|
179
196
|
}
|
|
180
197
|
const replacesHead = active.id === previous.head.nodeId &&
|
|
@@ -187,18 +204,23 @@ export class DurableSessionStore {
|
|
|
187
204
|
throw new Error("session checkpoint does not extend its durable tree");
|
|
188
205
|
}
|
|
189
206
|
assertSharedNodes(previous.conversation, conversation, replacesHead ? active.id : undefined);
|
|
207
|
+
if (extendsTree) {
|
|
208
|
+
await assertMissingNode(path.join(nodesDirectory, nodeName(active.id)));
|
|
209
|
+
}
|
|
190
210
|
const now = new Date().toISOString();
|
|
191
|
-
const head = {
|
|
211
|
+
const head = Object.freeze({
|
|
192
212
|
version: SESSION_SCHEMA,
|
|
193
213
|
sequence: previous.head.sequence + 1,
|
|
194
214
|
nodeId: active.id,
|
|
195
215
|
parentId: active.parentId,
|
|
196
216
|
revision: active.revision,
|
|
197
217
|
updatedAt: now,
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
await atomicWrite(path.join(directory, "
|
|
201
|
-
|
|
218
|
+
});
|
|
219
|
+
await atomicWrite(path.join(nodesDirectory, nodeName(active.id)), encodeNode(active, head.sequence, now), { mode: FILE_MODE, validate: async () => validateNodesDirectory() });
|
|
220
|
+
await atomicWrite(path.join(directory, "head.json"), encodeHead(head), {
|
|
221
|
+
mode: FILE_MODE,
|
|
222
|
+
validate: async () => assertDirectory(directory),
|
|
223
|
+
});
|
|
202
224
|
return Object.freeze({ meta: previous.meta, head, conversation });
|
|
203
225
|
}
|
|
204
226
|
async claim(id) {
|
|
@@ -271,17 +293,44 @@ function assertSharedNodes(previous, next, replacedId) {
|
|
|
271
293
|
if (node.id === replacedId)
|
|
272
294
|
continue;
|
|
273
295
|
const candidate = next.node(node.id);
|
|
274
|
-
if (candidate
|
|
296
|
+
if (candidate !== node) {
|
|
275
297
|
throw new Error("session checkpoint rewrites prior conversation history");
|
|
276
298
|
}
|
|
277
299
|
}
|
|
278
300
|
}
|
|
279
|
-
function
|
|
280
|
-
|
|
281
|
-
|
|
301
|
+
function assertSnapshot(snapshot, workspaceRoot, workspaceDigest) {
|
|
302
|
+
encodeMeta(snapshot.meta);
|
|
303
|
+
encodeHead(snapshot.head);
|
|
304
|
+
assertSessionId(snapshot.meta.id);
|
|
305
|
+
if (snapshot.meta.workspaceDigest !== workspaceDigest ||
|
|
306
|
+
workspaceKey(snapshot.meta.workspaceRoot) !== workspaceKey(workspaceRoot))
|
|
307
|
+
throw new Error("session snapshot belongs to a different workspace");
|
|
308
|
+
const active = snapshot.conversation.activeNode;
|
|
309
|
+
if (active === undefined ||
|
|
310
|
+
active.id !== snapshot.head.nodeId ||
|
|
311
|
+
active.parentId !== snapshot.head.parentId ||
|
|
312
|
+
active.revision !== snapshot.head.revision ||
|
|
313
|
+
snapshot.head.sequence < snapshot.conversation.nodes.length)
|
|
314
|
+
throw new Error("session snapshot does not match its verified head");
|
|
315
|
+
}
|
|
316
|
+
function sameHead(left, right) {
|
|
317
|
+
return left.version === right.version &&
|
|
318
|
+
left.sequence === right.sequence &&
|
|
319
|
+
left.nodeId === right.nodeId &&
|
|
320
|
+
left.parentId === right.parentId &&
|
|
321
|
+
left.revision === right.revision &&
|
|
322
|
+
left.updatedAt === right.updatedAt;
|
|
282
323
|
}
|
|
283
|
-
function
|
|
284
|
-
|
|
324
|
+
async function assertMissingNode(file) {
|
|
325
|
+
try {
|
|
326
|
+
await lstat(file);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error.code === "ENOENT")
|
|
330
|
+
return;
|
|
331
|
+
throw error;
|
|
332
|
+
}
|
|
333
|
+
throw new Error("session has an incomplete node outside its verified snapshot");
|
|
285
334
|
}
|
|
286
335
|
async function readNodes(directory) {
|
|
287
336
|
await assertDirectory(directory);
|
package/dist/settings.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
// Persistent, non-secret defaults for interactive and batch sessions.
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
3
2
|
import { chmod, mkdir } from "node:fs/promises";
|
|
4
3
|
import * as path from "node:path";
|
|
5
4
|
import { atomicWrite } from "./atomic.js";
|
|
@@ -9,6 +8,7 @@ import { providerNames } from "./providers/index.js";
|
|
|
9
8
|
import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
|
|
10
9
|
import { withStoreLock } from "./store-lock.js";
|
|
11
10
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
11
|
+
import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
|
|
12
12
|
export { EFFORTS } from "./effort.js";
|
|
13
13
|
let saved;
|
|
14
14
|
export function readSettings() {
|
|
@@ -24,7 +24,9 @@ export async function updateSettings(patch) {
|
|
|
24
24
|
await chmod(directory, 0o700);
|
|
25
25
|
return withStoreLock(file, async () => {
|
|
26
26
|
const next = normalize({ ...readStore(file), ...patch });
|
|
27
|
-
|
|
27
|
+
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
28
|
+
assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
|
|
29
|
+
await atomicWrite(file, text, { mode: 0o600 });
|
|
28
30
|
saved = next;
|
|
29
31
|
return file;
|
|
30
32
|
});
|
|
@@ -41,7 +43,7 @@ export function reloadSettings() {
|
|
|
41
43
|
}
|
|
42
44
|
function readStore(file = settingsPath()) {
|
|
43
45
|
try {
|
|
44
|
-
return normalize(
|
|
46
|
+
return normalize(readBoundedJsonSync(file, USER_STORE_LIMITS.settingsBytes));
|
|
45
47
|
}
|
|
46
48
|
catch {
|
|
47
49
|
// Missing, unreadable, and malformed stores all fall back safely. A bad
|
|
@@ -73,14 +75,14 @@ function normalize(value) {
|
|
|
73
75
|
function modelsOf(value, providers) {
|
|
74
76
|
if (!record(value))
|
|
75
77
|
return undefined;
|
|
76
|
-
const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) &&
|
|
78
|
+
const models = Object.fromEntries(Object.entries(value).filter((entry) => providers.includes(entry[0]) && boundedNonempty(entry[1], USER_STORE_LIMITS.model)));
|
|
77
79
|
return Object.keys(models).length === 0 ? undefined : models;
|
|
78
80
|
}
|
|
79
81
|
function member(value, values) {
|
|
80
82
|
return typeof value === "string" && values.includes(value) ? value : undefined;
|
|
81
83
|
}
|
|
82
84
|
function endpoint(value) {
|
|
83
|
-
if (
|
|
85
|
+
if (!boundedNonempty(value, USER_STORE_LIMITS.endpoint))
|
|
84
86
|
return undefined;
|
|
85
87
|
try {
|
|
86
88
|
return parseOllamaEndpoint(value).baseUrl;
|
|
@@ -89,6 +91,9 @@ function endpoint(value) {
|
|
|
89
91
|
return undefined;
|
|
90
92
|
}
|
|
91
93
|
}
|
|
94
|
+
function boundedNonempty(value, max) {
|
|
95
|
+
return typeof value === "string" && value.length <= max && value.trim() !== "";
|
|
96
|
+
}
|
|
92
97
|
function positiveInteger(value) {
|
|
93
98
|
return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
|
|
94
99
|
}
|