surf-skill 2.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/CHANGELOG.md +175 -0
- package/LICENSE +21 -0
- package/README.md +430 -0
- package/SKILL.md +278 -0
- package/bin/surf-skill.mjs +539 -0
- package/package.json +55 -0
- package/references/COSTS.md +72 -0
- package/references/parallel-api.md +155 -0
- package/references/tavily-api.md +90 -0
- package/src/env.mjs +125 -0
- package/src/index.mjs +22 -0
- package/src/install/postinstall.mjs +73 -0
- package/src/install/preuninstall.mjs +25 -0
- package/src/lib/api/crawl.mjs +55 -0
- package/src/lib/api/extract.mjs +46 -0
- package/src/lib/api/map.mjs +43 -0
- package/src/lib/api/research.mjs +96 -0
- package/src/lib/api/search.mjs +92 -0
- package/src/lib/audit.mjs +34 -0
- package/src/lib/cache.mjs +46 -0
- package/src/lib/cost.mjs +90 -0
- package/src/lib/dispatch.mjs +320 -0
- package/src/lib/flags.mjs +63 -0
- package/src/lib/format.mjs +110 -0
- package/src/lib/harness-install.mjs +149 -0
- package/src/lib/keys-cmd.mjs +138 -0
- package/src/lib/progress.mjs +81 -0
- package/src/lib/project-config.mjs +145 -0
- package/src/lib/providers/index.mjs +32 -0
- package/src/lib/providers/parallel.mjs +270 -0
- package/src/lib/providers/tavily.mjs +245 -0
- package/src/lib/setup.mjs +111 -0
- package/src/lib/state.mjs +197 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// State management: ~/.config/surf/keys.json with atomic writes, lockfile,
|
|
2
|
+
// monthly auto-reset of burned keys, and one-shot migration of the legacy
|
|
3
|
+
// ~/.cache/tavily-skill/ directory.
|
|
4
|
+
|
|
5
|
+
import { mkdir, readFile, writeFile, rename, rm, stat, chmod, readdir, open } from 'node:fs/promises';
|
|
6
|
+
import { existsSync } from 'node:fs';
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { join, dirname } from 'node:path';
|
|
9
|
+
import { sleep } from './flags.mjs';
|
|
10
|
+
|
|
11
|
+
export const CONFIG_DIR = join(homedir(), '.config', 'surf');
|
|
12
|
+
export const KEYS_FILE = join(CONFIG_DIR, 'keys.json');
|
|
13
|
+
export const LOCK_FILE = join(CONFIG_DIR, '.keys.lock');
|
|
14
|
+
export const CACHE_DIR = join(homedir(), '.cache', 'surf');
|
|
15
|
+
export const LEGACY_CACHE_DIR = join(homedir(), '.cache', 'tavily-skill');
|
|
16
|
+
|
|
17
|
+
export const PROVIDERS = ['tavily', 'parallel'];
|
|
18
|
+
export const SCHEMA_VERSION = 1;
|
|
19
|
+
|
|
20
|
+
const BURNED_CAP = 50;
|
|
21
|
+
|
|
22
|
+
function blankProvider() {
|
|
23
|
+
return { keys: [], current: 0, burned: [] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function blankState() {
|
|
27
|
+
return {
|
|
28
|
+
schema_version: SCHEMA_VERSION,
|
|
29
|
+
tavily: blankProvider(),
|
|
30
|
+
parallel: blankProvider(),
|
|
31
|
+
last_ok_provider: null,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function ensureConfigDir() {
|
|
36
|
+
await mkdir(CONFIG_DIR, { recursive: true });
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function acquireLock(timeoutMs = 2000) {
|
|
40
|
+
await ensureConfigDir();
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
let backoff = 20;
|
|
43
|
+
while (true) {
|
|
44
|
+
try {
|
|
45
|
+
const fh = await open(LOCK_FILE, 'wx');
|
|
46
|
+
await fh.close();
|
|
47
|
+
return;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (e.code !== 'EEXIST') throw e;
|
|
50
|
+
if (Date.now() - start > timeoutMs) {
|
|
51
|
+
try { await rm(LOCK_FILE, { force: true }); } catch {}
|
|
52
|
+
const fh = await open(LOCK_FILE, 'wx').catch(() => null);
|
|
53
|
+
if (fh) { await fh.close(); return; }
|
|
54
|
+
throw new Error('Could not acquire keys.json lock');
|
|
55
|
+
}
|
|
56
|
+
await sleep(backoff);
|
|
57
|
+
backoff = Math.min(backoff * 2, 200);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function releaseLock() {
|
|
63
|
+
try { await rm(LOCK_FILE, { force: true }); } catch {}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeProvider(p) {
|
|
67
|
+
const obj = p && typeof p === 'object' ? p : {};
|
|
68
|
+
return {
|
|
69
|
+
keys: Array.isArray(obj.keys) ? obj.keys.filter(k => typeof k === 'string' && k) : [],
|
|
70
|
+
current: Number.isInteger(obj.current) ? obj.current : 0,
|
|
71
|
+
burned: Array.isArray(obj.burned) ? obj.burned.filter(b => b && typeof b === 'object' && Number.isInteger(b.index)) : [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function applyMonthlyReset(state) {
|
|
76
|
+
const now = new Date();
|
|
77
|
+
const nowY = now.getUTCFullYear();
|
|
78
|
+
const nowM = now.getUTCMonth();
|
|
79
|
+
for (const p of PROVIDERS) {
|
|
80
|
+
const before = state[p].burned.length;
|
|
81
|
+
state[p].burned = state[p].burned.filter(b => {
|
|
82
|
+
const at = new Date(b.at);
|
|
83
|
+
if (Number.isNaN(at.getTime())) return false;
|
|
84
|
+
return !(nowY > at.getUTCFullYear() || (nowY === at.getUTCFullYear() && nowM > at.getUTCMonth()));
|
|
85
|
+
});
|
|
86
|
+
if (state[p].burned.length !== before) {
|
|
87
|
+
// current may now point to a slot that became usable again — leave as-is;
|
|
88
|
+
// nextUsableKeyIndex will surface the lowest usable.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function migrateLegacy() {
|
|
95
|
+
try {
|
|
96
|
+
if (!existsSync(LEGACY_CACHE_DIR)) return;
|
|
97
|
+
if (existsSync(CACHE_DIR)) {
|
|
98
|
+
// Both exist — move unique files from legacy into a sidecar dir.
|
|
99
|
+
const sidecar = join(CACHE_DIR, 'legacy-tavily');
|
|
100
|
+
await mkdir(sidecar, { recursive: true });
|
|
101
|
+
const entries = await readdir(LEGACY_CACHE_DIR);
|
|
102
|
+
for (const f of entries) {
|
|
103
|
+
const src = join(LEGACY_CACHE_DIR, f);
|
|
104
|
+
const dst = join(sidecar, f);
|
|
105
|
+
if (!existsSync(dst)) {
|
|
106
|
+
try { await rename(src, dst); } catch {}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
try { await rm(LEGACY_CACHE_DIR, { recursive: true, force: true }); } catch {}
|
|
110
|
+
} else {
|
|
111
|
+
await rename(LEGACY_CACHE_DIR, CACHE_DIR);
|
|
112
|
+
}
|
|
113
|
+
} catch {
|
|
114
|
+
// Migration is best-effort; never block startup.
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function loadState({ skipMonthlyReset = false } = {}) {
|
|
119
|
+
await ensureConfigDir();
|
|
120
|
+
let raw = blankState();
|
|
121
|
+
if (existsSync(KEYS_FILE)) {
|
|
122
|
+
try {
|
|
123
|
+
const txt = await readFile(KEYS_FILE, 'utf8');
|
|
124
|
+
const parsed = JSON.parse(txt);
|
|
125
|
+
raw = {
|
|
126
|
+
schema_version: parsed.schema_version || SCHEMA_VERSION,
|
|
127
|
+
tavily: normalizeProvider(parsed.tavily),
|
|
128
|
+
parallel: normalizeProvider(parsed.parallel),
|
|
129
|
+
last_ok_provider: PROVIDERS.includes(parsed.last_ok_provider) ? parsed.last_ok_provider : null,
|
|
130
|
+
};
|
|
131
|
+
} catch {
|
|
132
|
+
raw = blankState();
|
|
133
|
+
}
|
|
134
|
+
} else {
|
|
135
|
+
await saveStateAtomic(raw);
|
|
136
|
+
}
|
|
137
|
+
if (!skipMonthlyReset) applyMonthlyReset(raw);
|
|
138
|
+
return raw;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function saveStateAtomic(state) {
|
|
142
|
+
await ensureConfigDir();
|
|
143
|
+
await acquireLock();
|
|
144
|
+
try {
|
|
145
|
+
const safe = {
|
|
146
|
+
schema_version: SCHEMA_VERSION,
|
|
147
|
+
tavily: normalizeProvider(state.tavily),
|
|
148
|
+
parallel: normalizeProvider(state.parallel),
|
|
149
|
+
last_ok_provider: PROVIDERS.includes(state.last_ok_provider) ? state.last_ok_provider : null,
|
|
150
|
+
};
|
|
151
|
+
const tmp = KEYS_FILE + '.tmp';
|
|
152
|
+
const payload = JSON.stringify(safe, null, 2);
|
|
153
|
+
await writeFile(tmp, payload, { mode: 0o600 });
|
|
154
|
+
await rename(tmp, KEYS_FILE);
|
|
155
|
+
try { await chmod(KEYS_FILE, 0o600); } catch {}
|
|
156
|
+
} finally {
|
|
157
|
+
await releaseLock();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function providerHasUsableKey(state, provider) {
|
|
162
|
+
const p = state[provider];
|
|
163
|
+
if (!p || !p.keys.length) return false;
|
|
164
|
+
const burnedIdx = new Set(p.burned.map(b => b.index));
|
|
165
|
+
return p.keys.some((_, i) => !burnedIdx.has(i));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function nextUsableKeyIndex(state, provider, skipIndex = -1) {
|
|
169
|
+
const p = state[provider];
|
|
170
|
+
if (!p || !p.keys.length) return -1;
|
|
171
|
+
const burnedIdx = new Set(p.burned.map(b => b.index));
|
|
172
|
+
const n = p.keys.length;
|
|
173
|
+
const start = Number.isInteger(p.current) ? Math.max(0, Math.min(p.current, n - 1)) : 0;
|
|
174
|
+
for (let off = 0; off < n; off++) {
|
|
175
|
+
const i = (start + off) % n;
|
|
176
|
+
if (i === skipIndex) continue;
|
|
177
|
+
if (!burnedIdx.has(i)) return i;
|
|
178
|
+
}
|
|
179
|
+
return -1;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function markBurned(state, provider, index, reason) {
|
|
183
|
+
const p = state[provider];
|
|
184
|
+
if (!p) return;
|
|
185
|
+
if (p.burned.some(b => b.index === index)) return;
|
|
186
|
+
p.burned.push({ index, at: new Date().toISOString(), reason: String(reason || 'unknown') });
|
|
187
|
+
while (p.burned.length > BURNED_CAP) p.burned.shift();
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function clearBurned(state, provider) {
|
|
191
|
+
if (provider) state[provider].burned = [];
|
|
192
|
+
else for (const p of PROVIDERS) state[p].burned = [];
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export async function ensureCacheDir() {
|
|
196
|
+
await mkdir(CACHE_DIR, { recursive: true });
|
|
197
|
+
}
|