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,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
|
+
}
|