@visorcraft/idlehands 0.9.1

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 (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +30 -0
  3. package/dist/agent.js +2604 -0
  4. package/dist/agent.js.map +1 -0
  5. package/dist/anton/controller.js +341 -0
  6. package/dist/anton/controller.js.map +1 -0
  7. package/dist/anton/lock.js +110 -0
  8. package/dist/anton/lock.js.map +1 -0
  9. package/dist/anton/parser.js +303 -0
  10. package/dist/anton/parser.js.map +1 -0
  11. package/dist/anton/prompt.js +203 -0
  12. package/dist/anton/prompt.js.map +1 -0
  13. package/dist/anton/reporter.js +119 -0
  14. package/dist/anton/reporter.js.map +1 -0
  15. package/dist/anton/session.js +51 -0
  16. package/dist/anton/session.js.map +1 -0
  17. package/dist/anton/types.js +7 -0
  18. package/dist/anton/types.js.map +1 -0
  19. package/dist/anton/verifier.js +263 -0
  20. package/dist/anton/verifier.js.map +1 -0
  21. package/dist/bench/compare.js +239 -0
  22. package/dist/bench/compare.js.map +1 -0
  23. package/dist/bench/debug_hooks.js +17 -0
  24. package/dist/bench/debug_hooks.js.map +1 -0
  25. package/dist/bench/json_extract.js +22 -0
  26. package/dist/bench/json_extract.js.map +1 -0
  27. package/dist/bench/openclaw.js +86 -0
  28. package/dist/bench/openclaw.js.map +1 -0
  29. package/dist/bench/report.js +116 -0
  30. package/dist/bench/report.js.map +1 -0
  31. package/dist/bench/runner.js +312 -0
  32. package/dist/bench/runner.js.map +1 -0
  33. package/dist/bench/types.js +2 -0
  34. package/dist/bench/types.js.map +1 -0
  35. package/dist/bot/commands.js +444 -0
  36. package/dist/bot/commands.js.map +1 -0
  37. package/dist/bot/confirm-discord.js +133 -0
  38. package/dist/bot/confirm-discord.js.map +1 -0
  39. package/dist/bot/confirm-telegram.js +290 -0
  40. package/dist/bot/confirm-telegram.js.map +1 -0
  41. package/dist/bot/discord.js +826 -0
  42. package/dist/bot/discord.js.map +1 -0
  43. package/dist/bot/format.js +210 -0
  44. package/dist/bot/format.js.map +1 -0
  45. package/dist/bot/session-manager.js +270 -0
  46. package/dist/bot/session-manager.js.map +1 -0
  47. package/dist/bot/telegram.js +678 -0
  48. package/dist/bot/telegram.js.map +1 -0
  49. package/dist/cli/agent-turn.js +45 -0
  50. package/dist/cli/agent-turn.js.map +1 -0
  51. package/dist/cli/args.js +236 -0
  52. package/dist/cli/args.js.map +1 -0
  53. package/dist/cli/bot.js +252 -0
  54. package/dist/cli/bot.js.map +1 -0
  55. package/dist/cli/build-repl-context.js +365 -0
  56. package/dist/cli/build-repl-context.js.map +1 -0
  57. package/dist/cli/command-registry.js +20 -0
  58. package/dist/cli/command-registry.js.map +1 -0
  59. package/dist/cli/commands/anton.js +271 -0
  60. package/dist/cli/commands/anton.js.map +1 -0
  61. package/dist/cli/commands/editing.js +328 -0
  62. package/dist/cli/commands/editing.js.map +1 -0
  63. package/dist/cli/commands/model.js +274 -0
  64. package/dist/cli/commands/model.js.map +1 -0
  65. package/dist/cli/commands/project.js +255 -0
  66. package/dist/cli/commands/project.js.map +1 -0
  67. package/dist/cli/commands/runtime.js +63 -0
  68. package/dist/cli/commands/runtime.js.map +1 -0
  69. package/dist/cli/commands/session.js +281 -0
  70. package/dist/cli/commands/session.js.map +1 -0
  71. package/dist/cli/commands/tools.js +126 -0
  72. package/dist/cli/commands/tools.js.map +1 -0
  73. package/dist/cli/commands/trifecta.js +221 -0
  74. package/dist/cli/commands/trifecta.js.map +1 -0
  75. package/dist/cli/commands/tui.js +17 -0
  76. package/dist/cli/commands/tui.js.map +1 -0
  77. package/dist/cli/init.js +222 -0
  78. package/dist/cli/init.js.map +1 -0
  79. package/dist/cli/input.js +360 -0
  80. package/dist/cli/input.js.map +1 -0
  81. package/dist/cli/oneshot.js +254 -0
  82. package/dist/cli/oneshot.js.map +1 -0
  83. package/dist/cli/repl-context.js +2 -0
  84. package/dist/cli/repl-context.js.map +1 -0
  85. package/dist/cli/runtime-cmds.js +811 -0
  86. package/dist/cli/runtime-cmds.js.map +1 -0
  87. package/dist/cli/service.js +145 -0
  88. package/dist/cli/service.js.map +1 -0
  89. package/dist/cli/session-state.js +130 -0
  90. package/dist/cli/session-state.js.map +1 -0
  91. package/dist/cli/setup.js +815 -0
  92. package/dist/cli/setup.js.map +1 -0
  93. package/dist/cli/shell.js +79 -0
  94. package/dist/cli/shell.js.map +1 -0
  95. package/dist/cli/status.js +392 -0
  96. package/dist/cli/status.js.map +1 -0
  97. package/dist/cli/watch.js +33 -0
  98. package/dist/cli/watch.js.map +1 -0
  99. package/dist/client.js +676 -0
  100. package/dist/client.js.map +1 -0
  101. package/dist/commands.js +194 -0
  102. package/dist/commands.js.map +1 -0
  103. package/dist/config.js +507 -0
  104. package/dist/config.js.map +1 -0
  105. package/dist/confirm/auto.js +13 -0
  106. package/dist/confirm/auto.js.map +1 -0
  107. package/dist/confirm/headless.js +41 -0
  108. package/dist/confirm/headless.js.map +1 -0
  109. package/dist/confirm/terminal.js +90 -0
  110. package/dist/confirm/terminal.js.map +1 -0
  111. package/dist/context.js +49 -0
  112. package/dist/context.js.map +1 -0
  113. package/dist/git.js +136 -0
  114. package/dist/git.js.map +1 -0
  115. package/dist/harnesses.js +171 -0
  116. package/dist/harnesses.js.map +1 -0
  117. package/dist/history.js +139 -0
  118. package/dist/history.js.map +1 -0
  119. package/dist/index.js +700 -0
  120. package/dist/index.js.map +1 -0
  121. package/dist/indexer.js +374 -0
  122. package/dist/indexer.js.map +1 -0
  123. package/dist/jsonrpc.js +76 -0
  124. package/dist/jsonrpc.js.map +1 -0
  125. package/dist/lens.js +525 -0
  126. package/dist/lens.js.map +1 -0
  127. package/dist/lsp.js +605 -0
  128. package/dist/lsp.js.map +1 -0
  129. package/dist/markdown.js +275 -0
  130. package/dist/markdown.js.map +1 -0
  131. package/dist/mcp.js +554 -0
  132. package/dist/mcp.js.map +1 -0
  133. package/dist/recovery.js +178 -0
  134. package/dist/recovery.js.map +1 -0
  135. package/dist/replay.js +132 -0
  136. package/dist/replay.js.map +1 -0
  137. package/dist/replay_cli.js +24 -0
  138. package/dist/replay_cli.js.map +1 -0
  139. package/dist/runtime/executor.js +418 -0
  140. package/dist/runtime/executor.js.map +1 -0
  141. package/dist/runtime/planner.js +197 -0
  142. package/dist/runtime/planner.js.map +1 -0
  143. package/dist/runtime/store.js +289 -0
  144. package/dist/runtime/store.js.map +1 -0
  145. package/dist/runtime/types.js +2 -0
  146. package/dist/runtime/types.js.map +1 -0
  147. package/dist/safety.js +446 -0
  148. package/dist/safety.js.map +1 -0
  149. package/dist/spinner.js +224 -0
  150. package/dist/spinner.js.map +1 -0
  151. package/dist/sys/context.js +124 -0
  152. package/dist/sys/context.js.map +1 -0
  153. package/dist/sys/snapshot.sh +97 -0
  154. package/dist/term.js +61 -0
  155. package/dist/term.js.map +1 -0
  156. package/dist/themes.js +135 -0
  157. package/dist/themes.js.map +1 -0
  158. package/dist/tools.js +1114 -0
  159. package/dist/tools.js.map +1 -0
  160. package/dist/tui/branch-picker.js +65 -0
  161. package/dist/tui/branch-picker.js.map +1 -0
  162. package/dist/tui/command-handler.js +108 -0
  163. package/dist/tui/command-handler.js.map +1 -0
  164. package/dist/tui/confirm.js +90 -0
  165. package/dist/tui/confirm.js.map +1 -0
  166. package/dist/tui/controller.js +463 -0
  167. package/dist/tui/controller.js.map +1 -0
  168. package/dist/tui/event-bridge.js +44 -0
  169. package/dist/tui/event-bridge.js.map +1 -0
  170. package/dist/tui/events.js +2 -0
  171. package/dist/tui/events.js.map +1 -0
  172. package/dist/tui/keymap.js +144 -0
  173. package/dist/tui/keymap.js.map +1 -0
  174. package/dist/tui/layout.js +11 -0
  175. package/dist/tui/layout.js.map +1 -0
  176. package/dist/tui/render.js +186 -0
  177. package/dist/tui/render.js.map +1 -0
  178. package/dist/tui/screen.js +48 -0
  179. package/dist/tui/screen.js.map +1 -0
  180. package/dist/tui/state.js +167 -0
  181. package/dist/tui/state.js.map +1 -0
  182. package/dist/tui/theme.js +70 -0
  183. package/dist/tui/theme.js.map +1 -0
  184. package/dist/tui/types.js +2 -0
  185. package/dist/tui/types.js.map +1 -0
  186. package/dist/types.js +2 -0
  187. package/dist/types.js.map +1 -0
  188. package/dist/upgrade.js +412 -0
  189. package/dist/upgrade.js.map +1 -0
  190. package/dist/utils.js +87 -0
  191. package/dist/utils.js.map +1 -0
  192. package/dist/vault.js +520 -0
  193. package/dist/vault.js.map +1 -0
  194. package/dist/vim.js +160 -0
  195. package/dist/vim.js.map +1 -0
  196. package/package.json +67 -0
  197. package/src/sys/snapshot.sh +97 -0
package/dist/client.js ADDED
@@ -0,0 +1,676 @@
1
+ import { setTimeout as delay } from 'node:timers/promises';
2
+ function makeClientError(msg, status, retryable) {
3
+ const e = new Error(msg);
4
+ e.status = status;
5
+ e.retryable = retryable;
6
+ return e;
7
+ }
8
+ function isConnRefused(e) {
9
+ const msg = String(e?.message ?? '');
10
+ // Node fetch sometimes throws `TypeError: fetch failed` for transient network errors.
11
+ // Treat it as retryable in the same bucket as ECONNREFUSED.
12
+ return e?.cause?.code === 'ECONNREFUSED' || /ECONNREFUSED|fetch failed/i.test(msg);
13
+ }
14
+ function isFetchFailed(e) {
15
+ return /fetch failed/i.test(String(e?.message ?? ''));
16
+ }
17
+ function isConnTimeout(e) {
18
+ const msg = String(e?.message ?? '');
19
+ return Boolean(e?.retryable) && /Connection timeout \(\d+ms\)/i.test(msg);
20
+ }
21
+ function asError(e, fallback = 'unknown error') {
22
+ if (e instanceof Error)
23
+ return e;
24
+ if (e === undefined)
25
+ return new Error(fallback);
26
+ return new Error(String(e));
27
+ }
28
+ // ─── Rate Limiter ────────────────────────────────────────
29
+ /**
30
+ * Track 503 errors over a rolling 60s window.
31
+ * When count exceeds threshold, imposes escalating back-off.
32
+ */
33
+ export class RateLimiter {
34
+ windowMs;
35
+ threshold;
36
+ maxBackoffMs;
37
+ timestamps = [];
38
+ backoffLevel = 0;
39
+ constructor(windowMs = 60_000, threshold = 5, maxBackoffMs = 60_000) {
40
+ this.windowMs = windowMs;
41
+ this.threshold = threshold;
42
+ this.maxBackoffMs = maxBackoffMs;
43
+ }
44
+ record503() {
45
+ this.timestamps.push(Date.now());
46
+ this.prune();
47
+ if (this.timestamps.length >= this.threshold) {
48
+ this.backoffLevel = Math.min(this.backoffLevel + 1, 6);
49
+ }
50
+ }
51
+ prune() {
52
+ const cutoff = Date.now() - this.windowMs;
53
+ this.timestamps = this.timestamps.filter((t) => t > cutoff);
54
+ }
55
+ /** Returns delay in ms to wait before next request (0 = no delay). */
56
+ getDelay() {
57
+ this.prune();
58
+ if (this.timestamps.length < this.threshold) {
59
+ // Decay backoff level when window clears
60
+ if (this.timestamps.length === 0)
61
+ this.backoffLevel = 0;
62
+ return 0;
63
+ }
64
+ // Exponential: 2^level * 1000, capped at maxBackoffMs
65
+ return Math.min(Math.pow(2, this.backoffLevel) * 1000, this.maxBackoffMs);
66
+ }
67
+ get recentCount() {
68
+ this.prune();
69
+ return this.timestamps.length;
70
+ }
71
+ reset() {
72
+ this.timestamps = [];
73
+ this.backoffLevel = 0;
74
+ }
75
+ }
76
+ // ─── Backpressure Monitor ─────────────────────────────────
77
+ /**
78
+ * Track response times and detect backpressure (server overload).
79
+ * Warns when response time exceeds 3× rolling average.
80
+ */
81
+ export class BackpressureMonitor {
82
+ times = [];
83
+ maxSamples;
84
+ multiplier;
85
+ constructor(opts) {
86
+ this.maxSamples = opts?.maxSamples ?? 20;
87
+ this.multiplier = opts?.multiplier ?? 3;
88
+ }
89
+ record(responseMs) {
90
+ const avg = this.average;
91
+ this.times.push(responseMs);
92
+ if (this.times.length > this.maxSamples) {
93
+ this.times.shift();
94
+ }
95
+ // Need at least 3 samples before we judge
96
+ if (this.times.length < 3)
97
+ return { warn: false, avg, current: responseMs };
98
+ const warn = avg > 0 && responseMs > avg * this.multiplier;
99
+ return { warn, avg, current: responseMs };
100
+ }
101
+ get average() {
102
+ if (this.times.length === 0)
103
+ return 0;
104
+ return this.times.reduce((a, b) => a + b, 0) / this.times.length;
105
+ }
106
+ get samples() {
107
+ return this.times.length;
108
+ }
109
+ reset() {
110
+ this.times = [];
111
+ }
112
+ }
113
+ export class OpenAIClient {
114
+ endpoint;
115
+ apiKey;
116
+ verbose;
117
+ rateLimiter = new RateLimiter();
118
+ backpressure = new BackpressureMonitor();
119
+ exchangeHook;
120
+ /** Default response timeout in ms (overridable per-call). */
121
+ defaultResponseTimeoutMs = 600_000;
122
+ constructor(endpoint, apiKey, verbose = false) {
123
+ this.endpoint = endpoint;
124
+ this.apiKey = apiKey;
125
+ this.verbose = verbose;
126
+ }
127
+ /** Set the default response timeout (in seconds) for all requests. */
128
+ setResponseTimeout(seconds) {
129
+ if (Number.isFinite(seconds) && seconds > 0) {
130
+ this.defaultResponseTimeoutMs = seconds * 1000;
131
+ }
132
+ }
133
+ setVerbose(v) {
134
+ this.verbose = v;
135
+ }
136
+ setEndpoint(endpoint) {
137
+ this.endpoint = endpoint.replace(/\/+$/, '');
138
+ }
139
+ getEndpoint() {
140
+ return this.endpoint;
141
+ }
142
+ setExchangeHook(fn) {
143
+ this.exchangeHook = fn;
144
+ }
145
+ emitExchange(record) {
146
+ if (!this.exchangeHook)
147
+ return;
148
+ void Promise.resolve(this.exchangeHook(record)).catch(() => { });
149
+ }
150
+ rootEndpoint() {
151
+ return this.endpoint.replace(/\/v1\/?$/, '');
152
+ }
153
+ headers() {
154
+ const h = {
155
+ 'Content-Type': 'application/json'
156
+ };
157
+ if (this.apiKey)
158
+ h.Authorization = `Bearer ${this.apiKey}`;
159
+ return h;
160
+ }
161
+ log(msg) {
162
+ if (this.verbose)
163
+ console.error(`[idlehands] ${msg}`);
164
+ }
165
+ async models(signal) {
166
+ const url = `${this.endpoint}/models`;
167
+ const res = await this.fetchWithConnTimeout(url, { method: 'GET', headers: this.headers(), signal }, 10_000);
168
+ if (!res.ok) {
169
+ throw new Error(`GET /models failed: ${res.status} ${res.statusText}`);
170
+ }
171
+ return (await res.json());
172
+ }
173
+ async health(signal) {
174
+ const url = `${this.rootEndpoint()}/health`;
175
+ const res = await this.fetchWithConnTimeout(url, { method: 'GET', headers: this.headers(), signal }, 5_000);
176
+ if (!res.ok) {
177
+ throw makeClientError(`GET /health failed: ${res.status} ${res.statusText}`, res.status, true);
178
+ }
179
+ const ct = String(res.headers.get('content-type') ?? '');
180
+ if (ct.includes('application/json')) {
181
+ return await res.json();
182
+ }
183
+ const text = await res.text();
184
+ return { status: 'ok', raw: text };
185
+ }
186
+ async waitForReady(opts) {
187
+ const timeoutMs = opts?.timeoutMs ?? 120_000;
188
+ const pollMs = opts?.pollMs ?? 2000;
189
+ const start = Date.now();
190
+ while (Date.now() - start < timeoutMs) {
191
+ try {
192
+ await this.models();
193
+ return;
194
+ }
195
+ catch {
196
+ // keep waiting
197
+ }
198
+ await delay(pollMs);
199
+ }
200
+ throw makeClientError(`Server not ready after ${timeoutMs}ms (${this.endpoint})`, undefined, true);
201
+ }
202
+ sanitizeRequest(body) {
203
+ delete body.store;
204
+ delete body.reasoning_effort;
205
+ delete body.stream_options;
206
+ if (Array.isArray(body.messages)) {
207
+ for (const m of body.messages) {
208
+ if (m?.role === 'developer')
209
+ m.role = 'system';
210
+ }
211
+ }
212
+ if (body.max_completion_tokens != null && body.max_tokens == null) {
213
+ body.max_tokens = body.max_completion_tokens;
214
+ }
215
+ delete body.max_completion_tokens;
216
+ if (Array.isArray(body.tools)) {
217
+ for (const t of body.tools) {
218
+ if (t?.function) {
219
+ if ('strict' in t.function)
220
+ delete t.function.strict;
221
+ if (t.function.parameters && 'strict' in t.function.parameters) {
222
+ delete t.function.parameters.strict;
223
+ }
224
+ }
225
+ }
226
+ }
227
+ return body;
228
+ }
229
+ buildBody(opts) {
230
+ const body = {
231
+ model: opts.model,
232
+ messages: opts.messages.map((m) => ({ ...m })), // shallow copy to avoid mutating session state
233
+ temperature: opts.temperature,
234
+ top_p: opts.top_p,
235
+ max_tokens: opts.max_tokens,
236
+ tools: opts.tools,
237
+ tool_choice: opts.tool_choice,
238
+ stream: opts.stream ?? false,
239
+ ...opts.extra
240
+ };
241
+ for (const k of Object.keys(body)) {
242
+ if (body[k] === undefined)
243
+ delete body[k];
244
+ }
245
+ return this.sanitizeRequest(body);
246
+ }
247
+ /** Wrap fetch with a connection timeout derived from response_timeout (min 30s). */
248
+ async fetchWithConnTimeout(url, init, connTimeoutMs) {
249
+ // Connection timeout = min(response_timeout, 60s), floor 30s — enough to establish TCP but not wait forever.
250
+ if (!connTimeoutMs)
251
+ connTimeoutMs = Math.max(30_000, Math.min(this.defaultResponseTimeoutMs, 60_000));
252
+ const ac = new AbortController();
253
+ const chainedAbort = init.signal;
254
+ // If the caller's signal fires, propagate to our controller.
255
+ const onCallerAbort = () => ac.abort();
256
+ chainedAbort?.addEventListener('abort', onCallerAbort, { once: true });
257
+ const timer = setTimeout(() => ac.abort(), connTimeoutMs);
258
+ try {
259
+ const res = await fetch(url, { ...init, signal: ac.signal });
260
+ clearTimeout(timer);
261
+ return res;
262
+ }
263
+ catch (e) {
264
+ clearTimeout(timer);
265
+ // Distinguish connection timeout from caller abort.
266
+ if (ac.signal.aborted && !chainedAbort?.aborted) {
267
+ throw makeClientError(`Connection timeout (${connTimeoutMs}ms) to ${url}`, undefined, true);
268
+ }
269
+ throw asError(e, `connection failure to ${url}`);
270
+ }
271
+ finally {
272
+ chainedAbort?.removeEventListener('abort', onCallerAbort);
273
+ }
274
+ }
275
+ /** Non-streaming chat with retry (ECONNREFUSED 3x/2s, 503 exponential backoff, response timeout) */
276
+ async chat(opts) {
277
+ const url = `${this.endpoint}/chat/completions`;
278
+ const clean = this.buildBody({ ...opts, stream: false });
279
+ const responseTimeout = opts.responseTimeoutMs ?? this.defaultResponseTimeoutMs;
280
+ this.log(`→ POST ${url} ${opts.requestId ? `(rid=${opts.requestId})` : ''}`);
281
+ this.log(`request keys: ${Object.keys(clean).join(', ')}`);
282
+ // Rate-limit delay (§6e: back off if too many 503s in rolling window)
283
+ const rlDelay = this.rateLimiter.getDelay();
284
+ if (rlDelay > 0) {
285
+ this.log(`rate-limit backoff: waiting ${rlDelay}ms (${this.rateLimiter.recentCount} 503s in window)`);
286
+ console.warn(`[warn] server returned 503 ${this.rateLimiter.recentCount} times in 60s, backing off ${(rlDelay / 1000).toFixed(1)}s`);
287
+ await delay(rlDelay);
288
+ }
289
+ let lastErr = makeClientError(`POST /chat/completions failed without response`, 503, true);
290
+ const reqStart = Date.now();
291
+ for (let attempt = 0; attempt < 3; attempt++) {
292
+ // Build a combined signal that fires on caller abort OR response timeout.
293
+ const timeoutAc = new AbortController();
294
+ const callerSignal = opts.signal;
295
+ const onCallerAbort = () => timeoutAc.abort();
296
+ callerSignal?.addEventListener('abort', onCallerAbort, { once: true });
297
+ const timer = setTimeout(() => timeoutAc.abort(), responseTimeout);
298
+ try {
299
+ const res = await this.fetchWithConnTimeout(url, {
300
+ method: 'POST',
301
+ headers: this.headers(),
302
+ body: JSON.stringify(clean),
303
+ signal: timeoutAc.signal
304
+ });
305
+ if (res.status === 503) {
306
+ this.rateLimiter.record503();
307
+ const backoff = Math.pow(2, attempt + 1) * 1000; // 2s, 4s, 8s
308
+ this.log(`503 model loading, retrying in ${backoff}ms...`);
309
+ lastErr = makeClientError(`POST /chat/completions returned 503 (model loading), attempt ${attempt + 1}/3`, 503, true);
310
+ if (attempt < 2) {
311
+ await delay(backoff);
312
+ continue;
313
+ }
314
+ throw lastErr;
315
+ }
316
+ if (!res.ok) {
317
+ const text = await res.text().catch(() => '');
318
+ throw makeClientError(`POST /chat/completions failed: ${res.status} ${res.statusText}${text ? `\n${text.slice(0, 2000)}` : ''}`, res.status, false);
319
+ }
320
+ const result = (await res.json());
321
+ const totalMs = Date.now() - reqStart;
322
+ // Backpressure: track response time and warn on anomalies
323
+ const bp = this.backpressure.record(totalMs);
324
+ if (bp.warn) {
325
+ const avgS = (bp.avg / 1000).toFixed(1);
326
+ const curS = (bp.current / 1000).toFixed(1);
327
+ this.log(`backpressure warning: response ${curS}s > ${this.backpressure.multiplier}× avg ${avgS}s — consider reducing context size`);
328
+ console.warn(`[warn] server response time (${curS}s) exceeds ${this.backpressure.multiplier}× session average (${avgS}s) — consider reducing context size`);
329
+ }
330
+ result.meta = { total_ms: totalMs, streamed: false };
331
+ this.emitExchange({
332
+ timestamp: new Date().toISOString(),
333
+ request: clean,
334
+ response: result,
335
+ metrics: {
336
+ total_ms: totalMs,
337
+ prompt_tokens: result.usage?.prompt_tokens,
338
+ completion_tokens: result.usage?.completion_tokens,
339
+ },
340
+ });
341
+ return result;
342
+ }
343
+ catch (e) {
344
+ lastErr = asError(e, `POST /chat/completions attempt ${attempt + 1} failed`);
345
+ if (callerSignal?.aborted)
346
+ throw lastErr;
347
+ // Distinguish response timeout from other AbortErrors
348
+ if (timeoutAc.signal.aborted && !callerSignal?.aborted) {
349
+ throw makeClientError(`Response timeout (${responseTimeout}ms) waiting for ${url}`, undefined, true);
350
+ }
351
+ if (isConnTimeout(e)) {
352
+ // Spec: retry once on connection timeout (§11)
353
+ if (attempt < 1) {
354
+ this.log(`Connection timeout, retrying in 2s (attempt ${attempt + 1}/2)...`);
355
+ await delay(2000);
356
+ continue;
357
+ }
358
+ throw lastErr;
359
+ }
360
+ if (isConnRefused(e) || isFetchFailed(e)) {
361
+ if (attempt < 2) {
362
+ this.log(`Connection error (${e?.message ?? 'unknown'}), retrying in 2s (attempt ${attempt + 1}/3)...`);
363
+ await delay(2000);
364
+ continue;
365
+ }
366
+ throw makeClientError(`Cannot reach ${this.endpoint}. Is llama-server running? (${e?.message ?? ''})`, undefined, true);
367
+ }
368
+ // Non-retryable errors (4xx, etc.)
369
+ throw lastErr;
370
+ }
371
+ finally {
372
+ clearTimeout(timer);
373
+ callerSignal?.removeEventListener('abort', onCallerAbort);
374
+ }
375
+ }
376
+ throw lastErr;
377
+ }
378
+ /** Streaming chat with retry, read timeout, and 400→non-stream fallback */
379
+ async chatStream(opts) {
380
+ const url = `${this.endpoint}/chat/completions`;
381
+ const clean = this.buildBody({ ...opts, stream: true });
382
+ const readTimeout = (Number.isFinite(Number(process.env.IDLEHANDS_READ_TIMEOUT_MS))
383
+ ? Number(process.env.IDLEHANDS_READ_TIMEOUT_MS)
384
+ : undefined) ??
385
+ opts.readTimeoutMs ??
386
+ 30_000;
387
+ this.log(`→ POST ${url} (stream) ${opts.requestId ? `(rid=${opts.requestId})` : ''}`);
388
+ // Rate-limit delay (§6e: back off if too many 503s in rolling window)
389
+ const rlDelay = this.rateLimiter.getDelay();
390
+ if (rlDelay > 0) {
391
+ this.log(`rate-limit backoff: waiting ${rlDelay}ms (${this.rateLimiter.recentCount} 503s in window)`);
392
+ console.warn(`[warn] server returned 503 ${this.rateLimiter.recentCount} times in 60s, backing off ${(rlDelay / 1000).toFixed(1)}s`);
393
+ await delay(rlDelay);
394
+ }
395
+ let lastErr = makeClientError('POST /chat/completions (stream) failed without response', 503, true);
396
+ const reqStart = Date.now();
397
+ for (let attempt = 0; attempt < 3; attempt++) {
398
+ let res;
399
+ try {
400
+ res = await this.fetchWithConnTimeout(url, {
401
+ method: 'POST',
402
+ headers: this.headers(),
403
+ body: JSON.stringify(clean),
404
+ signal: opts.signal
405
+ });
406
+ }
407
+ catch (e) {
408
+ lastErr = asError(e, `POST /chat/completions (stream) attempt ${attempt + 1} failed`);
409
+ if (opts.signal?.aborted)
410
+ throw lastErr;
411
+ if (isConnTimeout(e)) {
412
+ if (attempt < 1) {
413
+ this.log(`Connection timeout, retrying in 2s (attempt ${attempt + 1}/2)...`);
414
+ await delay(2000);
415
+ continue;
416
+ }
417
+ throw lastErr;
418
+ }
419
+ if (isConnRefused(e) || isFetchFailed(e)) {
420
+ if (attempt < 2) {
421
+ this.log(`Connection error (${e?.message ?? 'unknown'}), retrying in 2s (attempt ${attempt + 1}/3)...`);
422
+ await delay(2000);
423
+ continue;
424
+ }
425
+ throw makeClientError(`Cannot reach ${this.endpoint}. Is llama-server running? (${e?.message ?? ''})`, undefined, true);
426
+ }
427
+ throw lastErr;
428
+ }
429
+ // HTTP 400 on stream → fall back to non-streaming (server doesn't support it)
430
+ if (res.status === 400) {
431
+ this.log('HTTP 400 on stream request, falling back to non-streaming');
432
+ return this.chat({ ...opts, stream: false });
433
+ }
434
+ // HTTP 503 → retry with exponential backoff
435
+ if (res.status === 503) {
436
+ this.rateLimiter.record503();
437
+ const backoff = Math.pow(2, attempt + 1) * 1000;
438
+ lastErr = makeClientError(`POST /chat/completions (stream) returned 503 (model loading), attempt ${attempt + 1}/3`, 503, true);
439
+ this.log(`503 model loading, retrying in ${backoff}ms...`);
440
+ if (attempt < 2) {
441
+ await delay(backoff);
442
+ continue;
443
+ }
444
+ throw lastErr;
445
+ }
446
+ // HTTP 5xx on stream → retry (and optionally fall back to non-stream after repeated failures)
447
+ if (res.status >= 500 && res.status <= 599) {
448
+ const text = await res.text().catch(() => '');
449
+ lastErr = makeClientError(`POST /chat/completions (stream) failed: ${res.status} ${res.statusText}${text ? `\n${text.slice(0, 2000)}` : ''}`, res.status, true);
450
+ // If we keep getting server errors, try a non-streaming request as a last resort.
451
+ const allowFallback = (process.env.IDLEHANDS_STREAM_FALLBACK ?? '1') !== '0';
452
+ if (allowFallback && attempt >= 1) {
453
+ this.log(`HTTP ${res.status} on stream request, falling back to non-streaming (attempt ${attempt + 1}/3)`);
454
+ return this.chat({ ...opts, stream: false });
455
+ }
456
+ const backoff = Math.pow(2, attempt + 1) * 1000;
457
+ this.log(`HTTP ${res.status} on stream request, retrying in ${backoff}ms...`);
458
+ await delay(backoff);
459
+ continue;
460
+ }
461
+ if (!res.ok) {
462
+ const text = await res.text().catch(() => '');
463
+ throw makeClientError(`POST /chat/completions (stream) failed: ${res.status} ${res.statusText}${text ? `\n${text.slice(0, 2000)}` : ''}`, res.status, false);
464
+ }
465
+ // --- Parse SSE stream with read timeout ---
466
+ const reader = res.body?.getReader();
467
+ if (!reader)
468
+ throw new Error('No response body to read (stream)');
469
+ const decoder = new TextDecoder();
470
+ let buf = '';
471
+ const agg = { id: 'stream', choices: [{ index: 0, delta: {}, finish_reason: null }] };
472
+ const toolArgsByIndex = {};
473
+ const toolNameByIndex = {};
474
+ const toolIdByIndex = {};
475
+ let sawDelta = false;
476
+ let firstDeltaMs;
477
+ let tokensReceived = 0;
478
+ while (true) {
479
+ // Race reader.read() against a cancellable read timeout.
480
+ // Using AbortController avoids leaking dangling delay() timers on every chunk.
481
+ const timeoutAc = new AbortController();
482
+ const timeoutPromise = delay(readTimeout, undefined, { signal: timeoutAc.signal })
483
+ .then(() => 'TIMEOUT')
484
+ .catch(() => 'CANCELLED');
485
+ const readPromise = reader.read().then((r) => { timeoutAc.abort(); return r; });
486
+ const result = await Promise.race([readPromise, timeoutPromise]);
487
+ if (result === 'TIMEOUT') {
488
+ reader.cancel().catch(() => { });
489
+ // If we got *some* deltas, returning partial content is usually worse UX for tool-using agents:
490
+ // it often leaves a truncated tool call / JSON args which then fails downstream.
491
+ // Instead, prefer retry/fallback to non-streaming when enabled.
492
+ const allowFallback = (process.env.IDLEHANDS_STREAM_FALLBACK ?? '1') !== '0';
493
+ if (allowFallback) {
494
+ if (sawDelta) {
495
+ this.log(`read timeout after ${tokensReceived} tokens, retrying via non-streaming`);
496
+ }
497
+ else {
498
+ this.log(`read timeout with no data, retrying via non-streaming`);
499
+ }
500
+ return this.chat({ ...opts, stream: false });
501
+ }
502
+ if (sawDelta) {
503
+ this.log(`read timeout after ${tokensReceived} tokens, returning partial`);
504
+ const partial = this.finalizeStreamAggregate(agg, toolIdByIndex, toolNameByIndex, toolArgsByIndex);
505
+ const content = partial.choices?.[0]?.message?.content;
506
+ if (content) {
507
+ partial.choices[0].message.content = content + `\n[connection lost after ${tokensReceived} tokens]`;
508
+ }
509
+ const totalMs = Date.now() - reqStart;
510
+ partial.meta = {
511
+ total_ms: totalMs,
512
+ ttft_ms: firstDeltaMs,
513
+ streamed: true,
514
+ partial: true,
515
+ };
516
+ this.emitExchange({
517
+ timestamp: new Date().toISOString(),
518
+ request: clean,
519
+ response: partial,
520
+ metrics: {
521
+ total_ms: totalMs,
522
+ ttft_ms: firstDeltaMs,
523
+ prompt_tokens: partial.usage?.prompt_tokens,
524
+ completion_tokens: partial.usage?.completion_tokens,
525
+ },
526
+ });
527
+ return partial;
528
+ }
529
+ throw makeClientError(`Stream read timeout (${readTimeout}ms) with no data received`, undefined, true);
530
+ }
531
+ if (result === 'CANCELLED')
532
+ continue; // timeout was cancelled, read won
533
+ const { value, done } = result;
534
+ if (done)
535
+ break;
536
+ buf += decoder.decode(value, { stream: true });
537
+ while (true) {
538
+ const idx = buf.indexOf('\n\n');
539
+ if (idx === -1)
540
+ break;
541
+ const frame = buf.slice(0, idx);
542
+ buf = buf.slice(idx + 2);
543
+ const lines = frame.split('\n');
544
+ for (const line of lines) {
545
+ const m = /^data:\s*(.*)$/.exec(line);
546
+ if (!m)
547
+ continue;
548
+ const data = m[1];
549
+ if (data === '[DONE]') {
550
+ const doneResult = this.finalizeStreamAggregate(agg, toolIdByIndex, toolNameByIndex, toolArgsByIndex);
551
+ const totalMs = Date.now() - reqStart;
552
+ doneResult.meta = {
553
+ total_ms: totalMs,
554
+ ttft_ms: firstDeltaMs,
555
+ streamed: true,
556
+ };
557
+ this.emitExchange({
558
+ timestamp: new Date().toISOString(),
559
+ request: clean,
560
+ response: doneResult,
561
+ metrics: {
562
+ total_ms: totalMs,
563
+ ttft_ms: firstDeltaMs,
564
+ prompt_tokens: doneResult.usage?.prompt_tokens,
565
+ completion_tokens: doneResult.usage?.completion_tokens,
566
+ },
567
+ });
568
+ return doneResult;
569
+ }
570
+ let chunk;
571
+ try {
572
+ chunk = JSON.parse(data);
573
+ }
574
+ catch {
575
+ continue;
576
+ }
577
+ const c = chunk.choices?.[0];
578
+ const d = c?.delta;
579
+ if (!d)
580
+ continue;
581
+ if (!sawDelta) {
582
+ sawDelta = true;
583
+ firstDeltaMs = Date.now() - reqStart;
584
+ opts.onFirstDelta?.();
585
+ }
586
+ if (d.content) {
587
+ tokensReceived++;
588
+ agg.choices[0].delta.content = (agg.choices[0].delta.content ?? '') + d.content;
589
+ opts.onToken?.(d.content);
590
+ }
591
+ if (Array.isArray(d.tool_calls)) {
592
+ for (const tc of d.tool_calls) {
593
+ const i = tc.index;
594
+ if (i === undefined)
595
+ continue;
596
+ if (tc.id)
597
+ toolIdByIndex[i] = tc.id;
598
+ if (tc.function?.name)
599
+ toolNameByIndex[i] = tc.function.name;
600
+ if (tc.function?.arguments) {
601
+ toolArgsByIndex[i] = (toolArgsByIndex[i] ?? '') + tc.function.arguments;
602
+ }
603
+ }
604
+ }
605
+ if (c.finish_reason) {
606
+ agg.choices[0].finish_reason = c.finish_reason;
607
+ }
608
+ // Capture usage if server provides it
609
+ if (chunk.usage) {
610
+ agg.usage = chunk.usage;
611
+ }
612
+ }
613
+ }
614
+ }
615
+ const streamResult = this.finalizeStreamAggregate(agg, toolIdByIndex, toolNameByIndex, toolArgsByIndex);
616
+ const totalMs = Date.now() - reqStart;
617
+ // Backpressure: track streaming response time
618
+ const bp = this.backpressure.record(totalMs);
619
+ if (bp.warn) {
620
+ const avgS = (bp.avg / 1000).toFixed(1);
621
+ const curS = (bp.current / 1000).toFixed(1);
622
+ this.log(`backpressure warning: response ${curS}s > ${this.backpressure.multiplier}× avg ${avgS}s — consider reducing context size`);
623
+ console.warn(`[warn] server response time (${curS}s) exceeds ${this.backpressure.multiplier}× session average (${avgS}s) — consider reducing context size`);
624
+ }
625
+ streamResult.meta = {
626
+ total_ms: totalMs,
627
+ ttft_ms: firstDeltaMs,
628
+ streamed: true,
629
+ };
630
+ const tgSpeed = (() => {
631
+ const completionTokens = streamResult.usage?.completion_tokens;
632
+ if (completionTokens == null || !Number.isFinite(completionTokens) || totalMs <= 0)
633
+ return undefined;
634
+ const genMs = Math.max(1, totalMs - (firstDeltaMs ?? 0));
635
+ return completionTokens / (genMs / 1000);
636
+ })();
637
+ this.emitExchange({
638
+ timestamp: new Date().toISOString(),
639
+ request: clean,
640
+ response: streamResult,
641
+ metrics: {
642
+ total_ms: totalMs,
643
+ ttft_ms: firstDeltaMs,
644
+ tg_speed: tgSpeed,
645
+ prompt_tokens: streamResult.usage?.prompt_tokens,
646
+ completion_tokens: streamResult.usage?.completion_tokens,
647
+ },
648
+ });
649
+ return streamResult;
650
+ }
651
+ throw lastErr;
652
+ }
653
+ finalizeStreamAggregate(agg, toolIdByIndex, toolNameByIndex, toolArgsByIndex) {
654
+ const content = agg.choices[0].delta?.content ?? '';
655
+ const toolCalls = [];
656
+ const indices = Object.keys(toolNameByIndex).map(Number).sort((a, b) => a - b);
657
+ for (const idx of indices) {
658
+ toolCalls.push({
659
+ id: toolIdByIndex[idx] ?? `call_${idx}`,
660
+ type: 'function',
661
+ function: {
662
+ name: toolNameByIndex[idx],
663
+ arguments: toolArgsByIndex[idx] ?? ''
664
+ }
665
+ });
666
+ }
667
+ agg.choices[0].message = {
668
+ role: 'assistant',
669
+ content: content.length ? content : null,
670
+ tool_calls: toolCalls.length ? toolCalls : undefined
671
+ };
672
+ delete agg.choices[0].delta;
673
+ return agg;
674
+ }
675
+ }
676
+ //# sourceMappingURL=client.js.map