wiki-viewer 1.6.0 → 1.7.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.
Files changed (129) 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/{0-qve5g~myvo-.js → 0vwkqw_rofm-c.js} +7 -7
  110. package/.next/standalone/docs/agent-fs-plan.md +3 -1
  111. package/.next/standalone/package.json +2 -2
  112. package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
  113. package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +114 -7
  114. package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +58 -3
  115. package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
  116. package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
  117. package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
  118. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +35 -5
  119. package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
  120. package/.next/standalone/pnpm-lock.yaml +3 -3
  121. package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
  122. package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
  123. package/.next/standalone/src/app/page.tsx +20 -0
  124. package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
  125. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  126. package/package.json +2 -2
  127. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → H0E5wVJm9tAuN5CrU6zG2}/_buildManifest.js +0 -0
  128. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → H0E5wVJm9tAuN5CrU6zG2}/_clientMiddlewareManifest.js +0 -0
  129. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → H0E5wVJm9tAuN5CrU6zG2}/_ssgManifest.js +0 -0
@@ -51,7 +51,9 @@ All routes reuse `checkAuth` + `enforceScope` + `withFileMutex` + `safeRootPath`
51
51
  | `POST` | `/api/agent/fs/move` | `{from, to, ifMatch?}`. Moves sidecar for `.md`. |
52
52
  | `POST` | `/api/agent/fs/search` | `{kind:"grep"\|"glob", query, path?, glob?, limit?}`. Server-side. |
53
53
 
54
- **No PATCH. No exec. No batch. No separate grep/glob.** Add later only if profiling proves need.
54
+ **No exec. No batch. No separate grep/glob.** Add later only if profiling proves need.
55
+
56
+ > **Update (shipped):** A server-side `PATCH /api/agent/fs/file/<path>` str-replace endpoint was added after profiling proved the whole-file-PUT payload dominates latency for large docs on slow uplinks (500KB @ 0.5Mbps ≈ 7.8s/edit transfer). It is strict: exact substring (no regex), text/UTF-8 only, `If-Match` required, `expectedOccurrences` must match exactly (default 1, else 422 `MATCH_COUNT_MISMATCH`). It shares one mutation code path (`applyMutation`) with PUT, so lock / R6 `COLLAB_ACTIVE` / reconcile / audit behave identically. The MCP `edit_file` tool uses PATCH first and falls back to read+PUT on older servers.
55
57
 
56
58
  ### 2.1 Read (`GET .../file/<path>`)
57
59
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-viewer",
3
- "version": "1.6.0",
3
+ "version": "1.7.1",
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,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");
@@ -33,6 +33,24 @@ export class CollabActiveError extends WikiViewerError {
33
33
  this.name = "CollabActiveError";
34
34
  }
35
35
  }
36
+ /** PATCH endpoint absent (old server) or method not allowed — caller should fall back to read+PUT. */
37
+ export class PatchUnsupportedError extends WikiViewerError {
38
+ constructor(path, status) {
39
+ super(status, "PATCH_UNSUPPORTED", `Server does not support PATCH for ${path}`, undefined);
40
+ this.name = "PatchUnsupportedError";
41
+ }
42
+ }
43
+ /** Server-side str-replace found a different number of matches than expected. */
44
+ export class MatchCountError extends WikiViewerError {
45
+ found;
46
+ expected;
47
+ constructor(path, found, expected, body) {
48
+ super(422, "MATCH_COUNT_MISMATCH", `${path}: expected ${expected} match(es), found ${found}`, body);
49
+ this.found = found;
50
+ this.expected = expected;
51
+ this.name = "MatchCountError";
52
+ }
53
+ }
36
54
  // ─── Client ──────────────────────────────────────────────────────────────────
