clanka 0.7.6 → 0.8.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 (51) hide show
  1. package/dist/Acp.d.ts +21 -2
  2. package/dist/Acp.d.ts.map +1 -1
  3. package/dist/Acp.js +68 -13
  4. package/dist/Acp.js.map +1 -1
  5. package/dist/Acp.test.js +118 -10
  6. package/dist/Acp.test.js.map +1 -1
  7. package/dist/AcpImage.test.js +1 -0
  8. package/dist/AcpImage.test.js.map +1 -1
  9. package/dist/Agent.d.ts.map +1 -1
  10. package/dist/Agent.js +10 -0
  11. package/dist/Agent.js.map +1 -1
  12. package/dist/Agent.test.js +47 -2
  13. package/dist/Agent.test.js.map +1 -1
  14. package/dist/AgentOutput.d.ts +8 -0
  15. package/dist/AgentOutput.d.ts.map +1 -1
  16. package/dist/AgentOutput.js +8 -0
  17. package/dist/AgentOutput.js.map +1 -1
  18. package/dist/AgentTools.d.ts.map +1 -1
  19. package/dist/AgentTools.js +22 -7
  20. package/dist/AgentTools.js.map +1 -1
  21. package/dist/AgentTools.test.d.ts +2 -0
  22. package/dist/AgentTools.test.d.ts.map +1 -0
  23. package/dist/AgentTools.test.js +63 -0
  24. package/dist/AgentTools.test.js.map +1 -0
  25. package/dist/bin/{Effect-DdkIoNCD.mjs → Effect-CeAMoJlg.mjs} +7 -2
  26. package/dist/bin/Effect-CeAMoJlg.mjs.map +1 -0
  27. package/dist/bin/{McpClient-0f-vTQC-.mjs → McpClient-zQibhsnA.mjs} +3 -3
  28. package/dist/bin/{McpClient-0f-vTQC-.mjs.map → McpClient-zQibhsnA.mjs.map} +1 -1
  29. package/dist/bin/{OutputFormatter-DN8OGwuK.mjs → OutputFormatter-C7Awn0DS.mjs} +3 -3
  30. package/dist/bin/{OutputFormatter-DN8OGwuK.mjs.map → OutputFormatter-C7Awn0DS.mjs.map} +1 -1
  31. package/dist/bin/{Schema-gTtf66A-.mjs → Schema-Bc06DxcV.mjs} +2 -2
  32. package/dist/bin/{Schema-gTtf66A-.mjs.map → Schema-Bc06DxcV.mjs.map} +1 -1
  33. package/dist/bin/{SemanticSearch-CBs6yELV.mjs → SemanticSearch-BPxQoRSC.mjs} +4 -4
  34. package/dist/bin/{SemanticSearch-CBs6yELV.mjs.map → SemanticSearch-BPxQoRSC.mjs.map} +1 -1
  35. package/dist/bin/{Stream-B5ebASRi.mjs → Stream-DlGXvw0L.mjs} +2 -2
  36. package/dist/bin/{Stream-B5ebASRi.mjs.map → Stream-DlGXvw0L.mjs.map} +1 -1
  37. package/dist/bin/cli.mjs +107 -40
  38. package/dist/bin/cli.mjs.map +1 -1
  39. package/dist/cli.js +17 -8
  40. package/dist/cli.js.map +1 -1
  41. package/package.json +1 -1
  42. package/src/Acp.test.ts +188 -9
  43. package/src/Acp.ts +92 -17
  44. package/src/AcpImage.test.ts +1 -0
  45. package/src/Agent.test.ts +54 -2
  46. package/src/Agent.ts +10 -0
  47. package/src/AgentOutput.ts +8 -0
  48. package/src/AgentTools.test.ts +116 -0
  49. package/src/AgentTools.ts +32 -12
  50. package/src/cli.ts +22 -9
  51. package/dist/bin/Effect-DdkIoNCD.mjs.map +0 -1
package/src/Agent.test.ts CHANGED
@@ -18,7 +18,7 @@ import type * as Response from "effect/unstable/ai/Response"
18
18
  import * as ResponseIdTracker from "effect/unstable/ai/ResponseIdTracker"
