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
|
@@ -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
|
+
}
|