esbi-cli 0.2.1__py3-none-any.whl
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.
- esbi_cli/__init__.py +8 -0
- esbi_cli/ask/__init__.py +0 -0
- esbi_cli/ask/answer.py +256 -0
- esbi_cli/bench/__init__.py +0 -0
- esbi_cli/bench/cases.py +57 -0
- esbi_cli/bench/metrics.py +23 -0
- esbi_cli/bench/report.py +117 -0
- esbi_cli/bench/runner.py +114 -0
- esbi_cli/capture/__init__.py +0 -0
- esbi_cli/capture/inbox.py +63 -0
- esbi_cli/capture/legacy.py +49 -0
- esbi_cli/cli.py +1387 -0
- esbi_cli/config.py +344 -0
- esbi_cli/doctor.py +391 -0
- esbi_cli/evaluate.py +91 -0
- esbi_cli/export.py +137 -0
- esbi_cli/extract/__init__.py +107 -0
- esbi_cli/extract/clip.py +30 -0
- esbi_cli/extract/html.py +60 -0
- esbi_cli/extract/image.py +58 -0
- esbi_cli/extract/pdf.py +109 -0
- esbi_cli/gitops.py +101 -0
- esbi_cli/index.py +303 -0
- esbi_cli/ingest/__init__.py +0 -0
- esbi_cli/ingest/apply.py +480 -0
- esbi_cli/ingest/chunks.py +49 -0
- esbi_cli/ingest/connect.py +87 -0
- esbi_cli/ingest/digest.py +91 -0
- esbi_cli/ingest/pipeline.py +176 -0
- esbi_cli/ingest/plan.py +231 -0
- esbi_cli/ingest/read.py +105 -0
- esbi_cli/ingest/retrieve.py +59 -0
- esbi_cli/init.py +176 -0
- esbi_cli/interrupts.py +90 -0
- esbi_cli/lang.py +341 -0
- esbi_cli/links.py +10 -0
- esbi_cli/lint/__init__.py +0 -0
- esbi_cli/lint/checks.py +178 -0
- esbi_cli/lint/report.py +60 -0
- esbi_cli/llm/__init__.py +0 -0
- esbi_cli/llm/adapter.py +393 -0
- esbi_cli/llm/schemas.py +146 -0
- esbi_cli/mail/__init__.py +0 -0
- esbi_cli/mail/convert.py +194 -0
- esbi_cli/mail/credentials.py +65 -0
- esbi_cli/mail/fetch.py +154 -0
- esbi_cli/mail/imap.py +92 -0
- esbi_cli/netguard.py +127 -0
- esbi_cli/privacy.py +81 -0
- esbi_cli/queue.py +179 -0
- esbi_cli/reingest.py +165 -0
- esbi_cli/report/__init__.py +0 -0
- esbi_cli/report/daily_index.py +235 -0
- esbi_cli/report/index_md.py +21 -0
- esbi_cli/report/readstate.py +26 -0
- esbi_cli/run.py +100 -0
- esbi_cli/runlock.py +31 -0
- esbi_cli/runlog.py +80 -0
- esbi_cli/schedule.py +106 -0
- esbi_cli/templates/SCHEMA.md +52 -0
- esbi_cli/templates/clipper-template.json +17 -0
- esbi_cli/templates/clipper-youtube-template.json +18 -0
- esbi_cli/templates/config.example.toml +108 -0
- esbi_cli/update.py +247 -0
- esbi_cli/vault.py +188 -0
- esbi_cli/wizards/clipper.sh +271 -0
- esbi_cli/wizards/email.sh +265 -0
- esbi_cli-0.2.1.dist-info/METADATA +167 -0
- esbi_cli-0.2.1.dist-info/RECORD +72 -0
- esbi_cli-0.2.1.dist-info/WHEEL +4 -0
- esbi_cli-0.2.1.dist-info/entry_points.txt +3 -0
- esbi_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
esbi_cli/cli.py
ADDED
|
@@ -0,0 +1,1387 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from contextlib import contextmanager
|
|
9
|
+
from dataclasses import replace
|
|
10
|
+
from datetime import date, datetime
|
|
11
|
+
from functools import partial
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from urllib.parse import quote, urlparse
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from esbi_cli import __version__, lang
|
|
18
|
+
from esbi_cli import schedule as launchd
|
|
19
|
+
from esbi_cli import update as updater
|
|
20
|
+
from esbi_cli.ask.answer import answer_question, save_answer
|
|
21
|
+
from esbi_cli.bench.cases import load_cases
|
|
22
|
+
from esbi_cli.bench.report import render_report, save_report, suggest_routing, summarize
|
|
23
|
+
from esbi_cli.bench.runner import run_benchmark
|
|
24
|
+
from esbi_cli.capture.inbox import scan_inbox
|
|
25
|
+
from esbi_cli.capture.legacy import import_legacy
|
|
26
|
+
from esbi_cli.config import (
|
|
27
|
+
Config,
|
|
28
|
+
find_config,
|
|
29
|
+
load_config,
|
|
30
|
+
parse_time,
|
|
31
|
+
reset_loaded,
|
|
32
|
+
wants_update_check,
|
|
33
|
+
)
|
|
34
|
+
from esbi_cli.doctor import run_checks
|
|
35
|
+
from esbi_cli.evaluate import evaluate, load_golden
|
|
36
|
+
from esbi_cli.export import export_site
|
|
37
|
+
from esbi_cli.extract import ExtractError, extract_source, is_url
|
|
38
|
+
from esbi_cli.extract.image import IMAGE_SUFFIXES, NO_OCR
|
|
39
|
+
from esbi_cli.gitops import GitError, commit_vault, has_git, push_vault
|
|
40
|
+
from esbi_cli.ingest.pipeline import ingest as run_ingest
|
|
41
|
+
from esbi_cli.init import (
|
|
42
|
+
MODELS,
|
|
43
|
+
OCR_MODEL,
|
|
44
|
+
RUNTIMES,
|
|
45
|
+
connect_remote,
|
|
46
|
+
init_vault,
|
|
47
|
+
local_model,
|
|
48
|
+
set_email_block,
|
|
49
|
+
write_config,
|
|
50
|
+
)
|
|
51
|
+
from esbi_cli.interrupts import exit_when_interrupted
|
|
52
|
+
from esbi_cli.lint.checks import LintReport, lint_vault
|
|
53
|
+
from esbi_cli.lint.report import write_lint_report
|
|
54
|
+
from esbi_cli.llm.adapter import LLMError, make_embedder, make_llm, make_ocr
|
|
55
|
+
from esbi_cli.mail.credentials import CredentialError, get_password, save_password
|
|
56
|
+
from esbi_cli.mail.fetch import MAIL_LINK_ORIGIN, fetch_mail
|
|
57
|
+
from esbi_cli.mail.imap import ImapMailClient, MailError
|
|
58
|
+
from esbi_cli.privacy import remote_host, remote_warning
|
|
59
|
+
from esbi_cli.queue import Queue, normalize_target
|
|
60
|
+
from esbi_cli.reingest import reingest_all
|
|
61
|
+
from esbi_cli.report.daily_index import build_daily_index
|
|
62
|
+
from esbi_cli.report.readstate import sync_read_state
|
|
63
|
+
from esbi_cli.run import RunLimits, run_queue
|
|
64
|
+
from esbi_cli.runlock import LockBusy, RunLock
|
|
65
|
+
from esbi_cli.runlog import RunLog, RunRecord, is_due, trim_log
|
|
66
|
+
from esbi_cli.vault import Vault
|
|
67
|
+
|
|
68
|
+
# Rich markup would eat `[notes]`, `[llm.ocr]`, `[run]` in help texts: they are config sections.
|
|
69
|
+
Typer = partial(typer.Typer, rich_markup_mode="markdown")
|
|
70
|
+
app = Typer(help="esbi-cli: maintain an Obsidian wiki from your saved sources.")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# (label, arguments for the real command; may prompt for input)
|
|
74
|
+
MENU = [
|
|
75
|
+
("Queue status", lambda: ["status"]),
|
|
76
|
+
("Run now (ingest what is queued)", lambda: ["run"]),
|
|
77
|
+
("Ask the wiki a question", lambda: ["ask", typer.prompt("Question")]),
|
|
78
|
+
(
|
|
79
|
+
"Add links or files to the queue",
|
|
80
|
+
lambda: ["add", *typer.prompt("URLs or file paths, separated by spaces").split()],
|
|
81
|
+
),
|
|
82
|
+
("Open today's note in Obsidian", lambda: ["today"]),
|
|
83
|
+
("Check the wiki's health (lint)", lambda: ["lint"]),
|
|
84
|
+
("Check my setup (doctor)", lambda: ["doctor"]),
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _menu() -> None:
|
|
89
|
+
while True:
|
|
90
|
+
typer.echo("\nesbi-cli")
|
|
91
|
+
for n, (label, _) in enumerate(MENU, 1):
|
|
92
|
+
typer.echo(f" {n}. {label}")
|
|
93
|
+
choice = typer.prompt("Choose (q to quit)", default="q").strip().lower()
|
|
94
|
+
if choice == "q":
|
|
95
|
+
return
|
|
96
|
+
if not (choice.isdigit() and 1 <= int(choice) <= len(MENU)):
|
|
97
|
+
typer.echo("Not an option.")
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
app(MENU[int(choice) - 1][1]()) # the real command, same code
|
|
101
|
+
except SystemExit: # it printed its own error or result; keep the menu going
|
|
102
|
+
pass
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# Commands after which the update notice never appears: the scheduled run and the ones that manage
|
|
106
|
+
# the installation or set it up (the notice would be noise or, for `run`, a network call at night).
|
|
107
|
+
NO_NOTICE = {"run", "schedule", "update", "setup", "init", "version", "doctor"}
|
|
108
|
+
_command: dict[str, str | None] = {"name": None} # which command is running: Typer's result
|
|
109
|
+
# callback gets no context, so the root callback leaves the name here
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _stderr_is_tty() -> bool:
|
|
113
|
+
return sys.stderr.isatty()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _update_notice(_result: object = None) -> None:
|
|
117
|
+
"""The single hook: runs once after a command that succeeded (Typer skips it when the command
|
|
118
|
+
raises or exits with an error). One line on stderr, only at a terminal, only when a newer
|
|
119
|
+
release is known; the network is asked at most once a day, and never for the commands above."""
|
|
120
|
+
name, _command["name"] = _command["name"], None
|
|
121
|
+
if name is None or name in NO_NOTICE or not _stderr_is_tty():
|
|
122
|
+
return
|
|
123
|
+
if not updater.checks_enabled(wants_update_check()):
|
|
124
|
+
return
|
|
125
|
+
release = updater.cached_latest()
|
|
126
|
+
if release and updater.is_newer(release.version, __version__):
|
|
127
|
+
typer.echo(
|
|
128
|
+
f"esbi-cli {release.version} is available (you have {__version__}). "
|
|
129
|
+
"Update with: sb update",
|
|
130
|
+
err=True,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@app.callback(invoke_without_command=True, result_callback=_update_notice)
|
|
135
|
+
def main(ctx: typer.Context) -> None:
|
|
136
|
+
"""esbi-cli. Run without a command for a menu."""
|
|
137
|
+
reset_loaded()
|
|
138
|
+
_command["name"] = ctx.invoked_subcommand
|
|
139
|
+
if ctx.invoked_subcommand is None:
|
|
140
|
+
_menu()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.command()
|
|
144
|
+
def version(
|
|
145
|
+
check: bool = typer.Option(
|
|
146
|
+
False, "--check", help="Also ask GitHub for the latest release (the one network call)."
|
|
147
|
+
),
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Print the installed version; with --check, say whether a newer one exists."""
|
|
150
|
+
if not check:
|
|
151
|
+
typer.echo(__version__)
|
|
152
|
+
return
|
|
153
|
+
release = updater.cached_latest(force=True)
|
|
154
|
+
typer.echo(f"installed: {__version__}")
|
|
155
|
+
if release is None:
|
|
156
|
+
typer.echo("latest: unknown")
|
|
157
|
+
typer.echo("Could not check for a newer version (offline, or GitHub did not answer).")
|
|
158
|
+
elif updater.is_newer(release.version, __version__):
|
|
159
|
+
typer.echo(f"latest: {release.version}")
|
|
160
|
+
typer.echo(f"A newer version is available ({release.url}). Update with: sb update")
|
|
161
|
+
else:
|
|
162
|
+
typer.echo(f"latest: {release.version}")
|
|
163
|
+
typer.echo("You are up to date.")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@app.command("update")
|
|
167
|
+
def update_command(
|
|
168
|
+
yes: bool = typer.Option(False, "--yes", "-y", help="Run the command without asking."),
|
|
169
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Only print the command."),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Update esbi-cli to the latest release: shows the command for how it was installed, asks, runs it."""
|
|
172
|
+
release = updater.cached_latest(force=True)
|
|
173
|
+
if release is None:
|
|
174
|
+
typer.secho(
|
|
175
|
+
"error: could not check for a newer version (offline, or GitHub did not answer)",
|
|
176
|
+
fg=typer.colors.RED,
|
|
177
|
+
err=True,
|
|
178
|
+
)
|
|
179
|
+
raise typer.Exit(1)
|
|
180
|
+
prefix = Path(sys.prefix)
|
|
181
|
+
method = updater.install_method(prefix, PACKAGE_DIR)
|
|
182
|
+
typer.echo(f"installed: {__version__}\nlatest: {release.version}\nmethod: {method}")
|
|
183
|
+
if not updater.is_newer(release.version, __version__):
|
|
184
|
+
typer.echo("You are up to date.")
|
|
185
|
+
return
|
|
186
|
+
pin = updater.pinned_source(prefix) if method == "uv-tool" else None
|
|
187
|
+
argv = None if pin else updater.upgrade_command(method, release.version)
|
|
188
|
+
if argv is None:
|
|
189
|
+
typer.echo(updater.manual_message(method, release.version, pin))
|
|
190
|
+
return
|
|
191
|
+
typer.echo(f"Will run: {shlex.join(argv)}")
|
|
192
|
+
if dry_run:
|
|
193
|
+
return
|
|
194
|
+
if not yes:
|
|
195
|
+
if not _interactive():
|
|
196
|
+
typer.secho(
|
|
197
|
+
"error: no terminal to ask in. Run it with --yes, or use --dry-run to only see "
|
|
198
|
+
"the command.",
|
|
199
|
+
fg=typer.colors.RED,
|
|
200
|
+
err=True,
|
|
201
|
+
)
|
|
202
|
+
raise typer.Exit(1)
|
|
203
|
+
if not typer.confirm("Run it now?", default=False):
|
|
204
|
+
typer.echo("Not run.")
|
|
205
|
+
return
|
|
206
|
+
try:
|
|
207
|
+
code = updater.run_command(argv)
|
|
208
|
+
except FileNotFoundError as exc:
|
|
209
|
+
typer.secho(f"error: `{argv[0]}` was not found on your PATH", fg=typer.colors.RED, err=True)
|
|
210
|
+
raise typer.Exit(1) from exc
|
|
211
|
+
if code != 0:
|
|
212
|
+
typer.secho(
|
|
213
|
+
f"error: the update command exited with code {code}", fg=typer.colors.RED, err=True
|
|
214
|
+
)
|
|
215
|
+
raise typer.Exit(code)
|
|
216
|
+
typer.echo("Updated. Run `sb version` to confirm.")
|
|
217
|
+
try: # launchd is only ever changed by the user: say so, do not do it
|
|
218
|
+
if launchd.is_loaded(os.getuid(), launchctl=launchd.run_launchctl):
|
|
219
|
+
typer.echo("The nightly job was written by the old version: run `sb schedule install`.")
|
|
220
|
+
except OSError: # no launchd here
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _open_queue(cfg: Config) -> Queue:
|
|
225
|
+
return Queue(cfg.vault / ".esbi" / "queue.sqlite3")
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _load(config: Path | None) -> Config:
|
|
229
|
+
try:
|
|
230
|
+
return load_config(config)
|
|
231
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
232
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
233
|
+
raise typer.Exit(1) from exc
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _config_path(config: Path | None) -> Path:
|
|
237
|
+
"""Which config file applies, or an error line and exit 1."""
|
|
238
|
+
try:
|
|
239
|
+
return find_config(config)
|
|
240
|
+
except FileNotFoundError as exc:
|
|
241
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
242
|
+
raise typer.Exit(1) from exc
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _vault(cfg: Config) -> Vault:
|
|
246
|
+
"""The vault, with the dense side of search when [llm.embed] is configured (off by default)."""
|
|
247
|
+
if "embed" not in cfg.llm:
|
|
248
|
+
return Vault(cfg.vault, language=cfg.language)
|
|
249
|
+
try:
|
|
250
|
+
return Vault(cfg.vault, embedder=make_embedder(cfg.llm["embed"]), language=cfg.language)
|
|
251
|
+
except ValueError as exc:
|
|
252
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
253
|
+
raise typer.Exit(1) from exc
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
CONFIG_OPTION = typer.Option(None, "--config", help="Path to config.toml.")
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@app.command()
|
|
260
|
+
def status(config: Path | None = CONFIG_OPTION) -> None:
|
|
261
|
+
"""Show how many sources are queued, done or failed."""
|
|
262
|
+
queue = _open_queue(_load(config))
|
|
263
|
+
counts = queue.counts()
|
|
264
|
+
for state in ("queued", "processing", "done", "failed"):
|
|
265
|
+
typer.echo(f"{state}: {counts.get(state, 0)}")
|
|
266
|
+
for item in queue.items("failed"):
|
|
267
|
+
typer.echo(f" failed: {item.target} ({item.error})")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _is_source(target: str, images: bool = False) -> bool:
|
|
271
|
+
suffixes = (".pdf", ".md", *(IMAGE_SUFFIXES if images else ()))
|
|
272
|
+
return is_url(target) or (
|
|
273
|
+
Path(target).expanduser().is_file() and Path(target).suffix.lower() in suffixes
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _ocr(cfg: Config):
|
|
278
|
+
"""The model that reads images and scanned PDFs, or None when [llm.ocr] is not configured."""
|
|
279
|
+
return make_ocr(cfg.llm["ocr"]) if "ocr" in cfg.llm else None
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _extractor(cfg: Config, ocr):
|
|
283
|
+
return partial(extract_source, ocr=ocr, max_ocr_pages=cfg.ocr_max_pages)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _report_unsupported(names: list[str]) -> None:
|
|
287
|
+
"""Files in inbox/ that nothing can read: said out loud, never ignored."""
|
|
288
|
+
if not names:
|
|
289
|
+
return
|
|
290
|
+
typer.echo(f"Not read, esbi-cli cannot read these files in inbox/: {', '.join(names)}")
|
|
291
|
+
if any(Path(n).suffix.lower() in IMAGE_SUFFIXES for n in names):
|
|
292
|
+
typer.echo(f" Images: {NO_OCR}")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
@app.command()
|
|
296
|
+
def add(
|
|
297
|
+
targets: list[str] = typer.Argument(
|
|
298
|
+
..., help="URLs, or paths to .pdf / .md files (and images, with an [llm.ocr] model)."
|
|
299
|
+
),
|
|
300
|
+
config: Path | None = CONFIG_OPTION,
|
|
301
|
+
) -> None:
|
|
302
|
+
"""Queue sources to be ingested by the next `sb run` (nothing is fetched now)."""
|
|
303
|
+
cfg = _load(config)
|
|
304
|
+
images = "ocr" in cfg.llm
|
|
305
|
+
bad = [t for t in targets if not _is_source(t, images)]
|
|
306
|
+
if bad:
|
|
307
|
+
typer.secho(
|
|
308
|
+
f"error: not an http(s) URL or an existing .pdf/.md{'/image' if images else ''} file:",
|
|
309
|
+
fg="red",
|
|
310
|
+
err=True,
|
|
311
|
+
)
|
|
312
|
+
for t in bad:
|
|
313
|
+
typer.secho(f" {t}", fg="red", err=True)
|
|
314
|
+
if not images and any(Path(t).suffix.lower() in IMAGE_SUFFIXES for t in bad):
|
|
315
|
+
typer.secho(f" Images: {NO_OCR}", fg="red", err=True)
|
|
316
|
+
raise typer.Exit(1)
|
|
317
|
+
queue = _open_queue(cfg)
|
|
318
|
+
in_wiki = {
|
|
319
|
+
normalize_target(str(p.meta["url"])): p.title
|
|
320
|
+
for p in _vault(cfg).iter_pages(("sources",))
|
|
321
|
+
if p.meta.get("url")
|
|
322
|
+
}
|
|
323
|
+
added = known = 0
|
|
324
|
+
for t in targets:
|
|
325
|
+
if title := (in_wiki.get(normalize_target(t)) if is_url(t) else None):
|
|
326
|
+
typer.echo(f"Skipped {t}: already in the wiki as [[{title}]]")
|
|
327
|
+
elif queue.add(t, "cli", label=None if is_url(t) else Path(t).stem):
|
|
328
|
+
added += 1
|
|
329
|
+
else:
|
|
330
|
+
known += 1
|
|
331
|
+
typer.echo(f"Queued {added} ({known} already known). `sb run` processes them.")
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def open_uri(uri: str) -> None:
|
|
335
|
+
try:
|
|
336
|
+
subprocess.run(["open", uri], check=False) # macOS
|
|
337
|
+
except FileNotFoundError:
|
|
338
|
+
typer.echo("(no `open` command here: open the link above yourself)")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
@app.command()
|
|
342
|
+
def today(config: Path | None = CONFIG_OPTION) -> None:
|
|
343
|
+
"""Open today's daily note in Obsidian, or print its path if [notes].viewer is "none"."""
|
|
344
|
+
cfg = _load(config)
|
|
345
|
+
vault, day = _vault(cfg), date.today().isoformat()
|
|
346
|
+
if not (vault.wiki / "daily" / f"{day}.md").exists():
|
|
347
|
+
_refresh_index(vault, _open_queue(cfg))
|
|
348
|
+
if cfg.viewer == "none": # no Obsidian: any Markdown editor opens this file
|
|
349
|
+
typer.echo(str(vault.wiki / "daily" / f"{day}.md"))
|
|
350
|
+
return
|
|
351
|
+
uri = (
|
|
352
|
+
f"obsidian://open?vault={quote(vault.root.name)}&file={quote(f'wiki/daily/{day}', safe='')}"
|
|
353
|
+
)
|
|
354
|
+
typer.echo(uri)
|
|
355
|
+
open_uri(uri)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@app.command()
|
|
359
|
+
def doctor(config: Path | None = CONFIG_OPTION) -> None:
|
|
360
|
+
"""Check config, vault, Obsidian, model, mailbox, nightly job and install; say what to fix."""
|
|
361
|
+
checks = run_checks(config)
|
|
362
|
+
for c in checks:
|
|
363
|
+
typer.echo(f" {c.level:<4} {c.name}: {c.text}")
|
|
364
|
+
if c.fix:
|
|
365
|
+
typer.echo(f" fix: {c.fix}")
|
|
366
|
+
if any(c.level == "FAIL" for c in checks):
|
|
367
|
+
raise typer.Exit(1)
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
@app.command()
|
|
371
|
+
def retry(
|
|
372
|
+
target: str | None = typer.Argument(
|
|
373
|
+
None, help="One source to retry; default: all parked ones."
|
|
374
|
+
),
|
|
375
|
+
config: Path | None = CONFIG_OPTION,
|
|
376
|
+
) -> None:
|
|
377
|
+
"""Put sources that failed for good back in the queue with a fresh attempt count."""
|
|
378
|
+
count = _open_queue(_load(config)).requeue_failed(target)
|
|
379
|
+
typer.echo(
|
|
380
|
+
f"Requeued {count} source{'s' if count != 1 else ''}." if count else "Nothing to retry."
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
@app.command()
|
|
385
|
+
def drop(
|
|
386
|
+
target: str = typer.Argument(..., help="URL or path exactly as `sb status` shows it."),
|
|
387
|
+
config: Path | None = CONFIG_OPTION,
|
|
388
|
+
) -> None:
|
|
389
|
+
"""Remove a source from the queue for good (its original file, if any, is not touched)."""
|
|
390
|
+
if not _open_queue(_load(config)).remove(target):
|
|
391
|
+
typer.secho(f"error: {target} is not in the queue", fg=typer.colors.RED, err=True)
|
|
392
|
+
raise typer.Exit(1)
|
|
393
|
+
typer.echo(f"Dropped {target}")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
@app.command("import-legacy")
|
|
397
|
+
def import_legacy_cmd(config: Path | None = CONFIG_OPTION) -> None:
|
|
398
|
+
"""One-time import of an older folder (Links/ with dated link lists, and PDFs/) into the queue."""
|
|
399
|
+
cfg = _load(config)
|
|
400
|
+
if cfg.legacy_vault is None or not cfg.legacy_vault.is_dir():
|
|
401
|
+
typer.secho("error: set [paths].legacy_vault to an existing folder", fg="red", err=True)
|
|
402
|
+
raise typer.Exit(1)
|
|
403
|
+
result = import_legacy(cfg.legacy_vault, _open_queue(cfg))
|
|
404
|
+
typer.echo(f"Queued {result.added} sources ({result.duplicates} already known).")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
@app.command()
|
|
408
|
+
def scan(config: Path | None = CONFIG_OPTION) -> None:
|
|
409
|
+
"""Queue what is waiting in the vault's inbox/ folder (Web Clipper notes, PDFs, images)."""
|
|
410
|
+
cfg = _load(config)
|
|
411
|
+
result = scan_inbox(_vault(cfg), _open_queue(cfg), images="ocr" in cfg.llm)
|
|
412
|
+
dupes = f" ({result.duplicates} duplicate{'s' if result.duplicates != 1 else ''} removed)"
|
|
413
|
+
typer.echo(
|
|
414
|
+
f"Queued {result.enqueued} sources from the inbox{dupes if result.duplicates else ''}."
|
|
415
|
+
)
|
|
416
|
+
_report_unsupported(result.unsupported)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def _refresh_index(vault: Vault, queue: Queue) -> None:
|
|
420
|
+
"""Register ticks made in earlier daily notes, then rewrite today's note from the state."""
|
|
421
|
+
today = date.today()
|
|
422
|
+
newly_read = sync_read_state(vault, today)
|
|
423
|
+
if newly_read:
|
|
424
|
+
typer.echo(f"Marked {len(newly_read)} source{'s' if len(newly_read) != 1 else ''} as read.")
|
|
425
|
+
path = build_daily_index(vault, queue, today)
|
|
426
|
+
typer.echo(f"Wrote {path.relative_to(vault.root)}")
|
|
427
|
+
try:
|
|
428
|
+
commit_vault(vault.root, f"index: {today.isoformat()}")
|
|
429
|
+
except GitError as exc:
|
|
430
|
+
typer.secho(f"warning: git commit failed: {exc}", fg=typer.colors.YELLOW, err=True)
|
|
431
|
+
if problem := push_vault(vault.root):
|
|
432
|
+
typer.secho(
|
|
433
|
+
f"warning: vault backup not pushed: {problem}", fg=typer.colors.YELLOW, err=True
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
@app.command()
|
|
438
|
+
def index(config: Path | None = CONFIG_OPTION) -> None:
|
|
439
|
+
"""Register read ticks and rebuild today's daily index note and the Home block."""
|
|
440
|
+
cfg = _load(config)
|
|
441
|
+
_refresh_index(_vault(cfg), _open_queue(cfg))
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@app.command()
|
|
445
|
+
def run(
|
|
446
|
+
config: Path | None = CONFIG_OPTION,
|
|
447
|
+
max_sources: int | None = typer.Option(None, "--limit", help="Max sources this run."),
|
|
448
|
+
if_due: bool = typer.Option(
|
|
449
|
+
False,
|
|
450
|
+
"--if-due",
|
|
451
|
+
help="Scheduled mode: run only if today's nightly run has not happened yet.",
|
|
452
|
+
),
|
|
453
|
+
) -> None:
|
|
454
|
+
"""Scan the inbox, then ingest queued sources within the configured limits."""
|
|
455
|
+
cfg = _load(config)
|
|
456
|
+
try:
|
|
457
|
+
with RunLock(cfg.vault / ".esbi" / "run.lock"):
|
|
458
|
+
_run_locked(cfg, max_sources, if_due)
|
|
459
|
+
except LockBusy:
|
|
460
|
+
typer.echo("Another run is in progress; skipping.")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _writers(cfg: Config):
|
|
464
|
+
"""(reader, synthesizer, private). The synthesis model is optional: it writes the digest and
|
|
465
|
+
the connections, so it can be a stronger, slower model than the one taking chunk notes. The
|
|
466
|
+
private model, also optional, is the only one that ever reads email."""
|
|
467
|
+
reader = make_llm(cfg.llm_for("summarize"))
|
|
468
|
+
synth = make_llm(cfg.llm["synthesize"]) if "synthesize" in cfg.llm else None
|
|
469
|
+
private = make_llm(cfg.llm["private"]) if "private" in cfg.llm else None
|
|
470
|
+
return reader, synth, private
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _run_locked(cfg: Config, max_sources: int | None, if_due: bool) -> None:
|
|
474
|
+
vault, queue = _vault(cfg), _open_queue(cfg)
|
|
475
|
+
trim_log(cfg.vault / ".esbi" / "logs" / "nightly.log")
|
|
476
|
+
runlog = RunLog(cfg.vault / ".esbi" / "runs.jsonl")
|
|
477
|
+
started = datetime.now()
|
|
478
|
+
if if_due and not is_due(runlog.runs(), started, cfg.nightly_at):
|
|
479
|
+
typer.echo("Not due: the nightly run already happened.")
|
|
480
|
+
return
|
|
481
|
+
try:
|
|
482
|
+
llm, synth, private = _writers(cfg)
|
|
483
|
+
ocr = _ocr(cfg)
|
|
484
|
+
except (KeyError, ValueError) as exc:
|
|
485
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
486
|
+
raise typer.Exit(1) from exc
|
|
487
|
+
|
|
488
|
+
if cfg.email.enabled:
|
|
489
|
+
try:
|
|
490
|
+
_fetch_mail(cfg, vault)
|
|
491
|
+
except (MailError, CredentialError) as exc: # mail trouble must not stop ingestion
|
|
492
|
+
typer.secho(f"warning: mail skipped: {exc}", fg=typer.colors.YELLOW, err=True)
|
|
493
|
+
|
|
494
|
+
scan = scan_inbox(vault, queue, images=ocr is not None)
|
|
495
|
+
recovered = queue.recover()
|
|
496
|
+
if scan.enqueued or recovered:
|
|
497
|
+
typer.echo(f"Inbox: {scan.enqueued} new, {recovered} recovered from an interrupted run.")
|
|
498
|
+
_report_unsupported(scan.unsupported)
|
|
499
|
+
|
|
500
|
+
def ingest_and_commit(target: str):
|
|
501
|
+
item = queue.get(target) # legacy items remember when they were saved
|
|
502
|
+
result = run_ingest(
|
|
503
|
+
target,
|
|
504
|
+
vault=vault,
|
|
505
|
+
llm=llm,
|
|
506
|
+
synth_llm=synth,
|
|
507
|
+
private_llm=private,
|
|
508
|
+
cfg=cfg,
|
|
509
|
+
extractor=_extractor(cfg, ocr),
|
|
510
|
+
captured=item.captured if item else None,
|
|
511
|
+
on_step=lambda step: typer.echo(f" ... {step}"),
|
|
512
|
+
from_email=bool(item and item.origin == MAIL_LINK_ORIGIN),
|
|
513
|
+
)
|
|
514
|
+
if result.applied:
|
|
515
|
+
typer.echo(f" + {result.applied.source_title}")
|
|
516
|
+
for warning in result.warnings:
|
|
517
|
+
typer.secho(f" warning: {warning}", fg=typer.colors.YELLOW, err=True)
|
|
518
|
+
if result.applied.unsupported_entities:
|
|
519
|
+
names = ", ".join(result.applied.unsupported_entities)
|
|
520
|
+
typer.echo(f" dropped entities not found in the source: {names}")
|
|
521
|
+
try:
|
|
522
|
+
commit_vault(cfg.vault, f"ingest: {result.applied.source_title}")
|
|
523
|
+
except GitError as exc:
|
|
524
|
+
typer.secho(f" warning: git commit failed: {exc}", fg="yellow", err=True)
|
|
525
|
+
return result
|
|
526
|
+
|
|
527
|
+
summary = run_queue(
|
|
528
|
+
queue,
|
|
529
|
+
ingest_and_commit,
|
|
530
|
+
RunLimits(max_sources or cfg.max_sources_per_run, cfg.max_tokens_per_run),
|
|
531
|
+
tokens_used=lambda: sum(m.tokens_used for m in (llm, synth, private, ocr) if m),
|
|
532
|
+
)
|
|
533
|
+
for f in summary.failures:
|
|
534
|
+
outcome = "parked (`sb retry` puts it back)" if f.parked else "will be tried again"
|
|
535
|
+
reason = " ".join(f.error.split()) # one line, however long the message was
|
|
536
|
+
reason = reason if len(reason) <= 160 else reason[:157] + "..."
|
|
537
|
+
typer.echo(
|
|
538
|
+
f" ! {f.name}: attempt {f.attempt} of {queue.max_attempts} failed, {outcome}: {reason}"
|
|
539
|
+
)
|
|
540
|
+
typer.echo(
|
|
541
|
+
f"ingested: {summary.ingested}, skipped: {summary.skipped}, failed: {summary.failed} "
|
|
542
|
+
f"({summary.tokens_used} tokens)"
|
|
543
|
+
)
|
|
544
|
+
runlog.record(
|
|
545
|
+
RunRecord(
|
|
546
|
+
started=started,
|
|
547
|
+
finished=datetime.now(),
|
|
548
|
+
ingested=summary.ingested,
|
|
549
|
+
skipped=summary.skipped,
|
|
550
|
+
failed=summary.failed,
|
|
551
|
+
tokens_used=summary.tokens_used,
|
|
552
|
+
stopped_by=summary.stopped_by,
|
|
553
|
+
trigger="scheduled" if if_due else "manual",
|
|
554
|
+
)
|
|
555
|
+
)
|
|
556
|
+
if summary.stopped_by == "interrupted": # no lint or index: the user asked to stop
|
|
557
|
+
typer.secho(
|
|
558
|
+
f"Interrupted: {summary.released} {'item' if summary.released == 1 else 'items'} "
|
|
559
|
+
"put back in the queue.",
|
|
560
|
+
fg="yellow",
|
|
561
|
+
err=True,
|
|
562
|
+
)
|
|
563
|
+
raise typer.Exit(128 + summary.signum)
|
|
564
|
+
report = _lint(vault)
|
|
565
|
+
if report.issues:
|
|
566
|
+
typer.echo(f"Lint: {len(report.issues)} issues (see wiki/review/Lint.md)")
|
|
567
|
+
_refresh_index(vault, queue) # even after an outage: the morning index must exist
|
|
568
|
+
if summary.stopped_by == "llm_unavailable":
|
|
569
|
+
typer.secho(
|
|
570
|
+
"The LLM is unreachable (is Ollama running?). Nothing was lost; sources stay queued.",
|
|
571
|
+
fg=typer.colors.RED,
|
|
572
|
+
err=True,
|
|
573
|
+
)
|
|
574
|
+
raise typer.Exit(1)
|
|
575
|
+
if summary.stopped_by:
|
|
576
|
+
typer.echo(f"Stopped early ({summary.stopped_by}); the rest stays queued for the next run.")
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _lint(vault: Vault) -> LintReport:
|
|
580
|
+
"""Check the wiki's health and (re)write, or remove, wiki/review/Lint.md."""
|
|
581
|
+
report = lint_vault(vault)
|
|
582
|
+
write_lint_report(vault, report, date.today())
|
|
583
|
+
return report
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
@app.command()
|
|
587
|
+
def lint(config: Path | None = CONFIG_OPTION) -> None:
|
|
588
|
+
"""Check the wiki for orphans, broken links, duplicates and more. Only reports."""
|
|
589
|
+
cfg = _load(config)
|
|
590
|
+
report = _lint(Vault(cfg.vault, language=cfg.language))
|
|
591
|
+
if not report.issues:
|
|
592
|
+
typer.echo("No problems found.")
|
|
593
|
+
return
|
|
594
|
+
for kind in dict.fromkeys(i.kind for i in report.issues):
|
|
595
|
+
typer.echo(f"{kind}: {sum(i.kind == kind for i in report.issues)}")
|
|
596
|
+
typer.echo("Details in wiki/review/Lint.md")
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _log_question(cfg: Config, question: str, answer, llm, duration_seconds: float) -> None:
|
|
600
|
+
"""One line per question in .esbi/asks.jsonl: what was retrieved, what was cited."""
|
|
601
|
+
row = {
|
|
602
|
+
"at": datetime.now().isoformat(timespec="seconds"),
|
|
603
|
+
"question": question,
|
|
604
|
+
"retrieved": answer.retrieved,
|
|
605
|
+
"grounded": answer.grounded,
|
|
606
|
+
"cited": answer.citations,
|
|
607
|
+
"tokens": llm.tokens_used,
|
|
608
|
+
"seconds": round(duration_seconds, 1),
|
|
609
|
+
}
|
|
610
|
+
path = cfg.vault / ".esbi" / "asks.jsonl"
|
|
611
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
612
|
+
with path.open("a", encoding="utf-8") as fh:
|
|
613
|
+
fh.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
614
|
+
|
|
615
|
+
|
|
616
|
+
@app.command()
|
|
617
|
+
def ask(
|
|
618
|
+
question: str = typer.Argument(..., help="A question the wiki should be able to answer."),
|
|
619
|
+
config: Path | None = CONFIG_OPTION,
|
|
620
|
+
save: bool = typer.Option(False, "--save", help="File a grounded answer in wiki/syntheses/."),
|
|
621
|
+
) -> None:
|
|
622
|
+
"""Answer a question from the wiki only, citing the pages used."""
|
|
623
|
+
cfg = _load(config)
|
|
624
|
+
vault = _vault(cfg)
|
|
625
|
+
try:
|
|
626
|
+
llm = make_llm(cfg.llm.get("ask") or cfg.llm_for("summarize"))
|
|
627
|
+
started = time.monotonic()
|
|
628
|
+
answer = answer_question(vault, llm, question, rewrite=cfg.rewrite_questions)
|
|
629
|
+
except (KeyError, ValueError, LLMError) as exc:
|
|
630
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
631
|
+
raise typer.Exit(1) from exc
|
|
632
|
+
_log_question(cfg, question, answer, llm, time.monotonic() - started)
|
|
633
|
+
typer.echo(answer.text)
|
|
634
|
+
if not answer.grounded:
|
|
635
|
+
return
|
|
636
|
+
typer.echo("\nSources: " + ", ".join(f"[[{c}]]" for c in answer.citations))
|
|
637
|
+
if save:
|
|
638
|
+
path = save_answer(vault, answer, date.today())
|
|
639
|
+
typer.echo(f"Saved {path.relative_to(vault.root)}")
|
|
640
|
+
try:
|
|
641
|
+
commit_vault(cfg.vault, f"ask: {path.stem}")
|
|
642
|
+
except GitError as exc:
|
|
643
|
+
typer.secho(f"warning: git commit failed: {exc}", fg=typer.colors.YELLOW, err=True)
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
@app.command()
|
|
647
|
+
def info(config: Path | None = CONFIG_OPTION) -> None:
|
|
648
|
+
"""Print where things are, as key=value lines (the setup wizards read this)."""
|
|
649
|
+
cfg = _load(config)
|
|
650
|
+
typer.echo(f"config={find_config(config)}")
|
|
651
|
+
typer.echo(f"vault={cfg.vault}")
|
|
652
|
+
typer.echo(f"viewer={cfg.viewer}")
|
|
653
|
+
typer.echo(f"language={cfg.language}")
|
|
654
|
+
typer.echo(f"email={'enabled' if cfg.email.enabled else 'disabled'}")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
setup_app = Typer(help="Optional guided setups: mail capture and the Web Clipper.")
|
|
658
|
+
app.add_typer(setup_app, name="setup")
|
|
659
|
+
PACKAGE_DIR = Path(__file__).parent
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _run_wizard(script: str, config: Path | None) -> None:
|
|
663
|
+
"""Run a bundled setup wizard (bash) in this terminal, with the paths it needs."""
|
|
664
|
+
path = _config_path(config)
|
|
665
|
+
sb = Path(sys.prefix) / "bin" / "sb"
|
|
666
|
+
env = {
|
|
667
|
+
**os.environ,
|
|
668
|
+
"SB": str(sb if sb.exists() else shutil.which("sb") or "sb"),
|
|
669
|
+
"SB_CONFIG": str(path),
|
|
670
|
+
"SB_TEMPLATES": str(PACKAGE_DIR / "templates"),
|
|
671
|
+
}
|
|
672
|
+
done = subprocess.run(["bash", str(PACKAGE_DIR / "wizards" / script)], env=env)
|
|
673
|
+
if done.returncode != 0:
|
|
674
|
+
raise typer.Exit(done.returncode)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@setup_app.command("email")
|
|
678
|
+
def setup_email(config: Path | None = CONFIG_OPTION) -> None:
|
|
679
|
+
"""Connect a Gmail mailbox so you can forward articles and PDFs to the app (optional)."""
|
|
680
|
+
_run_wizard("email.sh", config)
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
@setup_app.command("clipper")
|
|
684
|
+
def setup_clipper(config: Path | None = CONFIG_OPTION) -> None:
|
|
685
|
+
"""Connect the Obsidian Web Clipper to save pages and YouTube transcripts (optional; needs Obsidian)."""
|
|
686
|
+
_run_wizard("clipper.sh", config)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
GOLDEN_HELP = """\
|
|
690
|
+
No questions to score yet. Make {path} with one JSON object per line, a question and the
|
|
691
|
+
source(s) that should answer it (the start of a title is enough):
|
|
692
|
+
|
|
693
|
+
{{"question": "What is an agent harness?", "expect": ["Code as Agent Harness"]}}
|
|
694
|
+
"""
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
@app.command("eval")
|
|
698
|
+
def eval_command(
|
|
699
|
+
config: Path | None = CONFIG_OPTION,
|
|
700
|
+
k: int = typer.Option(6, "--k", help="How many pages count as retrieved (sb ask uses 6)."),
|
|
701
|
+
rewrite: bool = typer.Option(
|
|
702
|
+
False,
|
|
703
|
+
"--rewrite",
|
|
704
|
+
help="Rewrite each question into search terms first (one model call each).",
|
|
705
|
+
),
|
|
706
|
+
answers: bool = typer.Option(
|
|
707
|
+
False, "--answers", help="Also run sb ask on each question (slow; uses the ask model)."
|
|
708
|
+
),
|
|
709
|
+
) -> None:
|
|
710
|
+
"""Score how well retrieval finds the right pages, on the questions in .esbi/golden.jsonl."""
|
|
711
|
+
cfg = _load(config)
|
|
712
|
+
vault = _vault(cfg)
|
|
713
|
+
path = cfg.vault / ".esbi" / "golden.jsonl"
|
|
714
|
+
if not path.exists():
|
|
715
|
+
typer.secho(GOLDEN_HELP.format(path=path), fg=typer.colors.RED, err=True)
|
|
716
|
+
raise typer.Exit(1)
|
|
717
|
+
try:
|
|
718
|
+
golden = load_golden(path)
|
|
719
|
+
llm = (
|
|
720
|
+
make_llm(cfg.llm.get("ask") or cfg.llm_for("summarize")) if rewrite or answers else None
|
|
721
|
+
)
|
|
722
|
+
report = evaluate(
|
|
723
|
+
vault,
|
|
724
|
+
golden,
|
|
725
|
+
k,
|
|
726
|
+
rewrite_llm=llm if rewrite else None,
|
|
727
|
+
answer_llm=llm if answers else None,
|
|
728
|
+
)
|
|
729
|
+
except (ValueError, KeyError, LLMError) as exc:
|
|
730
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
731
|
+
raise typer.Exit(1) from exc
|
|
732
|
+
hits = sum(c.rank is not None for c in report.cases)
|
|
733
|
+
typer.echo(
|
|
734
|
+
f"Retrieval on {len(report.cases)} questions: recall@{k} {report.recall:.0%} "
|
|
735
|
+
f"({hits}/{len(report.cases)}), MRR {report.mrr:.2f}"
|
|
736
|
+
+ (" (questions rewritten)" if rewrite else "")
|
|
737
|
+
)
|
|
738
|
+
if answers:
|
|
739
|
+
typer.echo(
|
|
740
|
+
f"Answers: grounded {report.rate('grounded'):.0%}, "
|
|
741
|
+
f"cites the expected page {report.rate('cited'):.0%}"
|
|
742
|
+
)
|
|
743
|
+
for case in report.cases:
|
|
744
|
+
if case.rank is None:
|
|
745
|
+
typer.echo(
|
|
746
|
+
f" miss: {case.question} (expected {', '.join(case.expect)}; got {', '.join(case.retrieved) or 'nothing'})"
|
|
747
|
+
)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
@app.command("export")
|
|
751
|
+
def export_command(
|
|
752
|
+
config: Path | None = CONFIG_OPTION,
|
|
753
|
+
out: Path = typer.Option(
|
|
754
|
+
None, "--out", help="Where to write the site (default: <vault>/site)."
|
|
755
|
+
),
|
|
756
|
+
) -> None:
|
|
757
|
+
"""Write a read-only HTML copy of the wiki, to browse without Obsidian."""
|
|
758
|
+
cfg = _load(config)
|
|
759
|
+
target = out or cfg.vault / "site"
|
|
760
|
+
try:
|
|
761
|
+
count = export_site(_vault(cfg), target)
|
|
762
|
+
except ValueError as exc:
|
|
763
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
764
|
+
raise typer.Exit(1) from exc
|
|
765
|
+
typer.echo(f"Exported {count} pages. Open {target.expanduser().resolve() / 'index.html'}")
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
@app.command()
|
|
769
|
+
def bench(
|
|
770
|
+
config: Path | None = CONFIG_OPTION,
|
|
771
|
+
models: str | None = typer.Option(
|
|
772
|
+
None, "--models", help="Comma-separated, e.g. ollama/qwen3:4b"
|
|
773
|
+
),
|
|
774
|
+
cases: int | None = typer.Option(
|
|
775
|
+
None, "--cases", help="Cases per task (default: [bench].cases in config.toml)."
|
|
776
|
+
),
|
|
777
|
+
) -> None:
|
|
778
|
+
"""Compare models on your own material. Read-only for the wiki; prints and saves a report."""
|
|
779
|
+
cfg = _load(config)
|
|
780
|
+
names = [m.strip() for m in models.split(",")] if models else cfg.bench.models
|
|
781
|
+
if not names:
|
|
782
|
+
typer.secho("error: pass --models or set [bench].models in config.toml", fg="red", err=True)
|
|
783
|
+
raise typer.Exit(1)
|
|
784
|
+
vault = _vault(cfg)
|
|
785
|
+
ingest_cases, ask_cases = load_cases(vault, cases or cfg.bench.cases)
|
|
786
|
+
if not ingest_cases and not ask_cases:
|
|
787
|
+
typer.secho(
|
|
788
|
+
"error: the vault has no sources or concepts to test with yet", fg="red", err=True
|
|
789
|
+
)
|
|
790
|
+
raise typer.Exit(1)
|
|
791
|
+
|
|
792
|
+
base = cfg.llm_for("summarize")
|
|
793
|
+
|
|
794
|
+
def llm_for(model: str):
|
|
795
|
+
same_provider = model.split("/")[0] == base.model.split("/")[0]
|
|
796
|
+
# endpoint and key settings belong to one provider: don't leak them to another
|
|
797
|
+
return make_llm(
|
|
798
|
+
replace(
|
|
799
|
+
base,
|
|
800
|
+
model=model,
|
|
801
|
+
base_url=base.base_url if same_provider else None,
|
|
802
|
+
api_key_env=base.api_key_env if same_provider else None,
|
|
803
|
+
)
|
|
804
|
+
)
|
|
805
|
+
|
|
806
|
+
def progress(trial) -> None:
|
|
807
|
+
state = "ok" if trial.ok else f"FAILED ({trial.error})"
|
|
808
|
+
typer.echo(
|
|
809
|
+
f" {trial.model} · {trial.task} · {trial.case}: {state} in {trial.latency_seconds:.0f}s"
|
|
810
|
+
)
|
|
811
|
+
|
|
812
|
+
trials = run_benchmark(
|
|
813
|
+
names,
|
|
814
|
+
ingest_cases,
|
|
815
|
+
ask_cases,
|
|
816
|
+
vault,
|
|
817
|
+
llm_factory=llm_for,
|
|
818
|
+
max_source_chars=cfg.max_source_chars,
|
|
819
|
+
on_trial=progress,
|
|
820
|
+
)
|
|
821
|
+
summaries = summarize(trials, cfg.bench.prices)
|
|
822
|
+
now = datetime.now()
|
|
823
|
+
text = render_report(summaries, suggest_routing(summaries), when=now, language=vault.language)
|
|
824
|
+
typer.echo("\n" + text)
|
|
825
|
+
typer.echo(f"Saved {save_report(vault, text, now).relative_to(vault.root)}")
|
|
826
|
+
|
|
827
|
+
|
|
828
|
+
email_app = Typer(help="Capture mail sent to the dedicated mailbox.")
|
|
829
|
+
app.add_typer(email_app, name="email")
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def make_mail_client(cfg: Config) -> ImapMailClient:
|
|
833
|
+
if not cfg.email.enabled:
|
|
834
|
+
raise MailError("email is disabled: set [email].enabled = true in config.toml")
|
|
835
|
+
if not cfg.email.user:
|
|
836
|
+
raise MailError("set [email].user (the mailbox address) in config.toml")
|
|
837
|
+
return ImapMailClient(
|
|
838
|
+
host=cfg.email.imap_host,
|
|
839
|
+
user=cfg.email.user,
|
|
840
|
+
password=get_password(cfg.email.user),
|
|
841
|
+
mailbox=cfg.email.mailbox,
|
|
842
|
+
)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _fetch_mail(cfg: Config, vault: Vault) -> None:
|
|
846
|
+
client = make_mail_client(cfg)
|
|
847
|
+
try:
|
|
848
|
+
result = fetch_mail(
|
|
849
|
+
client,
|
|
850
|
+
vault,
|
|
851
|
+
_open_queue(cfg) if cfg.email.follow_links else None,
|
|
852
|
+
cfg.email.follow_links,
|
|
853
|
+
cfg.email.follow_links_max,
|
|
854
|
+
)
|
|
855
|
+
finally:
|
|
856
|
+
client.close()
|
|
857
|
+
extras = "".join(
|
|
858
|
+
f", {n} {what}"
|
|
859
|
+
for n, what in ((result.images, "images saved"), (result.links, "links queued"))
|
|
860
|
+
if n
|
|
861
|
+
)
|
|
862
|
+
typer.echo(
|
|
863
|
+
f"Mail: {result.saved} saved, {result.duplicates} duplicates, {result.failed} failed{extras}."
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
@email_app.command("fetch")
|
|
868
|
+
def email_fetch(config: Path | None = CONFIG_OPTION) -> None:
|
|
869
|
+
"""Save recent mail from the dedicated mailbox into the vault's inbox/."""
|
|
870
|
+
cfg = _load(config)
|
|
871
|
+
try:
|
|
872
|
+
_fetch_mail(cfg, _vault(cfg))
|
|
873
|
+
except (MailError, CredentialError) as exc:
|
|
874
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
875
|
+
raise typer.Exit(1) from exc
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
@email_app.command("configure")
|
|
879
|
+
def email_configure(
|
|
880
|
+
user: str = typer.Option(..., "--user", help="The Gmail address that receives your forwards."),
|
|
881
|
+
label: str = typer.Option("esbi-cli", "--label", help="The Gmail label to read."),
|
|
882
|
+
config: Path | None = CONFIG_OPTION,
|
|
883
|
+
) -> None:
|
|
884
|
+
"""Turn on mail capture in config.toml (the password is stored separately: set-password)."""
|
|
885
|
+
path = _config_path(config)
|
|
886
|
+
set_email_block(path, user, label)
|
|
887
|
+
typer.echo(f"Mail capture on for {user}, label {label}, in {path}")
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
@email_app.command("set-password")
|
|
891
|
+
def email_set_password(
|
|
892
|
+
config: Path | None = CONFIG_OPTION,
|
|
893
|
+
stdin: bool = typer.Option(
|
|
894
|
+
False,
|
|
895
|
+
"--stdin",
|
|
896
|
+
help="Read the password from standard input, e.g. `pbpaste | sb email set-password --stdin`.",
|
|
897
|
+
),
|
|
898
|
+
) -> None:
|
|
899
|
+
"""Store the mailbox app password in the macOS Keychain (typed hidden, never shown).
|
|
900
|
+
|
|
901
|
+
There is no `--password` option on purpose: an argument ends up in your shell history and in
|
|
902
|
+
the process list."""
|
|
903
|
+
cfg = _load(config)
|
|
904
|
+
if not cfg.email.user:
|
|
905
|
+
typer.secho("error: set [email].user in config.toml first", fg="red", err=True)
|
|
906
|
+
raise typer.Exit(1)
|
|
907
|
+
if stdin:
|
|
908
|
+
password = sys.stdin.read().strip()
|
|
909
|
+
if not password:
|
|
910
|
+
typer.secho("error: the password from standard input is empty", fg="red", err=True)
|
|
911
|
+
raise typer.Exit(1)
|
|
912
|
+
else:
|
|
913
|
+
password = typer.prompt(f"App password for {cfg.email.user}", hide_input=True)
|
|
914
|
+
try:
|
|
915
|
+
save_password(cfg.email.user, password)
|
|
916
|
+
except CredentialError as exc:
|
|
917
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
918
|
+
raise typer.Exit(1) from exc
|
|
919
|
+
typer.echo("Saved to the Keychain.")
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
schedule_app = Typer(help="Run the nightly job automatically with launchd (macOS).")
|
|
923
|
+
app.add_typer(schedule_app, name="schedule")
|
|
924
|
+
|
|
925
|
+
AGENTS_DIR_OPTION = typer.Option(
|
|
926
|
+
Path("~/Library/LaunchAgents"), "--agents-dir", help="Where launchd agents live."
|
|
927
|
+
)
|
|
928
|
+
RUNNING_VENV = Path(sys.prefix)
|
|
929
|
+
AGENTS_DIR = Path(
|
|
930
|
+
"~/Library/LaunchAgents"
|
|
931
|
+
) # where launchd agents live (a test points it elsewhere)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
@contextmanager
|
|
935
|
+
def _needs_launchd(config: Path | None = None):
|
|
936
|
+
"""Where launchctl does not exist (Linux), say so and show the cron line that does the same."""
|
|
937
|
+
try:
|
|
938
|
+
yield
|
|
939
|
+
except FileNotFoundError as exc:
|
|
940
|
+
try:
|
|
941
|
+
line = launchd.cron_line(find_config(config))
|
|
942
|
+
except FileNotFoundError:
|
|
943
|
+
line = launchd.cron_line(Path("config.toml"))
|
|
944
|
+
typer.secho(
|
|
945
|
+
"error: the nightly job uses launchd, which only macOS has. On this system add this "
|
|
946
|
+
f"line to cron (`crontab -e`; hourly is fine, it runs once a day):\n {line}",
|
|
947
|
+
fg=typer.colors.RED,
|
|
948
|
+
err=True,
|
|
949
|
+
)
|
|
950
|
+
raise typer.Exit(1) from exc
|
|
951
|
+
|
|
952
|
+
|
|
953
|
+
@schedule_app.command("install")
|
|
954
|
+
def schedule_install(
|
|
955
|
+
config: Path | None = CONFIG_OPTION,
|
|
956
|
+
agents_dir: Path = AGENTS_DIR_OPTION,
|
|
957
|
+
) -> None:
|
|
958
|
+
"""Install and load the LaunchAgent: runs at [run].nightly_time (or the next wake), retrying hourly."""
|
|
959
|
+
cfg = _load(config)
|
|
960
|
+
config = find_config(config).resolve()
|
|
961
|
+
try:
|
|
962
|
+
with _needs_launchd(config):
|
|
963
|
+
path = _install_job(config, agents_dir)
|
|
964
|
+
except launchd.ScheduleError as exc:
|
|
965
|
+
typer.secho(
|
|
966
|
+
f"error: {exc} (run `uv sync` if this is a checkout)", fg=typer.colors.RED, err=True
|
|
967
|
+
)
|
|
968
|
+
raise typer.Exit(1) from exc
|
|
969
|
+
typer.echo(
|
|
970
|
+
f"Installed {path}\nRuns every day at {cfg.nightly_time} "
|
|
971
|
+
f"(or at the next wake). Log: {cfg.vault / '.esbi' / 'logs' / 'nightly.log'}"
|
|
972
|
+
)
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
@schedule_app.command("uninstall")
|
|
976
|
+
def schedule_uninstall(agents_dir: Path = AGENTS_DIR_OPTION) -> None:
|
|
977
|
+
"""Unload and remove the LaunchAgent."""
|
|
978
|
+
with _needs_launchd():
|
|
979
|
+
removed = launchd.uninstall(
|
|
980
|
+
agents_dir=agents_dir.expanduser(), uid=os.getuid(), launchctl=launchd.run_launchctl
|
|
981
|
+
)
|
|
982
|
+
typer.echo("Removed." if removed else "Nothing to remove.")
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
@schedule_app.command("status")
|
|
986
|
+
def schedule_status(agents_dir: Path = AGENTS_DIR_OPTION) -> None:
|
|
987
|
+
"""Show whether the LaunchAgent is installed and loaded."""
|
|
988
|
+
path = agents_dir.expanduser() / f"{launchd.LABEL}.plist"
|
|
989
|
+
with _needs_launchd():
|
|
990
|
+
loaded = launchd.is_loaded(uid=os.getuid(), launchctl=launchd.run_launchctl)
|
|
991
|
+
typer.echo(f"installed: {'yes' if path.exists() else 'no'}")
|
|
992
|
+
typer.echo(f"loaded: {'yes' if loaded else 'no'}")
|
|
993
|
+
|
|
994
|
+
|
|
995
|
+
@app.command()
|
|
996
|
+
def ingest(
|
|
997
|
+
target: str = typer.Argument(
|
|
998
|
+
..., help="An http(s) URL, or a path to a local PDF, Markdown note or image."
|
|
999
|
+
),
|
|
1000
|
+
config: Path | None = typer.Option(None, "--config", help="Path to config.toml."),
|
|
1001
|
+
force: bool = typer.Option(False, "--force", help="Ingest even if already in the vault."),
|
|
1002
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="Print the LLM's plan; write nothing."),
|
|
1003
|
+
no_commit: bool = typer.Option(False, "--no-commit", help="Do not git-commit the vault."),
|
|
1004
|
+
) -> None:
|
|
1005
|
+
"""Ingest one source into the wiki."""
|
|
1006
|
+
try:
|
|
1007
|
+
cfg = load_config(config)
|
|
1008
|
+
llm, synth, private = _writers(cfg)
|
|
1009
|
+
with exit_when_interrupted():
|
|
1010
|
+
result = run_ingest(
|
|
1011
|
+
target,
|
|
1012
|
+
extractor=_extractor(cfg, _ocr(cfg)),
|
|
1013
|
+
vault=_vault(cfg),
|
|
1014
|
+
llm=llm,
|
|
1015
|
+
synth_llm=synth,
|
|
1016
|
+
private_llm=private,
|
|
1017
|
+
cfg=cfg,
|
|
1018
|
+
force=force,
|
|
1019
|
+
dry_run=dry_run,
|
|
1020
|
+
on_step=lambda step: typer.echo(f"... {step}"),
|
|
1021
|
+
)
|
|
1022
|
+
except (FileNotFoundError, KeyError, ValueError, ExtractError, LLMError) as exc:
|
|
1023
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
1024
|
+
raise typer.Exit(1) from exc
|
|
1025
|
+
|
|
1026
|
+
if result.status == "skipped":
|
|
1027
|
+
typer.echo(f"Already in the vault as [[{result.existing_title}]] (use --force to redo).")
|
|
1028
|
+
return
|
|
1029
|
+
assert result.plan is not None
|
|
1030
|
+
for warning in result.warnings:
|
|
1031
|
+
typer.secho(f"warning: {warning}", fg=typer.colors.YELLOW, err=True)
|
|
1032
|
+
if result.status == "dry-run":
|
|
1033
|
+
typer.echo(result.plan.model_dump_json(indent=2))
|
|
1034
|
+
return
|
|
1035
|
+
|
|
1036
|
+
applied = result.applied
|
|
1037
|
+
assert applied is not None
|
|
1038
|
+
typer.echo(f"Ingested: {applied.source_title}")
|
|
1039
|
+
typer.echo(f" created: {', '.join(applied.created) or '-'}")
|
|
1040
|
+
typer.echo(f" updated: {', '.join(applied.updated) or '-'}")
|
|
1041
|
+
if applied.reviews:
|
|
1042
|
+
typer.echo(f" review: {len(applied.reviews)} note(s) in wiki/review/")
|
|
1043
|
+
if applied.dropped:
|
|
1044
|
+
typer.echo(f" ignored references to unknown pages: {', '.join(applied.dropped)}")
|
|
1045
|
+
if applied.unsupported_entities:
|
|
1046
|
+
typer.echo(
|
|
1047
|
+
f" dropped entities not found in the source: {', '.join(applied.unsupported_entities)}"
|
|
1048
|
+
)
|
|
1049
|
+
if not no_commit:
|
|
1050
|
+
try:
|
|
1051
|
+
done = commit_vault(cfg.vault, f"ingest: {applied.source_title}")
|
|
1052
|
+
typer.echo(" committed to the vault repo" if done else " nothing to commit")
|
|
1053
|
+
except GitError as exc:
|
|
1054
|
+
typer.secho(f" warning: git commit failed: {exc}", fg=typer.colors.YELLOW, err=True)
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _interactive() -> bool:
|
|
1058
|
+
"""Ask questions only when a person is at the keyboard (tests and scripts use the flags)."""
|
|
1059
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1060
|
+
|
|
1061
|
+
|
|
1062
|
+
def _choose(question: str, options: list[str], default: int = 1) -> int:
|
|
1063
|
+
"""A numbered choice, 1-based."""
|
|
1064
|
+
typer.echo(question)
|
|
1065
|
+
for n, text in enumerate(options, 1):
|
|
1066
|
+
typer.echo(f" {n}. {text}")
|
|
1067
|
+
while True:
|
|
1068
|
+
answer = typer.prompt("Choose", default=str(default))
|
|
1069
|
+
if answer.isdigit() and 1 <= int(answer) <= len(options):
|
|
1070
|
+
return int(answer)
|
|
1071
|
+
typer.echo("Not an option.")
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _install_job(config: Path, agents_dir: Path) -> Path:
|
|
1075
|
+
"""Write and load the nightly LaunchAgent for this config (macOS)."""
|
|
1076
|
+
cfg = _load(config)
|
|
1077
|
+
if not (RUNNING_VENV / "bin" / "sb").exists():
|
|
1078
|
+
raise launchd.ScheduleError(f"no {RUNNING_VENV}/bin/sb")
|
|
1079
|
+
log_dir = cfg.vault / ".esbi" / "logs"
|
|
1080
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
1081
|
+
plist = launchd.render_plist(
|
|
1082
|
+
config=config, log_dir=log_dir, venv=RUNNING_VENV, at=cfg.nightly_at
|
|
1083
|
+
)
|
|
1084
|
+
return launchd.install(
|
|
1085
|
+
plist, agents_dir=agents_dir.expanduser(), uid=os.getuid(), launchctl=launchd.run_launchctl
|
|
1086
|
+
)
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
@app.command()
|
|
1090
|
+
def init(
|
|
1091
|
+
vault: Path | None = typer.Option(None, "--vault", help="Where the vault should live."),
|
|
1092
|
+
config_file: Path = typer.Option(
|
|
1093
|
+
Path("~/.config/esbi-cli/config.toml"), "--config-file", help="Where to write config."
|
|
1094
|
+
),
|
|
1095
|
+
model: str = typer.Option(
|
|
1096
|
+
None, "--model", help="Which model writes the notes: local, subscription or api."
|
|
1097
|
+
),
|
|
1098
|
+
runtime: str = typer.Option(None, "--runtime", help="For a local model: ollama or lmstudio."),
|
|
1099
|
+
local_name: str = typer.Option(
|
|
1100
|
+
None, "--local-model", help="The local model's name (required for lmstudio: see `lms ls`)."
|
|
1101
|
+
),
|
|
1102
|
+
base_url: str = typer.Option(
|
|
1103
|
+
None,
|
|
1104
|
+
"--base-url",
|
|
1105
|
+
help="Where the local runtime's server is, if not on this Mac (a local model only).",
|
|
1106
|
+
),
|
|
1107
|
+
language: str = typer.Option(
|
|
1108
|
+
None,
|
|
1109
|
+
"--language",
|
|
1110
|
+
help=f"The language the notes are written in ({lang.supported()}). The CLI itself is English.",
|
|
1111
|
+
),
|
|
1112
|
+
obsidian: bool = typer.Option(
|
|
1113
|
+
None, "--obsidian/--no-obsidian", help="Do you use Obsidian to read your notes?"
|
|
1114
|
+
),
|
|
1115
|
+
nightly: str = typer.Option(
|
|
1116
|
+
None, "--nightly", help="Run every night at HH:MM (installs the job on macOS), or `none`."
|
|
1117
|
+
),
|
|
1118
|
+
remote: str = typer.Option(None, "--remote", help="A git remote to back the vault up to."),
|
|
1119
|
+
ocr: bool = typer.Option(
|
|
1120
|
+
False,
|
|
1121
|
+
"--ocr/--no-ocr",
|
|
1122
|
+
help="Also read images and scanned PDFs, with a local Ollama vision model (about 2 GB).",
|
|
1123
|
+
),
|
|
1124
|
+
) -> None:
|
|
1125
|
+
"""First-time setup: create the vault, a config file, and (asking) the optional pieces.
|
|
1126
|
+
|
|
1127
|
+
The basics are adding PDFs and links. Obsidian, the nightly job, a backup remote, Gmail and the
|
|
1128
|
+
Web Clipper are all optional. Safe to run again: it only creates what is missing."""
|
|
1129
|
+
ask_user = _interactive()
|
|
1130
|
+
if ask_user:
|
|
1131
|
+
typer.echo(
|
|
1132
|
+
"esbi-cli turns the PDFs and links you save into a wiki of notes.\n"
|
|
1133
|
+
"That is all you need. Every question below is optional or has a default.\n"
|
|
1134
|
+
)
|
|
1135
|
+
root = Path(
|
|
1136
|
+
vault or typer.prompt("Vault folder", default="~/Documents/Obsidian/esbi")
|
|
1137
|
+
if ask_user
|
|
1138
|
+
else vault or "~/Documents/Obsidian/esbi"
|
|
1139
|
+
).expanduser()
|
|
1140
|
+
if language is None:
|
|
1141
|
+
codes = list(lang.LANGUAGES)
|
|
1142
|
+
language = (
|
|
1143
|
+
codes[
|
|
1144
|
+
_choose(
|
|
1145
|
+
"In which language should the notes be written? (The CLI itself is English.)",
|
|
1146
|
+
[lang.name(c) for c in codes],
|
|
1147
|
+
)
|
|
1148
|
+
- 1
|
|
1149
|
+
]
|
|
1150
|
+
if ask_user
|
|
1151
|
+
else lang.DEFAULT
|
|
1152
|
+
)
|
|
1153
|
+
if language not in lang.LANGUAGES:
|
|
1154
|
+
typer.secho(f"error: --language must be one of: {lang.supported()}", fg="red", err=True)
|
|
1155
|
+
raise typer.Exit(1)
|
|
1156
|
+
if obsidian is None:
|
|
1157
|
+
obsidian = (
|
|
1158
|
+
typer.confirm(
|
|
1159
|
+
"Do you use Obsidian to read your notes? (Any Markdown editor also works)",
|
|
1160
|
+
default=False,
|
|
1161
|
+
)
|
|
1162
|
+
if ask_user
|
|
1163
|
+
else True
|
|
1164
|
+
)
|
|
1165
|
+
if model is None:
|
|
1166
|
+
model = (
|
|
1167
|
+
["local", "subscription", "api"][
|
|
1168
|
+
_choose(
|
|
1169
|
+
"How should the notes be written?",
|
|
1170
|
+
[
|
|
1171
|
+
"On this Mac with a local model (nothing leaves it)",
|
|
1172
|
+
"With your Claude subscription (source text goes to Anthropic; email stays local)",
|
|
1173
|
+
"With an API key (source text goes to the provider; email stays local)",
|
|
1174
|
+
],
|
|
1175
|
+
)
|
|
1176
|
+
- 1
|
|
1177
|
+
]
|
|
1178
|
+
if ask_user
|
|
1179
|
+
else "local"
|
|
1180
|
+
)
|
|
1181
|
+
if model not in MODELS:
|
|
1182
|
+
typer.secho("error: --model must be local, subscription or api", fg="red", err=True)
|
|
1183
|
+
raise typer.Exit(1)
|
|
1184
|
+
if runtime is None:
|
|
1185
|
+
runtime = (
|
|
1186
|
+
RUNTIMES[
|
|
1187
|
+
_choose(
|
|
1188
|
+
"Which local runtime? (also used as the failsafe for the other choices)",
|
|
1189
|
+
["Ollama (ollama.com)", "LM Studio (lmstudio.ai)"],
|
|
1190
|
+
)
|
|
1191
|
+
- 1
|
|
1192
|
+
]
|
|
1193
|
+
if ask_user
|
|
1194
|
+
else "ollama"
|
|
1195
|
+
)
|
|
1196
|
+
if runtime not in RUNTIMES:
|
|
1197
|
+
typer.secho("error: --runtime must be ollama or lmstudio", fg="red", err=True)
|
|
1198
|
+
raise typer.Exit(1)
|
|
1199
|
+
if runtime == "lmstudio" and not local_name:
|
|
1200
|
+
local_name = (
|
|
1201
|
+
typer.prompt("Model identifier as LM Studio shows it (`lms ls`)") if ask_user else None
|
|
1202
|
+
)
|
|
1203
|
+
if not local_name:
|
|
1204
|
+
typer.secho(
|
|
1205
|
+
"error: LM Studio needs --local-model NAME (see `lms ls`)", fg="red", err=True
|
|
1206
|
+
)
|
|
1207
|
+
raise typer.Exit(1)
|
|
1208
|
+
if ocr and runtime != "ollama":
|
|
1209
|
+
typer.secho("error: --ocr needs Ollama (--runtime ollama)", fg="red", err=True)
|
|
1210
|
+
raise typer.Exit(1)
|
|
1211
|
+
if base_url is None and ask_user and model == "local":
|
|
1212
|
+
base_url = typer.prompt("Server address (Enter if it runs on this Mac)", default="") or None
|
|
1213
|
+
if base_url and (model != "local" or not urlparse(base_url).hostname):
|
|
1214
|
+
typer.secho(
|
|
1215
|
+
"error: --base-url needs an http(s) address and goes with --model local",
|
|
1216
|
+
fg="red",
|
|
1217
|
+
err=True,
|
|
1218
|
+
)
|
|
1219
|
+
raise typer.Exit(1)
|
|
1220
|
+
if nightly is None and ask_user:
|
|
1221
|
+
nightly = (
|
|
1222
|
+
typer.prompt("At what time? (HH:MM, 24 hours)", default="03:00")
|
|
1223
|
+
if typer.confirm("Run automatically every night?", default=False)
|
|
1224
|
+
else "none"
|
|
1225
|
+
)
|
|
1226
|
+
install_job = nightly not in (None, "none")
|
|
1227
|
+
if install_job:
|
|
1228
|
+
try:
|
|
1229
|
+
parse_time(nightly)
|
|
1230
|
+
except ValueError as exc:
|
|
1231
|
+
typer.secho(f"error: {exc}", fg="red", err=True)
|
|
1232
|
+
raise typer.Exit(1) from exc
|
|
1233
|
+
if remote is None and ask_user and has_git():
|
|
1234
|
+
remote = (
|
|
1235
|
+
typer.prompt("Git remote to back the vault up to (Enter to skip)", default="") or None
|
|
1236
|
+
)
|
|
1237
|
+
|
|
1238
|
+
config_file = config_file.expanduser()
|
|
1239
|
+
made = init_vault(root, language)
|
|
1240
|
+
wrote = write_config(
|
|
1241
|
+
config_file,
|
|
1242
|
+
root.resolve(),
|
|
1243
|
+
model,
|
|
1244
|
+
viewer="obsidian" if obsidian else "none",
|
|
1245
|
+
nightly=nightly if install_job else None,
|
|
1246
|
+
runtime=runtime,
|
|
1247
|
+
local_name=local_name,
|
|
1248
|
+
ocr=ocr,
|
|
1249
|
+
base_url=base_url,
|
|
1250
|
+
language=language,
|
|
1251
|
+
)
|
|
1252
|
+
typer.echo(f"Vault {root}: " + (", ".join(made) if made else "nothing to create"))
|
|
1253
|
+
typer.echo(f"Config {config_file}: " + ("written" if wrote else "already there, left alone"))
|
|
1254
|
+
typer.echo(f"Notes language: {language} ({lang.name(language)}). The CLI itself is English.")
|
|
1255
|
+
typer.echo(f"Model: {model}. {MODELS[model]}")
|
|
1256
|
+
if base_url and (host := remote_host(local_model(runtime, local_name), base_url)):
|
|
1257
|
+
typer.secho(
|
|
1258
|
+
f"warning: {remote_warning(local_model(runtime, local_name), host)}",
|
|
1259
|
+
fg="yellow",
|
|
1260
|
+
err=True,
|
|
1261
|
+
)
|
|
1262
|
+
if not (root / ".git").exists():
|
|
1263
|
+
typer.echo(
|
|
1264
|
+
"History: git is not installed, so the vault has no version history. That is optional; "
|
|
1265
|
+
"install git and run `sb init` again to turn it on."
|
|
1266
|
+
)
|
|
1267
|
+
if remote and not (root / ".git").exists():
|
|
1268
|
+
typer.echo("Backup remote skipped: it needs git.")
|
|
1269
|
+
elif remote:
|
|
1270
|
+
added = connect_remote(root, remote)
|
|
1271
|
+
typer.echo(
|
|
1272
|
+
f"Backup remote: {remote}. Everything in the vault is pushed there, including notes "
|
|
1273
|
+
"made from email: use a private repository."
|
|
1274
|
+
if added
|
|
1275
|
+
else "The vault already has a remote: left alone."
|
|
1276
|
+
)
|
|
1277
|
+
if install_job and wrote:
|
|
1278
|
+
if sys.platform == "darwin":
|
|
1279
|
+
try:
|
|
1280
|
+
path = _install_job(config_file.resolve(), AGENTS_DIR)
|
|
1281
|
+
typer.echo(f"Nightly job installed ({path.name}): runs every day at {nightly}.")
|
|
1282
|
+
except launchd.ScheduleError as exc:
|
|
1283
|
+
typer.secho(f"warning: nightly job not installed: {exc}", fg="yellow", err=True)
|
|
1284
|
+
else:
|
|
1285
|
+
typer.echo(
|
|
1286
|
+
"Nightly job: this system has no launchd. Add this to cron (hourly is fine, it runs once a day):\n"
|
|
1287
|
+
f" {launchd.cron_line(config_file)}"
|
|
1288
|
+
)
|
|
1289
|
+
if ask_user:
|
|
1290
|
+
_offer_integrations(config_file.resolve(), obsidian)
|
|
1291
|
+
steps = []
|
|
1292
|
+
if runtime == "ollama" and model != "api":
|
|
1293
|
+
steps.append("ollama pull llama3.2")
|
|
1294
|
+
if ocr:
|
|
1295
|
+
steps.append(f"ollama pull {OCR_MODEL}")
|
|
1296
|
+
steps.append("sb doctor")
|
|
1297
|
+
typer.echo(
|
|
1298
|
+
f"\nNext: {', then '.join(steps)}. Add a link with `sb add URL` or drop a PDF in the "
|
|
1299
|
+
"vault's inbox/ folder."
|
|
1300
|
+
)
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _offer_integrations(config: Path, obsidian: bool) -> None:
|
|
1304
|
+
"""The two optional connections, explained, never required."""
|
|
1305
|
+
typer.echo("\nOptional extras (skip both and the app works the same for PDFs and links):")
|
|
1306
|
+
if typer.confirm(" Connect Gmail, to forward an email and get a note?", default=False):
|
|
1307
|
+
_run_wizard("email.sh", config)
|
|
1308
|
+
if obsidian:
|
|
1309
|
+
if typer.confirm(
|
|
1310
|
+
" Set up the Obsidian Web Clipper, to save web pages and YouTube transcripts?",
|
|
1311
|
+
default=False,
|
|
1312
|
+
):
|
|
1313
|
+
_run_wizard("clipper.sh", config)
|
|
1314
|
+
else:
|
|
1315
|
+
typer.echo(
|
|
1316
|
+
" The Web Clipper needs Obsidian: skipped. (`sb add URL` and the inbox/ folder still work.)"
|
|
1317
|
+
)
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
@app.command()
|
|
1321
|
+
def reingest(
|
|
1322
|
+
titles: list[str] = typer.Argument(
|
|
1323
|
+
None, help="Rebuild only notes whose title contains one of these words (any format)."
|
|
1324
|
+
),
|
|
1325
|
+
config: Path | None = CONFIG_OPTION,
|
|
1326
|
+
all_sources: bool = typer.Option(
|
|
1327
|
+
False, "--all", help="Rebuild every note, even those already in the current format."
|
|
1328
|
+
),
|
|
1329
|
+
) -> None:
|
|
1330
|
+
"""Rebuild the notes from their saved raw sources with the current, richer pipeline.
|
|
1331
|
+
|
|
1332
|
+
Read ticks are kept and the vault is tagged in git first (`git reset --hard <tag>` undoes it).
|
|
1333
|
+
Notes are marked when rebuilt, so an interrupted run resumes where it stopped.
|
|
1334
|
+
"""
|
|
1335
|
+
cfg = _load(config)
|
|
1336
|
+
if not (cfg.vault / ".git").exists():
|
|
1337
|
+
typer.secho(
|
|
1338
|
+
"error: reingest rewrites every note and relies on the vault's git history to undo it; "
|
|
1339
|
+
"install git and run `sb init` first.",
|
|
1340
|
+
fg=typer.colors.RED,
|
|
1341
|
+
err=True,
|
|
1342
|
+
)
|
|
1343
|
+
raise typer.Exit(1)
|
|
1344
|
+
try:
|
|
1345
|
+
llm, synth, private = _writers(cfg)
|
|
1346
|
+
except (KeyError, ValueError) as exc:
|
|
1347
|
+
typer.secho(f"error: {exc}", fg=typer.colors.RED, err=True)
|
|
1348
|
+
raise typer.Exit(1) from exc
|
|
1349
|
+
try:
|
|
1350
|
+
with exit_when_interrupted(), RunLock(cfg.vault / ".esbi" / "run.lock"):
|
|
1351
|
+
result = reingest_all(
|
|
1352
|
+
_vault(cfg),
|
|
1353
|
+
llm,
|
|
1354
|
+
synth,
|
|
1355
|
+
cfg,
|
|
1356
|
+
private_llm=private,
|
|
1357
|
+
all_sources=all_sources,
|
|
1358
|
+
only=titles,
|
|
1359
|
+
on_progress=typer.echo,
|
|
1360
|
+
)
|
|
1361
|
+
except LockBusy:
|
|
1362
|
+
typer.echo("Another run is in progress; try again when it finishes.")
|
|
1363
|
+
raise typer.Exit(1) from None
|
|
1364
|
+
if problem := push_vault(cfg.vault):
|
|
1365
|
+
typer.secho(
|
|
1366
|
+
f"warning: vault backup not pushed: {problem}", fg=typer.colors.YELLOW, err=True
|
|
1367
|
+
)
|
|
1368
|
+
if result.tag:
|
|
1369
|
+
typer.echo(
|
|
1370
|
+
f"Vault tagged {result.tag} (undo: git -C {cfg.vault} reset --hard {result.tag})"
|
|
1371
|
+
)
|
|
1372
|
+
typer.echo(
|
|
1373
|
+
f"rebuilt: {len(result.done)}, skipped: {len(result.skipped)}, failed: {len(result.failed)}"
|
|
1374
|
+
)
|
|
1375
|
+
for title, why in [*result.skipped, *result.failed, *result.warnings]:
|
|
1376
|
+
typer.secho(f" {title}: {why}", fg=typer.colors.YELLOW, err=True)
|
|
1377
|
+
if result.stopped:
|
|
1378
|
+
typer.secho(
|
|
1379
|
+
"The LLM is unreachable (is Ollama running?). Nothing was lost: run `sb reingest` again to resume.",
|
|
1380
|
+
fg=typer.colors.RED,
|
|
1381
|
+
err=True,
|
|
1382
|
+
)
|
|
1383
|
+
raise typer.Exit(1)
|
|
1384
|
+
|
|
1385
|
+
|
|
1386
|
+
if __name__ == "__main__":
|
|
1387
|
+
app()
|