@giovannijecha/jecode 0.8.5 → 0.8.7

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 (104) hide show
  1. package/README.md +36 -9
  2. package/assets/tokenizers/LICENSE +21 -0
  3. package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
  4. package/dist/cli-info.js +8 -14
  5. package/dist/commands.js +7 -3
  6. package/dist/config.js +47 -53
  7. package/dist/context/automatic.js +16 -1
  8. package/dist/context/budget.js +5 -2
  9. package/dist/context/compactor.js +91 -37
  10. package/dist/context/diagnostics.js +108 -0
  11. package/dist/context/lifetime.js +18 -0
  12. package/dist/context/manager.js +67 -0
  13. package/dist/context/manual.js +11 -10
  14. package/dist/context/measurement.js +75 -0
  15. package/dist/context/policy.js +13 -10
  16. package/dist/context/request-observation.js +46 -0
  17. package/dist/context/request-projection.js +8 -34
  18. package/dist/context/request.js +22 -0
  19. package/dist/context/tokenizer/bpe.js +68 -0
  20. package/dist/context/tokenizer/o200k.js +54 -0
  21. package/dist/context/tokenizer/vocabulary.js +34 -0
  22. package/dist/controller-request.js +47 -45
  23. package/dist/controller.js +13 -2
  24. package/dist/credential-commands.js +3 -3
  25. package/dist/input-boundary.js +0 -62
  26. package/dist/launch.js +13 -9
  27. package/dist/model-command.js +7 -17
  28. package/dist/oauth-result-page.js +68 -0
  29. package/dist/openai-account-command.js +14 -12
  30. package/dist/openai-account.js +7 -5
  31. package/dist/openai-oauth-callback.js +15 -54
  32. package/dist/openai-oauth-tokens.js +9 -7
  33. package/dist/openai-oauth.js +8 -6
  34. package/dist/permission-command.js +9 -24
  35. package/dist/permissions.js +10 -8
  36. package/dist/provider-commands.js +1 -33
  37. package/dist/provider-errors.js +1 -10
  38. package/dist/provider-label.js +3 -10
  39. package/dist/providers/anthropic-wire.js +1 -1
  40. package/dist/providers/anthropic.js +3 -2
  41. package/dist/providers/index.js +1 -5
  42. package/dist/providers/input-measurement.js +85 -0
  43. package/dist/providers/ollama-context.js +42 -0
  44. package/dist/providers/ollama-endpoint.js +7 -34
  45. package/dist/providers/ollama-wire.js +1 -1
  46. package/dist/providers/ollama.js +15 -147
  47. package/dist/providers/openai-codex.js +7 -4
  48. package/dist/providers/openai-stream.js +20 -8
  49. package/dist/providers/openai-summary.js +37 -0
  50. package/dist/providers/openai-wire.js +1 -1
  51. package/dist/providers/openai.js +3 -1
  52. package/dist/sessions/bucket.js +55 -0
  53. package/dist/sessions/catalog-io.js +162 -0
  54. package/dist/sessions/catalog.js +3 -1
  55. package/dist/sessions/codec-messages.js +122 -0
  56. package/dist/sessions/codec-transcript.js +93 -0
  57. package/dist/sessions/codec-values.js +52 -0
  58. package/dist/sessions/codec.js +4 -257
  59. package/dist/sessions/files.js +158 -0
  60. package/dist/sessions/load.js +90 -0
  61. package/dist/sessions/snapshot.js +33 -0
  62. package/dist/sessions/store.js +42 -461
  63. package/dist/settings.js +17 -16
  64. package/dist/start.js +36 -53
  65. package/dist/timeline.js +2 -0
  66. package/dist/tools/file-read.js +192 -0
  67. package/dist/tools/file-summary.js +9 -0
  68. package/dist/tools/{fs.js → file-write.js} +5 -193
  69. package/dist/tools/glob.js +107 -0
  70. package/dist/tools/index.js +2 -1
  71. package/dist/tools/search.js +1 -105
  72. package/dist/tui/activity.js +1 -1
  73. package/dist/tui/app-input.js +34 -17
  74. package/dist/tui/app-workflows.js +13 -361
  75. package/dist/tui/app.js +55 -28
  76. package/dist/tui/approve.js +3 -2
  77. package/dist/tui/blocks.js +1 -2
  78. package/dist/tui/command-workflow.js +112 -0
  79. package/dist/tui/components/command-menu.js +7 -10
  80. package/dist/tui/components/menu.js +82 -43
  81. package/dist/tui/components/messages.js +16 -9
  82. package/dist/tui/components/status.js +1 -1
  83. package/dist/tui/components/tool-evidence.js +107 -0
  84. package/dist/tui/components/tool-motion.js +32 -0
  85. package/dist/tui/components/tool.js +48 -202
  86. package/dist/tui/frame.js +8 -3
  87. package/dist/tui/help.js +3 -2
  88. package/dist/tui/picker-layout.js +44 -0
  89. package/dist/tui/picker.js +7 -71
  90. package/dist/tui/screen.js +7 -0
  91. package/dist/tui/session-view.js +0 -1
  92. package/dist/tui/tool-details.js +135 -0
  93. package/dist/tui/transcript-grammar.js +1 -1
  94. package/dist/tui/transcript-view.js +30 -112
  95. package/dist/tui/turn-workflow.js +235 -0
  96. package/dist/tui/turn.js +7 -140
  97. package/dist/tui/view.js +4 -4
  98. package/dist/tui/workflow-types.js +2 -0
  99. package/dist/ui/render.js +0 -4
  100. package/package.json +16 -12
  101. package/dist/batch-view.js +0 -17
  102. package/dist/batch.js +0 -267
  103. package/dist/ollama-settings-command.js +0 -74
  104. package/dist/tui/motion.js +0 -32
