opencode-discord-bot 0.0.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +13 -0
  3. package/package.json +78 -0
  4. package/src/Bridge/LoopbackServer.test.ts +94 -0
  5. package/src/Bridge/LoopbackServer.ts +77 -0
  6. package/src/Bridge/ToolControl.test.ts +245 -0
  7. package/src/Bridge/ToolControl.ts +260 -0
  8. package/src/Bridge/ToolControlEdges.test.ts +49 -0
  9. package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
  10. package/src/Config.test.ts +142 -0
  11. package/src/Config.ts +295 -0
  12. package/src/ConfigSchema.ts +46 -0
  13. package/src/ConfigTypes.ts +11 -0
  14. package/src/Discord/ChatSdkDiscord.test.ts +257 -0
  15. package/src/Discord/ChatSdkDiscord.ts +206 -0
  16. package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
  17. package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
  18. package/src/Discord/DiscordGateway.test.ts +215 -0
  19. package/src/Discord/DiscordGateway.ts +140 -0
  20. package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
  21. package/src/Discord/DiscordJsDiscord.test.ts +208 -0
  22. package/src/Discord/DiscordJsDiscord.ts +267 -0
  23. package/src/Discord/DiscordPort.ts +30 -0
  24. package/src/Discord/MemoryDiscord.test.ts +44 -0
  25. package/src/Discord/MemoryDiscord.ts +85 -0
  26. package/src/Discord/Safety.ts +11 -0
  27. package/src/Main.test.ts +273 -0
  28. package/src/Main.ts +192 -0
  29. package/src/MainQueue.test.ts +124 -0
  30. package/src/Opencode/EventMapping.test.ts +188 -0
  31. package/src/Opencode/EventMapping.ts +232 -0
  32. package/src/Opencode/EventMappingState.ts +97 -0
  33. package/src/Opencode/MemoryOpencode.test.ts +18 -0
  34. package/src/Opencode/MemoryOpencode.ts +29 -0
  35. package/src/Opencode/OpencodePort.ts +30 -0
  36. package/src/Opencode/PromptParts.ts +47 -0
  37. package/src/Opencode/SdkOpencode.test.ts +280 -0
  38. package/src/Opencode/SdkOpencode.ts +270 -0
  39. package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
  40. package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
  41. package/src/Orchestrator/ContextAssembly.test.ts +115 -0
  42. package/src/Orchestrator/ContextAssembly.ts +120 -0
  43. package/src/Orchestrator/Orchestrator.ts +67 -0
  44. package/src/Orchestrator/StopCommand.test.ts +20 -0
  45. package/src/Orchestrator/StopCommand.ts +14 -0
  46. package/src/Orchestrator/Triggering.test.ts +56 -0
  47. package/src/Orchestrator/Triggering.ts +26 -0
  48. package/src/Orchestrator/TurnManager.test.ts +180 -0
  49. package/src/Orchestrator/TurnManager.ts +179 -0
  50. package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
  51. package/src/PublicContracts.test.ts +43 -0
  52. package/src/Render/Renderer.test.ts +249 -0
  53. package/src/Render/Renderer.ts +159 -0
  54. package/src/Render/Splitting.test.ts +30 -0
  55. package/src/Render/Splitting.ts +68 -0
  56. package/src/Schema.ts +93 -0
  57. package/src/Tools/Scaffolding.test.ts +56 -0
  58. package/src/Tools/Scaffolding.ts +60 -0
