clanka 0.3.0 → 0.4.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 (69) hide show
  1. package/dist/Acp.d.ts.map +1 -1
  2. package/dist/Acp.js +3 -0
  3. package/dist/Acp.js.map +1 -1
  4. package/dist/Acp.test.js +58 -0
  5. package/dist/Acp.test.js.map +1 -1
  6. package/dist/Agent.d.ts.map +1 -1
  7. package/dist/Agent.js +86 -8
  8. package/dist/Agent.js.map +1 -1
  9. package/dist/Agent.test.js +671 -0
  10. package/dist/Agent.test.js.map +1 -1
  11. package/dist/AgentExecutor.d.ts +5 -0
  12. package/dist/AgentExecutor.d.ts.map +1 -1
  13. package/dist/AgentExecutor.js +22 -0
  14. package/dist/AgentExecutor.js.map +1 -1
  15. package/dist/AgentOutput.d.ts +49 -4
  16. package/dist/AgentOutput.d.ts.map +1 -1
  17. package/dist/AgentOutput.js +45 -0
  18. package/dist/AgentOutput.js.map +1 -1
  19. package/dist/AgentSkills.d.ts +56 -0
  20. package/dist/AgentSkills.d.ts.map +1 -0
  21. package/dist/AgentSkills.js +126 -0
  22. package/dist/AgentSkills.js.map +1 -0
  23. package/dist/AgentSkills.test.d.ts +2 -0
  24. package/dist/AgentSkills.test.d.ts.map +1 -0
  25. package/dist/AgentSkills.test.js +356 -0
  26. package/dist/AgentSkills.test.js.map +1 -0
  27. package/dist/Codex.js +1 -1
  28. package/dist/Codex.js.map +1 -1
  29. package/dist/Compaction.d.ts +342 -0
  30. package/dist/Compaction.d.ts.map +1 -0
  31. package/dist/Compaction.js +566 -0
  32. package/dist/Compaction.js.map +1 -0
  33. package/dist/Compaction.test.d.ts +2 -0
  34. package/dist/Compaction.test.d.ts.map +1 -0
  35. package/dist/Compaction.test.js +576 -0
  36. package/dist/Compaction.test.js.map +1 -0
  37. package/dist/CompactionTransport.test.d.ts +2 -0
  38. package/dist/CompactionTransport.test.d.ts.map +1 -0
  39. package/dist/CompactionTransport.test.js +123 -0
  40. package/dist/CompactionTransport.test.js.map +1 -0
  41. package/dist/Copilot.d.ts.map +1 -1
  42. package/dist/Copilot.js +7 -2
  43. package/dist/Copilot.js.map +1 -1
  44. package/dist/OutputFormatter.d.ts.map +1 -1
  45. package/dist/OutputFormatter.js +9 -0
  46. package/dist/OutputFormatter.js.map +1 -1
  47. package/dist/cli.js +14 -5
  48. package/dist/cli.js.map +1 -1
  49. package/dist/index.d.ts +5 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +5 -0
  52. package/dist/index.js.map +1 -1
  53. package/package.json +1 -1
  54. package/src/Acp.test.ts +85 -0
  55. package/src/Acp.ts +2 -0
  56. package/src/Agent.test.ts +994 -0
  57. package/src/Agent.ts +127 -6
  58. package/src/AgentExecutor.ts +27 -0
  59. package/src/AgentOutput.ts +58 -0
  60. package/src/AgentSkills.test.ts +652 -0
  61. package/src/AgentSkills.ts +155 -0
  62. package/src/Codex.ts +3 -1
  63. package/src/Compaction.test.ts +824 -0
  64. package/src/Compaction.ts +788 -0
  65. package/src/CompactionTransport.test.ts +228 -0
  66. package/src/Copilot.ts +8 -1
  67. package/src/OutputFormatter.ts +9 -0
  68. package/src/cli.ts +27 -6
  69. package/src/index.ts +6 -0
