@denizokcu/haze 0.0.1 → 0.0.2

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 (51) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +114 -69
  3. package/dist/cli/commands/chat.d.ts +1 -0
  4. package/dist/cli/commands/chat.js +203 -11
  5. package/dist/cli/commands/commands.js +130 -6
  6. package/dist/cli/commands/formatters.d.ts +1 -0
  7. package/dist/cli/commands/formatters.js +18 -1
  8. package/dist/cli/commands/skills.d.ts +1 -1
  9. package/dist/cli/commands/skills.js +8 -5
  10. package/dist/cli/commands/streaming.d.ts +2 -0
  11. package/dist/cli/commands/streaming.js +424 -39
  12. package/dist/cli/index.js +1 -11
  13. package/dist/config/paths.d.ts +0 -1
  14. package/dist/config/paths.js +0 -1
  15. package/dist/llm/client.js +1 -1
  16. package/dist/llm/hazeTools.d.ts +32 -0
  17. package/dist/llm/hazeTools.js +136 -26
  18. package/dist/llm/initPrompt.js +2 -2
  19. package/dist/llm/systemPrompt.js +23 -9
  20. package/dist/skills/SkillLoader.d.ts +12 -2
  21. package/dist/skills/SkillLoader.js +64 -18
  22. package/dist/skills/SkillRegistry.d.ts +1 -5
  23. package/dist/skills/SkillRegistry.js +10 -21
  24. package/dist/skills/builder/SkillBuilder.d.ts +25 -1
  25. package/dist/skills/builder/SkillBuilder.js +169 -20
  26. package/dist/skills/skillTools.d.ts +20 -0
  27. package/dist/skills/skillTools.js +25 -0
  28. package/dist/skills/types.d.ts +12 -51
  29. package/dist/ui/components/Header.d.ts +2 -1
  30. package/dist/ui/components/Header.js +12 -2
  31. package/dist/ui/components/TextInput.d.ts +8 -1
  32. package/dist/ui/components/TextInput.js +29 -14
  33. package/dist/ui/theme.d.ts +1 -0
  34. package/dist/ui/theme.js +1 -0
  35. package/dist/utils/fs.d.ts +1 -0
  36. package/dist/utils/fs.js +10 -6
  37. package/examples/skills/files/SKILL.md +16 -0
  38. package/examples/skills/files/examples/file-editing.md +3 -0
  39. package/package.json +2 -2
  40. package/dist/skills/installer/SkillInstaller.d.ts +0 -1
  41. package/dist/skills/installer/SkillInstaller.js +0 -48
  42. package/dist/skills/manifestSchema.d.ts +0 -31
  43. package/dist/skills/manifestSchema.js +0 -23
  44. package/dist/tools/ToolExecutor.d.ts +0 -3
  45. package/dist/tools/ToolExecutor.js +0 -15
  46. package/dist/tools/types.d.ts +0 -9
  47. package/dist/tools/types.js +0 -1
  48. package/examples/skills/files/prompts/file_tasks.md +0 -1
  49. package/examples/skills/files/skill.yaml +0 -28
  50. package/examples/skills/files/tools/list_files.ts +0 -21
  51. package/examples/skills/files/tools/read_file.ts +0 -12
@@ -2,41 +2,354 @@ import { stepCountIs, streamText } from 'ai';
2
2
  import { model } from '../../llm/client.js';
3
3
  import { hazeTools } from '../../llm/hazeTools.js';
4
4
  import { buildSystemPrompt } from '../../llm/systemPrompt.js';
5
+ import { loadSkillRegistry } from '../../skills/SkillRegistry.js';
6
+ import { buildSkillTools } from '../../skills/skillTools.js';
5
7
  import { compact, toolCallSummary, toolResultSummary, formatSeconds } from './formatters.js';