@@ -0,0 +1,188 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import { decodeOpencodeEvent, opencodeEventStream } from "./EventMapping.ts"
4
+
5
+ describe("opencode event mapping", () => {
6
+ test("maps lifecycle, text delta, snapshot, tool, changed-file, and error events", async () => {
7
+ const events = await Effect.runPromise(
8
+ opencodeEventStream(
9
+ Stream.fromIterable([
10
+ { id: "e1", type: "session.idle", properties: { sessionID: "s1" } },
11
+ { id: "e2", type: "session.next.reasoning.delta", properties: { delta: "thinking" } },
12
+ { id: "e3", type: "session.next.text.delta", properties: { delta: "hello" } },
13
+ { id: "e4", type: "session.next.text.ended", properties: { text: "snapshot" } },
14
+ {
15
+ id: "e5-role",
16
+ type: "message.updated",
17
+ properties: { sessionID: "s1", info: { id: "assistant-message", role: "assistant" } }
18
+ },
19
+ {
20
+ id: "e5",
21
+ type: "message.part.delta",
22
+ properties: { messageID: "assistant-message", part: { messageID: "assistant-message", type: "text" }, delta: " fallback" }
23
+ },
24
+ {
25
+ id: "e6",
26
+ type: "message.part.updated",
27
+ properties: { part: { messageID: "assistant-message", type: "text", text: "updated" } }
28
+ },
29
+ { id: "e7", type: "session.next.tool.called", properties: { tool: "Running tests" } },
30
+ { id: "e8", type: "session.next.tool.success", properties: {} },
31
+ { id: "e9", type: "session.next.tool.failed", properties: { error: { message: "tool failed" } } },
32
+ { id: "e10", type: "session.next.step.started", properties: { step: { type: "tool", title: "Editing file" } } },
33
+ { id: "e11", type: "session.next.step.ended", properties: {} },
34
+ { id: "e12", type: "session.diff", properties: { diff: [{ path: "a.ts" }, { path: "b.ts" }] } },
35
+ { id: "e13", type: "session.error", properties: { error: { message: "boom" } } }
36
+ ])
37
+ ).pipe(Stream.runCollect)
38
+ )
39
+
40
+ expect(events).toEqual([
41
+ { type: "idle" },
42
+ { type: "text-delta", text: "hello" },
43
+ { type: "text-snapshot", text: "snapshot" },
44
+ { type: "text-delta", text: " fallback" },
45
+ { type: "text-snapshot", text: "updated" },
46
+ { type: "tool-start", title: "Running tests" },
47
+ { type: "tool-end" },
48
+ { type: "error", message: "tool failed" },
49
+ { type: "tool-start", title: "Editing file" },
50
+ { type: "tool-end" },
51
+ { type: "changed-files", files: 2, insertions: 0, deletions: 0 },
52
+ { type: "error", message: "boom" }
53
+ ])
54
+ })
55
+
56
+ test("ignores unknown or non-text event payloads", () => {
57
+ expect(decodeOpencodeEvent({ type: "unknown" })).toBeUndefined()
58
+ expect(decodeOpencodeEvent({ type: "session.next.reasoning.delta", properties: { delta: "hidden" } })).toBeUndefined()
59
+ expect(decodeOpencodeEvent({ type: "message.part.delta", part: { type: "reasoning" }, delta: "hidden" })).toBeUndefined()
60
+ })
61
+
62
+ test("maps wrapped SDK events and delta-bearing part updates", () => {
63
+ expect(
64
+ decodeOpencodeEvent({
65
+ directory: "/repo",
66
+ payload: { id: "e1", type: "session.next.text.delta", properties: { sessionID: "s1", delta: "wrapped" } }
67
+ })
68
+ ).toEqual({ type: "text-delta", text: "wrapped" })
69
+ expect(
70
+ decodeOpencodeEvent({
71
+ type: "message.part.updated",
72
+ properties: { sessionID: "s1", part: { type: "text", text: "hello" }, delta: "lo" }
73
+ })
74
+ ).toBeUndefined()
75
+ expect(
76
+ decodeOpencodeEvent(
77
+ {
78
+ type: "message.part.updated",
79
+ properties: { sessionID: "s1", part: { type: "text", text: "hello" }, delta: "lo" }
80
+ },
81
+ { includeGenericMessageParts: true }
82
+ )
83
+ ).toEqual({ type: "text-delta", text: "lo" })
84
+ expect(
85
+ decodeOpencodeEvent({ type: "message.part.delta", properties: { sessionID: "s1", field: "reasoning", delta: "hidden" } })
86
+ ).toBeUndefined()
87
+ expect(
88
+ decodeOpencodeEvent(
89
+ { type: "message.part.delta", properties: { sessionID: "s1", field: "text", delta: "ambiguous" } },
90
+ { includeGenericMessageParts: true }
91
+ )
92
+ ).toBeUndefined()
93
+ })
94
+
95
+ test("does not render reasoning part text deltas as assistant output", async () => {
96
+ const events = await Effect.runPromise(
97
+ opencodeEventStream(
98
+ Stream.fromIterable([
99
+ {
100
+ type: "message.updated",
101
+ properties: { sessionID: "s1", info: { id: "assistant-message", role: "assistant" } }
102
+ },
103
+ {
104
+ type: "message.part.updated",
105
+ properties: {
106
+ sessionID: "s1",
107
+ part: { id: "reasoning-part", sessionID: "s1", messageID: "assistant-message", type: "reasoning", text: "thinking" }
108
+ }
109
+ },
110
+ {
111
+ type: "message.part.delta",
112
+ properties: { sessionID: "s1", messageID: "assistant-message", partID: "reasoning-part", field: "text", delta: " hidden" }
113
+ },
114
+ { type: "session.next.text.delta", properties: { sessionID: "s1", delta: "answer" } },
115
+ { type: "session.idle", properties: { sessionID: "s1" } }
116
+ ])
117
+ ).pipe(Stream.runCollect)
118
+ )
119
+
120
+ expect(events).toEqual([{ type: "text-delta", text: "answer" }, { type: "idle" }])
121
+ })
122
+
123
+ test("maps numeric diff and legacy failed-step event shapes", () => {
124
+ expect(
125
+ decodeOpencodeEvent({
126
+ type: "session.diff",
127
+ properties: {
128
+ diff: [
129
+ { additions: 2, deletions: 1 },
130
+ { additions: 3, deletions: 0 }
131
+ ]
132
+ }
133
+ })
134
+ ).toEqual({
135
+ type: "changed-files",
136
+ files: 2,
137
+ insertions: 5,
138
+ deletions: 1
139
+ })
140
+ expect(decodeOpencodeEvent({ type: "session.diff", properties: { diff: [] } })).toBeUndefined()
141
+ expect(decodeOpencodeEvent({ type: "session.diff", properties: { files: 0, insertions: 0, deletions: 0 } })).toBeUndefined()
142
+ expect(decodeOpencodeEvent({ type: "session.diff", properties: { files: 2, insertions: 5, deletions: 1 } })).toEqual({
143
+ type: "changed-files",
144
+ files: 2,
145
+ insertions: 5,
146
+ deletions: 1
147
+ })
148
+ expect(decodeOpencodeEvent({ type: "session.next.step.finished" })).toEqual({ type: "tool-end" })
149
+ expect(decodeOpencodeEvent({ type: "session.step.failed", message: "legacy failed" })).toEqual({
150
+ type: "error",
151
+ message: "legacy failed"
152
+ })
153
+ expect(decodeOpencodeEvent(undefined)).toBeUndefined()
154
+ expect(decodeOpencodeEvent({ type: "session.diff", files: 1, insertions: 2, deletions: 3 })).toEqual({
155
+ type: "changed-files",
156
+ files: 1,
157
+ insertions: 2,
158
+ deletions: 3
159
+ })
160
+ })
161
+
162
+ test("does not render user prompt message parts as assistant output", async () => {
163
+ const events = await Effect.runPromise(
164
+ opencodeEventStream(
165
+ Stream.fromIterable([
166
+ {
167
+ type: "message.updated",
168
+ properties: { sessionID: "s1", info: { id: "user-message", role: "user" } }
169
+ },
170
+ {
171
+ type: "message.part.updated",
172
+ properties: {
173
+ sessionID: "s1",
174
+ part: { id: "user-text", sessionID: "s1", messageID: "user-message", type: "text", text: "Discord bridge context" }
175
+ }
176
+ },
177
+ {
178
+ type: "session.next.text.ended",
179
+ properties: { sessionID: "s1", assistantMessageID: "assistant-message", textID: "assistant-text", text: "Normal answer" }
180
+ },
181
+ { type: "session.idle", properties: { sessionID: "s1" } }
182
+ ])
183
+ ).pipe(Stream.runCollect)
184
+ )
185
+
186
+ expect(events).toEqual([{ type: "text-snapshot", text: "Normal answer" }, { type: "idle" }])
187
+ })
188
+ })
@@ -0,0 +1,232 @@
1
+ import { Stream } from "effect"
2
+ import type { OpencodeEvent } from "../Schema.ts"
3
+ import {
4
+ type DecodeOptions,
5
+ includeGenericMessageParts,
6
+ includeGenericPartDeltas,
7
+ initialDecodeState,
8
+ updateDecodeState
9
+ } from "./EventMappingState.ts"
10
+ import type { OpencodeError } from "./OpencodePort.ts"
11
+
12
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
13
+ typeof value === "object" && value !== null && !Array.isArray(value)
14
+
15
+ const stringField = (record: Readonly<Record<string, unknown>>, key: string): string | undefined => {
16
+ const value = record[key]
17
+ return typeof value === "string" ? value : undefined
18
+ }
19
+
20
+ const numberField = (record: Readonly<Record<string, unknown>>, key: string): number | undefined => {
21
+ const value = record[key]
22
+ return typeof value === "number" ? value : undefined
23
+ }
24
+
25
+ const payloadProperties = (record: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
26
+ const properties = record.properties
27
+ return isRecord(properties) ? properties : record
28
+ }
29
+
30
+ const eventPayload = (value: unknown): Readonly<Record<string, unknown>> | undefined => {
31
+ if (!isRecord(value)) return undefined
32
+ const payload = value.payload
33
+ return isRecord(payload) && stringField(payload, "type") !== undefined ? payload : value
34
+ }
35
+
36
+ const textPart = (value: unknown): Readonly<Record<string, unknown>> | undefined => {
37
+ if (!isRecord(value)) return undefined
38
+ return stringField(value, "type") === "text" ? value : undefined
39
+ }
40
+
41
+ const errorMessage = (value: unknown): string => {
42
+ if (!isRecord(value)) return "Unknown opencode error"
43
+ const properties = payloadProperties(value)
44
+ const nested = properties.error
45
+ if (isRecord(nested)) {
46
+ const data = nested.data
47
+ if (isRecord(data)) return stringField(data, "message") ?? stringField(nested, "message") ?? "Unknown opencode error"
48
+ return stringField(nested, "message") ?? "Unknown opencode error"
49
+ }
50
+ return stringField(properties, "message") ?? stringField(value, "message") ?? "Unknown opencode error"
51
+ }
52
+
53
+ const toolTitle = (value: unknown): string | undefined => {
54
+ if (!isRecord(value)) return undefined
55
+ const properties = payloadProperties(value)
56
+ const step = properties.step
57
+ if (!isRecord(step)) return undefined
58
+ return stringField(step, "type") === "tool" ? stringField(step, "title") : undefined
59
+ }
60
+
61
+ const toolName = (value: unknown): string | undefined => {
62
+ if (!isRecord(value)) return undefined
63
+ const properties = payloadProperties(value)
64
+ return stringField(properties, "tool") ?? stringField(properties, "title")
65
+ }
66
+
67
+ const changedFilesFromCounts = (files: number, insertions: number, deletions: number): OpencodeEvent | undefined => {
68
+ if (files <= 0 && insertions <= 0 && deletions <= 0) return undefined
69
+ return { type: "changed-files", files, insertions, deletions }
70
+ }
71
+
72
+ const changedFiles = (value: unknown): OpencodeEvent | undefined => {
73
+ if (!isRecord(value)) return undefined
74
+ const properties = payloadProperties(value)
75
+ const diff = properties.diff
76
+ if (Array.isArray(diff)) {
77
+ let insertions = 0
78
+ let deletions = 0
79
+ for (const item of diff) {
80
+ if (!isRecord(item)) continue
81
+ insertions += numberField(item, "additions") ?? numberField(item, "insertions") ?? 0
82
+ deletions += numberField(item, "deletions") ?? 0
83
+ }
84
+ return changedFilesFromCounts(diff.length, insertions, deletions)
85
+ }
86
+ return changedFilesFromCounts(
87
+ numberField(properties, "files") ?? numberField(value, "files") ?? 0,
88
+ numberField(properties, "insertions") ?? numberField(properties, "additions") ?? numberField(value, "insertions") ?? 0,
89
+ numberField(properties, "deletions") ?? numberField(value, "deletions") ?? 0
90
+ )
91
+ }
92
+
93
+ const decodeLifecycle = (payload: Readonly<Record<string, unknown>>, type: string | undefined): OpencodeEvent | undefined => {
94
+ switch (type) {
95
+ case "session.idle":
96
+ return { type: "idle" }
97
+ case "session.error":
98
+ return { type: "error", message: errorMessage(payload) }
99
+ case "session.diff":
100
+ return changedFiles(payload)
101
+ default:
102
+ return undefined
103
+ }
104
+ }
105
+
106
+ const decodeText = (properties: Readonly<Record<string, unknown>>, type: string | undefined): OpencodeEvent | undefined => {
107
+ switch (type) {
108
+ case "session.next.text.delta": {
109
+ const delta = stringField(properties, "delta")
110
+ return delta === undefined ? undefined : { type: "text-delta", text: delta }
111
+ }
112
+ case "session.next.text.ended": {
113
+ const text = stringField(properties, "text")
114
+ return text === undefined ? undefined : { type: "text-snapshot", text }
115
+ }
116
+ default:
117
+ return undefined
118
+ }
119
+ }
120
+
121
+ const textDelta = (
122
+ payload: Readonly<Record<string, unknown>>,
123
+ properties: Readonly<Record<string, unknown>>
124
+ ): OpencodeEvent | undefined => {
125
+ const delta = stringField(properties, "delta") ?? stringField(payload, "delta")
126
+ return delta === undefined ? undefined : { type: "text-delta", text: delta }
127
+ }
128
+
129
+ const decodePartDelta = (
130
+ payload: Readonly<Record<string, unknown>>,
131
+ properties: Readonly<Record<string, unknown>>,
132
+ options: DecodeOptions
133
+ ): OpencodeEvent | undefined => {
134
+ const part = properties.part ?? payload.part
135
+ if (part !== undefined) return textPart(part) === undefined ? undefined : textDelta(payload, properties)
136
+ if (options.includeGenericPartDeltas !== true) return undefined
137
+ return stringField(properties, "field") === "text" ? textDelta(payload, properties) : undefined
138
+ }
139
+
140
+ const decodePartUpdated = (
141
+ payload: Readonly<Record<string, unknown>>,
142
+ properties: Readonly<Record<string, unknown>>
143
+ ): OpencodeEvent | undefined => {
144
+ const part = textPart(properties.part ?? payload.part)
145
+ if (part === undefined) return undefined
146
+ const delta = textDelta(payload, properties)
147
+ if (delta !== undefined) return delta
148
+ const text = stringField(part, "text")
149
+ return text === undefined ? undefined : { type: "text-snapshot", text }
150
+ }
151
+
152
+ const decodePart = (
153
+ payload: Readonly<Record<string, unknown>>,
154
+ properties: Readonly<Record<string, unknown>>,
155
+ type: string | undefined,
156
+ options: DecodeOptions
157
+ ): OpencodeEvent | undefined => {
158
+ switch (type) {
159
+ case "message.part.delta": {
160
+ if (options.includeGenericMessageParts !== true) return undefined
161
+ return decodePartDelta(payload, properties, options)
162
+ }
163
+ case "session.next.message.part.delta":
164
+ return decodePartDelta(payload, properties, { ...options, includeGenericPartDeltas: true })
165
+ case "message.part.updated": {
166
+ if (options.includeGenericMessageParts !== true) return undefined
167
+ return decodePartUpdated(payload, properties)
168
+ }
169
+ case "session.next.message.part.updated":
170
+ return decodePartUpdated(payload, properties)
171
+ default:
172
+ return undefined
173
+ }
174
+ }
175
+
176
+ const decodeTool = (payload: Readonly<Record<string, unknown>>, type: string | undefined): OpencodeEvent | undefined => {
177
+ switch (type) {
178
+ case "session.next.tool.called": {
179
+ const title = toolName(payload)
180
+ return title === undefined ? undefined : { type: "tool-start", title }
181
+ }
182
+ case "session.next.tool.success":
183
+ return { type: "tool-end" }
184
+ case "session.next.tool.failed":
185
+ return { type: "error", message: errorMessage(payload) }
186
+ default:
187
+ return undefined
188
+ }
189
+ }
190
+
191
+ const decodeStep = (payload: Readonly<Record<string, unknown>>, type: string | undefined): OpencodeEvent | undefined => {
192
+ switch (type) {
193
+ case "session.next.step.started": {
194
+ const title = toolTitle(payload)
195
+ return title === undefined ? undefined : { type: "tool-start", title }
196
+ }
197
+ case "session.next.step.finished":
198
+ case "session.next.step.ended":
199
+ return { type: "tool-end" }
200
+ case "session.step.failed":
201
+ case "session.next.step.failed":
202
+ return { type: "error", message: errorMessage(payload) }
203
+ default:
204
+ return undefined
205
+ }
206
+ }
207
+
208
+ export const decodeOpencodeEvent = (payload: unknown, options: DecodeOptions = {}): OpencodeEvent | undefined => {
209
+ const event = eventPayload(payload)
210
+ if (event === undefined) return undefined
211
+ const type = stringField(event, "type")
212
+ const properties = payloadProperties(event)
213
+ return (
214
+ decodeLifecycle(event, type) ??
215
+ decodeText(properties, type) ??
216
+ decodePart(event, properties, type, options) ??
217
+ decodeTool(event, type) ??
218
+ decodeStep(event, type)
219
+ )
220
+ }
221
+
222
+ export const opencodeEventStream = (events: Stream.Stream<unknown, OpencodeError>) =>
223
+ events.pipe(
224
+ Stream.mapAccum(initialDecodeState, (state, event) => {
225
+ const nextState = updateDecodeState(state, event)
226
+ const decoded = decodeOpencodeEvent(event, {
227
+ includeGenericMessageParts: includeGenericMessageParts(nextState, event),
228
+ includeGenericPartDeltas: includeGenericPartDeltas(nextState, event)
229
+ })
230
+ return [nextState, decoded === undefined ? [] : [decoded]]
231
+ })
232
+ )
@@ -0,0 +1,97 @@
1
+ type MessageRole = "assistant" | "user"
2
+
3
+ export type DecodeOptions = {
4
+ readonly includeGenericMessageParts?: boolean
5
+ readonly includeGenericPartDeltas?: boolean
6
+ }
7
+
8
+ type DecodeState = {
9
+ readonly assistantMessageIds: Set<string>
10
+ readonly textPartIds: Set<string>
11
+ }
12
+
13
+ const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
14
+ typeof value === "object" && value !== null && !Array.isArray(value)
15
+
16
+ const stringField = (record: Readonly<Record<string, unknown>>, key: string): string | undefined => {
17
+ const value = record[key]
18
+ return typeof value === "string" ? value : undefined
19
+ }
20
+
21
+ const eventPayload = (value: unknown): Readonly<Record<string, unknown>> | undefined => {
22
+ if (!isRecord(value)) return undefined
23
+ const payload = value.payload
24
+ return isRecord(payload) && stringField(payload, "type") !== undefined ? payload : value
25
+ }
26
+
27
+ const payloadProperties = (record: Readonly<Record<string, unknown>>): Readonly<Record<string, unknown>> => {
28
+ const properties = record.properties
29
+ return isRecord(properties) ? properties : record
30
+ }
31
+
32
+ const genericMessagePartType = (type: string | undefined): boolean => type === "message.part.delta" || type === "message.part.updated"
33
+
34
+ const messagePart = (value: unknown): Readonly<Record<string, unknown>> | undefined => {
35
+ const event = eventPayload(value)
36
+ if (event === undefined || !genericMessagePartType(stringField(event, "type"))) return undefined
37
+ const properties = payloadProperties(event)
38
+ const part = properties.part ?? event.part
39
+ return isRecord(part) ? part : undefined
40
+ }
41
+
42
+ const partId = (part: Readonly<Record<string, unknown>> | undefined): string | undefined =>
43
+ part === undefined ? undefined : (stringField(part, "id") ?? stringField(part, "partID") ?? stringField(part, "partId"))
44
+
45
+ const messagePartField = (value: unknown, primary: string, secondary: string): string | undefined => {
46
+ const event = eventPayload(value)
47
+ if (event === undefined || !genericMessagePartType(stringField(event, "type"))) return undefined
48
+ const properties = payloadProperties(event)
49
+ const part = properties.part ?? event.part
50
+ if (isRecord(part)) {
51
+ const partValue = stringField(part, primary) ?? stringField(part, secondary)
52
+ if (partValue !== undefined) return partValue
53
+ }
54
+ return stringField(properties, primary) ?? stringField(properties, secondary)
55
+ }
56
+
57
+ const messagePartPartId = (value: unknown): string | undefined => partId(messagePart(value)) ?? messagePartField(value, "partID", "partId")
58
+
59
+ const messagePartMessageId = (value: unknown): string | undefined => messagePartField(value, "messageID", "messageId")
60
+
61
+ const messageRole = (value: unknown): MessageRole | undefined => (value === "assistant" || value === "user" ? value : undefined)
62
+
63
+ const messageRoleUpdate = (value: unknown): { readonly id: string; readonly role: MessageRole } | undefined => {
64
+ const event = eventPayload(value)
65
+ if (event === undefined || stringField(event, "type") !== "message.updated") return undefined
66
+ const info = payloadProperties(event).info
67
+ if (!isRecord(info)) return undefined
68
+ const id = stringField(info, "id")
69
+ const role = messageRole(info.role)
70
+ return id === undefined || role === undefined ? undefined : { id, role }
71
+ }
72
+
73
+ export const initialDecodeState = (): DecodeState => ({ assistantMessageIds: new Set(), textPartIds: new Set() })
74
+
75
+ export const updateDecodeState = (state: DecodeState, value: unknown): DecodeState => {
76
+ const update = messageRoleUpdate(value)
77
+ if (update !== undefined) state.assistantMessageIds[update.role === "assistant" ? "add" : "delete"](update.id)
78
+
79
+ const part = messagePart(value)
80
+ const id = partId(part)
81
+ if (part !== undefined && id !== undefined) state.textPartIds[stringField(part, "type") === "text" ? "add" : "delete"](id)
82
+ return state
83
+ }
84
+
85
+ export const includeGenericMessageParts = (state: DecodeState, value: unknown): boolean => {
86
+ const messageId = messagePartMessageId(value)
87
+ return messageId !== undefined && state.assistantMessageIds.has(messageId)
88
+ }
89
+
90
+ export const includeGenericPartDeltas = (state: DecodeState, value: unknown): boolean => {
91
+ const event = eventPayload(value)
92
+ if (event === undefined || stringField(event, "type") !== "message.part.delta") return false
93
+ const part = messagePart(value)
94
+ if (part !== undefined) return stringField(part, "type") === "text"
95
+ const id = messagePartPartId(value)
96
+ return id !== undefined && state.textPartIds.has(id)
97
+ }
@@ -0,0 +1,18 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { Effect, Stream } from "effect"
3
+ import type { DiscordScope } from "../Schema.ts"
4
+ import { makeMemoryOpencode } from "./MemoryOpencode.ts"
5
+
6
+ const scope: DiscordScope = { guildId: "g1", channelId: "c1", threadId: "t1" }
7
+
8
+ describe("makeMemoryOpencode", () => {
9
+ test("records prompts and aborts by Discord scope", async () => {
10
+ const opencode = makeMemoryOpencode([{ type: "text-delta", text: "ok" }, { type: "idle" }])
11
+ const events = await Effect.runPromise(opencode.runPrompt({ prompt: "hello", projectDir: "/repo", scope }).pipe(Stream.runCollect))
12
+ await Effect.runPromise(opencode.abort(scope).pipe(Stream.runCollect))
13
+
14
+ expect(events).toEqual([{ type: "text-delta", text: "ok" }, { type: "idle" }])
15
+ expect(opencode.prompts.map((item) => item.prompt)).toEqual(["hello"])
16
+ expect(opencode.aborted).toEqual(["t1"])
17
+ })
18
+ })
@@ -0,0 +1,29 @@
1
+ import { Stream } from "effect"
2
+ import type { OpencodeEvent } from "../Schema.ts"
3
+ import type { OpencodePrompt, OpencodeService } from "./OpencodePort.ts"
4
+
5
+ export type MemoryOpencode = OpencodeService & {
6
+ readonly prompts: Array<OpencodePrompt>
7
+ readonly aborted: Array<string>
8
+ }
9
+
10
+ const scopeKey = (scope: OpencodePrompt["scope"]) => scope.threadId ?? scope.channelId
11
+
12
+ export const makeMemoryOpencode = (events: ReadonlyArray<OpencodeEvent>): MemoryOpencode => {
13
+ const prompts: Array<OpencodePrompt> = []
14
+ const aborted: Array<string> = []
15
+
16
+ return {
17
+ prompts,
18
+ aborted,
19
+ checkHealth: Stream.runDrain(Stream.empty),
20
+ runPrompt: (input) => {
21
+ prompts.push(input)
22
+ return Stream.fromIterable(events)
23
+ },
24
+ abort: (scope) => {
25
+ aborted.push(scopeKey(scope))
26
+ return Stream.empty
27
+ }
28
+ }
29
+ }
@@ -0,0 +1,30 @@
1
+ import { Context, Data, type Effect, type Stream } from "effect"
2
+ import type { DiscordScope, OpencodeEvent } from "../Schema.ts"
3
+
4
+ export class OpencodeError extends Data.TaggedError("OpencodeError")<{
5
+ readonly message: string
6
+ }> {}
7
+
8
+ export type OpencodePrompt = {
9
+ readonly prompt: string
10
+ readonly parts?: ReadonlyArray<OpencodePromptFilePart>
11
+ readonly projectDir: string
12
+ readonly scope: DiscordScope
13
+ readonly model?: string
14
+ readonly agent?: string
15
+ }
16
+
17
+ export type OpencodePromptFilePart = {
18
+ readonly type: "file"
19
+ readonly mime: string
20
+ readonly filename?: string
21
+ readonly url: string
22
+ }
23
+
24
+ export type OpencodeService = {
25
+ readonly runPrompt: (input: OpencodePrompt) => Stream.Stream<OpencodeEvent, OpencodeError>
26
+ readonly abort: (scope: DiscordScope) => Stream.Stream<never, OpencodeError>
27
+ readonly checkHealth: Effect.Effect<void, OpencodeError>
28
+ }
29
+
30
+ export const Opencode = Context.Service<OpencodeService>("opencode-discord-bot/Opencode")
@@ -0,0 +1,47 @@
1
+ import { Buffer } from "node:buffer"
2
+ import { Data, Effect } from "effect"
3
+ import type { OpencodePromptFilePart } from "./OpencodePort.ts"
4
+
5
+ class PromptPartError extends Data.TaggedError("PromptPartError")<{
6
+ readonly message: string
7
+ }> {}
8
+
9
+ const causeText = (value: unknown): string => {
10
+ if (value instanceof Error) return value.message.length === 0 ? causeText(value.cause) : value.message
11
+ return typeof value === "string" && value.length > 0 ? value : "unknown error"
12
+ }
13
+
14
+ const isBase64DataUrl = (value: string): boolean => value.startsWith("data:") && /;base64,/i.test(value)
15
+
16
+ const partLabel = (part: OpencodePromptFilePart): string => part.filename ?? part.url
17
+
18
+ const imageError = (action: "fetch" | "read", part: OpencodePromptFilePart, detail: string) =>
19
+ new PromptPartError({ message: `failed to ${action} image attachment ${partLabel(part)}: ${detail}` })
20
+
21
+ const fetchImagePart = Effect.fn("fetchImagePartDataUrl")(function* (part: OpencodePromptFilePart) {
22
+ if (!part.mime.startsWith("image/") || isBase64DataUrl(part.url)) return part
23
+
24
+ const response = yield* Effect.tryPromise({
25
+ try: () => fetch(part.url),
26
+ catch: (cause) => imageError("fetch", part, causeText(cause))
27
+ })
28
+ if (!response.ok) {
29
+ const statusText = response.statusText.length === 0 ? "" : ` ${response.statusText}`
30
+ return yield* Effect.fail(imageError("fetch", part, `${response.status}${statusText}`))
31
+ }
32
+ const buffer = yield* Effect.tryPromise({
33
+ try: () => response.arrayBuffer(),
34
+ catch: (cause) => imageError("read", part, causeText(cause))
35
+ })
36
+ return { ...part, url: `data:${part.mime};base64,${Buffer.from(buffer).toString("base64")}` }
37
+ })
38
+
39
+ export const preparePromptParts = Effect.fn("prepareOpencodePromptParts")(function* (
40
+ parts: ReadonlyArray<OpencodePromptFilePart> | undefined
41
+ ) {
42
+ const prepared: Array<OpencodePromptFilePart> = []
43
+ for (const part of parts ?? []) {
44
+ prepared.push(yield* fetchImagePart(part))
45
+ }
46
+ return prepared
47
+ })