19
19
  import * as Agent from "./Agent.ts"
20
20
  import * as AgentExecutor from "./AgentExecutor.ts"
21
- import type * as AgentOutput from "./AgentOutput.ts"
21
+ import * as AgentOutput from "./AgentOutput.ts"
22
22
  import * as Compaction from "./Compaction.ts"
23
23
 
24
24
  const capabilities = new AgentExecutor.Capabilities({
@@ -310,11 +310,19 @@ const toolCall = (id: string, script: string): Response.StreamPartEncoded => ({
310
310
  const finish = (
311
311
  contextTokens: number,
312
312
  reason: "stop" | "tool-calls" = "tool-calls",
313
+ cache: {
314
+ readonly read?: number | undefined
315
+ readonly write?: number | undefined
316
+ } = {},
313
317
  ): Response.StreamPartEncoded => ({
314
318
  type: "finish",
315
319
  reason,
316
320
  usage: {
317
- inputTokens: { total: contextTokens },
321
+ inputTokens: {
322
+ total: contextTokens,
323
+ cacheRead: cache.read,
324
+ cacheWrite: cache.write,
325
+ },
318
326
  outputTokens: { total: 10 },
319
327
  },
320
328
  })
@@ -524,6 +532,50 @@ it.layer(localExecutorLayer, { excludeTestServices: true })(
524
532
 
525
533
  // --- tests -------------------------------------------------------------------
526
534
 
535
+ describe("Agent usage", () => {
536
+ it.effect("accumulates provider cache reads and writes", () =>
537
+ Effect.gen(function* () {
538
+ const { exit, outputs } = yield* runAgentCollect({
539
+ executor: makeExecutor(() => Stream.succeed("done")),
540
+ respond: (_call, index) => {
541
+ switch (index) {
542
+ case 0:
543
+ return Stream.fromIterable([
544
+ toolCall("call-1", "console.log('done')"),
545
+ finish(100, "tool-calls", { read: 25, write: 5 }),
546
+ ])
547
+ case 1:
548
+ return Stream.fromIterable([
549
+ ...text("complete"),
550
+ finish(60, "stop", { read: 15, write: 3 }),
551
+ ])
552
+ default:
553
+ return Stream.die(`unexpected model call #${index}`)
554
+ }
555
+ },
556
+ })
557
+
558
+ assert.deepStrictEqual(exit, Exit.succeed("complete"))
559
+ assert.deepStrictEqual(outputsOfTag(outputs, "Usage"), [
560
+ new AgentOutput.Usage({
561
+ contextTokens: 100,
562
+ inputTokens: 100,
563
+ outputTokens: 10,
564
+ cacheRead: 25,
565
+ cacheWrite: 5,
566
+ }),
567
+ new AgentOutput.Usage({
568
+ contextTokens: 60,
569
+ inputTokens: 160,
570
+ outputTokens: 20,
571
+ cacheRead: 40,
572
+ cacheWrite: 8,
573
+ }),
574
+ ])
575
+ }),
576
+ )
577
+ })
578
+
527
579
  describe("Agent execute output cap", () => {
528
580
  const dump = "DUMP-HEAD " + filler("log", 40_000) + " DUMP-TAIL"
529
581
 
package/src/Agent.ts CHANGED
@@ -211,6 +211,8 @@ ${content}
211
211
  const output = yield* Queue.make<Output, AgentFinished | AiError.AiError>()
212
212
  let inputTokens = 0
213
213
  let outputTokens = 0
214
+ let cacheRead = 0
215
+ let cacheWrite = 0
214
216
  const prompt = opts.disableHistory ? MutableRef.make(Prompt.empty) : history
215
217
 
216
218
  MutableRef.update(prompt, Prompt.concat(opts.prompt))
@@ -536,6 +538,12 @@ ${content}
536
538
  if (usage.outputTokens.total !== undefined) {
537
539
  outputTokens += usage.outputTokens.total
538
540
  }
541
+ if (usage.inputTokens.cacheRead !== undefined) {
542
+ cacheRead += usage.inputTokens.cacheRead
543
+ }
544
+ if (usage.inputTokens.cacheWrite !== undefined) {
545
+ cacheWrite += usage.inputTokens.cacheWrite
546
+ }
539
547
  if (usage.inputTokens.total !== undefined) {
540
548
  lastContextTokens = usage.inputTokens.total
541
549
  inputTokens += usage.inputTokens.total
@@ -545,6 +553,8 @@ ${content}
545
553
  contextTokens: usage.inputTokens.total,
546
554
  inputTokens,
547
555
  outputTokens,
556
+ cacheRead,
557
+ cacheWrite,
548
558
  }),
549
559
  })
550
560
  }
@@ -87,6 +87,14 @@ export class Usage extends Schema.TaggedClass<Usage>()("Usage", {
87
87
  contextTokens: Schema.Number,
88
88
  inputTokens: Schema.Number,
89
89
  outputTokens: Schema.Number,
90
+ /**
91
+ * Cumulative cache reads included in `inputTokens`. Zero is not proof of no cache activity.
92
+ */
93
+ cacheRead: Schema.Number,
94
+ /**
95
+ * Cumulative cache writes included in `inputTokens`. Zero is not proof of no cache activity.
96
+ */
97
+ cacheWrite: Schema.Number,
90
98
  }) {}
91
99
 
92
100
  /**
@@ -0,0 +1,116 @@
1
+ import { assert, describe, it } from "@effect/vitest"
2
+ import * as NodeHttpClient from "@effect/platform-node/NodeHttpClient"
3
+ import * as NodeServices from "@effect/platform-node/NodeServices"
4
+ import * as Effect from "effect/Effect"
5
+ import * as FileSystem from "effect/FileSystem"
6
+ import * as Layer from "effect/Layer"
7
+ import * as Path from "effect/Path"
8
+ import * as AgentExecutor from "./AgentExecutor.ts"
9
+
10
+ const match = "ignored-directory-regression-match"
11
+
12
+ const withProject = <A, E, R>(f: Effect.Effect<A, E, R>) =>
13
+ Effect.gen(function* () {
14
+ const fs = yield* FileSystem.FileSystem
15
+ const path = yield* Path.Path
16
+ const project = yield* fs.makeTempDirectoryScoped()
17
+ yield* fs.makeDirectory(path.join(project, ".git"))
18
+ yield* fs.writeFileString(
19
+ path.join(project, ".gitignore"),
20
+ "node_modules/\n",
21
+ )
22
+ yield* fs.makeDirectory(path.join(project, "src"))
23
+ yield* fs.makeDirectory(path.join(project, ".git", "src"))
24
+ yield* fs.makeDirectory(path.join(project, "node_modules", "dependency"), {
25
+ recursive: true,
26
+ })
27
+ yield* fs.makeDirectory(
28
+ path.join(project, "node_modules", "dependency", "src"),
29
+ )
30
+ yield* fs.writeFileString(
31
+ path.join(project, "src", "visible.ts"),
32
+ `${match} visible`,
33
+ )
34
+ yield* fs.writeFileString(
35
+ path.join(project, "node_modules", "dependency", "ignored.ts"),
36
+ `${match} ignored`,
37
+ )
38
+ yield* fs.writeFileString(
39
+ path.join(project, "node_modules", "dependency", "src", "inner.ts"),
40
+ `${match} ignored dependency source`,
41
+ )
42
+ yield* fs.writeFileString(
43
+ path.join(project, ".git", "src", "hidden.ts"),
44
+ `${match} hidden git source`,
45
+ )
46
+
47
+ return yield* f.pipe(
48
+ Effect.provide(
49
+ AgentExecutor.layerLocal({ directory: project }).pipe(
50
+ Layer.provide(NodeHttpClient.layerUndici),
51
+ ),
52
+ ),
53
+ )
54
+ }).pipe(Effect.provide(NodeServices.layer), Effect.scoped)
55
+
56
+ const rg = (glob?: string) =>
57
+ Effect.gen(function* () {
58
+ const executor = yield* AgentExecutor.AgentExecutor
59
+ const output = yield* executor.executeUnsafe({
60
+ tool: "rg",
61
+ params: { pattern: match, glob },
62
+ })
63
+ assert.isString(output)
64
+ return output
65
+ })
66
+
67
+ describe("rg", () => {
68
+ it.effect("does not search ignored directories by default", () =>
69
+ withProject(
70
+ Effect.gen(function* () {
71
+ const output = yield* rg()
72
+ assert.include(output, "src/visible.ts")
73
+ assert.notInclude(output, "node_modules/dependency/ignored.ts")
74
+ }),
75
+ ),
76
+ )
77
+
78
+ it.effect("does not search ignored directories for an ordinary glob", () =>
79
+ withProject(
80
+ Effect.gen(function* () {
81
+ const output = yield* rg("**/*.ts")
82
+ assert.include(output, "src/visible.ts")
83
+ assert.notInclude(output, "node_modules/dependency/ignored.ts")
84
+ }),
85
+ ),
86
+ )
87
+
88
+ it.effect("does not search ignored paths for an ordinary scoped glob", () =>
89
+ withProject(
90
+ Effect.gen(function* () {
91
+ const output = yield* rg("**/src/*.ts")
92
+ assert.include(output, "src/visible.ts")
93
+ assert.notInclude(output, "node_modules/dependency/src/inner.ts")
94
+ assert.notInclude(output, ".git/src/hidden.ts")
95
+ }),
96
+ ),
97
+ )
98
+
99
+ it.effect("searches an ignored directory explicitly named in the glob", () =>
100
+ withProject(
101
+ Effect.gen(function* () {
102
+ const output = yield* rg("**/node_modules/**")
103
+ assert.include(output, "node_modules/dependency/ignored.ts")
104
+ }),
105
+ ),
106
+ )
107
+
108
+ it.effect("searches a root-relative ignored directory glob", () =>
109
+ withProject(
110
+ Effect.gen(function* () {
111
+ const output = yield* rg("node_modules/**")
112
+ assert.include(output, "node_modules/dependency/ignored.ts")
113
+ }),
114
+ ),
115
+ )
116
+ })
package/src/AgentTools.ts CHANGED
@@ -82,6 +82,12 @@ export class ImageAttacher extends Context.Service<
82
82
  (image: ImageAttachment) => Effect.Effect<void>
