friday-mcp-v2 3.0.0 → 3.0.2

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 (2) hide show
  1. package/dist/mcp-server.js +1788 -100
  2. package/package.json +1 -1
@@ -11,6 +11,7 @@ import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextpro
11
11
  import { FridayWPClient, ConnectionRegistry } from "./wordpress-api.js";
12
12
  import { BridgeClient } from "./bridge-client.js";
13
13
  import fetch from "node-fetch";
14
+ import { randomUUID } from "node:crypto";
14
15
  import { readFileSync, statSync, realpathSync } from "node:fs";
15
16
  import { execFile } from "node:child_process";
16
17
  import { resolve as resolvePath, sep, dirname } from "node:path";
@@ -102,6 +103,207 @@ const statusCache = {
102
103
  }
103
104
  };
104
105
 
106
+ // ========================================
107
+ // Snapshot キャッシュ(Phase 1: インデックスズレ対処)
108
+ // ========================================
109
+
110
+ /**
111
+ * djb2 ハッシュ → base36 文字列
112
+ * @param {string} str
113
+ * @returns {string}
114
+ */
115
+ function simpleHash(str) {
116
+ let hash = 5381;
117
+ for (let i = 0; i < str.length; i++) {
118
+ hash = ((hash << 5) + hash) + str.charCodeAt(i);
119
+ hash = hash & hash;
120
+ }
121
+ return (hash >>> 0).toString(36);
122
+ }
123
+
124
+ function generateSnapshotId() {
125
+ return 'snap_' + randomUUID().replace(/-/g, '').slice(0, 12);
126
+ }
127
+
128
+ function generateBlockRef(index) {
129
+ return 'r' + index;
130
+ }
131
+
132
+ /**
133
+ * ブロックの fingerprint を生成
134
+ * @param {object} block - state.allBlocks の要素
135
+ * @param {string} mode - "editor" | "headless"
136
+ * @returns {string}
137
+ */
138
+ function computeFingerprint(block, mode) {
139
+ const type = block.type || 'unknown';
140
+ let contentSource;
141
+ if (mode === 'headless' && block.html) {
142
+ contentSource = block.html.slice(0, 200);
143
+ } else if (block.attributes) {
144
+ contentSource = JSON.stringify(block.attributes).slice(0, 200);
145
+ } else {
146
+ contentSource = '';
147
+ }
148
+ return type + ':' + simpleHash(contentSource);
149
+ }
150
+
151
+ /**
152
+ * revision を計算(内容ベースハッシュ)
153
+ * serialize_blocks 相当の粒度で内容変更を検知する
154
+ * @param {{ allBlocks: Array }} state
155
+ * @param {string} mode
156
+ * @returns {string}
157
+ */
158
+ function computeRevision(state, mode) {
159
+ const source = state.allBlocks.map(b => {
160
+ const content = (mode === 'headless' && b.html)
161
+ ? b.html.slice(0, 200)
162
+ : JSON.stringify(b.attributes || {}).slice(0, 200);
163
+ return `${b.type}:${b.index}:${content}`;
164
+ }).join('|');
165
+ return 'rev_' + simpleHash(source);
166
+ }
167
+
168
+ /**
169
+ * 既に取得済みの currentState から snapshot を組み立て、キャッシュに登録して返す。
170
+ * buildResponseSnapshot() の「state を受け取る版」— getCurrentStructure() を呼ばない。
171
+ */
172
+ function buildSnapshotFromState(currentState, mode, postId, sessionId, siteName) {
173
+ if (!currentState?.allBlocks || currentState.allBlocks.length === 0) return null;
174
+
175
+ const newSnapshotId = generateSnapshotId();
176
+ const newSnapshotBlocks = buildSnapshotBlocks(currentState, mode);
177
+ const newRevision = computeRevision(currentState, mode);
178
+
179
+ const snapshotRecord = {
180
+ snapshotId: newSnapshotId, postId, mode,
181
+ sessionId: mode === 'editor' ? sessionId : null,
182
+ siteName: siteName || 'default',
183
+ createdAt: Date.now(),
184
+ revision: newRevision,
185
+ blocks: newSnapshotBlocks,
186
+ displayMode: 'full',
187
+ };
188
+ snapshotCache.set(snapshotRecord);
189
+
190
+ return {
191
+ snapshotId: newSnapshotId,
192
+ revision: newRevision,
193
+ blocks: newSnapshotBlocks.map(b => ({
194
+ ref: b.ref, index: b.index, type: b.type,
195
+ depth: b.depth, parentIndex: b.parentIndex,
196
+ })),
197
+ };
198
+ }
199
+
200
+ /**
201
+ * expectedRevision と現在の revision を比較する。
202
+ * 一致すれば null を返す。不一致の場合は最新 snapshot 付きのエラーレスポンスを返す。
203
+ * currentState から直接 snapshot を組み立てるため、追加の通信コストなし。
204
+ * @returns {object|null} null=一致(続行可)、object=不一致エラーレスポンス
205
+ */
206
+ function checkRevisionMismatch(expectedRevision, currentState, mode, postId, sessionId, siteName) {
207
+ const currentRevision = computeRevision(currentState, mode);
208
+ if (expectedRevision === currentRevision) return null;
209
+
210
+ // 不一致 — snapshot をキャッシュに登録(AI が新 snapshotId で refetch 可能に)
211
+ // Phase 2: blocks 一覧は出力しない(コンテキスト軽量化)
212
+ const latestSnapshot = buildSnapshotFromState(currentState, mode, postId, sessionId, siteName);
213
+ const snapshotLine = latestSnapshot
214
+ ? `\n[snapshot:${latestSnapshot.snapshotId} rev:${latestSnapshot.revision}]`
215
+ : '';
216
+ const text = `❌ REVISION_MISMATCH: expected ${expectedRevision}, actual ${currentRevision}` +
217
+ snapshotLine +
218
+ `\n→ get_article_structure で最新を取得してください`;
219
+ return { content: [{ type: "text", text }], isError: true };
220
+ }
221
+
222
+ /**
223
+ * state.allBlocks から SnapshotBlock[] を構築
224
+ * @param {{ allBlocks: Array }} state
225
+ * @param {string} mode
226
+ * @returns {Array}
227
+ */
228
+ function buildSnapshotBlocks(state, mode) {
229
+ return state.allBlocks.map(b => ({
230
+ ref: generateBlockRef(b.index),
231
+ index: b.index,
232
+ type: b.type,
233
+ depth: b.depth || 0,
234
+ parentIndex: b.parentIndex ?? null,
235
+ fingerprint: computeFingerprint(b, mode),
236
+ section: b.section || null,
237
+ }));
238
+ }
239
+
240
+ const snapshotCache = {
241
+ /** @type {Map<string, object>} snapshotId → SnapshotRecord */
242
+ _entries: new Map(),
243
+ /** @type {Map<string, string[]>} "postId:siteName" → snapshotId[] (FIFO) */
244
+ _byArticle: new Map(),
245
+ TTL: 300_000,
246
+ MAX_PER_ARTICLE: 3,
247
+
248
+ /**
249
+ * snapshotId で取得(TTL チェック付き)
250
+ * @param {string} snapshotId
251
+ * @returns {object|null}
252
+ */
253
+ get(snapshotId) {
254
+ const entry = this._entries.get(snapshotId);
255
+ if (!entry) return null;
256
+ if ((Date.now() - entry.createdAt) >= this.TTL) {
257
+ this._remove(snapshotId);
258
+ return null;
259
+ }
260
+ return entry;
261
+ },
262
+
263
+ /**
264
+ * snapshot を登録(記事単位 eviction 付き)
265
+ * @param {object} record - SnapshotRecord
266
+ */
267
+ set(record) {
268
+ const articleKey = `${record.postId}:${record.siteName}`;
269
+ let articleSnaps = this._byArticle.get(articleKey) || [];
270
+ articleSnaps.push(record.snapshotId);
271
+ while (articleSnaps.length > this.MAX_PER_ARTICLE) {
272
+ const oldest = articleSnaps.shift();
273
+ this._entries.delete(oldest);
274
+ }
275
+ this._byArticle.set(articleKey, articleSnaps);
276
+ this._entries.set(record.snapshotId, record);
277
+ },
278
+
279
+ /** @param {string} snapshotId */
280
+ _remove(snapshotId) {
281
+ const entry = this._entries.get(snapshotId);
282
+ if (entry) {
283
+ const articleKey = `${entry.postId}:${entry.siteName}`;
284
+ const articleSnaps = this._byArticle.get(articleKey);
285
+ if (articleSnaps) {
286
+ const idx = articleSnaps.indexOf(snapshotId);
287
+ if (idx >= 0) articleSnaps.splice(idx, 1);
288
+ if (articleSnaps.length === 0) this._byArticle.delete(articleKey);
289
+ }
290
+ }
291
+ this._entries.delete(snapshotId);
292
+ },
293
+
294
+ /** @param {string} [siteName] 指定時はそのサイトのみ。省略時は全破棄。 */
295
+ clear(siteName) {
296
+ if (siteName) {
297
+ for (const [id, record] of this._entries) {
298
+ if (record.siteName === siteName) this._remove(id);
299
+ }
300
+ } else {
301
+ this._entries.clear();
302
+ this._byArticle.clear();
303
+ }
304
+ }
305
+ };
306
+
105
307
  // ヘルパー関数: 正規表現用に文字列をエスケープ
106
308
  function escapeRegExp(string) {
107
309
  return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -536,8 +738,17 @@ const targetSchema = {
536
738
  required: ["level", "contains"],
537
739
  description: "Select from heading to next same-level heading.",
538
740
  },
741
+ ref: {
742
+ type: "string",
743
+ description: "Block ref from snapshot (e.g. 'r5'). Requires snapshotId.",
744
+ },
745
+ refs: {
746
+ type: "array",
747
+ items: { type: "string" },
748
+ description: "Multiple block refs from snapshot. Requires snapshotId.",
749
+ },
539
750
  },
540
- description: "Target: one of selected/index/indices/range/heading + optional filters.",
751
+ description: "Target: one of selected/index/indices/range/heading/ref/refs + optional filters.",
541
752
  };
542
753
 
