nod-shout 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +82 -0
  2. package/TASK-AGENT-POSTS.md +112 -0
  3. package/assets/shout-default.svg +5 -0
  4. package/bin/shout +68 -0
  5. package/dist/index.d.ts +2 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +29 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/lib/ai.d.ts +13 -0
  10. package/dist/lib/ai.d.ts.map +1 -0
  11. package/dist/lib/ai.js +135 -0
  12. package/dist/lib/ai.js.map +1 -0
  13. package/dist/lib/content-filter.d.ts +74 -0
  14. package/dist/lib/content-filter.d.ts.map +1 -0
  15. package/dist/lib/content-filter.js +188 -0
  16. package/dist/lib/content-filter.js.map +1 -0
  17. package/dist/lib/context-extractor.d.ts +39 -0
  18. package/dist/lib/context-extractor.d.ts.map +1 -0
  19. package/dist/lib/context-extractor.js +170 -0
  20. package/dist/lib/context-extractor.js.map +1 -0
  21. package/dist/lib/match-engine.d.ts +31 -0
  22. package/dist/lib/match-engine.d.ts.map +1 -0
  23. package/dist/lib/match-engine.js +322 -0
  24. package/dist/lib/match-engine.js.map +1 -0
  25. package/dist/lib/metadata.d.ts +7 -0
  26. package/dist/lib/metadata.d.ts.map +1 -0
  27. package/dist/lib/metadata.js +311 -0
  28. package/dist/lib/metadata.js.map +1 -0
  29. package/dist/lib/skills.d.ts +3 -0
  30. package/dist/lib/skills.d.ts.map +1 -0
  31. package/dist/lib/skills.js +20 -0
  32. package/dist/lib/skills.js.map +1 -0
  33. package/dist/lib/supabase.d.ts +2 -0
  34. package/dist/lib/supabase.d.ts.map +1 -0
  35. package/dist/lib/supabase.js +8 -0
  36. package/dist/lib/supabase.js.map +1 -0
  37. package/dist/tools/collections.d.ts +3 -0
  38. package/dist/tools/collections.d.ts.map +1 -0
  39. package/dist/tools/collections.js +142 -0
  40. package/dist/tools/collections.js.map +1 -0
  41. package/dist/tools/intros.d.ts +3 -0
  42. package/dist/tools/intros.d.ts.map +1 -0
  43. package/dist/tools/intros.js +483 -0
  44. package/dist/tools/intros.js.map +1 -0
  45. package/dist/tools/links.d.ts +3 -0
  46. package/dist/tools/links.d.ts.map +1 -0
  47. package/dist/tools/links.js +424 -0
  48. package/dist/tools/links.js.map +1 -0
  49. package/dist/tools/posts.d.ts +3 -0
  50. package/dist/tools/posts.d.ts.map +1 -0
  51. package/dist/tools/posts.js +212 -0
  52. package/dist/tools/posts.js.map +1 -0
  53. package/dist/tools/settings.d.ts +3 -0
  54. package/dist/tools/settings.d.ts.map +1 -0
  55. package/dist/tools/settings.js +45 -0
  56. package/dist/tools/settings.js.map +1 -0
  57. package/dist/tools/shout_agent_curate.d.ts +28 -0
  58. package/dist/tools/shout_agent_curate.d.ts.map +1 -0
  59. package/dist/tools/shout_agent_curate.js +80 -0
  60. package/dist/tools/shout_agent_curate.js.map +1 -0
  61. package/dist/tools/social.d.ts +3 -0
  62. package/dist/tools/social.d.ts.map +1 -0
  63. package/dist/tools/social.js +169 -0
  64. package/dist/tools/social.js.map +1 -0
  65. package/dist/types.d.ts +60 -0
  66. package/dist/types.d.ts.map +1 -0
  67. package/dist/types.js +3 -0
  68. package/dist/types.js.map +1 -0
  69. package/package.json +24 -0
  70. package/quick-test.ts +22 -0
  71. package/regenerate-summaries.ts +111 -0
  72. package/save-jeffries-shout.ts +38 -0
  73. package/save-openai-shout.ts +35 -0
  74. package/save-prcarly.ts +46 -0
  75. package/save-shout.ts +35 -0
  76. package/save-techcrunch-shout.ts +59 -0
  77. package/save-zdnet-shout.ts +36 -0
  78. package/skills/collection-routing/SKILL.md +34 -0
  79. package/skills/link-summary/SKILL.md +53 -0
  80. package/skills/tagging-and-routing/SKILL.md +54 -0
  81. package/src/index.ts +32 -0
  82. package/src/lib/ai.ts +166 -0
  83. package/src/lib/content-filter.ts +258 -0
  84. package/src/lib/metadata.ts +353 -0
  85. package/src/lib/skills.ts +21 -0
  86. package/src/lib/supabase.ts +12 -0
  87. package/src/tools/collections.ts +182 -0
  88. package/src/tools/links.ts +524 -0
  89. package/src/tools/posts.ts +264 -0
  90. package/src/tools/settings.ts +55 -0
  91. package/src/tools/shout_agent_curate.ts +95 -0
  92. package/src/tools/social.ts +206 -0
  93. package/src/types.ts +66 -0
  94. package/supabase/.temp/cli-latest +1 -0
  95. package/supabase/.temp/gotrue-version +1 -0
  96. package/supabase/.temp/pooler-url +1 -0
  97. package/supabase/.temp/postgres-version +1 -0
  98. package/supabase/.temp/project-ref +1 -0
  99. package/supabase/.temp/rest-version +1 -0
  100. package/supabase/.temp/storage-migration +1 -0
  101. package/supabase/.temp/storage-version +1 -0
  102. package/supabase/migrations/001_initial_schema.sql +147 -0
  103. package/supabase/migrations/20260317010000_decouple_profiles_from_auth.sql +9 -0
  104. package/supabase/migrations/20260317020000_agent_curation.sql +10 -0
  105. package/supabase/migrations/20260320000000_agent_posts.sql +41 -0
  106. package/supabase/migrations/20260320120000_fix_draft_fk.sql +2 -0
  107. package/supabase/migrations/20260320130000_fix_identity.sql +17 -0
  108. package/supabase/migrations/20260320170000_intros.sql +118 -0
  109. package/test-model-comparison.ts +89 -0
  110. package/tsconfig.json +19 -0
