opencode-gateway 0.1.0 → 0.2.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 (60) hide show
  1. package/README.md +26 -0
  2. package/dist/binding/gateway.d.ts +2 -1
  3. package/dist/binding/index.d.ts +1 -1
  4. package/dist/cli/doctor.js +3 -1
  5. package/dist/cli/init.js +4 -1
  6. package/dist/cli/paths.js +1 -1
  7. package/dist/cli/templates.js +15 -0
  8. package/dist/cli.js +28 -4
  9. package/dist/config/gateway.d.ts +5 -0
  10. package/dist/config/gateway.js +6 -1
  11. package/dist/config/memory.d.ts +18 -0
  12. package/dist/config/memory.js +105 -0
  13. package/dist/config/paths.d.ts +2 -0
  14. package/dist/config/paths.js +5 -1
  15. package/dist/cron/runtime.d.ts +24 -5
  16. package/dist/cron/runtime.js +178 -13
  17. package/dist/delivery/text.js +1 -1
  18. package/dist/gateway.d.ts +3 -1
  19. package/dist/gateway.js +49 -37
  20. package/dist/host/logger.d.ts +8 -0
  21. package/dist/host/logger.js +53 -0
  22. package/dist/index.js +11 -7
  23. package/dist/memory/prompt.d.ts +9 -0
  24. package/dist/memory/prompt.js +122 -0
  25. package/dist/opencode/adapter.d.ts +2 -0
  26. package/dist/opencode/adapter.js +56 -7
  27. package/dist/runtime/conversation-coordinator.d.ts +4 -0
  28. package/dist/runtime/conversation-coordinator.js +22 -0
  29. package/dist/runtime/executor.d.ts +34 -5
  30. package/dist/runtime/executor.js +241 -22
  31. package/dist/runtime/runtime-singleton.d.ts +2 -0
  32. package/dist/runtime/runtime-singleton.js +28 -0
  33. package/dist/session/context.d.ts +1 -1
  34. package/dist/session/context.js +2 -23
  35. package/dist/session/system-prompt.d.ts +8 -0
  36. package/dist/session/system-prompt.js +52 -0
  37. package/dist/store/migrations.js +15 -1
  38. package/dist/store/sqlite.d.ts +20 -2
  39. package/dist/store/sqlite.js +103 -4
  40. package/dist/tools/channel-target.d.ts +5 -0
  41. package/dist/tools/channel-target.js +6 -0
  42. package/dist/tools/cron-run.js +1 -1
  43. package/dist/tools/cron-upsert.d.ts +2 -1
  44. package/dist/tools/cron-upsert.js +20 -6
  45. package/dist/tools/{cron-list.d.ts → schedule-cancel.d.ts} +1 -1
  46. package/dist/tools/schedule-cancel.js +12 -0
  47. package/dist/tools/schedule-format.d.ts +4 -0
  48. package/dist/tools/schedule-format.js +48 -0
  49. package/dist/tools/{cron-remove.d.ts → schedule-list.d.ts} +1 -1
  50. package/dist/tools/schedule-list.js +17 -0
  51. package/dist/tools/schedule-once.d.ts +4 -0
  52. package/dist/tools/schedule-once.js +43 -0
  53. package/dist/tools/schedule-status.d.ts +3 -0
  54. package/dist/tools/schedule-status.js +23 -0
  55. package/generated/wasm/pkg/opencode_gateway_ffi_bg.wasm +0 -0
  56. package/package.json +4 -4
  57. package/dist/host/noop.d.ts +0 -4
  58. package/dist/host/noop.js +0 -14
  59. package/dist/tools/cron-list.js +0 -34
  60. package/dist/tools/cron-remove.js +0 -12
@@ -1,6 +1,7 @@
1
1
  import { formatError } from "../utils/error";
2
2
  const CRON_EFFECTIVE_TIME_ZONE_KEY = "cron.effective_timezone";
3
3
  const LEGACY_CRON_TIME_ZONE = "UTC";
