tabtree-mcp 1.1.0 → 1.2.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 +24 -12
- package/package.json +2 -2
- package/tabtree-mcp.mjs +168 -38
package/README.md
CHANGED
|
@@ -7,17 +7,22 @@ Let Claude read your TabTree library and build new maps for you.
|
|
|
7
7
|
> "Draw our database schema as a board."
|
|
8
8
|
> "Add the risks we just discussed to my Q3 plan."
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
**You need TabTree itself.** This package is the bridge, not the app. It works
|
|
11
|
+
with both editions of TabTree, and you pick the mode with ONE environment variable:
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
13
|
+
| Edition | What Claude talks to | Set |
|
|
14
|
+
| --- | --- | --- |
|
|
15
|
+
| **TabTree** (subscription — your maps live in your account) | your account, through TabTree's API | `TABTREE_API_KEY` — the key from ⚙️ Settings → Account → **Claude connector** |
|
|
16
|
+
| **TabTree Classic** (the local file, bought once) | the 🛟 backup folder on your disk | `TABTREE_DIR` — that folder's path |
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
In account mode a new map appears in TabTree within a minute, on every device you
|
|
19
|
+
are signed in on. In Classic mode it appears as a file in your backup folder, and
|
|
20
|
+
you pull it in from *My maps* → **🛟** → **"Bring maps back from this folder…"**.
|
|
21
|
+
|
|
22
|
+
In both modes the connector **only ever creates new maps.** It cannot overwrite a
|
|
23
|
+
map, cannot delete one, and never touches your browser storage. With a folder,
|
|
24
|
+
nothing is uploaded anywhere; with an account, it talks only to TabTree's own
|
|
25
|
+
API for that account.
|
|
21
26
|
|
|
22
27
|
Changes to a map you already have are no exception. Claude never writes to it:
|
|
23
28
|
it files a **proposal**, TabTree shows it as a banner on that map, and you tick
|
|
@@ -27,9 +32,16 @@ the changes you want. ⌘Z undoes them afterwards, like anything else you do.
|
|
|
27
32
|
|
|
28
33
|
## Before you start
|
|
29
34
|
|
|
30
|
-
**1.
|
|
31
|
-
|
|
32
|
-
|
|
35
|
+
**1. Get your key, or your folder.** TabTree (subscription): open the app →
|
|
36
|
+
⚙️ Settings → Account → **Claude connector** → *Create a connector key*. It is
|
|
37
|
+
shown once — copy it. TabTree Classic: open TabTree → *My maps* → 🛟 and pick a
|
|
38
|
+
folder; that folder is what the connector reads and writes.
|
|
39
|
+
|
|
40
|
+
Quickest test, account mode:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
TABTREE_API_KEY=tt_live_… npx tabtree-mcp
|
|
44
|
+
```
|
|
33
45
|
|
|
34
46
|
**2. Node.js 18+ — for the npm and Claude Code routes.** Claude Desktop ships
|
|
35
47
|
its own Node, so the `.mcpb` install below needs nothing. For the other routes,
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabtree-mcp",
|
|
3
|
-
"version": "1.1
|
|
3
|
+
"version": "1.2.1",
|
|
4
4
|
"mcpName": "app.tabtree/tabtree",
|
|
5
|
-
"description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards
|
|
5
|
+
"description": "Let Claude read your TabTree library and turn conversations into mind maps and whiteboards — in your TabTree account, or from local files.",
|
|
6
6
|
"repository": {
|
|
7
7
|
"type": "git",
|
|
8
8
|
"url": "git+https://github.com/ai4xedu/tabtree-mcp.git"
|
package/tabtree-mcp.mjs
CHANGED
|
@@ -22,6 +22,44 @@ import { readFileSync, writeFileSync, readdirSync, statSync, existsSync, mkdirSy
|
|
|
22
22
|
import { join, basename } from "node:path";
|
|
23
23
|
|
|
24
24
|
const DIR = process.env.TABTREE_DIR || process.env.YGMIND_DIR || "";
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// DEUX SOURCES, UN CONNECTEUR (2026-09-08). Le dossier 🛟 sert TabTree Classic (le fichier
|
|
27
|
+
// local) ; le CLOUD sert l'abonné TabTree SaaS. La source est choisie par la présence d'une
|
|
28
|
+
// clé personnelle `TABTREE_API_KEY` (fabriquée dans ⚙️ Account → Claude connector) : les six
|
|
29
|
+
// outils ne changent pas d'un mot de contrat, seule la tuyauterie de lecture/écriture change.
|
|
30
|
+
// `TABTREE_API_URL` n'est là que pour une préversion — la valeur par défaut est la production.
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
let API_KEY = String(process.env.TABTREE_API_KEY || "").trim();
|
|
33
|
+
// ⚠️ UN CHAMP NON RENSEIGNÉ N'EST PAS UNE CLÉ FAUSSE (2026-09-09). Claude Desktop passe le
|
|
34
|
+
// gabarit `${user_config.api_key}` TEL QUEL quand le champ est vide — mesuré le jour où la
|
|
35
|
+
// réinstallation du .mcpb a vidé la configuration (la clé est `sensitive`, donc rangée dans le
|
|
36
|
+
// Trousseau et liée à l'installation précédente). `CLOUD = !!API_KEY` prenait ce littéral pour
|
|
37
|
+
// une clé, l'envoyait, et le serveur répondait « Unknown or revoked connector key » : un
|
|
38
|
+
// message FAUX, qui envoie chercher une clé révoquée là où il n'y a simplement rien de saisi.
|
|
39
|
+
if(/^\$\{.*\}$/.test(API_KEY)) API_KEY = "";
|
|
40
|
+
const API_URL = String(process.env.TABTREE_API_URL || "https://aynmiptyvisxoslzkzxn.supabase.co/functions/v1/mcp").trim();
|
|
41
|
+
const CLOUD = !!API_KEY;
|
|
42
|
+
// La FORME d'une clé se vérifie ici, avant tout aller-retour : l'app en fabrique une en
|
|
43
|
+
// `tt_live_` + 43 caractères base64url (`connKeyMaterial`). Une valeur qui n'y ressemble pas est
|
|
44
|
+
// une faute de copie ou un champ mal rempli — le dire tout de suite nomme la bonne cause, et
|
|
45
|
+
// évite d'aller faire dire au serveur quelque chose qu'il ne sait pas.
|
|
46
|
+
const API_KEY_OK = /^tt_live_[A-Za-z0-9_-]{20,}$/.test(API_KEY);
|
|
47
|
+
async function cloudCall(action, body){
|
|
48
|
+
if(!API_KEY_OK) throw new Error(
|
|
49
|
+
"TABTREE_API_KEY does not look like a connector key — it must start with \"tt_live_\". "
|
|
50
|
+
+ "If you just reinstalled the connector, its settings were reset: open TabTree → \u2699\ufe0f Settings "
|
|
51
|
+
+ "\u2192 Account \u2192 Claude connector, create a key, paste it into the connector's settings, "
|
|
52
|
+
+ "then restart the connector.");
|
|
53
|
+
let r;
|
|
54
|
+
try{
|
|
55
|
+
r = await fetch(API_URL, { method:"POST",
|
|
56
|
+
headers:{ "content-type":"application/json", "x-tabtree-key": API_KEY },
|
|
57
|
+
body: JSON.stringify(Object.assign({ action }, body || {})) });
|
|
58
|
+
}catch(e){ throw new Error("Could not reach the TabTree cloud (" + (e && e.message || e) + ")."); }
|
|
59
|
+
let j = null; try{ j = await r.json(); }catch(e){}
|
|
60
|
+
if(!r.ok || !j || j.ok === false) throw new Error((j && j.error) || ("The TabTree cloud answered HTTP " + r.status + "."));
|
|
61
|
+
return j;
|
|
62
|
+
}
|
|
25
63
|
const MANIFEST = "bibliotheque.json";
|
|
26
64
|
// Boîte aux lettres des propositions. Le connecteur reste EN AJOUT SEUL : il ne modifie
|
|
27
65
|
// jamais un .tabtree, il dépose ici un fichier que l'app lit, montre, et n'applique que si
|
|
@@ -33,7 +71,8 @@ const PROP_DIR = "Propositions";
|
|
|
33
71
|
// Utilitaires
|
|
34
72
|
// ---------------------------------------------------------------------------
|
|
35
73
|
function dirOk(){
|
|
36
|
-
if(
|
|
74
|
+
if(CLOUD) return; // la source est le compte, pas un dossier
|
|
75
|
+
if(!DIR) throw new Error("The connector has no source. For TabTree (subscription), set TABTREE_API_KEY to the connector key from ⚙️ Settings → Account → Claude connector. For TabTree Classic (the local file), set TABTREE_DIR to your 🛟 backup folder. (The older name YGMIND_DIR still works.)");
|
|
37
76
|
if(!existsSync(DIR)) throw new Error("The TABTREE_DIR folder does not exist: " + DIR);
|
|
38
77
|
}
|
|
39
78
|
// Même règle de nommage que l'app (bkSafeName) : la restauration retire le
|
|
@@ -116,7 +155,9 @@ const ART_IDS_MCP = [
|
|
|
116
155
|
"laptop", "phone", "mail", "folder", "book", "chart",
|
|
117
156
|
"money", "calendar", "building", "car", "globe", "rocket",
|
|
118
157
|
"house", "pin", "plane", "coffee", "plant", "sun",
|
|
119
|
-
"camera", "music", "lock", "key", "health", "gift"
|
|
158
|
+
"camera", "music", "lock", "key", "health", "gift",
|
|
159
|
+
"manager", "client", "expert", "assistant", "coach", "learner",
|
|
160
|
+
"developer", "seller", "support", "speaker", "freelancer", "group"
|
|
120
161
|
];
|
|
121
162
|
const ART = Object.fromEntries(ART_IDS_MCP.map(id=>[id, true]));
|
|
122
163
|
const EMO_PIC_PAT = "\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?(?:\\u200D\\p{Extended_Pictographic}(?:\\uFE0F|\\uFE0E)?)*";
|
|
@@ -267,7 +308,11 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
267
308
|
a0: (e.kind==="arc" && Number.isFinite(+e.a0)) ? +e.a0 : null,
|
|
268
309
|
a1: (e.kind==="arc" && Number.isFinite(+e.a1)) ? +e.a1 : null,
|
|
269
310
|
ir: (e.kind==="arc" && Number.isFinite(+e.ir)) ? +e.ir : null,
|
|
270
|
-
size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null
|
|
311
|
+
size: (+e.size>=9 && +e.size<=72) ? Math.round(+e.size) : null,
|
|
312
|
+
// L'image sans octets sur un élément de board (2026-09-03) : même frontière que
|
|
313
|
+
// pour un nœud — un id inventé retombe sur « aucune image », jamais sur du balisage.
|
|
314
|
+
// Pas sur un secteur, un gradin ou un chevron : leur silhouette n'a pas de « haut ».
|
|
315
|
+
pic: (e.pic != null && !["arc","trap","chev"].includes(kind)) ? okPic(String(e.pic)) : "" };
|
|
271
316
|
});
|
|
272
317
|
const byId = new Map();
|
|
273
318
|
for(const e of els){
|
|
@@ -332,10 +377,11 @@ function buildBoardDoc(name, elements, connections, style){
|
|
|
332
377
|
for(const e of els){
|
|
333
378
|
if(e.kind==="sticky"){
|
|
334
379
|
refOf.set(e.lid, "st:" + stickies.length);
|
|
335
|
-
stickies.push({ text:e.text, x:e.x, y:e.y, w:e.w, h:e.h, color:e.color, size:e.size||undefined });
|
|
380
|
+
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 });
|
|
336
381
|
} else {
|
|
337
382
|
refOf.set(e.lid, "sh:" + shapes.length);
|
|
338
383
|
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,
|
|
384
|
+
pic: e.pic||undefined,
|
|
339
385
|
tk: e.tk != null ? e.tk : undefined,
|
|
340
386
|
a0: e.a0 != null ? e.a0 : undefined,
|
|
341
387
|
a1: e.a1 != null ? e.a1 : undefined,
|
|
@@ -452,6 +498,36 @@ function scanLibrary(){
|
|
|
452
498
|
out.sort((a,b)=>b.mtime.localeCompare(a.mtime));
|
|
453
499
|
return out;
|
|
454
500
|
}
|
|
501
|
+
// La bibliothèque du COMPTE, présentée exactement comme celle du dossier : mêmes champs, donc
|
|
502
|
+
// les six outils n'ont rien à savoir de la source. `file` porte la clé de la carte (map_key),
|
|
503
|
+
// que `propose_changes` renvoie telle quelle — pas de suffixe « __id » à analyser ici.
|
|
504
|
+
async function cloudLibrary(){
|
|
505
|
+
const j = await cloudCall("docs", {});
|
|
506
|
+
const folders = new Map((j.folders||[]).filter(f=>f && f.folder_key).map(f=>[f.folder_key, f]));
|
|
507
|
+
const pathOf = (k)=>{
|
|
508
|
+
const out = [], seen = new Set();
|
|
509
|
+
let f = folders.get(k);
|
|
510
|
+
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(f.parent_key) : null; }
|
|
511
|
+
return out.length ? out.join(" / ") : null;
|
|
512
|
+
};
|
|
513
|
+
const out = [];
|
|
514
|
+
for(const r of (j.maps||[])){
|
|
515
|
+
const doc = r && r.doc;
|
|
516
|
+
if(!doc || !doc.root) continue;
|
|
517
|
+
out.push({
|
|
518
|
+
file: r.map_key,
|
|
519
|
+
name: String(r.name || "") || String(doc.root.text || "").slice(0, 60) || r.map_key,
|
|
520
|
+
folder: pathOf(r.folder_key),
|
|
521
|
+
kind: doc.board ? "board" : "map",
|
|
522
|
+
nodes: countNodes(doc.root),
|
|
523
|
+
elements: doc.board ? ((doc.shapes||[]).length + (doc.stickies||[]).length + (doc.arrows||[]).length + (doc.draws||[]).length) : undefined,
|
|
524
|
+
mtime: String(r.updated_at || "").slice(0,16).replace("T"," "),
|
|
525
|
+
doc
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return out;
|
|
529
|
+
}
|
|
530
|
+
async function loadLibrary(){ return CLOUD ? cloudLibrary() : scanLibrary(); }
|
|
455
531
|
function collectTexts(doc){
|
|
456
532
|
const texts = [];
|
|
457
533
|
(function walk(n){ if(n.text) texts.push(n.text); if(n.note) texts.push(n.note); (n.children||[]).forEach(walk); })(doc.root);
|
|
@@ -486,9 +562,9 @@ function boardToText(doc){
|
|
|
486
562
|
if((doc.draws||[]).length) lines.push(`Pencil strokes: ${doc.draws.length}`);
|
|
487
563
|
return lines.join("\n");
|
|
488
564
|
}
|
|
489
|
-
function findMap(file){
|
|
565
|
+
async function findMap(file){
|
|
490
566
|
checkRelPath(file);
|
|
491
|
-
const all =
|
|
567
|
+
const all = await loadLibrary();
|
|
492
568
|
const hit = all.find(m=>m.file===file) || all.find(m=>m.file.endsWith(file)) || all.find(m=>m.name===file);
|
|
493
569
|
if(!hit) throw new Error("No such map: " + file + ". Use list_maps to see what is available.");
|
|
494
570
|
return hit;
|
|
@@ -497,7 +573,20 @@ function findMap(file){
|
|
|
497
573
|
// ---------------------------------------------------------------------------
|
|
498
574
|
// Écriture (toujours un NOUVEAU fichier — jamais de modification en place)
|
|
499
575
|
// ---------------------------------------------------------------------------
|
|
500
|
-
function writeDoc(name, doc, folder){
|
|
576
|
+
async function writeDoc(name, doc, folder){
|
|
577
|
+
if(CLOUD){
|
|
578
|
+
// Le dossier est un NOM de dossier de bibliothèque : on le résout en clé s'il existe déjà,
|
|
579
|
+
// sinon la carte arrive à la racine et le texte de réponse le dit (« no silent caps »).
|
|
580
|
+
let folder_key = null;
|
|
581
|
+
if(folder){
|
|
582
|
+
const j = await cloudCall("list", {});
|
|
583
|
+
const hit = (j.folders||[]).find(f=>f && String(f.name||"").trim().toLowerCase() === String(folder).trim().toLowerCase());
|
|
584
|
+
if(hit) folder_key = hit.folder_key;
|
|
585
|
+
}
|
|
586
|
+
const kind = doc.board ? "board" : (doc.kanban ? "kanban" : "map");
|
|
587
|
+
const r = await cloudCall("create", { name: String(name||"").slice(0,200), kind, doc, folder_key });
|
|
588
|
+
return "cloud:" + r.map_key + (folder && !folder_key ? " (folder “" + folder + "” does not exist in this account — the map is at the root)" : "");
|
|
589
|
+
}
|
|
501
590
|
dirOk();
|
|
502
591
|
let target = DIR;
|
|
503
592
|
if(folder){
|
|
@@ -527,7 +616,7 @@ function countForest(roots){ return roots.reduce((n,r)=>n + countNodes(r), 0); }
|
|
|
527
616
|
// connecteur la refuserait à l'écriture, ou l'app la jetterait à la lecture — dans les deux
|
|
528
617
|
// cas Claude croirait avoir proposé quelque chose qui n'arrive jamais chez l'utilisateur.
|
|
529
618
|
// C'est le défaut du losange, rejoué. Un test compare les deux listes en ensembles.
|
|
530
|
-
const PROP_OPS = ["add","rename","note","check","delete","move","pic"];
|
|
619
|
+
const PROP_OPS = ["add","rename","note","check","delete","move","pic","persona"];
|
|
531
620
|
|
|
532
621
|
// Tout est vérifié ICI, contre la carte réelle, et un défaut fait ÉCHOUER l'appel. C'est
|
|
533
622
|
// délibéré : une cible introuvable remonte à Claude, qui peut relire la carte et corriger,
|
|
@@ -541,6 +630,24 @@ function buildProposalOps(mapDoc, changes){
|
|
|
541
630
|
const where = "change #" + (i+1);
|
|
542
631
|
const op = String((c && c.op) || "").trim();
|
|
543
632
|
if(!PROP_OPS.includes(op)) throw new Error(`${where}: unknown op "${op}". Use one of: ${PROP_OPS.join(", ")}.`);
|
|
633
|
+
if(op === "persona"){
|
|
634
|
+
// `persona` (2026-09-08) vise le DOCUMENT entier — la toile d'une work map — jamais un
|
|
635
|
+
// nœud : donc pas de `target`. C'est le relais skill → carte sans copier-coller : le skill
|
|
636
|
+
// « ma cartographie du travail » dépose ici l'objet de l'entretien, l'app propose de
|
|
637
|
+
// l'appliquer d'un clic. Contrôle SUPERFICIEL, et c'est une décision : `okPersona`, la
|
|
638
|
+
// frontière de confiance de l'app (soixante lignes de bornes), juge à l'application et
|
|
639
|
+
// nomme son refus dans le panneau. La dupliquer ici serait une seconde règle à figer
|
|
640
|
+
// caractère par caractère comme `okPic`. La raison qui fait valider les autres ops en
|
|
641
|
+
// profondeur — « une proposition à moitié valide arrive en lignes barrées » — ne
|
|
642
|
+
// s'applique pas : une op persona est ATOMIQUE, elle rebâtit la toile ou ne fait rien.
|
|
643
|
+
const P = c.persona;
|
|
644
|
+
if(!P || typeof P !== "object" || !Array.isArray(P.modules) || !P.modules.length)
|
|
645
|
+
throw new Error(`${where}: \`persona\` must be the interview object — { who, role, mission, tools, meetings, opener, modules:[{ name, procs:[{ name, h, after, freq, rep, data, stakes, lever, step, why }] }], trajectory, signs, parking }.`);
|
|
646
|
+
const n = P.modules.reduce((a, m)=>a + ((m && Array.isArray(m.procs)) ? m.procs.length : 0), 0);
|
|
647
|
+
if(n < 3) throw new Error(`${where}: the persona has ${n} process(es) — a work map needs at least three. Finish the interview first.`);
|
|
648
|
+
if(JSON.stringify(P).length > 60000) throw new Error(`${where}: the persona is too large (60000 characters max).`);
|
|
649
|
+
return { op, persona: P, procs: n };
|
|
650
|
+
}
|
|
544
651
|
const target = String(c.target == null ? "" : c.target);
|
|
545
652
|
const r = propFind(mapDoc.root, target);
|
|
546
653
|
if(r.error === "empty") throw new Error(`${where}: \`target\` is required — the node the change applies to.`);
|
|
@@ -595,17 +702,24 @@ function buildProposalOps(mapDoc, changes){
|
|
|
595
702
|
}
|
|
596
703
|
function parts_last(spec){ const p = String(spec||"").split(">"); return p[p.length-1].trim(); }
|
|
597
704
|
|
|
598
|
-
function writeProposal(map, note, ops){
|
|
705
|
+
async function writeProposal(map, note, ops){
|
|
706
|
+
const payload = (id)=>({
|
|
707
|
+
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: CLOUD ? map.file : basename(map.file), mapName: map.name,
|
|
708
|
+
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
709
|
+
});
|
|
710
|
+
if(CLOUD){
|
|
711
|
+
// La boîte aux lettres cloud : la table `proposals`, que l'app relit au sondage suivant.
|
|
712
|
+
// Même charge utile que le fichier — c'est le même `parseProposal` qui la lit côté app.
|
|
713
|
+
const r = await cloudCall("propose", { map_key: map.file, payload: payload(map.file) });
|
|
714
|
+
return "cloud:" + (r.id || "proposal");
|
|
715
|
+
}
|
|
599
716
|
dirOk();
|
|
600
717
|
const target = join(DIR, PROP_DIR);
|
|
601
718
|
if(!existsSync(target)) mkdirSync(target);
|
|
602
719
|
const id = mapIdOf(map.file);
|
|
603
720
|
if(!id) throw new Error("This map's file has no id suffix (“…__map123.tabtree”), so the app cannot match a proposal to it. Open it once in TabTree with the backup folder turned on, then try again.");
|
|
604
721
|
const file = "prop_" + id + "__" + Date.now().toString(36) + ".json";
|
|
605
|
-
writeFileSync(join(target, file), JSON.stringify(
|
|
606
|
-
v: 1, kind: "tabtree-proposal", mapId: id, mapFile: basename(map.file), mapName: map.name,
|
|
607
|
-
createdAt: Date.now(), by: "Claude", note: String(note||"").slice(0, 2000), ops
|
|
608
|
-
}, null, 2), "utf8");
|
|
722
|
+
writeFileSync(join(target, file), JSON.stringify(payload(id), null, 2), "utf8");
|
|
609
723
|
return PROP_DIR + "/" + file;
|
|
610
724
|
}
|
|
611
725
|
|
|
@@ -619,6 +733,8 @@ function writeProposal(map, note, ops){
|
|
|
619
733
|
// Tout répond « ✅ » et rien n'apparaît. Donner le nom du dossier rend l'écart
|
|
620
734
|
// visible en une seconde, sans que personne ait à soupçonner quoi que ce soit.
|
|
621
735
|
function importHint(){
|
|
736
|
+
if(CLOUD) return "Saved to the user's TabTree account. It appears in TabTree within a minute (the app polls "
|
|
737
|
+
+ "its account while open), or on the next sign-in on any device. Nothing is overwritten.";
|
|
622
738
|
return "Written to the folder “" + basename(DIR) + "”.\n"
|
|
623
739
|
+ "To see it in TabTree: My maps → 🛟 → “Bring maps back from this folder…”. "
|
|
624
740
|
+ "(Or press ⌘K and type “bring my maps back”.) Nothing is overwritten.\n"
|
|
@@ -674,6 +790,7 @@ const TOOLS = [
|
|
|
674
790
|
x:{ type:"number" }, y:{ type:"number" }, w:{ type:"number" }, h:{ type:"number" },
|
|
675
791
|
color:{ type:"string", description:"Hex fill colour (shapes) or sticky-note colour" },
|
|
676
792
|
size:{ type:"number", description:"Text size 9-72 (defaults to 15)" },
|
|
793
|
+
pic:{ type:"string", description:"Optional picture drawn INSIDE the element, no file needed — \"a:<id>\" for a built-in illustration (roles: manager, client, expert, assistant, coach, learner, developer, seller, support, speaker, freelancer, group — plus person, team, idea, target, rocket…) or \"e:<emoji>\". Not on arc/trap/chev." },
|
|
677
794
|
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)" },
|
|
678
795
|
a0:{ type:"number", description:"arc only — start angle in degrees, 0 at 12 o'clock, clockwise" },
|
|
679
796
|
a1:{ type:"number", description:"arc only — end angle in degrees (a1 > a0; a full circle is a0=0, a1=360)" },
|
|
@@ -696,34 +813,35 @@ const TOOLS = [
|
|
|
696
813
|
},
|
|
697
814
|
{
|
|
698
815
|
name: "propose_changes",
|
|
699
|
-
description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node. Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
|
|
816
|
+
description: "Proposes changes to an EXISTING mind map: add branches, rename a node, write a note, tick a task, remove a branch, illustrate a node — or, with op \"persona\", re-cast a WORK MAP canvas from a finished interview (the JSON block of the « ma cartographie du travail » skill). Nothing is applied — the proposal appears in TabTree as a banner on that map, the user reviews it change by change and picks what to keep (and can undo with Cmd+Z afterwards). Name each target node by its exact text, or by a path \"Ancestor > Node\" when the same wording appears twice. Read the map first so the wording matches.",
|
|
700
817
|
inputSchema: { type:"object", properties:{
|
|
701
818
|
file:{ type:"string", description:"The map to change — a file name (or map name) from list_maps" },
|
|
702
819
|
note:{ type:"string", description:"One line telling the user what this proposal does and why. Shown above the changes." },
|
|
703
820
|
changes:{ type:"array", description:"The proposed changes, applied in order", items:{ type:"object", properties:{
|
|
704
|
-
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it" },
|
|
705
|
-
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous" },
|
|
821
|
+
op:{ type:"string", enum:["add","rename","note","check","delete","move","pic","persona"], description:"add = new children under `target`; rename = change its text; note = set its note; check = tick/untick the task; delete = remove it and its children; move = send the card to a kanban column; pic = put an illustration on it; persona = re-cast a work map canvas (a board whose name starts with “Work map —” / “Cartographie —”) from an interview — no target" },
|
|
822
|
+
target:{ type:"string", description:"Exact node text, or \"Ancestor > Node\" if ambiguous (not used by persona)" },
|
|
823
|
+
persona:{ type:"object", description:"persona: the interview object exactly as the skill returns it — who, role, mission, tools, meetings, opener, modules[{name, procs[{name,h,after,freq,rep,data,stakes,lever,step,why}]}], trajectory, signs, parking. TabTree rebuilds the canvas AND its companion sheet from it." },
|
|
706
824
|
markdown:{ type:"string", description:"add: the new branch as a bullet list, 2 spaces per level" },
|
|
707
825
|
text:{ type:"string", description:"rename: the new text" },
|
|
708
826
|
note:{ type:"string", description:"note: the note body (empty string clears it)" },
|
|
709
827
|
value:{ type:"boolean", description:"check: true to tick, false to untick" },
|
|
710
828
|
col:{ type:"string", description:"move: the kanban column id (read_map shows them), or \"\" to take the card off the board" },
|
|
711
829
|
pic:{ type:"string", description:"pic: \"a:<id>\" for a built-in illustration, or \"e:<emoji>\" for one large emoji, or \"\" to remove it. Ids: person, team, chat, idea, target, trophy, star, heart, warning, done, flag, clock, laptop, phone, mail, folder, book, chart, money, calendar, building, car, globe, rocket, house, pin, plane, coffee, plant, sun, camera, music, lock, key, health, gift." }
|
|
712
|
-
}, required:["op"
|
|
830
|
+
}, required:["op"], additionalProperties:false } }
|
|
713
831
|
}, required:["file","changes"], additionalProperties:false }
|
|
714
832
|
}
|
|
715
833
|
];
|
|
716
834
|
|
|
717
835
|
const HANDLERS = {
|
|
718
|
-
list_maps(){
|
|
719
|
-
const maps =
|
|
720
|
-
if(!maps.length) return "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
836
|
+
async list_maps(){
|
|
837
|
+
const maps = await loadLibrary();
|
|
838
|
+
if(!maps.length) return CLOUD ? "This TabTree account has no maps yet." : "The library is empty (or TABTREE_DIR points at the wrong folder: " + DIR + ").";
|
|
721
839
|
return maps.map(m=>
|
|
722
840
|
`• ${m.name} [${m.kind}]${m.folder ? " 📁 " + m.folder : ""}\n file: ${m.file}\n ${m.kind==="board" ? m.elements + " elements" : m.nodes + " nodes"} · modified ${m.mtime}`
|
|
723
841
|
).join("\n");
|
|
724
842
|
},
|
|
725
|
-
read_map(args){
|
|
726
|
-
const m = findMap(args.file);
|
|
843
|
+
async read_map(args){
|
|
844
|
+
const m = await findMap(args.file);
|
|
727
845
|
const head = `${m.name} [${m.kind}] — file: ${m.file}\n`;
|
|
728
846
|
if(m.kind === "board") return head + boardToText(m.doc);
|
|
729
847
|
// Le rappel sur propose_changes vit ici et pas dans la description de l'outil : c'est au
|
|
@@ -731,11 +849,11 @@ const HANDLERS = {
|
|
|
731
849
|
return head + nodeToMarkdown(m.doc.root, 0)
|
|
732
850
|
+ "\n\n(To change this map, use propose_changes with `file: \"" + m.file + "\"`. Name a node by the exact text above, or \"Ancestor > Node\" when it appears twice. Nothing is applied until the user approves it in TabTree.)";
|
|
733
851
|
},
|
|
734
|
-
search_maps(args){
|
|
852
|
+
async search_maps(args){
|
|
735
853
|
const q = String(args.query||"").toLowerCase();
|
|
736
854
|
if(!q) throw new Error("`query` is empty.");
|
|
737
855
|
const hits = [];
|
|
738
|
-
for(const m of
|
|
856
|
+
for(const m of await loadLibrary()){
|
|
739
857
|
const found = [];
|
|
740
858
|
if(m.name.toLowerCase().includes(q)) found.push("(map name)");
|
|
741
859
|
for(const t of collectTexts(m.doc)){
|
|
@@ -746,7 +864,7 @@ const HANDLERS = {
|
|
|
746
864
|
}
|
|
747
865
|
return hits.length ? hits.join("\n") : "Nothing found for \"" + args.query + "\".";
|
|
748
866
|
},
|
|
749
|
-
create_mindmap(args){
|
|
867
|
+
async create_mindmap(args){
|
|
750
868
|
// Sans cette garde, un appel où « markdown » manque donnait String(undefined) =
|
|
751
869
|
// la chaîne "undefined" : une carte à un seul nœud, écrite sur le disque sans erreur.
|
|
752
870
|
if(typeof args.markdown !== "string" || !args.markdown.trim())
|
|
@@ -764,12 +882,12 @@ const HANDLERS = {
|
|
|
764
882
|
const doc = { v:1, root, images:[], stickies:[] };
|
|
765
883
|
const st = okStyleMcp(args.style);
|
|
766
884
|
if(st) doc.style = st;
|
|
767
|
-
const file = writeDoc(args.name || root.text, doc, args.folder);
|
|
885
|
+
const file = await writeDoc(args.name || root.text, doc, args.folder);
|
|
768
886
|
return `✅ Map created (${countNodes(root)} nodes): ${file}\n${importHint()}`;
|
|
769
887
|
},
|
|
770
|
-
create_board(args){
|
|
888
|
+
async create_board(args){
|
|
771
889
|
const { doc, warnings } = buildBoardDoc(args.name || "Board", args.elements, args.connections, args.style);
|
|
772
|
-
const file = writeDoc(args.name || "Board", doc, args.folder);
|
|
890
|
+
const file = await writeDoc(args.name || "Board", doc, args.folder);
|
|
773
891
|
const n = doc.shapes.length + doc.stickies.length;
|
|
774
892
|
// Les avertissements passent AVANT l'astuce d'import : c'est le seul moment où le texte
|
|
775
893
|
// peut encore être raccourci, et une ligne noyée en fin de réponse ne se lit pas.
|
|
@@ -781,12 +899,23 @@ const HANDLERS = {
|
|
|
781
899
|
: "";
|
|
782
900
|
return `✅ Board created (${n} elements, ${doc.arrows.length} connection(s)): ${file}\n${warn}${importHint()}`;
|
|
783
901
|
},
|
|
784
|
-
propose_changes(args){
|
|
785
|
-
const m = findMap(args.file);
|
|
786
|
-
|
|
902
|
+
async propose_changes(args){
|
|
903
|
+
const m = await findMap(args.file);
|
|
904
|
+
// Un persona est la SEULE op qui vise un board — la work map en est un — et il ne vise que ça.
|
|
905
|
+
const allPersona = Array.isArray(args.changes) && args.changes.length > 0 && args.changes.every(c=>c && c.op === "persona");
|
|
906
|
+
if(m.kind === "board" && !allPersona)
|
|
787
907
|
throw new Error("Proposals only apply to mind maps. A board has free positions and drawings, which a change list cannot describe.");
|
|
908
|
+
if(allPersona && m.kind !== "board")
|
|
909
|
+
throw new Error("A persona re-casts a WORK MAP canvas, which is a board. “" + m.name + "” is a mind map — pick the person's work map from list_maps (its name starts with “Work map —” or “Cartographie —”).");
|
|
788
910
|
const ops = buildProposalOps(m.doc, args.changes);
|
|
789
|
-
const file = writeProposal(m, args.note, ops);
|
|
911
|
+
const file = await writeProposal(m, args.note, ops);
|
|
912
|
+
if(allPersona){
|
|
913
|
+
return `📮 Interview filed for “${m.name}” — ${ops[0].procs} processes: ${file}\n`
|
|
914
|
+
+ "NOTHING has been changed yet. The canvas file is untouched.\n"
|
|
915
|
+
+ (CLOUD ? "TabTree shows a banner on that work map within a minute (app open, signed in): " : "TabTree shows a banner on that work map within a few seconds (backup folder connected, app open): ") + "“Your Claude interview is ready — re-cast this work map?”. "
|
|
916
|
+
+ "One click rebuilds the canvas AND its companion sheet from these hours; Cmd+Z undoes it.\n"
|
|
917
|
+
+ "Tell the user to look at TabTree — do not claim the map has been re-cast.";
|
|
918
|
+
}
|
|
790
919
|
const adds = ops.reduce((n,o)=>n + (o.adds||0), 0);
|
|
791
920
|
const drops = ops.reduce((n,o)=>n + (o.drops||0), 0);
|
|
792
921
|
return `📮 Proposal filed for “${m.name}” — ${ops.length} change(s)`
|
|
@@ -794,7 +923,7 @@ const HANDLERS = {
|
|
|
794
923
|
+ (drops ? `, ${drops} node(s) to remove` : "")
|
|
795
924
|
+ `: ${file}\n`
|
|
796
925
|
+ "NOTHING has been changed yet. The map file is untouched.\n"
|
|
797
|
-
+ "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). "
|
|
926
|
+
+ (CLOUD ? "TabTree shows a banner on that map within a minute (the app open and signed in). " : "TabTree shows a banner on that map within a few seconds (the backup folder must be connected, and the app open). ")
|
|
798
927
|
+ "The user ticks the changes they want, applies them, and Cmd+Z undoes the lot.\n"
|
|
799
928
|
+ "Tell the user to look at TabTree — do not claim the map has been updated.";
|
|
800
929
|
}
|
|
@@ -818,7 +947,7 @@ function handle(msg){
|
|
|
818
947
|
// déjà dérivé (1.0.0 ici, 1.1.0 dans le manifeste) sans que rien ne le
|
|
819
948
|
// signale : un client affiche l'une, le registre publie l'autre. Un
|
|
820
949
|
// autotest les compare désormais toutes les quatre.
|
|
821
|
-
serverInfo: { name: "tabtree", version: "1.1
|
|
950
|
+
serverInfo: { name: "tabtree", version: "1.2.1" }
|
|
822
951
|
});
|
|
823
952
|
} else if(method === "notifications/initialized" || (method||"").startsWith("notifications/")){
|
|
824
953
|
// notification : pas de réponse
|
|
@@ -830,12 +959,13 @@ function handle(msg){
|
|
|
830
959
|
const name = params && params.name;
|
|
831
960
|
const fn = HANDLERS[name];
|
|
832
961
|
if(!fn){ replyErr(id, -32602, "Outil inconnu : " + name); return; }
|
|
833
|
-
|
|
834
|
-
|
|
962
|
+
// Les gestionnaires sont ASYNCHRONES depuis la source cloud (2026-09-08) : un `fn` qui
|
|
963
|
+
// lève ou qui rejette rend le même message d'erreur — Claude n'a pas à distinguer les deux.
|
|
964
|
+
Promise.resolve().then(()=>fn((params && params.arguments) || {})).then(text=>{
|
|
835
965
|
reply(id, { content: [{ type:"text", text }] });
|
|
836
|
-
}
|
|
966
|
+
}, e=>{
|
|
837
967
|
reply(id, { content: [{ type:"text", text: "Erreur : " + (e && e.message || e) }], isError: true });
|
|
838
|
-
}
|
|
968
|
+
});
|
|
839
969
|
} else if(id !== undefined){
|
|
840
970
|
replyErr(id, -32601, "Méthode non supportée : " + method);
|
|
841
971
|
}
|