clanka 0.5.2 → 0.6.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.
Files changed (76) hide show
  1. package/dist/Acp.d.ts.map +1 -1
  2. package/dist/Acp.js +195 -16
  3. package/dist/Acp.js.map +1 -1
  4. package/dist/AcpImage.test.d.ts +2 -0
  5. package/dist/AcpImage.test.d.ts.map +1 -0
  6. package/dist/AcpImage.test.js +470 -0
  7. package/dist/AcpImage.test.js.map +1 -0
  8. package/dist/Agent.d.ts +9 -2
  9. package/dist/Agent.d.ts.map +1 -1
  10. package/dist/Agent.js +43 -6
  11. package/dist/Agent.js.map +1 -1
  12. package/dist/AgentExecutor.d.ts +27 -5
  13. package/dist/AgentExecutor.d.ts.map +1 -1
  14. package/dist/AgentExecutor.js +29 -4
  15. package/dist/AgentExecutor.js.map +1 -1
  16. package/dist/AgentExecutorImage.test.d.ts +2 -0
  17. package/dist/AgentExecutorImage.test.d.ts.map +1 -0
  18. package/dist/AgentExecutorImage.test.js +63 -0
  19. package/dist/AgentExecutorImage.test.js.map +1 -0
  20. package/dist/AgentImage.test.d.ts +2 -0
  21. package/dist/AgentImage.test.d.ts.map +1 -0
  22. package/dist/AgentImage.test.js +176 -0
  23. package/dist/AgentImage.test.js.map +1 -0
  24. package/dist/AgentTools.d.ts +25 -5
  25. package/dist/AgentTools.d.ts.map +1 -1
  26. package/dist/AgentTools.js +40 -4
  27. package/dist/AgentTools.js.map +1 -1
  28. package/dist/AgentToolsImage.test.d.ts +2 -0
  29. package/dist/AgentToolsImage.test.d.ts.map +1 -0
  30. package/dist/AgentToolsImage.test.js +158 -0
  31. package/dist/AgentToolsImage.test.js.map +1 -0
  32. package/dist/Codex.js +6 -1
  33. package/dist/Codex.js.map +1 -1
  34. package/dist/Compaction.d.ts.map +1 -1
  35. package/dist/Compaction.js +16 -1
  36. package/dist/Compaction.js.map +1 -1
  37. package/dist/CompactionImage.test.d.ts +2 -0
  38. package/dist/CompactionImage.test.d.ts.map +1 -0
  39. package/dist/CompactionImage.test.js +105 -0
  40. package/dist/CompactionImage.test.js.map +1 -0
  41. package/dist/Copilot.d.ts.map +1 -1
  42. package/dist/Copilot.js +5 -1
  43. package/dist/Copilot.js.map +1 -1
  44. package/dist/Image.d.ts +192 -0
  45. package/dist/Image.d.ts.map +1 -0
  46. package/dist/Image.js +586 -0
  47. package/dist/Image.js.map +1 -0
  48. package/dist/Image.test.d.ts +2 -0
  49. package/dist/Image.test.d.ts.map +1 -0
  50. package/dist/Image.test.js +142 -0
  51. package/dist/Image.test.js.map +1 -0
  52. package/dist/fixtures/TestImages.d.ts +73 -0
  53. package/dist/fixtures/TestImages.d.ts.map +1 -0
  54. package/dist/fixtures/TestImages.js +263 -0
  55. package/dist/fixtures/TestImages.js.map +1 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +4 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +2 -1
  61. package/src/Acp.ts +274 -21
  62. package/src/AcpImage.test.ts +676 -0
  63. package/src/Agent.ts +69 -7
  64. package/src/AgentExecutor.ts +51 -3
  65. package/src/AgentExecutorImage.test.ts +103 -0
  66. package/src/AgentImage.test.ts +295 -0
  67. package/src/AgentTools.ts +78 -7
  68. package/src/AgentToolsImage.test.ts +302 -0
  69. package/src/Codex.ts +6 -1
  70. package/src/Compaction.ts +16 -1
  71. package/src/CompactionImage.test.ts +142 -0
  72. package/src/Copilot.ts +5 -1
  73. package/src/Image.test.ts +220 -0
  74. package/src/Image.ts +704 -0
  75. package/src/fixtures/TestImages.ts +287 -0
  76. package/src/index.ts +5 -0
