@sakanano-moe/agenthelm 6.7.0 → 6.11.0-rc.1

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 (86) hide show
  1. package/package.json +1 -1
  2. package/runtime/index.json +6 -6
  3. package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
  4. package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
  5. package/runtime/plugins/agent-pack/plugin.json +1 -1
  6. package/runtime/plugins/agent-pack/references/local-only.md +126 -0
  7. package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
  8. package/runtime/plugins/agent-pack/references/nudge.md +58 -0
  9. package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
  10. package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
  11. package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
  12. package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
  13. package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
  14. package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
  15. package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
  16. package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
  17. package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
  18. package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
  19. package/runtime/plugins/agents-init/content.json +17 -2
  20. package/runtime/plugins/agents-init/plugin.json +1 -1
  21. package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
  22. package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
  23. package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
  24. package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
  25. package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
  26. package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
  27. package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
  28. package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
  29. package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
  30. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
  31. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
  32. package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
  33. package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
  34. package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
  35. package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
  36. package/runtime/plugins/opinion-manager/plugin.json +1 -1
  37. package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
  38. package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
  39. package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
  40. package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
  41. package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
  42. package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
  43. package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
  44. package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
  45. package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
  46. package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
  47. package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
  48. package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
  49. package/runtime/plugins/project-orchestrator/content.json +30 -3
  50. package/runtime/plugins/project-orchestrator/plugin.json +1 -1
  51. package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
  52. package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
  53. package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
  54. package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
  55. package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
  56. package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
  57. package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
  58. package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
  59. package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
  60. package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
  61. package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
  62. package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
  63. package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
  64. package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
  65. package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
  66. package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
  67. package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
  68. package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
  69. package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
  70. package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
  71. package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
  72. package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
  73. package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
  74. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
  75. package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
  76. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
  77. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
  78. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
  79. package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
  80. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
  81. package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
  82. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
  83. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
  84. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
  85. package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
  86. package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
