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.
- package/README.md +82 -0
- package/TASK-AGENT-POSTS.md +112 -0
- package/assets/shout-default.svg +5 -0
- package/bin/shout +68 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +29 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/ai.d.ts +13 -0
- package/dist/lib/ai.d.ts.map +1 -0
- package/dist/lib/ai.js +135 -0
- package/dist/lib/ai.js.map +1 -0
- package/dist/lib/content-filter.d.ts +74 -0
- package/dist/lib/content-filter.d.ts.map +1 -0
- package/dist/lib/content-filter.js +188 -0
- package/dist/lib/content-filter.js.map +1 -0
- package/dist/lib/context-extractor.d.ts +39 -0
- package/dist/lib/context-extractor.d.ts.map +1 -0
- package/dist/lib/context-extractor.js +170 -0
- package/dist/lib/context-extractor.js.map +1 -0
- package/dist/lib/match-engine.d.ts +31 -0
- package/dist/lib/match-engine.d.ts.map +1 -0
- package/dist/lib/match-engine.js +322 -0
- package/dist/lib/match-engine.js.map +1 -0
- package/dist/lib/metadata.d.ts +7 -0
- package/dist/lib/metadata.d.ts.map +1 -0
- package/dist/lib/metadata.js +311 -0
- package/dist/lib/metadata.js.map +1 -0
- package/dist/lib/skills.d.ts +3 -0
- package/dist/lib/skills.d.ts.map +1 -0
- package/dist/lib/skills.js +20 -0
- package/dist/lib/skills.js.map +1 -0
- package/dist/lib/supabase.d.ts +2 -0
- package/dist/lib/supabase.d.ts.map +1 -0
- package/dist/lib/supabase.js +8 -0
- package/dist/lib/supabase.js.map +1 -0
- package/dist/tools/collections.d.ts +3 -0
- package/dist/tools/collections.d.ts.map +1 -0
- package/dist/tools/collections.js +142 -0
- package/dist/tools/collections.js.map +1 -0
- package/dist/tools/intros.d.ts +3 -0
- package/dist/tools/intros.d.ts.map +1 -0
- package/dist/tools/intros.js +483 -0
- package/dist/tools/intros.js.map +1 -0
- package/dist/tools/links.d.ts +3 -0
- package/dist/tools/links.d.ts.map +1 -0
- package/dist/tools/links.js +424 -0
- package/dist/tools/links.js.map +1 -0
- package/dist/tools/posts.d.ts +3 -0
- package/dist/tools/posts.d.ts.map +1 -0
- package/dist/tools/posts.js +212 -0
- package/dist/tools/posts.js.map +1 -0
- package/dist/tools/settings.d.ts +3 -0
- package/dist/tools/settings.d.ts.map +1 -0
- package/dist/tools/settings.js +45 -0
- package/dist/tools/settings.js.map +1 -0
- package/dist/tools/shout_agent_curate.d.ts +28 -0
- package/dist/tools/shout_agent_curate.d.ts.map +1 -0
- package/dist/tools/shout_agent_curate.js +80 -0
- package/dist/tools/shout_agent_curate.js.map +1 -0
- package/dist/tools/social.d.ts +3 -0
- package/dist/tools/social.d.ts.map +1 -0
- package/dist/tools/social.js +169 -0
- package/dist/tools/social.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +24 -0
- package/quick-test.ts +22 -0
- package/regenerate-summaries.ts +111 -0
- package/save-jeffries-shout.ts +38 -0
- package/save-openai-shout.ts +35 -0
- package/save-prcarly.ts +46 -0
- package/save-shout.ts +35 -0
- package/save-techcrunch-shout.ts +59 -0
- package/save-zdnet-shout.ts +36 -0
- package/skills/collection-routing/SKILL.md +34 -0
- package/skills/link-summary/SKILL.md +53 -0
- package/skills/tagging-and-routing/SKILL.md +54 -0
- package/src/index.ts +32 -0
- package/src/lib/ai.ts +166 -0
- package/src/lib/content-filter.ts +258 -0
- package/src/lib/metadata.ts +353 -0
- package/src/lib/skills.ts +21 -0
- package/src/lib/supabase.ts +12 -0
- package/src/tools/collections.ts +182 -0
- package/src/tools/links.ts +524 -0
- package/src/tools/posts.ts +264 -0
- package/src/tools/settings.ts +55 -0
- package/src/tools/shout_agent_curate.ts +95 -0
- package/src/tools/social.ts +206 -0
- package/src/types.ts +66 -0
- package/supabase/.temp/cli-latest +1 -0
- package/supabase/.temp/gotrue-version +1 -0
- package/supabase/.temp/pooler-url +1 -0
- package/supabase/.temp/postgres-version +1 -0
- package/supabase/.temp/project-ref +1 -0
- package/supabase/.temp/rest-version +1 -0
- package/supabase/.temp/storage-migration +1 -0
- package/supabase/.temp/storage-version +1 -0
- package/supabase/migrations/001_initial_schema.sql +147 -0
- package/supabase/migrations/20260317010000_decouple_profiles_from_auth.sql +9 -0
- package/supabase/migrations/20260317020000_agent_curation.sql +10 -0
- package/supabase/migrations/20260320000000_agent_posts.sql +41 -0
- package/supabase/migrations/20260320120000_fix_draft_fk.sql +2 -0
- package/supabase/migrations/20260320130000_fix_identity.sql +17 -0
- package/supabase/migrations/20260320170000_intros.sql +118 -0
- package/test-model-comparison.ts +89 -0
- 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
|