@dench.com/cli 2.7.5 → 2.7.6-staging.1

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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.7.5",
3
+ "version": "2.7.6-staging.1",
4
4
  "description": "Dench agent workspace CLI.",
5
5
  "type": "module",
6
6
  "bin": {
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 includeDomains = splitCsv(getFlag(ctx.args, "--include-domains"));
307
- const excludeDomains = splitCsv(getFlag(ctx.args, "--exclude-domains"));
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
- text: { maxCharacters: maxChars },
324
- highlights: { maxCharacters: 200 },
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
- out(ctx, payload, formatSearchResults(payload, numResults));
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