@denizokcu/haze 0.0.2 → 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.
- package/CHANGELOG.md +19 -0
- package/README.md +100 -34
- package/dist/cli/commands/chat.d.ts +3 -1
- package/dist/cli/commands/chat.js +500 -56
- package/dist/cli/commands/commands.d.ts +5 -0
- package/dist/cli/commands/commands.js +114 -29
- package/dist/cli/commands/formatters.js +32 -2
- package/dist/cli/commands/streaming.d.ts +6 -1
- package/dist/cli/commands/streaming.js +316 -98
- package/dist/cli/index.js +5 -2
- package/dist/config/inputHistory.js +8 -0
- package/dist/config/providers.d.ts +26 -0
- package/dist/config/providers.js +88 -0
- package/dist/config/settings.d.ts +9 -2
- package/dist/core/agent/compaction.d.ts +13 -0
- package/dist/core/agent/compaction.js +34 -0
- package/dist/core/agent/errors.d.ts +3 -0
- package/dist/core/agent/errors.js +13 -0
- package/dist/core/agent/events.d.ts +58 -0
- package/dist/core/agent/events.js +3 -0
- package/dist/core/goal/completionPolicy.d.ts +28 -0
- package/dist/core/goal/completionPolicy.js +84 -0
- package/dist/core/goal/requestClassifier.d.ts +6 -0
- package/dist/core/goal/requestClassifier.js +31 -0
- package/dist/core/goal/sessionGoal.d.ts +30 -0
- package/dist/core/goal/sessionGoal.js +88 -0
- package/dist/core/session/sessionStore.d.ts +37 -0
- package/dist/core/session/sessionStore.js +59 -0
- package/dist/core/subagent/subagentRunner.d.ts +33 -0
- package/dist/core/subagent/subagentRunner.js +140 -0
- package/dist/llm/client.d.ts +1 -1
- package/dist/llm/client.js +6 -6
- package/dist/llm/hazeTools.d.ts +86 -0
- package/dist/llm/hazeTools.js +313 -93
- package/dist/llm/initPrompt.js +6 -4
- package/dist/llm/systemPrompt.js +11 -7
- package/dist/skills/builder/SkillBuilder.d.ts +6 -0
- package/dist/skills/builder/SkillBuilder.js +146 -24
- package/dist/ui/components/ErrorView.d.ts +2 -1
- package/dist/ui/components/Header.d.ts +2 -1
- package/dist/ui/components/Header.js +1 -11
- package/dist/ui/components/MarkdownText.d.ts +2 -1
- package/dist/ui/components/TextInput.d.ts +7 -3
- package/dist/ui/components/TextInput.js +112 -27
- package/dist/ui/theme.d.ts +3 -0
- package/dist/ui/theme.js +4 -1
- package/package.json +8 -8
package/dist/llm/hazeTools.js
CHANGED
|
@@ -40,6 +40,60 @@ function truncate(text, maxChars = MAX_OUTPUT_CHARS) {
|
|
|
40
40
|
function numberLines(lines, startLine) {
|
|
41
41
|
return lines.map((line, index) => `${String(startLine + index).padStart(4, ' ')} | ${line}`).join('\n');
|
|
42
42
|
}
|
|
43
|
+
function stripLineNumberPrefixes(text) {
|
|
44
|
+
return text.replace(/^\s*\d+\s+\| ?/gm, '');
|
|
45
|
+
}
|
|
46
|
+
function lineStartOffsets(text) {
|
|
47
|
+
const offsets = [0];
|
|
48
|
+
for (let index = 0; index < text.length; index++) {
|
|
49
|
+
if (text[index] === '\n')
|
|
50
|
+
offsets.push(index + 1);
|
|
51
|
+
}
|
|
52
|
+
return offsets;
|
|
53
|
+
}
|
|
54
|
+
function findLineTrimmedRange(original, oldText) {
|
|
55
|
+
const wantedLines = oldText.replace(/\r\n/g, '\n').split('\n').map(line => line.trimEnd());
|
|
56
|
+
if (wantedLines.at(-1) === '')
|
|
57
|
+
wantedLines.pop();
|
|
58
|
+
if (wantedLines.length === 0)
|
|
59
|
+
return undefined;
|
|
60
|
+
const originalLines = original.replace(/\r\n/g, '\n').split('\n');
|
|
61
|
+
const hasTrailingNewline = original.endsWith('\n');
|
|
62
|
+
if (hasTrailingNewline)
|
|
63
|
+
originalLines.pop();
|
|
64
|
+
const offsets = lineStartOffsets(original);
|
|
65
|
+
const matches = [];
|
|
66
|
+
for (let lineIndex = 0; lineIndex <= originalLines.length - wantedLines.length; lineIndex++) {
|
|
67
|
+
const window = originalLines.slice(lineIndex, lineIndex + wantedLines.length).map(line => line.trimEnd());
|
|
68
|
+
if (window.every((line, index) => line === wantedLines[index])) {
|
|
69
|
+
const start = offsets[lineIndex] ?? 0;
|
|
70
|
+
const endLineIndex = lineIndex + wantedLines.length;
|
|
71
|
+
const end = endLineIndex < offsets.length ? (offsets[endLineIndex] ?? original.length) : original.length;
|
|
72
|
+
matches.push({ start, end });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
if (matches.length !== 1)
|
|
76
|
+
return undefined;
|
|
77
|
+
return matches[0];
|
|
78
|
+
}
|
|
79
|
+
function findEditRange(original, oldText) {
|
|
80
|
+
const candidates = [oldText, stripLineNumberPrefixes(oldText)].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
|
|
81
|
+
for (const candidate of candidates) {
|
|
82
|
+
const first = original.indexOf(candidate);
|
|
83
|
+
if (first !== -1) {
|
|
84
|
+
const second = original.indexOf(candidate, first + candidate.length);
|
|
85
|
+
if (second !== -1)
|
|
86
|
+
return { kind: 'multiple' };
|
|
87
|
+
return { kind: 'found', start: first, end: first + candidate.length, approximate: candidate !== oldText };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
for (const candidate of candidates) {
|
|
91
|
+
const range = findLineTrimmedRange(original, candidate);
|
|
92
|
+
if (range)
|
|
93
|
+
return { kind: 'found', ...range, approximate: true };
|
|
94
|
+
}
|
|
95
|
+
return { kind: 'missing' };
|
|
96
|
+
}
|
|
43
97
|
function toolCallKey(toolName, input) {
|
|
44
98
|
return `${toolName}:${JSON.stringify(input)}`;
|
|
45
99
|
}
|
|
@@ -52,13 +106,46 @@ function isMutatingTool(toolName) {
|
|
|
52
106
|
return ['editFile', 'replaceLines', 'writeFile'].includes(toolName);
|
|
53
107
|
}
|
|
54
108
|
function isReadOnlyFileTool(toolName) {
|
|
55
|
-
return ['listFiles', 'readFile'].includes(toolName);
|
|
109
|
+
return ['listFiles', 'readFile', 'grep'].includes(toolName);
|
|
56
110
|
}
|
|
57
111
|
function inputPath(input) {
|
|
58
112
|
return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
|
|
59
113
|
? input.path
|
|
60
114
|
: undefined;
|
|
61
115
|
}
|
|
116
|
+
function isStructuredFailure(value) {
|
|
117
|
+
return typeof value === 'object' && value != null && 'ok' in value && value.ok === false;
|
|
118
|
+
}
|
|
119
|
+
function structuredToolFailure(toolName, error, suggestedNextStep, pathForError) {
|
|
120
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
121
|
+
return { ok: false, toolName, path: pathForError, error: message, recoverable: true, suggestedNextStep };
|
|
122
|
+
}
|
|
123
|
+
const INLINE_DIFF_LINE_LIMIT = 20;
|
|
124
|
+
function splitDiffLines(text) {
|
|
125
|
+
const lines = text.split(/\r?\n/);
|
|
126
|
+
if (text.endsWith('\n') || text.endsWith('\r\n'))
|
|
127
|
+
lines.pop();
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
function lineNumberAtOffset(text, offset) {
|
|
131
|
+
let line = 1;
|
|
132
|
+
for (let index = 0; index < offset; index++) {
|
|
133
|
+
if (text.charCodeAt(index) === 10)
|
|
134
|
+
line += 1;
|
|
135
|
+
}
|
|
136
|
+
return line;
|
|
137
|
+
}
|
|
138
|
+
function replacementDiff(oldText, newText, oldStartLine, newStartLine, context) {
|
|
139
|
+
const oldLines = splitDiffLines(oldText);
|
|
140
|
+
const newLines = splitDiffLines(newText);
|
|
141
|
+
const diff = [];
|
|
142
|
+
if (context?.before)
|
|
143
|
+
diff.push({ type: 'context', ...context.before });
|
|
144
|
+
diff.push(...oldLines.map((text, index) => ({ type: 'remove', oldLine: oldStartLine + index, text })), ...newLines.map((text, index) => ({ type: 'add', newLine: newStartLine + index, text })));
|
|
145
|
+
if (context?.after)
|
|
146
|
+
diff.push({ type: 'context', ...context.after });
|
|
147
|
+
return { diff, addedLines: newLines.length, removedLines: oldLines.length };
|
|
148
|
+
}
|
|
62
149
|
async function runDedupedTool(toolName, input, context, execute) {
|
|
63
150
|
const ctx = hazeContext(context);
|
|
64
151
|
if (!ctx)
|
|
@@ -67,9 +154,18 @@ async function runDedupedTool(toolName, input, context, execute) {
|
|
|
67
154
|
ctx.completedToolCalls ??= new Map();
|
|
68
155
|
ctx.failedMutationPaths ??= new Set();
|
|
69
156
|
ctx.pathsReadAfterFailedMutation ??= new Set();
|
|
157
|
+
ctx.inFlightMutationPaths ??= new Set();
|
|
70
158
|
ctx.mutationEpoch ??= 0;
|
|
71
159
|
const key = toolCallKey(toolName, input);
|
|
72
160
|
const pathForInput = inputPath(input);
|
|
161
|
+
if (isMutatingTool(toolName) && pathForInput && ctx.inFlightMutationPaths.has(pathForInput)) {
|
|
162
|
+
return {
|
|
163
|
+
ok: true,
|
|
164
|
+
duplicateSkipped: true,
|
|
165
|
+
toolName,
|
|
166
|
+
reason: `Skipped concurrent mutation for ${pathForInput}. Read the file again, then make one editFile call with all non-overlapping replacements or one replaceLines call based on the latest line numbers.`,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
73
169
|
if (isMutatingTool(toolName) && pathForInput && ctx.failedMutationPaths.has(pathForInput) && !ctx.pathsReadAfterFailedMutation.has(pathForInput)) {
|
|
74
170
|
throw new Error(`Read ${pathForInput} before attempting another edit after the previous edit failure.`);
|
|
75
171
|
}
|
|
@@ -93,10 +189,19 @@ async function runDedupedTool(toolName, input, context, execute) {
|
|
|
93
189
|
reason: 'Skipped duplicate in-flight tool call with identical input.',
|
|
94
190
|
};
|
|
95
191
|
}
|
|
192
|
+
if (isMutatingTool(toolName) && pathForInput)
|
|
193
|
+
ctx.inFlightMutationPaths.add(pathForInput);
|
|
96
194
|
const promise = execute();
|
|
97
195
|
ctx.inFlightToolCalls.set(key, promise);
|
|
98
196
|
try {
|
|
99
197
|
const result = await promise;
|
|
198
|
+
if (isStructuredFailure(result)) {
|
|
199
|
+
if (isMutatingTool(toolName) && pathForInput) {
|
|
200
|
+
ctx.failedMutationPaths.add(pathForInput);
|
|
201
|
+
ctx.pathsReadAfterFailedMutation.delete(pathForInput);
|
|
202
|
+
}
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
100
205
|
if (toolName === 'readFile' && pathForInput)
|
|
101
206
|
ctx.pathsReadAfterFailedMutation.add(pathForInput);
|
|
102
207
|
if (isMutatingTool(toolName)) {
|
|
@@ -118,6 +223,8 @@ async function runDedupedTool(toolName, input, context, execute) {
|
|
|
118
223
|
}
|
|
119
224
|
finally {
|
|
120
225
|
ctx.inFlightToolCalls.delete(key);
|
|
226
|
+
if (isMutatingTool(toolName) && pathForInput)
|
|
227
|
+
ctx.inFlightMutationPaths?.delete(pathForInput);
|
|
121
228
|
}
|
|
122
229
|
}
|
|
123
230
|
function looksLikeShellFileMutation(command) {
|
|
@@ -136,29 +243,34 @@ export const hazeTools = {
|
|
|
136
243
|
includeIgnored: z.boolean().default(false).describe('Include files ignored by .gitignore. Use only when explicitly needed.'),
|
|
137
244
|
}),
|
|
138
245
|
execute: async ({ path: dirPath, recursive, maxEntries, cursor, includeIgnored }, context) => runDedupedTool('listFiles', { path: dirPath, recursive, maxEntries, cursor, includeIgnored }, context, async () => {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
246
|
+
try {
|
|
247
|
+
const absolutePath = resolveWorkspacePath(dirPath);
|
|
248
|
+
await assertNotIgnored(absolutePath, dirPath, includeIgnored);
|
|
249
|
+
const entries = [];
|
|
250
|
+
let ignoredSkipped = 0;
|
|
251
|
+
const walked = await walkDir(absolutePath, { recursive, maxEntries: maxEntries + 1, cursor, filter: async (entry) => {
|
|
252
|
+
if (!includeIgnored && await isGitIgnored(entry.absolutePath)) {
|
|
253
|
+
ignoredSkipped++;
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
return true;
|
|
257
|
+
} });
|
|
258
|
+
const page = walked.slice(0, maxEntries);
|
|
259
|
+
const hasMore = walked.length > maxEntries;
|
|
260
|
+
for (const entry of page) {
|
|
261
|
+
if (entry.isDirectory) {
|
|
262
|
+
entries.push({ path: entry.path, type: 'directory' });
|
|
263
|
+
}
|
|
264
|
+
else if (entry.isFile) {
|
|
265
|
+
const stat = await fs.stat(entry.absolutePath);
|
|
266
|
+
entries.push({ path: entry.path, type: 'file', size: stat.size });
|
|
147
267
|
}
|
|
148
|
-
return true;
|
|
149
|
-
} });
|
|
150
|
-
const page = walked.slice(0, maxEntries);
|
|
151
|
-
const hasMore = walked.length > maxEntries;
|
|
152
|
-
for (const entry of page) {
|
|
153
|
-
if (entry.isDirectory) {
|
|
154
|
-
entries.push({ path: entry.path, type: 'directory' });
|
|
155
|
-
}
|
|
156
|
-
else if (entry.isFile) {
|
|
157
|
-
const stat = await fs.stat(entry.absolutePath);
|
|
158
|
-
entries.push({ path: entry.path, type: 'file', size: stat.size });
|
|
159
268
|
}
|
|
269
|
+
return { path: dirPath, recursive, includeIgnored, cursor, nextCursor: hasMore ? page.at(-1)?.path : undefined, ignoredSkipped, entries, truncated: hasMore };
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
return structuredToolFailure('listFiles', error, 'Check that the directory exists and is not ignored, or retry with a narrower path.', dirPath);
|
|
160
273
|
}
|
|
161
|
-
return { path: dirPath, recursive, includeIgnored, cursor, nextCursor: hasMore ? page.at(-1)?.path : undefined, ignoredSkipped, entries, truncated: hasMore };
|
|
162
274
|
}),
|
|
163
275
|
}),
|
|
164
276
|
readFile: tool({
|
|
@@ -170,26 +282,92 @@ export const hazeTools = {
|
|
|
170
282
|
allowIgnored: z.boolean().default(false).describe('Read the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
|
|
171
283
|
}),
|
|
172
284
|
execute: async ({ path: filePath, offset, limit, allowIgnored }, context) => runDedupedTool('readFile', { path: filePath, offset, limit, allowIgnored }, context, async () => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
285
|
+
try {
|
|
286
|
+
const absolutePath = resolveWorkspacePath(filePath);
|
|
287
|
+
await assertNotIgnored(absolutePath, filePath, allowIgnored);
|
|
288
|
+
const content = await fs.readFile(absolutePath, 'utf8');
|
|
289
|
+
const lines = content.split(/\r?\n/);
|
|
290
|
+
const start = offset == null ? 0 : offset - 1;
|
|
291
|
+
const end = limit == null ? lines.length : start + limit;
|
|
292
|
+
const selectedLines = lines.slice(start, end);
|
|
293
|
+
const selected = selectedLines.join('\n');
|
|
294
|
+
return {
|
|
295
|
+
path: filePath,
|
|
296
|
+
startLine: start + 1,
|
|
297
|
+
endLine: Math.min(end, lines.length),
|
|
298
|
+
totalLines: lines.length,
|
|
299
|
+
lineNumberedText: numberLines(selectedLines, start + 1),
|
|
300
|
+
...truncate(selected),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
return structuredToolFailure('readFile', error, 'Check the path with listFiles, or set allowIgnored=true only if the user explicitly asked to inspect an ignored file.', filePath);
|
|
305
|
+
}
|
|
306
|
+
}),
|
|
307
|
+
}),
|
|
308
|
+
grep: tool({
|
|
309
|
+
description: 'Search file contents with a regex pattern using ripgrep. Use this to find symbol definitions, usages, string literals, import paths, and code patterns across the workspace. Much faster and more targeted than reading files one by one with readFile. Respects .gitignore by default.',
|
|
310
|
+
inputSchema: z.object({
|
|
311
|
+
pattern: z.string().min(1).describe('Regex pattern to search for (PCRE-compatible). Examples: "function handleClick", "import.*from.*react", "class UserService", "TODO|FIXME"'),
|
|
312
|
+
path: z.string().default('.').describe('Directory or file path to search in, relative to the workspace. Narrow this to focus results.'),
|
|
313
|
+
glob: z.string().optional().describe('File glob filter. Examples: "*.ts", "*.{js,jsx}", "src/**/*.py". Narrows search to matching files.'),
|
|
314
|
+
contextLines: z.number().int().nonnegative().max(5).default(2).describe('Number of context lines before and after each match (0-5). Use 0 for compact output, 2-3 for understanding surrounding code.'),
|
|
315
|
+
maxMatches: z.number().int().positive().max(200).default(50).describe('Maximum number of matches to return. Increase for broad searches, decrease for focused lookups.'),
|
|
316
|
+
caseInsensitive: z.boolean().default(false).describe('Case-insensitive matching. Useful for symbol names that may vary in casing.'),
|
|
317
|
+
}),
|
|
318
|
+
execute: async ({ pattern, path: searchPath, glob, contextLines, maxMatches, caseInsensitive }, context) => runDedupedTool('grep', { pattern, path: searchPath, glob, contextLines, maxMatches, caseInsensitive }, context, async () => {
|
|
319
|
+
try {
|
|
320
|
+
const absolutePath = resolveWorkspacePath(searchPath);
|
|
321
|
+
const args = [
|
|
322
|
+
'--no-heading', '--line-number', '--color=never',
|
|
323
|
+
'--max-count', String(maxMatches),
|
|
324
|
+
'--context', String(contextLines),
|
|
325
|
+
];
|
|
326
|
+
if (caseInsensitive)
|
|
327
|
+
args.push('--ignore-case');
|
|
328
|
+
if (glob)
|
|
329
|
+
args.push('--glob', glob);
|
|
330
|
+
args.push('--', pattern, absolutePath);
|
|
331
|
+
let stdout = '';
|
|
332
|
+
try {
|
|
333
|
+
const result = await execFile('rg', args, { cwd: workspaceRoot(), timeout: 30_000 });
|
|
334
|
+
stdout = result.stdout;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
|
|
338
|
+
if (code === 1) {
|
|
339
|
+
stdout = '';
|
|
340
|
+
}
|
|
341
|
+
else {
|
|
342
|
+
throw error;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
if (!stdout) {
|
|
346
|
+
return { pattern, path: searchPath, glob: glob ?? null, caseInsensitive, matches: [], totalMatches: 0, truncated: false };
|
|
347
|
+
}
|
|
348
|
+
const { text: output, truncated } = truncate(stdout);
|
|
349
|
+
const lines = output.split('\n').filter(Boolean);
|
|
350
|
+
const matches = [];
|
|
351
|
+
for (const line of lines) {
|
|
352
|
+
const match = line.match(/^(\S+?):(\d+)[-:](.*)$/);
|
|
353
|
+
if (!match)
|
|
354
|
+
continue;
|
|
355
|
+
const [, file, lineStr, content] = match;
|
|
356
|
+
if (file && lineStr && content !== undefined) {
|
|
357
|
+
const isContext = line.includes('-');
|
|
358
|
+
const relativePath = path.relative(workspaceRoot(), file);
|
|
359
|
+
matches.push({ file: relativePath, line: Number(lineStr), content, isContext });
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
return { pattern, path: searchPath, glob: glob ?? null, caseInsensitive, matches, totalMatches: matches.filter(m => !m.isContext).length, truncated };
|
|
363
|
+
}
|
|
364
|
+
catch (error) {
|
|
365
|
+
return structuredToolFailure('grep', error, 'Check that the search path exists and the pattern is valid regex. Try a narrower path or simpler pattern.', searchPath);
|
|
366
|
+
}
|
|
189
367
|
}),
|
|
190
368
|
}),
|
|
191
369
|
replaceLines: tool({
|
|
192
|
-
description: 'Replace a 1-based inclusive line range in an existing UTF-8 text file. Prefer this after reading a file when exact editFile replacements are ambiguous or fail.',
|
|
370
|
+
description: 'Replace a 1-based inclusive line range in an existing UTF-8 text file. Prefer this after reading a file when exact editFile replacements are ambiguous or fail. If endLine is slightly beyond EOF, it is clamped to the current last line.',
|
|
193
371
|
inputSchema: z.object({
|
|
194
372
|
path: z.string().describe('File path relative to the current workspace'),
|
|
195
373
|
startLine: z.number().int().positive().describe('First 1-based line number to replace'),
|
|
@@ -198,30 +376,41 @@ export const hazeTools = {
|
|
|
198
376
|
allowIgnored: z.boolean().default(false).describe('Edit the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
|
|
199
377
|
}),
|
|
200
378
|
execute: async ({ path: filePath, startLine, endLine, content, allowIgnored }, context) => runDedupedTool('replaceLines', { path: filePath, startLine, endLine, content, allowIgnored }, context, async () => {
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
lines
|
|
379
|
+
try {
|
|
380
|
+
const absolutePath = resolveWorkspacePath(filePath);
|
|
381
|
+
await assertNotIgnored(absolutePath, filePath, allowIgnored);
|
|
382
|
+
const original = await fs.readFile(absolutePath, 'utf8');
|
|
383
|
+
const hasTrailingNewline = original.endsWith('\n');
|
|
384
|
+
const lines = original.split(/\r?\n/);
|
|
385
|
+
if (hasTrailingNewline)
|
|
386
|
+
lines.pop();
|
|
387
|
+
const isAppend = startLine === lines.length + 1 && endLine === lines.length;
|
|
388
|
+
if (!isAppend && endLine < startLine)
|
|
389
|
+
throw new Error('endLine must be greater than or equal to startLine, except when appending at EOF with startLine=totalLines+1 and endLine=totalLines');
|
|
390
|
+
if (startLine > lines.length + 1)
|
|
391
|
+
throw new Error(`startLine ${startLine} is beyond end of file (${lines.length} lines)`);
|
|
392
|
+
const effectiveEndLine = !isAppend && endLine > lines.length ? lines.length : endLine;
|
|
393
|
+
const replacementLines = content.length === 0 ? [] : content.split(/\r?\n/);
|
|
394
|
+
const removedText = isAppend ? '' : lines.slice(startLine - 1, effectiveEndLine).join('\n');
|
|
395
|
+
const beforeContext = startLine > 1 ? { oldLine: startLine - 1, newLine: startLine - 1, text: lines[startLine - 2] ?? '' } : undefined;
|
|
396
|
+
const afterContext = !isAppend && effectiveEndLine < lines.length
|
|
397
|
+
? { oldLine: effectiveEndLine + 1, newLine: startLine + replacementLines.length, text: lines[effectiveEndLine] ?? '' }
|
|
398
|
+
: undefined;
|
|
399
|
+
if (isAppend) {
|
|
400
|
+
lines.push(...replacementLines);
|
|
401
|
+
}
|
|
402
|
+
else {
|
|
403
|
+
lines.splice(startLine - 1, effectiveEndLine - startLine + 1, ...replacementLines);
|
|
404
|
+
}
|
|
405
|
+
const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
|
|
406
|
+
const { diff, addedLines, removedLines } = replacementDiff(removedText, content, startLine, startLine, { before: beforeContext, after: afterContext });
|
|
407
|
+
const diffLineCount = diff.length;
|
|
408
|
+
await fs.writeFile(absolutePath, updated, 'utf8');
|
|
409
|
+
return { ok: true, path: filePath, startLine, endLine: effectiveEndLine, requestedEndLine: endLine, endLineClamped: effectiveEndLine !== endLine, replacementLines: replacementLines.length, appended: isAppend, addedLines, removedLines, diffLineCount, diff: diffLineCount <= INLINE_DIFF_LINE_LIMIT ? diff : undefined };
|
|
218
410
|
}
|
|
219
|
-
|
|
220
|
-
|
|
411
|
+
catch (error) {
|
|
412
|
+
return structuredToolFailure('replaceLines', error, 'Read the file again for current line numbers, then retry replaceLines with a valid range.', filePath);
|
|
221
413
|
}
|
|
222
|
-
const updated = lines.join('\n') + (hasTrailingNewline ? '\n' : '');
|
|
223
|
-
await fs.writeFile(absolutePath, updated, 'utf8');
|
|
224
|
-
return { ok: true, path: filePath, startLine, endLine, replacementLines: replacementLines.length, appended: isAppend };
|
|
225
414
|
}),
|
|
226
415
|
}),
|
|
227
416
|
writeFile: tool({
|
|
@@ -233,26 +422,31 @@ export const hazeTools = {
|
|
|
233
422
|
allowIgnored: z.boolean().default(false).describe('Write the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
|
|
234
423
|
}),
|
|
235
424
|
execute: async ({ path: filePath, content, overwriteExisting, allowIgnored }, context) => runDedupedTool('writeFile', { path: filePath, content, overwriteExisting, allowIgnored }, context, async () => {
|
|
236
|
-
const absolutePath = resolveWorkspacePath(filePath);
|
|
237
|
-
await assertNotIgnored(absolutePath, filePath, allowIgnored);
|
|
238
425
|
try {
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
426
|
+
const absolutePath = resolveWorkspacePath(filePath);
|
|
427
|
+
await assertNotIgnored(absolutePath, filePath, allowIgnored);
|
|
428
|
+
try {
|
|
429
|
+
await fs.access(absolutePath);
|
|
430
|
+
if (!overwriteExisting) {
|
|
431
|
+
throw new Error(`Refusing to overwrite existing file: ${filePath}. Use editFile/replaceLines for targeted edits, or set overwriteExisting=true for an intentional complete rewrite.`);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
catch (error) {
|
|
435
|
+
const code = typeof error === 'object' && error != null && 'code' in error ? error.code : undefined;
|
|
436
|
+
if (code !== 'ENOENT')
|
|
437
|
+
throw error;
|
|
242
438
|
}
|
|
439
|
+
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
440
|
+
await fs.writeFile(absolutePath, content, 'utf8');
|
|
441
|
+
return { ok: true, path: filePath, bytes: Buffer.byteLength(content, 'utf8'), overwritten: overwriteExisting };
|
|
243
442
|
}
|
|
244
443
|
catch (error) {
|
|
245
|
-
|
|
246
|
-
if (code !== 'ENOENT')
|
|
247
|
-
throw error;
|
|
444
|
+
return structuredToolFailure('writeFile', error, 'Use editFile/replaceLines for existing files, set overwriteExisting=true only for an intentional rewrite, or check the path/ignored-file setting.', filePath);
|
|
248
445
|
}
|
|
249
|
-
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
|
|
250
|
-
await fs.writeFile(absolutePath, content, 'utf8');
|
|
251
|
-
return { ok: true, path: filePath, bytes: Buffer.byteLength(content, 'utf8'), overwritten: overwriteExisting };
|
|
252
446
|
}),
|
|
253
447
|
}),
|
|
254
448
|
editFile: tool({
|
|
255
|
-
description: 'Edit a text file using
|
|
449
|
+
description: 'Edit a text file using unique replacements. Each oldText should match the current file; line-number prefixes from readFile output and trailing-whitespace-only differences are tolerated when the match is still unique. Put multiple edits to the same file in one call. If this fails because text is missing or not unique, read the file again and use replaceLines.',
|
|
256
450
|
inputSchema: z.object({
|
|
257
451
|
path: z.string().describe('File path relative to the current workspace'),
|
|
258
452
|
edits: z.array(z.object({
|
|
@@ -262,29 +456,55 @@ export const hazeTools = {
|
|
|
262
456
|
allowIgnored: z.boolean().default(false).describe('Edit the file even if it is ignored by .gitignore. Use only when explicitly needed.'),
|
|
263
457
|
}),
|
|
264
458
|
execute: async ({ path: filePath, edits, allowIgnored }, context) => runDedupedTool('editFile', { path: filePath, edits, allowIgnored }, context, async () => {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
459
|
+
try {
|
|
460
|
+
const absolutePath = resolveWorkspacePath(filePath);
|
|
461
|
+
await assertNotIgnored(absolutePath, filePath, allowIgnored);
|
|
462
|
+
const original = await fs.readFile(absolutePath, 'utf8');
|
|
463
|
+
const ranges = edits.map((edit, index) => {
|
|
464
|
+
const match = findEditRange(original, edit.oldText);
|
|
465
|
+
if (match.kind === 'missing')
|
|
466
|
+
throw new Error(`edit ${index}: oldText was not found. Read the file again and use the exact current text, or use replaceLines with the latest line numbers.`);
|
|
467
|
+
if (match.kind === 'multiple')
|
|
468
|
+
throw new Error(`edit ${index}: oldText is not unique`);
|
|
469
|
+
return { index, start: match.start, end: match.end, edit, approximate: match.approximate };
|
|
470
|
+
}).sort((a, b) => a.start - b.start);
|
|
471
|
+
for (let i = 1; i < ranges.length; i++) {
|
|
472
|
+
if (ranges[i].start < ranges[i - 1].end) {
|
|
473
|
+
throw new Error(`edits ${ranges[i - 1].index} and ${ranges[i].index} overlap`);
|
|
474
|
+
}
|
|
280
475
|
}
|
|
476
|
+
let updated = original;
|
|
477
|
+
for (const range of [...ranges].sort((a, b) => b.start - a.start)) {
|
|
478
|
+
updated = updated.slice(0, range.start) + range.edit.newText + updated.slice(range.end);
|
|
479
|
+
}
|
|
480
|
+
const originalLines = splitDiffLines(original);
|
|
481
|
+
let lineDelta = 0;
|
|
482
|
+
let addedLines = 0;
|
|
483
|
+
let removedLines = 0;
|
|
484
|
+
const diff = [];
|
|
485
|
+
for (const range of ranges) {
|
|
486
|
+
const oldStartLine = lineNumberAtOffset(original, range.start);
|
|
487
|
+
const newStartLine = oldStartLine + lineDelta;
|
|
488
|
+
const oldLineCount = splitDiffLines(range.edit.oldText).length;
|
|
489
|
+
const newLineCount = splitDiffLines(range.edit.newText).length;
|
|
490
|
+
const beforeContext = oldStartLine > 1 ? { oldLine: oldStartLine - 1, newLine: newStartLine - 1, text: originalLines[oldStartLine - 2] ?? '' } : undefined;
|
|
491
|
+
const afterOldLine = oldStartLine + oldLineCount;
|
|
492
|
+
const afterContext = afterOldLine <= originalLines.length
|
|
493
|
+
? { oldLine: afterOldLine, newLine: newStartLine + newLineCount, text: originalLines[afterOldLine - 1] ?? '' }
|
|
494
|
+
: undefined;
|
|
495
|
+
const rangeDiff = replacementDiff(range.edit.oldText, range.edit.newText, oldStartLine, newStartLine, { before: beforeContext, after: afterContext });
|
|
496
|
+
diff.push(...rangeDiff.diff);
|
|
497
|
+
addedLines += rangeDiff.addedLines;
|
|
498
|
+
removedLines += rangeDiff.removedLines;
|
|
499
|
+
lineDelta += rangeDiff.addedLines - rangeDiff.removedLines;
|
|
500
|
+
}
|
|
501
|
+
const diffLineCount = diff.length;
|
|
502
|
+
await fs.writeFile(absolutePath, updated, 'utf8');
|
|
503
|
+
return { ok: true, path: filePath, edits: edits.length, approximateMatches: ranges.filter(range => range.approximate).length, addedLines, removedLines, diffLineCount, diff: diffLineCount <= INLINE_DIFF_LINE_LIMIT ? diff : undefined };
|
|
281
504
|
}
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
updated = updated.slice(0, range.start) + range.edit.newText + updated.slice(range.end);
|
|
505
|
+
catch (error) {
|
|
506
|
+
return structuredToolFailure('editFile', error, 'Read the file again, then retry with exact current text or use replaceLines with the latest line numbers.', filePath);
|
|
285
507
|
}
|
|
286
|
-
await fs.writeFile(absolutePath, updated, 'utf8');
|
|
287
|
-
return { ok: true, path: filePath, edits: edits.length };
|
|
288
508
|
}),
|
|
289
509
|
}),
|
|
290
510
|
bash: tool({
|
|
@@ -296,7 +516,7 @@ export const hazeTools = {
|
|
|
296
516
|
}),
|
|
297
517
|
execute: async ({ command, timeoutSeconds, allowMutation }, context) => runDedupedTool('bash', { command, timeoutSeconds, allowMutation }, context, async () => {
|
|
298
518
|
if (!allowMutation && looksLikeShellFileMutation(command)) {
|
|
299
|
-
|
|
519
|
+
return structuredToolFailure('bash', 'Refusing to mutate files via bash. Use writeFile/editFile/replaceLines, or set allowMutation=true only when shell mutation is explicitly required.', 'Use writeFile/editFile/replaceLines for file changes, or retry bash with allowMutation=true only when shell mutation is explicitly required.');
|
|
300
520
|
}
|
|
301
521
|
const timeoutMs = (timeoutSeconds ?? 60) * 1000;
|
|
302
522
|
return await new Promise(resolve => {
|
package/dist/llm/initPrompt.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
export function buildInitPrompt() {
|
|
2
2
|
return `Initialize this repository for Haze by creating a best-practice AGENTS.md file.
|
|
3
3
|
|
|
4
|
-
Explore the codebase first, respecting .gitignore:
|
|
4
|
+
Explore the codebase first, respecting .gitignore, but keep this quick and minimal:
|
|
5
5
|
1. Start with exactly one listFiles call from the workspace root. Do not announce that you are starting before the tool call.
|
|
6
|
-
2.
|
|
7
|
-
3.
|
|
6
|
+
2. Do not use bash for discovery unless package metadata is missing. Do not grep/find the tree for this command.
|
|
7
|
+
3. After listFiles returns, read only the small set needed to understand conventions: package/config files, README, existing AGENTS.md if present, and at most three key source entrypoints/directories.
|
|
8
|
+
4. Do not call listFiles with the same input twice. Do not read the same path repeatedly. Do not read speculative files; list the parent first if unsure.
|
|
9
|
+
5. Aim to finish in 12 tool calls or fewer. Do not read ignored files unless truly necessary.
|
|
8
10
|
|
|
9
11
|
Create or update AGENTS.md at the workspace root. It should be concise and useful for future coding agents. Include sections when known:
|
|
10
12
|
- Project overview
|
|
@@ -15,5 +17,5 @@ Create or update AGENTS.md at the workspace root. It should be concise and usefu
|
|
|
15
17
|
- Testing/validation expectations
|
|
16
18
|
- Safety notes or files/directories to avoid
|
|
17
19
|
|
|
18
|
-
If AGENTS.md already exists, preserve useful existing instructions and improve them. After writing it, summarize
|
|
20
|
+
If AGENTS.md already exists, preserve useful existing instructions and improve them with targeted editFile/replaceLines edits. Do not rewrite the entire file unless it is missing or unusable. After writing it, summarize only the change and validation status.`;
|
|
19
21
|
}
|
package/dist/llm/systemPrompt.js
CHANGED
|
@@ -5,31 +5,35 @@ export function buildSystemPrompt(contextFiles = []) {
|
|
|
5
5
|
return `You are Haze, an expert coding assistant operating inside a terminal-based agent CLI. You help users build apps by understanding the current conversation, inspecting projects, running commands, and editing files.
|
|
6
6
|
|
|
7
7
|
Available tools:
|
|
8
|
-
-
|
|
9
|
-
-
|
|
10
|
-
-
|
|
11
|
-
-
|
|
8
|
+
- grep: Fast regex search across the workspace using ripgrep. Use to find symbol definitions, usages, string literals, import paths, and code patterns. Prefer grep over readFile when you need to locate something in the codebase -- grep searches all files at once and returns matching lines with file paths and line numbers. Use glob to narrow to specific file types and path to narrow to specific directories.
|
|
9
|
+
- listFiles: List files and directories in the current workspace. Supports recursive listings and cursor pagination. Use for project structure discovery, not for finding specific code.
|
|
10
|
+
- readFile: Read a specific file when you already know which file to look at. Returns numbered lines for precise edits. Use after grep to read the full context around a match, not to search for code across files.
|
|
11
|
+
- editFile: Edit files with unique text replacements. Use only for small, unambiguous replacements. Put multiple edits to the same file in one editFile call; do not issue parallel separate edits for the same file.
|
|
12
|
+
- replaceLines: Replace a 1-based inclusive line range. Use when editFile is ambiguous or has failed once. To append at EOF, use startLine=totalLines+1 and endLine=totalLines from the latest readFile result. Slightly-too-large endLine values are clamped to EOF.
|
|
12
13
|
- writeFile: Create files, or overwrite existing files only when overwriteExisting=true is intentionally set for a complete rewrite. Prefer editFile/replaceLines for existing files.
|
|
13
14
|
- bash: Run shell commands for tests, builds, scripts, and inspection that cannot be done with file tools. Do not use bash to mutate files unless explicitly requested or file tools cannot do the job.
|
|
15
|
+
- subagent: Spawn focused subagents to run independent tasks in parallel. Each subagent gets a fresh context and full tool access. ONLY use subagents when a request clearly decomposes into 2+ independent subtasks that can run concurrently. Do NOT use subagents for single tasks, sequential work, or tasks that benefit from your full conversation context — do those yourself. Subagents have no access to the conversation history, so the main agent should always handle complex, context-dependent work directly.
|
|
14
16
|
- skill_*: Markdown skills installed in ~/.haze/skills. Use a skill tool when its description matches the user's request; it returns workflow instructions and explicitly referenced files.
|
|
15
17
|
|
|
16
18
|
Guidelines:
|
|
17
19
|
- Be concise, technical, and practical.
|
|
20
|
+
- Only spawn subagents when a request clearly splits into 2+ independent parallel tasks (e.g. "check auth, payments, and users" -> 3 subagents). For everything else, do the work yourself — you have the most context. Never spawn a single subagent for a task you could do directly.
|
|
18
21
|
- You have access to the tools listed above. Never claim that you cannot inspect files, run shell commands, or make file changes when an available tool can do it.
|
|
19
22
|
- Skills are optional instruction bundles. Call a skill tool only when relevant, then follow the returned SKILL.md instructions and references.
|
|
20
23
|
- If answering requires current workspace information, inspect it with tools instead of guessing or saying you cannot access it.
|
|
21
24
|
- When the user asks you to run a command, inspect command output, or reason about local project state, use bash or file tools rather than only explaining what the user could run.
|
|
22
25
|
- Preserve user-provided content exactly. When the user asks to add, modify, or use "this", "that", "it", or previous content, refer to the current conversation and do not substitute different text.
|
|
26
|
+
- Use grep to find code across the workspace. Do not read multiple files one by one to locate a symbol, import, or string -- use grep with a targeted pattern and glob filter instead. Only use readFile after grep has identified the relevant file and line range, or when the user names a specific file.
|
|
23
27
|
- Use listFiles for project discovery instead of bash ls/find. Start non-recursive, use recursive for focused directories, and follow nextCursor only when more listing is genuinely needed.
|
|
24
28
|
- Do not list or read the same path repeatedly unless the file changed or the previous result was insufficient.
|
|
25
29
|
- Read only directly relevant files, usually once. Do not read README/package files unless needed for the task.
|
|
26
30
|
- File tools follow .gitignore by default. Only set includeIgnored/allowIgnored when the user explicitly asks or the task truly requires ignored files, and say why.
|
|
27
|
-
- Prefer editFile for existing files when one small
|
|
31
|
+
- Prefer editFile for existing files when one small replacement is unique. For multiple edits in one file, use one editFile call with multiple non-overlapping edits instead of parallel tool calls.
|
|
28
32
|
- If editFile fails because oldText is missing or not unique, do not retry editFile for the same change; use replaceLines with lineNumberedText from readFile.
|
|
29
33
|
- Use writeFile for new files. For existing files, prefer editFile or replaceLines; only set writeFile overwriteExisting=true when a complete rewrite is intentional and safer than targeted edits.
|
|
30
34
|
- Use bash mainly for tests, builds, package scripts, and commands that are not covered by file tools. Do not combine validation with file mutation in one shell command; use file tools for edits and bash only for validation/inspection.
|
|
31
35
|
- After making changes, validate with the project's relevant test/typecheck/build command when practical. After editing source or test files in languages with syntax checkers, run the syntax check before the full test command when practical. Once a requested change is edited and validation passes, summarize; do not continue inspecting files.
|
|
32
|
-
- For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document",
|
|
36
|
+
- For action requests such as "add", "create", "write", "implement", "update", "fix", "test", or "document", work autonomously until the requested goal is complete, validation has run when practical, a concrete blocker prevents progress, or a user decision is required. Do not stop after only inspecting files.
|
|
33
37
|
- Requests like "create a plan", "make a plan", or "outline a plan" are planning requests, not implementation requests. If you create a plan document, summarize it; do not start implementing or validating unless asked.
|
|
34
38
|
- If editFile or replaceLines fails, read the affected file again with readFile before another edit attempt, then make one smaller targeted change; do not batch speculative replacements. Bash/cat does not satisfy this recovery step.
|
|
35
39
|
- For plan-only requests, stop after creating/updating the plan artifact and summarize it; do not edit source files or run validation in the same turn.
|
|
@@ -37,7 +41,7 @@ Guidelines:
|
|
|
37
41
|
- After tool use, always respond with a concise summary of what changed or what failed for the current user request only. Do not recap unrelated earlier tasks unless directly relevant.
|
|
38
42
|
- Do not call ordinary unfinished work or unresolved optional scope a blocker. A blocker is a concrete tool failure, missing/ambiguous requirement, permission problem, or unavailable dependency.
|
|
39
43
|
- For Ruby ad-hoc checks, prefer adding/running Minitest tests. If a one-liner is truly useful, use ruby -I. -e with require "file" rather than require_relative from -e.
|
|
40
|
-
- Do not say tools are unavailable just because a tool
|
|
44
|
+
- Do not say tools are unavailable just because a tool slice or loop guard was mentioned; if you can still call tools in the current turn, continue the requested work. If a local tool slice ends and work remains, state the next concrete unfinished action rather than asking the user to type continue.
|
|
41
45
|
- Do not claim tests passed or commands succeeded unless you actually ran them in the current turn and saw success.
|
|
42
46
|
- Ask before destructive actions.
|
|
43
47
|
- Show file paths clearly when working with files.${projectContext}
|
|
@@ -9,6 +9,9 @@ type GeneratedSkill = {
|
|
|
9
9
|
export declare function slug(s: string): string;
|
|
10
10
|
declare function fallbackSkill(description: string): GeneratedSkill;
|
|
11
11
|
declare function withStandardRequirements(content: string): string;
|
|
12
|
+
declare function withSkillName(content: string, name: string): string;
|
|
13
|
+
declare function normalizeSkillDescription(description: string): string;
|
|
14
|
+
declare function withSkillDescription(content: string, description: string): string;
|
|
12
15
|
declare function parseGeneratedSkill(text: string, description: string): GeneratedSkill;
|
|
13
16
|
export declare function createSkill(description: string): Promise<{
|
|
14
17
|
name: string;
|
|
@@ -21,5 +24,8 @@ export declare const internals: {
|
|
|
21
24
|
parseGeneratedSkill: typeof parseGeneratedSkill;
|
|
22
25
|
fallbackSkill: typeof fallbackSkill;
|
|
23
26
|
withStandardRequirements: typeof withStandardRequirements;
|
|
27
|
+
withSkillName: typeof withSkillName;
|
|
28
|
+
withSkillDescription: typeof withSkillDescription;
|
|
29
|
+
normalizeSkillDescription: typeof normalizeSkillDescription;
|
|
24
30
|
};
|
|
25
31
|
export {};
|