Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 35 additions & 3 deletions examples/opencode-plugin/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ examples/opencode-plugin/
│ ├── memory-tools.mjs
│ ├── memory-recall.mjs
│ └── utils.mjs
├── tests/
│ └── test-project-isolation.mjs
└── wrappers/
└── openviking.mjs
```
Expand Down Expand Up @@ -111,6 +113,7 @@ Create `~/.config/opencode/openviking-config.json`:
"account": "",
"user": "",
"agentId": "",
"projectIsolation": true,
"enabled": true,
"timeoutMs": 30000,
"repoContext": { "enabled": true, "cacheTtlMs": 60000 },
Expand All @@ -125,15 +128,44 @@ Create `~/.config/opencode/openviking-config.json`:
}
```

`apiKey` is sent as `X-API-Key`. `account`, `user`, and `agentId` are sent as
`X-OpenViking-Account`, `X-OpenViking-User`, and `X-OpenViking-Agent`.
They are required by multi-tenant OpenViking servers for tenant-scoped APIs.
`apiKey` is sent as `X-API-Key`. `account`, `user`, and the resolved
`agentId` are sent as `X-OpenViking-Account`, `X-OpenViking-User`, and
`X-OpenViking-Agent`. They are required by multi-tenant OpenViking servers for
tenant-scoped APIs.

`OPENVIKING_API_KEY`, `OPENVIKING_ACCOUNT`, `OPENVIKING_USER`, and
`OPENVIKING_AGENT_ID` take precedence over values in this file.

For advanced setups, `OPENVIKING_PLUGIN_CONFIG` can point to another config file path.

### Project Isolation

`projectIsolation` defaults to `true`. With isolation enabled, the plugin derives
the effective `agentId` from the configured base `agentId` and the current
OpenCode project directory:

```text
<agentId>-<project-name>-<directory-hash>
```

For example, two projects that both configure `"agentId": "alice"` will write
memories under different `X-OpenViking-Agent` values, preventing unrelated
project memories from mixing.

Set `"projectIsolation": false` to keep the previous behavior where every
project uses the exact configured `agentId`.

Set `OPENVIKING_AGENT_ID_OVERRIDE` to force one exact agent ID for the current
process. This is an escape hatch for one-off maintenance and migration commands.

If you already have memories under the old shared agent and want to move them to
a project's isolated agent, first determine the new value from the plugin log or
by matching the pattern above, then run:

```bash
ov mv viking://agent/<old> viking://agent/<new>
```

## Tools

### `memsearch`
Expand Down
27 changes: 24 additions & 3 deletions examples/opencode-plugin/lib/utils.mjs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import fs from "fs"
import path from "path"
import { homedir } from "os"
import { createHash } from "crypto"

