vipcare 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 +98 -0
- package/bin/vip.js +351 -0
- package/lib/config.js +48 -0
- package/lib/fetchers/search.js +73 -0
- package/lib/fetchers/twitter.js +74 -0
- package/lib/fetchers/web.js +29 -0
- package/lib/monitor.js +109 -0
- package/lib/profile.js +106 -0
- package/lib/resolver.js +95 -0
- package/lib/scheduler.js +92 -0
- package/lib/synthesizer.js +124 -0
- package/lib/templates.js +66 -0
- package/package.json +24 -0
- package/profiles/.gitkeep +0 -0
- package/profiles/sam-altman.md +49 -0
- package/skill/vip.md +96 -0
- package/tests/fetchers.test.js +21 -0
- package/tests/monitor.test.js +28 -0
- package/tests/profile.test.js +89 -0
- package/tests/resolver.test.js +40 -0
- package/tests/scheduler.test.js +22 -0
package/lib/monitor.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import { CHANGELOG_FILE, getProfilesDir } from './config.js';
|
|
3
|
+
import { searchPerson } from './fetchers/search.js';
|
|
4
|
+
import * as twitter from './fetchers/twitter.js';
|
|
5
|
+
import { listProfiles, loadProfile, saveProfile } from './profile.js';
|
|
6
|
+
import { synthesizeProfile, detectChanges } from './synthesizer.js';
|
|
7
|
+
|
|
8
|
+
export function extractMetadata(content) {
|
|
9
|
+
const meta = { twitterHandle: null, linkedinUrl: null, name: null };
|
|
10
|
+
|
|
11
|
+
const nameMatch = content.match(/^# (.+)$/m);
|
|
12
|
+
if (nameMatch) meta.name = nameMatch[1];
|
|
13
|
+
|
|
14
|
+
let twMatch = content.match(/twitter\.com\/(\w+)/i);
|
|
15
|
+
if (!twMatch) twMatch = content.match(/x\.com\/(\w+)/i);
|
|
16
|
+
if (twMatch) meta.twitterHandle = twMatch[1];
|
|
17
|
+
|
|
18
|
+
const liMatch = content.match(/(https?:\/\/[^/]*linkedin\.com\/in\/[^\s)]+)/);
|
|
19
|
+
if (liMatch) meta.linkedinUrl = liMatch[1];
|
|
20
|
+
|
|
21
|
+
return meta;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function gatherFreshData(meta) {
|
|
25
|
+
const rawParts = [];
|
|
26
|
+
const sources = [];
|
|
27
|
+
|
|
28
|
+
if (meta.twitterHandle) {
|
|
29
|
+
const data = twitter.fetchProfile(meta.twitterHandle);
|
|
30
|
+
if (data?.rawOutput) {
|
|
31
|
+
rawParts.push(`=== Twitter (@${meta.twitterHandle}) ===\n${data.rawOutput}`);
|
|
32
|
+
sources.push(`https://twitter.com/${meta.twitterHandle}`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (meta.name) {
|
|
37
|
+
const results = searchPerson(meta.name);
|
|
38
|
+
for (const r of results) {
|
|
39
|
+
rawParts.push(`${r.title}\n${r.body}`);
|
|
40
|
+
sources.push(r.url);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return [rawParts.join('\n\n'), sources];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function appendChangelog(entry) {
|
|
48
|
+
const dir = CHANGELOG_FILE.substring(0, CHANGELOG_FILE.lastIndexOf('/'));
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
50
|
+
fs.appendFileSync(CHANGELOG_FILE, JSON.stringify(entry) + '\n');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function readChangelog(days = 30) {
|
|
54
|
+
if (!fs.existsSync(CHANGELOG_FILE)) return [];
|
|
55
|
+
|
|
56
|
+
const cutoff = Date.now() - days * 86400000;
|
|
57
|
+
const lines = fs.readFileSync(CHANGELOG_FILE, 'utf-8').split('\n').filter(Boolean);
|
|
58
|
+
|
|
59
|
+
return lines
|
|
60
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
61
|
+
.filter(e => e && new Date(e.timestamp).getTime() >= cutoff);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function unreadCount() {
|
|
65
|
+
return readChangelog(7).length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function runMonitor(profilesDir, verbose = false) {
|
|
69
|
+
const dir = profilesDir || getProfilesDir();
|
|
70
|
+
const changes = [];
|
|
71
|
+
const profiles = listProfiles(dir);
|
|
72
|
+
|
|
73
|
+
for (const p of profiles) {
|
|
74
|
+
if (verbose) console.log(` Checking ${p.name}...`);
|
|
75
|
+
|
|
76
|
+
const oldContent = loadProfile(p.slug, dir);
|
|
77
|
+
if (!oldContent) continue;
|
|
78
|
+
|
|
79
|
+
const meta = extractMetadata(oldContent);
|
|
80
|
+
const [newData, sources] = gatherFreshData(meta);
|
|
81
|
+
|
|
82
|
+
if (!newData.trim()) {
|
|
83
|
+
if (verbose) console.log(' No new data found, skipping.');
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const changeSummary = await detectChanges(oldContent, newData);
|
|
88
|
+
|
|
89
|
+
if (changeSummary) {
|
|
90
|
+
const newProfile = await synthesizeProfile(newData, sources);
|
|
91
|
+
saveProfile(p.name, newProfile, dir);
|
|
92
|
+
|
|
93
|
+
const entry = {
|
|
94
|
+
timestamp: new Date().toISOString(),
|
|
95
|
+
name: p.name,
|
|
96
|
+
slug: p.slug,
|
|
97
|
+
summary: changeSummary,
|
|
98
|
+
};
|
|
99
|
+
appendChangelog(entry);
|
|
100
|
+
changes.push(entry);
|
|
101
|
+
|
|
102
|
+
if (verbose) console.log(` Changes detected: ${changeSummary}`);
|
|
103
|
+
} else if (verbose) {
|
|
104
|
+
console.log(' No significant changes.');
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return changes;
|
|
109
|
+
}
|
package/lib/profile.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { getProfilesDir } from './config.js';
|
|
4
|
+
|
|
5
|
+
export function slugify(name) {
|
|
6
|
+
return name
|
|
7
|
+
.toLowerCase()
|
|
8
|
+
.trim()
|
|
9
|
+
.replace(/[^\w\s-]/g, '')
|
|
10
|
+
.replace(/[\s_]+/g, '-')
|
|
11
|
+
.replace(/-+/g, '-')
|
|
12
|
+
.replace(/^-|-$/g, '');
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveProfile(name, content, profilesDir) {
|
|
16
|
+
const dir = profilesDir || getProfilesDir();
|
|
17
|
+
const slug = slugify(name);
|
|
18
|
+
const filepath = path.join(dir, `${slug}.md`);
|
|
19
|
+
fs.writeFileSync(filepath, content, 'utf-8');
|
|
20
|
+
return filepath;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function loadProfile(nameOrSlug, profilesDir) {
|
|
24
|
+
const dir = profilesDir || getProfilesDir();
|
|
25
|
+
const slug = slugify(nameOrSlug);
|
|
26
|
+
const filepath = path.join(dir, `${slug}.md`);
|
|
27
|
+
|
|
28
|
+
if (fs.existsSync(filepath)) {
|
|
29
|
+
return fs.readFileSync(filepath, 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Fuzzy match
|
|
33
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
|
|
34
|
+
const matches = files.filter(f => f.replace('.md', '').includes(slug));
|
|
35
|
+
if (matches.length === 1) {
|
|
36
|
+
return fs.readFileSync(path.join(dir, matches[0]), 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function listProfiles(profilesDir) {
|
|
43
|
+
const dir = profilesDir || getProfilesDir();
|
|
44
|
+
if (!fs.existsSync(dir)) return [];
|
|
45
|
+
|
|
46
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort();
|
|
47
|
+
return files.map(f => {
|
|
48
|
+
const filepath = path.join(dir, f);
|
|
49
|
+
const content = fs.readFileSync(filepath, 'utf-8');
|
|
50
|
+
|
|
51
|
+
const nameMatch = content.match(/^# (.+)$/m);
|
|
52
|
+
const name = nameMatch ? nameMatch[1] : f.replace('.md', '').replace(/-/g, ' ');
|
|
53
|
+
|
|
54
|
+
const summaryMatch = content.match(/^> (.+)$/m);
|
|
55
|
+
const summary = summaryMatch ? summaryMatch[1] : '';
|
|
56
|
+
|
|
57
|
+
const stat = fs.statSync(filepath);
|
|
58
|
+
const updated = stat.mtime.toISOString().slice(0, 10);
|
|
59
|
+
|
|
60
|
+
return { slug: f.replace('.md', ''), name, summary, updated, path: filepath };
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function searchProfiles(keyword, profilesDir) {
|
|
65
|
+
const dir = profilesDir || getProfilesDir();
|
|
66
|
+
const kw = keyword.toLowerCase();
|
|
67
|
+
const results = [];
|
|
68
|
+
|
|
69
|
+
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md')).sort();
|
|
70
|
+
for (const f of files) {
|
|
71
|
+
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
|
|
72
|
+
if (content.toLowerCase().includes(kw)) {
|
|
73
|
+
const nameMatch = content.match(/^# (.+)$/m);
|
|
74
|
+
const name = nameMatch ? nameMatch[1] : f.replace('.md', '');
|
|
75
|
+
|
|
76
|
+
const matches = content.split('\n')
|
|
77
|
+
.filter(line => line.toLowerCase().includes(kw))
|
|
78
|
+
.slice(0, 3)
|
|
79
|
+
.map(line => line.trim());
|
|
80
|
+
|
|
81
|
+
results.push({ slug: f.replace('.md', ''), name, matches, path: path.join(dir, f) });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return results;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function profileExists(name, profilesDir) {
|
|
89
|
+
const dir = profilesDir || getProfilesDir();
|
|
90
|
+
return fs.existsSync(path.join(dir, `${slugify(name)}.md`));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function getProfilePath(name, profilesDir) {
|
|
94
|
+
const dir = profilesDir || getProfilesDir();
|
|
95
|
+
return path.join(dir, `${slugify(name)}.md`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function deleteProfile(name, profilesDir) {
|
|
99
|
+
const dir = profilesDir || getProfilesDir();
|
|
100
|
+
const filepath = path.join(dir, `${slugify(name)}.md`);
|
|
101
|
+
if (fs.existsSync(filepath)) {
|
|
102
|
+
fs.unlinkSync(filepath);
|
|
103
|
+
return true;
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
package/lib/resolver.js
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { search, searchPerson } from './fetchers/search.js';
|
|
2
|
+
|
|
3
|
+
const TWITTER_IGNORE = new Set(['search', 'explore', 'home', 'hashtag', 'i', 'settings', 'login']);
|
|
4
|
+
|
|
5
|
+
export function parseTwitterHandle(url) {
|
|
6
|
+
const match = url.match(/(?:twitter\.com|x\.com)\/(@?\w+)/);
|
|
7
|
+
if (match) {
|
|
8
|
+
const handle = match[1].replace(/^@/, '');
|
|
9
|
+
if (!TWITTER_IGNORE.has(handle.toLowerCase())) return handle;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function parseLinkedinUrl(url) {
|
|
15
|
+
if (!url.includes('linkedin.com/in/')) return null;
|
|
16
|
+
const match = url.match(/(https?:\/\/[^/]*linkedin\.com\/in\/[^/?#]+)/);
|
|
17
|
+
return match ? match[1] : null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function linkedinMatchesPerson(snippetTitle, snippetBody, personName) {
|
|
21
|
+
const parts = personName.toLowerCase().split(/\s+/);
|
|
22
|
+
if (parts.length < 2) return true;
|
|
23
|
+
const text = (snippetTitle + ' ' + snippetBody).toLowerCase();
|
|
24
|
+
return text.includes(parts[0]) && text.includes(parts[parts.length - 1]);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractRealName(snippets, handle) {
|
|
28
|
+
for (const snippet of snippets) {
|
|
29
|
+
const re1 = new RegExp(`([A-Z][a-z]+ [A-Z][a-z]+(?:\\s[A-Z][a-z]+)?)\\s*(?:\\(|[-–—/|,])\\s*@?${handle}`, '');
|
|
30
|
+
const m1 = snippet.match(re1);
|
|
31
|
+
if (m1) return m1[1];
|
|
32
|
+
|
|
33
|
+
const re2 = new RegExp(`@?${handle}\\s*(?:\\)|[-–—/|,])\\s*([A-Z][a-z]+ [A-Z][a-z]+)`, '');
|
|
34
|
+
const m2 = snippet.match(re2);
|
|
35
|
+
if (m2) return m2[1];
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function resolveFromUrl(url) {
|
|
41
|
+
const person = { name: '', twitterHandle: null, linkedinUrl: null, otherUrls: [], rawSnippets: [] };
|
|
42
|
+
|
|
43
|
+
const handle = parseTwitterHandle(url);
|
|
44
|
+
if (handle) {
|
|
45
|
+
person.twitterHandle = handle;
|
|
46
|
+
|
|
47
|
+
const results = search(`@${handle} twitter`, 5);
|
|
48
|
+
const snippets = results.map(r => `${r.title}\n${r.body}`);
|
|
49
|
+
|
|
50
|
+
person.name = extractRealName(snippets, handle) || handle;
|
|
51
|
+
person.rawSnippets = snippets;
|
|
52
|
+
return person;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const linkedin = parseLinkedinUrl(url);
|
|
56
|
+
if (linkedin) {
|
|
57
|
+
person.linkedinUrl = linkedin;
|
|
58
|
+
const match = linkedin.match(/\/in\/([^/?#]+)/);
|
|
59
|
+
if (match) person.name = match[1].replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
60
|
+
return person;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
person.otherUrls.push(url);
|
|
64
|
+
return person;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function resolveFromName(name, company) {
|
|
68
|
+
const results = searchPerson(name, company);
|
|
69
|
+
const person = { name, twitterHandle: null, linkedinUrl: null, otherUrls: [], rawSnippets: [] };
|
|
70
|
+
|
|
71
|
+
for (const r of results) {
|
|
72
|
+
if (!person.twitterHandle) {
|
|
73
|
+
const handle = parseTwitterHandle(r.url);
|
|
74
|
+
if (handle) person.twitterHandle = handle;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!person.linkedinUrl) {
|
|
78
|
+
const linkedin = parseLinkedinUrl(r.url);
|
|
79
|
+
if (linkedin && linkedinMatchesPerson(r.title, r.body, name)) {
|
|
80
|
+
person.linkedinUrl = linkedin;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (!person.otherUrls.includes(r.url)) person.otherUrls.push(r.url);
|
|
85
|
+
|
|
86
|
+
const snippet = `${r.title}\n${r.body}`;
|
|
87
|
+
if (!person.rawSnippets.includes(snippet)) person.rawSnippets.push(snippet);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return person;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function isUrl(text) {
|
|
94
|
+
return /^(https?:\/\/|twitter\.com|x\.com|linkedin\.com)/.test(text);
|
|
95
|
+
}
|
package/lib/scheduler.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
import { loadConfig } from './config.js';
|
|
6
|
+
|
|
7
|
+
const PLIST_NAME = 'com.vip-crm.monitor';
|
|
8
|
+
const PLIST_PATH = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_NAME}.plist`);
|
|
9
|
+
|
|
10
|
+
function getVipPath() {
|
|
11
|
+
try {
|
|
12
|
+
return execSync('which vip', { encoding: 'utf-8' }).trim();
|
|
13
|
+
} catch {
|
|
14
|
+
const candidates = [
|
|
15
|
+
path.join(os.homedir(), '.npm-global', 'bin', 'vip'),
|
|
16
|
+
'/opt/homebrew/bin/vip',
|
|
17
|
+
'/usr/local/bin/vip',
|
|
18
|
+
];
|
|
19
|
+
for (const p of candidates) {
|
|
20
|
+
if (fs.existsSync(p)) return p;
|
|
21
|
+
}
|
|
22
|
+
throw new Error("Cannot find 'vip' executable. Is it installed?");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function createPlist(intervalHours) {
|
|
27
|
+
if (!intervalHours) {
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
intervalHours = config.monitor_interval_hours || 24;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const vipPath = getVipPath();
|
|
33
|
+
const intervalSeconds = intervalHours * 3600;
|
|
34
|
+
const logDir = path.join(os.homedir(), '.vip-crm');
|
|
35
|
+
|
|
36
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
37
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
38
|
+
<plist version="1.0">
|
|
39
|
+
<dict>
|
|
40
|
+
<key>Label</key>
|
|
41
|
+
<string>${PLIST_NAME}</string>
|
|
42
|
+
<key>ProgramArguments</key>
|
|
43
|
+
<array>
|
|
44
|
+
<string>${vipPath}</string>
|
|
45
|
+
<string>monitor</string>
|
|
46
|
+
<string>run</string>
|
|
47
|
+
</array>
|
|
48
|
+
<key>StartInterval</key>
|
|
49
|
+
<integer>${intervalSeconds}</integer>
|
|
50
|
+
<key>RunAtLoad</key>
|
|
51
|
+
<false/>
|
|
52
|
+
<key>StandardOutPath</key>
|
|
53
|
+
<string>${logDir}/monitor.log</string>
|
|
54
|
+
<key>StandardErrorPath</key>
|
|
55
|
+
<string>${logDir}/monitor-error.log</string>
|
|
56
|
+
</dict>
|
|
57
|
+
</plist>
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function install() {
|
|
62
|
+
const plist = createPlist();
|
|
63
|
+
fs.mkdirSync(path.dirname(PLIST_PATH), { recursive: true });
|
|
64
|
+
fs.writeFileSync(PLIST_PATH, plist);
|
|
65
|
+
execSync(`launchctl load "${PLIST_PATH}"`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function uninstall() {
|
|
69
|
+
if (fs.existsSync(PLIST_PATH)) {
|
|
70
|
+
try { execSync(`launchctl unload "${PLIST_PATH}"`); } catch { /* ignore */ }
|
|
71
|
+
fs.unlinkSync(PLIST_PATH);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function isRunning() {
|
|
76
|
+
try {
|
|
77
|
+
const output = execSync('launchctl list', { encoding: 'utf-8' });
|
|
78
|
+
return output.includes(PLIST_NAME);
|
|
79
|
+
} catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function status() {
|
|
85
|
+
const config = loadConfig();
|
|
86
|
+
return {
|
|
87
|
+
installed: fs.existsSync(PLIST_PATH),
|
|
88
|
+
running: isRunning(),
|
|
89
|
+
intervalHours: config.monitor_interval_hours || 24,
|
|
90
|
+
plistPath: PLIST_PATH,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { execSync } from 'child_process';
|
|
2
|
+
import { checkTool, loadConfig } from './config.js';
|
|
3
|
+
import { PROFILE_SYSTEM_PROMPT, CHANGE_DETECTION_PROMPT } from './templates.js';
|
|
4
|
+
|
|
5
|
+
function getBackend() {
|
|
6
|
+
const envBackend = process.env.VIP_AI_BACKEND?.toLowerCase();
|
|
7
|
+
if (envBackend) return envBackend;
|
|
8
|
+
|
|
9
|
+
const config = loadConfig();
|
|
10
|
+
if (config.ai_backend) return config.ai_backend.toLowerCase();
|
|
11
|
+
|
|
12
|
+
if (process.env.ANTHROPIC_API_KEY) return 'anthropic';
|
|
13
|
+
if (checkTool('claude')) return 'claude-cli';
|
|
14
|
+
if (checkTool('gh') && copilotAvailable()) return 'copilot-cli';
|
|
15
|
+
|
|
16
|
+
throw new Error(
|
|
17
|
+
'No AI backend available. Options:\n' +
|
|
18
|
+
' 1. Install Claude Code CLI\n' +
|
|
19
|
+
' 2. Set ANTHROPIC_API_KEY env var\n' +
|
|
20
|
+
' 3. Install GitHub Copilot CLI (gh copilot)'
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function copilotAvailable() {
|
|
25
|
+
try {
|
|
26
|
+
execSync('gh copilot --help', { stdio: 'ignore', timeout: 5000 });
|
|
27
|
+
return true;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function callClaudeCli(prompt, timeout = 120000) {
|
|
34
|
+
const result = execSync(`claude --print -p ${JSON.stringify(prompt)}`, {
|
|
35
|
+
encoding: 'utf-8',
|
|
36
|
+
timeout,
|
|
37
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
38
|
+
maxBuffer: 1024 * 1024 * 10,
|
|
39
|
+
});
|
|
40
|
+
return result.trim();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function callAnthropicApi(prompt) {
|
|
44
|
+
let anthropic;
|
|
45
|
+
try {
|
|
46
|
+
anthropic = await import('@anthropic-ai/sdk');
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error('Anthropic SDK not installed. Run: npm install @anthropic-ai/sdk');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const apiKey = process.env.ANTHROPIC_API_KEY;
|
|
52
|
+
if (!apiKey) throw new Error('ANTHROPIC_API_KEY environment variable not set.');
|
|
53
|
+
|
|
54
|
+
const config = loadConfig();
|
|
55
|
+
const model = config.anthropic_model || 'claude-sonnet-4-20250514';
|
|
56
|
+
|
|
57
|
+
const client = new anthropic.default({ apiKey });
|
|
58
|
+
const message = await client.messages.create({
|
|
59
|
+
model,
|
|
60
|
+
max_tokens: 4096,
|
|
61
|
+
messages: [{ role: 'user', content: prompt }],
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
return message.content[0].text.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function callCopilotCli(prompt, timeout = 120000) {
|
|
68
|
+
const result = execSync(`gh copilot suggest -t shell ${JSON.stringify(prompt)}`, {
|
|
69
|
+
encoding: 'utf-8',
|
|
70
|
+
timeout,
|
|
71
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
72
|
+
});
|
|
73
|
+
return result.trim();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function callBackend(prompt, backend) {
|
|
77
|
+
if (backend === 'claude-cli') return callClaudeCli(prompt);
|
|
78
|
+
if (backend === 'anthropic') return await callAnthropicApi(prompt);
|
|
79
|
+
if (backend === 'copilot-cli') return callCopilotCli(prompt);
|
|
80
|
+
throw new Error(`Unknown AI backend: ${backend}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function getBackendName() {
|
|
84
|
+
try {
|
|
85
|
+
return getBackend();
|
|
86
|
+
} catch {
|
|
87
|
+
return 'none';
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function synthesizeProfile(rawData, sources) {
|
|
92
|
+
const backend = getBackend();
|
|
93
|
+
const prompt = `${PROFILE_SYSTEM_PROMPT}\n\nRaw data:\n${rawData}`;
|
|
94
|
+
|
|
95
|
+
const profile = await callBackend(prompt, backend);
|
|
96
|
+
|
|
97
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
98
|
+
let footer = `\n\n---\n*Last updated: ${today}*`;
|
|
99
|
+
if (sources?.length) {
|
|
100
|
+
footer += `\n*Sources: ${sources.slice(0, 10).join(', ')}*`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return profile + footer;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function detectChanges(oldProfile, newData) {
|
|
107
|
+
let backend;
|
|
108
|
+
try {
|
|
109
|
+
backend = getBackend();
|
|
110
|
+
} catch {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const prompt = CHANGE_DETECTION_PROMPT
|
|
115
|
+
.replace('{old_profile}', oldProfile)
|
|
116
|
+
.replace('{new_data}', newData);
|
|
117
|
+
|
|
118
|
+
try {
|
|
119
|
+
const output = await callBackend(prompt, backend);
|
|
120
|
+
return output.includes('NO_SIGNIFICANT_CHANGES') ? null : output;
|
|
121
|
+
} catch {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/lib/templates.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
export const PROFILE_SYSTEM_PROMPT = `You are a research assistant building a VIP contact profile.
|
|
2
|
+
Given the raw data below from public sources (tweets, LinkedIn snippets, web search results), \
|
|
3
|
+
synthesize a clean profile in the exact Markdown format provided.
|
|
4
|
+
|
|
5
|
+
Rules:
|
|
6
|
+
- Only include information you can directly support from the provided data
|
|
7
|
+
- If a section has no data, write "No public information found."
|
|
8
|
+
- Do not fabricate or hallucinate details
|
|
9
|
+
- Write in a neutral, professional tone
|
|
10
|
+
- For the summary line, write a concise one-line description of who this person is
|
|
11
|
+
- Output ONLY the Markdown profile, no extra commentary
|
|
12
|
+
|
|
13
|
+
Format:
|
|
14
|
+
# {Full Name}
|
|
15
|
+
|
|
16
|
+
> {One-line summary / tagline}
|
|
17
|
+
|
|
18
|
+
## Basic Info
|
|
19
|
+
- **Title:** {Current role}
|
|
20
|
+
- **Company:** {Current company}
|
|
21
|
+
- **Location:** {City, Country}
|
|
22
|
+
- **Industry:** {Industry/domain}
|
|
23
|
+
|
|
24
|
+
## Links
|
|
25
|
+
- Twitter: {url}
|
|
26
|
+
- LinkedIn: {url}
|
|
27
|
+
- Website: {url if found}
|
|
28
|
+
|
|
29
|
+
## Bio
|
|
30
|
+
{2-3 paragraph biography synthesized from public sources}
|
|
31
|
+
|
|
32
|
+
## Key Interests & Topics
|
|
33
|
+
- {Topic 1}
|
|
34
|
+
- {Topic 2}
|
|
35
|
+
- {Topic 3}
|
|
36
|
+
|
|
37
|
+
## Notable Achievements
|
|
38
|
+
- {Achievement 1}
|
|
39
|
+
- {Achievement 2}
|
|
40
|
+
|
|
41
|
+
## Recent Activity
|
|
42
|
+
- {Summary of recent public posts/tweets/news}
|
|
43
|
+
|
|
44
|
+
## Background
|
|
45
|
+
{Education, career history, other public background}
|
|
46
|
+
|
|
47
|
+
## Personal
|
|
48
|
+
{Family info, hobbies, or personal details only if publicly shared}
|
|
49
|
+
|
|
50
|
+
## Notes
|
|
51
|
+
{Any other relevant public information}`;
|
|
52
|
+
|
|
53
|
+
export const CHANGE_DETECTION_PROMPT = `Compare the OLD profile and NEW data below. Identify any significant changes such as:
|
|
54
|
+
- Job title or company change
|
|
55
|
+
- New achievements or milestones
|
|
56
|
+
- Notable new public statements or positions
|
|
57
|
+
- Changes in focus areas or interests
|
|
58
|
+
|
|
59
|
+
If there are significant changes, output a brief summary (2-3 sentences) of what changed.
|
|
60
|
+
If there are no significant changes, output exactly: NO_SIGNIFICANT_CHANGES
|
|
61
|
+
|
|
62
|
+
OLD PROFILE:
|
|
63
|
+
{old_profile}
|
|
64
|
+
|
|
65
|
+
NEW DATA:
|
|
66
|
+
{new_data}`;
|
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "vipcare",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Auto-build VIP person profiles from Twitter/LinkedIn public data",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"vip": "bin/vip.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test tests/*.test.js"
|
|
11
|
+
},
|
|
12
|
+
"keywords": [
|
|
13
|
+
"crm",
|
|
14
|
+
"profile",
|
|
15
|
+
"twitter",
|
|
16
|
+
"linkedin",
|
|
17
|
+
"cli"
|
|
18
|
+
],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"commander": "^13.0.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {}
|
|
24
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Sam Altman
|
|
2
|
+
|
|
3
|
+
> CEO of OpenAI, leading the development and deployment of artificial general intelligence
|
|
4
|
+
|
|
5
|
+
## Basic Info
|
|
6
|
+
- **Title:** CEO
|
|
7
|
+
- **Company:** OpenAI
|
|
8
|
+
- **Location:** San Francisco, USA
|
|
9
|
+
- **Industry:** Artificial Intelligence
|
|
10
|
+
|
|
11
|
+
## Links
|
|
12
|
+
- Twitter: https://x.com/sama
|
|
13
|
+
- LinkedIn: No public information found.
|
|
14
|
+
- Website: No public information found.
|
|
15
|
+
|
|
16
|
+
## Bio
|
|
17
|
+
Sam Altman is the CEO of OpenAI, the artificial intelligence research and deployment company behind ChatGPT and the GPT series of large language models. He is a prominent figure in the technology industry, known for his leadership in advancing AI capabilities while advocating for AI safety.
|
|
18
|
+
|
|
19
|
+
Before leading OpenAI, Altman served as President of Y Combinator (YC), one of Silicon Valley's most influential startup accelerators. In that role he funded and mentored numerous startups, building a reputation as a key connector and investor in the tech ecosystem.
|
|
20
|
+
|
|
21
|
+
Under his leadership, OpenAI has launched products including GPT-4, GPT-5, and ChatGPT, which have become some of the most widely used AI systems in the world. Altman has also navigated significant partnerships, including agreements with the U.S. Department of War for classified AI deployments.
|
|
22
|
+
|
|
23
|
+
## Key Interests & Topics
|
|
24
|
+
- Artificial intelligence safety and alignment
|
|
25
|
+
- Broad distribution of AI benefits
|
|
26
|
+
- AI product development and deployment (GPT series, ChatGPT)
|
|
27
|
+
|
|
28
|
+
## Notable Achievements
|
|
29
|
+
- Led OpenAI as CEO through the launch of ChatGPT and the GPT model series
|
|
30
|
+
- Served as President of Y Combinator, mentoring and funding hundreds of startups
|
|
31
|
+
|
|
32
|
+
## Recent Activity
|
|
33
|
+
- **Aug 2025:** Announced GPT-5 rollout updates, including doubled rate limits for ChatGPT Plus users and continued access to legacy models like GPT-4o
|
|
34
|
+
- **Feb 2026:** Announced an agreement with the Department of War to deploy OpenAI models on their classified network, emphasizing AI safety principles
|
|
35
|
+
- **Mar 2026:** Published an internal post detailing amendments to the DoW agreement, including language reinforcing constitutional and legal safeguards (Fourth Amendment, National Security Act, FISA)
|
|
36
|
+
|
|
37
|
+
## Background
|
|
38
|
+
Sam Altman was President of Y Combinator before becoming CEO of OpenAI. He has been active in the startup and technology community since at least the early 2010s, funding companies and serving as a mentor to entrepreneurs.
|
|
39
|
+
|
|
40
|
+
## Personal
|
|
41
|
+
No public information found.
|
|
42
|
+
|
|
43
|
+
## Notes
|
|
44
|
+
- Twitter handle @sama has been active since July 2006
|
|
45
|
+
- Note: multiple unrelated individuals share the "Sama" name on LinkedIn; none of those profiles correspond to Sam Altman
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
*Last updated: 2026-04-07*
|
|
49
|
+
*Sources: https://www.linkedin.com/in/andrew-a-sama-md-4bb65242*
|