opencode-gateway 0.1.1 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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.1",
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
- }