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.
- package/LICENSE +21 -0
- package/README.md +13 -0
- package/package.json +78 -0
- package/src/Bridge/LoopbackServer.test.ts +94 -0
- package/src/Bridge/LoopbackServer.ts +77 -0
- package/src/Bridge/ToolControl.test.ts +245 -0
- package/src/Bridge/ToolControl.ts +260 -0
- package/src/Bridge/ToolControlEdges.test.ts +49 -0
- package/src/Bridge/ToolControlHighRisk.test.ts +153 -0
- package/src/Config.test.ts +142 -0
- package/src/Config.ts +295 -0
- package/src/ConfigSchema.ts +46 -0
- package/src/ConfigTypes.ts +11 -0
- package/src/Discord/ChatSdkDiscord.test.ts +257 -0
- package/src/Discord/ChatSdkDiscord.ts +206 -0
- package/src/Discord/ChatSdkGatewayIntake.test.ts +121 -0
- package/src/Discord/ChatSdkGatewayIntake.ts +235 -0
- package/src/Discord/DiscordGateway.test.ts +215 -0
- package/src/Discord/DiscordGateway.ts +140 -0
- package/src/Discord/DiscordGatewayFailures.test.ts +148 -0
- package/src/Discord/DiscordJsDiscord.test.ts +208 -0
- package/src/Discord/DiscordJsDiscord.ts +267 -0
- package/src/Discord/DiscordPort.ts +30 -0
- package/src/Discord/MemoryDiscord.test.ts +44 -0
- package/src/Discord/MemoryDiscord.ts +85 -0
- package/src/Discord/Safety.ts +11 -0
- package/src/Main.test.ts +273 -0
- package/src/Main.ts +192 -0
- package/src/MainQueue.test.ts +124 -0
- package/src/Opencode/EventMapping.test.ts +188 -0
- package/src/Opencode/EventMapping.ts +232 -0
- package/src/Opencode/EventMappingState.ts +97 -0
- package/src/Opencode/MemoryOpencode.test.ts +18 -0
- package/src/Opencode/MemoryOpencode.ts +29 -0
- package/src/Opencode/OpencodePort.ts +30 -0
- package/src/Opencode/PromptParts.ts +47 -0
- package/src/Opencode/SdkOpencode.test.ts +280 -0
- package/src/Opencode/SdkOpencode.ts +270 -0
- package/src/Opencode/SdkOpencodeAttachments.test.ts +79 -0
- package/src/Opencode/SdkOpencodeFailures.test.ts +113 -0
- package/src/Orchestrator/ContextAssembly.test.ts +115 -0
- package/src/Orchestrator/ContextAssembly.ts +120 -0
- package/src/Orchestrator/Orchestrator.ts +67 -0
- package/src/Orchestrator/StopCommand.test.ts +20 -0
- package/src/Orchestrator/StopCommand.ts +14 -0
- package/src/Orchestrator/Triggering.test.ts +56 -0
- package/src/Orchestrator/Triggering.ts +26 -0
- package/src/Orchestrator/TurnManager.test.ts +180 -0
- package/src/Orchestrator/TurnManager.ts +179 -0
- package/src/Orchestrator/TurnManagerStrategy.test.ts +136 -0
- package/src/PublicContracts.test.ts +43 -0
- package/src/Render/Renderer.test.ts +249 -0
- package/src/Render/Renderer.ts +159 -0
- package/src/Render/Splitting.test.ts +30 -0
- package/src/Render/Splitting.ts +68 -0
- package/src/Schema.ts +93 -0
- package/src/Tools/Scaffolding.test.ts +56 -0
- 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
|
+
})
|