opencode-gateway 0.1.0 → 0.1.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/dist/cli/doctor.js +3 -1
- package/dist/cli/init.js +4 -1
- package/dist/cli/paths.js +1 -1
- package/dist/cli.js +13 -4
- package/dist/config/gateway.d.ts +1 -0
- package/dist/config/gateway.js +2 -1
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +5 -1
- package/dist/cron/runtime.d.ts +24 -5
- package/dist/cron/runtime.js +178 -13
- package/dist/delivery/text.js +1 -1
- package/dist/gateway.js +41 -35
- package/dist/index.js +9 -5
- package/dist/opencode/adapter.d.ts +2 -0
- package/dist/opencode/adapter.js +56 -7
- package/dist/runtime/conversation-coordinator.d.ts +4 -0
- package/dist/runtime/conversation-coordinator.js +22 -0
- package/dist/runtime/executor.d.ts +33 -5
- package/dist/runtime/executor.js +229 -22
- package/dist/runtime/runtime-singleton.d.ts +2 -0
- package/dist/runtime/runtime-singleton.js +28 -0
- package/dist/session/context.js +6 -0
- package/dist/store/migrations.js +15 -1
- package/dist/store/sqlite.d.ts +19 -2
- package/dist/store/sqlite.js +81 -4
- package/dist/tools/channel-target.d.ts +5 -0
- package/dist/tools/channel-target.js +6 -0
- package/dist/tools/cron-run.js +1 -1
- package/dist/tools/cron-upsert.d.ts +2 -1
- package/dist/tools/cron-upsert.js +20 -6
- package/dist/tools/{cron-remove.d.ts → schedule-cancel.d.ts} +1 -1
- package/dist/tools/schedule-cancel.js +12 -0
- package/dist/tools/schedule-format.d.ts +4 -0
- package/dist/tools/schedule-format.js +48 -0
- package/dist/tools/{cron-list.d.ts → schedule-list.d.ts} +1 -1
- package/dist/tools/schedule-list.js +17 -0
- package/dist/tools/schedule-once.d.ts +4 -0
- package/dist/tools/schedule-once.js +43 -0
- package/dist/tools/schedule-status.d.ts +3 -0
- package/dist/tools/schedule-status.js +23 -0
- package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
- package/package.json +4 -4
- package/dist/tools/cron-list.js +0 -34
- package/dist/tools/cron-remove.js +0 -12
package/dist/index.js
CHANGED
|
@@ -2,12 +2,14 @@ import { loadGatewayBindingModule } from "./binding";
|
|
|
2
2
|
import { createGatewayRuntime } from "./gateway";
|
|
3
3
|
import { createChannelNewSessionTool } from "./tools/channel-new-session";
|
|
4
4
|
import { createChannelSendFileTool } from "./tools/channel-send-file";
|
|
5
|
-
import { createCronListTool } from "./tools/cron-list";
|
|
6
|
-
import { createCronRemoveTool } from "./tools/cron-remove";
|
|
7
5
|
import { createCronRunTool } from "./tools/cron-run";
|
|
8
6
|
import { createCronUpsertTool } from "./tools/cron-upsert";
|
|
9
7
|
import { createGatewayDispatchCronTool } from "./tools/gateway-dispatch-cron";
|
|
10
8
|
import { createGatewayStatusTool } from "./tools/gateway-status";
|
|
9
|
+
import { createScheduleCancelTool } from "./tools/schedule-cancel";
|
|
10
|
+
import { createScheduleListTool } from "./tools/schedule-list";
|
|
11
|
+
import { createScheduleOnceTool } from "./tools/schedule-once";
|
|
12
|
+
import { createScheduleStatusTool } from "./tools/schedule-status";
|
|
11
13
|
import { createTelegramSendTestTool } from "./tools/telegram-send-test";
|
|
12
14
|
import { createTelegramStatusTool } from "./tools/telegram-status";
|
|
13
15
|
/**
|
|
@@ -18,12 +20,14 @@ export const OpencodeGatewayPlugin = async (input) => {
|
|
|
18
20
|
const gatewayModule = await loadGatewayBindingModule();
|
|
19
21
|
const runtime = await createGatewayRuntime(gatewayModule, input);
|
|
20
22
|
const tools = {
|
|
21
|
-
cron_list: createCronListTool(runtime.cron),
|
|
22
|
-
cron_remove: createCronRemoveTool(runtime.cron),
|
|
23
23
|
cron_run: createCronRunTool(runtime.cron),
|
|
24
|
-
cron_upsert: createCronUpsertTool(runtime.cron),
|
|
24
|
+
cron_upsert: createCronUpsertTool(runtime.cron, runtime.sessionContext),
|
|
25
25
|
gateway_status: createGatewayStatusTool(runtime),
|
|
26
26
|
gateway_dispatch_cron: createGatewayDispatchCronTool(runtime.executor),
|
|
27
|
+
schedule_cancel: createScheduleCancelTool(runtime.cron),
|
|
28
|
+
schedule_list: createScheduleListTool(runtime.cron),
|
|
29
|
+
schedule_once: createScheduleOnceTool(runtime.cron, runtime.sessionContext),
|
|
30
|
+
schedule_status: createScheduleStatusTool(runtime.cron),
|
|
27
31
|
};
|
|
28
32
|
if (runtime.files.hasEnabledChannel()) {
|
|
29
33
|
tools.channel_send_file = createChannelSendFileTool(runtime.files, runtime.sessionContext);
|
|
@@ -6,6 +6,8 @@ export declare class OpencodeSdkAdapter {
|
|
|
6
6
|
private readonly directory;
|
|
7
7
|
constructor(client: OpencodeClient, directory: string);
|
|
8
8
|
createFreshSession(title: string): Promise<string>;
|
|
9
|
+
isSessionBusy(sessionId: string): Promise<boolean>;
|
|
10
|
+
abortSession(sessionId: string): Promise<void>;
|
|
9
11
|
execute(command: BindingOpencodeCommand): Promise<BindingOpencodeCommandResult>;
|
|
10
12
|
private lookupSession;
|
|
11
13
|
private createSession;
|
package/dist/opencode/adapter.js
CHANGED
|
@@ -18,6 +18,31 @@ export class OpencodeSdkAdapter {
|
|
|
18
18
|
});
|
|
19
19
|
return unwrapData(session).id;
|
|
20
20
|
}
|
|
21
|
+
async isSessionBusy(sessionId) {
|
|
22
|
+
const statuses = await this.client.session.status({
|
|
23
|
+
query: { directory: this.directory },
|
|
24
|
+
responseStyle: "data",
|
|
25
|
+
throwOnError: true,
|
|
26
|
+
});
|
|
27
|
+
const current = unwrapData(statuses)[sessionId];
|
|
28
|
+
return current?.type === "busy";
|
|
29
|
+
}
|
|
30
|
+
async abortSession(sessionId) {
|
|
31
|
+
try {
|
|
32
|
+
await this.client.session.abort({
|
|
33
|
+
path: { id: sessionId },
|
|
34
|
+
query: { directory: this.directory },
|
|
35
|
+
responseStyle: "data",
|
|
36
|
+
throwOnError: true,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (isMissingSessionError(error)) {
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
21
46
|
async execute(command) {
|
|
22
47
|
try {
|
|
23
48
|
switch (command.kind) {
|
|
@@ -125,6 +150,7 @@ export class OpencodeSdkAdapter {
|
|
|
125
150
|
}
|
|
126
151
|
async awaitPromptResponse(command) {
|
|
127
152
|
const deadline = Date.now() + PROMPT_RESPONSE_TIMEOUT_MS;
|
|
153
|
+
let stableCandidateKey = null;
|
|
128
154
|
for (;;) {
|
|
129
155
|
const messages = await this.client.session.messages({
|
|
130
156
|
path: { id: command.sessionId },
|
|
@@ -137,12 +163,19 @@ export class OpencodeSdkAdapter {
|
|
|
137
163
|
});
|
|
138
164
|
const response = selectAssistantResponse(unwrapData(messages), command.messageId);
|
|
139
165
|
if (response !== null) {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
166
|
+
const candidateKey = createAssistantCandidateKey(response);
|
|
167
|
+
if (stableCandidateKey === candidateKey) {
|
|
168
|
+
return {
|
|
169
|
+
kind: "awaitPromptResponse",
|
|
170
|
+
sessionId: command.sessionId,
|
|
171
|
+
messageId: response.info.id,
|
|
172
|
+
parts: response.parts.flatMap(toBindingMessagePart),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
stableCandidateKey = candidateKey;
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
stableCandidateKey = null;
|
|
146
179
|
}
|
|
147
180
|
if (Date.now() >= deadline) {
|
|
148
181
|
throw new Error(`assistant message for prompt ${command.messageId} is unavailable after prompt completion`);
|
|
@@ -221,6 +254,19 @@ function selectAssistantResponse(messages, userMessageId) {
|
|
|
221
254
|
}
|
|
222
255
|
return null;
|
|
223
256
|
}
|
|
257
|
+
function createAssistantCandidateKey(message) {
|
|
258
|
+
return JSON.stringify({
|
|
259
|
+
messageId: message.info.id,
|
|
260
|
+
finish: message.info.finish ?? null,
|
|
261
|
+
hasError: message.info.error !== undefined,
|
|
262
|
+
parts: message.parts.map((part) => ({
|
|
263
|
+
id: part.id ?? null,
|
|
264
|
+
type: part.type,
|
|
265
|
+
text: typeof part.text === "string" ? part.text : null,
|
|
266
|
+
ignored: part.ignored === true,
|
|
267
|
+
})),
|
|
268
|
+
});
|
|
269
|
+
}
|
|
224
270
|
function isAssistantChildMessage(userMessageId) {
|
|
225
271
|
return (message) => message.info?.role === "assistant" && message.info.parentID === userMessageId;
|
|
226
272
|
}
|
|
@@ -239,7 +285,10 @@ function toBindingMessagePart(part) {
|
|
|
239
285
|
];
|
|
240
286
|
}
|
|
241
287
|
function hasVisibleText(message) {
|
|
242
|
-
return message.parts.some((part) => part.type === "text" &&
|
|
288
|
+
return message.parts.some((part) => part.type === "text" &&
|
|
289
|
+
part.ignored !== true &&
|
|
290
|
+
typeof part.text === "string" &&
|
|
291
|
+
part.text.trim().length > 0);
|
|
243
292
|
}
|
|
244
293
|
function toBindingMessage(message) {
|
|
245
294
|
if (typeof message.info?.id !== "string" ||
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export class ConversationCoordinator {
|
|
2
|
+
tails = new Map();
|
|
3
|
+
async runExclusive(conversationKey, operation) {
|
|
4
|
+
const previous = this.tails.get(conversationKey) ?? Promise.resolve();
|
|
5
|
+
let release;
|
|
6
|
+
const current = new Promise((resolve) => {
|
|
7
|
+
release = resolve;
|
|
8
|
+
});
|
|
9
|
+
const tail = previous.then(() => current, () => current);
|
|
10
|
+
this.tails.set(conversationKey, tail);
|
|
11
|
+
await previous;
|
|
12
|
+
try {
|
|
13
|
+
return await operation();
|
|
14
|
+
}
|
|
15
|
+
finally {
|
|
16
|
+
release();
|
|
17
|
+
if (this.tails.get(conversationKey) === tail) {
|
|
18
|
+
this.tails.delete(conversationKey);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import type { BindingCronJobSpec, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
|
|
1
|
+
import type { BindingCronJobSpec, BindingDeliveryTarget, BindingInboundMessage, BindingLoggerHost, BindingPreparedExecution, BindingRuntimeReport, GatewayBindingModule } from "../binding";
|
|
2
2
|
import type { GatewayTextDelivery } from "../delivery/text";
|
|
3
3
|
import type { OpencodeSdkAdapter } from "../opencode/adapter";
|
|
4
4
|
import type { OpencodeEventHub } from "../opencode/events";
|
|
5
5
|
import type { MailboxEntryRecord, SqliteStore } from "../store/sqlite";
|
|
6
|
+
import { ConversationCoordinator } from "./conversation-coordinator";
|
|
6
7
|
export declare class GatewayExecutor {
|
|
7
8
|
private readonly module;
|
|
8
9
|
private readonly store;
|
|
@@ -10,15 +11,42 @@ export declare class GatewayExecutor {
|
|
|
10
11
|
private readonly events;
|
|
11
12
|
private readonly delivery;
|
|
12
13
|
private readonly logger;
|
|
13
|
-
|
|
14
|
+
private readonly coordinator;
|
|
15
|
+
private internalPromptSequence;
|
|
16
|
+
constructor(module: GatewayBindingModule, store: SqliteStore, opencode: GatewayOpencodeRuntimeLike, events: OpencodeEventHub, delivery: GatewayTextDeliveryLike, logger: BindingLoggerHost, coordinator?: ConversationCoordinator);
|
|
14
17
|
prepareInboundMessage(message: BindingInboundMessage): BindingPreparedExecution;
|
|
15
18
|
handleInboundMessage(message: BindingInboundMessage): Promise<BindingRuntimeReport>;
|
|
16
19
|
executeMailboxEntries(entries: MailboxEntryRecord[]): Promise<BindingRuntimeReport>;
|
|
17
20
|
dispatchCronJob(job: BindingCronJobSpec): Promise<BindingRuntimeReport>;
|
|
18
|
-
|
|
21
|
+
dispatchScheduledJob(input: DispatchScheduledJobInput): Promise<BindingRuntimeReport>;
|
|
22
|
+
appendContextToConversation(input: AppendContextToConversationInput): Promise<void>;
|
|
23
|
+
private executePreparedBatch;
|
|
24
|
+
private ensureConversationSession;
|
|
25
|
+
private initializeReplyTargetsIfMissing;
|
|
26
|
+
private preparePersistedSessionForPrompt;
|
|
27
|
+
private lookupSession;
|
|
28
|
+
private createSession;
|
|
29
|
+
private waitUntilIdle;
|
|
30
|
+
private appendPrompt;
|
|
31
|
+
private cleanupResidualBusySession;
|
|
32
|
+
private abortSessionAndWaitForSettle;
|
|
33
|
+
private createInternalPromptIdentity;
|
|
19
34
|
private executeDriver;
|
|
20
35
|
}
|
|
21
|
-
export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "executeMailboxEntries" | "prepareInboundMessage">;
|
|
36
|
+
export type GatewayExecutorLike = Pick<GatewayExecutor, "handleInboundMessage" | "dispatchCronJob" | "dispatchScheduledJob" | "appendContextToConversation" | "executeMailboxEntries" | "prepareInboundMessage">;
|
|
37
|
+
export type DispatchScheduledJobInput = {
|
|
38
|
+
jobId: string;
|
|
39
|
+
jobKind: "cron" | "once";
|
|
40
|
+
conversationKey: string;
|
|
41
|
+
prompt: string;
|
|
42
|
+
replyTarget: BindingPreparedExecution["replyTarget"];
|
|
43
|
+
};
|
|
44
|
+
export type AppendContextToConversationInput = {
|
|
45
|
+
conversationKey: string;
|
|
46
|
+
replyTarget: BindingDeliveryTarget | null;
|
|
47
|
+
body: string;
|
|
48
|
+
recordedAtMs: number;
|
|
49
|
+
};
|
|
22
50
|
type GatewayTextDeliveryLike = Pick<GatewayTextDelivery, "openMany">;
|
|
23
|
-
type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute">;
|
|
51
|
+
type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute" | "isSessionBusy" | "abortSession">;
|
|
24
52
|
export {};
|
package/dist/runtime/executor.js
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
+
import { ConversationCoordinator } from "./conversation-coordinator";
|
|
1
2
|
import { runOpencodeDriver } from "./opencode-runner";
|
|
3
|
+
const SESSION_ABORT_SETTLE_TIMEOUT_MS = 5_000;
|
|
4
|
+
const SESSION_ABORT_POLL_MS = 250;
|
|
2
5
|
export class GatewayExecutor {
|
|
3
6
|
module;
|
|
4
7
|
store;
|
|
@@ -6,13 +9,16 @@ export class GatewayExecutor {
|
|
|
6
9
|
events;
|
|
7
10
|
delivery;
|
|
8
11
|
logger;
|
|
9
|
-
|
|
12
|
+
coordinator;
|
|
13
|
+
internalPromptSequence = 0;
|
|
14
|
+
constructor(module, store, opencode, events, delivery, logger, coordinator = new ConversationCoordinator()) {
|
|
10
15
|
this.module = module;
|
|
11
16
|
this.store = store;
|
|
12
17
|
this.opencode = opencode;
|
|
13
18
|
this.events = events;
|
|
14
19
|
this.delivery = delivery;
|
|
15
20
|
this.logger = logger;
|
|
21
|
+
this.coordinator = coordinator;
|
|
16
22
|
}
|
|
17
23
|
prepareInboundMessage(message) {
|
|
18
24
|
return this.module.prepareInboundExecution(message);
|
|
@@ -52,19 +58,21 @@ export class GatewayExecutor {
|
|
|
52
58
|
}
|
|
53
59
|
const recordedAtMs = Date.now();
|
|
54
60
|
this.logger.log("info", "handling inbound gateway message");
|
|
55
|
-
this.
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
for (const entry of preparedEntries) {
|
|
60
|
-
this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
|
|
61
|
-
deliveryTarget: entry.prepared.replyTarget,
|
|
62
|
-
sender: entry.message.sender,
|
|
63
|
-
text: entry.message.text,
|
|
64
|
-
attachments: entry.message.attachments,
|
|
61
|
+
return await this.coordinator.runExclusive(conversationKey, async () => {
|
|
62
|
+
this.store.appendJournal(createJournalEntry("mailbox_flush", recordedAtMs, conversationKey, {
|
|
63
|
+
entryIds: preparedEntries.map((entry) => entry.entry.id),
|
|
64
|
+
count: preparedEntries.length,
|
|
65
65
|
}));
|
|
66
|
-
|
|
67
|
-
|
|
66
|
+
for (const entry of preparedEntries) {
|
|
67
|
+
this.store.appendJournal(createJournalEntry("inbound_message", entry.entry.createdAtMs, conversationKey, {
|
|
68
|
+
deliveryTarget: entry.prepared.replyTarget,
|
|
69
|
+
sender: entry.message.sender,
|
|
70
|
+
text: entry.message.text,
|
|
71
|
+
attachments: entry.message.attachments,
|
|
72
|
+
}));
|
|
73
|
+
}
|
|
74
|
+
return await this.executePreparedBatch(preparedEntries, recordedAtMs);
|
|
75
|
+
});
|
|
68
76
|
}
|
|
69
77
|
async dispatchCronJob(job) {
|
|
70
78
|
const prepared = this.module.prepareCronExecution(job);
|
|
@@ -80,20 +88,61 @@ export class GatewayExecutor {
|
|
|
80
88
|
deliveryTarget: prepared.replyTarget?.target ?? null,
|
|
81
89
|
deliveryTopic: prepared.replyTarget?.topic ?? null,
|
|
82
90
|
}));
|
|
83
|
-
return await this.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
|
|
92
|
+
return await this.executePreparedBatch([
|
|
93
|
+
{
|
|
94
|
+
entry: null,
|
|
95
|
+
message: null,
|
|
96
|
+
prepared,
|
|
97
|
+
},
|
|
98
|
+
], recordedAtMs);
|
|
99
|
+
});
|
|
90
100
|
}
|
|
91
|
-
async
|
|
101
|
+
async dispatchScheduledJob(input) {
|
|
102
|
+
const prepared = prepareTextExecution(input.conversationKey, input.prompt, input.replyTarget);
|
|
103
|
+
const recordedAtMs = Date.now();
|
|
104
|
+
this.logger.log("info", "dispatching scheduled gateway job");
|
|
105
|
+
return await this.coordinator.runExclusive(prepared.conversationKey, async () => {
|
|
106
|
+
this.store.appendJournal(createJournalEntry("cron_dispatch", recordedAtMs, prepared.conversationKey, {
|
|
107
|
+
id: input.jobId,
|
|
108
|
+
kind: input.jobKind,
|
|
109
|
+
promptParts: prepared.promptParts,
|
|
110
|
+
deliveryChannel: prepared.replyTarget?.channel ?? null,
|
|
111
|
+
deliveryTarget: prepared.replyTarget?.target ?? null,
|
|
112
|
+
deliveryTopic: prepared.replyTarget?.topic ?? null,
|
|
113
|
+
}));
|
|
114
|
+
return await this.executePreparedBatch([
|
|
115
|
+
{
|
|
116
|
+
entry: null,
|
|
117
|
+
message: null,
|
|
118
|
+
prepared,
|
|
119
|
+
},
|
|
120
|
+
], recordedAtMs);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
async appendContextToConversation(input) {
|
|
124
|
+
const conversationKey = normalizeRequiredField(input.conversationKey, "conversation key");
|
|
125
|
+
const body = normalizeRequiredField(input.body, "context body");
|
|
126
|
+
await this.coordinator.runExclusive(conversationKey, async () => {
|
|
127
|
+
const sessionId = await this.ensureConversationSession(conversationKey, input.recordedAtMs, input.replyTarget === null ? [] : [input.replyTarget]);
|
|
128
|
+
const promptIdentity = this.createInternalPromptIdentity("context", input.recordedAtMs);
|
|
129
|
+
await this.appendPrompt(sessionId, promptIdentity.messageId, [
|
|
130
|
+
{
|
|
131
|
+
kind: "text",
|
|
132
|
+
partId: promptIdentity.partId,
|
|
133
|
+
text: body,
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
async executePreparedBatch(entries, recordedAtMs) {
|
|
92
139
|
const conversationKey = entries[0].prepared.conversationKey;
|
|
93
140
|
const persistedSessionId = this.store.getSessionBinding(conversationKey);
|
|
141
|
+
const preparedSessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
|
|
94
142
|
const replyTargets = dedupeReplyTargets(entries.flatMap((entry) => (entry.prepared.replyTarget === null ? [] : [entry.prepared.replyTarget])));
|
|
95
143
|
const [deliverySession] = replyTargets.length === 0 ? [null] : await this.delivery.openMany(replyTargets, "auto");
|
|
96
|
-
const promptResult = await this.executeDriver(entries, recordedAtMs,
|
|
144
|
+
const promptResult = await this.executeDriver(entries, recordedAtMs, preparedSessionId, deliverySession, replyTargets);
|
|
145
|
+
await this.cleanupResidualBusySession(promptResult.sessionId);
|
|
97
146
|
this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, promptResult.sessionId, recordedAtMs);
|
|
98
147
|
let delivered = false;
|
|
99
148
|
if (deliverySession !== null) {
|
|
@@ -112,6 +161,128 @@ export class GatewayExecutor {
|
|
|
112
161
|
recordedAtMs: BigInt(recordedAtMs),
|
|
113
162
|
};
|
|
114
163
|
}
|
|
164
|
+
async ensureConversationSession(conversationKey, recordedAtMs, replyTargets) {
|
|
165
|
+
const persistedSessionId = this.store.getSessionBinding(conversationKey);
|
|
166
|
+
let sessionId = await this.preparePersistedSessionForPrompt(persistedSessionId);
|
|
167
|
+
let retryMissingSession = true;
|
|
168
|
+
for (;;) {
|
|
169
|
+
if (sessionId === null) {
|
|
170
|
+
sessionId = await this.createSession(conversationKey);
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
await this.waitUntilIdle(sessionId);
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
catch (error) {
|
|
177
|
+
if (retryMissingSession && error instanceof MissingSessionCommandError) {
|
|
178
|
+
retryMissingSession = false;
|
|
179
|
+
sessionId = null;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
this.store.putSessionBindingIfUnchanged(conversationKey, persistedSessionId, sessionId, recordedAtMs);
|
|
186
|
+
this.initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs);
|
|
187
|
+
return sessionId;
|
|
188
|
+
}
|
|
189
|
+
initializeReplyTargetsIfMissing(sessionId, conversationKey, replyTargets, recordedAtMs) {
|
|
190
|
+
if (replyTargets.length === 0 || this.store.listSessionReplyTargets(sessionId).length > 0) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
this.store.replaceSessionReplyTargets({
|
|
194
|
+
sessionId,
|
|
195
|
+
conversationKey,
|
|
196
|
+
targets: replyTargets,
|
|
197
|
+
recordedAtMs,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
async preparePersistedSessionForPrompt(sessionId) {
|
|
201
|
+
if (sessionId === null) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
const found = await this.lookupSession(sessionId);
|
|
205
|
+
if (!found) {
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
if (!(await this.opencode.isSessionBusy(sessionId))) {
|
|
209
|
+
return sessionId;
|
|
210
|
+
}
|
|
211
|
+
this.logger.log("warn", `aborting busy gateway session before prompt dispatch: ${sessionId}`);
|
|
212
|
+
try {
|
|
213
|
+
await this.abortSessionAndWaitForSettle(sessionId);
|
|
214
|
+
return sessionId;
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
this.logger.log("warn", `busy gateway session did not settle and will be replaced: ${sessionId}: ${extractErrorMessage(error)}`);
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
async lookupSession(sessionId) {
|
|
222
|
+
const result = await this.opencode.execute({
|
|
223
|
+
kind: "lookupSession",
|
|
224
|
+
sessionId,
|
|
225
|
+
});
|
|
226
|
+
const lookup = expectCommandResult(result, "lookupSession");
|
|
227
|
+
return lookup.found;
|
|
228
|
+
}
|
|
229
|
+
async createSession(conversationKey) {
|
|
230
|
+
const result = await this.opencode.execute({
|
|
231
|
+
kind: "createSession",
|
|
232
|
+
title: `Gateway ${conversationKey}`,
|
|
233
|
+
});
|
|
234
|
+
return expectCommandResult(result, "createSession").sessionId;
|
|
235
|
+
}
|
|
236
|
+
async waitUntilIdle(sessionId) {
|
|
237
|
+
const result = await this.opencode.execute({
|
|
238
|
+
kind: "waitUntilIdle",
|
|
239
|
+
sessionId,
|
|
240
|
+
});
|
|
241
|
+
expectCommandResult(result, "waitUntilIdle");
|
|
242
|
+
}
|
|
243
|
+
async appendPrompt(sessionId, messageId, parts) {
|
|
244
|
+
const result = await this.opencode.execute({
|
|
245
|
+
kind: "appendPrompt",
|
|
246
|
+
sessionId,
|
|
247
|
+
messageId,
|
|
248
|
+
parts,
|
|
249
|
+
});
|
|
250
|
+
expectCommandResult(result, "appendPrompt");
|
|
251
|
+
}
|
|
252
|
+
async cleanupResidualBusySession(sessionId) {
|
|
253
|
+
if (!(await this.opencode.isSessionBusy(sessionId))) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
this.logger.log("warn", `aborting residual busy gateway session after prompt completion: ${sessionId}`);
|
|
257
|
+
try {
|
|
258
|
+
await this.abortSessionAndWaitForSettle(sessionId);
|
|
259
|
+
}
|
|
260
|
+
catch (error) {
|
|
261
|
+
this.logger.log("warn", `residual busy gateway session did not settle after abort: ${sessionId}: ${extractErrorMessage(error)}`);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
async abortSessionAndWaitForSettle(sessionId) {
|
|
265
|
+
await this.opencode.abortSession(sessionId);
|
|
266
|
+
const deadline = Date.now() + SESSION_ABORT_SETTLE_TIMEOUT_MS;
|
|
267
|
+
for (;;) {
|
|
268
|
+
if (!(await this.opencode.isSessionBusy(sessionId))) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
if (Date.now() >= deadline) {
|
|
272
|
+
throw new Error(`session remained busy after abort for ${SESSION_ABORT_SETTLE_TIMEOUT_MS}ms`);
|
|
273
|
+
}
|
|
274
|
+
await Bun.sleep(SESSION_ABORT_POLL_MS);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
createInternalPromptIdentity(prefix, recordedAtMs) {
|
|
278
|
+
const suffix = `${recordedAtMs}_${this.internalPromptSequence}`;
|
|
279
|
+
this.internalPromptSequence += 1;
|
|
280
|
+
const normalizedPrefix = prefix.replaceAll(":", "_");
|
|
281
|
+
return {
|
|
282
|
+
messageId: `msg_gateway_${normalizedPrefix}_${suffix}`,
|
|
283
|
+
partId: `prt_gateway_${normalizedPrefix}_${suffix}_0`,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
115
286
|
async executeDriver(entries, recordedAtMs, persistedSessionId, deliverySession, replyTargets) {
|
|
116
287
|
return await runOpencodeDriver({
|
|
117
288
|
module: this.module,
|
|
@@ -143,6 +314,18 @@ function createJournalEntry(kind, recordedAtMs, conversationKey, payload) {
|
|
|
143
314
|
payload,
|
|
144
315
|
};
|
|
145
316
|
}
|
|
317
|
+
function prepareTextExecution(conversationKey, prompt, replyTarget) {
|
|
318
|
+
return {
|
|
319
|
+
conversationKey: normalizeRequiredField(conversationKey, "conversation key"),
|
|
320
|
+
promptParts: [
|
|
321
|
+
{
|
|
322
|
+
kind: "text",
|
|
323
|
+
text: normalizeRequiredField(prompt, "schedule prompt"),
|
|
324
|
+
},
|
|
325
|
+
],
|
|
326
|
+
replyTarget,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
146
329
|
function mailboxEntryToInboundMessage(entry) {
|
|
147
330
|
return {
|
|
148
331
|
deliveryTarget: {
|
|
@@ -174,6 +357,30 @@ function normalizeRequiredField(value, field) {
|
|
|
174
357
|
}
|
|
175
358
|
return trimmed;
|
|
176
359
|
}
|
|
360
|
+
function extractErrorMessage(error) {
|
|
361
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
362
|
+
return error.message;
|
|
363
|
+
}
|
|
364
|
+
return String(error);
|
|
365
|
+
}
|
|
366
|
+
function expectCommandResult(result, expectedKind) {
|
|
367
|
+
if (result.kind === "error") {
|
|
368
|
+
if (result.code === "missingSession") {
|
|
369
|
+
throw new MissingSessionCommandError(result.message);
|
|
370
|
+
}
|
|
371
|
+
throw new Error(result.message);
|
|
372
|
+
}
|
|
373
|
+
if (result.kind !== expectedKind) {
|
|
374
|
+
throw new Error(`unexpected OpenCode result kind: expected ${expectedKind}, received ${result.kind}`);
|
|
375
|
+
}
|
|
376
|
+
return result;
|
|
377
|
+
}
|
|
378
|
+
class MissingSessionCommandError extends Error {
|
|
379
|
+
constructor(message) {
|
|
380
|
+
super(message);
|
|
381
|
+
this.name = "MissingSessionCommandError";
|
|
382
|
+
}
|
|
383
|
+
}
|
|
177
384
|
function createPromptKey(entry, recordedAtMs, index) {
|
|
178
385
|
if (entry.entry !== null) {
|
|
179
386
|
return `mailbox:${entry.entry.id}:${recordedAtMs}`;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const RUNTIME_CACHE_KEY = Symbol.for("opencode-gateway.runtime-cache");
|
|
2
|
+
export function getOrCreateRuntimeSingleton(key, factory) {
|
|
3
|
+
const cache = getRuntimeCache();
|
|
4
|
+
const existing = cache.get(key);
|
|
5
|
+
if (existing !== undefined) {
|
|
6
|
+
return existing;
|
|
7
|
+
}
|
|
8
|
+
const promise = factory().catch((error) => {
|
|
9
|
+
if (cache.get(key) === promise) {
|
|
10
|
+
cache.delete(key);
|
|
11
|
+
}
|
|
12
|
+
throw error;
|
|
13
|
+
});
|
|
14
|
+
cache.set(key, promise);
|
|
15
|
+
return promise;
|
|
16
|
+
}
|
|
17
|
+
export function clearRuntimeSingletonForTests(key) {
|
|
18
|
+
getRuntimeCache().delete(key);
|
|
19
|
+
}
|
|
20
|
+
function getRuntimeCache() {
|
|
21
|
+
const globalScope = globalThis;
|
|
22
|
+
let cache = globalScope[RUNTIME_CACHE_KEY];
|
|
23
|
+
if (cache === undefined) {
|
|
24
|
+
cache = new Map();
|
|
25
|
+
globalScope[RUNTIME_CACHE_KEY] = cache;
|
|
26
|
+
}
|
|
27
|
+
return cache;
|
|
28
|
+
}
|
package/dist/session/context.js
CHANGED
|
@@ -31,6 +31,10 @@ export class GatewaySessionContext {
|
|
|
31
31
|
`- Current reply topic: ${target.topic ?? "none"}`,
|
|
32
32
|
"- Unless the user explicitly asks otherwise, channel-aware actions should default to this target.",
|
|
33
33
|
"- If the user asks to start a fresh channel session, use channel_new_session.",
|
|
34
|
+
"- If the user asks for a one-shot reminder or relative-time follow-up, prefer schedule_once.",
|
|
35
|
+
"- If the user asks for a recurring schedule, prefer cron_upsert.",
|
|
36
|
+
"- Use schedule_list and schedule_status to inspect existing scheduled jobs and recent run results.",
|
|
37
|
+
"- Scheduled results delivered to this channel are automatically appended to this session as context.",
|
|
34
38
|
].join("\n");
|
|
35
39
|
}
|
|
36
40
|
return [
|
|
@@ -39,6 +43,8 @@ export class GatewaySessionContext {
|
|
|
39
43
|
...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
|
|
40
44
|
"- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
|
|
41
45
|
"- If the user asks to start a fresh channel session for this route, use channel_new_session.",
|
|
46
|
+
"- Prefer schedule_once for one-shot reminders and cron_upsert for recurring schedules.",
|
|
47
|
+
"- Use schedule_list and schedule_status to inspect scheduled jobs and recent run results.",
|
|
42
48
|
].join("\n");
|
|
43
49
|
}
|
|
44
50
|
}
|
package/dist/store/migrations.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
const LATEST_SCHEMA_VERSION =
|
|
1
|
+
const LATEST_SCHEMA_VERSION = 7;
|
|
2
2
|
export function migrateGatewayDatabase(db) {
|
|
3
3
|
db.exec("PRAGMA journal_mode = WAL;");
|
|
4
4
|
db.exec("PRAGMA foreign_keys = ON;");
|
|
@@ -28,6 +28,10 @@ export function migrateGatewayDatabase(db) {
|
|
|
28
28
|
}
|
|
29
29
|
if (currentVersion === 5) {
|
|
30
30
|
migrateToV6(db);
|
|
31
|
+
currentVersion = 6;
|
|
32
|
+
}
|
|
33
|
+
if (currentVersion === 6) {
|
|
34
|
+
migrateToV7(db);
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
function readUserVersion(db) {
|
|
@@ -179,5 +183,15 @@ function migrateToV6(db) {
|
|
|
179
183
|
CREATE INDEX pending_questions_session_id_created_at_ms_idx
|
|
180
184
|
ON pending_questions (session_id, created_at_ms);
|
|
181
185
|
`);
|
|
186
|
+
db.exec("PRAGMA user_version = 6;");
|
|
187
|
+
}
|
|
188
|
+
function migrateToV7(db) {
|
|
189
|
+
db.exec(`
|
|
190
|
+
ALTER TABLE cron_jobs
|
|
191
|
+
ADD COLUMN kind TEXT NOT NULL DEFAULT 'cron';
|
|
192
|
+
|
|
193
|
+
ALTER TABLE cron_jobs
|
|
194
|
+
ADD COLUMN run_at_ms INTEGER;
|
|
195
|
+
`);
|
|
182
196
|
db.exec(`PRAGMA user_version = ${LATEST_SCHEMA_VERSION};`);
|
|
183
197
|
}
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -3,6 +3,17 @@ import type { BindingDeliveryTarget } from "../binding";
|
|
|
3
3
|
import type { GatewayQuestionInfo, PendingQuestionRecord } from "../questions/types";
|
|
4
4
|
export type RuntimeJournalKind = "inbound_message" | "cron_dispatch" | "delivery" | "mailbox_enqueue" | "mailbox_flush";
|
|
5
5
|
export type CronRunStatus = "running" | "succeeded" | "failed" | "abandoned";
|
|
6
|
+
export type ScheduleJobKind = "cron" | "once";
|
|
7
|
+
export type CronRunRecord = {
|
|
8
|
+
id: number;
|
|
9
|
+
jobId: string;
|
|
10
|
+
scheduledForMs: number;
|
|
11
|
+
startedAtMs: number;
|
|
12
|
+
finishedAtMs: number | null;
|
|
13
|
+
status: CronRunStatus;
|
|
14
|
+
responseText: string | null;
|
|
15
|
+
errorMessage: string | null;
|
|
16
|
+
};
|
|
6
17
|
export type RuntimeJournalEntry = {
|
|
7
18
|
kind: RuntimeJournalKind;
|
|
8
19
|
recordedAtMs: number;
|
|
@@ -11,7 +22,9 @@ export type RuntimeJournalEntry = {
|
|
|
11
22
|
};
|
|
12
23
|
export type CronJobRecord = {
|
|
13
24
|
id: string;
|
|
14
|
-
|
|
25
|
+
kind: ScheduleJobKind;
|
|
26
|
+
schedule: string | null;
|
|
27
|
+
runAtMs: number | null;
|
|
15
28
|
prompt: string;
|
|
16
29
|
deliveryChannel: string | null;
|
|
17
30
|
deliveryTarget: string | null;
|
|
@@ -43,7 +56,9 @@ export type MailboxEntryAttachmentRecord = {
|
|
|
43
56
|
};
|
|
44
57
|
export type PersistCronJobInput = {
|
|
45
58
|
id: string;
|
|
46
|
-
|
|
59
|
+
kind: ScheduleJobKind;
|
|
60
|
+
schedule: string | null;
|
|
61
|
+
runAtMs: number | null;
|
|
47
62
|
prompt: string;
|
|
48
63
|
deliveryChannel: string | null;
|
|
49
64
|
deliveryTarget: string | null;
|
|
@@ -119,6 +134,8 @@ export declare class SqliteStore {
|
|
|
119
134
|
listDueCronJobs(nowMs: number, limit: number): CronJobRecord[];
|
|
120
135
|
removeCronJob(id: string): boolean;
|
|
121
136
|
updateCronJobNextRun(id: string, nextRunAtMs: number, recordedAtMs: number): void;
|
|
137
|
+
setCronJobEnabled(id: string, enabled: boolean, recordedAtMs: number): void;
|
|
138
|
+
listCronRuns(jobId: string, limit: number): CronRunRecord[];
|
|
122
139
|
insertCronRun(jobId: string, scheduledForMs: number, startedAtMs: number): number;
|
|
123
140
|
finishCronRun(runId: number, status: Exclude<CronRunStatus, "running">, finishedAtMs: number, responseText: string | null, errorMessage: string | null): void;
|
|
124
141
|
abandonRunningCronRuns(finishedAtMs: number): number;
|