@abraca/mcp 2.5.0 → 2.7.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/src/server.ts CHANGED
@@ -5,22 +5,23 @@
5
5
  * by listing children of the server root. The first Space becomes the active
6
6
  * one; use switchSpace(docId) to change.
7
7
  */
8
- import * as Y from 'yjs'
8
+
9
+ import type { DocumentMeta, ServerInfo } from "@abraca/dabra";
9
10
  import {
10
- AbracadabraProvider,
11
- AbracadabraClient,
12
- Kind,
13
- SERVER_ROOT_ID,
14
- foldRecords,
15
- recordFromYAny,
16
- isEncryptedContent,
17
- } from '@abraca/dabra'
18
- import type { ServerInfo, DocumentMeta } from '@abraca/dabra'
19
- import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
20
- import type { Server } from '@modelcontextprotocol/sdk/server/index.js'
21
- import { waitForSync } from './utils.ts'
22
- import { loadOrCreateKeypair, signChallenge } from './crypto.ts'
23
- import { containsMention, stripMention } from './mentions.ts'
11
+ AbracadabraClient,
12
+ AbracadabraProvider,
13
+ foldRecords,
14
+ isEncryptedContent,
15
+ Kind,
16
+ recordFromYAny,
17
+ SERVER_ROOT_ID,
18
+ } from "@abraca/dabra";
19
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
20
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
21
+ import * as Y from "yjs";
22
+ import { loadOrCreateKeypair, signChallenge } from "./crypto.ts";
23
+ import { containsMention, stripMention } from "./mentions.ts";
24
+ import { waitForSync } from "./utils.ts";
24
25
 
25
26
  /**
26
27
  * Controls when the agent reacts to incoming chat:
@@ -29,941 +30,1160 @@ import { containsMention, stripMention } from './mentions.ts'
29
30
  * - `task` — ignore chat entirely; only respond to ai:task awareness events
30
31
  * - `mention+task` — group chats require mention OR ai:task; DMs always respond (default)
31
32
  */
32
- export type TriggerMode = 'all' | 'mention' | 'task' | 'mention+task'
33
+ export type TriggerMode = "all" | "mention" | "task" | "mention+task";
33
34
 
34
35
  export interface MCPServerConfig {
35
- url: string
36
- agentName?: string
37
- agentColor?: string
38
- inviteCode?: string
39
- keyFile?: string
40
- triggerMode?: TriggerMode
41
- /** Aliases matched in `@<alias>` tokens. Defaults to `[agentName]`. */
42
- mentionAliases?: string[]
36
+ url: string;
37
+ agentName?: string;
38
+ agentColor?: string;
39
+ inviteCode?: string;
40
+ keyFile?: string;
41
+ triggerMode?: TriggerMode;
42
+ /** Aliases matched in `@<alias>` tokens. Defaults to `[agentName]`. */
43
+ mentionAliases?: string[];
43
44
  }
44
45
 
45
46
  interface SpaceConnection {
46
- doc: Y.Doc
47
- provider: AbracadabraProvider
48
- docId: string
47
+ doc: Y.Doc;
48
+ provider: AbracadabraProvider;
49
+ docId: string;
49
50
  }
50
51
 
51
52
  interface CachedProvider {
52
- provider: AbracadabraProvider
53
- lastAccessed: number
53
+ provider: AbracadabraProvider;
54
+ lastAccessed: number;
54
55
  }
55
56
 
56
- const IDLE_TIMEOUT_MS = 5 * 60 * 1000 // 5 minutes
57
+ const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
57
58
 
