master-skill 0.10.1 → 0.12.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 (77) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/GEMINI.md +1 -1
  5. package/README.md +84 -336
  6. package/README_EN.md +108 -321
  7. package/bin/cli.mjs +249 -9
  8. package/gemini-extension.json +1 -1
  9. package/hooks/session-start +68 -74
  10. package/hooks/session_start.py +152 -0
  11. package/package.json +6 -2
  12. package/prebuilt/{compare → compare-masters}/SKILL.md +21 -2
  13. package/prebuilt/master-ajahn-chah/meta.json +6 -0
  14. package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
  15. package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
  16. package/prebuilt/master-curriculum/SKILL.md +1 -1
  17. package/prebuilt/master-curriculum/references/tiantai.md +1 -1
  18. package/prebuilt/master-debate/SKILL.md +15 -3
  19. package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
  20. package/prebuilt/master-help/SKILL.md +94 -0
  21. package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
  22. package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
  23. package/prebuilt/master-kumarajiva/meta.json +14 -3
  24. package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
  25. package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
  26. package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
  27. package/prebuilt/master-nagarjuna/meta.json +19 -4
  28. package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
  29. package/prebuilt/master-ouyi/meta.json +5 -0
  30. package/prebuilt/master-ouyi/references/teaching.md +3 -3
  31. package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
  32. package/prebuilt/master-tsongkhapa/meta.json +32 -5
  33. package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
  34. package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
  35. package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
  36. package/prebuilt/master-zhiyi/meta.json +2 -2
  37. package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
  38. package/references/teaching-modes.md +8 -1
  39. package/routing.json +209 -0
  40. package/scripts/check-audit-ignores.py +105 -0
  41. package/scripts/check-eval-sdk-surface.py +142 -0
  42. package/scripts/check-gate-liveness.py +421 -0
  43. package/scripts/reaudit-report.py +163 -0
  44. package/scripts/regrade-report.py +157 -0
  45. package/scripts/smoke-eval-sdk.py +174 -0
  46. package/scripts/test-fidelity.py +992 -89
  47. package/scripts/validate-citation-references.py +150 -0
  48. package/scripts/validate-citation-templates.py +176 -0
  49. package/scripts/validate-fidelity.py +6 -1
  50. package/scripts/validate-fixture-terms.py +127 -0
  51. package/scripts/validate-routing.py +254 -0
  52. package/scripts/validate.py +63 -36
  53. package/scripts/verify-adjudication.py +316 -0
  54. package/scripts/verify_citations.py +739 -39
  55. package/skill-catalog.json +83 -20
  56. package/tools/cross_reference.py +44 -10
  57. package/tools/fojin-known-absent.json +14 -0
  58. package/tools/fojin_bridge.py +138 -8
  59. package/tools/rag_query.py +45 -2
  60. package/tools/skill_writer.py +50 -7
  61. package/tools/verify_sources.py +240 -15
  62. package/hooks/tests/test_run_hook.sh +0 -114
  63. package/hooks/tests/test_run_hook_cmd.sh +0 -94
  64. package/hooks/tests/test_session_start.sh +0 -149
  65. package/scripts/tests/test_check_manifest_versions.py +0 -217
  66. package/scripts/tests/test_debate_protocol.py +0 -159
  67. package/scripts/tests/test_injection_hardening.py +0 -174
  68. package/scripts/tests/test_select_fidelity_smoke.py +0 -142
  69. package/scripts/tests/test_validate_citation_contract.py +0 -408
  70. package/scripts/tests/test_validate_cross_critique.py +0 -149
  71. package/scripts/tests/test_validate_curriculum_sources.py +0 -144
  72. package/scripts/tests/test_validate_fidelity.py +0 -59
  73. package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
  74. package/scripts/tests/test_validate_persona_fidelity.py +0 -317
  75. package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
  76. package/scripts/tests/test_validate_workflow.py +0 -265
  77. /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
