polite-crawler 0.1.2 → 0.1.3

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.
Files changed (40) hide show
  1. package/README.md +533 -8
  2. package/build/cli-daemon--cTmq0Z5.mjs +38 -0
  3. package/build/cli-daemon--cTmq0Z5.mjs.map +1 -0
  4. package/build/cli.mjs +288 -20
  5. package/build/cli.mjs.map +1 -1
  6. package/build/{open-Ca1lcWR3.d.mts → daemon-DP4EqjFL.d.mts} +913 -16
  7. package/build/daemon-fZTE24P5.mjs +515 -0
  8. package/build/daemon-fZTE24P5.mjs.map +1 -0
  9. package/build/{diff-Dk2eSvi-.mjs → diff-NQcQqKe9.mjs} +2 -2
  10. package/build/{diff-Dk2eSvi-.mjs.map → diff-NQcQqKe9.mjs.map} +1 -1
  11. package/build/{extract-DcwNrTK7.mjs → extract-ByFfc8Zk.mjs} +2 -2
  12. package/build/{extract-DcwNrTK7.mjs.map → extract-ByFfc8Zk.mjs.map} +1 -1
  13. package/build/hours.d.mts +26 -0
  14. package/build/hours.mjs +48 -1
  15. package/build/hours.mjs.map +1 -1
  16. package/build/index.d.mts +491 -11
  17. package/build/index.mjs +898 -9
  18. package/build/index.mjs.map +1 -0
  19. package/build/loopback-C_QYowSf.mjs +22 -0
  20. package/build/loopback-C_QYowSf.mjs.map +1 -0
  21. package/build/mcp.d.mts +158 -2
  22. package/build/mcp.mjs +567 -18
  23. package/build/mcp.mjs.map +1 -1
  24. package/build/ollama-BJxkjny4.mjs +160 -0
  25. package/build/ollama-BJxkjny4.mjs.map +1 -0
  26. package/build/{open-DnrWgkC0.mjs → open-C4fhXJgD.mjs} +1404 -21
  27. package/build/open-C4fhXJgD.mjs.map +1 -0
  28. package/build/{runtime-k8mo1_7U.mjs → runtime-JToRO63o.mjs} +3 -3
  29. package/build/{runtime-k8mo1_7U.mjs.map → runtime-JToRO63o.mjs.map} +1 -1
  30. package/build/transformers-BQMlaGj4.mjs +133 -0
  31. package/build/transformers-BQMlaGj4.mjs.map +1 -0
  32. package/build/{duration-iIuMMofi.mjs → version-ntbY-6wh.mjs} +10 -10
  33. package/build/version-ntbY-6wh.mjs.map +1 -0
  34. package/build/{wacz-sfOLT8e9.mjs → wacz-BFNG42Kt.mjs} +3 -3
  35. package/build/{wacz-sfOLT8e9.mjs.map → wacz-BFNG42Kt.mjs.map} +1 -1
  36. package/build/{warc-VIPscWxY.mjs → warc-CVS0t1fW.mjs} +2 -2
  37. package/build/{warc-VIPscWxY.mjs.map → warc-CVS0t1fW.mjs.map} +1 -1
  38. package/package.json +10 -2
  39. package/build/duration-iIuMMofi.mjs.map +0 -1
  40. package/build/open-DnrWgkC0.mjs.map +0 -1
