diff --git a/README_JA.md b/README_JA.md index 2ffa6e9389..197ea4b902 100644 --- a/README_JA.md +++ b/README_JA.md @@ -424,7 +424,7 @@ OpenViking統合後: 👉 **[参照: OpenClawコンテキストプラグイン](examples/openclaw-plugin/README.md)** -👉 **[参照: OpenCodeメモリプラグインの例](examples/opencode-memory-plugin/README.md)** +👉 **[参照: OpenCode統合プラグイン](examples/opencode-plugin/README.md)** 👉 **[参照: Claude Codeメモリプラグインの例](examples/claude-code-memory-plugin/README.md)** diff --git a/docs/en/agent-integrations/08-community-plugins.md b/docs/en/agent-integrations/08-community-plugins.md index 89ae6f8ad0..2d5e90053f 100644 --- a/docs/en/agent-integrations/08-community-plugins.md +++ b/docs/en/agent-integrations/08-community-plugins.md @@ -19,18 +19,130 @@ Provides auto-capture of group/DM conversations, semantic recall before each LLM - Four auto-commit triggers: message count, token threshold, idle timeout, and process-exit flush - Backfills platform message history on first venue encounter -## OpenCode plugins +## OpenCode plugin -Two OpenCode plugin variants exist with different design choices. Pick whichever matches your usage. +OpenViking provides one unified OpenCode plugin for repository context and long-term memory workflows. -### `opencode-memory-plugin` — explicit-tool variant +Source: [examples/opencode-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin) -Source: [examples/opencode-memory-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-memory-plugin) +The plugin combines indexed repository context, OpenViking memory tools, session synchronization, lifecycle commit, and automatic recall through OpenCode plugin hooks. Use this plugin for both the former explicit-tool and context-injection use cases. + +### Prerequisites -Exposes OpenViking memories as explicit OpenCode tools. The agent decides when to call them, and data is fetched on demand rather than pre-injected. +- [OpenCode](https://opencode.ai/) +- Node.js and npm +- An OpenViking HTTP server +- An OpenViking API key when your server requires authentication -### `opencode/plugin` — context-injection variant +Start your OpenViking server first: -Source: [examples/opencode/plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode/plugin) +```bash +openviking-server --config ~/.openviking/ov.conf +``` -Injects indexed code repos into OpenCode's context and auto-starts the OpenViking server when needed. \ No newline at end of file +In another terminal, check the service: + +```bash +curl http://localhost:1933/health +``` + +### Install + +If you are using the published package, add the plugin to `~/.config/opencode/opencode.json`. If package installation is not available in your environment yet, use the source install path below. + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +For development, debugging, or PR testing, install the plugin from this repository instead: + +```bash +git clone https://github.com/volcengine/OpenViking.git +cd OpenViking +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 source install creates the layout OpenCode can discover: + +```text +~/.config/opencode/plugins/ +├── openviking.mjs +└── openviking/ + ├── index.mjs + ├── package.json + ├── lib/ + └── node_modules/ +``` + +The top-level `openviking.mjs` is only a wrapper that forwards OpenCode's first-level plugin entry to the installed package directory. + +### Configure + +Create `~/.config/opencode/openviking-config.json`: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "peerId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +Prefer environment variables for secrets: + +```bash +export OPENVIKING_API_KEY="your-api-key-here" +export OPENVIKING_ACCOUNT="default" # optional, trusted-mode deployments only +export OPENVIKING_USER="opencode" # optional, trusted-mode deployments only +export OPENVIKING_PEER_ID="opencode" # optional, peer-scoped memory routing +``` + +Environment variables override `openviking-config.json`. `apiKey` is sent as `X-API-Key`; `account` and `user` are trusted-mode headers; `peerId` is sent as request-level `peer_id` for recall, search, and captured session messages. + +### Verify + +Restart OpenCode after installation. In an OpenCode session, the plugin should expose these tools: + +- `memsearch`, `memread`, `membrowse` +- `memgrep`, `memglob` +- `memadd`, `memremove`, `memqueue` +- `memcommit` + +Ask OpenCode to search or browse OpenViking memory, or request a manual session commit. Runtime state and errors are written to: + +```bash +~/.config/opencode/openviking/openviking-memory.log +~/.config/opencode/openviking/openviking-session-map.json +``` + +### Troubleshooting + +| Issue | What to check | +|-------|---------------| +| Plugin does not load | Confirm `~/.config/opencode/opencode.json` references `openviking-opencode-plugin`, or that `~/.config/opencode/plugins/openviking.mjs` exists for source installs | +| Tools call the wrong server | Check `endpoint` in `~/.config/opencode/openviking-config.json`, or set `OPENVIKING_PLUGIN_CONFIG` to the intended config path | +| 401 / 403 from OpenViking | Verify `OPENVIKING_API_KEY`; for trusted-mode deployments, also verify `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` | +| Recall is empty | Confirm the OpenViking server has indexed memories/resources and that `autoRecall.enabled` is `true` | +| Local `memadd` fails | Pass a file path, not a directory; local directories are not uploaded automatically yet | + +For all available tools, configuration fields, and runtime file details, see the [plugin README](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin). diff --git a/docs/images/agents/en/index.json b/docs/images/agents/en/index.json index 3e8e5a34ba..493ab4154c 100644 --- a/docs/images/agents/en/index.json +++ b/docs/images/agents/en/index.json @@ -93,7 +93,7 @@ "name": "OpenCode", "tags": ["Plugin"], "logo": "https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_sth/ljhwZthlaukjlkulzlp/agent_logo/OpenCode.png", - "summary": "Provides OpenCode plugins that can expose OpenViking either as model-selected tools or injected context.", + "summary": "Provides one unified OpenCode plugin for repository context, memory tools, session sync, and automatic recall.", "detailPath": "https://docs.openviking.net/agents/en/opencode.md" } ] diff --git a/docs/images/agents/en/opencode.md b/docs/images/agents/en/opencode.md index 957a66723d..15f640422d 100644 --- a/docs/images/agents/en/opencode.md +++ b/docs/images/agents/en/opencode.md @@ -1,13 +1,119 @@ -OpenCode has two plugin variants with different designs. Choose the one that matches how you want to use it. +OpenViking provides one unified OpenCode plugin for repository context and long-term memory workflows. -## Option 1: `opencode-memory-plugin` - Explicit tool version +## `opencode-plugin` -Source: [examples/opencode-memory-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-memory-plugin) +Source: [examples/opencode-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin) -This variant exposes OpenViking memory as explicit tools through OpenCode's tool mechanism. The model decides when to call them, and data is fetched on demand. +The plugin combines indexed repository context, OpenViking memory tools, session synchronization, lifecycle commit, and automatic recall through OpenCode plugin hooks. -## Option 2: `opencode/plugin` - Context injection version +## Step 1: Prepare OpenViking -Source: [examples/opencode/plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode/plugin) +Install OpenCode, Node.js/npm, and an OpenViking HTTP server. Start the server before launching OpenCode: -This variant injects indexed code repositories into OpenCode context and starts the OpenViking server automatically when needed. +```bash +openviking-server --config ~/.openviking/ov.conf +``` + +In another terminal, check the service: + +```bash +curl http://localhost:1933/health +``` + +For remote or multi-tenant deployments, prepare an OpenViking API key. + +## Step 2: Install the plugin + +For a published package install, add the plugin to `~/.config/opencode/opencode.json`. If package installation is not available in your environment yet, use the source install path below. + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +For development, debugging, or PR testing, copy the plugin from the OpenViking repository: + +```bash +git clone https://github.com/volcengine/OpenViking.git +cd OpenViking +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 +``` + +The source install creates: + +```text +~/.config/opencode/plugins/ +├── openviking.mjs +└── openviking/ + ├── index.mjs + ├── package.json + ├── lib/ + └── node_modules/ +``` + +## Step 3: Configure the OpenViking connection + +Create `~/.config/opencode/openviking-config.json`: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "peerId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +Prefer environment variables for secrets: + +```bash +export OPENVIKING_API_KEY="your-api-key-here" +export OPENVIKING_ACCOUNT="default" # optional, trusted-mode deployments only +export OPENVIKING_USER="opencode" # optional, trusted-mode deployments only +export OPENVIKING_PEER_ID="opencode" # optional, peer-scoped memory routing +``` + +Environment variables override the config file. + +## Step 4: Verify + +Restart OpenCode. The plugin should expose `memsearch`, `memread`, `membrowse`, `memgrep`, `memglob`, `memadd`, `memremove`, `memqueue`, and `memcommit`. + +Ask OpenCode to browse OpenViking or commit the current session. Check runtime logs if anything looks wrong: + +```bash +~/.config/opencode/openviking/openviking-memory.log +~/.config/opencode/openviking/openviking-session-map.json +``` + +## Troubleshooting + +| Symptom | Fix | +|---------|-----| +| Plugin is not loaded | Check `~/.config/opencode/opencode.json` for package installs, or `~/.config/opencode/plugins/openviking.mjs` for source installs | +| Tools call the wrong server | Check `endpoint`, or set `OPENVIKING_PLUGIN_CONFIG` to the intended config path | +| 401 / 403 from OpenViking | Verify `OPENVIKING_API_KEY`; trusted-mode deployments also need `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` | +| Recall is empty | Confirm OpenViking has memories/resources and `autoRecall.enabled` is `true` | + +## Reference docs + +- [Plugin README](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin) - full tool list, configuration fields, and runtime details +- [Deployment Guide](https://www.openviking.ai/en/guides/03-deployment) - setting up OpenViking server and CLI config diff --git a/docs/images/agents/zh/index.json b/docs/images/agents/zh/index.json index f227a5a41d..d27580a0f7 100644 --- a/docs/images/agents/zh/index.json +++ b/docs/images/agents/zh/index.json @@ -93,7 +93,7 @@ "name": "OpenCode", "tags": ["Plugin"], "logo": "https://lf3-static.bytednsdoc.com/obj/eden-cn/lm_sth/ljhwZthlaukjlkulzlp/agent_logo/OpenCode.png", - "summary": "提供两种 OpenCode 插件,可以模型自主选择使用,也可以注入上下文使用", + "summary": "提供一个统一 OpenCode 插件,支持仓库上下文、记忆工具、session 同步与自动 recall", "detailPath": "https://docs.openviking.net/agents/zh/opencode.md" } ] diff --git a/docs/images/agents/zh/opencode.md b/docs/images/agents/zh/opencode.md index d8f117cd1a..8c525cc262 100644 --- a/docs/images/agents/zh/opencode.md +++ b/docs/images/agents/zh/opencode.md @@ -1,14 +1,120 @@ -OpenCode 有两个设计路径不同的插件变体,请按你的使用方式自行选择。 +OpenViking 现在只保留一个面向 OpenCode 的统一插件,同时覆盖仓库上下文与长期记忆场景。 -## 方式 1:`opencode-memory-plugin` — 显式工具版本 +## `opencode-plugin` -源码:[examples/opencode-memory-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-memory-plugin) +源码:[examples/opencode-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin) -通过 OpenCode 的工具机制把 OpenViking 记忆暴露为显式工具。模型决定何时调用,数据按需获取。 +这个插件通过 OpenCode plugin hooks 组合已索引仓库上下文、OpenViking 记忆工具、session 同步、生命周期 commit 与自动 recall。 -## 方式 2:`opencode/plugin` — 上下文注入版本 +## 步骤 1:准备 OpenViking -源码:[examples/opencode/plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode/plugin) +先安装 OpenCode、Node.js/npm,并准备一个 OpenViking HTTP server。启动 OpenCode 前先启动服务: -把已索引的代码仓库注入 OpenCode 上下文,并按需自动启动 OpenViking 服务器。 +```bash +openviking-server --config ~/.openviking/ov.conf +``` + +在另一个终端检查服务: + +```bash +curl http://localhost:1933/health +``` + +远程或多租户部署需要提前准备 OpenViking API key。 + +## 步骤 2:安装插件 + +如果使用已发布的包,把插件加入 `~/.config/opencode/opencode.json`。如果当前环境还不能通过 package 安装,请使用下面的源码安装路径。 + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +开发、调试或 PR 测试时,可以从 OpenViking 仓库复制插件: + +```bash +git clone https://github.com/volcengine/OpenViking.git +cd OpenViking +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/ +``` + +## 步骤 3:配置 OpenViking 连接 + +创建 `~/.config/opencode/openviking-config.json`: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "peerId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +敏感信息建议用环境变量提供: + +```bash +export OPENVIKING_API_KEY="your-api-key-here" +export OPENVIKING_ACCOUNT="default" # 可选,仅 trusted-mode 部署需要 +export OPENVIKING_USER="opencode" # 可选,仅 trusted-mode 部署需要 +export OPENVIKING_PEER_ID="opencode" # 可选,peer 维度记忆路由需要 +``` + +环境变量优先级高于配置文件。 + +## 步骤 4:验证 + +重启 OpenCode。插件应暴露 `memsearch`、`memread`、`membrowse`、`memgrep`、`memglob`、`memadd`、`memremove`、`memqueue` 和 `memcommit`。 + +可以让 OpenCode 浏览 OpenViking 或 commit 当前 session。异常时查看运行时日志: + +```bash +~/.config/opencode/openviking/openviking-memory.log +~/.config/opencode/openviking/openviking-session-map.json +``` + +## 故障排查 + +| 现象 | 修复 | +|------|------| +| 插件没有加载 | package 安装检查 `~/.config/opencode/opencode.json`;源码安装检查 `~/.config/opencode/plugins/openviking.mjs` | +| Tools 连到了错误的 server | 检查 `endpoint`,或用 `OPENVIKING_PLUGIN_CONFIG` 指向正确配置文件 | +| OpenViking 返回 401 / 403 | 检查 `OPENVIKING_API_KEY`;trusted-mode 部署还需要 `OPENVIKING_ACCOUNT` 和 `OPENVIKING_USER` | +| recall 为空 | 确认 OpenViking 中已有 memories/resources,并且 `autoRecall.enabled` 为 `true` | + +## 参考文档 + +- [插件 README](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin) - 完整 tools、配置字段和运行时说明 +- [部署指南](https://www.openviking.ai/zh/guides/03-deployment) - OpenViking server 与 CLI 配置 diff --git a/docs/zh/agent-integrations/08-community-plugins.md b/docs/zh/agent-integrations/08-community-plugins.md index 7c9fa61101..4db30d53ae 100644 --- a/docs/zh/agent-integrations/08-community-plugins.md +++ b/docs/zh/agent-integrations/08-community-plugins.md @@ -21,16 +21,128 @@ ## OpenCode 插件 -OpenCode 有两个设计路径不同的插件变体,请按你的使用方式自行选择。 +OpenViking 现在只保留一个面向 OpenCode 的统一插件,同时覆盖仓库上下文与长期记忆场景。 -### `opencode-memory-plugin` — 显式工具版本 +源码:[examples/opencode-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin) + +这个插件通过 OpenCode plugin hooks 组合已索引仓库上下文、OpenViking 记忆工具、session 同步、生命周期 commit 与自动 recall。原来的显式工具和上下文注入两类用法都应使用这个统一插件。 + +### 前置条件 + +- [OpenCode](https://opencode.ai/) +- Node.js 和 npm +- OpenViking HTTP server +- 如果服务端启用了鉴权,需要一个可用的 OpenViking API key + +先启动 OpenViking server: -源码:[examples/opencode-memory-plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-memory-plugin) +```bash +openviking-server --config ~/.openviking/ov.conf +``` -通过 OpenCode 的工具机制把 OpenViking 记忆暴露为显式工具。模型决定何时调用,数据按需获取。 - -### `opencode/plugin` — 上下文注入版本 - -源码:[examples/opencode/plugin](https://github.com/volcengine/OpenViking/tree/main/examples/opencode/plugin) - -把已索引的代码仓库注入 OpenCode 上下文,并按需自动启动 OpenViking 服务器。 \ No newline at end of file +在另一个终端检查服务: + +```bash +curl http://localhost:1933/health +``` + +### 安装 + +如果使用已发布的包,把插件加入 `~/.config/opencode/opencode.json`。如果当前环境还不能通过 package 安装,请使用下面的源码安装路径。 + +```json +{ + "plugin": ["openviking-opencode-plugin"] +} +``` + +开发、调试或 PR 测试时,可以从本仓库源码安装: + +```bash +git clone https://github.com/volcengine/OpenViking.git +cd OpenViking +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 +``` + +源码安装后,OpenCode 能发现的目录结构应类似: + +```text +~/.config/opencode/plugins/ +├── openviking.mjs +└── openviking/ + ├── index.mjs + ├── package.json + ├── lib/ + └── node_modules/ +``` + +顶层 `openviking.mjs` 只是一个 wrapper,用来把 OpenCode 可发现的一级插件入口转发到实际安装目录。 + +### 配置 + +创建 `~/.config/opencode/openviking-config.json`: + +```json +{ + "endpoint": "http://localhost:1933", + "apiKey": "", + "account": "", + "user": "", + "peerId": "", + "enabled": true, + "timeoutMs": 30000, + "repoContext": { "enabled": true, "cacheTtlMs": 60000 }, + "autoRecall": { + "enabled": true, + "limit": 6, + "scoreThreshold": 0.15, + "maxContentChars": 500, + "preferAbstract": true, + "tokenBudget": 2000 + } +} +``` + +敏感信息建议用环境变量提供: + +```bash +export OPENVIKING_API_KEY="your-api-key-here" +export OPENVIKING_ACCOUNT="default" # 可选,仅 trusted-mode 部署需要 +export OPENVIKING_USER="opencode" # 可选,仅 trusted-mode 部署需要 +export OPENVIKING_PEER_ID="opencode" # 可选,peer 维度记忆路由需要 +``` + +环境变量优先级高于 `openviking-config.json`。`apiKey` 会作为 `X-API-Key` 发送;`account` 和 `user` 是 trusted-mode headers;`peerId` 会作为请求级 `peer_id` 用于 recall、search 和 session message 写入。 + +### 验证 + +安装后重启 OpenCode。进入 OpenCode session 后,插件应暴露这些 tools: + +- `memsearch`、`memread`、`membrowse` +- `memgrep`、`memglob` +- `memadd`、`memremove`、`memqueue` +- `memcommit` + +可以让 OpenCode 搜索或浏览 OpenViking memory,也可以要求它手动 commit 当前 session。运行时状态和错误日志会写入: + +```bash +~/.config/opencode/openviking/openviking-memory.log +~/.config/opencode/openviking/openviking-session-map.json +``` + +### 故障排查 + +| 问题 | 排查方向 | +|------|----------| +| 插件没有加载 | 确认 `~/.config/opencode/opencode.json` 引用了 `openviking-opencode-plugin`;源码安装时确认 `~/.config/opencode/plugins/openviking.mjs` 存在 | +| Tools 连到了错误的 server | 检查 `~/.config/opencode/openviking-config.json` 里的 `endpoint`,或用 `OPENVIKING_PLUGIN_CONFIG` 指向正确配置文件 | +| OpenViking 返回 401 / 403 | 检查 `OPENVIKING_API_KEY`;trusted-mode 部署还要检查 `OPENVIKING_ACCOUNT` 和 `OPENVIKING_USER` | +| recall 为空 | 确认 OpenViking server 中已有 memories/resources,并且 `autoRecall.enabled` 为 `true` | +| 本地 `memadd` 失败 | 传入文件路径而不是目录;目前还不支持自动上传本地目录 | + +完整 tools、配置字段和运行时文件说明见 [插件 README](https://github.com/volcengine/OpenViking/tree/main/examples/opencode-plugin)。 diff --git a/examples/basic-usage/README.md b/examples/basic-usage/README.md index 629af095ef..11f029749a 100644 --- a/examples/basic-usage/README.md +++ b/examples/basic-usage/README.md @@ -250,7 +250,7 @@ You can also use Volcengine or Azure OpenAI. For current provider-specific examp - [Server Mode Quick Start](../../docs/en/getting-started/03-quickstart-server.md): set up `openviking-server` properly. - [MCP Integration Guide](../../docs/en/guides/06-mcp-integration.md): connect OpenViking to Claude Code, Cursor, Claude Desktop, or OpenClaw. - [Claude Code Memory Plugin](../claude-code-memory-plugin/README.md): use OpenViking as long-term memory inside Claude Code. -- [OpenCode Memory Plugin](../opencode-memory-plugin/README.md): use OpenViking memory tools inside OpenCode. +- [OpenCode Plugin](../opencode-plugin/README.md): use OpenViking repository context and memory tools inside OpenCode. - [OpenClaw Plugin](../openclaw-plugin/README.md): integrate OpenViking with OpenClaw. ## Troubleshooting diff --git a/examples/basic-usage/README_CN.md b/examples/basic-usage/README_CN.md index 46d6a9ad16..b24dcebddb 100644 --- a/examples/basic-usage/README_CN.md +++ b/examples/basic-usage/README_CN.md @@ -252,7 +252,7 @@ memories = client.find( - [快速开始:服务端模式](../../docs/zh/getting-started/03-quickstart-server.md):正确启动 `openviking-server`。 - [MCP 集成指南](../../docs/zh/guides/06-mcp-integration.md):接入 Claude Code、Cursor、Claude Desktop、OpenClaw 等 MCP 宿主。 - [Claude Code 记忆插件](../claude-code-memory-plugin/README.md):在 Claude Code 中使用 OpenViking 长期记忆。 -- [OpenCode 记忆插件](../opencode-memory-plugin/README_CN.md):在 OpenCode 中使用 OpenViking 记忆工具。 +- [OpenCode 插件](../opencode-plugin/INSTALL-ZH.md):在 OpenCode 中使用 OpenViking 仓库上下文与记忆工具。 - [OpenClaw 插件](../openclaw-plugin/README_CN.md):与 OpenClaw 集成。 ## 常见问题 diff --git a/examples/opencode-memory-plugin/.gitignore b/examples/opencode-memory-plugin/.gitignore deleted file mode 100644 index c717c024b3..0000000000 --- a/examples/opencode-memory-plugin/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -.DS_Store -openviking-config.json -openviking-memory.log -openviking-session-map.json -*.corrupted.* diff --git a/examples/opencode-memory-plugin/INSTALL-ZH.md b/examples/opencode-memory-plugin/INSTALL-ZH.md deleted file mode 100644 index 6c40783e2a..0000000000 --- a/examples/opencode-memory-plugin/INSTALL-ZH.md +++ /dev/null @@ -1,241 +0,0 @@ -# 为 OpenCode 安装 OpenViking Memory Plugin - -这个示例把 OpenViking 暴露为 OpenCode 可直接调用的记忆工具,并自动把当前对话同步到 OpenViking Session 中。 - -安装完成后,你可以在 OpenCode 中使用这些工具: - -- `memsearch` -- `memread` -- `membrowse` -- `memcommit` - ---- - -## 机制说明 - -这个示例使用的是 OpenCode 的 tool 机制,把 OpenViking 能力显式暴露成 Agent 可调用的工具。 - -更具体一点: - -- Agent 会看到 `memsearch`、`memread`、`membrowse`、`memcommit` 这些显式工具 -- 只有在 Agent 主动调用这些工具时,OpenViking 的内容才会被读取回来 -- 插件还会在后台把 OpenCode session 映射到 OpenViking session,并在合适的时候触发记忆提取 - -这个示例的重点是显式 memory 访问、类文件系统浏览,以及会话到长期记忆的自动同步。 - ---- - -## 前置条件 - -你需要先准备: - -- 已安装 OpenCode -- 已启动 OpenViking HTTP Server -- 可用的 OpenViking API Key(如果服务端启用了认证) - -建议先确认 OpenViking 服务正常运行: - -```bash -openviking-server --config ~/.openviking/ov.conf -``` - -如果你已经在后台启动了服务,也可以直接检查健康状态: - -```bash -curl http://localhost:1933/health -``` - ---- - -## 安装步骤 - -OpenCode 官方文档更推荐把插件放在: - -```bash -~/.config/opencode/plugins -``` - -### Step 1: 创建插件目录 - -```bash -mkdir -p ~/.config/opencode/plugins -``` - -### Step 2: 复制示例文件 - -在 OpenViking 仓库根目录执行: - -```bash -cp examples/opencode-memory-plugin/openviking-memory.ts ~/.config/opencode/plugins/openviking-memory.ts -cp examples/opencode-memory-plugin/openviking-config.example.json ~/.config/opencode/plugins/openviking-config.json -cp examples/opencode-memory-plugin/.gitignore ~/.config/opencode/plugins/.gitignore -``` - -复制后,插件目录里应该至少有这些文件: - -```text -~/.config/opencode/plugins/ -├── .gitignore -├── openviking-config.json -└── openviking-memory.ts -``` - -### Step 3: 配置插件 - -编辑: - -```bash -~/.config/opencode/plugins/openviking-config.json -``` - -示例配置: - -```json -{ - "endpoint": "http://localhost:1933", - "apiKey": "", - "account": "", - "user": "", - "peerId": "", - "enabled": true, - "timeoutMs": 30000, - "autoCommit": { - "enabled": true, - "intervalMinutes": 10 - } -} -``` - -字段说明: - -- `endpoint`: OpenViking 服务地址 -- `apiKey`: 可留空,推荐用环境变量提供 -- `account` / `user`: 多租户身份头 -- `peerId`: 可选;作为请求级 `peer_id` 用于 recall/search 和 session message 写入 -- `enabled`: 是否启用插件 -- `timeoutMs`: 普通请求超时时间 -- `autoCommit.intervalMinutes`: 自动提交 session 的周期 - -### Step 3.5: 关于插件注册 - -这个插件不需要额外写进 `~/.config/opencode/opencode.json`。 - -原因是 OpenCode 会自动扫描 `~/.config/opencode/plugins/` 下面的一级 `*.ts` / `*.js` 文件,`openviking-memory.ts` 放在这个目录顶层即可被发现。 - -### Step 4: 配置 API Key - -推荐使用环境变量,不要把真实 key 写进配置文件: - -```bash -export OPENVIKING_API_KEY="your-api-key-here" -``` - -如果你使用 `zsh`,可以把它写进 `~/.zshrc`: - -```bash -echo 'export OPENVIKING_API_KEY="your-api-key-here"' >> ~/.zshrc -source ~/.zshrc -``` - ---- - -## 启动与验证 - -配置完成后,正常启动 OpenCode 即可。 - -插件初始化后会: - -- 对 OpenViking 做一次 health check -- 为每个 OpenCode session 自动建立对应的 OpenViking session -- 自动把用户消息和 assistant 消息写入 OpenViking -- 按周期触发后台 `commit` - -你可以在会话里尝试: - -```text -请用 memsearch 搜索我之前的偏好 -``` - -或者手动触发一次记忆提取: - -```text -请调用 memcommit -``` - ---- - -## 运行时文件 - -插件运行后,会在插件目录里生成这些本地文件: - -- `~/.config/opencode/plugins/openviking-config.json` -- `~/.config/opencode/plugins/openviking-memory.log` -- `~/.config/opencode/plugins/openviking-session-map.json` - -这些文件都是运行时产物,不建议提交到版本库。示例里的 `.gitignore` 已经帮你排除了它们。 - -如果你明确希望按工作区隔离插件,也可以把这三个文件和 `openviking-memory.ts` 一起放在工作区本地插件目录里。当前实现会把配置和运行时文件统一保存在“插件文件所在目录”。 - ---- - -## 常见问题 - -### 1. 插件没有生效 - -先确认文件位置正确: - -```bash -ls ~/.config/opencode/plugins/ -``` - -至少要能看到: - -- `openviking-memory.ts` -- `openviking-config.json` - -### 2. `Authentication failed` - -通常是 API Key 配置不对。优先检查: - -- `OPENVIKING_API_KEY` 是否已设置 -- 服务端是否启用了认证 -- `endpoint` 是否连到了正确的 OpenViking 服务 - -### 3. `Service unavailable` - -说明插件连不上 OpenViking 服务。检查: - -```bash -curl http://localhost:1933/health -``` - -如果失败,先启动: - -```bash -openviking-server --config ~/.openviking/ov.conf -``` - -### 4. `memcommit` 很慢或经常超时 - -这个示例已经改成了后台 commit task 模式。一般情况下,即使记忆提取比较慢,也不应该再出现“每分钟同步重试一次”的风暴。 - -如果你仍然觉得慢,优先检查的是: - -- OpenViking 服务端的模型配置 -- 服务端所在机器的资源是否吃满 -- `openviking-memory.log` 里是否有持续的 task failure - -### 5. 没有抽出任何 memory - -通常不是插件没工作,而是服务端提取条件不满足。优先检查: - -- OpenViking 的 `vlm` 和 `embedding` 是否已正确配置 -- 当前对话里是否真的有适合沉淀为 memory 的内容 - ---- - -## 相关文件 - -- [README.md](./README.md): English overview -- [openviking-memory.ts](./openviking-memory.ts): plugin implementation -- [openviking-config.example.json](./openviking-config.example.json): config template diff --git a/examples/opencode-memory-plugin/README.md b/examples/opencode-memory-plugin/README.md deleted file mode 100644 index 2017ec3a8d..0000000000 --- a/examples/opencode-memory-plugin/README.md +++ /dev/null @@ -1,259 +0,0 @@ -# OpenViking Memory Plugin for OpenCode - -OpenCode plugin example that exposes OpenViking memories as explicit tools and automatically syncs conversation sessions into OpenViking. - -Chinese install guide: [INSTALL-ZH.md](./INSTALL-ZH.md) - -## Mechanism - -This example uses OpenCode's tool mechanism to expose OpenViking capabilities as explicit agent-callable tools. - -In practice, that means: - -- the agent sees concrete tools and decides when to call them -- OpenViking data is fetched on demand through tool execution instead of being pre-injected into every prompt -- the plugin also keeps an OpenViking session in sync with the OpenCode conversation and triggers background memory extraction with `memcommit` - -This example focuses on explicit memory access, filesystem-style browsing, and session-to-memory synchronization inside OpenCode. - -## What It Does - -- Exposes four memory tools for OpenCode agents: - - `memsearch` - - `memread` - - `membrowse` - - `memcommit` -- Automatically maps each OpenCode session to an OpenViking session -- Streams user and assistant messages into OpenViking -- Uses background `commit` tasks to avoid repeated synchronous timeout failures -- Persists local runtime state for reconnect and recovery - -## Files - -This example contains: - -- `openviking-memory.ts`: the plugin implementation used by OpenCode -- `openviking-config.example.json`: template config -- `.gitignore`: ignores local runtime files after you copy the example into a workspace - -## Prerequisites - -- OpenCode -- OpenViking HTTP Server -- A valid OpenViking API key if your server requires authentication - -Start the server first if it is not already running: - -```bash -openviking-server --config ~/.openviking/ov.conf -``` - -## Install Into OpenCode - -Recommended location from the OpenCode docs: - -```bash -~/.config/opencode/plugins -``` - -Install with: - -```bash -mkdir -p ~/.config/opencode/plugins -cp examples/opencode-memory-plugin/openviking-memory.ts ~/.config/opencode/plugins/openviking-memory.ts -cp examples/opencode-memory-plugin/openviking-config.example.json ~/.config/opencode/plugins/openviking-config.json -cp examples/opencode-memory-plugin/.gitignore ~/.config/opencode/plugins/.gitignore -``` - -Then edit `~/.config/opencode/plugins/openviking-config.json`. - -OpenCode auto-discovers first-level `*.ts` and `*.js` files under `~/.config/opencode/plugins`, so no explicit `plugin` entry is required in `~/.config/opencode/opencode.json`. - -This plugin also works if you intentionally place it in a workspace-local plugin directory, because it stores config and runtime files next to the plugin file itself. - -Recommended: provide the API key and tenant identity via environment variables instead of writing them into the config file: - -```bash -export OPENVIKING_API_KEY="your-api-key-here" -export OPENVIKING_ACCOUNT="default" -export OPENVIKING_USER="opencode" -``` - -## Configuration - -Example config: - -```json -{ - "endpoint": "http://localhost:1933", - "apiKey": "", - "account": "default", - "user": "opencode", - "peerId": "", - "enabled": true, - "timeoutMs": 30000, - "autoCommit": { - "enabled": true, - "intervalMinutes": 10 - } -} -``` - -`account` and `user` are sent as `X-OpenViking-Account` and `X-OpenViking-User` tenant headers on every plugin API request; leave them empty to omit the headers. -`peerId` is sent as request-level `peer_id` for memory recall/search and captured session messages; leave it empty to preserve existing behavior. - -The environment variables `OPENVIKING_API_KEY`, `OPENVIKING_ACCOUNT`, `OPENVIKING_USER`, and `OPENVIKING_PEER_ID` take precedence over the config file. - -## Runtime Files - -After installation, the plugin creates these local files next to the plugin file: - -- `openviking-config.json` -- `openviking-memory.log` -- `openviking-session-map.json` - -These are runtime artifacts and should not be committed. - -## Tools - -### `memsearch` - -Unified search across memories, resources, and skills. - -Parameters: - -- `query`: search query -- `target_uri?`: narrow search to a URI prefix such as `viking://user/memories/` -- `mode?`: `auto | fast | deep` -- `limit?`: max results -- `score_threshold?`: optional minimum score - -### `memread` - -Read content from a specific `viking://` URI. - -Parameters: - -- `uri`: target URI -- `level?`: `auto | abstract | overview | read` - -### `membrowse` - -Browse the OpenViking filesystem layout. - -Parameters: - -- `uri`: target URI -- `view?`: `list | tree | stat` -- `recursive?`: only for `view: "list"` -- `simple?`: only for `view: "list"` - -### `memcommit` - -Trigger immediate memory extraction for the current session. - -Parameters: - -- `session_id?`: optional explicit OpenViking session ID - -Returns background task progress or completion details, including `task_id`, per-category `memories_extracted`, and `archived`. - -## Usage Examples - -Search and then read: - -```typescript -const results = await memsearch({ - query: "user coding preferences", - target_uri: "viking://user/memories/", - mode: "auto" -}) - -const content = await memread({ - uri: results[0].uri, - level: "auto" -}) -``` - -Browse first: - -```typescript -const tree = await membrowse({ - uri: "viking://resources/", - view: "tree" -}) -``` - -Force a mid-session commit: - -```typescript -const result = await memcommit({}) -``` - -## Memory Recall - -The plugin can automatically search OpenViking memories and inject relevant context into each user message before it reaches the LLM. This uses OpenCode's `chat.message` hook to prepend a synthetic memory part to the outgoing message. - -> **Note**: This feature relies on OpenCode's `chat.message` hook contract. The hook signature or behavior may change in future OpenCode versions. - -### How It Works - -1. On every user message, the plugin extracts text from the message parts -2. Searches OpenViking using semantic search (5-second timeout) -3. Ranks results using multi-factor scoring (base score + leaf boost + temporal boost + preference boost + lexical overlap) -4. Deduplicates results (abstract-based for regular memories, URI-based for events/cases) -5. Formats matching memories as a `` XML block -6. Prepends the block as a synthetic text part (`synthetic: true`) on the outgoing message so the memory persists across turns without polluting user input - -If OpenViking is unavailable or the search times out, the message is passed through unchanged. - -### Recall Configuration - -Add an `autoRecall` block to your `openviking-config.json` to customize recall behavior: - -- `enabled`: `boolean` (default: `true`) — enable or disable automatic memory recall -- `limit`: `number` (default: `6`) — maximum number of memories to inject (1–50) -- `scoreThreshold`: `number` (default: `0.15`) — minimum relevance score for a memory to be included (0–1) -- `maxContentChars`: `number` (default: `500`) — maximum characters per individual memory content -- `preferAbstract`: `boolean` (default: `true`) — prefer abstract (L0) content over full (L2) content when available -- `tokenBudget`: `number` (default: `2000`) — approximate total token budget for injected memories (100–10000, estimated at 4 chars per token) - -### Example Config with Recall - -```json -{ - "endpoint": "http://localhost:1933", - "apiKey": "", - "account": "default", - "user": "opencode", - "enabled": true, - "timeoutMs": 30000, - "autoCommit": { - "enabled": true, - "intervalMinutes": 10 - }, - "autoRecall": { - "enabled": true, - "limit": 6, - "scoreThreshold": 0.15, - "maxContentChars": 500, - "preferAbstract": true, - "tokenBudget": 2000 - } -} -``` - -To disable recall, set `"autoRecall": { "enabled": false }`. - -## Notes for Reviewers - -- The plugin is designed to run as a first-level `*.ts` file in the OpenCode plugins directory -- It intentionally keeps runtime config, logs, and session maps outside the repository example -- It uses OpenViking background commit tasks to avoid repeated timeout/retry loops during long memory extraction - -## Troubleshooting - -- Plugin not loading: confirm the file exists at `~/.config/opencode/plugins/openviking-memory.ts` -- Service unavailable: confirm `openviking-server` is running and reachable at the configured endpoint -- Authentication failed: check `OPENVIKING_API_KEY` or `openviking-config.json` -- No memories extracted: check that your OpenViking server has working `vlm` and `embedding` configuration diff --git a/examples/opencode-memory-plugin/README_CN.md b/examples/opencode-memory-plugin/README_CN.md deleted file mode 100644 index 61786acd2a..0000000000 --- a/examples/opencode-memory-plugin/README_CN.md +++ /dev/null @@ -1,204 +0,0 @@ -# OpenCode 的 OpenViking 记忆插件 - -OpenCode 插件示例,将 OpenViking 记忆作为显式工具暴露,并自动同步对话会话到 OpenViking。 - -安装指南:[INSTALL-ZH.md](./INSTALL-ZH.md) - -## 机制 - -本示例使用 OpenCode 的工具机制将 OpenViking 功能暴露为agent可调用的显式工具。 - -实际上,这意味着: - -- agent可以看到具体的工具并决定何时调用它们 -- OpenViking 数据通过工具执行按需获取,而不是预先注入到每个提示中 -- 插件还保持 OpenViking 会话与 OpenCode 对话同步,并使用 `memcommit` 触发后台记忆提取 - -本示例专注于 OpenCode 中的显式记忆访问、文件系统风格的浏览和会话到记忆的同步。 - -## 功能 - -- 为 OpenCode agent暴露四个记忆工具: - - `memsearch` - - `memread` - - `membrowse` - - `memcommit` -- 自动将每个 OpenCode 会话映射到 OpenViking 会话 -- 将用户和助手消息流式传输到 OpenViking -- 使用后台 `commit` 任务避免重复的同步导致超时失败 -- 持久化本地运行时状态以支持重新连接和恢复 - -## 文件 - -本示例包含: - -- `openviking-memory.ts`:OpenCode 使用的插件实现 -- `openviking-config.example.json`:配置模板 -- `.gitignore`:复制到工作区后忽略本地运行时文件 - -## 前置要求 - -- OpenCode -- OpenViking HTTP 服务器 -- 如果您的服务器需要身份验证,则需要有效的 OpenViking API 密钥 - -如果服务器尚未运行,请先启动: - -```bash -openviking-server --config ~/.openviking/ov.conf -``` - -## 安装到 OpenCode - -OpenCode 文档推荐的安装位置: - -```bash -~/.config/opencode/plugins -``` - -使用以下命令安装: - -```bash -mkdir -p ~/.config/opencode/plugins -cp examples/opencode-memory-plugin/openviking-memory.ts ~/.config/opencode/plugins/openviking-memory.ts -cp examples/opencode-memory-plugin/openviking-config.example.json ~/.config/opencode/plugins/openviking-config.json -cp examples/opencode-memory-plugin/.gitignore ~/.config/opencode/plugins/.gitignore -``` - -然后编辑 `~/.config/opencode/plugins/openviking-config.json`。 - -OpenCode 会自动发现 `~/.config/opencode/plugins` 下一级目录中的 `*.ts` 和 `*.js` 文件,因此不需要在 `~/.config/opencode/opencode.json` 中显式配置 `plugin` 条目。 - -如果您有意将插件放置在工作区本地插件目录中,此插件也可以使用,因为它会将配置和运行时文件存储在插件文件旁边。 - -推荐:通过环境变量提供 API 密钥和租户身份,而不是将其写入配置文件: - -```bash -export OPENVIKING_API_KEY="your-api-key-here" -export OPENVIKING_ACCOUNT="default" -export OPENVIKING_USER="opencode" -``` - -## 配置 - -配置示例: - -```json -{ - "endpoint": "http://localhost:1933", - "apiKey": "", - "account": "default", - "user": "opencode", - "peerId": "", - "enabled": true, - "timeoutMs": 30000, - "autoCommit": { - "enabled": true, - "intervalMinutes": 10 - } -} -``` - -`account` 和 `user` 会作为 `X-OpenViking-Account` 与 `X-OpenViking-User` 租户头随每次插件 API 请求发送;留空则不会附加这些头。 -`peerId` 会作为请求级 `peer_id` 用于 memory recall/search 和 session message 写入;留空则保持现有行为。 - -环境变量 `OPENVIKING_API_KEY`、`OPENVIKING_ACCOUNT`、`OPENVIKING_USER` 和 `OPENVIKING_PEER_ID` 优先于配置文件。 - -## 运行时文件 - -安装后,插件会在插件文件旁边创建这些本地文件: - -- `openviking-config.json` -- `openviking-memory.log` -- `openviking-session-map.json` - -这些是运行时生成的文件,不应提交到版本控制。 - -## 工具 - -### `memsearch` - -在记忆、资源和技能中进行统一搜索。 - -参数: - -- `query`:搜索查询 -- `target_uri?`:将搜索限制在 URI 前缀,如 `viking://user/memories/` -- `mode?`:`auto | fast | deep` -- `limit?`:最大结果数 -- `score_threshold?`:可选的最小分数 - -### `memread` - -从特定的 `viking://` URI 读取内容。 - -参数: - -- `uri`:目标 URI -- `level?`:`auto | abstract | overview | read` - -### `membrowse` - -浏览 OpenViking 文件系统布局。 - -参数: - -- `uri`:目标 URI -- `view?`:`list | tree | stat` -- `recursive?`:仅用于 `view: "list"` -- `simple?`:仅用于 `view: "list"` - -### `memcommit` - -触发当前会话的立即记忆提取。 - -参数: - -- `session_id?`:可选的显式 OpenViking 会话 ID - -返回后台任务进度或完成详情,包括 `task_id`、分类计数 `memories_extracted` 和 `archived`。 - -## 使用示例 - -搜索然后读取: - -```typescript -const results = await memsearch({ - query: "user coding preferences", - target_uri: "viking://user/memories/", - mode: "auto" -}) - -const content = await memread({ - uri: results[0].uri, - level: "auto" -}) -``` - -先浏览: - -```typescript -const tree = await membrowse({ - uri: "viking://resources/", - view: "tree" -}) -``` - -强制进行会话中期提交: - -```typescript -const result = await memcommit({}) -``` - -## 审查者说明 - -- 插件设计为作为 OpenCode 插件目录中的一级 `*.ts` 文件运行 -- 有意将运行时配置、日志和会话映射保留在仓库示例之外 -- 使用 OpenViking 后台提交任务来避免长时间记忆提取期间的重复超时/重试循环 - -## 故障排除 - -- 插件未加载:确认文件存在于 `~/.config/opencode/plugins/openviking-memory.ts` -- 服务不可用:确认 `openviking-server` 正在运行并且在配置的端点可访问 -- 身份验证失败:检查 `OPENVIKING_API_KEY` 或 `openviking-config.json` -- 未提取记忆:检查您的 OpenViking 服务器是否有正常工作的 `vlm` 和 `embedding` 配置 diff --git a/examples/opencode-memory-plugin/openviking-config.example.json b/examples/opencode-memory-plugin/openviking-config.example.json deleted file mode 100644 index cd5118470a..0000000000 --- a/examples/opencode-memory-plugin/openviking-config.example.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "endpoint": "http://localhost:1933", - "apiKey": "your-api-key-here", - "account": "default", - "user": "opencode", - "enabled": true, - "timeoutMs": 30000, - "autoCommit": { - "enabled": true, - "intervalMinutes": 10 - } -} diff --git a/examples/opencode-memory-plugin/openviking-memory.ts b/examples/opencode-memory-plugin/openviking-memory.ts deleted file mode 100644 index 21770e2fcf..0000000000 --- a/examples/opencode-memory-plugin/openviking-memory.ts +++ /dev/null @@ -1,2391 +0,0 @@ -/** - * OpenViking Memory Plugin for OpenCode - * - * Exposes OpenViking's semantic memory capabilities as tools for AI agents. - * Supports user profiles, preferences, entities, events, cases, and patterns. - * - * Contributed by: littlelory@convolens.net - * GitHub: https://github.com/convolens - * We are building Enterprise AI assistant for consumer brands,with process awareness and memory, - * Serving product development to pre-launch lifecycle - * Copyright 2026 Convolens. - */ - -import type { Hooks, PluginInput } from "@opencode-ai/plugin" -import { tool } from "@opencode-ai/plugin" -import * as fs from "fs" -import * as path from "path" -import { fileURLToPath } from "url" - -const z = tool.schema -const pluginFilePath = fileURLToPath(import.meta.url) -const pluginFileDir = path.dirname(pluginFilePath) - -// ============================================================================ -// Session State Management -// ============================================================================ - -interface SessionMapping { - ovSessionId: string - createdAt: number - capturedMessages: Set // Track captured message IDs to avoid duplicates - messageRoles: Map // Track message ID → role mapping - pendingMessages: Map // Track message ID → content for messages waiting for completion - sendingMessages: Set // Track message IDs currently being sent to avoid duplicate writes - lastCommitTime?: number - commitInFlight?: boolean - commitTaskId?: string - commitStartedAt?: number - pendingCleanup?: boolean -} - -// Persisted format for session mapping (for disk storage) -interface SessionMappingPersisted { - ovSessionId: string - createdAt: number - capturedMessages: string[] // Set → Array - messageRoles: [string, "user" | "assistant"][] // Map → Array of tuples - pendingMessages: [string, string][] // Map → Array of tuples - lastCommitTime?: number - commitInFlight?: boolean - commitTaskId?: string - commitStartedAt?: number - pendingCleanup?: boolean -} - -// Session map file format -interface SessionMapFile { - version: 1 - sessions: Record // opencodeSessionId → mapping - lastSaved: number // timestamp -} - -// Map: OpenCode session ID → OpenViking session ID -const sessionMap = new Map() - -// Buffer for messages that arrive before session mapping is established -interface BufferedMessage { - messageId: string - content?: string - role?: "user" | "assistant" - timestamp: number -} -const sessionMessageBuffer = new Map() // sessionId → messages -const MAX_BUFFERED_MESSAGES_PER_SESSION = 100 -const BUFFERED_MESSAGE_TTL_MS = 15 * 60 * 1000 -const BUFFER_CLEANUP_INTERVAL_MS = 30 * 1000 -let lastBufferCleanupAt = 0 - -// ============================================================================ -// Logging -// ============================================================================ - -let logFilePath: string | null = null -let pluginDataDir: string | null = null - -function ensurePluginDataDir(): string | null { - const pluginDir = pluginFileDir - try { - fs.mkdirSync(pluginDir, { recursive: true }) - return pluginDir - } catch (error) { - console.error("Failed to ensure plugin directory:", error) - return null - } -} - -function initLogger() { - const pluginDir = ensurePluginDataDir() - if (!pluginDir) return - pluginDataDir = pluginDir - logFilePath = path.join(pluginDir, "openviking-memory.log") -} - -function safeStringify(obj: any): any { - if (obj === null || obj === undefined) return obj - if (typeof obj !== "object") return obj - - // Handle arrays - if (Array.isArray(obj)) { - return obj.map((item) => safeStringify(item)) - } - - // Handle objects - const result: any = {} - for (const key in obj) { - if (Object.prototype.hasOwnProperty.call(obj, key)) { - const value = obj[key] - if (typeof value === "function") { - result[key] = "[Function]" - } else if (typeof value === "object" && value !== null) { - try { - result[key] = safeStringify(value) - } catch { - result[key] = "[Circular or Non-serializable]" - } - } else { - result[key] = value - } - } - } - return result -} - -function log(level: "INFO" | "ERROR" | "DEBUG", toolName: string, message: string, data?: any) { - if (!logFilePath) return - - const timestamp = new Date().toISOString() - const logEntry = { - timestamp, - level, - tool: toolName, - message, - ...(data && { data: safeStringify(data) }), - } - - try { - const logLine = JSON.stringify(logEntry) + "\n" - fs.appendFileSync(logFilePath, logLine, "utf-8") - } catch (error) { - console.error("Failed to write to log file:", error) - } -} - -// ============================================================================ -// Session Map Persistence -// ============================================================================ - -let sessionMapPath: string | null = null - -function initSessionMapPath() { - const pluginDir = pluginDataDir ?? ensurePluginDataDir() - if (!pluginDir) return - pluginDataDir = pluginDir - sessionMapPath = path.join(pluginDir, "openviking-session-map.json") -} - -function serializeSessionMapping(mapping: SessionMapping): SessionMappingPersisted { - 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: SessionMappingPersisted): SessionMapping { - 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, - } -} - -async function loadSessionMap(): Promise { - if (!sessionMapPath) return - - try { - if (!fs.existsSync(sessionMapPath)) { - log("INFO", "persistence", "No session map file found, starting fresh") - return - } - - const content = await fs.promises.readFile(sessionMapPath, "utf-8") - const data: SessionMapFile = JSON.parse(content) - - 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, - last_saved: new Date(data.lastSaved).toISOString() - }) - } catch (error: any) { - log("ERROR", "persistence", "Failed to load session map", { error: error.message }) - - // Backup corrupted file - if (fs.existsSync(sessionMapPath)) { - const backupPath = `${sessionMapPath}.corrupted.${Date.now()}` - await fs.promises.rename(sessionMapPath, backupPath) - log("INFO", "persistence", "Corrupted file backed up", { backup: backupPath }) - } - } -} - -async function saveSessionMap(): Promise { - if (!sessionMapPath) return - - try { - const sessions: Record = {} - for (const [opencodeSessionId, mapping] of sessionMap.entries()) { - sessions[opencodeSessionId] = serializeSessionMapping(mapping) - } - - const data: SessionMapFile = { - version: 1, - sessions, - lastSaved: Date.now() - } - - // Atomic write: temp file + rename - const tempPath = sessionMapPath + '.tmp' - await fs.promises.writeFile(tempPath, JSON.stringify(data, null, 2), "utf-8") - await fs.promises.rename(tempPath, sessionMapPath) - - log("DEBUG", "persistence", "Session map saved", { count: sessionMap.size }) - } catch (error: any) { - log("ERROR", "persistence", "Failed to save session map", { error: error.message }) - } -} - -// Debounced save to reduce disk I/O -let saveTimer: NodeJS.Timeout | null = null - -function debouncedSaveSessionMap(): void { - if (saveTimer) clearTimeout(saveTimer) - saveTimer = setTimeout(() => { - saveSessionMap().catch(error => { - log("ERROR", "persistence", "Debounced save failed", { error: error.message }) - }) - }, 300) -} - -// ============================================================================ -// Configuration -// ============================================================================ - -interface OpenVikingConfig { - endpoint: string - apiKey: string - account: string - user: string - peerId: string - enabled: boolean - timeoutMs: number - autoCommit?: { - enabled: boolean - intervalMinutes: number - } - // Auto memory recall configuration - autoRecall?: { - enabled: boolean - limit: number - scoreThreshold: number - maxContentChars: number - preferAbstract: boolean - tokenBudget: number - } -} - -// ============================================================================ -// API Response Types -// ============================================================================ - -interface OpenVikingResponse { - status: string - result?: T - error?: string | { code?: string; message?: string; details?: Record } - time?: number - usage?: Record -} - -interface SearchResult { - memories: any[] - resources: any[] - skills: any[] - total: number - query_plan?: string -} - -type MemoryCounts = number | Record - -interface CommitResult { - session_id: string - status: string - memories_extracted?: MemoryCounts - active_count_updated?: number - archived?: boolean - task_id?: string - message?: string - stats?: { - total_turns?: number - contexts_used?: number - skills_used?: number - memories_extracted?: number - } -} - -interface SessionResult { - session_id: string -} - -interface TaskResult { - task_id: string - task_type: string - status: "pending" | "running" | "completed" | "failed" - created_at: number - updated_at: number - resource_id?: string - result?: { - session_id?: string - memories_extracted?: MemoryCounts - archived?: boolean - } - error?: string | null -} - -type CommitStartResult = - | { mode: "background"; taskId: string } - | { mode: "completed"; result: CommitResult } - -const DEFAULT_CONFIG: OpenVikingConfig = { - endpoint: "http://localhost:1933", - apiKey: "", - account: "", - user: "", - peerId: "", - enabled: true, - timeoutMs: 30000, - autoCommit: { - enabled: true, - intervalMinutes: 10 - }, - autoRecall: { - enabled: true, - limit: 6, - scoreThreshold: 0.15, - maxContentChars: 500, - preferAbstract: true, - tokenBudget: 2000, - }, -} - -function totalMemoriesExtracted(memories?: MemoryCounts): number { - 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) -} - -function totalMemoriesFromResult(result?: { - memories_extracted?: MemoryCounts -} | null): number { - return totalMemoriesExtracted(result?.memories_extracted) -} - -function clampRecallConfig(recall: NonNullable): void { - recall.limit = Math.max(1, Math.min(50, Math.round(recall.limit))) - recall.scoreThreshold = Math.max(0, Math.min(1, recall.scoreThreshold)) - recall.tokenBudget = Math.max(100, Math.min(10000, Math.round(recall.tokenBudget))) -} - -function loadConfig(): OpenVikingConfig { - const configPath = path.join(pluginFileDir, "openviking-config.json") - - try { - if (fs.existsSync(configPath)) { - const fileContent = fs.readFileSync(configPath, "utf-8") - const fileConfig = JSON.parse(fileContent) - const config = { - ...DEFAULT_CONFIG, - ...fileConfig, - autoCommit: fileConfig.autoCommit - ? { - ...DEFAULT_CONFIG.autoCommit, - ...fileConfig.autoCommit, - } - : DEFAULT_CONFIG.autoCommit - ? { ...DEFAULT_CONFIG.autoCommit } - : undefined, - autoRecall: fileConfig.autoRecall - ? { - ...DEFAULT_CONFIG.autoRecall, - ...fileConfig.autoRecall, - } - : DEFAULT_CONFIG.autoRecall - ? { ...DEFAULT_CONFIG.autoRecall } - : undefined, - } - if (config.autoCommit) { - config.autoCommit.intervalMinutes = getAutoCommitIntervalMinutes(config) - } - - // Validate recall config ranges - if (config.autoRecall) { - clampRecallConfig(config.autoRecall) - } - - // Environment variable takes precedence over config file - 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_PEER_ID) { - config.peerId = process.env.OPENVIKING_PEER_ID - } - - return config - } - } catch (error) { - console.warn(`Failed to load OpenViking config from ${configPath}:`, error) - } - - // Check environment variable even if config file doesn't exist - const config = { - ...DEFAULT_CONFIG, - autoCommit: DEFAULT_CONFIG.autoCommit - ? { ...DEFAULT_CONFIG.autoCommit } - : undefined, - autoRecall: DEFAULT_CONFIG.autoRecall - ? { ...DEFAULT_CONFIG.autoRecall } - : undefined, - } - 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_PEER_ID) { - config.peerId = process.env.OPENVIKING_PEER_ID - } - if (config.autoCommit) { - config.autoCommit.intervalMinutes = getAutoCommitIntervalMinutes(config) - } - - return config -} - -// ============================================================================ -// HTTP Client -// ============================================================================ - -interface HttpRequestOptions { - method: "GET" | "POST" | "PUT" | "DELETE" - endpoint: string - body?: any - timeoutMs?: number - abortSignal?: AbortSignal -} - -function buildOpenVikingHeaders(config: OpenVikingConfig, includeContentType = true): Record { - const headers: Record = {} - - if (includeContentType) { - headers["Content-Type"] = "application/json" - } - - if (config.apiKey) { - headers["X-API-Key"] = config.apiKey - } - if (config.account) { - headers["X-OpenViking-Account"] = config.account - } - if (config.user) { - headers["X-OpenViking-User"] = config.user - } - - return headers -} - -async function makeRequest(config: OpenVikingConfig, options: HttpRequestOptions): Promise { - const url = `${config.endpoint}${options.endpoint}` - const headers = buildOpenVikingHeaders(config) - - const controller = new AbortController() - const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? config.timeoutMs) - - // Chain with tool's abort signal if provided - const signal = options.abortSignal - ? AbortSignal.any([options.abortSignal, controller.signal]) - : controller.signal - - try { - const response = await fetch(url, { - method: options.method, - headers, - body: options.body ? JSON.stringify(options.body) : undefined, - signal, - }) - - clearTimeout(timeout) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage: string - try { - const errorJson = JSON.parse(errorText) - // Handle case where error/message might be objects - const rawError = errorJson.error || errorJson.message - if (typeof rawError === "string") { - errorMessage = rawError - } else if (rawError && typeof rawError === "object") { - errorMessage = JSON.stringify(rawError) - } else { - errorMessage = errorText - } - } catch { - errorMessage = errorText - } - - switch (response.status) { - case 401: - case 403: - throw new Error("Authentication failed. Please check API key configuration.") - case 404: - throw new Error(`Resource not found: ${options.endpoint}`) - case 500: - throw new Error(`OpenViking server error: ${errorMessage}`) - default: - throw new Error(`Request failed (${response.status}): ${errorMessage}`) - } - } - - return (await response.json()) as T - } catch (error: any) { - clearTimeout(timeout) - - 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}. Please check if the service is running (try: openviking-server).`, - ) - } - - throw error - } -} - -function getResponseErrorMessage(error: OpenVikingResponse["error"]): string { - if (!error) return "Unknown OpenViking error" - if (typeof error === "string") return error - return error.message || error.code || "Unknown OpenViking error" -} - -function unwrapResponse(response: OpenVikingResponse): T { - 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 as T -} - -async function checkServiceHealth(config: OpenVikingConfig): Promise { - try { - const response = await fetch(`${config.endpoint}/health`, { - method: "GET", - signal: AbortSignal.timeout(3000), - }) - return response.ok - } catch (error: any) { - log("ERROR", "health", "OpenViking health check failed", { - endpoint: config.endpoint, - error: error.message, - }) - return false - } -} - -// ============================================================================ -// Session Lifecycle Helpers -// ============================================================================ - -function mergeMessageContent(existing: string | undefined, incoming: string): string { - 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: string, - messageId: string, - updates: Partial>, -): void { - const now = Date.now() - - if (now - lastBufferCleanupAt >= BUFFER_CLEANUP_INTERVAL_MS) { - for (const [bufferedSessionId, bufferedMessages] of sessionMessageBuffer.entries()) { - const freshMessages = bufferedMessages.filter((message) => now - message.timestamp <= BUFFERED_MESSAGE_TTL_MS) - if (freshMessages.length === 0) { - sessionMessageBuffer.delete(bufferedSessionId) - continue - } - if (freshMessages.length !== bufferedMessages.length) { - sessionMessageBuffer.set(bufferedSessionId, freshMessages) - } - } - lastBufferCleanupAt = now - } - - const existingBuffer = sessionMessageBuffer.get(sessionId) ?? [] - const freshBuffer = existingBuffer.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: number): void { - for (const [sessionId, buffer] of sessionMessageBuffer.entries()) { - if (sessionMap.has(sessionId)) { - continue - } - - const oldestMessage = buffer[0] - if (!oldestMessage) { - sessionMessageBuffer.delete(sessionId) - continue - } - - if (now - oldestMessage.timestamp <= BUFFERED_MESSAGE_TTL_MS * 2) { - continue - } - - log("INFO", "buffer", "Cleaning up orphaned message buffer", { - session_id: sessionId, - buffer_age_ms: now - oldestMessage.timestamp, - message_count: buffer.length, - }) - sessionMessageBuffer.delete(sessionId) - } -} - -function hasUnsavedSessionWork(mapping: SessionMapping): boolean { - return mapping.capturedMessages.size > 0 || - mapping.pendingMessages.size > 0 || - mapping.commitInFlight === true -} - -function getAutoCommitIntervalMinutes(config: OpenVikingConfig): number { - const configured = Number(config.autoCommit?.intervalMinutes ?? DEFAULT_CONFIG.autoCommit?.intervalMinutes ?? 10) - if (!Number.isFinite(configured)) { - return DEFAULT_CONFIG.autoCommit?.intervalMinutes ?? 10 - } - return Math.max(1, configured) -} - -function resolveEventSessionId(event: any): string | undefined { - return event?.properties?.info?.id - ?? event?.properties?.sessionID - ?? event?.properties?.sessionId -} - -/** - * Create or connect to OpenViking session for an OpenCode session - */ -async function ensureOpenVikingSession( - opencodeSessionId: string, - config: OpenVikingConfig, -): Promise { - const existingMapping = sessionMap.get(opencodeSessionId) - const knownSessionId = existingMapping?.ovSessionId - - if (knownSessionId) { - try { - const response = await makeRequest>(config, { - method: "GET", - endpoint: `/api/v1/sessions/${knownSessionId}`, - timeoutMs: 5000, - }) - const result = unwrapResponse(response) - if (result) { - log("INFO", "session", "Reconnected to persisted OpenViking session", { - opencode_session: opencodeSessionId, - openviking_session: knownSessionId, - }) - return knownSessionId - } - } catch (error: any) { - log("INFO", "session", "Persisted OpenViking session unavailable, creating a new one", { - opencode_session: opencodeSessionId, - openviking_session: knownSessionId, - error: error.message, - }) - } - } - - try { - const createResponse = await makeRequest>(config, { - method: "POST", - endpoint: "/api/v1/sessions", - body: {}, - timeoutMs: 5000, - }) - - const sessionId = unwrapResponse(createResponse)?.session_id - if (!sessionId) { - throw new Error("OpenViking did not return a session_id") - } - - log("INFO", "session", "Created new OpenViking session", { - opencode_session: opencodeSessionId, - openviking_session: sessionId, - }) - return sessionId - } catch (error: any) { - log("ERROR", "session", "Failed to create OpenViking session", { - opencode_session: opencodeSessionId, - error: error.message, - }) - return null - } -} - -async function sleep(ms: number, abortSignal?: AbortSignal): Promise { - await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - abortSignal?.removeEventListener("abort", onAbort) - resolve() - }, ms) - - function onAbort() { - clearTimeout(timer) - reject(new Error("Operation aborted")) - } - - abortSignal?.addEventListener("abort", onAbort, { once: true }) - }) -} - -async function findRunningCommitTaskId( - ovSessionId: string, - config: OpenVikingConfig, -): Promise { - 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) ?? [] - const runningTask = tasks.find((task) => task.status === "pending" || task.status === "running") - return runningTask?.task_id - } catch (error: any) { - log("ERROR", "session", "Failed to query running commit tasks", { - openviking_session: ovSessionId, - error: error.message, - }) - return undefined - } -} - -function clearCommitState(mapping: SessionMapping): void { - mapping.commitInFlight = false - mapping.commitTaskId = undefined - mapping.commitStartedAt = undefined -} - -function isMissingCommitTaskError(error: unknown): boolean { - if (!(error instanceof Error)) { - return false - } - - const message = error.message.toLowerCase() - return message.includes("resource not found") || message.includes("not found") -} - -let backgroundCommitSupported: boolean | null = null -const COMMIT_TIMEOUT_MS = 180000 - -async function detectBackgroundCommitSupport(config: OpenVikingConfig): Promise { - if (backgroundCommitSupported !== null) { - return backgroundCommitSupported - } - - const headers = buildOpenVikingHeaders(config, false) - - try { - const response = await fetch(`${config.endpoint}/api/v1/tasks?limit=1`, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) - backgroundCommitSupported = response.ok - } catch { - backgroundCommitSupported = false - } - - log( - "INFO", - "session", - backgroundCommitSupported - ? "Detected background commit API support" - : "Detected legacy synchronous commit API", - { endpoint: config.endpoint }, - ) - return backgroundCommitSupported -} - -async function finalizeCommitSuccess( - mapping: SessionMapping, - opencodeSessionId: string, - config: OpenVikingConfig, -): Promise { - mapping.lastCommitTime = Date.now() - mapping.capturedMessages.clear() - clearCommitState(mapping) - debouncedSaveSessionMap() - - await flushPendingMessages(opencodeSessionId, mapping, config) - - if (mapping.pendingCleanup) { - if (hasUnsavedSessionWork(mapping)) { - debouncedSaveSessionMap() - return - } - - sessionMap.delete(opencodeSessionId) - sessionMessageBuffer.delete(opencodeSessionId) - await saveSessionMap() - log("INFO", "session", "Cleaned up session mapping after commit completion", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - }) - } -} - -async function runSynchronousCommit( - mapping: SessionMapping, - opencodeSessionId: string, - config: OpenVikingConfig, - abortSignal?: AbortSignal, -): Promise { - mapping.commitInFlight = true - mapping.commitTaskId = undefined - mapping.commitStartedAt = Date.now() - debouncedSaveSessionMap() - - try { - const response = await makeRequest>(config, { - method: "POST", - endpoint: `/api/v1/sessions/${mapping.ovSessionId}/commit`, - timeoutMs: Math.max(config.timeoutMs, COMMIT_TIMEOUT_MS), - abortSignal, - }) - const result = unwrapResponse(response) - - log("INFO", "session", "OpenViking synchronous commit completed", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - memories_extracted: totalMemoriesFromResult(result), - archived: result?.archived ?? false, - }) - - await finalizeCommitSuccess(mapping, opencodeSessionId, config) - return result - } catch (error: any) { - clearCommitState(mapping) - debouncedSaveSessionMap() - throw error - } -} - -async function flushPendingMessages( - opencodeSessionId: string, - mapping: SessionMapping, - config: OpenVikingConfig, -): Promise { - 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 || !content.trim()) { - continue - } - - mapping.sendingMessages.add(messageId) - try { - log("DEBUG", "message", "Committing pending message content", { - session_id: opencodeSessionId, - message_id: messageId, - role, - content_length: content.length, - }) - - const success = await addMessageToSession( - mapping.ovSessionId, - role, - content, - config - ) - - if (success) { - const latestContent = mapping.pendingMessages.get(messageId) - if (latestContent && latestContent !== content) { - log("DEBUG", "message", "Message changed during send; keeping latest content pending", { - session_id: opencodeSessionId, - message_id: messageId, - role, - previous_length: content.length, - latest_length: latestContent.length, - }) - } else { - mapping.capturedMessages.add(messageId) - mapping.pendingMessages.delete(messageId) - debouncedSaveSessionMap() - log("INFO", "message", `${role} message captured successfully`, { - session_id: opencodeSessionId, - message_id: messageId, - role, - }) - } - } - } finally { - mapping.sendingMessages.delete(messageId) - } - } -} - -async function startBackgroundCommit( - mapping: SessionMapping, - opencodeSessionId: string, - config: OpenVikingConfig, - abortSignal?: AbortSignal, -): Promise { - if (mapping.commitInFlight && mapping.commitTaskId) { - return { mode: "background", taskId: mapping.commitTaskId } - } - - const supportsBackgroundCommit = await detectBackgroundCommitSupport(config) - if (!supportsBackgroundCommit) { - try { - const result = await runSynchronousCommit(mapping, opencodeSessionId, config, abortSignal) - return { mode: "completed", result } - } catch (error: any) { - log("ERROR", "session", "Failed to run synchronous commit", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - error: error.message, - }) - return null - } - } - - try { - const response = await makeRequest>(config, { - method: "POST", - endpoint: `/api/v1/sessions/${mapping.ovSessionId}/commit?wait=false`, - timeoutMs: 5000, - abortSignal, - }) - const data = unwrapResponse(response) - const taskId = data?.task_id - if (!taskId) { - throw new Error("OpenViking did not return a background task id") - } - - mapping.commitInFlight = true - mapping.commitTaskId = taskId - mapping.commitStartedAt = Date.now() - debouncedSaveSessionMap() - - log("INFO", "session", "OpenViking background commit accepted", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: taskId, - }) - return { mode: "background", taskId } - } catch (error: any) { - if (error.message?.includes("already has a commit in progress")) { - const taskId = await findRunningCommitTaskId(mapping.ovSessionId, config) - if (taskId) { - mapping.commitInFlight = true - mapping.commitTaskId = taskId - mapping.commitStartedAt = mapping.commitStartedAt ?? Date.now() - debouncedSaveSessionMap() - log("INFO", "session", "Recovered existing background commit task", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: taskId, - }) - return { mode: "background", taskId } - } - } - - if ( - error.message?.includes("Request timeout") || - error.message?.includes("background task id") - ) { - backgroundCommitSupported = false - try { - const result = await runSynchronousCommit(mapping, opencodeSessionId, config, abortSignal) - return { mode: "completed", result } - } catch (fallbackError: any) { - log("ERROR", "session", "Failed to fall back to synchronous commit", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - error: fallbackError.message, - }) - } - } - - log("ERROR", "session", "Failed to start OpenViking background commit", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - error: error.message, - }) - return null - } -} - -async function pollCommitTaskOnce( - mapping: SessionMapping, - opencodeSessionId: string, - config: OpenVikingConfig, -): Promise { - if (!mapping.commitInFlight) { - return "unknown" - } - - if (!mapping.commitTaskId) { - const recoveredTaskId = await findRunningCommitTaskId(mapping.ovSessionId, config) - if (!recoveredTaskId) { - log("INFO", "session", "Clearing stale in-flight commit without task id", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - }) - clearCommitState(mapping) - debouncedSaveSessionMap() - return "unknown" - } - - mapping.commitTaskId = recoveredTaskId - debouncedSaveSessionMap() - } - - try { - const response = await makeRequest>(config, { - method: "GET", - endpoint: `/api/v1/tasks/${mapping.commitTaskId}`, - timeoutMs: 5000, - }) - const task = unwrapResponse(response) - - if (task.status === "pending" || task.status === "running") { - return task.status - } - - if (task.status === "completed") { - const memoriesExtracted = totalMemoriesFromResult(task.result) - const archived = task.result?.archived ?? false - - log("INFO", "session", "OpenViking background commit completed", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: task.task_id, - memories_extracted: memoriesExtracted, - archived, - }) - - await finalizeCommitSuccess(mapping, opencodeSessionId, config) - - return task.status - } - - log("ERROR", "session", "OpenViking background commit failed", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: task.task_id, - error: task.error, - }) - - clearCommitState(mapping) - debouncedSaveSessionMap() - - if (mapping.pendingCleanup && !hasUnsavedSessionWork(mapping)) { - sessionMap.delete(opencodeSessionId) - sessionMessageBuffer.delete(opencodeSessionId) - await saveSessionMap() - log("INFO", "session", "Cleaned up session mapping after failed commit", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - }) - } - - return task.status - } catch (error: unknown) { - if (isMissingCommitTaskError(error)) { - log("INFO", "session", "Commit task disappeared; clearing stale state", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: mapping.commitTaskId, - }) - clearCommitState(mapping) - debouncedSaveSessionMap() - return "unknown" - } - - log("ERROR", "session", "Failed to poll OpenViking background commit", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: mapping.commitTaskId, - error: error instanceof Error ? error.message : String(error), - }) - return "unknown" - } -} - -async function waitForCommitCompletion( - mapping: SessionMapping, - opencodeSessionId: string, - config: OpenVikingConfig, - abortSignal?: AbortSignal, - timeoutMs = 180000, -): Promise { - 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) { - const recoveredTaskId = await findRunningCommitTaskId(mapping.ovSessionId, config) - if (!recoveredTaskId) { - clearCommitState(mapping) - debouncedSaveSessionMap() - return null - } - - mapping.commitTaskId = recoveredTaskId - debouncedSaveSessionMap() - } - - try { - const response = await makeRequest>(config, { - method: "GET", - endpoint: `/api/v1/tasks/${mapping.commitTaskId}`, - timeoutMs: 5000, - abortSignal, - }) - const task = unwrapResponse(response) - - if (task.status === "completed") { - const memoriesExtracted = totalMemoriesFromResult(task.result) - const archived = task.result?.archived ?? false - - await finalizeCommitSuccess(mapping, opencodeSessionId, config) - - log("INFO", "memcommit", "Background commit completed while waiting", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: task.task_id, - memories_extracted: memoriesExtracted, - archived, - }) - return task - } - - if (task.status === "failed") { - clearCommitState(mapping) - debouncedSaveSessionMap() - throw new Error(task.error || "Background commit failed") - } - - await sleep(2000, abortSignal) - } catch (error: unknown) { - if (isMissingCommitTaskError(error)) { - log("INFO", "session", "Commit task disappeared while waiting; clearing stale state", { - openviking_session: mapping.ovSessionId, - opencode_session: opencodeSessionId, - task_id: mapping.commitTaskId, - }) - clearCommitState(mapping) - debouncedSaveSessionMap() - return null - } - - throw error - } - } - - return null -} - -// ============================================================================ -// Auto-Commit Scheduler -// ============================================================================ - -let autoCommitTimer: NodeJS.Timeout | null = null - -function startAutoCommit(config: OpenVikingConfig) { - if (autoCommitTimer) { - log("INFO", "auto-commit", "Auto-commit scheduler already running") - return - } - - if (!config.autoCommit?.enabled) { - log("INFO", "auto-commit", "Auto-commit disabled in config") - return - } - - const checkIntervalMs = 60 * 1000 // Check every minute - - autoCommitTimer = setInterval(async () => { - await checkAndCommitSessions(config) - }, checkIntervalMs) - - log("INFO", "auto-commit", "Auto-commit scheduler started", { - check_interval_seconds: 60, - commit_interval_minutes: getAutoCommitIntervalMinutes(config) - }) -} - -function stopAutoCommit() { - if (autoCommitTimer) { - clearInterval(autoCommitTimer) - autoCommitTimer = null - log("INFO", "auto-commit", "Auto-commit scheduler stopped") - } -} - -async function checkAndCommitSessions(config: OpenVikingConfig): Promise { - const intervalMs = getAutoCommitIntervalMinutes(config) * 60 * 1000 - const now = Date.now() - - cleanupOrphanedMessageBuffers(now) - - for (const [opencodeSessionId, mapping] of sessionMap.entries()) { - if (mapping.commitInFlight) { - await pollCommitTaskOnce(mapping, opencodeSessionId, config) - continue - } - - if (mapping.pendingMessages.size > 0) { - await flushPendingMessages(opencodeSessionId, mapping, config) - } - - const timeSinceLastCommit = now - (mapping.lastCommitTime ?? mapping.createdAt) - const hasNewMessages = mapping.capturedMessages.size > 0 - - if (timeSinceLastCommit >= intervalMs && hasNewMessages) { - log("INFO", "auto-commit", "Triggering auto-commit", { - opencode_session: opencodeSessionId, - openviking_session: mapping.ovSessionId, - time_since_last_commit_minutes: Math.floor(timeSinceLastCommit / 60000), - captured_messages_count: mapping.capturedMessages.size - }) - - await startBackgroundCommit(mapping, opencodeSessionId, config) - } - } -} - -/** - * Add message to OpenViking session - */ -async function addMessageToSession( - ovSessionId: string, - role: "user" | "assistant", - content: string, - config: OpenVikingConfig, -): Promise { - try { - const body: { role: "user" | "assistant"; content: string; peer_id?: string } = { - role, - content, - } - const peerId = config.peerId.trim() - if (peerId) { - body.peer_id = peerId - } - const response = await makeRequest>(config, { - method: "POST", - endpoint: `/api/v1/sessions/${ovSessionId}/messages`, - body, - timeoutMs: 5000, - }) - unwrapResponse(response) - - log("INFO", "message", "Message added to OpenViking session", { - openviking_session: ovSessionId, - role, - content_length: content.length, - }) - return true - } catch (error: any) { - log("ERROR", "message", "Failed to add message to OpenViking session", { - openviking_session: ovSessionId, - role, - error: error.message, - }) - return false - } -} - -// ============================================================================ -// Helper Functions -// ============================================================================ - -function formatSearchResults( - result: SearchResult, - toolName: string, - query: string, - extra?: Record -): string { - const { memories = [], resources = [], skills = [] } = result - const allResults = [...memories, ...resources, ...skills] - if (allResults.length === 0) { - log("INFO", toolName, "No results found", { query }) - return "No results found matching the query." - } - log("INFO", toolName, "Search completed", { count: allResults.length }) - return JSON.stringify( - { total: result.total ?? allResults.length, memories, resources, skills, ...extra }, - null, 2 - ) -} - -function resolveSearchMode( - requestedMode: "auto" | "fast" | "deep" | undefined, - query: string, - sessionId?: string -): "fast" | "deep" { - if (requestedMode === "fast" || requestedMode === "deep") { - return requestedMode - } - - if (sessionId) { - return "deep" - } - - const normalized = query.trim() - const wordCount = normalized ? normalized.split(/\s+/).length : 0 - if (normalized.includes("?") || normalized.length >= 80 || wordCount >= 8) { - return "deep" - } - - return "fast" -} - -function validateVikingUri(uri: string, toolName: string): string | null { - if (!uri.startsWith("viking://")) { - const error = `Invalid URI format. Must start with "viking://". Example: viking://user/memories/` - log("ERROR", toolName, "Invalid URI format", { uri }) - return `Error: ${error}` - } - return null -} - -// ============================================================================ -// Memory Recall: Types, Ranking & Dedup -// ============================================================================ - -/** Shape returned by OpenViking search API, adapted for recall use. */ -interface RecallSearchItem { - uri: string - score: number - title?: string - abstract?: string - content?: string - type?: string - category?: string - level?: number - overview?: string -} - -const AUTO_RECALL_TIMEOUT_MS = 5_000 - -// ─── Scoring helpers ─── - -function recallClampScore(value: number | undefined): number { - if (typeof value !== "number" || Number.isNaN(value)) return 0 - return Math.max(0, Math.min(1, value)) -} - -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 - -interface RecallQueryProfile { - tokens: string[] - wantsPreference: boolean - wantsTemporal: boolean -} - -function buildRecallQueryProfile(query: string): RecallQueryProfile { - const text = query.trim() - const allTokens = text.toLowerCase().match(RECALL_TOKEN_RE) ?? [] - const tokens = allTokens.filter((t) => !RECALL_STOPWORDS.has(t)) - return { - tokens, - wantsPreference: PREFERENCE_QUERY_RE.test(text), - wantsTemporal: TEMPORAL_QUERY_RE.test(text), - } -} - -function lexicalOverlapBoost(tokens: string[], text: string): number { - 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: RecallSearchItem): boolean { - const cat = (item.category ?? "").toLowerCase() - return cat === "events" || item.uri.includes("/events/") -} - -function isPreferencesMemory(item: RecallSearchItem): boolean { - return item.category === "preferences" || item.uri.includes("/preferences/") || item.uri.endsWith("/preferences") -} - -function isLeafLikeMemory(item: RecallSearchItem): boolean { - return item.level === 2 -} - -function rankForInjection(item: RecallSearchItem, query: RecallQueryProfile): number { - 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 -} - -// ─── Dedup + selection ─── - -function normalizeDedupeText(text: string): string { - return text.toLowerCase().replace(/\s+/g, " ").trim() -} - -function isEventOrCaseMemory(item: RecallSearchItem): boolean { - const cat = (item.category ?? "").toLowerCase() - const uri = item.uri.toLowerCase() - return cat === "events" || cat === "cases" || uri.includes("/events/") || uri.includes("/cases/") -} - -function getMemoryDedupeKey(item: RecallSearchItem): string { - const abstract = normalizeDedupeText(item.abstract ?? item.overview ?? "") - const cat = (item.category ?? "").toLowerCase() || "unknown" - if (abstract && !isEventOrCaseMemory(item)) { - return `abstract:${cat}:${abstract}` - } - return `uri:${item.uri}` -} - -function pickMemoriesForInjection( - items: RecallSearchItem[], - limit: number, - queryText: string, - scoreThreshold: number = 0, -): RecallSearchItem[] { - if (items.length === 0 || limit <= 0) return [] - - const query = buildRecallQueryProfile(queryText) - const sorted = [...items].sort((a, b) => rankForInjection(b, query) - rankForInjection(a, query)) - - const deduped: RecallSearchItem[] = [] - const seen = new Set() - for (const item of sorted) { - const key = getMemoryDedupeKey(item) - if (seen.has(key)) continue - seen.add(key) - deduped.push(item) - } - - // Prefer leaf memories first, then supplement with non-leaf - 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 -} - -// ─── Post-processing ─── - -function postProcessMemories( - items: RecallSearchItem[], - maxContentChars: number, - preferAbstract: boolean, -): RecallSearchItem[] { - return items.map((item) => { - const abstract = (item.abstract ?? "").trim() - const content = (item.content ?? "").trim() - let displayContent: string - if (preferAbstract && abstract) { - displayContent = abstract.length > maxContentChars ? abstract.slice(0, maxContentChars) + "..." : abstract - } else if (content) { - displayContent = content.length > maxContentChars ? content.slice(0, maxContentChars) + "..." : content - } else if (abstract) { - displayContent = abstract.length > maxContentChars ? abstract.slice(0, maxContentChars) + "..." : abstract - } else { - displayContent = "" - } - return { ...item, content: displayContent, abstract: abstract || undefined } - }) -} - -function formatMemoryBlock( - items: RecallSearchItem[], - maxChars: number, - tokenBudget: number, -): string { - if (items.length === 0) return "" - - const maxBlockChars = tokenBudget * 4 // 4 chars ≈ 1 token - let usedChars = 0 - const lines: string[] = [""] - - for (const item of items) { - const title = item.title ? `${item.title}\n` : "" - const content = item.content ?? "" - const entry = `\n${title}${content}\n` - const entryChars = entry.length + 1 // +1 for newline - - if (usedChars + entryChars > maxBlockChars) break - lines.push(entry) - usedChars += entryChars - } - - if (usedChars === 0) return "" - lines.push("") - lines.push("Use the `memread` tool with a memory's URI and level=\"overview\" or level=\"read\" to retrieve more details.") - return lines.join("\n") -} - -// ─── Hook helpers ─── - -/** Extract text from message parts. Returns null if empty or already injected. */ -function extractMessageText(parts: { type: string; text?: string }[]): string | null { - const texts: string[] = [] - for (const part of parts) { - if (part.type === "text" && typeof part.text === "string") { - texts.push(part.text) - } - } - const joined = texts.join(" ").trim() - if (!joined) return null - if (joined.includes("")) return null - return joined -} - -/** Perform search against OpenViking with a timeout guard. Returns empty on any failure. */ -async function performRecallSearch(config: OpenVikingConfig, query: string): Promise { - try { - const body: { query: string; limit: number; mode: string; peer_id?: string } = { - query: query.slice(0, 4000), - limit: 20, - mode: "auto", - } - const peerId = config.peerId.trim() - if (peerId) { - body.peer_id = peerId - } - const response = await makeRequest>( - config, - { - method: "POST", - endpoint: "/api/v1/search/find", - body, - timeoutMs: AUTO_RECALL_TIMEOUT_MS, - }, - ) - const result = unwrapResponse(response) - return result?.memories ?? result?.results ?? [] - } catch { - return [] - } -} - -/** Run auto recall using chat.message hook — injects persistent synthetic part. */ -async function runAutoRecall( - input: { sessionID: string; messageID: string; parts: { type: string; text?: string }[] }, - output: { parts: any[] }, -): Promise { - const query = extractMessageText(input.parts ?? []) - if (!query) return - - const rawResults = await performRecallSearch(config, query) - if (rawResults.length === 0) return - - const ranked = pickMemoriesForInjection( - rawResults, - config.autoRecall.limit ?? 6, - query, - config.autoRecall.scoreThreshold ?? 0.15, - ) - if (ranked.length === 0) return - - const processed = postProcessMemories( - ranked, - config.autoRecall.maxContentChars ?? 500, - config.autoRecall.preferAbstract ?? true, - ) - - const block = formatMemoryBlock( - processed, - config.autoRecall.maxContentChars ?? 500, - config.autoRecall.tokenBudget ?? 2000, - ) - if (!block) return - - output.parts.unshift({ - id: `prt-ov-recall-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, - type: "text", - text: block, - synthetic: true, - sessionID: input.sessionID, - messageID: input.messageID, - }) - log("INFO", "recall", `Injected ${processed.length} memories`) -} - -// ============================================================================ -// Plugin Export -// ============================================================================ - -export const OpenVikingMemoryPlugin = async (input: PluginInput): Promise => { - const config = loadConfig() - initLogger() - initSessionMapPath() - - if (!config.enabled) { - console.log("OpenViking Memory Plugin is disabled in configuration") - return {} - } - - log("INFO", "plugin", "OpenViking Memory Plugin initialized", { endpoint: config.endpoint }) - - // Load session map from disk - await loadSessionMap() - - const healthy = await checkServiceHealth(config) - log("INFO", "health", healthy ? "OpenViking health check passed" : "OpenViking health check failed", { - endpoint: config.endpoint, - }) - - // Start auto-commit scheduler - startAutoCommit(config) - - return { - event: async ({ event }) => { - if (event && event.type && event.type === "session.diff") { - return; - } - - // Handle session lifecycle events - if (event.type === "session.created") { - const sessionId = resolveEventSessionId(event) - if (!sessionId) { - log("ERROR", "event", "session.created event missing sessionId", { - event: safeStringify(event) - }) - return - } - - log("INFO", "event", "OpenCode session created", { - session_id: sessionId, - session_info: safeStringify(event.properties?.info) - }) - - // Create or connect to OpenViking session (non-blocking) - const ovSessionId = await ensureOpenVikingSession(sessionId, config) - if (ovSessionId) { - sessionMap.set(sessionId, { - ovSessionId, - createdAt: Date.now(), - capturedMessages: new Set(), - messageRoles: new Map(), - pendingMessages: new Map(), - sendingMessages: new Set(), - lastCommitTime: undefined, - commitInFlight: false, - }) - - // Process buffered messages that arrived before session mapping - const bufferedMessages = sessionMessageBuffer.get(sessionId) - if (bufferedMessages && bufferedMessages.length > 0) { - log("INFO", "event", "Processing buffered messages", { - session_id: sessionId, - count: bufferedMessages.length - }) - - const mapping = sessionMap.get(sessionId)! - for (const buffered of bufferedMessages) { - // Store role if available - if (buffered.role) { - mapping.messageRoles.set(buffered.messageId, buffered.role) - } - // Store content as pending if available - if (buffered.content) { - mapping.pendingMessages.set( - buffered.messageId, - mergeMessageContent(mapping.pendingMessages.get(buffered.messageId), buffered.content) - ) - } - - } - - await flushPendingMessages(sessionId, mapping, config) - - // Clear buffer - sessionMessageBuffer.delete(sessionId) - } - - debouncedSaveSessionMap() - log("INFO", "event", "Session mapping established", { - opencode_session: sessionId, - openviking_session: ovSessionId, - session_info: safeStringify(event.properties?.info) - }) - } else { - log("ERROR", "event", "Failed to establish session mapping", { - session_id: sessionId, - session_info: safeStringify(event.properties?.info) - }) - } - } else if (event.type === "session.deleted") { - const sessionId = resolveEventSessionId(event) - if (!sessionId) { - log("ERROR", "event", "session.deleted event missing sessionId", { - event: safeStringify(event) - }) - return - } - - log("INFO", "event", "OpenCode session deleted", { - session_id: sessionId, - session_info: safeStringify(event.properties?.info) - }) - - // Commit OpenViking session if mapped - const mapping = sessionMap.get(sessionId) - if (mapping) { - await flushPendingMessages(sessionId, mapping, config) - - if (mapping.capturedMessages.size > 0 || mapping.commitInFlight) { - mapping.pendingCleanup = true - if (!mapping.commitInFlight) { - await startBackgroundCommit(mapping, sessionId, config) - } - } else { - sessionMap.delete(sessionId) - sessionMessageBuffer.delete(sessionId) // Clean up buffer - await saveSessionMap() - log("INFO", "event", "Session mapping removed", { - opencode_session: sessionId, - openviking_session: mapping.ovSessionId, - session_info: safeStringify(event.properties?.info) - }) - } - } else { - log("INFO", "event", "No session mapping found for deleted session", { - session_id: sessionId, - session_info: safeStringify(event.properties?.info) - }) - } - } else if (event.type === "session.error") { - const sessionId = resolveEventSessionId(event) - if (!sessionId) { - log("ERROR", "event", "session.error event missing sessionId", { - event: safeStringify(event) - }) - return - } - - log("ERROR", "event", "OpenCode session error", { - session_id: sessionId, - error: safeStringify(event.error), - session_info: safeStringify(event.properties?.info) - }) - - // Optionally commit session to preserve work - const mapping = sessionMap.get(sessionId) - if (mapping) { - log("INFO", "event", "Attempting to commit session after error", { - opencode_session: sessionId, - openviking_session: mapping.ovSessionId, - session_info: safeStringify(event.properties?.info) - }) - // Assistant text parts can arrive before finish=stop records the role. - let inferredRole = false - for (const [messageId, content] of mapping.pendingMessages.entries()) { - if (!mapping.messageRoles.has(messageId) && content.trim()) { - mapping.messageRoles.set(messageId, "assistant") - inferredRole = true - } - } - if (inferredRole) debouncedSaveSessionMap() - - await flushPendingMessages(sessionId, mapping, config) - - if (hasUnsavedSessionWork(mapping)) { - mapping.pendingCleanup = true - if (!mapping.commitInFlight) { - await startBackgroundCommit(mapping, sessionId, config) - } - } else { - sessionMap.delete(sessionId) - sessionMessageBuffer.delete(sessionId) // Clean up buffer - await saveSessionMap() - } - } - } else if (event.type === "message.updated") { - // Handle message capture for automatic session recording - const message = event.properties?.info - if (!message) { - log("DEBUG", "event", "message.updated event missing info", { - event: safeStringify(event) - }) - return - } - - const sessionId = message.sessionID - const messageId = message.id - const role = message.role - const finish = message.finish - - // Check if we have a session mapping - const mapping = sessionMap.get(sessionId) - if (!mapping) { - // Buffer this message for later processing - upsertBufferedMessage(sessionId, messageId, role ? { role } : {}) - log("DEBUG", "message", "Message buffered (no session mapping yet)", { - session_id: sessionId, - message_id: messageId, - role: role - }) - return - } - - if (role === "user") { - if (!mapping.messageRoles.has(messageId)) { - mapping.messageRoles.set(messageId, role) - log("DEBUG", "message", `${role} message role stored`, { - session_id: sessionId, - message_id: messageId, - role: role, - }) - } - } else if (role === "assistant" && finish === "stop") { - mapping.messageRoles.set(messageId, role) - - log("DEBUG", "message", `${role} message completed and role stored`, { - session_id: sessionId, - message_id: messageId, - role: role, - finish: finish, - }) - } - - await flushPendingMessages(sessionId, mapping, config) - - // For assistant messages: log when fully completed (with tokens/cost) - if (role === "assistant" && message.time?.completed) { - log("DEBUG", "message", "Assistant message fully completed", { - session_id: sessionId, - message_id: messageId, - tokens: message.tokens, - cost: message.cost, - }) - } - } else if (event.type === "message.part.updated") { - // Handle message part updates to capture content - const part = event.properties?.part - if (!part) { - return - } - - const sessionId = part.sessionID - const messageId = part.messageID - const partType = part.type - - // Check if we have a session mapping - const mapping = sessionMap.get(sessionId) - if (!mapping) { - // Buffer this message content for later processing - if (partType === "text" && part.text && part.text.trim().length > 0) { - upsertBufferedMessage(sessionId, messageId, { content: part.text }) - log("DEBUG", "message", "Message content buffered (no session mapping yet)", { - session_id: sessionId, - message_id: messageId, - content_length: part.text.length - }) - } - return - } - - // Only capture text parts - if (partType === "text" && part.text) { - // Check if message already captured - if (mapping.capturedMessages.has(messageId)) { - return - } - - const content = part.text - if (content && content.trim().length > 0) { - mapping.pendingMessages.set( - messageId, - mergeMessageContent(mapping.pendingMessages.get(messageId), content) - ) - log("DEBUG", "message", "Message content stored as pending", { - session_id: sessionId, - message_id: messageId, - content_length: content.length, - waiting_for_role: !mapping.messageRoles.has(messageId), - commit_in_flight: mapping.commitInFlight === true, - }) - } - } - } - }, - - tool: { - memread: tool({ - description: - "Retrieve the content of a specific memory, resource, or skill at a given viking:// URI.\n\nProgressive loading levels:\n- abstract: brief summary\n- overview: structured directory overview\n- read: full content\n- auto: choose overview for directories and read for files\n\nUse when:\n- You have a URI from memsearch or membrowse\n- You need to inspect a memory, resource, or skill in more detail\n\nRequires: Complete viking:// URI (e.g., viking://user/memories/profile.md)", - args: { - uri: z - .string() - .describe( - "Complete viking:// URI from search results or list output (e.g., viking://user/memories/profile.md, viking://resources/zh/guide.md)", - ), - level: z - .enum(["auto", "abstract", "overview", "read"]) - .optional() - .describe("'auto' (directory->overview, file->read), 'abstract' (brief summary), 'overview' (directory summary), 'read' (full content)"), - }, - async execute(args, context) { - log("INFO", "memread", "Reading memory", { uri: args.uri, level: args.level }) - - // Validate URI format - const validationError = validateVikingUri(args.uri, "memread") - if (validationError) return validationError - - try { - let level = args.level ?? "auto" - if (level === "auto") { - try { - const statResponse = await makeRequest>(config, { - method: "GET", - endpoint: `/api/v1/fs/stat?uri=${encodeURIComponent(args.uri)}`, - abortSignal: context.abort, - }) - const statResult = unwrapResponse(statResponse) - level = statResult?.isDir ? "overview" : "read" - } catch { - level = "read" - } - } - - const response = await makeRequest>>(config, { - method: "GET", - endpoint: `/api/v1/content/${level}?uri=${encodeURIComponent(args.uri)}`, - abortSignal: context.abort, - }) - - const content = unwrapResponse(response) - if (!content) { - log("INFO", "memread", "No content found", { uri: args.uri }) - return `No content found at ${args.uri}` - } - - log("INFO", "memread", "Read completed", { uri: args.uri, level }) - return typeof content === "string" ? content : JSON.stringify(content, null, 2) - } catch (error: any) { - log("ERROR", "memread", "Read failed", { error: error.message, uri: args.uri }) - return `Error: ${error.message}` - } - }, - }), - - membrowse: tool({ - description: - "Browse the OpenViking filesystem structure for a specific URI.\n\nViews:\n- list: list immediate children, or recurse when `recursive=true`\n- tree: return a directory tree view\n- stat: return metadata for a single file or directory\n\nUse when:\n- You need to discover available URIs before reading\n- You want to inspect directory structure under memories/resources/skills\n- You need file metadata before deciding how to read it\n\nRequires: Complete viking:// URI", - args: { - uri: z - .string() - .describe( - "Complete viking:// URI to inspect (e.g., viking://user/memories/, viking://user/skills/, viking://resources/zh/)", - ), - view: z - .enum(["list", "tree", "stat"]) - .optional() - .describe("'list' for directory listing, 'tree' for recursive tree view, 'stat' for metadata on a single URI"), - recursive: z.boolean().optional().describe("Only used with view='list'. Recursively list descendants."), - simple: z.boolean().optional().describe("Only used with view='list'. Return simpler URI-oriented output."), - }, - async execute(args, context) { - log("INFO", "membrowse", "Browsing URI", { args }) - - // Validate URI format - const validationError = validateVikingUri(args.uri, "membrowse") - if (validationError) return validationError - - try { - const view = args.view ?? "list" - const encodedUri = encodeURIComponent(args.uri) - - if (view === "stat") { - const response = await makeRequest>>(config, { - method: "GET", - endpoint: `/api/v1/fs/stat?uri=${encodedUri}`, - abortSignal: context.abort, - }) - const result = unwrapResponse(response) - return JSON.stringify({ view, item: result }, null, 2) - } - - const endpoint = view === "tree" - ? `/api/v1/fs/tree?uri=${encodedUri}` - : `/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, - }) - - const result = unwrapResponse(response) - const items = Array.isArray(result) ? result : [] - if (items.length === 0) { - return `No items found at ${args.uri}` - } - - return JSON.stringify({ view, count: items.length, items }, null, 2) - } catch (error: any) { - 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 from the accumulated conversation.\n\nBy default this tool commits the OpenViking session mapped to the current OpenCode session. Use `session_id` only when you need to target a specific OpenViking session manually.\n\nUse when:\n- You want a mid-session memory extraction without ending the chat\n- You want recently discussed preferences, entities, or cases persisted immediately\n\nAutomatically extracts and stores memories under viking://user/memories/.\n\nReturns background commit progress or completion details, including task_id, memories_extracted, and archived.", - args: { - session_id: z - .string() - .optional() - .describe("Optional explicit OpenViking session ID. Omit to commit the current OpenCode session's mapped OpenViking session."), - }, - async execute(args, context) { - let sessionId = args.session_id - if (!sessionId && context.sessionID) { - const mapping = sessionMap.get(context.sessionID) - if (mapping) { - sessionId = mapping.ovSessionId - } - } - - log("INFO", "memcommit", "Committing session", { - requested_session_id: args.session_id, - resolved_session_id: sessionId, - opencode_session_id: context.sessionID, - }) - - if (!sessionId) { - return "Error: No OpenViking session is associated with the current OpenCode session. Start or resume a normal OpenCode session first, or pass an explicit session_id." - } - - try { - const mapping = context.sessionID ? sessionMap.get(context.sessionID) : undefined - const resolvedMapping = mapping?.ovSessionId === sessionId ? mapping : undefined - - if (resolvedMapping) { - await flushPendingMessages( - context.sessionID ?? sessionId, - resolvedMapping, - config, - ) - } - - if (resolvedMapping?.commitInFlight) { - const task = await waitForCommitCompletion( - resolvedMapping, - context.sessionID ?? sessionId, - config, - context.abort, - ) - if (task?.status === "completed") { - const memoriesExtracted = totalMemoriesFromResult(task.result) - return JSON.stringify( - { - message: `Memory extraction complete: ${memoriesExtracted} memories extracted`, - session_id: task.result?.session_id ?? sessionId, - status: task.status, - memories_extracted: memoriesExtracted, - archived: task.result?.archived ?? false, - task_id: task.task_id, - }, - null, - 2, - ) - } - } - - const tempMapping: SessionMapping = resolvedMapping ?? { - ovSessionId: sessionId, - createdAt: Date.now(), - capturedMessages: new Set(), - messageRoles: new Map(), - pendingMessages: new Map(), - sendingMessages: new Set(), - } - - const commitStart = await startBackgroundCommit( - tempMapping, - context.sessionID ?? sessionId, - config, - context.abort, - ) - if (!commitStart) { - throw new Error("Failed to start background commit") - } - - if (commitStart.mode === "completed") { - const memoriesExtracted = totalMemoriesFromResult(commitStart.result) - return JSON.stringify( - { - message: `Memory extraction complete: ${memoriesExtracted} memories extracted`, - session_id: commitStart.result.session_id ?? sessionId, - status: commitStart.result.status ?? "completed", - memories_extracted: memoriesExtracted, - archived: commitStart.result.archived ?? false, - }, - null, - 2, - ) - } - - const task = await waitForCommitCompletion( - tempMapping, - context.sessionID ?? sessionId, - config, - context.abort, - ) - - if (!task) { - return JSON.stringify( - { - message: "Commit is still processing in the background", - session_id: sessionId, - status: "accepted", - task_id: commitStart.taskId, - }, - null, - 2, - ) - } - - const memoriesExtracted = totalMemoriesFromResult(task.result) - return JSON.stringify( - { - message: `Memory extraction complete: ${memoriesExtracted} memories extracted`, - session_id: task.result?.session_id ?? sessionId, - status: task.status, - memories_extracted: memoriesExtracted, - archived: task.result?.archived ?? false, - task_id: task.task_id, - }, - null, - 2, - ) - } catch (error: any) { - log("ERROR", "memcommit", "Commit failed", { - error: error.message, - session_id: sessionId, - }) - return `Error: ${error.message}` - } - }, - }), - - memsearch: tool( - { - description: - "Search OpenViking memories, resources, and skills through a unified interface.\n\nModes:\n- auto: choose between fast similarity search and deep context-aware search\n- fast: use simple semantic similarity search\n- deep: use intent analysis and optional session context\n\nReturns memories, resources, and skills with relevance scores and match reasons.\n\nUse when:\n- You want to find relevant memories or resources by meaning\n- You need a single search tool instead of choosing between low-level APIs\n- You want deeper retrieval for complex or ambiguous questions", - args: { - query: z.string().describe("Search query - can be natural language, a complex question, or a task description"), - target_uri: z - .string() - .optional() - .describe( - "Limit search to a specific URI prefix (e.g., viking://resources/, viking://user/memories/). Omit to search all contexts.", - ), - mode: z - .enum(["auto", "fast", "deep"]) - .optional() - .describe("Search mode. 'auto' chooses based on query complexity and session context, 'fast' forces /find, 'deep' forces /search"), - session_id: z - .string() - .optional() - .describe( - "Optional OpenViking session ID for context-aware search. If omitted in auto/deep mode, the current OpenCode session mapping will be used when available.", - ), - limit: z.number().optional().describe("Max results (default: 10)"), - score_threshold: z.number().optional().describe("Optional minimum score threshold"), - }, - async execute(args, context) { - log("INFO", "memsearch", "Executing unified search", { args }) - - // Auto-inject session_id if not provided - let sessionId = args.session_id - if (!sessionId && context.sessionID) { - const mapping = sessionMap.get(context.sessionID) - if (mapping) { - sessionId = mapping.ovSessionId - log("INFO", "memsearch", "Auto-injected session context", { - opencode_session: context.sessionID, - openviking_session: sessionId, - }) - } - } - - const mode = resolveSearchMode(args.mode, args.query, sessionId) - const requestBody: { - query: string - limit: number - target_uri?: string - session_id?: string - score_threshold?: number - peer_id?: string - } = { - query: args.query, - limit: args.limit ?? 10, - } - if (args.target_uri) requestBody.target_uri = args.target_uri - if (args.score_threshold !== undefined) requestBody.score_threshold = args.score_threshold - if (mode === "deep" && sessionId) requestBody.session_id = sessionId - const peerId = config.peerId.trim() - if (peerId) requestBody.peer_id = peerId - - try { - const response = await makeRequest>(config, { - method: "POST", - endpoint: mode === "deep" ? "/api/v1/search/search" : "/api/v1/search/find", - body: requestBody, - abortSignal: context.abort, - }) - - const result = unwrapResponse(response) ?? { memories: [], resources: [], skills: [], total: 0 } - return formatSearchResults(result, "memsearch", args.query, { - mode, - query_plan: result.query_plan, - }) - } catch (error: any) { - log("ERROR", "memsearch", "Search failed", { error: error.message, args }) - return `Error: ${error.message}` - } - }, - }, - ), - }, - - "chat.message": async (input, output) => { - try { - if (!config.autoRecall?.enabled) return - await runAutoRecall(input, output) - } catch (error: any) { - log("WARN", "recall", "Auto recall failed, skipping silently", { - error: error?.message ?? String(error), - }) - } - }, - - stop: async () => { - // Flush any pending debounced save - if (saveTimer) { - clearTimeout(saveTimer) - await saveSessionMap() - } - // Stop auto-commit scheduler - stopAutoCommit() - log("INFO", "plugin", "OpenViking Memory Plugin stopped") - } - } -} - -export default OpenVikingMemoryPlugin diff --git a/examples/opencode-plugin/INSTALL-ZH.md b/examples/opencode-plugin/INSTALL-ZH.md index b14e486b3b..d409386f50 100644 --- a/examples/opencode-plugin/INSTALL-ZH.md +++ b/examples/opencode-plugin/INSTALL-ZH.md @@ -5,7 +5,7 @@ - 外部仓库语义检索 - 长期记忆、session 同步、生命周期边界 commit、自动 recall -旧示例目前仍然保留,后续会下线。这个插件不再安装 `skills/openviking/SKILL.md`,也不要求 agent 使用 `ov` 命令。原 skill 中的能力会通过 OpenCode tools 暴露。 +这是仓库中唯一继续维护的 OpenCode 插件示例。这个插件不再安装 `skills/openviking/SKILL.md`,也不要求 agent 使用 `ov` 命令。原 skill 风格的能力会通过 OpenCode tools 暴露。 ## 前置条件 @@ -38,8 +38,6 @@ curl http://localhost:1933/health } ``` -如果发布前包名有调整,请使用最终发布包名。 - ## 安装方式二:源码安装 用于开发调试或 PR 测试。OpenCode 推荐插件目录: @@ -130,6 +128,30 @@ user/admin API key 的 API_KEY mode 时应留空。 高级场景可以用 `OPENVIKING_PLUGIN_CONFIG` 指向其他配置文件路径。 +## 验证 + +修改插件或 OpenViking 配置后,需要重启 OpenCode。 + +进入新的 OpenCode session 后,可以让 agent 浏览 OpenViking memory,或搜索一个已索引的资源。插件应暴露这些 tools: + +- `memsearch`、`memread`、`membrowse` +- `memgrep`、`memglob` +- `memadd`、`memremove`、`memqueue` +- `memcommit` + +如果行为异常,先查看运行时文件: + +```bash +ls ~/.config/opencode/openviking/ +tail -n 100 ~/.config/opencode/openviking/openviking-memory.log +``` + +如果使用本地 server,也确认 OpenViking 可访问: + +```bash +curl http://localhost:1933/health +``` + ## 可用工具 插件会通过 OpenCode `tool` hook 暴露这些工具: @@ -187,3 +209,13 @@ memadd path="file:///home/alice/project/notes.md" reason="project notes" 可以通过配置里的 `runtime.dataDir` 修改这个目录。 这些是本地运行时文件,不建议提交到版本库。 + +## 故障排查 + +| 问题 | 排查方向 | +|------|----------| +| 插件没有加载 | package 安装检查 `~/.config/opencode/opencode.json` 是否包含 `openviking-opencode-plugin`;源码安装检查 `~/.config/opencode/plugins/openviking.mjs` 是否存在 | +| Tools 连到了错误的 server | 检查 `~/.config/opencode/openviking-config.json` 里的 `endpoint`,或用 `OPENVIKING_PLUGIN_CONFIG` 指向正确配置文件 | +| OpenViking 返回 401 / 403 | 检查 `OPENVIKING_API_KEY`;trusted-mode 部署还要检查 `OPENVIKING_ACCOUNT` 和 `OPENVIKING_USER` | +| recall 为空 | 确认 OpenViking 中已有 memories/resources,并且 `autoRecall.enabled` 为 `true` | +| 本地 `memadd` 失败 | 传入文件路径而不是目录;目前还不支持自动上传本地目录 | diff --git a/examples/opencode-plugin/INSTALL.md b/examples/opencode-plugin/INSTALL.md index 33b5a7f3ed..bf89f1ef85 100644 --- a/examples/opencode-plugin/INSTALL.md +++ b/examples/opencode-plugin/INSTALL.md @@ -5,7 +5,7 @@ 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. +This is the only OpenCode plugin example maintained in this repository. It does not install `skills/openviking/SKILL.md`, and it does not require the agent to use the `ov` command. The former skill-style capabilities are exposed as OpenCode tools here. ## Prerequisites @@ -38,8 +38,6 @@ Normal users are recommended to enable it through OpenCode's package plugin mech } ``` -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: @@ -124,6 +122,30 @@ export OPENVIKING_API_KEY="your-api-key-here" For advanced setups, use `OPENVIKING_PLUGIN_CONFIG` to point to another configuration file path. +## Verify + +Restart OpenCode after changing plugin or OpenViking configuration. + +In a new OpenCode session, ask the agent to browse OpenViking memory or search for a known indexed resource. The plugin should expose these tools: + +- `memsearch`, `memread`, `membrowse` +- `memgrep`, `memglob` +- `memadd`, `memremove`, `memqueue` +- `memcommit` + +If anything looks wrong, check the runtime files: + +```bash +ls ~/.config/opencode/openviking/ +tail -n 100 ~/.config/opencode/openviking/openviking-memory.log +``` + +For a local server, also confirm OpenViking is reachable: + +```bash +curl http://localhost:1933/health +``` + ## Available Tools The plugin exposes the following tools through the OpenCode `tool` hook: @@ -181,3 +203,13 @@ Possible files include: You can change this directory with `runtime.dataDir` in the configuration. These are local runtime files and should not be committed to the repository. + +## Troubleshooting + +| Issue | What to check | +|-------|---------------| +| Plugin does not load | For package installs, confirm `~/.config/opencode/opencode.json` contains `openviking-opencode-plugin`; for source installs, confirm `~/.config/opencode/plugins/openviking.mjs` exists | +| Tools call the wrong server | Check `endpoint` in `~/.config/opencode/openviking-config.json`, or set `OPENVIKING_PLUGIN_CONFIG` to the intended config path | +| 401 / 403 from OpenViking | Verify `OPENVIKING_API_KEY`; for trusted-mode deployments, also verify `OPENVIKING_ACCOUNT` and `OPENVIKING_USER` | +| Recall is empty | Confirm OpenViking has indexed memories/resources and `autoRecall.enabled` is `true` | +| Local `memadd` fails | Pass a file path, not a directory; local directories are not uploaded automatically yet | diff --git a/examples/opencode-plugin/README.md b/examples/opencode-plugin/README.md index 12bf978e14..2a73c1b5d0 100644 --- a/examples/opencode-plugin/README.md +++ b/examples/opencode-plugin/README.md @@ -2,10 +2,7 @@ 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 +This is the only OpenCode plugin example maintained in this repository. It supersedes the former split examples for indexed repository prompt injection and long-term memory. 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. @@ -65,8 +62,6 @@ Normal users should enable it through OpenCode's package plugin mechanism: } ``` -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: diff --git a/examples/opencode/plugin/README.md b/examples/opencode/plugin/README.md deleted file mode 100644 index 07e4b9f706..0000000000 --- a/examples/opencode/plugin/README.md +++ /dev/null @@ -1,67 +0,0 @@ -# openviking-opencode - -OpenViking plugin for [OpenCode](https://opencode.ai). Injects your indexed code repos into the AI's context and auto-starts the OpenViking server when needed. - -## Prerequisites - -Install the latest OpenViking and configure `~/.openviking/ov.conf`: - -```bash -pip install openviking --upgrade -``` - -```json -{ - "storage": { - "workspace": "/path/to/your/workspace" - }, - "embedding": { - "dense": { - "provider": "openai", - "model": "your-embedding-model", - "api_key": "your-api-key", - "api_base": "https://your-provider/v1", - "dimension": 1024 - }, - "max_concurrent": 100 - }, - "vlm": { - "provider": "openai", - "model": "your-vlm-model", - "api_key": "your-api-key", - "api_base": "https://your-provider/v1" - } -} -``` - -For other providers (Volcengine, Anthropic, DeepSeek, Ollama, etc.) see the [OpenViking docs](https://www.openviking.ai/docs). - -Before starting OpenCode, make sure the OpenViking server is running. If it's not already started: - -```bash -openviking-server > /tmp/openviking.log 2>&1 & -``` - -## Usage in OpenCode - -Add the plugin to `~/.config/opencode/opencode.json`: - -```json -{ - "plugin": ["openviking-opencode"] -} -``` - -Restart OpenCode — the skill is installed automatically. - -**Index a repo** (just ask in chat): -``` -"Add https://github.com/tiangolo/fastapi to OpenViking" -``` - -**Search** — once repos are indexed, the AI searches them automatically when relevant. You can also trigger it explicitly: -``` -"How does fastapi handle dependency injection?" -"Use openviking to find how JWT tokens are verified" -``` - diff --git a/examples/opencode/plugin/README_CN.md b/examples/opencode/plugin/README_CN.md deleted file mode 100644 index db0d1a42ce..0000000000 --- a/examples/opencode/plugin/README_CN.md +++ /dev/null @@ -1,66 +0,0 @@ -# openviking-opencode - -用于 [OpenCode](https://opencode.ai) 的 OpenViking 插件。将您索引的代码仓库注入到 AI 的上下文中,并在需要时自动启动 OpenViking 服务器。 - -## 前置要求 - -安装最新版的 OpenViking 并配置 `~/.openviking/ov.conf`: - -```bash -pip install openviking --upgrade -``` - -```json -{ - "storage": { - "workspace": "/path/to/your/workspace" - }, - "embedding": { - "dense": { - "provider": "openai", - "model": "your-embedding-model", - "api_key": "your-api-key", - "api_base": "https://your-provider/v1", - "dimension": 1024 - }, - "max_concurrent": 100 - }, - "vlm": { - "provider": "openai", - "model": "your-vlm-model", - "api_key": "your-api-key", - "api_base": "https://your-provider/v1" - } -} -``` - -对于其他提供商(火山引擎、Anthropic、DeepSeek、Ollama 等),请参阅 [OpenViking 文档](https://www.openviking.ai/docs)。 - -启动 OpenCode 之前,请确保 OpenViking 服务器正在运行。如果尚未启动: - -```bash -openviking-server > /tmp/openviking.log 2>&1 & -``` - -## 在 OpenCode 中使用 - -将插件添加到 `~/.config/opencode/opencode.json`: - -```json -{ - "plugin": ["openviking-opencode"] -} -``` - -重启 OpenCode — 技能会自动安装。 - -**索引仓库**(直接在聊天中询问): -``` -"将 https://github.com/tiangolo/fastapi 添加到 OpenViking" -``` - -**搜索** — 仓库索引完成后,AI 会在相关时自动搜索它们。您也可以显式触发: -``` -"FastAPI 如何处理依赖注入?" -"使用 openviking 查找 JWT 令牌如何验证" -``` diff --git a/examples/opencode/plugin/index.mjs b/examples/opencode/plugin/index.mjs deleted file mode 100644 index 22db55f84c..0000000000 --- a/examples/opencode/plugin/index.mjs +++ /dev/null @@ -1,165 +0,0 @@ -import { exec } from "child_process" -import { promisify } from "util" -import { readFileSync, mkdirSync, writeFileSync, existsSync } from "fs" -import { homedir } from "os" -import { join, dirname } from "path" -import { fileURLToPath } from "url" - -const execAsync = promisify(exec) -const __dirname = dirname(fileURLToPath(import.meta.url)) - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -async function run(cmd, opts = {}) { - return execAsync(cmd, { timeout: 10000, ...opts }) -} - -async function isHealthy() { - try { - await run("ov health", { timeout: 3000 }) - return true - } catch { - return false - } -} - -async function startServer() { - // Start in background, wait up to 30s for healthy - await run("openviking-server > /tmp/openviking.log 2>&1 &") - for (let i = 0; i < 10; i++) { - await new Promise((r) => setTimeout(r, 3000)) - if (await isHealthy()) return true - } - return false -} - -let initPromise = null - -function makeToast(client) { - return (message, variant = "warning") => - client.tui.showToast({ - body: { title: "OpenViking", message, variant, duration: 8000 }, - }).catch(() => {}) -} - -// ── Skill auto-install ──────────────────────────────────────────────────────── - -function installSkill() { - const src = join(__dirname, "skills", "openviking", "SKILL.md") - const dest = join(homedir(), ".config", "opencode", "skills", "openviking", "SKILL.md") - mkdirSync(dirname(dest), { recursive: true }) - const content = readFileSync(src, "utf8") - if (!existsSync(dest) || readFileSync(dest, "utf8") !== content) { - writeFileSync(dest, content, "utf8") - } -} - -// ── Repo context cache ──────────────────────────────────────────────────────── - -let cachedRepos = null -let lastFetchTime = 0 -const CACHE_TTL_MS = 60 * 1000 - -async function loadRepos() { - const now = Date.now() - if (cachedRepos !== null && now - lastFetchTime < CACHE_TTL_MS) return - - try { - const { stdout } = await run( - "ov --output json ls viking://resources/ --abs-limit 2000" - ) - const items = JSON.parse(stdout)?.result ?? [] - const repos = items - .filter((item) => item.uri?.startsWith("viking://resources/")) - .map((item) => { - const name = item.uri.replace("viking://resources/", "").replace(/\/$/, "") - return item.abstract - ? `- **${name}** (${item.uri})\n ${item.abstract}` - : `- **${name}** (${item.uri})` - }) - if (repos.length > 0) { - cachedRepos = repos.join("\n") - lastFetchTime = now - } - } catch {} -} - -// ── Init: check deps, start server if needed ───────────────────────────────── - -async function _init(client) { - const toast = makeToast(client) - - // server already running - if (await isHealthy()) return true - - // check if ov is installed - try { - await run("command -v ov", { timeout: 2000 }) - } catch { - await toast("openviking is not installed. Run: pip install openviking", "error") - return false - } - - // installed but no config file — cannot start - const ovConf = join(homedir(), ".openviking", "ov.conf") - if (!existsSync(ovConf)) { - await toast("~/.openviking/ov.conf not found. Please configure API keys before starting the server.", "warning") - return false - } - - // installed + config exists — auto-start silently - const started = await startServer() - if (!started) { - await toast("Failed to start openviking server. Check logs: /tmp/openviking.log", "error") - return false - } - - return true -} - -async function init(client) { - if (!initPromise) initPromise = _init(client).finally(() => { initPromise = null }) - return initPromise -} - -// ── Plugin export ───────────────────────────────────────────────────────────── - -/** - * @type {import('@opencode-ai/plugin').Plugin} - */ -export async function OpenVikingPlugin({ client }) { - const toast = makeToast(client) - - try { - installSkill() - } catch (e) { - await toast(`Failed to install skill: ${e.message}`, "error") - } - - // init in background — do not block opencode startup - Promise.resolve().then(async () => { - const ready = await init(client) - if (ready) await loadRepos() - }) - - return { - "experimental.chat.system.transform": (_input, output) => { - if (!cachedRepos) return - output.system.push( - `## OpenViking — Indexed Code Repositories\n\n` + - `The following repos are semantically indexed and searchable.\n` + - `When the user asks about any of these projects or their internals, ` + - `you MUST proactively load skill("openviking") and use the correct ov commands to search and retrieve content before answering.\n\n` + - cachedRepos - ) - }, - - "session.created": async () => { - const ready = await init(client) - if (ready) { - cachedRepos = null - await loadRepos() - } - }, - } -} diff --git a/examples/opencode/plugin/package.json b/examples/opencode/plugin/package.json deleted file mode 100644 index 7795c1bdb7..0000000000 --- a/examples/opencode/plugin/package.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "name": "openviking-opencode", - "version": "0.3.3", - "description": "OpenCode plugin for OpenViking — injects indexed repo context into the AI assistant and auto-installs the openviking skill", - "type": "module", - "main": "index.mjs", - "exports": { - ".": "./index.mjs" - }, - "keywords": [ - "opencode", - "opencode-plugin", - "openviking", - "rag", - "code-search" - ], - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/volcengine/OpenViking" - } -} diff --git a/examples/opencode/plugin/skills/openviking/SKILL.md b/examples/opencode/plugin/skills/openviking/SKILL.md deleted file mode 100644 index 7de853deed..0000000000 --- a/examples/opencode/plugin/skills/openviking/SKILL.md +++ /dev/null @@ -1,129 +0,0 @@ ---- -name: openviking -description: "Activate when the user asks about any repository listed in the system prompt under 'OpenViking — Indexed Code Repositories', or when they ask about an external library, framework, or project that may have been indexed. Also activate when the user wants to add, remove, or manage repos. Always search the local codebase first before using this skill." -license: MIT -compatibility: opencode ---- - -# OpenViking Code Repository Search - -**IMPORTANT: All `ov` commands are terminal (shell) commands — run them via the `bash` tool. Execute directly — no pre-checks, no test commands. Handle errors when they occur.** - -## How OpenViking Organizes Data - -OpenViking stores content in a virtual filesystem under the `viking://` namespace. Each URI maps to a file or directory, e.g. `viking://resources/fastapi/routing.py`. Each directory has AI-generated summaries (`abstract` / `overview`). **The key principle: narrow the URI scope to improve retrieval efficiency.** Instead of searching all repos, lock to a specific repo or subdirectory — this reduces noise and speeds up results significantly. - -## Search Commands - -Choose the right command based on what you're looking for: - -| Command | Use when | Example | -|---------|----------|---------| -| `ov search` | Semantic search — use for concept/intent based queries | "dependency injection", "how auth works" | -| `ov grep` | You know the **exact keyword or symbol** | function name, class name, error string | -| `ov glob` | You want to **enumerate files** by pattern | all `*.py` files, all test files | - -```bash -# Semantic search -ov search "dependency injection" --uri viking://resources/fastapi --limit 10 -ov search "how tokens are refreshed" --uri viking://resources/fastapi/fastapi/security -ov search "JWT authentication" --limit 10 # across all repos -ov search "error handling" --limit 5 --threshold 0.7 # filter low-relevance results - -# Keyword search — exact match or regex -ov grep "verify_token" --uri viking://resources/fastapi -ov grep "class.*Session" --uri viking://resources/requests/requests - -# File enumeration — by name pattern (always specify --uri to scope the search) -ov glob "**/*.py" --uri viking://resources/fastapi -ov glob "**/test_*.py" --uri viking://resources/fastapi/tests -ov glob "**/*.py" --uri viking://resources/ # across all repos -``` - -**Narrowing scope:** once you identify a relevant directory, pass it as `--uri` to restrict subsequent searches to that subtree — this is faster and more precise than searching the whole repo. - -**Query formulation:** write specific, contextual queries rather than single keywords. -```bash -ov search "API" # too vague -ov search "REST API authentication with JWT tokens" # better -ov search "JWT token refresh flow" --uri viking://resources/backend # best -``` - -## Read Content - -```bash -# Directories: AI-generated summaries -ov abstract viking://resources/fastapi/fastapi/dependencies/ # one-line summary -ov overview viking://resources/fastapi/fastapi/dependencies/ # detailed breakdown - -# Files: raw content -ov read viking://resources/fastapi/fastapi/dependencies/utils.py -ov read viking://resources/fastapi/fastapi/dependencies/utils.py --offset 100 --limit 50 -``` - -`abstract` / `overview` only work on directories. `read` only works on files. - -## Browse - -```bash -ov ls viking://resources/ # list all indexed repos -ov ls viking://resources/fastapi # list repo top-level contents -ov ls viking://resources/fastapi --simple # paths only, no metadata -ov ls viking://resources/fastapi --recursive # list all files recursively -ov tree viking://resources/fastapi # full directory tree (default: 3 levels deep) -ov tree viking://resources/fastapi -L 2 # limit depth to 2 levels -ov tree viking://resources/fastapi -l 200 # truncate abstract column to 200 chars -ov tree viking://resources/fastapi -L 2 -l 200 # combined: 2 levels deep, 200-char summaries -``` - -`-L` controls how many levels deep the tree expands. `-l` controls the length of the AI-generated summary per directory. Use `ov tree -L 2 -l 200` as a good starting point to understand a repo's structure before diving in. - -## Add a Repository - -```bash -ov add-resource https://github.com/owner/repo --to viking://resources/repo --timeout 300 -``` - -`--timeout` is required (seconds). Use 300 (5 min) for small repos, increase for larger ones. - -After submitting, run `ov observer queue` once and report status to user. Indexing runs in background — do not poll or wait. - -| Repo Size | Files | Est. Time | -|-----------|-------|-----------| -| Small | < 100 | 2–5 min | -| Medium | 100–500 | 5–20 min | -| Large | 500+ | 20–60+ min | - -## Remove a Repository - -```bash -ov rm viking://resources/fastapi --recursive -``` - -This permanently deletes the repo and all its indexed content. Confirm with the user before running. - -## Error Handling - -**`command not found: ov`** → Tell user: `pip install openviking --upgrade`. Stop. - -**`url is required` / `CLI_CONFIG` error** → Auto-create config and retry: -```bash -mkdir -p ~/.openviking && echo '{"url": "http://localhost:1933"}' > ~/.openviking/ovcli.conf -``` - -**`CONNECTION_ERROR` / failed to connect:** -- `~/.openviking/ov.conf` **exists** → auto-start server, wait until healthy, retry: - ```bash - openviking-server > /tmp/openviking.log 2>&1 & - for i in $(seq 1 10); do ov health 2>/dev/null && break; sleep 3; done - ``` -- **Does not exist** → Tell user to configure `~/.openviking/ov.conf` first. Stop. - -## More Help - -For other issues or command details, run: - -```bash -ov help -ov --help # e.g. ov search --help -```