burn-mcp-server 2.0.4 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,1066 +1,1096 @@
1
1
  #!/usr/bin/env node
2
2
  "use strict";
3
- Object.defineProperty(exports, "__esModule", { value: true });
4
- const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
5
- const stdio_js_1 = require("@modelcontextprotocol/sdk/server/stdio.js");
6
- const supabase_js_1 = require("@supabase/supabase-js");
7
- const zod_1 = require("zod");
8
- // ---------------------------------------------------------------------------
9
- // Config
10
- // ---------------------------------------------------------------------------
11
- const SUPABASE_URL = process.env.BURN_SUPABASE_URL || 'https://juqtxylquemiuvvmgbej.supabase.co';
12
- const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || 'sb_publishable_reVgmmCC6ndIo6jFRMM2LQ_wujj5FrO';
13
- // Support both old JWT token (BURN_SUPABASE_TOKEN) and new long-lived MCP token (BURN_MCP_TOKEN)
3
+ var import_mcp = require("@modelcontextprotocol/sdk/server/mcp.js");
4
+ var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
5
+ var import_supabase_js = require("@supabase/supabase-js");
6
+ var import_zod = require("zod");
7
+ var import_node_os = require("node:os");
8
+ var import_node_fs = require("node:fs");
9
+ var import_node_path = require("node:path");
10
+ const SUPABASE_URL = process.env.BURN_SUPABASE_URL || "https://juqtxylquemiuvvmgbej.supabase.co";
11
+ const SUPABASE_ANON_KEY = process.env.BURN_SUPABASE_ANON_KEY || "sb_publishable_reVgmmCC6ndIo6jFRMM2LQ_wujj5FrO";
14
12
  const MCP_TOKEN = process.env.BURN_MCP_TOKEN;
15
13
  const LEGACY_JWT = process.env.BURN_SUPABASE_TOKEN;
16
- const EXCHANGE_URL = process.env.BURN_MCP_EXCHANGE_URL || 'https://api.burn451.cloud/api/mcp-exchange';
14
+ const EXCHANGE_URL = process.env.BURN_MCP_EXCHANGE_URL || "https://api.burn451.cloud/api/mcp-exchange";
17
15
  if (!MCP_TOKEN && !LEGACY_JWT) {
18
- console.error('Error: BURN_MCP_TOKEN environment variable is required.');
19
- console.error('Get your token from: Burn App Settings MCP Server Generate Token');
20
- process.exit(1);
16
+ console.error("Error: BURN_MCP_TOKEN environment variable is required.");
17
+ console.error("Get your token from: Burn App \u2192 Settings \u2192 MCP Server \u2192 Generate Token");
18
+ process.exit(1);
21
19
  }
22
- // ---------------------------------------------------------------------------
23
- // Supabase client — bootstrapped with anon key, session set after auth below
24
- // ---------------------------------------------------------------------------
25
- const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY, {
26
- auth: {
27
- persistSession: false,
28
- autoRefreshToken: true,
29
- },
30
- ...(LEGACY_JWT ? { global: { headers: { Authorization: `Bearer ${LEGACY_JWT}` } } } : {}),
20
+ const supabase = (0, import_supabase_js.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY, {
21
+ auth: {
22
+ persistSession: false,
23
+ autoRefreshToken: true
24
+ },
25
+ ...LEGACY_JWT ? { global: { headers: { Authorization: `Bearer ${LEGACY_JWT}` } } } : {}
31
26
  });
32
- // ---------------------------------------------------------------------------
33
- // Auth: exchange MCP token for a real Supabase session (auto-refreshes)
34
- // Caches session locally so exchange is only needed on first run or token expiry
35
- // ---------------------------------------------------------------------------
36
- const node_os_1 = require("node:os");
37
- const node_fs_1 = require("node:fs");
38
- const node_path_1 = require("node:path");
39
- const SESSION_CACHE_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.burn');
40
- const SESSION_CACHE_FILE = (0, node_path_1.join)(SESSION_CACHE_DIR, 'mcp-session.json');
27
+ const SESSION_CACHE_DIR = (0, import_node_path.join)((0, import_node_os.homedir)(), ".burn");
28
+ const SESSION_CACHE_FILE = (0, import_node_path.join)(SESSION_CACHE_DIR, "mcp-session.json");
41
29
  function loadCachedSession() {
42
- try {
43
- const raw = (0, node_fs_1.readFileSync)(SESSION_CACHE_FILE, 'utf-8');
44
- const data = JSON.parse(raw);
45
- if (data.access_token && data.refresh_token)
46
- return data;
47
- }
48
- catch { /* no cache or invalid */ }
49
- return null;
30
+ try {
31
+ const raw = (0, import_node_fs.readFileSync)(SESSION_CACHE_FILE, "utf-8");
32
+ const data = JSON.parse(raw);
33
+ if (data.access_token && data.refresh_token) return data;
34
+ } catch {
35
+ }
36
+ return null;
50
37
  }
51
38
  function saveCachedSession(access_token, refresh_token) {
52
- try {
53
- (0, node_fs_1.mkdirSync)(SESSION_CACHE_DIR, { recursive: true });
54
- (0, node_fs_1.writeFileSync)(SESSION_CACHE_FILE, JSON.stringify({ access_token, refresh_token }), { mode: 0o600 });
55
- }
56
- catch { /* non-fatal — next startup will re-exchange */ }
39
+ try {
40
+ (0, import_node_fs.mkdirSync)(SESSION_CACHE_DIR, { recursive: true });
41
+ (0, import_node_fs.writeFileSync)(SESSION_CACHE_FILE, JSON.stringify({ access_token, refresh_token }), { mode: 384 });
42
+ } catch {
43
+ }
57
44
  }
58
45
  async function initAuth() {
59
- if (LEGACY_JWT)
60
- return; // legacy mode: JWT already set in headers above
61
- // Step 1: Try cached session (avoids network call on every restart)
62
- const cached = loadCachedSession();
63
- if (cached) {
64
- const { error } = await supabase.auth.setSession(cached);
65
- if (!error) {
66
- // Listen for token refresh so we keep the cache fresh
67
- supabase.auth.onAuthStateChange((_event, session) => {
68
- if (session?.access_token && session?.refresh_token) {
69
- saveCachedSession(session.access_token, session.refresh_token);
70
- }
71
- });
72
- console.error('Burn MCP: restored session from cache (no network needed)');
73
- return;
46
+ if (LEGACY_JWT) return;
47
+ const cached = loadCachedSession();
48
+ if (cached) {
49
+ const { error } = await supabase.auth.setSession(cached);
50
+ if (!error) {
51
+ supabase.auth.onAuthStateChange((_event, session) => {
52
+ if (session?.access_token && session?.refresh_token) {
53
+ saveCachedSession(session.access_token, session.refresh_token);
74
54
  }
75
- console.error('Burn MCP: cached session expired, re-exchanging...');
55
+ });
56
+ console.error("Burn MCP: restored session from cache (no network needed)");
57
+ return;
76
58
  }
77
- // Step 2: Exchange MCP token for a fresh Supabase session via Vercel API
78
- try {
79
- const resp = await fetch(EXCHANGE_URL, {
80
- method: 'POST',
81
- headers: { 'Content-Type': 'application/json' },
82
- body: JSON.stringify({ token: MCP_TOKEN }),
83
- });
84
- if (!resp.ok) {
85
- const body = await resp.json().catch(() => ({}));
86
- console.error(`Error: Token exchange failed (${resp.status}): ${body.error || 'Unknown'}`);
87
- console.error('Tokens expire after 30 days. Generate a new one in Burn App → Settings → MCP Server.');
88
- process.exit(1);
89
- }
90
- const { access_token, refresh_token } = await resp.json();
91
- const { error: sessionError } = await supabase.auth.setSession({
92
- access_token,
93
- refresh_token,
94
- });
95
- if (sessionError) {
96
- console.error('Error: Failed to set session.', sessionError.message);
97
- process.exit(1);
98
- }
99
- // Cache for next startup + listen for refresh
100
- saveCachedSession(access_token, refresh_token);
101
- supabase.auth.onAuthStateChange((_event, session) => {
102
- if (session?.access_token && session?.refresh_token) {
103
- saveCachedSession(session.access_token, session.refresh_token);
104
- }
105
- });
106
- console.error('Burn MCP: session exchanged and cached');
59
+ console.error("Burn MCP: cached session expired, re-exchanging...");
60
+ }
61
+ try {
62
+ const resp = await fetch(EXCHANGE_URL, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({ token: MCP_TOKEN })
66
+ });
67
+ if (!resp.ok) {
68
+ const body = await resp.json().catch(() => ({}));
69
+ console.error(`Error: Token exchange failed (${resp.status}): ${body.error || "Unknown"}`);
70
+ console.error("Tokens expire after 30 days. Generate a new one in Burn App \u2192 Settings \u2192 MCP Server.");
71
+ process.exit(1);
107
72
  }
108
- catch (err) {
109
- console.error('Error: Could not reach token exchange endpoint.', err.message);
110
- console.error(`URL: ${EXCHANGE_URL}`);
111
- process.exit(1);
73
+ const { access_token, refresh_token } = await resp.json();
74
+ const { error: sessionError } = await supabase.auth.setSession({
75
+ access_token,
76
+ refresh_token
77
+ });
78
+ if (sessionError) {
79
+ console.error("Error: Failed to set session.", sessionError.message);
80
+ process.exit(1);
112
81
  }
82
+ saveCachedSession(access_token, refresh_token);
83
+ supabase.auth.onAuthStateChange((_event, session) => {
84
+ if (session?.access_token && session?.refresh_token) {
85
+ saveCachedSession(session.access_token, session.refresh_token);
86
+ }
87
+ });
88
+ console.error("Burn MCP: session exchanged and cached");
89
+ } catch (err) {
90
+ console.error("Error: Could not reach token exchange endpoint.", err.message);
91
+ console.error(`URL: ${EXCHANGE_URL}`);
92
+ process.exit(1);
93
+ }
113
94
  }
114
- // ---------------------------------------------------------------------------
115
- // Rate limiter — simple sliding window (per MCP session, in-memory)
116
- // ---------------------------------------------------------------------------
117
- const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
118
- const RATE_LIMIT_MAX_CALLS = 30; // max tool calls per window
95
+ const RATE_LIMIT_WINDOW_MS = 6e4;
96
+ const RATE_LIMIT_MAX_CALLS = 30;
119
97
  const rateLimitLog = [];
120
98
  function checkRateLimit() {
121
- const now = Date.now();
122
- // Remove entries outside the window
123
- while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
124
- rateLimitLog.shift();
125
- }
126
- if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
127
- const retryAfter = Math.ceil((rateLimitLog[0] + RATE_LIMIT_WINDOW_MS - now) / 1000);
128
- return `Rate limit exceeded (${RATE_LIMIT_MAX_CALLS} calls/min). Retry after ${retryAfter}s.`;
129
- }
130
- rateLimitLog.push(now);
131
- return null;
99
+ const now = Date.now();
100
+ while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
101
+ rateLimitLog.shift();
102
+ }
103
+ if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
104
+ const retryAfter = Math.ceil((rateLimitLog[0] + RATE_LIMIT_WINDOW_MS - now) / 1e3);
105
+ return `Rate limit exceeded (${RATE_LIMIT_MAX_CALLS} calls/min). Retry after ${retryAfter}s.`;
106
+ }
107
+ rateLimitLog.push(now);
108
+ return null;
132
109
  }
