wiki-viewer 1.5.1 → 1.6.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 (48) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  4. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  18. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/index.html +1 -1
  23. package/.next/standalone/.next/server/app/index.rsc +2 -2
  24. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  25. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
  26. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  27. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  28. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  29. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  30. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +1 -1
  31. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  32. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  33. package/.next/standalone/.next/server/pages/404.html +1 -1
  34. package/.next/standalone/.next/server/pages/500.html +1 -1
  35. package/.next/standalone/.next/static/chunks/{17~r3.8awh3qg.js → 0-qve5g~myvo-.js} +1 -1
  36. package/.next/standalone/agents/bootstrap-prompt.md +1 -1
  37. package/.next/standalone/agents/wiki-viewer-skill/SKILL.md +1 -1
  38. package/.next/standalone/package.json +1 -1
  39. package/.next/standalone/packages/wiki-viewer-mcp/bench-e2e.mjs +166 -0
  40. package/.next/standalone/packages/wiki-viewer-mcp/bench-latency.mjs +87 -0
  41. package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +28 -0
  42. package/.next/standalone/src/app/page.tsx +11 -1
  43. package/.next/standalone/src/tests/proof/bench-write.ts +179 -0
  44. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  45. package/package.json +1 -1
  46. /package/.next/standalone/.next/static/{3G_3qm2a9nOX7oi5k0mPV → HeeYGbBsAF0r0T0mY60Mo}/_buildManifest.js +0 -0
  47. /package/.next/standalone/.next/static/{3G_3qm2a9nOX7oi5k0mPV → HeeYGbBsAF0r0T0mY60Mo}/_clientMiddlewareManifest.js +0 -0
  48. /package/.next/standalone/.next/static/{3G_3qm2a9nOX7oi5k0mPV → HeeYGbBsAF0r0T0mY60Mo}/_ssgManifest.js +0 -0
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Latency-model benchmark: proves how per-call TCP/TLS handshakes (no keep-alive)
3
+ * vs pooled keep-alive connections behave under WAN-like RTT.
4
+ *
5
+ * It does NOT need the full Next server — it runs a tiny local HTTP server that
6
+ * sleeps RTT/2 before responding (modeling one-way latency), then compares:
7
+ * A) fetch with a fresh connection each call (no keep-alive)
8
+ * B) fetch over a pooled keep-alive agent (undici)
9
+ *
10
+ * For each, it models a "write" (1 request) and an "edit" (2 requests: GET+PUT).
11
+ *
12
+ * Run: node packages/wiki-viewer-mcp/bench-latency.mjs [rttMs]
13
+ */
14
+ import http from "node:http";
15
+ import { Agent, fetch as undiciFetch } from "undici";
16
+
17
+ const RTT = Number(process.argv[2] ?? process.env.RTT_MS ?? 80); // round-trip ms
18
+ const ITERS = Number(process.env.ITERS ?? 30);
19
+ const ONE_WAY = RTT / 2;
20
+
21
+ function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }
22
+
23
+ // Local server that injects one-way latency on both receive and send.
24
+ const server = http.createServer(async (req, res) => {
25
+ // drain body
26
+ for await (const _ of req) { /* consume */ }
27
+ await sleep(ONE_WAY); // network: client -> server
28
+ res.writeHead(200, { "Content-Type": "application/json", ETag: '"sha256:abc"', "X-File-Size": "1024" });
29
+ res.end(JSON.stringify({ sha256: "sha256:abc", size: 1024 }));
30
+ });
31
+
32
+ function pct(s, p) { return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; }
33
+ function summarize(label, samples) {
34
+ const s = [...samples].sort((a, b) => a - b);
35
+ const mean = s.reduce((a, b) => a + b, 0) / s.length;
36
+ return `${label.padEnd(40)} p50=${pct(s, 50).toFixed(0)}ms p95=${pct(s, 95).toFixed(0)}ms mean=${mean.toFixed(0)}ms`;
37
+ }
38
+
39
+ async function bench(label, doCall) {
40
+ const samples = [];
41
+ for (let i = 0; i < ITERS + 5; i++) {
42
+ const t = performance.now();
43
+ await doCall();
44
+ if (i >= 5) samples.push(performance.now() - t);
45
+ }
46
+ return summarize(label, samples);
47
+ }
48
+
49
+ async function main() {
50
+ await new Promise((r) => server.listen(0, "127.0.0.1", r));
51
+ const port = server.address().port;
52
+ const base = `http://127.0.0.1:${port}/x`;
53
+
54
+ // A) No keep-alive: force a fresh connection per request.
55
+ const noKa = new Agent({ pipelining: 0, connections: 1, keepAliveTimeout: 1, keepAliveMaxTimeout: 1 });
56
+ // B) Pooled keep-alive.
57
+ const ka = new Agent({ keepAliveTimeout: 30_000, keepAliveMaxTimeout: 60_000, connections: 10 });
58
+
59
+ const req = (dispatcher, method) =>
60
+ undiciFetch(base, { method, dispatcher, headers: { "content-type": "application/octet-stream" }, body: method === "PUT" ? "x".repeat(1024) : undefined })
61
+ .then((r) => r.arrayBuffer());
62
+
63
+ // Model a COLD connection: TCP(1 RTT) + TLS(≈2 RTT) handshake before the
64
+ // request even goes out. Real HTTPS over WAN pays this on every call when
65
+ // the client doesn't reuse connections. We add it explicitly because
66
+ // loopback can't reproduce handshake cost.
67
+ const HANDSHAKE_RTTS = Number(process.env.HANDSHAKE_RTTS ?? 3); // TCP+TLS ≈ 3 RTT
68
+ const freshReq = async (method) => {
69
+ await sleep(HANDSHAKE_RTTS * RTT); // cold-connection handshake penalty
70
+ const d = new Agent({ keepAliveTimeout: 1, connections: 1 });
71
+ await req(d, method);
72
+ await d.close();
73
+ };
74
+
75
+ console.log(`\nlatency model — RTT=${RTT}ms, iters=${ITERS}\n`);
76
+ console.log(await bench("write · fresh conn per call (no keep-alive)", () => freshReq("PUT")));
77
+ console.log(await bench("write · pooled keep-alive", () => req(ka, "PUT")));
78
+ console.log(await bench("edit · fresh conn per call (GET+PUT)", async () => { await freshReq("GET"); await freshReq("PUT"); }));
79
+ console.log(await bench("edit · pooled keep-alive (GET+PUT)", async () => { await req(ka, "GET"); await req(ka, "PUT"); }));
80
+ console.log("");
81
+ console.log("Note: 'fresh conn' pays TCP handshake (+~1 RTT) per call; real HTTPS adds TLS (+1-2 RTT).");
82
+ console.log("");
83
+
84
+ await noKa.close(); await ka.close();
85
+ server.close();
86
+ }
87
+ main().catch((e) => { console.error(e); process.exit(1); });
@@ -29,6 +29,33 @@ import * as z from "zod";
29
29
  import { parseArgs } from "node:util";
