@kanf/wema-kake 0.0.0-stage → 0.1.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.
Files changed (84) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +123 -2
  3. package/bin/wema-kake.mjs +220 -0
  4. package/dist/lib/index.d.ts +13 -0
  5. package/dist/lib/index.js +14 -0
  6. package/dist/lib/shared/api.d.ts +97 -0
  7. package/dist/lib/shared/api.js +23 -0
  8. package/dist/lib/shared/delta.d.ts +24 -0
  9. package/dist/lib/shared/delta.js +19 -0
  10. package/dist/lib/shared/guide.d.ts +32 -0
  11. package/dist/lib/shared/guide.js +259 -0
  12. package/dist/lib/shared/hierarchy.d.ts +31 -0
  13. package/dist/lib/shared/hierarchy.js +40 -0
  14. package/dist/lib/shared/i18n.d.ts +23 -0
  15. package/dist/lib/shared/i18n.js +58 -0
  16. package/dist/lib/shared/note-html.d.ts +10 -0
  17. package/dist/lib/shared/note-html.js +28 -0
  18. package/dist/lib/shared/note-info.d.ts +20 -0
  19. package/dist/lib/shared/note-info.js +29 -0
  20. package/dist/lib/shared/note-text.d.ts +11 -0
  21. package/dist/lib/shared/note-text.js +118 -0
  22. package/dist/lib/shared/op-summary.d.ts +30 -0
  23. package/dist/lib/shared/op-summary.js +37 -0
  24. package/dist/lib/shared/protocol.d.ts +74 -0
  25. package/dist/lib/shared/protocol.js +12 -0
  26. package/dist/lib/shared/site.d.ts +16 -0
  27. package/dist/lib/shared/site.js +28 -0
  28. package/dist/lib/shared/slug.d.ts +4 -0
  29. package/dist/lib/shared/slug.js +6 -0
  30. package/dist/lib/shared/tools.d.ts +67 -0
  31. package/dist/lib/shared/tools.js +353 -0
  32. package/dist/lib/worker/access.d.ts +34 -0
  33. package/dist/lib/worker/access.js +114 -0
  34. package/dist/lib/worker/apply-ops.d.ts +75 -0
  35. package/dist/lib/worker/apply-ops.js +335 -0
  36. package/dist/lib/worker/assets.d.ts +4 -0
  37. package/dist/lib/worker/assets.js +35 -0
  38. package/dist/lib/worker/config.d.ts +52 -0
  39. package/dist/lib/worker/config.js +23 -0
  40. package/dist/lib/worker/first-page.d.ts +10 -0
  41. package/dist/lib/worker/first-page.js +30 -0
  42. package/dist/lib/worker/images.d.ts +3 -0
  43. package/dist/lib/worker/images.js +54 -0
  44. package/dist/lib/worker/index.d.ts +8 -0
  45. package/dist/lib/worker/index.js +72 -0
  46. package/dist/lib/worker/indexer.d.ts +42 -0
  47. package/dist/lib/worker/indexer.js +93 -0
  48. package/dist/lib/worker/mcp/board-access.d.ts +28 -0
  49. package/dist/lib/worker/mcp/board-access.js +67 -0
  50. package/dist/lib/worker/mcp/server.d.ts +9 -0
  51. package/dist/lib/worker/mcp/server.js +39 -0
  52. package/dist/lib/worker/page-do.d.ts +330 -0
  53. package/dist/lib/worker/page-do.js +1168 -0
  54. package/dist/lib/worker/page-queries.d.ts +28 -0
  55. package/dist/lib/worker/page-queries.js +88 -0
  56. package/dist/lib/worker/pages-api.d.ts +5 -0
  57. package/dist/lib/worker/pages-api.js +257 -0
  58. package/dist/lib/worker/plain-text.d.ts +22 -0
  59. package/dist/lib/worker/plain-text.js +113 -0
  60. package/dist/lib/worker/revert.d.ts +21 -0
  61. package/dist/lib/worker/revert.js +126 -0
  62. package/dist/lib/worker/sanitize.d.ts +4 -0
  63. package/dist/lib/worker/sanitize.js +135 -0
  64. package/dist/lib/worker/validate.d.ts +21 -0
  65. package/dist/lib/worker/validate.js +218 -0
  66. package/dist/web/assets/index-CVwMafiH.js +5 -0
  67. package/dist/web/assets/index-Cgi5orcJ.css +1 -0
  68. package/dist/web/favicon.svg +13 -0
  69. package/dist/web/index.html +14 -0
  70. package/migrations/0001_init.sql +18 -0
  71. package/migrations/0002_fts.sql +9 -0
  72. package/migrations/0003_hierarchy.sql +9 -0
  73. package/migrations/0004_settings.sql +12 -0
  74. package/migrations/0005_bookmarks.sql +8 -0
  75. package/migrations/0006_page_color.sql +5 -0
  76. package/package.json +74 -4
  77. package/template/.dev.vars.example +4 -0
  78. package/template/README.md +55 -0
  79. package/template/gitignore +8 -0
  80. package/template/package.json +25 -0
  81. package/template/src/config.ts +18 -0
  82. package/template/src/index.ts +9 -0
  83. package/template/tsconfig.json +16 -0
  84. package/template/wrangler.jsonc +57 -0
