cowork-cli 2.2.1 → 2.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-cli",
3
- "version": "2.2.1",
3
+ "version": "2.3.0",
4
4
  "description": "work with cowork",
5
5
  "bin": {
6
6
  "cwk": "bin/cli.js"
@@ -19,7 +19,8 @@ function clientLoader() {
19
19
  return new OpenAI({
20
20
  apiKey: config.model_api_key,
21
21
  baseURL: baseURL,
22
- timeout: 60000 // 60 seconds timeout
22
+ timeout: 60000, // 60 seconds timeout
23
+ maxRetries: 0, // Disable SDK's built-in retries to prevent overlapping attempts with BaseModel's retry loop.
23
24
  });
24
25
  }
25
26
 
@@ -1,8 +1,22 @@
1
1
  import { toolDefinitions, dispatchTool } from '../tools/index.js';
2
- import { logger } from '../../utils/logger.js';
2
+ import { logger, formatMain, formatDim } from '../../utils/logger.js';
3
3
  import { ui } from '../../utils/ui.js';
4
4
  import { outputFormatted } from '../../utils/outputFormatter.js';
5
5
 
6
+ // Defined at module scope: avoid re-allocating on every caught error.
7
+ // Transient Node.js-level network error codes that warrant an automatic retry.
8
+ const TRANSIENT_NET_CODES = new Set([
9
+ 'ECONNRESET', // Connection forcibly closed by the remote side
10
+ 'ETIMEDOUT', // Connection or operation timed out
11
+ 'ECONNREFUSED', // Remote host actively refused the connection
12
+ 'EAI_AGAIN', // Temporary DNS resolution failure
13
+ 'ENETUNREACH', // Network is unreachable
14
+ 'EHOSTUNREACH', // Host is unreachable
15
+ ]);
16
+
17
+ // Maximum delay (ms) the backoff is allowed to reach, regardless of retry count.
18
+ const MAX_BACKOFF_MS = 30000;
19
+
6
20
  /**
7
21
  * Base class for AI model interaction handlers.
8
22
  * Encapsulates message history, API calling with retries, and robust tool execution.
@@ -18,6 +32,8 @@ export default class BaseModel {
18
32
  this.messages = [];
19
33
  this.maxTurns = 15; // Safeguard against infinite tool-calling loops
20
34
  this.lastRequestTime = 0; // For proactive throttling
35
+ this._runStartTime = 0;
36
+ this._runUsage = { prompt: 0, completion: 0, total: 0 };
21
37
  }
22
38
 
23
39
  /**
@@ -36,6 +52,10 @@ export default class BaseModel {
36
52
  * @param {string|null} systemPrompt Optional system-level instructions.
37
53
  */
38
54
  async run(query, systemPrompt = null) {
55
+ // Reset per-run tracking state
56
+ this._runStartTime = performance.now();
57
+ this._runUsage = { prompt: 0, completion: 0, total: 0 };
58
+
39
59
  if (systemPrompt) {
40
60
  this.addMessage('system', systemPrompt);
41
61
  }
@@ -50,7 +70,23 @@ export default class BaseModel {
50
70
  const response = await this._getCompletion();
51
71
  ui.stop();
52
72
 
53
- const message = response.choices[0].message;
73
+ // Guard against empty/null choices (content filter, provider quirks).
74
+ const choice = response.choices?.[0];
75
+ if (!choice?.message) {
76
+ logger.error("[API Error] Received empty or malformed response (no choices).");
77
+ return;
78
+ }
79
+ const message = choice.message;
80
+ const finishReason = choice.finish_reason;
81
+
82
+ // Surface meaningful finish reasons to the user instead of silent behaviour.
83
+ if (finishReason === 'content_filter') {
84
+ logger.secondary("[System]: Response was blocked by the provider's content filter.");
85
+ return;
86
+ }
87
+ if (finishReason === 'length') {
88
+ logger.secondary("[System]: Response was truncated due to token limits.");
89
+ }
54
90
 
55
91
  // Let subclasses handle/format the response (e.g. Gemini thought signatures)
56
92
  await this.handleResponse(message);
@@ -64,6 +100,7 @@ export default class BaseModel {
64
100
  process.stdout.write("\n");
65
101
  }
66
102
  }
103
+ this._printStats();
67
104
  return;
68
105
  }
69
106
 
@@ -71,7 +108,9 @@ export default class BaseModel {
71
108
  await this._processToolCalls(message.tool_calls);
72
109
 
73
110
  } catch (err) {
74
- ui.stop();
111
+ // Use ui.fail() (red dot) instead of ui.stop() (green dot) so the
112
+ // terminal reflects that the turn ended in error. fail() is a no-op when IDLE.
113
+ ui.fail();
75
114
  // Deep error logging for API failures
76
115
  if (err.status) {
77
116
  logger.error(`[API Error] Status: ${err.status}`);
@@ -87,6 +126,7 @@ export default class BaseModel {
87
126
  }
88
127
  }
89
128
 
129
+ this._printStats();
90
130
  logger.secondary("[System]: Reached maximum conversation turns. Ending session.");
91
131
  }
92
132
 
@@ -118,39 +158,44 @@ export default class BaseModel {
118
158
 
119
159
  // Update last request time on successful response
120
160
  this.lastRequestTime = Date.now();
161
+
162
+ // Accumulate token usage across all turns (usage may be absent on some providers)
163
+ const u = response.usage;
164
+ if (u) {
165
+ this._runUsage.prompt += u.prompt_tokens ?? 0;
166
+ this._runUsage.completion += u.completion_tokens ?? 0;
167
+ this._runUsage.total += u.total_tokens ?? 0;
168
+ }
169
+
121
170
  return response;
122
171
 
123
172
  } catch (err) {
124
173
  // Transient HTTP status codes (rate-limit, server errors)
125
174
  const isHttpTransient = [429, 500, 502, 503, 504].includes(err.status);
126
- // Transient Node.js-level network errors (flaky connections, DNS hiccups)
127
- const TRANSIENT_NET_CODES = new Set([
128
- 'ECONNRESET', // Connection forcibly closed by the remote side
129
- 'ETIMEDOUT', // Connection or operation timed out
130
- 'ECONNREFUSED', // Remote host actively refused the connection
131
- 'EAI_AGAIN', // Temporary DNS resolution failure
132
- 'ENETUNREACH', // Network is unreachable
133
- 'EHOSTUNREACH', // Host is unreachable
134
- ]);
135
175
  const isNetTransient = TRANSIENT_NET_CODES.has(err.code);
136
176
  const isTransient = isHttpTransient || isNetTransient;
137
177
 
138
178
  if (isTransient && retries < maxRetries) {
139
179
  retries++;
140
180
 
141
- let delay = Math.pow(2, retries) * 1000;
181
+ // Cap exponential delay at MAX_BACKOFF_MS to prevent unbounded wait times.
182
+ let delay = Math.min(Math.pow(2, retries) * 1000, MAX_BACKOFF_MS);
142
183
 
143
184
  // 2. Adhere to Retry-After header if present (HTTP errors only)
144
185
  const retryAfter = err.headers?.['retry-after'];
145
186
  if (retryAfter) {
146
187
  const seconds = parseInt(retryAfter);
147
188
  if (!isNaN(seconds)) {
148
- delay = seconds * 1000;
189
+ // Clamp to [1s, MAX_BACKOFF_MS] so a zero/negative header cannot bypass throttling
190
+ // and an absurd value cannot hang the process.
191
+ delay = Math.min(Math.max(seconds * 1000, 1000), MAX_BACKOFF_MS);
149
192
  } else {
150
193
  // Handle Date string format
151
194
  const retryDate = new Date(retryAfter);
152
195
  if (!isNaN(retryDate.getTime())) {
153
- delay = Math.max(0, retryDate.getTime() - Date.now());
196
+ // Clamp — past dates become 1s, far-future dates are capped at MAX_BACKOFF_MS
197
+ // so the process can never hang indefinitely.
198
+ delay = Math.min(Math.max(retryDate.getTime() - Date.now(), 1000), MAX_BACKOFF_MS);
154
199
  }
155
200
  }
156
201
  }
@@ -169,6 +214,11 @@ export default class BaseModel {
169
214
  throw err;
170
215
  }
171
216
  }
217
+
218
+ // Exhaustion guard — the while loop should always return or throw before
219
+ // reaching here. If it doesn't (e.g. a future refactor breaks the invariant),
220
+ // surface an explicit error rather than returning undefined to the caller.
221
+ throw new Error('_getCompletion: retry loop exhausted without returning a response.');
172
222
  }
173
223
 
174
224
  /**
@@ -253,4 +303,24 @@ export default class BaseModel {
253
303
  async handleResponse(message) {
254
304
  this.messages.push(message);
255
305
  }
306
+
307
+ /**
308
+ * Prints a compact stats line: elapsed time and cumulative token usage.
309
+ * Only printed on clean exits (final answer or max-turns). Skipped on errors.
310
+ * Token counts are omitted silently if the provider did not return usage data.
311
+ * @private
312
+ */
313
+ _printStats() {
314
+ const elapsed = ((performance.now() - this._runStartTime) / 1000).toFixed(2);
315
+ const { prompt, completion, total } = this._runUsage;
316
+
317
+ const timeStr = `${formatDim('time')} ${formatMain(elapsed + 's')}`;
318
+
319
+ // Only append token info when the provider actually returned usage data
320
+ const tokenStr = total > 0
321
+ ? ` ${formatDim('·')} ${formatDim('tokens')} ${formatMain(String(total))} ${formatDim(`(${prompt}p/${completion}c)`)}`
322
+ : '';
323
+
324
+ ui.log(timeStr + tokenStr);
325
+ }
256
326
  }
