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