tabtree-mcp 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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.1",
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
  //
@@ -625,9 +672,34 @@ function fitSize(text, w, h, kind, want){
625
672
  return { size: 9, shrunk: true, readable: false };
626
673
  }
627
674
 
675
+ // L'ordre de superposition (`z`, ≡ `zOk` de l'app) : un entier [-9, 9], 0 omis. À z égal l'app peint
676
+ // par nature — images < formes < flèches < tracés < post-it — donc un panneau coloré REMPLI recouvre
677
+ // un logo posé dedans : `z:-1` sur le panneau (ou `z:1` sur l'image) est ce qui le remet devant.
678
+ function zMcp(v){
679
+ const n = Math.round(+v);
680
+ return (v != null && isFinite(n) && n >= -9 && n <= 9 && n !== 0) ? n : undefined;
681
+ }
628
682
  function buildBoardDoc(name, elements, connections, style){
629
683
  if(!Array.isArray(elements) || !elements.length) throw new Error("`elements` must be a non-empty list.");
684
+ const imgCount = elements.filter(e=>e && e.kind === "image").length;
685
+ if(imgCount > 60) throw new Error("Too many images in one board (60 max).");
686
+ const imageNotes = [];
630
687
  const els = elements.map((e,i)=>{
688
+ if(e && e.kind === "image"){
689
+ const where = "elements[" + i + "] (image" + (e.id != null ? " “" + e.id + "”" : "") + ")";
690
+ const im = boardImageFrom(e, where);
691
+ imageNotes.push(im.note);
692
+ // La boîte garde les PROPORTIONS de l'image : un logo étiré se voit tout de suite. Une
693
+ // seule cote donnée → l'autre en découle ; aucune → 220 px sur le côté long.
694
+ const ar = (im.w > 0 && im.h > 0) ? im.w / im.h : 1;
695
+ let w = (+e.w >= 24) ? Math.round(+e.w) : null, h = (+e.h >= 24) ? Math.round(+e.h) : null;
696
+ if(w && !h) h = Math.max(24, Math.round(w / ar));
697
+ else if(h && !w) w = Math.max(24, Math.round(h * ar));
698
+ 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)); } }
699
+ return { lid: String(e.id != null ? e.id : i), kind: "image", text: "", src: im.src, z: zMcp(e.z),
700
+ x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
701
+ y: Number.isFinite(+e.y) && e.y!=null ? Math.round(+e.y) : null, w, h };
702
+ }
631
703
  const kind = KINDS.includes(e.kind) ? e.kind : "rect";
632
704
  return { lid: String(e.id != null ? e.id : i), kind, text: String(e.text||""),
633
705
  x: Number.isFinite(+e.x) && e.x!=null ? Math.round(+e.x) : null,
@@ -643,7 +715,8 @@ function buildBoardDoc(name, elements, connections, style){
643
715
  // L'image sans octets sur un élément de board (2026-09-03) : même frontière que
644
716
  // pour un nœud — un id inventé retombe sur « aucune image », jamais sur du balisage.
645
717
  // Pas sur un secteur, un gradin ou un chevron : leur silhouette n'a pas de « haut ».
646
- pic: (e.pic != null && !["arc","trap","chev"].includes(kind)) ? okPic(String(e.pic)) : "" };
718
+ pic: (e.pic != null && !["arc","trap","chev"].includes(kind)) ? okPic(String(e.pic)) : "",
719
+ z: zMcp(e.z) };
647
720
  });
648
721
  const byId = new Map();
649
722
  for(const e of els){
@@ -684,6 +757,7 @@ function buildBoardDoc(name, elements, connections, style){
684
757
  e.x = 60 + lv*320;
685
758
  e.y = 60 + idx*170;
686
759
  }
760
+ if(e.kind === "image") continue;
687
761
  if(!e.color) e.color = e.kind==="sticky" ? "#fff59d" : (level.get(e.lid)===0 && conns.length ? "#dbeafe" : "#ffffff");
688
762
  }
689
763
 
