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.
- package/README.md +30 -3
- package/dist/binding/gateway.d.ts +2 -1
- package/dist/binding/index.d.ts +1 -1
- package/dist/cli/doctor.js +7 -19
- package/dist/cli/init.js +5 -3
- package/dist/cli/opencode-config-file.d.ts +5 -0
- package/dist/cli/opencode-config-file.js +18 -0
- package/dist/cli/opencode-config.d.ts +2 -0
- package/dist/cli/opencode-config.js +141 -9
- package/dist/cli/templates.js +15 -0
- package/dist/cli.js +186 -32
- package/dist/config/gateway.d.ts +4 -0
- package/dist/config/gateway.js +4 -0
- package/dist/config/memory.d.ts +18 -0
- package/dist/config/memory.js +105 -0
- package/dist/config/paths.d.ts +2 -0
- package/dist/config/paths.js +2 -0
- package/dist/gateway.d.ts +3 -1
- package/dist/gateway.js +10 -4
- package/dist/host/logger.d.ts +8 -0
- package/dist/host/logger.js +53 -0
- package/dist/index.js +2 -2
- package/dist/memory/prompt.d.ts +9 -0
- package/dist/memory/prompt.js +122 -0
- package/dist/runtime/executor.d.ts +1 -0
- package/dist/runtime/executor.js +14 -2
- package/dist/session/context.d.ts +1 -1
- package/dist/session/context.js +2 -29
- package/dist/session/system-prompt.d.ts +8 -0
- package/dist/session/system-prompt.js +52 -0
- package/dist/store/sqlite.d.ts +1 -0
- package/dist/store/sqlite.js +22 -0
- package/package.json +1 -1
- package/dist/host/noop.d.ts +0 -4
- package/dist/host/noop.js +0 -14
|
@@ -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;
|
package/dist/runtime/executor.js
CHANGED
|
@@ -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 (
|
|
254
|
+
if (await this.waitForSessionToSettle(sessionId, SESSION_RESIDUAL_BUSY_GRACE_POLLS)) {
|
|
254
255
|
return;
|
|
255
256
|
}
|
|
256
|
-
this.logger.log("
|
|
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
|
-
|
|
9
|
+
isGatewaySession(sessionId: string): boolean;
|
|
10
10
|
}
|
package/dist/session/context.js
CHANGED
|
@@ -17,34 +17,7 @@ export class GatewaySessionContext {
|
|
|
17
17
|
getDefaultReplyTarget(sessionId) {
|
|
18
18
|
return this.store.getDefaultSessionReplyTarget(sessionId);
|
|
19
19
|
}
|
|
20
|
-
|
|
21
|
-
|
|
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
|
+
}
|
package/dist/store/sqlite.d.ts
CHANGED
|
@@ -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;
|
package/dist/store/sqlite.js
CHANGED
|
@@ -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
package/dist/host/noop.d.ts
DELETED
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
|
-
}
|