tabtree-mcp 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +15 -2
  2. package/package.json +2 -2
  3. package/tabtree-mcp.mjs +517 -30
package/README.md CHANGED
@@ -168,14 +168,27 @@ bullet lists both work, two spaces per level, `[x]` for a checked item and
168
168
  `[title](url)` for a link. The first top-level line becomes the centre of the
169
169
  map.
170
170
 
171
+ **Screenshots on nodes.** Ask Claude to build a map *with screenshots* — of an
172
+ app, a website, a slide deck. Claude takes the screenshot, saves it to disk, and
173
+ passes its path in `images`: `{ target: "Login page", file: "/path/to/shot.png" }`.
174
+ The picture becomes the node's cover, drawn on the map, in the kanban and in
175
+ every exported image. It is re-encoded on your machine before it is written
176
+ (about 840 px, a few dozen KB), so a 4 MB Retina capture never bloats the map;
177
+ nothing is uploaded anywhere. On macOS the connector uses the built-in `sips`;
178
+ elsewhere it uses ImageMagick if installed, or its own PNG encoder. The same
179
+ works on a map you already have: `propose_changes` with `op: "img"`.
180
+
171
181
  `create_board` takes a list of elements (`rect`, `ellipse`, `text`, `sticky`)
172
182
  and connections between them. Leave out the x/y coordinates and it lays the
173
183
  board out automatically — the connections define the hierarchy, left to right.
174
184
  Both accept an optional `folder`, which becomes a folder in your library.
175
185
 
176
186
  `propose_changes` is the one tool that points at a map you already have. It can
177
- add a branch, rename a node, write a note, tick a task, or remove a branch — and
178
- it does none of those things by itself. It writes a small file into a
187
+ add a branch, rename a node, write a note, tick a task, remove a branch, put a
188
+ screenshot on a node — and, on a kanban, move a card to a column, set its colour
189
+ labels, assign it to someone, give it a priority, a due date, an estimate or a
190
+ `Name: value` attribute, or leave a comment on it (signed "Claude" when applied).
191
+ It does none of those things by itself. It writes a small file into a
179
192
  `Propositions` subfolder of your backup folder; TabTree checks that folder while
180
193
  it is open and raises a banner on the map concerned. You review the changes one
181
194
  by one, tick the ones you want, and press **Apply**. ⌘Z undoes the lot.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "tabtree-mcp",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "mcpName": "app.tabtree/tabtree",
5
- "description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards. Local files, no account, nothing uploaded.",
5
+ "description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards — in your TabTree account, or from local files.",
6
6
  "repository": {
7
7
  "type": "git",
8
8
  "url": "git+https://github.com/ai4xedu/tabtree-mcp.git"
package/tabtree-mcp.mjs CHANGED
@@ -18,8 +18,11 @@
18
18
  // Protocole : JSON-RPC 2.0, un message JSON par ligne sur stdin/stdout.
19
19
  // ============================================================================
20
20
 
21
- import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync } from "node:fs";
22
- import { join, basename } from "node:path";
21
+ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSync, mkdtempSync, rmSync } from "node:fs";
22
+ import { join, basename, resolve } from "node:path";
23
+ import { execFileSync } from "node:child_process";
24
+ import { inflateSync, deflateSync } from "node:zlib";
25
+ import { tmpdir, platform } from "node:os";
23
26
 
24
27
  const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
25
28
  // ---------------------------------------------------------------------------
@@ -29,10 +32,27 @@ const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
29
32
  // outils ne changent pas d'un mot de contrat, seule la tuyauterie de lecture/écriture change.
30
33
  // `TABTREE_API_URL` n'est là que pour une préversion — la valeur par défaut est la production.
31
34
  // ---------------------------------------------------------------------------
32
- const API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
35
+ let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
36
+ // ⚠️ UN CHAMP NON RENSEIGNÉ N'EST PAS UNE CLÉ FAUSSE (2026-09-09). Claude Desktop passe le
37
+ // gabarit `${user_config.api_key}` TEL QUEL quand le champ est vide — mesuré le jour où la
38
+ // réinstallation du .mcpb a vidé la configuration (la clé est `sensitive`, donc rangée dans le
39
+ // Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
40
+ // une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
41
+ // message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
42
+ if(/^\$\{.*\}$/.test(API_KEY)) API_KEY = "";
33
43
  const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
34
44
  const CLOUD = !!API_KEY;
