opencode-gateway 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/dist/binding/execution.d.ts +24 -0
  4. package/dist/binding/execution.js +1 -0
  5. package/dist/binding/gateway.d.ts +71 -0
  6. package/dist/binding/gateway.js +1 -0
  7. package/dist/binding/index.d.ts +15 -0
  8. package/dist/binding/index.js +4 -0
  9. package/dist/binding/opencode.d.ts +123 -0
  10. package/dist/binding/opencode.js +1 -0
  11. package/dist/cli/args.d.ts +9 -0
  12. package/dist/cli/args.js +53 -0
  13. package/dist/cli/doctor.d.ts +6 -0
  14. package/dist/cli/doctor.js +59 -0
  15. package/dist/cli/init.d.ts +6 -0
  16. package/dist/cli/init.js +35 -0
  17. package/dist/cli/opencode-config.d.ts +10 -0
  18. package/dist/cli/opencode-config.js +62 -0
  19. package/dist/cli/paths.d.ts +7 -0
  20. package/dist/cli/paths.js +22 -0
  21. package/dist/cli/templates.d.ts +1 -0
  22. package/dist/cli/templates.js +26 -0
  23. package/dist/cli.d.ts +2 -0
  24. package/dist/cli.js +314 -0
  25. package/dist/config/cron.d.ts +7 -0
  26. package/dist/config/cron.js +52 -0
  27. package/dist/config/gateway.d.ts +26 -0
  28. package/dist/config/gateway.js +142 -0
  29. package/dist/config/paths.d.ts +10 -0
  30. package/dist/config/paths.js +33 -0
  31. package/dist/config/telegram.d.ts +13 -0
  32. package/dist/config/telegram.js +91 -0
  33. package/dist/cron/runtime.d.ts +40 -0
  34. package/dist/cron/runtime.js +237 -0
  35. package/dist/delivery/telegram.d.ts +16 -0
  36. package/dist/delivery/telegram.js +75 -0
  37. package/dist/delivery/text.d.ts +21 -0
  38. package/dist/delivery/text.js +175 -0
  39. package/dist/gateway.d.ts +33 -0
  40. package/dist/gateway.js +105 -0
  41. package/dist/host/file-sender.d.ts +16 -0
  42. package/dist/host/file-sender.js +59 -0
  43. package/dist/host/noop.d.ts +4 -0
  44. package/dist/host/noop.js +14 -0
  45. package/dist/host/transport.d.ts +9 -0
  46. package/dist/host/transport.js +35 -0
  47. package/dist/index.d.ts +7 -0
  48. package/dist/index.js +52 -0
  49. package/dist/mailbox/router.d.ts +7 -0
  50. package/dist/mailbox/router.js +16 -0
  51. package/dist/media/mime.d.ts +2 -0
  52. package/dist/media/mime.js +45 -0
  53. package/dist/opencode/adapter.d.ts +19 -0
  54. package/dist/opencode/adapter.js +291 -0
  55. package/dist/opencode/driver-hub.d.ts +15 -0
  56. package/dist/opencode/driver-hub.js +82 -0
  57. package/dist/opencode/event-normalize.d.ts +48 -0
  58. package/dist/opencode/event-normalize.js +48 -0
  59. package/dist/opencode/event-stream.d.ts +23 -0
  60. package/dist/opencode/event-stream.js +65 -0
  61. package/dist/opencode/events.d.ts +2 -0
  62. package/dist/opencode/events.js +1 -0
  63. package/dist/questions/client.d.ts +5 -0
  64. package/dist/questions/client.js +36 -0
  65. package/dist/questions/format.d.ts +3 -0
  66. package/dist/questions/format.js +36 -0
  67. package/dist/questions/normalize.d.ts +10 -0
  68. package/dist/questions/normalize.js +45 -0
  69. package/dist/questions/parser.d.ts +11 -0
  70. package/dist/questions/parser.js +96 -0
  71. package/dist/questions/runtime.d.ts +53 -0
  72. package/dist/questions/runtime.js +195 -0
  73. package/dist/questions/types.d.ts +22 -0
  74. package/dist/questions/types.js +1 -0
  75. package/dist/runtime/attachments.d.ts +3 -0
  76. package/dist/runtime/attachments.js +12 -0
  77. package/dist/runtime/executor.d.ts +24 -0
  78. package/dist/runtime/executor.js +188 -0
  79. package/dist/runtime/mailbox.d.ts +25 -0
  80. package/dist/runtime/mailbox.js +112 -0
  81. package/dist/runtime/opencode-runner.d.ts +26 -0
  82. package/dist/runtime/opencode-runner.js +79 -0
  83. package/dist/session/context.d.ts +10 -0
  84. package/dist/session/context.js +44 -0
  85. package/dist/session/conversation-key.d.ts +3 -0
  86. package/dist/session/conversation-key.js +3 -0
  87. package/dist/session/switcher.d.ts +25 -0
  88. package/dist/session/switcher.js +59 -0
  89. package/dist/store/migrations.d.ts +2 -0
  90. package/dist/store/migrations.js +183 -0
  91. package/dist/store/sqlite.d.ts +127 -0
  92. package/dist/store/sqlite.js +678 -0
  93. package/dist/telegram/client.d.ts +35 -0
  94. package/dist/telegram/client.js +179 -0
  95. package/dist/telegram/media.d.ts +13 -0
  96. package/dist/telegram/media.js +65 -0
  97. package/dist/telegram/normalize.d.ts +47 -0
  98. package/dist/telegram/normalize.js +119 -0
  99. package/dist/telegram/poller.d.ts +29 -0
  100. package/dist/telegram/poller.js +97 -0
  101. package/dist/telegram/runtime.d.ts +51 -0
  102. package/dist/telegram/runtime.js +133 -0
  103. package/dist/telegram/state.d.ts +36 -0
  104. package/dist/telegram/state.js +128 -0
  105. package/dist/telegram/types.d.ts +80 -0
  106. package/dist/telegram/types.js +1 -0
  107. package/dist/tools/channel-new-session.d.ts +4 -0
  108. package/dist/tools/channel-new-session.js +27 -0
  109. package/dist/tools/channel-send-file.d.ts +9 -0
  110. package/dist/tools/channel-send-file.js +27 -0
  111. package/dist/tools/channel-target.d.ts +7 -0
  112. package/dist/tools/channel-target.js +28 -0
  113. package/dist/tools/cron-list.d.ts +3 -0
  114. package/dist/tools/cron-list.js +34 -0
  115. package/dist/tools/cron-remove.d.ts +3 -0
  116. package/dist/tools/cron-remove.js +12 -0
  117. package/dist/tools/cron-run.d.ts +3 -0
  118. package/dist/tools/cron-run.js +20 -0
  119. package/dist/tools/cron-upsert.d.ts +3 -0
  120. package/dist/tools/cron-upsert.js +37 -0
  121. package/dist/tools/gateway-dispatch-cron.d.ts +3 -0
  122. package/dist/tools/gateway-dispatch-cron.js +33 -0
  123. package/dist/tools/gateway-status.d.ts +3 -0
  124. package/dist/tools/gateway-status.js +25 -0
  125. package/dist/tools/telegram-send-test.d.ts +3 -0
  126. package/dist/tools/telegram-send-test.js +26 -0
  127. package/dist/tools/telegram-status.d.ts +3 -0
  128. package/dist/tools/telegram-status.js +49 -0
  129. package/dist/tools/time.d.ts +3 -0
  130. package/dist/tools/time.js +25 -0
  131. package/dist/utils/error.d.ts +1 -0
  132. package/dist/utils/error.js +57 -0
  133. package/generated/wasm/pkg/opencode_gateway_ffi.d.ts +23 -0
  134. package/generated/wasm/pkg/opencode_gateway_ffi.js +574 -0
  135. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
  136. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm.d.ts +22 -0
  137. package/package.json +61 -0