@@ -695,6 +769,7 @@ function buildBoardDoc(name, elements, connections, style){
695
769
  // C'est l'uniformité qui fait qu'une toile remplie a l'air composée.
696
770
  const warnings = [];
697
771
  for(const e of els){
772
+ if(e.kind === "image") continue;
698
773
  const f = fitSize(e.text, e.w, e.h, e.kind, e.size || 15);
699
774
  if(!f.shrunk) continue;
700
775
  e.size = f.size;
@@ -704,11 +779,14 @@ function buildBoardDoc(name, elements, connections, style){
704
779
  }
705
780
 
706
781
  // Sortie au format de l'app : shapes / stickies + refs "sh:i" / "st:i"
707
- const shapes = [], stickies = [], refOf = new Map();
782
+ const shapes = [], stickies = [], images = [], refOf = new Map();
708
783
  for(const e of els){
709
- if(e.kind==="sticky"){
784
+ if(e.kind==="image"){
785
+ refOf.set(e.lid, "im:" + images.length);
786
+ images.push({ id: "im" + images.length, src: e.src, x: e.x, y: e.y, w: e.w, h: e.h, z: e.z });
787
+ } else if(e.kind==="sticky"){
710
788
  refOf.set(e.lid, "st:" + stickies.length);
711
- 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 });
789
+ 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, z:e.z });
712
790
  } else {
713
791
  refOf.set(e.lid, "sh:" + shapes.length);
714
792
  shapes.push({ kind:e.kind, x:e.x, y:e.y, w:e.w, h:e.h, color:e.kind==="text" ? "#ffffff" : e.color, text:e.text, size:e.size||undefined,
@@ -716,7 +794,8 @@ function buildBoardDoc(name, elements, connections, style){
716
794
  tk: e.tk != null ? e.tk : undefined,
717
795
  a0: e.a0 != null ? e.a0 : undefined,
718
796
  a1: e.a1 != null ? e.a1 : undefined,
719
- ir: e.ir != null ? e.ir : undefined });
797
+ ir: e.ir != null ? e.ir : undefined,
798
+ z: e.z });
720
799
  }
721
800
  }
722
801
  const arrows = conns.map(c=>{
@@ -726,12 +805,12 @@ function buildBoardDoc(name, elements, connections, style){
726
805
  from: refOf.get(c.from), to: refOf.get(c.to) };
727
806
  });
728
807
  const root = makeNode(name); root.color = "#dbeafe";
729
- const doc = { v:1, root, images:[], stickies, board:true, shapes, arrows, draws:[] };
808
+ const doc = { v:1, root, images, stickies, board:true, shapes, arrows, draws:[] };
730
809
  // `style` n'est émis que s'il est posé : l'app elle-même l'omet quand il vaut le défaut
731
810
  // (styleParDefaut), et le JSON d'un board ordinaire ne doit pas gagner un octet.
732
811
  const st = okStyleMcp(style);
733
812
  if(st) doc.style = st;
734
- return { doc, warnings };
813
+ return { doc, warnings, imageNotes };
735
814
  }
736
815
 
737
816
  // Habillage du document : familles de caractères, forme des nœuds, fond de page. Liste
@@ -852,6 +931,7 @@ function teamLocalId(owner, key){
852
931
  return "T" + t + "_" + key;
853
932
  }
854
933
  let cloudSpacesCache = null; // les espaces vus au dernier chargement — la création les résout par nom
934
+ let cloudMe = null; // le compte de la clé, vu au dernier chargement — la récolte en a besoin pour « Personal »
855
935
  async function cloudLibrary(){
856
936
  const pages = [];
857
937
  let total = Infinity, off = 0, j = null;
@@ -864,6 +944,7 @@ async function cloudLibrary(){
864
944
  off += got.length;
865
945
  }
866
946
  const me = (j && j.me) || null;
947
+ cloudMe = me;
867
948
  cloudSpacesCache = (j && j.spaces) || [];
868
949
  const spaceName = (owner, key)=>{ const sp = cloudSpacesCache.find(x=>x.owner === owner && x.space_key === key); return sp ? sp.name : null; };
869
950
  const folders = new Map((j && j.folders || []).filter(f=>f && f.folder_key).map(f=>[(f.owner || "") + "|" + f.folder_key, f]));
@@ -873,6 +954,15 @@ async function cloudLibrary(){
873
954
  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
955
  return out.length ? out.join(" / ") : null;
875
956
  };
957
+ // L'ESPACE d'une carte (1.6.0, pour la récolte) : le sien, sinon celui du dossier RACINE qui la contient —
958
+ // la règle de l'app (`spaceOfMapIn` : ce qui est dans un dossier hérite de la racine). Sans elle, une carte
959
+ // « Company knowledge » rangée dans un dossier d'un espace passerait pour une carte de Personal.
960
+ const rootSpaceOf = (owner, k)=>{
961
+ const seen = new Set();
962
+ let f = folders.get((owner || "") + "|" + k) || folders.get("|" + k);
963
+ 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); }
964
+ return (f && f.space_key) || null;
965
+ };
876
966
  const out = [];
877
967
  for(const r of pages){
878
968
  const doc = r && r.doc;
@@ -893,6 +983,8 @@ async function cloudLibrary(){
893
983
  nodes: countNodes(doc.root),
894
984
  elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
895
985
  mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
986
+ updatedAt: String(r.updated_at || ""),
987
+ spaceKey: r.space_key || (r.folder_key ? rootSpaceOf(owner, r.folder_key) : null),
896
988
  doc
897
989
  });
898
990
  }
@@ -912,15 +1004,27 @@ function boardToText(doc){
912
1004
  const o = arr[i];
913
1005
  return o ? `${tag}${i} ${JSON.stringify((o.text||"(no text)").slice(0,60))}` : tag+i+" ?";
914
1006
  };
