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,38 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { readFileSync } from 'fs';
3
+ import { extractMetadata } from './src/lib/metadata.js';
4
+ import { generateSummary } from './src/lib/ai.js';
5
+
6
+ async function main() {
7
+ const env = Object.fromEntries(
8
+ readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => {
9
+ const [k, ...v] = l.split('=');
10
+ return [k.trim(), v.join('=').trim()];
11
+ })
12
+ );
13
+ process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
14
+ const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
15
+ const { data: profile } = await supabase.from('profiles').select('id').eq('username', 'fubz').single();
16
+ if (!profile) throw new Error('profile not found');
17
+
18
+ const url = 'https://x.com/Dan_Jeffries1/status/2034916790711538061';
19
+
20
+ // tweet metadata - we have the full text already
21
+ const title = 'Dan Jeffries: OpenClaw is a Napster moment, not a DeepSeek moment';
22
+ const description = 'dan jeffries argues openclaw is the napster of software — it forces vendors to open up API access for agents or get routed around. just like napster forced the music industry to build itunes and spotify, openclaw hacks around locked-down platforms (whatsapp, google ads, etc) to give people the unified agent access vendors refuse to provide.';
23
+ const summary = description;
24
+ const image_url = null;
25
+ const tags = ['ai-agents', 'openclaw', 'open-source', 'platform-access', 'napster-analogy'];
26
+ const category = 'ai';
27
+
28
+ const { data, error } = await supabase.from('shouts').insert({
29
+ user_id: profile.id, url, title, description,
30
+ summary, user_take: null, image_url,
31
+ tags, category, collection_id: null,
32
+ source: 'conversation', visibility: 'public',
33
+ }).select('id,title,summary,tags,category').single();
34
+
35
+ if (error) throw error;
36
+ console.log(JSON.stringify(data, null, 2));
37
+ }
38
+ main().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,35 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { readFileSync } from 'fs';
3
+ import { extractMetadata } from './src/lib/metadata.js';
4
+ import { generateSummary } from './src/lib/ai.js';
5
+
6
+ async function main() {
7
+ const env = Object.fromEntries(
8
+ readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => {
9
+ const [k, ...v] = l.split('=');
10
+ return [k.trim(), v.join('=').trim()];
11
+ })
12
+ );
13
+ process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
14
+ const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
15
+ const { data: profile } = await supabase.from('profiles').select('id').eq('username', 'fubz').single();
16
+ if (!profile) throw new Error('profile not found');
17
+
18
+ const url = 'https://openai.com/index/introducing-gpt-5-4-mini-and-nano/';
19
+ const metadata = await extractMetadata(url);
20
+ const aiResult = await generateSummary({
21
+ url, title: metadata.title, description: metadata.description,
22
+ bodyText: metadata.bodyText, userContext: null,
23
+ });
24
+
25
+ const { data, error } = await supabase.from('shouts').insert({
26
+ user_id: profile.id, url, title: metadata.title, description: metadata.description,
27
+ summary: aiResult.summary, user_take: null, image_url: metadata.image_url,
28
+ tags: aiResult.tags, category: aiResult.category, collection_id: null,
29
+ source: 'conversation', visibility: 'public',
30
+ }).select('id,title,summary,tags,category').single();
31
+
32
+ if (error) throw error;
33
+ console.log(JSON.stringify(data, null, 2));
34
+ }
35
+ main().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,46 @@
1
+ import { extractMetadata } from './src/lib/metadata.js';
2
+ import { generateSummary } from './src/lib/ai.js';
3
+ import { createClient } from '@supabase/supabase-js';
4
+ import { readFileSync } from 'fs';
5
+
6
+ const env = Object.fromEntries(
7
+ readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => {
8
+ const [k, ...v] = l.split('=');
9
+ return [k.trim(), v.join('=').trim()];
10
+ })
11
+ );
12
+ process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
13
+ const sb = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
14
+
15
+ async function main() {
16
+ const { data: profile } = await sb.from('profiles').select('id').eq('username', 'fubz').single();
17
+ if (!profile) throw new Error('no profile');
18
+
19
+ const url = 'https://x.com/PRcarly/status/2034281879265018059';
20
+ const ai = await generateSummary({
21
+ url,
22
+ title: 'PR strategy got a trademark attorney on Google News above USA Today',
23
+ description: '311 earned media placements in 18 months from one repeatable strategy: spot news early, pitch expert commentary first.',
24
+ bodyText: 'Trademark attorney Josh Gerben built 311 earned media placements in 18 months from one strategy: spot trademark filings early, pitch expert commentary before anyone else. PR became his only marketing channel. His blog now appears in Google News above USA Today. Google algorithm looks for expertise, authority, trustworthiness. When 311 articles from major news outlets point back to the same domain, that is an overwhelming authority signal.',
25
+ userContext: 'Jeff shared this as a playbook for HypeLab/OCA and Remitian PR strategies',
26
+ });
27
+
28
+ const { data, error } = await sb.from('shouts').insert({
29
+ user_id: profile.id,
30
+ url,
31
+ title: 'PR strategy got a trademark attorney on Google News above USA Today',
32
+ description: '311 earned media placements in 18 months from one repeatable strategy.',
33
+ summary: ai.summary,
34
+ user_take: 'Direct playbook for what we are doing with HypeLab/OCA (AI agent news commentary) and Remitian (tax payment regulatory commentary). Same structure: expert spots news early, pitches take, domain authority compounds.',
35
+ image_url: 'https://pbs.twimg.com/media/HDs4loIawAEZfII.jpg',
36
+ tags: ai.tags,
37
+ category: ai.category || 'growth',
38
+ collection_id: '233be2f1-e79a-4d4b-aea4-e8c6b0e6dc1e',
39
+ source: 'conversation',
40
+ visibility: 'public',
41
+ }).select('id,title').single();
42
+
43
+ console.log(error ? error.message : 'Saved: ' + data.title);
44
+ }
45
+
46
+ main();
package/save-shout.ts ADDED
@@ -0,0 +1,35 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { readFileSync } from 'fs';
3
+ import { extractMetadata } from './src/lib/metadata.js';
4
+ import { generateSummary } from './src/lib/ai.js';
5
+
6
+ async function main() {
7
+ const env = Object.fromEntries(
8
+ readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => {
9
+ const [k, ...v] = l.split('=');
10
+ return [k.trim(), v.join('=').trim()];
11
+ })
12
+ );
13
+ process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
14
+ const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
15
+ const { data: profile } = await supabase.from('profiles').select('id').eq('username', 'fubz').single();
16
+ if (!profile) throw new Error('profile not found');
17
+
18
+ const url = 'https://techcrunch.com/2026/03/16/merriam-webster-openai-encyclopedia-brittanica-lawsuit/';
19
+ const metadata = await extractMetadata(url);
20
+ const aiResult = await generateSummary({
21
+ url, title: metadata.title, description: metadata.description,
22
+ bodyText: metadata.bodyText, userContext: null,
23
+ });
24
+
25
+ const { data, error } = await supabase.from('shouts').insert({
26
+ user_id: profile.id, url, title: metadata.title, description: metadata.description,
27
+ summary: aiResult.summary, user_take: null, image_url: metadata.image_url,
28
+ tags: aiResult.tags, category: aiResult.category, collection_id: null,
29
+ source: 'conversation', visibility: 'public',
30
+ }).select('id,title,summary,tags,category').single();
31
+
32
+ if (error) throw error;
33
+ console.log(JSON.stringify(data, null, 2));
34
+ }
35
+ main().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,59 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { readFileSync } from 'fs';
3
+ import { extractMetadata } from './src/lib/metadata.js';
4
+ import { generateSummary } from './src/lib/ai.js';
5
+
6
+ async function main() {
7
+ const env = Object.fromEntries(
8
+ readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => {
9
+ const [k, ...v] = l.split('=');
10
+ return [k.trim(), v.join('=').trim()];
11
+ })
12
+ );
13
+
14
+ process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
15
+
16
+ const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
17
+ const username = 'fubz';
18
+ const url = 'https://techcrunch.com/2026/03/17/buzzfeed-ai-slop-apps-sxsw-bf-island-conjure/';
19
+ const take = 'hilarious and brutal. the apps sound terrible, and the review does not let them off easy.';
20
+
21
+ const { data: profile } = await supabase.from('profiles').select('id').eq('username', username).single();
22
+ if (!profile) throw new Error('profile not found');
23
+
24
+ const metadata = await extractMetadata(url);
25
+ const aiResult = await generateSummary({
26
+ url,
27
+ title: metadata.title,
28
+ description: metadata.description,
29
+ bodyText: metadata.bodyText,
30
+ userContext: take,
31
+ });
32
+
33
+ const { data, error } = await supabase
34
+ .from('shouts')
35
+ .insert({
36
+ user_id: profile.id,
37
+ url,
38
+ title: metadata.title,
39
+ description: metadata.description,
40
+ summary: aiResult.summary,
41
+ user_take: take,
42
+ image_url: metadata.image_url,
43
+ tags: aiResult.tags,
44
+ category: aiResult.category,
45
+ collection_id: null,
46
+ source: 'conversation',
47
+ visibility: 'public',
48
+ })
49
+ .select('id,title,summary,tags,category')
50
+ .single();
51
+
52
+ if (error) throw error;
53
+ console.log(JSON.stringify({ metadata, aiResult, saved: data }, null, 2));
54
+ }
55
+
56
+ main().catch((err) => {
57
+ console.error(err);
58
+ process.exit(1);
59
+ });
@@ -0,0 +1,36 @@
1
+ import { createClient } from '@supabase/supabase-js';
2
+ import { readFileSync } from 'fs';
3
+ import { extractMetadata } from './src/lib/metadata.js';
4
+ import { generateSummary } from './src/lib/ai.js';
5
+
6
+ async function main() {
7
+ const env = Object.fromEntries(
8
+ readFileSync('.env', 'utf8').split('\n').filter(l => l.includes('=')).map(l => {
9
+ const [k, ...v] = l.split('=');
10
+ return [k.trim(), v.join('=').trim()];
11
+ })
12
+ );
13
+ process.env.OPENAI_API_KEY = env.OPENAI_API_KEY;
14
+ const supabase = createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY);
15
+
16
+ const { data: profile } = await supabase.from('profiles').select('id').eq('username', 'fubz').single();
17
+ if (!profile) throw new Error('profile not found');
18
+
19
+ const url = 'https://www.zdnet.com/article/gpt-5-4-mini-and-nano/';
20
+ const metadata = await extractMetadata(url);
21
+ const aiResult = await generateSummary({
22
+ url, title: metadata.title, description: metadata.description,
23
+ bodyText: metadata.bodyText, userContext: null,
24
+ });
25
+
26
+ const { data, error } = await supabase.from('shouts').insert({
27
+ user_id: profile.id, url, title: metadata.title, description: metadata.description,
28
+ summary: aiResult.summary, user_take: null, image_url: metadata.image_url,
29
+ tags: aiResult.tags, category: aiResult.category, collection_id: null,
30
+ source: 'conversation', visibility: 'public',
31
+ }).select('id,title,summary,tags,category').single();
32
+
33
+ if (error) throw error;
34
+ console.log(JSON.stringify(data, null, 2));
35
+ }
36
+ main().catch(e => { console.error(e); process.exit(1); });
@@ -0,0 +1,34 @@
1
+ ---
2
+ name: collection-routing
3
+ description: Use when deciding which collection a nod link belongs in. Helps map summaries and tags to stable collection intents.
4
+ ---
5
+
6
+ # Collection Routing
7
+
8
+ Goal: make links easier to file into the right collection.
9
+
10
+ ## Routing principles
11
+
12
+ - Think in terms of the link's main use, not every topic it mentions.
13
+ - Prefer the narrowest good fit.
14
+ - Do not spray broad tags just to increase match rate.
15
+ - If a link is mainly a tool or repo, prefer devtools over ai.
16
+ - If a link is mainly a protocol, spec, or agent system, prefer agents.
17
+ - If a link is mainly about exploits, prompt injection, auth, or abuse, prefer security.
18
+ - If a link is mainly about ad strategy, SEO, GEO, AEO, or distribution, prefer marketing.
19
+ - If a link is mainly about social products, messaging, creator tools, or community apps, prefer social.
20
+
21
+ ## Common routing examples
22
+
23
+ - MCP, A2A, agents.json, JSON agents, agent manifests, agent skills -> agents
24
+ - Claude Code tools, SDKs, CLIs, IDE integrations, GitHub repos -> devtools
25
+ - Prompt injection, jailbreaks, auth issues, exploits -> security
26
+ - GEO/AEO/SEO, ads, funnels, growth loops -> marketing
27
+ - Group chat, feeds, audio rooms, creator/community products -> social
28
+ - Pricing, SaaS margins, revenue, markets -> startups or finance depending on emphasis
29
+
30
+ ## Output behavior
31
+
32
+ - Tags should support routing.
33
+ - Category should reflect the primary route.
34
+ - When in doubt, choose one strong route and keep the rest in tags.
@@ -0,0 +1,53 @@
1
+ ---
2
+ name: link-summary
3
+ description: Use when summarizing a saved link for nod shout pages. Writes a short, specific preview that helps someone decide whether to click.
4
+ ---
5
+
6
+ # Link Summary
7
+
8
+ Goal: write a short summary that gives the reader a taste of the link before they click.
9
+
10
+ ## Output rules
11
+
12
+ - Write 2 short sentences.
13
+ - Sentence 1: say what the thing is or what happened.
14
+ - Sentence 2: give a reason to click or a key detail.
15
+ - If the page includes a concrete claim, number, company, feature, or person, include it.
16
+ - Use plain English. Write like you're texting a friend.
17
+ - Prefer direct nouns and verbs over adjectives.
18
+ - Keep it under 240 characters when possible.
19
+
20
+ ## Banned openings and phrases
21
+
22
+ Never start a summary with:
23
+ - "this article discusses"
24
+ - "this post is about"
25
+ - "the page explains"
26
+ - "the post discusses"
27
+ - "X tweeted that"
28
+ - "post by"
29
+ - "page"
30
+ - "clicking gives"
31
+ - "clicking provides"
32
+ - "clicking reveals"
33
+
34
+ Never use:
35
+ - "not just X but Y" or "isn't just X"
36
+ - "thought-provoking", "must-read", "fascinating", "interesting", "insightful"
37
+ - generic praise or vague claims about innovation or the future
38
+ - marketing tone
39
+
40
+ ## Good examples
41
+
42
+ - "Jeff Bezos calls AI a horizontal layer like electricity. Companies should treat it as core infrastructure, not a side feature."
43
+ - "Menu bar app for tracking Codex and Claude Code usage stats. Shows supported providers and how limits work."
44
+ - "Simon Willison breaks down prompt injection risks in MCP-style systems. Concrete examples of where tool-using agents can be tricked."
45
+ - "Claude now supports live stock quotes and options chains in chat. Shows what data is available and how to wire it up."
46
+
47
+ ## Bad examples
48
+
49
+ - "A fascinating look at the evolving AI landscape."
50
+ - "This article provides insight into the future of business and technology."
51
+ - "A must-read for anyone interested in AI."
52
+ - "Post by Jeff Bezos defines AI as a horizontal enabling layer. Clicking reveals his argument."
53
+ - "The page explains how agent discovery works in the protocol."
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: tagging-and-routing
3
+ description: Use when generating tags and a category for a saved nod link. Keep taxonomy tight and useful for filtering and collection routing.
4
+ ---
5
+
6
+ # Tagging and Routing
7
+
8
+ Goal: produce a small, useful set of tags and one category.
9
+
10
+ ## Tag rules
11
+
12
+ - Return 3 to 5 tags.
13
+ - Tags must be lowercase.
14
+ - Prefer specific nouns over broad abstractions.
15
+ - Use short hyphenated phrases only when a single word loses meaning.
16
+ - Do not use duplicate or near-duplicate tags.
17
+ - Do not use empty tags like "tech", "news", "innovation", "business" unless the link is truly generic.
18
+
19
+ ## Category rules
20
+
21
+ Pick exactly one category from this list when possible:
22
+ - ai
23
+ - agents
24
+ - devtools
25
+ - design
26
+ - growth
27
+ - marketing
28
+ - startups
29
+ - media
30
+ - social
31
+ - product
32
+ - engineering
33
+ - research
34
+ - security
35
+ - finance
36
+ - crypto
37
+ - policy
38
+
39
+ If nothing fits, use: uncategorized
40
+
41
+ ## Mapping hints
42
+
43
+ - MCP, A2A, agent specs, agent discovery, agent infra -> agents
44
+ - Claude Code, IDE tools, SDKs, APIs, GitHub repos for developers -> devtools
45
+ - prompt injection, auth flaws, exploits, abuse -> security
46
+ - ad strategy, distribution, SEO, GEO, AEO, funnels -> marketing
47
+ - social apps, creator products, messaging, feeds, community products -> social
48
+ - funding, pricing, SaaS economics, revenue, markets -> startups or finance depending on focus
49
+
50
+ ## Output quality
51
+
52
+ The tags should help a real person filter saved links later.
53
+ Bad: ["technology", "future", "innovation"]
54
+ Good: ["mcp", "prompt-injection", "tool-use", "security"]
package/src/index.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { registerLinkTools } from "./tools/links.js";
4
+ import { registerCollectionTools } from "./tools/collections.js";
5
+ import { registerSocialTools } from "./tools/social.js";
6
+ import { registerSettingsTools } from "./tools/settings.js";
7
+ import { registerPostTools } from "./tools/posts.js";
8
+ // agent curate tool is registered inside registerLinkTools
9
+
10
+ const server = new McpServer({
11
+ name: "nod-shout",
12
+ version: "0.1.0",
13
+ });
14
+
15
+ // register all tools
16
+ registerLinkTools(server);
17
+ registerCollectionTools(server);
18
+ registerSocialTools(server);
19
+ registerSettingsTools(server);
20
+ registerPostTools(server);
21
+
22
+ // start the server on stdio transport
23
+ async function main() {
24
+ const transport = new StdioServerTransport();
25
+ await server.connect(transport);
26
+ console.error("nod-shout mcp server running on stdio");
27
+ }
28
+
29
+ main().catch((err) => {
30
+ console.error("fatal error:", err);
31
+ process.exit(1);
32
+ });
package/src/lib/ai.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { AISummaryResult } from "../types.js";
2
+ import { loadSkills } from "./skills.js";
3
+
4
+ const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
5
+ const SKILL_CONTEXT = loadSkills(["link-summary", "tagging-and-routing", "collection-routing"]);
6
+ const ALLOWED_CATEGORIES = new Set([
7
+ "ai",
8
+ "agents",
9
+ "devtools",
10
+ "design",
11
+ "growth",
12
+ "marketing",
13
+ "startups",
14
+ "media",
15
+ "social",
16
+ "product",
17
+ "engineering",
18
+ "research",
19
+ "security",
20
+ "finance",
21
+ "crypto",
22
+ "policy",
23
+ "uncategorized",
24
+ ]);
25
+
26
+ /**
27
+ * generate a summary, tags, and category for a link using gpt-4.1-mini.
28
+ * falls back to basic extraction if no api key or on error.
29
+ */
30
+ export async function generateSummary(params: {
31
+ url: string;
32
+ title: string | null;
33
+ description: string | null;
34
+ bodyText: string | null;
35
+ userContext: string | null;
36
+ }): Promise<AISummaryResult> {
37
+ const { url, title, description, bodyText, userContext } = params;
38
+
39
+ // try ai summarization if key is available
40
+ if (OPENAI_API_KEY) {
41
+ try {
42
+ return await aiSummarize(url, title, description, bodyText, userContext);
43
+ } catch (err) {
44
+ console.error("ai summary failed, falling back to basic extraction:", err);
45
+ }
46
+ }
47
+
48
+ // fallback: basic extraction
49
+ return basicExtract(title, description);
50
+ }
51
+
52
+ async function aiSummarize(
53
+ url: string,
54
+ title: string | null,
55
+ description: string | null,
56
+ bodyText: string | null,
57
+ userContext: string | null
58
+ ): Promise<AISummaryResult> {
59
+ const prompt = `You are generating data for a nod shout card.${SKILL_CONTEXT}
60
+
61
+ Follow the skills above. Output one short summary, 3-5 tags, and one category.
62
+
63
+ Hard rules:
64
+ - summary should help someone decide whether to click
65
+ - 2 short sentences max
66
+ - sentence 1: what the thing is or what happened
67
+ - sentence 2: a reason to click or a key detail
68
+ - include concrete specifics when available
69
+ - never start with: "this article discusses", "this post is about", "the page explains", "X tweeted that", "post by", "page", "clicking gives", "clicking provides", "clicking reveals"
70
+ - never use: "not just X but Y", "isn't just X"
71
+ - tags must be lowercase
72
+ - category must be one of: ${Array.from(ALLOWED_CATEGORIES).join(", ")}
73
+ - if unsure, use uncategorized
74
+
75
+ url: ${url}
76
+ title: ${title || "unknown"}
77
+ description: ${description || "none"}
78
+ ${bodyText ? `page content: ${bodyText}` : ""}
79
+ ${userContext ? `user context: ${userContext}` : ""}
80
+
81
+ respond in json only, no markdown:
82
+ {"summary": "...", "tags": ["...", "..."], "category": "..."}`;
83
+
84
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
85
+ method: "POST",
86
+ headers: {
87
+ "Content-Type": "application/json",
88
+ Authorization: `Bearer ${OPENAI_API_KEY}`,
89
+ },
90
+ body: JSON.stringify({
91
+ model: "gpt-4.1-mini",
92
+ messages: [{ role: "user", content: prompt }],
93
+ temperature: 0.7,
94
+ max_tokens: 200,
95
+ response_format: { type: "json_object" },
96
+ }),
97
+ signal: AbortSignal.timeout(15000),
98
+ });
99
+
100
+ if (!response.ok) {
101
+ throw new Error(`openai api error: ${response.status}`);
102
+ }
103
+
104
+ const data = await response.json();
105
+ const content = data.choices?.[0]?.message?.content;
106
+ if (!content) throw new Error("no content in openai response");
107
+
108
+ const parsed = JSON.parse(content);
109
+ return normalizeResult({
110
+ summary: parsed.summary || description || "no summary available",
111
+ tags: Array.isArray(parsed.tags) ? parsed.tags.slice(0, 5) : [],
112
+ category: parsed.category || "uncategorized",
113
+ });
114
+ }
115
+
116
+ function normalizeResult(result: AISummaryResult): AISummaryResult {
117
+ const summary = String(result.summary || "no summary available")
118
+ .replace(/\s+/g, " ")
119
+ .trim()
120
+ .slice(0, 280);
121
+
122
+ const tags = Array.from(
123
+ new Set(
124
+ (result.tags || [])
125
+ .map((tag) => String(tag).toLowerCase().trim())
126
+ .map((tag) => tag.replace(/[^a-z0-9\s-]/g, ""))
127
+ .map((tag) => tag.replace(/\s+/g, "-"))
128
+ .filter((tag) => tag.length >= 2 && tag.length <= 32)
129
+ .filter((tag) => !["technology", "tech", "news", "innovation", "business"].includes(tag))
130
+ )
131
+ ).slice(0, 5);
132
+
133
+ const category = ALLOWED_CATEGORIES.has(String(result.category || "").toLowerCase().trim())
134
+ ? String(result.category).toLowerCase().trim()
135
+ : "uncategorized";
136
+
137
+ return { summary, tags, category };
138
+ }
139
+
140
+ function basicExtract(
141
+ title: string | null,
142
+ description: string | null
143
+ ): AISummaryResult {
144
+ const summary = description || title || "no summary available";
145
+ const text = `${title || ""} ${description || ""}`.toLowerCase();
146
+ const stopWords = new Set([
147
+ "the", "a", "an", "is", "are", "was", "were", "be", "been",
148
+ "being", "have", "has", "had", "do", "does", "did", "will",
149
+ "would", "could", "should", "may", "might", "can", "shall",
150
+ "to", "of", "in", "for", "on", "with", "at", "by", "from",
151
+ "as", "into", "through", "during", "before", "after", "and",
152
+ "but", "or", "nor", "not", "so", "yet", "both", "either",
153
+ "neither", "each", "every", "all", "any", "few", "more",
154
+ "most", "other", "some", "such", "no", "only", "own", "same",
155
+ "than", "too", "very", "just", "that", "this", "it", "its",
156
+ "how", "what", "which", "who", "whom", "where", "when", "why",
157
+ "about", "up", "out", "if", "then", "also", "new", "one",
158
+ ]);
159
+ const tags = text
160
+ .replace(/[^a-z0-9\s-]/g, "")
161
+ .split(/\s+/)
162
+ .filter((w) => w.length > 2 && !stopWords.has(w))
163
+ .slice(0, 5);
164
+ const category = "uncategorized";
165
+ return normalizeResult({ summary, tags, category });
166
+ }