diff --git a/examples/opencode-plugin/INSTALL-ZH.md b/examples/opencode-plugin/INSTALL-ZH.md new file mode 100644 index 000000000..57bdc7a03 --- /dev/null +++ b/examples/opencode-plugin/INSTALL-ZH.md @@ -0,0 +1,186 @@ +# 安装 OpenViking OpenCode 统一插件 + +这个插件新增了一个面向 OpenCode 的统一 OpenViking 插件: + +- 外部仓库语义检索 +- 长期记忆、session 同步、生命周期边界 commit、自动 recall + +旧示例目前仍然保留,后续会下线。这个插件不再安装 `skills/openviking/SKILL.md`,也不要求 agent 使用 `ov` 命令。原 skill 中的能力会通过 OpenCode tools 暴露。 + +## 前置条件 + +需要先准备: + +- OpenCode +- OpenViking HTTP Server +- Node.js / npm,用于安装插件依赖 +- 如果服务端启用了认证,需要可用的 OpenViking API Key + +建议先启动 OpenViking: + +```bash +openviking-server --config ~/.openviking/ov.conf +``` + +检查服务: + +```bash +curl http://localhost:1933/health +``` + +## 安装方式一:发布包安装 + +普通用户推荐通过 OpenCode 的 package plugin 机制启用: + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +如果发布前包名有调整,请使用最终发布包名。 + +## 安装方式二:源码安装 + +用于开发调试或 PR 测试。OpenCode 推荐插件目录: + +```bash +~/.config/opencode/plugins +``` + +在仓库根目录执行: + +```bash +mkdir -p ~/.config/opencode/plugins/openviking +cp examples/opencode-plugin/wrappers/openviking.mjs ~/.config/opencode/plugins/openviking.mjs +cp examples/opencode-plugin/index.mjs examples/opencode-plugin/package.json ~/.config/opencode/plugins/openviking/ +cp -r examples/opencode-plugin/lib ~/.config/opencode/plugins/openviking/ +cd ~/.config/opencode/plugins/openviking +npm install +``` + +安装后结构应类似: + +```text +~/.config/opencode/plugins/ +├── openviking.mjs +└── openviking/ + ├── index.mjs + ├── package.json + ├── lib/ + └── node_modules/ +``` + +顶层 `openviking.mjs` 只负责把 OpenCode 能发现的一级 `.mjs` 入口转发到插件目录: + +```js +export { OpenVikingPlugin, default } from "./openviking/index.mjs" +``` + +这个 wrapper 只用于上面这种源码安装目录结构。npm 包安装会通过 `package.json` 直接加载 `index.mjs`。 + +如果你使用 npm 包方式安装,也可以将 `examples/opencode-plugin` 作为一个普通 OpenCode 插件包使用。 + +## 配置 + +创建用户级配置文件: + +```bash +~/.config/opencode/openviking-config.json +``` + +示例配置: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "agentId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +推荐通过环境变量提供 API Key,而不是写入配置文件: + +```bash +export OPENVIKING_API_KEY="your-api-key-here" +``` + +`apiKey` 会作为 `X-API-Key` 发送。`account`、`user`、`agentId` 会分别作为 +`X-OpenViking-Account`、`X-OpenViking-User`、`X-OpenViking-Agent` 发送。 +如果 OpenViking 服务启用了多租户认证,租户级 API 通常必须配置 `account` 和 `user`。 + +`OPENVIKING_API_KEY`、`OPENVIKING_ACCOUNT`、`OPENVIKING_USER`、`OPENVIKING_AGENT_ID` +优先级高于 `openviking-config.json` 里的同名配置。 + +高级场景可以用 `OPENVIKING_PLUGIN_CONFIG` 指向其他配置文件路径。 + +## 可用工具 + +插件会通过 OpenCode `tool` hook 暴露这些工具: + +- `memsearch`:语义检索 memories/resources/skills +- `memread`:读取具体 `viking://` URI +- `membrowse`:浏览 OpenViking 文件系统 +- `memcommit`:提交当前 session 并触发记忆提取 +- `memgrep`:精确文本或模式搜索,替代原 `ov grep` +- `memglob`:文件 glob 枚举,替代原 `ov glob` +- `memadd`:添加远端 URL 或本地文件资源,替代常见 `ov add-resource` 场景 +- `memremove`:删除资源,替代 `ov rm` +- `memqueue`:查看处理队列,替代 `ov observer queue` + +使用建议: + +- 概念性问题用 `memsearch` +- 精确符号、函数名、类名、报错字符串用 `memgrep` +- 枚举文件用 `memglob` +- 读取内容用 `memread` +- 探索目录结构用 `membrowse` +- 删除前必须先获得用户明确确认,再调用 `memremove` 且传入 `confirm: true` + +## `memadd` 本地文件 + +`memadd` 支持三类输入: + +- 远端 `http(s)` URL:直接调用 `/api/v1/resources` +- 本地文件路径:先调用 `/api/v1/resources/temp_upload`,再用返回的 `temp_file_id` 添加资源 +- `file://` URL:按本地文件处理 + +相对路径会按 OpenCode 当前项目目录解析。示例: + +```text +memadd path="https://example.com/spec.md" to="viking://resources/spec" +memadd path="./docs/notes.md" parent="viking://resources/" +memadd path="file:///home/alice/project/notes.md" reason="project notes" +``` + +当前仍不支持本地目录自动打 zip 上传;传入目录时会返回明确错误。 + +## 运行时文件 + +插件默认会把运行时文件写入: + +```bash +~/.config/opencode/openviking/ +``` + +可能包含: + +- `openviking-memory.log` +- `openviking-session-map.json` + +可以通过配置里的 `runtime.dataDir` 修改这个目录。 + +这些是本地运行时文件,不建议提交到版本库。 diff --git a/examples/opencode-plugin/INSTALL.md b/examples/opencode-plugin/INSTALL.md new file mode 100644 index 000000000..54d3c522f --- /dev/null +++ b/examples/opencode-plugin/INSTALL.md @@ -0,0 +1,183 @@ +# Install the Unified OpenViking OpenCode Plugin + +This plugin adds one unified OpenViking plugin for OpenCode: + +- Semantic retrieval for external repositories +- Long-term memory, session synchronization, lifecycle commit, and automatic recall + +The older split examples remain available for now and will be deprecated in a future update. This plugin does not install `skills/openviking/SKILL.md`, and it does not require the agent to use the `ov` command. The capabilities from the former skill are exposed as OpenCode tools here. + +## Prerequisites + +Prepare the following first: + +- OpenCode +- OpenViking HTTP Server +- Node.js / npm, used to install plugin dependencies +- A valid OpenViking API key if authentication is enabled on the server + +Start OpenViking first: + +```bash +openviking-server --config ~/.openviking/ov.conf +``` + +Check the service: + +```bash +curl http://localhost:1933/health +``` + +## Installation Method 1: Published Package + +Normal users are recommended to enable it through OpenCode's package plugin mechanism: + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +Use the final published package name if it changes before release. + +## Installation Method 2: Source Install + +Use this method for development, debugging, or PR testing. OpenCode's recommended plugin directory is: + +```bash +~/.config/opencode/plugins +``` + +Run the following commands from the repository root: + +```bash +mkdir -p ~/.config/opencode/plugins/openviking +cp examples/opencode-plugin/wrappers/openviking.mjs ~/.config/opencode/plugins/openviking.mjs +cp examples/opencode-plugin/index.mjs examples/opencode-plugin/package.json ~/.config/opencode/plugins/openviking/ +cp -r examples/opencode-plugin/lib ~/.config/opencode/plugins/openviking/ +cd ~/.config/opencode/plugins/openviking +npm install +``` + +After installation, the layout should look like this: + +```text +~/.config/opencode/plugins/ +├── openviking.mjs +└── openviking/ + ├── index.mjs + ├── package.json + ├── lib/ + └── node_modules/ +``` + +The top-level `openviking.mjs` forwards the first-level `.mjs` entry that OpenCode can discover to the actual plugin directory: + +```js +export { OpenVikingPlugin, default } from "./openviking/index.mjs" +``` + +This wrapper is only for source installs with the directory layout shown above. npm package installs load `index.mjs` directly through `package.json`. + +If you install through an npm package, you can also use `examples/opencode-plugin` as a normal OpenCode plugin package. + +## Configuration + +Create the user-level configuration file: + +```bash +~/.config/opencode/openviking-config.json +``` + +Example configuration: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "agentId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +It is recommended to provide the API key through an environment variable instead of writing it into the configuration file: + +```bash +export OPENVIKING_API_KEY="your-api-key-here" +``` + +`apiKey` is sent as `X-API-Key`. `account`, `user`, and `agentId` are sent as `X-OpenViking-Account`, `X-OpenViking-User`, and `X-OpenViking-Agent`, respectively. If multi-tenant authentication is enabled on the OpenViking server, tenant-scoped APIs usually require `account` and `user` to be configured. + +`OPENVIKING_API_KEY`, `OPENVIKING_ACCOUNT`, `OPENVIKING_USER`, and `OPENVIKING_AGENT_ID` take precedence over the corresponding values in `openviking-config.json`. + +For advanced setups, use `OPENVIKING_PLUGIN_CONFIG` to point to another configuration file path. + +## Available Tools + +The plugin exposes the following tools through the OpenCode `tool` hook: + +- `memsearch`: semantic retrieval across memories, resources, and skills +- `memread`: read a specific `viking://` URI +- `membrowse`: browse the OpenViking filesystem +- `memcommit`: commit the current session and trigger memory extraction +- `memgrep`: exact text or pattern search, replacing the former `ov grep` use case +- `memglob`: file glob enumeration, replacing the former `ov glob` use case +- `memadd`: add a remote URL or local file resource, replacing common `ov add-resource` scenarios +- `memremove`: remove resources, replacing `ov rm` +- `memqueue`: inspect the processing queue, replacing `ov observer queue` + +Usage guidance: + +- Use `memsearch` for conceptual questions. +- Use `memgrep` for exact symbols, function names, class names, or error strings. +- Use `memglob` to enumerate files. +- Use `memread` to read content. +- Use `membrowse` to explore directory structure. +- Before deleting anything, obtain explicit user confirmation first; then call `memremove` with `confirm: true`. + +## Local Files with `memadd` + +`memadd` supports three input types: + +- Remote `http(s)` URL: directly calls `/api/v1/resources` +- Local file path: first calls `/api/v1/resources/temp_upload`, then adds the resource using the returned `temp_file_id` +- `file://` URL: handled as a local file + +Relative paths are resolved against the current OpenCode project directory. Examples: + +```text +memadd path="https://example.com/spec.md" to="viking://resources/spec" +memadd path="./docs/notes.md" parent="viking://resources/" +memadd path="file:///home/alice/project/notes.md" reason="project notes" +``` + +Automatic zip upload for local directories is not supported yet. Passing a directory will return a clear error. + +## Runtime Files + +By default, the plugin writes runtime files to: + +```bash +~/.config/opencode/openviking/ +``` + +Possible files include: + +- `openviking-memory.log` +- `openviking-session-map.json` + +You can change this directory with `runtime.dataDir` in the configuration. + +These are local runtime files and should not be committed to the repository. diff --git a/examples/opencode-plugin/README.md b/examples/opencode-plugin/README.md new file mode 100644 index 000000000..6cbe6d6e9 --- /dev/null +++ b/examples/opencode-plugin/README.md @@ -0,0 +1,214 @@ +# OpenViking OpenCode Plugin + +A unified OpenCode plugin for OpenViking repository retrieval and long-term memory. + +This PR adds a unified plugin package alongside the older split examples. The older examples remain available for now and will be deprecated in a future update: + +- `examples/opencode`: indexed repository prompt injection and CLI-oriented guidance +- `examples/opencode-memory-plugin`: long-term memory, session sync, commit, and recall + +The new plugin exposes everything through OpenCode tool hooks and talks to OpenViking through HTTP APIs. It does not install or require an OpenCode skill, and agents do not need to run `ov` shell commands. + +## What It Does + +- Injects indexed `viking://resources/` repositories into the system prompt. +- Exposes repository search, grep, glob, read, browse, add, remove, and queue status as tools. +- Maps each OpenCode session to an OpenViking session. +- Captures user and assistant text messages into OpenViking. +- Commits sessions at lifecycle boundaries for memory extraction. +- Automatically recalls relevant memories and appends them to the latest user message. + +## Files + +```text +examples/opencode-plugin/ +├── index.mjs +├── package.json +├── README.md +├── INSTALL-ZH.md +├── lib/ +│ ├── runtime.mjs +│ ├── repo-context.mjs +│ ├── memory-session.mjs +│ ├── memadd-local.mjs +│ ├── memory-tools.mjs +│ ├── memory-recall.mjs +│ └── utils.mjs +└── wrappers/ + └── openviking.mjs +``` + +There is intentionally no `skills/openviking/SKILL.md`. The former skill behavior is implemented as tools. + +## Requirements + +- OpenCode +- OpenViking HTTP server +- Node.js / npm for installing the plugin dependency +- An OpenViking API key if your server requires authentication + +Start OpenViking first: + +```bash +openviking-server --config ~/.openviking/ov.conf +``` + +## Installation + +### Published Package + +Normal users should enable it through OpenCode's package plugin mechanism: + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +Use the final published package name if it changes before release. + +### Source Install + +For development or PR testing, copy the package into OpenCode's plugin directory with a top-level wrapper: + +```bash +mkdir -p ~/.config/opencode/plugins/openviking +cp examples/opencode-plugin/wrappers/openviking.mjs ~/.config/opencode/plugins/openviking.mjs +cp examples/opencode-plugin/index.mjs examples/opencode-plugin/package.json ~/.config/opencode/plugins/openviking/ +cp -r examples/opencode-plugin/lib ~/.config/opencode/plugins/openviking/ +cd ~/.config/opencode/plugins/openviking +npm install +``` + +This creates a stable OpenCode plugin layout: + +```text +~/.config/opencode/plugins/ +├── openviking.mjs +└── openviking/ + ├── index.mjs + ├── package.json + ├── lib/ + └── node_modules/ +``` + +The top-level `openviking.mjs` is only a wrapper: + +```js +export { OpenVikingPlugin, default } from "./openviking/index.mjs" +``` + +This wrapper is only for source installs with the directory layout shown above. npm package installs load `index.mjs` directly through `package.json`. + +## Configuration + +Create `~/.config/opencode/openviking-config.json`: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "agentId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +`apiKey` is sent as `X-API-Key`. `account`, `user`, and `agentId` are sent as +`X-OpenViking-Account`, `X-OpenViking-User`, and `X-OpenViking-Agent`. +They are required by multi-tenant OpenViking servers for tenant-scoped APIs. + +`OPENVIKING_API_KEY`, `OPENVIKING_ACCOUNT`, `OPENVIKING_USER`, and +`OPENVIKING_AGENT_ID` take precedence over values in this file. + +For advanced setups, `OPENVIKING_PLUGIN_CONFIG` can point to another config file path. + +## Tools + +### `memsearch` + +Semantic search across memories, resources, and skills. + +Use for conceptual questions, repository internals, user preferences, and context-aware retrieval. Use `target_uri` to narrow scope, for example `viking://resources/fastapi/`. + +### `memread` + +Read a specific `viking://` URI using `abstract`, `overview`, `read`, or `auto`. + +Use after `memsearch`, `memgrep`, `memglob`, or `membrowse` returns a URI. + +### `membrowse` + +Browse OpenViking filesystem structure with `list`, `tree`, or `stat`. + +Use to discover exact URIs before reading content. + +### `memcommit` + +Commit the current OpenCode session to OpenViking and trigger memory extraction. + +The plugin also commits at session deletion, session error, compaction, and plugin shutdown boundaries. + +### `memgrep` + +Pattern search through OpenViking content. + +Use for exact symbols, class names, function names, error strings, or known keywords. + +### `memglob` + +Glob file matching through OpenViking content. + +Use to enumerate files such as `**/*.py`, `**/test_*.ts`, or `**/*.md`. + +### `memadd` + +Add a remote URL or local file resource to OpenViking. + +Remote `http(s)` URLs go directly through `POST /api/v1/resources`. +Local files use the safer two-step server flow: upload the file to +`POST /api/v1/resources/temp_upload`, then add it through +`POST /api/v1/resources` with the returned `temp_file_id`. + +Local paths may be absolute, relative to the OpenCode project directory, or +`file://` URLs. Local directory upload is not supported yet. + +Examples: + +```text +memadd path="https://example.com/spec.md" to="viking://resources/spec" +memadd path="./docs/notes.md" parent="viking://resources/" +memadd path="file:///home/alice/project/notes.md" reason="project notes" +``` + +After adding a resource, the tool also returns `GET /api/v1/observer/queue` status. + +### `memremove` + +Remove a `viking://` URI through `DELETE /api/v1/fs`. + +This tool requires `confirm: true`. The user must explicitly confirm deletion before the agent calls it. + +### `memqueue` + +Return OpenViking observer queue status for embedding and semantic processing. + +## Runtime Files + +The plugin writes runtime files to `~/.config/opencode/openviking/` by default: + +- `openviking-memory.log` +- `openviking-session-map.json` + +Set `runtime.dataDir` in config to override this directory. diff --git a/examples/opencode-plugin/index.mjs b/examples/opencode-plugin/index.mjs new file mode 100644 index 000000000..11de5d13d --- /dev/null +++ b/examples/opencode-plugin/index.mjs @@ -0,0 +1,64 @@ +import { dirname } from "path" +import { fileURLToPath } from "url" +import { initializeRuntime } from "./lib/runtime.mjs" +import { createRepoContext } from "./lib/repo-context.mjs" +import { createMemorySessionManager } from "./lib/memory-session.mjs" +import { createMemoryTools } from "./lib/memory-tools.mjs" +import { createMemoryRecall } from "./lib/memory-recall.mjs" +import { initLogger, loadConfig, log, resolveDataDir } from "./lib/utils.mjs" + +const pluginRoot = dirname(fileURLToPath(import.meta.url)) + +/** + * @type {import('@opencode-ai/plugin').Plugin} + */ +export async function OpenVikingPlugin({ client, directory }) { + const config = loadConfig(pluginRoot, directory) + const dataDir = resolveDataDir(pluginRoot, config) + initLogger(dataDir) + + if (!config.enabled) { + log("INFO", "plugin", "OpenViking plugin is disabled in configuration") + return {} + } + + const repoContext = createRepoContext({ config }) + const sessionManager = createMemorySessionManager({ config, pluginRoot: dataDir }) + const recall = createMemoryRecall({ config }) + const tools = createMemoryTools({ config, sessionManager, projectDirectory: directory }) + + await sessionManager.init() + + Promise.resolve().then(async () => { + const ready = await initializeRuntime(config, client) + if (ready) await repoContext.refreshRepos({ force: true }) + }) + + return { + event: async ({ event }) => { + await sessionManager.handleEvent(event) + if (event?.type === "session.created") { + await repoContext.refreshRepos({ force: true }) + } + }, + + tool: tools, + + "experimental.chat.system.transform": (_input, output) => { + const prompt = repoContext.getRepoSystemPrompt() + if (prompt) output.system.push(prompt) + }, + + "experimental.chat.messages.transform": async (_input, output) => { + await recall.injectRelevantMemories(output) + }, + + + stop: async () => { + await sessionManager.flushAll({ commit: true }) + log("INFO", "plugin", "OpenViking plugin stopped") + }, + } +} + +export default OpenVikingPlugin diff --git a/examples/opencode-plugin/lib/memadd-local.mjs b/examples/opencode-plugin/lib/memadd-local.mjs new file mode 100644 index 000000000..e84390adf --- /dev/null +++ b/examples/opencode-plugin/lib/memadd-local.mjs @@ -0,0 +1,114 @@ +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" +import { + ensureRemoteUrl, + makeMultipartRequest, + makeRequest, + unwrapResponse, +} from "./utils.mjs" + +export const MEMADD_LOCAL_FILE_ONLY_ERROR = "Error: memadd local upload currently supports files only." + +const ADD_RESOURCE_KEYS = [ + "to", + "parent", + "reason", + "instruction", + "wait", + "timeout", + "watch_interval", +] + +export function resolveMemaddSource(inputPath, projectDirectory = process.cwd()) { + if (ensureRemoteUrl(inputPath)) { + return { kind: "remote", path: inputPath } + } + + let filePath + try { + filePath = resolveLocalPath(inputPath, projectDirectory) + } catch (error) { + return { kind: "error", error: `Error: ${error.message}` } + } + + let stat + try { + stat = fs.statSync(filePath) + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") { + return { kind: "error", error: `Error: Local file not found: ${filePath}` } + } + return { kind: "error", error: `Error: Unable to access local file: ${filePath}: ${error.message}` } + } + + if (!stat.isFile()) { + return { kind: "error", error: MEMADD_LOCAL_FILE_ONLY_ERROR } + } + + return { kind: "local", path: filePath, filename: path.basename(filePath) } +} + +export function resolveLocalPath(inputPath, projectDirectory = process.cwd()) { + if (typeof inputPath !== "string" || inputPath.trim() === "") { + throw new Error("memadd path is required.") + } + + let localPath = inputPath + if (isFileUrl(inputPath)) { + localPath = fileURLToPath(inputPath) + } + + if (path.isAbsolute(localPath)) return path.normalize(localPath) + return path.resolve(projectDirectory || process.cwd(), localPath) +} + +export function buildAddResourceBody(args, source, tempFileId) { + const body = source.kind === "remote" ? { path: source.path } : { temp_file_id: tempFileId } + for (const key of ADD_RESOURCE_KEYS) { + if (args[key] !== undefined) body[key] = args[key] + } + return body +} + +export async function uploadLocalResource(config, source, abortSignal) { + const bytes = await fs.promises.readFile(source.path) + const form = new FormData() + form.append("file", new Blob([bytes], { type: "application/octet-stream" }), source.filename) + + const uploadResponse = await makeMultipartRequest(config, { + method: "POST", + endpoint: "/api/v1/resources/temp_upload", + body: form, + abortSignal, + }) + const tempFileId = unwrapResponse(uploadResponse)?.temp_file_id + if (!tempFileId) { + throw new Error("OpenViking temp upload did not return temp_file_id") + } + return tempFileId +} + +export async function addMemaddResource(config, args, projectDirectory, abortSignal) { + const source = resolveMemaddSource(args.path, projectDirectory) + if (source.kind === "error") return { error: source.error } + + const tempFileId = source.kind === "local" ? await uploadLocalResource(config, source, abortSignal) : undefined + const body = buildAddResourceBody(args, source, tempFileId) + const addResponse = await makeRequest(config, { + method: "POST", + endpoint: "/api/v1/resources", + body, + abortSignal, + timeoutMs: args.wait ? Math.max(config.timeoutMs, (args.timeout ?? 300) * 1000) : config.timeoutMs, + }) + return { addResponse, source } +} + +function isFileUrl(value) { + try { + return new URL(value).protocol === "file:" + } catch { + return false + } +} diff --git a/examples/opencode-plugin/lib/memory-recall.mjs b/examples/opencode-plugin/lib/memory-recall.mjs new file mode 100644 index 000000000..75ce8d38a --- /dev/null +++ b/examples/opencode-plugin/lib/memory-recall.mjs @@ -0,0 +1,218 @@ +import { log, makeRequest, unwrapResponse } from "./utils.mjs" + +const AUTO_RECALL_TIMEOUT_MS = 5000 +const RECALL_STOPWORDS = new Set([ + "what", "when", "where", "which", "who", "whom", "whose", "why", "how", + "did", "does", "is", "are", "was", "were", "the", "and", "for", "with", + "from", "that", "this", "your", "you", +]) +const RECALL_TOKEN_RE = /[a-z0-9]{2,}/gi +const PREFERENCE_QUERY_RE = /prefer|preference|favorite|favourite|like|偏好|喜欢|爱好|更倾向/i +const TEMPORAL_QUERY_RE = /when|what time|date|day|month|year|yesterday|today|tomorrow|last|next|什么时候|何时|哪天|几月|几年|昨天|今天|明天|上周|下周|上个月|下个月|去年|明年/i + +export function createMemoryRecall({ config }) { + async function injectRelevantMemories(output) { + try { + if (!config.autoRecall?.enabled) return + const query = extractLatestUserText(output.messages ?? []) + if (!query) return + + const rawResults = await performRecallSearch(query) + if (rawResults.length === 0) return + + const ranked = pickMemoriesForInjection( + rawResults, + config.autoRecall.limit, + query, + config.autoRecall.scoreThreshold, + ) + if (ranked.length === 0) return + + const processed = postProcessMemories( + ranked, + config.autoRecall.maxContentChars, + config.autoRecall.preferAbstract, + ) + const block = formatMemoryBlock(processed, config.autoRecall.tokenBudget) + if (!block) return + + const lastUser = [...output.messages].reverse().find((message) => message.info?.role === "user") + if (lastUser && appendToLastTextPart(lastUser, block)) { + log("INFO", "recall", `Injected ${processed.length} memories`) + } + } catch (error) { + log("WARN", "recall", "Auto recall failed, skipping silently", { error: error?.message ?? String(error) }) + } + } + + async function performRecallSearch(query) { + try { + const response = await makeRequest(config, { + method: "POST", + endpoint: "/api/v1/search/find", + body: { query: query.slice(0, 4000), limit: 20, mode: "auto" }, + timeoutMs: AUTO_RECALL_TIMEOUT_MS, + }) + const result = unwrapResponse(response) + return result?.memories ?? result?.results ?? [] + } catch { + return [] + } + } + + return { injectRelevantMemories } +} + +function extractLatestUserText(messages) { + for (let i = messages.length - 1; i >= 0; i -= 1) { + const message = messages[i] + if (message.info?.role !== "user") continue + const texts = [] + for (const part of message.parts ?? []) { + if (part.type === "text" && typeof part.text === "string") texts.push(part.text) + } + const joined = texts.join(" ").trim() + if (!joined) continue + if (joined.includes("")) return null + return joined + } + return null +} + +function buildRecallQueryProfile(query) { + const text = query.trim() + const allTokens = text.toLowerCase().match(RECALL_TOKEN_RE) ?? [] + return { + tokens: allTokens.filter((token) => !RECALL_STOPWORDS.has(token)), + wantsPreference: PREFERENCE_QUERY_RE.test(text), + wantsTemporal: TEMPORAL_QUERY_RE.test(text), + } +} + +function recallClampScore(value) { + if (typeof value !== "number" || Number.isNaN(value)) return 0 + return Math.max(0, Math.min(1, value)) +} + +function lexicalOverlapBoost(tokens, text) { + if (tokens.length === 0 || !text) return 0 + const haystack = ` ${text.toLowerCase()} ` + let matched = 0 + for (const token of tokens.slice(0, 8)) { + if (haystack.includes(` ${token} `) || haystack.includes(token)) matched += 1 + } + return Math.min(0.2, (matched / Math.min(tokens.length, 4)) * 0.2) +} + +function isEventMemory(item) { + const category = (item.category ?? "").toLowerCase() + return category === "events" || item.uri?.includes("/events/") +} + +function isPreferencesMemory(item) { + return item.category === "preferences" || item.uri?.includes("/preferences/") || item.uri?.endsWith("/preferences") +} + +function isLeafLikeMemory(item) { + return item.level === 2 || item.is_leaf === true +} + +function rankForInjection(item, query) { + const baseScore = recallClampScore(item.score) + const abstract = (item.abstract ?? item.overview ?? "").trim() + const leafBoost = isLeafLikeMemory(item) ? 0.12 : 0 + const eventBoost = query.wantsTemporal && isEventMemory(item) ? 0.1 : 0 + const preferenceBoost = query.wantsPreference && isPreferencesMemory(item) ? 0.08 : 0 + const overlapBoost = lexicalOverlapBoost(query.tokens, `${item.uri} ${abstract}`) + return baseScore + leafBoost + eventBoost + preferenceBoost + overlapBoost +} + +function normalizeDedupeText(text) { + return text.toLowerCase().replace(/\s+/g, " ").trim() +} + +function isEventOrCaseMemory(item) { + const category = (item.category ?? "").toLowerCase() + const uri = (item.uri ?? "").toLowerCase() + return category === "events" || category === "cases" || uri.includes("/events/") || uri.includes("/cases/") +} + +function getMemoryDedupeKey(item) { + const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? "") + const category = (item.category ?? "").toLowerCase() || "unknown" + if (abstract && !isEventOrCaseMemory(item)) return `abstract:${category}:${abstract}` + return `uri:${item.uri}` +} + +function pickMemoriesForInjection(items, limit, queryText, scoreThreshold = 0) { + const query = buildRecallQueryProfile(queryText) + const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query)) + const deduped = [] + const seen = new Set() + + for (const item of sorted) { + const key = getMemoryDedupeKey(item) + if (seen.has(key)) continue + seen.add(key) + deduped.push(item) + } + + const leaves = deduped.filter((item) => isLeafLikeMemory(item)) + if (leaves.length >= limit) return leaves.slice(0, limit) + + const picked = [...leaves] + const used = new Set(leaves.map((item) => item.uri)) + for (const item of deduped) { + if (picked.length >= limit) break + if (used.has(item.uri)) continue + if (recallClampScore(item.score) < scoreThreshold) continue + picked.push(item) + } + return picked +} + +function postProcessMemories(items, maxContentChars, preferAbstract) { + return items.map((item) => { + const abstract = (item.abstract ?? "").trim() + const content = (item.content ?? "").trim() + let displayContent = "" + if (preferAbstract && abstract) displayContent = abstract + else if (content) displayContent = content + else if (abstract) displayContent = abstract + if (displayContent.length > maxContentChars) displayContent = `${displayContent.slice(0, maxContentChars)}...` + return { ...item, content: displayContent, abstract: abstract || undefined } + }) +} + +function formatMemoryBlock(items, tokenBudget) { + if (items.length === 0) return "" + const maxBlockChars = tokenBudget * 4 + let usedChars = 0 + const lines = [""] + + for (const item of items) { + const title = item.title ? `${item.title}\n` : "" + const content = item.content ?? "" + const entry = `\n${title}${content}\n` + if (usedChars + entry.length + 1 > maxBlockChars) break + lines.push(entry) + usedChars += entry.length + 1 + } + + if (usedChars === 0) return "" + lines.push("") + lines.push('Use `memread` with a memory URI and level="overview" or level="read" for more details.') + return lines.join("\n") +} + +function appendToLastTextPart(message, injection) { + for (let i = (message.parts ?? []).length - 1; i >= 0; i -= 1) { + const part = message.parts[i] + if (part.type === "text" && typeof part.text === "string") { + part.text = `${part.text}\n\n${injection}` + return true + } + } + return false +} + diff --git a/examples/opencode-plugin/lib/memory-session.mjs b/examples/opencode-plugin/lib/memory-session.mjs new file mode 100644 index 000000000..80aaa1feb --- /dev/null +++ b/examples/opencode-plugin/lib/memory-session.mjs @@ -0,0 +1,625 @@ +import fs from "fs" +import path from "path" +import { + log, + makeRequest, + safeStringify, + unwrapResponse, +} from "./utils.mjs" + +const MAX_BUFFERED_MESSAGES_PER_SESSION = 100 +const BUFFERED_MESSAGE_TTL_MS = 15 * 60 * 1000 +const BUFFER_CLEANUP_INTERVAL_MS = 30 * 1000 +const COMMIT_WAIT_TIMEOUT_MS = 180000 + +export function createMemorySessionManager({ config, pluginRoot }) { + const sessionMap = new Map() + const sessionMessageBuffer = new Map() + const commitWatchers = new Map() + let sessionMapPath = path.join(pluginRoot, "openviking-session-map.json") + let saveTimer = null + let lastBufferCleanupAt = 0 + + async function init() { + await loadSessionMap() + resumeBackgroundCommits() + } + + async function loadSessionMap() { + try { + if (!fs.existsSync(sessionMapPath)) { + log("INFO", "persistence", "No session map file found, starting fresh") + return + } + const data = JSON.parse(await fs.promises.readFile(sessionMapPath, "utf8")) + if (data.version !== 1) { + log("ERROR", "persistence", "Unsupported session map version", { version: data.version }) + return + } + for (const [opencodeSessionId, persisted] of Object.entries(data.sessions ?? {})) { + sessionMap.set(opencodeSessionId, deserializeSessionMapping(persisted)) + } + log("INFO", "persistence", "Session map loaded", { count: sessionMap.size }) + } catch (error) { + log("ERROR", "persistence", "Failed to load session map", { error: error?.message }) + if (fs.existsSync(sessionMapPath)) { + await fs.promises.rename(sessionMapPath, `${sessionMapPath}.corrupted.${Date.now()}`) + } + } + } + + async function saveSessionMap() { + try { + const sessions = {} + for (const [opencodeSessionId, mapping] of sessionMap.entries()) { + sessions[opencodeSessionId] = serializeSessionMapping(mapping) + } + const tempPath = `${sessionMapPath}.tmp` + await fs.promises.writeFile(tempPath, JSON.stringify({ version: 1, sessions, lastSaved: Date.now() }, null, 2), "utf8") + await fs.promises.rename(tempPath, sessionMapPath) + log("DEBUG", "persistence", "Session map saved", { count: sessionMap.size }) + } catch (error) { + log("ERROR", "persistence", "Failed to save session map", { error: error?.message }) + } + } + + function debouncedSaveSessionMap() { + if (saveTimer) clearTimeout(saveTimer) + saveTimer = setTimeout(() => { + saveSessionMap().catch((error) => { + log("ERROR", "persistence", "Debounced save failed", { error: error?.message }) + }) + }, 300) + } + + function serializeSessionMapping(mapping) { + return { + ovSessionId: mapping.ovSessionId, + createdAt: mapping.createdAt, + capturedMessages: Array.from(mapping.capturedMessages), + messageRoles: Array.from(mapping.messageRoles.entries()), + pendingMessages: Array.from(mapping.pendingMessages.entries()), + lastCommitTime: mapping.lastCommitTime, + commitInFlight: mapping.commitInFlight, + commitTaskId: mapping.commitTaskId, + commitStartedAt: mapping.commitStartedAt, + pendingCleanup: mapping.pendingCleanup, + } + } + + function deserializeSessionMapping(persisted) { + return { + ovSessionId: persisted.ovSessionId, + createdAt: persisted.createdAt, + capturedMessages: new Set(persisted.capturedMessages ?? []), + messageRoles: new Map(persisted.messageRoles ?? []), + pendingMessages: new Map(persisted.pendingMessages ?? []), + sendingMessages: new Set(), + lastCommitTime: persisted.lastCommitTime, + commitInFlight: persisted.commitInFlight, + commitTaskId: persisted.commitTaskId, + commitStartedAt: persisted.commitStartedAt, + pendingCleanup: persisted.pendingCleanup, + } + } + + function getMappedSessionId(opencodeSessionId) { + return sessionMap.get(opencodeSessionId)?.ovSessionId + } + + async function handleEvent(event) { + if (!event?.type || event.type === "session.diff") return + + if (event.type === "session.created") { + await handleSessionCreated(event) + } else if (event.type === "session.deleted") { + await handleSessionDeleted(event) + } else if (event.type === "session.error") { + await handleSessionError(event) + } else if (event.type === "session.compacted") { + await handleSessionCompacted(event) + } else if (event.type === "message.updated") { + await handleMessageUpdated(event) + } else if (event.type === "message.part.updated") { + await handleMessagePartUpdated(event) + } + } + + async function handleSessionCreated(event) { + const sessionId = resolveEventSessionId(event) + if (!sessionId) { + log("ERROR", "event", "session.created event missing sessionId", { event: safeStringify(event) }) + return + } + + const ovSessionId = await ensureOpenVikingSession(sessionId) + if (!ovSessionId) return + + const existing = sessionMap.get(sessionId) + const mapping = existing ?? createSessionMapping(ovSessionId) + mapping.ovSessionId = ovSessionId + sessionMap.set(sessionId, mapping) + + const bufferedMessages = sessionMessageBuffer.get(sessionId) + if (bufferedMessages?.length) { + for (const buffered of bufferedMessages) { + if (buffered.role) mapping.messageRoles.set(buffered.messageId, buffered.role) + if (buffered.content) { + mapping.pendingMessages.set( + buffered.messageId, + mergeMessageContent(mapping.pendingMessages.get(buffered.messageId), buffered.content), + ) + } + } + sessionMessageBuffer.delete(sessionId) + await flushPendingMessages(sessionId, mapping) + } + + debouncedSaveSessionMap() + log("INFO", "event", "Session mapping established", { + opencode_session: sessionId, + openviking_session: ovSessionId, + }) + } + + async function handleSessionDeleted(event) { + const sessionId = resolveEventSessionId(event) + if (!sessionId) return + + const mapping = sessionMap.get(sessionId) + if (!mapping) { + sessionMessageBuffer.delete(sessionId) + return + } + + await flushPendingMessages(sessionId, mapping) + if (mapping.capturedMessages.size > 0 || mapping.commitInFlight) { + mapping.pendingCleanup = true + if (!mapping.commitInFlight) await startBackgroundCommit(mapping, sessionId) + } else { + sessionMap.delete(sessionId) + sessionMessageBuffer.delete(sessionId) + await saveSessionMap() + } + } + + async function handleSessionError(event) { + const sessionId = resolveEventSessionId(event) + if (!sessionId) return + log("ERROR", "event", "OpenCode session error", { session_id: sessionId, error: safeStringify(event.error) }) + await handleSessionDeleted(event) + } + + async function handleSessionCompacted(event) { + await commitSessionBoundary(event, "session.compacted") + } + + async function commitSessionBoundary(event, reason) { + const sessionId = resolveEventSessionId(event) + if (!sessionId) return + + const mapping = sessionMap.get(sessionId) + if (!mapping) return + + await flushPendingMessages(sessionId, mapping) + if (mapping.commitInFlight) { + monitorBackgroundCommit(mapping, sessionId) + return + } + if (mapping.capturedMessages.size > 0) { + log("INFO", "session", "Committing OpenViking session at lifecycle boundary", { + opencode_session: sessionId, + openviking_session: mapping.ovSessionId, + reason, + }) + await startBackgroundCommit(mapping, sessionId) + } + } + + async function handleMessageUpdated(event) { + const message = event.properties?.info + if (!message) return + + const sessionId = message.sessionID + const messageId = message.id + const role = message.role + const finish = message.finish + if (!sessionId || !messageId) return + + const mapping = sessionMap.get(sessionId) + if (!mapping) { + upsertBufferedMessage(sessionId, messageId, role ? { role } : {}) + return + } + + if (role === "user") { + mapping.messageRoles.set(messageId, role) + } else if (role === "assistant" && finish === "stop") { + mapping.messageRoles.set(messageId, role) + } + + await flushPendingMessages(sessionId, mapping) + } + + async function handleMessagePartUpdated(event) { + const part = event.properties?.part + if (!part) return + + const sessionId = part.sessionID + const messageId = part.messageID + if (!sessionId || !messageId || part.type !== "text" || !part.text?.trim()) return + + const mapping = sessionMap.get(sessionId) + if (!mapping) { + upsertBufferedMessage(sessionId, messageId, { content: part.text }) + return + } + + if (mapping.capturedMessages.has(messageId)) return + mapping.pendingMessages.set(messageId, mergeMessageContent(mapping.pendingMessages.get(messageId), part.text)) + } + + async function ensureOpenVikingSession(opencodeSessionId) { + const knownSessionId = sessionMap.get(opencodeSessionId)?.ovSessionId + if (knownSessionId) { + try { + const response = await makeRequest(config, { + method: "GET", + endpoint: `/api/v1/sessions/${encodeURIComponent(knownSessionId)}`, + timeoutMs: 5000, + }) + if (unwrapResponse(response)) return knownSessionId + } catch (error) { + log("INFO", "session", "Persisted OpenViking session unavailable, creating a new one", { + opencode_session: opencodeSessionId, + openviking_session: knownSessionId, + error: error?.message, + }) + } + } + + try { + const response = await makeRequest(config, { + method: "POST", + endpoint: "/api/v1/sessions", + body: {}, + timeoutMs: 5000, + }) + const sessionId = unwrapResponse(response)?.session_id + if (!sessionId) throw new Error("OpenViking did not return a session_id") + return sessionId + } catch (error) { + log("ERROR", "session", "Failed to create OpenViking session", { + opencode_session: opencodeSessionId, + error: error?.message, + }) + return null + } + } + + async function flushPendingMessages(opencodeSessionId, mapping) { + if (mapping.commitInFlight) return + + for (const messageId of Array.from(mapping.pendingMessages.keys())) { + if (mapping.capturedMessages.has(messageId) || mapping.sendingMessages.has(messageId)) continue + const role = mapping.messageRoles.get(messageId) + const content = mapping.pendingMessages.get(messageId) + if (!role || !content?.trim()) continue + + mapping.sendingMessages.add(messageId) + try { + const success = await addMessageToSession(mapping.ovSessionId, role, content) + if (success) { + const latest = mapping.pendingMessages.get(messageId) + if (latest && latest !== content) { + continue + } + mapping.pendingMessages.delete(messageId) + mapping.capturedMessages.add(messageId) + debouncedSaveSessionMap() + } + } finally { + mapping.sendingMessages.delete(messageId) + } + } + } + + async function addMessageToSession(ovSessionId, role, content) { + try { + const response = await makeRequest(config, { + method: "POST", + endpoint: `/api/v1/sessions/${encodeURIComponent(ovSessionId)}/messages`, + body: { role, content }, + timeoutMs: 5000, + }) + unwrapResponse(response) + return true + } catch (error) { + log("ERROR", "message", "Failed to add message to OpenViking session", { + openviking_session: ovSessionId, + role, + error: error?.message, + }) + return false + } + } + + async function startBackgroundCommit(mapping, opencodeSessionId, abortSignal) { + if (mapping.commitInFlight && mapping.commitTaskId) { + if (!abortSignal) monitorBackgroundCommit(mapping, opencodeSessionId) + return { mode: "background", taskId: mapping.commitTaskId } + } + + try { + const response = await makeRequest(config, { + method: "POST", + endpoint: `/api/v1/sessions/${encodeURIComponent(mapping.ovSessionId)}/commit`, + timeoutMs: 10000, + abortSignal, + }) + const result = unwrapResponse(response) + const taskId = result?.task_id + + if (!taskId) { + await finalizeCommitSuccess(mapping, opencodeSessionId) + return { mode: "completed", result } + } + + mapping.commitInFlight = true + mapping.commitTaskId = taskId + mapping.commitStartedAt = Date.now() + debouncedSaveSessionMap() + if (!abortSignal) monitorBackgroundCommit(mapping, opencodeSessionId) + return { mode: "background", taskId } + } catch (error) { + if (error?.message?.includes("already has a commit in progress")) { + const taskId = await findRunningCommitTaskId(mapping.ovSessionId) + if (taskId) { + mapping.commitInFlight = true + mapping.commitTaskId = taskId + mapping.commitStartedAt = mapping.commitStartedAt ?? Date.now() + debouncedSaveSessionMap() + if (!abortSignal) monitorBackgroundCommit(mapping, opencodeSessionId) + return { mode: "background", taskId } + } + } + log("ERROR", "session", "Failed to start OpenViking commit", { + openviking_session: mapping.ovSessionId, + opencode_session: opencodeSessionId, + error: error?.message, + }) + return null + } + } + + async function waitForCommitCompletion(mapping, opencodeSessionId, abortSignal, timeoutMs = COMMIT_WAIT_TIMEOUT_MS) { + const startedAt = Date.now() + while (Date.now() - startedAt < timeoutMs) { + if (abortSignal?.aborted) throw new Error("Operation aborted") + if (!mapping.commitInFlight) return null + if (!mapping.commitTaskId) { + mapping.commitTaskId = await findRunningCommitTaskId(mapping.ovSessionId) + if (!mapping.commitTaskId) { + clearCommitState(mapping) + debouncedSaveSessionMap() + return null + } + } + + const task = await getTask(mapping.commitTaskId, abortSignal) + if (task.status === "completed") { + await finalizeCommitSuccess(mapping, opencodeSessionId) + return task + } + if (task.status === "failed") { + clearCommitState(mapping) + debouncedSaveSessionMap() + throw new Error(task.error || "Background commit failed") + } + await sleep(2000, abortSignal) + } + return null + } + + async function getTask(taskId, abortSignal) { + const response = await makeRequest(config, { + method: "GET", + endpoint: `/api/v1/tasks/${encodeURIComponent(taskId)}`, + timeoutMs: 5000, + abortSignal, + }) + return unwrapResponse(response) + } + + async function findRunningCommitTaskId(ovSessionId) { + try { + const response = await makeRequest(config, { + method: "GET", + endpoint: `/api/v1/tasks?task_type=session_commit&resource_id=${encodeURIComponent(ovSessionId)}&limit=10`, + timeoutMs: 5000, + }) + const tasks = unwrapResponse(response) ?? [] + return tasks.find((task) => task.status === "pending" || task.status === "running")?.task_id + } catch (error) { + log("WARN", "session", "Failed to query running commit tasks", { error: error?.message }) + return undefined + } + } + + async function finalizeCommitSuccess(mapping, opencodeSessionId) { + mapping.lastCommitTime = Date.now() + mapping.capturedMessages.clear() + clearCommitState(mapping) + debouncedSaveSessionMap() + + await flushPendingMessages(opencodeSessionId, mapping) + + if (mapping.pendingCleanup) { + sessionMap.delete(opencodeSessionId) + sessionMessageBuffer.delete(opencodeSessionId) + await saveSessionMap() + } + } + + function resumeBackgroundCommits() { + for (const [opencodeSessionId, mapping] of sessionMap.entries()) { + if (mapping.commitInFlight) monitorBackgroundCommit(mapping, opencodeSessionId) + } + } + + function monitorBackgroundCommit(mapping, opencodeSessionId) { + if (!mapping.commitTaskId) return + if (commitWatchers.has(mapping.commitTaskId)) return + + const taskId = mapping.commitTaskId + const watcher = waitForCommitCompletion(mapping, opencodeSessionId) + .then((task) => { + if (!task) { + log("WARN", "session", "Background commit is still pending after the wait timeout", { + task_id: taskId, + openviking_session: mapping.ovSessionId, + opencode_session: opencodeSessionId, + }) + } + }) + .catch((error) => { + log("ERROR", "session", "Background commit watcher failed", { + task_id: taskId, + openviking_session: mapping.ovSessionId, + opencode_session: opencodeSessionId, + error: error?.message, + }) + }) + .finally(() => { + commitWatchers.delete(taskId) + }) + commitWatchers.set(taskId, watcher) + } + + async function flushAll({ commit = false } = {}) { + if (saveTimer) { + clearTimeout(saveTimer) + saveTimer = null + } + for (const [sessionId, mapping] of sessionMap.entries()) { + await flushPendingMessages(sessionId, mapping) + if (commit) { + if (mapping.commitInFlight) { + monitorBackgroundCommit(mapping, sessionId) + } else if (mapping.capturedMessages.size > 0) { + await startBackgroundCommit(mapping, sessionId) + } + } + } + await saveSessionMap() + } + + async function commitSession(sessionId, opencodeSessionId, abortSignal) { + let mapping = opencodeSessionId ? sessionMap.get(opencodeSessionId) : undefined + if (!mapping || mapping.ovSessionId !== sessionId) { + mapping = createSessionMapping(sessionId) + } else { + await flushPendingMessages(opencodeSessionId, mapping) + } + + if (mapping.commitInFlight) { + const task = await waitForCommitCompletion(mapping, opencodeSessionId ?? sessionId, abortSignal) + if (task?.status === "completed") return { status: "completed", task } + } + + const start = await startBackgroundCommit(mapping, opencodeSessionId ?? sessionId, abortSignal) + if (!start) throw new Error("Failed to start OpenViking session commit") + if (start.mode === "completed") return { status: "completed", result: start.result } + + const task = await waitForCommitCompletion(mapping, opencodeSessionId ?? sessionId, abortSignal) + if (!task) return { status: "accepted", task_id: start.taskId } + return { status: task.status, task } + } + + return { + init, + handleEvent, + getMappedSessionId, + commitSession, + flushAll, + } + + function createSessionMapping(ovSessionId) { + return { + ovSessionId, + createdAt: Date.now(), + capturedMessages: new Set(), + messageRoles: new Map(), + pendingMessages: new Map(), + sendingMessages: new Set(), + lastCommitTime: undefined, + commitInFlight: false, + } + } + + function resolveEventSessionId(event) { + return event?.properties?.info?.id ?? event?.properties?.sessionID ?? event?.properties?.sessionId + } + + function mergeMessageContent(existing, incoming) { + const next = incoming?.trim() + if (!next) return existing ?? "" + if (!existing) return next + if (next === existing) return existing + if (next.startsWith(existing)) return next + if (existing.startsWith(next)) return existing + if (next.includes(existing)) return next + if (existing.includes(next)) return existing + return `${existing}\n${next}`.trim() + } + + function upsertBufferedMessage(sessionId, messageId, updates) { + const now = Date.now() + if (now - lastBufferCleanupAt >= BUFFER_CLEANUP_INTERVAL_MS) { + cleanupOrphanedMessageBuffers(now) + lastBufferCleanupAt = now + } + + const freshBuffer = (sessionMessageBuffer.get(sessionId) ?? []) + .filter((message) => now - message.timestamp <= BUFFERED_MESSAGE_TTL_MS) + let buffered = freshBuffer.find((message) => message.messageId === messageId) + if (!buffered) { + while (freshBuffer.length >= MAX_BUFFERED_MESSAGES_PER_SESSION) freshBuffer.shift() + buffered = { messageId, timestamp: now } + freshBuffer.push(buffered) + } else { + buffered.timestamp = now + } + if (updates.role) buffered.role = updates.role + if (updates.content) buffered.content = mergeMessageContent(buffered.content, updates.content) + sessionMessageBuffer.set(sessionId, freshBuffer) + } + + function cleanupOrphanedMessageBuffers(now) { + for (const [sessionId, buffer] of sessionMessageBuffer.entries()) { + if (sessionMap.has(sessionId)) continue + const oldest = buffer[0] + if (!oldest || now - oldest.timestamp > BUFFERED_MESSAGE_TTL_MS * 2) { + sessionMessageBuffer.delete(sessionId) + } + } + } + + function clearCommitState(mapping) { + mapping.commitInFlight = false + mapping.commitTaskId = undefined + mapping.commitStartedAt = undefined + } + + async function sleep(ms, abortSignal) { + await new Promise((resolve, reject) => { + const timer = setTimeout(resolve, ms) + if (!abortSignal) return + const onAbort = () => { + clearTimeout(timer) + reject(new Error("Operation aborted")) + } + abortSignal.addEventListener("abort", onAbort, { once: true }) + }) + } +} diff --git a/examples/opencode-plugin/lib/memory-tools.mjs b/examples/opencode-plugin/lib/memory-tools.mjs new file mode 100644 index 000000000..00a2330e1 --- /dev/null +++ b/examples/opencode-plugin/lib/memory-tools.mjs @@ -0,0 +1,358 @@ +import { tool } from "@opencode-ai/plugin" +import { addMemaddResource } from "./memadd-local.mjs" +import { + log, + makeRequest, + unwrapResponse, + validateVikingUri, +} from "./utils.mjs" + +const z = tool.schema + +export function createMemoryTools({ config, sessionManager, projectDirectory }) { + return { + memsearch: tool({ + description: + "Search OpenViking memories, indexed repositories, and skills. Use this for semantic or conceptual questions. Narrow `target_uri` whenever possible, for example viking://resources/project/ or viking://user/memories/.", + args: { + query: z.string().describe("Natural language query, question, or task description."), + target_uri: z.string().optional().describe("Optional Viking URI scope, e.g. viking://resources/ or viking://user/memories/."), + mode: z.enum(["auto", "fast", "deep"]).optional().describe("auto chooses based on query complexity; fast uses /find; deep uses /search with session context when available."), + session_id: z.string().optional().describe("Optional explicit OpenViking session ID for context-aware search."), + limit: z.number().optional().describe("Maximum number of results. Defaults to 10."), + score_threshold: z.number().optional().describe("Optional minimum score threshold."), + }, + async execute(args, context) { + try { + let sessionId = args.session_id + if (!sessionId && context.sessionID) { + sessionId = sessionManager.getMappedSessionId(context.sessionID) + } + + const mode = resolveSearchMode(args.mode, args.query, sessionId) + const body = { + query: args.query, + limit: args.limit ?? 10, + } + if (args.target_uri) body.target_uri = args.target_uri + if (args.score_threshold !== undefined) body.score_threshold = args.score_threshold + if (mode === "deep" && sessionId) body.session_id = sessionId + + const response = await makeRequest(config, { + method: "POST", + endpoint: mode === "deep" ? "/api/v1/search/search" : "/api/v1/search/find", + body, + abortSignal: context.abort, + }) + return formatSearchResults(unwrapResponse(response), args.query, { mode }) + } catch (error) { + log("ERROR", "memsearch", "Search failed", { error: error?.message, args }) + return `Error: ${error.message}` + } + }, + }), + + memread: tool({ + description: + "Read a specific viking:// URI. Use after memsearch, membrowse, memgrep, or memglob returns a URI. `auto` chooses overview for directories and read for files.", + args: { + uri: z.string().describe("Complete Viking URI to read."), + level: z.enum(["auto", "abstract", "overview", "read"]).optional().describe("Read level. Defaults to auto."), + }, + async execute(args, context) { + const validationError = validateVikingUri(args.uri, "memread") + if (validationError) return validationError + + try { + let level = args.level ?? "auto" + if (level === "auto") { + level = await resolveReadLevel(config, args.uri, context.abort) + } + const response = await makeRequest(config, { + method: "GET", + endpoint: `/api/v1/content/${level}?uri=${encodeURIComponent(args.uri)}`, + abortSignal: context.abort, + }) + const content = unwrapResponse(response) + return typeof content === "string" ? content : JSON.stringify(content, null, 2) + } catch (error) { + log("ERROR", "memread", "Read failed", { error: error?.message, uri: args.uri }) + return `Error: ${error.message}` + } + }, + }), + + membrowse: tool({ + description: + "Browse OpenViking filesystem structure. Use list/tree/stat to discover exact URIs before reading. Scope to the narrowest useful viking:// path.", + args: { + uri: z.string().describe("Viking URI to inspect, e.g. viking://resources/ or viking://user/memories/."), + view: z.enum(["list", "tree", "stat"]).optional().describe("Browse view. Defaults to list."), + recursive: z.boolean().optional().describe("For list view only, recursively list descendants."), + simple: z.boolean().optional().describe("For list view only, return simpler URI-oriented output."), + }, + async execute(args, context) { + const validationError = validateVikingUri(args.uri, "membrowse") + if (validationError) return validationError + + try { + const view = args.view ?? "list" + const encodedUri = encodeURIComponent(args.uri) + let endpoint + if (view === "stat") { + endpoint = `/api/v1/fs/stat?uri=${encodedUri}` + } else if (view === "tree") { + endpoint = `/api/v1/fs/tree?uri=${encodedUri}` + } else { + endpoint = `/api/v1/fs/ls?uri=${encodedUri}&recursive=${args.recursive ? "true" : "false"}&simple=${args.simple ? "true" : "false"}` + } + const response = await makeRequest(config, { method: "GET", endpoint, abortSignal: context.abort }) + return JSON.stringify({ view, result: unwrapResponse(response) }, null, 2) + } catch (error) { + log("ERROR", "membrowse", "Browse failed", { error: error?.message, uri: args.uri }) + return `Error: ${error.message}` + } + }, + }), + + memcommit: tool({ + description: + "Commit the current OpenCode session to OpenViking and extract persistent memories. Use for immediate memory extraction before ending a conversation or after important preferences/decisions are discussed.", + args: { + session_id: z.string().optional().describe("Optional explicit OpenViking session ID. Omit to use the current OpenCode session mapping."), + }, + async execute(args, context) { + const sessionId = args.session_id ?? (context.sessionID ? sessionManager.getMappedSessionId(context.sessionID) : undefined) + if (!sessionId) { + return "Error: No OpenViking session is associated with the current OpenCode session. Start or resume a normal OpenCode session first, or pass session_id." + } + + try { + const result = await sessionManager.commitSession(sessionId, context.sessionID, context.abort) + return formatCommitResult(sessionId, result) + } catch (error) { + log("ERROR", "memcommit", "Commit failed", { error: error?.message, session_id: sessionId }) + return `Error: ${error.message}` + } + }, + }), + + memgrep: tool({ + description: + "Search exact text or regex-like patterns in OpenViking content. Use this for symbols, function names, classes, error strings, or known keywords. Narrow `uri` to the smallest relevant repository or directory.", + args: { + pattern: z.string().describe("Pattern or exact keyword to search for."), + uri: z.string().optional().describe("Starting Viking URI. Defaults to viking://resources/."), + case_insensitive: z.boolean().optional().describe("Whether search should ignore case."), + exclude_uri: z.string().optional().describe("Optional URI prefix to exclude from matches."), + level_limit: z.number().optional().describe("Optional maximum traversal depth."), + }, + async execute(args, context) { + const uri = args.uri ?? "viking://resources/" + const validationError = validateVikingUri(uri, "memgrep") + if (validationError) return validationError + + try { + const body = { uri, pattern: args.pattern } + if (args.case_insensitive !== undefined) body.case_insensitive = args.case_insensitive + if (args.exclude_uri) body.exclude_uri = args.exclude_uri + if (args.level_limit !== undefined) body.level_limit = args.level_limit + const response = await makeRequest(config, { + method: "POST", + endpoint: "/api/v1/search/grep", + body, + abortSignal: context.abort, + }) + return JSON.stringify(unwrapResponse(response), null, 2) + } catch (error) { + log("ERROR", "memgrep", "Grep failed", { error: error?.message, args }) + return `Error: ${error.message}` + } + }, + }), + + memglob: tool({ + description: + "List files by glob pattern in OpenViking. Use this to enumerate candidate files before memread. Narrow `uri` to the smallest relevant repository or directory.", + args: { + pattern: z.string().describe("Glob pattern, e.g. **/*.py or **/test_*.ts."), + uri: z.string().optional().describe("Starting Viking URI. Defaults to viking://resources/."), + node_limit: z.number().optional().describe("Optional maximum number of matches."), + }, + async execute(args, context) { + const uri = args.uri ?? "viking://resources/" + const validationError = validateVikingUri(uri, "memglob") + if (validationError) return validationError + + try { + const body = { uri, pattern: args.pattern } + if (args.node_limit !== undefined) body.node_limit = args.node_limit + const response = await makeRequest(config, { + method: "POST", + endpoint: "/api/v1/search/glob", + body, + abortSignal: context.abort, + }) + return JSON.stringify(unwrapResponse(response), null, 2) + } catch (error) { + log("ERROR", "memglob", "Glob failed", { error: error?.message, args }) + return `Error: ${error.message}` + } + }, + }), + + memadd: tool({ + description: + "Add a remote URL or local file resource to OpenViking under viking://resources/. Local files are uploaded through OpenViking temp upload before indexing. After adding, this returns observer queue status so indexing progress is visible.", + args: { + path: z.string().describe("Remote http(s) URL, local file path, or file:// URL to add. Relative local paths are resolved from the OpenCode project directory."), + to: z.string().optional().describe("Exact target URI under viking://resources/. Cannot be used with parent."), + parent: z.string().optional().describe("Parent URI under viking://resources/. Cannot be used with to."), + reason: z.string().optional().describe("Reason for adding this resource."), + instruction: z.string().optional().describe("Optional processing instruction."), + wait: z.boolean().optional().describe("Whether OpenViking should wait for semantic processing."), + timeout: z.number().optional().describe("Timeout seconds when wait=true."), + watch_interval: z.number().optional().describe("Minutes between scheduled refreshes. Requires to."), + }, + async execute(args, context) { + if (args.to && args.parent) return "Error: Use either `to` or `parent`, not both." + if (args.to && !args.to.startsWith("viking://resources")) return "Error: `to` must be under viking://resources/." + if (args.parent && !args.parent.startsWith("viking://resources")) return "Error: `parent` must be under viking://resources/." + + try { + const result = await addMemaddResource(config, args, projectDirectory, context.abort) + if (result.error) return result.error + const queue = await getQueueStatus(config, context.abort) + return JSON.stringify({ add_resource: unwrapResponse(result.addResponse), queue }, null, 2) + } catch (error) { + log("ERROR", "memadd", "Add resource failed", { error: error?.message, args }) + return `Error: ${error.message}` + } + }, + }), + + memremove: tool({ + description: + "Remove a viking:// resource. The user must explicitly confirm deletion before this tool is called. Set confirm=true, otherwise deletion is refused.", + args: { + uri: z.string().describe("Viking URI to remove."), + recursive: z.boolean().optional().describe("Recursively remove a directory."), + confirm: z.boolean().describe("Must be true after explicit user confirmation."), + }, + async execute(args, context) { + if (!args.confirm) { + return "Error: Refusing to delete. Ask the user for explicit confirmation, then call memremove with confirm=true." + } + const validationError = validateVikingUri(args.uri, "memremove") + if (validationError) return validationError + + try { + const response = await makeRequest(config, { + method: "DELETE", + endpoint: `/api/v1/fs?uri=${encodeURIComponent(args.uri)}&recursive=${args.recursive ? "true" : "false"}`, + abortSignal: context.abort, + }) + return JSON.stringify(unwrapResponse(response), null, 2) + } catch (error) { + log("ERROR", "memremove", "Remove failed", { error: error?.message, args }) + return `Error: ${error.message}` + } + }, + }), + + memqueue: tool({ + description: "Return OpenViking observer queue status for embedding and semantic processing after resource indexing operations.", + args: {}, + async execute(_args, context) { + try { + const queue = await getQueueStatus(config, context.abort) + return JSON.stringify(queue, null, 2) + } catch (error) { + log("ERROR", "memqueue", "Queue status failed", { error: error?.message }) + return `Error: ${error.message}` + } + }, + }), + } +} + +async function resolveReadLevel(config, uri, abortSignal) { + try { + const statResponse = await makeRequest(config, { + method: "GET", + endpoint: `/api/v1/fs/stat?uri=${encodeURIComponent(uri)}`, + abortSignal, + }) + return unwrapResponse(statResponse)?.isDir ? "overview" : "read" + } catch { + return "read" + } +} + +function resolveSearchMode(requestedMode, query, sessionId) { + if (requestedMode === "fast" || requestedMode === "deep") return requestedMode + if (sessionId) return "deep" + const normalized = query.trim() + const wordCount = normalized ? normalized.split(/\s+/).length : 0 + return normalized.includes("?") || normalized.length >= 80 || wordCount >= 8 ? "deep" : "fast" +} + +function formatSearchResults(result, query, extra) { + const memories = result?.memories ?? [] + const resources = result?.resources ?? [] + const skills = result?.skills ?? [] + const allResults = [...memories, ...resources, ...skills] + if (allResults.length === 0) { + return "No results found matching the query." + } + return JSON.stringify( + { + total: result?.total ?? allResults.length, + memories, + resources, + skills, + query_plan: result?.query_plan, + query, + ...extra, + }, + null, + 2, + ) +} + +function formatCommitResult(sessionId, result) { + const task = result.task + const payload = task?.result ?? result.result ?? {} + const memoriesExtracted = totalMemoriesExtracted(payload.memories_extracted) + return JSON.stringify( + { + message: result.status === "accepted" ? "Commit is still processing in the background" : `Memory extraction complete: ${memoriesExtracted} memories extracted`, + session_id: payload.session_id ?? sessionId, + status: result.status, + memories_extracted: memoriesExtracted, + archived: payload.archived ?? false, + task_id: task?.task_id ?? result.task_id, + }, + null, + 2, + ) +} + +function totalMemoriesExtracted(memories) { + if (typeof memories === "number") return memories + if (!memories || typeof memories !== "object") return 0 + return Object.entries(memories).reduce((sum, [key, value]) => { + if (key === "total") return sum + return sum + (typeof value === "number" ? value : 0) + }, 0) +} + +async function getQueueStatus(config, abortSignal) { + const response = await makeRequest(config, { + method: "GET", + endpoint: "/api/v1/observer/queue", + abortSignal, + timeoutMs: 5000, + }) + return unwrapResponse(response) +} diff --git a/examples/opencode-plugin/lib/repo-context.mjs b/examples/opencode-plugin/lib/repo-context.mjs new file mode 100644 index 000000000..0e509d869 --- /dev/null +++ b/examples/opencode-plugin/lib/repo-context.mjs @@ -0,0 +1,68 @@ +import { log, makeRequest, unwrapResponse } from "./utils.mjs" + +export function createRepoContext({ config }) { + let cachedRepos = null + let lastFetchTime = 0 + + async function refreshRepos({ force = false } = {}) { + if (!config.repoContext?.enabled) return null + + const now = Date.now() + const ttl = config.repoContext?.cacheTtlMs ?? 60000 + if (!force && cachedRepos !== null && now - lastFetchTime < ttl) { + return cachedRepos + } + + try { + const response = await makeRequest(config, { + method: "GET", + endpoint: `/api/v1/fs/ls?uri=${encodeURIComponent("viking://resources/")}&recursive=false&simple=false`, + timeoutMs: 8000, + }) + const result = unwrapResponse(response) + const items = Array.isArray(result) ? result : [] + const repos = items + .filter((item) => item?.uri?.startsWith("viking://resources/") && item.uri !== "viking://resources/") + .map(formatRepoLine) + + cachedRepos = repos.length > 0 ? repos.join("\n") : "" + lastFetchTime = now + log("INFO", "repo-context", "Repo context refreshed", { count: repos.length }) + return cachedRepos + } catch (error) { + log("WARN", "repo-context", "Failed to refresh indexed repositories", { error: error?.message }) + return cachedRepos + } + } + + function getRepoSystemPrompt() { + if (!config.repoContext?.enabled || !cachedRepos) return null + return [ + "## OpenViking - Indexed Code Repositories", + "", + "The following external repositories are indexed in OpenViking and searchable through tools.", + "When the user asks about these projects or their internals, use the OpenViking tools before answering.", + "", + "Tool guidance:", + "- Use `memsearch` for semantic or conceptual repository questions.", + "- Use `memgrep` for exact symbols, error strings, class names, function names, and regex-like searches.", + "- Use `memglob` to enumerate files by pattern.", + "- Use `membrowse` to inspect directory structure and `memread` to read specific URIs.", + "- Use `memadd`, `memremove`, and `memqueue` for repository resource management when explicitly requested.", + "", + cachedRepos, + ].join("\n") + } + + return { + refreshRepos, + getRepoSystemPrompt, + } +} + +function formatRepoLine(item) { + const name = item.uri.replace("viking://resources/", "").replace(/\/$/, "") || "resources" + const abstract = item.abstract || item.overview + return abstract ? `- **${name}** (${item.uri})\n ${abstract}` : `- **${name}** (${item.uri})` +} + diff --git a/examples/opencode-plugin/lib/runtime.mjs b/examples/opencode-plugin/lib/runtime.mjs new file mode 100644 index 000000000..1c0311ff5 --- /dev/null +++ b/examples/opencode-plugin/lib/runtime.mjs @@ -0,0 +1,33 @@ +import { log, makeToast, normalizeEndpoint } from "./utils.mjs" + +export async function checkServiceHealth(config, timeoutMs = 3000) { + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(`${normalizeEndpoint(config.endpoint)}/health`, { + method: "GET", + signal: controller.signal, + }) + return response.ok + } catch (error) { + log("WARN", "health", "OpenViking health check failed", { + endpoint: config.endpoint, + error: error?.message, + }) + return false + } finally { + clearTimeout(timeout) + } +} + +export async function initializeRuntime(config, client) { + const toast = makeToast(client) + + if (await checkServiceHealth(config)) { + log("INFO", "runtime", "OpenViking service is healthy", { endpoint: config.endpoint }) + return true + } + + await toast(`OpenViking service is not reachable at ${config.endpoint}. Start openviking-server before using memory tools.`, "warning") + return false +} diff --git a/examples/opencode-plugin/lib/utils.mjs b/examples/opencode-plugin/lib/utils.mjs new file mode 100644 index 000000000..07f207fe1 --- /dev/null +++ b/examples/opencode-plugin/lib/utils.mjs @@ -0,0 +1,332 @@ +import fs from "fs" +import path from "path" +import { homedir } from "os" + +export const DEFAULT_CONFIG = { + endpoint: "http://localhost:1933", + apiKey: "", + account: "", + user: "", + agentId: "", + enabled: true, + timeoutMs: 30000, + runtime: { + dataDir: "", + }, + repoContext: { + enabled: true, + cacheTtlMs: 60000, + }, + autoRecall: { + enabled: true, + limit: 6, + scoreThreshold: 0.15, + maxContentChars: 500, + preferAbstract: true, + tokenBudget: 2000, + }, +} + +let logFilePath = null + +function cloneDefaultConfig() { + return JSON.parse(JSON.stringify(DEFAULT_CONFIG)) +} + +function mergeConfig(fileConfig = {}) { + const config = cloneDefaultConfig() + for (const key of ["endpoint", "apiKey", "account", "user", "agentId", "enabled", "timeoutMs"]) { + if (fileConfig[key] !== undefined) config[key] = fileConfig[key] + } + config.runtime = { + ...DEFAULT_CONFIG.runtime, + dataDir: fileConfig.runtime?.dataDir ?? DEFAULT_CONFIG.runtime.dataDir, + } + config.repoContext = { ...DEFAULT_CONFIG.repoContext, ...(fileConfig.repoContext ?? {}) } + config.autoRecall = { ...DEFAULT_CONFIG.autoRecall, ...(fileConfig.autoRecall ?? {}) } + + if (process.env.OPENVIKING_API_KEY) { + config.apiKey = process.env.OPENVIKING_API_KEY + } + if (process.env.OPENVIKING_ACCOUNT) { + config.account = process.env.OPENVIKING_ACCOUNT + } + if (process.env.OPENVIKING_USER) { + config.user = process.env.OPENVIKING_USER + } + if (process.env.OPENVIKING_AGENT_ID) { + config.agentId = process.env.OPENVIKING_AGENT_ID + } + + config.timeoutMs = normalizeNumber(config.timeoutMs, DEFAULT_CONFIG.timeoutMs, 1000, 300000) + config.repoContext.cacheTtlMs = normalizeNumber( + config.repoContext.cacheTtlMs, + DEFAULT_CONFIG.repoContext.cacheTtlMs, + 1000, + 60 * 60 * 1000, + ) + clampRecallConfig(config.autoRecall) + return config +} + +function normalizeNumber(value, fallback, min, max) { + const next = Number(value) + if (!Number.isFinite(next)) return fallback + return Math.max(min, Math.min(max, next)) +} + +function clampRecallConfig(recall) { + recall.limit = Math.max(1, Math.min(50, Math.round(Number(recall.limit) || 6))) + recall.scoreThreshold = Math.max(0, Math.min(1, Number(recall.scoreThreshold) || 0)) + recall.maxContentChars = Math.max(100, Math.min(5000, Math.round(Number(recall.maxContentChars) || 500))) + recall.tokenBudget = Math.max(100, Math.min(10000, Math.round(Number(recall.tokenBudget) || 2000))) +} + +export function loadConfig(pluginRoot, projectDirectory) { + for (const configPath of getConfigPaths(pluginRoot, projectDirectory)) { + try { + if (fs.existsSync(configPath)) { + const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8")) + return mergeConfig(fileConfig) + } + } catch (error) { + console.warn(`Failed to load OpenViking config from ${configPath}:`, error) + } + } + return mergeConfig() +} + +function getConfigPaths(pluginRoot, projectDirectory) { + const paths = [] + if (process.env.OPENVIKING_PLUGIN_CONFIG) paths.push(expandHome(process.env.OPENVIKING_PLUGIN_CONFIG)) + if (projectDirectory) paths.push(path.join(projectDirectory, ".opencode", "openviking-config.json")) + paths.push(path.join(homedir(), ".config", "opencode", "openviking-config.json")) + paths.push(path.join(pluginRoot, "openviking-config.json")) + return paths +} + +export function resolveDataDir(pluginRoot, config) { + const configured = config.runtime?.dataDir + if (configured) return expandHome(configured) + return path.join(homedir(), ".config", "opencode", "openviking") +} + +function expandHome(value) { + if (!value || typeof value !== "string") return value + if (value === "~") return homedir() + if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2)) + return value +} + +export function initLogger(dataDir) { + fs.mkdirSync(dataDir, { recursive: true }) + logFilePath = path.join(dataDir, "openviking-memory.log") +} + +export function safeStringify(value) { + if (value === null || value === undefined) return value + if (typeof value !== "object") return value + if (Array.isArray(value)) return value.map((item) => safeStringify(item)) + + const result = {} + for (const key of Object.keys(value)) { + const item = value[key] + if (typeof item === "function") { + result[key] = "[Function]" + } else if (typeof item === "object" && item !== null) { + try { + result[key] = safeStringify(item) + } catch { + result[key] = "[Circular or Non-serializable]" + } + } else { + result[key] = item + } + } + return result +} + +export function log(level, toolName, message, data) { + const normalizedLevel = String(level || "INFO").toUpperCase() + const entry = { + timestamp: new Date().toISOString(), + level: normalizedLevel, + tool: toolName, + message, + ...(data ? { data: safeStringify(data) } : {}), + } + + if (!logFilePath) { + if (normalizedLevel === "ERROR") console.error(message, data ?? "") + return + } + + try { + fs.appendFileSync(logFilePath, `${JSON.stringify(entry)}\n`, "utf8") + } catch (error) { + console.error("Failed to write OpenViking plugin log:", error) + } +} + +export function makeToast(client) { + return (message, variant = "warning") => + client?.tui?.showToast?.({ + body: { title: "OpenViking", message, variant, duration: 8000 }, + }).catch(() => {}) +} + +export function normalizeEndpoint(endpoint) { + return endpoint.replace(/\/+$/, "") +} + +export async function makeRequest(config, options) { + const url = `${normalizeEndpoint(config.endpoint)}${options.endpoint}` + const headers = makeAuthHeaders(config, { "Content-Type": "application/json", ...(options.headers ?? {}) }) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? config.timeoutMs) + let onAbort = null + + if (options.abortSignal) { + if (options.abortSignal.aborted) controller.abort() + onAbort = () => controller.abort() + options.abortSignal.addEventListener("abort", onAbort, { once: true }) + } + + try { + const response = await fetch(url, { + method: options.method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: controller.signal, + }) + + const text = await response.text() + const payload = text ? parseJsonOrText(text) : {} + + if (!response.ok) { + const rawError = typeof payload === "object" ? payload.error ?? payload.message : payload + const errorMessage = typeof rawError === "string" ? rawError : JSON.stringify(rawError) + if (response.status === 401 || response.status === 403) { + throw new Error("Authentication failed. Please check apiKey/account/user in openviking-config.json or OPENVIKING_* environment variables.") + } + throw new Error(`Request failed (${response.status}): ${errorMessage}`) + } + + return payload + } catch (error) { + if (error?.name === "AbortError") { + throw new Error(`Request timeout after ${options.timeoutMs ?? config.timeoutMs}ms`) + } + if (error?.message?.includes("fetch failed") || error?.code === "ECONNREFUSED") { + throw new Error(`OpenViking service unavailable at ${config.endpoint}. Start it with: openviking-server --config ~/.openviking/ov.conf`) + } + throw error + } finally { + clearTimeout(timeout) + if (options.abortSignal && onAbort) { + options.abortSignal.removeEventListener("abort", onAbort) + } + } +} + +export async function makeMultipartRequest(config, options) { + const url = `${normalizeEndpoint(config.endpoint)}${options.endpoint}` + const headers = makeAuthHeaders(config, options.headers ?? {}) + + const controller = new AbortController() + const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? config.timeoutMs) + let onAbort = null + + if (options.abortSignal) { + if (options.abortSignal.aborted) controller.abort() + onAbort = () => controller.abort() + options.abortSignal.addEventListener("abort", onAbort, { once: true }) + } + + try { + const response = await fetch(url, { + method: options.method, + headers, + body: options.body, + signal: controller.signal, + }) + + const text = await response.text() + const payload = text ? parseJsonOrText(text) : {} + + if (!response.ok) { + const rawError = typeof payload === "object" ? payload.error ?? payload.message : payload + const errorMessage = typeof rawError === "string" ? rawError : JSON.stringify(rawError) + if (response.status === 401 || response.status === 403) { + throw new Error("Authentication failed. Please check apiKey/account/user in openviking-config.json or OPENVIKING_* environment variables.") + } + throw new Error(`Request failed (${response.status}): ${errorMessage}`) + } + + return payload + } catch (error) { + if (error?.name === "AbortError") { + throw new Error(`Request timeout after ${options.timeoutMs ?? config.timeoutMs}ms`) + } + if (error?.message?.includes("fetch failed") || error?.code === "ECONNREFUSED") { + throw new Error(`OpenViking service unavailable at ${config.endpoint}. Start it with: openviking-server --config ~/.openviking/ov.conf`) + } + throw error + } finally { + clearTimeout(timeout) + if (options.abortSignal && onAbort) { + options.abortSignal.removeEventListener("abort", onAbort) + } + } +} + +function makeAuthHeaders(config, headers = {}) { + const result = { ...headers } + if (config.apiKey) result["X-API-Key"] = config.apiKey + if (config.account) result["X-OpenViking-Account"] = config.account + if (config.user) result["X-OpenViking-User"] = config.user + if (config.agentId) result["X-OpenViking-Agent"] = config.agentId + return result +} + +function parseJsonOrText(text) { + try { + return JSON.parse(text) + } catch { + return text + } +} + +export function getResponseErrorMessage(error) { + if (!error) return "Unknown OpenViking error" + if (typeof error === "string") return error + return error.message || error.code || "Unknown OpenViking error" +} + +export function unwrapResponse(response) { + if (!response || typeof response !== "object") { + throw new Error("OpenViking returned an invalid response") + } + if (response.status && response.status !== "ok") { + throw new Error(getResponseErrorMessage(response.error)) + } + return response.result +} + +export function validateVikingUri(uri, toolName = "tool") { + if (typeof uri !== "string" || !uri.startsWith("viking://")) { + log("ERROR", toolName, "Invalid Viking URI", { uri }) + return 'Error: Invalid URI format. Must start with "viking://".' + } + return null +} + +export function ensureRemoteUrl(value) { + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" + } catch { + return false + } +} diff --git a/examples/opencode-plugin/package.json b/examples/opencode-plugin/package.json new file mode 100644 index 000000000..d2c6b655d --- /dev/null +++ b/examples/opencode-plugin/package.json @@ -0,0 +1,37 @@ +{ + "name": "openviking-opencode-plugin", + "version": "0.1.0", + "author": "tanyouqing", + "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory", + "type": "module", + "main": "index.mjs", + "exports": { + ".": "./index.mjs" + }, + "files": [ + "index.mjs", + "lib/", + "README.md", + "INSTALL.md", + "INSTALL-ZH.md" + ], + "scripts": { + "check": "node --check index.mjs && node --check lib/runtime.mjs && node --check lib/repo-context.mjs && node --check lib/memory-session.mjs && node --check lib/memadd-local.mjs && node --check lib/memory-tools.mjs && node --check lib/memory-recall.mjs && node --check lib/utils.mjs" + }, + "dependencies": { + "@opencode-ai/plugin": "^1.14.37" + }, + "repository": { + "type": "git", + "url": "https://github.com/volcengine/OpenViking" + }, + "keywords": [ + "opencode", + "opencode-plugin", + "openviking", + "memory", + "rag", + "code-search" + ], + "license": "Apache-2.0" +} diff --git a/examples/opencode-plugin/wrappers/openviking.mjs b/examples/opencode-plugin/wrappers/openviking.mjs new file mode 100644 index 000000000..4858ace3c --- /dev/null +++ b/examples/opencode-plugin/wrappers/openviking.mjs @@ -0,0 +1 @@ +export { OpenVikingPlugin, default } from "./openviking/index.mjs"