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