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.
Files changed (72) hide show
  1. esbi_cli/__init__.py +8 -0
  2. esbi_cli/ask/__init__.py +0 -0
  3. esbi_cli/ask/answer.py +256 -0
  4. esbi_cli/bench/__init__.py +0 -0
  5. esbi_cli/bench/cases.py +57 -0
  6. esbi_cli/bench/metrics.py +23 -0
  7. esbi_cli/bench/report.py +117 -0
  8. esbi_cli/bench/runner.py +114 -0
  9. esbi_cli/capture/__init__.py +0 -0
  10. esbi_cli/capture/inbox.py +63 -0
  11. esbi_cli/capture/legacy.py +49 -0
  12. esbi_cli/cli.py +1387 -0
  13. esbi_cli/config.py +344 -0
  14. esbi_cli/doctor.py +391 -0
  15. esbi_cli/evaluate.py +91 -0
  16. esbi_cli/export.py +137 -0
  17. esbi_cli/extract/__init__.py +107 -0
  18. esbi_cli/extract/clip.py +30 -0
  19. esbi_cli/extract/html.py +60 -0
  20. esbi_cli/extract/image.py +58 -0
  21. esbi_cli/extract/pdf.py +109 -0
  22. esbi_cli/gitops.py +101 -0
  23. esbi_cli/index.py +303 -0
  24. esbi_cli/ingest/__init__.py +0 -0
  25. esbi_cli/ingest/apply.py +480 -0
  26. esbi_cli/ingest/chunks.py +49 -0
  27. esbi_cli/ingest/connect.py +87 -0
  28. esbi_cli/ingest/digest.py +91 -0
  29. esbi_cli/ingest/pipeline.py +176 -0
  30. esbi_cli/ingest/plan.py +231 -0
  31. esbi_cli/ingest/read.py +105 -0
  32. esbi_cli/ingest/retrieve.py +59 -0
  33. esbi_cli/init.py +176 -0
  34. esbi_cli/interrupts.py +90 -0
  35. esbi_cli/lang.py +341 -0
  36. esbi_cli/links.py +10 -0
  37. esbi_cli/lint/__init__.py +0 -0
  38. esbi_cli/lint/checks.py +178 -0
  39. esbi_cli/lint/report.py +60 -0
  40. esbi_cli/llm/__init__.py +0 -0
  41. esbi_cli/llm/adapter.py +393 -0
  42. esbi_cli/llm/schemas.py +146 -0
  43. esbi_cli/mail/__init__.py +0 -0
  44. esbi_cli/mail/convert.py +194 -0
  45. esbi_cli/mail/credentials.py +65 -0
  46. esbi_cli/mail/fetch.py +154 -0
  47. esbi_cli/mail/imap.py +92 -0
  48. esbi_cli/netguard.py +127 -0
  49. esbi_cli/privacy.py +81 -0
  50. esbi_cli/queue.py +179 -0
  51. esbi_cli/reingest.py +165 -0
  52. esbi_cli/report/__init__.py +0 -0
  53. esbi_cli/report/daily_index.py +235 -0
  54. esbi_cli/report/index_md.py +21 -0
  55. esbi_cli/report/readstate.py +26 -0
  56. esbi_cli/run.py +100 -0
  57. esbi_cli/runlock.py +31 -0
  58. esbi_cli/runlog.py +80 -0
  59. esbi_cli/schedule.py +106 -0
  60. esbi_cli/templates/SCHEMA.md +52 -0
  61. esbi_cli/templates/clipper-template.json +17 -0
  62. esbi_cli/templates/clipper-youtube-template.json +18 -0
  63. esbi_cli/templates/config.example.toml +108 -0
  64. esbi_cli/update.py +247 -0
  65. esbi_cli/vault.py +188 -0
  66. esbi_cli/wizards/clipper.sh +271 -0
  67. esbi_cli/wizards/email.sh +265 -0
  68. esbi_cli-0.2.1.dist-info/METADATA +167 -0
  69. esbi_cli-0.2.1.dist-info/RECORD +72 -0
  70. esbi_cli-0.2.1.dist-info/WHEEL +4 -0
  71. esbi_cli-0.2.1.dist-info/entry_points.txt +3 -0
  72. esbi_cli-0.2.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,235 @@
