heyspex 0.2.5 → 0.2.7
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/.claude-plugin/marketplace.json +1 -1
- package/bin/hs.mjs +349 -38
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/dispatch-guard.mjs +75 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -0
- package/dev-factory/lib/runner-path.mjs +51 -0
- package/dev-factory/lib/seat-context.mjs +20 -4
- package/dev-factory/model-catalog.json +222 -222
- package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
- package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
- package/dist/cli/hs-connect.mjs +40 -3
- package/dist/cli/hs-defer.mjs +3817 -3741
- package/dist/cli/hs-events.mjs +1296 -0
- package/dist/cli/hs-export-events.mjs +1252 -0
- package/dist/cli/hs-handoff.mjs +2576 -0
- package/dist/cli/hs-index-rebuild.mjs +228 -37
- package/dist/cli/hs-index-reconcile.mjs +231 -40
- package/dist/cli/hs-init.mjs +22 -3
- package/dist/cli/hs-install.mjs +22 -3
- package/dist/cli/hs-join.mjs +40 -3
- package/dist/cli/hs-next-launch.mjs +2777 -0
- package/dist/cli/hs-pin.mjs +1336 -0
- package/dist/cli/hs-prereq.mjs +1380 -0
- package/dist/cli/hs-revalidate.mjs +3257 -0
- package/dist/cli/hs-spawn.mjs +12403 -0
- package/dist/cli/hs-test.mjs +1046 -0
- package/dist/cli/hs-trash.mjs +362 -0
- package/dist/cli/hs-trust.mjs +197 -0
- package/dist/cli/hs-vault-pull.mjs +233 -42
- package/dist/cli/hs-vault-push.mjs +231 -40
- package/dist/cli/hs-vault.mjs +21 -7
- package/dist/cli/hs-workspace.mjs +19 -1
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +9 -2
- package/docs/mcp-client-config.md +6 -0
- package/package.json +266 -257
- package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
- package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
- package/plugins/heyspex/README.md +6 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
- package/plugins/heyspex/hooks/hooks.json +418 -418
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
- package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
- package/scripts/claude-statusline/statusline-command.mjs +287 -27
- package/scripts/hs-companion.mjs +41 -3
- package/scripts/install-claude-code-hooks.mjs +104 -3
- package/scripts/install-copilot-hooks.mjs +21 -1
- package/scripts/install-cursor-hooks.mjs +4 -3
- package/scripts/lib/capture-execution-guard.mjs +221 -0
- package/scripts/lib/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +53 -3
- package/scripts/lib/hs-companion-settings.mjs +26 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- package/scripts/record-grounding.mjs +38 -0
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
- package/spexharness/src/adapters/copilot-models.mjs +179 -0
- package/spexharness/src/adapters/harness-detect.mjs +148 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
- package/spexharness/src/seat-context.mjs +10 -5
- package/spexharness/src/session-bootstrap.mjs +6 -3
- package/vendor/heyspex-mcp/cli.mjs +6 -1
- package/vendor/heyspex-mcp/config-seed.yaml +15 -15
- package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
- package/vendor/heyspex-mcp/http-transport.mjs +1 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
- package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
- package/vendor/heyspex-mcp/research-tools.mjs +22 -2
- package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
- package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
- package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
- package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
- package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
- package/vendor/heyspex-mcp/vault-io.mjs +720 -720
- package/bin/postinstall.mjs +0 -88
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* spexcrawl-cli.mjs — CLI bridge from a Claude subagent (Bash tool only, no JS imports) to
|
|
4
|
+
* SpexCrawl: search/gather through the user's HeySpex Companion, brokered by the spex-console.
|
|
5
|
+
*
|
|
6
|
+
* THE GAP THIS CLOSES ([[HS-AIV8-G805-DIAG-hs-res-declares-spexcrawl-primary-but]]): the
|
|
7
|
+
* 2026-08-14 owner ruling makes SpexCrawl-via-Companion the PRIMARY research gather method
|
|
8
|
+
* (`.heyspex/config.yaml` default_source: spexcrawl-companion), but until this CLI existed no
|
|
9
|
+
* agent-reachable bridge did — every /hs-res run fell straight to the permission-gated Firecrawl
|
|
10
|
+
* fallback. This is the SpexCrawl sibling of firecrawl-cli.mjs: same verbs, same --json shape
|
|
11
|
+
* intent, same machine-readable failure prefix + exit-code conventions, so the /hs-res skill
|
|
12
|
+
* swaps backends without reshaping its steps.
|
|
13
|
+
*
|
|
14
|
+
* HOW IT WORKS (asynchronous — results come from the user's own machine):
|
|
15
|
+
* 1. POST <console>/api/agent/gather-jobs queues the job as a launch intent.
|
|
16
|
+
* 2. The HeySpex Companion app polls intents, searches/fetches/cleans pages LOCALLY
|
|
17
|
+
* (the user's browser context, robots-respecting), and uploads pages back to the console.
|
|
18
|
+
* 3. This CLI polls GET <console>/api/agent/gather-jobs/<jobId> every POLL_INTERVAL_MS until
|
|
19
|
+
* status "complete" (or --timeout), then prints the stored cleaned pages.
|
|
20
|
+
*
|
|
21
|
+
* PREREQUISITE (stated in every failure line so nobody has to dig): the HeySpex Companion
|
|
22
|
+
* tray app must be running — it is the only thing that executes gather jobs. Since HS-V9WH
|
|
23
|
+
* its relay polls loopback consoles too, so a same-machine dev console needs no LAN bind;
|
|
24
|
+
* only a Companion on a DIFFERENT machine needs the console in LAN mode
|
|
25
|
+
* (`npm run console:log:lan`). Without a Companion, jobs queue forever and this CLI exits 3
|
|
26
|
+
* at the poll timeout.
|
|
27
|
+
*
|
|
28
|
+
* FAILURE CONTRACT (mirrors FIRECRAWL_UNAVAILABLE so calling skill prompts can grep one prefix):
|
|
29
|
+
* - unreachable console, non-2xx that is not a 400, or poll timeout -> print a single line
|
|
30
|
+
* starting `SPEXCRAWL_UNAVAILABLE: ...` (including the LAN-mode hint) and exit 3.
|
|
31
|
+
* On the POLL leg only, a short streak of transient read failures is ridden out first
|
|
32
|
+
* (MAX_TRANSIENT_POLL_FAILURES) — the job is already filed and the Companion keeps
|
|
33
|
+
* working through a console blip, so one hiccup must not throw away a finished gather.
|
|
34
|
+
* - a 400 from the API -> print the server's message and exit 1 (the request was wrong,
|
|
35
|
+
* falling back to another backend would not fix it).
|
|
36
|
+
*
|
|
37
|
+
* Usage:
|
|
38
|
+
* node dev-factory/spexcrawl/spexcrawl-cli.mjs search "<query>" [--json] [--timeout SECONDS]
|
|
39
|
+
* node dev-factory/spexcrawl/spexcrawl-cli.mjs scrape "<url>" [<url>...] [--json] [--timeout SECONDS]
|
|
40
|
+
* (`gather` is accepted as an alias of `scrape` — it is the vault-native verb for the same job.)
|
|
41
|
+
* node dev-factory/spexcrawl/spexcrawl-cli.mjs crawl "<site>" [--max-pages N] [--max-depth N]
|
|
42
|
+
* [--json] [--timeout SECONDS]
|
|
43
|
+
* (walks a whole site — sitemap + same-origin links — instead of fetching known addresses;
|
|
44
|
+
* default --timeout is longer (600s) than search/scrape/gather's 180s, since a walk is bigger
|
|
45
|
+
* than a scrape. [[HS-QHES-90JL-TASK-crawl-reaches-agents-console-route]])
|
|
46
|
+
*
|
|
47
|
+
* Exit codes: 0 ok · 1 bad request / server-rejected input (server message printed) ·
|
|
48
|
+
* 3 SPEXCRAWL_UNAVAILABLE (unreachable / non-2xx / poll timeout — the
|
|
49
|
+
* stop-and-ask-before-Firecrawl signal) · 64 usage error (bad args).
|
|
50
|
+
*/
|
|
51
|
+
import { readFileSync } from "node:fs";
|
|
52
|
+
import { homedir } from "node:os";
|
|
53
|
+
import { dirname, join } from "node:path";
|
|
54
|
+
import { fileURLToPath } from "node:url";
|
|
55
|
+
|
|
56
|
+
// The ONE shared console-URL resolver ([[HS-GD45-MCPC-TASK-mcp-cloud-auth-shared-console-resolver]])
|
|
57
|
+
// — env → ~/.heyspex/credentials.yaml console_url → mode-derived default, so this CLI flips
|
|
58
|
+
// with `hs dev` / `hs login` like every other consumer instead of hand-rolling its own
|
|
59
|
+
// env-only lookup.
|
|
60
|
+
import { resolveConsoleUrl as resolveConsoleUrlShared } from "../../scripts/lib/console-url-resolver.mjs";
|
|
61
|
+
import { parseRepoBinding } from "../../vendor/heyspex-mcp/vault-binding.mjs";
|
|
62
|
+
|
|
63
|
+
export const DEFAULT_CONSOLE_URL = "http://localhost:3000";
|
|
64
|
+
export const DEFAULT_TIMEOUT_MS = 180_000;
|
|
65
|
+
/** `crawl`'s default poll timeout — a whole-site walk legitimately runs longer
|
|
66
|
+
* than a single search/scrape/gather round trip. */
|
|
67
|
+
export const DEFAULT_CRAWL_TIMEOUT_MS = 600_000;
|
|
68
|
+
export const POLL_INTERVAL_MS = 3_000;
|
|
69
|
+
/**
|
|
70
|
+
* How many CONSECUTIVE transient read failures the poll loop rides out before declaring the
|
|
71
|
+
* console unavailable ([[HS-FBZ3-NWVU-DIAG-spexcrawl-companion-connectivity]]). Read failures
|
|
72
|
+
* are independent of the job: the Companion keeps working through a console blip. Bounded and
|
|
73
|
+
* still subject to the overall `--timeout`, so a console that is genuinely down costs at most
|
|
74
|
+
* this many extra poll intervals before the Firecrawl fallback fires. Any answered poll resets
|
|
75
|
+
* the streak. A 400 is NOT transient and is never retried.
|
|
76
|
+
*/
|
|
77
|
+
export const MAX_TRANSIENT_POLL_FAILURES = 3;
|
|
78
|
+
|
|
79
|
+
/** Appended to every SPEXCRAWL_UNAVAILABLE line — the prerequisite must be stated by the
|
|
80
|
+
* CLI's own error message, not left to archaeology (diagnosis AC3). Since HS-V9WH the
|
|
81
|
+
* Companion polls loopback consoles too, so same-machine dev needs no LAN mode — LAN
|
|
82
|
+
* bind only matters when the Companion runs on a different machine than the console. */
|
|
83
|
+
export const LAN_HINT =
|
|
84
|
+
"if the Companion is not picking up jobs, check the HeySpex Companion tray app is running " +
|
|
85
|
+
"(rebuilt after 2026-08-21 — older builds ignore loopback consoles); a Companion on " +
|
|
86
|
+
"another machine additionally needs the console in LAN mode (npm run console:log:lan)";
|
|
87
|
+
|
|
88
|
+
/** Appended instead of {@link LAN_HINT} when the console REFUSED the bearer (401/403). That is a
|
|
89
|
+
* server-side auth verdict on this machine's token, not a Companion liveness problem — sending
|
|
90
|
+
* the reader to check the tray app hid a wrong-route bug for a day
|
|
91
|
+
* ([[HS-WN3F-LY85-DIAG-post-api-agent-gather-jobs-returns-401]] AC/fix 4). */
|
|
92
|
+
export const AUTH_HINT =
|
|
93
|
+
"the console refused this machine's bearer (clerk.companion_token in ~/.heyspex/credentials.yaml, " +
|
|
94
|
+
"or HEYSPEX_COMPANION_TOKEN) — the Companion being up or down does not matter here; re-pair this " +
|
|
95
|
+
"machine to the console so a fresh, unrevoked token is written, then retry";
|
|
96
|
+
|
|
97
|
+
/** Appended instead of {@link LAN_HINT} when the console refused the job because no Companion
|
|
98
|
+
* polls its vault ([[HS-L4LC]]). Names the vault-mismatch case: the Companion may be running
|
|
99
|
+
* fine, just bound (vaultRepoPath) to a different vault than the one this job named. */
|
|
100
|
+
export function companionVaultHint(vaultId) {
|
|
101
|
+
const v = jobVault();
|
|
102
|
+
const run = v.runVaultId && v.runVaultId !== vaultId ? `; this run's own vault is ${v.runVaultId}` : "";
|
|
103
|
+
return (
|
|
104
|
+
`no Companion is polling vault ${vaultId || "(none)"} (resolved via ${v.source}${run}) — ` +
|
|
105
|
+
"a Companion claims jobs only for the ONE vault its companion.settings.json vaultRepoPath names: " +
|
|
106
|
+
"if the tray app is running it is bound to another vault (set HEYSPEX_SPEXCRAWL_VAULT_ID to that " +
|
|
107
|
+
"vault's id, or point vaultRepoPath here), otherwise start the HeySpex Companion tray app"
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ── console endpoint + auth resolution ─────────────────────────────────────────────────────────
|
|
112
|
+
// Delegates to the shared resolver (env override still wins first); no `repoRoot` here, so the
|
|
113
|
+
// admin-local discovery-file rung is never consulted by this CLI (unchanged from before).
|
|
114
|
+
export function resolveConsoleUrl(env = process.env) {
|
|
115
|
+
return resolveConsoleUrlShared({ env });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Bearer token for /api/agent/*: env HEYSPEX_COMPANION_TOKEN first (LAN dev / CI), else the
|
|
120
|
+
* logged-in Companion session token (~/.heyspex/credentials.yaml, clerk.companion_token) — the
|
|
121
|
+
* SAME source the Companion itself reads (FileCompanionCredentialReader) and the same regex
|
|
122
|
+
* resolution as dev-factory/fleet/manager.mjs fleetToken(). Null = send no auth header, which
|
|
123
|
+
* mirrors the Companion's own optional-bearer behavior (the dev-loopback console accepts it).
|
|
124
|
+
*/
|
|
125
|
+
export function resolveConsoleToken(env = process.env, readFile = readFileSync) {
|
|
126
|
+
const fromEnv = String(env.HEYSPEX_COMPANION_TOKEN || "").trim();
|
|
127
|
+
if (fromEnv) return fromEnv;
|
|
128
|
+
try {
|
|
129
|
+
const yaml = readFile(join(homedir(), ".heyspex", "credentials.yaml"), "utf8");
|
|
130
|
+
const clerk = yaml.match(/^clerk:\s*$([\s\S]*?)(?=^\S|\s*$(?![\s\S]))/m)?.[1] ?? yaml;
|
|
131
|
+
const m = clerk.match(/^\s*companion_token:\s*["']?([^"'\r\n#]+)/m);
|
|
132
|
+
return m?.[1]?.trim() || null;
|
|
133
|
+
} catch {
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The vault this run is bound to, for `x-heyspex-vault-id`. The console refuses a bearer request
|
|
140
|
+
* that names no vault (409 VAULT_SCOPE_MISMATCH / vault-scope-required), so an unscoped POST to
|
|
141
|
+
* /api/agent/gather-jobs never reaches the Companion. Sources, first hit wins: env
|
|
142
|
+
* HEYSPEX_VAULT_ID; `vault.id` of a repo-binding heyspex.yaml (or legacy `vault_id:`) at the cwd (or an ancestor); then the vault root
|
|
143
|
+
* the repo's .mcp.json pins in mcpServers.heyspex.env.HEYSPEX_VAULT_ROOT (a code repo attached to a
|
|
144
|
+
* sibling vault). Null = send no header, which the console will refuse with its own hint.
|
|
145
|
+
*/
|
|
146
|
+
/** Vault id named by `<dir>/heyspex.yaml` — repo-binding `vault.id` first, legacy flat `vault_id:` last. */
|
|
147
|
+
function vaultIdInDir(dir, readFile = readFileSync) {
|
|
148
|
+
try {
|
|
149
|
+
const text = readFile(join(dir, "heyspex.yaml"), "utf8");
|
|
150
|
+
return parseRepoBinding(text)?.id || text.match(/^vault_id:\s*["']?([^"'\s#]+)/m)?.[1] || null;
|
|
151
|
+
} catch {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function resolveVaultId(env = process.env, readFile = readFileSync, cwd = process.cwd()) {
|
|
157
|
+
const fromEnv = String(env.HEYSPEX_VAULT_ID || "").trim();
|
|
158
|
+
if (fromEnv) return fromEnv;
|
|
159
|
+
const idIn = (dir) => vaultIdInDir(dir, readFile);
|
|
160
|
+
// HEYSPEX_VAULT_ROOT is the documented explicit override (owner ruling 2026-10-14 resolution
|
|
161
|
+
// order: --vault -> HEYSPEX_VAULT_ROOT -> walk up). It used to be ignored here, so a run aimed at
|
|
162
|
+
// another vault silently resolved the cwd's binding instead ([[HS-L4LC]] root cause 1).
|
|
163
|
+
const root = String(env.HEYSPEX_VAULT_ROOT || "").trim();
|
|
164
|
+
if (root && !root.includes("${")) {
|
|
165
|
+
const id = idIn(root);
|
|
166
|
+
if (id) return id;
|
|
167
|
+
}
|
|
168
|
+
for (let dir = cwd, prev = null; dir !== prev; prev = dir, dir = dirname(dir)) {
|
|
169
|
+
const id = idIn(dir);
|
|
170
|
+
if (id) return id;
|
|
171
|
+
}
|
|
172
|
+
try {
|
|
173
|
+
const pinned = JSON.parse(readFile(join(cwd, ".mcp.json"), "utf8"))?.mcpServers?.heyspex?.env?.HEYSPEX_VAULT_ROOT;
|
|
174
|
+
if (typeof pinned === "string" && pinned.trim() && !pinned.includes("${")) return idIn(pinned.trim());
|
|
175
|
+
} catch {
|
|
176
|
+
// no .mcp.json / unparseable: fall through to null
|
|
177
|
+
}
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Where the local Companion keeps `companion.settings.json`, in the order its own
|
|
183
|
+
* CompanionPathLocator.SettingsPath() would find it: explicit env override, the source checkout
|
|
184
|
+
* this CLI lives in (`<repo>/companion/`, a dev-built Companion), then the per-user install dir.
|
|
185
|
+
*/
|
|
186
|
+
export function companionSettingsCandidates(env = process.env) {
|
|
187
|
+
const out = [];
|
|
188
|
+
const fromEnv = String(env.HEYSPEX_COMPANION_SETTINGS || "").trim();
|
|
189
|
+
if (fromEnv) out.push(fromEnv);
|
|
190
|
+
try {
|
|
191
|
+
const here = dirname(fileURLToPath(import.meta.url)); // <repo>/dev-factory/spexcrawl
|
|
192
|
+
out.push(join(here, "..", "..", "companion", "companion.settings.json"));
|
|
193
|
+
} catch {
|
|
194
|
+
// non-file URL (bundled): skip the checkout rung
|
|
195
|
+
}
|
|
196
|
+
const localAppData = String(env.LOCALAPPDATA || "").trim();
|
|
197
|
+
if (localAppData) out.push(join(localAppData, "HeySpex", "companion.settings.json"));
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The vault the local Companion polls launch intents for — the ONLY vault whose gather jobs it
|
|
203
|
+
* will ever claim. The Companion stamps `x-heyspex-vault-id` from the `heyspex.yaml` under its
|
|
204
|
+
* single `vaultRepoPath` (RelayEnvironment.ResolveVaultId), and the console drains intents per
|
|
205
|
+
* (owner, vault), so a job filed under any other vault sits "pending" until the CLI times out
|
|
206
|
+
* ([[HS-L4LC]]: every HS-NPMN scrape for the MLC vault). Returns
|
|
207
|
+
* `{ vaultId, vaultRepoPath, settingsPath }` or null when no Companion settings are readable.
|
|
208
|
+
*/
|
|
209
|
+
export function resolveCompanionVault(env = process.env, readFile = readFileSync) {
|
|
210
|
+
for (const settingsPath of companionSettingsCandidates(env)) {
|
|
211
|
+
let text;
|
|
212
|
+
try {
|
|
213
|
+
text = readFile(settingsPath, "utf8");
|
|
214
|
+
} catch {
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const raw = text.match(/"vaultRepoPath"\s*:\s*("(?:[^"\\]|\\.)*")/)?.[1];
|
|
218
|
+
let vaultRepoPath = null;
|
|
219
|
+
try {
|
|
220
|
+
vaultRepoPath = raw ? JSON.parse(raw).trim() : null;
|
|
221
|
+
} catch {
|
|
222
|
+
vaultRepoPath = null;
|
|
223
|
+
}
|
|
224
|
+
if (!vaultRepoPath) continue;
|
|
225
|
+
const vaultId = vaultIdInDir(vaultRepoPath, readFile);
|
|
226
|
+
if (vaultId) return { vaultId, vaultRepoPath, settingsPath };
|
|
227
|
+
}
|
|
228
|
+
return null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The vault a SpexCrawl job is FILED under (`x-heyspex-vault-id` on the POST and the poll).
|
|
233
|
+
* A gather/research/crawl job is a work order for the user's own Companion, not vault content —
|
|
234
|
+
* the pages come straight back to this CLI, and whatever the caller saves lands in its own vault
|
|
235
|
+
* through its own write path. So the job is addressed to the vault the Companion actually
|
|
236
|
+
* listens on. Order: env HEYSPEX_SPEXCRAWL_VAULT_ID (explicit override) -> the local Companion's
|
|
237
|
+
* vault -> the run's own vault ({@link resolveVaultId}). `runVaultId` is reported so a mismatch
|
|
238
|
+
* is visible instead of silent.
|
|
239
|
+
*/
|
|
240
|
+
export function resolveJobVault(env = process.env, readFile = readFileSync, cwd = process.cwd()) {
|
|
241
|
+
const runVaultId = resolveVaultId(env, readFile, cwd);
|
|
242
|
+
const override = String(env.HEYSPEX_SPEXCRAWL_VAULT_ID || "").trim();
|
|
243
|
+
if (override) return { vaultId: override, source: "HEYSPEX_SPEXCRAWL_VAULT_ID", runVaultId };
|
|
244
|
+
const companion = resolveCompanionVault(env, readFile);
|
|
245
|
+
if (companion) {
|
|
246
|
+
return { vaultId: companion.vaultId, source: `companion (${companion.vaultRepoPath})`, runVaultId };
|
|
247
|
+
}
|
|
248
|
+
return { vaultId: runVaultId, source: "run vault (no Companion settings found)", runVaultId };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Resolved once per process: the vault every request of this run names. */
|
|
252
|
+
let jobVaultMemo = null;
|
|
253
|
+
function jobVault() {
|
|
254
|
+
jobVaultMemo ??= resolveJobVault();
|
|
255
|
+
return jobVaultMemo;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** One stderr line naming the vault the job was queued under — stdout stays pure for --json. */
|
|
259
|
+
function announceJobVault(posted) {
|
|
260
|
+
const v = jobVault();
|
|
261
|
+
const mismatch = v.runVaultId && v.vaultId && v.runVaultId !== v.vaultId ? ` (run vault ${v.runVaultId})` : "";
|
|
262
|
+
console.error(`spexcrawl: job ${posted.jobId} queued under vault ${v.vaultId || "(none)"} via ${v.source}${mismatch}`);
|
|
263
|
+
if (posted.companion?.likelyOffline) {
|
|
264
|
+
console.error(
|
|
265
|
+
`spexcrawl: WARNING no Companion has polled vault ${v.vaultId || "(none)"} recently ` +
|
|
266
|
+
`(last seen ${posted.companion.lastSeenAt || "never"}) — this job will likely sit pending until --timeout`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function authHeaders(token) {
|
|
272
|
+
const vaultId = jobVault().vaultId;
|
|
273
|
+
return {
|
|
274
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
275
|
+
...(vaultId ? { "x-heyspex-vault-id": vaultId } : {}),
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── HTTP layer (every path returns a clean shape — never throws to the caller) ─────────────────
|
|
280
|
+
/** Extract the server's own words from a 400 body (JSON `error`/`message` or raw text). */
|
|
281
|
+
async function readErrorMessage(res) {
|
|
282
|
+
try {
|
|
283
|
+
const text = await res.text();
|
|
284
|
+
try {
|
|
285
|
+
const j = JSON.parse(text);
|
|
286
|
+
return String(j.error || j.message || text);
|
|
287
|
+
} catch {
|
|
288
|
+
return text || `HTTP ${res.status}`;
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
return `HTTP ${res.status}`;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function postJob(body, { baseUrl, token, httpFetch }) {
|
|
296
|
+
let res;
|
|
297
|
+
try {
|
|
298
|
+
res = await httpFetch(`${baseUrl}/api/agent/gather-jobs`, {
|
|
299
|
+
method: "POST",
|
|
300
|
+
headers: { "content-type": "application/json", ...authHeaders(token) },
|
|
301
|
+
body: JSON.stringify(body),
|
|
302
|
+
});
|
|
303
|
+
} catch (e) {
|
|
304
|
+
return { ok: false, unavailable: true, reason: `console unreachable at ${baseUrl}: ${e?.message || e}` };
|
|
305
|
+
}
|
|
306
|
+
if (res.status === 400) {
|
|
307
|
+
return { ok: false, badRequest: true, message: await readErrorMessage(res) };
|
|
308
|
+
}
|
|
309
|
+
if (res.status === 401 || res.status === 403) {
|
|
310
|
+
// An auth refusal is a verdict on the TOKEN, not on whether the Companion is running —
|
|
311
|
+
// `renderFailure` swaps the Companion hint for the re-pair hint on this flag.
|
|
312
|
+
return {
|
|
313
|
+
ok: false,
|
|
314
|
+
unavailable: true,
|
|
315
|
+
authRefused: true,
|
|
316
|
+
reason: `POST /api/agent/gather-jobs -> HTTP ${res.status} (${await readErrorMessage(res)})`,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
if (res.status === 503) {
|
|
320
|
+
// The console REFUSES (does not queue) a job when no Companion has polled the job's vault
|
|
321
|
+
// recently ([[HS-L4LC]] fix direction 2) — typed `companion_not_listening`. Fail fast with a
|
|
322
|
+
// vault-naming hint instead of the generic tray-app one; any other 503 stays generic.
|
|
323
|
+
let j = null;
|
|
324
|
+
try {
|
|
325
|
+
j = await res.json();
|
|
326
|
+
} catch {
|
|
327
|
+
j = null;
|
|
328
|
+
}
|
|
329
|
+
if (j?.error === "companion_not_listening") {
|
|
330
|
+
return {
|
|
331
|
+
ok: false,
|
|
332
|
+
unavailable: true,
|
|
333
|
+
companionNotListening: true,
|
|
334
|
+
vaultId: j.vaultId || jobVault().vaultId || null,
|
|
335
|
+
lastSeenAt: j.companion?.lastSeenAt ?? null,
|
|
336
|
+
reason: `POST /api/agent/gather-jobs -> HTTP 503 companion_not_listening (${j.message || "no Companion polls this vault"})`,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { ok: false, unavailable: true, reason: `POST /api/agent/gather-jobs -> HTTP 503` };
|
|
340
|
+
}
|
|
341
|
+
if (!res.ok) {
|
|
342
|
+
return { ok: false, unavailable: true, reason: `POST /api/agent/gather-jobs -> HTTP ${res.status}` };
|
|
343
|
+
}
|
|
344
|
+
let parsed;
|
|
345
|
+
try {
|
|
346
|
+
parsed = await res.json();
|
|
347
|
+
} catch (e) {
|
|
348
|
+
return { ok: false, unavailable: true, reason: `POST /api/agent/gather-jobs returned invalid JSON: ${e?.message || e}` };
|
|
349
|
+
}
|
|
350
|
+
const jobId = parsed?.jobId;
|
|
351
|
+
if (!jobId) {
|
|
352
|
+
return { ok: false, unavailable: true, reason: "POST /api/agent/gather-jobs returned no jobId" };
|
|
353
|
+
}
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
jobId,
|
|
357
|
+
intentId: parsed.intentId,
|
|
358
|
+
// Console-reported relay presence for the job's vault ([[HS-L4LC]]); absent on older consoles.
|
|
359
|
+
...(parsed.companion && typeof parsed.companion === "object" ? { companion: parsed.companion } : {}),
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Poll until the Companion has uploaded the pages (status "complete") or the deadline passes.
|
|
365
|
+
* The deadline path is the EXPECTED failure when the relay prerequisite is unmet (loopback
|
|
366
|
+
* console / Companion not running) — hence the LAN hint on the resulting unavailable line.
|
|
367
|
+
*
|
|
368
|
+
* TRANSIENT TOLERANCE ([[HS-FBZ3-NWVU-DIAG-spexcrawl-companion-connectivity]]): once the job is
|
|
369
|
+
* FILED, the Companion executes it whether or not this process can reach the console at that
|
|
370
|
+
* instant. A single hiccup on the read leg — the dev console self-restarting on its memory
|
|
371
|
+
* threshold under concurrent /hs-res load, a 4s-timeout reachability gap, a 5xx — therefore says
|
|
372
|
+
* nothing about the job. Measured live 2026-09-12: two pollers both took `GET
|
|
373
|
+
* /api/agent/gather-jobs/<id> 401` across one Next dev restart and gave up, while the Companion
|
|
374
|
+
* uploaded both jobs' pages seconds later. So up to {@link MAX_TRANSIENT_POLL_FAILURES}
|
|
375
|
+
* CONSECUTIVE transient read failures are retried at the normal poll cadence instead of ending
|
|
376
|
+
* the run. Nothing about the failure contract changes: a 400 still exits 1 immediately, and a
|
|
377
|
+
* streak longer than the bound (or the deadline, whichever comes first) still returns
|
|
378
|
+
* `unavailable` carrying the last real reason.
|
|
379
|
+
*/
|
|
380
|
+
export async function pollJob(jobId, { baseUrl, token, httpFetch, timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn = (ms) => new Promise((r) => setTimeout(r, ms)), nowFn = Date.now } = {}) {
|
|
381
|
+
const started = nowFn();
|
|
382
|
+
/** Consecutive transient read failures; reset by any answered poll. */
|
|
383
|
+
let transientStreak = 0;
|
|
384
|
+
const deadlinePassed = () => nowFn() - started >= timeoutMs;
|
|
385
|
+
|
|
386
|
+
/** Retry a transient read at the poll cadence, or give up with its reason. */
|
|
387
|
+
const tolerate = async (reason) => {
|
|
388
|
+
transientStreak += 1;
|
|
389
|
+
if (transientStreak > MAX_TRANSIENT_POLL_FAILURES || deadlinePassed()) {
|
|
390
|
+
return {
|
|
391
|
+
ok: false,
|
|
392
|
+
unavailable: true,
|
|
393
|
+
reason: `${reason} (${transientStreak} consecutive poll failure(s))`,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
await sleepFn(pollIntervalMs);
|
|
397
|
+
return null; // keep polling
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
for (;;) {
|
|
401
|
+
let res;
|
|
402
|
+
try {
|
|
403
|
+
res = await httpFetch(`${baseUrl}/api/agent/gather-jobs/${encodeURIComponent(jobId)}`, {
|
|
404
|
+
headers: authHeaders(token),
|
|
405
|
+
});
|
|
406
|
+
} catch (e) {
|
|
407
|
+
const giveUp = await tolerate(`console unreachable at ${baseUrl} while polling job ${jobId}: ${e?.message || e}`);
|
|
408
|
+
if (giveUp) return giveUp;
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (res.status === 400) {
|
|
412
|
+
return { ok: false, badRequest: true, message: await readErrorMessage(res) };
|
|
413
|
+
}
|
|
414
|
+
if (!res.ok) {
|
|
415
|
+
const giveUp = await tolerate(`GET /api/agent/gather-jobs/${jobId} -> HTTP ${res.status}`);
|
|
416
|
+
if (giveUp) return giveUp;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
let parsed;
|
|
420
|
+
try {
|
|
421
|
+
parsed = await res.json();
|
|
422
|
+
} catch (e) {
|
|
423
|
+
const giveUp = await tolerate(`GET /api/agent/gather-jobs/${jobId} returned invalid JSON: ${e?.message || e}`);
|
|
424
|
+
if (giveUp) return giveUp;
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
transientStreak = 0;
|
|
428
|
+
if (parsed?.status === "complete") {
|
|
429
|
+
return {
|
|
430
|
+
ok: true,
|
|
431
|
+
jobId,
|
|
432
|
+
pages: Array.isArray(parsed.pages) ? parsed.pages : [],
|
|
433
|
+
// A research job's engine-level search account (which engines were
|
|
434
|
+
// tried, which answered, why the others did not) — present only when
|
|
435
|
+
// the Companion uploaded one. This is what makes a zero-page search
|
|
436
|
+
// explainable instead of silent (HS-RIZN).
|
|
437
|
+
...(parsed.search !== undefined && parsed.search !== null ? { search: parsed.search } : {}),
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
if (deadlinePassed()) {
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
unavailable: true,
|
|
444
|
+
reason: `job ${jobId} still "${parsed?.status || "pending"}" after ${Math.round(timeoutMs / 1000)}s — the Companion never uploaded results (job queued under vault ${jobVault().vaultId || "(none)"} via ${jobVault().source}; a Companion only claims jobs for the ONE vault its vaultRepoPath names — set HEYSPEX_SPEXCRAWL_VAULT_ID to that vault if they differ)`,
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
await sleepFn(pollIntervalMs);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ── subcommand implementations (testable independent of the CLI/exit layer) ────────────────────
|
|
452
|
+
/**
|
|
453
|
+
* `search`: ONE round trip covers the skill's search+scrape steps together — the Companion
|
|
454
|
+
* searches locally and gathers the top results in the same pass, so the completed job's pages
|
|
455
|
+
* are the CLEANED MARKDOWN of the top hits, not bare links.
|
|
456
|
+
*/
|
|
457
|
+
export async function runSearch(question, { httpFetch = (...a) => globalThis.fetch(...a), baseUrl = resolveConsoleUrl(), token = resolveConsoleToken(), timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn, nowFn } = {}) {
|
|
458
|
+
const posted = await postJob({ kind: "research", question }, { baseUrl, token, httpFetch });
|
|
459
|
+
if (!posted.ok) return posted;
|
|
460
|
+
announceJobVault(posted);
|
|
461
|
+
return pollJob(posted.jobId, { baseUrl, token, httpFetch, timeoutMs, pollIntervalMs, sleepFn, nowFn });
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** `scrape`/`gather`: fetch+clean the given URL(s) through the Companion. */
|
|
465
|
+
export async function runGather(urls, { httpFetch = (...a) => globalThis.fetch(...a), baseUrl = resolveConsoleUrl(), token = resolveConsoleToken(), timeoutMs = DEFAULT_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn, nowFn } = {}) {
|
|
466
|
+
const posted = await postJob({ kind: "gather", urls }, { baseUrl, token, httpFetch });
|
|
467
|
+
if (!posted.ok) return posted;
|
|
468
|
+
announceJobVault(posted);
|
|
469
|
+
return pollJob(posted.jobId, { baseUrl, token, httpFetch, timeoutMs, pollIntervalMs, sleepFn, nowFn });
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/** `crawl`: walk a whole site (sitemap + same-origin links) through the Companion.
|
|
473
|
+
* `maxPages`/`maxDepth` are sent only when they are positive numbers — omitted
|
|
474
|
+
* entirely otherwise (never `null`, never `0`), matching the console route's
|
|
475
|
+
* validation and `emitCrawlIntent`'s envelope contract. */
|
|
476
|
+
export async function runCrawl(site, { httpFetch = (...a) => globalThis.fetch(...a), baseUrl = resolveConsoleUrl(), token = resolveConsoleToken(), timeoutMs = DEFAULT_CRAWL_TIMEOUT_MS, pollIntervalMs = POLL_INTERVAL_MS, sleepFn, nowFn, maxPages, maxDepth } = {}) {
|
|
477
|
+
const body = { kind: "crawl", site };
|
|
478
|
+
if (typeof maxPages === "number" && maxPages > 0) body.max_pages = maxPages;
|
|
479
|
+
if (typeof maxDepth === "number" && maxDepth > 0) body.max_depth = maxDepth;
|
|
480
|
+
const posted = await postJob(body, { baseUrl, token, httpFetch });
|
|
481
|
+
if (!posted.ok) return posted;
|
|
482
|
+
announceJobVault(posted);
|
|
483
|
+
return pollJob(posted.jobId, { baseUrl, token, httpFetch, timeoutMs, pollIntervalMs, sleepFn, nowFn });
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── output formatting + the failure contract (exact lines, exact codes) ────────────────────────
|
|
487
|
+
/** @returns {{code:number, line:string}} what the CLI prints and the exit code it sets. */
|
|
488
|
+
export function renderFailure(result) {
|
|
489
|
+
if (result.badRequest) {
|
|
490
|
+
return { code: 1, line: String(result.message || "bad request") };
|
|
491
|
+
}
|
|
492
|
+
const hint = result.authRefused
|
|
493
|
+
? AUTH_HINT
|
|
494
|
+
: result.companionNotListening
|
|
495
|
+
? companionVaultHint(result.vaultId)
|
|
496
|
+
: LAN_HINT;
|
|
497
|
+
return { code: 3, line: `SPEXCRAWL_UNAVAILABLE: ${result.reason} — ${hint}` };
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Render a research job's search report: the one-line summary, then one row per
|
|
502
|
+
* engine — answered engines with their result count, failed engines with the
|
|
503
|
+
* engine's own reason. Empty string when the job carried no report (gather /
|
|
504
|
+
* crawl jobs, or a Companion built before the report existed).
|
|
505
|
+
*/
|
|
506
|
+
export function formatSearchReportText(search) {
|
|
507
|
+
if (!search || typeof search !== "object") return "";
|
|
508
|
+
const lines = [`── search: ${search.summary || "(no summary)"}`];
|
|
509
|
+
for (const e of Array.isArray(search.engines) ? search.engines : []) {
|
|
510
|
+
if (e.answered) {
|
|
511
|
+
lines.push(` ${e.engine}: answered, ${e.results} result(s)`);
|
|
512
|
+
} else {
|
|
513
|
+
const status = e.httpStatus ? ` [HTTP ${e.httpStatus}]` : "";
|
|
514
|
+
lines.push(` ${e.engine}: no answer — ${e.reason || "(no reason given)"}${status}`);
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
return lines.join("\n");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* The machine-readable failure tag for a page that was not cleaned, e.g.
|
|
522
|
+
* " [rate_limited, retry after 44s]". The Companion stamps `failureCode`
|
|
523
|
+
* (today only `rate_limited`: a request budget, ours or GitHub's, said no) and
|
|
524
|
+
* `retryAfterSeconds` when the wait is knowable (HS-L4LC). Empty for a page
|
|
525
|
+
* without a code, so older results print exactly as before. In --json both
|
|
526
|
+
* fields ride on each page verbatim.
|
|
527
|
+
*/
|
|
528
|
+
export function formatFailureTag(p) {
|
|
529
|
+
if (!p || typeof p.failureCode !== "string" || !p.failureCode) return "";
|
|
530
|
+
const wait =
|
|
531
|
+
Number.isFinite(p.retryAfterSeconds) && p.retryAfterSeconds > 0 ? `, retry after ${p.retryAfterSeconds}s` : "";
|
|
532
|
+
return ` [${p.failureCode}${wait}]`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export function formatPagesText(pages) {
|
|
536
|
+
if (!pages.length) return "(no pages)";
|
|
537
|
+
return pages
|
|
538
|
+
.map((p, i) => {
|
|
539
|
+
const state = p.state ? ` [${p.state}]` : "";
|
|
540
|
+
const lines = [`── ${i + 1}. ${p.url || "(no url)"}${state}`];
|
|
541
|
+
if (p.finalUrl && p.finalUrl !== p.url) lines.push(` finalUrl: ${p.finalUrl}`);
|
|
542
|
+
// The crawl identity this page was actually fetched under (preset id +
|
|
543
|
+
// robots.txt group token) — absent on pages fetched before the
|
|
544
|
+
// Companion stamped provenance, or when no identity was ever chosen.
|
|
545
|
+
if (p.identity && p.identity.presetId) {
|
|
546
|
+
lines.push(` identity: ${p.identity.presetId} (robots: ${p.identity.robotsToken})`);
|
|
547
|
+
}
|
|
548
|
+
if (p.markdown) lines.push("", p.markdown);
|
|
549
|
+
else if (p.reason) lines.push(` (no content: ${p.reason})${formatFailureTag(p)}`);
|
|
550
|
+
return lines.join("\n");
|
|
551
|
+
})
|
|
552
|
+
.join("\n\n");
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** `crawl`'s text rows: {title, url, markdown} per walked page — the shape a
|
|
556
|
+
* whole-site walk is expected to print (vs. `formatPagesText`'s state/finalUrl
|
|
557
|
+
* detail, which suits a known-address gather/scrape). */
|
|
558
|
+
export function formatCrawlPagesText(pages) {
|
|
559
|
+
if (!pages.length) return "(no pages)";
|
|
560
|
+
return pages
|
|
561
|
+
.map((p, i) => {
|
|
562
|
+
const lines = [`── ${i + 1}. ${p.title || "(no title)"}`, ` ${p.url || "(no url)"}`];
|
|
563
|
+
if (p.markdown) lines.push("", p.markdown);
|
|
564
|
+
else if (p.reason) lines.push(` (no content: ${p.reason})${formatFailureTag(p)}`);
|
|
565
|
+
return lines.join("\n");
|
|
566
|
+
})
|
|
567
|
+
.join("\n\n");
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// ── CLI entry ──────────────────────────────────────────────────────────────────────────────────
|
|
571
|
+
const HELP = `usage: spexcrawl-cli.mjs search "<query>" [--json] [--timeout SECONDS]
|
|
572
|
+
spexcrawl-cli.mjs scrape "<url>" [<url>...] [--json] [--timeout SECONDS]
|
|
573
|
+
(gather is an alias of scrape)
|
|
574
|
+
spexcrawl-cli.mjs crawl "<site>" [--max-pages N] [--max-depth N] [--json] [--timeout SECONDS]
|
|
575
|
+
|
|
576
|
+
Runs SpexCrawl jobs through the HeySpex Companion via the spex-console: the job
|
|
577
|
+
is queued as a launch intent, the user's Companion app searches/fetches/cleans
|
|
578
|
+
on the local machine, and this CLI polls until the pages are uploaded.
|
|
579
|
+
|
|
580
|
+
search NOTE: results are the CLEANED PAGES (markdown) of the top hits — the
|
|
581
|
+
Companion searches and gathers in one pass, so one search call covers a research
|
|
582
|
+
skill's search+scrape steps together.
|
|
583
|
+
|
|
584
|
+
crawl NOTE: walks a whole site (sitemap + same-origin links) instead of fetching
|
|
585
|
+
known addresses — prints {title, url, markdown} rows. Its default --timeout is
|
|
586
|
+
longer (${DEFAULT_CRAWL_TIMEOUT_MS / 1000}) than search/scrape/gather's ${DEFAULT_TIMEOUT_MS / 1000}, since a walk is
|
|
587
|
+
bigger than a scrape. --max-pages/--max-depth are omitted from the request
|
|
588
|
+
entirely when not given (never sent as 0).
|
|
589
|
+
|
|
590
|
+
--timeout SECONDS give up polling after this long (default ${DEFAULT_TIMEOUT_MS / 1000}; crawl default ${DEFAULT_CRAWL_TIMEOUT_MS / 1000})
|
|
591
|
+
--json print { "jobId", "pages": [{ url, state, markdown, ... }],
|
|
592
|
+
"search"? } — search jobs also carry the engine-level report
|
|
593
|
+
{ query, enginesAttempted, enginesAnswered, resultsFound,
|
|
594
|
+
resultsGathered, summary, engines: [...] }, so a zero-page
|
|
595
|
+
search says which engines were tried, which answered, and
|
|
596
|
+
why the others did not. A page that was not cleaned may
|
|
597
|
+
carry failureCode ("rate_limited") + retryAfterSeconds, so
|
|
598
|
+
a caller can back off without parsing "reason"
|
|
599
|
+
--max-pages N crawl only: cap the number of pages walked (positive integer)
|
|
600
|
+
--max-depth N crawl only: cap the link depth walked (positive integer)
|
|
601
|
+
|
|
602
|
+
Environment:
|
|
603
|
+
HEYSPEX_CONSOLE_URL console base URL (default ${DEFAULT_CONSOLE_URL})
|
|
604
|
+
HEYSPEX_COMPANION_TOKEN bearer token override; default reads
|
|
605
|
+
clerk.companion_token from ~/.heyspex/credentials.yaml
|
|
606
|
+
(no auth header is sent when neither resolves)
|
|
607
|
+
|
|
608
|
+
Prerequisite: ${LAN_HINT}.
|
|
609
|
+
|
|
610
|
+
Exit codes: 0 ok · 1 bad request (server message printed) · 3 SPEXCRAWL_UNAVAILABLE
|
|
611
|
+
(console unreachable / non-2xx / poll timeout) · 64 usage error`;
|
|
612
|
+
|
|
613
|
+
function printUsage() {
|
|
614
|
+
console.error('usage: spexcrawl-cli.mjs search "<query>" [--json] [--timeout SECONDS]');
|
|
615
|
+
console.error(' spexcrawl-cli.mjs scrape "<url>" [<url>...] [--json] [--timeout SECONDS] (alias: gather)');
|
|
616
|
+
console.error(' spexcrawl-cli.mjs crawl "<site>" [--max-pages N] [--max-depth N] [--json] [--timeout SECONDS]');
|
|
617
|
+
console.error(" spexcrawl-cli.mjs --help");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Set the exit code and RETURN — never call process.exit() after doing network work.
|
|
622
|
+
* (Same rule as firecrawl-cli.mjs: forcing an exit with live HTTP handles aborts the process on
|
|
623
|
+
* Windows with a libuv assertion AFTER stdout flushed — perfect JSON alongside a non-zero exit,
|
|
624
|
+
* which poisons any caller that checks exit status. Letting the loop drain exits cleanly.)
|
|
625
|
+
*/
|
|
626
|
+
function finish(code) {
|
|
627
|
+
process.exitCode = code;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function exitOnResult(result, { json, formatText = formatPagesText }) {
|
|
631
|
+
if (!result.ok) {
|
|
632
|
+
const { code, line } = renderFailure(result);
|
|
633
|
+
console.log(line);
|
|
634
|
+
return finish(code);
|
|
635
|
+
}
|
|
636
|
+
if (json) {
|
|
637
|
+
console.log(JSON.stringify({
|
|
638
|
+
jobId: result.jobId,
|
|
639
|
+
pages: result.pages,
|
|
640
|
+
...(result.search !== undefined ? { search: result.search } : {}),
|
|
641
|
+
}));
|
|
642
|
+
return finish(0);
|
|
643
|
+
}
|
|
644
|
+
// The search report leads: it is short, and on a zero-page result it IS the
|
|
645
|
+
// answer — which engines were tried, which answered, why the others did not.
|
|
646
|
+
const searchText = result.search !== undefined ? formatSearchReportText(result.search) : "";
|
|
647
|
+
console.log([searchText, formatText(result.pages)].filter(Boolean).join("\n\n"));
|
|
648
|
+
return finish(0);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Flags that take a value — used to strip both the flag and its value out of
|
|
652
|
+
* the positional-args list, and (via indexOf) to parse the value itself. */
|
|
653
|
+
const VALUE_FLAGS = ["--timeout", "--max-pages", "--max-depth"];
|
|
654
|
+
|
|
655
|
+
async function main() {
|
|
656
|
+
const argv = process.argv.slice(2);
|
|
657
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
658
|
+
console.log(HELP);
|
|
659
|
+
process.exit(0);
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const cmd = argv[0];
|
|
664
|
+
const json = argv.includes("--json");
|
|
665
|
+
|
|
666
|
+
if (cmd !== "search" && cmd !== "scrape" && cmd !== "gather" && cmd !== "crawl") {
|
|
667
|
+
printUsage();
|
|
668
|
+
process.exit(64);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const timeoutIdx = argv.indexOf("--timeout");
|
|
673
|
+
const timeoutSec = timeoutIdx !== -1 ? Number(argv[timeoutIdx + 1]) : NaN;
|
|
674
|
+
const defaultTimeoutMs = cmd === "crawl" ? DEFAULT_CRAWL_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
|
675
|
+
const timeoutMs = Number.isFinite(timeoutSec) && timeoutSec > 0 ? timeoutSec * 1000 : defaultTimeoutMs;
|
|
676
|
+
|
|
677
|
+
// positional args after the verb, minus --json and every value-flag + its value
|
|
678
|
+
const targets = argv
|
|
679
|
+
.slice(1)
|
|
680
|
+
.filter((a, i, arr) => a !== "--json" && !VALUE_FLAGS.includes(a) && !VALUE_FLAGS.includes(arr[i - 1]));
|
|
681
|
+
|
|
682
|
+
if (!targets.length) {
|
|
683
|
+
console.error(`usage error: missing ${cmd === "search" ? "<query>" : cmd === "crawl" ? "<site>" : "<url>"} argument`);
|
|
684
|
+
printUsage();
|
|
685
|
+
process.exit(64);
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
let maxPages;
|
|
690
|
+
let maxDepth;
|
|
691
|
+
if (cmd === "crawl") {
|
|
692
|
+
const maxPagesIdx = argv.indexOf("--max-pages");
|
|
693
|
+
if (maxPagesIdx !== -1) {
|
|
694
|
+
maxPages = Number(argv[maxPagesIdx + 1]);
|
|
695
|
+
if (!Number.isInteger(maxPages) || maxPages <= 0) {
|
|
696
|
+
console.error(`usage error: --max-pages must be a positive integer, got ${JSON.stringify(argv[maxPagesIdx + 1])}`);
|
|
697
|
+
printUsage();
|
|
698
|
+
process.exit(64);
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
const maxDepthIdx = argv.indexOf("--max-depth");
|
|
703
|
+
if (maxDepthIdx !== -1) {
|
|
704
|
+
maxDepth = Number(argv[maxDepthIdx + 1]);
|
|
705
|
+
if (!Number.isInteger(maxDepth) || maxDepth <= 0) {
|
|
706
|
+
console.error(`usage error: --max-depth must be a positive integer, got ${JSON.stringify(argv[maxDepthIdx + 1])}`);
|
|
707
|
+
printUsage();
|
|
708
|
+
process.exit(64);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
const result =
|
|
716
|
+
cmd === "search"
|
|
717
|
+
? await runSearch(targets.join(" "), { timeoutMs })
|
|
718
|
+
: cmd === "crawl"
|
|
719
|
+
? await runCrawl(targets[0], { timeoutMs, maxPages, maxDepth })
|
|
720
|
+
: await runGather(targets, { timeoutMs });
|
|
721
|
+
exitOnResult(result, { json, formatText: cmd === "crawl" ? formatCrawlPagesText : formatPagesText });
|
|
722
|
+
} catch (e) {
|
|
723
|
+
// Last-resort catch — every internal path above already returns a clean shape, but this
|
|
724
|
+
// guarantees the CLI NEVER surfaces a raw stack trace, per the shared failure contract.
|
|
725
|
+
console.log(`SPEXCRAWL_UNAVAILABLE: unexpected error — ${e?.message || String(e)} — ${LAN_HINT}`);
|
|
726
|
+
return finish(3);
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const isMain = import.meta.url.endsWith("/" + (process.argv[1]?.split(/[\\/]/).pop() ?? ""));
|
|
731
|
+
|
|
732
|
+
if (isMain) {
|
|
733
|
+
main();
|
|
734
|
+
}
|