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,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); });
|
package/save-prcarly.ts
ADDED
|
@@ -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
|
+
}
|