133
- // ---------------------------------------------------------------------------
134
- // MCP Server
135
- // ---------------------------------------------------------------------------
136
- const server = new mcp_js_1.McpServer({
137
- name: 'burn-mcp-server',
138
- version: '2.0.0',
110
+ const server = new import_mcp.McpServer({
111
+ name: "burn-mcp-server",
112
+ version: "2.0.0"
139
113
  });
140
- // ---------------------------------------------------------------------------
141
- // Helper: standard text result
142
- // ---------------------------------------------------------------------------
143
114
  function textResult(text) {
144
- return { content: [{ type: 'text', text }] };
115
+ return { content: [{ type: "text", text }] };
145
116
  }
146
- // ---------------------------------------------------------------------------
147
- // Helper: verify bookmark exists and has expected status
148
- // ---------------------------------------------------------------------------
149
117
  async function verifyBookmark(id, expectedStatus) {
150
- const { data, error } = await supabase
151
- .from('bookmarks')
152
- .select('*')
153
- .eq('id', id)
154
- .single();
155
- if (error)
156
- return { data: null, error: error.code === 'PGRST116' ? 'Bookmark not found' : error.message };
157
- if (expectedStatus) {
158
- const allowed = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus];
159
- if (!allowed.includes(data.status)) {
160
- const statusLabels = { active: 'Flame', read: 'Spark', absorbed: 'Vault', ash: 'Ash' };
161
- return { data, error: `Bookmark is in ${statusLabels[data.status] || data.status} (expected ${allowed.map(s => statusLabels[s] || s).join(' or ')})` };
162
- }
118
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("id", id).single();
119
+ if (error) return { data: null, error: error.code === "PGRST116" ? "Bookmark not found" : error.message };
120
+ if (expectedStatus) {
121
+ const allowed = Array.isArray(expectedStatus) ? expectedStatus : [expectedStatus];
122
+ if (!allowed.includes(data.status)) {
123
+ const statusLabels = { active: "Flame", read: "Spark", absorbed: "Vault", ash: "Ash" };
124
+ return { data, error: `Bookmark is in ${statusLabels[data.status] || data.status} (expected ${allowed.map((s) => statusLabels[s] || s).join(" or ")})` };
163
125
  }
164
- return { data, error: null };
126
+ }
127
+ return { data, error: null };
165
128
  }
166
- // ---------------------------------------------------------------------------
167
- // Helper: merge fields into content_metadata JSONB without overwriting
168
- // ---------------------------------------------------------------------------
169
129
  async function mergeContentMetadata(bookmarkId, fields, extraColumns) {
170
- const { data, error } = await supabase
171
- .from('bookmarks')
172
- .select('content_metadata')
173
- .eq('id', bookmarkId)
174
- .single();
175
- if (error)
176
- return { error: error.message };
177
- const existing = (data.content_metadata || {});
178
- // Only merge non-undefined fields
179
- const cleaned = {};
180
- for (const [k, v] of Object.entries(fields)) {
181
- if (v !== undefined && v !== null)
182
- cleaned[k] = v;
183
- }
184
- const merged = { ...existing, ...cleaned };
185
- const { error: updateError } = await supabase
186
- .from('bookmarks')
187
- .update({ content_metadata: merged, ...extraColumns })
188
- .eq('id', bookmarkId);
189
- return { error: updateError?.message || null };
130
+ const { data, error } = await supabase.from("bookmarks").select("content_metadata").eq("id", bookmarkId).single();
131
+ if (error) return { error: error.message };
132
+ const existing = data.content_metadata || {};
133
+ const cleaned = {};
134
+ for (const [k, v] of Object.entries(fields)) {
135
+ if (v !== void 0 && v !== null) cleaned[k] = v;
136
+ }
137
+ const merged = { ...existing, ...cleaned };
138
+ const { error: updateError } = await supabase.from("bookmarks").update({ content_metadata: merged, ...extraColumns }).eq("id", bookmarkId);
139
+ return { error: updateError?.message || null };
190
140
  }
191
- // ---------------------------------------------------------------------------
192
- // Helper: extract fields from content_metadata JSONB
193
- // ---------------------------------------------------------------------------
194
141
  function meta(row) {
195
- const m = row.content_metadata || {};
196
- return {
197
- id: row.id,
198
- url: row.url,
199
- title: row.title,
200
- author: m.author || row.author || null,
201
- platform: row.platform,
202
- status: row.status,
203
- tags: m.tags || [],
204
- thumbnail: m.thumbnail || null,
205
- vaultCategory: m.vault_category || null,
206
- vaultedAt: m.vaulted_at || null,
207
- aiPositioning: m.ai_positioning || null,
208
- aiDensity: m.ai_density || null,
209
- aiMinutes: m.ai_minutes || null,
210
- aiTakeaway: m.ai_takeaway || [],
211
- aiStrategyReason: m.ai_strategy_reason || null,
212
- aiHowToRead: m.ai_how_to_read || null,
213
- aiOverlap: m.ai_overlap || null,
214
- aiVerdict: m.ai_verdict || null,
215
- aiSummary: m.ai_summary || null,
216
- sparkInsight: m.spark_insight || null,
217
- extractedContent: m.extracted_content || null,
218
- externalURL: m.external_url || null,
219
- aiRelevance: m.ai_relevance || null,
220
- aiNovelty: m.ai_novelty || null,
221
- createdAt: row.created_at,
222
- countdownExpiresAt: row.countdown_expires_at,
223
- readAt: row.read_at,
224
- };
142
+ const m = row.content_metadata || {};
143
+ return {
144
+ id: row.id,
145
+ url: row.url,
146
+ title: row.title,
147
+ author: m.author || row.author || null,
148
+ platform: row.platform,
149
+ status: row.status,
150
+ tags: m.tags || [],
151
+ thumbnail: m.thumbnail || null,
152
+ vaultCategory: m.vault_category || null,
153
+ vaultedAt: m.vaulted_at || null,
154
+ aiPositioning: m.ai_positioning || null,
155
+ aiDensity: m.ai_density || null,
156
+ aiMinutes: m.ai_minutes || null,
157
+ aiTakeaway: m.ai_takeaway || [],
158
+ aiStrategyReason: m.ai_strategy_reason || null,
159
+ aiHowToRead: m.ai_how_to_read || null,
160
+ aiOverlap: m.ai_overlap || null,
161
+ aiVerdict: m.ai_verdict || null,
162
+ aiSummary: m.ai_summary || null,
163
+ sparkInsight: m.spark_insight || null,
164
+ extractedContent: m.extracted_content || null,
165
+ externalURL: m.external_url || null,
166
+ aiRelevance: m.ai_relevance || null,
167
+ aiNovelty: m.ai_novelty || null,
168
+ createdAt: row.created_at,
169
+ countdownExpiresAt: row.countdown_expires_at,
170
+ readAt: row.read_at
171
+ };
225
172
  }
226
- /** Compact summary for list views (no extracted content) */
227
173
  function metaSummary(row) {
228
- const m = row.content_metadata || {};
229
- return {
230
- id: row.id,
231
- url: row.url,
232
- title: row.title,
233
- author: m.author || null,
234
- platform: row.platform,
235
- tags: m.tags || [],
236
- vaultCategory: m.vault_category || null,
237
- vaultedAt: m.vaulted_at || null,
238
- aiPositioning: m.ai_positioning || null,
239
- aiTakeaway: m.ai_takeaway || [],
240
- };
174
+ const m = row.content_metadata || {};
175
+ return {
176
+ id: row.id,
177
+ url: row.url,
178
+ title: row.title,
179
+ author: m.author || null,
180
+ platform: row.platform,
181
+ tags: m.tags || [],
182
+ vaultCategory: m.vault_category || null,
183
+ vaultedAt: m.vaulted_at || null,
184
+ aiPositioning: m.ai_positioning || null,
185
+ aiTakeaway: m.ai_takeaway || []
186
+ };
241
187
  }
242
- /** Flame-specific summary with countdown and AI triage fields */
243
188
  function flameSummary(row) {
244
- const m = row.content_metadata || {};
245
- const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null;
246
- const now = new Date();
247
- const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
248
- const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10);
249
- return {
250
- id: row.id,
251
- url: row.url,
252
- title: row.title,
253
- author: m.author || null,
254
- platform: row.platform,
255
- tags: m.tags || [],
256
- createdAt: row.created_at,
257
- expiresAt: row.countdown_expires_at,
258
- remainingHours,
259
- isBurning: remainingHours <= 6,
260
- isCritical: remainingHours <= 1,
261
- aiPositioning: m.ai_positioning || null,
262
- aiDensity: m.ai_density || null,
263
- aiMinutes: m.ai_minutes || null,
264
- aiTakeaway: m.ai_takeaway || [],
265
- aiStrategy: m.ai_strategy || null,
266
- aiStrategyReason: m.ai_strategy_reason || null,
267
- aiHowToRead: m.ai_how_to_read || null,
268
- aiRelevance: m.ai_relevance || null,
269
- aiNovelty: m.ai_novelty || null,
270
- aiOverlap: m.ai_overlap || null,
271
- aiHook: m.ai_hook || null,
272
- aiAbout: m.ai_about || [],
273
- };
189
+ const m = row.content_metadata || {};
190
+ const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null;
191
+ const now = /* @__PURE__ */ new Date();
192
+ const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
193
+ const remainingHours = Math.max(0, Math.round(remainingMs / 36e5 * 10) / 10);
194
+ return {
195
+ id: row.id,
196
+ url: row.url,
197
+ title: row.title,
198
+ author: m.author || null,
199
+ platform: row.platform,
200
+ tags: m.tags || [],
201
+ createdAt: row.created_at,
202
+ expiresAt: row.countdown_expires_at,
203
+ remainingHours,
204
+ isBurning: remainingHours <= 6,
205
+ isCritical: remainingHours <= 1,
206
+ aiPositioning: m.ai_positioning || null,
207
+ aiDensity: m.ai_density || null,
208
+ aiMinutes: m.ai_minutes || null,
209
+ aiTakeaway: m.ai_takeaway || [],
210
+ aiStrategy: m.ai_strategy || null,
211
+ aiStrategyReason: m.ai_strategy_reason || null,
212
+ aiHowToRead: m.ai_how_to_read || null,
213
+ aiRelevance: m.ai_relevance || null,
214
+ aiNovelty: m.ai_novelty || null,
215
+ aiOverlap: m.ai_overlap || null,
216
+ aiHook: m.ai_hook || null,
217
+ aiAbout: m.ai_about || []
218
+ };
274
219
  }
275
- // ---------------------------------------------------------------------------
276
- // Tool handlers (all wrapped with rate limiting)
277
- // ---------------------------------------------------------------------------
278
- /** Wrap a handler with rate limiting */
279
220
  function rateLimited(handler) {
280
- return async (args) => {
281
- const err = checkRateLimit();
282
- if (err)
283
- return textResult(err);
284
- return handler(args);
285
- };
221
+ return async (args) => {
222
+ const err = checkRateLimit();
223
+ if (err) return textResult(err);
224
+ return handler(args);
225
+ };
286
226
  }
287
227
  async function handleSearchVault(args) {
288
- const { query, limit } = args;
289
- const { data, error } = await supabase
290
- .from('bookmarks')
291
- .select('*')
292
- .eq('status', 'absorbed')
293
- .ilike('title', `%${query}%`)
294
- .order('created_at', { ascending: false })
295
- .limit(limit || 10);
296
- if (error)
297
- return textResult(`Error: ${error.message}`);
298
- // Also search in content_metadata tags and takeaway
299
- let results = (data || []).map(metaSummary);
300
- // If title search returned few results, also search by tag
301
- if (results.length < (limit || 10)) {
302
- const { data: tagData } = await supabase
303
- .from('bookmarks')
304
- .select('*')
305
- .eq('status', 'absorbed')
306
- .order('created_at', { ascending: false })
307
- .limit(50);
308
- if (tagData) {
309
- const existingIds = new Set(results.map((r) => r.id));
310
- const tagMatches = tagData
311
- .filter((row) => {
312
- if (existingIds.has(row.id))
313
- return false;
314
- const m = row.content_metadata || {};
315
- const tags = (m.tags || []);
316
- const takeaway = (m.ai_takeaway || []);
317
- const positioning = m.ai_positioning || '';
318
- const allText = [...tags, ...takeaway, positioning].join(' ').toLowerCase();
319
- return allText.includes(query.toLowerCase());
320
- })
321
- .map(metaSummary);
322
- results = [...results, ...tagMatches].slice(0, limit || 10);
323
- }
228
+ const { query, limit } = args;
229
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "absorbed").ilike("title", `%${query}%`).order("created_at", { ascending: false }).limit(limit || 10);
230
+ if (error) return textResult(`Error: ${error.message}`);
231
+ let results = (data || []).map(metaSummary);
232
+ if (results.length < (limit || 10)) {
233
+ const { data: tagData } = await supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false }).limit(50);
234
+ if (tagData) {
235
+ const existingIds = new Set(results.map((r) => r.id));
236
+ const tagMatches = tagData.filter((row) => {
237
+ if (existingIds.has(row.id)) return false;
238
+ const m = row.content_metadata || {};
239
+ const tags = m.tags || [];
240
+ const takeaway = m.ai_takeaway || [];
241
+ const positioning = m.ai_positioning || "";
242
+ const allText = [...tags, ...takeaway, positioning].join(" ").toLowerCase();
243
+ return allText.includes(query.toLowerCase());
244
+ }).map(metaSummary);
245
+ results = [...results, ...tagMatches].slice(0, limit || 10);
324
246
  }
325
- return textResult(JSON.stringify(results, null, 2));
247
+ }
248
+ return textResult(JSON.stringify(results, null, 2));
326
249
  }