1
+ """The daily index note: where the day starts. Regenerated from state on every run."""
2
+
3
+ import re
4
+ from datetime import date
5
+ from pathlib import Path
6
+
7
+ from esbi_cli import lang
8
+ from esbi_cli.extract import is_url
9
+ from esbi_cli.links import link_targets
10
+ from esbi_cli.queue import Queue, normalize_target
11
+ from esbi_cli.runlog import RunLog
12
+ from esbi_cli.vault import Page, Vault, fold
13
+
14
+
15
+ def _sources_where(vault: Vault, key: str, today: date) -> list[Page]:
16
+ found = [p for p in vault.iter_pages(("sources",)) if p.meta.get(key) == today.isoformat()]
17
+ return sorted(found, key=lambda p: fold(p.title))
18
+
19
+
20
+ def _read_section(vault: Vault, today: date) -> list[str]:
21
+ pages = _sources_where(vault, "read", today)
22
+ if not pages:
23
+ return [lang.t(vault.language, "nothing_read")]
24
+ return [f"- [[{p.title}]] — {p.meta.get('summary', '')}" for p in pages]
25
+
26
+
27
+ def _processed_section(vault: Vault, today: date) -> list[str]:
28
+ lines = []
29
+ for page in _sources_where(vault, "processed", today):
30
+ box = "x" if page.meta.get("status") == "read" else " "
31
+ lines.append(f"- [{box}] [[{page.title}]] — {page.meta.get('summary', '')}")
32
+ return lines or [lang.t(vault.language, "nothing_processed")]
33
+
34
+
35
+ QUEUE_LIST_LIMIT = 20
36
+
37
+
38
+ def _short(target: str) -> str:
39
+ return target if is_url(target) else Path(target).name
40
+
41
+
42
+ def _name(item) -> str:
43
+ """The most readable name we have: the label (clip title), else the URL or file name."""
44
+ return item.label or _short(item.target)
45
+
46
+
47
+ def _pending(vault: Vault, queue: Queue) -> list:
48
+ """Queued items not already in the wiki: those are skipped at run time with no model call."""
49
+ known = {
50
+ normalize_target(str(p.meta["url"]))
51
+ for p in vault.iter_pages(("sources",))
52
+ if p.meta.get("url")
53
+ }
54
+ return [i for i in queue.items("queued") if normalize_target(i.target) not in known]
55
+
56
+
57
+ def _queue_section(vault: Vault, queue: Queue) -> list[str]:
58
+ items = _pending(vault, queue)
59
+ L = vault.language
60
+ if not items:
61
+ return [lang.t(L, "queue_empty")]
62
+ total = len(items)
63
+ header = lang.t(L, "queue_one" if total == 1 else "queue_many", n=total)
64
+ if total > QUEUE_LIST_LIMIT:
65
+ header += lang.t(L, "queue_cap", n=QUEUE_LIST_LIMIT)
66
+ names = [_name(i) for i in items]
67
+ return [header + ".", *(f"- {n}" for n in names[:QUEUE_LIST_LIMIT])]
68
+
69
+
70
+ def _review_section(vault: Vault, queue: Queue) -> list[str]:
71
+ notes = sorted((vault.wiki / "review").glob("*.md"))
72
+ lines = [f"- [[{n.stem}]]" for n in notes]
73
+ L = vault.language
74
+ lines += [
75
+ lang.t(L, "could_not_process", name=_name(i), error=i.error) for i in queue.items("failed")
76
+ ]
77
+ lines += [
78
+ lang.t(L, "retrying", name=_name(i), n=i.attempts, max=queue.max_attempts, error=i.error)
79
+ for i in queue.items("queued")
80
+ if i.attempts
81
+ ]
82
+ return lines or [lang.t(L, "nothing_pending")]
83
+
84
+
85
+ REVISIT_COUNT = 3
86
+
87
+
88
+ def _revisit_section(vault: Vault, today: date) -> list[str]:
89
+ """A few older pages, rotating with the date so each day surfaces different ones."""
90
+ older = [
91
+ p
92
+ for p in vault.iter_pages()
93
+ if str(p.meta.get("processed") or p.meta.get("updated") or "") < today.isoformat()
94
+ ]
95
+ if not older:
96
+ return [lang.t(vault.language, "no_old_notes")]
97
+ older.sort(key=lambda p: (p.kind, fold(p.title)))
98
+ start = (today.toordinal() * REVISIT_COUNT) % len(older)
99
+ picked = [older[(start + k) % len(older)] for k in range(min(REVISIT_COUNT, len(older)))]
100
+ return [f"- [[{p.title}]] — {p.meta.get('summary', '')}" for p in picked]
101
+
102
+
103
+ def _orphans(pages: list[Page]) -> int:
104
+ """Pages that no other page links to."""
105
+ linked: set[str] = set()
106
+ for page in pages:
107
+ own = fold(page.title)
108
+ linked |= {fold(t) for t in link_targets(page.body)} - {own}
109
+ return sum(fold(p.title) not in linked for p in pages)
110
+
111
+
112
+ def _stats_section(vault: Vault, today: date) -> list[str]:
113
+ pages = list(vault.iter_pages())
114
+ by_kind = {k: [p for p in pages if p.kind == k] for k in ("sources", "concepts", "entities")}
115
+ sources = by_kind["sources"]
116
+ day = today.isoformat()
117
+ touched = [p for p in by_kind["concepts"] + by_kind["entities"] if p.meta.get("updated") == day]
118
+ read = sum(p.meta.get("status") == "read" for p in sources)
119
+ L = vault.language
120
+ return [
121
+ lang.t(
122
+ L,
123
+ "stat_pages",
124
+ total=len(pages),
125
+ sources=len(sources),
126
+ concepts=len(by_kind["concepts"]),
127
+ entities=len(by_kind["entities"]),
128
+ ),
129
+ lang.t(
130
+ L,
131
+ "stat_today",
132
+ processed=sum(p.meta.get("processed") == day for p in sources),
133
+ touched=len(touched),
134
+ ),
135
+ lang.t(L, "stat_read", read=read, total=len(sources)),
136
+ lang.t(L, "stat_orphans", n=_orphans(pages)),
137
+ ]
138
+
139
+
140
+ STOP_REASONS = { # RunRecord.stopped_by -> label key
141
+ "max_sources": "stop_max_sources",
142
+ "token_budget": "stop_token_budget",
143
+ "llm_unavailable": "stop_llm_unavailable",
144
+ }
145
+
146
+
147
+ def _runs_section(vault: Vault, today: date) -> list[str]:
148
+ runs = [
149
+ r for r in RunLog(vault.root / ".esbi" / "runs.jsonl").runs() if r.started.date() == today
150
+ ]
151
+ L = vault.language
152
+ if not runs:
153
+ return [lang.t(L, "no_runs")]
154
+ lines = []
155
+ for run in runs:
156
+ minutes = int((run.finished - run.started).total_seconds() // 60)
157
+ line = lang.t(
158
+ L,
159
+ "run_line",
160
+ at=f"{run.started:%H:%M}",
161
+ manual=lang.t(L, "run_manual") if run.trigger == "manual" else "",
162
+ ingested=run.ingested,
163
+ skipped=run.skipped,
164
+ failed=run.failed,
165
+ tokens=run.tokens_used,
166
+ duration=lang.t(L, "run_minutes", n=minutes)
167
+ if minutes
168
+ else lang.t(L, "run_under_minute"),
169
+ )
170
+ if run.stopped_by:
171
+ key = STOP_REASONS.get(run.stopped_by)
172
+ line += lang.t(L, "run_stopped", reason=lang.t(L, key) if key else run.stopped_by)
173
+ lines.append(line)
174
+ return lines
175
+
176
+
177
+ HOME_START, HOME_END = "<!-- esbi:start -->", "<!-- esbi:end -->"
178
+
179
+
180
+ def _update_home(vault: Vault, queue: Queue, today: date) -> None:
181
+ """Rewrite only the worker-managed block of Home.md; the rest of the page stays the user's."""
182
+ unread = sum(p.meta.get("status") == "processed" for p in vault.iter_pages(("sources",)))
183
+ L = vault.language
184
+ block = "\n".join(
185
+ [
186
+ HOME_START,
187
+ lang.t(L, "home_today", link=f"[[{today.isoformat()}]]"),
188
+ lang.t(L, "home_unread", n=unread),
189
+ lang.t(L, "home_queue", n=len(_pending(vault, queue))),
190
+ HOME_END,
191
+ ]
192
+ )
193
+ home = vault.root / "Home.md"
194
+ text = home.read_text(encoding="utf-8") if home.exists() else "# esbi-cli\n"
195
+ text = text.replace("<!-- second-brain:start -->", HOME_START).replace( # legacy markers
196
+ "<!-- second-brain:end -->", HOME_END
197
+ )
198
+ pattern = re.compile(re.escape(HOME_START) + r".*?" + re.escape(HOME_END), re.DOTALL)
199
+ if pattern.search(text):
200
+ text = pattern.sub(lambda _: block, text)
201
+ else:
202
+ text = text.rstrip("\n") + "\n\n" + block + "\n"
203
+ home.write_text(text, encoding="utf-8")
204
+
205
+
206
+ def build_daily_index(vault: Vault, queue: Queue, today: date) -> Path:
207
+ L = vault.language
208
+ body = [
209
+ f"# {lang.t(L, 'daily_title', date=today.isoformat())}",
210
+ "",
211
+ f"## {lang.t(L, 'daily_read')}",
212
+ *_read_section(vault, today),
213
+ "",
214
+ f"## {lang.t(L, 'daily_processed')}",
215
+ *_processed_section(vault, today),
216
+ "",
217
+ f"## {lang.t(L, 'daily_queue')}",
218
+ *_queue_section(vault, queue),
219
+ "",
220
+ f"## {lang.t(L, 'daily_review')}",
221
+ *_review_section(vault, queue),
222
+ "",
223
+ f"## {lang.t(L, 'daily_revisit')}",
224
+ *_revisit_section(vault, today),
225
+ "",
226
+ f"## {lang.t(L, 'daily_runs')}",
227
+ *_runs_section(vault, today),
228
+ "",
229
+ f"## {lang.t(L, 'daily_stats')}",
230
+ *_stats_section(vault, today),
231
+ ]
232
+ path = vault.wiki / "daily" / f"{today.isoformat()}.md"
233
+ vault.write_page(Page(path, {"type": "daily", "date": today.isoformat()}, "\n".join(body)))
234
+ _update_home(vault, queue, today)
235
+ return path
@@ -0,0 +1,21 @@
1
+ """Rebuild index.md deterministically from page frontmatter (no LLM needed)."""
2
+
3
+ from esbi_cli import lang
4
+ from esbi_cli.vault import Vault, fold
5
+
6
+ SECTIONS = ("sources", "concepts", "entities", "syntheses") # page folders, and their label keys
7
+
8
+
9
+ def rebuild_index(vault: Vault) -> None:
10
+ by_kind: dict[str, list[tuple[str, str]]] = {kind: [] for kind in SECTIONS}
11
+ for page in vault.iter_pages():
12
+ by_kind[page.kind].append((page.title, str(page.meta.get("summary") or "")))
13
+ total = sum(len(v) for v in by_kind.values())
14
+ L = vault.language
15
+ lines = [f"# {lang.t(L, 'index_title')}", "", lang.t(L, "index_blurb", total=total), ""]
16
+ for kind in SECTIONS:
17
+ lines += [f"## {lang.t(L, kind)} ({len(by_kind[kind])})", ""]
18
+ for title, summary in sorted(by_kind[kind], key=lambda t: fold(t[0])):
19
+ lines.append(f"- [[{title}]]" + (f" — {summary}" if summary else ""))
20
+ lines.append("")
21
+ (vault.root / "index.md").write_text("\n".join(lines), encoding="utf-8")
@@ -0,0 +1,26 @@
1
+ """Read tracking: ticked checkboxes in the daily index notes mark sources as read."""
2
+
3
+ import re
4
+ from datetime import date
5
+
6
+ from esbi_cli.vault import Vault
7
+
8
+ _TICKED = re.compile(r"^\s*- \[[xX]\] \[\[([^\]|#]+)", re.MULTILINE)
9
+
10
+
11
+ def sync_read_state(vault: Vault, today: date) -> list[str]:
12
+ """Flip ticked sources from processed to read. Returns the titles newly marked read."""
13
+ ticked: list[str] = []
14
+ for note in sorted((vault.wiki / "daily").glob("*.md")):
15
+ ticked += _TICKED.findall(note.read_text(encoding="utf-8"))
16
+
17
+ newly_read: list[str] = []
18
+ for title in dict.fromkeys(t.strip() for t in ticked):
19
+ page = vault.find_page(title, ("sources",))
20
+ if page is None or page.meta.get("status") != "processed":
21
+ continue
22
+ page.meta["status"] = "read"
23
+ page.meta["read"] = today.isoformat()
24
+ vault.write_page(page)
25
+ newly_read.append(page.title)
26
+ return newly_read
esbi_cli/run.py ADDED
@@ -0,0 +1,100 @@
1
+ """Drain the ingest queue within limits, isolating failures per item."""
2
+
3
+ from collections.abc import Callable
4
+ from dataclasses import dataclass, field
5
+
6
+ from esbi_cli.ingest.pipeline import IngestResult
7
+ from esbi_cli.interrupts import Interrupted, handling, interruptible
8
+ from esbi_cli.llm.adapter import LLMError, LLMTimeout
9
+ from esbi_cli.queue import Queue
10
+
11
+
12
+ @dataclass
13
+ class RunLimits:
14
+ max_sources: int
15
+ max_tokens: int | None = None
16
+
17
+
18
+ @dataclass
19
+ class Failure:
20
+ name: str # the label (clip title, file name) or the target
21
+ attempt: int # which attempt this was
22
+ error: str
23
+ parked: bool # out of attempts: it waits for `sb retry`
24
+
25
+
26
+ @dataclass
27
+ class RunSummary:
28
+ ingested: int = 0
29
+ skipped: int = 0
30
+ failed: int = 0
31
+ failures: list[Failure] = field(default_factory=list)
32
+ tokens_used: int = 0
33
+ # "max_sources" | "token_budget" | "llm_unavailable" | "interrupted" | None (queue drained)
34
+ stopped_by: str | None = None
35
+ signum: int | None = None # set when stopped_by == "interrupted"
36
+ released: int = 0 # items put back in the queue by an interruption
37
+
38
+
39
+ def run_queue(
40
+ queue: Queue,
41
+ ingest_fn: Callable[[str], IngestResult],
42
+ limits: RunLimits,
43
+ tokens_used: Callable[[], int] = lambda: 0,
44
+ ) -> RunSummary:
45
+ summary = RunSummary()
46
+ start_tokens = tokens_used()
47
+ tried: set[int] = set() # a failed item is requeued, but must wait for the next run
48
+ item = None
49
+ with handling():
50
+ try:
51
+ while True:
52
+ with interruptible(): # a signal that came between two items stops the run here
53
+ pass
54
+ claimed = queue.claim(1, exclude=tried)
55
+ if not claimed:
56
+ break
57
+ item = claimed[0]
58
+ if len(tried) >= limits.max_sources:
59
+ queue.release(item.id)
60
+ summary.stopped_by = "max_sources"
61
+ break
62
+ if (
63
+ limits.max_tokens is not None
64
+ and tokens_used() - start_tokens >= limits.max_tokens
65
+ ):
66
+ queue.release(item.id)
67
+ summary.stopped_by = "token_budget"
68
+ break
69
+ tried.add(item.id)
70
+ try:
71
+ with interruptible():
72
+ result = ingest_fn(item.target)
73
+ except Exception as exc: # one bad source must not stop the nightly batch
74
+ if isinstance(exc, LLMError) and not isinstance(exc, LLMTimeout):
75
+ # the model is the problem, not this source: keep it fresh and stop
76
+ queue.release(item.id)
77
+ summary.stopped_by = "llm_unavailable"
78
+ break
79
+ error = f"{type(exc).__name__}: {exc}"
80
+ queue.fail(item.id, error)
81
+ attempt = item.attempts + 1
82
+ summary.failures.append(
83
+ Failure(
84
+ item.label or item.target, attempt, error, attempt >= queue.max_attempts
85
+ )
86
+ )
87
+ summary.failed += 1
88
+ continue
89
+ queue.complete(item.id)
90
+ if result.status == "skipped":
91
+ summary.skipped += 1
92
+ else:
93
+ summary.ingested += 1
94
+ except Interrupted as exc:
95
+ # like an outage: the item goes back untouched. Release only affects a row still
96
+ # `processing`, so a finished item is not undone.
97
+ summary.stopped_by, summary.signum = "interrupted", exc.signum
98
+ summary.released = int(item is not None and queue.release(item.id))
99
+ summary.tokens_used = tokens_used() - start_tokens
100
+ return summary
esbi_cli/runlock.py ADDED
@@ -0,0 +1,31 @@
1
+ """Single-run guard: an OS-level file lock that dies with the process, so it can never get stuck."""
2
+
3
+ import fcntl
4
+ from pathlib import Path
5
+
6
+
7
+ class LockBusy(RuntimeError):
8
+ pass
9
+
10
+
11
+ class RunLock:
12
+ def __init__(self, path: Path):
13
+ self.path = path
14
+ self._file = None
15
+
16
+ def __enter__(self) -> "RunLock":
17
+ self.path.parent.mkdir(parents=True, exist_ok=True)
18
+ file = self.path.open("a")
19
+ try:
20
+ fcntl.flock(file, fcntl.LOCK_EX | fcntl.LOCK_NB)
21
+ except BlockingIOError:
22
+ file.close()
23
+ raise LockBusy(f"another run holds {self.path}") from None
24
+ self._file = file
25
+ return self
26
+
27
+ def __exit__(self, *exc_info) -> None:
28
+ if self._file:
29
+ fcntl.flock(self._file, fcntl.LOCK_UN)
30
+ self._file.close()
31
+ self._file = None
esbi_cli/runlog.py ADDED
@@ -0,0 +1,80 @@
1
+ """Append-only record of every `sb run`: feeds the daily index and the "is the nightly due?" check."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import datetime, timedelta
6
+ from pathlib import Path
7
+
8
+
9
+ @dataclass
10
+ class RunRecord:
11
+ started: datetime
12
+ finished: datetime
13
+ ingested: int
14
+ skipped: int
15
+ failed: int
16
+ tokens_used: int
17
+ stopped_by: str | None # None = queue drained; see run.RunSummary
18
+ trigger: str # "scheduled" (sb run --if-due) or "manual"
19
+
20
+
21
+ class RunLog:
22
+ def __init__(self, path: Path, keep: int = 500):
23
+ self.path, self.keep = path, keep # older runs are dropped: only recent ones are ever read
24
+
25
+ def record(self, run: RunRecord) -> None:
26
+ self.path.parent.mkdir(parents=True, exist_ok=True)
27
+ row = {
28
+ **asdict(run),
29
+ "started": run.started.isoformat(),
30
+ "finished": run.finished.isoformat(),
31
+ }
32
+ with self.path.open("a", encoding="utf-8") as fh:
33
+ fh.write(json.dumps(row) + "\n")
34
+ lines = self.path.read_text(encoding="utf-8").splitlines()
35
+ if len(lines) > self.keep:
36
+ self.path.write_text("\n".join(lines[-self.keep :]) + "\n", encoding="utf-8")
37
+
38
+ def runs(self) -> list[RunRecord]:
39
+ if not self.path.exists():
40
+ return []
41
+ rows = [
42
+ json.loads(line) for line in self.path.read_text(encoding="utf-8").splitlines() if line
43
+ ]
44
+ return [
45
+ RunRecord(
46
+ **{
47
+ **row,
48
+ "started": datetime.fromisoformat(row["started"]),
49
+ "finished": datetime.fromisoformat(row["finished"]),
50
+ }
51
+ )
52
+ for row in rows
53
+ ]
54
+
55
+
56
+ def is_due(runs: list[RunRecord], now: datetime, at: tuple[int, int] = (3, 0)) -> bool:
57
+ """Has the nightly run still to happen?
58
+
59
+ True until a scheduled run that reached the LLM has started since the most recent `at` (hour, minute)
60
+ boundary. Runs stopped by an LLM outage or an interruption don't count (retried on the next
61
+ hourly check) and neither do manual runs (which may be partial).
62
+ """
63
+ boundary = now.replace(hour=at[0], minute=at[1], second=0, microsecond=0)
64
+ if now < boundary:
65
+ boundary -= timedelta(days=1)
66
+ return not any(
67
+ r.trigger == "scheduled"
68
+ and r.stopped_by not in ("llm_unavailable", "interrupted")
69
+ and r.started >= boundary
70
+ for r in runs
71
+ )
72
+
73
+
74
+ def trim_log(path: Path, limit_bytes: int = 1_000_000, keep_bytes: int = 200_000) -> None:
75
+ """Cut a log that passed `limit_bytes` to its last `keep_bytes`, from a whole line. launchd
76
+ appends to this file, so it is trimmed in place at the start of a run, never renamed."""
77
+ if not path.is_file() or path.stat().st_size <= limit_bytes:
78
+ return
79
+ tail = path.read_bytes()[-keep_bytes:]
80
+ path.write_bytes(tail[tail.find(b"\n") + 1 :])
esbi_cli/schedule.py ADDED
@@ -0,0 +1,106 @@
1
+ """launchd integration: `sb run --if-due` at the chosen time (or the next wake), at login, and hourly as a retry net."""
2
+
3
+ import plistlib
4
+ import shlex
5
+ import subprocess
6
+ from collections.abc import Callable
7
+ from pathlib import Path
8
+
9
+ LABEL = "com.esbi-cli.nightly"
10
+ OLD_LABEL = "com.secondbrain.nightly" # legacy: the name before esbi-cli
11
+ CHECK_EVERY_SECONDS = 3600
12
+
13
+ Launchctl = Callable[[list[str]], subprocess.CompletedProcess]
14
+
15
+
16
+ class ScheduleError(RuntimeError):
17
+ pass
18
+
19
+
20
+ def run_launchctl(args: list[str]) -> subprocess.CompletedProcess:
21
+ return subprocess.run(["launchctl", *args], capture_output=True, text=True)
22
+
23
+
24
+ def stable_prefix(prefix: Path) -> Path:
25
+ """Under Homebrew the environment lives in `<brew prefix>/Cellar/esbi-cli/<version>/libexec`, a
26
+ folder `brew upgrade` deletes. `<brew prefix>/opt/esbi-cli` is a symlink brew keeps pointing at
27
+ the current version: that is the path a long-lived job must use. Any other prefix is unchanged."""
28
+ parts = prefix.parts
29
+ for i in range(len(parts) - 2):
30
+ if parts[i : i + 2] == ("Cellar", "esbi-cli"):
31
+ return Path(*parts[:i], "opt", "esbi-cli", *parts[i + 3 :])
32
+ return prefix
33
+
34
+
35
+ def render_plist(config: Path, log_dir: Path, venv: Path, at: tuple[int, int] = (3, 0)) -> bytes:
36
+ """`venv` is the environment that runs `sb`; it need not live inside the repo. `at` is the
37
+ time of day, (hour, minute), the nightly job runs."""
38
+ venv = stable_prefix(venv)
39
+ command = (
40
+ # macOS sometimes flags the venv as hidden, and Python 3.13 then ignores its .pth files
41
+ f"chflags -R nohidden {shlex.quote(str(venv))} 2>/dev/null; "
42
+ f"exec /usr/bin/caffeinate -i {shlex.quote(str(venv / 'bin' / 'sb'))} run --if-due "
43
+ f"--config {shlex.quote(str(config))}"
44
+ )
45
+ log = str(log_dir / "nightly.log")
46
+ return plistlib.dumps(
47
+ {
48
+ "Label": LABEL,
49
+ "ProgramArguments": ["/bin/bash", "-c", command],
50
+ # Not the repo: it usually lives in ~/Documents, and macOS privacy (TCC) then blocks the
51
+ # job at start-up. Everything the job needs is passed as an absolute path.
52
+ "WorkingDirectory": str(Path.home()),
53
+ "StartInterval": CHECK_EVERY_SECONDS,
54
+ # cron skips jobs while the Mac sleeps; launchd runs a calendar job at the next wake
55
+ "StartCalendarInterval": {"Hour": at[0], "Minute": at[1]},
56
+ "RunAtLoad": True,
57
+ "StandardOutPath": log,
58
+ "StandardErrorPath": log,
59
+ "EnvironmentVariables": {"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"},
60
+ }
61
+ )
62
+
63
+
64
+ def _plist_path(agents_dir: Path) -> Path:
65
+ return agents_dir / f"{LABEL}.plist"
66
+
67
+
68
+ def _remove_old_job(agents_dir: Path, uid: int, launchctl: Launchctl) -> bool:
69
+ """legacy: a job installed under the old label would run next to the new one."""
70
+ old = agents_dir / f"{OLD_LABEL}.plist"
71
+ unloaded = launchctl(["bootout", f"gui/{uid}/{OLD_LABEL}"]).returncode == 0
72
+ existed = old.exists()
73
+ old.unlink(missing_ok=True)
74
+ return unloaded or existed
75
+
76
+
77
+ def cron_line(config: Path) -> str:
78
+ """What to put in cron where there is no launchd (hourly is fine: it runs once a day)."""
79
+ return f"0 * * * * sb run --if-due --config {config.resolve()}"
80
+
81
+
82
+ def install(plist: bytes, agents_dir: Path, uid: int, launchctl: Launchctl = run_launchctl) -> Path:
83
+ """Write the LaunchAgent and (re)load it into the user's GUI session."""
84
+ path = _plist_path(agents_dir)
85
+ # first, so that a system without launchctl fails before anything is written
86
+ _remove_old_job(agents_dir, uid, launchctl)
87
+ launchctl(["bootout", f"gui/{uid}/{LABEL}"]) # a previous version may be loaded; ignore errors
88
+ agents_dir.mkdir(parents=True, exist_ok=True)
89
+ path.write_bytes(plist)
90
+ result = launchctl(["bootstrap", f"gui/{uid}", str(path)])
91
+ if result.returncode != 0:
92
+ raise ScheduleError(result.stderr.strip() or f"launchctl exited with {result.returncode}")
93
+ return path
94
+
95
+
96
+ def uninstall(agents_dir: Path, uid: int, launchctl: Launchctl = run_launchctl) -> bool:
97
+ """Unload and delete the LaunchAgent. Returns False if there was nothing to remove."""
98
+ path = _plist_path(agents_dir)
99
+ unloaded = launchctl(["bootout", f"gui/{uid}/{LABEL}"]).returncode == 0
100
+ existed = path.exists()
101
+ path.unlink(missing_ok=True)
102
+ return _remove_old_job(agents_dir, uid, launchctl) or unloaded or existed
103
+
104
+
105
+ def is_loaded(uid: int, launchctl: Launchctl = run_launchctl) -> bool:
106
+ return launchctl(["print", f"gui/{uid}/{LABEL}"]).returncode == 0
@@ -0,0 +1,52 @@
1
+ # SCHEMA — the rules of this wiki
2
+
3
+ This document defines how the worker (and the LLM) maintains this wiki. It is given to the model as context on every operation. Based on Karpathy's "LLM Wiki" pattern.
4
+
5
+ ## Language
6
+ All notes are written in **{language}**. Verbatim quotes keep the language of their source. The language is set by `[notes].language` in config.toml; if that setting and this paragraph disagree, the setting wins.
7
+
8
+ ## Layers
9
+ 1. `raw/`: the original sources, **immutable** (markdown snapshots, PDFs). Never edited.
10
+ 2. `wiki/`: pages generated and maintained by the LLM. The human reads; the LLM writes.
11
+ 3. `SCHEMA.md`: this file.
12
+
13
+ ## Page types (`wiki/`)
14
+ - `sources/`: one summary per source.
15
+ - `concepts/`: ideas, techniques, topics. One page per concept, which grows with each new source.
16
+ - `entities/`: people, organizations, tools, papers.
17
+ - `syntheses/`: analyses that cross several sources, and useful answers to questions.
18
+ - `daily/YYYY-MM-DD.md`: the daily index.
19
+ - `review/`: the queue of things for the human to check.
20
+
21
+ ## Frontmatter
22
+ Sources (`wiki/sources/`):
23
+ ```yaml
24
+ ---
25
+ type: source
26
+ title: ""
27
+ url: "" # or a path in raw/
28
+ kind: article|paper|email|other
29
+ status: processed # queued | processed | read | failed
30
+ captured: YYYY-MM-DD
31
+ processed: YYYY-MM-DD
32
+ read: null # YYYY-MM-DD when the human marks it as read
33
+ tags: []
34
+ ---
35
+ ```
36
+ Concepts and entities: `type`, `title`, `aliases`, `tags`, `sources` (a list of wikilinks), `updated`.
37
+
38
+ ## Links
39
+ - Use wikilinks: `[[Page title]]`.
40
+ - Every source links to the concepts and entities it mentions, and the other way round (a concept lists its sources).
41
+ - Prefer updating an existing page to creating a duplicate. Check aliases.
42
+
43
+ ## Operations
44
+ - **Ingest**: summarize the source, extract concepts and entities, create or update pages, link them, update `index.md`, add a line to `log.md`.
45
+ - **Query**: answer by citing wiki pages; if the answer is valuable, save it in `syntheses/`.
46
+ - **Lint**: find orphans, stale claims, contradictions, concepts without a page, broken links.
47
+
48
+ ## Contradictions and doubts
49
+ When a new source contradicts existing content, do not overwrite: note the contradiction on both pages and create a note in `review/`. Merges, deletions and uncertain links also go to `review/`.
50
+
51
+ ## Log
52
+ `log.md` is append-only. Format: `## [YYYY-MM-DD] ingest | Title`.
@@ -0,0 +1,17 @@
1
+ {
2
+ "schemaVersion": "0.1.0",
3
+ "name": "esbi-cli",
4
+ "behavior": "create",
5
+ "noteNameFormat": "{{title|safe_name}}",
6
+ "path": "inbox",
7
+ "triggers": [],
8
+ "properties": [
9
+ { "name": "title", "value": "{{title}}", "type": "text" },
10
+ { "name": "source", "value": "{{url}}", "type": "text" },
11
+ { "name": "author", "value": "{{author}}", "type": "text" },
12
+ { "name": "published", "value": "{{published}}", "type": "text" },
13
+ { "name": "kind", "value": "article", "type": "text" }
14
+ ],
15
+ "noteContentFormat": "{{content}}",
16
+ "context": ""
17
+ }