@@ -0,0 +1,38 @@
1
+ import { t as describeDaemonStatus } from "./cli.mjs";
2
+ //#region src/cli-daemon.ts
3
+ /**
4
+ * This function does the command `daemon`, and gives the exit code. `daemon status` gives 0 when a
5
+ * daemon operates on the store, and 1 when none operates. `daemon` gives 0 after a clean stop. An
6
+ * error at start rejects, so the command exits with 1.
7
+ */
8
+ const daemonCommand = async (line, options, io) => {
9
+ const { changeCommand, readDaemonState, startDaemon } = await import("./daemon-fZTE24P5.mjs").then((n) => n.a);
10
+ if (line.action === "status") {
11
+ const state = readDaemonState(options.dir);
12
+ io.out(io.json ? JSON.stringify(state ?? null, null, 2) : describeDaemonStatus(state, options.dir));
13
+ return state?.alive === true ? 0 : 1;
14
+ }
15
+ await (await startDaemon({
16
+ ...options,
17
+ tick: line.tickMs,
18
+ jitter: line.jitterMs,
19
+ budget: line.budgetMs,
20
+ maxPages: line.maxPages,
21
+ grace: line.graceMs,
22
+ events: line.events,
23
+ onChange: line.onChange === void 0 ? void 0 : changeCommand(line.onChange),
24
+ mcp: line.mcp === void 0 ? void 0 : {
25
+ port: line.mcp.port,
26
+ host: line.mcp.host,
27
+ allowRemote: line.mcp.allowRemote,
28
+ tokenFile: line.mcp.tokenFile,
29
+ allowUserProvided: io.allowUserProvided
30
+ },
31
+ signals: true
32
+ })).done;
33
+ return 0;
34
+ };
35
+ //#endregion
36
+ export { daemonCommand };
37
+
38
+ //# sourceMappingURL=cli-daemon--cTmq0Z5.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"cli-daemon--cTmq0Z5.mjs","names":[],"sources":["../src/cli-daemon.ts"],"sourcesContent":["/*\n * The command `daemon` of the command line: `daemon` runs the daemon until `SIGINT` or `SIGTERM`, and\n * `daemon status` prints the state file. `cli.ts` loads this file only for the command `daemon`.\n */\nimport type { DaemonLine } from \"./cli-line.ts\";\nimport { describeDaemonStatus } from \"./cli-line.ts\";\nimport type { OpenOptions } from \"./open.ts\";\n\n/** These are the parts of the command line that the command `daemon` uses. */\nexport interface DaemonIo {\n /** The value true lets `user_provided` work when the MCP client cannot ask the user. */\n readonly allowUserProvided: boolean;\n /** The value true prints the status as JSON. */\n readonly json: boolean;\n /** This function prints a line on the standard output. */\n readonly out: (text: string) => void;\n}\n\n/**\n * This function does the command `daemon`, and gives the exit code. `daemon status` gives 0 when a\n * daemon operates on the store, and 1 when none operates. `daemon` gives 0 after a clean stop. An\n * error at start rejects, so the command exits with 1.\n */\nexport const daemonCommand = async (\n line: DaemonLine,\n options: OpenOptions,\n io: DaemonIo,\n): Promise<number> => {\n const { changeCommand, readDaemonState, startDaemon } = await import(\"./daemon.ts\");\n if (line.action === \"status\") {\n const state = readDaemonState(options.dir);\n io.out(\n io.json ? JSON.stringify(state ?? null, null, 2) : describeDaemonStatus(state, options.dir),\n );\n return state?.alive === true ? 0 : 1;\n }\n const daemon = await startDaemon({\n ...options,\n tick: line.tickMs,\n jitter: line.jitterMs,\n budget: line.budgetMs,\n maxPages: line.maxPages,\n grace: line.graceMs,\n events: line.events,\n onChange: line.onChange === undefined ? undefined : changeCommand(line.onChange),\n mcp:\n line.mcp === undefined\n ? undefined\n : {\n port: line.mcp.port,\n host: line.mcp.host,\n allowRemote: line.mcp.allowRemote,\n tokenFile: line.mcp.tokenFile,\n allowUserProvided: io.allowUserProvided,\n },\n signals: true,\n });\n await daemon.done;\n return 0;\n};\n"],"mappings":";;;;;;;AAuBA,MAAa,gBAAgB,OAC3B,MACA,SACA,OACoB;CACpB,MAAM,EAAE,eAAe,iBAAiB,gBAAgB,MAAM,OAAO,wBAAc,CAAA,MAAA,MAAA,EAAA,CAAA;CACnF,IAAI,KAAK,WAAW,UAAU;EAC5B,MAAM,QAAQ,gBAAgB,QAAQ,GAAG;EACzC,GAAG,IACD,GAAG,OAAO,KAAK,UAAU,SAAS,MAAM,MAAM,CAAC,IAAI,qBAAqB,OAAO,QAAQ,GAAG,CAC5F;EACA,OAAO,OAAO,UAAU,OAAO,IAAI;CACrC;CAsBA,OAAM,MArBe,YAAY;EAC/B,GAAG;EACH,MAAM,KAAK;EACX,QAAQ,KAAK;EACb,QAAQ,KAAK;EACb,UAAU,KAAK;EACf,OAAO,KAAK;EACZ,QAAQ,KAAK;EACb,UAAU,KAAK,aAAa,KAAA,IAAY,KAAA,IAAY,cAAc,KAAK,QAAQ;EAC/E,KACE,KAAK,QAAQ,KAAA,IACT,KAAA,IACA;GACE,MAAM,KAAK,IAAI;GACf,MAAM,KAAK,IAAI;GACf,aAAa,KAAK,IAAI;GACtB,WAAW,KAAK,IAAI;GACpB,mBAAmB,GAAG;EACxB;EACN,SAAS;CACX,CAAC,EAAA,CACY;CACb,OAAO;AACT"}
package/build/cli.mjs CHANGED
@@ -1,5 +1,6 @@
1
1
  #!/usr/bin/env node
2
- import { i as VERSION, t as durationMs } from "./duration-iIuMMofi.mjs";
2
+ import { i as durationMs, r as VERSION } from "./version-ntbY-6wh.mjs";
3
+ import { t as isLoopbackHost } from "./loopback-C_QYowSf.mjs";
3
4
  import { join, resolve } from "node:path";
4
5
  import { readFileSync } from "node:fs";
5
6
  import { parseArgs } from "node:util";
@@ -80,30 +81,62 @@ const OPTIONS = {
80
81
  "archive-despite-robots": { type: "boolean" },
81
82
  "sign-key": { type: "string" },
82
83
  "signature-agent": { type: "string" },
84
+ tick: { type: "string" },
85
+ jitter: { type: "string" },
86
+ "tick-budget": { type: "string" },
87
+ "tick-pages": { type: "string" },
88
+ grace: { type: "string" },
89
+ events: { type: "string" },
90
+ "on-change": { type: "string" },
91
+ "mcp-http": { type: "boolean" },
92
+ "mcp-port": { type: "string" },
93
+ "mcp-host": { type: "string" },
94
+ "mcp-token-file": { type: "string" },
95
+ "allow-remote": { type: "boolean" },
83
96
  field: {
84
97
  type: "string",
85
98
  multiple: true
86
99
  },
87
100
  capture: { type: "string" },
101
+ embedder: { type: "string" },
102
+ "embed-model": { type: "string" },
103
+ "ollama-url": { type: "string" },
104
+ "allow-remote-ollama": { type: "boolean" },
105
+ "max-docs": { type: "string" },
88
106
  ...EXPORT_OPTIONS
89
107
  };