@@ -0,0 +1,228 @@
1
+ import { OpenAiClient } from "@effect/ai-openai"
2
+ import { OpenAiClient as CompatClient } from "@effect/ai-openai-compat"
3
+ import { assert, describe, it } from "@effect/vitest"
4
+ import * as Effect from "effect/Effect"
5
+ import * as Exit from "effect/Exit"
6
+ import * as Layer from "effect/Layer"
7
+ import * as Option from "effect/Option"
8
+ import * as Stream from "effect/Stream"
9
+ import * as AiError from "effect/unstable/ai/AiError"
10
+ import * as LanguageModel from "effect/unstable/ai/LanguageModel"
11
+ import * as Prompt from "effect/unstable/ai/Prompt"
12
+ import * as HttpClient from "effect/unstable/http/HttpClient"
13
+ import type * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"
14
+ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"
15
+ import * as Codex from "./Codex.ts"
16
+ import * as Compaction from "./Compaction.ts"
17
+ import * as Copilot from "./Copilot.ts"
18
+
19
+ const user = (text: string) =>
20
+ Prompt.makeMessage("user", {
21
+ content: [Prompt.makePart("text", { text })],
22
+ })
23
+ const history = Prompt.fromMessages([
24
+ user("old context ".repeat(1_000)),
25
+ user("continue"),
26
+ ])
27
+ const summaryPrompt = Compaction.summarizerPrompt({
28
+ previousSummary: Option.none(),
29
+ messages: history.content,
30
+ })
31
+
32
+ // Use the real provider model and client. Stop at the transport boundary so no
33
+ // credentials, network access, or permissive scripted LanguageModel are involved.
34
+ const captureHttp = () => {
35
+ const requests: Array<HttpClientRequest.HttpClientRequest> = []
36
+ const client = HttpClient.make((request) =>
37
+ Effect.sync(() => {
38
+ requests.push(request)
39
+ return HttpClientResponse.fromWeb(
40
+ request,
41
+ new Response(
42
+ JSON.stringify({
43
+ error: {
44
+ message: "transport probe",
45
+ type: "invalid_request_error",
46
+ },
47
+ }),
48
+ { status: 400, headers: { "content-type": "application/json" } },
49
+ ),
50
+ )
51
+ }),
52
+ )
53
+ return { requests, layer: Layer.succeed(HttpClient.HttpClient, client) }
54
+ }
55
+
56
+ const body = (
57
+ request: HttpClientRequest.HttpClientRequest,
58
+ ): Record<string, unknown> => {
59
+ if (request.body._tag !== "Uint8Array")
60
+ throw new Error("expected a JSON request body")
61
+ return JSON.parse(new TextDecoder().decode(request.body.body))
62
+ }
63
+
64
+ const compact = Compaction.compact({
65
+ prompt: history,
66
+ reason: "threshold",
67
+ }).pipe(
68
+ Effect.provide(Compaction.CompactionConfig.layer({ keepRecentTokens: 100 })),
69
+ )
70
+
71
+ const streamedSummary = Effect.gen(function* () {
72
+ const ai = yield* LanguageModel.LanguageModel
73
+ const transform = yield* Compaction.SummarizerTransform
74
+ return yield* transform(
75
+ ai.streamText({ prompt: summaryPrompt }).pipe(Stream.runDrain),
76
+ )
77
+ })
78
+
79
+ const socketError = AiError.make({
80
+ module: "test/socket",
81
+ method: "createResponseStream",
82
+ reason: new AiError.InvalidRequestError({ description: "transport probe" }),
83
+ })
84
+
85
+ describe("Compaction provider transport", () => {
86
+ it.effect("sets stream: true on the Codex HTTP summarization request", () =>
87
+ Effect.gen(function* () {
88
+ const http = captureHttp()
89
+ const exit = yield* compact.pipe(
90
+ Effect.provide(
91
+ Codex.model("gpt-6-astra").pipe(
92
+ Layer.provide(
93
+ OpenAiClient.layer({ apiUrl: "https://codex.test" }).pipe(
94
+ Layer.provide(http.layer),
95
+ ),
96
+ ),
97
+ ),
98
+ ),
99
+ Effect.exit,
100
+ )
101
+ assert.isTrue(Exit.isFailure(exit))
102
+ assert.strictEqual(http.requests.length, 1)
103
+ assert.strictEqual(http.requests[0]!.url, "https://codex.test/responses")
104
+ const request = body(http.requests[0]!)
105
+ assert.strictEqual(
106
+ request.stream,
107
+ true,
108
+ "Codex rejects non-streaming responses with HTTP 400",
109
+ )
110
+ assert.isFalse("max_output_tokens" in request)
111
+ assert.deepStrictEqual(request.tools ?? [], [])
112
+ }),
113
+ )
114
+
115
+ it.effect(
116
+ "routes compaction through the installed OpenAiSocket rather than HTTP",
117
+ () =>
118
+ Effect.gen(function* () {
119
+ const http = captureHttp()
120
+ const requests: Array<
121
+ Parameters<
122
+ OpenAiClient.OpenAiSocket["Service"]["createResponseStream"]
123
+ >[0]
124
+ > = []
125
+ const exit = yield* compact.pipe(
126
+ Effect.provideService(OpenAiClient.OpenAiSocket, {
127
+ createResponseStream: (request) =>
128
+ Effect.suspend(() => {
129
+ requests.push(request)
130
+ return Effect.fail(socketError)
131
+ }),
132
+ }),
133
+ Effect.provide(
134
+ Codex.model("gpt-6-astra").pipe(
135
+ Layer.provide(
136
+ OpenAiClient.layer({ apiUrl: "https://codex.test" }).pipe(
137
+ Layer.provide(http.layer),
138
+ ),
139
+ ),
140
+ ),
141
+ ),
142
+ Effect.exit,
143
+ )
144
+ assert.strictEqual(
145
+ http.requests.length,
146
+ 0,
147
+ "generateText bypasses the websocket and must not be used here",
148
+ )
149
+ assert.strictEqual(requests.length, 1)
150
+ assert.deepStrictEqual(exit, Exit.fail(socketError))
151
+ assert.isFalse("max_output_tokens" in requests[0]!)
152
+ assert.deepStrictEqual(requests[0]!.tools ?? [], [])
153
+ }),
154
+ )
155
+
156
+ it.effect(
157
+ "omits the unsupported Codex output-token cap even when streaming is selected",
158
+ () =>
159
+ Effect.gen(function* () {
160
+ const http = captureHttp()
161
+ yield* streamedSummary.pipe(
162
+ Effect.provide(
163
+ Codex.model("gpt-6-astra").pipe(
164
+ Layer.provide(
165
+ OpenAiClient.layer({ apiUrl: "https://codex.test" }).pipe(
166
+ Layer.provide(http.layer),
167
+ ),
168
+ ),
169
+ ),
170
+ ),
171
+ Effect.exit,
172
+ )
173
+ assert.strictEqual(http.requests.length, 1)
174
+ const request = body(http.requests[0]!)
175
+ assert.strictEqual(request.stream, true)
176
+ // The live Codex backend rejects this parameter; a 4k cap is unavailable.
177
+ assert.isFalse(
178
+ "max_output_tokens" in request,
179
+ "Codex does not support max_output_tokens",
180
+ )
181
+ assert.deepInclude(request.input, {
182
+ role: "system",
183
+ content: [{ type: "input_text", text: Compaction.summarizerSystem }],
184
+ })
185
+ }),
186
+ )
187
+
188
+ it.effect(
189
+ "retains Copilot's 4k wire-level cap only for the streamed summary",
190
+ () =>
191
+ Effect.gen(function* () {
192
+ const http = captureHttp()
193
+ yield* Effect.gen(function* () {
194
+ yield* streamedSummary.pipe(Effect.exit)
195
+ const ai = yield* LanguageModel.LanguageModel
196
+ yield* ai
197
+ .streamText({ prompt: "continue" })
198
+ .pipe(Stream.runDrain, Effect.exit)
199
+ }).pipe(
200
+ Effect.provide(
201
+ Copilot.model("gpt-4.1").pipe(
202
+ Layer.provide(
203
+ CompatClient.layer({ apiUrl: "https://copilot.test" }).pipe(
204
+ Layer.provide(http.layer),
205
+ ),
206
+ ),
207
+ ),
208
+ ),
209
+ )
210
+ assert.strictEqual(http.requests.length, 2)
211
+ assert.strictEqual(
212
+ http.requests[0]!.url,
213
+ "https://copilot.test/chat/completions",
214
+ )
215
+ const summary = body(http.requests[0]!)
216
+ const continuation = body(http.requests[1]!)
217
+ assert.strictEqual(summary.stream, true)
218
+ // The compatibility client translates max_output_tokens to max_tokens.
219
+ assert.strictEqual(summary.max_tokens, 4_000)
220
+ assert.deepStrictEqual(summary.tools ?? [], [])
221
+ assert.isFalse(
222
+ "max_tokens" in continuation,
223
+ "summary override must not leak into the next model call",
224
+ )
225
+ assert.strictEqual(continuation.stream, true)
226
+ }),
227
+ )
228
+ })
package/src/Copilot.ts CHANGED
@@ -6,6 +6,7 @@ import * as Layer from "effect/Layer"
6
6
  import * as Struct from "effect/Struct"
