opencode-gateway 0.1.1 → 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.
package/README.md CHANGED
@@ -48,6 +48,7 @@ opencode serve
48
48
  ```toml
49
49
  [gateway]
50
50
  state_db = "/home/you/.local/share/opencode-gateway/state.db"
51
+ # log_level = "warn"
51
52
 
52
53
  [cron]
53
54
  enabled = true
@@ -60,6 +61,17 @@ bot_token_env = "TELEGRAM_BOT_TOKEN"
60
61
  poll_timeout_seconds = 25
61
62
  allowed_chats = []
62
63
  allowed_users = []
64
+
65
+ [[memory.entries]]
66
+ path = "memory/project.md"
67
+ description = "Project conventions and long-lived context"
68
+ inject_content = true
69
+
70
+ [[memory.entries]]
71
+ path = "memory/notes"
72
+ description = "Domain notes and operating docs"
73
+ inject_markdown_contents = true
74
+ globs = ["**/*.rs", "notes/**/*.txt"]
63
75
  ```
64
76
 
65
77
  When Telegram is enabled, export the bot token through the configured
@@ -68,3 +80,17 @@ environment variable, for example:
68
80
  ```bash
69
81
  export TELEGRAM_BOT_TOKEN="..."
70
82
  ```
83
+
84
+ Gateway plugin logs are off by default. Set `gateway.log_level` to `error`,
85
+ `warn`, `info`, or `debug` to emit that level and anything above it.
86
+
87
+ Memory rules:
88
+
89
+ - all entries inject their configured path and description
90
+ - file contents are injected only when `inject_content = true`
91
+ - directory entries default to description-only
92
+ - `inject_markdown_contents = true` recursively injects `*.md` and `*.markdown`
93
+ - `globs` are relative to the configured directory and may match other UTF-8
94
+ text files
95
+ - relative paths are resolved from `opencode-gateway.toml`
96
+ - memory is injected only into gateway-managed sessions
@@ -60,8 +60,9 @@ export type BindingOutboundMessage = {
60
60
  export type BindingTransportHost = {
61
61
  sendMessage(message: BindingOutboundMessage): Promise<BindingHostAck>;
62
62
  };
63
+ export type BindingLogLevel = "debug" | "info" | "warn" | "error";
63
64
  export type BindingLoggerHost = {
64
- log(level: string, message: string): void;
65
+ log(level: BindingLogLevel, message: string): void;
65
66
  };
66
67
  export type GatewayContract = {
67
68
  gatewayStatus(): GatewayStatusSnapshot;
@@ -1,7 +1,7 @@
1
1
  import type { BindingCronJobSpec, BindingInboundMessage, BindingPreparedExecution, GatewayContract } from "./gateway";
2
2
  import type { BindingOpencodeExecutionInput, OpencodeExecutionDriver } from "./opencode";
3
3
  export type { BindingExecutionObservation, BindingProgressiveDirective } from "./execution";
4
- export type { BindingCronJobSpec, BindingDeliveryTarget, BindingHostAck, BindingInboundAttachment, BindingInboundMessage, BindingLoggerHost, BindingOutboundMessage, BindingPreparedExecution, BindingPromptPart, BindingRuntimeReport, BindingTransportHost, GatewayContract, GatewayStatusSnapshot, } from "./gateway";
4
+ export type { BindingCronJobSpec, BindingDeliveryTarget, BindingHostAck, BindingInboundAttachment, BindingInboundMessage, BindingLoggerHost, BindingLogLevel, BindingOutboundMessage, BindingPreparedExecution, BindingPromptPart, BindingRuntimeReport, BindingTransportHost, GatewayContract, GatewayStatusSnapshot, } from "./gateway";
5
5
  export type { BindingOpencodeCommand, BindingOpencodeCommandPart, BindingOpencodeCommandResult, BindingOpencodeDriverStep, BindingOpencodeExecutionInput, BindingOpencodeMessage, BindingOpencodeMessagePart, BindingOpencodePrompt, OpencodeExecutionDriver, } from "./opencode";
6
6
  export type GatewayBindingModule = GatewayContract & {
7
7
  prepareInboundExecution: (message: BindingInboundMessage) => BindingPreparedExecution;
@@ -5,6 +5,7 @@ export function buildGatewayConfigTemplate(stateDbPath) {
5
5
  "",
6
6
  "[gateway]",
7
7
  `state_db = "${escapeTomlString(stateDbPath)}"`,
8
+ '# log_level = "warn"',
8
9
  "",
9
10
  "[cron]",
10
11
  "enabled = true",
@@ -19,6 +20,20 @@ export function buildGatewayConfigTemplate(stateDbPath) {
19
20
  "allowed_chats = []",
20
21
  "allowed_users = []",
21
22
  "",
23
+ "# Optional long-lived memory sources injected into gateway-managed sessions.",
24
+ "# Relative paths are resolved from this config file.",
25
+ "#",
26
+ "# [[memory.entries]]",
27
+ '# path = "memory/project.md"',
28
+ '# description = "Project conventions and long-lived context"',
29
+ "# inject_content = true",
30
+ "#",
31
+ "# [[memory.entries]]",
32
+ '# path = "memory/notes"',
33
+ '# description = "Domain notes and operating docs"',
34
+ "# inject_markdown_contents = true",
35
+ '# globs = ["**/*.rs", "notes/**/*.txt"]',
36
+ "",
22
37
  ].join("\n");
23
38
  }
24
39
  function escapeTomlString(value) {
package/dist/cli.js CHANGED
@@ -247,6 +247,7 @@ function buildGatewayConfigTemplate(stateDbPath) {
247
247
  "",
248
248
  "[gateway]",
249
249
  `state_db = "${escapeTomlString(stateDbPath)}"`,
250
+ '# log_level = "warn"',
250
251
  "",
251
252
  "[cron]",
252
253
  "enabled = true",
@@ -260,6 +261,20 @@ function buildGatewayConfigTemplate(stateDbPath) {
260
261
  "poll_timeout_seconds = 25",
261
262
  "allowed_chats = []",
262
263
  "allowed_users = []",
264
+ "",
265
+ "# Optional long-lived memory sources injected into gateway-managed sessions.",
266
+ "# Relative paths are resolved from this config file.",
267
+ "#",
268
+ "# [[memory.entries]]",
269
+ '# path = "memory/project.md"',
270
+ '# description = "Project conventions and long-lived context"',
271
+ "# inject_content = true",
272
+ "#",
273
+ "# [[memory.entries]]",
274
+ '# path = "memory/notes"',
275
+ '# description = "Domain notes and operating docs"',
276
+ "# inject_markdown_contents = true",
277
+ '# globs = ["**/*.rs", "notes/**/*.txt"]',
263
278
  ""
264
279
  ].join(`
265
280
  `);
@@ -1,4 +1,6 @@
1
+ import { type GatewayLogLevel } from "../host/logger";
1
2
  import { type CronConfig } from "./cron";
3
+ import { type GatewayMemoryConfig } from "./memory";
2
4
  import { type TelegramConfig } from "./telegram";
3
5
  export type GatewayMailboxRouteConfig = {
4
6
  channel: string;
@@ -16,9 +18,11 @@ export type GatewayConfig = {
16
18
  stateDbPath: string;
17
19
  mediaRootPath: string;
18
20
  workspaceDirPath: string;
21
+ logLevel: GatewayLogLevel;
19
22
  hasLegacyGatewayTimezone: boolean;
20
23
  legacyGatewayTimezone: string | null;
21
24
  mailbox: GatewayMailboxConfig;
25
+ memory: GatewayMemoryConfig;
22
26
  cron: CronConfig;
23
27
  telegram: TelegramConfig;
24
28
  };
@@ -1,6 +1,8 @@
1
1
  import { existsSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
+ import { parseGatewayLogLevel } from "../host/logger";
3
4
  import { parseCronConfig } from "./cron";
5
+ import { parseMemoryConfig } from "./memory";
4
6
  import { defaultGatewayStateDbPath, resolveGatewayConfigPath, resolveGatewayWorkspacePath } from "./paths";
5
7
  import { parseTelegramConfig } from "./telegram";
6
8
  export async function loadGatewayConfig(env = process.env) {
@@ -16,9 +18,11 @@ export async function loadGatewayConfig(env = process.env) {
16
18
  stateDbPath,
17
19
  mediaRootPath: resolveMediaRootPath(stateDbPath),
18
20
  workspaceDirPath: resolveGatewayWorkspacePath(configPath),
21
+ logLevel: parseGatewayLogLevel(rawConfig?.gateway?.log_level, "gateway.log_level"),
19
22
  hasLegacyGatewayTimezone: rawConfig?.gateway?.timezone !== undefined,
20
23
  legacyGatewayTimezone: readLegacyGatewayTimezone(rawConfig?.gateway?.timezone),
21
24
  mailbox: parseMailboxConfig(rawConfig?.gateway?.mailbox),
25
+ memory: await parseMemoryConfig(rawConfig?.memory, configPath),
22
26
  cron: parseCronConfig(rawConfig?.cron),
23
27
  telegram: parseTelegramConfig(rawConfig?.channels?.telegram, env),
24
28
  };
@@ -0,0 +1,18 @@
1
+ export type GatewayMemoryConfig = {
2
+ entries: GatewayMemoryEntryConfig[];
3
+ };
4
+ export type GatewayMemoryEntryConfig = {
5
+ kind: "file";
6
+ path: string;
7
+ displayPath: string;
8
+ description: string;
9
+ injectContent: boolean;
10
+ } | {
11
+ kind: "directory";
12
+ path: string;
13
+ displayPath: string;
14
+ description: string;
15
+ injectMarkdownContents: boolean;
16
+ globs: string[];
17
+ };
18
+ export declare function parseMemoryConfig(value: unknown, configPath: string): Promise<GatewayMemoryConfig>;
@@ -0,0 +1,105 @@
1
+ import { stat } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ export async function parseMemoryConfig(value, configPath) {
4
+ const table = readMemoryTable(value);
5
+ const entries = await readMemoryEntries(table.entries, configPath);
6
+ return { entries };
7
+ }
8
+ function readMemoryTable(value) {
9
+ if (value === undefined) {
10
+ return {};
11
+ }
12
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
13
+ throw new Error("memory must be a table when present");
14
+ }
15
+ return value;
16
+ }
17
+ async function readMemoryEntries(value, configPath) {
18
+ if (value === undefined) {
19
+ return [];
20
+ }
21
+ if (!Array.isArray(value)) {
22
+ throw new Error("memory.entries must be an array when present");
23
+ }
24
+ return await Promise.all(value.map((entry, index) => readMemoryEntry(entry, index, configPath)));
25
+ }
26
+ async function readMemoryEntry(value, index, configPath) {
27
+ const field = `memory.entries[${index}]`;
28
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
29
+ throw new Error(`${field} must be a table`);
30
+ }
31
+ const entry = value;
32
+ const displayPath = readRequiredString(entry.path, `${field}.path`);
33
+ const description = readRequiredString(entry.description, `${field}.description`);
34
+ const resolvedPath = resolve(dirname(configPath), displayPath);
35
+ const metadata = await statPath(resolvedPath, `${field}.path`);
36
+ if (metadata.isFile()) {
37
+ ensureDirectoryOnlyFieldIsAbsent(entry.inject_markdown_contents, `${field}.inject_markdown_contents`);
38
+ ensureDirectoryOnlyFieldIsAbsent(entry.globs, `${field}.globs`);
39
+ return {
40
+ kind: "file",
41
+ path: resolvedPath,
42
+ displayPath,
43
+ description,
44
+ injectContent: readBoolean(entry.inject_content, `${field}.inject_content`, false),
45
+ };
46
+ }
47
+ if (metadata.isDirectory()) {
48
+ ensureFileOnlyFieldIsAbsent(entry.inject_content, `${field}.inject_content`);
49
+ return {
50
+ kind: "directory",
51
+ path: resolvedPath,
52
+ displayPath,
53
+ description,
54
+ injectMarkdownContents: readBoolean(entry.inject_markdown_contents, `${field}.inject_markdown_contents`, false),
55
+ globs: readGlobList(entry.globs, `${field}.globs`),
56
+ };
57
+ }
58
+ throw new Error(`${field}.path must point to a regular file or directory`);
59
+ }
60
+ async function statPath(path, field) {
61
+ try {
62
+ return await stat(path);
63
+ }
64
+ catch (error) {
65
+ throw new Error(`${field} does not exist: ${path}`, { cause: error });
66
+ }
67
+ }
68
+ function ensureDirectoryOnlyFieldIsAbsent(value, field) {
69
+ if (value !== undefined) {
70
+ throw new Error(`${field} is only valid for directory entries`);
71
+ }
72
+ }
73
+ function ensureFileOnlyFieldIsAbsent(value, field) {
74
+ if (value !== undefined) {
75
+ throw new Error(`${field} is only valid for file entries`);
76
+ }
77
+ }
78
+ function readBoolean(value, field, fallback) {
79
+ if (value === undefined) {
80
+ return fallback;
81
+ }
82
+ if (typeof value !== "boolean") {
83
+ throw new Error(`${field} must be a boolean when present`);
84
+ }
85
+ return value;
86
+ }
87
+ function readGlobList(value, field) {
88
+ if (value === undefined) {
89
+ return [];
90
+ }
91
+ if (!Array.isArray(value)) {
92
+ throw new Error(`${field} must be an array when present`);
93
+ }
94
+ return value.map((entry, index) => readRequiredString(entry, `${field}[${index}]`));
95
+ }
96
+ function readRequiredString(value, field) {
97
+ if (typeof value !== "string") {
98
+ throw new Error(`${field} must be a string`);
99
+ }
100
+ const trimmed = value.trim();
101
+ if (trimmed.length === 0) {
102
+ throw new Error(`${field} must not be empty`);
103
+ }
104
+ return trimmed;
105
+ }
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
@@ -4,9 +4,10 @@ import { GatewayCronRuntime } from "./cron/runtime";
4
4
  import { TelegramProgressiveSupport } from "./delivery/telegram";
5
5
  import { GatewayTextDelivery } from "./delivery/text";
6
6
  import { ChannelFileSender } from "./host/file-sender";
7
- import { ConsoleLoggerHost } from "./host/noop";
7
+ import { ConsoleLoggerHost } from "./host/logger";
8
8
  import { GatewayTransportHost } from "./host/transport";
9
9
  import { GatewayMailboxRouter } from "./mailbox/router";
10
+ import { GatewayMemoryPromptProvider } from "./memory/prompt";
10
11
  import { OpencodeSdkAdapter } from "./opencode/adapter";
11
12
  import { OpencodeEventStream } from "./opencode/event-stream";
12
13
  import { OpencodeEventHub } from "./opencode/events";
@@ -18,6 +19,7 @@ import { getOrCreateRuntimeSingleton } from "./runtime/runtime-singleton";
18
19
  import { GatewaySessionContext } from "./session/context";
19
20
  import { resolveConversationKeyForTarget } from "./session/conversation-key";
20
21
  import { ChannelSessionSwitcher } from "./session/switcher";
22
+ import { GatewaySystemPromptBuilder } from "./session/system-prompt";
21
23
  import { openSqliteStore } from "./store/sqlite";
22
24
  import { TelegramBotClient } from "./telegram/client";
23
25
  import { TelegramInboundMediaStore } from "./telegram/media";
@@ -31,7 +33,8 @@ export class GatewayPluginRuntime {
31
33
  files;
32
34
  channelSessions;
33
35
  sessionContext;
34
- constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext) {
36
+ systemPrompts;
37
+ constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts) {
35
38
  this.contract = contract;
36
39
  this.executor = executor;
37
40
  this.cron = cron;
@@ -39,6 +42,7 @@ export class GatewayPluginRuntime {
39
42
  this.files = files;
40
43
  this.channelSessions = channelSessions;
41
44
  this.sessionContext = sessionContext;
45
+ this.systemPrompts = systemPrompts;
42
46
  }
43
47
  status() {
44
48
  const rustStatus = this.contract.gatewayStatus();
@@ -61,7 +65,7 @@ export async function createGatewayRuntime(module, input) {
61
65
  const config = await loadGatewayConfig();
62
66
  return await getOrCreateRuntimeSingleton(config.configPath, async () => {
63
67
  await mkdir(config.workspaceDirPath, { recursive: true });
64
- const logger = new ConsoleLoggerHost();
68
+ const logger = new ConsoleLoggerHost(config.logLevel);
65
69
  if (config.hasLegacyGatewayTimezone) {
66
70
  const suffix = config.legacyGatewayTimezone === null ? "" : ` (${config.legacyGatewayTimezone})`;
67
71
  logger.log("warn", `gateway.timezone${suffix} is ignored; use cron.timezone instead`);
@@ -69,6 +73,8 @@ export async function createGatewayRuntime(module, input) {
69
73
  const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
70
74
  const store = await openSqliteStore(config.stateDbPath);
71
75
  const sessionContext = new GatewaySessionContext(store);
76
+ const memoryPrompts = new GatewayMemoryPromptProvider(config.memory, logger);
77
+ const systemPrompts = new GatewaySystemPromptBuilder(sessionContext, memoryPrompts);
72
78
  const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
73
79
  const telegramMediaStore = config.telegram.enabled && telegramClient !== null
74
80
  ? new TelegramInboundMediaStore(telegramClient, config.mediaRootPath)
@@ -95,7 +101,7 @@ export async function createGatewayRuntime(module, input) {
95
101
  cron.start();
96
102
  mailbox.start();
97
103
  telegram.start();
98
- return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext);
104
+ return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts);
99
105
  });
100
106
  }
101
107
  function resolveEffectiveCronTimeZone(module, config) {
@@ -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
@@ -46,8 +46,8 @@ export const OpencodeGatewayPlugin = async (input) => {
46
46
  if (!sessionId) {
47
47
  return;
48
48
  }
49
- const systemPrompt = runtime.sessionContext.buildSystemPrompt(sessionId);
50
- if (systemPrompt !== null) {
49
+ const systemPrompts = await runtime.systemPrompts.buildPrompts(sessionId);
50
+ for (const systemPrompt of systemPrompts) {
51
51
  output.system.push(systemPrompt);
52
52
  }
53
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
+ }
@@ -0,0 +1,122 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { extname, relative } from "node:path";
3
+ const MARKDOWN_GLOBS = ["**/*.md", "**/*.markdown"];
4
+ const UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
5
+ export class GatewayMemoryPromptProvider {
6
+ config;
7
+ logger;
8
+ constructor(config, logger) {
9
+ this.config = config;
10
+ this.logger = logger;
11
+ }
12
+ async buildPrompt() {
13
+ if (this.config.entries.length === 0) {
14
+ return null;
15
+ }
16
+ const sections = await Promise.all(this.config.entries.map((entry) => this.buildEntrySection(entry)));
17
+ return ["Gateway memory:", ...sections].join("\n\n");
18
+ }
19
+ async buildEntrySection(entry) {
20
+ const lines = [`Configured path: ${entry.displayPath}`, `Description: ${entry.description}`];
21
+ const injectedFiles = await collectInjectedFiles(entry, this.logger);
22
+ for (const file of injectedFiles) {
23
+ lines.push("");
24
+ lines.push(`File: ${file.displayPath}`);
25
+ lines.push(codeFence(file.infoString, file.text));
26
+ }
27
+ return lines.join("\n");
28
+ }
29
+ }
30
+ async function collectInjectedFiles(entry, logger) {
31
+ if (entry.kind === "file") {
32
+ if (!entry.injectContent) {
33
+ return [];
34
+ }
35
+ const text = await readTextFile(entry.path, logger);
36
+ if (text === null) {
37
+ return [];
38
+ }
39
+ return [
40
+ {
41
+ displayPath: entry.displayPath,
42
+ infoString: inferFenceInfoString(entry.path),
43
+ text,
44
+ },
45
+ ];
46
+ }
47
+ const filePaths = new Set();
48
+ if (entry.injectMarkdownContents) {
49
+ for (const pattern of MARKDOWN_GLOBS) {
50
+ addMatchingFiles(filePaths, entry.path, pattern);
51
+ }
52
+ }
53
+ for (const pattern of entry.globs) {
54
+ addMatchingFiles(filePaths, entry.path, pattern);
55
+ }
56
+ const injectedFiles = [];
57
+ for (const filePath of [...filePaths].sort((left, right) => left.localeCompare(right))) {
58
+ const text = await readTextFile(filePath, logger);
59
+ if (text === null) {
60
+ continue;
61
+ }
62
+ injectedFiles.push({
63
+ displayPath: relativeDisplayPath(entry.path, entry.displayPath, filePath),
64
+ infoString: inferFenceInfoString(filePath),
65
+ text,
66
+ });
67
+ }
68
+ return injectedFiles;
69
+ }
70
+ function addMatchingFiles(result, cwd, pattern) {
71
+ const glob = new Bun.Glob(pattern);
72
+ for (const match of glob.scanSync({ cwd, absolute: true, onlyFiles: true })) {
73
+ result.add(match);
74
+ }
75
+ }
76
+ async function readTextFile(path, logger) {
77
+ let bytes;
78
+ try {
79
+ bytes = await readFile(path);
80
+ }
81
+ catch (error) {
82
+ logger.log("warn", `memory file could not be read and will be skipped: ${path}: ${formatError(error)}`);
83
+ return null;
84
+ }
85
+ let text;
86
+ try {
87
+ text = UTF8_TEXT_DECODER.decode(bytes);
88
+ }
89
+ catch {
90
+ logger.log("warn", `memory file is not valid UTF-8 and will be skipped: ${path}`);
91
+ return null;
92
+ }
93
+ if (text.includes("\u0000")) {
94
+ logger.log("warn", `memory file looks binary and will be skipped: ${path}`);
95
+ return null;
96
+ }
97
+ return text;
98
+ }
99
+ function relativeDisplayPath(rootPath, rootDisplayPath, filePath) {
100
+ const suffix = relative(rootPath, filePath);
101
+ if (suffix.length === 0) {
102
+ return rootDisplayPath;
103
+ }
104
+ return `${rootDisplayPath}/${suffix.replaceAll("\\", "/")}`;
105
+ }
106
+ function inferFenceInfoString(path) {
107
+ const extension = extname(path).slice(1).toLowerCase();
108
+ if (!/^[a-z0-9_+-]+$/.test(extension)) {
109
+ return "";
110
+ }
111
+ return extension;
112
+ }
113
+ function codeFence(infoString, text) {
114
+ const language = infoString.length === 0 ? "" : infoString;
115
+ return [`\`\`\`${language}`, text, "```"].join("\n");
116
+ }
117
+ function formatError(error) {
118
+ if (error instanceof Error && error.message.trim().length > 0) {
119
+ return error.message;
120
+ }
121
+ return String(error);
122
+ }
@@ -29,6 +29,7 @@ export declare class GatewayExecutor {
29
29
  private waitUntilIdle;
30
30
  private appendPrompt;
31
31
  private cleanupResidualBusySession;
32
+ private waitForSessionToSettle;
32
33
  private abortSessionAndWaitForSettle;
33
34
  private createInternalPromptIdentity;
34
35
  private executeDriver;
@@ -2,6 +2,7 @@ import { ConversationCoordinator } from "./conversation-coordinator";
2
2
  import { runOpencodeDriver } from "./opencode-runner";
3
3
  const SESSION_ABORT_SETTLE_TIMEOUT_MS = 5_000;
4
4
  const SESSION_ABORT_POLL_MS = 250;
5
+ const SESSION_RESIDUAL_BUSY_GRACE_POLLS = 3;
5
6
  export class GatewayExecutor {
6
7
  module;
7
8
  store;
@@ -250,10 +251,10 @@ export class GatewayExecutor {
250
251
  expectCommandResult(result, "appendPrompt");
251
252
  }
252
253
  async cleanupResidualBusySession(sessionId) {
253
- if (!(await this.opencode.isSessionBusy(sessionId))) {
254
+ if (await this.waitForSessionToSettle(sessionId, SESSION_RESIDUAL_BUSY_GRACE_POLLS)) {
254
255
  return;
255
256
  }
256
- this.logger.log("warn", `aborting residual busy gateway session after prompt completion: ${sessionId}`);
257
+ this.logger.log("debug", `aborting residual busy gateway session after prompt completion: ${sessionId}`);
257
258
  try {
258
259
  await this.abortSessionAndWaitForSettle(sessionId);
259
260
  }
@@ -261,6 +262,17 @@ export class GatewayExecutor {
261
262
  this.logger.log("warn", `residual busy gateway session did not settle after abort: ${sessionId}: ${extractErrorMessage(error)}`);
262
263
  }
263
264
  }
265
+ async waitForSessionToSettle(sessionId, extraPolls) {
266
+ for (let attempt = 0; attempt <= extraPolls; attempt += 1) {
267
+ if (!(await this.opencode.isSessionBusy(sessionId))) {
268
+ return true;
269
+ }
270
+ if (attempt < extraPolls) {
271
+ await Bun.sleep(SESSION_ABORT_POLL_MS);
272
+ }
273
+ }
274
+ return false;
275
+ }
264
276
  async abortSessionAndWaitForSettle(sessionId) {
265
277
  await this.opencode.abortSession(sessionId);
266
278
  const deadline = Date.now() + SESSION_ABORT_SETTLE_TIMEOUT_MS;
@@ -6,5 +6,5 @@ export declare class GatewaySessionContext {
6
6
  replaceReplyTargets(sessionId: string, conversationKey: string, targets: BindingDeliveryTarget[], recordedAtMs: number): void;
7
7
  listReplyTargets(sessionId: string): BindingDeliveryTarget[];
8
8
  getDefaultReplyTarget(sessionId: string): BindingDeliveryTarget | null;
9
- buildSystemPrompt(sessionId: string): string | null;
9
+ isGatewaySession(sessionId: string): boolean;
10
10
  }
@@ -17,34 +17,7 @@ export class GatewaySessionContext {
17
17
  getDefaultReplyTarget(sessionId) {
18
18
  return this.store.getDefaultSessionReplyTarget(sessionId);
19
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
- "- 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.",
38
- ].join("\n");
39
- }
40
- return [
41
- "Gateway context:",
42
- `- This session currently fans out to ${targets.length} reply targets.`,
43
- ...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
44
- "- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
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.",
48
- ].join("\n");
20
+ isGatewaySession(sessionId) {
21
+ return this.store.hasGatewaySession(sessionId);
49
22
  }
50
23
  }
@@ -0,0 +1,8 @@
1
+ import type { GatewayMemoryPromptProvider } from "../memory/prompt";
2
+ import type { GatewaySessionContext } from "./context";
3
+ export declare class GatewaySystemPromptBuilder {
4
+ private readonly sessions;
5
+ private readonly memory;
6
+ constructor(sessions: GatewaySessionContext, memory: GatewayMemoryPromptProvider);
7
+ buildPrompts(sessionId: string): Promise<string[]>;
8
+ }
@@ -0,0 +1,52 @@
1
+ export class GatewaySystemPromptBuilder {
2
+ sessions;
3
+ memory;
4
+ constructor(sessions, memory) {
5
+ this.sessions = sessions;
6
+ this.memory = memory;
7
+ }
8
+ async buildPrompts(sessionId) {
9
+ if (!this.sessions.isGatewaySession(sessionId)) {
10
+ return [];
11
+ }
12
+ const prompts = [];
13
+ const gatewayPrompt = buildGatewayContextPrompt(this.sessions.listReplyTargets(sessionId));
14
+ if (gatewayPrompt !== null) {
15
+ prompts.push(gatewayPrompt);
16
+ }
17
+ const memoryPrompt = await this.memory.buildPrompt();
18
+ if (memoryPrompt !== null) {
19
+ prompts.push(memoryPrompt);
20
+ }
21
+ return prompts;
22
+ }
23
+ }
24
+ function buildGatewayContextPrompt(targets) {
25
+ if (targets.length === 0) {
26
+ return null;
27
+ }
28
+ if (targets.length === 1) {
29
+ const target = targets[0];
30
+ return [
31
+ "Gateway context:",
32
+ `- Current message source channel: ${target.channel}`,
33
+ `- Current reply target id: ${target.target}`,
34
+ `- Current reply topic: ${target.topic ?? "none"}`,
35
+ "- Unless the user explicitly asks otherwise, channel-aware actions should default to this target.",
36
+ "- If the user asks to start a fresh channel session, use channel_new_session.",
37
+ "- If the user asks for a one-shot reminder or relative-time follow-up, prefer schedule_once.",
38
+ "- If the user asks for a recurring schedule, prefer cron_upsert.",
39
+ "- Use schedule_list and schedule_status to inspect existing scheduled jobs and recent run results.",
40
+ "- Scheduled results delivered to this channel are automatically appended to this session as context.",
41
+ ].join("\n");
42
+ }
43
+ return [
44
+ "Gateway context:",
45
+ `- This session currently fans out to ${targets.length} reply targets.`,
46
+ ...targets.map((target, index) => `- Target ${index + 1}: channel=${target.channel}, id=${target.target}, topic=${target.topic ?? "none"}`),
47
+ "- If a tool needs a single explicit target, do not guess; ask the user or use explicit tool arguments.",
48
+ "- If the user asks to start a fresh channel session for this route, use channel_new_session.",
49
+ "- Prefer schedule_once for one-shot reminders and cron_upsert for recurring schedules.",
50
+ "- Use schedule_list and schedule_status to inspect scheduled jobs and recent run results.",
51
+ ].join("\n");
52
+ }
@@ -112,6 +112,7 @@ export declare class SqliteStore {
112
112
  replaceSessionReplyTargets(input: PersistSessionReplyTargetsInput): void;
113
113
  listSessionReplyTargets(sessionId: string): BindingDeliveryTarget[];
114
114
  getDefaultSessionReplyTarget(sessionId: string): BindingDeliveryTarget | null;
115
+ hasGatewaySession(sessionId: string): boolean;
115
116
  appendJournal(entry: RuntimeJournalEntry): void;
116
117
  replacePendingQuestion(input: PersistPendingQuestionInput): void;
117
118
  deletePendingQuestion(requestId: string): void;
@@ -109,6 +109,28 @@ export class SqliteStore {
109
109
  .get(sessionId);
110
110
  return row ? mapSessionReplyTargetRow(row) : null;
111
111
  }
112
+ hasGatewaySession(sessionId) {
113
+ const binding = this.db
114
+ .query(`
115
+ SELECT 1 AS present
116
+ FROM session_bindings
117
+ WHERE session_id = ?1
118
+ LIMIT 1;
119
+ `)
120
+ .get(sessionId);
121
+ if (binding?.present === 1) {
122
+ return true;
123
+ }
124
+ const replyTarget = this.db
125
+ .query(`
126
+ SELECT 1 AS present
127
+ FROM session_reply_targets
128
+ WHERE session_id = ?1
129
+ LIMIT 1;
130
+ `)
131
+ .get(sessionId);
132
+ return replyTarget?.present === 1;
133
+ }
112
134
  appendJournal(entry) {
113
135
  this.db
114
136
  .query(`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gateway",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Gateway plugin for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,4 +0,0 @@
1
- import type { BindingLoggerHost } from "../binding";
2
- export declare class ConsoleLoggerHost implements BindingLoggerHost {
3
- log(level: string, message: string): void;
4
- }
package/dist/host/noop.js DELETED
@@ -1,14 +0,0 @@
1
- export class ConsoleLoggerHost {
2
- log(level, message) {
3
- const line = `[gateway:${level}] ${message}`;
4
- if (level === "error") {
5
- console.error(line);
6
- return;
7
- }
8
- if (level === "warn") {
9
- console.warn(line);
10
- return;
11
- }
12
- console.info(line);
13
- }
14
- }