1007
+ const zs = o=>o && o.z ? ` z:${o.z}` : "";
915
1008
  lines.push(`Shapes (${(doc.shapes||[]).length}):`);
916
- (doc.shapes||[]).forEach((s,i)=>lines.push(` sh:${i} [${s.kind}] ${JSON.stringify((s.text||"").slice(0,80))} @(${s.x},${s.y}) ${s.w}×${s.h}`));
1009
+ (doc.shapes||[]).forEach((s,i)=>lines.push(` sh:${i} [${s.kind}] ${JSON.stringify((s.text||"").slice(0,80))} @(${s.x},${s.y}) ${s.w}×${s.h}${zs(s)}`));
1010
+ // Les images d'un board (2026-10-23) : le type et le poids, jamais les octets — une data URL de
1011
+ // 200 Ko dans la réponse noierait tout le reste, et Claude n'en lirait rien.
1012
+ const imgs = (doc.images||[]);
1013
+ if(imgs.length){
1014
+ lines.push(`Images (${imgs.length}):`);
1015
+ imgs.forEach((m,i)=>{
1016
+ const t = (/^data:image\/([a-z+.-]+)/i.exec(m.src||"") || [,"?"])[1].replace("svg+xml","svg");
1017
+ lines.push(` im:${i} [${t}, ${Math.round(String(m.src||"").length * 3 / 4 / 1024)} KB] @(${m.x},${m.y}) ${m.w}×${m.h}${zs(m)}`);
1018
+ });
1019
+ }
917
1020
  if((doc.stickies||[]).length){
918
1021
  lines.push(`Sticky notes (${doc.stickies.length}):`);
919
1022
  doc.stickies.forEach((s,i)=>lines.push(` st:${i} ${JSON.stringify((s.text||"").slice(0,80))} @(${s.x},${s.y})`));
920
1023
  }
921
1024
  const named = ref=>{
922
1025
  if(typeof ref !== "string") return null;
923
- const m = /^(sh|st):(\d+)$/.exec(ref); if(!m) return null;
1026
+ const m = /^(sh|st|im):(\d+)$/.exec(ref); if(!m) return null;
1027
+ if(m[1]==="im") return (doc.images||[])[+m[2]] ? "im:" + m[2] + " (image)" : "im:" + m[2] + " ?";
924
1028
  return label(m[1]==="sh" ? (doc.shapes||[]) : (doc.stickies||[]), +m[2], m[1]+":");
925
1029
  };
926
1030
  if((doc.arrows||[]).length){
@@ -1007,6 +1111,9 @@ async function writeDoc(name, doc, folder, opts){
1007
1111
  where = " (space “" + sp.name + "”" + (owner !== me ? " — a team space: every member sees it" : "") + ")";
1008
1112
  }
1009
1113
  const id = (owner && me && owner !== me) ? teamLocalId(owner, r.map_key) : r.map_key;
1114
+ // La récolte (1.6.0) crée une carte PUIS y dépose une proposition : il lui faut la clé et le
1115
+ // propriétaire, que la phrase ne rend pas sans perte (une carte d'équipe s'y nomme par son id d'app).
1116
+ 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
1117
  return "cloud:" + (id || r.map_key) + where;
1011
1118
  }
1012
1119
  dirOk();
@@ -1038,7 +1145,7 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
1038
1145
  // connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
1039
1146
  // cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
1040
1147
  // 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"];
1148
+ const PROP_OPS = ["add","rename","note","check","delete","move","pic","img","persona","tags","assign","priority","due","attr","comment","est","start","cols","link","vote"];
1042
1149
  // ⚡ Les ops que l'app applique D'ELLE-MÊME (1.5.0, décidé « hybride » le 2026-10-20) — ≡
1043
1150
  // `PROP_AUTO_OPS` d'app/index.html (le test compare les deux). Plus `cols` quand le tableau n'a
1044
1151
  // pas encore de colonnes (`seed`). C'est l'APP qui décide : le connecteur ne fait que demander
@@ -1231,6 +1338,9 @@ function buildProposalOps(mapDoc0, changes){
1231
1338
  const q = (c[op] == null) ? "" : String(c[op]);
1232
1339
  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
1340
  out[op] = q;
1341
+ } else if(op === "vote"){
1342
+ // 👍 Un vote est le geste d'une PERSONNE sur le tableau (app, page publiée) : Claude ne vote pas à la place de quelqu'un.
1343
+ throw new Error(`${where}: "vote" is cast by people on the board, not proposed by Claude.`);
1234
1344
  } else if(op === "link"){
1235
1345
  // La liste blanche de l'app (`okOpLink`), en plus étroit : une page web, un e-mail, ou une
1236
1346
  // autre carte (`map:<id>` — l'id que list_maps donne). Jamais `javascript:` ni `data:`.
@@ -1312,7 +1422,7 @@ async function writeProposal(map, note, ops, opts){
1312
1422
  const payload = (id, list, auto)=>Object.assign({
1313
1423
  v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
1314
1424
  createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops: list
1315
- }, auto ? { auto: true } : {});
1425
+ }, auto ? { auto: true } : {}, o.extra || {});
1316
1426
  const shots = ops.filter(x=>x.op === "img" && x.img).length;