@@ -6,133 +6,196 @@
6
6
  "kind": "persona",
7
7
  "source": "prebuilt/master-nagarjuna",
8
8
  "install_dir": "master-nagarjuna",
9
- "aliases": ["nagarjuna", "master-nagarjuna"]
9
+ "aliases": [
10
+ "nagarjuna",
11
+ "master-nagarjuna"
12
+ ]
10
13
  },
11
14
  {
12
15
  "name": "master-xuanzang",
13
16
  "kind": "persona",
14
17
  "source": "prebuilt/master-xuanzang",
15
18
  "install_dir": "master-xuanzang",
16
- "aliases": ["xuanzang", "master-xuanzang"]
19
+ "aliases": [
20
+ "xuanzang",
21
+ "master-xuanzang"
22
+ ]
17
23
  },
18
24
  {
19
25
  "name": "master-kumarajiva",
20
26
  "kind": "persona",
21
27
  "source": "prebuilt/master-kumarajiva",
22
28
  "install_dir": "master-kumarajiva",
23
- "aliases": ["kumarajiva", "master-kumarajiva"]
29
+ "aliases": [
30
+ "kumarajiva",
31
+ "master-kumarajiva"
32
+ ]
24
33
  },
25
34
  {
26
35
  "name": "master-huineng",
27
36
  "kind": "persona",
28
37
  "source": "prebuilt/master-huineng",
29
38
  "install_dir": "master-huineng",
30
- "aliases": ["huineng", "master-huineng"]
39
+ "aliases": [
40
+ "huineng",
41
+ "master-huineng"
42
+ ]
31
43
  },
32
44
  {
33
45
  "name": "master-zhiyi",
34
46
  "kind": "persona",
35
47
  "source": "prebuilt/master-zhiyi",
36
48
  "install_dir": "master-zhiyi",
37
- "aliases": ["zhiyi", "master-zhiyi"]
49
+ "aliases": [
50
+ "zhiyi",
51
+ "master-zhiyi"
52
+ ]
38
53
  },
39
54
  {
40
55
  "name": "master-fazang",
41
56
  "kind": "persona",
42
57
  "source": "prebuilt/master-fazang",
43
58
  "install_dir": "master-fazang",
44
- "aliases": ["fazang", "master-fazang"]
59
+ "aliases": [
60
+ "fazang",
61
+ "master-fazang"
62
+ ]
45
63
  },
46
64
  {
47
65
  "name": "master-yinguang",
48
66
  "kind": "persona",
49
67
  "source": "prebuilt/master-yinguang",
50
68
  "install_dir": "master-yinguang",
51
- "aliases": ["yinguang", "master-yinguang"]
69
+ "aliases": [
70
+ "yinguang",
71
+ "master-yinguang"
72
+ ]
52
73
  },
53
74
  {
54
75
  "name": "master-ouyi",
55
76
  "kind": "persona",
56
77
  "source": "prebuilt/master-ouyi",
57
78
  "install_dir": "master-ouyi",
58
- "aliases": ["ouyi", "master-ouyi"]
79
+ "aliases": [
80
+ "ouyi",
81
+ "master-ouyi"
82
+ ]
59
83
  },
60
84
  {
61
85
  "name": "master-xuyun",
62
86
  "kind": "persona",
63
87
  "source": "prebuilt/master-xuyun",
64
88
  "install_dir": "master-xuyun",
65
- "aliases": ["xuyun", "master-xuyun"]
89
+ "aliases": [
90
+ "xuyun",
91
+ "master-xuyun"
92
+ ]
66
93
  },
67
94
  {
68
95
  "name": "master-atisha",
69
96
  "kind": "persona",
70
97
  "source": "prebuilt/master-atisha",
71
98
  "install_dir": "master-atisha",
72
- "aliases": ["atisha", "master-atisha"]
99
+ "aliases": [
100
+ "atisha",
101
+ "master-atisha"
102
+ ]
73
103
  },
