@kanf/wema-kake 0.0.0-stage → 0.1.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 (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 +75 -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,30 @@
1
+ /** 保存する符号 */
2
+ export declare const systemSummary: {
3
+ /** ページを、付箋を置いた状態で作った(最初のページ) */
4
+ pageCreated: () => string;
5
+ /** 子ページ `page` を削除したので、その付箋を消した */
6
+ childRemoved: (page: string) => string;
7
+ /** 操作 `seq` を取り消した(要約を付けずに取り消したとき) */
8
+ revert: (seq: number) => string;
9
+ /** ページ `page` から移した(写した)付箋を置いた */
10
+ notesReceived: (page: string) => string;
11
+ /** 子ページ `page` の色に、その付箋の色を合わせた */
12
+ childRecolored: (page: string) => string;
13
+ };
14
+ export type SystemSummary = {
15
+ kind: 'pageCreated';
16
+ } | {
17
+ kind: 'childRemoved';
18
+ page: string;
19
+ } | {
20
+ kind: 'revert';
21
+ seq: number;
22
+ } | {
23
+ kind: 'notesReceived';
24
+ page: string;
25
+ } | {
26
+ kind: 'childRecolored';
27
+ page: string;
28
+ };
29
+ /** 要約が、サーバーの付けた符号なら、その中身を返す。符号でなければ(知らない符号も)null */
30
+ export declare function parseSystemSummary(summary: string): SystemSummary | null;
@@ -0,0 +1,37 @@
1
+ // サーバーが付ける、操作の要約(ops.summary)。文言ではなく符号で保存し、画面が、今の言語の文言に
2
+ // して出す(docs/plan.md のフェーズ 9)。LLM が MCP で付けた要約は、書かれた文のまま保存する。
3
+ //
4
+ // 符号を足す前に保存された要約(日本語の文)は、そのまま残っている。画面は、符号でない要約を、
5
+ // そのまま出す。
6
+ const PREFIX = 'system:';
7
+ /** 保存する符号 */
8
+ export const systemSummary = {
9
+ /** ページを、付箋を置いた状態で作った(最初のページ) */
10
+ pageCreated: () => `${PREFIX}page-created`,
11
+ /** 子ページ `page` を削除したので、その付箋を消した */
12
+ childRemoved: (page) => `${PREFIX}child-removed:${page}`,
13
+ /** 操作 `seq` を取り消した(要約を付けずに取り消したとき) */
14
+ revert: (seq) => `${PREFIX}revert:${seq}`,
15
+ /** ページ `page` から移した(写した)付箋を置いた */
16
+ notesReceived: (page) => `${PREFIX}notes-received:${page}`,
17
+ /** 子ページ `page` の色に、その付箋の色を合わせた */
18
+ childRecolored: (page) => `${PREFIX}child-recolored:${page}`,
19
+ };
20
+ /** 要約が、サーバーの付けた符号なら、その中身を返す。符号でなければ(知らない符号も)null */
21
+ export function parseSystemSummary(summary) {
22
+ if (!summary.startsWith(PREFIX))
23
+ return null;
24
+ const [kind, ...rest] = summary.slice(PREFIX.length).split(':');
25
+ const value = rest.join(':');
26
+ if (kind === 'page-created' && value === '')
27
+ return { kind: 'pageCreated' };
28
+ if (kind === 'child-removed' && value !== '')
29
+ return { kind: 'childRemoved', page: value };
30
+ if (kind === 'revert' && /^\d+$/.test(value))
31
+ return { kind: 'revert', seq: Number(value) };
32
+ if (kind === 'notes-received' && value !== '')
33
+ return { kind: 'notesReceived', page: value };
34
+ if (kind === 'child-recolored' && value !== '')
35
+ return { kind: 'childRecolored', page: value };
36
+ return null;
37
+ }
@@ -0,0 +1,74 @@
1
+ import type { HistoryDelta, Snapshot } from './delta';
2
+ /** 認証の期限が切れた接続を、サーバーが閉じるときのコード。クライアントは再読み込みして認証し直す */
3
+ export declare const CLOSE_AUTH_EXPIRED = 4401;
4
+ /**
5
+ * ページが削除されたときに、サーバーが接続を閉じるコード。クライアントは再接続しない
6
+ * (再接続すると、手元の未確定の操作でページが作り直されてしまう)
7
+ */
8
+ export declare const CLOSE_PAGE_DELETED = 4410;
9
+ /** 接続維持のためにクライアントが送る文字列と、サーバーの応答(JSON ではない) */
10
+ export declare const PING = "ping";
11
+ export declare const PONG = "pong";
12
+ /** text の更新で、`before.text` がサーバーの現在値と違ったときの `reject` の `reason` */
13
+ export declare const REASON_TEXT_CONFLICT = "text conflict";
14
+ /** ブラウザ → サーバー */
15
+ export type ClientMsg =
16
+ /**
17
+ * `epoch` は、前に受け取ったスナップショットの値(あれば)。サーバーの今の値と違えば、
18
+ * ページが削除されたか作り直されているので、サーバーは CLOSE_PAGE_DELETED で閉じる
19
+ */
20
+ {
21
+ type: 'hello';
22
+ clientId: string;
23
+ lastSeq?: number;
24
+ epoch?: string;
25
+ } | {
26
+ type: 'ops';
27
+ opId: string;
28
+ deltas: HistoryDelta[];
29
+ };
30
+ /** 確定した変更。送信元も含めた全員に配信する */
31
+ export interface OpsMsg {
32
+ type: 'ops';
33
+ seq: number;
34
+ actor: string;
35
+ clientId: string;
36
+ opId: string;
37
+ /**
38
+ * 操作を記録した時刻(UNIX ミリ秒)。付箋ごとの付加情報(src/shared/note-info.ts)の更新に使う。
39
+ * 配信と、再接続のときの差分に付く。配信しなかった結果を送信元にだけ返すとき(再送への応答、
40
+ * デルタをすべて捨てた操作)には付かない
41
+ */
42
+ createdAt?: number;
43
+ /** サーバーが実際に適用したデルタ。送信元は自分の opId のものを適用しない */
44
+ deltas: HistoryDelta[];
45
+ summary?: string;
46
+ /**
47
+ * この操作が取り消しなら、取り消した対象の seq。取り消しは HTTP の API か MCP から行われ、
48
+ * 要求元のブラウザも手元には適用していない。自分の clientId の操作でも、これがあれば適用する
49
+ */
50
+ reverts?: number;
51
+ /** 送信元にだけ付く。サーバーが変えた分で、送信元はこれだけを適用する */
52
+ fixups?: HistoryDelta[];
53
+ }
54
+ /** サーバー → ブラウザ */
55
+ export type ServerMsg = ({
56
+ type: 'snapshot';
57
+ } & Snapshot) | OpsMsg
58
+ /** 表示名か、ページの色が変わった。付箋の変更ではないので seq は進まない */
59
+ | {
60
+ type: 'meta';
61
+ title: string | null;
62
+ color: string | null;
63
+ epoch: string | null;
64
+ }
65
+ /**
66
+ * 操作を受け付けなかった。送信元は手元の変更を巻き戻し、`fixups`(サーバーの現在値に
67
+ * 合わせるためのデルタ。text が競合した付箋など)を適用する
68
+ */
69
+ | {
70
+ type: 'reject';
71
+ opId: string;
72
+ reason: string;
73
+ fixups?: HistoryDelta[];
74
+ };
@@ -0,0 +1,12 @@
1
+ /** 認証の期限が切れた接続を、サーバーが閉じるときのコード。クライアントは再読み込みして認証し直す */
2
+ export const CLOSE_AUTH_EXPIRED = 4401;
3
+ /**
4
+ * ページが削除されたときに、サーバーが接続を閉じるコード。クライアントは再接続しない
5
+ * (再接続すると、手元の未確定の操作でページが作り直されてしまう)
6
+ */
7
+ export const CLOSE_PAGE_DELETED = 4410;
8
+ /** 接続維持のためにクライアントが送る文字列と、サーバーの応答(JSON ではない) */
9
+ export const PING = 'ping';
10
+ export const PONG = 'pong';
11
+ /** text の更新で、`before.text` がサーバーの現在値と違ったときの `reject` の `reason` */
12
+ export const REASON_TEXT_CONFLICT = 'text conflict';
@@ -0,0 +1,16 @@
1
+ import { type Lang } from './i18n';
2
+ export interface SiteConfig {
3
+ /** 画面に出すサイトの名前(一覧の見出しと、タブの表示名) */
4
+ siteName: string;
5
+ /** Cookie もブラウザの言語も、対応する言語に当たらないときの言語 */
6
+ defaultLang: Lang;
7
+ }
8
+ /** 設定を省いたときの値 */
9
+ export declare const DEFAULT_SITE: SiteConfig;
10
+ /** index.html の `<head>` に入れる `<meta>` の名前。`content` は SiteConfig の JSON */
11
+ export declare const SITE_META_NAME = "wema-kake-site";
12
+ /**
13
+ * `<meta>` の `content` を読む。読めない値や欠けた項目は、既定の値にする(Worker を通らずに
14
+ * 配られた index.html でも、画面が動くようにする)
15
+ */
16
+ export declare function parseSiteConfig(content: string | null | undefined): SiteConfig;
@@ -0,0 +1,28 @@
1
+ // サイトの設定のうち、画面が使うもの。Worker が index.html に埋め込み、画面が読み込み時に読む。
2
+ //
3
+ // 画面の言語は、スクリプトを読み込んだときに同期で決める(src/web/i18n/index.ts)。API で
4
+ // 問い合わせる形だと、その応答を待つまで文言を決められないので、HTML に入れて渡す。
5
+ import { DEFAULT_LANG, isLang } from './i18n';
6
+ /** 設定を省いたときの値 */
7
+ export const DEFAULT_SITE = { siteName: 'wema-kake', defaultLang: DEFAULT_LANG };
8
+ /** index.html の `<head>` に入れる `<meta>` の名前。`content` は SiteConfig の JSON */
9
+ export const SITE_META_NAME = 'wema-kake-site';
10
+ /**
11
+ * `<meta>` の `content` を読む。読めない値や欠けた項目は、既定の値にする(Worker を通らずに
12
+ * 配られた index.html でも、画面が動くようにする)
13
+ */
14
+ export function parseSiteConfig(content) {
15
+ let value = {};
16
+ try {
17
+ const parsed = JSON.parse(content ?? '');
18
+ if (parsed && typeof parsed === 'object')
19
+ value = parsed;
20
+ }
21
+ catch {
22
+ // 下で既定の値にする
23
+ }
24
+ return {
25
+ siteName: typeof value.siteName === 'string' && value.siteName !== '' ? value.siteName : DEFAULT_SITE.siteName,
26
+ defaultLang: typeof value.defaultLang === 'string' && isLang(value.defaultLang) ? value.defaultLang : DEFAULT_SITE.defaultLang,
27
+ };
28
+ }
@@ -0,0 +1,4 @@
1
+ /** スラッグの形(正規表現の中身)。小文字の英数字とハイフンで、64 文字まで */
2
+ export declare const SLUG_PATTERN = "[a-z0-9][a-z0-9-]{0,63}";
3
+ export declare const SLUG_RE: RegExp;
4
+ export declare function isValidSlug(slug: string): boolean;
@@ -0,0 +1,6 @@
1
+ /** スラッグの形(正規表現の中身)。小文字の英数字とハイフンで、64 文字まで */
2
+ export const SLUG_PATTERN = '[a-z0-9][a-z0-9-]{0,63}';
3
+ export const SLUG_RE = new RegExp(`^${SLUG_PATTERN}$`);
4
+ export function isValidSlug(slug) {
5
+ return SLUG_RE.test(slug);
6
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from 'zod';
2
+ import { type OpSummary, type RevertOutcome } from './api';
3
+ import type { HistoryDelta, WemaEdge, WemaNote } from './delta';
4
+ /** ツールが入力や状態の問題で実行できなかった。メッセージは LLM に返す */
5
+ export declare class ToolError extends Error {
6
+ }
7
+ export interface PageInfo {
8
+ name: string;
9
+ title: string | null;
10
+ note_count: number;
11
+ updated_at: number;
12
+ /** 親ページのスラッグ。ルートのページ(どのページの子でもない)なら null */
13
+ parent: string | null;
14
+ }
15
+ type AuthoredNote = WemaNote & {
16
+ createdBy: string | null;
17
+ };
18
+ export interface BoardState {
19
+ title: string | null;
20
+ notes: AuthoredNote[];
21
+ edges: WemaEdge[];
22
+ }
23
+ export type ApplyOutcome =
24
+ /** `seq` は記録した操作の番号。何も変わらなかった(値が同じ、対象がもうない)なら null */
25
+ {
26
+ ok: true;
27
+ seq: number | null;
28
+ } | {
29
+ ok: false;
30
+ reason: string;
31
+ };
32
+ export interface BoardAccess {
33
+ listPages(options: {
34
+ updatedAfter?: number;
35
+ limit?: number;
36
+ }): Promise<PageInfo[]>;
37
+ searchPages(query: string): Promise<{
38
+ name: string;
39
+ title: string | null;
40
+ parent: string | null;
41
+ snippet?: string;
42
+ }[]>;
43
+ /** ページがまだ作られていなければ null */
44
+ readBoard(page: string): Promise<BoardState | null>;
45
+ /** 付箋の text(HTML)をプレーンテキストにする */
46
+ plainText(html: string): Promise<string>;
47
+ /**
48
+ * デルタを 1 つの操作として適用する。`summary` は履歴に出る説明。
49
+ * ページがなければ適用せず、`ok: false` を返すこと(ツールはページを作らない)
50
+ */
51
+ apply(page: string, deltas: HistoryDelta[], summary: string): Promise<ApplyOutcome>;
52
+ /** 自分(この agent)の最近の操作 */
53
+ ownOps(page: string): Promise<OpSummary[]>;
54
+ /** 自分の操作を取り消す */
55
+ revert(page: string, seq: number, summary: string): Promise<RevertOutcome>;
56
+ }
57
+ /** ツール 1 つ。`input` は入力のスキーマ、`run` は BoardAccess を使った実装 */
58
+ export interface Tool<S extends z.ZodType = z.ZodType> {
59
+ name: string;
60
+ description: string;
61
+ input: S;
62
+ /** ボードを変更しないか */
63
+ readOnly: boolean;
64
+ run(access: BoardAccess, input: z.infer<S>): Promise<unknown>;
65
+ }
66
+ export declare const TOOLS: Tool[];
67
+ export {};
@@ -0,0 +1,353 @@
1
+ // LLM に公開するツールの定義と実装。
2
+ //
3
+ // 実装は BoardAccess(ボードを読む、デルタを適用する、取り消す)越しに書いてある。リモート MCP では
4
+ // サーバー版(ページの DO と D1 を呼ぶ)を渡す。WebMCP を足すときは、ブラウザ版(wema の API を呼ぶ)を
5
+ // 渡せば、同じツールがそのまま使える。
6
+ import { computeAutoLayout } from '@kanf/wema';
7
+ import { z } from 'zod';
8
+ import { MAX_LIST_LIMIT, MAX_QUERY_LENGTH } from './api';
9
+ import { childPageOf } from './hierarchy';
10
+ import { textToHtml } from './note-text';
11
+ import { SLUG_RE } from './slug';
12
+ /** ツールが入力や状態の問題で実行できなかった。メッセージは LLM に返す */
13
+ export class ToolError extends Error {
14
+ }
15
+ const NOTE_WIDTH = 200;
16
+ const NOTE_HEIGHT = 150;
17
+ const NOTE_COLOR = '#FFF9C4';
18
+ const GAP = 40;
19
+ const MAX_ITEMS = 200;
20
+ const page = z
21
+ .string()
22
+ .regex(SLUG_RE)
23
+ .describe('ページのスラッグ(list_pages の name)');
24
+ const reason = z
25
+ .string()
26
+ .max(200)
27
+ .optional()
28
+ .describe('この操作の説明。操作の履歴に表示され、人が取り消すかどうかの判断に使う');
29
+ const noteText = z
30
+ .string()
31
+ .max(20_000)
32
+ .describe('付箋の本文(プレーンテキスト)。改行できる。"- " で始まる行は箇条書き、"- [ ] " / "- [x] " で始まる行はチェックリストになる。[[スラッグ]] と書くと、そのページへのリンクになる(read_board の本文にも、ページへのリンクは同じ書式で出る)。外部のページへは、URL(http:// か https://)をそのまま書くと、その URL を文字にしたリンクになる。リンクの文字は変えられないので、何のリンクかは URL の前後に文で書く。URL の直後の句読点(. , ; : ! ?)は、URL に含めない。HTML と Markdown のリンク([文字](URL))は書けない');
33
+ const color = z
34
+ .string()
35
+ .regex(/^#[0-9a-fA-F]{6}$/)
36
+ .describe('付箋の色(#RRGGBB)。分類に使う');
37
+ /** 付箋の幅と高さ */
38
+ const noteSize = z.number().min(40).max(2000);
39
+ const overlaps = (a, b) => a.x < b.x + b.width + GAP / 2 &&
40
+ b.x < a.x + a.width + GAP / 2 &&
41
+ a.y < b.y + b.height + GAP / 2 &&
42
+ b.y < a.y + a.height + GAP / 2;
43
+ /** 本文の量から、付箋の高さを見積もる(サーバーでは実際の描画の大きさを測れない) */
44
+ function estimateHeight(text, width) {
45
+ // 1 行に入る文字数は、幅に比例する(幅 200 で、全角 13 文字ほど)
46
+ const perLine = Math.max(1, Math.floor((13 * width) / NOTE_WIDTH));
47
+ const lines = text.split('\n').reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / perLine)), 0);
48
+ return Math.min(Math.max(NOTE_HEIGHT, 40 + lines * 22), 500);
49
+ }
50
+ /** 置き場所を決める。`near` があればその右、なければ既存の付箋の下。重なる間は下へずらす */
51
+ function placeNote(size, near, taken) {
52
+ const rect = near
53
+ ? { ...size, x: near.x + near.width + GAP, y: near.y }
54
+ : { ...size, x: GAP, y: taken.reduce((bottom, r) => Math.max(bottom, r.y + r.height), 0) + GAP };
55
+ // 重なった付箋の下まで、一度にずらす
56
+ for (let hit = taken.find((r) => overlaps(rect, r)); hit; hit = taken.find((r) => overlaps(rect, r))) {
57
+ rect.y = hit.y + hit.height + GAP;
58
+ }
59
+ return rect;
60
+ }
61
+ async function existingBoard(access, slug) {
62
+ const board = await access.readBoard(slug);
63
+ // ページの作成はツールでは行わない(人が画面から作る)
64
+ if (!board)
65
+ throw new ToolError(`page not found: ${slug}`);
66
+ return { ...board, notesById: new Map(board.notes.map((n) => [n.id, n])) };
67
+ }
68
+ async function applyOrThrow(access, slug, deltas, summary) {
69
+ if (deltas.length === 0)
70
+ throw new ToolError('nothing to change');
71
+ const result = await access.apply(slug, deltas, summary);
72
+ if (!result.ok)
73
+ throw new ToolError(result.reason);
74
+ // 操作として記録されていないので、返せる番号がない(別の操作の番号を返すと、取り消しで事故になる)
75
+ if (result.seq === null) {
76
+ throw new ToolError('nothing changed: the board already has these values, or the targets no longer exist');
77
+ }
78
+ return result.seq;
79
+ }
80
+ /** 同じ id を 2 回指定していたら断る(2 件目が、1 件目の結果と食い違って拒否されるため) */
81
+ function assertUnique(ids) {
82
+ const seen = new Set();
83
+ for (const id of ids) {
84
+ if (seen.has(id))
85
+ throw new ToolError(`duplicate id: ${id}`);
86
+ seen.add(id);
87
+ }
88
+ }
89
+ const summaryOf = (tool, why) => (why ? `${tool}: ${why}` : tool);
90
+ function findNote(board, id) {
91
+ const note = board.notesById.get(id);
92
+ if (!note)
93
+ throw new ToolError(`note not found: ${id}`);
94
+ return note;
95
+ }
96
+ const tool = (definition) => definition;
97
+ const ARROW = { fromAnchor: 'auto', toAnchor: 'auto', style: 'arrow' };
98
+ export const TOOLS = [
99
+ tool({
100
+ name: 'list_pages',
101
+ description: 'ページの一覧(名前、表示名、付箋の数、更新日時、親ページ)を、更新の新しい順に返す。updated_at は ISO 8601(UTC)の日時。ページは階層になっていて、parent は親ページの名前(ルートのページなら null)。',
102
+ readOnly: true,
103
+ input: z.object({
104
+ updated_after: z
105
+ .union([z.iso.datetime({ offset: true }), z.iso.date()])
106
+ .optional()
107
+ .describe('この日時より後に更新されたページだけを返す。ISO 8601 の日時(updated_at と同じ書式)か、日付だけ(その日の UTC の 0 時)'),
108
+ limit: z.number().int().min(1).max(MAX_LIST_LIMIT).optional(),
109
+ }),
110
+ async run(access, input) {
111
+ const updatedAfter = input.updated_after === undefined ? undefined : Date.parse(input.updated_after);
112
+ const pages = await access.listPages({ updatedAfter, limit: input.limit });
113
+ // 日時は、数値(UNIX ミリ秒)のままだと読み違えやすいので、文字列にして返す
114
+ return pages.map((p) => ({ ...p, updated_at: new Date(p.updated_at).toISOString() }));
115
+ },
116
+ }),
117
+ tool({
118
+ name: 'search_pages',
119
+ description: 'ページを全文検索する(表示名と付箋の本文)。parent は親ページの名前(ルートのページなら null)。',
120
+ readOnly: true,
121
+ input: z.object({ query: z.string().min(1).max(MAX_QUERY_LENGTH) }),
122
+ run: (access, input) => access.searchPages(input.query),
123
+ }),
124
+ tool({
125
+ name: 'read_board',
126
+ description: '1 ページの付箋(id、本文、座標、大きさ、色、作成者)と接続線(id、from、to、label)を返す。座標は左上が原点で、単位はピクセル。child_page のある付箋は、子ページを表す付箋で、値は子ページの名前(中身は、その名前で read_board を呼ぶと読める)。',
127
+ readOnly: true,
128
+ input: z.object({ page }),
129
+ async run(access, input) {
130
+ const board = await existingBoard(access, input.page);
131
+ return {
132
+ title: board.title,
133
+ notes: await Promise.all(board.notes.map(async (n) => ({
134
+ id: n.id,
135
+ text: await access.plainText(n.text),
136
+ x: n.x,
137
+ y: n.y,
138
+ width: n.width,
139
+ height: n.height,
140
+ color: n.color,
141
+ created_by: n.createdBy,
142
+ // 子ページの付箋だけに付ける
143
+ child_page: childPageOf(n),
144
+ }))),
145
+ edges: board.edges.map((e) => ({ id: e.id, from: e.from, to: e.to, label: e.label })),
146
+ };
147
+ },
148
+ }),
149
+ tool({
150
+ name: 'add_notes',
151
+ description: `付箋を追加する。位置を省略すると、near で指定した付箋の右(なければ既存の付箋の下)に、重ならないように置く。大きさを省略すると、幅は ${NOTE_WIDTH}、高さは本文の量に合わせて自動で決まる(長い本文でも、ふつうは指定しなくてよい)。connect_from を指定すると、その付箋から新しい付箋へ接続線を引く。`,
152
+ readOnly: false,
153
+ input: z.object({
154
+ page,
155
+ notes: z
156
+ .array(z.object({
157
+ text: noteText,
158
+ x: z.number().optional(),
159
+ y: z.number().optional(),
160
+ color: color.optional(),
161
+ width: noteSize.optional().describe(`付箋の幅。省略すると ${NOTE_WIDTH}`),
162
+ height: noteSize
163
+ .optional()
164
+ .describe('付箋の高さ。省略すると、本文の量と幅に合わせて自動で決まる'),
165
+ near: z.string().optional().describe('この id の付箋の近くに置く'),
166
+ connect_from: z.string().optional().describe('この id の付箋から、新しい付箋へ接続線を引く'),
167
+ }))
168
+ .min(1)
169
+ .max(MAX_ITEMS),
170
+ reason,
171
+ }),
172
+ async run(access, input) {
173
+ const board = await existingBoard(access, input.page);
174
+ const taken = [...board.notes];
175
+ const deltas = [];
176
+ const ids = [];
177
+ for (const spec of input.notes) {
178
+ const anchor = spec.near ?? spec.connect_from;
179
+ const near = anchor === undefined ? undefined : findNote(board, anchor);
180
+ const noteWidth = spec.width ?? NOTE_WIDTH;
181
+ const size = { width: noteWidth, height: spec.height ?? estimateHeight(spec.text, noteWidth) };
182
+ if ((spec.x === undefined) !== (spec.y === undefined)) {
183
+ throw new ToolError('x and y must be given together (or both omitted for automatic placement)');
184
+ }
185
+ const rect = spec.x !== undefined && spec.y !== undefined
186
+ ? { ...size, x: spec.x, y: spec.y }
187
+ : placeNote(size, near, taken);
188
+ taken.push(rect);
189
+ const id = crypto.randomUUID();
190
+ ids.push(id);
191
+ deltas.push({
192
+ type: 'note:create',
193
+ note: { id, ...rect, text: textToHtml(spec.text), color: spec.color ?? NOTE_COLOR, zIndex: 1 },
194
+ });
195
+ if (spec.connect_from !== undefined) {
196
+ findNote(board, spec.connect_from);
197
+ deltas.push({
198
+ type: 'edge:create',
199
+ edge: { id: crypto.randomUUID(), from: spec.connect_from, to: id, ...ARROW },
200
+ });
201
+ }
202
+ }
203
+ const seq = await applyOrThrow(access, input.page, deltas, summaryOf('add_notes', input.reason));
204
+ return { operation: seq, note_ids: ids };
205
+ },
206
+ }),
207
+ tool({
208
+ name: 'update_notes',
209
+ description: '付箋の本文、色、位置、大きさを変更する。指定した項目だけが変わる。',
210
+ readOnly: false,
211
+ input: z.object({
212
+ page,
213
+ notes: z
214
+ .array(z.object({
215
+ id: z.string(),
216
+ text: noteText.optional(),
217
+ color: color.optional(),
218
+ x: z.number().optional(),
219
+ y: z.number().optional(),
220
+ width: noteSize.optional(),
221
+ height: noteSize.optional(),
222
+ }))
223
+ .min(1)
224
+ .max(MAX_ITEMS),
225
+ reason,
226
+ }),
227
+ async run(access, input) {
228
+ const board = await existingBoard(access, input.page);
229
+ assertUnique(input.notes.map((n) => n.id));
230
+ const deltas = input.notes.map(({ id, text, ...rest }) => {
231
+ const note = findNote(board, id);
232
+ const after = { ...rest };
233
+ // text の更新には、読んだ時点の text を添える(その後に誰かが編集していたら拒否される)
234
+ const before = {};
235
+ if (text !== undefined) {
236
+ before.text = note.text;
237
+ after.text = textToHtml(text);
238
+ }
239
+ return { type: 'note:update', noteId: id, before, after };
240
+ });
241
+ const seq = await applyOrThrow(access, input.page, deltas, summaryOf('update_notes', input.reason));
242
+ return { operation: seq };
243
+ },
244
+ }),
245
+ tool({
246
+ name: 'delete_notes',
247
+ description: '付箋を削除する。その付箋につながる接続線も削除される。',
248
+ readOnly: false,
249
+ input: z.object({ page, note_ids: z.array(z.string()).min(1).max(MAX_ITEMS), reason }),
250
+ async run(access, input) {
251
+ const board = await existingBoard(access, input.page);
252
+ assertUnique(input.note_ids);
253
+ const deltas = input.note_ids.map((id) => ({ type: 'note:delete', note: findNote(board, id) }));
254
+ const seq = await applyOrThrow(access, input.page, deltas, summaryOf('delete_notes', input.reason));
255
+ return { operation: seq };
256
+ },
257
+ }),
258
+ tool({
259
+ name: 'connect_notes',
260
+ description: '付箋同士を接続線(矢印)でつなぐ。ラベルを付けられる。',
261
+ readOnly: false,
262
+ input: z.object({
263
+ page,
264
+ connections: z
265
+ .array(z.object({ from: z.string(), to: z.string(), label: z.string().max(200).optional() }))
266
+ .min(1)
267
+ .max(MAX_ITEMS),
268
+ reason,
269
+ }),
270
+ async run(access, input) {
271
+ const board = await existingBoard(access, input.page);
272
+ const ids = [];
273
+ const deltas = input.connections.map(({ from, to, label }) => {
274
+ findNote(board, from);
275
+ findNote(board, to);
276
+ const id = crypto.randomUUID();
277
+ ids.push(id);
278
+ const edge = { id, from, to, ...ARROW };
279
+ if (label)
280
+ edge.label = label;
281
+ return { type: 'edge:create', edge };
282
+ });
283
+ const seq = await applyOrThrow(access, input.page, deltas, summaryOf('connect_notes', input.reason));
284
+ return { operation: seq, edge_ids: ids };
285
+ },
286
+ }),
287
+ tool({
288
+ name: 'disconnect_notes',
289
+ description: '接続線を削除する。',
290
+ readOnly: false,
291
+ input: z.object({ page, edge_ids: z.array(z.string()).min(1).max(MAX_ITEMS), reason }),
292
+ async run(access, input) {
293
+ const board = await existingBoard(access, input.page);
294
+ const deltas = input.edge_ids.map((id) => {
295
+ const edge = board.edges.find((e) => e.id === id);
296
+ if (!edge)
297
+ throw new ToolError(`edge not found: ${id}`);
298
+ return { type: 'edge:delete', edge };
299
+ });
300
+ const seq = await applyOrThrow(access, input.page, deltas, summaryOf('disconnect_notes', input.reason));
301
+ return { operation: seq };
302
+ },
303
+ }),
304
+ tool({
305
+ name: 'auto_layout',
306
+ description: '接続線のつながりに基づいて、付箋を階層に並べ直す。note_ids を省略すると、ページの全付箋が対象になる。1 回で動かせる付箋は 2000 枚まで(超える場合は note_ids で対象を絞る)。',
307
+ readOnly: false,
308
+ input: z.object({ page, note_ids: z.array(z.string()).max(2000).optional(), reason }),
309
+ async run(access, input) {
310
+ const board = await existingBoard(access, input.page);
311
+ input.note_ids?.forEach((id) => findNote(board, id));
312
+ const positions = computeAutoLayout(board.notes, board.edges, { noteIds: input.note_ids });
313
+ const deltas = [];
314
+ for (const { id, x, y } of positions) {
315
+ const note = findNote(board, id);
316
+ if (note.x === x && note.y === y)
317
+ continue;
318
+ deltas.push({ type: 'note:update', noteId: id, before: { x: note.x, y: note.y }, after: { x, y } });
319
+ }
320
+ const seq = await applyOrThrow(access, input.page, deltas, summaryOf('auto_layout', input.reason));
321
+ return { operation: seq, moved: deltas.length };
322
+ },
323
+ }),
324
+ tool({
325
+ name: 'revert_operation',
326
+ description: '自分が行った操作を取り消す。operation を省略すると、まだ取り消していない直前の操作が対象になる。その後に人が変更した付箋は戻さず、skipped で返す。何も戻せなかった場合(applied が 0)は、それより前の自分の操作の番号を earlier_operations で返すので、必要なら operation に指定して呼び直す。',
327
+ readOnly: false,
328
+ input: z.object({
329
+ page,
330
+ operation: z.number().int().optional().describe('取り消す操作の番号(各ツールが返す operation)'),
331
+ reason,
332
+ }),
333
+ async run(access, input) {
334
+ // ページの存在は確かめない(ページがなければ、操作の一覧が空で、取り消しも断られる)
335
+ let seq = input.operation;
336
+ let earlier = [];
337
+ if (seq === undefined) {
338
+ // 取り消しそのもの(reverts がある)と、取り消し済みのものは対象にしない
339
+ const candidates = (await access.ownOps(input.page))
340
+ .filter((op) => op.revertedBy === null && op.reverts === null)
341
+ .map((op) => op.seq);
342
+ if (candidates.length === 0)
343
+ throw new ToolError('no operation to revert');
344
+ [seq, ...earlier] = candidates;
345
+ }
346
+ const outcome = await access.revert(input.page, seq, summaryOf('revert_operation', input.reason));
347
+ const result = { reverted: seq, operation: outcome.seq, applied: outcome.applied, skipped: outcome.skipped };
348
+ // 何も戻せなかった操作は取り消し済みにならず、省略時にまた選ばれる。それより前の操作を
349
+ // 取り消したいときに番号を指定できるよう、候補を返す
350
+ return outcome.applied === 0 && earlier.length > 0 ? { ...result, earlier_operations: earlier } : result;
351
+ },
352
+ }),
353
+ ];