543
754
  const targetSchemaNoSelected = {
@@ -574,12 +785,16 @@ function normalizeTarget(target) {
574
785
  target.indices && 'indices',
575
786
  target.range && 'range',
576
787
  target.heading && 'heading',
788
+ target.ref && 'ref',
789
+ target.refs && 'refs',
577
790
  ].filter(Boolean);
578
791
  if (primaries.length > 1) {
579
792
  throw new Error(`target に複数の primary selector が指定されています: ${primaries.join(', ')}。1つだけ指定してください。`);
580
793
  }
581
794
 
582
795
  const result = {};
796
+ if (target.ref) result._ref = target.ref;
797
+ if (target.refs) result._refs = target.refs;
583
798
  if (target.selected) result.target = "selected";
584
799
  if (target.index !== undefined) result.index = target.index;
585
800
  if (target.indices) result.indices = target.indices;
@@ -628,6 +843,1051 @@ function normalizeInsert(insert) {
628
843
  return { insertOnly: true, insertPosition: pos };
629
844
  }
630
845
 
846
+ // ========================================
847
+ // ref 解決ヘルパー(Phase 2a)
848
+ // ========================================
849
+
850
+ /**
851
+ * 現在のブロック構造を取得する(ref 解決・レスポンス snapshot 用)
852
+ * @param {string} mode - "editor" | "headless"
853
+ * @param {object} client - WordPressAPI インスタンス
854
+ * @param {number} postId
855
+ * @param {string|null} sessionId
856
+ * @returns {Promise<object>} { allBlocks: Block[] }
857
+ */
858
+ async function getCurrentStructure(mode, client, postId, sessionId, { safetyCritical = false } = {}) {
859
+ if (mode === 'editor') {
860
+ // Phase 3.1: editor からライブ状態を即時取得(session store キャッシュをバイパス)
861
+ try {
862
+ const result = await client.sendEditorCommand(
863
+ 'get_fresh_state', {}, 10000, postId, sessionId
864
+ );
865
+ if (result?.success && result.allBlocks) {
866
+ return result;
867
+ }
868
+ } catch (_e) {
869
+ // get_fresh_state 失敗
870
+ }
871
+ // safety-critical 経路: stale キャッシュにフォールバックしない
872
+ if (safetyCritical) {
873
+ throw new Error('editor のライブ状態を取得できませんでした。拡張機能の接続を確認してください。');
874
+ }
875
+ // 非 safety-critical: 従来キャッシュにフォールバック
876
+ return await client.getEditorState(postId, sessionId);
877
+ } else {
878
+ const structure = await client.headlessGetStructure(postId);
879
+ const blocksData = await client.headlessGetBlocks(postId);
880
+ structure.allBlocks = blocksData.blocks;
881
+ return structure;
882
+ }
883
+ }
884
+
885
+ /**
886
+ * snapshot の ref を現在構造上の index に解決する
887
+ * @param {string} snapshotId
888
+ * @param {string} ref
889
+ * @param {string} mode - 現在の mode
890
+ * @param {string|null} sessionId - 現在の sessionId
891
+ * @param {number} postId
892
+ * @param {object} currentState - getCurrentStructure() の結果(キャッシュ用)
893
+ * @returns {number} 解決された現在の index
894
+ * @throws {Error} 解決不能な場合
895
+ */
896
+ function resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState) {
897
+ // 1. snapshot 取得
898
+ const snap = snapshotCache.get(snapshotId);
899
+ if (!snap) {
900
+ throw new Error(`snapshot "${snapshotId}" が見つかりません(期限切れまたは無効)。get_article_structure を再取得してください。`);
901
+ }
902
+
903
+ // 2. 状態ソース束縛チェック
904
+ if (snap.mode !== mode) {
905
+ throw new Error(`snapshot は ${snap.mode} モードで取得されましたが、現在は ${mode} モードです。get_article_structure を再取得してください。`);
906
+ }
907
+ if (mode === 'editor' && snap.sessionId && sessionId && snap.sessionId !== sessionId) {
908
+ throw new Error(`snapshot は sessionId "${snap.sessionId}" で取得されましたが、現在の sessionId は "${sessionId}" です。get_article_structure を再取得してください。`);
909
+ }
910
+ if (snap.postId !== postId) {
911
+ throw new Error(`snapshot は postId ${snap.postId} 用ですが、postId ${postId} に対して使用されています。`);
912
+ }
913
+
914
+ // 3. snapshot 内の ref 情報を取得
915
+ const snapBlock = snap.blocks.find(b => b.ref === ref);
916
+ if (!snapBlock) {
917
+ throw new Error(`ref "${ref}" は snapshot "${snapshotId}" 内に存在しません。`);
918
+ }
919
+
920
+ // 4. 現在構造の検証
921
+ const currentBlocks = currentState?.allBlocks;
922
+ if (!currentBlocks || currentBlocks.length === 0) {
923
+ throw new Error('現在のブロック構造を取得できませんでした。');
924
+ }
925
+
926
+ // 5. fingerprint 照合(2段、曖昧ならエラー)
927
+ const candidates = [];
928
+ for (const cb of currentBlocks) {
929
+ const fp = computeFingerprint(cb, mode);
930
+ if (fp === snapBlock.fingerprint) {
931
+ candidates.push(cb);
932
+ }
933
+ }
934
+
935
+ // 5a. 完全一致 1件 → 確定
936
+ if (candidates.length === 1) return candidates[0].index;
937
+
938
+ // 5b. 完全一致 複数件 → depth + parentIndex で絞り込み
939
+ if (candidates.length > 1) {
940
+ const refined = candidates.filter(c =>
941
+ (c.depth || 0) === snapBlock.depth &&
942
+ (c.parentIndex ?? null) === snapBlock.parentIndex
943
+ );
944
+ if (refined.length === 1) return refined[0].index;
945
+ throw new Error(
946
+ `ref "${ref}" (type: ${snapBlock.type}) の解決先が ${refined.length > 0 ? refined.length : candidates.length} 件見つかりました。` +
947
+ `一意に特定できません。get_article_structure を再取得してください。`
948
+ );
949
+ }
950
+
951
+ // 5c. 一致 0件 → エラー
952
+ throw new Error(
953
+ `ref "${ref}" (${snapBlock.type}, fingerprint: ${snapBlock.fingerprint}) に一致するブロックが見つかりません。` +
954
+ `構造が大幅に変更された可能性があります。get_article_structure を再取得してください。`
955
+ );
956
+ }
957
+
958
+ /**
959
+ * safety-critical な fresh state 取得 + expectedRevision チェック。
960
+ * state 取得と revision 判定の入口を統一し、分岐ごとのズレを防ぐ。
961
+ * @returns {{ currentState, error? }}
962
+ */
963
+ async function acquireFreshState({
964
+ expectedRevision, mode, client, postId, sessionId, siteName,
965
+ }) {
966
+ let currentState;
967
+ try {
968
+ currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
969
+ } catch (e) {
970
+ return { error: {
971
+ content: [{ type: "text", text: `❌ 構造取得に失敗しました: ${e.message}${expectedRevision ? '\nexpectedRevision が指定されているため、更新を中止します。' : ''}` }],
972
+ isError: true,
973
+ }};
974
+ }
975
+ if (expectedRevision) {
976
+ const mismatch = checkRevisionMismatch(expectedRevision, currentState, mode, postId, sessionId, siteName);
977
+ if (mismatch) return { error: mismatch };
978
+ }
979
+ return { currentState };
980
+ }
981
+
982
+ /**
983
+ * ref/refs → index/indices 解決 + expectedRevision チェックの共通処理。
984
+ * delete_block / duplicate_block / update_blocks / table_operations で共通利用する。
985
+ * @returns {{ index?, indices?, error? }}
986
+ * - 成功: { index } or { indices } or {}(ref なしで revision OK の場合)
987
+ * - 失敗: { error: MCP エラーレスポンス }(呼び出し元は return error で即返却)
988
+ */
989
+ async function resolveRefsAndCheckRevision({
990
+ snapshotId, ref, refs, expectedRevision,
991
+ mode, client, postId, sessionId, siteName,
992
+ }) {
993
+ const hasRef = ref || refs;
994
+
995
+ // Case 1: ref/refs 指定あり
996
+ if (hasRef) {
997
+ if (!snapshotId) {
998
+ return { error: {
999
+ content: [{ type: "text", text: "❌ ref/refs を使用するには snapshotId が必要です。get_article_structure で取得してください。" }],
1000
+ isError: true,
1001
+ }};
1002
+ }
1003
+ const { currentState, error } = await acquireFreshState({
1004
+ expectedRevision, mode, client, postId, sessionId, siteName,
1005
+ });
1006
+ if (error) return { error };
1007
+ try {
1008
+ if (ref) {
1009
+ const index = resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState);
1010
+ return { index, currentState };
1011
+ }
1012
+ if (refs) {
1013
+ const indices = refs.map(r => resolveRefFromState(snapshotId, r, mode, sessionId, postId, currentState));
1014
+ return { indices, currentState };
1015
+ }
1016
+ } catch (e) {
1017
+ return { error: {
1018
+ content: [{ type: "text", text: `❌ ref 解決エラー: ${e.message}` }],
1019
+ isError: true,
1020
+ }};
1021
+ }
1022
+ }
1023
+
1024
+ // Case 2: ref なし + expectedRevision のみ
1025
+ if (expectedRevision) {
1026
+ const { currentState, error } = await acquireFreshState({
1027
+ expectedRevision, mode, client, postId, sessionId, siteName,
1028
+ });
1029
+ if (error) return { error };
1030
+ return { currentState };
1031
+ }
1032
+
1033
+ // Case 3: どちらもなし
1034
+ return {};
1035
+ }
1036
+
1037
+ /**
1038
+ * 下流レスポンスの blocks を buildSnapshotBlocks / computeRevision が
1039
+ * 期待する state.allBlocks 形式に正規化する。
1040
+ * @param {Array} blocks - 下流 formatBlocksForSnapshot() の結果
1041
+ * @returns {{ allBlocks: Array }}
1042
+ */
1043
+ function normalizeDownstreamBlocks(blocks) {
1044
+ return {
1045
+ allBlocks: blocks.map(b => ({
1046
+ index: b.index,
1047
+ type: b.type,
1048
+ html: b.html || '',
1049
+ attributes: b.attributes || {},
1050
+ section: b.section || null,
1051
+ depth: b.depth || 0,
1052
+ parentIndex: b.parentIndex ?? null,
1053
+ })),
1054
+ };
1055
+ }
1056
+
1057
+ /**
1058
+ * 下流レスポンスの result.blocks から snapshot を構築する。
1059
+ * blocks がない場合は null を返す(呼び出し元でフォールバック判断)。
1060
+ * @param {object} result - 下流レスポンス
1061
+ * @param {string} mode
1062
+ * @param {number} postId
1063
+ * @param {string|null} sessionId
1064
+ * @param {string} siteName
1065
+ * @returns {object|null} { snapshotId, revision, blocks[] } or null
1066
+ */
1067
+ function buildResponseSnapshotFromResult(result, mode, postId, sessionId, siteName) {
1068
+ const blocks = result?.blocks;
1069
+ if (!blocks || !Array.isArray(blocks) || blocks.length === 0) {
1070
+ return null;
1071
+ }
1072
+ const state = normalizeDownstreamBlocks(blocks);
1073
+ return buildSnapshotFromState(state, mode, postId, sessionId, siteName);
1074
+ }
1075
+
1076
+ /**
1077
+ * 更新後のレスポンス snapshot を構築する。
1078
+ * result が渡され、result.blocks があればそこから構築(再取得不要)。
1079
+ * なければ従来通り getCurrentStructure() で再取得する。
1080
+ * @param {string} mode
1081
+ * @param {object} client
1082
+ * @param {number} postId
1083
+ * @param {string|null} sessionId
1084
+ * @param {string} siteName
1085
+ * @param {object} [result] - 下流レスポンス(Phase 1: blocks 再利用)
1086
+ * @returns {Promise<object|null>} { snapshotId, revision, blocks[] } or null
1087
+ */
1088
+ async function buildResponseSnapshot(mode, client, postId, sessionId, siteName, result) {
1089
+ // Phase 1: 下流 result.blocks があればそこから構築(再取得不要)
1090
+ if (result) {
1091
+ const fromResult = buildResponseSnapshotFromResult(result, mode, postId, sessionId, siteName);
1092
+ if (fromResult) return fromResult;
1093
+ }
1094
+ let newState;
1095
+ try {
1096
+ newState = await getCurrentStructure(mode, client, postId, sessionId);
1097
+ } catch {
1098
+ return null;
1099
+ }
1100
+
1101
+ if (!newState?.allBlocks || newState.allBlocks.length === 0) {
1102
+ return null;
1103
+ }
1104
+
1105
+ const newSnapshotId = generateSnapshotId();
1106
+ const newSnapshotBlocks = buildSnapshotBlocks(newState, mode);
1107
+ const newRevision = computeRevision(newState, mode);
1108
+
1109
+ const snapshotRecord = {
1110
+ snapshotId: newSnapshotId,
1111
+ postId,
1112
+ mode,
1113
+ sessionId: mode === 'editor' ? sessionId : null,
1114
+ siteName: siteName || 'default',
1115
+ createdAt: Date.now(),
1116
+ revision: newRevision,
1117
+ blocks: newSnapshotBlocks,
1118
+ displayMode: 'full',
1119
+ };
1120
+ snapshotCache.set(snapshotRecord);
1121
+
1122
+ return {
1123
+ snapshotId: newSnapshotId,
1124
+ revision: newRevision,
1125
+ blocks: newSnapshotBlocks.map(b => ({
1126
+ ref: b.ref,
1127
+ index: b.index,
1128
+ type: b.type,
1129
+ depth: b.depth,
1130
+ parentIndex: b.parentIndex,
1131
+ })),
1132
+ };
1133
+ }
1134
+
1135
+ /**
1136
+ * @deprecated Phase 7 で削除予定。batch 経路および index 経路の後方互換用。
1137
+ * snapshot 情報をテキストレスポンスに付加する
1138
+ * blocks[] を含めて AI が新しい ref を取得できるようにする
1139
+ * @param {string} text - 元のレスポンステキスト
1140
+ * @param {object|null} snapshot - buildResponseSnapshot の結果
1141
+ * @param {object} [refInfo] - 単体操作の ref 解決情報(1→N 展開追跡用)
1142
+ * @param {string} [refInfo.oldSnapshotId] - 元の snapshotId
1143
+ * @param {string} [refInfo.usedRef] - 使用した ref
1144
+ * @param {number} [refInfo.resolvedIndex] - 解決された元の index
1145
+ * @returns {string} snapshot 行と blocks 一覧が付加されたテキスト
1146
+ */
1147
+ function appendSnapshotToTextLegacy(text, snapshot, refInfo) {
1148
+ if (!snapshot) return text;
1149
+
1150
+ // 1→N 展開チェック(単体操作時のみ)
1151
+ let expandedLine = '';
1152
+ if (refInfo?.oldSnapshotId && refInfo?.usedRef) {
1153
+ const oldSnap = snapshotCache.get(refInfo.oldSnapshotId);
1154
+ if (oldSnap) {
1155
+ const oldCount = oldSnap.blocks.length;
1156
+ const newCount = snapshot.blocks.length;
1157
+ const delta = newCount - oldCount;
1158
+ if (delta > 0) {
1159
+ // 単体操作での展開: 元 index 位置から delta+1 個が新ブロック
1160
+ const newRefs = snapshot.blocks
1161
+ .filter(b => b.index >= refInfo.resolvedIndex && b.index < refInfo.resolvedIndex + 1 + delta)
1162
+ .map(b => b.ref);
1163
+ expandedLine = `\nexpanded: ${refInfo.usedRef} → [${newRefs.join(', ')}]`;
1164
+ }
1165
+ }
1166
+ }
1167
+
1168
+ let out = text + `\n\n[snapshot:${snapshot.snapshotId} rev:${snapshot.revision}]${expandedLine}`;
1169
+ if (snapshot.blocks && snapshot.blocks.length > 0) {
1170
+ out += '\nblocks:';
1171
+ for (const b of snapshot.blocks) {
1172
+ const parent = b.parentIndex !== null ? `,p:${b.parentIndex}` : '';
1173
+ out += `\n [${b.index}|${b.ref}] ${b.type} (d:${b.depth}${parent})`;
1174
+ }
1175
+ }
1176
+ return out;
1177
+ }
1178
+
1179
+ /**
1180
+ * Phase 5: update_blocks / insert_block の差分/Legacy 自動分岐。
1181
+ * inputRef が null なら即 Legacy。差分構築失敗時も Legacy フォールバック。
1182
+ * @param {string} text - メッセージ本文
1183
+ * @param {object|null} snap - buildResponseSnapshot の結果
1184
+ * @param {object} result - 下流レスポンス
1185
+ * @param {object|null} preState - _preState
1186
+ * @param {string|null} inputRef - 入力 ref(null = Legacy 固定)
1187
+ * @param {boolean} isInsert - insertOnly 操作か
1188
+ * @param {object|null} refInfo - Legacy フォールバック用 _refInfo
1189
+ * @returns {string}
1190
+ */
1191
+ function buildUpdateDiffResponse(text, snap, result, preState, inputRef, isInsert, refInfo) {
1192
+ if (!inputRef || !snap) {
1193
+ return appendSnapshotToTextLegacy(text, snap, refInfo);
1194
+ }
1195
+
1196
+ if (isInsert) {
1197
+ const changeInfo = buildChangeInfoFromResult('inserted', snap, result, preState);
1198
+ if (changeInfo) return appendRefChangesToText(text, changeInfo);
1199
+ return appendSnapshotToTextLegacy(text, snap, refInfo);
1200
+ }
1201
+
1202
+ // update / expand 判定
1203
+ // success:false の change を除外(invalid regex, parse failure 等)
1204
+ const changes = result?.changes?.filter(c => c.success !== false);
1205
+ if (!changes || changes.length === 0) {
1206
+ return appendSnapshotToTextLegacy(text, snap, refInfo);
1207
+ }
1208
+ // filtered 済み changes で result を差し替え(buildChangeInfoFromResult が result.changes を直接参照するため)
1209
+ const filteredResult = { ...result, changes };
1210
+ if (changes.length === 1 && changes[0]?.newIndices?.length > 1) {
1211
+ // 1→N 展開(単体 target のみ)
1212
+ const changeInfo = buildChangeInfoFromResult('expanded', snap, filteredResult, preState, { inputRef });
1213
+ if (changeInfo) return appendRefChangesToText(text, changeInfo);
1214
+ } else if (changes.length === 1) {
1215
+ // 1→1 更新(単体 ref で成功 1 件のときだけ差分化)
1216
+ const changeInfo = buildChangeInfoFromResult('updated', snap, filteredResult, preState);
1217
+ if (changeInfo) return appendRefChangesToText(text, changeInfo);
1218
+ }
1219
+
1220
+ return appendSnapshotToTextLegacy(text, snap, refInfo);
1221
+ }
1222
+
1223
+ // ============================================================
1224
+ // Phase 2: ref 変化分類ロジック(差分通知方式の基盤)
1225
+ // ============================================================
1226
+
1227
+ /**
1228
+ * @typedef {Object} DeletedChange
1229
+ * @property {string} snapshotId
1230
+ * @property {string} revision
1231
+ * @property {'deleted'} type
1232
+ * @property {string[]} deletedRefs - 削除された ref 配列 ['r5', 'r8']
1233
+ * @property {boolean} [refetchRequired]
1234
+ */
1235
+
1236
+ /**
1237
+ * @typedef {Object} InsertedChange
1238
+ * @property {string} snapshotId
1239
+ * @property {string} revision
1240
+ * @property {'inserted'} type
1241
+ * @property {Array<{index: number, ref: string, blockType: string, depth: number}>} inserted
1242
+ * @property {boolean} [refetchRequired]
1243
+ */
1244
+
1245
+ /**
1246
+ * @typedef {Object} MovedChange
1247
+ * @property {string} snapshotId
1248
+ * @property {string} revision
1249
+ * @property {'moved'} type
1250
+ * @property {Array<{oldRef: string, newRef: string}>} moved
1251
+ * @property {boolean} [refetchRequired]
1252
+ */
1253
+
1254
+ /**
1255
+ * @typedef {Object} UpdatedChange
1256
+ * @property {string} snapshotId
1257
+ * @property {string} revision
1258
+ * @property {'updated'} type
1259
+ * @property {string[]} updatedRefs - fingerprint 変化した ref 配列
1260
+ * @property {boolean} [refetchRequired]
1261
+ */
1262
+
1263
+ /**
1264
+ * @typedef {Object} ExpandedChange
1265
+ * @property {string} snapshotId
1266
+ * @property {string} revision
1267
+ * @property {'expanded'} type
1268
+ * @property {{oldRef: string, newRefs: string[]}} expanded
1269
+ * @property {boolean} [refetchRequired]
1270
+ */
1271
+
1272
+ /**
1273
+ * @typedef {Object} DuplicatedChange
1274
+ * @property {string} snapshotId
1275
+ * @property {string} revision
1276
+ * @property {'duplicated'} type
1277
+ * @property {string} sourceRef
1278
+ * @property {string} newRef
1279
+ * @property {number} sourceIndex
1280
+ * @property {number} newIndex
1281
+ * @property {boolean} [refetchRequired]
1282
+ */
1283
+
1284
+ /**
1285
+ * @typedef {DeletedChange|InsertedChange|MovedChange|UpdatedChange|ExpandedChange|DuplicatedChange} ChangeInfo
1286
+ */
1287
+
1288
+ /**
1289
+ * ref 変化情報をテキストに付加する��差分モード)��
1290
+ * blocks 一��は出力しない。
1291
+ * @param {string} text
1292
+ * @param {ChangeInfo|null} changeInfo
1293
+ * @returns {string}
1294
+ */
1295
+ function appendRefChangesToText(text, changeInfo) {
1296
+ if (!changeInfo) return text;
1297
+
1298
+ let out = text + `\n\n[snapshot:${changeInfo.snapshotId} rev:${changeInfo.revision}]`;
1299
+
1300
+ switch (changeInfo.type) {
1301
+ case 'deleted':
1302
+ out += `\ndeleted: [${changeInfo.deletedRefs.join(', ')}] (invalid in new snapshot)`;
1303
+ break;
1304
+ case 'inserted':
1305
+ out += '\ninserted: ' + changeInfo.inserted
1306
+ .map(b => `[${b.index}|${b.ref}] ${b.blockType} (d:${b.depth})`)
1307
+ .join(', ');
1308
+ break;
1309
+ case 'moved':
1310
+ out += '\nmoved: ' + changeInfo.moved
1311
+ .map(m => `${m.oldRef}\u2192${m.newRef}`)
1312
+ .join(', ');
1313
+ break;
1314
+ case 'updated':
1315
+ out += `\nupdated: [${changeInfo.updatedRefs.join(', ')}] (use new snapshot)`;
1316
+ break;
1317
+ case 'expanded':
1318
+ out += `\nexpanded: ${changeInfo.expanded.oldRef} \u2192 [${changeInfo.expanded.newRefs.join(', ')}]`;
1319
+ break;
1320
+ case 'duplicated':
1321
+ out += `\nduplicated: ${changeInfo.sourceRef} \u2192 ${changeInfo.newRef} (source:[${changeInfo.sourceIndex}], new:[${changeInfo.newIndex}]) (use new snapshot)`;
1322
+ break;
1323
+ }
1324
+
1325
+ if (changeInfo.refetchRequired) {
1326
+ out += '\n\u26a0 refetchRequired: \u5b50\u30d6\u30ed\u30c3\u30af\u5909\u66f4\u3042\u308a\u3002get_article_structure \u3067\u6700\u65b0\u3092\u53d6\u5f97\u3057\u3066\u304f\u3060\u3055\u3044\u3002';
1327
+ }
1328
+
1329
+ return out;
1330
+ }
1331
+
1332
+ /**
1333
+ * @typedef {Object} BatchDiffEntry
1334
+ * @property {number} opIndex
1335
+ * @property {'updated'|'expanded'|'failed'|'skipped'} type
1336
+ * @property {string} [ref] - updated/failed/skipped 時
1337
+ * @property {string} [oldRef] - expanded 時
1338
+ * @property {string[]} [newRefs] - expanded 時
1339
+ * @property {string} [error] - failed 時
1340
+ * @property {boolean} [refetchRequired] - この entry 単体の refetch 判定
1341
+ */
1342
+
1343
+ /**
1344
+ * @typedef {Object} BatchDiffInfo
1345
+ * @property {string} snapshotId
1346
+ * @property {string} revision
1347
+ * @property {'batch'} type
1348
+ * @property {BatchDiffEntry[]} entries
1349
+ * @property {boolean} [refetchRequired] - batch 全体の OR
1350
+ */
1351
+
1352
+ /**
1353
+ * BatchDiffInfo をテキストに付加する(batch 差分モード)。
1354
+ * @param {string} text
1355
+ * @param {BatchDiffInfo|null} batchInfo
1356
+ * @returns {string}
1357
+ */
1358
+ function appendBatchRefChangesToText(text, batchInfo) {
1359
+ if (!batchInfo) return text;
1360
+
1361
+ let out = text + `\n\n[snapshot:${batchInfo.snapshotId} rev:${batchInfo.revision}]`;
1362
+ out += '\nchanges:';
1363
+
1364
+ for (const entry of batchInfo.entries) {
1365
+ switch (entry.type) {
1366
+ case 'updated':
1367
+ out += `\n [${entry.opIndex}] updated: [${entry.ref}]`;
1368
+ break;
1369
+ case 'expanded':
1370
+ out += `\n [${entry.opIndex}] expanded: ${entry.oldRef} \u2192 [${entry.newRefs.join(', ')}]`;
1371
+ break;
1372
+ case 'failed':
1373
+ out += `\n [${entry.opIndex}] failed: ${entry.ref}`;
1374
+ if (entry.error) out += ` \u2014 ${entry.error}`;
1375
+ break;
1376
+ case 'skipped':
1377
+ out += `\n [${entry.opIndex}] skipped: ${entry.ref}`;
1378
+ break;
1379
+ }
1380
+ if (entry.refetchRequired) out += ' (refetch)';
1381
+ }
1382
+
1383
+ if (batchInfo.refetchRequired) {
1384
+ out += '\n\u26a0 refetchRequired: \u5b50\u30d6\u30ed\u30c3\u30af\u5909\u66f4\u3042\u308a\u3002get_article_structure \u3067\u6700\u65b0\u3092\u53d6\u5f97\u3057\u3066\u304f\u3060\u3055\u3044\u3002';
1385
+ }
1386
+
1387
+ return out;
1388
+ }
1389
+
1390
+ // ============================================================
1391
+ // Phase 7: batch 差分化 helper
1392
+ // ============================================================
1393
+
1394
+ /**
1395
+ * allBlocks の parentIndex チェーンを辿り、resolvedOps 間に祖先/子孫関係があるか検出。
1396
+ * @param {Array<{resolvedIndex: number, ref: string}>} resolvedOps
1397
+ * @param {Array<{index: number, parentIndex: number|null}>} allBlocks
1398
+ * @returns {{childRef: string, childIndex: number, ancestorRef: string, ancestorIndex: number}|null}
1399
+ */
1400
+ function checkAncestorOverlap(resolvedOps, allBlocks) {
1401
+ const targetMap = new Map(resolvedOps.map(o => [o.resolvedIndex, o.ref]));
1402
+ const parentMap = new Map(allBlocks.map(b => [b.index, b.parentIndex ?? null]));
1403
+
1404
+ for (const op of resolvedOps) {
1405
+ let current = parentMap.get(op.resolvedIndex);
1406
+ while (current != null) {
1407
+ if (targetMap.has(current)) {
1408
+ return {
1409
+ childRef: op.ref,
1410
+ childIndex: op.resolvedIndex,
1411
+ ancestorRef: targetMap.get(current),
1412
+ ancestorIndex: current,
1413
+ };
1414
+ }
1415
+ current = parentMap.get(current) ?? null;
1416
+ }
1417
+ }
1418
+ return null;
1419
+ }
1420
+
1421
+ /**
1422
+ * allBlocks 内の rootIndex を含む subtree の flat size を返す。
1423
+ * @param {Array<{index: number, parentIndex: number|null}>} allBlocks
1424
+ * @param {number} rootIndex
1425
+ * @returns {number}
1426
+ */
1427
+ function countFlatSubtreeSize(allBlocks, rootIndex) {
1428
+ const childrenOf = new Map();
1429
+ for (const b of allBlocks) {
1430
+ if (b.parentIndex != null) {
1431
+ if (!childrenOf.has(b.parentIndex)) childrenOf.set(b.parentIndex, []);
1432
+ childrenOf.get(b.parentIndex).push(b.index);
1433
+ }
1434
+ }
1435
+ let count = 1;
1436
+ const stack = childrenOf.get(rootIndex) ? [...childrenOf.get(rootIndex)] : [];
1437
+ while (stack.length > 0) {
1438
+ const idx = stack.pop();
1439
+ count++;
1440
+ const children = childrenOf.get(idx);
1441
+ if (children) stack.push(...children);
1442
+ }
1443
+ return count;
1444
+ }
1445
+
1446
+ /**
1447
+ * opResult.blocks から新 subtree の flat size を計算する。
1448
+ * rawNewIndices のうち集合内に親を持たないものを root とみなし、各 root の subtree size を合算。
1449
+ * editor(top-level のみ)/ headless(flat 全体)両対応。
1450
+ * @param {Array<{index: number, parentIndex?: number|null}>} opResultBlocks
1451
+ * @param {number[]} rawNewIndices
1452
+ * @returns {number}
1453
+ */
1454
+ function computeNewFlatSize(opResultBlocks, rawNewIndices) {
1455
+ if (!rawNewIndices || rawNewIndices.length === 0) return 0;
1456
+ const indexSet = new Set(rawNewIndices);
1457
+ const newRoots = rawNewIndices.filter(idx => {
1458
+ const b = opResultBlocks.find(bl => bl.index === idx);
1459
+ return !b || b.parentIndex == null || !indexSet.has(b.parentIndex);
1460
+ });
1461
+ let total = 0;
1462
+ const blocksForSize = opResultBlocks.map(b => ({ index: b.index, parentIndex: b.parentIndex ?? null }));
1463
+ for (const root of newRoots) {
1464
+ total += countFlatSubtreeSize(blocksForSize, root);
1465
+ }
1466
+ return total;
1467
+ }
1468
+
1469
+ /**
1470
+ * [pre-state 戦略] 操作前の状態で対象ブロックにネスト子があるか判定。
1471
+ * delete, move, duplicate 用。操作後は対象が消える/移動するため pre-state で見る。
1472
+ * 下流 hasChildren フラグがあればそちらを優先(duplicate, move が返す)。
1473
+ * @param {object|null} preState - 操作前の state
1474
+ * @param {number[]} targetIndices - 操作対象 index 配列
1475
+ * @param {object} [downstream] - 下流 result(hasChildren があれば優先)
1476
+ * @returns {boolean}
1477
+ */
1478
+ function checkRefetchFromPreState(preState, targetIndices, downstream) {
1479
+ if (downstream?.hasChildren === true) return true;
1480
+ if (downstream?.hasChildren === false) return false;
1481
+
1482
+ if (!preState?.allBlocks) return true;
1483
+
1484
+ for (const idx of targetIndices) {
1485
+ const hasChild = preState.allBlocks.some(b => b.parentIndex === idx);
1486
+ if (hasChild) return true;
1487
+ }
1488
+ return false;
1489
+ }
1490
+
1491
+ /**
1492
+ * [post-result 戦略] 操作結果にネストブロックが含ま���るか判定。
1493
+ * insert, update 用��非ネストだったブロックを nested HTML に更新したケースを検知。
1494
+ * 新 snapshot の blocks から、対象 index を parentIndex に持つブロックがあるかで判定。
1495
+ * @param {object} snapshot - buildResponseSnapshot() の結果(新 snapshot)
1496
+ * @param {number[]} resultIndices - 操作結果の index 配列(挿入/更新された位置)
1497
+ * @returns {boolean}
1498
+ */
1499
+ function checkRefetchFromResult(snapshot, resultIndices) {
1500
+ if (!snapshot?.blocks || resultIndices.length === 0) return false;
1501
+
1502
+ for (const idx of resultIndices) {
1503
+ const hasNested = snapshot.blocks.some(b => b.parentIndex === idx);
1504
+ if (hasNested) return true;
1505
+ }
1506
+ return false;
1507
+ }
1508
+
1509
+ /**
1510
+ * 下流 result + 新 snapshot → ChangeInfo を構築する。
1511
+ * 構築不能な場合は null を返す(呼び出し側は Legacy フォールバック必須)。
1512
+ * @param {'deleted'|'inserted'|'moved'|'updated'|'expanded'|'duplicated'} type
1513
+ * @param {object} snapshot - buildResponseSnapshot() の結果
1514
+ * @param {object} result - 下流レスポンス
1515
+ * @param {object|null} preState - _preState
1516
+ * @param {object} [opts] - 追加オプション
1517
+ * @returns {ChangeInfo|null} null = Legacy フォールバック
1518
+ */
1519
+ function buildChangeInfoFromResult(type, snapshot, result, preState, opts) {
1520
+ if (!snapshot?.snapshotId || !result) return null;
1521
+
1522
+ const base = { snapshotId: snapshot.snapshotId, revision: snapshot.revision };
1523
+
1524
+ switch (type) {
1525
+ case 'deleted': {
1526
+ const deleted = result.deleted;
1527
+ if (!deleted || deleted.length === 0) return null;
1528
+ const deletedRefs = opts?.inputRefs || deleted.map(d => generateBlockRef(d.index));
1529
+ const refetch = checkRefetchFromPreState(preState, deleted.map(d => d.index), result);
1530
+ return { ...base, type: 'deleted', deletedRefs, ...(refetch ? { refetchRequired: true } : {}) };
1531
+ }
1532
+ case 'inserted': {
1533
+ let indices = result.insertedIndices;
1534
+ if (!indices && result.changes) {
1535
+ indices = result.changes.flatMap(c => c.newIndices || []).filter(i => i != null);
1536
+ }
1537
+ if (!indices || indices.length === 0) return null;
1538
+ // refetchRequired は全 indices で判定(子孫含む)
1539
+ const refetch = checkRefetchFromResult(snapshot, indices);
1540
+ // top-level のみ: 他の inserted index を parentIndex に持つものを除外
1541
+ // (headless は subtree 全体の flat index を返すが、editor は top-level のみ。差分レスポンスは top-level に統一)
1542
+ const indexSet = new Set(indices);
1543
+ const topLevelIndices = indices.filter(idx => {
1544
+ const b = snapshot.blocks?.find(bl => bl.index === idx);
1545
+ return !b || b.parentIndex == null || !indexSet.has(b.parentIndex);
1546
+ });
1547
+ const inserted = topLevelIndices.map(idx => {
1548
+ const b = snapshot.blocks?.find(bl => bl.index === idx);
1549
+ return b
1550
+ ? { index: b.index, ref: b.ref, blockType: b.type, depth: b.depth }
1551
+ : { index: idx, ref: generateBlockRef(idx), blockType: 'unknown', depth: 0 };
1552
+ });
1553
+ if (inserted.length === 0) return null;
1554
+ return { ...base, type: 'inserted', inserted, ...(refetch ? { refetchRequired: true } : {}) };
1555
+ }
1556
+ case 'moved': {
1557
+ const mapping = result.movedMapping;
1558
+ if (!mapping || mapping.length === 0) return null;
1559
+ // inputRefs 長さ不一致 → Legacy フォールバック(count > 1 安全弁)
1560
+ if (opts?.inputRefs && opts.inputRefs.length !== mapping.length) return null;
1561
+ const moved = mapping.map((m, i) => ({
1562
+ oldRef: opts?.inputRefs ? opts.inputRefs[i] : generateBlockRef(m.oldIndex),
1563
+ newRef: generateBlockRef(m.newIndex),
1564
+ }));
1565
+ const refetch = checkRefetchFromPreState(preState, mapping.map(m => m.oldIndex), result);
1566
+ return { ...base, type: 'moved', moved, ...(refetch ? { refetchRequired: true } : {}) };
1567
+ }
1568
+ case 'updated': {
1569
+ const changes = result.changes;
1570
+ if (!changes || changes.length === 0) return null;
1571
+ const updatedRefs = changes.map(c => generateBlockRef(c.newIndices?.[0] ?? c.oldIndex));
1572
+ const resultIndices = changes.flatMap(c => c.newIndices || []).filter(i => i != null);
1573
+ const refetch = checkRefetchFromResult(snapshot, resultIndices);
1574
+ return { ...base, type: 'updated', updatedRefs, ...(refetch ? { refetchRequired: true } : {}) };
1575
+ }
1576
+ case 'expanded': {
1577
+ const changes = result.changes || [];
1578
+ if (changes.length !== 1) return null;
1579
+ const change = changes[0];
1580
+ if (!change || !change.newIndices || change.newIndices.length <= 1) return null;
1581
+ const oldRef = opts?.inputRef || generateBlockRef(change.oldIndex);
1582
+ const newRefs = change.newIndices.map(idx => generateBlockRef(idx));
1583
+ const refetch = checkRefetchFromResult(snapshot, change.newIndices);
1584
+ return { ...base, type: 'expanded', expanded: { oldRef, newRefs }, ...(refetch ? { refetchRequired: true } : {}) };
1585
+ }
1586
+ case 'duplicated': {
1587
+ const srcIdx = result.sourceIndex ?? result.duplicated?.index;
1588
+ const newIdx = result.newIndex ?? result.duplicated?.newIndex;
1589
+ if (srcIdx == null || newIdx == null) return null;
1590
+ const refetch = checkRefetchFromPreState(preState, [srcIdx], result);
1591
+ return {
1592
+ ...base, type: 'duplicated',
1593
+ sourceRef: opts?.inputRef || generateBlockRef(srcIdx),
1594
+ newRef: generateBlockRef(newIdx),
1595
+ sourceIndex: srcIdx, newIndex: newIdx,
1596
+ ...(refetch ? { refetchRequired: true } : {}),
1597
+ };
1598
+ }
1599
+ default:
1600
+ return null;
1601
+ }
1602
+ }
1603
+
1604
+ /**
1605
+ * 実行完了した resolvedOps + snapshot から BatchDiffInfo を構築する。
1606
+ * @param {Array} resolvedOps - rebasing 済みの op 配列
1607
+ * @param {object|null} snapshot - 最終 snapshot
1608
+ * @returns {BatchDiffInfo|null}
1609
+ */
1610
+ function buildBatchDiffInfo(resolvedOps, snapshot) {
1611
+ if (!snapshot?.snapshotId) return null;
1612
+
1613
+ let refetchRequired = false;
1614
+ const entries = [];
1615
+
1616
+ // opIndex 順(入力順)でソート
1617
+ const opsByInput = [...resolvedOps].sort((a, b) => a.opIndex - b.opIndex);
1618
+
1619
+ for (const op of opsByInput) {
1620
+ if (op.status === 'failed') {
1621
+ entries.push({ opIndex: op.opIndex, type: 'failed', ref: op.ref, error: op.error || null });
1622
+ continue;
1623
+ }
1624
+ if (op.status === 'skipped') {
1625
+ entries.push({ opIndex: op.opIndex, type: 'skipped', ref: op.ref });
1626
+ continue;
1627
+ }
1628
+
1629
+ // rebased な新 ref を snapshot から取得
1630
+ const newRefs = (op.rebasedNewIndices || []).map(idx => {
1631
+ const b = snapshot.blocks?.find(bl => bl.index === idx);
1632
+ return b ? b.ref : generateBlockRef(idx);
1633
+ });
1634
+
1635
+ // refetch 判定
1636
+ const opRefetch = checkRefetchFromResult(snapshot, op.rebasedNewIndices || []);
1637
+ if (opRefetch) refetchRequired = true;
1638
+
1639
+ if (op.status === 'expanded') {
1640
+ entries.push({
1641
+ opIndex: op.opIndex, type: 'expanded',
1642
+ oldRef: op.ref, newRefs,
1643
+ ...(opRefetch ? { refetchRequired: true } : {}),
1644
+ });
1645
+ } else {
1646
+ // updated
1647
+ entries.push({
1648
+ opIndex: op.opIndex, type: 'updated',
1649
+ ref: newRefs[0] || op.ref,
1650
+ ...(opRefetch ? { refetchRequired: true } : {}),
1651
+ });
1652
+ }
1653
+ }
1654
+
1655
+ return {
1656
+ snapshotId: snapshot.snapshotId,
1657
+ revision: snapshot.revision,
1658
+ type: 'batch',
1659
+ entries,
1660
+ ...(refetchRequired ? { refetchRequired: true } : {}),
1661
+ };
1662
+ }
1663
+
1664
+ /**
1665
+ * batch operations の実行
1666
+ * @param {Array} operations - 各 operation: { target, newHTML?, replacements?, attributeUpdates? }
1667
+ * @param {string} snapshotId
1668
+ * @param {string} mode
1669
+ * @param {object} client
1670
+ * @param {number} postId
1671
+ * @param {string|null} sessionId
1672
+ * @param {string} siteName
1673
+ * @param {string} _modeTag
1674
+ * @param {string} toolName
1675
+ */
1676
+ async function handleBatchOperations(operations, snapshotId, mode, client, postId, sessionId, siteName, _modeTag, toolName, expectedRevision) {
1677
+ // 1. 全 operation の target を正規化 & ref を一括解決
1678
+ let currentState;
1679
+ try {
1680
+ currentState = await getCurrentStructure(mode, client, postId, sessionId, { safetyCritical: true });
1681
+ } catch (e) {
1682
+ return { content: [{ type: "text", text: `❌ 現在のブロック構造を取得できませんでした: ${e.message}` }], isError: true };
1683
+ }
1684
+
1685
+ // revision 楽観的ロック(expectedRevision が指定されている場合のみ)
1686
+ if (expectedRevision) {
1687
+ const mismatch = checkRevisionMismatch(
1688
+ expectedRevision, currentState, mode, postId, sessionId, siteName
1689
+ );
1690
+ if (mismatch) return mismatch;
1691
+ }
1692
+
1693
+ // batch は ref のみ許可(1 operation = 1 ref = 1 block)
1694
+ const resolvedOps = [];
1695
+ try {
1696
+ for (let i = 0; i < operations.length; i++) {
1697
+ const op = operations[i];
1698
+ const ref = op.target?.ref;
1699
+ if (!ref || typeof ref !== 'string') {
1700
+ return {
1701
+ content: [{ type: "text", text: `❌ operations[${i}]: batch の target には ref(文字列)が必須です。index/section/range 等は batch では使用できません。` }],
1702
+ isError: true,
1703
+ };
1704
+ }
1705
+ // ref 以外の selector が指定されていないか確認
1706
+ const extraKeys = Object.keys(op.target).filter(k => k !== 'ref');
1707
+ if (extraKeys.length > 0) {
1708
+ return {
1709
+ content: [{ type: "text", text: `❌ operations[${i}]: batch の target は ref のみ指定可能です。不正なキー: ${extraKeys.join(', ')}` }],
1710
+ isError: true,
1711
+ };
1712
+ }
1713
+
1714
+ // 変更種別がちょうど1つか検証(1 op = 1 ref = 1 change-kind)
1715
+ const changeKinds = [
1716
+ op.replacements && op.replacements.length > 0 && 'replacements',
1717
+ op.newHTML && 'newHTML',
1718
+ op.attributeUpdates && 'attributeUpdates',
1719
+ ].filter(Boolean);
1720
+ if (changeKinds.length === 0) {
1721
+ return {
1722
+ content: [{ type: "text", text: `❌ operations[${i}]: 変更内容が指定されていません。replacements, newHTML, attributeUpdates のいずれか1つを指定してください。` }],
1723
+ isError: true,
1724
+ };
1725
+ }
1726
+ if (changeKinds.length > 1) {
1727
+ return {
1728
+ content: [{ type: "text", text: `❌ operations[${i}]: 複数の変更種別が指定されています: ${changeKinds.join(', ')}。1つだけ指定してください。` }],
1729
+ isError: true,
1730
+ };
1731
+ }
1732
+
1733
+ const resolvedIndex = resolveRefFromState(snapshotId, ref, mode, sessionId, postId, currentState);
1734
+
1735
+ resolvedOps.push({
1736
+ opIndex: i,
1737
+ ref,
1738
+ resolvedIndex,
1739
+ newHTML: op.newHTML,
1740
+ replacements: op.replacements,
1741
+ attributeUpdates: op.attributeUpdates,
1742
+ });
1743
+ }
1744
+ } catch (e) {
1745
+ return { content: [{ type: "text", text: `❌ batch ref 解決エラー: ${e.message}` }], isError: true };
1746
+ }
1747
+
1748
+ // 同一 index への複数 operation を排他チェック
1749
+ const indexSet = new Set();
1750
+ for (const op of resolvedOps) {
1751
+ if (indexSet.has(op.resolvedIndex)) {
1752
+ return {
1753
+ content: [{ type: "text", text: `❌ index ${op.resolvedIndex} (ref: ${op.ref}) に対して複数の operation が指定されています。同一ブロックへの複数操作は許可されていません。` }],
1754
+ isError: true,
1755
+ };
1756
+ }
1757
+ indexSet.add(op.resolvedIndex);
1758
+ }
1759
+
1760
+ // ancestor/descendant overlap チェック(親子関係の target は batch 不可)
1761
+ const ancestorOverlap = checkAncestorOverlap(resolvedOps, currentState.allBlocks);
1762
+ if (ancestorOverlap) {
1763
+ return {
1764
+ content: [{ type: "text", text: `❌ operations に親子関係の target が含まれるため batch 不可です。ref ${ancestorOverlap.childRef} (index ${ancestorOverlap.childIndex}) は ref ${ancestorOverlap.ancestorRef} (index ${ancestorOverlap.ancestorIndex}) の子孫です。` }],
1765
+ isError: true,
1766
+ };
1767
+ }
1768
+
1769
+ // 2. oldFlatSize を計算(rebasing 用、降順ソート前に実行)
1770
+ for (const op of resolvedOps) {
1771
+ op.oldFlatSize = countFlatSubtreeSize(currentState.allBlocks, op.resolvedIndex);
1772
+ }
1773
+
1774
+ // 3. 降順ソート(index が大きい方から処理 — 1 op = 1 index なので安全)
1775
+ resolvedOps.sort((a, b) => b.resolvedIndex - a.resolvedIndex);
1776
+
1777
+ // 4. 各 operation を順次実行(結果情報を保持)
1778
+ let lastSuccessfulResult = null;
1779
+ for (const op of resolvedOps) {
1780
+ const index = op.resolvedIndex;
1781
+
1782
+ let opResult;
1783
+ try {
1784
+ const downstreamParams = {
1785
+ index,
1786
+ replacements: op.replacements,
1787
+ newHTML: op.newHTML,
1788
+ attributeUpdates: op.attributeUpdates,
1789
+ insertOnly: false,
1790
+ dryRun: false,
1791
+ _fromInsertBlock: false,
1792
+ };
1793
+ if (mode === 'headless') {
1794
+ opResult = await client.headlessUpdate(postId, downstreamParams);
1795
+ } else {
1796
+ opResult = await client.sendEditorCommand("update_blocks", downstreamParams, 10000, postId, sessionId);
1797
+ }
1798
+ } catch (e) {
1799
+ op.status = 'failed';
1800
+ op.error = e.message;
1801
+ op.rawNewIndices = [];
1802
+ op.newFlatSize = op.oldFlatSize;
1803
+ op.opResult = null;
1804
+ continue;
1805
+ }
1806
+
1807
+ // op 成功判定
1808
+ const topLevelFail = !opResult || opResult.success === false || opResult.ok === false;
1809
+ if (topLevelFail) {
1810
+ op.status = 'failed';
1811
+ op.error = opResult?.error || 'unknown error';
1812
+ op.rawNewIndices = [];
1813
+ op.newFlatSize = op.oldFlatSize;
1814
+ op.opResult = opResult;
1815
+ continue;
1816
+ }
1817
+
1818
+ // skipped 判定(attributeUpdates の filter mismatch)
1819
+ const successfulChanges = (opResult.changes || []).filter(c => c.success !== false);
1820
+ const hasSkippedResult = opResult.results?.some(r => r.skipped === true);
1821
+ if (successfulChanges.length === 0 && !topLevelFail && !opResult.error) {
1822
+ if (hasSkippedResult || op.attributeUpdates != null) {
1823
+ op.status = 'skipped';
1824
+ op.rawNewIndices = [];
1825
+ op.newFlatSize = op.oldFlatSize;
1826
+ op.opResult = opResult;
1827
+ continue;
1828
+ }
1829
+ // changes 空で skipped でもない → failed
1830
+ op.status = 'failed';
1831
+ op.error = 'no successful changes';
1832
+ op.rawNewIndices = [];
1833
+ op.newFlatSize = op.oldFlatSize;
1834
+ op.opResult = opResult;
1835
+ continue;
1836
+ }
1837
+
1838
+ // 成功: rawNewIndices を取得
1839
+ op.rawNewIndices = successfulChanges.flatMap(c => c.newIndices || []);
1840
+ op.newFlatSize = (opResult.blocks && opResult.blocks.length > 0)
1841
+ ? computeNewFlatSize(opResult.blocks, op.rawNewIndices)
1842
+ : op.rawNewIndices.length || op.oldFlatSize;
1843
+ op.status = (op.rawNewIndices.length > 1) ? 'expanded' : 'updated';
1844
+ op.opResult = opResult;
1845
+ lastSuccessfulResult = opResult;
1846
+ }
1847
+
1848
+ // 5. rebasing(resolvedIndex 昇順で走査、低 index の展開が高 index をずらす)
1849
+ const opsForRebase = [...resolvedOps].sort((a, b) => a.resolvedIndex - b.resolvedIndex);
1850
+ let cumulativeDelta = 0;
1851
+ for (const op of opsForRebase) {
1852
+ if (op.status === 'failed' || op.status === 'skipped') {
1853
+ op.rebasedNewIndices = [];
1854
+ continue;
1855
+ }
1856
+ op.rebasedNewIndices = op.rawNewIndices.map(idx => idx + cumulativeDelta);
1857
+ cumulativeDelta += (op.newFlatSize - op.oldFlatSize);
1858
+ }
1859
+
1860
+ // 6. 最終 snapshot 構築
1861
+ let snapshot;
1862
+ if (lastSuccessfulResult) {
1863
+ snapshot = buildResponseSnapshotFromResult(lastSuccessfulResult, mode, postId, sessionId, siteName);
1864
+ }
1865
+ if (!snapshot) {
1866
+ // 全 op 失敗/skipped or result.blocks なし → currentState から構築
1867
+ snapshot = buildSnapshotFromState(currentState, mode, postId, sessionId, siteName);
1868
+ }
1869
+ if (!snapshot) {
1870
+ // 最終手段: 再取得
1871
+ snapshot = await buildResponseSnapshot(mode, client, postId, sessionId, siteName);
1872
+ }
1873
+
1874
+ // 7. BatchDiffInfo 構築
1875
+ const batchDiff = buildBatchDiffInfo(resolvedOps, snapshot);
1876
+
1877
+ // 8. レスポンス組み立て
1878
+ const successCount = resolvedOps.filter(r => r.status !== 'failed' && r.status !== 'skipped').length;
1879
+ const totalCount = resolvedOps.length;
1880
+ let text = `\u2705 batch \u5b8c\u4e86 (${successCount}/${totalCount} \u6210\u529f)`;
1881
+
1882
+ if (batchDiff) {
1883
+ text = appendBatchRefChangesToText(text + _modeTag, batchDiff);
1884
+ } else {
1885
+ text = appendSnapshotToTextLegacy(text + _modeTag, snapshot);
1886
+ }
1887
+
1888
+ return { content: [{ type: "text", text }] };
1889
+ }
1890
+
631
1891
  // フィードバック送信ヘルパー
