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/.cursor-plugin/plugin.json +19 -0
- package/.mcp.json +11 -0
- package/README.md +30 -1
- package/api/mcp.js +36075 -0
- package/dist/http.mjs +1131 -0
- package/dist/index.js +1021 -1009
- package/package.json +1 -1
- package/server.json +16 -2
- package/src/vercel-node-handler.ts +76 -0
- package/vercel.json +11 -0
package/dist/index.js
CHANGED
|
@@ -1,1096 +1,1108 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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)(
|
|
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(
|
|
57
|
+
function saveCachedSession(session) {
|
|
39
58
|
try {
|
|
40
|
-
(0, import_node_fs.mkdirSync)(
|
|
41
|
-
(0, import_node_fs.writeFileSync)(
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
supabase.
|
|
84
|
-
|
|
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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
104
|
-
const
|
|
105
|
-
return
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
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
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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.
|
|
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
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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: "
|
|
390
|
+
source: "error",
|
|
401
391
|
url,
|
|
402
392
|
platform,
|
|
403
|
-
|
|
404
|
-
|
|
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: "
|
|
398
|
+
source: "live",
|
|
416
399
|
url,
|
|
417
400
|
platform,
|
|
418
|
-
|
|
419
|
-
|
|
401
|
+
title: result.title,
|
|
402
|
+
author: result.author,
|
|
403
|
+
content: result.content
|
|
420
404
|
}, null, 2));
|
|
421
405
|
}
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
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
|
-
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
results
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
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
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
return textResult(`Error:
|
|
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
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
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
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
}
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
const
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
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
|
|
725
|
-
|
|
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
|
-
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
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
|
-
|
|
748
|
-
|
|
749
|
-
|
|
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
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
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
|
-
|
|
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(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/'/g, "'").replace(/&#(\d+);/g, (_, n) => String.fromCharCode(parseInt(n, 10)));
|
|
800
669
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
if (
|
|
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
|
-
|
|
830
|
-
|
|
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
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
);
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
)
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
);
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
);
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
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
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
const { data, error } = await supabase.from("
|
|
1066
|
-
|
|
1067
|
-
|
|
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
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
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
|
-
|
|
1075
|
-
|
|
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
|
-
|
|
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
|
});
|