37
55
  export class WikiViewerClient {
38
56
  baseUrl;
@@ -114,6 +132,8 @@ export class WikiViewerClient {
114
132
  collabRevision: collab.collabRevision,
115
133
  collabSnapshot: collab.collabSnapshot,
116
134
  fetchedAt: Date.now(),
135
+ // Cache the text body (bounded) so edit_file can skip a re-GET.
136
+ body: text !== null && bodyBuf.byteLength <= cache.MAX_CACHED_BODY_BYTES ? text : undefined,
117
137
  };
118
138
  cache.set(path, state);
119
139
  return { body: bodyBuf, text, sha256, size, mtime, contentType, ...collab };
@@ -134,23 +154,110 @@ export class WikiViewerClient {
134
154
  params["mkdirs"] = "true";
135
155
  if (opts.force)
136
156
  params["force"] = "true";
137
- const body = typeof content === "string"
157
+ const encoded = typeof content === "string"
138
158
  ? new TextEncoder().encode(content)
139
159
  : content;
140
- const res = await this._fetch(this.url(`/api/agent/fs/file/${encodeFilePath(path)}`, params), {
160
+ const body = encoded;
161
+ const doPut = (headers) => this._fetch(this.url(`/api/agent/fs/file/${encodeFilePath(path)}`, params), {
141
162
  method: "PUT",
142
- headers: this.headers({ "Content-Type": "application/octet-stream", ...extraHeaders }),
163
+ headers: this.headers({ "Content-Type": "application/octet-stream", ...headers }),
143
164
  body,
144
165
  });
166
+ let res = await doPut(extraHeaders);
167
+ // Auto-recover from "If-Match required" (overwrite without a known sha):
168
+ // the server demands a precondition for existing files. Transparently fetch
169
+ // the current sha and retry once, instead of bubbling a 412 to the agent
170
+ // (which would cost it an extra read + retry round-trip of its own).
171
+ if (res.status === 412 &&
172
+ !extraHeaders["If-Match"] &&
173
+ !opts.force &&
174
+ opts.ifMatch === undefined) {
175
+ let body412;
176
+ try {
177
+ body412 = await res.clone().json();
178
+ }
179
+ catch { /* ignore */ }
180
+ const code = body412?.error;
181
+ if (code === "PRECONDITION_REQUIRED") {
182
+ const cur = await this.readFile(path);
183
+ if (cur?.sha256) {
184
+ res = await doPut({ ...extraHeaders, "If-Match": cur.sha256 });
185
+ }
186
+ }
187
+ }
188
+ await this.assertOk(res, path);
189
+ const result = await res.json();
190
+ // Update cache after a successful write. Preserve known collab fields from a
191
+ // prior read (do NOT clobber to not-markdown), refresh sha + body.
192
+ const prior = cache.get(path);
193
+ const text = typeof content === "string" ? content : null;
194
+ cache.set(path, {
195
+ sha256: result.sha256,
196
+ collabState: prior?.collabState ?? "not-markdown",
197
+ collabRevision: prior?.collabRevision ?? null,
198
+ collabSnapshot: prior?.collabSnapshot ?? null,
199
+ fetchedAt: Date.now(),
200
+ body: text !== null && encoded.byteLength <= cache.MAX_CACHED_BODY_BYTES ? text : undefined,
201
+ });
202
+ return result;
203
+ }
204
+ // ── patch_file (server-side str-replace) ─────────────────────────────────────
205
+ /**
206
+ * Server-side exact str-replace: sends only {find, replace} instead of the
207
+ * whole file. Requires If-Match (auto-filled from cache if not given).
208
+ * Throws PatchUnsupportedError on 404/405 so callers can fall back to
209
+ * read+PUT against older servers, and MatchCountError on 422.
210
+ */
211
+ async patchFile(path, find, replace, opts = {}) {
212
+ const ifMatch = opts.ifMatch ?? cache.get(path)?.sha256;
213
+ const headers = { "Content-Type": "application/json" };
214
+ if (ifMatch)
215
+ headers["If-Match"] = ifMatch;
216
+ if (opts.ifCollabMatch !== undefined)
217
+ headers["If-Collab-Match"] = String(opts.ifCollabMatch);
218
+ const payload = { find, replace };
219
+ if (opts.expectedOccurrences !== undefined)
220
+ payload.expectedOccurrences = opts.expectedOccurrences;
221
+ const res = await this._fetch(this.url(`/api/agent/fs/file/${encodeFilePath(path)}`), { method: "PATCH", headers: this.headers(headers), body: JSON.stringify(payload) });
222
+ // 405 = method not allowed (endpoint absent). 404 is ambiguous: our handler
223
+ // returns 404 {error:"NOT_FOUND"} for a missing FILE, but an old server with
224
+ // no PATCH route returns a bare 404. Only treat 404 as "unsupported" when the
225
+ // body is NOT our structured NOT_FOUND (so a missing file surfaces normally).
226
+ if (res.status === 405) {
227
+ throw new PatchUnsupportedError(path, res.status);
228
+ }
229
+ if (res.status === 404) {
230
+ let body;
231
+ try {
232
+ body = await res.clone().json();
233
+ }
234
+ catch { /* not JSON → likely no route */ }
235
+ const code = body?.error;
236
+ if (code !== "NOT_FOUND")
237
+ throw new PatchUnsupportedError(path, 404);
238
+ // else fall through to assertOk → throws WikiViewerError(404) for missing file
239
+ }
240
+ if (res.status === 422) {
241
+ let body;
242
+ try {
243
+ body = await res.json();
244
+ }
245
+ catch { /* ignore */ }
246
+ const b = body;
247
+ throw new MatchCountError(path, Number(b?.found ?? -1), Number(b?.expected ?? -1), body);
248
+ }
145
249
  await this.assertOk(res, path);
146
250
  const result = await res.json();
147
- // Update cache after successful write
251
+ const prior = cache.get(path);
148
252
  cache.set(path, {
149
253
  sha256: result.sha256,
150
- collabState: "not-markdown",
151
- collabRevision: null,
152
- collabSnapshot: null,
254
+ collabState: prior?.collabState ?? "not-markdown",
255
+ collabRevision: prior?.collabRevision ?? null,
256
+ collabSnapshot: prior?.collabSnapshot ?? null,
153
257
  fetchedAt: Date.now(),
258
+ // We don't have the full new body here without reconstructing it; drop the
259
+ // stale cached body so the next edit re-reads rather than patching blind.
260
+ body: undefined,
154
261
  });
155
262
  return result;
156
263
  }
@@ -22,8 +22,33 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
22
22
  import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
23
23
  import * as z from "zod";
24
24
  import { parseArgs } from "node:util";
25
- import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError } from "./http-client.js";
25
+ import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError, PatchUnsupportedError, MatchCountError } 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,41 @@ 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
+ // Best path: server-side PATCH str-replace sends only {find,replace}
250
+ // (~hundreds of bytes) instead of the whole file. One small request.
251
+ // Requires a known sha (If-Match); use cached sha if present, else the
252
+ // server's 412-recover path handles it. Falls back to read+PUT if the
253
+ // server has no PATCH route (older version).
254
+ const cachedForPatch = stateCache.get(path);
255
+ if (cachedForPatch?.collabState !== "active") {
256
+ try {
257
+ const r = await client.patchFile(path, find, replace, {
258
+ ifMatch: cachedForPatch?.sha256,
259
+ });
260
+ return ok(`Edited: ${path}\nReplaced ${JSON.stringify(find)} → ${JSON.stringify(replace)}\nNew sha256: ${r.sha256}`);
261
+ }
262
+ catch (e) {
263
+ if (e instanceof MatchCountError) {
264
+ return err(`edit_file: expected to replace exactly 1 occurrence of ${JSON.stringify(find)} in "${path}", ` +
265
+ `but found ${e.found}. Re-read the file or make the search string unique.`);
266
+ }
267
+ if (e instanceof CollabActiveError) {
268
+ return err(collabActiveMessage(path, e.snapshotUrl));
269
+ }
270
+ if (e instanceof IfMatchError) {
271
+ // sha was stale — fall through to read+retry below.
272
+ }
273
+ else if (!(e instanceof PatchUnsupportedError)) {
274
+ throw e;
275
+ }
276
+ // PatchUnsupportedError or stale If-Match → fall back to read+PUT.
277
+ }
278
+ }
279
+ // Fallback path: read → transform → PUT with If-Match.
225
280
  const readResult = await client.readFile(path);
226
281
  if (readResult.text === null) {
227
282
  return err(`edit_file: "${path}" appears to be binary — cannot do text replacement.`);
228
283
  }
229
- // Check collab state from fresh read
230
284
  if (readResult.collabState === "active") {
231
285
  return err(collabActiveMessage(path, readResult.collabSnapshot));
232
286
  }
@@ -338,6 +392,7 @@ function buildJsonSchema(val) {
338
392
  }
339
393
  // ─── Entrypoints ─────────────────────────────────────────────────────────────
340
394
  async function main() {
395
+ await enableKeepAlive();
341
396
  const client = createClient();
342
397
  const server = createServer(client);
343
398
  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
  }
@@ -27,7 +27,7 @@ import {
27
27
  import * as z from "zod";
28
28
 
29
29
  import { parseArgs } from "node:util";
30
- import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError } from "./http-client.js";
30
+ import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError, PatchUnsupportedError, MatchCountError } from "./http-client.js";
31
31
  import * as stateCache from "./state-cache.js";