632
1892
  const FEEDBACK_URL = process.env.FRIDAY_FEEDBACK_URL || '';
633
1893
  async function sendFeedback(data) {
@@ -776,7 +2036,7 @@ const tools = [
776
2036
  },
777
2037
  {
778
2038
  name: "delete_block",
779
- description: "Delete block(s) by index or selection.",
2039
+ description: "Delete block(s) by index, ref, or selection.",
780
2040
  inputSchema: {
781
2041
  type: "object",
782
2042
  properties: {
@@ -785,12 +2045,16 @@ const tools = [
785
2045
  index: { type: "number", description: "Index (0-based). Omit for selected." },
786
2046
  count: { type: "number", description: "Consecutive count (default: 1)" },
787
2047
  indices: { type: "array", items: { type: "number" }, description: "Multiple indices (exclusive with index/count)" },
2048
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref/refs." },
2049
+ ref: { type: "string", description: "Block ref from snapshot (exclusive with index/indices)." },
2050
+ refs: { type: "array", items: { type: "string" }, description: "Multiple block refs (exclusive with index/indices)." },
2051
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
788
2052
  },
789
2053
  },
790
2054
  },
791
2055
  {
792
2056
  name: "move_block",
793
- description: "Move block(s). Use from/to (top-level) or fromFlat/toFlat (nested).",
2057
+ description: "Move block(s). Use from/to (top-level), fromFlat/toFlat (nested), or fromRef+beforeRef/afterRef (ref-based).",
794
2058
  inputSchema: {
795
2059
  type: "object",
796
2060
  properties: {
@@ -801,6 +2065,11 @@ const tools = [
801
2065
  fromFlat: { type: "number", description: "Source flattened index" },
802
2066
  toFlat: { type: "number", description: "Target flattened position" },
803
2067
  count: { type: "integer", description: "Consecutive count (default: 1)" },
2068
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
2069
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
2070
+ fromRef: { type: "string", description: "Source block ref (exclusive with from/fromFlat)." },
2071
+ beforeRef: { type: "string", description: "Move before this ref (exclusive with to/toFlat/afterRef)." },
2072
+ afterRef: { type: "string", description: "Move after this ref (exclusive with to/toFlat/beforeRef)." },
804
2073
  },
805
2074
  },
806
2075
  },
@@ -830,13 +2099,16 @@ const tools = [
830
2099
  },
831
2100
  {
832
2101
  name: "duplicate_block",
833
- description: "Duplicate block by index or selection.",
2102
+ description: "Duplicate block by index, ref, or selection.",
834
2103
  inputSchema: {
835
2104
  type: "object",
836
2105
  properties: {
837
2106
  postId: postIdParam,
838
2107
  site: siteParam,
839
2108
  index: { type: "number", description: "Index (0-based). Omit for selected." },
2109
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
2110
+ ref: { type: "string", description: "Block ref from snapshot (exclusive with index)." },
2111
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
840
2112
  },
841
2113
  },
842
2114
  },
@@ -919,7 +2191,7 @@ const tools = [
919
2191
  },
920
2192
  {
921
2193
  name: "insert_block",
922
- description: "Insert Gutenberg HTML. Omit index to append.",
2194
+ description: "Insert Gutenberg HTML. Omit index to append. Use beforeRef/afterRef for ref-based positioning.",
923
2195
  inputSchema: {
924
2196
  type: "object",
925
2197
  properties: {
@@ -928,6 +2200,11 @@ const tools = [
928
2200
  rawHTML: { type: "string", description: "HTML to insert (exclusive with filePath)" },
929
2201
  filePath: { type: "string", description: "Local file path (exclusive with rawHTML)" },
930
2202
  index: { type: "number", description: "Position (0-based). Omit to append." },
2203
+ position: { type: "string", enum: ["before", "after"], description: "Insert relative to index: 'before' (default) or 'after'. Requires index." },
2204
+ snapshotId: { type: "string", description: "Snapshot ID. Required when using beforeRef/afterRef." },
2205
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
2206
+ beforeRef: { type: "string", description: "Insert before this ref (exclusive with index)." },
2207
+ afterRef: { type: "string", description: "Insert after this ref (exclusive with index)." },
931
2208
  },
932
2209
  },
933
2210
  },
@@ -957,13 +2234,59 @@ const tools = [
957
2234
  },
958
2235
  {
959
2236
  name: "update_blocks",
960
- description: "Update block(s). Supports replacements, newHTML, or attributeUpdates.",
2237
+ description: "Update block(s). Supports replacements, newHTML, or attributeUpdates. Use ref+snapshotId for safe index resolution.",
961
2238
  inputSchema: {
962
2239
  type: "object",
963
2240
  properties: {
964
2241
  postId: postIdParam,
965
2242
  site: siteParam,
2243
+ snapshotId: {
2244
+ type: "string",
2245
+ description: "Snapshot ID from get_article_structure. Required when using ref/refs in target.",
2246
+ },
2247
+ expectedRevision: {
2248
+ type: "string",
2249
+ description: "Revision from snapshot. If provided, rejects update when structure has changed.",
2250
+ },
966
2251
  target: targetSchema,
2252
+ operations: {
2253
+ type: "array",
2254
+ items: {
2255
+ type: "object",
2256
+ properties: {
2257
+ target: {
2258
+ type: "object",
2259
+ properties: {
2260
+ ref: { type: "string", description: "Block ref from snapshot (required)." },
2261
+ },
2262
+ required: ["ref"],
2263
+ description: "Batch target: ref only (1 block per operation).",
2264
+ },
2265
+ replacements: {
2266
+ type: "array",
2267
+ items: {
2268
+ type: "object",
2269
+ properties: {
2270
+ old: { type: "string" },
2271
+ new: { type: "string" },
2272
+ regex: { type: "boolean" },
2273
+ },
2274
+ required: ["old", "new"],
2275
+ },
2276
+ },
2277
+ newHTML: { type: "string" },
2278
+ attributeUpdates: {
2279
+ type: "object",
2280
+ properties: {
2281
+ filter: { type: "object" },
2282
+ set: { type: "object" },
2283
+ },
2284
+ },
2285
+ },
2286
+ required: ["target"],
2287
+ },
2288
+ description: "Batch operations: each targets a single block by ref. Requires snapshotId.",
2289
+ },
967
2290
  replacements: {
968
2291
  type: "array",
969
2292
  items: {
@@ -999,12 +2322,12 @@ const tools = [
999
2322
  description: "Preview only",
1000
2323
  },
1001
2324
  },
1002
- required: ["target"],
2325
+ // target は operations[] 使用時は不要
1003
2326
  },
1004
2327
  },
1005
2328
  {
1006
2329
  name: "table_operations",
1007
- description: "Table operations (get/update/add/delete rows/columns/cells).",
2330
+ description: "Table operations (get/update/add/delete rows/columns/cells). Use ref+snapshotId for safe index resolution.",
1008
2331
  inputSchema: {
1009
2332
  type: "object",
1010
2333
  properties: {
@@ -1040,8 +2363,11 @@ const tools = [
1040
2363
  items: { type: "string" },
1041
2364
  description: "add_row: new cells, add_column: init values, update_row/column: replacements. Omit for empty.",
1042
2365
  },
2366
+ snapshotId: { type: "string", description: "Snapshot ID from get_article_structure. Required when using ref." },
2367
+ ref: { type: "string", description: "Block ref from snapshot (exclusive with index)." },
2368
+ expectedRevision: { type: "string", description: "Revision from snapshot. Rejects if structure changed." },
1043
2369
  },
1044
- required: ["index", "action"],
2370
+ required: ["action"],
1045
2371
  },
1046
2372
  },
1047
2373
  {
@@ -1114,11 +2440,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1114
2440
  */
1115
2441
  async function handleUpdateBlocksTool(args, toolName) {
1116
2442
  const name = toolName || "update_blocks";
1117
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
2443
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
1118
2444
  if (mode === 'error') {
1119
2445
  return errorResponse(name, message, args?.site);
1120
2446
  }
1121
2447
  const _modeTag = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
2448
+ const _siteName = siteName || args?.site || 'default';
1122
2449
 
1123
2450
  // slug → postId 解決(headless モード時のみ)
1124
2451
  const _resolved = mode === 'headless' ? await resolvePostId(args?.postId, client) : { postId: _postId };
@@ -1140,6 +2467,27 @@ async function handleUpdateBlocksTool(args, toolName) {
1140
2467
  };
1141
2468
  }
1142
2469
 
2470
+ // --- operations[] 排他チェック ---
2471
+ const snapshotId = args?.snapshotId;
2472
+ if (args?.operations && args.operations.length > 0) {
2473
+ const conflicting = ['target', 'replacements', 'newHTML', 'attributeUpdates', 'insert', 'filePath']
2474
+ .filter(k => args[k] !== undefined);
2475
+ if (conflicting.length > 0) {
2476
+ return {
2477
+ content: [{ type: "text", text: `❌ operations と ${conflicting.join(', ')} は同時に指定できません。operations を使う場合、各操作は operations 内に記述してください。` }],
2478
+ isError: true,
2479
+ };
2480
+ }
2481
+ if (!snapshotId) {
2482
+ return {
2483
+ content: [{ type: "text", text: `❌ operations には snapshotId が必要です。get_article_structure で取得してください。` }],
2484
+ isError: true,
2485
+ };
2486
+ }
2487
+ // batch 実行に委譲
2488
+ return await handleBatchOperations(args.operations, snapshotId, mode, client, postId, _sessionId, _siteName, _modeTag, name, args?.expectedRevision);
2489
+ }
2490
+
1143
2491
  // --- normalizeTarget / normalizeInsert ---
1144
2492
  let tp, ins;
1145
2493
  try {
@@ -1149,6 +2497,32 @@ async function handleUpdateBlocksTool(args, toolName) {
1149
2497
  return { content: [{ type: "text", text: `❌ ${e.message}` }], isError: true };
1150
2498
  }
1151
2499
 
2500
+ // --- ref 解決 + revision チェック(共通ヘルパー) ---
2501
+ const resolved = await resolveRefsAndCheckRevision({
2502
+ snapshotId,
2503
+ ref: tp._ref,
2504
+ refs: tp._refs,
2505
+ expectedRevision: args?.expectedRevision,
2506
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
2507
+ });
2508
+ if (resolved.error) return resolved.error;
2509
+ const _preState = resolved.currentState || null; // Phase 2: 差分計算用
2510
+ if (resolved.index !== undefined) {
2511
+ tp.index = resolved.index;
2512
+ delete tp._ref;
2513
+ }
2514
+ if (resolved.indices !== undefined) {
2515
+ tp.indices = resolved.indices;
2516
+ delete tp._refs;
2517
+ }
2518
+
2519
+ // 1→N 展開追跡用の refInfo(単体 ref + newHTML 置換時のみ。insert 系は対象外)
2520
+ const _refInfo = (snapshotId && args?.target?.ref && args?.newHTML && !args?.insert) ? {
2521
+ oldSnapshotId: snapshotId,
2522
+ usedRef: args.target.ref,
2523
+ resolvedIndex: tp.index,
2524
+ } : null;
2525
+
1152
2526
  const { target, index, indices, startIndex, endIndex, section, blockType, typeIndex,
1153
2527
  contains, headingLevel, headingContains } = tp;
1154
2528
  const { insertOnly, insertPosition } = ins;
@@ -1156,6 +2530,17 @@ async function handleUpdateBlocksTool(args, toolName) {
1156
2530
  _fromInsertBlock, appendToEnd } = (args || {});
1157
2531
  let { newHTML } = (args || {});
1158
2532
 
2533
+ // Phase 5: 差分化オプション
2534
+ // - 単体 target.ref かつ snapshotId ありのみ差分化
2535
+ // - appendToEnd は Legacy 固定(早期リターンで自然に除外)
2536
+ const _inputRef = (snapshotId && args?.target?.ref) ? args.target.ref : null;
2537
+ const _isRefInsert = !!(insertOnly && _inputRef);
2538
+
2539
+ // [INSERT_OBSERVE] Step 5: insert_block 経由時の送信パラメータログ
2540
+ if (process.env.FRIDAY_DEBUG === '1' && _fromInsertBlock) {
2541
+ console.log(`[INSERT_OBSERVE] mcp-send: index=${index}, postId=${postId}, sessionId=${_sessionId || 'none'}, mode=${mode}, appendToEnd=${appendToEnd}, htmlLen=${(newHTML || '').length}`);
2542
+ }
2543
+
1159
2544
  // --- filePath → newHTML 解決(update_blocks 直接呼び出し時) ---
1160
2545
  if (args?.filePath) {
1161
2546
  if (newHTML) {
@@ -1196,7 +2581,7 @@ async function handleUpdateBlocksTool(args, toolName) {
1196
2581
  };
1197
2582
  }
1198
2583
 
1199
- // --- ターゲットチェック(appendToEnd 時は不要) ---
2584
+ // --- ターゲットチェック(appendToEnd / _parentIndex 時は不要) ---
1200
2585
  const hasTarget = target || index !== undefined || indices ||
1201
2586
  startIndex !== undefined || section || blockType || contains ||
1202
2587
  (headingLevel && headingContains);
@@ -1238,7 +2623,8 @@ async function handleUpdateBlocksTool(args, toolName) {
1238
2623
  _fromInsertBlock: _fromInsertBlock || false,
1239
2624
  });
1240
2625
  const count = result.results?.[0]?.count || 1;
1241
- return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}` }] };
2626
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
2627
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1242
2628
  }
1243
2629
 
1244
2630
  const result = await client.headlessUpdate(postId, {
@@ -1263,13 +2649,14 @@ async function handleUpdateBlocksTool(args, toolName) {
1263
2649
  return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText}${deprecationWarning}` }] };
1264
2650
  }
1265
2651
 
2652
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
1266
2653
  if (result.results) {
1267
2654
  const successCount = result.results.filter(r => r.success).length;
1268
2655
  const failCount = result.results.filter(r => !r.success).length;
1269
- return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}` }] };
2656
+ return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ 完了\n\n成功: ${successCount} / 失敗: ${failCount}${deprecationWarning}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
1270
2657
  }
1271
2658
 
1272
- return { content: [{ type: "text", text: `✅ 更新完了${deprecationWarning}${_modeTag}` }] };
2659
+ return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ 更新完了${deprecationWarning}${_modeTag}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
1273
2660
  } catch (e) {
1274
2661
  const formatted = formatHeadlessConflictError(e);
1275
2662
  if (formatted) return formatted;
@@ -1291,7 +2678,8 @@ async function handleUpdateBlocksTool(args, toolName) {
1291
2678
  if (!result || result.timeout) return timeoutResponse(name, client, args?.site);
1292
2679
  if (!result.success) return errorResponse(name, result.error, args?.site);
1293
2680
  const count = result.inserted || 1;
1294
- return { content: [{ type: "text", text: `✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}` }] };
2681
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
2682
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${count}ブロック挿入 at end${deprecationWarning}${_modeTag}`, _snap, _refInfo) }] };
1295
2683
  }