package/dist/settings.js CHANGED
@@ -1,11 +1,11 @@
1
- // Persistent, non-secret defaults for interactive and batch sessions.
1
+ // Persistent, non-secret defaults for interactive sessions.
2
2
  import * as path from "node:path";
3
3
  import { atomicWrite } from "./atomic.js";
4
4
  import { assertDirectoryAnchor, captureDirectDirectorySync, preparePrivateDirectory, } from "./directory-anchor.js";
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
@@ -1,12 +1,11 @@
1
- // Application bootstrap: resolve one session, then choose its terminal surface.
1
+ // Application bootstrap: validate the terminal and resolve one interactive session.
2
2
  import * as path from "node:path";
3
- import { runBatch } from "./batch.js";
4
3
  import { showCliInfo } from "./cli-info.js";
5
4
  import { loadConfig } from "./config.js";
6
5
  import { ConversationTree } from "./conversation.js";
7
6
  import { parseLaunch } from "./launch.js";
8
7
  import { systemPrompt } from "./prompt.js";
9
- import { configureProviders, selectProvider } from "./providers/index.js";
8
+ import { selectProvider } from "./providers/index.js";
10
9
  import { SessionPersistence } from "./sessions/runtime.js";
11
10
  import { DurableSessionStore } from "./sessions/store.js";
12
11
  import { builtinTools } from "./tools/index.js";