1317
1427
  if(JSON.stringify(payload("x", ops)).length > PROP_FILE_MAX)
1318
1428
  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 +1601,91 @@ function buildKanbanDoc(args){
1491
1601
  return doc;
1492
1602
  }
1493
1603
 
1604
+ // ---------------------------------------------------------------------------
1605
+ // 🏁 LA RÉCOLTE D'UNE CONVERSATION (1.6.0, 2026-10-20) — docs/second-brain-harvest-spec.md
1606
+ // ---------------------------------------------------------------------------
1607
+ // À la fin d'une conversation, Claude envoie ce qui mérite d'être gardé — faits, décisions, leçons — et
1608
+ // la personne le range d'un clic. Trois règles portent tout :
1609
+ // · JAMAIS SANS CLIC. Toute la récolte part à la revue (`review`), marquée `origin: "harvest"`, et l'app
1610
+ // refuse de l'appliquer seule même si chaque op est dans PROP_AUTO_OPS (`propAutoOk`). L'appel d'outil
1611
+ // est fait par le MODÈLE, qui a pu lire une page qui lui dicte quoi envoyer : rien de ce qu'il envoie ne
1612
+ // devient un FAIT sans un geste dans l'app.
1613
+ // · LE LOT ENTIER OU RIEN. Un élément invalide fait échouer l'appel, en le nommant : une récolte est
1614
+ // petite et la personne l'a vue dans la conversation — mieux vaut la corriger que la ranger à moitié.
1615
+ // · UN ESPACE PAR APPEL. Les faits ne traversent pas les domaines ; les LEÇONS, elles, vont toujours dans
1616
+ // la carte « 🌱 Lessons » de Personal : la pensée appartient à la personne.
1617
+ // Les entrées ont la grammaire de 📚 File it (`kbFileEntry`) : un titre, une note qui finit par sa
1618
+ // provenance, des attributs « Nom: valeur » en enfants, rien de coché (une case recopiée serait une tâche
1619
+ // FANTÔME dans My work).
1620
+ // ≡ `KNOW_NAME` et `KNOW_DEFAULT` d'app/index.html (le test compare) : le connecteur cherche la carte de
1621
+ // connaissance par ce NOM — la marque `know` est un champ d'index du compte, qu'il ne lit pas encore — et
1622
+ // la crée avec ces sections quand l'espace n'en a pas.
1623
+ const KNOW_NAME = "Company knowledge";
1624
+ const KNOW_DEFAULT = ["🔑 Access — who has what", "🌐 Domains & infrastructure", "🧰 Tools & subscriptions",
1625
+ "💼 Clients & deals", "📖 Playbooks", "⚖️ Decisions"];
1626
+ const LESSONS_NAME = "🌱 Lessons";
1627
+ const HARVEST_MAX = 12;
1628
+ // ≡ app/index.html, comparés caractère par caractère par le test : un secret que l'app retirerait au
1629
+ // rangement et que le connecteur aurait laissé partir serait déjà dans la table `proposals`.
1630
+ const KB_SECRET_RES = [
1631
+ /\b(pass(word|wd|code|phrase)|pwd|mdp|mot de passe|secret|api[ _-]?key|token)\s*[:=]/i,
1632
+ /\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,})/,
1633
+ /\bBearer [A-Za-z0-9._~+\/-]{12,}/i
1634
+ ];
1635
+ function kbSecretLike(line){
1636
+ const s = String(line == null ? "" : line);
1637
+ if(KB_SECRET_RES.some(re => re.test(s))) return true;
1638
+ for(const w of s.split(/\s+/)){
1639
+ if(w.length >= 32 && !/^(https?:|mailto:|map:|fsref:)/i.test(w) && /^[A-Za-z0-9._~+\/=-]+$/.test(w)
1640
+ && /[A-Za-z]/.test(w) && /[0-9]/.test(w)) return true;
1641
+ }
1642
+ return false;
1643
+ }
1644
+ function knowNorm(s){ return String(s || "").toLowerCase().normalize("NFD").replace(/[̀-ͯ]/g, ""); }
1645
+ const hvSame = (a, b)=>knowNorm(a).trim() === knowNorm(b).trim();
1646
+ // Un texte d'une ligne, borné. `[` et `]` deviennent des parenthèses dans ce qui passe par le markdown d'un
1647
+ // `add` : « [x] » y serait lu comme une case, « [a](b) » comme un lien.
1648
+ function hvLine(v, max){ return (typeof v === "string") ? v.replace(/[\x00-\x1f\x7f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max) : ""; }
1649
+ function hvMd(v){ return String(v).replace(/\[/g, "(").replace(/\]/g, ")"); }
1650
+ // Un JOUR qui existe : la forme (`DAY_RE`) puis l'aller-retour par le calendrier — le piège du 30 février,
1651
+ // payé par 🏁 Harvest côté app.
1652
+ 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; }
1653
+ const okNid = v=>typeof v === "string" && /^[A-Za-z0-9_]{1,40}$/.test(v);
1654
+ // Le document d'une carte de connaissance (ou de leçons) NEUVE : celui que `kbKnowCreate` écrirait.
1655
+ function hvNewDoc(name, sections){
1656
+ const root = makeNode(name);
1657
+ root.color = "#dbeafe";
1658
+ root.children = sections.map(t=>makeNode(t));
1659
+ assignIds(root);
1660
+ return { v:1, root, images:[], stickies:[] };
1661
+ }
1662
+ // La carte la plus récente qui porte EXACTEMENT ce nom dans une portée (propriétaire + espace ; Personal =
1663
+ // pas d'espace), jamais un board, un kanban, un tableur ni un Gantt — la règle de `kbKnowMapId`.
1664
+ function hvFindMap(lib, owner, spaceKey, name){
1665
+ return lib.filter(m=>m.owner === owner && (m.spaceKey || null) === (spaceKey || null)
1666
+ && (m.kind === "map" || m.kind === "note") && hvSame(m.name, name))
1667
+ .sort((x, y)=>String(y.updatedAt).localeCompare(String(x.updatedAt)))[0] || null;
1668
+ }
1669
+ // Les ops d'UNE carte : un état de travail (la carte telle que les ops précédentes la laissent) et la liste.
1670
+ function hvTarget(doc){ return { seed: doc, doc: cloneDoc(doc), ops: [] }; }
1671
+ // Une op qui vise un nœud : par son ID quand il en a un valide (exact, jamais ambigu), avec le chemin
1672
+ // lisible pour le panneau ; sinon par le chemin seul.
1673
+ function hvAt(node, path){ return Object.assign(okNid(node.id) ? { node: node.id } : {}, { target: path.slice(0, 600) }); }
1674
+ // La section d'une carte de connaissance, par son nom (casse et accents ignorés, la règle de
1675
+ // `kbKnowSection`) ; absente, elle est AJOUTÉE par une op de section, une seule fois par appel. Elle ne
1676
+ // porte pas de `hv` : deux éléments peuvent y aller, et décocher l'un ne doit pas retirer l'autre.
1677
+ function hvSection(t, name){
1678
+ const root = t.doc.root;
1679
+ const hit = (root.children || []).find(c=>hvSame(c.text, name));
1680
+ if(hit) return hit;
1681
+ const nid = newNid();
1682
+ const node = Object.assign(makeNode(name), { id: nid });
1683
+ root.children = (root.children || []).concat([node]);
1684
+ 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 }));
1685
+ return node;
1686
+ }
1687
+ function hvFindEntry(section, text){ return (section.children || []).find(c=>hvSame(c.text, text)) || null; }
1688
+
1494
1689
  // ---------------------------------------------------------------------------
