@giovannijecha/jecode 0.8.5 → 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 (64) hide show
  1. package/README.md +22 -5
  2. package/dist/batch-view.js +27 -2
  3. package/dist/batch.js +2 -0
  4. package/dist/cli-info.js +0 -1
  5. package/dist/config.js +14 -6
  6. package/dist/credential-commands.js +3 -3
  7. package/dist/openai-account-command.js +14 -12
  8. package/dist/openai-account.js +7 -5
  9. package/dist/openai-oauth-callback.js +13 -4
  10. package/dist/openai-oauth-tokens.js +9 -7
  11. package/dist/openai-oauth.js +8 -6
  12. package/dist/permission-command.js +1 -1
  13. package/dist/provider-commands.js +1 -33
  14. package/dist/provider-errors.js +1 -10
  15. package/dist/provider-label.js +3 -10
  16. package/dist/providers/anthropic.js +0 -1
  17. package/dist/providers/index.js +1 -5
  18. package/dist/providers/ollama-context.js +42 -0
  19. package/dist/providers/ollama-endpoint.js +7 -34
  20. package/dist/providers/ollama.js +13 -147
  21. package/dist/providers/openai-codex.js +4 -4
  22. package/dist/providers/openai.js +0 -1
  23. package/dist/sessions/bucket.js +55 -0
  24. package/dist/sessions/catalog-io.js +162 -0
  25. package/dist/sessions/catalog.js +3 -1
  26. package/dist/sessions/codec-messages.js +122 -0
  27. package/dist/sessions/codec-transcript.js +93 -0
  28. package/dist/sessions/codec-values.js +52 -0
  29. package/dist/sessions/codec.js +4 -257
  30. package/dist/sessions/files.js +158 -0
  31. package/dist/sessions/load.js +90 -0
  32. package/dist/sessions/snapshot.js +33 -0
  33. package/dist/sessions/store.js +42 -461
  34. package/dist/settings-command.js +10 -5
  35. package/dist/settings.js +16 -15
  36. package/dist/start.js +1 -2
  37. package/dist/tools/file-read.js +192 -0
  38. package/dist/tools/file-summary.js +9 -0
  39. package/dist/tools/{fs.js → file-write.js} +5 -193
  40. package/dist/tools/glob.js +107 -0
  41. package/dist/tools/index.js +2 -1
  42. package/dist/tools/search.js +1 -105
  43. package/dist/tui/app-workflows.js +8 -361
  44. package/dist/tui/approve.js +5 -3
  45. package/dist/tui/blocks.js +8 -7
  46. package/dist/tui/command-workflow.js +106 -0
  47. package/dist/tui/components/command-menu.js +7 -10
  48. package/dist/tui/components/menu.js +74 -43
  49. package/dist/tui/components/messages.js +16 -9
  50. package/dist/tui/components/tool-evidence.js +107 -0
  51. package/dist/tui/components/tool-motion.js +32 -0
  52. package/dist/tui/components/tool.js +48 -202
  53. package/dist/tui/help.js +1 -1
  54. package/dist/tui/picker-layout.js +40 -0
  55. package/dist/tui/picker.js +7 -71
  56. package/dist/tui/tool-details.js +135 -0
  57. package/dist/tui/transcript-grammar.js +8 -1
  58. package/dist/tui/transcript-view.js +26 -112
  59. package/dist/tui/turn-workflow.js +264 -0
  60. package/dist/tui/turn.js +7 -140
  61. package/dist/tui/workflow-types.js +2 -0
  62. package/package.json +12 -12
  63. package/dist/ollama-settings-command.js +0 -74
  64. package/dist/tui/motion.js +0 -32
package/dist/settings.js CHANGED
@@ -5,7 +5,7 @@ import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirect
5
5
  import { MAX_COMPACTION_PERCENT, MIN_COMPACTION_PERCENT } from "./context/policy.js";