package/src/Acp.ts CHANGED
@@ -11,11 +11,14 @@
11
11
  import { randomUUID } from "node:crypto"
12
12
  import * as Cause from "effect/Cause"
13
13
  import * as Effect from "effect/Effect"
14
+ import * as Encoding from "effect/Encoding"
14
15
  import * as Exit from "effect/Exit"
15
16
  import * as Fiber from "effect/Fiber"
17
+ import * as FileSystem from "effect/FileSystem"
16
18
  import type * as Layer from "effect/Layer"
17
19
  import * as MutableRef from "effect/MutableRef"
18
20
  import * as Option from "effect/Option"
21
+ import * as Path from "effect/Path"
19
22
  import type * as PlatformError from "effect/PlatformError"
20
23
  import * as Queue from "effect/Queue"
21
24
  import * as Schema from "effect/Schema"
@@ -25,10 +28,12 @@ import * as Stream from "effect/Stream"
25
28
  import type * as LanguageModel from "effect/unstable/ai/LanguageModel"
26
29
  import type * as Model from "effect/unstable/ai/Model"
27
30
  import * as Prompt from "effect/unstable/ai/Prompt"
31
+ import * as HttpClient from "effect/unstable/http/HttpClient"
28
32
  import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
29
33
  import type * as Agent from "./Agent.ts"
30
34
  import type * as AgentOutput from "./AgentOutput.ts"
31
35
  import * as Compaction from "./Compaction.ts"
36
+ import * as Image from "./Image.ts"
32
37
 
