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,264 @@
1
+ import { z } from "zod";
2
+ import { supabase } from "../lib/supabase.js";
3
+ import { filterPII, filterShoutContentFull } from "../lib/content-filter.js";
4
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+
6
+ async function publishDraft(draftId: string, userId: string, text: string, tags: string[], category: string | null, collectionId: string | null, visibility: string) {
7
+ // PII filter runs again on publish (hard constraint)
8
+ const filtered = await filterShoutContentFull({ take: text, skipLLMForMetadata: true });
9
+
10
+ if (filtered.blocked) {
11
+ // update draft status to rejected with reason
12
+ await supabase.from("draft_posts").update({
13
+ status: "rejected",
14
+ filter_report: filtered.blockReason,
15
+ updated_at: new Date().toISOString(),
16
+ }).eq("id", draftId);
17
+ return { published: false, reason: filtered.blockReason };
18
+ }
19
+
20
+ const finalText = filtered.take || text;
21
+
22
+ // insert into shouts
23
+ const { data: shout, error: shoutErr } = await supabase
24
+ .from("shouts")
25
+ .insert({
26
+ user_id: userId,
27
+ url: null,
28
+ title: null,
29
+ description: finalText,
30
+ summary: finalText,
31
+ user_take: finalText,
32
+ image_url: null,
33
+ tags,
34
+ category,
35
+ collection_id: collectionId,
36
+ source: "agent_post",
37
+ visibility,
38
+ shout_type: "post",
39
+ draft_id: draftId,
40
+ })
41
+ .select()
42
+ .single();
43
+
44
+ if (shoutErr) {
45
+ return { published: false, reason: `db error: ${shoutErr.message}` };
46
+ }
47
+
48
+ // update draft status
49
+ await supabase.from("draft_posts").update({
50
+ status: "published",
51
+ published_shout_id: shout.id,
52
+ filtered_text: finalText,
53
+ filter_report: filtered.filterReport,
54
+ updated_at: new Date().toISOString(),
55
+ }).eq("id", draftId);
56
+
57
+ return { published: true, shoutId: shout.id, text: finalText };
58
+ }
59
+
60
+ export function registerPostTools(server: McpServer) {
61
+ // shout_draft_post — create a text post draft
62
+ server.tool(
63
+ "shout_draft_post",
64
+ "draft a text post for a shout page. runs PII filter. saved as pending unless auto-publish is on.",
65
+ {
66
+ user_id: z.string().uuid().describe("the user's id"),
67
+ text: z.string().max(500).describe("post content, max 500 chars"),
68
+ tags: z.array(z.string()).optional().describe("tags for the post"),
69
+ category: z.string().optional().describe("category"),
70
+ collection: z.string().optional().describe("collection slug"),
71
+ visibility: z.enum(["public", "private", "unlisted"]).optional().default("public"),
72
+ },
73
+ async ({ user_id, text, tags, category, collection, visibility }) => {
74
+ // layer 1: regex PII filter
75
+ const regexResult = filterPII(text);
76
+
77
+ // resolve collection
78
+ let collection_id: string | null = null;
79
+ if (collection) {
80
+ const { data: col } = await supabase
81
+ .from("collections")
82
+ .select("id")
83
+ .eq("user_id", user_id)
84
+ .eq("slug", collection)
85
+ .single();
86
+ if (col) collection_id = col.id;
87
+ }
88
+
89
+ // create draft
90
+ const { data: draft, error } = await supabase
91
+ .from("draft_posts")
92
+ .insert({
93
+ user_id,
94
+ text: regexResult.text,
95
+ filtered_text: regexResult.text,
96
+ tags: tags || [],
97
+ category: category || null,
98
+ collection_id,
99
+ visibility: visibility || "public",
100
+ status: "pending",
101
+ filter_report: regexResult.filtered ? `regex: ${regexResult.removals.join(", ")}` : null,
102
+ source: "agent",
103
+ })
104
+ .select()
105
+ .single();
106
+
107
+ if (error) {
108
+ return { content: [{ type: "text" as const, text: `error creating draft: ${error.message}` }] };
109
+ }
110
+
111
+ // check auto-publish setting
112
+ const { data: settings } = await supabase
113
+ .from("user_settings")
114
+ .select("auto_publish")
115
+ .eq("user_id", user_id)
116
+ .single();
117
+
118
+ if (settings?.auto_publish) {
119
+ const result = await publishDraft(
120
+ draft.id, user_id, regexResult.text, tags || [], category || null, collection_id, visibility || "public"
121
+ );
122
+ if (result.published) {
123
+ return { content: [{ type: "text" as const, text: `auto-published post: "${result.text}" (shout id: ${result.shoutId})` }] };
124
+ } else {
125
+ return { content: [{ type: "text" as const, text: `auto-publish blocked by content filter: ${result.reason}` }] };
126
+ }
127
+ }
128
+
129
+ return {
130
+ content: [{
131
+ type: "text" as const,
132
+ text: `draft created (id: ${draft.id}). status: pending approval.${regexResult.filtered ? ` PII filter applied: ${regexResult.removals.join(", ")}` : ""}\n\ntext: "${regexResult.text}"`,
133
+ }],
134
+ };
135
+ }
136
+ );
137
+
138
+ // shout_list_drafts — list draft posts
139
+ server.tool(
140
+ "shout_list_drafts",
141
+ "list draft posts by status. defaults to pending.",
142
+ {
143
+ user_id: z.string().uuid().describe("the user's id"),
144
+ status: z.enum(["pending", "approved", "rejected", "published"]).optional().default("pending"),
145
+ limit: z.number().optional().default(20),
146
+ },
147
+ async ({ user_id, status, limit }) => {
148
+ const { data, error } = await supabase
149
+ .from("draft_posts")
150
+ .select("*")
151
+ .eq("user_id", user_id)
152
+ .eq("status", status || "pending")
153
+ .order("created_at", { ascending: false })
154
+ .limit(limit || 20);
155
+
156
+ if (error) {
157
+ return { content: [{ type: "text" as const, text: `error listing drafts: ${error.message}` }] };
158
+ }
159
+
160
+ if (!data || data.length === 0) {
161
+ return { content: [{ type: "text" as const, text: `no ${status || "pending"} drafts found.` }] };
162
+ }
163
+
164
+ const lines = data.map((d: any, i: number) =>
165
+ `${i + 1}. [${d.id}] "${d.text?.substring(0, 80)}${(d.text?.length || 0) > 80 ? "..." : ""}" (${d.status}, ${new Date(d.created_at).toLocaleDateString()})`
166
+ );
167
+
168
+ return { content: [{ type: "text" as const, text: `${data.length} ${status || "pending"} drafts:\n\n${lines.join("\n")}` }] };
169
+ }
170
+ );
171
+
172
+ // shout_approve_draft — approve, reject, or edit a draft
173
+ server.tool(
174
+ "shout_approve_draft",
175
+ "approve, reject, or edit a draft post. approved posts are published to the shout page.",
176
+ {
177
+ draft_id: z.string().uuid().describe("the draft post id"),
178
+ user_id: z.string().uuid().describe("the user's id (for auth)"),
179
+ action: z.enum(["approve", "reject", "edit"]).describe("action to take"),
180
+ edited_text: z.string().max(500).optional().describe("revised text if action is edit"),
181
+ },
182
+ async ({ draft_id, user_id, action, edited_text }) => {
183
+ // fetch draft
184
+ const { data: draft, error } = await supabase
185
+ .from("draft_posts")
186
+ .select("*")
187
+ .eq("id", draft_id)
188
+ .eq("user_id", user_id)
189
+ .single();
190
+
191
+ if (error || !draft) {
192
+ return { content: [{ type: "text" as const, text: `draft not found or access denied.` }] };
193
+ }
194
+
195
+ if (draft.status !== "pending") {
196
+ return { content: [{ type: "text" as const, text: `draft is already ${draft.status}, can't ${action}.` }] };
197
+ }
198
+
199
+ if (action === "reject") {
200
+ await supabase.from("draft_posts").update({
201
+ status: "rejected",
202
+ updated_at: new Date().toISOString(),
203
+ }).eq("id", draft_id);
204
+ return { content: [{ type: "text" as const, text: `draft rejected.` }] };
205
+ }
206
+
207
+ if (action === "edit") {
208
+ if (!edited_text) {
209
+ return { content: [{ type: "text" as const, text: `edited_text is required for edit action.` }] };
210
+ }
211
+ const regexResult = filterPII(edited_text);
212
+ await supabase.from("draft_posts").update({
213
+ text: regexResult.text,
214
+ filtered_text: regexResult.text,
215
+ filter_report: regexResult.filtered ? `regex: ${regexResult.removals.join(", ")}` : null,
216
+ updated_at: new Date().toISOString(),
217
+ }).eq("id", draft_id);
218
+ return { content: [{ type: "text" as const, text: `draft updated. text: "${regexResult.text}"${regexResult.filtered ? ` (PII filtered: ${regexResult.removals.join(", ")})` : ""}` }] };
219
+ }
220
+
221
+ // approve → publish
222
+ const result = await publishDraft(
223
+ draft_id, user_id, draft.text, draft.tags || [], draft.category, draft.collection_id, draft.visibility
224
+ );
225
+
226
+ if (result.published) {
227
+ return { content: [{ type: "text" as const, text: `published! shout id: ${result.shoutId}\ntext: "${result.text}"` }] };
228
+ } else {
229
+ return { content: [{ type: "text" as const, text: `publish blocked by content filter: ${result.reason}` }] };
230
+ }
231
+ }
232
+ );
233
+
234
+ // shout_set_auto_publish — toggle auto-publish
235
+ server.tool(
236
+ "shout_set_auto_publish",
237
+ "enable or disable auto-publish for agent posts. PII filter always runs regardless.",
238
+ {
239
+ user_id: z.string().uuid().describe("the user's id"),
240
+ enabled: z.boolean().describe("true to enable auto-publish, false to disable"),
241
+ filter_level: z.enum(["strict", "standard"]).optional().default("strict"),
242
+ },
243
+ async ({ user_id, enabled, filter_level }) => {
244
+ const { error } = await supabase
245
+ .from("user_settings")
246
+ .upsert({
247
+ user_id,
248
+ auto_publish: enabled,
249
+ auto_publish_filter_level: filter_level || "strict",
250
+ }, { onConflict: "user_id" });
251
+
252
+ if (error) {
253
+ return { content: [{ type: "text" as const, text: `error updating settings: ${error.message}` }] };
254
+ }
255
+
256
+ return {
257
+ content: [{
258
+ type: "text" as const,
259
+ text: `auto-publish ${enabled ? "enabled" : "disabled"}. filter level: ${filter_level || "strict"}. PII filter always runs.`,
260
+ }],
261
+ };
262
+ }
263
+ );
264
+ }
@@ -0,0 +1,55 @@
1
+ import { z } from "zod";
2
+ import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+
4
+ // in-memory settings store for v1
5
+ // TODO: persist to supabase user_settings table
6
+ const userSettings: Map<string, Record<string, unknown>> = new Map();
7
+
8
+ export function registerSettingsTools(server: McpServer) {
9
+ // shout_settings - configure behavior
10
+ server.tool(
11
+ "shout_settings",
12
+ "configure your shout behavior preferences.",
13
+ {
14
+ user_id: z.string().uuid().describe("the user's id"),
15
+ auto_detect: z
16
+ .boolean()
17
+ .optional()
18
+ .describe("auto-detect links in conversation"),
19
+ default_visibility: z
20
+ .enum(["public", "private", "unlisted"])
21
+ .optional()
22
+ .describe("default visibility for new shouts"),
23
+ digest_frequency: z
24
+ .enum(["daily", "weekly", "monthly", "never"])
25
+ .optional()
26
+ .describe("how often to generate digests"),
27
+ },
28
+ async ({ user_id, auto_detect, default_visibility, digest_frequency }) => {
29
+ const current = userSettings.get(user_id) || {
30
+ auto_detect: true,
31
+ default_visibility: "public",
32
+ digest_frequency: "weekly",
33
+ };
34
+
35
+ if (auto_detect !== undefined) current.auto_detect = auto_detect;
36
+ if (default_visibility !== undefined)
37
+ current.default_visibility = default_visibility;
38
+ if (digest_frequency !== undefined)
39
+ current.digest_frequency = digest_frequency;
40
+
41
+ userSettings.set(user_id, current);
42
+
43
+ return {
44
+ content: [
45
+ {
46
+ type: "text" as const,
47
+ text: `settings updated:\n- auto_detect: ${current.auto_detect}\n- default_visibility: ${current.default_visibility}\n- digest_frequency: ${current.digest_frequency}`,
48
+ },
49
+ ],
50
+ };
51
+ }
52
+ );
53
+
54
+ // shout_generate_digest is registered in links.ts
55
+ }
@@ -0,0 +1,95 @@
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 type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
6
+
7
+ export const shoutAgentCurateSchema = z.object({
8
+ url: z.string().url().describe("The URL to save"),
9
+ agent_take: z
10
+ .string()
11
+ .optional()
12
+ .describe("The agent's own commentary or opinion on why this link is interesting"),
13
+ tags: z
14
+ .array(z.string())
15
+ .optional()
16
+ .describe("Override auto-generated tags with these tags"),
17
+ collection_id: z
18
+ .string()
19
+ .uuid()
20
+ .optional()
21
+ .describe("Put the shout in a specific collection by ID"),
22
+ user_id: z.string().uuid().describe("The user's profile ID (fubz)"),
23
+ });
24
+
25
+ export type ShoutAgentCurateInput = z.infer<typeof shoutAgentCurateSchema>;
26
+
27
+ export async function shoutAgentCurate(input: ShoutAgentCurateInput) {
28
+ // 1. Fetch metadata
29
+ const meta = await extractMetadata(input.url);
30
+
31
+ // 2. AI summary + tags + category (all returned from generateSummary)
32
+ const aiResult = await generateSummary({
33
+ url: input.url,
34
+ title: meta.title,
35
+ description: meta.description,
36
+ bodyText: meta.bodyText,
37
+ userContext: input.agent_take ?? null,
38
+ });
39
+
40
+ const tags = input.tags ?? aiResult.tags;
41
+ const category = aiResult.category;
42
+ const summary = aiResult.summary;
43
+
44
+ // 3. PII filter before saving
45
+ const { filterShoutContent } = await import("../lib/content-filter.js");
46
+ const filtered = filterShoutContent({
47
+ take: input.agent_take ?? null,
48
+ summary,
49
+ title: meta.title,
50
+ description: meta.description,
51
+ });
52
+ if (filtered.filterReport) {
53
+ console.log(`[nod-shout] agent curate filter: ${filtered.filterReport}`);
54
+ }
55
+
56
+ // 4. Insert shout with source "agent_curated"
57
+ const { data, error } = await supabase
58
+ .from("shouts")
59
+ .insert({
60
+ user_id: input.user_id,
61
+ url: input.url,
62
+ title: filtered.title,
63
+ description: filtered.description,
64
+ summary: filtered.summary,
65
+ user_take: filtered.take,
66
+ image_url: meta.image_url,
67
+ tags,
68
+ category,
69
+ collection_id: input.collection_id ?? null,
70
+ source: "agent_curated",
71
+ agent_context: input.agent_take ? `Agent curated: ${input.agent_take}` : "Agent curated link",
72
+ visibility: "public",
73
+ })
74
+ .select()
75
+ .single();
76
+
77
+ if (error) throw new Error(`Failed to save agent-curated shout: ${error.message}`);
78
+
79
+ return {
80
+ message: `Agent curated! "${meta.title}" saved.`,
81
+ shout: data,
82
+ };
83
+ }
84
+
85
+ export function registerAgentCurateTools(server: McpServer) {
86
+ server.tool(
87
+ "shout_agent_curate",
88
+ "Save a link to the agent's shout page. Use this when you find an interesting link worth sharing — from conversations, research, or browsing. The agent curates its own page.",
89
+ shoutAgentCurateSchema.shape,
90
+ async (params) => {
91
+ const result = await shoutAgentCurate(params as ShoutAgentCurateInput);
92
+ return { content: [{ type: "text", text: JSON.stringify(result) }] };
93
+ }
94
+ );
95
+ }
@@ -0,0 +1,206 @@
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 registerSocialTools(server: McpServer) {
6
+ // shout_follow - subscribe to a user's shouts
7
+ server.tool(
8
+ "shout_follow",
9
+ "follow another user's shouts. optionally follow a specific collection.",
10
+ {
11
+ user_id: z.string().uuid().describe("your user id"),
12
+ username: z.string().describe("username to follow"),
13
+ collection: z
14
+ .string()
15
+ .optional()
16
+ .describe("specific collection slug to follow"),
17
+ },
18
+ async ({ user_id, username, collection }) => {
19
+ // resolve username to user id
20
+ const { data: profile } = await supabase
21
+ .from("profiles")
22
+ .select("id")
23
+ .eq("username", username)
24
+ .single();
25
+
26
+ if (!profile) {
27
+ return {
28
+ content: [
29
+ { type: "text" as const, text: `user "${username}" not found.` },
30
+ ],
31
+ };
32
+ }
33
+
34
+ // resolve collection if provided
35
+ let collection_id: string | null = null;
36
+ if (collection) {
37
+ const { data: col } = await supabase
38
+ .from("collections")
39
+ .select("id")
40
+ .eq("user_id", profile.id)
41
+ .eq("slug", collection)
42
+ .single();
43
+ collection_id = col?.id || null;
44
+ }
45
+
46
+ // check if already following
47
+ let existingQuery = supabase
48
+ .from("subscriptions")
49
+ .select("id")
50
+ .eq("follower_id", user_id)
51
+ .eq("following_id", profile.id);
52
+
53
+ if (collection_id) {
54
+ existingQuery = existingQuery.eq("collection_id", collection_id);
55
+ } else {
56
+ existingQuery = existingQuery.is("collection_id", null);
57
+ }
58
+
59
+ const { data: existing } = await existingQuery.single();
60
+
61
+ if (existing) {
62
+ return {
63
+ content: [
64
+ { type: "text" as const, text: `you're already following ${username}${collection ? ` (${collection})` : ""}.` },
65
+ ],
66
+ };
67
+ }
68
+
69
+ const { error } = await supabase.from("subscriptions").insert({
70
+ follower_id: user_id,
71
+ following_id: profile.id,
72
+ collection_id,
73
+ });
74
+
75
+ if (error) {
76
+ return {
77
+ content: [
78
+ { type: "text" as const, text: `error following: ${error.message}` },
79
+ ],
80
+ };
81
+ }
82
+
83
+ return {
84
+ content: [
85
+ {
86
+ type: "text" as const,
87
+ text: `now following ${username}${collection ? ` (${collection})` : ""}!`,
88
+ },
89
+ ],
90
+ };
91
+ }
92
+ );
93
+
94
+ // shout_unfollow
95
+ server.tool(
96
+ "shout_unfollow",
97
+ "unfollow a user's shouts.",
98
+ {
99
+ user_id: z.string().uuid().describe("your user id"),
100
+ username: z.string().describe("username to unfollow"),
101
+ },
102
+ async ({ user_id, username }) => {
103
+ const { data: profile } = await supabase
104
+ .from("profiles")
105
+ .select("id")
106
+ .eq("username", username)
107
+ .single();
108
+
109
+ if (!profile) {
110
+ return {
111
+ content: [
112
+ { type: "text" as const, text: `user "${username}" not found.` },
113
+ ],
114
+ };
115
+ }
116
+
117
+ const { error } = await supabase
118
+ .from("subscriptions")
119
+ .delete()
120
+ .eq("follower_id", user_id)
121
+ .eq("following_id", profile.id);
122
+
123
+ if (error) {
124
+ return {
125
+ content: [
126
+ { type: "text" as const, text: `error unfollowing: ${error.message}` },
127
+ ],
128
+ };
129
+ }
130
+
131
+ return {
132
+ content: [
133
+ { type: "text" as const, text: `unfollowed ${username}.` },
134
+ ],
135
+ };
136
+ }
137
+ );
138
+
139
+ // shout_feed - aggregated feed from followed users
140
+ server.tool(
141
+ "shout_feed",
142
+ "get an aggregated feed of shouts from users you follow.",
143
+ {
144
+ user_id: z.string().uuid().describe("your user id"),
145
+ limit: z.number().optional().default(20).describe("max results"),
146
+ },
147
+ async ({ user_id, limit }) => {
148
+ // get list of followed user ids
149
+ const { data: subs } = await supabase
150
+ .from("subscriptions")
151
+ .select("following_id, collection_id")
152
+ .eq("follower_id", user_id);
153
+
154
+ if (!subs || subs.length === 0) {
155
+ return {
156
+ content: [
157
+ {
158
+ type: "text" as const,
159
+ text: "you're not following anyone yet. use shout_follow to subscribe to someone's shouts.",
160
+ },
161
+ ],
162
+ };
163
+ }
164
+
165
+ const followingIds = subs.map((s: any) => s.following_id);
166
+
167
+ const { data, error } = await supabase
168
+ .from("shouts")
169
+ .select("*, profiles!inner(username, display_name)")
170
+ .in("user_id", followingIds)
171
+ .eq("visibility", "public")
172
+ .order("created_at", { ascending: false })
173
+ .limit(limit || 20);
174
+
175
+ if (error) {
176
+ return {
177
+ content: [
178
+ { type: "text" as const, text: `error fetching feed: ${error.message}` },
179
+ ],
180
+ };
181
+ }
182
+
183
+ if (!data || data.length === 0) {
184
+ return {
185
+ content: [
186
+ { type: "text" as const, text: "no shouts in your feed yet." },
187
+ ],
188
+ };
189
+ }
190
+
191
+ const lines = data.map((s: any, i: number) => {
192
+ const who = (s as any).profiles?.username || "unknown";
193
+ return `${i + 1}. @${who}: ${s.title || s.url}\n ${s.summary || ""}\n tags: ${(s.tags || []).join(", ")} | ${s.created_at}`;
194
+ });
195
+
196
+ return {
197
+ content: [
198
+ {
199
+ type: "text" as const,
200
+ text: `feed (${data.length} shouts):\n\n${lines.join("\n\n")}`,
201
+ },
202
+ ],
203
+ };
204
+ }
205
+ );
206
+ }
package/src/types.ts ADDED
@@ -0,0 +1,66 @@
1
+ // shared types for nod-shout mcp server
2
+
3
+ export interface Profile {
4
+ id: string;
5
+ username: string;
6
+ display_name: string | null;
7
+ bio: string | null;
8
+ avatar_url: string | null;
9
+ created_at: string;
10
+ updated_at: string;
11
+ }
12
+
13
+ export interface Shout {
14
+ id: string;
15
+ user_id: string;
16
+ url: string;
17
+ title: string | null;
18
+ description: string | null;
19
+ summary: string | null;
20
+ user_take: string | null;
21
+ image_url: string | null;
22
+ tags: string[] | null;
23
+ category: string | null;
24
+ collection_id: string | null;
25
+ source: string;
26
+ agent_context: string | null;
27
+ visibility: "public" | "private" | "unlisted";
28
+ created_at: string;
29
+ updated_at: string;
30
+ }
31
+
32
+ export interface Collection {
33
+ id: string;
34
+ user_id: string;
35
+ name: string;
36
+ description: string | null;
37
+ slug: string;
38
+ visibility: "public" | "private" | "unlisted";
39
+ auto_rules: Record<string, unknown> | null;
40
+ created_at: string;
41
+ updated_at: string;
42
+ }
43
+
44
+ export interface Subscription {
45
+ id: string;
46
+ follower_id: string;
47
+ following_id: string;
48
+ collection_id: string | null;
49
+ notify: boolean;
50
+ created_at: string;
51
+ }
52
+
53
+ export interface PageMetadata {
54
+ title: string | null;
55
+ description: string | null;
56
+ image_url: string | null;
57
+ author: string | null;
58
+ date: string | null;
59
+ bodyText: string | null;
60
+ }
61
+
62
+ export interface AISummaryResult {
63
+ summary: string;
64
+ tags: string[];
65
+ category: string;
66
+ }
@@ -0,0 +1 @@
1
+ v2.78.1
@@ -0,0 +1 @@
1
+ v2.188.1
@@ -0,0 +1 @@
1
+ postgresql://postgres.ooykzbkcquvreeheaijy@aws-1-us-east-1.pooler.supabase.com:5432/postgres
@@ -0,0 +1 @@
1
+ 17.6.1.084
@@ -0,0 +1 @@
1
+ ooykzbkcquvreeheaijy