@@ -1,3 +1,5 @@
1
+ import { parse } from 'node-html-parser';
2
+
1
3
  const TIMEOUT_MS = 10000;
2
4
  const MAX_RESULTS_HARD_LIMIT = 20;
3
5
 
@@ -38,23 +40,20 @@ export default async function webSearch({ query, limit = 5 }) {
38
40
  const html = await response.text();
39
41
  const results = [];
40
42
 
41
- // Split HTML into result blocks.
42
- // The slice(1) skips the header block before the first result.
43
- const blocks = html.split('class="links_main links_deep result__body"').slice(1);
43
+ const root = parse(html);
44
+ const resultNodes = root.querySelectorAll('.result__body');
44
45
 
45
- for (const block of blocks) {
46
+ for (const node of resultNodes) {
46
47
  if (results.length >= maxLimit) break;
47
48
 
48
- const titleMatch = block.match(/<h2 class="result__title">[\s\S]*?<a[^>]*>([\s\S]*?)<\/a>/i);
49
- const snippetMatch = block.match(/<a class="result__snippet[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/i);
49
+ const titleEl = node.querySelector('.result__title a');
50
+ const snippetEl = node.querySelector('.result__snippet');
50
51
 
51
- if (titleMatch && snippetMatch) {
52
- // Strip nested HTML tags (like <b> tags for highlighted keywords)
53
- const title = titleMatch[1].replace(/<[^>]+>/g, '').trim();
54
- const snippet = snippetMatch[2].replace(/<[^>]+>/g, '').trim();
52
+ if (titleEl && snippetEl) {
53
+ const title = titleEl.textContent.replace(/\s+/g, ' ').trim();
54
+ const snippet = snippetEl.textContent.replace(/\s+/g, ' ').trim();
55
55
 
56
- // Clean DuckDuckGo's tracking wrapper from the URL
57
- let url = snippetMatch[1];
56
+ let url = titleEl.getAttribute('href') || '';
58
57
  if (url.startsWith('//duckduckgo.com/l/?uddg=')) {
59
58
  url = decodeURIComponent(url.split('uddg=')[1].split('&')[0]);
60
59
  }