duet-mcp 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +367 -0
- package/doc/README.jp.md +347 -0
- package/lib/blob.d.ts +16 -0
- package/lib/blob.js +57 -0
- package/lib/boot.d.ts +2 -0
- package/lib/boot.js +134 -0
- package/lib/client-store.d.ts +28 -0
- package/lib/client-store.js +147 -0
- package/lib/client.d.ts +25 -0
- package/lib/client.js +58 -0
- package/lib/diff.d.ts +27 -0
- package/lib/diff.js +103 -0
- package/lib/doc.d.ts +30 -0
- package/lib/doc.js +221 -0
- package/lib/edit.d.ts +25 -0
- package/lib/edit.js +63 -0
- package/lib/http.d.ts +9 -0
- package/lib/http.js +151 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +2 -0
- package/lib/mcp.d.ts +5 -0
- package/lib/mcp.js +109 -0
- package/lib/op.d.ts +10 -0
- package/lib/op.js +19 -0
- package/lib/paths.d.ts +4 -0
- package/lib/paths.js +9 -0
- package/lib/protocol.d.ts +36 -0
- package/lib/protocol.js +10 -0
- package/lib/server.d.ts +1 -0
- package/lib/server.js +1 -0
- package/lib/shot.d.ts +8 -0
- package/lib/shot.js +85 -0
- package/lib/transport.d.ts +7 -0
- package/lib/transport.js +22 -0
- package/lib/types.d.ts +66 -0
- package/lib/types.js +1 -0
- package/lib/wire.d.ts +14 -0
- package/lib/wire.js +42 -0
- package/package.json +97 -0
- package/template/app.ts +17 -0
- package/template/doc.ts +18 -0
- package/template/main.ts +8 -0
- package/template/ops.ts +42 -0
- package/template/start.ts +4 -0
- package/template/ui/canvas.tsx +91 -0
- package/template/ui/card-editing.tsx +48 -0
- package/template/ui/edit-actions.tsx +30 -0
- package/template/ui/index.html +15 -0
- package/template/ui/main.tsx +53 -0
- package/template/ui/style.css +15 -0
- package/template/ui/tsconfig.json +15 -0
- package/template/ui/vite.config.ts +24 -0
package/lib/edit.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** 下書きと観測時の呼び口を一緒に保持する。行の外に置けば移動しても残る。 */
|
|
2
|
+
export class EditSession {
|
|
3
|
+
base = null;
|
|
4
|
+
inflight = null;
|
|
5
|
+
listeners = new Set();
|
|
6
|
+
state = { active: false, value: undefined, pending: false, result: null, error: null };
|
|
7
|
+
getSnapshot = () => this.state;
|
|
8
|
+
subscribe = (listener) => {
|
|
9
|
+
this.listeners.add(listener);
|
|
10
|
+
return () => { this.listeners.delete(listener); };
|
|
11
|
+
};
|
|
12
|
+
update(state) {
|
|
13
|
+
this.state = state;
|
|
14
|
+
for (const listener of this.listeners)
|
|
15
|
+
listener();
|
|
16
|
+
}
|
|
17
|
+
editable() { if (this.inflight)
|
|
18
|
+
throw new Error("送信中は編集を変更できない。"); }
|
|
19
|
+
begin = (base, value) => {
|
|
20
|
+
if (this.state.active)
|
|
21
|
+
throw new Error("既に編集中。見直す場合は restart を使うこと。");
|
|
22
|
+
this.restart(base, value);
|
|
23
|
+
};
|
|
24
|
+
restart = (base, value) => {
|
|
25
|
+
this.editable();
|
|
26
|
+
this.base = base;
|
|
27
|
+
this.update({ active: true, value, pending: false, result: null, error: null });
|
|
28
|
+
};
|
|
29
|
+
setValue = (value) => {
|
|
30
|
+
this.editable();
|
|
31
|
+
if (!this.base)
|
|
32
|
+
throw new Error("編集を begin していない。");
|
|
33
|
+
this.update({ ...this.state, value });
|
|
34
|
+
};
|
|
35
|
+
cancel = () => {
|
|
36
|
+
this.editable();
|
|
37
|
+
this.base = null;
|
|
38
|
+
this.update({ active: false, value: undefined, pending: false, result: null, error: null });
|
|
39
|
+
};
|
|
40
|
+
run = (name, args) => {
|
|
41
|
+
if (this.inflight)
|
|
42
|
+
return this.inflight;
|
|
43
|
+
const base = this.base;
|
|
44
|
+
if (!base)
|
|
45
|
+
throw new Error("編集を begin していない。");
|
|
46
|
+
// microtask で開始し、同期の二重呼び出しでも同じ Promise を返す。
|
|
47
|
+
const promise = Promise.resolve().then(() => base.run(name, args)).then((result) => {
|
|
48
|
+
if ("ok" in result) {
|
|
49
|
+
this.base = null;
|
|
50
|
+
this.update({ active: false, value: undefined, pending: false, result, error: null });
|
|
51
|
+
}
|
|
52
|
+
else
|
|
53
|
+
this.update({ ...this.state, pending: false, result, error: null });
|
|
54
|
+
return result;
|
|
55
|
+
}, (err) => {
|
|
56
|
+
this.update({ ...this.state, pending: false, error: err instanceof Error ? err.message : String(err) });
|
|
57
|
+
throw err;
|
|
58
|
+
}).finally(() => { this.inflight = null; });
|
|
59
|
+
this.inflight = promise;
|
|
60
|
+
this.update({ ...this.state, pending: true, error: null });
|
|
61
|
+
return promise;
|
|
62
|
+
};
|
|
63
|
+
}
|
package/lib/http.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import type { DocStore } from "./doc.js";
|
|
3
|
+
import type { AppDef } from "./types.js";
|
|
4
|
+
/**
|
|
5
|
+
* DocStore に触れる唯一の実装。
|
|
6
|
+
* ブラウザも、daemon 自身の MCP 層も、別プロセスの MCP 層も、全部ここを通る。
|
|
7
|
+
* 経路が 1 本なので「daemon かどうかで挙動が変わらない」を維持する必要が無い。
|
|
8
|
+
*/
|
|
9
|
+
export declare function createHttpApp<Doc>(app: AppDef<Doc>, getStore: () => DocStore<Doc>): Hono;
|
package/lib/http.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { BlobStore } from "./blob.js";
|
|
5
|
+
import { rootFor, dataDirFor } from "./paths.js";
|
|
6
|
+
import { takeShot } from "./shot.js";
|
|
7
|
+
import { baseUrlFor, MAX_WAIT_MS, MIN_WAIT_MS, portFor, WAIT_MS } from "./wire.js";
|
|
8
|
+
const MIME = {
|
|
9
|
+
".html": "text/html; charset=utf-8",
|
|
10
|
+
".js": "text/javascript; charset=utf-8",
|
|
11
|
+
".css": "text/css; charset=utf-8",
|
|
12
|
+
".json": "application/json; charset=utf-8",
|
|
13
|
+
".map": "application/json; charset=utf-8",
|
|
14
|
+
".svg": "image/svg+xml",
|
|
15
|
+
".png": "image/png",
|
|
16
|
+
".jpg": "image/jpeg",
|
|
17
|
+
".jpeg": "image/jpeg",
|
|
18
|
+
".webp": "image/webp",
|
|
19
|
+
".avif": "image/avif",
|
|
20
|
+
".gif": "image/gif",
|
|
21
|
+
".ico": "image/x-icon",
|
|
22
|
+
".woff2": "font/woff2",
|
|
23
|
+
".woff": "font/woff",
|
|
24
|
+
".ttf": "font/ttf",
|
|
25
|
+
".wasm": "application/wasm",
|
|
26
|
+
};
|
|
27
|
+
function serveFile(root, filePath) {
|
|
28
|
+
const rel = path.relative(root, filePath);
|
|
29
|
+
if (rel.startsWith("..") || path.isAbsolute(rel))
|
|
30
|
+
return null;
|
|
31
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile())
|
|
32
|
+
return null;
|
|
33
|
+
const type = MIME[path.extname(filePath)] ?? "application/octet-stream";
|
|
34
|
+
return new Response(new Uint8Array(fs.readFileSync(filePath)), {
|
|
35
|
+
headers: { "content-type": type },
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
const num = (v) => {
|
|
39
|
+
if (v === undefined || v === "")
|
|
40
|
+
return undefined;
|
|
41
|
+
const n = Number(v);
|
|
42
|
+
return Number.isFinite(n) ? n : undefined;
|
|
43
|
+
};
|
|
44
|
+
const list = (v) => v === undefined || v === "" ? undefined : v.split(",").filter(Boolean);
|
|
45
|
+
// 下限を切らないと、timeoutMs: 0 が「即 timedOut で返り続ける」空回りになる。
|
|
46
|
+
const clampTimeout = (v) => Math.min(Math.max(v ?? WAIT_MS, MIN_WAIT_MS), MAX_WAIT_MS);
|
|
47
|
+
/**
|
|
48
|
+
* 呼び出し元の名前。ヘッダが無ければブラウザなので "human"。
|
|
49
|
+
* MCP プロセスは env の DUET_ACTOR を送ってくる。
|
|
50
|
+
*/
|
|
51
|
+
const actorOf = (c) => c.req.header("x-duet-actor") || "human";
|
|
52
|
+
/**
|
|
53
|
+
* DocStore に触れる唯一の実装。
|
|
54
|
+
* ブラウザも、daemon 自身の MCP 層も、別プロセスの MCP 層も、全部ここを通る。
|
|
55
|
+
* 経路が 1 本なので「daemon かどうかで挙動が変わらない」を維持する必要が無い。
|
|
56
|
+
*/
|
|
57
|
+
export function createHttpApp(app, getStore) {
|
|
58
|
+
const http = new Hono();
|
|
59
|
+
// verify と実リクエストの間に別アプリへ入れ替わっても操作を渡さない。
|
|
60
|
+
http.use("/api/*", async (c, next) => {
|
|
61
|
+
const expected = c.req.header("x-duet-app-id");
|
|
62
|
+
if (expected !== undefined && expected !== app.id)
|
|
63
|
+
return c.json({ error: "接続先は別の duet アプリ。" }, 409);
|
|
64
|
+
await next();
|
|
65
|
+
});
|
|
66
|
+
const blobs = new BlobStore(app.id, dataDirFor(app));
|
|
67
|
+
/** DocStore は最初のリクエストまで作られない(生成の遅延は boot.ts 側にある)。 */
|
|
68
|
+
const store = getStore;
|
|
69
|
+
// ---- 正体確認と居場所 ----
|
|
70
|
+
// ポートは app.id から導出されるので、人にも LLM にも見えない。
|
|
71
|
+
// 「どこで開いているか」を答えられる口を基盤が既定で持つ。
|
|
72
|
+
http.get("/api/hello", (c) => c.json({
|
|
73
|
+
id: app.id,
|
|
74
|
+
version: app.version,
|
|
75
|
+
port: portFor(app.id),
|
|
76
|
+
url: baseUrlFor(app.id),
|
|
77
|
+
}));
|
|
78
|
+
// ---- ドキュメント取得 / 待機 ----
|
|
79
|
+
// since を付けると変化があるまで返さない(ロングポーリング)。
|
|
80
|
+
// ブラウザの購読も MCP の await_change もこれ 1 本。
|
|
81
|
+
http.get("/api/doc", async (c) => {
|
|
82
|
+
const since = c.req.query("since");
|
|
83
|
+
return c.json(await store().wait(since, list(c.req.query("until")), clampTimeout(num(c.req.query("timeout"))), actorOf(c), c.req.raw.signal));
|
|
84
|
+
});
|
|
85
|
+
// snapshot と操作結果を DocStore が同時に確定する。
|
|
86
|
+
http.post("/api/op/:name", async (c) => {
|
|
87
|
+
const name = c.req.param("name");
|
|
88
|
+
if (!app.ops.some((o) => o.name === name))
|
|
89
|
+
return c.json({ error: `unknown op: ${name}` }, 404);
|
|
90
|
+
const args = await c.req.json().catch(() => ({}));
|
|
91
|
+
return c.json(store().run(name, args, actorOf(c)));
|
|
92
|
+
});
|
|
93
|
+
// ---- 活動の申告 ----
|
|
94
|
+
// 「今この人が触っている」だけを記録する。revision も doc も動かさない。
|
|
95
|
+
// 打鍵ごとに来るので、応答に doc を載せない(載せると 1 打鍵ごとに全状態が往復する)。
|
|
96
|
+
http.post("/api/touch", (c) => {
|
|
97
|
+
store().touch(actorOf(c));
|
|
98
|
+
return c.json({ ok: true });
|
|
99
|
+
});
|
|
100
|
+
// ---- スクリーンショット(GUI をそのまま撮る)----
|
|
101
|
+
http.post("/api/shot", async (c) => {
|
|
102
|
+
const body = (await c.req.json().catch(() => ({})));
|
|
103
|
+
try {
|
|
104
|
+
return c.json({ data: await takeShot(app, store().revision, body.path || "/") });
|
|
105
|
+
}
|
|
106
|
+
catch (err) {
|
|
107
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 400);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
// ---- blob(画像などの実体)----
|
|
111
|
+
http.post("/api/blob", async (c) => {
|
|
112
|
+
const mime = c.req.header("content-type") ?? "application/octet-stream";
|
|
113
|
+
const bytes = new Uint8Array(await c.req.arrayBuffer());
|
|
114
|
+
if (bytes.byteLength === 0)
|
|
115
|
+
return c.json({ error: "empty body" }, 400);
|
|
116
|
+
return c.json(blobs.put(bytes, mime));
|
|
117
|
+
});
|
|
118
|
+
http.get("/api/blob", (c) => c.json(blobs.list()));
|
|
119
|
+
http.get("/blob/:id", (c) => {
|
|
120
|
+
const found = blobs.get(c.req.param("id"));
|
|
121
|
+
if (!found)
|
|
122
|
+
return c.text("not found", 404);
|
|
123
|
+
return new Response(new Uint8Array(found.bytes), {
|
|
124
|
+
headers: {
|
|
125
|
+
"content-type": found.mime,
|
|
126
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
127
|
+
},
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
// ---- GUI(vite ビルド成果物)。実ファイルが無ければ index.html を返す ----
|
|
131
|
+
// /api の打ち間違いが GUI の HTML で返ると原因が分からなくなるので、先に落とす。
|
|
132
|
+
http.all("/api/*", (c) => c.json({ error: `unknown endpoint: ${c.req.path}` }, 404));
|
|
133
|
+
const webDist = path.resolve(rootFor(app), app.webDist);
|
|
134
|
+
http.get("/*", (c) => {
|
|
135
|
+
let rel;
|
|
136
|
+
try {
|
|
137
|
+
rel = decodeURIComponent(new URL(c.req.url).pathname).replace(/^\/+/, "");
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
return c.text("bad path", 400);
|
|
141
|
+
}
|
|
142
|
+
const asFile = serveFile(webDist, path.resolve(webDist, rel));
|
|
143
|
+
if (asFile)
|
|
144
|
+
return asFile;
|
|
145
|
+
const index = serveFile(webDist, path.join(webDist, "index.html"));
|
|
146
|
+
if (index)
|
|
147
|
+
return index;
|
|
148
|
+
return c.text(`${app.webDist} not built. run: npm run build:web`, 404);
|
|
149
|
+
});
|
|
150
|
+
return http;
|
|
151
|
+
}
|
package/lib/index.d.ts
ADDED
package/lib/index.js
ADDED
package/lib/mcp.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { AppDef } from "./types.js";
|
|
3
|
+
/** daemon の HTTP を叩く。daemon 自身も自分を叩く。 */
|
|
4
|
+
export type Call = <T>(pathname: string, init?: RequestInit) => Promise<T>;
|
|
5
|
+
export declare function registerTools<Doc>(server: McpServer, app: AppDef<Doc>, call: Call): void;
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { dataDirFor } from "./paths.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { BlobStore } from "./blob.js";
|
|
4
|
+
import { inputShape } from "./op.js";
|
|
5
|
+
import { MAX_WAIT_MS, WAIT_MS } from "./wire.js";
|
|
6
|
+
const json = (value) => ({
|
|
7
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
8
|
+
});
|
|
9
|
+
/**
|
|
10
|
+
* conflict を扱えないと詰まるので、それを説明文で担保する。
|
|
11
|
+
* 競合後に最新 doc で意図を見直すことを伝える。
|
|
12
|
+
*
|
|
13
|
+
* op ごとに付くので短く保つこと。activity の説明は await_change 側に 1 度だけ置く。
|
|
14
|
+
*/
|
|
15
|
+
const CONFLICT_NOTE = " baseRevision は意図を決めるために観測した revision(文字列)をそのまま渡す。" +
|
|
16
|
+
"版が変わっていたら handler を実行せず conflict と最新 doc を返す。" +
|
|
17
|
+
"最新 doc を見て意図を見直すこと。同じ引数を新しい版で自動再送しない。" +
|
|
18
|
+
"通信エラーは適用結果が不明な場合がある。まず await_change で現在の状態を確認する。";
|
|
19
|
+
/** 基盤が生やすツール。op がこの名前を使うと登録が衝突する。 */
|
|
20
|
+
const RESERVED = ["gui_url", "await_change", "render_screenshot", "read_blob"];
|
|
21
|
+
export function registerTools(server, app, call) {
|
|
22
|
+
const blobs = new BlobStore(app.id, dataDirFor(app));
|
|
23
|
+
const clash = app.ops.filter((o) => RESERVED.includes(o.name)).map((o) => o.name);
|
|
24
|
+
if (clash.length > 0) {
|
|
25
|
+
throw new Error(`op の名前が基盤のツールと衝突している: ${clash.join(", ")}`);
|
|
26
|
+
}
|
|
27
|
+
const post = (pathname, body) => call(pathname, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: { "content-type": "application/json" },
|
|
30
|
+
body: JSON.stringify(body),
|
|
31
|
+
});
|
|
32
|
+
// ---- アプリが定義した op をそのままツールにする ----
|
|
33
|
+
for (const op of app.ops) {
|
|
34
|
+
server.registerTool(op.name, {
|
|
35
|
+
title: op.name,
|
|
36
|
+
description: op.description + CONFLICT_NOTE,
|
|
37
|
+
inputSchema: inputShape(op),
|
|
38
|
+
}, async (args) => json(await post(`/api/op/${encodeURIComponent(op.name)}`, args)));
|
|
39
|
+
}
|
|
40
|
+
// ---- どこで開いているか ----
|
|
41
|
+
// ポートは app.id から導出されるので、人は自力で知りようがない。
|
|
42
|
+
// 「ブラウザで開きたい」と言われたら、これを呼んで URL を伝えること。
|
|
43
|
+
server.registerTool("gui_url", {
|
|
44
|
+
title: "gui_url",
|
|
45
|
+
description: "人が操作する GUI の URL を返す。ポートは app.id から導出されるので、" +
|
|
46
|
+
"人に「どこで開いているか」を聞かれたらこれで答えること。",
|
|
47
|
+
inputSchema: {},
|
|
48
|
+
}, async () => json(await call("/api/hello")));
|
|
49
|
+
// ---- 人間の操作を待つ ----
|
|
50
|
+
server.registerTool("await_change", {
|
|
51
|
+
title: "await_change",
|
|
52
|
+
description: "省略すると現在の doc と revision を即時取得する。sinceRevision は観測した文字列をそのまま渡す。" +
|
|
53
|
+
"その版以後のコミットを待ち、既に変更があれば即返す。until は待つ op 名を絞る。" +
|
|
54
|
+
"全応答に doc が載る。truncated は差分を説明できないという意味。doc を確認すること。" +
|
|
55
|
+
"再起動や履歴の保持範囲外でも最新 doc を即返す。timedOut の場合も doc と revision を組で読む。" +
|
|
56
|
+
"changes は対象 op の変更説明で、連続した同じ参加者の同じ op は count にまとまる。" +
|
|
57
|
+
"activity は最終活動からの経過ミリ秒で、編集完了や優先権は保証しない。activity 自体では起床しない。",
|
|
58
|
+
inputSchema: {
|
|
59
|
+
sinceRevision: z
|
|
60
|
+
.string()
|
|
61
|
+
.optional()
|
|
62
|
+
.describe("直前に観測した revision。省略すると待たずに今の doc を返す。"),
|
|
63
|
+
until: z.array(z.string()).optional().describe("待つ op 名。省略すると任意の変更。"),
|
|
64
|
+
timeoutMs: z.number().optional().describe(`既定 ${WAIT_MS}、上限 ${MAX_WAIT_MS}。`),
|
|
65
|
+
},
|
|
66
|
+
}, async ({ sinceRevision, until, timeoutMs }) => {
|
|
67
|
+
// sinceRevision は任意。省略すると http.ts の「待たずに今の doc を返す」経路に落ちる。
|
|
68
|
+
// 初回にはまだ観測識別子を持っていない。
|
|
69
|
+
const q = new URLSearchParams();
|
|
70
|
+
if (sinceRevision !== undefined)
|
|
71
|
+
q.set("since", String(sinceRevision));
|
|
72
|
+
if (until?.length)
|
|
73
|
+
q.set("until", until.join(","));
|
|
74
|
+
if (timeoutMs !== undefined)
|
|
75
|
+
q.set("timeout", String(Math.min(timeoutMs, MAX_WAIT_MS)));
|
|
76
|
+
return json(await call(`/api/doc?${q}`));
|
|
77
|
+
});
|
|
78
|
+
// ---- 描画結果を見る。人が見ている GUI をそのまま撮る ----
|
|
79
|
+
server.registerTool("render_screenshot", {
|
|
80
|
+
title: "render_screenshot",
|
|
81
|
+
description: "同じ文書を GUI の別セッションで描画した PNG を返す。人間の下書きやスクロール位置は共有しない。" +
|
|
82
|
+
"path を渡すとその画面へ移動してから撮る(GUI が解釈する URL をそのまま書く)。",
|
|
83
|
+
inputSchema: {
|
|
84
|
+
path: z.string().optional().describe('既定 "/"。例: "/board/2?debug=1"'),
|
|
85
|
+
},
|
|
86
|
+
}, async ({ path }) => {
|
|
87
|
+
const r = await post("/api/shot", { path });
|
|
88
|
+
const { data } = r;
|
|
89
|
+
return { content: [{ type: "image", data, mimeType: "image/png" }] };
|
|
90
|
+
});
|
|
91
|
+
// ---- blob(画像などの実体)。不変なので daemon を通さない ----
|
|
92
|
+
server.registerTool("read_blob", {
|
|
93
|
+
title: "read_blob",
|
|
94
|
+
description: "blob を読む。画像なら画像として返す。id は doc から得る。",
|
|
95
|
+
inputSchema: { id: z.string() },
|
|
96
|
+
}, async ({ id }) => {
|
|
97
|
+
const found = blobs.get(id);
|
|
98
|
+
if (!found)
|
|
99
|
+
return json({ error: `blob not found: ${id}` });
|
|
100
|
+
if (found.mime.startsWith("image/")) {
|
|
101
|
+
return {
|
|
102
|
+
content: [
|
|
103
|
+
{ type: "image", data: found.bytes.toString("base64"), mimeType: found.mime },
|
|
104
|
+
],
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return json({ id, mime: found.mime, text: found.bytes.toString("utf8") });
|
|
108
|
+
});
|
|
109
|
+
}
|
package/lib/op.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { AppDef, Op, ZodRawShape } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Doc を固定した op 定義ヘルパ。
|
|
4
|
+
* `const op = opFactory<MyDoc>()` としてから op({...}) と書くと、
|
|
5
|
+
* handler の ctx.doc と args に型が付く。
|
|
6
|
+
*/
|
|
7
|
+
export declare function opFactory<Doc>(): <Shape extends ZodRawShape>(op: Op<Doc, Shape>) => Op<Doc, Shape>;
|
|
8
|
+
export declare function defineApp<Doc>(app: AppDef<Doc>): AppDef<Doc>;
|
|
9
|
+
/** 両方の入口に同じ観測識別子を要求する。 */
|
|
10
|
+
export declare function inputShape<Doc>(op: Op<Doc>): ZodRawShape;
|
package/lib/op.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
/**
|
|
3
|
+
* Doc を固定した op 定義ヘルパ。
|
|
4
|
+
* `const op = opFactory<MyDoc>()` としてから op({...}) と書くと、
|
|
5
|
+
* handler の ctx.doc と args に型が付く。
|
|
6
|
+
*/
|
|
7
|
+
export function opFactory() {
|
|
8
|
+
return (op) => op;
|
|
9
|
+
}
|
|
10
|
+
export function defineApp(app) {
|
|
11
|
+
return app;
|
|
12
|
+
}
|
|
13
|
+
/** 両方の入口に同じ観測識別子を要求する。 */
|
|
14
|
+
export function inputShape(op) {
|
|
15
|
+
return {
|
|
16
|
+
...op.input,
|
|
17
|
+
baseRevision: z.string().min(1).describe("意図を決めるために観測した revision をそのまま渡す。数値の旧形式は使えない。版が変われば実行前に conflict。"),
|
|
18
|
+
};
|
|
19
|
+
}
|
package/lib/paths.d.ts
ADDED
package/lib/paths.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/** パッケージの設置場所ではなく、アプリが指定したルートを使う。 */
|
|
3
|
+
export function rootFor(app) {
|
|
4
|
+
if (app.rootDir !== undefined && !path.isAbsolute(app.rootDir)) {
|
|
5
|
+
throw new Error("rootDir は絶対パスで指定してください。");
|
|
6
|
+
}
|
|
7
|
+
return app.rootDir ?? process.cwd();
|
|
8
|
+
}
|
|
9
|
+
export const dataDirFor = (app) => path.join(rootFor(app), "data");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Json } from "./diff.js";
|
|
2
|
+
/** 不透明な観測識別子。受け取った値をそのまま返す。 */
|
|
3
|
+
export type Revision = string;
|
|
4
|
+
export type Snapshot<Doc> = {
|
|
5
|
+
revision: Revision;
|
|
6
|
+
actor: string;
|
|
7
|
+
doc: Readonly<Doc>;
|
|
8
|
+
activity: Record<string, number>;
|
|
9
|
+
};
|
|
10
|
+
export type Change = {
|
|
11
|
+
revision: Revision;
|
|
12
|
+
op: string;
|
|
13
|
+
actor: string;
|
|
14
|
+
count: number;
|
|
15
|
+
touched: string[];
|
|
16
|
+
};
|
|
17
|
+
export type Diff = {
|
|
18
|
+
changes: Change[];
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
};
|
|
21
|
+
export type RunResult<Doc> = Snapshot<Doc> & ({
|
|
22
|
+
ok: true;
|
|
23
|
+
result?: Json;
|
|
24
|
+
} | {
|
|
25
|
+
rejected: string;
|
|
26
|
+
} | ({
|
|
27
|
+
conflict: true;
|
|
28
|
+
} & Diff));
|
|
29
|
+
export type WaitResult<Doc> = Snapshot<Doc> & Diff & {
|
|
30
|
+
timedOut: boolean;
|
|
31
|
+
};
|
|
32
|
+
/** 基盤内部だけで使う。アプリは revision を解析しない。 */
|
|
33
|
+
export declare function parseRevision(value: unknown): {
|
|
34
|
+
epoch: string;
|
|
35
|
+
seq: number;
|
|
36
|
+
} | null;
|
package/lib/protocol.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** 基盤内部だけで使う。アプリは revision を解析しない。 */
|
|
2
|
+
export function parseRevision(value) {
|
|
3
|
+
if (typeof value !== "string")
|
|
4
|
+
return null;
|
|
5
|
+
const match = /^([^:]+):(0|[1-9]\d*)$/.exec(value);
|
|
6
|
+
if (!match)
|
|
7
|
+
return null;
|
|
8
|
+
const seq = Number(match[2]);
|
|
9
|
+
return Number.isSafeInteger(seq) ? { epoch: match[1], seq } : null;
|
|
10
|
+
}
|
package/lib/server.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runApp } from "./boot.js";
|
package/lib/server.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runApp } from "./boot.js";
|
package/lib/shot.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Revision } from "./protocol.js";
|
|
2
|
+
import type { AppDef } from "./types.js";
|
|
3
|
+
/**
|
|
4
|
+
* 同じ daemon の要求時点以降の DOM 反映を待つ。
|
|
5
|
+
* 非同期画像などのアプリ固有の描画完了は、この属性だけでは判定できない。
|
|
6
|
+
* 基盤と GUI の間の contract はこの属性 1 つだけ。
|
|
7
|
+
*/
|
|
8
|
+
export declare function takeShot<Doc>(app: AppDef<Doc>, revision: Revision, at?: string): Promise<string>;
|
package/lib/shot.js
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { chromium } from "playwright";
|
|
2
|
+
import { parseRevision } from "./protocol.js";
|
|
3
|
+
import { baseUrlFor } from "./wire.js";
|
|
4
|
+
const READY_TIMEOUT_MS = 10_000;
|
|
5
|
+
const DEFAULT_VIEWPORT = { width: 1024, height: 768 };
|
|
6
|
+
/**
|
|
7
|
+
* 撮影先。既定は daemon 自身(= 人が見ているのと同じ vite ビルド成果物)。
|
|
8
|
+
*
|
|
9
|
+
* 開発時だけ差し替える。`dev:web` 中は人間が :5173 の最新を見ているのに、
|
|
10
|
+
* daemon は古い(あるいは未ビルドの)dist を配るので、既定のままだと
|
|
11
|
+
* ビルド成果物の食い違いを避けるため、開発中はここを :5173 に向ける。
|
|
12
|
+
*
|
|
13
|
+
* DUET_SHOT_ORIGIN=http://127.0.0.1:5173
|
|
14
|
+
*
|
|
15
|
+
* vite の dev server は /api と /blob を daemon にプロキシするので、
|
|
16
|
+
* data-duet-revision の契約はそのまま成立する。
|
|
17
|
+
*/
|
|
18
|
+
const shotOrigin = (id) => process.env.DUET_SHOT_ORIGIN?.replace(/\/+$/, "") ?? baseUrlFor(id);
|
|
19
|
+
const holder = globalThis;
|
|
20
|
+
/**
|
|
21
|
+
* 同じ GUI を別セッションで描く。人間の下書きやスクロール位置は共有しない。
|
|
22
|
+
*
|
|
23
|
+
* ページは開いたままにする。GUI は useDoc でロングポーリングしているので、
|
|
24
|
+
* 常に最新を映している。撮影ごとの navigate も再読み込みも要らない。
|
|
25
|
+
*/
|
|
26
|
+
async function getPage(app, at) {
|
|
27
|
+
const url = `${shotOrigin(app.id)}${at}`;
|
|
28
|
+
const live = holder.__duetShot;
|
|
29
|
+
if (live && live.browser.isConnected() && !live.page.isClosed()) {
|
|
30
|
+
if (live.at !== at) {
|
|
31
|
+
await live.page.goto(url);
|
|
32
|
+
live.at = at;
|
|
33
|
+
}
|
|
34
|
+
return live.page;
|
|
35
|
+
}
|
|
36
|
+
// 死んだ browser を掴んだままにすると、以後の撮影が永久に失敗する。
|
|
37
|
+
holder.__duetShot = undefined;
|
|
38
|
+
const reusable = live?.browser.isConnected() === true ? live.browser : null;
|
|
39
|
+
if (reusable)
|
|
40
|
+
await live.page.close().catch(() => { });
|
|
41
|
+
const browser = reusable ?? (await chromium.launch({ headless: true }));
|
|
42
|
+
const v = app.shot?.viewport;
|
|
43
|
+
const page = await browser.newPage({
|
|
44
|
+
viewport: v ? { width: Math.ceil(v.w), height: Math.ceil(v.h) } : DEFAULT_VIEWPORT,
|
|
45
|
+
});
|
|
46
|
+
await page.goto(url);
|
|
47
|
+
holder.__duetShot = { browser, page, at };
|
|
48
|
+
return page;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* 同じ daemon の要求時点以降の DOM 反映を待つ。
|
|
52
|
+
* 非同期画像などのアプリ固有の描画完了は、この属性だけでは判定できない。
|
|
53
|
+
* 基盤と GUI の間の contract はこの属性 1 つだけ。
|
|
54
|
+
*/
|
|
55
|
+
export function takeShot(app, revision, at = "/") {
|
|
56
|
+
// 参加者は複数居てよい設計なので、render_screenshot が同時に来ることはある。
|
|
57
|
+
// 並べないと、片方が撮っている間にもう片方が goto して別の画面が写る。
|
|
58
|
+
const prev = holder.__duetShotQueue ?? Promise.resolve();
|
|
59
|
+
const next = prev.then(() => capture(app, revision, at));
|
|
60
|
+
holder.__duetShotQueue = next.then(() => undefined, () => undefined);
|
|
61
|
+
return next;
|
|
62
|
+
}
|
|
63
|
+
async function capture(app, revision, at) {
|
|
64
|
+
const page = await getPage(app, at);
|
|
65
|
+
const want = parseRevision(revision);
|
|
66
|
+
if (!want)
|
|
67
|
+
throw new Error("invalid revision");
|
|
68
|
+
await page.waitForFunction((target) => {
|
|
69
|
+
const raw = document.documentElement.dataset.duetRevision;
|
|
70
|
+
const parts = raw?.split(":");
|
|
71
|
+
return parts?.[0] === target.epoch && Number(parts[1]) >= target.seq;
|
|
72
|
+
}, want, { timeout: READY_TIMEOUT_MS });
|
|
73
|
+
await page.evaluate(() => document.fonts.ready.then(() => true));
|
|
74
|
+
const epoch = await page.evaluate(() => document.documentElement.dataset.duetRevision?.split(":")[0]);
|
|
75
|
+
if (epoch !== want.epoch)
|
|
76
|
+
throw new Error("撮影中に daemon が交代した。最新状態を取得して撮り直すこと。");
|
|
77
|
+
const selector = app.shot?.selector;
|
|
78
|
+
const buf = selector
|
|
79
|
+
? await page.locator(selector).screenshot({ type: "png" })
|
|
80
|
+
: await page.screenshot({ type: "png" });
|
|
81
|
+
const after = await page.evaluate(() => document.documentElement.dataset.duetRevision?.split(":")[0]);
|
|
82
|
+
if (after !== want.epoch)
|
|
83
|
+
throw new Error("撮影中に daemon が交代した。最新状態を取得して撮り直すこと。");
|
|
84
|
+
return buf.toString("base64");
|
|
85
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** 読み取りの再接続と、結果不明の書き込みを区別する。 */
|
|
2
|
+
export declare class Reached extends Error {
|
|
3
|
+
}
|
|
4
|
+
export declare class OutcomeUnknown extends Error {
|
|
5
|
+
constructor();
|
|
6
|
+
}
|
|
7
|
+
export declare function requestWithRecovery<T>(request: () => Promise<T>, recover: () => Promise<void>, method?: string): Promise<T>;
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** 読み取りの再接続と、結果不明の書き込みを区別する。 */
|
|
2
|
+
export class Reached extends Error {
|
|
3
|
+
}
|
|
4
|
+
export class OutcomeUnknown extends Error {
|
|
5
|
+
constructor() { super("操作の適用結果が不明。自動再送していない。最新の doc を取得して確認すること。"); }
|
|
6
|
+
}
|
|
7
|
+
export async function requestWithRecovery(request, recover, method = "GET") {
|
|
8
|
+
try {
|
|
9
|
+
return await request();
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
if (err instanceof Reached)
|
|
13
|
+
throw err;
|
|
14
|
+
if (method.toUpperCase() !== "GET") {
|
|
15
|
+
// 所有者の回復は試すが、元の操作を再実行しない。
|
|
16
|
+
await recover().catch(() => { });
|
|
17
|
+
throw new OutcomeUnknown();
|
|
18
|
+
}
|
|
19
|
+
await recover();
|
|
20
|
+
return request();
|
|
21
|
+
}
|
|
22
|
+
}
|
package/lib/types.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { z } from "zod";
|
|
2
|
+
import type { Json } from "./diff.js";
|
|
3
|
+
export type { Revision, Snapshot, RunResult } from "./protocol.js";
|
|
4
|
+
export type { Json } from "./diff.js";
|
|
5
|
+
export type ZodRawShape = z.ZodRawShape;
|
|
6
|
+
/**
|
|
7
|
+
* 誰が操作したか。ただの文字列。
|
|
8
|
+
* ブラウザからは "human"、MCP からは env の DUET_ACTOR(既定 "llm")。
|
|
9
|
+
* MCP 設定に DUET_ACTOR=gpt と書けば別参加者になる。
|
|
10
|
+
*
|
|
11
|
+
* 「面」でも「役割」でもない。席の割り当てはアプリの doc に書くこと。
|
|
12
|
+
*/
|
|
13
|
+
export type Actor = string;
|
|
14
|
+
/**
|
|
15
|
+
* op のハンドラが受け取るもの。これで全部。
|
|
16
|
+
*
|
|
17
|
+
* doc はそのまま書き換えてよい。これは複製なので、reject や例外で抜けた場合は
|
|
18
|
+
* 途中まで書いた変更ごと捨てられる。commit / 永続化 / revision 採番は基盤が行う。
|
|
19
|
+
*/
|
|
20
|
+
export type Ctx<Doc> = {
|
|
21
|
+
doc: Doc;
|
|
22
|
+
actor: Actor;
|
|
23
|
+
/**
|
|
24
|
+
* この操作は適用しない、と宣言して中断する。revision は進まない。
|
|
25
|
+
* 非合法な入力、状態的に許されない要求、手番違反はすべてこれ(例外ではなく正常な結果)。
|
|
26
|
+
* `return reject(...)` の形で呼ぶこと。
|
|
27
|
+
*/
|
|
28
|
+
reject: (reason: string) => never;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* 操作の唯一の定義。ここから MCP ツールと HTTP ルートの両方が生える。
|
|
32
|
+
*
|
|
33
|
+
* handler をメソッド構文で宣言しているのは、具体的な Shape を持つ Op を
|
|
34
|
+
* Op<Doc, ZodRawShape>[] に代入できるようにするため(双変性)。
|
|
35
|
+
*/
|
|
36
|
+
export type Op<Doc, Shape extends ZodRawShape = ZodRawShape> = {
|
|
37
|
+
name: string;
|
|
38
|
+
description: string;
|
|
39
|
+
/** baseRevision は基盤が必須項目として足す。 */
|
|
40
|
+
input: Shape;
|
|
41
|
+
/** 同期で doc と結果だけを計算する。外部副作用・Promise は扱わない。 */
|
|
42
|
+
handler(ctx: Ctx<Doc>, args: z.infer<z.ZodObject<Shape>>): Json | void;
|
|
43
|
+
};
|
|
44
|
+
export type AppDef<Doc> = {
|
|
45
|
+
/** ポートとデータファイル名の元になる。プロセス間の正体確認にも使う。 */
|
|
46
|
+
id: string;
|
|
47
|
+
version: string;
|
|
48
|
+
initialDoc: () => Doc;
|
|
49
|
+
ops: Op<Doc>[];
|
|
50
|
+
/** アプリのルートの絶対パス。省略時は起動時の作業ディレクトリ。保存先はこの下の data/。 */
|
|
51
|
+
rootDir?: string;
|
|
52
|
+
/** GUI の出力先。rootDir からの相対パス、または絶対パス。 */
|
|
53
|
+
webDist: string;
|
|
54
|
+
/**
|
|
55
|
+
* スクリーンショットの撮り方。省略するとビューポート全体を等倍で撮る。
|
|
56
|
+
* 絵は GUI をそのまま撮るので、LLM 用に別途描画するものは無い。
|
|
57
|
+
*/
|
|
58
|
+
shot?: {
|
|
59
|
+
/** 撮る要素の CSS セレクタ。省略するとページ全体。 */
|
|
60
|
+
selector?: string;
|
|
61
|
+
viewport?: {
|
|
62
|
+
w: number;
|
|
63
|
+
h: number;
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
};
|
package/lib/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|