@openviking/opencode-plugin 0.2.3 → 2026.9.9-1
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 +25 -7
- package/INSTALL.md +27 -7
- package/README.md +52 -13
- package/index.mjs +10 -3
- package/lib/config.mjs +50 -8
- package/lib/mcp-config.mjs +2 -1
- package/lib/memory-recall.mjs +19 -7
- package/lib/memory-session.mjs +61 -18
- package/lib/runtime.mjs +1 -0
- package/lib/shared/batch-send.mjs +2 -3
- package/lib/shared/capture-utils.mjs +99 -21
- package/lib/shared/credentials.mjs +32 -0
- package/lib/shared/input-filters.mjs +219 -0
- package/lib/shared/mcp-proxy-config.mjs +124 -0
- package/lib/shared/mcp-proxy-core.mjs +60 -2
- package/lib/shared/pending-queue.mjs +13 -3
- package/lib/shared/recall-compress-core.mjs +206 -0
- package/lib/shared/recall-core.mjs +394 -18
- package/lib/shared/retryable.mjs +9 -0
- package/lib/shared/setup-wizard.mjs +14 -11
- package/lib/shared/uri-guard.mjs +28 -6
- package/lib/shared/workspace-identity.mjs +334 -0
- package/lib/shared/workspace-peer.mjs +126 -8
- package/lib/utils.mjs +8 -1
- package/lib/viking-uri-guard.mjs +1 -1
- package/package.json +3 -2
- package/servers/mcp-proxy.mjs +16 -39
- package/servers/mcp-proxy.test.mjs +2 -0
- package/lib/shared/async-writer.mjs +0 -78
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Workspace identity: where the workspace root is, and what git calls it.
|
|
4
|
+
*
|
|
5
|
+
* Every hook is a fresh Node process and this runs on prompt-level paths, so
|
|
6
|
+
* the derivation is pure filesystem work — no `git` subprocess. That keeps it
|
|
7
|
+
* inside the tightest hook budget (Codex allows SessionEnd 3s against
|
|
8
|
+
* SessionStart's 70s), and it keeps working where git is absent from PATH or
|
|
9
|
+
* would refuse the repo over dubious ownership.
|
|
10
|
+
*
|
|
11
|
+
* The result is cached per cwd under the state dir so the several hooks of one
|
|
12
|
+
* turn pay for the walk once.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash } from "node:crypto";
|
|
16
|
+
import { mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { homedir } from "node:os";
|
|
18
|
+
import { dirname, isAbsolute, join, parse, resolve, sep } from "node:path";
|
|
19
|
+
|
|
20
|
+
// The workspace config file names live here, and `workspace-config.mjs`
|
|
21
|
+
// re-exports them: the walk below needs the filenames to recognise a directory
|
|
22
|
+
// someone marked as a workspace, and it must not pull in the config layer to
|
|
23
|
+
// learn them.
|
|
24
|
+
export const CONFIG_DIR_NAME = ".openviking";
|
|
25
|
+
export const TEAM_FILE = "config.json";
|
|
26
|
+
export const LOCAL_FILE = "config.local.json";
|
|
27
|
+
|
|
28
|
+
const IDENTITY_CACHE_TTL_MS = 60_000;
|
|
29
|
+
// 255 is the AGFS path-segment limit; stopping well short leaves room for the
|
|
30
|
+
// hash suffix and for anything that later prefixes a peer id.
|
|
31
|
+
const MAX_PEER_ID_LENGTH = 100;
|
|
32
|
+
|
|
33
|
+
export function stateDir(env = process.env) {
|
|
34
|
+
const explicit = String(env.OPENVIKING_STATE_DIR || "").trim();
|
|
35
|
+
if (explicit) return explicit;
|
|
36
|
+
const home = String(env.OPENVIKING_HOME || "").trim();
|
|
37
|
+
const base = home ? home.replace(/^~(?=$|\/)/, homedir()) : join(homedir(), ".openviking");
|
|
38
|
+
return join(base, "state");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function shortHash(value) {
|
|
42
|
+
return createHash("sha256").update(String(value)).digest("hex").slice(0, 12);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Legacy peer sanitation: one byte in, one byte out, no collapsing and no
|
|
47
|
+
* trimming, so a leading `/` still becomes a leading `-`. Kept byte-exact
|
|
48
|
+
* because `peer.source: "cwd"` and the legacy id that dual-read recomputes both
|
|
49
|
+
* depend on it — do not "improve" it.
|
|
50
|
+
*/
|
|
51
|
+
export function legacySanitize(value) {
|
|
52
|
+
return String(value || "").replace(/[^A-Za-z0-9]/g, "-");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Readable sanitation for values that were never path-shaped — a normalized
|
|
57
|
+
* remote, a directory name. Mirrors the server's own `_sanitize_component`
|
|
58
|
+
* (`openviking/ingest/peer.py:32`) so both languages agree on the id, then
|
|
59
|
+
* enforces what `validate_identifier_part` additionally requires.
|
|
60
|
+
*/
|
|
61
|
+
export function sanitizePeerId(value) {
|
|
62
|
+
const raw = String(value || "").trim();
|
|
63
|
+
let cleaned = raw.replace(/[^a-zA-Z0-9_.@-]+/g, "-").replace(/-{2,}/g, "-").replace(/^[-.]+|[-.]+$/g, "");
|
|
64
|
+
if (!cleaned) return "";
|
|
65
|
+
// The server accepts at most one `@` in an identifier part.
|
|
66
|
+
const at = cleaned.indexOf("@");
|
|
67
|
+
if (at !== -1) cleaned = cleaned.slice(0, at + 1) + cleaned.slice(at + 1).replace(/@/g, "-");
|
|
68
|
+
// `ext-` is the server's namespace for base64-encoded external identities,
|
|
69
|
+
// and `__self` its operation-target sentinel. Neither is ours to occupy.
|
|
70
|
+
if (cleaned.startsWith("ext-")) cleaned = `x-${cleaned}`;
|
|
71
|
+
if (cleaned === "__self") cleaned = "self";
|
|
72
|
+
if (cleaned === "." || cleaned === "..") return "";
|
|
73
|
+
if (cleaned.length > MAX_PEER_ID_LENGTH) {
|
|
74
|
+
cleaned = `${cleaned.slice(0, MAX_PEER_ID_LENGTH - 13).replace(/[-.]+$/, "")}-${shortHash(raw)}`;
|
|
75
|
+
}
|
|
76
|
+
return cleaned;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Normalize a remote URL to `host/path`, lowercased.
|
|
81
|
+
*
|
|
82
|
+
* Both spellings of the same repo converge, and userinfo is dropped — so a
|
|
83
|
+
* remote with an embedded token cannot leak it into a peer id. The cost of
|
|
84
|
+
* case folding is that two repos differing only in case share one namespace on
|
|
85
|
+
* the rare case-sensitive forge.
|
|
86
|
+
*/
|
|
87
|
+
export function normalizeGitRemote(url) {
|
|
88
|
+
const raw = String(url || "").trim();
|
|
89
|
+
if (!raw) return "";
|
|
90
|
+
|
|
91
|
+
// `C:\src\repo` and `C:/src/repo` are one machine's directory, not a shared
|
|
92
|
+
// identity — and the scp pattern would happily read the drive as a host.
|
|
93
|
+
if (/^[A-Za-z]:[\\/]/.test(raw)) return "";
|
|
94
|
+
|
|
95
|
+
let host = "";
|
|
96
|
+
let path = "";
|
|
97
|
+
const scp = /^(?:[^@/\\]+@)?([^:/\\]+):(?!\/)(.+)$/.exec(raw);
|
|
98
|
+
if (scp && !/^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(raw)) {
|
|
99
|
+
host = scp[1];
|
|
100
|
+
path = scp[2];
|
|
101
|
+
} else {
|
|
102
|
+
let parsed;
|
|
103
|
+
try {
|
|
104
|
+
parsed = new URL(raw);
|
|
105
|
+
} catch {
|
|
106
|
+
return "";
|
|
107
|
+
}
|
|
108
|
+
// A local clone has no stable shared identity — fall through to the path
|
|
109
|
+
// rules instead of minting one from `file://` or a bare directory.
|
|
110
|
+
if (parsed.protocol === "file:" || !parsed.hostname) return "";
|
|
111
|
+
host = parsed.hostname;
|
|
112
|
+
path = parsed.pathname;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
host = host.toLowerCase().replace(/^\[|\]$/g, "");
|
|
116
|
+
path = path.replace(/^\/+/, "").replace(/\/+$/, "").replace(/\.git$/i, "").toLowerCase();
|
|
117
|
+
if (!host || !path) return "";
|
|
118
|
+
return `${host}/${path}`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readFileOrEmpty(path) {
|
|
122
|
+
try {
|
|
123
|
+
return readFileSync(path, "utf-8");
|
|
124
|
+
} catch {
|
|
125
|
+
return "";
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Read `[remote "origin"] url` out of a git config with a minimal INI parse.
|
|
131
|
+
*
|
|
132
|
+
* `include` / `includeIf` are deliberately not followed: resolving them means
|
|
133
|
+
* more filesystem walking for a value the fallback chain already covers, so an
|
|
134
|
+
* unreadable remote just falls through to the next template.
|
|
135
|
+
*/
|
|
136
|
+
export function readGitRemoteUrl(commonDir, remote = "origin") {
|
|
137
|
+
const text = readFileOrEmpty(join(commonDir, "config"));
|
|
138
|
+
if (!text) return "";
|
|
139
|
+
|
|
140
|
+
let inSection = false;
|
|
141
|
+
for (const line of text.split("\n")) {
|
|
142
|
+
const trimmed = line.trim();
|
|
143
|
+
if (!trimmed || trimmed.startsWith("#") || trimmed.startsWith(";")) continue;
|
|
144
|
+
const section = /^\[([^\]]+)\]/.exec(trimmed);
|
|
145
|
+
if (section) {
|
|
146
|
+
const header = section[1].trim();
|
|
147
|
+
// git folds section and key names to lower case but keeps a quoted
|
|
148
|
+
// subsection exact, so `[Remote "origin"]` is the same section and
|
|
149
|
+
// `[remote "Origin"]` is not.
|
|
150
|
+
const quoted = /^remote\s+"(.*)"$/i.exec(header);
|
|
151
|
+
inSection = Boolean(quoted && quoted[1] === remote);
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (!inSection) continue;
|
|
155
|
+
const pair = /^url\s*=\s*(.*)$/i.exec(trimmed);
|
|
156
|
+
// git treats an unquoted `#` or `;` as starting a comment anywhere on the
|
|
157
|
+
// line, so a trailing note is not part of the URL.
|
|
158
|
+
if (pair) return pair[1].split(/[#;]/)[0].trim().replace(/^["']|["']$/g, "");
|
|
159
|
+
}
|
|
160
|
+
return "";
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function resolveGitDir(root) {
|
|
164
|
+
const dotGit = join(root, ".git");
|
|
165
|
+
let stat;
|
|
166
|
+
try {
|
|
167
|
+
stat = statSync(dotGit);
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (stat.isDirectory()) return { gitDir: dotGit, commonDir: dotGit, kind: "repo" };
|
|
172
|
+
if (!stat.isFile()) return null;
|
|
173
|
+
|
|
174
|
+
const pointer = /^gitdir:\s*(.+)$/m.exec(readFileOrEmpty(dotGit));
|
|
175
|
+
if (!pointer) return null;
|
|
176
|
+
const gitDir = resolve(root, pointer[1].trim());
|
|
177
|
+
|
|
178
|
+
// `commondir` is the worktree signal, so it is read first — a repository
|
|
179
|
+
// that merely lives under a directory called `modules` is not a submodule.
|
|
180
|
+
const commonRef = readFileOrEmpty(join(gitDir, "commondir")).trim();
|
|
181
|
+
const commonDir = commonRef
|
|
182
|
+
? (isAbsolute(commonRef) ? commonRef : resolve(gitDir, commonRef))
|
|
183
|
+
: gitDir;
|
|
184
|
+
|
|
185
|
+
// A submodule keeps its own remote under the superproject's
|
|
186
|
+
// `.git/modules/<name>`; converging it onto the superproject would merge two
|
|
187
|
+
// repositories that release, and are reviewed, separately. Only the segment
|
|
188
|
+
// right after a `.git` directory means that, which is also why a worktree of
|
|
189
|
+
// a submodule resolves here through its own commondir.
|
|
190
|
+
const kind = /[/\\]\.git[/\\]modules[/\\]/.test(`${commonDir}${sep}`)
|
|
191
|
+
? "submodule"
|
|
192
|
+
: (commonRef ? "worktree" : "repo");
|
|
193
|
+
return { gitDir, commonDir, kind };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A directory the user made a workspace on purpose, by giving it a config file. */
|
|
197
|
+
function hasWorkspaceMarker(dir) {
|
|
198
|
+
for (const name of [TEAM_FILE, LOCAL_FILE]) {
|
|
199
|
+
try {
|
|
200
|
+
if (statSync(join(dir, CONFIG_DIR_NAME, name)).isFile()) return true;
|
|
201
|
+
} catch { /* not marked here */ }
|
|
202
|
+
}
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const NO_ROOT = Object.freeze({ root: "", rootKind: "", git: null, gitRoot: "" });
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Walk up from `cwd` to the nearest workspace root.
|
|
210
|
+
*
|
|
211
|
+
* A workspace is a git repository, or a directory someone marked as one by
|
|
212
|
+
* creating `.openviking/config.json` (or `config.local.json`) in it. Nearest
|
|
213
|
+
* wins. Above a marked directory the walk continues to the enclosing
|
|
214
|
+
* repository, if any, so the git-shaped variables still resolve there.
|
|
215
|
+
* Anything else — a scratch folder, a download, an app's per-task directory —
|
|
216
|
+
* is not a workspace and gets no peer of its own.
|
|
217
|
+
*
|
|
218
|
+
* `$HOME` and the filesystem root are never workspace roots: a stray `.git`
|
|
219
|
+
* in either would silently make every unrelated directory one workspace.
|
|
220
|
+
*/
|
|
221
|
+
export function findWorkspaceRoot(cwd, env = process.env) {
|
|
222
|
+
const start = String(cwd || "").trim();
|
|
223
|
+
if (!start) return NO_ROOT;
|
|
224
|
+
|
|
225
|
+
let absolute;
|
|
226
|
+
try {
|
|
227
|
+
// A relative `start` makes `resolve` read process.cwd(), which throws when
|
|
228
|
+
// the directory is gone — the very case the catch below is meant to cover.
|
|
229
|
+
absolute = resolve(start);
|
|
230
|
+
} catch {
|
|
231
|
+
return NO_ROOT;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// `current` and `stopAt` are compared as strings, so they must be
|
|
235
|
+
// canonicalized together. A `$HOME` reached through a symlink plus an
|
|
236
|
+
// unresolvable cwd would otherwise walk straight past the guard.
|
|
237
|
+
const home = String(env.HOME || "").trim() || homedir();
|
|
238
|
+
let current;
|
|
239
|
+
let stopAt;
|
|
240
|
+
try {
|
|
241
|
+
current = realpathSync(absolute);
|
|
242
|
+
stopAt = realpathSync(home);
|
|
243
|
+
} catch {
|
|
244
|
+
current = absolute;
|
|
245
|
+
stopAt = home;
|
|
246
|
+
}
|
|
247
|
+
const filesystemRoot = parse(current).root;
|
|
248
|
+
if (current === stopAt || current === filesystemRoot) return NO_ROOT;
|
|
249
|
+
|
|
250
|
+
let root = "";
|
|
251
|
+
let rootKind = "";
|
|
252
|
+
while (current && current !== filesystemRoot && current !== stopAt) {
|
|
253
|
+
const git = resolveGitDir(current);
|
|
254
|
+
if (git) return { root: root || current, rootKind: rootKind || "git", git, gitRoot: current };
|
|
255
|
+
if (!root && hasWorkspaceMarker(current)) {
|
|
256
|
+
root = current;
|
|
257
|
+
rootKind = "config";
|
|
258
|
+
}
|
|
259
|
+
const parent = dirname(current);
|
|
260
|
+
if (parent === current) break;
|
|
261
|
+
current = parent;
|
|
262
|
+
}
|
|
263
|
+
return root ? { root, rootKind, git: null, gitRoot: "" } : NO_ROOT;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function cachePath(cwd, env) {
|
|
267
|
+
return join(stateDir(env), `ws-identity-${shortHash(cwd)}.json`);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function readCache(path, now) {
|
|
271
|
+
try {
|
|
272
|
+
const cached = JSON.parse(readFileSync(path, "utf-8"));
|
|
273
|
+
// Bounded on both sides: an entry stamped in the future — a clock that ran
|
|
274
|
+
// fast, or `Infinity` — would otherwise pin a stale identity indefinitely.
|
|
275
|
+
if (typeof cached?.ts !== "number" || cached.ts > now || now - cached.ts > IDENTITY_CACHE_TTL_MS) return null;
|
|
276
|
+
const identity = cached.identity;
|
|
277
|
+
if (identity && typeof identity === "object" && identity.vars && typeof identity.vars === "object") {
|
|
278
|
+
return identity;
|
|
279
|
+
}
|
|
280
|
+
} catch { /* a cold or corrupt cache just costs one walk */ }
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function writeCache(path, identity, now) {
|
|
285
|
+
try {
|
|
286
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
287
|
+
const tmp = `${path}.${process.pid}.tmp`;
|
|
288
|
+
writeFileSync(tmp, JSON.stringify({ ts: now, identity }), { mode: 0o600 });
|
|
289
|
+
renameSync(tmp, path);
|
|
290
|
+
} catch { /* best effort */ }
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Everything the peer templates can substitute, for one cwd.
|
|
295
|
+
*
|
|
296
|
+
* `git_remote` and `dir` are already sanitized; `git_root` and `cwd` carry the
|
|
297
|
+
* legacy byte-for-byte rule, because they are the two that must reproduce a
|
|
298
|
+
* peer minted before any of this existed.
|
|
299
|
+
*/
|
|
300
|
+
export function resolveWorkspaceIdentity({ cwd = "", env = process.env, cache = true, now = Date.now() } = {}) {
|
|
301
|
+
const key = String(cwd || "");
|
|
302
|
+
const path = cachePath(key, env);
|
|
303
|
+
if (cache) {
|
|
304
|
+
const hit = readCache(path, now);
|
|
305
|
+
if (hit) return hit;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const { root, rootKind, git, gitRoot } = findWorkspaceRoot(key, env);
|
|
309
|
+
// Only the normalized form is kept. The raw URL may carry a token, and this
|
|
310
|
+
// file outlives the process — writing it here would undo the care
|
|
311
|
+
// `normalizeGitRemote` takes to drop userinfo.
|
|
312
|
+
const remote = git ? normalizeGitRemote(readGitRemoteUrl(git.commonDir)) : "";
|
|
313
|
+
const identity = {
|
|
314
|
+
cwd: key,
|
|
315
|
+
root,
|
|
316
|
+
rootKind,
|
|
317
|
+
isGit: Boolean(git),
|
|
318
|
+
gitKind: git?.kind || "",
|
|
319
|
+
gitCommonDir: git?.commonDir || "",
|
|
320
|
+
gitRoot,
|
|
321
|
+
remote,
|
|
322
|
+
vars: {
|
|
323
|
+
git_remote: sanitizePeerId(remote),
|
|
324
|
+
// The enclosing repository's root, which is not the workspace root when a
|
|
325
|
+
// marker file below it won. Empty outside a repository, so the `git`
|
|
326
|
+
// preset resolves to nothing there rather than to a bare path.
|
|
327
|
+
git_root: git ? legacySanitize(gitRoot) : "",
|
|
328
|
+
cwd: legacySanitize(key),
|
|
329
|
+
dir: root ? sanitizePeerId(root.split(/[/\\]/).filter(Boolean).pop() || "") : "",
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
if (cache) writeCache(path, identity, now);
|
|
333
|
+
return identity;
|
|
334
|
+
}
|
|
@@ -1,16 +1,134 @@
|
|
|
1
1
|
// GENERATED FROM examples/memory-plugin-shared/lib. DO NOT EDIT.
|
|
2
|
+
/**
|
|
3
|
+
* Which peer a workspace writes its memories under.
|
|
4
|
+
*
|
|
5
|
+
* The peer used to be the working directory with every non-alphanumeric byte
|
|
6
|
+
* turned into a dash, which made the identity an accident of where the
|
|
7
|
+
* repository happened to sit: a clone on another machine, a rename, a worktree
|
|
8
|
+
* or simply `cd examples/` each minted a separate, empty namespace. The default
|
|
9
|
+
* is now git's own idea of the repository, so one project keeps one memory
|
|
10
|
+
* wherever it is checked out.
|
|
11
|
+
*
|
|
12
|
+
* `peer.source` decides the rule. Presets cover the three answers most people
|
|
13
|
+
* want; a template — or a list of templates tried in order — covers the rest.
|
|
14
|
+
* The old behaviour is still one word away, byte for byte.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { legacySanitize, resolveWorkspaceIdentity, sanitizePeerId } from "./workspace-identity.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `git` is the default, and it resolves only inside a repository: the remote
|
|
21
|
+
* first, because that is the one name every clone agrees on, then the
|
|
22
|
+
* repository root for a repo that has none. Anywhere else it resolves to
|
|
23
|
+
* nothing and no peer is sent — a scratch folder or an app's per-task
|
|
24
|
+
* directory would otherwise mint a fresh, empty namespace each time, so those
|
|
25
|
+
* memories go to the user-level space instead. Naming such a directory is what
|
|
26
|
+
* `peer.id` is for, and deriving one from a bare path is what `cwd` is for;
|
|
27
|
+
* both are opt-in.
|
|
28
|
+
*
|
|
29
|
+
* No preset adds a prefix. A path-derived id starts with `-` on POSIX, so it
|
|
30
|
+
* cannot collide with a remote-derived one; anyone who wants a prefix writes
|
|
31
|
+
* their own template.
|
|
32
|
+
*/
|
|
33
|
+
export const PEER_SOURCE_PRESETS = {
|
|
34
|
+
git: ["{git_remote}", "{git_root}"],
|
|
35
|
+
cwd: ["{cwd}"],
|
|
36
|
+
none: [],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const DEFAULT_PEER_SOURCE = "git";
|
|
40
|
+
|
|
41
|
+
const VARIABLE_RE = /\{([a-z_]+)\}/g;
|
|
42
|
+
|
|
2
43
|
export function deriveWorkspacePeerId(cwd) {
|
|
3
|
-
return
|
|
44
|
+
return legacySanitize(cwd);
|
|
4
45
|
}
|
|
5
46
|
|
|
6
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Normalize `peer.source` — a preset name, a template, or a list — to templates.
|
|
49
|
+
*
|
|
50
|
+
* A bare string with no `{` is a misspelled preset, not a template: `Git` or
|
|
51
|
+
* `gti` would otherwise become a peer literally named `Git`, quietly stranding
|
|
52
|
+
* the workspace's memories in a namespace nobody meant to create. Such a value
|
|
53
|
+
* warns and falls back to the default chain. An array is left alone — writing a
|
|
54
|
+
* list is explicit enough to mean the constant it contains.
|
|
55
|
+
*/
|
|
56
|
+
export function peerSourceTemplates(source, onWarn = null) {
|
|
57
|
+
if (Array.isArray(source)) return source.map(String).filter(Boolean);
|
|
58
|
+
const raw = String(source ?? "").trim();
|
|
59
|
+
if (!raw) return PEER_SOURCE_PRESETS[DEFAULT_PEER_SOURCE];
|
|
60
|
+
if (Object.hasOwn(PEER_SOURCE_PRESETS, raw)) return PEER_SOURCE_PRESETS[raw];
|
|
61
|
+
if (raw.includes("{")) return [raw];
|
|
62
|
+
|
|
63
|
+
const message = `OpenViking: ignored peer.source ${JSON.stringify(raw)}: it is neither a preset `
|
|
64
|
+
+ `(${Object.keys(PEER_SOURCE_PRESETS).join(", ")}) nor a template such as "team-{dir}". `
|
|
65
|
+
+ `Falling back to ${DEFAULT_PEER_SOURCE}.`;
|
|
66
|
+
if (typeof onWarn === "function") onWarn(message);
|
|
67
|
+
else process.stderr.write(`${message}\n`);
|
|
68
|
+
return PEER_SOURCE_PRESETS[DEFAULT_PEER_SOURCE];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Substitute one template, or return "" when any variable it names is empty.
|
|
73
|
+
*
|
|
74
|
+
* All-or-nothing on purpose: a half-resolved template like `git-` would be a
|
|
75
|
+
* silently shared identity, so an empty variable falls through to the next
|
|
76
|
+
* template instead.
|
|
77
|
+
*/
|
|
78
|
+
export function renderPeerTemplate(template, vars) {
|
|
79
|
+
const text = String(template || "");
|
|
80
|
+
if (!text) return "";
|
|
81
|
+
let empty = false;
|
|
82
|
+
const rendered = text.replace(VARIABLE_RE, (match, name) => {
|
|
83
|
+
if (!Object.hasOwn(vars, name)) {
|
|
84
|
+
empty = true;
|
|
85
|
+
return "";
|
|
86
|
+
}
|
|
87
|
+
const value = String(vars[name] ?? "");
|
|
88
|
+
if (!value) empty = true;
|
|
89
|
+
return value;
|
|
90
|
+
});
|
|
91
|
+
return empty ? "" : rendered;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The peer this process should send, and where it came from.
|
|
96
|
+
*
|
|
97
|
+
* `source` keeps its three values — call sites compare it against the literal
|
|
98
|
+
* `"workspace"` to decide whether a session pin may be reused — while `origin`
|
|
99
|
+
* names the template that actually produced the id, and `legacyPeerId` carries
|
|
100
|
+
* the pre-git id whenever it differs, so recall can still reach memories
|
|
101
|
+
* written under it.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveEffectivePeerId({ cfg = {}, cwd = "", identity = null, env = process.env, onWarn = null } = {}) {
|
|
7
104
|
const explicit = String(cfg.peerId || "").trim();
|
|
8
|
-
if (explicit) return { peerId: explicit, source: "explicit" };
|
|
105
|
+
if (explicit) return { peerId: explicit, source: "explicit", origin: "explicit", legacyPeerId: "" };
|
|
9
106
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
107
|
+
// `OPENVIKING_WORKSPACE_PEER=0` predates `peer.source` and still means "none".
|
|
108
|
+
if (cfg.workspacePeer === false) return { peerId: "", source: "none", origin: "disabled", legacyPeerId: "" };
|
|
109
|
+
|
|
110
|
+
const templates = peerSourceTemplates(cfg.peerSource, onWarn);
|
|
111
|
+
if (!templates.length) return { peerId: "", source: "none", origin: "none", legacyPeerId: "" };
|
|
14
112
|
|
|
15
|
-
|
|
113
|
+
// `harness` is composed here rather than in the identity, whose result is
|
|
114
|
+
// cached on disk under a cwd-only key — two harnesses in one directory would
|
|
115
|
+
// otherwise read each other's peer back out of that cache.
|
|
116
|
+
const vars = {
|
|
117
|
+
...((identity || resolveWorkspaceIdentity({ cwd, env })).vars || {}),
|
|
118
|
+
harness: sanitizePeerId(cfg.harness || cfg.clientId || ""),
|
|
119
|
+
};
|
|
120
|
+
const legacyPeerId = deriveWorkspacePeerId(cwd);
|
|
121
|
+
for (const template of templates) {
|
|
122
|
+
const peerId = renderPeerTemplate(template, vars);
|
|
123
|
+
if (!peerId) continue;
|
|
124
|
+
return {
|
|
125
|
+
peerId,
|
|
126
|
+
source: "workspace",
|
|
127
|
+
origin: template,
|
|
128
|
+
legacyPeerId: peerId === legacyPeerId ? "" : legacyPeerId,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
// No peer, but the pre-git id is still what earlier sessions in this
|
|
132
|
+
// directory wrote under, so recall keeps reaching it.
|
|
133
|
+
return { peerId: "", source: "none", origin: "unresolved", legacyPeerId };
|
|
16
134
|
}
|
package/lib/utils.mjs
CHANGED
|
@@ -68,6 +68,10 @@ export function effectivePeerId(config) {
|
|
|
68
68
|
return String(config.effectivePeer?.peerId || config.peerId || "").trim() || null
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
+
function responseTraceId(payload) {
|
|
72
|
+
return payload?.result?.trace_id || payload?.error?.trace_id || payload?.trace_id || undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
71
75
|
export async function fetchJSON(config, endpoint, init = {}, options = {}) {
|
|
72
76
|
const url = `${normalizeEndpoint(config.endpoint)}${endpoint}`
|
|
73
77
|
const headers = makeAuthHeaders(
|
|
@@ -85,14 +89,16 @@ export async function fetchJSON(config, endpoint, init = {}, options = {}) {
|
|
|
85
89
|
})
|
|
86
90
|
const text = await response.text()
|
|
87
91
|
const payload = text ? parseJsonOrText(text) : {}
|
|
92
|
+
const traceId = responseTraceId(payload)
|
|
88
93
|
if (!response.ok || payload?.status === "error") {
|
|
89
94
|
return {
|
|
90
95
|
ok: false,
|
|
91
96
|
status: response.status,
|
|
92
97
|
error: payload?.error || payload?.message || { message: `HTTP ${response.status}` },
|
|
98
|
+
traceId,
|
|
93
99
|
}
|
|
94
100
|
}
|
|
95
|
-
return { ok: true, status: response.status, result: payload?.result ?? payload }
|
|
101
|
+
return { ok: true, status: response.status, result: payload?.result ?? payload, traceId }
|
|
96
102
|
} catch (error) {
|
|
97
103
|
return { ok: false, status: 0, error: { message: error?.message ?? String(error) } }
|
|
98
104
|
} finally {
|
|
@@ -213,6 +219,7 @@ function makeAuthHeaders(config, headers = {}, actorPeerId = "") {
|
|
|
213
219
|
if (config.user) result["X-OpenViking-User"] = config.user
|
|
214
220
|
const peerId = String(actorPeerId || "").trim()
|
|
215
221
|
if (peerId) result["X-OpenViking-Actor-Peer"] = peerId
|
|
222
|
+
if (config.userAgent) result["User-Agent"] = config.userAgent
|
|
216
223
|
return result
|
|
217
224
|
}
|
|
218
225
|
|
package/lib/viking-uri-guard.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { buildGuardMessage, findVikingUri, normalizeToolName } from "./shared/ur
|
|
|
4
4
|
const FILESYSTEM_TOOL_HINTS = {
|
|
5
5
|
read: {
|
|
6
6
|
tool: "openviking_read",
|
|
7
|
-
example: (uri) => `openviking_read(
|
|
7
|
+
example: (uri) => `openviking_read(uris=["${uri}"])`,
|
|
8
8
|
},
|
|
9
9
|
glob: {
|
|
10
10
|
tool: "openviking_glob",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openviking/opencode-plugin",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2026.9.9-1",
|
|
4
4
|
"author": "@openviking",
|
|
5
5
|
"description": "Unified OpenCode plugin for OpenViking repository retrieval and long-term memory",
|
|
6
6
|
"type": "module",
|
|
@@ -43,5 +43,6 @@
|
|
|
43
43
|
"rag",
|
|
44
44
|
"code-search"
|
|
45
45
|
],
|
|
46
|
-
"license": "Apache-2.0"
|
|
46
|
+
"license": "Apache-2.0",
|
|
47
|
+
"openvikingSourceCommit": "708dba60bd2484a9ecb69da27383b5203d8daee2"
|
|
47
48
|
}
|
package/servers/mcp-proxy.mjs
CHANGED
|
@@ -13,55 +13,32 @@ import { join, resolve as resolvePath } from "node:path"
|
|
|
13
13
|
import { fileURLToPath } from "node:url"
|
|
14
14
|
import { loadConfig } from "../lib/config.mjs"
|
|
15
15
|
import { createLogger } from "../lib/shared/debug-log.mjs"
|
|
16
|
+
import { buildMcpProxyConfig, resolveMcpActorPeerId } from "../lib/shared/mcp-proxy-config.mjs"
|
|
16
17
|
import { createOpenVikingMcpProxy } from "../lib/shared/mcp-proxy-core.mjs"
|
|
17
|
-
import { resolveEffectivePeerId } from "../lib/shared/workspace-peer.mjs"
|
|
18
18
|
|
|
19
19
|
export { createOpenVikingMcpProxy } from "../lib/shared/mcp-proxy-core.mjs"
|
|
20
20
|
|
|
21
|
-
const DEFAULT_TIMEOUT_MS = 15000
|
|
22
|
-
|
|
23
|
-
function trimSlash(value) {
|
|
24
|
-
return String(value || "").replace(/\/+$/, "")
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
function normalizePath(value) {
|
|
28
|
-
const raw = String(value || "").trim()
|
|
29
|
-
if (!raw) return ""
|
|
30
|
-
if (raw === "~") return homedir()
|
|
31
|
-
if (raw.startsWith("~/")) return resolvePath(join(homedir(), raw.slice(2)))
|
|
32
|
-
return resolvePath(raw)
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function uniq(values) {
|
|
36
|
-
return [...new Set(values.filter(Boolean))]
|
|
37
|
-
}
|
|
38
|
-
|
|
39
21
|
function readProxyConfig() {
|
|
40
22
|
const cfg = loadConfig(resolvePath(fileURLToPath(import.meta.url), "..", ".."))
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
timeoutMs:
|
|
50
|
-
debug: cfg.debug
|
|
23
|
+
return buildMcpProxyConfig({
|
|
24
|
+
baseUrl: cfg.endpoint,
|
|
25
|
+
mcpUrl: cfg.mcpUrl,
|
|
26
|
+
apiKey: cfg.apiKey,
|
|
27
|
+
account: cfg.account,
|
|
28
|
+
user: cfg.user,
|
|
29
|
+
peerId: resolveMcpActorPeerId(cfg),
|
|
30
|
+
userAgent: cfg.userAgent,
|
|
31
|
+
timeoutMs: cfg.timeoutMs,
|
|
32
|
+
debug: cfg.debug,
|
|
51
33
|
debugLogPath: cfg.debugLogPath,
|
|
52
|
-
credentialSource: cfg.credentialSource
|
|
53
|
-
credentialPath: cfg.credentialPath || cfg.configPath
|
|
54
|
-
watchedPaths:
|
|
34
|
+
credentialSource: cfg.credentialSource,
|
|
35
|
+
credentialPath: cfg.credentialPath || cfg.configPath,
|
|
36
|
+
watchedPaths: [
|
|
55
37
|
cfg.credentialPath,
|
|
56
38
|
cfg.configPath,
|
|
57
|
-
normalizePath(process.env.OPENVIKING_CLI_CONFIG_FILE),
|
|
58
|
-
normalizePath(process.env.OPENVIKING_CONFIG_FILE),
|
|
59
|
-
normalizePath(process.env.OPENVIKING_PLUGIN_CONFIG),
|
|
60
|
-
join(homedir(), ".openviking", "ovcli.conf"),
|
|
61
|
-
join(homedir(), ".openviking", "ov.conf"),
|
|
62
39
|
join(homedir(), ".config", "opencode", "openviking-config.json"),
|
|
63
|
-
]
|
|
64
|
-
}
|
|
40
|
+
],
|
|
41
|
+
})
|
|
65
42
|
}
|
|
66
43
|
|
|
67
44
|
if (process.argv[1] && fileURLToPath(import.meta.url) === resolvePath(process.argv[1])) {
|
|
@@ -56,6 +56,7 @@ function makeProxy(url) {
|
|
|
56
56
|
account: "acct",
|
|
57
57
|
user: "user",
|
|
58
58
|
peerId: "peer",
|
|
59
|
+
userAgent: "openviking-memory-opencode/9.9.9",
|
|
59
60
|
timeoutMs: 5000,
|
|
60
61
|
debug: false,
|
|
61
62
|
debugLogPath: "",
|
|
@@ -81,6 +82,7 @@ test("OpenCode MCP proxy forwards initialize with auth and identity headers", as
|
|
|
81
82
|
assert.equal(entry.headers["x-openviking-account"], "acct")
|
|
82
83
|
assert.equal(entry.headers["x-openviking-user"], "user")
|
|
83
84
|
assert.equal(entry.headers["x-openviking-actor-peer"], "peer")
|
|
85
|
+
assert.equal(entry.headers["user-agent"], "openviking-memory-opencode/9.9.9")
|
|
84
86
|
res.writeHead(200, {
|
|
85
87
|
"content-type": "text/event-stream",
|
|
86
88
|
"mcp-session-id": "sid-oc",
|