@dench.com/cli 2.7.5 → 2.7.7-staging.21
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 +1 -1
- package/crm.ts +164 -26
- package/dench.ts +2 -3
- package/email.ts +50 -6
- package/host.ts +1 -1
- package/lib/api-schemas.ts +101 -5
- package/lib/command-registry.ts +169 -3
- package/lib/enrichment-gateway.ts +61 -0
- package/lib/exa-companies.ts +389 -0
- package/lib/exa-people.ts +682 -0
- package/lib/exa-search-constraints.ts +70 -0
- package/package.json +1 -1
- package/search.ts +204 -9
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which Exa `/search` params are unusable per `category`.
|
|
3
|
+
*
|
|
4
|
+
* MEASURED against the live API on 2026-08-12, because the published docs are
|
|
5
|
+
* wrong in both directions and the two categories do NOT share a list:
|
|
6
|
+
*
|
|
7
|
+
* | param | category: "people" | category: "company" |
|
|
8
|
+
* |--------------------|---------------------------|-----------------------|
|
|
9
|
+
* | includeDomains | 200, then SILENTLY IGNORED| 200, genuinely applied|
|
|
10
|
+
* | excludeDomains | HTTP 400 | 200, genuinely applied|
|
|
11
|
+
* | startPublishedDate | HTTP 400 | HTTP 400 |
|
|
12
|
+
* | endPublishedDate | HTTP 400 | HTTP 400 |
|
|
13
|
+
*
|
|
14
|
+
* The docs claim all four 400 for people (wrong: `includeDomains` does not)
|
|
15
|
+
* and that `excludeDomains` 400s for company (wrong: it works). Verified by
|
|
16
|
+
* observation, not by reading: `includeDomains: ["github.com"]` on a people
|
|
17
|
+
* search returned five linkedin.com profiles, while the same filter on a
|
|
18
|
+
* company search returned zero results, and `excludeDomains: ["stripe.com"]`
|
|
19
|
+
* removed Stripe from a company search that otherwise ranked it first.
|
|
20
|
+
*
|
|
21
|
+
* The silently-ignored case is the dangerous one — a caller gets plausible
|
|
22
|
+
* results and no signal their filter did nothing — so it is listed here
|
|
23
|
+
* alongside the hard failures even though it does not error. Domain filters
|
|
24
|
+
* are deliberately NOT listed for company, because there they work.
|
|
25
|
+
*/
|
|
26
|
+
export const EXA_UNSUPPORTED_PARAMS_BY_CATEGORY: Record<
|
|
27
|
+
string,
|
|
28
|
+
readonly string[]
|
|
29
|
+
> = {
|
|
30
|
+
people: [
|
|
31
|
+
"includeDomains",
|
|
32
|
+
"excludeDomains",
|
|
33
|
+
"startPublishedDate",
|
|
34
|
+
"endPublishedDate",
|
|
35
|
+
],
|
|
36
|
+
company: ["startPublishedDate", "endPublishedDate"],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Params unusable for `category`; empty for categories with no restrictions. */
|
|
40
|
+
export function unsupportedParamsForCategory(
|
|
41
|
+
category: string | undefined,
|
|
42
|
+
): readonly string[] {
|
|
43
|
+
if (!category) return [];
|
|
44
|
+
return EXA_UNSUPPORTED_PARAMS_BY_CATEGORY[category] ?? [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Delete the params that do not work for this category, returning what was
|
|
49
|
+
* removed so the caller can report it. Mutates `body`; a no-op for
|
|
50
|
+
* unrestricted categories.
|
|
51
|
+
*
|
|
52
|
+
* Callers deliberately differ in how they use the return value: the agent
|
|
53
|
+
* workflow step strips silently and reports `droppedParams` (a model cannot
|
|
54
|
+
* foresee a provider incompatibility, and a hard 400 costs it a whole turn),
|
|
55
|
+
* while `dench search` refuses outright (a human who typed the flag meant it,
|
|
56
|
+
* and neither erroring nor silently ignoring it is an acceptable answer).
|
|
57
|
+
*/
|
|
58
|
+
export function stripUnsupportedParamsForCategory(
|
|
59
|
+
body: Record<string, unknown>,
|
|
60
|
+
category: string | undefined,
|
|
61
|
+
): string[] {
|
|
62
|
+
const dropped: string[] = [];
|
|
63
|
+
for (const key of unsupportedParamsForCategory(category)) {
|
|
64
|
+
if (body[key] !== undefined) {
|
|
65
|
+
delete body[key];
|
|
66
|
+
dropped.push(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return dropped;
|
|
70
|
+
}
|
package/package.json
CHANGED
package/search.ts
CHANGED
|
@@ -10,12 +10,32 @@
|
|
|
10
10
|
* search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural]
|
|
11
11
|
* [--category news|company|people|...]
|
|
12
12
|
* [--include-domains a.com,b.com] [--exclude-domains c.com]
|
|
13
|
+
* [--start-published-date ISO] [--end-published-date ISO]
|
|
13
14
|
* [--max-chars 800] [--json]
|
|
14
15
|
* search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]
|
|
15
16
|
* search answer "<query>" [--json]
|
|
17
|
+
*
|
|
18
|
+
* `--category people` and `--category company` upgrade the SAME search rather
|
|
19
|
+
* than being separate subcommands: those categories hit Exa's dedicated entity
|
|
20
|
+
* indices, so the results are structured records and are rendered as such.
|
|
21
|
+
*
|
|
22
|
+
* Filter support is PER-CATEGORY and measured, not guessed: people search
|
|
23
|
+
* cannot use domain or published-date filters, while company search uses
|
|
24
|
+
* domain filters happily and only loses the date ones. See
|
|
25
|
+
* cli/lib/exa-search-constraints.ts for the observed behaviour. This CLI
|
|
26
|
+
* refuses an unusable flag; the agent-side equivalent
|
|
27
|
+
* (src/workflows/exa-web-steps.ts) strips it instead — that file explains why
|
|
28
|
+
* the two surfaces deliberately differ.
|
|
16
29
|
*/
|
|
17
30
|
|
|
18
|
-
import { getFlag } from "./lib/cli-args";
|
|
31
|
+
import { assertNoUnknownFlags, getFlag } from "./lib/cli-args";
|
|
32
|
+
import { formatUsdCompact, parseExaCompanyResults } from "./lib/exa-companies";
|
|
33
|
+
import {
|
|
34
|
+
currentRole,
|
|
35
|
+
parseExaPeopleResults,
|
|
36
|
+
personFullName,
|
|
37
|
+
} from "./lib/exa-people";
|
|
38
|
+
import { unsupportedParamsForCategory } from "./lib/exa-search-constraints";
|
|
19
39
|
|
|
20
40
|
type SearchCliContext = {
|
|
21
41
|
args: string[];
|
|
@@ -65,6 +85,40 @@ const ALLOWED_CATEGORIES = new Set([
|
|
|
65
85
|
"people",
|
|
66
86
|
]);
|
|
67
87
|
|
|
88
|
+
/** CLI flag → the Exa request param it sets. */
|
|
89
|
+
const FLAG_TO_EXA_PARAM: Record<string, string> = {
|
|
90
|
+
"--include-domains": "includeDomains",
|
|
91
|
+
"--exclude-domains": "excludeDomains",
|
|
92
|
+
"--start-published-date": "startPublishedDate",
|
|
93
|
+
"--end-published-date": "endPublishedDate",
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Refuse flags that do not work for the requested category. The list is
|
|
98
|
+
* per-category and measured, not guessed — domain filters are unusable for
|
|
99
|
+
* people but genuinely work for company.
|
|
100
|
+
*/
|
|
101
|
+
function assertFlagsSupportedForCategory(
|
|
102
|
+
category: string | undefined,
|
|
103
|
+
provided: string[],
|
|
104
|
+
): void {
|
|
105
|
+
const unsupported = new Set(unsupportedParamsForCategory(category));
|
|
106
|
+
const offenders = provided.filter((flag) =>
|
|
107
|
+
unsupported.has(FLAG_TO_EXA_PARAM[flag] ?? ""),
|
|
108
|
+
);
|
|
109
|
+
if (offenders.length === 0) return;
|
|
110
|
+
throw new SearchCliError(
|
|
111
|
+
`${offenders.join(", ")} has no effect on ${category} search — Exa ` +
|
|
112
|
+
"either rejects it or silently ignores it, so the results would not " +
|
|
113
|
+
"match what you asked for. (Unsupported for " +
|
|
114
|
+
`${category}: ${[...unsupported].join(", ")}.) ` +
|
|
115
|
+
"Put those constraints in the query text instead, e.g. " +
|
|
116
|
+
(category === "company"
|
|
117
|
+
? '`dench search "Swiss fintech founded after 2020" --category company`.'
|
|
118
|
+
: '`dench search "engineers at Stripe in Dublin" --category people`.'),
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
68
122
|
function normalizeGatewayBaseUrl(value: string): string {
|
|
69
123
|
return value.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
70
124
|
}
|
|
@@ -216,6 +270,96 @@ function formatSearchResults(payload: unknown, limit?: number): string {
|
|
|
216
270
|
return lines.join("\n").trimEnd();
|
|
217
271
|
}
|
|
218
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Render Exa people results from their structured `entities`, not page text.
|
|
275
|
+
* Each line is name · current title · company · location, so a list-building
|
|
276
|
+
* run is readable without `--json`.
|
|
277
|
+
*/
|
|
278
|
+
function formatPeopleResults(payload: unknown, limit?: number): string {
|
|
279
|
+
const hits = parseExaPeopleResults(payload);
|
|
280
|
+
const lines: string[] = [];
|
|
281
|
+
const auto = asString(asObject(payload)?.autopromptString);
|
|
282
|
+
if (auto) {
|
|
283
|
+
lines.push(`Auto-prompt: ${auto}`);
|
|
284
|
+
lines.push("");
|
|
285
|
+
}
|
|
286
|
+
if (hits.length === 0) {
|
|
287
|
+
lines.push("(no people found)");
|
|
288
|
+
return lines.join("\n");
|
|
289
|
+
}
|
|
290
|
+
const cap = limit !== undefined ? Math.min(hits.length, limit) : hits.length;
|
|
291
|
+
for (let i = 0; i < cap; i++) {
|
|
292
|
+
const hit = hits[i];
|
|
293
|
+
const role = currentRole(hit.person);
|
|
294
|
+
const name = personFullName(hit.person) ?? hit.title ?? "(unnamed)";
|
|
295
|
+
const roleLine = [role?.title, role?.company?.name]
|
|
296
|
+
.filter(Boolean)
|
|
297
|
+
.join(" · ");
|
|
298
|
+
lines.push(`${i + 1}. ${name}`);
|
|
299
|
+
if (roleLine) lines.push(` ${roleLine}`);
|
|
300
|
+
if (hit.person.location) lines.push(` ${hit.person.location}`);
|
|
301
|
+
if (hit.url) lines.push(` ${hit.url}`);
|
|
302
|
+
lines.push("");
|
|
303
|
+
}
|
|
304
|
+
const cost = asNumber(asObject(asObject(payload)?.costDollars)?.total);
|
|
305
|
+
if (cost !== undefined) lines.push(`Cost (USD): $${cost.toFixed(4)}`);
|
|
306
|
+
return lines.join("\n").trimEnd();
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Render Exa company results from their structured `entities`. One line of
|
|
311
|
+
* firmographics per company so an account-list run is readable without
|
|
312
|
+
* `--json`.
|
|
313
|
+
*/
|
|
314
|
+
function formatCompanyResults(payload: unknown, limit?: number): string {
|
|
315
|
+
const hits = parseExaCompanyResults(payload);
|
|
316
|
+
const lines: string[] = [];
|
|
317
|
+
const auto = asString(asObject(payload)?.autopromptString);
|
|
318
|
+
if (auto) {
|
|
319
|
+
lines.push(`Auto-prompt: ${auto}`);
|
|
320
|
+
lines.push("");
|
|
321
|
+
}
|
|
322
|
+
if (hits.length === 0) {
|
|
323
|
+
lines.push("(no companies found)");
|
|
324
|
+
return lines.join("\n");
|
|
325
|
+
}
|
|
326
|
+
const cap = limit !== undefined ? Math.min(hits.length, limit) : hits.length;
|
|
327
|
+
for (let i = 0; i < cap; i++) {
|
|
328
|
+
const { company, url, title } = hits[i];
|
|
329
|
+
const hq = company.headquarters;
|
|
330
|
+
const facts = [
|
|
331
|
+
company.foundedYear ? `founded ${company.foundedYear}` : null,
|
|
332
|
+
company.headcount
|
|
333
|
+
? `${company.headcount.toLocaleString()} employees`
|
|
334
|
+
: null,
|
|
335
|
+
[hq?.city, hq?.country].filter(Boolean).join(", ") || null,
|
|
336
|
+
].filter(Boolean);
|
|
337
|
+
const money = [
|
|
338
|
+
company.financials?.fundingTotal
|
|
339
|
+
? `raised ${formatUsdCompact(company.financials.fundingTotal)}`
|
|
340
|
+
: null,
|
|
341
|
+
company.financials?.fundingLatestRound?.name
|
|
342
|
+
? `latest ${company.financials.fundingLatestRound.name}`
|
|
343
|
+
: null,
|
|
344
|
+
company.financials?.revenueAnnual
|
|
345
|
+
? `revenue ${formatUsdCompact(company.financials.revenueAnnual)}`
|
|
346
|
+
: null,
|
|
347
|
+
].filter(Boolean);
|
|
348
|
+
|
|
349
|
+
lines.push(`${i + 1}. ${company.name ?? title ?? "(unnamed)"}`);
|
|
350
|
+
if (url) lines.push(` ${url}`);
|
|
351
|
+
if (facts.length > 0) lines.push(` ${facts.join(" · ")}`);
|
|
352
|
+
if (money.length > 0) lines.push(` ${money.join(" · ")}`);
|
|
353
|
+
if (company.description) {
|
|
354
|
+
lines.push(` ${trimSnippet(company.description, 200)}`);
|
|
355
|
+
}
|
|
356
|
+
lines.push("");
|
|
357
|
+
}
|
|
358
|
+
const cost = asNumber(asObject(asObject(payload)?.costDollars)?.total);
|
|
359
|
+
if (cost !== undefined) lines.push(`Cost (USD): $${cost.toFixed(4)}`);
|
|
360
|
+
return lines.join("\n").trimEnd();
|
|
361
|
+
}
|
|
362
|
+
|
|
219
363
|
function formatContentsResults(payload: unknown): string {
|
|
220
364
|
const root = asObject(payload);
|
|
221
365
|
if (!root) return "(no contents)";
|
|
@@ -303,32 +447,61 @@ async function runSearchSubcommand(ctx: SearchCliContext): Promise<void> {
|
|
|
303
447
|
`Invalid --category: ${category}. Allowed: ${[...ALLOWED_CATEGORIES].join(", ")}`,
|
|
304
448
|
);
|
|
305
449
|
}
|
|
306
|
-
const
|
|
307
|
-
const
|
|
450
|
+
const rawIncludeDomains = getFlag(ctx.args, "--include-domains");
|
|
451
|
+
const rawExcludeDomains = getFlag(ctx.args, "--exclude-domains");
|
|
452
|
+
const startPublishedDate = getFlag(ctx.args, "--start-published-date");
|
|
453
|
+
const endPublishedDate = getFlag(ctx.args, "--end-published-date");
|
|
454
|
+
assertFlagsSupportedForCategory(
|
|
455
|
+
category,
|
|
456
|
+
[
|
|
457
|
+
rawIncludeDomains !== undefined ? "--include-domains" : null,
|
|
458
|
+
rawExcludeDomains !== undefined ? "--exclude-domains" : null,
|
|
459
|
+
startPublishedDate !== undefined ? "--start-published-date" : null,
|
|
460
|
+
endPublishedDate !== undefined ? "--end-published-date" : null,
|
|
461
|
+
].filter((flag): flag is string => flag !== null),
|
|
462
|
+
);
|
|
463
|
+
const includeDomains = splitCsv(rawIncludeDomains);
|
|
464
|
+
const excludeDomains = splitCsv(rawExcludeDomains);
|
|
308
465
|
const maxChars =
|
|
309
466
|
parsePositiveInt("--max-chars", getFlag(ctx.args, "--max-chars")) ?? 800;
|
|
467
|
+
assertNoUnknownFlags(ctx.args, "search");
|
|
310
468
|
const queryParts = ctx.args.slice();
|
|
311
469
|
ctx.args.length = 0;
|
|
312
470
|
const query = queryParts.join(" ").trim();
|
|
313
471
|
if (!query) {
|
|
314
472
|
throw new SearchCliError(
|
|
315
|
-
'Usage: dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning] [--category ...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--max-chars 800] [--json]',
|
|
473
|
+
'Usage: dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning] [--category ...] [--include-domains a.com,b.com] [--exclude-domains c.com] [--start-published-date ISO] [--end-published-date ISO] [--max-chars 800] [--json]',
|
|
316
474
|
);
|
|
317
475
|
}
|
|
476
|
+
// `people` and `company` hit Exa's dedicated entity indices, which return
|
|
477
|
+
// structured records rather than pages. Full page text adds nothing there —
|
|
478
|
+
// the typed fields are the point — so contents are trimmed to highlights
|
|
479
|
+
// and the output is rendered from the entities instead of snippets.
|
|
480
|
+
const isEntitySearch = category === "people" || category === "company";
|
|
318
481
|
const body: Record<string, unknown> = {
|
|
319
482
|
query,
|
|
320
483
|
numResults: numResults ?? 10,
|
|
321
484
|
type: type ?? "auto",
|
|
322
|
-
contents:
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
485
|
+
contents: isEntitySearch
|
|
486
|
+
? { highlights: { maxCharacters: 200 } }
|
|
487
|
+
: {
|
|
488
|
+
text: { maxCharacters: maxChars },
|
|
489
|
+
highlights: { maxCharacters: 200 },
|
|
490
|
+
},
|
|
326
491
|
};
|
|
327
492
|
if (category) body.category = category;
|
|
328
493
|
if (includeDomains) body.includeDomains = includeDomains;
|
|
329
494
|
if (excludeDomains) body.excludeDomains = excludeDomains;
|
|
495
|
+
if (startPublishedDate) body.startPublishedDate = startPublishedDate;
|
|
496
|
+
if (endPublishedDate) body.endPublishedDate = endPublishedDate;
|
|
330
497
|
const payload = await callGateway(ctx, "/v1/search", body);
|
|
331
|
-
|
|
498
|
+
const rendered =
|
|
499
|
+
category === "people"
|
|
500
|
+
? formatPeopleResults(payload, numResults)
|
|
501
|
+
: category === "company"
|
|
502
|
+
? formatCompanyResults(payload, numResults)
|
|
503
|
+
: formatSearchResults(payload, numResults);
|
|
504
|
+
out(ctx, payload, rendered);
|
|
332
505
|
}
|
|
333
506
|
|
|
334
507
|
async function runContentsSubcommand(ctx: SearchCliContext): Promise<void> {
|
|
@@ -385,8 +558,30 @@ Search the web (default subcommand):
|
|
|
385
558
|
dench search "<query>" [--num-results N] [--type auto|fast|deep|deep-reasoning|neural]
|
|
386
559
|
[--category news|company|people|...]
|
|
387
560
|
[--include-domains a.com,b.com] [--exclude-domains c.com]
|
|
561
|
+
[--start-published-date ISO] [--end-published-date ISO]
|
|
388
562
|
[--max-chars 800] [--json]
|
|
389
563
|
|
|
564
|
+
Find people or companies (--category people|company):
|
|
565
|
+
Those two categories search dedicated indices and print STRUCTURED
|
|
566
|
+
records instead of page snippets — people give name, current title,
|
|
567
|
+
company, location, work history and education; companies give domain,
|
|
568
|
+
description, founded year, headcount, HQ, total funding, latest round
|
|
569
|
+
and revenue. Neither returns emails or phone numbers.
|
|
570
|
+
|
|
571
|
+
Put every constraint in the query text: role, seniority, skill,
|
|
572
|
+
company, industry, location, funding stage and founding year all work.
|
|
573
|
+
Similarity queries work well for companies.
|
|
574
|
+
|
|
575
|
+
dench search "senior ML engineers at fintech companies" --category people
|
|
576
|
+
dench search "VP Engineering AI infrastructure SF" --category people
|
|
577
|
+
dench search "fintech companies in Switzerland" --category company
|
|
578
|
+
dench search "companies like Notion" --category company
|
|
579
|
+
|
|
580
|
+
Filter support differs by category: --include-domains/--exclude-domains
|
|
581
|
+
work with --category company but NOT with --category people, and
|
|
582
|
+
published-date filters work with neither. Passing one that does not
|
|
583
|
+
apply is refused rather than silently ignored.
|
|
584
|
+
|
|
390
585
|
Deep-read one or more URLs (Exa /search/contents):
|
|
391
586
|
dench search contents <url> [<url>...] [--max-chars 4000] [--summary "..."] [--json]
|
|
392
587
|
|