327
250
  async function handleGetBookmark(args) {
328
- const { data, error } = await supabase
329
- .from('bookmarks')
330
- .select('*')
331
- .eq('id', args.id)
332
- .single();
333
- if (error)
334
- return textResult(`Error: ${error.message}`);
335
- return textResult(JSON.stringify(meta(data), null, 2));
251
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).single();
252
+ if (error) return textResult(`Error: ${error.message}`);
253
+ return textResult(JSON.stringify(meta(data), null, 2));
336
254
  }
337
255
  async function handleListCategories() {
338
- const { data, error } = await supabase
339
- .from('bookmarks')
340
- .select('content_metadata')
341
- .eq('status', 'absorbed');
342
- if (error)
343
- return textResult(`Error: ${error.message}`);
344
- const counts = {};
345
- for (const row of data || []) {
346
- const cat = row.content_metadata?.vault_category || 'Uncategorized';
347
- counts[cat] = (counts[cat] || 0) + 1;
348
- }
349
- const categories = Object.entries(counts)
350
- .map(([category, count]) => ({ category, count }))
351
- .sort((a, b) => b.count - a.count);
352
- return textResult(JSON.stringify(categories, null, 2));
256
+ const { data, error } = await supabase.from("bookmarks").select("content_metadata").eq("status", "absorbed");
257
+ if (error) return textResult(`Error: ${error.message}`);
258
+ const counts = {};
259
+ for (const row of data || []) {
260
+ const cat = row.content_metadata?.vault_category || "Uncategorized";
261
+ counts[cat] = (counts[cat] || 0) + 1;
262
+ }
263
+ const categories = Object.entries(counts).map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
264
+ return textResult(JSON.stringify(categories, null, 2));
353
265
  }
354
266
  async function handleGetCollections() {
355
- const { data, error } = await supabase
356
- .from('collections')
357
- .select('id, name, bookmark_ids, ai_overview');
358
- if (error)
359
- return textResult(`Error: ${error.message}`);
360
- const collections = (data || []).map((c) => ({
361
- id: c.id,
362
- name: c.name,
363
- articleCount: Array.isArray(c.bookmark_ids) ? c.bookmark_ids.length : 0,
364
- overview: c.ai_overview?.theme || null,
365
- }));
366
- return textResult(JSON.stringify(collections, null, 2));
267
+ const { data, error } = await supabase.from("collections").select("id, name, bookmark_ids, ai_overview");
268
+ if (error) return textResult(`Error: ${error.message}`);
269
+ const collections = (data || []).map((c) => ({
270
+ id: c.id,
271
+ name: c.name,
272
+ articleCount: Array.isArray(c.bookmark_ids) ? c.bookmark_ids.length : 0,
273
+ overview: c.ai_overview?.theme || null
274
+ }));
275
+ return textResult(JSON.stringify(collections, null, 2));
367
276
  }
368
277
  async function handleGetCollectionOverview(args) {
369
- const { data: collection, error } = await supabase
370
- .from('collections')
371
- .select('*')
372
- .eq('name', args.name)
373
- .single();
374
- if (error) {
375
- return textResult(error.code === 'PGRST116'
376
- ? `No collection found with name "${args.name}".`
377
- : `Error: ${error.message}`);
378
- }
379
- let bookmarks = [];
380
- if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
381
- const { data: bData, error: bError } = await supabase
382
- .from('bookmarks')
383
- .select('*')
384
- .in('id', collection.bookmark_ids);
385
- if (!bError && bData) {
386
- bookmarks = bData.map(metaSummary);
387
- }
278
+ const { data: collection, error } = await supabase.from("collections").select("*").eq("name", args.name).single();
279
+ if (error) {
280
+ return textResult(
281
+ error.code === "PGRST116" ? `No collection found with name "${args.name}".` : `Error: ${error.message}`
282
+ );
283
+ }
284
+ let bookmarks = [];
285
+ if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
286
+ const { data: bData, error: bError } = await supabase.from("bookmarks").select("*").in("id", collection.bookmark_ids);
287
+ if (!bError && bData) {
288
+ bookmarks = bData.map(metaSummary);
388
289
  }
389
- return textResult(JSON.stringify({
390
- id: collection.id,
391
- name: collection.name,
392
- articleCount: Array.isArray(collection.bookmark_ids) ? collection.bookmark_ids.length : 0,
393
- aiOverview: collection.ai_overview,
394
- bookmarks,
395
- }, null, 2));
290
+ }
291
+ return textResult(JSON.stringify({
292
+ id: collection.id,
293
+ name: collection.name,
294
+ articleCount: Array.isArray(collection.bookmark_ids) ? collection.bookmark_ids.length : 0,
295
+ aiOverview: collection.ai_overview,
296
+ bookmarks
297
+ }, null, 2));
396
298
  }
397
299
  async function handleGetArticleContent(args) {
398
- const { data, error } = await supabase
399
- .from('bookmarks')
400
- .select('*')
401
- .eq('id', args.id)
402
- .single();
403
- if (error)
404
- return textResult(`Error: ${error.message}`);
405
- return textResult(JSON.stringify(meta(data), null, 2));
300
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).single();
301
+ if (error) return textResult(`Error: ${error.message}`);
302
+ return textResult(JSON.stringify(meta(data), null, 2));
406
303
  }
407
- // ---------------------------------------------------------------------------
408
- // Vercel API base URL for content fetching
409
- // ---------------------------------------------------------------------------
410
- const API_BASE = process.env.BURN_API_URL || 'https://api.burn451.cloud';
411
- const API_KEY = process.env.BURN_API_KEY || 'burn451-2026-secret-key';
412
- /** Detect platform from URL */
304
+ const API_BASE = process.env.BURN_API_URL || "https://api.burn451.cloud";
305
+ const API_KEY = process.env.BURN_API_KEY;
413
306
  function detectPlatform(url) {
414
- if (/x\.com|twitter\.com/i.test(url))
415
- return 'x';
416
- if (/youtube\.com|youtu\.be/i.test(url))
417
- return 'youtube';
418
- if (/reddit\.com|redd\.it/i.test(url))
419
- return 'reddit';
420
- if (/bilibili\.com|b23\.tv/i.test(url))
421
- return 'bilibili';
422
- if (/open\.spotify\.com/i.test(url))
423
- return 'spotify';
424
- if (/mp\.weixin\.qq\.com/i.test(url))
425
- return 'wechat';
426
- if (/xiaohongshu\.com|xhslink\.com/i.test(url))
427
- return 'xhs';
428
- return 'web';
307
+ if (/x\.com|twitter\.com/i.test(url)) return "x";
308
+ if (/youtube\.com|youtu\.be/i.test(url)) return "youtube";
309
+ if (/reddit\.com|redd\.it/i.test(url)) return "reddit";
310
+ if (/bilibili\.com|b23\.tv/i.test(url)) return "bilibili";
311
+ if (/open\.spotify\.com/i.test(url)) return "spotify";
312
+ if (/mp\.weixin\.qq\.com/i.test(url)) return "wechat";
313
+ if (/xiaohongshu\.com|xhslink\.com/i.test(url)) return "xhs";
314
+ return "web";
429
315
  }
