lorewell 0.3.0__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.
lorewell.py ADDED
@@ -0,0 +1,2436 @@
1
+ #!/usr/bin/env python3
2
+ """lorewell: reference packs ("lores") for languages and their tools, that an agent looks things up in
3
+ instead of holding them in context. One stdlib-only file; a lore is one SQLite file.
4
+
5
+ A lore holds a language's reference (sections, every function/class/keyword as a symbol, examples) and its
6
+ tools' documentation (what each command-line tool is for, its flags, real runs), searchable with SQLite's
7
+ full-text search. Installed, it's a skill folder any agent that reads skills can use (designed for hobkin;
8
+ Claude Code reads it as it is): SKILL.md, this file
9
+ (as lore.py, the lookup tool) and the .lore.db.
10
+
11
+ Making a lore:
12
+ lorewell compile SRC [-o NAME.lore.db] compile a pack source (a folder: pack.json, sections.jsonl,
13
+ symbols.jsonl, tools.jsonl, examples.jsonl, cheatsheet.md,
14
+ skill.md) into a lore; examples are run through the pack's
15
+ check command when it has one (--no-check to skip)
16
+ lorewell install LORE [--hobkin | --claude | --project DIR | --to DIR]
17
+ a skill folder for it: by default for the agent that's here
18
+ (hobkin's shared skills, else ~/.claude/skills); or as asked
19
+
20
+ Looking things up (an agent runs these, as `python3 lore.py ...` in the skill folder, or `lorewell ...`
21
+ with --lore FILE or $LORE):
22
+ info what this lore covers, and its counts
23
+ cheatsheet the short always-useful page
24
+ toc [ID] the outline, or one branch of it
25
+ search QUERY [--kind K] ranked hits with snippets (K: section, symbol, tool, example, note)
26
+ symbol NAME one function/class/keyword/...: signature, behaviour, examples, notes
27
+ show ID [--from N] a section, a page at a time
28
+ examples [QUERY] [--symbol NAME] worked examples (with their checked result)
29
+ tools the language's command-line tools
30
+ tool NAME one tool: purpose, usage, flags, real runs
31
+ note TEXT [--on REF] keep something learned the hard way (kept apart from the lore; survives rebuilds)
32
+ notes [QUERY] what's been noted
33
+
34
+ Building a pack source from documentation (with any OpenAI-format model), and reviewing it:
35
+ lorewell build NAME [DOCS...] [--tool CMD]... --base-url URL --model M [--check CMD]
36
+ PDF, HTML, Markdown, folders, URLs, tools' --help and man
37
+ pages -> packs/NAME, every entry citing the source lines it
38
+ rests on; model answers recorded, so rebuilds are cheap
39
+ lorewell review DIR a local page: each entry beside its source; accept, edit, reject
40
+
41
+ Code that lives in a database (a lore whose tools can pull and deploy it), worked from a git repo:
42
+ lorewell ws init LORE | list | pull | status [--remote] | deploy FILE... | validate | compile | test
43
+
44
+ Serving lores to any MCP client (VS Code, Cursor, Claude Desktop...), over stdio:
45
+ lorewell mcp [LORE...] (in an installed skill: python3 lore.py mcp)
46
+
47
+ Standard library only; Python 3.9+. See README.md for the pack source format.
48
+ """
49
+
50
+ from __future__ import annotations
51
+
52
+ __version__ = "0.3.0"
53
+
54
+ import argparse
55
+ import base64
56
+ import difflib
57
+ import hashlib
58
+ import html.parser
59
+ import json
60
+ import os
61
+ import re
62
+ import secrets
63
+ import shlex
64
+ import shutil
65
+ import sqlite3
66
+ import subprocess
67
+ import sys
68
+ import tempfile
69
+ import textwrap
70
+ import threading
71
+ import time
72
+ import urllib.error
73
+ import urllib.request
74
+ from pathlib import Path
75
+
76
+ # ---- map: `grep -n '^# ----' lorewell.py` lists these sections.
77
+ # format the lore's schema, the pack source files, reading and checking them
78
+ # compile pack source -> .lore.db (running the examples through the pack's check command)
79
+ # lookup the commands an agent uses: info, cheatsheet, toc, search, symbol, show, examples, tools, tool
80
+ # notes what agents learn, in a notes file beside the lore (never inside it)
81
+ # install the skill folder any skill-reading agent uses: SKILL.md, lore.py, the lore
82
+ # build any documentation -> a pack source, with a model: gather, chunk, distill, verify, finish
83
+ # review a local web page to review a built pack source, entry by entry, beside the source it cites
84
+ # workspace code that lives in a database, in a git repo: pull, status, deploy (conflict-checked), validate
85
+ # mcp the lookups as an MCP server over stdio (VS Code, Cursor, Claude Desktop, any MCP client)
86
+ # command line argument parsing, main
87
+
88
+
89
+ # ---- format: the lore's schema, the pack source files, reading and checking them
90
+
91
+ SCHEMA_VERSION = 1 # a lore of another version is rebuilt from its source, never converted
92
+ PAGE_LINES = 80 # `show` gives this many lines at a time
93
+ BODY_LINES = 40 # longest body `symbol`/`tool`/`examples` print before pointing at `show`
94
+ SYMBOL_KINDS = ("function", "method", "class", "property", "event", "keyword", "operator", "statement", "type",
95
+ "variable", "constant", "directive", "builtin", "other")
96
+
97
+ SCHEMA = """
98
+ CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT);
99
+ CREATE TABLE sections (id TEXT PRIMARY KEY, parent TEXT, title TEXT NOT NULL, body TEXT NOT NULL, source TEXT,
100
+ ord INTEGER);
101
+ CREATE TABLE symbols (name TEXT NOT NULL, kind TEXT NOT NULL, signature TEXT, summary TEXT, body TEXT,
102
+ section TEXT, source TEXT, see TEXT, PRIMARY KEY (name, kind));
103
+ CREATE TABLE tools (name TEXT PRIMARY KEY, purpose TEXT, usage TEXT, body TEXT, flags TEXT, runs TEXT);
104
+ CREATE TABLE examples (id TEXT PRIMARY KEY, title TEXT, code TEXT NOT NULL, input TEXT, expect TEXT, notes TEXT,
105
+ symbols TEXT, checked TEXT, check_output TEXT);
106
+ CREATE VIRTUAL TABLE fts USING fts5(kind UNINDEXED, ref UNINDEXED, title, body,
107
+ tokenize = "unicode61 tokenchars '_@$'");
108
+ """
109
+ NOTES_SCHEMA = """
110
+ CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY, at TEXT, by TEXT, ref TEXT, text TEXT NOT NULL);
111
+ """
112
+
113
+ SOURCE_FILES = ("pack.json", "sections.jsonl", "symbols.jsonl", "tools.jsonl", "examples.jsonl", "operations.json",
114
+ "files/",
115
+ "cheatsheet.md", "skill.md")
116
+
117
+
118
+ OPERATIONS = { # what the lore's operations.json may define; {placeholders} each one is given
119
+ "list": "the objects of a {type}, one per line of output (parsed with `parse`)",
120
+ "project_list": "the objects in a {project}, one per line of output (parsed with `parse`)",
121
+ "pull": "copies object {type} {name} from the database into {file} (or prints it: \"output\": \"stdout\"); "
122
+ "\"missing\": a regex for its message when there's no such object",
123
+ "deploy": "copies {file} into the database as object {type} {name}",
124
+ "validate": "checks {file} (object {type} {name}) without changing the database",
125
+ "compile": "compiles object {type} {name} in the database",
126
+ "build_project": "builds {project} in the database",
127
+ "export_project": "exports {project} into the folder {dir}",
128
+ }
129
+ OBJECT_KEYS = ("type", "name", "file", "dir", "project")
130
+
131
+
132
+ def check_operations(ops: dict) -> list[str]:
133
+ """Problems with an operations.json: unknown operations, placeholders nothing will fill, parse patterns that
134
+ don't compile. (`build` and `compile` refuse a broken one; `review` shows what's wrong.)"""
135
+ problems = []
136
+ known = set(OBJECT_KEYS) | set((ops.get("variables") or {})) | set((ops.get("secrets") or {}))
137
+ for name, op in (ops.get("operations") or {}).items():
138
+ if name not in OPERATIONS:
139
+ problems.append(f"operation {name!r}: not one of {', '.join(OPERATIONS)}")
140
+ continue
141
+ if not isinstance(op, dict) or not str(op.get("command") or "").strip():
142
+ problems.append(f"operation {name!r}: needs a command")
143
+ continue
144
+ try:
145
+ words = shlex.split(op["command"])
146
+ except ValueError as e:
147
+ problems.append(f"operation {name!r}: {e}")
148
+ continue
149
+ for w in words:
150
+ for ph in re.findall(r"\{(\w+)\}", w):
151
+ if ph not in known:
152
+ problems.append(f"operation {name!r}: {{{ph}}} isn't a variable, a secret or one of "
153
+ f"{', '.join(OBJECT_KEYS)}")
154
+ if op.get("missing"):
155
+ try:
156
+ re.compile(op["missing"])
157
+ except re.error as e:
158
+ problems.append(f"operation {name!r}: missing: {e}")
159
+ if op.get("parse"):
160
+ try:
161
+ rx = re.compile(op["parse"])
162
+ if "name" not in rx.groupindex:
163
+ problems.append(f"operation {name!r}: parse needs a (?P<name>...) group")
164
+ except re.error as e:
165
+ problems.append(f"operation {name!r}: parse: {e}")
166
+ if (ops.get("operations") or {}).get("pull") is None and ops.get("operations"):
167
+ problems.append("no pull operation: nothing can come from the database")
168
+ return problems
169
+
170
+
171
+ class LoreError(Exception):
172
+ """Something the user can fix: printed without a traceback."""
173
+
174
+
175
+ def read_jsonl(path: Path) -> list[dict]:
176
+ if not path.exists():
177
+ return []
178
+ out = []
179
+ for n, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
180
+ if line.strip():
181
+ try:
182
+ out.append(json.loads(line))
183
+ except json.JSONDecodeError as e:
184
+ raise LoreError(f"{path.name}:{n}: not JSON ({e.msg})") from None
185
+ return out
186
+
187
+
188
+ FILES_LIMIT = 20 * 1024 * 1024 # a lore's helper files, all together (a lore stays one file you can hand over)
189
+
190
+
191
+ def read_files(folder: Path) -> list[dict]:
192
+ """The pack source's files/ folder: helpers that ship with the lore (a check harness, test fixtures, small
193
+ scripts for its tools). Stored inside the lore, written into the skill folder on install."""
194
+ if not folder.is_dir():
195
+ return []
196
+ out, total = [], 0
197
+ for f in sorted(folder.rglob("*")):
198
+ rel = f.relative_to(folder)
199
+ if not f.is_file() or any(part.startswith(".") or part == "__pycache__" for part in rel.parts):
200
+ continue
201
+ data = f.read_bytes()
202
+ total += len(data)
203
+ if total > FILES_LIMIT:
204
+ raise LoreError(f"files/ holds more than {FILES_LIMIT // 1024 // 1024} MB: keep a lore's files small")
205
+ out.append({"path": rel.as_posix(), "data": data, "exec": bool(f.stat().st_mode & 0o111)})
206
+ return out
207
+
208
+
209
+ def read_source(src: Path) -> dict:
210
+ """A pack source folder, read and checked. Errors stop the compile; warnings are returned."""
211
+ if not (src / "pack.json").exists():
212
+ raise LoreError(f"{src} has no pack.json (a pack source folder holds {', '.join(SOURCE_FILES)})")
213
+ pack = json.loads((src / "pack.json").read_text(encoding="utf-8"))
214
+ for key in ("name", "title", "description"):
215
+ if not pack.get(key):
216
+ raise LoreError(f"pack.json needs {key!r}")
217
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,40}", pack["name"]):
218
+ raise LoreError("pack.json name: lowercase letters, digits and hyphens (it names the skill)")
219
+ kept = lambda rows: [r for r in rows if (r.get("review") or {}).get("status") != "rejected"] # noqa: E731
220
+ p = {"pack": pack, # what a reviewer rejected (lorewell review) stays in the source, out of the lore
221
+ "sections": kept(read_jsonl(src / "sections.jsonl")), "symbols": kept(read_jsonl(src / "symbols.jsonl")),
222
+ "tools": kept(read_jsonl(src / "tools.jsonl")), "examples": kept(read_jsonl(src / "examples.jsonl")),
223
+ "cheatsheet": (src / "cheatsheet.md").read_text(encoding="utf-8") if (src / "cheatsheet.md").exists() else "",
224
+ "skill": (src / "skill.md").read_text(encoding="utf-8") if (src / "skill.md").exists() else "",
225
+ "operations": json.loads((src / "operations.json").read_text(encoding="utf-8"))
226
+ if (src / "operations.json").exists() else {},
227
+ "files": read_files(src / "files")}
228
+ errors, warnings = [], []
229
+ ids = [s.get("id") for s in p["sections"]]
230
+ for s in p["sections"]:
231
+ if not s.get("id") or not s.get("title") or s.get("body") is None:
232
+ errors.append(f"section {s.get('id')!r}: needs id, title and body")
233
+ dup = {i for i in ids if ids.count(i) > 1}
234
+ if dup:
235
+ errors.append(f"sections: duplicate ids {sorted(dup)}")
236
+ known = set(ids)
237
+ for s in p["sections"]:
238
+ if s.get("parent") and s["parent"] not in known:
239
+ errors.append(f"section {s['id']!r}: parent {s['parent']!r} doesn't exist")
240
+ names = set()
241
+ for y in p["symbols"]:
242
+ if not y.get("name") or not y.get("kind"):
243
+ errors.append(f"symbol {y.get('name')!r}: needs name and kind")
244
+ continue
245
+ if y["kind"] not in SYMBOL_KINDS:
246
+ errors.append(f"symbol {y['name']!r}: kind {y['kind']!r} isn't one of {', '.join(SYMBOL_KINDS)}")
247
+ if (y["name"], y["kind"]) in names:
248
+ errors.append(f"symbol {y['name']!r} ({y['kind']}) twice")
249
+ names.add((y["name"], y["kind"]))
250
+ if y.get("section") and y["section"] not in known:
251
+ errors.append(f"symbol {y['name']!r}: section {y['section']!r} doesn't exist")
252
+ if not y.get("source"):
253
+ warnings.append(f"symbol {y['name']!r}: no source (where in the documentation it comes from)")
254
+ symbol_names = {n for n, _ in names}
255
+ for y in p["symbols"]:
256
+ for ref in y.get("see") or []:
257
+ if ref not in symbol_names and ref not in known:
258
+ warnings.append(f"symbol {y['name']!r}: see {ref!r} is neither a symbol nor a section")
259
+ eids = [e.get("id") for e in p["examples"]]
260
+ for e in p["examples"]:
261
+ if not e.get("id") or not e.get("code"):
262
+ errors.append(f"example {e.get('id')!r}: needs id and code")
263
+ for ref in e.get("symbols") or []:
264
+ if ref not in symbol_names:
265
+ warnings.append(f"example {e.get('id')!r}: symbol {ref!r} isn't in symbols.jsonl")
266
+ if len(set(eids)) != len(eids):
267
+ errors.append("examples: duplicate ids")
268
+ for t in p["tools"]:
269
+ if not t.get("name"):
270
+ errors.append("a tool without a name")
271
+ errors += [f"operations.json: {e}" for e in check_operations(p["operations"])] if p["operations"] else []
272
+ if errors:
273
+ raise LoreError("the pack source has problems:\n " + "\n ".join(errors))
274
+ p["warnings"] = warnings
275
+ return p
276
+
277
+
278
+ # ---- compile: pack source -> .lore.db (running the examples through the pack's check command)
279
+
280
+ def run_check(check: dict, example: dict, timeout: float | None = None,
281
+ pack_dir: Path | None = None) -> tuple[str, str]:
282
+ """Run one example through the pack's check command: `{file}` in the command is a file holding the code,
283
+ the example's input goes to stdin. Passes when the command succeeds and, if the example has `expect`, its
284
+ output matches (check "compare": exact, words or json). `{pack}` is the pack source folder (for a harness
285
+ in its files/), "timeout" in seconds. Exit 77 means the check can't run here: "unchecked", not "fail".
286
+ Returns ("pass" | "fail" | "error" | "unchecked", the output)."""
287
+ with tempfile.TemporaryDirectory() as tmp:
288
+ f = Path(tmp) / ("example" + check.get("ext", ".txt"))
289
+ f.write_text(example["code"], encoding="utf-8")
290
+ cmd = check["command"].replace("{file}", shlex.quote(str(f)))
291
+ if pack_dir is not None:
292
+ cmd = cmd.replace("{pack}", shlex.quote(str(Path(pack_dir).resolve())))
293
+ timeout = timeout or float(check.get("timeout", 20))
294
+ try:
295
+ r = subprocess.run(cmd, shell=True, input=example.get("input") or "", capture_output=True, text=True,
296
+ timeout=timeout, cwd=tmp)
297
+ except subprocess.TimeoutExpired:
298
+ return "error", f"timed out after {timeout:g}s"
299
+ out = (r.stdout + (("\n" + r.stderr) if r.stderr.strip() else "")).strip()
300
+ if r.returncode == 77: # the harness can't run here (no browser, no service): not a failure
301
+ return "unchecked", out
302
+ if r.returncode != 0:
303
+ return "fail", out or f"exit {r.returncode}"
304
+ if example.get("expect") is not None and not same_output(r.stdout, str(example["expect"]),
305
+ check.get("compare", "exact")):
306
+ return "fail", f"expected:\n{str(example['expect']).strip()}\ngot:\n{r.stdout.strip()}"
307
+ return "pass", out
308
+
309
+
310
+ def json_stream(text: str) -> list | None:
311
+ """A stream of JSON values (as jq and many tools print them: one after another, maybe comma-separated),
312
+ or None if it isn't one."""
313
+ dec, i, vals = json.JSONDecoder(), 0, []
314
+ while True:
315
+ while i < len(text) and (text[i].isspace() or text[i] == ","):
316
+ i += 1
317
+ if i >= len(text):
318
+ return vals
319
+ try:
320
+ v, i = dec.raw_decode(text, i)
321
+ except json.JSONDecodeError:
322
+ return None
323
+ vals.append(v)
324
+
325
+
326
+ def same_output(got: str, expect: str, compare: str) -> bool:
327
+ """exact: the same text (ends trimmed); words: the same apart from spacing; json: the same JSON values."""
328
+ if compare == "json":
329
+ a, b = json_stream(got), json_stream(expect)
330
+ if a is not None and b is not None:
331
+ return a == b
332
+ if compare in ("json", "words"):
333
+ return got.split() == expect.split() or "".join(got.split()) == "".join(expect.split())
334
+ return got.strip() == expect.strip()
335
+
336
+
337
+ def compile_pack(src: Path, out: Path, check: bool = True, log=print) -> dict:
338
+ p = read_source(src)
339
+ pack = p["pack"]
340
+ out = out.resolve()
341
+ tmp = out.with_suffix(".building")
342
+ if tmp.exists():
343
+ tmp.unlink()
344
+ con = sqlite3.connect(tmp)
345
+ try:
346
+ con.executescript(SCHEMA)
347
+ except sqlite3.OperationalError as e:
348
+ con.close()
349
+ tmp.unlink()
350
+ raise LoreError(f"this Python's SQLite can't build a lore ({e}); it needs FTS5") from None
351
+ meta = {"schema": str(SCHEMA_VERSION), "name": pack["name"], "title": pack["title"],
352
+ "description": pack["description"], "language": pack.get("language", pack["title"]),
353
+ "version": pack.get("version", ""), "sources": json.dumps(pack.get("sources", [])),
354
+ "check": json.dumps(pack.get("check")) if pack.get("check") else "",
355
+ "cheatsheet": p["cheatsheet"], "skill": p["skill"], "built_at": time.strftime("%Y-%m-%d %H:%M:%S"),
356
+ "operations": json.dumps(p["operations"]) if p["operations"] else "",
357
+ "files": json.dumps([{"path": f["path"], "exec": f["exec"],
358
+ "data": base64.b64encode(f["data"]).decode()} for f in p["files"]])
359
+ if p["files"] else "",
360
+ "built_by": f"lorewell {__version__}"}
361
+ con.executemany("INSERT INTO meta VALUES (?, ?)", meta.items())
362
+ for i, s in enumerate(p["sections"]):
363
+ con.execute("INSERT INTO sections VALUES (?,?,?,?,?,?)",
364
+ (s["id"], s.get("parent"), s["title"], s["body"], s.get("source"), i))
365
+ con.execute("INSERT INTO fts VALUES ('section', ?, ?, ?)", (s["id"], s["title"], s["body"]))
366
+ for y in p["symbols"]:
367
+ con.execute("INSERT INTO symbols VALUES (?,?,?,?,?,?,?,?)",
368
+ (y["name"], y["kind"], y.get("signature"), y.get("summary"), y.get("body"), y.get("section"),
369
+ y.get("source"), json.dumps(y.get("see") or [])))
370
+ con.execute("INSERT INTO fts VALUES ('symbol', ?, ?, ?)",
371
+ (y["name"], f"{y['name']} {y.get('signature') or ''}",
372
+ " ".join(filter(None, [y.get("summary"), y.get("body")]))))
373
+ for t in p["tools"]:
374
+ con.execute("INSERT INTO tools VALUES (?,?,?,?,?,?)",
375
+ (t["name"], t.get("purpose"), t.get("usage"), t.get("body"), json.dumps(t.get("flags") or []),
376
+ json.dumps(t.get("runs") or [])))
377
+ flags = " ".join(f"{f.get('flag', '')} {f.get('meaning', '')}" for f in t.get("flags") or [])
378
+ con.execute("INSERT INTO fts VALUES ('tool', ?, ?, ?)",
379
+ (t["name"], t["name"], " ".join(filter(None, [t.get("purpose"), t.get("usage"), t.get("body"),
380
+ flags]))))
381
+ chk = pack.get("check")
382
+ runner = shlex.split(chk["command"].replace("{pack}", str(src.resolve())))[0] if chk else ""
383
+ can_check = bool(check and chk and shutil.which(runner)) # (a harness in files/ is found through {pack})
384
+ if check and chk and not can_check:
385
+ log(f"note: {runner} isn't installed here, so the examples aren't checked")
386
+ results = {"pass": 0, "fail": 0, "error": 0, "unchecked": 0}
387
+ for e in p["examples"]:
388
+ state, output = run_check(chk, e, pack_dir=src) if can_check else ("unchecked", "")
389
+ results[state] += 1
390
+ if state in ("fail", "error"):
391
+ log(f"example {e['id']}: {state}\n{textwrap.indent(output, ' ')}")
392
+ con.execute("INSERT INTO examples VALUES (?,?,?,?,?,?,?,?,?)",
393
+ (e["id"], e.get("title"), e["code"], e.get("input"),
394
+ None if e.get("expect") is None else str(e["expect"]), e.get("notes"),
395
+ json.dumps(e.get("symbols") or []), state, output[:4000]))
396
+ con.execute("INSERT INTO fts VALUES ('example', ?, ?, ?)",
397
+ (e["id"], e.get("title") or e["id"], " ".join(filter(None, [e["code"], e.get("notes")]))))
398
+ con.commit()
399
+ con.execute("INSERT INTO fts(fts) VALUES ('optimize')")
400
+ con.commit()
401
+ con.close()
402
+ os.replace(tmp, out)
403
+ for w in p["warnings"]:
404
+ log(f"warning: {w}")
405
+ return {"lore": str(out), "sections": len(p["sections"]), "symbols": len(p["symbols"]),
406
+ "tools": len(p["tools"]), "examples": len(p["examples"]), "checked": results,
407
+ "warnings": len(p["warnings"])}
408
+
409
+
410
+ # ---- lookup: the commands an agent uses: info, cheatsheet, toc, search, symbol, show, examples, tools, tool
411
+
412
+ def find_lore(given: str | None) -> Path:
413
+ """The lore to read: --lore, else $LORE, else the one .lore.db beside this file (an installed skill)."""
414
+ if given or os.environ.get("LORE"):
415
+ path = Path(given or os.environ["LORE"]).expanduser()
416
+ if not path.exists():
417
+ raise LoreError(f"no lore at {path}")
418
+ return path
419
+ here = sorted(Path(__file__).resolve().parent.glob("*.lore.db"))
420
+ if len(here) == 1:
421
+ return here[0]
422
+ raise LoreError("which lore? give --lore FILE (or set LORE)" if not here else
423
+ f"several lores here ({', '.join(h.name for h in here)}): give --lore FILE")
424
+
425
+
426
+ class Lore:
427
+ def __init__(self, path: Path):
428
+ self.path = path
429
+ try:
430
+ self.con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
431
+ self.meta = dict(self.con.execute("SELECT key, value FROM meta"))
432
+ except sqlite3.DatabaseError as e:
433
+ raise LoreError(f"{path} isn't a lore ({e})") from None
434
+ if self.meta.get("schema") != str(SCHEMA_VERSION):
435
+ raise LoreError(f"{path.name} was built for lore schema {self.meta.get('schema')}; this tool reads "
436
+ f"schema {SCHEMA_VERSION}. Rebuild it from its source with this version.")
437
+ self.notes = Notes(notes_path(path))
438
+
439
+ def q(self, sql: str, *args):
440
+ return self.con.execute(sql, args).fetchall()
441
+
442
+
443
+ def page(text: str, start: int, lines: int, more: str) -> str:
444
+ rows = text.splitlines()
445
+ chunk = rows[start:start + lines]
446
+ out = "\n".join(chunk)
447
+ if start + lines < len(rows):
448
+ out += f"\n\n[lines {start + 1}-{start + len(chunk)} of {len(rows)}; more: {more} --from {start + lines}]"
449
+ return out
450
+
451
+
452
+ def cmd_info(lore: Lore, args) -> str:
453
+ m = lore.meta
454
+ n = lambda t: lore.q(f"SELECT COUNT(*) FROM {t}")[0][0] # noqa: E731
455
+ checked = dict(lore.q("SELECT checked, COUNT(*) FROM examples GROUP BY checked"))
456
+ lines = [f"{m['title']}{' ' + m['version'] if m.get('version') else ''}: {m['description']}",
457
+ f"{n('sections')} sections, {n('symbols')} symbols, {n('tools')} tools, {n('examples')} examples"
458
+ + (f" ({checked.get('pass', 0)} checked and passing)" if checked.get("pass") else ""),
459
+ f"notes: {len(lore.notes.all())} (in {lore.notes.path.name})"
460
+ + (f"; files: {len(lore_files(m))} (installed in the skill folder's files/)" if m.get("files") else ""),
461
+ f"built {m.get('built_at', '?')} by {m.get('built_by', '?')}"]
462
+ srcs = json.loads(m.get("sources") or "[]")
463
+ if srcs:
464
+ lines.append("from: " + "; ".join(s if isinstance(s, str) else s.get("title", str(s)) for s in srcs))
465
+ return "\n".join(lines)
466
+
467
+
468
+ def cmd_cheatsheet(lore: Lore, args) -> str:
469
+ return lore.meta.get("cheatsheet") or "(this lore has no cheatsheet)"
470
+
471
+
472
+ def cmd_toc(lore: Lore, args) -> str:
473
+ rows = lore.q("SELECT id, parent, title FROM sections ORDER BY ord")
474
+ kids: dict = {}
475
+ for i, parent, title in rows:
476
+ kids.setdefault(parent, []).append((i, title))
477
+ root = args.id
478
+ if root and root not in {r[0] for r in rows}:
479
+ raise LoreError(f"no section {root!r}" + suggest(root, [r[0] for r in rows]))
480
+ out, depth_limit = [], 3 if root else 2
481
+
482
+ def walk(parent, depth):
483
+ for i, title in kids.get(parent, []):
484
+ more = f" (+{len(kids[i])})" if i in kids and depth + 1 >= depth_limit else ""
485
+ out.append(f"{' ' * depth}{title} [{i}]{more}")
486
+ if depth + 1 < depth_limit:
487
+ walk(i, depth + 1)
488
+ if root:
489
+ title = next(t for i, _, t in rows if i == root)
490
+ out.append(f"{title} [{root}]")
491
+ walk(root, 1)
492
+ else:
493
+ walk(None, 0)
494
+ return "\n".join(out) or "(no sections)"
495
+
496
+
497
+ def fts_query(text: str, op: str) -> str:
498
+ """The user's words as an FTS5 query: each one quoted (so punctuation can't break the syntax) and matched as a
499
+ prefix (identifiers are whole tokens), joined with AND or OR."""
500
+ words = re.findall(r"[\w@$]+", text)
501
+ return f" {op} ".join('"' + w.replace('"', "") + '"*' for w in words) # a prefix: "group" finds group_by
502
+
503
+
504
+ def cmd_search(lore: Lore, args) -> str:
505
+ query = " ".join(args.query)
506
+ kinds = (args.kind,) if args.kind else ("section", "symbol", "tool", "example")
507
+ hits = []
508
+ if args.kind != "note":
509
+ for op in ("AND", "OR"): # every word first; any word if that finds nothing
510
+ q = fts_query(query, op)
511
+ if not q:
512
+ break
513
+ ph = ",".join("?" * len(kinds))
514
+ hits = lore.q(f"SELECT kind, ref, title, snippet(fts, 3, '[', ']', ' ... ', 14) FROM fts "
515
+ f"WHERE fts MATCH ? AND kind IN ({ph}) ORDER BY bm25(fts, 0, 0, 4.0, 1.0) LIMIT ?",
516
+ q, *kinds, args.n)
517
+ if hits:
518
+ break
519
+ out = []
520
+ if args.kind in (None, "symbol"): # a word that is exactly a symbol's name: that symbol first
521
+ words = {w.lower() for w in re.findall(r"[^\s()\[\];,\"']+", query)}
522
+ exact = [r[0] for r in lore.q("SELECT DISTINCT name FROM symbols") if r[0].lower() in words]
523
+ for name in exact:
524
+ summary = lore.q("SELECT signature, summary FROM symbols WHERE name = ? LIMIT 1", name)[0]
525
+ out.append(f"symbol: {name} -> symbol {name}\n {summary[0] or ''}: {summary[1] or ''}")
526
+ hits = [h for h in hits if not (h[0] == "symbol" and h[1] in exact)]
527
+ for kind, ref, title, snip in hits:
528
+ hint = {"section": f"show {ref}", "symbol": f"symbol {ref}", "tool": f"tool {ref}",
529
+ "example": f"examples --id {ref}"}[kind]
530
+ out.append(f"{kind}: {ref if kind == 'symbol' else title.strip()} -> {hint}\n {' '.join(snip.split())}")
531
+ if args.kind in (None, "note"):
532
+ out += [f"note on {r or '(general)'}: {t}" for r, t in lore.notes.search(query)[:5]]
533
+ return "\n".join(out) or f"nothing for {query!r}. Try other words, or browse: toc"
534
+
535
+
536
+ def suggest(name: str, names: list[str]) -> str:
537
+ close = difflib.get_close_matches(name, names, n=5, cutoff=0.6)
538
+ low = [n for n in names if name.lower() in n.lower() and n not in close][:5]
539
+ near = close + low
540
+ return f"; did you mean: {', '.join(near)}?" if near else ""
541
+
542
+
543
+ def cmd_symbol(lore: Lore, args) -> str:
544
+ rows = lore.q("SELECT name, kind, signature, summary, body, section, source, see FROM symbols "
545
+ "WHERE name = ? OR lower(name) = lower(?) ORDER BY name = ? DESC, kind", args.name, args.name,
546
+ args.name)
547
+ if not rows:
548
+ names = [r[0] for r in lore.q("SELECT name FROM symbols")]
549
+ raise LoreError(f"no symbol {args.name!r}{suggest(args.name, names)} (or: search {args.name})")
550
+ out = []
551
+ for name, kind, sig, summary, body, section, source, see in rows:
552
+ out.append(f"{name} ({kind})")
553
+ if sig:
554
+ out.append(f" {sig}")
555
+ if summary:
556
+ out.append(summary)
557
+ if body:
558
+ b = body.splitlines()
559
+ out.append("\n".join(b[:BODY_LINES]) + (f"\n[... {len(b) - BODY_LINES} more lines: show {section}]"
560
+ if len(b) > BODY_LINES and section else ""))
561
+ refs = json.loads(see or "[]")
562
+ if refs:
563
+ out.append("see: " + ", ".join(refs))
564
+ ex = lore.q("SELECT id, title FROM examples WHERE EXISTS (SELECT 1 FROM json_each(examples.symbols) "
565
+ "WHERE value = ?) LIMIT 5", name)
566
+ if ex:
567
+ out.append("examples: " + ", ".join(f"{i} ({t})" if t else i for i, t in ex)
568
+ + f" -> examples --symbol {name}")
569
+ if section:
570
+ out.append(f"section: {section}")
571
+ if source:
572
+ out.append(f"source: {source}")
573
+ notes = lore.notes.on(name)
574
+ if notes:
575
+ out.append("notes:\n" + "\n".join(f" - {t}" for t in notes))
576
+ out.append("")
577
+ return "\n".join(out).rstrip()
578
+
579
+
580
+ def cmd_show(lore: Lore, args) -> str:
581
+ row = lore.q("SELECT id, title, body, source, parent FROM sections WHERE id = ?", args.id)
582
+ if not row:
583
+ ids = [r[0] for r in lore.q("SELECT id FROM sections")]
584
+ raise LoreError(f"no section {args.id!r}{suggest(args.id, ids)}")
585
+ i, title, body, source, parent = row[0]
586
+ kids = lore.q("SELECT id, title FROM sections WHERE parent = ? ORDER BY ord", i)
587
+ head = f"# {title} [{i}]" + (f"\n(in {parent})" if parent else "")
588
+ text = body + ("\n\nSubsections: " + ", ".join(f"{t} [{k}]" for k, t in kids) if kids else "")
589
+ text += f"\n\nsource: {source}" if source else ""
590
+ notes = lore.notes.on(i)
591
+ if notes:
592
+ text += "\n\nnotes:\n" + "\n".join(f" - {t}" for t in notes)
593
+ return head + "\n\n" + page(text, args.from_, PAGE_LINES, f"show {i}")
594
+
595
+
596
+ def cmd_examples(lore: Lore, args) -> str:
597
+ if args.id:
598
+ rows = lore.q("SELECT id, title, code, input, expect, notes, checked FROM examples WHERE id = ?", args.id)
599
+ elif args.symbol:
600
+ rows = lore.q("SELECT id, title, code, input, expect, notes, checked FROM examples WHERE EXISTS "
601
+ "(SELECT 1 FROM json_each(examples.symbols) WHERE value = ?) LIMIT ?", args.symbol, args.n)
602
+ elif args.query:
603
+ ids = [r[0] for r in lore.q("SELECT ref FROM fts WHERE fts MATCH ? AND kind = 'example' "
604
+ "ORDER BY bm25(fts, 0, 0, 4.0, 1.0) LIMIT ?",
605
+ fts_query(" ".join(args.query), "OR"), args.n)] if fts_query(
606
+ " ".join(args.query), "OR") else []
607
+ rows = [lore.q("SELECT id, title, code, input, expect, notes, checked FROM examples WHERE id = ?", i)[0]
608
+ for i in ids]
609
+ else:
610
+ rows = lore.q("SELECT id, title, code, input, expect, notes, checked FROM examples LIMIT ?", args.n)
611
+ if not rows:
612
+ return "no examples found"
613
+ out = []
614
+ for i, title, code, inp, expect, notes, checked in rows:
615
+ state = {"pass": "checked: runs as shown", "fail": "checked: FAILED (don't trust it)",
616
+ "error": "check errored", "unchecked": "not checked"}.get(checked, checked)
617
+ out.append(f"## {title or i} [{i}] ({state})\n{code.rstrip()}")
618
+ if inp:
619
+ out.append(f"input:\n{inp.rstrip()}")
620
+ if expect is not None:
621
+ out.append(f"output:\n{expect.rstrip()}")
622
+ if notes:
623
+ out.append(notes.rstrip())
624
+ out.append("")
625
+ return "\n".join(out).rstrip()
626
+
627
+
628
+ def cmd_tools(lore: Lore, args) -> str:
629
+ rows = lore.q("SELECT name, purpose FROM tools ORDER BY name")
630
+ return "\n".join(f"{n}: {p or ''} -> tool {n}" for n, p in rows) or "(this lore documents no tools)"
631
+
632
+
633
+ def cmd_tool(lore: Lore, args) -> str:
634
+ row = lore.q("SELECT name, purpose, usage, body, flags, runs FROM tools WHERE name = ?", args.name)
635
+ if not row:
636
+ names = [r[0] for r in lore.q("SELECT name FROM tools")]
637
+ raise LoreError(f"no tool {args.name!r}{suggest(args.name, names)} (tools lists them)")
638
+ name, purpose, usage, body, flags, runs = row[0]
639
+ out = [f"# {name}", purpose or ""]
640
+ if usage:
641
+ out.append(f"usage: {usage}")
642
+ if body:
643
+ out.append(body.rstrip())
644
+ fl = json.loads(flags or "[]")
645
+ if fl:
646
+ width = min(28, max(len(f.get("flag", "")) for f in fl))
647
+ out.append("flags:\n" + "\n".join(f" {f.get('flag', ''):<{width}} {f.get('meaning', '')}" for f in fl))
648
+ rs = json.loads(runs or "[]")
649
+ for r in rs[:6]:
650
+ out.append(f"$ {r.get('command', '')}\n{(r.get('output') or '').rstrip()}")
651
+ notes = lore.notes.on(name)
652
+ if notes:
653
+ out.append("notes:\n" + "\n".join(f" - {t}" for t in notes))
654
+ return page("\n\n".join(x for x in out if x), args.from_, PAGE_LINES * 2, f"tool {name}")
655
+
656
+
657
+ # ---- notes: what agents learn, in a notes file beside the lore (never inside it)
658
+
659
+ def notes_path(lore_path: Path) -> Path:
660
+ return Path(os.environ.get("LORE_NOTES") or str(lore_path).replace(".lore.db", "") + ".notes.db")
661
+
662
+
663
+ class Notes:
664
+ """Notes live apart from the lore, so a rebuilt or reinstalled lore keeps what agents learned. Opened (and
665
+ created) only when written to."""
666
+
667
+ def __init__(self, path: Path):
668
+ self.path = path
669
+
670
+ def _con(self, write=False):
671
+ if not write and not self.path.exists():
672
+ return None
673
+ con = sqlite3.connect(self.path, timeout=10)
674
+ con.executescript(NOTES_SCHEMA)
675
+ return con
676
+
677
+ def add(self, text: str, ref: str | None, by: str | None) -> int:
678
+ con = self._con(write=True)
679
+ cur = con.execute("INSERT INTO notes (at, by, ref, text) VALUES (?,?,?,?)",
680
+ (time.strftime("%Y-%m-%d %H:%M"), by, ref, text))
681
+ con.commit()
682
+ con.close()
683
+ return cur.lastrowid
684
+
685
+ def all(self) -> list:
686
+ con = self._con()
687
+ if not con:
688
+ return []
689
+ rows = con.execute("SELECT id, at, by, ref, text FROM notes ORDER BY id").fetchall()
690
+ con.close()
691
+ return rows
692
+
693
+ def on(self, ref: str) -> list[str]:
694
+ return [t for _, _, _, r, t in self.all() if r == ref]
695
+
696
+ def search(self, query: str) -> list[tuple]:
697
+ words = [w.lower() for w in re.findall(r"[\w@$]+", query)]
698
+ return [(r, t) for _, _, _, r, t in self.all()
699
+ if words and all(w in f"{r or ''} {t}".lower() for w in words)]
700
+
701
+
702
+ def cmd_note(lore: Lore, args) -> str:
703
+ text = " ".join(args.text).strip()
704
+ if not text:
705
+ raise LoreError("note what? (note TEXT [--on SYMBOL|SECTION|TOOL])")
706
+ n = lore.notes.add(text, args.on, getattr(args, "by", None) or os.environ.get("HOBKIN_ID") or os.environ.get("USER"))
707
+ return f"noted (#{n}{' on ' + args.on if args.on else ''}); it shows up in search, and on {args.on or 'notes'}"
708
+
709
+
710
+ def cmd_notes(lore: Lore, args) -> str:
711
+ rows = lore.notes.all()
712
+ if args.query:
713
+ words = [w.lower() for w in args.query]
714
+ rows = [r for r in rows if all(w in f"{r[3] or ''} {r[4]}".lower() for w in words)]
715
+ return "\n".join(f"#{i} {at}{' ' + by if by else ''}{' on ' + ref if ref else ''}: {t}"
716
+ for i, at, by, ref, t in rows) or "no notes yet"
717
+
718
+
719
+ # ---- install: the skill folder any skill-reading agent uses (hobkin, Claude Code): SKILL.md, lore.py, the lore
720
+
721
+ def lore_files(meta: dict) -> list[dict]:
722
+ return [{"path": f["path"], "exec": f.get("exec", False), "data": base64.b64decode(f["data"])}
723
+ for f in json.loads(meta.get("files") or "[]")]
724
+
725
+
726
+ def file_note(f: dict) -> str:
727
+ """What a helper file is: its first comment or docstring line, if it has one."""
728
+ try:
729
+ text = f["data"][:2000].decode("utf-8")
730
+ except UnicodeDecodeError:
731
+ return ""
732
+ for line in text.splitlines():
733
+ t = re.sub(r"^<!--|-->$", "", line.strip()).strip().strip('"').strip("'").lstrip("#/*").strip()
734
+ if t and not t.startswith("!") and not t.lower().startswith(("<!doctype", "<html", "-*-")):
735
+ return t[:140]
736
+ return ""
737
+
738
+
739
+ def skill_md(meta: dict, has_tools: bool, has_examples: bool) -> str:
740
+ """SKILL.md in the standard skill format (hobkin and Claude Code both read it): name and description in front
741
+ matter, then the
742
+ instructions. The description says when to use it, since that's all an agent sees before opening it."""
743
+ name, title = f"{meta['name']}-lore", meta["title"]
744
+ desc = (f"The {title} reference: look up {meta['language']} syntax, functions and keywords"
745
+ + (", and its command-line tools," if has_tools else "")
746
+ + f" instead of guessing. Use it whenever you write, read, debug or explain {meta['language']}.")
747
+ run = 'python3 "<this skill\'s folder>/lore.py"'
748
+ lines = [
749
+ "---", f"name: {name}", f"description: {json.dumps(desc)}", "---", "",
750
+ f"# {title}{' ' + meta['version'] if meta.get('version') else ''}", "",
751
+ meta["description"], "",
752
+ "This is a searchable reference, not something to read through. Look up exactly what you need, when you "
753
+ "need it. Every command below is",
754
+ "", f" {run} COMMAND ...", "",
755
+ "using this skill's folder (its full path is given when the skill is opened). Output is short and paged.",
756
+ "",
757
+ "## Commands", "",
758
+ "| Command | What it gives |", "| --- | --- |",
759
+ "| `cheatsheet` | The essentials: read this first, once per task |",
760
+ "| `search WORDS [--kind section\\|symbol\\|tool\\|example\\|note]` | Ranked hits with snippets |",
761
+ "| `symbol NAME` | One function, class, keyword...: signature, behaviour, examples, notes |",
762
+ "| `toc [ID]` | The outline, or one branch of it |",
763
+ "| `show ID [--from N]` | A section, a page at a time |",
764
+ "| `examples [WORDS] [--symbol NAME]` | Worked examples" + (" (checked: they run as shown)" if has_examples
765
+ else "") + " |",
766
+ ]
767
+ if has_tools:
768
+ lines += ["| `tools`, `tool NAME` | The command-line tools: purpose, flags, real runs |"]
769
+ lines += [
770
+ "| `note TEXT [--on NAME]` | Keep something you learned the hard way, for next time |",
771
+ "| `info` | What this reference covers |", "",
772
+ "## How to work", "",
773
+ "1. Read the `cheatsheet` once at the start of a task.",
774
+ f"2. Before you use a function, class, keyword or event you aren't certain of, look it up with `symbol` "
775
+ f"(or `search` when you don't know its name). Don't rely on memory for {meta['language']}'s details: "
776
+ "names, argument order, return values and edge cases are where mistakes come from.",
777
+ "3. Prefer a checked example over your own guess at an idiom.",
778
+ ]
779
+ if has_tools:
780
+ lines += ["4. Use the language's tools (`tools`, then `tool NAME`) to run, check or test what you write, "
781
+ "and read their errors with the tool's page at hand."]
782
+ lines += [f"{5 if has_tools else 4}. Say which entries you relied on (the ids in brackets), so a reviewer "
783
+ "can check them.",
784
+ f"{6 if has_tools else 5}. When the reference was wrong, missing or surprising, `note` it "
785
+ "(with `--on NAME`), so the next lookup shows it.", ""]
786
+ files = lore_files(meta)
787
+ if files:
788
+ lines += ["## Files that come with it", "",
789
+ "In this skill's folder, under `files/` (use them by their full path):", ""]
790
+ lines += [f"- `files/{f['path']}`" + (f": {file_note(f)}" if file_note(f) else "") for f in files[:40]]
791
+ lines += [""]
792
+ ops = json.loads(meta.get("operations") or "{}")
793
+ if ops.get("operations"):
794
+ have = ", ".join(sorted(ops["operations"]))
795
+ lines += ["## Working with the code in the database", "",
796
+ f"This lore can also run {meta['language']}'s tools for you (operations: {have}), on code that "
797
+ "lives in a database, from a git repo laid out as `src/TYPE/NAME`. In such a repo (it has a "
798
+ "`.lore-workspace/` folder):", "",
799
+ "| Command | Does |", "| --- | --- |",
800
+ "| `ws status [--remote]` | What changed here, and (with --remote) in the database since your pull |",
801
+ "| `ws list --type T` / `--project P` | Objects in the database |",
802
+ "| `ws pull TYPE NAME` / `--type T` / `--project P` / `--file F` | From the database into files "
803
+ "(never over your unsaved work) |",
804
+ "| `ws validate FILE` | Check a file with the tools, without changing the database |",
805
+ "| `ws deploy FILE... [--compile]` | Into the (dev) database, after checking nobody deployed over it "
806
+ "since your pull |",
807
+ "| `ws test` | Try the operations safely (list, pull, validate) |", "",
808
+ "Pull before you change something, validate before you deploy, and commit what you deploy. When "
809
+ "deploy reports a CONFLICT (someone else deployed the same object since your pull), don't force "
810
+ "it: tell the person you're working for, show them the three versions it saved, and merge with "
811
+ "them. Deploy only to the database the workspace is set up for, never anywhere else.", ""]
812
+ if meta.get("skill"):
813
+ lines += ["## About this language", "", meta["skill"].rstrip(), ""]
814
+ return "\n".join(lines)
815
+
816
+
817
+ HOBKIN_SKILLS = lambda: Path(os.environ.get("HOBKIN_HOME", "~/.hobkins")).expanduser() / "_shared" / "skills"
818
+ CLAUDE_SKILLS = lambda: Path("~/.claude/skills").expanduser()
819
+
820
+
821
+ def install_dir(args) -> Path:
822
+ """Where the skill goes: as asked, else the agent that's on this box (hobkin, which lorewell is designed for,
823
+ then Claude Code), else ask for a folder."""
824
+ if args.to:
825
+ return Path(args.to).expanduser()
826
+ if args.claude:
827
+ return CLAUDE_SKILLS()
828
+ if args.project:
829
+ return Path(args.project).expanduser() / ".claude" / "skills"
830
+ if args.hobkin or HOBKIN_SKILLS().parent.parent.is_dir():
831
+ return HOBKIN_SKILLS()
832
+ if CLAUDE_SKILLS().parent.is_dir():
833
+ return CLAUDE_SKILLS()
834
+ raise LoreError("no agent's skills folder here (hobkin's ~/.hobkins, Claude Code's ~/.claude): "
835
+ "say where with --to DIR (or --hobkin, --claude, --project DIR)")
836
+
837
+
838
+ def install(lore_path: Path, base: Path) -> Path:
839
+ lore = Lore(lore_path)
840
+ meta = lore.meta
841
+ has_tools = bool(lore.q("SELECT COUNT(*) FROM tools")[0][0])
842
+ has_examples = bool(lore.q("SELECT COUNT(*) FROM examples WHERE checked = 'pass'")[0][0])
843
+ lore.con.close()
844
+ folder = base / f"{meta['name']}-lore"
845
+ folder.mkdir(parents=True, exist_ok=True)
846
+ for old in folder.glob("*.lore.db"): # a reinstall replaces the lore; notes (*.notes.db) stay
847
+ if old.name != f"{meta['name']}.lore.db":
848
+ old.unlink()
849
+ shutil.copyfile(lore_path, folder / f"{meta['name']}.lore.db")
850
+ shutil.copyfile(Path(__file__).resolve(), folder / "lore.py")
851
+ shutil.rmtree(folder / "files", ignore_errors=True) # the lore's files, as this version of it has them
852
+ for f in lore_files(meta):
853
+ target = (folder / "files" / f["path"]).resolve()
854
+ if not str(target).startswith(str((folder / "files").resolve()) + os.sep):
855
+ raise LoreError(f"refusing a file outside the skill folder: {f['path']!r}")
856
+ target.parent.mkdir(parents=True, exist_ok=True)
857
+ target.write_bytes(f["data"])
858
+ if f["exec"]:
859
+ target.chmod(target.stat().st_mode | 0o111)
860
+ (folder / "SKILL.md").write_text(skill_md(meta, has_tools, has_examples), encoding="utf-8")
861
+ return folder
862
+
863
+
864
+ # ---- build: any documentation -> a pack source, with a model (gather, chunk, distill, verify, finish)
865
+
866
+ CHUNK_CHARS = 12000 # a chunk the model reads whole: big enough for a page of reference, small enough to read well
867
+ QUOTE_STATES = ("unreviewed", "accepted", "edited", "rejected")
868
+ KEEP_ON_REBUILD = ("accepted", "edited", "rejected") # a reviewer's decision outlives a rebuild
869
+
870
+
871
+ class PageText(html.parser.HTMLParser):
872
+ """HTML as plain text that keeps its structure: headings as #, code blocks fenced, list items as -."""
873
+ SKIP = {"script", "style", "nav", "header", "footer", "noscript", "svg"}
874
+
875
+ def __init__(self):
876
+ super().__init__(convert_charrefs=True)
877
+ self.out, self.skip, self.pre = [], 0, 0
878
+
879
+ def handle_starttag(self, tag, attrs):
880
+ if tag in self.SKIP:
881
+ self.skip += 1
882
+ elif tag in ("h1", "h2", "h3", "h4"):
883
+ self.out.append("\n\n" + "#" * int(tag[1]) + " ")
884
+ elif tag == "pre":
885
+ self.pre += 1
886
+ self.out.append("\n```\n")
887
+ elif tag in ("p", "div", "section", "tr", "dt", "dd", "br", "table"):
888
+ self.out.append("\n")
889
+ elif tag == "li":
890
+ self.out.append("\n- ")
891
+ elif tag == "code" and not self.pre:
892
+ self.out.append("`")
893
+
894
+ def handle_endtag(self, tag):
895
+ if tag in self.SKIP:
896
+ self.skip = max(0, self.skip - 1)
897
+ elif tag == "pre":
898
+ self.pre = max(0, self.pre - 1)
899
+ self.out.append("\n```\n")
900
+ elif tag in ("h1", "h2", "h3", "h4", "p", "dt", "dd"):
901
+ self.out.append("\n")
902
+ elif tag == "code" and not self.pre:
903
+ self.out.append("`")
904
+ elif tag in ("td", "th"):
905
+ self.out.append(" | ")
906
+
907
+ def handle_data(self, data):
908
+ if not self.skip:
909
+ self.out.append(data if self.pre else re.sub(r"\s+", " ", data))
910
+
911
+ def text(self) -> str:
912
+ t = "".join(self.out)
913
+ return re.sub(r"\n{3,}", "\n\n", "\n".join(line.rstrip() for line in t.splitlines())).strip() + "\n"
914
+
915
+
916
+ def html_text(raw: str) -> str:
917
+ p = PageText()
918
+ p.feed(raw)
919
+ return p.text()
920
+
921
+
922
+ SCRIPT_EXTS = (".sh", ".bash", ".ksh", ".zsh", ".dms", ".dat", ".sql", ".ps1", ".bat", ".cmd", ".py", ".pl",
923
+ ".pc", ".pcode", ".mak", ".cfg", ".ini", ".log", ".transcript")
924
+ HELP_FLAGS = ("--help", "-h", "-help", "-H", "-?", "help", "/?") # tools of every era: GNU, Oracle, Windows
925
+ HELP_LIMIT = 60 # help pages read per tool, subcommands included
926
+ SUBCOMMANDS_SYSTEM = """You read a command-line tool's help text and list the commands that print the help of
927
+ its subcommands, modes or topics, so all of the tool's options can be documented. Only commands that print help
928
+ and do nothing else: never one that lists, exports, builds, connects or changes anything. Use the help flag this
929
+ tool uses. Reply with only a JSON object: {"invocations": [["subcommand", "--help"], ["help", "topic"], ...]}
930
+ (the arguments after the tool's name; an empty list when it has no subcommands)."""
931
+
932
+
933
+ def safe_help(args: list) -> bool:
934
+ """Whether an invocation only asks for help: words for subcommands, then a help flag (or `help WORD...`).
935
+ Plain code decides, not the model: a wrong suggestion can never run an operation."""
936
+ if not args or not all(isinstance(a, str) for a in args) or len(args) > 4:
937
+ return False
938
+ word = re.compile(r"[A-Za-z][\w.-]{0,40}")
939
+ if args[0] == "help":
940
+ return all(word.fullmatch(a) for a in args[1:])
941
+ return args[-1] in HELP_FLAGS and all(word.fullmatch(a) for a in args[:-1])
942
+
943
+
944
+ def run_help(argv: list[str]) -> str:
945
+ """A help page: never with stdin (a tool waiting for a login just gets end-of-file), never for long."""
946
+ try:
947
+ r = subprocess.run(argv, capture_output=True, text=True, timeout=20, stdin=subprocess.DEVNULL)
948
+ except subprocess.TimeoutExpired:
949
+ return ""
950
+ except OSError as e:
951
+ raise LoreError(f"tool {argv[0]!r}: {e}") from None
952
+ return plain(r.stdout + r.stderr)
953
+
954
+
955
+ def looks_like_help(text: str) -> bool:
956
+ return bool(text.strip()) and (re.search(r"usage|options|synopsis|commands|flags|parameters", text, re.I)
957
+ is not None or len(text.strip().splitlines()) >= 4)
958
+
959
+
960
+ def tool_help(cmd: str, model, log=print, limit: int = HELP_LIMIT) -> list[str]:
961
+ """A tool's help: the first of the usual help flags that gives some, then (with a model) its subcommands'
962
+ help, two levels down, at most HELP_LIMIT pages. Only help is ever run."""
963
+ base = shlex.split(cmd)
964
+ pages, flag = [], None
965
+ for f in HELP_FLAGS:
966
+ text = run_help(base + [f])
967
+ if looks_like_help(text):
968
+ pages.append((f, [f], text))
969
+ flag = f
970
+ break
971
+ if not pages:
972
+ log(f" {cmd}: no help from any of {' '.join(HELP_FLAGS)}")
973
+ return []
974
+ if model is not None:
975
+ seen, queue = {tuple(pages[0][1])}, [(pages[0][1], pages[0][2], 0)]
976
+ while queue and len(pages) < limit:
977
+ args, text, depth = queue.pop(0)
978
+ if depth >= 2:
979
+ continue
980
+ ask = model.ask("subcommands", SUBCOMMANDS_SYSTEM,
981
+ f"Tool: {cmd}\nIts help flag: {flag}\nHelp printed by: {cmd} {' '.join(args)}\n\n"
982
+ f"{text[:CHUNK_CHARS]}")
983
+ for inv in ask.get("invocations") or []:
984
+ if not isinstance(inv, list):
985
+ continue
986
+ inv = [str(a) for a in inv]
987
+ if tuple(inv) in seen or len(pages) >= limit:
988
+ continue
989
+ seen.add(tuple(inv))
990
+ if not safe_help(inv):
991
+ log(f" {cmd}: not running {' '.join(inv)!r} (not a help request)")
992
+ continue
993
+ sub = run_help(base + inv)
994
+ if looks_like_help(sub) and sub.strip() != text.strip():
995
+ pages.append((" ".join(inv), inv, sub))
996
+ queue.append((inv, sub, depth + 1))
997
+ if len(pages) > 1:
998
+ log(f" {cmd}: {len(pages)} help pages (subcommands found)")
999
+ return [f"# {cmd} {label}\n\n{text}" for label, _, text in pages]
1000
+
1001
+
1002
+ def plain(text: str) -> str:
1003
+ """Terminal output as plain text: no colour or bold codes (man still emits them), no overstrike."""
1004
+ return re.sub(r"\x1b\[[0-9;?]*[A-Za-z]|.\x08", "", text)
1005
+
1006
+
1007
+ def slug(text: str, n: int = 48) -> str:
1008
+ return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")[:n] or "x"
1009
+
1010
+
1011
+ def gather(sources: list[str], tools: list[str], work: Path, log=print, model: Model | None = None,
1012
+ help_limit: int = HELP_LIMIT) -> list[dict]:
1013
+ """Every source as text in work/sources/, listed in work/sources.json: files (PDF, HTML, Markdown, text,
1014
+ scripts), folders of them, URLs, and for each tool its help (its subcommands' too, found with the model) and
1015
+ man page."""
1016
+ out_dir = work / "sources"
1017
+ out_dir.mkdir(parents=True, exist_ok=True)
1018
+ listed: list[dict] = []
1019
+
1020
+ def keep(name: str, kind: str, text: str):
1021
+ if not text.strip():
1022
+ log(f" {name}: no text, skipped")
1023
+ return
1024
+ sid = f"{len(listed) + 1:03d}-{slug(name, 40)}"
1025
+ (out_dir / f"{sid}.txt").write_text(text, encoding="utf-8")
1026
+ listed.append({"id": sid, "name": name, "kind": kind, "chars": len(text)})
1027
+ log(f" {name}: {len(text):,} characters ({kind})")
1028
+
1029
+ def one_file(path: Path):
1030
+ ext = path.suffix.lower()
1031
+ if ext == ".pdf":
1032
+ if not shutil.which("pdftotext"):
1033
+ raise LoreError(f"{path}: reading PDFs needs pdftotext (poppler-utils)")
1034
+ r = subprocess.run(["pdftotext", "-layout", str(path), "-"], capture_output=True, text=True)
1035
+ keep(path.name, "pdf", r.stdout.replace("\f", "\n\n"))
1036
+ elif ext in (".html", ".htm", ".xhtml"):
1037
+ keep(path.name, "html", html_text(path.read_text(encoding="utf-8", errors="replace")))
1038
+ elif ext in (".md", ".txt", ".rst", ".adoc", "") or ext.startswith(".man"):
1039
+ keep(path.name, "text", path.read_text(encoding="utf-8", errors="replace"))
1040
+ elif ext in SCRIPT_EXTS: # how the tools are really used: flags, sequences, idioms
1041
+ keep(path.name, "script", f"# Script {path.name}, as it is used\n\n```\n"
1042
+ + path.read_text(encoding="utf-8", errors="replace") + "\n```\n")
1043
+
1044
+ for src in sources:
1045
+ if re.match(r"https?://", src):
1046
+ req = urllib.request.Request(src, headers={"User-Agent": f"lorewell/{__version__}"})
1047
+ with urllib.request.urlopen(req, timeout=60) as r:
1048
+ raw = r.read().decode(r.headers.get_content_charset() or "utf-8", "replace")
1049
+ keep(src, "html" if "<html" in raw[:2000].lower() else "text",
1050
+ html_text(raw) if "<html" in raw[:2000].lower() else raw)
1051
+ continue
1052
+ path = Path(src).expanduser()
1053
+ if path.is_dir():
1054
+ for f in sorted(p for p in path.rglob("*") if p.is_file()):
1055
+ one_file(f)
1056
+ elif path.is_file():
1057
+ one_file(path)
1058
+ else:
1059
+ raise LoreError(f"no such source: {src}")
1060
+ for cmd in tools:
1061
+ name = shlex.split(cmd)[0]
1062
+ parts = tool_help(cmd, model, log, help_limit)
1063
+ if shutil.which("man"):
1064
+ r = subprocess.run(["man", "-P", "cat", name], capture_output=True, text=True, timeout=30,
1065
+ stdin=subprocess.DEVNULL,
1066
+ env={**os.environ, "MANWIDTH": "100", "MAN_KEEP_FORMATTING": "0",
1067
+ "GROFF_NO_SGR": "1"})
1068
+ if r.returncode == 0 and r.stdout.strip():
1069
+ parts.append(f"# man {name}\n\n" + plain(r.stdout))
1070
+ keep(f"tool: {cmd}", "tool", "\n\n".join(parts))
1071
+ (work / "sources.json").write_text(json.dumps(listed, indent=1), encoding="utf-8")
1072
+ return listed
1073
+
1074
+
1075
+ def chunk(text: str, size: int = CHUNK_CHARS) -> list[tuple[int, int, str]]:
1076
+ """Pieces of about `size` characters, cut at headings (else blank lines): (first line, last line, text),
1077
+ lines counted from 1."""
1078
+ lines = text.splitlines()
1079
+ out, start, cur = [], 0, 0
1080
+
1081
+ def is_head(i):
1082
+ return bool(re.match(r"#{1,4} \S", lines[i])) or (i + 1 < len(lines) and re.fullmatch(r"[=-]{3,}",
1083
+ lines[i + 1]))
1084
+ for i in range(len(lines)):
1085
+ cur += len(lines[i]) + 1
1086
+ nxt = i + 1
1087
+ if nxt >= len(lines):
1088
+ break
1089
+ if cur >= size * 0.6 and is_head(nxt) or cur >= size and not lines[nxt].strip() or cur >= size * 1.5:
1090
+ out.append((start + 1, nxt, "\n".join(lines[start:nxt])))
1091
+ start, cur = nxt, 0
1092
+ if start < len(lines):
1093
+ out.append((start + 1, len(lines), "\n".join(lines[start:])))
1094
+ return [c for c in out if c[2].strip()]
1095
+
1096
+
1097
+ class Model:
1098
+ """An OpenAI-format chat endpoint (OpenRouter, a gateway, Ollama, vLLM...), over urllib. Every answer is
1099
+ recorded in `answers` keyed by (task, prompt, model): a rebuild asks again only what changed."""
1100
+
1101
+ def __init__(self, base_url: str, model: str, key: str | None, answers: Path, timeout: float = 300):
1102
+ self.url = base_url.rstrip("/") + "/chat/completions"
1103
+ self.model, self.key, self.answers, self.timeout = model, key, answers, timeout
1104
+ self.answers.mkdir(parents=True, exist_ok=True)
1105
+ self.calls = 0
1106
+ self.json_mode = True
1107
+
1108
+ def _post(self, messages: list[dict]) -> str:
1109
+ body = {"model": self.model, "messages": messages}
1110
+ if self.json_mode:
1111
+ body["response_format"] = {"type": "json_object"}
1112
+ req = urllib.request.Request(self.url, data=json.dumps(body).encode(), method="POST",
1113
+ headers={"Content-Type": "application/json",
1114
+ **({"Authorization": f"Bearer {self.key}"} if self.key else {})})
1115
+ for attempt in range(5):
1116
+ try:
1117
+ with urllib.request.urlopen(req, timeout=self.timeout) as r:
1118
+ data = json.loads(r.read())
1119
+ self.calls += 1
1120
+ return data["choices"][0]["message"]["content"] or ""
1121
+ except urllib.error.HTTPError as e:
1122
+ detail = e.read().decode("utf-8", "replace")[:300]
1123
+ if e.code == 400 and self.json_mode and "response_format" in detail:
1124
+ self.json_mode = False # this endpoint has no JSON mode: the prompt asks for JSON
1125
+ return self._post(messages)
1126
+ if e.code in (408, 429, 500, 502, 503, 504) and attempt < 4:
1127
+ time.sleep(min(60, 2 ** attempt * 3))
1128
+ continue
1129
+ raise LoreError(f"model call failed: HTTP {e.code}: {detail}") from None
1130
+ except (urllib.error.URLError, TimeoutError, ConnectionError) as e:
1131
+ if attempt < 4:
1132
+ time.sleep(min(60, 2 ** attempt * 3))
1133
+ continue
1134
+ raise LoreError(f"model call failed: {e}") from None
1135
+ raise LoreError("model call failed")
1136
+
1137
+ def ask(self, task: str, system: str, user: str) -> dict:
1138
+ key = hashlib.sha256(json.dumps([task, system, user, self.model]).encode()).hexdigest()[:24]
1139
+ f = self.answers / f"{task}-{key}.json"
1140
+ if f.exists():
1141
+ return json.loads(f.read_text(encoding="utf-8"))["output"]
1142
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": user}]
1143
+ for _ in range(2):
1144
+ raw = self._post(messages)
1145
+ m = re.search(r"\{.*\}", raw, re.S)
1146
+ try:
1147
+ out = json.loads(m.group(0) if m else raw)
1148
+ problem = None if isinstance(out, dict) else "not an object"
1149
+ except json.JSONDecodeError as e:
1150
+ problem = str(e)
1151
+ if not problem:
1152
+ break
1153
+ messages += [{"role": "assistant", "content": raw},
1154
+ {"role": "user", "content": f"That wasn't one JSON object ({problem}). "
1155
+ "Reply again with only the JSON object."}]
1156
+ else:
1157
+ raise LoreError(f"{task}: the model's answer wasn't JSON")
1158
+ tmp = f.with_suffix(".tmp")
1159
+ tmp.write_text(json.dumps({"task": task, "model": self.model, "key": key, "at": time.strftime(
1160
+ "%Y-%m-%d %H:%M:%S"), "output": out}, ensure_ascii=False, indent=1), encoding="utf-8")
1161
+ os.replace(tmp, f)
1162
+ return out
1163
+
1164
+
1165
+ DISTILL_SYSTEM = """You turn one passage of documentation into reference entries for a programming language
1166
+ and its tools, for an AI that will look them up while writing code. Write entries in your own words, short
1167
+ and exact: every fact a programmer needs (names, arguments and their order, return values, defaults, errors,
1168
+ edge cases), none of the prose. Include only what this passage documents; leave lists empty otherwise.
1169
+
1170
+ An example's "code" is only the program text in the language, exactly as it would be saved in a file: never
1171
+ the shell command that runs it (no `tool '...'` wrapper, no prompt); put such a command in "notes".
1172
+
1173
+ A passage may be a tool's help text or a script that uses the tools: from help, document each tool and every
1174
+ flag; from scripts, take real usage as examples (with "notes" saying what it does) and as evidence for flags.
1175
+
1176
+ Every entry needs "quote": a short passage (5 to 30 words) copied exactly, character for character, from the
1177
+ passage, that supports the entry; one stretch of text, without "..." in it. Entries whose quote isn't in the
1178
+ passage are flagged for a person to check.
1179
+
1180
+ Reply with only a JSON object:
1181
+ {"sections": [{"title": "...", "parent": "title of the section it belongs under, or null",
1182
+ "body": "the section's content as Markdown", "quote": "..."}],
1183
+ "symbols": [{"name": "...", "kind": "KIND", "signature": "...", "summary": "one line",
1184
+ "body": "details: arguments, returns, errors, edge cases", "see": ["related names"], "quote": "..."}],
1185
+ "examples": [{"title": "...", "code": "...", "input": "input it reads, if the documentation shows one",
1186
+ "expect": "its output, only if the documentation shows it exactly", "symbols": ["names it uses"],
1187
+ "notes": "the command that runs it, if the documentation shows one",
1188
+ "quote": "..."}],
1189
+ "tools": [{"name": "command name", "purpose": "one line", "usage": "synopsis", "body": "how to use it",
1190
+ "flags": [{"flag": "-x, --long ARG", "meaning": "..."}], "quote": "..."}]}
1191
+
1192
+ KIND is one of: """ + ", ".join(SYMBOL_KINDS) + "."
1193
+
1194
+ FINISH_SYSTEM = """You write the two short texts an AI reads before using a programming language's reference.
1195
+ Reply with only a JSON object:
1196
+ {"description": "two sentences: what the language is and what this reference covers",
1197
+ "cheatsheet": "the essentials as a Markdown list, at most 25 lines: syntax rules, the mistakes people make
1198
+ most, how to run and check code with its tools",
1199
+ "skill": "how to work in this language with its tools, a short Markdown paragraph or list: how to run, test
1200
+ or check what you write, and how to read the errors"}"""
1201
+
1202
+
1203
+ CALLABLE = {"function", "builtin", "method", "other"} # one name a model may call any of these
1204
+
1205
+
1206
+ def uses(code: str, name: str) -> bool:
1207
+ """Whether code mentions a name as a whole word (names like @csv or .[] by their text)."""
1208
+ if re.fullmatch(r"\w+", name):
1209
+ return re.search(rf"(?<![\w$]){re.escape(name)}(?!\w)", code) is not None
1210
+ return name in code
1211
+
1212
+
1213
+ def norm(s: str) -> str:
1214
+ """For finding a quote: case, spacing and Markdown's backticks and stars don't matter."""
1215
+ return re.sub(r"\s+", " ", re.sub(r"[`*]", "", s)).strip().lower()
1216
+
1217
+
1218
+ def locate(quote: str, chunk_text: str, first_line: int) -> tuple[int, int] | None:
1219
+ """The lines (in the source) a quote spans, or None if it isn't in the chunk (whitespace and case aside)."""
1220
+ pieces = [norm(p) for p in re.split(r"\.\.\.|…", quote) if norm(p)]
1221
+ if not pieces or sum(len(p) for p in pieces) < 8:
1222
+ return None
1223
+ lines = chunk_text.splitlines()
1224
+ flat, starts = "", []
1225
+ for i, line in enumerate(lines):
1226
+ starts.append(len(flat))
1227
+ n = norm(line)
1228
+ if n: # a blank line adds no space: a quote may run across a paragraph break
1229
+ flat += n + " "
1230
+ at = flat.find(pieces[0]) # a shortened quote ("a ... b"): each piece verbatim, in order, close by
1231
+ if at < 0:
1232
+ return None
1233
+ end = at + len(pieces[0])
1234
+ for p in pieces[1:]:
1235
+ nxt = flat.find(p, end)
1236
+ if nxt < 0 or nxt - end > 600:
1237
+ return None
1238
+ end = nxt + len(p)
1239
+ a = max(i for i, st in enumerate(starts) if st <= at)
1240
+ b = max(i for i, st in enumerate(starts) if st <= end - 1)
1241
+ return first_line + a, first_line + b
1242
+
1243
+
1244
+ def distill_all(model: Model, srcs: list[dict], work: Path, language: str, jobs: int, limit: int | None,
1245
+ log=print) -> list[dict]:
1246
+ """[chunk] and [distill]: every chunk of every source through the model (in parallel). Returns the raw entries,
1247
+ each with its chunk."""
1248
+ chunks = []
1249
+ for s in srcs:
1250
+ text = (work / "sources" / f"{s['id']}.txt").read_text(encoding="utf-8")
1251
+ for a, b, t in chunk(text):
1252
+ chunks.append({"id": f"{s['id']}:{a}-{b}", "source": s, "first": a, "last": b, "text": t})
1253
+ if limit:
1254
+ chunks = chunks[:limit]
1255
+ (work / "chunks.json").write_text(json.dumps([{k: c[k] for k in ("id", "first", "last")} | {
1256
+ "source": c["source"]["id"], "name": c["source"]["name"]} for c in chunks], indent=1), encoding="utf-8")
1257
+ log(f"distilling {len(chunks)} chunks ({jobs} at a time)...")
1258
+ done = [0]
1259
+
1260
+ def one(c):
1261
+ user = (f"Language: {language}\nSource: {c['source']['name']}, lines {c['first']}-{c['last']}\n\n"
1262
+ f"Passage:\n{c['text']}")
1263
+ out = model.ask("distill", DISTILL_SYSTEM, user)
1264
+ done[0] += 1
1265
+ if done[0] % 10 == 0 or done[0] == len(chunks):
1266
+ log(f" {done[0]}/{len(chunks)}")
1267
+ return c, out
1268
+ from concurrent.futures import ThreadPoolExecutor
1269
+ with ThreadPoolExecutor(max(1, jobs)) as ex:
1270
+ results = list(ex.map(one, chunks))
1271
+ entries = []
1272
+ for c, out in results:
1273
+ for kind in ("sections", "symbols", "examples", "tools"):
1274
+ for e in out.get(kind) or []:
1275
+ if isinstance(e, dict):
1276
+ entries.append({"kind": kind, "entry": e, "chunk": c})
1277
+ return entries
1278
+
1279
+
1280
+ def verify_and_merge(entries: list[dict]) -> dict[str, list[dict]]:
1281
+ """[verify] and merge: each entry's quote looked up in its chunk (a line-range citation if found, a flag if
1282
+ not), duplicates merged, ids given, sections nested, symbols tied to their chunk's section."""
1283
+ packs: dict[str, list[dict]] = {"sections": [], "symbols": [], "examples": [], "tools": []}
1284
+ top: dict[str, dict] = {}
1285
+ sec_by_title: dict[tuple, str] = {}
1286
+ sec_of_chunk: dict[str, str] = {}
1287
+ ids: set = set()
1288
+
1289
+ def unique(base):
1290
+ i, n = base, 2
1291
+ while i in ids:
1292
+ i, n = f"{base}-{n}", n + 1
1293
+ ids.add(i)
1294
+ return i
1295
+ for item in entries:
1296
+ e, c, kind = item["entry"], item["chunk"], item["kind"]
1297
+ src = c["source"]
1298
+ span = locate(str(e.get("quote") or ""), c["text"], c["first"])
1299
+ flags = [] if span else ["quote not found in the source"]
1300
+ cite = f"{src['name']}, lines {span[0]}-{span[1]}" if span else f"{src['name']}, lines {c['first']}-{c['last']}"
1301
+ review = {"status": "unreviewed", "flags": flags, "quote": e.get("quote"), "chunk": c["id"]}
1302
+ if kind == "sections":
1303
+ if not e.get("title") or not e.get("body"):
1304
+ continue
1305
+ if src["id"] not in top:
1306
+ tid = unique(slug(src["name"]))
1307
+ top[src["id"]] = {"id": tid, "parent": None, "title": src["name"], "body": f"From {src['name']}.",
1308
+ "source": src["name"], "review": {"status": "unreviewed", "flags": []}}
1309
+ packs["sections"].append(top[src["id"]])
1310
+ parent = sec_by_title.get((src["id"], norm(e.get("parent") or ""))) or top[src["id"]]["id"]
1311
+ sid = unique(f"{top[src['id']]['id']}/{slug(e['title'])}")
1312
+ sec_by_title[(src["id"], norm(e["title"]))] = sid
1313
+ sec_of_chunk.setdefault(c["id"], sid)
1314
+ packs["sections"].append({"id": sid, "parent": parent, "title": e["title"], "body": e["body"],
1315
+ "source": cite, "review": review})
1316
+ elif kind == "symbols":
1317
+ name, sk = str(e.get("name") or "").strip(), e.get("kind")
1318
+ if not name:
1319
+ continue
1320
+ if sk not in SYMBOL_KINDS:
1321
+ review["flags"].append(f"kind {sk!r} wasn't one of the known kinds")
1322
+ sk = "other"
1323
+ same = next((y for y in packs["symbols"] if y["name"] == name and (
1324
+ y["kind"] == sk or {y["kind"], sk} <= CALLABLE)), None) # a model's "builtin" vs "function"
1325
+ if same: # documented in two places: keep the fuller, and both citations
1326
+ if len(e.get("body") or "") > len(same.get("body") or ""):
1327
+ same.update({k: e.get(k) or same.get(k) for k in ("signature", "summary", "body")})
1328
+ same["see"] = sorted(set(same["see"]) | set(e.get("see") or []))
1329
+ same["source"] += f"; {cite}"
1330
+ if not flags:
1331
+ same["review"]["flags"] = [f for f in same["review"]["flags"] if "quote" not in f]
1332
+ continue
1333
+ packs["symbols"].append({"name": name, "kind": sk, "signature": e.get("signature"),
1334
+ "summary": e.get("summary"), "body": e.get("body"),
1335
+ "section": c["id"], "source": cite,
1336
+ "see": [s for s in (e.get("see") or []) if isinstance(s, str)],
1337
+ "review": review})
1338
+ elif kind == "examples":
1339
+ if not e.get("code"):
1340
+ continue
1341
+ if any(x["code"].strip() == str(e["code"]).strip() and (x["input"] or "").strip() == str(
1342
+ e.get("input") or "").strip() for x in packs["examples"]):
1343
+ continue # the same example in two places (a manual and its man page)
1344
+ packs["examples"].append({"id": unique(f"ex-{slug(e.get('title') or e['code'][:30], 40)}"),
1345
+ "title": e.get("title"), "code": e["code"], "input": e.get("input") or None,
1346
+ "expect": e.get("expect") or None, "notes": e.get("notes") or None,
1347
+ "symbols": [s for s in (e.get("symbols") or []) if isinstance(s, str)],
1348
+ "source": cite, "review": review})
1349
+ elif kind == "tools":
1350
+ if not e.get("name"):
1351
+ continue
1352
+ same = next((t for t in packs["tools"] if t["name"] == e["name"]), None)
1353
+ if same:
1354
+ have = {f.get("flag") for f in same["flags"]}
1355
+ same["flags"] += [f for f in e.get("flags") or [] if isinstance(f, dict) and f.get("flag") not in have]
1356
+ continue
1357
+ packs["tools"].append({"name": e["name"], "purpose": e.get("purpose"), "usage": e.get("usage"),
1358
+ "body": e.get("body"), "flags": [f for f in e.get("flags") or []
1359
+ if isinstance(f, dict)],
1360
+ "runs": [], "source": cite, "review": review})
1361
+ names = {y["name"] for y in packs["symbols"]}
1362
+ for y in packs["symbols"]: # tie each symbol to its chunk's first section; drop dangling see-alsos
1363
+ y["section"] = sec_of_chunk.get(y["section"]) or None
1364
+ y["see"] = [s for s in y["see"] if s in names and s != y["name"]]
1365
+ for e in packs["examples"]: # only symbols the code really uses: a model's lists are loose
1366
+ e["symbols"] = [s for s in e["symbols"] if s in names and uses(e["code"], s)]
1367
+ return packs
1368
+
1369
+
1370
+ def entry_key(kind: str, e: dict) -> str:
1371
+ return f"{e['name']}\t{e.get('kind', '')}" if kind in ("symbols", "tools") else e.get("id", "")
1372
+
1373
+
1374
+ def by_hand(r: dict) -> bool:
1375
+ """An entry a person wrote (no review record: build never makes one without) or reviewed (accepted, edited,
1376
+ rejected): a rebuild keeps it as it is."""
1377
+ return "review" not in r or (r.get("review") or {}).get("status") in KEEP_ON_REBUILD
1378
+
1379
+
1380
+ def write_pack(out: Path, pack: dict, packs: dict[str, list[dict]], cheatsheet: str, skill: str, log=print,
1381
+ ops: dict | None = None) -> None:
1382
+ """The pack source, keeping what people did: entries written by hand, and entries accepted, edited or rejected
1383
+ in review, stay as they are; everything else is the new build. So a build can extend a hand-written pack."""
1384
+ out.mkdir(parents=True, exist_ok=True)
1385
+ kept = 0
1386
+ for kind, rows in packs.items():
1387
+ old = {entry_key(kind, r): r for r in read_jsonl(out / f"{kind}.jsonl")}
1388
+ merged, seen = [], set()
1389
+ for r in rows:
1390
+ k = entry_key(kind, r)
1391
+ prev = old.get(k)
1392
+ if prev and by_hand(prev):
1393
+ r, kept = prev, kept + 1
1394
+ merged.append(r)
1395
+ seen.add(k)
1396
+ merged += [r for k, r in old.items() if k not in seen and by_hand(r)] # not in the new build: kept
1397
+ tmp = out / f"{kind}.jsonl.tmp"
1398
+ tmp.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in merged), encoding="utf-8")
1399
+ os.replace(tmp, out / f"{kind}.jsonl")
1400
+ (out / "pack.json").write_text(json.dumps(pack, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
1401
+ work = out / ".build"
1402
+ work.mkdir(parents=True, exist_ok=True)
1403
+ texts = [("cheatsheet.md", cheatsheet), ("skill.md", skill)]
1404
+ if ops:
1405
+ texts.append(("operations.json", json.dumps(ops, indent=2, ensure_ascii=False)))
1406
+ for fname, text in texts:
1407
+ # replaced only if nobody changed it since the last build wrote it
1408
+ last, cur = work / f"generated-{fname}", out / fname
1409
+ if not cur.exists() or (last.exists() and cur.read_text(encoding="utf-8") == last.read_text(encoding="utf-8")):
1410
+ cur.write_text(text.strip() + "\n", encoding="utf-8")
1411
+ else:
1412
+ log(f"kept {fname}: it was edited by hand")
1413
+ last.write_text(text.strip() + "\n", encoding="utf-8")
1414
+ if kept:
1415
+ log(f"kept {kept} entries written or reviewed by hand")
1416
+
1417
+
1418
+ OPERATIONS_SYSTEM = """You set up how a team works on code that lives in a database, through its command-line
1419
+ tools. From the tools' documentation and the scripts that use them, write the command template for each operation
1420
+ the tools can do. Reply with only a JSON object:
1421
+ {"variables": {"name": "what it is (e.g. the database name, the operator id)"},
1422
+ "secrets": {"name": "what it is (e.g. the database password)"},
1423
+ "layout": {"ext": {"OBJECTTYPE": ".ext", "*": ".txt"}},
1424
+ "operations": {"OPERATION": {"command": "tool --flag {placeholder} ...", "output": "file" or "stdout",
1425
+ "parse": "a regex for one line of list output, with (?P<name>...) and maybe (?P<type>...)",
1426
+ "notes": "anything a person setting it up should check"}}}
1427
+
1428
+ Operations (leave out any the tools can't do):
1429
+ """ + "\n".join(f"- {k}: {v}" for k, v in OPERATIONS.items()) + """
1430
+
1431
+ A command is one tool invocation, words separated by spaces, no shell (no pipes, no redirection, no &&). Use
1432
+ {placeholders} for the object ({type}, {name}, {file}, {dir}, {project}) and for every site-specific value
1433
+ (database, server, user) as a variable, and every password as a secret: never a real value. Use only flags the
1434
+ documentation shows."""
1435
+
1436
+
1437
+ def draft_operations(model: Model, language: str, packs: dict, log=print) -> dict:
1438
+ """[operations] The model drafts operations.json from the tool entries and script examples; plain code
1439
+ checks it. A draft that doesn't hold together is kept with its problems noted, for review."""
1440
+ tools = "\n\n".join(f"## {t['name']}\n{t.get('purpose') or ''}\nusage: {t.get('usage') or ''}\n"
1441
+ f"{t.get('body') or ''}\n" + "\n".join(f" {f.get('flag')} {f.get('meaning')}"
1442
+ for f in t.get("flags") or [])
1443
+ for t in packs["tools"])
1444
+ used = "\n".join(f"- {e['code'][:300]} ({e.get('notes') or ''})" for e in packs["examples"]
1445
+ if any(t["name"] in e["code"] for t in packs["tools"]))[:20000]
1446
+ ops = model.ask("operations", OPERATIONS_SYSTEM, f"Language: {language}\n\nTools:\n{tools[:40000]}\n\n"
1447
+ f"How the scripts use them:\n{used or '(none)'}")
1448
+ ops = {k: v for k, v in ops.items() if k in ("variables", "secrets", "layout", "operations")}
1449
+ problems = check_operations(ops)
1450
+ if problems:
1451
+ ops["problems"] = problems
1452
+ log("operations drafted, with problems to fix in review: " + "; ".join(problems))
1453
+ else:
1454
+ log(f"operations drafted: {', '.join(sorted(ops.get('operations') or {}))} (review them before use)")
1455
+ return ops
1456
+
1457
+
1458
+ def build(name: str, sources: list[str], tools: list[str], out: Path, model: Model, title: str | None = None,
1459
+ language: str | None = None, version: str | None = None, check: dict | None = None, jobs: int = 4,
1460
+ limit: int | None = None, log=print, help_limit: int = HELP_LIMIT) -> dict:
1461
+ if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,40}", name):
1462
+ raise LoreError("the name: lowercase letters, digits and hyphens (it names the skill)")
1463
+ work = out / ".build"
1464
+ title, language = title or name, language or title or name
1465
+ log("gathering sources...")
1466
+ srcs = gather(sources, tools, work, log, model, help_limit)
1467
+ if not srcs:
1468
+ raise LoreError("no text in any of the sources")
1469
+ entries = distill_all(model, srcs, work, language, jobs, limit, log)
1470
+ packs = verify_and_merge(entries)
1471
+ names = "\n".join(f"- {y['name']} ({y['kind']}): {y.get('summary') or ''}" for y in packs["symbols"][:400])
1472
+ tool_lines = "\n".join(f"- {t['name']}: {t.get('purpose') or ''}" for t in packs["tools"])
1473
+ heads = "\n".join(f"- {s['title']}" for s in packs["sections"][:200])
1474
+ fin = model.ask("finish", FINISH_SYSTEM, f"Language: {language}\n\nSections:\n{heads}\n\nSymbols:\n{names}"
1475
+ f"\n\nTools:\n{tool_lines or '(none documented)'}")
1476
+ ops = {}
1477
+ if packs["tools"]:
1478
+ ops = draft_operations(model, language, packs, log)
1479
+ pack = {"name": name, "title": title, "language": language, "version": version or "",
1480
+ "description": fin.get("description") or f"The {language} reference.",
1481
+ "sources": [s["name"] for s in srcs]}
1482
+ if check:
1483
+ pack["check"] = check
1484
+ write_pack(out, pack, packs, str(fin.get("cheatsheet") or ""), str(fin.get("skill") or ""), log, ops)
1485
+ flagged = sum(1 for rows in packs.values() for r in rows if (r.get("review") or {}).get("flags"))
1486
+ return {"out": str(out), "sources": len(srcs), "chunks": len(json.loads((work / "chunks.json").read_text())),
1487
+ **{k: len(v) for k, v in packs.items()}, "flagged": flagged, "model_calls": model.calls}
1488
+
1489
+
1490
+ # ---- review: a local web page to review a built pack source, entry by entry, beside the source it cites
1491
+
1492
+ REVIEW_FIELDS = {
1493
+ "sections": ["id", "parent", "title", "body"],
1494
+ "symbols": ["name", "kind", "signature", "summary", "body", "see"],
1495
+ "examples": ["id", "title", "code", "input", "expect", "notes", "symbols"],
1496
+ "tools": ["name", "purpose", "usage", "body", "flags"],
1497
+ }
1498
+
1499
+
1500
+ class Review:
1501
+ """The pack source on disk is the state: every read comes from the files, every change is written back."""
1502
+
1503
+ def __init__(self, src: Path):
1504
+ if not (src / "pack.json").exists():
1505
+ raise LoreError(f"{src} isn't a pack source (no pack.json)")
1506
+ self.src = src
1507
+ self.lock = threading.Lock()
1508
+
1509
+ def rows(self, kind: str) -> list[dict]:
1510
+ return read_jsonl(self.src / f"{kind}.jsonl")
1511
+
1512
+ def save(self, kind: str, rows: list[dict]) -> None:
1513
+ tmp = self.src / f"{kind}.jsonl.tmp"
1514
+ tmp.write_text("".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows), encoding="utf-8")
1515
+ os.replace(tmp, self.src / f"{kind}.jsonl")
1516
+
1517
+ def summary(self) -> dict:
1518
+ out = {"pack": json.loads((self.src / "pack.json").read_text(encoding="utf-8")), "kinds": {}}
1519
+ for kind in REVIEW_FIELDS:
1520
+ rows = self.rows(kind)
1521
+ st = lambda r: (r.get("review") or {}).get("status", "unreviewed") # noqa: E731
1522
+ out["kinds"][kind] = {"total": len(rows), "flagged": sum(1 for r in rows if (r.get("review") or {})
1523
+ .get("flags") and st(r) == "unreviewed"),
1524
+ **{s: sum(1 for r in rows if st(r) == s) for s in QUOTE_STATES}}
1525
+ return out
1526
+
1527
+ def listing(self, kind: str, status: str = "", q: str = "") -> list[dict]:
1528
+ out = []
1529
+ for r in self.rows(kind):
1530
+ rv = r.get("review") or {}
1531
+ if status == "flagged":
1532
+ if not rv.get("flags") or rv.get("status", "unreviewed") != "unreviewed":
1533
+ continue
1534
+ elif status and rv.get("status", "unreviewed") != status:
1535
+ continue
1536
+ title = r.get("title") or r.get("name") or r.get("id")
1537
+ if q and q.lower() not in json.dumps(r, ensure_ascii=False).lower():
1538
+ continue
1539
+ out.append({"key": entry_key(kind, r), "title": title, "status": rv.get("status", "unreviewed"),
1540
+ "flags": rv.get("flags") or [], "line": r.get("summary") or r.get("purpose") or
1541
+ (r.get("body") or r.get("code") or "")[:120]})
1542
+ out.sort(key=lambda e: (e["status"] != "unreviewed", not e["flags"], str(e["title"]).lower()))
1543
+ return out
1544
+
1545
+ def entry(self, kind: str, key: str) -> dict:
1546
+ r = next((r for r in self.rows(kind) if entry_key(kind, r) == key), None)
1547
+ if r is None:
1548
+ raise LoreError(f"no {kind} entry {key!r}")
1549
+ return {"entry": r, "fields": REVIEW_FIELDS[kind], "context": self.context(r)}
1550
+
1551
+ def context(self, r: dict) -> dict | None:
1552
+ """The cited source lines (a few around them), and which hold the quote."""
1553
+ rv = r.get("review") or {}
1554
+ m = re.match(r"(.+):(\d+)-(\d+)$", rv.get("chunk") or "")
1555
+ f = self.src / ".build" / "sources" / f"{m.group(1)}.txt" if m else None
1556
+ if not m or not f.exists():
1557
+ return None
1558
+ a, b = int(m.group(2)), int(m.group(3))
1559
+ lines = f.read_text(encoding="utf-8").splitlines()
1560
+ span = locate(str(rv.get("quote") or ""), "\n".join(lines[a - 1:b]), a)
1561
+ lo, hi = (span[0] - 8, span[1] + 8) if span else (a, min(b, a + 60))
1562
+ lo, hi = max(1, lo), min(len(lines), hi)
1563
+ return {"first": lo, "lines": lines[lo - 1:hi], "quote": list(span) if span else None,
1564
+ "chunk": [a, b], "source": next((s["name"] for s in json.loads(
1565
+ (self.src / ".build" / "sources.json").read_text()) if s["id"] == m.group(1)), m.group(1))}
1566
+
1567
+ def update(self, kind: str, key: str, fields: dict | None, status: str | None) -> dict:
1568
+ with self.lock:
1569
+ rows = self.rows(kind)
1570
+ r = next((r for r in rows if entry_key(kind, r) == key), None)
1571
+ if r is None:
1572
+ raise LoreError(f"no {kind} entry {key!r}")
1573
+ rv = r.setdefault("review", {})
1574
+ changed = False
1575
+ for k, v in (fields or {}).items():
1576
+ if k in REVIEW_FIELDS[kind] and r.get(k) != v:
1577
+ r[k], changed = v, True
1578
+ if status:
1579
+ if status not in QUOTE_STATES:
1580
+ raise LoreError(f"status: one of {', '.join(QUOTE_STATES)}")
1581
+ rv["status"] = status
1582
+ elif changed:
1583
+ rv["status"] = "edited"
1584
+ rv["at"] = time.strftime("%Y-%m-%d %H:%M")
1585
+ self.save(kind, rows)
1586
+ return r
1587
+
1588
+ def check_example(self, key: str) -> dict:
1589
+ pack = json.loads((self.src / "pack.json").read_text(encoding="utf-8"))
1590
+ if not pack.get("check"):
1591
+ return {"state": "unchecked", "output": "this pack has no check command (pack.json \"check\")"}
1592
+ if not shutil.which(pack["check"]["command"].split()[0]):
1593
+ return {"state": "unchecked", "output": f"{pack['check']['command'].split()[0]} isn't installed here"}
1594
+ r = next((r for r in self.rows("examples") if entry_key("examples", r) == key), None)
1595
+ if r is None:
1596
+ raise LoreError(f"no example {key!r}")
1597
+ state, output = run_check(pack["check"], r, pack_dir=self.src)
1598
+ return {"state": state, "output": output}
1599
+
1600
+ def text(self, name: str, value: str | None = None) -> str:
1601
+ if name not in ("cheatsheet.md", "skill.md", "operations.json"):
1602
+ raise LoreError("only cheatsheet.md, skill.md and operations.json")
1603
+ f = self.src / name
1604
+ if value is not None and name == "operations.json":
1605
+ try:
1606
+ problems = check_operations(json.loads(value))
1607
+ except json.JSONDecodeError as e:
1608
+ raise LoreError(f"not JSON: {e}") from None
1609
+ if problems:
1610
+ raise LoreError("; ".join(problems))
1611
+ if value is not None:
1612
+ f.write_text(value, encoding="utf-8")
1613
+ return f.read_text(encoding="utf-8") if f.exists() else ""
1614
+
1615
+
1616
+ REVIEW_PAGE = r"""<!doctype html><html lang="en"><head><meta charset="utf-8">
1617
+ <meta name="viewport" content="width=device-width, initial-scale=1"><title>lore review</title>
1618
+ <style>
1619
+ :root{--bg:#f6f7f9;--panel:#fff;--ink:#1b1f24;--muted:#5d6672;--line:#d5dae1;--accent:#2f5d8f;--ok:#2c7a46;--bad:#a8322d;--warn:#94660b;--mark:#fff1b8}
1620
+ @media (prefers-color-scheme:dark){:root{--bg:#15181c;--panel:#1d2126;--ink:#e6e9ed;--muted:#9aa4b0;--line:#343a42;--accent:#7fb0e8;--ok:#7ccf98;--bad:#f0928b;--warn:#e7c06b;--mark:#5a4a12}}
1621
+ *{box-sizing:border-box}body{margin:0;font:14px/1.45 system-ui,sans-serif;background:var(--bg);color:var(--ink)}
1622
+ header{padding:10px 16px;border-bottom:1px solid var(--line);display:flex;gap:12px;align-items:center;flex-wrap:wrap}
1623
+ header b{font-size:15px}.muted{color:var(--muted)}main{display:grid;grid-template-columns:minmax(260px,360px) 1fr;height:calc(100vh - 52px)}
1624
+ @media (max-width:800px){main{grid-template-columns:1fr;height:auto}}
1625
+ #side{border-right:1px solid var(--line);overflow:auto;padding:10px}#detail{overflow:auto;padding:14px 18px}
1626
+ .tabs{display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px}.tabs button,.filters select,.filters input{font:inherit}
1627
+ button{border:1px solid var(--line);background:var(--panel);color:var(--ink);border-radius:6px;padding:4px 10px;cursor:pointer}
1628
+ button.on{border-color:var(--accent);color:var(--accent);font-weight:600}button.ok{border-color:var(--ok);color:var(--ok)}button.bad{border-color:var(--bad);color:var(--bad)}
1629
+ .filters{display:flex;gap:6px;margin-bottom:8px}.filters input{flex:1;min-width:0;padding:4px 6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink)}
1630
+ .item{padding:6px 8px;border-radius:6px;cursor:pointer;border:1px solid transparent}.item:hover{background:var(--panel)}.item.sel{border-color:var(--accent);background:var(--panel)}
1631
+ .item .t{font-weight:600;overflow-wrap:anywhere}.item .l{color:var(--muted);font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
1632
+ .chip{font-size:11px;border-radius:999px;padding:0 6px;border:1px solid var(--line);margin-left:4px}.chip.accepted{color:var(--ok);border-color:var(--ok)}.chip.rejected{color:var(--bad);border-color:var(--bad)}.chip.edited{color:var(--accent);border-color:var(--accent)}.chip.flag{color:var(--warn);border-color:var(--warn)}
1633
+ .grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}@media (max-width:1100px){.grid{grid-template-columns:1fr}}
1634
+ label{display:block;font-size:12px;color:var(--muted);margin:8px 0 2px}textarea,input.f{width:100%;font:13px/1.4 ui-monospace,monospace;padding:6px;border:1px solid var(--line);border-radius:6px;background:var(--panel);color:var(--ink)}
1635
+ pre.src{background:var(--panel);border:1px solid var(--line);border-radius:6px;padding:8px;font-size:12px;white-space:pre-wrap;overflow-wrap:anywhere;margin:0}
1636
+ pre.src span{display:block}pre.src .hit{background:var(--mark)}pre.src .n{color:var(--muted);user-select:none;display:inline-block;width:4em}
1637
+ .acts{display:flex;gap:8px;margin:12px 0;flex-wrap:wrap}.flags{color:var(--warn)}#msg{margin-left:auto}
1638
+ </style></head><body>
1639
+ <header><b id="title">lore review</b><span id="counts" class="muted"></span><span id="msg" class="muted"></span></header>
1640
+ <main><div id="side"><div class="tabs" id="tabs"></div>
1641
+ <div class="filters"><select id="status"><option value="">all</option><option value="flagged">flagged</option><option>unreviewed</option><option>accepted</option><option>edited</option><option>rejected</option></select><input id="q" placeholder="filter"></div>
1642
+ <div id="list"></div></div><div id="detail"><p class="muted">Pick an entry. Flagged ones (their quote isn't in the source) come first.</p></div></main>
1643
+ <script>
1644
+ const TOKEN = "__TOKEN__";
1645
+ const KINDS = ["symbols","sections","examples","tools","texts"];
1646
+ let kind = "symbols", current = null;
1647
+ const $ = (id) => document.getElementById(id);
1648
+ const esc = (s) => String(s ?? "").replace(/[&<>"]/g, (c) => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;"})[c]);
1649
+ async function api(path, body) {
1650
+ const r = await fetch(path, body ? {method:"POST", headers:{"Content-Type":"application/json","X-Lore-Token":TOKEN}, body:JSON.stringify(body)} : {});
1651
+ const j = await r.json(); if (!r.ok) throw new Error(j.error || r.status); return j;
1652
+ }
1653
+ function say(t) { $("msg").textContent = t; setTimeout(() => { if ($("msg").textContent === t) $("msg").textContent = ""; }, 4000); }
1654
+ async function loadSummary() {
1655
+ const s = await api("/api/summary");
1656
+ $("title").textContent = `${s.pack.title} lore: review`;
1657
+ $("tabs").innerHTML = KINDS.map((k) => { const c = s.kinds[k];
1658
+ return `<button data-k="${k}" class="${k===kind?"on":""}">${k}${c ? ` <span class="muted">${c.total - c.unreviewed}/${c.total}</span>` : ""}</button>`; }).join("");
1659
+ $("tabs").querySelectorAll("button").forEach((b) => b.onclick = () => { kind = b.dataset.k; current = null; loadSummary(); loadList(); });
1660
+ const f = Object.values(s.kinds).reduce((a, c) => a + c.flagged, 0), u = Object.values(s.kinds).reduce((a, c) => a + c.unreviewed, 0);
1661
+ $("counts").textContent = `${u} unreviewed, ${f} flagged`;
1662
+ }
1663
+ async function loadList() {
1664
+ if (kind === "texts") { $("list").innerHTML = ["cheatsheet.md","skill.md","operations.json"].map((n) => `<div class="item" data-n="${n}"><div class="t">${n}</div></div>`).join("");
1665
+ $("list").querySelectorAll(".item").forEach((d) => d.onclick = () => openText(d.dataset.n)); return; }
1666
+ const rows = await api(`/api/list?kind=${kind}&status=${encodeURIComponent($("status").value)}&q=${encodeURIComponent($("q").value)}`);
1667
+ $("list").innerHTML = rows.map((r) => `<div class="item${current===r.key?" sel":""}" data-k="${esc(r.key)}"><div class="t">${esc(r.title)}<span class="chip ${r.status}">${r.status}</span>${r.flags.length?'<span class="chip flag">flag</span>':""}</div><div class="l">${esc(r.line)}</div></div>`).join("") || '<p class="muted">nothing here</p>';
1668
+ $("list").querySelectorAll(".item").forEach((d) => d.onclick = () => openEntry(d.dataset.k));
1669
+ }
1670
+ function field(name, v) {
1671
+ const val = typeof v === "string" || v == null ? (v ?? "") : JSON.stringify(v, null, 1);
1672
+ const big = ["body","code","input","expect","notes","flags"].includes(name) || val.length > 80;
1673
+ return `<label>${name}</label>` + (big ? `<textarea data-f="${name}" rows="${Math.min(18, Math.max(3, val.split("\n").length + 1))}">${esc(val)}</textarea>` : `<input class="f" data-f="${name}" value="${esc(val)}">`);
1674
+ }
1675
+ async function openEntry(key) {
1676
+ current = key; loadList();
1677
+ const d = await api(`/api/entry?kind=${kind}&key=${encodeURIComponent(key)}`), e = d.entry, rv = e.review || {}, c = d.context;
1678
+ const src = c ? `<label>${esc(c.source)}, lines ${c.first}-${c.first + c.lines.length - 1}${c.quote ? "" : " (the quote isn't here: this is the chunk it came from)"}</label><pre class="src">${c.lines.map((l, i) => { const n = c.first + i, hit = c.quote && n >= c.quote[0] && n <= c.quote[1];
1679
+ return `<span class="${hit?"hit":""}"><span class="n">${n}</span>${esc(l)}</span>`; }).join("")}</pre>` : '<p class="muted">no source context (hand-written, or the build folder is gone)</p>';
1680
+ $("detail").innerHTML = `<h2 style="margin:0 0 4px">${esc(e.title || e.name || e.id)} <span class="chip ${rv.status||"unreviewed"}">${rv.status||"unreviewed"}</span></h2>
1681
+ ${rv.flags && rv.flags.length ? `<p class="flags">${rv.flags.map(esc).join("; ")}</p>` : ""}
1682
+ <p class="muted">quote: “${esc(rv.quote || "")}” · cited as: ${esc(e.source || "")}</p>
1683
+ <div class="acts"><button class="ok" id="acc">Accept</button><button id="sav">Save edits</button><button class="bad" id="rej">Reject</button>
1684
+ ${kind==="examples" ? '<button id="chk">Run the check</button>' : ""}<span id="chkout" class="muted"></span></div>
1685
+ <div class="grid"><div>${d.fields.map((f) => field(f, e[f])).join("")}</div><div>${src}</div></div>`;
1686
+ const fields = () => { const o = {}; $("detail").querySelectorAll("[data-f]").forEach((el) => { let v = el.value;
1687
+ if (["see","symbols","flags"].includes(el.dataset.f)) { try { v = JSON.parse(v || "[]"); } catch { v = v.split(",").map((s) => s.trim()).filter(Boolean); } }
1688
+ else if (v === "" && ["parent","signature","input","expect","notes"].includes(el.dataset.f)) v = null;
1689
+ o[el.dataset.f] = v; }); return o; };
1690
+ const act = async (status, withFields) => { try { await api("/api/update", {kind, key, status, fields: withFields ? fields() : null});
1691
+ say(status ? status : "saved"); await loadSummary(); await openEntry(key); } catch (err) { say("error: " + err.message); } };
1692
+ $("acc").onclick = () => act("accepted", true); $("rej").onclick = () => act("rejected", false); $("sav").onclick = () => act(null, true);
1693
+ if ($("chk")) $("chk").onclick = async () => { $("chkout").textContent = "running..."; const r = await api("/api/check", {key});
1694
+ $("chkout").textContent = `${r.state}${r.output ? ": " + r.output.slice(0, 300) : ""}`; };
1695
+ }
1696
+ async function openText(name) {
1697
+ const t = await api(`/api/text?name=${name}`);
1698
+ $("detail").innerHTML = `<h2>${name}</h2><textarea id="tx" rows="24">${esc(t.text)}</textarea><div class="acts"><button id="sv">Save</button></div>`;
1699
+ $("sv").onclick = async () => { await api("/api/text", {name, text: $("tx").value}); say("saved"); };
1700
+ }
1701
+ $("status").onchange = loadList; let tq; $("q").oninput = () => { clearTimeout(tq); tq = setTimeout(loadList, 250); };
1702
+ loadSummary().then(loadList);
1703
+ </script></body></html>"""
1704
+
1705
+
1706
+ def review_server(src: Path, host: str = "127.0.0.1", port: int = 8765):
1707
+ """The review page and its little API. Changes need the token embedded in the page, so another site open
1708
+ in the same browser can't post to it."""
1709
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
1710
+ from urllib.parse import parse_qs, urlsplit
1711
+ rv, token = Review(src), secrets.token_urlsafe(16)
1712
+
1713
+ class H(BaseHTTPRequestHandler):
1714
+ def log_message(self, *a):
1715
+ pass
1716
+
1717
+ def send(self, code, obj, ctype="application/json; charset=utf-8"):
1718
+ raw = obj.encode() if isinstance(obj, str) else json.dumps(obj, ensure_ascii=False).encode()
1719
+ self.send_response(code)
1720
+ self.send_header("Content-Type", ctype)
1721
+ self.send_header("Content-Length", str(len(raw)))
1722
+ self.send_header("Cache-Control", "no-store")
1723
+ self.end_headers()
1724
+ self.wfile.write(raw)
1725
+
1726
+ def do_GET(self):
1727
+ u = urlsplit(self.path)
1728
+ q = {k: v[0] for k, v in parse_qs(u.query).items()}
1729
+ try:
1730
+ if u.path == "/":
1731
+ return self.send(200, REVIEW_PAGE.replace("__TOKEN__", token), "text/html; charset=utf-8")
1732
+ if u.path == "/favicon.ico":
1733
+ self.send_response(204)
1734
+ self.end_headers()
1735
+ return None
1736
+ if u.path == "/api/summary":
1737
+ return self.send(200, rv.summary())
1738
+ if u.path == "/api/list":
1739
+ return self.send(200, rv.listing(q.get("kind", "symbols"), q.get("status", ""), q.get("q", "")))
1740
+ if u.path == "/api/entry":
1741
+ return self.send(200, rv.entry(q.get("kind", ""), q.get("key", "")))
1742
+ if u.path == "/api/text":
1743
+ return self.send(200, {"text": rv.text(q.get("name", ""))})
1744
+ self.send(404, {"error": "not found"})
1745
+ except (LoreError, KeyError) as e:
1746
+ self.send(400, {"error": str(e)})
1747
+
1748
+ def do_POST(self):
1749
+ if self.headers.get("X-Lore-Token") != token:
1750
+ return self.send(403, {"error": "no token: use the review page"})
1751
+ try:
1752
+ body = json.loads(self.rfile.read(int(self.headers.get("Content-Length") or 0)) or b"{}")
1753
+ if self.path == "/api/update":
1754
+ return self.send(200, rv.update(body["kind"], body["key"], body.get("fields"), body.get("status")))
1755
+ if self.path == "/api/check":
1756
+ return self.send(200, rv.check_example(body["key"]))
1757
+ if self.path == "/api/text":
1758
+ return self.send(200, {"text": rv.text(body["name"], body["text"])})
1759
+ self.send(404, {"error": "not found"})
1760
+ except (LoreError, KeyError, json.JSONDecodeError) as e:
1761
+ self.send(400, {"error": str(e)})
1762
+
1763
+ srv = ThreadingHTTPServer((host, port), H)
1764
+ srv.token = token
1765
+ return srv
1766
+
1767
+
1768
+ # ---- workspace: code that lives in a database, worked from a git repo through the lore's tools
1769
+
1770
+ WS_DIR = ".lore-workspace"
1771
+ MISSING = r"not found|no such|does not exist|unknown object|doesn't exist|no rows" # a pull's "there's none"
1772
+ class Workspace:
1773
+ """A git repo (or any folder) holding code that lives in a database: files laid out as ROOT/TYPE/NAME.EXT,
1774
+ worked through the lore's operations. `.lore-workspace/config.json` says which lore and the site's
1775
+ variables; the manifest records what each file was pulled from (a hash, and a copy of it), so a deploy can
1776
+ tell when the database changed since."""
1777
+
1778
+ def __init__(self, start: Path | None = None):
1779
+ d = (start or Path.cwd()).resolve()
1780
+ while not (d / WS_DIR / "config.json").exists():
1781
+ if d.parent == d:
1782
+ raise LoreError("not in a workspace (no .lore-workspace/config.json here or above): "
1783
+ "lore.py ws init")
1784
+ d = d.parent
1785
+ self.root = d
1786
+ self.cfg = json.loads((d / WS_DIR / "config.json").read_text(encoding="utf-8"))
1787
+ lore_path = Path(self.cfg["lore"]).expanduser()
1788
+ if not lore_path.is_absolute():
1789
+ lore_path = (d / lore_path).resolve()
1790
+ self.lore = Lore(lore_path)
1791
+ self.ops = json.loads(self.lore.meta.get("operations") or "{}")
1792
+ for k, v in (self.cfg.get("operations") or {}).items(): # site overrides, without a rebuild
1793
+ self.ops.setdefault("operations", {})[k] = v
1794
+ problems = check_operations(self.ops)
1795
+ if problems:
1796
+ raise LoreError("the operations don't hold together:\n " + "\n ".join(problems))
1797
+ self.src = d / self.cfg.get("root", "src")
1798
+ self.mpath = d / WS_DIR / "manifest.json"
1799
+ self.manifest = json.loads(self.mpath.read_text()) if self.mpath.exists() else {}
1800
+
1801
+ # --- running an operation
1802
+ def secrets(self) -> dict:
1803
+ out = {}
1804
+ for key, env in (self.cfg.get("secrets") or {}).items():
1805
+ if env and os.environ.get(env):
1806
+ out[key] = os.environ[env]
1807
+ return out
1808
+
1809
+ def mask(self, text: str) -> str:
1810
+ for v in self.secrets().values():
1811
+ if v:
1812
+ text = text.replace(v, "*****")
1813
+ return text
1814
+
1815
+ def argv(self, op: str, **kw) -> list[str]:
1816
+ """An operation's command, filled in: split into words first, so a value is always exactly one word."""
1817
+ spec = (self.ops.get("operations") or {}).get(op)
1818
+ if not spec:
1819
+ raise LoreError(f"this lore has no {op!r} operation ({OPERATIONS.get(op, '')})")
1820
+ values = {**{k: str(v) for k, v in (self.cfg.get("variables") or {}).items()}, **self.secrets(),
1821
+ **{k: str(v) for k, v in kw.items()}}
1822
+ argv = []
1823
+ for word in shlex.split(spec["command"]): # split first, fill in after: a value can't add words
1824
+ missing = [ph for ph in re.findall(r"\{(\w+)\}", word) if ph not in values]
1825
+ if missing:
1826
+ raise LoreError(f"{op}: no value for {', '.join('{' + m + '}' for m in missing)} (set it under "
1827
+ f"variables or secrets in {WS_DIR}/config.json)")
1828
+ argv.append(re.sub(r"\{(\w+)\}", lambda m: values[m.group(1)], word))
1829
+ return argv
1830
+
1831
+ def run(self, op: str, **kw) -> subprocess.CompletedProcess:
1832
+ argv = self.argv(op, **kw)
1833
+ try:
1834
+ r = subprocess.run(argv, capture_output=True, text=True, stdin=subprocess.DEVNULL, cwd=self.root,
1835
+ timeout=float(self.cfg.get("timeout", 600)))
1836
+ except FileNotFoundError:
1837
+ raise LoreError(f"{op}: {argv[0]} isn't installed (or isn't on PATH)") from None
1838
+ except subprocess.TimeoutExpired:
1839
+ raise LoreError(f"{op}: timed out") from None
1840
+ if r.returncode != 0:
1841
+ raise LoreError(f"{op} failed (exit {r.returncode}): {self.mask((r.stderr or r.stdout).strip())[:2000]}")
1842
+ return r
1843
+
1844
+ # --- objects and files
1845
+ def ext(self, otype: str) -> str:
1846
+ exts = (self.ops.get("layout") or {}).get("ext") or {}
1847
+ return exts.get(otype, exts.get("*", ".txt"))
1848
+
1849
+ def path_for(self, otype: str, name: str) -> Path:
1850
+ if not re.fullmatch(r"[\w.$#@ -]+", otype) or ".." in name.split("/") or name.startswith("/"):
1851
+ raise LoreError(f"can't make a path from {otype!r} {name!r}")
1852
+ return self.src / otype / (name + self.ext(otype))
1853
+
1854
+ def object_of(self, path: Path) -> tuple[str, str]:
1855
+ rel = path.resolve().relative_to(self.src.resolve())
1856
+ otype, rest = rel.parts[0], "/".join(rel.parts[1:])
1857
+ ext = self.ext(otype)
1858
+ return otype, rest[: -len(ext)] if ext and rest.endswith(ext) else rest
1859
+
1860
+ def key(self, path: Path) -> str:
1861
+ return path.resolve().relative_to(self.root).as_posix()
1862
+
1863
+ def fetch(self, otype: str, name: str) -> bytes | None:
1864
+ """The database's copy of an object (None if it isn't there)."""
1865
+ spec = self.ops["operations"]["pull"]
1866
+ with tempfile.TemporaryDirectory() as tmp:
1867
+ f = Path(tmp) / ("object" + self.ext(otype))
1868
+ try:
1869
+ r = self.run("pull", type=otype, name=name, file=f, dir=tmp)
1870
+ except LoreError as e:
1871
+ if re.search(spec.get("missing") or MISSING, str(e), re.I):
1872
+ return None
1873
+ raise
1874
+ content = r.stdout.encode() if spec.get("output") == "stdout" else (
1875
+ f.read_bytes() if f.exists() else b"")
1876
+ if spec.get("missing") and re.search(spec["missing"], r.stdout + r.stderr, re.I):
1877
+ return None
1878
+ return content or None # nothing came back: there's no such object
1879
+
1880
+ def objects(self, otype: str | None = None, project: str | None = None) -> list[tuple[str, str]]:
1881
+ op = "project_list" if project else "list"
1882
+ spec = (self.ops.get("operations") or {}).get(op) or {}
1883
+ r = self.run(op, type=otype or "", project=project or "")
1884
+ rx = re.compile(spec.get("parse") or r"^(?P<name>\S+)")
1885
+ out = []
1886
+ for line in r.stdout.splitlines():
1887
+ m = rx.search(line)
1888
+ if m and m.group("name"):
1889
+ out.append((m.groupdict().get("type") or otype or "", m.group("name").strip()))
1890
+ return out
1891
+
1892
+ def save_manifest(self):
1893
+ self.mpath.parent.mkdir(parents=True, exist_ok=True)
1894
+ tmp = self.mpath.with_suffix(".tmp")
1895
+ tmp.write_text(json.dumps(self.manifest, indent=1, sort_keys=True), encoding="utf-8")
1896
+ os.replace(tmp, self.mpath)
1897
+
1898
+ def record_base(self, path: Path, otype: str, name: str, content: bytes):
1899
+ base = self.root / WS_DIR / "base" / self.key(path)
1900
+ base.parent.mkdir(parents=True, exist_ok=True)
1901
+ base.write_bytes(content)
1902
+ self.manifest[self.key(path)] = {"type": otype, "name": name, "hash": hashlib.sha256(content).hexdigest(),
1903
+ "at": time.strftime("%Y-%m-%d %H:%M:%S")}
1904
+
1905
+ # --- the commands
1906
+ def pull(self, objs: list[tuple[str, str]], force: bool = False, log=print) -> dict:
1907
+ """Objects from the database into their files. A file changed here since its last pull is left alone
1908
+ (unless --force), so a pull never loses work."""
1909
+ done = {"pulled": 0, "same": 0, "kept": 0, "missing": 0}
1910
+ for otype, name in objs:
1911
+ path = self.path_for(otype, name)
1912
+ content = self.fetch(otype, name)
1913
+ if content is None:
1914
+ done["missing"] += 1
1915
+ log(f" {otype} {name}: not in the database")
1916
+ continue
1917
+ m = self.manifest.get(self.key(path))
1918
+ if path.exists() and not force:
1919
+ here = hashlib.sha256(path.read_bytes()).hexdigest()
1920
+ if m is None or here != m["hash"]:
1921
+ if here != hashlib.sha256(content).hexdigest():
1922
+ done["kept"] += 1
1923
+ log(f" {self.key(path)}: changed here, not overwritten (deploy it, or pull --force)")
1924
+ continue
1925
+ same = path.exists() and path.read_bytes() == content
1926
+ path.parent.mkdir(parents=True, exist_ok=True)
1927
+ path.write_bytes(content)
1928
+ self.record_base(path, otype, name, content)
1929
+ done["same" if same else "pulled"] += 1
1930
+ self.save_manifest()
1931
+ return done
1932
+
1933
+ def status(self, remote: bool = False) -> list[tuple[str, str]]:
1934
+ """Each file: same, changed here, changed in the database (with remote), both (a conflict), new here, or
1935
+ gone here."""
1936
+ out = []
1937
+ tracked = set(self.manifest)
1938
+ for k, m in sorted(self.manifest.items()):
1939
+ path = self.root / k
1940
+ here = hashlib.sha256(path.read_bytes()).hexdigest() if path.exists() else None
1941
+ local = "gone here" if here is None else ("changed here" if here != m["hash"] else "")
1942
+ db = ""
1943
+ if remote:
1944
+ cur = self.fetch(m["type"], m["name"])
1945
+ if cur is None:
1946
+ db = "gone from the database"
1947
+ elif hashlib.sha256(cur).hexdigest() != m["hash"]:
1948
+ db = "changed in the database"
1949
+ state = "CONFLICT: " + " and ".join(x for x in (local, db) if x) if local and db else (local or db or "same")
1950
+ out.append((k, state))
1951
+ if self.src.exists():
1952
+ for f in sorted(self.src.rglob("*")):
1953
+ if f.is_file() and self.key(f) not in tracked:
1954
+ out.append((self.key(f), "new here (never pulled)"))
1955
+ return out
1956
+
1957
+ def deploy(self, files: list[Path], force: bool = False, validate: bool = True, compile_: bool = False,
1958
+ log=print) -> dict:
1959
+ """Files into the database. Before writing, the database's copy is compared with what was pulled: if
1960
+ someone deployed over it since, this stops, and leaves base, theirs and yours side by side for a merge by
1961
+ hand (then deploy --force). Nothing is ever overwritten without that check or --force."""
1962
+ done = {"deployed": 0, "conflicts": 0}
1963
+ has = (self.ops.get("operations") or {})
1964
+ for f in files:
1965
+ path = f.resolve()
1966
+ otype, name = self.object_of(path)
1967
+ k, mine = self.key(path), path.read_bytes()
1968
+ if validate and "validate" in has:
1969
+ self.run("validate", type=otype, name=name, file=path, dir=path.parent)
1970
+ if not force:
1971
+ cur = self.fetch(otype, name)
1972
+ m = self.manifest.get(k)
1973
+ base_hash = m["hash"] if m else None
1974
+ cur_hash = hashlib.sha256(cur).hexdigest() if cur is not None else None
1975
+ if cur_hash != base_hash and cur_hash != hashlib.sha256(mine).hexdigest():
1976
+ cdir = self.root / WS_DIR / "conflicts" / k
1977
+ cdir.mkdir(parents=True, exist_ok=True)
1978
+ base = self.root / WS_DIR / "base" / k
1979
+ (cdir / "base").write_bytes(base.read_bytes() if base.exists() else b"")
1980
+ (cdir / "theirs").write_bytes(cur or b"")
1981
+ (cdir / "mine").write_bytes(mine)
1982
+ done["conflicts"] += 1
1983
+ who = "isn't in the database any more" if cur is None else (
1984
+ "was deployed by someone else since your pull" if m else "already exists in the database")
1985
+ log(f" CONFLICT {k}: it {who}. Not deployed.\n"
1986
+ f" merge by hand: code --merge {cdir}/mine {cdir}/theirs {cdir}/base {k}\n"
1987
+ f" (or: git merge-file {k} {cdir}/base {cdir}/theirs), then: lore.py ws deploy {k} --force")
1988
+ continue
1989
+ self.run("deploy", type=otype, name=name, file=path, dir=path.parent)
1990
+ if compile_ and "compile" in has:
1991
+ self.run("compile", type=otype, name=name, file=path, dir=path.parent)
1992
+ self.record_base(path, otype, name, mine)
1993
+ shutil.rmtree(self.root / WS_DIR / "conflicts" / k, ignore_errors=True)
1994
+ done["deployed"] += 1
1995
+ log(f" {k}: deployed")
1996
+ self.save_manifest()
1997
+ return done
1998
+
1999
+
2000
+ def portable(path: Path, root: Path, vscode: bool = False) -> str:
2001
+ """A path for a file the team commits: relative inside the repo, ~ (or VS Code's ${userHome}) inside the
2002
+ home folder, absolute otherwise. Everyone who installed the lore in the same place can use it as is."""
2003
+ home = Path.home().resolve()
2004
+ try:
2005
+ return path.relative_to(root.resolve()).as_posix()
2006
+ except ValueError:
2007
+ pass
2008
+ try:
2009
+ rest = path.relative_to(home).as_posix()
2010
+ return ("${userHome}/" if vscode else "~/") + rest
2011
+ except ValueError:
2012
+ return str(path)
2013
+
2014
+
2015
+ def ws_init(root: Path, lore_path: Path, src: str = "src") -> Path:
2016
+ """A workspace in `root`: config (which lore, the site's variables and secrets to fill in), .gitignore lines
2017
+ for what's per developer, and VS Code tasks for the commands."""
2018
+ lore = Lore(lore_path)
2019
+ ops = json.loads(lore.meta.get("operations") or "{}")
2020
+ if not ops.get("operations"):
2021
+ raise LoreError(f"{lore_path.name} has no operations (its pack source needs operations.json)")
2022
+ d = root / WS_DIR
2023
+ if (d / "config.json").exists():
2024
+ raise LoreError(f"{root} is already a workspace ({WS_DIR}/config.json)")
2025
+ d.mkdir(parents=True)
2026
+ lore_ref = portable(lore_path.resolve(), root)
2027
+ cfg = {"lore": lore_ref, "root": src,
2028
+ "variables": {k: "" for k in (ops.get("variables") or {})},
2029
+ "secrets": {k: f"LORE_{k.upper()}" for k in (ops.get("secrets") or {})},
2030
+ "operations": {}, "timeout": 600}
2031
+ (d / "config.json").write_text(json.dumps(cfg, indent=2) + "\n", encoding="utf-8")
2032
+ gi = root / ".gitignore"
2033
+ lines = gi.read_text().splitlines() if gi.exists() else []
2034
+ for want in (f"{WS_DIR}/base/", f"{WS_DIR}/conflicts/", f"{WS_DIR}/manifest.json"):
2035
+ if want not in lines:
2036
+ lines.append(want)
2037
+ gi.write_text("\n".join(lines) + "\n", encoding="utf-8")
2038
+ tasks = root / ".vscode" / "tasks.json"
2039
+ if not tasks.exists():
2040
+ tasks.parent.mkdir(exist_ok=True)
2041
+ beside = lore_path.resolve().parent / "lore.py" # an installed skill: its own lore.py
2042
+ lore_py = portable(beside if beside.exists() else Path(__file__).resolve(), root, vscode=True)
2043
+ mk = lambda label, args: {"label": label, "type": "process", "command": "python3", # noqa: E731
2044
+ "args": [lore_py, "ws", *args], "problemMatcher": []}
2045
+ tasks.write_text(json.dumps({"version": "2.0.0", "tasks": [
2046
+ mk("lore: status", ["status"]), mk("lore: status (against the database)", ["status", "--remote"]),
2047
+ mk("lore: pull this file", ["pull", "--file", "${file}"]),
2048
+ mk("lore: validate this file", ["validate", "${file}"]),
2049
+ mk("lore: deploy this file", ["deploy", "${file}"]),
2050
+ mk("lore: deploy this file (after merging a conflict)", ["deploy", "${file}", "--force"]),
2051
+ ]}, indent=2) + "\n", encoding="utf-8")
2052
+ return d / "config.json"
2053
+
2054
+
2055
+ def ws_test(ws: Workspace, otype: str | None, log=print) -> bool:
2056
+ """Each operation tried safely: list (or a project's), then one object pulled to a temporary file and
2057
+ validated. Nothing is deployed or changed."""
2058
+ ok = True
2059
+ types = [otype] if otype else [t for t in ((ws.ops.get("layout") or {}).get("ext") or {}) if t != "*"][:1] or [""]
2060
+ try:
2061
+ objs = ws.objects(types[0])
2062
+ log(f"list {types[0]}: {len(objs)} objects" + (f", e.g. {objs[0][0]} {objs[0][1]}" if objs else ""))
2063
+ except LoreError as e:
2064
+ log(f"list: FAILED: {e}")
2065
+ return False
2066
+ if not objs:
2067
+ log("nothing listed: give --type of a kind of object the database has")
2068
+ return False
2069
+ t, n = objs[0]
2070
+ try:
2071
+ content = ws.fetch(t or types[0], n)
2072
+ log(f"pull {t} {n}: {'ok, ' + str(len(content)) + ' bytes' if content else 'FAILED: nothing came back'}")
2073
+ ok = ok and bool(content)
2074
+ except LoreError as e:
2075
+ log(f"pull: FAILED: {e}")
2076
+ return False
2077
+ if "validate" in ws.ops["operations"] and content:
2078
+ with tempfile.TemporaryDirectory() as tmp:
2079
+ f = Path(tmp) / ("object" + ws.ext(t or types[0]))
2080
+ f.write_bytes(content)
2081
+ try:
2082
+ ws.run("validate", type=t or types[0], name=n, file=f, dir=tmp)
2083
+ log("validate: ok (the object as it is in the database)")
2084
+ except LoreError as e:
2085
+ log(f"validate: FAILED: {e}")
2086
+ ok = False
2087
+ return ok
2088
+
2089
+
2090
+ # ---- mcp: the lookups as an MCP server (stdio), for VS Code, Cursor, Claude Desktop and any other MCP client
2091
+
2092
+ MCP_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05") # newest first; we answer in the client's if we know it
2093
+
2094
+
2095
+ def mcp_tools(lores: dict[str, Lore]) -> list[dict]:
2096
+ """The tools: the lookup commands, each described for a model that hasn't opened the skill. With several
2097
+ lores, each tool takes `lore` (one of their names)."""
2098
+ names = list(lores)
2099
+ langs = ", ".join(lores[n].meta["language"] for n in names)
2100
+ which = {"lore": {"type": "string", "enum": names, "description": "Which reference: " + ", ".join(
2101
+ f"{n} ({lores[n].meta['title']})" for n in names)}} if len(names) > 1 else {}
2102
+ s = lambda props=None, required=(): {"type": "object", "properties": {**which, **(props or {})}, # noqa: E731
2103
+ "required": (["lore"] if which else []) + list(required)}
2104
+ text = lambda d: {"type": "string", "description": d} # noqa: E731
2105
+ num = lambda d: {"type": "integer", "description": d} # noqa: E731
2106
+ tools = [
2107
+ ("cheatsheet", f"The essentials of {langs}: read once at the start of a task, before writing code.", s()),
2108
+ ("search", f"Search the {langs} reference: sections, symbols, tools, examples and notes, ranked, with "
2109
+ "snippets. A word that is exactly a symbol's name puts that symbol first.",
2110
+ s({"query": text("words to look for, e.g. a function name or what you want to do"),
2111
+ "kind": {"type": "string", "enum": ["section", "symbol", "tool", "example", "note"],
2112
+ "description": "only this kind of entry"},
2113
+ "limit": num("how many hits (default 8)")}, ["query"])),
2114
+ ("symbol", f"One {langs} function, class, keyword, operator...: signature, behaviour, see-also, examples "
2115
+ "and notes. Look up anything you aren't certain of before using it.",
2116
+ s({"name": text("its exact name; a near miss suggests the right one")}, ["name"])),
2117
+ ("toc", "The reference's outline, or one branch of it (section ids in brackets).",
2118
+ s({"id": text("a section id, for its branch")})),
2119
+ ("show", "One section of the reference, a page at a time.",
2120
+ s({"id": text("the section id"), "from": num("the line to start at, for the next page")}, ["id"])),
2121
+ ("examples", "Worked examples, each marked whether the language's own tool ran it as shown.",
2122
+ s({"query": text("words to look for"), "symbol": text("examples that use this symbol"),
2123
+ "id": text("one example by id")})),
2124
+ ("tools", f"The command-line tools that come with {langs}.", s()),
2125
+ ("tool", "One command-line tool: purpose, usage, flags and real runs with their output.",
2126
+ s({"name": text("the tool's name")}, ["name"])),
2127
+ ("note", "Keep something learned the hard way (the reference was wrong, missing or surprising), so the "
2128
+ "next lookup shows it.",
2129
+ s({"text": text("what you learned"), "on": text("the symbol, section or tool it's about")}, ["text"])),
2130
+ ("info", "What this reference covers, its counts and where it came from.", s()),
2131
+ ]
2132
+ return [{"name": n, "description": d, "inputSchema": sch} for n, d, sch in tools]
2133
+
2134
+
2135
+ def mcp_call(lores: dict[str, Lore], name: str, a: dict) -> str:
2136
+ """Run one tool: the same code as the command line, so both always agree."""
2137
+ lore = lores[a["lore"]] if len(lores) > 1 else next(iter(lores.values()))
2138
+ words = lambda v: str(v).split() if v else [] # noqa: E731
2139
+ ns = {"cheatsheet": {}, "info": {}, "tools": {},
2140
+ "search": {"query": words(a.get("query")), "kind": a.get("kind"), "n": int(a.get("limit") or 8)},
2141
+ "symbol": {"name": a.get("name", "")},
2142
+ "toc": {"id": a.get("id")},
2143
+ "show": {"id": a.get("id", ""), "from_": int(a.get("from") or 0)},
2144
+ "examples": {"query": words(a.get("query")), "symbol": a.get("symbol"), "id": a.get("id"), "n": 5},
2145
+ "tool": {"name": a.get("name", ""), "from_": int(a.get("from") or 0)},
2146
+ "note": {"text": words(a.get("text")), "on": a.get("on")}}
2147
+ if name not in ns:
2148
+ raise LoreError(f"no tool {name!r}")
2149
+ return LOOKUPS[name](lore, argparse.Namespace(**ns[name]))
2150
+
2151
+
2152
+ def mcp_serve(lores: dict[str, Lore], stdin=None, stdout=None) -> None:
2153
+ """MCP over stdio: one JSON-RPC message per line in, one per line out. Nothing else goes to stdout."""
2154
+ stdin, stdout = stdin or sys.stdin, stdout or sys.stdout
2155
+ tools = mcp_tools(lores)
2156
+ first = next(iter(lores.values())).meta
2157
+ title = " + ".join(lo.meta["title"] for lo in lores.values())
2158
+
2159
+ def send(msg):
2160
+ stdout.write(json.dumps({"jsonrpc": "2.0", **msg}, ensure_ascii=False) + "\n")
2161
+ stdout.flush()
2162
+
2163
+ for line in stdin:
2164
+ if not line.strip():
2165
+ continue
2166
+ try:
2167
+ msg = json.loads(line)
2168
+ except json.JSONDecodeError:
2169
+ send({"id": None, "error": {"code": -32700, "message": "parse error"}})
2170
+ continue
2171
+ mid, method, params = msg.get("id"), msg.get("method"), msg.get("params") or {}
2172
+ if mid is None: # a notification (initialized, cancelled...): nothing to answer
2173
+ continue
2174
+ if method == "initialize":
2175
+ asked = params.get("protocolVersion")
2176
+ send({"id": mid, "result": {
2177
+ "protocolVersion": asked if asked in MCP_VERSIONS else MCP_VERSIONS[0],
2178
+ "capabilities": {"tools": {"listChanged": False}},
2179
+ "serverInfo": {"name": f"{first['name']}-lore" if len(lores) == 1 else "lorewell",
2180
+ "title": f"{title} reference", "version": __version__},
2181
+ "instructions": (f"A searchable reference for {title}. Before writing or fixing code in it, read "
2182
+ "cheatsheet once, then look up each function, keyword or tool you aren't certain "
2183
+ "of (symbol, or search when you don't know the name). Prefer checked examples "
2184
+ "to guesses, and note what the reference got wrong.")}})
2185
+ elif method == "ping":
2186
+ send({"id": mid, "result": {}})
2187
+ elif method == "tools/list":
2188
+ send({"id": mid, "result": {"tools": tools}})
2189
+ elif method == "tools/call":
2190
+ try:
2191
+ text, err = mcp_call(lores, params.get("name", ""), params.get("arguments") or {}), False
2192
+ except (LoreError, KeyError, ValueError) as e:
2193
+ text, err = str(e) if not isinstance(e, KeyError) else f"which lore? {e} isn't one", True
2194
+ send({"id": mid, "result": {"content": [{"type": "text", "text": text}], "isError": err}})
2195
+ else:
2196
+ send({"id": mid, "error": {"code": -32601, "message": f"method not found: {method}"}})
2197
+
2198
+
2199
+ # ---- command line: argument parsing, main
2200
+
2201
+ LOOKUPS = {"info": cmd_info, "cheatsheet": cmd_cheatsheet, "toc": cmd_toc, "search": cmd_search,
2202
+ "symbol": cmd_symbol, "show": cmd_show, "examples": cmd_examples, "tools": cmd_tools, "tool": cmd_tool,
2203
+ "note": cmd_note, "notes": cmd_notes}
2204
+
2205
+
2206
+ def parser() -> argparse.ArgumentParser:
2207
+ ap = argparse.ArgumentParser(prog="lorewell" if Path(sys.argv[0]).name != "lore.py" else "lore.py",
2208
+ description="Reference packs (lores) for languages and their tools.")
2209
+ ap.add_argument("--version", action="version", version=f"lorewell {__version__}")
2210
+ ap.add_argument("--lore", help="the .lore.db to read (default: $LORE, or the one beside lore.py)")
2211
+ sub = ap.add_subparsers(dest="cmd")
2212
+ c = sub.add_parser("compile", help="compile a pack source folder into a lore")
2213
+ c.add_argument("src")
2214
+ c.add_argument("-o", "--out", help="where to write it (default: NAME.lore.db here)")
2215
+ c.add_argument("--no-check", action="store_true", help="don't run the examples through the check command")
2216
+ i = sub.add_parser("install", help="install a lore as a skill (by default for the agent that's here)")
2217
+ i.add_argument("lore_file", metavar="LORE")
2218
+ where = i.add_mutually_exclusive_group()
2219
+ where.add_argument("--hobkin", action="store_true", help="~/.hobkins/_shared/skills (for every hobkin here)")
2220
+ where.add_argument("--claude", action="store_true", help="~/.claude/skills (Claude Code, for you)")
2221
+ where.add_argument("--project", metavar="DIR", help="DIR/.claude/skills (Claude Code, for one project)")
2222
+ where.add_argument("--to", metavar="DIR", help="any skills folder")
2223
+ sub.add_parser("info", help="what this lore covers")
2224
+ sub.add_parser("cheatsheet", help="the essentials")
2225
+ t = sub.add_parser("toc", help="the outline, or one branch of it")
2226
+ t.add_argument("id", nargs="?")
2227
+ s = sub.add_parser("search", help="ranked hits with snippets")
2228
+ s.add_argument("query", nargs="+")
2229
+ s.add_argument("--kind", choices=["section", "symbol", "tool", "example", "note"])
2230
+ s.add_argument("-n", type=int, default=8, help="how many hits (default 8)")
2231
+ y = sub.add_parser("symbol", help="one function/class/keyword/...")
2232
+ y.add_argument("name")
2233
+ sh = sub.add_parser("show", help="a section, a page at a time")
2234
+ sh.add_argument("id")
2235
+ sh.add_argument("--from", dest="from_", type=int, default=0, help="the line to start at")
2236
+ e = sub.add_parser("examples", help="worked examples")
2237
+ e.add_argument("query", nargs="*")
2238
+ e.add_argument("--symbol")
2239
+ e.add_argument("--id")
2240
+ e.add_argument("-n", type=int, default=5)
2241
+ b = sub.add_parser("build", help="build a pack source from documentation, with a model")
2242
+ b.add_argument("name", help="the lore's name (lowercase, hyphens): it names the skill, NAME-lore")
2243
+ b.add_argument("sources", nargs="*", help="PDF, HTML, Markdown or text files, folders of them, or URLs")
2244
+ b.add_argument("--tool", action="append", default=[], metavar="CMD",
2245
+ help="a command-line tool of the language: its help (every subcommand's, found by the model; "
2246
+ "only help is ever run) and man page are read (repeatable)")
2247
+ b.add_argument("--out", help="the pack source folder to write (default: packs/NAME)")
2248
+ b.add_argument("--title")
2249
+ b.add_argument("--language", help="the language's name, if it isn't the title")
2250
+ b.add_argument("--version", dest="lang_version", help="the language's version the documentation is for")
2251
+ b.add_argument("--check", metavar="CMD", help="a command that runs an example: {file} is its code, its input "
2252
+ "goes on stdin, e.g. 'jq -c -f {file}'")
2253
+ b.add_argument("--check-ext", default=".txt", help="the example file's extension, for --check")
2254
+ b.add_argument("--check-compare", choices=["exact", "words", "json"], default="exact",
2255
+ help="how an example's output is compared with its expected output (json: as JSON values)")
2256
+ b.add_argument("--base-url", default=os.environ.get("LORE_BASE_URL"),
2257
+ help="an OpenAI-format endpoint (default: $LORE_BASE_URL), e.g. https://openrouter.ai/api/v1")
2258
+ b.add_argument("--model", default=os.environ.get("LORE_MODEL"), help="the model (default: $LORE_MODEL)")
2259
+ b.add_argument("--key-env", default="LORE_API_KEY", help="the variable holding the API key (default: "
2260
+ "LORE_API_KEY; none needed for a local endpoint)")
2261
+ b.add_argument("--jobs", type=int, default=4, help="chunks read at once (default 4)")
2262
+ b.add_argument("--help-limit", type=int, default=HELP_LIMIT,
2263
+ help=f"help pages read per tool, subcommands included (default {HELP_LIMIT})")
2264
+ b.add_argument("--limit", type=int, help="only the first N chunks (to try a build cheaply)")
2265
+ r = sub.add_parser("review", help="review a built pack source in the browser")
2266
+ r.add_argument("src", help="the pack source folder")
2267
+ r.add_argument("--port", type=int, default=8765)
2268
+ r.add_argument("--host", default="127.0.0.1")
2269
+ w = sub.add_parser("ws", aliases=["workspace"],
2270
+ help="code that lives in a database, in a git repo: pull, status, deploy, validate")
2271
+ wsub = w.add_subparsers(dest="ws_cmd")
2272
+ wi = wsub.add_parser("init", help="make this folder a workspace for a lore that has operations")
2273
+ wi.add_argument("lore_file", nargs="?", metavar="LORE", help="the .lore.db (default: --lore, $LORE, or the "
2274
+ "one beside lore.py)")
2275
+ wi.add_argument("--root", default="src", help="the folder the objects go in (default: src)")
2276
+ wl = wsub.add_parser("list", help="objects in the database")
2277
+ wl.add_argument("--type")
2278
+ wl.add_argument("--project")
2279
+ wp = wsub.add_parser("pull", help="objects from the database into files")
2280
+ wp.add_argument("object", nargs="*", metavar="TYPE NAME")
2281
+ wp.add_argument("--type", help="every object of this type")
2282
+ wp.add_argument("--project", help="every object in this project")
2283
+ wp.add_argument("--file", action="append", default=[], help="the object this file holds (repeatable)")
2284
+ wp.add_argument("--force", action="store_true", help="overwrite files changed here")
2285
+ wst = wsub.add_parser("status", help="what changed here (and, with --remote, in the database)")
2286
+ wst.add_argument("--remote", action="store_true", help="also compare with the database (one pull per object)")
2287
+ wd = wsub.add_parser("deploy", help="files into the database, after a conflict check")
2288
+ wd.add_argument("files", nargs="+", metavar="FILE")
2289
+ wd.add_argument("--force", action="store_true", help="skip the conflict check (after merging by hand)")
2290
+ wd.add_argument("--no-validate", action="store_true", help="don't validate first")
2291
+ wd.add_argument("--compile", action="store_true", help="compile each object after deploying it")
2292
+ wv = wsub.add_parser("validate", help="check files with the tools, without changing the database")
2293
+ wv.add_argument("files", nargs="+", metavar="FILE")
2294
+ wc = wsub.add_parser("compile", help="compile objects in the database")
2295
+ wc.add_argument("files", nargs="+", metavar="FILE")
2296
+ wt = wsub.add_parser("test", help="try the operations safely: list, pull one object, validate it")
2297
+ wt.add_argument("--type")
2298
+ m = sub.add_parser("mcp", help="serve lores as an MCP server over stdio (for VS Code, Cursor, Claude Desktop...)")
2299
+ m.add_argument("lores", nargs="*", metavar="LORE", help="the .lore.db files (default: --lore, $LORE, or the one "
2300
+ "beside lore.py)")
2301
+ sub.add_parser("tools", help="the command-line tools")
2302
+ tl = sub.add_parser("tool", help="one tool")
2303
+ tl.add_argument("name")
2304
+ tl.add_argument("--from", dest="from_", type=int, default=0)
2305
+ n = sub.add_parser("note", help="keep something learned the hard way")
2306
+ n.add_argument("text", nargs="+")
2307
+ n.add_argument("--on", help="the symbol, section or tool it's about")
2308
+ n.add_argument("--by", help="who's noting it (default: the hobkin, else the user)")
2309
+ ns = sub.add_parser("notes", help="what's been noted")
2310
+ ns.add_argument("query", nargs="*")
2311
+ return ap
2312
+
2313
+
2314
+ def cmd_ws(args) -> int:
2315
+ if args.ws_cmd == "init":
2316
+ cfg = ws_init(Path.cwd(), Path(args.lore_file).expanduser() if args.lore_file else find_lore(args.lore),
2317
+ args.root)
2318
+ c = json.loads(cfg.read_text())
2319
+ print(f"workspace set up: {cfg}")
2320
+ if c["variables"]:
2321
+ print(f" fill in the variables there: {', '.join(c['variables'])}")
2322
+ if c["secrets"]:
2323
+ print(" and set the secrets as environment variables: "
2324
+ + ", ".join(f"{v} ({k})" for k, v in c["secrets"].items()))
2325
+ print(" then: lore.py ws test")
2326
+ return 0
2327
+ ws = Workspace()
2328
+ if args.ws_cmd == "list":
2329
+ for t, n in ws.objects(args.type, args.project):
2330
+ print(f"{t} {n}".strip())
2331
+ return 0
2332
+ if args.ws_cmd == "pull":
2333
+ objs = []
2334
+ if len(args.object) % 2:
2335
+ raise LoreError("pull TYPE NAME [TYPE NAME...]")
2336
+ objs += list(zip(args.object[::2], args.object[1::2]))
2337
+ objs += [ws.object_of(Path(f)) for f in args.file]
2338
+ if args.type or args.project:
2339
+ objs += ws.objects(args.type, args.project)
2340
+ if not objs:
2341
+ raise LoreError("pull what? TYPE NAME, --type T, --project P or --file F")
2342
+ r = ws.pull(objs, force=args.force)
2343
+ print(f"pulled {r['pulled']}, unchanged {r['same']}, kept (changed here) {r['kept']}, "
2344
+ f"missing {r['missing']}")
2345
+ return 0
2346
+ if args.ws_cmd == "status":
2347
+ rows = [(k, st) for k, st in ws.status(args.remote) if st != "same"]
2348
+ print("\n".join(f"{st:<40} {k}" for k, st in rows) or "everything is as pulled")
2349
+ return 1 if any(st.startswith("CONFLICT") for _, st in rows) else 0
2350
+ if args.ws_cmd == "deploy":
2351
+ r = ws.deploy([Path(f) for f in args.files], force=args.force, validate=not args.no_validate,
2352
+ compile_=args.compile)
2353
+ print(f"deployed {r['deployed']}" + (f", {r['conflicts']} CONFLICTS (not deployed)" if r["conflicts"] else ""))
2354
+ return 1 if r["conflicts"] else 0
2355
+ if args.ws_cmd in ("validate", "compile"):
2356
+ for f in args.files:
2357
+ t, n = ws.object_of(Path(f))
2358
+ r = ws.run(args.ws_cmd, type=t, name=n, file=Path(f).resolve(), dir=Path(f).resolve().parent)
2359
+ print(f"{f}: ok" + (f"\n{ws.mask(r.stdout.strip())}" if r.stdout.strip() else ""))
2360
+ return 0
2361
+ if args.ws_cmd == "test":
2362
+ return 0 if ws_test(ws, args.type) else 1
2363
+ raise LoreError("ws what? init, list, pull, status, deploy, validate, compile, test")
2364
+
2365
+
2366
+ def main(argv: list[str] | None = None) -> int:
2367
+ ap = parser()
2368
+ args = ap.parse_args(argv)
2369
+ try:
2370
+ if args.cmd == "compile":
2371
+ src = Path(args.src)
2372
+ name = json.loads((src / "pack.json").read_text())["name"] if (src / "pack.json").exists() else "pack"
2373
+ r = compile_pack(src, Path(args.out or f"{name}.lore.db"), check=not args.no_check)
2374
+ c = r["checked"]
2375
+ print(f"{r['lore']}: {r['sections']} sections, {r['symbols']} symbols, {r['tools']} tools, "
2376
+ f"{r['examples']} examples ({c['pass']} pass, {c['fail']} fail, {c['error']} error, "
2377
+ f"{c['unchecked']} unchecked), {r['warnings']} warnings")
2378
+ return 1 if c["fail"] or c["error"] else 0
2379
+ if args.cmd == "install":
2380
+ folder = install(Path(args.lore_file), install_dir(args))
2381
+ print(f"installed: {folder}\n SKILL.md, lore.py, {Path(args.lore_file).name}"
2382
+ f"\n try: python3 {folder / 'lore.py'} info"
2383
+ f"\n as an MCP server (VS Code, Cursor, Claude Desktop): python3 {folder / 'lore.py'} mcp")
2384
+ return 0
2385
+ if args.cmd == "build":
2386
+ if not args.base_url or not args.model:
2387
+ raise LoreError("build needs a model: --base-url and --model (or LORE_BASE_URL and LORE_MODEL)")
2388
+ if not args.sources and not args.tool:
2389
+ raise LoreError("build from what? give documentation (files, folders, URLs) and/or --tool CMD")
2390
+ out = Path(args.out or f"packs/{args.name}")
2391
+ model = Model(args.base_url, args.model, os.environ.get(args.key_env), out / ".build" / "answers")
2392
+ check = {"command": args.check, "ext": args.check_ext,
2393
+ "compare": args.check_compare} if args.check else None
2394
+ r = build(args.name, args.sources, args.tool, out, model, args.title, args.language, args.lang_version,
2395
+ check, args.jobs, args.limit, help_limit=args.help_limit)
2396
+ print(f"{r['out']}: {r['sources']} sources, {r['chunks']} chunks -> {r['sections']} sections, "
2397
+ f"{r['symbols']} symbols, {r['examples']} examples, {r['tools']} tools; {r['flagged']} flagged "
2398
+ f"for review; {r['model_calls']} model calls (the rest were recorded)"
2399
+ f"\n next: lorewell review {r['out']} then: lorewell compile {r['out']}")
2400
+ return 0
2401
+ if args.cmd == "review":
2402
+ srv = review_server(Path(args.src), args.host, args.port)
2403
+ print(f"reviewing {args.src}: http://{args.host}:{srv.server_address[1]}/ (Ctrl-C to stop)")
2404
+ try:
2405
+ srv.serve_forever()
2406
+ except KeyboardInterrupt:
2407
+ pass
2408
+ return 0
2409
+ if args.cmd in ("ws", "workspace"):
2410
+ return cmd_ws(args)
2411
+ if args.cmd == "mcp":
2412
+ paths = [Path(p).expanduser() for p in args.lores] or [find_lore(args.lore)]
2413
+ lores = {}
2414
+ for p in paths:
2415
+ lo = Lore(p)
2416
+ if lo.meta["name"] in lores:
2417
+ raise LoreError(f"two lores named {lo.meta['name']!r}")
2418
+ lores[lo.meta["name"]] = lo
2419
+ mcp_serve(lores)
2420
+ return 0
2421
+ if args.cmd in LOOKUPS:
2422
+ print(LOOKUPS[args.cmd](Lore(find_lore(args.lore)), args))
2423
+ return 0
2424
+ ap.print_help()
2425
+ return 0
2426
+ except LoreError as e:
2427
+ print(f"lore: {e}", file=sys.stderr)
2428
+ return 2
2429
+
2430
+
2431
+ def cli() -> None:
2432
+ sys.exit(main())
2433
+
2434
+
2435
+ if __name__ == "__main__":
2436
+ cli()