@openviking/opencode-plugin 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/utils.mjs ADDED
@@ -0,0 +1,341 @@
1
+ import fs from "fs"
2
+ import path from "path"
3
+ import { homedir } from "os"
4
+
5
+ export const DEFAULT_CONFIG = {
6
+ endpoint: "http://localhost:1933",
7
+ apiKey: "",
8
+ account: "",
9
+ user: "",
10
+ peerId: "",
11
+ enabled: true,
12
+ timeoutMs: 30000,
13
+ runtime: {
14
+ dataDir: "",
15
+ },
16
+ repoContext: {
17
+ enabled: true,
18
+ cacheTtlMs: 60000,
19
+ },
20
+ autoRecall: {
21
+ enabled: true,
22
+ limit: 6,
23
+ scoreThreshold: 0.15,
24
+ maxContentChars: 500,
25
+ preferAbstract: true,
26
+ tokenBudget: 2000,
27
+ },
28
+ }
29
+
30
+ let logFilePath = null
31
+
32
+ function cloneDefaultConfig() {
33
+ return JSON.parse(JSON.stringify(DEFAULT_CONFIG))
34
+ }
35
+
36
+ function mergeConfig(fileConfig = {}) {
37
+ const config = cloneDefaultConfig()
38
+ for (const key of ["endpoint", "apiKey", "account", "user", "peerId", "enabled", "timeoutMs"]) {
39
+ if (fileConfig[key] !== undefined) config[key] = fileConfig[key]
40
+ }
41
+ config.runtime = {
42
+ ...DEFAULT_CONFIG.runtime,
43
+ dataDir: fileConfig.runtime?.dataDir ?? DEFAULT_CONFIG.runtime.dataDir,
44
+ }
45
+ config.repoContext = { ...DEFAULT_CONFIG.repoContext, ...(fileConfig.repoContext ?? {}) }
46
+ config.autoRecall = { ...DEFAULT_CONFIG.autoRecall, ...(fileConfig.autoRecall ?? {}) }
47
+
48
+ if (process.env.OPENVIKING_API_KEY) {
49
+ config.apiKey = process.env.OPENVIKING_API_KEY
50
+ }
51
+ if (process.env.OPENVIKING_ACCOUNT) {
52
+ config.account = process.env.OPENVIKING_ACCOUNT
53
+ }
54
+ if (process.env.OPENVIKING_USER) {
55
+ config.user = process.env.OPENVIKING_USER
56
+ }
57
+ if (process.env.OPENVIKING_PEER_ID) {
58
+ config.peerId = process.env.OPENVIKING_PEER_ID
59
+ }
60
+
61
+ config.timeoutMs = normalizeNumber(config.timeoutMs, DEFAULT_CONFIG.timeoutMs, 1000, 300000)
62
+ config.repoContext.cacheTtlMs = normalizeNumber(
63
+ config.repoContext.cacheTtlMs,
64
+ DEFAULT_CONFIG.repoContext.cacheTtlMs,
65
+ 1000,
66
+ 60 * 60 * 1000,
67
+ )
68
+ clampRecallConfig(config.autoRecall)
69
+ return config
70
+ }
71
+
72
+ function normalizeNumber(value, fallback, min, max) {
73
+ const next = Number(value)
74
+ if (!Number.isFinite(next)) return fallback
75
+ return Math.max(min, Math.min(max, next))
76
+ }
77
+
78
+ function clampRecallConfig(recall) {
79
+ recall.limit = Math.max(1, Math.min(50, Math.round(Number(recall.limit) || 6)))
80
+ recall.scoreThreshold = Math.max(0, Math.min(1, Number(recall.scoreThreshold) || 0))
81
+ recall.maxContentChars = Math.max(100, Math.min(5000, Math.round(Number(recall.maxContentChars) || 500)))
82
+ recall.tokenBudget = Math.max(100, Math.min(10000, Math.round(Number(recall.tokenBudget) || 2000)))
83
+ }
84
+
85
+ export function loadConfig(pluginRoot, projectDirectory) {
86
+ for (const configPath of getConfigPaths(pluginRoot, projectDirectory)) {
87
+ try {
88
+ if (fs.existsSync(configPath)) {
89
+ const fileConfig = JSON.parse(fs.readFileSync(configPath, "utf8"))
90
+ return mergeConfig(fileConfig)
91
+ }
92
+ } catch (error) {
93
+ console.warn(`Failed to load OpenViking config from ${configPath}:`, error)
94
+ }
95
+ }
96
+ return mergeConfig()
97
+ }
98
+
99
+ function getConfigPaths(pluginRoot, projectDirectory) {
100
+ const paths = []
101
+ if (process.env.OPENVIKING_PLUGIN_CONFIG) paths.push(expandHome(process.env.OPENVIKING_PLUGIN_CONFIG))
102
+ if (projectDirectory) paths.push(path.join(projectDirectory, ".opencode", "openviking-config.json"))
103
+ paths.push(path.join(homedir(), ".config", "opencode", "openviking-config.json"))
104
+ paths.push(path.join(pluginRoot, "openviking-config.json"))
105
+ return paths
106
+ }
107
+
108
+ export function resolveDataDir(pluginRoot, config) {
109
+ const configured = config.runtime?.dataDir
110
+ if (configured) return expandHome(configured)
111
+ return path.join(homedir(), ".config", "opencode", "openviking")
112
+ }
113
+
114
+ function expandHome(value) {
115
+ if (!value || typeof value !== "string") return value
116
+ if (value === "~") return homedir()
117
+ if (value.startsWith("~/") || value.startsWith("~\\")) return path.join(homedir(), value.slice(2))
118
+ return value
119
+ }
120
+
121
+ export function initLogger(dataDir) {
122
+ fs.mkdirSync(dataDir, { recursive: true })
123
+ logFilePath = path.join(dataDir, "openviking-memory.log")
124
+ }
125
+
126
+ export function safeStringify(value) {
127
+ if (value === null || value === undefined) return value
128
+ if (typeof value !== "object") return value
129
+ if (Array.isArray(value)) return value.map((item) => safeStringify(item))
130
+
131
+ const result = {}
132
+ for (const key of Object.keys(value)) {
133
+ const item = value[key]
134
+ if (typeof item === "function") {
135
+ result[key] = "[Function]"
136
+ } else if (typeof item === "object" && item !== null) {
137
+ try {
138
+ result[key] = safeStringify(item)
139
+ } catch {
140
+ result[key] = "[Circular or Non-serializable]"
141
+ }
142
+ } else {
143
+ result[key] = item
144
+ }
145
+ }
146
+ return result
147
+ }
148
+
149
+ export function log(level, toolName, message, data) {
150
+ const normalizedLevel = String(level || "INFO").toUpperCase()
151
+ const entry = {
152
+ timestamp: new Date().toISOString(),
153
+ level: normalizedLevel,
154
+ tool: toolName,
155
+ message,
156
+ ...(data ? { data: safeStringify(data) } : {}),
157
+ }
158
+
159
+ if (!logFilePath) {
160
+ if (normalizedLevel === "ERROR") console.error(message, data ?? "")
161
+ return
162
+ }
163
+
164
+ try {
165
+ fs.appendFileSync(logFilePath, `${JSON.stringify(entry)}\n`, "utf8")
166
+ } catch (error) {
167
+ console.error("Failed to write OpenViking plugin log:", error)
168
+ }
169
+ }
170
+
171
+ export function makeToast(client) {
172
+ return (message, variant = "warning") =>
173
+ client?.tui?.showToast?.({
174
+ body: { title: "OpenViking", message, variant, duration: 8000 },
175
+ }).catch(() => {})
176
+ }
177
+
178
+ export function normalizeEndpoint(endpoint) {
179
+ return endpoint.replace(/\/+$/, "")
180
+ }
181
+
182
+ export function effectivePeerId(config) {
183
+ return String(config.peerId || "").trim() || null
184
+ }
185
+
186
+ export async function makeRequest(config, options) {
187
+ const url = `${normalizeEndpoint(config.endpoint)}${options.endpoint}`
188
+ const headers = makeAuthHeaders(
189
+ config,
190
+ { "Content-Type": "application/json", ...(options.headers ?? {}) },
191
+ options.actorPeerId,
192
+ )
193
+
194
+ const controller = new AbortController()
195
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? config.timeoutMs)
196
+ let onAbort = null
197
+
198
+ if (options.abortSignal) {
199
+ if (options.abortSignal.aborted) controller.abort()
200
+ onAbort = () => controller.abort()
201
+ options.abortSignal.addEventListener("abort", onAbort, { once: true })
202
+ }
203
+
204
+ try {
205
+ const response = await fetch(url, {
206
+ method: options.method,
207
+ headers,
208
+ body: options.body === undefined ? undefined : JSON.stringify(options.body),
209
+ signal: controller.signal,
210
+ })
211
+
212
+ const text = await response.text()
213
+ const payload = text ? parseJsonOrText(text) : {}
214
+
215
+ if (!response.ok) {
216
+ const rawError = typeof payload === "object" ? payload.error ?? payload.message : payload
217
+ const errorMessage = typeof rawError === "string" ? rawError : JSON.stringify(rawError)
218
+ if (response.status === 401 || response.status === 403) {
219
+ throw new Error("Authentication failed. Please check apiKey/account/user in openviking-config.json or OPENVIKING_* environment variables.")
220
+ }
221
+ throw new Error(`Request failed (${response.status}): ${errorMessage}`)
222
+ }
223
+
224
+ return payload
225
+ } catch (error) {
226
+ if (error?.name === "AbortError") {
227
+ throw new Error(`Request timeout after ${options.timeoutMs ?? config.timeoutMs}ms`)
228
+ }
229
+ if (error?.message?.includes("fetch failed") || error?.code === "ECONNREFUSED") {
230
+ throw new Error(`OpenViking service unavailable at ${config.endpoint}. Start it with: openviking-server --config ~/.openviking/ov.conf`)
231
+ }
232
+ throw error
233
+ } finally {
234
+ clearTimeout(timeout)
235
+ if (options.abortSignal && onAbort) {
236
+ options.abortSignal.removeEventListener("abort", onAbort)
237
+ }
238
+ }
239
+ }
240
+
241
+ export async function makeMultipartRequest(config, options) {
242
+ const url = `${normalizeEndpoint(config.endpoint)}${options.endpoint}`
243
+ const headers = makeAuthHeaders(config, options.headers ?? {}, options.actorPeerId)
244
+
245
+ const controller = new AbortController()
246
+ const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? config.timeoutMs)
247
+ let onAbort = null
248
+
249
+ if (options.abortSignal) {
250
+ if (options.abortSignal.aborted) controller.abort()
251
+ onAbort = () => controller.abort()
252
+ options.abortSignal.addEventListener("abort", onAbort, { once: true })
253
+ }
254
+
255
+ try {
256
+ const response = await fetch(url, {
257
+ method: options.method,
258
+ headers,
259
+ body: options.body,
260
+ signal: controller.signal,
261
+ })
262
+
263
+ const text = await response.text()
264
+ const payload = text ? parseJsonOrText(text) : {}
265
+
266
+ if (!response.ok) {
267
+ const rawError = typeof payload === "object" ? payload.error ?? payload.message : payload
268
+ const errorMessage = typeof rawError === "string" ? rawError : JSON.stringify(rawError)
269
+ if (response.status === 401 || response.status === 403) {
270
+ throw new Error("Authentication failed. Please check apiKey/account/user in openviking-config.json or OPENVIKING_* environment variables.")
271
+ }
272
+ throw new Error(`Request failed (${response.status}): ${errorMessage}`)
273
+ }
274
+
275
+ return payload
276
+ } catch (error) {
277
+ if (error?.name === "AbortError") {
278
+ throw new Error(`Request timeout after ${options.timeoutMs ?? config.timeoutMs}ms`)
279
+ }
280
+ if (error?.message?.includes("fetch failed") || error?.code === "ECONNREFUSED") {
281
+ throw new Error(`OpenViking service unavailable at ${config.endpoint}. Start it with: openviking-server --config ~/.openviking/ov.conf`)
282
+ }
283
+ throw error
284
+ } finally {
285
+ clearTimeout(timeout)
286
+ if (options.abortSignal && onAbort) {
287
+ options.abortSignal.removeEventListener("abort", onAbort)
288
+ }
289
+ }
290
+ }
291
+
292
+ function makeAuthHeaders(config, headers = {}, actorPeerId = "") {
293
+ const result = { ...headers }
294
+ if (config.apiKey) result["X-API-Key"] = config.apiKey
295
+ if (config.account) result["X-OpenViking-Account"] = config.account
296
+ if (config.user) result["X-OpenViking-User"] = config.user
297
+ const peerId = String(actorPeerId || "").trim()
298
+ if (peerId) result["X-OpenViking-Actor-Peer"] = peerId
299
+ return result
300
+ }
301
+
302
+ function parseJsonOrText(text) {
303
+ try {
304
+ return JSON.parse(text)
305
+ } catch {
306
+ return text
307
+ }
308
+ }
309
+
310
+ export function getResponseErrorMessage(error) {
311
+ if (!error) return "Unknown OpenViking error"
312
+ if (typeof error === "string") return error
313
+ return error.message || error.code || "Unknown OpenViking error"
314
+ }
315
+
316
+ export function unwrapResponse(response) {
317
+ if (!response || typeof response !== "object") {
318
+ throw new Error("OpenViking returned an invalid response")
319
+ }
320
+ if (response.status && response.status !== "ok") {
321
+ throw new Error(getResponseErrorMessage(response.error))
322
+ }
323
+ return response.result
324
+ }
325
+
326
+ export function validateVikingUri(uri, toolName = "tool") {
327
+ if (typeof uri !== "string" || !uri.startsWith("viking://")) {
328
+ log("ERROR", toolName, "Invalid Viking URI", { uri })
329
+ return 'Error: Invalid URI format. Must start with "viking://".'
330
+ }
331
+ return null
332
+ }
333
+
334
+ export function ensureRemoteUrl(value) {
335
+ try {
336
+ const url = new URL(value)
337
+ return url.protocol === "http:" || url.protocol === "https:"
338
+ } catch {
339
+ return false
340
+ }
341
+ }
@@ -0,0 +1,52 @@
1
+ import { log } from "./utils.mjs"
2
+
3
+ const FILESYSTEM_TOOL_HINTS = {
4
+ read: {
5
+ replacement: "memread",
6
+ example: (uri) => `memread(uri="${uri}", level="auto")`,
7
+ },
8
+ glob: {
9
+ replacement: "membrowse",
10
+ example: (uri) => `membrowse(uri="${uri}", view="list", recursive=true)`,
11
+ },
12
+ grep: {
13
+ replacement: "memsearch",
14
+ example: (uri, args) => `memsearch(query="${String(args.pattern ?? "").replaceAll('"', '\\"')}", target_uri="${uri}")`,
15
+ },
16
+ }
17
+
18
+ export function createVikingUriGuard() {
19
+ return async (input, output) => {
20
+ const toolName = normalizeToolName(input?.tool ?? input?.name)
21
+ const hint = FILESYSTEM_TOOL_HINTS[toolName]
22
+ if (!hint) return
23
+
24
+ const args = output?.args ?? input?.args ?? {}
25
+ const uri = findVikingUri(args)
26
+ if (!uri) return
27
+
28
+ log("INFO", "viking-uri-guard", "Blocked filesystem tool for viking URI", {
29
+ tool: toolName,
30
+ uri,
31
+ })
32
+ throw new Error(
33
+ [
34
+ "viking:// URIs are OpenViking virtual paths, not local filesystem paths.",
35
+ `Use ${hint.replacement} instead.`,
36
+ `Example: ${hint.example(uri, args)}`,
37
+ ].join("\n"),
38
+ )
39
+ }
40
+ }
41
+
42
+ export function findVikingUri(args = {}) {
43
+ for (const key of ["filePath", "path", "uri"]) {
44
+ const value = args[key]
45
+ if (typeof value === "string" && value.startsWith("viking://")) return value
46
+ }
47
+ return null
48
+ }
49
+
50
+ function normalizeToolName(value) {
51
+ return String(value || "").trim().toLowerCase()
52
+ }
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@openviking/opencode-plugin",
3
+ "version": "0.1.5",
4
+ "author": "tanyouqing",
5
+ "description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
6
+ "type": "module",
7
+ "main": "index.mjs",
8
+ "exports": {
9
+ ".": "./index.mjs"
10
+ },
11
+ "files": [
12
+ "index.mjs",
13
+ "lib/",
14
+ "README.md",
15
+ "INSTALL.md",
16
+ "INSTALL-ZH.md"
17
+ ],
18
+ "scripts": {
19
+ "check": "node --check index.mjs && node --check lib/runtime.mjs && node --check lib/repo-context.mjs && node --check lib/memory-session.mjs && node --check lib/memadd-local.mjs && node --check lib/memory-tools.mjs && node --check lib/code-tools.mjs && node --check lib/memory-recall.mjs && node --check lib/viking-uri-guard.mjs && node --check lib/utils.mjs && node --check tests/*.test.mjs",
20
+ "test": "node --test tests/*.test.mjs"
21
+ },
22
+ "dependencies": {
23
+ "@opencode-ai/plugin": "^1.14.37"
24
+ },
25
+ "publishConfig": {
26
+ "access": "public"
27
+ },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/volcengine/OpenViking.git",
31
+ "directory": "examples/opencode-plugin"
32
+ },
33
+ "keywords": [
34
+ "opencode",
35
+ "opencode-plugin",
36
+ "openviking",
37
+ "memory",
38
+ "rag",
39
+ "code-search"
40
+ ],
41
+ "license": "Apache-2.0"
42
+ }