wiki-viewer 1.5.2 → 1.7.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/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/build-manifest.json +9 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js +15 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js +5 -5
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0p12-6~._.js → [root-of-the-server]__094q5bj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0w47wil._.js → [root-of-the-server]__0km8suj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__12qerfy._.js → [root-of-the-server]__0mzo03s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0e7si9p._.js → [root-of-the-server]__0rzo40z._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t5mg.n._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0zi3nkn._.js → [root-of-the-server]__106uw_c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0qhdf-p._.js → [root-of-the-server]__12800b1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_download_route_actions_0rd6p-3.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_00650~e._.js → 0~w0_katex_dist_katex_mjs_0b8ra51._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0jyyo2t._.js → [externals]__05-uq57._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0m6xq-c._.js → [externals]__0btd01s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06mzg2t._.js → [root-of-the-server]__00lk0r~._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12ahxbl._.js → [root-of-the-server]__0ytvlzs._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +7 -7
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0v_nwsz._.js → node_modules__pnpm_0-x6zex._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0tkiej3._.js → node_modules__pnpm_012nt.s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~2~c30._.js → node_modules__pnpm_07rxv2x._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gbgliv._.js → node_modules__pnpm_0a4su30._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_10413dq._.js → node_modules__pnpm_0ajzar9._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zzzsrj._.js → node_modules__pnpm_0bum3-3._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wffzo0._.js → node_modules__pnpm_0c71i8a._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_099cv.b._.js → node_modules__pnpm_0cbsnwf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f_uzgy._.js → node_modules__pnpm_0cxwhfd._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03ij5.g._.js → node_modules__pnpm_0dboa_e._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0iojvye._.js → node_modules__pnpm_0de8xt-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02gx8km._.js → node_modules__pnpm_0ezm2us._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m5-azh._.js → node_modules__pnpm_0hobcbs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~.6dgb._.js → node_modules__pnpm_0hrbz84._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0rebktf._.js → node_modules__pnpm_0i.pw_m._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03v21p-._.js → node_modules__pnpm_0il8j.j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0cwpt9p._.js → node_modules__pnpm_0j3mvgg._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0fixtms._.js → node_modules__pnpm_0j53hti._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0li2vzs._.js → node_modules__pnpm_0kwc-vs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_05qsply._.js → node_modules__pnpm_0lm8e2c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_08ibh0w._.js → node_modules__pnpm_0mdo4i3._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_06pgebo._.js → node_modules__pnpm_0o.c1z8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gofu~q._.js → node_modules__pnpm_0rw91-f._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0n1kfcp._.js → node_modules__pnpm_0xrmzh1._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02mew~s._.js → node_modules__pnpm_0yq5z.h._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xxwmv2._.js → node_modules__pnpm_0z~h2ow._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f9fpy5._.js → node_modules__pnpm_0~ln0by._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wouz19._.js → node_modules__pnpm_11e~j49._.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/{17~r3.8awh3qg.js → 0vwkqw_rofm-c.js} +7 -7
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/packages/wiki-viewer-mcp/bench-e2e.mjs +166 -0
- package/.next/standalone/packages/wiki-viewer-mcp/bench-latency.mjs +87 -0
- package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
- package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +36 -7
- package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +50 -2
- package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
- package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
- package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
- package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +63 -5
- package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
- package/.next/standalone/pnpm-lock.yaml +3 -3
- package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
- package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
- package/.next/standalone/src/app/page.tsx +31 -1
- package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
- package/.next/standalone/src/tests/proof/bench-write.ts +179 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_ssgManifest.js +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wiki-viewer",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Local file viewer and wiki — view markdown, HTML, PDFs, notebooks, office docs, and more from any directory",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"better-auth": "^1.6.12",
|
|
41
41
|
"better-sqlite3": "^12.10.0",
|
|
42
42
|
"chokidar": "^5.0.0",
|
|
43
|
+
"jszip": "^3.10.1",
|
|
43
44
|
"kysely": "0.28.5",
|
|
44
45
|
"next": "16.2.4",
|
|
45
46
|
"proper-lockfile": "^4.1.2",
|
|
@@ -103,7 +104,6 @@
|
|
|
103
104
|
"gray-matter": "^4.0.3",
|
|
104
105
|
"hast-util-to-html": "^9.0.5",
|
|
105
106
|
"highlight.js": "^11.11.1",
|
|
106
|
-
"jszip": "^3.10.1",
|
|
107
107
|
"lowlight": "^3.3.0",
|
|
108
108
|
"lucide-react": "^0.525.0",
|
|
109
109
|
"mammoth": "^1.12.0",
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end MCP benchmark — measures the FULL stack a remote agent crosses,
|
|
3
|
+
* minus the LLM: MCP stdio JSON-RPC -> shim fetch (keep-alive or not) -> HTTP
|
|
4
|
+
* -> Next standalone server -> route handler -> disk.
|
|
5
|
+
*
|
|
6
|
+
* It spawns the real Next standalone server on a loopback port with an
|
|
7
|
+
* isolated temp ROOT_DIR + temp HOME (seeded agent registry), then drives the
|
|
8
|
+
* REAL wiki-viewer-mcp shim over stdio via the MCP SDK client.
|
|
9
|
+
*
|
|
10
|
+
* Run from repo root:
|
|
11
|
+
* node packages/wiki-viewer-mcp/bench-e2e.mjs
|
|
12
|
+
* Requires: `npm run build` (root) and shim built (npm --prefix packages/wiki-viewer-mcp run build).
|
|
13
|
+
* Env: BENCH_ITERS (default 50), BENCH_WARMUP (default 10), BENCH_URL (skip spawning, hit a remote instance)
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
|
|
17
|
+
import { tmpdir } from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
23
|
+
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
24
|
+
|
|
25
|
+
const ITERS = Number(process.env.BENCH_ITERS) || 50;
|
|
26
|
+
const WARMUP = Number(process.env.BENCH_WARMUP) || 10;
|
|
27
|
+
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const REPO = path.resolve(HERE, "../..");
|
|
29
|
+
|
|
30
|
+
function sha256hex(buf) {
|
|
31
|
+
return createHash("sha256").update(buf).digest("hex");
|
|
32
|
+
}
|
|
33
|
+
function pct(s, p) { return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; }
|
|
34
|
+
function stats(label, samples) {
|
|
35
|
+
const s = [...samples].sort((a, b) => a - b);
|
|
36
|
+
const mean = s.reduce((a, b) => a + b, 0) / s.length;
|
|
37
|
+
return { label, p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99), mean, max: s[s.length - 1] };
|
|
38
|
+
}
|
|
39
|
+
async function timeIt(fn) { const t = performance.now(); await fn(); return performance.now() - t; }
|
|
40
|
+
|
|
41
|
+
function waitForLog(child, re, timeoutMs = 30000) {
|
|
42
|
+
return new Promise((resolve, reject) => {
|
|
43
|
+
const to = setTimeout(() => reject(new Error("server start timeout")), timeoutMs);
|
|
44
|
+
const onData = (d) => {
|
|
45
|
+
const s = d.toString();
|
|
46
|
+
if (re.test(s)) { clearTimeout(to); child.stdout.off("data", onData); child.stderr.off("data", onData); resolve(); }
|
|
47
|
+
};
|
|
48
|
+
child.stdout.on("data", onData);
|
|
49
|
+
child.stderr.on("data", onData);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function main() {
|
|
54
|
+
let serverProc = null;
|
|
55
|
+
let baseUrl = process.env.BENCH_URL;
|
|
56
|
+
let tmpHome, tmpRoot;
|
|
57
|
+
const TOKEN = randomBytes(32).toString("hex");
|
|
58
|
+
const AGENT_ID = "ai:bench";
|
|
59
|
+
|
|
60
|
+
if (!baseUrl) {
|
|
61
|
+
const PORT = 3917;
|
|
62
|
+
tmpHome = await mkdtemp(path.join(tmpdir(), "bench-e2e-home-"));
|
|
63
|
+
tmpRoot = await mkdtemp(path.join(tmpdir(), "bench-e2e-root-"));
|
|
64
|
+
// Seed registry: ~/.wiki-viewer/agents.json with our agent + hashed token
|
|
65
|
+
const tokenHash = sha256hex(TOKEN);
|
|
66
|
+
await mkdir(path.join(tmpHome, ".wiki-viewer"), { recursive: true });
|
|
67
|
+
const now = new Date().toISOString();
|
|
68
|
+
await writeFile(
|
|
69
|
+
path.join(tmpHome, ".wiki-viewer", "agents.json"),
|
|
70
|
+
JSON.stringify({
|
|
71
|
+
version: 1,
|
|
72
|
+
agents: {
|
|
73
|
+
[AGENT_ID]: {
|
|
74
|
+
id: AGENT_ID, displayName: "Bench", tokenHash,
|
|
75
|
+
scope: { paths: ["**/*"], ops: ["read", "mutate", "delete"] },
|
|
76
|
+
createdAt: now, lastSeen: now,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
}, null, 2),
|
|
80
|
+
{ mode: 0o600 },
|
|
81
|
+
);
|
|
82
|
+
await writeFile(path.join(tmpRoot, "bench.md"), "# Doc\n\n" + "lorem ipsum dolor sit amet. ".repeat(40));
|
|
83
|
+
|
|
84
|
+
serverProc = spawn("node", [path.join(REPO, ".next/standalone/server.js")], {
|
|
85
|
+
env: {
|
|
86
|
+
...process.env,
|
|
87
|
+
HOME: tmpHome,
|
|
88
|
+
ROOT_DIR: tmpRoot,
|
|
89
|
+
PORT: String(PORT),
|
|
90
|
+
HOSTNAME: "127.0.0.1",
|
|
91
|
+
WIKI_ALLOW_INSECURE: "1",
|
|
92
|
+
NODE_ENV: "production",
|
|
93
|
+
},
|
|
94
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
95
|
+
});
|
|
96
|
+
await waitForLog(serverProc, /Ready|started server|Listening|localhost:|127\.0\.0\.1:/i);
|
|
97
|
+
baseUrl = `http://127.0.0.1:${PORT}`;
|
|
98
|
+
// settle
|
|
99
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Drive the REAL shim over stdio
|
|
103
|
+
const transport = new StdioClientTransport({
|
|
104
|
+
command: "node",
|
|
105
|
+
args: [path.join(HERE, "dist/index.js")],
|
|
106
|
+
env: {
|
|
107
|
+
...process.env,
|
|
108
|
+
WIKI_VIEWER_URL: baseUrl,
|
|
109
|
+
WIKI_VIEWER_TOKEN: TOKEN,
|
|
110
|
+
WIKI_VIEWER_AGENT_ID: AGENT_ID,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
const client = new Client({ name: "bench", version: "0" }, { capabilities: {} });
|
|
114
|
+
await client.connect(transport);
|
|
115
|
+
|
|
116
|
+
const call = (name, args) => client.callTool({ name, arguments: args });
|
|
117
|
+
|
|
118
|
+
const bodyBase = "# Doc\n\n" + "lorem ipsum dolor sit amet. ".repeat(40);
|
|
119
|
+
|
|
120
|
+
// Warm + measure read_file
|
|
121
|
+
const readS = [], writeS = [], editS = [];
|
|
122
|
+
for (let i = 0; i < WARMUP + ITERS; i++) {
|
|
123
|
+
const t = await timeIt(async () => { await call("read_file", { path: "bench.md" }); });
|
|
124
|
+
if (i >= WARMUP) readS.push(t);
|
|
125
|
+
}
|
|
126
|
+
// write_file (whole file) — read once to prime sha cache in shim
|
|
127
|
+
await call("read_file", { path: "bench.md" });
|
|
128
|
+
for (let i = 0; i < WARMUP + ITERS; i++) {
|
|
129
|
+
const t = await timeIt(async () => {
|
|
130
|
+
await call("write_file", { path: "bench.md", content: bodyBase + "\n\nw " + i, force: true });
|
|
131
|
+
});
|
|
132
|
+
if (i >= WARMUP) writeS.push(t);
|
|
133
|
+
}
|
|
134
|
+
// edit_file (read+replace+write inside one tool call)
|
|
135
|
+
await call("write_file", { path: "bench.md", content: bodyBase + "\n\nseed", force: true });
|
|
136
|
+
for (let i = 0; i < WARMUP + ITERS; i++) {
|
|
137
|
+
const find = i === 0 ? "seed" : "e " + (i - 1);
|
|
138
|
+
const t = await timeIt(async () => {
|
|
139
|
+
await call("edit_file", { path: "bench.md", find, replace: "e " + i });
|
|
140
|
+
});
|
|
141
|
+
if (i >= WARMUP) editS.push(t);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const results = [stats("read_file", readS), stats("write_file (whole-file)", writeS), stats("edit_file (read+write)", editS)];
|
|
145
|
+
console.log(`\nE2E MCP benchmark — full stack minus LLM (iters=${ITERS}, warmup=${WARMUP})`);
|
|
146
|
+
console.log(`target: ${baseUrl}${serverProc ? " (local standalone)" : " (remote)"}\n`);
|
|
147
|
+
console.log(["tool".padEnd(26), "p50", "p95", "p99", "mean", "max"].join(" "));
|
|
148
|
+
for (const r of results) {
|
|
149
|
+
console.log([
|
|
150
|
+
r.label.padEnd(26),
|
|
151
|
+
`${r.p50.toFixed(1)}ms`.padStart(8),
|
|
152
|
+
`${r.p95.toFixed(1)}ms`.padStart(8),
|
|
153
|
+
`${r.p99.toFixed(1)}ms`.padStart(8),
|
|
154
|
+
`${r.mean.toFixed(1)}ms`.padStart(8),
|
|
155
|
+
`${r.max.toFixed(1)}ms`.padStart(8),
|
|
156
|
+
].join(" "));
|
|
157
|
+
}
|
|
158
|
+
console.log("");
|
|
159
|
+
|
|
160
|
+
await client.close();
|
|
161
|
+
if (serverProc) serverProc.kill("SIGTERM");
|
|
162
|
+
if (tmpHome) await rm(tmpHome, { recursive: true, force: true });
|
|
163
|
+
if (tmpRoot) await rm(tmpRoot, { recursive: true, force: true });
|
|
164
|
+
process.exit(0);
|
|
165
|
+
}
|
|
166
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Latency-model benchmark: proves how per-call TCP/TLS handshakes (no keep-alive)
|
|
3
|
+
* vs pooled keep-alive connections behave under WAN-like RTT.
|
|
4
|
+
*
|
|
5
|
+
* It does NOT need the full Next server — it runs a tiny local HTTP server that
|
|
6
|
+
* sleeps RTT/2 before responding (modeling one-way latency), then compares:
|
|
7
|
+
* A) fetch with a fresh connection each call (no keep-alive)
|
|
8
|
+
* B) fetch over a pooled keep-alive agent (undici)
|
|
9
|
+
*
|
|
10
|
+
* For each, it models a "write" (1 request) and an "edit" (2 requests: GET+PUT).
|
|
11
|
+
*
|
|
12
|
+
* Run: node packages/wiki-viewer-mcp/bench-latency.mjs [rttMs]
|
|
13
|
+
*/
|
|
14
|
+
import http from "node:http";
|
|
15
|
+
import { Agent, fetch as undiciFetch } from "undici";
|
|
16
|
+
|
|
17
|
+
const RTT = Number(process.argv[2] ?? process.env.RTT_MS ?? 80); // round-trip ms
|
|
18
|
+
const ITERS = Number(process.env.ITERS ?? 30);
|
|
19
|
+
const ONE_WAY = RTT / 2;
|
|
20
|
+
|
|
21
|
+
function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
|
|
22
|
+
|
|
23
|
+
// Local server that injects one-way latency on both receive and send.
|
|
24
|
+
const server = http.createServer(async (req, res) => {
|
|
25
|
+
// drain body
|
|
26
|
+
for await (const _ of req) { /* consume */ }
|
|
27
|
+
await sleep(ONE_WAY); // network: client -> server
|
|
28
|
+
res.writeHead(200, { "Content-Type": "application/json", ETag: '"sha256:abc"', "X-File-Size": "1024" });
|
|
29
|
+
res.end(JSON.stringify({ sha256: "sha256:abc", size: 1024 }));
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function pct(s, p) { return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; }
|
|
33
|
+
function summarize(label, samples) {
|
|
34
|
+
const s = [...samples].sort((a, b) => a - b);
|
|
35
|
+
const mean = s.reduce((a, b) => a + b, 0) / s.length;
|
|
36
|
+
return `${label.padEnd(40)} p50=${pct(s, 50).toFixed(0)}ms p95=${pct(s, 95).toFixed(0)}ms mean=${mean.toFixed(0)}ms`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function bench(label, doCall) {
|
|
40
|
+
const samples = [];
|
|
41
|
+
for (let i = 0; i < ITERS + 5; i++) {
|
|
42
|
+
const t = performance.now();
|
|
43
|
+
await doCall();
|
|
44
|
+
if (i >= 5) samples.push(performance.now() - t);
|
|
45
|
+
}
|
|
46
|
+
return summarize(label, samples);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async function main() {
|
|
50
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
51
|
+
const port = server.address().port;
|
|
52
|
+
const base = `http://127.0.0.1:${port}/x`;
|
|
53
|
+
|
|
54
|
+
// A) No keep-alive: force a fresh connection per request.
|
|
55
|
+
const noKa = new Agent({ pipelining: 0, connections: 1, keepAliveTimeout: 1, keepAliveMaxTimeout: 1 });
|
|
56
|
+
// B) Pooled keep-alive.
|
|
57
|
+
const ka = new Agent({ keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000, connections: 10 });
|
|
58
|
+
|
|
59
|
+
const req = (dispatcher, method) =>
|
|
60
|
+
undiciFetch(base, { method, dispatcher, headers: { "content-type": "application/octet-stream" }, body: method === "PUT" ? "x".repeat(1024) : undefined })
|
|
61
|
+
.then((r) => r.arrayBuffer());
|
|
62
|
+
|
|
63
|
+
// Model a COLD connection: TCP(1 RTT) + TLS(≈2 RTT) handshake before the
|
|
64
|
+
// request even goes out. Real HTTPS over WAN pays this on every call when
|
|
65
|
+
// the client doesn't reuse connections. We add it explicitly because
|
|
66
|
+
// loopback can't reproduce handshake cost.
|
|
67
|
+
const HANDSHAKE_RTTS = Number(process.env.HANDSHAKE_RTTS ?? 3); // TCP+TLS ≈ 3 RTT
|
|
68
|
+
const freshReq = async (method) => {
|
|
69
|
+
await sleep(HANDSHAKE_RTTS * RTT); // cold-connection handshake penalty
|
|
70
|
+
const d = new Agent({ keepAliveTimeout: 1, connections: 1 });
|
|
71
|
+
await req(d, method);
|
|
72
|
+
await d.close();
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
console.log(`\nlatency model — RTT=${RTT}ms, iters=${ITERS}\n`);
|
|
76
|
+
console.log(await bench("write · fresh conn per call (no keep-alive)", () => freshReq("PUT")));
|
|
77
|
+
console.log(await bench("write · pooled keep-alive", () => req(ka, "PUT")));
|
|
78
|
+
console.log(await bench("edit · fresh conn per call (GET+PUT)", async () => { await freshReq("GET"); await freshReq("PUT"); }));
|
|
79
|
+
console.log(await bench("edit · pooled keep-alive (GET+PUT)", async () => { await req(ka, "GET"); await req(ka, "PUT"); }));
|
|
80
|
+
console.log("");
|
|
81
|
+
console.log("Note: 'fresh conn' pays TCP handshake (+~1 RTT) per call; real HTTPS adds TLS (+1-2 RTT).");
|
|
82
|
+
console.log("");
|
|
83
|
+
|
|
84
|
+
await noKa.close(); await ka.close();
|
|
85
|
+
server.close();
|
|
86
|
+
}
|
|
87
|
+
main().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Payload-size model: how much does whole-file PUT cost vs a diff/patch,
|
|
3
|
+
* across realistic .md sizes and uplink speeds? Decides whether a server-side
|
|
4
|
+
* patch endpoint is worth building.
|
|
5
|
+
*
|
|
6
|
+
* Pure arithmetic model (no network needed): upload_time = bytes / uplink_Bps.
|
|
7
|
+
* A small edit changes ~1 line; a patch payload ≈ the changed region + framing.
|
|
8
|
+
*
|
|
9
|
+
* Run: node packages/wiki-viewer-mcp/bench-payload.mjs
|
|
10
|
+
*/
|
|
11
|
+
const DOC_SIZES = [
|
|
12
|
+
{ label: "small note (2 KB)", bytes: 2 * 1024 },
|
|
13
|
+
{ label: "typical doc (20 KB)", bytes: 20 * 1024 },
|
|
14
|
+
{ label: "long doc (100 KB)", bytes: 100 * 1024 },
|
|
15
|
+
{ label: "big doc (500 KB)", bytes: 500 * 1024 },
|
|
16
|
+
];
|
|
17
|
+
// Uplink speeds (bytes/sec). Home/office uplinks are often the bottleneck.
|
|
18
|
+
const UPLINKS = [
|
|
19
|
+
{ label: "fast (10 MB/s)", bps: 10 * 1024 * 1024 },
|
|
20
|
+
{ label: "typical home up (2 Mbps = 0.25 MB/s)", bps: 0.25 * 1024 * 1024 },
|
|
21
|
+
{ label: "slow / shared (0.5 Mbps)", bps: 0.0625 * 1024 * 1024 },
|
|
22
|
+
];
|
|
23
|
+
const PATCH_PAYLOAD = 300; // bytes: a one-line str-replace patch + JSON framing
|
|
24
|
+
|
|
25
|
+
function ms(bytes, bps) { return (bytes / bps) * 1000; }
|
|
26
|
+
|
|
27
|
+
console.log("\nupload-time model — whole-file PUT vs ~300B patch (transfer only)\n");
|
|
28
|
+
for (const up of UPLINKS) {
|
|
29
|
+
console.log(`uplink: ${up.label}`);
|
|
30
|
+
console.log(" " + ["doc".padEnd(22), "whole-file", "patch", "speedup"].join(" "));
|
|
31
|
+
for (const d of DOC_SIZES) {
|
|
32
|
+
const whole = ms(d.bytes, up.bps);
|
|
33
|
+
const patch = ms(PATCH_PAYLOAD, up.bps);
|
|
34
|
+
const speedup = whole / patch;
|
|
35
|
+
console.log(" " + [
|
|
36
|
+
d.label.padEnd(22),
|
|
37
|
+
`${whole.toFixed(0)}ms`.padStart(10),
|
|
38
|
+
`${patch.toFixed(1)}ms`.padStart(7),
|
|
39
|
+
`${speedup.toFixed(0)}x`.padStart(7),
|
|
40
|
+
].join(" "));
|
|
41
|
+
}
|
|
42
|
+
console.log("");
|
|
43
|
+
}
|
|
44
|
+
console.log("Note: this is transfer time only; add RTT + handshake (now pooled) on top.");
|
|
45
|
+
console.log("Whole-file PUT also re-sends unchanged bytes every edit; patch sends only the change.\n");
|
|
@@ -114,6 +114,8 @@ export class WikiViewerClient {
|
|
|
114
114
|
collabRevision: collab.collabRevision,
|
|
115
115
|
collabSnapshot: collab.collabSnapshot,
|
|
116
116
|
fetchedAt: Date.now(),
|
|
117
|
+
// Cache the text body (bounded) so edit_file can skip a re-GET.
|
|
118
|
+
body: text !== null && bodyBuf.byteLength <= cache.MAX_CACHED_BODY_BYTES ? text : undefined,
|
|
117
119
|
};
|
|
118
120
|
cache.set(path, state);
|
|
119
121
|
return { body: bodyBuf, text, sha256, size, mtime, contentType, ...collab };
|
|
@@ -134,23 +136,50 @@ export class WikiViewerClient {
|
|
|
134
136
|
params["mkdirs"] = "true";
|
|
135
137
|
if (opts.force)
|
|
136
138
|
params["force"] = "true";
|
|
137
|
-
const
|
|
139
|
+
const encoded = typeof content === "string"
|
|
138
140
|
? new TextEncoder().encode(content)
|
|
139
141
|
: content;
|
|
140
|
-
const
|
|
142
|
+
const body = encoded;
|
|
143
|
+
const doPut = (headers) => this._fetch(this.url(`/api/agent/fs/file/${encodeFilePath(path)}`, params), {
|
|
141
144
|
method: "PUT",
|
|
142
|
-
headers: this.headers({ "Content-Type": "application/octet-stream", ...
|
|
145
|
+
headers: this.headers({ "Content-Type": "application/octet-stream", ...headers }),
|
|
143
146
|
body,
|
|
144
147
|
});
|
|
148
|
+
let res = await doPut(extraHeaders);
|
|
149
|
+
// Auto-recover from "If-Match required" (overwrite without a known sha):
|
|
150
|
+
// the server demands a precondition for existing files. Transparently fetch
|
|
151
|
+
// the current sha and retry once, instead of bubbling a 412 to the agent
|
|
152
|
+
// (which would cost it an extra read + retry round-trip of its own).
|
|
153
|
+
if (res.status === 412 &&
|
|
154
|
+
!extraHeaders["If-Match"] &&
|
|
155
|
+
!opts.force &&
|
|
156
|
+
opts.ifMatch === undefined) {
|
|
157
|
+
let body412;
|
|
158
|
+
try {
|
|
159
|
+
body412 = await res.clone().json();
|
|
160
|
+
}
|
|
161
|
+
catch { /* ignore */ }
|
|
162
|
+
const code = body412?.error;
|
|
163
|
+
if (code === "PRECONDITION_REQUIRED") {
|
|
164
|
+
const cur = await this.readFile(path);
|
|
165
|
+
if (cur?.sha256) {
|
|
166
|
+
res = await doPut({ ...extraHeaders, "If-Match": cur.sha256 });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
145
170
|
await this.assertOk(res, path);
|
|
146
171
|
const result = await res.json();
|
|
147
|
-
// Update cache after successful write
|
|
172
|
+
// Update cache after a successful write. Preserve known collab fields from a
|
|
173
|
+
// prior read (do NOT clobber to not-markdown), refresh sha + body.
|
|
174
|
+
const prior = cache.get(path);
|
|
175
|
+
const text = typeof content === "string" ? content : null;
|
|
148
176
|
cache.set(path, {
|
|
149
177
|
sha256: result.sha256,
|
|
150
|
-
collabState: "not-markdown",
|
|
151
|
-
collabRevision: null,
|
|
152
|
-
collabSnapshot: null,
|
|
178
|
+
collabState: prior?.collabState ?? "not-markdown",
|
|
179
|
+
collabRevision: prior?.collabRevision ?? null,
|
|
180
|
+
collabSnapshot: prior?.collabSnapshot ?? null,
|
|
153
181
|
fetchedAt: Date.now(),
|
|
182
|
+
body: text !== null && encoded.byteLength <= cache.MAX_CACHED_BODY_BYTES ? text : undefined,
|
|
154
183
|
});
|
|
155
184
|
return result;
|
|
156
185
|
}
|
|
@@ -24,6 +24,31 @@ import * as z from "zod";
|
|
|
24
24
|
import { parseArgs } from "node:util";
|
|
25
25
|
import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError } from "./http-client.js";
|
|
26
26
|
import * as stateCache from "./state-cache.js";
|
|
27
|
+
/**
|
|
28
|
+
* Enable pooled HTTP keep-alive for the global fetch the client uses.
|
|
29
|
+
*
|
|
30
|
+
* Without this, every tool call opens a fresh connection and pays a full
|
|
31
|
+
* TCP + TLS handshake (~3 RTT) before the request even goes out. Over a WAN
|
|
32
|
+
* link that turns a sub-100ms write into 300ms-1s+, and multiplies across the
|
|
33
|
+
* GET+PUT an edit performs. Reusing connections removes that per-call penalty.
|
|
34
|
+
*
|
|
35
|
+
* Wrapped in try/catch + dynamic import so a missing/edge undici never breaks
|
|
36
|
+
* startup; it just falls back to default (non-pooled) fetch.
|
|
37
|
+
*/
|
|
38
|
+
async function enableKeepAlive() {
|
|
39
|
+
try {
|
|
40
|
+
const { Agent, setGlobalDispatcher } = await import("undici");
|
|
41
|
+
setGlobalDispatcher(new Agent({
|
|
42
|
+
keepAliveTimeout: 30_000,
|
|
43
|
+
keepAliveMaxTimeout: 60_000,
|
|
44
|
+
connections: 16,
|
|
45
|
+
pipelining: 1,
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// undici unavailable — default fetch still works, just without pooling.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
27
52
|
import { register, RegistrationDeniedError, RegistrationExpiredError, RegistrationTimeoutError, } from "./register.js";
|
|
28
53
|
// ─── Config ───────────────────────────────────────────────────────────────────
|
|
29
54
|
function requireEnv(name) {
|
|
@@ -221,12 +246,34 @@ export function createServer(client) {
|
|
|
221
246
|
const block = checkCollabBlock(path);
|
|
222
247
|
if (block)
|
|
223
248
|
return err(block);
|
|
224
|
-
//
|
|
249
|
+
// Fast path: if a prior read cached this file's text + sha, transform
|
|
250
|
+
// from cache and do a conditional PUT (If-Match=cached sha). This
|
|
251
|
+
// skips the GET round-trip. If the file changed server-side the PUT
|
|
252
|
+
// 412s (IfMatchError) and we fall back to a fresh read+retry — so the
|
|
253
|
+
// result is always against current content, never a stale write.
|
|
254
|
+
const cached = stateCache.get(path);
|
|
255
|
+
if (cached?.body !== undefined &&
|
|
256
|
+
cached.sha256 &&
|
|
257
|
+
cached.collabState !== "active" &&
|
|
258
|
+
cached.body.includes(find)) {
|
|
259
|
+
const newContent = cached.body.replace(find, replace);
|
|
260
|
+
try {
|
|
261
|
+
const writeResult = await client.writeFile(path, newContent, {
|
|
262
|
+
ifMatch: cached.sha256,
|
|
263
|
+
});
|
|
264
|
+
return ok(`Edited: ${path}\nReplaced ${JSON.stringify(find)} → ${JSON.stringify(replace)}\nNew sha256: ${writeResult.sha256}`);
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
if (!(e instanceof IfMatchError))
|
|
268
|
+
throw e;
|
|
269
|
+
// File changed since our cached read — fall through to re-read.
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
// Slow path: read → transform → PUT with If-Match.
|
|
225
273
|
const readResult = await client.readFile(path);
|
|
226
274
|
if (readResult.text === null) {
|
|
227
275
|
return err(`edit_file: "${path}" appears to be binary — cannot do text replacement.`);
|
|
228
276
|
}
|
|
229
|
-
// Check collab state from fresh read
|
|
230
277
|
if (readResult.collabState === "active") {
|
|
231
278
|
return err(collabActiveMessage(path, readResult.collabSnapshot));
|
|
232
279
|
}
|
|
@@ -338,6 +385,7 @@ function buildJsonSchema(val) {
|
|
|
338
385
|
}
|
|
339
386
|
// ─── Entrypoints ─────────────────────────────────────────────────────────────
|
|
340
387
|
async function main() {
|
|
388
|
+
await enableKeepAlive();
|
|
341
389
|
const client = createClient();
|
|
342
390
|
const server = createServer(client);
|
|
343
391
|
const transport = new StdioServerTransport();
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
* Per-path cache for last-known file metadata from GET responses.
|
|
3
3
|
* Drives If-Match and collab-state checks on subsequent writes.
|
|
4
4
|
*/
|
|
5
|
+
/** Don't cache bodies larger than this (avoid unbounded memory). */
|
|
6
|
+
export const MAX_CACHED_BODY_BYTES = 256 * 1024;
|
|
5
7
|
const cache = new Map();
|
|
6
8
|
export function set(path, state) {
|
|
7
9
|
cache.set(normalisePath(path), state);
|
|
@@ -51,6 +51,22 @@ export class CollabActiveError extends WikiViewerError {
|
|
|
51
51
|
}
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** PATCH endpoint absent (old server) or method not allowed — caller should fall back to read+PUT. */
|
|
55
|
+
export class PatchUnsupportedError extends WikiViewerError {
|
|
56
|
+
constructor(path: string, status: number) {
|
|
57
|
+
super(status, "PATCH_UNSUPPORTED", `Server does not support PATCH for ${path}`, undefined);
|
|
58
|
+
this.name = "PatchUnsupportedError";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Server-side str-replace found a different number of matches than expected. */
|
|
63
|
+
export class MatchCountError extends WikiViewerError {
|
|
64
|
+
constructor(path: string, public readonly found: number, public readonly expected: number, body?: unknown) {
|
|
65
|
+
super(422, "MATCH_COUNT_MISMATCH", `${path}: expected ${expected} match(es), found ${found}`, body);
|
|
66
|
+
this.name = "MatchCountError";
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
54
70
|
// ─── Response shapes ─────────────────────────────────────────────────────────
|
|
55
71
|
|
|
56
72
|
export interface ReadResult {
|
|
@@ -189,6 +205,8 @@ export class WikiViewerClient {
|
|
|
189
205
|
collabRevision: collab.collabRevision,
|
|
190
206
|
collabSnapshot: collab.collabSnapshot,
|
|
191
207
|
fetchedAt: Date.now(),
|
|
208
|
+
// Cache the text body (bounded) so edit_file can skip a re-GET.
|
|
209
|
+
body: text !== null && bodyBuf.byteLength <= cache.MAX_CACHED_BODY_BYTES ? text : undefined,
|
|
192
210
|
};
|
|
193
211
|
cache.set(path, state);
|
|
194
212
|
|
|
@@ -221,28 +239,119 @@ export class WikiViewerClient {
|
|
|
221
239
|
if (opts.mkdirs) params["mkdirs"] = "true";
|
|
222
240
|
if (opts.force) params["force"] = "true";
|
|
223
241
|
|
|
224
|
-
const
|
|
225
|
-
?
|
|
226
|
-
:
|
|
242
|
+
const encoded: Uint8Array = typeof content === "string"
|
|
243
|
+
? new TextEncoder().encode(content)
|
|
244
|
+
: content;
|
|
245
|
+
const body = encoded as unknown as Uint8Array<ArrayBuffer>;
|
|
227
246
|
|
|
228
|
-
const
|
|
229
|
-
this.url(`/api/agent/fs/file/${encodeFilePath(path)}`, params),
|
|
230
|
-
{
|
|
247
|
+
const doPut = (headers: Record<string, string>) =>
|
|
248
|
+
this._fetch(this.url(`/api/agent/fs/file/${encodeFilePath(path)}`, params), {
|
|
231
249
|
method: "PUT",
|
|
232
|
-
headers: this.headers({ "Content-Type": "application/octet-stream", ...
|
|
250
|
+
headers: this.headers({ "Content-Type": "application/octet-stream", ...headers }),
|
|
233
251
|
body,
|
|
234
|
-
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
let res = await doPut(extraHeaders);
|
|
255
|
+
|
|
256
|
+
// Auto-recover from "If-Match required" (overwrite without a known sha):
|
|
257
|
+
// the server demands a precondition for existing files. Transparently fetch
|
|
258
|
+
// the current sha and retry once, instead of bubbling a 412 to the agent
|
|
259
|
+
// (which would cost it an extra read + retry round-trip of its own).
|
|
260
|
+
if (
|
|
261
|
+
res.status === 412 &&
|
|
262
|
+
!extraHeaders["If-Match"] &&
|
|
263
|
+
!opts.force &&
|
|
264
|
+
opts.ifMatch === undefined
|
|
265
|
+
) {
|
|
266
|
+
let body412: unknown;
|
|
267
|
+
try { body412 = await res.clone().json(); } catch { /* ignore */ }
|
|
268
|
+
const code = (body412 as Record<string, unknown> | undefined)?.error;
|
|
269
|
+
if (code === "PRECONDITION_REQUIRED") {
|
|
270
|
+
const cur = await this.readFile(path);
|
|
271
|
+
if (cur?.sha256) {
|
|
272
|
+
res = await doPut({ ...extraHeaders, "If-Match": cur.sha256 });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
await this.assertOk(res, path);
|
|
278
|
+
|
|
279
|
+
const result: WriteResult = await res.json() as WriteResult;
|
|
280
|
+
// Update cache after a successful write. Preserve known collab fields from a
|
|
281
|
+
// prior read (do NOT clobber to not-markdown), refresh sha + body.
|
|
282
|
+
const prior = cache.get(path);
|
|
283
|
+
const text = typeof content === "string" ? content : null;
|
|
284
|
+
cache.set(path, {
|
|
285
|
+
sha256: result.sha256,
|
|
286
|
+
collabState: prior?.collabState ?? "not-markdown",
|
|
287
|
+
collabRevision: prior?.collabRevision ?? null,
|
|
288
|
+
collabSnapshot: prior?.collabSnapshot ?? null,
|
|
289
|
+
fetchedAt: Date.now(),
|
|
290
|
+
body: text !== null && encoded.byteLength <= cache.MAX_CACHED_BODY_BYTES ? text : undefined,
|
|
291
|
+
});
|
|
292
|
+
return result;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── patch_file (server-side str-replace) ─────────────────────────────────────
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Server-side exact str-replace: sends only {find, replace} instead of the
|
|
299
|
+
* whole file. Requires If-Match (auto-filled from cache if not given).
|
|
300
|
+
* Throws PatchUnsupportedError on 404/405 so callers can fall back to
|
|
301
|
+
* read+PUT against older servers, and MatchCountError on 422.
|
|
302
|
+
*/
|
|
303
|
+
async patchFile(
|
|
304
|
+
path: string,
|
|
305
|
+
find: string,
|
|
306
|
+
replace: string,
|
|
307
|
+
opts: { ifMatch?: string; expectedOccurrences?: number; ifCollabMatch?: number } = {},
|
|
308
|
+
): Promise<WriteResult> {
|
|
309
|
+
const ifMatch = opts.ifMatch ?? cache.get(path)?.sha256;
|
|
310
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
311
|
+
if (ifMatch) headers["If-Match"] = ifMatch;
|
|
312
|
+
if (opts.ifCollabMatch !== undefined) headers["If-Collab-Match"] = String(opts.ifCollabMatch);
|
|
313
|
+
|
|
314
|
+
const payload: Record<string, unknown> = { find, replace };
|
|
315
|
+
if (opts.expectedOccurrences !== undefined) payload.expectedOccurrences = opts.expectedOccurrences;
|
|
316
|
+
|
|
317
|
+
const res = await this._fetch(
|
|
318
|
+
this.url(`/api/agent/fs/file/${encodeFilePath(path)}`),
|
|
319
|
+
{ method: "PATCH", headers: this.headers(headers), body: JSON.stringify(payload) },
|
|
235
320
|
);
|
|
321
|
+
|
|
322
|
+
// 405 = method not allowed (endpoint absent). 404 is ambiguous: our handler
|
|
323
|
+
// returns 404 {error:"NOT_FOUND"} for a missing FILE, but an old server with
|
|
324
|
+
// no PATCH route returns a bare 404. Only treat 404 as "unsupported" when the
|
|
325
|
+
// body is NOT our structured NOT_FOUND (so a missing file surfaces normally).
|
|
326
|
+
if (res.status === 405) {
|
|
327
|
+
throw new PatchUnsupportedError(path, res.status);
|
|
328
|
+
}
|
|
329
|
+
if (res.status === 404) {
|
|
330
|
+
let body: unknown;
|
|
331
|
+
try { body = await res.clone().json(); } catch { /* not JSON → likely no route */ }
|
|
332
|
+
const code = (body as Record<string, unknown> | undefined)?.error;
|
|
333
|
+
if (code !== "NOT_FOUND") throw new PatchUnsupportedError(path, 404);
|
|
334
|
+
// else fall through to assertOk → throws WikiViewerError(404) for missing file
|
|
335
|
+
}
|
|
336
|
+
if (res.status === 422) {
|
|
337
|
+
let body: unknown;
|
|
338
|
+
try { body = await res.json(); } catch { /* ignore */ }
|
|
339
|
+
const b = body as Record<string, unknown> | undefined;
|
|
340
|
+
throw new MatchCountError(path, Number(b?.found ?? -1), Number(b?.expected ?? -1), body);
|
|
341
|
+
}
|
|
236
342
|
await this.assertOk(res, path);
|
|
237
343
|
|
|
238
344
|
const result: WriteResult = await res.json() as WriteResult;
|
|
239
|
-
|
|
345
|
+
const prior = cache.get(path);
|
|
240
346
|
cache.set(path, {
|
|
241
347
|
sha256: result.sha256,
|
|
242
|
-
collabState: "not-markdown",
|
|
243
|
-
collabRevision: null,
|
|
244
|
-
collabSnapshot: null,
|
|
348
|
+
collabState: prior?.collabState ?? "not-markdown",
|
|
349
|
+
collabRevision: prior?.collabRevision ?? null,
|
|
350
|
+
collabSnapshot: prior?.collabSnapshot ?? null,
|
|
245
351
|
fetchedAt: Date.now(),
|
|
352
|
+
// We don't have the full new body here without reconstructing it; drop the
|
|
353
|
+
// stale cached body so the next edit re-reads rather than patching blind.
|
|
354
|
+
body: undefined,
|
|
246
355
|
});
|
|
247
356
|
return result;
|
|
248
357
|
}
|