@@ -0,0 +1,23 @@
1
+ // パッケージの利用側が渡す設定と、Worker が受け取るバインディング。どちらも、パッケージの
2
+ // 入口(src/index.ts)から型として公開する。**公開した後は約束になる**ので、項目を落とすことと、
3
+ // 既定の値を変えることは、破壊的変更として扱う(docs/package.md の「互換の決まり」)。
4
+ import { isLang } from '../shared/i18n';
5
+ import { DEFAULT_SITE } from '../shared/site';
6
+ /**
7
+ * 既定の値を埋める。不正な値は、Worker の起動時(モジュールの読み込み時)に例外にする。
8
+ * リクエストを受けてから気付く形にしない
9
+ */
10
+ export function resolveConfig(config = {}) {
11
+ const { siteName = DEFAULT_SITE.siteName, defaultLang = DEFAULT_SITE.defaultLang, mcp = true, firstPage = true } = config;
12
+ if (typeof siteName !== 'string' || siteName.trim() === '') {
13
+ throw new TypeError('wema-kake: siteName must be a non-empty string');
14
+ }
15
+ if (!isLang(defaultLang))
16
+ throw new TypeError(`wema-kake: unknown defaultLang: ${String(defaultLang)}`);
17
+ // JavaScript から渡されると型の検査を通らないので、'false' のような文字列をここで断る(真として扱われる)
18
+ for (const [key, value] of Object.entries({ mcp, firstPage })) {
19
+ if (typeof value !== 'boolean')
20
+ throw new TypeError(`wema-kake: ${key} must be a boolean`);
21
+ }
22
+ return { siteName, defaultLang, mcp, firstPage };
23
+ }
@@ -0,0 +1,10 @@
1
+ import type { Lang } from '../shared/i18n';
2
+ import type { WemaKakeEnv } from './config';
3
+ /**
4
+ * まだ作っていなければ、最初のページを作る。作ったら true。
5
+ * 一覧が空のときにだけ呼ぶ(呼ぶたびに D1 へ書き込みを試みるため)。
6
+ *
7
+ * `lang` は、一覧を開いた人の画面の言語。その言語の表示名と付箋で作る。作った後は、ふつうの
8
+ * ページなので、言語を切り替えても変わらない
9
+ */
10
+ export declare function createFirstPage(env: WemaKakeEnv, lang: Lang): Promise<boolean>;
@@ -0,0 +1,30 @@
1
+ import { FIRST_PAGE_SLUG, firstPageTitle, guideBoard } from '../shared/guide';
2
+ const KEY = 'first_page';
3
+ /**
4
+ * まだ作っていなければ、最初のページを作る。作ったら true。
5
+ * 一覧が空のときにだけ呼ぶ(呼ぶたびに D1 へ書き込みを試みるため)。
6
+ *
7
+ * `lang` は、一覧を開いた人の画面の言語。その言語の表示名と付箋で作る。作った後は、ふつうの
8
+ * ページなので、言語を切り替えても変わらない
9
+ */
10
+ export async function createFirstPage(env, lang) {
11
+ // 記録を先に書く。同時に届いたリクエストのうち、書けた 1 つだけが作る
12
+ const claimed = await env.DB.prepare(`INSERT OR IGNORE INTO settings (key, value) VALUES (?, 'created')`)
13
+ .bind(KEY)
14
+ .run();
15
+ if (claimed.meta.changes === 0)
16
+ return false;
17
+ try {
18
+ const { notes, edges } = guideBoard('first', lang);
19
+ const deltas = [
20
+ ...notes.map((note) => ({ type: 'note:create', note })),
21
+ ...edges.map((edge) => ({ type: 'edge:create', edge })),
22
+ ];
23
+ return await env.PAGE.getByName(FIRST_PAGE_SLUG).createWithContent(firstPageTitle(lang), deltas);
24
+ }
25
+ catch (e) {
26
+ // 作れなかったら、記録を消して、次に一覧を開いたときにやり直す
27
+ await env.DB.prepare(`DELETE FROM settings WHERE key = ?`).bind(KEY).run();
28
+ throw e;
29
+ }
30
+ }
@@ -0,0 +1,3 @@
1
+ import { Hono } from 'hono';
2
+ import type { AuthEnv } from './access';
3
+ export declare const images: Hono<AuthEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,54 @@
1
+ // 付箋に貼る画像。R2 に置き、text には URL(/img/<key>)だけを入れる。
2
+ import { Hono } from 'hono';
3
+ import { bodyLimit } from 'hono/body-limit';
4
+ const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
5
+ /** 受け付ける形式。SVG はスクリプトを含められるので受け付けない */
6
+ const EXTENSIONS = {
7
+ 'image/png': 'png',
8
+ 'image/jpeg': 'jpg',
9
+ 'image/gif': 'gif',
10
+ 'image/webp': 'webp',
11
+ 'image/avif': 'avif',
12
+ };
13
+ const KEY_RE = new RegExp(`^[0-9a-f-]{36}\\.(${Object.values(EXTENSIONS).join('|')})$`);
14
+ export const images = new Hono();
15
+ /** 本文に画像のバイト列、Content-Type に画像の形式を入れて送る */
16
+ images.post('/api/images',
17
+ // Content-Length のない送信でも、上限を超える本文をメモリに載せない
18
+ bodyLimit({
19
+ maxSize: MAX_IMAGE_BYTES,
20
+ onError: (c) => c.json({ error: 'image too large' }, 413),
21
+ }), async (c) => {
22
+ const contentType = c.req.header('Content-Type')?.split(';')[0].trim().toLowerCase() ?? '';
23
+ const ext = EXTENSIONS[contentType];
24
+ if (!ext)
25
+ return c.json({ error: 'unsupported image type' }, 415);
26
+ const body = await c.req.arrayBuffer();
27
+ if (body.byteLength === 0)
28
+ return c.json({ error: 'empty image' }, 400);
29
+ const key = `${crypto.randomUUID()}.${ext}`;
30
+ await c.env.IMAGES.put(key, body, {
31
+ httpMetadata: { contentType },
32
+ customMetadata: { uploadedBy: c.get('actor') },
33
+ });
34
+ return c.json({ url: `/img/${key}` }, 201);
35
+ });
36
+ images.get('/img/:key', async (c) => {
37
+ const key = c.req.param('key');
38
+ if (!KEY_RE.test(key))
39
+ return c.notFound();
40
+ const object = await c.env.IMAGES.get(key);
41
+ if (!object)
42
+ return c.notFound();
43
+ return new Response(object.body, {
44
+ headers: {
45
+ 'Content-Type': object.httpMetadata?.contentType ?? 'application/octet-stream',
46
+ ETag: object.httpEtag,
47
+ // キーは内容ごとに新しく作るので、同じ URL の内容は変わらない
48
+ 'Cache-Control': 'private, max-age=31536000, immutable',
49
+ // 申告された形式と中身が違っても、ブラウザに HTML などとして解釈させない
50
+ 'X-Content-Type-Options': 'nosniff',
51
+ 'Content-Security-Policy': "default-src 'none'; sandbox",
52
+ },
53
+ });
54
+ });
@@ -0,0 +1,8 @@
1
+ import { Hono } from 'hono';
2
+ import { type AuthEnv } from './access';
3
+ import { type WemaKakeConfig } from './config';
4
+ /**
5
+ * Worker のアプリを組み立てる。パッケージの入口(src/index.ts)の `createWemaKake` が呼ぶ。
6
+ * テストは、これを直接呼んで `app.request` を使う。設定の不正は、ここで例外にする
7
+ */
8
+ export declare function createApp(options?: WemaKakeConfig): Hono<AuthEnv, import("hono/types").BlankSchema, "/">;
@@ -0,0 +1,72 @@
1
+ import { Hono } from 'hono';
2
+ import { requireAccess } from './access';
3
+ import { assetServer } from './assets';
4
+ import { resolveConfig } from './config';
5
+ import { images } from './images';
6
+ import { handleMcp } from './mcp/server';
7
+ import { ACTOR_HEADER, AUTH_EXPIRES_HEADER } from './page-do';
8
+ import { pagesApi, validSlug } from './pages-api';
9
+ /**
10
+ * Worker のアプリを組み立てる。パッケージの入口(src/index.ts)の `createWemaKake` が呼ぶ。
11
+ * テストは、これを直接呼んで `app.request` を使う。設定の不正は、ここで例外にする
12
+ */
13
+ export function createApp(options = {}) {
14
+ const config = resolveConfig(options);
15
+ const serveAsset = assetServer(config);
16
+ const app = new Hono();
17
+ app.use(async (c, next) => {
18
+ c.set('config', config);
19
+ await next();
20
+ });
21
+ // このアプリに届くリクエストはすべて認証を通す(画面のアセットも、末尾のルートから返すので、
22
+ // 認証の後になる)。`/mcp` は MCP のクライアント(agent)だけ、それ以外は Access にログインした
23
+ // 人だけを通す。
24
+ //
25
+ // MCP のクライアントは、Access の Managed OAuth で認可される。クライアントの登録、認可の画面、
26
+ // トークンの発行は Access が行い、Worker には届かない。
27
+ // **`/mcp` は、下の `app.use(requireAccess('user'))` より前に置くこと。** ハンドラが応答を返すので、
28
+ // 人向けの認証には進まない。後ろに置くと、agent が人向けの認証で断られる
29
+ // 無効なら、人向けの認証より前で 404 を返す(後ろに落とすと、agent のトークンが人向けの認証で 403 になる)
30
+ if (config.mcp) {
31
+ app.all('/mcp', requireAccess('agent'), (c) => handleMcp(c.req.raw, c.env, c.executionCtx, c.get('actor')));
32
+ }
33
+ else {
34
+ app.all('/mcp', (c) => c.notFound());
35
+ }
36
+ app.use(requireAccess('user'));
37
+ // Access の Cookie は他サイトからのリクエストにも付く。状態を変えるリクエストと WebSocket の
38
+ // 接続要求(同一オリジンの制約を受けない)は、他のオリジンからのものを断る
39
+ app.use(async (c, next) => {
40
+ const method = c.req.method;
41
+ const guarded = (method !== 'GET' && method !== 'HEAD') || c.req.header('Upgrade') === 'websocket';
42
+ const origin = c.req.header('Origin');
43
+ if (guarded && origin && origin !== new URL(c.req.url).origin) {
44
+ return c.json({ error: 'forbidden origin' }, 403);
45
+ }
46
+ await next();
47
+ });
48
+ app.route('/api', pagesApi);
49
+ app.route('/', images);
50
+ app.get('/ws/:slug', validSlug, async (c) => {
51
+ if (c.req.header('Upgrade') !== 'websocket') {
52
+ return c.json({ error: 'expected websocket' }, 426);
53
+ }
54
+ // クライアントが同名のヘッダーを付けてきても、認証結果で上書きする
55
+ const headers = new Headers(c.req.raw.headers);
56
+ headers.set(ACTOR_HEADER, c.get('actor'));
57
+ const expiresAt = c.get('authExpiresAt');
58
+ if (expiresAt === undefined)
59
+ headers.delete(AUTH_EXPIRES_HEADER);
60
+ else
61
+ headers.set(AUTH_EXPIRES_HEADER, String(expiresAt));
62
+ return c.env.PAGE.getByName(c.req.param('slug')).fetch(new Request(c.req.raw, { headers }));
63
+ });
64
+ // API のパスで、上のどのルートにも合わなかったもの。画面(index.html)ではなく JSON の 404 にする。
65
+ // **API のパスの最初の部分を足したら、ここにも足す**(足さないと、そのパスの「ない」が画面の 200 になる)
66
+ for (const path of ['/api/*', '/ws/*', '/img/*', '/mcp', '/mcp/*'])
67
+ app.all(path, (c) => c.notFound());
68
+ // 残りの GET と HEAD は、画面のアセット(`run_worker_first: true` なので、静的ファイルの要求もここに来る)
69
+ app.on(['GET', 'HEAD'], '*', (c) => serveAsset(c.req.raw, c.env));
70
+ app.notFound((c) => c.json({ error: 'not found' }, 404));
71
+ return app;
72
+ }
@@ -0,0 +1,42 @@
1
+ import type { WemaNote } from '../shared/delta';
2
+ export interface PageContent {
3
+ plainText: string;
4
+ /** このページからリンクしているページのスラッグ */
5
+ links: string[];
6
+ }
7
+ /** 全付箋の text(サニタイズ済みの HTML)から、検索用のテキストとページ間リンクを作る */
8
+ export declare function buildPageContent(slug: string, noteTexts: string[], siteOrigin: string | undefined): Promise<PageContent>;
9
+ /** 検索とリンクに関わる内容が前回の反映から変わったかを比べるための値 */
10
+ export declare function contentHash(title: string | null, content: PageContent): Promise<string>;
11
+ /** 検索とリンクのほかに、ページの行へ書く値 */
12
+ export interface PageRow {
13
+ title: string | null;
14
+ /** ページの色。付けていなければ null */
15
+ color: string | null;
16
+ /** 親ページのスラッグ。ルートのページなら null */
17
+ parent: string | null;
18
+ /** 付箋の配置(buildLayout の結果) */
19
+ layout: string;
20
+ noteCount: number;
21
+ now: number;
22
+ }
23
+ /**
24
+ * 付箋の配置を、小さな JSON にする。親ページの画面が、子ページの付箋の上に、子ページの
25
+ * 配置を簡易に再現するのに使う。1 枚は `[x, y, 幅, 高さ, 色]`。本文と接続線は入れない
26
+ */
27
+ export declare function buildLayout(notes: Pick<WemaNote, 'x' | 'y' | 'width' | 'height' | 'color'>[]): string;
28
+ /**
29
+ * 検索とリンク以外の値(付箋の数、更新日時、親、配置)だけを書く。付箋の移動や色の変更のように、
30
+ * 検索とリンクに関わる内容が変わっていないときに使う。ページの行がなければ false を返す
31
+ * (そのときは writePage で作る)。
32
+ */
33
+ export declare function touchPage(db: D1Database, slug: string, row: PageRow): Promise<boolean>;
34
+ /** 索引の上で `slug` を親としているページ */
35
+ export declare function indexedChildren(db: D1Database, slug: string): Promise<string[]>;
36
+ /**
37
+ * 1 ページ分の索引を消す。他のページからこのページへのリンクは残す
38
+ * (リンク元の付箋にはリンクが残っていて、一覧では未作成のページとして出る)
39
+ */
40
+ export declare function removePage(db: D1Database, slug: string): Promise<void>;
41
+ /** 1 ページ分の索引を入れ替える。1 つのトランザクションで実行する */
42
+ export declare function writePage(db: D1Database, slug: string, content: PageContent, row: PageRow): Promise<void>;
@@ -0,0 +1,93 @@
1
+ import { extractContent, linkedSlug } from './plain-text';
2
+ /** pages.plain_text の上限(文字数)。D1 の 1 行の上限に収め、検索の対象としても十分な量にする */
3
+ const MAX_PLAIN_TEXT_LENGTH = 300_000;
4
+ const MAX_LINKS = 500;
5
+ /** 全付箋の text(サニタイズ済みの HTML)から、検索用のテキストとページ間リンクを作る */
6
+ export async function buildPageContent(slug, noteTexts, siteOrigin) {
7
+ const texts = [];
8
+ let length = 0;
9
+ const links = new Set();
10
+ for (const html of noteTexts) {
11
+ const { text, hrefs } = await extractContent(html);
12
+ if (text && length < MAX_PLAIN_TEXT_LENGTH) {
13
+ texts.push(text);
14
+ length += text.length + 1;
15
+ }
16
+ for (const href of hrefs) {
17
+ const to = linkedSlug(href, siteOrigin);
18
+ if (to && to !== slug && links.size < MAX_LINKS)
19
+ links.add(to);
20
+ }
21
+ }
22
+ return {
23
+ plainText: texts.join('\n').slice(0, MAX_PLAIN_TEXT_LENGTH),
24
+ links: [...links].sort(),
25
+ };
26
+ }
27
+ /** 検索とリンクに関わる内容が前回の反映から変わったかを比べるための値 */
28
+ export async function contentHash(title, content) {
29
+ const bytes = new TextEncoder().encode(JSON.stringify([title, content.plainText, content.links]));
30
+ const digest = await crypto.subtle.digest('SHA-256', bytes);
31
+ return btoa(String.fromCharCode(...new Uint8Array(digest)));
32
+ }
33
+ /** 配置に入れる付箋の数の上限 */
34
+ const MAX_LAYOUT_NOTES = 200;
35
+ /**
36
+ * 付箋の配置を、小さな JSON にする。親ページの画面が、子ページの付箋の上に、子ページの
37
+ * 配置を簡易に再現するのに使う。1 枚は `[x, y, 幅, 高さ, 色]`。本文と接続線は入れない
38
+ */
39
+ export function buildLayout(notes) {
40
+ return JSON.stringify(notes
41
+ .slice(0, MAX_LAYOUT_NOTES)
42
+ .map((n) => [Math.round(n.x), Math.round(n.y), Math.round(n.width), Math.round(n.height), n.color]));
43
+ }
44
+ /**
45
+ * 検索とリンク以外の値(付箋の数、更新日時、親、配置)だけを書く。付箋の移動や色の変更のように、
46
+ * 検索とリンクに関わる内容が変わっていないときに使う。ページの行がなければ false を返す
47
+ * (そのときは writePage で作る)。
48
+ */
49
+ export async function touchPage(db, slug, row) {
50
+ const result = await db
51
+ .prepare(`UPDATE pages SET note_count = ?, updated_at = ?, parent = ?, layout = ?, color = ? WHERE name = ?`)
52
+ .bind(row.noteCount, row.now, row.parent, row.layout, row.color, slug)
53
+ .run();
54
+ return result.meta.changes > 0;
55
+ }
56
+ /** 索引の上で `slug` を親としているページ */
57
+ export async function indexedChildren(db, slug) {
58
+ const { results } = await db.prepare(`SELECT name FROM pages WHERE parent = ?`).bind(slug).all();
59
+ return results.map((row) => row.name);
60
+ }
61
+ /**
62
+ * 1 ページ分の索引を消す。他のページからこのページへのリンクは残す
63
+ * (リンク元の付箋にはリンクが残っていて、一覧では未作成のページとして出る)
64
+ */
65
+ export async function removePage(db, slug) {
66
+ await db.batch([
67
+ db.prepare(`DELETE FROM pages_fts WHERE rowid = (SELECT id FROM pages WHERE name = ?)`).bind(slug),
68
+ db.prepare(`DELETE FROM links WHERE from_page = ?`).bind(slug),
69
+ db.prepare(`DELETE FROM pages WHERE name = ?`).bind(slug),
70
+ db.prepare(`DELETE FROM bookmarks WHERE page = ?`).bind(slug),
71
+ ]);
72
+ }
73
+ /** 1 ページ分の索引を入れ替える。1 つのトランザクションで実行する */
74
+ export async function writePage(db, slug, content, row) {
75
+ await db.batch([
76
+ db
77
+ .prepare(`INSERT INTO pages (name, title, plain_text, note_count, updated_at, parent, layout, color)
78
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
79
+ ON CONFLICT (name) DO UPDATE SET title = excluded.title, plain_text = excluded.plain_text,
80
+ note_count = excluded.note_count, updated_at = excluded.updated_at,
81
+ parent = excluded.parent, layout = excluded.layout, color = excluded.color`)
82
+ .bind(slug, row.title, content.plainText, row.noteCount, row.now, row.parent, row.layout, row.color),
83
+ db.prepare(`DELETE FROM links WHERE from_page = ?`).bind(slug),
84
+ db
85
+ .prepare(`INSERT INTO links (from_page, to_page) SELECT ?, value FROM json_each(?)`)
86
+ .bind(slug, JSON.stringify(content.links)),
87
+ db.prepare(`DELETE FROM pages_fts WHERE rowid = (SELECT id FROM pages WHERE name = ?)`).bind(slug),
88
+ db
89
+ .prepare(`INSERT INTO pages_fts (rowid, title, plain_text)
90
+ SELECT id, coalesce(title, ''), plain_text FROM pages WHERE name = ?`)
91
+ .bind(slug),
92
+ ]);
93
+ }
@@ -0,0 +1,28 @@
1
+ import type { OpSummary, RevertOutcome } from '../../shared/api';
2
+ import type { HistoryDelta } from '../../shared/delta';
3
+ import { type ApplyOutcome, type BoardAccess, type BoardState } from '../../shared/tools';
4
+ import type { WemaKakeEnv } from '../config';
5
+ export declare class ServerBoardAccess implements BoardAccess {
6
+ private readonly env;
7
+ private readonly actor;
8
+ /**
9
+ * @param actor 変更の主体('agent:<client>')。認証結果から決めたものを渡す
10
+ */
11
+ constructor(env: WemaKakeEnv, actor: string);
12
+ private page;
13
+ listPages(options: {
14
+ updatedAfter?: number;
15
+ limit?: number;
16
+ }): Promise<import("../../shared/tools").PageInfo[]>;
17
+ searchPages(query: string): Promise<{
18
+ name: string;
19
+ title: string | null;
20
+ parent: string | null;
21
+ snippet: string | undefined;
22
+ }[]>;
23
+ readBoard(slug: string): Promise<BoardState | null>;
24
+ plainText(html: string): Promise<string>;
25
+ apply(slug: string, deltas: HistoryDelta[], summary: string): Promise<ApplyOutcome>;
26
+ ownOps(slug: string): Promise<OpSummary[]>;
27
+ revert(slug: string, seq: number, summary: string): Promise<RevertOutcome>;
28
+ }
@@ -0,0 +1,67 @@
1
+ import { ToolError } from '../../shared/tools';
2
+ import { listPages, searchPages } from '../page-queries';
3
+ import { extractContent } from '../plain-text';
4
+ /** ops に記録する clientId。MCP にはブラウザのような接続ごとの id がない */
5
+ const MCP_CLIENT_ID = 'mcp';
6
+ export class ServerBoardAccess {
7
+ env;
8
+ actor;
9
+ /**
10
+ * @param actor 変更の主体('agent:<client>')。認証結果から決めたものを渡す
11
+ */
12
+ constructor(env, actor) {
13
+ this.env = env;
14
+ this.actor = actor;
15
+ }
16
+ page(slug) {
17
+ return this.env.PAGE.getByName(slug);
18
+ }
19
+ listPages(options) {
20
+ return listPages(this.env.DB, options);
21
+ }
22
+ async searchPages(query) {
23
+ const hits = await searchPages(this.env.DB, query);
24
+ if (hits === null)
25
+ throw new ToolError('invalid query');
26
+ return hits.map(({ name, title, parent, snippet }) => ({ name, title, parent, snippet }));
27
+ }
28
+ async readBoard(slug) {
29
+ return this.page(slug).getBoardState();
30
+ }
31
+ async plainText(html) {
32
+ // ページへのリンクは、書くときと同じ書式([[slug]])で残す
33
+ return (await extractContent(html, { siteOrigin: this.env.SITE_ORIGIN })).text;
34
+ }
35
+ async apply(slug, deltas, summary) {
36
+ const result = await this.page(slug).applyOps({
37
+ actor: this.actor,
38
+ clientId: MCP_CLIENT_ID,
39
+ opId: crypto.randomUUID(),
40
+ deltas,
41
+ summary,
42
+ // 読んでから適用するまでの間にページが削除されていても、作り直さない
43
+ mustExist: true,
44
+ });
45
+ if (!result.ok)
46
+ return { ok: false, reason: result.reason };
47
+ // すべてのデルタが捨てられた場合、seq は進んでいない(直前の別の操作の番号が入っている)
48
+ return { ok: true, seq: result.deltas.length > 0 ? result.seq : null };
49
+ }
50
+ async ownOps(slug) {
51
+ return this.page(slug).listOps({ actor: this.actor });
52
+ }
53
+ async revert(slug, seq, summary) {
54
+ const result = await this.page(slug).revert({
55
+ seq,
56
+ actor: this.actor,
57
+ clientId: MCP_CLIENT_ID,
58
+ opId: crypto.randomUUID(),
59
+ // agent が取り消せるのは、自分の操作だけ
60
+ ownOnly: true,
61
+ summary,
62
+ });
63
+ if (!result.ok)
64
+ throw new ToolError(result.reason);
65
+ return { seq: result.seq, applied: result.applied, skipped: result.skipped };
66
+ }
67
+ }
@@ -0,0 +1,9 @@
1
+ import { McpServer } from '@modelcontextprotocol/server';
2
+ import { type BoardAccess } from '../../shared/tools';
3
+ import type { WemaKakeEnv } from '../config';
4
+ export declare function createMcpServer(access: BoardAccess): McpServer;
5
+ /**
6
+ * `/mcp` のハンドラ。認証は呼び出し側(`src/worker/index.ts`)が済ませていて、
7
+ * そこで決めた変更の主体(`agent:<client>`)を受け取る
8
+ */
9
+ export declare function handleMcp(request: Request, env: WemaKakeEnv, ctx: ExecutionContext, actor: string): Promise<Response>;
@@ -0,0 +1,39 @@
1
+ // リモート MCP サーバー。ツールの定義と実装は src/shared/tools.ts にあり、ここでは MCP の
2
+ // サーバーに登録して、結果を MCP の形にするだけ。
3
+ import { McpServer } from '@modelcontextprotocol/server';
4
+ import { createMcpHandler } from 'agents/mcp/server';
5
+ import { TOOLS, ToolError } from '../../shared/tools';
6
+ import { ServerBoardAccess } from './board-access';
7
+ export function createMcpServer(access) {
8
+ const server = new McpServer({ name: 'wema-kake', version: '0.1.0' });
9
+ for (const tool of TOOLS) {
10
+ server.registerTool(tool.name, {
11
+ description: tool.description,
12
+ inputSchema: tool.input,
13
+ annotations: { readOnlyHint: tool.readOnly },
14
+ }, async (input) => {
15
+ try {
16
+ // 入力は、MCP のサーバーが tool.input で検証済み
17
+ const result = await tool.run(access, input);
18
+ return { content: [{ type: 'text', text: JSON.stringify(result) }] };
19
+ }
20
+ catch (e) {
21
+ // 入力や状態の問題は、LLM が読んで直せるように結果として返す
22
+ if (e instanceof ToolError) {
23
+ return { isError: true, content: [{ type: 'text', text: e.message }] };
24
+ }
25
+ throw e;
26
+ }
27
+ });
28
+ }
29
+ return server;
30
+ }
31
+ /**
32
+ * `/mcp` のハンドラ。認証は呼び出し側(`src/worker/index.ts`)が済ませていて、
33
+ * そこで決めた変更の主体(`agent:<client>`)を受け取る
34
+ */
35
+ export function handleMcp(request, env, ctx, actor) {
36
+ const access = new ServerBoardAccess(env, actor);
37
+ // リクエストごとにサーバーを作る(MCP のサーバーは、同時に走るリクエストで共有できない)
38
+ return createMcpHandler(() => createMcpServer(access))(request, env, ctx);
39
+ }