1495
1690
  // Définition des outils
1496
1691
  // ---------------------------------------------------------------------------
@@ -1541,12 +1736,12 @@ const TOOLS = [
1541
1736
  },
1542
1737
  {
1543
1738
  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.",
1739
+ 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
1740
  inputSchema: { type:"object", properties:{
1546
1741
  name:{ type:"string", description:"Name of the board" },
1547
1742
  elements:{ type:"array", description:"Board elements", items:{ type:"object", properties:{
1548
1743
  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)." },
1744
+ 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
1745
  text:{ type:"string" },
1551
1746
  x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
1552
1747
  color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
@@ -1555,8 +1750,11 @@ const TOOLS = [
1555
1750
  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
1751
  a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
1557
1752
  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 } },
1753
+ ir:{ type:"number", description:"arc only — inner-radius fraction 0..0.9 (0 = pie slice, 0.55 = donut)" },
1754
+ 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)." },
1755
+ data:{ type:"string", description:"image only — a data:image/(png|jpeg|webp|gif|svg+xml) URL instead of a file" },
1756
+ z:{ type:"number", description:"Stacking order, -9..9 (default 0). At equal z the app paints images UNDER shapes, shapes under sticky notes — so a filled background panel hides a logo placed on it. Give such a panel z:-1 (or the image z:1) to keep the picture visible." }
1757
+ }, additionalProperties:false } },
1560
1758
  connections:{ type:"array", description:"Arrows anchored between elements (by id)", items:{ type:"object", properties:{
1561
1759
  from:{ type:"string" }, to:{ type:"string" }
1562
1760
  }, required:["from","to"], additionalProperties:false } },
@@ -1637,6 +1835,27 @@ const TOOLS = [
1637
1835
  est:{ type:"string", description:"est: the estimate as a duration — \"2h\", \"1h30\", \"45\" (minutes), \"2:15\" — or \"\" to remove it" }
1638
1836
  }, required:["op"], additionalProperties:false } }
