@abraca/mcp 1.6.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -3,15 +3,23 @@
3
3
  * Abracadabra MCP Server — entry point.
4
4
  *
5
5
  * Environment variables:
6
- * ABRA_URL (required) — Server URL (e.g. http://localhost:1234)
6
+ * ABRA_URL (required) — Server URL (e.g. http://localhost:1234)
7
7
  * ABRA_AGENT_NAME — Display name (default: "AI Assistant")
8
8
  * ABRA_AGENT_COLOR — HSL color for presence (default: "hsl(270, 80%, 60%)")
9
9
  * ABRA_INVITE_CODE — Invite code for first-run registration (grants role)
10
10
  * ABRA_KEY_FILE — Path to Ed25519 key file (default: ~/.abracadabra/agent.key)
11
+ * ABRA_AGENT_TRIGGER_MODE — When to respond in group chats:
12
+ * all → every message (legacy)
13
+ * mention → only when @<alias> is used
14
+ * task → only ai:task awareness events
15
+ * mention+task → mention OR ai:task (default)
16
+ * DMs always trigger regardless of mode.
17
+ * ABRA_AGENT_MENTION_ALIASES — Comma-separated aliases for @mentions
18
+ * (default: [ABRA_AGENT_NAME])
11
19
  */
12
20
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
13
21
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
14
- import { AbracadabraMCPServer } from './server.ts'
22
+ import { AbracadabraMCPServer, type TriggerMode } from './server.ts'
15
23
  import { registerTreeTools } from './tools/tree.ts'
16
24
  import { registerContentTools } from './tools/content.ts'
17
25
  import { registerMetaTools } from './tools/meta.ts'
@@ -33,6 +41,21 @@ async function main() {
33
41
  process.exit(1)
34
42
  }
35
43
 
44
+ // Parse trigger mode (defaults to mention+task)
45
+ const rawMode = (process.env.ABRA_AGENT_TRIGGER_MODE ?? 'mention+task').trim().toLowerCase()
46
+ const validModes: TriggerMode[] = ['all', 'mention', 'task', 'mention+task']
47
+ const triggerMode = (validModes as string[]).includes(rawMode)
48
+ ? (rawMode as TriggerMode)
49
+ : 'mention+task'
50
+ if (rawMode && !(validModes as string[]).includes(rawMode)) {
51
+ console.error(`[abracadabra-mcp] Invalid ABRA_AGENT_TRIGGER_MODE="${rawMode}", falling back to "mention+task"`)
52
+ }
53
+
54
+ const aliasEnv = process.env.ABRA_AGENT_MENTION_ALIASES
55
+ const mentionAliases: string[] | undefined = aliasEnv
56
+ ? aliasEnv.split(',').map((a: string) => a.trim()).filter((a: string) => a.length > 0)
57
+ : undefined
58
+
36
59
  // Create the Abracadabra connection manager
37
60
  const server = new AbracadabraMCPServer({
38
61
  url,
@@ -40,8 +63,12 @@ async function main() {
40
63
  agentColor: process.env.ABRA_AGENT_COLOR,
41
64
  inviteCode: process.env.ABRA_INVITE_CODE,
42
65
  keyFile: process.env.ABRA_KEY_FILE,
66
+ triggerMode,
67
+ mentionAliases,
43
68
  })
44
69
 
70
+ console.error(`[abracadabra-mcp] Trigger mode: ${triggerMode}; aliases: ${server.mentionAliases.join(', ')}`)
71
+
45
72
  // Create MCP server with channel capability
46
73
  const mcp = new McpServer(
47
74
  { name: 'abracadabra', version: '1.0.0' },
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Mention parsing for chat messages.
3
+ *
4
+ * Recognizes `@alias` tokens (case-insensitive, word-boundary) so the agent
5
+ * can decide whether a group-chat message is directed at it.
6
+ */
7
+
8
+ /** Escape regex metacharacters in an alias string. */
9
+ function escapeRegex(s: string): string {
10
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
11
+ }
12
+
13
+ /**
14
+ * Build a regex that matches `@<alias>` for any of the given aliases.
15
+ * Requires a non-word char (or start) before `@` and a word boundary after the alias
16
+ * so `@Claude` matches but `email@claudesomething` does not.
17
+ */
18
+ function buildMentionRegex(aliases: string[]): RegExp | null {
19
+ const cleaned = aliases.map(a => a.trim()).filter(a => a.length > 0)
20
+ if (cleaned.length === 0) return null
21
+ const alt = cleaned.map(escapeRegex).join('|')
22
+ return new RegExp(`(?:^|[^\\w@])@(?:${alt})\\b`, 'i')
23
+ }
24
+
25
+ /** Returns true if `text` contains `@alias` for any alias (case-insensitive). */
26
+ export function containsMention(text: string, aliases: string[]): boolean {
27
+ const re = buildMentionRegex(aliases)
28
+ if (!re) return false
29
+ return re.test(text)
30
+ }
31
+
32
+ /**
33
+ * Remove `@alias` tokens from the text. Leaves surrounding whitespace tidy so
34
+ * the cleaned prompt reads naturally (e.g. `"@Claude help"` → `"help"`).
35
+ */
36
+ export function stripMention(text: string, aliases: string[]): string {
37
+ const cleaned = aliases.map(a => a.trim()).filter(a => a.length > 0)
38
+ if (cleaned.length === 0) return text
39
+ const alt = cleaned.map(escapeRegex).join('|')
40
+ const re = new RegExp(`(^|\\s)@(?:${alt})\\b[,:]?\\s*`, 'gi')
41
+ return text.replace(re, (_m, lead) => (lead ? ' ' : '')).replace(/\s{2,}/g, ' ').trim()
42
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Static resource: AI agent guide for working with Abracadabra documents.
3
3
  */
4
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
4
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
5
 
6
6
  const AGENT_GUIDE = `# Abracadabra AI Agent Guide
7
7
 
@@ -47,24 +47,38 @@ If you are adding content to an existing space, call \`get_document_tree(rootId:
47
47
 
48
48
  ## Page Types Reference
49
49
 
50
+ ### Core Types (always available)
51
+
50
52
  | Type | Children Are | Grandchildren Are | Depth | Key Meta on Children |
51
53
  |------|-------------|-------------------|-------|---------------------|
52
54
  | **doc** | Sub-documents | Sub-sub-documents | ∞ | — |
53
55
  | **kanban** | Columns | Cards | 2 | color, icon on cards |
54
56
  | **table** | Columns | Cells (positional rows) | 2 | — |
55
- | **calendar** | Events | — | 1 | datetimeStart, datetimeEnd, allDay, color |
57
+ | **calendar** | Events | — | 1 | datetimeStart, datetimeEnd, allDay, color, icon |
56
58
  | **timeline** | Epics | Tasks | 2 | dateStart, dateEnd, taskProgress, color |
57
59
  | **checklist** | Tasks | Sub-tasks | ∞ | checked, priority, dateEnd |
58
60
  | **outline** | Items | Sub-items | ∞ | — |
59
- | **mindmap** | Central nodes | Branches | ∞ | mmX, mmY |
60
61
  | **graph** | Nodes | — | 1 | graphX, graphY, graphPinned, color |
61
- | **gallery** | Items | — | 1 | geoLat, geoLng, datetimeStart, tags |
62
+ | **gallery** | Items | — | 1 | geoLat, geoLng, datetimeStart, tags, rating, icon, color |
62
63
  | **map** | Markers/Lines | Points (for lines) | 2 | geoType, geoLat, geoLng, icon, color |
63
- | **dashboard** | Items | | 1 | deskX, deskY, deskMode |
64
- | **spatial** | Objects | Sub-parts | | spShape, spX/Y/Z, spColor, spOpacity |
65
- | **media** | Tracks | | 1 | tags |
66
- | **slides** | Slides | | 1 | |
67
- | **whiteboard** | Objects | — | 1 | wbX, wbY, wbW, wbH |
64
+ | **slides** | Slides | Sub-slides | 2 | slidesTransition, color |
65
+ | **dashboard** | Items | | 1 | deskX, deskY, deskZ, deskMode |
66
+ | **chart** | Data points | Data points | 2 | number (value), color, tags |
67
+ | **sheets** | Columns | Cells | 2 | formula, bold, italic, textColor, bgColor, align |
68
+ | **overview** | Pages | — | 1 | |
69
+ | **call** | — (no children) | — | 0 | — |
70
+
71
+ Alias: \`desktop\` → \`dashboard\`.
72
+
73
+ ### Plugin Types (require plugin enabled on the server)
74
+
75
+ | Type | Plugin | Children Are | Grandchildren Are | Depth | Key Meta on Children |
76
+ |------|--------|-------------|-------------------|-------|---------------------|
77
+ | **spatial** | spatial | Objects | Sub-parts | ∞ | spShape, spX/Y/Z, spRX/RY/RZ, spSX/SY/SZ, color, spOpacity |
78
+ | **media** | media | Tracks | — | ∞ | tags |
79
+ | **coder** | coder | Files/Folders | — | ∞ | fileType, entry |
80
+
81
+ > Spatial uses the universal \`color\` key for object color — there is no \`spColor\`.
68
82
 
69
83
  ---
70
84
 
@@ -137,18 +151,65 @@ If you are adding content to an existing space, call \`get_document_tree(rootId:
137
151
  4. Modes: \`"icon"\` (small), \`"widget-sm"\` (240×180), \`"widget-lg"\` (400×320)
138
152
  5. Grid uses 80px cells
139
153
 
140
- **Spatial (3D scene):**
154
+ **Spatial (3D scene)** *(requires spatial plugin):*
141
155
  1. \`create_document(parentId, "3D Scene", "spatial")\`
142
156
  2. Create objects: \`create_document(sceneId, "Red Cube")\`
143
- 3. Set 3D properties: \`update_metadata(objId, { spShape: "box", spColor: "#ef4444", spX: 0, spY: 1, spZ: 0, spSX: 2, spSY: 2, spSZ: 2 })\`
144
- 4. Shapes: \`"box"\`, \`"sphere"\`, \`"cylinder"\`, \`"cone"\`, \`"plane"\`, \`"torus"\`, \`"glb"\` (uploaded 3D model)
157
+ 3. Set 3D properties: \`update_metadata(objId, { spShape: "box", color: "#ef4444", spX: 0, spY: 1, spZ: 0, spSX: 2, spSY: 2, spSZ: 2 })\`
158
+ 4. Shapes: \`"box"\`, \`"sphere"\`, \`"cylinder"\`, \`"cone"\`, \`"plane"\`, \`"torus"\`, \`"glb"\` (uploaded 3D model — set \`spModelUploadId\` and \`spModelDocId\`)
145
159
  5. Rotation (degrees): \`spRX\`, \`spRY\`, \`spRZ\`. Scale: \`spSX\`, \`spSY\`, \`spSZ\` (default 1). Opacity: \`spOpacity\` (0–100)
160
+ 6. Use the universal \`color\` key — **never \`spColor\`**
146
161
 
147
162
  **Outline (nested items):**
148
163
  1. \`create_document(parentId, "Meeting Notes", "outline")\`
149
164
  2. Create items: \`create_document(outlineId, "Agenda Item 1")\`
150
165
  3. Create sub-items (unlimited depth): \`create_document(itemId, "Sub-point")\`
151
166
 
167
+ **Slides (presentation with two-axis nav):**
168
+ 1. \`create_document(parentId, "Q1 Review", "slides")\`
169
+ 2. Create slides as direct children: \`create_document(deckId, "Intro")\`
170
+ 3. Create sub-slides (vertical navigation) as grandchildren: \`create_document(slideId, "Deep dive")\`
171
+ 4. Per-slide transition: \`update_metadata(slideId, { slidesTransition: "fade", color: "#6366f1" })\`
172
+ 5. Deck-level theme: \`update_metadata(deckId, { slidesTheme: "dark" })\`
173
+
174
+ **Chart (data viz — manual or aggregation):**
175
+ 1. \`create_document(parentId, "Sales", "chart")\`
176
+ 2. Configure: \`update_metadata(chartId, { chartType: "bar", chartMetric: "value", chartShowLegend: true, chartLimit: 10 })\`
177
+ 3. Modes:
178
+ - **Manual data points**: create children with \`number\` (value) and optional \`tags\`/\`color\`
179
+ \`create_document(chartId, "Q1")\` then \`update_metadata(dpId, { number: 42500, color: "#6366f1" })\`
180
+ - **Aggregation**: set \`chartMetric\` to \`"type"\`/\`"tag"\`/\`"status"\`/\`"priority"\`/\`"activity"\`/\`"completion"\` and point the chart at a subtree — it aggregates the descendants' meta automatically
181
+ 4. Chart types: \`"bar"\`, \`"stacked bar"\`, \`"line"\`, \`"donut"\`, \`"treemap"\`
182
+ 5. Color schemes: \`"default"\`, \`"warm"\`, \`"cool"\`, \`"mono"\`
183
+
184
+ **Sheets (spreadsheet with formulas):**
185
+ 1. \`create_document(parentId, "Budget", "sheets")\`
186
+ 2. Create columns: \`create_document(sheetId, "A")\`, \`create_document(sheetId, "B")\`
187
+ 3. Create cells under columns (positional rows, like \`table\`)
188
+ 4. Formulas on cells: \`update_metadata(cellId, { formula: "=A1+B1" })\`
189
+ 5. Cell formatting: \`update_metadata(cellId, { bold: true, bgColor: "#fef3c7", align: "right" })\`
190
+ 6. Deck config: \`update_metadata(sheetId, { sheetsDefaultColWidth: 120, sheetsDefaultRowHeight: 28, sheetsShowGridlines: true, sheetsFreezeRows: 1, sheetsFreezeCols: 1 })\`
191
+
192
+ **Overview (space home):**
193
+ 1. \`create_document(parentId, "Home", "overview")\`
194
+ 2. No children required — renders activity, people, stats from the surrounding space
195
+ 3. Children (if any) show as linked pages
196
+
197
+ **Call (video room):**
198
+ 1. \`create_document(parentId, "Daily Standup", "call")\`
199
+ 2. Video rooms have **no children** — do not add documents underneath
200
+
201
+ **Coder (multi-file collaborative editor)** *(requires coder plugin):*
202
+ 1. \`create_document(parentId, "My App", "coder")\`
203
+ 2. Create files/folders: \`create_document(projectId, "App.vue")\`, \`create_document(projectId, "src")\`
204
+ 3. Set file type: \`update_metadata(fileId, { fileType: "vue", entry: true })\`
205
+ 4. \`fileType\` options: \`"vue"\`, \`"ts"\`, \`"js"\`, \`"css"\`, \`"json"\`, \`"folder"\`
206
+ 5. Mark the entry file with \`entry: true\` — the renderer uses it as the preview root
207
+
208
+ **Media (audio/video playlist)** *(requires media plugin):*
209
+ 1. \`create_document(parentId, "Focus Mix", "media")\`
210
+ 2. Create tracks: \`create_document(playlistId, "Track 1")\` — attach audio/video file via upload tool
211
+ 3. Playlist config: \`update_metadata(playlistId, { mediaRepeat: "all", mediaShuffle: false })\`
212
+
152
213
  ---
153
214
 
154
215
  ## Document References
@@ -223,22 +284,55 @@ In the **graph** page type, document references (embeds and links) create visibl
223
284
  |-----|------|-----------|--------|
224
285
  | \`kanbanColumnWidth\` | string | kanban | "narrow", "default", "wide" |
225
286
  | \`galleryColumns\` | number | gallery | 1–6 |
226
- | \`galleryAspect\` | string | gallery | "square", "4:3", "16:9" |
287
+ | \`galleryAspect\` | string | gallery | "square", "4:3", "3:2", "16:9", "free" |
288
+ | \`galleryCardStyle\` | string | gallery | "default", "compact", "detailed" |
289
+ | \`galleryShowLabels\` | boolean | gallery | show item labels |
290
+ | \`gallerySortBy\` | string | gallery | "manual", "date", "name", "rating" |
227
291
  | \`calendarView\` | string | calendar | "month", "week", "day" |
228
292
  | \`calendarWeekStart\` | string | calendar | "sun", "mon" |
293
+ | \`calendarShowWeekNumbers\` | boolean | calendar | — |
229
294
  | \`tableMode\` | string | table | "hierarchy", "flat" |
295
+ | \`tableSortKey\` | string | table | meta key to sort by |
296
+ | \`tableSortDir\` | string | table | "asc", "desc" |
297
+ | \`timelineZoom\` | string | timeline | "week", "month", "quarter" |
298
+ | \`timelinePixelsPerDay\` | number | timeline | zoom granularity |
299
+ | \`timelineCenterDate\` | string | timeline | ISO date to center view |
300
+ | \`checklistFilter\` | string | checklist | "all", "active", "completed" |
301
+ | \`checklistSort\` | string | checklist | "manual", "priority", "due" |
302
+ | \`mapShowLabels\` | boolean | map | — |
303
+ | \`graphSpacing\` | string | graph | "compact", "default", "spacious" |
304
+ | \`graphShowLabels\` | boolean | graph | — |
305
+ | \`graphEdgeThickness\` | string | graph | "thin", "normal", "thick" |
230
306
  | \`showRefEdges\` | boolean | graph | show doc-reference edges |
231
-
232
- ### Spatial 3D Keys (for spatial children)
307
+ | \`mmSpacing\` | string | mindmap-layout | spacing between branches |
308
+ | \`spatialGridVisible\` | boolean | spatial | show ground grid |
309
+ | \`slidesTheme\` | string | slides | "dark", "light" |
310
+ | \`chartType\` | string | chart | "bar", "stacked bar", "line", "donut", "treemap" |
311
+ | \`chartMetric\` | string | chart | "value", "type", "tag", "status", "priority", "activity", "completion" |
312
+ | \`chartColorScheme\` | string | chart | "default", "warm", "cool", "mono" |
313
+ | \`chartLimit\` | number | chart | 3–30 (max items) |
314
+ | \`chartShowLegend\` | boolean | chart | — |
315
+ | \`chartShowValues\` | boolean | chart | — |
316
+ | \`sheetsDefaultColWidth\` | number | sheets | 40–500 |
317
+ | \`sheetsDefaultRowHeight\` | number | sheets | 20–100 |
318
+ | \`sheetsShowGridlines\` | boolean | sheets | — |
319
+ | \`sheetsFreezeRows\` | number | sheets | frozen rows count |
320
+ | \`sheetsFreezeCols\` | number | sheets | frozen cols count |
321
+ | \`mediaRepeat\` | string | media | "off", "all", "one" (plugin) |
322
+ | \`mediaShuffle\` | boolean | media | plugin |
323
+
324
+ ### Spatial 3D Keys (for spatial children — requires spatial plugin)
233
325
 
234
326
  | Key | Type | Default | Meaning |
235
327
  |-----|------|---------|---------|
236
328
  | \`spShape\` | string | — | "box", "sphere", "cylinder", "cone", "plane", "torus", "glb" |
237
- | \`spColor\` | string | — | CSS color |
329
+ | \`color\` | string | — | CSS color — universal key, **not** \`spColor\` |
238
330
  | \`spOpacity\` | number | 100 | 0–100 |
239
331
  | \`spX\`, \`spY\`, \`spZ\` | number | 0 | Position |
240
332
  | \`spRX\`, \`spRY\`, \`spRZ\` | number | 0 | Rotation (degrees) |
241
333
  | \`spSX\`, \`spSY\`, \`spSZ\` | number | 1 | Scale |
334
+ | \`spModelUploadId\` | string | — | GLB upload ID (when \`spShape: "glb"\`) |
335
+ | \`spModelDocId\` | string | — | Doc ID that owns the GLB upload |
242
336
 
243
337
  ### Dashboard Keys (for dashboard children)
244
338
 
@@ -248,6 +342,28 @@ In the **graph** page type, document references (embeds and links) create visibl
248
342
  | \`deskZ\` | number | Z-index (layering) |
249
343
  | \`deskMode\` | string | "icon", "widget-sm" (240×180), "widget-lg" (400×320) |
250
344
 
345
+ ### Sheets Cell Formatting (per-cell meta)
346
+
347
+ | Key | Type | Meaning |
348
+ |-----|------|---------|
349
+ | \`formula\` | string | Cell formula (e.g. "=A1+B1") |
350
+ | \`bold\` | boolean | — |
351
+ | \`italic\` | boolean | — |
352
+ | \`textColor\` | string | CSS color |
353
+ | \`bgColor\` | string | CSS color |
354
+ | \`align\` | string | "left", "center", "right" |
355
+
356
+ ### Coder Keys (for coder children — plugin)
357
+
358
+ | Key | Type | Meaning |
359
+ |-----|------|---------|
360
+ | \`fileType\` | string | "vue", "ts", "js", "css", "json", "folder" |
361
+ | \`entry\` | boolean | Mark this file as the preview entry point |
362
+
363
+ ### Discovering What Metadata Applies
364
+
365
+ Use the \`list_page_types\` tool to enumerate all known page types and their declared \`metaSchema\` (fields that apply to children/descendants) and \`defaultMetaFields\` (renderer config fields on the page itself). It's the authoritative list of what meta keys make sense for any given page type.
366
+
251
367
  ---
252
368
 
253
369
  ## Content Structure
@@ -330,8 +446,11 @@ Always clear fields when done by setting them to \`null\`.
330
446
  | **Outline** | \`outline:editing\` | nodeId | Editing an outline node |
331
447
  | **Gallery** | \`gallery:focused\` | itemId | Item hovered/selected |
332
448
  | **Timeline** | \`timeline:focused\` | taskId | Task selected |
333
- | **Mindmap** | \`mindmap:focused\` | nodeId | Node selected/edited |
334
449
  | **Graph** | \`graph:focused\` | nodeId | Node hovered/selected |
450
+ | **Slides** | \`slides:current\` | slideId | Slide being presented |
451
+ | **Spatial** | \`spatial:selected\`, \`spatial:camera\` | objectId / camera state | plugin |
452
+ | **Media** | \`media:playing\`, \`media:position\` | trackId / 0–1 | plugin |
453
+ | **Coder** | \`coder:activeFile\` | fileId | plugin |
335
454
  | **Map** | \`map:focused\` | markerId | Marker hovered/selected |
336
455
  | **Doc** | \`doc:scroll\` | 0–1 number | Scroll position in document |
337
456
 
@@ -414,22 +533,25 @@ Always check and traverse the \`children\` array returned by \`read_document\`.*
414
533
  - Create top-level documents without first confirming the correct hub doc ID
415
534
  - Rename or write content to the space hub document itself
416
535
  - Set \`type\` on child items (cards, cells, events) — only set type on the parent page
417
- `
536
+ `;
418
537
 
419
538
  export function registerAgentGuide(mcp: McpServer) {
420
539
  mcp.resource(
421
- 'agent-guide',
422
- 'abracadabra://agent-guide',
540
+ "agent-guide",
541
+ "abracadabra://agent-guide",
423
542
  {
424
- description: 'Comprehensive guide for AI agents working with Abracadabra documents. Covers page types, tree operations, metadata, content structure, and best practices.',
425
- mimeType: 'text/markdown',
543
+ description:
544
+ "Comprehensive guide for AI agents working with Abracadabra documents. Covers page types, tree operations, metadata, content structure, and best practices.",
545
+ mimeType: "text/markdown",
426
546
  },
427
547
  async () => ({
428
- contents: [{
429
- uri: 'abracadabra://agent-guide',
430
- text: AGENT_GUIDE,
431
- mimeType: 'text/markdown',
432
- }],
433
- })
434
- )
548
+ contents: [
549
+ {
550
+ uri: "abracadabra://agent-guide",
551
+ text: AGENT_GUIDE,
552
+ mimeType: "text/markdown",
553
+ },
554
+ ],
555
+ }),
556
+ );
435
557
  }
package/src/server.ts CHANGED
@@ -12,6 +12,16 @@ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
12
12
  import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
13
13
  import { waitForSync } from './utils.ts'
14
14
  import { loadOrCreateKeypair, signChallenge } from './crypto.ts'
15
+ import { containsMention, stripMention } from './mentions.ts'
16
+
17
+ /**
18
+ * Controls when the agent reacts to incoming chat:
19
+ * - `all` — respond to every message in every visible channel (legacy)
20
+ * - `mention` — group chats require `@<alias>`; DMs always respond
21
+ * - `task` — ignore chat entirely; only respond to ai:task awareness events
22
+ * - `mention+task` — group chats require mention OR ai:task; DMs always respond (default)
23
+ */
24
+ export type TriggerMode = 'all' | 'mention' | 'task' | 'mention+task'
15
25
 
16
26
  export interface MCPServerConfig {
17
27
  url: string
@@ -19,6 +29,9 @@ export interface MCPServerConfig {
19
29
  agentColor?: string
20
30
  inviteCode?: string
21
31
  keyFile?: string
32
+ triggerMode?: TriggerMode
33
+ /** Aliases matched in `@<alias>` tokens. Defaults to `[agentName]`. */
34
+ mentionAliases?: string[]
22
35
  }
23
36
 
24
37
  interface SpaceConnection {
@@ -52,6 +65,9 @@ export class AbracadabraMCPServer {
52
65
  private _typingInterval: ReturnType<typeof setInterval> | null = null
53
66
  private _lastChatChannel: string | null = null
54
67
  private _signFn: ((challenge: string) => Promise<string>) | null = null
68
+ /** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
69
+ private _toolHistory: Array<{ tool: string; target?: string; ts: number; channel: string | null }> = []
70
+ private static readonly TOOL_HISTORY_MAX = 20
55
71
 
56
72
  constructor(config: MCPServerConfig) {
57
73
  this.config = config
@@ -69,6 +85,16 @@ export class AbracadabraMCPServer {
69
85
  return this.config.agentColor || 'hsl(270, 80%, 60%)'
70
86
  }
71
87
 
88
+ get triggerMode(): TriggerMode {
89
+ return this.config.triggerMode ?? 'mention+task'
90
+ }
91
+
92
+ get mentionAliases(): string[] {
93
+ const explicit = this.config.mentionAliases?.filter(a => a.trim().length > 0)
94
+ if (explicit && explicit.length > 0) return explicit
95
+ return [this.agentName]
96
+ }
97
+
72
98
  get serverInfo(): ServerInfo | null {
73
99
  return this._serverInfo
74
100
  }
@@ -121,7 +147,7 @@ export class AbracadabraMCPServer {
121
147
  throw err
122
148
  }
123
149
  }
124
- console.error(`[abracadabra-mcp] Authenticated as ${this.agentName} (${keypair.publicKeyB64.slice(0, 12)}...)`)
150
+ console.error(`[abracadabra-mcp] Authenticated as ${this.agentName} (pubkey=${keypair.publicKeyB64})`)
125
151
 
126
152
  // Step 3: Discover server info
127
153
  this._serverInfo = await this.client.serverInfo()
@@ -209,6 +235,8 @@ export class AbracadabraMCPServer {
209
235
  provider.awareness.setLocalStateField('status', null)
210
236
  provider.awareness.setLocalStateField('activeToolCall', null)
211
237
  provider.awareness.setLocalStateField('statusContext', null)
238
+ provider.awareness.setLocalStateField('turnId', null)
239
+ provider.awareness.setLocalStateField('toolHistory', [])
212
240
 
213
241
  const conn: SpaceConnection = { doc, provider, docId }
214
242
  this._spaceConnections.set(docId, conn)
@@ -249,6 +277,19 @@ export class AbracadabraMCPServer {
249
277
  return this._activeConnection?.doc.getMap('doc-trash') ?? null
250
278
  }
251
279
 
280
+ /** Get plugin names enabled in the active space via space-plugins Y.Map. */
281
+ getEnabledPluginNames(): string[] {
282
+ const doc = this._activeConnection?.doc
283
+ if (!doc) return []
284
+ const pluginsMap = doc.getMap('space-plugins')
285
+ const names: string[] = []
286
+ pluginsMap.forEach((value: any, key: string) => {
287
+ const entry = value?.toJSON ? value.toJSON() : value
288
+ if (entry?.enabled) names.push(key)
289
+ })
290
+ return names
291
+ }
292
+
252
293
  /**
253
294
  * Get or create a child provider for a given document ID.
254
295
  * Caches providers and waits for sync before returning.
@@ -346,6 +387,9 @@ export class AbracadabraMCPServer {
346
387
  private _observeRootAwareness(provider: AbracadabraProvider): void {
347
388
  const selfId = provider.awareness.clientID
348
389
  provider.awareness.on('change', () => {
390
+ // Strict `mention` mode ignores ai:task awareness; every other mode honors it.
391
+ if (this.triggerMode === 'mention') return
392
+
349
393
  const states = provider.awareness.getStates()
350
394
  for (const [clientId, state] of states) {
351
395
  if (clientId === selfId) continue
@@ -363,6 +407,7 @@ export class AbracadabraMCPServer {
363
407
  : 'Unknown'
364
408
 
365
409
  console.error(`[abracadabra-mcp] Handling ai:task id=${id} from ${senderName}: ${text.slice(0, 80)}`)
410
+ this._beginTurn()
366
411
  this.setAutoStatus('thinking')
367
412
  this._dispatchAiTask({
368
413
  id,
@@ -433,14 +478,44 @@ export class AbracadabraMCPServer {
433
478
 
434
479
  const channel = data.channel as string | undefined
435
480
  const docId = channel?.startsWith('group:') ? channel.slice(6) : ''
481
+ const isDM = channel?.startsWith('dm:') ?? false
482
+ const isGroup = channel?.startsWith('group:') ?? false
436
483
 
437
484
  // Only process DMs where the agent is a participant
438
- if (channel?.startsWith('dm:')) {
439
- const parts = channel.split(':')
485
+ if (isDM) {
486
+ const parts = channel!.split(':')
440
487
  if (parts.length === 3 && parts[1] !== this._userId && parts[2] !== this._userId) {
488
+ console.error(
489
+ `[abracadabra-mcp] Dropping DM: agent _userId=${this._userId} not in channel parts=[${parts[1]}, ${parts[2]}] — channel='${channel}'. ` +
490
+ `The dashboard's awareness likely points at a stale Claude identity.`,
491
+ )
492
+ return
493
+ }
494
+ }
495
+
496
+ // ── Trigger mode gate ─────────────────────────────────────────────
497
+ // DMs always dispatch (implicit summon). Groups obey the trigger mode.
498
+ const mode = this.triggerMode
499
+ const content = typeof data.content === 'string' ? data.content : ''
500
+ let dispatchContent = content
501
+
502
+ if (isGroup) {
503
+ if (mode === 'task') {
504
+ // Chat never triggers in task-only mode
441
505
  return
442
506
  }
507
+ if (mode === 'mention' || mode === 'mention+task') {
508
+ const aliases = this.mentionAliases
509
+ if (!containsMention(content, aliases)) {
510
+ console.error(`[abracadabra-mcp] skipped message on ${channel} — no @mention for ${aliases.join('|')}`)
511
+ return
512
+ }
513
+ // Strip the mention so Claude sees a clean prompt
514
+ dispatchContent = stripMention(content, aliases) || content
515
+ }
516
+ // mode === 'all' falls through unchanged
443
517
  }
518
+ // ──────────────────────────────────────────────────────────────────
444
519
 
445
520
  // Auto-mark channel as read so sender sees a read receipt
446
521
  if (channel) {
@@ -452,19 +527,22 @@ export class AbracadabraMCPServer {
452
527
  timestamp: Math.floor(Date.now() / 1000),
453
528
  }))
454
529
  }
