klypix-mcp 1.0.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/FORMAT.md +105 -0
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/klypix-append.mjs +44 -0
- package/bin/klypix-mcp.mjs +195 -0
- package/bin/klypix-read.mjs +50 -0
- package/bin/klypix-write.mjs +45 -0
- package/index.mjs +5 -0
- package/package.json +51 -0
- package/src/klypix-format.mjs +471 -0
package/FORMAT.md
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# The `.klypix` format (v4)
|
|
2
|
+
|
|
3
|
+
`.klypix` is an **open, local-first, agent-neutral** canvas file. It's a plain
|
|
4
|
+
**ZIP** of JSON + assets — no proprietary binary, fully inspectable (`unzip
|
|
5
|
+
your.klypix`), and parseable with the MIT library in this package. You own it;
|
|
6
|
+
any agent or app can read and write it.
|
|
7
|
+
|
|
8
|
+
## Container layout
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
your.klypix (a ZIP archive)
|
|
12
|
+
├── manifest.json metadata + stats
|
|
13
|
+
├── canvas.json spatial layout: order, positions, connections, lines, strokes, settings
|
|
14
|
+
├── items/
|
|
15
|
+
│ └── <2-hex>/<id>.json one file per item (content only; position lives in canvas.json)
|
|
16
|
+
└── assets/
|
|
17
|
+
└── <assetId> embedded binaries (images, PDFs, audio, video, files)
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Item files are **sharded** by the first 2 hex chars of the id's random part
|
|
21
|
+
(e.g. `items/a3/txt_a3f9…json`) so a canvas with thousands of items stays fast
|
|
22
|
+
to read partially.
|
|
23
|
+
|
|
24
|
+
## `manifest.json`
|
|
25
|
+
|
|
26
|
+
```json
|
|
27
|
+
{
|
|
28
|
+
"format": "klypix",
|
|
29
|
+
"version": 4,
|
|
30
|
+
"schemaVersion": 4,
|
|
31
|
+
"createdAt": "ISO-8601",
|
|
32
|
+
"updatedAt": "ISO-8601",
|
|
33
|
+
"title": "My board",
|
|
34
|
+
"stats": { "itemCount": 12, "assetCount": 3, "totalBytes": 0 }
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## `canvas.json`
|
|
39
|
+
|
|
40
|
+
```json
|
|
41
|
+
{
|
|
42
|
+
"version": 4,
|
|
43
|
+
"view": { "panX": 0, "panY": 0, "zoom": 0.8 },
|
|
44
|
+
"order": ["<id>", "..."], // z-order (render order)
|
|
45
|
+
"positions": { // per-item geometry, by id
|
|
46
|
+
"<id>": { "x": 0, "y": 0, "w": 280, "h": 80, "zKey": "a001", "zIndex": 1, "parentId": null }
|
|
47
|
+
},
|
|
48
|
+
"connections": [ // arrows between items
|
|
49
|
+
{ "id": "con_…", "fromId": "<id>", "toId": "<id>", "relationship": "leads_to", "arrowHead": true }
|
|
50
|
+
],
|
|
51
|
+
"lines": [], "strokes": [], // freehand drawing
|
|
52
|
+
"settings": { "background": "#0a0a0f" }
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Position/geometry is kept in `canvas.json.positions`, **not** in the item file —
|
|
57
|
+
so moving an item never rewrites its content, and the spatial layout can be read
|
|
58
|
+
without loading every item.
|
|
59
|
+
|
|
60
|
+
## Item files — `items/<shard>/<id>.json`
|
|
61
|
+
|
|
62
|
+
Content only (no x/y/w/h). Common types: `text`, `box`, `image`, `file`,
|
|
63
|
+
`code`, `video`, `audio`, `link`, `canvasLink`, `container`. Example text item:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"type": "text",
|
|
68
|
+
"content": "Decision: ship the open format first",
|
|
69
|
+
"fontSize": 15,
|
|
70
|
+
"color": "#e8e8ed",
|
|
71
|
+
"border": true,
|
|
72
|
+
"heading": false,
|
|
73
|
+
"createdBy": "agent"
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Items can reference an embedded binary in `assets/` (e.g. an `image`/`file`/
|
|
78
|
+
`video`/`audio` item points at its asset id).
|
|
79
|
+
|
|
80
|
+
## Connections, links, tags
|
|
81
|
+
|
|
82
|
+
- **Arrows:** `canvas.json.connections` (`fromId`/`toId`, optional
|
|
83
|
+
`relationship` ∈ `leads_to | depends_on | relates_to | conflicts_with |
|
|
84
|
+
supports | questions | costs | blocks`).
|
|
85
|
+
- **`[[wikilinks]]`** inside text content cross-link cards (and auto-draw edges).
|
|
86
|
+
- **`#tags`** inside text content group cards.
|
|
87
|
+
|
|
88
|
+
## Legacy `.any` (v1–v3)
|
|
89
|
+
|
|
90
|
+
Older files keep an inline `items` array at the root of `canvas.json` instead of
|
|
91
|
+
the `items/` folder + `positions` map. The parser in this package handles both.
|
|
92
|
+
|
|
93
|
+
## Read / write it
|
|
94
|
+
|
|
95
|
+
```js
|
|
96
|
+
import { parseKlypix, buildKlypix, appendToKlypix, structToMarkdown } from 'klypix-mcp';
|
|
97
|
+
|
|
98
|
+
const { struct } = await parseKlypix(fs.readFileSync('board.klypix'));
|
|
99
|
+
console.log(structToMarkdown(struct)); // cards + graph + links + tags
|
|
100
|
+
|
|
101
|
+
const buf = await buildKlypix({ title: 'Plan', cards: [{ text: 'kickoff' }] });
|
|
102
|
+
fs.writeFileSync('plan.klypix', buf);
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
That's the whole contract: **a ZIP you own, that any model can read and write.**
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Abdullah Aldahshan — Dahshan Labs
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# klypix-mcp
|
|
2
|
+
|
|
3
|
+
**An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.**
|
|
4
|
+
|
|
5
|
+
Your AI forgets everything between sessions, and you can't hand it your *whole*
|
|
6
|
+
messy project at once. `klypix-mcp` fixes that with a single portable file:
|
|
7
|
+
|
|
8
|
+
- **`.klypix`** is one file that holds your whole project spatially — text, PDFs,
|
|
9
|
+
screenshots, audio, code, links — arranged and connected on an infinite canvas.
|
|
10
|
+
- This package is an **MCP server**: any MCP-capable agent (Claude Desktop,
|
|
11
|
+
Cursor, Cline, …) can **list, read, search, create, and append** to your
|
|
12
|
+
`.klypix` canvases — so your agent gets durable, multimodal, *spatial* memory.
|
|
13
|
+
- **Local-first + agent-neutral.** The file lives on *your* disk. No lab is in
|
|
14
|
+
the loop — read it with Claude today, GPT tomorrow, a local model next week.
|
|
15
|
+
No vendor can take it away.
|
|
16
|
+
|
|
17
|
+
## Quick start (60 seconds)
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
# point it at any folder of .klypix files (a "vault")
|
|
21
|
+
npx klypix-mcp --vault ./canvases
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then add it to your MCP client. For **Claude Desktop**, in
|
|
25
|
+
`claude_desktop_config.json`:
|
|
26
|
+
|
|
27
|
+
```json
|
|
28
|
+
{
|
|
29
|
+
"mcpServers": {
|
|
30
|
+
"klypix": {
|
|
31
|
+
"command": "npx",
|
|
32
|
+
"args": ["-y", "klypix-mcp", "--vault", "/absolute/path/to/canvases"]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Now ask your agent things like *"summarize the canvas `roadmap`,"* *"turn these
|
|
39
|
+
notes into a board,"* or *"add a card with the decision we just made."*
|
|
40
|
+
|
|
41
|
+
## Tools the server exposes
|
|
42
|
+
|
|
43
|
+
| Tool | What it does |
|
|
44
|
+
|---|---|
|
|
45
|
+
| `list_canvases` | List every `.klypix` in the vault |
|
|
46
|
+
| `read_canvas` | Read a canvas as markdown (cards, the connection graph, `[[links]]`, `#tags`) |
|
|
47
|
+
| `search_canvases` | Search across canvases by name + content |
|
|
48
|
+
| `create_canvas` | Create a new `.klypix` from cards + connections |
|
|
49
|
+
| `add_to_canvas` | Append cards/connections to an existing canvas (positions preserved) |
|
|
50
|
+
|
|
51
|
+
## Use it as a library
|
|
52
|
+
|
|
53
|
+
```js
|
|
54
|
+
import { parseKlypix, buildKlypix, appendToKlypix, structToMarkdown } from 'klypix-mcp';
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
…or read/write from the shell:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
npx -p klypix-mcp klypix-read path/to/board.klypix # → markdown brief
|
|
61
|
+
echo '{ "title": "Plan", "cards": [{ "text": "kickoff" }] }' \
|
|
62
|
+
| npx -p klypix-mcp klypix-write --out plan.klypix
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## The `.klypix` format
|
|
66
|
+
|
|
67
|
+
A `.klypix` is just a **ZIP** of JSON + assets — open, inspectable, versioned.
|
|
68
|
+
Full spec in [FORMAT.md](FORMAT.md). The point: *you own the file, and any agent
|
|
69
|
+
can drive it.*
|
|
70
|
+
|
|
71
|
+
## Why this exists
|
|
72
|
+
|
|
73
|
+
Frontier labs are racing to put your context inside *their* canvas — a roach
|
|
74
|
+
motel your work checks into and never leaves for a competitor. `klypix-mcp` is
|
|
75
|
+
the opposite: **your project, your file, any model, offline.** That's the one
|
|
76
|
+
thing a lab is structurally disincentivized to build.
|
|
77
|
+
|
|
78
|
+
MIT licensed. Built by [Dahshan Labs](https://klypix.com). The KLYPIX desktop
|
|
79
|
+
app is the spatial editor for these files — but the file and this server are
|
|
80
|
+
fully open and work without it.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// append-klypix — add cards (+ optional connections) to an EXISTING .klypix,
|
|
3
|
+
// preserving every existing item and its position. The CLI twin of the MCP
|
|
4
|
+
// server's add_to_canvas; both use appendToKlypix() in ./klypix-format.mjs.
|
|
5
|
+
//
|
|
6
|
+
// This is what lets a canvas be a *living* brain: read it, then append new
|
|
7
|
+
// decisions/findings as connected cards without rebuilding or moving anything.
|
|
8
|
+
//
|
|
9
|
+
// Usage:
|
|
10
|
+
// node scripts/append-klypix.mjs <file.klypix> <addition.json>
|
|
11
|
+
// echo '<addition>' | node scripts/append-klypix.mjs <file.klypix>
|
|
12
|
+
//
|
|
13
|
+
// addition:
|
|
14
|
+
// { "cards": [{ "text": "...", "heading"?, "color"? }],
|
|
15
|
+
// "connections": [{ "from": <idx|title>, "to": <idx|title>, "relationship"? }] }
|
|
16
|
+
// from/to may reference a NEW card (by index in this addition, or its title)
|
|
17
|
+
// or an EXISTING card already on the canvas (by its title). New cards land in
|
|
18
|
+
// a column just to the right of the current content, stacked on top.
|
|
19
|
+
|
|
20
|
+
import fs from 'fs';
|
|
21
|
+
import { appendToKlypix, atomicWrite } from '../src/klypix-format.mjs';
|
|
22
|
+
|
|
23
|
+
const args = process.argv.slice(2);
|
|
24
|
+
const file = args.find(a => !a.startsWith('--'));
|
|
25
|
+
const additionPath = args.filter(a => !a.startsWith('--'))[1];
|
|
26
|
+
if (!file) { console.error('Usage: node append-klypix.mjs <file.klypix> <addition.json>'); process.exit(2); }
|
|
27
|
+
if (!fs.existsSync(file)) { console.error(`File not found: ${file}`); process.exit(2); }
|
|
28
|
+
|
|
29
|
+
let addition;
|
|
30
|
+
try {
|
|
31
|
+
const raw = additionPath ? fs.readFileSync(additionPath, 'utf8') : fs.readFileSync(0, 'utf8');
|
|
32
|
+
addition = JSON.parse(raw);
|
|
33
|
+
} catch (e) { console.error('Addition is not valid JSON:', e.message); process.exit(2); }
|
|
34
|
+
|
|
35
|
+
let buf;
|
|
36
|
+
try {
|
|
37
|
+
buf = await appendToKlypix(fs.readFileSync(file), addition);
|
|
38
|
+
} catch (e) { console.error(e.message); process.exit(1); }
|
|
39
|
+
|
|
40
|
+
await atomicWrite(file, buf);
|
|
41
|
+
const cardCount = Array.isArray(addition.cards) ? addition.cards.length : 0;
|
|
42
|
+
const connCount = Array.isArray(addition.connections) ? addition.connections.length : 0;
|
|
43
|
+
console.log(`Appended ${cardCount} card(s), ${connCount} connection(s) to ${file}.`);
|
|
44
|
+
console.log(`Reopen it in KLYPIX, or verify: node scripts/read-klypix.mjs "${file}"`);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// klypix-mcp-server — a Model Context Protocol server that gives any MCP client
|
|
3
|
+
// (Claude Desktop, Claude Code, "cowork", …) full READ + WRITE access to your
|
|
4
|
+
// .klypix canvas library. It turns the one-off read-klypix / write-klypix
|
|
5
|
+
// skills into a standing, tool-based connection: an outside agent can list your
|
|
6
|
+
// canvases, read one (cards + connection graph + [[links]] + #tags), search
|
|
7
|
+
// across all of them, create a new board, or add cards to an existing one.
|
|
8
|
+
//
|
|
9
|
+
// It operates on the .klypix FILES in a "vault" folder — no need for the KLYPIX
|
|
10
|
+
// desktop app to be running, and nothing here can corrupt a live canvas.
|
|
11
|
+
//
|
|
12
|
+
// Run (stdio): node scripts/klypix-mcp-server.mjs --vault "C:\\path\\to\\canvases"
|
|
13
|
+
// or set env: KLYPIX_VAULT=... (default: ~/Documents)
|
|
14
|
+
//
|
|
15
|
+
// Register in Claude Code (.mcp.json) or Claude Desktop (claude_desktop_config
|
|
16
|
+
// .json) — see docs/KLYPIX_MCP.md.
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import os from 'os';
|
|
20
|
+
import path from 'path';
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
23
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
24
|
+
import { parseKlypix, buildKlypix, appendToKlypix, structToMarkdown, atomicWrite } from '../src/klypix-format.mjs';
|
|
25
|
+
|
|
26
|
+
// IMPORTANT: stdout is the JSON-RPC channel. Never console.log — only stderr.
|
|
27
|
+
const log = (...a) => console.error('[klypix-mcp]', ...a);
|
|
28
|
+
|
|
29
|
+
const vaultArgIdx = process.argv.indexOf('--vault');
|
|
30
|
+
const VAULT = path.resolve(
|
|
31
|
+
vaultArgIdx >= 0 ? process.argv[vaultArgIdx + 1]
|
|
32
|
+
: process.env.KLYPIX_VAULT || path.join(os.homedir(), 'Documents'),
|
|
33
|
+
);
|
|
34
|
+
const IS_CANVAS = /\.(klypix|any)$/i;
|
|
35
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.cache', 'AppData', '$Recycle.Bin', 'Windows']);
|
|
36
|
+
const MAX_FILES = 400;
|
|
37
|
+
|
|
38
|
+
function walkVault() {
|
|
39
|
+
const out = [];
|
|
40
|
+
const visit = (dir, depth) => {
|
|
41
|
+
if (out.length >= MAX_FILES || depth > 6) return;
|
|
42
|
+
let entries;
|
|
43
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
44
|
+
for (const e of entries) {
|
|
45
|
+
if (out.length >= MAX_FILES) return;
|
|
46
|
+
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue;
|
|
47
|
+
const full = path.join(dir, e.name);
|
|
48
|
+
if (e.isDirectory()) visit(full, depth + 1);
|
|
49
|
+
else if (e.isFile() && IS_CANVAS.test(e.name)) out.push(full);
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
visit(VAULT, 0);
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Resolve a user-supplied canvas reference: absolute path, vault-relative path,
|
|
57
|
+
// or a bare filename (matched against the walked list, case-insensitively).
|
|
58
|
+
function resolveCanvas(ref) {
|
|
59
|
+
if (!ref) return null;
|
|
60
|
+
if (path.isAbsolute(ref) && fs.existsSync(ref)) return ref;
|
|
61
|
+
const rel = path.join(VAULT, ref);
|
|
62
|
+
if (fs.existsSync(rel)) return rel;
|
|
63
|
+
const want = path.basename(ref).toLowerCase();
|
|
64
|
+
const matches = walkVault().filter(f => path.basename(f).toLowerCase() === want
|
|
65
|
+
|| path.basename(f).toLowerCase() === want + '.klypix'
|
|
66
|
+
|| path.basename(f).toLowerCase() === want + '.any');
|
|
67
|
+
return matches[0] || null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function safeName(title) {
|
|
71
|
+
const base = String(title || 'untitled').replace(/[^\w\- ]+/g, '').trim() || 'untitled';
|
|
72
|
+
let name = base, n = 1;
|
|
73
|
+
while (fs.existsSync(path.join(VAULT, `${name}.klypix`))) name = `${base} ${++n}`;
|
|
74
|
+
return `${name}.klypix`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const cardSchema = z.object({
|
|
78
|
+
text: z.string().describe('Card text. First line is the card title.'),
|
|
79
|
+
heading: z.boolean().optional().describe('Bold title card for the main goal/topic.'),
|
|
80
|
+
color: z.string().optional().describe('Hex color, e.g. #ef4444 for a risk/blocker.'),
|
|
81
|
+
});
|
|
82
|
+
const connSchema = z.object({
|
|
83
|
+
from: z.union([z.number(), z.string()]).describe('Source card: index (0-based), title, or id.'),
|
|
84
|
+
to: z.union([z.number(), z.string()]).describe('Target card: index, title, or id.'),
|
|
85
|
+
relationship: z.string().optional().describe('leads_to|depends_on|relates_to|conflicts_with|supports|questions|costs|blocks'),
|
|
86
|
+
label: z.string().optional(),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const server = new McpServer({ name: 'klypix-canvas', version: '1.0.0' });
|
|
90
|
+
|
|
91
|
+
server.registerTool('list_canvases', {
|
|
92
|
+
title: 'List KLYPIX canvases',
|
|
93
|
+
description: 'List all .klypix / .any canvas files in the vault, with card and connection counts.',
|
|
94
|
+
inputSchema: {},
|
|
95
|
+
}, async () => {
|
|
96
|
+
const files = walkVault();
|
|
97
|
+
if (files.length === 0) {
|
|
98
|
+
return { content: [{ type: 'text', text: `No .klypix/.any files found under vault: ${VAULT}\nSet --vault or KLYPIX_VAULT to your canvas folder.` }] };
|
|
99
|
+
}
|
|
100
|
+
const rows = [];
|
|
101
|
+
for (const f of files) {
|
|
102
|
+
try {
|
|
103
|
+
const { struct } = await parseKlypix(fs.readFileSync(f));
|
|
104
|
+
const st = fs.statSync(f);
|
|
105
|
+
rows.push(`- ${path.relative(VAULT, f)} — "${struct.title}" · ${struct.counts.cards} cards, ${struct.counts.connections} connections · ${new Date(st.mtimeMs).toISOString().slice(0, 10)}`);
|
|
106
|
+
} catch {
|
|
107
|
+
rows.push(`- ${path.relative(VAULT, f)} — (unreadable)`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return { content: [{ type: 'text', text: `# Canvases in ${VAULT}\n\n${rows.join('\n')}` }] };
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
server.registerTool('read_canvas', {
|
|
114
|
+
title: 'Read a KLYPIX canvas',
|
|
115
|
+
description: 'Read a canvas as structured markdown: every card, the connection graph, [[wikilinks]], #tags, and an asset list. Accepts a filename, vault-relative path, or absolute path.',
|
|
116
|
+
inputSchema: { canvas: z.string().describe('Canvas filename, vault-relative path, or absolute path.') },
|
|
117
|
+
}, async ({ canvas }) => {
|
|
118
|
+
const file = resolveCanvas(canvas);
|
|
119
|
+
if (!file) return { content: [{ type: 'text', text: `Canvas not found: ${canvas} (vault: ${VAULT})` }], isError: true };
|
|
120
|
+
try {
|
|
121
|
+
const { struct } = await parseKlypix(fs.readFileSync(file));
|
|
122
|
+
return { content: [{ type: 'text', text: structToMarkdown(struct) }] };
|
|
123
|
+
} catch (e) {
|
|
124
|
+
return { content: [{ type: 'text', text: `Failed to read ${file}: ${e.message}` }], isError: true };
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
server.registerTool('search_canvases', {
|
|
129
|
+
title: 'Search inside all canvases',
|
|
130
|
+
description: 'Search card text, titles, and #tags across every canvas in the vault. Returns the canvases and the matching cards.',
|
|
131
|
+
inputSchema: { query: z.string().describe('Text or #tag to find inside canvases.') },
|
|
132
|
+
}, async ({ query }) => {
|
|
133
|
+
const q = String(query || '').trim().toLowerCase();
|
|
134
|
+
if (!q) return { content: [{ type: 'text', text: 'Provide a non-empty query.' }], isError: true };
|
|
135
|
+
const hits = [];
|
|
136
|
+
for (const f of walkVault()) {
|
|
137
|
+
let struct;
|
|
138
|
+
try { ({ struct } = await parseKlypix(fs.readFileSync(f))); } catch { continue; }
|
|
139
|
+
const matched = struct.cards.filter(c =>
|
|
140
|
+
(c.title || '').toLowerCase().includes(q) ||
|
|
141
|
+
String(c.text || '').toLowerCase().includes(q) ||
|
|
142
|
+
(c.tags || []).some(t => ('#' + t).toLowerCase().includes(q)));
|
|
143
|
+
if (matched.length) {
|
|
144
|
+
hits.push(`## ${path.relative(VAULT, f)} — "${struct.title}"\n` +
|
|
145
|
+
matched.slice(0, 6).map(c => `- ${c.title || '(card)'}: ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 120)}`).join('\n'));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return { content: [{ type: 'text', text: hits.length ? `# Matches for "${query}"\n\n${hits.join('\n\n')}` : `No matches for "${query}" in ${VAULT}.` }] };
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
server.registerTool('create_canvas', {
|
|
152
|
+
title: 'Create a KLYPIX canvas',
|
|
153
|
+
description: 'Create a new .klypix canvas from cards + connections and save it to the vault. The user opens it in KLYPIX (Canvas → Open). Prefer short, titled cards (one idea each) connected by meaningful arrows.',
|
|
154
|
+
inputSchema: {
|
|
155
|
+
title: z.string().describe('Canvas title (also the filename).'),
|
|
156
|
+
cards: z.array(cardSchema).min(1).describe('The cards. 5-12 atomic cards is ideal.'),
|
|
157
|
+
connections: z.array(connSchema).optional().describe('Arrows between cards.'),
|
|
158
|
+
filename: z.string().optional().describe('Override the output filename (without extension).'),
|
|
159
|
+
},
|
|
160
|
+
}, async ({ title, cards, connections, filename }) => {
|
|
161
|
+
if (!fs.existsSync(VAULT)) { try { fs.mkdirSync(VAULT, { recursive: true }); } catch { /* ignore */ } }
|
|
162
|
+
try {
|
|
163
|
+
const buf = await buildKlypix({ title, cards, connections });
|
|
164
|
+
const name = filename ? safeName(filename.replace(IS_CANVAS, '')) : safeName(title);
|
|
165
|
+
const out = path.join(VAULT, name);
|
|
166
|
+
await atomicWrite(out, buf);
|
|
167
|
+
return { content: [{ type: 'text', text: `Created ${out} — ${cards.length} cards, ${(connections || []).length} connections. Open it in KLYPIX (Canvas → Open).` }] };
|
|
168
|
+
} catch (e) {
|
|
169
|
+
return { content: [{ type: 'text', text: `Create failed: ${e.message}` }], isError: true };
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
server.registerTool('add_to_canvas', {
|
|
174
|
+
title: 'Add cards to an existing canvas',
|
|
175
|
+
description: 'Append cards (and optional connections) to an existing v4 .klypix, preserving all existing items and their positions. New cards are placed to the right of the current content. Connections may reference new cards (by index/title) or existing cards (by title).',
|
|
176
|
+
inputSchema: {
|
|
177
|
+
canvas: z.string().describe('Canvas filename, vault-relative path, or absolute path.'),
|
|
178
|
+
cards: z.array(cardSchema).min(1).describe('Cards to add.'),
|
|
179
|
+
connections: z.array(connSchema).optional(),
|
|
180
|
+
},
|
|
181
|
+
}, async ({ canvas, cards, connections }) => {
|
|
182
|
+
const file = resolveCanvas(canvas);
|
|
183
|
+
if (!file) return { content: [{ type: 'text', text: `Canvas not found: ${canvas}` }], isError: true };
|
|
184
|
+
try {
|
|
185
|
+
const buf = await appendToKlypix(fs.readFileSync(file), { cards, connections });
|
|
186
|
+
await atomicWrite(file, buf);
|
|
187
|
+
return { content: [{ type: 'text', text: `Added ${cards.length} card(s) to ${path.relative(VAULT, file)}. Reopen the canvas in KLYPIX to see them.` }] };
|
|
188
|
+
} catch (e) {
|
|
189
|
+
return { content: [{ type: 'text', text: `Add failed: ${e.message}` }], isError: true };
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const transport = new StdioServerTransport();
|
|
194
|
+
await server.connect(transport);
|
|
195
|
+
log(`ready · vault=${VAULT}`);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// read-klypix — turn a .klypix (or legacy .any) file into structured markdown
|
|
3
|
+
// an AI agent can fully understand: every card's text, the connection graph,
|
|
4
|
+
// [[wikilinks]] + #tags, and a manifest of images/files (with extracted paths
|
|
5
|
+
// so the agent can read images with vision).
|
|
6
|
+
//
|
|
7
|
+
// This is the reference CLI behind the `read-klypix` Claude Code skill. The
|
|
8
|
+
// actual parsing lives in ./klypix-format.mjs (shared with write-klypix and the
|
|
9
|
+
// MCP server) so the format logic has exactly one home.
|
|
10
|
+
//
|
|
11
|
+
// Usage:
|
|
12
|
+
// node scripts/read-klypix.mjs <file.klypix> [--assets <outDir>] [--json]
|
|
13
|
+
// --assets <dir> extract binary assets (images/files) into <dir>
|
|
14
|
+
// --json emit a structured JSON object instead of markdown
|
|
15
|
+
|
|
16
|
+
import fs from 'fs';
|
|
17
|
+
import path from 'path';
|
|
18
|
+
import { parseKlypix, structToMarkdown } from '../src/klypix-format.mjs';
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const file = args.find(a => !a.startsWith('--'));
|
|
22
|
+
const assetsDir = (() => { const i = args.indexOf('--assets'); return i >= 0 ? args[i + 1] : null; })();
|
|
23
|
+
const asJson = args.includes('--json');
|
|
24
|
+
if (!file) { console.error('Usage: node read-klypix.mjs <file.klypix> [--assets <dir>] [--json]'); process.exit(2); }
|
|
25
|
+
if (!fs.existsSync(file)) { console.error(`File not found: ${file}`); process.exit(2); }
|
|
26
|
+
|
|
27
|
+
let parsed;
|
|
28
|
+
try {
|
|
29
|
+
parsed = await parseKlypix(fs.readFileSync(file));
|
|
30
|
+
} catch (e) {
|
|
31
|
+
console.error(e.message);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
const { struct, zip, assetPaths } = parsed;
|
|
35
|
+
// Fall back to the filename for the title when the file didn't store one.
|
|
36
|
+
if (!struct.title || struct.title === 'Untitled') {
|
|
37
|
+
struct.title = path.basename(file).replace(/\.(klypix|any)$/i, '');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Optionally extract binary assets so the agent can open images with vision.
|
|
41
|
+
if (assetsDir && assetPaths.length) {
|
|
42
|
+
fs.mkdirSync(assetsDir, { recursive: true });
|
|
43
|
+
for (const p of assetPaths) {
|
|
44
|
+
const bytes = await zip.file(p).async('nodebuffer');
|
|
45
|
+
fs.writeFileSync(path.join(assetsDir, path.basename(p)), bytes);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (asJson) { console.log(JSON.stringify(struct, null, 2)); process.exit(0); }
|
|
50
|
+
console.log(structToMarkdown(struct, { assetsDir }));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// write-klypix — assemble a real .klypix canvas from a simple JSON spec.
|
|
3
|
+
// The reverse of read-klypix. The build logic (content-aware sizing, BFS
|
|
4
|
+
// layout, v4 ZIP output) lives in ./klypix-format.mjs (shared with the MCP
|
|
5
|
+
// server) so it has exactly one home.
|
|
6
|
+
//
|
|
7
|
+
// Usage:
|
|
8
|
+
// node scripts/write-klypix.mjs <spec.json> [--out <file.klypix>]
|
|
9
|
+
// cat spec.json | node scripts/write-klypix.mjs --out board.klypix
|
|
10
|
+
//
|
|
11
|
+
// Spec:
|
|
12
|
+
// { "title": "...", "cards": [{ "text": "...", "heading"?, "color"? }],
|
|
13
|
+
// "connections": [{ "from": 0, "to": 1, "relationship"? }] }
|
|
14
|
+
// from/to reference a card by INDEX, generated id, or its title (first line).
|
|
15
|
+
// relationship ∈ leads_to | depends_on | relates_to | conflicts_with |
|
|
16
|
+
// supports | questions | costs | blocks.
|
|
17
|
+
|
|
18
|
+
import fs from 'fs';
|
|
19
|
+
import { buildKlypix, atomicWrite } from '../src/klypix-format.mjs';
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const outIdx = args.indexOf('--out');
|
|
23
|
+
const outArg = outIdx >= 0 ? args[outIdx + 1] : null;
|
|
24
|
+
// The spec path is the first POSITIONAL arg that isn't a flag AND isn't the
|
|
25
|
+
// value consumed by --out (else `--out x.klypix` with stdin spec mis-reads x as
|
|
26
|
+
// the spec). null → read the spec from stdin.
|
|
27
|
+
const specPath = args.find((a, i) => !a.startsWith('--') && i !== outIdx + 1) || null;
|
|
28
|
+
|
|
29
|
+
let spec;
|
|
30
|
+
try {
|
|
31
|
+
const raw = specPath ? fs.readFileSync(specPath, 'utf8') : fs.readFileSync(0, 'utf8');
|
|
32
|
+
spec = JSON.parse(raw);
|
|
33
|
+
} catch (e) { console.error('Spec is not valid JSON:', e.message); process.exit(2); }
|
|
34
|
+
|
|
35
|
+
let buf;
|
|
36
|
+
try {
|
|
37
|
+
buf = await buildKlypix(spec);
|
|
38
|
+
} catch (e) { console.error(e.message); process.exit(2); }
|
|
39
|
+
|
|
40
|
+
const outPath = outArg || `${(spec.title || 'untitled').replace(/[^\w\- ]+/g, '').trim() || 'untitled'}.klypix`;
|
|
41
|
+
await atomicWrite(outPath, buf);
|
|
42
|
+
const cardCount = spec.cards.length;
|
|
43
|
+
const connCount = Array.isArray(spec.connections) ? spec.connections.length : 0;
|
|
44
|
+
console.log(`Wrote ${outPath} — ${cardCount} cards, ${connCount} connections.`);
|
|
45
|
+
console.log(`Open it in KLYPIX (Canvas → Open), or verify: node scripts/read-klypix.mjs "${outPath}"`);
|
package/index.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// klypix-mcp — programmatic entry. Re-exports the pure .klypix format library
|
|
2
|
+
// (parse / build / append / markdown / atomic write) so you can read and write
|
|
3
|
+
// the agent-neutral canvas file from any Node code, with no MCP/agent in the
|
|
4
|
+
// loop. The MCP server itself is the `klypix-mcp` bin.
|
|
5
|
+
export * from './src/klypix-format.mjs';
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "klypix-mcp",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "An open, local-first, agent-neutral canvas file your AI reads and writes over MCP — works with Claude, Cursor, Cline, any model.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"canvas",
|
|
11
|
+
"agent",
|
|
12
|
+
"agent-memory",
|
|
13
|
+
"local-first",
|
|
14
|
+
"klypix",
|
|
15
|
+
"whiteboard",
|
|
16
|
+
"spatial",
|
|
17
|
+
"ai"
|
|
18
|
+
],
|
|
19
|
+
"homepage": "https://klypix.com",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/dahshanlabs/klypix-mcp"
|
|
23
|
+
},
|
|
24
|
+
"bin": {
|
|
25
|
+
"klypix-mcp": "bin/klypix-mcp.mjs",
|
|
26
|
+
"klypix-read": "bin/klypix-read.mjs",
|
|
27
|
+
"klypix-write": "bin/klypix-write.mjs",
|
|
28
|
+
"klypix-append": "bin/klypix-append.mjs"
|
|
29
|
+
},
|
|
30
|
+
"main": "index.mjs",
|
|
31
|
+
"exports": {
|
|
32
|
+
".": "./index.mjs",
|
|
33
|
+
"./format": "./src/klypix-format.mjs"
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"src",
|
|
37
|
+
"bin",
|
|
38
|
+
"index.mjs",
|
|
39
|
+
"FORMAT.md",
|
|
40
|
+
"README.md",
|
|
41
|
+
"LICENSE"
|
|
42
|
+
],
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=18"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
48
|
+
"jszip": "^3.10.1",
|
|
49
|
+
"zod": "^4.3.6"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
// klypix-format — the single source of truth for reading & writing .klypix
|
|
2
|
+
// (and legacy .any) canvas files. Shared by read-klypix.mjs, write-klypix.mjs,
|
|
3
|
+
// and klypix-mcp-server.mjs so the format logic lives in exactly one place.
|
|
4
|
+
//
|
|
5
|
+
// .klypix v4 ZIP layout: manifest.json · canvas.json · items/<prefix>/<id>.json
|
|
6
|
+
// · assets/<assetId>. Legacy .any (v1–v3) keeps an inline items array in
|
|
7
|
+
// canvas.json at the root — handled by parseKlypix too.
|
|
8
|
+
|
|
9
|
+
import JSZip from 'jszip';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
|
|
13
|
+
export const WIKILINK = /\[\[([^[\]]+)\]\]/g;
|
|
14
|
+
export const TAG = /(^|\s)(#[a-zA-Z][\w-]*)/g;
|
|
15
|
+
|
|
16
|
+
export function extractLinks(text) {
|
|
17
|
+
const out = []; WIKILINK.lastIndex = 0; let m;
|
|
18
|
+
while ((m = WIKILINK.exec(text || '')) !== null) out.push(m[1].trim());
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
export function extractTags(text) {
|
|
22
|
+
const out = []; TAG.lastIndex = 0; let m;
|
|
23
|
+
while ((m = TAG.exec(text || '')) !== null) out.push(m[2].slice(1));
|
|
24
|
+
return out;
|
|
25
|
+
}
|
|
26
|
+
export function cardTitle(item) {
|
|
27
|
+
if (item?.type === 'container') return item.title || null;
|
|
28
|
+
if (item?.type !== 'text') return null;
|
|
29
|
+
for (const line of String(item.content ?? '').split('\n')) {
|
|
30
|
+
const t = line.trim();
|
|
31
|
+
if (t) return t.replace(/^([#>\-*•]+\s+|\d+\.\s+)/, '').trim() || t;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
// v4 shards item files by the first 2 hex chars of the id's random part.
|
|
36
|
+
export const shard = (id) => id.replace(/^[a-z]+[_:]/i, '').toLowerCase().slice(0, 2).padStart(2, '_');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Atomically persist a .klypix buffer: verify it round-trips, write a sibling
|
|
40
|
+
* .tmp, then rename over the target. A concurrent reader (e.g. the shared-brain
|
|
41
|
+
* watcher) therefore never parses a half-written ZIP, and a failed/garbage
|
|
42
|
+
* write leaves the previous good file intact. Use this for ALL brain writes
|
|
43
|
+
* instead of fs.writeFileSync.
|
|
44
|
+
*/
|
|
45
|
+
export async function atomicWrite(filePath, buf) {
|
|
46
|
+
try { await parseKlypix(buf); }
|
|
47
|
+
catch (e) { throw new Error('refusing to write an unparseable .klypix (' + path.basename(filePath) + '): ' + (e?.message || e)); }
|
|
48
|
+
const tmp = filePath + '.tmp-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
|
|
49
|
+
fs.writeFileSync(tmp, buf);
|
|
50
|
+
try { fs.renameSync(tmp, filePath); } // Node uses MoveFileEx(REPLACE_EXISTING) on Windows → overwrites atomically
|
|
51
|
+
catch (e) { try { fs.rmSync(tmp); } catch { /* */ } throw e; }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Parse a .klypix/.any buffer into a structured object + the loaded zip (so
|
|
56
|
+
* callers can extract binary assets). Throws on a non-canvas file.
|
|
57
|
+
*/
|
|
58
|
+
export async function parseKlypix(buffer) {
|
|
59
|
+
const zip = await JSZip.loadAsync(buffer);
|
|
60
|
+
const readText = async (p) => { const e = zip.file(p); return e ? e.async('string') : null; };
|
|
61
|
+
|
|
62
|
+
const manifestRaw = await readText('manifest.json');
|
|
63
|
+
const canvasRaw = await readText('canvas.json');
|
|
64
|
+
if (!canvasRaw) throw new Error('Not a valid .klypix/.any — no canvas.json inside.');
|
|
65
|
+
|
|
66
|
+
const manifest = manifestRaw ? JSON.parse(manifestRaw) : null;
|
|
67
|
+
const canvas = JSON.parse(canvasRaw);
|
|
68
|
+
// v4 manifests are {format:"klypix", version:4}; positions presence is the
|
|
69
|
+
// robust fallback (legacy .any keeps an inline items array, no positions).
|
|
70
|
+
const isV4 = (!!manifest && manifest.format === 'klypix' && manifest.version >= 4) || !!canvas.positions;
|
|
71
|
+
|
|
72
|
+
const order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
73
|
+
const items = {};
|
|
74
|
+
if (isV4 && canvas.positions) {
|
|
75
|
+
for (const id of order) {
|
|
76
|
+
const raw = await readText(`items/${shard(id)}/${id}.json`);
|
|
77
|
+
if (!raw) continue;
|
|
78
|
+
items[id] = { id, ...(canvas.positions[id] || {}), ...JSON.parse(raw) };
|
|
79
|
+
}
|
|
80
|
+
} else if (Array.isArray(canvas.items)) {
|
|
81
|
+
for (const it of canvas.items) items[it.id] = it;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
85
|
+
const titleOf = (id) => cardTitle(items[id]) || (items[id]?.type ? `${items[id].type} ${String(id).slice(0, 8)}` : String(id).slice(0, 8));
|
|
86
|
+
const assetPaths = Object.keys(zip.files).filter(p => p.startsWith('assets/') && !zip.files[p].dir);
|
|
87
|
+
const cards = order.length ? order.map(id => items[id]).filter(Boolean) : Object.values(items);
|
|
88
|
+
|
|
89
|
+
const struct = {
|
|
90
|
+
title: manifest?.title || canvas.title || 'Untitled',
|
|
91
|
+
format: isV4 ? 'klypix-v4' : `legacy-v${canvas.version ?? '?'}`,
|
|
92
|
+
counts: { cards: cards.length, connections: connections.length, assets: assetPaths.length },
|
|
93
|
+
cards: cards.map(it => ({
|
|
94
|
+
id: it.id, type: it.type,
|
|
95
|
+
title: cardTitle(it),
|
|
96
|
+
text: it.type === 'text' ? it.content : (it.name || it.title || it.url || null),
|
|
97
|
+
links: it.type === 'text' ? extractLinks(it.content) : [],
|
|
98
|
+
tags: it.type === 'text' ? extractTags(it.content) : [],
|
|
99
|
+
pos: { x: it.x, y: it.y },
|
|
100
|
+
})),
|
|
101
|
+
connections: connections.map(c => ({
|
|
102
|
+
from: titleOf(c.fromId), to: titleOf(c.toId),
|
|
103
|
+
relationship: c.relationship || null, label: c.label || null,
|
|
104
|
+
})),
|
|
105
|
+
assets: assetPaths.map(p => path.basename(p)),
|
|
106
|
+
};
|
|
107
|
+
return { struct, zip, assetPaths, isV4, canvas, manifest };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks']);
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build a real .klypix v4 file (nodebuffer) from a simple spec:
|
|
114
|
+
* { title, cards: [{id?, type?, text, heading?, color?, x?, y?, w?}], connections: [{from, to, relationship?, label?}] }
|
|
115
|
+
* from/to reference a card by INDEX, generated id, or its title (first line).
|
|
116
|
+
* Cards are content-sized and laid out on a BFS-ordered grid so linked cards
|
|
117
|
+
* land near each other.
|
|
118
|
+
*/
|
|
119
|
+
export async function buildKlypix(spec) {
|
|
120
|
+
if (!spec || !Array.isArray(spec.cards) || spec.cards.length === 0) {
|
|
121
|
+
throw new Error('spec needs a non-empty "cards" array');
|
|
122
|
+
}
|
|
123
|
+
const now = Date.now();
|
|
124
|
+
const nowIso = new Date(now).toISOString();
|
|
125
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
126
|
+
|
|
127
|
+
const cards = spec.cards.map((c, i) => {
|
|
128
|
+
const type = c.type || 'text';
|
|
129
|
+
const prefix = type === 'text' ? 'txt' : type === 'image' ? 'img' : type === 'container' ? 'ctn' : 'itm';
|
|
130
|
+
return { ...c, type, _id: c.id || `${prefix}_${rand()}_${i}` };
|
|
131
|
+
});
|
|
132
|
+
const idByIndex = cards.map(c => c._id);
|
|
133
|
+
const firstLine = (t) => String(t ?? '').split('\n').map(s => s.trim()).find(Boolean) || '';
|
|
134
|
+
const idByTitle = new Map(cards.map(c => [firstLine(c.text).toLowerCase(), c._id]));
|
|
135
|
+
const resolveRef = (ref) => {
|
|
136
|
+
if (typeof ref === 'number') return idByIndex[ref] ?? null;
|
|
137
|
+
if (typeof ref === 'string') { if (idByIndex.includes(ref)) return ref; return idByTitle.get(ref.trim().toLowerCase()) ?? null; }
|
|
138
|
+
return null;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const connections = (Array.isArray(spec.connections) ? spec.connections : []).map((c, i) => {
|
|
142
|
+
const fromId = resolveRef(c.from), toId = resolveRef(c.to);
|
|
143
|
+
if (!fromId || !toId || fromId === toId) return null;
|
|
144
|
+
return {
|
|
145
|
+
id: `con_${rand()}_${i}`, fromId, toId,
|
|
146
|
+
relationship: REL.has(c.relationship) ? c.relationship : undefined,
|
|
147
|
+
label: typeof c.label === 'string' ? c.label : undefined,
|
|
148
|
+
arrowHead: true, width: 2, color: '#10b981', style: 'solid',
|
|
149
|
+
};
|
|
150
|
+
}).filter(Boolean);
|
|
151
|
+
|
|
152
|
+
// BFS order so connected cards land near each other.
|
|
153
|
+
const adj = new Map(idByIndex.map(id => [id, []]));
|
|
154
|
+
for (const c of connections) { adj.get(c.fromId)?.push(c.toId); adj.get(c.toId)?.push(c.fromId); }
|
|
155
|
+
const indeg = new Map(idByIndex.map(id => [id, 0]));
|
|
156
|
+
for (const c of connections) indeg.set(c.toId, (indeg.get(c.toId) || 0) + 1);
|
|
157
|
+
const visited = new Set();
|
|
158
|
+
const order = [];
|
|
159
|
+
const starts = [...idByIndex].sort((a, b) => (indeg.get(a) - indeg.get(b)) || (idByIndex.indexOf(a) - idByIndex.indexOf(b)));
|
|
160
|
+
for (const s of starts) {
|
|
161
|
+
if (visited.has(s)) continue;
|
|
162
|
+
const q = [s];
|
|
163
|
+
while (q.length) {
|
|
164
|
+
const id = q.shift();
|
|
165
|
+
if (visited.has(id)) continue;
|
|
166
|
+
visited.add(id); order.push(id);
|
|
167
|
+
for (const n of (adj.get(id) || [])) if (!visited.has(n)) q.push(n);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const FONT = 20, PAD = 28, LINE_H = FONT * 1.35;
|
|
172
|
+
const sizeFor = (card) => {
|
|
173
|
+
if (card.x != null && card.w != null) return { w: card.w, h: card.h ?? 40 };
|
|
174
|
+
const lines = String(card.text ?? '').split('\n');
|
|
175
|
+
const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
176
|
+
const w = Math.max(160, Math.min(360, Math.round(longest * (FONT * 0.55)) + PAD));
|
|
177
|
+
const h = Math.max(40, Math.round(lines.length * LINE_H) + 14);
|
|
178
|
+
return { w, h };
|
|
179
|
+
};
|
|
180
|
+
const cols = Math.max(1, Math.ceil(Math.sqrt(order.length)));
|
|
181
|
+
const COL_W = 380, GAP_Y = 70, START = 80;
|
|
182
|
+
const positions = {};
|
|
183
|
+
let zi = 0;
|
|
184
|
+
order.forEach((id, idx) => {
|
|
185
|
+
const card = cards[idByIndex.indexOf(id)];
|
|
186
|
+
const { w, h } = sizeFor(card);
|
|
187
|
+
const col = idx % cols, row = Math.floor(idx / cols);
|
|
188
|
+
positions[id] = {
|
|
189
|
+
x: card.x ?? (START + col * COL_W),
|
|
190
|
+
y: card.y ?? (START + row * (180 + GAP_Y) + (col % 2) * 12),
|
|
191
|
+
w, h, zKey: 'a' + String(idx).padStart(4, '0'), zIndex: zi++, parentId: null,
|
|
192
|
+
};
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const itemJson = (card) => {
|
|
196
|
+
if (card.type === 'text') {
|
|
197
|
+
return {
|
|
198
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
199
|
+
content: String(card.text ?? ''), fontSize: FONT,
|
|
200
|
+
color: card.color || '#1a1a1f', border: !!card.border, borderColor: '#1e1e2e',
|
|
201
|
+
heading: !!card.heading, fontFamily: 'Thmanyah Sans',
|
|
202
|
+
fontWeight: card.heading ? 'bold' : 'normal', fontStyle: 'normal',
|
|
203
|
+
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { type: card.type, locked: false, createdAt: now, createdBy: 'agent', ...(card._raw || {}) };
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
const zip = new JSZip();
|
|
210
|
+
const manifest = {
|
|
211
|
+
format: 'klypix', version: 4, schemaVersion: 4,
|
|
212
|
+
createdAt: nowIso, updatedAt: nowIso,
|
|
213
|
+
title: spec.title || 'Untitled',
|
|
214
|
+
stats: { itemCount: order.length, assetCount: 0, totalBytes: 0 },
|
|
215
|
+
sync: { enabled: false, lastSyncRev: null, lastSyncAt: null, deviceId: `dev_${rand()}${rand()}` },
|
|
216
|
+
};
|
|
217
|
+
const xs = Object.values(positions);
|
|
218
|
+
const minX = Math.min(...xs.map(p => p.x)), minY = Math.min(...xs.map(p => p.y));
|
|
219
|
+
const canvasJson = {
|
|
220
|
+
version: 4,
|
|
221
|
+
view: { panX: 120 - minX * 0.7, panY: 120 - minY * 0.7, zoom: 0.7 },
|
|
222
|
+
order, connections, lines: [], strokes: [], nextGroupNumber: 1,
|
|
223
|
+
positions, settings: { background: '#0a0a0f' },
|
|
224
|
+
};
|
|
225
|
+
zip.file('manifest.json', JSON.stringify(manifest));
|
|
226
|
+
zip.file('canvas.json', JSON.stringify(canvasJson));
|
|
227
|
+
for (const id of order) {
|
|
228
|
+
const card = cards[idByIndex.indexOf(id)];
|
|
229
|
+
zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify(itemJson(card)));
|
|
230
|
+
}
|
|
231
|
+
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Append cards (+ optional connections) to an EXISTING v4 .klypix, preserving
|
|
236
|
+
* every existing item and its position. New cards are placed in a column just
|
|
237
|
+
* to the right of the current content and stacked on top (z above existing).
|
|
238
|
+
* connection from/to may reference a NEW card by index/title, or an EXISTING
|
|
239
|
+
* card by its title. Returns a nodebuffer of the updated file.
|
|
240
|
+
*/
|
|
241
|
+
export async function appendToKlypix(buffer, addition) {
|
|
242
|
+
const { zip, canvas, manifest, isV4, struct } = await parseKlypix(buffer);
|
|
243
|
+
if (!isV4 || !canvas.positions) {
|
|
244
|
+
throw new Error('append supports v4 .klypix only; for a legacy .any, create a new canvas instead');
|
|
245
|
+
}
|
|
246
|
+
const newCards = (addition?.cards || []).filter(c => c && typeof c.text === 'string' && c.text.trim());
|
|
247
|
+
if (newCards.length === 0) throw new Error('nothing to add — provide cards[] with text');
|
|
248
|
+
|
|
249
|
+
const now = Date.now();
|
|
250
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
251
|
+
const FONT = 20, LINE_H = FONT * 1.35;
|
|
252
|
+
|
|
253
|
+
const ex = Object.values(canvas.positions);
|
|
254
|
+
const maxX = ex.length ? Math.max(...ex.map(p => p.x + (p.w || 160))) : 80;
|
|
255
|
+
const minY = ex.length ? Math.min(...ex.map(p => p.y)) : 80;
|
|
256
|
+
const startX = maxX + 80;
|
|
257
|
+
|
|
258
|
+
const titleToId = new Map();
|
|
259
|
+
for (const c of struct.cards) { const t = (c.title || '').toLowerCase(); if (t && !titleToId.has(t)) titleToId.set(t, c.id); }
|
|
260
|
+
|
|
261
|
+
let zTop = Array.isArray(canvas.order) ? canvas.order.length : 0;
|
|
262
|
+
const added = newCards.map((c, i) => {
|
|
263
|
+
const lines = String(c.text).split('\n');
|
|
264
|
+
const longest = lines.reduce((m, l) => Math.max(m, l.length), 0);
|
|
265
|
+
const w = Math.max(160, Math.min(360, Math.round(longest * (FONT * 0.55)) + 28));
|
|
266
|
+
const h = Math.max(40, Math.round(lines.length * LINE_H) + 14);
|
|
267
|
+
return { id: `txt_${rand()}_${i}`, card: c, x: startX, y: minY + i * 160, w, h, z: zTop + i };
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
const addedTitle = new Map(added.map(a => [String(a.card.text).split('\n').map(s => s.trim()).find(Boolean)?.toLowerCase() || '', a.id]));
|
|
271
|
+
const resolve = (ref) => {
|
|
272
|
+
if (typeof ref === 'number') return added[ref]?.id ?? null;
|
|
273
|
+
if (typeof ref === 'string') {
|
|
274
|
+
const k = ref.trim().toLowerCase();
|
|
275
|
+
return addedTitle.get(k) || titleToId.get(k) || (canvas.positions[ref] ? ref : null);
|
|
276
|
+
}
|
|
277
|
+
return null;
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
281
|
+
for (const a of added) {
|
|
282
|
+
zip.file(`items/${shard(a.id)}/${a.id}.json`, JSON.stringify({
|
|
283
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
284
|
+
content: String(a.card.text), fontSize: FONT,
|
|
285
|
+
color: a.card.color || '#1a1a1f', border: !!a.card.border, borderColor: '#1e1e2e',
|
|
286
|
+
heading: !!a.card.heading, fontFamily: 'Thmanyah Sans',
|
|
287
|
+
fontWeight: a.card.heading ? 'bold' : 'normal', fontStyle: 'normal',
|
|
288
|
+
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
289
|
+
}));
|
|
290
|
+
// 'z' prefix sorts new cards above existing ones (which use 'a…').
|
|
291
|
+
canvas.positions[a.id] = { x: a.x, y: a.y, w: a.w, h: a.h, zKey: 'z' + String(a.z).padStart(5, '0'), zIndex: a.z, parentId: null };
|
|
292
|
+
canvas.order.push(a.id);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
296
|
+
(addition?.connections || []).forEach((cn, i) => {
|
|
297
|
+
const fromId = resolve(cn.from), toId = resolve(cn.to);
|
|
298
|
+
if (!fromId || !toId || fromId === toId) return;
|
|
299
|
+
canvas.connections.push({
|
|
300
|
+
id: `con_${rand()}_${i}`, fromId, toId,
|
|
301
|
+
relationship: REL.has(cn.relationship) ? cn.relationship : undefined,
|
|
302
|
+
label: typeof cn.label === 'string' ? cn.label : undefined,
|
|
303
|
+
arrowHead: true, width: 2, color: '#10b981', style: 'solid',
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
if (manifest) {
|
|
308
|
+
manifest.updatedAt = new Date(now).toISOString();
|
|
309
|
+
manifest.stats = manifest.stats || {};
|
|
310
|
+
manifest.stats.itemCount = canvas.order.length;
|
|
311
|
+
zip.file('manifest.json', JSON.stringify(manifest));
|
|
312
|
+
}
|
|
313
|
+
zip.file('canvas.json', JSON.stringify(canvas));
|
|
314
|
+
const out = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
315
|
+
// Parse-resilience: never return a buffer that doesn't round-trip — the
|
|
316
|
+
// caller keeps the last-known-good file rather than writing corruption.
|
|
317
|
+
try { await parseKlypix(out); }
|
|
318
|
+
catch (e) { throw new Error('append produced an unparseable .klypix — aborting to protect the brain: ' + (e?.message || e)); }
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Build a RICH "map" .klypix: areas become titled containers, their cards
|
|
324
|
+
* stack inside, connections draw across. Produces a real spatial board (used by
|
|
325
|
+
* the project brain) rather than a flat grid. Spec:
|
|
326
|
+
* { title, areas: [{ title, color?, cards: [{text, heading?, color?}] }],
|
|
327
|
+
* connections: [{ from, to, relationship?, label? }] } // from/to by card title
|
|
328
|
+
*/
|
|
329
|
+
export async function buildKlypixMap(spec) {
|
|
330
|
+
if (!spec || !Array.isArray(spec.areas) || spec.areas.length === 0) {
|
|
331
|
+
throw new Error('map spec needs a non-empty "areas" array');
|
|
332
|
+
}
|
|
333
|
+
const now = Date.now();
|
|
334
|
+
const nowIso = new Date(now).toISOString();
|
|
335
|
+
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
336
|
+
|
|
337
|
+
const TITLE_BAR = 44, PAD = 16, CARD_GAP = 12, CARD_W = 280, FONT = 15, LINE_H = FONT * 1.4;
|
|
338
|
+
const AREA_W = CARD_W + PAD * 2;
|
|
339
|
+
const COL_GAP = 48, ROW_GAP = 48, START = 80;
|
|
340
|
+
const cols = Math.max(1, Math.min(4, Math.ceil(Math.sqrt(spec.areas.length))));
|
|
341
|
+
|
|
342
|
+
const positions = {};
|
|
343
|
+
const order = [];
|
|
344
|
+
const items = {}; // id -> json
|
|
345
|
+
const titleToId = new Map(); // card title -> id (for connections)
|
|
346
|
+
const firstLine = (t) => String(t ?? '').split('\n').map(s => s.trim()).find(Boolean) || '';
|
|
347
|
+
let z = 0;
|
|
348
|
+
|
|
349
|
+
// Shelf-pack areas into rows of `cols`; each row's height = tallest area.
|
|
350
|
+
let rowTopY = START, rowMaxH = 0, colX = START, colIdx = 0;
|
|
351
|
+
spec.areas.forEach((area, ai) => {
|
|
352
|
+
const cards = (area.cards || []).filter(c => c && typeof c.text === 'string' && c.text.trim());
|
|
353
|
+
// measure card heights
|
|
354
|
+
const measured = cards.map(c => {
|
|
355
|
+
const lines = String(c.text).split('\n').length;
|
|
356
|
+
return Math.max(40, Math.round(lines * LINE_H) + 18);
|
|
357
|
+
});
|
|
358
|
+
const innerH = measured.reduce((s, h) => s + h + CARD_GAP, 0);
|
|
359
|
+
const areaH = TITLE_BAR + PAD + innerH + PAD;
|
|
360
|
+
|
|
361
|
+
if (colIdx >= cols) { // new row
|
|
362
|
+
rowTopY += rowMaxH + ROW_GAP;
|
|
363
|
+
rowMaxH = 0; colIdx = 0; colX = START;
|
|
364
|
+
}
|
|
365
|
+
const ax = colX, ay = rowTopY;
|
|
366
|
+
|
|
367
|
+
const ctnId = `ctn_${rand()}_${ai}`;
|
|
368
|
+
items[ctnId] = {
|
|
369
|
+
type: 'container', locked: false, createdAt: now, createdBy: 'agent',
|
|
370
|
+
title: area.title || `Area ${ai + 1}`, collapsed: false, scopeLocked: false,
|
|
371
|
+
borderColor: area.color || '#10b981',
|
|
372
|
+
};
|
|
373
|
+
positions[ctnId] = { x: ax, y: ay, w: AREA_W, h: areaH, zKey: 'a' + String(z).padStart(4, '0'), zIndex: z, parentId: null };
|
|
374
|
+
order.push(ctnId); z++;
|
|
375
|
+
|
|
376
|
+
let cy = ay + TITLE_BAR + PAD;
|
|
377
|
+
cards.forEach((c, ci) => {
|
|
378
|
+
const id = `txt_${rand()}_${ai}_${ci}`;
|
|
379
|
+
const h = measured[ci];
|
|
380
|
+
items[id] = {
|
|
381
|
+
type: 'text', locked: false, createdAt: now, createdBy: 'agent',
|
|
382
|
+
content: String(c.text), fontSize: FONT,
|
|
383
|
+
color: c.color || '#e8e8ed', border: true,
|
|
384
|
+
borderColor: c.color || 'rgba(16,185,129,0.35)',
|
|
385
|
+
fillColor: 'rgba(18,18,26,0.85)',
|
|
386
|
+
heading: !!c.heading,
|
|
387
|
+
fontWeight: c.heading ? 'bold' : 'normal', fontStyle: 'normal',
|
|
388
|
+
textDecoration: 'none', textAlign: 'left', verticalAlign: 'top',
|
|
389
|
+
fontFamily: 'Thmanyah Sans',
|
|
390
|
+
};
|
|
391
|
+
positions[id] = { x: ax + PAD, y: cy, w: CARD_W, h, zKey: 'a' + String(z).padStart(4, '0'), zIndex: z, parentId: ctnId };
|
|
392
|
+
order.push(id); z++;
|
|
393
|
+
const t = firstLine(c.text).toLowerCase();
|
|
394
|
+
if (t && !titleToId.has(t)) titleToId.set(t, id);
|
|
395
|
+
cy += h + CARD_GAP;
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
rowMaxH = Math.max(rowMaxH, areaH);
|
|
399
|
+
colX += AREA_W + COL_GAP;
|
|
400
|
+
colIdx++;
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
// Connections (by card title, across all areas).
|
|
404
|
+
const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks']);
|
|
405
|
+
const resolve = (ref) => {
|
|
406
|
+
if (typeof ref === 'string') return titleToId.get(ref.trim().toLowerCase()) || null;
|
|
407
|
+
return null;
|
|
408
|
+
};
|
|
409
|
+
const connections = (Array.isArray(spec.connections) ? spec.connections : []).map((c, i) => {
|
|
410
|
+
const fromId = resolve(c.from), toId = resolve(c.to);
|
|
411
|
+
if (!fromId || !toId || fromId === toId) return null;
|
|
412
|
+
return {
|
|
413
|
+
id: `con_${rand()}_${i}`, fromId, toId,
|
|
414
|
+
relationship: REL.has(c.relationship) ? c.relationship : undefined,
|
|
415
|
+
label: typeof c.label === 'string' ? c.label : undefined,
|
|
416
|
+
arrowHead: true, width: 2, color: '#10b981', style: 'solid',
|
|
417
|
+
};
|
|
418
|
+
}).filter(Boolean);
|
|
419
|
+
|
|
420
|
+
const zip = new JSZip();
|
|
421
|
+
const manifest = {
|
|
422
|
+
format: 'klypix', version: 4, schemaVersion: 4, createdAt: nowIso, updatedAt: nowIso,
|
|
423
|
+
title: spec.title || 'Brain', stats: { itemCount: order.length, assetCount: 0, totalBytes: 0 },
|
|
424
|
+
sync: { enabled: false, lastSyncRev: null, lastSyncAt: null, deviceId: `dev_${rand()}${rand()}` },
|
|
425
|
+
};
|
|
426
|
+
const xs = Object.values(positions);
|
|
427
|
+
const minX = Math.min(...xs.map(p => p.x)), minY = Math.min(...xs.map(p => p.y));
|
|
428
|
+
const canvasJson = {
|
|
429
|
+
version: 4, view: { panX: 120 - minX * 0.55, panY: 120 - minY * 0.55, zoom: 0.55 },
|
|
430
|
+
order, connections, lines: [], strokes: [], nextGroupNumber: spec.areas.length + 1,
|
|
431
|
+
positions, settings: { background: '#0a0a0f' },
|
|
432
|
+
};
|
|
433
|
+
zip.file('manifest.json', JSON.stringify(manifest));
|
|
434
|
+
zip.file('canvas.json', JSON.stringify(canvasJson));
|
|
435
|
+
for (const id of order) zip.file(`items/${shard(id)}/${id}.json`, JSON.stringify(items[id]));
|
|
436
|
+
return zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Render a parsed struct to the markdown brief (shared by read-klypix + MCP). */
|
|
440
|
+
export function structToMarkdown(struct, { assetsDir } = {}) {
|
|
441
|
+
const L = [];
|
|
442
|
+
L.push(`# ${struct.title}`);
|
|
443
|
+
L.push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · ${struct.counts.assets} assets*\n`);
|
|
444
|
+
L.push(`## Cards`);
|
|
445
|
+
for (const c of struct.cards) {
|
|
446
|
+
L.push(`### ${c.title || `(${c.type})`} \`${c.type}\``);
|
|
447
|
+
if (c.text) L.push(c.type === 'text' ? String(c.text).trim() : `→ ${c.text}`);
|
|
448
|
+
const meta = [];
|
|
449
|
+
if (c.links?.length) meta.push(`links: ${c.links.map(t => `[[${t}]]`).join(', ')}`);
|
|
450
|
+
if (c.tags?.length) meta.push(`tags: ${c.tags.map(t => `#${t}`).join(' ')}`);
|
|
451
|
+
if (meta.length) L.push(`\n_${meta.join(' · ')}_`);
|
|
452
|
+
L.push('');
|
|
453
|
+
}
|
|
454
|
+
if (struct.connections.length) {
|
|
455
|
+
L.push(`## Connection graph`);
|
|
456
|
+
for (const e of struct.connections) {
|
|
457
|
+
const rel = e.relationship ? ` —(${e.relationship})→ ` : ' → ';
|
|
458
|
+
L.push(`- ${e.from}${rel}${e.to}${e.label ? ` (${e.label})` : ''}`);
|
|
459
|
+
}
|
|
460
|
+
L.push('');
|
|
461
|
+
}
|
|
462
|
+
if (struct.assets.length) {
|
|
463
|
+
L.push(`## Assets (images / files)`);
|
|
464
|
+
L.push(assetsDir
|
|
465
|
+
? `Extracted to \`${assetsDir}\` — open them to read images with vision:`
|
|
466
|
+
: `Re-run with \`--assets <dir>\` to extract these for reading:`);
|
|
467
|
+
for (const a of struct.assets) L.push(`- ${a}`);
|
|
468
|
+
L.push('');
|
|
469
|
+
}
|
|
470
|
+
return L.join('\n');
|
|
471
|
+
}
|