tabtree-mcp 1.5.0 → 1.6.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.
package/README.md CHANGED
@@ -68,8 +68,9 @@ that reads a JSON config:
68
68
  | `search_maps` | Searches the whole library — map names, nodes, notes, shapes, stickies. |
69
69
  | `create_mindmap` | Creates a mind map from a markdown outline — or a **note** (a page of text) or a **Gantt** (give it dates). |
70
70
  | `create_kanban` | Creates a kanban board in one go: columns, and cards with owners, start and due days, priority, colour labels, time estimates, an Ai4X lever, a note (a Google Meet link in it becomes clickable), a link and sub-tasks. |
71
- | `create_board` | Creates a whiteboard — shapes and arrows that stay anchored in the app. |
71
+ | `create_board` | Creates a whiteboard — shapes, **images** (`kind: "image"` + a PNG/JPEG/WebP/GIF/SVG `file`: logos, photos, posters) and arrows that stay anchored in the app. |
72
72
  | `propose_changes` | Changes a map or kanban you already have — see below. |
73
+ | `harvest` | Keeps what matters from the conversation — facts, decisions, lessons — in your Company knowledge and 🌱 Lessons maps. Nothing is filed until you accept it in TabTree. |
73
74
 
74
75
  Every creation tool takes an optional `folder` and an optional `space`. Name a
75
76
  **team space** you are an editor of and the new map lives there: every member
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tabtree-mcp",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "mcpName": "app.tabtree/tabtree",
5
5
  "description": "Let Claude read your TabTree library and build mind maps, kanban boards and whiteboards in your TabTree account — team spaces included.",
