burn-mcp-server 2.0.5 → 2.0.7
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 +68 -4
- package/api/health.mjs +7 -0
- package/api/mcp.js +36075 -0
- package/dist/http.mjs +1131 -0
- package/dist/index.js +965 -923
- package/glama.json +3 -0
- package/package.json +12 -6
- package/public/index.html +47 -0
- package/server.json +21 -6
- package/src/http-dev.ts +56 -0
- package/src/http.ts +62 -0
- package/src/index.ts +28 -1344
- package/src/lib/auth-stdio.ts +26 -0
- package/src/lib/auth.ts +71 -0
- package/src/setup.ts +1529 -0
- package/src/vercel-handler.ts +20 -0
- package/src/vercel-node-handler.ts +76 -0
- package/tsconfig.json +10 -6
- package/vercel.json +9 -0
- package/.mcpregistry_github_token +0 -1
- package/.mcpregistry_registry_token +0 -1
- package/smithery.yaml +0 -17
package/dist/index.js
CHANGED
|
@@ -1,1066 +1,1108 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (!MCP_TOKEN && !LEGACY_JWT) {
|
|
18
|
-
console.error('Error: BURN_MCP_TOKEN environment variable is required.');
|
|
19
|
-
console.error('Get your token from: Burn App → Settings → MCP Server → Generate Token');
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|
|
22
|
-
// ---------------------------------------------------------------------------
|
|
23
|
-
// Supabase client — bootstrapped with anon key, session set after auth below
|
|
24
|
-
// ---------------------------------------------------------------------------
|
|
25
|
-
const supabase = (0, supabase_js_1.createClient)(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
|
26
|
-
auth: {
|
|
27
|
-
persistSession: false,
|
|
28
|
-
autoRefreshToken: true,
|
|
29
|
-
},
|
|
30
|
-
...(LEGACY_JWT ? { global: { headers: { Authorization: `Bearer ${LEGACY_JWT}` } } } : {}),
|
|
31
|
-
});
|
|
32
|
-
// ---------------------------------------------------------------------------
|
|
33
|
-
// Auth: exchange MCP token for a real Supabase session (auto-refreshes)
|
|
34
|
-
// Caches session locally so exchange is only needed on first run or token expiry
|
|
35
|
-
// ---------------------------------------------------------------------------
|
|
36
|
-
const node_os_1 = require("node:os");
|
|
37
|
-
const node_fs_1 = require("node:fs");
|
|
38
|
-
const node_path_1 = require("node:path");
|
|
39
|
-
const SESSION_CACHE_DIR = (0, node_path_1.join)((0, node_os_1.homedir)(), '.burn');
|
|
40
|
-
const SESSION_CACHE_FILE = (0, node_path_1.join)(SESSION_CACHE_DIR, 'mcp-session.json');
|
|
41
|
-
function loadCachedSession() {
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
var import_stdio = require("@modelcontextprotocol/sdk/server/stdio.js");
|
|
5
|
+
var import_supabase_js = require("@supabase/supabase-js");
|
|
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 = "";
|
|
42
17
|
try {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (data.access_token && data.refresh_token)
|
|
46
|
-
return data;
|
|
18
|
+
detail = JSON.stringify(await resp.json());
|
|
19
|
+
} catch {
|
|
47
20
|
}
|
|
48
|
-
|
|
49
|
-
|
|
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 };
|
|
50
28
|
}
|
|
51
|
-
function
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
+
}
|
|
57
39
|
}
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
}
|
|
75
|
-
console.error('Burn MCP: cached session expired, re-exchanging...');
|
|
76
|
-
}
|
|
77
|
-
// Step 2: Exchange MCP token for a fresh Supabase session via Vercel API
|
|
78
|
-
try {
|
|
79
|
-
const resp = await fetch(EXCHANGE_URL, {
|
|
80
|
-
method: 'POST',
|
|
81
|
-
headers: { 'Content-Type': 'application/json' },
|
|
82
|
-
body: JSON.stringify({ token: MCP_TOKEN }),
|
|
83
|
-
});
|
|
84
|
-
if (!resp.ok) {
|
|
85
|
-
const body = await resp.json().catch(() => ({}));
|
|
86
|
-
console.error(`Error: Token exchange failed (${resp.status}): ${body.error || 'Unknown'}`);
|
|
87
|
-
console.error('Tokens expire after 30 days. Generate a new one in Burn App → Settings → MCP Server.');
|
|
88
|
-
process.exit(1);
|
|
89
|
-
}
|
|
90
|
-
const { access_token, refresh_token } = await resp.json();
|
|
91
|
-
const { error: sessionError } = await supabase.auth.setSession({
|
|
92
|
-
access_token,
|
|
93
|
-
refresh_token,
|
|
94
|
-
});
|
|
95
|
-
if (sessionError) {
|
|
96
|
-
console.error('Error: Failed to set session.', sessionError.message);
|
|
97
|
-
process.exit(1);
|
|
98
|
-
}
|
|
99
|
-
// Cache for next startup + listen for refresh
|
|
100
|
-
saveCachedSession(access_token, refresh_token);
|
|
101
|
-
supabase.auth.onAuthStateChange((_event, session) => {
|
|
102
|
-
if (session?.access_token && session?.refresh_token) {
|
|
103
|
-
saveCachedSession(session.access_token, session.refresh_token);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
console.error('Burn MCP: session exchanged and cached');
|
|
107
|
-
}
|
|
108
|
-
catch (err) {
|
|
109
|
-
console.error('Error: Could not reach token exchange endpoint.', err.message);
|
|
110
|
-
console.error(`URL: ${EXCHANGE_URL}`);
|
|
111
|
-
process.exit(1);
|
|
112
|
-
}
|
|
40
|
+
var HTTP_CACHE_TTL_MS = 5 * 6e4;
|
|
41
|
+
|
|
42
|
+
// src/lib/auth-stdio.ts
|
|
43
|
+
var import_node_os = require("node:os");
|
|
44
|
+
var import_node_fs = require("node:fs");
|
|
45
|
+
var import_node_path = require("node:path");
|
|
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");
|
|
48
|
+
function loadCachedSession() {
|
|
49
|
+
try {
|
|
50
|
+
const raw = (0, import_node_fs.readFileSync)(CACHE_FILE, "utf-8");
|
|
51
|
+
const data = JSON.parse(raw);
|
|
52
|
+
if (data.access_token && data.refresh_token) return data;
|
|
53
|
+
} catch {
|
|
54
|
+
}
|
|
55
|
+
return null;
|
|
113
56
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
57
|
+
function saveCachedSession(session) {
|
|
58
|
+
try {
|
|
59
|
+
(0, import_node_fs.mkdirSync)(CACHE_DIR, { recursive: true });
|
|
60
|
+
(0, import_node_fs.writeFileSync)(CACHE_FILE, JSON.stringify(session), { mode: 384 });
|
|
61
|
+
} catch {
|
|
62
|
+
}
|
|
63
|
+
}
|
|
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;
|
|
121
74
|
const now = Date.now();
|
|
122
|
-
// Remove entries outside the window
|
|
123
75
|
while (rateLimitLog.length > 0 && rateLimitLog[0] < now - RATE_LIMIT_WINDOW_MS) {
|
|
124
|
-
|
|
76
|
+
rateLimitLog.shift();
|
|
125
77
|
}
|
|
126
78
|
if (rateLimitLog.length >= RATE_LIMIT_MAX_CALLS) {
|
|
127
|
-
|
|
128
|
-
|
|
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.`;
|
|
129
81
|
}
|
|
130
82
|
rateLimitLog.push(now);
|
|
131
83
|
return null;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
function textResult(text) {
|
|
144
|
-
return { content: [{ type: 'text', text }] };
|
|
145
|
-
}
|
|
146
|
-
// ---------------------------------------------------------------------------
|
|
147
|
-
// Helper: verify bookmark exists and has expected status
|
|
148
|
-
// ---------------------------------------------------------------------------
|
|
149
|
-
async function verifyBookmark(id, expectedStatus) {
|
|
150
|
-
const { data, error } = await supabase
|
|
151
|
-
.from('bookmarks')
|
|
152
|
-
.select('*')
|
|
153
|
-
.eq('id', id)
|
|
154
|
-
.single();
|
|
155
|
-
if (error)
|
|
156
|
-
return { data: null, error: error.code === 'PGRST116' ? 'Bookmark not found' : error.message };
|
|
84
|
+
}
|
|
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 };
|
|
157
95
|
if (expectedStatus) {
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
+
}
|
|
163
101
|
}
|
|
164
102
|
return { data, error: null };
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const { data, error } = await supabase
|
|
171
|
-
.from('bookmarks')
|
|
172
|
-
.select('content_metadata')
|
|
173
|
-
.eq('id', bookmarkId)
|
|
174
|
-
.single();
|
|
175
|
-
if (error)
|
|
176
|
-
return { error: error.message };
|
|
177
|
-
const existing = (data.content_metadata || {});
|
|
178
|
-
// Only merge non-undefined fields
|
|
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 || {};
|
|
179
108
|
const cleaned = {};
|
|
180
109
|
for (const [k, v] of Object.entries(fields)) {
|
|
181
|
-
|
|
182
|
-
cleaned[k] = v;
|
|
110
|
+
if (v !== void 0 && v !== null) cleaned[k] = v;
|
|
183
111
|
}
|
|
184
112
|
const merged = { ...existing, ...cleaned };
|
|
185
|
-
const { error: updateError } = await supabase
|
|
186
|
-
.from('bookmarks')
|
|
187
|
-
.update({ content_metadata: merged, ...extraColumns })
|
|
188
|
-
.eq('id', bookmarkId);
|
|
113
|
+
const { error: updateError } = await supabase.from("bookmarks").update({ content_metadata: merged, ...extraColumns }).eq("id", bookmarkId);
|
|
189
114
|
return { error: updateError?.message || null };
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Helper: extract fields from content_metadata JSONB
|
|
193
|
-
// ---------------------------------------------------------------------------
|
|
194
|
-
function meta(row) {
|
|
115
|
+
}
|
|
116
|
+
function meta(row) {
|
|
195
117
|
const m = row.content_metadata || {};
|
|
196
118
|
return {
|
|
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
|
-
|
|
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
|
|
224
146
|
};
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function metaSummary(row) {
|
|
147
|
+
}
|
|
148
|
+
function metaSummary(row) {
|
|
228
149
|
const m = row.content_metadata || {};
|
|
229
150
|
return {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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 || []
|
|
240
161
|
};
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
function flameSummary(row) {
|
|
162
|
+
}
|
|
163
|
+
function flameSummary(row) {
|
|
244
164
|
const m = row.content_metadata || {};
|
|
245
165
|
const expiresAt = row.countdown_expires_at ? new Date(row.countdown_expires_at) : null;
|
|
246
|
-
const now = new Date();
|
|
166
|
+
const now = /* @__PURE__ */ new Date();
|
|
247
167
|
const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
|
|
248
|
-
const remainingHours = Math.max(0, Math.round(remainingMs /
|
|
168
|
+
const remainingHours = Math.max(0, Math.round(remainingMs / 36e5 * 10) / 10);
|
|
249
169
|
return {
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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 || []
|
|
273
193
|
};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// Tool handlers (all wrapped with rate limiting)
|
|
277
|
-
// ---------------------------------------------------------------------------
|
|
278
|
-
/** Wrap a handler with rate limiting */
|
|
279
|
-
function rateLimited(handler) {
|
|
194
|
+
}
|
|
195
|
+
function rateLimited(handler) {
|
|
280
196
|
return async (args) => {
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
return handler(args);
|
|
197
|
+
const err = checkRateLimit();
|
|
198
|
+
if (err) return textResult(err);
|
|
199
|
+
return handler(args);
|
|
285
200
|
};
|
|
286
|
-
}
|
|
287
|
-
async function handleSearchVault(args) {
|
|
201
|
+
}
|
|
202
|
+
async function handleSearchVault(args) {
|
|
288
203
|
const { query, limit } = args;
|
|
289
|
-
const { data, error } = await supabase
|
|
290
|
-
|
|
291
|
-
.select('*')
|
|
292
|
-
.eq('status', 'absorbed')
|
|
293
|
-
.ilike('title', `%${query}%`)
|
|
294
|
-
.order('created_at', { ascending: false })
|
|
295
|
-
.limit(limit || 10);
|
|
296
|
-
if (error)
|
|
297
|
-
return textResult(`Error: ${error.message}`);
|
|
298
|
-
// Also search in content_metadata tags and takeaway
|
|
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}`);
|
|
299
206
|
let results = (data || []).map(metaSummary);
|
|
300
|
-
// If title search returned few results, also search by tag
|
|
301
207
|
if (results.length < (limit || 10)) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
324
222
|
}
|
|
325
223
|
return textResult(JSON.stringify(results, null, 2));
|
|
326
|
-
}
|
|
327
|
-
async function handleGetBookmark(args) {
|
|
328
|
-
const { data, error } = await supabase
|
|
329
|
-
|
|
330
|
-
.select('*')
|
|
331
|
-
.eq('id', args.id)
|
|
332
|
-
.single();
|
|
333
|
-
if (error)
|
|
334
|
-
return textResult(`Error: ${error.message}`);
|
|
224
|
+
}
|
|
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}`);
|
|
335
228
|
return textResult(JSON.stringify(meta(data), null, 2));
|
|
336
|
-
}
|
|
337
|
-
async function handleListCategories() {
|
|
338
|
-
const { data, error } = await supabase
|
|
339
|
-
|
|
340
|
-
.select('content_metadata')
|
|
341
|
-
.eq('status', 'absorbed');
|
|
342
|
-
if (error)
|
|
343
|
-
return textResult(`Error: ${error.message}`);
|
|
229
|
+
}
|
|
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}`);
|
|
344
233
|
const counts = {};
|
|
345
234
|
for (const row of data || []) {
|
|
346
|
-
|
|
347
|
-
|
|
235
|
+
const cat = row.content_metadata?.vault_category || "Uncategorized";
|
|
236
|
+
counts[cat] = (counts[cat] || 0) + 1;
|
|
348
237
|
}
|
|
349
|
-
const categories = Object.entries(counts)
|
|
350
|
-
.map(([category, count]) => ({ category, count }))
|
|
351
|
-
.sort((a, b) => b.count - a.count);
|
|
238
|
+
const categories = Object.entries(counts).map(([category, count]) => ({ category, count })).sort((a, b) => b.count - a.count);
|
|
352
239
|
return textResult(JSON.stringify(categories, null, 2));
|
|
353
|
-
}
|
|
354
|
-
async function handleGetCollections() {
|
|
355
|
-
const { data, error } = await supabase
|
|
356
|
-
|
|
357
|
-
.select('id, name, bookmark_ids, ai_overview');
|
|
358
|
-
if (error)
|
|
359
|
-
return textResult(`Error: ${error.message}`);
|
|
240
|
+
}
|
|
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}`);
|
|
360
244
|
const collections = (data || []).map((c) => ({
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
365
249
|
}));
|
|
366
250
|
return textResult(JSON.stringify(collections, null, 2));
|
|
367
|
-
}
|
|
368
|
-
async function handleGetCollectionOverview(args) {
|
|
369
|
-
const { data: collection, error } = await supabase
|
|
370
|
-
.from('collections')
|
|
371
|
-
.select('*')
|
|
372
|
-
.eq('name', args.name)
|
|
373
|
-
.single();
|
|
251
|
+
}
|
|
252
|
+
async function handleGetCollectionOverview(args) {
|
|
253
|
+
const { data: collection, error } = await supabase.from("collections").select("*").eq("name", args.name).single();
|
|
374
254
|
if (error) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
255
|
+
return textResult(
|
|
256
|
+
error.code === "PGRST116" ? `No collection found with name "${args.name}".` : `Error: ${error.message}`
|
|
257
|
+
);
|
|
378
258
|
}
|
|
379
259
|
let bookmarks = [];
|
|
380
260
|
if (Array.isArray(collection.bookmark_ids) && collection.bookmark_ids.length > 0) {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
if (!bError && bData) {
|
|
386
|
-
bookmarks = bData.map(metaSummary);
|
|
387
|
-
}
|
|
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
|
+
}
|
|
388
265
|
}
|
|
389
266
|
return textResult(JSON.stringify({
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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
|
|
395
272
|
}, null, 2));
|
|
396
|
-
}
|
|
397
|
-
async function handleGetArticleContent(args) {
|
|
398
|
-
const { data, error } = await supabase
|
|
399
|
-
|
|
400
|
-
.select('*')
|
|
401
|
-
.eq('id', args.id)
|
|
402
|
-
.single();
|
|
403
|
-
if (error)
|
|
404
|
-
return textResult(`Error: ${error.message}`);
|
|
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}`);
|
|
405
277
|
return textResult(JSON.stringify(meta(data), null, 2));
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
if (/
|
|
415
|
-
|
|
416
|
-
if (/
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
if (/bilibili\.com|b23\.tv/i.test(url))
|
|
421
|
-
return 'bilibili';
|
|
422
|
-
if (/open\.spotify\.com/i.test(url))
|
|
423
|
-
return 'spotify';
|
|
424
|
-
if (/mp\.weixin\.qq\.com/i.test(url))
|
|
425
|
-
return 'wechat';
|
|
426
|
-
if (/xiaohongshu\.com|xhslink\.com/i.test(url))
|
|
427
|
-
return 'xhs';
|
|
428
|
-
return 'web';
|
|
429
|
-
}
|
|
430
|
-
/** Fetch content via Vercel API proxy (bypasses GFW for X.com, etc.) */
|
|
431
|
-
async function fetchViaAPI(url, platform) {
|
|
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) {
|
|
432
292
|
try {
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
}
|
|
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 ? `
|
|
336
|
+
|
|
337
|
+
[Quote from @${data.quote.handle}]: ${data.quote.text}` : "";
|
|
499
338
|
return {
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
339
|
+
title: data.text?.slice(0, 100) || "Tweet",
|
|
340
|
+
author: data.author ? `@${data.handle || data.author}` : void 0,
|
|
341
|
+
content: text + quoteText
|
|
503
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
|
+
}
|
|
358
|
+
return {
|
|
359
|
+
title: data.title,
|
|
360
|
+
author: data.author,
|
|
361
|
+
content: data.content || data.extracted_content || data.text || data.transcript || null
|
|
362
|
+
};
|
|
363
|
+
} catch (err) {
|
|
364
|
+
return { error: err.message || "Fetch failed" };
|
|
504
365
|
}
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
}
|
|
509
|
-
async function handleFetchContent(args) {
|
|
366
|
+
}
|
|
367
|
+
async function handleFetchContent(args) {
|
|
510
368
|
const { url } = args;
|
|
511
369
|
const platform = detectPlatform(url);
|
|
512
|
-
|
|
513
|
-
const { data: existing } = await supabase
|
|
514
|
-
.from('bookmarks')
|
|
515
|
-
.select('*')
|
|
516
|
-
.eq('url', url)
|
|
517
|
-
.limit(1)
|
|
518
|
-
.maybeSingle();
|
|
370
|
+
const { data: existing } = await supabase.from("bookmarks").select("*").eq("url", url).limit(1).maybeSingle();
|
|
519
371
|
if (existing) {
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
}
|
|
534
|
-
}
|
|
535
|
-
// Step 2: Fetch fresh content via Vercel API
|
|
536
|
-
const result = await fetchViaAPI(url, platform);
|
|
537
|
-
if (result.error) {
|
|
372
|
+
const m = existing.content_metadata || {};
|
|
373
|
+
if (m.extracted_content && m.extracted_content.length > 50) {
|
|
538
374
|
return textResult(JSON.stringify({
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
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
|
|
544
384
|
}, null, 2));
|
|
385
|
+
}
|
|
545
386
|
}
|
|
546
|
-
|
|
547
|
-
|
|
387
|
+
const result = await fetchViaAPI(url, platform);
|
|
388
|
+
if (result.error) {
|
|
389
|
+
return textResult(JSON.stringify({
|
|
390
|
+
source: "error",
|
|
548
391
|
url,
|
|
549
392
|
platform,
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
393
|
+
error: result.error,
|
|
394
|
+
hint: platform === "x" ? "X.com content is fetched via Vercel Edge proxy to bypass GFW" : void 0
|
|
395
|
+
}, null, 2));
|
|
396
|
+
}
|
|
397
|
+
return textResult(JSON.stringify({
|
|
398
|
+
source: "live",
|
|
399
|
+
url,
|
|
400
|
+
platform,
|
|
401
|
+
title: result.title,
|
|
402
|
+
author: result.author,
|
|
403
|
+
content: result.content
|
|
553
404
|
}, null, 2));
|
|
554
|
-
}
|
|
555
|
-
async function handleListSparks(args) {
|
|
556
|
-
const { data, error } = await supabase
|
|
557
|
-
|
|
558
|
-
.select('*')
|
|
559
|
-
.eq('status', 'read')
|
|
560
|
-
.order('created_at', { ascending: false })
|
|
561
|
-
.limit(args.limit || 20);
|
|
562
|
-
if (error)
|
|
563
|
-
return textResult(`Error: ${error.message}`);
|
|
405
|
+
}
|
|
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}`);
|
|
564
409
|
const results = (data || []).map((row) => {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
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
|
+
};
|
|
572
417
|
});
|
|
573
418
|
return textResult(JSON.stringify(results, null, 2));
|
|
574
|
-
}
|
|
575
|
-
async function handleSearchSparks(args) {
|
|
419
|
+
}
|
|
420
|
+
async function handleSearchSparks(args) {
|
|
576
421
|
const { query, limit } = args;
|
|
577
|
-
const { data, error } = await supabase
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
.
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
.
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
.slice(0, limit || 10)
|
|
598
|
-
.map((row) => {
|
|
599
|
-
const s = metaSummary(row);
|
|
600
|
-
const m = row.content_metadata || {};
|
|
601
|
-
return {
|
|
602
|
-
...s,
|
|
603
|
-
sparkInsight: m.spark_insight || null,
|
|
604
|
-
sparkExpiresAt: m.spark_expires_at || null,
|
|
605
|
-
};
|
|
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
|
+
};
|
|
606
442
|
});
|
|
607
443
|
return textResult(JSON.stringify(results, null, 2));
|
|
608
|
-
}
|
|
609
|
-
async function handleListFlame(args) {
|
|
610
|
-
const { data, error } = await supabase
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
return textResult(`Error: ${error.message}`);
|
|
618
|
-
// Filter out already expired ones (should be ash but not yet processed)
|
|
619
|
-
const now = new Date();
|
|
620
|
-
const results = (data || [])
|
|
621
|
-
.filter((row) => {
|
|
622
|
-
if (!row.countdown_expires_at)
|
|
623
|
-
return true;
|
|
624
|
-
return new Date(row.countdown_expires_at).getTime() > now.getTime();
|
|
625
|
-
})
|
|
626
|
-
.map(flameSummary);
|
|
444
|
+
}
|
|
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);
|
|
627
453
|
return textResult(JSON.stringify(results, null, 2));
|
|
628
|
-
}
|
|
629
|
-
async function handleGetFlameDetail(args) {
|
|
630
|
-
const { data, error } = await supabase
|
|
631
|
-
.from('bookmarks')
|
|
632
|
-
.select('*')
|
|
633
|
-
.eq('id', args.id)
|
|
634
|
-
.eq('status', 'active')
|
|
635
|
-
.single();
|
|
454
|
+
}
|
|
455
|
+
async function handleGetFlameDetail(args) {
|
|
456
|
+
const { data, error } = await supabase.from("bookmarks").select("*").eq("id", args.id).eq("status", "active").single();
|
|
636
457
|
if (error) {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
+
);
|
|
640
461
|
}
|
|
641
|
-
// Return full detail including extracted content
|
|
642
462
|
const m = data.content_metadata || {};
|
|
643
463
|
const expiresAt = data.countdown_expires_at ? new Date(data.countdown_expires_at) : null;
|
|
644
|
-
const now = new Date();
|
|
464
|
+
const now = /* @__PURE__ */ new Date();
|
|
645
465
|
const remainingMs = expiresAt ? expiresAt.getTime() - now.getTime() : 0;
|
|
646
|
-
const remainingHours = Math.max(0, Math.round(remainingMs /
|
|
466
|
+
const remainingHours = Math.max(0, Math.round(remainingMs / 36e5 * 10) / 10);
|
|
647
467
|
const result = {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
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
|
|
655
475
|
};
|
|
656
476
|
return textResult(JSON.stringify(result, null, 2));
|
|
657
|
-
}
|
|
658
|
-
async function handleListVault(args) {
|
|
659
|
-
let query = supabase
|
|
660
|
-
.from('bookmarks')
|
|
661
|
-
.select('*')
|
|
662
|
-
.eq('status', 'absorbed')
|
|
663
|
-
.order('created_at', { ascending: false })
|
|
664
|
-
.limit(args.limit || 20);
|
|
477
|
+
}
|
|
478
|
+
async function handleListVault(args) {
|
|
479
|
+
let query = supabase.from("bookmarks").select("*").eq("status", "absorbed").order("created_at", { ascending: false }).limit(args.limit || 20);
|
|
665
480
|
const { data, error } = await query;
|
|
666
|
-
if (error)
|
|
667
|
-
return textResult(`Error: ${error.message}`);
|
|
481
|
+
if (error) return textResult(`Error: ${error.message}`);
|
|
668
482
|
let results = (data || []).map(metaSummary);
|
|
669
|
-
// Filter by category if provided
|
|
670
483
|
if (args.category) {
|
|
671
|
-
|
|
484
|
+
results = results.filter(
|
|
485
|
+
(r) => r.vaultCategory?.toLowerCase() === args.category.toLowerCase()
|
|
486
|
+
);
|
|
672
487
|
}
|
|
673
488
|
return textResult(JSON.stringify(results, null, 2));
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
const { data, error } = await verifyBookmark(args.id, 'active');
|
|
680
|
-
if (error)
|
|
681
|
-
return textResult(`Error: ${error}`);
|
|
682
|
-
const sparkExpiresAt = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
489
|
+
}
|
|
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();
|
|
683
494
|
const metaFields = { spark_expires_at: sparkExpiresAt };
|
|
684
|
-
if (args.spark_insight)
|
|
685
|
-
metaFields.spark_insight = args.spark_insight;
|
|
495
|
+
if (args.spark_insight) metaFields.spark_insight = args.spark_insight;
|
|
686
496
|
const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
|
|
687
|
-
|
|
688
|
-
|
|
497
|
+
status: "read",
|
|
498
|
+
read_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
689
499
|
});
|
|
690
|
-
if (mergeErr)
|
|
691
|
-
return textResult(`Error: ${mergeErr}`);
|
|
500
|
+
if (mergeErr) return textResult(`Error: ${mergeErr}`);
|
|
692
501
|
return textResult(JSON.stringify({
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
502
|
+
success: true,
|
|
503
|
+
id: args.id,
|
|
504
|
+
title: data.title,
|
|
505
|
+
action: "flame \u2192 spark",
|
|
506
|
+
sparkExpiresAt
|
|
698
507
|
}, null, 2));
|
|
699
|
-
}
|
|
700
|
-
async function handleMoveFlameToAsh(args) {
|
|
701
|
-
const { data, error } = await verifyBookmark(args.id,
|
|
702
|
-
if (error)
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
.from('bookmarks')
|
|
706
|
-
.update({ status: 'ash' })
|
|
707
|
-
.eq('id', args.id);
|
|
708
|
-
if (updateErr)
|
|
709
|
-
return textResult(`Error: ${updateErr.message}`);
|
|
508
|
+
}
|
|
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}`);
|
|
710
514
|
return textResult(JSON.stringify({
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
515
|
+
success: true,
|
|
516
|
+
id: args.id,
|
|
517
|
+
title: data.title,
|
|
518
|
+
action: "flame \u2192 ash",
|
|
519
|
+
reason: args.reason || null
|
|
716
520
|
}, null, 2));
|
|
717
|
-
}
|
|
718
|
-
async function handleMoveSparkToVault(args) {
|
|
719
|
-
const { data, error } = await verifyBookmark(args.id,
|
|
720
|
-
if (error)
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
if (args.vault_category)
|
|
724
|
-
metaFields.vault_category = args.vault_category;
|
|
521
|
+
}
|
|
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;
|
|
725
527
|
const { error: mergeErr } = await mergeContentMetadata(args.id, metaFields, {
|
|
726
|
-
|
|
528
|
+
status: "absorbed"
|
|
727
529
|
});
|
|
728
|
-
if (mergeErr)
|
|
729
|
-
return textResult(`Error: ${mergeErr}`);
|
|
530
|
+
if (mergeErr) return textResult(`Error: ${mergeErr}`);
|
|
730
531
|
return textResult(JSON.stringify({
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
532
|
+
success: true,
|
|
533
|
+
id: args.id,
|
|
534
|
+
title: data.title,
|
|
535
|
+
action: "spark \u2192 vault",
|
|
536
|
+
vaultCategory: args.vault_category || null
|
|
736
537
|
}, null, 2));
|
|
737
|
-
}
|
|
738
|
-
async function handleMoveSparkToAsh(args) {
|
|
739
|
-
const { data, error } = await verifyBookmark(args.id,
|
|
740
|
-
if (error)
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
.from('bookmarks')
|
|
744
|
-
.update({ status: 'ash' })
|
|
745
|
-
.eq('id', args.id);
|
|
746
|
-
if (updateErr)
|
|
747
|
-
return textResult(`Error: ${updateErr.message}`);
|
|
538
|
+
}
|
|
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}`);
|
|
748
544
|
return textResult(JSON.stringify({
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
545
|
+
success: true,
|
|
546
|
+
id: args.id,
|
|
547
|
+
title: data.title,
|
|
548
|
+
action: "spark \u2192 ash"
|
|
753
549
|
}, null, 2));
|
|
754
|
-
}
|
|
755
|
-
async function handleBatchTriageFlame(args) {
|
|
550
|
+
}
|
|
551
|
+
async function handleBatchTriageFlame(args) {
|
|
756
552
|
const results = [];
|
|
757
553
|
for (const decision of args.decisions) {
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
}
|
|
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
|
+
}
|
|
768
563
|
}
|
|
769
|
-
const succeeded = results.filter(r => r.success).length;
|
|
770
|
-
const failed = results.filter(r => !r.success).length;
|
|
564
|
+
const succeeded = results.filter((r) => r.success).length;
|
|
565
|
+
const failed = results.filter((r) => !r.success).length;
|
|
771
566
|
return textResult(JSON.stringify({
|
|
772
|
-
|
|
773
|
-
|
|
567
|
+
summary: `${succeeded} succeeded, ${failed} failed (of ${results.length} total)`,
|
|
568
|
+
results
|
|
774
569
|
}, null, 2));
|
|
775
|
-
}
|
|
776
|
-
|
|
777
|
-
// Layer 3: AI analysis writeback handler (分析层)
|
|
778
|
-
// ---------------------------------------------------------------------------
|
|
779
|
-
async function handleWriteBookmarkAnalysis(args) {
|
|
570
|
+
}
|
|
571
|
+
async function handleWriteBookmarkAnalysis(args) {
|
|
780
572
|
const { data, error } = await verifyBookmark(args.id);
|
|
781
|
-
if (error)
|
|
782
|
-
return textResult(`Error: ${error}`);
|
|
573
|
+
if (error) return textResult(`Error: ${error}`);
|
|
783
574
|
const { error: mergeErr } = await mergeContentMetadata(args.id, args.analysis);
|
|
784
|
-
if (mergeErr)
|
|
785
|
-
|
|
786
|
-
const fieldsWritten = Object.keys(args.analysis).filter(k => args.analysis[k] !== undefined);
|
|
575
|
+
if (mergeErr) return textResult(`Error: ${mergeErr}`);
|
|
576
|
+
const fieldsWritten = Object.keys(args.analysis).filter((k) => args.analysis[k] !== void 0);
|
|
787
577
|
return textResult(JSON.stringify({
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
578
|
+
success: true,
|
|
579
|
+
id: args.id,
|
|
580
|
+
title: data.title,
|
|
581
|
+
fieldsWritten
|
|
792
582
|
}, null, 2));
|
|
793
|
-
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
async function handleCreateCollection(args) {
|
|
798
|
-
// Get user ID from an existing bookmark (RLS ensures we only see our own)
|
|
799
|
-
const { data: sample } = await supabase
|
|
800
|
-
.from('bookmarks')
|
|
801
|
-
.select('user_id')
|
|
802
|
-
.limit(1)
|
|
803
|
-
.single();
|
|
804
|
-
if (!sample)
|
|
805
|
-
return textResult('Error: No bookmarks found — cannot determine user ID');
|
|
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");
|
|
806
587
|
const bookmarkIds = args.bookmark_ids || [];
|
|
807
|
-
// Verify bookmark_ids exist if provided
|
|
808
588
|
if (bookmarkIds.length > 0) {
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
if (missing.length > 0) {
|
|
816
|
-
return textResult(`Error: Bookmark IDs not found: ${missing.join(', ')}`);
|
|
817
|
-
}
|
|
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
|
+
}
|
|
818
595
|
}
|
|
819
|
-
const { data, error } = await supabase
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
})
|
|
827
|
-
.select()
|
|
828
|
-
.single();
|
|
829
|
-
if (error)
|
|
830
|
-
return textResult(`Error: ${error.message}`);
|
|
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}`);
|
|
831
603
|
return textResult(JSON.stringify({
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
604
|
+
success: true,
|
|
605
|
+
id: data.id,
|
|
606
|
+
name: data.name,
|
|
607
|
+
articleCount: bookmarkIds.length
|
|
836
608
|
}, null, 2));
|
|
837
|
-
}
|
|
838
|
-
async function handleAddToCollection(args) {
|
|
839
|
-
const { data: collection, error } = await supabase
|
|
840
|
-
.from('collections')
|
|
841
|
-
.select('*')
|
|
842
|
-
.eq('id', args.collection_id)
|
|
843
|
-
.single();
|
|
609
|
+
}
|
|
610
|
+
async function handleAddToCollection(args) {
|
|
611
|
+
const { data: collection, error } = await supabase.from("collections").select("*").eq("id", args.collection_id).single();
|
|
844
612
|
if (error) {
|
|
845
|
-
|
|
613
|
+
return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
|
|
846
614
|
}
|
|
847
|
-
|
|
848
|
-
const { data: existing } = await supabase
|
|
849
|
-
.from('bookmarks')
|
|
850
|
-
.select('id')
|
|
851
|
-
.in('id', args.bookmark_ids);
|
|
615
|
+
const { data: existing } = await supabase.from("bookmarks").select("id").in("id", args.bookmark_ids);
|
|
852
616
|
const existingIds = new Set((existing || []).map((b) => b.id));
|
|
853
|
-
const missing = args.bookmark_ids.filter(id => !existingIds.has(id));
|
|
617
|
+
const missing = args.bookmark_ids.filter((id) => !existingIds.has(id));
|
|
854
618
|
if (missing.length > 0) {
|
|
855
|
-
|
|
619
|
+
return textResult(`Error: Bookmark IDs not found: ${missing.join(", ")}`);
|
|
856
620
|
}
|
|
857
|
-
// Union with existing (deduplicate)
|
|
858
621
|
const currentIds = new Set(collection.bookmark_ids || []);
|
|
859
|
-
const newIds = args.bookmark_ids.filter(id => !currentIds.has(id));
|
|
860
|
-
const merged = [...
|
|
861
|
-
const { error: updateErr } = await supabase
|
|
862
|
-
|
|
863
|
-
.update({ bookmark_ids: merged, is_overview_stale: true })
|
|
864
|
-
.eq('id', args.collection_id);
|
|
865
|
-
if (updateErr)
|
|
866
|
-
return textResult(`Error: ${updateErr.message}`);
|
|
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}`);
|
|
867
626
|
return textResult(JSON.stringify({
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
|
874
633
|
}, null, 2));
|
|
875
|
-
}
|
|
876
|
-
async function handleRemoveFromCollection(args) {
|
|
877
|
-
const { data: collection, error } = await supabase
|
|
878
|
-
.from('collections')
|
|
879
|
-
.select('*')
|
|
880
|
-
.eq('id', args.collection_id)
|
|
881
|
-
.single();
|
|
634
|
+
}
|
|
635
|
+
async function handleRemoveFromCollection(args) {
|
|
636
|
+
const { data: collection, error } = await supabase.from("collections").select("*").eq("id", args.collection_id).single();
|
|
882
637
|
if (error) {
|
|
883
|
-
|
|
638
|
+
return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
|
|
884
639
|
}
|
|
885
640
|
const removeSet = new Set(args.bookmark_ids);
|
|
886
641
|
const filtered = (collection.bookmark_ids || []).filter((id) => !removeSet.has(id));
|
|
887
642
|
const removed = (collection.bookmark_ids || []).length - filtered.length;
|
|
888
|
-
const { error: updateErr } = await supabase
|
|
889
|
-
|
|
890
|
-
.update({ bookmark_ids: filtered, is_overview_stale: true })
|
|
891
|
-
.eq('id', args.collection_id);
|
|
892
|
-
if (updateErr)
|
|
893
|
-
return textResult(`Error: ${updateErr.message}`);
|
|
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}`);
|
|
894
645
|
return textResult(JSON.stringify({
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
646
|
+
success: true,
|
|
647
|
+
collectionId: args.collection_id,
|
|
648
|
+
name: collection.name,
|
|
649
|
+
removed,
|
|
650
|
+
totalArticles: filtered.length
|
|
900
651
|
}, null, 2));
|
|
901
|
-
}
|
|
902
|
-
async function handleUpdateCollectionOverview(args) {
|
|
903
|
-
const { data: collection, error } = await supabase
|
|
904
|
-
.from('collections')
|
|
905
|
-
.select('id, name')
|
|
906
|
-
.eq('id', args.collection_id)
|
|
907
|
-
.single();
|
|
652
|
+
}
|
|
653
|
+
async function handleUpdateCollectionOverview(args) {
|
|
654
|
+
const { data: collection, error } = await supabase.from("collections").select("id, name").eq("id", args.collection_id).single();
|
|
908
655
|
if (error) {
|
|
909
|
-
|
|
656
|
+
return textResult(error.code === "PGRST116" ? "Error: Collection not found" : `Error: ${error.message}`);
|
|
910
657
|
}
|
|
911
|
-
const { error: updateErr } = await supabase
|
|
912
|
-
|
|
913
|
-
.update({ ai_overview: args.overview, is_overview_stale: false })
|
|
914
|
-
.eq('id', args.collection_id);
|
|
915
|
-
if (updateErr)
|
|
916
|
-
return textResult(`Error: ${updateErr.message}`);
|
|
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}`);
|
|
917
660
|
return textResult(JSON.stringify({
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
661
|
+
success: true,
|
|
662
|
+
collectionId: args.collection_id,
|
|
663
|
+
name: collection.name,
|
|
664
|
+
overviewTheme: args.overview.theme
|
|
922
665
|
}, null, 2));
|
|
923
|
-
}
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
server.tool('fetch_content', 'Fetch article/tweet content from a URL. Works with X.com (bypasses GFW via proxy), Reddit, YouTube, Bilibili, WeChat, and any web page. First checks Supabase cache, then fetches live.', { url: zod_1.z.string().describe('The URL to fetch content from') }, rateLimited(handleFetchContent));
|
|
935
|
-
server.tool('list_categories', 'List all Vault categories with article counts', {}, rateLimited(handleListCategories));
|
|
936
|
-
server.tool('list_flame', 'List bookmarks in your Flame inbox (24h countdown). Shows AI triage info (strategy, relevance, novelty, hook) and time remaining. Use this to see what needs attention before it burns to Ash.', { limit: zod_1.z.number().optional().describe('Max results (default 20)') }, rateLimited(handleListFlame));
|
|
937
|
-
server.tool('get_flame_detail', 'Get full details of a Flame bookmark including extracted article content, AI analysis, and reading guidance. Use this to deep-read a bookmark before deciding its fate.', { id: zod_1.z.string().describe('Bookmark UUID') }, rateLimited(handleGetFlameDetail));
|
|
938
|
-
server.tool('get_collections', 'List all your Collections with article counts and AI overview themes', {}, rateLimited(handleGetCollections));
|
|
939
|
-
server.tool('get_collection_overview', 'Get a Collection by name with its AI overview and linked bookmarks metadata', { name: zod_1.z.string().describe('Collection name') }, rateLimited(handleGetCollectionOverview));
|
|
940
|
-
// ---------------------------------------------------------------------------
|
|
941
|
-
// Layer 1: Status flow tools (决策层)
|
|
942
|
-
// ---------------------------------------------------------------------------
|
|
943
|
-
server.tool('move_flame_to_spark', 'Move a Flame bookmark to Spark (mark as worth reading). Sets 30-day Spark lifespan.', {
|
|
944
|
-
id: zod_1.z.string().describe('Bookmark UUID'),
|
|
945
|
-
spark_insight: zod_1.z.string().max(500).optional().describe('One-line insight about why this is worth reading'),
|
|
946
|
-
}, rateLimited(handleMoveFlameToSpark));
|
|
947
|
-
server.tool('move_flame_to_ash', 'Burn a Flame bookmark to Ash (not worth keeping).', {
|
|
948
|
-
id: zod_1.z.string().describe('Bookmark UUID'),
|
|
949
|
-
reason: zod_1.z.string().max(200).optional().describe('Why this was burned'),
|
|
950
|
-
}, rateLimited(handleMoveFlameToAsh));
|
|
951
|
-
server.tool('move_spark_to_vault', 'Promote a Spark bookmark to permanent Vault storage.', {
|
|
952
|
-
id: zod_1.z.string().describe('Bookmark UUID'),
|
|
953
|
-
vault_category: zod_1.z.string().max(100).optional().describe('Category to file under in the Vault'),
|
|
954
|
-
}, rateLimited(handleMoveSparkToVault));
|
|
955
|
-
server.tool('move_spark_to_ash', 'Burn a Spark bookmark to Ash (not valuable enough to vault).', {
|
|
956
|
-
id: zod_1.z.string().describe('Bookmark UUID'),
|
|
957
|
-
}, rateLimited(handleMoveSparkToAsh));
|
|
958
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
959
|
-
server.tool('batch_triage_flame', 'Triage multiple Flame bookmarks at once. Each decision moves a bookmark to Spark or Ash.', {
|
|
960
|
-
decisions: zod_1.z.array(zod_1.z.object({
|
|
961
|
-
id: zod_1.z.string().describe('Bookmark UUID'),
|
|
962
|
-
action: zod_1.z.enum(['spark', 'ash']).describe('spark = keep, ash = burn'),
|
|
963
|
-
spark_insight: zod_1.z.string().max(500).optional().describe('Insight (only for spark action)'),
|
|
964
|
-
})).min(1).max(20).describe('Array of triage decisions'),
|
|
965
|
-
}, rateLimited(handleBatchTriageFlame));
|
|
966
|
-
// ---------------------------------------------------------------------------
|
|
967
|
-
// Layer 3: AI analysis writeback tools (分析层)
|
|
968
|
-
// ---------------------------------------------------------------------------
|
|
969
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
970
|
-
server.tool('write_bookmark_analysis', 'Write AI analysis results into a bookmark. Agent analyzes content with its own LLM, then writes structured results back to Burn. Only provided fields are merged — existing data is preserved.', {
|
|
971
|
-
id: zod_1.z.string().describe('Bookmark UUID'),
|
|
972
|
-
analysis: zod_1.z.object({
|
|
973
|
-
ai_summary: zod_1.z.string().max(200).optional().describe('One-line summary'),
|
|
974
|
-
ai_strategy: zod_1.z.enum(['deep_read', 'skim', 'skip_read', 'reference']).optional().describe('Reading strategy'),
|
|
975
|
-
ai_strategy_reason: zod_1.z.string().max(200).optional().describe('Why this strategy'),
|
|
976
|
-
ai_minutes: zod_1.z.number().int().min(1).max(999).optional().describe('Estimated reading minutes'),
|
|
977
|
-
ai_takeaway: zod_1.z.array(zod_1.z.string().max(200)).max(5).optional().describe('Key takeaways'),
|
|
978
|
-
ai_relevance: zod_1.z.number().int().min(0).max(100).optional().describe('Relevance score 0-100'),
|
|
979
|
-
ai_novelty: zod_1.z.number().int().min(0).max(100).optional().describe('Novelty score 0-100'),
|
|
980
|
-
tags: zod_1.z.array(zod_1.z.string().max(50)).max(10).optional().describe('Topic tags'),
|
|
981
|
-
}).describe('Analysis fields to write'),
|
|
982
|
-
}, rateLimited(handleWriteBookmarkAnalysis));
|
|
983
|
-
// ---------------------------------------------------------------------------
|
|
984
|
-
// Layer 2: Collection tools (组合层)
|
|
985
|
-
// ---------------------------------------------------------------------------
|
|
986
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
987
|
-
server.tool('create_collection', 'Create a new Collection to group related bookmarks together.', {
|
|
988
|
-
name: zod_1.z.string().min(1).max(200).describe('Collection name'),
|
|
989
|
-
bookmark_ids: zod_1.z.array(zod_1.z.string()).optional().describe('Initial bookmark UUIDs to include'),
|
|
990
|
-
}, rateLimited(handleCreateCollection));
|
|
991
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
992
|
-
server.tool('add_to_collection', 'Add bookmarks to an existing Collection. Duplicates are silently ignored.', {
|
|
993
|
-
collection_id: zod_1.z.string().describe('Collection UUID'),
|
|
994
|
-
bookmark_ids: zod_1.z.array(zod_1.z.string()).min(1).max(50).describe('Bookmark UUIDs to add'),
|
|
995
|
-
}, rateLimited(handleAddToCollection));
|
|
996
|
-
server.tool('remove_from_collection', 'Remove bookmarks from a Collection.', {
|
|
997
|
-
collection_id: zod_1.z.string().describe('Collection UUID'),
|
|
998
|
-
bookmark_ids: zod_1.z.array(zod_1.z.string()).min(1).describe('Bookmark UUIDs to remove'),
|
|
999
|
-
}, rateLimited(handleRemoveFromCollection));
|
|
1000
|
-
// @ts-expect-error — MCP SDK TS2589
|
|
1001
|
-
server.tool('update_collection_overview', 'Write an AI-generated overview for a Collection (theme, synthesis, patterns, gaps).', {
|
|
1002
|
-
collection_id: zod_1.z.string().describe('Collection UUID'),
|
|
1003
|
-
overview: zod_1.z.object({
|
|
1004
|
-
theme: zod_1.z.string().describe('Overarching theme'),
|
|
1005
|
-
synthesis: zod_1.z.string().optional().describe('Cross-bookmark synthesis'),
|
|
1006
|
-
patterns: zod_1.z.array(zod_1.z.string()).optional().describe('Patterns identified'),
|
|
1007
|
-
gaps: zod_1.z.array(zod_1.z.string()).optional().describe('Knowledge gaps identified'),
|
|
1008
|
-
}).describe('AI-generated overview'),
|
|
1009
|
-
}, rateLimited(handleUpdateCollectionOverview));
|
|
1010
|
-
// ---------------------------------------------------------------------------
|
|
1011
|
-
// Resource: burn://vault/bookmarks
|
|
1012
|
-
// ---------------------------------------------------------------------------
|
|
1013
|
-
server.resource('vault-bookmarks', 'burn://vault/bookmarks', async (uri) => {
|
|
1014
|
-
const { data, error } = await supabase
|
|
1015
|
-
.from('bookmarks')
|
|
1016
|
-
.select('*')
|
|
1017
|
-
.eq('status', 'absorbed')
|
|
1018
|
-
.order('created_at', { ascending: false });
|
|
1019
|
-
if (error) {
|
|
1020
|
-
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] };
|
|
666
|
+
}
|
|
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)));
|
|
669
|
+
}
|
|
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();
|
|
1021
677
|
}
|
|
1022
|
-
|
|
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 {
|
|
698
|
+
}
|
|
699
|
+
const author = extractXMLValue(block, "author") || extractXMLValue(block, "dc:creator") || "";
|
|
700
|
+
items.push({ url, title, author, publishedAt });
|
|
701
|
+
}
|
|
702
|
+
return items;
|
|
703
|
+
}
|
|
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 [];
|
|
734
|
+
}
|
|
735
|
+
}
|
|
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.");
|
|
775
|
+
}
|
|
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
|
+
}
|
|
809
|
+
}
|
|
810
|
+
const totalAdded = results.reduce((s, r) => s + (r.added || 0), 0);
|
|
811
|
+
return textResult(JSON.stringify({ totalAdded, sources: results }, null, 2));
|
|
812
|
+
}
|
|
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 {
|
|
1023
1028
|
contents: [{
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
});
|
|
1030
|
-
// ---------------------------------------------------------------------------
|
|
1031
|
-
// Resource: burn://vault/categories
|
|
1032
|
-
// ---------------------------------------------------------------------------
|
|
1033
|
-
server.resource('vault-categories', 'burn://vault/categories', async (uri) => {
|
|
1034
|
-
const { data, error } = await supabase
|
|
1035
|
-
.from('bookmarks')
|
|
1036
|
-
.select('content_metadata')
|
|
1037
|
-
.eq('status', 'absorbed');
|
|
1038
|
-
if (error) {
|
|
1039
|
-
return { contents: [{ uri: uri.href, mimeType: 'application/json', text: JSON.stringify({ error: error.message }) }] };
|
|
1029
|
+
uri: uri.href,
|
|
1030
|
+
mimeType: "application/json",
|
|
1031
|
+
text: JSON.stringify((data || []).map(metaSummary), null, 2)
|
|
1032
|
+
}]
|
|
1033
|
+
};
|
|
1040
1034
|
}
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
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";
|
|
1044
1047
|
counts[cat] = (counts[cat] || 0) + 1;
|
|
1045
|
-
|
|
1046
|
-
|
|
1048
|
+
}
|
|
1049
|
+
return {
|
|
1047
1050
|
contents: [{
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
+
}
|
|
1057
1075
|
async function main() {
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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);
|
|
1101
|
+
const transport = new import_stdio.StdioServerTransport();
|
|
1102
|
+
await server.connect(transport);
|
|
1103
|
+
console.error("Burn MCP Server running on stdio");
|
|
1062
1104
|
}
|
|
1063
1105
|
main().catch((err) => {
|
|
1064
|
-
|
|
1065
|
-
|
|
1106
|
+
console.error("Fatal error:", err.message || err);
|
|
1107
|
+
process.exit(1);
|
|
1066
1108
|
});
|