1639
1837
  }, required:["file","changes"], additionalProperties:false }
1838
+ },
1839
+ {
1840
+ // 🏁 1.6.0. La description porte la règle de déclenchement : c'est elle que Claude lit au moment de choisir.
1841
+ name: "harvest",
1842
+ 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.",
1843
+ inputSchema: { type:"object", properties:{
1844
+ 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." },
1845
+ 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." },
1846
+ nothing: { type:"boolean", description:"true when nothing is worth keeping — then send no items." },
1847
+ items: { type:"array", description:"What to keep, 12 at most.", items:{ type:"object", properties:{
1848
+ 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." },
1849
+ text: { type:"string", description:"The entry's title: one sentence that stays true on its own, without the conversation. 300 characters max." },
1850
+ detail: { type:"string", description:"Optional: what a reader needs in six months. 1500 characters max." },
1851
+ 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." },
1852
+ 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." },
1853
+ signal: { type:"string", description:"decision only: the observable sign that will say whether it was right, e.g. \"Q4 margins back above 22%\"." },
1854
+ check_on:{ type:"string", description:"decision only: YYYY-MM-DD, the day to look at the signal." },
1855
+ replaces:{ type:"string", description:"decision only: the EXACT text of an earlier decision this one supersedes. The earlier one is kept." },
1856
+ why: { type:"string", description:"One short line: why keep it. Shown next to the item when the user reviews it." }
1857
+ }, required:["kind","text"], additionalProperties:false } }
1858
+ }, additionalProperties:false }
1640
1859
  }
1641
1860
  ];
1642
1861
 
@@ -1735,9 +1954,10 @@ const HANDLERS = {
1735
1954
  return `✅ ${label} created (${countNodes(root)} nodes${dated.length ? ", " + dated.length + " dated" : ""}): ${file}\n${shotLines}${importHint()}`;
1736
1955
  },