430
- /** Fetch content via Vercel API proxy (bypasses GFW for X.com, etc.) */
431
316
  async function fetchViaAPI(url, platform) {
432
- try {
433
- let endpoint;
434
- let params;
435
- switch (platform) {
436
- case 'x':
437
- endpoint = `${API_BASE}/api/parse-x`;
438
- params = `url=${encodeURIComponent(url)}`;
439
- break;
440
- case 'reddit':
441
- endpoint = `${API_BASE}/api/parse-reddit`;
442
- params = `url=${encodeURIComponent(url)}`;
443
- break;
444
- case 'spotify':
445
- endpoint = `${API_BASE}/api/parse-meta`;
446
- params = `url=${encodeURIComponent(url)}&_platform=spotify`;
447
- break;
448
- case 'wechat':
449
- case 'xhs':
450
- endpoint = `${API_BASE}/api/parse-meta`;
451
- params = `url=${encodeURIComponent(url)}&_platform=${platform}`;
452
- break;
453
- case 'youtube':
454
- // Try transcript extraction via jina-extract with youtube platform hint
455
- endpoint = `${API_BASE}/api/jina-extract`;
456
- params = `url=${encodeURIComponent(url)}&_platform=youtube`;
457
- break;
458
- default:
459
- endpoint = `${API_BASE}/api/jina-extract`;
460
- params = `url=${encodeURIComponent(url)}`;
461
- break;
462
- }
463
- const resp = await fetch(`${endpoint}?${params}`, {
464
- headers: {
465
- 'x-api-key': API_KEY,
466
- 'Accept': 'application/json',
467
- },
468
- signal: AbortSignal.timeout(30000),
469
- });
470
- if (!resp.ok) {
471
- return { error: `API returned ${resp.status}` };
472
- }
473
- const data = await resp.json();
474
- // Normalize response across different API endpoints
475
- if (platform === 'x') {
476
- const text = data.text || data.article_text || '';
477
- const quoteText = data.quote ? `\n\n[Quote from @${data.quote.handle}]: ${data.quote.text}` : '';
478
- return {
479
- title: data.text?.slice(0, 100) || 'Tweet',
480
- author: data.author ? `@${data.handle || data.author}` : undefined,
481
- content: text + quoteText,
482
- };
483
- }
484
- if (platform === 'spotify') {
485
- return {
486
- title: data.title,
487
- author: data.author,
488
- content: data.extracted_content || data.description || null,
489
- };
490
- }
491
- if (platform === 'wechat') {
492
- // WeChat parse-meta returns extracted_content from js_content div
493
- return {
494
- title: data.title,
495
- author: data.author,
496
- content: data.extracted_content || data.content || null,
497
- };
498
- }
499
- return {
500
- title: data.title,
501
- author: data.author,
502
- content: data.content || data.extracted_content || data.text || data.transcript || null,
503
- };
317
+ try {
318
+ let endpoint;
319
+ let params;
320
+ switch (platform) {
321
+ case "x":
322
+ endpoint = `${API_BASE}/api/parse-x`;
323
+ params = `url=${encodeURIComponent(url)}`;
324
+ break;
325
+ case "reddit":
326
+ endpoint = `${API_BASE}/api/parse-reddit`;
327
+ params = `url=${encodeURIComponent(url)}`;
328
+ break;
329
+ case "spotify":
330
+ endpoint = `${API_BASE}/api/parse-meta`;
331
+ params = `url=${encodeURIComponent(url)}&_platform=spotify`;
332
+ break;
333
+ case "wechat":
334
+ case "xhs":
335
+ endpoint = `${API_BASE}/api/parse-meta`;
336
+ params = `url=${encodeURIComponent(url)}&_platform=${platform}`;
337
+ break;
338
+ case "youtube":
339
+ endpoint = `${API_BASE}/api/jina-extract`;
340
+ params = `url=${encodeURIComponent(url)}&_platform=youtube`;
341
+ break;
342
+ default:
343
+ endpoint = `${API_BASE}/api/jina-extract`;
344
+ params = `url=${encodeURIComponent(url)}`;
345
+ break;
346
+ }
347
+ const resp = await fetch(`${endpoint}?${params}`, {
348
+ headers: {
349
+ ...API_KEY ? { "x-api-key": API_KEY } : {},
350
+ "Accept": "application/json"
351
+ },
352
+ signal: AbortSignal.timeout(3e4)
353
+ });
354
+ if (!resp.ok) {
355
+ return { error: `API returned ${resp.status}` };
504
356
  }
505
- catch (err) {
506
- return { error: err.message || 'Fetch failed' };
357
+ const data = await resp.json();
358
+ if (platform === "x") {
359
+ const text = data.text || data.article_text || "";
360
+ const quoteText = data.quote ? `
361
+
362
+ [Quote from @${data.quote.handle}]: ${data.quote.text}` : "";
363
+ return {
364
+ title: data.text?.slice(0, 100) || "Tweet",
365
+ author: data.author ? `@${data.handle || data.author}` : void 0,
366
+ content: text + quoteText
367
+ };
507
368
  }
508
- }
509
- async function handleFetchContent(args) {
510
- const { url } = args;
511
- const platform = detectPlatform(url);
512
- // Step 1: Check if we already have content in Supabase
513
- const { data: existing } = await supabase
514
- .from('bookmarks')
515
- .select('*')
516
- .eq('url', url)
517
- .limit(1)
518
- .maybeSingle();
519
- if (existing) {
520
- const m = existing.content_metadata || {};
521
- if (m.extracted_content && m.extracted_content.length > 50) {
522
- return textResult(JSON.stringify({
523
- source: 'cache',
524
- url,
525
- platform,
526
- title: existing.title,
527
- author: m.author,
528
- content: m.extracted_content,
529
- aiPositioning: m.ai_positioning,
530
- aiTakeaway: m.ai_takeaway,
531
- tags: m.tags,
532
- }, null, 2));
533
- }
369
+ if (platform === "spotify") {
370
+ return {
371
+ title: data.title,
372
+ author: data.author,
373
+ content: data.extracted_content || data.description || null
374
+ };
534
375
  }
535
- // Step 2: Fetch fresh content via Vercel API
536
- const result = await fetchViaAPI(url, platform);
537
- if (result.error) {
538
- return textResult(JSON.stringify({
539
- source: 'error',
540
- url,
541
- platform,
542
- error: result.error,
543
- hint: platform === 'x' ? 'X.com content is fetched via Vercel Edge proxy to bypass GFW' : undefined,
544
- }, null, 2));
376
+ if (platform === "wechat") {
377
+ return {
378
+ title: data.title,
379
+ author: data.author,
380
+ content: data.extracted_content || data.content || null
381
+ };
545
382
  }
546
- return textResult(JSON.stringify({
547
- source: 'live',
383
+ return {
384
+ title: data.title,
385
+ author: data.author,
386
+ content: data.content || data.extracted_content || data.text || data.transcript || null
387
+ };
388
+ } catch (err) {
389
+ return { error: err.message || "Fetch failed" };
390
+ }
391
+ }
392
+ async function handleFetchContent(args) {
393
+ const { url } = args;
394
+ const platform = detectPlatform(url);
395
+ const { data: existing } = await supabase.from("bookmarks").select("*").eq("url", url).limit(1).maybeSingle();
396
+ if (existing) {
397
+ const m = existing.content_metadata || {};
398
+ if (m.extracted_content && m.extracted_content.length > 50) {
399
+ return textResult(JSON.stringify({
400
+ source: "cache",
548
401
  url,
549
402
  platform,
550
- title: result.title,
551
- author: result.author,
552
- content: result.content,
403
+ title: existing.title,
404
+ author: m.author,
405
+ content: m.extracted_content,
406
+ aiPositioning: m.ai_positioning,
407
+ aiTakeaway: m.ai_takeaway,
408
+ tags: m.tags
409
+ }, null, 2));
410
+ }
411
+ }
412
+ const result = await fetchViaAPI(url, platform);
413
+ if (result.error) {
414
+ return textResult(JSON.stringify({
415
+ source: "error",
416
+ url,
417
+ platform,
418
+ error: result.error,
419
+ hint: platform === "x" ? "X.com content is fetched via Vercel Edge proxy to bypass GFW" : void 0
553
420
  }, null, 2));
421
+ }
422
+ return textResult(JSON.stringify({
423
+ source: "live",
424
+ url,
425
+ platform,
426
+ title: result.title,
427
+ author: result.author,
428
+ content: result.content
429
+ }, null, 2));
554
430
  }
555
431
  async function handleListSparks(args) {
556
- const { data, error } = await supabase
557
- .from('bookmarks')
558
- .select('*')
559
- .eq('status', 'read')
560
- .order('created_at', { ascending: false })
561
- .limit(args.limit || 20);
562
- if (error)
563
- return textResult(`Error: ${error.message}`);
564
- const results = (data || []).map((row) => {
565
- const s = metaSummary(row);
566
- const m = row.content_metadata || {};
567
- return {
568
- ...s,
569
- sparkInsight: m.spark_insight || null,
570
- sparkExpiresAt: m.spark_expires_at || null,
571
- };
572
- });
573
- return textResult(JSON.stringify(results, null, 2));
432
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "read").order("created_at", { ascending: false }).limit(args.limit || 20);
433
+ if (error) return textResult(`Error: ${error.message}`);
434
+ const results = (data || []).map((row) => {
435
+ const s = metaSummary(row);
436
+ const m = row.content_metadata || {};
437
+ return {
438
+ ...s,
439
+ sparkInsight: m.spark_insight || null,
440
+ sparkExpiresAt: m.spark_expires_at || null
441
+ };
442
+ });
443
+ return textResult(JSON.stringify(results, null, 2));
574
444
  }
575
445
  async function handleSearchSparks(args) {
576
- const { query, limit } = args;
577
- const { data, error } = await supabase
578
- .from('bookmarks')
579
- .select('*')
580
- .eq('status', 'read')
581
- .order('created_at', { ascending: false })
582
- .limit(50);
583
- if (error)
584
- return textResult(`Error: ${error.message}`);
585
- const results = (data || [])
586
- .filter((row) => {
587
- const m = row.content_metadata || {};
588
- const searchable = [
589
- row.title || '',
590
- ...(m.tags || []),
591
- ...(m.ai_takeaway || []),
592
- m.ai_positioning || '',
593
- m.spark_insight || '',
594
- ].join(' ').toLowerCase();
595
- return searchable.includes(query.toLowerCase());
596
- })
597
- .slice(0, limit || 10)
598
- .map((row) => {
599
- const s = metaSummary(row);
600
- const m = row.content_metadata || {};
601
- return {
602
- ...s,
603
- sparkInsight: m.spark_insight || null,
604
- sparkExpiresAt: m.spark_expires_at || null,
605
- };
606
- });
607
- return textResult(JSON.stringify(results, null, 2));
446
+ const { query, limit } = args;
447
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "read").order("created_at", { ascending: false }).limit(50);
448
+ if (error) return textResult(`Error: ${error.message}`);
449
+ const results = (data || []).filter((row) => {
450
+ const m = row.content_metadata || {};
451
+ const searchable = [
452
+ row.title || "",
453
+ ...m.tags || [],
454
+ ...m.ai_takeaway || [],
455
+ m.ai_positioning || "",
456
+ m.spark_insight || ""
457
+ ].join(" ").toLowerCase();
458
+ return searchable.includes(query.toLowerCase());
459
+ }).slice(0, limit || 10).map((row) => {
460
+ const s = metaSummary(row);
461
+ const m = row.content_metadata || {};
462
+ return {
463
+ ...s,
464
+ sparkInsight: m.spark_insight || null,
465
+ sparkExpiresAt: m.spark_expires_at || null
466
+ };
467
+ });
468
+ return textResult(JSON.stringify(results, null, 2));
608
469
  }
609
470
  async function handleListFlame(args) {
610
- const { data, error } = await supabase
611
- .from('bookmarks')
612
- .select('*')
613
- .eq('status', 'active')
614
- .order('created_at', { ascending: false })
615
- .limit(args.limit || 20);
616
- if (error)
617
- return textResult(`Error: ${error.message}`);
618
- // Filter out already expired ones (should be ash but not yet processed)
619
- const now = new Date();
620
- const results = (data || [])
621
- .filter((row) => {
622
- if (!row.countdown_expires_at)
623
- return true;
624
- return new Date(row.countdown_expires_at).getTime() > now.getTime();
625
- })
626
- .map(flameSummary);
627
- return textResult(JSON.stringify(results, null, 2));
471
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "active").order("created_at", { ascending: false }).limit(args.limit || 20);
472
+ if (error) return textResult(`Error: ${error.message}`);
473
+ const now = /* @__PURE__ */ new Date();
474
+ const results = (data || []).filter((row) => {
475
+ if (!row.countdown_expires_at) return true;
476
+ return new Date(row.countdown_expires_at).getTime() > now.getTime();
477
+ }).map(flameSummary);
478
+ return textResult(JSON.stringify(results, null, 2));
628
479
  }
629
480
  async function handleGetFlameDetail(args) {
630
- const { data, error } = await supabase
631
- .from('bookmarks')
632
- .select('*')
633
- .eq('id', args.id)
634
- .eq('status', 'active')
635
- .single();
636
- if (error) {
637
- return textResult(error.code === 'PGRST116'
638
- ? `No active Flame bookmark found with id "${args.id}". It may have already burned to Ash or been moved to Spark/Vault.`
639
- : `Error: ${error.message}`);
640
- }
641
- // Return full detail including extracted content
642
- const m = data.content_metadata || {};
643
- const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null;
644
- const now = new Date();
645
- const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
646
- const remainingHours = Math.max(0, Math.round(remainingMs / 3600000 * 10) / 10);
647
- const result = {
648
- ...flameSummary(data),
649
- extractedContent: m.extracted_content || null,
650
- externalURL: m.external_url || null,
651
- thumbnail: m.thumbnail || null,
652
- aiFocus: m.ai_focus || null,
653
- aiUse: m.ai_use || null,
654
- aiBuzz: m.ai_buzz || null,
655
- };
656
- return textResult(JSON.stringify(result, null, 2));
481
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).eq("status", "active").single();
482
+ if (error) {
483
+ return textResult(
484
+ error.code === "PGRST116" ? `No active Flame bookmark found with id "${args.id}". It may have already burned to Ash or been moved to Spark/Vault.` : `Error: ${error.message}`
485
+ );
486
+ }
487
+ const m = data.content_metadata || {};
488
+ const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null;
489
+ const now = /* @__PURE__ */ new Date();
490
+ const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
491
+ const remainingHours = Math.max(0, Math.round(remainingMs / 36e5 * 10) / 10);
492
+ const result = {
493
+ ...flameSummary(data),
494
+ extractedContent: m.extracted_content || null,
495
+ externalURL: m.external_url || null,
496
+ thumbnail: m.thumbnail || null,
497
+ aiFocus: m.ai_focus || null,
498
+ aiUse: m.ai_use || null,
499
+ aiBuzz: m.ai_buzz || null
500
+ };
501
+ return textResult(JSON.stringify(result, null, 2));
657
502
  }
658
503
  async function handleListVault(args) {
659
- let query = supabase
660
- .from('bookmarks')
661
- .select('*')
662
- .eq('status', 'absorbed')
663
- .order('created_at', { ascending: false })
664
- .limit(args.limit || 20);
665
- const { data, error } = await query;
666
- if (error)
667
- return textResult(`Error: ${error.message}`);
668
- let results = (data || []).map(metaSummary);
669
- // Filter by category if provided
670
- if (args.category) {
671
- results = results.filter((r) => r.vaultCategory?.toLowerCase() === args.category.toLowerCase());
672
- }
673
- return textResult(JSON.stringify(results, null, 2));
504
+ let query = supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false }).limit(args.limit || 20);
505
+ const { data, error } = await query;
506
+ if (error) return textResult(`Error: ${error.message}`);
507
+ let results = (data || []).map(metaSummary);
508
+ if (args.category) {
509
+ results = results.filter(
510
+ (r) => r.vaultCategory?.toLowerCase() === args.category.toLowerCase()
511
+ );
512
+ }
513
+ return textResult(JSON.stringify(results, null, 2));
674
514
  }
675
- // ---------------------------------------------------------------------------
676
- // Layer 1: Status flow handlers (决策层)
677
- // ---------------------------------------------------------------------------
678
515
  async function handleMoveFlameToSpark(args) {
679
- const { data, error } = await verifyBookmark(args.id, 'active');
680
- if (error)
681
- return textResult(`Error: ${error}`);
682
- const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
683
- const metaFields = { spark_expires_at: sparkExpiresAt };
684
- if (args.spark_insight)
685
- metaFields.spark_insight = args.spark_insight;
686
- const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
687
- status: 'read',
688
- read_at: new Date().toISOString(),
689
- });
690
- if (mergeErr)
691
- return textResult(`Error: ${mergeErr}`);
692
- return textResult(JSON.stringify({
693
- success: true,
694
- id: args.id,
695
- title: data.title,
696
- action: 'flame → spark',
697
- sparkExpiresAt,
698
- }, null, 2));
516
+ const { data, error } = await verifyBookmark(args.id, "active");
517
+ if (error) return textResult(`Error: ${error}`);
518
+ const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1e3).toISOString();
519
+ const metaFields = { spark_expires_at: sparkExpiresAt };
520
+ if (args.spark_insight) metaFields.spark_insight = args.spark_insight;
521
+ const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
522
+ status: "read",
523
+ read_at: (/* @__PURE__ */ new Date()).toISOString()
524
+ });
525
+ if (mergeErr) return textResult(`Error: ${mergeErr}`);
526
+ return textResult(JSON.stringify({
527
+ success: true,
528
+ id: args.id,
529
+ title: data.title,
530
+ action: "flame \u2192 spark",
531
+ sparkExpiresAt
532
+ }, null, 2));
699
533
  }
