opencode-gateway 0.2.10 → 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
@@ -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
- });
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";
@@ -22492,7 +22713,7 @@ function buildGatewayContextPrompt(targets) {
22492
22713
 
22493
22714
  // src/store/sqlite.ts
22494
22715
  import { mkdir as mkdir2 } from "node:fs/promises";
22495
- import { dirname as dirname3 } from "node:path";
22716
+ import { dirname as dirname4 } from "node:path";
22496
22717
 
22497
22718
  // src/store/migrations.ts
22498
22719
  var LATEST_SCHEMA_VERSION = 7;
@@ -23406,7 +23627,7 @@ function readBooleanField(value, field, fallback) {
23406
23627
  return raw;
23407
23628
  }
23408
23629
  async function openSqliteStore(path) {
23409
- await mkdir2(dirname3(path), { recursive: true });
23630
+ await mkdir2(dirname4(path), { recursive: true });
23410
23631
  const { openRuntimeSqliteDatabase: openRuntimeSqliteDatabase2 } = await Promise.resolve().then(() => (init_database(), exports_database));
23411
23632
  const db2 = await openRuntimeSqliteDatabase2(path);
23412
23633
  migrateGatewayDatabase(db2);
@@ -24136,7 +24357,8 @@ class GatewayPluginRuntime {
24136
24357
  channelSessions;
24137
24358
  sessionContext;
24138
24359
  systemPrompts;
24139
- constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts) {
24360
+ memory;
24361
+ constructor(contract, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts, memory) {
24140
24362
  this.contract = contract;
24141
24363
  this.executor = executor;
24142
24364
  this.cron = cron;
@@ -24145,6 +24367,7 @@ class GatewayPluginRuntime {
24145
24367
  this.channelSessions = channelSessions;
24146
24368
  this.sessionContext = sessionContext;
24147
24369
  this.systemPrompts = systemPrompts;
24370
+ this.memory = memory;
24148
24371
  }
24149
24372
  status() {
24150
24373
  const rustStatus = this.contract.gatewayStatus();
@@ -24175,6 +24398,7 @@ async function createGatewayRuntime(module, input) {
24175
24398
  const effectiveCronTimeZone = resolveEffectiveCronTimeZone(module, config);
24176
24399
  const store = await openSqliteStore(config.stateDbPath);
24177
24400
  const sessionContext = new GatewaySessionContext(store);
24401
+ const memory = new GatewayMemoryRuntime(config.memory, logger);
24178
24402
  const memoryPrompts = new GatewayMemoryPromptProvider(config.memory, logger);
24179
24403
  const systemPrompts = new GatewaySystemPromptBuilder(sessionContext, memoryPrompts);
24180
24404
  const telegramClient = config.telegram.enabled ? new TelegramBotClient(config.telegram.botToken) : null;
@@ -24199,7 +24423,7 @@ async function createGatewayRuntime(module, input) {
24199
24423
  cron.start();
24200
24424
  mailbox.start();
24201
24425
  telegram.start();
24202
- return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts);
24426
+ return new GatewayPluginRuntime(module, executor, cron, telegram, files, channelSessions, sessionContext, systemPrompts, memory);
24203
24427
  });
24204
24428
  }
24205
24429
  function resolveEffectiveCronTimeZone(module, config) {
@@ -36761,6 +36985,63 @@ function formatGatewayStatus(status) {
36761
36985
  `);
36762
36986
  }
36763
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
+
36764
37045
  // src/tools/schedule-cancel.ts
36765
37046
  function createScheduleCancelTool(runtime) {
36766
37047
  return tool({
@@ -37001,6 +37282,10 @@ var OpencodeGatewayPlugin = async (input) => {
37001
37282
  schedule_once: createScheduleOnceTool(runtime.cron, runtime.sessionContext),
37002
37283
  schedule_status: createScheduleStatusTool(runtime.cron)
37003
37284
  };
37285
+ if (runtime.memory.hasEntries()) {
37286
+ tools.memory_search = createMemorySearchTool(runtime.memory);
37287
+ tools.memory_get = createMemoryGetTool(runtime.memory);
37288
+ }
37004
37289
  if (runtime.files.hasEnabledChannel()) {
37005
37290
  tools.channel_send_file = createChannelSendFileTool(runtime.files, runtime.sessionContext);
37006
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.10",
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",