6
6
  "repository": {
package/tabtree-mcp.mjs CHANGED
@@ -482,6 +482,53 @@ function applyImages(root, images){
482
482
  return notes;
483
483
  }
484
484
 
485
+ // --- Les IMAGES d'un board (2026-10-23) ----------------------------------------------
486
+ // Un logo, une photo, une affiche posés LIBREMENT sur la toile : `doc.images` de l'app
487
+ // ({ id, src, x, y, w, h }), le même tableau que remplit un glisser-déposer. Rapporté : « l'outil
488
+ // qui crée les boards ne sait poser que des pictogrammes intégrés, pas tes fichiers de logo » —
489
+ // Claude avait dû écrire le fichier du board à la main. Un PNG/JPEG/WebP/GIF passe par la porte
490
+ // des captures (`coverFrom` : même budget, même ré-encodage) ; un SVG — la forme ordinaire d'un
491
+ // logo — est gardé tel quel, sans rasterisation (aucun codec ici). Il ne court aucun risque :
492
+ // l'app le peint dans un `<image href>`, où un script ne s'exécute jamais, et `deserialize`
493
+ // accepte tout `data:image/` sur un élément image (c'est ainsi que vit le logo de la toile Ai4X).
494
+ const SVG_BUDGET = 300000;
495
+ function svgSize(txt){
496
+ const head = (/<svg\b[^>]*>/i.exec(txt) || [""])[0];
497
+ const num = re => { const m = re.exec(head); const v = m ? parseFloat(m[1]) : NaN; return v > 0 ? v : 0; };
498
+ let w = num(/\swidth\s*=\s*["']\s*([\d.]+)\s*(?:px)?\s*["']/i), h = num(/\sheight\s*=\s*["']\s*([\d.]+)\s*(?:px)?\s*["']/i);
499
+ const vb = /\sviewBox\s*=\s*["']\s*[-\d.]+[\s,]+[-\d.]+[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(head);
500
+ if((!w || !h) && vb){ w = parseFloat(vb[1]); h = parseFloat(vb[2]); }
501
+ return { w, h };
502
+ }
503
+ function boardImageFrom(spec, where){
504
+ if(spec && typeof spec.data === "string" && /^data:image\/svg\+xml/i.test(spec.data.trim())){
505
+ const d = spec.data.trim();
506
+ if(d.length > SVG_BUDGET) throw new Error(`${where}: the SVG is over ${Math.round(SVG_BUDGET / 1024)} KB.`);
507
+ let txt = "";
508
+ const b64 = /^data:image\/svg\+xml;base64,([A-Za-z0-9+/=\s]+)$/i.exec(d);
509
+ if(b64) txt = Buffer.from(b64[1].replace(/\s+/g, ""), "base64").toString("utf8");
510
+ else { const c = d.indexOf(","); try{ txt = decodeURIComponent(d.slice(c + 1)); }catch(e){ txt = ""; } }
511
+ if(!/<svg\b/i.test(txt)) throw new Error(`${where}: \`data\` is not an SVG image.`);
512
+ return { src: "data:image/svg+xml;base64," + Buffer.from(txt, "utf8").toString("base64"), ...svgSize(txt), note: `inline SVG kept as is` };
513
+ }
514
+ if(spec && typeof spec.file === "string" && spec.file.trim()){
515
+ const p = resolve(spec.file.trim());
516
+ if(existsSync(p) && statSync(p).isFile() && statSync(p).size <= IMG_FILE_MAX){
517
+ const raw = readFileSync(p);
518
+ const head = raw.subarray(0, 512).toString("utf8").replace(/^\uFEFF/, "").trimStart();
519
+ if(/^(<\?xml|<svg\b|<!--|<!DOCTYPE svg)/i.test(head) && /<svg\b/i.test(raw.toString("utf8"))){
520
+ const txt = raw.toString("utf8");
521
+ const src = "data:image/svg+xml;base64," + Buffer.from(txt, "utf8").toString("base64");
522
+ if(src.length > SVG_BUDGET) throw new Error(`${where}: ${basename(p)} is an SVG of ${Math.round(raw.length / 1024)} KB — over the ${Math.round(SVG_BUDGET / 1024)} KB budget. Simplify it, or export it as a PNG.`);
523
+ return { src, ...svgSize(txt), note: `${basename(p)} (SVG, ${Math.round(src.length / 1024)} KB, kept as is)` };
524
+ }
525
+ }
526
+ }
527
+ const { img, note } = coverFrom(spec, where);
528
+ const info = imageInfo(Buffer.from(img.slice(img.indexOf(",") + 1), "base64")) || {};
529
+ return { src: img, w: info.w || 0, h: info.h || 0, note };
530
+ }
531
+
485
532
  // ---------------------------------------------------------------------------
486
533
  // Désignation d'un nœud (partagé mot pour mot avec l'app — voir tabtree-mcp.test.mjs)
487
534
  //
@@ -627,7 +674,25 @@ function fitSize(text, w, h, kind, want){
627
674
 
628
675
  function buildBoardDoc(name, elements, connections, style){
629
676
  if(!Array.isArray(elements) || !elements.length) throw new Error("`elements` must be a non-empty list.");
677
+ const imgCount = elements.filter(e=>e && e.kind === "image").length;
678
+ if(imgCount > 60) throw new Error("Too many images in one board (60 max).");
679
+ const imageNotes = [];
630
680
  const els = elements.map((e,i)=>{
681
+ if(e && e.kind === "image"){
682
+ const where = "elements[" + i + "] (image" + (e.id != null ? " “" + e.id + "”" : "") + ")";
683
+ const im = boardImageFrom(e, where);
684
+ imageNotes.push(im.note);
685
+ // La boîte garde les PROPORTIONS de l'image : un logo étiré se voit tout de suite. Une
686
+ // seule cote donnée → l'autre en découle ; aucune → 220 px sur le côté long.
687
+ const ar = (im.w > 0 && im.h > 0) ? im.w / im.h : 1;
688
+ let w = (+e.w >= 24) ? Math.round(+e.w) : null, h = (+e.h >= 24) ? Math.round(+e.h) : null;
689
+ if(w && !h) h = Math.max(24, Math.round(w / ar));
690
+ else if(h && !w) w = Math.max(24, Math.round(h * ar));
691
+ else if(!w && !h){ if(ar >= 1){ w = 220; h = Math.max(24, Math.round(220 / ar)); } else { h = 220; w = Math.max(24, Math.round(220 * ar)); } }
692
+ return { lid: String(e.id != null ? e.id : i), kind: "image", text: "", src: im.src,
693
+ x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
694
+ y: Number.isFinite(+e.y) && e.y!=null ? Math.round(+e.y) : null, w, h };
695
+ }
631
696
  const kind = KINDS.includes(e.kind) ? e.kind : "rect";
632
697
  return { lid: String(e.id != null ? e.id : i), kind, text: String(e.text||""),
633
698
  x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
@@ -684,6 +749,7 @@ function buildBoardDoc(name, elements, connections, style){
684
749
  e.x = 60 + lv*320;
685
750
  e.y = 60 + idx*170;
686
751
  }
752
+ if(e.kind === "image") continue;
687
753
  if(!e.color) e.color = e.kind==="sticky" ? "#fff59d" : (level.get(e.lid)===0 && conns.length ? "#dbeafe" : "#ffffff");
688
754
  }
689
755
 
@@ -695,6 +761,7 @@ function buildBoardDoc(name, elements, connections, style){
695
761
  // C'est l'uniformité qui fait qu'une toile remplie a l'air composée.
696
762
  const warnings = [];
697
763
  for(const e of els){
764
+ if(e.kind === "image") continue;
698
765
  const f = fitSize(e.text, e.w, e.h, e.kind, e.size || 15);
699
766
  if(!f.shrunk) continue;
700
767
  e.size = f.size;
@@ -704,9 +771,12 @@ function buildBoardDoc(name, elements, connections, style){
704
771
  }
705
772
 
706
773
  // Sortie au format de l'app : shapes / stickies + refs "sh:i" / "st:i"
707
- const shapes = [], stickies = [], refOf = new Map();
774
+ const shapes = [], stickies = [], images = [], refOf = new Map();
708
775
  for(const e of els){
709
- if(e.kind==="sticky"){
776
+ if(e.kind==="image"){
777
+ refOf.set(e.lid, "im:" + images.length);
778
+ images.push({ id: "im" + images.length, src: e.src, x: e.x, y: e.y, w: e.w, h: e.h });
779
+ } else if(e.kind==="sticky"){
710
780
  refOf.set(e.lid, "st:" + stickies.length);
711
781
  stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined, pic:e.pic||undefined });
712
782
  } else {
@@ -726,12 +796,12 @@ function buildBoardDoc(name, elements, connections, style){
726
796
  from: refOf.get(c.from), to: refOf.get(c.to) };
727
797
  });
728
798
  const root = makeNode(name); root.color = "#dbeafe";
729
- const doc = { v:1, root, images:[], stickies, board:true, shapes, arrows, draws:[] };
799
+ const doc = { v:1, root, images, stickies, board:true, shapes, arrows, draws:[] };
730
800
  // `style` n'est émis que s'il est posé : l'app elle-même l'omet quand il vaut le défaut
731
801
  // (styleParDefaut), et le JSON d'un board ordinaire ne doit pas gagner un octet.
732
802
  const st = okStyleMcp(style);
733
803
  if(st) doc.style = st;
734
- return { doc, warnings };
804
+ return { doc, warnings, imageNotes };
735
805
  }
736
806
 
737
807
  // Habillage du document : familles de caractères, forme des nœuds, fond de page. Liste
@@ -852,6 +922,7 @@ function teamLocalId(owner, key){
852
922
  return "T" + t + "_" + key;
853
923
  }
854
924
  let cloudSpacesCache = null; // les espaces vus au dernier chargement — la création les résout par nom
925
+ let cloudMe = null; // le compte de la clé, vu au dernier chargement — la récolte en a besoin pour « Personal »
855
926
  async function cloudLibrary(){
856
927
  const pages = [];
857
928
  let total = Infinity, off = 0, j = null;
@@ -864,6 +935,7 @@ async function cloudLibrary(){
864
935
  off += got.length;
865
936
  }
866
937
  const me = (j && j.me) || null;
938
+ cloudMe = me;
867
939
  cloudSpacesCache = (j && j.spaces) || [];
868
940
  const spaceName = (owner, key)=>{ const sp = cloudSpacesCache.find(x=>x.owner === owner && x.space_key === key); return sp ? sp.name : null; };
869
941
  const folders = new Map((j && j.folders || []).filter(f=>f && f.folder_key).map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
@@ -873,6 +945,15 @@ async function cloudLibrary(){
873
945
  while(f && !seen.has(f.folder_key) && out.length <= 16){ seen.add(f.folder_key); out.unshift(f.name); f = f.parent_key ? (folders.get((owner || "") + "|" + f.parent_key) || folders.get("|" + f.parent_key)) : null; }
874
946
  return out.length ? out.join(" / ") : null;
875
947
  };
948
+ // L'ESPACE d'une carte (1.6.0, pour la récolte) : le sien, sinon celui du dossier RACINE qui la contient —
949
+ // la règle de l'app (`spaceOfMapIn` : ce qui est dans un dossier hérite de la racine). Sans elle, une carte
950
+ // « Company knowledge » rangée dans un dossier d'un espace passerait pour une carte de Personal.
951
+ const rootSpaceOf = (owner, k)=>{
952
+ const seen = new Set();
953
+ let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
954
+ while(f && f.parent_key && !seen.has(f.folder_key) && seen.size <= 16){ seen.add(f.folder_key); f = folders.get((owner || "") + "|" + f.parent_key) || folders.get("|" + f.parent_key); }
955
+ return (f && f.space_key) || null;
956
+ };
876
957
  const out = [];
877
958
  for(const r of pages){
878
959
  const doc = r && r.doc;
@@ -893,6 +974,8 @@ async function cloudLibrary(){
893
974
  nodes: countNodes(doc.root),
894
975
  elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
895
976
  mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
977
+ updatedAt: String(r.updated_at || ""),
978
+ spaceKey: r.space_key || (r.folder_key ? rootSpaceOf(owner, r.folder_key) : null),
896
979
  doc
897
980
  });
898
981
  }
@@ -1007,6 +1090,9 @@ async function writeDoc(name, doc, folder, opts){
1007
1090
  where = " (space “" + sp.name + "”" + (owner !== me ? " — a team space: every member sees it" : "") + ")";
1008
1091
  }
1009
1092
  const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
1093
+ // La récolte (1.6.0) crée une carte PUIS y dépose une proposition : il lui faut la clé et le
1094
+ // propriétaire, que la phrase ne rend pas sans perte (une carte d'équipe s'y nomme par son id d'app).
1095
+ if(o.info) return { text: "cloud:" + (id || r.map_key) + where, key: r.map_key, owner, id: id || r.map_key, team: !!(owner && me && owner !== me) };
1010
1096
  return "cloud:" + (id || r.map_key) + where;
1011
1097
  }
1012
1098
  dirOk();
@@ -1038,7 +1124,7 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
1038
1124
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
1039
1125
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
1040
1126
  // C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
1041
- const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link"];
1127
+ const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link","vote"];
1042
1128
  // ⚡ Les ops que l'app applique D'ELLE-MÊME (1.5.0, décidé « hybride » le 2026-10-20) — ≡
1043
1129
  // `PROP_AUTO_OPS` d'app/index.html (le test compare les deux). Plus `cols` quand le tableau n'a
1044
1130
  // pas encore de colonnes (`seed`). C'est l'APP qui décide : le connecteur ne fait que demander
@@ -1231,6 +1317,9 @@ function buildProposalOps(mapDoc0, changes){
1231
1317
  const q = (c[op] == null) ? "" : String(c[op]);
1232
1318
  if(q && !/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/.test(q)) throw new Error(`${where}: \`${op}\` must be a day written YYYY-MM-DD (or "" to remove it).`);
1233
1319
  out[op] = q;
1320
+ } else if(op === "vote"){
1321
+ // 👍 Un vote est le geste d'une PERSONNE sur le tableau (app, page publiée) : Claude ne vote pas à la place de quelqu'un.
1322
+ throw new Error(`${where}: "vote" is cast by people on the board, not proposed by Claude.`);
1234
1323
  } else if(op === "link"){
1235
1324
  // La liste blanche de l'app (`okOpLink`), en plus étroit : une page web, un e-mail, ou une
1236
1325
  // autre carte (`map:<id>` — l'id que list_maps donne). Jamais `javascript:` ni `data:`.
@@ -1312,7 +1401,7 @@ async function writeProposal(map, note, ops, opts){
1312
1401
  const payload = (id, list, auto)=>Object.assign({
1313
1402
  v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
1314
1403
  createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops: list
1315
- }, auto ? { auto: true } : {});
1404
+ }, auto ? { auto: true } : {}, o.extra || {});
1316
1405
  const shots = ops.filter(x=>x.op === "img" && x.img).length;
1317
1406
  if(JSON.stringify(payload("x", ops)).length > PROP_FILE_MAX)
1318
1407
  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.");
@@ -1491,6 +1580,91 @@ function buildKanbanDoc(args){
1491
1580
  return doc;
1492
1581
  }
1493
1582
 
1583
+ // ---------------------------------------------------------------------------
1584
+ // 🏁 LA RÉCOLTE D'UNE CONVERSATION (1.6.0, 2026-10-20) — docs/second-brain-harvest-spec.md
1585
+ // ---------------------------------------------------------------------------
1586
+ // À la fin d'une conversation, Claude envoie ce qui mérite d'être gardé — faits, décisions, leçons — et
1587
+ // la personne le range d'un clic. Trois règles portent tout :
1588
+ // · JAMAIS SANS CLIC. Toute la récolte part à la revue (`review`), marquée `origin: "harvest"`, et l'app
1589
+ // refuse de l'appliquer seule même si chaque op est dans PROP_AUTO_OPS (`propAutoOk`). L'appel d'outil
1590
+ // est fait par le MODÈLE, qui a pu lire une page qui lui dicte quoi envoyer : rien de ce qu'il envoie ne
1591
+ // devient un FAIT sans un geste dans l'app.
1592
+ // · LE LOT ENTIER OU RIEN. Un élément invalide fait échouer l'appel, en le nommant : une récolte est
1593
+ // petite et la personne l'a vue dans la conversation — mieux vaut la corriger que la ranger à moitié.
1594
+ // · UN ESPACE PAR APPEL. Les faits ne traversent pas les domaines ; les LEÇONS, elles, vont toujours dans
1595
+ // la carte « 🌱 Lessons » de Personal : la pensée appartient à la personne.
1596
+ // Les entrées ont la grammaire de 📚 File it (`kbFileEntry`) : un titre, une note qui finit par sa
1597
+ // provenance, des attributs « Nom: valeur » en enfants, rien de coché (une case recopiée serait une tâche
1598
+ // FANTÔME dans My work).
1599
+ // ≡ `KNOW_NAME` et `KNOW_DEFAULT` d'app/index.html (le test compare) : le connecteur cherche la carte de
1600
+ // connaissance par ce NOM — la marque `know` est un champ d'index du compte, qu'il ne lit pas encore — et
1601
+ // la crée avec ces sections quand l'espace n'en a pas.
1602
+ const KNOW_NAME = "Company knowledge";
1603
+ const KNOW_DEFAULT = ["🔑 Access — who has what", "🌐 Domains & infrastructure", "🧰 Tools & subscriptions",
1604
+ "💼 Clients & deals", "📖 Playbooks", "⚖️ Decisions"];
1605
+ const LESSONS_NAME = "🌱 Lessons";
1606
+ const HARVEST_MAX = 12;
1607
+ // ≡ app/index.html, comparés caractère par caractère par le test : un secret que l'app retirerait au
1608
+ // rangement et que le connecteur aurait laissé partir serait déjà dans la table `proposals`.
1609
+ const KB_SECRET_RES = [
1610
+ /\b(pass(word|wd|code|phrase)|pwd|mdp|mot de passe|secret|api[ _-]?key|token)\s*[:=]/i,
1611
+ /\b(sk-(ant-)?[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{12,}|xox[abpr]-[A-Za-z0-9-]{8,}|AKIA[0-9A-Z]{12,}|AIza[0-9A-Za-z_-]{20,})/,
1612
+ /\bBearer [A-Za-z0-9._~+\/-]{12,}/i
1613
+ ];
1614
+ function kbSecretLike(line){
1615
+ const s = String(line == null ? "" : line);
1616
+ if(KB_SECRET_RES.some(re => re.test(s))) return true;
1617
+ for(const w of s.split(/\s+/)){
1618
+ if(w.length >= 32 && !/^(https?:|mailto:|map:|fsref:)/i.test(w) && /^[A-Za-z0-9._~+\/=-]+$/.test(w)
1619
+ && /[A-Za-z]/.test(w) && /[0-9]/.test(w)) return true;
1620
+ }
1621
+ return false;
1622
+ }
1623
+ function knowNorm(s){ return String(s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); }
1624
+ const hvSame = (a, b)=>knowNorm(a).trim() === knowNorm(b).trim();
1625
+ // Un texte d'une ligne, borné. `[` et `]` deviennent des parenthèses dans ce qui passe par le markdown d'un
1626
+ // `add` : « [x] » y serait lu comme une case, « [a](b) » comme un lien.
1627
+ function hvLine(v, max){ return (typeof v === "string") ? v.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max) : ""; }
1628
+ function hvMd(v){ return String(v).replace(/\[/g, "(").replace(/\]/g, ")"); }
1629
+ // Un JOUR qui existe : la forme (`DAY_RE`) puis l'aller-retour par le calendrier — le piège du 30 février,
1630
+ // payé par 🏁 Harvest côté app.
1631
+ function hvDayOk(s){ if(!DAY_RE.test(s)) return false; const d = new Date(s + "T00:00:00Z"); return !isNaN(d) && d.toISOString().slice(0, 10) === s; }
1632
+ const okNid = v=>typeof v === "string" && /^[A-Za-z0-9_]{1,40}$/.test(v);
1633
+ // Le document d'une carte de connaissance (ou de leçons) NEUVE : celui que `kbKnowCreate` écrirait.
1634
+ function hvNewDoc(name, sections){
1635
+ const root = makeNode(name);
1636
+ root.color = "#dbeafe";
1637
+ root.children = sections.map(t=>makeNode(t));
1638
+ assignIds(root);
1639
+ return { v:1, root, images:[], stickies:[] };
1640
+ }
1641
+ // La carte la plus récente qui porte EXACTEMENT ce nom dans une portée (propriétaire + espace ; Personal =
1642
+ // pas d'espace), jamais un board, un kanban, un tableur ni un Gantt — la règle de `kbKnowMapId`.
1643
+ function hvFindMap(lib, owner, spaceKey, name){
1644
+ return lib.filter(m=>m.owner === owner && (m.spaceKey || null) === (spaceKey || null)
1645
+ && (m.kind === "map" || m.kind === "note") && hvSame(m.name, name))
1646
+ .sort((x, y)=>String(y.updatedAt).localeCompare(String(x.updatedAt)))[0] || null;
1647
+ }
1648
+ // Les ops d'UNE carte : un état de travail (la carte telle que les ops précédentes la laissent) et la liste.
1649
+ function hvTarget(doc){ return { seed: doc, doc: cloneDoc(doc), ops: [] }; }
1650
+ // Une op qui vise un nœud : par son ID quand il en a un valide (exact, jamais ambigu), avec le chemin
1651
+ // lisible pour le panneau ; sinon par le chemin seul.
1652
+ function hvAt(node, path){ return Object.assign(okNid(node.id) ? { node: node.id } : {}, { target: path.slice(0, 600) }); }
1653
+ // La section d'une carte de connaissance, par son nom (casse et accents ignorés, la règle de
1654
+ // `kbKnowSection`) ; absente, elle est AJOUTÉE par une op de section, une seule fois par appel. Elle ne
1655
+ // porte pas de `hv` : deux éléments peuvent y aller, et décocher l'un ne doit pas retirer l'autre.
1656
+ function hvSection(t, name){
1657
+ const root = t.doc.root;
1658
+ const hit = (root.children || []).find(c=>hvSame(c.text, name));
1659
+ if(hit) return hit;
1660
+ const nid = newNid();
1661
+ const node = Object.assign(makeNode(name), { id: nid });
1662
+ root.children = (root.children || []).concat([node]);
1663
+ t.ops.push(Object.assign({ op:"add" }, hvAt(root, String(root.text || "")), { markdown: "- " + hvMd(name), text: name, nid, adds: 1, why: "New section of your " + KNOW_NAME }));
1664
+ return node;
1665
+ }
1666
+ function hvFindEntry(section, text){ return (section.children || []).find(c=>hvSame(c.text, text)) || null; }
1667
+
1494
1668
  // ---------------------------------------------------------------------------
1495
1669
  // Définition des outils
1496
1670
  // ---------------------------------------------------------------------------
@@ -1541,12 +1715,12 @@ const TOOLS = [
1541
1715
  },
1542
1716
  {
1543
1717
  name: "create_board",
1544
- description: "Creates a NEW TabTree board (whiteboard): shapes (rect, ellipse, diamond, text, sticky) and arrow connections that stay anchored to the shapes in the app. With no x/y coordinates, layout is automatic by level (the connections define the hierarchy, left to right). Good for a diagram, a flow, a brainstorm. In a flowchart use ellipse for start/end, rect for a step, and diamond for a decision — its text should read as a closed question with exactly two ways out.\n\nFIT THE TEXT TO THE BOX. A box shows roughly (w−24)/7 characters per line and (h−10)/20 lines, and every blank line costs a full line. Write captions, not paragraphs: when filling a canvas template (Business Model Canvas, Lean Canvas, SWOT…), the shipped templates use 70–110 characters per block, and that is what looks composed. Longer text is shrunk to fit — nothing ever spills out — but past ~11px it stops being readable and the response will warn you.",
1718
+ description: "Creates a NEW TabTree board (whiteboard): shapes (rect, ellipse, diamond, text, sticky), IMAGES (kind \"image\" with a `file` path — your logos, photos, posters, placed where you say) and arrow connections that stay anchored to the shapes in the app. With no x/y coordinates, layout is automatic by level (the connections define the hierarchy, left to right). Good for a diagram, a flow, a brainstorm. In a flowchart use ellipse for start/end, rect for a step, and diamond for a decision — its text should read as a closed question with exactly two ways out.\n\nFIT THE TEXT TO THE BOX. A box shows roughly (w−24)/7 characters per line and (h−10)/20 lines, and every blank line costs a full line. Write captions, not paragraphs: when filling a canvas template (Business Model Canvas, Lean Canvas, SWOT…), the shipped templates use 70–110 characters per block, and that is what looks composed. Longer text is shrunk to fit — nothing ever spills out — but past ~11px it stops being readable and the response will warn you.",
1545
1719
  inputSchema: { type:"object", properties:{
1546
1720
  name:{ type:"string", description:"Name of the board" },
1547
1721
  elements:{ type:"array", description:"Board elements", items:{ type:"object", properties:{
1548
1722
  id:{ type:"string", description:"Logical id used by connections (defaults to the index)" },
1549
- kind:{ type:"string", enum:["rect","ellipse","diamond","trap","chev","arc","text","sticky"], description:"Type (defaults to rect). diamond = a decision in a flowchart, trap = a pyramid/funnel tier (tk = small-side ratio, negative when the base is the narrow side), chev = a process step pointing forward, arc = a pie slice (a0/a1 in degrees from 12 o'clock, ir = inner-radius fraction for a donut)." },
1723
+ kind:{ type:"string", enum:["rect","ellipse","diamond","trap","chev","arc","text","sticky","image"], description:"Type (defaults to rect). image = a PICTURE placed freely on the board — a logo, a photo, a poster: give `file` (full path of a PNG/JPEG/WebP/GIF/SVG on this machine) or `data` (a data:image/… URL); w/h keep the image's proportions when only one (or neither, 220 px on the long side) is given; arrows can start or end on it; `text` is ignored. diamond = a decision in a flowchart, trap = a pyramid/funnel tier (tk = small-side ratio, negative when the base is the narrow side), chev = a process step pointing forward, arc = a pie slice (a0/a1 in degrees from 12 o'clock, ir = inner-radius fraction for a donut)." },
1550
1724
  text:{ type:"string" },
1551
1725
  x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
1552
1726
  color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
@@ -1555,8 +1729,10 @@ const TOOLS = [
1555
1729
  tk:{ type:"number", description:"trap only — small-side/large-side ratio, 0..0.95 (0 = triangle); NEGATIVE puts the narrow side at the bottom (funnel tier)" },
1556
1730
  a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
1557
1731
  a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
1558
- ir:{ type:"number", description:"arc only — inner-radius fraction 0..0.9 (0 = pie slice, 0.55 = donut)" }
1559
- }, required:["text"], additionalProperties:false } },
1732
+ ir:{ type:"number", description:"arc only — inner-radius fraction 0..0.9 (0 = pie slice, 0.55 = donut)" },
1733
+ file:{ type:"string", description:"image only — full path of a PNG/JPEG/WebP/GIF/SVG file on this machine. Rasters are re-encoded to about 840 px; an SVG is kept as is (300 KB max)." },
1734
+ data:{ type:"string", description:"image only — a data:image/(png|jpeg|webp|gif|svg+xml) URL instead of a file" }
1735
+ }, additionalProperties:false } },
1560
1736
  connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
1561
1737
  from:{ type:"string" }, to:{ type:"string" }
1562
1738
  }, required:["from","to"], additionalProperties:false } },
@@ -1637,6 +1813,27 @@ const TOOLS = [
1637
1813
  est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
1638
1814
  }, required:["op"], additionalProperties:false } }
1639
1815
  }, required:["file","changes"], additionalProperties:false }
1816
+ },
1817
+ {
1818
+ // 🏁 1.6.0. La description porte la règle de déclenchement : c'est elle que Claude lit au moment de choisir.
1819
+ name: "harvest",
1820
+ description: "Keeps what matters from THIS conversation in the user's TabTree second brain: facts, decisions and lessons. Call it ONLY after you listed the items to the user in the conversation and they agreed — then once. Nothing is filed until the user accepts it in TabTree (a banner, on any of their devices). Use it when the user ends a conversation (thanks, \"that's all\", \"ok bye\") or right after a decision was made. Facts and decisions go to the \"Company knowledge\" map of ONE space (created if the space has none); lessons always go to the user's \"🌱 Lessons\" map in Personal. Never for tasks (use propose_changes or create_kanban). Never a password, key or token. If nothing is worth keeping, call it with nothing: true and tell the user so.",
1821
+ inputSchema: { type:"object", properties:{
1822
+ space: { type:"string", description:"The space the conversation was about, by name (list_maps shows them). Omit for Personal. If two spaces could apply, ASK the user — never guess: facts never cross between spaces." },
1823
+ title: { type:"string", description:"One line naming the conversation, e.g. \"Discount request — Batimar\". Shown in TabTree and written as the source of every entry." },
1824
+ nothing: { type:"boolean", description:"true when nothing is worth keeping — then send no items." },
1825
+ items: { type:"array", description:"What to keep, 12 at most.", items:{ type:"object", properties:{
1826
+ kind: { type:"string", enum:["fact","decision","lesson"], description:"fact = what is true today (a price, an access, a client's answer); decision = what was decided, appended and never rewritten; lesson = what the user learned, reusable anywhere." },
1827
+ text: { type:"string", description:"The entry's title: one sentence that stays true on its own, without the conversation. 300 characters max." },
1828
+ detail: { type:"string", description:"Optional: what a reader needs in six months. 1500 characters max." },
1829
+ section: { type:"string", description:"fact only, required: the section of the Company knowledge map — its EXACT name when it exists (read_map shows them; the defaults are " + KNOW_DEFAULT.map(s=>"\"" + s + "\"").join(", ") + "), or a new name to create one." },
1830
+ updates: { type:"string", description:"fact only: the EXACT text of the entry of that section this fact replaces (its new value). Omit for a new entry." },
1831
+ signal: { type:"string", description:"decision only: the observable sign that will say whether it was right, e.g. \"Q4 margins back above 22%\"." },
1832
+ check_on:{ type:"string", description:"decision only: YYYY-MM-DD, the day to look at the signal." },
1833
+ replaces:{ type:"string", description:"decision only: the EXACT text of an earlier decision this one supersedes. The earlier one is kept." },
1834
+ why: { type:"string", description:"One short line: why keep it. Shown next to the item when the user reviews it." }
1835
+ }, required:["kind","text"], additionalProperties:false } }
1836
+ }, additionalProperties:false }
1640
1837
  }
1641
1838
  ];
