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
@@ -27,8 +27,35 @@ 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
+
33
+ /**
34
+ * Enable pooled HTTP keep-alive for the global fetch the client uses.
35
+ *
36
+ * Without this, every tool call opens a fresh connection and pays a full
37
+ * TCP + TLS handshake (~3 RTT) before the request even goes out. Over a WAN
38
+ * link that turns a sub-100ms write into 300ms-1s+, and multiplies across the
39
+ * GET+PUT an edit performs. Reusing connections removes that per-call penalty.
40
+ *
41
+ * Wrapped in try/catch + dynamic import so a missing/edge undici never breaks
42
+ * startup; it just falls back to default (non-pooled) fetch.
43
+ */
44
+ async function enableKeepAlive(): Promise<void> {
45
+ try {
46
+ const { Agent, setGlobalDispatcher } = await import("undici");
47
+ setGlobalDispatcher(
48
+ new Agent({
49
+ keepAliveTimeout: 30_000,
50
+ keepAliveMaxTimeout: 60_000,
51
+ connections: 16,
52
+ pipelining: 1,
53
+ }),
54
+ );
55
+ } catch {
56
+ // undici unavailable — default fetch still works, just without pooling.
57
+ }
58
+ }
32
59
  import {
33
60
  register,
34
61
  RegisterScope,
@@ -287,17 +314,47 @@ export function createServer(client: WikiViewerClient): Server {
287
314
  const block = checkCollabBlock(path);
288
315
  if (block) return err(block);
289
316
 
290
- // 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.
291
351
  const readResult = await client.readFile(path);
292
352
  if (readResult.text === null) {
293
353
  return err(`edit_file: "${path}" appears to be binary — cannot do text replacement.`);
294
354
  }
295
-
296
- // Check collab state from fresh read
297
355
  if (readResult.collabState === "active") {
298
356
  return err(collabActiveMessage(path, readResult.collabSnapshot));
299
357
  }
300
-
301
358
  if (!readResult.text.includes(find)) {
302
359
  return err(
303
360
  `edit_file: string not found in "${path}".\n` +
@@ -433,6 +490,7 @@ function buildJsonSchema(val: AnyZodVal): Record<string, unknown> {
433
490
  // ─── Entrypoints ─────────────────────────────────────────────────────────────
434
491
 
435
492
  async function main() {
493
+ await enableKeepAlive();
436
494
  const client = createClient();
437
495
  const server = createServer(client);
438
496
  const transport = new StdioServerTransport();
@@ -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
@@ -24,6 +24,7 @@ import {
24
24
  isMarkdown,
25
25
  atomicWrite,
26
26
  ensureParentDir,
27
+ looksLikeBinary,
27
28
  } from "@/lib/proof/raw-fs";
28
29
  import { computeCollabState } from "@/lib/proof/collab-state";
29
30
 
@@ -125,16 +126,28 @@ export async function GET(
125
126
  });
126
127
  }
127
128
 
128
- // ── PUT ──────────────────────────────────────────────────────────────────────
129
+ // ── Shared mutation path (PUT + PATCH) ────────────────────────────────────────
129
130
 
130
- export async function PUT(
131
+ /**
132
+ * Single code path for all Tier-1 byte mutations so PUT and PATCH cannot drift
133
+ * on auth/scope/If-Match/R6/atomicWrite/reconcile/audit.
134
+ *
135
+ * `computeNewBody` receives the existing file buffer (or null if absent) and
136
+ * returns the new bytes, or an error response to short-circuit (e.g. PATCH
137
+ * match-count mismatch). `op` is the audit verb ("put" | "patch").
138
+ */
139
+ async function applyMutation(
131
140
  req: Request,
132
- { params }: { params: Promise<{ path: string[] }> },
141
+ segments: string[],
142
+ opts: {
143
+ op: "put" | "patch";
144
+ allowCreate: boolean;
145
+ computeNewBody: (existing: Buffer | null) => Buffer | NextResponse;
146
+ },
133
147
  ): Promise<NextResponse> {
134
148
  const auth = await checkAuth(req);
135
149
  if (!auth.ok) return errJson("UNAUTHORIZED", auth.message ?? "Unauthorized", 401);
136
150
 
137
- const { path: segments } = await params;
138
151
  const relPath = rel(segments);
139
152
  const url = new URL(req.url);
140
153
  const mkdirs = url.searchParams.get("mkdirs") === "true";
@@ -150,56 +163,52 @@ export async function PUT(
150
163
  if (!scope.ok) return errJson(scope.code, scope.message, 403);
151
164
 
152
165
  const ifMatch = req.headers.get("if-match");
153
- const bodyBuf = Buffer.from(await req.arrayBuffer());
154
- const newSha = sha256ofBuf(bodyBuf);
155
-
156
- // Check existing file
157
- let existingSha: string | undefined;
158
- let existed = false;
159
- try {
160
- const existing = await readFile(absPath);
161
- existingSha = sha256ofBuf(existing);
162
- existed = true;
163
- } catch (e) {
164
- if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
165
- }
166
+ const ifCollabMatch = req.headers.get("if-collab-match");
167
+ const rootDir = getRootDir();
166
168
 
167
- if (existed) {
168
- // Overwrite: If-Match required by default (unless ?force=true)
169
- if (!force && !ifMatch) {
170
- return errJson(
171
- "PRECONDITION_REQUIRED",
172
- "If-Match header required for overwrites (use ?force=true to bypass with audit)",
173
- 412,
174
- );
169
+ // The whole read-existing → precondition → compute → write → reconcile → audit
170
+ // sequence runs inside the mutex for .md (so R6 and reconcile are atomic with
171
+ // the write); non-.md runs it without the mutex.
172
+ const doMutation = async (): Promise<NextResponse> => {
173
+ // Read existing
174
+ let existingBuf: Buffer | null = null;
175
+ let existingSha: string | undefined;
176
+ try {
177
+ existingBuf = await readFile(absPath);
178
+ existingSha = sha256ofBuf(existingBuf);
179
+ } catch (e) {
180
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
175
181
  }
176
- if (ifMatch) {
177
- const provided = extractShaHex(ifMatch);
178
- const current = extractShaHex(existingSha!);
179
- if (provided !== current) {
182
+ const existed = existingBuf !== null;
183
+
184
+ if (existed) {
185
+ // Overwrite: If-Match required by default (unless ?force=true)
186
+ if (!force && !ifMatch) {
187
+ return errJson(
188
+ "PRECONDITION_REQUIRED",
189
+ "If-Match header required for overwrites (use ?force=true to bypass with audit)",
190
+ 412,
191
+ );
192
+ }
193
+ if (ifMatch && extractShaHex(ifMatch) !== extractShaHex(existingSha!)) {
180
194
  return errJson("PRECONDITION_FAILED", "If-Match sha256 mismatch", 412);
181
195
  }
196
+ } else {
197
+ if (!opts.allowCreate) {
198
+ return errJson("NOT_FOUND", "File not found", 404);
199
+ }
200
+ const parentOk = await ensureParentDir(absPath, mkdirs);
201
+ if (!parentOk) {
202
+ return errJson(
203
+ "PARENT_NOT_FOUND",
204
+ "Parent directory does not exist (use ?mkdirs=true to create)",
205
+ 400,
206
+ );
207
+ }
182
208
  }
183
- } else {
184
- // Create: ensure parent dir exists
185
- const parentOk = await ensureParentDir(absPath, mkdirs);
186
- if (!parentOk) {
187
- return errJson(
188
- "PARENT_NOT_FOUND",
189
- "Parent directory does not exist (use ?mkdirs=true to create)",
190
- 400,
191
- );
192
- }
193
- }
194
209
 
195
- const rootDir = getRootDir();
196
- // R6: for .md, re-check collab state atomically inside write mutex
197
- const ifCollabMatch = req.headers.get("if-collab-match");
198
-
199
- if (isMarkdown(relPath)) {
200
- // R1: acquire shared mutex; R2: reconcile eagerly inside it
201
- return await withFileMutex(relPath, async () => {
202
- // R6: atomic collab-state check (closes TOCTOU race)
210
+ // R6: for .md, re-check collab state atomically (inside mutex)
211
+ if (isMarkdown(relPath)) {
203
212
  const { state, revision, snapshotUrl } = await computeCollabState(rootDir, relPath);
204
213
  if (state === "active") {
205
214
  const matchVal = ifCollabMatch?.trim();
@@ -216,9 +225,17 @@ export async function PUT(
216
225
  );
217
226
  }
218
227
  }
228
+ }
229
+
230
+ // Compute new bytes (PUT: request body; PATCH: str-replace on existing)
231
+ const computed = opts.computeNewBody(existingBuf);
232
+ if (computed instanceof NextResponse) return computed;
233
+ const bodyBuf = computed;
234
+ const newSha = sha256ofBuf(bodyBuf);
219
235
 
220
- await atomicWrite(absPath, bodyBuf);
236
+ await atomicWrite(absPath, bodyBuf);
221
237
 
238
+ if (isMarkdown(relPath)) {
222
239
  const content = bodyBuf.toString("utf-8");
223
240
  const sidecar = (await readSidecar(rootDir, relPath)) ?? emptySidecar(relPath);
224
241
  await reconcileSidecar({
@@ -230,44 +247,119 @@ export async function PUT(
230
247
  eventType: "file.rawWritten",
231
248
  fingerprint: newSha,
232
249
  });
250
+ }
233
251
 
234
- writeAuditRow({
235
- agentId: auth.agent.id,
236
- op: "put",
237
- path: relPath,
238
- oldSha: existingSha,
239
- newSha,
240
- forced: force,
241
- });
252
+ writeAuditRow({
253
+ agentId: auth.agent.id,
254
+ op: opts.op,
255
+ path: relPath,
256
+ oldSha: existingSha,
257
+ newSha,
258
+ forced: force,
259
+ });
242
260
 
243
- const st = await stat(absPath);
244
- return NextResponse.json({
245
- path: relPath,
246
- sha256: newSha,
247
- size: st.size,
248
- mtime: st.mtime.toISOString(),
249
- created: !existed,
250
- });
261
+ const st = await stat(absPath);
262
+ return NextResponse.json({
263
+ path: relPath,
264
+ sha256: newSha,
265
+ size: st.size,
266
+ mtime: st.mtime.toISOString(),
267
+ created: !existed,
251
268
  });
252
- }
269
+ };
253
270
 
254
- // Non-.md: atomic write + audit (no mutex, no sidecar)
255
- await atomicWrite(absPath, bodyBuf);
256
- writeAuditRow({
257
- agentId: auth.agent.id,
271
+ return isMarkdown(relPath) ? withFileMutex(relPath, doMutation) : doMutation();
272
+ }
273
+
274
+ // ── PUT ──────────────────────────────────────────────────────────────────────
275
+
276
+ export async function PUT(
277
+ req: Request,
278
+ { params }: { params: Promise<{ path: string[] }> },
279
+ ): Promise<NextResponse> {
280
+ const { path: segments } = await params;
281
+ const bodyBuf = Buffer.from(await req.arrayBuffer());
282
+ return applyMutation(req, segments, {
258
283
  op: "put",
259
- path: relPath,
260
- oldSha: existingSha,
261
- newSha,
262
- forced: force,
284
+ allowCreate: true,
285
+ computeNewBody: () => bodyBuf,
263
286
  });
264
- const st = await stat(absPath);
265
- return NextResponse.json({
266
- path: relPath,
267
- sha256: newSha,
268
- size: st.size,
269
- mtime: st.mtime.toISOString(),
270
- created: !existed,
287
+ }
288
+
289
+ // ── PATCH ─────────────────────────────────────────────────────────────────────
290
+ // Server-side str-replace so agents send only the change, not the whole file.
291
+ // Strict: exact substring (no regex), text/UTF-8 only, If-Match required,
292
+ // expectedOccurrences must match exactly (default 1). Shares applyMutation, so
293
+ // lock / R6 / reconcile / audit behave identically to PUT.
294
+
295
+ const MAX_PATCH_STRING = 1_000_000; // 1MB cap on find/replace strings
296
+
297
+ export async function PATCH(
298
+ req: Request,
299
+ { params }: { params: Promise<{ path: string[] }> },
300
+ ): Promise<NextResponse> {
301
+ const { path: segments } = await params;
302
+
303
+ let body: { find?: unknown; replace?: unknown; expectedOccurrences?: unknown };
304
+ try {
305
+ body = (await req.json()) as typeof body;
306
+ } catch {
307
+ return errJson("INVALID_PAYLOAD", "Body must be JSON {find, replace, expectedOccurrences?}", 400);
308
+ }
309
+ const find = body.find;
310
+ const replace = body.replace;
311
+ if (typeof find !== "string" || typeof replace !== "string") {
312
+ return errJson("INVALID_PAYLOAD", "find and replace must be strings", 400);
313
+ }
314
+ if (find.length === 0) {
315
+ return errJson("INVALID_PAYLOAD", "find must not be empty", 400);
316
+ }
317
+ if (find.length > MAX_PATCH_STRING || replace.length > MAX_PATCH_STRING) {
318
+ return errJson("PAYLOAD_TOO_LARGE", "find/replace exceeds 1MB limit", 413);
319
+ }
320
+ let expected = 1;
321
+ if (body.expectedOccurrences !== undefined) {
322
+ if (typeof body.expectedOccurrences !== "number" || !Number.isInteger(body.expectedOccurrences) || body.expectedOccurrences < 1) {
323
+ return errJson("INVALID_PAYLOAD", "expectedOccurrences must be a positive integer", 400);
324
+ }
325
+ expected = body.expectedOccurrences;
326
+ }
327
+
328
+ return applyMutation(req, segments, {
329
+ op: "patch",
330
+ allowCreate: false, // patch only edits existing files
331
+ computeNewBody: (existing) => {
332
+ if (existing === null) return errJson("NOT_FOUND", "File not found", 404);
333
+ if (looksLikeBinary(existing)) {
334
+ return errJson("UNSUPPORTED", "Cannot patch binary / non-text file", 415);
335
+ }
336
+ let text: string;
337
+ try {
338
+ text = new TextDecoder("utf-8", { fatal: true }).decode(existing);
339
+ } catch {
340
+ return errJson("UNSUPPORTED", "File is not valid UTF-8", 415);
341
+ }
342
+ // Count exact occurrences (no regex).
343
+ let count = 0;
344
+ let idx = text.indexOf(find);
345
+ while (idx !== -1) {
346
+ count++;
347
+ idx = text.indexOf(find, idx + find.length);
348
+ }
349
+ if (count !== expected) {
350
+ return NextResponse.json(
351
+ {
352
+ error: "MATCH_COUNT_MISMATCH",
353
+ message: `Expected ${expected} occurrence(s) of find, found ${count}. Re-read the file or adjust expectedOccurrences.`,
354
+ found: count,
355
+ expected,
356
+ },
357
+ { status: 422 },
358
+ );
359
+ }
360
+ // Replace all (count == expected) occurrences, literally.
361
+ return Buffer.from(text.split(find).join(replace), "utf-8");
362
+ },
271
363
  });
272
364
  }
273
365
 
@@ -0,0 +1,99 @@
1
+ import { createReadStream } from "node:fs";
2
+ import { readdir, readFile, stat } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { Readable } from "node:stream";
5
+ import JSZip from "jszip";
6
+ import { NextResponse } from "next/server";
7
+ import { requireUser } from "@/lib/auth/server";
8
+ import { safeRootPath } from "@/lib/root-dir";
9
+
10
+ // Skip noise that should never end up in a downloaded archive.
11
+ const SKIP_DIRS = new Set([".git", "node_modules", ".next", ".proof"]);
12
+
13
+ function contentDisposition(filename: string): string {
14
+ // RFC 5987: ASCII fallback + UTF-8 encoded form for non-ASCII names.
15
+ const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/"/g, "'");
16
+ const encoded = encodeURIComponent(filename);
17
+ return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
18
+ }
19
+
20
+ async function addDirToZip(
21
+ zip: JSZip,
22
+ absDir: string,
23
+ zipPrefix: string,
24
+ ): Promise<void> {
25
+ const names = await readdir(absDir);
26
+ for (const name of names) {
27
+ if (SKIP_DIRS.has(name)) continue;
28
+ const abs = path.join(absDir, name);
29
+ const info = await stat(abs);
30
+ const zipPath = zipPrefix ? `${zipPrefix}/${name}` : name;
31
+ if (info.isDirectory()) {
32
+ await addDirToZip(zip, abs, zipPath);
33
+ } else if (info.isFile()) {
34
+ zip.file(zipPath, await readFile(abs));
35
+ }
36
+ }
37
+ }
38
+
39
+ export async function GET(request: Request) {
40
+ const auth = await requireUser(request);
41
+ if (!auth.ok)
42
+ return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 });
43
+
44
+ const { searchParams } = new URL(request.url);
45
+ const rel = searchParams.get("path") ?? "";
46
+ const target = safeRootPath(rel);
47
+ if (!target)
48
+ return NextResponse.json({ error: "Invalid path" }, { status: 400 });
49
+
50
+ let info: Awaited<ReturnType<typeof stat>>;
51
+ try {
52
+ info = await stat(target);
53
+ } catch {
54
+ return NextResponse.json({ error: "Not found" }, { status: 404 });
55
+ }
56
+
57
+ const baseName = path.basename(target) || "download";
58
+
59
+ if (info.isFile()) {
60
+ const webStream = Readable.toWeb(
61
+ createReadStream(target),
62
+ ) as ReadableStream;
63
+ return new Response(webStream, {
64
+ headers: {
65
+ "Content-Type": "application/octet-stream",
66
+ "Content-Length": String(info.size),
67
+ "Content-Disposition": contentDisposition(baseName),
68
+ "Cache-Control": "private, no-store",
69
+ },
70
+ });
71
+ }
72
+
73
+ if (info.isDirectory()) {
74
+ const zip = new JSZip();
75
+ try {
76
+ await addDirToZip(zip, target, "");
77
+ } catch {
78
+ return NextResponse.json(
79
+ { error: "Failed to read folder" },
80
+ { status: 500 },
81
+ );
82
+ }
83
+ const buffer = await zip.generateAsync({
84
+ type: "nodebuffer",
85
+ compression: "DEFLATE",
86
+ compressionOptions: { level: 6 },
87
+ });
88
+ return new Response(new Uint8Array(buffer), {
89
+ headers: {
90
+ "Content-Type": "application/zip",
91
+ "Content-Length": String(buffer.length),
92
+ "Content-Disposition": contentDisposition(`${baseName}.zip`),
93
+ "Cache-Control": "private, no-store",
94
+ },
95
+ });
96
+ }
97
+
98
+ return NextResponse.json({ error: "Unsupported path" }, { status: 400 });
99
+ }
@@ -6,6 +6,7 @@ import {
6
6
  Check,
7
7
  ChevronDown,
8
8
  ChevronRight,
9
+ Download,
9
10
  File,
10
11
  FilePlus,
11
12
  FileText,
@@ -272,6 +273,7 @@ export default function Page() {
272
273
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
273
274
  const [settingsOpen, setSettingsOpen] = useState(false);
274
275
  const [fileContent, setFileContent] = useState<string | null>(null);
276
+ const [fileRevision, setFileRevision] = useState(0);
275
277
  const [fileLoading, setFileLoading] = useState(false);
276
278
  const [editing, setEditing] = useState(false);
277
279
  const [editContent, setEditContent] = useState("");
@@ -411,6 +413,7 @@ export default function Page() {
411
413
  if (res.ok) {
412
414
  const d: { content: string } = await res.json();
413
415
  setFileContent(d.content);
416
+ setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
414
417
  }
415
418
  } catch {
416
419
  /* ignore */
@@ -536,6 +539,7 @@ export default function Page() {
536
539
  setEditing(false);
537
540
  setSaveError(null);
538
541
  setFileContent(null);
542
+ setFileRevision(0);
539
543
  const kind = viewerKindFor(node.name, node.type);
540
544
  if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
541
545
  setFileLoading(true);
@@ -546,6 +550,7 @@ export default function Page() {
546
550
  if (res.ok) {
547
551
  const d: { content: string } = await res.json();
548
552
  setFileContent(d.content);
553
+ setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
549
554
  }
550
555
  } catch {
551
556
  /* ignore */
@@ -624,9 +629,15 @@ export default function Page() {
624
629
  const res = await fetch("/api/wiki/content", {
625
630
  method: "PUT",
626
631
  headers: { "Content-Type": "application/json" },
627
- body: JSON.stringify({ path: openFile.path, content: editContent }),
632
+ body: JSON.stringify({
633
+ path: openFile.path,
634
+ content: editContent,
635
+ baseRevision: fileRevision,
636
+ }),
628
637
  });
629
638
  if (res.ok) {
639
+ const d: { revision?: number } = await res.json();
640
+ if (typeof d.revision === "number") setFileRevision(d.revision);
630
641
  setFileContent(editContent);
631
642
  setEditing(false);
632
643
  } else {
@@ -732,6 +743,16 @@ export default function Page() {
732
743
  }
733
744
  }
734
745
 
746
+ function handleDownload(node: TreeNode) {
747
+ const url = `/api/wiki/download?path=${encodeURIComponent(node.path)}`;
748
+ const a = document.createElement("a");
749
+ a.href = url;
750
+ a.download = node.type === "file" ? node.name : `${node.name}.zip`;
751
+ document.body.appendChild(a);
752
+ a.click();
753
+ a.remove();
754
+ }
755
+
735
756
  async function handleDelete() {
736
757
  if (!deletingPath) return;
737
758
  await fetch("/api/wiki", {
@@ -946,6 +967,15 @@ export default function Page() {
946
967
  </Button>
947
968
  </>
948
969
  )}
970
+ <Button
971
+ size="sm"
972
+ variant="ghost"
973
+ className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
974
+ title={node.type === "file" ? "Download" : "Download as zip"}
975
+ onClick={() => handleDownload(node)}
976
+ >
977
+ <Download className="h-3 w-3" />
978
+ </Button>
949
979
  <Button
950
980
  size="sm"
951
981
  variant="ghost"