1296
2684
 
1297
2685
  // target: "selected" の場合 → エディタ状態から選択情報を取得してMCP側で処理
@@ -1344,7 +2732,10 @@ async function handleUpdateBlocksTool(args, toolName) {
1344
2732
  return timeoutResponse(name, client, args?.site);
1345
2733
  if (!result.success)
1346
2734
  return errorResponse(name, result.error, args?.site);
1347
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}${deprecationWarning}` }] };
2735
+ {
2736
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
2737
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${hasTextSelection ? "(カーソル選択モード)" : "(差分)"}${deprecationWarning}`, _snap, _refInfo) }] };
2738
+ }
1348
2739
  }
1349
2740
 
1350
2741
  // newHTMLの場合
@@ -1360,7 +2751,10 @@ async function handleUpdateBlocksTool(args, toolName) {
1360
2751
  return timeoutResponse(name, client, args?.site);
1361
2752
  if (!result.success)
1362
2753
  return errorResponse(name, result.error, args?.site);
1363
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}` }] };
2754
+ {
2755
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
2756
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}`, _snap, _refInfo) }] };
2757
+ }
1364
2758
  }
1365
2759
 
1366
2760
  // attributeUpdatesの場合
@@ -1374,12 +2768,15 @@ async function handleUpdateBlocksTool(args, toolName) {
1374
2768
  return timeoutResponse(name, client, args?.site);
1375
2769
  if (!result.success)
1376
2770
  return errorResponse(name, result.error, args?.site);
1377
- return { content: [{ type: "text", text: `✅ 属性更新完了${deprecationWarning}` }] };
2771
+ {
2772
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
2773
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(`✅ 属性更新完了${deprecationWarning}`, _snap, _refInfo) }] };
2774
+ }
1378
2775
  }
