@openviking/opencode-plugin 0.1.6 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/INSTALL-ZH.md +50 -49
- package/INSTALL.md +50 -49
- package/README.md +40 -123
- package/index.mjs +20 -12
- package/lib/config.mjs +272 -0
- package/lib/mcp-config.mjs +21 -0
- package/lib/memory-recall.mjs +21 -172
- package/lib/memory-session.mjs +301 -478
- package/lib/repo-context.mjs +5 -5
- package/lib/session-inject.mjs +71 -0
- package/lib/shared/capture-utils.mjs +500 -0
- package/lib/shared/credentials.mjs +207 -0
- package/lib/shared/debug-log.mjs +58 -0
- package/lib/shared/mcp-proxy-core.mjs +451 -0
- package/lib/shared/pending-queue.mjs +439 -0
- package/lib/shared/profile-inject.mjs +294 -0
- package/lib/shared/recall-core.mjs +282 -0
- package/lib/shared/session-model.mjs +57 -0
- package/lib/shared/setup-wizard.mjs +111 -0
- package/lib/shared/uri-guard.mjs +56 -0
- package/lib/utils.mjs +33 -116
- package/lib/viking-uri-guard.mjs +9 -24
- package/package.json +8 -7
- package/scripts/setup.mjs +8 -0
- package/servers/mcp-proxy.mjs +67 -0
- package/servers/mcp-proxy.test.mjs +100 -0
- package/lib/code-tools.mjs +0 -134
- package/lib/memadd-local.mjs +0 -118
- package/lib/memory-tools.mjs +0 -410
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Interactive ovcli.conf setup wizard shared by the OpenViking memory plugins.
|
|
4
|
+
*
|
|
5
|
+
* Lets a pure-marketplace install (no installer script) configure the server
|
|
6
|
+
* URL and API key that both the lifecycle hooks and the stdio MCP proxy read:
|
|
7
|
+
*
|
|
8
|
+
* node <plugin>/scripts/setup.mjs
|
|
9
|
+
*
|
|
10
|
+
* Conventions: show current values, keep existing secrets on empty input,
|
|
11
|
+
* merge-write (never drop unknown fields), back up the previous file, 0600.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
|
|
15
|
+
import { dirname } from "node:path";
|
|
16
|
+
import { createInterface } from "node:readline/promises";
|
|
17
|
+
import { loadCredentialFiles } from "./credentials.mjs";
|
|
18
|
+
|
|
19
|
+
const CLOUD_URL = "https://api.vikingdb.cn-beijing.volces.com/openviking";
|
|
20
|
+
const LOCAL_URL = "http://127.0.0.1:1933";
|
|
21
|
+
|
|
22
|
+
function maskSecret(value) {
|
|
23
|
+
const s = String(value || "");
|
|
24
|
+
if (!s) return "(not set)";
|
|
25
|
+
if (s.length <= 8) return "****";
|
|
26
|
+
return `${s.slice(0, 4)}…${s.slice(-4)} (${s.length} chars)`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readJsonSafe(path) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(path, "utf-8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return {};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runSetupWizard({
|
|
38
|
+
input = process.stdin,
|
|
39
|
+
output = process.stdout,
|
|
40
|
+
env = process.env,
|
|
41
|
+
} = {}) {
|
|
42
|
+
const { cliPath } = loadCredentialFiles(env);
|
|
43
|
+
const current = readJsonSafe(cliPath);
|
|
44
|
+
const rl = createInterface({ input, output });
|
|
45
|
+
const say = (line = "") => output.write(`${line}\n`);
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
say("OpenViking memory plugin setup");
|
|
49
|
+
say(`Config file: ${cliPath}`);
|
|
50
|
+
say("");
|
|
51
|
+
say("Current values:");
|
|
52
|
+
say(` url: ${current.url || "(not set)"}`);
|
|
53
|
+
say(` api_key: ${maskSecret(current.api_key)}`);
|
|
54
|
+
if (current.account) say(` account: ${current.account}`);
|
|
55
|
+
if (current.user) say(` user: ${current.user}`);
|
|
56
|
+
say("");
|
|
57
|
+
|
|
58
|
+
const defaultUrl = current.url || LOCAL_URL;
|
|
59
|
+
say("Where do you connect to OpenViking?");
|
|
60
|
+
say(` 1) Self-hosted / local [${LOCAL_URL}]`);
|
|
61
|
+
say(` 2) Volcengine OpenViking Cloud [${CLOUD_URL}]`);
|
|
62
|
+
say(` 3) Custom URL / keep current [${defaultUrl}]`);
|
|
63
|
+
const mode = (await rl.question("Choice [1/2/3, default 3]: ")).trim();
|
|
64
|
+
let url = defaultUrl;
|
|
65
|
+
if (mode === "1") {
|
|
66
|
+
url = LOCAL_URL;
|
|
67
|
+
} else if (mode === "2") {
|
|
68
|
+
url = CLOUD_URL;
|
|
69
|
+
} else {
|
|
70
|
+
const answer = (await rl.question(`Server URL [${defaultUrl}]: `)).trim();
|
|
71
|
+
if (answer) url = answer;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const keyPrompt = current.api_key
|
|
75
|
+
? `API key [enter = keep ${maskSecret(current.api_key)}, '-' = clear]: `
|
|
76
|
+
: "API key (leave empty for unauthenticated local mode): ";
|
|
77
|
+
const keyAnswer = await rl.question(keyPrompt);
|
|
78
|
+
let apiKey = current.api_key || "";
|
|
79
|
+
if (keyAnswer === "-") apiKey = "";
|
|
80
|
+
else if (keyAnswer.trim()) apiKey = keyAnswer.trim();
|
|
81
|
+
else if (!current.api_key) apiKey = "";
|
|
82
|
+
|
|
83
|
+
const next = { ...current, url, api_key: apiKey };
|
|
84
|
+
|
|
85
|
+
say("");
|
|
86
|
+
say("Changes:");
|
|
87
|
+
say(` url: ${current.url || "(not set)"} -> ${next.url}`);
|
|
88
|
+
say(` api_key: ${maskSecret(current.api_key)} -> ${maskSecret(next.api_key)}`);
|
|
89
|
+
const confirm = (await rl.question("Write config? [Y/n] ")).trim().toLowerCase();
|
|
90
|
+
if (confirm === "n" || confirm === "no") {
|
|
91
|
+
say("Aborted; nothing written.");
|
|
92
|
+
return { written: false, path: cliPath };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
mkdirSync(dirname(cliPath), { recursive: true });
|
|
96
|
+
if (existsSync(cliPath)) {
|
|
97
|
+
copyFileSync(cliPath, `${cliPath}.bak.${Date.now()}`);
|
|
98
|
+
}
|
|
99
|
+
writeFileSync(cliPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
100
|
+
try {
|
|
101
|
+
chmodSync(cliPath, 0o600);
|
|
102
|
+
} catch {
|
|
103
|
+
/* best effort on platforms without chmod semantics */
|
|
104
|
+
}
|
|
105
|
+
say(`Written: ${cliPath}`);
|
|
106
|
+
say("The stdio MCP proxy and hooks pick this up on the next harness start.");
|
|
107
|
+
return { written: true, path: cliPath };
|
|
108
|
+
} finally {
|
|
109
|
+
rl.close();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
const DEFAULT_URI_KEYS = [
|
|
3
|
+
"filePath",
|
|
4
|
+
"file_path",
|
|
5
|
+
"filepath",
|
|
6
|
+
"path",
|
|
7
|
+
"uri",
|
|
8
|
+
"target_uri",
|
|
9
|
+
"targetUri",
|
|
10
|
+
"pattern",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
export function normalizeToolName(value) {
|
|
14
|
+
return String(value || "").trim().toLowerCase();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function findVikingUri(args = {}, keys = DEFAULT_URI_KEYS) {
|
|
18
|
+
if (!args || typeof args !== "object") return null;
|
|
19
|
+
for (const key of keys) {
|
|
20
|
+
const uri = findVikingUriInValue(args[key]);
|
|
21
|
+
if (uri) return uri;
|
|
22
|
+
}
|
|
23
|
+
return findVikingUriInValue(args);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function findVikingUriInValue(value) {
|
|
27
|
+
if (typeof value === "string") {
|
|
28
|
+
const match = value.match(/\bviking:\/\/[^\s"'`<>)]*/i);
|
|
29
|
+
return match?.[0] || null;
|
|
30
|
+
}
|
|
31
|
+
if (Array.isArray(value)) {
|
|
32
|
+
for (const item of value) {
|
|
33
|
+
const uri = findVikingUriInValue(item);
|
|
34
|
+
if (uri) return uri;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
if (value && typeof value === "object") {
|
|
39
|
+
for (const item of Object.values(value)) {
|
|
40
|
+
const uri = findVikingUriInValue(item);
|
|
41
|
+
if (uri) return uri;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function buildGuardMessage(uri, hint = {}) {
|
|
48
|
+
const tool = hint.tool || "the OpenViking MCP tools";
|
|
49
|
+
const example = typeof hint.example === "function" ? hint.example(uri) : hint.example;
|
|
50
|
+
const lines = [
|
|
51
|
+
"viking:// URIs are OpenViking virtual paths, not local filesystem paths.",
|
|
52
|
+
`Use ${tool} instead.`,
|
|
53
|
+
];
|
|
54
|
+
if (example) lines.push(`Example: ${example}`);
|
|
55
|
+
return lines.join("\n");
|
|
56
|
+
}
|
package/lib/utils.mjs
CHANGED
|
@@ -1,123 +1,8 @@
|
|
|
1
1
|
import fs from "fs"
|
|
2
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
3
|
|
|
30
4
|
let logFilePath = null
|
|
31
5
|
|
|
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
6
|
export function initLogger(dataDir) {
|
|
122
7
|
fs.mkdirSync(dataDir, { recursive: true })
|
|
123
8
|
logFilePath = path.join(dataDir, "openviking-memory.log")
|
|
@@ -183,6 +68,38 @@ export function effectivePeerId(config) {
|
|
|
183
68
|
return String(config.peerId || "").trim() || null
|
|
184
69
|
}
|
|
185
70
|
|
|
71
|
+
export async function fetchJSON(config, endpoint, init = {}, options = {}) {
|
|
72
|
+
const url = `${normalizeEndpoint(config.endpoint)}${endpoint}`
|
|
73
|
+
const headers = makeAuthHeaders(
|
|
74
|
+
config,
|
|
75
|
+
{ "Content-Type": "application/json", ...(init.headers ?? {}) },
|
|
76
|
+
options.actorPeerId,
|
|
77
|
+
)
|
|
78
|
+
const controller = new AbortController()
|
|
79
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? config.timeoutMs)
|
|
80
|
+
try {
|
|
81
|
+
const response = await fetch(url, {
|
|
82
|
+
...init,
|
|
83
|
+
headers,
|
|
84
|
+
signal: controller.signal,
|
|
85
|
+
})
|
|
86
|
+
const text = await response.text()
|
|
87
|
+
const payload = text ? parseJsonOrText(text) : {}
|
|
88
|
+
if (!response.ok || payload?.status === "error") {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
status: response.status,
|
|
92
|
+
error: payload?.error || payload?.message || { message: `HTTP ${response.status}` },
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return { ok: true, status: response.status, result: payload?.result ?? payload }
|
|
96
|
+
} catch (error) {
|
|
97
|
+
return { ok: false, status: 0, error: { message: error?.message ?? String(error) } }
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(timeout)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
186
103
|
export async function makeRequest(config, options) {
|
|
187
104
|
const url = `${normalizeEndpoint(config.endpoint)}${options.endpoint}`
|
|
188
105
|
const headers = makeAuthHeaders(
|
|
@@ -291,7 +208,7 @@ export async function makeMultipartRequest(config, options) {
|
|
|
291
208
|
|
|
292
209
|
function makeAuthHeaders(config, headers = {}, actorPeerId = "") {
|
|
293
210
|
const result = { ...headers }
|
|
294
|
-
if (config.apiKey) result["
|
|
211
|
+
if (config.apiKey) result["Authorization"] = `Bearer ${config.apiKey}`
|
|
295
212
|
if (config.account) result["X-OpenViking-Account"] = config.account
|
|
296
213
|
if (config.user) result["X-OpenViking-User"] = config.user
|
|
297
214
|
const peerId = String(actorPeerId || "").trim()
|
package/lib/viking-uri-guard.mjs
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
import { log } from "./utils.mjs"
|
|
2
|
+
import { buildGuardMessage, findVikingUri, normalizeToolName } from "./shared/uri-guard.mjs"
|
|
2
3
|
|
|
3
4
|
const FILESYSTEM_TOOL_HINTS = {
|
|
4
5
|
read: {
|
|
5
|
-
|
|
6
|
-
example: (uri) => `
|
|
6
|
+
tool: "openviking_read",
|
|
7
|
+
example: (uri) => `openviking_read(uri="${uri}")`,
|
|
7
8
|
},
|
|
8
9
|
glob: {
|
|
9
|
-
|
|
10
|
-
example: (uri) => `
|
|
10
|
+
tool: "openviking_glob",
|
|
11
|
+
example: (uri) => `openviking_glob(uri="${uri}", pattern="**/*")`,
|
|
11
12
|
},
|
|
12
13
|
grep: {
|
|
13
|
-
|
|
14
|
-
example: (uri, args) => `
|
|
14
|
+
tool: "openviking_search",
|
|
15
|
+
example: (uri, args = {}) => `openviking_search(query="${String(args.pattern ?? "").replaceAll('"', '\\"')}", target_uri="${uri}")`,
|
|
15
16
|
},
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -29,24 +30,8 @@ export function createVikingUriGuard() {
|
|
|
29
30
|
tool: toolName,
|
|
30
31
|
uri,
|
|
31
32
|
})
|
|
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
|
-
)
|
|
33
|
+
throw new Error(buildGuardMessage(uri, { ...hint, example: hint.example(uri, args) }))
|
|
39
34
|
}
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
export
|
|
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
|
-
}
|
|
37
|
+
export { findVikingUri, normalizeToolName }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/opencode-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"author": "@openviking",
|
|
5
5
|
"description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
|
|
6
6
|
"type": "module",
|
|
@@ -10,22 +10,23 @@
|
|
|
10
10
|
"tui"
|
|
11
11
|
],
|
|
12
12
|
"exports": {
|
|
13
|
-
".": "./index.mjs"
|
|
13
|
+
".": "./index.mjs",
|
|
14
|
+
"./servers/mcp-proxy.mjs": "./servers/mcp-proxy.mjs"
|
|
14
15
|
},
|
|
15
16
|
"files": [
|
|
16
17
|
"index.mjs",
|
|
17
18
|
"lib/",
|
|
19
|
+
"servers/",
|
|
20
|
+
"scripts/",
|
|
18
21
|
"README.md",
|
|
19
22
|
"INSTALL.md",
|
|
20
23
|
"INSTALL-ZH.md"
|
|
21
24
|
],
|
|
22
25
|
"scripts": {
|
|
23
|
-
"check": "node --check index.mjs && node --check lib/
|
|
24
|
-
"test": "node --test tests/*.test.mjs"
|
|
25
|
-
},
|
|
26
|
-
"dependencies": {
|
|
27
|
-
"@opencode-ai/plugin": "^1.14.37"
|
|
26
|
+
"check": "node --check index.mjs && node --check lib/config.mjs && node --check lib/mcp-config.mjs && node --check lib/runtime.mjs && node --check lib/repo-context.mjs && node --check lib/memory-session.mjs && node --check lib/memory-recall.mjs && node --check lib/session-inject.mjs && node --check lib/viking-uri-guard.mjs && node --check lib/utils.mjs && node --check lib/shared/*.mjs && node --check servers/mcp-proxy.mjs && node --check scripts/setup.mjs && node --check tests/*.test.mjs",
|
|
27
|
+
"test": "node --test tests/*.test.mjs servers/*.test.mjs"
|
|
28
28
|
},
|
|
29
|
+
"dependencies": {},
|
|
29
30
|
"publishConfig": {
|
|
30
31
|
"access": "public"
|
|
31
32
|
},
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* stdio -> streamable-HTTP MCP proxy for the OpenViking OpenCode plugin.
|
|
5
|
+
*
|
|
6
|
+
* OpenCode starts this process as a local MCP server. The proxy reads the same
|
|
7
|
+
* OpenViking credential sources as the lifecycle hooks, forwards JSON-RPC
|
|
8
|
+
* requests to the server's /mcp endpoint, and keeps stdout protocol-clean.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { homedir } from "node:os"
|
|
12
|
+
import { join, resolve as resolvePath } from "node:path"
|
|
13
|
+
import { fileURLToPath } from "node:url"
|
|
14
|
+
import { loadConfig } from "../lib/config.mjs"
|
|
15
|
+
import { createLogger } from "../lib/shared/debug-log.mjs"
|
|
16
|
+
import { createOpenVikingMcpProxy } from "../lib/shared/mcp-proxy-core.mjs"
|
|
17
|
+
|
|
18
|
+
export { createOpenVikingMcpProxy } from "../lib/shared/mcp-proxy-core.mjs"
|
|
19
|
+
|
|
20
|
+
const DEFAULT_TIMEOUT_MS = 15000
|
|
21
|
+
|
|
22
|
+
function trimSlash(value) {
|
|
23
|
+
return String(value || "").replace(/\/+$/, "")
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function normalizePath(value) {
|
|
27
|
+
const raw = String(value || "").trim()
|
|
28
|
+
if (!raw) return ""
|
|
29
|
+
if (raw === "~") return homedir()
|
|
30
|
+
if (raw.startsWith("~/")) return resolvePath(join(homedir(), raw.slice(2)))
|
|
31
|
+
return resolvePath(raw)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function uniq(values) {
|
|
35
|
+
return [...new Set(values.filter(Boolean))]
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readProxyConfig() {
|
|
39
|
+
const cfg = loadConfig(resolvePath(fileURLToPath(import.meta.url), "..", ".."))
|
|
40
|
+
const mcpUrl = cfg.mcpUrl || `${trimSlash(cfg.endpoint)}/mcp`
|
|
41
|
+
return {
|
|
42
|
+
mcpUrl,
|
|
43
|
+
apiKey: cfg.apiKey || "",
|
|
44
|
+
account: cfg.account || "",
|
|
45
|
+
user: cfg.user || "",
|
|
46
|
+
peerId: cfg.peerId || "",
|
|
47
|
+
timeoutMs: Math.max(1000, Number(cfg.timeoutMs) || DEFAULT_TIMEOUT_MS),
|
|
48
|
+
debug: cfg.debug === true,
|
|
49
|
+
debugLogPath: cfg.debugLogPath,
|
|
50
|
+
credentialSource: cfg.credentialSource || "auto",
|
|
51
|
+
credentialPath: cfg.credentialPath || cfg.configPath || "",
|
|
52
|
+
watchedPaths: uniq([
|
|
53
|
+
cfg.credentialPath,
|
|
54
|
+
cfg.configPath,
|
|
55
|
+
normalizePath(process.env.OPENVIKING_CLI_CONFIG_FILE),
|
|
56
|
+
normalizePath(process.env.OPENVIKING_CONFIG_FILE),
|
|
57
|
+
normalizePath(process.env.OPENVIKING_PLUGIN_CONFIG),
|
|
58
|
+
join(homedir(), ".openviking", "ovcli.conf"),
|
|
59
|
+
join(homedir(), ".openviking", "ov.conf"),
|
|
60
|
+
join(homedir(), ".config", "opencode", "openviking-config.json"),
|
|
61
|
+
]),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (process.argv[1] && fileURLToPath(import.meta.url) === resolvePath(process.argv[1])) {
|
|
66
|
+
createOpenVikingMcpProxy({ readConfig: readProxyConfig, loggerFactory: createLogger }).start()
|
|
67
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import test from "node:test"
|
|
2
|
+
import assert from "node:assert/strict"
|
|
3
|
+
import { createServer } from "node:http"
|
|
4
|
+
import { Writable } from "node:stream"
|
|
5
|
+
import { createOpenVikingMcpProxy } from "./mcp-proxy.mjs"
|
|
6
|
+
|
|
7
|
+
function jsonRpc(id, result = {}) {
|
|
8
|
+
return { jsonrpc: "2.0", id, result }
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sseMessage(obj) {
|
|
12
|
+
return `event: message\r\ndata: ${JSON.stringify(obj)}\r\n\r\n`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function readBody(req) {
|
|
16
|
+
const chunks = []
|
|
17
|
+
for await (const chunk of req) chunks.push(chunk)
|
|
18
|
+
return Buffer.concat(chunks).toString("utf-8")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function withServer(handler, fn) {
|
|
22
|
+
const requests = []
|
|
23
|
+
const server = createServer(async (req, res) => {
|
|
24
|
+
const body = await readBody(req)
|
|
25
|
+
const entry = {
|
|
26
|
+
method: req.method,
|
|
27
|
+
url: req.url,
|
|
28
|
+
headers: req.headers,
|
|
29
|
+
body: body ? JSON.parse(body) : null,
|
|
30
|
+
}
|
|
31
|
+
requests.push(entry)
|
|
32
|
+
await handler(req, res, entry)
|
|
33
|
+
})
|
|
34
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
|
|
35
|
+
try {
|
|
36
|
+
const { port } = server.address()
|
|
37
|
+
return await fn({ url: `http://127.0.0.1:${port}/mcp`, requests })
|
|
38
|
+
} finally {
|
|
39
|
+
await new Promise((resolve) => server.close(resolve))
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function makeProxy(url) {
|
|
44
|
+
const out = []
|
|
45
|
+
const stdout = new Writable({
|
|
46
|
+
write(chunk, _encoding, callback) {
|
|
47
|
+
out.push(chunk.toString("utf-8"))
|
|
48
|
+
callback()
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
const proxy = createOpenVikingMcpProxy({
|
|
52
|
+
stdout,
|
|
53
|
+
readConfig: () => ({
|
|
54
|
+
mcpUrl: url,
|
|
55
|
+
apiKey: "test-key",
|
|
56
|
+
account: "acct",
|
|
57
|
+
user: "user",
|
|
58
|
+
peerId: "peer",
|
|
59
|
+
timeoutMs: 5000,
|
|
60
|
+
debug: false,
|
|
61
|
+
debugLogPath: "",
|
|
62
|
+
credentialSource: "test",
|
|
63
|
+
credentialPath: "",
|
|
64
|
+
watchedPaths: [],
|
|
65
|
+
}),
|
|
66
|
+
loggerFactory: () => ({ log() {}, logError() {} }),
|
|
67
|
+
})
|
|
68
|
+
return {
|
|
69
|
+
proxy,
|
|
70
|
+
async messages() {
|
|
71
|
+
await new Promise((resolve) => setImmediate(resolve))
|
|
72
|
+
return out.join("").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line))
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test("OpenCode MCP proxy forwards initialize with auth and identity headers", async () => {
|
|
78
|
+
await withServer((_req, res, entry) => {
|
|
79
|
+
assert.equal(entry.method, "POST")
|
|
80
|
+
assert.equal(entry.headers.authorization, "Bearer test-key")
|
|
81
|
+
assert.equal(entry.headers["x-openviking-account"], "acct")
|
|
82
|
+
assert.equal(entry.headers["x-openviking-user"], "user")
|
|
83
|
+
assert.equal(entry.headers["x-openviking-actor-peer"], "peer")
|
|
84
|
+
res.writeHead(200, {
|
|
85
|
+
"content-type": "text/event-stream",
|
|
86
|
+
"mcp-session-id": "sid-oc",
|
|
87
|
+
})
|
|
88
|
+
res.end(sseMessage(jsonRpc(1, { protocolVersion: "2025-06-18" })))
|
|
89
|
+
}, async ({ url, requests }) => {
|
|
90
|
+
const { proxy, messages } = makeProxy(url)
|
|
91
|
+
await proxy.handleMessage({
|
|
92
|
+
jsonrpc: "2.0",
|
|
93
|
+
id: 1,
|
|
94
|
+
method: "initialize",
|
|
95
|
+
params: { protocolVersion: "2025-06-18" },
|
|
96
|
+
})
|
|
97
|
+
assert.deepEqual(await messages(), [jsonRpc(1, { protocolVersion: "2025-06-18" })])
|
|
98
|
+
assert.equal(requests.length, 1)
|
|
99
|
+
})
|
|
100
|
+
})
|