@semanticist14/clco 0.2.0 → 0.2.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.
package/bun.lock CHANGED
@@ -13,6 +13,7 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@types/bun": "latest",
16
+ "@types/turndown": "^5.0.6",
16
17
  "typescript": "^5",
17
18
  },
18
19
  },
@@ -30,6 +31,8 @@
30
31
 
31
32
  "@types/node": ["@types/node@26.5.1", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-CzNm2FezW4VR/LjG6yUdiEgLE/rAQ9Slj5gCu/C2VrdcW7I0ahNZ8DRbHT7zOZ6r3ONgd/bsQIeSaoDGrd1C6g=="],
32
33
 
34
+ "@types/turndown": ["@types/turndown@5.0.6", "", {}, "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg=="],
35
+
33
36
  "boolbase": ["boolbase@2.0.0", "", {}, "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA=="],
34
37
 
35
38
  "bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@semanticist14/clco",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Run Claude Code on a GitHub Copilot subscription",
@@ -28,6 +28,7 @@
28
28
  },
29
29
  "devDependencies": {
30
30
  "@types/bun": "latest",
31
+ "@types/turndown": "^5.0.6",
31
32
  "typescript": "^5"
32
33
  },
33
34
  "dependencies": {
package/src/responses.ts CHANGED
@@ -196,9 +196,68 @@ function incompleteError(response: Record<string, unknown> | undefined): OpenAIR
196
196
  export class ResponsesEventAdapter {
197
197
  private toolIndexes = new Map<string, number>()
198
198
  private argsAccum = new Map<string, string>()
199
+ private argsRescued = new Set<string>()
199
200
  private textAccum = new Map<string, string>()
201
+ private textRescued = new Set<string>()
202
+ private lastToolKey: string | null = null
203
+ private lastTextKey: string | null = null
200
204
  private nextIndex = 0
201
205
  private sawToolCall = false
206
+ private fallbacks = 0
207
+ private drops = 0
208
+ private firstLoss: string | null = null
209
+
210
+ // Some upstreams spell the item_id differently on output_item.added than on
211
+ // the delta/done events that follow (observed with gpt-6-astra). An unknown
212
+ // id falls back to the most recent item of that kind so the event is not
213
+ // silently dropped. Ceilings: interleaved parallel tool calls with distinct
214
+ // mismatched ids cannot be correlated and merge into the latest item, and a
215
+ // text item announced only by added (deltas under another id) is not
216
+ // rescued. Both are visible in lossSummary().
217
+ private toolKeyFor(
218
+ itemId: string,
219
+ event?: Record<string, unknown>,
220
+ ): string | null {
221
+ if (this.toolIndexes.has(itemId)) return itemId
222
+ if (this.lastToolKey !== null) {
223
+ this.fallbacks++
224
+ this.noteLoss(
225
+ `arg event item_id=${itemId} matched to most recent tool call ${this.lastToolKey}`,
226
+ event,
227
+ )
228
+ }
229
+ return this.lastToolKey
230
+ }
231
+
232
+ private textKeyFor(
233
+ itemId: string,
234
+ event?: Record<string, unknown>,
235
+ ): string {
236
+ if (this.textAccum.has(itemId) || this.lastTextKey === null)
237
+ return itemId
238
+ this.fallbacks++
239
+ this.noteLoss(
240
+ `text event item_id=${itemId} matched to most recent text item ${this.lastTextKey}`,
241
+ event,
242
+ )
243
+ return this.lastTextKey
244
+ }
245
+
246
+ private noteLoss(detail: string, event?: Record<string, unknown>): void {
247
+ if (this.firstLoss !== null) return
248
+ const raw = event ? ` event=${JSON.stringify(event).slice(0, 300)}` : ""
249
+ this.firstLoss = detail + raw
250
+ }
251
+
252
+ /** One line per stream, or null when every event matched an added item. */
253
+ lossSummary(): string | null {
254
+ if (this.fallbacks === 0 && this.drops === 0) return null
255
+ const parts: string[] = []
256
+ if (this.fallbacks > 0)
257
+ parts.push(`${this.fallbacks} event(s) matched by fallback (guessed item)`)
258
+ if (this.drops > 0) parts.push(`${this.drops} event(s) dropped (lost)`)
259
+ return `${parts.join("; ")}; first: ${this.firstLoss}`
260
+ }
202
261
 
203
262
  /** Convert one Responses stream event into OpenAI-chunk shape (or null). */
204
263
  pushEvent(event: Record<string, unknown>): OpenAIResponse | null {
@@ -208,8 +267,9 @@ export class ResponsesEventAdapter {
208
267
  if (type === "response.output_text.delta") {
209
268
  const delta = event.delta as string | undefined
210
269
  if (!delta) return null
211
- const key = String(event.item_id ?? "r")
270
+ const key = this.textKeyFor(String(event.item_id ?? "r"))
212
271
  this.textAccum.set(key, (this.textAccum.get(key) ?? "") + delta)
272
+ this.lastTextKey = key
213
273
  return {
214
274
  id: key,
215
275
  model: "",
@@ -220,9 +280,10 @@ export class ResponsesEventAdapter {
220
280
  if (type === "response.output_text.done") {
221
281
  // Authoritative fallback: if no delta arrived for this item, emit the
222
282
  // full text so a lost stream cannot silently become an empty message.
223
- const key = String(event.item_id ?? "r")
283
+ const key = this.textKeyFor(String(event.item_id ?? "r"), event)
224
284
  const full = (event.text as string | undefined) ?? ""
225
- if (full && !(this.textAccum.get(key) ?? "")) {
285
+ if (full && !(this.textRescued.has(key) || this.textAccum.get(key))) {
286
+ this.textRescued.add(key)
226
287
  return {
227
288
  id: key,
228
289
  model: "",
@@ -236,13 +297,20 @@ export class ResponsesEventAdapter {
236
297
  const item = event.item as
237
298
  | { type?: string; call_id?: string; id?: string; name?: string }
238
299
  | undefined
239
- if (item?.type !== "function_call") return null
300
+ if (item?.type !== "function_call") {
301
+ // A non-function item (message, reasoning, ...) is a genuinely new
302
+ // text item: stop pointing lastTextKey at the previous one so its
303
+ // deltas are never folded in.
304
+ this.lastTextKey = null
305
+ return null
306
+ }
240
307
  this.sawToolCall = true
241
308
  const index = this.nextIndex++
242
309
  const key = String(item.call_id ?? item.id ?? index)
243
310
  this.toolIndexes.set(key, index)
244
311
  // Arguments deltas are keyed by item_id; remember that too.
245
312
  if (item.id) this.toolIndexes.set(String(item.id), index)
313
+ this.lastToolKey = String(item.id ?? key)
246
314
  return {
247
315
  id: String(item.call_id ?? item.id ?? "r"),
248
316
  model: "",
@@ -267,9 +335,16 @@ export class ResponsesEventAdapter {
267
335
  if (type === "response.function_call_arguments.delta") {
268
336
  const delta = event.delta as string | undefined
269
337
  if (!delta) return null
270
- const key = String(event.item_id ?? "")
271
- const index = this.toolIndexes.get(key)
272
- if (index === undefined) return null
338
+ const key = this.toolKeyFor(String(event.item_id ?? ""), event)
339
+ if (key === null || !this.toolIndexes.has(key)) {
340
+ this.drops++
341
+ this.noteLoss(
342
+ `arg delta item_id=${event.item_id} had no tool call to attach to`,
343
+ event,
344
+ )
345
+ return null
346
+ }
347
+ const index = this.toolIndexes.get(key)!
273
348
  this.argsAccum.set(key, (this.argsAccum.get(key) ?? "") + delta)
274
349
  return {
275
350
  id: key,
@@ -287,11 +362,20 @@ export class ResponsesEventAdapter {
287
362
  }
288
363
 
289
364
  if (type === "response.function_call_arguments.done") {
290
- const key = String(event.item_id ?? "")
291
- const index = this.toolIndexes.get(key)
292
- if (index === undefined) return null
365
+ const key = this.toolKeyFor(String(event.item_id ?? ""), event)
366
+ if (key === null || !this.toolIndexes.has(key)) {
367
+ this.drops++
368
+ this.noteLoss(
369
+ `arg done item_id=${event.item_id} had no tool call to attach to`,
370
+ event,
371
+ )
372
+ return null
373
+ }
374
+ const index = this.toolIndexes.get(key)!
293
375
  const full = (event.arguments as string | undefined) ?? ""
294
- if (full && !(this.argsAccum.get(key) ?? "")) {
376
+ if (full && !(this.argsRescued.has(key) || this.argsAccum.get(key))) {
377
+ this.argsRescued.add(key)
378
+ this.argsAccum.set(key, full)
295
379
  return {
296
380
  id: key,
297
381
  model: "",
package/src/server.ts CHANGED
@@ -361,6 +361,10 @@ function sseResponse(
361
361
  if (!errored) {
362
362
  for (const ev of translator.finish()) send(ev)
363
363
  }
364
+ // Item-id mismatches and drops must never be silent again: this
365
+ // incident hid for 97 tool calls behind a debug-only log path.
366
+ const loss = eventAdapter?.lossSummary()
367
+ if (loss) console.error("[clco:responses]", loss)
364
368
  } catch (err) {
365
369
  send({
366
370
  event: "error",