1379
2776
 
1380
2777
  // その他のターゲット → 全てWP側で解決&実行
1381
2778
  const maxWait = (section || blockType || contains) ? 15000 : 10000;
1382
- const result = await client.sendEditorCommand("update_blocks", {
2779
+ const editorParams = {
1383
2780
  index, indices, startIndex, endIndex,
1384
2781
  section, blockType, typeIndex, contains,
1385
2782
  headingLevel, headingContains,
@@ -1388,7 +2785,8 @@ async function handleUpdateBlocksTool(args, toolName) {
1388
2785
  insertPosition,
1389
2786
  dryRun: dryRun || false,
1390
2787
  _fromInsertBlock: _fromInsertBlock || false,
1391
- }, maxWait, _postId, _sessionId);
2788
+ };
2789
+ const result = await client.sendEditorCommand("update_blocks", editorParams, maxWait, _postId, _sessionId);
1392
2790
  if (!result || result.timeout)
1393
2791
  return timeoutResponse(name, client, args?.site);
1394
2792
  if (!result.success)
@@ -1412,15 +2810,16 @@ async function handleUpdateBlocksTool(args, toolName) {
1412
2810
  return { content: [{ type: "text", text: `🔍 プレビュー\n\n対象: ${result.results.length} ブロック\n\n${previewText || "(対象なし)"}${deprecationWarning}` }] };
1413
2811
  }
1414
2812
 
2813
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
1415
2814
  if (result.results) {
1416
2815
  const successCount = result.results.filter(r => r.success && !r.skipped).length;
1417
2816
  const skipCount = result.results.filter(r => r.skipped).length;
1418
2817
  const failCount = result.results.filter(r => !r.success).length;
1419
2818
  const detailText = result.results.filter(r => r.success && r.replaceCount > 0).map(r => ` [${r.index}]: ${r.replaceCount}箇所`).join('\n');
1420
- return { content: [{ type: "text", text: `✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}${_modeTag}` }] };
2819
+ return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ 完了\n\n成功: ${successCount} / スキップ: ${skipCount} / 失敗: ${failCount}\n\n${detailText}${deprecationWarning}${_modeTag}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
1421
2820
  }
1422
2821
 
1423
- return { content: [{ type: "text", text: `✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}${_modeTag}` }] };
2822
+ return { content: [{ type: "text", text: buildUpdateDiffResponse(`✅ ${result.mode === "insert" ? "挿入" : "更新"}完了${deprecationWarning}${_modeTag}`, _snap, result, _preState, _inputRef, _isRefInsert, _refInfo) }] };
1424
2823
  }