@@ -0,0 +1,182 @@
1
+ import { z } from "zod";
2
+ import { supabase } from "../lib/supabase.js";
3
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+
5
+ export function registerCollectionTools(server: McpServer) {
6
+ // shout_create_collection
7
+ server.tool(
8
+ "shout_create_collection",
9
+ "create a new collection to organize your shouts.",
10
+ {
11
+ user_id: z.string().uuid().describe("the user's id"),
12
+ name: z.string().describe("collection name"),
13
+ description: z.string().optional().describe("collection description"),
14
+ auto_rules: z
15
+ .record(z.unknown())
16
+ .optional()
17
+ .describe("auto-sort rules (e.g. tag-based routing)"),
18
+ },
19
+ async ({ user_id, name, description, auto_rules }) => {
20
+ // generate slug from name
21
+ const slug = name
22
+ .toLowerCase()
23
+ .replace(/[^a-z0-9\s-]/g, "")
24
+ .replace(/\s+/g, "-")
25
+ .replace(/-+/g, "-")
26
+ .trim();
27
+
28
+ const { data, error } = await supabase
29
+ .from("collections")
30
+ .insert({
31
+ user_id,
32
+ name,
33
+ description: description || null,
34
+ slug,
35
+ auto_rules: auto_rules || null,
36
+ })
37
+ .select()
38
+ .single();
39
+
40
+ if (error) {
41
+ return {
42
+ content: [
43
+ { type: "text" as const, text: `error creating collection: ${error.message}` },
44
+ ],
45
+ };
46
+ }
47
+
48
+ return {
49
+ content: [
50
+ {
51
+ type: "text" as const,
52
+ text: `collection "${name}" created (slug: ${slug}, id: ${data.id})`,
53
+ },
54
+ ],
55
+ };
56
+ }
57
+ );
58
+
59
+ // shout_add_to_collection
60
+ server.tool(
61
+ "shout_add_to_collection",
62
+ "add a shout to a collection by slug.",
63
+ {
64
+ shout_id: z.string().uuid().describe("the shout id"),
65
+ user_id: z.string().uuid().describe("the user's id"),
66
+ collection_slug: z.string().describe("the collection slug to add to"),
67
+ },
68
+ async ({ shout_id, user_id, collection_slug }) => {
69
+ // resolve collection by slug for this user
70
+ const { data: col, error: colErr } = await supabase
71
+ .from("collections")
72
+ .select("id, name")
73
+ .eq("user_id", user_id)
74
+ .eq("slug", collection_slug)
75
+ .single();
76
+
77
+ if (colErr || !col) {
78
+ return {
79
+ content: [
80
+ { type: "text" as const, text: `collection "${collection_slug}" not found.` },
81
+ ],
82
+ };
83
+ }
84
+
85
+ // update the shout's collection_id
86
+ const { error } = await supabase
87
+ .from("shouts")
88
+ .update({ collection_id: col.id })
89
+ .eq("id", shout_id)
90
+ .eq("user_id", user_id);
91
+
92
+ if (error) {
93
+ return {
94
+ content: [
95
+ { type: "text" as const, text: `error adding to collection: ${error.message}` },
96
+ ],
97
+ };
98
+ }
99
+
100
+ return {
101
+ content: [
102
+ { type: "text" as const, text: `shout ${shout_id} added to "${col.name}" (${collection_slug}).` },
103
+ ],
104
+ };
105
+ }
106
+ );
107
+
108
+ // shout_remove_from_collection
109
+ server.tool(
110
+ "shout_remove_from_collection",
111
+ "remove a shout from its collection (sets collection_id to null).",
112
+ {
113
+ shout_id: z.string().uuid().describe("the shout id"),
114
+ user_id: z.string().uuid().describe("the user's id"),
115
+ },
116
+ async ({ shout_id, user_id }) => {
117
+ const { error } = await supabase
118
+ .from("shouts")
119
+ .update({ collection_id: null })
120
+ .eq("id", shout_id)
121
+ .eq("user_id", user_id);
122
+
123
+ if (error) {
124
+ return {
125
+ content: [
126
+ { type: "text" as const, text: `error removing from collection: ${error.message}` },
127
+ ],
128
+ };
129
+ }
130
+
131
+ return {
132
+ content: [
133
+ { type: "text" as const, text: `shout ${shout_id} removed from its collection.` },
134
+ ],
135
+ };
136
+ }
137
+ );
138
+
139
+ // shout_list_collections
140
+ server.tool(
141
+ "shout_list_collections",
142
+ "list all your collections.",
143
+ {
144
+ user_id: z.string().uuid().describe("the user's id"),
145
+ },
146
+ async ({ user_id }) => {
147
+ const { data, error } = await supabase
148
+ .from("collections")
149
+ .select("*")
150
+ .eq("user_id", user_id)
151
+ .order("created_at", { ascending: false });
152
+
153
+ if (error) {
154
+ return {
155
+ content: [
156
+ { type: "text" as const, text: `error listing collections: ${error.message}` },
157
+ ],
158
+ };
159
+ }
160
+
161
+ if (!data || data.length === 0) {
162
+ return {
163
+ content: [{ type: "text" as const, text: "no collections yet." }],
164
+ };
165
+ }
166
+
167
+ const lines = data.map(
168
+ (c: any, i: number) =>
169
+ `${i + 1}. ${c.name} (/${c.slug}) - ${c.description || "no description"} [${c.visibility}]`
170
+ );
171
+
172
+ return {
173
+ content: [
174
+ {
175
+ type: "text" as const,
176
+ text: `${data.length} collections:\n\n${lines.join("\n")}`,
177
+ },
178
+ ],
179
+ };
180
+ }
181
+ );
182
+ }
@@ -0,0 +1,524 @@
1
+ import { z } from "zod";
2
+ import { supabase } from "../lib/supabase.js";
3
+ import { extractMetadata } from "../lib/metadata.js";
4
+ import { generateSummary } from "../lib/ai.js";
5
+ import { filterShoutContent } from "../lib/content-filter.js";
6
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+
8
+ function inferRoutingHints(params: {
9
+ url: string;
10
+ title: string | null;
11
+ description: string | null;
12
+ tags: string[];
13
+ category: string;
14
+ }): string[] {
15
+ const haystack = [
16
+ params.url,
17
+ params.title || "",
18
+ params.description || "",
19
+ params.category,
20
+ ...params.tags,
21
+ ]
22
+ .join(" ")
23
+ .toLowerCase();
24
+
25
+ const hints = new Set<string>();
26
+
27
+ if (/(\bmcp\b|model context protocol|a2a|agent discovery|agents\.json|json agents|agent manifest|agent skills)/.test(haystack)) {
28
+ hints.add("agents");
29
+ hints.add("protocols");
30
+ }
31
+
32
+ if (/(github|sdk|api|cli|claude code|codex|developer|devtool|typescript|repo)/.test(haystack)) {
33
+ hints.add("devtools");
34
+ }
35
+
36
+ if (/(prompt injection|security|auth|exploit|abuse|vulnerability)/.test(haystack)) {
37
+ hints.add("security");
38
+ }
39
+
40
+ return Array.from(hints);
41
+ }
42
+
43
+ export function registerLinkTools(server: McpServer) {
44
+ // shout_save_link - fetch metadata, generate summary, store in supabase
45
+ server.tool(
46
+ "shout_save_link",
47
+ "save a link to your shout page. fetches metadata, generates summary and tags automatically.",
48
+ {
49
+ url: z.string().url().describe("the url to save"),
50
+ user_id: z.string().uuid().describe("the user's id"),
51
+ take: z
52
+ .string()
53
+ .optional()
54
+ .describe("user's commentary or take on the link"),
55
+ collection: z
56
+ .string()
57
+ .optional()
58
+ .describe("collection slug to add this to"),
59
+ visibility: z
60
+ .enum(["public", "private", "unlisted"])
61
+ .optional()
62
+ .default("public")
63
+ .describe("visibility of this shout"),
64
+ },
65
+ async ({ url, user_id, take, collection, visibility }) => {
66
+ // fetch page metadata
67
+ const metadata = await extractMetadata(url);
68
+
69
+ // generate ai summary + tags (stubbed for v1)
70
+ const aiResult = await generateSummary({
71
+ url,
72
+ title: metadata.title,
73
+ description: metadata.description,
74
+ bodyText: metadata.bodyText,
75
+ userContext: take || null,
76
+ });
77
+
78
+ // resolve collection id if slug provided
79
+ let collection_id: string | null = null;
80
+ if (collection) {
81
+ const { data: col } = await supabase
82
+ .from("collections")
83
+ .select("id")
84
+ .eq("user_id", user_id)
85
+ .eq("slug", collection)
86
+ .single();
87
+ collection_id = col?.id || null;
88
+ }
89
+
90
+ // auto-rules engine: if no explicit collection, check tag-based auto-routing
91
+ if (!collection_id) {
92
+ const routingHints = inferRoutingHints({
93
+ url,
94
+ title: metadata.title,
95
+ description: metadata.description,
96
+ tags: aiResult.tags,
97
+ category: aiResult.category,
98
+ });
99
+
100
+ const combinedTags = Array.from(new Set([...aiResult.tags, ...routingHints]));
101
+
102
+ if (combinedTags.length > 0) {
103
+ const { data: collections } = await supabase
104
+ .from("collections")
105
+ .select("id, auto_rules")
106
+ .eq("user_id", user_id)
107
+ .not("auto_rules", "is", null);
108
+
109
+ if (collections && collections.length > 0) {
110
+ for (const col of collections) {
111
+ const rules = col.auto_rules as Record<string, unknown> | null;
112
+ if (!rules) continue;
113
+ const matchTags = rules.match_tags;
114
+ if (!Array.isArray(matchTags)) continue;
115
+
116
+ const hasMatch = matchTags.some((ruleTag: unknown) =>
117
+ typeof ruleTag === "string" &&
118
+ combinedTags.some((t) => t.toLowerCase() === ruleTag.toLowerCase())
119
+ );
120
+
121
+ if (hasMatch) {
122
+ collection_id = col.id;
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ // PII/proprietary data filter before saving
131
+ const filtered = filterShoutContent({
132
+ take: take || null,
133
+ summary: aiResult.summary,
134
+ title: metadata.title,
135
+ description: metadata.description,
136
+ });
137
+
138
+ if (filtered.filterReport) {
139
+ console.log(`[nod-shout] content filter active: ${filtered.filterReport}`);
140
+ }
141
+
142
+ // insert the shout
143
+ const { data, error } = await supabase
144
+ .from("shouts")
145
+ .insert({
146
+ user_id,
147
+ url,
148
+ title: filtered.title,
149
+ description: filtered.description,
150
+ summary: filtered.summary,
151
+ user_take: filtered.take,
152
+ image_url: metadata.image_url,
153
+ tags: aiResult.tags,
154
+ category: aiResult.category,
155
+ collection_id,
156
+ source: "conversation",
157
+ visibility: visibility || "public",
158
+ })
159
+ .select()
160
+ .single();
161
+
162
+ if (error) {
163
+ return {
164
+ content: [
165
+ { type: "text" as const, text: `error saving shout: ${error.message}` },
166
+ ],
167
+ };
168
+ }
169
+
170
+ const tagStr = aiResult.tags.length > 0 ? aiResult.tags.join(", ") : "none";
171
+ return {
172
+ content: [
173
+ {
174
+ type: "text" as const,
175
+ text: `saved! "${metadata.title || url}"\nsummary: ${aiResult.summary}\ntags: ${tagStr}\ncategory: ${aiResult.category}`,
176
+ },
177
+ ],
178
+ };
179
+ }
180
+ );
181
+
182
+ // shout_agent_curate - agent proactively saves links it finds interesting
183
+ server.tool(
184
+ "shout_agent_curate",
185
+ "save a link the agent (fubz/clawd) finds interesting. use this to proactively curate links from conversations, research, or browsing. sets source to 'agent_curated' to distinguish from user-shared links.",
186
+ {
187
+ url: z.string().url().describe("the URL to save"),
188
+ user_id: z.string().uuid().describe("the fubz profile user id"),
189
+ agent_take: z
190
+ .string()
191
+ .optional()
192
+ .describe("the agent's own commentary on why this link is interesting"),
193
+ tags: z
194
+ .array(z.string())
195
+ .optional()
196
+ .describe("override auto-generated tags"),
197
+ collection_id: z
198
+ .string()
199
+ .uuid()
200
+ .optional()
201
+ .describe("put the shout in a specific collection"),
202
+ },
203
+ async ({ url, user_id, agent_take, tags, collection_id }) => {
204
+ const metadata = await extractMetadata(url);
205
+ const aiResult = await generateSummary({
206
+ url,
207
+ title: metadata.title,
208
+ description: metadata.description,
209
+ bodyText: metadata.bodyText,
210
+ userContext: agent_take || null,
211
+ });
212
+
213
+ const finalTags = tags || aiResult.tags;
214
+
215
+ const { data, error } = await supabase
216
+ .from("shouts")
217
+ .insert({
218
+ user_id,
219
+ url,
220
+ title: metadata.title,
221
+ description: metadata.description,
222
+ summary: aiResult.summary,
223
+ user_take: agent_take || null,
224
+ image_url: metadata.image_url,
225
+ tags: finalTags,
226
+ category: aiResult.category,
227
+ collection_id: collection_id || null,
228
+ source: "agent_curated",
229
+ visibility: "public",
230
+ })
231
+ .select()
232
+ .single();
233
+
234
+ if (error) {
235
+ return {
236
+ content: [{ type: "text" as const, text: `error saving agent-curated shout: ${error.message}` }],
237
+ };
238
+ }
239
+
240
+ const tagStr = finalTags.length > 0 ? finalTags.join(", ") : "none";
241
+ return {
242
+ content: [
243
+ {
244
+ type: "text" as const,
245
+ text: `agent curated! "${metadata.title || url}"\nsummary: ${aiResult.summary}\ntags: ${tagStr}\ncategory: ${aiResult.category}${agent_take ? `\nagent take: ${agent_take}` : ""}`,
246
+ },
247
+ ],
248
+ };
249
+ }
250
+ );
251
+
252
+ // shout_detect_links - scan text for URLs and offer to save them
253
+ server.tool(
254
+ "shout_detect_links",
255
+ "scan a message or conversation text for URLs. returns found links with metadata so the agent can ask the user if they want to shout any of them. call this when a user shares links in conversation.",
256
+ {
257
+ text: z.string().describe("the message text to scan for URLs"),
258
+ user_id: z.string().uuid().describe("the user's id"),
259
+ },
260
+ async ({ text, user_id }) => {
261
+ // extract URLs from text
262
+ const urlRegex = /https?:\/\/[^\s<>"')\]]+/gi;
263
+ const urls = [...new Set(text.match(urlRegex) || [])];
264
+
265
+ if (urls.length === 0) {
266
+ return {
267
+ content: [
268
+ { type: "text" as const, text: "no links found in the text." },
269
+ ],
270
+ };
271
+ }
272
+
273
+ // check which URLs are already saved
274
+ const { data: existing } = await supabase
275
+ .from("shouts")
276
+ .select("url")
277
+ .eq("user_id", user_id)
278
+ .in("url", urls);
279
+
280
+ const existingUrls = new Set((existing || []).map((s: any) => s.url));
281
+ const newUrls = urls.filter((u) => !existingUrls.has(u));
282
+
283
+ if (newUrls.length === 0) {
284
+ return {
285
+ content: [
286
+ {
287
+ type: "text" as const,
288
+ text: `found ${urls.length} link${urls.length !== 1 ? "s" : ""} but ${urls.length === 1 ? "it's" : "they're"} already saved.`,
289
+ },
290
+ ],
291
+ };
292
+ }
293
+
294
+ // fetch metadata for new URLs (in parallel)
295
+ const previews = await Promise.all(
296
+ newUrls.map(async (url) => {
297
+ try {
298
+ const metadata = await extractMetadata(url);
299
+ return {
300
+ url,
301
+ title: metadata.title || url,
302
+ description: metadata.description || null,
303
+ already_saved: false,
304
+ };
305
+ } catch {
306
+ return { url, title: url, description: null, already_saved: false };
307
+ }
308
+ })
309
+ );
310
+
311
+ const lines = previews.map(
312
+ (p, i) =>
313
+ `${i + 1}. ${p.title}\n ${p.url}${p.description ? `\n ${p.description.slice(0, 120)}` : ""}`
314
+ );
315
+
316
+ return {
317
+ content: [
318
+ {
319
+ type: "text" as const,
320
+ text: `found ${newUrls.length} new link${newUrls.length !== 1 ? "s" : ""}:\n\n${lines.join("\n\n")}\n\nask the user which ones to shout, then call shout_save_link for each.`,
321
+ },
322
+ ],
323
+ };
324
+ }
325
+ );
326
+
327
+ // shout_generate_digest - generate a weekly digest from recent shouts
328
+ server.tool(
329
+ "shout_generate_digest",
330
+ "generate a digest summary from recent shouts. returns markdown suitable for a blog post or email. also stores the digest in the digests table.",
331
+ {
332
+ user_id: z.string().uuid().describe("the user's id"),
333
+ days: z
334
+ .number()
335
+ .optional()
336
+ .default(7)
337
+ .describe("number of days to include (default 7)"),
338
+ },
339
+ async ({ user_id, days }) => {
340
+ const since = new Date(
341
+ Date.now() - (days || 7) * 24 * 60 * 60 * 1000
342
+ ).toISOString();
343
+
344
+ const { data: shouts } = await supabase
345
+ .from("shouts")
346
+ .select("url, title, summary, user_take, tags, category, created_at")
347
+ .eq("user_id", user_id)
348
+ .gte("created_at", since)
349
+ .order("created_at", { ascending: true });
350
+
351
+ if (!shouts || shouts.length === 0) {
352
+ return {
353
+ content: [
354
+ {
355
+ type: "text" as const,
356
+ text: `no shouts in the last ${days} days.`,
357
+ },
358
+ ],
359
+ };
360
+ }
361
+
362
+ const { data: profile } = await supabase
363
+ .from("profiles")
364
+ .select("username, display_name")
365
+ .eq("id", user_id)
366
+ .single();
367
+
368
+ const name =
369
+ (profile as any)?.display_name ||
370
+ (profile as any)?.username ||
371
+ "someone";
372
+
373
+ // build the digest content
374
+ const categories = new Map<string, typeof shouts>();
375
+ for (const s of shouts) {
376
+ const cat = (s as any).category || "uncategorized";
377
+ if (!categories.has(cat)) categories.set(cat, []);
378
+ categories.get(cat)!.push(s);
379
+ }
380
+
381
+ let markdown = `# ${name}'s weekly shouts\n\n`;
382
+ markdown += `*${shouts.length} links from the last ${days} days*\n\n`;
383
+
384
+ for (const [cat, items] of categories) {
385
+ markdown += `## ${cat}\n\n`;
386
+ for (const s of items) {
387
+ markdown += `**[${(s as any).title || (s as any).url}](${(s as any).url})**\n`;
388
+ if ((s as any).summary)
389
+ markdown += `${(s as any).summary}\n`;
390
+ if ((s as any).user_take)
391
+ markdown += `> ${(s as any).user_take}\n`;
392
+ markdown += `\n`;
393
+ }
394
+ }
395
+
396
+ // store digest in supabase
397
+ const { error: digestError } = await supabase.from("digests").insert({
398
+ user_id,
399
+ period_start: since,
400
+ period_end: new Date().toISOString(),
401
+ content: markdown,
402
+ shout_count: shouts.length,
403
+ });
404
+
405
+ // don't fail if digests table doesn't exist yet
406
+ if (digestError) {
407
+ console.error("digest storage failed (table may not exist):", digestError.message);
408
+ }
409
+
410
+ return {
411
+ content: [
412
+ {
413
+ type: "text" as const,
414
+ text: markdown,
415
+ },
416
+ ],
417
+ };
418
+ }
419
+ );
420
+
421
+ // shout_list - list user's shouts with optional filters
422
+ server.tool(
423
+ "shout_list",
424
+ "list your saved shouts. filter by tag, collection, or limit results.",
425
+ {
426
+ user_id: z.string().uuid().describe("the user's id"),
427
+ filter: z
428
+ .string()
429
+ .optional()
430
+ .describe("filter by tag name"),
431
+ collection: z
432
+ .string()
433
+ .optional()
434
+ .describe("filter by collection slug"),
435
+ limit: z
436
+ .number()
437
+ .optional()
438
+ .default(20)
439
+ .describe("max results to return"),
440
+ },
441
+ async ({ user_id, filter, collection, limit }) => {
442
+ let query = supabase
443
+ .from("shouts")
444
+ .select("*")
445
+ .eq("user_id", user_id)
446
+ .order("created_at", { ascending: false })
447
+ .limit(limit || 20);
448
+
449
+ // filter by tag (postgres array contains)
450
+ if (filter) {
451
+ query = query.contains("tags", [filter]);
452
+ }
453
+
454
+ // filter by collection slug
455
+ if (collection) {
456
+ const { data: col } = await supabase
457
+ .from("collections")
458
+ .select("id")
459
+ .eq("user_id", user_id)
460
+ .eq("slug", collection)
461
+ .single();
462
+ if (col) {
463
+ query = query.eq("collection_id", col.id);
464
+ }
465
+ }
466
+
467
+ const { data, error } = await query;
468
+
469
+ if (error) {
470
+ return {
471
+ content: [
472
+ { type: "text" as const, text: `error listing shouts: ${error.message}` },
473
+ ],
474
+ };
475
+ }
476
+
477
+ if (!data || data.length === 0) {
478
+ return {
479
+ content: [{ type: "text" as const, text: "no shouts found." }],
480
+ };
481
+ }
482
+
483
+ const lines = data.map(
484
+ (s: any, i: number) =>
485
+ `${i + 1}. ${s.title || s.url}\n ${s.summary || ""}\n tags: ${(s.tags || []).join(", ")} | ${s.visibility} | ${s.created_at}`
486
+ );
487
+
488
+ return {
489
+ content: [
490
+ { type: "text" as const, text: `${data.length} shouts:\n\n${lines.join("\n\n")}` },
491
+ ],
492
+ };
493
+ }
494
+ );
495
+
496
+ // shout_remove - delete a shout by id
497
+ server.tool(
498
+ "shout_remove",
499
+ "delete a shout by its id.",
500
+ {
501
+ id: z.string().uuid().describe("the shout id to delete"),
502
+ user_id: z.string().uuid().describe("the user's id"),
503
+ },
504
+ async ({ id, user_id }) => {
505
+ const { error } = await supabase
506
+ .from("shouts")
507
+ .delete()
508
+ .eq("id", id)
509
+ .eq("user_id", user_id);
510
+
511
+ if (error) {
512
+ return {
513
+ content: [
514
+ { type: "text" as const, text: `error removing shout: ${error.message}` },
515
+ ],
516
+ };
517
+ }
518
+
519
+ return {
520
+ content: [{ type: "text" as const, text: `shout ${id} removed.` }],
521
+ };
522
+ }
523
+ );
524
+ }