@giovannijecha/jecode 0.8.4 → 0.8.6

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 (99) hide show
  1. package/README.md +28 -9
  2. package/dist/accounts.js +47 -10
  3. package/dist/atomic.js +24 -14
  4. package/dist/batch-view.js +27 -2
  5. package/dist/batch.js +39 -4
  6. package/dist/bounded-file.js +212 -0
  7. package/dist/cli-info.js +0 -1
  8. package/dist/commands.js +2 -0
  9. package/dist/config.js +16 -7
  10. package/dist/context/automatic.js +35 -0
  11. package/dist/context/compactor.js +32 -2
  12. package/dist/context/manual.js +20 -3
  13. package/dist/context/request-projection.js +130 -0
  14. package/dist/controller-request.js +33 -11
  15. package/dist/credential-commands.js +25 -9
  16. package/dist/credentials.js +56 -18
  17. package/dist/directory-anchor.js +91 -0
  18. package/dist/file-identity.js +12 -0
  19. package/dist/model-command.js +5 -4
  20. package/dist/openai-account-command.js +23 -10
  21. package/dist/openai-account.js +7 -5
  22. package/dist/openai-oauth-callback.js +13 -4
  23. package/dist/openai-oauth-tokens.js +9 -7
  24. package/dist/openai-oauth.js +8 -6
  25. package/dist/permission-command.js +1 -1
  26. package/dist/process-lease.js +329 -0
  27. package/dist/provider-commands.js +11 -37
  28. package/dist/provider-errors.js +31 -7
  29. package/dist/provider-label.js +9 -3
  30. package/dist/providers/anthropic-stream.js +4 -1
  31. package/dist/providers/anthropic-wire.js +8 -3
  32. package/dist/providers/anthropic.js +39 -20
  33. package/dist/providers/catalog.js +4 -4
  34. package/dist/providers/failure.js +181 -0
  35. package/dist/providers/http.js +82 -23
  36. package/dist/providers/index.js +1 -5
  37. package/dist/providers/ollama-context.js +42 -0
  38. package/dist/providers/ollama-endpoint.js +7 -34
  39. package/dist/providers/ollama-stream.js +5 -1
  40. package/dist/providers/ollama.js +36 -158
  41. package/dist/providers/openai-codex.js +71 -45
  42. package/dist/providers/openai-stream.js +26 -2
  43. package/dist/providers/openai.js +51 -25
  44. package/dist/providers/sse.js +52 -8
  45. package/dist/request-identity.js +32 -0
  46. package/dist/sessions/bucket.js +55 -0
  47. package/dist/sessions/catalog-io.js +162 -0
  48. package/dist/sessions/catalog.js +3 -1
  49. package/dist/sessions/codec-messages.js +122 -0
  50. package/dist/sessions/codec-transcript.js +93 -0
  51. package/dist/sessions/codec-values.js +52 -0
  52. package/dist/sessions/codec.js +4 -257
  53. package/dist/sessions/files.js +158 -0
  54. package/dist/sessions/lease.js +132 -49
  55. package/dist/sessions/load.js +90 -0
  56. package/dist/sessions/runtime.js +15 -8
  57. package/dist/sessions/snapshot.js +33 -0
  58. package/dist/sessions/store.js +183 -378
  59. package/dist/settings-command.js +10 -5
  60. package/dist/settings.js +75 -22
  61. package/dist/stable-directory.js +148 -0
  62. package/dist/start.js +1 -2
  63. package/dist/store-lock.js +68 -84
  64. package/dist/tools/args.js +2 -2
  65. package/dist/tools/file-read.js +192 -0
  66. package/dist/tools/file-summary.js +9 -0
  67. package/dist/tools/{fs.js → file-write.js} +5 -171
  68. package/dist/tools/glob.js +107 -0
  69. package/dist/tools/index.js +2 -1
  70. package/dist/tools/search.js +66 -205
  71. package/dist/tools/text-boundary.js +7 -33
  72. package/dist/tui/app-workflows.js +9 -334
  73. package/dist/tui/approve.js +5 -3
  74. package/dist/tui/blocks.js +8 -7
  75. package/dist/tui/command-workflow.js +106 -0
  76. package/dist/tui/components/command-menu.js +7 -10
  77. package/dist/tui/components/footer.js +1 -1
  78. package/dist/tui/components/menu.js +74 -43
  79. package/dist/tui/components/messages.js +16 -9
  80. package/dist/tui/components/tool-evidence.js +107 -0
  81. package/dist/tui/components/tool-motion.js +32 -0
  82. package/dist/tui/components/tool.js +48 -202
  83. package/dist/tui/feedback.js +4 -0
  84. package/dist/tui/help.js +1 -1
  85. package/dist/tui/picker-layout.js +40 -0
  86. package/dist/tui/picker.js +7 -71
  87. package/dist/tui/session-view.js +7 -2
  88. package/dist/tui/tool-details.js +135 -0
  89. package/dist/tui/transcript-grammar.js +8 -1
  90. package/dist/tui/transcript-view.js +26 -112
  91. package/dist/tui/turn-workflow.js +264 -0
  92. package/dist/tui/turn.js +7 -140
  93. package/dist/tui/workflow-types.js +2 -0
  94. package/dist/tui/workspace.js +21 -7
  95. package/dist/user-store.js +23 -31
  96. package/package.json +14 -15
  97. package/dist/ollama-settings-command.js +0 -74
  98. package/dist/tools/ripgrep.js +0 -230
  99. package/dist/tui/motion.js +0 -32
