@sean.holung/minicode 0.1.0

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 (54) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +241 -0
  3. package/dist/src/agent/agent.js +209 -0
  4. package/dist/src/agent/config.js +151 -0
  5. package/dist/src/agent/types.js +1 -0
  6. package/dist/src/index.js +138 -0
  7. package/dist/src/indexer/cache.js +121 -0
  8. package/dist/src/indexer/code-map.js +92 -0
  9. package/dist/src/indexer/plugin-loader.js +78 -0
  10. package/dist/src/indexer/plugins/typescript.js +327 -0
  11. package/dist/src/indexer/project-index.js +145 -0
  12. package/dist/src/indexer/types.js +1 -0
  13. package/dist/src/model/client.js +374 -0
  14. package/dist/src/prompt/system-prompt.js +91 -0
  15. package/dist/src/safety/guardrails.js +55 -0
  16. package/dist/src/session/session.js +95 -0
  17. package/dist/src/tools/edit-file.js +73 -0
  18. package/dist/src/tools/find-references.js +52 -0
  19. package/dist/src/tools/get-dependencies.js +56 -0
  20. package/dist/src/tools/helpers.js +42 -0
  21. package/dist/src/tools/list-files.js +63 -0
  22. package/dist/src/tools/read-file.js +79 -0
  23. package/dist/src/tools/read-symbol.js +96 -0
  24. package/dist/src/tools/registry.js +68 -0
  25. package/dist/src/tools/run-command.js +92 -0
  26. package/dist/src/tools/search-code-map.js +72 -0
  27. package/dist/src/tools/search.js +153 -0
  28. package/dist/src/tools/write-file.js +44 -0
  29. package/dist/src/ui/app.js +31 -0
  30. package/dist/src/ui/cli-ink.js +168 -0
  31. package/dist/src/ui/components/activity-pane.js +35 -0
  32. package/dist/src/ui/components/header-bar.js +6 -0
  33. package/dist/src/ui/components/input-composer.js +46 -0
  34. package/dist/src/ui/components/tool-timeline-item.js +37 -0
  35. package/dist/src/ui/events.js +1 -0
  36. package/dist/src/ui/state/ui-store.js +89 -0
  37. package/dist/src/ui/theme.js +23 -0
  38. package/dist/tests/agent.test.js +130 -0
  39. package/dist/tests/cache.test.js +37 -0
  40. package/dist/tests/config.test.js +37 -0
  41. package/dist/tests/dependency-graph.test.js +27 -0
  42. package/dist/tests/file-tools.test.js +73 -0
  43. package/dist/tests/find-references.test.js +30 -0
  44. package/dist/tests/get-dependencies.test.js +35 -0
  45. package/dist/tests/guardrails.test.js +18 -0
  46. package/dist/tests/indexer.test.js +201 -0
  47. package/dist/tests/model-client-openai.test.js +84 -0
  48. package/dist/tests/read-symbol.test.js +83 -0
  49. package/dist/tests/search-code-map.test.js +30 -0
  50. package/dist/tests/session.test.js +37 -0
  51. package/dist/tests/system-prompt.test.js +82 -0
  52. package/dist/tests/test-utils.js +18 -0
  53. package/dist/tests/tool-registry.test.js +41 -0
  54. package/package.json +43 -0
