drafted 1.18.1 → 1.18.2
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 +28 -16
- package/agent-instructions/global.md +3 -0
- package/cli/repo-scan.mjs +156 -0
- package/install-mcp.sh +1269 -0
- package/mcp/active-project-store.mjs +81 -0
- package/mcp/gates.mjs +187 -0
- package/mcp/server.mjs +115 -25
- package/mcp/test-file-path-text.mjs +58 -0
- package/mcp/test-org-guards.mjs +172 -0
- package/mcp/test-project-index.mjs +88 -0
- package/mcp/widgets/canvas-overview.html +229 -0
- package/mcp/widgets/frame-preview.html +162 -0
- package/package.json +80 -16
- package/plugin/commands/create-project.md +20 -0
- package/plugin/commands/create-skill.md +18 -0
- package/plugin/commands/extract.md +16 -0
- package/plugin/commands/improve-project-harness.md +16 -0
- package/plugin/commands/improve-skill.md +14 -0
- package/plugin/commands/improve-wiki.md +14 -0
- package/plugin/commands/ingest.md +20 -0
- package/plugin/commands/onboard-drafted.md +17 -0
- package/plugin/skills/drafted/SKILL.md +90 -0
- package/server/lib/umami.mjs +162 -0
- package/src/shared/excalidraw.mjs +84 -0
- package/src/shared/gate-budget.mjs +30 -0
- package/src/shared/minion-presets.mjs +67 -0
- package/src/shared/okf-log.mjs +62 -0
- package/src/shared/record-conformance.mjs +167 -0
- package/src/shared/test-excalidraw-merge.mjs +53 -0
- package/src/shared/wiki-excalidraw.mjs +90 -0
- package/skills/import-website-to-drafted.md +0 -317
- /package/{shared → src/shared}/constants.mjs +0 -0
package/package.json
CHANGED
|
@@ -1,38 +1,102 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "drafted",
|
|
3
|
-
"version": "1.18.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.18.2",
|
|
4
|
+
"description": "Drafted — visual thinking surface for humans and AI agents. Renders HTML, markdown, images, and code as frames on a zoomable canvas, with MCP tools for AI agents and real-time sync for humans.",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"cli/",
|
|
8
|
+
"mcp/",
|
|
9
|
+
"server/lib/umami.mjs",
|
|
10
|
+
"src/shared/",
|
|
11
|
+
"agent-instructions/",
|
|
12
|
+
"plugin/skills/",
|
|
13
|
+
"plugin/commands/",
|
|
14
|
+
"install-mcp.sh"
|
|
15
|
+
],
|
|
6
16
|
"bin": {
|
|
7
17
|
"drafted": "./cli/drafted.mjs",
|
|
8
18
|
"drafted-mcp": "./mcp/server.mjs"
|
|
9
19
|
},
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
"
|
|
13
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
16
|
-
|
|
20
|
+
"scripts": {
|
|
21
|
+
"dev": "./dev.sh",
|
|
22
|
+
"dev:server": "tsx watch --import ./server/instrument.mjs server/server.mjs",
|
|
23
|
+
"dev:local": "node server/server-local.mjs",
|
|
24
|
+
"install-global": "npm install -g .",
|
|
25
|
+
"db:migrate": "psql $DATABASE_URL -f drizzle/migrate.sql",
|
|
26
|
+
"db:push": "drizzle-kit push",
|
|
27
|
+
"db:studio": "drizzle-kit studio",
|
|
28
|
+
"migrate-fs": "tsx server/migrate-fs.ts",
|
|
29
|
+
"db:clean-sessions": "tsx src/auth/cleanup.ts",
|
|
30
|
+
"import:legacy": "tsx scripts/import-legacy.mts",
|
|
31
|
+
"test": "vitest run",
|
|
32
|
+
"test:watch": "vitest",
|
|
33
|
+
"version": "node scripts/sync-versions.mjs && git add plugin/.claude-plugin/plugin.json .claude-plugin/marketplace.json web-plugin/plugin/.claude-plugin/plugin.json web-plugin/.claude-plugin/marketplace.json",
|
|
34
|
+
"version:check": "node scripts/sync-versions.mjs",
|
|
35
|
+
"postpublish": "bash scripts/sync-plugin.sh \"chore: sync plugin to v$npm_package_version\" && bash scripts/sync-web-plugin.sh \"chore: sync web-plugin to v$npm_package_version\"",
|
|
36
|
+
"deploy:check:google": "node scripts/check-google-drive-deploy.mjs",
|
|
37
|
+
"build:excalidraw": "node scripts/build-excalidraw-editor.mjs",
|
|
38
|
+
"build:office-viewer": "node scripts/build-office-viewer.mjs",
|
|
39
|
+
"prepublishOnly": "node scripts/check-npm-package.mjs",
|
|
40
|
+
"test:marketing": "node server/lib/marketing/test-marketing.mjs"
|
|
41
|
+
},
|
|
17
42
|
"dependencies": {
|
|
43
|
+
"@aws-sdk/client-s3": "^3.1007.0",
|
|
44
|
+
"@aws-sdk/s3-request-presigner": "^3.1008.0",
|
|
45
|
+
"@clerk/backend": "^3.2.12",
|
|
46
|
+
"@clerk/express": "^2.1.4",
|
|
47
|
+
"@clerk/mcp-tools": "^0.3.1",
|
|
48
|
+
"@excalidraw/excalidraw": "^0.18.1",
|
|
49
|
+
"@modelcontextprotocol/ext-apps": "^1.6.0",
|
|
18
50
|
"@modelcontextprotocol/sdk": "^1.27.1",
|
|
51
|
+
"@sentry/browser": "^10.53.1",
|
|
52
|
+
"@sentry/node": "^10.53.1",
|
|
53
|
+
"@xmldom/xmldom": "^0.9.10",
|
|
19
54
|
"commander": "^11.1.0",
|
|
55
|
+
"dagre": "^0.8.5",
|
|
56
|
+
"docx": "^9.7.1",
|
|
57
|
+
"dotenv": "^17.3.1",
|
|
58
|
+
"drizzle-orm": "^0.45.1",
|
|
59
|
+
"esbuild": "^0.28.0",
|
|
60
|
+
"exceljs": "^4.4.0",
|
|
61
|
+
"express": "^4.18.2",
|
|
62
|
+
"jszip": "^3.10.1",
|
|
63
|
+
"lucide": "^1.16.0",
|
|
64
|
+
"mdast-util-from-markdown": "^2.0.3",
|
|
65
|
+
"multer": "^2.1.1",
|
|
66
|
+
"nodemailer": "^8.0.2",
|
|
67
|
+
"parse5": "^8.0.1",
|
|
68
|
+
"pg": "^8.20.0",
|
|
69
|
+
"playwright": "^1.58.2",
|
|
70
|
+
"puppeteer": "^24.37.5",
|
|
20
71
|
"qrcode-terminal": "^0.12.0",
|
|
21
|
-
"
|
|
72
|
+
"react": "^18.3.1",
|
|
73
|
+
"react-dom": "^18.3.1",
|
|
74
|
+
"sharp": "^0.34.5",
|
|
75
|
+
"ws": "^8.21.3",
|
|
76
|
+
"yaml": "^2.8.3",
|
|
22
77
|
"zod": "^4.3.6"
|
|
23
78
|
},
|
|
24
79
|
"keywords": [
|
|
25
|
-
"drafted",
|
|
26
80
|
"design",
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"
|
|
30
|
-
"
|
|
81
|
+
"canvas",
|
|
82
|
+
"preview",
|
|
83
|
+
"html",
|
|
84
|
+
"claude"
|
|
31
85
|
],
|
|
32
86
|
"author": "ddfourtwo",
|
|
33
87
|
"repository": {
|
|
34
88
|
"type": "git",
|
|
35
89
|
"url": "git+https://github.com/ddfourtwo/drafted.git"
|
|
36
90
|
},
|
|
37
|
-
"license": "MIT"
|
|
91
|
+
"license": "MIT",
|
|
92
|
+
"devDependencies": {
|
|
93
|
+
"@types/express": "^5.0.6",
|
|
94
|
+
"@types/nodemailer": "^7.0.11",
|
|
95
|
+
"@types/pg": "^8.18.0",
|
|
96
|
+
"docx-preview": "^0.3.7",
|
|
97
|
+
"drizzle-kit": "^0.31.9",
|
|
98
|
+
"tsx": "^4.19.0",
|
|
99
|
+
"typescript": "^5.9.3",
|
|
100
|
+
"vitest": "^3.2.4"
|
|
101
|
+
}
|
|
38
102
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Start a new project — or a reusable template — with proper search of knowledge and skills first
|
|
3
|
+
argument-hint: <project name and what you want to do>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Spin up a project on the surface, or generalize one into a reusable template. Goal: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
First ask: **a new project, or a reusable template?** (This command does both.)
|
|
9
|
+
|
|
10
|
+
Search before creating (also satisfies the gates):
|
|
11
|
+
1. `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant knowledge. [G1]
|
|
12
|
+
2. `fs(search, path="/o/<org>/skills", query="<topic>")` for procedures that should be attached. [G2/G3]
|
|
13
|
+
|
|
14
|
+
Then:
|
|
15
|
+
- Create the project — a project is a folder: `fs(mkdir, path="/o/<org>/projects/<name>")`. It starts with **no layers**; you define them by writing into them.
|
|
16
|
+
- `fs(write, path="/o/<org>/projects/<name>/<layer>/<lane>/brief.md", content=...)` a real brief at the earliest layer (goal, audience, constraints — 6-12 lines, not a placeholder). The layer + lane auto-create.
|
|
17
|
+
- Attach the relevant skills you found via the skill gates.
|
|
18
|
+
- `fs(ls, path="/o/<org>/projects/<name>")` to confirm the structure, and hand the user the `projectUrl` from the response.
|
|
19
|
+
|
|
20
|
+
For a **template**: build the layer structure + anchored guidance, then note how to fork it next time. Don't speculatively fill downstream frames — wait for direction.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Capture a repeatable procedure as an org skill — with proper knowledge and prior-art search
|
|
3
|
+
argument-hint: <what the procedure is for>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Turn a repeatable way of working into a Drafted skill so every future agent in the org follows it. Procedure: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
Do the searches first (they also satisfy the gates):
|
|
9
|
+
1. `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant org knowledge the procedure should reference. [G1]
|
|
10
|
+
2. `fs(search, path="/o/<org>/skills", query="<topic>")` for prior art — an existing skill to improve instead of duplicating. [G2 — also enforced on writing a new skill.] If a close match exists, prefer `/drafted:improve-skill`.
|
|
11
|
+
|
|
12
|
+
Then define a PROPER procedure, not a vague note:
|
|
13
|
+
- a clear trigger ("when to use this"),
|
|
14
|
+
- ordered, concrete steps,
|
|
15
|
+
- success criteria / what "done right" looks like,
|
|
16
|
+
- written in the second person so any agent can follow it directly. Keep it sharp (~40 lines).
|
|
17
|
+
|
|
18
|
+
Show the draft to the user with a proposed `name` (Title Case), one-line `description`, `tags` (3-5), and `triggerPatterns`. After approval, write it with `fs(write, path="/o/<org>/skills/<slug>", content=...)` — the slug is derived from the name. Confirm with the slug and note it will auto-surface on matching tasks.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Deposit what this session produced into the harness — knowledge, a skill, and/or a template
|
|
3
|
+
argument-hint: <optional: what to capture>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Session-end deposit. Harvest what's durable from this conversation back into the harness so the next session starts smarter. Focus: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
Review the session, then **present the user options for which store(s) to deposit into** — don't auto-decide. Offer any that apply:
|
|
9
|
+
|
|
10
|
+
- **Knowledge → wiki** — durable facts, decisions, or findings worth keeping. Search first (`fs(search, path="/o/<org>/wiki", ...)`) to avoid fragmenting, then `fs(write, path="/o/<org>/wiki/<path>", ...)`.
|
|
11
|
+
- **Procedure → skill** — a repeatable way of working that emerged. Follow `/drafted:create-skill`.
|
|
12
|
+
- **Template → surface** — a reusable project structure that emerged. Build it as a project via `fs(mkdir, ...)` + frames.
|
|
13
|
+
|
|
14
|
+
Show the user the candidate deposits per store, let them pick which to keep, then write the chosen ones with their approval. Confirm what landed where.
|
|
15
|
+
|
|
16
|
+
One command, all three stores — because at session end the human shouldn't have to remember which store is which.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Turn corrections you made in a project into enforced gates (anchors, attached skills, layer rules)
|
|
3
|
+
argument-hint: <optional: the correction to enforce>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
When the user had to correct the work in this project, codify the correction so future work is compelled to comply. This is the nurture → enforce bridge. Correction: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
1. Identify the recurring correction(s) or standing rule from this session.
|
|
9
|
+
2. Choose the right Drafted-side gate per correction (each counts toward the project's context budget):
|
|
10
|
+
- **Project anchor** — a brief, constraint, or style guide that must be in context project-wide: `fs(write, path="/o/<org>/projects/<project>/<layer>/<lane>/<file>", content=...)` it, then anchor it so it surfaces on open. [G5]
|
|
11
|
+
- **Attached skill** — a procedure that must be loaded before work: `/drafted:create-skill` (or `fs(search, path="/o/<org>/skills", ...)` for an existing one), then attach it to the project. [G4]
|
|
12
|
+
- **Layer rule** — a standing instruction for one stage: set that layer's rules so work in it is gated. [G6]
|
|
13
|
+
3. Propose which mechanism for each correction, confirm with the user, then apply.
|
|
14
|
+
4. Confirm what is now enforced — the next session in this project will be gated on it automatically.
|
|
15
|
+
|
|
16
|
+
Keep within the project's context budget — if a deposit would exceed it, tighten existing gates instead of piling on.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Fix a skill you noticed is inefficient, wrong, or missing a step
|
|
3
|
+
argument-hint: <which skill, and what's off>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Improve an existing org skill when it underperformed in practice. Skill / issue: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
1. `fs(search, path="/o/<org>/skills", query="<slug>")` then `fs(read, path="/o/<org>/skills/<slug>")` the skill in question — read it fully.
|
|
9
|
+
2. Pinpoint the inefficiency: a missing step, a wrong instruction, an ambiguous trigger, or a step that wastes effort.
|
|
10
|
+
3. Propose the specific edit to the user — show before/after of the changed steps.
|
|
11
|
+
4. After approval, `fs(write, path="/o/<org>/skills/<slug>", content=<updated>)` — writing an existing skill updates it (the version bumps automatically). If it was archived, writing restores it.
|
|
12
|
+
5. Confirm what changed so the next agent benefits.
|
|
13
|
+
|
|
14
|
+
Skills are the org's stable processes — every fix compounds across everyone who uses them.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Fix the wiki — reconcile stale, wrong, fragmented, or contradictory knowledge
|
|
3
|
+
argument-hint: <what's wrong, or the topic to clean up>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Improve the org wiki when knowledge has drifted. Issue/topic: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
1. `fs(search, path="/o/<org>/wiki", query="<terms>")` (3-5 paraphrased queries) and `fs(read, path="/o/<org>/wiki/<path>")` the affected pages — don't trust titles, open them.
|
|
9
|
+
2. Diagnose: duplicate pages, a stale fact, a contradiction, or knowledge fragmented across pages.
|
|
10
|
+
3. Propose the fix to the user: consolidate duplicates, correct the fact, reconcile the contradiction, or re-link fragments.
|
|
11
|
+
4. Apply with `fs(write, path="/o/<org>/wiki/<path>", content=<updated>)` (hashline `edit` for surgical changes) or `fs(mv, path="/o/<org>/wiki/<from>", to="/o/<org>/wiki/<to>")` (which rewrites inbound links). Check what links to a page before moving or archiving it.
|
|
12
|
+
5. Never hard-delete — `fs(rm, path="/o/<org>/wiki/<path>")` moves a page to the archive folder.
|
|
13
|
+
|
|
14
|
+
Leave the wiki more coherent than you found it — fewer, sharper, better-linked pages.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Bring knowledge into the wiki — from a research output, existing documents, or by interrogating you
|
|
3
|
+
argument-hint: <optional: what to ingest, or a source>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Feed durable knowledge into the org wiki. Source/intent: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
First decide WHAT to ingest. If it's obvious from the conversation (a research run just finished, or the user named a source), use that. Otherwise ask which of these three modes fits:
|
|
9
|
+
|
|
10
|
+
1. **A research producible** — the output of a research run (a provider-native `/deep-research` or similar). Structure it into wiki pages.
|
|
11
|
+
2. **Existing documents** — the user points at local / remote / connected folders. Scour them (spawn sub-agents if there are many) for relevant material, shortlist, confirm, then ingest the right ones. Folder scour needs file access (desktop / Cowork); on web, use connected sources.
|
|
12
|
+
3. **Interrogate the user** (grill-me) — when the knowledge is in their head. Interview relentlessly, walking ONE branch of the decision tree at a time and proposing your recommended answer at each step, until you reach shared understanding. Then structure what you captured.
|
|
13
|
+
|
|
14
|
+
Then deposit:
|
|
15
|
+
- `fs(search, path="/o/<org>/wiki", query="<terms>")` first (3-5 paraphrased queries) so you don't fragment existing pages.
|
|
16
|
+
- Propose the page set to the user before writing.
|
|
17
|
+
- Write with `fs(write, path="/o/<org>/wiki/<path>", content=...)` per page. Cross-link related pages.
|
|
18
|
+
- Confirm what landed where, with the page links.
|
|
19
|
+
|
|
20
|
+
The wiki is the org's durable knowledge — write for the next agent and teammate, not just this session.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: First-run setup — orient to Drafted and bootstrap the harness (wiki, skills, first project)
|
|
3
|
+
argument-hint: <optional: what you want to start working on>
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Onboard the user to Drafted — a producibles harness that compounds across three primitives: knowledge (the wiki), procedures (skills), and the project surface. Goal/context: $ARGUMENTS
|
|
7
|
+
|
|
8
|
+
This is the over-arching prime command: orient, then seed all three stores so every later session starts smart.
|
|
9
|
+
|
|
10
|
+
1. **Orient** — in 3-4 lines explain the loop: you *prime* from the harness (the system makes you search the wiki, and auto-loads the project's attached skills, anchors, and layer rules before you work), you *build*, then you *compound* (deposit what you learned). The more it's used, the less searching and the more stable the work. Drafted is navigated like a filesystem — one `fs` tool, three roots: `/wiki`, `/skills`, `/projects`.
|
|
11
|
+
2. **Name the org** — the org is an address, not a cursor: address wiki/skills with `org=...` on the call, and projects self-derive their org from the path. Never rely on an "active" org: a project-less create by a multi-org user is refused rather than guessed.
|
|
12
|
+
3. **Seed knowledge** — run the `/drafted:ingest` flow: help the user point at existing business materials (folders, docs, past research) or interrogate them for tacit knowledge, and land durable pages in the wiki.
|
|
13
|
+
4. **Seed procedures** — from those materials and the conversation, surface 1-3 candidate SOPs. For the most valuable, run the `/drafted:create-skill` flow.
|
|
14
|
+
5. **Seed the surface** — run the `/drafted:create-project` flow for the user's immediate piece of work (or a reusable template).
|
|
15
|
+
6. Close by showing what now exists (wiki pages, skills, project) and restate the one-line loop: prime → build → compound.
|
|
16
|
+
|
|
17
|
+
Keep it guided and conversational — one step at a time, confirming before each deposit. Don't dump everything at once.
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: drafted
|
|
3
|
+
description: Use the Drafted producibles harness — a compounding workspace that uplifts any AI across three primitives: knowledge (the org wiki), procedures (skills), and the project surface (frames on a shared real-time canvas). Drafted is navigated like a filesystem — one fs tool, one path grammar, org first: `fs(ls, path="/")` lists your orgs, then `/o/<org>/<root>/...`. Prime from the harness before working, build durable artifacts as frames instead of burying output in chat, and deposit what you learned back so the next session starts smarter. When Google Drive is connected, prefer Google Workspace frames for docs, sheets, and slides.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Drafted — a producibles harness that compounds
|
|
7
|
+
|
|
8
|
+
Drafted makes any AI more effective by giving it a memory and a workspace that get better with use. It has three primitives that layer:
|
|
9
|
+
|
|
10
|
+
- **Knowledge — the org wiki.** Durable facts, decisions, conventions. *More knowledge = less searching.*
|
|
11
|
+
- **Procedures — skills.** Reusable SOPs the org has encoded. *Better skills = more stable process.*
|
|
12
|
+
- **Surface — projects.** Reviewable work as frames on a shared zoomable canvas the user watches live at `https://drafted.live`. *Reusable projects + anchors = velocity with accuracy.*
|
|
13
|
+
|
|
14
|
+
The point is the **compounding loop**: you don't produce in a vacuum, you draw on what the org already knows and leave it richer each pass.
|
|
15
|
+
|
|
16
|
+
## The surface is a filesystem — one tool, one path grammar
|
|
17
|
+
|
|
18
|
+
There is exactly **one** tool to know: `fs`. It looks, feels, and behaves like a local filesystem, and an **org is the top folder** — you address an org, you never switch to it:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
fs(ls, path="/") → the orgs you belong to
|
|
22
|
+
fs(ls, path="/o/<org>") → that org's wiki · skills · projects
|
|
23
|
+
/o/<org>/wiki/<path> org knowledge pages (markdown, free nesting)
|
|
24
|
+
/o/<org>/skills/<slug> reusable procedures (flat: one dir per skill)
|
|
25
|
+
/o/<org>/projects/<project>/<layer>/<lane>/<file> frames on the surface (project → layer → lane → file)
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Verbs: `ls` (list) · `read` (cat, hashline-annotated) · `write` (create/overwrite) · `edit` (hashline ops) · `mv` (rename/move) · `rm` (move to archive) · `mkdir` (create a project) · `search` (grep).
|
|
29
|
+
|
|
30
|
+
**A project is a folder.** It starts with **no layers** — writing to `/o/<org>/projects/<name>/<layer>/...` auto-creates the layer (mkdir -p). The agent defines the filesystem structure by writing into it. URLs are the same paths: `https://drafted.live/o/<org>/projects/<project>/<layer>/<lane>/<file>` — the URL's pathname IS the fs path (one canonical link for both humans and agents). Bare `/wiki`, `/skills`, `/projects` roots still work via the session's working org.
|
|
31
|
+
|
|
32
|
+
## The loop — prime → build → compound
|
|
33
|
+
|
|
34
|
+
- **Prime (session start).** Pull accumulated value in. The system *enforces* this: you must search the wiki before working (G1), and a project's attached skills and anchored frames are required reading when you open it. Don't fight the gates — they make you start smart.
|
|
35
|
+
- **Build (the work).** Produce artifacts as frames on the surface.
|
|
36
|
+
- **Compound (session end / when you notice something).** Deposit learning back: capture knowledge, distill or fix a skill, harden the project. This is *your* responsibility — the system can't force it, so do it.
|
|
37
|
+
|
|
38
|
+
## The gates you'll encounter (and how to satisfy them)
|
|
39
|
+
|
|
40
|
+
These reset every session. A gate that blocks you tells you exactly what to call next — do it, don't work around it.
|
|
41
|
+
|
|
42
|
+
- **G1 — wiki search before work.** Before reading or editing anything, `fs(search, path="/o/<org>/wiki", query="<terms>")` for relevant org knowledge.
|
|
43
|
+
- **G2 — prior-art before a new skill.** Writing a new `/o/<org>/skills/<slug>` requires `fs(search, path="/o/<org>/skills", query="<topic>")` first.
|
|
44
|
+
- **G3 — prior-art before a new project.** Creating a project requires wiki + skill searches first.
|
|
45
|
+
- **G4 — attached skills** are auto-injected when you open a project. Follow them — they're how the org does this work.
|
|
46
|
+
- **G5 — the project's anchored frames** are required reading (briefs, constraints, style guides).
|
|
47
|
+
- **G6 — a layer's rules** are surfaced when you work in that layer. Honor them.
|
|
48
|
+
|
|
49
|
+
## The commands (when to reach for each)
|
|
50
|
+
|
|
51
|
+
These bookend the loop. Prime/feed at the start, deposit at the end.
|
|
52
|
+
|
|
53
|
+
- `/drafted:onboard-drafted` — first run: orient + bootstrap the harness (seed wiki, starter skills, first project).
|
|
54
|
+
- `/drafted:ingest` — bring knowledge into the wiki (a research output, existing documents, or by interrogating the user).
|
|
55
|
+
- `/drafted:create-skill` — capture a repeatable procedure (with knowledge + prior-art search).
|
|
56
|
+
- `/drafted:create-project` — start a project or a reusable template (with knowledge/skill/template search).
|
|
57
|
+
- `/drafted:improve-wiki` — fix stale / wrong / fragmented knowledge.
|
|
58
|
+
- `/drafted:improve-skill` — fix a skill that underperformed.
|
|
59
|
+
- `/drafted:improve-project-harness` — turn corrections into enforced gates (anchors / attached skills / layer rules).
|
|
60
|
+
- `/drafted:extract` — session-end: deposit knowledge, a skill, and/or a template (the user picks which).
|
|
61
|
+
|
|
62
|
+
## Working on the surface
|
|
63
|
+
|
|
64
|
+
- **Navigate by path, exactly like a filesystem.** `fs(ls, path="/")` to see your orgs; `fs(ls, path="/o/<org>/projects")` to see a project list; `fs(ls, path="/o/<org>/projects/<project>")` for layers/lanes; `fs(ls, path="/o/<org>/projects/<project>/<layer>")` for frames. The project is resolved from the path itself — no separate "open" step.
|
|
65
|
+
- **Create a project with `fs(mkdir, path="/o/<org>/projects/<name>")`** — or just `fs(write, path="/o/<org>/projects/<name>/<layer>/<lane>/<file>", content=...)` and the project + layer auto-create in the addressed org.
|
|
66
|
+
- **Default to the surface for substantive artifacts.** When asked to draft, write, plan, analyze, compare, design, document, summarize, report, spec, model, or make a deck/table, create or update frames instead of leaving the durable result only in chat. One visible frame per artifact or section.
|
|
67
|
+
- **Read before editing.** `fs(read)` returns every line hashline-annotated (`1abc|<content>`); `fs(edit, ops=[{type:"replace", lineHash:"1abc", newContent:"..."}])` targets exactly that line. For partial reads, pass `lines: "2-50"` — you get back just that range, still hash-annotated, and can edit within it.
|
|
68
|
+
- **Prefer Google Workspace when Drive is connected.** Use `fs(write, path=".../<name>.google-doc"|".google-sheet"|".google-slide")` for docs, sheets, and decks; populate immediately with the matching native write action.
|
|
69
|
+
- **`fs(mv, from="/o/<org>/projects/<p>/<layer>/<lane>/<file>", to="...")`** renames or moves (cross-project too). **`fs(rm, path="/o/<org>/projects/<project>")` archives** — agents never hard-delete; the archive is in the web UI.
|
|
70
|
+
- **Return a clickable link** for what you touched — the `frameUrl`/`projectUrl` in the fs response is the URL the user opens.
|
|
71
|
+
|
|
72
|
+
## Quality conventions
|
|
73
|
+
|
|
74
|
+
- **Match format to layer intent.** Research/strategy/copy are usually markdown; visual work (wireframes, designs, dashboards) is HTML.
|
|
75
|
+
- **Wireframes are low-fidelity** (grayscale, placeholders); reserve color and real content for the designs/final layer.
|
|
76
|
+
- **Choose dimensions to fit content** — `autoSize: true` for HTML, or explicit `width`/`height`.
|
|
77
|
+
- **Don't re-read unchanged frames** you already have this conversation.
|
|
78
|
+
- **Diagrams: prefer native Excalidraw.** For flowcharts, process maps, architecture/system/data-flow diagrams, or visual maps, write a `.excalidraw` file (scene JSON) and load the `excalidraw-drafted` skill for the authoring guidance. Use HTML/markdown frames for web/UI mockups, rich layouts, or non-editable artifacts.
|
|
79
|
+
|
|
80
|
+
## Skill authoring
|
|
81
|
+
|
|
82
|
+
- **Git is the source of truth for skills; Drafted renders, indexes, and searches.** When a repo is connected to a folder, the skills under `.agents/skills/` in that repo ARE the skills for that folder — authoring happens by committing to git, not by writing into Drafted. Drafted write paths for a repo-owned skill return `409 repo_owned` naming the repo, branch, and path to commit to. Use `repo(action="rescan")` after a push so the index reflects the change.
|
|
83
|
+
- **For a folder with NO connected repo, author skills in Drafted** with `fs(write, path="/o/<org>/skills/<slug>", content=..., readme=...)` (G2 gate first). A `README.md` is required (the `readme` param) so the skill directory is self-documenting. `fs(mv, path="/o/<org>/skills/<old>", to="/o/<org>/skills/<new>")` renames.
|
|
84
|
+
- **Knowledge goes in the wiki, procedure in skills.** Drafted owns org knowledge (wiki pages via `fs` under `/o/<org>/wiki/`); git owns reusable procedure (skills under `.agents/skills/`). Don't put a skill body in a wiki page or a knowledge doc in a SKILL.md.
|
|
85
|
+
- **Machine-specific build output is never portable.** Build `node_modules`, downloaded browsers, compiled binaries into a `.skillinstall/` directory inside the skill — Drafted always strips it on push and skill push auto-gitignores it, so the rebuildable bundle stays local while the method and recipe live in git/Drafted.
|
|
86
|
+
- **Improve skills when you find a better way.** Fix or distill a skill that underperformed rather than leaving it stale.
|
|
87
|
+
|
|
88
|
+
## Surface URL recognition
|
|
89
|
+
|
|
90
|
+
Any URL containing `/f/{uuid}` is a Drafted frame link — `fs(read, path=URL)` gets its content. Canonical links are the fs paths themselves: `/o/<org>/wiki/...`, `/o/<org>/skills/<slug>`, `/o/<org>/projects/<project>/<layer>/<lane>/<file>`. Never `WebFetch` Drafted URLs — the MCP tools authenticate properly.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
export const UMAMI_EVENTS = Object.freeze({
|
|
2
|
+
SIGNUP_STARTED: 'signup_started',
|
|
3
|
+
SIGNUP_COMPLETED: 'signup_completed',
|
|
4
|
+
ORG_CREATED: 'org_created',
|
|
5
|
+
PROJECT_CREATED: 'project_created',
|
|
6
|
+
PROJECT_OPENED: 'project_opened',
|
|
7
|
+
TEMPLATE_USED: 'template_used',
|
|
8
|
+
FRAME_CREATED: 'frame_created',
|
|
9
|
+
FRAME_UPDATED: 'frame_updated',
|
|
10
|
+
FRAME_DELETED: 'frame_deleted',
|
|
11
|
+
SHARE_CREATED: 'share_created',
|
|
12
|
+
SHARE_ACCEPTED: 'share_accepted',
|
|
13
|
+
INVITE_SENT: 'invite_sent',
|
|
14
|
+
MCP_CONNECTED: 'mcp_connected',
|
|
15
|
+
MCP_TOOL_CALLED: 'mcp_tool_called',
|
|
16
|
+
WIKI_PAGE_CREATED: 'wiki_page_created',
|
|
17
|
+
SKILL_ATTACHED: 'skill_attached',
|
|
18
|
+
DRIVE_CONNECTED: 'drive_connected',
|
|
19
|
+
DRIVE_SYNC_COMPLETED: 'drive_sync_completed',
|
|
20
|
+
DRAFTED_INSTALL: 'drafted_install',
|
|
21
|
+
DRAFTED_UPDATE: 'drafted_update',
|
|
22
|
+
DRAFTED_HEARTBEAT: 'drafted_heartbeat',
|
|
23
|
+
DRAFTED_MCP_CONFIGURED: 'drafted_mcp_configured',
|
|
24
|
+
DRAFTED_MCP_REQUEST: 'drafted_mcp_request',
|
|
25
|
+
DRAFTED_MCP_ERROR: 'drafted_mcp_error',
|
|
26
|
+
DRAFTED_UPDATE_HELPER_STARTED: 'drafted_update_helper_started',
|
|
27
|
+
DRAFTED_UPDATE_HELPER_FAILED: 'drafted_update_helper_failed',
|
|
28
|
+
CTA_CLICKED: 'cta_clicked',
|
|
29
|
+
TEMPLATE_SELECTED: 'template_selected',
|
|
30
|
+
PROJECT_NAVIGATED: 'project_navigated',
|
|
31
|
+
SHARE_MODAL_OPENED: 'share_modal_opened',
|
|
32
|
+
SHARE_MODAL_ACTION: 'share_modal_action',
|
|
33
|
+
SURFACE_LOADED: 'surface_loaded',
|
|
34
|
+
SIDEBAR_USED: 'sidebar_used',
|
|
35
|
+
TOOL_PANEL_USED: 'tool_panel_used',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const BLOCKED_KEYS = new Set([
|
|
39
|
+
'email', 'userEmail', 'name', 'userName', 'orgName', 'projectName', 'label', 'title',
|
|
40
|
+
'content', 'html', 'markdown', 'body', 'prompt', 'token', 'cookie', 'authorization',
|
|
41
|
+
'password', 'secret', 'apiKey', 'request', 'headers', 'file', 'buffer', 'dataUrl',
|
|
42
|
+
]);
|
|
43
|
+
const ID_KEYS = new Set(['userId', 'orgId', 'projectId', 'projectSlug', 'frameId', 'shareId', 'skillId', 'templateId', 'templateSlug', 'userRole', 'role', 'buildId', 'pageType', 'tool', 'action', 'source', 'layer', 'lane', 'mode', 'installId', 'schemaVersion', 'installerVersion', 'cliVersion', 'osFamily', 'osVersion', 'arch', 'nodeVersion', 'npmVersion', 'claudeDesktop', 'claudeCode', 'codex', 'cursor', 'updateHelperStatus', 'mcpMode', 'errorCode', 'matchKey']);
|
|
44
|
+
|
|
45
|
+
export function getUmamiConfig(config = {}) {
|
|
46
|
+
const hostUrl = (config.umamiHostUrl || process.env.UMAMI_HOST_URL || '').replace(/\/$/, '');
|
|
47
|
+
const websiteId = config.umamiWebsiteId || process.env.UMAMI_WEBSITE_ID || '';
|
|
48
|
+
return { hostUrl, websiteId, enabled: Boolean(hostUrl && websiteId) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function sanitizeUmamiEventData(data = {}) {
|
|
52
|
+
const output = {};
|
|
53
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return output;
|
|
54
|
+
for (const [key, value] of Object.entries(data)) {
|
|
55
|
+
if (BLOCKED_KEYS.has(key)) continue;
|
|
56
|
+
if (value === undefined || value === null) continue;
|
|
57
|
+
if (typeof value === 'object') continue;
|
|
58
|
+
if (!ID_KEYS.has(key) && /email|name|content|html|markdown|body|prompt|token|cookie|auth|secret|password|key|file/i.test(key)) continue;
|
|
59
|
+
if (typeof value === 'string') {
|
|
60
|
+
if (value.length > 120) continue;
|
|
61
|
+
if (/@/.test(value)) continue;
|
|
62
|
+
output[key] = value;
|
|
63
|
+
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
|
64
|
+
output[key] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return output;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function getUmamiHead(config = {}) {
|
|
71
|
+
const umami = getUmamiConfig(config);
|
|
72
|
+
if (!umami.enabled) return '';
|
|
73
|
+
const ctx = sanitizeUmamiEventData({
|
|
74
|
+
userId: config.userId,
|
|
75
|
+
orgId: config.orgId,
|
|
76
|
+
projectId: config.projectId,
|
|
77
|
+
projectSlug: config.projectSlug,
|
|
78
|
+
userRole: config.userRole,
|
|
79
|
+
buildId: config.buildId,
|
|
80
|
+
pageType: config.pageType,
|
|
81
|
+
});
|
|
82
|
+
return `<script defer src="${umami.hostUrl}/script.js" data-website-id="${umami.websiteId}"></script>
|
|
83
|
+
<script>
|
|
84
|
+
window.__DRAFTED_ANALYTICS__ = ${JSON.stringify(ctx)};
|
|
85
|
+
window.draftedTrack = function(name, data) {
|
|
86
|
+
var base = window.__DRAFTED_ANALYTICS__ || {};
|
|
87
|
+
var payload = Object.assign({}, base, data || {});
|
|
88
|
+
if (window.umami && typeof window.umami.track === 'function') window.umami.track(name, payload);
|
|
89
|
+
};
|
|
90
|
+
window.addEventListener('load', function() {
|
|
91
|
+
var c = window.__DRAFTED_ANALYTICS__ || {};
|
|
92
|
+
if (c.userId && window.umami && typeof window.umami.identify === 'function') window.umami.identify(c.userId, { orgId: c.orgId, role: c.userRole });
|
|
93
|
+
});
|
|
94
|
+
</script>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function getUmamiInteractionScript(pageType) {
|
|
98
|
+
return `<script>
|
|
99
|
+
(function() {
|
|
100
|
+
function track(name, data) { if (window.draftedTrack) window.draftedTrack(name, Object.assign({ pageType: ${JSON.stringify(pageType)} }, data || {})); }
|
|
101
|
+
window.addEventListener('load', function() {
|
|
102
|
+
if (${JSON.stringify(pageType)} === 'surface') track('surface_loaded');
|
|
103
|
+
});
|
|
104
|
+
document.addEventListener('click', function(event) {
|
|
105
|
+
var el = event.target && event.target.closest && event.target.closest('a,button,[data-analytics-event],[data-template-slug],[data-project-id]');
|
|
106
|
+
if (!el) return;
|
|
107
|
+
var eventName = el.getAttribute('data-analytics-event');
|
|
108
|
+
if (eventName) return track(eventName, { action: el.getAttribute('data-analytics-action') || undefined });
|
|
109
|
+
var href = el.getAttribute('href') || '';
|
|
110
|
+
var cls = el.className || '';
|
|
111
|
+
if (el.matches('.nav-cta,.btn-primary,.btn-secondary')) track('cta_clicked', { action: href || (el.textContent || '').trim().slice(0, 40) });
|
|
112
|
+
if (el.getAttribute('data-template-slug')) track('template_selected', { templateSlug: el.getAttribute('data-template-slug') });
|
|
113
|
+
if (el.getAttribute('data-project-id')) track('project_navigated', { projectId: el.getAttribute('data-project-id') });
|
|
114
|
+
if (/share/i.test(String(cls)) || /share/i.test(el.textContent || '')) track('share_modal_action');
|
|
115
|
+
if (/sidebar/i.test(String(cls))) track('sidebar_used');
|
|
116
|
+
if (/tool|panel/i.test(String(cls))) track('tool_panel_used');
|
|
117
|
+
}, true);
|
|
118
|
+
})();
|
|
119
|
+
</script>`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getRequestUrl(req) {
|
|
123
|
+
if (!req) return undefined;
|
|
124
|
+
const host = typeof req.get === 'function' ? req.get('host') : req.headers?.host;
|
|
125
|
+
const protocol = req.protocol || 'https';
|
|
126
|
+
return host ? `${protocol}://${host}${req.originalUrl || req.url || ''}` : undefined;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function trackUmamiEvent(name, data = {}, req = null) {
|
|
130
|
+
const umami = getUmamiConfig();
|
|
131
|
+
if (!umami.enabled || !name || name.length > 50) return;
|
|
132
|
+
// Account de-dupe: when the event carries a userId, send it as Umami's distinct id
|
|
133
|
+
// (`payload.id`). Umami then derives sessionId = uuid(websiteId, userId) with no IP/UA/date
|
|
134
|
+
// salt, so server-side events land in the SAME canonical session as the browser's
|
|
135
|
+
// umami.identify(userId) call — collapsing each account to one session across devices/time.
|
|
136
|
+
const distinctId = (typeof data.userId === 'string' && data.userId.length <= 64) ? data.userId : undefined;
|
|
137
|
+
const payload = {
|
|
138
|
+
type: 'event',
|
|
139
|
+
payload: {
|
|
140
|
+
website: umami.websiteId,
|
|
141
|
+
name,
|
|
142
|
+
...(distinctId ? { id: distinctId } : {}),
|
|
143
|
+
url: getRequestUrl(req) || process.env.BASE_URL || 'https://drafted.live',
|
|
144
|
+
hostname: req?.hostname || 'drafted.live',
|
|
145
|
+
language: req?.headers?.['accept-language'],
|
|
146
|
+
referrer: req?.headers?.referer,
|
|
147
|
+
data: sanitizeUmamiEventData(data),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
const controller = new AbortController();
|
|
151
|
+
const timeout = setTimeout(() => controller.abort(), 1500);
|
|
152
|
+
fetch(`${umami.hostUrl}/api/send`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: {
|
|
155
|
+
'Content-Type': 'application/json',
|
|
156
|
+
'User-Agent': req?.headers?.['user-agent'] || 'Drafted Server Analytics',
|
|
157
|
+
'X-Forwarded-For': req?.ip || req?.headers?.['x-forwarded-for'] || '',
|
|
158
|
+
},
|
|
159
|
+
body: JSON.stringify(payload),
|
|
160
|
+
signal: controller.signal,
|
|
161
|
+
}).catch(() => {}).finally(() => clearTimeout(timeout));
|
|
162
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
export const EXCALIDRAW_MIME = 'application/vnd.excalidraw+json';
|
|
2
|
+
|
|
3
|
+
// Linear elements (arrow/line/freedraw) REQUIRE a `points` array — Excalidraw's scene
|
|
4
|
+
// restore reads points.length and crashes ("Cannot read properties of undefined (reading
|
|
5
|
+
// 'length')") → eternal "Loading scene" spinner. Hand-authored/agent-written scenes
|
|
6
|
+
// routinely omit it (source: "https://excalidraw.com", ids like "el1"), so repair it
|
|
7
|
+
// here: every read (serve) and write path funnels through normalizeExcalidrawScene, so
|
|
8
|
+
// existing broken frames open and new bad writes are fixed before they land.
|
|
9
|
+
const LINEAR_ELEMENT_TYPES = new Set(['arrow', 'line', 'freedraw']);
|
|
10
|
+
|
|
11
|
+
function repairLinearElement(el) {
|
|
12
|
+
if (!LINEAR_ELEMENT_TYPES.has(el.type)) return el;
|
|
13
|
+
const pts = el.points;
|
|
14
|
+
const valid = Array.isArray(pts) && pts.length > 0 &&
|
|
15
|
+
pts.every((p) => Array.isArray(p) && p.length >= 2 && typeof p[0] === 'number' && typeof p[1] === 'number');
|
|
16
|
+
if (valid) return el;
|
|
17
|
+
const w = typeof el.width === 'number' && el.width > 0 ? el.width : 1;
|
|
18
|
+
const h = typeof el.height === 'number' && el.height > 0 ? el.height : 0;
|
|
19
|
+
return { ...el, points: [[0, 0], [w, h]] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function emptyExcalidrawScene() {
|
|
23
|
+
return {
|
|
24
|
+
type: 'excalidraw',
|
|
25
|
+
version: 2,
|
|
26
|
+
source: 'https://drafted.live',
|
|
27
|
+
elements: [],
|
|
28
|
+
appState: {},
|
|
29
|
+
files: {},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeExcalidrawScene(input) {
|
|
34
|
+
const scene = typeof input === 'string' ? JSON.parse(input) : input;
|
|
35
|
+
if (!scene || typeof scene !== 'object' || Array.isArray(scene)) {
|
|
36
|
+
throw new Error('Excalidraw data must be an object');
|
|
37
|
+
}
|
|
38
|
+
if (!Array.isArray(scene.elements)) {
|
|
39
|
+
throw new Error('Excalidraw data must include elements array');
|
|
40
|
+
}
|
|
41
|
+
const appState = scene.appState && typeof scene.appState === 'object' && !Array.isArray(scene.appState)
|
|
42
|
+
? scene.appState
|
|
43
|
+
: {};
|
|
44
|
+
const files = scene.files && typeof scene.files === 'object' && !Array.isArray(scene.files)
|
|
45
|
+
? scene.files
|
|
46
|
+
: {};
|
|
47
|
+
return {
|
|
48
|
+
type: scene.type || 'excalidraw',
|
|
49
|
+
version: scene.version || 2,
|
|
50
|
+
source: scene.source || 'https://drafted.live',
|
|
51
|
+
elements: scene.elements.map(repairLinearElement).filter(Boolean),
|
|
52
|
+
appState,
|
|
53
|
+
files,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function stringifyExcalidrawScene(input) {
|
|
58
|
+
return JSON.stringify(normalizeExcalidrawScene(input), null, 2);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Element-level merge for surgical edits: upsert `upserts` into the stored scene
|
|
62
|
+
// by element id (shallow-merge onto an existing element so a partial {id,x,y}
|
|
63
|
+
// moves it without dropping its other props), and drop every id in `removeIds`.
|
|
64
|
+
// Existing element order is preserved (z-order = array order); updated elements
|
|
65
|
+
// keep their position, new ones append. This is the accumulate/merge path that
|
|
66
|
+
// write_excalidraw (full replace) is not — it lets a large scene be edited with
|
|
67
|
+
// a small payload instead of resending the whole thing.
|
|
68
|
+
export function mergeExcalidrawElements(existing, upserts = [], removeIds = []) {
|
|
69
|
+
const scene = normalizeExcalidrawScene(existing);
|
|
70
|
+
const removeSet = new Set(Array.isArray(removeIds) ? removeIds : []);
|
|
71
|
+
const byId = new Map();
|
|
72
|
+
for (const el of scene.elements) {
|
|
73
|
+
if (!el || removeSet.has(el.id)) continue;
|
|
74
|
+
byId.set(el.id, el);
|
|
75
|
+
}
|
|
76
|
+
for (const el of (Array.isArray(upserts) ? upserts : [])) {
|
|
77
|
+
if (!el || typeof el !== 'object' || Array.isArray(el) || !el.id) {
|
|
78
|
+
throw new Error('Each element to upsert must be an object with an id');
|
|
79
|
+
}
|
|
80
|
+
if (removeSet.has(el.id)) continue;
|
|
81
|
+
byId.set(el.id, byId.has(el.id) ? { ...byId.get(el.id), ...el } : el);
|
|
82
|
+
}
|
|
83
|
+
return { ...scene, elements: Array.from(byId.values()) };
|
|
84
|
+
}
|