@@ -1,21 +1,18 @@
1
1
  // Bounded, read-only workspace discovery without borrowing a shell.
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
+ import { BoundedFileError, readBoundedFile } from "../bounded-file.js";
5
+ import { readStableDirectory, StableDirectoryError, } from "../stable-directory.js";
4
6
  import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
5
7
  import { displayPath, resolveExistingInRoot } from "./paths.js";
6
- import { trySearchWithRipgrep } from "./ripgrep.js";
7
8
  import { leadingText } from "./text-boundary.js";
9
+ import { glob } from "./glob.js";
8
10
  const DEFAULT_RESULTS = 100;
9
11
  const MAX_RESULTS = 500;
10
12
  const MAX_VISITED = 20_000;
11
13
  const MAX_FILE_BYTES = 1_000_000;
12
14
  const MAX_MATCH_LINE = 500;
13
- const MAX_GLOB_CHARS = 512;
14
15
  const PORTABLE_SEARCH_CONCURRENCY = 8;
15
- const RG_PREFIX_BYTES = 2_000_000;
16
- const RG_PREFIX_FILES = 500;
17
- const MIN_RG_TAIL_BYTES = 2_000_000;
18
- const MIN_RG_TAIL_FILES = 500;
19
16
  const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
20
17
  export const findFiles = {
21
18
  name: "find_files",
@@ -84,80 +81,30 @@ export const searchText = {
84
81
  const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
85
82
  const limit = resultLimit(args);
86
83
  const found = [];
87
- const candidates = [];
88
- const prefix = [];
89
- const tail = [];
84
+ const pending = [];
90
85
  let skipped = 0;
91
- let prefixBytes = 0;
92
- let prefixFiles = 0;
93
- let tailBytes = 0;
94
- const flushPrefix = async () => {
95
- if (prefix.length === 0)
86
+ const flushPending = async () => {
87
+ if (pending.length === 0)
96
88
  return false;
97
- const searched = await portableSearch(prefix.splice(0), scoped, needle, sensitive, limit - found.length);
89
+ const searched = await portableSearch(pending.splice(0), scoped, needle, sensitive, limit - found.length);
98
90
  found.push(...searched.matches);
99
91
  skipped += searched.skipped;
100
92
  return found.length >= limit;
101
93
  };
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) => {
94
+ const walked = await walk(start, scoped, async (lexical, expected) => {
134
95
  const relative = displayPath(scoped.root, lexical);
135
96
  if (!match(relative))
136
97
  return false;
137
- candidates.push(lexical);
138
- return candidates.length >= PORTABLE_SEARCH_CONCURRENCY
139
- ? await flushCandidates()
140
- : false;
98
+ const bytes = Number(expected.size);
99
+ if (bytes > MAX_FILE_BYTES) {
100
+ skipped++;
101
+ return false;
102
+ }
103
+ pending.push({ path: lexical, bytes, expected });
104
+ return pending.length >= PORTABLE_SEARCH_CONCURRENCY && await flushPending();
141
105
  });
142
106
  if (found.length < limit)
143
- await flushCandidates();
144
- if (found.length < limit)
145
- await flushPrefix();
146
- const accelerated = found.length < limit && preferRipgrep(tail, tailBytes)
147
- ? await trySearchWithRipgrep({
148
- root: scoped.root,
149
- files: tail,
150
- query,
151
- caseSensitive: sensitive,
152
- limit: limit - found.length,
153
- signal: ctx.signal,
154
- })
155
- : undefined;
156
- const portable = found.length < limit && accelerated === undefined && tail.length > 0
157
- ? await portableSearch(tail, scoped, needle, sensitive, limit - found.length)
158
- : undefined;
159
- found.push(...(portable?.matches ?? accelerated?.matches.map((match) => (`${displayPath(scoped.root, match.path)}:${match.line}:${clip(match.text)}`)) ?? []));
160
- skipped += portable?.skipped ?? accelerated?.binaryPaths.length ?? 0;
107
+ await flushPending();
161
108
  const extra = skipped === 0 ? "" : ` · skipped ${skipped} binary/large/unreadable`;
162
109
  return {
163
110
  output: found.length === 0 ? "[no matches]" : found.join("\n"),
@@ -165,7 +112,7 @@ export const searchText = {
165
112
  };
166
113
  },
167
114
  };
168
- export async function portableSearch(files, ctx, needle, sensitive, limit, read = readSearchFile) {
115
+ export async function portableSearch(files, ctx, needle, sensitive, limit, read) {
169
116
  const found = [];
170
117
  let skipped = 0;
171
118
  for (let start = 0; start < files.length; start += PORTABLE_SEARCH_CONCURRENCY) {
@@ -189,19 +136,12 @@ export async function portableSearch(files, ctx, needle, sensitive, limit, read
189
136
  }
190
137
  return { matches: found, skipped };
191
138
  }
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
139
  async function searchFile(file, ctx, needle, sensitive, limit, read) {
202
140
  let data;
203
141
  try {
204
- data = await read(file.path, ctx.signal);
142
+ data = read === undefined
143
+ ? await readVerifiedSearchFile(file, ctx)
144
+ : await read(file.path, ctx.signal);
205
145
  }
206
146
  catch (error) {
207
147
  checkAbort(ctx.signal);
@@ -225,55 +165,79 @@ async function searchFile(file, ctx, needle, sensitive, limit, read) {
225
165
  }
226
166
  return found;
227
167
  }
228
- function readSearchFile(file, signal) {
229
- return signal === undefined ? fs.readFile(file) : fs.readFile(file, { signal });
230
- }
231
- function preferRipgrep(files, bytes) {
232
- return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
168
+ async function readVerifiedSearchFile(file, ctx) {
169
+ if (file.expected === undefined) {
170
+ throw new BoundedFileError("changed", "search file has no verified identity");
171
+ }
172
+ return readBoundedFile(file.path, MAX_FILE_BYTES, {
173
+ label: "search file",
174
+ signal: ctx.signal,
175
+ expected: file.expected,
176
+ validate: async () => {
177
+ const confirmed = await resolveExistingInRoot(ctx.root, file.path);
178
+ if (confirmed !== file.path) {
179
+ throw new BoundedFileError("changed", "search file left the workspace");
180
+ }
181
+ },
182
+ });
233
183
  }
234
184
  async function walk(start, ctx, visit) {
235
185
  const pending = [];
236
186
  let seen = 0;
187
+ let buffered = 0;
188
+ let capped = false;
237
189
  const enter = async (lexical) => {
238
190
  checkAbort(ctx.signal);
239
191
  const directory = await resolveExistingInRoot(ctx.root, lexical);
240
- let entries;
192
+ const room = MAX_VISITED - seen - buffered;
193
+ if (room <= 0) {
194
+ capped = true;
195
+ return false;
196
+ }
241
197
  try {
242
- entries = await fs.readdir(directory, { withFileTypes: true, encoding: "utf8" });
198
+ const inspected = await readStableDirectory(ctx.root, directory, {
199
+ maxEntries: room,
200
+ signal: ctx.signal,
201
+ });
202
+ if (inspected.capped)
203
+ capped = true;
204
+ const entries = [...inspected.entries];
205
+ buffered += entries.length;
206
+ pending.push({ directory, entries, next: 0 });
243
207
  }
244
208
  catch (error) {
245
209
  if (skippable(error))
246
- return;
210
+ return true;
247
211
  throw error;
248
212
  }
249
- entries.sort((a, b) => a.name.localeCompare(b.name));
250
- pending.push({ directory, entries, next: 0 });
213
+ return true;
251
214
  };
252
- await enter(start);
215
+ if (!await enter(start))
216
+ return { capped: true };
253
217
  while (pending.length > 0) {
254
218
  checkAbort(ctx.signal);
255
219
  const frame = pending[pending.length - 1];
256
220
  if (frame === undefined)
257
221
  break;
258
- const entry = frame.entries[frame.next++];
222
+ const index = frame.next++;
223
+ const entry = frame.entries[index];
259
224
  if (entry === undefined) {
260
225
  pending.pop();
261
226
  continue;
262
227
  }
263
- if (++seen > MAX_VISITED)
264
- return { capped: true };
265
- if (entry.isSymbolicLink())
266
- continue;
228
+ frame.entries[index] = undefined;
229
+ buffered--;
230
+ seen++;
267
231
  const target = path.join(frame.directory, entry.name);
268
- if (entry.isDirectory()) {
232
+ if (entry.kind === "directory") {
269
233
  if (!SKIP.has(entry.name))
270
234
  await enter(target);
271
235
  }
272
- else if (entry.isFile() && (await visit(target))) {
273
- return { capped: false };
236
+ else if (await visit(target, entry.expected)) {
237
+ return { capped };
274
238
  }
275
239
  }
276
- return { capped: false };
240
+ return { capped };
277
241
  }
278
242
  async function startAt(args, ctx) {
279
243
  const candidate = optionalString(args, "path") ?? ".";
@@ -291,110 +255,6 @@ function resultLimit(args) {
291
255
  throw new Error('"max_results" must be a positive integer');
292
256
  return Math.min(requested, MAX_RESULTS);
293
257
  }
294
- function glob(pattern) {
295
- const normalized = pattern.replace(/\\/g, "/");
296
- if (normalized.length > MAX_GLOB_CHARS) {
297
- throw new Error(`"pattern" must be at most ${MAX_GLOB_CHARS} characters`);
298
- }
299
- const tokens = tokenizeGlob(normalized.toLowerCase());
300
- const basenameOnly = !normalized.includes("/");
301
- return (relative) => {
302
- const candidate = relative.replace(/\\/g, "/");
303
- const target = (basenameOnly ? path.posix.basename(candidate) : candidate).toLowerCase();
304
- return matchGlob(tokens, Array.from(target));
305
- };
306
- }
307
- function tokenizeGlob(pattern) {
308
- const chars = Array.from(pattern);
309
- const tokens = [];
310
- let index = 0;
311
- while (index < chars.length) {
312
- const char = chars[index];
313
- if (char === "*") {
314
- let end = index + 1;
315
- while (chars[end] === "*")
316
- end++;
317
- if (end - index >= 2) {
318
- if (chars[end] === "/") {
319
- tokens.push({ kind: "globdir-start" }, { kind: "globdir-body" });
320
- index = end + 1;
321
- }
322
- else {
323
- tokens.push({ kind: "globstar" });
324
- index = end;
325
- }
326
- }
327
- else {
328
- tokens.push({ kind: "star" });
329
- index = end;
330
- }
331
- continue;
332
- }
333
- tokens.push(char === "?" ? { kind: "one" } : { kind: "literal", value: char });
334
- index++;
335
- }
336
- return tokens;
337
- }
338
- /** Thompson-style wildcard matching: O(pattern × path), with no regex backtracking. */
339
- function matchGlob(tokens, text) {
340
- let states = epsilonClosure(new Set([0]), tokens);
341
- for (const char of text) {
342
- const next = new Set();
343
- for (const state of states) {
344
- const token = tokens[state];
345
- if (token === undefined)
346
- continue;
347
- switch (token.kind) {
348
- case "literal":
349
- if (token.value === char)
350
- next.add(state + 1);
351
- break;
352
- case "one":
353
- if (char !== "/")
354
- next.add(state + 1);
355
- break;
356
- case "star":
357
- if (char !== "/")
358
- next.add(state);
359
- break;
360
- case "globstar":
361
- next.add(state);
362
- break;
363
- case "globdir-body":
364
- next.add(state);
365
- if (char === "/")
366
- next.add(state + 1);
367
- break;
368
- case "globdir-start":
369
- break;
370
- }
371
- }
372
- states = epsilonClosure(next, tokens);
373
- if (states.size === 0)
374
- return false;
375
- }
376
- return epsilonClosure(states, tokens).has(tokens.length);
377
- }
378
- function epsilonClosure(seed, tokens) {
379
- const states = new Set(seed);
380
- const pending = [...seed];
381
- while (pending.length > 0) {
382
- const state = pending.pop();
383
- const token = tokens[state];
384
- const targets = token?.kind === "globdir-start"
385
- ? [state + 1, state + 2]
386
- : token?.kind === "star" || token?.kind === "globstar"
387
- ? [state + 1]
388
- : [];
389
- for (const target of targets) {
390
- if (states.has(target))
391
- continue;
392
- states.add(target);
393
- pending.push(target);
394
- }
395
- }
396
- return states;
397
- }
398
258
  function summary(count, limit, capped, one, many) {
399
259
  const noun = count === 1 ? one : many;
400
260
  if (count >= limit)
@@ -414,5 +274,6 @@ function checkAbort(signal) {
414
274
  throw signal.reason instanceof Error ? signal.reason : new Error("aborted");
415
275
  }
416
276
  function skippable(error) {
417
- return ["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
277
+ return error instanceof BoundedFileError || error instanceof StableDirectoryError ||
278
+ ["EACCES", "EPERM", "ENOENT"].includes(error.code ?? "");
418
279
  }
@@ -1,48 +1,22 @@
1
1
  // Shared size and truncation boundaries for text handled by workspace tools.
2
- import { constants } from "node:fs";
3
- import { lstat, open } from "node:fs/promises";
2
+ import { BoundedFileError, readBoundedText } from "../bounded-file.js";
4
3
  export { leadingText, trailingText } from "../text-boundary.js";
5
4
  export const MAX_EDITABLE_BYTES = 4_000_000;
6
5
  export const MAX_EDITABLE_CHARS = 1_000_000;
7
6
  export const MAX_EDITABLE_LINES = 20_000;
8
- const READ_CHUNK_BYTES = 64 * 1024;
9
7
  /** Read a regular UTF-8 file without allowing an unbounded allocation. */
10
8
  export async function readEditableText(file, options = {}) {
11
9
  const label = options.label ?? "file";
12
- const details = await lstat(file);
13
- if (!details.isFile())
14
- throw new Error(`${label} must be a regular file`);
15
- const flags = process.platform === "win32"
16
- ? "r"
17
- : constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
18
- const handle = await open(file, flags);
19
10
  try {
20
- const stat = await handle.stat();
21
- if (!stat.isFile())
22
- throw new Error(`${label} must be a regular file`);
23
- if (stat.size > MAX_EDITABLE_BYTES) {
24
- throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
25
- }
26
- const chunks = [];
27
- let total = 0;
28
- while (total <= MAX_EDITABLE_BYTES) {
29
- const room = MAX_EDITABLE_BYTES + 1 - total;
30
- const buffer = Buffer.allocUnsafe(Math.min(READ_CHUNK_BYTES, room));
31
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, total);
32
- if (bytesRead === 0)
33
- break;
34
- chunks.push(buffer.subarray(0, bytesRead));
35
- total += bytesRead;
36
- }
37
- if (total > MAX_EDITABLE_BYTES) {
38
- throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
39
- }
40
- const text = Buffer.concat(chunks, total).toString("utf8");
11
+ const text = await readBoundedText(file, MAX_EDITABLE_BYTES, { label });
41
12
  assertEditableText(text, label);
42
13
  return text;
43
14
  }
44
- finally {
45
- await handle.close();
15
+ catch (error) {
16
+ if (error instanceof BoundedFileError && error.kind === "too-large") {
17
+ throw limitError(label, `${MAX_EDITABLE_BYTES} UTF-8 bytes`);
18
+ }
19
+ throw error;
46
20
  }
47
21
  }
48
22
  /** Reject whole-file writes that exceed Jecode's mutation budget. */