@giovannijecha/jecode 0.8.5 → 0.8.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -9
- package/assets/tokenizers/LICENSE +21 -0
- package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
- package/dist/cli-info.js +8 -14
- package/dist/commands.js +7 -3
- package/dist/config.js +47 -53
- package/dist/context/automatic.js +16 -1
- package/dist/context/budget.js +5 -2
- package/dist/context/compactor.js +91 -37
- package/dist/context/diagnostics.js +108 -0
- package/dist/context/lifetime.js +18 -0
- package/dist/context/manager.js +67 -0
- package/dist/context/manual.js +11 -10
- package/dist/context/measurement.js +75 -0
- package/dist/context/policy.js +13 -10
- package/dist/context/request-observation.js +46 -0
- package/dist/context/request-projection.js +8 -34
- package/dist/context/request.js +22 -0
- package/dist/context/tokenizer/bpe.js +68 -0
- package/dist/context/tokenizer/o200k.js +54 -0
- package/dist/context/tokenizer/vocabulary.js +34 -0
- package/dist/controller-request.js +47 -45
- package/dist/controller.js +13 -2
- package/dist/credential-commands.js +3 -3
- package/dist/input-boundary.js +0 -62
- package/dist/launch.js +13 -9
- package/dist/model-command.js +7 -17
- package/dist/oauth-result-page.js +68 -0
- package/dist/openai-account-command.js +14 -12
- package/dist/openai-account.js +7 -5
- package/dist/openai-oauth-callback.js +15 -54
- package/dist/openai-oauth-tokens.js +9 -7
- package/dist/openai-oauth.js +8 -6
- package/dist/permission-command.js +9 -24
- package/dist/permissions.js +10 -8
- package/dist/provider-commands.js +1 -33
- package/dist/provider-errors.js +1 -10
- package/dist/provider-label.js +3 -10
- package/dist/providers/anthropic-wire.js +1 -1
- package/dist/providers/anthropic.js +3 -2
- package/dist/providers/index.js +1 -5
- package/dist/providers/input-measurement.js +85 -0
- package/dist/providers/ollama-context.js +42 -0
- package/dist/providers/ollama-endpoint.js +7 -34
- package/dist/providers/ollama-wire.js +1 -1
- package/dist/providers/ollama.js +15 -147
- package/dist/providers/openai-codex.js +7 -4
- package/dist/providers/openai-stream.js +20 -8
- package/dist/providers/openai-summary.js +37 -0
- package/dist/providers/openai-wire.js +1 -1
- package/dist/providers/openai.js +3 -1
- package/dist/sessions/bucket.js +55 -0
- package/dist/sessions/catalog-io.js +162 -0
- package/dist/sessions/catalog.js +3 -1
- package/dist/sessions/codec-messages.js +122 -0
- package/dist/sessions/codec-transcript.js +93 -0
- package/dist/sessions/codec-values.js +52 -0
- package/dist/sessions/codec.js +4 -257
- package/dist/sessions/files.js +158 -0
- package/dist/sessions/load.js +90 -0
- package/dist/sessions/snapshot.js +33 -0
- package/dist/sessions/store.js +42 -461
- package/dist/settings.js +17 -16
- package/dist/start.js +36 -53
- package/dist/timeline.js +2 -0
- package/dist/tools/file-read.js +192 -0
- package/dist/tools/file-summary.js +9 -0
- package/dist/tools/{fs.js → file-write.js} +5 -193
- package/dist/tools/glob.js +107 -0
- package/dist/tools/index.js +2 -1
- package/dist/tools/search.js +1 -105
- package/dist/tui/activity.js +1 -1
- package/dist/tui/app-input.js +34 -17
- package/dist/tui/app-workflows.js +13 -361
- package/dist/tui/app.js +55 -28
- package/dist/tui/approve.js +3 -2
- package/dist/tui/blocks.js +1 -2
- package/dist/tui/command-workflow.js +112 -0
- package/dist/tui/components/command-menu.js +7 -10
- package/dist/tui/components/menu.js +82 -43
- package/dist/tui/components/messages.js +16 -9
- package/dist/tui/components/status.js +1 -1
- package/dist/tui/components/tool-evidence.js +107 -0
- package/dist/tui/components/tool-motion.js +32 -0
- package/dist/tui/components/tool.js +48 -202
- package/dist/tui/frame.js +8 -3
- package/dist/tui/help.js +3 -2
- package/dist/tui/picker-layout.js +44 -0
- package/dist/tui/picker.js +7 -71
- package/dist/tui/screen.js +7 -0
- package/dist/tui/session-view.js +0 -1
- package/dist/tui/tool-details.js +135 -0
- package/dist/tui/transcript-grammar.js +1 -1
- package/dist/tui/transcript-view.js +30 -112
- package/dist/tui/turn-workflow.js +235 -0
- package/dist/tui/turn.js +7 -140
- package/dist/tui/view.js +4 -4
- package/dist/tui/workflow-types.js +2 -0
- package/dist/ui/render.js +0 -4
- package/package.json +16 -12
- package/dist/batch-view.js +0 -17
- package/dist/batch.js +0 -267
- package/dist/ollama-settings-command.js +0 -74
- package/dist/tui/motion.js +0 -32
package/dist/settings.js
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
// Persistent, non-secret defaults for interactive
|
|
1
|
+
// Persistent, non-secret defaults for interactive sessions.
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { atomicWrite } from "./atomic.js";
|
|
4
4
|
import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
|
|
5
5
|
import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
|
|
6
6
|
import { EFFORTS } from "./effort.js";
|
|
7
7
|
import { providerNames } from "./providers/index.js";
|
|
8
|
-
import {
|
|
8
|
+
import { isLegacyOllamaCloudHost, OLLAMA_CLOUD_HOST } from "./providers/ollama-endpoint.js";
|
|
9
9
|
import { withStoreLock } from "./store-lock.js";
|
|
10
10
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
11
11
|
import { assertStoreText, readBoundedJsonForMutationSync, readBoundedJsonSync, USER_STORE_LIMITS, } from "./user-store.js";
|
|
@@ -17,12 +17,14 @@ export function readSettings() {
|
|
|
17
17
|
return copy(saved);
|
|
18
18
|
}
|
|
19
19
|
export async function updateSettings(patch) {
|
|
20
|
+
assertRetiredOllamaHost(patch);
|
|
20
21
|
const file = settingsPath();
|
|
21
22
|
const directory = path.dirname(file);
|
|
22
23
|
const anchor = await preparePrivateDirectory(directory, "settings store directory");
|
|
23
24
|
const anchoredFile = path.join(anchor.path, path.basename(file));
|
|
24
25
|
return withStoreLock(anchoredFile, async () => {
|
|
25
26
|
const next = normalize({ ...readStoreForMutation(anchoredFile, anchor), ...patch });
|
|
27
|
+
delete next.ollamaHost;
|
|
26
28
|
const text = `${JSON.stringify(next, null, 2)}\n`;
|
|
27
29
|
assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
|
|
28
30
|
await atomicWrite(anchoredFile, text, {
|
|
@@ -50,8 +52,8 @@ function readStore(file = settingsPath()) {
|
|
|
50
52
|
return normalize(readBoundedJsonSync(anchoredFile, USER_STORE_LIMITS.settingsBytes, directory));
|
|
51
53
|
}
|
|
52
54
|
catch {
|
|
53
|
-
// Missing, unreadable, and malformed stores
|
|
54
|
-
//
|
|
55
|
+
// Missing, unreadable, and malformed stores fall back safely. Parsed
|
|
56
|
+
// retired endpoint markers survive normalization for startup validation.
|
|
55
57
|
return {};
|
|
56
58
|
}
|
|
57
59
|
}
|
|
@@ -88,9 +90,7 @@ function assertMutableSettings(value) {
|
|
|
88
90
|
Object.values(models).some((model) => !boundedNonempty(model, USER_STORE_LIMITS.model)))
|
|
89
91
|
throw new Error("settings store has invalid models");
|
|
90
92
|
}
|
|
91
|
-
|
|
92
|
-
throw new Error("settings store has an invalid Ollama endpoint");
|
|
93
|
-
}
|
|
93
|
+
assertRetiredOllamaHost(value);
|
|
94
94
|
if ("effort" in value && member(value["effort"], EFFORTS) === undefined) {
|
|
95
95
|
throw new Error("settings store has an invalid reasoning effort");
|
|
96
96
|
}
|
|
@@ -109,7 +109,7 @@ function normalize(value) {
|
|
|
109
109
|
const providers = providerNames();
|
|
110
110
|
const provider = member(value["provider"], providers);
|
|
111
111
|
const models = modelsOf(value["models"], providers);
|
|
112
|
-
const ollamaHost =
|
|
112
|
+
const ollamaHost = "ollamaHost" in value ? legacyOllamaHost(value["ollamaHost"]) : undefined;
|
|
113
113
|
const effort = member(value["effort"], EFFORTS);
|
|
114
114
|
const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
|
|
115
115
|
const maxTokens = positiveInteger(value["maxTokens"]);
|
|
@@ -133,14 +133,15 @@ function modelsOf(value, providers) {
|
|
|
133
133
|
function member(value, values) {
|
|
134
134
|
return typeof value === "string" && values.includes(value) ? value : undefined;
|
|
135
135
|
}
|
|
136
|
-
function
|
|
137
|
-
if (
|
|
138
|
-
return
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
136
|
+
function legacyOllamaHost(value) {
|
|
137
|
+
if (isLegacyOllamaCloudHost(value))
|
|
138
|
+
return OLLAMA_CLOUD_HOST;
|
|
139
|
+
// Even an invalid retired value must not disappear and enable cloud use.
|
|
140
|
+
return boundedNonempty(value, USER_STORE_LIMITS.endpoint) ? value : "unsupported legacy Ollama endpoint";
|
|
141
|
+
}
|
|
142
|
+
function assertRetiredOllamaHost(value) {
|
|
143
|
+
if ("ollamaHost" in value && !isLegacyOllamaCloudHost(value.ollamaHost)) {
|
|
144
|
+
throw new Error("settings store has a retired Ollama endpoint; remove ollamaHost from settings.json to use Ollama API");
|
|
144
145
|
}
|
|
145
146
|
}
|
|
146
147
|
function boundedNonempty(value, max) {
|
package/dist/start.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
// Application bootstrap:
|
|
1
|
+
// Application bootstrap: validate the terminal and resolve one interactive session.
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { runBatch } from "./batch.js";
|
|
4
3
|
import { showCliInfo } from "./cli-info.js";
|
|
5
4
|
import { loadConfig } from "./config.js";
|
|
6
5
|
import { ConversationTree } from "./conversation.js";
|
|
7
6
|
import { parseLaunch } from "./launch.js";
|
|
8
7
|
import { systemPrompt } from "./prompt.js";
|
|
9
|
-
import {
|
|
8
|
+
import { selectProvider } from "./providers/index.js";
|
|
10
9
|
import { SessionPersistence } from "./sessions/runtime.js";
|
|
11
10
|
import { DurableSessionStore } from "./sessions/store.js";
|
|
12
11
|
import { builtinTools } from "./tools/index.js";
|
|
@@ -22,23 +21,17 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
22
21
|
if (await showCliInfo(args, applicationRoot, write))
|
|
23
22
|
return;
|
|
24
23
|
const launch = parseLaunch(args);
|
|
25
|
-
const config = loadConfig(launch.configArgs);
|
|
26
|
-
configureProviders(config);
|
|
27
|
-
const provider = selectProvider(config.providerId);
|
|
28
24
|
const hasScreen = environment.interactive?.() ?? interactive();
|
|
29
|
-
if (
|
|
30
|
-
throw new Error("
|
|
25
|
+
if (!hasScreen) {
|
|
26
|
+
throw new Error("an interactive terminal is required on stdin and stdout; run jecode directly without pipes or redirection");
|
|
31
27
|
}
|
|
28
|
+
const config = loadConfig(launch.configArgs, environment.readSettings?.());
|
|
29
|
+
const provider = selectProvider(config.providerId);
|
|
32
30
|
if (launch.kind === "resume" && config.ephemeral) {
|
|
33
31
|
throw new Error("--ephemeral cannot be combined with resume");
|
|
34
32
|
}
|
|
35
|
-
// A provider whose catalogue is not fixed has no sensible default model.
|
|
36
|
-
// The TUI can ask; a pipe cannot, so batch mode still requires one up front.
|
|
37
33
|
const model = config.model === "" ? provider.defaultModel : config.model;
|
|
38
|
-
|
|
39
|
-
throw new Error(`${provider.id} has no default model — pass --model <id> (or set JECODE_MODEL)`);
|
|
40
|
-
}
|
|
41
|
-
configureColor(hasScreen);
|
|
34
|
+
configureColor(true);
|
|
42
35
|
const session = {
|
|
43
36
|
config,
|
|
44
37
|
provider,
|
|
@@ -49,52 +42,42 @@ export async function start(args = process.argv.slice(2), environment = {}) {
|
|
|
49
42
|
conversation: ConversationTree.empty(),
|
|
50
43
|
usage: emptyUsage(),
|
|
51
44
|
};
|
|
52
|
-
if (
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
session.resume = { candidates, open };
|
|
73
|
-
}
|
|
74
|
-
else {
|
|
75
|
-
session.persistence = SessionPersistence.fresh(store);
|
|
76
|
-
}
|
|
45
|
+
if (!config.ephemeral) {
|
|
46
|
+
const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
|
|
47
|
+
if (launch.kind === "resume") {
|
|
48
|
+
const candidates = await SessionPersistence.candidates(store);
|
|
49
|
+
if (candidates.length === 0)
|
|
50
|
+
throw new Error("no resumable sessions found for this workspace");
|
|
51
|
+
const open = async (id) => {
|
|
52
|
+
const resumed = await SessionPersistence.resume(store, id);
|
|
53
|
+
try {
|
|
54
|
+
applyResumedSession(session, resumed.conversation, resumed.persistence);
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
await resumed.persistence.close();
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
if (launch.latest)
|
|
62
|
+
await open(candidates[0].id);
|
|
63
|
+
else
|
|
64
|
+
session.resume = { candidates, open };
|
|
77
65
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
|
|
81
|
-
}
|
|
82
|
-
else {
|
|
83
|
-
await environment.runInteractive(session, transcriptRoot, environment.signal);
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
finally {
|
|
87
|
-
await session.persistence?.close();
|
|
66
|
+
else {
|
|
67
|
+
session.persistence = SessionPersistence.fresh(store);
|
|
88
68
|
}
|
|
89
69
|
}
|
|
90
|
-
|
|
91
|
-
if (environment.
|
|
92
|
-
await
|
|
70
|
+
try {
|
|
71
|
+
if (environment.runInteractive === undefined) {
|
|
72
|
+
await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
|
|
93
73
|
}
|
|
94
74
|
else {
|
|
95
|
-
await environment.
|
|
75
|
+
await environment.runInteractive(session, transcriptRoot, environment.signal);
|
|
96
76
|
}
|
|
97
77
|
}
|
|
78
|
+
finally {
|
|
79
|
+
await session.persistence?.close();
|
|
80
|
+
}
|
|
98
81
|
}
|
|
99
82
|
function applyResumedSession(session, conversation, persistence) {
|
|
100
83
|
const identity = conversation.activeNode?.identity;
|
package/dist/timeline.js
CHANGED
|
@@ -16,6 +16,8 @@ export function timelinePicker(conversation, palette) {
|
|
|
16
16
|
searchable: true,
|
|
17
17
|
query: "",
|
|
18
18
|
visible: 8,
|
|
19
|
+
// Turn labels are previews; keep each node on its own row without a second copy.
|
|
20
|
+
overflow: "truncate",
|
|
19
21
|
options: entries.map((entry) => ({
|
|
20
22
|
label: `${entry.prefix}${preview(entry.node)}`,
|
|
21
23
|
hint: stamp(entry.node.createdAt),
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Bounded workspace file reads and stable directory listings.
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { withStableFile } from "../bounded-file.js";
|
|
4
|
+
import { readStableDirectory } from "../stable-directory.js";
|
|
5
|
+
import { optionalInt, optionalString, requireString } from "./args.js";
|
|
6
|
+
import { resolveExistingInRoot } from "./paths.js";
|
|
7
|
+
import { leadingText } from "./text-boundary.js";
|
|
8
|
+
import { count, plural } from "./file-summary.js";
|
|
9
|
+
const MAX_READ_CHARS = 60_000;
|
|
10
|
+
const MAX_LIST_CHARS = 60_000;
|
|
11
|
+
const MAX_LIST_ENTRIES = 2_000;
|
|
12
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
13
|
+
const MAX_READ_SCAN_BYTES = 16 * 1024 * 1024;
|
|
14
|
+
export const readFile = {
|
|
15
|
+
name: "read_file",
|
|
16
|
+
description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
17
|
+
"(1-based) and cap how many lines come back. Large files are truncated.",
|
|
18
|
+
dangerous: false,
|
|
19
|
+
concurrency: "shared",
|
|
20
|
+
input: {
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
path: { type: "string", description: "Path relative to the workspace root." },
|
|
24
|
+
offset: { type: "integer", description: "First line to return, 1-based." },
|
|
25
|
+
limit: { type: "integer", description: "How many lines to return." },
|
|
26
|
+
},
|
|
27
|
+
required: ["path"],
|
|
28
|
+
},
|
|
29
|
+
async run(args, ctx) {
|
|
30
|
+
return runReadFile(args, ctx);
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
export async function runReadFile(args, ctx, dependencies = {}) {
|
|
34
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
35
|
+
const target = await resolveExistingInRoot(root, requireString(args, "path"));
|
|
36
|
+
const offset = optionalInt(args, "offset");
|
|
37
|
+
const limit = optionalInt(args, "limit");
|
|
38
|
+
const { text, truncated, scanCapped } = await readRange(root, target, offset, limit, ctx.signal, dependencies);
|
|
39
|
+
if (truncated) {
|
|
40
|
+
return {
|
|
41
|
+
output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
|
|
42
|
+
summary: `truncated at ${MAX_READ_CHARS} characters`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
if (scanCapped) {
|
|
46
|
+
const notice = `[read stopped after scanning ${MAX_READ_SCAN_BYTES} bytes — use a smaller offset or search_text]`;
|
|
47
|
+
return {
|
|
48
|
+
output: text === "" ? notice : `${text}\n\n${notice}`,
|
|
49
|
+
summary: `scan capped at ${MAX_READ_SCAN_BYTES} bytes`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
if (text === "")
|
|
53
|
+
return { output: "[file is empty]", summary: "empty" };
|
|
54
|
+
return { output: text, summary: `${count(text, "line")}` };
|
|
55
|
+
}
|
|
56
|
+
export const listDir = {
|
|
57
|
+
name: "list_dir",
|
|
58
|
+
description: "List the entries of a directory inside the workspace. Directories end with a slash.",
|
|
59
|
+
dangerous: false,
|
|
60
|
+
concurrency: "shared",
|
|
61
|
+
input: {
|
|
62
|
+
type: "object",
|
|
63
|
+
properties: {
|
|
64
|
+
path: { type: "string", description: "Directory relative to the workspace root. Defaults to the root." },
|
|
65
|
+
},
|
|
66
|
+
required: [],
|
|
67
|
+
},
|
|
68
|
+
async run(args, ctx) {
|
|
69
|
+
return runListDir(args, ctx);
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
export async function runListDir(args, ctx, dependencies = {}) {
|
|
73
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
74
|
+
const requested = optionalString(args, "path");
|
|
75
|
+
const relative = requested === undefined || requested.trim() === "" ? "." : requested;
|
|
76
|
+
const target = await resolveExistingInRoot(root, relative);
|
|
77
|
+
const inspected = await readStableDirectory(root, target, {
|
|
78
|
+
maxEntries: MAX_LIST_ENTRIES + 1,
|
|
79
|
+
signal: ctx.signal,
|
|
80
|
+
beforeOpen: dependencies.beforeOpen,
|
|
81
|
+
});
|
|
82
|
+
const entries = [];
|
|
83
|
+
let chars = 0;
|
|
84
|
+
let truncated = inspected.capped;
|
|
85
|
+
for (const entry of inspected.entries) {
|
|
86
|
+
const label = entry.kind === "directory" ? `${entry.name}/` : entry.name;
|
|
87
|
+
const separator = entries.length === 0 ? 0 : 1;
|
|
88
|
+
if (entries.length >= MAX_LIST_ENTRIES ||
|
|
89
|
+
chars + separator + label.length > MAX_LIST_CHARS) {
|
|
90
|
+
truncated = true;
|
|
91
|
+
break;
|
|
92
|
+
}
|
|
93
|
+
entries.push(label);
|
|
94
|
+
chars += separator + label.length;
|
|
95
|
+
}
|
|
96
|
+
if (entries.length === 0)
|
|
97
|
+
return { output: "[empty directory]", summary: "empty" };
|
|
98
|
+
const listing = entries.join("\n");
|
|
99
|
+
if (truncated) {
|
|
100
|
+
return {
|
|
101
|
+
output: `${listing}\n\n[truncated after ${entries.length} entries]`,
|
|
102
|
+
summary: `${entries.length}+ entries`,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
return { output: listing, summary: plural(entries.length, "entry", "entries") };
|
|
106
|
+
}
|
|
107
|
+
async function readRange(root, target, offset, limit, signal, dependencies) {
|
|
108
|
+
throwIfAborted(signal);
|
|
109
|
+
const firstLine = Math.max(1, offset ?? 1);
|
|
110
|
+
const lineCount = limit === undefined ? undefined : Math.max(0, limit);
|
|
111
|
+
const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
|
|
112
|
+
if (lineCount === 0)
|
|
113
|
+
return { text: "", truncated: false, scanCapped: false };
|
|
114
|
+
const result = await withStableFile(target, {
|
|
115
|
+
label: "read file",
|
|
116
|
+
signal,
|
|
117
|
+
beforeOpen: dependencies.beforeOpen,
|
|
118
|
+
}, async (handle, opened) => {
|
|
119
|
+
const decoder = new TextDecoder();
|
|
120
|
+
let text = "";
|
|
121
|
+
let line = 1;
|
|
122
|
+
let stopped = false;
|
|
123
|
+
let truncated = false;
|
|
124
|
+
let reachedEnd = false;
|
|
125
|
+
const selected = (at) => at >= firstLine && at < endLine;
|
|
126
|
+
const append = (fragment) => {
|
|
127
|
+
if (fragment === "")
|
|
128
|
+
return;
|
|
129
|
+
const room = MAX_READ_CHARS - text.length;
|
|
130
|
+
if (fragment.length > room) {
|
|
131
|
+
text += leadingText(fragment, room);
|
|
132
|
+
truncated = true;
|
|
133
|
+
stopped = true;
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
text += fragment;
|
|
137
|
+
};
|
|
138
|
+
const consume = (chunk) => {
|
|
139
|
+
let start = 0;
|
|
140
|
+
while (!stopped && start < chunk.length) {
|
|
141
|
+
const newline = chunk.indexOf("\n", start);
|
|
142
|
+
const end = newline === -1 ? chunk.length : newline;
|
|
143
|
+
if (selected(line))
|
|
144
|
+
append(chunk.slice(start, end));
|
|
145
|
+
if (stopped || newline === -1)
|
|
146
|
+
return;
|
|
147
|
+
const nextLine = line + 1;
|
|
148
|
+
if (selected(line) && selected(nextLine))
|
|
149
|
+
append("\n");
|
|
150
|
+
line = nextLine;
|
|
151
|
+
if (line >= endLine) {
|
|
152
|
+
stopped = true;
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
start = newline + 1;
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
let position = 0;
|
|
159
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
160
|
+
while (!stopped && position < MAX_READ_SCAN_BYTES) {
|
|
161
|
+
throwIfAborted(signal);
|
|
162
|
+
const length = Math.min(buffer.length, MAX_READ_SCAN_BYTES - position);
|
|
163
|
+
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
164
|
+
throwIfAborted(signal);
|
|
165
|
+
if (bytesRead === 0) {
|
|
166
|
+
reachedEnd = true;
|
|
167
|
+
break;
|
|
168
|
+
}
|
|
169
|
+
position += bytesRead;
|
|
170
|
+
consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
|
|
171
|
+
}
|
|
172
|
+
if (!stopped && BigInt(position) >= opened.size)
|
|
173
|
+
reachedEnd = true;
|
|
174
|
+
if (!stopped && reachedEnd)
|
|
175
|
+
consume(decoder.decode());
|
|
176
|
+
return {
|
|
177
|
+
text,
|
|
178
|
+
truncated,
|
|
179
|
+
scanCapped: !stopped && !reachedEnd && position >= MAX_READ_SCAN_BYTES,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
const confirmed = await resolveExistingInRoot(root, target);
|
|
183
|
+
if (path.relative(target, confirmed) !== "") {
|
|
184
|
+
throw new Error("read file changed while it was being read");
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
function throwIfAborted(signal) {
|
|
189
|
+
if (signal?.aborted !== true)
|
|
190
|
+
return;
|
|
191
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
192
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Shared line and entry counts in filesystem tool summaries.
|
|
2
|
+
export function count(text, noun) {
|
|
3
|
+
if (text === "")
|
|
4
|
+
return "empty";
|
|
5
|
+
return plural(text.split("\n").length, noun, `${noun}s`);
|
|
6
|
+
}
|
|
7
|
+
export function plural(n, one, many) {
|
|
8
|
+
return `${n} ${n === 1 ? one : many}`;
|
|
9
|
+
}
|
|
@@ -1,111 +1,12 @@
|
|
|
1
|
-
//
|
|
1
|
+
// Atomic file mutations share preview validation and exact replacement rules.
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import * as path from "node:path";
|
|
4
|
-
import { withStableFile } from "../bounded-file.js";
|
|
5
|
-
import { readStableDirectory } from "../stable-directory.js";
|
|
6
|
-
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
7
|
-
import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
|
|
8
|
-
import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
9
4
|
import { atomicWrite } from "../atomic.js";
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const MAX_READ_SCAN_BYTES = 16 * 1024 * 1024;
|
|
5
|
+
import { optionalBool, requireString } from "./args.js";
|
|
6
|
+
import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
|
|
7
|
+
import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
8
|
+
import { count, plural } from "./file-summary.js";
|
|
15
9
|
const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
|
|
16
|
-
export const readFile = {
|
|
17
|
-
name: "read_file",
|
|
18
|
-
description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
19
|
-
"(1-based) and cap how many lines come back. Large files are truncated.",
|
|
20
|
-
dangerous: false,
|
|
21
|
-
concurrency: "shared",
|
|
22
|
-
input: {
|
|
23
|
-
type: "object",
|
|
24
|
-
properties: {
|
|
25
|
-
path: { type: "string", description: "Path relative to the workspace root." },
|
|
26
|
-
offset: { type: "integer", description: "First line to return, 1-based." },
|
|
27
|
-
limit: { type: "integer", description: "How many lines to return." },
|
|
28
|
-
},
|
|
29
|
-
required: ["path"],
|
|
30
|
-
},
|
|
31
|
-
async run(args, ctx) {
|
|
32
|
-
return runReadFile(args, ctx);
|
|
33
|
-
},
|
|
34
|
-
};
|
|
35
|
-
export async function runReadFile(args, ctx, dependencies = {}) {
|
|
36
|
-
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
37
|
-
const target = await resolveExistingInRoot(root, requireString(args, "path"));
|
|
38
|
-
const offset = optionalInt(args, "offset");
|
|
39
|
-
const limit = optionalInt(args, "limit");
|
|
40
|
-
const { text, truncated, scanCapped } = await readRange(root, target, offset, limit, ctx.signal, dependencies);
|
|
41
|
-
if (truncated) {
|
|
42
|
-
return {
|
|
43
|
-
output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
|
|
44
|
-
summary: `truncated at ${MAX_READ_CHARS} characters`,
|
|
45
|
-
};
|
|
46
|
-
}
|
|
47
|
-
if (scanCapped) {
|
|
48
|
-
const notice = `[read stopped after scanning ${MAX_READ_SCAN_BYTES} bytes — use a smaller offset or search_text]`;
|
|
49
|
-
return {
|
|
50
|
-
output: text === "" ? notice : `${text}\n\n${notice}`,
|
|
51
|
-
summary: `scan capped at ${MAX_READ_SCAN_BYTES} bytes`,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
if (text === "")
|
|
55
|
-
return { output: "[file is empty]", summary: "empty" };
|
|
56
|
-
return { output: text, summary: `${count(text, "line")}` };
|
|
57
|
-
}
|
|
58
|
-
export const listDir = {
|
|
59
|
-
name: "list_dir",
|
|
60
|
-
description: "List the entries of a directory inside the workspace. Directories end with a slash.",
|
|
61
|
-
dangerous: false,
|
|
62
|
-
concurrency: "shared",
|
|
63
|
-
input: {
|
|
64
|
-
type: "object",
|
|
65
|
-
properties: {
|
|
66
|
-
path: { type: "string", description: "Directory relative to the workspace root. Defaults to the root." },
|
|
67
|
-
},
|
|
68
|
-
required: [],
|
|
69
|
-
},
|
|
70
|
-
async run(args, ctx) {
|
|
71
|
-
return runListDir(args, ctx);
|
|
72
|
-
},
|
|
73
|
-
};
|
|
74
|
-
export async function runListDir(args, ctx, dependencies = {}) {
|
|
75
|
-
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
76
|
-
const requested = optionalString(args, "path");
|
|
77
|
-
const relative = requested === undefined || requested.trim() === "" ? "." : requested;
|
|
78
|
-
const target = await resolveExistingInRoot(root, relative);
|
|
79
|
-
const inspected = await readStableDirectory(root, target, {
|
|
80
|
-
maxEntries: MAX_LIST_ENTRIES + 1,
|
|
81
|
-
signal: ctx.signal,
|
|
82
|
-
beforeOpen: dependencies.beforeOpen,
|
|
83
|
-
});
|
|
84
|
-
const entries = [];
|
|
85
|
-
let chars = 0;
|
|
86
|
-
let truncated = inspected.capped;
|
|
87
|
-
for (const entry of inspected.entries) {
|
|
88
|
-
const label = entry.kind === "directory" ? `${entry.name}/` : entry.name;
|
|
89
|
-
const separator = entries.length === 0 ? 0 : 1;
|
|
90
|
-
if (entries.length >= MAX_LIST_ENTRIES ||
|
|
91
|
-
chars + separator + label.length > MAX_LIST_CHARS) {
|
|
92
|
-
truncated = true;
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
entries.push(label);
|
|
96
|
-
chars += separator + label.length;
|
|
97
|
-
}
|
|
98
|
-
if (entries.length === 0)
|
|
99
|
-
return { output: "[empty directory]", summary: "empty" };
|
|
100
|
-
const listing = entries.join("\n");
|
|
101
|
-
if (truncated) {
|
|
102
|
-
return {
|
|
103
|
-
output: `${listing}\n\n[truncated after ${entries.length} entries]`,
|
|
104
|
-
summary: `${entries.length}+ entries`,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
return { output: listing, summary: plural(entries.length, "entry", "entries") };
|
|
108
|
-
}
|
|
109
10
|
export const writeFile = {
|
|
110
11
|
name: "write_file",
|
|
111
12
|
description: "Create a file, or replace its entire contents. Parent directories are " +
|
|
@@ -221,87 +122,6 @@ export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEP
|
|
|
221
122
|
summary: plural(made, "replacement", "replacements"),
|
|
222
123
|
};
|
|
223
124
|
}
|
|
224
|
-
async function readRange(root, target, offset, limit, signal, dependencies) {
|
|
225
|
-
throwIfAborted(signal);
|
|
226
|
-
const firstLine = Math.max(1, offset ?? 1);
|
|
227
|
-
const lineCount = limit === undefined ? undefined : Math.max(0, limit);
|
|
228
|
-
const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
|
|
229
|
-
if (lineCount === 0)
|
|
230
|
-
return { text: "", truncated: false, scanCapped: false };
|
|
231
|
-
const result = await withStableFile(target, {
|
|
232
|
-
label: "read file",
|
|
233
|
-
signal,
|
|
234
|
-
beforeOpen: dependencies.beforeOpen,
|
|
235
|
-
}, async (handle, opened) => {
|
|
236
|
-
const decoder = new TextDecoder();
|
|
237
|
-
let text = "";
|
|
238
|
-
let line = 1;
|
|
239
|
-
let stopped = false;
|
|
240
|
-
let truncated = false;
|
|
241
|
-
let reachedEnd = false;
|
|
242
|
-
const selected = (at) => at >= firstLine && at < endLine;
|
|
243
|
-
const append = (fragment) => {
|
|
244
|
-
if (fragment === "")
|
|
245
|
-
return;
|
|
246
|
-
const room = MAX_READ_CHARS - text.length;
|
|
247
|
-
if (fragment.length > room) {
|
|
248
|
-
text += leadingText(fragment, room);
|
|
249
|
-
truncated = true;
|
|
250
|
-
stopped = true;
|
|
251
|
-
return;
|
|
252
|
-
}
|
|
253
|
-
text += fragment;
|
|
254
|
-
};
|
|
255
|
-
const consume = (chunk) => {
|
|
256
|
-
let start = 0;
|
|
257
|
-
while (!stopped && start < chunk.length) {
|
|
258
|
-
const newline = chunk.indexOf("\n", start);
|
|
259
|
-
const end = newline === -1 ? chunk.length : newline;
|
|
260
|
-
if (selected(line))
|
|
261
|
-
append(chunk.slice(start, end));
|
|
262
|
-
if (stopped || newline === -1)
|
|
263
|
-
return;
|
|
264
|
-
const nextLine = line + 1;
|
|
265
|
-
if (selected(line) && selected(nextLine))
|
|
266
|
-
append("\n");
|
|
267
|
-
line = nextLine;
|
|
268
|
-
if (line >= endLine) {
|
|
269
|
-
stopped = true;
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
start = newline + 1;
|
|
273
|
-
}
|
|
274
|
-
};
|
|
275
|
-
let position = 0;
|
|
276
|
-
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
277
|
-
while (!stopped && position < MAX_READ_SCAN_BYTES) {
|
|
278
|
-
throwIfAborted(signal);
|
|
279
|
-
const length = Math.min(buffer.length, MAX_READ_SCAN_BYTES - position);
|
|
280
|
-
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
|
281
|
-
throwIfAborted(signal);
|
|
282
|
-
if (bytesRead === 0) {
|
|
283
|
-
reachedEnd = true;
|
|
284
|
-
break;
|
|
285
|
-
}
|
|
286
|
-
position += bytesRead;
|
|
287
|
-
consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
|
|
288
|
-
}
|
|
289
|
-
if (!stopped && BigInt(position) >= opened.size)
|
|
290
|
-
reachedEnd = true;
|
|
291
|
-
if (!stopped && reachedEnd)
|
|
292
|
-
consume(decoder.decode());
|
|
293
|
-
return {
|
|
294
|
-
text,
|
|
295
|
-
truncated,
|
|
296
|
-
scanCapped: !stopped && !reachedEnd && position >= MAX_READ_SCAN_BYTES,
|
|
297
|
-
};
|
|
298
|
-
});
|
|
299
|
-
const confirmed = await resolveExistingInRoot(root, target);
|
|
300
|
-
if (path.relative(target, confirmed) !== "") {
|
|
301
|
-
throw new Error("read file changed while it was being read");
|
|
302
|
-
}
|
|
303
|
-
return result;
|
|
304
|
-
}
|
|
305
125
|
function throwIfAborted(signal) {
|
|
306
126
|
if (signal?.aborted !== true)
|
|
307
127
|
return;
|
|
@@ -370,11 +190,3 @@ function changedFile(operation, previewed) {
|
|
|
370
190
|
const when = previewed ? "after the preview" : "while preparing the change";
|
|
371
191
|
return new Error(`file changed ${when} — inspect it and retry the ${operation}`);
|
|
372
192
|
}
|
|
373
|
-
function count(text, noun) {
|
|
374
|
-
if (text === "")
|
|
375
|
-
return "empty";
|
|
376
|
-
return plural(text.split("\n").length, noun, `${noun}s`);
|
|
377
|
-
}
|
|
378
|
-
function plural(n, one, many) {
|
|
379
|
-
return `${n} ${n === 1 ? one : many}`;
|
|
380
|
-
}
|