clanka 0.2.72 → 0.3.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.
package/src/Acp.ts ADDED
@@ -0,0 +1,596 @@
1
+ /** @effect-diagnostics schemaNumber:off */
2
+ /**
3
+ * Agent Client Protocol (ACP) server for clanka.
4
+ *
5
+ * Speaks JSON-RPC 2.0 over stdio so ACP clients (Zed, Multica, ...) can drive
6
+ * an Agent. Sessions are persisted to the KeyValueStore so `session/load`
7
+ * works across processes.
8
+ *
9
+ * @since 1.0.0
10
+ */
11
+ import { randomUUID } from "node:crypto"
12
+ import * as Cause from "effect/Cause"
13
+ import * as Effect from "effect/Effect"
14
+ import * as Exit from "effect/Exit"
15
+ import * as Fiber from "effect/Fiber"
16
+ import type * as Layer from "effect/Layer"
17
+ import * as MutableRef from "effect/MutableRef"
18
+ import * as Option from "effect/Option"
19
+ import type * as PlatformError from "effect/PlatformError"
20
+ import * as Queue from "effect/Queue"
21
+ import * as Schema from "effect/Schema"
22
+ import * as Scope from "effect/Scope"
23
+ import * as Stdio from "effect/Stdio"
24
+ import * as Stream from "effect/Stream"
25
+ import type * as LanguageModel from "effect/unstable/ai/LanguageModel"
26
+ import type * as Model from "effect/unstable/ai/Model"
27
+ import * as Prompt from "effect/unstable/ai/Prompt"
28
+ import * as KeyValueStore from "effect/unstable/persistence/KeyValueStore"
29
+ import type * as Agent from "./Agent.ts"
30
+ import type * as AgentOutput from "./AgentOutput.ts"
31
+
32
+ /**
33
+ * @since 1.0.0
34
+ * @category Models
35
+ */
36
+ export type ModelServices =
37
+ | LanguageModel.LanguageModel
38
+ | Model.ProviderName
39
+ | Model.ModelName
40
+ | Agent.SubagentModel
41
+
42
+ /**
43
+ * @since 1.0.0
44
+ * @category Models
45
+ */
46
+ export interface Options<RAgent, RModel> {
47
+ readonly version: string
48
+ /**
49
+ * Model id used when a client does not select one. Model ids are opaque to
50
+ * the server and resolved with `makeModel`.
51
+ */
52
+ readonly defaultModel: string
53
+ /**
54
+ * Write one JSON-RPC message to the client.
55
+ */
56
+ readonly send: (message: object) => Effect.Effect<void>
57
+ /**
58
+ * Create the Agent backing a session rooted at `cwd`.
59
+ */
60
+ readonly makeAgent: (
61
+ cwd: string,
62
+ ) => Effect.Effect<Agent.Agent, never, Scope.Scope | RAgent>
63
+ /**
64
+ * Resolve a model id to the services an Agent needs. `None` rejects the id.
65
+ */
66
+ readonly makeModel: (
67
+ modelId: string,
68
+ ) => Option.Option<Layer.Layer<ModelServices, never, RModel>>
69
+ }
70
+
71
+ /**
72
+ * @since 1.0.0
73
+ * @category Models
74
+ */
75
+ export interface Server<R = never> {
76
+ /**
77
+ * Handle one line of JSON-RPC input. Completes once the message has been
78
+ * fully processed, including any responses sent.
79
+ */
80
+ readonly handle: (line: string) => Effect.Effect<void, never, R>
81
+ }
82
+
83
+ /**
84
+ * @since 1.0.0
85
+ * @category Models
86
+ */
87
+ export class SessionRecord extends Schema.Class<SessionRecord>(
88
+ "clanka/Acp/SessionRecord",
89
+ )({
90
+ cwd: Schema.String,
91
+ model: Schema.String,
92
+ history: Prompt.Prompt,
93
+ }) {}
94
+
95
+ /**
96
+ * @since 1.0.0
97
+ * @category Errors
98
+ */
99
+ export class RpcError extends Schema.TaggedError<RpcError>()("AcpRpcError", {
100
+ code: Schema.Number,
101
+ message: Schema.String,
102
+ }) {}
103
+
104
+ const RequestId = Schema.Union([Schema.Number, Schema.String])
105
+
106
+ const IncomingMessage = Schema.Struct({
107
+ id: Schema.optionalKey(RequestId),
108
+ method: Schema.optionalKey(Schema.String),
109
+ params: Schema.optionalKey(Schema.Unknown),
110
+ })
111
+
112
+ const decodeIncoming = Schema.decodeEffect(
113
+ Schema.fromJsonString(IncomingMessage),
114
+ )
115
+
116
+ const NewSessionParams = Schema.Struct({
117
+ cwd: Schema.String,
118
+ model: Schema.optionalKey(Schema.String),
119
+ })
120
+
121
+ const LoadSessionParams = Schema.Struct({
122
+ sessionId: Schema.String,
123
+ cwd: Schema.optionalKey(Schema.String),
124
+ model: Schema.optionalKey(Schema.String),
125
+ })
126
+
127
+ const SessionIdParams = Schema.Struct({
128
+ sessionId: Schema.String,
129
+ })
130
+
131
+ const SetModelParams = Schema.Struct({
132
+ sessionId: Schema.String,
133
+ modelId: Schema.String,
134
+ })
135
+
136
+ const TextBlock = Schema.Struct({
137
+ type: Schema.Literal("text"),
138
+ text: Schema.String,
139
+ })
140
+
141
+ const ResourceLinkBlock = Schema.Struct({
142
+ type: Schema.Literal("resource_link"),
143
+ uri: Schema.String,
144
+ name: Schema.optionalKey(Schema.String),
145
+ })
146
+
147
+ const ResourceBlock = Schema.Struct({
148
+ type: Schema.Literal("resource"),
149
+ resource: Schema.Struct({
150
+ uri: Schema.String,
151
+ text: Schema.optionalKey(Schema.String),
152
+ }),
153
+ })
154
+
155
+ const ContentBlock = Schema.Union([TextBlock, ResourceLinkBlock, ResourceBlock])
156
+
157
+ const PromptParams = Schema.Struct({
158
+ sessionId: Schema.String,
159
+ prompt: Schema.Array(ContentBlock),
160
+ })
161
+
162
+ const decodeParams = <S extends Schema.Top>(schema: S, params: unknown) =>
163
+ Schema.decodeUnknownEffect(schema)(params).pipe(
164
+ Effect.mapError(
165
+ (error) => new RpcError({ code: -32602, message: error.message }),
166
+ ),
167
+ )
168
+
169
+ const sessionNotFound = (sessionId: string) =>
170
+ new RpcError({ code: -32602, message: `Session not found: ${sessionId}` })
171
+
172
+ const toRpcError = (cause: Cause.Cause<unknown>) => {
173
+ const error = Cause.squash(cause)
174
+ return error instanceof RpcError
175
+ ? error
176
+ : new RpcError({ code: -32603, message: Cause.pretty(cause) })
177
+ }
178
+
179
+ const renderPrompt = (
180
+ blocks: ReadonlyArray<typeof ContentBlock.Type>,
181
+ ): string =>
182
+ blocks
183
+ .map((block) => {
184
+ switch (block.type) {
185
+ case "text":
186
+ return block.text
187
+ case "resource_link":
188
+ return `[${block.name ?? block.uri}](${block.uri})`
189
+ case "resource":
190
+ return block.resource.text === undefined
191
+ ? `[${block.resource.uri}](${block.resource.uri})`
192
+ : `<resource uri="${block.resource.uri}">\n${block.resource.text}\n</resource>`
193
+ }
194
+ })
195
+ .join("\n\n")
196
+
197
+ const textContent = (text: string) => ({ type: "text", text })
198
+
199
+ const historyUpdates = (history: Prompt.Prompt) => {
200
+ const updates: Array<object> = []
201
+ for (const message of history.content) {
202
+ if (message.role !== "user" && message.role !== "assistant") continue
203
+ const sessionUpdate =
204
+ message.role === "user" ? "user_message_chunk" : "agent_message_chunk"
205
+ for (const part of message.content) {
206
+ if (part.type === "text") {
207
+ updates.push({ sessionUpdate, content: textContent(part.text) })
208
+ }
209
+ }
210
+ }
211
+ return updates
212
+ }
213
+
214
+ interface Session {
215
+ readonly id: string
216
+ readonly cwd: string
217
+ model: string
218
+ readonly agent: Agent.Agent
219
+ running: Fiber.Fiber<void, unknown> | undefined
220
+ toolCalls: number
221
+ }
222
+
223
+ /**
224
+ * @since 1.0.0
225
+ * @category Constructors
226
+ */
227
+ export const make = Effect.fnUntraced(function* <RAgent, RModel>(
228
+ options: Options<RAgent, RModel>,
229
+ ): Effect.fn.Return<
230
+ Server<RAgent | RModel>,
231
+ never,
232
+ KeyValueStore.KeyValueStore | Scope.Scope
233
+ > {
234
+ const kvs = yield* KeyValueStore.KeyValueStore
235
+ const store = KeyValueStore.toSchemaStore(
236
+ KeyValueStore.prefix(kvs, "session-"),
237
+ SessionRecord,
238
+ )
239
+ const scope = yield* Effect.scope
240
+ const sessions = new Map<string, Session>()
241
+
242
+ const notify = (method: string, params: object) =>
243
+ options.send({ jsonrpc: "2.0", method, params })
244
+
245
+ const update = (sessionId: string, update: object) =>
246
+ notify("session/update", { sessionId, update })
247
+
248
+ const getSession = (sessionId: string) =>
249
+ Effect.fromOption(Option.fromNullishOr(sessions.get(sessionId)), () =>
250
+ sessionNotFound(sessionId),
251
+ )
252
+
253
+ const requireModel = (modelId: string) =>
254
+ Effect.fromOption(
255
+ options.makeModel(modelId),
256
+ () =>
257
+ new RpcError({ code: -32602, message: `Unknown model: ${modelId}` }),
258
+ )
259
+
260
+ const modelsInfo = (session: Session) => ({
261
+ availableModels: [...new Set([session.model, options.defaultModel])].map(
262
+ (modelId) => ({ modelId, name: modelId }),
263
+ ),
264
+ currentModelId: session.model,
265
+ })
266
+
267
+ const persist = (session: Session) =>
268
+ store
269
+ .set(
270
+ session.id,
271
+ new SessionRecord({
272
+ cwd: session.cwd,
273
+ model: session.model,
274
+ history: session.agent.history.current,
275
+ }),
276
+ )
277
+ .pipe(
278
+ Effect.catch((error) =>
279
+ Effect.logWarning("Failed to persist session", error),
280
+ ),
281
+ )
282
+
283
+ const openSession = Effect.fnUntraced(function* (
284
+ id: string,
285
+ record: SessionRecord,
286
+ ) {
287
+ const agent = yield* Scope.provide(options.makeAgent(record.cwd), scope)
288
+ MutableRef.set(agent.history, record.history)
289
+ const session: Session = {
290
+ id,
291
+ cwd: record.cwd,
292
+ model: record.model,
293
+ agent,
294
+ running: undefined,
295
+ toolCalls: 0,
296
+ }
297
+ sessions.set(id, session)
298
+ return session
299
+ })
300
+
301
+ const replayHistory = (session: Session) =>
302
+ Effect.forEach(
303
+ historyUpdates(session.agent.history.current),
304
+ (sessionUpdate) => update(session.id, sessionUpdate),
305
+ { discard: true },
306
+ )
307
+
308
+ const runTurn = Effect.fnUntraced(function* (
309
+ session: Session,
310
+ prompt: string,
311
+ ) {
312
+ const stream = yield* session.agent.send({ prompt })
313
+ let script = ""
314
+ let toolCallId = ""
315
+
316
+ const emit = (part: AgentOutput.Output): Effect.Effect<void> => {
317
+ switch (part._tag) {
318
+ case "ReasoningDelta":
319
+ return update(session.id, {
320
+ sessionUpdate: "agent_thought_chunk",
321
+ content: textContent(part.delta),
322
+ })
323
+ case "ScriptStart":
324
+ script = ""
325
+ return Effect.void
326
+ case "ScriptDelta":
327
+ script += part.delta
328
+ return Effect.void
329
+ case "ScriptEnd":
330
+ toolCallId = `execute-${++session.toolCalls}`
331
+ return update(session.id, {
332
+ sessionUpdate: "tool_call",
333
+ toolCallId,
334
+ title: "execute",
335
+ kind: "execute",
336
+ status: "in_progress",
337
+ rawInput: { script },
338
+ content: [{ type: "content", content: textContent(script) }],
339
+ })
340
+ case "ScriptOutput":
341
+ return update(session.id, {
342
+ sessionUpdate: "tool_call_update",
343
+ toolCallId,
344
+ status: "completed",
345
+ rawOutput: { output: part.output },
346
+ content: [{ type: "content", content: textContent(part.output) }],
347
+ })
348
+ case "Usage":
349
+ return update(session.id, {
350
+ sessionUpdate: "usage_update",
351
+ usage: {
352
+ inputTokens: part.inputTokens,
353
+ outputTokens: part.outputTokens,
354
+ },
355
+ })
356
+ case "ErrorRetry":
357
+ return update(session.id, {
358
+ sessionUpdate: "agent_thought_chunk",
359
+ content: textContent(
360
+ `Retrying after error: ${part.error.message}\n`,
361
+ ),
362
+ })
363
+ case "SubagentStart":
364
+ return update(session.id, {
365
+ sessionUpdate: "tool_call",
366
+ toolCallId: `subagent-${part.id}`,
367
+ title: "subagent",
368
+ kind: "think",
369
+ status: "in_progress",
370
+ rawInput: { prompt: part.prompt },
371
+ })
372
+ case "SubagentComplete":
373
+ return update(session.id, {
374
+ sessionUpdate: "tool_call_update",
375
+ toolCallId: `subagent-${part.id}`,
376
+ status: "completed",
377
+ rawOutput: { summary: part.summary },
378
+ content: [{ type: "content", content: textContent(part.summary) }],
379
+ })
380
+ default:
381
+ return Effect.void
382
+ }
383
+ }
384
+
385
+ yield* stream.pipe(
386
+ Stream.runForEach(emit),
387
+ Effect.catchTag("AgentFinished", (finished) =>
388
+ update(session.id, {
389
+ sessionUpdate: "agent_message_chunk",
390
+ content: textContent(finished.summary),
391
+ }),
392
+ ),
393
+ )
394
+ })
395
+
396
+ const initialize = Effect.succeed({
397
+ protocolVersion: 1,
398
+ agentCapabilities: {
399
+ loadSession: true,
400
+ promptCapabilities: { image: false, audio: false, embeddedContext: true },
401
+ mcpCapabilities: { http: false, sse: false },
402
+ },
403
+ authMethods: [],
404
+ agentInfo: { name: "clanka", version: options.version },
405
+ })
406
+
407
+ const newSession = Effect.fnUntraced(function* (params: unknown) {
408
+ const { cwd, model } = yield* decodeParams(NewSessionParams, params)
409
+ const modelId = model ?? options.defaultModel
410
+ yield* requireModel(modelId)
411
+ const session = yield* openSession(
412
+ randomUUID(),
413
+ new SessionRecord({ cwd, model: modelId, history: Prompt.empty }),
414
+ )
415
+ yield* persist(session)
416
+ return { sessionId: session.id, models: modelsInfo(session) }
417
+ })
418
+
419
+ const loadSession = Effect.fnUntraced(function* (
420
+ params: unknown,
421
+ replay: boolean,
422
+ ) {
423
+ const { sessionId, cwd, model } = yield* decodeParams(
424
+ LoadSessionParams,
425
+ params,
426
+ )
427
+ const existing = sessions.get(sessionId)
428
+ if (existing !== undefined) {
429
+ return { models: modelsInfo(existing) }
430
+ }
431
+ const record = yield* store
432
+ .get(sessionId)
433
+ .pipe(
434
+ Effect.mapError(
435
+ (error) => new RpcError({ code: -32603, message: error.message }),
436
+ ),
437
+ )
438
+ if (Option.isNone(record)) {
439
+ return yield* sessionNotFound(sessionId)
440
+ }
441
+ const modelId = model ?? record.value.model
442
+ yield* requireModel(modelId)
443
+ const session = yield* openSession(
444
+ sessionId,
445
+ new SessionRecord({
446
+ cwd: cwd ?? record.value.cwd,
447
+ model: modelId,
448
+ history: record.value.history,
449
+ }),
450
+ )
451
+ if (replay) {
452
+ yield* replayHistory(session)
453
+ }
454
+ return { models: modelsInfo(session) }
455
+ })
456
+
457
+ const setModel = Effect.fnUntraced(function* (params: unknown) {
458
+ const { sessionId, modelId } = yield* decodeParams(SetModelParams, params)
459
+ const session = yield* getSession(sessionId)
460
+ yield* requireModel(modelId)
461
+ session.model = modelId
462
+ yield* persist(session)
463
+ return {}
464
+ })
465
+
466
+ const prompt = Effect.fnUntraced(function* (params: unknown) {
467
+ const { sessionId, prompt } = yield* decodeParams(PromptParams, params)
468
+ const session = yield* getSession(sessionId)
469
+ if (session.running !== undefined) {
470
+ return yield* new RpcError({
471
+ code: -32000,
472
+ message: `Session ${sessionId} already has a prompt in progress`,
473
+ })
474
+ }
475
+ const model = yield* requireModel(session.model)
476
+ session.running = yield* runTurn(session, renderPrompt(prompt)).pipe(
477
+ Effect.provide(model),
478
+ Effect.scoped,
479
+ Effect.forkChild,
480
+ )
481
+ const exit = yield* Effect.exit(Fiber.join(session.running))
482
+ session.running = undefined
483
+ yield* persist(session)
484
+ if (Exit.isSuccess(exit)) return { stopReason: "end_turn" }
485
+ if (Cause.hasInterruptsOnly(exit.cause)) return { stopReason: "cancelled" }
486
+ return yield* toRpcError(exit.cause)
487
+ })
488
+
489
+ const cancel = Effect.fnUntraced(function* (params: unknown) {
490
+ const { sessionId } = yield* decodeParams(SessionIdParams, params)
491
+ const running = sessions.get(sessionId)?.running
492
+ if (running !== undefined) {
493
+ yield* Fiber.interrupt(running)
494
+ }
495
+ })
496
+
497
+ const dispatch = (
498
+ method: string,
499
+ params: unknown,
500
+ ): Effect.Effect<object, RpcError, RAgent | RModel> => {
501
+ switch (method) {
502
+ case "initialize":
503
+ return initialize
504
+ case "authenticate":
505
+ case "session/set_mode":
506
+ case "session/set_config_option":
507
+ return Effect.succeed({})
508
+ case "session/new":
509
+ return newSession(params)
510
+ case "session/load":
511
+ return loadSession(params, true)
512
+ case "session/resume":
513
+ return loadSession(params, false)
514
+ case "session/set_model":
515
+ return setModel(params)
516
+ case "session/prompt":
517
+ return prompt(params)
518
+ default:
519
+ return Effect.fail(
520
+ new RpcError({
521
+ code: -32601,
522
+ message: `Method not found: ${method}`,
523
+ }),
524
+ )
525
+ }
526
+ }
527
+
528
+ const handle = Effect.fnUntraced(
529
+ function* (line: string) {
530
+ if (line.trim() === "") return
531
+ const message = yield* decodeIncoming(line)
532
+ // Responses to requests are ignored: the server never sends requests
533
+ if (message.method === undefined) return
534
+ if (message.id === undefined) {
535
+ if (message.method === "session/cancel") {
536
+ yield* cancel(message.params)
537
+ }
538
+ return
539
+ }
540
+ const id = message.id
541
+ yield* dispatch(message.method, message.params).pipe(
542
+ Effect.matchCauseEffect({
543
+ onSuccess: (result) => options.send({ jsonrpc: "2.0", id, result }),
544
+ onFailure: (cause) => {
545
+ const error = toRpcError(cause)
546
+ return options.send({
547
+ jsonrpc: "2.0",
548
+ id,
549
+ error: { code: error.code, message: error.message },
550
+ })
551
+ },
552
+ }),
553
+ )
554
+ },
555
+ Effect.catch((error) =>
556
+ Effect.logWarning("Ignoring invalid JSON-RPC message", error),
557
+ ),
558
+ )
559
+
560
+ return { handle }
561
+ })
562
+
563
+ /**
564
+ * Run the ACP server over the process stdio.
565
+ *
566
+ * @since 1.0.0
567
+ * @category Constructors
568
+ */
569
+ export const runStdio = <RAgent, RModel>(
570
+ options: Omit<Options<RAgent, RModel>, "send">,
571
+ ): Effect.Effect<
572
+ void,
573
+ PlatformError.PlatformError,
574
+ Stdio.Stdio | KeyValueStore.KeyValueStore | RAgent | RModel
575
+ > =>
576
+ Effect.gen(function* () {
577
+ const stdio = yield* Stdio.Stdio
578
+ const output = yield* Queue.make<string, Cause.Done>()
579
+ const writer = yield* Stream.fromQueue(output).pipe(
580
+ Stream.map((line) => `${line}\n`),
581
+ Stream.run(stdio.stdout()),
582
+ Effect.forkScoped,
583
+ )
584
+ const server = yield* make({
585
+ ...options,
586
+ send: (message) =>
587
+ Queue.offer(output, JSON.stringify(message)).pipe(Effect.asVoid),
588
+ })
589
+ yield* stdio.stdin.pipe(
590
+ Stream.decodeText,
591
+ Stream.splitLines,
592
+ Stream.runForEach((line) => Effect.forkScoped(server.handle(line))),
593
+ )
594
+ yield* Queue.end(output)
595
+ yield* Fiber.join(writer)
596
+ }).pipe(Effect.scoped)
@@ -1,5 +1,5 @@
1
1
  import * as Console from "effect/Console"
2
- import type * as Effect from "effect/Effect"
2
+ import * as Effect from "effect/Effect"
3
3
  import * as Layer from "effect/Layer"
4
4
  import * as Context from "effect/Context"
5
5
 
@@ -19,3 +19,10 @@ export const layerConsole = Layer.succeed(DeviceCodeHandler, {
19
19
  `Open ${options.verifyUrl} and enter code ${options.deviceCode}.`,
20
20
  ),
21
21
  })
22
+
23
+ export const layerLog = Layer.succeed(DeviceCodeHandler, {
24
+ onCode: (options) =>
25
+ Effect.logWarning(
26
+ `Open ${options.verifyUrl} and enter code ${options.deviceCode}.`,
27
+ ),
28
+ })