108
+ /** These options belong to `daemon`, and not to `daemon status`. */
109
+ const DAEMON_RUN_OPTIONS = [
110
+ "tick",
111
+ "jitter",
112
+ "tick-budget",
113
+ "tick-pages",
114
+ "grace",
115
+ "events",
116
+ "on-change",
117
+ "mcp-http",
118
+ "mcp-port",
119
+ "mcp-host",
120
+ "mcp-token-file",
121
+ "allow-remote"
122
+ ];
90
123
  /** These options belong to one command, or to the commands that the text names. */
91
124
  const OWNER = {
92
125
  render: "fetch crawl watch",
93
126
  "max-age": "fetch crawl discover",
94
127
  limit: "find",
95
- kind: "find",
128
+ kind: "find embed",
96
129
  rank: "find",
97
130
  from: "read diff",
98
131
  length: "read",
99
132
  "user-provided": "read extract",
100
- "allow-user-provided": "mcp",
133
+ "allow-user-provided": "mcp daemon",
101
134
  ...Object.fromEntries(Object.keys(EXPORT_OPTIONS).map((option) => [option, "export"])),
102
135
  "max-urls": "discover",
103
136
  feed: "discover",
104
137
  "max-pages": "crawl watch",
105
138
  "max-depth": "crawl",
106
- budget: "crawl watch",
139
+ budget: "crawl watch embed",
107
140
  include: "crawl",
108
141
  exclude: "crawl",
109
142
  sitemaps: "crawl",
@@ -116,10 +149,16 @@ const OWNER = {
116
149
  label: "watch",
117
150
  "archive-fallback": "fetch",
118
151
  "archive-despite-robots": "fetch",
119
- "sign-key": "fetch discover crawl watch mcp key-directory",
120
- "signature-agent": "fetch discover crawl watch mcp key-directory",
152
+ "sign-key": "fetch discover crawl watch mcp key-directory daemon",
153
+ "signature-agent": "fetch discover crawl watch mcp key-directory daemon",
154
+ ...Object.fromEntries(DAEMON_RUN_OPTIONS.map((option) => [option, "daemon"])),
121
155
  field: "extract",
122
- capture: "extract"
156
+ capture: "extract",
157
+ embedder: "find embed mcp",
158
+ "embed-model": "find embed mcp",
159
+ "ollama-url": "find embed mcp",
160
+ "allow-remote-ollama": "find embed mcp",
161
+ "max-docs": "embed"
123
162
  };
124
163
  /** These are the commands, with the smallest and the largest count of arguments. */
125
164
  const COMMANDS = {
@@ -178,6 +217,11 @@ const COMMANDS = {
178
217
  max: Infinity,
179
218
  wants: "watch wants add, remove, list or run"
180
219
  },
220
+ daemon: {
221
+ min: 0,
222
+ max: 1,
223
+ wants: "daemon takes no argument, or status"
224
+ },
181
225
  "key-directory": {
182
226
  min: 0,
183
227
  max: 0,
@@ -187,6 +231,11 @@ const COMMANDS = {
187
231
  min: 1,
188
232
  max: 1,
189
233
  wants: "extract wants one document id and --field name=value"
234
+ },
235
+ embed: {
236
+ min: 0,
237
+ max: 0,
238
+ wants: "embed takes no argument: give the model with --embedder"
190
239
  }
191
240
  };
192
241
  const parse = (args) => {
@@ -424,6 +473,102 @@ const describeEvent = (event) => {
424
473
  ].join("\n");
425
474
  };
426
475
  const describeWatchSummary = (summary) => `${String(summary.checked)} checked: ${String(summary.new)} new, ${String(summary.changed)} changed, ${String(summary.unchanged)} unchanged, ${String(summary.gone)} gone, ${String(summary.refused)} refused, ${String(summary.error)} errors. ${String(summary.suspected)} suspected, ${String(summary.confirmed)} confirmed, ${String(summary.due)} still due. Stopped (${summary.stopped}): ${summary.reason}.`;
476
+ /** This function reads a file name of the command line: a leading `~` is the home folder. */
477
+ const fileOf = (text) => text === "~" ? homedir() : /^~[\\/]/.test(text) ? resolve(homedir(), text.slice(2)) : resolve(text);
478
+ /** This function reads a duration of `daemon`, with a floor in milliseconds. */
479
+ const daemonDuration = (name, text, floor) => {
480
+ if (text === void 0) return void 0;
481
+ let ms;
482
+ try {
483
+ ms = durationMs(text);
484
+ } catch (error) {
485
+ throw new UsageError(`--${name}: ${error.message}`, { cause: error });
486
+ }
487
+ if (ms < floor) throw new UsageError(floor === 0 ? `--${name} wants 0 or more` : `--${name} wants ${String(floor / 1e3)}s or more`);
488
+ return ms;
489
+ };
490
+ /** These options of `daemon` belong to its MCP server, so they need `--mcp-http`. */
491
+ const MCP_OPTIONS = [
492
+ "mcp-port",
493
+ "mcp-host",
494
+ "mcp-token-file",
495
+ "allow-remote"
496
+ ];
497
+ /** This function checks the options of `daemon` and of `daemon status`. */
498
+ const daemonLineOf = (values, rest) => {
499
+ const [action] = rest;
500
+ if (action !== void 0 && action !== "status") throw new UsageError(`daemon takes no argument, or status, not "${action}"`);
501
+ if (action === "status") {
502
+ const run = [
503
+ ...DAEMON_RUN_OPTIONS,
504
+ "allow-user-provided",
505
+ "sign-key",
506
+ "signature-agent"
507
+ ];
508
+ for (const option of run) if (values[option] !== void 0) throw new UsageError(`--${option} is an option of daemon, not of daemon status`);
509
+ }
510
+ if (values["mcp-http"] !== true) {
511
+ for (const option of MCP_OPTIONS) if (values[option] !== void 0) throw new UsageError(`--${option} goes with --mcp-http`);
512
+ }
513
+ const host = (values["mcp-host"] ?? "127.0.0.1").trim();
514
+ if (host === "") throw new UsageError("--mcp-host wants a host");
515
+ const allowRemote = values["allow-remote"] === true;
516
+ if (values["mcp-http"] === true && !isLoopbackHost(host) && !allowRemote) throw new UsageError(`--mcp-host ${host} is not a loopback address: add --allow-remote to serve other computers, after you read the warning in the README`);
517
+ const port = whole("mcp-port", values["mcp-port"], 9307, 0);
518
+ if (port > 65535) throw new UsageError("--mcp-port wants a port from 0 to 65535");
519
+ const onChange = values["on-change"];
520
+ if (onChange !== void 0 && onChange.trim() === "") throw new UsageError("--on-change wants a command");
521
+ const events = values.events;
522
+ if (events !== void 0 && events.trim() === "") throw new UsageError("--events wants a file");
523
+ const tokenFile = values["mcp-token-file"];
524
+ if (tokenFile !== void 0 && tokenFile.trim() === "") throw new UsageError("--mcp-token-file wants a file");
525
+ return {
526
+ action: action === "status" ? "status" : "run",
527
+ tickMs: daemonDuration("tick", values.tick, 1e3),
528
+ jitterMs: daemonDuration("jitter", values.jitter, 0),
529
+ budgetMs: daemonDuration("tick-budget", values["tick-budget"], 1e3),
530
+ maxPages: values["tick-pages"] === void 0 ? void 0 : whole("tick-pages", values["tick-pages"], 1, 1),
531
+ graceMs: daemonDuration("grace", values.grace, 0),
532
+ events: events === void 0 ? void 0 : fileOf(events),
533
+ onChange,
534
+ mcp: values["mcp-http"] === true ? {
535
+ port,
536
+ host,
537
+ allowRemote,
538
+ tokenFile: tokenFile === void 0 ? void 0 : fileOf(tokenFile)
539
+ } : void 0
540
+ };
541
+ };
542
+ /** This function gives the first line of `daemon status`: the daemon runs, stopped, or stopped with no clean stop. */
543
+ const daemonHead = (state) => state.alive ? `the daemon runs (process ${String(state.pid)}) since ${state.started_at}, on the store ${state.store}` : state.running ? `the daemon of process ${String(state.pid)} does not run: it stopped with no clean stop. Its state is of ${state.updated_at}` : `the daemon stopped at ${state.stopped_at ?? state.updated_at}`;
544
+ /** This function gives the line of the last tick of `daemon status`. */
545
+ const lastTick = (state) => {
546
+ const tick = state.last_tick;
547
+ if (tick === null) return "last tick: none yet";
548
+ if (tick.summary === null) return `last tick: ${tick.ended_at}, failed: ${tick.error ?? "error"}`;
549
+ const summary = tick.summary;
550
+ return `last tick: ${tick.ended_at}, ${String(summary.checked)} checked, ${String(summary.confirmed)} changes, ${String(summary.refused)} refused, ${String(summary.error)} errors (${summary.stopped})`;
551
+ };
552
+ /** This function gives the text of `daemon status`. */
553
+ const describeDaemonStatus = (state, dir) => {
554
+ if (state === void 0) return `no daemon has run on the store ${dir}`;
555
+ const lines = [daemonHead(state), lastTick(state)];
556
+ if (state.alive && state.next_tick_at !== null) lines.push(`next tick: ${state.next_tick_at}`);
557
+ lines.push(`${String(state.ticks)} ticks, ${String(state.checked)} checks, ${String(state.changes)} changes`);
558
+ if (state.next_due.length === 0) lines.push("next due: no watch");
559
+ else {
560
+ lines.push("next due:");
561
+ for (const due of state.next_due) lines.push(` ${due.next_at} ${due.url} (every ${everyText(due.every_ms)}, last ${due.last_kind ?? "none"})`);
562
+ }
563
+ if (state.mcp !== null) {
564
+ const where = state.mcp.token_file === null ? "" : ` (the token is in ${state.mcp.token_file})`;
565
+ lines.push(`MCP: ${state.mcp.url}${where}`);
566
+ lines.push(state.tasks.length === 0 ? "tasks: none running" : `tasks: ${String(state.tasks.length)} running`);
567
+ for (const task of state.tasks) lines.push(` ${task.id} ${task.tool} ${task.status}${task.message === null ? "" : `: ${task.message}`}`);
568
+ }
569
+ lines.push(state.last_error === null ? "last error: none" : `last error: ${state.last_error.at}: ${state.last_error.message}`);
570
+ return lines.join("\n");
571
+ };
427
572
  /** These are the kinds of a field (`FIELD_KINDS` of `extract.ts`). The command line knows them before it loads the crawler. */
428
573
  const KINDS = [
429
574
  "text",
@@ -463,6 +608,33 @@ const fieldsOf = (given) => {
463
608
  }
464
609
  return fields;
465
610
  };
611
+ /**
612
+ * This function reads the embedder of the command line, or of the environment for the commands
613
+ * that use one. `--embed-model` and `--ollama-url` need `--embedder`.
614
+ */
615
+ const embedderOf = (values, env, command) => {
616
+ const uses = OWNER["embedder"].split(" ").includes(command);
617
+ const fromEnv = (name) => uses ? env[name] || void 0 : void 0;
618
+ const name = values.embedder ?? fromEnv("POLITE_CRAWLER_EMBEDDER");
619
+ const model = values["embed-model"] ?? fromEnv("POLITE_CRAWLER_EMBED_MODEL");
620
+ const url = values["ollama-url"] ?? fromEnv("POLITE_CRAWLER_OLLAMA_URL");
621
+ const allowRemote = values["allow-remote-ollama"] === true || uses && envFlag(env, "POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA");
622
+ if (name === void 0) {
623
+ if (values["embed-model"] !== void 0 || values["ollama-url"] !== void 0) throw new UsageError("--embed-model and --ollama-url go with --embedder transformers or ollama");
624
+ return;
625
+ }
626
+ if (name !== "transformers" && name !== "ollama") throw new UsageError(`--embedder is transformers or ollama, not "${name}"`);
627
+ if (model !== void 0 && model.trim() === "") throw new UsageError("--embed-model wants a model");
628
+ if (values["ollama-url"] !== void 0 && name !== "ollama") throw new UsageError("--ollama-url goes with --embedder ollama");
629
+ return {
630
+ kind: name,
631
+ model,
632
+ url: name === "ollama" ? url : void 0,
633
+ allowRemote
634
+ };
635
+ };
636
+ /** This function gives the text of `embed`: the counts and the reason of the stop. */
637
+ const describeEmbed = (result) => [`${String(result.embedded)} texts got vectors (${String(result.chunks)} chunks); ${String(result.reused)} documents had vectors; ${String(result.held_back)} held back, so not sent to the model`, `${String(result.docs)} documents examined in ${String(result.ms)} ms with ${result.embedder}. Stopped (${result.stopped})${result.stopped === "done" ? "" : ": start embed again for the rest"}.`].join("\n");
466
638
  /** This function gives the pages of `fetch`: a rejected fetch is an error of its URL only. A page that needs a browser names the reason when rendering is off. */
467
639
  const fetchedPages = (urls, settled, renderOff) => settled.map((result, at) => {
468
640
  const url = urls[at];
@@ -477,7 +649,7 @@ const fetchedPages = (urls, settled, renderOff) => settled.map((result, at) => {
477
649
  } : page;
478
650
  });
479
651
  /** This function gives the text of the results of `find`. An archived copy says so. */
480
- const describeFound = (found) => found.length === 0 ? "nothing stored matches" : found.map((hit) => `${hit.doc} ${hit.title}${hit.archived === void 0 ? "" : ` [archived copy, captured ${hit.archived.timestamp}]`}\n ${hit.snippet.replace(/\s+/g, " ")}`).join("\n");
652
+ const describeFound = (found) => found.length === 0 ? "nothing stored matches" : found.map((hit) => `${hit.doc} ${hit.title}${hit.archived === void 0 ? "" : ` [archived copy, captured ${hit.archived.timestamp}]`}${hit.passage === void 0 ? "" : ` [passage ${String(hit.passage.start)}–${String(hit.passage.end)}]`}\n ${hit.snippet.replace(/\s+/g, " ")}`).join("\n");
481
653
  /** This function gives the text of `read`. With `--at`, the head names the capture and its fetch time. */
482
654
  const describeRead = (read, at) => {
483
655
  const version = at === void 0 ? "" : ` ${read.capture} (${read.fetched_at})`;
@@ -577,7 +749,11 @@ const readLine = (args, env) => {
577
749
  const render = values.render ?? "auto";
578
750
  if (render !== "auto" && render !== "never" && render !== "always") throw new UsageError(`--render is auto, never or always, not "${render}"`);
579
751
  const rank = values.rank ?? "exact";
580
- if (rank !== "exact" && rank !== "fast") throw new UsageError(`--rank is exact or fast, not "${rank}"`);
752
+ if (rank !== "exact" && rank !== "fast" && rank !== "hybrid") throw new UsageError(`--rank is exact, fast or hybrid, not "${rank}"`);
753
+ const embedder = embedderOf(values, env, command);
754
+ if (rank === "hybrid" && embedder === void 0) throw new UsageError("--rank hybrid wants --embedder transformers or ollama");
755
+ if (command === "embed" && embedder === void 0) throw new UsageError("embed wants --embedder transformers or ollama");
756
+ const maxDocs = values["max-docs"] === void 0 ? void 0 : whole("max-docs", values["max-docs"], 1, 1);
581
757
  const limit = whole("limit", values.limit, 10, 1);
582
758
  const from = command === "read" ? whole("from", values.from, 0, 0) : 0;
583
759
  const length = whole("length", values.length, 4e3, 1);
@@ -587,11 +763,12 @@ const readLine = (args, env) => {
587
763
  const diffTo = versionOf("to", values.to);
588
764
  const watchLine = command === "watch" ? watchLineOf(values, rest) : void 0;
589
765
  const extractFields = command === "extract" ? fieldsOf(values.field) : void 0;
766
+ const daemonLine = command === "daemon" ? daemonLineOf(values, rest) : void 0;
590
767
  const dir = storeDir(values.dir ?? (env["POLITE_CRAWLER_DIR"] || void 0));
591
768
  const allowPrivate = values["allow-private"] === true || envFlag(env, "POLITE_CRAWLER_ALLOW_PRIVATE");
592
- const allowUserProvided = values["allow-user-provided"] === true || command === "mcp" && envFlag(env, "POLITE_CRAWLER_ALLOW_USER_PROVIDED");
769
+ const allowUserProvided = values["allow-user-provided"] === true || (command === "mcp" || daemonLine?.action === "run") && envFlag(env, "POLITE_CRAWLER_ALLOW_USER_PROVIDED");
593
770
  if (values["archive-despite-robots"] === true && values["archive-fallback"] !== true) throw new UsageError("--archive-despite-robots works only with --archive-fallback");
594
- const signs = OWNER["sign-key"].split(" ").includes(command) && (watchLine === void 0 || watchLine.action === "run");
771
+ const signs = OWNER["sign-key"].split(" ").includes(command) && (watchLine === void 0 || watchLine.action === "run") && (daemonLine === void 0 || daemonLine.action === "run");
595
772
  const signKey = values["sign-key"] ?? (signs ? env["POLITE_CRAWLER_SIGN_KEY"] || void 0 : void 0);
596
773
  const signatureAgent = values["signature-agent"] ?? (signs ? env["POLITE_CRAWLER_SIGNATURE_AGENT"] || void 0 : void 0);
597
774
  if (command === "key-directory") {
@@ -619,6 +796,7 @@ const readLine = (args, env) => {
619
796
  diffFrom,
620
797
  diffTo,
621
798
  watchLine,
799
+ daemonLine,
622
800
  extractFields,
623
801
  exportPlan: command === "export" ? exportPlan(values) : void 0,
624
802
  dir,
@@ -626,7 +804,9 @@ const readLine = (args, env) => {
626
804
  allowPrivate,
627
805
  allowUserProvided,
628
806
  signKey,
629
- signatureAgent
807
+ signatureAgent,
808
+ embedder,
809
+ maxDocs
630
810
  };
631
811
  };
632
812
  //#endregion
@@ -656,9 +836,13 @@ Usage:
656
836
  polite-crawler watch remove <url...> stop the watch of these pages
657
837
  polite-crawler watch list print the watch list
658
838
  polite-crawler watch run check the pages that are due, within a budget
839
+ polite-crawler daemon check the watch list on a schedule, until SIGINT or SIGTERM;
840
+ with --mcp-http, serve the MCP tools over local HTTP too
841
+ polite-crawler daemon status print the state of the daemon of the store
659
842
  polite-crawler key-directory print the key directory (JWKS) of --sign-key, to host
660
843
  polite-crawler extract <doc> --field name=value[:kind]...
661
844
  check proposed field values against a stored page
845
+ polite-crawler embed --embedder <name> give vectors to the stored pages, for find --rank hybrid
662
846
 
663
847
  Options:
664
848
  --dir <path> the store (default $POLITE_CRAWLER_DIR, or ~/.polite-crawler)
@@ -670,6 +854,13 @@ Options:
670
854
  ($POLITE_CRAWLER_SIGN_KEY)
671
855
  --signature-agent <url> the https origin of your key directory, or the URL of a
672
856
  JSON Web Key Set on a path, for --sign-key ($POLITE_CRAWLER_SIGNATURE_AGENT)
857
+ --embedder <name> the local model of hybrid search, for find, embed and mcp: transformers
858
+ (transformers.js in this process) or ollama ($POLITE_CRAWLER_EMBEDDER)
859
+ --embed-model <m> the model: Xenova/bge-small-en-v1.5 (transformers) or nomic-embed-text
860
+ (ollama) by default ($POLITE_CRAWLER_EMBED_MODEL)
861
+ --ollama-url <url> the Ollama server, http://127.0.0.1:11434 by default ($POLITE_CRAWLER_OLLAMA_URL)
862
+ --allow-remote-ollama permit an Ollama server on a different computer: the page text goes
863
+ to it ($POLITE_CRAWLER_ALLOW_REMOTE_OLLAMA=1)
673
864
  --json print JSON
674
865
  -v, --version print the version
675
866
  -h, --help print this help
@@ -683,7 +874,8 @@ Options:
683
874
  find --limit <n> how many results (default 10)
684
875
  --kind <kind> only this kind of document, such as page or pdf
685
876
  --rank <mode> exact (default): BM25 over each match; fast: leave common words
686
- out of the one-word part, faster for a long question
877
+ out of the one-word part, faster for a long question; hybrid: BM25
878
+ and the meaning of the text, fused (needs --embedder)
687
879
  read --from <n> where the range starts (default 0)
688
880
  --length <n> how many characters (default 4000)
689
881
  --user-provided you gave this URL yourself: read a page that refuses AI input (logged)
@@ -720,17 +912,38 @@ Options:
720
912
  --render <mode> as for fetch
721
913
  watch run --budget <time> the longest time (default 10m); no request starts after it
722
914
  --max-pages <n> how many pages to check (default 100)
915
+ daemon --tick <time> the longest wait between two ticks: 1s or more (default 1m)
916
+ --jitter <time> a random wait from 0 to this after each tick (default 10s)
917
+ --tick-budget <time> the longest time of the checks of one tick (default 10m)
918
+ --tick-pages <n> how many pages one tick checks at most (default 100)
919
+ --grace <time> at a stop, the checks in progress get this time to end (default 10s)
920
+ --events <file> write each change as one JSON line to this file (default: the
921
+ standard output)
922
+ --on-change <command> start this command of yours for each change: it gets the
923
+ event as one JSON line on its standard input
924
+ --mcp-http serve the MCP tools over HTTP; each request needs the bearer token
925
+ --mcp-port <n> the port (default 9307)
926
+ --mcp-host <host> the host to bind (default 127.0.0.1)
927
+ --mcp-token-file <file> read the token from this file (default: a new token at each
928
+ start, in <store>/daemon/mcp-token)
929
+ --allow-remote permit a --mcp-host that is not loopback: read the README first
930
+ --allow-user-provided as for mcp
931
+ daemon status exit code 0 when a daemon runs on the store, 1 when none runs
723
932
  extract --field <name=value[:kind]> a value to check, never made up by the crawler (repeat it);
724
933
  kind: text (default), number, money, date, url, email, phone,
725
934
  or json-ld:<path> such as json-ld:Product.offers.price
726
935
  --capture <id> check the text of this capture, not the current text
727
936
  --user-provided as for read
937
+ embed --budget <time> the longest time (default 10m); no embedding starts after it
938
+ --max-docs <n> how many texts get vectors at most
939
+ --kind <kind> only this kind of document
728
940
 
729
941
  Exit codes:
730
942
  0 done
731
943
  1 not done: a page not fetched, no match, a quote not found, a document unknown or
732
944
  held back, a watch check that failed or that is still due, a field value that extract
733
- did not find or that is not clear, or an error
945
+ did not find or that is not clear, embed that stopped before each page had vectors,
946
+ or an error
734
947
  (fetch: a page with an archived copy counts as done)
735
948
  2 the command line is wrong
736
949
  `;
@@ -739,7 +952,7 @@ Exit codes:
739
952
  * command line first. The export only reads the store: it makes no store, and it sends no request.
740
953
  */
741
954
  const exportCommand = async (plan, dir, json) => {
742
- const result = plan.format === "wacz" ? await (await import("./wacz-sfOLT8e9.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-VIPscWxY.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
955
+ const result = plan.format === "wacz" ? await (await import("./wacz-BFNG42Kt.mjs").then((n) => n.n)).exportWacz(dir, plan.options) : await (await import("./warc-CVS0t1fW.mjs").then((n) => n.a)).exportWarc(dir, plan.options);
743
956
  process.stdout.write(`${json ? JSON.stringify(result, null, 2) : describeExport(result, plan.options.includeRefusals)}\n`);
744
957
  return 0;
745
958
  };
@@ -861,7 +1074,7 @@ const runWatchCommand = async (crawler, line, values, limits, json, out) => {
861
1074
  };
862
1075
  /** This function gives the text of a diff for a person: the two captures, then the text of `formatDiff`. */
863
1076
  const describeDiff = async (diff) => {
864
- const { formatDiff } = await import("./diff-Dk2eSvi-.mjs").then((n) => n.i);
1077
+ const { formatDiff } = await import("./diff-NQcQqKe9.mjs").then((n) => n.i);
865
1078
  return [
866
1079
  `${diff.doc} ${diff.from.capture} (${diff.from.fetched_at}) -> ${diff.to.capture} (${diff.to.fetched_at})`,
867
1080
  formatDiff(diff),
@@ -871,13 +1084,54 @@ const describeDiff = async (diff) => {
871
1084
  };
872
1085
  /** This function checks the fields against a stored document, and prints a line for each field. The exit code is 0 when each field is verified or normalized. */
873
1086
  const runExtract = async (crawler, doc, fields, options, json, out) => {
874
- const { extractDocument } = await import("./extract-DcwNrTK7.mjs").then((n) => n.i);
1087
+ const { extractDocument } = await import("./extract-ByFfc8Zk.mjs").then((n) => n.i);
875
1088
  const claims = fields;
876
1089
  const result = crawler.extract === void 0 ? await extractDocument(crawler.runtime, doc, claims, options) : await crawler.extract(doc, claims, options);
877
1090
  if (json) out(JSON.stringify(result, null, 2));
878
1091
  else for (const line of describeExtract(result)) out(line);
879
1092
  return extractCode(result);
880
1093
  };
1094
+ /**
1095
+ * This function makes the embedder of the command line. It loads no model: the model loads at the
1096
+ * first text. The transformers embedder keeps the files of its model in `<store>/models`.
1097
+ */
1098
+ const embedderFor = async (choice, dir) => {
1099
+ try {
1100
+ if (choice.kind === "ollama") {
1101
+ const { ollamaEmbedder } = await import("./ollama-BJxkjny4.mjs").then((n) => n.a);
1102
+ return ollamaEmbedder({
1103
+ ...choice.model === void 0 ? {} : { model: choice.model },
1104
+ ...choice.url === void 0 ? {} : { baseUrl: choice.url },
1105
+ allowRemote: choice.allowRemote
1106
+ });
1107
+ }
1108
+ const { transformersEmbedder } = await import("./transformers-BQMlaGj4.mjs").then((n) => n.i);
1109
+ return transformersEmbedder({
1110
+ ...choice.model === void 0 ? {} : { model: choice.model },
1111
+ cacheDir: join(dir, "models")
1112
+ });
1113
+ } catch (error) {
1114
+ throw new UsageError(`--embedder ${choice.kind}: ${error.message}`, { cause: error });
1115
+ }
1116
+ };
1117
+ /** This function gives vectors to the stored pages within the budget. The exit code is 0 when each page has vectors. */
1118
+ const runEmbed = async (crawler, limits, options, json, out) => {
1119
+ const controller = new AbortController();
1120
+ const stop = () => controller.abort(/* @__PURE__ */ new Error("interrupted"));
1121
+ process.once("SIGINT", stop);
1122
+ try {
1123
+ const result = await crawler.embed({
1124
+ budget: limits.budgetMs,
1125
+ signal: controller.signal,
1126
+ ...options.maxDocs === void 0 ? {} : { maxDocs: options.maxDocs },
1127
+ ...options.kind === void 0 ? {} : { kind: options.kind }
1128
+ });
1129
+ out(json ? JSON.stringify(result, null, 2) : describeEmbed(result));
1130
+ return result.stopped === "done" ? 0 : 1;
1131
+ } finally {
1132
+ process.removeListener("SIGINT", stop);
1133
+ }
1134
+ };
881
1135
  /** This function does the command of the command line, and gives the exit code. */
882
1136
  const main = async (args) => {
883
1137
  const out = (text) => void process.stdout.write(`${text}\n`);
@@ -891,6 +1145,7 @@ const main = async (args) => {
891
1145
  return 0;
892
1146
  }
893
1147
  const { command, rest, values, render, rank, limit, from, length, maxAge, crawlLimits, at, diffFrom, diffTo, watchLine, extractFields, dir, signKey, signatureAgent } = line;
1148
+ const embedder = line.embedder === void 0 ? void 0 : await embedderFor(line.embedder, dir);
894
1149
  if (command === "key-directory") return keyDirectoryCommand(keyFile(signKey), signatureAgent);
895
1150
  let signer;
896
1151
  if (signKey !== void 0 && signatureAgent !== void 0) {
@@ -911,7 +1166,8 @@ const main = async (args) => {
911
1166
  contact: line.contact,
912
1167
  ...values["no-browser"] === true ? { renderer: false } : {},
913
1168
  ...line.allowPrivate ? { egress: { allowPrivate: true } } : {},
914
- ...signer === void 0 ? {} : { signer }
1169
+ ...signer === void 0 ? {} : { signer },
1170
+ ...embedder === void 0 ? {} : { embedder }
915
1171
  };
916
1172
  if (command === "mcp") {
917
1173
  const { startMcpServer } = await import("./mcp.mjs");
@@ -921,8 +1177,16 @@ const main = async (args) => {
921
1177
  });
922
1178
  return 0;
923
1179
  }
1180
+ if (command === "daemon") {
1181
+ const { daemonCommand } = await import("./cli-daemon--cTmq0Z5.mjs");
1182
+ return daemonCommand(line.daemonLine, options, {
1183
+ allowUserProvided: line.allowUserProvided,
1184
+ json: values.json === true,
1185
+ out
1186
+ });
1187
+ }
924
1188
  if (line.exportPlan !== void 0) return exportCommand(line.exportPlan, dir, values.json === true);
925
- const { openCrawler } = await import("./open-DnrWgkC0.mjs").then((n) => n.a);
1189
+ const { openCrawler } = await import("./open-C4fhXJgD.mjs").then((n) => n.a);
926
1190
  let renderOff;
927
1191
  let renderer = options.renderer;
928
1192
  if (!(command === "fetch" || command === "crawl" || watchLine?.action === "run") || render === "never") renderer = false;
@@ -1005,6 +1269,10 @@ const main = async (args) => {
1005
1269
  userProvided: values["user-provided"] === true,
1006
1270
  at
1007
1271
  }, values.json === true, out);
1272
+ case "embed": return await runEmbed(crawler, crawlLimits, {
1273
+ maxDocs: line.maxDocs,
1274
+ kind: values.kind
1275
+ }, values.json === true, out);
1008
1276
  default: {
1009
1277
  const counts = await crawler.reindex();
1010
1278
  print(counts, () => describeReindex(counts));
@@ -1024,6 +1292,6 @@ main(process.argv.slice(2)).then((code) => {
1024
1292
  process.exitCode = usage ? 2 : 1;
1025
1293
  });
1026
1294
  //#endregion
1027
- export {};
1295
+ export { describeDaemonStatus as t };
1028
1296
 
1029
1297
  //# sourceMappingURL=cli.mjs.map