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.
Files changed (131) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/routes-manifest.json +6 -0
  5. package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  22. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  23. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  24. package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js +1 -1
  25. package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js +1 -1
  27. package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js +1 -1
  29. package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js +1 -1
  31. package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +2 -2
  33. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/wiki/content/route.js +1 -1
  37. package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/wiki/download/route/app-paths-manifest.json +3 -0
  39. package/.next/standalone/.next/server/app/api/wiki/download/route/build-manifest.json +9 -0
  40. package/.next/standalone/.next/server/app/api/wiki/download/route/server-reference-manifest.json +4 -0
  41. package/.next/standalone/.next/server/app/api/wiki/download/route.js +15 -0
  42. package/.next/standalone/.next/server/app/api/wiki/download/route.js.map +5 -0
  43. package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -0
  44. package/.next/standalone/.next/server/app/api/wiki/download/route_client-reference-manifest.js +3 -0
  45. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/wiki/watch/route.js +5 -5
  47. package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/index.html +1 -1
  49. package/.next/standalone/.next/server/app/index.rsc +2 -2
  50. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
  52. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  53. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  54. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  55. package/.next/standalone/.next/server/app/page.js +1 -1
  56. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/signin/page.js +1 -1
  59. package/.next/standalone/.next/server/app/signin/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  61. package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +1 -1
  62. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0p12-6~._.js → [root-of-the-server]__094q5bj._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0w47wil._.js → [root-of-the-server]__0km8suj._.js} +2 -2
  64. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__12qerfy._.js → [root-of-the-server]__0mzo03s._.js} +2 -2
  65. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0e7si9p._.js → [root-of-the-server]__0rzo40z._.js} +2 -2
  66. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t5mg.n._.js +3 -0
  67. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0zi3nkn._.js → [root-of-the-server]__106uw_c._.js} +2 -2
  68. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0qhdf-p._.js → [root-of-the-server]__12800b1._.js} +2 -2
  69. package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_download_route_actions_0rd6p-3.js +3 -0
  70. package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +3 -0
  71. 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
  72. package/.next/standalone/.next/server/chunks/ssr/{[externals]__0jyyo2t._.js → [externals]__05-uq57._.js} +2 -2
  73. package/.next/standalone/.next/server/chunks/ssr/{[externals]__0m6xq-c._.js → [externals]__0btd01s._.js} +2 -2
  74. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06mzg2t._.js → [root-of-the-server]__00lk0r~._.js} +2 -2
  75. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12ahxbl._.js → [root-of-the-server]__0ytvlzs._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +7 -7
  77. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0v_nwsz._.js → node_modules__pnpm_0-x6zex._.js} +1 -1
  78. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0tkiej3._.js → node_modules__pnpm_012nt.s._.js} +2 -2
  79. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~2~c30._.js → node_modules__pnpm_07rxv2x._.js} +1 -1
  80. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gbgliv._.js → node_modules__pnpm_0a4su30._.js} +2 -2
  81. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_10413dq._.js → node_modules__pnpm_0ajzar9._.js} +1 -1
  82. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zzzsrj._.js → node_modules__pnpm_0bum3-3._.js} +1 -1
  83. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wffzo0._.js → node_modules__pnpm_0c71i8a._.js} +1 -1
  84. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_099cv.b._.js → node_modules__pnpm_0cbsnwf._.js} +1 -1
  85. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f_uzgy._.js → node_modules__pnpm_0cxwhfd._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03ij5.g._.js → node_modules__pnpm_0dboa_e._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0iojvye._.js → node_modules__pnpm_0de8xt-._.js} +2 -2
  88. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02gx8km._.js → node_modules__pnpm_0ezm2us._.js} +1 -1
  89. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m5-azh._.js → node_modules__pnpm_0hobcbs._.js} +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~.6dgb._.js → node_modules__pnpm_0hrbz84._.js} +1 -1
  91. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0rebktf._.js → node_modules__pnpm_0i.pw_m._.js} +1 -1
  92. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03v21p-._.js → node_modules__pnpm_0il8j.j._.js} +2 -2
  93. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0cwpt9p._.js → node_modules__pnpm_0j3mvgg._.js} +1 -1
  94. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0fixtms._.js → node_modules__pnpm_0j53hti._.js} +1 -1
  95. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0li2vzs._.js → node_modules__pnpm_0kwc-vs._.js} +1 -1
  96. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_05qsply._.js → node_modules__pnpm_0lm8e2c._.js} +2 -2
  97. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_08ibh0w._.js → node_modules__pnpm_0mdo4i3._.js} +2 -2
  98. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_06pgebo._.js → node_modules__pnpm_0o.c1z8._.js} +2 -2
  99. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gofu~q._.js → node_modules__pnpm_0rw91-f._.js} +1 -1
  100. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0n1kfcp._.js → node_modules__pnpm_0xrmzh1._.js} +1 -1
  101. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02mew~s._.js → node_modules__pnpm_0yq5z.h._.js} +2 -2
  102. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xxwmv2._.js → node_modules__pnpm_0z~h2ow._.js} +1 -1
  103. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f9fpy5._.js → node_modules__pnpm_0~ln0by._.js} +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wouz19._.js → node_modules__pnpm_11e~j49._.js} +1 -1
  105. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  106. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  107. package/.next/standalone/.next/server/pages/404.html +1 -1
  108. package/.next/standalone/.next/server/pages/500.html +1 -1
  109. package/.next/standalone/.next/static/chunks/{17~r3.8awh3qg.js → 0vwkqw_rofm-c.js} +7 -7
  110. package/.next/standalone/package.json +2 -2
  111. package/.next/standalone/packages/wiki-viewer-mcp/bench-e2e.mjs +166 -0
  112. package/.next/standalone/packages/wiki-viewer-mcp/bench-latency.mjs +87 -0
  113. package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
  114. package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +36 -7
  115. package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +50 -2
  116. package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
  117. package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
  118. package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
  119. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +63 -5
  120. package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
  121. package/.next/standalone/pnpm-lock.yaml +3 -3
  122. package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
  123. package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
  124. package/.next/standalone/src/app/page.tsx +31 -1
  125. package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
  126. package/.next/standalone/src/tests/proof/bench-write.ts +179 -0
  127. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  128. package/package.json +2 -2
  129. /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_buildManifest.js +0 -0
  130. /package/.next/standalone/.next/static/{_avhhNXTnEvzPnQc1sIDF → uQH5T9TFHOhO0NJbO4QGI}/_clientMiddlewareManifest.js +0 -0
  131. /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.5.2",
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 body = typeof content === "string"
139
+ const encoded = typeof content === "string"
138
140
  ? new TextEncoder().encode(content)
139
141
  : content;
140
- const res = await this._fetch(this.url(`/api/agent/fs/file/${encodeFilePath(path)}`, params), {
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", ...extraHeaders }),
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
- // Read transform PUT with If-Match
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);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-viewer-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "MCP filesystem adapter for wiki-viewer agent API",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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 body: BodyInit = typeof content === "string"
225
- ? (new TextEncoder().encode(content) as unknown as Uint8Array<ArrayBuffer>)
226
- : (content as unknown as Uint8Array<ArrayBuffer>);
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 res = await this._fetch(
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", ...extraHeaders }),
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
- // Update cache after successful write
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
  }