33
38
  /**
34
39
  * @since 1.0.0
@@ -139,21 +144,36 @@ const TextBlock = Schema.Struct({
139
144
  text: Schema.String,
140
145
  })
141
146
 
147
+ const ImageBlock = Schema.Struct({
148
+ type: Schema.Literal("image"),
149
+ data: Schema.String,
150
+ mimeType: Schema.String,
151
+ uri: Schema.optionalKey(Schema.String),
152
+ })
153
+
142
154
  const ResourceLinkBlock = Schema.Struct({
143
155
  type: Schema.Literal("resource_link"),
144
156
  uri: Schema.String,
145
157
  name: Schema.optionalKey(Schema.String),
158
+ mimeType: Schema.optionalKey(Schema.String),
146
159
  })
147
160
 
148
161
  const ResourceBlock = Schema.Struct({
149
162
  type: Schema.Literal("resource"),
150
163
  resource: Schema.Struct({
151
164
  uri: Schema.String,
165
+ mimeType: Schema.optionalKey(Schema.String),
152
166
  text: Schema.optionalKey(Schema.String),
167
+ blob: Schema.optionalKey(Schema.String),
153
168
  }),
154
169
  })
155
170
 
156
- const ContentBlock = Schema.Union([TextBlock, ResourceLinkBlock, ResourceBlock])
171
+ const ContentBlock = Schema.Union([
172
+ TextBlock,
173
+ ImageBlock,
174
+ ResourceLinkBlock,
175
+ ResourceBlock,
176
+ ])
157
177
 
158
178
  const PromptParams = Schema.Struct({
159
179
  sessionId: Schema.String,
@@ -177,26 +197,42 @@ const toRpcError = (cause: Cause.Cause<unknown>) => {
177
197
  : new RpcError({ code: -32603, message: Cause.pretty(cause) })
178
198
  }
179
199
 
180
- const renderPrompt = (
181
- blocks: ReadonlyArray<typeof ContentBlock.Type>,
182
- ): string =>
183
- blocks
184
- .map((block) => {
185
- switch (block.type) {
186
- case "text":
187
- return block.text
188
- case "resource_link":
189
- return `[${block.name ?? block.uri}](${block.uri})`
190
- case "resource":
191
- return block.resource.text === undefined
192
- ? `[${block.resource.uri}](${block.resource.uri})`
193
- : `<resource uri="${block.resource.uri}">\n${block.resource.text}\n</resource>`
194
- }
195
- })
196
- .join("\n\n")
200
+ const renderLink = (uri: string, name?: string) => `[${name ?? uri}](${uri})`
201
+
202
+ // Covers the request and entire body read.
203
+ const remoteImageTimeout = "30 seconds"
204
+
205
+ const invalidParams = (message: string) =>
206
+ new RpcError({ code: -32602, message })
207
+
208
+ const decodeBase64 = (data: string, what: string) => {
209
+ if (data.length > Math.ceil(Image.maxInputBytes / 3) * 4) {
210
+ return Effect.fail(
211
+ invalidParams(
212
+ `Image data in ${what} exceeds the ${Image.maxInputBytes} byte input limit`,
213
+ ),
214
+ )
215
+ }
216
+ const decoded = Encoding.decodeBase64(data)
217
+ return decoded._tag === "Success"
218
+ ? Effect.succeed(decoded.success)
219
+ : Effect.fail(invalidParams(`Invalid base64 image data in ${what}`))
220
+ }
221
+
222
+ const fileNameOf = (uri: string | undefined): string | undefined => {
223
+ if (uri === undefined) return undefined
224
+ return uri.split(/[\\/]/).findLast((part) => part.length > 0)
225
+ }
197
226
 
198
227
  const textContent = (text: string) => ({ type: "text", text })
199
228
 
229
+ const imageContent = (part: Prompt.FilePart) =>
230
+ Option.map(Image.partBytes(part), (bytes) => ({
231
+ type: "image",
232
+ data: Encoding.encodeBase64(bytes),
233
+ mimeType: part.mediaType,
234
+ }))
235
+
200
236
  const historyUpdates = (history: Prompt.Prompt) => {
201
237
  const updates: Array<object> = []
202
238
  for (const message of history.content) {
@@ -207,6 +243,11 @@ const historyUpdates = (history: Prompt.Prompt) => {
207
243
  for (const part of message.content) {
208
244
  if (part.type === "text") {
209
245
  updates.push({ sessionUpdate, content: textContent(part.text) })
246
+ } else if (message.role === "user" && Image.isImagePart(part)) {
247
+ const content = imageContent(part)
248
+ if (Option.isSome(content)) {
249
+ updates.push({ sessionUpdate, content: content.value })
250
+ }
210
251
  }
211
252
  }
212
253
  }
@@ -234,6 +275,12 @@ export const make = Effect.fnUntraced(function* <RAgent, RModel>(
234
275
  KeyValueStore.KeyValueStore | Scope.Scope
235
276
  > {
236
277
  const kvs = yield* KeyValueStore.KeyValueStore
278
+ // Optional: `https:` images need an HttpClient and `file:` images need a
279
+ // FileSystem and Path. Inline (base64) images work without either. When a
280
+ // service is missing the corresponding block is rejected, never fetched.
281
+ const http = yield* Effect.serviceOption(HttpClient.HttpClient)
282
+ const fs = yield* Effect.serviceOption(FileSystem.FileSystem)
283
+ const path = yield* Effect.serviceOption(Path.Path)
237
284
  const store = KeyValueStore.toSchemaStore(
238
285
  KeyValueStore.prefix(kvs, "session-"),
239
286
  SessionRecord,
@@ -307,9 +354,212 @@ export const make = Effect.fnUntraced(function* <RAgent, RModel>(
307
354
  { discard: true },
308
355
  )
309
356
 
357
+ // ---------------------------------------------------------------------
358
+ // Prompt content blocks
359
+ // ---------------------------------------------------------------------
360
+
361
+ /**
362
+ * Fetch an `http(s):` image. Any transport or non-2xx failure rejects the
363
+ * prompt; nothing is retried here.
364
+ */
365
+ const fetchImage = Effect.fnUntraced(
366
+ function* (uri: string) {
367
+ if (Option.isNone(http)) {
368
+ return yield* invalidParams(
369
+ `Cannot fetch image ${uri}: no HttpClient available`,
370
+ )
371
+ }
372
+ const response = yield* HttpClient.withScope(http.value).get(uri)
373
+ if (response.status < 200 || response.status >= 300) {
374
+ return yield* invalidParams(
375
+ `Failed to fetch image ${uri}: HTTP ${response.status}`,
376
+ )
377
+ }
378
+ const tooLarge = () =>
379
+ invalidParams(
380
+ `Image ${uri} exceeds the ${Image.maxInputBytes} byte download limit`,
381
+ )
382
+ if (Number(response.headers["content-length"]) > Image.maxInputBytes) {
383
+ return yield* tooLarge()
384
+ }
385
+ return yield* Image.collectInput(response.stream, tooLarge)
386
+ },
387
+ Effect.scoped,
388
+ Effect.timeout(remoteImageTimeout),
389
+ (effect, uri) =>
390
+ effect.pipe(
391
+ Effect.mapError((error) =>
392
+ error instanceof RpcError
393
+ ? error
394
+ : invalidParams(`Failed to fetch image ${uri}: ${error.message}`),
395
+ ),
396
+ ),
397
+ )
398
+
399
+ /**
400
+ * Read a `file:` image, but only when its real path (symlinks resolved)
401
+ * is inside the session directory (symlinks resolved as well).
402
+ */
403
+ const readLocalImage = (
404
+ cwd: string,
405
+ uri: string,
406
+ ): Effect.Effect<Uint8Array, RpcError> =>
407
+ Option.match(Option.all([fs, path]), {
408
+ onNone: () =>
409
+ Effect.fail(
410
+ invalidParams(`Cannot read image ${uri}: no FileSystem available`),
411
+ ),
412
+ onSome: ([fs, path]) =>
413
+ Effect.gen(function* () {
414
+ const url = yield* Effect.try({
415
+ try: () => new URL(uri),
416
+ catch: () => invalidParams(`Invalid file URI: ${uri}`),
417
+ })
418
+ const filePath = yield* path
419
+ .fromFileUrl(url)
420
+ .pipe(
421
+ Effect.mapError(() => invalidParams(`Invalid file URI: ${uri}`)),
422
+ )
423
+ const resolved = path.resolve(cwd, filePath)
424
+ const [realCwd, real] = yield* Effect.all([
425
+ fs.realPath(cwd),
426
+ fs.realPath(resolved),
427
+ ]).pipe(
428
+ Effect.mapError(() =>
429
+ invalidParams(`Image file not found: ${uri}`),
430
+ ),
431
+ )
432
+ if (real !== realCwd && !real.startsWith(realCwd + path.sep)) {
433
+ return yield* invalidParams(
434
+ `Image file is outside the session directory: ${uri}`,
435
+ )
436
+ }
437
+ return yield* Image.readBoundedFile(fs, real).pipe(
438
+ Effect.mapError((error) =>
439
+ invalidParams(`Failed to read image ${uri}: ${error.message}`),
440
+ ),
441
+ )
442
+ }),
443
+ })
444
+
445
+ const resolveImageUri = (
446
+ cwd: string,
447
+ uri: string,
448
+ ): Effect.Effect<Uint8Array, RpcError> => {
449
+ if (/^https?:\/\//i.test(uri)) return fetchImage(uri)
450
+ if (/^file:/i.test(uri)) return readLocalImage(cwd, uri)
451
+ return Effect.fail(invalidParams(`Unsupported image URI scheme: ${uri}`))
452
+ }
453
+
454
+ /**
455
+ * Turn raw image bytes into a prompt part, sniffing the real type and
456
+ * applying the shared size limits.
457
+ */
458
+ const imagePart = Effect.fnUntraced(function* (
459
+ bytes: Uint8Array,
460
+ declared: string | undefined,
461
+ fileName: string | undefined,
462
+ ) {
463
+ const mediaType = Option.getOrElse(Image.mediaTypeFromBytes(bytes), () =>
464
+ declared !== undefined && Image.isImageMediaType(declared)
465
+ ? declared
466
+ : undefined,
467
+ )
468
+ if (mediaType === undefined) {
469
+ return yield* invalidParams(
470
+ `Unsupported image type${declared === undefined ? "" : `: ${declared}`}`,
471
+ )
472
+ }
473
+ const prepared = yield* Image.prepare({ data: bytes, mediaType }).pipe(
474
+ Effect.mapError((error) => invalidParams(error.message)),
475
+ )
476
+ return Prompt.makePart("file", {
477
+ mediaType: prepared.mediaType,
478
+ ...(fileName === undefined ? {} : { fileName }),
479
+ data: prepared.data,
480
+ })
481
+ })
482
+
483
+ /**
484
+ * Map ACP content blocks onto one user message. Text-like blocks are
485
+ * joined into text parts; image blocks become `file` parts in place.
486
+ */
487
+ const promptFromBlocks = Effect.fnUntraced(function* (
488
+ cwd: string,
489
+ blocks: ReadonlyArray<typeof ContentBlock.Type>,
490
+ ): Effect.fn.Return<Prompt.RawInput, RpcError> {
491
+ const parts: Array<Prompt.UserMessagePart> = []
492
+ let text: Array<string> = []
493
+ const flush = () => {
494
+ if (text.length === 0) return
495
+ parts.push(Prompt.makePart("text", { text: text.join("\n\n") }))
496
+ text = []
497
+ }
498
+ const addImage = (part: Prompt.FilePart) => {
499
+ flush()
500
+ parts.push(part)
501
+ }
502
+
503
+ for (const block of blocks) {
504
+ switch (block.type) {
505
+ case "text":
506
+ text.push(block.text)
507
+ break
508
+ case "image": {
509
+ const bytes =
510
+ block.data.length > 0
511
+ ? yield* decodeBase64(block.data, "image block")
512
+ : block.uri !== undefined
513
+ ? yield* resolveImageUri(cwd, block.uri)
514
+ : yield* invalidParams("Image block has no data")
515
+ addImage(
516
+ yield* imagePart(bytes, block.mimeType, fileNameOf(block.uri)),
517
+ )
518
+ break
519
+ }
520
+ case "resource_link": {
521
+ if (
522
+ block.mimeType !== undefined &&
523
+ Image.isImageMediaType(block.mimeType)
524
+ ) {
525
+ const bytes = yield* resolveImageUri(cwd, block.uri)
526
+ addImage(
527
+ yield* imagePart(
528
+ bytes,
529
+ block.mimeType,
530
+ block.name ?? fileNameOf(block.uri),
531
+ ),
532
+ )
533
+ } else {
534
+ text.push(renderLink(block.uri, block.name))
535
+ }
536
+ break
537
+ }
538
+ case "resource": {
539
+ const { uri, mimeType, text: body, blob } = block.resource
540
+ if (
541
+ blob !== undefined &&
542
+ mimeType !== undefined &&
543
+ Image.isImageMediaType(mimeType)
544
+ ) {
545
+ const bytes = yield* decodeBase64(blob, `resource ${uri}`)
546
+ addImage(yield* imagePart(bytes, mimeType, fileNameOf(uri)))
547
+ } else if (body !== undefined) {
548
+ text.push(`<resource uri="${uri}">\n${body}\n</resource>`)
549
+ } else {
550
+ text.push(renderLink(uri))
551
+ }
552
+ break
553
+ }
554
+ }
555
+ }
556
+ flush()
557
+ return [{ role: "user", content: parts }]
558
+ })
559
+
310
560
  const runTurn = Effect.fnUntraced(function* (
311
561
  session: Session,
312
- prompt: string,
562
+ prompt: Prompt.RawInput,
313
563
  ) {
314
564
  const stream = yield* session.agent.send({ prompt })
315
565
  let script = ""
@@ -399,7 +649,7 @@ export const make = Effect.fnUntraced(function* <RAgent, RModel>(
399
649
  protocolVersion: 1,
400
650
  agentCapabilities: {
401
651
  loadSession: true,
402
- promptCapabilities: { image: false, audio: false, embeddedContext: true },
652
+ promptCapabilities: { image: true, audio: false, embeddedContext: true },
403
653
  mcpCapabilities: { http: false, sse: false },
404
654
  },
405
655
  authMethods: [],
@@ -475,7 +725,10 @@ export const make = Effect.fnUntraced(function* <RAgent, RModel>(
475
725
  })
476
726
  }
477
727
  const model = yield* requireModel(session.model)
478
- session.running = yield* runTurn(session, renderPrompt(prompt)).pipe(
728
+ // Resolve images before the turn starts, so a bad block rejects the
729
+ // request without a model call.
730
+ const input = yield* promptFromBlocks(session.cwd, prompt)
731
+ session.running = yield* runTurn(session, input).pipe(
479
732
  Effect.provide(model),
480
733
  Effect.scoped,
481
734
  Effect.forkChild,