@openviking/opencode-plugin 0.2.4 → 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 +22 -5
- package/INSTALL.md +23 -5
- package/README.md +46 -11
- package/index.mjs +9 -2
- package/lib/config.mjs +17 -3
- package/lib/mcp-config.mjs +2 -1
- package/lib/memory-recall.mjs +5 -2
- package/lib/memory-session.mjs +53 -9
- package/lib/shared/capture-utils.mjs +98 -19
- package/lib/shared/credentials.mjs +11 -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 +13 -1
- package/lib/shared/pending-queue.mjs +11 -0
- package/lib/shared/recall-compress-core.mjs +17 -6
- package/lib/shared/recall-core.mjs +74 -5
- 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 +7 -1
- package/package.json +3 -2
- package/servers/mcp-proxy.mjs +16 -40
- package/lib/shared/async-writer.mjs +0 -78
- package/lib/shared/plugin-config.mjs +0 -90
|
@@ -86,10 +86,16 @@ export function repairDigestUris(digest, validUris = []) {
|
|
|
86
86
|
lines.push(line);
|
|
87
87
|
continue;
|
|
88
88
|
}
|
|
89
|
+
let tokenized = line;
|
|
90
|
+
for (const uri of [...valid].sort((a, b) => b.length - a.length)) {
|
|
91
|
+
if (/\s/.test(uri)) tokenized = tokenized.split(uri).join(encodeURI(uri));
|
|
92
|
+
}
|
|
89
93
|
let dropped = false;
|
|
90
|
-
const repaired =
|
|
91
|
-
|
|
92
|
-
|
|
94
|
+
const repaired = tokenized.replace(/viking:\/\/[^\s<>"')\]]+/g, (uri) => {
|
|
95
|
+
let decoded = uri;
|
|
96
|
+
try { decoded = decodeURI(uri); } catch { /* keep the original candidate */ }
|
|
97
|
+
if (validSet.has(decoded)) return decoded;
|
|
98
|
+
const nearest = nearestUri(decoded, valid);
|
|
93
99
|
if (nearest) return nearest;
|
|
94
100
|
dropped = true;
|
|
95
101
|
return uri;
|
|
@@ -149,6 +155,7 @@ async function writeCache(path, value) {
|
|
|
149
155
|
export async function compressRecallContext({
|
|
150
156
|
query,
|
|
151
157
|
rendered,
|
|
158
|
+
shortContext = rendered,
|
|
152
159
|
entries = [],
|
|
153
160
|
cfg = {},
|
|
154
161
|
runCompressor,
|
|
@@ -158,7 +165,9 @@ export async function compressRecallContext({
|
|
|
158
165
|
const input = String(rendered || "").trim();
|
|
159
166
|
if (!input) return { status: COMPRESS_EMPTY, context: "" };
|
|
160
167
|
const minChars = Math.max(0, Number(cfg.recallCompressMinInputChars ?? 1500));
|
|
161
|
-
if (input.length < minChars)
|
|
168
|
+
if (input.length < minChars) {
|
|
169
|
+
return { status: COMPRESS_OK, context: String(shortContext ?? input).trim() };
|
|
170
|
+
}
|
|
162
171
|
|
|
163
172
|
const maxInputChars = Math.max(1000, Number(cfg.recallCompressMaxInputChars || 18000));
|
|
164
173
|
const maxBullets = Math.max(1, Number(cfg.recallCompressMaxBullets || 6));
|
|
@@ -171,7 +180,8 @@ export async function compressRecallContext({
|
|
|
171
180
|
});
|
|
172
181
|
const cached = await readCache(cachePath);
|
|
173
182
|
if (cached?.key === key && typeof cached.digest === "string") {
|
|
174
|
-
|
|
183
|
+
const digest = normalizeCompressedContext(cached.digest, 4000, maxBullets);
|
|
184
|
+
if (digest) return { status: COMPRESS_OK, context: digest };
|
|
175
185
|
}
|
|
176
186
|
|
|
177
187
|
const prompt = buildRecallCompressionPrompt({
|
|
@@ -185,9 +195,10 @@ export async function compressRecallContext({
|
|
|
185
195
|
if (!normalized) return { status: COMPRESS_EMPTY, context: "" };
|
|
186
196
|
|
|
187
197
|
const validUris = entries.map((entry) => entry?.uri).filter(Boolean);
|
|
188
|
-
const
|
|
198
|
+
const repaired = repairDigestUris(normalized, validUris.length
|
|
189
199
|
? validUris
|
|
190
200
|
: (input.match(/viking:\/\/[^\s<>"']+/g) || []));
|
|
201
|
+
const digest = normalizeCompressedContext(repaired, 4000, maxBullets);
|
|
191
202
|
if (!digest) return { status: COMPRESS_FAILED, context: "" };
|
|
192
203
|
|
|
193
204
|
await writeCache(cachePath, { key, digest, updatedAt: now || 0 });
|
|
@@ -14,8 +14,8 @@ const STOPWORDS = new Set([
|
|
|
14
14
|
]);
|
|
15
15
|
const USER_RESERVED_DIRS = new Set(["memories", "skills"]);
|
|
16
16
|
const SOURCES = [
|
|
17
|
-
{ type: "memory", uri: "viking
|
|
18
|
-
{ type: "skill", uri: "viking
|
|
17
|
+
{ type: "memory", uri: "viking://~/memories", bucket: "memories" },
|
|
18
|
+
{ type: "skill", uri: "viking://~/skills", bucket: "skills" },
|
|
19
19
|
];
|
|
20
20
|
const DEFAULT_CONTEXT_LIMIT = 10;
|
|
21
21
|
const DEFAULT_CONTEXT_MAX_TOKENS = 1600;
|
|
@@ -164,10 +164,9 @@ export function contextRequestTimeoutMs(cfg = {}, body = {}) {
|
|
|
164
164
|
// `query_expansion` defaults to "auto" server-side, so only an explicit "off"
|
|
165
165
|
// takes the expansion fuse back out of the budget.
|
|
166
166
|
const wantsExpansion = Boolean(body.session_id) && body.query_expansion !== "off";
|
|
167
|
-
if (!wantsRewrite && !wantsExpansion) return undefined;
|
|
168
|
-
|
|
169
167
|
const configured = Number(cfg.recallContextTimeoutMs);
|
|
170
168
|
if (Number.isFinite(configured) && configured > 0) return Math.max(1000, Math.floor(configured));
|
|
169
|
+
if (!wantsRewrite && !wantsExpansion) return undefined;
|
|
171
170
|
const floor = wantsRewrite ? SERVER_REWRITE_REQUEST_TIMEOUT_MS : EXPANSION_REQUEST_TIMEOUT_MS;
|
|
172
171
|
return Math.max(Number(cfg.timeoutMs) || 0, floor);
|
|
173
172
|
}
|
|
@@ -272,6 +271,11 @@ async function resolveUserSpace(fetchJSON, actorPeerId = "") {
|
|
|
272
271
|
|
|
273
272
|
async function resolveTargetUri(fetchJSON, targetUri, actorPeerId = "") {
|
|
274
273
|
const trimmed = targetUri.trim().replace(/\/+$/, "");
|
|
274
|
+
// viking://~ is the home alias: the server expands it to the caller's own user
|
|
275
|
+
// space, so it needs no client-side rewrite.
|
|
276
|
+
if (trimmed === "viking://~" || trimmed.startsWith("viking://~/")) return trimmed;
|
|
277
|
+
// Legacy compat: uid-less viking://user/<reserved> URIs may still sit in plugin
|
|
278
|
+
// configs. Newer servers reject them, so rewrite to an explicit-uid URI here.
|
|
275
279
|
const m = trimmed.match(/^viking:\/\/user(?:\/(.*))?$/);
|
|
276
280
|
if (!m) return trimmed;
|
|
277
281
|
const rawRest = (m[1] ?? "").trim();
|
|
@@ -413,6 +417,31 @@ export async function markContextFaceLegacy(path = stateFile("context-face.json"
|
|
|
413
417
|
await writeJsonFile(path, { legacyUntil: now + LEGACY_CACHE_TTL_MS });
|
|
414
418
|
}
|
|
415
419
|
|
|
420
|
+
/**
|
|
421
|
+
* A `peer_scope` the server rejects is remembered the same way, but unlike the
|
|
422
|
+
* context face this one is not a silent capability probe: dropping the field
|
|
423
|
+
* widens recall from the caller's own peer to the whole user root, so doctor
|
|
424
|
+
* reads this file back and warns.
|
|
425
|
+
*/
|
|
426
|
+
export function peerScopeMemoPath() {
|
|
427
|
+
return stateFile("peer-scope.json");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function readPeerScopeDowngrade(path = peerScopeMemoPath(), now = Date.now()) {
|
|
431
|
+
const cached = await readJsonFile(path);
|
|
432
|
+
if (!cached?.legacyUntil || Number(cached.legacyUntil) <= now) return null;
|
|
433
|
+
return cached;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export async function markPeerScopeDowngrade(scope, status, path = peerScopeMemoPath(), now = Date.now()) {
|
|
437
|
+
await writeJsonFile(path, {
|
|
438
|
+
legacyUntil: now + LEGACY_CACHE_TTL_MS,
|
|
439
|
+
scope: String(scope || ""),
|
|
440
|
+
status: Number(status) || 0,
|
|
441
|
+
at: now,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
|
|
416
445
|
function looksLikeUnknownField(res) {
|
|
417
446
|
const text = JSON.stringify(res?.error ?? res?.result ?? res?.detail ?? "").toLowerCase();
|
|
418
447
|
return text.includes("extra") || text.includes("mode") || text.includes("unexpected");
|
|
@@ -421,7 +450,7 @@ function looksLikeUnknownField(res) {
|
|
|
421
450
|
function wrapContext(body) {
|
|
422
451
|
return [
|
|
423
452
|
"<openviking-context>",
|
|
424
|
-
"Relevant memory from OpenViking. Use the
|
|
453
|
+
"Relevant memory from OpenViking. Use the search/read MCP tools to expand URIs.",
|
|
425
454
|
body,
|
|
426
455
|
"</openviking-context>",
|
|
427
456
|
].join("\n");
|
|
@@ -554,7 +583,15 @@ async function recallViaEndpoint(fetchJSON, cfg, query, actorPeerId = "", log =
|
|
|
554
583
|
export async function postRecall(fetchJSON, body, opts = {}) {
|
|
555
584
|
const actorPeerId = opts.actorPeerId || "";
|
|
556
585
|
const log = opts.log || (() => {});
|
|
586
|
+
const memoPath = opts.peerScopeMemoPath;
|
|
557
587
|
const request = { ...body };
|
|
588
|
+
|
|
589
|
+
// A remembered downgrade is still a downgrade: recall runs wider than the
|
|
590
|
+
// caller asked for, so the memo doubles as the doctor's evidence.
|
|
591
|
+
if (request.peer_scope && await readPeerScopeDowngrade(memoPath)) {
|
|
592
|
+
delete request.peer_scope;
|
|
593
|
+
}
|
|
594
|
+
|
|
558
595
|
const res = await fetchJSON("/api/v1/search/recall", {
|
|
559
596
|
method: "POST",
|
|
560
597
|
body: JSON.stringify(request),
|
|
@@ -562,9 +599,17 @@ export async function postRecall(fetchJSON, body, opts = {}) {
|
|
|
562
599
|
if (!request.peer_scope || (res.status !== 400 && res.status !== 422)) {
|
|
563
600
|
return res;
|
|
564
601
|
}
|
|
602
|
+
// Only an unknown-field rejection means "this server predates peer_scope".
|
|
603
|
+
// Retrying every other 400/422 without it silently widens the search from
|
|
604
|
+
// the caller's own peer to the whole user root.
|
|
605
|
+
if (!looksLikeUnknownField(res)) {
|
|
606
|
+
log("recall_peer_scope_error", { status: res.status || 0 });
|
|
607
|
+
return res;
|
|
608
|
+
}
|
|
565
609
|
|
|
566
610
|
const downgraded = { ...request };
|
|
567
611
|
delete downgraded.peer_scope;
|
|
612
|
+
await markPeerScopeDowngrade(String(request.peer_scope), res.status || 0, memoPath);
|
|
568
613
|
log("recall_peer_scope_downgrade", { status: res.status || 0 });
|
|
569
614
|
return fetchJSON("/api/v1/search/recall", {
|
|
570
615
|
method: "POST",
|
|
@@ -572,7 +617,31 @@ export async function postRecall(fetchJSON, body, opts = {}) {
|
|
|
572
617
|
}, { actorPeerId });
|
|
573
618
|
}
|
|
574
619
|
|
|
620
|
+
/**
|
|
621
|
+
* Recall, plus whatever is still filed under the peer this workspace used
|
|
622
|
+
* before the identity rule changed.
|
|
623
|
+
*
|
|
624
|
+
* Under the default `peer_scope: "all"` this costs nothing: the server's own
|
|
625
|
+
* sweep of `{user_root}/peers` already reaches the old peer's memories. Under
|
|
626
|
+
* `"actor"` that sweep is off by definition, so the old peer is asked for
|
|
627
|
+
* separately — as itself, which is both cheaper and wider than a bare
|
|
628
|
+
* cross-peer read (that would need the user id, and reaches memories only).
|
|
629
|
+
*/
|
|
575
630
|
export async function buildRecallBlock(fetchJSON, cfg, query, options = {}) {
|
|
631
|
+
const primary = await recallForPeer(fetchJSON, cfg, query, options);
|
|
632
|
+
|
|
633
|
+
const legacyPeerId = String(options.legacyPeerId || "").trim();
|
|
634
|
+
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
635
|
+
if (cfg.recallPeerScope !== "actor" || !legacyPeerId || legacyPeerId === actorPeerId) return primary;
|
|
636
|
+
|
|
637
|
+
const log = options.log || (() => {});
|
|
638
|
+
const legacy = await recallForPeer(fetchJSON, cfg, query, { ...options, actorPeerId: legacyPeerId });
|
|
639
|
+
if (!legacy) return primary;
|
|
640
|
+
log("recall_legacy_peer_hit", { legacyPeerId });
|
|
641
|
+
return primary ? `${primary}\n${legacy}` : legacy;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
async function recallForPeer(fetchJSON, cfg, query, options = {}) {
|
|
576
645
|
const actorPeerId = options.actorPeerId ?? cfg.peerId ?? "";
|
|
577
646
|
const log = options.log || (() => {});
|
|
578
647
|
const trimmed = String(query || "").trim();
|
|
@@ -39,14 +39,17 @@ export async function runSetupWizard({
|
|
|
39
39
|
output = process.stdout,
|
|
40
40
|
env = process.env,
|
|
41
41
|
} = {}) {
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
// cliPath is empty until the file exists; first-run writes go to the candidate
|
|
43
|
+
// path (OPENVIKING_CLI_CONFIG_FILE, else ~/.openviking/ovcli.conf).
|
|
44
|
+
const { cliPath, cliPathCandidate } = loadCredentialFiles(env);
|
|
45
|
+
const targetPath = cliPath || cliPathCandidate;
|
|
46
|
+
const current = readJsonSafe(targetPath);
|
|
44
47
|
const rl = createInterface({ input, output });
|
|
45
48
|
const say = (line = "") => output.write(`${line}\n`);
|
|
46
49
|
|
|
47
50
|
try {
|
|
48
51
|
say("OpenViking memory plugin setup");
|
|
49
|
-
say(`Config file: ${
|
|
52
|
+
say(`Config file: ${targetPath}`);
|
|
50
53
|
say("");
|
|
51
54
|
say("Current values:");
|
|
52
55
|
say(` url: ${current.url || "(not set)"}`);
|
|
@@ -89,22 +92,22 @@ export async function runSetupWizard({
|
|
|
89
92
|
const confirm = (await rl.question("Write config? [Y/n] ")).trim().toLowerCase();
|
|
90
93
|
if (confirm === "n" || confirm === "no") {
|
|
91
94
|
say("Aborted; nothing written.");
|
|
92
|
-
return { written: false, path:
|
|
95
|
+
return { written: false, path: targetPath };
|
|
93
96
|
}
|
|
94
97
|
|
|
95
|
-
mkdirSync(dirname(
|
|
96
|
-
if (existsSync(
|
|
97
|
-
copyFileSync(
|
|
98
|
+
mkdirSync(dirname(targetPath), { recursive: true });
|
|
99
|
+
if (existsSync(targetPath)) {
|
|
100
|
+
copyFileSync(targetPath, `${targetPath}.bak.${Date.now()}`);
|
|
98
101
|
}
|
|
99
|
-
writeFileSync(
|
|
102
|
+
writeFileSync(targetPath, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
100
103
|
try {
|
|
101
|
-
chmodSync(
|
|
104
|
+
chmodSync(targetPath, 0o600);
|
|
102
105
|
} catch {
|
|
103
106
|
/* best effort on platforms without chmod semantics */
|
|
104
107
|
}
|
|
105
|
-
say(`Written: ${
|
|
108
|
+
say(`Written: ${targetPath}`);
|
|
106
109
|
say("The stdio MCP proxy and hooks pick this up on the next harness start.");
|
|
107
|
-
return { written: true, path:
|
|
110
|
+
return { written: true, path: targetPath };
|
|
108
111
|
} finally {
|
|
109
112
|
rl.close();
|
|
110
113
|
}
|
package/lib/shared/uri-guard.mjs
CHANGED
|
@@ -14,30 +14,52 @@ export function normalizeToolName(value) {
|
|
|
14
14
|
return String(value || "").trim().toLowerCase();
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Arguments that carry file CONTENT rather than a location. The sweep below
|
|
18
|
+
// looks past the known path keys so an unusual one (`paths`, a nested target)
|
|
19
|
+
// is still caught, but text a tool is asked to WRITE is not a path: a local
|
|
20
|
+
// `write` whose body merely mentions viking://user/default/ was denied, and no
|
|
21
|
+
// file was created. Skipped by name at any depth.
|
|
22
|
+
const DEFAULT_CONTENT_KEYS = [
|
|
23
|
+
"content",
|
|
24
|
+
"contents",
|
|
25
|
+
"text",
|
|
26
|
+
"body",
|
|
27
|
+
"old_string",
|
|
28
|
+
"oldString",
|
|
29
|
+
"new_string",
|
|
30
|
+
"newString",
|
|
31
|
+
"old_str",
|
|
32
|
+
"new_str",
|
|
33
|
+
"file_text",
|
|
34
|
+
"insert_line",
|
|
35
|
+
"replacement",
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
export function findVikingUri(args = {}, keys = DEFAULT_URI_KEYS, contentKeys = DEFAULT_CONTENT_KEYS) {
|
|
18
39
|
if (!args || typeof args !== "object") return null;
|
|
19
40
|
for (const key of keys) {
|
|
20
41
|
const uri = findVikingUriInValue(args[key]);
|
|
21
42
|
if (uri) return uri;
|
|
22
43
|
}
|
|
23
|
-
return findVikingUriInValue(args);
|
|
44
|
+
return findVikingUriInValue(args, new Set(contentKeys));
|
|
24
45
|
}
|
|
25
46
|
|
|
26
|
-
export function findVikingUriInValue(value) {
|
|
47
|
+
export function findVikingUriInValue(value, skipKeys) {
|
|
27
48
|
if (typeof value === "string") {
|
|
28
49
|
const match = value.match(/\bviking:\/\/[^\s"'`<>)]*/i);
|
|
29
50
|
return match?.[0] || null;
|
|
30
51
|
}
|
|
31
52
|
if (Array.isArray(value)) {
|
|
32
53
|
for (const item of value) {
|
|
33
|
-
const uri = findVikingUriInValue(item);
|
|
54
|
+
const uri = findVikingUriInValue(item, skipKeys);
|
|
34
55
|
if (uri) return uri;
|
|
35
56
|
}
|
|
36
57
|
return null;
|
|
37
58
|
}
|
|
38
59
|
if (value && typeof value === "object") {
|
|
39
|
-
for (const item of Object.
|
|
40
|
-
|
|
60
|
+
for (const [key, item] of Object.entries(value)) {
|
|
61
|
+
if (skipKeys?.has(key)) continue;
|
|
62
|
+
const uri = findVikingUriInValue(item, skipKeys);
|
|
41
63
|
if (uri) return uri;
|
|
42
64
|
}
|
|
43
65
|
}
|
|
@@ -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
|
+
}
|