1737
1956
  async create_board(args){
1738
- const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
1957
+ const { doc, warnings, imageNotes } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
1739
1958
  const file = await writeDoc(args.name || "Board", doc, args.folder, { space: args.space });
1740
- const n = doc.shapes.length + doc.stickies.length;
1959
+ const n = doc.shapes.length + doc.stickies.length + doc.images.length;
1960
+ const imgs = imageNotes.length ? `🖼 ${imageNotes.length} image(s):\n` + imageNotes.map(t=>" • " + t).join("\n") + "\n" : "";
1741
1961
  // Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
1742
1962
  // peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
1743
1963
  const warn = warnings.length
@@ -1746,7 +1966,7 @@ const HANDLERS = {
1746
1966
  + `\n Nothing spills out — the text was shrunk to fit — but blocks at different sizes look\n`
1747
1967
  + ` uneven on a canvas. Recreate the board with shorter text to keep it clean.\n`
1748
1968
  : "";
1749
- return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
1969
+ return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${imgs}${warn}${importHint()}`;
1750
1970
  },
1751
1971
  async create_kanban(args){
1752
1972
  if(typeof args.name !== "string" || !args.name.trim()) throw new Error("The `name` parameter is required (the board's name).");
@@ -1794,6 +2014,161 @@ const HANDLERS = {
1794
2014
  + "Tell the user to look at TabTree — do not claim those changes are made.\n";
1795
2015
  }
1796
2016
  return out + (shotLines ? shotLines + "\n" : "");
2017
+ },
2018
+ async harvest(args){
2019
+ 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.");
2020
+ const a = args || {};
2021
+ const items = Array.isArray(a.items) ? a.items : [];
2022
+ if(a.nothing === true){
2023
+ if(items.length) throw new Error("`nothing: true` means nothing is worth keeping — send no items with it.");
2024
+ return "Nothing filed: nothing from this conversation needed keeping. Tell the user so.";
2025
+ }
2026
+ if(!items.length) throw new Error("No items. List what is worth keeping — facts, decisions, lessons — or pass nothing: true.");
2027
+ 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.");
2028
+ const title = hvLine(a.title, 120);
2029
+ if(!title) throw new Error("`title` is required — one line naming the conversation; it is written as the source of every entry.");
2030
+ const today = new Date().toISOString().slice(0, 10);
2031
+
2032
+ // 1. Contrôler chaque élément AVANT de toucher au compte : un appel refusé ne crée rien.
2033
+ const clean = items.map((it, i)=>{
2034
+ const where = "item #" + (i + 1);
2035
+ if(!it || typeof it !== "object") throw new Error(where + ": must be an object { kind, text, … }.");
2036
+ const kind = it.kind;
2037
+ if(!["fact", "decision", "lesson"].includes(kind)) throw new Error(where + ": `kind` must be fact, decision or lesson.");
2038
+ const text = hvLine(it.text, 300);
2039
+ if(!text) throw new Error(where + ": `text` is required — one sentence that stays true on its own.");
2040
+ if(kbSecretLike(text)) throw new Error(where + ": its text looks like a password, key or token. Never file a secret — leave it out.");
2041
+ let secrets = 0;
2042
+ const detail = (typeof it.detail === "string" ? it.detail.replace(/\r/g, "").slice(0, 1500) : "").split("\n")
2043
+ .filter(l=>{ if(kbSecretLike(l)){ secrets++; return false; } return true; }).join("\n").trim();
2044
+ const out = { i, kind, text, detail, secrets, why: hvLine(it.why, 200) };
2045
+ 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 + "."); };
2046
+ only("section", ["fact"]); only("updates", ["fact"]);
2047
+ only("signal", ["decision"]); only("check_on", ["decision"]); only("replaces", ["decision"]);
2048
+ if(kind === "fact"){
2049
+ out.section = hvLine(it.section, 80);
2050
+ if(!out.section) throw new Error(where + ": a fact needs its `section` of the " + KNOW_NAME + " map (read_map shows them).");
2051
+ out.updates = hvLine(it.updates, 300);
2052
+ }
2053
+ if(kind === "decision"){
2054
+ out.signal = hvLine(it.signal, 300);
2055
+ out.replaces = hvLine(it.replaces, 300);
2056
+ out.check_on = hvLine(it.check_on, 10);
2057
+ if(out.check_on && !hvDayOk(out.check_on)) throw new Error(where + ": `check_on` must be a real day written YYYY-MM-DD.");
2058
+ if(kbSecretLike(out.signal)) throw new Error(where + ": its signal looks like a password, key or token.");
2059
+ }
2060
+ return out;
2061
+ });
2062
+
2063
+ // 2. L'espace, puis les deux cartes (sans rien créer encore).
2064
+ const lib = await loadLibrary();
2065
+ const me = cloudMe;
2066
+ let sp = null;
2067
+ if(a.space != null && String(a.space).trim()){
2068
+ const hits = (cloudSpacesCache || []).filter(x=>hvSame(x.name, a.space));
2069
+ 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" : "") + ".");
2070
+ sp = hits.find(x=>x.owner === me) || hits[0];
2071
+ 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.");
2072
+ }
2073
+ const knowOwner = sp ? sp.owner : me, knowKey = sp ? sp.space_key : null;
2074
+ const needKnow = clean.some(x=>x.kind !== "lesson"), needLessons = clean.some(x=>x.kind === "lesson");
2075
+ const knowMap = needKnow ? hvFindMap(lib, knowOwner, knowKey, KNOW_NAME) : null;
2076
+ const lessMap = needLessons ? hvFindMap(lib, me, null, LESSONS_NAME) : null;
2077
+ const know = needKnow ? hvTarget(knowMap ? knowMap.doc : hvNewDoc(KNOW_NAME, KNOW_DEFAULT)) : null;
2078
+ const less = needLessons ? hvTarget(lessMap ? lessMap.doc : hvNewDoc(LESSONS_NAME, [])) : null;
2079
+
2080
+ // 3. Les ops, élément par élément, contre la carte TELLE QUE LES ÉLÉMENTS PRÉCÉDENTS LA LAISSENT.
2081
+ const said = [], skipped = [];
2082
+ let secretLines = 0;
2083
+ const src = (verb)=>verb + " a conversation with Claude — “" + title + "” · " + today;
2084
+ for(const x of clean){
2085
+ const where = "item #" + (x.i + 1);
2086
+ secretLines += x.secrets;
2087
+ const hv = x.i;
2088
+ const noteOf = (verb)=>(x.detail ? x.detail + "\n\n" : "") + src(verb);
2089
+ const addEntry = (t, parent, path, attrs)=>{
2090
+ const nid = newNid();
2091
+ const md = "- " + hvMd(x.text) + attrs.map(([k, v])=>"\n - " + k + ": " + hvMd(v)).join("");
2092
+ 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 } : {}));
2093
+ t.ops.push({ op:"note", node: nid, target: (path + " > " + x.text).slice(0, 600), note: noteOf("From"), hv });
2094
+ const node = Object.assign(makeNode(x.text), { id: nid });
2095
+ node.children = attrs.map(([k, v])=>makeNode(k + ": " + v));
2096
+ parent.children = (parent.children || []).concat([node]);
2097
+ };
2098
+ if(x.kind === "lesson"){
2099
+ const root = less.doc.root;
2100
+ if(hvFindEntry(root, x.text)){ skipped.push("“" + x.text + "” is already in your " + LESSONS_NAME); continue; }
2101
+ addEntry(less, root, String(root.text || LESSONS_NAME), []);
2102
+ said.push("🌱 lesson — " + x.text);
2103
+ continue;
2104
+ }
2105
+ const root = know.doc.root;
2106
+ if(x.kind === "decision"){
2107
+ const sec = (root.children || []).find(c=>/decision/.test(knowNorm(c.text))) || hvSection(know, "⚖️ Decisions");
2108
+ if(hvFindEntry(sec, x.text)){ skipped.push("“" + x.text + "” is already among the decisions"); continue; }
2109
+ const attrs = [];
2110
+ if(x.signal) attrs.push(["Signal", x.signal]);
2111
+ if(x.check_on) attrs.push(["Check on", x.check_on]);
2112
+ if(x.replaces){
2113
+ const prev = hvFindEntry(sec, x.replaces);
2114
+ 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(", ") + ")" : "") + ".");
2115
+ attrs.push(["Replaces", String(prev.text)]);
2116
+ }
2117
+ addEntry(know, sec, String(root.text) + " > " + String(sec.text), attrs);
2118
+ said.push("⚖️ decision — " + x.text + (x.check_on ? " (check on " + x.check_on + ")" : ""));
2119
+ continue;
2120
+ }
2121
+ // fact
2122
+ const sec = hvSection(know, x.section);
2123
+ const path = String(root.text) + " > " + String(sec.text);
2124
+ if(x.updates){
2125
+ const prev = hvFindEntry(sec, x.updates);
2126
+ 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") + ".");
2127
+ const at = hvAt(prev, path + " > " + String(prev.text));
2128
+ 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 } : {}));
2129
+ know.ops.push(Object.assign({ op:"note" }, at, { was: String(prev.text), note: noteOf("Updated from"), hv }));
2130
+ prev.text = x.text;
2131
+ said.push("✏️ updated in " + sec.text + " — " + x.text);
2132
+ continue;
2133
+ }
2134
+ const dup = hvFindEntry(sec, x.text);
2135
+ if(dup){
2136
+ if(!x.detail){ skipped.push("“" + x.text + "” is already in “" + sec.text + "”"); continue; }
2137
+ know.ops.push(Object.assign({ op:"note" }, hvAt(dup, path + " > " + String(dup.text)), { was: String(dup.text), note: noteOf("Updated from"), hv }));
2138
+ said.push("✏️ detail updated in " + sec.text + " — " + x.text);
2139
+ continue;
2140
+ }
2141
+ addEntry(know, sec, path, []);
2142
+ said.push("📚 " + sec.text + " — " + x.text);
2143
+ }
2144
+ const targets = [];
2145
+ if(know && know.ops.length) targets.push([know, knowMap, KNOW_NAME, sp ? sp.name : undefined]);
2146
+ if(less && less.ops.length) targets.push([less, lessMap, LESSONS_NAME, undefined]);
2147
+ if(!targets.length) return "Nothing new to file — everything is already there:\n" + skipped.map(s=>" • " + s).join("\n") + "\nTell the user so.";
2148
+
2149
+ // 4. Créer ce qui manque (un squelette sans un fait : rien à approuver), puis déposer, à la revue.
2150
+ const harvest_id = "hv" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
2151
+ const created = [], filed = [];
2152
+ for(const [t, map0, name, space] of targets){
2153
+ let map = map0;
2154
+ if(!map){
2155
+ // Le squelette créé est EXACTEMENT celui contre lequel les ops ont été validées (`seed`, avant
2156
+ // toute op) : mêmes ids — les ops de la proposition visent ses sections par leur id.
2157
+ const info = await writeDoc(name, t.seed, null, { kind: "map", space, info: true });
2158
+ map = { file: info.id, key: info.key, owner: info.owner, team: info.team, name };
2159
+ created.push("“" + name + "”" + (space ? " in the space “" + space + "”" : " in Personal"));
2160
+ }
2161
+ const res = await writeProposal(map, "🏁 From your conversation with Claude: “" + title + "”", t.ops, { review: true, extra: { origin: "harvest", harvest_id, title } });
2162
+ filed.push(res.ids.review);
2163
+ }
2164
+ const n = said.length;
2165
+ return "🏁 " + n + " item" + (n > 1 ? "s are" : " is") + " waiting in TabTree" + (sp ? " (space “" + sp.name + "”)" : "") + ":\n"
2166
+ + said.map(s=>" • " + s).join("\n") + "\n"
2167
+ + (skipped.length ? "Left out, already there:\n" + skipped.map(s=>" • " + s).join("\n") + "\n" : "")
2168
+ + (secretLines ? "🔒 " + secretLines + " line(s) of detail looked like a password, key or token and were left out.\n" : "")
2169
+ + (created.length ? "📚 Created " + created.join(" and ") + " to hold them.\n" : "")
2170
+ + "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"
2171
+ + "Tell the user it is waiting in TabTree — do not claim it is filed.";
1797
2172
  }
1798
2173
  };
1799
2174
 
@@ -1815,7 +2190,7 @@ function handle(msg){
1815
2190
  // déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
1816
2191
  // signale : un client affiche l'une, le registre publie l'autre. Un
1817
2192
  // autotest les compare désormais toutes les quatre.
1818
- serverInfo: { name: "tabtree", version: "1.5.0" }
2193
+ serverInfo: { name: "tabtree", version: "1.6.1" }
1819
2194
  });
1820
2195
  } else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
1821
2196
  // notification : pas de réponse