@giovannijecha/jecode 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/dist/batch.js +22 -18
- package/dist/cli-info.js +1 -1
- package/dist/config.js +3 -2
- package/dist/context/budget.js +37 -0
- package/dist/context/compactor.js +9 -2
- package/dist/context/estimate.js +31 -0
- package/dist/context/policy.js +11 -8
- package/dist/controller-request.js +26 -3
- package/dist/controller.js +1 -0
- package/dist/conversation.js +48 -31
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/http.js +4 -2
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +1 -1
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +3 -3
- package/dist/providers/stream-limits.js +14 -2
- package/dist/tools/fs.js +80 -38
- package/dist/tools/search.js +106 -44
- package/dist/tools/shell.js +14 -6
- package/dist/tools/text-boundary.js +37 -25
- package/dist/tui/app-state.js +0 -1
- package/dist/tui/app-workflows.js +38 -29
- package/dist/tui/app.js +12 -14
- package/dist/tui/blocks.js +0 -2
- package/dist/tui/components/tool.js +4 -8
- package/dist/tui/session-view.js +2 -1
- package/dist/tui/transcript-view.js +178 -106
- package/dist/tui/view.js +8 -3
- package/package.json +2 -1
package/dist/tools/fs.js
CHANGED
|
@@ -4,12 +4,13 @@ import * as fs from "node:fs/promises";
|
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
6
6
|
import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
|
|
7
|
-
import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
7
|
+
import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
8
8
|
import { atomicWrite } from "../atomic.js";
|
|
9
9
|
const MAX_READ_CHARS = 60_000;
|
|
10
10
|
const MAX_LIST_CHARS = 60_000;
|
|
11
11
|
const MAX_LIST_ENTRIES = 2_000;
|
|
12
12
|
const READ_CHUNK_BYTES = 64 * 1024;
|
|
13
|
+
const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
|
|
13
14
|
export const readFile = {
|
|
14
15
|
name: "read_file",
|
|
15
16
|
description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
@@ -111,24 +112,35 @@ export const writeFile = {
|
|
|
111
112
|
assertEditableText(content);
|
|
112
113
|
// A write against a file that is already there is a replacement, and the
|
|
113
114
|
// user is owed the difference rather than a wall of green.
|
|
114
|
-
|
|
115
|
+
const before = await current(target);
|
|
116
|
+
return { before: before.text, after: content, beforeExists: before.exists };
|
|
115
117
|
},
|
|
116
118
|
async run(args, ctx) {
|
|
117
|
-
|
|
118
|
-
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
119
|
-
const content = requireString(args, "content", true);
|
|
120
|
-
assertEditableText(content);
|
|
121
|
-
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
122
|
-
const validate = () => assertDirectWritableInRoot(root, target);
|
|
123
|
-
await validate();
|
|
124
|
-
await unchangedSinceApproval(target, ctx.preview?.before);
|
|
125
|
-
await atomicWrite(target, content, { validate });
|
|
126
|
-
return {
|
|
127
|
-
output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
|
|
128
|
-
summary: count(content, "line"),
|
|
129
|
-
};
|
|
119
|
+
return runWriteFile(args, ctx);
|
|
130
120
|
},
|
|
131
121
|
};
|
|
122
|
+
export async function runWriteFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
123
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
124
|
+
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
125
|
+
const content = requireString(args, "content", true);
|
|
126
|
+
assertEditableText(content);
|
|
127
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
128
|
+
await assertDirectWritableInRoot(root, target);
|
|
129
|
+
const before = await current(target);
|
|
130
|
+
assertApproved(before, ctx.preview, "write");
|
|
131
|
+
await dependencies.atomicWrite(target, content, {
|
|
132
|
+
async validate(phase) {
|
|
133
|
+
await assertDirectWritableInRoot(root, target);
|
|
134
|
+
if (phase === "before-rename") {
|
|
135
|
+
await assertUnchanged(target, before, "write", ctx.preview !== undefined);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
return {
|
|
140
|
+
output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
|
|
141
|
+
summary: count(content, "line"),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
132
144
|
export const editFile = {
|
|
133
145
|
name: "edit_file",
|
|
134
146
|
description: "Replace an exact string in a file. The old text must appear exactly once " +
|
|
@@ -150,32 +162,43 @@ export const editFile = {
|
|
|
150
162
|
async preview(args, ctx) {
|
|
151
163
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
152
164
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
153
|
-
const before = await current(target);
|
|
165
|
+
const before = await current(target, true);
|
|
154
166
|
// An edit that will not apply gets no preview: the run is about to say so
|
|
155
167
|
// properly, and a diff of a match that does not exist would be a lie.
|
|
156
168
|
try {
|
|
157
|
-
return {
|
|
169
|
+
return {
|
|
170
|
+
before: before.text,
|
|
171
|
+
after: applied(before.text, args).after,
|
|
172
|
+
beforeExists: true,
|
|
173
|
+
};
|
|
158
174
|
}
|
|
159
175
|
catch {
|
|
160
176
|
return undefined;
|
|
161
177
|
}
|
|
162
178
|
},
|
|
163
179
|
async run(args, ctx) {
|
|
164
|
-
|
|
165
|
-
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
166
|
-
const before = await readEditableText(target);
|
|
167
|
-
if (ctx.preview !== undefined && before !== ctx.preview.before) {
|
|
168
|
-
throw new Error("file changed after the preview — inspect it and retry the edit");
|
|
169
|
-
}
|
|
170
|
-
const { after, made } = applied(before, args);
|
|
171
|
-
const validate = () => assertDirectWritableInRoot(root, target, true);
|
|
172
|
-
await atomicWrite(target, after, { validate });
|
|
173
|
-
return {
|
|
174
|
-
output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
|
|
175
|
-
summary: plural(made, "replacement", "replacements"),
|
|
176
|
-
};
|
|
180
|
+
return runEditFile(args, ctx);
|
|
177
181
|
},
|
|
178
182
|
};
|
|
183
|
+
export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
184
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
185
|
+
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
186
|
+
const before = await current(target, true);
|
|
187
|
+
assertApproved(before, ctx.preview, "edit");
|
|
188
|
+
const { after, made } = applied(before.text, args);
|
|
189
|
+
await dependencies.atomicWrite(target, after, {
|
|
190
|
+
async validate(phase) {
|
|
191
|
+
await assertDirectWritableInRoot(root, target, true);
|
|
192
|
+
if (phase === "before-rename") {
|
|
193
|
+
await assertUnchanged(target, before, "edit", ctx.preview !== undefined);
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
});
|
|
197
|
+
return {
|
|
198
|
+
output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
|
|
199
|
+
summary: plural(made, "replacement", "replacements"),
|
|
200
|
+
};
|
|
201
|
+
}
|
|
179
202
|
async function readRange(target, offset, limit, signal) {
|
|
180
203
|
throwIfAborted(signal);
|
|
181
204
|
const firstLine = Math.max(1, offset ?? 1);
|
|
@@ -203,8 +226,7 @@ async function readRange(target, offset, limit, signal) {
|
|
|
203
226
|
return;
|
|
204
227
|
const room = MAX_READ_CHARS - text.length;
|
|
205
228
|
if (fragment.length > room) {
|
|
206
|
-
|
|
207
|
-
text += fragment.slice(0, room);
|
|
229
|
+
text += leadingText(fragment, room);
|
|
208
230
|
truncated = true;
|
|
209
231
|
stopped = true;
|
|
210
232
|
return;
|
|
@@ -284,9 +306,17 @@ function applied(before, args) {
|
|
|
284
306
|
assertEditableText(after, "edited content");
|
|
285
307
|
return { after, made };
|
|
286
308
|
}
|
|
287
|
-
/** What is on disk now,
|
|
288
|
-
async function current(target) {
|
|
289
|
-
|
|
309
|
+
/** What is on disk now, preserving the difference between absent and empty. */
|
|
310
|
+
async function current(target, mustExist = false) {
|
|
311
|
+
try {
|
|
312
|
+
return { exists: true, text: await readEditableText(target) };
|
|
313
|
+
}
|
|
314
|
+
catch (error) {
|
|
315
|
+
if (!mustExist && error.code === "ENOENT") {
|
|
316
|
+
return { exists: false, text: "" };
|
|
317
|
+
}
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
290
320
|
}
|
|
291
321
|
function countOccurrences(haystack, needle) {
|
|
292
322
|
let occurrences = 0;
|
|
@@ -295,12 +325,24 @@ function countOccurrences(haystack, needle) {
|
|
|
295
325
|
}
|
|
296
326
|
return occurrences;
|
|
297
327
|
}
|
|
298
|
-
|
|
328
|
+
function assertApproved(current, preview, operation) {
|
|
329
|
+
if (preview === undefined)
|
|
330
|
+
return;
|
|
331
|
+
const existenceChanged = preview.beforeExists !== undefined && current.exists !== preview.beforeExists;
|
|
332
|
+
if (existenceChanged || current.text !== preview.before) {
|
|
333
|
+
throw changedFile(operation, true);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
async function assertUnchanged(target, expected, operation, previewed) {
|
|
299
337
|
const onDisk = await current(target);
|
|
300
|
-
if (
|
|
301
|
-
throw
|
|
338
|
+
if (onDisk.exists !== expected.exists || onDisk.text !== expected.text) {
|
|
339
|
+
throw changedFile(operation, previewed);
|
|
302
340
|
}
|
|
303
341
|
}
|
|
342
|
+
function changedFile(operation, previewed) {
|
|
343
|
+
const when = previewed ? "after the preview" : "while preparing the change";
|
|
344
|
+
return new Error(`file changed ${when} — inspect it and retry the ${operation}`);
|
|
345
|
+
}
|
|
304
346
|
function count(text, noun) {
|
|
305
347
|
if (text === "")
|
|
306
348
|
return "empty";
|
package/dist/tools/search.js
CHANGED
|
@@ -4,12 +4,14 @@ import * as path from "node:path";
|
|
|
4
4
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
5
5
|
import { displayPath, resolveExistingInRoot } from "./paths.js";
|
|
6
6
|
import { trySearchWithRipgrep } from "./ripgrep.js";
|
|
7
|
+
import { leadingText } from "./text-boundary.js";
|
|
7
8
|
const DEFAULT_RESULTS = 100;
|
|
8
9
|
const MAX_RESULTS = 500;
|
|
9
10
|
const MAX_VISITED = 20_000;
|
|
10
11
|
const MAX_FILE_BYTES = 1_000_000;
|
|
11
12
|
const MAX_MATCH_LINE = 500;
|
|
12
13
|
const MAX_GLOB_CHARS = 512;
|
|
14
|
+
const PORTABLE_SEARCH_CONCURRENCY = 8;
|
|
13
15
|
const RG_PREFIX_BYTES = 2_000_000;
|
|
14
16
|
const RG_PREFIX_FILES = 500;
|
|
15
17
|
const MIN_RG_TAIL_BYTES = 2_000_000;
|
|
@@ -82,36 +84,66 @@ export const searchText = {
|
|
|
82
84
|
const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
|
|
83
85
|
const limit = resultLimit(args);
|
|
84
86
|
const found = [];
|
|
87
|
+
const candidates = [];
|
|
88
|
+
const prefix = [];
|
|
85
89
|
const tail = [];
|
|
86
90
|
let skipped = 0;
|
|
87
91
|
let prefixBytes = 0;
|
|
88
92
|
let prefixFiles = 0;
|
|
89
93
|
let tailBytes = 0;
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
if (!match(relative))
|
|
94
|
+
const flushPrefix = async () => {
|
|
95
|
+
if (prefix.length === 0)
|
|
93
96
|
return false;
|
|
94
|
-
const
|
|
95
|
-
const info = await fs.stat(file);
|
|
96
|
-
if (info.size > MAX_FILE_BYTES) {
|
|
97
|
-
skipped++;
|
|
98
|
-
return false;
|
|
99
|
-
}
|
|
100
|
-
const candidate = { path: file, bytes: info.size };
|
|
101
|
-
if (prefixFiles + 1 > RG_PREFIX_FILES ||
|
|
102
|
-
prefixBytes + info.size > RG_PREFIX_BYTES) {
|
|
103
|
-
tail.push(candidate);
|
|
104
|
-
tailBytes += info.size;
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
prefixFiles++;
|
|
108
|
-
prefixBytes += info.size;
|
|
109
|
-
const searched = await portableSearch([candidate], scoped, needle, sensitive, limit - found.length);
|
|
97
|
+
const searched = await portableSearch(prefix.splice(0), scoped, needle, sensitive, limit - found.length);
|
|
110
98
|
found.push(...searched.matches);
|
|
111
99
|
skipped += searched.skipped;
|
|
112
100
|
return found.length >= limit;
|
|
101
|
+
};
|
|
102
|
+
const flushCandidates = async () => {
|
|
103
|
+
if (candidates.length === 0)
|
|
104
|
+
return false;
|
|
105
|
+
const inspected = await Promise.all(candidates.splice(0).map((lexical) => inspectCandidate(scoped.root, lexical)));
|
|
106
|
+
checkAbort(ctx.signal);
|
|
107
|
+
for (const result of inspected) {
|
|
108
|
+
if ("error" in result) {
|
|
109
|
+
if (!skippable(result.error))
|
|
110
|
+
throw result.error;
|
|
111
|
+
skipped++;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
const candidate = result.file;
|
|
115
|
+
if (candidate.bytes > MAX_FILE_BYTES) {
|
|
116
|
+
skipped++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (prefixFiles + 1 > RG_PREFIX_FILES ||
|
|
120
|
+
prefixBytes + candidate.bytes > RG_PREFIX_BYTES) {
|
|
121
|
+
tail.push(candidate);
|
|
122
|
+
tailBytes += candidate.bytes;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
prefixFiles++;
|
|
126
|
+
prefixBytes += candidate.bytes;
|
|
127
|
+
prefix.push(candidate);
|
|
128
|
+
if (prefix.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPrefix())
|
|
129
|
+
return true;
|
|
130
|
+
}
|
|
131
|
+
return false;
|
|
132
|
+
};
|
|
133
|
+
const walked = await walk(start, scoped, async (lexical) => {
|
|
134
|
+
const relative = displayPath(scoped.root, lexical);
|
|
135
|
+
if (!match(relative))
|
|
136
|
+
return false;
|
|
137
|
+
candidates.push(lexical);
|
|
138
|
+
return candidates.length >= PORTABLE_SEARCH_CONCURRENCY
|
|
139
|
+
? await flushCandidates()
|
|
140
|
+
: false;
|
|
113
141
|
});
|
|
114
|
-
|
|
142
|
+
if (found.length < limit)
|
|
143
|
+
await flushCandidates();
|
|
144
|
+
if (found.length < limit)
|
|
145
|
+
await flushPrefix();
|
|
146
|
+
const accelerated = found.length < limit && preferRipgrep(tail, tailBytes)
|
|
115
147
|
? await trySearchWithRipgrep({
|
|
116
148
|
root: scoped.root,
|
|
117
149
|
files: tail,
|
|
@@ -121,7 +153,7 @@ export const searchText = {
|
|
|
121
153
|
signal: ctx.signal,
|
|
122
154
|
})
|
|
123
155
|
: undefined;
|
|
124
|
-
const portable = accelerated === undefined && tail.length > 0
|
|
156
|
+
const portable = found.length < limit && accelerated === undefined && tail.length > 0
|
|
125
157
|
? await portableSearch(tail, scoped, needle, sensitive, limit - found.length)
|
|
126
158
|
: undefined;
|
|
127
159
|
found.push(...(portable?.matches ?? accelerated?.matches.map((match) => (`${displayPath(scoped.root, match.path)}:${match.line}:${clip(match.text)}`)) ?? []));
|
|
@@ -133,39 +165,69 @@ export const searchText = {
|
|
|
133
165
|
};
|
|
134
166
|
},
|
|
135
167
|
};
|
|
136
|
-
async function portableSearch(files, ctx, needle, sensitive, limit) {
|
|
168
|
+
export async function portableSearch(files, ctx, needle, sensitive, limit, read = readSearchFile) {
|
|
137
169
|
const found = [];
|
|
138
170
|
let skipped = 0;
|
|
139
|
-
for (
|
|
171
|
+
for (let start = 0; start < files.length; start += PORTABLE_SEARCH_CONCURRENCY) {
|
|
140
172
|
checkAbort(ctx.signal);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
173
|
+
const remaining = limit - found.length;
|
|
174
|
+
if (remaining <= 0)
|
|
175
|
+
break;
|
|
176
|
+
const batch = await Promise.all(files.slice(start, start + PORTABLE_SEARCH_CONCURRENCY)
|
|
177
|
+
.map((file) => searchFile(file, ctx, needle, sensitive, remaining, read)));
|
|
178
|
+
for (const searched of batch) {
|
|
179
|
+
if (searched === undefined) {
|
|
145
180
|
skipped++;
|
|
146
181
|
continue;
|
|
147
182
|
}
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
skipped++;
|
|
153
|
-
continue;
|
|
183
|
+
for (const match of searched) {
|
|
184
|
+
found.push(match);
|
|
185
|
+
if (found.length >= limit)
|
|
186
|
+
return { matches: found, skipped };
|
|
154
187
|
}
|
|
155
|
-
throw error;
|
|
156
|
-
}
|
|
157
|
-
for (const [index, line] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
|
|
158
|
-
checkAbort(ctx.signal);
|
|
159
|
-
const haystack = sensitive ? line : line.toLocaleLowerCase();
|
|
160
|
-
if (!haystack.includes(needle))
|
|
161
|
-
continue;
|
|
162
|
-
found.push(`${displayPath(ctx.root, file.path)}:${index + 1}:${clip(line)}`);
|
|
163
|
-
if (found.length >= limit)
|
|
164
|
-
return { matches: found, skipped };
|
|
165
188
|
}
|
|
166
189
|
}
|
|
167
190
|
return { matches: found, skipped };
|
|
168
191
|
}
|
|
192
|
+
async function inspectCandidate(root, lexical) {
|
|
193
|
+
try {
|
|
194
|
+
const file = await resolveExistingInRoot(root, lexical);
|
|
195
|
+
return { file: { path: file, bytes: (await fs.stat(file)).size } };
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
return { error };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
async function searchFile(file, ctx, needle, sensitive, limit, read) {
|
|
202
|
+
let data;
|
|
203
|
+
try {
|
|
204
|
+
data = await read(file.path, ctx.signal);
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
checkAbort(ctx.signal);
|
|
208
|
+
if (skippable(error))
|
|
209
|
+
return undefined;
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
checkAbort(ctx.signal);
|
|
213
|
+
if (data.byteLength > MAX_FILE_BYTES || data.includes(0))
|
|
214
|
+
return undefined;
|
|
215
|
+
const found = [];
|
|
216
|
+
const text = data.toString("utf8");
|
|
217
|
+
for (const [index, line] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
|
|
218
|
+
checkAbort(ctx.signal);
|
|
219
|
+
const haystack = sensitive ? line : line.toLocaleLowerCase();
|
|
220
|
+
if (!haystack.includes(needle))
|
|
221
|
+
continue;
|
|
222
|
+
found.push(`${displayPath(ctx.root, file.path)}:${index + 1}:${clip(line)}`);
|
|
223
|
+
if (found.length >= limit)
|
|
224
|
+
break;
|
|
225
|
+
}
|
|
226
|
+
return found;
|
|
227
|
+
}
|
|
228
|
+
function readSearchFile(file, signal) {
|
|
229
|
+
return signal === undefined ? fs.readFile(file) : fs.readFile(file, { signal });
|
|
230
|
+
}
|
|
169
231
|
function preferRipgrep(files, bytes) {
|
|
170
232
|
return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
|
|
171
233
|
}
|
|
@@ -335,7 +397,7 @@ function summary(count, limit, capped, one, many) {
|
|
|
335
397
|
function clip(line) {
|
|
336
398
|
if (line.length <= MAX_MATCH_LINE)
|
|
337
399
|
return line;
|
|
338
|
-
return `${line
|
|
400
|
+
return `${leadingText(line, MAX_MATCH_LINE - 1)}…`;
|
|
339
401
|
}
|
|
340
402
|
function checkAbort(signal) {
|
|
341
403
|
if (signal?.aborted !== true)
|
package/dist/tools/shell.js
CHANGED
|
@@ -5,6 +5,7 @@ import * as path from "node:path";
|
|
|
5
5
|
import { optionalInt, requireString } from "./args.js";
|
|
6
6
|
import { credentialRedactor, redactCredentials, shellEnvironment } from "../credential-safety.js";
|
|
7
7
|
import { resolveExecutable } from "../executable.js";
|
|
8
|
+
import { leadingText, trailingText } from "./text-boundary.js";
|
|
8
9
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
9
10
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
10
11
|
const MAX_OUTPUT_CHARS = 30_000;
|
|
@@ -128,18 +129,25 @@ function capture(onOutput) {
|
|
|
128
129
|
let head = "";
|
|
129
130
|
let tail = "";
|
|
130
131
|
let total = 0;
|
|
132
|
+
let headClosed = false;
|
|
131
133
|
const appendSafe = (chunk) => {
|
|
132
134
|
total += chunk.length;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
135
|
+
let rest = chunk;
|
|
136
|
+
if (!headClosed) {
|
|
137
|
+
const candidate = `${head}${chunk}`;
|
|
138
|
+
head = leadingText(candidate, half);
|
|
139
|
+
rest = candidate.slice(head.length);
|
|
140
|
+
if (candidate.length > half)
|
|
141
|
+
headClosed = true;
|
|
142
|
+
}
|
|
143
|
+
if (rest !== "") {
|
|
144
|
+
tail = trailingText(`${tail}${rest}`, MAX_OUTPUT_CHARS - head.length);
|
|
145
|
+
}
|
|
138
146
|
if (chunk !== "")
|
|
139
147
|
onOutput?.(formatted().trimEnd());
|
|
140
148
|
};
|
|
141
149
|
const formatted = () => {
|
|
142
|
-
if (total
|
|
150
|
+
if (total === head.length + tail.length)
|
|
143
151
|
return `${head}${tail}`;
|
|
144
152
|
const cut = total - head.length - tail.length;
|
|
145
153
|
return `${head}\n\n[... ${cut} characters cut ...]\n\n${tail}`;
|
|
@@ -1,36 +1,51 @@
|
|
|
1
|
-
// Shared
|
|
1
|
+
// Shared size and truncation boundaries for text handled by workspace tools.
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import { lstat, open } from "node:fs/promises";
|
|
4
4
|
export const MAX_EDITABLE_BYTES = 4_000_000;
|
|
5
5
|
export const MAX_EDITABLE_CHARS = 1_000_000;
|
|
6
6
|
export const MAX_EDITABLE_LINES = 20_000;
|
|
7
7
|
const READ_CHUNK_BYTES = 64 * 1024;
|
|
8
|
+
const GRAPHEME_SEGMENTER = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
9
|
+
/** Keep a bounded prefix without returning part of a user-perceived character. */
|
|
10
|
+
export function leadingText(text, maxCodeUnits) {
|
|
11
|
+
if (maxCodeUnits <= 0)
|
|
12
|
+
return "";
|
|
13
|
+
if (text.length <= maxCodeUnits)
|
|
14
|
+
return text;
|
|
15
|
+
let end = 0;
|
|
16
|
+
for (const { index, segment } of GRAPHEME_SEGMENTER.segment(text)) {
|
|
17
|
+
const next = index + segment.length;
|
|
18
|
+
if (next > maxCodeUnits)
|
|
19
|
+
break;
|
|
20
|
+
end = next;
|
|
21
|
+
}
|
|
22
|
+
return text.slice(0, end);
|
|
23
|
+
}
|
|
24
|
+
/** Keep a bounded suffix without returning part of a user-perceived character. */
|
|
25
|
+
export function trailingText(text, maxCodeUnits) {
|
|
26
|
+
if (maxCodeUnits <= 0)
|
|
27
|
+
return "";
|
|
28
|
+
if (text.length <= maxCodeUnits)
|
|
29
|
+
return text;
|
|
30
|
+
let start = text.length;
|
|
31
|
+
for (const { index } of GRAPHEME_SEGMENTER.segment(text)) {
|
|
32
|
+
if (text.length - index <= maxCodeUnits) {
|
|
33
|
+
start = index;
|
|
34
|
+
break;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return text.slice(start);
|
|
38
|
+
}
|
|
8
39
|
/** Read a regular UTF-8 file without allowing an unbounded allocation. */
|
|
9
40
|
export async function readEditableText(file, options = {}) {
|
|
10
41
|
const label = options.label ?? "file";
|
|
11
|
-
|
|
12
|
-
try {
|
|
13
|
-
details = await lstat(file);
|
|
14
|
-
}
|
|
15
|
-
catch (error) {
|
|
16
|
-
if (options.missingAsEmpty === true && isMissing(error))
|
|
17
|
-
return "";
|
|
18
|
-
throw error;
|
|
19
|
-
}
|
|
42
|
+
const details = await lstat(file);
|
|
20
43
|
if (!details.isFile())
|
|
21
44
|
throw new Error(`${label} must be a regular file`);
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
27
|
-
handle = await open(file, flags);
|
|
28
|
-
}
|
|
29
|
-
catch (error) {
|
|
30
|
-
if (options.missingAsEmpty === true && isMissing(error))
|
|
31
|
-
return "";
|
|
32
|
-
throw error;
|
|
33
|
-
}
|
|
45
|
+
const flags = process.platform === "win32"
|
|
46
|
+
? "r"
|
|
47
|
+
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
48
|
+
const handle = await open(file, flags);
|
|
34
49
|
try {
|
|
35
50
|
const stat = await handle.stat();
|
|
36
51
|
if (!stat.isFile())
|
|
@@ -97,6 +112,3 @@ function newlineCount(text) {
|
|
|
97
112
|
function limitError(label, limit) {
|
|
98
113
|
return new Error(`${label} exceeds the whole-file mutation limit of ${limit}`);
|
|
99
114
|
}
|
|
100
|
-
function isMissing(error) {
|
|
101
|
-
return error.code === "ENOENT";
|
|
102
|
-
}
|
package/dist/tui/app-state.js
CHANGED
|
@@ -4,7 +4,7 @@ import { runTurn } from "../controller.js";
|
|
|
4
4
|
import { resolveContextPolicy } from "../context/capacity.js";
|
|
5
5
|
import { compactContext } from "../context/compactor.js";
|
|
6
6
|
import { compactSession } from "../context/manual.js";
|
|
7
|
-
import { isContextOverflow
|
|
7
|
+
import { isContextOverflow } from "../context/policy.js";
|
|
8
8
|
import { updateSettings } from "../settings.js";
|
|
9
9
|
import { saveTranscript } from "../transcript-export.js";
|
|
10
10
|
import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
|
|
@@ -117,7 +117,31 @@ export function appWorkflows(options) {
|
|
|
117
117
|
const prospectiveNodeId = session.conversation.nodes.length + 1;
|
|
118
118
|
let nodeId;
|
|
119
119
|
let context;
|
|
120
|
-
let
|
|
120
|
+
let firstPolicy = true;
|
|
121
|
+
const policy = () => {
|
|
122
|
+
let visible = firstPolicy;
|
|
123
|
+
firstPolicy = false;
|
|
124
|
+
if (visible) {
|
|
125
|
+
state.status = "Checking context";
|
|
126
|
+
options.render();
|
|
127
|
+
}
|
|
128
|
+
return resolveContextPolicy({
|
|
129
|
+
provider: session.provider,
|
|
130
|
+
model: session.model,
|
|
131
|
+
compactionPercent: session.config.compactionPercent,
|
|
132
|
+
signal: activity.control.signal,
|
|
133
|
+
onStatus: (status) => {
|
|
134
|
+
visible = true;
|
|
135
|
+
state.status = status;
|
|
136
|
+
options.render();
|
|
137
|
+
},
|
|
138
|
+
}).finally(() => {
|
|
139
|
+
if (visible) {
|
|
140
|
+
state.status = WAITING;
|
|
141
|
+
options.render();
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
};
|
|
121
145
|
options.emit({ kind: "user", text });
|
|
122
146
|
const user = { role: "user", content: [{ kind: "text", text }] };
|
|
123
147
|
history.push(user);
|
|
@@ -157,31 +181,12 @@ export function appWorkflows(options) {
|
|
|
157
181
|
nodeId = next.activeNodeId;
|
|
158
182
|
state.committedNodeId = next.activeNodeId;
|
|
159
183
|
};
|
|
160
|
-
const compact = async (checkpoint, projected,
|
|
161
|
-
if (reason === "overflow" &&
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
const force = reason === "overflow";
|
|
165
|
-
if (!shouldResolveContextPolicy(projected, session.usage.lastInputTokens, force)) {
|
|
184
|
+
const compact = async (checkpoint, projected, request) => {
|
|
185
|
+
if (request.reason === "overflow" &&
|
|
186
|
+
(request.error === undefined || !isContextOverflow(request.error))) {
|
|
166
187
|
return undefined;
|
|
167
188
|
}
|
|
168
|
-
|
|
169
|
-
state.status = "Checking context";
|
|
170
|
-
options.render();
|
|
171
|
-
contextPolicy = resolveContextPolicy({
|
|
172
|
-
provider: session.provider,
|
|
173
|
-
model: session.model,
|
|
174
|
-
compactionPercent: session.config.compactionPercent,
|
|
175
|
-
signal: activity.control.signal,
|
|
176
|
-
onStatus: (status) => {
|
|
177
|
-
state.status = status;
|
|
178
|
-
options.render();
|
|
179
|
-
},
|
|
180
|
-
}).finally(() => {
|
|
181
|
-
state.status = WAITING;
|
|
182
|
-
options.render();
|
|
183
|
-
});
|
|
184
|
-
}
|
|
189
|
+
const force = request.reason === "overflow";
|
|
185
190
|
const result = await compactContext({
|
|
186
191
|
provider: session.provider,
|
|
187
192
|
model: session.model,
|
|
@@ -190,10 +195,10 @@ export function appWorkflows(options) {
|
|
|
190
195
|
turn: checkpoint.slice(historyStart),
|
|
191
196
|
nodeId: nodeId ?? prospectiveNodeId,
|
|
192
197
|
coveredMessages: context?.messageCount ?? 0,
|
|
193
|
-
lastInputTokens: session.usage.lastInputTokens,
|
|
198
|
+
lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
|
|
194
199
|
signal: activity.control.signal,
|
|
195
200
|
force,
|
|
196
|
-
policy:
|
|
201
|
+
policy: request.policy,
|
|
197
202
|
onBegin: () => {
|
|
198
203
|
state.status = "Compacting";
|
|
199
204
|
options.render();
|
|
@@ -213,7 +218,11 @@ export function appWorkflows(options) {
|
|
|
213
218
|
events.onContext = compact;
|
|
214
219
|
events.onCheckpoint = async (checkpoint, settlement, projected) => {
|
|
215
220
|
await persist(checkpoint, settlement);
|
|
216
|
-
const compacted = await compact(checkpoint, projected,
|
|
221
|
+
const compacted = await compact(checkpoint, projected, {
|
|
222
|
+
reason: "budget",
|
|
223
|
+
policy: await policy(),
|
|
224
|
+
inputTokens: session.usage.lastInputTokens,
|
|
225
|
+
});
|
|
217
226
|
if (compacted !== undefined)
|
|
218
227
|
await persist(checkpoint, settlement);
|
|
219
228
|
return compacted;
|
|
@@ -221,7 +230,7 @@ export function appWorkflows(options) {
|
|
|
221
230
|
let finishReason;
|
|
222
231
|
let failed;
|
|
223
232
|
try {
|
|
224
|
-
await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal, modelHistory);
|
|
233
|
+
await runTurn(history, controllerOptions(session, policy, permissions.availableTools()), events, activity.control.signal, modelHistory);
|
|
225
234
|
}
|
|
226
235
|
catch (error) {
|
|
227
236
|
const interrupted = activity.control.signal.aborted;
|