@giovannijecha/jecode 0.7.1 → 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 +20 -15
- 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/manual.js +1 -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 +89 -35
- 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/sessions/codec.js +54 -8
- package/dist/sessions/runtime.js +2 -2
- package/dist/sessions/store.js +54 -32
- package/dist/timeline.js +12 -6
- 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 +92 -34
- package/dist/tui/app.js +43 -20
- 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/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,12 +4,13 @@ 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";
|
|
11
11
|
import { selectTimeline } from "../timeline.js";
|
|
12
12
|
import { answerAt } from "./approve.js";
|
|
13
|
+
import * as edit from "./editor.js";
|
|
13
14
|
import { cancel as cancelOpen } from "./overlay.js";
|
|
14
15
|
import { controllerOptions, turnFailure } from "./session-view.js";
|
|
15
16
|
import { transcribe } from "./turn.js";
|
|
@@ -116,7 +117,31 @@ export function appWorkflows(options) {
|
|
|
116
117
|
const prospectiveNodeId = session.conversation.nodes.length + 1;
|
|
117
118
|
let nodeId;
|
|
118
119
|
let context;
|
|
119
|
-
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
|
+
};
|
|
120
145
|
options.emit({ kind: "user", text });
|
|
121
146
|
const user = { role: "user", content: [{ kind: "text", text }] };
|
|
122
147
|
history.push(user);
|
|
@@ -136,7 +161,7 @@ export function appWorkflows(options) {
|
|
|
136
161
|
},
|
|
137
162
|
usage: (usage) => recordUsage(session.usage, usage),
|
|
138
163
|
});
|
|
139
|
-
const persist = async (checkpoint, settlement) => {
|
|
164
|
+
const persist = async (checkpoint, settlement, failure) => {
|
|
140
165
|
const next = session.conversation.commit({
|
|
141
166
|
...(nodeId === undefined ? {} : { nodeId }),
|
|
142
167
|
parentId,
|
|
@@ -149,37 +174,19 @@ export function appWorkflows(options) {
|
|
|
149
174
|
messages: checkpoint.slice(historyStart),
|
|
150
175
|
blocks: state.blocks.slice(blockStart),
|
|
151
176
|
...(context === undefined ? {} : { context }),
|
|
177
|
+
...(failure === undefined ? {} : { failure }),
|
|
152
178
|
}, settlement);
|
|
153
179
|
await session.persistence?.checkpoint(next);
|
|
154
180
|
session.conversation = next;
|
|
155
181
|
nodeId = next.activeNodeId;
|
|
156
182
|
state.committedNodeId = next.activeNodeId;
|
|
157
183
|
};
|
|
158
|
-
const compact = async (checkpoint, projected,
|
|
159
|
-
if (reason === "overflow" &&
|
|
184
|
+
const compact = async (checkpoint, projected, request) => {
|
|
185
|
+
if (request.reason === "overflow" &&
|
|
186
|
+
(request.error === undefined || !isContextOverflow(request.error))) {
|
|
160
187
|
return undefined;
|
|
161
188
|
}
|
|
162
|
-
const force = reason === "overflow";
|
|
163
|
-
if (!shouldResolveContextPolicy(projected, session.usage.lastInputTokens, force)) {
|
|
164
|
-
return undefined;
|
|
165
|
-
}
|
|
166
|
-
if (contextPolicy === undefined) {
|
|
167
|
-
state.status = "Checking context";
|
|
168
|
-
options.render();
|
|
169
|
-
contextPolicy = resolveContextPolicy({
|
|
170
|
-
provider: session.provider,
|
|
171
|
-
model: session.model,
|
|
172
|
-
compactionPercent: session.config.compactionPercent,
|
|
173
|
-
signal: activity.control.signal,
|
|
174
|
-
onStatus: (status) => {
|
|
175
|
-
state.status = status;
|
|
176
|
-
options.render();
|
|
177
|
-
},
|
|
178
|
-
}).finally(() => {
|
|
179
|
-
state.status = WAITING;
|
|
180
|
-
options.render();
|
|
181
|
-
});
|
|
182
|
-
}
|
|
189
|
+
const force = request.reason === "overflow";
|
|
183
190
|
const result = await compactContext({
|
|
184
191
|
provider: session.provider,
|
|
185
192
|
model: session.model,
|
|
@@ -188,10 +195,10 @@ export function appWorkflows(options) {
|
|
|
188
195
|
turn: checkpoint.slice(historyStart),
|
|
189
196
|
nodeId: nodeId ?? prospectiveNodeId,
|
|
190
197
|
coveredMessages: context?.messageCount ?? 0,
|
|
191
|
-
lastInputTokens: session.usage.lastInputTokens,
|
|
198
|
+
lastInputTokens: Math.max(session.usage.lastInputTokens, request.inputTokens),
|
|
192
199
|
signal: activity.control.signal,
|
|
193
200
|
force,
|
|
194
|
-
policy:
|
|
201
|
+
policy: request.policy,
|
|
195
202
|
onBegin: () => {
|
|
196
203
|
state.status = "Compacting";
|
|
197
204
|
options.render();
|
|
@@ -211,24 +218,75 @@ export function appWorkflows(options) {
|
|
|
211
218
|
events.onContext = compact;
|
|
212
219
|
events.onCheckpoint = async (checkpoint, settlement, projected) => {
|
|
213
220
|
await persist(checkpoint, settlement);
|
|
214
|
-
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
|
+
});
|
|
215
226
|
if (compacted !== undefined)
|
|
216
227
|
await persist(checkpoint, settlement);
|
|
217
228
|
return compacted;
|
|
218
229
|
};
|
|
219
230
|
let finishReason;
|
|
231
|
+
let failed;
|
|
220
232
|
try {
|
|
221
|
-
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);
|
|
222
234
|
}
|
|
223
235
|
catch (error) {
|
|
224
236
|
const interrupted = activity.control.signal.aborted;
|
|
225
|
-
|
|
226
|
-
|
|
237
|
+
const completed = nodeId !== undefined && session.conversation.activeNodeId === nodeId &&
|
|
238
|
+
session.conversation.activeNode?.settlement === "completed";
|
|
239
|
+
if (completed) {
|
|
240
|
+
const notice = turnFailure(session, error, interrupted);
|
|
241
|
+
feedback.show({ text: notice.text, tone: notice.tone, timeoutMs: 6_000 });
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
finishReason = interrupted ? "interrupted" : "failed";
|
|
245
|
+
failed = { error: error, interrupted };
|
|
246
|
+
}
|
|
227
247
|
}
|
|
228
248
|
finally {
|
|
229
|
-
|
|
230
|
-
|
|
249
|
+
try {
|
|
250
|
+
events.finish(finishReason);
|
|
251
|
+
if (failed !== undefined) {
|
|
252
|
+
const notice = turnFailure(session, failed.error, failed.interrupted);
|
|
253
|
+
const settlement = failed.interrupted ? "interrupted" : "failed";
|
|
254
|
+
const failure = {
|
|
255
|
+
text: notice.text,
|
|
256
|
+
tone: failed.interrupted ? "warn" : "error",
|
|
257
|
+
};
|
|
258
|
+
options.emit(notice);
|
|
259
|
+
try {
|
|
260
|
+
await persist(closeFailedTurn(history, settlement), settlement, failure);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
// A failed persistence boundary cannot remain visible as if it had
|
|
264
|
+
// been saved. Revert to the last durable path and return the input
|
|
265
|
+
// to the composer so the user can retry without losing it.
|
|
266
|
+
options.replaceTranscript();
|
|
267
|
+
state.editor = edit.of(text);
|
|
268
|
+
feedback.show({
|
|
269
|
+
text: error.message,
|
|
270
|
+
tone: "error",
|
|
271
|
+
timeoutMs: 6_000,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
finally {
|
|
277
|
+
options.finishActivity(activity);
|
|
278
|
+
}
|
|
231
279
|
}
|
|
232
280
|
}
|
|
233
281
|
return { command, turn };
|
|
234
282
|
}
|
|
283
|
+
function closeFailedTurn(history, settlement) {
|
|
284
|
+
const closed = [...history];
|
|
285
|
+
if (closed.at(-1)?.role === "assistant")
|
|
286
|
+
return closed;
|
|
287
|
+
const text = settlement === "interrupted"
|
|
288
|
+
? "The previous attempt was interrupted by the user before completion."
|
|
289
|
+
: "The previous attempt failed before completion.";
|
|
290
|
+
closed.push({ role: "assistant", content: [{ kind: "text", text }] });
|
|
291
|
+
return closed;
|
|
292
|
+
}
|