30
30
  import { WikiViewerClient, IfMatchError, CollabActiveError, WikiViewerError } 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,
@@ -433,6 +460,7 @@ function buildJsonSchema(val: AnyZodVal): Record<string, unknown> {
433
460
  // ─── Entrypoints ─────────────────────────────────────────────────────────────
434
461
 
435
462
  async function main() {
463
+ await enableKeepAlive();
436
464
  const client = createClient();
437
465
  const server = createServer(client);
438
466
  const transport = new StdioServerTransport();
@@ -272,6 +272,7 @@ export default function Page() {
272
272
  const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
273
273
  const [settingsOpen, setSettingsOpen] = useState(false);
274
274
  const [fileContent, setFileContent] = useState<string | null>(null);
275
+ const [fileRevision, setFileRevision] = useState(0);
275
276
  const [fileLoading, setFileLoading] = useState(false);
276
277
  const [editing, setEditing] = useState(false);
277
278
  const [editContent, setEditContent] = useState("");
@@ -411,6 +412,7 @@ export default function Page() {
411
412
  if (res.ok) {
412
413
  const d: { content: string } = await res.json();
413
414
  setFileContent(d.content);
415
+ setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
414
416
  }
415
417
  } catch {
416
418
  /* ignore */
@@ -536,6 +538,7 @@ export default function Page() {
536
538
  setEditing(false);
537
539
  setSaveError(null);
538
540
  setFileContent(null);
541
+ setFileRevision(0);
539
542
  const kind = viewerKindFor(node.name, node.type);
540
543
  if (!["editor", "text"].includes(kind) && !isText(node.name)) return;
541
544
  setFileLoading(true);
@@ -546,6 +549,7 @@ export default function Page() {
546
549
  if (res.ok) {
547
550
  const d: { content: string } = await res.json();
548
551
  setFileContent(d.content);
552
+ setFileRevision(Number(res.headers.get("X-Wiki-Revision") ?? 0));
549
553
  }
550
554
  } catch {
551
555
  /* ignore */
@@ -624,9 +628,15 @@ export default function Page() {
624
628
  const res = await fetch("/api/wiki/content", {
625
629
  method: "PUT",
626
630
  headers: { "Content-Type": "application/json" },
627
- body: JSON.stringify({ path: openFile.path, content: editContent }),
631
+ body: JSON.stringify({
632
+ path: openFile.path,
633
+ content: editContent,
634
+ baseRevision: fileRevision,
635
+ }),
628
636
  });
629
637
  if (res.ok) {
638
+ const d: { revision?: number } = await res.json();
639
+ if (typeof d.revision === "number") setFileRevision(d.revision);
630
640
  setFileContent(editContent);
631
641
  setEditing(false);
632
642
  } else {
@@ -0,0 +1,179 @@
1
+ /**
2
+ * Isolated write-path benchmark — NOT a test, NOT touching production state.
3
+ *
4
+ * Drives the real Tier-1 route handlers (filePUT / fileGET) against a throwaway
5
+ * temp rootDir + temp HOME (lock dir), exactly like agent-fs.test.ts, and
6
+ * measures end-to-end server-side latency for the write path:
7
+ * - non-.md overwrite (raw, no lock/sidecar)
8
+ * - .md overwrite (cross-proc lock + reconcile + sidecar write + datasync)
9
+ * - edit_file equivalent (GET + PUT round-trip on .md)
10
+ *
11
+ * Run: npx tsx src/tests/proof/bench-write.ts
12
+ * Optional env: BENCH_ITERS (default 300), BENCH_WARMUP (default 30)
13
+ */
14
+ import { mkdtemp, writeFile, rm } from "node:fs/promises";
15
+ import { tmpdir } from "node:os";
16
+ import path from "node:path";
17
+ import { createHash, randomBytes } from "node:crypto";
18
+
19
+ import { setRootDir } from "../../lib/root-dir.js";
20
+ import { ensureRegistry, addAgent, hashToken } from "../../lib/proof/registry.js";
21
+
22
+ const ITERS = Number(process.env.BENCH_ITERS) || 300;
23
+ const WARMUP = Number(process.env.BENCH_WARMUP) || 30;
24
+
25
+ function sha256(buf: Buffer): string {
26
+ return "sha256:" + createHash("sha256").update(buf).digest("hex");
27
+ }
28
+ function hdrs(token: string, id: string): Record<string, string> {
29
+ return { Authorization: `Bearer ${token}`, "X-Agent-Id": id };
30
+ }
31
+ function fileUrl(rel: string, qs = ""): string {
32
+ return `http://localhost/api/agent/fs/file/${rel}${qs}`;
33
+ }
34
+
35
+ function pct(sorted: number[], p: number): number {
36
+ const i = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
37
+ return sorted[i];
38
+ }
39
+ function stats(label: string, samples: number[]) {
40
+ const s = [...samples].sort((a, b) => a - b);
41
+ const sum = s.reduce((a, b) => a + b, 0);
42
+ const mean = sum / s.length;
43
+ return {
44
+ label,
45
+ n: s.length,
46
+ mean: +mean.toFixed(3),
47
+ p50: +pct(s, 50).toFixed(3),
48
+ p95: +pct(s, 95).toFixed(3),
49
+ p99: +pct(s, 99).toFixed(3),
50
+ min: +s[0].toFixed(3),
51
+ max: +s[s.length - 1].toFixed(3),
52
+ };
53
+ }
54
+
55
+ async function timeIt(fn: () => Promise<void>): Promise<number> {
56
+ const t0 = performance.now();
57
+ await fn();
58
+ return performance.now() - t0;
59
+ }
60
+
61
+ async function main() {
62
+ const tmpHome = await mkdtemp(path.join(tmpdir(), "bench-home-"));
63
+ const tmpRoot = await mkdtemp(path.join(tmpdir(), "bench-root-"));
64
+ process.env.HOME = tmpHome;
65
+ setRootDir(tmpRoot);
66
+ await ensureRegistry();
67
+
68
+ const TOKEN = randomBytes(32).toString("hex");
69
+ await addAgent({
70
+ id: "ai:bench",
71
+ displayName: "Bench",
72
+ tokenHash: hashToken(TOKEN),
73
+ scope: { paths: ["**/*"], ops: ["read", "mutate", "delete"] },
74
+ createdAt: new Date().toISOString(),
75
+ lastSeen: new Date().toISOString(),
76
+ });
77
+
78
+ const fileRoute = await import("../../app/api/agent/fs/file/[...path]/route.js");
79
+ const filePUT = fileRoute.PUT;
80
+ const fileGET = fileRoute.GET;
81
+ const H = hdrs(TOKEN, "ai:bench");
82
+
83
+ const ctx = (rel: string) => ({ params: Promise.resolve({ path: rel.split("/") }) });
84
+
85
+ // Seed bodies (~1KB, realistic small doc)
86
+ const body = Buffer.from("# Doc\n\n" + "lorem ipsum dolor sit amet. ".repeat(40));
87
+ const txtBody = Buffer.from("config_value = " + "x".repeat(900));
88
+
89
+ async function putOnce(rel: string, data: Buffer, ifMatch?: string, ifCollab?: string): Promise<Response> {
90
+ const headers: Record<string, string> = { ...H, "Content-Type": "application/octet-stream" };
91
+ if (ifMatch) headers["If-Match"] = ifMatch;
92
+ if (ifCollab) headers["If-Collab-Match"] = ifCollab;
93
+ return filePUT(new Request(fileUrl(rel), { method: "PUT", headers, body: new Uint8Array(data) }), ctx(rel));
94
+ }
95
+ async function getSha(rel: string): Promise<{ sha: string; collabRev: string | null }> {
96
+ const res = await fileGET(new Request(fileUrl(rel), { headers: H }), ctx(rel));
97
+ const etag = (res.headers.get("ETag") ?? "").replace(/"/g, "");
98
+ return { sha: etag, collabRev: res.headers.get("X-Collab-Revision") };
99
+ }
100
+
101
+ // ── Scenario A: non-.md overwrite (lean path) ──────────────────────────────
102
+ await putOnce("bench.txt", txtBody, undefined); // create
103
+ const txtSamples: number[] = [];
104
+ {
105
+ let sha = (await getSha("bench.txt")).sha;
106
+ for (let i = 0; i < WARMUP + ITERS; i++) {
107
+ const data = Buffer.from(txtBody.toString() + i);
108
+ const t = await timeIt(async () => {
109
+ const res = await putOnce("bench.txt", data, sha);
110
+ if (res.status !== 200) throw new Error("txt PUT " + res.status);
111
+ sha = (await res.json() as { sha256: string }).sha256;
112
+ });
113
+ if (i >= WARMUP) txtSamples.push(t);
114
+ }
115
+ }
116
+
117
+ // ── Scenario B: .md overwrite (lock + reconcile + sidecar + datasync) ──────
118
+ await putOnce("bench.md", body, undefined); // create
119
+ const mdSamples: number[] = [];
120
+ {
121
+ let sha = (await getSha("bench.md")).sha;
122
+ for (let i = 0; i < WARMUP + ITERS; i++) {
123
+ const data = Buffer.from(body.toString() + "\n\nedit " + i);
124
+ const t = await timeIt(async () => {
125
+ const res = await putOnce("bench.md", data, sha);
126
+ if (res.status !== 200) throw new Error("md PUT " + res.status + " " + await res.text());
127
+ sha = (await res.json() as { sha256: string }).sha256;
128
+ });
129
+ if (i >= WARMUP) mdSamples.push(t);
130
+ }
131
+ }
132
+
133
+ // ── Scenario C: edit_file equivalent — GET + PUT round-trip on .md ─────────
134
+ const editSamples: number[] = [];
135
+ {
136
+ for (let i = 0; i < WARMUP + ITERS; i++) {
137
+ const data = Buffer.from(body.toString() + "\n\nrt " + i);
138
+ const t = await timeIt(async () => {
139
+ const { sha } = await getSha("bench.md");
140
+ const res = await putOnce("bench.md", data, sha);
141
+ if (res.status !== 200) throw new Error("edit PUT " + res.status);
142
+ await res.json();
143
+ });
144
+ if (i >= WARMUP) editSamples.push(t);
145
+ }
146
+ }
147
+
148
+ const results = [
149
+ stats("non-.md overwrite (lean)", txtSamples),
150
+ stats(".md overwrite (lock+reconcile+sidecar+datasync)", mdSamples),
151
+ stats("edit_file (GET+PUT .md round-trip)", editSamples),
152
+ ];
153
+
154
+ console.log(`\nwrite-path benchmark (iters=${ITERS}, warmup=${WARMUP}, ~1KB bodies)\n`);
155
+ console.log(
156
+ ["scenario".padEnd(48), "p50", "p95", "p99", "mean", "max"].join(" "),
157
+ );
158
+ for (const r of results) {
159
+ console.log(
160
+ [
161
+ r.label.padEnd(48),
162
+ `${r.p50}ms`.padStart(7),
163
+ `${r.p95}ms`.padStart(7),
164
+ `${r.p99}ms`.padStart(7),
165
+ `${r.mean}ms`.padStart(7),
166
+ `${r.max}ms`.padStart(7),
167
+ ].join(" "),
168
+ );
169
+ }
170
+ console.log("");
171
+
172
+ await rm(tmpHome, { recursive: true, force: true });
173
+ await rm(tmpRoot, { recursive: true, force: true });
174
+ }
175
+
176
+ main().catch((e) => {
177
+ console.error(e);
178
+ process.exit(1);
179
+ });