8
+ function stableToolKey(toolCall) {
9
+ return `${toolCall.toolName}:${JSON.stringify(toolCall.input)}`;
10
+ }
11
+ function uniqueRepeatedToolNames(toolCalls) {
12
+ const seen = new Set();
13
+ const repeated = new Set();
14
+ for (const toolCall of toolCalls) {
15
+ const key = stableToolKey(toolCall);
16
+ if (seen.has(key))
17
+ repeated.add(toolCall.toolName);
18
+ seen.add(key);
19
+ }
20
+ return [...repeated];
21
+ }
22
+ function toolOnlyStepCount(steps) {
23
+ let count = 0;
24
+ for (const step of [...steps].reverse()) {
25
+ if (step.toolCalls.length === 0 || step.text.trim().length > 0)
26
+ break;
27
+ count += 1;
28
+ }
29
+ return count;
30
+ }
31
+ function isPlanOnlyRequest(value) {
32
+ return /\b(create|make|write|draft|outline)\s+(?:a\s+)?plan\b|\bplan\s+(?:for|to)\b/i.test(value) && !/\bimplement|execute|do\b/i.test(value);
33
+ }
34
+ function isLikelyActionRequest(value) {
35
+ if (isPlanOnlyRequest(value))
36
+ return false;
37
+ return /\b(add|create|write|implement|update|fix|change|support|wire|test|tests|document|docs|documentation|run|verify)\b/i.test(value);
38
+ }
39
+ function isValidationRequest(value) {
40
+ if (isPlanOnlyRequest(value))
41
+ return false;
42
+ return /\b(run|verify|test|tests|check|validate)\b/i.test(value);
43
+ }
44
+ function isPlanImplementationRequest(value) {
45
+ return /\b(implement|execute|do)\b.*\bplan\b|\bplan\.md\b|\btest_plan\.md\b/i.test(value);
46
+ }
47
+ function looksIncomplete(text) {
48
+ return /\b(incomplete|what remains|remains:|next:|not implemented|not created|no tests exist|created no docs|has not been|have not been|not yet|never executed|not executed|not run|cannot retry|cannot write|cannot validate|tool budget reached)\b/i.test(text);
49
+ }
50
+ function sanitizeAssistantText(text) {
51
+ return [...text].filter(char => {
52
+ const code = char.charCodeAt(0);
53
+ return !(code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127 || code === 155);
54
+ }).join('');
55
+ }
56
+ function toolInputPath(input) {
57
+ return typeof input === 'object' && input != null && 'path' in input && typeof input.path === 'string'
58
+ ? input.path
59
+ : undefined;
60
+ }
61
+ function isDuplicateSkippedOutput(output) {
62
+ return typeof output === 'object' && output != null && 'duplicateSkipped' in output && output.duplicateSkipped === true;
63
+ }
6
64
  export async function runAgentTurn(value, displayValue, contextFiles, callbacks) {
7
65
  const displayVal = displayValue ?? value;
8
66
  const userMessage = { role: 'user', text: displayVal };
9
67
  callbacks.setBusy(true);
10
68
  callbacks.addMessage(userMessage);
69
+ const abortController = new AbortController();
70
+ callbacks.setAbortController?.(abortController);
71
+ let idleTimer;
72
+ const resetIdleTimer = () => {
73
+ if (idleTimer)
74
+ clearTimeout(idleTimer);
75
+ idleTimer = setTimeout(() => abortController.abort('Haze turn timed out after no model/tool activity.'), 90_000);
76
+ };
11
77
  try {
12
78
  const m = await model();
13
79
  if (!m) {
14
- callbacks.addMessage({ role: 'assistant', text: 'No model configured. Run /login, then /model <model-name>. Haze cannot hallucinate without credentials. Progress.' });
80
+ callbacks.addMessage({ role: 'assistant', text: 'No API key configured. Run /login, then /model x-ai/grok-build-0.1. Haze cannot hallucinate without credentials. Progress.' });
15
81
  return;
16
82
  }
17
- const lastAssistantText = callbacks.getLastAssistantText();
18
- const refersToPrevious = /\b(this|that|previous|above|it)\b/i.test(value) && lastAssistantText.trim().length > 0;
19
- const userContent = refersToPrevious
20
- ? `${value}\n\nReferenced previous Haze response to preserve exactly:\n${lastAssistantText}`
21
- : value;
22
- const requestMessages = [...callbacks.getConversation(), { role: 'user', content: userContent }];
83
+ const activeModel = m;
84
+ const skillRegistry = await loadSkillRegistry();
85
+ const availableTools = { ...hazeTools, ...buildSkillTools(skillRegistry) };
86
+ const likelyPlanOnlyRequest = isPlanOnlyRequest(value);
87
+ const likelyPlanImplementationRequest = isPlanImplementationRequest(value);
88
+ const likelyActionRequest = isLikelyActionRequest(value);
89
+ const likelyValidationRequest = isValidationRequest(value);
90
+ const planImplementationGuidance = 'When implementing a plan file, first identify the concrete required checklist items and compare them with the current files. Do not edit source or tests when the required behavior is already present. Implement the smallest clearly required phase or required items, skip optional/design-question items unless explicitly requested, add tests rather than exploratory one-off scripts where possible, use file tools (not bash) for any file changes, run validation once after code/test edits, then update plan status with file tools if requested. Do not call unresolved optional scope a blocker.';
91
+ const requestMessages = likelyPlanImplementationRequest
92
+ ? [...callbacks.getConversation(), { role: 'user', content: value }, { role: 'user', content: planImplementationGuidance }]
93
+ : [...callbacks.getConversation(), { role: 'user', content: value }];
23
94
  callbacks.setConversation(requestMessages);
24
- const assistantId = `assistant-${Date.now()}`;
95
+ resetIdleTimer();
96
+ let currentAssistantId = `assistant-${Date.now()}`;
25
97
  let assistantStarted = false;
98
+ let currentAssistantText = '';
26
99
  let assistantText = '';
100
+ let toolEpoch = 0;
101
+ let currentAssistantToolEpoch = 0;
27
102
  let editFileFailed = false;
28
103
  let mutatingToolSucceeded = false;
104
+ let validationToolSucceeded = false;
105
+ let sawReadOnlyTool = false;
106
+ let sawToolCall = false;
107
+ let textAfterTool = false;
108
+ let forcedContinuationUsed = false;
109
+ let secondContinuationUsed = false;
110
+ let editRecoveryPath;
111
+ let editRecoveryReadSatisfied = false;
29
112
  const toolSummaries = [];
30
- callbacks.debugLog(`request started with ${requestMessages.length} conversation messages${refersToPrevious ? ' and previous-response reference' : ''}`);
113
+ const toolExecutionContext = { inFlightToolCalls: new Map() };
114
+ const toolGroupId = `tools-${Date.now()}-${Math.random().toString(36).slice(2)}`;
115
+ const toolDisplayItems = [];
116
+ let toolGroupStarted = false;
117
+ function renderToolGroup(streaming) {
118
+ const visibleItems = toolDisplayItems.filter(item => !item.hidden);
119
+ const grouped = new Map();
120
+ for (const item of visibleItems) {
121
+ const key = `${item.status}:${item.summary}:${item.result ?? ''}`;
122
+ const current = grouped.get(key);
123
+ if (current)
124
+ current.count += 1;
125
+ else
126
+ grouped.set(key, { item, count: 1 });
127
+ }
128
+ const rows = [...grouped.values()];
129
+ const running = visibleItems.some(item => item.status === 'running');
130
+ const header = running || streaming ? 'Running tools' : `Tools: ${visibleItems.length} call${visibleItems.length === 1 ? '' : 's'}`;
131
+ const lines = rows.map(({ item, count }) => {
132
+ const icon = item.status === 'running' ? '…' : item.status === 'success' ? '✓' : '✗';
133
+ const countText = count > 1 ? ` ×${count}` : '';
134
+ const result = item.status === 'running' ? '' : ` — ${item.result ?? item.status}${item.durationMs == null ? '' : ` in ${formatSeconds(item.durationMs)}`}`;
135
+ return ` ${icon} ${item.summary}${countText}${result}`;
136
+ });
137
+ return [header, ...lines].join('\n');
138
+ }
139
+ function updateToolGroup(streaming = true) {
140
+ const text = renderToolGroup(streaming);
141
+ if (!toolGroupStarted) {
142
+ toolGroupStarted = true;
143
+ callbacks.addMessage({ id: toolGroupId, role: 'tool', text, streaming });
144
+ }
145
+ else {
146
+ callbacks.updateMessage(toolGroupId, { text, streaming });
147
+ }
148
+ }
149
+ function recordToolStart(toolCall) {
150
+ toolDisplayItems.push({ id: toolCall.toolCallId, summary: toolCallSummary(toolCall.toolName, toolCall.input), status: 'running' });
151
+ updateToolGroup(true);
152
+ }
153
+ function recordToolDisplayFinish(event) {
154
+ const item = toolDisplayItems.find(candidate => candidate.id === event.toolCall.toolCallId);
155
+ if (!item)
156
+ return;
157
+ item.status = event.success ? 'success' : 'error';
158
+ item.result = toolResultSummary(event);
159
+ item.durationMs = event.durationMs;
160
+ item.hidden = isDuplicateSkippedOutput(event.output);
161
+ updateToolGroup(toolDisplayItems.some(candidate => candidate.status === 'running'));
162
+ }
163
+ callbacks.debugLog(`request started with ${requestMessages.length} conversation messages; action=${likelyActionRequest}`);
164
+ function recordToolFinish(event) {
165
+ const path = toolInputPath(event.toolCall.input);
166
+ const duplicateSkipped = isDuplicateSkippedOutput(event.output);
167
+ if (!event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
168
+ editFileFailed = true;
169
+ editRecoveryPath = path;
170
+ editRecoveryReadSatisfied = false;
171
+ }
172
+ if (event.success && ['listFiles', 'readFile'].includes(event.toolCall.toolName))
173
+ sawReadOnlyTool = true;
174
+ if (event.success && event.toolCall.toolName === 'readFile' && path && path === editRecoveryPath && !duplicateSkipped) {
175
+ editRecoveryReadSatisfied = true;
176
+ }
177
+ if (event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName)) {
178
+ mutatingToolSucceeded = true;
179
+ if (!path || path === editRecoveryPath) {
180
+ editRecoveryPath = undefined;
181
+ editRecoveryReadSatisfied = false;
182
+ editFileFailed = false;
183
+ }
184
+ }
185
+ if (event.success && event.toolCall.toolName === 'bash') {
186
+ const ok = typeof event.output === 'object' && event.output != null && 'ok' in event.output ? Boolean(event.output.ok) : true;
187
+ if (ok)
188
+ validationToolSucceeded = true;
189
+ }
190
+ }
191
+ async function streamAssistantResponse(messages, reason, prompt, allowTools = false) {
192
+ callbacks.debugLog(`requesting assistant ${allowTools ? 'continuation' : 'text'}: ${reason}`);
193
+ const responseId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
194
+ let responseStarted = false;
195
+ let responseText = '';
196
+ let continuationToolCalls = 0;
197
+ const continuationMessages = [
198
+ ...messages,
199
+ { role: 'user', content: prompt },
200
+ ];
201
+ const followUp = streamText({
202
+ model: activeModel,
203
+ temperature: 0,
204
+ system: buildSystemPrompt(contextFiles),
205
+ messages: continuationMessages,
206
+ tools: availableTools,
207
+ toolChoice: allowTools ? 'auto' : 'none',
208
+ stopWhen: stepCountIs(10),
209
+ abortSignal: abortController.signal,
210
+ experimental_context: toolExecutionContext,
211
+ prepareStep({ steps, messages }) {
212
+ continuationToolCalls = steps.flatMap(step => step.toolCalls).length;
213
+ if (continuationToolCalls >= 10 || toolOnlyStepCount(steps) >= 5) {
214
+ return {
215
+ toolChoice: 'none',
216
+ messages: [
217
+ ...messages,
218
+ { role: 'user', content: 'Tool budget reached. If the current request is complete, summarize only current-turn changes and validation. If incomplete, state the concrete blocker briefly; do not claim tools are unavailable and do not recap unrelated earlier tasks.' },
219
+ ],
220
+ };
221
+ }
222
+ if (likelyPlanOnlyRequest && mutatingToolSucceeded) {
223
+ return {
224
+ toolChoice: 'none',
225
+ messages: [
226
+ ...messages,
227
+ { role: 'user', content: 'This was a planning request and the plan artifact has been created or updated. Stop using tools and summarize the plan file only; do not implement or validate it.' },
228
+ ],
229
+ };
230
+ }
231
+ if (editRecoveryPath && !editRecoveryReadSatisfied) {
232
+ return {
233
+ activeTools: ['readFile'],
234
+ messages: [
235
+ ...messages,
236
+ { role: 'user', content: `A previous edit failed for ${editRecoveryPath}. Before any further edit or bash inspection, call readFile on exactly ${editRecoveryPath}. Bash/cat does not satisfy this recovery step.` },
237
+ ],
238
+ };
239
+ }
240
+ if (editFileFailed)
241
+ return { activeTools: ['listFiles', 'readFile', 'replaceLines', 'writeFile', 'bash'] };
242
+ return undefined;
243
+ },
244
+ onError({ error }) {
245
+ callbacks.debugLog(`stream error: ${error instanceof Error ? error.message : String(error)}`);
246
+ },
247
+ onFinish(event) {
248
+ callbacks.setConversation([...continuationMessages, ...event.response.messages]);
249
+ callbacks.debugLog(`conversation updated to ${continuationMessages.length + event.response.messages.length} messages after follow-up`);
250
+ },
251
+ experimental_onToolCallStart({ toolCall }) {
252
+ sawToolCall = true;
253
+ recordToolStart(toolCall);
254
+ resetIdleTimer();
255
+ callbacks.debugLog(`follow-up tool start: ${toolCall.toolName} ${compact(toolCall.input)}`);
256
+ },
257
+ experimental_onToolCallFinish(event) {
258
+ resetIdleTimer();
259
+ recordToolFinish(event);
260
+ const summary = toolResultSummary(event);
261
+ toolSummaries.push(`${event.toolCall.toolName}: ${summary}`);
262
+ recordToolDisplayFinish(event);
263
+ if (!isDuplicateSkippedOutput(event.output))
264
+ toolEpoch += 1;
265
+ callbacks.debugLog(event.success
266
+ ? `follow-up tool done: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.output)}`
267
+ : `follow-up tool error: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.error)}`);
268
+ },
269
+ });
270
+ for await (const rawDelta of followUp.textStream) {
271
+ resetIdleTimer();
272
+ const delta = sanitizeAssistantText(rawDelta);
273
+ responseText += delta;
274
+ if (!responseStarted) {
275
+ responseStarted = true;
276
+ callbacks.addMessage({ id: responseId, role: 'assistant', text: delta, streaming: true });
277
+ }
278
+ else {
279
+ callbacks.updateMessage(responseId, { text: responseText });
280
+ }
281
+ }
282
+ if (responseStarted) {
283
+ callbacks.setLastAssistantText(responseText.trim());
284
+ callbacks.updateMessage(responseId, { streaming: false });
285
+ }
286
+ return responseText.trim();
287
+ }
31
288
  const result = streamText({
32
- model: m,
289
+ model: activeModel,
290
+ temperature: 0,
33
291
  system: buildSystemPrompt(contextFiles),
34
292
  messages: requestMessages,
35
- tools: hazeTools,
36
- stopWhen: stepCountIs(15),
37
- prepareStep() {
38
- if (mutatingToolSucceeded)
39
- return { toolChoice: 'none' };
293
+ tools: availableTools,
294
+ stopWhen: stepCountIs(12),
295
+ abortSignal: abortController.signal,
296
+ experimental_context: toolExecutionContext,
297
+ onError({ error }) {
298
+ callbacks.debugLog(`stream error: ${error instanceof Error ? error.message : String(error)}`);
299
+ },
300
+ prepareStep({ steps, messages }) {
301
+ const toolCalls = steps.flatMap(step => step.toolCalls);
302
+ const repeatedToolNames = uniqueRepeatedToolNames(toolCalls);
303
+ const repeatedToolCall = repeatedToolNames.length > 0;
304
+ const consecutiveToolOnlySteps = toolOnlyStepCount(steps);
305
+ if (likelyPlanOnlyRequest && mutatingToolSucceeded) {
306
+ return {
307
+ toolChoice: 'none',
308
+ messages: [
309
+ ...messages,
310
+ { role: 'user', content: 'This was a planning request and the plan artifact has been created or updated. Stop using tools and summarize the plan file only; do not implement or validate it.' },
311
+ ],
312
+ };
313
+ }
314
+ if (editRecoveryPath && !editRecoveryReadSatisfied) {
315
+ return {
316
+ activeTools: ['readFile'],
317
+ messages: [
318
+ ...messages,
319
+ { role: 'user', content: `A previous edit failed for ${editRecoveryPath}. Before any further edit or bash inspection, call readFile on exactly ${editRecoveryPath}. Bash/cat does not satisfy this recovery step.` },
320
+ ],
321
+ };
322
+ }
323
+ if (repeatedToolCall) {
324
+ const activeTools = Object.keys(availableTools).filter(name => !repeatedToolNames.includes(name));
325
+ callbacks.debugLog(`disabling repeated tools for next step: ${repeatedToolNames.join(', ')}`);
326
+ return {
327
+ activeTools,
328
+ messages: [
329
+ ...messages,
330
+ { role: 'user', content: `You already called ${repeatedToolNames.join(', ')} with the same input. Do not repeat that tool call. Use a different relevant tool. If this is an action request and no file change has been made yet, continue with edit/write tools rather than summarizing.` },
331
+ ],
332
+ };
333
+ }
334
+ if (likelyActionRequest && !mutatingToolSucceeded && consecutiveToolOnlySteps >= 3 && toolCalls.length < 10) {
335
+ callbacks.debugLog('nudging action request toward mutation after read-only steps');
336
+ return {
337
+ messages: [
338
+ ...messages,
339
+ { role: 'user', content: 'You have inspected enough for now. This is an action request; make the requested change with editFile, replaceLines, or writeFile instead of saying tools are unavailable or summarizing.' },
340
+ ],
341
+ };
342
+ }
343
+ if (toolCalls.length >= 12 || consecutiveToolOnlySteps >= 5) {
344
+ callbacks.debugLog('forcing text response to avoid tool loop');
345
+ return {
346
+ toolChoice: 'none',
347
+ messages: [
348
+ ...messages,
349
+ { role: 'user', content: 'Tool budget reached. If the current request is complete, summarize only current-turn changes and validation. If the requested change is incomplete, state the concrete blocker briefly. Do not claim tools are unavailable, recap unrelated earlier tasks, or provide a generic remains list.' },
350
+ ],
351
+ };
352
+ }
40
353
  if (editFileFailed)
41
354
  return { activeTools: ['listFiles', 'readFile', 'replaceLines', 'writeFile', 'bash'] };
42
355
  return undefined;
@@ -44,58 +357,130 @@ export async function runAgentTurn(value, displayValue, contextFiles, callbacks)
44
357
  onStepFinish({ stepNumber, text, toolCalls, toolResults, finishReason }) {
45
358
  callbacks.debugLog(`step ${stepNumber} finished: ${finishReason}; text=${text.length}; toolCalls=${toolCalls.length}; toolResults=${toolResults.length}`);
46
359
  },
47
- onFinish({ response }) {
48
- const nextConversation = [...requestMessages, ...response.messages];
360
+ onFinish(event) {
361
+ const nextConversation = [...requestMessages, ...event.response.messages];
49
362
  callbacks.setConversation(nextConversation);
50
363
  callbacks.debugLog(`conversation updated to ${nextConversation.length} messages`);
51
364
  },
52
365
  experimental_onToolCallStart({ toolCall }) {
53
- const text = toolCallSummary(toolCall.toolName, toolCall.input);
54
- callbacks.addMessage({ id: `tool-${toolCall.toolCallId}`, role: 'tool', text, streaming: true });
366
+ sawToolCall = true;
367
+ recordToolStart(toolCall);
368
+ resetIdleTimer();
55
369
  callbacks.debugLog(`tool start: ${toolCall.toolName} ${compact(toolCall.input)}`);
56
370
  },
57
371
  experimental_onToolCallFinish(event) {
372
+ resetIdleTimer();
58
373
  const summary = toolResultSummary(event);
59
- const text = `${toolCallSummary(event.toolCall.toolName, event.toolCall.input)}\n${event.success ? '✓' : '✗'} ${summary} in ${formatSeconds(event.durationMs)}`;
60
- if (!event.success && event.toolCall.toolName === 'editFile')
61
- editFileFailed = true;
62
- if (event.success && ['editFile', 'replaceLines', 'writeFile'].includes(event.toolCall.toolName))
63
- mutatingToolSucceeded = true;
374
+ recordToolFinish(event);
64
375
  toolSummaries.push(`${event.toolCall.toolName}: ${summary}`);
65
- callbacks.updateMessage(`tool-${event.toolCall.toolCallId}`, { text, streaming: false });
376
+ recordToolDisplayFinish(event);
377
+ if (!isDuplicateSkippedOutput(event.output))
378
+ toolEpoch += 1;
66
379
  callbacks.debugLog(event.success
67
380
  ? `tool done: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.output)}`
68
381
  : `tool error: ${event.toolCall.toolName} after ${event.durationMs}ms ${compact(event.error)}`);
69
382
  }
70
383
  });
71
- for await (const delta of result.textStream) {
384
+ for await (const rawDelta of result.textStream) {
385
+ resetIdleTimer();
386
+ const delta = sanitizeAssistantText(rawDelta);
387
+ if (sawToolCall)
388
+ textAfterTool = true;
389
+ if (currentAssistantText.length > 0 && toolEpoch > currentAssistantToolEpoch) {
390
+ callbacks.updateMessage(currentAssistantId, { streaming: false });
391
+ currentAssistantId = `assistant-${Date.now()}-${Math.random().toString(36).slice(2)}`;
392
+ currentAssistantText = '';
393
+ currentAssistantToolEpoch = toolEpoch;
394
+ }
72
395
  assistantText += delta;
73
- if (!assistantStarted) {
396
+ currentAssistantText += delta;
397
+ if (currentAssistantText === delta) {
74
398
  assistantStarted = true;
75
- callbacks.addMessage({ id: assistantId, role: 'assistant', text: delta, streaming: true });
399
+ callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: currentAssistantText, streaming: true });
76
400
  }
77
401
  else {
78
- callbacks.updateMessage(assistantId, { text: assistantText });
402
+ callbacks.updateMessage(currentAssistantId, { text: currentAssistantText });
79
403
  }
80
404
  }
81
- callbacks.debugLog(`response stream finished; session has ${callbacks.getConversation().length} model messages`);
405
+ let completedConversation = callbacks.getConversation();
406
+ try {
407
+ const response = await result.response;
408
+ completedConversation = [...requestMessages, ...response.messages];
409
+ callbacks.setConversation(completedConversation);
410
+ }
411
+ catch {
412
+ // Keep the conversation from onFinish if the response promise is unavailable.
413
+ }
414
+ callbacks.debugLog(`response stream finished; session has ${completedConversation.length} model messages`);
415
+ const finalAssistantText = assistantText.trim();
416
+ const assistantAdmitsIncomplete = looksIncomplete(finalAssistantText);
417
+ const requestCompletedByTools = mutatingToolSucceeded && validationToolSucceeded && !editRecoveryPath;
418
+ const needsActionContinuation = likelyActionRequest
419
+ && !requestCompletedByTools
420
+ && ((sawReadOnlyTool && !mutatingToolSucceeded) || editFileFailed || assistantAdmitsIncomplete);
421
+ const needsValidationContinuation = likelyValidationRequest && !requestCompletedByTools && !validationToolSucceeded && (sawReadOnlyTool || mutatingToolSucceeded || assistantAdmitsIncomplete);
82
422
  if (assistantStarted) {
83
- callbacks.setLastAssistantText(assistantText.trim());
84
- callbacks.updateMessage(assistantId, { streaming: false });
423
+ callbacks.setLastAssistantText(finalAssistantText);
424
+ callbacks.updateMessage(currentAssistantId, { streaming: false });
425
+ if ((needsActionContinuation || needsValidationContinuation) && !forcedContinuationUsed) {
426
+ forcedContinuationUsed = true;
427
+ callbacks.updateMessage(currentAssistantId, { text: 'Continuing to complete the requested change...', streaming: false });
428
+ const prompt = editFileFailed
429
+ ? 'Your editFile attempt failed. Use the latest readFile line-numbered output and replaceLines to complete the requested change. Continue with any remaining tests or validation if relevant. Do not stop with a summary.'
430
+ : needsValidationContinuation
431
+ ? 'You have not run the requested validation yet. Continue now by running the appropriate test/check command. Summarize only after the command finishes.'
432
+ : mutatingToolSucceeded
433
+ ? 'Your previous response says the current request is incomplete. Continue now with the remaining edits and validation for this same request. Do not summarize a plan unless blocked.'
434
+ : 'You inspected files but have not made the requested change yet. Continue now by editing or writing the necessary files. Do not summarize a plan unless blocked.';
435
+ const continuationText = await streamAssistantResponse(completedConversation, 'current-turn completion gate', prompt, true);
436
+ if (!secondContinuationUsed && looksIncomplete(continuationText) && (likelyActionRequest || likelyValidationRequest)) {
437
+ secondContinuationUsed = true;
438
+ await streamAssistantResponse(callbacks.getConversation(), 'post-continuation completion gate', 'Your previous response still described unfinished work, missing validation, or a tool-budget issue. If any tools are still available, complete the remaining edit or run the final validation now. Only call something a blocker if a concrete tool failure prevents progress.', true);
439
+ }
440
+ }
441
+ else if (sawToolCall && !textAfterTool) {
442
+ const followUpText = await streamAssistantResponse(completedConversation, 'tool use completed without follow-up text', 'Continue from the tool result and answer my original request. Do not call tools. Summarize only current-turn changes and validation; do not recap unrelated earlier tasks.', false);
443
+ if (!followUpText) {
444
+ callbacks.addMessage({ role: 'assistant', text: 'Stopped after tool use without a follow-up response. You can ask me to continue if the task is not complete.' });
445
+ }
446
+ }
447
+ }
448
+ else if (sawToolCall) {
449
+ const allowTools = (likelyActionRequest && (!mutatingToolSucceeded || editFileFailed)) || (likelyValidationRequest && !validationToolSucceeded);
450
+ const prompt = allowTools
451
+ ? 'Continue the original request now. If it asks for a change, edit or write the necessary files. If it asks to run or verify tests, run the command. Do not provide only a retrospective summary unless blocked.'
452
+ : 'Continue from the tool result and answer my original request. Do not call tools. Summarize only current-turn changes and validation; do not recap unrelated earlier tasks.';
453
+ const followUpText = await streamAssistantResponse(completedConversation, 'tool-only turn completed without text', prompt, allowTools);
454
+ if (!secondContinuationUsed && allowTools && looksIncomplete(followUpText)) {
455
+ secondContinuationUsed = true;
456
+ await streamAssistantResponse(callbacks.getConversation(), 'post-follow-up completion gate', 'Your previous response still described unfinished work, missing validation, or a tool-budget issue. If any tools are still available, complete the remaining edit or run the final validation now. Only call something a blocker if a concrete tool failure prevents progress.', true);
457
+ }
458
+ if (!followUpText) {
459
+ const fallback = toolSummaries.length > 0
460
+ ? `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.`
461
+ : 'Finished without a text response.';
462
+ callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: fallback, streaming: false });
463
+ }
85
464
  }
86
465
  else {
87
- const fallback = toolSummaries.length > 0
88
- ? `Finished tool work but the model did not produce a final response. Last tool result: ${toolSummaries.at(-1)}.`
89
- : 'Finished without a text response.';
90
- callbacks.addMessage({ id: assistantId, role: 'assistant', text: fallback, streaming: false });
466
+ callbacks.addMessage({ id: currentAssistantId, role: 'assistant', text: 'Finished without a text response.', streaming: false });
91
467
  }
92
468
  }
93
469
  catch (error) {
94
- const text = error instanceof Error ? error.message : String(error);
95
- callbacks.debugLog(`error: ${text}`);
96
- callbacks.addMessage({ role: 'assistant', text: `Model call failed: ${text}` });
470
+ if (abortController.signal.aborted) {
471
+ callbacks.debugLog('request aborted');
472
+ callbacks.addMessage({ role: 'system', text: 'Thinking aborted. You can type again.' });
473
+ }
474
+ else {
475
+ const text = error instanceof Error ? error.message : String(error);
476
+ callbacks.debugLog(`error: ${text}`);
477
+ callbacks.addMessage({ role: 'assistant', text: `Model call failed: ${text}` });
478
+ }
97
479
  }
98
480
  finally {
481
+ if (idleTimer)
482
+ clearTimeout(idleTimer);
483
+ callbacks.setAbortController?.(null);
99
484
  callbacks.setBusy(false);
100
485
  }
101
486
  }
