opencode-gateway 0.2.10 → 0.3.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 CHANGED
@@ -98,8 +98,12 @@ inject_content = true
98
98
  [[memory.entries]]
99
99
  path = "memory/notes"
100
100
  description = "Domain notes and operating docs"
101
- inject_markdown_contents = true
102
- 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"]
103
107
  ```
104
108
 
105
109
  When Telegram is enabled, export the bot token through the configured
@@ -122,13 +126,16 @@ Mailbox rules:
122
126
  Memory rules:
123
127
 
124
128
  - all entries inject their configured path and description
125
- - file contents are injected only when `inject_content = true`
126
- - directory entries default to description-only
127
- - `inject_markdown_contents = true` recursively injects `*.md` and `*.markdown`
128
- - `globs` are relative to the configured directory and may match other UTF-8
129
- 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
130
135
  - relative paths are resolved from `opencode-gateway-workspace`
131
136
  - absolute paths are still allowed
132
137
  - missing files and directories are created automatically on load
133
138
  - the default template includes `USER.md` as persistent user-profile memory
134
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
package/dist/cli.js CHANGED
@@ -518,10 +518,19 @@ function buildGatewayConfigTemplate(stateDbPath) {
518
518
  "inject_content = true",
519
519
  "#",
520
520
  "# [[memory.entries]]",
521
+ '# path = "memory/project.md"',
522
+ '# description = "Project conventions and long-lived context"',
523
+ "# inject_content = true",
524
+ "#",
525
+ "# [[memory.entries]]",
521
526
  '# path = "memory/notes"',
522
- '# description = "Domain notes and operating docs"',
523
- "# inject_markdown_contents = true",
524
- '# 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"]',
525
534
  ""
526
535
  ].join(`
527
536
  `);
@@ -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
- });
17689
- }
17690
- return injectedFiles;
17691
- }
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);
17676
+ files.push(...await readDirectoryFiles(entry, [ALL_FILES_GLOB], logger));
17695
17677
  }
17678
+ return files;
17696
17679
  }
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";
@@ -18010,18 +18231,24 @@ function listAssistantResponses(messages, userMessageId) {
18010
18231
  function selectAssistantResponse(assistantChildren) {
18011
18232
  for (let index = assistantChildren.length - 1;index >= 0; index -= 1) {
18012
18233
  const candidate = assistantChildren[index];
18013
- if (hasVisibleText(candidate)) {
18234
+ if (isTerminalAssistantMessage(candidate) && hasVisibleText(candidate)) {
18014
18235
  return candidate;
18015
18236
  }
18016
18237
  }
18017
18238
  for (let index = assistantChildren.length - 1;index >= 0; index -= 1) {
18018
18239
  const candidate = assistantChildren[index];
18019
- if (candidate.info?.finish === "stop" || candidate.info?.error !== undefined) {
18240
+ if (isTerminalAssistantMessage(candidate)) {
18020
18241
  return candidate;
18021
18242
  }
18022
18243
  }
18023
18244
  return null;
18024
18245
  }
18246
+ function isTerminalAssistantMessage(message) {
18247
+ if (message.info.error !== undefined) {
18248
+ return true;
18249
+ }
18250
+ return typeof message.info.finish === "string" && message.info.finish !== "tool-calls";
18251
+ }
18025
18252
  function createAssistantProgressKey(messages) {
18026
18253
  return JSON.stringify(messages.map(createAssistantCandidateKey));
18027
18254
  }
@@ -22492,7 +22719,7 @@ function buildGatewayContextPrompt(targets) {
22492
22719
 
22493
22720
  // src/store/sqlite.ts
22494
22721
  import { mkdir as mkdir2 } from "node:fs/promises";
22495
- import { dirname as dirname3 } from "node:path";
22722
+ import { dirname as dirname4 } from "node:path";
22496
22723
 
22497
22724
  // src/store/migrations.ts
22498
22725
  var LATEST_SCHEMA_VERSION = 7;
@@ -23406,7 +23633,7 @@ function readBooleanField(value, field, fallback) {
23406
23633
  return raw;
23407
23634
  }
23408
23635
  async function openSqliteStore(path) {
23409
- await mkdir2(dirname3(path), { recursive: true });
23636
+ await mkdir2(dirname4(path), { recursive: true });
23410
23637
  const { openRuntimeSqliteDatabase: openRuntimeSqliteDatabase2 } = await Promise.resolve().then(() => (init_database(), exports_database));
23411
23638
  const db2 = await openRuntimeSqliteDatabase2(path);
23412
23639
  migrateGatewayDatabase(db2);
@@ -24136,7 +24363,8 @@ class GatewayPluginRuntime {
24136
24363
  channelSessions;
24137
24364
  sessionContext;
24138
24365
  systemPrompts;
24139
- constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts) {
24366
+ memory;
24367
+ constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts, memory) {
24140
24368
  this.contract = contract;
24141
24369
  this.executor = executor;
24142
24370
  this.cron = cron;
@@ -24145,6 +24373,7 @@ class GatewayPluginRuntime {
24145
24373
  this.channelSessions = channelSessions;
24146
24374
  this.sessionContext = sessionContext;
24147
24375
  this.systemPrompts = systemPrompts;
24376
+ this.memory = memory;
24148
24377
  }
24149
24378
  status() {
24150
24379
  const rustStatus = this.contract.gatewayStatus();
@@ -24175,6 +24404,7 @@ async function createGatewayRuntime(module, input) {
24175
24404
  const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
24176
24405
  const store = await openSqliteStore(config.stateDbPath);
24177
24406
  const sessionContext = new GatewaySessionContext(store);
24407
+ const memory = new GatewayMemoryRuntime(config.memory, logger);
24178
24408
  const memoryPrompts = new GatewayMemoryPromptProvider(config.memory, logger);
24179
24409
  const systemPrompts = new GatewaySystemPromptBuilder(sessionContext, memoryPrompts);
24180
24410
  const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
@@ -24199,7 +24429,7 @@ async function createGatewayRuntime(module, input) {
24199
24429
  cron.start();
24200
24430
  mailbox.start();
24201
24431
  telegram.start();
24202
- return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts);
24432
+ return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts, memory);
24203
24433
  });
24204
24434
  }
24205
24435
  function resolveEffectiveCronTimeZone(module, config) {
@@ -36761,6 +36991,63 @@ function formatGatewayStatus(status) {
36761
36991
  `);
