@giovannijecha/jecode 0.7.2 → 0.7.4
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 +87 -23
- package/dist/atomic.js +14 -0
- package/dist/batch.js +26 -19
- 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 +24 -14
- package/dist/controller-request.js +27 -4
- package/dist/controller.js +6 -4
- package/dist/conversation.js +48 -31
- package/dist/oauth-http.js +2 -1
- package/dist/openai-oauth-callback.js +2 -1
- package/dist/providers/anthropic.js +1 -1
- package/dist/providers/http.js +8 -5
- package/dist/providers/ollama.js +1 -1
- package/dist/providers/openai-codex.js +1 -3
- package/dist/providers/openai.js +1 -1
- package/dist/providers/sse.js +117 -40
- package/dist/providers/stream-limits.js +14 -2
- package/dist/sessions/store.js +2 -1
- package/dist/text-boundary.js +47 -0
- package/dist/timeline.js +2 -1
- package/dist/tools/fs.js +85 -38
- package/dist/tools/search.js +106 -44
- package/dist/tools/shell.js +14 -6
- package/dist/tools/text-boundary.js +7 -25
- package/dist/tui/app-state.js +0 -1
- package/dist/tui/app-workflows.js +40 -30
- package/dist/tui/app.js +12 -14
- package/dist/tui/blocks.js +0 -2
- package/dist/tui/components/messages.js +2 -5
- package/dist/tui/components/tool.js +11 -10
- package/dist/tui/session-view.js +2 -1
- package/dist/tui/transcript-view.js +178 -106
- package/dist/tui/turn.js +29 -8
- package/dist/tui/view.js +8 -3
- package/dist/ui/diff.js +51 -16
- package/dist/ui/render.js +16 -24
- package/dist/ui/width.js +31 -22
- package/dist/usage.js +5 -1
- 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,38 @@ 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
|
+
throwIfAborted(ctx.signal);
|
|
124
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
125
|
+
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"));
|
|
126
|
+
const content = requireString(args, "content", true);
|
|
127
|
+
assertEditableText(content);
|
|
128
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
129
|
+
throwIfAborted(ctx.signal);
|
|
130
|
+
await assertDirectWritableInRoot(root, target);
|
|
131
|
+
const before = await current(target);
|
|
132
|
+
assertApproved(before, ctx.preview, "write");
|
|
133
|
+
await dependencies.atomicWrite(target, content, {
|
|
134
|
+
signal: ctx.signal,
|
|
135
|
+
async validate(phase) {
|
|
136
|
+
await assertDirectWritableInRoot(root, target);
|
|
137
|
+
if (phase === "before-rename") {
|
|
138
|
+
await assertUnchanged(target, before, "write", ctx.preview !== undefined);
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
output: `wrote ${displayPath(root, target)} (${content.length} characters)`,
|
|
144
|
+
summary: count(content, "line"),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
132
147
|
export const editFile = {
|
|
133
148
|
name: "edit_file",
|
|
134
149
|
description: "Replace an exact string in a file. The old text must appear exactly once " +
|
|
@@ -150,32 +165,45 @@ export const editFile = {
|
|
|
150
165
|
async preview(args, ctx) {
|
|
151
166
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
152
167
|
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
153
|
-
const before = await current(target);
|
|
168
|
+
const before = await current(target, true);
|
|
154
169
|
// An edit that will not apply gets no preview: the run is about to say so
|
|
155
170
|
// properly, and a diff of a match that does not exist would be a lie.
|
|
156
171
|
try {
|
|
157
|
-
return {
|
|
172
|
+
return {
|
|
173
|
+
before: before.text,
|
|
174
|
+
after: applied(before.text, args).after,
|
|
175
|
+
beforeExists: true,
|
|
176
|
+
};
|
|
158
177
|
}
|
|
159
178
|
catch {
|
|
160
179
|
return undefined;
|
|
161
180
|
}
|
|
162
181
|
},
|
|
163
182
|
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
|
-
};
|
|
183
|
+
return runEditFile(args, ctx);
|
|
177
184
|
},
|
|
178
185
|
};
|
|
186
|
+
export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEPENDENCIES) {
|
|
187
|
+
throwIfAborted(ctx.signal);
|
|
188
|
+
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
189
|
+
const target = await resolveDirectWritableInRoot(root, requireString(args, "path"), true);
|
|
190
|
+
const before = await current(target, true);
|
|
191
|
+
assertApproved(before, ctx.preview, "edit");
|
|
192
|
+
const { after, made } = applied(before.text, args);
|
|
193
|
+
await dependencies.atomicWrite(target, after, {
|
|
194
|
+
signal: ctx.signal,
|
|
195
|
+
async validate(phase) {
|
|
196
|
+
await assertDirectWritableInRoot(root, target, true);
|
|
197
|
+
if (phase === "before-rename") {
|
|
198
|
+
await assertUnchanged(target, before, "edit", ctx.preview !== undefined);
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
return {
|
|
203
|
+
output: `edited ${displayPath(root, target)} (${made} replacement${made === 1 ? "" : "s"})`,
|
|
204
|
+
summary: plural(made, "replacement", "replacements"),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
179
207
|
async function readRange(target, offset, limit, signal) {
|
|
180
208
|
throwIfAborted(signal);
|
|
181
209
|
const firstLine = Math.max(1, offset ?? 1);
|
|
@@ -203,8 +231,7 @@ async function readRange(target, offset, limit, signal) {
|
|
|
203
231
|
return;
|
|
204
232
|
const room = MAX_READ_CHARS - text.length;
|
|
205
233
|
if (fragment.length > room) {
|
|
206
|
-
|
|
207
|
-
text += fragment.slice(0, room);
|
|
234
|
+
text += leadingText(fragment, room);
|
|
208
235
|
truncated = true;
|
|
209
236
|
stopped = true;
|
|
210
237
|
return;
|
|
@@ -284,9 +311,17 @@ function applied(before, args) {
|
|
|
284
311
|
assertEditableText(after, "edited content");
|
|
285
312
|
return { after, made };
|
|
286
313
|
}
|
|
287
|
-
/** What is on disk now,
|
|
288
|
-
async function current(target) {
|
|
289
|
-
|
|
314
|
+
/** What is on disk now, preserving the difference between absent and empty. */
|
|
315
|
+
async function current(target, mustExist = false) {
|
|
316
|
+
try {
|
|
317
|
+
return { exists: true, text: await readEditableText(target) };
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
if (!mustExist && error.code === "ENOENT") {
|
|
321
|
+
return { exists: false, text: "" };
|
|
322
|
+
}
|
|
323
|
+
throw error;
|
|
324
|
+
}
|
|
290
325
|
}
|
|
291
326
|
function countOccurrences(haystack, needle) {
|
|
292
327
|
let occurrences = 0;
|
|
@@ -295,12 +330,24 @@ function countOccurrences(haystack, needle) {
|
|
|
295
330
|
}
|
|
296
331
|
return occurrences;
|
|
297
332
|
}
|
|
298
|
-
|
|
333
|
+
function assertApproved(current, preview, operation) {
|
|
334
|
+
if (preview === undefined)
|
|
335
|
+
return;
|
|
336
|
+
const existenceChanged = preview.beforeExists !== undefined && current.exists !== preview.beforeExists;
|
|
337
|
+
if (existenceChanged || current.text !== preview.before) {
|
|
338
|
+
throw changedFile(operation, true);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async function assertUnchanged(target, expected, operation, previewed) {
|
|
299
342
|
const onDisk = await current(target);
|
|
300
|
-
if (
|
|
301
|
-
throw
|
|
343
|
+
if (onDisk.exists !== expected.exists || onDisk.text !== expected.text) {
|
|
344
|
+
throw changedFile(operation, previewed);
|
|
302
345
|
}
|
|
303
346
|
}
|
|
347
|
+
function changedFile(operation, previewed) {
|
|
348
|
+
const when = previewed ? "after the preview" : "while preparing the change";
|
|
349
|
+
return new Error(`file changed ${when} — inspect it and retry the ${operation}`);
|
|
350
|
+
}
|
|
304
351
|
function count(text, noun) {
|
|
305
352
|
if (text === "")
|
|
306
353
|
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,6 +1,7 @@
|
|
|
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
|
+
export { leadingText, trailingText } from "../text-boundary.js";
|
|
4
5
|
export const MAX_EDITABLE_BYTES = 4_000_000;
|
|
5
6
|
export const MAX_EDITABLE_CHARS = 1_000_000;
|
|
6
7
|
export const MAX_EDITABLE_LINES = 20_000;
|
|
@@ -8,29 +9,13 @@ const READ_CHUNK_BYTES = 64 * 1024;
|
|
|
8
9
|
/** Read a regular UTF-8 file without allowing an unbounded allocation. */
|
|
9
10
|
export async function readEditableText(file, options = {}) {
|
|
10
11
|
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
|
-
}
|
|
12
|
+
const details = await lstat(file);
|
|
20
13
|
if (!details.isFile())
|
|
21
14
|
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
|
-
}
|
|
15
|
+
const flags = process.platform === "win32"
|
|
16
|
+
? "r"
|
|
17
|
+
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
18
|
+
const handle = await open(file, flags);
|
|
34
19
|
try {
|
|
35
20
|
const stat = await handle.stat();
|
|
36
21
|
if (!stat.isFile())
|
|
@@ -97,6 +82,3 @@ function newlineCount(text) {
|
|
|
97
82
|
function limitError(label, limit) {
|
|
98
83
|
return new Error(`${label} exceeds the whole-file mutation limit of ${limit}`);
|
|
99
84
|
}
|
|
100
|
-
function isMissing(error) {
|
|
101
|
-
return error.code === "ENOENT";
|
|
102
|
-
}
|
package/dist/tui/app-state.js
CHANGED
|
@@ -4,10 +4,10 @@ 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
|
-
import { recordAuxiliaryUsage, recordUsage } from "../usage.js";
|
|
10
|
+
import { recordAuxiliaryUsage, recordRequestInput, recordUsage } from "../usage.js";
|
|
11
11
|
import { selectTimeline } from "../timeline.js";
|
|
12
12
|
import { answerAt } from "./approve.js";
|
|
13
13
|
import * as edit from "./editor.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);
|
|
@@ -136,6 +160,7 @@ export function appWorkflows(options) {
|
|
|
136
160
|
state.status = text;
|
|
137
161
|
},
|
|
138
162
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
163
|
+
requestInput: (inputTokens) => recordRequestInput(session.usage, inputTokens),
|
|
139
164
|
});
|
|
140
165
|
const persist = async (checkpoint, settlement, failure) => {
|
|
141
166
|
const next = session.conversation.commit({
|
|
@@ -157,31 +182,12 @@ export function appWorkflows(options) {
|
|
|
157
182
|
nodeId = next.activeNodeId;
|
|
158
183
|
state.committedNodeId = next.activeNodeId;
|
|
159
184
|
};
|
|
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)) {
|
|
185
|
+
const compact = async (checkpoint, projected, request) => {
|
|
186
|
+
if (request.reason === "overflow" &&
|
|
187
|
+
(request.error === undefined || !isContextOverflow(request.error))) {
|
|
166
188
|
return undefined;
|
|
167
189
|
}
|
|
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
|
-
}
|
|
190
|
+
const force = request.reason === "overflow";
|
|
185
191
|
const result = await compactContext({
|
|
186
192
|
provider: session.provider,
|
|
187
193
|
model: session.model,
|
|
@@ -190,10 +196,10 @@ export function appWorkflows(options) {
|
|
|
190
196
|
turn: checkpoint.slice(historyStart),
|
|
191
197
|
nodeId: nodeId ?? prospectiveNodeId,
|
|
192
198
|
coveredMessages: context?.messageCount ?? 0,
|
|
193
|
-
lastInputTokens: session.usage.lastInputTokens,
|
|
199
|
+
lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
|
|
194
200
|
signal: activity.control.signal,
|
|
195
201
|
force,
|
|
196
|
-
policy:
|
|
202
|
+
policy: request.policy,
|
|
197
203
|
onBegin: () => {
|
|
198
204
|
state.status = "Compacting";
|
|
199
205
|
options.render();
|
|
@@ -213,7 +219,11 @@ export function appWorkflows(options) {
|
|
|
213
219
|
events.onContext = compact;
|
|
214
220
|
events.onCheckpoint = async (checkpoint, settlement, projected) => {
|
|
215
221
|
await persist(checkpoint, settlement);
|
|
216
|
-
const compacted = await compact(checkpoint, projected,
|
|
222
|
+
const compacted = await compact(checkpoint, projected, {
|
|
223
|
+
reason: "budget",
|
|
224
|
+
policy: await policy(),
|
|
225
|
+
inputTokens: session.usage.lastInputTokens,
|
|
226
|
+
});
|
|
217
227
|
if (compacted !== undefined)
|
|
218
228
|
await persist(checkpoint, settlement);
|
|
219
229
|
return compacted;
|
|
@@ -221,7 +231,7 @@ export function appWorkflows(options) {
|
|
|
221
231
|
let finishReason;
|
|
222
232
|
let failed;
|
|
223
233
|
try {
|
|
224
|
-
await runTurn(history, controllerOptions(session, permissions.availableTools()), events, activity.control.signal, modelHistory);
|
|
234
|
+
await runTurn(history, controllerOptions(session, policy, permissions.availableTools()), events, activity.control.signal, modelHistory);
|
|
225
235
|
}
|
|
226
236
|
catch (error) {
|
|
227
237
|
const interrupted = activity.control.signal.aborted;
|