@@ -22,23 +21,17 @@ export async function start(args = process.argv.slice(2), environment = {}) {
22
21
  if (await showCliInfo(args, applicationRoot, write))
23
22
  return;
24
23
  const launch = parseLaunch(args);
25
- const config = loadConfig(launch.configArgs);
26
- configureProviders(config);
27
- const provider = selectProvider(config.providerId);
28
24
  const hasScreen = environment.interactive?.() ?? interactive();
29
- if (launch.kind === "resume" && !hasScreen) {
30
- throw new Error("resume needs an interactive terminal");
25
+ if (!hasScreen) {
26
+ throw new Error("an interactive terminal is required on stdin and stdout; run jecode directly without pipes or redirection");
31
27
  }
28
+ const config = loadConfig(launch.configArgs, environment.readSettings?.());
29
+ const provider = selectProvider(config.providerId);
32
30
  if (launch.kind === "resume" && config.ephemeral) {
33
31
  throw new Error("--ephemeral cannot be combined with resume");
34
32
  }
35
- // A provider whose catalogue is not fixed has no sensible default model.
36
- // The TUI can ask; a pipe cannot, so batch mode still requires one up front.
37
33
  const model = config.model === "" ? provider.defaultModel : config.model;
38
- if (model === "" && !hasScreen) {
39
- throw new Error(`${provider.id} has no default model — pass --model <id> (or set JECODE_MODEL)`);
40
- }
41
- configureColor(hasScreen);
34
+ configureColor(true);
42
35
  const session = {
43
36
  config,
44
37
  provider,
@@ -49,52 +42,42 @@ export async function start(args = process.argv.slice(2), environment = {}) {
49
42
  conversation: ConversationTree.empty(),
50
43
  usage: emptyUsage(),
51
44
  };
52
- if (hasScreen) {
53
- if (!config.ephemeral) {
54
- const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
55
- if (launch.kind === "resume") {
56
- const candidates = await SessionPersistence.candidates(store);
57
- if (candidates.length === 0)
58
- throw new Error("no resumable sessions found for this workspace");
59
- const open = async (id) => {
60
- const resumed = await SessionPersistence.resume(store, id);
61
- try {
62
- applyResumedSession(session, resumed.conversation, resumed.persistence);
63
- }
64
- catch (error) {
65
- await resumed.persistence.close();
66
- throw error;
67
- }
68
- };
69
- if (launch.latest)
70
- await open(candidates[0].id);
71
- else
72
- session.resume = { candidates, open };
73
- }
74
- else {
75
- session.persistence = SessionPersistence.fresh(store);
76
- }
45
+ if (!config.ephemeral) {
46
+ const store = await DurableSessionStore.open(config.root, environment.sessionsRoot);
47
+ if (launch.kind === "resume") {
48
+ const candidates = await SessionPersistence.candidates(store);
49
+ if (candidates.length === 0)
50
+ throw new Error("no resumable sessions found for this workspace");
51
+ const open = async (id) => {
52
+ const resumed = await SessionPersistence.resume(store, id);
53
+ try {
54
+ applyResumedSession(session, resumed.conversation, resumed.persistence);
55
+ }
56
+ catch (error) {
57
+ await resumed.persistence.close();
58
+ throw error;
59
+ }
60
+ };
61
+ if (launch.latest)
62
+ await open(candidates[0].id);
63
+ else
64
+ session.resume = { candidates, open };
77
65
  }
78
- try {
79
- if (environment.runInteractive === undefined) {
80
- await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
81
- }
82
- else {
83
- await environment.runInteractive(session, transcriptRoot, environment.signal);
84
- }
85
- }
86
- finally {
87
- await session.persistence?.close();
66
+ else {
67
+ session.persistence = SessionPersistence.fresh(store);
88
68
  }
89
69
  }
90
- else {
91
- if (environment.runNonInteractive === undefined) {
92
- await runBatch(session, { signal: environment.signal });
70
+ try {
71
+ if (environment.runInteractive === undefined) {
72
+ await runApp(session, transcriptRoot, { shutdownSignal: environment.signal });
93
73
  }
94
74
  else {
95
- await environment.runNonInteractive(session, environment.signal);
75
+ await environment.runInteractive(session, transcriptRoot, environment.signal);
96
76
  }
97
77
  }
78
+ finally {
79
+ await session.persistence?.close();
80
+ }
98
81
  }
99
82
  function applyResumedSession(session, conversation, persistence) {
100
83
  const identity = conversation.activeNode?.identity;
package/dist/timeline.js CHANGED
@@ -16,6 +16,8 @@ export function timelinePicker(conversation, palette) {
16
16
  searchable: true,
17
17
  query: "",
18
18
  visible: 8,
19
+ // Turn labels are previews; keep each node on its own row without a second copy.
20
+ overflow: "truncate",
19
21
  options: entries.map((entry) => ({
20
22
  label: `${entry.prefix}${preview(entry.node)}`,
21
23
  hint: stamp(entry.node.createdAt),
@@ -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
- }