karma365-mcp 2.0.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.
Files changed (3) hide show
  1. package/README.md +100 -0
  2. package/index.js +1446 -0
  3. package/package.json +31 -0
package/index.js ADDED
@@ -0,0 +1,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
+
10
+ // ── Database connection & Auto-detect ─────────────────────────────────────────
11
+
12
+ function autoDetectDbPath() {
13
+ if (process.env.KARMA365_DB && process.env.KARMA365_DB.trim()) {
14
+ const custom = path.resolve(process.env.KARMA365_DB.trim())
15
+ const dir = path.dirname(custom)
16
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
17
+ return custom
18
+ }
19
+
20
+ const home = os.homedir()
21
+ const candidates = [
22
+ // Windows AppData standard
23
+ process.env.APPDATA ? path.join(process.env.APPDATA, 'karma365', 'karma365.db') : null,
24
+ // macOS standard
25
+ path.join(home, 'Library', 'Application Support', 'com.karma365.app', 'karma365.db'),
26
+ // Linux standard
27
+ path.join(home, '.local', 'share', 'karma365', 'karma365.db'),
28
+ // Standard cross-platform hidden folder
29
+ path.join(home, '.karma365', 'karma365.db'),
30
+ // Current working directory (if user runs from repo/project folder)
31
+ path.join(process.cwd(), 'karma365.db'),
32
+ ].filter(Boolean)
33
+
34
+ for (const candidate of candidates) {
35
+ if (fs.existsSync(candidate)) return candidate
36
+ }
37
+
38
+ // Fallback: create default ~/.karma365/karma365.db
39
+ const defaultPath = path.join(home, '.karma365', 'karma365.db')
40
+ const dir = path.dirname(defaultPath)
41
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
42
+ return defaultPath
43
+ }
44
+
45
+ const DB_PATH = autoDetectDbPath()
46
+ process.stderr.write(`[karma365-mcp] Using SQLite database: ${DB_PATH}\n`)
47
+
48
+ let DatabaseSync
49
+ try {
50
+ const mod = await import('node:sqlite')
51
+ DatabaseSync = mod.DatabaseSync
52
+ } catch (_) {
53
+ try {
54
+ const mod = await import('better-sqlite3')
55
+ DatabaseSync = mod.default
56
+ } catch (err) {
57
+ process.stderr.write('ERROR: SQLite support requires Node 22.5+ (node:sqlite) or better-sqlite3.\n')
58
+ process.exit(1)
59
+ }
60
+ }
61
+
62
+ const db = new DatabaseSync(DB_PATH)
63
+ db.exec('PRAGMA foreign_keys = ON;')
64
+
65
+ // Initialize schema if not present
66
+ db.exec(`
67
+ CREATE TABLE IF NOT EXISTS major_topics (
68
+ id TEXT PRIMARY KEY,
69
+ name TEXT NOT NULL,
70
+ color TEXT,
71
+ note TEXT,
72
+ note_title TEXT,
73
+ created_at TEXT NOT NULL,
74
+ is_pinned INTEGER NOT NULL DEFAULT 0,
75
+ is_starred INTEGER NOT NULL DEFAULT 0,
76
+ sort_order INTEGER NOT NULL DEFAULT 0,
77
+ synced INTEGER NOT NULL DEFAULT 0
78
+ );
79
+
80
+ CREATE TABLE IF NOT EXISTS topics (
81
+ id TEXT PRIMARY KEY,
82
+ name TEXT NOT NULL,
83
+ difficulty TEXT CHECK (difficulty IN ('Easy','Medium','Hard')),
84
+ color TEXT,
85
+ major_topic_id TEXT REFERENCES major_topics(id) ON DELETE SET NULL,
86
+ parent_topic_id TEXT REFERENCES topics(id) ON DELETE SET NULL,
87
+ note TEXT,
88
+ note_title TEXT,
89
+ created_at TEXT NOT NULL,
90
+ updated_at TEXT,
91
+ last_opened TEXT,
92
+ visit_count INTEGER NOT NULL DEFAULT 0,
93
+ is_pinned INTEGER NOT NULL DEFAULT 0,
94
+ is_starred INTEGER NOT NULL DEFAULT 0,
95
+ sort_order INTEGER NOT NULL DEFAULT 0,
96
+ synced INTEGER NOT NULL DEFAULT 0
97
+ );
98
+
99
+ CREATE TABLE IF NOT EXISTS resources (
100
+ id TEXT PRIMARY KEY,
101
+ topic_id TEXT NOT NULL REFERENCES topics(id) ON DELETE CASCADE,
102
+ type TEXT NOT NULL,
103
+ description TEXT NOT NULL DEFAULT '',
104
+ path_or_url TEXT NOT NULL,
105
+ original_filename TEXT,
106
+ youtube_video_id TEXT,
107
+ thumbnail_path TEXT,
108
+ is_completed INTEGER NOT NULL DEFAULT 0,
109
+ is_pinned INTEGER NOT NULL DEFAULT 0,
110
+ is_starred INTEGER NOT NULL DEFAULT 0,
111
+ sort_order INTEGER NOT NULL DEFAULT 0,
112
+ notes TEXT,
113
+ created_at TEXT,
114
+ synced INTEGER NOT NULL DEFAULT 0
115
+ );
116
+
117
+ CREATE TABLE IF NOT EXISTS todos (
118
+ id TEXT PRIMARY KEY,
119
+ name TEXT NOT NULL,
120
+ priority TEXT NOT NULL CHECK (priority IN ('high','medium','low')) DEFAULT 'medium',
121
+ is_completed INTEGER NOT NULL DEFAULT 0,
122
+ notes TEXT,
123
+ created_at TEXT NOT NULL,
124
+ updated_at TEXT NOT NULL
125
+ );
126
+
127
+ CREATE TABLE IF NOT EXISTS settings (
128
+ key TEXT PRIMARY KEY,
129
+ value TEXT NOT NULL
130
+ );
131
+
132
+ CREATE INDEX IF NOT EXISTS idx_topics_major ON topics(major_topic_id);
133
+ CREATE INDEX IF NOT EXISTS idx_topics_parent ON topics(parent_topic_id);
134
+ CREATE INDEX IF NOT EXISTS idx_resources_topic ON resources(topic_id);
135
+ `)
136
+
137
+ // Helpers
138
+ function newId() { return crypto.randomUUID() }
139
+ function nowIso() { return new Date().toISOString() }
140
+
141
+ function isUuid(s) {
142
+ 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 || ''))
143
+ }
144
+
145
+ const COLOR_MAP = {
146
+ red: '#ef4444', laal: '#ef4444', lal: '#ef4444',
147
+ blue: '#3b82f6', neela: '#3b82f6', nilo: '#3b82f6',
148
+ green: '#22c55e', hara: '#22c55e', hario: '#22c55e',
149
+ yellow: '#eab308', peela: '#eab308', pilo: '#eab308',
150
+ purple: '#a855f7', jambli: '#a855f7',
151
+ pink: '#ec4899', gulabi: '#ec4899',
152
+ orange: '#f97316', narangi: '#f97316',
153
+ teal: '#14b8a6', indigo: '#6366f1', violet: '#8b5cf6',
154
+ gray: '#6b7280', grey: '#6b7280', coral: '#f87171',
155
+ navy: '#1e3a8a', rose: '#f43f5e', amber: '#f59e0b',
156
+ }
157
+
158
+ function resolveColor(c) {
159
+ if (!c) return null
160
+ const s = String(c).trim()
161
+ if (/^#?[0-9a-f]{6}$/i.test(s)) return s.startsWith('#') ? s : `#${s}`
162
+ return COLOR_MAP[s.toLowerCase()] || s
163
+ }
164
+
165
+ function cleanName(s) {
166
+ return ` ${String(s).toLowerCase().trim()} `
167
+ .replace(/\bsub[- ]?topics?\b/g, ' ')
168
+ .replace(/\b(major|minor)\b/g, ' ')
169
+ .replace(/\btopics?\b/g, ' ')
170
+ .replace(/\bcategor(?:y|ies)\b/g, ' ')
171
+ .replace(/\s+/g, ' ')
172
+ .trim()
173
+ }
174
+
175
+ function findByName(items, nameOrId) {
176
+ const lower = String(nameOrId).toLowerCase().trim()
177
+ const cleaned = cleanName(nameOrId)
178
+ return items.find(x => x.name.toLowerCase() === lower)
179
+ || (cleaned && items.find(x => x.name.toLowerCase() === cleaned))
180
+ || (cleaned && items.find(x => x.name.toLowerCase().includes(cleaned)))
181
+ || (lower && items.find(x => x.name.toLowerCase().includes(lower)))
182
+ || null
183
+ }
184
+
185
+ function resolveTopic(nameOrId) {
186
+ if (isUuid(nameOrId)) {
187
+ const row = db.prepare('SELECT * FROM topics WHERE id = ?').get(nameOrId)
188
+ if (row) return row
189
+ }
190
+ const all = db.prepare('SELECT * FROM topics').all()
191
+ const found = findByName(all, nameOrId)
192
+ if (!found) throw new Error(`Topic not found: "${nameOrId}"`)
193
+ return found
194
+ }
195
+
196
+ function resolveMajorTopic(nameOrId) {
197
+ if (isUuid(nameOrId)) {
198
+ const row = db.prepare('SELECT * FROM major_topics WHERE id = ?').get(nameOrId)
199
+ if (row) return row
200
+ }
201
+ const all = db.prepare('SELECT * FROM major_topics').all()
202
+ const found = findByName(all, nameOrId)
203
+ if (!found) throw new Error(`Major topic not found: "${nameOrId}"`)
204
+ return found
205
+ }
206
+
207
+ function resolveTodo(nameOrId) {
208
+ if (isUuid(nameOrId)) {
209
+ const row = db.prepare('SELECT * FROM todos WHERE id = ?').get(nameOrId)
210
+ if (row) return row
211
+ }
212
+ const all = db.prepare('SELECT * FROM todos').all()
213
+ const found = all.find(t => t.name.toLowerCase() === String(nameOrId).toLowerCase().trim())
214
+ if (!found) throw new Error(`Todo not found: "${nameOrId}"`)
215
+ return found
216
+ }
217
+
218
+ // ── Search helpers (DuckDuckGo / SearXNG / YouTube) ───────────────────────────
219
+
220
+ const SEARCH_UA = 'Mozilla/5.0 (compatible; KarmaSearch/2.0)'
221
+
222
+ async function fetchWithTimeout(url, opts = {}, ms = 8000) {
223
+ const ctrl = new AbortController()
224
+ const t = setTimeout(() => ctrl.abort(), ms)
225
+ try {
226
+ return await fetch(url, { ...opts, signal: ctrl.signal })
227
+ } finally {
228
+ clearTimeout(t)
229
+ }
230
+ }
231
+
232
+ async function searchWebKeyless(query) {
233
+ const searx = [
234
+ 'https://searx.be',
235
+ 'https://searxng.world',
236
+ 'https://search.mdosch.de',
237
+ 'https://searx.tiekoetter.com',
238
+ ]
239
+ for (const base of searx) {
240
+ try {
241
+ const res = await fetchWithTimeout(
242
+ `${base}/search?q=${encodeURIComponent(query)}&format=json&categories=general&language=en-US`,
243
+ { headers: { 'User-Agent': SEARCH_UA, 'Accept': 'application/json' } })
244
+ if (!res.ok) continue
245
+ const data = await res.json()
246
+ const items = (data.results || [])
247
+ .filter(r => r.url && String(r.url).startsWith('http') && r.title)
248
+ .slice(0, 5)
249
+ .map(r => ({ title: String(r.title).trim(), url: String(r.url).trim(),
250
+ snippet: String(r.content || '').slice(0, 200) }))
251
+ if (items.length) return items
252
+ } catch { /* try next */ }
253
+ }
254
+
255
+ // DuckDuckGo Instant Answer fallback
256
+ try {
257
+ const res = await fetchWithTimeout(
258
+ `https://api.duckduckgo.com/?q=${encodeURIComponent(query)}&format=json&no_html=1&skip_disambig=1`,
259
+ {}, 6000)
260
+ if (res.ok) {
261
+ const data = await res.json()
262
+ const out = []
263
+ if (data.AbstractText && data.AbstractURL) {
264
+ out.push({ title: data.Heading || query, url: data.AbstractURL,
265
+ snippet: String(data.AbstractText).slice(0, 200) })
266
+ }
267
+ for (const it of (data.RelatedTopics || []).slice(0, 4)) {
268
+ if (it.FirstURL && it.Text) {
269
+ out.push({ title: String(it.Text).slice(0, 100), url: it.FirstURL, snippet: '' })
270
+ }
271
+ }
272
+ if (out.length) return out
273
+ }
274
+ } catch { /* ignore */ }
275
+
276
+ return [{ title: `Search: ${query}`,
277
+ url: `https://duckduckgo.com/?q=${encodeURIComponent(query)}`,
278
+ snippet: `Web results for "${query}"` }]
279
+ }
280
+
281
+ async function searchYouTubeKeyless(query, maxResults = 3) {
282
+ const max = Math.max(1, Math.min(5, maxResults || 3))
283
+ const piped = [
284
+ 'https://pipedapi.kavin.rocks',
285
+ 'https://pipedapi.adminforge.de',
286
+ 'https://api-piped.mha.fi',
287
+ ]
288
+ for (const base of piped) {
289
+ try {
290
+ const res = await fetchWithTimeout(
291
+ `${base}/search?q=${encodeURIComponent(query)}&filter=videos`,
292
+ { headers: { 'User-Agent': SEARCH_UA, 'Accept': 'application/json' } })
293
+ if (!res.ok) continue
294
+ const data = await res.json()
295
+ const items = (data.items || data || [])
296
+ .filter(v => v && (v.url || v.id))
297
+ .slice(0, max)
298
+ .map(v => {
299
+ const id = v.url ? String(v.url).split('v=')[1] : v.id
300
+ return { title: (v.title || 'YouTube video').trim(),
301
+ url: `https://www.youtube.com/watch?v=${id}` }
302
+ })
303
+ .filter(v => v.url.includes('watch?v=') && v.url.split('v=')[1])
304
+ if (items.length) return items
305
+ } catch { /* try next */ }
306
+ }
307
+
308
+ // Scrape the results page
309
+ try {
310
+ const res = await fetchWithTimeout(
311
+ `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`,
312
+ { headers: { 'User-Agent': SEARCH_UA } })
313
+ if (res.ok) {
314
+ const html = await res.text()
315
+ const out = []
316
+ const seen = new Set()
317
+ const re = /"videoRenderer":\{"videoId":"([\w-]{11})".*?"text":"([^"]+)"/g
318
+ let m
319
+ while ((m = re.exec(html)) && out.length < max) {
320
+ if (seen.has(m[1])) continue
321
+ seen.add(m[1])
322
+ out.push({ title: m[2], url: `https://www.youtube.com/watch?v=${m[1]}` })
323
+ }
324
+ if (out.length) return out
325
+ }
326
+ } catch { /* ignore */ }
327
+
328
+ return [{ title: `YouTube: ${query}`,
329
+ url: `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}` }]
330
+ }
331
+
332
+ // ── MCP Tool Definitions ──────────────────────────────────────────────────────
333
+
334
+ const TOOLS = [
335
+ {
336
+ name: 'list_major_topics',
337
+ description: 'List all major topic categories in KARMA 365',
338
+ inputSchema: { type: 'object', properties: {} }
339
+ },
340
+ {
341
+ name: 'create_major_topic',
342
+ description: 'Create a new major topic category in KARMA 365',
343
+ inputSchema: {
344
+ type: 'object',
345
+ required: ['name'],
346
+ properties: {
347
+ name: { type: 'string', description: 'Name of the major topic' },
348
+ is_pinned: { type: 'boolean', description: 'Whether to pin this major topic' }
349
+ }
350
+ }
351
+ },
352
+ {
353
+ name: 'delete_major_topic',
354
+ description: 'Delete a major topic by name or ID (topics under it stay but lose their category).',
355
+ inputSchema: {
356
+ type: 'object',
357
+ required: ['name'],
358
+ properties: {
359
+ name: { type: 'string', description: 'Major topic name or ID' },
360
+ pin: { type: 'string', description: 'Optional PIN if locked' }
361
+ }
362
+ }
363
+ },
364
+ {
365
+ name: 'list_topics',
366
+ description: 'List topics in KARMA 365, optionally filtered by major topic, difficulty, or parent topic',
367
+ inputSchema: {
368
+ type: 'object',
369
+ properties: {
370
+ major_topic_name: { type: 'string', description: 'Filter by major topic name' },
371
+ difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
372
+ parent_topic_name: { type: 'string', description: 'List subtopics of this topic' }
373
+ }
374
+ }
375
+ },
376
+ {
377
+ name: 'get_topic',
378
+ description: 'Get full details of a topic including all its resources',
379
+ inputSchema: {
380
+ type: 'object',
381
+ required: ['name'],
382
+ properties: {
383
+ name: { type: 'string', description: 'Topic name or ID' }
384
+ }
385
+ }
386
+ },
387
+ {
388
+ name: 'create_topic',
389
+ description: 'Create a new topic in KARMA 365',
390
+ inputSchema: {
391
+ type: 'object',
392
+ required: ['name'],
393
+ properties: {
394
+ name: { type: 'string', description: 'Topic name' },
395
+ difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] },
396
+ major_topic_name: { type: 'string', description: 'Assign to this major topic' },
397
+ color: { type: 'string', description: 'Hex color code e.g. #FF5733' }
398
+ }
399
+ }
400
+ },
401
+ {
402
+ name: 'create_subtopic',
403
+ description: 'Create a subtopic under an existing topic',
404
+ inputSchema: {
405
+ type: 'object',
406
+ required: ['name', 'parent_topic_name'],
407
+ properties: {
408
+ name: { type: 'string', description: 'Subtopic name' },
409
+ parent_topic_name: { type: 'string', description: 'Parent topic name or ID' },
410
+ difficulty: { type: 'string', enum: ['Easy', 'Medium', 'Hard'] }
411
+ }
412
+ }
413
+ },
414
+ {
415
+ name: 'delete_topic',
416
+ description: 'Delete a topic and all its resources permanently.',
417
+ inputSchema: {
418
+ type: 'object',
419
+ required: ['name'],
420
+ properties: {
421
+ name: { type: 'string', description: 'Topic name or ID' },
422
+ pin: { type: 'string', description: 'Optional PIN if locked' }
423
+ }
424
+ }
425
+ },
426
+ {
427
+ name: 'list_resources',
428
+ description: 'List all resources attached to a topic',
429
+ inputSchema: {
430
+ type: 'object',
431
+ required: ['topic_name'],
432
+ properties: {
433
+ topic_name: { type: 'string', description: 'Topic name or ID' },
434
+ type: { type: 'string', enum: ['pdf', 'video', 'youtube', 'link', 'audio', 'image', 'code'] }
435
+ }
436
+ }
437
+ },
438
+ {
439
+ name: 'add_youtube',
440
+ description: 'Add a YouTube video link to a topic',
441
+ inputSchema: {
442
+ type: 'object',
443
+ required: ['topic_name', 'url', 'description'],
444
+ properties: {
445
+ topic_name: { type: 'string' },
446
+ url: { type: 'string', description: 'YouTube URL' },
447
+ description: { type: 'string', description: 'Label for this resource' }
448
+ }
449
+ }
450
+ },
451
+ {
452
+ name: 'add_link',
453
+ description: 'Add a web link to a topic',
454
+ inputSchema: {
455
+ type: 'object',
456
+ required: ['topic_name', 'url', 'description'],
457
+ properties: {
458
+ topic_name: { type: 'string' },
459
+ url: { type: 'string' },
460
+ description: { type: 'string' }
461
+ }
462
+ }
463
+ },
464
+ {
465
+ name: 'add_pdf',
466
+ description: 'Add a PDF resource (URL or local file path) to a topic',
467
+ inputSchema: {
468
+ type: 'object',
469
+ required: ['topic_name', 'url', 'description'],
470
+ properties: {
471
+ topic_name: { type: 'string' },
472
+ url: { type: 'string', description: 'http(s) URL or local file path' },
473
+ description: { type: 'string' }
474
+ }
475
+ }
476
+ },
477
+ {
478
+ name: 'add_audio',
479
+ description: 'Add an audio resource (URL or local file path) to a topic',
480
+ inputSchema: {
481
+ type: 'object',
482
+ required: ['topic_name', 'url', 'description'],
483
+ properties: {
484
+ topic_name: { type: 'string' },
485
+ url: { type: 'string', description: 'http(s) URL or local file path' },
486
+ description: { type: 'string' }
487
+ }
488
+ }
489
+ },
490
+ {
491
+ name: 'add_video',
492
+ description: 'Add a video resource (URL or local file path) to a topic',
493
+ inputSchema: {
494
+ type: 'object',
495
+ required: ['topic_name', 'url', 'description'],
496
+ properties: {
497
+ topic_name: { type: 'string' },
498
+ url: { type: 'string', description: 'http(s) URL or local file path' },
499
+ description: { type: 'string' }
500
+ }
501
+ }
502
+ },
503
+ {
504
+ name: 'add_image',
505
+ description: 'Add an image resource (URL or local file path) to a topic',
506
+ inputSchema: {
507
+ type: 'object',
508
+ required: ['topic_name', 'url', 'description'],
509
+ properties: {
510
+ topic_name: { type: 'string' },
511
+ url: { type: 'string', description: 'http(s) URL or local file path' },
512
+ description: { type: 'string' }
513
+ }
514
+ }
515
+ },
516
+ {
517
+ name: 'add_code',
518
+ description: 'Add a code file resource (URL or local file path) to a topic',
519
+ inputSchema: {
520
+ type: 'object',
521
+ required: ['topic_name', 'url', 'description'],
522
+ properties: {
523
+ topic_name: { type: 'string' },
524
+ url: { type: 'string', description: 'http(s) URL or local file path' },
525
+ description: { type: 'string' }
526
+ }
527
+ }
528
+ },
529
+ {
530
+ name: 'delete_resource',
531
+ description: 'Delete a resource by its ID.',
532
+ inputSchema: {
533
+ type: 'object',
534
+ required: ['resource_id'],
535
+ properties: {
536
+ resource_id: { type: 'string', description: 'Resource UUID' },
537
+ pin: { type: 'string', description: 'Optional PIN if locked' }
538
+ }
539
+ }
540
+ },
541
+ {
542
+ name: 'mark_resource_complete',
543
+ description: 'Mark a resource as studied/complete (or revert to incomplete)',
544
+ inputSchema: {
545
+ type: 'object',
546
+ required: ['resource_id', 'is_completed'],
547
+ properties: {
548
+ resource_id: { type: 'string' },
549
+ is_completed: { type: 'boolean' }
550
+ }
551
+ }
552
+ },
553
+ {
554
+ name: 'write_topic_note',
555
+ description: 'Write, add, or append notes/text into a topic note',
556
+ inputSchema: {
557
+ type: 'object',
558
+ required: ['topic_name', 'note'],
559
+ properties: {
560
+ topic_name: { type: 'string' },
561
+ note: { type: 'string' },
562
+ title: { type: 'string', description: 'Optional note title' },
563
+ pin: { type: 'string', description: 'Optional PIN if locked' }
564
+ }
565
+ }
566
+ },
567
+ {
568
+ name: 'list_todos',
569
+ description: 'List all todos in KARMA 365, optionally filtered by priority or completion',
570
+ inputSchema: {
571
+ type: 'object',
572
+ properties: {
573
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] },
574
+ is_completed: { type: 'boolean' }
575
+ }
576
+ }
577
+ },
578
+ {
579
+ name: 'create_todo',
580
+ description: 'Create a new task/todo in KARMA 365',
581
+ inputSchema: {
582
+ type: 'object',
583
+ required: ['name'],
584
+ properties: {
585
+ name: { type: 'string' },
586
+ priority: { type: 'string', enum: ['high', 'medium', 'low'], description: 'Defaults to medium' },
587
+ notes: { type: 'string' }
588
+ }
589
+ }
590
+ },
591
+ {
592
+ name: 'complete_todo',
593
+ description: 'Mark a todo as done',
594
+ inputSchema: {
595
+ type: 'object',
596
+ required: ['name'],
597
+ properties: {
598
+ name: { type: 'string', description: 'Todo name or ID' }
599
+ }
600
+ }
601
+ },
602
+ {
603
+ name: 'update_todo',
604
+ description: 'Update a todo\'s name, priority, notes or completion status',
605
+ inputSchema: {
606
+ type: 'object',
607
+ required: ['id'],
608
+ properties: {
609
+ id: { type: 'string', description: 'Todo ID' },
610
+ name: { type: 'string' },
611
+ priority: { type: 'string', enum: ['high', 'medium', 'low'] },
612
+ notes: { type: 'string' },
613
+ is_completed: { type: 'boolean' }
614
+ }
615
+ }
616
+ },
617
+ {
618
+ name: 'delete_todo',
619
+ description: 'Delete a todo by name or ID',
620
+ inputSchema: {
621
+ type: 'object',
622
+ required: ['name'],
623
+ properties: {
624
+ name: { type: 'string', description: 'Todo name or ID' }
625
+ }
626
+ }
627
+ },
628
+ {
629
+ name: 'star_topic',
630
+ description: 'Star (favourite) or unstar a topic/subtopic.',
631
+ inputSchema: {
632
+ type: 'object',
633
+ required: ['name', 'starred'],
634
+ properties: {
635
+ name: { type: 'string', description: 'Topic name or ID' },
636
+ starred: { type: 'boolean', description: 'true = add to favourites, false = remove' }
637
+ }
638
+ }
639
+ },
640
+ {
641
+ name: 'star_major_topic',
642
+ description: 'Star (favourite) or unstar a major topic.',
643
+ inputSchema: {
644
+ type: 'object',
645
+ required: ['name', 'starred'],
646
+ properties: {
647
+ name: { type: 'string', description: 'Major topic name or ID' },
648
+ starred: { type: 'boolean', description: 'true = add to favourites, false = remove' }
649
+ }
650
+ }
651
+ },
652
+ {
653
+ name: 'pin_major_topic',
654
+ description: 'Pin (lock) or unpin a major topic.',
655
+ inputSchema: {
656
+ type: 'object',
657
+ required: ['name', 'pinned'],
658
+ properties: {
659
+ name: { type: 'string', description: 'Major topic name or ID' },
660
+ pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' }
661
+ }
662
+ }
663
+ },
664
+ {
665
+ name: 'pin_topic',
666
+ description: 'Pin (lock) or unpin a topic.',
667
+ inputSchema: {
668
+ type: 'object',
669
+ required: ['name', 'pinned'],
670
+ properties: {
671
+ name: { type: 'string', description: 'Topic name or ID' },
672
+ pinned: { type: 'boolean', description: 'true = lock/pin, false = unlock/unpin' }
673
+ }
674
+ }
675
+ },
676
+ {
677
+ name: 'set_major_topic_color',
678
+ description: 'Change the colour of a major topic.',
679
+ inputSchema: {
680
+ type: 'object',
681
+ required: ['name', 'color'],
682
+ properties: {
683
+ name: { type: 'string', description: 'Major topic name or ID' },
684
+ color: { type: 'string', description: 'Hex colour or color name' }
685
+ }
686
+ }
687
+ },
688
+ {
689
+ name: 'set_topic_color',
690
+ description: 'Change the colour of a topic.',
691
+ inputSchema: {
692
+ type: 'object',
693
+ required: ['name', 'color'],
694
+ properties: {
695
+ name: { type: 'string', description: 'Topic name or ID' },
696
+ color: { type: 'string', description: 'Hex colour or color name' }
697
+ }
698
+ }
699
+ },
700
+ {
701
+ name: 'rename_major_topic',
702
+ description: 'Rename a major topic.',
703
+ inputSchema: {
704
+ type: 'object',
705
+ required: ['name', 'new_name'],
706
+ properties: {
707
+ name: { type: 'string', description: 'CURRENT major topic name or ID' },
708
+ new_name: { type: 'string', description: 'The NEW name' }
709
+ }
710
+ }
711
+ },
712
+ {
713
+ name: 'rename_topic',
714
+ description: 'Rename a topic.',
715
+ inputSchema: {
716
+ type: 'object',
717
+ required: ['name', 'new_name'],
718
+ properties: {
719
+ name: { type: 'string', description: 'CURRENT topic name or ID' },
720
+ new_name: { type: 'string', description: 'The NEW name' }
721
+ }
722
+ }
723
+ },
724
+ {
725
+ name: 'delete_completed_todos',
726
+ description: 'Delete ALL completed todos/tasks at once.',
727
+ inputSchema: { type: 'object', properties: {} }
728
+ },
729
+ {
730
+ name: 'clean_empty_major_topics',
731
+ description: 'Delete all major topics that have no resources in any of their subtopics.',
732
+ inputSchema: { type: 'object', properties: {} }
733
+ },
734
+ {
735
+ name: 'get_stats',
736
+ description: 'Get KARMA 365 dashboard statistics: topic counts, resource completion, todos',
737
+ inputSchema: { type: 'object', properties: {} }
738
+ },
739
+ {
740
+ name: 'search',
741
+ description: 'Search across all topics, resources, and todos by keyword',
742
+ inputSchema: {
743
+ type: 'object',
744
+ required: ['query'],
745
+ properties: {
746
+ query: { type: 'string', description: 'Search keyword' }
747
+ }
748
+ }
749
+ },
750
+ {
751
+ name: 'set_dark_mode',
752
+ description: 'Turn the KARMA 365 app dark mode ON or OFF.',
753
+ inputSchema: {
754
+ type: 'object',
755
+ required: ['enabled'],
756
+ properties: {
757
+ enabled: { type: 'boolean', description: 'true = dark mode ON, false = light mode' }
758
+ }
759
+ }
760
+ },
761
+ {
762
+ name: 'search_web',
763
+ description: 'Search the public web for articles/pages by keyword (KEYLESS). Optionally save the results as link resources to a topic.',
764
+ inputSchema: {
765
+ type: 'object',
766
+ required: ['query'],
767
+ properties: {
768
+ query: { type: 'string', description: 'Web search query' },
769
+ topic_name: { type: 'string', description: 'If given, save the results as link resources to this topic' }
770
+ }
771
+ }
772
+ },
773
+ {
774
+ name: 'search_youtube',
775
+ description: 'Search YouTube for real videos by keyword (KEYLESS). Optionally save results to a topic.',
776
+ inputSchema: {
777
+ type: 'object',
778
+ required: ['query'],
779
+ properties: {
780
+ query: { type: 'string', description: 'YouTube search query' },
781
+ topic_name: { type: 'string', description: 'If given, save the found videos to this topic' },
782
+ max_results: { type: 'number', description: 'Number of videos to fetch (default 3, max 5)' }
783
+ }
784
+ }
785
+ }
786
+ ]
787
+
788
+ // ── Phone Live Bridge Setup & Auto-Discovery (Wi-Fi, Hotspot, USB) ─────────
789
+ const EXPLICIT_PHONE_URL = process.env.KARMA365_PHONE_URL ? process.env.KARMA365_PHONE_URL.trim().replace(/\/+$/, '') : null
790
+ const KARMA_TOKEN = process.env.KARMA365_TOKEN ? process.env.KARMA365_TOKEN.trim() : null
791
+ const PHONE_CACHE_FILE = path.join(os.homedir(), '.karma365', 'last_phone_url.txt')
792
+
793
+ let activePhoneUrl = EXPLICIT_PHONE_URL || readCachedPhoneUrl()
794
+ let isDiscoveryRunning = false
795
+ let lastDiscoveryTime = 0
796
+
797
+ function readCachedPhoneUrl() {
798
+ try {
799
+ if (fs.existsSync(PHONE_CACHE_FILE)) {
800
+ const u = fs.readFileSync(PHONE_CACHE_FILE, 'utf-8').trim()
801
+ if (u && u.startsWith('http')) return u
802
+ }
803
+ } catch (_) {}
804
+ return null
805
+ }
806
+
807
+ function writeCachedPhoneUrl(url) {
808
+ try {
809
+ const d = path.dirname(PHONE_CACHE_FILE)
810
+ if (!fs.existsSync(d)) fs.mkdirSync(d, { recursive: true })
811
+ fs.writeFileSync(PHONE_CACHE_FILE, url, 'utf-8')
812
+ } catch (_) {}
813
+ }
814
+
815
+ async function probePhoneHealth(baseUrl, timeoutMs = 600) {
816
+ try {
817
+ const res = await fetch(`${baseUrl}/api/health`, { signal: AbortSignal.timeout(timeoutMs) })
818
+ if (!res.ok) return false
819
+ const d = await res.json()
820
+ return d && d.app === 'karma365'
821
+ } catch (_) {
822
+ return false
823
+ }
824
+ }
825
+
826
+ async function autoDiscoverPhone(force = false) {
827
+ if (EXPLICIT_PHONE_URL) return EXPLICIT_PHONE_URL
828
+ const now = Date.now()
829
+ if (!force && activePhoneUrl && (now - lastDiscoveryTime < 15000)) {
830
+ return activePhoneUrl
831
+ }
832
+ if (isDiscoveryRunning) return activePhoneUrl
833
+ isDiscoveryRunning = true
834
+ lastDiscoveryTime = now
835
+
836
+ try {
837
+ // 1. Check cached URL, localhost (USB adb forward), or Android hotspot default gateway
838
+ const quickCandidates = [
839
+ activePhoneUrl,
840
+ 'http://127.0.0.1:8365',
841
+ 'http://192.168.43.1:8365',
842
+ ].filter(Boolean)
843
+
844
+ for (const url of quickCandidates) {
845
+ if (await probePhoneHealth(url, 350)) {
846
+ activePhoneUrl = url
847
+ writeCachedPhoneUrl(url)
848
+ process.stderr.write(`[karma365-mcp] 📱 Connected to KARMA 365 Mobile App: ${url}\n`)
849
+ return url
850
+ }
851
+ }
852
+
853
+ // 2. Fast parallel sweep across active PC network interfaces
854
+ const nets = os.networkInterfaces()
855
+ const subnets = new Set()
856
+ for (const name of Object.keys(nets)) {
857
+ for (const net of nets[name] || []) {
858
+ if (net.family === 'IPv4' && !net.internal) {
859
+ const parts = net.address.split('.')
860
+ if (parts.length === 4) {
861
+ subnets.add(`${parts[0]}.${parts[1]}.${parts[2]}.`)
862
+ }
863
+ }
864
+ }
865
+ }
866
+
867
+ const checks = []
868
+ for (const subnet of subnets) {
869
+ for (let i = 1; i <= 254; i++) {
870
+ const url = `http://${subnet}${i}:8365`
871
+ checks.push(
872
+ probePhoneHealth(url, 650)
873
+ .then(ok => (ok ? url : null))
874
+ .catch(() => null)
875
+ )
876
+ }
877
+ }
878
+
879
+ if (checks.length > 0) {
880
+ const results = await Promise.all(checks)
881
+ const found = results.find(Boolean)
882
+ if (found) {
883
+ activePhoneUrl = found
884
+ writeCachedPhoneUrl(found)
885
+ process.stderr.write(`[karma365-mcp] 📱 Auto-Discovered KARMA 365 Mobile App: ${found}\n`)
886
+ return found
887
+ }
888
+ }
889
+ } catch (e) {
890
+ process.stderr.write(`[karma365-mcp] Auto-discovery warning: ${e.message}\n`)
891
+ } finally {
892
+ isDiscoveryRunning = false
893
+ }
894
+
895
+ return activePhoneUrl
896
+ }
897
+
898
+ if (EXPLICIT_PHONE_URL) {
899
+ process.stderr.write(`[karma365-mcp] Phone Live Bridge Configured: ${EXPLICIT_PHONE_URL} (Token: ${KARMA_TOKEN ? 'Present' : 'None'})\n`)
900
+ } else {
901
+ process.stderr.write(`[karma365-mcp] Auto-Discovery Enabled (searching on :8365, local SQLite fallback: ${DB_PATH})\n`)
902
+ }
903
+
904
+ async function callPhoneBridge(name, args) {
905
+ let targetBase = activePhoneUrl || (await autoDiscoverPhone())
906
+ if (!targetBase) return null
907
+
908
+ const targetUrl = `${targetBase}/api/execute`
909
+ const isLargePayload = args && args.url && String(args.url).length > 50000
910
+ const timeoutMs = isLargePayload ? 60000 : 8000
911
+ const controller = new AbortController()
912
+ const timeoutId = setTimeout(() => controller.abort(), timeoutMs)
913
+
914
+ try {
915
+ const headers = { 'Content-Type': 'application/json' }
916
+ if (KARMA_TOKEN) {
917
+ headers['X-Karma-Token'] = KARMA_TOKEN
918
+ }
919
+
920
+ const res = await fetch(targetUrl, {
921
+ method: 'POST',
922
+ headers,
923
+ body: JSON.stringify({ name, args }),
924
+ signal: controller.signal,
925
+ })
926
+ clearTimeout(timeoutId)
927
+
928
+ if (res.status === 401) {
929
+ const data = await res.json().catch(() => ({}))
930
+ throw new Error(`Phone Authentication Failed (401): ${data.error || 'Invalid or missing KARMA365_TOKEN'}. Check your pairing token in Karma 365 app settings.`)
931
+ }
932
+
933
+ if (!res.ok) {
934
+ const data = await res.json().catch(() => ({}))
935
+ throw new Error(`Phone returned status ${res.status}: ${data.error || res.statusText}`)
936
+ }
937
+
938
+ const data = await res.json()
939
+ if (data.success === false) {
940
+ throw new Error(data.error || 'Execution failed on mobile app')
941
+ }
942
+
943
+ return data.result
944
+ } catch (err) {
945
+ clearTimeout(timeoutId)
946
+ if (err.message && err.message.includes('401')) {
947
+ throw err
948
+ }
949
+ process.stderr.write(`[karma365-mcp] Phone bridge unreachable at ${targetBase} (${err.message}). Checking auto-discovery or local SQLite...\n`)
950
+
951
+ // Try one quick re-discovery if the phone's IP changed on Wi-Fi
952
+ const rediscovered = await autoDiscoverPhone(true)
953
+ if (rediscovered && rediscovered !== targetBase) {
954
+ process.stderr.write(`[karma365-mcp] Retrying request on new phone IP: ${rediscovered}\n`)
955
+ activePhoneUrl = rediscovered
956
+ return callPhoneBridge(name, args)
957
+ }
958
+
959
+ activePhoneUrl = null
960
+ return null
961
+ }
962
+ }
963
+
964
+ async function handleTool(name, args) {
965
+ // If Phone Live Bridge is active or auto-discovered, delegate directly to phone app
966
+ const phoneUrl = activePhoneUrl || (await autoDiscoverPhone())
967
+ if (phoneUrl && name !== 'search_web' && name !== 'search_youtube') {
968
+ let argsToSend = { ...args }
969
+ if (['add_image', 'add_pdf', 'add_audio', 'add_video', 'add_code'].includes(name) && argsToSend.url) {
970
+ const localPath = String(argsToSend.url).trim()
971
+ if (fs.existsSync(localPath)) {
972
+ try {
973
+ const ext = path.extname(localPath).toLowerCase()
974
+ const mimeMap = {
975
+ '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.webp': 'image/webp',
976
+ '.pdf': 'application/pdf',
977
+ '.mp3': 'audio/mpeg', '.wav': 'audio/wav', '.m4a': 'audio/m4a',
978
+ '.mp4': 'video/mp4', '.mov': 'video/quicktime',
979
+ }
980
+ const mime = mimeMap[ext] || 'application/octet-stream'
981
+ const fileBytes = fs.readFileSync(localPath)
982
+ argsToSend.url = `data:${mime};base64,${fileBytes.toString('base64')}`
983
+ if (!argsToSend.description && !argsToSend.title) {
984
+ argsToSend.description = path.basename(localPath)
985
+ }
986
+ } catch (e) {
987
+ process.stderr.write(`[karma365-mcp] Failed to read local file ${localPath}: ${e.message}\n`)
988
+ }
989
+ }
990
+ }
991
+ const phoneResult = await callPhoneBridge(name, argsToSend)
992
+ if (phoneResult !== null && phoneResult !== undefined) {
993
+ if (typeof phoneResult === 'string') return phoneResult
994
+ return JSON.stringify(phoneResult, null, 2)
995
+ }
996
+ }
997
+
998
+ switch (name) {
999
+
1000
+ case 'set_dark_mode': {
1001
+ const enabled = args.enabled === true || String(args.enabled).toLowerCase() === 'true'
1002
+ const val = enabled ? 'true' : 'false'
1003
+ db.prepare(`INSERT INTO settings (key, value) VALUES ('dark_mode', ?)
1004
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(val)
1005
+ return enabled
1006
+ ? '✓ Dark mode turned ON 🌙 (saved to local settings)'
1007
+ : '✓ Dark mode turned OFF — light mode ☀️ (saved to local settings)'
1008
+ }
1009
+
1010
+ case 'list_major_topics': {
1011
+ const rows = db.prepare('SELECT * FROM major_topics ORDER BY sort_order ASC, created_at DESC').all()
1012
+ if (!rows.length) return 'No major topics found. Create one with create_major_topic.'
1013
+ return `Found ${rows.length} major topic(s):\n` +
1014
+ rows.map(m => `• ${m.name}${m.color ? ` [${m.color}]` : ''}${m.is_pinned ? ' 🔒' : ''}${m.is_starred ? ' ⭐' : ''} (ID: ${m.id})`).join('\n')
1015
+ }
1016
+
1017
+ case 'create_major_topic': {
1018
+ const id = newId()
1019
+ const isPinned = args.is_pinned ? 1 : 0
1020
+ db.prepare(`INSERT INTO major_topics (id, name, is_pinned, is_starred, sort_order, created_at, synced)
1021
+ VALUES (?, ?, ?, 0, 0, ?, 1)`).run(id, args.name.trim(), isPinned, nowIso())
1022
+ return `✓ Created major topic "${args.name}" (ID: ${id})`
1023
+ }
1024
+
1025
+ case 'delete_major_topic': {
1026
+ const item = resolveMajorTopic(args.name)
1027
+ db.prepare('UPDATE topics SET major_topic_id = NULL WHERE major_topic_id = ?').run(item.id)
1028
+ db.prepare('DELETE FROM major_topics WHERE id = ?').run(item.id)
1029
+ return `✓ Deleted major topic "${item.name}". Subtopics under it have been kept.`
1030
+ }
1031
+
1032
+ case 'rename_major_topic': {
1033
+ const item = resolveMajorTopic(args.name)
1034
+ const newName = args.new_name.trim()
1035
+ db.prepare('UPDATE major_topics SET name = ? WHERE id = ?').run(newName, item.id)
1036
+ return `✓ Renamed major topic from "${item.name}" to "${newName}"`
1037
+ }
1038
+
1039
+ case 'set_major_topic_color': {
1040
+ const item = resolveMajorTopic(args.name)
1041
+ const color = resolveColor(args.color)
1042
+ db.prepare('UPDATE major_topics SET color = ? WHERE id = ?').run(color, item.id)
1043
+ return `✓ Changed colour of major topic "${item.name}" to ${color}`
1044
+ }
1045
+
1046
+ case 'pin_major_topic': {
1047
+ const item = resolveMajorTopic(args.name)
1048
+ const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
1049
+ db.prepare('UPDATE major_topics SET is_pinned = ? WHERE id = ?').run(pinned, item.id)
1050
+ return pinned ? `✓ Locked/pinned major topic "${item.name}" 🔒` : `✓ Unlocked major topic "${item.name}"`
1051
+ }
1052
+
1053
+ case 'star_major_topic': {
1054
+ const item = resolveMajorTopic(args.name)
1055
+ const starred = args.starred === true || String(args.starred).toLowerCase() === 'true' ? 1 : 0
1056
+ db.prepare('UPDATE major_topics SET is_starred = ? WHERE id = ?').run(starred, item.id)
1057
+ return starred ? `✓ Added major topic "${item.name}" to favourites ⭐` : `✓ Removed major topic "${item.name}" from favourites`
1058
+ }
1059
+
1060
+ case 'list_topics': {
1061
+ let query = `SELECT t.*, m.name as major_name
1062
+ FROM topics t
1063
+ LEFT JOIN major_topics m ON t.major_topic_id = m.id
1064
+ WHERE 1=1`
1065
+ const params = []
1066
+
1067
+ if (args.major_topic_name) {
1068
+ const m = resolveMajorTopic(args.major_topic_name)
1069
+ query += ' AND t.major_topic_id = ?'
1070
+ params.push(m.id)
1071
+ }
1072
+ if (args.difficulty) {
1073
+ query += ' AND t.difficulty = ?'
1074
+ params.push(args.difficulty)
1075
+ }
1076
+ if (args.parent_topic_name) {
1077
+ const p = resolveTopic(args.parent_topic_name)
1078
+ query += ' AND t.parent_topic_id = ?'
1079
+ params.push(p.id)
1080
+ }
1081
+
1082
+ query += ' ORDER BY t.sort_order ASC, t.created_at DESC'
1083
+ const rows = db.prepare(query).all(...params)
1084
+
1085
+ if (!rows.length) return 'No topics found matching criteria.'
1086
+ return `Found ${rows.length} topic(s):\n` +
1087
+ rows.map(t => {
1088
+ const cat = t.major_name ? ` [Category: ${t.major_name}]` : ''
1089
+ const diff = t.difficulty ? ` (${t.difficulty})` : ''
1090
+ const icons = `${t.is_pinned ? ' 🔒' : ''}${t.is_starred ? ' ⭐' : ''}`
1091
+ return `• ${t.name}${cat}${diff}${icons} (ID: ${t.id})`
1092
+ }).join('\n')
1093
+ }
1094
+
1095
+ case 'get_topic': {
1096
+ const topic = resolveTopic(args.name)
1097
+ const resources = db.prepare('SELECT * FROM resources WHERE topic_id = ? ORDER BY sort_order ASC, created_at DESC').all(topic.id)
1098
+ let majorName = null
1099
+ if (topic.major_topic_id) {
1100
+ const m = db.prepare('SELECT name FROM major_topics WHERE id = ?').get(topic.major_topic_id)
1101
+ if (m) majorName = m.name
1102
+ }
1103
+
1104
+ let out = `Topic: ${topic.name}\n`
1105
+ out += `ID: ${topic.id}\n`
1106
+ if (majorName) out += `Category: ${majorName}\n`
1107
+ if (topic.difficulty) out += `Difficulty: ${topic.difficulty}\n`
1108
+ if (topic.color) out += `Color: ${topic.color}\n`
1109
+ if (topic.note) out += `Note: ${topic.note}\n`
1110
+ out += `Status: ${topic.is_pinned ? '🔒 Pinned ' : ''}${topic.is_starred ? '⭐ Starred' : ''}\n\n`
1111
+
1112
+ if (!resources.length) {
1113
+ out += 'Resources: None attached yet.'
1114
+ } else {
1115
+ out += `Resources (${resources.length}):\n`
1116
+ out += resources.map(r =>
1117
+ `• [${r.type.toUpperCase()}] ${r.description || '(no description)'} ${r.is_completed ? '✓' : '○'}\n ${r.path_or_url}`
1118
+ ).join('\n')
1119
+ }
1120
+ return out
1121
+ }
1122
+
1123
+ case 'create_topic': {
1124
+ let majorId = null
1125
+ if (args.major_topic_name) {
1126
+ const m = resolveMajorTopic(args.major_topic_name)
1127
+ majorId = m.id
1128
+ }
1129
+ const id = newId()
1130
+ const color = resolveColor(args.color)
1131
+ db.prepare(`INSERT INTO topics (id, name, difficulty, major_topic_id, color, created_at, synced)
1132
+ VALUES (?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', majorId, color, nowIso())
1133
+ return `✓ Created topic "${args.name}" (ID: ${id})`
1134
+ }
1135
+
1136
+ case 'create_subtopic': {
1137
+ const parent = resolveTopic(args.parent_topic_name)
1138
+ const id = newId()
1139
+ db.prepare(`INSERT INTO topics (id, name, difficulty, parent_topic_id, major_topic_id, created_at, synced)
1140
+ VALUES (?, ?, ?, ?, ?, ?, 1)`).run(id, args.name.trim(), args.difficulty || 'Medium', parent.id, parent.major_topic_id, nowIso())
1141
+ return `✓ Created subtopic "${args.name}" under parent "${parent.name}" (ID: ${id})`
1142
+ }
1143
+
1144
+ case 'delete_topic': {
1145
+ const topic = resolveTopic(args.name)
1146
+ db.prepare('DELETE FROM resources WHERE topic_id = ?').run(topic.id)
1147
+ db.prepare('DELETE FROM topics WHERE id = ?').run(topic.id)
1148
+ return `✓ Deleted topic "${topic.name}" and all its resources.`
1149
+ }
1150
+
1151
+ case 'rename_topic': {
1152
+ const topic = resolveTopic(args.name)
1153
+ const newName = args.new_name.trim()
1154
+ db.prepare('UPDATE topics SET name = ?, updated_at = ? WHERE id = ?').run(newName, nowIso(), topic.id)
1155
+ return `✓ Renamed topic from "${topic.name}" to "${newName}"`
1156
+ }
1157
+
1158
+ case 'set_topic_color': {
1159
+ const topic = resolveTopic(args.name)
1160
+ const color = resolveColor(args.color)
1161
+ db.prepare('UPDATE topics SET color = ?, updated_at = ? WHERE id = ?').run(color, nowIso(), topic.id)
1162
+ return `✓ Changed colour of topic "${topic.name}" to ${color}`
1163
+ }
1164
+
1165
+ case 'pin_topic': {
1166
+ const topic = resolveTopic(args.name)
1167
+ const pinned = args.pinned === true || String(args.pinned).toLowerCase() === 'true' ? 1 : 0
1168
+ db.prepare('UPDATE topics SET is_pinned = ?, updated_at = ? WHERE id = ?').run(pinned, nowIso(), topic.id)
1169
+ return pinned ? `✓ Locked/pinned topic "${topic.name}" 🔒` : `✓ Unlocked topic "${topic.name}"`
1170
+ }
1171
+
1172
+ case 'star_topic': {
1173
+ const topic = resolveTopic(args.name)
1174
+ const starred = args.starred === true || String(args.starred).toLowerCase() === 'true' ? 1 : 0
1175
+ db.prepare('UPDATE topics SET is_starred = ?, updated_at = ? WHERE id = ?').run(starred, nowIso(), topic.id)
1176
+ return starred ? `✓ Added topic "${topic.name}" to favourites ⭐` : `✓ Removed topic "${topic.name}" from favourites`
1177
+ }
1178
+
1179
+ case 'list_resources': {
1180
+ const topic = resolveTopic(args.topic_name)
1181
+ let query = 'SELECT * FROM resources WHERE topic_id = ?'
1182
+ const params = [topic.id]
1183
+ if (args.type) {
1184
+ query += ' AND type = ?'
1185
+ params.push(args.type)
1186
+ }
1187
+ query += ' ORDER BY sort_order ASC, created_at DESC'
1188
+ const rows = db.prepare(query).all(...params)
1189
+ if (!rows.length) return `No resources found for topic "${topic.name}".`
1190
+ return `Found ${rows.length} resource(s) in "${topic.name}":\n` +
1191
+ 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')
1192
+ }
1193
+
1194
+ case 'add_youtube':
1195
+ case 'add_link':
1196
+ case 'add_pdf':
1197
+ case 'add_audio':
1198
+ case 'add_video':
1199
+ case 'add_image':
1200
+ case 'add_code': {
1201
+ const topic = resolveTopic(args.topic_name)
1202
+ const type = RESOURCE_TYPE_MAP[name]
1203
+ const id = newId()
1204
+ let finalPathOrUrl = String(args.url || '').trim()
1205
+
1206
+ // For youtube, extract video ID if present
1207
+ let ytVideoId = null
1208
+ if (type === 'youtube') {
1209
+ const m = finalPathOrUrl.match(/(?:youtu\.be\/|v=|\/embed\/|\/v\/|watch\?v=)([\w-]{11})/)
1210
+ if (m) ytVideoId = m[1]
1211
+ }
1212
+
1213
+ db.prepare(`INSERT INTO resources (id, topic_id, type, description, path_or_url, youtube_video_id, is_completed, created_at, synced)
1214
+ VALUES (?, ?, ?, ?, ?, ?, 0, ?, 1)`).run(id, topic.id, type, args.description.trim(), finalPathOrUrl, ytVideoId, nowIso())
1215
+ return `✓ Added [${type.toUpperCase()}] "${args.description}" to "${topic.name}" (ID: ${id})`
1216
+ }
1217
+
1218
+ case 'delete_resource': {
1219
+ const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
1220
+ if (!row) throw new Error(`Resource not found with ID: "${args.resource_id}"`)
1221
+ db.prepare('DELETE FROM resources WHERE id = ?').run(row.id)
1222
+ return `✓ Deleted resource "${row.description || row.path_or_url}"`
1223
+ }
1224
+
1225
+ case 'mark_resource_complete': {
1226
+ const isCompleted = args.is_completed === true || String(args.is_completed).toLowerCase() === 'true' ? 1 : 0
1227
+ const row = db.prepare('SELECT * FROM resources WHERE id = ?').get(args.resource_id)
1228
+ if (!row) throw new Error(`Resource not found with ID: "${args.resource_id}"`)
1229
+ db.prepare('UPDATE resources SET is_completed = ? WHERE id = ?').run(isCompleted, row.id)
1230
+ return `✓ Resource marked as ${isCompleted ? 'completed ✓' : 'incomplete ○'}`
1231
+ }
1232
+
1233
+ case 'list_todos': {
1234
+ let query = 'SELECT * FROM todos WHERE 1=1'
1235
+ const params = []
1236
+ if (args.priority) {
1237
+ query += ' AND priority = ?'
1238
+ params.push(args.priority.toLowerCase())
1239
+ }
1240
+ if (args.is_completed !== undefined) {
1241
+ query += ' AND is_completed = ?'
1242
+ params.push(args.is_completed ? 1 : 0)
1243
+ }
1244
+ query += ' ORDER BY created_at DESC'
1245
+ const rows = db.prepare(query).all(...params)
1246
+ if (!rows.length) return 'No todos found.'
1247
+ return `Found ${rows.length} task(s):\n` +
1248
+ rows.map(t => `• [${t.is_completed ? '✓ Done' : '○ Todo'}] ${t.name} (Priority: ${t.priority})${t.notes ? ` - ${t.notes}` : ''} (ID: ${t.id})`).join('\n')
1249
+ }
1250
+
1251
+ case 'create_todo': {
1252
+ const id = newId()
1253
+ const now = nowIso()
1254
+ db.prepare(`INSERT INTO todos (id, name, priority, notes, is_completed, created_at, updated_at)
1255
+ VALUES (?, ?, ?, ?, 0, ?, ?)`).run(id, args.name.trim(), (args.priority || 'medium').toLowerCase(), args.notes || '', now, now)
1256
+ return `✓ Created todo "${args.name}" (ID: ${id})`
1257
+ }
1258
+
1259
+ case 'complete_todo': {
1260
+ const item = resolveTodo(args.name)
1261
+ db.prepare('UPDATE todos SET is_completed = 1, updated_at = ? WHERE id = ?').run(nowIso(), item.id)
1262
+ return `✓ Completed task "${item.name}" 🎉`
1263
+ }
1264
+
1265
+ case 'update_todo': {
1266
+ const item = db.prepare('SELECT * FROM todos WHERE id = ?').get(args.id)
1267
+ if (!item) throw new Error(`Todo not found with ID: "${args.id}"`)
1268
+
1269
+ const name = args.name !== undefined ? args.name.trim() : item.name
1270
+ const priority = args.priority !== undefined ? args.priority.toLowerCase() : item.priority
1271
+ const notes = args.notes !== undefined ? args.notes : item.notes
1272
+ const isCompleted = args.is_completed !== undefined ? (args.is_completed ? 1 : 0) : item.is_completed
1273
+
1274
+ db.prepare(`UPDATE todos SET name = ?, priority = ?, notes = ?, is_completed = ?, updated_at = ?
1275
+ WHERE id = ?`).run(name, priority, notes, isCompleted, nowIso(), item.id)
1276
+ return `✓ Updated todo "${name}"`
1277
+ }
1278
+
1279
+ case 'delete_todo': {
1280
+ const item = resolveTodo(args.name)
1281
+ db.prepare('DELETE FROM todos WHERE id = ?').run(item.id)
1282
+ return `✓ Deleted todo "${item.name}"`
1283
+ }
1284
+
1285
+ case 'delete_completed_todos': {
1286
+ const res = db.prepare('DELETE FROM todos WHERE is_completed = 1').run()
1287
+ return `✓ Removed ${res.changes || 0} completed task(s).`
1288
+ }
1289
+
1290
+ case 'write_topic_note': {
1291
+ const topic = resolveTopic(args.topic_name)
1292
+ checkPinIfLocked(topic.is_pinned, args.pin)
1293
+ const existing = (topic.note || '').trim()
1294
+ const newNote = existing ? `${existing}\n\n${args.note.trim()}` : args.note.trim()
1295
+ const title = args.title ? args.title.trim() : topic.note_title
1296
+ db.prepare('UPDATE topics SET note = ?, note_title = ?, updated_at = ? WHERE id = ?')
1297
+ .run(newNote, title, nowIso(), topic.id)
1298
+ return `✓ Note saved to topic "${topic.name}"`
1299
+ }
1300
+
1301
+ case 'clean_empty_major_topics': {
1302
+ const allMajor = db.prepare('SELECT * FROM major_topics').all()
1303
+ let removed = 0
1304
+ for (const m of allMajor) {
1305
+ const countRow = db.prepare(`
1306
+ SELECT count(r.id) as count
1307
+ FROM topics t
1308
+ LEFT JOIN resources r ON r.topic_id = t.id
1309
+ WHERE t.major_topic_id = ?
1310
+ `).get(m.id)
1311
+ if (!countRow || countRow.count === 0) {
1312
+ db.prepare('DELETE FROM major_topics WHERE id = ?').run(m.id)
1313
+ removed++
1314
+ }
1315
+ }
1316
+ return `✓ Cleaned ${removed} empty major topic(s) with no resources.`
1317
+ }
1318
+
1319
+ case 'get_stats': {
1320
+ const majorCount = db.prepare('SELECT count(*) as count FROM major_topics').get().count
1321
+ const topicCount = db.prepare('SELECT count(*) as count FROM topics').get().count
1322
+ const resTotal = db.prepare('SELECT count(*) as count FROM resources').get().count
1323
+ const resDone = db.prepare('SELECT count(*) as count FROM resources WHERE is_completed = 1').get().count
1324
+ const todoTotal = db.prepare('SELECT count(*) as count FROM todos').get().count
1325
+ const todoDone = db.prepare('SELECT count(*) as count FROM todos WHERE is_completed = 1').get().count
1326
+
1327
+ const completionPct = resTotal > 0 ? Math.round((resDone / resTotal) * 100) : 0
1328
+ return `📊 KARMA 365 Statistics:\n` +
1329
+ `• Major Topics: ${majorCount}\n` +
1330
+ `• Topics: ${topicCount}\n` +
1331
+ `• Resources: ${resTotal} total (${resDone} studied / ${completionPct}%)\n` +
1332
+ `• Tasks: ${todoTotal} total (${todoDone} completed)\n` +
1333
+ `• Storage: Local SQLite DB (${DB_PATH})`
1334
+ }
1335
+
1336
+ case 'search': {
1337
+ const q = `%${args.query.trim().toLowerCase()}%`
1338
+ const topics = db.prepare('SELECT * FROM topics WHERE LOWER(name) LIKE ? OR LOWER(note) LIKE ? LIMIT 10').all(q, q)
1339
+ const resources = db.prepare('SELECT * FROM resources WHERE LOWER(description) LIKE ? OR LOWER(path_or_url) LIKE ? LIMIT 10').all(q, q)
1340
+ const todos = db.prepare('SELECT * FROM todos WHERE LOWER(name) LIKE ? OR LOWER(notes) LIKE ? LIMIT 10').all(q, q)
1341
+
1342
+ let out = `Search results for "${args.query}":\n`
1343
+ if (topics.length) {
1344
+ out += `\nTopics (${topics.length}):\n` + topics.map(t => `• ${t.name} (ID: ${t.id})`).join('\n')
1345
+ }
1346
+ if (resources.length) {
1347
+ out += `\nResources (${resources.length}):\n` + resources.map(r => `• [${r.type}] ${r.description} (${r.path_or_url})`).join('\n')
1348
+ }
1349
+ if (todos.length) {
1350
+ out += `\nTodos (${todos.length}):\n` + todos.map(t => `• ${t.name} [${t.priority}]`).join('\n')
1351
+ }
1352
+ if (!topics.length && !resources.length && !todos.length) {
1353
+ out += 'No matching items found.'
1354
+ }
1355
+ return out
1356
+ }
1357
+
1358
+ case 'search_web': {
1359
+ const results = await searchWebKeyless(args.query)
1360
+ let out = `Web search results for "${args.query}":\n` +
1361
+ results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ''}`).join('\n\n')
1362
+
1363
+ if (args.topic_name) {
1364
+ const phoneUrl = activePhoneUrl || (await autoDiscoverPhone())
1365
+ if (phoneUrl) {
1366
+ try {
1367
+ for (const item of results) {
1368
+ await callPhoneBridge('add_link', { topic_name: args.topic_name, url: item.url, description: item.title })
1369
+ }
1370
+ out += `\n\n✓ Saved ${results.length} search links directly to topic "${args.topic_name}" on mobile app.`
1371
+ return out
1372
+ } catch (e) {
1373
+ process.stderr.write(`[karma365-mcp] Failed saving search links to phone: ${e.message}. Falling back to local DB.\n`)
1374
+ }
1375
+ }
1376
+ const topic = resolveTopic(args.topic_name)
1377
+ for (const item of results) {
1378
+ db.prepare(`INSERT INTO resources (id, topic_id, type, description, path_or_url, is_completed, created_at, synced)
1379
+ VALUES (?, ?, 'link', ?, ?, 0, ?, 1)`).run(newId(), topic.id, item.title, item.url, nowIso())
1380
+ }
1381
+ out += `\n\n✓ Saved ${results.length} search links directly to topic "${topic.name}".`
1382
+ }
1383
+ return out
1384
+ }
1385
+
1386
+ case 'search_youtube': {
1387
+ const results = await searchYouTubeKeyless(args.query, args.max_results || 3)
1388
+ let out = `YouTube search results for "${args.query}":\n` +
1389
+ results.map((r, i) => `${i + 1}. ${r.title}\n ${r.url}`).join('\n\n')
1390
+
1391
+ if (args.topic_name) {
1392
+ const phoneUrl = activePhoneUrl || (await autoDiscoverPhone())
1393
+ if (phoneUrl) {
1394
+ try {
1395
+ for (const item of results) {
1396
+ await callPhoneBridge('add_youtube', { topic_name: args.topic_name, url: item.url, description: item.title })
1397
+ }
1398
+ out += `\n\n✓ Saved ${results.length} YouTube videos directly to topic "${args.topic_name}" on mobile app.`
1399
+ return out
1400
+ } catch (e) {
1401
+ process.stderr.write(`[karma365-mcp] Failed saving YouTube videos to phone: ${e.message}. Falling back to local DB.\n`)
1402
+ }
1403
+ }
1404
+ const topic = resolveTopic(args.topic_name)
1405
+ for (const item of results) {
1406
+ const m = item.url.match(/v=([\w-]{11})/)
1407
+ const ytid = m ? m[1] : null
1408
+ db.prepare(`INSERT INTO resources (id, topic_id, type, description, path_or_url, youtube_video_id, is_completed, created_at, synced)
1409
+ VALUES (?, ?, 'youtube', ?, ?, ?, 0, ?, 1)`).run(newId(), topic.id, item.title, item.url, ytid, nowIso())
1410
+ }
1411
+ out += `\n\n✓ Saved ${results.length} YouTube videos directly to topic "${topic.name}".`
1412
+ }
1413
+ return out
1414
+ }
1415
+
1416
+ default:
1417
+ throw new Error(`Unknown tool: ${name}`)
1418
+ }
1419
+ }
1420
+
1421
+ // ── MCP Server Startup ────────────────────────────────────────────────────────
1422
+
1423
+ const server = new Server(
1424
+ { name: 'karma365-mcp', version: '2.0.0' },
1425
+ { capabilities: { tools: {} } }
1426
+ )
1427
+
1428
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
1429
+ return { tools: TOOLS }
1430
+ })
1431
+
1432
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1433
+ try {
1434
+ const text = await handleTool(request.params.name, request.params.arguments || {})
1435
+ return { content: [{ type: 'text', text: String(text) }] }
1436
+ } catch (err) {
1437
+ return { content: [{ type: 'text', text: `ERROR: ${err.message}` }], isError: true }
1438
+ }
1439
+ })
1440
+
1441
+ const transport = new StdioServerTransport()
1442
+ await server.connect(transport)
1443
+ process.stderr.write('[karma365-mcp] Server running on stdio transport (Zero-Server mode)\n')
1444
+
1445
+ // Trigger background phone discovery on network
1446
+ autoDiscoverPhone().catch(() => {})