1425
2824
 
1426
2825
  // ツール実行のハンドラ
@@ -1475,6 +2874,46 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1475
2874
  state = await client.getEditorState(_postId, _sessionId);
1476
2875
  }
1477
2876
 
2877
+ // --- snapshot 作成(allBlocks がある場合のみ) ---
2878
+ let _refTag = (_idx, _prefix) => '';
2879
+ let _snapshotLine = '';
2880
+
2881
+ if (state.allBlocks && state.allBlocks.length > 0) {
2882
+ const snapshotId = generateSnapshotId();
2883
+ const snapshotBlocks = buildSnapshotBlocks(state, mode);
2884
+ const revision = computeRevision(state, mode);
2885
+ const snapshotRecord = {
2886
+ snapshotId, postId, mode,
2887
+ sessionId: mode === 'editor' ? _sessionId : null,
2888
+ siteName: siteName || 'default',
2889
+ createdAt: Date.now(),
2890
+ revision,
2891
+ blocks: snapshotBlocks,
2892
+ displayMode: full ? 'full' : contains ? 'contains' : section ? (blockType ? 'section+blockType' : 'section') : blockType ? 'blockType' : headingLevel ? 'headingLevel' : 'default',
2893
+ };
2894
+ snapshotCache.set(snapshotRecord);
2895
+
2896
+ const _refMap = new Map(snapshotBlocks.map(b => [b.index, b.ref]));
2897
+ _refTag = (idx, prefix = '|') => { const r = _refMap.get(idx); return r ? `${prefix}${r}` : ''; };
2898
+ _snapshotLine = `\n[snapshot:${snapshotId} rev:${revision}]`;
2899
+ }
2900
+
2901
+ // ツリー構造情報のフォーマットヘルパー
2902
+ function formatTreeLine(b) {
2903
+ const depth = b.depth || 0;
2904
+ const indent = ' '.repeat(depth + 1);
2905
+ let marker = '';
2906
+ let childInfo = '';
2907
+ if (b.isContainer) {
2908
+ marker = '▼ ';
2909
+ if (b.columnCount) childInfo = ` [${b.columnCount}カラム]`;
2910
+ else if (b.tabNames && b.tabNames.length > 0) childInfo = ` [${b.tabNames.join(", ")}]`;
2911
+ else if (b.childCount) childInfo = ` [${b.childCount}子]`;
2912
+ }
2913
+ const parentInfo = (depth > 0 && b.parentIndex != null) ? `, p:${b.parentIndex}` : '';
2914
+ return { indent, marker, childInfo, depthInfo: `(d:${depth}${parentInfo})` };
2915
+ }
2916
+
1478
2917
  // ========================================
1479
2918
  // 全ブロック情報取得(full=true)
1480
2919
  // ========================================
@@ -1505,7 +2944,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1505
2944
 
1506
2945
  const blockList = blocks.map(b => {
1507
2946
  const attrPreview = JSON.stringify(b.attributes || {}).slice(0, 200);
1508
- return ` [${b.index}] ${b.type} - ${b.section || "(記事冒頭)"} (d:${b.depth})\n ${attrPreview}${attrPreview.length >= 200 ? '...' : ''}`;
2947
+ const t = formatTreeLine(b);
2948
+ return `${t.indent}[${b.index}${_refTag(b.index)}] ${t.marker}${b.type}${t.childInfo} - ${b.section || "(記事冒頭)"} ${t.depthInfo}\n${t.indent} ${attrPreview}${attrPreview.length >= 200 ? '...' : ''}`;
1509
2949
  }).join("\n\n");
1510
2950
 
1511
2951
  let paginationInfo = '';
@@ -1519,7 +2959,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1519
2959
  return {
1520
2960
  content: [{
1521
2961
  type: "text",
1522
- text: `📊 get_article_structure(${paramLabel})\n\n全ブロック情報 (${blocks.length}件):\n\n${blockList}${paginationInfo}${_modeTag_gas}`,
2962
+ text: `📊 get_article_structure(${paramLabel})\n\n全ブロック情報 (${blocks.length}件):\n\n${blockList}${paginationInfo}${_snapshotLine}${_modeTag_gas}`,
1523
2963
  }],
1524
2964
  };
1525
2965
  }
@@ -1556,6 +2996,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1556
2996
  type: block.type,
1557
2997
  section: block.section,
1558
2998
  depth: block.depth,
2999
+ parentIndex: block.parentIndex,
3000
+ isContainer: block.isContainer,
3001
+ childCount: block.childCount,
1559
3002
  preview: preview
1560
3003
  });
1561
3004
  }
@@ -1572,9 +3015,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1572
3015
 
1573
3016
  const matchList = matches
1574
3017
  .map(m => {
1575
- let line = ` index ${m.index}: ${m.type} - ${m.section || "(記事冒頭)"} (d:${m.depth})`;
3018
+ const t = formatTreeLine(m);
3019
+ let line = `${t.indent}index ${m.index}${_refTag(m.index, ' ')}: ${t.marker}${m.type}${t.childInfo} - ${m.section || "(記事冒頭)"} ${t.depthInfo}`;
1576
3020
  if (m.preview) {
1577
- line += `\n "${m.preview}"`;
3021
+ line += `\n${t.indent} "${m.preview}"`;
1578
3022
  }
1579
3023
  return line;
1580
3024
  })
@@ -1583,7 +3027,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1583
3027
  return {
1584
3028
  content: [{
1585
3029
  type: "text",
1586
- text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${matches.length}個):\n\n${matchList}${_modeTag_gas}`,
3030
+ text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${matches.length}個):\n\n${matchList}${_snapshotLine}${_modeTag_gas}`,
1587
3031
  }],
1588
3032
  };
1589
3033
  }
@@ -1613,9 +3057,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1613
3057
 
1614
3058
  const matchList = (searchResult.matches || [])
1615
3059
  .map(m => {
1616
- let line = ` index ${m.index}: ${m.type} - ${m.section || "(記事冒頭)"} (d:${m.depth})`;
3060
+ const t = formatTreeLine(m);
3061
+ let line = `${t.indent}index ${m.index}${_refTag(m.index, ' ')}: ${t.marker}${m.type}${t.childInfo} - ${m.section || "(記事冒頭)"} ${t.depthInfo}`;
1617
3062
  if (m.preview) {
1618
- line += `\n "${m.preview}"`;
3063
+ line += `\n${t.indent} "${m.preview}"`;
1619
3064
  }
1620
3065
  return line;
1621
3066
  })
@@ -1624,7 +3069,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1624
3069
  return {
1625
3070
  content: [{
1626
3071
  type: "text",
1627
- text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${(searchResult.matches || []).length}個):\n\n${matchList}${_modeTag_gas}`,
3072
+ text: `📊 get_article_structure(${paramLabel})\n\n"${contains}" を含むブロック (${(searchResult.matches || []).length}個):\n\n${matchList}${_snapshotLine}${_modeTag_gas}`,
1628
3073
  }],
1629
3074
  };
1630
3075
  }
@@ -1698,12 +3143,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1698
3143
  }
1699
3144
 
1700
3145
  const blockList = filteredBlocks
1701
- .map(b => ` index ${b.index}: d:${b.depth || 0}`)
3146
+ .map(b => {
3147
+ const t = formatTreeLine(b);
3148
+ return `${t.indent}index ${b.index}${_refTag(b.index, ' ')}: ${t.marker}${blockType}${t.childInfo} ${t.depthInfo}`;
3149
+ })
1702
3150
  .join("\n");
1703
3151
  return {
1704
3152
  content: [{
1705
3153
  type: "text",
1706
- text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション内の ${blockType} (${filteredBlocks.length}個):\n\n${blockList}${_modeTag_gas}`,
3154
+ text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション内の ${blockType} (${filteredBlocks.length}個):\n\n${blockList}${_snapshotLine}${_modeTag_gas}`,
1707
3155
  }],
1708
3156
  };
1709
3157
  }
@@ -1723,19 +3171,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1723
3171
  const typeData = state.blockSummary[blockType];
1724
3172
  const blockList = typeData.blocks
1725
3173
  .map(b => {
1726
- let info = ` index ${b.index}: ${b.section || "(記事冒頭)"} (H${b.sectionLevel || "-"}) d:${b.depth || 0}`;
1727
- if (b.tabNames && b.tabNames.length > 0) {
1728
- info += ` [${b.tabNames.join(", ")}]`;
1729
- } else if (b.columnCount) {
1730
- info += ` [${b.columnCount}カラム]`;
1731
- }
1732
- return info;
3174
+ const t = formatTreeLine(b);
3175
+ return `${t.indent}index ${b.index}${_refTag(b.index, ' ')}: ${t.marker}${b.section || "(記事冒頭)"} (H${b.sectionLevel || "-"})${t.childInfo} ${t.depthInfo}`;
1733
3176
  })
1734
3177
  .join("\n");
1735
3178
  return {
1736
3179
  content: [{
1737
3180
  type: "text",
1738
- text: `📊 get_article_structure(${paramLabel})\n\n${blockType} の一覧 (${typeData.count}個):\n\n${blockList}${_modeTag_gas}`,
3181
+ text: `📊 get_article_structure(${paramLabel})\n\n${blockType} の一覧 (${typeData.count}個):\n\n${blockList}${_snapshotLine}${_modeTag_gas}`,
1739
3182
  }],
1740
3183
  };
1741
3184
  }
@@ -1793,20 +3236,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1793
3236
 
1794
3237
  const blockList = sectionBlocks
1795
3238
  .map(b => {
1796
- const indent = ' '.repeat((b.depth || 0) + 1);
1797
- let marker = '';
1798
- let info = '';
1799
- if (b.isContainer) {
1800
- marker = '▼ ';
1801
- if (b.columnCount) {
1802
- info = ` [${b.columnCount}カラム]`;
1803
- } else if (b.tabNames && b.tabNames.length > 0) {
1804
- info = ` [${b.tabNames.join(", ")}]`;
1805
- } else if (b.childCount) {
1806
- info = ` [${b.childCount}子]`;
1807
- }
1808
- }
1809
- return `${indent}${b.index}: ${marker}${b.type}${info} (d:${b.depth || 0})`;
3239
+ const t = formatTreeLine(b);
3240
+ return `${t.indent}${b.index}${_refTag(b.index, ' ')}: ${t.marker}${b.type}${t.childInfo} ${t.depthInfo}`;
1810
3241
  })
1811
3242
  .join("\n");
1812
3243
 
@@ -1817,7 +3248,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1817
3248
  return {
1818
3249
  content: [{
1819
3250
  type: "text",
1820
- text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション (index:${sectionHeading.index}):\n\n${blockList}${nextInfo}${_modeTag_gas}`,
3251
+ text: `📊 get_article_structure(${paramLabel})\n\n${sectionHeading.text} セクション (index:${sectionHeading.index}):\n\n${blockList}${nextInfo}${_snapshotLine}${_modeTag_gas}`,
1821
3252
  }],
