@r1z4x/tezgah 1.0.0 → 1.1.0
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.
- package/CHANGELOG.md +71 -1
- package/MANIFEST +8 -0
- package/bin/tezgah-agents +10 -1
- package/bin/tezgah-docs +1 -1
- package/bin/tezgah-doctor +11 -0
- package/bin/tezgah-research +31 -14
- package/bin/tezgah-setup +154 -29
- package/bin/tezgah-skill +59 -0
- package/bin/tezgah-skill.py +59 -0
- package/bin/tezgah-status +62 -1
- package/bin/tezgah-triage +5 -3
- package/docs/README.md +1 -0
- package/docs/architecture.md +5 -2
- package/docs/citations-baseline.json +1 -1
- package/docs/clarity-baseline.json +9 -9
- package/docs/contract.md +68 -26
- package/docs/environment.md +6 -3
- package/docs/evidence.md +7 -0
- package/docs/gate.md +13 -7
- package/docs/glossary.md +1 -1
- package/docs/hosts.md +48 -1
- package/docs/index.json +29 -1
- package/docs/judge.md +61 -55
- package/docs/models.md +14 -8
- package/docs/operations.md +10 -5
- package/docs/orca.md +126 -0
- package/docs/research.md +38 -3
- package/docs/skills.md +74 -0
- package/docs/status-line.md +3 -1
- package/docs/testing.md +33 -21
- package/docs/vision.md +12 -8
- package/hooks/projects-pretooluse.py +17 -5
- package/hooks/tezgah_agents.py +135 -13
- package/hooks/tezgah_components.py +3 -0
- package/hooks/tezgah_context.py +65 -22
- package/hooks/tezgah_gate.py +133 -17
- package/hooks/tezgah_integrity.py +60 -13
- package/hooks/tezgah_judge.py +239 -56
- package/hooks/tezgah_models.py +41 -5
- package/hooks/tezgah_orca.py +144 -0
- package/hooks/tezgah_paths.py +40 -6
- package/hooks/tezgah_policy.py +109 -35
- package/hooks/tezgah_research.py +104 -8
- package/hooks/tezgah_skill_pick.py +459 -8
- package/hooks/tezgah_stack.py +382 -0
- package/hooks/tezgah_stopspec.py +2 -2
- package/hosts/codex/hook.py +7 -1
- package/hosts/cursor/hook.py +7 -4
- package/hosts/omp/hook.py +12 -6
- package/hosts/omp/tezgah-hook.ts.in +20 -7
- package/hosts/opencode/plugins/tezgah.js +18 -9
- package/output-styles/tezgah.md +34 -19
- package/package.json +1 -1
- package/skills/i-have-adhd/SKILL.md +2 -2
- package/skills/research/SKILL.md +60 -17
- package/skills/tezgah-contract/SKILL.md +55 -13
|
@@ -28,14 +28,35 @@ design: no credential, a timeout, a refused request, a reply that is not the
|
|
|
28
28
|
documented shape, a cache dir that cannot be written - every one of them returns
|
|
29
29
|
"" instead of raising, since this runs on the prompt path of every turn and a
|
|
30
30
|
missing hint must cost the turn nothing.
|
|
31
|
-
|
|
31
|
+
|
|
32
|
+
Alongside the judgement sits a section search over every installed skill
|
|
33
|
+
(sections/search/skill_roots below, exposed as `bin/tezgah-skill`): the roster
|
|
34
|
+
a session meets is names only, and the corpora a host mounts - 284 skills under
|
|
35
|
+
omp's custom directories on this machine - were reachable by topic by nobody.
|
|
36
|
+
The search is local BM25 over headings and bodies; the hint reuses it to point
|
|
37
|
+
at the matching SECTION of the skill the judgement named, as a
|
|
38
|
+
`skill://<name>:<start>-<end>` address omp's read tool resolves plus the
|
|
39
|
+
absolute path every other host opens. The search itself is not armed: it is a
|
|
40
|
+
local read, so `bin/tezgah-skill` costs nothing until it is run.
|
|
41
|
+
|
|
42
|
+
DEFAULT ON, NO MODEL: `section_hint` (below) runs the same search on every
|
|
43
|
+
prompt the judgement did not answer, over an index cached in SQLite and rebuilt
|
|
44
|
+
only when a SKILL.md's stat moves, and appends one line naming the top section
|
|
45
|
+
when it carries enough of the prompt's words - each section once per session.
|
|
46
|
+
No credential and no arming file, so `judge-off` does not reach it (it is not a
|
|
47
|
+
judgement); `reminder-off` drops it with the rest of the per-turn text."""
|
|
48
|
+
|
|
49
|
+
|
|
32
50
|
import hashlib
|
|
33
51
|
import json
|
|
52
|
+
import math
|
|
34
53
|
import os
|
|
35
54
|
import re
|
|
55
|
+
import sqlite3
|
|
36
56
|
|
|
37
57
|
import tezgah_integrity as ti
|
|
38
58
|
import tezgah_judge
|
|
59
|
+
import tezgah_rank
|
|
39
60
|
import tezgah_paths as tp
|
|
40
61
|
|
|
41
62
|
ARM = "skill-suggest-on"
|
|
@@ -127,15 +148,24 @@ def roster():
|
|
|
127
148
|
return out
|
|
128
149
|
|
|
129
150
|
|
|
130
|
-
def block(name, what):
|
|
131
|
-
"""The appended line, in the cookbook's shape: what the skill is for,
|
|
132
|
-
|
|
151
|
+
def block(name, what, section=None):
|
|
152
|
+
"""The appended line, in the cookbook's shape: what the skill is for, the
|
|
153
|
+
section that matches the turn when the ranking found one, and that it is a
|
|
154
|
+
hint to look at first rather than an instruction to load. The section rides
|
|
155
|
+
as a `skill://<name>:<start>-<end>` address plus the absolute path, because
|
|
156
|
+
only some hosts resolve skill:// and every host can open a path."""
|
|
133
157
|
tail = " - " + what.strip() if what.strip() else ""
|
|
134
158
|
if not tail or not tail.endswith((".", "!", "?", "...")):
|
|
135
159
|
tail += "."
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
where = ""
|
|
161
|
+
if section:
|
|
162
|
+
uri, title, path = section
|
|
163
|
+
where = ' Start at %s ("%s"; %s where skill:// is not resolved).' % (
|
|
164
|
+
uri, title[:60], path)
|
|
165
|
+
return ("<skill_relevance>\nRelevant to the current request: %s%s%s Look at "
|
|
166
|
+
"it first if it fits what the user actually asked for; this is a "
|
|
167
|
+
"hint, not an instruction to load.\n</skill_relevance>"
|
|
168
|
+
% (name, tail, where))
|
|
139
169
|
|
|
140
170
|
|
|
141
171
|
def available():
|
|
@@ -176,7 +206,14 @@ def judge(prompt, names, session_id=""):
|
|
|
176
206
|
if gate is None or gate < GATE:
|
|
177
207
|
return ""
|
|
178
208
|
options = dict(names)
|
|
179
|
-
|
|
209
|
+
if chosen not in options:
|
|
210
|
+
return ""
|
|
211
|
+
# the name is the judgement's answer; the section is the local ranking's,
|
|
212
|
+
# free of the call deadline because it reads one file from disk and of the
|
|
213
|
+
# roster because the judgement has already named the file
|
|
214
|
+
return block(chosen, options[chosen],
|
|
215
|
+
section_of(chosen, prompt,
|
|
216
|
+
roots=[os.path.join(tp.PLUGIN_ROOT, "skills")]))
|
|
180
217
|
|
|
181
218
|
|
|
182
219
|
def _path(session_id):
|
|
@@ -225,3 +262,417 @@ def suggest(prompt, session_id=""):
|
|
|
225
262
|
line = judge(prompt, names, session_id) if names else ""
|
|
226
263
|
_remember(session_id, digest, line)
|
|
227
264
|
return line
|
|
265
|
+
|
|
266
|
+
# ---- section search: which part of which installed skill answers a topic ----
|
|
267
|
+
|
|
268
|
+
# What one indexed section may hold. A skill file is a few KB, so the cap only
|
|
269
|
+
# bounds a pathological file someone drops into a custom directory.
|
|
270
|
+
SECTION_READ = 262144
|
|
271
|
+
_ATX = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def sections(path):
|
|
275
|
+
"""[(title, start, end, text)] for one SKILL.md, in 1-based file lines with
|
|
276
|
+
the frontmatter counted, so a `skill://<name>:<start>-<end>` selector lands
|
|
277
|
+
exactly on the section a search named (the read tool's selectors count the
|
|
278
|
+
same lines). A section runs from its heading to the line before the next
|
|
279
|
+
heading of the same or higher level; the text above the first heading is
|
|
280
|
+
frontmatter and belongs to no section."""
|
|
281
|
+
try:
|
|
282
|
+
with open(path, encoding="utf-8", errors="replace") as fh:
|
|
283
|
+
lines = fh.read(SECTION_READ).splitlines()
|
|
284
|
+
except OSError:
|
|
285
|
+
return []
|
|
286
|
+
marks, fenced = [], False
|
|
287
|
+
for i, line in enumerate(lines):
|
|
288
|
+
if line.lstrip().startswith("```"):
|
|
289
|
+
fenced = not fenced # a toggling fence: ``` opens, ``` closes
|
|
290
|
+
continue
|
|
291
|
+
if fenced:
|
|
292
|
+
continue # a `# comment` inside code is not a heading
|
|
293
|
+
m = _ATX.match(line)
|
|
294
|
+
if m:
|
|
295
|
+
marks.append((len(m.group(1)), m.group(2).strip(), i + 1))
|
|
296
|
+
out = []
|
|
297
|
+
for j, (level, title, start) in enumerate(marks):
|
|
298
|
+
end = len(lines)
|
|
299
|
+
for lvl, _t, ln in marks[j + 1:]:
|
|
300
|
+
if lvl <= level:
|
|
301
|
+
end = ln - 1
|
|
302
|
+
break
|
|
303
|
+
out.append((title, start, end, "\n".join(lines[start - 1:end])))
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _omp_custom_dirs(config):
|
|
308
|
+
"""The `skills.customDirectories` list of omp's config.yml, verbatim paths.
|
|
309
|
+
|
|
310
|
+
omp is the one host whose big skill corpora (284 here) ride a config key
|
|
311
|
+
rather than a fixed directory, so a search that missed it would index a
|
|
312
|
+
fifth of the machine and claim the rest did not exist. Two lines of
|
|
313
|
+
indentation-bounded scanning, not a YAML dependency, for a file whose only
|
|
314
|
+
interesting part is a four-line list."""
|
|
315
|
+
roots, inside = [], False
|
|
316
|
+
try:
|
|
317
|
+
with open(config, encoding="utf-8", errors="replace") as fh:
|
|
318
|
+
rows = fh.read().splitlines()
|
|
319
|
+
except OSError:
|
|
320
|
+
return []
|
|
321
|
+
for row in rows:
|
|
322
|
+
if re.match(r"^\s*customDirectories:\s*$", row):
|
|
323
|
+
inside = True
|
|
324
|
+
continue
|
|
325
|
+
if inside:
|
|
326
|
+
item = re.match(r"^\s+-\s+(.+?)\s*$", row)
|
|
327
|
+
if item:
|
|
328
|
+
roots.append(os.path.expanduser(item.group(1)))
|
|
329
|
+
elif row.strip():
|
|
330
|
+
break # the next keyed block: the list is over
|
|
331
|
+
return roots
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def skill_roots(extra=()):
|
|
335
|
+
"""Every installed-skill root on this machine, highest-precedence first.
|
|
336
|
+
|
|
337
|
+
The order mirrors omp's provider priority (the plugin's own skills, omp's
|
|
338
|
+
user directory, then its custom directories, then the other hosts), which
|
|
339
|
+
only matters for a name that differs between roots: the first one keeps
|
|
340
|
+
the bare `skill://<name>` address omp resolves. Identical copies (every
|
|
341
|
+
host links the same plugin skills) collapse on realpath, so the order
|
|
342
|
+
costs nothing on this machine."""
|
|
343
|
+
home = os.path.expanduser("~")
|
|
344
|
+
xdg = os.environ.get("XDG_CONFIG_HOME") or os.path.join(home, ".config")
|
|
345
|
+
roots = ([os.path.join(tp.PLUGIN_ROOT, "skills"),
|
|
346
|
+
os.path.join(home, ".omp", "agent", "skills")]
|
|
347
|
+
+ _omp_custom_dirs(os.path.join(home, ".omp", "agent", "config.yml"))
|
|
348
|
+
+ [os.path.join(home, ".claude", "skills"),
|
|
349
|
+
os.path.join(home, ".agents", "skills"),
|
|
350
|
+
os.path.join(os.environ.get("CODEX_HOME")
|
|
351
|
+
or os.path.join(home, ".codex"), "skills"),
|
|
352
|
+
os.path.join(xdg, "opencode", "skills")]
|
|
353
|
+
+ list(extra))
|
|
354
|
+
seen, out = set(), []
|
|
355
|
+
for root in roots:
|
|
356
|
+
real = os.path.realpath(root)
|
|
357
|
+
if real in seen or not os.path.isdir(real):
|
|
358
|
+
continue
|
|
359
|
+
seen.add(real)
|
|
360
|
+
out.append(root)
|
|
361
|
+
return out
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _rows(roots=None):
|
|
365
|
+
"""[(skill, path, title, start, end, text)] over every installed skill's
|
|
366
|
+
sections, deduplicated on the file's realpath so a skill five hosts link
|
|
367
|
+
is one entry, not five."""
|
|
368
|
+
out, seen = [], set()
|
|
369
|
+
for root in (skill_roots() if roots is None else roots):
|
|
370
|
+
try:
|
|
371
|
+
names = sorted(os.listdir(root))
|
|
372
|
+
except OSError:
|
|
373
|
+
continue
|
|
374
|
+
for name in names:
|
|
375
|
+
path = os.path.realpath(os.path.join(root, name, "SKILL.md"))
|
|
376
|
+
if not os.path.isfile(path):
|
|
377
|
+
continue
|
|
378
|
+
for title, start, end, text in sections(path):
|
|
379
|
+
if (path, start) in seen:
|
|
380
|
+
continue
|
|
381
|
+
seen.add((path, start))
|
|
382
|
+
out.append((name, path, title, start, end, text))
|
|
383
|
+
return out
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def index(roots=None):
|
|
387
|
+
"""[(skill, path, title, start, end)] over every section of every installed
|
|
388
|
+
skill. `roots` overrides discovery (tests, one-directory searches)."""
|
|
389
|
+
return [row[:5] for row in _rows(roots)]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def search(query, k=5, roots=None):
|
|
393
|
+
"""The `k` sections that answer `query`, best first, as
|
|
394
|
+
[(skill, path, title, start, end)]. BM25 over heading (weighted x3) plus
|
|
395
|
+
body via tezgah_rank - the same ranking the per-turn lessons block uses,
|
|
396
|
+
because this is the same problem one level down: which of many short texts
|
|
397
|
+
a turn's topic is about. A query no section shares a term with returns []."""
|
|
398
|
+
rows = _rows(roots)
|
|
399
|
+
# the heading twice more than the body: a section whose TITLE answers the
|
|
400
|
+
# query outranks one that merely mentions the words in passing
|
|
401
|
+
ranked = tezgah_rank.rank(query, ["%s\n%s\n%s" % (row[2], row[2], row[5])
|
|
402
|
+
for row in rows], k)
|
|
403
|
+
return [rows[i][:5] for i in ranked]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def skill_path(skill, roots=None):
|
|
407
|
+
"""The SKILL.md a name resolves to, first root wins, or None.
|
|
408
|
+
|
|
409
|
+
Root by root, not through `index()`: on the prompt path this runs once per
|
|
410
|
+
turn, and the machine's full corpus is 280 files a name lookup has no
|
|
411
|
+
reason to open."""
|
|
412
|
+
for root in (skill_roots() if roots is None else roots):
|
|
413
|
+
path = os.path.realpath(os.path.join(root, skill, "SKILL.md"))
|
|
414
|
+
if os.path.isfile(path):
|
|
415
|
+
return path
|
|
416
|
+
return None
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def section_of(skill, prompt, roots=None):
|
|
420
|
+
"""The one section of `skill` that matches `prompt` best, as the
|
|
421
|
+
(uri, title, path) the hint line carries, or None when nothing scores.
|
|
422
|
+
|
|
423
|
+
This is what turns the hint from a skill name into an address: the
|
|
424
|
+
judgement already answered WHICH skill, so the ranking here runs inside
|
|
425
|
+
that one file's directory only, and a prompt whose topic the file never
|
|
426
|
+
mentions keeps the old name-only line rather than an invented range."""
|
|
427
|
+
path = skill_path(skill, roots)
|
|
428
|
+
if not path:
|
|
429
|
+
return None
|
|
430
|
+
own = [row for row in _rows([os.path.dirname(os.path.dirname(path))])
|
|
431
|
+
if row[0] == skill]
|
|
432
|
+
ranked = tezgah_rank.rank(prompt, ["%s\n%s\n%s" % (r[2], r[2], r[5])
|
|
433
|
+
for r in own], 1)
|
|
434
|
+
if not ranked:
|
|
435
|
+
return None
|
|
436
|
+
title, start, end = own[ranked[0]][2:5]
|
|
437
|
+
return ("skill://%s:%d-%d" % (skill, start, end), title, path)
|
|
438
|
+
|
|
439
|
+
# ---- the default-on local hint: the same index, cached, no model ----------
|
|
440
|
+
|
|
441
|
+
# The coverage bar: of the prompt's distinct terms (function words out) that
|
|
442
|
+
# the corpus knows at all, the top section must carry HINT_COVER of them and
|
|
443
|
+
# at least HINT_MIN_TERMS. Coverage, not an absolute BM25 score, because
|
|
444
|
+
# scores scale with corpus size and idf spread - a bar that reads well on the
|
|
445
|
+
# live corpus fires on nothing in a small one and vice versa, while "did the
|
|
446
|
+
# section carry the topic's words" transfers. A word no skill contains (a file
|
|
447
|
+
# name, a typo, the project's own jargon) says nothing about which section
|
|
448
|
+
# fits, so it counts neither for the bar nor against it. Three terms, not two:
|
|
449
|
+
# on this machine's corpus a two-word overlap put "why is the build failing on
|
|
450
|
+
# CI" on an output-style section, while every topic prompt tried carried 3-8.
|
|
451
|
+
HINT_COVER = 0.6
|
|
452
|
+
HINT_MIN_TERMS = 3
|
|
453
|
+
# Only the head of a pasted prompt is ranked: the topic is in its first lines,
|
|
454
|
+
# and a log pasted below it must not cost the turn its time budget.
|
|
455
|
+
HINT_PROMPT_MAX = 600
|
|
456
|
+
INDEX_CACHE = "skill-search.sqlite"
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def _toktext(row):
|
|
460
|
+
"""The text a row is ranked on: the heading twice more than the body, the
|
|
461
|
+
same weighting `search` gives it, so the two rankings cannot drift."""
|
|
462
|
+
return "%s\n%s\n%s" % (row[2], row[2], row[5])
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _postings(rows):
|
|
466
|
+
"""({term: [(section, count), ...]}, [section length]): the inverted index
|
|
467
|
+
`rank_postings` scores from. Tokenised with the ranker's own `words`, so
|
|
468
|
+
the index holds exactly the terms it would see."""
|
|
469
|
+
posts, lengths = {}, []
|
|
470
|
+
for i, row in enumerate(rows):
|
|
471
|
+
counts = {}
|
|
472
|
+
for term in tezgah_rank.words(_toktext(row)):
|
|
473
|
+
counts[term] = counts.get(term, 0) + 1
|
|
474
|
+
lengths.append(sum(counts.values()))
|
|
475
|
+
for term, n in counts.items():
|
|
476
|
+
posts.setdefault(term, []).append((i, n))
|
|
477
|
+
return posts, lengths
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def rank_postings(query, posts, lengths, k):
|
|
481
|
+
"""`tezgah_rank.rank`'s ordering over postings, no max_df cap. `posts` may
|
|
482
|
+
hold only the query's own terms - nothing else is read.
|
|
483
|
+
|
|
484
|
+
The arithmetic is the ranker's own (K1, B, its idf), read from the module
|
|
485
|
+
so a retuned constant moves both paths at once; `PostingsIndex` pins the
|
|
486
|
+
two orderings equal rather than trusting the copy."""
|
|
487
|
+
terms = set(tezgah_rank.words(query))
|
|
488
|
+
if not posts or not terms or not lengths:
|
|
489
|
+
return []
|
|
490
|
+
n = len(lengths)
|
|
491
|
+
avg = sum(lengths) / n or 1.0
|
|
492
|
+
k1, b = tezgah_rank.K1, tezgah_rank.B
|
|
493
|
+
scores = {}
|
|
494
|
+
for t in terms:
|
|
495
|
+
entries = posts.get(t)
|
|
496
|
+
if not entries:
|
|
497
|
+
continue
|
|
498
|
+
idf = math.log(1 + (n - len(entries) + 0.5) / (len(entries) + 0.5))
|
|
499
|
+
for i, c in entries:
|
|
500
|
+
scores[i] = scores.get(i, 0.0) + idf * c * (k1 + 1) / (
|
|
501
|
+
c + k1 * (1 - b + b * lengths[i] / avg))
|
|
502
|
+
ranked = sorted((-s, i) for i, s in scores.items() if s > 0)
|
|
503
|
+
return [i for _s, i in ranked[:k]]
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _signature():
|
|
507
|
+
"""[[root, skill, mtime_ns, size]] per installed SKILL.md: what a rebuild
|
|
508
|
+
keys on. The file's own stat, not its directory's - installing, editing or
|
|
509
|
+
removing one skill is exactly the change the cache must notice."""
|
|
510
|
+
sig = []
|
|
511
|
+
for root in skill_roots():
|
|
512
|
+
try:
|
|
513
|
+
names = sorted(os.listdir(root))
|
|
514
|
+
except OSError:
|
|
515
|
+
continue
|
|
516
|
+
for name in names:
|
|
517
|
+
try:
|
|
518
|
+
st = os.stat(os.path.join(root, name, "SKILL.md"))
|
|
519
|
+
except OSError:
|
|
520
|
+
continue
|
|
521
|
+
sig.append([root, name, st.st_mtime_ns, st.st_size])
|
|
522
|
+
return sig
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
def _build(path, sig, rows):
|
|
526
|
+
"""Write the index for `rows` under a private temp name and move it into
|
|
527
|
+
place, so a concurrent reader sees the old file or the new one."""
|
|
528
|
+
posts, lengths = _postings(rows)
|
|
529
|
+
tmp = "%s.%d.tmp" % (path, os.getpid())
|
|
530
|
+
if os.path.exists(tmp):
|
|
531
|
+
os.remove(tmp)
|
|
532
|
+
con = sqlite3.connect(tmp)
|
|
533
|
+
try:
|
|
534
|
+
con.executescript(
|
|
535
|
+
"CREATE TABLE meta(sig TEXT);"
|
|
536
|
+
"CREATE TABLE sec(id INTEGER PRIMARY KEY, skill TEXT, path TEXT,"
|
|
537
|
+
" title TEXT, start INTEGER, end INTEGER, len INTEGER);"
|
|
538
|
+
"CREATE TABLE post(term TEXT, sec INTEGER, n INTEGER,"
|
|
539
|
+
" PRIMARY KEY(term, sec)) WITHOUT ROWID;")
|
|
540
|
+
con.execute("INSERT INTO meta VALUES (?)", (sig,))
|
|
541
|
+
con.executemany("INSERT INTO sec VALUES (?,?,?,?,?,?,?)",
|
|
542
|
+
[(i,) + tuple(r[:5]) + (lengths[i],)
|
|
543
|
+
for i, r in enumerate(rows)])
|
|
544
|
+
con.executemany("INSERT INTO post VALUES (?,?,?)",
|
|
545
|
+
((t, i, n) for t, entries in posts.items()
|
|
546
|
+
for i, n in entries))
|
|
547
|
+
con.commit()
|
|
548
|
+
finally:
|
|
549
|
+
con.close()
|
|
550
|
+
os.replace(tmp, path)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _open_index():
|
|
554
|
+
"""A read-only connection to the machine's cached index, rebuilt first when
|
|
555
|
+
any SKILL.md's stat moved since it was written; None when none can be had."""
|
|
556
|
+
path = os.path.join(tp.cache_dir(), INDEX_CACHE)
|
|
557
|
+
sig = json.dumps(_signature())
|
|
558
|
+
for attempt in (0, 1):
|
|
559
|
+
if os.path.isfile(path):
|
|
560
|
+
con = sqlite3.connect("file:%s?mode=ro" % path, uri=True)
|
|
561
|
+
try:
|
|
562
|
+
row = con.execute("SELECT sig FROM meta").fetchone()
|
|
563
|
+
except sqlite3.Error:
|
|
564
|
+
row = None
|
|
565
|
+
if row and row[0] == sig:
|
|
566
|
+
return con
|
|
567
|
+
con.close()
|
|
568
|
+
if attempt:
|
|
569
|
+
return None
|
|
570
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
571
|
+
_build(path, sig, _rows())
|
|
572
|
+
return None
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
def top_section(prompt, roots=None):
|
|
576
|
+
"""((skill, path, title, start, end), covered, known) for the section that
|
|
577
|
+
ranks first for `prompt`, or None: `covered` of the prompt's `known`
|
|
578
|
+
content terms (stopwords out, present in the corpus) occur in it.
|
|
579
|
+
|
|
580
|
+
With `roots` the index is built in memory and no cache is read or written,
|
|
581
|
+
so a fixture lookup can never overwrite the machine's own index. Without,
|
|
582
|
+
it is the cached SQLite index: a warm lookup reads the prompt's postings
|
|
583
|
+
and one integer column, never the corpus."""
|
|
584
|
+
terms = sorted(set(tezgah_rank.words(prompt[:HINT_PROMPT_MAX])))
|
|
585
|
+
if not terms:
|
|
586
|
+
return None
|
|
587
|
+
con = _open_index() if roots is None else None
|
|
588
|
+
if roots is None and con is None:
|
|
589
|
+
return None
|
|
590
|
+
try:
|
|
591
|
+
if con is None:
|
|
592
|
+
rows = _rows(roots)
|
|
593
|
+
every, lengths = _postings(rows)
|
|
594
|
+
posts = {t: every[t] for t in terms if t in every}
|
|
595
|
+
else:
|
|
596
|
+
posts = {}
|
|
597
|
+
for t, i, n in con.execute(
|
|
598
|
+
"SELECT term, sec, n FROM post WHERE term IN (%s)"
|
|
599
|
+
% ",".join("?" * len(terms)), terms):
|
|
600
|
+
posts.setdefault(t, []).append((i, n))
|
|
601
|
+
lengths = [r[0] for r in con.execute(
|
|
602
|
+
"SELECT len FROM sec ORDER BY id")]
|
|
603
|
+
top = rank_postings(" ".join(terms), posts, lengths, 1)
|
|
604
|
+
if not top:
|
|
605
|
+
return None
|
|
606
|
+
i = top[0]
|
|
607
|
+
hit = (tuple(rows[i][:5]) if con is None else con.execute(
|
|
608
|
+
"SELECT skill, path, title, start, end FROM sec WHERE id=?",
|
|
609
|
+
(i,)).fetchone())
|
|
610
|
+
finally:
|
|
611
|
+
if con is not None:
|
|
612
|
+
con.close()
|
|
613
|
+
if not hit:
|
|
614
|
+
return None
|
|
615
|
+
known = [t for t in terms if t in posts and t not in tezgah_rank.STOPWORDS]
|
|
616
|
+
covered = sum(1 for t in known if any(j == i for j, _n in posts[t]))
|
|
617
|
+
return tuple(hit), covered, len(known)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _hint_path(session_id):
|
|
621
|
+
return os.path.join(tp.cache_dir(), "skill-section-hint",
|
|
622
|
+
slug(session_id) + ".json")
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
def _shown(session_id):
|
|
626
|
+
"""The sections this session was already pointed at, ["path:start"]."""
|
|
627
|
+
try:
|
|
628
|
+
with open(_hint_path(session_id), encoding="utf-8") as fh:
|
|
629
|
+
data = json.load(fh)
|
|
630
|
+
except (OSError, ValueError):
|
|
631
|
+
return []
|
|
632
|
+
return data if isinstance(data, list) else []
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _remember_shown(session_id, shown):
|
|
636
|
+
"""Bounded and best effort, like the judgement's cache: a dir that cannot
|
|
637
|
+
be written costs a repeated line, never the turn."""
|
|
638
|
+
path = _hint_path(session_id)
|
|
639
|
+
try:
|
|
640
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
641
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
642
|
+
json.dump(shown[-KEPT:], fh)
|
|
643
|
+
except OSError:
|
|
644
|
+
pass
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def section_hint(prompt, session_id="", host=None, roots=None):
|
|
648
|
+
"""The default-on `<skill_relevance>` line: the top SECTION of the local
|
|
649
|
+
index for this prompt, or "".
|
|
650
|
+
|
|
651
|
+
No model, no credential, no arming file - no search ran over the skills
|
|
652
|
+
unless a session thought to, and a hint that waits for an opt-in never
|
|
653
|
+
runs. It fires only when the section clears the coverage bar, names each
|
|
654
|
+
section once per session, and carries the absolute path everywhere but
|
|
655
|
+
omp, whose read tool resolves the `skill://` address itself. Total: an
|
|
656
|
+
index that cannot be read or built costs the line, never the turn."""
|
|
657
|
+
prompt = (prompt or "").strip()
|
|
658
|
+
if not prompt or prompt.startswith("/"):
|
|
659
|
+
return ""
|
|
660
|
+
try:
|
|
661
|
+
found = top_section(prompt, roots)
|
|
662
|
+
except (OSError, sqlite3.Error, ValueError):
|
|
663
|
+
return ""
|
|
664
|
+
if not found:
|
|
665
|
+
return ""
|
|
666
|
+
(skill, path, title, start, end), covered, known = found
|
|
667
|
+
if covered < max(HINT_MIN_TERMS, math.ceil(HINT_COVER * known)):
|
|
668
|
+
return ""
|
|
669
|
+
key = "%s:%d" % (path, start)
|
|
670
|
+
shown = _shown(session_id)
|
|
671
|
+
if key in shown:
|
|
672
|
+
return ""
|
|
673
|
+
_remember_shown(session_id, shown + [key])
|
|
674
|
+
where = "" if host == "omp" else " (%s)" % path
|
|
675
|
+
return ("<skill_relevance>\nA local skill search matched this request: "
|
|
676
|
+
"skill://%s:%d-%d%s, \"%s\". Read that range first if it fits; "
|
|
677
|
+
"a hint, not an instruction to load.\n</skill_relevance>"
|
|
678
|
+
% (skill, start, end, where, title[:60]))
|