45
+ // La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
46
+ // `tt_live_` + 43 caractères base64url (`connKeyMaterial`). Une valeur qui n'y ressemble pas est
47
+ // une faute de copie ou un champ mal rempli — le dire tout de suite nomme la bonne cause, et
48
+ // évite d'aller faire dire au serveur quelque chose qu'il ne sait pas.
49
+ const API_KEY_OK = /^tt_live_[A-Za-z0-9_-]{20,}$/.test(API_KEY);
35
50
  async function cloudCall(action, body){
51
+ if(!API_KEY_OK) throw new Error(
52
+ "TABTREE_API_KEY does not look like a connector key — it must start with \"tt_live_\". "
53
+ + "If you just reinstalled the connector, its settings were reset: open TabTree → \u2699\ufe0f Settings "
54
+ + "\u2192 Account \u2192 Claude connector, create a key, paste it into the connector's settings, "
55
+ + "then restart the connector.");
36
56
  let r;
37
57
  try{
38
58
  r = await fetch(API_URL, { method:"POST",
@@ -140,7 +160,11 @@ const ART_IDS_MCP = [
140
160
  "house", "pin", "plane", "coffee", "plant", "sun",
141
161
  "camera", "music", "lock", "key", "health", "gift",
142
162
  "manager", "client", "expert", "assistant", "coach", "learner",
143
- "developer", "seller", "support", "speaker", "freelancer", "group"
163
+ "developer", "seller", "support", "speaker", "freelancer", "group",
164
+ "uilogin", "uifeed", "uichat", "uidash", "uichart", "uishop",
165
+ "uimap", "uivideo", "uislide", "uiprofile", "uiboard", "uitable",
166
+ "atom", "dna", "flask", "microscope", "brain", "planet",
167
+ "pencil", "gradcap", "calc", "palette", "ruler", "backpack"
144
168
  ];
145
169
  const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
146
170
  const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
@@ -168,6 +192,276 @@ function tplPicAssign(n){
168
192
  })(n);
169
193
  }
170
194
 
195
+ // ---------------------------------------------------------------------------
196
+ // Une CAPTURE D'ÉCRAN sur un nœud (`node.img`, 2026-09-15)
197
+ //
198
+ // Demande de l'utilisateur : « générer une mindmap avec des screenshots tirés d'une app ».
199
+ // Le champ existait de bout en bout — `node.img` est la COUVERTURE d'un nœud, tenue dans les
200
+ // cinq places, peinte sur le canevas, dans l'export et sur le recto kanban. Ce qui manquait,
201
+ // c'est la PORTE : le connecteur ne savait poser qu'une désignation (`pic`), jamais des pixels.
202
+ //
203
+ // Deux contraintes décident de tout ce qui suit :
204
+ // 1. `okCover` (index.html) n'accepte qu'un `data:image/(png|jpeg|webp|gif)` de moins de
205
+ // COVER_MAX_BYTES caractères. Une capture d'écran Retina fait 2 à 5 Mo : embarquée telle
206
+ // quelle, l'app la REFUSE à l'ouverture et le nœud arrive nu — sans une erreur. Il faut donc
207
+ // ré-encoder ICI, avant d'écrire. La constante et la fonction sont portées MOT POUR MOT et le
208
+ // test les compare à l'app : si elles divergent, le connecteur écrit une image que l'app
209
+ // efface, et personne ne sait pourquoi.
210
+ // 2. Le connecteur est ZÉRO dépendance et Node n'a aucun codec d'image. Trois encodeurs, dans
211
+ // l'ordre : `sips` (livré avec macOS), `magick`/`convert` (ImageMagick, s'il est là), et un
212
+ // chemin PUR JS — décodeur PNG (zlib est natif), réduction par moyenne de zone, encodeur PNG.
213
+ // Le chemin JS ne lit que du PNG, ce qui est le format de TOUTES les captures que Claude
214
+ // produit (navigateur, computer-use, simulateur). Quand rien ne tient dans le budget, on
215
+ // REFUSE en nommant la limite — jamais une image tronquée, jamais un nœud silencieusement nu.
216
+ //
217
+ // Le côté long est ré-encodé à IMG_LONG = 840 px, soit 2 × COVER_MAX_DIM : l'app affiche une
218
+ // couverture à 420 px au plus, et 840 la garde nette sur un écran Retina. Au-delà on stockerait
219
+ // des pixels que personne ne voit. Le budget par image (IMG_BUDGET) est bien plus bas que
220
+ // COVER_MAX_BYTES, et c'est délibéré : vingt captures à 900 Ko feraient un document de 18 Mo, lu
221
+ // SYNCHRONIQUEMENT depuis le miroir localStorage de l'app. À 840 px en JPEG, une capture pèse
222
+ // 80 à 200 Ko ; en PNG (chemin JS) 150 à 400 Ko — d'où les trois paliers de repli.
223
+ // ---------------------------------------------------------------------------
224
+ const COVER_MAX_BYTES = 900000; // ~900 Ko de data URL : au-delà, on refuse
225
+ function okCover(v){
226
+ return (typeof v === "string" && v.length <= COVER_MAX_BYTES
227
+ && /^data:image\/(png|jpeg|webp|gif);base64,[A-Za-z0-9+/=]+$/.test(v)) ? v : "";
228
+ }
229
+ const IMG_LONG = 840; // côté long visé (2 × COVER_MAX_DIM de l'app)
230
+ const IMG_STEPS = [840, 600, 420]; // paliers de repli quand le budget ne tient pas
231
+ const IMG_BUDGET = 320000; // data URL par image — bien sous COVER_MAX_BYTES, voir ci-dessus
232
+ const IMG_FILE_MAX = 40 * 1024 * 1024; // un fichier plus gros n'est pas une capture d'écran
233
+ const IMG_JPEG_Q = 82;
234
+ const IMG_MIMES = { png:"image/png", jpeg:"image/jpeg", webp:"image/webp", gif:"image/gif" };
235
+
236
+ // Sonde les octets, jamais l'extension : un « .png » qui contient du JPEG s'ouvre partout, et
237
+ // c'est l'octet qui décide de ce que l'app saura décoder.
238
+ function imageInfo(buf){
239
+ if(!buf || buf.length < 12) return null;
240
+ if(buf[0] === 0x89 && buf.toString("latin1", 1, 4) === "PNG" && buf.length >= 24)
241
+ return { kind:"png", w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
242
+ if(buf[0] === 0xFF && buf[1] === 0xD8){
243
+ let i = 2;
244
+ while(i + 9 < buf.length){
245
+ if(buf[i] !== 0xFF){ i++; continue; }
246
+ const m = buf[i + 1];
247
+ if(m === 0xFF){ i++; continue; }
248
+ if(m >= 0xC0 && m <= 0xCF && m !== 0xC4 && m !== 0xC8 && m !== 0xCC)
249
+ return { kind:"jpeg", h: buf.readUInt16BE(i + 5), w: buf.readUInt16BE(i + 7) };
250
+ if(m === 0xD8 || (m >= 0xD0 && m <= 0xD7) || m === 0x01){ i += 2; continue; }
251
+ i += 2 + buf.readUInt16BE(i + 2);
252
+ }
253
+ return { kind:"jpeg", w:0, h:0 };
254
+ }
255
+ if(buf.toString("latin1", 0, 4) === "GIF8")
256
+ return { kind:"gif", w: buf.readUInt16LE(6), h: buf.readUInt16LE(8) };
257
+ if(buf.toString("latin1", 0, 4) === "RIFF" && buf.toString("latin1", 8, 12) === "WEBP" && buf.length >= 30){
258
+ const c = buf.toString("latin1", 12, 16);
259
+ if(c === "VP8 ") return { kind:"webp", w: buf.readUInt16LE(26) & 0x3fff, h: buf.readUInt16LE(28) & 0x3fff };
260
+ if(c === "VP8L"){ const b = buf.subarray(21, 25); return { kind:"webp", w: 1 + (((b[1] & 0x3F) << 8) | b[0]), h: 1 + (((b[3] & 0xF) << 10) | (b[2] << 2) | ((b[1] & 0xC0) >> 6)) }; }
261
+ if(c === "VP8X") return { kind:"webp", w: 1 + buf.readUIntLE(24, 3), h: 1 + buf.readUIntLE(27, 3) };
262
+ return { kind:"webp", w:0, h:0 };
263
+ }
264
+ return null;
265
+ }
266
+ function dataUrlOf(buf, kind){ return "data:" + IMG_MIMES[kind] + ";base64," + buf.toString("base64"); }
267
+
268
+ // --- Le chemin PUR JS : PNG → RGB, réduction, → PNG ------------------------------------
269
+ // 8 bits, non entrelacé, types 0/2/3/4/6 : c'est ce qu'écrivent Chrome, macOS et Windows pour
270
+ // une capture. Le reste (16 bits, entrelacé) est refusé en le disant. L'alpha est composité
271
+ // sur du BLANC — même règle qu'`avatarFromFile` dans l'app : une capture transparente sortirait
272
+ // noire dans un JPEG, et grise dans l'export.
273
+ function pngDecode(buf){
274
+ let i = 8, w = 0, h = 0, depth = 0, ct = 0, inter = 0, plte = null;
275
+ const idat = [];
276
+ while(i + 8 <= buf.length){
277
+ const len = buf.readUInt32BE(i), type = buf.toString("latin1", i + 4, i + 8);
278
+ const data = buf.subarray(i + 8, i + 8 + len);
279
+ if(type === "IHDR"){ w = data.readUInt32BE(0); h = data.readUInt32BE(4); depth = data[8]; ct = data[9]; inter = data[12]; }
280
+ else if(type === "PLTE") plte = data;
281
+ else if(type === "IDAT") idat.push(data);
282
+ else if(type === "IEND") break;
283
+ i += 12 + len;
284
+ }
285
+ if(!w || !h) throw new Error("the PNG has no IHDR");
286
+ if(depth !== 8) throw new Error("a " + depth + "-bit PNG — only 8-bit PNGs can be re-encoded here");
287
+ if(inter) throw new Error("an interlaced PNG cannot be re-encoded here");
288
+ const ch = { 0:1, 2:3, 3:1, 4:2, 6:4 }[ct];
289
+ if(!ch || (ct === 3 && !plte)) throw new Error("unsupported PNG colour type " + ct);
290
+ const raw = inflateSync(Buffer.concat(idat));
291
+ const stride = w * ch, rgb = new Uint8Array(w * h * 3);
292
+ let prev = new Uint8Array(stride), cur = new Uint8Array(stride), p = 0;
293
+ for(let y = 0; y < h; y++){
294
+ const f = raw[p++];
295
+ for(let x = 0; x < stride; x++){
296
+ const a = x >= ch ? cur[x - ch] : 0, b = prev[x], c = x >= ch ? prev[x - ch] : 0;
297
+ let v = raw[p++];
298
+ if(f === 1) v += a; else if(f === 2) v += b; else if(f === 3) v += (a + b) >> 1;
299
+ else if(f === 4){ const pa = Math.abs(b - c), pb = Math.abs(a - c), pc = Math.abs(a + b - 2 * c); v += (pa <= pb && pa <= pc) ? a : (pb <= pc ? b : c); }
300
+ cur[x] = v & 255;
301
+ }
302
+ for(let x = 0; x < w; x++){
303
+ const q = x * ch; let r, g, bl, al = 255;
304
+ if(ct === 0){ r = g = bl = cur[q]; }
305
+ else if(ct === 4){ r = g = bl = cur[q]; al = cur[q + 1]; }
306
+ else if(ct === 2){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; }
307
+ else if(ct === 6){ r = cur[q]; g = cur[q + 1]; bl = cur[q + 2]; al = cur[q + 3]; }
308
+ else { const k = cur[q] * 3; r = plte[k]; g = plte[k + 1]; bl = plte[k + 2]; }
309
+ if(al < 255){ r = (r * al + 255 * (255 - al)) / 255 | 0; g = (g * al + 255 * (255 - al)) / 255 | 0; bl = (bl * al + 255 * (255 - al)) / 255 | 0; }
310
+ const o = (y * w + x) * 3; rgb[o] = r; rgb[o + 1] = g; rgb[o + 2] = bl;
311
+ }
312
+ const t = prev; prev = cur; cur = t;
313
+ }
314
+ return { w, h, rgb };
315
+ }
316
+ // Moyenne de zone : chaque pixel de sortie est la moyenne du rectangle source qu'il couvre.
317
+ // C'est ce qui garde lisible le texte d'une capture réduite d'un facteur 3 — un simple
318
+ // sous-échantillonnage saute des lignes de texte entières.
319
+ function rgbScale(src, W, H){
320
+ const { w, h, rgb } = src, out = new Uint8Array(W * H * 3);
321
+ for(let Y = 0; Y < H; Y++){
322
+ const y0 = Math.floor(Y * h / H), y1 = Math.max(y0 + 1, Math.floor((Y + 1) * h / H));
323
+ for(let X = 0; X < W; X++){
324
+ const x0 = Math.floor(X * w / W), x1 = Math.max(x0 + 1, Math.floor((X + 1) * w / W));
325
+ let r = 0, g = 0, b = 0, n = 0;
326
+ for(let y = y0; y < y1; y++){ let o = (y * w + x0) * 3; for(let x = x0; x < x1; x++){ r += rgb[o]; g += rgb[o + 1]; b += rgb[o + 2]; o += 3; n++; } }
327
+ const q = (Y * W + X) * 3; out[q] = r / n | 0; out[q + 1] = g / n | 0; out[q + 2] = b / n | 0;
328
+ }
329
+ }
330
+ return { w: W, h: H, rgb: out };
331
+ }
332
+ const CRC_T = (()=>{ const t = new Int32Array(256); for(let n = 0; n < 256; n++){ let c = n; for(let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); t[n] = c; } return t; })();
333
+ function crc32(buf){ let c = -1; for(let i = 0; i < buf.length; i++) c = CRC_T[(c ^ buf[i]) & 255] ^ (c >>> 8); return (c ^ -1) >>> 0; }
334
+ function pngEncode(img){
335
+ const { w, h, rgb } = img;
336
+ const raw = Buffer.alloc((w * 3 + 1) * h);
337
+ for(let y = 0; y < h; y++){ raw[y * (w * 3 + 1)] = 0; raw.set(rgb.subarray(y * w * 3, (y + 1) * w * 3), y * (w * 3 + 1) + 1); }
338
+ const chunk = (type, data)=>{
339
+ const out = Buffer.alloc(12 + data.length);
340
+ out.writeUInt32BE(data.length, 0); out.write(type, 4, "latin1"); data.copy(out, 8);
341
+ out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
342
+ return out;
343
+ };
344
+ const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2;
345
+ return Buffer.concat([Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw, { level: 9 })), chunk("IEND", Buffer.alloc(0))]);
346
+ }
347
+
348
+ // --- Les encodeurs, et l'ordre dans lequel on les essaie ---------------------------------
349
+ // `TABTREE_IMG_ENCODER` force un chemin (js | sips | magick) — c'est ce qui permet au test
350
+ // d'exercer le chemin pur JS sur une machine qui a `sips`, et à qui veut un rendu identique
351
+ // sur toutes ses machines de le demander.
352
+ function encoders(){
353
+ const want = String(process.env.TABTREE_IMG_ENCODER || "").trim();
354
+ if(want) return [want];
355
+ return platform() === "darwin" ? ["sips", "magick", "js"] : ["magick", "js"];
356
+ }
357
+ function withTmpFiles(fn){
358
+ const dir = mkdtempSync(join(tmpdir(), "tabtree-img-"));
359
+ try{ return fn(dir); }
360
+ finally{ try{ rmSync(dir, { recursive:true, force:true }); }catch(e){} }
361
+ }
362
+ // Rend { buf, kind, w, h } ou lève. `long` = côté long visé.
363
+ function encodeWith(name, buf, info, long){
364
+ if(name === "js"){
365
+ if(info.kind !== "png") throw new Error("the built-in encoder only reads PNG");
366
+ const src = pngDecode(buf);
367
+ const k = Math.min(1, long / Math.max(src.w, src.h));
368
+ const W = Math.max(1, Math.round(src.w * k)), H = Math.max(1, Math.round(src.h * k));
369
+ const out = pngEncode(k < 1 ? rgbScale(src, W, H) : src);
370
+ return { buf: out, kind:"png", w: W, h: H };
371
+ }
372
+ return withTmpFiles(dir=>{
373
+ const inp = join(dir, "in." + (info.kind === "jpeg" ? "jpg" : info.kind)), outp = join(dir, "out.jpg");
374
+ writeFileSync(inp, buf);
375
+ if(name === "sips"){
376
+ execFileSync("sips", ["-Z", String(long), "-s", "format", "jpeg", "-s", "formatOptions", String(IMG_JPEG_Q), inp, "--out", outp], { stdio:"ignore", timeout: 20000 });
377
+ } else if(name === "magick"){
378
+ const bin = ["magick", "convert"];
379
+ let ok = false, last = null;
380
+ for(const b of bin){
381
+ try{ execFileSync(b, [inp, "-resize", long + "x" + long + ">", "-quality", String(IMG_JPEG_Q), outp], { stdio:"ignore", timeout: 20000 }); ok = true; break; }
382
+ catch(e){ last = e; }
383
+ }
384
+ if(!ok) throw last || new Error("ImageMagick is not installed");
385
+ } else throw new Error("unknown encoder " + name);
386
+ const out = readFileSync(outp), oi = imageInfo(out);
387
+ if(!oi || oi.kind !== "jpeg") throw new Error(name + " produced no JPEG");
388
+ return { buf: out, kind:"jpeg", w: oi.w, h: oi.h };
389
+ });
390
+ }
391
+
392
+ // --- La porte : un fichier ou une data URL → une couverture que l'app acceptera ---------
393
+ // Rend { img, note } — `note` dit ce qui a été fait, pour la réponse : une image ré-encodée
394
+ // à l'insu de Claude serait une capture qu'il croit envoyer en pleine résolution.
395
+ function readImageSpec(spec, where){
396
+ if(!spec || typeof spec !== "object") throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
397
+ if(typeof spec.data === "string" && spec.data){
398
+ const m = /^data:image\/(png|jpeg|jpg|webp|gif);base64,([A-Za-z0-9+/=\s]+)$/.exec(spec.data.trim());
399
+ if(!m) throw new Error(`${where}: \`data\` must be a data:image/(png|jpeg|webp|gif);base64,… URL.`);
400
+ return { buf: Buffer.from(m[2].replace(/\s+/g, ""), "base64"), label: "inline image" };
401
+ }
402
+ if(typeof spec.file === "string" && spec.file.trim()){
403
+ const p = resolve(spec.file.trim());
404
+ if(!existsSync(p)) throw new Error(`${where}: no file at ${p}. Save the screenshot to disk first (a PNG), then pass its full path.`);
405
+ const st = statSync(p);
406
+ if(!st.isFile()) throw new Error(`${where}: ${p} is not a file.`);
407
+ if(st.size > IMG_FILE_MAX) throw new Error(`${where}: ${basename(p)} is ${(st.size / 1048576).toFixed(0)} MB — too large for a screenshot (40 MB max).`);
408
+ return { buf: readFileSync(p), label: basename(p) };
409
+ }
410
+ throw new Error(`${where}: give \`file\` (a path on this machine) or \`data\` (a data:image/… URL).`);
411
+ }
412
+ function coverFrom(spec, where){
413
+ const { buf, label } = readImageSpec(spec, where);
414
+ const info = imageInfo(buf);
415
+ if(!info) throw new Error(`${where}: ${label} is not a PNG, JPEG, WebP or GIF image.`);
416
+ const long = Math.max(info.w, info.h);
417
+ const src = `${info.w}×${info.h} ${info.kind.toUpperCase()}`;
418
+ // Tel quel quand ça tient DÉJÀ — le budget et la taille : ré-encoder une image qui va bien
419
+ // ne ferait que la dégrader.
420
+ if(long && long <= IMG_LONG){
421
+ const d = dataUrlOf(buf, info.kind);
422
+ if(d.length <= IMG_BUDGET && okCover(d)) return { img: d, note: `${label} (${src}, ${Math.round(d.length / 1024)} KB, kept as is)` };
423
+ }
424
+ const tried = [];
425
+ for(const step of IMG_STEPS){
426
+ if(long && step > long && step !== IMG_STEPS[IMG_STEPS.length - 1]) continue; // ne pas « réduire » vers plus grand
427
+ for(const enc of encoders()){
428
+ let out;
429
+ try{ out = encodeWith(enc, buf, info, step); }
430
+ catch(e){ tried.push(enc + "@" + step + ": " + (e && e.message || e).split("\n")[0]); continue; }
431
+ const d = dataUrlOf(out.buf, out.kind);
432
+ if(d.length <= IMG_BUDGET && okCover(d))
433
+ return { img: d, note: `${label} (${src} → ${out.w}×${out.h} ${out.kind.toUpperCase()}, ${Math.round(d.length / 1024)} KB, via ${enc})` };
434
+ tried.push(enc + "@" + step + ": " + Math.round(d.length / 1024) + " KB, over the " + Math.round(IMG_BUDGET / 1024) + " KB budget");
435
+ }
436
+ }
437
+ throw new Error(`${where}: ${label} (${src}) could not be brought under ${Math.round(IMG_BUDGET / 1024)} KB. `
438
+ + (info.kind === "png" ? "Crop it to the part that matters, or " : "Save it as a PNG (the built-in encoder only reads PNG), or ")
439
+ + "install ImageMagick. Tried: " + tried.join("; "));
440
+ }
441
+ // Pose les captures d'une carte NEUVE : chaque entrée désigne un nœud par son texte (ou un
442
+ // chemin « Parent > Nœud »), exactement comme une proposition — une désignation qui touche deux
443
+ // nœuds est refusée, jamais devinée. Une capture REMPLACE une illustration `pic` : deux images
444
+ // sur un nœud seraient deux vérités pour une question, et c'est `deserialize` qui trancherait.
445
+ function applyImages(root, images){
446
+ if(images == null) return [];
447
+ if(!Array.isArray(images)) throw new Error("`images` must be a list of { target, file } (or { target, data }).");
448
+ if(images.length > 60) throw new Error("Too many screenshots in one map (60 max).");
449
+ const notes = [];
450
+ images.forEach((im, i)=>{
451
+ const where = "images[" + i + "]";
452
+ const target = String((im && im.target) == null ? "" : im.target);
453
+ const r = propFind(root, target);
454
+ if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the exact text of the node the screenshot goes on.`);
455
+ if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this outline. Use the exact wording of a line (without its bullet, checkbox or @-marker).`);
456
+ if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
457
+ const { img, note } = coverFrom(im, where);
458
+ r.node.img = img;
459
+ if(r.node.pic) delete r.node.pic;
460
+ notes.push("“" + String(r.node.text || "").slice(0, 60) + "” ← " + note);
461
+ });
462
+ return notes;
463
+ }
464
+
171
465
  // ---------------------------------------------------------------------------