455
- // Send immediate typing indicator and start periodic re-sends
530
+ // Remember the channel so setAutoStatus can scope statusContext to it.
531
+ // NOTE: no typing indicator here — "thinking" status + tool pills are
532
+ // the correct signal while Claude is working; typing is reserved for
533
+ // the actual send_chat_message burst below.
456
534
  this._lastChatChannel = channel
457
- this.sendTypingIndicator(channel)
458
- this._startTypingInterval(channel)
459
535
  }
460
536
 
461
- // Set status to "thinking" so dashboard shows AI is processing
537
+ // Mint a fresh turn id so the dashboard ties its incantation + tool
538
+ // trace to THIS turn, and set status to "thinking".
539
+ this._beginTurn()
462
540
  this.setAutoStatus('thinking')
463
541
 
464
542
  await this._serverRef.notification({
465
543
  method: 'notifications/claude/channel',
466
544
  params: {
467
- content: data.content ?? '',
545
+ content: dispatchContent,
468
546
  instructions: `You MUST use send_chat_message with channel="${channel ?? ''}" for ALL responses — both progress updates and final answers. The user CANNOT see plain text output; they only see messages sent via send_chat_message. When doing multi-step work, send brief status updates via send_chat_message (e.g. "Looking into that..." or "Found it, writing up results...") so the user knows you're working. Never output plain text as a substitute for send_chat_message.`,
469
547
  meta: {
470
548
  source: 'abracadabra',
@@ -505,23 +583,47 @@ export class AbracadabraMCPServer {
505
583
  const context = status ? (statusContext !== undefined ? statusContext : this._lastChatChannel) : null
506
584
  provider.awareness.setLocalStateField('statusContext', context ?? null)
507
585
 
508
- // When clearing status, also stop typing interval
586
+ // When clearing status this is the authoritative end-of-turn signal:
587
+ // drop the tool pill, the turn id, and the running tool history so the
588
+ // dashboard's incantation + activity trace all collapse in lockstep.
509
589
  if (!status) {
510
590
  this._stopTypingInterval()
591
+ provider.awareness.setLocalStateField('activeToolCall', null)
592
+ provider.awareness.setLocalStateField('turnId', null)
593
+ this._toolHistory = []
594
+ provider.awareness.setLocalStateField('toolHistory', [])
511
595
  }
512
596
 
513
- // Auto-clear status after 30s of no updates (generous timeout so status
514
- // persists visibly between consecutive tool calls instead of flickering)
597
+ // Auto-clear status after 10s of no updates. Short enough that a real
598
+ // idle is noticed quickly; long enough that normal inter-tool gaps
599
+ // (PostToolUse → next PreToolUse) don't flicker.
515
600
  if (status) {
516
601
  this._statusClearTimer = setTimeout(() => {
517
602
  provider.awareness.setLocalStateField('status', null)
518
603
  provider.awareness.setLocalStateField('activeToolCall', null)
519
604
  provider.awareness.setLocalStateField('statusContext', null)
605
+ provider.awareness.setLocalStateField('turnId', null)
606
+ this._toolHistory = []
607
+ provider.awareness.setLocalStateField('toolHistory', [])
520
608
  this._stopTypingInterval()
521
- }, 30_000)
609
+ }, 10_000)
522
610
  }
523
611
  }
524
612
 
613
+ /**
614
+ * Start a new agent turn. Mints a fresh UUID and writes it to awareness so
615
+ * the dashboard can gate the incantation on "there is an active turn",
616
+ * decoupled from the (racier) status field. Called from chat arrival and
617
+ * ai:task dispatch right before `setAutoStatus('thinking')`.
618
+ */
619
+ private _beginTurn(): void {
620
+ const provider = this._activeConnection?.provider
621
+ if (!provider) return
622
+ this._toolHistory = []
623
+ provider.awareness.setLocalStateField('toolHistory', [])
624
+ provider.awareness.setLocalStateField('turnId', crypto.randomUUID())
625
+ }
626
+
525
627
  /** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
526
628
  private _startTypingInterval(channel: string): void {
527
629
  this._stopTypingInterval()
@@ -540,10 +642,30 @@ export class AbracadabraMCPServer {
540
642
 
541
643
  /**
542
644
  * Broadcast which tool the agent is currently executing.
543
- * Dashboard renders this as a ChatTool indicator.
645
+ *
646
+ * Renders as a ChatTool pill on the dashboard. On non-null calls, the tool
647
+ * is also appended to `toolHistory` (capped at TOOL_HISTORY_MAX) and written
648
+ * to awareness so the dashboard's inline trace can show the turn's recent
649
+ * activity. Tools do NOT clear (`setActiveToolCall(null)`) on completion —
650
+ * the pill stays until the next tool replaces it or `setAutoStatus(null)`
651
+ * flushes the turn. This keeps pills visible long enough to see.
544
652
  */
545
653
  setActiveToolCall(toolCall: { name: string; target?: string } | null): void {
546
- this._activeConnection?.provider?.awareness.setLocalStateField('activeToolCall', toolCall)
654
+ const provider = this._activeConnection?.provider
655
+ if (!provider) return
656
+ provider.awareness.setLocalStateField('activeToolCall', toolCall)
657
+ if (toolCall) {
658
+ this._toolHistory.push({
659
+ tool: toolCall.name,
660
+ target: toolCall.target,
661
+ ts: Date.now(),
662
+ channel: this._lastChatChannel,
663
+ })
664
+ if (this._toolHistory.length > AbracadabraMCPServer.TOOL_HISTORY_MAX) {
665
+ this._toolHistory.splice(0, this._toolHistory.length - AbracadabraMCPServer.TOOL_HISTORY_MAX)
666
+ }
667
+ provider.awareness.setLocalStateField('toolHistory', [...this._toolHistory])
668
+ }
547
669
  }
548
670
 
549
671
  /**
@@ -581,8 +703,11 @@ export class AbracadabraMCPServer {
581
703
  conn.provider.awareness.setLocalStateField('status', null)
582
704
  conn.provider.awareness.setLocalStateField('activeToolCall', null)
583
705
  conn.provider.awareness.setLocalStateField('statusContext', null)
706
+ conn.provider.awareness.setLocalStateField('turnId', null)
707
+ conn.provider.awareness.setLocalStateField('toolHistory', [])
584
708
  conn.provider.destroy()
585
709
  }
710
+ this._toolHistory = []
586
711
  this._spaceConnections.clear()
587
712
  this._activeConnection = null
588
713