opencode-gateway 0.2.9 → 0.3.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
@@ -28,10 +28,22 @@ Check what it resolved:
28
28
  npx opencode-gateway doctor
29
29
  ```
30
30
 
31
- Then start OpenCode normally:
31
+ Recommended:
32
+
33
+ ```bash
34
+ opencode-gateway serve
35
+ ```
36
+
37
+ This wraps `opencode serve` and warms the gateway plugin worker immediately, so
38
+ Telegram polling and scheduled jobs do not stay idle until the first
39
+ project-scoped request.
40
+
41
+ If you still prefer the raw OpenCode command, warm the gateway explicitly after
42
+ startup:
32
43
 
33
44
  ```bash
34
45
  opencode serve
46
+ opencode-gateway warm
35
47
  ```
36
48
 
37
49
  If you want a separate managed config tree instead of editing your existing
@@ -39,9 +51,7 @@ OpenCode config:
39
51
 
40
52
  ```bash
41
53
  npx opencode-gateway init --managed
42
- export OPENCODE_CONFIG="$HOME/.config/opencode-gateway/opencode/opencode.json"
43
- export OPENCODE_CONFIG_DIR="$HOME/.config/opencode-gateway/opencode"
44
- opencode serve
54
+ opencode-gateway serve --managed
45
55
  ```
46
56
 
47
57
  ## Example gateway config
@@ -88,8 +98,12 @@ inject_content = true
88
98
  [[memory.entries]]
89
99
  path = "memory/notes"
90
100
  description = "Domain notes and operating docs"
91
- inject_markdown_contents = true
92
- globs = ["**/*.rs", "notes/**/*.txt"]
101
+ search_only = true
102
+
103
+ [[memory.entries]]
104
+ path = "memory/snippets"
105
+ description = "Selected files are auto-injected; the rest stay searchable on demand"
106
+ globs = ["**/*.md", "notes/**/*.txt"]
93
107
  ```
94
108
 
95
109
  When Telegram is enabled, export the bot token through the configured
@@ -112,13 +126,16 @@ Mailbox rules:
112
126
  Memory rules:
113
127
 
114
128
  - all entries inject their configured path and description
115
- - file contents are injected only when `inject_content = true`
116
- - directory entries default to description-only
117
- - `inject_markdown_contents = true` recursively injects `*.md` and `*.markdown`
118
- - `globs` are relative to the configured directory and may match other UTF-8
119
- text files
129
+ - file contents are auto-injected only when `inject_content = true`
130
+ - `search_only = true` keeps an entry available to `memory_search` and `memory_get`
131
+ without auto-injecting its content
132
+ - directory entries default to description-only plus on-demand search
133
+ - directory `globs` are relative to the configured directory and define which
134
+ files are auto-injected; other UTF-8 text files remain searchable on demand
120
135
  - relative paths are resolved from `opencode-gateway-workspace`
121
136
  - absolute paths are still allowed
122
137
  - missing files and directories are created automatically on load
123
138
  - the default template includes `USER.md` as persistent user-profile memory
124
139
  - memory is injected only into gateway-managed sessions
140
+ - `memory_search` returns matching snippets and paths; `memory_get` reads a
141
+ specific configured memory file by path and optional line window
@@ -1,7 +1,7 @@
1
1
  export type CliCommand = {
2
2
  kind: "help";
3
3
  } | {
4
- kind: "init" | "doctor";
4
+ kind: "init" | "doctor" | "serve" | "warm";
5
5
  managed: boolean;
6
6
  configDir: string | null;
7
7
  };