172
466
  // Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
173
467
  //
@@ -209,14 +503,31 @@ function propFind(root, spec){
209
503
  // ---------------------------------------------------------------------------
210
504
  // Arbre → markdown (lecture d'une carte)
211
505
  // ---------------------------------------------------------------------------
506
+ // Les marqueurs ⟪…⟫ disent ce qu'un plan ne montre pas (2026-10-12) : les dépendances (par le
507
+ // TEXTE du nœud attendu, résolu ici — Claude ne lit pas des ids), la récurrence, le nombre de
508
+ // commentaires et l'estimation. Sans eux, Claude proposerait une date avant celle du
509
+ // prédécesseur ou cocherait une tâche qui repart aussitôt, sans rien savoir de ce qu'il fait.
510
+ const RECUR_LABEL = { d:"daily", wd:"weekdays", w:"weekly", "2w":"every 2 weeks", m:"monthly", q:"quarterly", y:"yearly" };
511
+ function durText(sec){ const m = Math.round(sec / 60), h = Math.floor(m / 60); return h ? (h + "h" + (m % 60 ? String(m % 60).padStart(2, "0") : "")) : (m + "m"); }
212
512
  function nodeToMarkdown(n, depth){
213
513
  const lines = [];
514
+ const byId = new Map();
515
+ (function idx(node){ if(node && node.id) byId.set(node.id, node); (node.children||[]).forEach(idx); })(n);
214
516
  (function walk(node, d){
215
517
  let bullet;
216
518
  if(d===0) bullet = "# ";
217
519
  else bullet = " ".repeat(d-1) + (node.check!=null ? (node.check ? "- [x] " : "- [ ] ") : "- ");
218
520
  const body = node.link ? `[${node.text}](${node.link})` : node.text;
219
- lines.push(bullet + body + (node.note && node.note.trim() ? " ⟪note⟫" : ""));
521
+ const marks = [];
522
+ if(node.note && node.note.trim()) marks.push("⟪note⟫");
523
+ if(Array.isArray(node.deps) && node.deps.length){
524
+ const names = node.deps.map(id => byId.get(id)).filter(Boolean).map(p => String(p.text || "").trim()).filter(Boolean);
525
+ if(names.length) marks.push("⟪waits on: " + names.join(", ") + "⟫");
526
+ }
527
+ if(node.recur && RECUR_LABEL[node.recur]) marks.push("⟪repeats " + RECUR_LABEL[node.recur] + "⟫");
528
+ if(Array.isArray(node.cmts) && node.cmts.length) marks.push("⟪" + node.cmts.length + " comment" + (node.cmts.length > 1 ? "s" : "") + "⟫");
529
+ if(typeof node.est === "number" && node.est > 0) marks.push("⟪est " + durText(node.est) + "⟫");
530
+ lines.push(bullet + body + (marks.length ? " " + marks.join(" ") : ""));
220
531
  (node.children||[]).forEach(c=>walk(c, d+1));
221
532
  })(n, depth||0);
222
533
  return lines.join("\n");
@@ -437,8 +748,17 @@ function readManifest(){
437
748
  while(f && !seen.has(f.id) && out.length <= 16){ seen.add(f.id); out.unshift(f.name); f = f.parentId ? folders.get(f.parentId) : null; }
438
749
  return out.length ? out.join(" / ") : null;
439
750
  };
751
+ // Un ONGLET (2026-09-15) est un document CACHÉ de la bibliothèque : `sub` porte l'id de son
752
+ // classeur. Sans le lire, Claude verrait « Sheet 2 » comme une carte ordinaire, sans savoir
753
+ // de quel classeur elle est la seconde page — et proposerait de la ranger, ou de la lier.
754
+ const byId = new Map((m.maps||[]).filter(x=>x && x.id).map(x=>[x.id, x]));
755
+ const okId = v => typeof v === "string" && /^[A-Za-z0-9_]{1,64}$/.test(v);
440
756
  const byFile = new Map();
441
- (m.maps||[]).forEach(x=>{ if(x && x.file) byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId) }); });
757
+ (m.maps||[]).forEach(x=>{
758
+ if(!x || !x.file) return;
759
+ const book = okId(x.sub) && x.sub !== x.id ? byId.get(x.sub) : null;
760
+ byFile.set(x.file, { name:x.name, folder: pathOf(x.folderId), tabOf: book ? (book.name || book.id) : null });
761
+ });
442
762
  return byFile;
