@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.
Files changed (56) hide show
  1. package/CHANGELOG.md +71 -1
  2. package/MANIFEST +8 -0
  3. package/bin/tezgah-agents +10 -1
  4. package/bin/tezgah-docs +1 -1
  5. package/bin/tezgah-doctor +11 -0
  6. package/bin/tezgah-research +31 -14
  7. package/bin/tezgah-setup +154 -29
  8. package/bin/tezgah-skill +59 -0
  9. package/bin/tezgah-skill.py +59 -0
  10. package/bin/tezgah-status +62 -1
  11. package/bin/tezgah-triage +5 -3
  12. package/docs/README.md +1 -0
  13. package/docs/architecture.md +5 -2
  14. package/docs/citations-baseline.json +1 -1
  15. package/docs/clarity-baseline.json +9 -9
  16. package/docs/contract.md +68 -26
  17. package/docs/environment.md +6 -3
  18. package/docs/evidence.md +7 -0
  19. package/docs/gate.md +13 -7
  20. package/docs/glossary.md +1 -1
  21. package/docs/hosts.md +48 -1
  22. package/docs/index.json +29 -1
  23. package/docs/judge.md +61 -55
  24. package/docs/models.md +14 -8
  25. package/docs/operations.md +10 -5
  26. package/docs/orca.md +126 -0
  27. package/docs/research.md +38 -3
  28. package/docs/skills.md +74 -0
  29. package/docs/status-line.md +3 -1
  30. package/docs/testing.md +33 -21
  31. package/docs/vision.md +12 -8
  32. package/hooks/projects-pretooluse.py +17 -5
  33. package/hooks/tezgah_agents.py +135 -13
  34. package/hooks/tezgah_components.py +3 -0
  35. package/hooks/tezgah_context.py +65 -22
  36. package/hooks/tezgah_gate.py +133 -17
  37. package/hooks/tezgah_integrity.py +60 -13
  38. package/hooks/tezgah_judge.py +239 -56
  39. package/hooks/tezgah_models.py +41 -5
  40. package/hooks/tezgah_orca.py +144 -0
  41. package/hooks/tezgah_paths.py +40 -6
  42. package/hooks/tezgah_policy.py +109 -35
  43. package/hooks/tezgah_research.py +104 -8
  44. package/hooks/tezgah_skill_pick.py +459 -8
  45. package/hooks/tezgah_stack.py +382 -0
  46. package/hooks/tezgah_stopspec.py +2 -2
  47. package/hosts/codex/hook.py +7 -1
  48. package/hosts/cursor/hook.py +7 -4
  49. package/hosts/omp/hook.py +12 -6
  50. package/hosts/omp/tezgah-hook.ts.in +20 -7
  51. package/hosts/opencode/plugins/tezgah.js +18 -9
  52. package/output-styles/tezgah.md +34 -19
  53. package/package.json +1 -1
  54. package/skills/i-have-adhd/SKILL.md +2 -2
  55. package/skills/research/SKILL.md +60 -17
  56. 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, and that
132
- it is a hint to look at first rather than an instruction to load."""
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
- return ("<skill_relevance>\nRelevant to the current request: %s%s Look at its "
137
- "SKILL.md first if it fits what the user actually asked for; this is a "
138
- "hint, not an instruction to load.\n</skill_relevance>" % (name, tail))
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
- return block(chosen, options[chosen]) if chosen in options else ""
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]))