@@ -0,0 +1,19 @@
1
+ type CliOptions = {
2
+ managed: boolean;
3
+ configDir: string | null;
4
+ };
5
+ export type ResolvedServeTarget = {
6
+ configDir: string;
7
+ gatewayConfigPath: string;
8
+ workspaceDirPath: string;
9
+ opencodeConfigPath: string;
10
+ serverOrigin: string;
11
+ env: Record<string, string>;
12
+ };
13
+ export declare function resolveServeTarget(options: CliOptions, env: Record<string, string | undefined>): Promise<ResolvedServeTarget>;
14
+ export declare function warmGatewayProject(target: ResolvedServeTarget, options?: {
15
+ deadlineMs?: number;
16
+ intervalMs?: number;
17
+ }): Promise<void>;
18
+ export declare function resolveServerOriginFromDocument(document: Record<string, unknown>): string;
19
+ export {};
@@ -0,0 +1,6 @@
1
+ type ServeOptions = {
2
+ managed: boolean;
3
+ configDir: string | null;
4
+ };
5
+ export declare function runServe(options: ServeOptions, env: Record<string, string | undefined>): Promise<void>;
6
+ export {};
@@ -0,0 +1,6 @@
1
+ type WarmOptions = {
2
+ managed: boolean;
3
+ configDir: string | null;
4
+ };
5
+ export declare function runWarm(options: WarmOptions, env: Record<string, string | undefined>): Promise<void>;
6
+ export {};
package/dist/cli.js CHANGED
@@ -7,7 +7,7 @@ function parseCliCommand(argv) {
7
7
  if (!command || command === "help" || command === "--help" || command === "-h") {
8
8
  return { kind: "help" };
9
9
  }
10
- if (command !== "init" && command !== "doctor") {
10
+ if (command !== "init" && command !== "doctor" && command !== "serve" && command !== "warm") {
11
11
  throw new Error(`unknown command: ${command}`);
12
12
  }
13
13
  let managed = false;
@@ -48,6 +48,8 @@ function formatCliHelp() {
48
48
  "Commands:",
49
49
  " opencode-gateway init [--managed] [--config-dir <path>]",
50
50
  " opencode-gateway doctor [--managed] [--config-dir <path>]",
51
+ " opencode-gateway warm [--managed] [--config-dir <path>]",
52
+ " opencode-gateway serve [--managed] [--config-dir <path>]",
51
53
  "",
52
54
  "Defaults:",
53
55
  " init/doctor use OPENCODE_CONFIG_DIR when set, otherwise ~/.config/opencode",
@@ -57,42 +59,7 @@ function formatCliHelp() {
57
59
  }
58
60
 
59
61
  // src/cli/doctor.ts
60
- import { readFile } from "node:fs/promises";
61
- import { join as join3 } from "node:path";
62
-
63
- // src/config/paths.ts
64
- import { homedir } from "node:os";
65
- import { dirname, join, resolve as resolve2 } from "node:path";
66
- var GATEWAY_CONFIG_FILE = "opencode-gateway.toml";
67
- var OPENCODE_CONFIG_FILE = "opencode.json";
68
- var OPENCODE_CONFIG_FILE_JSONC = "opencode.jsonc";
69
- var OPENCODE_CONFIG_FILE_CANDIDATES = [OPENCODE_CONFIG_FILE_JSONC, OPENCODE_CONFIG_FILE];
70
- var GATEWAY_WORKSPACE_DIR = "opencode-gateway-workspace";
71
- function resolveOpencodeConfigDir(env) {
72
- const explicit = env.OPENCODE_CONFIG_DIR;
73
- if (explicit && explicit.trim().length > 0) {
74
- return resolve2(explicit);
75
- }
76
- return defaultOpencodeConfigDir(env);
77
- }
78
- function resolveManagedOpencodeConfigDir(env) {
79
- return join(resolveConfigHome(env), "opencode-gateway", "opencode");
80
- }
81
- function resolveGatewayWorkspacePath(configPath) {
82
- return join(dirname(configPath), GATEWAY_WORKSPACE_DIR);
83
- }
84
- function defaultGatewayStateDbPath(env) {
85
- return join(resolveDataHome(env), "opencode-gateway", "state.db");
86
- }
87
- function resolveConfigHome(env) {
88
- return env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
89
- }
90
- function resolveDataHome(env) {
91
- return env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
92
- }
93
- function defaultOpencodeConfigDir(env) {
94
- return join(resolveConfigHome(env), "opencode");
95
- }
62
+ import { readFile as readFile2 } from "node:fs/promises";
96
63
 
97
64
  // src/cli/opencode-config.ts
98
65
  var OPENCODE_SCHEMA_URL = "https://opencode.ai/config.json";
@@ -290,6 +257,47 @@ function isGatewayPluginReference(entry) {
290
257
  // src/cli/opencode-config-file.ts
291
258
  import { join as join2 } from "node:path";
292
259
 
260
+ // src/config/paths.ts
261
+ import { homedir } from "node:os";
262
+ import { dirname, join, resolve as resolve2 } from "node:path";
263
+ var GATEWAY_CONFIG_FILE = "opencode-gateway.toml";
264
+ var OPENCODE_CONFIG_FILE = "opencode.json";
265
+ var OPENCODE_CONFIG_FILE_JSONC = "opencode.jsonc";
266
+ var OPENCODE_CONFIG_FILE_CANDIDATES = [OPENCODE_CONFIG_FILE_JSONC, OPENCODE_CONFIG_FILE];
267
+ var GATEWAY_WORKSPACE_DIR = "opencode-gateway-workspace";
268
+ function resolveGatewayConfigPath(env) {
269
+ const explicit = env.OPENCODE_GATEWAY_CONFIG;
270
+ if (explicit && explicit.trim().length > 0) {
271
+ return resolve2(explicit);
272
+ }
273
+ return join(resolveOpencodeConfigDir(env), GATEWAY_CONFIG_FILE);
274
+ }
275
+ function resolveOpencodeConfigDir(env) {
276
+ const explicit = env.OPENCODE_CONFIG_DIR;
277
+ if (explicit && explicit.trim().length > 0) {
278
+ return resolve2(explicit);
279
+ }
280
+ return defaultOpencodeConfigDir(env);
281
+ }
282
+ function resolveManagedOpencodeConfigDir(env) {
283
+ return join(resolveConfigHome(env), "opencode-gateway", "opencode");
284
+ }
285
+ function resolveGatewayWorkspacePath(configPath) {
286
+ return join(dirname(configPath), GATEWAY_WORKSPACE_DIR);
287
+ }
288
+ function defaultGatewayStateDbPath(env) {
289
+ return join(resolveDataHome(env), "opencode-gateway", "state.db");
290
+ }
291
+ function resolveConfigHome(env) {
292
+ return env.XDG_CONFIG_HOME ?? join(homedir(), ".config");
293
+ }
294
+ function resolveDataHome(env) {
295
+ return env.XDG_DATA_HOME ?? join(homedir(), ".local", "share");
296
+ }
297
+ function defaultOpencodeConfigDir(env) {
298
+ return join(resolveConfigHome(env), "opencode");
299
+ }
300
+
293
301
  // src/cli/paths.ts
294
302
  import { constants } from "node:fs";
295
303
  import { access } from "node:fs/promises";
@@ -329,19 +337,106 @@ async function resolveOpencodeConfigFile(configDir) {
329
337
  };
330
338
  }
331
339
 
340
+ // src/cli/opencode-server.ts
341
+ import { readFile } from "node:fs/promises";
342
+ var DEFAULT_SERVER_ORIGIN = "http://127.0.0.1:4096";
343
+ var WARM_REQUEST_TIMEOUT_MS = 2000;
344
+ async function resolveServeTarget(options, env) {
345
+ const configDir = resolveCliConfigDir(options, env);
346
+ const gatewayConfigPath = resolveGatewayConfigPath({
347
+ ...env,
348
+ OPENCODE_CONFIG_DIR: configDir
349
+ });
350
+ const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
351
+ const opencodeConfig = await resolveOpencodeConfigFile(configDir);
352
+ const serverOrigin = opencodeConfig.exists ? await resolveServerOrigin(opencodeConfig.path) : DEFAULT_SERVER_ORIGIN;
353
+ return {
354
+ configDir,
355
+ gatewayConfigPath,
356
+ workspaceDirPath,
357
+ opencodeConfigPath: opencodeConfig.path,
358
+ serverOrigin,
359
+ env: {
360
+ OPENCODE_CONFIG_DIR: configDir,
361
+ OPENCODE_CONFIG: opencodeConfig.path
362
+ }
363
+ };
364
+ }
365
+ async function warmGatewayProject(target, options) {
366
+ const deadlineMs = options?.deadlineMs ?? 30000;
367
+ const intervalMs = options?.intervalMs ?? 250;
368
+ const warmUrl = new URL("/experimental/tool/ids", target.serverOrigin);
369
+ warmUrl.searchParams.set("directory", target.workspaceDirPath);
370
+ const deadline = Date.now() + deadlineMs;
371
+ while (Date.now() < deadline) {
372
+ try {
373
+ const response = await fetch(warmUrl, {
374
+ signal: AbortSignal.timeout(WARM_REQUEST_TIMEOUT_MS)
375
+ });
376
+ if (response.ok) {
377
+ const payload = await response.json();
378
+ if (isGatewayToolList(payload)) {
379
+ return;
380
+ }
381
+ }
382
+ } catch {}
383
+ await delay(intervalMs);
384
+ }
385
+ throw new Error(`failed to warm the gateway plugin at ${target.serverOrigin} for ${target.workspaceDirPath}`);
386
+ }
387
+ function resolveServerOriginFromDocument(document) {
388
+ const server = document.server;
389
+ if (server === null || typeof server !== "object" || Array.isArray(server)) {
390
+ return DEFAULT_SERVER_ORIGIN;
391
+ }
392
+ const hostname = readNonEmptyString(server.hostname);
393
+ const port = readPort(server.port);
394
+ if (hostname === null || port === null) {
395
+ return DEFAULT_SERVER_ORIGIN;
396
+ }
397
+ return `http://${hostname}:${port}`;
398
+ }
399
+ async function resolveServerOrigin(configPath) {
400
+ const source = await readFile(configPath, "utf8");
401
+ const document = parseOpencodeConfig(source, configPath);
402
+ return resolveServerOriginFromDocument(document);
403
+ }
404
+ function readNonEmptyString(value) {
405
+ if (typeof value !== "string") {
406
+ return null;
407
+ }
408
+ const normalized = value.trim();
409
+ return normalized.length > 0 ? normalized : null;
410
+ }
411
+ function readPort(value) {
412
+ if (typeof value !== "number" || !Number.isInteger(value)) {
413
+ return null;
414
+ }
415
+ return value >= 1 && value <= 65535 ? value : null;
416
+ }
417
+ function isGatewayToolList(value) {
418
+ return Array.isArray(value) && value.includes("gateway_status");
419
+ }
420
+ function delay(durationMs) {
421
+ return new Promise((resolve4) => {
422
+ setTimeout(resolve4, durationMs);
423
+ });
424
+ }
425
+
332
426
  // src/cli/doctor.ts
333
427
  async function runDoctor(options, env) {
334
428
  const configDir = resolveCliConfigDir(options, env);
335
- const gatewayConfigPath = join3(configDir, GATEWAY_CONFIG_FILE);
336
- const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
337
429
  const opencodeConfig = await resolveOpencodeConfigFile(configDir);
338
430
  const opencodeStatus = await inspectOpencodeConfig(opencodeConfig.path);
339
431
  const gatewayOverride = env.OPENCODE_GATEWAY_CONFIG?.trim() || null;
432
+ const serveTarget = await resolveServeTarget(options, env);
340
433
  console.log("doctor report");
341
434
  console.log(` config dir: ${configDir}`);
342
435
  console.log(` opencode config: ${await describePath(opencodeConfig.path)}`);
343
- console.log(` gateway config: ${await describePath(gatewayConfigPath)}`);
344
- console.log(` gateway workspace: ${await describePath(workspaceDirPath)}`);
436
+ console.log(` gateway config: ${await describePath(serveTarget.gatewayConfigPath)}`);
437
+ console.log(` gateway workspace: ${await describePath(serveTarget.workspaceDirPath)}`);
438
+ console.log(` warm server: ${serveTarget.serverOrigin}`);
439
+ console.log(` warm directory: ${serveTarget.workspaceDirPath}`);
345
440
  console.log(` gateway config override: ${gatewayOverride ?? "not set"}`);
346
441
  console.log(` plugin configured: ${opencodeStatus.pluginConfigured}`);
347
442
  console.log(` TELEGRAM_BOT_TOKEN: ${env.TELEGRAM_BOT_TOKEN?.trim() ? "set" : "missing"}`);
@@ -360,7 +455,7 @@ async function inspectOpencodeConfig(path) {
360
455
  };
361
456
  }
362
457
  try {
363
- const parsed = parseOpencodeConfig(await readFile(path, "utf8"), path);
458
+ const parsed = parseOpencodeConfig(await readFile2(path, "utf8"), path);
364
459
  return {
365
460
  pluginConfigured: inspectGatewayPlugin(parsed),
366
461
  error: null
@@ -374,8 +469,8 @@ async function inspectOpencodeConfig(path) {
374
469
  }
375
470
 
376
471
  // src/cli/init.ts
377
- import { mkdir, readFile as readFile2, writeFile } from "node:fs/promises";
378
- import { dirname as dirname2, join as join4 } from "node:path";
472
+ import { mkdir, readFile as readFile3, writeFile } from "node:fs/promises";
473
+ import { dirname as dirname2, join as join3 } from "node:path";
379
474
 
380
475
  // src/cli/templates.ts
381
476
  function buildGatewayConfigTemplate(stateDbPath) {
@@ -423,10 +518,19 @@ function buildGatewayConfigTemplate(stateDbPath) {
423
518
  "inject_content = true",
424
519
  "#",
425
520
  "# [[memory.entries]]",
521
+ '# path = "memory/project.md"',
522
+ '# description = "Project conventions and long-lived context"',
523
+ "# inject_content = true",
524
+ "#",
525
+ "# [[memory.entries]]",
426
526
  '# path = "memory/notes"',
427
- '# description = "Domain notes and operating docs"',
428
- "# inject_markdown_contents = true",
429
- '# globs = ["**/*.rs", "notes/**/*.txt"]',
527
+ '# description = "Domain notes and operating docs. Use memory_search and memory_get to inspect this directory on demand."',
528
+ "# search_only = true",
529
+ "#",
530
+ "# [[memory.entries]]",
531
+ '# path = "memory/snippets"',
532
+ '# description = "Selected files are auto-injected; the rest stay searchable on demand."',
533
+ '# globs = ["**/*.md", "notes/**/*.txt"]',
430
534
  ""
431
535
  ].join(`
432
536
  `);
@@ -438,7 +542,7 @@ function escapeTomlString(value) {
438
542
  // src/cli/init.ts
439
543
  async function runInit(options, env) {
440
544
  const configDir = resolveCliConfigDir(options, env);
441
- const gatewayConfigPath = join4(configDir, GATEWAY_CONFIG_FILE);
545
+ const gatewayConfigPath = join3(configDir, GATEWAY_CONFIG_FILE);
442
546
  const workspaceDirPath = resolveGatewayWorkspacePath(gatewayConfigPath);
443
547
  const opencodeConfig = await resolveOpencodeConfigFile(configDir);
444
548
  const opencodeConfigPath = opencodeConfig.path;
@@ -449,7 +553,7 @@ async function runInit(options, env) {
449
553
  await writeFile(opencodeConfigPath, stringifyOpencodeConfig(createDefaultOpencodeConfig(options.managed)));
450
554
  opencodeStatus = "created";
451
555
  } else {
452
- const source = await readFile2(opencodeConfigPath, "utf8");
556
+ const source = await readFile3(opencodeConfigPath, "utf8");
453
557
  const parsed = parseOpencodeConfig(source, opencodeConfigPath);
454
558
  const next = ensureGatewayPlugin(parsed);
455
559
  if (next.changed) {
@@ -467,6 +571,54 @@ async function runInit(options, env) {
467
571
  console.log(`opencode config: ${opencodeConfigPath} (${opencodeStatus})`);
468
572
  console.log(`gateway config: ${gatewayConfigPath} (${gatewayStatus})`);
469
573
  console.log(`gateway workspace: ${workspaceDirPath} (ready)`);
574
+ console.log("next step: start OpenCode with `opencode-gateway serve`");
575
+ console.log("fallback: if you still run `opencode serve`, run `opencode-gateway warm` after startup");
576
+ }
577
+
578
+ // src/cli/serve.ts
579
+ import { spawn } from "node:child_process";
580
+ import { mkdir as mkdir2 } from "node:fs/promises";
581
+ async function runServe(options, env) {
582
+ const target = await resolveServeTarget(options, env);
583
+ await mkdir2(target.workspaceDirPath, { recursive: true });
584
+ const child = spawn("opencode", ["serve"], {
585
+ stdio: "inherit",
586
+ env: {
587
+ ...process.env,
588
+ ...target.env
589
+ }
590
+ });
591
+ warmGatewayProject(target).catch((error) => {
592
+ const message = error instanceof Error ? error.message : String(error);
593
+ console.warn(`warning: ${message}`);
594
+ console.warn("warning: the gateway plugin may stay idle until the first project-scoped request");
595
+ });
596
+ const exitCode = await waitForChild(child);
597
+ if (exitCode !== 0) {
598
+ process.exitCode = exitCode;
599
+ }
600
+ }
601
+ function waitForChild(child) {
602
+ return new Promise((resolve4, reject) => {
603
+ child.once("error", reject);
604
+ child.once("exit", (code, signal) => {
605
+ if (signal !== null) {
606
+ resolve4(1);
607
+ return;
608
+ }
609
+ resolve4(code ?? 0);
610
+ });
611
+ });
612
+ }
613
+
614
+ // src/cli/warm.ts
615
+ import { mkdir as mkdir3 } from "node:fs/promises";
616
+ async function runWarm(options, env) {
617
+ const target = await resolveServeTarget(options, env);
618
+ await mkdir3(target.workspaceDirPath, { recursive: true });
619
+ await warmGatewayProject(target);
620
+ console.log(`gateway plugin warmed: ${target.serverOrigin}`);
621
+ console.log(`warm directory: ${target.workspaceDirPath}`);
470
622
  }
471
623
 
472
624
  // src/cli.ts
@@ -482,6 +634,12 @@ async function main() {
482
634
  case "init":
483
635
  await runInit(command, process.env);
484
636
  return;
637
+ case "serve":
638
+ await runServe(command, process.env);
639
+ return;
640
+ case "warm":
641
+ await runWarm(command, process.env);
642
+ return;
485
643
  }
486
644
  }
487
645
  main().catch((error) => {
@@ -7,12 +7,13 @@ export type GatewayMemoryEntryConfig = {
7
7
  displayPath: string;
8
8
  description: string;
9
9
  injectContent: boolean;
10
+ searchOnly: boolean;
10
11
  } | {
11
12
  kind: "directory";
12
13
  path: string;
13
14
  displayPath: string;
14
15
  description: string;
15
- injectMarkdownContents: boolean;
16
16
  globs: string[];
17
+ searchOnly: boolean;
17
18
  };
18
19
  export declare function parseMemoryConfig(value: unknown, workspaceDirPath: string): Promise<GatewayMemoryConfig>;
package/dist/gateway.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin";
2
2
  import type { GatewayBindingModule, GatewayContract } from "./binding";
3
3
  import { GatewayCronRuntime } from "./cron/runtime";
4
4
  import { ChannelFileSender } from "./host/file-sender";
5
+ import { GatewayMemoryRuntime } from "./memory/runtime";
5
6
  import { GatewayExecutor } from "./runtime/executor";
6
7
  import { GatewaySessionContext } from "./session/context";
7
8
  import { ChannelSessionSwitcher } from "./session/switcher";
@@ -29,7 +30,8 @@ export declare class GatewayPluginRuntime {
29
30
  readonly channelSessions: ChannelSessionSwitcher;
30
31
  readonly sessionContext: GatewaySessionContext;
31
32
  readonly systemPrompts: GatewaySystemPromptBuilder;
32
- constructor(contract: GatewayContract, executor: GatewayExecutor, cron: GatewayCronRuntime, telegram: GatewayTelegramRuntime, files: ChannelFileSender, channelSessions: ChannelSessionSwitcher, sessionContext: GatewaySessionContext, systemPrompts: GatewaySystemPromptBuilder);
33
+ readonly memory: GatewayMemoryRuntime;
34
+ constructor(contract: GatewayContract, executor: GatewayExecutor, cron: GatewayCronRuntime, telegram: GatewayTelegramRuntime, files: ChannelFileSender, channelSessions: ChannelSessionSwitcher, sessionContext: GatewaySessionContext, systemPrompts: GatewaySystemPromptBuilder, memory: GatewayMemoryRuntime);
33
35
  status(): GatewayPluginStatus;
34
36
  }
35
37
  export declare function createGatewayRuntime(module: GatewayBindingModule, input: PluginInput): Promise<GatewayPluginRuntime>;
package/dist/index.js CHANGED
@@ -16151,30 +16151,42 @@ async function readMemoryEntry(value, index, workspaceDirPath) {
16151
16151
  throw new Error(`${field} must be a table`);
16152
16152
  }
16153
16153
  const entry = value;
16154
+ if (entry.inject_markdown_contents !== undefined) {
16155
+ throw new Error(`${field}.inject_markdown_contents has been removed; use globs for directory injection or search_only for on-demand access`);
16156
+ }
16154
16157
  const displayPath = readRequiredString(entry.path, `${field}.path`);
16155
16158
  const description = readRequiredString(entry.description, `${field}.description`);
16156
16159
  const resolvedPath = resolve(workspaceDirPath, displayPath);
16157
16160
  const metadata = await ensurePathMetadata(resolvedPath, displayPath, entry, `${field}.path`);
16161
+ const searchOnly = readBoolean2(entry.search_only, `${field}.search_only`, false);
16158
16162
  if (metadata.isFile()) {
16159
- ensureDirectoryOnlyFieldIsAbsent(entry.inject_markdown_contents, `${field}.inject_markdown_contents`);
16160
16163
  ensureDirectoryOnlyFieldIsAbsent(entry.globs, `${field}.globs`);
16164
+ const injectContent = readBoolean2(entry.inject_content, `${field}.inject_content`, false);
16165
+ if (searchOnly && injectContent) {
16166
+ throw new Error(`${field} cannot enable both inject_content and search_only`);
16167
+ }
16161
16168
  return {
16162
16169
  kind: "file",
16163
16170
  path: resolvedPath,
16164
16171
  displayPath,
16165
16172
  description,
16166
- injectContent: readBoolean2(entry.inject_content, `${field}.inject_content`, false)
16173
+ injectContent,
16174
+ searchOnly
16167
16175
  };
16168
16176
  }
16169
16177
  if (metadata.isDirectory()) {
16170
16178
  ensureFileOnlyFieldIsAbsent(entry.inject_content, `${field}.inject_content`);
16179
+ const globs = readGlobList(entry.globs, `${field}.globs`);
16180
+ if (searchOnly && globs.length > 0) {
16181
+ throw new Error(`${field} cannot enable both globs and search_only`);
16182
+ }
16171
16183
  return {
16172
16184
  kind: "directory",
16173
16185
  path: resolvedPath,
16174
16186
  displayPath,
16175
16187
  description,
16176
- injectMarkdownContents: readBoolean2(entry.inject_markdown_contents, `${field}.inject_markdown_contents`, false),
16177
- globs: readGlobList(entry.globs, `${field}.globs`)
16188
+ globs,
16189
+ searchOnly
16178
16190
  };
16179
16191
  }
16180
16192
  throw new Error(`${field}.path must point to a regular file or directory`);
@@ -16198,7 +16210,7 @@ function inferMissingEntryKind(displayPath, entry) {
16198
16210
  if (entry.inject_content !== undefined) {
16199
16211
  return "file";
16200
16212
  }
16201
- if (entry.inject_markdown_contents !== undefined || entry.globs !== undefined) {
16213
+ if (entry.globs !== undefined) {
16202
16214
  return "directory";
16203
16215
  }
16204
16216
  const trimmedPath = displayPath.trim();
@@ -17614,87 +17626,58 @@ class GatewayMailboxRouter {
17614
17626
  }
17615
17627
  }
17616
17628
 
17617
- // src/memory/prompt.ts
17629
+ // src/memory/files.ts
17618
17630
  var import_fast_glob = __toESM(require_out4(), 1);
17619
17631
  import { readFile as readFile3 } from "node:fs/promises";
17620
17632
  import { extname as extname3, relative } from "node:path";
17621
- var MARKDOWN_GLOBS = ["**/*.md", "**/*.markdown"];
17633
+ var ALL_FILES_GLOB = "**/*";
17622
17634
  var UTF8_TEXT_DECODER = new TextDecoder("utf-8", { fatal: true });
17623
-
17624
- class GatewayMemoryPromptProvider {
17625
- config;
17626
- logger;
17627
- constructor(config, logger) {
17628
- this.config = config;
17629
- this.logger = logger;
17630
- }
17631
- async buildPrompt() {
17632
- if (this.config.entries.length === 0) {
17633
- return null;
17634
- }
17635
- const sections = await Promise.all(this.config.entries.map((entry) => this.buildEntrySection(entry)));
17636
- return ["Gateway memory:", ...sections].join(`
17637
-
17638
- `);
17639
- }
17640
- async buildEntrySection(entry) {
17641
- const lines = [`Configured path: ${entry.displayPath}`, `Description: ${entry.description}`];
17642
- const injectedFiles = await collectInjectedFiles(entry, this.logger);
17643
- for (const file of injectedFiles) {
17644
- lines.push("");
17645
- lines.push(`File: ${file.displayPath}`);
17646
- lines.push(codeFence(file.infoString, file.text));
17647
- }
17648
- return lines.join(`
17649
- `);
17650
- }
17651
- }
17652
- async function collectInjectedFiles(entry, logger) {
17635
+ async function collectInjectedMemoryFiles(entry, logger) {
17653
17636
  if (entry.kind === "file") {
17654
- if (!entry.injectContent) {
17637
+ if (entry.searchOnly || !entry.injectContent) {
17655
17638
  return [];
17656
17639
  }
17657
- const text = await readTextFile(entry.path, logger);
17640
+ const text = await readMemoryTextFile(entry.path, logger);
17658
17641
  if (text === null) {
17659
17642
  return [];
17660
17643
  }
17661
17644
  return [
17662
17645
  {
17646
+ path: entry.path,
17663
17647
  displayPath: entry.displayPath,
17648
+ description: entry.description,
17664
17649
  infoString: inferFenceInfoString(entry.path),
17665
17650
  text
17666
17651
  }
17667
17652
  ];
17668
17653
  }
17669
- const filePaths = new Set;
17670
- if (entry.injectMarkdownContents) {
17671
- for (const pattern of MARKDOWN_GLOBS) {
17672
- addMatchingFiles(filePaths, entry.path, pattern);
17673
- }
17674
- }
17675
- for (const pattern of entry.globs) {
17676
- addMatchingFiles(filePaths, entry.path, pattern);
17654
+ if (entry.searchOnly || entry.globs.length === 0) {
17655
+ return [];
17677
17656
  }
17678
- const injectedFiles = [];
17679
- for (const filePath of [...filePaths].sort((left, right) => left.localeCompare(right))) {
17680
- const text = await readTextFile(filePath, logger);
17681
- if (text === null) {
17657
+ return await readDirectoryFiles(entry, entry.globs, logger);
17658
+ }
17659
+ async function collectSearchableMemoryFiles(config, logger) {
17660
+ const files = [];
17661
+ for (const entry of config.entries) {
17662
+ if (entry.kind === "file") {
17663
+ const text = await readMemoryTextFile(entry.path, logger);
17664
+ if (text === null) {
17665
+ continue;
17666
+ }
17667
+ files.push({
17668
+ path: entry.path,
17669
+ displayPath: entry.displayPath,
17670
+ description: entry.description,
17671
+ infoString: inferFenceInfoString(entry.path),
17672
+ text
17673
+ });
17682
17674
  continue;
17683
17675
  }
17684
- injectedFiles.push({
17685
- displayPath: relativeDisplayPath(entry.path, entry.displayPath, filePath),
17686
- infoString: inferFenceInfoString(filePath),
17687
- text
17688
- });
17676
+ files.push(...await readDirectoryFiles(entry, [ALL_FILES_GLOB], logger));
17689
17677
  }
17690
- return injectedFiles;
17678
+ return files;
17691
17679
  }
17692
- function addMatchingFiles(result, cwd, pattern) {
17693
- for (const match of import_fast_glob.globSync(pattern, { cwd, absolute: true, onlyFiles: true })) {
17694
- result.add(match);
17695
- }
17696
- }
17697
- async function readTextFile(path, logger) {
17680
+ async function readMemoryTextFile(path, logger) {
17698
17681
  let bytes;
17699
17682
  try {
17700
17683
  bytes = await readFile3(path);
@@ -17715,6 +17698,37 @@ async function readTextFile(path, logger) {
17715
17698
  }
17716
17699
  return text;
17717
17700
  }
17701
+ function codeFence(infoString, text) {
17702
+ const language = infoString.length === 0 ? "" : infoString;
17703
+ return [`\`\`\`${language}`, text, "```"].join(`
17704
+ `);
17705
+ }
17706
+ function addMatchingFiles(result, cwd, pattern) {
17707
+ for (const match of import_fast_glob.globSync(pattern, { cwd, absolute: true, onlyFiles: true, followSymbolicLinks: false })) {
17708
+ result.add(match);
17709
+ }
17710
+ }
17711
+ async function readDirectoryFiles(entry, patterns, logger) {
17712
+ const filePaths = new Set;
17713
+ for (const pattern of patterns) {
17714
+ addMatchingFiles(filePaths, entry.path, pattern);
17715
+ }
17716
+ const files = [];
17717
+ for (const filePath of [...filePaths].sort((left, right) => left.localeCompare(right))) {
17718
+ const text = await readMemoryTextFile(filePath, logger);
17719
+ if (text === null) {
17720
+ continue;
17721
+ }
17722
+ files.push({
17723
+ path: filePath,
17724
+ displayPath: relativeDisplayPath(entry.path, entry.displayPath, filePath),
17725
+ description: entry.description,
17726
+ infoString: inferFenceInfoString(filePath),
17727
+ text
17728
+ });
17729
+ }
17730
+ return files;
17731
+ }
17718
17732
  function relativeDisplayPath(rootPath, rootDisplayPath, filePath) {
17719
17733
  const suffix = relative(rootPath, filePath);
17720
17734
  if (suffix.length === 0) {
@@ -17729,11 +17743,6 @@ function inferFenceInfoString(path) {
17729
17743
  }
17730
17744
  return extension2;
17731
17745
  }
17732
- function codeFence(infoString, text) {
17733
- const language = infoString.length === 0 ? "" : infoString;
17734
- return [`\`\`\`${language}`, text, "```"].join(`
17735
- `);
17736
- }
17737
17746
  function formatError2(error) {
17738
17747
  if (error instanceof Error && error.message.trim().length > 0) {
17739
17748
  return error.message;
@@ -17741,6 +17750,218 @@ function formatError2(error) {
17741
17750
  return String(error);
17742
17751
  }
17743
17752
 
17753
+ // src/memory/prompt.ts
17754
+ class GatewayMemoryPromptProvider {
17755
+ config;
17756
+ logger;
17757
+ constructor(config, logger) {
17758
+ this.config = config;
17759
+ this.logger = logger;
17760
+ }
17761
+ async buildPrompt() {
17762
+ if (this.config.entries.length === 0) {
17763
+ return null;
17764
+ }
17765
+ const sections = await Promise.all(this.config.entries.map((entry) => this.buildEntrySection(entry)));
17766
+ return ["Gateway memory:", ...sections].join(`
17767
+
17768
+ `);
17769
+ }
17770
+ async buildEntrySection(entry) {
17771
+ const lines = [
17772
+ `Configured path: ${entry.displayPath}`,
17773
+ `Description: ${entry.description}`,
17774
+ `Access: ${describeMemoryAccess(entry)}`
17775
+ ];
17776
+ const injectedFiles = await collectInjectedMemoryFiles(entry, this.logger);
17777
+ if (entry.kind === "directory" && entry.globs.length > 0 && !entry.searchOnly) {
17778
+ lines.push(`Auto-injected globs: ${entry.globs.join(", ")}`);
17779
+ }
17780
+ for (const file of injectedFiles) {
17781
+ lines.push("");
17782
+ lines.push(`File: ${file.displayPath}`);
17783
+ lines.push(codeFence(file.infoString, file.text));
17784
+ }
17785
+ return lines.join(`
17786
+ `);
17787
+ }
17788
+ }
17789
+ function describeMemoryAccess(entry) {
17790
+ if (entry.kind === "file") {
17791
+ if (entry.injectContent && !entry.searchOnly) {
17792
+ return "auto-injected; use memory_search or memory_get for targeted follow-up";
17793
+ }
17794
+ return "search-only; use memory_search or memory_get when this file is relevant";
17795
+ }
17796
+ if (entry.searchOnly) {
17797
+ return "search-only; all UTF-8 text files under this directory are available via memory_search or memory_get";
17798
+ }
17799
+ if (entry.globs.length > 0) {
17800
+ return "globs are auto-injected; all UTF-8 text files under this directory remain available via memory_search or memory_get";
17801
+ }
17802
+ return "search-only by default; use memory_search or memory_get when this directory is relevant";
17803
+ }
17804
+
17805
+ // src/memory/runtime.ts
17806
+ import { dirname as dirname3 } from "node:path";
17807
+ import { FileFinder } from "@ff-labs/fff-node";
17808
+ var DEFAULT_SEARCH_LIMIT = 5;
17809
+ var MAX_SEARCH_LIMIT = 20;
17810
+ var DEFAULT_GET_MAX_LINES = 200;
17811
+ var MAX_GET_MAX_LINES = 500;
17812
+ var SEARCH_CONTEXT_RADIUS = 1;
17813
+ var SEARCH_SCAN_TIMEOUT_MS = 5000;
17814
+
17815
+ class GatewayMemoryRuntime {
17816
+ config;
17817
+ logger;
17818
+ finderCache = new Map;
17819
+ constructor(config, logger) {
17820
+ this.config = config;
17821
+ this.logger = logger;
17822
+ }
17823
+ hasEntries() {
17824
+ return this.config.entries.length > 0;
17825
+ }
17826
+ async search(query, limit = DEFAULT_SEARCH_LIMIT) {
17827
+ const normalizedQuery = normalizeRequiredString(query, "query");
17828
+ const normalizedLimit = normalizePositiveInteger(limit, "limit", MAX_SEARCH_LIMIT);
17829
+ const searchableFiles = await collectSearchableMemoryFiles(this.config, this.logger);
17830
+ const searchableFilesByPath = new Map(searchableFiles.map((file) => [file.path, file]));
17831
+ const results = [];
17832
+ for (const entry of this.config.entries) {
17833
+ const finder = await this.getFinder(searchRootForEntry(entry));
17834
+ const grep = finder.grep(normalizedQuery, {
17835
+ mode: "plain",
17836
+ beforeContext: SEARCH_CONTEXT_RADIUS,
17837
+ afterContext: SEARCH_CONTEXT_RADIUS
17838
+ });
17839
+ if (!grep.ok) {
17840
+ throw new Error(`memory search failed for ${entry.displayPath}: ${grep.error}`);
17841
+ }
17842
+ for (const match of grep.value.items) {
17843
+ if (entry.kind === "file" && match.path !== entry.path) {
17844
+ continue;
17845
+ }
17846
+ const searchableFile = searchableFilesByPath.get(match.path);
17847
+ if (searchableFile === undefined) {
17848
+ continue;
17849
+ }
17850
+ const window2 = readSnippetWindow(searchableFile.text, match.lineNumber, SEARCH_CONTEXT_RADIUS);
17851
+ results.push({
17852
+ path: displayPathForMatch(entry, match.relativePath),
17853
+ description: entry.description,
17854
+ lineStart: window2.lineStart,
17855
+ lineEnd: window2.lineEnd,
17856
+ snippet: window2.text,
17857
+ infoString: searchableFile.infoString
17858
+ });
17859
+ if (results.length >= normalizedLimit) {
17860
+ return results;
17861
+ }
17862
+ }
17863
+ }
17864
+ return results;
17865
+ }
17866
+ async get(path, startLine = 1, maxLines = DEFAULT_GET_MAX_LINES) {
17867
+ const normalizedPath = normalizeRequiredString(path, "path");
17868
+ const normalizedStartLine = normalizePositiveInteger(startLine, "start_line");
17869
+ const normalizedMaxLines = normalizePositiveInteger(maxLines, "max_lines", MAX_GET_MAX_LINES);
17870
+ const files = await collectSearchableMemoryFiles(this.config, this.logger);
17871
+ const matches = files.filter((file2) => file2.displayPath === normalizedPath);
17872
+ if (matches.length === 0) {
17873
+ throw new Error(`memory path was not found: ${normalizedPath}`);
17874
+ }
17875
+ if (matches.length > 1) {
17876
+ throw new Error(`memory path is ambiguous: ${normalizedPath}`);
17877
+ }
17878
+ const file = matches[0];
17879
+ const lines = splitLines(file.text);
17880
+ if (normalizedStartLine > lines.length) {
17881
+ throw new Error(`start_line ${normalizedStartLine} exceeds the file length of ${lines.length} line(s) for ${normalizedPath}`);
17882
+ }
17883
+ const startIndex = normalizedStartLine - 1;
17884
+ const window2 = lines.slice(startIndex, startIndex + normalizedMaxLines);
17885
+ return {
17886
+ path: file.displayPath,
17887
+ description: file.description,
17888
+ lineStart: normalizedStartLine,
17889
+ lineEnd: startIndex + window2.length,
17890
+ text: window2.join(`
17891
+ `),
17892
+ infoString: file.infoString
17893
+ };
17894
+ }
17895
+ async getFinder(rootPath) {
17896
+ const cached = this.finderCache.get(rootPath);
17897
+ if (cached !== undefined && !cached.isDestroyed) {
17898
+ return cached;
17899
+ }
17900
+ const created = FileFinder.create({
17901
+ basePath: rootPath,
17902
+ aiMode: true
17903
+ });
17904
+ if (!created.ok) {
17905
+ throw new Error(`could not initialize memory search index for ${rootPath}: ${created.error}`);
17906
+ }
17907
+ const finder = created.value;
17908
+ const ready = await finder.waitForScan(SEARCH_SCAN_TIMEOUT_MS);
17909
+ if (!ready.ok) {
17910
+ finder.destroy();
17911
+ throw new Error(`memory search index failed while waiting for scan: ${ready.error}`);
17912
+ }
17913
+ if (!ready.value) {
17914
+ this.logger.log("warn", `memory search scan is still warming after ${SEARCH_SCAN_TIMEOUT_MS}ms: ${rootPath}`);
17915
+ }
17916
+ this.finderCache.set(rootPath, finder);
17917
+ return finder;
17918
+ }
17919
+ }
17920
+ function searchRootForEntry(entry) {
17921
+ return entry.kind === "file" ? dirname3(entry.path) : entry.path;
17922
+ }
17923
+ function displayPathForMatch(entry, relativePath) {
17924
+ if (entry.kind === "file") {
17925
+ return entry.displayPath;
17926
+ }
17927
+ const normalizedRelativePath = relativePath.replaceAll("\\", "/");
17928
+ return normalizedRelativePath.length === 0 ? entry.displayPath : `${entry.displayPath}/${normalizedRelativePath}`;
17929
+ }
17930
+ function splitLines(text) {
17931
+ return text.split(/\r?\n/);
17932
+ }
17933
+ function readSnippetWindow(text, lineNumber, contextRadius) {
17934
+ const lines = splitLines(text);
17935
+ const matchIndex = Math.min(Math.max(lineNumber - 1, 0), Math.max(lines.length - 1, 0));
17936
+ const startIndex = Math.max(0, matchIndex - contextRadius);
17937
+ const endIndex = Math.min(lines.length - 1, matchIndex + contextRadius);
17938
+ return {
17939
+ lineStart: startIndex + 1,
17940
+ lineEnd: endIndex + 1,
17941
+ text: lines.slice(startIndex, endIndex + 1).join(`
17942
+ `)
17943
+ };
17944
+ }
17945
+ function normalizePositiveInteger(value, field, maxValue) {
17946
+ if (!Number.isSafeInteger(value)) {
17947
+ throw new Error(`${field} must be an integer`);
17948
+ }
17949
+ if (value <= 0) {
17950
+ throw new Error(`${field} must be greater than 0`);
17951
+ }
17952
+ if (maxValue !== undefined && value > maxValue) {
17953
+ throw new Error(`${field} must be less than or equal to ${maxValue}`);
17954
+ }
17955
+ return value;
17956
+ }
17957
+ function normalizeRequiredString(value, field) {
17958
+ const trimmed = value.trim();
17959
+ if (trimmed.length === 0) {
17960
+ throw new Error(`${field} must not be empty`);
17961
+ }
17962
+ return trimmed;
17963
+ }
17964
+
17744
17965
  // src/opencode/adapter.ts
17745
17966
  import { basename as basename2 } from "node:path";
17746
17967
  import { pathToFileURL } from "node:url";
@@ -17754,7 +17975,9 @@ function delay(durationMs) {
17754
17975
 
17755
17976
  // src/opencode/adapter.ts
17756
17977
  var SESSION_IDLE_POLL_MS = 250;
17757
- var PROMPT_RESPONSE_TIMEOUT_MS = 90000;
17978
+ var PROMPT_RESPONSE_PROGRESS_TIMEOUT_MS = 90000;
17979
+ var PROMPT_RESPONSE_MAX_TIMEOUT_MS = 10 * 60000;
17980
+ var PROMPT_RESPONSE_SETTLE_MS = 1000;
17758
17981
 
17759
17982
  class OpencodeSdkAdapter {
17760
17983
  client;
@@ -17900,8 +18123,12 @@ class OpencodeSdkAdapter {
17900
18123
  };
17901
18124
  }
17902
18125
  async awaitPromptResponse(command) {
17903
- const deadline = Date.now() + PROMPT_RESPONSE_TIMEOUT_MS;
18126
+ const startedAtMs = Date.now();
18127
+ const maxDeadline = startedAtMs + PROMPT_RESPONSE_MAX_TIMEOUT_MS;
18128
+ let progressDeadline = startedAtMs + PROMPT_RESPONSE_PROGRESS_TIMEOUT_MS;
17904
18129
  let stableCandidateKey = null;
18130
+ let stableCandidateSinceMs = null;
18131
+ let progressKey = null;
17905
18132
  for (;; ) {
17906
18133
  const messages = await this.client.session.messages({
17907
18134
  path: { id: command.sessionId },
@@ -17912,22 +18139,32 @@ class OpencodeSdkAdapter {
17912
18139
  responseStyle: "data",
17913
18140
  throwOnError: true
17914
18141
  });
17915
- const response = selectAssistantResponse(unwrapData(messages), command.messageId);
18142
+ const assistantChildren = listAssistantResponses(unwrapData(messages), command.messageId);
18143
+ const nextProgressKey = createAssistantProgressKey(assistantChildren);
18144
+ const now = Date.now();
18145
+ if (progressKey !== nextProgressKey) {
18146
+ progressKey = nextProgressKey;
18147
+ progressDeadline = now + PROMPT_RESPONSE_PROGRESS_TIMEOUT_MS;
18148
+ }
18149
+ const response = selectAssistantResponse(assistantChildren);
17916
18150
  if (response !== null) {
17917
18151
  const candidateKey = createAssistantCandidateKey(response);
17918
18152
  if (stableCandidateKey === candidateKey) {
17919
- return {
17920
- kind: "awaitPromptResponse",
17921
- sessionId: command.sessionId,
17922
- messageId: response.info.id,
17923
- parts: response.parts.flatMap(toBindingMessagePart)
17924
- };
18153
+ if (stableCandidateSinceMs !== null && now - stableCandidateSinceMs >= PROMPT_RESPONSE_SETTLE_MS) {
18154
+ return toAwaitPromptResponseResult(command.sessionId, response);
18155
+ }
18156
+ } else {
18157
+ stableCandidateKey = candidateKey;
18158
+ stableCandidateSinceMs = now;
17925
18159
  }
17926
- stableCandidateKey = candidateKey;
17927
18160
  } else {
17928
18161
  stableCandidateKey = null;
18162
+ stableCandidateSinceMs = null;
17929
18163
  }
17930
- if (Date.now() >= deadline) {
18164
+ if (now >= progressDeadline || now >= maxDeadline) {
18165
+ if (response !== null) {
18166
+ return toAwaitPromptResponseResult(command.sessionId, response);
18167
+ }
17931
18168
  throw new Error(`assistant message for prompt ${command.messageId} is unavailable after prompt completion`);
17932
18169
  }
17933
18170
  await delay(SESSION_IDLE_POLL_MS);
@@ -17988,8 +18225,10 @@ function toSessionPromptPart(part) {
17988
18225
  };
17989
18226
  }
17990
18227
  }
17991
- function selectAssistantResponse(messages, userMessageId) {
17992
- const assistantChildren = messages.filter(isAssistantChildMessage(userMessageId));
18228
+ function listAssistantResponses(messages, userMessageId) {
18229
+ return messages.filter(isAssistantChildMessage(userMessageId));
18230
+ }
18231
+ function selectAssistantResponse(assistantChildren) {
17993
18232
  for (let index = assistantChildren.length - 1;index >= 0; index -= 1) {
17994
18233
  const candidate = assistantChildren[index];
17995
18234
  if (hasVisibleText(candidate)) {
@@ -18004,6 +18243,9 @@ function selectAssistantResponse(messages, userMessageId) {
18004
18243
  }
18005
18244
  return null;
18006
18245
  }
18246
+ function createAssistantProgressKey(messages) {
18247
+ return JSON.stringify(messages.map(createAssistantCandidateKey));
18248
+ }
18007
18249
  function createAssistantCandidateKey(message) {
18008
18250
  return JSON.stringify({
18009
18251
  messageId: message.info.id,
@@ -18020,6 +18262,14 @@ function createAssistantCandidateKey(message) {
18020
18262
  function isAssistantChildMessage(userMessageId) {
18021
18263
  return (message) => message.info?.role === "assistant" && message.info.parentID === userMessageId;
18022
18264
  }
18265
+ function toAwaitPromptResponseResult(sessionId, message) {
18266
+ return {
18267
+ kind: "awaitPromptResponse",
18268
+ sessionId,
18269
+ messageId: message.info.id,
18270
+ parts: message.parts.flatMap(toBindingMessagePart)
18271
+ };
18272
+ }
18023
18273
  function toBindingMessagePart(part) {
18024
18274
  if (typeof part.id !== "string" || typeof part.messageID !== "string" || part.type.length === 0) {
18025
18275
  return [];
@@ -22463,7 +22713,7 @@ function buildGatewayContextPrompt(targets) {
22463
22713
 
22464
22714
  // src/store/sqlite.ts
22465
22715
  import { mkdir as mkdir2 } from "node:fs/promises";
22466
- import { dirname as dirname3 } from "node:path";
22716
+ import { dirname as dirname4 } from "node:path";
22467
22717
 
22468
22718
  // src/store/migrations.ts
22469
22719
  var LATEST_SCHEMA_VERSION = 7;
@@ -23377,7 +23627,7 @@ function readBooleanField(value, field, fallback) {
23377
23627
  return raw;
23378
23628
  }
23379
23629
  async function openSqliteStore(path) {
23380
- await mkdir2(dirname3(path), { recursive: true });
23630
+ await mkdir2(dirname4(path), { recursive: true });
23381
23631
  const { openRuntimeSqliteDatabase: openRuntimeSqliteDatabase2 } = await Promise.resolve().then(() => (init_database(), exports_database));
23382
23632
  const db2 = await openRuntimeSqliteDatabase2(path);
23383
23633
  migrateGatewayDatabase(db2);
@@ -24107,7 +24357,8 @@ class GatewayPluginRuntime {
24107
24357
  channelSessions;
24108
24358
  sessionContext;
24109
24359
  systemPrompts;
24110
- constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts) {
24360
+ memory;
24361
+ constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts, memory) {
24111
24362
  this.contract = contract;
24112
24363
  this.executor = executor;
24113
24364
  this.cron = cron;
@@ -24116,6 +24367,7 @@ class GatewayPluginRuntime {
24116
24367
  this.channelSessions = channelSessions;
24117
24368
  this.sessionContext = sessionContext;
24118
24369
  this.systemPrompts = systemPrompts;
24370
+ this.memory = memory;
24119
24371
  }
24120
24372
  status() {
24121
24373
  const rustStatus = this.contract.gatewayStatus();
@@ -24146,6 +24398,7 @@ async function createGatewayRuntime(module, input) {
24146
24398
  const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
24147
24399
  const store = await openSqliteStore(config.stateDbPath);
24148
24400
  const sessionContext = new GatewaySessionContext(store);
24401
+ const memory = new GatewayMemoryRuntime(config.memory, logger);
24149
24402
  const memoryPrompts = new GatewayMemoryPromptProvider(config.memory, logger);
24150
24403
  const systemPrompts = new GatewaySystemPromptBuilder(sessionContext, memoryPrompts);
24151
24404
  const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
@@ -24170,7 +24423,7 @@ async function createGatewayRuntime(module, input) {
24170
24423
  cron.start();
24171
24424
  mailbox.start();
24172
24425
  telegram.start();
24173
- return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts);
24426
+ return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts, memory);
24174
24427
  });
24175
24428
  }
24176
24429
  function resolveEffectiveCronTimeZone(module, config) {
@@ -36732,6 +36985,63 @@ function formatGatewayStatus(status) {
36732
36985
  `);
36733
36986
  }
36734
36987
 
36988
+ // src/tools/memory-get.ts
36989
+ function createMemoryGetTool(runtime) {
36990
+ return tool({
36991
+ description: "Read a configured gateway memory file by path. Use the path returned by memory_search or a configured file path.",
36992
+ args: {
36993
+ path: tool.schema.string().min(1),
36994
+ start_line: tool.schema.number().optional(),
36995
+ max_lines: tool.schema.number().optional()
36996
+ },
36997
+ async execute(args) {
36998
+ return formatMemoryGetResult(await runtime.get(args.path, args.start_line, args.max_lines));
36999
+ }
37000
+ });
37001
+ }
37002
+ function formatMemoryGetResult(result) {
37003
+ return [
37004
+ `path=${result.path}`,
37005
+ `description=${result.description}`,
37006
+ `line_start=${result.lineStart}`,
37007
+ `line_end=${result.lineEnd}`,
37008
+ "content:",
37009
+ codeFence(result.infoString, result.text)
37010
+ ].join(`
37011
+ `);
37012
+ }
37013
+
37014
+ // src/tools/memory-search.ts
37015
+ function createMemorySearchTool(runtime) {
37016
+ return tool({
37017
+ description: "Search configured gateway memory files and directories. Returns matching snippets and paths that can be read in more detail with memory_get.",
37018
+ args: {
37019
+ query: tool.schema.string().min(1),
37020
+ limit: tool.schema.number().optional()
37021
+ },
37022
+ async execute(args) {
37023
+ const results = await runtime.search(args.query, args.limit);
37024
+ if (results.length === 0) {
37025
+ return "no memory matches";
37026
+ }
37027
+ return results.map((result, index) => formatMemorySearchResult(result, index + 1)).join(`
37028
+
37029
+ `);
37030
+ }
37031
+ });
37032
+ }
37033
+ function formatMemorySearchResult(result, ordinal) {
37034
+ return [
37035
+ `result[${ordinal}].path=${result.path}`,
37036
+ `result[${ordinal}].description=${result.description}`,
37037
+ `result[${ordinal}].line_start=${result.lineStart}`,
37038
+ `result[${ordinal}].line_end=${result.lineEnd}`,
37039
+ `result[${ordinal}].snippet:`,
37040
+ codeFence(result.infoString, result.snippet)
37041
+ ].join(`
37042
+ `);
37043
+ }
37044
+
36735
37045
  // src/tools/schedule-cancel.ts
36736
37046
  function createScheduleCancelTool(runtime) {
36737
37047
  return tool({
@@ -36972,6 +37282,10 @@ var OpencodeGatewayPlugin = async (input) => {
36972
37282
  schedule_once: createScheduleOnceTool(runtime.cron, runtime.sessionContext),
36973
37283
  schedule_status: createScheduleStatusTool(runtime.cron)
36974
37284
  };
37285
+ if (runtime.memory.hasEntries()) {
37286
+ tools.memory_search = createMemorySearchTool(runtime.memory);
37287
+ tools.memory_get = createMemoryGetTool(runtime.memory);
37288
+ }
36975
37289
  if (runtime.files.hasEnabledChannel()) {
36976
37290
  tools.channel_send_file = createChannelSendFileTool(runtime.files, runtime.sessionContext);
36977
37291
  }
@@ -0,0 +1,13 @@
1
+ import type { BindingLoggerHost } from "../binding";
2
+ import type { GatewayMemoryConfig, GatewayMemoryEntryConfig } from "../config/memory";
3
+ export type SearchableMemoryFile = {
4
+ path: string;
5
+ displayPath: string;
6
+ description: string;
7
+ infoString: string;
8
+ text: string;
9
+ };
10
+ export declare function collectInjectedMemoryFiles(entry: GatewayMemoryEntryConfig, logger: Pick<BindingLoggerHost, "log">): Promise<SearchableMemoryFile[]>;
11
+ export declare function collectSearchableMemoryFiles(config: GatewayMemoryConfig, logger: Pick<BindingLoggerHost, "log">): Promise<SearchableMemoryFile[]>;
12
+ export declare function readMemoryTextFile(path: string, logger: Pick<BindingLoggerHost, "log">): Promise<string | null>;
13
+ export declare function codeFence(infoString: string, text: string): string;
@@ -0,0 +1,28 @@
1
+ import type { BindingLoggerHost } from "../binding";
2
+ import type { GatewayMemoryConfig } from "../config/memory";
3
+ export type MemorySearchResult = {
4
+ path: string;
5
+ description: string;
6
+ lineStart: number;
7
+ lineEnd: number;
8
+ snippet: string;
9
+ infoString: string;
10
+ };
11
+ export type MemoryGetResult = {
12
+ path: string;
13
+ description: string;
14
+ lineStart: number;
15
+ lineEnd: number;
16
+ text: string;
17
+ infoString: string;
18
+ };
19
+ export declare class GatewayMemoryRuntime {
20
+ private readonly config;
21
+ private readonly logger;
22
+ private readonly finderCache;
23
+ constructor(config: GatewayMemoryConfig, logger: Pick<BindingLoggerHost, "log">);
24
+ hasEntries(): boolean;
25
+ search(query: string, limit?: number): Promise<MemorySearchResult[]>;
26
+ get(path: string, startLine?: number, maxLines?: number): Promise<MemoryGetResult>;
27
+ private getFinder;
28
+ }
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from "@opencode-ai/plugin";
2
+ import type { GatewayMemoryRuntime } from "../memory/runtime";
3
+ export declare function createMemoryGetTool(runtime: GatewayMemoryRuntime): ToolDefinition;
@@ -0,0 +1,3 @@
1
+ import type { ToolDefinition } from "@opencode-ai/plugin";
2
+ import type { GatewayMemoryRuntime } from "../memory/runtime";
3
+ export declare function createMemorySearchTool(runtime: GatewayMemoryRuntime): ToolDefinition;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-gateway",
3
- "version": "0.2.9",
3
+ "version": "0.3.0",
4
4
  "description": "Gateway plugin for OpenCode",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -52,6 +52,7 @@
52
52
  "access": "public"
53
53
  },
54
54
  "dependencies": {
55
+ "@ff-labs/fff-node": "^0.4.2",
55
56
  "@opencode-ai/plugin": "~1.3.0",
56
57
  "@opencode-ai/sdk": "~1.3.0",
57
58
  "better-sqlite3": "^12.8.0",