443
763
  }catch(e){ return new Map(); }
444
764
  }
@@ -469,6 +789,7 @@ function scanLibrary(){
469
789
  file: rel,
470
790
  name: meta.name || ent.name.replace(/\.(tabtree|ygmind|json)$/i, "").replace(/__map[A-Za-z0-9_]+$/, ""),
471
791
  folder: meta.folder || folder || null,
792
+ tabOf: meta.tabOf || null, // le classeur dont ce document est un onglet, sinon null
472
793
  kind: doc.board ? "board" : "map",
473
794
  nodes: countNodes(doc.root),
474
795
  elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
@@ -558,17 +879,50 @@ async function findMap(file){
558
879
  // ---------------------------------------------------------------------------
559
880
  async function writeDoc(name, doc, folder){
560
881
  if(CLOUD){
561
- // Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il existe déjà,
562
- // sinon la carte arrive à la racine et le texte de réponse le dit (« no silent caps »).
563
- let folder_key = null;
882
+ // Le dossier est un NOM ou un CHEMIN de dossier de bibliothèque : on le résout en clé s'il
883
+ // existe déjà, sinon la carte arrive à la racine et le texte de réponse le dit
884
+ // (« no silent caps »).
885
+ //
886
+ // ⚠️ 2026-09-23 — LE NOM SEUL NE SUFFIT PAS. Cette résolution comparait `f.name` et prenait
887
+ // le PREMIER trouvé. Un compte qui a « Ai4X » à la racine ET un « Ai4X » imbriqué dedans
888
+ // (ce qui arrive dès qu'on range un dossier dans un autre) faisait alors tirer au sort :
889
+ // les cartes atterrissaient dans « Ai4X / Ai4X » pendant que leur auteur les cherchait dans
890
+ // « Ai4X », et concluait que le connecteur n'avait rien écrit. Deux règles maintenant :
891
+ // un CHEMIN complet (« Ai4X / Ai4X ») gagne toujours, et un nom seul prend le dossier le
892
+ // MOINS PROFOND — celui qu'on a en tête quand on écrit « Ai4X ». L'ambiguïté est dite.
893
+ let folder_key = null, folder_path = null, folder_twins = 0;
564
894
  if(folder){
565
895
  const j = await cloudCall("list", {});
566
- const hit = (j.folders||[]).find(f=>f && String(f.name||"").trim().toLowerCase() === String(folder).trim().toLowerCase());
567
- if(hit) folder_key = hit.folder_key;
896
+ const all = (j.folders||[]).filter(f=>f && f.folder_key);
897
+ const byKey = new Map(all.map(f=>[f.folder_key, f]));
898
+ const chain = (k)=>{ const out=[]; const seen=new Set(); let f=byKey.get(k);
899
+ while(f && !seen.has(f.folder_key) && out.length<=16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? byKey.get(f.parent_key) : null; }
900
+ return out; };
901
+ const norm = (x)=>String(x||"").split("/").map(t=>t.trim()).filter(Boolean).join(" / ").toLowerCase();
902
+ const want = norm(folder);
903
+ let hits = all.filter(f=>norm(chain(f.folder_key).join(" / ")) === want); // chemin exact
904
+ if(!hits.length) hits = all.filter(f=>norm(f.name) === want) // nom de feuille
905
+ .sort((a,b)=>chain(a.folder_key).length - chain(b.folder_key).length);
906
+ if(hits.length){
907
+ folder_key = hits[0].folder_key;
908
+ folder_path = chain(folder_key).join(" / ");
909
+ folder_twins = hits.length;
910
+ }
568
911
  }
569
912
  const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
570
913
  const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
571
- return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
914
+ // On DIT toujours où la carte a atterri : le chemin complet, pas le nom demandé. C'est ce
915
+ // qui permet de la retrouver du premier coup au lieu de fouiller la bibliothèque.
916
+ let where = "";
917
+ if(folder_key){
918
+ where = " (in “" + folder_path + "”)";
919
+ if(folder_twins > 1) where += " ⚠ " + folder_twins + " folders are named “" + folder
920
+ + "” in this account; the shallowest was used. Pass the full path (e.g. “" + folder + " / " + folder
921
+ + "”) to choose another one.";
922
+ } else if(folder){
923
+ where = " (folder “" + folder + "” does not exist in this account — the map is at the root)";
924
+ }
925
+ return "cloud:" + r.map_key + where;
572
926
  }
573
927
  dirOk();
574
928
  let target = DIR;
@@ -599,7 +953,9 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
599
953
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
600
954
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
601
955
  // C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
602
- const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
956
+ const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"];
957
+ // Les dix étiquettes de couleur du kanban — ≡ `KB_TAGS` de index.html (le test compare les deux).
958
+ const KB_TAG_KEYS = ["green","yellow","orange","red","purple","blue","sky","lime","pink","gray"];
603
959
 
604
960
  // Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
605
961
  // délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
@@ -624,11 +980,18 @@ function buildProposalOps(mapDoc, changes){
624
980
  // profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
625
981
  // s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
626
982
  const P = c.persona;
627
- if(!P || typeof P !== "object" || !Array.isArray(P.modules) || !P.modules.length)
628
- throw new Error(`${where}: \`persona\` must be the interview object — { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }.`);
983
+ // DEUX personas (2026-09-11) : celui d'une WORK MAP porte des `modules`, celui d'un SECOND
984
+ // CERVEAU des `domains`. Même contrôle superficiel — `okBrain` juge à l'application.
985
+ const isBrain = !!(P && typeof P === "object" && Array.isArray(P.domains) && P.domains.length);
986
+ if(!P || typeof P !== "object" || (!isBrain && (!Array.isArray(P.modules) || !P.modules.length)))
987
+ throw new Error(`${where}: \`persona\` must be the interview object — a work map { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }, or a second-brain blueprint { who, role, mission, tools, opener, domains:[{ name, kind, share, holds }], losses, outputs, sources, nevermix, cadence, review, ailevel, signs }.`);
988
+ if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
989
+ if(isBrain){
990
+ if(P.domains.length > 5) throw new Error(`${where}: the blueprint has ${P.domains.length} domains — five at most.`);
991
+ return { op, persona: P, procs: 0, domains: P.domains.length };
992
+ }
629
993
  const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
630
994
  if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
631
- if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
632
995
  return { op, persona: P, procs: n };
633
996
  }
634
997
  const target = String(c.target == null ? "" : c.target);
@@ -637,6 +1000,14 @@ function buildProposalOps(mapDoc, changes){
637
1000
  if(r.error === "missing") throw new Error(`${where}: no node "${target}" in this map. Run read_map first and copy the wording exactly.`);
638
1001
  if(r.error === "ambiguous") throw new Error(`${where}: "${target}" matches ${r.count} nodes. Name an ancestor too, e.g. "Parent > ${parts_last(target)}".`);
639
1002
  const out = { op, target, was: String(r.node.text||"") };
1003
+ // `why` (2026-10-12) : la RAISON d'un changement, en une ligne — l'app la montre sous la ligne
1004
+ // à approuver. ≡ la règle de `parseProposal` côté app (une ligne, 200 caractères) : une raison
1005
+ // trop longue est rognée, jamais refusée ; une raison qui n'est pas du texte, si.
1006
+ if(c.why != null){
1007
+ if(typeof c.why !== "string") throw new Error(`${where}: \`why\` must be text — one short line saying why this change.`);
1008
+ const w = c.why.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 200);
1009
+ if(w) out.why = w;
1010
+ }
640
1011
  if(op === "add"){
641
1012
  if(typeof c.markdown !== "string" || !c.markdown.trim()) throw new Error(`${where}: \`markdown\` is required for an "add".`);
642
1013
  if(c.markdown.length > 20000) throw new Error(`${where}: \`markdown\` is too long (20000 characters max).`);
@@ -644,6 +1015,22 @@ function buildProposalOps(mapDoc, changes){
644
1015
  if(!forest.length) throw new Error(`${where}: \`markdown\` has no usable structure (a bullet list, indented by 2 spaces).`);
645
1016
  out.markdown = c.markdown;
646
1017
  out.adds = countForest(forest);
1018
+ // `col` facultatif (2026-10-12) : les cartes NEUVES vont dans cette colonne du kanban.
1019
+ // Validé comme pour `move` — une colonne inventée est refusée ICI, en nommant les vraies.
1020
+ if(c.col != null && c.col !== ""){
1021
+ const cols = (mapDoc.kanban && Array.isArray(mapDoc.kanban.cols)) ? mapDoc.kanban.cols : null;
1022
+ const col = String(c.col);
1023
+ if(!cols || !cols.some(k => k.id === col))
1024
+ throw new Error(`${where}: no column "${col}" on this board${cols ? ". Use one of: " + cols.map(k => `"${k.name}" (${k.id})`).join(", ") : " — it has no kanban yet"}.`);
1025
+ out.col = col;
1026
+ }
1027
+ // `assign` facultatif (2026-10-12, 👥) : le RESPONSABLE des cartes neuves. Mêmes bornes que l'op
1028
+ // `assign` ; refusé ICI en le disant, plutôt que de laisser l'app le retirer en silence.
1029
+ if(c.assign != null && c.assign !== ""){
1030
+ if(typeof c.assign !== "string" || c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign))
1031
+ throw new Error(`${where}: \`assign\` must be a name or e-mail (120 characters max) — the owner of the new cards.`);
1032
+ if(c.assign.trim()) out.assign = c.assign.trim();
1033
+ }
647
1034
  } else if(op === "rename"){
648
1035
  if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "rename".`);
649
1036
  out.text = c.text.trim().slice(0, 2000);
@@ -666,6 +1053,15 @@ function buildProposalOps(mapDoc, changes){
666
1053
  + `or "a:" followed by one of: ${ART_IDS_MCP.join(", ")} — or "" to remove it.`);
667
1054
  }
668
1055
  out.pic = pic;
1056
+ } else if(op === "img"){
1057
+ // Des PIXELS, cette fois — ré-encodés ICI pour passer `okCover` chez l'utilisateur. Une
1058
+ // capture trop lourde est refusée à l'écriture, en nommant la limite, plutôt que d'arriver
1059
+ // dans le panneau comme une ligne grisée. `img: ""` retire la capture.
1060
+ if(c.img === "" || c.img === null){ out.img = ""; }
1061
+ else {
1062
+ const { img, note } = coverFrom(c, where);
1063
+ out.img = img; out.shot = note;
1064
+ }
669
1065
  } else if(op === "move"){
670
1066
  // ⚠️ Dans le FICHIER les colonnes sont `{cols:[…]}` ; en mémoire, côté app, c'est un
671
1067
  // tableau nu. Confondre les deux donne un contrôle qui ne regarde rien.
@@ -679,17 +1075,85 @@ function buildProposalOps(mapDoc, changes){
679
1075
  throw new Error(`${where}: no column "${col}" on this board. Use one of: ${noms} — or "" to take the card off the board.`);
680
1076
  }
681
1077
  out.col = col;
1078
+ } else if(op === "tags"){
1079
+ // Les dix couleurs, liste FERMÉE — ≡ `okTags` de l'app. Une clé inventée est refusée ICI,
1080
+ // en nommant la liste, plutôt que de tomber en silence à la lecture. [] retire tout.
1081
+ if(!Array.isArray(c.tags)) throw new Error(`${where}: \`tags\` must be a list of label colours for a "tags" (an empty list clears them).`);
1082
+ const bad = c.tags.filter(t => !KB_TAG_KEYS.includes(t));
1083
+ if(bad.length) throw new Error(`${where}: unknown label colour "${bad[0]}". Use any of: ${KB_TAG_KEYS.join(", ")} (read_map shows the names the board gave them).`);
1084
+ out.tags = KB_TAG_KEYS.filter(k => c.tags.includes(k));
1085
+ } else if(op === "assign"){
1086
+ if(typeof c.assign !== "string") throw new Error(`${where}: \`assign\` is required for an "assign" — a name or e-mail (an empty string clears it).`);
1087
+ if(c.assign.length > 120 || /[\x00-\x1f\x7f]/.test(c.assign)) throw new Error(`${where}: \`assign\` is too long or contains control characters (120 characters max).`);
1088
+ out.assign = c.assign.trim();
1089
+ } else if(op === "priority"){
1090
+ const q = (c.priority == null) ? 0 : c.priority;
1091
+ if(!Number.isInteger(q) || q < 0 || q > 6) throw new Error(`${where}: \`priority\` must be a whole number from 1 (highest) to 6, or 0 to remove it.`);
1092
+ out.priority = q;
1093
+ } else if(op === "due"){
1094
+ const q = (c.due == null) ? "" : String(c.due);
1095
+ if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`due\` must be a day written YYYY-MM-DD (or "" to remove the due date).`);
1096
+ out.due = q;
1097
+ } else if(op === "attr"){
1098
+ if(typeof c.name !== "string" || !c.name.trim() || c.name.length > 60 || /[:\x00-\x1f]/.test(c.name))
1099
+ throw new Error(`${where}: \`name\` is required for an "attr" — the attribute's name, without a colon (60 characters max).`);
1100
+ if(typeof c.value !== "string" || c.value.length > 400 || /[\x00-\x1f]/.test(c.value))
1101
+ throw new Error(`${where}: \`value\` is required for an "attr" — its value as text (400 characters max; "" removes the attribute).`);
1102
+ out.name = c.name.trim(); out.value = c.value.trim();
1103
+ } else if(op === "comment"){
1104
+ // Un commentaire est AJOUTÉ au fil de la carte, jamais substitué : la seule op qui ne
1105
+ // réécrit rien. Signé « Claude » par l'app (le `by` de la proposition), borné comme
1106
+ // `CMT_TEXT_MAX` côté app — un fil est lu par des gens, pas par un tableur.
1107
+ if(typeof c.text !== "string" || !c.text.trim()) throw new Error(`${where}: \`text\` is required for a "comment" — what you want to say about this card.`);
1108
+ if(c.text.length > 2000) throw new Error(`${where}: the comment is too long (2000 characters max).`);
1109
+ out.text = c.text.trim();
1110
+ } else if(op === "est"){
1111
+ // Une DURÉE, dans les formes que `parseDur` lit côté app (« 2h », « 1h30 », « 45 » = minutes,
1112
+ // « 2:15 »), jamais une phrase : « soon » est refusé ICI, en nommant les formes, plutôt que
1113
+ // de tomber en silence à la lecture. Vide = retirer l'estimation.
1114
+ const q = (c.est == null) ? "" : String(c.est).trim().toLowerCase().replace(/\s+/g, "");
1115
+ if(q && !DUR_RE.test(q)) throw new Error(`${where}: \`est\` must be a duration — "2h", "1h30", "45" (minutes) or "2:15" — or "" to remove the estimate.`);
1116
+ out.est = q;
682
1117
  }
683
1118
  return out;
684
1119
  });