700
534
  async function handleMoveFlameToAsh(args) {
701
- const { data, error } = await verifyBookmark(args.id, 'active');
702
- if (error)
703
- return textResult(`Error: ${error}`);
704
- const { error: updateErr } = await supabase
705
- .from('bookmarks')
706
- .update({ status: 'ash' })
707
- .eq('id', args.id);
708
- if (updateErr)
709
- return textResult(`Error: ${updateErr.message}`);
710
- return textResult(JSON.stringify({
711
- success: true,
712
- id: args.id,
713
- title: data.title,
714
- action: 'flame → ash',
715
- reason: args.reason || null,
716
- }, null, 2));
535
+ const { data, error } = await verifyBookmark(args.id, "active");
536
+ if (error) return textResult(`Error: ${error}`);
537
+ const { error: updateErr } = await supabase.from("bookmarks").update({ status: "ash" }).eq("id", args.id);
538
+ if (updateErr) return textResult(`Error: ${updateErr.message}`);
539
+ return textResult(JSON.stringify({
540
+ success: true,
541
+ id: args.id,
542
+ title: data.title,
543
+ action: "flame \u2192 ash",
544
+ reason: args.reason || null
545
+ }, null, 2));
717
546
  }
718
547
  async function handleMoveSparkToVault(args) {
719
- const { data, error } = await verifyBookmark(args.id, 'read');
720
- if (error)
721
- return textResult(`Error: ${error}`);
722
- const metaFields = { vaulted_at: new Date().toISOString() };
723
- if (args.vault_category)
724
- metaFields.vault_category = args.vault_category;
725
- const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
726
- status: 'absorbed',
727
- });
728
- if (mergeErr)
729
- return textResult(`Error: ${mergeErr}`);
730
- return textResult(JSON.stringify({
731
- success: true,
732
- id: args.id,
733
- title: data.title,
734
- action: 'spark → vault',
735
- vaultCategory: args.vault_category || null,
736
- }, null, 2));
548
+ const { data, error } = await verifyBookmark(args.id, "read");
549
+ if (error) return textResult(`Error: ${error}`);
550
+ const metaFields = { vaulted_at: (/* @__PURE__ */ new Date()).toISOString() };
551
+ if (args.vault_category) metaFields.vault_category = args.vault_category;
552
+ const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
553
+ status: "absorbed"
554
+ });
555
+ if (mergeErr) return textResult(`Error: ${mergeErr}`);
556
+ return textResult(JSON.stringify({
557
+ success: true,
558
+ id: args.id,
559
+ title: data.title,
560
+ action: "spark \u2192 vault",
561
+ vaultCategory: args.vault_category || null
562
+ }, null, 2));
737
563
  }
738
564
  async function handleMoveSparkToAsh(args) {
739
- const { data, error } = await verifyBookmark(args.id, 'read');
740
- if (error)
741
- return textResult(`Error: ${error}`);
742
- const { error: updateErr } = await supabase
743
- .from('bookmarks')
744
- .update({ status: 'ash' })
745
- .eq('id', args.id);
746
- if (updateErr)
747
- return textResult(`Error: ${updateErr.message}`);
748
- return textResult(JSON.stringify({
749
- success: true,
750
- id: args.id,
751
- title: data.title,
752
- action: 'spark → ash',
753
- }, null, 2));
565
+ const { data, error } = await verifyBookmark(args.id, "read");
566
+ if (error) return textResult(`Error: ${error}`);
567
+ const { error: updateErr } = await supabase.from("bookmarks").update({ status: "ash" }).eq("id", args.id);
568
+ if (updateErr) return textResult(`Error: ${updateErr.message}`);
569
+ return textResult(JSON.stringify({
570
+ success: true,
571
+ id: args.id,
572
+ title: data.title,
573
+ action: "spark \u2192 ash"
574
+ }, null, 2));
754
575
  }
755
576
  async function handleBatchTriageFlame(args) {
756
- const results = [];
757
- for (const decision of args.decisions) {
758
- if (decision.action === 'spark') {
759
- const res = await handleMoveFlameToSpark({ id: decision.id, spark_insight: decision.spark_insight });
760
- const parsed = JSON.parse(res.content[0].text);
761
- results.push({ id: decision.id, action: 'flame spark', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title });
762
- }
763
- else {
764
- const res = await handleMoveFlameToAsh({ id: decision.id });
765
- const parsed = JSON.parse(res.content[0].text);
766
- results.push({ id: decision.id, action: 'flame → ash', success: !!parsed.success, error: parsed.success ? undefined : res.content[0].text, title: parsed.title });
767
- }
577
+ const results = [];
578
+ for (const decision of args.decisions) {
579
+ if (decision.action === "spark") {
580
+ const res = await handleMoveFlameToSpark({ id: decision.id, spark_insight: decision.spark_insight });
581
+ const parsed = JSON.parse(res.content[0].text);
582
+ results.push({ id: decision.id, action: "flame \u2192 spark", success: !!parsed.success, error: parsed.success ? void 0 : res.content[0].text, title: parsed.title });
583
+ } else {
584
+ const res = await handleMoveFlameToAsh({ id: decision.id });
585
+ const parsed = JSON.parse(res.content[0].text);
586
+ results.push({ id: decision.id, action: "flame \u2192 ash", success: !!parsed.success, error: parsed.success ? void 0 : res.content[0].text, title: parsed.title });
768
587
  }
769
- const succeeded = results.filter(r => r.success).length;
770
- const failed = results.filter(r => !r.success).length;
771
- return textResult(JSON.stringify({
772
- summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
773
- results,
774
- }, null, 2));
588
+ }
589
+ const succeeded = results.filter((r) => r.success).length;
590
+ const failed = results.filter((r) => !r.success).length;
591
+ return textResult(JSON.stringify({
592
+ summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
593
+ results
594
+ }, null, 2));
775
595
  }
776
- // ---------------------------------------------------------------------------
777
- // Layer 3: AI analysis writeback handler (分析层)
778
- // ---------------------------------------------------------------------------
779
596
  async function handleWriteBookmarkAnalysis(args) {
780
- const { data, error } = await verifyBookmark(args.id);
781
- if (error)
782
- return textResult(`Error: ${error}`);
783
- const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis);
784
- if (mergeErr)
785
- return textResult(`Error: ${mergeErr}`);
786
- const fieldsWritten = Object.keys(args.analysis).filter(k => args.analysis[k] !== undefined);
787
- return textResult(JSON.stringify({
788
- success: true,
789
- id: args.id,
790
- title: data.title,
791
- fieldsWritten,
792
- }, null, 2));
597
+ const { data, error } = await verifyBookmark(args.id);
598
+ if (error) return textResult(`Error: ${error}`);
599
+ const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis);
600
+ if (mergeErr) return textResult(`Error: ${mergeErr}`);
601
+ const fieldsWritten = Object.keys(args.analysis).filter((k) => args.analysis[k] !== void 0);
602
+ return textResult(JSON.stringify({
603
+ success: true,
604
+ id: args.id,
605
+ title: data.title,
606
+ fieldsWritten
607
+ }, null, 2));
793
608
  }
