karma365-mcp 2.0.0 → 2.1.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.
Potentially problematic release.
This version of karma365-mcp might be problematic. Click here for more details.
- package/README.md +30 -1
- package/index.js +1795 -1446
- package/package.json +2 -1
- package/youtube_check.js +76 -0
package/index.js
CHANGED
|
@@ -1,1446 +1,1795 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
3
|
-
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
5
|
-
import fs from 'fs'
|
|
6
|
-
import path from 'path'
|
|
7
|
-
import os from 'os'
|
|
8
|
-
import crypto from 'node:crypto'
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
const
|
|
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
|
-
if
|
|
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
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
}
|
|
1264
|
-
|
|
1265
|
-
case '
|
|
1266
|
-
const
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
db.prepare('DELETE FROM
|
|
1282
|
-
return `✓ Deleted
|
|
1283
|
-
}
|
|
1284
|
-
|
|
1285
|
-
case '
|
|
1286
|
-
const
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
const
|
|
1323
|
-
const
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
}
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
}
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
}
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
}
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
const
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
)
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js'
|
|
3
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
4
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'
|
|
5
|
+
import fs from 'fs'
|
|
6
|
+
import path from 'path'
|
|
7
|
+
import os from 'os'
|
|
8
|
+
import crypto from 'node:crypto'
|
|
9
|
+
import { checkYouTubeVideo } from './youtube_check.js'
|
|
10
|
+
|
|
11
|
+
// ── Secure link to the phone ──────────────────────────────────────────────────
|
|
12
|
+
// Same protocol as karma_flutter/lib/services/device_link.dart. The device key is
|
|
13
|
+
// made during pairing, never crosses the network, and stays in the file below.
|
|
14
|
+
|
|
15
|
+
const LINK_FILE = path.join(os.homedir(), '.karma365', 'link.json')
|
|
16
|
+
const LOCAL_ONLY = process.env.KARMA365_LOCAL_ONLY === 'true' || process.env.KARMA365_PHONE_URL === 'none' || process.env.KARMA365_PHONE_URL === 'false'
|
|
17
|
+
const MAX_LOCAL_FILE_BYTES = 50 * 1024 * 1024
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The token in the MCP config. A key is made from it, so the token itself is never
|
|
21
|
+
* sent; the phone makes the same key. Same as DeviceLinks.setToken on the phone.
|
|
22
|
+
*/
|
|
23
|
+
function linkFromToken(token) {
|
|
24
|
+
return {
|
|
25
|
+
deviceId: crypto.createHash('sha256').update(`karma365-token-id|${token}`).digest('hex').slice(0, 24),
|
|
26
|
+
key: Buffer.from(crypto.hkdfSync('sha256', Buffer.from(token), Buffer.alloc(0), Buffer.from('karma365-token-v2'), 32)).toString('base64'),
|
|
27
|
+
fromToken: true,
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function readLink() {
|
|
32
|
+
const token = (process.env.KARMA365_TOKEN || '').trim()
|
|
33
|
+
if (token) return linkFromToken(token)
|
|
34
|
+
try {
|
|
35
|
+
const link = JSON.parse(fs.readFileSync(LINK_FILE, 'utf-8'))
|
|
36
|
+
return link && link.deviceId && link.key ? link : null
|
|
37
|
+
} catch (_) {
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeLink(link) {
|
|
43
|
+
fs.mkdirSync(path.dirname(LINK_FILE), { recursive: true, mode: 0o700 })
|
|
44
|
+
fs.writeFileSync(LINK_FILE, JSON.stringify(link, null, 2), { mode: 0o600 })
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const linkAad = (direction, deviceId) => Buffer.from(`karma365-v2|${direction}|${deviceId}`)
|
|
48
|
+
|
|
49
|
+
function sealForPhone(link, message) {
|
|
50
|
+
const iv = crypto.randomBytes(12)
|
|
51
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', Buffer.from(link.key, 'base64'), iv)
|
|
52
|
+
cipher.setAAD(linkAad('c2s', link.deviceId))
|
|
53
|
+
const body = Buffer.concat([cipher.update(JSON.stringify(message), 'utf-8'), cipher.final(), cipher.getAuthTag()])
|
|
54
|
+
return { d: link.deviceId, iv: iv.toString('base64'), c: body.toString('base64') }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function openFromPhone(link, envelope) {
|
|
58
|
+
const sealed = Buffer.from(envelope.c, 'base64')
|
|
59
|
+
const decipher = crypto.createDecipheriv('aes-256-gcm', Buffer.from(link.key, 'base64'), Buffer.from(envelope.iv, 'base64'))
|
|
60
|
+
decipher.setAAD(linkAad('s2c', link.deviceId))
|
|
61
|
+
decipher.setAuthTag(sealed.subarray(sealed.length - 16))
|
|
62
|
+
return JSON.parse(Buffer.concat([decipher.update(sealed.subarray(0, sealed.length - 16)), decipher.final()]).toString('utf-8'))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** One sealed request. Throws if whatever answers cannot prove it holds the key. */
|
|
66
|
+
async function secureCall(baseUrl, link, method, routePath, body, timeoutMs = 8000) {
|
|
67
|
+
const nonce = crypto.randomBytes(16).toString('hex')
|
|
68
|
+
const res = await fetch(`${baseUrl}/api/secure`, {
|
|
69
|
+
method: 'POST',
|
|
70
|
+
headers: { 'Content-Type': 'application/json' },
|
|
71
|
+
redirect: 'error',
|
|
72
|
+
body: JSON.stringify(sealForPhone(link, {
|
|
73
|
+
ts: Date.now(),
|
|
74
|
+
n: nonce,
|
|
75
|
+
m: method,
|
|
76
|
+
p: routePath,
|
|
77
|
+
h: { 'content-type': 'application/json' },
|
|
78
|
+
b: body === undefined ? null : Buffer.from(JSON.stringify(body)).toString('base64'),
|
|
79
|
+
})),
|
|
80
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
81
|
+
})
|
|
82
|
+
if (res.status === 401) {
|
|
83
|
+
const error = new Error('The phone did not accept this PC. Copy the current token from KARMA 365: Settings, AI MCP.')
|
|
84
|
+
error.notPaired = true
|
|
85
|
+
throw error
|
|
86
|
+
}
|
|
87
|
+
if (!res.ok) throw new Error(`Phone returned status ${res.status}`)
|
|
88
|
+
const message = openFromPhone(link, await res.json())
|
|
89
|
+
if (message.n !== nonce) throw new Error('Phone reply did not match this request')
|
|
90
|
+
return { status: message.s, body: message.b ? JSON.parse(Buffer.from(message.b, 'base64').toString('utf-8')) : {} }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function runPairCommand() {
|
|
94
|
+
const flag = process.argv.indexOf('--phone')
|
|
95
|
+
const raw = (flag !== -1 ? process.argv[flag + 1] : process.env.KARMA365_PHONE_URL) || ''
|
|
96
|
+
if (!raw) {
|
|
97
|
+
process.stderr.write('Usage: npx karma365-mcp pair --phone http://<phone-ip>:8365\n(The address is in the app: Settings, AI MCP.)\n')
|
|
98
|
+
process.exit(1)
|
|
99
|
+
}
|
|
100
|
+
const baseUrl = raw.trim().replace(/\/+$/, '')
|
|
101
|
+
const keys = crypto.generateKeyPairSync('x25519')
|
|
102
|
+
const clientPublic = keys.publicKey.export({ type: 'spki', format: 'der' }).subarray(-32)
|
|
103
|
+
|
|
104
|
+
// Commit before seeing the phone's key, so nothing in the middle can search for
|
|
105
|
+
// keys that make the two codes come out the same.
|
|
106
|
+
const first = await fetch(`${baseUrl}/api/pair/request`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: { 'Content-Type': 'application/json' },
|
|
109
|
+
redirect: 'error',
|
|
110
|
+
body: JSON.stringify({
|
|
111
|
+
client_name: `MCP on ${os.hostname()}`,
|
|
112
|
+
commit: crypto.createHash('sha256').update(clientPublic).digest('base64'),
|
|
113
|
+
}),
|
|
114
|
+
})
|
|
115
|
+
const started = await first.json().catch(() => ({}))
|
|
116
|
+
if (!first.ok || !started.server_pub) throw new Error(started.error || `Pairing rejected (${first.status})`)
|
|
117
|
+
|
|
118
|
+
const serverPublic = Buffer.from(started.server_pub, 'base64')
|
|
119
|
+
const phoneKey = crypto.createPublicKey({
|
|
120
|
+
key: Buffer.concat([Buffer.from('302a300506032b656e032100', 'hex'), serverPublic]),
|
|
121
|
+
format: 'der',
|
|
122
|
+
type: 'spki',
|
|
123
|
+
})
|
|
124
|
+
const shared = crypto.diffieHellman({ privateKey: keys.privateKey, publicKey: phoneKey })
|
|
125
|
+
const salt = crypto.createHash('sha256').update(Buffer.concat([clientPublic, serverPublic])).digest()
|
|
126
|
+
const derived = Buffer.from(crypto.hkdfSync('sha256', shared, salt, Buffer.from('karma365-pair-v2'), 36))
|
|
127
|
+
const code = String(derived.readUInt32BE(32) % 1000000).padStart(6, '0')
|
|
128
|
+
|
|
129
|
+
process.stderr.write(`\nPairing code: ${code}\nIf your phone shows the same code, tap Allow there. Otherwise tap Decline.\n\n`)
|
|
130
|
+
|
|
131
|
+
const second = await fetch(`${baseUrl}/api/pair/confirm`, {
|
|
132
|
+
method: 'POST',
|
|
133
|
+
headers: { 'Content-Type': 'application/json' },
|
|
134
|
+
redirect: 'error',
|
|
135
|
+
body: JSON.stringify({ pair_id: started.pair_id, client_pub: clientPublic.toString('base64') }),
|
|
136
|
+
})
|
|
137
|
+
const finished = await second.json().catch(() => ({}))
|
|
138
|
+
if (!second.ok || !finished.device_id) throw new Error(finished.error || `Pairing rejected (${second.status})`)
|
|
139
|
+
|
|
140
|
+
writeLink({ deviceId: finished.device_id, key: derived.subarray(0, 32).toString('base64'), phoneUrl: baseUrl })
|
|
141
|
+
process.stderr.write('Paired. This PC can now reach your phone.\n')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (process.argv[2] === 'pair') {
|
|
145
|
+
try {
|
|
146
|
+
await runPairCommand()
|
|
147
|
+
// Let the HTTP client finish closing its sockets; exiting mid-close can crash
|
|
148
|
+
// Node on Windows even though pairing succeeded.
|
|
149
|
+
await new Promise((resolve) => setTimeout(resolve, 200))
|
|
150
|
+
process.exit(0)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
process.stderr.write(`Pairing failed: ${err.message}\n`)
|
|
153
|
+
process.exit(1)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Files an AI may send to the phone. A web page can trick an AI into asking for
|
|
159
|
+
* any path, so nothing is readable until the user names folders for it.
|
|
160
|
+
*/
|
|
161
|
+
function assertFileMaySend(localPath) {
|
|
162
|
+
const real = fs.realpathSync(localPath)
|
|
163
|
+
const blocked = /(^|[\\/])(\.ssh|\.aws|\.gnupg|\.env[^\\/]*|[^\\/]*\.pem|id_rsa[^\\/]*|[^\\/]*\.keystore|[^\\/]*\.jks|link\.json|\.mcp\.json|claude_desktop_config\.json|key\.properties|secret\.md|\.secret_salt[^\\/]*|\.npmrc|\.netrc|\.git-credentials)($|[\\/])/i
|
|
164
|
+
if (blocked.test(real)) throw new Error('This file holds secrets and cannot be sent.')
|
|
165
|
+
const listed = (process.env.KARMA365_ALLOWED_DIRS || '').split(path.delimiter).filter(Boolean)
|
|
166
|
+
const usual = ['Desktop', 'Documents', 'Downloads', 'Pictures', 'Music', 'Videos'].map((dir) => path.join(os.homedir(), dir))
|
|
167
|
+
const allowed = (listed.length ? listed : usual)
|
|
168
|
+
.flatMap((dir) => {
|
|
169
|
+
try { return [fs.realpathSync(dir)] } catch (_) { return [] }
|
|
170
|
+
})
|
|
171
|
+
if (!allowed.some((dir) => real === dir || real.startsWith(dir + path.sep))) {
|
|
172
|
+
throw new Error('Files can be sent from Desktop, Documents, Downloads, Pictures, Music and Videos. For other folders, list them in KARMA365_ALLOWED_DIRS.')
|
|
173
|
+
}
|
|
174
|
+
if (fs.statSync(real).size > MAX_LOCAL_FILE_BYTES) throw new Error('This file is larger than 50 MB.')
|
|
175
|
+
return real
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Database connection & Auto-detect ─────────────────────────────────────────
|
|
179
|
+
|
|
180
|
+
function autoDetectDbPath() {
|
|
181
|
+
if (process.env.KARMA365_DB && process.env.KARMA365_DB.trim()) {
|
|
182
|
+
const custom = path.resolve(process.env.KARMA365_DB.trim())
|
|
183
|
+
const dir = path.dirname(custom)
|
|
184
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
185
|
+
return custom
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const home = os.homedir()
|
|
189
|
+
const candidates = [
|
|
190
|
+
// Windows AppData standard
|
|
191
|
+
process.env.APPDATA ? path.join(process.env.APPDATA, 'karma365', 'karma365.db') : null,
|
|
192
|
+
// macOS standard
|
|
193
|
+
path.join(home, 'Library', 'Application Support', 'com.karma365.app', 'karma365.db'),
|
|
194
|
+
// Linux standard
|
|
195
|
+
path.join(home, '.local', 'share', 'karma365', 'karma365.db'),
|
|
196
|
+
// Standard cross-platform hidden folder
|
|
197
|
+
path.join(home, '.karma365', 'karma365.db'),
|
|
198
|
+
// Current working directory (if user runs from repo/project folder)
|
|
199
|
+
path.join(process.cwd(), 'karma365.db'),
|
|
200
|
+
].filter(Boolean)
|
|
201
|
+
|
|
202
|
+
for (const candidate of candidates) {
|
|
203
|
+
if (fs.existsSync(candidate)) return candidate
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Fallback: create default ~/.karma365/karma365.db
|
|
207
|
+
const defaultPath = path.join(home, '.karma365', 'karma365.db')
|
|
208
|
+
const dir = path.dirname(defaultPath)
|
|
209
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
210
|
+
return defaultPath
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const DB_PATH = LOCAL_ONLY ? autoDetectDbPath() : null
|
|
214
|
+
if (LOCAL_ONLY) process.stderr.write(`[karma365-mcp] Using SQLite database: ${DB_PATH}\n`)
|
|
215
|
+
|
|
216
|
+
let DatabaseSync
|
|
217
|
+
if (LOCAL_ONLY) {
|
|
218
|
+
try {
|
|
219
|
+
const mod = await import('node:sqlite')
|
|
220
|
+
DatabaseSync = mod.DatabaseSync
|
|
221
|
+
} catch (_) {
|
|
222
|
+
try {
|
|
223
|
+
const mod = await import('better-sqlite3')
|
|
224
|
+
DatabaseSync = mod.default
|
|
225
|
+
} catch (err) {
|
|
226
|
+
process.stderr.write('ERROR: SQLite support requires Node 22.5+ (node:sqlite) or better-sqlite3.\n')
|
|
227
|
+
process.exit(1)
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const db = LOCAL_ONLY ? new DatabaseSync(DB_PATH) : null
|
|
233
|
+
|
|
234
|
+
// Initialize schema if not present
|
|
235
|
+
if (db) db.exec('PRAGMA foreign_keys = ON;')
|
|
236
|
+
if (db) db.exec(`
|
|
237
|
+
CREATE TABLE IF NOT EXISTS major_topics (
|
|
238
|
+
id TEXT PRIMARY KEY,
|
|
239
|
+
name TEXT NOT NULL,
|
|
240
|
+
color TEXT,
|
|
241
|
+
note TEXT,
|
|
242
|
+
note_title TEXT,
|
|
243
|
+
created_at TEXT NOT NULL,
|
|
244
|
+
is_pinned INTEGER NOT NULL DEFAULT 0,
|
|
245
|
+
is_starred INTEGER NOT NULL DEFAULT 0,
|
|
246
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
247
|
+
synced INTEGER NOT NULL DEFAULT 0
|
|
248
|
+
);
|
|
249
|
+
|
|
250
|
+
CREATE TABLE IF NOT EXISTS topics (
|
|
251
|
+
id TEXT PRIMARY KEY,
|
|
252
|
+
name TEXT NOT NULL,
|
|
253
|
+
difficulty TEXT CHECK (difficulty IN ('Easy','Medium','Hard')),
|
|
254
|
+
color TEXT,
|
|
255
|
+
major_topic_id TEXT REFERENCES major_topics(id) ON DELETE SET NULL,
|
|
256
|
+
parent_topic_id TEXT REFERENCES topics(id) ON DELETE SET NULL,
|
|
257
|
+
note TEXT,
|
|
258
|
+
note_title TEXT,
|
|
259
|
+
created_at TEXT NOT NULL,
|
|
260
|
+
updated_at TEXT,
|
|
261
|
+
last_opened TEXT,
|
|
262
|
+
visit_count INTEGER NOT NULL DEFAULT 0,
|
|
263
|
+
is_pinned INTEGER NOT NULL DEFAULT 0,
|
|
264
|
+
is_starred INTEGER NOT NULL DEFAULT 0,
|
|
265
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
266
|
+
synced INTEGER NOT NULL DEFAULT 0
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
CREATE TABLE IF NOT EXISTS resources (
|
|
270
|
+
id TEXT PRIMARY KEY,
|
|
271
|
+
topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
|
|
272
|
+
type TEXT NOT NULL,
|
|
273
|
+
description TEXT NOT NULL DEFAULT '',
|
|
274
|
+
path_or_url TEXT NOT NULL,
|
|
275
|
+
original_filename TEXT,
|
|
276
|
+
youtube_video_id TEXT,
|
|
277
|
+
thumbnail_path TEXT,
|
|
278
|
+
is_completed INTEGER NOT NULL DEFAULT 0,
|
|
279
|
+
is_pinned INTEGER NOT NULL DEFAULT 0,
|
|
280
|
+
is_starred INTEGER NOT NULL DEFAULT 0,
|
|
281
|
+
sort_order INTEGER NOT NULL DEFAULT 0,
|
|
282
|
+
notes TEXT,
|
|
283
|
+
created_at TEXT,
|
|
284
|
+
synced INTEGER NOT NULL DEFAULT 0
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
CREATE TABLE IF NOT EXISTS todos (
|
|
288
|
+
id TEXT PRIMARY KEY,
|
|
289
|
+
name TEXT NOT NULL,
|
|
290
|
+
priority TEXT NOT NULL CHECK (priority IN ('high','medium','low')) DEFAULT 'medium',
|
|
291
|
+
is_completed INTEGER NOT NULL DEFAULT 0,
|
|
292
|
+
notes TEXT,
|
|
293
|
+
created_at TEXT NOT NULL,
|
|
294
|
+
updated_at TEXT NOT NULL
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
CREATE TABLE IF NOT EXISTS settings (
|
|
298
|
+
key TEXT PRIMARY KEY,
|
|
299
|
+
value TEXT NOT NULL
|
|
300
|
+
);
|
|
301
|
+
|
|
302
|
+
CREATE INDEX IF NOT EXISTS idx_topics_major ON topics(major_topic_id);
|
|
303
|
+
CREATE INDEX IF NOT EXISTS idx_topics_parent ON topics(parent_topic_id);
|
|
304
|
+
CREATE INDEX IF NOT EXISTS idx_resources_topic ON resources(topic_id);
|
|
305
|
+
`)
|
|
306
|
+
|
|
307
|
+
// Helpers
|
|
308
|
+
function newId() { return crypto.randomUUID() }
|
|
309
|
+
function nowIso() { return new Date().toISOString() }
|
|
310
|
+
|
|
311
|
+
function isUuid(s) {
|
|
312
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(String(s || ''))
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const COLOR_MAP = {
|
|
316
|
+
red: '#ef4444', laal: '#ef4444', lal: '#ef4444',
|
|
317
|
+
blue: '#3b82f6', neela: '#3b82f6', nilo: '#3b82f6',
|
|
318
|
+
green: '#22c55e', hara: '#22c55e', hario: '#22c55e',
|
|
319
|
+
yellow: '#eab308', peela: '#eab308', pilo: '#eab308',
|
|
320
|
+
purple: '#a855f7', jambli: '#a855f7',
|
|
321
|
+
pink: '#ec4899', gulabi: '#ec4899',
|
|
322
|
+
orange: '#f97316', narangi: '#f97316',
|
|
323
|
+
teal: '#14b8a6', indigo: '#6366f1', violet: '#8b5cf6',
|
|
324
|
+
gray: '#6b7280', grey: '#6b7280', coral: '#f87171',
|
|
325
|
+
navy: '#1e3a8a', rose: '#f43f5e', amber: '#f59e0b',
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function resolveColor(c) {
|
|
329
|
+
if (!c) return null
|
|
330
|
+
const s = String(c).trim()
|
|
331
|
+
if (/^#?[0-9a-f]{6}$/i.test(s)) return s.startsWith('#') ? s : `#${s}`
|
|
332
|
+
return COLOR_MAP[s.toLowerCase()] || s
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function cleanName(s) {
|
|
336
|
+
return ` ${String(s).toLowerCase().trim()} `
|
|
337
|
+
.replace(/\bsub[- ]?topics?\b/g, ' ')
|
|
338
|
+
.replace(/\b(major|minor)\b/g, ' ')
|
|
339
|
+
.replace(/\btopics?\b/g, ' ')
|
|
340
|
+
.replace(/\bcategor(?:y|ies)\b/g, ' ')
|
|
341
|
+
.replace(/\s+/g, ' ')
|
|
342
|
+
.trim()
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function findByName(items, nameOrId) {
|
|
346
|
+
const lower = String(nameOrId).toLowerCase().trim()
|
|
347
|
+
const cleaned = cleanName(nameOrId)
|
|
348
|
+
return items.find(x => x.name.toLowerCase() === lower)
|
|
349
|
+
|| (cleaned && items.find(x => x.name.toLowerCase() === cleaned))
|
|
350
|
+
|| (cleaned && items.find(x => x.name.toLowerCase().includes(cleaned)))
|
|
351
|
+
|| (lower && items.find(x => x.name.toLowerCase().includes(lower)))
|
|
352
|
+
|| null
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function resolveTopic(nameOrId) {
|
|
356
|
+
if (isUuid(nameOrId)) {
|
|
357
|
+
const row = db.prepare('SELECT * FROM topics WHERE id = ?').get(nameOrId)
|
|
358
|
+
if (row) return row
|
|
359
|
+
}
|
|
360
|
+
const all = db.prepare('SELECT * FROM topics').all()
|
|
361
|
+
const found = findByName(all, nameOrId)
|
|
362
|
+
if (!found) throw new Error(`Topic not found: "${nameOrId}"`)
|
|
363
|
+
return found
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function resolveMajorTopic(nameOrId) {
|
|
367
|
+
if (isUuid(nameOrId)) {
|
|
368
|
+
const row = db.prepare('SELECT * FROM major_topics WHERE id = ?').get(nameOrId)
|
|
369
|
+
if (row) return row
|
|
370
|
+
}
|
|
371
|
+
const all = db.prepare('SELECT * FROM major_topics').all()
|
|
372
|
+
const found = findByName(all, nameOrId)
|
|
373
|
+
if (!found) throw new Error(`Major topic not found: "${nameOrId}"`)
|
|
374
|
+
return found
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function resolveTodo(nameOrId) {
|
|
378
|
+
if (isUuid(nameOrId)) {
|
|
379
|
+
const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(nameOrId)
|
|
380
|
+
if (row) return row
|
|
381
|
+
}
|
|
382
|
+
const all = db.prepare('SELECT * FROM todos').all()
|
|
383
|
+
const found = all.find(t => t.name.toLowerCase() === String(nameOrId).toLowerCase().trim())
|
|
384
|
+
if (!found) throw new Error(`Todo not found: "${nameOrId}"`)
|
|
385
|
+
return found
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ── Security & PIN System ─────────────────────────────────────────────────────
|
|
389
|
+
|
|
390
|
+
function hashPin(pin, salt) {
|
|
391
|
+
return `scrypt:${crypto.scryptSync(String(pin), salt, 32).toString('hex')}`
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
const PIN_MAX_ATTEMPTS = 6
|
|
395
|
+
const PIN_LOCK_MS = 30000
|
|
396
|
+
|
|
397
|
+
function getSetting(key) {
|
|
398
|
+
try {
|
|
399
|
+
const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key)
|
|
400
|
+
return row ? row.value : null
|
|
401
|
+
} catch {
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function setSetting(key, value) {
|
|
407
|
+
db.prepare(`INSERT INTO settings (key, value) VALUES (?, ?)
|
|
408
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(key, String(value))
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function hasPin() {
|
|
412
|
+
const hash = getSetting('pin_hash')
|
|
413
|
+
const salt = getSetting('pin_salt')
|
|
414
|
+
return Boolean(hash && salt)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function setMasterPin(newPin) {
|
|
418
|
+
const trimmed = String(newPin ?? '').trim()
|
|
419
|
+
if (!trimmed) {
|
|
420
|
+
throw new Error('PIN cannot be empty. Please provide a numeric PIN (e.g. 111111).')
|
|
421
|
+
}
|
|
422
|
+
const salt = crypto.randomBytes(16).toString('hex')
|
|
423
|
+
setSetting('pin_salt', salt)
|
|
424
|
+
setSetting('pin_hash', hashPin(trimmed, salt))
|
|
425
|
+
setSetting('pin_fails', 0)
|
|
426
|
+
setSetting('pin_lock_until', 0)
|
|
427
|
+
return true
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function sameText(a, b) {
|
|
431
|
+
const left = Buffer.from(String(a))
|
|
432
|
+
const right = Buffer.from(String(b))
|
|
433
|
+
return left.length === right.length && crypto.timingSafeEqual(left, right)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function verifyPin(pin) {
|
|
437
|
+
const salt = getSetting('pin_salt')
|
|
438
|
+
const storedHash = getSetting('pin_hash')
|
|
439
|
+
if (!salt || !storedHash) return false
|
|
440
|
+
const trimmed = String(pin ?? '').trim()
|
|
441
|
+
if (!trimmed) return false
|
|
442
|
+
if (Number(getSetting('pin_lock_until') || 0) > Date.now()) return false
|
|
443
|
+
|
|
444
|
+
const isOldHash = !storedHash.startsWith('scrypt:')
|
|
445
|
+
const ok = isOldHash
|
|
446
|
+
? sameText(crypto.createHash('sha256').update(salt + trimmed).digest('hex'), storedHash)
|
|
447
|
+
: sameText(hashPin(trimmed, salt), storedHash)
|
|
448
|
+
if (ok) {
|
|
449
|
+
setSetting('pin_fails', 0)
|
|
450
|
+
if (isOldHash) setSetting('pin_hash', hashPin(trimmed, salt))
|
|
451
|
+
return true
|
|
452
|
+
}
|
|
453
|
+
const fails = Number(getSetting('pin_fails') || 0) + 1
|
|
454
|
+
if (fails >= PIN_MAX_ATTEMPTS) {
|
|
455
|
+
setSetting('pin_fails', 0)
|
|
456
|
+
setSetting('pin_lock_until', Date.now() + PIN_LOCK_MS)
|
|
457
|
+
} else {
|
|
458
|
+
setSetting('pin_fails', fails)
|
|
459
|
+
}
|
|
460
|
+
return false
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** True when this topic, a topic above it, or its major topic is locked. */
|
|
464
|
+
function isTopicLocked(topic) {
|
|
465
|
+
const seen = new Set()
|
|
466
|
+
let current = topic
|
|
467
|
+
while (current && !seen.has(current.id)) {
|
|
468
|
+
seen.add(current.id)
|
|
469
|
+
if (current.is_pinned === 1) return true
|
|
470
|
+
if (current.major_topic_id) {
|
|
471
|
+
const major = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(current.major_topic_id)
|
|
472
|
+
if (major && major.is_pinned === 1) return true
|
|
473
|
+
}
|
|
474
|
+
current = current.parent_topic_id
|
|
475
|
+
? db.prepare('SELECT * FROM topics WHERE id = ?').get(current.parent_topic_id)
|
|
476
|
+
: null
|
|
477
|
+
}
|
|
478
|
+
return false
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Checks if a locked item requires PIN verification.
|
|
483
|
+
* Returns:
|
|
484
|
+
* 'ok' -> Not locked OR valid PIN provided
|
|
485
|
+
* 'required' -> Item is locked and no PIN provided
|
|
486
|
+
* 'invalid' -> Item is locked and wrong PIN provided
|
|
487
|
+
*/
|
|
488
|
+
function checkPinIfLocked(isPinned, pin) {
|
|
489
|
+
if (!isPinned || isPinned === 0) return 'ok'
|
|
490
|
+
if (!hasPin()) return 'required'
|
|
491
|
+
const trimmed = String(pin ?? '').trim()
|
|
492
|
+
if (!trimmed) return 'required'
|
|
493
|
+
return verifyPin(trimmed) ? 'ok' : 'invalid'
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
const PIN_REQ = (name, action = 'this action') =>
|
|
497
|
+
`PIN_REQUIRED: "${name}" is locked/PIN-protected 🔒. Prompt the user for their PIN to authorize ${action}, then retry with pin="<their_pin>".`
|
|
498
|
+
|
|
499
|
+
const PIN_BAD = (name, action = 'performed') =>
|
|
500
|
+
`PIN_INVALID: Authentication failed — incorrect PIN entered ❌. "${name}" was NOT ${action}.`
|
|
501
|
+
|
|
502
|
+
// ── Search helpers (DuckDuckGo / SearXNG) ─────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
const SEARCH_UA = 'Mozilla/5.0 (compatible; KarmaSearch/2.0)'
|
|
505
|
+
|
|
506
|
+
async function fetchWithTimeout(url, opts = {}, ms = 8000) {
|
|
507
|
+
const ctrl = new AbortController()
|
|
508
|
+
const t = setTimeout(() => ctrl.abort(), ms)
|
|
509
|
+
try {
|
|
510
|
+
return await fetch(url, { ...opts, signal: ctrl.signal })
|
|
511
|
+
} finally {
|
|
512
|
+
clearTimeout(t)
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
async function searchWebKeyless(query) {
|
|
517
|
+
const searx = [
|
|
518
|
+
'https://searx.be',
|
|
519
|
+
'https://searxng.world',
|
|
520
|
+
'https://search.mdosch.de',
|
|
521
|
+
'https://searx.tiekoetter.com',
|
|
522
|
+
]
|
|
523
|
+
for (const base of searx) {
|
|
524
|
+
try {
|
|
525
|
+
const res = await fetchWithTimeout(
|
|
526
|
+
`${base}/search?q=${encodeURIComponent(query)}&format=json&categories=general&language=en-US`,
|
|
527
|
+
{ headers: { 'User-Agent': SEARCH_UA, 'Accept': 'application/json' } })
|
|
528
|
+
if (!res.ok) continue
|
|
529
|
+
const data = await res.json()
|
|
530
|
+
const items = (data.results || [])
|
|
531
|
+
.filter(r => r.url && String(r.url).startsWith('http') && r.title)
|
|
532
|
+
.slice(0, 5)
|
|
533
|
+
.map(r => ({ title: String(r.title).trim(), url: String(r.url).trim(),
|
|
534
|
+
snippet: String(r.content || '').slice(0, 200) }))
|
|
535
|
+
if (items.length) return items
|
|
536
|
+
} catch { /* try next */ }
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// DuckDuckGo Instant Answer fallback
|
|
540
|
+
try {
|
|
541
|
+
const res = await fetchWithTimeout(
|
|
542
|
+
`https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`,
|
|
543
|
+
{}, 6000)
|
|
544
|
+
if (res.ok) {
|
|
545
|
+
const data = await res.json()
|
|
546
|
+
const out = []
|
|
547
|
+
if (data.AbstractText && data.AbstractURL) {
|
|
548
|
+
out.push({ title: data.Heading || query, url: data.AbstractURL,
|
|
549
|
+
snippet: String(data.AbstractText).slice(0, 200) })
|
|
550
|
+
}
|
|
551
|
+
for (const it of (data.RelatedTopics || []).slice(0, 4)) {
|
|
552
|
+
if (it.FirstURL && it.Text) {
|
|
553
|
+
out.push({ title: String(it.Text).slice(0, 100), url: it.FirstURL, snippet: '' })
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
if (out.length) return out
|
|
557
|
+
}
|
|
558
|
+
} catch { /* ignore */ }
|
|
559
|
+
|
|
560
|
+
return [{ title: `Search: ${query}`,
|
|
561
|
+
url: `https://duckduckgo.com/?q=${encodeURIComponent(query)}`,
|
|
562
|
+
snippet: `Web results for "${query}"` }]
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// ── MCP Tool Definitions ──────────────────────────────────────────────────────
|
|
566
|
+
|
|
567
|
+
const TOOLS = [
|
|
568
|
+
{
|
|
569
|
+
name: 'list_major_topics',
|
|
570
|
+
description: 'List all major topic categories in KARMA 365',
|
|
571
|
+
inputSchema: { type: 'object', properties: {} }
|
|
572
|
+
},
|
|
573
|
+
{
|
|
574
|
+
name: 'create_major_topic',
|
|
575
|
+
description: 'Create a new major topic category in KARMA 365',
|
|
576
|
+
inputSchema: {
|
|
577
|
+
type: 'object',
|
|
578
|
+
required: ['name'],
|
|
579
|
+
properties: {
|
|
580
|
+
name: { type: 'string', description: 'Name of the major topic' },
|
|
581
|
+
is_pinned: { type: 'boolean', description: 'Whether to pin this major topic' }
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
},
|
|
585
|
+
{
|
|
586
|
+
name: 'delete_major_topic',
|
|
587
|
+
description: 'Delete a major topic by name or ID. Its topics and resources are kept and moved into Karma 365, unless delete_children is true.',
|
|
588
|
+
inputSchema: {
|
|
589
|
+
type: 'object',
|
|
590
|
+
required: ['name'],
|
|
591
|
+
properties: {
|
|
592
|
+
name: { type: 'string', description: 'Major topic name or ID' },
|
|
593
|
+
delete_children: { type: 'boolean', description: 'Also permanently delete its topics and resources. Default false.' },
|
|
594
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
name: 'list_topics',
|
|
600
|
+
description: 'List topics in KARMA 365, optionally filtered by major topic, difficulty, or parent topic',
|
|
601
|
+
inputSchema: {
|
|
602
|
+
type: 'object',
|
|
603
|
+
properties: {
|
|
604
|
+
major_topic_name: { type: 'string', description: 'Filter by major topic name' },
|
|
605
|
+
difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
|
|
606
|
+
parent_topic_name: { type: 'string', description: 'List subtopics of this topic' }
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
name: 'get_topic',
|
|
612
|
+
description: 'Get full details of a topic including all its resources. PIN required if topic is locked.',
|
|
613
|
+
inputSchema: {
|
|
614
|
+
type: 'object',
|
|
615
|
+
required: ['name'],
|
|
616
|
+
properties: {
|
|
617
|
+
name: { type: 'string', description: 'Topic name or ID' },
|
|
618
|
+
pin: { type: 'string', description: 'Optional PIN required if the topic is locked' }
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
{
|
|
623
|
+
name: 'create_topic',
|
|
624
|
+
description: 'Create a new topic in KARMA 365. If parent major topic is locked, PIN is required.',
|
|
625
|
+
inputSchema: {
|
|
626
|
+
type: 'object',
|
|
627
|
+
required: ['name'],
|
|
628
|
+
properties: {
|
|
629
|
+
name: { type: 'string', description: 'Topic name' },
|
|
630
|
+
difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
|
|
631
|
+
major_topic_name: { type: 'string', description: 'Assign to this major topic' },
|
|
632
|
+
color: { type: 'string', description: 'Hex color code e.g. #FF5733' },
|
|
633
|
+
is_pinned: { type: 'boolean', description: 'Whether to lock/pin this topic' },
|
|
634
|
+
pin: { type: 'string', description: 'Optional PIN if parent major topic is locked' }
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
},
|
|
638
|
+
{
|
|
639
|
+
name: 'create_subtopic',
|
|
640
|
+
description: 'Create a subtopic under an existing topic. If parent topic is locked, PIN is required.',
|
|
641
|
+
inputSchema: {
|
|
642
|
+
type: 'object',
|
|
643
|
+
required: ['name', 'parent_topic_name'],
|
|
644
|
+
properties: {
|
|
645
|
+
name: { type: 'string', description: 'Subtopic name' },
|
|
646
|
+
parent_topic_name: { type: 'string', description: 'Parent topic name or ID' },
|
|
647
|
+
difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
|
|
648
|
+
is_pinned: { type: 'boolean', description: 'Whether to lock/pin this subtopic' },
|
|
649
|
+
pin: { type: 'string', description: 'Optional PIN if parent topic is locked' }
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
{
|
|
654
|
+
name: 'delete_topic',
|
|
655
|
+
description: 'Delete a topic and all its resources permanently.',
|
|
656
|
+
inputSchema: {
|
|
657
|
+
type: 'object',
|
|
658
|
+
required: ['name'],
|
|
659
|
+
properties: {
|
|
660
|
+
name: { type: 'string', description: 'Topic name or ID' },
|
|
661
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
},
|
|
665
|
+
{
|
|
666
|
+
name: 'list_resources',
|
|
667
|
+
description: 'List all resources attached to a topic',
|
|
668
|
+
inputSchema: {
|
|
669
|
+
type: 'object',
|
|
670
|
+
required: ['topic_name'],
|
|
671
|
+
properties: {
|
|
672
|
+
topic_name: { type: 'string', description: 'Topic name or ID' },
|
|
673
|
+
type: { type: 'string', enum: ['pdf', 'video', 'youtube', 'link', 'audio', 'image', 'code'] }
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
},
|
|
677
|
+
{
|
|
678
|
+
name: 'add_youtube',
|
|
679
|
+
description: 'Add one YouTube video to a topic. This server cannot search YouTube: when the user names a song or video, find it with your own web search first and pass its youtube.com/watch?v=… link. Never make up a video ID. The link is checked with YouTube before saving, and a video that does not exist is not saved. The title comes from YouTube when no description is given.',
|
|
680
|
+
inputSchema: {
|
|
681
|
+
type: 'object',
|
|
682
|
+
required: ['topic_name', 'url'],
|
|
683
|
+
properties: {
|
|
684
|
+
topic_name: { type: 'string' },
|
|
685
|
+
url: { type: 'string', description: 'Link to one YouTube video (watch, youtu.be or shorts)' },
|
|
686
|
+
description: { type: 'string', description: 'Label for this resource. Optional: the video title is used when empty.' },
|
|
687
|
+
pin: { type: 'string', description: 'Optional PIN if the topic is locked' }
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
name: 'add_link',
|
|
693
|
+
description: 'Add a web link to a topic',
|
|
694
|
+
inputSchema: {
|
|
695
|
+
type: 'object',
|
|
696
|
+
required: ['topic_name', 'url', 'description'],
|
|
697
|
+
properties: {
|
|
698
|
+
topic_name: { type: 'string' },
|
|
699
|
+
url: { type: 'string' },
|
|
700
|
+
description: { type: 'string' }
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
},
|
|
704
|
+
{
|
|
705
|
+
name: 'add_pdf',
|
|
706
|
+
description: 'Add a PDF resource (URL or local file path) to a topic',
|
|
707
|
+
inputSchema: {
|
|
708
|
+
type: 'object',
|
|
709
|
+
required: ['topic_name', 'url', 'description'],
|
|
710
|
+
properties: {
|
|
711
|
+
topic_name: { type: 'string' },
|
|
712
|
+
url: { type: 'string', description: 'http(s) URL or local file path' },
|
|
713
|
+
description: { type: 'string' }
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
{
|
|
718
|
+
name: 'add_audio',
|
|
719
|
+
description: 'Add an audio resource (URL or local file path) to a topic',
|
|
720
|
+
inputSchema: {
|
|
721
|
+
type: 'object',
|
|
722
|
+
required: ['topic_name', 'url', 'description'],
|
|
723
|
+
properties: {
|
|
724
|
+
topic_name: { type: 'string' },
|
|
725
|
+
url: { type: 'string', description: 'http(s) URL or local file path' },
|
|
726
|
+
description: { type: 'string' }
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
{
|
|
731
|
+
name: 'add_video',
|
|
732
|
+
description: 'Add a video resource (URL or local file path) to a topic',
|
|
733
|
+
inputSchema: {
|
|
734
|
+
type: 'object',
|
|
735
|
+
required: ['topic_name', 'url', 'description'],
|
|
736
|
+
properties: {
|
|
737
|
+
topic_name: { type: 'string' },
|
|
738
|
+
url: { type: 'string', description: 'http(s) URL or local file path' },
|
|
739
|
+
description: { type: 'string' }
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
},
|
|
743
|
+
{
|
|
744
|
+
name: 'add_image',
|
|
745
|
+
description: 'Add an image resource (URL or local file path) to a topic',
|
|
746
|
+
inputSchema: {
|
|
747
|
+
type: 'object',
|
|
748
|
+
required: ['topic_name', 'url', 'description'],
|
|
749
|
+
properties: {
|
|
750
|
+
topic_name: { type: 'string' },
|
|
751
|
+
url: { type: 'string', description: 'http(s) URL or local file path' },
|
|
752
|
+
description: { type: 'string' }
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
},
|
|
756
|
+
{
|
|
757
|
+
name: 'add_code',
|
|
758
|
+
description: 'Add a code file resource (URL or local file path) to a topic',
|
|
759
|
+
inputSchema: {
|
|
760
|
+
type: 'object',
|
|
761
|
+
required: ['topic_name', 'url', 'description'],
|
|
762
|
+
properties: {
|
|
763
|
+
topic_name: { type: 'string' },
|
|
764
|
+
url: { type: 'string', description: 'http(s) URL or local file path' },
|
|
765
|
+
description: { type: 'string' }
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
},
|
|
769
|
+
{
|
|
770
|
+
name: 'delete_resource',
|
|
771
|
+
description: 'Delete a resource by its ID.',
|
|
772
|
+
inputSchema: {
|
|
773
|
+
type: 'object',
|
|
774
|
+
required: ['resource_id'],
|
|
775
|
+
properties: {
|
|
776
|
+
resource_id: { type: 'string', description: 'Resource UUID' },
|
|
777
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
},
|
|
781
|
+
{
|
|
782
|
+
name: 'mark_resource_complete',
|
|
783
|
+
description: 'Mark a resource as studied/complete (or revert to incomplete)',
|
|
784
|
+
inputSchema: {
|
|
785
|
+
type: 'object',
|
|
786
|
+
required: ['resource_id', 'is_completed'],
|
|
787
|
+
properties: {
|
|
788
|
+
resource_id: { type: 'string' },
|
|
789
|
+
is_completed: { type: 'boolean' }
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
name: 'write_topic_note',
|
|
795
|
+
description: 'Write, add, or append notes/text into a topic note',
|
|
796
|
+
inputSchema: {
|
|
797
|
+
type: 'object',
|
|
798
|
+
required: ['topic_name', 'note'],
|
|
799
|
+
properties: {
|
|
800
|
+
topic_name: { type: 'string' },
|
|
801
|
+
note: { type: 'string' },
|
|
802
|
+
title: { type: 'string', description: 'Optional note title' },
|
|
803
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
{
|
|
808
|
+
name: 'list_todos',
|
|
809
|
+
description: 'List all todos in KARMA 365, optionally filtered by priority or completion',
|
|
810
|
+
inputSchema: {
|
|
811
|
+
type: 'object',
|
|
812
|
+
properties: {
|
|
813
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
814
|
+
is_completed: { type: 'boolean' }
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
name: 'create_todo',
|
|
820
|
+
description: 'Create a new task/todo in KARMA 365',
|
|
821
|
+
inputSchema: {
|
|
822
|
+
type: 'object',
|
|
823
|
+
required: ['name'],
|
|
824
|
+
properties: {
|
|
825
|
+
name: { type: 'string' },
|
|
826
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'Defaults to medium' },
|
|
827
|
+
notes: { type: 'string' }
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
name: 'complete_todo',
|
|
833
|
+
description: 'Mark a todo as done',
|
|
834
|
+
inputSchema: {
|
|
835
|
+
type: 'object',
|
|
836
|
+
required: ['name'],
|
|
837
|
+
properties: {
|
|
838
|
+
name: { type: 'string', description: 'Todo name or ID' }
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
},
|
|
842
|
+
{
|
|
843
|
+
name: 'update_todo',
|
|
844
|
+
description: 'Update a todo\'s name, priority, notes or completion status',
|
|
845
|
+
inputSchema: {
|
|
846
|
+
type: 'object',
|
|
847
|
+
required: ['id'],
|
|
848
|
+
properties: {
|
|
849
|
+
id: { type: 'string', description: 'Todo ID' },
|
|
850
|
+
name: { type: 'string' },
|
|
851
|
+
priority: { type: 'string', enum: ['high', 'medium', 'low'] },
|
|
852
|
+
notes: { type: 'string' },
|
|
853
|
+
is_completed: { type: 'boolean' }
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
name: 'delete_todo',
|
|
859
|
+
description: 'Delete a todo by name or ID',
|
|
860
|
+
inputSchema: {
|
|
861
|
+
type: 'object',
|
|
862
|
+
required: ['name'],
|
|
863
|
+
properties: {
|
|
864
|
+
name: { type: 'string', description: 'Todo name or ID' }
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
},
|
|
868
|
+
{
|
|
869
|
+
name: 'star_topic',
|
|
870
|
+
description: 'Star (favourite) or unstar a topic/subtopic.',
|
|
871
|
+
inputSchema: {
|
|
872
|
+
type: 'object',
|
|
873
|
+
required: ['name', 'starred'],
|
|
874
|
+
properties: {
|
|
875
|
+
name: { type: 'string', description: 'Topic name or ID' },
|
|
876
|
+
starred: { type: 'boolean', description: 'true = add to favourites, false = remove' }
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
},
|
|
880
|
+
{
|
|
881
|
+
name: 'star_major_topic',
|
|
882
|
+
description: 'Star (favourite) or unstar a major topic.',
|
|
883
|
+
inputSchema: {
|
|
884
|
+
type: 'object',
|
|
885
|
+
required: ['name', 'starred'],
|
|
886
|
+
properties: {
|
|
887
|
+
name: { type: 'string', description: 'Major topic name or ID' },
|
|
888
|
+
starred: { type: 'boolean', description: 'true = add to favourites, false = remove' }
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
},
|
|
892
|
+
{
|
|
893
|
+
name: 'pin_major_topic',
|
|
894
|
+
description: 'Pin (lock) or unpin a major topic. Unpinning requires PIN if locked.',
|
|
895
|
+
inputSchema: {
|
|
896
|
+
type: 'object',
|
|
897
|
+
required: ['name', 'pinned'],
|
|
898
|
+
properties: {
|
|
899
|
+
name: { type: 'string', description: 'Major topic name or ID' },
|
|
900
|
+
pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' },
|
|
901
|
+
pin: { type: 'string', description: 'Optional PIN required if currently locked and unpinning' }
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
{
|
|
906
|
+
name: 'pin_topic',
|
|
907
|
+
description: 'Pin (lock) or unpin a topic. Unpinning requires PIN if locked.',
|
|
908
|
+
inputSchema: {
|
|
909
|
+
type: 'object',
|
|
910
|
+
required: ['name', 'pinned'],
|
|
911
|
+
properties: {
|
|
912
|
+
name: { type: 'string', description: 'Topic name or ID' },
|
|
913
|
+
pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' },
|
|
914
|
+
pin: { type: 'string', description: 'Optional PIN required if currently locked and unpinning' }
|
|
915
|
+
}
|
|
916
|
+
}
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: 'set_major_topic_color',
|
|
920
|
+
description: 'Change the colour of a major topic. PIN required if locked.',
|
|
921
|
+
inputSchema: {
|
|
922
|
+
type: 'object',
|
|
923
|
+
required: ['name', 'color'],
|
|
924
|
+
properties: {
|
|
925
|
+
name: { type: 'string', description: 'Major topic name or ID' },
|
|
926
|
+
color: { type: 'string', description: 'Hex colour or color name' },
|
|
927
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
name: 'set_topic_color',
|
|
933
|
+
description: 'Change the colour of a topic. PIN required if locked.',
|
|
934
|
+
inputSchema: {
|
|
935
|
+
type: 'object',
|
|
936
|
+
required: ['name', 'color'],
|
|
937
|
+
properties: {
|
|
938
|
+
name: { type: 'string', description: 'Topic name or ID' },
|
|
939
|
+
color: { type: 'string', description: 'Hex colour or color name' },
|
|
940
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
},
|
|
944
|
+
{
|
|
945
|
+
name: 'rename_major_topic',
|
|
946
|
+
description: 'Rename a major topic. PIN required if locked.',
|
|
947
|
+
inputSchema: {
|
|
948
|
+
type: 'object',
|
|
949
|
+
required: ['name', 'new_name'],
|
|
950
|
+
properties: {
|
|
951
|
+
name: { type: 'string', description: 'CURRENT major topic name or ID' },
|
|
952
|
+
new_name: { type: 'string', description: 'The NEW name' },
|
|
953
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
},
|
|
957
|
+
{
|
|
958
|
+
name: 'rename_topic',
|
|
959
|
+
description: 'Rename a topic. PIN required if locked.',
|
|
960
|
+
inputSchema: {
|
|
961
|
+
type: 'object',
|
|
962
|
+
required: ['name', 'new_name'],
|
|
963
|
+
properties: {
|
|
964
|
+
name: { type: 'string', description: 'CURRENT topic name or ID' },
|
|
965
|
+
new_name: { type: 'string', description: 'The NEW name' },
|
|
966
|
+
pin: { type: 'string', description: 'Optional PIN if locked' }
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
{
|
|
971
|
+
name: 'set_pin',
|
|
972
|
+
description: 'Set or update the security PIN for KARMA 365. Used to unlock, view, edit, or delete locked/PIN-protected topics and categories.',
|
|
973
|
+
inputSchema: {
|
|
974
|
+
type: 'object',
|
|
975
|
+
required: ['new_pin'],
|
|
976
|
+
properties: {
|
|
977
|
+
new_pin: { type: 'string', description: 'New numeric PIN (e.g. 111111)' },
|
|
978
|
+
current_pin: { type: 'string', description: 'Current PIN (required if a PIN is already set)' }
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
},
|
|
982
|
+
{
|
|
983
|
+
name: 'verify_pin',
|
|
984
|
+
description: 'Verify if a PIN is correct against the KARMA 365 master PIN',
|
|
985
|
+
inputSchema: {
|
|
986
|
+
type: 'object',
|
|
987
|
+
required: ['pin'],
|
|
988
|
+
properties: {
|
|
989
|
+
pin: { type: 'string', description: 'PIN to test' }
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
{
|
|
994
|
+
name: 'delete_completed_todos',
|
|
995
|
+
description: 'Delete ALL completed todos/tasks at once.',
|
|
996
|
+
inputSchema: { type: 'object', properties: {} }
|
|
997
|
+
},
|
|
998
|
+
{
|
|
999
|
+
name: 'clean_empty_major_topics',
|
|
1000
|
+
description: 'Delete all major topics that have no resources in any of their subtopics.',
|
|
1001
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1002
|
+
},
|
|
1003
|
+
{
|
|
1004
|
+
name: 'get_stats',
|
|
1005
|
+
description: 'Get KARMA 365 dashboard statistics: topic counts, resource completion, todos',
|
|
1006
|
+
inputSchema: { type: 'object', properties: {} }
|
|
1007
|
+
},
|
|
1008
|
+
{
|
|
1009
|
+
name: 'search',
|
|
1010
|
+
description: 'Search across all topics, resources, and todos by keyword',
|
|
1011
|
+
inputSchema: {
|
|
1012
|
+
type: 'object',
|
|
1013
|
+
required: ['query'],
|
|
1014
|
+
properties: {
|
|
1015
|
+
query: { type: 'string', description: 'Search keyword' }
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
},
|
|
1019
|
+
{
|
|
1020
|
+
name: 'set_dark_mode',
|
|
1021
|
+
description: 'Turn the KARMA 365 app dark mode ON or OFF.',
|
|
1022
|
+
inputSchema: {
|
|
1023
|
+
type: 'object',
|
|
1024
|
+
required: ['enabled'],
|
|
1025
|
+
properties: {
|
|
1026
|
+
enabled: { type: 'boolean', description: 'true = dark mode ON, false = light mode' }
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
},
|
|
1030
|
+
{
|
|
1031
|
+
name: 'search_web',
|
|
1032
|
+
description: 'Search the public web for articles/pages by keyword (KEYLESS). Optionally save the results as link resources to a topic.',
|
|
1033
|
+
inputSchema: {
|
|
1034
|
+
type: 'object',
|
|
1035
|
+
required: ['query'],
|
|
1036
|
+
properties: {
|
|
1037
|
+
query: { type: 'string', description: 'Web search query' },
|
|
1038
|
+
topic_name: { type: 'string', description: 'If given, save the results as link resources to this topic' }
|
|
1039
|
+
}
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
]
|
|
1043
|
+
|
|
1044
|
+
// ── Phone Live Bridge Setup & Auto-Discovery (Wi-Fi, Hotspot, USB) ─────────
|
|
1045
|
+
const EXPLICIT_PHONE_URL = (!LOCAL_ONLY && process.env.KARMA365_PHONE_URL) ? process.env.KARMA365_PHONE_URL.trim().replace(/\/+$/, '') : null
|
|
1046
|
+
|
|
1047
|
+
let activePhoneUrl = LOCAL_ONLY ? null : (EXPLICIT_PHONE_URL || readLink()?.phoneUrl || null)
|
|
1048
|
+
let isDiscoveryRunning = false
|
|
1049
|
+
let lastDiscoveryTime = 0
|
|
1050
|
+
|
|
1051
|
+
/** True only if whatever answers at this address proves it holds our device key. */
|
|
1052
|
+
async function isOurPhone(baseUrl, timeoutMs = 1200) {
|
|
1053
|
+
const link = readLink()
|
|
1054
|
+
if (!link) return false
|
|
1055
|
+
try {
|
|
1056
|
+
const health = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs), redirect: 'error' })
|
|
1057
|
+
if (!health.ok) return false
|
|
1058
|
+
const reply = await secureCall(baseUrl, link, 'GET', '/api/stats', undefined, Math.max(timeoutMs, 2500))
|
|
1059
|
+
return reply.status === 200 || reply.status === 423
|
|
1060
|
+
} catch (_) {
|
|
1061
|
+
return false
|
|
1062
|
+
}
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function rememberPhoneUrl(url) {
|
|
1066
|
+
const link = readLink()
|
|
1067
|
+
if (link && !link.fromToken && link.phoneUrl !== url) {
|
|
1068
|
+
try { writeLink({ ...link, phoneUrl: url }) } catch (_) {}
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
async function autoDiscoverPhone(force = false) {
|
|
1073
|
+
if (LOCAL_ONLY) return null
|
|
1074
|
+
if (EXPLICIT_PHONE_URL) return EXPLICIT_PHONE_URL
|
|
1075
|
+
const now = Date.now()
|
|
1076
|
+
if (!force && activePhoneUrl && (now - lastDiscoveryTime < 15000)) {
|
|
1077
|
+
return activePhoneUrl
|
|
1078
|
+
}
|
|
1079
|
+
if (isDiscoveryRunning) return activePhoneUrl
|
|
1080
|
+
isDiscoveryRunning = true
|
|
1081
|
+
lastDiscoveryTime = now
|
|
1082
|
+
|
|
1083
|
+
try {
|
|
1084
|
+
// 1. Check cached URL, localhost (USB adb forward), or Android hotspot default gateway
|
|
1085
|
+
const quickCandidates = [
|
|
1086
|
+
activePhoneUrl,
|
|
1087
|
+
'http://127.0.0.1:8365',
|
|
1088
|
+
'http://192.168.43.1:8365',
|
|
1089
|
+
].filter(Boolean)
|
|
1090
|
+
|
|
1091
|
+
for (const url of quickCandidates) {
|
|
1092
|
+
if (await isOurPhone(url, 1500)) {
|
|
1093
|
+
activePhoneUrl = url
|
|
1094
|
+
rememberPhoneUrl(url)
|
|
1095
|
+
process.stderr.write(`[karma365-mcp] 📱 Connected to KARMA 365 Mobile App: ${url}\n`)
|
|
1096
|
+
return url
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// 2. Fast parallel sweep across active PC network interfaces
|
|
1101
|
+
const nets = os.networkInterfaces()
|
|
1102
|
+
const subnets = new Set()
|
|
1103
|
+
for (const name of Object.keys(nets)) {
|
|
1104
|
+
for (const net of nets[name] || []) {
|
|
1105
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
1106
|
+
const parts = net.address.split('.')
|
|
1107
|
+
if (parts.length === 4) {
|
|
1108
|
+
subnets.add(`${parts[0]}.${parts[1]}.${parts[2]}.`)
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
const checks = []
|
|
1115
|
+
for (const subnet of subnets) {
|
|
1116
|
+
for (let i = 1; i <= 254; i++) {
|
|
1117
|
+
const url = `http://${subnet}${i}:8365`
|
|
1118
|
+
checks.push(
|
|
1119
|
+
isOurPhone(url, 650)
|
|
1120
|
+
.then(ok => (ok ? url : null))
|
|
1121
|
+
.catch(() => null)
|
|
1122
|
+
)
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (checks.length > 0) {
|
|
1127
|
+
const results = await Promise.all(checks)
|
|
1128
|
+
const found = results.find(Boolean)
|
|
1129
|
+
if (found) {
|
|
1130
|
+
activePhoneUrl = found
|
|
1131
|
+
rememberPhoneUrl(found)
|
|
1132
|
+
process.stderr.write(`[karma365-mcp] 📱 Auto-Discovered KARMA 365 Mobile App: ${found}\n`)
|
|
1133
|
+
return found
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
} catch (e) {
|
|
1137
|
+
process.stderr.write(`[karma365-mcp] Auto-discovery warning: ${e.message}\n`)
|
|
1138
|
+
} finally {
|
|
1139
|
+
isDiscoveryRunning = false
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return activePhoneUrl
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
if (EXPLICIT_PHONE_URL) {
|
|
1146
|
+
process.stderr.write(`[karma365-mcp] Phone Live Bridge Configured: ${EXPLICIT_PHONE_URL} (Paired: ${readLink() ? 'yes' : 'no'})\n`)
|
|
1147
|
+
} else {
|
|
1148
|
+
process.stderr.write(`[karma365-mcp] ${LOCAL_ONLY ? `Local database only: ${DB_PATH}` : 'Auto-Discovery Enabled (searching on :8365)'}\n`)
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
async function callPhoneBridge(name, args) {
|
|
1152
|
+
const link = readLink()
|
|
1153
|
+
if (!link) throw new Error('KARMA365_TOKEN is missing. Copy the config from KARMA 365: Settings, AI MCP.')
|
|
1154
|
+
|
|
1155
|
+
const targetBase = activePhoneUrl || (await autoDiscoverPhone())
|
|
1156
|
+
if (!targetBase) throw new Error('Phone not found. Open KARMA 365, turn on "Allow PC connection", and use the same Wi-Fi.')
|
|
1157
|
+
|
|
1158
|
+
const isLargePayload = args && args.url && String(args.url).length > 50000
|
|
1159
|
+
const call = (base) => secureCall(base, link, 'POST', '/api/execute', { name, args }, isLargePayload ? 120000 : 8000)
|
|
1160
|
+
let reply
|
|
1161
|
+
try {
|
|
1162
|
+
reply = await call(targetBase)
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (err.notPaired) throw err
|
|
1165
|
+
process.stderr.write(`[karma365-mcp] Phone not reachable at ${targetBase} (${err.message}).\n`)
|
|
1166
|
+
activePhoneUrl = null
|
|
1167
|
+
// Try one quick re-discovery if the phone's IP changed on Wi-Fi.
|
|
1168
|
+
const found = await autoDiscoverPhone(true)
|
|
1169
|
+
if (!found || found === targetBase) {
|
|
1170
|
+
throw new Error('Phone not reachable, so nothing was changed. Open KARMA 365 on the same Wi-Fi and try again.')
|
|
1171
|
+
}
|
|
1172
|
+
activePhoneUrl = found
|
|
1173
|
+
reply = await call(found)
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
if (reply.status === 423) throw new Error('KARMA 365 is locked. Unlock it on your phone first.')
|
|
1177
|
+
const data = reply.body || {}
|
|
1178
|
+
if (reply.status === 403 && typeof data.error === 'string') return data.error
|
|
1179
|
+
if (reply.status >= 400 || data.success === false) {
|
|
1180
|
+
throw new Error(data.error || `Phone returned status ${reply.status}`)
|
|
1181
|
+
}
|
|
1182
|
+
return data.result
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
// Resource type for each add_* tool in local mode (KARMA365_LOCAL_ONLY).
|
|
1186
|
+
const RESOURCE_TYPE_MAP = {
|
|
1187
|
+
add_youtube: 'youtube',
|
|
1188
|
+
add_link: 'link',
|
|
1189
|
+
add_pdf: 'pdf',
|
|
1190
|
+
add_audio: 'audio',
|
|
1191
|
+
add_video: 'video',
|
|
1192
|
+
add_image: 'image',
|
|
1193
|
+
add_code: 'code'
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
async function handleTool(name, args) {
|
|
1197
|
+
let youTubeNote = ''
|
|
1198
|
+
if (name === 'add_youtube') {
|
|
1199
|
+
const video = await checkYouTubeVideo(args.url)
|
|
1200
|
+
if (!video.ok) return video.message
|
|
1201
|
+
args = { ...args }
|
|
1202
|
+
if (!String(args.description || '').trim()) {
|
|
1203
|
+
args.description = video.title || `YouTube video ${video.id}`
|
|
1204
|
+
}
|
|
1205
|
+
if (!video.checked) youTubeNote = '(YouTube could not be reached, so this link was saved without checking it.)'
|
|
1206
|
+
else if (video.restricted) youTubeNote = '(YouTube says this video is private or cannot be played outside YouTube.)'
|
|
1207
|
+
else if (video.title) youTubeNote = `(YouTube: "${video.title}"${video.channel ? ` by ${video.channel}` : ''})`
|
|
1208
|
+
}
|
|
1209
|
+
const reply = await runTool(name, args)
|
|
1210
|
+
// A failed save keeps its message as it is, so the note never hides an error.
|
|
1211
|
+
if (!youTubeNote || typeof reply !== 'string' || !reply.startsWith('✓')) return reply
|
|
1212
|
+
return `${reply} ${youTubeNote}`
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
async function runTool(name, args) {
|
|
1216
|
+
// If Phone Live Bridge is active or auto-discovered, delegate directly to phone app
|
|
1217
|
+
if (!LOCAL_ONLY && name !== 'search_web') {
|
|
1218
|
+
let argsToSend = { ...args }
|
|
1219
|
+
if (['add_image', 'add_pdf', 'add_audio', 'add_video', 'add_code'].includes(name) && argsToSend.url) {
|
|
1220
|
+
const localPath = String(argsToSend.url).trim()
|
|
1221
|
+
if (fs.existsSync(localPath)) {
|
|
1222
|
+
assertFileMaySend(localPath)
|
|
1223
|
+
try {
|
|
1224
|
+
const ext = path.extname(localPath).toLowerCase()
|
|
1225
|
+
const mimeMap = {
|
|
1226
|
+
'.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
|
|
1227
|
+
'.pdf': 'application/pdf',
|
|
1228
|
+
'.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/m4a',
|
|
1229
|
+
'.mp4': 'video/mp4', '.mov': 'video/quicktime',
|
|
1230
|
+
}
|
|
1231
|
+
const mime = mimeMap[ext] || 'application/octet-stream'
|
|
1232
|
+
const fileBytes = fs.readFileSync(localPath)
|
|
1233
|
+
argsToSend.url = `data:${mime};base64,${fileBytes.toString('base64')}`
|
|
1234
|
+
if (!argsToSend.description && !argsToSend.title) {
|
|
1235
|
+
argsToSend.description = path.basename(localPath)
|
|
1236
|
+
}
|
|
1237
|
+
} catch (e) {
|
|
1238
|
+
process.stderr.write(`[karma365-mcp] Failed to read local file ${localPath}: ${e.message}\n`)
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
const phoneResult = await callPhoneBridge(name, argsToSend)
|
|
1243
|
+
return typeof phoneResult === 'string' ? phoneResult : JSON.stringify(phoneResult ?? {}, null, 2)
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
switch (name) {
|
|
1247
|
+
|
|
1248
|
+
case 'set_dark_mode': {
|
|
1249
|
+
const enabled = args.enabled === true || String(args.enabled).toLowerCase() === 'true'
|
|
1250
|
+
const val = enabled ? 'true' : 'false'
|
|
1251
|
+
db.prepare(`INSERT INTO settings (key, value) VALUES ('dark_mode', ?)
|
|
1252
|
+
ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(val)
|
|
1253
|
+
return enabled
|
|
1254
|
+
? '✓ Dark mode turned ON 🌙 (saved to local settings)'
|
|
1255
|
+
: '✓ Dark mode turned OFF — light mode ☀️ (saved to local settings)'
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
case 'list_major_topics': {
|
|
1259
|
+
const rows = db.prepare('SELECT * FROM major_topics ORDER BY sort_order ASC, created_at DESC').all()
|
|
1260
|
+
if (!rows.length) return 'No major topics found. Create one with create_major_topic.'
|
|
1261
|
+
return `Found ${rows.length} major topic(s):\n` +
|
|
1262
|
+
rows.map(m => `• ${m.name}${m.color ? ` [${m.color}]` : ''}${m.is_pinned ? ' 🔒' : ''}${m.is_starred ? ' ⭐' : ''} (ID: ${m.id})`).join('\n')
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
case 'create_major_topic': {
|
|
1266
|
+
const id = newId()
|
|
1267
|
+
const isPinned = args.is_pinned ? 1 : 0
|
|
1268
|
+
db.prepare(`INSERT INTO major_topics (id, name, is_pinned, is_starred, sort_order, created_at, synced)
|
|
1269
|
+
VALUES (?, ?, ?, 0, 0, ?, 1)`).run(id, args.name.trim(), isPinned, nowIso())
|
|
1270
|
+
return `✓ Created major topic "${args.name}" (ID: ${id})`
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
case 'delete_major_topic': {
|
|
1274
|
+
const item = resolveMajorTopic(args.name)
|
|
1275
|
+
if (item.is_pinned === 1) {
|
|
1276
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1277
|
+
if (guard === 'required') return PIN_REQ(item.name, 'deletion')
|
|
1278
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'deleted')
|
|
1279
|
+
}
|
|
1280
|
+
db.prepare('UPDATE topics SET major_topic_id = NULL WHERE major_topic_id = ?').run(item.id)
|
|
1281
|
+
db.prepare('DELETE FROM major_topics WHERE id = ?').run(item.id)
|
|
1282
|
+
return `✓ Deleted major topic "${item.name}". Subtopics under it have been kept.`
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
case 'rename_major_topic': {
|
|
1286
|
+
const item = resolveMajorTopic(args.name)
|
|
1287
|
+
if (item.is_pinned === 1) {
|
|
1288
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1289
|
+
if (guard === 'required') return PIN_REQ(item.name, 'renaming')
|
|
1290
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'renamed')
|
|
1291
|
+
}
|
|
1292
|
+
const newName = args.new_name.trim()
|
|
1293
|
+
db.prepare('UPDATE major_topics SET name = ? WHERE id = ?').run(newName, item.id)
|
|
1294
|
+
return `✓ Renamed major topic from "${item.name}" to "${newName}"`
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
case 'set_major_topic_color': {
|
|
1298
|
+
const item = resolveMajorTopic(args.name)
|
|
1299
|
+
if (item.is_pinned === 1) {
|
|
1300
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1301
|
+
if (guard === 'required') return PIN_REQ(item.name, 'changing colour')
|
|
1302
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'color-changed')
|
|
1303
|
+
}
|
|
1304
|
+
const color = resolveColor(args.color)
|
|
1305
|
+
db.prepare('UPDATE major_topics SET color = ? WHERE id = ?').run(color, item.id)
|
|
1306
|
+
return `✓ Changed colour of major topic "${item.name}" to ${color}`
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
case 'pin_major_topic': {
|
|
1310
|
+
const item = resolveMajorTopic(args.name)
|
|
1311
|
+
const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1312
|
+
if (!pinned && item.is_pinned === 1) {
|
|
1313
|
+
const guard = checkPinIfLocked(item.is_pinned, args.pin)
|
|
1314
|
+
if (guard === 'required') return PIN_REQ(item.name, 'unlocking')
|
|
1315
|
+
if (guard === 'invalid') return PIN_BAD(item.name, 'unlocked')
|
|
1316
|
+
}
|
|
1317
|
+
db.prepare('UPDATE major_topics SET is_pinned = ? WHERE id = ?').run(pinned, item.id)
|
|
1318
|
+
return pinned ? `✓ Locked/pinned major topic "${item.name}" 🔒` : `✓ Unlocked major topic "${item.name}"`
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
case 'star_major_topic': {
|
|
1322
|
+
const item = resolveMajorTopic(args.name)
|
|
1323
|
+
const starred = args.starred === true || String(args.starred).toLowerCase() === 'true' ? 1 : 0
|
|
1324
|
+
db.prepare('UPDATE major_topics SET is_starred = ? WHERE id = ?').run(starred, item.id)
|
|
1325
|
+
return starred ? `✓ Added major topic "${item.name}" to favourites ⭐` : `✓ Removed major topic "${item.name}" from favourites`
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
case 'list_topics': {
|
|
1329
|
+
let query = `SELECT t.*, m.name as major_name
|
|
1330
|
+
FROM topics t
|
|
1331
|
+
LEFT JOIN major_topics m ON t.major_topic_id = m.id
|
|
1332
|
+
WHERE 1=1`
|
|
1333
|
+
const params = []
|
|
1334
|
+
|
|
1335
|
+
if (args.major_topic_name) {
|
|
1336
|
+
const m = resolveMajorTopic(args.major_topic_name)
|
|
1337
|
+
query += ' AND t.major_topic_id = ?'
|
|
1338
|
+
params.push(m.id)
|
|
1339
|
+
}
|
|
1340
|
+
if (args.difficulty) {
|
|
1341
|
+
query += ' AND t.difficulty = ?'
|
|
1342
|
+
params.push(args.difficulty)
|
|
1343
|
+
}
|
|
1344
|
+
if (args.parent_topic_name) {
|
|
1345
|
+
const p = resolveTopic(args.parent_topic_name)
|
|
1346
|
+
query += ' AND t.parent_topic_id = ?'
|
|
1347
|
+
params.push(p.id)
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
query += ' ORDER BY t.sort_order ASC, t.created_at DESC'
|
|
1351
|
+
const rows = db.prepare(query).all(...params)
|
|
1352
|
+
|
|
1353
|
+
if (!rows.length) return 'No topics found matching criteria.'
|
|
1354
|
+
return `Found ${rows.length} topic(s):\n` +
|
|
1355
|
+
rows.map(t => {
|
|
1356
|
+
const cat = t.major_name ? ` [Category: ${t.major_name}]` : ''
|
|
1357
|
+
const diff = t.difficulty ? ` (${t.difficulty})` : ''
|
|
1358
|
+
const icons = `${t.is_pinned ? ' 🔒' : ''}${t.is_starred ? ' ⭐' : ''}`
|
|
1359
|
+
return `• ${t.name}${cat}${diff}${icons} (ID: ${t.id})`
|
|
1360
|
+
}).join('\n')
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
case 'get_topic': {
|
|
1364
|
+
const topic = resolveTopic(args.name)
|
|
1365
|
+
let majorIsPinned = 0
|
|
1366
|
+
if (topic.major_topic_id) {
|
|
1367
|
+
const m = db.prepare('SELECT is_pinned, name FROM major_topics WHERE id = ?').get(topic.major_topic_id)
|
|
1368
|
+
if (m && m.is_pinned === 1) majorIsPinned = 1
|
|
1369
|
+
}
|
|
1370
|
+
const isLocked = topic.is_pinned === 1 || majorIsPinned === 1
|
|
1371
|
+
if (isLocked) {
|
|
1372
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1373
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'opening / reading')
|
|
1374
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'opened')
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
const resources = db.prepare('SELECT * FROM resources WHERE topic_id = ? ORDER BY sort_order ASC, created_at DESC').all(topic.id)
|
|
1378
|
+
let majorName = null
|
|
1379
|
+
if (topic.major_topic_id) {
|
|
1380
|
+
const m = db.prepare('SELECT name FROM major_topics WHERE id = ?').get(topic.major_topic_id)
|
|
1381
|
+
if (m) majorName = m.name
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
let out = `Topic: ${topic.name}\n`
|
|
1385
|
+
out += `ID: ${topic.id}\n`
|
|
1386
|
+
if (majorName) out += `Category: ${majorName}\n`
|
|
1387
|
+
if (topic.difficulty) out += `Difficulty: ${topic.difficulty}\n`
|
|
1388
|
+
if (topic.color) out += `Color: ${topic.color}\n`
|
|
1389
|
+
if (topic.note) out += `Note: ${topic.note}\n`
|
|
1390
|
+
out += `Status: ${topic.is_pinned ? '🔒 Pinned ' : ''}${topic.is_starred ? '⭐ Starred' : ''}\n\n`
|
|
1391
|
+
|
|
1392
|
+
if (!resources.length) {
|
|
1393
|
+
out += 'Resources: None attached yet.'
|
|
1394
|
+
} else {
|
|
1395
|
+
out += `Resources (${resources.length}):\n`
|
|
1396
|
+
out += resources.map(r =>
|
|
1397
|
+
`• [${r.type.toUpperCase()}] ${r.description || '(no description)'} ${r.is_completed ? '✓' : '○'}\n ${r.path_or_url}`
|
|
1398
|
+
).join('\n')
|
|
1399
|
+
}
|
|
1400
|
+
return out
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
case 'create_topic': {
|
|
1404
|
+
let majorId = null
|
|
1405
|
+
if (args.major_topic_name) {
|
|
1406
|
+
const m = resolveMajorTopic(args.major_topic_name)
|
|
1407
|
+
if (m.is_pinned === 1) {
|
|
1408
|
+
const guard = checkPinIfLocked(m.is_pinned, args.pin)
|
|
1409
|
+
if (guard === 'required') return PIN_REQ(m.name, 'adding topic under it')
|
|
1410
|
+
if (guard === 'invalid') return PIN_BAD(m.name, 'modified')
|
|
1411
|
+
}
|
|
1412
|
+
majorId = m.id
|
|
1413
|
+
}
|
|
1414
|
+
const id = newId()
|
|
1415
|
+
const color = resolveColor(args.color)
|
|
1416
|
+
const isPinned = args.is_pinned === true || args.pinned === true || String(args.is_pinned).toLowerCase() === 'true' || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1417
|
+
db.prepare(`INSERT INTO topics (id, name, difficulty, major_topic_id, color, is_pinned, created_at, synced)
|
|
1418
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', majorId, color, isPinned, nowIso())
|
|
1419
|
+
return `✓ Created topic "${args.name}" (ID: ${id})${isPinned ? ' 🔒' : ''}`
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
case 'create_subtopic': {
|
|
1423
|
+
const parent = resolveTopic(args.parent_topic_name)
|
|
1424
|
+
if (parent.is_pinned === 1) {
|
|
1425
|
+
const guard = checkPinIfLocked(parent.is_pinned, args.pin)
|
|
1426
|
+
if (guard === 'required') return PIN_REQ(parent.name, 'adding subtopic under it')
|
|
1427
|
+
if (guard === 'invalid') return PIN_BAD(parent.name, 'modified')
|
|
1428
|
+
}
|
|
1429
|
+
const id = newId()
|
|
1430
|
+
const isPinned = args.is_pinned === true || args.pinned === true || String(args.is_pinned).toLowerCase() === 'true' || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1431
|
+
db.prepare(`INSERT INTO topics (id, name, difficulty, parent_topic_id, major_topic_id, is_pinned, created_at, synced)
|
|
1432
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', parent.id, parent.major_topic_id, isPinned, nowIso())
|
|
1433
|
+
return `✓ Created subtopic "${args.name}" under parent "${parent.name}" (ID: ${id})${isPinned ? ' 🔒' : ''}`
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
case 'delete_topic': {
|
|
1437
|
+
const topic = resolveTopic(args.name)
|
|
1438
|
+
let majorIsPinned = 0
|
|
1439
|
+
if (topic.major_topic_id) {
|
|
1440
|
+
const m = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(topic.major_topic_id)
|
|
1441
|
+
if (m && m.is_pinned === 1) majorIsPinned = 1
|
|
1442
|
+
}
|
|
1443
|
+
const isLocked = topic.is_pinned === 1 || majorIsPinned === 1
|
|
1444
|
+
if (isLocked) {
|
|
1445
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1446
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'deletion')
|
|
1447
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'deleted')
|
|
1448
|
+
}
|
|
1449
|
+
db.prepare('DELETE FROM resources WHERE topic_id = ?').run(topic.id)
|
|
1450
|
+
db.prepare('DELETE FROM topics WHERE id = ?').run(topic.id)
|
|
1451
|
+
return `✓ Deleted topic "${topic.name}" and all its resources.`
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
case 'rename_topic': {
|
|
1455
|
+
const topic = resolveTopic(args.name)
|
|
1456
|
+
if (isTopicLocked(topic)) {
|
|
1457
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1458
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'renaming')
|
|
1459
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'renamed')
|
|
1460
|
+
}
|
|
1461
|
+
const newName = args.new_name.trim()
|
|
1462
|
+
db.prepare('UPDATE topics SET name = ?, updated_at = ? WHERE id = ?').run(newName, nowIso(), topic.id)
|
|
1463
|
+
return `✓ Renamed topic from "${topic.name}" to "${newName}"`
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
case 'set_topic_color': {
|
|
1467
|
+
const topic = resolveTopic(args.name)
|
|
1468
|
+
if (isTopicLocked(topic)) {
|
|
1469
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1470
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'changing colour')
|
|
1471
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'color-changed')
|
|
1472
|
+
}
|
|
1473
|
+
const color = resolveColor(args.color)
|
|
1474
|
+
db.prepare('UPDATE topics SET color = ?, updated_at = ? WHERE id = ?').run(color, nowIso(), topic.id)
|
|
1475
|
+
return `✓ Changed colour of topic "${topic.name}" to ${color}`
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
case 'pin_topic': {
|
|
1479
|
+
const topic = resolveTopic(args.name)
|
|
1480
|
+
const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
|
|
1481
|
+
if (!pinned && topic.is_pinned === 1) {
|
|
1482
|
+
const guard = checkPinIfLocked(topic.is_pinned, args.pin)
|
|
1483
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'unlocking')
|
|
1484
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'unlocked')
|
|
1485
|
+
}
|
|
1486
|
+
db.prepare('UPDATE topics SET is_pinned = ?, updated_at = ? WHERE id = ?').run(pinned, nowIso(), topic.id)
|
|
1487
|
+
return pinned ? `✓ Locked/pinned topic "${topic.name}" 🔒` : `✓ Unlocked topic "${topic.name}"`
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
case 'star_topic': {
|
|
1491
|
+
const topic = resolveTopic(args.name)
|
|
1492
|
+
const starred = args.starred === true || String(args.starred).toLowerCase() === 'true' ? 1 : 0
|
|
1493
|
+
db.prepare('UPDATE topics SET is_starred = ?, updated_at = ? WHERE id = ?').run(starred, nowIso(), topic.id)
|
|
1494
|
+
return starred ? `✓ Added topic "${topic.name}" to favourites ⭐` : `✓ Removed topic "${topic.name}" from favourites`
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
case 'list_resources': {
|
|
1498
|
+
const topic = resolveTopic(args.topic_name)
|
|
1499
|
+
if (isTopicLocked(topic)) {
|
|
1500
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1501
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'listing its resources')
|
|
1502
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'opened')
|
|
1503
|
+
}
|
|
1504
|
+
let query = 'SELECT * FROM resources WHERE topic_id = ?'
|
|
1505
|
+
const params = [topic.id]
|
|
1506
|
+
if (args.type) {
|
|
1507
|
+
query += ' AND type = ?'
|
|
1508
|
+
params.push(args.type)
|
|
1509
|
+
}
|
|
1510
|
+
query += ' ORDER BY sort_order ASC, created_at DESC'
|
|
1511
|
+
const rows = db.prepare(query).all(...params)
|
|
1512
|
+
if (!rows.length) return `No resources found for topic "${topic.name}".`
|
|
1513
|
+
return `Found ${rows.length} resource(s) in "${topic.name}":\n` +
|
|
1514
|
+
rows.map(r => `• [${r.type.toUpperCase()}] ${r.description || '(no label)'} ${r.is_completed ? '✓ Done' : '○ Incomplete'}\n URL/Path: ${r.path_or_url}\n ID: ${r.id}`).join('\n')
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
case 'add_youtube':
|
|
1518
|
+
case 'add_link':
|
|
1519
|
+
case 'add_pdf':
|
|
1520
|
+
case 'add_audio':
|
|
1521
|
+
case 'add_video':
|
|
1522
|
+
case 'add_image':
|
|
1523
|
+
case 'add_code': {
|
|
1524
|
+
const topic = resolveTopic(args.topic_name)
|
|
1525
|
+
if (isTopicLocked(topic)) {
|
|
1526
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1527
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'adding resource to it')
|
|
1528
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'modified')
|
|
1529
|
+
}
|
|
1530
|
+
const type = RESOURCE_TYPE_MAP[name]
|
|
1531
|
+
const id = newId()
|
|
1532
|
+
let finalPathOrUrl = String(args.url || '').trim()
|
|
1533
|
+
|
|
1534
|
+
// For youtube, extract video ID if present
|
|
1535
|
+
let ytVideoId = null
|
|
1536
|
+
if (type === 'youtube') {
|
|
1537
|
+
const m = finalPathOrUrl.match(/(?:youtu\.be\/|v=|\/embed\/|\/v\/|watch\?v=)([\w-]{11})/)
|
|
1538
|
+
if (m) ytVideoId = m[1]
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
db.prepare(`INSERT INTO resources (id, topic_id, type, description, path_or_url, youtube_video_id, is_completed, created_at, synced)
|
|
1542
|
+
VALUES (?, ?, ?, ?, ?, ?, 0, ?, 1)`).run(id, topic.id, type, args.description.trim(), finalPathOrUrl, ytVideoId, nowIso())
|
|
1543
|
+
return `✓ Added [${type.toUpperCase()}] "${args.description}" to "${topic.name}" (ID: ${id})`
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
case 'delete_resource': {
|
|
1547
|
+
const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
|
|
1548
|
+
if (!row) throw new Error(`Resource not found with ID: "${args.resource_id}"`)
|
|
1549
|
+
let isLocked = row.is_pinned || 0
|
|
1550
|
+
if (!isLocked && row.topic_id) {
|
|
1551
|
+
const t = db.prepare('SELECT is_pinned, major_topic_id FROM topics WHERE id = ?').get(row.topic_id)
|
|
1552
|
+
if (t && t.is_pinned === 1) isLocked = 1
|
|
1553
|
+
else if (t && t.major_topic_id) {
|
|
1554
|
+
const m = db.prepare('SELECT is_pinned FROM major_topics WHERE id = ?').get(t.major_topic_id)
|
|
1555
|
+
if (m && m.is_pinned === 1) isLocked = 1
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (isLocked) {
|
|
1559
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1560
|
+
if (guard === 'required') return PIN_REQ(row.description || 'Resource', 'deletion')
|
|
1561
|
+
if (guard === 'invalid') return PIN_BAD(row.description || 'Resource', 'deleted')
|
|
1562
|
+
}
|
|
1563
|
+
db.prepare('DELETE FROM resources WHERE id = ?').run(row.id)
|
|
1564
|
+
return `✓ Deleted resource "${row.description || row.path_or_url}"`
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
case 'set_pin':
|
|
1568
|
+
case 'update_pin': {
|
|
1569
|
+
const newPin = String(args.new_pin ?? args.pin ?? '').trim()
|
|
1570
|
+
const currentPin = args.current_pin ? String(args.current_pin).trim() : undefined
|
|
1571
|
+
if (!newPin) {
|
|
1572
|
+
return '❌ PIN cannot be empty. Please provide a numeric PIN (e.g. 111111).'
|
|
1573
|
+
}
|
|
1574
|
+
if (hasPin()) {
|
|
1575
|
+
if (!currentPin) {
|
|
1576
|
+
return 'PIN_REQUIRED: A PIN is already set. Please provide current_pin to authorize updating to a new PIN.'
|
|
1577
|
+
}
|
|
1578
|
+
if (!verifyPin(currentPin)) {
|
|
1579
|
+
return 'PIN_INVALID: Current PIN is incorrect. Master PIN was NOT updated.'
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
setMasterPin(newPin)
|
|
1583
|
+
return `✓ Master PIN successfully updated to ${newPin.replace(/./g, '•')} 🔒`
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
case 'verify_pin': {
|
|
1587
|
+
const pin = String(args.pin ?? '').trim()
|
|
1588
|
+
if (!hasPin()) {
|
|
1589
|
+
return 'ℹ️ No master PIN is set yet. Use set_pin to create one.'
|
|
1590
|
+
}
|
|
1591
|
+
if (verifyPin(pin)) {
|
|
1592
|
+
return '✓ PIN verified successfully.'
|
|
1593
|
+
}
|
|
1594
|
+
return 'PIN_INVALID: Incorrect PIN.'
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
case 'mark_resource_complete': {
|
|
1598
|
+
const isCompleted = args.is_completed === true || String(args.is_completed).toLowerCase() === 'true' ? 1 : 0
|
|
1599
|
+
const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
|
|
1600
|
+
if (!row) throw new Error(`Resource not found with ID: "${args.resource_id}"`)
|
|
1601
|
+
db.prepare('UPDATE resources SET is_completed = ? WHERE id = ?').run(isCompleted, row.id)
|
|
1602
|
+
return `✓ Resource marked as ${isCompleted ? 'completed ✓' : 'incomplete ○'}`
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
case 'list_todos': {
|
|
1606
|
+
let query = 'SELECT * FROM todos WHERE 1=1'
|
|
1607
|
+
const params = []
|
|
1608
|
+
if (args.priority) {
|
|
1609
|
+
query += ' AND priority = ?'
|
|
1610
|
+
params.push(args.priority.toLowerCase())
|
|
1611
|
+
}
|
|
1612
|
+
if (args.is_completed !== undefined) {
|
|
1613
|
+
query += ' AND is_completed = ?'
|
|
1614
|
+
params.push(args.is_completed ? 1 : 0)
|
|
1615
|
+
}
|
|
1616
|
+
query += ' ORDER BY created_at DESC'
|
|
1617
|
+
const rows = db.prepare(query).all(...params)
|
|
1618
|
+
if (!rows.length) return 'No todos found.'
|
|
1619
|
+
return `Found ${rows.length} task(s):\n` +
|
|
1620
|
+
rows.map(t => `• [${t.is_completed ? '✓ Done' : '○ Todo'}] ${t.name} (Priority: ${t.priority})${t.notes ? ` - ${t.notes}` : ''} (ID: ${t.id})`).join('\n')
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
case 'create_todo': {
|
|
1624
|
+
const id = newId()
|
|
1625
|
+
const now = nowIso()
|
|
1626
|
+
db.prepare(`INSERT INTO todos (id, name, priority, notes, is_completed, created_at, updated_at)
|
|
1627
|
+
VALUES (?, ?, ?, ?, 0, ?, ?)`).run(id, args.name.trim(), (args.priority || 'medium').toLowerCase(), args.notes || '', now, now)
|
|
1628
|
+
return `✓ Created todo "${args.name}" (ID: ${id})`
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
case 'complete_todo': {
|
|
1632
|
+
const item = resolveTodo(args.name)
|
|
1633
|
+
db.prepare('UPDATE todos SET is_completed = 1, updated_at = ? WHERE id = ?').run(nowIso(), item.id)
|
|
1634
|
+
return `✓ Completed task "${item.name}" 🎉`
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
case 'update_todo': {
|
|
1638
|
+
const item = db.prepare('SELECT * FROM todos WHERE id = ?').get(args.id)
|
|
1639
|
+
if (!item) throw new Error(`Todo not found with ID: "${args.id}"`)
|
|
1640
|
+
|
|
1641
|
+
const name = args.name !== undefined ? args.name.trim() : item.name
|
|
1642
|
+
const priority = args.priority !== undefined ? args.priority.toLowerCase() : item.priority
|
|
1643
|
+
const notes = args.notes !== undefined ? args.notes : item.notes
|
|
1644
|
+
const isCompleted = args.is_completed !== undefined ? (args.is_completed ? 1 : 0) : item.is_completed
|
|
1645
|
+
|
|
1646
|
+
db.prepare(`UPDATE todos SET name = ?, priority = ?, notes = ?, is_completed = ?, updated_at = ?
|
|
1647
|
+
WHERE id = ?`).run(name, priority, notes, isCompleted, nowIso(), item.id)
|
|
1648
|
+
return `✓ Updated todo "${name}"`
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
case 'delete_todo': {
|
|
1652
|
+
const item = resolveTodo(args.name)
|
|
1653
|
+
db.prepare('DELETE FROM todos WHERE id = ?').run(item.id)
|
|
1654
|
+
return `✓ Deleted todo "${item.name}"`
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
case 'delete_completed_todos': {
|
|
1658
|
+
const res = db.prepare('DELETE FROM todos WHERE is_completed = 1').run()
|
|
1659
|
+
return `✓ Removed ${res.changes || 0} completed task(s).`
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
case 'write_topic_note': {
|
|
1663
|
+
const topic = resolveTopic(args.topic_name)
|
|
1664
|
+
if (isTopicLocked(topic)) {
|
|
1665
|
+
const guard = checkPinIfLocked(1, args.pin)
|
|
1666
|
+
if (guard === 'required') return PIN_REQ(topic.name, 'writing/updating note')
|
|
1667
|
+
if (guard === 'invalid') return PIN_BAD(topic.name, 'updated')
|
|
1668
|
+
}
|
|
1669
|
+
const existing = (topic.note || '').trim()
|
|
1670
|
+
const newNote = existing ? `${existing}\n\n${args.note.trim()}` : args.note.trim()
|
|
1671
|
+
const title = args.title ? args.title.trim() : topic.note_title
|
|
1672
|
+
db.prepare('UPDATE topics SET note = ?, note_title = ?, updated_at = ? WHERE id = ?')
|
|
1673
|
+
.run(newNote, title, nowIso(), topic.id)
|
|
1674
|
+
return `✓ Note saved to topic "${topic.name}"`
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1677
|
+
case 'clean_empty_major_topics': {
|
|
1678
|
+
const allMajor = db.prepare('SELECT * FROM major_topics').all()
|
|
1679
|
+
let removed = 0
|
|
1680
|
+
for (const m of allMajor) {
|
|
1681
|
+
const countRow = db.prepare(`
|
|
1682
|
+
SELECT count(r.id) as count
|
|
1683
|
+
FROM topics t
|
|
1684
|
+
LEFT JOIN resources r ON r.topic_id = t.id
|
|
1685
|
+
WHERE t.major_topic_id = ?
|
|
1686
|
+
`).get(m.id)
|
|
1687
|
+
if (!countRow || countRow.count === 0) {
|
|
1688
|
+
db.prepare('DELETE FROM major_topics WHERE id = ?').run(m.id)
|
|
1689
|
+
removed++
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
return `✓ Cleaned ${removed} empty major topic(s) with no resources.`
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
case 'get_stats': {
|
|
1696
|
+
const majorCount = db.prepare('SELECT count(*) as count FROM major_topics').get().count
|
|
1697
|
+
const topicCount = db.prepare('SELECT count(*) as count FROM topics').get().count
|
|
1698
|
+
const resTotal = db.prepare('SELECT count(*) as count FROM resources').get().count
|
|
1699
|
+
const resDone = db.prepare('SELECT count(*) as count FROM resources WHERE is_completed = 1').get().count
|
|
1700
|
+
const todoTotal = db.prepare('SELECT count(*) as count FROM todos').get().count
|
|
1701
|
+
const todoDone = db.prepare('SELECT count(*) as count FROM todos WHERE is_completed = 1').get().count
|
|
1702
|
+
|
|
1703
|
+
const completionPct = resTotal > 0 ? Math.round((resDone / resTotal) * 100) : 0
|
|
1704
|
+
return `📊 KARMA 365 Statistics:\n` +
|
|
1705
|
+
`• Major Topics: ${majorCount}\n` +
|
|
1706
|
+
`• Topics: ${topicCount}\n` +
|
|
1707
|
+
`• Resources: ${resTotal} total (${resDone} studied / ${completionPct}%)\n` +
|
|
1708
|
+
`• Tasks: ${todoTotal} total (${todoDone} completed)\n` +
|
|
1709
|
+
`• Storage: Local SQLite DB (${DB_PATH})`
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
case 'search': {
|
|
1713
|
+
const q = `%${args.query.trim().toLowerCase()}%`
|
|
1714
|
+
const pinOk = Boolean(args.pin) && verifyPin(args.pin)
|
|
1715
|
+
const topics = db.prepare('SELECT * FROM topics WHERE LOWER(name) LIKE ? OR LOWER(note) LIKE ? LIMIT 10').all(q, q)
|
|
1716
|
+
.filter((row) => pinOk || !isTopicLocked(row))
|
|
1717
|
+
const resources = db.prepare('SELECT * FROM resources WHERE LOWER(description) LIKE ? OR LOWER(path_or_url) LIKE ? LIMIT 10').all(q, q)
|
|
1718
|
+
.filter((row) => {
|
|
1719
|
+
if (pinOk) return true
|
|
1720
|
+
if (row.is_pinned === 1) return false
|
|
1721
|
+
const owner = db.prepare('SELECT * FROM topics WHERE id = ?').get(row.topic_id)
|
|
1722
|
+
return !owner || !isTopicLocked(owner)
|
|
1723
|
+
})
|
|
1724
|
+
const todos = db.prepare('SELECT * FROM todos WHERE LOWER(name) LIKE ? OR LOWER(notes) LIKE ? LIMIT 10').all(q, q)
|
|
1725
|
+
|
|
1726
|
+
let out = `Search results for "${args.query}":\n`
|
|
1727
|
+
if (topics.length) {
|
|
1728
|
+
out += `\nTopics (${topics.length}):\n` + topics.map(t => `• ${t.name} (ID: ${t.id})`).join('\n')
|
|
1729
|
+
}
|
|
1730
|
+
if (resources.length) {
|
|
1731
|
+
out += `\nResources (${resources.length}):\n` + resources.map(r => `• [${r.type}] ${r.description} (${r.path_or_url})`).join('\n')
|
|
1732
|
+
}
|
|
1733
|
+
if (todos.length) {
|
|
1734
|
+
out += `\nTodos (${todos.length}):\n` + todos.map(t => `• ${t.name} [${t.priority}]`).join('\n')
|
|
1735
|
+
}
|
|
1736
|
+
if (!topics.length && !resources.length && !todos.length) {
|
|
1737
|
+
out += 'No matching items found.'
|
|
1738
|
+
}
|
|
1739
|
+
return out
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
case 'search_web': {
|
|
1743
|
+
const results = await searchWebKeyless(args.query)
|
|
1744
|
+
let out = `Web search results for "${args.query}":\n` +
|
|
1745
|
+
results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`).join('\n\n')
|
|
1746
|
+
|
|
1747
|
+
if (args.topic_name) {
|
|
1748
|
+
if (!LOCAL_ONLY) {
|
|
1749
|
+
for (const item of results) {
|
|
1750
|
+
await callPhoneBridge('add_link', { topic_name: args.topic_name, url: item.url, description: item.title })
|
|
1751
|
+
}
|
|
1752
|
+
out += `\n\n✓ Saved ${results.length} search links directly to topic "${args.topic_name}" on mobile app.`
|
|
1753
|
+
return out
|
|
1754
|
+
}
|
|
1755
|
+
const topic = resolveTopic(args.topic_name)
|
|
1756
|
+
for (const item of results) {
|
|
1757
|
+
db.prepare(`INSERT INTO resources (id, topic_id, type, description, path_or_url, is_completed, created_at, synced)
|
|
1758
|
+
VALUES (?, ?, 'link', ?, ?, 0, ?, 1)`).run(newId(), topic.id, item.title, item.url, nowIso())
|
|
1759
|
+
}
|
|
1760
|
+
out += `\n\n✓ Saved ${results.length} search links directly to topic "${topic.name}".`
|
|
1761
|
+
}
|
|
1762
|
+
return out
|
|
1763
|
+
}
|
|
1764
|
+
|
|
1765
|
+
default:
|
|
1766
|
+
throw new Error(`Unknown tool: ${name}`)
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
// ── MCP Server Startup ────────────────────────────────────────────────────────
|
|
1771
|
+
|
|
1772
|
+
const server = new Server(
|
|
1773
|
+
{ name: 'karma365-mcp', version: '2.1.0' },
|
|
1774
|
+
{ capabilities: { tools: {} } }
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
1778
|
+
return { tools: TOOLS }
|
|
1779
|
+
})
|
|
1780
|
+
|
|
1781
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
1782
|
+
try {
|
|
1783
|
+
const text = await handleTool(request.params.name, request.params.arguments || {})
|
|
1784
|
+
return { content: [{ type: 'text', text: String(text) }] }
|
|
1785
|
+
} catch (err) {
|
|
1786
|
+
return { content: [{ type: 'text', text: `ERROR: ${err.message}` }], isError: true }
|
|
1787
|
+
}
|
|
1788
|
+
})
|
|
1789
|
+
|
|
1790
|
+
const transport = new StdioServerTransport()
|
|
1791
|
+
await server.connect(transport)
|
|
1792
|
+
process.stderr.write('[karma365-mcp] Server running on stdio transport (Zero-Server mode)\n')
|
|
1793
|
+
|
|
1794
|
+
// Trigger background phone discovery on network
|
|
1795
|
+
if (!LOCAL_ONLY) autoDiscoverPhone().catch(() => {})
|