74
104
  {
75
105
  "name": "master-tsongkhapa",
76
106
  "kind": "persona",
77
107
  "source": "prebuilt/master-tsongkhapa",
78
108
  "install_dir": "master-tsongkhapa",
79
- "aliases": ["tsongkhapa", "master-tsongkhapa"]
109
+ "aliases": [
110
+ "tsongkhapa",
111
+ "master-tsongkhapa"
112
+ ]
80
113
  },
81
114
  {
82
115
  "name": "master-milarepa",
83
116
  "kind": "persona",
84
117
  "source": "prebuilt/master-milarepa",
85
118
  "install_dir": "master-milarepa",
86
- "aliases": ["milarepa", "master-milarepa"]
119
+ "aliases": [
120
+ "milarepa",
121
+ "master-milarepa"
122
+ ]
87
123
  },
88
124
  {
89
125
  "name": "master-buddhaghosa",
90
126
  "kind": "persona",
91
127
  "source": "prebuilt/master-buddhaghosa",
92
128
  "install_dir": "master-buddhaghosa",
93
- "aliases": ["buddhaghosa", "master-buddhaghosa"]
129
+ "aliases": [
130
+ "buddhaghosa",
131
+ "master-buddhaghosa"
132
+ ]
94
133
  },
95
134
  {
96
135
  "name": "master-mahasi-sayadaw",
97
136
  "kind": "persona",
98
137
  "source": "prebuilt/master-mahasi-sayadaw",
99
138
  "install_dir": "master-mahasi-sayadaw",
100
- "aliases": ["mahasi-sayadaw", "master-mahasi-sayadaw"]
139
+ "aliases": [
140
+ "mahasi-sayadaw",
141
+ "master-mahasi-sayadaw"
142
+ ]
101
143
  },
102
144
  {
103
145
  "name": "master-ajahn-chah",
104
146
  "kind": "persona",
105
147
  "source": "prebuilt/master-ajahn-chah",
106
148
  "install_dir": "master-ajahn-chah",
107
- "aliases": ["ajahn-chah", "master-ajahn-chah"]
149
+ "aliases": [
150
+ "ajahn-chah",
151
+ "master-ajahn-chah"
152
+ ]
108
153
  },
109
154
  {
110
155
  "name": "compare-masters",
111
156
  "kind": "teaching-mode",
112
- "source": "prebuilt/compare",
157
+ "source": "prebuilt/compare-masters",
113
158
  "install_dir": "compare-masters",
114
- "aliases": ["compare-masters"]
159
+ "aliases": [
160
+ "compare-masters"
161
+ ]
115
162
  },
116
163
  {
117
164
  "name": "master-debate",
118
165
  "kind": "teaching-mode",
119
166
  "source": "prebuilt/master-debate",
120
167
  "install_dir": "master-debate",
121
- "aliases": ["master-debate"]
168
+ "aliases": [
169
+ "master-debate"
170
+ ]
122
171
  },
123
172
  {
124
173
  "name": "master-curriculum",
125
174
  "kind": "teaching-mode",
126
175
  "source": "prebuilt/master-curriculum",
127
176
  "install_dir": "master-curriculum",
128
- "aliases": ["master-curriculum"]
177
+ "aliases": [
178
+ "master-curriculum"
179
+ ]
180
+ },
181
+ {
182
+ "name": "master-help",
183
+ "kind": "teaching-mode",
184
+ "source": "prebuilt/master-help",
185
+ "install_dir": "master-help",
186
+ "aliases": [
187
+ "help",
188
+ "master-help"
189
+ ]
129
190
  },