58
59
  export class AbracadabraMCPServer {
59
- readonly config: MCPServerConfig
60
- readonly client: AbracadabraClient
61
- private _serverInfo: ServerInfo | null = null
62
- private _rootDocId: string | null = null
63
- private _spaces: DocumentMeta[] = []
64
- private _activeConnection: SpaceConnection | null = null
65
- private _spaceConnections = new Map<string, SpaceConnection>()
66
- private childCache = new Map<string, CachedProvider>()
67
- private evictionTimer: ReturnType<typeof setInterval> | null = null
68
- private _mcpServerRef: McpServer | null = null
69
- private _serverRef: Server | null = null
70
- private _handledTaskIds = new Set<string>()
71
- private _userId: string | null = null
72
- private _statusClearTimer: ReturnType<typeof setTimeout> | null = null
73
- private _typingInterval: ReturnType<typeof setInterval> | null = null
74
- private _lastChatChannel: string | null = null
75
- private _signFn: ((challenge: string) => Promise<string>) | null = null
76
- /** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
77
- private _toolHistory: Array<{ tool: string; target?: string; ts: number; channel: string | null }> = []
78
- private static readonly TOOL_HISTORY_MAX = 20
79
-
80
- // ── Inbox-driven dispatch ──────────────────────────────────────────────────
81
- // The server fans DMs + @mentions out as entries on this agent's per-user
82
- // `kind="inbox"` doc (server is the only writer). That is the *only*
83
- // recipient-correct signal for a DM the agent isn't subscribed to the
84
- // `messages:new_message` stateless broadcast only reaches subscribers of the
85
- // channel/DM doc, and the agent never subscribes to DM docs. So we observe
86
- // the inbox's `entries` Y.Array and dispatch from there.
87
- private _inboxStarted = false
88
- private _inboxDocId: string | null = null
89
- private _inboxDoc: Y.Doc | null = null
90
- private _inboxProvider: AbracadabraProvider | null = null
91
- private _inboxDisposers: Array<() => void> = []
92
- private _inboxInitialized = false
93
- private _seenInboxIds = new Set<string>()
94
- /** Shared message-id dedupe so the inbox path and `_handleStatelessChat`
95
- * (subscribed-doc broadcast) never dispatch the same message twice. */
96
- private _dispatchedMessageIds = new Set<string>()
97
- private static readonly DEDUPE_MAX = 1000
98
-
99
- constructor(config: MCPServerConfig) {
100
- this.config = config
101
- this.client = new AbracadabraClient({
102
- url: config.url,
103
- persistAuth: false,
104
- })
105
- }
106
-
107
- get agentName(): string {
108
- return this.config.agentName || 'AI Assistant'
109
- }
110
-
111
- get agentColor(): string {
112
- return this.config.agentColor || 'hsl(270, 80%, 60%)'
113
- }
114
-
115
- get triggerMode(): TriggerMode {
116
- return this.config.triggerMode ?? 'mention+task'
117
- }
118
-
119
- get mentionAliases(): string[] {
120
- const explicit = this.config.mentionAliases?.filter(a => a.trim().length > 0)
121
- if (explicit && explicit.length > 0) return explicit
122
- return [this.agentName]
123
- }
124
-
125
- get serverInfo(): ServerInfo | null {
126
- return this._serverInfo
127
- }
128
-
129
- get rootDocId(): string | null {
130
- return this._rootDocId
131
- }
132
-
133
- /**
134
- * Spaces visible to the caller — direct children of the server root with
135
- * `kind === "space"`. Populated by {@link connect}.
136
- */
137
- get spaces(): DocumentMeta[] {
138
- return this._spaces
139
- }
140
-
141
- get rootDocument(): Y.Doc | null {
142
- return this._activeConnection?.doc ?? null
143
- }
144
-
145
- get rootYProvider(): AbracadabraProvider | null {
146
- return this._activeConnection?.provider ?? null
147
- }
148
-
149
- get userId(): string | null {
150
- return this._userId
151
- }
152
-
153
- /** Start the server: authenticate with Ed25519 key, discover spaces/root doc, connect provider. */
154
- async connect(): Promise<void> {
155
- // Step 1: Load or generate Ed25519 keypair
156
- const keypair = await loadOrCreateKeypair(this.config.keyFile)
157
- this._userId = keypair.publicKeyB64
158
- const signFn = (challenge: string) => Promise.resolve(signChallenge(challenge, keypair.privateKey))
159
- this._signFn = signFn
160
-
161
- // Step 2: Authenticate via challenge-response (register on first run)
162
- try {
163
- await this.client.loginWithKey(keypair.publicKeyB64, signFn)
164
- } catch (err: any) {
165
- // Key not registered auto-register and retry.
166
- // Servers signal this with 404/422, or 401 + "public key not registered" / "user not found".
167
- const status = err?.status ?? err?.response?.status
168
- const msg = String(err?.message ?? '').toLowerCase()
169
- const notRegistered =
170
- status === 404 ||
171
- status === 422 ||
172
- (status === 401 && /not registered|user not found|no such user/.test(msg))
173
- if (notRegistered) {
174
- console.error('[abracadabra-mcp] Key not registered, creating new account...')
175
- await this.client.registerWithKey({
176
- publicKey: keypair.publicKeyB64,
177
- username: this.agentName.replace(/\s+/g, '-').toLowerCase(),
178
- displayName: this.agentName,
179
- deviceName: 'MCP Agent',
180
- inviteCode: this.config.inviteCode,
181
- })
182
- await this.client.loginWithKey(keypair.publicKeyB64, signFn)
183
- } else {
184
- throw err
185
- }
186
- }
187
- console.error(`[abracadabra-mcp] Authenticated as ${this.agentName} (pubkey=${keypair.publicKeyB64})`)
188
-
189
- // Step 3: Discover server info
190
- this._serverInfo = await this.client.serverInfo()
191
-
192
- // Step 4: Discover Spaces — top-level docs (children of the server root)
193
- // tagged with kind="space". The first Space is the default landing doc;
194
- // any other top-level doc serves as a fallback if no Spaces exist.
195
- const roots = await this.client.listChildren()
196
- this._spaces = roots.filter(d => d.kind === Kind.Space)
197
- const first = this._spaces[0] ?? roots[0]
198
- const initialDocId = first?.id ?? null
199
- if (first) {
200
- console.error(
201
- `[abracadabra-mcp] Active space: ${first.label ?? first.id} (${first.id})`,
202
- )
203
- }
204
-
205
- if (!initialDocId) {
206
- throw new Error(
207
- `No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
208
- )
209
- }
210
-
211
- this._rootDocId = initialDocId
212
- console.error(`[abracadabra-mcp] Active space doc: ${initialDocId}`)
213
-
214
- // Step 5: Connect to initial space
215
- await this._connectToSpace(initialDocId)
216
- console.error('[abracadabra-mcp] Space doc synced')
217
-
218
- // Step 6: Start eviction timer
219
- this.evictionTimer = setInterval(() => this.evictIdle(), 60_000)
220
- }
221
-
222
- /** Connect to a space's root doc and cache it. Sets it as the active connection. */
223
- private async _connectToSpace(docId: string): Promise<SpaceConnection> {
224
- const existing = this._spaceConnections.get(docId)
225
- if (existing) {
226
- this._activeConnection = existing
227
- this._rootDocId = docId
228
- return existing
229
- }
230
-
231
- // Re-authenticate if JWT has expired (prevents WS auth failures)
232
- if (!this.client.isTokenValid() && this._signFn && this._userId) {
233
- console.error('[abracadabra-mcp] JWT expired, re-authenticating...')
234
- await this.client.loginWithKey(this._userId, this._signFn)
235
- console.error('[abracadabra-mcp] Re-authenticated successfully')
236
- }
237
-
238
- const doc = new Y.Doc({ guid: docId })
239
- const provider = new AbracadabraProvider({
240
- name: docId,
241
- document: doc,
242
- client: this.client,
243
- disableOfflineStore: true,
244
- subdocLoading: 'lazy',
245
- })
246
-
247
- await waitForSync(provider)
248
-
249
- provider.awareness.setLocalStateField('user', {
250
- name: this.agentName,
251
- color: this.agentColor,
252
- publicKey: this._userId,
253
- isAgent: true,
254
- })
255
- // Ensure no stale status from a previous session
256
- provider.awareness.setLocalStateField('status', null)
257
- provider.awareness.setLocalStateField('activeToolCall', null)
258
- provider.awareness.setLocalStateField('statusContext', null)
259
- provider.awareness.setLocalStateField('turnId', null)
260
- provider.awareness.setLocalStateField('toolHistory', [])
261
-
262
- const conn: SpaceConnection = { doc, provider, docId }
263
- this._spaceConnections.set(docId, conn)
264
- this._activeConnection = conn
265
- this._rootDocId = docId
266
-
267
- // Re-attach awareness + stateless listeners if messaging is active (handles space switches)
268
- if (this._mcpServerRef) {
269
- this._observeRootAwareness(provider)
270
- provider.on('stateless', ({ payload }: { payload: string }) => {
271
- this._handleStatelessChat(payload)
272
- })
273
- }
274
-
275
- return conn
276
- }
277
-
278
- /**
279
- * Switch the active space to the given doc ID.
280
- * Clears the child provider cache (children belong to the previous space).
281
- */
282
- async switchSpace(docId: string): Promise<void> {
283
- for (const [, cached] of this.childCache) {
284
- cached.provider.destroy()
285
- }
286
- this.childCache.clear()
287
- await this._connectToSpace(docId)
288
- console.error(`[abracadabra-mcp] Switched active space to ${docId}`)
289
- }
290
-
291
- /** Get the root doc-tree Y.Map of the active space. */
292
- getTreeMap(): Y.Map<any> | null {
293
- return this._activeConnection?.doc.getMap('doc-tree') ?? null
294
- }
295
-
296
- /** Get the root doc-trash Y.Map of the active space. */
297
- getTrashMap(): Y.Map<any> | null {
298
- return this._activeConnection?.doc.getMap('doc-trash') ?? null
299
- }
300
-
301
- /** Get plugin names enabled in the active space via space-plugins Y.Map. */
302
- getEnabledPluginNames(): string[] {
303
- const doc = this._activeConnection?.doc
304
- if (!doc) return []
305
- const pluginsMap = doc.getMap('space-plugins')
306
- const names: string[] = []
307
- pluginsMap.forEach((value: any, key: string) => {
308
- const entry = value?.toJSON ? value.toJSON() : value
309
- if (entry?.enabled) names.push(key)
310
- })
311
- return names
312
- }
313
-
314
- /**
315
- * Get or create a child provider for a given document ID.
316
- * Caches providers and waits for sync before returning.
317
- */
318
- async getChildProvider(docId: string): Promise<AbracadabraProvider> {
319
- const cached = this.childCache.get(docId)
320
- if (cached) {
321
- cached.lastAccessed = Date.now()
322
- return cached.provider
323
- }
324
-
325
- const activeProvider = this._activeConnection?.provider
326
- if (!activeProvider) {
327
- throw new Error('Not connected. Call connect() first.')
328
- }
329
-
330
- // Re-authenticate if JWT has expired (prevents child WS auth failures)
331
- if (!this.client.isTokenValid() && this._signFn && this._userId) {
332
- console.error('[abracadabra-mcp] JWT expired, re-authenticating...')
333
- await this.client.loginWithKey(this._userId, this._signFn)
334
- console.error('[abracadabra-mcp] Re-authenticated successfully')
335
- }
336
-
337
- const childProvider = await activeProvider.loadChild(docId)
338
- await waitForSync(childProvider)
339
-
340
- childProvider.awareness.setLocalStateField('user', {
341
- name: this.agentName,
342
- color: this.agentColor,
343
- publicKey: this._userId,
344
- isAgent: true,
345
- })
346
-
347
- this.childCache.set(docId, {
348
- provider: childProvider,
349
- lastAccessed: Date.now(),
350
- })
351
-
352
- return childProvider
353
- }
354
-
355
- /** Update root awareness to reflect the currently focused document. */
356
- setFocusedDoc(docId: string): void {
357
- this.rootYProvider?.awareness.setLocalStateField('docId', docId)
358
- }
359
-
360
- /**
361
- * Set a TipTap-compatible collapsed cursor (anchor === head) on a child doc's awareness.
362
- * Only call after getChildProvider has cached the provider.
363
- * @param index Character index in xmlFragment ('default'). Clamped to [0, length].
364
- */
365
- setDocCursor(docId: string, index: number): void {
366
- const cached = this.childCache.get(docId)
367
- if (!cached) return
368
-
369
- const fragment = cached.provider.document.getXmlFragment('default')
370
- const clampedIndex = Math.max(0, Math.min(index, fragment.length))
371
- const relPos = Y.createRelativePositionFromTypeIndex(fragment, clampedIndex)
372
- const relPosJson = Y.relativePositionToJSON(relPos)
373
-
374
- cached.provider.awareness.setLocalStateField('anchor', relPosJson)
375
- cached.provider.awareness.setLocalStateField('head', relPosJson)
376
- }
377
-
378
- /** Evict child providers that have been idle for too long. */
379
- private evictIdle(): void {
380
- const now = Date.now()
381
- for (const [docId, cached] of this.childCache) {
382
- if (now - cached.lastAccessed > IDLE_TIMEOUT_MS) {
383
- // Clear cursor before destroying so TipTap removes the caret overlay
384
- cached.provider.awareness.setLocalStateField('anchor', null)
385
- cached.provider.awareness.setLocalStateField('head', null)
386
- cached.provider.destroy()
387
- this.childCache.delete(docId)
388
- console.error(`[abracadabra-mcp] Evicted idle provider: ${docId}`)
389
- }
390
- }
391
- }
392
-
393
- /** Wire up real-time channel notifications via awareness observation and stateless chat. */
394
- startChannelNotifications(mcpServer: McpServer): void {
395
- this._mcpServerRef = mcpServer
396
- this._serverRef = mcpServer.server
397
- const provider = this._activeConnection?.provider
398
- if (provider) {
399
- this._observeRootAwareness(provider)
400
- provider.on('stateless', ({ payload }: { payload: string }) => {
401
- this._handleStatelessChat(payload)
402
- })
403
- console.error('[abracadabra-mcp] Stateless chat listener attached')
404
- void this._startInboxNotifications()
405
- }
406
- }
407
-
408
- /**
409
- * Bootstrap inbox observation. Sends `messages:inbox_fetch` over the active
410
- * provider; the server's `messages:inbox_history` reply carries the
411
- * `inbox_doc_id`. We then open a dedicated provider on that doc and observe
412
- * its `entries` Y.Array for live DM/mention dispatch. Mirrors the
413
- * dashboard's `useNotifications` pattern.
414
- */
415
- private async _startInboxNotifications(): Promise<void> {
416
- if (this._inboxStarted) return
417
- const provider = this._activeConnection?.provider
418
- if (!provider) return
419
- this._inboxStarted = true
420
-
421
- provider.on('stateless', ({ payload }: { payload: string }) => {
422
- if (!payload.includes('"messages:inbox_history"')) return
423
- try {
424
- const data = JSON.parse(payload)
425
- if (data?.type !== 'messages:inbox_history') return
426
- const inboxDocId = typeof data.inbox_doc_id === 'string' ? data.inbox_doc_id : null
427
- if (inboxDocId) void this._ensureInboxObserver(inboxDocId)
428
- } catch {
429
- /* ignore malformed */
430
- }
431
- })
432
-
433
- // Elicit the inbox_history (and thus inbox_doc_id). unread_only:false so a
434
- // fresh agent learns its inbox doc even with no unread entries.
435
- provider.sendStateless(JSON.stringify({
436
- type: 'messages:inbox_fetch',
437
- limit: 50,
438
- unread_only: false,
439
- }))
440
- console.error('[abracadabra-mcp] Inbox bootstrap sent (messages:inbox_fetch)')
441
- }
442
-
443
- /**
444
- * Open a dedicated provider on the agent's inbox doc and observe its
445
- * `entries` Y.Array. The server is the only writer; we only read.
446
- */
447
- private async _ensureInboxObserver(inboxDocId: string): Promise<void> {
448
- if (this._inboxDocId) return // already observing
449
- this._inboxDocId = inboxDocId
450
-
451
- // Re-authenticate if the JWT expired (mirrors _connectToSpace).
452
- if (!this.client.isTokenValid() && this._signFn && this._userId) {
453
- await this.client.loginWithKey(this._userId, this._signFn)
454
- }
455
-
456
- const doc = new Y.Doc({ guid: inboxDocId })
457
- const provider = new AbracadabraProvider({
458
- name: inboxDocId,
459
- document: doc,
460
- client: this.client,
461
- disableOfflineStore: true,
462
- subdocLoading: 'lazy',
463
- })
464
- this._inboxDoc = doc
465
- this._inboxProvider = provider
466
-
467
- try {
468
- await waitForSync(provider)
469
- } catch (err: any) {
470
- console.error(`[abracadabra-mcp] Inbox sync failed: ${err?.message ?? err}`)
471
- return
472
- }
473
-
474
- const entriesArr = doc.getArray('entries')
475
- const readMap = doc.getMap('read')
476
- const onChange = () => { void this._pumpInbox(entriesArr, readMap) }
477
- entriesArr.observe(onChange)
478
- readMap.observe(onChange)
479
- this._inboxDisposers.push(() => entriesArr.unobserve(onChange))
480
- this._inboxDisposers.push(() => readMap.unobserve(onChange))
481
-
482
- await this._pumpInbox(entriesArr, readMap)
483
- console.error(`[abracadabra-mcp] Inbox observer attached (${inboxDocId})`)
484
- }
485
-
486
- /**
487
- * Diff the inbox `entries` array against what we've already seen. On the
488
- * first pump we only record a baseline (don't replay history). New entries
489
- * are classified by their authoritative `kind` and dispatched.
490
- */
491
- private async _pumpInbox(entriesArr: Y.Array<any>, readMap: Y.Map<any>): Promise<void> {
492
- const raw = entriesArr.toArray() as any[]
493
- const entries = raw.map((e) => (typeof e?.toJSON === 'function' ? e.toJSON() : e))
494
-
495
- if (!this._inboxInitialized) {
496
- for (const e of entries) if (e?.id) this._seenInboxIds.add(e.id)
497
- this._inboxInitialized = true
498
- console.error(`[abracadabra-mcp] Inbox baseline: ${this._seenInboxIds.size} existing entries (not replayed)`)
499
- return
500
- }
501
-
502
- for (const e of entries) {
503
- const id: string | undefined = e?.id
504
- if (!id || this._seenInboxIds.has(id)) continue
505
- this._seenInboxIds.add(id)
506
- if (readMap.get(id)) continue // already read elsewhere
507
- try {
508
- await this._dispatchInboxEntry(e)
509
- } catch (err: any) {
510
- console.error(`[abracadabra-mcp] Inbox dispatch failed for ${id}: ${err?.message ?? err}`)
511
- }
512
- }
513
- this._trimSet(this._seenInboxIds)
514
- }
515
-
516
- /** Classify + dispatch one inbox entry as a channel notification. */
517
- private async _dispatchInboxEntry(entry: any): Promise<void> {
518
- if (!this._serverRef) return
519
- const kind = typeof entry?.kind === 'string' ? entry.kind : ''
520
- const channelDocId = typeof entry?.channel_doc_id === 'string' ? entry.channel_doc_id : ''
521
- const messageId = typeof entry?.message_id === 'string' ? entry.message_id : null
522
- const senderId = typeof entry?.sender_id === 'string' ? entry.sender_id : ''
523
- if (!channelDocId) return
524
- if (senderId && senderId === this._userId) return // own message (defensive)
525
-
526
- // Authoritative trigger gate — `kind` comes from the server, no guessing.
527
- // dm → always dispatch (DMs trigger regardless of mode)
528
- // mention/reply dispatch unless mode === 'task' (chat-ignoring)
529
- // system/other → skip
530
- const mode = this.triggerMode
531
- if (kind === 'mention' || kind === 'reply') {
532
- if (mode === 'task') return
533
- } else if (kind !== 'dm') {
534
- return
535
- }
536
-
537
- // Cross-path dedupe: a mention on a doc the agent is also subscribed to
538
- // arrives via both the inbox and `_handleStatelessChat`. First one wins.
539
- if (messageId && this._rememberDispatched(messageId)) return
540
-
541
- const content = await this._resolveInboxContent(channelDocId, messageId, entry?.preview)
542
-
543
- this._lastChatChannel = channelDocId
544
- this._beginTurn()
545
- this.setAutoStatus('thinking')
546
-
547
- await this._serverRef.notification({
548
- method: 'notifications/claude/channel',
549
- params: {
550
- content,
551
- instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" 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.`,
552
- meta: {
553
- source: 'abracadabra',
554
- type: kind === 'dm' ? 'dm_message' : 'chat_message',
555
- channel_doc_id: channelDocId,
556
- sender: entry?.sender_name ?? 'Unknown',
557
- sender_id: senderId,
558
- doc_id: channelDocId,
559
- },
560
- },
561
- })
562
- console.error(`[abracadabra-mcp] Dispatched ${kind} on ${channelDocId} from ${entry?.sender_name ?? (senderId || 'unknown')}`)
563
-
564
- // Mark the inbox entry read so it doesn't re-pump on reconnect.
565
- this._activeConnection?.provider?.sendStateless(JSON.stringify({
566
- type: 'messages:inbox_mark_read',
567
- id: entry.id,
568
- }))
569
- }
570
-
571
- /**
572
- * Resolve full message content. The inbox `preview` is truncated to ≤200
573
- * bytes (and null for E2E), so for fidelity we read the actual record from
574
- * the channel/DM doc's active period — same shape the dashboard reads.
575
- * Falls back to the preview, then a short notice.
576
- */
577
- private async _resolveInboxContent(
578
- channelDocId: string,
579
- messageId: string | null,
580
- preview: unknown,
581
- ): Promise<string> {
582
- const previewStr = typeof preview === 'string' && preview.length > 0 ? preview : null
583
- const root = this._activeConnection?.provider
584
- if (root && messageId) {
585
- try {
586
- const wrapper = await root.loadChild(channelDocId)
587
- if (!wrapper.synced) await waitForSync(wrapper)
588
- const periods = wrapper.document.getArray('periods')
589
- const len = periods.length
590
- // Scan the last two periods (resilient to a rollover between send + read).
591
- for (let i = len - 1; i >= 0 && i >= len - 2; i--) {
592
- const p: any = periods.get(i)
593
- const periodId: string | undefined = p?.id ?? p?.get?.('id')
594
- if (!periodId) continue
595
- const period = await root.loadChild(periodId)
596
- if (!period.synced) await waitForSync(period)
597
- const records = (period.document.getArray('messages').toArray() as unknown[])
598
- .map((v) => recordFromYAny(v))
599
- .filter((r): r is NonNullable<ReturnType<typeof recordFromYAny>> => r !== null)
600
- const folded = foldRecords(records)
601
- const hit = folded.find((f) => f.id === messageId)
602
- if (hit) {
603
- if (isEncryptedContent(hit.content)) {
604
- return previewStr ?? '[encrypted message — this agent is not provisioned to read this channel]'
605
- }
606
- return hit.content
607
- }
608
- }
609
- } catch (err: any) {
610
- console.error(`[abracadabra-mcp] content read failed for ${channelDocId}/${messageId}: ${err?.message ?? err}`)
611
- }
612
- }
613
- return previewStr ?? '[message content unavailable]'
614
- }
615
-
616
- private _rememberDispatched(messageId: string): boolean {
617
- if (this._dispatchedMessageIds.has(messageId)) return true
618
- this._dispatchedMessageIds.add(messageId)
619
- this._trimSet(this._dispatchedMessageIds)
620
- return false
621
- }
622
-
623
- private _trimSet(s: Set<string>): void {
624
- if (s.size <= AbracadabraMCPServer.DEDUPE_MAX) return
625
- const excess = s.size - AbracadabraMCPServer.DEDUPE_MAX
626
- let i = 0
627
- for (const v of s) {
628
- if (i++ >= excess) break
629
- s.delete(v)
630
- }
631
- }
632
-
633
- /** Attach awareness observer to detect `ai:task` fields from human users. */
634
- private _observeRootAwareness(provider: AbracadabraProvider): void {
635
- const selfId = provider.awareness.clientID
636
- provider.awareness.on('change', () => {
637
- // Strict `mention` mode ignores ai:task awareness; every other mode honors it.
638
- if (this.triggerMode === 'mention') return
639
-
640
- const states = provider.awareness.getStates()
641
- for (const [clientId, state] of states) {
642
- if (clientId === selfId) continue
643
- const task = (state as Record<string, any>)['ai:task']
644
- if (!task || typeof task !== 'object') continue
645
- const { id, text } = task as { id?: string; text?: string }
646
- if (!id || !text) continue
647
- if (this._handledTaskIds.has(id)) continue
648
- this._handledTaskIds.add(id)
649
-
650
- // Extract sender name from awareness state
651
- const user = (state as Record<string, any>)['user']
652
- const senderName = (user && typeof user === 'object' && typeof user.name === 'string')
653
- ? user.name
654
- : 'Unknown'
655
-
656
- console.error(`[abracadabra-mcp] Handling ai:task id=${id} from ${senderName}: ${text.slice(0, 80)}`)
657
- this._beginTurn()
658
- this.setAutoStatus('thinking')
659
- this._dispatchAiTask({
660
- id,
661
- text,
662
- docId: (state as Record<string, any>)['docId'],
663
- senderName,
664
- })
665
- }
666
- })
667
- console.error('[abracadabra-mcp] Root awareness observation started')
668
- }
669
-
670
- /** Dispatch an ai:task as a channel notification. */
671
- private async _dispatchAiTask(task: { id: string; text: string; docId?: string; senderName: string }): Promise<void> {
672
- if (!this._serverRef) return
673
- try {
674
- await this._serverRef.notification({
675
- method: 'notifications/claude/channel',
676
- params: {
677
- content: task.text,
678
- instructions: `You MUST use the reply tool with doc_id="${task.docId ?? ''}" and task_id="${task.id}" for your final response. The user CANNOT see plain text output; they only see replies sent via MCP tools. For progress updates during multi-step work, use send_chat_message with channel="group:${task.docId ?? ''}" (e.g. "Looking into that..." or "Found it, writing up results..."). Never output plain text as a substitute for MCP tools.`,
679
- meta: {
680
- source: 'abracadabra',
681
- type: 'ai_task',
682
- task_id: task.id,
683
- sender: task.senderName,
684
- doc_id: task.docId ?? '',
685
- },
686
- },
687
- })
688
- console.error(`[abracadabra-mcp] Channel notification sent for ai:task id=${task.id}`)
689
- } catch (error: any) {
690
- console.error(`[abracadabra-mcp] Channel notification failed: ${error.message}`)
691
- }
692
- }
693
-
694
- /**
695
- * Clear an ai:task from awareness to signal completion.
696
- * Scans all awareness states for the given task ID and removes it.
697
- */
698
- clearAiTask(taskId: string): void {
699
- const provider = this._activeConnection?.provider
700
- if (!provider) return
701
- const selfId = provider.awareness.clientID
702
- const states = provider.awareness.getStates()
703
- for (const [clientId, state] of states) {
704
- if (clientId === selfId) continue
705
- const task = (state as Record<string, any>)['ai:task']
706
- if (task && typeof task === 'object' && (task as any).id === taskId) {
707
- // We can't clear another client's awareness directly, but we can
708
- // signal completion by setting our own awareness field
709
- provider.awareness.setLocalStateField('ai:task:done', taskId)
710
- break
711
- }
712
- }
713
- }
714
-
715
- /** Handle incoming `messages:new_message` broadcasts and dispatch as channel notifications. */
716
- private async _handleStatelessChat(payload: string): Promise<void> {
717
- if (!this._serverRef) return
718
- if (!payload.includes('"messages:new_message"')) return
719
-
720
- try {
721
- const env = JSON.parse(payload)
722
- if (env.type !== 'messages:new_message') return
723
- const data = env.record
724
- if (!data) return
725
- // Only react to actual messages — skip edit/tombstone records.
726
- if (data.record_kind && data.record_kind !== 'message') return
727
- // Skip own messages
728
- if (data.sender_id && data.sender_id === this._userId) return
729
-
730
- const channelDocId = data.channel_doc_id as string | undefined
731
- if (!channelDocId) return
732
-
733
- // Cross-path dedupe: DMs + @mentions also arrive via the inbox observer
734
- // (the authoritative, recipient-correct path that knows `kind`). If the
735
- // inbox already dispatched this message id, don't fire it again here.
736
- if (data.id && this._rememberDispatched(data.id)) return
737
-
738
- // This path only ever sees docs the agent is *subscribed* to (the active
739
- // space root + lazily-loaded children) — i.e. group/channel/space chat,
740
- // never a DM doc (the agent never subscribes to those). DM correctness
741
- // lives entirely in the inbox observer, so group trigger semantics are
742
- // correct here.
743
- const isGroup = true
744
-
745
- // ── Trigger mode gate ─────────────────────────────────────────────
746
- const mode = this.triggerMode
747
- const content = typeof data.content === 'string' ? data.content : ''
748
- let dispatchContent = content
749
-
750
- if (isGroup) {
751
- if (mode === 'task') {
752
- return
753
- }
754
- if (mode === 'mention' || mode === 'mention+task') {
755
- const aliases = this.mentionAliases
756
- if (!containsMention(content, aliases)) {
757
- console.error(`[abracadabra-mcp] skipped message on ${channelDocId} no @mention for ${aliases.join('|')}`)
758
- return
759
- }
760
- dispatchContent = stripMention(content, aliases) || content
761
- }
762
- // mode === 'all' falls through unchanged
763
- }
764
- // ──────────────────────────────────────────────────────────────────
765
-
766
- // Auto-mark this message position read.
767
- const rootProvider = this._activeConnection?.provider
768
- if (rootProvider && data.id && data.period_id) {
769
- rootProvider.sendStateless(JSON.stringify({
770
- type: 'messages:mark_read',
771
- channel_doc_id: channelDocId,
772
- period_id: data.period_id,
773
- message_id: data.id,
774
- ts: Date.now(),
775
- }))
776
- }
777
- this._lastChatChannel = channelDocId
778
-
779
- this._beginTurn()
780
- this.setAutoStatus('thinking')
781
-
782
- await this._serverRef.notification({
783
- method: 'notifications/claude/channel',
784
- params: {
785
- content: dispatchContent,
786
- instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" 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.`,
787
- meta: {
788
- source: 'abracadabra',
789
- type: 'chat_message',
790
- channel_doc_id: channelDocId,
791
- sender: data.sender_name ?? 'Unknown',
792
- sender_id: data.sender_id ?? '',
793
- doc_id: channelDocId,
794
- },
795
- },
796
- })
797
- console.error(`[abracadabra-mcp] Chat message from ${data.sender_name ?? 'unknown'} on ${channelDocId}`)
798
- } catch {
799
- // Ignore non-chat or malformed payloads
800
- }
801
- }
802
-
803
- /**
804
- * Set the agent's status in root awareness with auto-clear after idle.
805
- * @param statusContext — scopes the status to a specific channel/context so the
806
- * dashboard only shows it in the relevant chat. Defaults to `_lastChatChannel`.
807
- */
808
- setAutoStatus(status: string | null, docId?: string, statusContext?: string | null): void {
809
- const provider = this._activeConnection?.provider
810
- if (!provider) return
811
-
812
- if (this._statusClearTimer) {
813
- clearTimeout(this._statusClearTimer)
814
- this._statusClearTimer = null
815
- }
816
-
817
- provider.awareness.setLocalStateField('status', status)
818
- if (docId !== undefined) {
819
- provider.awareness.setLocalStateField('docId', docId)
820
- }
821
-
822
- // Scope status to the originating channel so the dashboard can filter
823
- const context = status ? (statusContext !== undefined ? statusContext : this._lastChatChannel) : null
824
- provider.awareness.setLocalStateField('statusContext', context ?? null)
825
-
826
- // When clearing status this is the authoritative end-of-turn signal:
827
- // drop the tool pill, the turn id, and the running tool history so the
828
- // dashboard's incantation + activity trace all collapse in lockstep.
829
- if (!status) {
830
- this._stopTypingInterval()
831
- provider.awareness.setLocalStateField('activeToolCall', null)
832
- provider.awareness.setLocalStateField('turnId', null)
833
- this._toolHistory = []
834
- provider.awareness.setLocalStateField('toolHistory', [])
835
- }
836
-
837
- // Auto-clear status after 10s of no updates. Short enough that a real
838
- // idle is noticed quickly; long enough that normal inter-tool gaps
839
- // (PostToolUse next PreToolUse) don't flicker.
840
- if (status) {
841
- this._statusClearTimer = setTimeout(() => {
842
- provider.awareness.setLocalStateField('status', null)
843
- provider.awareness.setLocalStateField('activeToolCall', null)
844
- provider.awareness.setLocalStateField('statusContext', null)
845
- provider.awareness.setLocalStateField('turnId', null)
846
- this._toolHistory = []
847
- provider.awareness.setLocalStateField('toolHistory', [])
848
- this._stopTypingInterval()
849
- }, 10_000)
850
- }
851
- }
852
-
853
- /**
854
- * Start a new agent turn. Mints a fresh UUID and writes it to awareness so
855
- * the dashboard can gate the incantation on "there is an active turn",
856
- * decoupled from the (racier) status field. Called from chat arrival and
857
- * ai:task dispatch right before `setAutoStatus('thinking')`.
858
- */
859
- private _beginTurn(): void {
860
- const provider = this._activeConnection?.provider
861
- if (!provider) return
862
- this._toolHistory = []
863
- provider.awareness.setLocalStateField('toolHistory', [])
864
- provider.awareness.setLocalStateField('turnId', crypto.randomUUID())
865
- }
866
-
867
- /** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
868
- private _startTypingInterval(channel: string): void {
869
- this._stopTypingInterval()
870
- this._typingInterval = setInterval(() => {
871
- this.sendTypingIndicator(channel)
872
- }, 2000)
873
- }
874
-
875
- private _stopTypingInterval(): void {
876
- if (this._typingInterval) {
877
- clearInterval(this._typingInterval)
878
- this._typingInterval = null
879
- }
880
- this._lastChatChannel = null
881
- }
882
-
883
- /**
884
- * Broadcast which tool the agent is currently executing.
885
- *
886
- * Renders as a ChatTool pill on the dashboard. On non-null calls, the tool
887
- * is also appended to `toolHistory` (capped at TOOL_HISTORY_MAX) and written
888
- * to awareness so the dashboard's inline trace can show the turn's recent
889
- * activity. Tools do NOT clear (`setActiveToolCall(null)`) on completion —
890
- * the pill stays until the next tool replaces it or `setAutoStatus(null)`
891
- * flushes the turn. This keeps pills visible long enough to see.
892
- */
893
- setActiveToolCall(toolCall: { name: string; target?: string } | null): void {
894
- const provider = this._activeConnection?.provider
895
- if (!provider) return
896
- provider.awareness.setLocalStateField('activeToolCall', toolCall)
897
- if (toolCall) {
898
- this._toolHistory.push({
899
- tool: toolCall.name,
900
- target: toolCall.target,
901
- ts: Date.now(),
902
- channel: this._lastChatChannel,
903
- })
904
- if (this._toolHistory.length > AbracadabraMCPServer.TOOL_HISTORY_MAX) {
905
- this._toolHistory.splice(0, this._toolHistory.length - AbracadabraMCPServer.TOOL_HISTORY_MAX)
906
- }
907
- provider.awareness.setLocalStateField('toolHistory', [...this._toolHistory])
908
- }
909
- }
910
-
911
- /**
912
- * Send a typing indicator to a chat channel. Pass the channel doc id
913
- * (or for legacy callers, a `group:<docId>` string — we strip the prefix).
914
- */
915
- sendTypingIndicator(channel: string): void {
916
- const rootProvider = this._activeConnection?.provider
917
- if (!rootProvider) return
918
- const channelDocId = channel.startsWith('group:')
919
- ? channel.slice(6)
920
- : channel.startsWith('dm:')
921
- ? channel
922
- : channel
923
- rootProvider.sendStateless(JSON.stringify({
924
- type: 'messages:typing',
925
- channel_doc_id: channelDocId,
926
- }))
927
- }
928
-
929
- /** Graceful shutdown. */
930
- async destroy(): Promise<void> {
931
- this._stopTypingInterval()
932
- if (this._statusClearTimer) {
933
- clearTimeout(this._statusClearTimer)
934
- this._statusClearTimer = null
935
- }
936
- if (this.evictionTimer) {
937
- clearInterval(this.evictionTimer)
938
- this.evictionTimer = null
939
- }
940
-
941
- for (const dispose of this._inboxDisposers) {
942
- try { dispose() } catch { /* ignore */ }
943
- }
944
- this._inboxDisposers = []
945
- this._inboxProvider?.destroy()
946
- this._inboxProvider = null
947
- this._inboxDoc = null
948
-
949
- for (const [, cached] of this.childCache) {
950
- cached.provider.destroy()
951
- }
952
- this.childCache.clear()
953
-
954
- // Clear awareness fields before destroying so other clients don't see stale state
955
- for (const [, conn] of this._spaceConnections) {
956
- conn.provider.awareness.setLocalStateField('status', null)
957
- conn.provider.awareness.setLocalStateField('activeToolCall', null)
958
- conn.provider.awareness.setLocalStateField('statusContext', null)
959
- conn.provider.awareness.setLocalStateField('turnId', null)
960
- conn.provider.awareness.setLocalStateField('toolHistory', [])
961
- conn.provider.destroy()
962
- }
963
- this._toolHistory = []
964
- this._spaceConnections.clear()
965
- this._activeConnection = null
966
-
967
- console.error('[abracadabra-mcp] Shutdown complete')
968
- }
60
+ readonly config: MCPServerConfig;
61
+ readonly client: AbracadabraClient;
62
+ private _serverInfo: ServerInfo | null = null;
63
+ private _rootDocId: string | null = null;
64
+ private _spaces: DocumentMeta[] = [];
65
+ private _activeConnection: SpaceConnection | null = null;
66
+ private _spaceConnections = new Map<string, SpaceConnection>();
67
+ private childCache = new Map<string, CachedProvider>();
68
+ private evictionTimer: ReturnType<typeof setInterval> | null = null;
69
+ private _mcpServerRef: McpServer | null = null;
70
+ private _serverRef: Server | null = null;
71
+ private _handledTaskIds = new Set<string>();
72
+ private _userId: string | null = null;
73
+ private _statusClearTimer: ReturnType<typeof setTimeout> | null = null;
74
+ private _typingInterval: ReturnType<typeof setInterval> | null = null;
75
+ private _lastChatChannel: string | null = null;
76
+ // Channel of the CURRENTLY-ACTIVE chat/task turn (null when no turn is in
77
+ // flight). Status updates from the hook-bridge (general Claude Code tool
78
+ // activity) default their `statusContext` to THIS, not `_lastChatChannel`.
79
+ // Using the (sticky) `_lastChatChannel` made unrelated session activity
80
+ // leak the "thinking" incantation into the last chat the agent ever touched,
81
+ // even when it never received that conversation's message.
82
+ private _activeTurnChannel: string | null = null;
83
+ private _signFn: ((challenge: string) => Promise<string>) | null = null;
84
+ /** Rolling buffer of the last N tool calls in the current turn, surfaced via awareness. */
85
+ private _toolHistory: Array<{
86
+ tool: string;
87
+ target?: string;
88
+ ts: number;
89
+ channel: string | null;
90
+ /** Expandable detail (diff / code / command / text) for the dashboard. */
91
+ detail?: unknown;
92
+ }> = [];
93
+ private static readonly TOOL_HISTORY_MAX = 20;
94
+
95
+ // ── Inbox-driven dispatch ──────────────────────────────────────────────────
96
+ // The server fans DMs + @mentions out as entries on this agent's per-user
97
+ // `kind="inbox"` doc (server is the only writer). That is the *only*
98
+ // recipient-correct signal for a DM the agent isn't subscribed to — the
99
+ // `messages:new_message` stateless broadcast only reaches subscribers of the
100
+ // channel/DM doc, and the agent never subscribes to DM docs. So we observe
101
+ // the inbox's `entries` Y.Array and dispatch from there.
102
+ private _inboxStarted = false;
103
+ private _inboxDocId: string | null = null;
104
+ private _inboxDoc: Y.Doc | null = null;
105
+ private _inboxProvider: AbracadabraProvider | null = null;
106
+ private _inboxDisposers: Array<() => void> = [];
107
+ private _inboxInitialized = false;
108
+ private _seenInboxIds = new Set<string>();
109
+ /** Shared message-id dedupe so the inbox path and `_handleStatelessChat`
110
+ * (subscribed-doc broadcast) never dispatch the same message twice. */
111
+ private _dispatchedMessageIds = new Set<string>();
112
+ private static readonly DEDUPE_MAX = 1000;
113
+
114
+ constructor(config: MCPServerConfig) {
115
+ this.config = config;
116
+ this.client = new AbracadabraClient({
117
+ url: config.url,
118
+ persistAuth: false,
119
+ });
120
+ }
121
+
122
+ get agentName(): string {
123
+ return this.config.agentName || "AI Assistant";
124
+ }
125
+
126
+ get agentColor(): string {
127
+ return this.config.agentColor || "hsl(270, 80%, 60%)";
128
+ }
129
+
130
+ get triggerMode(): TriggerMode {
131
+ return this.config.triggerMode ?? "mention+task";
132
+ }
133
+
134
+ get mentionAliases(): string[] {
135
+ const explicit = this.config.mentionAliases?.filter(
136
+ (a) => a.trim().length > 0,
137
+ );
138
+ if (explicit && explicit.length > 0) return explicit;
139
+ return [this.agentName];
140
+ }
141
+
142
+ get serverInfo(): ServerInfo | null {
143
+ return this._serverInfo;
144
+ }
145
+
146
+ get rootDocId(): string | null {
147
+ return this._rootDocId;
148
+ }
149
+
150
+ /**
151
+ * Spaces visible to the caller — direct children of the server root with
152
+ * `kind === "space"`. Populated by {@link connect}.
153
+ */
154
+ get spaces(): DocumentMeta[] {
155
+ return this._spaces;
156
+ }
157
+
158
+ get rootDocument(): Y.Doc | null {
159
+ return this._activeConnection?.doc ?? null;
160
+ }
161
+
162
+ get rootYProvider(): AbracadabraProvider | null {
163
+ return this._activeConnection?.provider ?? null;
164
+ }
165
+
166
+ get userId(): string | null {
167
+ return this._userId;
168
+ }
169
+
170
+ /** Start the server: authenticate with Ed25519 key, discover spaces/root doc, connect provider. */
171
+ async connect(): Promise<void> {
172
+ // Step 1: Load or generate Ed25519 keypair
173
+ const keypair = await loadOrCreateKeypair(this.config.keyFile);
174
+ this._userId = keypair.publicKeyB64;
175
+ const signFn = (challenge: string) =>
176
+ Promise.resolve(signChallenge(challenge, keypair.privateKey));
177
+ this._signFn = signFn;
178
+
179
+ // Step 2: Authenticate via challenge-response (register on first run)
180
+ try {
181
+ await this.client.loginWithKey(keypair.publicKeyB64, signFn);
182
+ } catch (err: any) {
183
+ // Key not registered — auto-register and retry.
184
+ // Servers signal this with 404/422, or 401 + "public key not registered" / "user not found".
185
+ const status = err?.status ?? err?.response?.status;
186
+ const msg = String(err?.message ?? "").toLowerCase();
187
+ const notRegistered =
188
+ status === 404 ||
189
+ status === 422 ||
190
+ (status === 401 &&
191
+ /not registered|user not found|no such user/.test(msg));
192
+ if (notRegistered) {
193
+ console.error(
194
+ "[abracadabra-mcp] Key not registered, creating new account...",
195
+ );
196
+ await this.client.registerWithKey({
197
+ publicKey: keypair.publicKeyB64,
198
+ username: this.agentName.replace(/\s+/g, "-").toLowerCase(),
199
+ displayName: this.agentName,
200
+ deviceName: "MCP Agent",
201
+ inviteCode: this.config.inviteCode,
202
+ });
203
+ await this.client.loginWithKey(keypair.publicKeyB64, signFn);
204
+ } else {
205
+ throw err;
206
+ }
207
+ }
208
+ console.error(
209
+ `[abracadabra-mcp] Authenticated as ${this.agentName} (pubkey=${keypair.publicKeyB64})`,
210
+ );
211
+
212
+ // Step 3: Discover server info
213
+ this._serverInfo = await this.client.serverInfo();
214
+
215
+ // Step 4: Discover Spaces top-level docs (children of the server root)
216
+ // tagged with kind="space". The first Space is the default landing doc;
217
+ // any other top-level doc serves as a fallback if no Spaces exist.
218
+ const roots = await this.client.listChildren();
219
+ this._spaces = roots.filter((d) => d.kind === Kind.Space);
220
+ const first = this._spaces[0] ?? roots[0];
221
+ const initialDocId = first?.id ?? null;
222
+ if (first) {
223
+ console.error(
224
+ `[abracadabra-mcp] Active space: ${first.label ?? first.id} (${first.id})`,
225
+ );
226
+ }
227
+
228
+ if (!initialDocId) {
229
+ throw new Error(
230
+ `No entry point found: server has no top-level documents under ${SERVER_ROOT_ID}. Create a Space first.`,
231
+ );
232
+ }
233
+
234
+ this._rootDocId = initialDocId;
235
+ console.error(`[abracadabra-mcp] Active space doc: ${initialDocId}`);
236
+
237
+ // Step 5: Connect to initial space
238
+ await this._connectToSpace(initialDocId);
239
+ console.error("[abracadabra-mcp] Space doc synced");
240
+
241
+ // Step 6: Start eviction timer
242
+ this.evictionTimer = setInterval(() => this.evictIdle(), 60_000);
243
+ }
244
+
245
+ /** Connect to a space's root doc and cache it. Sets it as the active connection. */
246
+ private async _connectToSpace(docId: string): Promise<SpaceConnection> {
247
+ const existing = this._spaceConnections.get(docId);
248
+ if (existing) {
249
+ this._activeConnection = existing;
250
+ this._rootDocId = docId;
251
+ return existing;
252
+ }
253
+
254
+ // Re-authenticate if JWT has expired (prevents WS auth failures)
255
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
256
+ console.error("[abracadabra-mcp] JWT expired, re-authenticating...");
257
+ await this.client.loginWithKey(this._userId, this._signFn);
258
+ console.error("[abracadabra-mcp] Re-authenticated successfully");
259
+ }
260
+
261
+ const doc = new Y.Doc({ guid: docId });
262
+ const provider = new AbracadabraProvider({
263
+ name: docId,
264
+ document: doc,
265
+ client: this.client,
266
+ disableOfflineStore: true,
267
+ subdocLoading: "lazy",
268
+ });
269
+
270
+ await waitForSync(provider);
271
+
272
+ provider.awareness.setLocalStateField("user", {
273
+ name: this.agentName,
274
+ color: this.agentColor,
275
+ publicKey: this._userId,
276
+ isAgent: true,
277
+ });
278
+ // Ensure no stale status from a previous session
279
+ provider.awareness.setLocalStateField("status", null);
280
+ provider.awareness.setLocalStateField("activeToolCall", null);
281
+ provider.awareness.setLocalStateField("statusContext", null);
282
+ provider.awareness.setLocalStateField("turnId", null);
283
+ provider.awareness.setLocalStateField("toolHistory", []);
284
+
285
+ const conn: SpaceConnection = { doc, provider, docId };
286
+ this._spaceConnections.set(docId, conn);
287
+ this._activeConnection = conn;
288
+ this._rootDocId = docId;
289
+
290
+ // Re-attach awareness + stateless listeners if messaging is active (handles space switches)
291
+ if (this._mcpServerRef) {
292
+ this._observeRootAwareness(provider);
293
+ provider.on("stateless", ({ payload }: { payload: string }) => {
294
+ this._handleStatelessChat(payload);
295
+ });
296
+ }
297
+
298
+ return conn;
299
+ }
300
+
301
+ /**
302
+ * Switch the active space to the given doc ID.
303
+ * Clears the child provider cache (children belong to the previous space).
304
+ */
305
+ async switchSpace(docId: string): Promise<void> {
306
+ for (const [, cached] of this.childCache) {
307
+ cached.provider.destroy();
308
+ }
309
+ this.childCache.clear();
310
+ await this._connectToSpace(docId);
311
+ console.error(`[abracadabra-mcp] Switched active space to ${docId}`);
312
+ }
313
+
314
+ /** Get the root doc-tree Y.Map of the active space. */
315
+ getTreeMap(): Y.Map<any> | null {
316
+ return this._activeConnection?.doc.getMap("doc-tree") ?? null;
317
+ }
318
+
319
+ /**
320
+ * Resolve a doc's `{label, type}` from the active space's tree map. Used to
321
+ * enrich chat-dispatch notifications so Claude knows what kind of doc the
322
+ * chat is attached to (e.g. checklist vs. kanban) without burning tool
323
+ * calls to discover it. Returns null when the doc isn't in this tree.
324
+ */
325
+ getDocSummary(docId: string): { label?: string; type?: string } | null {
326
+ const tree = this.getTreeMap();
327
+ if (!tree) return null;
328
+ const raw = tree.get(docId);
329
+ if (!raw) return null;
330
+ // Entries may be Y.Map or plain JS objects depending on how they were
331
+ // written. Normalize both.
332
+ const entry = typeof raw?.toJSON === "function" ? raw.toJSON() : raw;
333
+ return {
334
+ label: typeof entry?.label === "string" ? entry.label : undefined,
335
+ type: typeof entry?.type === "string" ? entry.type : undefined,
336
+ };
337
+ }
338
+
339
+ /** Get the root doc-trash Y.Map of the active space. */
340
+ getTrashMap(): Y.Map<any> | null {
341
+ return this._activeConnection?.doc.getMap("doc-trash") ?? null;
342
+ }
343
+
344
+ /** Get plugin names enabled in the active space via space-plugins Y.Map. */
345
+ getEnabledPluginNames(): string[] {
346
+ const doc = this._activeConnection?.doc;
347
+ if (!doc) return [];
348
+ const pluginsMap = doc.getMap("space-plugins");
349
+ const names: string[] = [];
350
+ pluginsMap.forEach((value: any, key: string) => {
351
+ const entry = value?.toJSON ? value.toJSON() : value;
352
+ if (entry?.enabled) names.push(key);
353
+ });
354
+ return names;
355
+ }
356
+
357
+ /**
358
+ * Get or create a child provider for a given document ID.
359
+ * Caches providers and waits for sync before returning.
360
+ */
361
+ async getChildProvider(docId: string): Promise<AbracadabraProvider> {
362
+ const cached = this.childCache.get(docId);
363
+ if (cached) {
364
+ cached.lastAccessed = Date.now();
365
+ return cached.provider;
366
+ }
367
+
368
+ const activeProvider = this._activeConnection?.provider;
369
+ if (!activeProvider) {
370
+ throw new Error("Not connected. Call connect() first.");
371
+ }
372
+
373
+ // Re-authenticate if JWT has expired (prevents child WS auth failures)
374
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
375
+ console.error("[abracadabra-mcp] JWT expired, re-authenticating...");
376
+ await this.client.loginWithKey(this._userId, this._signFn);
377
+ console.error("[abracadabra-mcp] Re-authenticated successfully");
378
+ }
379
+
380
+ const childProvider = await activeProvider.loadChild(docId);
381
+ await waitForSync(childProvider);
382
+
383
+ childProvider.awareness.setLocalStateField("user", {
384
+ name: this.agentName,
385
+ color: this.agentColor,
386
+ publicKey: this._userId,
387
+ isAgent: true,
388
+ });
389
+
390
+ this.childCache.set(docId, {
391
+ provider: childProvider,
392
+ lastAccessed: Date.now(),
393
+ });
394
+
395
+ return childProvider;
396
+ }
397
+
398
+ /** Update root awareness to reflect the currently focused document. */
399
+ setFocusedDoc(docId: string): void {
400
+ this.rootYProvider?.awareness.setLocalStateField("docId", docId);
401
+ }
402
+
403
+ /**
404
+ * Set a TipTap-compatible collapsed cursor (anchor === head) on a child doc's awareness.
405
+ * Only call after getChildProvider has cached the provider.
406
+ * @param index Character index in xmlFragment ('default'). Clamped to [0, length].
407
+ */
408
+ setDocCursor(docId: string, index: number): void {
409
+ const cached = this.childCache.get(docId);
410
+ if (!cached) return;
411
+
412
+ const fragment = cached.provider.document.getXmlFragment("default");
413
+ const clampedIndex = Math.max(0, Math.min(index, fragment.length));
414
+ const relPos = Y.createRelativePositionFromTypeIndex(
415
+ fragment,
416
+ clampedIndex,
417
+ );
418
+ const relPosJson = Y.relativePositionToJSON(relPos);
419
+
420
+ cached.provider.awareness.setLocalStateField("anchor", relPosJson);
421
+ cached.provider.awareness.setLocalStateField("head", relPosJson);
422
+ }
423
+
424
+ /** Evict child providers that have been idle for too long. */
425
+ private evictIdle(): void {
426
+ const now = Date.now();
427
+ for (const [docId, cached] of this.childCache) {
428
+ if (now - cached.lastAccessed > IDLE_TIMEOUT_MS) {
429
+ // Clear cursor before destroying so TipTap removes the caret overlay
430
+ cached.provider.awareness.setLocalStateField("anchor", null);
431
+ cached.provider.awareness.setLocalStateField("head", null);
432
+ cached.provider.destroy();
433
+ this.childCache.delete(docId);
434
+ console.error(`[abracadabra-mcp] Evicted idle provider: ${docId}`);
435
+ }
436
+ }
437
+ }
438
+
439
+ /** Wire up real-time channel notifications via awareness observation and stateless chat. */
440
+ startChannelNotifications(mcpServer: McpServer): void {
441
+ this._mcpServerRef = mcpServer;
442
+ this._serverRef = mcpServer.server;
443
+ const provider = this._activeConnection?.provider;
444
+ if (provider) {
445
+ this._observeRootAwareness(provider);
446
+ provider.on("stateless", ({ payload }: { payload: string }) => {
447
+ this._handleStatelessChat(payload);
448
+ });
449
+ console.error("[abracadabra-mcp] Stateless chat listener attached");
450
+ void this._startInboxNotifications();
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Bootstrap inbox observation. Sends `messages:inbox_fetch` over the active
456
+ * provider; the server's `messages:inbox_history` reply carries the
457
+ * `inbox_doc_id`. We then open a dedicated provider on that doc and observe
458
+ * its `entries` Y.Array for live DM/mention dispatch. Mirrors the
459
+ * dashboard's `useNotifications` pattern.
460
+ */
461
+ private async _startInboxNotifications(): Promise<void> {
462
+ if (this._inboxStarted) return;
463
+ const provider = this._activeConnection?.provider;
464
+ if (!provider) return;
465
+ this._inboxStarted = true;
466
+
467
+ provider.on("stateless", ({ payload }: { payload: string }) => {
468
+ if (!payload.includes('"messages:inbox_history"')) return;
469
+ try {
470
+ const data = JSON.parse(payload);
471
+ if (data?.type !== "messages:inbox_history") return;
472
+ const inboxDocId =
473
+ typeof data.inbox_doc_id === "string" ? data.inbox_doc_id : null;
474
+ if (inboxDocId) void this._ensureInboxObserver(inboxDocId);
475
+ } catch {
476
+ /* ignore malformed */
477
+ }
478
+ });
479
+
480
+ // Elicit the inbox_history (and thus inbox_doc_id). unread_only:false so a
481
+ // fresh agent learns its inbox doc even with no unread entries.
482
+ provider.sendStateless(
483
+ JSON.stringify({
484
+ type: "messages:inbox_fetch",
485
+ limit: 50,
486
+ unread_only: false,
487
+ }),
488
+ );
489
+ console.error(
490
+ "[abracadabra-mcp] Inbox bootstrap sent (messages:inbox_fetch)",
491
+ );
492
+ }
493
+
494
+ /**
495
+ * Open a dedicated provider on the agent's inbox doc and observe its
496
+ * `entries` Y.Array. The server is the only writer; we only read.
497
+ */
498
+ private async _ensureInboxObserver(inboxDocId: string): Promise<void> {
499
+ if (this._inboxDocId) return; // already observing
500
+ this._inboxDocId = inboxDocId;
501
+
502
+ // Re-authenticate if the JWT expired (mirrors _connectToSpace).
503
+ if (!this.client.isTokenValid() && this._signFn && this._userId) {
504
+ await this.client.loginWithKey(this._userId, this._signFn);
505
+ }
506
+
507
+ const doc = new Y.Doc({ guid: inboxDocId });
508
+ const provider = new AbracadabraProvider({
509
+ name: inboxDocId,
510
+ document: doc,
511
+ client: this.client,
512
+ disableOfflineStore: true,
513
+ subdocLoading: "lazy",
514
+ });
515
+ this._inboxDoc = doc;
516
+ this._inboxProvider = provider;
517
+
518
+ try {
519
+ await waitForSync(provider);
520
+ } catch (err: any) {
521
+ console.error(
522
+ `[abracadabra-mcp] Inbox sync failed: ${err?.message ?? err}`,
523
+ );
524
+ return;
525
+ }
526
+
527
+ const entriesArr = doc.getArray("entries");
528
+ const readMap = doc.getMap("read");
529
+ const onChange = () => {
530
+ void this._pumpInbox(entriesArr, readMap);
531
+ };
532
+ entriesArr.observe(onChange);
533
+ readMap.observe(onChange);
534
+ this._inboxDisposers.push(() => entriesArr.unobserve(onChange));
535
+ this._inboxDisposers.push(() => readMap.unobserve(onChange));
536
+
537
+ await this._pumpInbox(entriesArr, readMap);
538
+ console.error(`[abracadabra-mcp] Inbox observer attached (${inboxDocId})`);
539
+ }
540
+
541
+ /**
542
+ * Diff the inbox `entries` array against what we've already seen. On the
543
+ * first pump we only record a baseline (don't replay history). New entries
544
+ * are classified by their authoritative `kind` and dispatched.
545
+ */
546
+ private async _pumpInbox(
547
+ entriesArr: Y.Array<any>,
548
+ readMap: Y.Map<any>,
549
+ ): Promise<void> {
550
+ const raw = entriesArr.toArray() as any[];
551
+ const entries = raw.map((e) =>
552
+ typeof e?.toJSON === "function" ? e.toJSON() : e,
553
+ );
554
+
555
+ if (!this._inboxInitialized) {
556
+ for (const e of entries) if (e?.id) this._seenInboxIds.add(e.id);
557
+ this._inboxInitialized = true;
558
+ console.error(
559
+ `[abracadabra-mcp] Inbox baseline: ${this._seenInboxIds.size} existing entries (not replayed)`,
560
+ );
561
+ return;
562
+ }
563
+
564
+ for (const e of entries) {
565
+ const id: string | undefined = e?.id;
566
+ if (!id || this._seenInboxIds.has(id)) continue;
567
+ this._seenInboxIds.add(id);
568
+ console.error(
569
+ `[abracadabra-mcp] inbox NEW entry: kind=${e?.kind} channel=${e?.channel_doc_id} sender=${e?.sender_name ?? e?.sender_id} read=${!!readMap.get(id)}`,
570
+ );
571
+ if (readMap.get(id)) continue; // already read elsewhere
572
+ try {
573
+ await this._dispatchInboxEntry(e);
574
+ } catch (err: any) {
575
+ console.error(
576
+ `[abracadabra-mcp] Inbox dispatch failed for ${id}: ${err?.message ?? err}`,
577
+ );
578
+ }
579
+ }
580
+ this._trimSet(this._seenInboxIds);
581
+ }
582
+
583
+ /** Classify + dispatch one inbox entry as a channel notification. */
584
+ private async _dispatchInboxEntry(entry: any): Promise<void> {
585
+ if (!this._serverRef) return;
586
+ const kind = typeof entry?.kind === "string" ? entry.kind : "";
587
+ const channelDocId =
588
+ typeof entry?.channel_doc_id === "string" ? entry.channel_doc_id : "";
589
+ const messageId =
590
+ typeof entry?.message_id === "string" ? entry.message_id : null;
591
+ const senderId =
592
+ typeof entry?.sender_id === "string" ? entry.sender_id : "";
593
+ if (!channelDocId) return;
594
+ if (senderId && senderId === this._userId) return; // own message (defensive)
595
+
596
+ // Authoritative trigger gate — `kind` comes from the server, no guessing.
597
+ // dm → always dispatch (DMs trigger regardless of mode)
598
+ // mention/reply dispatch unless mode === 'task' (chat-ignoring)
599
+ // system/other → skip
600
+ const mode = this.triggerMode;
601
+ if (kind === "mention" || kind === "reply") {
602
+ if (mode === "task") return;
603
+ } else if (kind !== "dm") {
604
+ return;
605
+ }
606
+
607
+ // Cross-path dedupe: a mention on a doc the agent is also subscribed to
608
+ // arrives via both the inbox and `_handleStatelessChat`. First one wins.
609
+ if (messageId && this._rememberDispatched(messageId)) return;
610
+
611
+ const content = await this._resolveInboxContent(
612
+ channelDocId,
613
+ messageId,
614
+ entry?.preview,
615
+ );
616
+
617
+ this._lastChatChannel = channelDocId;
618
+ this._beginTurn(channelDocId);
619
+ this.setAutoStatus("thinking");
620
+
621
+ const attachedDoc = this.getDocSummary(channelDocId);
622
+ const attachedHint =
623
+ attachedDoc &&
624
+ attachedDoc.type &&
625
+ attachedDoc.type !== "channel" &&
626
+ attachedDoc.type !== "dm"
627
+ ? ` The chat is attached to the document channel_doc_id="${channelDocId}" (label: ${JSON.stringify(attachedDoc.label ?? "")}, type: "${attachedDoc.type}"). If the user refers to "this <doc-kind>" or talks about the document the chat is attached to, they mean THIS doc — operate on it directly via its id without searching.`
628
+ : "";
629
+
630
+ await this._serverRef.notification({
631
+ method: "notifications/claude/channel",
632
+ params: {
633
+ content,
634
+ instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" 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.${attachedHint}`,
635
+ meta: {
636
+ source: "abracadabra",
637
+ type: kind === "dm" ? "dm_message" : "chat_message",
638
+ channel_doc_id: channelDocId,
639
+ sender: entry?.sender_name ?? "Unknown",
640
+ sender_id: senderId,
641
+ doc_id: channelDocId,
642
+ ...(attachedDoc
643
+ ? {
644
+ attached_doc: {
645
+ id: channelDocId,
646
+ label: attachedDoc.label,
647
+ type: attachedDoc.type,
648
+ },
649
+ }
650
+ : {}),
651
+ },
652
+ },
653
+ });
654
+ console.error(
655
+ `[abracadabra-mcp] Dispatched ${kind} on ${channelDocId} from ${entry?.sender_name ?? (senderId || "unknown")}`,
656
+ );
657
+
658
+ // Mark the inbox entry read so it doesn't re-pump on reconnect.
659
+ this._activeConnection?.provider?.sendStateless(
660
+ JSON.stringify({
661
+ type: "messages:inbox_mark_read",
662
+ id: entry.id,
663
+ }),
664
+ );
665
+ }
666
+
667
+ /**
668
+ * Resolve full message content. The inbox `preview` is truncated to ≤200
669
+ * bytes (and null for E2E), so for fidelity we read the actual record from
670
+ * the channel/DM doc's active period — same shape the dashboard reads.
671
+ * Falls back to the preview, then a short notice.
672
+ */
673
+ private async _resolveInboxContent(
674
+ channelDocId: string,
675
+ messageId: string | null,
676
+ preview: unknown,
677
+ ): Promise<string> {
678
+ const previewStr =
679
+ typeof preview === "string" && preview.length > 0 ? preview : null;
680
+ const root = this._activeConnection?.provider;
681
+ if (root && messageId) {
682
+ try {
683
+ const wrapper = await root.loadChild(channelDocId);
684
+ if (!wrapper.synced) await waitForSync(wrapper);
685
+ const periods = wrapper.document.getArray("periods");
686
+ const len = periods.length;
687
+ // Scan the last two periods (resilient to a rollover between send + read).
688
+ for (let i = len - 1; i >= 0 && i >= len - 2; i--) {
689
+ const p: any = periods.get(i);
690
+ const periodId: string | undefined = p?.id ?? p?.get?.("id");
691
+ if (!periodId) continue;
692
+ const period = await root.loadChild(periodId);
693
+ if (!period.synced) await waitForSync(period);
694
+ const records = (
695
+ period.document.getArray("messages").toArray() as unknown[]
696
+ )
697
+ .map((v) => recordFromYAny(v))
698
+ .filter(
699
+ (r): r is NonNullable<ReturnType<typeof recordFromYAny>> =>
700
+ r !== null,
701
+ );
702
+ const folded = foldRecords(records);
703
+ const hit = folded.find((f) => f.id === messageId);
704
+ if (hit) {
705
+ if (isEncryptedContent(hit.content)) {
706
+ return (
707
+ previewStr ??
708
+ "[encrypted message this agent is not provisioned to read this channel]"
709
+ );
710
+ }
711
+ return hit.content;
712
+ }
713
+ }
714
+ } catch (err: any) {
715
+ console.error(
716
+ `[abracadabra-mcp] content read failed for ${channelDocId}/${messageId}: ${err?.message ?? err}`,
717
+ );
718
+ }
719
+ }
720
+ return previewStr ?? "[message content unavailable]";
721
+ }
722
+
723
+ /** Check-and-mark: true if already dispatched, else records it and returns false. */
724
+ private _rememberDispatched(messageId: string): boolean {
725
+ if (this._dispatchedMessageIds.has(messageId)) return true;
726
+ this._dispatchedMessageIds.add(messageId);
727
+ this._trimSet(this._dispatchedMessageIds);
728
+ return false;
729
+ }
730
+
731
+ /** Check-only: true if this id was already dispatched (does NOT record it). */
732
+ private _wasDispatched(messageId: string): boolean {
733
+ return this._dispatchedMessageIds.has(messageId);
734
+ }
735
+
736
+ private _trimSet(s: Set<string>): void {
737
+ if (s.size <= AbracadabraMCPServer.DEDUPE_MAX) return;
738
+ const excess = s.size - AbracadabraMCPServer.DEDUPE_MAX;
739
+ let i = 0;
740
+ for (const v of s) {
741
+ if (i++ >= excess) break;
742
+ s.delete(v);
743
+ }
744
+ }
745
+
746
+ /** Attach awareness observer to detect `ai:task` fields from human users. */
747
+ private _observeRootAwareness(provider: AbracadabraProvider): void {
748
+ const selfId = provider.awareness.clientID;
749
+ provider.awareness.on("change", () => {
750
+ // Strict `mention` mode ignores ai:task awareness; every other mode honors it.
751
+ if (this.triggerMode === "mention") return;
752
+
753
+ const states = provider.awareness.getStates();
754
+ for (const [clientId, state] of states) {
755
+ if (clientId === selfId) continue;
756
+ const task = (state as Record<string, any>)["ai:task"];
757
+ if (!task || typeof task !== "object") continue;
758
+ const { id, text } = task as { id?: string; text?: string };
759
+ if (!id || !text) continue;
760
+ if (this._handledTaskIds.has(id)) continue;
761
+ this._handledTaskIds.add(id);
762
+
763
+ // Extract sender name from awareness state
764
+ const user = (state as Record<string, any>)["user"];
765
+ const senderName =
766
+ user && typeof user === "object" && typeof user.name === "string"
767
+ ? user.name
768
+ : "Unknown";
769
+
770
+ console.error(
771
+ `[abracadabra-mcp] Handling ai:task id=${id} from ${senderName}: ${text.slice(0, 80)}`,
772
+ );
773
+ this._beginTurn();
774
+ this.setAutoStatus("thinking");
775
+ this._dispatchAiTask({
776
+ id,
777
+ text,
778
+ docId: (state as Record<string, any>)["docId"],
779
+ senderName,
780
+ });
781
+ }
782
+ });
783
+ console.error("[abracadabra-mcp] Root awareness observation started");
784
+ }
785
+
786
+ /** Dispatch an ai:task as a channel notification. */
787
+ private async _dispatchAiTask(task: {
788
+ id: string;
789
+ text: string;
790
+ docId?: string;
791
+ senderName: string;
792
+ }): Promise<void> {
793
+ if (!this._serverRef) return;
794
+ try {
795
+ await this._serverRef.notification({
796
+ method: "notifications/claude/channel",
797
+ params: {
798
+ content: task.text,
799
+ instructions: `You MUST use the reply tool with doc_id="${task.docId ?? ""}" and task_id="${task.id}" for your final response. The user CANNOT see plain text output; they only see replies sent via MCP tools. For progress updates during multi-step work, use send_chat_message with channel="group:${task.docId ?? ""}" (e.g. "Looking into that..." or "Found it, writing up results..."). Never output plain text as a substitute for MCP tools.`,
800
+ meta: {
801
+ source: "abracadabra",
802
+ type: "ai_task",
803
+ task_id: task.id,
804
+ sender: task.senderName,
805
+ doc_id: task.docId ?? "",
806
+ },
807
+ },
808
+ });
809
+ console.error(
810
+ `[abracadabra-mcp] Channel notification sent for ai:task id=${task.id}`,
811
+ );
812
+ } catch (error: any) {
813
+ console.error(
814
+ `[abracadabra-mcp] Channel notification failed: ${error.message}`,
815
+ );
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Clear an ai:task from awareness to signal completion.
821
+ * Scans all awareness states for the given task ID and removes it.
822
+ */
823
+ clearAiTask(taskId: string): void {
824
+ const provider = this._activeConnection?.provider;
825
+ if (!provider) return;
826
+ const selfId = provider.awareness.clientID;
827
+ const states = provider.awareness.getStates();
828
+ for (const [clientId, state] of states) {
829
+ if (clientId === selfId) continue;
830
+ const task = (state as Record<string, any>)["ai:task"];
831
+ if (task && typeof task === "object" && (task as any).id === taskId) {
832
+ // We can't clear another client's awareness directly, but we can
833
+ // signal completion by setting our own awareness field
834
+ provider.awareness.setLocalStateField("ai:task:done", taskId);
835
+ break;
836
+ }
837
+ }
838
+ }
839
+
840
+ /** Handle incoming `messages:new_message` broadcasts and dispatch as channel notifications. */
841
+ private async _handleStatelessChat(payload: string): Promise<void> {
842
+ if (!this._serverRef) return;
843
+ if (!payload.includes('"messages:new_message"')) return;
844
+
845
+ try {
846
+ const env = JSON.parse(payload);
847
+ if (env.type !== "messages:new_message") return;
848
+ const data = env.record;
849
+ if (!data) return;
850
+ // Only react to actual messages — skip edit/tombstone records.
851
+ if (data.record_kind && data.record_kind !== "message") return;
852
+ // Skip own messages
853
+ if (data.sender_id && data.sender_id === this._userId) return;
854
+
855
+ const channelDocId = data.channel_doc_id as string | undefined;
856
+ if (!channelDocId) return;
857
+
858
+ // Cross-path dedupe (CHECK ONLY here — do NOT mark yet). If the inbox
859
+ // already dispatched this id, skip. We must not *mark* the id before
860
+ // the trigger gate below: a non-mention group message that this path
861
+ // skips would otherwise poison the dedupe set and block the inbox
862
+ // observer from dispatching a legitimate @mention/DM for the same id.
863
+ if (data.id && this._wasDispatched(data.id)) return;
864
+
865
+ // This path only ever sees docs the agent is *subscribed* to (the active
866
+ // space root + lazily-loaded children) — i.e. group/channel/space chat,
867
+ // never a DM doc (the agent never subscribes to those). DM correctness
868
+ // lives entirely in the inbox observer, so group trigger semantics are
869
+ // correct here.
870
+ const isGroup = true;
871
+
872
+ // ── Trigger mode gate ─────────────────────────────────────────────
873
+ const mode = this.triggerMode;
874
+ const content = typeof data.content === "string" ? data.content : "";
875
+ let dispatchContent = content;
876
+
877
+ if (isGroup) {
878
+ if (mode === "task") {
879
+ return;
880
+ }
881
+ if (mode === "mention" || mode === "mention+task") {
882
+ const aliases = this.mentionAliases;
883
+ // Authoritative signal: the server-stored `mentions` array carries
884
+ // the resolved pubkeys the sender @-mentioned. If our pubkey is in
885
+ // it, we're mentioned — regardless of how the @text was written.
886
+ // Fall back to text matching for older clients that don't send it.
887
+ const mentionedByPubkey =
888
+ Array.isArray(data.mentions) &&
889
+ !!this._userId &&
890
+ data.mentions.includes(this._userId);
891
+ const mentionedByText = containsMention(content, aliases);
892
+ if (!mentionedByPubkey && !mentionedByText) {
893
+ console.error(
894
+ `[abracadabra-mcp] skipped ${channelDocId} not mentioned (mentions=${JSON.stringify(data.mentions ?? [])}, aliases=${aliases.join("|")})`,
895
+ );
896
+ return;
897
+ }
898
+ dispatchContent = stripMention(content, aliases) || content;
899
+ }
900
+ // mode === 'all' falls through unchanged
901
+ }
902
+ // ──────────────────────────────────────────────────────────────────
903
+
904
+ // Auto-mark this message position read.
905
+ const rootProvider = this._activeConnection?.provider;
906
+ if (rootProvider && data.id && data.period_id) {
907
+ rootProvider.sendStateless(
908
+ JSON.stringify({
909
+ type: "messages:mark_read",
910
+ channel_doc_id: channelDocId,
911
+ period_id: data.period_id,
912
+ message_id: data.id,
913
+ ts: Date.now(),
914
+ }),
915
+ );
916
+ }
917
+ this._lastChatChannel = channelDocId;
918
+
919
+ // Mark dispatched only now that the gate passed — this is the message
920
+ // we're actually handling, so the inbox path should skip its twin.
921
+ if (data.id) this._rememberDispatched(data.id);
922
+
923
+ this._beginTurn(channelDocId);
924
+ this.setAutoStatus("thinking");
925
+
926
+ const attachedDoc = this.getDocSummary(channelDocId);
927
+ const attachedHint =
928
+ attachedDoc &&
929
+ attachedDoc.type &&
930
+ attachedDoc.type !== "channel" &&
931
+ attachedDoc.type !== "dm"
932
+ ? ` The chat is attached to the document channel_doc_id="${channelDocId}" (label: ${JSON.stringify(attachedDoc.label ?? "")}, type: "${attachedDoc.type}"). If the user refers to "this <doc-kind>" or talks about the document the chat is attached to, they mean THIS doc — operate on it directly via its id without searching.`
933
+ : "";
934
+
935
+ await this._serverRef.notification({
936
+ method: "notifications/claude/channel",
937
+ params: {
938
+ content: dispatchContent,
939
+ instructions: `You MUST use send_chat_message with channel_doc_id="${channelDocId}" 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.${attachedHint}`,
940
+ meta: {
941
+ source: "abracadabra",
942
+ type: "chat_message",
943
+ channel_doc_id: channelDocId,
944
+ sender: data.sender_name ?? "Unknown",
945
+ sender_id: data.sender_id ?? "",
946
+ doc_id: channelDocId,
947
+ ...(attachedDoc
948
+ ? {
949
+ attached_doc: {
950
+ id: channelDocId,
951
+ label: attachedDoc.label,
952
+ type: attachedDoc.type,
953
+ },
954
+ }
955
+ : {}),
956
+ },
957
+ },
958
+ });
959
+ console.error(
960
+ `[abracadabra-mcp] Chat message from ${data.sender_name ?? "unknown"} on ${channelDocId}`,
961
+ );
962
+ } catch {
963
+ // Ignore non-chat or malformed payloads
964
+ }
965
+ }
966
+
967
+ /**
968
+ * Set the agent's status in root awareness with auto-clear after idle.
969
+ * @param statusContext — scopes the status to a specific channel/context so the
970
+ * dashboard only shows it in the relevant chat. Defaults to `_lastChatChannel`.
971
+ */
972
+ setAutoStatus(
973
+ status: string | null,
974
+ docId?: string,
975
+ statusContext?: string | null,
976
+ ): void {
977
+ const provider = this._activeConnection?.provider;
978
+ if (!provider) return;
979
+
980
+ if (this._statusClearTimer) {
981
+ clearTimeout(this._statusClearTimer);
982
+ this._statusClearTimer = null;
983
+ }
984
+
985
+ provider.awareness.setLocalStateField("status", status);
986
+ if (docId !== undefined) {
987
+ provider.awareness.setLocalStateField("docId", docId);
988
+ }
989
+
990
+ // Scope status to the channel of the ACTIVE turn (null when none), so the
991
+ // dashboard only shows the incantation/typing in the chat the agent is
992
+ // actually engaged with. Defaulting to the sticky `_lastChatChannel` is
993
+ // what leaked unrelated session activity into an idle chat.
994
+ const context = status
995
+ ? statusContext !== undefined
996
+ ? statusContext
997
+ : this._activeTurnChannel
998
+ : null;
999
+ provider.awareness.setLocalStateField("statusContext", context ?? null);
1000
+
1001
+ // When clearing status this is the authoritative end-of-turn signal:
1002
+ // drop the tool pill and the turn id so the incantation + typing dots
1003
+ // collapse. We deliberately do NOT wipe `toolHistory` here — the dashboard
1004
+ // merges that array into its persistent inline tool-call log, and wiping
1005
+ // it in the same awareness flush as the final message would race the
1006
+ // client and lose the whole turn's activity. toolHistory is reset only at
1007
+ // the start of the next turn (`_beginTurn`).
1008
+ if (!status) {
1009
+ this._stopTypingInterval();
1010
+ this._activeTurnChannel = null;
1011
+ provider.awareness.setLocalStateField("activeToolCall", null);
1012
+ provider.awareness.setLocalStateField("turnId", null);
1013
+ }
1014
+
1015
+ // Auto-clear status after 10s of no updates. Short enough that a real
1016
+ // idle is noticed quickly; long enough that normal inter-tool gaps
1017
+ // (PostToolUse → next PreToolUse) don't flicker. Same rule: leave
1018
+ // toolHistory in place for the persistent client log.
1019
+ if (status) {
1020
+ this._statusClearTimer = setTimeout(() => {
1021
+ provider.awareness.setLocalStateField("status", null);
1022
+ provider.awareness.setLocalStateField("activeToolCall", null);
1023
+ provider.awareness.setLocalStateField("statusContext", null);
1024
+ provider.awareness.setLocalStateField("turnId", null);
1025
+ this._activeTurnChannel = null;
1026
+ this._stopTypingInterval();
1027
+ }, 10_000);
1028
+ }
1029
+ }
1030
+
1031
+ /**
1032
+ * Start a new agent turn. Mints a fresh UUID and writes it to awareness so
1033
+ * the dashboard can gate the incantation on "there is an active turn",
1034
+ * decoupled from the (racier) status field. Called from chat arrival and
1035
+ * ai:task dispatch right before `setAutoStatus('thinking')`.
1036
+ *
1037
+ * Also starts a heartbeat typing indicator so the dashboard renders typing
1038
+ * dots whenever no tool pill is active during the turn — this replaces the
1039
+ * "dead air" gap users see between thinking and the final reply.
1040
+ */
1041
+ private _beginTurn(channel?: string): void {
1042
+ const provider = this._activeConnection?.provider;
1043
+ if (!provider) return;
1044
+ this._toolHistory = [];
1045
+ // Remember which channel (if any) this turn belongs to, so hook-bridge
1046
+ // status updates scope to it — and ONLY it — for the turn's duration.
1047
+ this._activeTurnChannel = channel ?? null;
1048
+ provider.awareness.setLocalStateField("toolHistory", []);
1049
+ provider.awareness.setLocalStateField("turnId", crypto.randomUUID());
1050
+ // Only chat turns get a typing heartbeat — pass the channel explicitly.
1051
+ // ai:task turns (document replies, no chat channel) call _beginTurn()
1052
+ // with no arg and must NOT spray typing frames at a stale channel.
1053
+ if (channel) this._startTypingInterval(channel);
1054
+ }
1055
+
1056
+ /**
1057
+ * Enter the "writing" phase: the agent has finished reasoning and is about
1058
+ * to send a chat message. The dashboard maps `status === 'writing'` to
1059
+ * typing dots (not the incantation phrase). Keeps the turn alive (turnId
1060
+ * stays set) and emits a typing frame immediately so the dots appear at
1061
+ * once; the heartbeat keeps them alive until `setAutoStatus(null)`.
1062
+ */
1063
+ setWritingStatus(channel: string): void {
1064
+ this._lastChatChannel = channel;
1065
+ this.setAutoStatus("writing", undefined, channel);
1066
+ this._startTypingInterval(channel);
1067
+ }
1068
+
1069
+ /** Re-send typing indicator every 2s so dashboard keeps showing it (expires at 3s). */
1070
+ private _startTypingInterval(channel: string): void {
1071
+ this._stopTypingInterval();
1072
+ // Fire one immediately so the indicator appears without waiting 2s.
1073
+ this.sendTypingIndicator(channel);
1074
+ this._typingInterval = setInterval(() => {
1075
+ this.sendTypingIndicator(channel);
1076
+ }, 2000);
1077
+ }
1078
+
1079
+ private _stopTypingInterval(): void {
1080
+ if (this._typingInterval) {
1081
+ clearInterval(this._typingInterval);
1082
+ this._typingInterval = null;
1083
+ }
1084
+ // Do NOT clear _lastChatChannel here — subsequent turns need it as the
1085
+ // default channel context. _lastChatChannel is rewritten on every chat
1086
+ // arrival anyway, so leaving it set across turns is correct.
1087
+ }
1088
+
1089
+ /**
1090
+ * Broadcast which tool the agent is currently executing.
1091
+ *
1092
+ * Renders as a ChatTool pill on the dashboard. On non-null calls, the tool
1093
+ * is also appended to `toolHistory` (capped at TOOL_HISTORY_MAX) and written
1094
+ * to awareness so the dashboard's inline trace can show the turn's recent
1095
+ * activity. Tools do NOT clear (`setActiveToolCall(null)`) on completion —
1096
+ * the pill stays until the next tool replaces it or `setAutoStatus(null)`
1097
+ * flushes the turn. This keeps pills visible long enough to see.
1098
+ */
1099
+ setActiveToolCall(
1100
+ toolCall: { name: string; target?: string; detail?: unknown } | null,
1101
+ ): void {
1102
+ const provider = this._activeConnection?.provider;
1103
+ if (!provider) return;
1104
+ provider.awareness.setLocalStateField("activeToolCall", toolCall);
1105
+ if (toolCall) {
1106
+ this._toolHistory.push({
1107
+ tool: toolCall.name,
1108
+ target: toolCall.target,
1109
+ ts: Date.now(),
1110
+ channel: this._lastChatChannel,
1111
+ detail: toolCall.detail,
1112
+ });
1113
+ if (this._toolHistory.length > AbracadabraMCPServer.TOOL_HISTORY_MAX) {
1114
+ this._toolHistory.splice(
1115
+ 0,
1116
+ this._toolHistory.length - AbracadabraMCPServer.TOOL_HISTORY_MAX,
1117
+ );
1118
+ }
1119
+ provider.awareness.setLocalStateField("toolHistory", [
1120
+ ...this._toolHistory,
1121
+ ]);
1122
+ }
1123
+ }
1124
+
1125
+ /**
1126
+ * Send a typing indicator to a chat channel. Pass the channel doc id
1127
+ * (or for legacy callers, a `group:<docId>` string — we strip the prefix).
1128
+ */
1129
+ sendTypingIndicator(channel: string): void {
1130
+ const rootProvider = this._activeConnection?.provider;
1131
+ if (!rootProvider) return;
1132
+ const channelDocId = channel.startsWith("group:")
1133
+ ? channel.slice(6)
1134
+ : channel.startsWith("dm:")
1135
+ ? channel
1136
+ : channel;
1137
+ rootProvider.sendStateless(
1138
+ JSON.stringify({
1139
+ type: "messages:typing",
1140
+ channel_doc_id: channelDocId,
1141
+ }),
1142
+ );
1143
+ }
1144
+
1145
+ /** Graceful shutdown. */
1146
+ async destroy(): Promise<void> {
1147
+ this._stopTypingInterval();
1148
+ if (this._statusClearTimer) {
1149
+ clearTimeout(this._statusClearTimer);
1150
+ this._statusClearTimer = null;
1151
+ }
1152
+ if (this.evictionTimer) {
1153
+ clearInterval(this.evictionTimer);
1154
+ this.evictionTimer = null;
1155
+ }
1156
+
1157
+ for (const dispose of this._inboxDisposers) {
1158
+ try {
1159
+ dispose();
1160
+ } catch {
1161
+ /* ignore */
1162
+ }
1163
+ }
1164
+ this._inboxDisposers = [];
1165
+ this._inboxProvider?.destroy();
1166
+ this._inboxProvider = null;
1167
+ this._inboxDoc = null;
1168
+
1169
+ for (const [, cached] of this.childCache) {
1170
+ cached.provider.destroy();
1171
+ }
1172
+ this.childCache.clear();
1173
+
1174
+ // Clear awareness fields before destroying so other clients don't see stale state
1175
+ for (const [, conn] of this._spaceConnections) {
1176
+ conn.provider.awareness.setLocalStateField("status", null);
1177
+ conn.provider.awareness.setLocalStateField("activeToolCall", null);
1178
+ conn.provider.awareness.setLocalStateField("statusContext", null);
1179
+ conn.provider.awareness.setLocalStateField("turnId", null);
1180
+ conn.provider.awareness.setLocalStateField("toolHistory", []);
1181
+ conn.provider.destroy();
1182
+ }
1183
+ this._toolHistory = [];
1184
+ this._spaceConnections.clear();
1185
+ this._activeConnection = null;
1186
+
1187
+ console.error("[abracadabra-mcp] Shutdown complete");
1188
+ }
969
1189
  }