package/dist/cli/index.js CHANGED
@@ -3,9 +3,6 @@ import { readFileSync } from 'node:fs';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { Command } from 'commander';
6
- import { listSkills, infoSkill, removeSkill, validateSkill } from './commands/skills.js';
7
- import { buildSkill } from '../skills/builder/SkillBuilder.js';
8
- import { installSkill } from '../skills/installer/SkillInstaller.js';
9
6
  import { chatCommand } from './commands/chat.js';
10
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
11
8
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8'));
@@ -16,15 +13,8 @@ program
16
13
  .version(pkg.version)
17
14
  .option('--debug', 'show simple model/tool debug logs in the chat UI');
18
15
  program.action(async () => {
19
- await chatCommand({ debug: Boolean(program.opts().debug) });
16
+ await chatCommand({ debug: Boolean(program.opts().debug), version: pkg.version });
20
17
  });
21
- program.command('build-skill <description...>').description('Deliberately design and create a new file-based skill').action(async (d) => buildSkill(d.join(' ')));
22
- program.command('install-skill <githubRepo>').description('Install a skill from GitHub with mandatory approval').action(installSkill);
23
- const skills = program.command('skills').description('Manage skills');
24
- skills.command('list').description('List installed skills').action(listSkills);
25
- skills.command('info <name>').description('Show skill details').action(infoSkill);
26
- skills.command('remove <name>').description('Remove an installed skill').action(removeSkill);
27
- skills.command('validate <dir>').description('Validate a skill directory').action(validateSkill);
28
18
  program.parseAsync().catch(error => {
29
19
  console.error(error instanceof Error ? error.message : error);
30
20
  process.exit(1);
@@ -1,3 +1,2 @@
1
1
  export declare const HAZE_DIR: string;
2
2
  export declare const GLOBAL_SKILLS_DIR: string;
3
- export declare const LOCAL_SKILLS_DIR: string;
@@ -2,4 +2,3 @@ import os from 'node:os';
2
2
  import path from 'node:path';
3
3
  export const HAZE_DIR = path.join(os.homedir(), '.haze');
4
4
  export const GLOBAL_SKILLS_DIR = path.join(HAZE_DIR, 'skills');
5
- export const LOCAL_SKILLS_DIR = path.join(process.cwd(), '.haze', 'skills');
@@ -4,7 +4,7 @@ export async function model() {
4
4
  const settings = await readSettings();
5
5
  const baseURL = process.env.OPENAI_BASE_URL ?? settings.baseURL;
6
6
  const apiKey = process.env.OPENAI_API_KEY ?? settings.apiKey;
7
- const name = process.env.HAZE_MODEL ?? settings.model ?? 'openai/gpt-4o-mini';
7
+ const name = process.env.HAZE_MODEL ?? settings.model ?? 'x-ai/grok-build-0.1';
8
8
  if (!apiKey)
9
9
  return null;
10
10
  return createOpenAI({ apiKey, baseURL })(name);
@@ -4,10 +4,18 @@ export declare const hazeTools: {
4
4
  recursive: boolean;
5
5
  maxEntries: number;
6
6
  includeIgnored: boolean;
7
+ cursor?: string | undefined;
7
8
  }, {
9
+ ok: true;
10
+ duplicateSkipped: true;
11
+ toolName: string;
12
+ reason: string;
13
+ } | {
8
14
  path: string;
9
15
  recursive: boolean;
10
16
  includeIgnored: boolean;
17
+ cursor: string | undefined;
18
+ nextCursor: string | undefined;
11
19
  ignoredSkipped: number;
12
20
  entries: {
13
21
  path: string;
@@ -22,6 +30,11 @@ export declare const hazeTools: {
22
30
  offset?: number | undefined;
23
31
  limit?: number | undefined;
24
32
  }, {
33
+ ok: true;
34
+ duplicateSkipped: true;
35
+ toolName: string;
36
+ reason: string;
37
+ } | {
25
38
  text: string;
26
39
  truncated: boolean;
27
40
  omittedChars?: undefined;
@@ -47,20 +60,33 @@ export declare const hazeTools: {
47
60
  content: string;
48
61
  allowIgnored: boolean;
49
62
  }, {
63
+ ok: true;
64
+ duplicateSkipped: true;
65
+ toolName: string;
66
+ reason: string;
67
+ } | {
50
68
  ok: boolean;
51
69
  path: string;
52
70
  startLine: number;
53
71
  endLine: number;
54
72
  replacementLines: number;
73
+ appended: boolean;
55
74
  }>;
56
75
  writeFile: import("ai").Tool<{
57
76
  path: string;
58
77
  content: string;
78
+ overwriteExisting: boolean;
59
79
  allowIgnored: boolean;
60
80
  }, {
81
+ ok: true;
82
+ duplicateSkipped: true;
83
+ toolName: string;
84
+ reason: string;
85
+ } | {
61
86
  ok: boolean;
62
87
  path: string;
63
88
  bytes: number;
89
+ overwritten: boolean;
64
90
  }>;
65
91
  editFile: import("ai").Tool<{
66
92
  path: string;
@@ -70,12 +96,18 @@ export declare const hazeTools: {
70
96
  }[];
71
97
  allowIgnored: boolean;
72
98
  }, {
99
+ ok: true;
100
+ duplicateSkipped: true;
101
+ toolName: string;
102
+ reason: string;
103
+ } | {
73
104
  ok: boolean;
74
105
  path: string;
75
106
  edits: number;
76
107
  }>;
77
108
  bash: import("ai").Tool<{
78
109
  command: string;
110
+ allowMutation: boolean;
79
111
  timeoutSeconds?: number | undefined;
80
112
  }, unknown>;
81
113
  };