@@ -0,0 +1,112 @@
1
+ import { formatError } from "../utils/error";
2
+ import { deleteInboundAttachmentFiles } from "./attachments";
3
+ const RETRY_DELAY_MS = 1_000;
4
+ export class GatewayMailboxRuntime {
5
+ executor;
6
+ store;
7
+ logger;
8
+ config;
9
+ questions;
10
+ activeMailboxes = new Set();
11
+ scheduledMailboxes = new Map();
12
+ constructor(executor, store, logger, config, questions) {
13
+ this.executor = executor;
14
+ this.store = store;
15
+ this.logger = logger;
16
+ this.config = config;
17
+ this.questions = questions;
18
+ }
19
+ start() {
20
+ for (const mailboxKey of this.store.listPendingMailboxKeys()) {
21
+ this.scheduleImmediate(mailboxKey);
22
+ }
23
+ }
24
+ async enqueueInboundMessage(message, sourceKind, externalId) {
25
+ if (await this.questions.tryHandleInboundMessage(message)) {
26
+ return;
27
+ }
28
+ const prepared = this.executor.prepareInboundMessage(message);
29
+ const recordedAtMs = Date.now();
30
+ this.store.enqueueMailboxEntry({
31
+ mailboxKey: prepared.conversationKey,
32
+ sourceKind,
33
+ externalId,
34
+ sender: message.sender,
35
+ text: message.text,
36
+ attachments: message.attachments,
37
+ replyChannel: message.deliveryTarget.channel,
38
+ replyTarget: message.deliveryTarget.target,
39
+ replyTopic: message.deliveryTarget.topic,
40
+ recordedAtMs,
41
+ });
42
+ this.store.appendJournal(createJournalEntry("mailbox_enqueue", recordedAtMs, prepared.conversationKey, {
43
+ sourceKind,
44
+ externalId,
45
+ sender: message.sender,
46
+ text: message.text,
47
+ attachments: message.attachments,
48
+ deliveryTarget: message.deliveryTarget,
49
+ }));
50
+ this.scheduleAfterEnqueue(prepared.conversationKey);
51
+ }
52
+ scheduleAfterEnqueue(mailboxKey) {
53
+ if (this.activeMailboxes.has(mailboxKey) || this.scheduledMailboxes.has(mailboxKey)) {
54
+ return;
55
+ }
56
+ this.schedule(mailboxKey, this.config.batchReplies ? this.config.batchWindowMs : 0);
57
+ }
58
+ scheduleImmediate(mailboxKey) {
59
+ if (this.activeMailboxes.has(mailboxKey) || this.scheduledMailboxes.has(mailboxKey)) {
60
+ return;
61
+ }
62
+ this.schedule(mailboxKey, 0);
63
+ }
64
+ scheduleRetry(mailboxKey) {
65
+ if (this.activeMailboxes.has(mailboxKey) || this.scheduledMailboxes.has(mailboxKey)) {
66
+ return;
67
+ }
68
+ this.schedule(mailboxKey, RETRY_DELAY_MS);
69
+ }
70
+ schedule(mailboxKey, delayMs) {
71
+ const handle = setTimeout(() => {
72
+ this.scheduledMailboxes.delete(mailboxKey);
73
+ void this.processMailbox(mailboxKey);
74
+ }, delayMs);
75
+ this.scheduledMailboxes.set(mailboxKey, handle);
76
+ }
77
+ async processMailbox(mailboxKey) {
78
+ if (this.activeMailboxes.has(mailboxKey)) {
79
+ return;
80
+ }
81
+ this.activeMailboxes.add(mailboxKey);
82
+ try {
83
+ const entries = this.store.listMailboxEntries(mailboxKey);
84
+ if (entries.length === 0) {
85
+ return;
86
+ }
87
+ const batch = this.config.batchReplies ? entries : [entries[0]];
88
+ await this.executor.executeMailboxEntries(batch);
89
+ this.store.deleteMailboxEntries(batch.map((entry) => entry.id));
90
+ await deleteInboundAttachmentFiles(batch, this.logger);
91
+ }
92
+ catch (error) {
93
+ this.logger.log("warn", `mailbox flush failed for ${mailboxKey}: ${formatError(error)}`);
94
+ this.scheduleRetry(mailboxKey);
95
+ return;
96
+ }
97
+ finally {
98
+ this.activeMailboxes.delete(mailboxKey);
99
+ }
100
+ if (this.store.listMailboxEntries(mailboxKey).length > 0) {
101
+ this.scheduleImmediate(mailboxKey);
102
+ }
103
+ }
104
+ }
105
+ function createJournalEntry(kind, recordedAtMs, conversationKey, payload) {
106
+ return {
107
+ kind,
108
+ recordedAtMs,
109
+ conversationKey,
110
+ payload,
111
+ };
112
+ }
@@ -0,0 +1,26 @@
1
+ import type { BindingPromptPart, GatewayBindingModule } from "../binding";
2
+ import type { TextDeliverySession } from "../delivery/text";
3
+ import type { OpencodeSdkAdapter } from "../opencode/adapter";
4
+ import type { OpencodeEventHub } from "../opencode/events";
5
+ export type OpencodeDriverPrompt = {
6
+ promptKey: string;
7
+ parts: BindingPromptPart[];
8
+ };
9
+ export type PromptExecutionResult = {
10
+ sessionId: string;
11
+ responseText: string;
12
+ finalText: string | null;
13
+ };
14
+ type GatewayOpencodeRuntimeLike = Pick<OpencodeSdkAdapter, "execute">;
15
+ type TextDeliverySessionLike = Pick<TextDeliverySession, "mode" | "preview">;
16
+ export declare function runOpencodeDriver(options: {
17
+ module: GatewayBindingModule;
18
+ opencode: GatewayOpencodeRuntimeLike;
19
+ events: OpencodeEventHub;
20
+ conversationKey: string;
21
+ persistedSessionId: string | null;
22
+ deliverySession: TextDeliverySessionLike | null;
23
+ prompts: OpencodeDriverPrompt[];
24
+ onSessionAvailable?: (sessionId: string) => Promise<void> | void;
25
+ }): Promise<PromptExecutionResult>;
26
+ export {};
@@ -0,0 +1,79 @@
1
+ const DEFAULT_FLUSH_INTERVAL_MS = 400;
2
+ export async function runOpencodeDriver(options) {
3
+ const driver = new options.module.OpencodeExecutionDriver({
4
+ conversationKey: options.conversationKey,
5
+ persistedSessionId: options.persistedSessionId,
6
+ mode: options.deliverySession?.mode === "progressive" ? "progressive" : "oneshot",
7
+ flushIntervalMs: DEFAULT_FLUSH_INTERVAL_MS,
8
+ prompts: options.prompts,
9
+ });
10
+ let registration = null;
11
+ let activeSessionId = null;
12
+ try {
13
+ let step = driver.start();
14
+ for (;;) {
15
+ if (step.kind === "command") {
16
+ activeSessionId = await syncSessionContext(activeSessionId, step.command, options.onSessionAvailable);
17
+ registration = syncDriverRegistration(registration, step.command, driver, options);
18
+ const result = await options.opencode.execute(step.command);
19
+ activeSessionId = await syncSessionContext(activeSessionId, result, options.onSessionAvailable);
20
+ registration = syncDriverRegistration(registration, result, driver, options);
21
+ step = driver.resume(result);
22
+ continue;
23
+ }
24
+ if (step.kind === "complete") {
25
+ return {
26
+ sessionId: step.sessionId,
27
+ responseText: step.responseText,
28
+ finalText: step.finalText,
29
+ };
30
+ }
31
+ throw new Error(step.message);
32
+ }
33
+ }
34
+ finally {
35
+ registration?.dispose();
36
+ driver.free?.();
37
+ }
38
+ }
39
+ async function syncSessionContext(currentSessionId, value, onSessionAvailable) {
40
+ const sessionId = sessionIdFromCommandOrResult(value);
41
+ if (sessionId === null || sessionId === currentSessionId) {
42
+ return currentSessionId;
43
+ }
44
+ await onSessionAvailable?.(sessionId);
45
+ return sessionId;
46
+ }
47
+ function syncDriverRegistration(registration, value, driver, options) {
48
+ const sessionId = sessionIdFromCommandOrResult(value);
49
+ if (sessionId === null) {
50
+ return registration;
51
+ }
52
+ if (registration !== null) {
53
+ registration.updateSession(sessionId);
54
+ return registration;
55
+ }
56
+ const deliverySession = options.deliverySession;
57
+ return options.events.registerDriver(sessionId, driver, async (snapshot) => {
58
+ if (deliverySession?.mode !== "progressive") {
59
+ return;
60
+ }
61
+ await deliverySession.preview(snapshot);
62
+ });
63
+ }
64
+ function sessionIdFromCommandOrResult(value) {
65
+ switch (value.kind) {
66
+ case "lookupSession":
67
+ case "waitUntilIdle":
68
+ case "appendPrompt":
69
+ case "sendPromptAsync":
70
+ case "awaitPromptResponse":
71
+ case "readMessage":
72
+ case "listMessages":
73
+ return value.sessionId;
74
+ case "createSession":
75
+ return "sessionId" in value ? value.sessionId : null;
76
+ case "error":
77
+ return value.sessionId;
78
+ }
79
+ }
@@ -0,0 +1,10 @@
1
+ import type { BindingDeliveryTarget } from "../binding";
2
+ import type { SqliteStore } from "../store/sqlite";
3
+ export declare class GatewaySessionContext {
4
+ private readonly store;
5
+ constructor(store: SqliteStore);
6
+ replaceReplyTargets(sessionId: string, conversationKey: string, targets: BindingDeliveryTarget[], recordedAtMs: number): void;
7
+ listReplyTargets(sessionId: string): BindingDeliveryTarget[];
8
+ getDefaultReplyTarget(sessionId: string): BindingDeliveryTarget | null;
9
+ buildSystemPrompt(sessionId: string): string | null;
10
+ }
@@ -0,0 +1,44 @@
1
+ export class GatewaySessionContext {
2
+ store;
3
+ constructor(store) {
4
+ this.store = store;
5
+ }
6
+ replaceReplyTargets(sessionId, conversationKey, targets, recordedAtMs) {
7
+ this.store.replaceSessionReplyTargets({
8
+ sessionId,
9
+ conversationKey,
10
+ targets,
11
+ recordedAtMs,
12
+ });
13
+ }
14
+ listReplyTargets(sessionId) {
15
+ return this.store.listSessionReplyTargets(sessionId);
16
+ }
17
+ getDefaultReplyTarget(sessionId) {
18
+ return this.store.getDefaultSessionReplyTarget(sessionId);
19
+ }
20
+ buildSystemPrompt(sessionId) {
21
+ const targets = this.listReplyTargets(sessionId);
22
+ if (targets.length === 0) {
23
+ return null;
24
+ }
25
+ if (targets.length === 1) {
26
+ const target = targets[0];
27
+ return [
28
+ "Gateway context:",
29
+ `- Current message source channel: ${target.channel}`,
30
+ `- Current reply target id: ${target.target}`,
31
+ `- Current reply topic: ${target.topic ?? "none"}`,
32
+ "- Unless the user explicitly asks otherwise, channel-aware actions should default to this target.",
33
+ "- If the user asks to start a fresh channel session, use channel_new_session.",
34
+ ].join("\n");
35
+ }
36
+ return [
37
+ "Gateway context:",
38
+ `- This session currently fans out to ${targets.length} reply targets.`,
39
+ ...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
40
+ "- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
41
+ "- If the user asks to start a fresh channel session for this route, use channel_new_session.",
42
+ ].join("\n");
43
+ }
44
+ }
@@ -0,0 +1,3 @@
1
+ import type { BindingDeliveryTarget, GatewayContract } from "../binding";
2
+ import type { GatewayMailboxRouter } from "../mailbox/router";
3
+ export declare function resolveConversationKeyForTarget(target: BindingDeliveryTarget, router: GatewayMailboxRouter, contract: Pick<GatewayContract, "conversationKeyForDeliveryTarget">): string;
@@ -0,0 +1,3 @@
1
+ export function resolveConversationKeyForTarget(target, router, contract) {
2
+ return router.resolve(target) ?? contract.conversationKeyForDeliveryTarget(target);
3
+ }
@@ -0,0 +1,25 @@
1
+ import type { BindingDeliveryTarget, GatewayContract } from "../binding";
2
+ import type { GatewayMailboxRouter } from "../mailbox/router";
3
+ import type { OpencodeSdkAdapter } from "../opencode/adapter";
4
+ import type { SqliteStore } from "../store/sqlite";
5
+ import type { GatewaySessionContext } from "./context";
6
+ export type ChannelSessionSwitchResult = {
7
+ channel: string;
8
+ target: string;
9
+ topic: string | null;
10
+ conversationKey: string;
11
+ previousSessionId: string | null;
12
+ newSessionId: string;
13
+ effectiveOn: "next_message";
14
+ };
15
+ export declare class ChannelSessionSwitcher {
16
+ private readonly store;
17
+ private readonly sessions;
18
+ private readonly router;
19
+ private readonly contract;
20
+ private readonly opencode;
21
+ private readonly telegramEnabled;
22
+ constructor(store: SqliteStore, sessions: GatewaySessionContext, router: GatewayMailboxRouter, contract: Pick<GatewayContract, "conversationKeyForDeliveryTarget">, opencode: Pick<OpencodeSdkAdapter, "createFreshSession">, telegramEnabled: boolean);
23
+ hasEnabledChannel(): boolean;
24
+ createAndSwitchSession(target: BindingDeliveryTarget, title: string | null): Promise<ChannelSessionSwitchResult>;
25
+ }
@@ -0,0 +1,59 @@
1
+ import { resolveConversationKeyForTarget } from "./conversation-key";
2
+ export class ChannelSessionSwitcher {
3
+ store;
4
+ sessions;
5
+ router;
6
+ contract;
7
+ opencode;
8
+ telegramEnabled;
9
+ constructor(store, sessions, router, contract, opencode, telegramEnabled) {
10
+ this.store = store;
11
+ this.sessions = sessions;
12
+ this.router = router;
13
+ this.contract = contract;
14
+ this.opencode = opencode;
15
+ this.telegramEnabled = telegramEnabled;
16
+ }
17
+ hasEnabledChannel() {
18
+ return this.telegramEnabled;
19
+ }
20
+ async createAndSwitchSession(target, title) {
21
+ assertSupportedTarget(target, this.telegramEnabled);
22
+ const conversationKey = resolveConversationKeyForTarget(target, this.router, this.contract);
23
+ const previousSessionId = this.store.getSessionBinding(conversationKey);
24
+ const recordedAtMs = Date.now();
25
+ const newSessionId = await this.opencode.createFreshSession(defaultSessionTitle(title, target));
26
+ if (previousSessionId !== null) {
27
+ this.store.deletePendingQuestionsForSession(previousSessionId);
28
+ this.store.clearSessionReplyTargets(previousSessionId);
29
+ }
30
+ this.store.putSessionBinding(conversationKey, newSessionId, recordedAtMs);
31
+ this.sessions.replaceReplyTargets(newSessionId, conversationKey, [target], recordedAtMs);
32
+ return {
33
+ channel: target.channel,
34
+ target: target.target,
35
+ topic: target.topic,
36
+ conversationKey,
37
+ previousSessionId,
38
+ newSessionId,
39
+ effectiveOn: "next_message",
40
+ };
41
+ }
42
+ }
43
+ function assertSupportedTarget(target, telegramEnabled) {
44
+ if (target.channel !== "telegram") {
45
+ throw new Error(`unsupported channel for session switching: ${target.channel}`);
46
+ }
47
+ if (!telegramEnabled) {
48
+ throw new Error("telegram is not enabled");
49
+ }
50
+ }
51
+ function defaultSessionTitle(title, target) {
52
+ const normalized = title?.trim() ?? "";
53
+ if (normalized.length > 0) {
54
+ return normalized;
55
+ }
56
+ return target.topic === null
57
+ ? `Gateway ${target.channel}:${target.target}`
58
+ : `Gateway ${target.channel}:${target.target} topic ${target.topic}`;
59
+ }
@@ -0,0 +1,2 @@
1
+ import type { Database } from "bun:sqlite";
2
+ export declare function migrateGatewayDatabase(db: Database): void;
@@ -0,0 +1,183 @@
1
+ const LATEST_SCHEMA_VERSION = 6;
2
+ export function migrateGatewayDatabase(db) {
3
+ db.exec("PRAGMA journal_mode = WAL;");
4
+ db.exec("PRAGMA foreign_keys = ON;");
5
+ let currentVersion = readUserVersion(db);
6
+ if (currentVersion > LATEST_SCHEMA_VERSION) {
7
+ throw new Error(`unsupported gateway database schema version: ${currentVersion}`);
8
+ }
9
+ if (currentVersion === 0) {
10
+ migrateToV1(db);
11
+ currentVersion = 1;
12
+ }
13
+ if (currentVersion === 1) {
14
+ migrateToV2(db);
15
+ currentVersion = 2;
16
+ }
17
+ if (currentVersion === 2) {
18
+ migrateToV3(db);
19
+ currentVersion = 3;
20
+ }
21
+ if (currentVersion === 3) {
22
+ migrateToV4(db);
23
+ currentVersion = 4;
24
+ }
25
+ if (currentVersion === 4) {
26
+ migrateToV5(db);
27
+ currentVersion = 5;
28
+ }
29
+ if (currentVersion === 5) {
30
+ migrateToV6(db);
31
+ }
32
+ }
33
+ function readUserVersion(db) {
34
+ const row = db.query("PRAGMA user_version;").get();
35
+ return row?.user_version ?? 0;
36
+ }
37
+ function migrateToV1(db) {
38
+ db.exec(`
39
+ CREATE TABLE session_bindings (
40
+ conversation_key TEXT PRIMARY KEY NOT NULL,
41
+ session_id TEXT NOT NULL,
42
+ updated_at_ms INTEGER NOT NULL
43
+ );
44
+
45
+ CREATE TABLE runtime_journal (
46
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
47
+ kind TEXT NOT NULL,
48
+ recorded_at_ms INTEGER NOT NULL,
49
+ conversation_key TEXT,
50
+ payload_json TEXT NOT NULL
51
+ );
52
+
53
+ CREATE INDEX runtime_journal_kind_recorded_at_ms_idx
54
+ ON runtime_journal (kind, recorded_at_ms);
55
+ `);
56
+ db.exec("PRAGMA user_version = 1;");
57
+ }
58
+ function migrateToV2(db) {
59
+ db.exec(`
60
+ CREATE TABLE kv_state (
61
+ key TEXT PRIMARY KEY NOT NULL,
62
+ value TEXT NOT NULL,
63
+ updated_at_ms INTEGER NOT NULL
64
+ );
65
+ `);
66
+ db.exec("PRAGMA user_version = 2;");
67
+ }
68
+ function migrateToV3(db) {
69
+ db.exec(`
70
+ CREATE TABLE cron_jobs (
71
+ id TEXT PRIMARY KEY NOT NULL,
72
+ schedule TEXT NOT NULL,
73
+ prompt TEXT NOT NULL,
74
+ delivery_channel TEXT,
75
+ delivery_target TEXT,
76
+ delivery_topic TEXT,
77
+ enabled INTEGER NOT NULL,
78
+ next_run_at_ms INTEGER NOT NULL,
79
+ created_at_ms INTEGER NOT NULL,
80
+ updated_at_ms INTEGER NOT NULL
81
+ );
82
+
83
+ CREATE INDEX cron_jobs_enabled_next_run_at_ms_idx
84
+ ON cron_jobs (enabled, next_run_at_ms);
85
+
86
+ CREATE TABLE cron_runs (
87
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
88
+ job_id TEXT NOT NULL,
89
+ scheduled_for_ms INTEGER NOT NULL,
90
+ started_at_ms INTEGER NOT NULL,
91
+ finished_at_ms INTEGER,
92
+ status TEXT NOT NULL,
93
+ response_text TEXT,
94
+ error_message TEXT
95
+ );
96
+
97
+ CREATE INDEX cron_runs_job_id_started_at_ms_idx
98
+ ON cron_runs (job_id, started_at_ms DESC);
99
+
100
+ CREATE INDEX cron_runs_status_started_at_ms_idx
101
+ ON cron_runs (status, started_at_ms DESC);
102
+ `);
103
+ db.exec("PRAGMA user_version = 3;");
104
+ }
105
+ function migrateToV4(db) {
106
+ db.exec(`
107
+ CREATE TABLE mailbox_entries (
108
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
109
+ mailbox_key TEXT NOT NULL,
110
+ source_kind TEXT NOT NULL,
111
+ external_id TEXT NOT NULL,
112
+ sender TEXT NOT NULL,
113
+ body TEXT NOT NULL,
114
+ reply_channel TEXT,
115
+ reply_target TEXT,
116
+ reply_topic TEXT,
117
+ created_at_ms INTEGER NOT NULL
118
+ );
119
+
120
+ CREATE UNIQUE INDEX mailbox_entries_source_kind_external_id_idx
121
+ ON mailbox_entries (source_kind, external_id);
122
+
123
+ CREATE INDEX mailbox_entries_mailbox_key_id_idx
124
+ ON mailbox_entries (mailbox_key, id);
125
+ `);
126
+ db.exec("PRAGMA user_version = 4;");
127
+ }
128
+ function migrateToV5(db) {
129
+ db.exec(`
130
+ CREATE TABLE mailbox_entry_attachments (
131
+ mailbox_entry_id INTEGER NOT NULL REFERENCES mailbox_entries(id) ON DELETE CASCADE,
132
+ ordinal INTEGER NOT NULL,
133
+ kind TEXT NOT NULL,
134
+ mime_type TEXT NOT NULL,
135
+ file_name TEXT,
136
+ local_path TEXT NOT NULL,
137
+ PRIMARY KEY (mailbox_entry_id, ordinal)
138
+ );
139
+
140
+ CREATE INDEX mailbox_entry_attachments_entry_id_ordinal_idx
141
+ ON mailbox_entry_attachments (mailbox_entry_id, ordinal);
142
+ `);
143
+ db.exec("PRAGMA user_version = 5;");
144
+ }
145
+ function migrateToV6(db) {
146
+ db.exec(`
147
+ CREATE TABLE session_reply_targets (
148
+ session_id TEXT NOT NULL,
149
+ ordinal INTEGER NOT NULL,
150
+ conversation_key TEXT NOT NULL,
151
+ delivery_channel TEXT NOT NULL,
152
+ delivery_target TEXT NOT NULL,
153
+ delivery_topic TEXT NOT NULL,
154
+ updated_at_ms INTEGER NOT NULL,
155
+ PRIMARY KEY (session_id, ordinal)
156
+ );
157
+
158
+ CREATE INDEX session_reply_targets_conversation_key_updated_at_ms_idx
159
+ ON session_reply_targets (conversation_key, updated_at_ms DESC);
160
+
161
+ CREATE TABLE pending_questions (
162
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
163
+ request_id TEXT NOT NULL,
164
+ session_id TEXT NOT NULL,
165
+ delivery_channel TEXT NOT NULL,
166
+ delivery_target TEXT NOT NULL,
167
+ delivery_topic TEXT NOT NULL,
168
+ question_json TEXT NOT NULL,
169
+ telegram_message_id INTEGER,
170
+ created_at_ms INTEGER NOT NULL
171
+ );
172
+
173
+ CREATE UNIQUE INDEX pending_questions_request_target_topic_idx
174
+ ON pending_questions (request_id, delivery_channel, delivery_target, delivery_topic);
175
+
176
+ CREATE INDEX pending_questions_target_topic_created_at_ms_idx
177
+ ON pending_questions (delivery_channel, delivery_target, delivery_topic, created_at_ms);
178
+
179
+ CREATE INDEX pending_questions_session_id_created_at_ms_idx
180
+ ON pending_questions (session_id, created_at_ms);
181
+ `);
182
+ db.exec(`PRAGMA user_version = ${LATEST_SCHEMA_VERSION};`);
183
+ }
@@ -0,0 +1,127 @@
1
+ import { Database } from "bun:sqlite";
2
+ import type { BindingDeliveryTarget } from "../binding";
3
+ import type { GatewayQuestionInfo, PendingQuestionRecord } from "../questions/types";
4
+ export type RuntimeJournalKind = "inbound_message" | "cron_dispatch" | "delivery" | "mailbox_enqueue" | "mailbox_flush";
5
+ export type CronRunStatus = "running" | "succeeded" | "failed" | "abandoned";
6
+ export type RuntimeJournalEntry = {
7
+ kind: RuntimeJournalKind;
8
+ recordedAtMs: number;
9
+ conversationKey: string | null;
10
+ payload: unknown;
11
+ };
12
+ export type CronJobRecord = {
13
+ id: string;
14
+ schedule: string;
15
+ prompt: string;
16
+ deliveryChannel: string | null;
17
+ deliveryTarget: string | null;
18
+ deliveryTopic: string | null;
19
+ enabled: boolean;
20
+ nextRunAtMs: number;
21
+ createdAtMs: number;
22
+ updatedAtMs: number;
23
+ };
24
+ export type MailboxEntryRecord = {
25
+ id: number;
26
+ mailboxKey: string;
27
+ sourceKind: string;
28
+ externalId: string;
29
+ sender: string;
30
+ text: string | null;
31
+ attachments: MailboxEntryAttachmentRecord[];
32
+ replyChannel: string | null;
33
+ replyTarget: string | null;
34
+ replyTopic: string | null;
35
+ createdAtMs: number;
36
+ };
37
+ export type MailboxEntryAttachmentRecord = {
38
+ kind: "image";
39
+ ordinal: number;
40
+ mimeType: string;
41
+ fileName: string | null;
42
+ localPath: string;
43
+ };
44
+ export type PersistCronJobInput = {
45
+ id: string;
46
+ schedule: string;
47
+ prompt: string;
48
+ deliveryChannel: string | null;
49
+ deliveryTarget: string | null;
50
+ deliveryTopic: string | null;
51
+ enabled: boolean;
52
+ nextRunAtMs: number;
53
+ recordedAtMs: number;
54
+ };
55
+ export type PersistMailboxEntryInput = {
56
+ mailboxKey: string;
57
+ sourceKind: string;
58
+ externalId: string;
59
+ sender: string;
60
+ text: string | null;
61
+ attachments: PersistMailboxEntryAttachmentInput[];
62
+ replyChannel: string | null;
63
+ replyTarget: string | null;
64
+ replyTopic: string | null;
65
+ recordedAtMs: number;
66
+ };
67
+ export type PersistMailboxEntryAttachmentInput = {
68
+ kind: "image";
69
+ mimeType: string;
70
+ fileName: string | null;
71
+ localPath: string;
72
+ };
73
+ export type PersistSessionReplyTargetsInput = {
74
+ sessionId: string;
75
+ conversationKey: string;
76
+ targets: BindingDeliveryTarget[];
77
+ recordedAtMs: number;
78
+ };
79
+ export type PersistPendingQuestionInput = {
80
+ requestId: string;
81
+ sessionId: string;
82
+ questions: GatewayQuestionInfo[];
83
+ targets: Array<{
84
+ deliveryTarget: BindingDeliveryTarget;
85
+ telegramMessageId: number | null;
86
+ }>;
87
+ recordedAtMs: number;
88
+ };
89
+ export declare class SqliteStore {
90
+ private readonly db;
91
+ constructor(db: Database);
92
+ getSessionBinding(conversationKey: string): string | null;
93
+ putSessionBinding(conversationKey: string, sessionId: string, recordedAtMs: number): void;
94
+ putSessionBindingIfUnchanged(conversationKey: string, expectedSessionId: string | null, nextSessionId: string, recordedAtMs: number): boolean;
95
+ deleteSessionBinding(conversationKey: string): void;
96
+ clearSessionReplyTargets(sessionId: string): void;
97
+ replaceSessionReplyTargets(input: PersistSessionReplyTargetsInput): void;
98
+ listSessionReplyTargets(sessionId: string): BindingDeliveryTarget[];
99
+ getDefaultSessionReplyTarget(sessionId: string): BindingDeliveryTarget | null;
100
+ appendJournal(entry: RuntimeJournalEntry): void;
101
+ replacePendingQuestion(input: PersistPendingQuestionInput): void;
102
+ deletePendingQuestion(requestId: string): void;
103
+ deletePendingQuestionsForSession(sessionId: string): void;
104
+ getPendingQuestionForTarget(target: BindingDeliveryTarget): PendingQuestionRecord | null;
105
+ getPendingQuestionForTelegramMessage(target: BindingDeliveryTarget, telegramMessageId: number): PendingQuestionRecord | null;
106
+ hasMailboxEntry(sourceKind: string, externalId: string): boolean;
107
+ enqueueMailboxEntry(input: PersistMailboxEntryInput): void;
108
+ listPendingMailboxKeys(): string[];
109
+ listMailboxEntries(mailboxKey: string): MailboxEntryRecord[];
110
+ deleteMailboxEntries(ids: number[]): void;
111
+ getTelegramUpdateOffset(): number | null;
112
+ putTelegramUpdateOffset(offset: number, recordedAtMs: number): void;
113
+ getStateValue(key: string): string | null;
114
+ putStateValue(key: string, value: string, recordedAtMs: number): void;
115
+ upsertCronJob(input: PersistCronJobInput): void;
116
+ getCronJob(id: string): CronJobRecord | null;
117
+ listCronJobs(): CronJobRecord[];
118
+ listOverdueCronJobs(nowMs: number): CronJobRecord[];
119
+ listDueCronJobs(nowMs: number, limit: number): CronJobRecord[];
120
+ removeCronJob(id: string): boolean;
121
+ updateCronJobNextRun(id: string, nextRunAtMs: number, recordedAtMs: number): void;
122
+ insertCronRun(jobId: string, scheduledForMs: number, startedAtMs: number): number;
123
+ finishCronRun(runId: number, status: Exclude<CronRunStatus, "running">, finishedAtMs: number, responseText: string | null, errorMessage: string | null): void;
124
+ abandonRunningCronRuns(finishedAtMs: number): number;
125
+ close(): void;
126
+ }
127
+ export declare function openSqliteStore(path: string): Promise<SqliteStore>;