@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/dist/abracadabra-mcp.cjs +10151 -10023
- package/dist/abracadabra-mcp.cjs.map +1 -1
- package/dist/abracadabra-mcp.esm.js +10145 -10017
- package/dist/abracadabra-mcp.esm.js.map +1 -1
- package/dist/index.d.ts +29 -2
- package/package.json +2 -2
- package/src/hook-bridge.ts +305 -197
- package/src/index.ts +150 -136
- package/src/server.ts +1160 -940
- package/src/tools/channel.ts +138 -98
- package/src/tools/hooks.ts +44 -35
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
|
-
|
|
8
|
+
|
|
9
|
+
import type { DocumentMeta, ServerInfo } from "@abraca/dabra";
|
|
9
10
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from
|
|
18
|
-
import type {
|
|
19
|
-
import type { McpServer } from
|
|
20
|
-
import
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
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 =
|
|
33
|
+
export type TriggerMode = "all" | "mention" | "task" | "mention+task";
|
|
33
34
|
|
|
34
35
|
export interface MCPServerConfig {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
doc: Y.Doc;
|
|
48
|
+
provider: AbracadabraProvider;
|
|
49
|
+
docId: string;
|
|
49
50
|
}
|
|
50
51
|
|
|
51
52
|
interface CachedProvider {
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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
|
}
|