685
1120
  }
1121
+ // 👥 L'ÉQUIPE du tableau (`kanban.team`, 2026-10-12) — qui fait quoi. Montrée par read_map pour la
1122
+ // même raison que dans l'app : c'est elle qui permet de proposer un RESPONSABLE (assign) au lieu de
1123
+ // deviner à partir d'un prénom écrit dans un titre. Lecture seule, bornée comme `okKbTeam` côté app
1124
+ // (30 personnes, textes rognés) : le fichier vient du disque, c'est-à-dire de n'importe qui.
1125
+ function teamToText(doc){
1126
+ const t = doc && doc.kanban && doc.kanban.team;
1127
+ if(!t || typeof t !== "object" || !Array.isArray(t.people)) return "";
1128
+ const line = (v, max) => String(typeof v === "string" ? v : "").replace(/[\x00-\x1f\x7f]+/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
1129
+ const people = t.people.filter(p => p && typeof p.who === "string" && line(p.who, 120)).slice(0, 30);
1130
+ if(!people.length) return "";
1131
+ let out = "\n\n👥 Team — who does what (pick owners from here; `assign` takes the exact name):";
1132
+ const goal = line(t.goal, 800);
1133
+ if(goal) out += "\n Team objectives: " + goal;
1134
+ for(const p of people){
1135
+ out += "\n• " + line(p.who, 120) + (line(p.role, 80) ? " — " + line(p.role, 80) : "");
1136
+ if(line(p.does, 800)) out += "\n owns: " + line(p.does, 800);
1137
+ if(line(p.goals, 800)) out += "\n objectives: " + line(p.goals, 800);
1138
+ }
1139
+ return out;
1140
+ }
1141
+ // ≡ les formes que `parseDur` accepte dans index.html (hh:mm, Nh, NhMM, Nm, Ns, N = minutes).
1142
+ const DUR_RE = /^(\d{1,5}:[0-5]\d|\d{1,5}h([0-5]?\d)?m?|\d{1,7}m(in)?|\d{1,9}s(ec)?|\d{1,7})$/;
686
1143
  function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
687
1144
 
1145
+ // L'app ignore un fichier de proposition au-delà de PROP_FILE_MAX octets (`propScan`) — sans un
1146
+ // mot, puisqu'elle ne peut pas savoir ce qu'il contenait. Avec des captures d'écran dedans, la
1147
+ // borne se rencontre : on refuse ICI en nommant le geste, jamais un dépôt qui n'arrive pas.
1148
+ const PROP_FILE_MAX = 1400000;
688
1149
  async function writeProposal(map, note, ops){
689
1150
  const payload = (id)=>({
690
1151
  v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
691
1152
  createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
692
1153
  });
1154
+ const shots = ops.filter(o=>o.op === "img" && o.img).length;
1155
+ if(JSON.stringify(payload("x")).length > PROP_FILE_MAX)
1156
+ throw new Error("This proposal is too large for TabTree's mailbox (" + Math.round(PROP_FILE_MAX / 1024) + " KB max) — it carries " + shots + " screenshot(s). Send it as several proposals with fewer screenshots each.");
693
1157
  if(CLOUD){
694
1158
  // La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
695
1159
  // Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
@@ -746,11 +1210,16 @@ const TOOLS = [
746
1210
  },
747
1211
  {
748
1212
  name: "create_mindmap",
749
- description: "Creates a NEW TabTree mind map from a markdown outline (#/## headings, bullet lists indented by 2 spaces, [x]/[ ] checkboxes, [title](url) links, and \" @a:<id>\" / \" @e:<emoji>\" at the end of a line for an illustration). The first top-level line becomes the centre. Writes a .tabtree file into the backup folder; the user pulls it into the app from the 🛟 menu.",
1213
+ description: "Creates a NEW TabTree mind map from a markdown outline (#/## headings, bullet lists indented by 2 spaces, [x]/[ ] checkboxes, [title](url) links, and \" @a:<id>\" / \" @e:<emoji>\" at the end of a line for an illustration). The first top-level line becomes the centre. SCREENSHOTS: pass `images` — a list of { target, file } where `file` is the path of a PNG/JPEG on this machine (a screenshot you saved) and `target` the exact text of the node it goes on; the picture is re-encoded to fit and drawn as the node's cover. Writes a .tabtree file into the backup folder; the user pulls it into the app from the 🛟 menu.",
750
1214
  inputSchema: { type:"object", properties:{
751
1215
  name:{ type:"string", description:"Name of the map" },
752
1216
  markdown:{ type:"string", description:"Markdown outline (headings and/or bullet list). End any line with \" @a:<id>\" to give that node a built-in illustration, or \" @e:<emoji>\" for one large emoji — e.g. \"Launch @a:rocket\". Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift. An unknown id is left as plain text rather than dropped." },
753
- folder:{ type:"string", description:"Optional TabTree subfolder (e.g. \"From Claude\")" },
1217
+ folder:{ type:"string", description:"Optional TabTree folder. A NAME (\"From Claude\") or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which — pass the full path to choose another." },
1218
+ images:{ type:"array", description:"Optional screenshots, one per node: { target, file } or { target, data }. `target` = the exact text of a line of the outline (or \"Ancestor > Node\" when the wording repeats); `file` = full path of a PNG/JPEG/WebP/GIF on this machine (take the screenshot, save it, pass the path); `data` = a data:image/… URL instead of a file. The image is re-encoded to about 840 px and becomes the node's cover picture (it replaces any @a:/@e: illustration on that node). 60 max.", items:{ type:"object", properties:{
1219
+ target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\"" },
1220
+ file:{ type:"string", description:"Path of the image file on this machine" },
1221
+ data:{ type:"string", description:"A data:image/(png|jpeg|webp|gif);base64,… URL — instead of `file`" }
1222
+ }, required:["target"], additionalProperties:false } },
754
1223
  style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
755
1224
  font:{ type:"string", enum:["","grotesk","humanist","condensed","rounded","serif","garamond","didone","slab","typewriter","mono","hand"], description:"Type family: grotesk (corporate), humanist (warm), condensed (dense), rounded (friendly), serif/garamond (editorial), didone (elegant), slab (sturdy), typewriter, mono (technical), hand (handwritten)." },
756
1225
  shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
@@ -782,7 +1251,7 @@ const TOOLS = [
782
1251
  connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
783
1252
  from:{ type:"string" }, to:{ type:"string" }
784
1253
  }, required:["from","to"], additionalProperties:false } },
785
- folder:{ type:"string", description:"Optional TabTree subfolder" },
1254
+ folder:{ type:"string", description:"Optional TabTree folder. A NAME or a full PATH (\"Ai4X / Programmes\"). When several folders share a name, the shallowest is used and the answer says which." },
786
1255
  style:{ type:"object", description:"Optional look of the document. Omit it for the default.", properties:{
787
1256
  font:{ type:"string", enum:["","grotesk","humanist","condensed","rounded","serif","garamond","didone","slab","typewriter","mono","hand"], description:"Type family: grotesk (corporate), humanist (warm), condensed (dense), rounded (friendly), serif/garamond (editorial), didone (elegant), slab (sturdy), typewriter, mono (technical), hand (handwritten)." },
788
1257
  shape:{ type:"string", enum:["","line","plain"], description:"Node shape: cards (default), line = underlined, plain = plain text." },
@@ -796,20 +1265,31 @@ const TOOLS = [
796
1265
  },
797
1266
  {
798
1267
  name: "propose_changes",
799
- description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
1268
+ description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node, put a screenshot on a node (op \"img\" with `file`) — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
800
1269
  inputSchema: { type:"object", properties:{
801
1270
  file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
802
1271
  note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
803
1272
  changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
804
- op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","persona"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) from an interview — no target" },
1273
+ op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; img = put a SCREENSHOT on it (give `file`, the path of the image on this machine — or `data`; `img: \"\"` removes it); persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) or a Second Brain Blueprint (“Second brain —”) from an interview — no target; tags = set the card's colour labels; assign = who the task is for; priority = 1 (highest) to 6; due = the due day; attr = set a “Name: value” attribute on the node (an estimate, a budget…); comment = add a signed comment to the card's thread (give `text`); est = set the time ESTIMATE of the task (give `est`, e.g. \"2h\", \"45\" = 45 minutes, \"\" removes it)" },
805
1274
  target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
806
1275
  persona:{ type:"object", description:"persona: the interview object exactly as the skill returns it — who, role, mission, tools, meetings, opener, modules[{name, procs[{name,h,after,freq,rep,data,stakes,lever,step,why}]}], trajectory, signs, parking. TabTree rebuilds the canvas AND its companion sheet from it." },
807
1276
  markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
808
- text:{ type:"string", description:"rename: the new text" },
1277
+ text:{ type:"string", description:"rename: the new text. comment: the comment body (plain text; http/mailto links become clickable)" },
809
1278
  note:{ type:"string", description:"note: the note body (empty string clears it)" },
810
1279
  value:{ type:"boolean", description:"check: true to tick, false to untick" },
811
- col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
812
- pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." }
1280
+ col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board. add: optional — the column the NEW cards go to" },
1281
+ pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." },
1282
+ file:{ type:"string", description:"img: full path of the screenshot (PNG/JPEG/WebP/GIF) on this machine. It is re-encoded to about 840 px and becomes the node's cover picture." },
1283
+ data:{ type:"string", description:"img: a data:image/…;base64 URL instead of `file`" },
1284
+ img:{ type:"string", description:"img: pass \"\" to REMOVE the screenshot from that node (otherwise give `file` or `data`)" },
1285
+ tags:{ type:"array", items:{ type:"string" }, description:"tags: the colour labels to set, from: green, yellow, orange, red, purple, blue, sky, lime, pink, gray — read_map shows the name the board gave each colour. [] clears them." },
1286
+ assign:{ type:"string", description:"assign: a name or e-mail (\"\" clears it). add: optional — the owner of the NEW cards. When read_map shows a 👥 Team, pick from it, by role, what each person owns and their objectives" },
1287
+ why:{ type:"string", description:"Any change: optional — one short line saying WHY (e.g. \"Owns the website\"). Shown to the user next to the change, so they can judge it" },
1288
+ priority:{ type:"integer", description:"priority: 1 (highest) to 6, or 0 to remove it" },
1289
+ due:{ type:"string", description:"due: the due day as YYYY-MM-DD (\"\" removes it)" },
1290
+ name:{ type:"string", description:"attr: the attribute's name, e.g. \"Estimate\"" },
1291
+ value:{ type:"string", description:"attr: its value, e.g. \"2h\" (\"\" removes the attribute)" },
1292
+ est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
813
1293
  }, required:["op"], additionalProperties:false } }
814
1294
  }, required:["file","changes"], additionalProperties:false }
815
1295
  }
@@ -820,7 +1300,7 @@ const HANDLERS = {
820
1300
  const maps = await loadLibrary();
821
1301
  if(!maps.length) return CLOUD ? "This TabTree account has no maps yet." : "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
822
1302
  return maps.map(m=>
823
- `• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
1303
+ `• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}${m.tabOf ? " ↳ tab of “" + m.tabOf + "”" : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
824
1304
  ).join("\n");
825
1305
  },
826
1306
  async read_map(args){
@@ -829,7 +1309,7 @@ const HANDLERS = {
829
1309
  if(m.kind === "board") return head + boardToText(m.doc);
830
1310
  // Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
831
1311
  // moment où le plan est sous les yeux qu'il est utile, et il porte la règle de désignation.
832
- return head + nodeToMarkdown(m.doc.root, 0)
1312
+ return head + nodeToMarkdown(m.doc.root, 0) + teamToText(m.doc)
833
1313
  + "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. Nothing is applied until the user approves it in TabTree.)";
834
1314
  },
835
1315
  async search_maps(args){
@@ -862,11 +1342,15 @@ const HANDLERS = {
862
1342
  // comparé caractère par caractère avec celui de l'app, et lui ajouter une syntaxe le ferait
863
1343
  // diverger en silence. Même partage des rôles que côté modèles.
864
1344
  tplPicAssign(root);
1345
+ // Les captures d'écran APRÈS les marqueurs : une désignation se retire du texte avant que le
1346
+ // texte serve de cible, et une capture remplace l'illustration du même nœud.
1347
+ const shots = applyImages(root, args.images);
865
1348
  const doc = { v:1, root, images:[], stickies:[] };
866
1349
  const st = okStyleMcp(args.style);
867
1350
  if(st) doc.style = st;
868
1351
  const file = await writeDoc(args.name || root.text, doc, args.folder);
869
- return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
1352
+ const shotLines = shots.length ? `\n📷 ${shots.length} screenshot(s) on the map:\n` + shots.map(x=>" • " + x).join("\n") + "\n" : "";
1353
+ return `✅ Map created (${countNodes(root)} nodes): ${file}\n${shotLines}${importHint()}`;
870
1354
  },
871
1355
  async create_board(args){
872
1356
  const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
@@ -889,11 +1373,11 @@ const HANDLERS = {
889
1373
  if(m.kind === "board" && !allPersona)
890
1374
  throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
891
1375
  if(allPersona && m.kind !== "board")
892
- throw new Error("A persona re-casts a WORK MAP canvas, which is a board. “" + m.name + "” is a mind map — pick the person's work map from list_maps (its name starts with “Work map —” or “Cartographie —”).");
1376
+ throw new Error("A persona re-casts a WORK MAP or a SECOND BRAIN BLUEPRINT canvas, which is a board. “" + m.name + "” is a mind map — pick the person's canvas from list_maps (its name starts with “Work map —”, “Cartographie —” or “Second brain —”).");
893
1377
  const ops = buildProposalOps(m.doc, args.changes);
894
1378
  const file = await writeProposal(m, args.note, ops);
895
1379
  if(allPersona){
896
- return `📮 Interview filed for “${m.name}” — ${ops[0].procs} processes: ${file}\n`
1380
+ return `📮 Interview filed for “${m.name}” — ${ops[0].domains ? ops[0].domains + " domains" : ops[0].procs + " processes"}: ${file}\n`
897
1381
  + "NOTHING has been changed yet. The canvas file is untouched.\n"
898
1382
  + (CLOUD ? "TabTree shows a banner on that work map within a minute (app open, signed in): " : "TabTree shows a banner on that work map within a few seconds (backup folder connected, app open): ") + "“Your Claude interview is ready — re-cast this work map?”. "
899
1383
  + "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
@@ -901,10 +1385,13 @@ const HANDLERS = {
901
1385
  }
902
1386
  const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
903
1387
  const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
1388
+ const shots = ops.filter(o=>o.op === "img" && o.img);
904
1389
  return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
905
1390
  + (adds ? `, ${adds} node(s) to add` : "")
906
1391
  + (drops ? `, ${drops} node(s) to remove` : "")
1392
+ + (shots.length ? `, ${shots.length} screenshot(s)` : "")
907
1393
  + `: ${file}\n`
1394
+ + (shots.length ? shots.map(o=>" 📷 “" + String(o.was || o.target).slice(0, 60) + "” ← " + o.shot).join("\n") + "\n" : "")
908
1395
  + "NOTHING has been changed yet. The map file is untouched.\n"
909
1396
  + (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
910
1397
  + "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
@@ -930,7 +1417,7 @@ function handle(msg){
930
1417
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
931
1418
  // signale : un client affiche l'une, le registre publie l'autre. Un
932
1419
  // autotest les compare désormais toutes les quatre.
933
- serverInfo: { name: "tabtree", version: "1.2.0" }
1420
+ serverInfo: { name: "tabtree", version: "1.4.0" }
934
1421
  });
935
1422
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
936
1423
  // notification : pas de réponse