130
191
  {
131
192
  "name": "create-master",
132
193
  "kind": "generator",
133
194
  "source": ".",
134
195
  "install_dir": "create-master",
135
- "aliases": ["create-master"],
196
+ "aliases": [
197
+ "create-master"
198
+ ],
136
199
  "bundle_paths": [
137
200
  "SKILL.md",
138
201
  "tools",
@@ -9,6 +9,7 @@ Usage:
9
9
 
10
10
  import argparse
11
11
  import json
12
+ import re
12
13
  import os
13
14
  import sys
14
15
  from pathlib import Path
@@ -18,7 +19,7 @@ from typing import Optional
18
19
  PROJECT_ROOT = Path(__file__).resolve().parent.parent
19
20
  sys.path.insert(0, str(PROJECT_ROOT / "tools"))
20
21
 
21
- from fojin_bridge import FojinBridge, create_bridge
22
+ from fojin_bridge import FojinBridge, FojinConfigError, create_bridge
22
23
 
23
24
 
24
25
  # ── Teacher registry ────────────────────────────────────────────
@@ -26,9 +27,27 @@ from fojin_bridge import FojinBridge, create_bridge
26
27
  PREBUILT_DIR = PROJECT_ROOT / "prebuilt"
27
28
 
28
29
 
30
+ # Slugs reach this module from `--teachers xuanzang,kumarajiva`, split on
31
+ # commas and joined onto PREBUILT_DIR. Without a charset restriction
32
+ # `--teachers ../../../../etc` read a meta.json from anywhere on the machine —
33
+ # verified before this guard existed. Every other entry point in this repo
34
+ # already had one: `_SAFE_MASTER` in scripts/verify_citations.py and
35
+ # scripts/query.py, `isSafeName` in bin/cli.mjs. This file was the gap.
36
+ _SAFE_SLUG = re.compile(r"^[A-Za-z0-9_-]+$")
37
+
38
+
29
39
  def load_teacher_meta(slug: str) -> dict:
30
40
  """Load a teacher's meta.json by slug."""
41
+ if not _SAFE_SLUG.match(slug):
42
+ raise ValueError(
43
+ f"invalid teacher slug: {slug!r} (letters, digits, '-' and '_' only)"
44
+ )
31
45
  meta_path = PREBUILT_DIR / slug / "meta.json"
46
+ # Resolved and re-checked rather than trusted to the charset alone: the
47
+ # charset is the rule, this is the assertion that the rule held.
48
+ resolved = meta_path.resolve()
49
+ if not resolved.is_relative_to(PREBUILT_DIR.resolve()):
50
+ raise ValueError(f"teacher slug escapes prebuilt/: {slug!r}")
32
51
  if not meta_path.exists():
33
52
  raise FileNotFoundError(f"Teacher not found: {slug}")
34
53
  with open(meta_path, "r", encoding="utf-8") as f:
@@ -347,16 +366,31 @@ def main():
347
366
  parser.print_help()
348
367
  return
349
368
 
350
- bridge = create_bridge()
369
+ try:
370
+ bridge = create_bridge()
371
+ except FojinConfigError as error:
372
+ # Matches the contract the other entry points keep: a message a person
373
+ # can act on, and an exit code, instead of a traceback.
374
+ print(f"[配置错误] {error}", file=sys.stderr)
375
+ sys.exit(2)
351
376
 
352
- if args.command == "lineage":
353
- output = cmd_lineage(bridge, args.person_a, args.person_b)
354
- elif args.command == "concept":
355
- teacher_slugs = [s.strip() for s in args.teachers.split(",")]
356
- output = cmd_concept(bridge, args.concept, teacher_slugs)
357
- else:
358
- parser.print_help()
359
- return
377
+ try:
378
+ if args.command == "lineage":
379
+ output = cmd_lineage(bridge, args.person_a, args.person_b)
380
+ elif args.command == "concept":
381
+ teacher_slugs = [s.strip() for s in args.teachers.split(",")]
382
+ output = cmd_concept(bridge, args.concept, teacher_slugs)
383
+ else:
384
+ parser.print_help()
385
+ return
386
+ except ValueError as error:
387
+ # Raised by load_teacher_meta for a slug outside the allowed charset.
388
+ # scripts/query.py answers the same mistake the same way, exit 2.
389
+ print(f"{error}", file=sys.stderr)
390
+ sys.exit(2)
391
+ except FileNotFoundError as error:
392
+ print(f"{error}", file=sys.stderr)
393
+ sys.exit(1)
360
394
 
361
395
  print(output)
362
396
 
@@ -0,0 +1,14 @@
1
+ {
2
+ "_comment": "FoJin 确认不收录的 CBETA id。每条都必须写明理由与核验日期。verify_sources.py 用它把周检的『Not found』分成『已知缺失』与『新出现的缺失』——前者不再每周开 issue,后者照常。清单本身也会失效:列在这里的 id 若哪天在 FoJin 查得到,同样报告并要求删除该条,所以它不会悄悄烂掉。",
3
+ "absent": [
4
+ {
5
+ "cbeta_id": "J36nB348",
6
+ "short_cbeta_id": "JB348",
7
+ "title": "灵峰宗论",
8
+ "used_by": ["master-ouyi"],
9
+ "reason": "嘉兴藏(J)不在 FoJin 的收录范围内。2026-09-13 以 tools/fojin_bridge.py 直接查证:lookup-cbeta 对 'JB348' 与 'J36nB348' 均返回 {},而同一次请求里的 'T0235' 正常解析为 text_id=7 —— 是缺这部书,不是接口不通。",
10
+ "verified_absent_on": "2026-09-13",
11
+ "consequence": "master-ouyi 引用《灵峰宗论》时拿不到 fojin.app 活链接。离线审计不受影响:J36nB348 在 meta.json 里已声明,verify_citations.py 照常解析。"
12
+ }
13
+ ]
14
+ }
@@ -1,30 +1,101 @@
1
1
  """
2
2
  FoJin Data Bridge — connects Master-skill to FoJin's Buddhist text platform.
3
3
 
4
- Two modes:
5
- - API mode (default): calls fojin.app REST API, works for any user
6
- - Local mode: direct database access, for FoJin developers only
4
+ Every call goes through the fojin.app REST API. The `mode` argument (and
5
+ `FOJIN_MODE`) is accepted but inert: the "local mode: direct database access"
6
+ this docstring advertised for two years was never implemented, and describing
7
+ an unwritten feature as a shipped one is its own kind of unverified claim.
7
8
  """
8
9
 
10
+ from __future__ import annotations
11
+
9
12
  import json
10
13
  import logging
11
14
  import os
15
+ import time
12
16
  from typing import Optional
17
+ from urllib.parse import urlparse
13
18
 
14
19
  import requests
15
20
 
21
+ # A response this big is a broken endpoint or a hostile one, never a real
22
+ # search result — the largest legitimate payload observed is a full juan at
23
+ # well under 2 MB. Without a cap, `resp.json()` reads the whole body into
24
+ # memory first, so a wedged or redirected host can take the process out.
25
+ MAX_RESPONSE_BYTES = 16 * 1024 * 1024
26
+
27
+ # (connect, read). One scalar timeout applies the same value to both, so a
28
+ # host that accepts the connection and then says nothing held the old code for
29
+ # the full 30s. Connecting is either fast or not happening.
30
+ DEFAULT_TIMEOUT = (5, 30)
31
+
32
+ # Wall clock for a whole streamed body. `stream=True` applies the read timeout
33
+ # to each chunk, not to the transfer, so a slow drip never trips it.
34
+ READ_DEADLINE = 60.0
35
+
16
36
 
17
37
  class FojinUnavailableError(Exception):
18
38
  """Raised when FoJin API is unreachable. Callers should handle gracefully."""
19
39
  pass
20
40
 
21
41
 
42
+ class FojinConfigError(ValueError):
43
+ """Raised when FOJIN_URL is not a usable https base URL."""
44
+ pass
45
+
46
+
47
+ def _validate_base_url(url: str) -> str:
48
+ """Reject a base URL that is not plain https.
49
+
50
+ FOJIN_URL is read from the environment and every request is built on top of
51
+ it, so it decides where citations are verified against. Allowing `http://`
52
+ or a `file://`-ish scheme through would silently downgrade or redirect that
53
+ check.
54
+
55
+ Two deliberate exits from that rule:
56
+
57
+ - `http://localhost` (and 127.0.0.1 / ::1) is always allowed; the traffic
58
+ never leaves the machine.
59
+ - `FOJIN_ALLOW_INSECURE_URL=1` allows any http host, for someone running
60
+ FoJin on a LAN or inside a container network. It is opt-in because the
61
+ default has to be the safe one, and because self-hosting is not
62
+ documented anywhere in this repo — an earlier version of this docstring
63
+ claimed references/fojin-api.md covered the local path; that file does
64
+ not mention it at all.
65
+ """
66
+ parsed = urlparse(url)
67
+ if parsed.scheme == "https":
68
+ return url.rstrip("/")
69
+ if parsed.scheme == "http":
70
+ if parsed.hostname in ("localhost", "127.0.0.1", "::1"):
71
+ return url.rstrip("/")
72
+ if os.environ.get("FOJIN_ALLOW_INSECURE_URL") == "1":
73
+ logging.getLogger(__name__).warning(
74
+ "FOJIN_URL is plain http (%s); citations will be verified over "
75
+ "an unauthenticated connection", url
76
+ )
77
+ return url.rstrip("/")
78
+ raise FojinConfigError(
79
+ f"FOJIN_URL must be https: {url!r}. http is allowed on localhost, or "
80
+ "anywhere if you set FOJIN_ALLOW_INSECURE_URL=1 deliberately."
81
+ )
82
+
83
+
22
84
  class FojinBridge:
23
85
  """Bridge to FoJin Buddhist text platform."""
24
86
 
25
87
  def __init__(self, mode: str = "api", base_url: str = "https://fojin.app"):
26
- self.mode = mode
27
- self.base_url = base_url.rstrip("/")
88
+ # `mode` has never done anything: the docstring's "local mode: direct
89
+ # database access" was never implemented, and every call goes through
90
+ # the REST path regardless. Kept as an accepted argument so existing
91
+ # callers and FOJIN_MODE=… do not break, but it is now recorded as
92
+ # inert rather than left looking like a feature.
93
+ if mode not in ("api", ""):
94
+ logging.getLogger(__name__).warning(
95
+ "FojinBridge mode=%r is not implemented; using the REST API", mode
96
+ )
97
+ self.mode = "api"
98
+ self.base_url = _validate_base_url(base_url)
28
99
  self.session = requests.Session()
29
100
  self.session.headers.update({"Accept": "application/json"})
30
101
 
@@ -121,14 +192,70 @@ class FojinBridge:
121
192
  requests.HTTPError: On 4xx/5xx responses
122
193
  """
123
194
  url = f"{self.base_url}{path}"
195
+ resp = None
124
196
  try:
125
- resp = self.session.get(url, params=params, timeout=30)
197
+ # stream=True so the body is not pulled into memory before its
198
+ # size can be checked.
199
+ resp = self.session.get(
200
+ url, params=params, timeout=DEFAULT_TIMEOUT, stream=True
201
+ )
126
202
  resp.raise_for_status()
127
- return resp.json()
203
+ body = self._read_capped(resp)
128
204
  except requests.ConnectionError as e:
129
205
  raise FojinUnavailableError(f"FoJin API unreachable: {e}") from e
130
206
  except requests.Timeout as e:
131
207
  raise FojinUnavailableError(f"FoJin API timeout: {e}") from e
208
+ finally:
209
+ # A streamed response holds its connection until closed.
210
+ if resp is not None:
211
+ resp.close()
212
+ return json.loads(body)
213
+
214
+ @staticmethod
215
+ def _read_capped(resp, deadline: float | None = None) -> bytearray:
216
+ """Read a response body, refusing one that is too large or too slow.
217
+
218
+ The size is checked while reading rather than from Content-Length,
219
+ which a hostile or merely chunked response need not send truthfully.
220
+
221
+ Two things the first version missed:
222
+
223
+ - it accumulated a list of chunks and then `b"".join`ed them, so at the
224
+ 16 MB cap the peak was ~32 MB — the copy defeats part of the point of
225
+ capping at all. A single bytearray grows in place.
226
+ - `stream=True` moves the read timeout to *per chunk*, so a sender
227
+ dripping one byte inside every read window could hold the connection
228
+ indefinitely without ever tripping the size cap. A wall-clock
229
+ deadline closes that.
230
+ """
231
+ budget = READ_DEADLINE if deadline is None else None
232
+ limit = time.monotonic() + READ_DEADLINE if deadline is None else deadline
233
+ body = bytearray()
234
+ for chunk in resp.iter_content(64 * 1024):
235
+ body += chunk
236
+ if len(body) > MAX_RESPONSE_BYTES:
237
+ raise FojinUnavailableError(
238
+ f"FoJin API response exceeded {MAX_RESPONSE_BYTES} bytes; "
239
+ "refusing to buffer it"
240
+ )
241
+ if time.monotonic() > limit:
242
+ # Reports the deadline actually in force. The first version
243
+ # interpolated the module constant regardless of what the
244
+ # caller passed, so a 0.05s deadline announced itself as 60s —
245
+ # and the test asserting on that message locked the lie in.
246
+ howlong = (
247
+ f"{budget}s" if budget is not None else "the caller's deadline"
248
+ )
249
+ raise FojinUnavailableError(
250
+ f"FoJin API response took longer than {howlong} to arrive; "
251
+ "abandoning it"
252
+ )
253
+ # The bytearray IS the return value. `bytes(body)` was a second full
254
+ # copy — precisely the allocation `b"".join` used to make — and
255
+ # measured *worse* than the code it replaced: 33.64 MB peak against
256
+ # 32.03 MB, for a 16 MB body. `json.loads` accepts a bytearray, so the
257
+ # copy bought nothing at all.
258
+ return body
132
259
 
133
260
  def test_connection(self) -> bool:
134
261
  """Test if FoJin API is reachable."""
@@ -140,7 +267,10 @@ class FojinBridge:
140
267
 
141
268
 
142
269
  def create_bridge() -> FojinBridge:
143
- """Create a FojinBridge from environment variables."""
270
+ """Create a FojinBridge from environment variables.
271
+
272
+ Raises FojinConfigError if FOJIN_URL is not a usable https base URL.
273
+ """
144
274
  mode = os.environ.get("FOJIN_MODE", "api")
145
275
  url = os.environ.get("FOJIN_URL", "https://fojin.app")
146
276
  return FojinBridge(mode=mode, base_url=url)
@@ -17,7 +17,7 @@ import os
17
17
  # Ensure tools/ is on the path so we can import fojin_bridge
18
18
  sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
19
19
 
20
- from fojin_bridge import create_bridge, FojinUnavailableError
20
+ from fojin_bridge import create_bridge, FojinConfigError, FojinUnavailableError
21
21
 
22
22
 
23
23
  def source_identity(item: dict) -> tuple[str, str]:
@@ -243,14 +243,37 @@ _CONTROL_CHARS = re.compile(
243
243
  )
244
244
 
245
245
 
246
+ # Everything this tool prints lands in an agent's context window, and the
247
+ # formatters cap each snippet but not how many there are — they render whatever
248
+ # `items` the endpoint returned. Asking for `--top_k 5` and being handed 10,000
249
+ # produced 6.4 million characters in a measurement, from a service this repo
250
+ # does not control and whose data is enriched from third-party editable sources.
251
+ #
252
+ # The cap lives here rather than in each formatter because `emit` is the single
253
+ # place all four commands pass through: a formatter added later cannot route
254
+ # around it. Truncation is announced, so a short answer is never mistaken for a
255
+ # complete one.
256
+ MAX_EMIT_CHARS = 60_000
257
+
258
+
246
259
  def emit(body: str) -> None:
247
260
  """Print a retrieval result wrapped in a data boundary, with control chars
248
- and any forged boundary lines stripped so the fence can't be broken out of."""
261
+ and any forged boundary lines stripped so the fence can't be broken out of,
262
+ and the whole thing capped so it cannot flood the caller's context."""
249
263
  cleaned = _CONTROL_CHARS.sub("", body or "")
250
264
  # Loop until stable — a single replace pass is defeatable by overlapping
251
265
  # boundary lines that rejoin into a fresh marker after the inner one is cut.
252
266
  while _EMIT_HEADER in cleaned or _EMIT_FOOTER in cleaned:
253
267
  cleaned = cleaned.replace(_EMIT_HEADER, "").replace(_EMIT_FOOTER, "")
268
+ if len(cleaned) > MAX_EMIT_CHARS:
269
+ # Truncated AFTER the boundary strip, so the cut cannot leave a partial
270
+ # marker behind that the next pass would not see.
271
+ dropped = len(cleaned) - MAX_EMIT_CHARS
272
+ cleaned = (
273
+ cleaned[:MAX_EMIT_CHARS]
274
+ + f"\n[已截断:检索结果超过 {MAX_EMIT_CHARS} 字符,省略 {dropped} 字符。"
275
+ "这不是完整结果 —— 缩小查询范围或降低 --top_k 后重试。]"
276
+ )
254
277
  print(f"{_EMIT_HEADER}\n{cleaned}\n{_EMIT_FOOTER}")
255
278
 
256
279
 
@@ -316,8 +339,28 @@ def main():
316
339
 
317
340
  try:
318
341
  args.func(args)
342
+ except FojinConfigError as e:
343
+ # A misconfigured FOJIN_URL is not the same as FoJin being down, but it
344
+ # reaches the agent the same way: no retrieval. Routing it through the
345
+ # documented degradation contract (the marker on stdout, exit 0 — see
346
+ # prompts/rag_instructions.md §50) keeps a self-hoster's typo from
347
+ # blowing up a `set -e` pipeline the agent wrote, while the operator
348
+ # gets the actionable reason on stderr.
349
+ print("[FoJin API 当前不可用]")
350
+ print("配置有误,未发起检索。法师将仅基于预置 teaching.md 回答。")
351
+ print(f"[配置错误] {e}", file=sys.stderr)
352
+ sys.exit(0)
319
353
  except FojinUnavailableError:
354
+ # exit 0 是**故意**的,不是本仓一直在修的那个「假绿」形状 ——
355
+ # 这里的调用方是读 stdout 的 agent,不是读退出码的脚本:
356
+ # prompts/rag_instructions.md §50 规定它按 "[FoJin API 当前不可用]"
357
+ # 这行字判断降级。非零退出会让 agent 顺手写的 `set -e` 管道整条炸掉,
358
+ # 换来的是它本来就不看的一个信号。
359
+ # 同一行同时写到 stderr,好让确实在看退出状态的脚本至少能在日志里
360
+ # 分辨「检索不到」与「检索成功」。契约由
361
+ # tests/test_rag_query.py::test_unavailable_* 钉住。
320
362
  print("[FoJin API 当前不可用]")
363
+ print("[FoJin API 当前不可用] 无检索结果,输出仅来自预置内容", file=sys.stderr)
321
364
  print("无法检索真实经文。法师将仅基于预置 teaching.md 回答。")
322
365
  print("建议:")
323
366
  print("- 稍后重试")