4
+ const MAX_STATUS_RUNS = 20;
4
5
  export class GatewayCronRuntime {
5
6
  executor;
6
7
  contract;
@@ -8,15 +9,17 @@ export class GatewayCronRuntime {
8
9
  logger;
9
10
  config;
10
11
  effectiveTimeZone;
12
+ resolveConversationKeyForTarget;
11
13
  runningJobIds = new Set();
12
14
  running = false;
13
- constructor(executor, contract, store, logger, config, effectiveTimeZone) {
15
+ constructor(executor, contract, store, logger, config, effectiveTimeZone, resolveConversationKeyForTarget) {
14
16
  this.executor = executor;
15
17
  this.contract = contract;
16
18
  this.store = store;
17
19
  this.logger = logger;
18
20
  this.config = config;
19
21
  this.effectiveTimeZone = effectiveTimeZone;
22
+ this.resolveConversationKeyForTarget = resolveConversationKeyForTarget;
20
23
  }
21
24
  isEnabled() {
22
25
  return this.config.enabled;
@@ -39,8 +42,21 @@ export class GatewayCronRuntime {
39
42
  this.running = false;
40
43
  });
41
44
  }
42
- listJobs() {
43
- return this.store.listCronJobs();
45
+ listJobs(includeTerminal = false) {
46
+ const jobs = this.store.listCronJobs();
47
+ if (includeTerminal) {
48
+ return jobs;
49
+ }
50
+ return jobs.filter((job) => !isTerminalJob(job));
51
+ }
52
+ getJobStatus(id, limit = 5) {
53
+ const job = this.requireJob(normalizeId(id));
54
+ const runs = this.store.listCronRuns(job.id, clampStatusLimit(limit));
55
+ return {
56
+ job,
57
+ state: deriveJobState(job, runs[0] ?? null),
58
+ runs,
59
+ };
44
60
  }
45
61
  upsertJob(input) {
46
62
  const normalized = normalizeUpsertInput(input);
@@ -53,13 +69,31 @@ export class GatewayCronRuntime {
53
69
  });
54
70
  return this.requireJob(normalized.id);
55
71
  }
56
- removeJob(id) {
57
- return this.store.removeCronJob(normalizeId(id));
72
+ scheduleOnce(input) {
73
+ const normalized = normalizeOnceInput(input);
74
+ const recordedAtMs = Date.now();
75
+ this.store.upsertCronJob({
76
+ ...normalized,
77
+ nextRunAtMs: normalized.runAtMs ?? recordedAtMs,
78
+ recordedAtMs,
79
+ });
80
+ return this.requireJob(normalized.id);
81
+ }
82
+ cancelJob(id) {
83
+ const job = this.store.getCronJob(normalizeId(id));
84
+ if (job === null || !job.enabled) {
85
+ return false;
86
+ }
87
+ this.store.setCronJobEnabled(job.id, false, Date.now());
88
+ return true;
58
89
  }
59
90
  async runNow(id) {
60
91
  const job = this.requireJob(normalizeId(id));
92
+ if (!job.enabled) {
93
+ throw new Error(`schedule job is not active: ${job.id}`);
94
+ }
61
95
  if (this.runningJobIds.has(job.id)) {
62
- throw new Error(`cron job is already running: ${job.id}`);
96
+ throw new Error(`schedule job is already running: ${job.id}`);
63
97
  }
64
98
  this.runningJobIds.add(job.id);
65
99
  try {
@@ -88,7 +122,7 @@ export class GatewayCronRuntime {
88
122
  ? `rebasing enabled cron jobs from legacy ${LEGACY_CRON_TIME_ZONE} semantics to ${this.effectiveTimeZone}`
89
123
  : `cron time zone changed from ${previousTimeZone} to ${this.effectiveTimeZone}; rebasing enabled jobs`;
90
124
  this.logger.log("warn", message);
91
- this.rebaseJobs(this.store.listCronJobs().filter((job) => job.enabled), nowMs);
125
+ this.rebaseJobs(this.store.listCronJobs().filter((job) => job.enabled && job.kind === "cron"), nowMs);
92
126
  }
93
127
  else {
94
128
  this.rebaseJobs(this.store.listOverdueCronJobs(nowMs), nowMs);
@@ -108,7 +142,7 @@ export class GatewayCronRuntime {
108
142
  this.runningJobIds.add(job.id);
109
143
  void this.executeJob(job, job.nextRunAtMs, nowMs)
110
144
  .catch((error) => {
111
- this.logger.log("error", `cron job ${job.id} failed: ${formatError(error)}`);
145
+ this.logger.log("error", `schedule job ${job.id} failed: ${formatError(error)}`);
112
146
  })
113
147
  .finally(() => {
114
148
  this.runningJobIds.delete(job.id);
@@ -120,26 +154,60 @@ export class GatewayCronRuntime {
120
154
  }
121
155
  async executeJob(job, scheduledForMs, nextRunBaseMs) {
122
156
  const startedAtMs = Date.now();
123
- if (nextRunBaseMs !== null) {
157
+ if (job.kind === "cron" && nextRunBaseMs !== null) {
124
158
  const nextRunAtMs = computeNextRunAt(this.contract, job, Math.max(nextRunBaseMs, scheduledForMs), this.effectiveTimeZone);
125
159
  this.store.updateCronJobNextRun(job.id, nextRunAtMs, startedAtMs);
126
160
  }
161
+ else if (job.kind === "once") {
162
+ this.store.setCronJobEnabled(job.id, false, startedAtMs);
163
+ }
127
164
  const runId = this.store.insertCronRun(job.id, scheduledForMs, startedAtMs);
128
165
  try {
129
- const report = await this.executor.dispatchCronJob(toBindingCronJobSpec(job));
166
+ const report = await this.executor.dispatchScheduledJob({
167
+ jobId: job.id,
168
+ jobKind: job.kind,
169
+ conversationKey: conversationKeyForJob(job),
170
+ prompt: job.prompt,
171
+ replyTarget: toReplyTarget(job),
172
+ });
130
173
  this.store.finishCronRun(runId, "succeeded", Date.now(), report.responseText, null);
174
+ await this.appendScheduleResultToTarget(job, scheduledForMs, {
175
+ kind: "success",
176
+ responseText: report.responseText,
177
+ });
131
178
  return report;
132
179
  }
133
180
  catch (error) {
134
181
  const message = formatError(error);
135
182
  this.store.finishCronRun(runId, "failed", Date.now(), null, message);
183
+ await this.appendScheduleResultToTarget(job, scheduledForMs, {
184
+ kind: "failure",
185
+ errorMessage: message,
186
+ });
136
187
  throw error;
137
188
  }
138
189
  }
190
+ async appendScheduleResultToTarget(job, scheduledForMs, outcome) {
191
+ const replyTarget = toReplyTarget(job);
192
+ if (replyTarget === null) {
193
+ return;
194
+ }
195
+ try {
196
+ await this.executor.appendContextToConversation({
197
+ conversationKey: this.resolveConversationKeyForTarget(replyTarget),
198
+ replyTarget,
199
+ body: formatScheduleContextNote(job, scheduledForMs, outcome),
200
+ recordedAtMs: Date.now(),
201
+ });
202
+ }
203
+ catch (error) {
204
+ this.logger.log("warn", `failed to append schedule result to ${replyTarget.channel}:${replyTarget.target}: ${formatError(error)}`);
205
+ }
206
+ }
139
207
  requireJob(id) {
140
208
  const job = this.store.getCronJob(id);
141
209
  if (job === null) {
142
- throw new Error(`unknown cron job: ${id}`);
210
+ throw new Error(`unknown schedule job: ${id}`);
143
211
  }
144
212
  return job;
145
213
  }
@@ -186,7 +254,9 @@ function normalizeUpsertInput(input) {
186
254
  }
187
255
  return {
188
256
  id,
257
+ kind: "cron",
189
258
  schedule,
259
+ runAtMs: null,
190
260
  prompt,
191
261
  enabled: input.enabled,
192
262
  deliveryChannel,
@@ -196,8 +266,57 @@ function normalizeUpsertInput(input) {
196
266
  recordedAtMs: 0,
197
267
  };
198
268
  }
269
+ function normalizeOnceInput(input) {
270
+ const id = normalizeId(input.id);
271
+ const prompt = normalizeRequiredField(input.prompt, "schedule prompt");
272
+ const deliveryChannel = normalizeOptionalField(input.deliveryChannel);
273
+ const deliveryTarget = normalizeOptionalField(input.deliveryTarget);
274
+ const deliveryTopic = normalizeOptionalField(input.deliveryTopic);
275
+ if ((deliveryChannel === null) !== (deliveryTarget === null)) {
276
+ throw new Error("schedule delivery_channel and delivery_target must be provided together");
277
+ }
278
+ if (deliveryChannel === null && deliveryTopic !== null) {
279
+ throw new Error("schedule delivery_topic requires delivery_channel and delivery_target");
280
+ }
281
+ if (deliveryChannel !== null && deliveryChannel !== "telegram") {
282
+ throw new Error(`unsupported schedule delivery channel: ${deliveryChannel}`);
283
+ }
284
+ const runAtMs = resolveOnceRunAt(input);
285
+ return {
286
+ id,
287
+ kind: "once",
288
+ schedule: null,
289
+ runAtMs,
290
+ prompt,
291
+ enabled: true,
292
+ deliveryChannel,
293
+ deliveryTarget,
294
+ deliveryTopic,
295
+ nextRunAtMs: runAtMs,
296
+ recordedAtMs: 0,
297
+ };
298
+ }
299
+ function resolveOnceRunAt(input) {
300
+ if (input.delaySeconds === null && input.runAtMs === null) {
301
+ throw new Error("schedule_once requires delay_seconds or run_at_ms");
302
+ }
303
+ if (input.delaySeconds !== null && input.runAtMs !== null) {
304
+ throw new Error("schedule_once accepts only one of delay_seconds or run_at_ms");
305
+ }
306
+ if (input.runAtMs !== null) {
307
+ if (!Number.isSafeInteger(input.runAtMs) || input.runAtMs < 0) {
308
+ throw new Error("schedule run_at_ms must be a non-negative integer");
309
+ }
310
+ return input.runAtMs;
311
+ }
312
+ const delaySeconds = input.delaySeconds ?? 0;
313
+ if (!Number.isSafeInteger(delaySeconds) || delaySeconds < 0) {
314
+ throw new Error("schedule delay_seconds must be a non-negative integer");
315
+ }
316
+ return Date.now() + delaySeconds * 1_000;
317
+ }
199
318
  function normalizeId(id) {
200
- return normalizeRequiredField(id, "cron id");
319
+ return normalizeRequiredField(id, "schedule id");
201
320
  }
202
321
  function normalizeRequiredField(value, field) {
203
322
  const trimmed = value.trim();
@@ -216,7 +335,7 @@ function normalizeOptionalField(value) {
216
335
  function toBindingCronJobSpec(job) {
217
336
  return {
218
337
  id: job.id,
219
- schedule: job.schedule,
338
+ schedule: normalizeRequiredField(job.schedule ?? "", "cron schedule"),
220
339
  prompt: job.prompt,
221
340
  deliveryChannel: job.deliveryChannel,
222
341
  deliveryTarget: job.deliveryTarget,
@@ -235,3 +354,49 @@ function sleep(durationMs) {
235
354
  setTimeout(resolve, durationMs);
236
355
  });
237
356
  }
357
+ function clampStatusLimit(limit) {
358
+ if (!Number.isSafeInteger(limit) || limit <= 0) {
359
+ throw new Error("schedule_status limit must be a positive integer");
360
+ }
361
+ return Math.min(limit, MAX_STATUS_RUNS);
362
+ }
363
+ function deriveJobState(job, latestRun) {
364
+ if (latestRun?.status === "running") {
365
+ return "running";
366
+ }
367
+ if (job.enabled) {
368
+ return "scheduled";
369
+ }
370
+ if (latestRun !== null) {
371
+ return latestRun.status;
372
+ }
373
+ return "canceled";
374
+ }
375
+ function isTerminalJob(job) {
376
+ return !job.enabled;
377
+ }
378
+ function toReplyTarget(job) {
379
+ if (job.deliveryChannel === null || job.deliveryTarget === null) {
380
+ return null;
381
+ }
382
+ return {
383
+ channel: job.deliveryChannel,
384
+ target: job.deliveryTarget,
385
+ topic: job.deliveryTopic,
386
+ };
387
+ }
388
+ function conversationKeyForJob(job) {
389
+ return job.kind === "cron" ? `cron:${job.id}` : `once:${job.id}`;
390
+ }
391
+ function formatScheduleContextNote(job, scheduledForMs, outcome) {
392
+ const header = [
393
+ "[Gateway schedule result]",
394
+ `job_id=${job.id}`,
395
+ `job_kind=${job.kind}`,
396
+ `scheduled_for_ms=${scheduledForMs}`,
397
+ ];
398
+ if (outcome.kind === "success") {
399
+ return [...header, "status=succeeded", "", outcome.responseText].join("\n");
400
+ }
401
+ return [...header, "status=failed", "", outcome.errorMessage].join("\n");
402
+ }
@@ -114,7 +114,7 @@ class ProgressiveTextDeliverySession {
114
114
  this.telegramSupport.startTyping(this.target);
115
115
  }
116
116
  async preview(text) {
117
- if (this.previewFailed || this.closed) {
117
+ if (this.previewFailed || this.closed || text.trim().length === 0) {
118
118
  return;
119
119
  }
120
120
  const runPreview = async () => {
package/dist/gateway.d.ts CHANGED
@@ -5,6 +5,7 @@ import { ChannelFileSender } from "./host/file-sender";
5
5
  import { GatewayExecutor } from "./runtime/executor";
6
6
  import { GatewaySessionContext } from "./session/context";
7
7
  import { ChannelSessionSwitcher } from "./session/switcher";
8
+ import { GatewaySystemPromptBuilder } from "./session/system-prompt";
8
9
  import { GatewayTelegramRuntime } from "./telegram/runtime";
9
10
  export type GatewayPluginStatus = {
10
11
  runtimeMode: string;
@@ -27,7 +28,8 @@ export declare class GatewayPluginRuntime {
27
28
  readonly files: ChannelFileSender;
28
29
  readonly channelSessions: ChannelSessionSwitcher;
29
30
  readonly sessionContext: GatewaySessionContext;
30
- constructor(contract: GatewayContract, executor: GatewayExecutor, cron: GatewayCronRuntime, telegram: GatewayTelegramRuntime, files: ChannelFileSender, channelSessions: ChannelSessionSwitcher, sessionContext: GatewaySessionContext);
31
+ readonly systemPrompts: GatewaySystemPromptBuilder;
32
+ constructor(contract: GatewayContract, executor: GatewayExecutor, cron: GatewayCronRuntime, telegram: GatewayTelegramRuntime, files: ChannelFileSender, channelSessions: ChannelSessionSwitcher, sessionContext: GatewaySessionContext, systemPrompts: GatewaySystemPromptBuilder);
31
33
  status(): GatewayPluginStatus;
32
34
  }
33
35
  export declare function createGatewayRuntime(module: GatewayBindingModule, input: PluginInput): Promise<GatewayPluginRuntime>;
package/dist/gateway.js CHANGED
@@ -1,11 +1,13 @@
1
+ import { mkdir } from "node:fs/promises";
1
2
  import { loadGatewayConfig } from "./config/gateway";
2
3
  import { GatewayCronRuntime } from "./cron/runtime";
3
4
  import { TelegramProgressiveSupport } from "./delivery/telegram";
4
5
  import { GatewayTextDelivery } from "./delivery/text";
5
6
  import { ChannelFileSender } from "./host/file-sender";
6
- import { ConsoleLoggerHost } from "./host/noop";
7
+ import { ConsoleLoggerHost } from "./host/logger";
7
8
  import { GatewayTransportHost } from "./host/transport";
8
9
  import { GatewayMailboxRouter } from "./mailbox/router";
10
+ import { GatewayMemoryPromptProvider } from "./memory/prompt";
9
11
  import { OpencodeSdkAdapter } from "./opencode/adapter";
10
12
  import { OpencodeEventStream } from "./opencode/event-stream";
11
13
  import { OpencodeEventHub } from "./opencode/events";
@@ -13,8 +15,11 @@ import { createQuestionClient } from "./questions/client";
13
15
  import { GatewayQuestionRuntime } from "./questions/runtime";
14
16
  import { GatewayExecutor } from "./runtime/executor";
15
17
  import { GatewayMailboxRuntime } from "./runtime/mailbox";
18
+ import { getOrCreateRuntimeSingleton } from "./runtime/runtime-singleton";
16
19
  import { GatewaySessionContext } from "./session/context";
20
+ import { resolveConversationKeyForTarget } from "./session/conversation-key";
17
21
  import { ChannelSessionSwitcher } from "./session/switcher";
22
+ import { GatewaySystemPromptBuilder } from "./session/system-prompt";
18
23
  import { openSqliteStore } from "./store/sqlite";
19
24
  import { TelegramBotClient } from "./telegram/client";
20
25
  import { TelegramInboundMediaStore } from "./telegram/media";
@@ -28,7 +33,8 @@ export class GatewayPluginRuntime {
28
33
  files;
29
34
  channelSessions;
30
35
  sessionContext;
31
- constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext) {
36
+ systemPrompts;
37
+ constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts) {
32
38
  this.contract = contract;
33
39
  this.executor = executor;
34
40
  this.cron = cron;
@@ -36,6 +42,7 @@ export class GatewayPluginRuntime {
36
42
  this.files = files;
37
43
  this.channelSessions = channelSessions;
38
44
  this.sessionContext = sessionContext;
45
+ this.systemPrompts = systemPrompts;
39
46
  }
40
47
  status() {
41
48
  const rustStatus = this.contract.gatewayStatus();
@@ -56,41 +63,46 @@ export class GatewayPluginRuntime {
56
63
  }
57
64
  export async function createGatewayRuntime(module, input) {
58
65
  const config = await loadGatewayConfig();
59
- const logger = new ConsoleLoggerHost();
60
- if (config.hasLegacyGatewayTimezone) {
61
- const suffix = config.legacyGatewayTimezone === null ? "" : ` (${config.legacyGatewayTimezone})`;
62
- logger.log("warn", `gateway.timezone${suffix} is ignored; use cron.timezone instead`);
63
- }
64
- const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
65
- const store = await openSqliteStore(config.stateDbPath);
66
- const sessionContext = new GatewaySessionContext(store);
67
- const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
68
- const telegramMediaStore = config.telegram.enabled && telegramClient !== null
69
- ? new TelegramInboundMediaStore(telegramClient, config.mediaRootPath)
70
- : null;
71
- const mailboxRouter = new GatewayMailboxRouter(config.mailbox.routes);
72
- const opencodeEvents = new OpencodeEventHub();
73
- const opencode = new OpencodeSdkAdapter(input.client, input.directory);
74
- const questionClient = createQuestionClient(input.client, input.serverUrl, input.directory);
75
- const transport = new GatewayTransportHost(telegramClient, store);
76
- const files = new ChannelFileSender(telegramClient);
77
- const channelSessions = new ChannelSessionSwitcher(store, sessionContext, mailboxRouter, module, opencode, config.telegram.enabled);
78
- const questions = new GatewayQuestionRuntime(questionClient, input.directory, store, sessionContext, transport, telegramClient, logger);
79
- const progressiveSupport = new TelegramProgressiveSupport(telegramClient, store, logger);
80
- const delivery = new GatewayTextDelivery(transport, store, progressiveSupport);
81
- const executor = new GatewayExecutor(module, store, opencode, opencodeEvents, delivery, logger);
82
- const mailbox = new GatewayMailboxRuntime(executor, store, logger, config.mailbox, questions);
83
- const cron = new GatewayCronRuntime(executor, module, store, logger, config.cron, effectiveCronTimeZone);
84
- const eventStream = new OpencodeEventStream(input.client, input.directory, opencodeEvents, [questions], logger);
85
- const telegramPolling = config.telegram.enabled && telegramClient !== null && telegramMediaStore !== null
86
- ? new TelegramPollingService(telegramClient, mailbox, store, logger, config.telegram, mailboxRouter, telegramMediaStore, questions)
87
- : null;
88
- const telegram = new GatewayTelegramRuntime(telegramClient, delivery, store, logger, config.telegram, telegramPolling, eventStream);
89
- eventStream.start();
90
- cron.start();
91
- mailbox.start();
92
- telegram.start();
93
- return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext);
66
+ return await getOrCreateRuntimeSingleton(config.configPath, async () => {
67
+ await mkdir(config.workspaceDirPath, { recursive: true });
68
+ const logger = new ConsoleLoggerHost(config.logLevel);
69
+ if (config.hasLegacyGatewayTimezone) {
70
+ const suffix = config.legacyGatewayTimezone === null ? "" : ` (${config.legacyGatewayTimezone})`;
71
+ logger.log("warn", `gateway.timezone${suffix} is ignored; use cron.timezone instead`);
72
+ }
73
+ const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
74
+ const store = await openSqliteStore(config.stateDbPath);
75
+ const sessionContext = new GatewaySessionContext(store);
76
+ const memoryPrompts = new GatewayMemoryPromptProvider(config.memory, logger);
77
+ const systemPrompts = new GatewaySystemPromptBuilder(sessionContext, memoryPrompts);
78
+ const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
79
+ const telegramMediaStore = config.telegram.enabled && telegramClient !== null
80
+ ? new TelegramInboundMediaStore(telegramClient, config.mediaRootPath)
81
+ : null;
82
+ const mailboxRouter = new GatewayMailboxRouter(config.mailbox.routes);
83
+ const opencodeEvents = new OpencodeEventHub();
84
+ const opencode = new OpencodeSdkAdapter(input.client, config.workspaceDirPath);
85
+ const questionClient = createQuestionClient(input.client, input.serverUrl, config.workspaceDirPath);
86
+ const transport = new GatewayTransportHost(telegramClient, store);
87
+ const files = new ChannelFileSender(telegramClient);
88
+ const channelSessions = new ChannelSessionSwitcher(store, sessionContext, mailboxRouter, module, opencode, config.telegram.enabled);
89
+ const questions = new GatewayQuestionRuntime(questionClient, config.workspaceDirPath, store, sessionContext, transport, telegramClient, logger);
90
+ const progressiveSupport = new TelegramProgressiveSupport(telegramClient, store, logger);
91
+ const delivery = new GatewayTextDelivery(transport, store, progressiveSupport);
92
+ const executor = new GatewayExecutor(module, store, opencode, opencodeEvents, delivery, logger);
93
+ const mailbox = new GatewayMailboxRuntime(executor, store, logger, config.mailbox, questions);
94
+ const cron = new GatewayCronRuntime(executor, module, store, logger, config.cron, effectiveCronTimeZone, (target) => resolveConversationKeyForTarget(target, mailboxRouter, module));
95
+ const eventStream = new OpencodeEventStream(input.client, config.workspaceDirPath, opencodeEvents, [questions], logger);
96
+ const telegramPolling = config.telegram.enabled && telegramClient !== null && telegramMediaStore !== null
97
+ ? new TelegramPollingService(telegramClient, mailbox, store, logger, config.telegram, mailboxRouter, telegramMediaStore, questions)
98
+ : null;
99
+ const telegram = new GatewayTelegramRuntime(telegramClient, delivery, store, logger, config.telegram, telegramPolling, eventStream);
100
+ eventStream.start();
101
+ cron.start();
102
+ mailbox.start();
103
+ telegram.start();
104
+ return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts);
105
+ });
94
106
  }
95
107
  function resolveEffectiveCronTimeZone(module, config) {
96
108
  const candidate = config.cron.timezone ?? resolveRuntimeLocalTimeZone();
@@ -0,0 +1,8 @@
1
+ import type { BindingLoggerHost, BindingLogLevel } from "../binding";
2
+ export type GatewayLogLevel = BindingLogLevel | "off";
3
+ export declare class ConsoleLoggerHost implements BindingLoggerHost {
4
+ private readonly threshold;
5
+ constructor(threshold: GatewayLogLevel);
6
+ log(level: BindingLogLevel, message: string): void;
7
+ }
8
+ export declare function parseGatewayLogLevel(value: unknown, field: string): GatewayLogLevel;
@@ -0,0 +1,53 @@
1
+ const LOG_LEVEL_PRIORITY = {
2
+ debug: 10,
3
+ info: 20,
4
+ warn: 30,
5
+ error: 40,
6
+ };
7
+ export class ConsoleLoggerHost {
8
+ threshold;
9
+ constructor(threshold) {
10
+ this.threshold = threshold;
11
+ }
12
+ log(level, message) {
13
+ if (!shouldLog(level, this.threshold)) {
14
+ return;
15
+ }
16
+ const line = `[gateway:${level}] ${message}`;
17
+ switch (level) {
18
+ case "error":
19
+ console.error(line);
20
+ return;
21
+ case "warn":
22
+ console.warn(line);
23
+ return;
24
+ default:
25
+ console.info(line);
26
+ }
27
+ }
28
+ }
29
+ export function parseGatewayLogLevel(value, field) {
30
+ if (value === undefined) {
31
+ return "off";
32
+ }
33
+ if (typeof value !== "string") {
34
+ throw new Error(`${field} must be a string when present`);
35
+ }
36
+ const normalized = value.trim().toLowerCase();
37
+ if (normalized === "off") {
38
+ return "off";
39
+ }
40
+ if (isBindingLogLevel(normalized)) {
41
+ return normalized;
42
+ }
43
+ throw new Error(`${field} must be one of: off, error, warn, info, debug`);
44
+ }
45
+ function shouldLog(level, threshold) {
46
+ if (threshold === "off") {
47
+ return false;
48
+ }
49
+ return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[threshold];
50
+ }
51
+ function isBindingLogLevel(value) {
52
+ return value === "debug" || value === "info" || value === "warn" || value === "error";
53
+ }
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);
@@ -42,8 +46,8 @@ export const OpencodeGatewayPlugin = async (input) => {
42
46
  if (!sessionId) {
43
47
  return;
44
48
  }
45
- const systemPrompt = runtime.sessionContext.buildSystemPrompt(sessionId);
46
- if (systemPrompt !== null) {
49
+ const systemPrompts = await runtime.systemPrompts.buildPrompts(sessionId);
50
+ for (const systemPrompt of systemPrompts) {
47
51
  output.system.push(systemPrompt);
48
52
  }
49
53
  },
@@ -0,0 +1,9 @@
1
+ import type { BindingLoggerHost } from "../binding";
2
+ import type { GatewayMemoryConfig } from "../config/memory";
3
+ export declare class GatewayMemoryPromptProvider {
4
+ private readonly config;
5
+ private readonly logger;
6
+ constructor(config: GatewayMemoryConfig, logger: Pick<BindingLoggerHost, "log">);
7
+ buildPrompt(): Promise<string | null>;
8
+ private buildEntrySection;
9
+ }