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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/routes-manifest.json +6 -0
- package/.next/standalone/.next/server/app/_global-error/page.js +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/[agentId]/revoke/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/agents/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/admin/registrations/[regId]/approve/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js +1 -1
- package/.next/standalone/.next/server/app/api/agent/internal/span/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js +2 -2
- package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js +1 -1
- package/.next/standalone/.next/server/app/api/wiki/content/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/download/route/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/build-manifest.json +9 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js +15 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.map +5 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/api/wiki/download/route_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js +5 -5
- package/.next/standalone/.next/server/app/api/wiki/watch/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js +1 -1
- package/.next/standalone/.next/server/app/signin/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
- package/.next/standalone/.next/server/chunks/0p91_next_dist_esm_build_templates_app-route_0ytral2.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0p12-6~._.js → [root-of-the-server]__094q5bj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0w47wil._.js → [root-of-the-server]__0km8suj._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__12qerfy._.js → [root-of-the-server]__0mzo03s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0e7si9p._.js → [root-of-the-server]__0rzo40z._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t5mg.n._.js +3 -0
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0zi3nkn._.js → [root-of-the-server]__106uw_c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0qhdf-p._.js → [root-of-the-server]__12800b1._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_next-internal_server_app_api_wiki_download_route_actions_0rd6p-3.js +3 -0
- package/.next/standalone/.next/server/chunks/node_modules__pnpm_0aj5g-w._.js +3 -0
- 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
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0jyyo2t._.js → [externals]__05-uq57._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[externals]__0m6xq-c._.js → [externals]__0btd01s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__06mzg2t._.js → [root-of-the-server]__00lk0r~._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__12ahxbl._.js → [root-of-the-server]__0ytvlzs._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +7 -7
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0v_nwsz._.js → node_modules__pnpm_0-x6zex._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0tkiej3._.js → node_modules__pnpm_012nt.s._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~2~c30._.js → node_modules__pnpm_07rxv2x._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0gbgliv._.js → node_modules__pnpm_0a4su30._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_10413dq._.js → node_modules__pnpm_0ajzar9._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0zzzsrj._.js → node_modules__pnpm_0bum3-3._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wffzo0._.js → node_modules__pnpm_0c71i8a._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_099cv.b._.js → node_modules__pnpm_0cbsnwf._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f_uzgy._.js → node_modules__pnpm_0cxwhfd._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03ij5.g._.js → node_modules__pnpm_0dboa_e._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0iojvye._.js → node_modules__pnpm_0de8xt-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02gx8km._.js → node_modules__pnpm_0ezm2us._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0m5-azh._.js → node_modules__pnpm_0hobcbs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0~.6dgb._.js → node_modules__pnpm_0hrbz84._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0rebktf._.js → node_modules__pnpm_0i.pw_m._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_03v21p-._.js → node_modules__pnpm_0il8j.j._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0cwpt9p._.js → node_modules__pnpm_0j3mvgg._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0fixtms._.js → node_modules__pnpm_0j53hti._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0li2vzs._.js → node_modules__pnpm_0kwc-vs._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_05qsply._.js → node_modules__pnpm_0lm8e2c._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_08ibh0w._.js → node_modules__pnpm_0mdo4i3._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_06pgebo._.js → node_modules__pnpm_0o.c1z8._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0gofu~q._.js → node_modules__pnpm_0rw91-f._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0n1kfcp._.js → node_modules__pnpm_0xrmzh1._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_02mew~s._.js → node_modules__pnpm_0yq5z.h._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xxwmv2._.js → node_modules__pnpm_0z~h2ow._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0f9fpy5._.js → node_modules__pnpm_0~ln0by._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wouz19._.js → node_modules__pnpm_11e~j49._.js} +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/static/chunks/{0-qve5g~myvo-.js → 0vwkqw_rofm-c.js} +7 -7
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/packages/wiki-viewer-mcp/bench-payload.mjs +45 -0
- package/.next/standalone/packages/wiki-viewer-mcp/dist/http-client.js +36 -7
- package/.next/standalone/packages/wiki-viewer-mcp/dist/index.js +50 -2
- package/.next/standalone/packages/wiki-viewer-mcp/dist/state-cache.js +2 -0
- package/.next/standalone/packages/wiki-viewer-mcp/package.json +1 -1
- package/.next/standalone/packages/wiki-viewer-mcp/src/http-client.ts +121 -12
- package/.next/standalone/packages/wiki-viewer-mcp/src/index.ts +35 -5
- package/.next/standalone/packages/wiki-viewer-mcp/src/state-cache.ts +10 -0
- package/.next/standalone/pnpm-lock.yaml +3 -3
- package/.next/standalone/src/app/api/agent/fs/file/[...path]/route.ts +173 -81
- package/.next/standalone/src/app/api/wiki/download/route.ts +99 -0
- package/.next/standalone/src/app/page.tsx +20 -0
- package/.next/standalone/src/tests/proof/agent-fs.test.ts +149 -0
- package/.next/standalone/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{HeeYGbBsAF0r0T0mY60Mo → uQH5T9TFHOhO0NJbO4QGI}/_clientMiddlewareManifest.js +0 -0
- /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.
|
|
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
|
|
139
|
+
const encoded = typeof content === "string"
|
|
138
140
|
? new TextEncoder().encode(content)
|
|
139
141
|
: content;
|
|
140
|
-
const
|
|
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", ...
|
|
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
|
-
//
|
|
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);
|
|
@@ -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
|
|
225
|
-
?
|
|
226
|
-
:
|
|
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
|
|
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", ...
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|