export const DEFAULT_CONFIG = {
endpoint: "http://localhost:1933",
apiKey: "",
account: "",
user: "",
agentId: "",
projectIsolation: true,
enabled: true,
timeoutMs: 30000,
runtime: {
Expand Down Expand Up @@ -35,7 +37,7 @@ function cloneDefaultConfig() {

function mergeConfig(fileConfig = {}) {
const config = cloneDefaultConfig()
for (const key of ["endpoint", "apiKey", "account", "user", "agentId", "enabled", "timeoutMs"]) {
for (const key of ["endpoint", "apiKey", "account", "user", "agentId", "projectIsolation", "enabled", "timeoutMs"]) {
if (fileConfig[key] !== undefined) config[key] = fileConfig[key]
}
config.runtime = {
Expand Down Expand Up @@ -69,6 +71,25 @@ function mergeConfig(fileConfig = {}) {
return config
}

function resolveProjectAgentId(config, projectDirectory) {
if (config.projectIsolation === false) return config
if (process.env.OPENVIKING_AGENT_ID_OVERRIDE !== undefined) {
config.agentId = process.env.OPENVIKING_AGENT_ID_OVERRIDE
return config
}
if (!projectDirectory) return config

const projectPath = String(projectDirectory)
const projectName = sanitizeProjectName(path.basename(projectPath)) || "project"
const projectHash = createHash("sha1").update(projectPath).digest("hex").slice(0, 8)
config.agentId = `${config.agentId}-${projectName}-${projectHash}`
return config
}

function sanitizeProjectName(value) {
return String(value || "").replace(/[^A-Za-z0-9._-]/g, "")
}

function normalizeNumber(value, fallback, min, max) {
const next = Number(value)
if (!Number.isFinite(next)) return fallback
Expand All @@ -87,13 +108,13 @@ export function loadConfig(pluginRoot, projectDirectory) {
try {
if (fs.existsSync(configPath)) {
const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8"))
return mergeConfig(fileConfig)
return resolveProjectAgentId(mergeConfig(fileConfig), projectDirectory)
}
} catch (error) {
console.warn(`Failed to load OpenViking config from ${configPath}:`, error)
}
}
return mergeConfig()
return resolveProjectAgentId(mergeConfig(), projectDirectory)
}

function getConfigPaths(pluginRoot, projectDirectory) {
Expand Down
94 changes: 94 additions & 0 deletions examples/opencode-plugin/tests/test-project-isolation.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import assert from "node:assert/strict"
import fs from "node:fs"
import os from "node:os"
import path from "node:path"
import { loadConfig, makeRequest } from "../lib/utils.mjs"

const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openviking-opencode-plugin-"))
const pluginRoot = path.join(tempRoot, "plugin")
const configPath = path.join(tempRoot, "openviking-config.json")

fs.mkdirSync(pluginRoot, { recursive: true })

const originalEnv = {
OPENVIKING_PLUGIN_CONFIG: process.env.OPENVIKING_PLUGIN_CONFIG,
OPENVIKING_API_KEY: process.env.OPENVIKING_API_KEY,
OPENVIKING_ACCOUNT: process.env.OPENVIKING_ACCOUNT,
OPENVIKING_USER: process.env.OPENVIKING_USER,
OPENVIKING_AGENT_ID: process.env.OPENVIKING_AGENT_ID,
OPENVIKING_AGENT_ID_OVERRIDE: process.env.OPENVIKING_AGENT_ID_OVERRIDE,
}

function restoreEnv() {
for (const [key, value] of Object.entries(originalEnv)) {
if (value === undefined) {
delete process.env[key]
} else {
process.env[key] = value
}
}
}

function resetEnv() {
for (const key of Object.keys(originalEnv)) delete process.env[key]
process.env.OPENVIKING_PLUGIN_CONFIG = configPath
}

function writeConfig(config) {
fs.writeFileSync(configPath, JSON.stringify(config), "utf8")
}

function loadFor(directory) {
return loadConfig(pluginRoot, directory)
}

try {
resetEnv()
writeConfig({ agentId: "shared-agent", projectIsolation: true })

const alphaDir = path.join(tempRoot, "project-alpha")
const betaDir = path.join(tempRoot, "project-beta")
const alphaConfig = loadFor(alphaDir)
const betaConfig = loadFor(betaDir)

assert.notEqual(alphaConfig.agentId, betaConfig.agentId)
assert.match(alphaConfig.agentId, /^shared-agent-project-alpha-[a-f0-9]{8}$/)
assert.match(betaConfig.agentId, /^shared-agent-project-beta-[a-f0-9]{8}$/)
assert.equal(loadFor(alphaDir).agentId, alphaConfig.agentId)

writeConfig({ agentId: "shared-agent", projectIsolation: false })
assert.equal(loadFor(alphaDir).agentId, "shared-agent")
assert.equal(loadFor(betaDir).agentId, "shared-agent")

writeConfig({ agentId: "shared-agent", projectIsolation: true })
process.env.OPENVIKING_AGENT_ID = "env-agent"
process.env.OPENVIKING_AGENT_ID_OVERRIDE = "override-agent"
assert.equal(loadFor(alphaDir).agentId, "override-agent")
assert.equal(loadFor(betaDir).agentId, "override-agent")

delete process.env.OPENVIKING_AGENT_ID
delete process.env.OPENVIKING_AGENT_ID_OVERRIDE
assert.equal(loadFor(undefined).agentId, "shared-agent")

const unicodeDir = path.join(tempRoot, "项目 alpha!")
assert.match(loadFor(unicodeDir).agentId, /^shared-agent-alpha-[a-f0-9]{8}$/)

const config = loadFor(alphaDir)
const originalFetch = globalThis.fetch
globalThis.fetch = async (_url, options) => {
assert.equal(options.headers["X-OpenViking-Agent"], config.agentId)
return {
ok: true,
status: 200,
text: async () => "{}",
}
}
try {
await makeRequest(config, { endpoint: "/api/v1/ping", method: "GET" })
} finally {
globalThis.fetch = originalFetch
}
} finally {
restoreEnv()
fs.rmSync(tempRoot, { recursive: true, force: true })
}