32
32
 
33
33
  /**
@@ -314,17 +314,47 @@ export function createServer(client: WikiViewerClient): Server {
314
314
  const block = checkCollabBlock(path);
315
315
  if (block) return err(block);
316
316
 
317
- // Read transform PUT with If-Match
317
+ // Best path: server-side PATCH str-replace sends only {find,replace}
318
+ // (~hundreds of bytes) instead of the whole file. One small request.
319
+ // Requires a known sha (If-Match); use cached sha if present, else the
320
+ // server's 412-recover path handles it. Falls back to read+PUT if the
321
+ // server has no PATCH route (older version).
322
+ const cachedForPatch = stateCache.get(path);
323
+ if (cachedForPatch?.collabState !== "active") {
324
+ try {
325
+ const r = await client.patchFile(path, find, replace, {
326
+ ifMatch: cachedForPatch?.sha256,
327
+ });
328
+ return ok(
329
+ `Edited: ${path}\nReplaced ${JSON.stringify(find)} → ${JSON.stringify(replace)}\nNew sha256: ${r.sha256}`,
330
+ );
331
+ } catch (e) {
332
+ if (e instanceof MatchCountError) {
333
+ return err(
334
+ `edit_file: expected to replace exactly 1 occurrence of ${JSON.stringify(find)} in "${path}", ` +
335
+ `but found ${e.found}. Re-read the file or make the search string unique.`,
336
+ );
337
+ }
338
+ if (e instanceof CollabActiveError) {
339
+ return err(collabActiveMessage(path, e.snapshotUrl));
340
+ }
341
+ if (e instanceof IfMatchError) {
342
+ // sha was stale — fall through to read+retry below.
343
+ } else if (!(e instanceof PatchUnsupportedError)) {
344
+ throw e;
345
+ }
346
+ // PatchUnsupportedError or stale If-Match → fall back to read+PUT.
347
+ }
348
+ }
349
+
350
+ // Fallback path: read → transform → PUT with If-Match.
318
351
  const readResult = await client.readFile(path);
319
352
  if (readResult.text === null) {
320
353
  return err(`edit_file: "${path}" appears to be binary — cannot do text replacement.`);
321
354
  }
322
-
323
- // Check collab state from fresh read
324
355
  if (readResult.collabState === "active") {
325
356
  return err(collabActiveMessage(path, readResult.collabSnapshot));
326
357
  }
327
-
328
358
  if (!readResult.text.includes(find)) {
329
359
  return err(
330
360
  `edit_file: string not found in "${path}".\n` +
@@ -15,8 +15,18 @@ export interface PathState {
15
15
  collabSnapshot: string | null;
16
16
  /** When this cache entry was last updated (ms) */