36762
36992
  }
36763
36993
 
36994
+ // src/tools/memory-get.ts
36995
+ function createMemoryGetTool(runtime) {
36996
+ return tool({
36997
+ description: "Read a configured gateway memory file by path. Use the path returned by memory_search or a configured file path.",
36998
+ args: {
36999
+ path: tool.schema.string().min(1),
37000
+ start_line: tool.schema.number().optional(),
37001
+ max_lines: tool.schema.number().optional()
37002
+ },
37003
+ async execute(args) {
37004
+ return formatMemoryGetResult(await runtime.get(args.path, args.start_line, args.max_lines));
37005
+ }
37006
+ });
37007
+ }
37008
+ function formatMemoryGetResult(result) {
37009
+ return [
37010
+ `path=${result.path}`,
37011
+ `description=${result.description}`,
37012
+ `line_start=${result.lineStart}`,
37013
+ `line_end=${result.lineEnd}`,
37014
+ "content:",
37015
+ codeFence(result.infoString, result.text)
37016
+ ].join(`
37017
+ `);
37018
+ }
37019
+
37020
+ // src/tools/memory-search.ts
37021
+ function createMemorySearchTool(runtime) {
37022
+ return tool({
37023
+ description: "Search configured gateway memory files and directories. Returns matching snippets and paths that can be read in more detail with memory_get.",
37024
+ args: {
37025
+ query: tool.schema.string().min(1),
37026
+ limit: tool.schema.number().optional()
37027
+ },
37028
+ async execute(args) {
37029
+ const results = await runtime.search(args.query, args.limit);
37030
+ if (results.length === 0) {
37031
+ return "no memory matches";
37032
+ }
37033
+ return results.map((result, index) => formatMemorySearchResult(result, index + 1)).join(`
37034
+
37035
+ `);
37036
+ }
37037
+ });
37038
+ }
37039
+ function formatMemorySearchResult(result, ordinal) {
37040
+ return [
37041
+ `result[${ordinal}].path=${result.path}`,
37042
+ `result[${ordinal}].description=${result.description}`,
37043
+ `result[${ordinal}].line_start=${result.lineStart}`,
37044
+ `result[${ordinal}].line_end=${result.lineEnd}`,
37045
+ `result[${ordinal}].snippet:`,
37046
+ codeFence(result.infoString, result.snippet)
37047
+ ].join(`
37048
+ `);
37049
+ }
37050
+
36764
37051
  // src/tools/schedule-cancel.ts
36765
37052
  function createScheduleCancelTool(runtime) {
36766
37053
  return tool({
@@ -37001,6 +37288,10 @@ var OpencodeGatewayPlugin = async (input) => {
37001
37288
  schedule_once: createScheduleOnceTool(runtime.cron, runtime.sessionContext),
37002
37289
  schedule_status: createScheduleStatusTool(runtime.cron)
37003
37290
  };
37291
+ if (runtime.memory.hasEntries()) {
37292
+ tools.memory_search = createMemorySearchTool(runtime.memory);
37293
+ tools.memory_get = createMemoryGetTool(runtime.memory);
37294
+ }
37004
37295
  if (runtime.files.hasEnabledChannel()) {
37005
37296
  tools.channel_send_file = createChannelSendFileTool(runtime.files, runtime.sessionContext);
37006
37297
  }
@@ -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.10",
3
+ "version": "0.3.1",
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",