83
83
  >()("clanka/AgentTools/ImageAttacher") {}
84
84
 
85
+ const globNamesDirectory = (glob: string) =>
86
+ glob
87
+ .split("/")
88
+ .slice(0, -1)
89
+ .some((segment) => segment.length > 0 && !/[*?[\]{}]/.test(segment))
90
+
85
91
  /**
86
92
  * @since 1.0.0
87
93
  * @category Context
@@ -440,24 +446,38 @@ export const AgentToolHandlersNoDeps = AgentToolsWithSearch.toLayer(
440
446
  if (options.filesOnly) {
441
447
  args.push("--files-with-matches")
442
448
  }
449
+ const searchesIgnoredFiles =
450
+ options.glob !== undefined && !options.glob.startsWith("*")
443
451
  if (options.glob) {
444
452
  args.push("--glob", options.glob)
445
- if (!options.glob.startsWith("*")) {
453
+ if (searchesIgnoredFiles) {
446
454
  args.push("-uu")
447
455
  }
448
456
  }
449
457
  args.push(options.pattern)
450
- let stream = spawner.streamLines(
451
- ChildProcess.make("rg", args, {
452
- cwd,
453
- stdin: "ignore",
454
- }),
455
- )
456
- stream = Stream.take(stream, options.maxLines ?? 500)
457
- return yield* Stream.runCollect(stream).pipe(
458
- Effect.map(Array.join("\n")),
459
- Effect.orDie,
460
- )
458
+ const run = Effect.fnUntraced(function* (args: Array<string>) {
459
+ let stream = spawner.streamLines(
460
+ ChildProcess.make("rg", args, {
461
+ cwd,
462
+ stdin: "ignore",
463
+ }),
464
+ )
465
+ stream = Stream.take(stream, options.maxLines ?? 500)
466
+ return yield* Stream.runCollect(stream).pipe(
467
+ Effect.map(Array.join("\n")),
468
+ Effect.orDie,
469
+ )
470
+ })
471
+ const output = yield* run(args)
472
+ if (
473
+ output.length > 0 ||
474
+ searchesIgnoredFiles ||
475
+ options.glob === undefined ||
476
+ !globNamesDirectory(options.glob)
477
+ ) {
478
+ return output
479
+ }
480
+ return yield* run(args.slice(0, -1).concat("-uu", options.pattern))
461
481
  }),
462
482
  glob: Effect.fn("AgentTools.glob")(function* (pattern) {
463
483
  yield* Effect.logInfo(`Calling "glob"`).pipe(
package/src/cli.ts CHANGED
@@ -64,14 +64,25 @@ const modelLayer = (provider: Provider, model: string, effort: string) =>
64
64
  Layer.provide(Copilot.layerClient),
65
65
  )
66
66
 
67
+ const isProvider = (provider: string | undefined): provider is Provider =>
68
+ providers.includes(provider as Provider)
69
+
70
+ // `<provider>/<model>/<effort>` as accepted by the `acp` --model flag.
67
71
  const parseModelId = (modelId: string) => {
68
72
  const [provider, model, effort, ...rest] = modelId.split("/")
69
- return provider !== undefined &&
70
- providers.includes(provider as Provider) &&
73
+ return isProvider(provider) &&
71
74
  model !== undefined &&
72
- effort !== undefined &&
75
+ Schema.is(Acp.ThoughtLevel)(effort) &&
73
76
  rest.length === 0
74
- ? Option.some({ provider: provider as Provider, model, effort })
77
+ ? Option.some({ model: `${provider}:${model}`, thoughtLevel: effort })
78
+ : Option.none()
79
+ }
80
+
81
+ // `<provider>:<model>` as advertised over ACP.
82
+ const parseAcpModelId = (modelId: string) => {
83
+ const [provider, model, ...rest] = modelId.split(":")
84
+ return isProvider(provider) && model !== undefined && rest.length === 0
85
+ ? Option.some({ provider, model })
75
86
  : Option.none()
76
87
  }
77
88
 
@@ -80,7 +91,7 @@ const ModelId = Schema.String.pipe(
80
91
  Schema.makeFilter(
81
92
  (modelId: string) =>
82
93
  Option.isSome(parseModelId(modelId)) ||
83
- `Invalid model "${modelId}", expected <provider>/<model>/<effort>`,
94
+ `Invalid model "${modelId}", expected <provider>/<model>/<effort> with effort one of ${Acp.ThoughtLevel.literals.join(", ")}`,
84
95
  ),
85
96
  ),
86
97
  )
@@ -192,6 +203,7 @@ const acpModel = Flag.String("model").pipe(
192
203
  ),
193
204
  Flag.withSchema(ModelId),
194
205
  Flag.withDefault("openai/gpt-6-astra/medium"),
206
+ Flag.map((modelId) => Option.getOrThrow(parseModelId(modelId))),
195
207
  )
196
208
 
197
209
  const acp = Command.make("acp", { model: acpModel, compaction }).pipe(
@@ -201,14 +213,15 @@ const acp = Command.make("acp", { model: acpModel, compaction }).pipe(
201
213
  Command.withHandler(({ model }) =>
202
214
  Acp.runStdio({
203
215
  version,
204
- defaultModel: model,
216
+ defaultModel: model.model,
217
+ defaultThoughtLevel: model.thoughtLevel,
205
218
  makeAgent: (cwd) =>
206
219
  Agent.make.pipe(
207
220
  Effect.provide(AgentExecutor.layerLocal({ directory: cwd })),
208
221
  ),
209
- makeModel: (modelId) =>
210
- Option.map(parseModelId(modelId), ({ provider, model, effort }) =>
211
- modelLayer(provider, model, effort),
222
+ makeModel: (modelId, thoughtLevel) =>
223
+ Option.map(parseAcpModelId(modelId), ({ provider, model }) =>
224
+ modelLayer(provider, model, thoughtLevel),
212
225
  ),
213
226
  }),
214
227
  ),