vipcare 0.1.0 → 0.2.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/CLAUDE.md +58 -0
- package/README.md +21 -0
- package/bin/vip.js +253 -14
- package/lib/card.js +306 -0
- package/lib/config.js +9 -2
- package/lib/fetchers/search.js +17 -16
- package/lib/fetchers/twitter.js +3 -3
- package/lib/fetchers/youtube.js +108 -0
- package/lib/monitor.js +2 -2
- package/lib/profile.js +11 -1
- package/lib/scheduler.js +5 -5
- package/lib/synthesizer.js +5 -5
- package/lib/templates.js +94 -11
- package/package.json +2 -2
- package/web/index.html +204 -0
- package/lib/fetchers/web.js +0 -29
- package/profiles/.gitkeep +0 -0
- package/profiles/sam-altman.md +0 -49
- package/skill/vip.md +0 -96
- package/tests/fetchers.test.js +0 -21
- package/tests/monitor.test.js +0 -28
- package/tests/profile.test.js +0 -89
- package/tests/resolver.test.js +0 -40
- package/tests/scheduler.test.js +0 -22
package/lib/card.js
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { loadProfile } from './profile.js';
|
|
4
|
+
|
|
5
|
+
export function extractVipData(content) {
|
|
6
|
+
const match = content.match(/<!--\s*VIP_DATA\s*\n([\s\S]*?)\n-->/);
|
|
7
|
+
if (!match) return null;
|
|
8
|
+
|
|
9
|
+
try {
|
|
10
|
+
return JSON.parse(match[1]);
|
|
11
|
+
} catch {
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function generateCards(profiles, outputPath = 'web/index.html') {
|
|
17
|
+
const cards = [];
|
|
18
|
+
|
|
19
|
+
for (const p of profiles) {
|
|
20
|
+
const content = loadProfile(p.slug);
|
|
21
|
+
if (!content) continue;
|
|
22
|
+
|
|
23
|
+
const data = extractVipData(content);
|
|
24
|
+
if (!data) {
|
|
25
|
+
// Fallback: parse what we can from markdown headers
|
|
26
|
+
const nameMatch = content.match(/^# (.+)$/m);
|
|
27
|
+
const summaryMatch = content.match(/^> (.+)$/m);
|
|
28
|
+
const titleMatch = content.match(/\*\*Title:\*\*\s*(.+)/);
|
|
29
|
+
const companyMatch = content.match(/\*\*Company:\*\*\s*(.+)/);
|
|
30
|
+
const locationMatch = content.match(/\*\*Location:\*\*\s*(.+)/);
|
|
31
|
+
const industryMatch = content.match(/\*\*Industry:\*\*\s*(.+)/);
|
|
32
|
+
|
|
33
|
+
cards.push({
|
|
34
|
+
name: nameMatch ? nameMatch[1] : p.name,
|
|
35
|
+
title: titleMatch ? titleMatch[1].trim() : '',
|
|
36
|
+
company: companyMatch ? companyMatch[1].trim() : '',
|
|
37
|
+
location: locationMatch ? locationMatch[1].trim() : '',
|
|
38
|
+
disc: '?',
|
|
39
|
+
mbti: '?',
|
|
40
|
+
scores: {},
|
|
41
|
+
tags: industryMatch ? [industryMatch[1].trim()] : [],
|
|
42
|
+
icebreakers: [],
|
|
43
|
+
dos: [],
|
|
44
|
+
donts: [],
|
|
45
|
+
gifts: [],
|
|
46
|
+
expertise: [],
|
|
47
|
+
superpower: '',
|
|
48
|
+
quote: summaryMatch ? summaryMatch[1] : (p.summary || ''),
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
cards.push(data);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const html = buildHtml(cards);
|
|
57
|
+
|
|
58
|
+
const dir = path.dirname(outputPath);
|
|
59
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
60
|
+
fs.writeFileSync(outputPath, html, 'utf-8');
|
|
61
|
+
|
|
62
|
+
return path.resolve(outputPath);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function escapeHtml(str) {
|
|
66
|
+
return String(str)
|
|
67
|
+
.replace(/&/g, '&')
|
|
68
|
+
.replace(/</g, '<')
|
|
69
|
+
.replace(/>/g, '>')
|
|
70
|
+
.replace(/"/g, '"');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function buildHtml(cards) {
|
|
74
|
+
// Escape </script> in JSON to prevent XSS
|
|
75
|
+
const cardsJson = JSON.stringify(cards).replace(/<\//g, '<\\/');
|
|
76
|
+
|
|
77
|
+
return `<!DOCTYPE html>
|
|
78
|
+
<html lang="en">
|
|
79
|
+
<head>
|
|
80
|
+
<meta charset="UTF-8">
|
|
81
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
82
|
+
<title>VIPCare - Baseball Cards</title>
|
|
83
|
+
<style>
|
|
84
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
85
|
+
html { scroll-behavior: smooth; }
|
|
86
|
+
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; padding: 20px; padding: max(20px, env(safe-area-inset-top)) max(20px, env(safe-area-inset-right)) max(20px, env(safe-area-inset-bottom)) max(20px, env(safe-area-inset-left)); }
|
|
87
|
+
h1 { text-align: center; font-size: 1.8em; margin: 20px 0 30px; color: #38bdf8; }
|
|
88
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 20px; max-width: 1200px; margin: 0 auto; }
|
|
89
|
+
|
|
90
|
+
.card {
|
|
91
|
+
background: linear-gradient(145deg, #1e293b, #334155);
|
|
92
|
+
border-radius: 16px;
|
|
93
|
+
padding: 24px;
|
|
94
|
+
border: 1px solid #475569;
|
|
95
|
+
cursor: pointer;
|
|
96
|
+
transition: transform 0.2s, box-shadow 0.2s;
|
|
97
|
+
position: relative;
|
|
98
|
+
overflow: hidden;
|
|
99
|
+
min-height: 44px;
|
|
100
|
+
-webkit-tap-highlight-color: transparent;
|
|
101
|
+
}
|
|
102
|
+
.card:hover { transform: translateY(-4px); box-shadow: 0 12px 40px rgba(56,189,248,0.15); }
|
|
103
|
+
.card:active { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(56,189,248,0.1); }
|
|
104
|
+
.card::before {
|
|
105
|
+
content: '';
|
|
106
|
+
position: absolute;
|
|
107
|
+
top: 0; left: 0; right: 0;
|
|
108
|
+
height: 4px;
|
|
109
|
+
background: linear-gradient(90deg, #38bdf8, #818cf8, #c084fc);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.card-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
|
|
113
|
+
.card-name { font-size: 1.4em; font-weight: 700; color: #f1f5f9; }
|
|
114
|
+
.card-role { font-size: 0.85em; color: #94a3b8; margin-top: 2px; }
|
|
115
|
+
.card-badges { display: flex; gap: 6px; }
|
|
116
|
+
.badge { padding: 4px 10px; border-radius: 6px; font-size: 0.75em; font-weight: 700; min-height: 28px; display: inline-flex; align-items: center; }
|
|
117
|
+
.badge-disc { background: #38bdf8; color: #0f172a; }
|
|
118
|
+
.badge-mbti { background: #818cf8; color: #0f172a; }
|
|
119
|
+
|
|
120
|
+
.card-quote { font-style: italic; color: #94a3b8; font-size: 0.8em; margin: 10px 0; padding: 8px 12px; border-left: 3px solid #475569; }
|
|
121
|
+
|
|
122
|
+
.radar-container { display: flex; justify-content: center; margin: 16px 0; }
|
|
123
|
+
.radar { width: 200px; height: 200px; max-width: 100%; }
|
|
124
|
+
|
|
125
|
+
.tags { display: flex; flex-wrap: wrap; gap: 6px; margin: 12px 0; }
|
|
126
|
+
.tag { background: #1e3a5f; color: #38bdf8; padding: 4px 12px; border-radius: 12px; font-size: 0.75em; min-height: 28px; display: inline-flex; align-items: center; }
|
|
127
|
+
|
|
128
|
+
.expertise { margin: 10px 0; }
|
|
129
|
+
.expertise-title { font-size: 0.75em; color: #64748b; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
|
|
130
|
+
.expertise-item { font-size: 0.8em; color: #cbd5e1; padding: 2px 0; }
|
|
131
|
+
.superpower { color: #fbbf24; font-weight: 600; font-size: 0.85em; margin: 6px 0; }
|
|
132
|
+
|
|
133
|
+
.tips { margin-top: 12px; border-top: 1px solid #475569; padding-top: 12px; }
|
|
134
|
+
.tip-row { display: flex; gap: 4px; font-size: 0.8em; margin: 4px 0; color: #cbd5e1; min-height: 44px; align-items: center; }
|
|
135
|
+
.tip-icon { width: 20px; text-align: center; }
|
|
136
|
+
.tip-label { color: #64748b; min-width: 55px; }
|
|
137
|
+
|
|
138
|
+
/* Modal */
|
|
139
|
+
.modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.7); z-index: 100; justify-content: center; align-items: center; padding: 20px; }
|
|
140
|
+
.modal-overlay.active { display: flex; }
|
|
141
|
+
.modal {
|
|
142
|
+
background: #1e293b; border-radius: 16px; max-width: 600px; width: 100%; max-height: 90vh; overflow-y: auto; padding: 32px;
|
|
143
|
+
border: 1px solid #475569;
|
|
144
|
+
-webkit-overflow-scrolling: touch;
|
|
145
|
+
}
|
|
146
|
+
.modal-close { float: right; background: none; border: none; color: #94a3b8; font-size: 1.5em; cursor: pointer; min-width: 44px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; }
|
|
147
|
+
.modal h2 { color: #38bdf8; margin: 16px 0 8px; font-size: 1.1em; }
|
|
148
|
+
.modal p, .modal li { color: #cbd5e1; font-size: 0.9em; line-height: 1.6; }
|
|
149
|
+
.modal ul { padding-left: 20px; }
|
|
150
|
+
|
|
151
|
+
/* Mobile: screens < 480px */
|
|
152
|
+
@media (max-width: 480px) {
|
|
153
|
+
body { padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left)); }
|
|
154
|
+
h1 { font-size: 1.5em; margin: 12px 0 20px; }
|
|
155
|
+
.grid { grid-template-columns: 1fr; gap: 16px; }
|
|
156
|
+
.card { padding: 18px; }
|
|
157
|
+
.card-name { font-size: 1.25em; }
|
|
158
|
+
.card-role { font-size: 0.9em; }
|
|
159
|
+
.card-quote { font-size: 0.85em; }
|
|
160
|
+
.tip-row { font-size: 0.85em; }
|
|
161
|
+
.radar { width: 180px; height: 180px; }
|
|
162
|
+
.badge { font-size: 0.8em; padding: 5px 12px; }
|
|
163
|
+
.tag { font-size: 0.8em; padding: 5px 14px; }
|
|
164
|
+
|
|
165
|
+
.modal-overlay { padding: 0; align-items: stretch; }
|
|
166
|
+
.modal { max-width: 100%; max-height: 100vh; height: 100%; border-radius: 0; padding: 20px; padding-top: max(20px, env(safe-area-inset-top)); padding-bottom: max(20px, env(safe-area-inset-bottom)); }
|
|
167
|
+
.modal h2 { font-size: 1.15em; }
|
|
168
|
+
.modal p, .modal li { font-size: 0.95em; line-height: 1.7; }
|
|
169
|
+
}
|
|
170
|
+
</style>
|
|
171
|
+
</head>
|
|
172
|
+
<body>
|
|
173
|
+
|
|
174
|
+
<h1>VIPCare</h1>
|
|
175
|
+
<div class="grid" id="grid"></div>
|
|
176
|
+
|
|
177
|
+
<div class="modal-overlay" id="modal" onclick="if(event.target===this)closeModal()">
|
|
178
|
+
<div class="modal" id="modal-content"></div>
|
|
179
|
+
</div>
|
|
180
|
+
|
|
181
|
+
<script>
|
|
182
|
+
const cards = ${cardsJson};
|
|
183
|
+
|
|
184
|
+
const SCORE_LABELS = {
|
|
185
|
+
openness: 'Openness',
|
|
186
|
+
conscientiousness: 'Conscientiousness',
|
|
187
|
+
extraversion: 'Extraversion',
|
|
188
|
+
agreeableness: 'Agreeableness',
|
|
189
|
+
resilience: 'Resilience',
|
|
190
|
+
decision_style: 'Decision',
|
|
191
|
+
risk_appetite: 'Risk',
|
|
192
|
+
communication: 'Communication',
|
|
193
|
+
influence: 'Influence',
|
|
194
|
+
leadership: 'Leadership'
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
function radarSvg(scores, size = 200) {
|
|
198
|
+
const keys = Object.keys(SCORE_LABELS);
|
|
199
|
+
const cx = size / 2, cy = size / 2, r = size * 0.38;
|
|
200
|
+
const n = keys.length;
|
|
201
|
+
|
|
202
|
+
let gridLines = '';
|
|
203
|
+
for (let level = 1; level <= 5; level++) {
|
|
204
|
+
const lr = r * level / 5;
|
|
205
|
+
let pts = [];
|
|
206
|
+
for (let i = 0; i < n; i++) {
|
|
207
|
+
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
|
|
208
|
+
pts.push(\`\${cx + lr * Math.cos(angle)},\${cy + lr * Math.sin(angle)}\`);
|
|
209
|
+
}
|
|
210
|
+
gridLines += \`<polygon points="\${pts.join(' ')}" fill="none" stroke="#334155" stroke-width="0.5"/>\`;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let axes = '', labels = '', dataPoints = [];
|
|
214
|
+
for (let i = 0; i < n; i++) {
|
|
215
|
+
const angle = (Math.PI * 2 * i / n) - Math.PI / 2;
|
|
216
|
+
const x = cx + r * Math.cos(angle);
|
|
217
|
+
const y = cy + r * Math.sin(angle);
|
|
218
|
+
axes += \`<line x1="\${cx}" y1="\${cy}" x2="\${x}" y2="\${y}" stroke="#334155" stroke-width="0.5"/>\`;
|
|
219
|
+
|
|
220
|
+
const lx = cx + (r + 22) * Math.cos(angle);
|
|
221
|
+
const ly = cy + (r + 22) * Math.sin(angle);
|
|
222
|
+
const label = SCORE_LABELS[keys[i]] || keys[i];
|
|
223
|
+
labels += \`<text x="\${lx}" y="\${ly}" text-anchor="middle" dominant-baseline="middle" fill="#64748b" font-size="9">\${label}</text>\`;
|
|
224
|
+
|
|
225
|
+
const val = (scores[keys[i]] || 0) / 5;
|
|
226
|
+
const dx = cx + r * val * Math.cos(angle);
|
|
227
|
+
const dy = cy + r * val * Math.sin(angle);
|
|
228
|
+
dataPoints.push(\`\${dx},\${dy}\`);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const dataPolygon = \`<polygon points="\${dataPoints.join(' ')}" fill="rgba(56,189,248,0.2)" stroke="#38bdf8" stroke-width="1.5"/>\`;
|
|
232
|
+
|
|
233
|
+
return \`<svg viewBox="0 0 \${size} \${size}" class="radar">\${gridLines}\${axes}\${dataPolygon}\${labels}</svg>\`;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function renderCard(card, index) {
|
|
237
|
+
const scores = card.scores || {};
|
|
238
|
+
const radar = radarSvg(scores);
|
|
239
|
+
|
|
240
|
+
return \`
|
|
241
|
+
<div class="card" onclick="openModal(\${index})">
|
|
242
|
+
<div class="card-header">
|
|
243
|
+
<div>
|
|
244
|
+
<div class="card-name">\${card.name || 'Unknown'}</div>
|
|
245
|
+
<div class="card-role">\${card.title || ''}\${card.company ? ' @ ' + card.company : ''}</div>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="card-badges">
|
|
248
|
+
<span class="badge badge-disc">\${card.disc || '?'}</span>
|
|
249
|
+
<span class="badge badge-mbti">\${card.mbti || '?'}</span>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
\${card.quote ? \`<div class="card-quote">"\${card.quote.slice(0, 120)}\${card.quote.length > 120 ? '...' : ''}"</div>\` : ''}
|
|
253
|
+
<div class="radar-container">\${radar}</div>
|
|
254
|
+
\${card.superpower ? \`<div class="superpower">⚡ \${card.superpower}</div>\` : ''}
|
|
255
|
+
\${card.tags?.length ? \`<div class="tags">\${card.tags.map(t => \`<span class="tag">\${t}</span>\`).join('')}</div>\` : ''}
|
|
256
|
+
<div class="tips">
|
|
257
|
+
\${card.icebreakers?.length ? \`<div class="tip-row"><span class="tip-icon">💡</span><span class="tip-label">Icebreaker</span>\${card.icebreakers[0]}</div>\` : ''}
|
|
258
|
+
\${card.dos?.length ? \`<div class="tip-row"><span class="tip-icon">✅</span><span class="tip-label">Do</span>\${card.dos[0]}</div>\` : ''}
|
|
259
|
+
\${card.donts?.length ? \`<div class="tip-row"><span class="tip-icon">❌</span><span class="tip-label">Don't</span>\${card.donts[0]}</div>\` : ''}
|
|
260
|
+
</div>
|
|
261
|
+
</div>\`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function openModal(index) {
|
|
265
|
+
const card = cards[index];
|
|
266
|
+
const s = card.scores || {};
|
|
267
|
+
const modal = document.getElementById('modal-content');
|
|
268
|
+
|
|
269
|
+
modal.innerHTML = \`
|
|
270
|
+
<button class="modal-close" onclick="closeModal()">×</button>
|
|
271
|
+
<h1 style="color:#38bdf8;margin-bottom:4px">\${card.name}</h1>
|
|
272
|
+
<p style="color:#94a3b8">\${card.title || ''}\${card.company ? ' @ ' + card.company : ''}\${card.location ? ' · ' + card.location : ''}</p>
|
|
273
|
+
\${card.quote ? \`<div class="card-quote" style="margin:16px 0">"\${card.quote}"</div>\` : ''}
|
|
274
|
+
|
|
275
|
+
<h2>Personality</h2>
|
|
276
|
+
<p><strong>DISC:</strong> \${card.disc || '?'} <strong>MBTI:</strong> \${card.mbti || '?'}</p>
|
|
277
|
+
<div style="display:flex;justify-content:center;margin:16px 0">\${radarSvg(s, 260)}</div>
|
|
278
|
+
|
|
279
|
+
\${card.expertise?.length ? \`<h2>Expertise</h2><ul>\${card.expertise.map(e => \`<li>\${e}</li>\`).join('')}</ul>\` : ''}
|
|
280
|
+
\${card.superpower ? \`<p><strong>⚡ Superpower:</strong> \${card.superpower}</p>\` : ''}
|
|
281
|
+
|
|
282
|
+
<h2>How to Work With Them</h2>
|
|
283
|
+
\${card.icebreakers?.length ? \`<p><strong>💡 Icebreakers:</strong> \${card.icebreakers.join(', ')}</p>\` : ''}
|
|
284
|
+
\${card.dos?.length ? \`<p><strong>✅ Do:</strong> \${card.dos.join(' · ')}</p>\` : ''}
|
|
285
|
+
\${card.donts?.length ? \`<p><strong>❌ Don't:</strong> \${card.donts.join(' · ')}</p>\` : ''}
|
|
286
|
+
\${card.gifts?.length ? \`<p><strong>🎁 Gifts:</strong> \${card.gifts.join(', ')}</p>\` : ''}
|
|
287
|
+
|
|
288
|
+
\${card.tags?.length ? \`<h2>Tags</h2><div class="tags">\${card.tags.map(t => \`<span class="tag">\${t}</span>\`).join('')}</div>\` : ''}
|
|
289
|
+
\`;
|
|
290
|
+
|
|
291
|
+
document.getElementById('modal').classList.add('active');
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function closeModal() {
|
|
295
|
+
document.getElementById('modal').classList.remove('active');
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
|
|
299
|
+
|
|
300
|
+
// Render
|
|
301
|
+
const grid = document.getElementById('grid');
|
|
302
|
+
grid.innerHTML = cards.map((card, i) => renderCard(card, i)).join('');
|
|
303
|
+
</script>
|
|
304
|
+
</body>
|
|
305
|
+
</html>`;
|
|
306
|
+
}
|
package/lib/config.js
CHANGED
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import {
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
5
|
|
|
6
6
|
const CONFIG_DIR = path.join(os.homedir(), '.vip-crm');
|
|
7
7
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
8
8
|
const CHANGELOG_FILE = path.join(CONFIG_DIR, 'changelog.jsonl');
|
|
9
9
|
|
|
10
|
+
const TRANSCRIPTS_DIR = path.join(CONFIG_DIR, 'transcripts');
|
|
11
|
+
|
|
10
12
|
const DEFAULT_CONFIG = {
|
|
11
13
|
profiles_dir: path.join(os.homedir(), 'Projects', 'vip-crm', 'profiles'),
|
|
12
14
|
monitor_interval_hours: 24,
|
|
15
|
+
youtube_transcriber_path: path.join(os.homedir(), '.claude', 'skills', 'youtube-transcribe', 'youtube_transcriber.py'),
|
|
16
|
+
whisper_model: 'base',
|
|
17
|
+
transcript_max_chars: 15000,
|
|
13
18
|
};
|
|
14
19
|
|
|
20
|
+
export { TRANSCRIPTS_DIR };
|
|
21
|
+
|
|
15
22
|
export { CONFIG_DIR, CONFIG_FILE, CHANGELOG_FILE };
|
|
16
23
|
|
|
17
24
|
export function loadConfig() {
|
|
@@ -40,7 +47,7 @@ export function getProfilesDir() {
|
|
|
40
47
|
|
|
41
48
|
export function checkTool(name) {
|
|
42
49
|
try {
|
|
43
|
-
|
|
50
|
+
execFileSync('which', [name], { stdio: 'ignore' });
|
|
44
51
|
return true;
|
|
45
52
|
} catch {
|
|
46
53
|
return false;
|
package/lib/fetchers/search.js
CHANGED
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { checkTool } from '../config.js';
|
|
3
3
|
|
|
4
4
|
export function search(query, maxResults = 5) {
|
|
5
|
-
// Use ddgs CLI (installed via npm or pip)
|
|
6
5
|
if (checkTool('ddgs')) {
|
|
7
6
|
try {
|
|
8
|
-
const output =
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
const output = execFileSync('ddgs', ['text', query, '-m', String(maxResults), '-o', 'json'], {
|
|
8
|
+
encoding: 'utf-8',
|
|
9
|
+
timeout: 15000,
|
|
10
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
11
|
+
});
|
|
12
12
|
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
13
|
+
const parsed = JSON.parse(output);
|
|
14
|
+
if (!Array.isArray(parsed)) return [];
|
|
15
|
+
return parsed.map(r => ({
|
|
16
|
+
title: String(r.title || ''),
|
|
17
|
+
url: String(r.href || r.url || ''),
|
|
18
|
+
body: String(r.body || ''),
|
|
18
19
|
}));
|
|
19
20
|
} catch {
|
|
20
21
|
// fall through
|
|
@@ -24,12 +25,12 @@ export function search(query, maxResults = 5) {
|
|
|
24
25
|
// Fallback: use curl with DuckDuckGo lite
|
|
25
26
|
try {
|
|
26
27
|
const encoded = encodeURIComponent(query);
|
|
27
|
-
const output =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
28
|
+
const output = execFileSync('curl', ['-s', `https://lite.duckduckgo.com/lite/?q=${encoded}`, '-H', 'User-Agent: VIPCare/0.1'], {
|
|
29
|
+
encoding: 'utf-8',
|
|
30
|
+
timeout: 10000,
|
|
31
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
32
|
+
});
|
|
31
33
|
|
|
32
|
-
// Basic parsing of DDG lite HTML results
|
|
33
34
|
const results = [];
|
|
34
35
|
const linkRegex = /<a[^>]+href="([^"]+)"[^>]*class="result-link"[^>]*>([^<]+)<\/a>/g;
|
|
35
36
|
const snippetRegex = /<td class="result-snippet">([^<]+)<\/td>/g;
|
package/lib/fetchers/twitter.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { checkTool } from '../config.js';
|
|
3
3
|
|
|
4
4
|
export function isAvailable() {
|
|
@@ -42,7 +42,7 @@ export function fetchProfile(handle) {
|
|
|
42
42
|
const data = { handle, bio: '', displayName: '', tweets: [], rawOutput: '' };
|
|
43
43
|
|
|
44
44
|
try {
|
|
45
|
-
const output =
|
|
45
|
+
const output = execFileSync('bird', ['search', `from:${handle}`, '--count', '10'], {
|
|
46
46
|
encoding: 'utf-8',
|
|
47
47
|
timeout: 30000,
|
|
48
48
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -63,7 +63,7 @@ export function fetchTweetsByUrl(url) {
|
|
|
63
63
|
if (!isAvailable()) return null;
|
|
64
64
|
|
|
65
65
|
try {
|
|
66
|
-
return
|
|
66
|
+
return execFileSync('bird', ['read', url], {
|
|
67
67
|
encoding: 'utf-8',
|
|
68
68
|
timeout: 30000,
|
|
69
69
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { checkTool, loadConfig, TRANSCRIPTS_DIR } from '../config.js';
|
|
5
|
+
import { search } from './search.js';
|
|
6
|
+
|
|
7
|
+
export function isAvailable() {
|
|
8
|
+
if (!checkTool('python3')) return false;
|
|
9
|
+
|
|
10
|
+
const config = loadConfig();
|
|
11
|
+
return fs.existsSync(config.youtube_transcriber_path);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isYouTubeUrl(url) {
|
|
15
|
+
try {
|
|
16
|
+
const parsed = new URL(url);
|
|
17
|
+
return /^(www\.)?(youtube\.com|youtu\.be)$/.test(parsed.hostname) &&
|
|
18
|
+
(parsed.pathname.startsWith('/watch') || parsed.pathname.startsWith('/shorts/') || parsed.hostname === 'youtu.be');
|
|
19
|
+
} catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function transcribeVideo(url) {
|
|
25
|
+
if (!isYouTubeUrl(url)) {
|
|
26
|
+
throw new Error(`Not a YouTube URL: ${url}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (!isAvailable()) {
|
|
30
|
+
throw new Error(
|
|
31
|
+
'YouTube transcriber not available. Requires:\n' +
|
|
32
|
+
' - python3\n' +
|
|
33
|
+
' - yt-dlp (pip install yt-dlp)\n' +
|
|
34
|
+
' - whisper (pip install openai-whisper)\n' +
|
|
35
|
+
' - Transcriber script at ~/.claude/skills/youtube-transcribe/youtube_transcriber.py'
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const config = loadConfig();
|
|
40
|
+
const transcriptDir = TRANSCRIPTS_DIR;
|
|
41
|
+
fs.mkdirSync(transcriptDir, { recursive: true });
|
|
42
|
+
|
|
43
|
+
// Run the Python transcriber
|
|
44
|
+
try {
|
|
45
|
+
execFileSync('python3', [config.youtube_transcriber_path, url], {
|
|
46
|
+
encoding: 'utf-8',
|
|
47
|
+
timeout: 600000, // 10 minutes
|
|
48
|
+
cwd: transcriptDir,
|
|
49
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
50
|
+
});
|
|
51
|
+
} catch (e) {
|
|
52
|
+
throw new Error(`Transcription failed: ${e.stderr || e.message}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Find the most recent transcript and metadata files
|
|
56
|
+
const files = fs.readdirSync(path.join(transcriptDir, 'downloads'))
|
|
57
|
+
.filter(f => f.endsWith('_transcript.txt'))
|
|
58
|
+
.map(f => ({
|
|
59
|
+
name: f,
|
|
60
|
+
path: path.join(transcriptDir, 'downloads', f),
|
|
61
|
+
mtime: fs.statSync(path.join(transcriptDir, 'downloads', f)).mtimeMs,
|
|
62
|
+
}))
|
|
63
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
64
|
+
|
|
65
|
+
if (!files.length) {
|
|
66
|
+
throw new Error('No transcript file generated.');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const transcriptFile = files[0].path;
|
|
70
|
+
const metadataFile = transcriptFile.replace('_transcript.txt', '_metadata.json');
|
|
71
|
+
|
|
72
|
+
let transcript = fs.readFileSync(transcriptFile, 'utf-8');
|
|
73
|
+
let metadata = {};
|
|
74
|
+
|
|
75
|
+
if (fs.existsSync(metadataFile)) {
|
|
76
|
+
metadata = JSON.parse(fs.readFileSync(metadataFile, 'utf-8'));
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Truncate transcript if too long
|
|
80
|
+
const maxChars = config.transcript_max_chars || 15000;
|
|
81
|
+
if (transcript.length > maxChars) {
|
|
82
|
+
transcript = transcript.slice(0, maxChars) + `\n\n[Transcript truncated at ${maxChars} characters. Full transcript: ${transcriptFile}]`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Clean up the MP3 to save space
|
|
86
|
+
const mp3File = transcriptFile.replace('_transcript.txt', '.mp3');
|
|
87
|
+
if (fs.existsSync(mp3File)) {
|
|
88
|
+
try { fs.unlinkSync(mp3File); } catch { /* ignore */ }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
title: metadata.title || 'Unknown Video',
|
|
93
|
+
url,
|
|
94
|
+
uploader: metadata.uploader || '',
|
|
95
|
+
duration: metadata.duration || 0,
|
|
96
|
+
transcript,
|
|
97
|
+
transcriptFile,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function searchYouTubeVideos(personName, maxResults = 5) {
|
|
102
|
+
const query = `"${personName}" interview OR talk OR keynote OR podcast site:youtube.com`;
|
|
103
|
+
const results = search(query, maxResults);
|
|
104
|
+
|
|
105
|
+
return results
|
|
106
|
+
.filter(r => /youtube\.com|youtu\.be/.test(r.url))
|
|
107
|
+
.map(r => ({ title: r.title, url: r.url, body: r.body }));
|
|
108
|
+
}
|
package/lib/monitor.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import { CHANGELOG_FILE, getProfilesDir } from './config.js';
|
|
3
4
|
import { searchPerson } from './fetchers/search.js';
|
|
4
5
|
import * as twitter from './fetchers/twitter.js';
|
|
@@ -45,8 +46,7 @@ function gatherFreshData(meta) {
|
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export function appendChangelog(entry) {
|
|
48
|
-
|
|
49
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
49
|
+
fs.mkdirSync(path.dirname(CHANGELOG_FILE), { recursive: true });
|
|
50
50
|
fs.appendFileSync(CHANGELOG_FILE, JSON.stringify(entry) + '\n');
|
|
51
51
|
}
|
|
52
52
|
|
package/lib/profile.js
CHANGED
|
@@ -3,13 +3,22 @@ import path from 'path';
|
|
|
3
3
|
import { getProfilesDir } from './config.js';
|
|
4
4
|
|
|
5
5
|
export function slugify(name) {
|
|
6
|
-
|
|
6
|
+
let slug = name
|
|
7
7
|
.toLowerCase()
|
|
8
8
|
.trim()
|
|
9
9
|
.replace(/[^\w\s-]/g, '')
|
|
10
10
|
.replace(/[\s_]+/g, '-')
|
|
11
11
|
.replace(/-+/g, '-')
|
|
12
12
|
.replace(/^-|-$/g, '');
|
|
13
|
+
|
|
14
|
+
if (!slug) slug = 'unnamed';
|
|
15
|
+
return slug;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validateName(name) {
|
|
19
|
+
if (!name || typeof name !== 'string') return false;
|
|
20
|
+
const slug = slugify(name);
|
|
21
|
+
return slug !== 'unnamed' && slug.length > 0;
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
export function saveProfile(name, content, profilesDir) {
|
|
@@ -63,6 +72,7 @@ export function listProfiles(profilesDir) {
|
|
|
63
72
|
|
|
64
73
|
export function searchProfiles(keyword, profilesDir) {
|
|
65
74
|
const dir = profilesDir || getProfilesDir();
|
|
75
|
+
if (!fs.existsSync(dir)) return [];
|
|
66
76
|
const kw = keyword.toLowerCase();
|
|
67
77
|
const results = [];
|
|
68
78
|
|
package/lib/scheduler.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'fs';
|
|
2
2
|
import path from 'path';
|
|
3
3
|
import os from 'os';
|
|
4
|
-
import {
|
|
4
|
+
import { execFileSync } from 'child_process';
|
|
5
5
|
import { loadConfig } from './config.js';
|
|
6
6
|
|
|
7
7
|
const PLIST_NAME = 'com.vip-crm.monitor';
|
|
@@ -9,7 +9,7 @@ const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_N
|
|
|
9
9
|
|
|
10
10
|
function getVipPath() {
|
|
11
11
|
try {
|
|
12
|
-
return
|
|
12
|
+
return execFileSync('which', ['vip'], { encoding: 'utf-8' }).trim();
|
|
13
13
|
} catch {
|
|
14
14
|
const candidates = [
|
|
15
15
|
path.join(os.homedir(), '.npm-global', 'bin', 'vip'),
|
|
@@ -62,19 +62,19 @@ export function install() {
|
|
|
62
62
|
const plist = createPlist();
|
|
63
63
|
fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
|
|
64
64
|
fs.writeFileSync(PLIST_PATH, plist);
|
|
65
|
-
|
|
65
|
+
execFileSync('launchctl', ['load', PLIST_PATH]);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function uninstall() {
|
|
69
69
|
if (fs.existsSync(PLIST_PATH)) {
|
|
70
|
-
try {
|
|
70
|
+
try { execFileSync('launchctl', ['unload', PLIST_PATH]); } catch { /* ignore */ }
|
|
71
71
|
fs.unlinkSync(PLIST_PATH);
|
|
72
72
|
}
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
export function isRunning() {
|
|
76
76
|
try {
|
|
77
|
-
const output =
|
|
77
|
+
const output = execFileSync('launchctl', ['list'], { encoding: 'utf-8' });
|
|
78
78
|
return output.includes(PLIST_NAME);
|
|
79
79
|
} catch {
|
|
80
80
|
return false;
|
package/lib/synthesizer.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
2
|
import { checkTool, loadConfig } from './config.js';
|
|
3
3
|
import { PROFILE_SYSTEM_PROMPT, CHANGE_DETECTION_PROMPT } from './templates.js';
|
|
4
4
|
|
|
@@ -23,7 +23,7 @@ function getBackend() {
|
|
|
23
23
|
|
|
24
24
|
function copilotAvailable() {
|
|
25
25
|
try {
|
|
26
|
-
|
|
26
|
+
execFileSync('gh', ['copilot', '--help'], { stdio: 'ignore', timeout: 5000 });
|
|
27
27
|
return true;
|
|
28
28
|
} catch {
|
|
29
29
|
return false;
|
|
@@ -31,7 +31,7 @@ function copilotAvailable() {
|
|
|
31
31
|
}
|
|
32
32
|
|
|
33
33
|
function callClaudeCli(prompt, timeout = 120000) {
|
|
34
|
-
const result =
|
|
34
|
+
const result = execFileSync('claude', ['--print', '-p', prompt], {
|
|
35
35
|
encoding: 'utf-8',
|
|
36
36
|
timeout,
|
|
37
37
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -57,7 +57,7 @@ async function callAnthropicApi(prompt) {
|
|
|
57
57
|
const client = new anthropic.default({ apiKey });
|
|
58
58
|
const message = await client.messages.create({
|
|
59
59
|
model,
|
|
60
|
-
max_tokens:
|
|
60
|
+
max_tokens: 8192,
|
|
61
61
|
messages: [{ role: 'user', content: prompt }],
|
|
62
62
|
});
|
|
63
63
|
|
|
@@ -65,7 +65,7 @@ async function callAnthropicApi(prompt) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
function callCopilotCli(prompt, timeout = 120000) {
|
|
68
|
-
const result =
|
|
68
|
+
const result = execFileSync('gh', ['copilot', 'suggest', '-t', 'shell', prompt], {
|
|
69
69
|
encoding: 'utf-8',
|
|
70
70
|
timeout,
|
|
71
71
|
stdio: ['pipe', 'pipe', 'pipe'],
|