flashrev-ai-enrich 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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/flashrev-ai-enrich.js +23 -0
- package/examples/company-profile.job.json +14 -0
- package/examples/leads.csv +4 -0
- package/examples/person-email-unlock.job.json +13 -0
- package/examples/sample-prospects-enriched.csv +8 -0
- package/examples/sample-prospects.csv +8 -0
- package/package.json +42 -0
- package/skills/flashrev-ai-enrich/SKILL.md +212 -0
- package/skills/flashrev-ai-enrich/agents/openai.yaml +3 -0
- package/skills/flashrev-ai-enrich/references/api_contract.md +165 -0
- package/src/args.js +118 -0
- package/src/billing.js +54 -0
- package/src/capabilities.js +2473 -0
- package/src/cli.js +435 -0
- package/src/config.js +114 -0
- package/src/csv.js +81 -0
- package/src/customer-api.js +101 -0
- package/src/estimate.js +64 -0
- package/src/flashrev-client.js +338 -0
- package/src/job.js +126 -0
- package/src/prompt-router.js +144 -0
- package/src/runner.js +269 -0
- package/src/table.js +41 -0
- package/src/tabular.js +17 -0
- package/src/utils.js +104 -0
package/src/cli.js
ADDED
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
5
|
+
import { parseArgv, readFlag, readFlagList, requireFlag } from "./args.js";
|
|
6
|
+
import { CAPABILITIES, applyRemoteConfigs } from "./capabilities.js";
|
|
7
|
+
import { loadConfig, writeInitialConfig } from "./config.js";
|
|
8
|
+
import { estimateJob } from "./estimate.js";
|
|
9
|
+
import { FlashRevEnrichClient } from "./flashrev-client.js";
|
|
10
|
+
import { mappingsFromFlags, resolveJob, validateJobForHeaders } from "./job.js";
|
|
11
|
+
import { runEnrichment, TerminateError } from "./runner.js";
|
|
12
|
+
import { readCsvSource, writeCsvFile } from "./tabular.js";
|
|
13
|
+
import { parseKeyValueList } from "./utils.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Fetch remote capability registry and merge it on top of the local fallback.
|
|
17
|
+
* Network failure is non-blocking — local CAPABILITIES still works.
|
|
18
|
+
*/
|
|
19
|
+
async function syncRemoteConfigs(client) {
|
|
20
|
+
try {
|
|
21
|
+
const remote = await client.listConfigs();
|
|
22
|
+
if (Array.isArray(remote) && remote.length) applyRemoteConfigs(remote);
|
|
23
|
+
return remote;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.warn(`Warning: failed to fetch capability configs (${e.message}). Using local fallback.`);
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function main(argv) {
|
|
31
|
+
const { positionals, flags } = parseArgv(argv);
|
|
32
|
+
// ai-mode: --ai-mode flag OR FLASHREV_ENRICH_AI_MODE=1 env var.
|
|
33
|
+
// When on, list commands auto-emit JSON and bin/ wraps errors in a structured envelope.
|
|
34
|
+
const aiMode = flags.aiMode === true || process.env.FLASHREV_ENRICH_AI_MODE === "1";
|
|
35
|
+
process.env.__FLASHREV_AI_MODE = aiMode ? "1" : "";
|
|
36
|
+
if (aiMode) flags.json = true;
|
|
37
|
+
if (flags.version || flags.v) return commandVersion();
|
|
38
|
+
const command = positionals[0] || (flags.help || flags.h ? "help" : "");
|
|
39
|
+
|
|
40
|
+
switch (command) {
|
|
41
|
+
case "init":
|
|
42
|
+
return commandInit(flags);
|
|
43
|
+
case "doctor":
|
|
44
|
+
return commandDoctor(flags);
|
|
45
|
+
case "tokens":
|
|
46
|
+
return commandTokens(flags);
|
|
47
|
+
case "token-history":
|
|
48
|
+
case "tokens-history":
|
|
49
|
+
return commandTokenHistory(flags);
|
|
50
|
+
case "schema":
|
|
51
|
+
return commandSchema(flags);
|
|
52
|
+
case "dry-run":
|
|
53
|
+
return commandRun({ ...flags, dryRun: true });
|
|
54
|
+
case "run":
|
|
55
|
+
return commandRun(flags);
|
|
56
|
+
case "help":
|
|
57
|
+
case "":
|
|
58
|
+
case undefined:
|
|
59
|
+
console.log(helpText());
|
|
60
|
+
return;
|
|
61
|
+
default:
|
|
62
|
+
throw new Error(`Unknown command: ${command}. Run flashrev-ai-enrich --help.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function commandInit(flags) {
|
|
67
|
+
const path = await writeInitialConfig(readFlag(flags, "config"), {
|
|
68
|
+
force: Boolean(flags.force),
|
|
69
|
+
baseUrl: readFlag(flags, "baseUrl"),
|
|
70
|
+
apiKeyEnv: readFlag(flags, "apiKeyEnv"),
|
|
71
|
+
apiKey: readFlag(flags, "apiKey"),
|
|
72
|
+
saveApiKey: Boolean(flags.saveApiKey),
|
|
73
|
+
mePath: readFlag(flags, "mePath"),
|
|
74
|
+
tokensPath: readFlag(flags, "tokensPath"),
|
|
75
|
+
quotePath: readFlag(flags, "quotePath")
|
|
76
|
+
});
|
|
77
|
+
console.log(`Created config: ${path}`);
|
|
78
|
+
console.log("Set FLASHREV_API_KEY before calling FlashRev Enrichment APIs.");
|
|
79
|
+
if (flags.apiKey && !flags.saveApiKey) {
|
|
80
|
+
console.log("API key was not saved. Prefer environment variables for external users.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function commandDoctor(flags) {
|
|
85
|
+
const config = await loadConfig(readFlag(flags, "config"));
|
|
86
|
+
const checks = [];
|
|
87
|
+
checks.push(["Node >=20", Number(process.versions.node.split(".")[0]) >= 20]);
|
|
88
|
+
checks.push(["Config path resolved", Boolean(config.__path)]);
|
|
89
|
+
checks.push([`API key env ${config.flashrev.apiKeyEnv}`, Boolean(config.__apiKey)]);
|
|
90
|
+
checks.push(["Tokens endpoint path", Boolean(config.flashrev.endpoints.tokens.path)]);
|
|
91
|
+
checks.push(["Configs endpoint path", Boolean(config.flashrev.endpoints.configs?.path)]);
|
|
92
|
+
checks.push(["Enrich endpoint path", Boolean(config.flashrev.endpoints.enrich.path)]);
|
|
93
|
+
checks.push(["Capability registry loaded", CAPABILITIES.length > 0]);
|
|
94
|
+
|
|
95
|
+
for (const [label, ok] of checks) console.log(`${ok ? "OK " : "NO "} ${label}`);
|
|
96
|
+
|
|
97
|
+
if (flags.api === false || flags.noApi) {
|
|
98
|
+
console.log("Skipped live API checks because --no-api was provided.");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const client = new FlashRevEnrichClient(config);
|
|
103
|
+
const remote = await syncRemoteConfigs(client);
|
|
104
|
+
console.log(`OK Remote capability configs: ${remote ? remote.length : 0}`);
|
|
105
|
+
const tokens = await client.tokens();
|
|
106
|
+
console.log(`OK FlashRev tokens remaining: ${tokens.remaining}`);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function commandTokens(flags) {
|
|
110
|
+
const config = await loadConfig(readFlag(flags, "config"));
|
|
111
|
+
const client = new FlashRevEnrichClient(config);
|
|
112
|
+
const tokens = await client.tokens();
|
|
113
|
+
if (flags.json) {
|
|
114
|
+
console.log(JSON.stringify(tokens, null, 2));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
console.log(`FlashRev tokens remaining: ${tokens.remaining}`);
|
|
118
|
+
if (tokens.remaining <= 0) {
|
|
119
|
+
console.log("Balance is 0. Please recharge tokens in FlashRev before running enrichment.");
|
|
120
|
+
}
|
|
121
|
+
if (tokens.total) console.log(`Total: ${tokens.total}`);
|
|
122
|
+
if (tokens.used) console.log(`Used: ${tokens.used}`);
|
|
123
|
+
if (tokens.plan) console.log(`Plan: ${tokens.plan}`);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function commandTokenHistory(flags) {
|
|
127
|
+
const config = await loadConfig(readFlag(flags, "config"));
|
|
128
|
+
const client = new FlashRevEnrichClient(config);
|
|
129
|
+
const history = await client.tokenHistory({
|
|
130
|
+
from: readFlag(flags, "from"),
|
|
131
|
+
to: readFlag(flags, "to"),
|
|
132
|
+
limit: readFlag(flags, "limit")
|
|
133
|
+
});
|
|
134
|
+
if (flags.json) {
|
|
135
|
+
console.log(JSON.stringify(history, null, 2));
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!history.length) {
|
|
139
|
+
console.log("No token usage history found for the selected range.");
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
for (const item of history) {
|
|
143
|
+
console.log(`${item.date || "-"} ${String(item.tokens).padStart(4)} ${item.action || "-"}${item.status ? ` ${item.status}` : ""}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function commandSchema(flags) {
|
|
148
|
+
// Prefer remote /configs; fall back to local capabilities.js if it fails.
|
|
149
|
+
const config = await loadConfig(readFlag(flags, "config")).catch(() => null);
|
|
150
|
+
if (config && config.__apiKey) {
|
|
151
|
+
await syncRemoteConfigs(new FlashRevEnrichClient(config));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (flags.json) {
|
|
155
|
+
console.log(JSON.stringify({ capabilities: CAPABILITIES.map(publicCapability) }, null, 2));
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
const groups = new Map();
|
|
159
|
+
for (const capability of CAPABILITIES) {
|
|
160
|
+
const group = capability.group || "Other";
|
|
161
|
+
if (!groups.has(group)) groups.set(group, []);
|
|
162
|
+
groups.get(group).push(capability);
|
|
163
|
+
}
|
|
164
|
+
for (const [group, capabilities] of groups.entries()) {
|
|
165
|
+
console.log(group);
|
|
166
|
+
for (const capability of capabilities) {
|
|
167
|
+
const outputs = capability.dynamicOutput
|
|
168
|
+
? "dynamic outputs"
|
|
169
|
+
: (capability.outputFields || []).map((f) => f.key).join(", ");
|
|
170
|
+
const price = capability.unitPriceToken != null ? ` (${capability.unitPriceToken}t)` : "";
|
|
171
|
+
console.log(` ${capability.id}${price} - ${capability.name || capability.displayName} -> ${outputs}`);
|
|
172
|
+
}
|
|
173
|
+
console.log("");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function commandRun(flags) {
|
|
178
|
+
const config = await loadConfig(readFlag(flags, "config"));
|
|
179
|
+
const source = readFlag(flags, "source");
|
|
180
|
+
const out = readFlag(flags, "out");
|
|
181
|
+
if (!flags.dryRun && !out) throw new Error("Missing required flag: --out");
|
|
182
|
+
if (!flags.dryRun && source && resolve(source) === resolve(out)) {
|
|
183
|
+
throw new Error("Refusing to overwrite source CSV. Choose a different --out path.");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const imported = source ? await readCsvSource(source) : importedFromInlineInput(flags);
|
|
187
|
+
if (!imported.records.length) throw new Error("No rows found in source CSV.");
|
|
188
|
+
|
|
189
|
+
const client = new FlashRevEnrichClient(config);
|
|
190
|
+
|
|
191
|
+
// Sync remote capability registry on startup (unitPriceToken / concurrency / rules).
|
|
192
|
+
await syncRemoteConfigs(client);
|
|
193
|
+
|
|
194
|
+
const { inputMapping, outputMapping } = mappingsFromFlags(readFlagList(flags, "map"), readFlagList(flags, "output"));
|
|
195
|
+
// Inline input mode: auto-map each --input key to the capability field of the same name,
|
|
196
|
+
// so users don't need to also write `--map email=email`.
|
|
197
|
+
if (!source) {
|
|
198
|
+
for (const key of imported.headers) {
|
|
199
|
+
if (inputMapping[key] == null) inputMapping[key] = key;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
const job = await resolveJob(flags, inputMapping, outputMapping, imported.headers, {
|
|
203
|
+
client,
|
|
204
|
+
capabilities: CAPABILITIES
|
|
205
|
+
});
|
|
206
|
+
if (job.routedFromPrompt) printRoutingDecision(job);
|
|
207
|
+
validateJobForHeaders(job, imported.headers);
|
|
208
|
+
|
|
209
|
+
const estimate = estimateJob(config, job, imported.records);
|
|
210
|
+
printEstimate(estimate);
|
|
211
|
+
|
|
212
|
+
if (flags.dryRun) {
|
|
213
|
+
console.log("Dry run only. No FlashRev enrichment request was sent.");
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (!flags.yes && !flags.y) {
|
|
218
|
+
await confirmCostBeforeRun(estimate);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (!flags.skipBalanceCheck) {
|
|
222
|
+
const tokens = await client.tokens();
|
|
223
|
+
if (tokens.remaining <= 0) {
|
|
224
|
+
const error = new Error("FlashRev token balance is 0. Please recharge tokens before running enrichment.");
|
|
225
|
+
error.hint = "Run flashrev-ai-enrich tokens to check balance after recharging.";
|
|
226
|
+
throw error;
|
|
227
|
+
}
|
|
228
|
+
if (estimate.estimatedTokens > tokens.remaining) {
|
|
229
|
+
console.log(`Warning: estimated tokens (${estimate.estimatedTokens}) exceed current balance (${tokens.remaining}).`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Query balance once before the batch so we can reconcile precisely afterwards.
|
|
234
|
+
// (response.cost.tokens can be slightly inflated under concurrency because the
|
|
235
|
+
// server-side remainBefore/After windows interleave; the before/after balance
|
|
236
|
+
// delta is the only fully accurate figure.)
|
|
237
|
+
let balanceBeforeRun = null;
|
|
238
|
+
try {
|
|
239
|
+
balanceBeforeRun = (await client.tokens()).remaining;
|
|
240
|
+
} catch {
|
|
241
|
+
// Best-effort; ignore if balance query fails.
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let result;
|
|
245
|
+
let terminateError = null;
|
|
246
|
+
try {
|
|
247
|
+
result = await runEnrichment({ client, config, job, imported, flags });
|
|
248
|
+
} catch (e) {
|
|
249
|
+
if (!(e instanceof TerminateError)) throw e;
|
|
250
|
+
terminateError = e;
|
|
251
|
+
// runEnrichment fills the results array (with skipped markers) before throwing; pick it from e.partial.
|
|
252
|
+
result = e.partial || { records: [], completed: 0, stoppedAfterSample: false, summary: null };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Query balance after the batch to compute true deduction = before - after.
|
|
256
|
+
let balanceAfterRun = null;
|
|
257
|
+
try {
|
|
258
|
+
balanceAfterRun = (await client.tokens()).remaining;
|
|
259
|
+
} catch {
|
|
260
|
+
// Fall back to summary's accumulated cost.tokens.
|
|
261
|
+
}
|
|
262
|
+
if (result.summary && balanceBeforeRun != null && balanceAfterRun != null) {
|
|
263
|
+
result.summary.tokensUsedActual = balanceBeforeRun - balanceAfterRun;
|
|
264
|
+
result.summary.balanceBefore = balanceBeforeRun;
|
|
265
|
+
result.summary.balanceAfter = balanceAfterRun;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (result.records.length) {
|
|
269
|
+
const path = await writeCsvFile(out, result.records);
|
|
270
|
+
console.log("");
|
|
271
|
+
console.log(`Wrote ${result.records.length} row(s) to ${path}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
printRunSummary(result, estimate);
|
|
275
|
+
|
|
276
|
+
if (result.stoppedAfterSample) {
|
|
277
|
+
console.log("Stopped after sample preview. Re-run with --yes to continue after the preview.");
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (terminateError) {
|
|
281
|
+
console.error("");
|
|
282
|
+
console.error(`Run terminated: ${terminateError.message}`);
|
|
283
|
+
process.exitCode = 1;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function printRunSummary(result, estimate) {
|
|
288
|
+
const s = result.summary || {};
|
|
289
|
+
console.log("");
|
|
290
|
+
console.log("Summary:");
|
|
291
|
+
console.log(` Completed: ${result.completed}`);
|
|
292
|
+
console.log(` Failed: ${s.failedCount || 0}`);
|
|
293
|
+
console.log(` Cached: ${s.cachedCount || 0}`);
|
|
294
|
+
// Prefer the before-after balance delta (real deduction); fall back to summed cost.tokens
|
|
295
|
+
// when balance is unavailable. cost.tokens can be inflated under concurrency, treat as approximate.
|
|
296
|
+
if (s.tokensUsedActual != null) {
|
|
297
|
+
console.log(` Tokens used: ${s.tokensUsedActual}${estimate ? ` (estimated ${estimate.estimatedTokens})` : ""}`);
|
|
298
|
+
if (s.balanceBefore != null && s.balanceAfter != null) {
|
|
299
|
+
console.log(` (balance ${s.balanceBefore} → ${s.balanceAfter})`);
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
console.log(` Tokens used: ~${s.tokensUsed || 0}${estimate ? ` (estimated ${estimate.estimatedTokens})` : ""}`);
|
|
303
|
+
console.log(` (approximate; query 'tokens' or 'token-history' for exact)`);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function confirmCostBeforeRun(estimate) {
|
|
308
|
+
if (!process.stdin.isTTY) {
|
|
309
|
+
const error = new Error("Refusing to run enrichment without cost confirmation in non-interactive mode.");
|
|
310
|
+
error.hint = "Run dry-run first, then pass --yes when the estimated token cost is approved.";
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
const rl = createInterface({ input, output });
|
|
314
|
+
try {
|
|
315
|
+
const answer = await rl.question(
|
|
316
|
+
`Estimated cost is ${estimate.estimatedTokens} token(s) for ${estimate.rowCount} row(s). Continue? [y/N] `
|
|
317
|
+
);
|
|
318
|
+
if (!/^y(es)?$/i.test(answer.trim())) {
|
|
319
|
+
throw new Error("Stopped before enrichment because the estimated token cost was not approved.");
|
|
320
|
+
}
|
|
321
|
+
} finally {
|
|
322
|
+
rl.close();
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function importedFromInlineInput(flags) {
|
|
327
|
+
const inputValues = readFlagList(flags, "input");
|
|
328
|
+
if (!flags.prompt && !inputValues.length) {
|
|
329
|
+
throw new Error("Missing required flag: --source. For inline mode, provide --input key=value pairs (with --capability) or --prompt.");
|
|
330
|
+
}
|
|
331
|
+
const record = parseKeyValueList(inputValues, "--input");
|
|
332
|
+
const headers = Object.keys(record);
|
|
333
|
+
return {
|
|
334
|
+
headers,
|
|
335
|
+
records: [record],
|
|
336
|
+
source: "inline-input",
|
|
337
|
+
format: "inline"
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
async function commandVersion() {
|
|
342
|
+
const pkgUrl = new URL("../package.json", import.meta.url);
|
|
343
|
+
const pkg = JSON.parse(await readFile(pkgUrl, "utf8"));
|
|
344
|
+
console.log(pkg.version);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function printRoutingDecision(job) {
|
|
348
|
+
const fmtMap = (m) =>
|
|
349
|
+
Object.entries(m || {})
|
|
350
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
351
|
+
.join(", ") || "(none)";
|
|
352
|
+
console.log("");
|
|
353
|
+
console.log("Routing decision:");
|
|
354
|
+
console.log(` capability: ${job.capability.funcName || job.capability.id}`);
|
|
355
|
+
console.log(` inputMapping: ${fmtMap(job.inputMapping)}`);
|
|
356
|
+
console.log(` outputMapping: ${fmtMap(job.outputMapping)}`);
|
|
357
|
+
if (job.routerReasoning) console.log(` reasoning: ${job.routerReasoning}`);
|
|
358
|
+
console.log(` routing cost: ${job.routerTokensCharged || 0} token(s)`);
|
|
359
|
+
console.log("");
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function printEstimate(estimate) {
|
|
363
|
+
console.log(`Capability: ${estimate.displayName || estimate.capability}`);
|
|
364
|
+
console.log(`Rows: ${estimate.rowCount}`);
|
|
365
|
+
console.log(`Unit cost: ${estimate.unitCost} token(s) / ${estimate.unit}`);
|
|
366
|
+
console.log(`Concurrency: ${estimate.effectiveConcurrency}`);
|
|
367
|
+
console.log(`Estimated API calls: ${estimate.estimatedApiCalls}`);
|
|
368
|
+
console.log(`Estimated tokens: ${estimate.estimatedTokens}`);
|
|
369
|
+
console.log(`Estimated duration: ~${formatDuration(estimate.estimatedSeconds)}`);
|
|
370
|
+
console.log(`Estimate source: ${estimate.estimateMethod}`);
|
|
371
|
+
if (estimate.note) console.log(`Note: ${estimate.note}`);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function formatDuration(seconds) {
|
|
375
|
+
if (!seconds || seconds < 60) return `${seconds || 0}s`;
|
|
376
|
+
if (seconds < 3600) return `${Math.round(seconds / 60)}m`;
|
|
377
|
+
const h = Math.floor(seconds / 3600);
|
|
378
|
+
const m = Math.round((seconds % 3600) / 60);
|
|
379
|
+
return `${h}h ${m}m`;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function publicCapability(capability) {
|
|
383
|
+
return {
|
|
384
|
+
id: capability.id,
|
|
385
|
+
displayName: capability.displayName,
|
|
386
|
+
name: capability.name,
|
|
387
|
+
group: capability.group,
|
|
388
|
+
inputFields: capability.inputFields,
|
|
389
|
+
rules: capability.rules,
|
|
390
|
+
outputFields: capability.outputFields,
|
|
391
|
+
dynamicOutput: capability.dynamicOutput
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
export function helpText() {
|
|
396
|
+
return `FlashRev AI Enrich
|
|
397
|
+
|
|
398
|
+
Usage:
|
|
399
|
+
flashrev-ai-enrich init [--config .flashrev/enrich-config.json]
|
|
400
|
+
flashrev-ai-enrich doctor [--no-api]
|
|
401
|
+
flashrev-ai-enrich tokens [--json]
|
|
402
|
+
flashrev-ai-enrich token-history [--from YYYY-MM-DD] [--to YYYY-MM-DD] [--json]
|
|
403
|
+
flashrev-ai-enrich schema [--json]
|
|
404
|
+
flashrev-ai-enrich dry-run --source leads.csv --capability match_company_id --map company_linkedin=linkedin
|
|
405
|
+
flashrev-ai-enrich run --source leads.csv --out enriched.csv --capability enrich_email --map first_name=first_name --map last_name=last_name --map company_name=company --yes
|
|
406
|
+
flashrev-ai-enrich run --source leads.csv --out enriched.csv --prompt "fill missing business emails" --yes
|
|
407
|
+
|
|
408
|
+
Core flags:
|
|
409
|
+
--config PATH Config file path. Defaults to .flashrev/enrich-config.json
|
|
410
|
+
--source PATH Source CSV file
|
|
411
|
+
--input key=value Inline condition for prompt-only enrichment. Repeatable
|
|
412
|
+
--out PATH Output CSV file for run
|
|
413
|
+
--capability ID Enrichment capability ID from schema
|
|
414
|
+
--prompt TEXT Natural-language request (routed via run_llm, costs 1 token). Agents should prefer --capability
|
|
415
|
+
--job PATH JSON job file with capability, inputMapping, and outputs
|
|
416
|
+
--map input=csv_column Map enrichment input field to CSV column. Repeatable
|
|
417
|
+
--output column=response Map output CSV column to response field. Repeatable
|
|
418
|
+
--output-fields a,b Dynamic output fields for LLM/search/extract capabilities
|
|
419
|
+
--sample-size N Preview N enriched rows before the remaining batch. Default 10
|
|
420
|
+
--concurrency N Number of row requests to run at a time after validation. Default 3
|
|
421
|
+
--skip-balance-check Skip pre-run tokens balance check
|
|
422
|
+
--yes Continue automatically after sample preview
|
|
423
|
+
--ai-mode JSON output for list commands and JSON error envelope. Also enabled by FLASHREV_ENRICH_AI_MODE=1
|
|
424
|
+
|
|
425
|
+
Examples:
|
|
426
|
+
# Generate a private app key at https://info.flashlabs.ai/settings/privateApps
|
|
427
|
+
export FLASHREV_API_KEY="your_flashrev_api_key"
|
|
428
|
+
flashrev-ai-enrich tokens
|
|
429
|
+
flashrev-ai-enrich token-history --from 2026-05-01 --to 2026-05-27
|
|
430
|
+
flashrev-ai-enrich schema
|
|
431
|
+
flashrev-ai-enrich dry-run --source leads.csv --capability get_person_location --map email=email
|
|
432
|
+
flashrev-ai-enrich run --source leads.csv --out leads.enriched.csv --capability enrich_email --map email=email --yes
|
|
433
|
+
flashrev-ai-enrich run --source leads.csv --out enriched.csv --prompt "fill missing business emails" --yes
|
|
434
|
+
`;
|
|
435
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { deepMerge, ensureDir, readJson, writeJson } from "./utils.js";
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_CONFIG = {
|
|
6
|
+
flashrev: {
|
|
7
|
+
baseUrl: "https://open-ai-api.flashlabs.ai",
|
|
8
|
+
apiKeyEnv: "FLASHREV_API_KEY",
|
|
9
|
+
auth: {
|
|
10
|
+
// FlashRev open API expects the X-API-Key header with no prefix; same as cli-npm-cli-flashrev-api-ai.
|
|
11
|
+
type: "header",
|
|
12
|
+
header: "X-API-Key"
|
|
13
|
+
},
|
|
14
|
+
endpoints: {
|
|
15
|
+
// Balance query: returns limit.tokenTotal / tokenCost / tokenCategoryRemain.
|
|
16
|
+
tokens: {
|
|
17
|
+
method: "GET",
|
|
18
|
+
path: "/flashrev/api/v2/oauth/me"
|
|
19
|
+
},
|
|
20
|
+
// Token consumption history.
|
|
21
|
+
tokenHistory: {
|
|
22
|
+
method: "POST",
|
|
23
|
+
path: "/flashrev/api/v2/commodity/token/transaction/list"
|
|
24
|
+
},
|
|
25
|
+
// Capability registry.
|
|
26
|
+
configs: {
|
|
27
|
+
method: "GET",
|
|
28
|
+
path: "/flashrev/api/v1/enrich/configs"
|
|
29
|
+
},
|
|
30
|
+
// Row-level enrichment.
|
|
31
|
+
enrich: {
|
|
32
|
+
method: "POST",
|
|
33
|
+
path: "/flashrev/api/v1/enrich/run"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
defaults: {
|
|
38
|
+
sampleSize: 10,
|
|
39
|
+
concurrency: 3,
|
|
40
|
+
retryCount: 1,
|
|
41
|
+
retryDelayMs: 500,
|
|
42
|
+
statusColumn: "flashrev_enrich_status",
|
|
43
|
+
errorColumn: "flashrev_enrich_error",
|
|
44
|
+
tokensPerRow: {
|
|
45
|
+
company: 1,
|
|
46
|
+
person: 1,
|
|
47
|
+
flashagent: 1,
|
|
48
|
+
unlock_email: 2,
|
|
49
|
+
unlock_phone: 8,
|
|
50
|
+
verify_email: 1,
|
|
51
|
+
verify_phone: 1,
|
|
52
|
+
generic: 1
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
paths: {
|
|
56
|
+
dataDir: ".flashrev/enrich"
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
export function resolveConfigPath(value) {
|
|
61
|
+
return resolve(process.cwd(), value || process.env.FLASHREV_ENRICH_CONFIG || ".flashrev/enrich-config.json");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function loadConfig(path) {
|
|
65
|
+
const configPath = resolveConfigPath(path);
|
|
66
|
+
const userConfig = existsSync(configPath) ? await readJson(configPath) : {};
|
|
67
|
+
const config = deepMerge(DEFAULT_CONFIG, userConfig);
|
|
68
|
+
config.__path = configPath;
|
|
69
|
+
config.__apiKey = readApiKey(config);
|
|
70
|
+
return config;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function writeInitialConfig(path, options = {}) {
|
|
74
|
+
const configPath = resolveConfigPath(path);
|
|
75
|
+
if (existsSync(configPath) && !options.force) {
|
|
76
|
+
throw new Error(`Config already exists at ${configPath}. Use --force to overwrite.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const config = deepMerge(DEFAULT_CONFIG, {
|
|
80
|
+
flashrev: {
|
|
81
|
+
baseUrl: options.baseUrl || DEFAULT_CONFIG.flashrev.baseUrl,
|
|
82
|
+
apiKeyEnv: options.apiKeyEnv || DEFAULT_CONFIG.flashrev.apiKeyEnv,
|
|
83
|
+
endpoints: {
|
|
84
|
+
tokens: {
|
|
85
|
+
method: DEFAULT_CONFIG.flashrev.endpoints.tokens.method,
|
|
86
|
+
path: options.tokensPath || DEFAULT_CONFIG.flashrev.endpoints.tokens.path
|
|
87
|
+
},
|
|
88
|
+
tokenHistory: {
|
|
89
|
+
method: DEFAULT_CONFIG.flashrev.endpoints.tokenHistory.method,
|
|
90
|
+
path: options.tokenHistoryPath || DEFAULT_CONFIG.flashrev.endpoints.tokenHistory.path
|
|
91
|
+
},
|
|
92
|
+
configs: {
|
|
93
|
+
method: DEFAULT_CONFIG.flashrev.endpoints.configs.method,
|
|
94
|
+
path: options.configsPath || DEFAULT_CONFIG.flashrev.endpoints.configs.path
|
|
95
|
+
},
|
|
96
|
+
enrich: {
|
|
97
|
+
method: DEFAULT_CONFIG.flashrev.endpoints.enrich.method,
|
|
98
|
+
path: options.enrichPath || DEFAULT_CONFIG.flashrev.endpoints.enrich.path
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
if (options.saveApiKey && options.apiKey) config.flashrev.apiKey = options.apiKey;
|
|
105
|
+
|
|
106
|
+
await ensureDir(resolve(process.cwd(), config.paths.dataDir));
|
|
107
|
+
await writeJson(configPath, config);
|
|
108
|
+
return configPath;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function readApiKey(config) {
|
|
112
|
+
const envName = config.flashrev.apiKeyEnv || "FLASHREV_API_KEY";
|
|
113
|
+
return process.env[envName] || config.flashrev.apiKey || "";
|
|
114
|
+
}
|
package/src/csv.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { normalizeHeader } from "./utils.js";
|
|
2
|
+
|
|
3
|
+
export function parseDelimitedText(text, delimiter = detectDelimiter(text)) {
|
|
4
|
+
const rows = parseDelimited(text, delimiter).filter((row) => row.some((cell) => cell.trim() !== ""));
|
|
5
|
+
if (rows.length === 0) return { headers: [], records: [] };
|
|
6
|
+
|
|
7
|
+
const headers = rows[0].map((header, index) => normalizeHeader(header) || `column_${index + 1}`);
|
|
8
|
+
const records = rows.slice(1).map((row) => {
|
|
9
|
+
const record = {};
|
|
10
|
+
headers.forEach((header, index) => {
|
|
11
|
+
record[header] = row[index] === undefined ? "" : row[index];
|
|
12
|
+
});
|
|
13
|
+
return record;
|
|
14
|
+
});
|
|
15
|
+
return { headers, records };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function toDelimitedText(records, delimiter = ",") {
|
|
19
|
+
if (!records.length) return "";
|
|
20
|
+
const headers = [...new Set(records.flatMap((record) => Object.keys(record)))];
|
|
21
|
+
const lines = [headers.map((header) => escapeCell(header, delimiter)).join(delimiter)];
|
|
22
|
+
for (const record of records) {
|
|
23
|
+
lines.push(headers.map((header) => escapeCell(record[header] ?? "", delimiter)).join(delimiter));
|
|
24
|
+
}
|
|
25
|
+
return `${lines.join("\n")}\n`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function detectDelimiter(text) {
|
|
29
|
+
const sample = text.split(/\r?\n/, 1)[0] || "";
|
|
30
|
+
const commaCount = (sample.match(/,/g) || []).length;
|
|
31
|
+
const tabCount = (sample.match(/\t/g) || []).length;
|
|
32
|
+
const semicolonCount = (sample.match(/;/g) || []).length;
|
|
33
|
+
if (tabCount > commaCount && tabCount >= semicolonCount) return "\t";
|
|
34
|
+
if (semicolonCount > commaCount) return ";";
|
|
35
|
+
return ",";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseDelimited(text, delimiter) {
|
|
39
|
+
const rows = [];
|
|
40
|
+
let row = [];
|
|
41
|
+
let cell = "";
|
|
42
|
+
let inQuotes = false;
|
|
43
|
+
|
|
44
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
45
|
+
const char = text[index];
|
|
46
|
+
const next = text[index + 1];
|
|
47
|
+
if (char === '"') {
|
|
48
|
+
if (inQuotes && next === '"') {
|
|
49
|
+
cell += '"';
|
|
50
|
+
index += 1;
|
|
51
|
+
} else {
|
|
52
|
+
inQuotes = !inQuotes;
|
|
53
|
+
}
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!inQuotes && char === delimiter) {
|
|
57
|
+
row.push(cell);
|
|
58
|
+
cell = "";
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (!inQuotes && (char === "\n" || char === "\r")) {
|
|
62
|
+
if (char === "\r" && next === "\n") index += 1;
|
|
63
|
+
row.push(cell);
|
|
64
|
+
rows.push(row);
|
|
65
|
+
row = [];
|
|
66
|
+
cell = "";
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
cell += char;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
row.push(cell);
|
|
73
|
+
rows.push(row);
|
|
74
|
+
return rows;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function escapeCell(value, delimiter) {
|
|
78
|
+
const text = String(value);
|
|
79
|
+
const escaped = text.replace(/"/g, '""');
|
|
80
|
+
return text.includes(delimiter) || /["\n\r]/.test(text) ? `"${escaped}"` : text;
|
|
81
|
+
}
|