sproutkit-code 0.4.0 → 0.5.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.
package/dist/index.js CHANGED
@@ -80617,33 +80617,43 @@ var require_places = __commonJS({
80617
80617
  return Math.max(0, Math.round(score));
80618
80618
  }
80619
80619
  var COST_PER_REQUEST_GBP = 0.025;
80620
- async function searchPlaces(args) {
80621
- const results = [];
80622
- const seen = /* @__PURE__ */ new Set();
80623
- for (const type of args.types) {
80624
- if (results.length >= args.maxResults)
80625
- break;
80626
- const body = {
80627
- textQuery: `${type} in ${args.city}`,
80628
- maxResultCount: 20
80620
+ async function searchOneType(args) {
80621
+ const body = {
80622
+ textQuery: `${args.type} in ${args.city}`,
80623
+ maxResultCount: 20
80624
+ };
80625
+ if (args.center && args.radiusMeters) {
80626
+ body.locationBias = {
80627
+ circle: {
80628
+ center: { latitude: args.center.lat, longitude: args.center.lng },
80629
+ radius: args.radiusMeters
80630
+ }
80629
80631
  };
80630
- if (args.center && args.radiusMeters) {
80631
- body.locationBias = {
80632
- circle: {
80633
- center: { latitude: args.center.lat, longitude: args.center.lng },
80634
- radius: args.radiusMeters
80635
- }
80636
- };
80637
- }
80632
+ }
80633
+ try {
80638
80634
  const r = await axios_1.default.post("https://places.googleapis.com/v1/places:searchText", body, {
80639
80635
  headers: {
80640
80636
  "X-Goog-Api-Key": args.apiKey,
80641
80637
  "X-Goog-FieldMask": "places.id,places.displayName,places.formattedAddress,places.nationalPhoneNumber,places.websiteUri,places.rating,places.userRatingCount,places.location,places.priceLevel,places.businessStatus,places.regularOpeningHours"
80642
80638
  }
80643
80639
  });
80644
- for (const p of r.data.places ?? []) {
80645
- if (results.length >= args.maxResults)
80646
- break;
80640
+ return { type: args.type, places: r.data.places ?? [] };
80641
+ } catch {
80642
+ return { type: args.type, places: [] };
80643
+ }
80644
+ }
80645
+ async function searchPlaces(args) {
80646
+ const batches = await Promise.all(args.types.map((type) => searchOneType({
80647
+ apiKey: args.apiKey,
80648
+ city: args.city,
80649
+ type,
80650
+ center: args.center,
80651
+ radiusMeters: args.radiusMeters
80652
+ })));
80653
+ const results = [];
80654
+ const seen = /* @__PURE__ */ new Set();
80655
+ for (const { type, places } of batches) {
80656
+ for (const p of places) {
80647
80657
  if (seen.has(p.id))
80648
80658
  continue;
80649
80659
  seen.add(p.id);
@@ -80687,7 +80697,7 @@ var require_places = __commonJS({
80687
80697
  }
80688
80698
  }
80689
80699
  results.sort((a, b) => b.score - a.score);
80690
- return results;
80700
+ return results.slice(0, args.maxResults);
80691
80701
  }
80692
80702
  function haversineMeters(lat1, lon1, lat2, lon2) {
80693
80703
  const R = 6371e3;
@@ -80729,39 +80739,6 @@ var require_lead_search = __commonJS({
80729
80739
  exports2.searchAndSaveLeads = searchAndSaveLeads;
80730
80740
  var db_1 = require_db();
80731
80741
  var places_1 = require_places();
80732
- var llm_1 = require_llm();
80733
- async function hasRealWebsite(cand, anthropicKey) {
80734
- const prompt = `Search the web to determine whether this specific local business has its OWN real, live website (a site they control \u2014 e.g. a .com/.co.uk, Wix, Squarespace, or self-hosted site).
80735
-
80736
- Business: "${cand.name}"
80737
- Address: ${cand.address}${cand.city ? `, ${cand.city}` : ""}
80738
-
80739
- Rules for your judgement:
80740
- - A Facebook, Instagram, or other social-media page is NOT a website.
80741
- - A directory listing (Yelp, Yell, TripAdvisor, Google profile, JustEat, Deliveroo, Treatwell, Fresha, etc.) is NOT their own website.
80742
- - A parked, for-sale, or dead domain with no real content is NOT a live website.
80743
- - Only answer true if you find a genuine website that clearly belongs to THIS business.
80744
- - If you are unsure, or can't confirm, answer false.
80745
-
80746
- Respond with ONLY compact JSON, no prose: {"hasWebsite": boolean, "url": string|null}`;
80747
- try {
80748
- const res = await (0, llm_1.generateText)({
80749
- apiKey: anthropicKey,
80750
- prompt,
80751
- allowWebSearch: true,
80752
- maxTokens: 1024,
80753
- timeoutMs: 9e4
80754
- });
80755
- const raw = res.text.replace(/^```(?:json)?\s*/i, "").replace(/\s*```\s*$/, "").trim();
80756
- const match = raw.match(/\{[\s\S]*\}/);
80757
- if (!match)
80758
- return false;
80759
- const parsed = JSON.parse(match[0]);
80760
- return parsed.hasWebsite === true;
80761
- } catch {
80762
- return false;
80763
- }
80764
- }
80765
80742
  async function searchAndSaveLeads(args) {
80766
80743
  const db2 = (0, db_1.getDb)();
80767
80744
  const results = await (0, places_1.searchPlaces)({
@@ -80773,20 +80750,10 @@ Respond with ONLY compact JSON, no prose: {"hasWebsite": boolean, "url": string|
80773
80750
  radiusMeters: args.radiusMeters
80774
80751
  });
80775
80752
  let saved = 0;
80776
- let verifiedOut = 0;
80777
80753
  for (const r of results) {
80778
80754
  const before = db2.all("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
80779
80755
  if (before.length > 0)
80780
80756
  continue;
80781
- if (args.verifyNoWebsite) {
80782
- args.onProgress?.(`Checking ${r.name} has no website\u2026`);
80783
- const real = await hasRealWebsite({ name: r.name, address: r.address, city: args.city }, args.anthropicKey);
80784
- if (real) {
80785
- verifiedOut++;
80786
- args.onProgress?.(`${r.name} already has a website \u2014 skipping.`);
80787
- continue;
80788
- }
80789
- }
80790
80757
  db2.run(`INSERT INTO leads
80791
80758
  (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status)
80792
80759
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered')`, [
@@ -80807,7 +80774,7 @@ Respond with ONLY compact JSON, no prose: {"hasWebsite": boolean, "url": string|
80807
80774
  ]);
80808
80775
  saved++;
80809
80776
  }
80810
- return { found: results.length, saved, verifiedOut };
80777
+ return { found: results.length, saved };
80811
80778
  }
80812
80779
  }
80813
80780
  });
@@ -81146,12 +81113,7 @@ Interested?`;
81146
81113
  types: categories,
81147
81114
  maxResults: Math.min(Number(input.maxResults ?? 25), 100),
81148
81115
  center: place ? { lat: place.lat, lng: place.lng } : void 0,
81149
- radiusMeters: Number(input.radiusMeters ?? 3e3),
81150
- // Web-search each candidate to confirm no real website exists before
81151
- // saving. Skippable via verify:false. Fails open on any error.
81152
- verifyNoWebsite: input.verify !== false,
81153
- anthropicKey: getDecryptedSetting("anthropic_key"),
81154
- onProgress: (msg) => ctx.emitProgress?.({ channel: "leads:searching", payload: { msg } })
81116
+ radiusMeters: Number(input.radiusMeters ?? 3e3)
81155
81117
  });
81156
81118
  ctx.emitProgress?.({
81157
81119
  channel: "leads:changed",
@@ -82320,7 +82282,7 @@ var require_package = __commonJS({
82320
82282
  "package.json"(exports2, module2) {
82321
82283
  module2.exports = {
82322
82284
  name: "sproutkit-code",
82323
- version: "0.4.0",
82285
+ version: "0.5.1",
82324
82286
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
82325
82287
  bin: {
82326
82288
  sproutkit: "./dist/index.js"
@@ -82576,6 +82538,77 @@ var Status = class {
82576
82538
  else say(` ${mark} ${label}`);
82577
82539
  }
82578
82540
  };
82541
+ var STATE_MARK = {
82542
+ queued: () => c.dim("\xB7"),
82543
+ building: () => c.cyan(""),
82544
+ // spinner frame filled in at render time
82545
+ built: () => c.green("\u2713"),
82546
+ failed: () => c.red("\u2717"),
82547
+ skipped: () => c.dim("\u2013")
82548
+ };
82549
+ var StatusBoard = class {
82550
+ rows = [];
82551
+ frame = 0;
82552
+ timer = null;
82553
+ lastLineCount = 0;
82554
+ tty = process.stdout.isTTY && useColor;
82555
+ // Non-TTY: remember the last printed signature per row so we only log changes.
82556
+ lastSig = /* @__PURE__ */ new Map();
82557
+ header = "";
82558
+ start(header, rows) {
82559
+ this.header = header;
82560
+ this.rows = rows;
82561
+ if (!this.tty) {
82562
+ say(c.cyan(` \u27F3 ${header}`));
82563
+ this.rows.forEach((r) => this.logRowIfChanged(r));
82564
+ return;
82565
+ }
82566
+ say(c.cyan(` \u27F3 ${header}`));
82567
+ this.render();
82568
+ this.timer = setInterval(() => {
82569
+ this.frame = (this.frame + 1) % FRAMES.length;
82570
+ this.render();
82571
+ }, 90);
82572
+ }
82573
+ update(row) {
82574
+ const i = this.rows.findIndex((r) => r.key === row.key);
82575
+ if (i >= 0) this.rows[i] = row;
82576
+ else this.rows.push(row);
82577
+ if (!this.tty) this.logRowIfChanged(row);
82578
+ else this.render();
82579
+ }
82580
+ // Replace the whole set at once (e.g. from a job snapshot).
82581
+ set(rows) {
82582
+ this.rows = rows;
82583
+ if (!this.tty) rows.forEach((r) => this.logRowIfChanged(r));
82584
+ else this.render();
82585
+ }
82586
+ line(r) {
82587
+ const mark = r.state === "building" ? c.cyan(FRAMES[this.frame]) : STATE_MARK[r.state]();
82588
+ const detail = r.detail ? c.dim(` \xB7 ${r.detail}`) : "";
82589
+ return ` ${mark} ${r.label}${detail}`;
82590
+ }
82591
+ logRowIfChanged(r) {
82592
+ const sig = `${r.state}:${r.detail ?? ""}`;
82593
+ if (this.lastSig.get(r.key) === sig) return;
82594
+ this.lastSig.set(r.key, sig);
82595
+ const mark = r.state === "built" ? "\u2713" : r.state === "failed" ? "\u2717" : r.state === "skipped" ? "\u2013" : "\u2026";
82596
+ say(` ${mark} ${r.label}${r.detail ? ` \xB7 ${r.detail}` : ""}`);
82597
+ }
82598
+ render() {
82599
+ if (this.lastLineCount > 0) process.stdout.write(`\x1B[${this.lastLineCount}A`);
82600
+ const out = this.rows.map((r) => `\x1B[2K${this.line(r)}`).join("\n");
82601
+ process.stdout.write(out + "\n");
82602
+ this.lastLineCount = this.rows.length;
82603
+ }
82604
+ stop() {
82605
+ if (this.timer) {
82606
+ clearInterval(this.timer);
82607
+ this.timer = null;
82608
+ }
82609
+ if (this.tty) this.render();
82610
+ }
82611
+ };
82579
82612
 
82580
82613
  // src/skill-install.ts
82581
82614
  var import_fs = __toESM(require("fs"));
@@ -83370,6 +83403,70 @@ async function cmdOutreach(argv) {
83370
83403
  if (result.noPhone) say(c.dim(` ${result.noPhone} had no phone number and were skipped for messaging.`));
83371
83404
  say();
83372
83405
  }
83406
+ async function cmdBuildAll(argv) {
83407
+ const { flags } = parseArgs(argv);
83408
+ const json = !!flags.json;
83409
+ const ids = String(flags.ids ?? "").split(",").map((s) => Number(s.trim())).filter((n) => !Number.isNaN(n) && n > 0);
83410
+ const leadIds = ids.length ? ids : (await runTool("list_leads", { limit: 100 }, makeCtx({ json }))).filter((l) => !l.site_url).map((l) => l.id);
83411
+ if (leadIds.length === 0) {
83412
+ const msg = "No unbuilt leads to build. Find some first, or pass --ids.";
83413
+ json ? emitJson({ ok: false, error: msg }) : say(c.amber(" " + msg));
83414
+ return;
83415
+ }
83416
+ const anthropicKey = getSetting("anthropic_key");
83417
+ const vercelToken = getSetting("vercel_token");
83418
+ if (!vercelToken) {
83419
+ const msg = "Vercel token not set. Run 'sproutkit setup'.";
83420
+ json ? emitJson({ ok: false, error: msg }) : say(c.red(" " + msg));
83421
+ process.exitCode = 1;
83422
+ return;
83423
+ }
83424
+ if (getBackend() === "api" && !anthropicKey) {
83425
+ const msg = "Anthropic API key not set. Run 'sproutkit setup'.";
83426
+ json ? emitJson({ ok: false, error: msg }) : say(c.red(" " + msg));
83427
+ process.exitCode = 1;
83428
+ return;
83429
+ }
83430
+ const concurrency = Math.min(Math.max(Number(flags.concurrency ?? 3), 1), 4);
83431
+ const board = json ? null : new StatusBoard();
83432
+ const stateToRow = (l) => ({
83433
+ key: l.leadId,
83434
+ label: `#${l.leadId} ${l.name}`,
83435
+ state: l.status,
83436
+ detail: l.status === "built" ? l.siteUrl : l.status === "failed" ? l.error : l.message
83437
+ });
83438
+ await new Promise((resolve) => {
83439
+ const emit = (channel, payload) => {
83440
+ const snap2 = payload;
83441
+ if (channel === "auto:progress" && board) board.set(snap2.leads.map(stateToRow));
83442
+ if (channel === "auto:done") {
83443
+ if (board) {
83444
+ board.set(snap2.leads.map(stateToRow));
83445
+ board.stop();
83446
+ }
83447
+ if (json) emitJson(snap2);
83448
+ else {
83449
+ say();
83450
+ say(
83451
+ c.green(` Done \u2014 ${snap2.completed} built`) + (snap2.failed ? c.red(`, ${snap2.failed} failed`) : "") + c.dim(` (concurrency ${snap2.concurrency}).`)
83452
+ );
83453
+ say();
83454
+ }
83455
+ autoSync();
83456
+ resolve();
83457
+ }
83458
+ };
83459
+ const snap = startAutoJob({
83460
+ leadIds,
83461
+ concurrency,
83462
+ anthropicKey: anthropicKey ?? "",
83463
+ vercelToken,
83464
+ placesKey: getSetting("places_key"),
83465
+ emit
83466
+ });
83467
+ if (board) board.start(`Building ${leadIds.length} sites (up to ${concurrency} at once)`, snap.leads.map(stateToRow));
83468
+ });
83469
+ }
83373
83470
 
83374
83471
  // src/index.ts
83375
83472
  process.env.SPROUTKIT_HEADLESS = "1";
@@ -83405,7 +83502,7 @@ async function main() {
83405
83502
  closeTty();
83406
83503
  return;
83407
83504
  }
83408
- if (arg === "find" || arg === "list" || arg === "build" || arg === "outreach") {
83505
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "outreach") {
83409
83506
  const subArgs = process.argv.slice(3);
83410
83507
  const lic2 = await check();
83411
83508
  if (lic2.status !== "active") {
@@ -83419,6 +83516,7 @@ async function main() {
83419
83516
  if (arg === "find") await cmdFind(subArgs);
83420
83517
  else if (arg === "list") await cmdList(subArgs);
83421
83518
  else if (arg === "build") await cmdBuild(subArgs);
83519
+ else if (arg === "build-all") await cmdBuildAll(subArgs);
83422
83520
  else await cmdOutreach(subArgs);
83423
83521
  closeTty();
83424
83522
  return;
@@ -83432,6 +83530,7 @@ async function main() {
83432
83530
  say(` ${c.bold('sproutkit find "<area>"')} find leads (--categories "barber shop,cafe")`);
83433
83531
  say(` ${c.bold("sproutkit list")} list leads (--status, --city, --json)`);
83434
83532
  say(` ${c.bold("sproutkit build <id>")} build a website (--instructions, --cms)`);
83533
+ say(` ${c.bold("sproutkit build-all")} build many at once (--ids, --concurrency 3)`);
83435
83534
  say(` ${c.bold("sproutkit outreach")} prep outreach links (--ids 3,5)`);
83436
83535
  say(` ${c.bold("sproutkit setup")} set or update keys, licence, backend`);
83437
83536
  say(` ${c.bold("sproutkit models")} list Claude models and pick one`);
@@ -22,10 +22,8 @@ you can parse results reliably, then summarise them for the user in prose.
22
22
 
23
23
  - **Find leads** — `sproutkit find "<area>" --categories "<type>,<type>" --json`
24
24
  - Searches Google Places for businesses of the given categories in the area,
25
- filters out any that already have a website, and saves the rest as leads.
26
- - It also web-searches each candidate to confirm it has no real website before
27
- saving — so the results are already verified. Social pages and directory
28
- listings don't count as a website; those businesses are kept as leads.
25
+ filters out any that already have a website (per Google's data), and saves
26
+ the rest as leads.
29
27
  - Example: `sproutkit find "Shoreditch, London" --categories "barber shop,cafe" --json`
30
28
  - Optional: `--max 25`, `--radius 3000`.
31
29
 
@@ -40,6 +38,16 @@ you can parse results reliably, then summarise them for the user in prose.
40
38
  - Returns the live `url`. If the lead already has a site, it returns
41
39
  `{"ok":false,...}` — tell the user and offer to regenerate.
42
40
 
41
+ - **Build MANY sites at once** — `sproutkit build-all --json` (optional `--ids 3,5,9`, `--concurrency 3`)
42
+ - Builds several sites in parallel using its own concurrency pool and shows a
43
+ live per-site board. With no `--ids`, it builds every lead that has no site.
44
+ - **Use this for any bulk request** ("build them all", "build the best 5",
45
+ "build sites for leads 1-10"). Do NOT spawn your own subagents to run
46
+ `sproutkit build` many times in parallel — `build-all` owns the
47
+ parallelism, keeps it within the user's subscription rate limits, and
48
+ renders the progress. One `sproutkit build-all` call is correct; N parallel
49
+ `sproutkit build` calls via subagents is not.
50
+
43
51
  - **Prepare outreach** — `sproutkit outreach --json` (optional `--ids 3,5,9`)
44
52
  - Fills the message template, moves the leads to Contacted, and returns a
45
53
  link per lead to open in WhatsApp/SMS. It does NOT auto-send — give the user
@@ -54,7 +62,7 @@ Chain commands to fulfil what the user asked, one step at a time:
54
62
  - "Find cafes in Peckham and build the best two" → find, then `sproutkit list --json` to see what came back, then `sproutkit build <id> --json` for the two highest-scoring.
55
63
  - "Build a site for The Oak Café" → `sproutkit list --json`, find the matching lead's id, then `sproutkit build <id> --json`.
56
64
  - "Message everyone I've built sites for" → `sproutkit outreach --json`, then list the links for the user to open.
57
- - "Find 20 cafes in Bristol, build them all, and prep outreach" → find, list to get ids, build each, then outreach.
65
+ - "Find 20 cafes in Bristol, build them all, and prep outreach" → find, then `sproutkit build-all --json` (NOT twenty separate build calls), then `sproutkit outreach --json`.
58
66
 
59
67
  Rules:
60
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"