@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.
@@ -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,36 +1,51 @@
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
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
- 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
- }
42
+ const details = await lstat(file);
20
43
  if (!details.isFile())
21
44
  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
- }
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
- }
@@ -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,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, 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
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 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
+ };
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, reason, error) => {
159
- if (reason === "overflow" && (error === undefined || !isContextOverflow(error))) {
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: await contextPolicy,
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, "budget");
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
- finishReason = interrupted ? "interrupted" : "failed";
226
- options.emit(turnFailure(session, error, interrupted));
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
- events.finish(finishReason);
230
- options.finishActivity(activity);
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
+ }