1822
3253
  };
1823
3254
  }
@@ -1836,12 +3267,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1836
3267
  };
1837
3268
  }
1838
3269
  const headingList = filteredHeadings
1839
- .map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index})`)
3270
+ .map(h => ` ${"#".repeat(h.level)} ${h.text} (index:${h.index}${_refTag(h.index)})`)
1840
3271
  .join("\n");
1841
3272
  return {
1842
3273
  content: [{
1843
3274
  type: "text",
1844
- text: `📊 get_article_structure(${paramLabel})\n\nH${headingLevel}見出し一覧 (${filteredHeadings.length}個):\n\n${headingList}${_modeTag_gas}`,
3275
+ text: `📊 get_article_structure(${paramLabel})\n\nH${headingLevel}見出し一覧 (${filteredHeadings.length}個):\n\n${headingList}${_snapshotLine}${_modeTag_gas}`,
1845
3276
  }],
1846
3277
  };
1847
3278
  }
@@ -1854,11 +3285,11 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1854
3285
  for (const heading of state.headings) {
1855
3286
  const indent = heading.level === 2 ? "" : " ";
1856
3287
  const prefix = "#".repeat(heading.level);
1857
- output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index})\n`;
3288
+ output += `${indent}${prefix} ${heading.text} (H${heading.level}, index:${heading.index}${_refTag(heading.index)})\n`;
1858
3289
  }
1859
3290
 
1860
3291
  return {
1861
- content: [{ type: "text", text: output + _modeTag_gas }],
3292
+ content: [{ type: "text", text: output + _snapshotLine + _modeTag_gas }],
1862
3293
  };
1863
3294
  }
1864
3295
 
@@ -1916,7 +3347,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1916
3347
  }
1917
3348
 