@@ -0,0 +1,374 @@
1
+ import process from "node:process";
2
+ import { setTimeout as sleep } from "node:timers/promises";
3
+ import Anthropic from "@anthropic-ai/sdk";
4
+ function toAnthropicMessages(messages) {
5
+ const converted = [];
6
+ for (const message of messages) {
7
+ if (message.role === "user") {
8
+ converted.push({
9
+ role: "user",
10
+ content: message.content,
11
+ });
12
+ continue;
13
+ }
14
+ if (message.role === "assistant") {
15
+ const content = [];
16
+ if (message.content.trim().length > 0) {
17
+ content.push({
18
+ type: "text",
19
+ text: message.content,
20
+ });
21
+ }
22
+ for (const toolCall of message.toolCalls ?? []) {
23
+ content.push({
24
+ type: "tool_use",
25
+ id: toolCall.id,
26
+ name: toolCall.name,
27
+ input: toolCall.input,
28
+ });
29
+ }
30
+ converted.push({
31
+ role: "assistant",
32
+ content: content.length > 0 ? content : "",
33
+ });
34
+ continue;
35
+ }
36
+ converted.push({
37
+ role: "user",
38
+ content: [
39
+ {
40
+ type: "tool_result",
41
+ tool_use_id: message.toolCallId,
42
+ content: message.content,
43
+ },
44
+ ],
45
+ });
46
+ }
47
+ return converted;
48
+ }
49
+ function parseResponse(response) {
50
+ const textParts = [];
51
+ const toolCalls = [];
52
+ for (const block of response.content) {
53
+ if (block.type === "text") {
54
+ textParts.push(block.text);
55
+ continue;
56
+ }
57
+ if (block.type === "tool_use") {
58
+ toolCalls.push({
59
+ id: block.id,
60
+ name: block.name,
61
+ input: block.input,
62
+ });
63
+ }
64
+ }
65
+ return {
66
+ text: textParts.join("\n").trim(),
67
+ toolCalls,
68
+ stopReason: response.stop_reason === "tool_use"
69
+ ? "tool_use"
70
+ : response.stop_reason === "max_tokens"
71
+ ? "max_tokens"
72
+ : "end_turn",
73
+ usage: {
74
+ inputTokens: response.usage.input_tokens,
75
+ outputTokens: response.usage.output_tokens,
76
+ },
77
+ };
78
+ }
79
+ function isAbortError(error) {
80
+ return error instanceof Error && error.name === "AbortError";
81
+ }
82
+ async function withRetry(fn, attempts = 3) {
83
+ let lastError;
84
+ for (let attempt = 1; attempt <= attempts; attempt += 1) {
85
+ try {
86
+ return await fn();
87
+ }
88
+ catch (error) {
89
+ lastError = error;
90
+ if (isAbortError(error)) {
91
+ throw error;
92
+ }
93
+ if (attempt === attempts) {
94
+ break;
95
+ }
96
+ const delayMs = 500 * 2 ** (attempt - 1);
97
+ await sleep(delayMs);
98
+ }
99
+ }
100
+ throw lastError;
101
+ }
102
+ function toOpenAICompatibleMessages(system, messages) {
103
+ const converted = [
104
+ {
105
+ role: "system",
106
+ content: system,
107
+ },
108
+ ];
109
+ for (const message of messages) {
110
+ if (message.role === "user") {
111
+ converted.push({
112
+ role: "user",
113
+ content: message.content,
114
+ });
115
+ continue;
116
+ }
117
+ if (message.role === "assistant") {
118
+ const toolCalls = message.toolCalls?.map((toolCall) => ({
119
+ id: toolCall.id,
120
+ type: "function",
121
+ function: {
122
+ name: toolCall.name,
123
+ arguments: JSON.stringify(toolCall.input),
124
+ },
125
+ }));
126
+ converted.push({
127
+ role: "assistant",
128
+ content: message.content.length > 0 ? message.content : null,
129
+ ...(toolCalls ? { tool_calls: toolCalls } : {}),
130
+ });
131
+ continue;
132
+ }
133
+ converted.push({
134
+ role: "tool",
135
+ tool_call_id: message.toolCallId,
136
+ content: message.content,
137
+ });
138
+ }
139
+ return converted;
140
+ }
141
+ function toOpenAICompatibleTools(tools) {
142
+ return tools.map((tool) => ({
143
+ type: "function",
144
+ function: {
145
+ name: tool.name,
146
+ description: tool.description,
147
+ parameters: tool.input_schema,
148
+ },
149
+ }));
150
+ }
151
+ function parseOpenAICompatibleToolArguments(rawArguments) {
152
+ try {
153
+ const parsed = JSON.parse(rawArguments);
154
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
155
+ return parsed;
156
+ }
157
+ }
158
+ catch {
159
+ // Fall back to empty object when model emits malformed function arguments.
160
+ }
161
+ return {};
162
+ }
163
+ function parseOpenAICompatibleResponse(response) {
164
+ const firstChoice = response.choices?.[0];
165
+ const message = firstChoice?.message;
166
+ if (!message) {
167
+ throw new Error("OpenAI-compatible response missing choices[0].message.");
168
+ }
169
+ const toolCalls = message.tool_calls?.map((toolCall, index) => ({
170
+ id: toolCall.id || `tool-call-${index + 1}`,
171
+ name: toolCall.function.name,
172
+ input: parseOpenAICompatibleToolArguments(toolCall.function.arguments),
173
+ })) ?? [];
174
+ const finishReason = firstChoice?.finish_reason ?? null;
175
+ const stopReason = finishReason === "tool_calls" || finishReason === "function_call"
176
+ ? "tool_use"
177
+ : finishReason === "length"
178
+ ? "max_tokens"
179
+ : toolCalls.length > 0
180
+ ? "tool_use"
181
+ : "end_turn";
182
+ return {
183
+ text: (message.content ?? "").trim(),
184
+ toolCalls,
185
+ stopReason,
186
+ usage: {
187
+ inputTokens: response.usage?.prompt_tokens ?? 0,
188
+ outputTokens: response.usage?.completion_tokens ?? 0,
189
+ },
190
+ };
191
+ }
192
+ function normalizeBaseUrl(baseUrl) {
193
+ const trimmed = baseUrl.trim().replace(/\/+$/, "");
194
+ if (trimmed.length === 0) {
195
+ throw new Error("OPENAI_BASE_URL cannot be empty.");
196
+ }
197
+ return trimmed;
198
+ }
199
+ export class AnthropicModelClient {
200
+ client;
201
+ constructor(apiKey = process.env.ANTHROPIC_API_KEY) {
202
+ if (!apiKey) {
203
+ throw new Error("Missing ANTHROPIC_API_KEY. Copy .env.example to .env and set a key.");
204
+ }
205
+ this.client = new Anthropic({ apiKey });
206
+ }
207
+ async chat(params) {
208
+ const response = await withRetry(() => this.client.messages.create({
209
+ model: params.model,
210
+ max_tokens: params.maxTokens,
211
+ system: params.system,
212
+ messages: toAnthropicMessages(params.messages),
213
+ tools: params.tools,
214
+ stream: false,
215
+ }));
216
+ return parseResponse(response);
217
+ }
218
+ }
219
+ async function parseOpenAIStream(reader, onStream) {
220
+ const decoder = new TextDecoder();
221
+ let buffer = "";
222
+ let content = "";
223
+ const toolCallsAcc = [];
224
+ const usage = { prompt_tokens: 0, completion_tokens: 0 };
225
+ let finishReason = null;
226
+ const processLines = (lines) => {
227
+ for (const line of lines) {
228
+ const trimmed = line.trim();
229
+ if (!trimmed.startsWith("data: "))
230
+ continue;
231
+ const data = trimmed.slice(6).trim();
232
+ if (data === "[DONE]")
233
+ continue;
234
+ try {
235
+ const chunk = JSON.parse(data);
236
+ const choice = chunk.choices?.[0];
237
+ if (!choice)
238
+ continue;
239
+ const delta = choice.delta;
240
+ if (delta?.content) {
241
+ content += delta.content;
242
+ onStream?.(delta.content);
243
+ }
244
+ if (delta?.tool_calls) {
245
+ for (const tc of delta.tool_calls) {
246
+ const idx = tc.index ?? 0;
247
+ if (!toolCallsAcc[idx]) {
248
+ toolCallsAcc[idx] = { id: tc.id ?? "", name: tc.function?.name ?? "", arguments: tc.function?.arguments ?? "" };
249
+ }
250
+ else {
251
+ if (tc.id)
252
+ toolCallsAcc[idx].id = tc.id;
253
+ if (tc.function?.name)
254
+ toolCallsAcc[idx].name = tc.function.name;
255
+ if (tc.function?.arguments)
256
+ toolCallsAcc[idx].arguments += tc.function.arguments;
257
+ }
258
+ }
259
+ }
260
+ if (choice.finish_reason)
261
+ finishReason = choice.finish_reason;
262
+ if (chunk.usage) {
263
+ usage.prompt_tokens = chunk.usage.prompt_tokens ?? 0;
264
+ usage.completion_tokens = chunk.usage.completion_tokens ?? 0;
265
+ }
266
+ }
267
+ catch {
268
+ // skip malformed chunks
269
+ }
270
+ }
271
+ };
272
+ while (true) {
273
+ const { done, value } = await reader.read();
274
+ if (value) {
275
+ buffer += decoder.decode(value, { stream: true });
276
+ }
277
+ const lines = buffer.split(/\r?\n/);
278
+ buffer = lines.pop() ?? "";
279
+ processLines(lines);
280
+ if (done) {
281
+ if (buffer.trim())
282
+ processLines([buffer]);
283
+ break;
284
+ }
285
+ }
286
+ const toolCalls = toolCallsAcc
287
+ .filter((tc) => tc.id || tc.name)
288
+ .map((tc, i) => ({
289
+ id: tc.id || `tool-call-${i + 1}`,
290
+ name: tc.name,
291
+ input: parseOpenAICompatibleToolArguments(tc.arguments),
292
+ }));
293
+ const stopReason = finishReason === "tool_calls" || finishReason === "function_call"
294
+ ? "tool_use"
295
+ : finishReason === "length"
296
+ ? "max_tokens"
297
+ : toolCalls.length > 0
298
+ ? "tool_use"
299
+ : "end_turn";
300
+ return {
301
+ text: content.trim(),
302
+ toolCalls,
303
+ stopReason,
304
+ usage: { inputTokens: usage.prompt_tokens, outputTokens: usage.completion_tokens },
305
+ };
306
+ }
307
+ export class OpenAICompatibleModelClient {
308
+ baseUrl;
309
+ apiKey;
310
+ fetchImpl;
311
+ constructor(params) {
312
+ this.baseUrl = normalizeBaseUrl(params?.baseUrl ?? process.env.OPENAI_BASE_URL ?? "http://localhost:1234/v1");
313
+ const isOpenRouter = this.baseUrl.includes("openrouter");
314
+ this.apiKey =
315
+ params?.apiKey ??
316
+ (isOpenRouter
317
+ ? process.env.OPENROUTER_API_KEY ?? process.env.OPENAI_API_KEY
318
+ : process.env.OPENAI_API_KEY);
319
+ this.fetchImpl = params?.fetchImpl ?? fetch;
320
+ }
321
+ async chat(params) {
322
+ const headers = {
323
+ "Content-Type": "application/json",
324
+ };
325
+ const apiKey = this.apiKey?.trim();
326
+ if (apiKey && apiKey.length > 0) {
327
+ if (this.baseUrl.includes("openrouter") &&
328
+ apiKey.startsWith("sk-proj-")) {
329
+ throw new Error("OpenRouter requires an OpenRouter API key (sk-or-v1-...), not an OpenAI key (sk-proj-...). Get one at https://openrouter.ai/keys");
330
+ }
331
+ headers.Authorization = `Bearer ${apiKey}`;
332
+ }
333
+ else if (this.baseUrl.includes("openrouter")) {
334
+ throw new Error("Missing OpenRouter API key. Set OPENAI_API_KEY or OPENROUTER_API_KEY in .env. Get one at https://openrouter.ai/keys");
335
+ }
336
+ const useStream = params.onStream !== undefined;
337
+ const response = await withRetry(async () => {
338
+ const httpResponse = await this.fetchImpl(`${this.baseUrl}/chat/completions`, {
339
+ method: "POST",
340
+ headers,
341
+ body: JSON.stringify({
342
+ model: params.model,
343
+ messages: toOpenAICompatibleMessages(params.system, params.messages),
344
+ tools: toOpenAICompatibleTools(params.tools),
345
+ tool_choice: "auto",
346
+ max_tokens: params.maxTokens,
347
+ stream: useStream,
348
+ }),
349
+ ...(params.signal && { signal: params.signal }),
350
+ });
351
+ if (!httpResponse.ok) {
352
+ const bodyText = await httpResponse.text();
353
+ throw new Error(`OpenAI-compatible request failed (${httpResponse.status}): ${bodyText}`);
354
+ }
355
+ if (useStream && httpResponse.body) {
356
+ return parseOpenAIStream(httpResponse.body.getReader(), params.onStream);
357
+ }
358
+ const payload = (await httpResponse.json());
359
+ return parseOpenAICompatibleResponse(payload);
360
+ });
361
+ return response;
362
+ }
363
+ }
364
+ export function createModelClient(config) {
365
+ if (config.modelProvider === "openai-compatible") {
366
+ return new OpenAICompatibleModelClient({
367
+ baseUrl: config.openAiBaseUrl,
368
+ ...(config.openAiApiKey !== undefined
369
+ ? { apiKey: config.openAiApiKey }
370
+ : {}),
371
+ });
372
+ }
373
+ return new AnthropicModelClient();
374
+ }
@@ -0,0 +1,91 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ function detectProjectType(workspaceRoot) {
4
+ const checks = [
5
+ { file: "package.json", type: "Node.js / TypeScript" },
6
+ { file: "pyproject.toml", type: "Python" },
7
+ { file: "requirements.txt", type: "Python" },
8
+ { file: "go.mod", type: "Go" },
9
+ { file: "Cargo.toml", type: "Rust" },
10
+ { file: "pom.xml", type: "Java (Maven)" },
11
+ ];
12
+ for (const check of checks) {
13
+ if (existsSync(path.join(workspaceRoot, check.file))) {
14
+ return check.type;
15
+ }
16
+ }
17
+ return "Unknown";
18
+ }
19
+ function renderToolList(tools) {
20
+ return tools
21
+ .map((tool) => `- ${tool.name}: ${tool.description}`)
22
+ .join("\n");
23
+ }
24
+ function hasTool(tools, name) {
25
+ return tools.some((t) => t.name === name);
26
+ }
27
+ export function buildSystemPrompt(config, tools, codeMapResult) {
28
+ const projectType = detectProjectType(config.workspaceRoot);
29
+ const sections = [
30
+ "[Identity]",
31
+ "You are a coding agent. You help developers read, understand, and modify code in their projects.",
32
+ "",
33
+ "[Workspace Context]",
34
+ `Working directory: ${config.workspaceRoot}`,
35
+ `Project type: ${projectType}`,
36
+ "",
37
+ ];
38
+ const hasSearchCodeMap = hasTool(tools, "search_code_map");
39
+ if (codeMapResult && codeMapResult.text.length > 0) {
40
+ sections.push("[Project Code Map]", codeMapResult.text, "");
41
+ const truncated = codeMapResult.totalCount > 0 &&
42
+ codeMapResult.shownCount < codeMapResult.totalCount;
43
+ if (truncated) {
44
+ const hint = hasSearchCodeMap
45
+ ? " Use search_code_map to find symbols not listed above."
46
+ : "";
47
+ sections.push(`Showing ${codeMapResult.shownCount} of ${codeMapResult.totalCount} symbols.${hint}`, "");
48
+ }
49
+ }
50
+ const hasReadSymbol = hasTool(tools, "read_symbol");
51
+ const hasFindRefs = hasTool(tools, "find_references");
52
+ const hasGetDeps = hasTool(tools, "get_dependencies");
53
+ const hasSpecializedTools = hasReadSymbol || hasFindRefs || hasGetDeps || hasSearchCodeMap;
54
+ const toolGuidelines = [
55
+ "[Tool Usage Guidelines]",
56
+ "- Always read a file before editing it.",
57
+ "- Prefer edit_file over write_file for existing files.",
58
+ "- Run tests or lint after code changes when applicable.",
59
+ "- Default to using preferred tools when doing planning, code exploration, or investigation."
60
+ ];
61
+ if (hasSpecializedTools) {
62
+ toolGuidelines.push("", "[Code exploration PREFERRED TOOLS — prefer these over read_file and search]", ...(hasReadSymbol
63
+ ? [
64
+ "- PREFER read_symbol over read_file for .ts/.tsx/.js/.jsx when you need a function or class. The code map lists all symbols; use read_symbol(name) for targeted reads — it returns only the relevant code and avoids bloating context.",
65
+ "- Use read_file only for config files, small files, non-code files, or when the symbol name is unknown.",
66
+ ]
67
+ : []), ...(hasFindRefs
68
+ ? [
69
+ "- Use find_references to see what calls or uses a symbol — essential for understanding impact before changes.",
70
+ ]
71
+ : []), ...(hasGetDeps
72
+ ? [
73
+ "- Use get_dependencies to see what a symbol depends on — essential for understanding implementation and data flow.",
74
+ ]
75
+ : []), "- Use search only when you don't know the symbol name; once you find a symbol in the code map or search results, use read_symbol (not read_file) to read it.", "- When tracing code: use get_dependencies to go inward (what does X call?), find_references to go outward (what calls X?).", ...(hasSearchCodeMap
76
+ ? [
77
+ "- PREFER search_code_map over search. When the code map is truncated, use search_code_map to find symbols by name or substring — then use read_symbol with the result.",
78
+ ]
79
+ : []));
80
+ }
81
+ else {
82
+ toolGuidelines.push("", "- Use read_file with offset and limit for large files to read only needed portions.", "- Use search to find relevant code before making changes.");
83
+ }
84
+ sections.push("[Tool Descriptions]", "You have the following tools available:", renderToolList(tools), "", ...toolGuidelines, "", "[Code Reading Strategy]", "- Start with entry points (e.g. index.ts, main) and follow the flow.", ...(hasSpecializedTools
85
+ ? [
86
+ "- Use find_references to see who uses a symbol; use get_dependencies to see what it calls.",
87
+ "- Trace usage outward (find_references) or implementation inward (get_dependencies) as needed.",
88
+ ]
89
+ : ["- Use search to locate relevant code, then read_file to inspect it."]), "", "[Termination Policy]", "- When the user asks you to do something (edit code, search, run commands, etc.), you MUST use the appropriate tools first. Do not conclude until you have actually performed the work.", "- When the task is complete, respond with a concise summary of what you changed.", "- If you cannot complete the task, explain what is blocking you.", "- Do not respond with empty text. Always provide a summary or explanation.", "- Do not continue exploring once the task is done.", "", "[Safety Rules]", "- Never modify files outside the workspace directory.", "- Never run destructive commands without explicit user confirmation.", "- Ask for clarification if user intent is ambiguous.", "- When asked to perform a task, communicate your execution plan to the user and ask for their confirmation before proceeding with any modifications.");
90
+ return sections.join("\n");
91
+ }
@@ -0,0 +1,55 @@
1
+ import path from "node:path";
2
+ const DESTRUCTIVE_COMMAND_PATTERNS = [
3
+ /\brm\s+-rf\b/i,
4
+ /\bmv\b.+\s+\/dev\/null\b/i,
5
+ /\bgit\s+reset\s+--hard\b/i,
6
+ /\bgit\s+clean\s+-fdx?\b/i,
7
+ ];
8
+ export function normalizeWorkspaceRoot(workspaceRoot) {
9
+ return path.resolve(workspaceRoot);
10
+ }
11
+ export function resolveWorkspacePath(requestedPath, workspaceRoot) {
12
+ const normalizedRoot = normalizeWorkspaceRoot(workspaceRoot);
13
+ const absolutePath = path.resolve(normalizedRoot, requestedPath);
14
+ if (!isWithinWorkspacePath(absolutePath, normalizedRoot)) {
15
+ throw new Error(`Path "${requestedPath}" resolves outside workspace root "${normalizedRoot}".`);
16
+ }
17
+ return absolutePath;
18
+ }
19
+ export function isWithinWorkspacePath(absolutePath, workspaceRoot) {
20
+ const normalizedRoot = normalizeWorkspaceRoot(workspaceRoot);
21
+ const relative = path.relative(normalizedRoot, absolutePath);
22
+ if (relative === "") {
23
+ return true;
24
+ }
25
+ return !relative.startsWith("..") && !path.isAbsolute(relative);
26
+ }
27
+ export function validatePath(requestedPath, workspaceRoot) {
28
+ try {
29
+ resolveWorkspacePath(requestedPath, workspaceRoot);
30
+ return true;
31
+ }
32
+ catch {
33
+ return false;
34
+ }
35
+ }
36
+ export function validateCommand(command, denylist) {
37
+ for (const pattern of denylist) {
38
+ if (pattern.test(command)) {
39
+ throw new Error(`Command "${command}" blocked by safety denylist (${pattern}).`);
40
+ }
41
+ }
42
+ }
43
+ export function isDestructiveCommand(command) {
44
+ return DESTRUCTIVE_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
45
+ }
46
+ export function ensureStepWithinLimit(step, maxSteps) {
47
+ if (step >= maxSteps) {
48
+ throw new Error(`Reached maximum step limit (${maxSteps}). Stopping tool loop.`);
49
+ }
50
+ }
51
+ export function validateFileReadSize(actualSizeBytes, maxFileSizeBytes) {
52
+ if (actualSizeBytes > maxFileSizeBytes) {
53
+ throw new Error(`File too large to read (${actualSizeBytes} bytes). Limit is ${maxFileSizeBytes} bytes.`);
54
+ }
55
+ }
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from "node:crypto";
2
+ function estimateMessageTokens(message) {
3
+ if (message.role === "tool") {
4
+ return Math.ceil((message.toolName.length + message.content.length) / 4);
5
+ }
6
+ const toolCallTokens = message.role === "assistant" && message.toolCalls?.length
7
+ ? Math.ceil(JSON.stringify(message.toolCalls).length / 4)
8
+ : 0;
9
+ return Math.ceil(message.content.length / 4) + toolCallTokens;
10
+ }
11
+ export class Session {
12
+ id;
13
+ createdAt;
14
+ messages;
15
+ constructor(id = randomUUID()) {
16
+ this.id = id;
17
+ this.createdAt = new Date();
18
+ this.messages = [];
19
+ }
20
+ addMessage(message) {
21
+ this.messages.push(message);
22
+ }
23
+ getMessages() {
24
+ return [...this.messages];
25
+ }
26
+ getTokenEstimate() {
27
+ return this.messages.reduce((total, message) => total + estimateMessageTokens(message), 0);
28
+ }
29
+ trim(maxTokens, keepRecentMessages) {
30
+ if (keepRecentMessages < 0) {
31
+ return;
32
+ }
33
+ while (this.getTokenEstimate() > maxTokens &&
34
+ this.messages.length > keepRecentMessages) {
35
+ const protectedStart = this.getProtectedStart(keepRecentMessages);
36
+ if (protectedStart <= 0) {
37
+ return;
38
+ }
39
+ const removed = this.removeOldestChunk(protectedStart);
40
+ if (!removed) {
41
+ return;
42
+ }
43
+ }
44
+ }
45
+ getProtectedStart(keepRecentMessages) {
46
+ let protectedStart = Math.max(0, this.messages.length - keepRecentMessages);
47
+ const boundaryMessage = this.messages[protectedStart];
48
+ if (!boundaryMessage || boundaryMessage.role !== "tool") {
49
+ return protectedStart;
50
+ }
51
+ while (protectedStart > 0 &&
52
+ this.messages[protectedStart - 1]?.role === "tool") {
53
+ protectedStart -= 1;
54
+ }
55
+ const potentialToolCallMessage = this.messages[protectedStart - 1];
56
+ if (potentialToolCallMessage?.role === "assistant" &&
57
+ potentialToolCallMessage.toolCalls?.length) {
58
+ protectedStart -= 1;
59
+ }
60
+ return protectedStart;
61
+ }
62
+ removeOldestChunk(removableCount) {
63
+ if (removableCount <= 0 || this.messages.length === 0) {
64
+ return false;
65
+ }
66
+ const first = this.messages[0];
67
+ if (!first) {
68
+ return false;
69
+ }
70
+ if (first.role === "assistant" && first.toolCalls?.length) {
71
+ let removeCount = 1;
72
+ while (this.messages[removeCount]?.role === "tool") {
73
+ removeCount += 1;
74
+ }
75
+ if (removeCount > removableCount) {
76
+ return false;
77
+ }
78
+ this.messages.splice(0, removeCount);
79
+ return true;
80
+ }
81
+ if (first.role === "tool") {
82
+ let removeCount = 1;
83
+ while (this.messages[removeCount]?.role === "tool") {
84
+ removeCount += 1;
85
+ }
86
+ if (removeCount > removableCount) {
87
+ return false;
88
+ }
89
+ this.messages.splice(0, removeCount);
90
+ return true;
91
+ }
92
+ this.messages.splice(0, 1);
93
+ return true;
94
+ }
95
+ }
@@ -0,0 +1,73 @@
1
+ import path from "node:path";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { resolveWorkspacePath } from "../safety/guardrails.js";
4
+ import { expectNonEmptyString } from "./helpers.js";
5
+ function expectString(input, key) {
6
+ const value = input[key];
7
+ if (typeof value !== "string") {
8
+ throw new Error(`Input "${key}" must be a string.`);
9
+ }
10
+ return value;
11
+ }
12
+ function countOccurrences(haystack, needle) {
13
+ if (needle.length === 0) {
14
+ return 0;
15
+ }
16
+ let count = 0;
17
+ let index = 0;
18
+ while (true) {
19
+ const found = haystack.indexOf(needle, index);
20
+ if (found === -1) {
21
+ break;
22
+ }
23
+ count += 1;
24
+ index = found + needle.length;
25
+ }
26
+ return count;
27
+ }
28
+ export function createEditFileTool(config, projectIndex) {
29
+ return {
30
+ name: "edit_file",
31
+ description: "Replace exactly one instance of old_string with new_string in a file.",
32
+ inputSchema: {
33
+ type: "object",
34
+ properties: {
35
+ path: {
36
+ type: "string",
37
+ description: "Path to file relative to workspace root.",
38
+ },
39
+ old_string: {
40
+ type: "string",
41
+ description: "Exact text to replace (must match once).",
42
+ },
43
+ new_string: {
44
+ type: "string",
45
+ description: "Replacement text.",
46
+ },
47
+ },
48
+ required: ["path", "old_string", "new_string"],
49
+ additionalProperties: false,
50
+ },
51
+ execute: async (input) => {
52
+ const requestedPath = expectNonEmptyString(input, "path");
53
+ const oldString = expectNonEmptyString(input, "old_string");
54
+ const newString = expectString(input, "new_string");
55
+ const filePath = resolveWorkspacePath(requestedPath, config.workspaceRoot);
56
+ const current = await readFile(filePath, "utf8");
57
+ const occurrences = countOccurrences(current, oldString);
58
+ if (occurrences === 0) {
59
+ throw new Error(`old_string was not found in "${requestedPath}".`);
60
+ }
61
+ if (occurrences > 1) {
62
+ throw new Error(`old_string matched ${occurrences} times in "${requestedPath}". It must be unique.`);
63
+ }
64
+ const updated = current.replace(oldString, newString);
65
+ await writeFile(filePath, updated, "utf8");
66
+ if (projectIndex) {
67
+ const relPath = path.relative(config.workspaceRoot, filePath);
68
+ projectIndex.reindexFile(relPath, updated);
69
+ }
70
+ return `Updated "${requestedPath}" successfully.`;
71
+ },
72
+ };
73
+ }