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.

Files changed (4) hide show
  1. package/README.md +30 -1
  2. package/index.js +1795 -1446
  3. package/package.json +2 -1
  4. 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
- // ── 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(() => {})
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(() => {})