wiki-viewer 1.6.0 → 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 (128) 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/package.json +2 -2
  111. package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
  112. package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +36 -7
  113. package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +50 -2
  114. package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
  115. package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
  116. package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
  117. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +35 -5
  118. package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
  119. package/.next/standalone/pnpm-lock.yaml +3 -3
  120. package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
  121. package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
  122. package/.next/standalone/src/app/page.tsx +20 -0
  123. package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
  124. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  125. package/package.json +2 -2
  126. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_buildManifest.js +0 -0
  127. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_clientMiddlewareManifest.js +0 -0
  128. /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_ssgManifest.js +0 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wiki-viewer",
3
- "version": "1.6.0",
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,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
  }
@@ -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