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-0.3.0.dist-info/METADATA +285 -0
- lorewell-0.3.0.dist-info/RECORD +6 -0
- lorewell-0.3.0.dist-info/WHEEL +4 -0
- lorewell-0.3.0.dist-info/entry_points.txt +3 -0
- lorewell-0.3.0.dist-info/licenses/LICENSE +21 -0
- lorewell.py +2436 -0
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) => ({"&":"&","<":"<",">":">",'"':"""})[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()
|