@giovannijecha/jecode 0.8.4 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -9
- package/dist/accounts.js +47 -10
- package/dist/atomic.js +24 -14
- package/dist/batch-view.js +27 -2
- package/dist/batch.js +39 -4
- package/dist/bounded-file.js +212 -0
- package/dist/cli-info.js +0 -1
- package/dist/commands.js +2 -0
- package/dist/config.js +16 -7
- 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 +25 -9
- 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 +23 -10
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +13 -4
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +1 -1
- package/dist/process-lease.js +329 -0
- package/dist/provider-commands.js +11 -37
- package/dist/provider-errors.js +31 -7
- package/dist/provider-label.js +9 -3
- package/dist/providers/anthropic-stream.js +4 -1
- package/dist/providers/anthropic-wire.js +8 -3
- package/dist/providers/anthropic.js +39 -20
- package/dist/providers/catalog.js +4 -4
- package/dist/providers/failure.js +181 -0
- package/dist/providers/http.js +82 -23
- package/dist/providers/index.js +1 -5
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama-stream.js +5 -1
- package/dist/providers/ollama.js +36 -158
- package/dist/providers/openai-codex.js +71 -45
- package/dist/providers/openai-stream.js +26 -2
- package/dist/providers/openai.js +51 -25
- package/dist/providers/sse.js +52 -8
- package/dist/request-identity.js +32 -0
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/lease.js +132 -49
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/runtime.js +15 -8
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +183 -378
- package/dist/settings-command.js +10 -5
- package/dist/settings.js +75 -22
- package/dist/stable-directory.js +148 -0
- package/dist/start.js +1 -2
- package/dist/store-lock.js +68 -84
- package/dist/tools/args.js +2 -2
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -171
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +66 -205
- package/dist/tools/text-boundary.js +7 -33
- package/dist/tui/app-workflows.js +9 -334
- package/dist/tui/approve.js +5 -3
- package/dist/tui/blocks.js +8 -7
- package/dist/tui/command-workflow.js +106 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/footer.js +1 -1
- package/dist/tui/components/menu.js +74 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/feedback.js +4 -0
- package/dist/tui/help.js +1 -1
- package/dist/tui/picker-layout.js +40 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/session-view.js +7 -2
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +8 -1
- package/dist/tui/transcript-view.js +26 -112
- package/dist/tui/turn-workflow.js +264 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/workflow-types.js +2 -0
- package/dist/tui/workspace.js +21 -7
- package/dist/user-store.js +23 -31
- package/package.json +14 -15
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tools/ripgrep.js +0 -230
- package/dist/tui/motion.js +0 -32
package/README.md
CHANGED
|
@@ -26,9 +26,9 @@
|
|
|
26
26
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
27
27
|
</p>
|
|
28
28
|
|
|
29
|
-
> Jecode is
|
|
30
|
-
>
|
|
31
|
-
>
|
|
29
|
+
> Jecode is stabilizing for 1.0. New capabilities are deferred while correctness,
|
|
30
|
+
> recovery, performance, accessibility, and interface refinements continue within
|
|
31
|
+
> the [compatibility contract](https://github.com/giovannijecha/jecode/blob/main/docs/COMPATIBILITY.md).
|
|
32
32
|
|
|
33
33
|
## Why Jecode
|
|
34
34
|
|
|
@@ -37,11 +37,20 @@ 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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
and
|
|
40
|
+
Tool records keep the target, outcome, and compact evidence together. Expand a
|
|
41
|
+
record to inspect its complete output or diff while the composer stays ready
|
|
42
|
+
at the full width of the terminal.
|
|
43
|
+
|
|
44
|
+
Menus keep the selected choice and its details together. Approvals separate
|
|
45
|
+
the target from the decision and make the scope of remembered access explicit.
|
|
46
|
+
|
|
47
|
+
Choose Anthropic API, OpenAI API, OpenAI Account with eligible ChatGPT access,
|
|
48
|
+
or Ollama API without changing the workflow. Ollama uses its cloud API only.
|
|
49
|
+
API connections and account access stay separate, and
|
|
50
|
+
the footer always names the route selected for the next request. Jecode is
|
|
51
|
+
written in TypeScript and released as plain JavaScript for Node.js, with no
|
|
52
|
+
installation scripts and zero third-party runtime dependencies. The runtime
|
|
53
|
+
stays small enough to inspect, understand, and change.
|
|
45
54
|
|
|
46
55
|
## Quick start
|
|
47
56
|
|
|
@@ -71,7 +80,8 @@ jecode
|
|
|
71
80
|
```
|
|
72
81
|
|
|
73
82
|
Jecode opens directly on an empty composer. Use `/providers` to connect a
|
|
74
|
-
service
|
|
83
|
+
service and `/models` to select both the model and the connection that will run
|
|
84
|
+
it, then describe the work you want done.
|
|
75
85
|
|
|
76
86
|
```text
|
|
77
87
|
Review this project, explain its architecture, and propose the smallest safe
|
|
@@ -164,6 +174,15 @@ The [user guide](https://github.com/giovannijecha/jecode/blob/main/docs/USAGE.md
|
|
|
164
174
|
covers provider access, TUI controls, sessions and compaction, batch mode,
|
|
165
175
|
configuration, and safety boundaries.
|
|
166
176
|
|
|
177
|
+
The [architecture guide](https://github.com/giovannijecha/jecode/blob/main/docs/ARCHITECTURE.md)
|
|
178
|
+
explains the current implementation and module boundaries. Maintainers use the
|
|
179
|
+
[release guide](https://github.com/giovannijecha/jecode/blob/main/docs/RELEASING.md)
|
|
180
|
+
for publishing and release recovery.
|
|
181
|
+
|
|
182
|
+
The [development environments](https://github.com/giovannijecha/jecode/blob/main/dev/README.md)
|
|
183
|
+
provide the TUI lab, current interface direction, and manual performance probes
|
|
184
|
+
for working from a source checkout.
|
|
185
|
+
|
|
167
186
|
## Community
|
|
168
187
|
|
|
169
188
|
- Ask questions, share workflows, and explore early ideas in
|
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-view.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
// Project semantic blocks into a compact transcript for pipes.
|
|
2
|
-
import {
|
|
2
|
+
import { renderAnswer, renderReasoning, renderUser } from "./tui/components/messages.js";
|
|
3
|
+
import { renderNotice } from "./tui/components/misc.js";
|
|
4
|
+
import { renderDetail } from "./tui/components/tool-evidence.js";
|
|
5
|
+
import { stateInk, stateMark } from "./tui/components/tool-motion.js";
|
|
6
|
+
import { toolDuration } from "./duration.js";
|
|
7
|
+
import { row } from "./ui/render.js";
|
|
3
8
|
export function renderBatch(block, width, pal) {
|
|
4
9
|
const rows = [];
|
|
5
10
|
let lastWasBlank = false;
|
|
6
|
-
for (const rendered of
|
|
11
|
+
for (const rendered of batchRows(block, width, pal)) {
|
|
7
12
|
const line = rendered.trimEnd();
|
|
8
13
|
const blank = line === "";
|
|
9
14
|
if (blank && lastWasBlank)
|
|
@@ -15,3 +20,23 @@ export function renderBatch(block, width, pal) {
|
|
|
15
20
|
rows.pop();
|
|
16
21
|
return rows;
|
|
17
22
|
}
|
|
23
|
+
function batchRows(block, width, pal) {
|
|
24
|
+
switch (block.kind) {
|
|
25
|
+
case "answer": return renderAnswer(block, width, pal);
|
|
26
|
+
case "reasoning": return renderReasoning(block, width, pal);
|
|
27
|
+
case "user": return renderUser(block, width, pal);
|
|
28
|
+
case "notice": return renderNotice(block, width, pal);
|
|
29
|
+
case "tool": {
|
|
30
|
+
// Pipes cannot expand records or repaint a running connector.
|
|
31
|
+
const mark = stateMark(block);
|
|
32
|
+
const result = [block.right, block.durationMs === undefined ? "" : toolDuration(block.durationMs)]
|
|
33
|
+
.filter(Boolean).join(" · ");
|
|
34
|
+
return ["", row(width, [
|
|
35
|
+
{ text: `${mark} `, fg: stateInk(block, pal) },
|
|
36
|
+
{ text: block.name, fg: pal.ink.bright },
|
|
37
|
+
{ text: block.target === "" ? "" : ` ${block.target}`, fg: pal.technical },
|
|
38
|
+
], [{ text: result, fg: pal.ink.muted }]),
|
|
39
|
+
...(block.body ?? []).map((detail) => renderDetail(detail, block.tone, width, pal))];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
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;
|
|
@@ -56,12 +68,14 @@ export async function runBatch(session, environment = {}) {
|
|
|
56
68
|
provider: session.provider,
|
|
57
69
|
model: session.model,
|
|
58
70
|
compactionPercent: session.config.compactionPercent,
|
|
71
|
+
signal,
|
|
59
72
|
});
|
|
60
73
|
};
|
|
61
74
|
const user = { role: "user", content: [{ kind: "text", text: line }] };
|
|
62
75
|
history.push(user);
|
|
63
76
|
modelHistory.push(structuredClone(user));
|
|
64
77
|
const turn = events(emit, session);
|
|
78
|
+
const specs = toolSpecs(session.tools);
|
|
65
79
|
const commit = (checkpoint, settlement) => {
|
|
66
80
|
session.conversation = session.conversation.commit({
|
|
67
81
|
...(nodeId === undefined ? {} : { nodeId }),
|
|
@@ -83,11 +97,17 @@ export async function runBatch(session, environment = {}) {
|
|
|
83
97
|
(request.error === undefined || !isContextOverflow(request.error))) {
|
|
84
98
|
return undefined;
|
|
85
99
|
}
|
|
86
|
-
const force = request.reason === "overflow";
|
|
100
|
+
const force = request.reason === "overflow" || request.projectionSaturated;
|
|
101
|
+
const key = automaticCompactionKey(session.provider.id, session.model, nodeId ?? prospectiveNodeId, checkpoint.length);
|
|
102
|
+
const attempt = { key, reason: request.reason };
|
|
103
|
+
if (!automaticCompaction.allows(attempt))
|
|
104
|
+
return undefined;
|
|
105
|
+
let attempted = false;
|
|
87
106
|
const result = await compactContext({
|
|
88
107
|
provider: session.provider,
|
|
89
108
|
model: session.model,
|
|
90
109
|
effort: session.config.effort,
|
|
110
|
+
signal,
|
|
91
111
|
context: projected,
|
|
92
112
|
turn: checkpoint.slice(before),
|
|
93
113
|
nodeId: nodeId ?? prospectiveNodeId,
|
|
@@ -96,9 +116,22 @@ export async function runBatch(session, environment = {}) {
|
|
|
96
116
|
estimatedInputTokens: request.inputTokens,
|
|
97
117
|
force,
|
|
98
118
|
policy: request.policy,
|
|
119
|
+
requestEnvelope: {
|
|
120
|
+
system: session.system,
|
|
121
|
+
tools: specs,
|
|
122
|
+
maxOutputTokens: session.config.maxTokens,
|
|
123
|
+
},
|
|
124
|
+
requestIdentity: requestIdentityForSession(session),
|
|
125
|
+
onBegin: () => {
|
|
126
|
+
attempted = true;
|
|
127
|
+
},
|
|
99
128
|
});
|
|
100
|
-
if (result === undefined)
|
|
129
|
+
if (result === undefined) {
|
|
130
|
+
if (attempted && signal?.aborted !== true)
|
|
131
|
+
automaticCompaction.failed(attempt);
|
|
101
132
|
return undefined;
|
|
133
|
+
}
|
|
134
|
+
automaticCompaction.succeeded(attempt);
|
|
102
135
|
context = result.anchor;
|
|
103
136
|
if (result.usage !== undefined)
|
|
104
137
|
recordAuxiliaryUsage(session.usage, result.usage);
|
|
@@ -111,6 +144,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
111
144
|
reason: "budget",
|
|
112
145
|
policy: await policy(),
|
|
113
146
|
inputTokens: session.usage.lastInputTokens,
|
|
147
|
+
projectionSaturated: false,
|
|
114
148
|
});
|
|
115
149
|
if (compacted !== undefined)
|
|
116
150
|
commit(checkpoint, settlement);
|
|
@@ -123,7 +157,7 @@ export async function runBatch(session, environment = {}) {
|
|
|
123
157
|
throwIfAborted(signal);
|
|
124
158
|
if (!(error instanceof Error))
|
|
125
159
|
throw error;
|
|
126
|
-
const message = providerFailure(session.provider, error);
|
|
160
|
+
const message = providerFailure(session.provider, error, true);
|
|
127
161
|
if (message === error.message)
|
|
128
162
|
throw error;
|
|
129
163
|
throw new Error(message, { cause: error });
|
|
@@ -186,6 +220,7 @@ function options(session, contextPolicy) {
|
|
|
186
220
|
maxTokens: session.config.maxTokens,
|
|
187
221
|
contextPolicy,
|
|
188
222
|
effort: session.config.effort,
|
|
223
|
+
requestIdentity: requestIdentityForSession(session),
|
|
189
224
|
maxModelRequests: session.config.maxModelRequests,
|
|
190
225
|
toolContext: { root: session.config.root },
|
|
191
226
|
};
|
|
@@ -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/cli-info.js
CHANGED
|
@@ -12,7 +12,6 @@ Options:
|
|
|
12
12
|
--root <path> workspace root (default: current directory)
|
|
13
13
|
--provider <id> anthropic, openai, openai-codex, or ollama
|
|
14
14
|
--model <id> model for the selected provider
|
|
15
|
-
--ollama-host <url> Ollama Cloud, local, or custom endpoint
|
|
16
15
|
--effort <level> low, medium, high, xhigh, or max
|
|
17
16
|
--max-tokens <number> output-token ceiling
|
|
18
17
|
--max-steps <number> optional per-turn model-request budget
|
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" });
|