17
17
  fetchedAt: number;
18
+ /**
19
+ * Last-known text body, paired with `sha256`. Only set for text files from a
20
+ * read. Lets edit_file skip the GET round-trip: it transforms this cached
21
+ * text and PUTs with If-Match=sha256; if the file changed server-side the
22
+ * PUT 412s and the caller re-reads. Capped by MAX_CACHED_BODY_BYTES.
23
+ */
24
+ body?: string;
18
25
  }
19
26
 
27
+ /** Don't cache bodies larger than this (avoid unbounded memory). */
28
+ export const MAX_CACHED_BODY_BYTES = 256 * 1024;
29
+
20
30
  const cache = new Map<string, PathState>();
21
31
 
22
32
  export function set(path: string, state: PathState): void {
@@ -20,6 +20,9 @@ importers:
20
20
  chokidar:
21
21
  specifier: ^5.0.0
22
22
  version: 5.0.0
23
+ jszip:
24
+ specifier: ^3.10.1
25
+ version: 3.10.1
23
26
  kysely:
24
27
  specifier: 0.28.5
25
28
  version: 0.28.5
@@ -204,9 +207,6 @@ importers:
204
207
  highlight.js:
205
208
  specifier: ^11.11.1
206
209
  version: 11.11.1
207
- jszip:
208
- specifier: ^3.10.1
209
- version: 3.10.1
210
210
  lowlight:
211
211
  specifier: ^3.3.0
212
212
  version: 3.3.0