@crowdlisten/harness 1.0.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/AGENTS.md +167 -0
- package/LICENSE +21 -0
- package/README.md +153 -0
- package/dist/agent-proxy.d.ts +24 -0
- package/dist/agent-proxy.js +140 -0
- package/dist/agent-tools.d.ts +736 -0
- package/dist/agent-tools.js +409 -0
- package/dist/context/api.d.ts +5 -0
- package/dist/context/api.js +164 -0
- package/dist/context/cli.d.ts +19 -0
- package/dist/context/cli.js +108 -0
- package/dist/context/extractor.d.ts +12 -0
- package/dist/context/extractor.js +43 -0
- package/dist/context/index.d.ts +12 -0
- package/dist/context/index.js +11 -0
- package/dist/context/matcher.d.ts +39 -0
- package/dist/context/matcher.js +246 -0
- package/dist/context/parser.d.ts +28 -0
- package/dist/context/parser.js +157 -0
- package/dist/context/pipeline.d.ts +26 -0
- package/dist/context/pipeline.js +56 -0
- package/dist/context/prompts.d.ts +6 -0
- package/dist/context/prompts.js +60 -0
- package/dist/context/providers.d.ts +6 -0
- package/dist/context/providers.js +106 -0
- package/dist/context/redactor.d.ts +10 -0
- package/dist/context/redactor.js +68 -0
- package/dist/context/server.d.ts +5 -0
- package/dist/context/server.js +134 -0
- package/dist/context/store.d.ts +12 -0
- package/dist/context/store.js +82 -0
- package/dist/context/types.d.ts +79 -0
- package/dist/context/types.js +4 -0
- package/dist/context/user-state.d.ts +40 -0
- package/dist/context/user-state.js +144 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +385 -0
- package/dist/insights/browser/BrowserPool.d.ts +87 -0
- package/dist/insights/browser/BrowserPool.js +266 -0
- package/dist/insights/browser/RequestInterceptor.d.ts +46 -0
- package/dist/insights/browser/RequestInterceptor.js +115 -0
- package/dist/insights/cli.d.ts +8 -0
- package/dist/insights/cli.js +206 -0
- package/dist/insights/core/base/BaseAdapter.d.ts +37 -0
- package/dist/insights/core/base/BaseAdapter.js +123 -0
- package/dist/insights/core/health/HealthMonitor.d.ts +75 -0
- package/dist/insights/core/health/HealthMonitor.js +171 -0
- package/dist/insights/core/interfaces/SocialMediaPlatform.d.ts +125 -0
- package/dist/insights/core/interfaces/SocialMediaPlatform.js +42 -0
- package/dist/insights/core/utils/DataNormalizer.d.ts +53 -0
- package/dist/insights/core/utils/DataNormalizer.js +349 -0
- package/dist/insights/core/utils/InstagramUrlUtils.d.ts +11 -0
- package/dist/insights/core/utils/InstagramUrlUtils.js +60 -0
- package/dist/insights/core/utils/TikTokUrlUtils.d.ts +10 -0
- package/dist/insights/core/utils/TikTokUrlUtils.js +57 -0
- package/dist/insights/handlers.d.ts +157 -0
- package/dist/insights/handlers.js +246 -0
- package/dist/insights/index.d.ts +437 -0
- package/dist/insights/index.js +426 -0
- package/dist/insights/platforms/instagram/InstagramAdapter.d.ts +34 -0
- package/dist/insights/platforms/instagram/InstagramAdapter.js +342 -0
- package/dist/insights/platforms/moltbook/MoltbookAdapter.d.ts +31 -0
- package/dist/insights/platforms/moltbook/MoltbookAdapter.js +227 -0
- package/dist/insights/platforms/reddit/RedditAdapter.d.ts +21 -0
- package/dist/insights/platforms/reddit/RedditAdapter.js +212 -0
- package/dist/insights/platforms/tiktok/TikTokAdapter.d.ts +34 -0
- package/dist/insights/platforms/tiktok/TikTokAdapter.js +269 -0
- package/dist/insights/platforms/twitter/TwitterAdapter.d.ts +23 -0
- package/dist/insights/platforms/twitter/TwitterAdapter.js +211 -0
- package/dist/insights/platforms/xiaohongshu/XiaohongshuAdapter.d.ts +35 -0
- package/dist/insights/platforms/xiaohongshu/XiaohongshuAdapter.js +258 -0
- package/dist/insights/platforms/youtube/YouTubeAdapter.d.ts +22 -0
- package/dist/insights/platforms/youtube/YouTubeAdapter.js +254 -0
- package/dist/insights/service-config.d.ts +7 -0
- package/dist/insights/service-config.js +60 -0
- package/dist/insights/services/UnifiedSocialMediaService.d.ts +94 -0
- package/dist/insights/services/UnifiedSocialMediaService.js +259 -0
- package/dist/insights/vision/VisionExtractor.d.ts +46 -0
- package/dist/insights/vision/VisionExtractor.js +236 -0
- package/dist/learnings.d.ts +50 -0
- package/dist/learnings.js +130 -0
- package/dist/openapi.d.ts +29 -0
- package/dist/openapi.js +169 -0
- package/dist/server-factory.d.ts +20 -0
- package/dist/server-factory.js +41 -0
- package/dist/suggestions.d.ts +16 -0
- package/dist/suggestions.js +72 -0
- package/dist/telemetry.d.ts +44 -0
- package/dist/telemetry.js +93 -0
- package/dist/tools/registry.d.ts +65 -0
- package/dist/tools/registry.js +256 -0
- package/dist/tools.d.ts +2433 -0
- package/dist/tools.js +2294 -0
- package/dist/transport/http.d.ts +15 -0
- package/dist/transport/http.js +154 -0
- package/package.json +76 -0
- package/skills/catalog.json +272 -0
- package/skills/community-catalog.json +4202 -0
- package/skills/competitive-analysis/SKILL.md +174 -0
- package/skills/content-creator/SKILL.md +256 -0
- package/skills/content-strategy/SKILL.md +222 -0
- package/skills/data-storytelling/SKILL.md +248 -0
- package/skills/heuristic-evaluation/SKILL.md +201 -0
- package/skills/market-research-reports/SKILL.md +184 -0
- package/skills/user-stories/SKILL.md +178 -0
- package/skills/ux-researcher/SKILL.md +239 -0
- package/web-dist/assets/index-B1b25lNd.css +1 -0
- package/web-dist/assets/index-CDWHwHbl.js +64 -0
- package/web-dist/index.html +16 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisionExtractor — standalone vision-based content extraction.
|
|
3
|
+
*
|
|
4
|
+
* Given any URL, opens a browser, takes a screenshot, sends to an LLM
|
|
5
|
+
* (Claude → Gemini → OpenAI fallback), and returns structured Post[] or Comment[].
|
|
6
|
+
*
|
|
7
|
+
* This is NOT a filter/verifier — it's a full extractor that reads the screenshot
|
|
8
|
+
* and returns structured data directly.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const vision = new VisionExtractor();
|
|
12
|
+
* const result = await vision.extract('https://tiktok.com/...', { mode: 'posts' });
|
|
13
|
+
*/
|
|
14
|
+
import { Post, Comment } from '../core/interfaces/SocialMediaPlatform.js';
|
|
15
|
+
export interface VisionExtractionOptions {
|
|
16
|
+
mode: 'posts' | 'comments' | 'raw';
|
|
17
|
+
limit?: number;
|
|
18
|
+
scrollCount?: number;
|
|
19
|
+
}
|
|
20
|
+
export interface VisionExtractionResult {
|
|
21
|
+
posts?: Post[];
|
|
22
|
+
comments?: Comment[];
|
|
23
|
+
raw?: string;
|
|
24
|
+
provider: string;
|
|
25
|
+
url: string;
|
|
26
|
+
}
|
|
27
|
+
export declare class VisionExtractor {
|
|
28
|
+
/**
|
|
29
|
+
* Check if any vision provider is configured.
|
|
30
|
+
*/
|
|
31
|
+
isAvailable(): boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Extract structured content from any URL using vision.
|
|
34
|
+
*/
|
|
35
|
+
extract(url: string, options: VisionExtractionOptions): Promise<VisionExtractionResult>;
|
|
36
|
+
private detectPlatform;
|
|
37
|
+
private buildPrompt;
|
|
38
|
+
/**
|
|
39
|
+
* Try LLM providers in order: Claude → Gemini → OpenAI
|
|
40
|
+
*/
|
|
41
|
+
private askLLM;
|
|
42
|
+
private askClaude;
|
|
43
|
+
private askGemini;
|
|
44
|
+
private askOpenAI;
|
|
45
|
+
private parseResponse;
|
|
46
|
+
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* VisionExtractor — standalone vision-based content extraction.
|
|
3
|
+
*
|
|
4
|
+
* Given any URL, opens a browser, takes a screenshot, sends to an LLM
|
|
5
|
+
* (Claude → Gemini → OpenAI fallback), and returns structured Post[] or Comment[].
|
|
6
|
+
*
|
|
7
|
+
* This is NOT a filter/verifier — it's a full extractor that reads the screenshot
|
|
8
|
+
* and returns structured data directly.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* const vision = new VisionExtractor();
|
|
12
|
+
* const result = await vision.extract('https://tiktok.com/...', { mode: 'posts' });
|
|
13
|
+
*/
|
|
14
|
+
import { getBrowserPool } from '../browser/BrowserPool.js';
|
|
15
|
+
export class VisionExtractor {
|
|
16
|
+
/**
|
|
17
|
+
* Check if any vision provider is configured.
|
|
18
|
+
*/
|
|
19
|
+
isAvailable() {
|
|
20
|
+
return !!(process.env.ANTHROPIC_API_KEY ||
|
|
21
|
+
process.env.GEMINI_API_KEY ||
|
|
22
|
+
process.env.OPENAI_API_KEY);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Extract structured content from any URL using vision.
|
|
26
|
+
*/
|
|
27
|
+
async extract(url, options) {
|
|
28
|
+
const { mode, limit = 10, scrollCount = 3 } = options;
|
|
29
|
+
if (!this.isAvailable()) {
|
|
30
|
+
throw new Error('Vision extraction requires at least one LLM API key.\n' +
|
|
31
|
+
'Set ANTHROPIC_API_KEY, GEMINI_API_KEY, or OPENAI_API_KEY.');
|
|
32
|
+
}
|
|
33
|
+
const pool = getBrowserPool();
|
|
34
|
+
const platform = this.detectPlatform(url);
|
|
35
|
+
const page = await pool.acquire(platform);
|
|
36
|
+
try {
|
|
37
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 20000 });
|
|
38
|
+
// Wait for content to load
|
|
39
|
+
try {
|
|
40
|
+
await page.waitForLoadState('networkidle', { timeout: 10000 });
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// networkidle timeout is non-fatal
|
|
44
|
+
}
|
|
45
|
+
// Scroll to load more content
|
|
46
|
+
for (let i = 0; i < scrollCount; i++) {
|
|
47
|
+
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
|
|
48
|
+
await page.waitForTimeout(1500);
|
|
49
|
+
}
|
|
50
|
+
// Take full-page screenshot
|
|
51
|
+
const screenshotBuffer = await page.screenshot({ fullPage: true });
|
|
52
|
+
const screenshotBase64 = screenshotBuffer.toString('base64');
|
|
53
|
+
const prompt = this.buildPrompt(mode, url, limit);
|
|
54
|
+
const { text, provider } = await this.askLLM(screenshotBase64, prompt);
|
|
55
|
+
return this.parseResponse(text, provider, url, mode);
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
await pool.release(page);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
detectPlatform(url) {
|
|
62
|
+
if (url.includes('tiktok.com'))
|
|
63
|
+
return 'tiktok';
|
|
64
|
+
if (url.includes('instagram.com'))
|
|
65
|
+
return 'instagram';
|
|
66
|
+
if (url.includes('x.com') || url.includes('twitter.com'))
|
|
67
|
+
return 'twitter';
|
|
68
|
+
if (url.includes('xiaohongshu.com'))
|
|
69
|
+
return 'xiaohongshu';
|
|
70
|
+
if (url.includes('reddit.com'))
|
|
71
|
+
return 'reddit';
|
|
72
|
+
if (url.includes('youtube.com'))
|
|
73
|
+
return 'youtube';
|
|
74
|
+
return 'twitter'; // default profile
|
|
75
|
+
}
|
|
76
|
+
buildPrompt(mode, url, limit) {
|
|
77
|
+
if (mode === 'raw') {
|
|
78
|
+
return (`This is a screenshot of ${url}.\n\n` +
|
|
79
|
+
`Extract ALL visible text content from this page. Return it as plain text, preserving the structure ` +
|
|
80
|
+
`(headings, paragraphs, lists). Do not add interpretation — just extract what you see.`);
|
|
81
|
+
}
|
|
82
|
+
if (mode === 'comments') {
|
|
83
|
+
return (`This is a screenshot of ${url}.\n\n` +
|
|
84
|
+
`Extract up to ${limit} visible comments/replies from this page.\n` +
|
|
85
|
+
`Return ONLY this JSON — no explanation, no markdown:\n` +
|
|
86
|
+
`{"comments": [\n` +
|
|
87
|
+
` {"id": "1", "author": "username", "text": "comment text", "likes": 0},\n` +
|
|
88
|
+
` ...\n` +
|
|
89
|
+
`]}\n\n` +
|
|
90
|
+
`For each comment, extract the author username, full comment text, and like count (0 if not visible).` +
|
|
91
|
+
` Return exactly the JSON format above.`);
|
|
92
|
+
}
|
|
93
|
+
// mode === 'posts'
|
|
94
|
+
return (`This is a screenshot of ${url}.\n\n` +
|
|
95
|
+
`Extract up to ${limit} visible posts/content items from this page.\n` +
|
|
96
|
+
`Return ONLY this JSON — no explanation, no markdown:\n` +
|
|
97
|
+
`{"posts": [\n` +
|
|
98
|
+
` {"id": "1", "author": "username", "content": "post text", "likes": 0, "comments": 0, "shares": 0, "url": ""},\n` +
|
|
99
|
+
` ...\n` +
|
|
100
|
+
`]}\n\n` +
|
|
101
|
+
`For each post, extract: author username, content/caption text, engagement metrics (likes, comments, shares — 0 if not visible), ` +
|
|
102
|
+
`and URL if visible. Return exactly the JSON format above.`);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Try LLM providers in order: Claude → Gemini → OpenAI
|
|
106
|
+
*/
|
|
107
|
+
async askLLM(screenshotBase64, prompt) {
|
|
108
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
109
|
+
try {
|
|
110
|
+
const text = await this.askClaude(screenshotBase64, prompt);
|
|
111
|
+
return { text, provider: 'claude' };
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
console.warn(`[VisionExtractor] Claude failed: ${err}. Trying next...`);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (process.env.GEMINI_API_KEY) {
|
|
118
|
+
try {
|
|
119
|
+
const text = await this.askGemini(screenshotBase64, prompt);
|
|
120
|
+
return { text, provider: 'gemini' };
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
console.warn(`[VisionExtractor] Gemini failed: ${err}. Trying next...`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (process.env.OPENAI_API_KEY) {
|
|
127
|
+
try {
|
|
128
|
+
const text = await this.askOpenAI(screenshotBase64, prompt);
|
|
129
|
+
return { text, provider: 'openai' };
|
|
130
|
+
}
|
|
131
|
+
catch (err) {
|
|
132
|
+
console.warn(`[VisionExtractor] OpenAI failed: ${err}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
throw new Error('All vision providers failed. Check API keys and try again.');
|
|
136
|
+
}
|
|
137
|
+
async askClaude(screenshotBase64, prompt) {
|
|
138
|
+
const Anthropic = (await import('@anthropic-ai/sdk')).default;
|
|
139
|
+
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
|
140
|
+
const response = await client.messages.create({
|
|
141
|
+
model: 'claude-sonnet-4-6',
|
|
142
|
+
max_tokens: 4096,
|
|
143
|
+
messages: [{
|
|
144
|
+
role: 'user',
|
|
145
|
+
content: [
|
|
146
|
+
{ type: 'image', source: { type: 'base64', media_type: 'image/png', data: screenshotBase64 } },
|
|
147
|
+
{ type: 'text', text: prompt },
|
|
148
|
+
],
|
|
149
|
+
}],
|
|
150
|
+
});
|
|
151
|
+
return response.content
|
|
152
|
+
.filter((block) => block.type === 'text')
|
|
153
|
+
.map((block) => block.text)
|
|
154
|
+
.join('');
|
|
155
|
+
}
|
|
156
|
+
async askGemini(screenshotBase64, prompt) {
|
|
157
|
+
const { GoogleGenerativeAI } = await import('@google/generative-ai');
|
|
158
|
+
const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY);
|
|
159
|
+
const model = genAI.getGenerativeModel({ model: 'gemini-2.0-flash' });
|
|
160
|
+
const result = await model.generateContent([
|
|
161
|
+
{ inlineData: { mimeType: 'image/png', data: screenshotBase64 } },
|
|
162
|
+
{ text: prompt },
|
|
163
|
+
]);
|
|
164
|
+
return result.response.text();
|
|
165
|
+
}
|
|
166
|
+
async askOpenAI(screenshotBase64, prompt) {
|
|
167
|
+
const OpenAI = (await import('openai')).default;
|
|
168
|
+
const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
|
|
169
|
+
const response = await client.chat.completions.create({
|
|
170
|
+
model: 'gpt-4o-mini',
|
|
171
|
+
max_tokens: 4096,
|
|
172
|
+
messages: [{
|
|
173
|
+
role: 'user',
|
|
174
|
+
content: [
|
|
175
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${screenshotBase64}` } },
|
|
176
|
+
{ type: 'text', text: prompt },
|
|
177
|
+
],
|
|
178
|
+
}],
|
|
179
|
+
});
|
|
180
|
+
return response.choices[0]?.message?.content || '';
|
|
181
|
+
}
|
|
182
|
+
parseResponse(text, provider, url, mode) {
|
|
183
|
+
if (mode === 'raw') {
|
|
184
|
+
return { raw: text, provider, url };
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
|
188
|
+
if (!jsonMatch) {
|
|
189
|
+
console.warn(`[VisionExtractor] No JSON found in ${provider} response. Returning raw.`);
|
|
190
|
+
return { raw: text, provider, url };
|
|
191
|
+
}
|
|
192
|
+
const parsed = JSON.parse(jsonMatch[0]);
|
|
193
|
+
const platform = this.detectPlatform(url);
|
|
194
|
+
if (mode === 'comments' && Array.isArray(parsed.comments)) {
|
|
195
|
+
const comments = parsed.comments.map((c, i) => ({
|
|
196
|
+
id: c.id || `vision_comment_${i}`,
|
|
197
|
+
author: {
|
|
198
|
+
id: c.author || '',
|
|
199
|
+
username: c.author || '',
|
|
200
|
+
displayName: c.author || '',
|
|
201
|
+
},
|
|
202
|
+
text: c.text || '',
|
|
203
|
+
timestamp: new Date(),
|
|
204
|
+
likes: c.likes || 0,
|
|
205
|
+
}));
|
|
206
|
+
return { comments, provider, url };
|
|
207
|
+
}
|
|
208
|
+
if (mode === 'posts' && Array.isArray(parsed.posts)) {
|
|
209
|
+
const posts = parsed.posts.map((p, i) => ({
|
|
210
|
+
id: p.id || `vision_post_${i}`,
|
|
211
|
+
platform: platform,
|
|
212
|
+
author: {
|
|
213
|
+
id: p.author || '',
|
|
214
|
+
username: p.author || '',
|
|
215
|
+
displayName: p.author || '',
|
|
216
|
+
},
|
|
217
|
+
content: p.content || '',
|
|
218
|
+
engagement: {
|
|
219
|
+
likes: p.likes || 0,
|
|
220
|
+
comments: p.comments || 0,
|
|
221
|
+
shares: p.shares || 0,
|
|
222
|
+
views: p.views || 0,
|
|
223
|
+
},
|
|
224
|
+
timestamp: new Date(),
|
|
225
|
+
url: p.url || url,
|
|
226
|
+
hashtags: (p.content || '').match(/#\w+/g)?.map((h) => h.slice(1)) || [],
|
|
227
|
+
}));
|
|
228
|
+
return { posts, provider, url };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
catch (err) {
|
|
232
|
+
console.warn(`[VisionExtractor] Failed to parse ${provider} response as JSON:`, err);
|
|
233
|
+
}
|
|
234
|
+
return { raw: text, provider, url };
|
|
235
|
+
}
|
|
236
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Project Learnings — JSONL-backed persistent insights.
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.crowdlisten/learnings.jsonl (append-only)
|
|
5
|
+
*
|
|
6
|
+
* Confidence decay: 1 point per 30 days for observed/inferred sources.
|
|
7
|
+
* User-stated learnings never decay.
|
|
8
|
+
* Entries below confidence 1 are excluded from search results.
|
|
9
|
+
*/
|
|
10
|
+
export type LearningType = "pattern" | "pitfall" | "preference" | "architecture" | "tool" | "operational";
|
|
11
|
+
export type LearningSource = "observed" | "user-stated" | "inferred";
|
|
12
|
+
export interface Learning {
|
|
13
|
+
id: string;
|
|
14
|
+
ts: string;
|
|
15
|
+
skill: string;
|
|
16
|
+
type: LearningType;
|
|
17
|
+
key: string;
|
|
18
|
+
insight: string;
|
|
19
|
+
confidence: number;
|
|
20
|
+
source: LearningSource;
|
|
21
|
+
project?: string;
|
|
22
|
+
files?: string[];
|
|
23
|
+
}
|
|
24
|
+
export interface LearningWithDecay extends Learning {
|
|
25
|
+
effectiveConfidence: number;
|
|
26
|
+
ageInDays: number;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Log a new learning. Deduplicates by key — if a learning with the same key
|
|
30
|
+
* exists, the new one supersedes it (both remain in JSONL, search returns latest).
|
|
31
|
+
*/
|
|
32
|
+
export declare function logLearning(input: Omit<Learning, "id" | "ts">): Learning;
|
|
33
|
+
/**
|
|
34
|
+
* Search learnings by keyword query. Returns results with decayed confidence,
|
|
35
|
+
* filtered to effective confidence >= 1, sorted by effective confidence desc.
|
|
36
|
+
*/
|
|
37
|
+
export declare function searchLearnings(query: string, opts?: {
|
|
38
|
+
crossProject?: boolean;
|
|
39
|
+
currentProject?: string;
|
|
40
|
+
limit?: number;
|
|
41
|
+
}): LearningWithDecay[];
|
|
42
|
+
/**
|
|
43
|
+
* Get stats about the learnings store.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getLearningsStats(): {
|
|
46
|
+
total: number;
|
|
47
|
+
uniqueKeys: number;
|
|
48
|
+
byType: Record<string, number>;
|
|
49
|
+
bySource: Record<string, number>;
|
|
50
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-Project Learnings — JSONL-backed persistent insights.
|
|
3
|
+
*
|
|
4
|
+
* Storage: ~/.crowdlisten/learnings.jsonl (append-only)
|
|
5
|
+
*
|
|
6
|
+
* Confidence decay: 1 point per 30 days for observed/inferred sources.
|
|
7
|
+
* User-stated learnings never decay.
|
|
8
|
+
* Entries below confidence 1 are excluded from search results.
|
|
9
|
+
*/
|
|
10
|
+
import * as fs from "fs";
|
|
11
|
+
import * as path from "path";
|
|
12
|
+
import * as os from "os";
|
|
13
|
+
import * as crypto from "crypto";
|
|
14
|
+
const BASE_DIR = path.join(os.homedir(), ".crowdlisten");
|
|
15
|
+
const LEARNINGS_FILE = path.join(BASE_DIR, "learnings.jsonl");
|
|
16
|
+
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
17
|
+
function ensureDir() {
|
|
18
|
+
if (!fs.existsSync(BASE_DIR)) {
|
|
19
|
+
fs.mkdirSync(BASE_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function daysSince(isoDate) {
|
|
23
|
+
const then = new Date(isoDate).getTime();
|
|
24
|
+
const now = Date.now();
|
|
25
|
+
return (now - then) / (1000 * 60 * 60 * 24);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Compute effective confidence with decay.
|
|
29
|
+
* User-stated learnings don't decay. Others lose 1 point per 30 days.
|
|
30
|
+
* Minimum effective confidence is 0 (entries below 1 are excluded from results).
|
|
31
|
+
*/
|
|
32
|
+
function computeEffectiveConfidence(learning) {
|
|
33
|
+
if (learning.source === "user-stated")
|
|
34
|
+
return learning.confidence;
|
|
35
|
+
const age = daysSince(learning.ts);
|
|
36
|
+
const decay = age / 30;
|
|
37
|
+
return Math.max(0, learning.confidence - decay);
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Read all learnings from JSONL file.
|
|
41
|
+
*/
|
|
42
|
+
function readAllLearnings() {
|
|
43
|
+
if (!fs.existsSync(LEARNINGS_FILE))
|
|
44
|
+
return [];
|
|
45
|
+
try {
|
|
46
|
+
const raw = fs.readFileSync(LEARNINGS_FILE, "utf-8");
|
|
47
|
+
const lines = raw.trim().split("\n").filter(Boolean);
|
|
48
|
+
return lines.map((line) => JSON.parse(line));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return [];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// ─── Public API ─────────────────────────────────────────────────────────────
|
|
55
|
+
/**
|
|
56
|
+
* Log a new learning. Deduplicates by key — if a learning with the same key
|
|
57
|
+
* exists, the new one supersedes it (both remain in JSONL, search returns latest).
|
|
58
|
+
*/
|
|
59
|
+
export function logLearning(input) {
|
|
60
|
+
ensureDir();
|
|
61
|
+
const learning = {
|
|
62
|
+
...input,
|
|
63
|
+
id: crypto.randomUUID(),
|
|
64
|
+
ts: new Date().toISOString(),
|
|
65
|
+
};
|
|
66
|
+
fs.appendFileSync(LEARNINGS_FILE, JSON.stringify(learning) + "\n");
|
|
67
|
+
return learning;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Search learnings by keyword query. Returns results with decayed confidence,
|
|
71
|
+
* filtered to effective confidence >= 1, sorted by effective confidence desc.
|
|
72
|
+
*/
|
|
73
|
+
export function searchLearnings(query, opts) {
|
|
74
|
+
const limit = opts?.limit ?? 10;
|
|
75
|
+
const crossProject = opts?.crossProject ?? false;
|
|
76
|
+
const currentProject = opts?.currentProject;
|
|
77
|
+
const all = readAllLearnings();
|
|
78
|
+
const lower = query.toLowerCase();
|
|
79
|
+
// Deduplicate by key — keep the latest entry for each key
|
|
80
|
+
const byKey = new Map();
|
|
81
|
+
for (const l of all) {
|
|
82
|
+
byKey.set(l.key, l);
|
|
83
|
+
}
|
|
84
|
+
let results = [];
|
|
85
|
+
for (const learning of byKey.values()) {
|
|
86
|
+
// Filter by project scope
|
|
87
|
+
if (!crossProject && currentProject && learning.project && learning.project !== currentProject) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
// Keyword matching on key, insight, type, and skill
|
|
91
|
+
const searchable = [learning.key, learning.insight, learning.type, learning.skill]
|
|
92
|
+
.join(" ")
|
|
93
|
+
.toLowerCase();
|
|
94
|
+
if (!searchable.includes(lower))
|
|
95
|
+
continue;
|
|
96
|
+
const effectiveConfidence = computeEffectiveConfidence(learning);
|
|
97
|
+
// Exclude stale entries
|
|
98
|
+
if (effectiveConfidence < 1)
|
|
99
|
+
continue;
|
|
100
|
+
results.push({
|
|
101
|
+
...learning,
|
|
102
|
+
effectiveConfidence: Math.round(effectiveConfidence * 10) / 10,
|
|
103
|
+
ageInDays: Math.round(daysSince(learning.ts)),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
// Sort by effective confidence descending
|
|
107
|
+
results.sort((a, b) => b.effectiveConfidence - a.effectiveConfidence);
|
|
108
|
+
return results.slice(0, limit);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Get stats about the learnings store.
|
|
112
|
+
*/
|
|
113
|
+
export function getLearningsStats() {
|
|
114
|
+
const all = readAllLearnings();
|
|
115
|
+
const byKey = new Map();
|
|
116
|
+
for (const l of all)
|
|
117
|
+
byKey.set(l.key, l);
|
|
118
|
+
const byType = {};
|
|
119
|
+
const bySource = {};
|
|
120
|
+
for (const l of byKey.values()) {
|
|
121
|
+
byType[l.type] = (byType[l.type] || 0) + 1;
|
|
122
|
+
bySource[l.source] = (bySource[l.source] || 0) + 1;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
total: all.length,
|
|
126
|
+
uniqueKeys: byKey.size,
|
|
127
|
+
byType,
|
|
128
|
+
bySource,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 3.0 Spec Generator — auto-generates from MCP tool definitions
|
|
3
|
+
*
|
|
4
|
+
* Each MCP tool becomes a POST /tools/{name} endpoint.
|
|
5
|
+
* Tags group tools by their pack prefix (e.g., [Analysis], [Content]).
|
|
6
|
+
*/
|
|
7
|
+
interface OpenApiSpec {
|
|
8
|
+
openapi: string;
|
|
9
|
+
info: {
|
|
10
|
+
title: string;
|
|
11
|
+
version: string;
|
|
12
|
+
description: string;
|
|
13
|
+
};
|
|
14
|
+
servers: {
|
|
15
|
+
url: string;
|
|
16
|
+
description: string;
|
|
17
|
+
}[];
|
|
18
|
+
paths: Record<string, unknown>;
|
|
19
|
+
components: {
|
|
20
|
+
securitySchemes: Record<string, unknown>;
|
|
21
|
+
};
|
|
22
|
+
tags: {
|
|
23
|
+
name: string;
|
|
24
|
+
description: string;
|
|
25
|
+
}[];
|
|
26
|
+
security: Record<string, string[]>[];
|
|
27
|
+
}
|
|
28
|
+
export declare function generateOpenApiSpec(): OpenApiSpec;
|
|
29
|
+
export {};
|
package/dist/openapi.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAPI 3.0 Spec Generator — auto-generates from MCP tool definitions
|
|
3
|
+
*
|
|
4
|
+
* Each MCP tool becomes a POST /tools/{name} endpoint.
|
|
5
|
+
* Tags group tools by their pack prefix (e.g., [Analysis], [Content]).
|
|
6
|
+
*/
|
|
7
|
+
import { TOOLS } from "./tools.js";
|
|
8
|
+
import { SERVER_VERSION } from "./server-factory.js";
|
|
9
|
+
// Extract pack tag from tool description, e.g. "[Analysis] ..." → "analysis"
|
|
10
|
+
function extractTag(description) {
|
|
11
|
+
const match = description.match(/^\[([^\]]+)\]/);
|
|
12
|
+
if (match)
|
|
13
|
+
return match[1].toLowerCase();
|
|
14
|
+
// Infer from description keywords
|
|
15
|
+
if (description.includes("[Setup]"))
|
|
16
|
+
return "setup";
|
|
17
|
+
if (description.includes("[Advanced]"))
|
|
18
|
+
return "sessions";
|
|
19
|
+
if (description.includes("[Context]"))
|
|
20
|
+
return "context";
|
|
21
|
+
// Default categorization
|
|
22
|
+
return "planning";
|
|
23
|
+
}
|
|
24
|
+
function tagDisplayName(tag) {
|
|
25
|
+
const names = {
|
|
26
|
+
analysis: "Analysis",
|
|
27
|
+
content: "Content & Vectors",
|
|
28
|
+
generation: "Document Generation",
|
|
29
|
+
llm: "LLM Proxy",
|
|
30
|
+
"agent network": "Agent Network",
|
|
31
|
+
setup: "Setup",
|
|
32
|
+
sessions: "Multi-Agent Sessions",
|
|
33
|
+
context: "Context Extraction",
|
|
34
|
+
planning: "Planning & Tasks",
|
|
35
|
+
};
|
|
36
|
+
return names[tag] || tag.charAt(0).toUpperCase() + tag.slice(1);
|
|
37
|
+
}
|
|
38
|
+
function tagDescription(tag) {
|
|
39
|
+
const descriptions = {
|
|
40
|
+
analysis: "Run audience analyses, extract themes and sentiment from social platforms",
|
|
41
|
+
content: "Ingest content, semantic vector search, manage content documents",
|
|
42
|
+
generation: "Generate PRDs and product documentation from analysis results",
|
|
43
|
+
llm: "Free LLM completion proxy — no API key required",
|
|
44
|
+
"agent network": "Register agents, discover capabilities, share analysis results",
|
|
45
|
+
setup: "Board and project management, initial configuration",
|
|
46
|
+
sessions: "Multi-agent parallel session coordination",
|
|
47
|
+
context: "Extract reusable context blocks from chat transcripts with PII redaction",
|
|
48
|
+
planning: "Task management, execution planning, knowledge base",
|
|
49
|
+
};
|
|
50
|
+
return descriptions[tag] || "";
|
|
51
|
+
}
|
|
52
|
+
export function generateOpenApiSpec() {
|
|
53
|
+
const paths = {};
|
|
54
|
+
const tagSet = new Set();
|
|
55
|
+
for (const tool of TOOLS) {
|
|
56
|
+
const tag = extractTag(tool.description);
|
|
57
|
+
tagSet.add(tag);
|
|
58
|
+
// Strip the [Pack] prefix from description for cleaner OpenAPI docs
|
|
59
|
+
const cleanDescription = tool.description.replace(/^\[[^\]]+\]\s*/, "");
|
|
60
|
+
const requestBody = {};
|
|
61
|
+
const schema = tool.inputSchema;
|
|
62
|
+
const hasProperties = schema.properties && Object.keys(schema.properties).length > 0;
|
|
63
|
+
if (hasProperties) {
|
|
64
|
+
requestBody.required = true;
|
|
65
|
+
requestBody.content = {
|
|
66
|
+
"application/json": {
|
|
67
|
+
schema: {
|
|
68
|
+
type: "object",
|
|
69
|
+
properties: schema.properties,
|
|
70
|
+
...("required" in schema ? { required: schema.required } : {}),
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
paths[`/tools/${tool.name}`] = {
|
|
76
|
+
post: {
|
|
77
|
+
operationId: tool.name,
|
|
78
|
+
summary: cleanDescription.split(".")[0] + ".",
|
|
79
|
+
description: cleanDescription,
|
|
80
|
+
tags: [tagDisplayName(tag)],
|
|
81
|
+
...(hasProperties ? { requestBody } : {}),
|
|
82
|
+
responses: {
|
|
83
|
+
"200": {
|
|
84
|
+
description: "Tool result",
|
|
85
|
+
content: {
|
|
86
|
+
"application/json": {
|
|
87
|
+
schema: {
|
|
88
|
+
type: "object",
|
|
89
|
+
properties: {
|
|
90
|
+
content: {
|
|
91
|
+
type: "array",
|
|
92
|
+
items: {
|
|
93
|
+
type: "object",
|
|
94
|
+
properties: {
|
|
95
|
+
type: { type: "string", example: "text" },
|
|
96
|
+
text: { type: "string" },
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
"401": { description: "API key required (paid tools)" },
|
|
106
|
+
"500": { description: "Tool execution error" },
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
// Health endpoint
|
|
112
|
+
paths["/health"] = {
|
|
113
|
+
get: {
|
|
114
|
+
operationId: "health_check",
|
|
115
|
+
summary: "Health check",
|
|
116
|
+
tags: ["System"],
|
|
117
|
+
responses: {
|
|
118
|
+
"200": {
|
|
119
|
+
description: "Server is healthy",
|
|
120
|
+
content: {
|
|
121
|
+
"application/json": {
|
|
122
|
+
schema: {
|
|
123
|
+
type: "object",
|
|
124
|
+
properties: {
|
|
125
|
+
status: { type: "string", example: "ok" },
|
|
126
|
+
version: { type: "string", example: SERVER_VERSION },
|
|
127
|
+
tools: { type: "number" },
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
const tags = [...tagSet].map((t) => ({
|
|
137
|
+
name: tagDisplayName(t),
|
|
138
|
+
description: tagDescription(t),
|
|
139
|
+
}));
|
|
140
|
+
tags.push({ name: "System", description: "Health and metadata endpoints" });
|
|
141
|
+
return {
|
|
142
|
+
openapi: "3.0.3",
|
|
143
|
+
info: {
|
|
144
|
+
title: "CrowdListen Harness API",
|
|
145
|
+
version: SERVER_VERSION,
|
|
146
|
+
description: "Audience intelligence, social listening, planning, and context extraction for AI agents. " +
|
|
147
|
+
"This API exposes the full CrowdListen tool surface as REST endpoints. " +
|
|
148
|
+
"Free tools (LLM, Agent Network) require no authentication. " +
|
|
149
|
+
"Paid tools (Analysis, Content, Generation) require a CROWDLISTEN_API_KEY.",
|
|
150
|
+
},
|
|
151
|
+
servers: [
|
|
152
|
+
{ url: "https://mcp.crowdlisten.com", description: "Production" },
|
|
153
|
+
{ url: "http://localhost:3848", description: "Local development" },
|
|
154
|
+
],
|
|
155
|
+
paths,
|
|
156
|
+
components: {
|
|
157
|
+
securitySchemes: {
|
|
158
|
+
apiKey: {
|
|
159
|
+
type: "apiKey",
|
|
160
|
+
in: "header",
|
|
161
|
+
name: "Authorization",
|
|
162
|
+
description: 'Bearer token: CROWDLISTEN_API_KEY or Supabase JWT. Format: "Bearer <key>"',
|
|
163
|
+
},
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
tags,
|
|
167
|
+
security: [{ apiKey: [] }],
|
|
168
|
+
};
|
|
169
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Server Factory — shared between stdio and HTTP transports
|
|
3
|
+
*
|
|
4
|
+
* Creates a configured MCP Server instance with tool handlers.
|
|
5
|
+
* Both stdio (CLI) and HTTP (remote) transports use this factory
|
|
6
|
+
* so the tool surface is identical regardless of transport.
|
|
7
|
+
*/
|
|
8
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
9
|
+
declare const SERVER_NAME = "crowdlisten/harness";
|
|
10
|
+
declare const SERVER_VERSION = "0.6.0";
|
|
11
|
+
export interface McpServerOptions {
|
|
12
|
+
supabase: any;
|
|
13
|
+
userId: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Creates and configures an MCP Server with all tool handlers registered.
|
|
17
|
+
* The caller is responsible for connecting a transport (stdio or HTTP).
|
|
18
|
+
*/
|
|
19
|
+
export declare function createMcpServer(opts: McpServerOptions): Server;
|
|
20
|
+
export { SERVER_NAME, SERVER_VERSION };
|