1918
3349
  case "delete_block": {
1919
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
3350
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
1920
3351
  if (mode === 'error') {
1921
3352
  return errorResponse(name, message, args?.site);
1922
3353
  }
@@ -1925,13 +3356,35 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1925
3356
  if (_resolved.editorConnected) mode = 'editor';
1926
3357
  const postId = _resolved.postId ?? _postId;
1927
3358
  const _mt_db = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
3359
+ const _siteName = siteName || args?.site || 'default';
1928
3360
 
1929
3361
  if (mode === 'headless') {
1930
3362
  const _guard = await guardHeadlessConflict(postId, client, name);
1931
3363
  if (_guard) return _guard;
1932
3364
  }
1933
3365
 
1934
- const { index, count, indices } = args;
3366
+ // ref/refs index/indices 排他チェック
3367
+ if ((args?.ref || args?.refs) && (args?.index !== undefined || args?.indices !== undefined)) {
3368
+ return { content: [{ type: "text", text: "❌ ref/refs と index/indices は同時に指定できません。" }], isError: true };
3369
+ }
3370
+
3371
+ // ref 解決 + revision チェック
3372
+ const resolved = await resolveRefsAndCheckRevision({
3373
+ snapshotId: args?.snapshotId, ref: args?.ref, refs: args?.refs,
3374
+ expectedRevision: args?.expectedRevision,
3375
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
3376
+ });
3377
+ if (resolved.error) return resolved.error;
3378
+ const _preState = resolved.currentState || null; // Phase 2: 差分計算用
3379
+
3380
+ let { index, count, indices } = args;
3381
+ if (resolved.index !== undefined) { index = resolved.index; count = count || 1; }
3382
+ if (resolved.indices !== undefined) { indices = resolved.indices; }
3383
+
3384
+ // Phase 3: 入力 ref を保持(ref + count > 1 は差分化できないので Legacy)
3385
+ const _inputRefs = args?.refs ? [...new Set(args.refs)]
3386
+ : (args?.ref && (count || 1) <= 1) ? [args.ref]
3387
+ : null;
1935
3388
 
1936
3389
  // indices と index/count の排他バリデーション
1937
3390
  if (indices !== undefined && (index !== undefined || count !== undefined)) {
@@ -1953,7 +3406,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1953
3406
  try {
1954
3407
  const result = await client.headlessDeleteMultiple(postId, uniqueIndices);
1955
3408
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1956
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
3409
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3410
+ const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
3411
+ if (_inputRefs) {
3412
+ const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
3413
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3414
+ }
3415
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
1957
3416
  } catch (e) {
1958
3417
  const formatted = formatHeadlessConflictError(e);
1959
3418
  if (formatted) return formatted;
@@ -1967,7 +3426,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1967
3426
  if (!result.success)
1968
3427
  return errorResponse(name, result.error, args?.site);
1969
3428
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1970
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
3429
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3430
+ const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
3431
+ if (_inputRefs) {
3432
+ const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
3433
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3434
+ }
3435
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
1971
3436
  }
1972
3437
 
1973
3438
  // 既存モード(index + count)
@@ -1978,7 +3443,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1978
3443
  try {
1979
3444
  const result = await client.headlessDelete(postId, index, count || 1);
1980
3445
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1981
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
3446
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3447
+ const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
3448
+ if (_inputRefs) {
3449
+ const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
3450
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3451
+ }
3452
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
1982
3453
  } catch (e) {
1983
3454
  const formatted = formatHeadlessConflictError(e);
1984
3455
  if (formatted) return formatted;
@@ -1992,11 +3463,17 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1992
3463
  if (!result.success)
1993
3464
  return errorResponse(name, result.error, args?.site);
1994
3465
  const details = result.deleted.map(d => ` [${d.index}] ${d.type}`).join('\n');
1995
- return { content: [{ type: "text", text: `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}` }] };
3466
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3467
+ const _msg = `✅ ${result.count}ブロック削除\n\n${details}${_mt_db}`;
3468
+ if (_inputRefs) {
3469
+ const changeInfo = buildChangeInfoFromResult('deleted', _snap, result, _preState, { inputRefs: _inputRefs });
3470
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3471
+ }
3472
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
1996
3473
  }
1997
3474
 
1998
3475
  case "move_block": {
1999
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
3476
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2000
3477
  if (mode === 'error') {
2001
3478
  return errorResponse(name, message, args?.site);
2002
3479
  }
@@ -2005,13 +3482,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2005
3482
  if (_resolved.editorConnected) mode = 'editor';
2006
3483
  const postId = _resolved.postId ?? _postId;
2007
3484
  const _mt_mb = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
3485
+ const _siteName = siteName || args?.site || 'default';
2008
3486
 
2009
3487
  if (mode === 'headless') {
2010
3488
  const _guard = await guardHeadlessConflict(postId, client, name);
2011
3489
  if (_guard) return _guard;
2012
3490
  }
2013
3491
 
2014
- const { from, to, fromFlat, toFlat } = args;
3492
+ const { from, to } = args;
3493
+ let { fromFlat, toFlat } = args;
2015
3494
  const count = args.count ?? 1;
2016
3495
 
2017
3496
  // count バリデーション
@@ -2019,16 +3498,71 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2019
3498
  return { content: [{ type: "text", text: "❌ count は1以上の整数を指定してください。" }], isError: true };
2020
3499
  }
2021
3500
 
2022
- // 排他バリデーション
3501
+ // 3 モード排他バリデーション
2023
3502
  const hasTopLevel = from !== undefined || to !== undefined;
2024
3503
  const hasFlat = fromFlat !== undefined || toFlat !== undefined;
2025
- if (hasTopLevel && hasFlat) {
2026
- return { content: [{ type: "text", text: "❌ from/to と fromFlat/toFlat は同時に指定できません。" }], isError: true };
3504
+ const hasRef = args?.fromRef !== undefined || args?.beforeRef !== undefined || args?.afterRef !== undefined;
3505
+ const _modeCount = [hasTopLevel, hasFlat, hasRef].filter(Boolean).length;
3506
+
3507
+ if (_modeCount > 1) {
3508
+ return { content: [{ type: "text", text: "❌ from/to, fromFlat/toFlat, ref(fromRef/beforeRef/afterRef) は同時に指定できません。" }], isError: true };
3509
+ }
3510
+ if (_modeCount === 0) {
3511
+ return { content: [{ type: "text", text: "❌ from/to, fromFlat/toFlat, または fromRef+beforeRef/afterRef を指定してください。" }], isError: true };
3512
+ }
3513
+
3514
+ // expectedRevision チェック(全モード共通)
3515
+ // ref モード以外でも expectedRevision が指定されていれば revision チェックを実施
3516
+ let _preState = null; // Phase 2: 差分計算用
3517
+ if (args?.expectedRevision && !hasRef) {
3518
+ const { currentState, error: _revError } = await acquireFreshState({
3519
+ expectedRevision: args.expectedRevision,
3520
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
3521
+ });
3522
+ if (_revError) return _revError;
3523
+ _preState = currentState || null;
2027
3524
  }
2028
- if (!hasTopLevel && !hasFlat) {
2029
- return { content: [{ type: "text", text: "❌ from/to または fromFlat/toFlat を指定してください。" }], isError: true };
3525
+
3526
+ // ref モード: fromRef + beforeRef/afterRef
3527
+ if (hasRef) {
3528
+ if (!args.fromRef) {
3529
+ return { content: [{ type: "text", text: "❌ ref モードでは fromRef が必須です。" }], isError: true };
3530
+ }
3531
+ if (!args.beforeRef && !args.afterRef) {
3532
+ return { content: [{ type: "text", text: "❌ ref モードでは beforeRef または afterRef が必須です。" }], isError: true };
3533
+ }
3534
+ if (args.beforeRef && args.afterRef) {
3535
+ return { content: [{ type: "text", text: "❌ beforeRef と afterRef は同時に指定できません。" }], isError: true };
3536
+ }
3537
+ if (!args.snapshotId) {
3538
+ return { content: [{ type: "text", text: "❌ ref を使用するには snapshotId が必要です。get_article_structure で取得してください。" }], isError: true };
3539
+ }
3540
+
3541
+ // acquireFreshState で 1 回だけ state 取得 + revision チェック
3542
+ const { currentState, error: _stateError } = await acquireFreshState({
3543
+ expectedRevision: args.expectedRevision,
3544
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
3545
+ });
3546
+ if (_stateError) return _stateError;
3547
+ _preState = currentState || null; // Phase 2: 差分計算用
3548
+
3549
+ // ref 解決(同じ currentState で 2 つの ref を解決)
3550
+ try {
3551
+ fromFlat = resolveRefFromState(args.snapshotId, args.fromRef, mode, _sessionId, postId, currentState);
3552
+ const destRef = args.beforeRef || args.afterRef;
3553
+ const resolvedDest = resolveRefFromState(args.snapshotId, destRef, mode, _sessionId, postId, currentState);
3554
+ // 位置計算: beforeRef → そのまま, afterRef → +1
3555
+ toFlat = args.beforeRef ? resolvedDest : resolvedDest + 1;
3556
+ } catch (e) {
3557
+ return { content: [{ type: "text", text: `❌ ref 解決エラー: ${e.message}` }], isError: true };
3558
+ }
3559
+
3560
+ // ref → flat に変換完了。以降 flat モードに合流
2030
3561
  }
2031
3562
 
3563
+ // Phase 4: 入力 ref 保持(ref モード + count <= 1 のみ差分化)
3564
+ const _inputRefs = (hasRef && count <= 1) ? [args.fromRef] : null;
3565
+
2032
3566
  // 移動結果のレスポンス生成ヘルパー
2033
3567
  const moveMsg = (moved) => {
2034
3568
  if (moved?.noop) return `✅ 移動不要(同じ位置)${_mt_mb}`;
@@ -2039,8 +3573,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2039
3573
  : `✅ ブロック移動 (${typeLabel})${_mt_mb}`;
2040
3574
  };
2041
3575
 
2042
- // fromFlat/toFlat モード(フラットインデックス移動)
2043
- if (hasFlat) {
3576
+ // fromFlat/toFlat モード(フラットインデックス移動)— ref モードもここに合流
3577
+ if (hasFlat || hasRef) {
2044
3578
  if (fromFlat === undefined || toFlat === undefined) {
2045
3579
  return { content: [{ type: "text", text: "❌ fromFlat と toFlat の両方を指定してください。" }], isError: true };
2046
3580
  }
@@ -2051,7 +3585,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2051
3585
  if (mode === 'headless') {
2052
3586
  try {
2053
3587
  const result = await client.headlessMoveFlat(postId, fromFlat, toFlat, count);
2054
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
3588
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3589
+ const _msg = moveMsg(result.moved);
3590
+ if (_inputRefs) {
3591
+ const changeInfo = buildChangeInfoFromResult('moved', _snap, result, _preState, { inputRefs: _inputRefs });
3592
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3593
+ }
3594
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
2055
3595
  } catch (e) {
2056
3596
  const formatted = formatHeadlessConflictError(e);
2057
3597
  if (formatted) return formatted;
@@ -2064,7 +3604,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2064
3604
  return timeoutResponse(name, client, args?.site);
2065
3605
  if (!result.success)
2066
3606
  return errorResponse(name, result.error, args?.site);
2067
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
3607
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3608
+ const _msg = moveMsg(result.moved);
3609
+ if (_inputRefs) {
3610
+ const changeInfo = buildChangeInfoFromResult('moved', _snap, result, _preState, { inputRefs: _inputRefs });
3611
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3612
+ }
3613
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
2068
3614
  }
2069
3615
 
2070
3616
  // 既存モード(from/to トップレベル)
@@ -2075,7 +3621,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2075
3621
  if (mode === 'headless') {
2076
3622
  try {
2077
3623
  const result = await client.headlessMove(postId, from, to, count);
2078
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
3624
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3625
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(moveMsg(result.moved), _snap) }] };
2079
3626
  } catch (e) {
2080
3627
  const formatted = formatHeadlessConflictError(e);
2081
3628
  if (formatted) return formatted;
@@ -2088,7 +3635,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2088
3635
  return timeoutResponse(name, client, args?.site);
2089
3636
  if (!result.success)
2090
3637
  return errorResponse(name, result.error, args?.site);
2091
- return { content: [{ type: "text", text: moveMsg(result.moved) }] };
3638
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3639
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(moveMsg(result.moved), _snap) }] };
2092
3640
  }
2093
3641
 
2094
3642
  case "undo": {
@@ -2124,7 +3672,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2124
3672
  }
2125
3673
 
2126
3674
  case "duplicate_block": {
2127
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
3675
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2128
3676
  if (mode === 'error') {
2129
3677
  return errorResponse(name, message, args?.site);
2130
3678
  }
@@ -2133,8 +3681,26 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2133
3681
  if (_resolved.editorConnected) mode = 'editor';
2134
3682
  const postId = _resolved.postId ?? _postId;
2135
3683
  const _mt_dup = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
3684
+ const _siteName = siteName || args?.site || 'default';
3685
+
3686
+ // ref と index 排他チェック
3687
+ if (args?.ref && args?.index !== undefined) {
3688
+ return { content: [{ type: "text", text: "❌ ref と index は同時に指定できません。" }], isError: true };
3689
+ }
2136
3690
 
2137
- const { index } = args;
3691
+ // ref 解決 + revision チェック
3692
+ const resolved = await resolveRefsAndCheckRevision({
3693
+ snapshotId: args?.snapshotId, ref: args?.ref,
3694
+ expectedRevision: args?.expectedRevision,
3695
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
3696
+ });
3697
+ if (resolved.error) return resolved.error;
3698
+ const _preState = resolved.currentState || null; // Phase 2: 差分計算用
3699
+
3700
+ const index = resolved.index !== undefined ? resolved.index : args?.index;
3701
+
3702
+ // Phase 3: 入力 ref を保持(index 経路は差分化しない)
3703
+ const _inputRef = args?.ref || null;
2138
3704
 
2139
3705
  if (mode === 'headless') {
2140
3706
  const _guard = await guardHeadlessConflict(postId, client, name);
@@ -2144,7 +3710,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2144
3710
  }
2145
3711
  try {
2146
3712
  const result = await client.headlessDuplicate(postId, index);
2147
- return { content: [{ type: "text", text: `✅ ブロック複製完了 (index ${result.duplicated.index} → ${result.duplicated.newIndex})${_mt_dup}` }] };
3713
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3714
+ const _msg = `✅ ブロック複製完了 (index ${result.duplicated.index} → ${result.duplicated.newIndex})${_mt_dup}`;
3715
+ if (_inputRef) {
3716
+ const changeInfo = buildChangeInfoFromResult('duplicated', _snap, result, _preState, { inputRef: _inputRef });
3717
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3718
+ }
3719
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
2148
3720
  } catch (e) {
2149
3721
  const formatted = formatHeadlessConflictError(e);
2150
3722
  if (formatted) return formatted;
@@ -2157,7 +3729,13 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2157
3729
  return timeoutResponse(name, client, args?.site);
2158
3730
  if (!result.success)
2159
3731
  return errorResponse(name, result.error, args?.site);
2160
- return { content: [{ type: "text", text: `✅ ブロック複製完了${_mt_dup}` }] };
3732
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
3733
+ const _msg = `✅ ブロック複製完了${_mt_dup}`;
3734
+ if (_inputRef) {
3735
+ const changeInfo = buildChangeInfoFromResult('duplicated', _snap, result, _preState, { inputRef: _inputRef });
3736
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
3737
+ }
3738
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
2161
3739
  }
2162
3740
 
2163
3741
  case "save_post": {
@@ -2362,13 +3940,38 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2362
3940
  }
2363
3941
 
2364
3942
  case "insert_block": {
2365
- let { rawHTML, filePath, index } = (args || {});
3943
+ let { rawHTML, filePath, index, position } = (args || {});
3944
+
3945
+ // parentIndex は廃止済み(v3.0.0 Phase 5)— position を使用
3946
+ if (args?.parentIndex !== undefined) {
3947
+ return { content: [{ type: "text", text: "❌ parentIndex は廃止されました。代わりに index + position ('before'/'after') を使用してください。" }], isError: true };
3948
+ }
2366
3949
 
2367
3950
  // 排他チェック
2368
3951
  if (rawHTML && filePath) {
2369
3952
  return { content: [{ type: "text", text: "❌ rawHTML と filePath は同時に指定できません。どちらか一方を指定してください。" }], isError: true };
2370
3953
  }
2371
3954
 
3955
+ // Phase 4b: beforeRef/afterRef と index/position の排他チェック
3956
+ const _hasRefPosition = args?.beforeRef !== undefined || args?.afterRef !== undefined;
3957
+ const _hasIndexPosition = index !== undefined || position !== undefined;
3958
+ if (_hasRefPosition && _hasIndexPosition) {
3959
+ return { content: [{ type: "text", text: "❌ beforeRef/afterRef と index/position は同時に指定できません。" }], isError: true };
3960
+ }
3961
+ if (args?.beforeRef && args?.afterRef) {
3962
+ return { content: [{ type: "text", text: "❌ beforeRef と afterRef は同時に指定できません。" }], isError: true };
3963
+ }
3964
+
3965
+ // position 指定時は index 必須(ref モードでないとき)
3966
+ if (!_hasRefPosition && position !== undefined && index === undefined) {
3967
+ return { content: [{ type: "text", text: "❌ position を指定する場合は index も指定してください。末尾追加は index を省略してください。" }], isError: true };
3968
+ }
3969
+
3970
+ // position バリデーション
3971
+ if (position !== undefined && position !== "before" && position !== "after") {
3972
+ return { content: [{ type: "text", text: `❌ position は 'before' または 'after' を指定してください: ${position}` }], isError: true };
3973
+ }
3974
+
2372
3975
  // filePath → rawHTML 解決
2373
3976
  if (filePath) {
2374
3977
  try { rawHTML = readHTMLFromFile(filePath).html; }
@@ -2378,21 +3981,39 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2378
3981
  return { content: [{ type: "text", text: "❌ rawHTML または filePath を指定してください。Gutenberg HTML 形式のブロックマークアップが必要です。" }], isError: true };
2379
3982
  }
2380
3983
 
2381
- // index バリデーション(現行 Editor 互換: 整数 >= 0
2382
- if (index !== undefined && (!Number.isInteger(index) || index < 0)) {
3984
+ // index バリデーション(現行 Editor 互換: 整数 >= 0)— ref モードでないとき
3985
+ if (!_hasRefPosition && index !== undefined && (!Number.isInteger(index) || index < 0)) {
2383
3986
  return { content: [{ type: "text", text: `❌ Invalid index: ${index}` }], isError: true };
2384
3987
  }
2385
3988
 
2386
- // update_blocks コードパスに委譲(新形式 target/insert を使用)
3989
+ // update_blocks コードパスに委譲
3990
+ // expectedRevision は全モードで委譲(ref/index/append 問わず)
2387
3991
  const delegatedArgs = {
2388
3992
  postId: args.postId,
2389
3993
  site: args.site,
2390
3994
  newHTML: rawHTML,
2391
- insert: { position: 'before' },
2392
3995
  _fromInsertBlock: true,
2393
- appendToEnd: index === undefined,
3996
+ expectedRevision: args.expectedRevision,
2394
3997
  };
2395
- if (index !== undefined) delegatedArgs.target = { index };
3998
+
3999
+ if (_hasRefPosition) {
4000
+ // ref モード: target.ref + insert.position で委譲
4001
+ const _ref = args.beforeRef || args.afterRef;
4002
+ delegatedArgs.target = { ref: _ref };
4003
+ delegatedArgs.insert = { position: args.beforeRef ? 'before' : 'after' };
4004
+ delegatedArgs.snapshotId = args.snapshotId;
4005
+ delegatedArgs.appendToEnd = false;
4006
+ } else if (index !== undefined) {
4007
+ // 既存: index モード
4008
+ delegatedArgs.target = { index };
4009
+ delegatedArgs.insert = { position: position ?? 'before' };
4010
+ delegatedArgs.appendToEnd = false;
4011
+ } else {
4012
+ // 末尾追加
4013
+ delegatedArgs.insert = { position: 'before' };
4014
+ delegatedArgs.appendToEnd = true;
4015
+ }
4016
+
2396
4017
  return await handleUpdateBlocksTool(delegatedArgs, name);
2397
4018
  }
2398
4019
 
@@ -2468,7 +4089,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2468
4089
  }
2469
4090
 
2470
4091
  case "get_selection": {
2471
- const { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
4092
+ const { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2472
4093
  if (mode === 'error') {
2473
4094
  return errorResponse(name, message, args?.site);
2474
4095
  }
@@ -2484,16 +4105,48 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2484
4105
  content: [{ type: "text", text: `ブロックが選択されていません。${_mt_gs}` }],
2485
4106
  };
2486
4107
  }
4108
+
4109
+ // ref 対応: fresh state から snapshot を毎回新規作成
4110
+ // safetyCritical: true — stale Store へのフォールバックを防ぐ
4111
+ const _siteName_gs = siteName || args?.site || 'default';
4112
+ let _snapInfo = null;
4113
+ try {
4114
+ const freshState = await getCurrentStructure(mode, client, _postId, _sessionId, { safetyCritical: true });
4115
+ if (freshState?.allBlocks?.length > 0) {
4116
+ _snapInfo = buildSnapshotFromState(freshState, mode, _postId, _sessionId, _siteName_gs);
4117
+ }
4118
+ } catch (_e) {
4119
+ // fresh state 取得失敗 → ref なしで index-only にフォールバック
4120
+ }
4121
+
2487
4122
  // 複数選択
2488
4123
  if (sel.isMultiSelect && sel.blockIds) {
2489
4124
  let text = `選択中: ${sel.blockIds.length}ブロック\n` +
2490
4125
  `タイプ: ${sel.blockTypes?.join(", ")}\n` +
2491
- `位置: ${sel.blockIndices?.join(", ")}${_mt_gs}`;
4126
+ `位置: ${sel.blockIndices?.join(", ")}`;
4127
+ if (_snapInfo && sel.blockIndices?.length > 0) {
4128
+ const refs = sel.blockIndices
4129
+ .map(idx => _snapInfo.blocks?.find(b => b.index === idx)?.ref)
4130
+ .filter(Boolean);
4131
+ // 全数一致のときだけ refs を返す(部分一致は AI を誤誘導するため)
4132
+ if (refs.length === sel.blockIndices.length) {
4133
+ text += `\nrefs: ${refs.join(", ")}`;
4134
+ text += `\n[snapshot:${_snapInfo.snapshotId} rev:${_snapInfo.revision}]`;
4135
+ }
4136
+ }
4137
+ text += _mt_gs;
2492
4138
  return { content: [{ type: "text", text }] };
2493
4139
  }
2494
4140
  // 単一選択
2495
4141
  let text = `選択中: ${sel.blockType}\n` +
2496
4142
  `位置: index ${sel.blockIndex}`;
4143
+ if (_snapInfo) {
4144
+ const refEntry = _snapInfo.blocks?.find(b => b.index === sel.blockIndex);
4145
+ if (refEntry) {
4146
+ text += `\nref: ${refEntry.ref}`;
4147
+ text += `\n[snapshot:${_snapInfo.snapshotId} rev:${_snapInfo.revision}]`;
4148
+ }
4149
+ }
2497
4150
  if (sel.textSelection?.text) {
2498
4151
  text += `\n\nカーソル選択テキスト: "${sel.textSelection.text}"`;
2499
4152
  if (sel.textSelection.context) {
@@ -2510,7 +4163,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2510
4163
  }
2511
4164
 
2512
4165
  case "table_operations": {
2513
- let { mode, postId: _postId, sessionId: _sessionId, message, client } = await resolveMode(args, name);
4166
+ let { mode, postId: _postId, sessionId: _sessionId, message, client, siteName } = await resolveMode(args, name);
2514
4167
  if (mode === 'error') {
2515
4168
  return errorResponse(name, message, args?.site);
2516
4169
  }
@@ -2518,15 +4171,33 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2518
4171
  if (_resolved.error) return { content: [{ type: "text", text: _resolved.error }], isError: true };
2519
4172
  if (_resolved.editorConnected) mode = 'editor';
2520
4173
  const postId = _resolved.postId ?? _postId;
4174
+ const _siteName = siteName || args?.site || 'default';
2521
4175
 
2522
- const { index, action } = (args || {});
2523
- if (index === undefined) {
2524
- return { content: [{ type: "text", text: "❌ index は必須です" }], isError: true };
2525
- }
4176
+ const { action } = (args || {});
4177
+ const _inputRef = args?.ref || null;
2526
4178
  if (!action) {
2527
4179
  return { content: [{ type: "text", text: "❌ action は必須です" }], isError: true };
2528
4180
  }
2529
4181
 
4182
+ // ref と index 排他チェック
4183
+ if (args?.ref && args?.index !== undefined) {
4184
+ return { content: [{ type: "text", text: "❌ ref と index は同時に指定できません。" }], isError: true };
4185
+ }
4186
+
4187
+ // ref 解決 + revision チェック
4188
+ const resolved = await resolveRefsAndCheckRevision({
4189
+ snapshotId: args?.snapshotId, ref: args?.ref,
4190
+ expectedRevision: args?.expectedRevision,
4191
+ mode, client, postId, sessionId: _sessionId, siteName: _siteName,
4192
+ });
4193
+ if (resolved.error) return resolved.error;
4194
+ const _preState = resolved.currentState || null; // Phase 2: 差分計算用
4195
+
4196
+ const index = resolved.index !== undefined ? resolved.index : args?.index;
4197
+ if (index === undefined) {
4198
+ return { content: [{ type: "text", text: "❌ table_operations では index または ref のいずれかが必要です。" }], isError: true };
4199
+ }
4200
+
2530
4201
  const tableParams = {
2531
4202
  index, action,
2532
4203
  row: args.row, col: args.col,
@@ -2557,16 +4228,25 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2557
4228
  return text;
2558
4229
  };
2559
4230
 
4231
+ const _isWrite = action !== 'get_structure';
2560
4232
  const _mt_to = process.env.FRIDAY_DEBUG === '1' ? `\n[DEBUG] mode=${mode}` : '';
2561
4233
  if (mode === 'headless') {
2562
4234
  const _guard = await guardHeadlessConflict(postId, client, name);
2563
4235
  if (_guard) return _guard;
2564
4236
  try {
2565
4237
  const result = await client.headlessTableOperation(postId, tableParams);
2566
- if (action === 'get_structure') {
4238
+ if (!_isWrite) {
2567
4239
  return { content: [{ type: "text", text: formatStructure(result) + _mt_to }] };
2568
4240
  }
2569
- return { content: [{ type: "text", text: `✅ ${action} 完了: ${JSON.stringify(result.detail || result)}${_mt_to}` }] };
4241
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
4242
+ const _msg = `✅ ${action} 完了: ${JSON.stringify(result.detail || result)}${_mt_to}`;
4243
+ if (_inputRef) {
4244
+ const changedIndex = result.targetIndex ?? index;
4245
+ const syntheticResult = { changes: [{ oldIndex: changedIndex, newIndices: [changedIndex], success: true }] };
4246
+ const changeInfo = buildChangeInfoFromResult('updated', _snap, syntheticResult, _preState);
4247
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
4248
+ }
4249
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
2570
4250
  } catch (e) {
2571
4251
  const formatted = formatHeadlessConflictError(e);
2572
4252
  if (formatted) return formatted;
@@ -2581,10 +4261,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2581
4261
  if (!result.success)
2582
4262
  return errorResponse(name, result.error, args?.site);
2583
4263
 
2584
- if (action === 'get_structure') {
4264
+ if (!_isWrite) {
2585
4265
  return { content: [{ type: "text", text: formatStructure(result.structure) + _mt_to }] };
2586
4266
  }
2587
- return { content: [{ type: "text", text: `✅ ${action} 完了: ${JSON.stringify(result.detail || {})}${_mt_to}` }] };
4267
+ const _snap = await buildResponseSnapshot(mode, client, postId, _sessionId, _siteName, result);
4268
+ const _msg = `✅ ${action} 完了: ${JSON.stringify(result.detail || {})}${_mt_to}`;
4269
+ if (_inputRef) {
4270
+ const changedIndex = result.targetIndex ?? index;
4271
+ const syntheticResult = { changes: [{ oldIndex: changedIndex, newIndices: [changedIndex], success: true }] };
4272
+ const changeInfo = buildChangeInfoFromResult('updated', _snap, syntheticResult, _preState);
4273
+ if (changeInfo) return { content: [{ type: "text", text: appendRefChangesToText(_msg, changeInfo) }] };
4274
+ }
4275
+ return { content: [{ type: "text", text: appendSnapshotToTextLegacy(_msg, _snap) }] };
2588
4276
  }
2589
4277
 
2590
4278
  case "open_in_browser": {