burn-mcp-server 2.0.6 → 2.1.0

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