7
7
  import { API_URL, GithubCopilotAuth } from "./CopilotAuth.ts"
8
8
  import { AgentModelConfig } from "./Agent.ts"
9
+ import * as Compaction from "./Compaction.ts"
9
10
  import * as Model from "effect/unstable/ai/Model"
10
11
  import type * as LanguageModel from "effect/unstable/ai/LanguageModel"
11
12
 
@@ -34,7 +35,7 @@ export const model = (
34
35
  Model.make(
35
36
  "openai",
36
37
  model,
37
- Layer.merge(
38
+ Layer.mergeAll(
38
39
  OpenAiLanguageModel.layer({
39
40
  model,
40
41
  config: Struct.omit(options ?? {}, ["systemPromptTransform"]),
@@ -42,5 +43,11 @@ export const model = (
42
43
  AgentModelConfig.layer({
43
44
  systemPromptTransform: options?.systemPromptTransform,
44
45
  }),
46
+ // Cap compaction summaries; Copilot honours max_output_tokens.
47
+ Layer.succeed(Compaction.SummarizerTransform, (effect) =>
48
+ OpenAiLanguageModel.withConfigOverride(effect, {
49
+ max_output_tokens: Compaction.summarizerMaxOutputTokens,
50
+ }),
51
+ ),
45
52
  ),
46
53
  )
@@ -98,6 +98,15 @@ ${output.summary}\n\n`
98
98
  case "Usage": {
99
99
  return `${prefix}${chalkInfoHeading(`${infoIcon} Usage:`)} ${numberFormat.format(output.contextTokens)} context / ${numberFormat.format(output.inputTokens)} input / ${numberFormat.format(output.outputTokens)} output\n\n`
100
100
  }
101
+ case "ExecuteOutputCapped": {
102
+ return `${prefix}${chalkInfoHeading(`${infoIcon} Output capped:`)} ${numberFormat.format(output.charsBefore)} chars reduced to ${numberFormat.format(output.charsAfter)}\n\n`
103
+ }
104
+ case "CompactionStarted": {
105
+ return `${prefix}${chalkInfoHeading(`${infoIcon} Compacting context (${output.reason})...`)}\n\n`
106
+ }
107
+ case "CompactionEnded": {
108
+ return `${prefix}${chalkInfoHeading(`${infoIcon} Context compacted (${output.reason}):`)} ~${numberFormat.format(output.tokensBefore)} tokens to ~${numberFormat.format(output.tokensAfter)}\n\n`
109
+ }
101
110
  }
102
111
  }),
103
112
  Stream.catchIf(
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import * as AgentExecutor from "./AgentExecutor.ts"
12
12
  import * as Codex from "./Codex.ts"
13
13
  import * as Copilot from "./Copilot.ts"
14
14
  import * as Agent from "./Agent.ts"
15
+ import * as Compaction from "./Compaction.ts"
15
16
  import * as Stream from "effect/Stream"
16
17
  import * as OutputFormatter from "./OutputFormatter.ts"
17
18
  import * as Stdio from "effect/Stdio"
@@ -28,7 +29,7 @@ import * as Option from "effect/Option"
28
29
  import { OpenAiClient, OpenAiEmbeddingModel } from "@effect/ai-openai"
29
30
  import { DeviceCodeHandler } from "./index.ts"
30
31
 
31
- const version = "0.0.1"
32
+ const version = "1.0.0"
32
33
 
33
34
  type Provider = "openai" | "copilot"
34
35
 
@@ -122,6 +123,16 @@ const prompt = Flag.String("prompt").pipe(
122
123
  Flag.optional,
123
124
  )
124
125
 
126
+ // Kill switch: `--no-compaction` or CLANKA_COMPACTION=false.
127
+ const compaction = Flag.Boolean("compaction").pipe(
128
+ Flag.withDescription(
129
+ "Auto-compact the conversation history when it nears the context window. Disable with --no-compaction or CLANKA_COMPACTION=false; the execute output cap stays on.",
130
+ ),
131
+ Flag.withFallbackConfig(
132
+ Config.Boolean("CLANKA_COMPACTION").pipe(Config.withDefault(true)),
133
+ ),
134
+ )
135
+
125
136
  const Kvs = Layer.unwrap(
126
137
  Effect.gen(function* () {
127
138
  const path = yield* Path.Path
@@ -182,7 +193,7 @@ const acpModel = Flag.String("model").pipe(
182
193
  Flag.withDefault("openai/gpt-6-astra/medium"),
183
194
  )
184
195
 
185
- const acp = Command.make("acp", { model: acpModel }).pipe(
196
+ const acp = Command.make("acp", { model: acpModel, compaction }).pipe(
186
197
  Command.withDescription(
187
198
  "Run as an Agent Client Protocol (ACP) server over stdio",
188
199
  ),
@@ -200,16 +211,23 @@ const acp = Command.make("acp", { model: acpModel }).pipe(
200
211
  ),
201
212
  }),
202
213
  ),
203
- Command.provide(
214
+ Command.provide(({ compaction }) =>
204
215
  Layer.mergeAll(
205
216
  Agent.ConversationMode.layer(true),
206
217
  Layer.succeed(Logger.LogToStderr, true),
207
218
  DeviceCodeHandler.layerLog,
219
+ Compaction.CompactionConfig.layer({ enabled: compaction }),
208
220
  ),
209
221
  ),
210
222
  )
211
223
 
212
- Command.make("clanka", { provider, model, semantic, prompt }).pipe(
224
+ Command.make("clanka", {
225
+ provider,
226
+ model,
227
+ semantic,
228
+ prompt,
229
+ compaction,
230
+ }).pipe(
213
231
  Command.withHandler(
214
232
  Effect.fnUntraced(function* ({
215
233
  provider,
@@ -264,8 +282,11 @@ Command.make("clanka", { provider, model, semantic, prompt }).pipe(
264
282
  )
265
283
  }),
266
284
  ),
267
- Command.provide(({ prompt }) =>
268
- Agent.ConversationMode.layer(Option.isNone(prompt)),
285
+ Command.provide(({ prompt, compaction }) =>
286
+ Layer.mergeAll(
287
+ Agent.ConversationMode.layer(Option.isNone(prompt)),
288
+ Compaction.CompactionConfig.layer({ enabled: compaction }),
289
+ ),
269
290
  ),
270
291
  Command.withSubcommands([acp]),
271
292
  Command.run({
package/src/index.ts CHANGED
@@ -12,6 +12,7 @@ export * as Agent from "./Agent.ts"
12
12
  * @since 1.0.0
13
13
  */
14
14
  export * as AgentExecutor from "./AgentExecutor.ts"
15
+ export * as AgentSkills from "./AgentSkills.ts"
15
16
 
16
17
  /**
17
18
  * @since 1.0.0
@@ -33,6 +34,11 @@ export * as Codex from "./Codex.ts"
33
34
  */
34
35
  export * as CodeChunker from "./CodeChunker.ts"
35
36
 
37
+ /**
38
+ * @since 1.0.0
39
+ */
40
+ export * as Compaction from "./Compaction.ts"
41
+
36
42
  /**
37
43
  * @since 1.0.0
38
44
  */