@@ -0,0 +1,48 @@
1
+ # Candidate inbox (inbox)
2
+
3
+ **English** · [简体中文](inbox.md)
4
+
5
+ The inbox keeps opinions that the Agent noticed during work and that still await the user's decision. It is not a set of rules: it never writes `OPINION.md`, a Profile or a lock, and it changes nothing the user has already confirmed. Stashing needs no confirmation; every status change needs the user's explicit decision.
6
+
7
+ ## Location and privacy
8
+
9
+ - File: `.opinion/inbox.json` inside the project (a project-private directory, like `.opinion/profiles`).
10
+ - **It holds the user's own words, which may contain sensitive information.** Projects that use this feature should stay private, or at least stay out of public channels. If you want to make the repository public, sync it to a shared folder or let others see it, whether to exclude `.opinion/` and its contents is your decision. The tool only reminds you; it does not decide for you and never edits `.gitignore`, deletes or rewrites anything.
11
+ - When you are reminded: the first `add` that creates the inbox returns a `notice` (the Agent passes it on to you); each project gets one reminder when a session starts (the `nudge` hook); `inbox exposure` or `agenthelm nudge status` shows the current state at any time (ignored by Git / tracked by Git / not ignored / not a Git repository). Nothing is shown once Git ignores the file.
12
+ - Symbolic links are refused; read-modify-write holds `.opinion/.inbox.lock`, so several sessions can stash at the same time without losing entries.
13
+
14
+ ## Statuses
15
+
16
+ - `pending`: waits for confirmation and is not in effect yet.
17
+ - `trial`: the user agreed to try it in this project. The hook injects the full text of trial candidates with the protocol and the Agent works by it; a trial that is not made formal within `nudge.trial_expire_days` (default 30) returns to `pending`.
18
+ - `formal`: the user agreed to make it a formal rule. This only records the intent; writing it into `OPINION.md` still goes through the full preview and confirmation.
19
+ - `held`: put aside, no more reminders.
20
+ - `rejected`: discarded.
21
+
22
+ ## Commands
23
+
24
+ All commands are `opinion_manager.py inbox <action>`, and `--project` is required. `--lang zh|en` selects the message language (by default `AGENTHELM_LANG`, then the system locale, then English). Output is JSON; `list --output markdown` gives a numbered list for the user.
25
+
26
+ - `add --title --text --scope [--quote] [--client] [--session]`: stash a candidate. Title up to 120, text up to 1200 and quote up to 600 characters; at least one `--scope`. Candidates with the same content (ignoring whitespace and case) are merged and the new quote is added as evidence.
27
+ - `list [--status a,b] [--session ID] [--unpresented]`: list candidates.
28
+ - `decide <ids...> --to pending|trial|formal|held|rejected --confirmed [--note]`: record a decision. Without `--confirmed` it is always refused; with several ids, either all succeed or none changes.
29
+ - `present <ids...>`: record that the user has been shown these candidates (the hook calls it when it sends the confirmation request, so the same candidate is not raised again and again).
30
+ - `expire --days N`: return trials older than N days to `pending`. The return keeps `last_action_at`, so the item still counts as "untouched for a long time".
31
+ - `pending [--session ID]`: the pending-item provider, see below.
32
+ - `exposure`: read-only; reports the Git state of `.opinion/inbox.json` and the reminder text.
33
+ - `brief [--session ID] [--max-chars N]`: builds the protocol and the full text of trial candidates injected at session start; the default limit is 6000 characters.
34
+
35
+ ## Pending-item provider contract
36
+
37
+ agent-pack's session-start check aggregates every source with the same structure (opinion candidates, and later SOP to-dos and so on). A provider returns:
38
+
39
+ ```json
40
+ {"ok": true, "source": "opinion",
41
+ "items": [{"id": "cand-20261029-001", "source": "opinion", "kind": "candidate", "title": "…",
42
+ "status": "pending", "created_at": "…Z", "last_action_at": "…Z", "age_days": 3.2,
43
+ "hint_command": "the command that handles this item"}],
44
+ "counts": {"pending": 2, "trial": 1, "formal": 0, "held": 0, "rejected": 0},
45
+ "oldest_pending_days": 3.2, "last_action_at": "…Z", "session_unpresented": ["cand-…"]}
46
+ ```
47
+
48
+ Items whose `status` is `pending` count toward the reminder thresholds; `age_days` is measured from `last_action_at` (the user's last action, creation time when there is none). A provider is read-only and never changes state while reading.
@@ -0,0 +1,48 @@
1
+ # 候选收件箱(inbox)
2
+
3
+ [English](inbox.en.md) · **简体中文**
4
+
5
+ 收件箱保存 Agent 在工作中发现的、尚待用户裁决的观点。它不是规则:不写入 `OPINION.md`、Profile 或锁,也不改变任何已确认内容。暂存无需确认,所有状态变化都需要用户明确表态。
6
+
7
+ ## 位置与隐私
8
+
9
+ - 文件:项目内的 `.opinion/inbox.json`(与 `.opinion/profiles` 同属项目私有目录)。
10
+ - **含用户原话,可能含敏感信息。**启用本功能的项目建议保持私有(至少不要放进公开渠道)。若要公开仓库、同步到共享目录或让他人看到,是否排除 `.opinion/` 及其中内容由你决定;工具只提醒,不会替你决定,也不会自动修改 `.gitignore`、删除或改写任何内容。
11
+ - 提醒的时机:第一次 `add` 创建收件箱时,返回里带 `notice`(Agent 会转告你);会话开始时每个项目提醒一次(`nudge` 的 hook);`inbox exposure` 或 `agenthelm nudge status` 随时查看当前状态(已被 Git 忽略 / 已被跟踪 / 未被忽略 / 不是 Git 仓库)。已被 Git 忽略时不再提醒。
12
+ - 拒绝符号链接;读改写持有 `.opinion/.inbox.lock`,多个会话同时暂存不会丢失。
13
+
14
+ ## 状态
15
+
16
+ - `pending`:待确认,尚未生效。
17
+ - `trial`:用户同意在本项目试行。hook 会把试行候选的全文随协议注入会话,Agent 按其工作;超过 `nudge.trial_expire_days`(默认 30)未转正回到 `pending`。
18
+ - `formal`:用户同意转为正式规则。这里只记录意愿,写入 `OPINION.md` 仍走完整预览与确认。
19
+ - `held`:暂缓,不再提示。
20
+ - `rejected`:丢弃。
21
+
22
+ ## 命令
23
+
24
+ 以下命令都在 `opinion_manager.py inbox <动作>` 下,`--project` 必填;`--lang zh|en` 指定提示语言(缺省按 `AGENTHELM_LANG`、系统 locale 决定,最后用英文);输出为 JSON,`list` 可用 `--output markdown` 得到给用户看的编号列表。
25
+
26
+ - `add --title --text --scope [--quote] [--client] [--session]`:暂存。标题 ≤120、正文 ≤1200、原话 ≤600 个字符;至少一个 `--scope`。内容相同(忽略空白与大小写)的候选合并,新原话追加为证据。
27
+ - `list [--status a,b] [--session ID] [--unpresented]`:列出候选。
28
+ - `decide <编号…> --to pending|trial|formal|held|rejected --confirmed [--note]`:裁决。没有 `--confirmed` 一律拒绝;多个编号要么全部成功,要么都不改。
29
+ - `present <编号…>`:记录已向用户展示过(hook 在发出确认请求时调用,避免同一候选反复提示)。
30
+ - `expire --days N`:把试行超过 N 天的候选退回 `pending`。退回保留 `last_action_at`,因此仍计入“久未处理”。
31
+ - `pending [--session ID]`:待确认项提供者,见下。
32
+ - `exposure`:只读,报告 `.opinion/inbox.json` 在 Git 里的状态与提醒文字。
33
+ - `brief [--session ID] [--max-chars N]`:生成会话开始时注入的协议与试行候选全文,默认上限 6000 字符。
34
+
35
+ ## 待确认项提供者契约
36
+
37
+ agent-pack 的会话开始检测按同一结构汇总各来源(opinion 候选,以及将来的 SOP 待办等)。一个提供者返回:
38
+
39
+ ```json
40
+ {"ok": true, "source": "opinion",
41
+ "items": [{"id": "cand-20261029-001", "source": "opinion", "kind": "candidate", "title": "…",
42
+ "status": "pending", "created_at": "…Z", "last_action_at": "…Z", "age_days": 3.2,
43
+ "hint_command": "处理这一项的命令"}],
44
+ "counts": {"pending": 2, "trial": 1, "formal": 0, "held": 0, "rejected": 0},
45
+ "oldest_pending_days": 3.2, "last_action_at": "…Z", "session_unpresented": ["cand-…"]}
46
+ ```
47
+
48
+ `status` 为 `pending` 的条目计入提醒阈值;`age_days` 按 `last_action_at`(上一次用户动作,缺省为创建时间)计算。提供者只读,不在读取时改变状态。
@@ -8,9 +8,10 @@ from pathlib import Path
8
8
  from typing import Any
9
9
 
10
10
  import versioned
11
+ from i18n_opinion import _, N_ # noqa: E402
11
12
 
12
13
 
13
- FALLBACK = "完整读取项目根目录 OPINION.md"
14
+ FALLBACK = N_("完整读取项目根目录 OPINION.md")
14
15
  PROJECT_PROFILE = Path(".agents") / "moe.sakanano.agent-pack" / "project.json"
15
16
  DIRECT = "(direct)"
16
17
  EXIT_OK, EXIT_MISSING, EXIT_ERROR, EXIT_FULL = 0, 1, 2, 3
@@ -26,7 +27,7 @@ def project_mode(root: Path) -> str:
26
27
  return "tiered"
27
28
  mode = versioned.read_object(path).get("opinion", {}).get("loading_mode", "tiered")
28
29
  if mode not in ("tiered", "full"):
29
- versioned.fail(f"Project Profile 的 opinion.loading_mode 必须为 tiered 或 full:{mode!r}")
30
+ versioned.fail(_("Project Profile 的 opinion.loading_mode 必须为 tiered 或 full:{0!r}").format(mode))
30
31
  return mode
31
32
 
32
33
 
@@ -36,15 +37,15 @@ def load(args: argparse.Namespace) -> tuple[dict[str, Any], dict[str, Any] | Non
36
37
  profile, lock = versioned.load_profile(args.profile), None
37
38
  else:
38
39
  if args.project is None:
39
- versioned.fail("context 需要 --project 或 --profile")
40
+ versioned.fail(_("context 需要 --project 或 --profile"))
40
41
  root = versioned.safe_path(args.project)
41
42
  versioned.verify_project(root)
42
43
  lock = versioned.read_object(root / "opinion.lock.json")
43
44
  profile = lock["profile"]
44
45
  if project_mode(root) == "full":
45
- raise FullReading("Project Profile 设为完整读取")
46
+ raise FullReading(_("Project Profile 设为完整读取"))
46
47
  if "loading" not in profile:
47
- raise FullReading("当前 Profile 未声明分层加载")
48
+ raise FullReading(_("当前 Profile 未声明分层加载"))
48
49
  return profile, lock
49
50
 
50
51
 
@@ -58,7 +59,7 @@ def partition(profile: dict[str, Any]) -> dict[str, Any]:
58
59
  always = set(loading["core"]) | set(unlisted)
59
60
  assigned: dict[str, str] = {}
60
61
  for wanted in (True, False):
61
- for _, family, value in templates:
62
+ for _ref, family, value in templates:
62
63
  if (family in always) is not wanted:
63
64
  continue
64
65
  for ref in value["rules"]:
@@ -100,13 +101,13 @@ def core_text(profile: dict[str, Any], lock: dict[str, Any] | None) -> str:
100
101
  def resolve_bundle(parts: dict[str, Any], name: str) -> str:
101
102
  family = {ref: family for family, ref in parts["refs"].items()}.get(name, name)
102
103
  if family not in {bundle["family"] for bundle in parts["bundles"]}:
103
- versioned.fail(f"未知的按需规则束:{name}")
104
+ versioned.fail(_("未知的按需规则束:{0}").format(name))
104
105
  return family
105
106
 
106
107
 
107
108
  def bundle_text(profile: dict[str, Any], names: list[str]) -> str:
108
109
  if not names:
109
- versioned.fail("bundle 需要至少一个模板系列或精确模板引用")
110
+ versioned.fail(_("bundle 需要至少一个模板系列或精确模板引用"))
110
111
  parts = partition(profile)
111
112
  lines: list[str] = []
112
113
  for name in names:
@@ -138,12 +139,12 @@ def check(profile: dict[str, Any], signals: list[str], loaded: list[str]) -> dic
138
139
  vocabulary = profile["loading"]["signals"]
139
140
  declared: dict[str, set[str]] = {}
140
141
  for item in signals:
141
- key, _, value = item.partition("=")
142
+ key, _sep, value = item.partition("=")
142
143
  if key not in vocabulary or value not in vocabulary[key]:
143
- versioned.fail(f"未声明的交付信号:{item}")
144
+ versioned.fail(_("未声明的交付信号:{0}").format(item))
144
145
  declared.setdefault(key, set()).add(value)
145
146
  if not declared:
146
- versioned.fail("check 至少需要一个 --signal")
147
+ versioned.fail(_("check 至少需要一个 --signal"))
147
148
  have = {resolve_bundle(parts, name) for name in loaded}
148
149
  required = [bundle["family"] for bundle in parts["bundles"]
149
150
  if any(all(declared.get(key, set()) & set(values) for key, values in route.items()) for route in bundle["routes"])]
@@ -153,13 +154,13 @@ def check(profile: dict[str, Any], signals: list[str], loaded: list[str]) -> dic
153
154
 
154
155
 
155
156
  def add_command(subparsers: Any) -> None:
156
- parser = subparsers.add_parser("context", help="按已确认的加载声明只读切分正文,并核对交付信号")
157
+ parser = subparsers.add_parser("context", help=_("按已确认的加载声明只读切分正文,并核对交付信号"))
157
158
  parser.add_argument("action", choices=("core", "bundle", "index", "check"))
158
- parser.add_argument("names", nargs="*", help="bundle 的模板系列或精确模板引用")
159
+ parser.add_argument("names", nargs="*", help=_("bundle 的模板系列或精确模板引用"))
159
160
  parser.add_argument("--project", type=Path)
160
- parser.add_argument("--profile", type=Path, help="用候选或已保存的 Profile 预览,不读取项目锁")
161
- parser.add_argument("--signal", action="append", default=[], help="交付信号,格式为 键=值,可重复")
162
- parser.add_argument("--loaded", action="append", default=[], help="本次已读取的规则束,可重复")
161
+ parser.add_argument("--profile", type=Path, help=_("用候选或已保存的 Profile 预览,不读取项目锁"))
162
+ parser.add_argument("--signal", action="append", default=[], help=_("交付信号,格式为 键=值,可重复"))
163
+ parser.add_argument("--loaded", action="append", default=[], help=_("本次已读取的规则束,可重复"))
163
164
  parser.add_argument("--output", choices=("json", "markdown"), default="markdown")
164
165
 
165
166
 
@@ -175,6 +176,6 @@ def execute(args: argparse.Namespace) -> tuple[int, str]:
175
176
  result = check(profile, args.signal, args.loaded)
176
177
  return (EXIT_OK if result["ok"] else EXIT_MISSING), versioned.pretty(result)
177
178
  except FullReading as exc:
178
- return EXIT_FULL, versioned.pretty({"ok": False, "error": str(exc), "fallback": FALLBACK})
179
+ return EXIT_FULL, versioned.pretty({"ok": False, "error": str(exc), "fallback": _(FALLBACK)})
179
180
  except (versioned.OpinionVersionError, OSError, UnicodeDecodeError, json.JSONDecodeError, KeyError, TypeError) as exc:
180
- return EXIT_ERROR, versioned.pretty({"ok": False, "error": str(exc), "fallback": FALLBACK})
181
+ return EXIT_ERROR, versioned.pretty({"ok": False, "error": str(exc), "fallback": _(FALLBACK)})
@@ -0,0 +1,78 @@
1
+ """运行时文案的多语言:源语言是中文,``_("中文原文")`` 在中文下原样返回,在英文下查 ``locale/en.json``。
2
+
3
+ 语言按这个顺序决定:调用方指定(如 ``nudge.language``)→ 环境变量 ``AGENTHELM_LANG`` → 用户配置
4
+ ``~/.config/agenthelm/config.json`` 的 ``language`` → 系统 locale(``LC_ALL``、``LC_MESSAGES``、``LANG``)→ 英文。
5
+ ``auto``、空值和 C/POSIX 视为未指定;中文 locale 用中文,其他语言的 locale 暂用英文。
6
+ 没有英文译文的文案原样输出(中文),测试守住译文完整。四个插件各带一份相同的本模块(模块名不同,以免
7
+ 同一进程里互相覆盖),``tests/test_i18n_parity.py`` 守住它们行为一致。模块级常量里的文案用 ``N_`` 标记
8
+ (原样返回,仅供提取),使用处再调用 ``_``,这样语言在运行时而不是导入时决定。
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ from pathlib import Path
16
+ from typing import Mapping
17
+
18
+ LANGUAGES = ("zh", "en")
19
+ DEFAULT_LANGUAGE = "en"
20
+ LOCALE_DIR = Path(__file__).resolve().parent / "locale"
21
+ _catalogs: dict[str, dict[str, str]] = {}
22
+ _configured: dict[str, tuple[tuple, str | None]] = {}
23
+
24
+
25
+ def normalize(value: object) -> str | None:
26
+ text = str(value or "").strip().lower().replace("_", "-")
27
+ if not text or text in ("auto", "c", "posix") or text.startswith(("c.", "posix.")):
28
+ return None
29
+ return "zh" if text.startswith("zh") else "en"
30
+
31
+
32
+ def configured_language(environ: Mapping[str, str]) -> str | None:
33
+ """用户配置里的 language;按文件修改时间缓存,读不了就当没有。"""
34
+ base = environ.get("XDG_CONFIG_HOME") or (str(Path(environ["HOME"]) / ".config") if environ.get("HOME") else "")
35
+ if not base:
36
+ return None
37
+ path = Path(base) / "agenthelm" / "config.json"
38
+ try:
39
+ info = path.stat()
40
+ signature = (info.st_mtime_ns, info.st_size)
41
+ cached = _configured.get(str(path))
42
+ if cached and cached[0] == signature:
43
+ return cached[1]
44
+ data = json.loads(path.read_text(encoding="utf-8"))
45
+ value = normalize(data.get("language")) if isinstance(data, dict) else None
46
+ except (OSError, ValueError):
47
+ return None
48
+ _configured[str(path)] = (signature, value)
49
+ return value
50
+
51
+
52
+ def resolve_language(requested: str | None = None, environ: Mapping[str, str] | None = None) -> str:
53
+ environ = os.environ if environ is None else environ
54
+ for candidate in (requested, environ.get("AGENTHELM_LANG"), configured_language(environ),
55
+ environ.get("LC_ALL"), environ.get("LC_MESSAGES"), environ.get("LANG")):
56
+ language = normalize(candidate)
57
+ if language:
58
+ return language
59
+ return DEFAULT_LANGUAGE
60
+
61
+
62
+ def catalog(language: str) -> dict[str, str]:
63
+ if language not in _catalogs:
64
+ try:
65
+ data = json.loads((LOCALE_DIR / f"{language}.json").read_text(encoding="utf-8"))
66
+ except (OSError, ValueError):
67
+ data = {}
68
+ _catalogs[language] = data if isinstance(data, dict) else {}
69
+ return _catalogs[language]
70
+
71
+
72
+ def _(message: str) -> str:
73
+ language = resolve_language()
74
+ return message if language == "zh" else catalog(language).get(message, message)
75
+
76
+
77
+ def N_(message: str) -> str:
78
+ return message