794
- // ---------------------------------------------------------------------------
795
- // Layer 2: Collection handlers (组合层)
796
- // ---------------------------------------------------------------------------
797
609
  async function handleCreateCollection(args) {
798
- // Get user ID from an existing bookmark (RLS ensures we only see our own)
799
- const { data: sample } = await supabase
800
- .from('bookmarks')
801
- .select('user_id')
802
- .limit(1)
803
- .single();
804
- if (!sample)
805
- return textResult('Error: No bookmarks found — cannot determine user ID');
806
- const bookmarkIds = args.bookmark_ids || [];
807
- // Verify bookmark_ids exist if provided
808
- if (bookmarkIds.length > 0) {
809
- const { data: existing } = await supabase
810
- .from('bookmarks')
811
- .select('id')
812
- .in('id', bookmarkIds);
813
- const existingIds = new Set((existing || []).map((b) => b.id));
814
- const missing = bookmarkIds.filter(id => !existingIds.has(id));
815
- if (missing.length > 0) {
816
- return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`);
817
- }
610
+ const { data: sample } = await supabase.from("bookmarks").select("user_id").limit(1).single();
611
+ if (!sample) return textResult("Error: No bookmarks found \u2014 cannot determine user ID");
612
+ const bookmarkIds = args.bookmark_ids || [];
613
+ if (bookmarkIds.length > 0) {
614
+ const { data: existing } = await supabase.from("bookmarks").select("id").in("id", bookmarkIds);
615
+ const existingIds = new Set((existing || []).map((b) => b.id));
616
+ const missing = bookmarkIds.filter((id) => !existingIds.has(id));
617
+ if (missing.length > 0) {
618
+ return textResult(`Error: Bookmark IDs not found: ${missing.join(", ")}`);
818
619
  }
819
- const { data, error } = await supabase
820
- .from('collections')
821
- .insert({
822
- user_id: sample.user_id,
823
- name: args.name,
824
- bookmark_ids: bookmarkIds,
825
- is_overview_stale: true,
826
- })
827
- .select()
828
- .single();
829
- if (error)
830
- return textResult(`Error: ${error.message}`);
831
- return textResult(JSON.stringify({
832
- success: true,
833
- id: data.id,
834
- name: data.name,
835
- articleCount: bookmarkIds.length,
836
- }, null, 2));
620
+ }
621
+ const { data, error } = await supabase.from("collections").insert({
622
+ user_id: sample.user_id,
623
+ name: args.name,
624
+ bookmark_ids: bookmarkIds,
625
+ is_overview_stale: true
626
+ }).select().single();
627
+ if (error) return textResult(`Error: ${error.message}`);
628
+ return textResult(JSON.stringify({
629
+ success: true,
630
+ id: data.id,
631
+ name: data.name,
632
+ articleCount: bookmarkIds.length
633
+ }, null, 2));
837
634
  }
838
635
  async function handleAddToCollection(args) {
839
- const { data: collection, error } = await supabase
840
- .from('collections')
841
- .select('*')
842
- .eq('id', args.collection_id)
843
- .single();
844
- if (error) {
845
- return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`);
636
+ const { data: collection, error } = await supabase.from("collections").select("*").eq("id", args.collection_id).single();
637
+ if (error) {
638
+ return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
639
+ }
640
+ const { data: existing } = await supabase.from("bookmarks").select("id").in("id", args.bookmark_ids);
641
+ const existingIds = new Set((existing || []).map((b) => b.id));
642
+ const missing = args.bookmark_ids.filter((id) => !existingIds.has(id));
643
+ if (missing.length > 0) {
644
+ return textResult(`Error: Bookmark IDs not found: ${missing.join(", ")}`);
645
+ }
646
+ const currentIds = new Set(collection.bookmark_ids || []);
647
+ const newIds = args.bookmark_ids.filter((id) => !currentIds.has(id));
648
+ const merged = [...collection.bookmark_ids || [], ...newIds];
649
+ const { error: updateErr } = await supabase.from("collections").update({ bookmark_ids: merged, is_overview_stale: true }).eq("id", args.collection_id);
650
+ if (updateErr) return textResult(`Error: ${updateErr.message}`);
651
+ return textResult(JSON.stringify({
652
+ success: true,
653
+ collectionId: args.collection_id,
654
+ name: collection.name,
655
+ added: newIds.length,
656
+ alreadyPresent: args.bookmark_ids.length - newIds.length,
657
+ totalArticles: merged.length
658
+ }, null, 2));
659
+ }
660
+ async function handleRemoveFromCollection(args) {
661
+ const { data: collection, error } = await supabase.from("collections").select("*").eq("id", args.collection_id).single();
662
+ if (error) {
663
+ return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
664
+ }
665
+ const removeSet = new Set(args.bookmark_ids);
666
+ const filtered = (collection.bookmark_ids || []).filter((id) => !removeSet.has(id));
667
+ const removed = (collection.bookmark_ids || []).length - filtered.length;
668
+ const { error: updateErr } = await supabase.from("collections").update({ bookmark_ids: filtered, is_overview_stale: true }).eq("id", args.collection_id);
669
+ if (updateErr) return textResult(`Error: ${updateErr.message}`);
670
+ return textResult(JSON.stringify({
671
+ success: true,
672
+ collectionId: args.collection_id,
673
+ name: collection.name,
674
+ removed,
675
+ totalArticles: filtered.length
676
+ }, null, 2));
677
+ }
678
+ async function handleUpdateCollectionOverview(args) {
679
+ const { data: collection, error } = await supabase.from("collections").select("id, name").eq("id", args.collection_id).single();
680
+ if (error) {
681
+ return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
682
+ }
683
+ const { error: updateErr } = await supabase.from("collections").update({ ai_overview: args.overview, is_overview_stale: false }).eq("id", args.collection_id);
684
+ if (updateErr) return textResult(`Error: ${updateErr.message}`);
685
+ return textResult(JSON.stringify({
686
+ success: true,
687
+ collectionId: args.collection_id,
688
+ name: collection.name,
689
+ overviewTheme: args.overview.theme
690
+ }, null, 2));
691
+ }
692
+ function decodeXMLEntities(str) {
693
+ return str.replace(/&amp;/g, "&").replace(/&lt;/g, "<").replace(/&gt;/g, ">").replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&apos;/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
694
+ }
695
+ function extractXMLValue(block, tag) {
696
+ const cdataRe = new RegExp(`<${tag}[^>]*><!\\[CDATA\\[([\\s\\S]*?)\\]\\]><\\/${tag}>`, "i");
697
+ const cdata = block.match(cdataRe);
698
+ if (cdata) return cdata[1].trim();
699
+ if (tag === "link") {
700
+ const href = block.match(/<link[^>]+href=["']([^"']+)["'][^>]*(?:\/>|>)/i);
701
+ if (href) return href[1].trim();
702
+ }
703
+ const normalRe = new RegExp(`<${tag}[^>]*>([\\s\\S]*?)<\\/${tag}>`, "i");
704
+ const normal = block.match(normalRe);
705
+ if (normal) return decodeXMLEntities(normal[1].trim());
706
+ return null;
707
+ }
708
+ function parseRSSFeed(xml) {
709
+ const items = [];
710
+ const itemRe = /<(?:item|entry)(?: [^>]*)?>([\s\S]*?)<\/(?:item|entry)>/gi;
711
+ let m;
712
+ while ((m = itemRe.exec(xml)) !== null) {
713
+ const block = m[1];
714
+ const title = extractXMLValue(block, "title") || "Untitled";
715
+ const rawUrl = extractXMLValue(block, "link") || extractXMLValue(block, "id") || "";
716
+ if (!rawUrl.startsWith("http")) continue;
717
+ const url = rawUrl.replace(/^https?:\/\/nitter\.[^/]+/, "https://x.com");
718
+ const pubStr = extractXMLValue(block, "pubDate") || extractXMLValue(block, "published") || extractXMLValue(block, "updated") || "";
719
+ let publishedAt = (/* @__PURE__ */ new Date()).toISOString();
720
+ try {
721
+ if (pubStr) publishedAt = new Date(pubStr).toISOString();
722
+ } catch {
846
723
  }
847
- // Verify bookmark_ids exist
848
- const { data: existing } = await supabase
849
- .from('bookmarks')
850
- .select('id')
851
- .in('id', args.bookmark_ids);
852
- const existingIds = new Set((existing || []).map((b) => b.id));
853
- const missing = args.bookmark_ids.filter(id => !existingIds.has(id));
854
- if (missing.length > 0) {
855
- return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`);
724
+ const author = extractXMLValue(block, "author") || extractXMLValue(block, "dc:creator") || "";
725
+ items.push({ url, title, author, publishedAt });
726
+ }
727
+ return items;
728
+ }
729
+ async function fetchRSSFeed(feedUrl) {
730
+ const resp = await fetch(feedUrl, {
731
+ signal: AbortSignal.timeout(12e3),
732
+ headers: {
733
+ // Use browser UA — many RSS hosts (bearblog, Substack) block bot UAs
734
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
735
+ Accept: "application/rss+xml, application/atom+xml, text/xml, */*"
856
736
  }
857
- // Union with existing (deduplicate)
858
- const currentIds = new Set(collection.bookmark_ids || []);
859
- const newIds = args.bookmark_ids.filter(id => !currentIds.has(id));
860
- const merged = [...(collection.bookmark_ids || []), ...newIds];
861
- const { error: updateErr } = await supabase
862
- .from('collections')
863
- .update({ bookmark_ids: merged, is_overview_stale: true })
864
- .eq('id', args.collection_id);
865
- if (updateErr)
866
- return textResult(`Error: ${updateErr.message}`);
867
- return textResult(JSON.stringify({
868
- success: true,
869
- collectionId: args.collection_id,
870
- name: collection.name,
871
- added: newIds.length,
872
- alreadyPresent: args.bookmark_ids.length - newIds.length,
873
- totalArticles: merged.length,
874
- }, null, 2));
737
+ });
738
+ if (!resp.ok) throw new Error(`HTTP ${resp.status} from ${feedUrl}`);
739
+ return parseRSSFeed(await resp.text());
875
740
  }
876
- async function handleRemoveFromCollection(args) {
877
- const { data: collection, error } = await supabase
878
- .from('collections')
879
- .select('*')
880
- .eq('id', args.collection_id)
881
- .single();
882
- if (error) {
883
- return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`);
741
+ async function getSourceItems(source) {
742
+ const since = source.last_checked_at ? new Date(source.last_checked_at) : /* @__PURE__ */ new Date(0);
743
+ switch (source.source_type) {
744
+ case "x_user": {
745
+ throw new Error(`X/Twitter timeline scraping is unavailable \u2014 public nitter/RSS proxies are offline. To add @${source.handle} tweets, use fetch_content with individual tweet URLs.`);
884
746
  }
885
- const removeSet = new Set(args.bookmark_ids);
886
- const filtered = (collection.bookmark_ids || []).filter((id) => !removeSet.has(id));
887
- const removed = (collection.bookmark_ids || []).length - filtered.length;
888
- const { error: updateErr } = await supabase
889
- .from('collections')
890
- .update({ bookmark_ids: filtered, is_overview_stale: true })
891
- .eq('id', args.collection_id);
892
- if (updateErr)
893
- return textResult(`Error: ${updateErr.message}`);
894
- return textResult(JSON.stringify({
895
- success: true,
896
- collectionId: args.collection_id,
897
- name: collection.name,
898
- removed,
899
- totalArticles: filtered.length,
900
- }, null, 2));
747
+ case "rss": {
748
+ const items = await fetchRSSFeed(source.handle);
749
+ return items.filter((i) => new Date(i.publishedAt) > since);
750
+ }
751
+ case "youtube": {
752
+ const channelId = source.handle.match(/UC[A-Za-z0-9_-]{21}[AQgw]/)?.[0] || source.handle;
753
+ const rssUrl = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
754
+ const items = await fetchRSSFeed(rssUrl);
755
+ return items.filter((i) => new Date(i.publishedAt) > since);
756
+ }
757
+ default:
758
+ return [];
759
+ }
901
760
  }
902
- async function handleUpdateCollectionOverview(args) {
903
- const { data: collection, error } = await supabase
904
- .from('collections')
905
- .select('id, name')
906
- .eq('id', args.collection_id)
907
- .single();
908
- if (error) {
909
- return textResult(error.code === 'PGRST116' ? 'Error: Collection not found' : `Error: ${error.message}`);
761
+ async function handleAddWatchedSource(args) {
762
+ const { data: { user } } = await supabase.auth.getUser();
763
+ if (!user) return textResult("Error: Not authenticated");
764
+ const { data: existing } = await supabase.from("watched_sources").select("id, display_name").eq("user_id", user.id).eq("handle", args.handle).eq("active", true).maybeSingle();
765
+ if (existing) return textResult(`Already watching "${existing.display_name}"`);
766
+ const { data, error } = await supabase.from("watched_sources").insert({
767
+ user_id: user.id,
768
+ source_type: args.source_type,
769
+ handle: args.handle,
770
+ display_name: args.name || args.handle
771
+ // null = never checked; first scrape will fetch whatever the feed currently contains
772
+ }).select().single();
773
+ if (error) return textResult(`Error: ${error.message}`);
774
+ return textResult(JSON.stringify({
775
+ success: true,
776
+ id: data.id,
777
+ message: `Now watching "${data.display_name}" (${data.source_type}). Call scrape_watched_sources to fetch new items.`
778
+ }, null, 2));
779
+ }
780
+ async function handleListWatchedSources() {
781
+ const { data, error } = await supabase.from("watched_sources").select("id, source_type, handle, display_name, last_checked_at, created_at").eq("active", true).order("created_at", { ascending: false });
782
+ if (error) return textResult(`Error: ${error.message}`);
783
+ if (!data || data.length === 0) return textResult("No watched sources yet. Use add_watched_source to add one.");
784
+ return textResult(JSON.stringify(data, null, 2));
785
+ }
786
+ async function handleRemoveWatchedSource(args) {
787
+ const { error } = await supabase.from("watched_sources").update({ active: false }).eq("id", args.id);
788
+ if (error) return textResult(`Error: ${error.message}`);
789
+ return textResult("Watched source removed.");
790
+ }
791
+ async function handleScrapeWatchedSources(args) {
792
+ const { data: { user } } = await supabase.auth.getUser();
793
+ if (!user) return textResult("Error: Not authenticated");
794
+ let query = supabase.from("watched_sources").select("*").eq("active", true).eq("user_id", user.id);
795
+ if (args.source_id) query = query.eq("id", args.source_id);
796
+ const { data: sources, error } = await query;
797
+ if (error) return textResult(`Error: ${error.message}`);
798
+ if (!sources || sources.length === 0) {
799
+ return textResult("No active watched sources. Use add_watched_source to add one.");
800
+ }
801
+ const countdownExpiresAt = new Date(Date.now() + 24 * 60 * 60 * 1e3).toISOString();
802
+ const results = [];
803
+ for (const source of sources) {
804
+ let added = 0;
805
+ let skipped = 0;
806
+ try {
807
+ const items = await getSourceItems(source);
808
+ for (const item of items) {
809
+ const { data: dupe } = await supabase.from("bookmarks").select("id").eq("url", item.url).maybeSingle();
810
+ if (dupe) {
811
+ skipped++;
812
+ continue;
813
+ }
814
+ const { error: insertErr } = await supabase.from("bookmarks").insert({
815
+ user_id: user.id,
816
+ url: item.url,
817
+ title: item.title,
818
+ platform: detectPlatform(item.url),
819
+ status: "active",
820
+ countdown_expires_at: countdownExpiresAt,
821
+ content_metadata: {
822
+ author: item.author || source.display_name,
823
+ watched_source_id: source.id,
824
+ watched_source_name: source.display_name
825
+ }
826
+ });
827
+ if (!insertErr) added++;
828
+ }
829
+ await supabase.from("watched_sources").update({ last_checked_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", source.id);
830
+ results.push({ source: source.display_name, type: source.source_type, added, skipped });
831
+ } catch (err) {
832
+ results.push({ source: source.display_name, type: source.source_type, error: err.message });
910
833
  }
911
- const { error: updateErr } = await supabase
912
- .from('collections')
913
- .update({ ai_overview: args.overview, is_overview_stale: false })
914
- .eq('id', args.collection_id);
915
- if (updateErr)
916
- return textResult(`Error: ${updateErr.message}`);
917
- return textResult(JSON.stringify({
918
- success: true,
919
- collectionId: args.collection_id,
920
- name: collection.name,
921
- overviewTheme: args.overview.theme,
922
- }, null, 2));
834
+ }
835
+ const totalAdded = results.reduce((s, r) => s + (r.added || 0), 0);
836
+ return textResult(JSON.stringify({ totalAdded, sources: results }, null, 2));
923
837
  }
924
- // ---------------------------------------------------------------------------
925
- // Register tools
926
- // ---------------------------------------------------------------------------
927
- // @ts-expect-error MCP SDK 1.27 TS2589: type instantiation too deep with multiple .tool() calls
928
- server.tool('search_vault', 'Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)', { query: zod_1.z.string().describe('Search keyword'), limit: zod_1.z.number().optional().describe('Max results (default 10)') }, rateLimited(handleSearchVault));
929
- server.tool('list_vault', 'List bookmarks in your Vault, optionally filtered by category', { limit: zod_1.z.number().optional().describe('Max results (default 20)'), category: zod_1.z.string().optional().describe('Filter by vault category') }, rateLimited(handleListVault));
930
- server.tool('list_sparks', 'List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.', { limit: zod_1.z.number().optional().describe('Max results (default 20)') }, rateLimited(handleListSparks));
931
- server.tool('search_sparks', 'Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)', { query: zod_1.z.string().describe('Search keyword'), limit: zod_1.z.number().optional().describe('Max results (default 10)') }, rateLimited(handleSearchSparks));
932
- server.tool('get_bookmark', 'Get full details of a single bookmark including AI analysis and extracted content', { id: zod_1.z.string().describe('Bookmark UUID') }, rateLimited(handleGetBookmark));
933
- server.tool('get_article_content', 'Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)', { id: zod_1.z.string().describe('Bookmark UUID') }, rateLimited(handleGetArticleContent));
934
- server.tool('fetch_content', 'Fetch article/tweet content from a URL. Works with X.com (bypasses GFW via proxy), Reddit, YouTube, Bilibili, WeChat, and any web page. First checks Supabase cache, then fetches live.', { url: zod_1.z.string().describe('The URL to fetch content from') }, rateLimited(handleFetchContent));
935
- server.tool('list_categories', 'List all Vault categories with article counts', {}, rateLimited(handleListCategories));
936
- server.tool('list_flame', 'List bookmarks in your Flame inbox (24h countdown). Shows AI triage info (strategy, relevance, novelty, hook) and time remaining. Use this to see what needs attention before it burns to Ash.', { limit: zod_1.z.number().optional().describe('Max results (default 20)') }, rateLimited(handleListFlame));
937
- server.tool('get_flame_detail', 'Get full details of a Flame bookmark including extracted article content, AI analysis, and reading guidance. Use this to deep-read a bookmark before deciding its fate.', { id: zod_1.z.string().describe('Bookmark UUID') }, rateLimited(handleGetFlameDetail));
938
- server.tool('get_collections', 'List all your Collections with article counts and AI overview themes', {}, rateLimited(handleGetCollections));
939
- server.tool('get_collection_overview', 'Get a Collection by name with its AI overview and linked bookmarks metadata', { name: zod_1.z.string().describe('Collection name') }, rateLimited(handleGetCollectionOverview));
940
- // ---------------------------------------------------------------------------
941
- // Layer 1: Status flow tools (决策层)
942
- // ---------------------------------------------------------------------------
943
- server.tool('move_flame_to_spark', 'Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.', {
944
- id: zod_1.z.string().describe('Bookmark UUID'),
945
- spark_insight: zod_1.z.string().max(500).optional().describe('One-line insight about why this is worth reading'),
946
- }, rateLimited(handleMoveFlameToSpark));
947
- server.tool('move_flame_to_ash', 'Burn a Flame bookmark to Ash (not worth keeping).', {
948
- id: zod_1.z.string().describe('Bookmark UUID'),
949
- reason: zod_1.z.string().max(200).optional().describe('Why this was burned'),
950
- }, rateLimited(handleMoveFlameToAsh));
951
- server.tool('move_spark_to_vault', 'Promote a Spark bookmark to permanent Vault storage.', {
952
- id: zod_1.z.string().describe('Bookmark UUID'),
953
- vault_category: zod_1.z.string().max(100).optional().describe('Category to file under in the Vault'),
954
- }, rateLimited(handleMoveSparkToVault));
955
- server.tool('move_spark_to_ash', 'Burn a Spark bookmark to Ash (not valuable enough to vault).', {
956
- id: zod_1.z.string().describe('Bookmark UUID'),
957
- }, rateLimited(handleMoveSparkToAsh));
958
- // @ts-expect-error — MCP SDK TS2589
959
- server.tool('batch_triage_flame', 'Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.', {
960
- decisions: zod_1.z.array(zod_1.z.object({
961
- id: zod_1.z.string().describe('Bookmark UUID'),
962
- action: zod_1.z.enum(['spark', 'ash']).describe('spark = keep, ash = burn'),
963
- spark_insight: zod_1.z.string().max(500).optional().describe('Insight (only for spark action)'),
964
- })).min(1).max(20).describe('Array of triage decisions'),
965
- }, rateLimited(handleBatchTriageFlame));
966
- // ---------------------------------------------------------------------------
967
- // Layer 3: AI analysis writeback tools (分析层)
968
- // ---------------------------------------------------------------------------
969
- // @ts-expect-error — MCP SDK TS2589
970
- server.tool('write_bookmark_analysis', 'Write AI analysis results into a bookmark. Agent analyzes content with its own LLM, then writes structured results back to Burn. Only provided fields are merged — existing data is preserved.', {
971
- id: zod_1.z.string().describe('Bookmark UUID'),
972
- analysis: zod_1.z.object({
973
- ai_summary: zod_1.z.string().max(200).optional().describe('One-line summary'),
974
- ai_strategy: zod_1.z.enum(['deep_read', 'skim', 'skip_read', 'reference']).optional().describe('Reading strategy'),
975
- ai_strategy_reason: zod_1.z.string().max(200).optional().describe('Why this strategy'),
976
- ai_minutes: zod_1.z.number().int().min(1).max(999).optional().describe('Estimated reading minutes'),
977
- ai_takeaway: zod_1.z.array(zod_1.z.string().max(200)).max(5).optional().describe('Key takeaways'),
978
- ai_relevance: zod_1.z.number().int().min(0).max(100).optional().describe('Relevance score 0-100'),
979
- ai_novelty: zod_1.z.number().int().min(0).max(100).optional().describe('Novelty score 0-100'),
980
- tags: zod_1.z.array(zod_1.z.string().max(50)).max(10).optional().describe('Topic tags'),
981
- }).describe('Analysis fields to write'),
982
- }, rateLimited(handleWriteBookmarkAnalysis));
983
- // ---------------------------------------------------------------------------
984
- // Layer 2: Collection tools (组合层)
985
- // ---------------------------------------------------------------------------
986
- // @ts-expect-error MCP SDK TS2589
987
- server.tool('create_collection', 'Create a new Collection to group related bookmarks together.', {
988
- name: zod_1.z.string().min(1).max(200).describe('Collection name'),
989
- bookmark_ids: zod_1.z.array(zod_1.z.string()).optional().describe('Initial bookmark UUIDs to include'),
990
- }, rateLimited(handleCreateCollection));
991
- // @ts-expect-error — MCP SDK TS2589
992
- server.tool('add_to_collection', 'Add bookmarks to an existing Collection. Duplicates are silently ignored.', {
993
- collection_id: zod_1.z.string().describe('Collection UUID'),
994
- bookmark_ids: zod_1.z.array(zod_1.z.string()).min(1).max(50).describe('Bookmark UUIDs to add'),
995
- }, rateLimited(handleAddToCollection));
996
- server.tool('remove_from_collection', 'Remove bookmarks from a Collection.', {
997
- collection_id: zod_1.z.string().describe('Collection UUID'),
998
- bookmark_ids: zod_1.z.array(zod_1.z.string()).min(1).describe('Bookmark UUIDs to remove'),
999
- }, rateLimited(handleRemoveFromCollection));
1000
- // @ts-expect-error — MCP SDK TS2589
1001
- server.tool('update_collection_overview', 'Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).', {
1002
- collection_id: zod_1.z.string().describe('Collection UUID'),
1003
- overview: zod_1.z.object({
1004
- theme: zod_1.z.string().describe('Overarching theme'),
1005
- synthesis: zod_1.z.string().optional().describe('Cross-bookmark synthesis'),
1006
- patterns: zod_1.z.array(zod_1.z.string()).optional().describe('Patterns identified'),
1007
- gaps: zod_1.z.array(zod_1.z.string()).optional().describe('Knowledge gaps identified'),
1008
- }).describe('AI-generated overview'),
1009
- }, rateLimited(handleUpdateCollectionOverview));
1010
- // ---------------------------------------------------------------------------
1011
- // Resource: burn://vault/bookmarks
1012
- // ---------------------------------------------------------------------------
1013
- server.resource('vault-bookmarks', 'burn://vault/bookmarks', async (uri) => {
1014
- const { data, error } = await supabase
1015
- .from('bookmarks')
1016
- .select('*')
1017
- .eq('status', 'absorbed')
1018
- .order('created_at', { ascending: false });
838
+ server.tool(
839
+ "search_vault",
840
+ "Search your Burn Vault for bookmarks by keyword (searches title, tags, AI takeaway)",
841
+ { query: import_zod.z.string().describe("Search keyword"), limit: import_zod.z.number().optional().describe("Max results (default 10)") },
842
+ rateLimited(handleSearchVault)
843
+ );
844
+ server.tool(
845
+ "list_vault",
846
+ "List bookmarks in your Vault, optionally filtered by category",
847
+ { limit: import_zod.z.number().optional().describe("Max results (default 20)"), category: import_zod.z.string().optional().describe("Filter by vault category") },
848
+ rateLimited(handleListVault)
849
+ );
850
+ server.tool(
851
+ "list_sparks",
852
+ "List your Sparks (bookmarks you have read, with 30-day lifespan). Includes spark insight and expiry date.",
853
+ { limit: import_zod.z.number().optional().describe("Max results (default 20)") },
854
+ rateLimited(handleListSparks)
855
+ );
856
+ server.tool(
857
+ "search_sparks",
858
+ "Search your Sparks by keyword (searches title, tags, AI takeaway, spark insight)",
859
+ { query: import_zod.z.string().describe("Search keyword"), limit: import_zod.z.number().optional().describe("Max results (default 10)") },
860
+ rateLimited(handleSearchSparks)
861
+ );
862
+ server.tool(
863
+ "get_bookmark",
864
+ "Get full details of a single bookmark including AI analysis and extracted content",
865
+ { id: import_zod.z.string().describe("Bookmark UUID") },
866
+ rateLimited(handleGetBookmark)
867
+ );
868
+ server.tool(
869
+ "get_article_content",
870
+ "Get full article content and AI analysis for a bookmark by ID (same as get_bookmark)",
871
+ { id: import_zod.z.string().describe("Bookmark UUID") },
872
+ rateLimited(handleGetArticleContent)
873
+ );
874
+ server.tool(
875
+ "fetch_content",
876
+ "Fetch article/tweet content from a URL. Works with X.com (bypasses GFW via proxy), Reddit, YouTube, Bilibili, WeChat, and any web page. First checks Supabase cache, then fetches live.",
877
+ { url: import_zod.z.string().describe("The URL to fetch content from") },
878
+ rateLimited(handleFetchContent)
879
+ );
880
+ server.tool(
881
+ "list_categories",
882
+ "List all Vault categories with article counts",
883
+ {},
884
+ rateLimited(handleListCategories)
885
+ );
886
+ server.tool(
887
+ "list_flame",
888
+ "List bookmarks in your Flame inbox (24h countdown). Shows AI triage info (strategy, relevance, novelty, hook) and time remaining. Use this to see what needs attention before it burns to Ash.",
889
+ { limit: import_zod.z.number().optional().describe("Max results (default 20)") },
890
+ rateLimited(handleListFlame)
891
+ );
892
+ server.tool(
893
+ "get_flame_detail",
894
+ "Get full details of a Flame bookmark including extracted article content, AI analysis, and reading guidance. Use this to deep-read a bookmark before deciding its fate.",
895
+ { id: import_zod.z.string().describe("Bookmark UUID") },
896
+ rateLimited(handleGetFlameDetail)
897
+ );
898
+ server.tool(
899
+ "get_collections",
900
+ "List all your Collections with article counts and AI overview themes",
901
+ {},
902
+ rateLimited(handleGetCollections)
903
+ );
904
+ server.tool(
905
+ "get_collection_overview",
906
+ "Get a Collection by name with its AI overview and linked bookmarks metadata",
907
+ { name: import_zod.z.string().describe("Collection name") },
908
+ rateLimited(handleGetCollectionOverview)
909
+ );
910
+ server.tool(
911
+ "move_flame_to_spark",
912
+ "Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.",
913
+ {
914
+ id: import_zod.z.string().describe("Bookmark UUID"),
915
+ spark_insight: import_zod.z.string().max(500).optional().describe("One-line insight about why this is worth reading")
916
+ },
917
+ rateLimited(handleMoveFlameToSpark)
918
+ );
919
+ server.tool(
920
+ "move_flame_to_ash",
921
+ "Burn a Flame bookmark to Ash (not worth keeping).",
922
+ {
923
+ id: import_zod.z.string().describe("Bookmark UUID"),
924
+ reason: import_zod.z.string().max(200).optional().describe("Why this was burned")
925
+ },
926
+ rateLimited(handleMoveFlameToAsh)
927
+ );
928
+ server.tool(
929
+ "move_spark_to_vault",
930
+ "Promote a Spark bookmark to permanent Vault storage.",
931
+ {
932
+ id: import_zod.z.string().describe("Bookmark UUID"),
933
+ vault_category: import_zod.z.string().max(100).optional().describe("Category to file under in the Vault")
934
+ },
935
+ rateLimited(handleMoveSparkToVault)
936
+ );
937
+ server.tool(
938
+ "move_spark_to_ash",
939
+ "Burn a Spark bookmark to Ash (not valuable enough to vault).",
940
+ {
941
+ id: import_zod.z.string().describe("Bookmark UUID")
942
+ },
943
+ rateLimited(handleMoveSparkToAsh)
944
+ );
945
+ server.tool(
946
+ "batch_triage_flame",
947
+ "Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.",
948
+ {
949
+ decisions: import_zod.z.array(import_zod.z.object({
950
+ id: import_zod.z.string().describe("Bookmark UUID"),
951
+ action: import_zod.z.enum(["spark", "ash"]).describe("spark = keep, ash = burn"),
952
+ spark_insight: import_zod.z.string().max(500).optional().describe("Insight (only for spark action)")
953
+ })).min(1).max(20).describe("Array of triage decisions")
954
+ },
955
+ rateLimited(handleBatchTriageFlame)
956
+ );
957
+ server.tool(
958
+ "write_bookmark_analysis",
959
+ "Write AI analysis results into a bookmark. Agent analyzes content with its own LLM, then writes structured results back to Burn. Only provided fields are merged \u2014 existing data is preserved.",
960
+ {
961
+ id: import_zod.z.string().describe("Bookmark UUID"),
962
+ analysis: import_zod.z.object({
963
+ ai_summary: import_zod.z.string().max(200).optional().describe("One-line summary"),
964
+ ai_strategy: import_zod.z.enum(["deep_read", "skim", "skip_read", "reference"]).optional().describe("Reading strategy"),
965
+ ai_strategy_reason: import_zod.z.string().max(200).optional().describe("Why this strategy"),
966
+ ai_minutes: import_zod.z.number().int().min(1).max(999).optional().describe("Estimated reading minutes"),
967
+ ai_takeaway: import_zod.z.array(import_zod.z.string().max(200)).max(5).optional().describe("Key takeaways"),
968
+ ai_relevance: import_zod.z.number().int().min(0).max(100).optional().describe("Relevance score 0-100"),
969
+ ai_novelty: import_zod.z.number().int().min(0).max(100).optional().describe("Novelty score 0-100"),
970
+ tags: import_zod.z.array(import_zod.z.string().max(50)).max(10).optional().describe("Topic tags")
971
+ }).describe("Analysis fields to write")
972
+ },
973
+ rateLimited(handleWriteBookmarkAnalysis)
974
+ );
975
+ server.tool(
976
+ "create_collection",
977
+ "Create a new Collection to group related bookmarks together.",
978
+ {
979
+ name: import_zod.z.string().min(1).max(200).describe("Collection name"),
980
+ bookmark_ids: import_zod.z.array(import_zod.z.string()).optional().describe("Initial bookmark UUIDs to include")
981
+ },
982
+ rateLimited(handleCreateCollection)
983
+ );
984
+ server.tool(
985
+ "add_to_collection",
986
+ "Add bookmarks to an existing Collection. Duplicates are silently ignored.",
987
+ {
988
+ collection_id: import_zod.z.string().describe("Collection UUID"),
989
+ bookmark_ids: import_zod.z.array(import_zod.z.string()).min(1).max(50).describe("Bookmark UUIDs to add")
990
+ },
991
+ rateLimited(handleAddToCollection)
992
+ );
993
+ server.tool(
994
+ "remove_from_collection",
995
+ "Remove bookmarks from a Collection.",
996
+ {
997
+ collection_id: import_zod.z.string().describe("Collection UUID"),
998
+ bookmark_ids: import_zod.z.array(import_zod.z.string()).min(1).describe("Bookmark UUIDs to remove")
999
+ },
1000
+ rateLimited(handleRemoveFromCollection)
1001
+ );
1002
+ server.tool(
1003
+ "update_collection_overview",
1004
+ "Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).",
1005
+ {
1006
+ collection_id: import_zod.z.string().describe("Collection UUID"),
1007
+ overview: import_zod.z.object({
1008
+ theme: import_zod.z.string().describe("Overarching theme"),
1009
+ synthesis: import_zod.z.string().optional().describe("Cross-bookmark synthesis"),
1010
+ patterns: import_zod.z.array(import_zod.z.string()).optional().describe("Patterns identified"),
1011
+ gaps: import_zod.z.array(import_zod.z.string()).optional().describe("Knowledge gaps identified")
1012
+ }).describe("AI-generated overview")
1013
+ },
1014
+ rateLimited(handleUpdateCollectionOverview)
1015
+ );
1016
+ server.tool(
1017
+ "add_watched_source",
1018
+ "Watch an X user, RSS feed, or YouTube channel \u2014 new posts auto-appear in Burn Flame on each scrape.",
1019
+ {
1020
+ source_type: import_zod.z.enum(["x_user", "rss", "youtube"]).describe("x_user = Twitter/X handle | rss = any RSS/Atom feed URL | youtube = YouTube channel ID"),
1021
+ handle: import_zod.z.string().describe('x_user: username without @ (e.g. "karpathy") | rss: full feed URL | youtube: channel ID starting with UC'),
1022
+ name: import_zod.z.string().optional().describe("Human-friendly display name (defaults to handle)")
1023
+ },
1024
+ rateLimited(handleAddWatchedSource)
1025
+ );
1026
+ server.tool(
1027
+ "list_watched_sources",
1028
+ "List all active watched sources (X users, RSS feeds, YouTube channels).",
1029
+ {},
1030
+ rateLimited(handleListWatchedSources)
1031
+ );
1032
+ server.tool(
1033
+ "remove_watched_source",
1034
+ "Stop watching a source. Use list_watched_sources to find the source ID.",
1035
+ { id: import_zod.z.string().describe("Watched source UUID from list_watched_sources") },
1036
+ rateLimited(handleRemoveWatchedSource)
1037
+ );
1038
+ server.tool(
1039
+ "scrape_watched_sources",
1040
+ "Fetch new content from all watched sources (or one specific source) and add new items to Burn Flame. Call this on a schedule or on demand.",
1041
+ { source_id: import_zod.z.string().optional().describe("Scrape only this source ID \u2014 omit to scrape all active sources") },
1042
+ rateLimited(handleScrapeWatchedSources)
1043
+ );
1044
+ server.resource(
1045
+ "vault-bookmarks",
1046
+ "burn://vault/bookmarks",
1047
+ async (uri) => {
1048
+ const { data, error } = await supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false });
1019
1049
  if (error) {
1020
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] };
1050
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: error.message }) }] };
1021
1051
  }
1022
1052
  return {
1023
- contents: [{
1024
- uri: uri.href,
1025
- mimeType: 'application/json',
1026
- text: JSON.stringify((data || []).map(metaSummary), null, 2),
1027
- }],
1053
+ contents: [{
1054
+ uri: uri.href,
1055
+ mimeType: "application/json",
1056
+ text: JSON.stringify((data || []).map(metaSummary), null, 2)
1057
+ }]
1028
1058
  };
1029
- });
1030
- // ---------------------------------------------------------------------------
1031
- // Resource: burn://vault/categories
1032
- // ---------------------------------------------------------------------------
1033
- server.resource('vault-categories', 'burn://vault/categories', async (uri) => {
1034
- const { data, error } = await supabase
1035
- .from('bookmarks')
1036
- .select('content_metadata')
1037
- .eq('status', 'absorbed');
1059
+ }
1060
+ );
1061
+ server.resource(
1062
+ "vault-categories",
1063
+ "burn://vault/categories",
1064
+ async (uri) => {
1065
+ const { data, error } = await supabase.from("bookmarks").select("content_metadata").eq("status", "absorbed");
1038
1066
  if (error) {
1039
- return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] };
1067
+ return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify({ error: error.message }) }] };
1040
1068
  }