1642
1839
 
@@ -1735,9 +1932,10 @@ const HANDLERS = {
1735
1932
  return `✅ ${label} created (${countNodes(root)} nodes${dated.length ? ", " + dated.length + " dated" : ""}): ${file}\n${shotLines}${importHint()}`;
1736
1933
  },
1737
1934
  async create_board(args){
1738
- const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
1935
+ const { doc, warnings, imageNotes } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
1739
1936
  const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
1740
- const n = doc.shapes.length + doc.stickies.length;
1937
+ const n = doc.shapes.length + doc.stickies.length + doc.images.length;
1938
+ const imgs = imageNotes.length ? `🖼 ${imageNotes.length} image(s):\n` + imageNotes.map(t=>" • " + t).join("\n") + "\n" : "";
1741
1939
  // Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
1742
1940
  // peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
1743
1941
  const warn = warnings.length
@@ -1746,7 +1944,7 @@ const HANDLERS = {
1746
1944
  + `\n Nothing spills out — the text was shrunk to fit — but blocks at different sizes look\n`
1747
1945
  + ` uneven on a canvas. Recreate the board with shorter text to keep it clean.\n`
1748
1946
  : "";
1749
- return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
1947
+ return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${imgs}${warn}${importHint()}`;
1750
1948
  },
1751
1949
  async create_kanban(args){
1752
1950
  if(typeof args.name !== "string" || !args.name.trim()) throw new Error("The `name` parameter is required (the board's name).");
@@ -1794,6 +1992,161 @@ const HANDLERS = {
1794
1992
  + "Tell the user to look at TabTree — do not claim those changes are made.\n";
1795
1993
  }
1796
1994
  return out + (shotLines ? shotLines + "\n" : "");
1995
+ },
1996
+ async harvest(args){
1997
+ if(!CLOUD) throw new Error("harvest keeps what matters in the user's TabTree ACCOUNT, and this connector reads a backup folder. Connect it with a connector key (TabTree → Settings → Account → Claude connector) — the backup-folder mode does not receive new features.");
1998
+ const a = args || {};
1999
+ const items = Array.isArray(a.items) ? a.items : [];
2000
+ if(a.nothing === true){
2001
+ if(items.length) throw new Error("`nothing: true` means nothing is worth keeping — send no items with it.");
2002
+ return "Nothing filed: nothing from this conversation needed keeping. Tell the user so.";
2003
+ }
2004
+ if(!items.length) throw new Error("No items. List what is worth keeping — facts, decisions, lessons — or pass nothing: true.");
2005
+ if(items.length > HARVEST_MAX) throw new Error(items.length + " items: a harvest keeps " + HARVEST_MAX + " at most. Beyond that it is a report — put it in a map of its own with create_mindmap, or keep only what matters.");
2006
+ const title = hvLine(a.title, 120);
2007
+ if(!title) throw new Error("`title` is required — one line naming the conversation; it is written as the source of every entry.");
2008
+ const today = new Date().toISOString().slice(0, 10);
2009
+
2010
+ // 1. Contrôler chaque élément AVANT de toucher au compte : un appel refusé ne crée rien.
2011
+ const clean = items.map((it, i)=>{
2012
+ const where = "item #" + (i + 1);
2013
+ if(!it || typeof it !== "object") throw new Error(where + ": must be an object { kind, text, … }.");
2014
+ const kind = it.kind;
2015
+ if(!["fact", "decision", "lesson"].includes(kind)) throw new Error(where + ": `kind` must be fact, decision or lesson.");
2016
+ const text = hvLine(it.text, 300);
2017
+ if(!text) throw new Error(where + ": `text` is required — one sentence that stays true on its own.");
2018
+ if(kbSecretLike(text)) throw new Error(where + ": its text looks like a password, key or token. Never file a secret — leave it out.");
2019
+ let secrets = 0;
2020
+ const detail = (typeof it.detail === "string" ? it.detail.replace(/\r/g, "").slice(0, 1500) : "").split("\n")
2021
+ .filter(l=>{ if(kbSecretLike(l)){ secrets++; return false; } return true; }).join("\n").trim();
2022
+ const out = { i, kind, text, detail, secrets, why: hvLine(it.why, 200) };
2023
+ const only = (k, kinds)=>{ if(it[k] != null && it[k] !== "" && !kinds.includes(kind)) throw new Error(where + ": `" + k + "` is for a " + kinds.join(" or ") + ", not a " + kind + "."); };
2024
+ only("section", ["fact"]); only("updates", ["fact"]);
2025
+ only("signal", ["decision"]); only("check_on", ["decision"]); only("replaces", ["decision"]);
2026
+ if(kind === "fact"){
2027
+ out.section = hvLine(it.section, 80);
2028
+ if(!out.section) throw new Error(where + ": a fact needs its `section` of the " + KNOW_NAME + " map (read_map shows them).");
2029
+ out.updates = hvLine(it.updates, 300);
2030
+ }
2031
+ if(kind === "decision"){
2032
+ out.signal = hvLine(it.signal, 300);
2033
+ out.replaces = hvLine(it.replaces, 300);
2034
+ out.check_on = hvLine(it.check_on, 10);
2035
+ if(out.check_on && !hvDayOk(out.check_on)) throw new Error(where + ": `check_on` must be a real day written YYYY-MM-DD.");
2036
+ if(kbSecretLike(out.signal)) throw new Error(where + ": its signal looks like a password, key or token.");
2037
+ }
2038
+ return out;
2039
+ });
2040
+
2041
+ // 2. L'espace, puis les deux cartes (sans rien créer encore).
2042
+ const lib = await loadLibrary();
2043
+ const me = cloudMe;
2044
+ let sp = null;
2045
+ if(a.space != null && String(a.space).trim()){
2046
+ const hits = (cloudSpacesCache || []).filter(x=>hvSame(x.name, a.space));
2047
+ if(!hits.length) throw new Error("No space named “" + a.space + "” in this account" + ((cloudSpacesCache || []).length ? ". Spaces: " + cloudSpacesCache.map(x=>"“" + x.name + "”").join(", ") + " — or omit `space` for Personal" : "") + ".");
2048
+ sp = hits.find(x=>x.owner === me) || hits[0];
2049
+ if(!sp.writable) throw new Error("The space “" + sp.name + "” is read-only for this user (" + (sp.role === "viewer" ? "their role there is viewer — ask its owner for the editor role" : "its owner's plan has ended") + "). Nothing was filed.");
2050
+ }
2051
+ const knowOwner = sp ? sp.owner : me, knowKey = sp ? sp.space_key : null;
2052
+ const needKnow = clean.some(x=>x.kind !== "lesson"), needLessons = clean.some(x=>x.kind === "lesson");
2053
+ const knowMap = needKnow ? hvFindMap(lib, knowOwner, knowKey, KNOW_NAME) : null;
2054
+ const lessMap = needLessons ? hvFindMap(lib, me, null, LESSONS_NAME) : null;
2055
+ const know = needKnow ? hvTarget(knowMap ? knowMap.doc : hvNewDoc(KNOW_NAME, KNOW_DEFAULT)) : null;
2056
+ const less = needLessons ? hvTarget(lessMap ? lessMap.doc : hvNewDoc(LESSONS_NAME, [])) : null;
2057
+
2058
+ // 3. Les ops, élément par élément, contre la carte TELLE QUE LES ÉLÉMENTS PRÉCÉDENTS LA LAISSENT.
2059
+ const said = [], skipped = [];
2060
+ let secretLines = 0;
2061
+ const src = (verb)=>verb + " a conversation with Claude — “" + title + "” · " + today;
2062
+ for(const x of clean){
2063
+ const where = "item #" + (x.i + 1);
2064
+ secretLines += x.secrets;
2065
+ const hv = x.i;
2066
+ const noteOf = (verb)=>(x.detail ? x.detail + "\n\n" : "") + src(verb);
2067
+ const addEntry = (t, parent, path, attrs)=>{
2068
+ const nid = newNid();
2069
+ const md = "- " + hvMd(x.text) + attrs.map(([k, v])=>"\n - " + k + ": " + hvMd(v)).join("");
2070
+ t.ops.push(Object.assign({ op:"add" }, hvAt(parent, path), { markdown: md, text: x.text, nid, adds: 1 + attrs.length, hv }, x.why ? { why: x.why } : {}));
2071
+ t.ops.push({ op:"note", node: nid, target: (path + " > " + x.text).slice(0, 600), note: noteOf("From"), hv });
2072
+ const node = Object.assign(makeNode(x.text), { id: nid });
2073
+ node.children = attrs.map(([k, v])=>makeNode(k + ": " + v));
2074
+ parent.children = (parent.children || []).concat([node]);
2075
+ };
2076
+ if(x.kind === "lesson"){
2077
+ const root = less.doc.root;
2078
+ if(hvFindEntry(root, x.text)){ skipped.push("“" + x.text + "” is already in your " + LESSONS_NAME); continue; }
2079
+ addEntry(less, root, String(root.text || LESSONS_NAME), []);
2080
+ said.push("🌱 lesson — " + x.text);
2081
+ continue;
2082
+ }
2083
+ const root = know.doc.root;
2084
+ if(x.kind === "decision"){
2085
+ const sec = (root.children || []).find(c=>/decision/.test(knowNorm(c.text))) || hvSection(know, "⚖️ Decisions");
2086
+ if(hvFindEntry(sec, x.text)){ skipped.push("“" + x.text + "” is already among the decisions"); continue; }
2087
+ const attrs = [];
2088
+ if(x.signal) attrs.push(["Signal", x.signal]);
2089
+ if(x.check_on) attrs.push(["Check on", x.check_on]);
2090
+ if(x.replaces){
2091
+ const prev = hvFindEntry(sec, x.replaces);
2092
+ if(!prev) throw new Error(where + ": no decision “" + x.replaces + "” in “" + sec.text + "” — `replaces` takes the EXACT text of an earlier decision" + ((sec.children || []).length ? " (" + sec.children.slice(-6).map(c=>"“" + c.text + "”").join(", ") + ")" : "") + ".");
2093
+ attrs.push(["Replaces", String(prev.text)]);
2094
+ }
2095
+ addEntry(know, sec, String(root.text) + " > " + String(sec.text), attrs);
2096
+ said.push("⚖️ decision — " + x.text + (x.check_on ? " (check on " + x.check_on + ")" : ""));
2097
+ continue;
2098
+ }
2099
+ // fact
2100
+ const sec = hvSection(know, x.section);
2101
+ const path = String(root.text) + " > " + String(sec.text);
2102
+ if(x.updates){
2103
+ const prev = hvFindEntry(sec, x.updates);
2104
+ if(!prev) throw new Error(where + ": no entry “" + x.updates + "” in “" + sec.text + "” — `updates` takes the EXACT text of the entry it replaces" + ((sec.children || []).length ? " (" + sec.children.slice(0, 8).map(c=>"“" + c.text + "”").join(", ") + ")" : ", and that section is empty") + ".");
2105
+ const at = hvAt(prev, path + " > " + String(prev.text));
2106
+ if(!hvSame(prev.text, x.text)) know.ops.push(Object.assign({ op:"rename" }, at, { was: String(prev.text), text: x.text, hv }, x.why ? { why: x.why } : {}));
2107
+ know.ops.push(Object.assign({ op:"note" }, at, { was: String(prev.text), note: noteOf("Updated from"), hv }));
2108
+ prev.text = x.text;
2109
+ said.push("✏️ updated in " + sec.text + " — " + x.text);
2110
+ continue;
2111
+ }
2112
+ const dup = hvFindEntry(sec, x.text);
2113
+ if(dup){
2114
+ if(!x.detail){ skipped.push("“" + x.text + "” is already in “" + sec.text + "”"); continue; }
2115
+ know.ops.push(Object.assign({ op:"note" }, hvAt(dup, path + " > " + String(dup.text)), { was: String(dup.text), note: noteOf("Updated from"), hv }));
2116
+ said.push("✏️ detail updated in " + sec.text + " — " + x.text);
2117
+ continue;
2118
+ }
2119
+ addEntry(know, sec, path, []);
2120
+ said.push("📚 " + sec.text + " — " + x.text);
2121
+ }
2122
+ const targets = [];
2123
+ if(know && know.ops.length) targets.push([know, knowMap, KNOW_NAME, sp ? sp.name : undefined]);
2124
+ if(less && less.ops.length) targets.push([less, lessMap, LESSONS_NAME, undefined]);
2125
+ if(!targets.length) return "Nothing new to file — everything is already there:\n" + skipped.map(s=>" • " + s).join("\n") + "\nTell the user so.";
2126
+
2127
+ // 4. Créer ce qui manque (un squelette sans un fait : rien à approuver), puis déposer, à la revue.
2128
+ const harvest_id = "hv" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
2129
+ const created = [], filed = [];
2130
+ for(const [t, map0, name, space] of targets){
2131
+ let map = map0;
2132
+ if(!map){
2133
+ // Le squelette créé est EXACTEMENT celui contre lequel les ops ont été validées (`seed`, avant
2134
+ // toute op) : mêmes ids — les ops de la proposition visent ses sections par leur id.
2135
+ const info = await writeDoc(name, t.seed, null, { kind: "map", space, info: true });
2136
+ map = { file: info.id, key: info.key, owner: info.owner, team: info.team, name };
2137
+ created.push("“" + name + "”" + (space ? " in the space “" + space + "”" : " in Personal"));
2138
+ }
2139
+ const res = await writeProposal(map, "🏁 From your conversation with Claude: “" + title + "”", t.ops, { review: true, extra: { origin: "harvest", harvest_id, title } });
2140
+ filed.push(res.ids.review);
2141
+ }
2142
+ const n = said.length;
2143
+ return "🏁 " + n + " item" + (n > 1 ? "s are" : " is") + " waiting in TabTree" + (sp ? " (space “" + sp.name + "”)" : "") + ":\n"
2144
+ + said.map(s=>" • " + s).join("\n") + "\n"
2145
+ + (skipped.length ? "Left out, already there:\n" + skipped.map(s=>" • " + s).join("\n") + "\n" : "")
2146
+ + (secretLines ? "🔒 " + secretLines + " line(s) of detail looked like a password, key or token and were left out.\n" : "")
2147
+ + (created.length ? "📚 Created " + created.join(" and ") + " to hold them.\n" : "")
2148
+ + "NOTHING is filed yet. TabTree shows a banner on any device where it is open (otherwise the next time it opens): the user ticks what to keep and accepts it; Cmd+Z undoes it.\n"
2149
+ + "Tell the user it is waiting in TabTree — do not claim it is filed.";
1797
2150
  }
1798
2151
  };
1799
2152
 
@@ -1815,7 +2168,7 @@ function handle(msg){
1815
2168
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
1816
2169
  // signale : un client affiche l'une, le registre publie l'autre. Un
1817
2170
  // autotest les compare désormais toutes les quatre.
1818
- serverInfo: { name: "tabtree", version: "1.5.0" }
2171
+ serverInfo: { name: "tabtree", version: "1.6.0" }
1819
2172
  });
1820
2173
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
1821
2174
  // notification : pas de réponse