6
6
  import { EFFORTS } from "./effort.js";
7
7
  import { providerNames } from "./providers/index.js";
8
- import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
8
+ import { isLegacyOllamaCloudHost, OLLAMA_CLOUD_HOST } from "./providers/ollama-endpoint.js";
9
9
  import { withStoreLock } from "./store-lock.js";
10
10
  import { userDataLabel, userDataPath } from "./user-data.js";
11
11
  import { assertStoreText, readBoundedJsonForMutationSync, readBoundedJsonSync, USER_STORE_LIMITS, } from "./user-store.js";
@@ -17,12 +17,14 @@ export function readSettings() {
17
17
  return copy(saved);
18
18
  }
19
19
  export async function updateSettings(patch) {
20
+ assertRetiredOllamaHost(patch);
20
21
  const file = settingsPath();
21
22
  const directory = path.dirname(file);
22
23
  const anchor = await preparePrivateDirectory(directory, "settings store directory");
23
24
  const anchoredFile = path.join(anchor.path, path.basename(file));
24
25
  return withStoreLock(anchoredFile, async () => {
25
26
  const next = normalize({ ...readStoreForMutation(anchoredFile, anchor), ...patch });
27
+ delete next.ollamaHost;
26
28
  const text = `${JSON.stringify(next, null, 2)}\n`;
27
29
  assertStoreText(text, USER_STORE_LIMITS.settingsBytes);
28
30
  await atomicWrite(anchoredFile, text, {
@@ -50,8 +52,8 @@ function readStore(file = settingsPath()) {
50
52
  return normalize(readBoundedJsonSync(anchoredFile, USER_STORE_LIMITS.settingsBytes, directory));
51
53
  }
52
54
  catch {
53
- // Missing, unreadable, and malformed stores all fall back safely. A bad
54
- // preference must never prevent the agent from starting.
55
+ // Missing, unreadable, and malformed stores fall back safely. Parsed
56
+ // retired endpoint markers survive normalization for startup validation.
55
57
  return {};
56
58
  }
57
59
  }
@@ -88,9 +90,7 @@ function assertMutableSettings(value) {
88
90
  Object.values(models).some((model) => !boundedNonempty(model, USER_STORE_LIMITS.model)))
89
91
  throw new Error("settings store has invalid models");
90
92
  }
91
- if ("ollamaHost" in value && endpoint(value["ollamaHost"]) === undefined) {
92
- throw new Error("settings store has an invalid Ollama endpoint");
93
- }
93
+ assertRetiredOllamaHost(value);
94
94
  if ("effort" in value && member(value["effort"], EFFORTS) === undefined) {
95
95
  throw new Error("settings store has an invalid reasoning effort");
96
96
  }
@@ -109,7 +109,7 @@ function normalize(value) {
109
109
  const providers = providerNames();
110
110
  const provider = member(value["provider"], providers);
111
111
  const models = modelsOf(value["models"], providers);
112
- const ollamaHost = endpoint(value["ollamaHost"]);
112
+ const ollamaHost = "ollamaHost" in value ? legacyOllamaHost(value["ollamaHost"]) : undefined;
113
113
  const effort = member(value["effort"], EFFORTS);
114
114
  const reducedMotion = typeof value["reducedMotion"] === "boolean" ? value["reducedMotion"] : undefined;
115
115
  const maxTokens = positiveInteger(value["maxTokens"]);
@@ -133,14 +133,15 @@ function modelsOf(value, providers) {
133
133
  function member(value, values) {
134
134
  return typeof value === "string" && values.includes(value) ? value : undefined;
135
135
  }
136
- function endpoint(value) {
137
- if (!boundedNonempty(value, USER_STORE_LIMITS.endpoint))
138
- return undefined;
139
- try {
140
- return parseOllamaEndpoint(value).baseUrl;
141
- }
142
- catch {
143
- return undefined;
136
+ function legacyOllamaHost(value) {
137
+ if (isLegacyOllamaCloudHost(value))
138
+ return OLLAMA_CLOUD_HOST;
139
+ // Even an invalid retired value must not disappear and enable cloud use.
140
+ return boundedNonempty(value, USER_STORE_LIMITS.endpoint) ? value : "unsupported legacy Ollama endpoint";
141
+ }
142
+ function assertRetiredOllamaHost(value) {
143
+ if ("ollamaHost" in value && !isLegacyOllamaCloudHost(value.ollamaHost)) {
144
+ throw new Error("settings store has a retired Ollama endpoint; remove ollamaHost from settings.json to use Ollama API");
144
145
  }
145
146
  }
146
147
  function boundedNonempty(value, max) {
package/dist/start.js CHANGED
@@ -6,7 +6,7 @@ import { loadConfig } from "./config.js";
6
6
  import { ConversationTree } from "./conversation.js";
7
7
  import { parseLaunch } from "./launch.js";
8
8
  import { systemPrompt } from "./prompt.js";
9
- import { configureProviders, selectProvider } from "./providers/index.js";
9
+ import { selectProvider } from "./providers/index.js";
10
10
  import { SessionPersistence } from "./sessions/runtime.js";
11
11
  import { DurableSessionStore } from "./sessions/store.js";
12
12
  import { builtinTools } from "./tools/index.js";
@@ -23,7 +23,6 @@ export async function start(args = process.argv.slice(2), environment = {}) {
23
23
  return;
24
24
  const launch = parseLaunch(args);
25
25
  const config = loadConfig(launch.configArgs);
26
- configureProviders(config);
27
26
  const provider = selectProvider(config.providerId);
28
27
  const hasScreen = environment.interactive?.() ?? interactive();
29
28
  if (launch.kind === "resume" && !hasScreen) {
@@ -0,0 +1,192 @@
1
+ // Bounded workspace file reads and stable directory listings.
2
+ import * as path from "node:path";
3
+ import { withStableFile } from "../bounded-file.js";
4
+ import { readStableDirectory } from "../stable-directory.js";
5
+ import { optionalInt, optionalString, requireString } from "./args.js";
6
+ import { resolveExistingInRoot } from "./paths.js";
7
+ import { leadingText } from "./text-boundary.js";
8
+ import { count, plural } from "./file-summary.js";
9
+ const MAX_READ_CHARS = 60_000;
10
+ const MAX_LIST_CHARS = 60_000;
11
+ const MAX_LIST_ENTRIES = 2_000;
12
+ const READ_CHUNK_BYTES = 64 * 1024;
13
+ const MAX_READ_SCAN_BYTES = 16 * 1024 * 1024;
14
+ export const readFile = {
15
+ name: "read_file",
16
+ description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
17
+ "(1-based) and cap how many lines come back. Large files are truncated.",
18
+ dangerous: false,
19
+ concurrency: "shared",
20
+ input: {
21
+ type: "object",
22
+ properties: {
23
+ path: { type: "string", description: "Path relative to the workspace root." },
24
+ offset: { type: "integer", description: "First line to return, 1-based." },
25
+ limit: { type: "integer", description: "How many lines to return." },
26
+ },
27
+ required: ["path"],
28
+ },
29
+ async run(args, ctx) {
30
+ return runReadFile(args, ctx);
31
+ },
32
+ };
33
+ export async function runReadFile(args, ctx, dependencies = {}) {
34
+ const root = await resolveExistingInRoot(ctx.root, ".");
35
+ const target = await resolveExistingInRoot(root, requireString(args, "path"));
36
+ const offset = optionalInt(args, "offset");
37
+ const limit = optionalInt(args, "limit");
38
+ const { text, truncated, scanCapped } = await readRange(root, target, offset, limit, ctx.signal, dependencies);
39
+ if (truncated) {
40
+ return {
41
+ output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
42
+ summary: `truncated at ${MAX_READ_CHARS} characters`,
43
+ };
44
+ }
45
+ if (scanCapped) {
46
+ const notice = `[read stopped after scanning ${MAX_READ_SCAN_BYTES} bytes — use a smaller offset or search_text]`;
47
+ return {
48
+ output: text === "" ? notice : `${text}\n\n${notice}`,
49
+ summary: `scan capped at ${MAX_READ_SCAN_BYTES} bytes`,
50
+ };
51
+ }
52
+ if (text === "")
53
+ return { output: "[file is empty]", summary: "empty" };
54
+ return { output: text, summary: `${count(text, "line")}` };
55
+ }
56
+ export const listDir = {
57
+ name: "list_dir",
58
+ description: "List the entries of a directory inside the workspace. Directories end with a slash.",
59
+ dangerous: false,
60
+ concurrency: "shared",
61
+ input: {
62
+ type: "object",
63
+ properties: {
64
+ path: { type: "string", description: "Directory relative to the workspace root. Defaults to the root." },
65
+ },
66
+ required: [],
67
+ },
68
+ async run(args, ctx) {
69
+ return runListDir(args, ctx);
70
+ },
71
+ };
72
+ export async function runListDir(args, ctx, dependencies = {}) {
73
+ const root = await resolveExistingInRoot(ctx.root, ".");
74
+ const requested = optionalString(args, "path");
75
+ const relative = requested === undefined || requested.trim() === "" ? "." : requested;
76
+ const target = await resolveExistingInRoot(root, relative);
77
+ const inspected = await readStableDirectory(root, target, {
78
+ maxEntries: MAX_LIST_ENTRIES + 1,
79
+ signal: ctx.signal,
80
+ beforeOpen: dependencies.beforeOpen,
81
+ });
82
+ const entries = [];
83
+ let chars = 0;
84
+ let truncated = inspected.capped;
85
+ for (const entry of inspected.entries) {
86
+ const label = entry.kind === "directory" ? `${entry.name}/` : entry.name;
87
+ const separator = entries.length === 0 ? 0 : 1;
88
+ if (entries.length >= MAX_LIST_ENTRIES ||
89
+ chars + separator + label.length > MAX_LIST_CHARS) {
90
+ truncated = true;
91
+ break;
92
+ }
93
+ entries.push(label);
94
+ chars += separator + label.length;
95
+ }
96
+ if (entries.length === 0)
97
+ return { output: "[empty directory]", summary: "empty" };
98
+ const listing = entries.join("\n");
99
+ if (truncated) {
100
+ return {
101
+ output: `${listing}\n\n[truncated after ${entries.length} entries]`,
102
+ summary: `${entries.length}+ entries`,
103
+ };
104
+ }
105
+ return { output: listing, summary: plural(entries.length, "entry", "entries") };
106
+ }
107
+ async function readRange(root, target, offset, limit, signal, dependencies) {
108
+ throwIfAborted(signal);
109
+ const firstLine = Math.max(1, offset ?? 1);
110
+ const lineCount = limit === undefined ? undefined : Math.max(0, limit);
111
+ const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
112
+ if (lineCount === 0)
113
+ return { text: "", truncated: false, scanCapped: false };
114
+ const result = await withStableFile(target, {
115
+ label: "read file",
116
+ signal,
117
+ beforeOpen: dependencies.beforeOpen,
118
+ }, async (handle, opened) => {
119
+ const decoder = new TextDecoder();
120
+ let text = "";
121
+ let line = 1;
122
+ let stopped = false;
123
+ let truncated = false;
124
+ let reachedEnd = false;
125
+ const selected = (at) => at >= firstLine && at < endLine;
126
+ const append = (fragment) => {
127
+ if (fragment === "")
128
+ return;
129
+ const room = MAX_READ_CHARS - text.length;
130
+ if (fragment.length > room) {
131
+ text += leadingText(fragment, room);
132
+ truncated = true;
133
+ stopped = true;
134
+ return;
135
+ }
136
+ text += fragment;
137
+ };
138
+ const consume = (chunk) => {
139
+ let start = 0;
140
+ while (!stopped && start < chunk.length) {
141
+ const newline = chunk.indexOf("\n", start);
142
+ const end = newline === -1 ? chunk.length : newline;
143
+ if (selected(line))
144
+ append(chunk.slice(start, end));
145
+ if (stopped || newline === -1)
146
+ return;
147
+ const nextLine = line + 1;
148
+ if (selected(line) && selected(nextLine))
149
+ append("\n");
150
+ line = nextLine;
151
+ if (line >= endLine) {
152
+ stopped = true;
153
+ return;
154
+ }
155
+ start = newline + 1;
156
+ }
157
+ };
158
+ let position = 0;
159
+ const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
160
+ while (!stopped && position < MAX_READ_SCAN_BYTES) {
161
+ throwIfAborted(signal);
162
+ const length = Math.min(buffer.length, MAX_READ_SCAN_BYTES - position);
163
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
164
+ throwIfAborted(signal);
165
+ if (bytesRead === 0) {
166
+ reachedEnd = true;
167
+ break;
168
+ }
169
+ position += bytesRead;
170
+ consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
171
+ }
172
+ if (!stopped && BigInt(position) >= opened.size)
173
+ reachedEnd = true;
174
+ if (!stopped && reachedEnd)
175
+ consume(decoder.decode());
176
+ return {
177
+ text,
178
+ truncated,
179
+ scanCapped: !stopped && !reachedEnd && position >= MAX_READ_SCAN_BYTES,
180
+ };
181
+ });
182
+ const confirmed = await resolveExistingInRoot(root, target);
183
+ if (path.relative(target, confirmed) !== "") {
184
+ throw new Error("read file changed while it was being read");
185
+ }
186
+ return result;
187
+ }
188
+ function throwIfAborted(signal) {
189
+ if (signal?.aborted !== true)
190
+ return;
191
+ throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
192
+ }
@@ -0,0 +1,9 @@
1
+ // Shared line and entry counts in filesystem tool summaries.
2
+ export function count(text, noun) {
3
+ if (text === "")
4
+ return "empty";
5
+ return plural(text.split("\n").length, noun, `${noun}s`);
6
+ }
7
+ export function plural(n, one, many) {
8
+ return `${n} ${n === 1 ? one : many}`;
9
+ }
@@ -1,111 +1,12 @@
1
- // Filesystem tools: read, list, write, edit.
1
+ // Atomic file mutations share preview validation and exact replacement rules.
2
2
  import * as fs from "node:fs/promises";
3
3
  import * as path from "node:path";
4
- import { withStableFile } from "../bounded-file.js";
5
- import { readStableDirectory } from "../stable-directory.js";
6
- import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
7
- import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
8
- import { assertEditableText, assertReplacementFits, leadingText, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
9
4
  import { atomicWrite } from "../atomic.js";
10
- const MAX_READ_CHARS = 60_000;
11
- const MAX_LIST_CHARS = 60_000;
12
- const MAX_LIST_ENTRIES = 2_000;
13
- const READ_CHUNK_BYTES = 64 * 1024;
14
- const MAX_READ_SCAN_BYTES = 16 * 1024 * 1024;
5
+ import { optionalBool, requireString } from "./args.js";
6
+ import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
7
+ import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
8
+ import { count, plural } from "./file-summary.js";
15
9
  const DEFAULT_MUTATION_DEPENDENCIES = { atomicWrite };
16
- export const readFile = {
17
- name: "read_file",
18
- description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
19
- "(1-based) and cap how many lines come back. Large files are truncated.",
20
- dangerous: false,
21
- concurrency: "shared",
22
- input: {
23
- type: "object",
24
- properties: {
25
- path: { type: "string", description: "Path relative to the workspace root." },
26
- offset: { type: "integer", description: "First line to return, 1-based." },
27
- limit: { type: "integer", description: "How many lines to return." },
28
- },
29
- required: ["path"],
30
- },
31
- async run(args, ctx) {
32
- return runReadFile(args, ctx);
33
- },
34
- };
35
- export async function runReadFile(args, ctx, dependencies = {}) {
36
- const root = await resolveExistingInRoot(ctx.root, ".");
37
- const target = await resolveExistingInRoot(root, requireString(args, "path"));
38
- const offset = optionalInt(args, "offset");
39
- const limit = optionalInt(args, "limit");
40
- const { text, truncated, scanCapped } = await readRange(root, target, offset, limit, ctx.signal, dependencies);
41
- if (truncated) {
42
- return {
43
- output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
44
- summary: `truncated at ${MAX_READ_CHARS} characters`,
45
- };
46
- }
47
- if (scanCapped) {
48
- const notice = `[read stopped after scanning ${MAX_READ_SCAN_BYTES} bytes — use a smaller offset or search_text]`;
49
- return {
50
- output: text === "" ? notice : `${text}\n\n${notice}`,
51
- summary: `scan capped at ${MAX_READ_SCAN_BYTES} bytes`,
52
- };
53
- }
54
- if (text === "")
55
- return { output: "[file is empty]", summary: "empty" };
56
- return { output: text, summary: `${count(text, "line")}` };
57
- }
58
- export const listDir = {
59
- name: "list_dir",
60
- description: "List the entries of a directory inside the workspace. Directories end with a slash.",
61
- dangerous: false,
62
- concurrency: "shared",
63
- input: {
64
- type: "object",
65
- properties: {
66
- path: { type: "string", description: "Directory relative to the workspace root. Defaults to the root." },
67
- },
68
- required: [],
69
- },
70
- async run(args, ctx) {
71
- return runListDir(args, ctx);
72
- },
73
- };
74
- export async function runListDir(args, ctx, dependencies = {}) {
75
- const root = await resolveExistingInRoot(ctx.root, ".");
76
- const requested = optionalString(args, "path");
77
- const relative = requested === undefined || requested.trim() === "" ? "." : requested;
78
- const target = await resolveExistingInRoot(root, relative);
79
- const inspected = await readStableDirectory(root, target, {
80
- maxEntries: MAX_LIST_ENTRIES + 1,
81
- signal: ctx.signal,
82
- beforeOpen: dependencies.beforeOpen,
83
- });
84
- const entries = [];
85
- let chars = 0;
86
- let truncated = inspected.capped;
87
- for (const entry of inspected.entries) {
88
- const label = entry.kind === "directory" ? `${entry.name}/` : entry.name;
89
- const separator = entries.length === 0 ? 0 : 1;
90
- if (entries.length >= MAX_LIST_ENTRIES ||
91
- chars + separator + label.length > MAX_LIST_CHARS) {
92
- truncated = true;
93
- break;
94
- }
95
- entries.push(label);
96
- chars += separator + label.length;
97
- }
98
- if (entries.length === 0)
99
- return { output: "[empty directory]", summary: "empty" };
100
- const listing = entries.join("\n");
101
- if (truncated) {
102
- return {
103
- output: `${listing}\n\n[truncated after ${entries.length} entries]`,
104
- summary: `${entries.length}+ entries`,
105
- };
106
- }
107
- return { output: listing, summary: plural(entries.length, "entry", "entries") };
108
- }
109
10
  export const writeFile = {
110
11
  name: "write_file",
111
12
  description: "Create a file, or replace its entire contents. Parent directories are " +
@@ -221,87 +122,6 @@ export async function runEditFile(args, ctx, dependencies = DEFAULT_MUTATION_DEP
221
122
  summary: plural(made, "replacement", "replacements"),
222
123
  };
223
124
  }
224
- async function readRange(root, target, offset, limit, signal, dependencies) {
225
- throwIfAborted(signal);
226
- const firstLine = Math.max(1, offset ?? 1);
227
- const lineCount = limit === undefined ? undefined : Math.max(0, limit);
228
- const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
229
- if (lineCount === 0)
230
- return { text: "", truncated: false, scanCapped: false };
231
- const result = await withStableFile(target, {
232
- label: "read file",
233
- signal,
234
- beforeOpen: dependencies.beforeOpen,
235
- }, async (handle, opened) => {
236
- const decoder = new TextDecoder();
237
- let text = "";
238
- let line = 1;
239
- let stopped = false;
240
- let truncated = false;
241
- let reachedEnd = false;
242
- const selected = (at) => at >= firstLine && at < endLine;
243
- const append = (fragment) => {
244
- if (fragment === "")
245
- return;
246
- const room = MAX_READ_CHARS - text.length;
247
- if (fragment.length > room) {
248
- text += leadingText(fragment, room);
249
- truncated = true;
250
- stopped = true;
251
- return;
252
- }
253
- text += fragment;
254
- };
255
- const consume = (chunk) => {
256
- let start = 0;
257
- while (!stopped && start < chunk.length) {
258
- const newline = chunk.indexOf("\n", start);
259
- const end = newline === -1 ? chunk.length : newline;
260
- if (selected(line))
261
- append(chunk.slice(start, end));
262
- if (stopped || newline === -1)
263
- return;
264
- const nextLine = line + 1;
265
- if (selected(line) && selected(nextLine))
266
- append("\n");
267
- line = nextLine;
268
- if (line >= endLine) {
269
- stopped = true;
270
- return;
271
- }
272
- start = newline + 1;
273
- }
274
- };
275
- let position = 0;
276
- const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
277
- while (!stopped && position < MAX_READ_SCAN_BYTES) {
278
- throwIfAborted(signal);
279
- const length = Math.min(buffer.length, MAX_READ_SCAN_BYTES - position);
280
- const { bytesRead } = await handle.read(buffer, 0, length, position);
281
- throwIfAborted(signal);
282
- if (bytesRead === 0) {
283
- reachedEnd = true;
284
- break;
285
- }
286
- position += bytesRead;
287
- consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
288
- }
289
- if (!stopped && BigInt(position) >= opened.size)
290
- reachedEnd = true;
291
- if (!stopped && reachedEnd)
292
- consume(decoder.decode());
293
- return {
294
- text,
295
- truncated,
296
- scanCapped: !stopped && !reachedEnd && position >= MAX_READ_SCAN_BYTES,
297
- };
298
- });
299
- const confirmed = await resolveExistingInRoot(root, target);
300
- if (path.relative(target, confirmed) !== "") {
301
- throw new Error("read file changed while it was being read");
302
- }
303
- return result;
304
- }
305
125
  function throwIfAborted(signal) {
306
126
  if (signal?.aborted !== true)
307
127
  return;
@@ -370,11 +190,3 @@ function changedFile(operation, previewed) {
370
190
  const when = previewed ? "after the preview" : "while preparing the change";
371
191
  return new Error(`file changed ${when} — inspect it and retry the ${operation}`);
372
192
  }
373
- function count(text, noun) {
374
- if (text === "")
375
- return "empty";
376
- return plural(text.split("\n").length, noun, `${noun}s`);
377
- }
378
- function plural(n, one, many) {
379
- return `${n} ${n === 1 ? one : many}`;
380
- }
@@ -0,0 +1,107 @@
1
+ // Bounded workspace glob matching without regular-expression backtracking.
2
+ import * as path from "node:path";
3
+ const MAX_GLOB_CHARS = 512;
4
+ export function glob(pattern) {
5
+ const normalized = pattern.replace(/\\/g, "/");
6
+ if (normalized.length > MAX_GLOB_CHARS) {
7
+ throw new Error(`"pattern" must be at most ${MAX_GLOB_CHARS} characters`);
8
+ }
9
+ const tokens = tokenizeGlob(normalized.toLowerCase());
10
+ const basenameOnly = !normalized.includes("/");
11
+ return (relative) => {
12
+ const candidate = relative.replace(/\\/g, "/");
13
+ const target = (basenameOnly ? path.posix.basename(candidate) : candidate).toLowerCase();
14
+ return matchGlob(tokens, Array.from(target));
15
+ };
16
+ }
17
+ function tokenizeGlob(pattern) {
18
+ const chars = Array.from(pattern);
19
+ const tokens = [];
20
+ let index = 0;
21
+ while (index < chars.length) {
22
+ const char = chars[index];
23
+ if (char === "*") {
24
+ let end = index + 1;
25
+ while (chars[end] === "*")
26
+ end++;
27
+ if (end - index >= 2) {
28
+ if (chars[end] === "/") {
29
+ tokens.push({ kind: "globdir-start" }, { kind: "globdir-body" });
30
+ index = end + 1;
31
+ }
32
+ else {
33
+ tokens.push({ kind: "globstar" });
34
+ index = end;
35
+ }
36
+ }
37
+ else {
38
+ tokens.push({ kind: "star" });
39
+ index = end;
40
+ }
41
+ continue;
42
+ }
43
+ tokens.push(char === "?" ? { kind: "one" } : { kind: "literal", value: char });
44
+ index++;
45
+ }
46
+ return tokens;
47
+ }
48
+ /** Thompson-style wildcard matching: O(pattern × path), with no regex backtracking. */
49
+ function matchGlob(tokens, text) {
50
+ let states = epsilonClosure(new Set([0]), tokens);
51
+ for (const char of text) {
52
+ const next = new Set();
53
+ for (const state of states) {
54
+ const token = tokens[state];
55
+ if (token === undefined)
56
+ continue;
57
+ switch (token.kind) {
58
+ case "literal":
59
+ if (token.value === char)
60
+ next.add(state + 1);
61
+ break;
62
+ case "one":
63
+ if (char !== "/")
64
+ next.add(state + 1);
65
+ break;
66
+ case "star":
67
+ if (char !== "/")
68
+ next.add(state);
69
+ break;
70
+ case "globstar":
71
+ next.add(state);
72
+ break;
73
+ case "globdir-body":
74
+ next.add(state);
75
+ if (char === "/")
76
+ next.add(state + 1);
77
+ break;
78
+ case "globdir-start":
79
+ break;
80
+ }
81
+ }
82
+ states = epsilonClosure(next, tokens);
83
+ if (states.size === 0)
84
+ return false;
85
+ }
86
+ return epsilonClosure(states, tokens).has(tokens.length);
87
+ }
88
+ function epsilonClosure(seed, tokens) {
89
+ const states = new Set(seed);
90
+ const pending = [...seed];
91
+ while (pending.length > 0) {
92
+ const state = pending.pop();
93
+ const token = tokens[state];
94
+ const targets = token?.kind === "globdir-start"
95
+ ? [state + 1, state + 2]
96
+ : token?.kind === "star" || token?.kind === "globstar"
97
+ ? [state + 1]
98
+ : [];
99
+ for (const target of targets) {
100
+ if (states.has(target))
101
+ continue;
102
+ states.add(target);
103
+ pending.push(target);
104
+ }
105
+ }
106
+ return states;
107
+ }
@@ -1,5 +1,6 @@
1
1
  // The tool registry, and the one place a tool actually gets run.
2
- import { editFile, listDir, readFile, writeFile } from "./fs.js";
2
+ import { listDir, readFile } from "./file-read.js";
3
+ import { editFile, writeFile } from "./file-write.js";
3
4
  import { runCommand } from "./shell.js";
4
5
  import { findFiles, searchText } from "./search.js";
5
6
  export function builtinTools() {