1041
1069
  const counts = {};
1042
1070
  for (const row of data || []) {
1043
- const cat = row.content_metadata?.vault_category || 'Uncategorized';
1044
- counts[cat] = (counts[cat] || 0) + 1;
1071
+ const cat = row.content_metadata?.vault_category || "Uncategorized";
1072
+ counts[cat] = (counts[cat] || 0) + 1;
1045
1073
  }
1046
1074
  return {
1047
- contents: [{
1048
- uri: uri.href,
1049
- mimeType: 'application/json',
1050
- text: JSON.stringify(Object.entries(counts).map(([category, count]) => ({ category, count })), null, 2),
1051
- }],
1075
+ contents: [{
1076
+ uri: uri.href,
1077
+ mimeType: "application/json",
1078
+ text: JSON.stringify(
1079
+ Object.entries(counts).map(([category, count]) => ({ category, count })),
1080
+ null,
1081
+ 2
1082
+ )
1083
+ }]
1052
1084
  };
1053
- });
1054
- // ---------------------------------------------------------------------------
1055
- // Start
1056
- // ---------------------------------------------------------------------------
1085
+ }
1086
+ );
1057
1087
  async function main() {
1058
- await initAuth();
1059
- const transport = new stdio_js_1.StdioServerTransport();
1060
- await server.connect(transport);
1061
- console.error('Burn MCP Server running on stdio');
1088
+ await initAuth();
1089
+ const transport = new import_stdio.StdioServerTransport();
1090
+ await server.connect(transport);
1091
+ console.error("Burn MCP Server running on stdio");
1062
1092
  }
1063
1093
  main().catch((err) => {
1064
- console.error('Fatal error:', err);
1065
- process.exit(1);
1094
+ console.error("Fatal error:", err);
1095
+ process.exit(1);
1066
1096
  });