@openpalm/discord-portal 0.12.7
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/README.md +64 -0
- package/package.json +24 -0
- package/src/commands.ts +202 -0
- package/src/index.test.ts +1154 -0
- package/src/index.ts +814 -0
- package/src/oc-event-hub.test.ts +100 -0
- package/src/oc-event-hub.ts +161 -0
- package/src/oc-events.ts +157 -0
- package/src/opencode.test.ts +78 -0
- package/src/opencode.ts +130 -0
- package/src/permissions.ts +55 -0
- package/src/runtime.ts +211 -0
- package/src/session.test.ts +74 -0
- package/src/stream-render.test.ts +127 -0
- package/src/stream-render.ts +482 -0
- package/src/types.ts +49 -0
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
export { OcClient, type OcClientOptions, type OcSession } from './opencode.ts';
|
|
4
|
+
export {
|
|
5
|
+
asRaw,
|
|
6
|
+
extractPermissionAsk,
|
|
7
|
+
extractQuestionAsk,
|
|
8
|
+
extractTextDelta,
|
|
9
|
+
extractToolUpdate,
|
|
10
|
+
isSessionError,
|
|
11
|
+
isTurnEnd,
|
|
12
|
+
partSnapshotType,
|
|
13
|
+
statusName,
|
|
14
|
+
TURN_IDLE_STATUSES,
|
|
15
|
+
type PermissionAsk,
|
|
16
|
+
type QuestionAsk,
|
|
17
|
+
type QuestionInfo,
|
|
18
|
+
type QuestionOption,
|
|
19
|
+
type RawEvent,
|
|
20
|
+
type ToolUpdate,
|
|
21
|
+
} from './oc-events.ts';
|
|
22
|
+
|
|
23
|
+
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
|
24
|
+
|
|
25
|
+
export function createLogger(service: string) {
|
|
26
|
+
function log(level: LogLevel, msg: string, extra?: Record<string, unknown>): void {
|
|
27
|
+
const entry = { ts: new Date().toISOString(), level, service, msg, ...(extra ? { extra } : {}) };
|
|
28
|
+
(level === 'error' || level === 'warn' ? console.error : console.log)(JSON.stringify(entry));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return {
|
|
32
|
+
info: (msg: string, extra?: Record<string, unknown>) => log('info', msg, extra),
|
|
33
|
+
warn: (msg: string, extra?: Record<string, unknown>) => log('warn', msg, extra),
|
|
34
|
+
error: (msg: string, extra?: Record<string, unknown>) => log('error', msg, extra),
|
|
35
|
+
debug: (msg: string, extra?: Record<string, unknown>) => log('debug', msg, extra),
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class SecretFileError extends Error {
|
|
40
|
+
constructor(public readonly envKey: string, reason: string) {
|
|
41
|
+
super(`${envKey}: ${reason}`);
|
|
42
|
+
this.name = 'SecretFileError';
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function stripTrailingNewline(value: string): string {
|
|
47
|
+
return value.replace(/[\r\n]+$/, '');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readRequiredSecretFile(envKey: string, env: Record<string, string | undefined> = Bun.env): string {
|
|
51
|
+
const path = env[envKey]?.trim();
|
|
52
|
+
if (!path) {
|
|
53
|
+
throw new SecretFileError(envKey, 'secret file env var is not set');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let value: string;
|
|
57
|
+
try {
|
|
58
|
+
value = stripTrailingNewline(readFileSync(path, 'utf8'));
|
|
59
|
+
} catch {
|
|
60
|
+
throw new SecretFileError(envKey, 'secret file is unreadable');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!value) {
|
|
64
|
+
throw new SecretFileError(envKey, 'secret file is empty');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return value;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function parseIdList(raw: string | undefined): Set<string> {
|
|
71
|
+
if (!raw) return new Set();
|
|
72
|
+
return new Set(
|
|
73
|
+
raw
|
|
74
|
+
.split(',')
|
|
75
|
+
.map((s) => s.trim())
|
|
76
|
+
.filter(Boolean),
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function splitMessage(content: string, maxLength: number): string[] {
|
|
81
|
+
if (!content) return [];
|
|
82
|
+
if (content.length <= maxLength) return [content];
|
|
83
|
+
|
|
84
|
+
const chunks: string[] = [];
|
|
85
|
+
let remaining = content;
|
|
86
|
+
|
|
87
|
+
while (remaining.length > 0) {
|
|
88
|
+
if (remaining.length <= maxLength) {
|
|
89
|
+
chunks.push(remaining);
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let splitIndex = maxLength;
|
|
94
|
+
const beforeSplit = remaining.slice(0, splitIndex);
|
|
95
|
+
const codeBlockStarts = (beforeSplit.match(/```/g) || []).length;
|
|
96
|
+
const inCodeBlock = codeBlockStarts % 2 === 1;
|
|
97
|
+
|
|
98
|
+
if (inCodeBlock) {
|
|
99
|
+
const newlineIndex = remaining.lastIndexOf('\n', splitIndex);
|
|
100
|
+
if (newlineIndex > maxLength / 2) splitIndex = newlineIndex;
|
|
101
|
+
} else {
|
|
102
|
+
const doubleNewline = remaining.lastIndexOf('\n\n', splitIndex);
|
|
103
|
+
const singleNewline = remaining.lastIndexOf('\n', splitIndex);
|
|
104
|
+
if (doubleNewline > maxLength / 2) splitIndex = doubleNewline + 2;
|
|
105
|
+
else if (singleNewline > maxLength / 2) splitIndex = singleNewline + 1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let chunk = remaining.slice(0, splitIndex);
|
|
109
|
+
remaining = remaining.slice(splitIndex);
|
|
110
|
+
|
|
111
|
+
const chunkCodeBlocks = (chunk.match(/```/g) || []).length;
|
|
112
|
+
if (chunkCodeBlocks % 2 === 1) {
|
|
113
|
+
chunk += '\n```';
|
|
114
|
+
const match = chunk.match(/```(\w+)?/);
|
|
115
|
+
const lang = match?.[1] || '';
|
|
116
|
+
remaining = '```' + lang + '\n' + remaining;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
chunks.push(chunk.trim());
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return chunks.filter((c) => c.length > 0);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
type SessionTask = {
|
|
126
|
+
run: () => Promise<void>;
|
|
127
|
+
onQueued?: () => Promise<void>;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
type SessionState = {
|
|
131
|
+
processing: boolean;
|
|
132
|
+
queue: SessionTask[];
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export class ConversationQueue {
|
|
136
|
+
private states = new Map<string, SessionState>();
|
|
137
|
+
|
|
138
|
+
isProcessing(sessionKey: string): boolean {
|
|
139
|
+
return this.states.get(sessionKey)?.processing ?? false;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
queuedCount(sessionKey: string): number {
|
|
143
|
+
return this.states.get(sessionKey)?.queue.length ?? 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clear(sessionKey: string): number {
|
|
147
|
+
const state = this.states.get(sessionKey);
|
|
148
|
+
if (!state) return 0;
|
|
149
|
+
|
|
150
|
+
const dropped = state.queue.length;
|
|
151
|
+
state.queue.length = 0;
|
|
152
|
+
|
|
153
|
+
if (!state.processing) {
|
|
154
|
+
this.states.delete(sessionKey);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return dropped;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async runOrQueue(sessionKey: string, task: SessionTask): Promise<'started' | 'queued'> {
|
|
161
|
+
const state = this.states.get(sessionKey) ?? { processing: false, queue: [] };
|
|
162
|
+
this.states.set(sessionKey, state);
|
|
163
|
+
|
|
164
|
+
if (state.processing) {
|
|
165
|
+
state.queue.push(task);
|
|
166
|
+
try {
|
|
167
|
+
await task.onQueued?.();
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
return 'queued';
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
state.processing = true;
|
|
174
|
+
try {
|
|
175
|
+
await task.run();
|
|
176
|
+
} finally {
|
|
177
|
+
state.processing = false;
|
|
178
|
+
if (state.queue.length > 0) {
|
|
179
|
+
void this.drain(sessionKey);
|
|
180
|
+
} else {
|
|
181
|
+
this.states.delete(sessionKey);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return 'started';
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private async drain(sessionKey: string): Promise<void> {
|
|
189
|
+
const state = this.states.get(sessionKey);
|
|
190
|
+
if (!state || state.processing) return;
|
|
191
|
+
|
|
192
|
+
const next = state.queue.shift();
|
|
193
|
+
if (!next) {
|
|
194
|
+
this.states.delete(sessionKey);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
state.processing = true;
|
|
199
|
+
try {
|
|
200
|
+
await next.run();
|
|
201
|
+
} catch {
|
|
202
|
+
} finally {
|
|
203
|
+
state.processing = false;
|
|
204
|
+
if (state.queue.length > 0) {
|
|
205
|
+
void this.drain(sessionKey);
|
|
206
|
+
} else {
|
|
207
|
+
this.states.delete(sessionKey);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test";
|
|
2
|
+
import { ConversationQueue } from './runtime.ts';
|
|
3
|
+
|
|
4
|
+
function deferred(): { promise: Promise<void>; resolve: () => void } {
|
|
5
|
+
let resolve = () => {};
|
|
6
|
+
const promise = new Promise<void>((innerResolve) => {
|
|
7
|
+
resolve = innerResolve;
|
|
8
|
+
});
|
|
9
|
+
return { promise, resolve };
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("ConversationQueue", () => {
|
|
13
|
+
it("runs queued work sequentially", async () => {
|
|
14
|
+
const queue = new ConversationQueue();
|
|
15
|
+
const blocker = deferred();
|
|
16
|
+
const events: string[] = [];
|
|
17
|
+
|
|
18
|
+
const first = queue.runOrQueue("session-1", {
|
|
19
|
+
run: async () => {
|
|
20
|
+
events.push("first:start");
|
|
21
|
+
await blocker.promise;
|
|
22
|
+
events.push("first:end");
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const second = queue.runOrQueue("session-1", {
|
|
27
|
+
onQueued: async () => {
|
|
28
|
+
events.push("second:queued");
|
|
29
|
+
},
|
|
30
|
+
run: async () => {
|
|
31
|
+
events.push("second:run");
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(await second).toBe("queued");
|
|
36
|
+
expect(queue.queuedCount("session-1")).toBe(1);
|
|
37
|
+
|
|
38
|
+
blocker.resolve();
|
|
39
|
+
expect(await first).toBe("started");
|
|
40
|
+
|
|
41
|
+
await Bun.sleep(0);
|
|
42
|
+
expect(events).toEqual(["first:start", "second:queued", "first:end", "second:run"]);
|
|
43
|
+
expect(queue.isProcessing("session-1")).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("drops queued work when cleared", async () => {
|
|
47
|
+
const queue = new ConversationQueue();
|
|
48
|
+
const blocker = deferred();
|
|
49
|
+
const events: string[] = [];
|
|
50
|
+
|
|
51
|
+
const first = queue.runOrQueue("session-1", {
|
|
52
|
+
run: async () => {
|
|
53
|
+
events.push("first:start");
|
|
54
|
+
await blocker.promise;
|
|
55
|
+
events.push("first:end");
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
await queue.runOrQueue("session-1", {
|
|
60
|
+
run: async () => {
|
|
61
|
+
events.push("second:run");
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(queue.clear("session-1")).toBe(1);
|
|
66
|
+
|
|
67
|
+
blocker.resolve();
|
|
68
|
+
await first;
|
|
69
|
+
await Bun.sleep(0);
|
|
70
|
+
|
|
71
|
+
expect(events).toEqual(["first:start", "first:end"]);
|
|
72
|
+
expect(queue.queuedCount("session-1")).toBe(0);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discord stream renderer — PURE event-narrowing unit tests (design §4.1, §4.2).
|
|
3
|
+
*
|
|
4
|
+
* The Discord rendering side-effects (thread.send/edit, ActionRow clicks) need a
|
|
5
|
+
* live Discord gateway and are stated in needsLiveVerification. What IS unit-
|
|
6
|
+
* provable is the native-OpenCode-event correlation/narrowing logic, which is
|
|
7
|
+
* the security- and correctness-load-bearing part: a delta must be attributed to
|
|
8
|
+
* the right session AND messageID, a foreign session's frames must be ignored,
|
|
9
|
+
* permission.asked must surface the requestID, and turn-end must be detected.
|
|
10
|
+
*/
|
|
11
|
+
import { describe, test, expect } from "bun:test";
|
|
12
|
+
import { _internal } from "./stream-render.ts";
|
|
13
|
+
|
|
14
|
+
const { extractTextDelta, isTurnEnd, extractToolUpdate, extractPermissionAsk, toolEmoji, asRaw } = _internal;
|
|
15
|
+
|
|
16
|
+
const SID = "ses_target";
|
|
17
|
+
const MID = "^msgcorrelation";
|
|
18
|
+
|
|
19
|
+
function ev(type: string, properties: Record<string, unknown>) {
|
|
20
|
+
return asRaw({ type, properties });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe("extractTextDelta — sessionID-only correlation (§4.2, corrected)", () => {
|
|
24
|
+
test("message.part.delta for our session yields the delta", () => {
|
|
25
|
+
expect(extractTextDelta(ev("message.part.delta", { sessionID: SID, messageID: MID, delta: "hi" }), SID)).toBe("hi");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("session.next.text.delta (preferred 1.15.13 family) yields the delta", () => {
|
|
29
|
+
expect(extractTextDelta(ev("session.next.text.delta", { sessionID: SID, messageID: MID, delta: "yo" }), SID)).toBe("yo");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test("delta for a DIFFERENT session is ignored", () => {
|
|
33
|
+
expect(extractTextDelta(ev("message.part.delta", { sessionID: "other", messageID: MID, delta: "x" }), SID)).toBeNull();
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("a SERVER-generated messageID (≠ client id) still renders — session is the key (live-verified)", () => {
|
|
37
|
+
expect(extractTextDelta(ev("message.part.delta", { sessionID: SID, messageID: "msg_server", delta: "x" }), SID)).toBe("x");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("non-text field delta is ignored", () => {
|
|
41
|
+
expect(extractTextDelta(ev("message.part.delta", { sessionID: SID, messageID: MID, field: "reasoning", delta: "x" }), SID)).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("a non-delta event yields null", () => {
|
|
45
|
+
expect(extractTextDelta(ev("session.status", { sessionID: SID, status: { type: "busy" } }), SID)).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe("isTurnEnd — session.status idle, fallback session.idle (§1.1)", () => {
|
|
50
|
+
test("session.idle for our session ends the turn", () => {
|
|
51
|
+
expect(isTurnEnd(ev("session.idle", { sessionID: SID }), SID)).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("session.status idle ends the turn", () => {
|
|
55
|
+
expect(isTurnEnd(ev("session.status", { sessionID: SID, status: "idle" }), SID)).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("session.status busy does NOT end the turn", () => {
|
|
59
|
+
expect(isTurnEnd(ev("session.status", { sessionID: SID, status: "busy" }), SID)).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("turn-end for a DIFFERENT session is ignored", () => {
|
|
63
|
+
expect(isTurnEnd(ev("session.idle", { sessionID: "other" }), SID)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("extractToolUpdate — colored by state.status (§4.1)", () => {
|
|
68
|
+
test("message.part.updated tool part yields a tool update", () => {
|
|
69
|
+
const t = extractToolUpdate(
|
|
70
|
+
ev("message.part.updated", { sessionID: SID, part: { type: "tool", callID: "c1", tool: "bash", state: { status: "running", title: "echo hi" } } }),
|
|
71
|
+
SID,
|
|
72
|
+
);
|
|
73
|
+
expect(t).not.toBeNull();
|
|
74
|
+
expect(t!.callID).toBe("c1");
|
|
75
|
+
expect(t!.tool).toBe("bash");
|
|
76
|
+
expect(t!.status).toBe("running");
|
|
77
|
+
expect(t!.title).toBe("echo hi");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("session.next.tool.called yields a running update", () => {
|
|
81
|
+
const t = extractToolUpdate(ev("session.next.tool.called", { sessionID: SID, callID: "c2", tool: "edit" }), SID);
|
|
82
|
+
expect(t).not.toBeNull();
|
|
83
|
+
expect(t!.callID).toBe("c2");
|
|
84
|
+
expect(t!.status).toBe("running");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a tool update for a foreign session is ignored", () => {
|
|
88
|
+
expect(extractToolUpdate(ev("message.part.updated", { sessionID: "other", part: { type: "tool", callID: "c", tool: "bash", state: { status: "running" } } }), SID)).toBeNull();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("toolEmoji maps tool kind → a reaction emoji", () => {
|
|
92
|
+
expect(toolEmoji("akm_curate")).toBe("🔎");
|
|
93
|
+
expect(toolEmoji("akm_remember")).toBe("🧠");
|
|
94
|
+
expect(toolEmoji("bash")).toBe("🐚");
|
|
95
|
+
expect(toolEmoji("edit")).toBe("✏️");
|
|
96
|
+
expect(toolEmoji("something_unknown")).toBe("🔧");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("extractPermissionAsk — surfaces requestID for our session (§4.1)", () => {
|
|
101
|
+
test("permission.asked yields the requestID + permission + patterns", () => {
|
|
102
|
+
const ask = extractPermissionAsk(
|
|
103
|
+
ev("permission.asked", { sessionID: SID, id: "per_1", permission: "bash", patterns: ["echo *"], always: ["echo *"] }),
|
|
104
|
+
SID,
|
|
105
|
+
);
|
|
106
|
+
expect(ask).not.toBeNull();
|
|
107
|
+
expect(ask!.requestID).toBe("per_1");
|
|
108
|
+
expect(ask!.permission).toBe("bash");
|
|
109
|
+
expect(ask!.patterns).toEqual(["echo *"]);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("permission.asked for a foreign session is ignored", () => {
|
|
113
|
+
expect(extractPermissionAsk(ev("permission.asked", { sessionID: "other", id: "per_2", permission: "bash" }), SID)).toBeNull();
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("a permission.asked missing an id yields null", () => {
|
|
117
|
+
expect(extractPermissionAsk(ev("permission.asked", { sessionID: SID, permission: "bash" }), SID)).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("DISCORD_SESSION_PREAMBLE — channel-level question-tool nudge", () => {
|
|
122
|
+
test("is a non-empty default that mentions the question tool (so the API channel never gets it — it lives here)", async () => {
|
|
123
|
+
const { DISCORD_SESSION_PREAMBLE } = await import("./stream-render.ts");
|
|
124
|
+
expect(DISCORD_SESSION_PREAMBLE.length).toBeGreaterThan(0);
|
|
125
|
+
expect(DISCORD_SESSION_PREAMBLE).toContain("question");
|
|
126
|
+
});
|
|
127
|
+
});
|