@giovannijecha/jecode 0.8.4 → 0.8.5
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 +8 -6
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch.js +37 -4
- package/dist/bounded-file.js +212 -0
- package/dist/commands.js +2 -0
- package/dist/config.js +2 -1
- package/dist/context/automatic.js +35 -0
- package/dist/context/compactor.js +32 -2
- package/dist/context/manual.js +20 -3
- package/dist/context/request-projection.js +130 -0
- package/dist/controller-request.js +33 -11
- package/dist/credential-commands.js +22 -6
- package/dist/credentials.js +56 -18
- package/dist/directory-anchor.js +91 -0
- package/dist/file-identity.js +12 -0
- package/dist/model-command.js +5 -4
- package/dist/openai-account-command.js +13 -2
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +11 -5
- package/dist/provider-errors.js +37 -4
- package/dist/provider-label.js +13 -0
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -19
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +82 -23
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +31 -19
- package/dist/providers/openai-codex.js +67 -41
- package/dist/providers/openai-stream.js +26 -2
- package/dist/providers/openai.js +51 -24
- package/dist/providers/sse.js +52 -8
- package/dist/request-identity.js +32 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/store.js +366 -142
- package/dist/settings.js +62 -10
- package/dist/stable-directory.js +148 -0
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/fs.js +124 -102
- package/dist/tools/search.js +65 -100
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +32 -4
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +3 -4
- package/dist/tools/ripgrep.js +0 -230
package/README.md
CHANGED
|
@@ -37,11 +37,12 @@ follow tool calls and diffs, steer the model while it runs, and resume the same
|
|
|
37
37
|
conversation later. One controller carries each turn from prompt to result—no
|
|
38
38
|
delegated agents or hidden model workers.
|
|
39
39
|
|
|
40
|
-
Choose Anthropic, OpenAI, an eligible ChatGPT account, or Ollama without
|
|
41
|
-
changing the workflow.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
and
|
|
40
|
+
Choose Anthropic API, OpenAI API, an eligible ChatGPT account, or Ollama without
|
|
41
|
+
changing the workflow. API connections and account access stay separate, and
|
|
42
|
+
the footer always names the route selected for the next request. Jecode is
|
|
43
|
+
written in TypeScript and released as plain JavaScript for Node.js, with no
|
|
44
|
+
installation scripts and zero third-party runtime dependencies. The runtime
|
|
45
|
+
stays small enough to inspect, understand, and change.
|
|
45
46
|
|
|
46
47
|
## Quick start
|
|
47
48
|
|
|
@@ -71,7 +72,8 @@ jecode
|
|
|
71
72
|
```
|
|
72
73
|
|
|
73
74
|
Jecode opens directly on an empty composer. Use `/providers` to connect a
|
|
74
|
-
service
|
|
75
|
+
service and `/models` to select both the model and the connection that will run
|
|
76
|
+
it, then describe the work you want done.
|
|
75
77
|
|
|
76
78
|
```text
|
|
77
79
|
Review this project, explain its architecture, and propose the smallest safe
|
package/dist/accounts.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// OAuth accounts persisted under ~/.jecode, apart from API keys.
|
|
2
|
-
import { chmod, mkdir } from "node:fs/promises";
|
|
3
2
|
import * as path from "node:path";
|
|
4
3
|
import { atomicWrite } from "./atomic.js";
|
|
4
|
+
import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
|
|
5
5
|
import { withStoreLock } from "./store-lock.js";
|
|
6
6
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
7
|
-
import { assertStoreText, readBoundedJsonSync, USER_STORE_LIMITS } from "./user-store.js";
|
|
7
|
+
import { assertStoreText, readBoundedJsonForMutationSync, readBoundedJsonSync, USER_STORE_LIMITS, } from "./user-store.js";
|
|
8
8
|
let cached;
|
|
9
9
|
export function openAICodexAccount() {
|
|
10
10
|
const account = store().accounts["openai-codex"];
|
|
@@ -33,11 +33,10 @@ export function accountsLabel() {
|
|
|
33
33
|
export async function updateOpenAICodexAccount(change, signal) {
|
|
34
34
|
const file = accountsPath();
|
|
35
35
|
const directory = path.dirname(file);
|
|
36
|
-
await
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const current = readStore(file);
|
|
36
|
+
const anchor = await preparePrivateDirectory(directory, "account store directory");
|
|
37
|
+
const anchoredFile = path.join(anchor.path, path.basename(file));
|
|
38
|
+
return withStoreLock(anchoredFile, async () => {
|
|
39
|
+
const current = readStoreForMutation(anchoredFile, anchor);
|
|
41
40
|
const next = await change(current.accounts["openai-codex"]);
|
|
42
41
|
const normalized = next === undefined ? undefined : normalizeOpenAI(next);
|
|
43
42
|
if (next !== undefined && normalized === undefined)
|
|
@@ -50,10 +49,13 @@ export async function updateOpenAICodexAccount(change, signal) {
|
|
|
50
49
|
const updated = { version: 1, accounts };
|
|
51
50
|
const text = `${JSON.stringify(updated, null, 2)}\n`;
|
|
52
51
|
assertStoreText(text, USER_STORE_LIMITS.accountsBytes);
|
|
53
|
-
await atomicWrite(
|
|
52
|
+
await atomicWrite(anchoredFile, text, {
|
|
53
|
+
mode: 0o600,
|
|
54
|
+
validate: async () => assertDirectoryAnchor(anchor),
|
|
55
|
+
});
|
|
54
56
|
cached = updated;
|
|
55
57
|
return normalized === undefined ? undefined : { ...normalized };
|
|
56
|
-
}, signal);
|
|
58
|
+
}, signal, async () => assertDirectoryAnchor(anchor));
|
|
57
59
|
}
|
|
58
60
|
export function reloadAccounts() {
|
|
59
61
|
cached = undefined;
|
|
@@ -65,12 +67,43 @@ function store() {
|
|
|
65
67
|
}
|
|
66
68
|
function readStore(file) {
|
|
67
69
|
try {
|
|
68
|
-
|
|
70
|
+
const directory = captureDirectDirectorySync(path.dirname(file), "account store directory");
|
|
71
|
+
const anchoredFile = path.join(directory.path, path.basename(file));
|
|
72
|
+
return normalize(readBoundedJsonSync(anchoredFile, USER_STORE_LIMITS.accountsBytes, directory));
|
|
69
73
|
}
|
|
70
74
|
catch {
|
|
71
75
|
return { version: 1, accounts: {} };
|
|
72
76
|
}
|
|
73
77
|
}
|
|
78
|
+
function readStoreForMutation(file, directory) {
|
|
79
|
+
const value = readBoundedJsonForMutationSync(file, USER_STORE_LIMITS.accountsBytes, "account store", directory);
|
|
80
|
+
if (value === undefined)
|
|
81
|
+
return { version: 1, accounts: {} };
|
|
82
|
+
if (!record(value) || value["version"] !== 1 || !record(value["accounts"]) ||
|
|
83
|
+
hasUnknownKeys(value, ["version", "accounts"])) {
|
|
84
|
+
throw new Error("account store has an unsupported structure");
|
|
85
|
+
}
|
|
86
|
+
const accounts = value["accounts"];
|
|
87
|
+
if (Object.keys(accounts).some((name) => name !== "openai-codex")) {
|
|
88
|
+
throw new Error("account store has an unsupported structure");
|
|
89
|
+
}
|
|
90
|
+
const raw = accounts["openai-codex"];
|
|
91
|
+
if (raw === undefined)
|
|
92
|
+
return { version: 1, accounts: {} };
|
|
93
|
+
if (!record(raw) || hasUnknownKeys(raw, [
|
|
94
|
+
"accessToken",
|
|
95
|
+
"refreshToken",
|
|
96
|
+
"expiresAt",
|
|
97
|
+
"accountId",
|
|
98
|
+
"email",
|
|
99
|
+
"plan",
|
|
100
|
+
]))
|
|
101
|
+
throw new Error("account store has an unsupported structure");
|
|
102
|
+
const account = normalizeOpenAI(raw);
|
|
103
|
+
if (account === undefined)
|
|
104
|
+
throw new Error("account store has an invalid OpenAI account");
|
|
105
|
+
return { version: 1, accounts: { "openai-codex": account } };
|
|
106
|
+
}
|
|
74
107
|
function normalize(value) {
|
|
75
108
|
if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
|
|
76
109
|
return { version: 1, accounts: {} };
|
|
@@ -112,3 +145,7 @@ function nonempty(value, max) {
|
|
|
112
145
|
function record(value) {
|
|
113
146
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
114
147
|
}
|
|
148
|
+
function hasUnknownKeys(value, known) {
|
|
149
|
+
const allowed = new Set(known);
|
|
150
|
+
return Object.keys(value).some((key) => !allowed.has(key));
|
|
151
|
+
}
|
package/dist/atomic.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
//
|
|
2
|
-
// rename.
|
|
1
|
+
// Process-crash-safe replacement of one file, using a verified temporary
|
|
2
|
+
// sibling and rename. POSIX also receives a best-effort parent-directory sync.
|
|
3
3
|
import { lstat, open, rename, rm, stat } from "node:fs/promises";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
5
|
import * as path from "node:path";
|
|
6
|
+
import { fileIdentity, sameFileIdentity } from "./file-identity.js";
|
|
6
7
|
export async function atomicWrite(file, content, options = {}) {
|
|
7
8
|
const temporary = path.join(path.dirname(file), `.${path.basename(file)}.${process.pid}.${randomUUID()}.tmp`);
|
|
8
9
|
let handle;
|
|
@@ -14,7 +15,7 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
14
15
|
const permissions = options.mode ?? (await existingMode(file));
|
|
15
16
|
throwIfAborted(options.signal);
|
|
16
17
|
handle = await open(temporary, "wx", permissions);
|
|
17
|
-
identity = fileIdentity(await handle.stat());
|
|
18
|
+
identity = fileIdentity(await handle.stat({ bigint: true }));
|
|
18
19
|
throwIfAborted(options.signal);
|
|
19
20
|
await options.validate?.("before-write");
|
|
20
21
|
throwIfAborted(options.signal);
|
|
@@ -26,7 +27,7 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
26
27
|
}
|
|
27
28
|
await handle.sync();
|
|
28
29
|
throwIfAborted(options.signal);
|
|
29
|
-
identity = fileIdentity(await handle.stat());
|
|
30
|
+
identity = fileIdentity(await handle.stat({ bigint: true }));
|
|
30
31
|
await options.validate?.("before-rename");
|
|
31
32
|
throwIfAborted(options.signal);
|
|
32
33
|
await assertNamedFile(temporary, identity);
|
|
@@ -35,6 +36,7 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
35
36
|
const completed = handle;
|
|
36
37
|
handle = undefined;
|
|
37
38
|
await completed.close().catch(() => undefined);
|
|
39
|
+
await syncParentDirectory(file);
|
|
38
40
|
}
|
|
39
41
|
catch (error) {
|
|
40
42
|
if (handle !== undefined) {
|
|
@@ -47,14 +49,30 @@ export async function atomicWrite(file, content, options = {}) {
|
|
|
47
49
|
throw error;
|
|
48
50
|
}
|
|
49
51
|
}
|
|
52
|
+
async function syncParentDirectory(file) {
|
|
53
|
+
if (process.platform === "win32")
|
|
54
|
+
return;
|
|
55
|
+
let directory;
|
|
56
|
+
try {
|
|
57
|
+
directory = await open(path.dirname(file), "r");
|
|
58
|
+
await directory.sync();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// The rename is already committed and some filesystems do not support
|
|
62
|
+
// directory fsync. Do not report a successful replacement as failed.
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await directory?.close().catch(() => undefined);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
50
68
|
function throwIfAborted(signal) {
|
|
51
69
|
if (signal?.aborted !== true)
|
|
52
70
|
return;
|
|
53
71
|
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
54
72
|
}
|
|
55
73
|
async function assertNamedFile(file, expected) {
|
|
56
|
-
const details = await lstat(file);
|
|
57
|
-
if (details.isSymbolicLink() || !
|
|
74
|
+
const details = await lstat(file, { bigint: true });
|
|
75
|
+
if (details.isSymbolicLink() || !sameFileIdentity(expected, fileIdentity(details))) {
|
|
58
76
|
throw new Error("atomic write target changed during replacement");
|
|
59
77
|
}
|
|
60
78
|
}
|
|
@@ -70,14 +88,6 @@ async function removeTemporary(file, expected, validate) {
|
|
|
70
88
|
// A changed path is no longer ours to remove.
|
|
71
89
|
}
|
|
72
90
|
}
|
|
73
|
-
function fileIdentity(details) {
|
|
74
|
-
return { dev: details.dev, ino: details.ino, birthtimeMs: details.birthtimeMs };
|
|
75
|
-
}
|
|
76
|
-
function sameFile(left, right) {
|
|
77
|
-
if (left.dev !== right.dev || left.ino !== right.ino)
|
|
78
|
-
return false;
|
|
79
|
-
return left.ino !== 0 || left.birthtimeMs === right.birthtimeMs;
|
|
80
|
-
}
|
|
81
91
|
async function existingMode(file) {
|
|
82
92
|
if (process.platform === "win32")
|
|
83
93
|
return undefined;
|
package/dist/batch.js
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { stdin, stdout } from "node:process";
|
|
6
6
|
import { runTurn } from "./controller.js";
|
|
7
7
|
import { resolveContextPolicy } from "./context/capacity.js";
|
|
8
|
+
import { automaticCompactionGate, automaticCompactionKey, } from "./context/automatic.js";
|
|
8
9
|
import { compactContext } from "./context/compactor.js";
|
|
9
10
|
import { compactSession } from "./context/manual.js";
|
|
10
11
|
import { isContextOverflow } from "./context/policy.js";
|
|
@@ -15,12 +16,15 @@ import { terminalText } from "./ui/terminal-text.js";
|
|
|
15
16
|
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "./usage.js";
|
|
16
17
|
import { assertPromptLength, boundedInputLines } from "./input-boundary.js";
|
|
17
18
|
import { providerFailure } from "./provider-errors.js";
|
|
19
|
+
import { toolSpecs } from "./tools/index.js";
|
|
20
|
+
import { requestIdentityForSession } from "./request-identity.js";
|
|
18
21
|
export async function runBatch(session, environment = {}) {
|
|
19
22
|
const write = environment.write ?? ((text) => stdout.write(text));
|
|
20
23
|
const width = environment.width ?? columns();
|
|
21
24
|
const signal = environment.signal;
|
|
22
25
|
const source = environment.lines ?? boundedInputLines(stdin);
|
|
23
26
|
const lines = abortableLines(source, signal);
|
|
27
|
+
const automaticCompaction = automaticCompactionGate();
|
|
24
28
|
const emit = (block) => {
|
|
25
29
|
for (const line of renderBatch(block, width, session.palette))
|
|
26
30
|
write(`${line}\n`);
|
|
@@ -37,7 +41,15 @@ export async function runBatch(session, environment = {}) {
|
|
|
37
41
|
if ((await handleCommand(line, session, {
|
|
38
42
|
emit,
|
|
39
43
|
signal,
|
|
40
|
-
|
|
44
|
+
reset: () => {
|
|
45
|
+
automaticCompaction.reset();
|
|
46
|
+
},
|
|
47
|
+
compact: async () => {
|
|
48
|
+
const result = await compactSession(session, { signal });
|
|
49
|
+
if (result === "compacted")
|
|
50
|
+
automaticCompaction.reset();
|
|
51
|
+
return result;
|
|
52
|
+
},
|
|
41
53
|
})) === "exit")
|
|
42
54
|
break;
|
|
43
55
|
continue;
|
|
@@ -62,6 +74,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
62
74
|
history.push(user);
|
|
63
75
|
modelHistory.push(structuredClone(user));
|
|
64
76
|
const turn = events(emit, session);
|
|
77
|
+
const specs = toolSpecs(session.tools);
|
|
65
78
|
const commit = (checkpoint, settlement) => {
|
|
66
79
|
session.conversation = session.conversation.commit({
|
|
67
80
|
...(nodeId === undefined ? {} : { nodeId }),
|
|
@@ -83,7 +96,12 @@ export async function runBatch(session, environment = {}) {
|
|
|
83
96
|
(request.error === undefined || !isContextOverflow(request.error))) {
|
|
84
97
|
return undefined;
|
|
85
98
|
}
|
|
86
|
-
const force = request.reason === "overflow";
|
|
99
|
+
const force = request.reason === "overflow" || request.projectionSaturated;
|
|
100
|
+
const key = automaticCompactionKey(session.provider.id, session.model, nodeId ?? prospectiveNodeId, checkpoint.length);
|
|
101
|
+
const attempt = { key, reason: request.reason };
|
|
102
|
+
if (!automaticCompaction.allows(attempt))
|
|
103
|
+
return undefined;
|
|
104
|
+
let attempted = false;
|
|
87
105
|
const result = await compactContext({
|
|
88
106
|
provider: session.provider,
|
|
89
107
|
model: session.model,
|
|
@@ -96,9 +114,22 @@ export async function runBatch(session, environment = {}) {
|
|
|
96
114
|
estimatedInputTokens: request.inputTokens,
|
|
97
115
|
force,
|
|
98
116
|
policy: request.policy,
|
|
117
|
+
requestEnvelope: {
|
|
118
|
+
system: session.system,
|
|
119
|
+
tools: specs,
|
|
120
|
+
maxOutputTokens: session.config.maxTokens,
|
|
121
|
+
},
|
|
122
|
+
requestIdentity: requestIdentityForSession(session),
|
|
123
|
+
onBegin: () => {
|
|
124
|
+
attempted = true;
|
|
125
|
+
},
|
|
99
126
|
});
|
|
100
|
-
if (result === undefined)
|
|
127
|
+
if (result === undefined) {
|
|
128
|
+
if (attempted && signal?.aborted !== true)
|
|
129
|
+
automaticCompaction.failed(attempt);
|
|
101
130
|
return undefined;
|
|
131
|
+
}
|
|
132
|
+
automaticCompaction.succeeded(attempt);
|
|
102
133
|
context = result.anchor;
|
|
103
134
|
if (result.usage !== undefined)
|
|
104
135
|
recordAuxiliaryUsage(session.usage, result.usage);
|
|
@@ -111,6 +142,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
111
142
|
reason: "budget",
|
|
112
143
|
policy: await policy(),
|
|
113
144
|
inputTokens: session.usage.lastInputTokens,
|
|
145
|
+
projectionSaturated: false,
|
|
114
146
|
});
|
|
115
147
|
if (compacted !== undefined)
|
|
116
148
|
commit(checkpoint, settlement);
|
|
@@ -123,7 +155,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
123
155
|
throwIfAborted(signal);
|
|
124
156
|
if (!(error instanceof Error))
|
|
125
157
|
throw error;
|
|
126
|
-
const message = providerFailure(session.provider, error);
|
|
158
|
+
const message = providerFailure(session.provider, error, true);
|
|
127
159
|
if (message === error.message)
|
|
128
160
|
throw error;
|
|
129
161
|
throw new Error(message, { cause: error });
|
|
@@ -186,6 +218,7 @@ function options(session, contextPolicy) {
|
|
|
186
218
|
maxTokens: session.config.maxTokens,
|
|
187
219
|
contextPolicy,
|
|
188
220
|
effort: session.config.effort,
|
|
221
|
+
requestIdentity: requestIdentityForSession(session),
|
|
189
222
|
maxModelRequests: session.config.maxModelRequests,
|
|
190
223
|
toolContext: { root: session.config.root },
|
|
191
224
|
};
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// Descriptor-based reads for untrusted local files. Identity-sensitive
|
|
2
|
+
// metadata stays bigint so NTFS file IDs cannot collide through rounding.
|
|
3
|
+
import { constants } from "node:fs";
|
|
4
|
+
import { closeSync, fstatSync, lstatSync, openSync, readSync, } from "node:fs";
|
|
5
|
+
import { lstat, open } from "node:fs/promises";
|
|
6
|
+
import { fileIdentity, sameFileIdentity } from "./file-identity.js";
|
|
7
|
+
export class BoundedFileError extends Error {
|
|
8
|
+
kind;
|
|
9
|
+
constructor(kind, message) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.name = "BoundedFileError";
|
|
12
|
+
this.kind = kind;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Run one reader against a verified regular-file descriptor, then confirm
|
|
17
|
+
* that the descriptor and named path still identify the same stable file.
|
|
18
|
+
*/
|
|
19
|
+
export async function withStableFile(file, options, reader) {
|
|
20
|
+
const label = options.label ?? "file";
|
|
21
|
+
requireOptionalLimit(options.maxBytes);
|
|
22
|
+
throwIfAborted(options.signal);
|
|
23
|
+
await options.validate?.();
|
|
24
|
+
const before = await lstat(file, { bigint: true });
|
|
25
|
+
requireRegular(before, label);
|
|
26
|
+
requireSize(before, options.maxBytes, label);
|
|
27
|
+
if (options.expected !== undefined && !sameExpectation(options.expected, before)) {
|
|
28
|
+
throw changed(label);
|
|
29
|
+
}
|
|
30
|
+
await options.beforeOpen?.();
|
|
31
|
+
await options.validate?.();
|
|
32
|
+
throwIfAborted(options.signal);
|
|
33
|
+
let handle;
|
|
34
|
+
try {
|
|
35
|
+
handle = await open(file, readFlags());
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
if (unsafeOpenError(error))
|
|
39
|
+
throw changed(label);
|
|
40
|
+
throw error;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const opened = await handle.stat({ bigint: true });
|
|
44
|
+
requireRegular(opened, label);
|
|
45
|
+
requireSize(opened, options.maxBytes, label);
|
|
46
|
+
if (!sameIdentity(before, opened))
|
|
47
|
+
throw changed(label);
|
|
48
|
+
await options.validate?.();
|
|
49
|
+
const value = await reader(handle, opened);
|
|
50
|
+
throwIfAborted(options.signal);
|
|
51
|
+
await options.validate?.();
|
|
52
|
+
const after = await handle.stat({ bigint: true });
|
|
53
|
+
let linked;
|
|
54
|
+
try {
|
|
55
|
+
linked = await lstat(file, { bigint: true });
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
if (unsafeOpenError(error))
|
|
59
|
+
throw changed(label);
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
if (!stableMetadata(opened, after) || !sameIdentity(opened, after) ||
|
|
63
|
+
linked.isSymbolicLink() || !linked.isFile() ||
|
|
64
|
+
!stableMetadata(opened, linked) || !sameIdentity(opened, linked))
|
|
65
|
+
throw changed(label);
|
|
66
|
+
await options.validate?.();
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
await handle.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Open, verify, and read one complete bounded regular file. */
|
|
74
|
+
export async function readBoundedFile(file, maxBytes, options = {}) {
|
|
75
|
+
requireLimit(maxBytes);
|
|
76
|
+
return withStableFile(file, { ...options, maxBytes }, async (handle, opened) => {
|
|
77
|
+
const expected = Number(opened.size);
|
|
78
|
+
const capacity = Math.min(maxBytes + 1, Math.max(1, expected + 1));
|
|
79
|
+
const bytes = Buffer.allocUnsafe(capacity);
|
|
80
|
+
let offset = 0;
|
|
81
|
+
while (offset < capacity) {
|
|
82
|
+
throwIfAborted(options.signal);
|
|
83
|
+
const read = await handle.read(bytes, offset, capacity - offset, offset);
|
|
84
|
+
if (read.bytesRead === 0)
|
|
85
|
+
break;
|
|
86
|
+
offset += read.bytesRead;
|
|
87
|
+
}
|
|
88
|
+
if (offset > maxBytes || offset !== expected)
|
|
89
|
+
throw changed(options.label ?? "file");
|
|
90
|
+
return bytes.subarray(0, offset);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export async function readBoundedText(file, maxBytes, options = {}) {
|
|
94
|
+
return (await readBoundedFile(file, maxBytes, options)).toString("utf8");
|
|
95
|
+
}
|
|
96
|
+
export function stableFileExpectation(details) {
|
|
97
|
+
return Object.freeze({
|
|
98
|
+
identity: fileIdentity(details),
|
|
99
|
+
size: details.size,
|
|
100
|
+
mtimeNs: details.mtimeNs,
|
|
101
|
+
ctimeNs: details.ctimeNs,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
/** Synchronous companion for tiny startup stores whose public APIs are sync. */
|
|
105
|
+
export function readBoundedFileSync(file, maxBytes, options = "file") {
|
|
106
|
+
requireLimit(maxBytes);
|
|
107
|
+
const normalized = typeof options === "string" ? { label: options } : options;
|
|
108
|
+
const label = normalized.label ?? "file";
|
|
109
|
+
normalized.validate?.();
|
|
110
|
+
const before = lstatSync(file, { bigint: true });
|
|
111
|
+
requireRegular(before, label);
|
|
112
|
+
requireSize(before, maxBytes, label);
|
|
113
|
+
normalized.beforeOpen?.();
|
|
114
|
+
normalized.validate?.();
|
|
115
|
+
let descriptor;
|
|
116
|
+
try {
|
|
117
|
+
descriptor = openSync(file, readFlags());
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
if (unsafeOpenError(error))
|
|
121
|
+
throw changed(label);
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
try {
|
|
125
|
+
const opened = fstatSync(descriptor, { bigint: true });
|
|
126
|
+
requireRegular(opened, label);
|
|
127
|
+
requireSize(opened, maxBytes, label);
|
|
128
|
+
if (!sameIdentity(before, opened))
|
|
129
|
+
throw changed(label);
|
|
130
|
+
normalized.validate?.();
|
|
131
|
+
const expected = Number(opened.size);
|
|
132
|
+
const capacity = Math.min(maxBytes + 1, Math.max(1, expected + 1));
|
|
133
|
+
const bytes = Buffer.allocUnsafe(capacity);
|
|
134
|
+
let offset = 0;
|
|
135
|
+
while (offset < capacity) {
|
|
136
|
+
const count = readSync(descriptor, bytes, offset, capacity - offset, offset);
|
|
137
|
+
if (count === 0)
|
|
138
|
+
break;
|
|
139
|
+
offset += count;
|
|
140
|
+
}
|
|
141
|
+
normalized.afterRead?.();
|
|
142
|
+
const after = fstatSync(descriptor, { bigint: true });
|
|
143
|
+
let linked;
|
|
144
|
+
try {
|
|
145
|
+
linked = lstatSync(file, { bigint: true });
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (unsafeOpenError(error))
|
|
149
|
+
throw changed(label);
|
|
150
|
+
throw error;
|
|
151
|
+
}
|
|
152
|
+
if (offset > maxBytes || offset !== expected ||
|
|
153
|
+
!stableMetadata(opened, after) || !sameIdentity(opened, after) ||
|
|
154
|
+
linked.isSymbolicLink() || !linked.isFile() ||
|
|
155
|
+
!stableMetadata(opened, linked) || !sameIdentity(opened, linked))
|
|
156
|
+
throw changed(label);
|
|
157
|
+
normalized.validate?.();
|
|
158
|
+
return bytes.subarray(0, offset);
|
|
159
|
+
}
|
|
160
|
+
finally {
|
|
161
|
+
closeSync(descriptor);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function readFlags() {
|
|
165
|
+
return process.platform === "win32"
|
|
166
|
+
? "r"
|
|
167
|
+
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0) | (constants.O_NOFOLLOW ?? 0);
|
|
168
|
+
}
|
|
169
|
+
function requireRegular(details, label) {
|
|
170
|
+
if (details.isSymbolicLink() || !details.isFile()) {
|
|
171
|
+
throw new BoundedFileError("unsafe", `${label} must be a regular file`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function requireSize(details, maxBytes, label) {
|
|
175
|
+
if (details.size < 0n || (maxBytes !== undefined && details.size > BigInt(maxBytes))) {
|
|
176
|
+
throw new BoundedFileError("too-large", `${label} exceeds ${maxBytes} bytes`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
function requireOptionalLimit(maxBytes) {
|
|
180
|
+
if (maxBytes !== undefined)
|
|
181
|
+
requireLimit(maxBytes);
|
|
182
|
+
}
|
|
183
|
+
function requireLimit(maxBytes) {
|
|
184
|
+
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0 || maxBytes >= Number.MAX_SAFE_INTEGER) {
|
|
185
|
+
throw new RangeError("bounded file limit must be a non-negative safe integer");
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function sameIdentity(left, right) {
|
|
189
|
+
return sameFileIdentity(fileIdentity(left), fileIdentity(right));
|
|
190
|
+
}
|
|
191
|
+
function stableMetadata(left, right) {
|
|
192
|
+
return left.size === right.size && left.mtimeNs === right.mtimeNs &&
|
|
193
|
+
left.ctimeNs === right.ctimeNs;
|
|
194
|
+
}
|
|
195
|
+
function sameExpectation(expected, current) {
|
|
196
|
+
return sameFileIdentity(expected.identity, fileIdentity(current)) &&
|
|
197
|
+
expected.size === current.size && expected.mtimeNs === current.mtimeNs &&
|
|
198
|
+
expected.ctimeNs === current.ctimeNs;
|
|
199
|
+
}
|
|
200
|
+
function unsafeOpenError(error) {
|
|
201
|
+
const code = error.code;
|
|
202
|
+
return code === "ENOENT" || code === "ELOOP" || code === "EISDIR" ||
|
|
203
|
+
code === "ENXIO" || code === "ENOTDIR";
|
|
204
|
+
}
|
|
205
|
+
function changed(label) {
|
|
206
|
+
return new BoundedFileError("changed", `${label} changed while it was being read`);
|
|
207
|
+
}
|
|
208
|
+
function throwIfAborted(signal) {
|
|
209
|
+
if (signal?.aborted !== true)
|
|
210
|
+
return;
|
|
211
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
|
|
212
|
+
}
|
package/dist/commands.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// slash commands never become user messages. This file keeps discovery,
|
|
5
5
|
// dispatch, and session operations outside the canonical transcript.
|
|
6
6
|
import { ConversationTree } from "./conversation.js";
|
|
7
|
+
import { resetRequestIdentity } from "./request-identity.js";
|
|
7
8
|
import { modelsCommand } from "./model-command.js";
|
|
8
9
|
import { providersCommand } from "./provider-commands.js";
|
|
9
10
|
import { permissionsCommand } from "./permission-command.js";
|
|
@@ -48,6 +49,7 @@ export async function handleCommand(line, session, host) {
|
|
|
48
49
|
return "exit";
|
|
49
50
|
case "new":
|
|
50
51
|
await host.reset?.();
|
|
52
|
+
resetRequestIdentity(session);
|
|
51
53
|
session.conversation = ConversationTree.empty();
|
|
52
54
|
session.usage = emptyUsage();
|
|
53
55
|
host.emit({ kind: "notice", text: "new session", tone: "info" });
|
package/dist/config.js
CHANGED
|
@@ -36,7 +36,8 @@ export function loadConfig(argv, saved = readSettings()) {
|
|
|
36
36
|
: { ollamaHost: parseOllamaEndpoint(ollamaHost).baseUrl }),
|
|
37
37
|
reducedMotion: bool(flags["reduced-motion"], process.env.JECODE_REDUCED_MOTION, saved.reducedMotion ?? false),
|
|
38
38
|
effort,
|
|
39
|
-
//
|
|
39
|
+
// This is a ceiling, not a target. Request budgeting may lower it to fit
|
|
40
|
+
// the selected model, while provider rate and billing limits still apply.
|
|
40
41
|
maxTokens: toInt(pick(flags["max-tokens"], process.env.JECODE_MAX_TOKENS, String(saved.maxTokens ?? 64000)), "max-tokens"),
|
|
41
42
|
...(maxModelRequests === undefined
|
|
42
43
|
? {}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Process-local breaker for optional automatic compaction. It prevents an
|
|
2
|
+
// unchanged provider projection from opening the same failing summary request
|
|
3
|
+
// at consecutive checkpoint/request boundaries.
|
|
4
|
+
const MAX_SETTLED_GENERATIONS = 4_096;
|
|
5
|
+
export function automaticCompactionGate() {
|
|
6
|
+
const settled = new Set();
|
|
7
|
+
return Object.freeze({
|
|
8
|
+
allows(attempt) {
|
|
9
|
+
return !settled.has(settledKey(attempt));
|
|
10
|
+
},
|
|
11
|
+
failed(attempt) {
|
|
12
|
+
remember(settled, settledKey(attempt));
|
|
13
|
+
},
|
|
14
|
+
succeeded(attempt) {
|
|
15
|
+
remember(settled, settledKey(attempt));
|
|
16
|
+
},
|
|
17
|
+
reset() {
|
|
18
|
+
settled.clear();
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
function settledKey(attempt) {
|
|
23
|
+
return `${attempt.reason}\0${attempt.key}`;
|
|
24
|
+
}
|
|
25
|
+
function remember(settled, key) {
|
|
26
|
+
settled.add(key);
|
|
27
|
+
if (settled.size <= MAX_SETTLED_GENERATIONS)
|
|
28
|
+
return;
|
|
29
|
+
const oldest = settled.values().next().value;
|
|
30
|
+
if (oldest !== undefined)
|
|
31
|
+
settled.delete(oldest);
|
|
32
|
+
}
|
|
33
|
+
export function automaticCompactionKey(providerId, model, nodeId, canonicalMessages) {
|
|
34
|
+
return `${providerId}\0${model}\0${nodeId}\0${canonicalMessages}`;
|
|
35
|
+
}
|