@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.
Files changed (43) hide show
  1. package/README.md +87 -23
  2. package/dist/atomic.js +14 -0
  3. package/dist/batch.js +26 -19
  4. package/dist/cli-info.js +1 -1
  5. package/dist/config.js +3 -2
  6. package/dist/context/budget.js +37 -0
  7. package/dist/context/compactor.js +9 -2
  8. package/dist/context/estimate.js +31 -0
  9. package/dist/context/policy.js +24 -14
  10. package/dist/controller-request.js +27 -4
  11. package/dist/controller.js +6 -4
  12. package/dist/conversation.js +48 -31
  13. package/dist/oauth-http.js +2 -1
  14. package/dist/openai-oauth-callback.js +2 -1
  15. package/dist/providers/anthropic.js +1 -1
  16. package/dist/providers/http.js +8 -5
  17. package/dist/providers/ollama.js +1 -1
  18. package/dist/providers/openai-codex.js +1 -3
  19. package/dist/providers/openai.js +1 -1
  20. package/dist/providers/sse.js +117 -40
  21. package/dist/providers/stream-limits.js +14 -2
  22. package/dist/sessions/store.js +2 -1
  23. package/dist/text-boundary.js +47 -0
  24. package/dist/timeline.js +2 -1
  25. package/dist/tools/fs.js +85 -38
  26. package/dist/tools/search.js +106 -44
  27. package/dist/tools/shell.js +14 -6
  28. package/dist/tools/text-boundary.js +7 -25
  29. package/dist/tui/app-state.js +0 -1
  30. package/dist/tui/app-workflows.js +40 -30
  31. package/dist/tui/app.js +12 -14
  32. package/dist/tui/blocks.js +0 -2
  33. package/dist/tui/components/messages.js +2 -5
  34. package/dist/tui/components/tool.js +11 -10
  35. package/dist/tui/session-view.js +2 -1
  36. package/dist/tui/transcript-view.js +178 -106
  37. package/dist/tui/turn.js +29 -8
  38. package/dist/tui/view.js +8 -3
  39. package/dist/ui/diff.js +51 -16
  40. package/dist/ui/render.js +16 -24
  41. package/dist/ui/width.js +31 -22
  42. package/dist/usage.js +5 -1
  43. 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
- return { before: await current(target), after: content };
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
- const root = await resolveExistingInRoot(ctx.root, ".");
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 { before, after: applied(before, args).after };
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
- const root = await resolveExistingInRoot(ctx.root, ".");
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
- if (room > 0)
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, or nothing at all — a file that is not there yet. */
288
- async function current(target) {
289
- return readEditableText(target, { missingAsEmpty: true });
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
- async function unchangedSinceApproval(target, approved) {
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 (approved !== undefined && onDisk !== approved) {
301
- throw new Error("file changed after the preview — inspect it and retry the write");
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";
@@ -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 walked = await walk(start, scoped, async (lexical) => {
91
- const relative = displayPath(scoped.root, lexical);
92
- if (!match(relative))
94
+ const flushPrefix = async () => {
95
+ if (prefix.length === 0)
93
96
  return false;
94
- const file = await resolveExistingInRoot(scoped.root, lexical);
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
- const accelerated = preferRipgrep(tail, tailBytes)
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 (const file of files) {
171
+ for (let start = 0; start < files.length; start += PORTABLE_SEARCH_CONCURRENCY) {
140
172
  checkAbort(ctx.signal);
141
- let text;
142
- try {
143
- const data = await fs.readFile(file.path);
144
- if (data.includes(0)) {
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
- text = data.toString("utf8");
149
- }
150
- catch (error) {
151
- if (skippable(error)) {
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.slice(0, MAX_MATCH_LINE - 1)}…`;
400
+ return `${leadingText(line, MAX_MATCH_LINE - 1)}…`;
339
401
  }
340
402
  function checkAbort(signal) {
341
403
  if (signal?.aborted !== true)
@@ -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
- const room = Math.max(0, half - head.length);
134
- head += chunk.slice(0, room);
135
- const rest = chunk.slice(room);
136
- if (rest !== "")
137
- tail = `${tail}${rest}`.slice(-half);
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 <= MAX_OUTPUT_CHARS)
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 limits for tools that need to hold an entire text file in memory.
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
- let details;
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
- let handle;
23
- try {
24
- const flags = process.platform === "win32"
25
- ? "r"
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
- }
@@ -11,7 +11,6 @@ export function appState() {
11
11
  past: [],
12
12
  recall: -1,
13
13
  draft: "",
14
- spin: 0,
15
14
  closeWhenIdle: false,
16
15
  committedNodeId: 0,
17
16
  };
@@ -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, shouldResolveContextPolicy } from "../context/policy.js";
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 contextPolicy;
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, reason, error) => {
161
- if (reason === "overflow" && (error === undefined || !isContextOverflow(error))) {
162
- return undefined;
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
- if (contextPolicy === undefined) {
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: await contextPolicy,
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, "budget");
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;