@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.
- package/package.json +1 -1
- package/runtime/index.json +6 -6
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agent-pack/hooks/hooks.json +32 -0
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/local-only.md +126 -0
- package/runtime/plugins/agent-pack/references/nudge.en.md +58 -0
- package/runtime/plugins/agent-pack/references/nudge.md +58 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_nudge.py +573 -0
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +419 -107
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +77 -53
- package/runtime/plugins/agent-pack/runtime/i18n_agent_pack.py +78 -0
- package/runtime/plugins/agent-pack/runtime/local_only.py +1130 -0
- package/runtime/plugins/agent-pack/runtime/locale/en.json +385 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +25 -0
- package/runtime/plugins/agent-pack/runtime/nudge_text.py +107 -0
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +9 -1
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/agents-init/content.json +17 -2
- package/runtime/plugins/agents-init/plugin.json +1 -1
- package/runtime/plugins/agents-init/skills/agents-init/AGENT.template.md +47 -0
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +39 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +404 -70
- package/runtime/plugins/agents-init/skills/agents-init/scripts/adopt.py +596 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/i18n_init.py +78 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +245 -72
- package/runtime/plugins/agents-init/skills/agents-init/scripts/locale/en.json +210 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/regions.py +99 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +1081 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_adopt.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_init_project.py +46 -2
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +555 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +66 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +1 -1
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/references/inbox.en.md +48 -0
- package/runtime/plugins/opinion-manager/references/inbox.md +48 -0
- package/runtime/plugins/opinion-manager/runtime/context.py +19 -18
- package/runtime/plugins/opinion-manager/runtime/i18n_opinion.py +78 -0
- package/runtime/plugins/opinion-manager/runtime/inbox.py +397 -0
- package/runtime/plugins/opinion-manager/runtime/inbox_text.py +101 -0
- package/runtime/plugins/opinion-manager/runtime/lifecycle.py +45 -44
- package/runtime/plugins/opinion-manager/runtime/locale/en.json +184 -0
- package/runtime/plugins/opinion-manager/runtime/opinion_manager.py +55 -50
- package/runtime/plugins/opinion-manager/runtime/versioned.py +84 -83
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +15 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/content.json +30 -3
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/references/case-workspaces.md +9 -1
- package/runtime/plugins/project-orchestrator/references/case.schema.json +1 -0
- package/runtime/plugins/project-orchestrator/references/sop-interview.md +83 -0
- package/runtime/plugins/project-orchestrator/references/sop.md +156 -0
- package/runtime/plugins/project-orchestrator/runtime/assets.py +52 -57
- package/runtime/plugins/project-orchestrator/runtime/case.py +139 -80
- package/runtime/plugins/project-orchestrator/runtime/case_structure.py +71 -0
- package/runtime/plugins/project-orchestrator/runtime/case_workspace.py +47 -46
- package/runtime/plugins/project-orchestrator/runtime/i18n_orchestrator.py +78 -0
- package/runtime/plugins/project-orchestrator/runtime/locale/en.json +1409 -0
- package/runtime/plugins/project-orchestrator/runtime/memory.py +44 -43
- package/runtime/plugins/project-orchestrator/runtime/private_paths.py +119 -0
- package/runtime/plugins/project-orchestrator/runtime/project_base.py +37 -19
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +267 -174
- package/runtime/plugins/project-orchestrator/runtime/project_demo.py +130 -48
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +524 -134
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +159 -85
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +191 -106
- package/runtime/plugins/project-orchestrator/runtime/project_write.py +58 -55
- package/runtime/plugins/project-orchestrator/runtime/sop.py +1670 -0
- package/runtime/plugins/project-orchestrator/runtime/sop_structure.py +226 -0
- package/runtime/plugins/project-orchestrator/runtime/wf.py +74 -70
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +22 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.en.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/blank/SOP.md +57 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.en.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/SOP.md +77 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/knowledge-digest/templates/digest.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.en.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/video-to-transcript/SOP.md +87 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.en.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/SOP.md +75 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.en.md +11 -0
- package/runtime/plugins/project-orchestrator/sop-templates/weekly-review/templates/review.md +12 -0
- package/runtime/plugins/project-orchestrator/templates/CASE.en.md +39 -0
|
@@ -0,0 +1,573 @@
|
|
|
1
|
+
"""agenthelm nudge:在 Claude Code、Codex、Cursor 的 hook 里自动采集观点候选、注入试行规则并提示未决事项。
|
|
2
|
+
|
|
3
|
+
``nudge hook`` 由客户端的 hook 调用(stdin 为 hook 的 JSON),永远退出 0、只在有话要说时输出一段 JSON:
|
|
4
|
+
|
|
5
|
+
* ``session-start``:向模型注入候选采集协议与项目内试行的候选;未决候选超过阈值或久未处理时附一行用户可见提示。
|
|
6
|
+
* ``prompt-submit``(Claude Code、Codex):用户消息命中长期偏好关键词时,提醒模型把它暂存为候选。
|
|
7
|
+
* ``stop``:任务看起来已完成且本次会话暂存了还没展示过的候选时,让模型一次性请用户裁决。
|
|
8
|
+
|
|
9
|
+
候选保存在项目的 ``.opinion/inbox.json``(由 opinion-manager 的 ``inbox`` 维护);本模块只读取汇总,
|
|
10
|
+
按「待确认项提供者」契约聚合各来源,不写规则。阈值写在 Project Profile 的 ``opinion.nudge`` 或用户配置
|
|
11
|
+
``~/.config/agenthelm/config.json`` 的 ``nudge``(用户配置优先)。``nudge status`` 显示生效配置与当前汇总,
|
|
12
|
+
``nudge install-hooks --client cursor`` 预览或写入 Cursor 的 hooks.json。
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from nudge_text import LANGUAGES, resolve_language, text as say
|
|
27
|
+
|
|
28
|
+
ENV_DISABLE = "AGENTHELM_NO_NUDGE"
|
|
29
|
+
ENV_DEBUG = "AGENTHELM_NUDGE_DEBUG"
|
|
30
|
+
CLIENTS = ("claude", "codex", "cursor")
|
|
31
|
+
EVENTS = ("session-start", "prompt-submit", "stop")
|
|
32
|
+
STOP_MODES = ("off", "on-completion", "always")
|
|
33
|
+
OURS = re.compile(r"agenthelm\.py\"?\s+nudge\s+hook")
|
|
34
|
+
SUBPROCESS_TIMEOUT = 5
|
|
35
|
+
MAX_PROMPT_CHARS = 1500
|
|
36
|
+
SESSION_RETENTION_SECONDS = 14 * 24 * 3600
|
|
37
|
+
SESSION_LIMIT = 200
|
|
38
|
+
|
|
39
|
+
DEFAULT_KEYWORDS = [
|
|
40
|
+
"以后都", "以后要", "以后不要", "以后别", "今后", "从现在起", "从现在开始", "每次都", "总是", "永远", "别再", "不要再",
|
|
41
|
+
"不要总是", "必须", "一定要", "务必", "记住", "记一下", "下次", "我希望你", "我不喜欢", "我喜欢你",
|
|
42
|
+
"always", "never", "from now on", "going forward", "remember to", "don't ever", "stop doing", "next time",
|
|
43
|
+
]
|
|
44
|
+
DEFAULT_COMPLETION = [
|
|
45
|
+
"完成", "搞定", "已提交", "已推送", "已创建", "已实现", "已修复", "已合并", "总结",
|
|
46
|
+
"done", "completed", "finished", "all set", "merged", "pushed", "implemented", "fixed",
|
|
47
|
+
]
|
|
48
|
+
DEFAULTS: dict[str, Any] = {
|
|
49
|
+
"enabled": True,
|
|
50
|
+
"language": "auto",
|
|
51
|
+
"pending_threshold": 5,
|
|
52
|
+
"stale_days": 7,
|
|
53
|
+
"max_prompts_per_session": 1,
|
|
54
|
+
"capture_hints_per_session": 3,
|
|
55
|
+
"trial_expire_days": 30,
|
|
56
|
+
"reminder_repeat_hours": 24,
|
|
57
|
+
"stop_prompt": {"claude": "on-completion", "codex": "on-completion", "cursor": "off"},
|
|
58
|
+
"keywords": DEFAULT_KEYWORDS,
|
|
59
|
+
"completion_keywords": DEFAULT_COMPLETION,
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# ---------------------------------------------------------------- configuration
|
|
64
|
+
|
|
65
|
+
def _positive_int(value: Any, fallback: int) -> int:
|
|
66
|
+
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 1 else fallback
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _words(value: Any, fallback: list[str]) -> list[str]:
|
|
70
|
+
if isinstance(value, list) and value and all(isinstance(item, str) and item.strip() for item in value):
|
|
71
|
+
return [item.strip().lower() for item in value]
|
|
72
|
+
return [item.lower() for item in fallback]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def merge_config(*layers: Any) -> tuple[dict[str, Any], list[str]]:
|
|
76
|
+
"""内置默认 ← 项目 Profile ← 用户配置;非法值忽略并报告,不让会话失败。"""
|
|
77
|
+
config = {key: (dict(value) if isinstance(value, dict) else value) for key, value in DEFAULTS.items()}
|
|
78
|
+
problems: list[tuple[str, dict]] = []
|
|
79
|
+
for index, layer in enumerate(layers):
|
|
80
|
+
label = ("label_profile", "label_user")[index]
|
|
81
|
+
if layer is None:
|
|
82
|
+
continue
|
|
83
|
+
if not isinstance(layer, dict):
|
|
84
|
+
problems.append(("warn_object", {"label": label}))
|
|
85
|
+
continue
|
|
86
|
+
for key, value in layer.items():
|
|
87
|
+
if key not in DEFAULTS:
|
|
88
|
+
problems.append(("warn_unknown", {"label": label, "key": key}))
|
|
89
|
+
elif key == "enabled":
|
|
90
|
+
if isinstance(value, bool):
|
|
91
|
+
config[key] = value
|
|
92
|
+
else:
|
|
93
|
+
problems.append(("warn_bool", {"label": label, "key": key}))
|
|
94
|
+
elif key == "language":
|
|
95
|
+
if value in ("auto", *LANGUAGES):
|
|
96
|
+
config[key] = value
|
|
97
|
+
else:
|
|
98
|
+
problems.append(("warn_language", {"label": label}))
|
|
99
|
+
elif key == "stop_prompt":
|
|
100
|
+
modes = {client: value for client in CLIENTS} if isinstance(value, str) else value
|
|
101
|
+
if isinstance(modes, dict) and all(client in CLIENTS and mode in STOP_MODES for client, mode in modes.items()):
|
|
102
|
+
config[key] = {**config[key], **modes}
|
|
103
|
+
else:
|
|
104
|
+
problems.append(("warn_stop", {"label": label, "modes": "/".join(STOP_MODES)}))
|
|
105
|
+
elif key in ("keywords", "completion_keywords"):
|
|
106
|
+
parsed = _words(value, [])
|
|
107
|
+
if parsed:
|
|
108
|
+
config[key] = parsed
|
|
109
|
+
else:
|
|
110
|
+
problems.append(("warn_words", {"label": label, "key": key}))
|
|
111
|
+
elif isinstance(value, int) and not isinstance(value, bool) and value >= 1:
|
|
112
|
+
config[key] = value
|
|
113
|
+
else:
|
|
114
|
+
problems.append(("warn_int", {"label": label, "key": key}))
|
|
115
|
+
config["keywords"] = _words(config["keywords"], DEFAULT_KEYWORDS)
|
|
116
|
+
config["completion_keywords"] = _words(config["completion_keywords"], DEFAULT_COMPLETION)
|
|
117
|
+
config["lang"] = resolve_language(config["language"])
|
|
118
|
+
warnings = [say(key, config["lang"], **{**fields, **({"label": say(fields["label"], config["lang"])} if "label" in fields else {})})
|
|
119
|
+
for key, fields in problems]
|
|
120
|
+
return config, warnings
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def project_profile(host: Any, root: Path) -> dict:
|
|
124
|
+
try:
|
|
125
|
+
data = host.read_json(root / host.PROFILE_FILE)
|
|
126
|
+
except (OSError, ValueError):
|
|
127
|
+
return {}
|
|
128
|
+
return data if isinstance(data, dict) else {}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_config(host: Any, env: Any, root: Path | None) -> tuple[dict[str, Any], list[str]]:
|
|
132
|
+
profile_layer = None
|
|
133
|
+
if root is not None:
|
|
134
|
+
opinion = project_profile(host, root).get("opinion")
|
|
135
|
+
profile_layer = opinion.get("nudge") if isinstance(opinion, dict) else None
|
|
136
|
+
try:
|
|
137
|
+
user = host.read_json(env.config_file)
|
|
138
|
+
user_layer = user.get("nudge") if isinstance(user, dict) else None
|
|
139
|
+
except (OSError, ValueError):
|
|
140
|
+
user_layer = None
|
|
141
|
+
return merge_config(profile_layer, user_layer)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
# ---------------------------------------------------------------- project and manager discovery
|
|
145
|
+
|
|
146
|
+
def find_project(host: Any, start: Path | None) -> Path | None:
|
|
147
|
+
"""从 start 向上找启用了 opinion-manager 的项目根;与 Opinion 无关的目录不产生任何输出。"""
|
|
148
|
+
if start is None:
|
|
149
|
+
return None
|
|
150
|
+
base = start.expanduser().resolve()
|
|
151
|
+
for path in [base, *base.parents]:
|
|
152
|
+
if (path / host.PROFILE_FILE).is_file():
|
|
153
|
+
opinion = project_profile(host, path).get("opinion")
|
|
154
|
+
provider = opinion.get("provider") if isinstance(opinion, dict) else None
|
|
155
|
+
enabled = provider == "opinion-manager" or (path / ".opinion").is_dir() or (path / "opinion.lock.json").is_file()
|
|
156
|
+
return path if enabled else None
|
|
157
|
+
if (path / ".opinion").is_dir() or (path / "opinion.lock.json").is_file():
|
|
158
|
+
return path
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def payload_directory(payload: dict, client: str, environment: dict) -> Path | None:
|
|
163
|
+
roots = payload.get("workspace_roots")
|
|
164
|
+
candidate = (roots[0] if isinstance(roots, list) and roots else None) or payload.get("cwd") \
|
|
165
|
+
or environment.get("CURSOR_PROJECT_DIR") or environment.get("CLAUDE_PROJECT_DIR")
|
|
166
|
+
return Path(candidate) if isinstance(candidate, str) and candidate else None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def detect_client(requested: str, payload: dict, environment: dict) -> str:
|
|
170
|
+
if requested != "auto":
|
|
171
|
+
return requested
|
|
172
|
+
if "workspace_roots" in payload or "conversation_id" in payload:
|
|
173
|
+
return "cursor"
|
|
174
|
+
if "turn_id" in payload or environment.get("PLUGIN_ROOT"):
|
|
175
|
+
return "codex"
|
|
176
|
+
return "claude"
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def find_manager(host: Any, env: Any) -> Path | None:
|
|
180
|
+
installed = host.installed_plugins(env, {"opinion-manager"})
|
|
181
|
+
found = host.locate_companion("opinion-manager", installed)
|
|
182
|
+
return found / "runtime" / "opinion_manager.py" if found is not None else None
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def run_manager(manager: Path, *arguments: str, lang: str | None = None) -> Any:
|
|
186
|
+
command = [*arguments, "--lang", lang] if lang else list(arguments)
|
|
187
|
+
try:
|
|
188
|
+
completed = subprocess.run([sys.executable, str(manager), *command], capture_output=True, text=True,
|
|
189
|
+
check=False, timeout=SUBPROCESS_TIMEOUT)
|
|
190
|
+
except (OSError, subprocess.SubprocessError):
|
|
191
|
+
return None
|
|
192
|
+
if completed.returncode != 0:
|
|
193
|
+
return None
|
|
194
|
+
if "markdown" in arguments:
|
|
195
|
+
return completed.stdout
|
|
196
|
+
try:
|
|
197
|
+
result = json.loads(completed.stdout)
|
|
198
|
+
except ValueError:
|
|
199
|
+
return None
|
|
200
|
+
return result if isinstance(result, dict) and result.get("ok") else None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------- pending-item providers
|
|
204
|
+
|
|
205
|
+
def opinion_provider(manager: Path | None, root: Path, session: str, lang: str) -> dict | None:
|
|
206
|
+
"""待确认项提供者契约:返回 {source, items[{id, source, title, status, age_days, hint_command…}],
|
|
207
|
+
counts, oldest_pending_days, last_action_at, session_unpresented}。其他来源(如 SOP 待办)按同一结构接入。"""
|
|
208
|
+
if manager is None or not (root / ".opinion" / "inbox.json").is_file():
|
|
209
|
+
return None
|
|
210
|
+
arguments = ["inbox", "pending", "--project", str(root)] + (["--session", session] if session else [])
|
|
211
|
+
return run_manager(manager, *arguments, lang=lang)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
PROVIDERS = (opinion_provider,)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def collect(manager: Path | None, root: Path, session: str, lang: str) -> dict:
|
|
218
|
+
summaries = [result for provider in PROVIDERS if (result := provider(manager, root, session, lang))]
|
|
219
|
+
waiting = [item for summary in summaries for item in summary["items"] if item["status"] == "pending"]
|
|
220
|
+
return {"summaries": summaries, "pending": len(waiting),
|
|
221
|
+
"trial": sum(1 for summary in summaries for item in summary["items"] if item["status"] == "trial"),
|
|
222
|
+
"oldest_days": max((item["age_days"] for item in waiting), default=0.0),
|
|
223
|
+
"hint": next((item["hint_command"] for item in waiting), None),
|
|
224
|
+
"session_unpresented": [identity for summary in summaries for identity in summary.get("session_unpresented", [])]}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def reminder_text(summary: dict, config: dict) -> str | None:
|
|
228
|
+
count, oldest, lang = summary["pending"], summary["oldest_days"], config["lang"]
|
|
229
|
+
if not count or not (count >= config["pending_threshold"] or oldest >= config["stale_days"]):
|
|
230
|
+
return None
|
|
231
|
+
return say("reminder", lang, count=count,
|
|
232
|
+
age=say("reminder_age", lang, days=int(oldest)) if oldest >= 1 else "",
|
|
233
|
+
trial=say("reminder_trial", lang, trial=summary["trial"]) if summary["trial"] else "")
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------- machine-local state
|
|
237
|
+
|
|
238
|
+
def state_file(env: Any) -> Path:
|
|
239
|
+
return env.state_dir / "agenthelm" / "nudge.json"
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def load_state(host: Any, env: Any) -> dict:
|
|
243
|
+
try:
|
|
244
|
+
data = host.read_json(state_file(env))
|
|
245
|
+
if isinstance(data, dict) and isinstance(data.get("sessions"), dict):
|
|
246
|
+
data.setdefault("reminders", {})
|
|
247
|
+
data.setdefault("exposure_noted", {})
|
|
248
|
+
return data
|
|
249
|
+
except (OSError, ValueError):
|
|
250
|
+
pass
|
|
251
|
+
return {"sessions": {}, "reminders": {}, "exposure_noted": {}}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def save_state(host: Any, env: Any, state: dict) -> None:
|
|
255
|
+
moment = time.time()
|
|
256
|
+
sessions = {key: value for key, value in state["sessions"].items() if moment - value.get("at", 0) < SESSION_RETENTION_SECONDS}
|
|
257
|
+
if len(sessions) > SESSION_LIMIT:
|
|
258
|
+
sessions = dict(sorted(sessions.items(), key=lambda item: item[1].get("at", 0))[-SESSION_LIMIT:])
|
|
259
|
+
state["sessions"] = sessions
|
|
260
|
+
try:
|
|
261
|
+
host.write_json_atomic(state_file(env), state)
|
|
262
|
+
except OSError:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def session_record(state: dict, session: str) -> dict:
|
|
267
|
+
record = state["sessions"].setdefault(session or "unknown", {"capture_hints": 0, "stop_prompts": 0})
|
|
268
|
+
record["at"] = time.time()
|
|
269
|
+
return record
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# ---------------------------------------------------------------- output per client
|
|
273
|
+
|
|
274
|
+
def render(client: str, event: str, *, context: str | None = None, system: str | None = None,
|
|
275
|
+
follow_up: bool = False) -> dict | None:
|
|
276
|
+
"""各客户端的 hook 输出格式。context 为给模型的文字,system 为给用户看的一行。"""
|
|
277
|
+
if not context and not system:
|
|
278
|
+
return None
|
|
279
|
+
if client == "cursor":
|
|
280
|
+
if event == "session-start":
|
|
281
|
+
return {"additional_context": context} if context else None
|
|
282
|
+
return {"followup_message": context} if context and event == "stop" else None
|
|
283
|
+
if event == "stop" and client == "codex":
|
|
284
|
+
return {"decision": "block", "reason": context} if context else None
|
|
285
|
+
name = {"session-start": "SessionStart", "prompt-submit": "UserPromptSubmit", "stop": "Stop"}[event]
|
|
286
|
+
output: dict[str, Any] = {}
|
|
287
|
+
if system:
|
|
288
|
+
output["systemMessage"] = system
|
|
289
|
+
if context:
|
|
290
|
+
output["hookSpecificOutput"] = {"hookEventName": name, "additionalContext": context}
|
|
291
|
+
return output
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------- events
|
|
295
|
+
|
|
296
|
+
def session_start(host: Any, env: Any, payload: dict, client: str, root: Path, config: dict) -> dict | None:
|
|
297
|
+
manager = find_manager(host, env)
|
|
298
|
+
if manager is None:
|
|
299
|
+
return None
|
|
300
|
+
session = str(payload.get("session_id") or payload.get("conversation_id") or "")
|
|
301
|
+
source = str(payload.get("source") or "startup")
|
|
302
|
+
if (root / ".opinion" / "inbox.json").is_file():
|
|
303
|
+
run_manager(manager, "inbox", "expire", "--project", str(root), "--days", str(config["trial_expire_days"]), lang=config["lang"])
|
|
304
|
+
brief = run_manager(manager, "inbox", "brief", "--project", str(root), *(["--session", session] if session else []), lang=config["lang"])
|
|
305
|
+
context = brief["text"] if brief else None
|
|
306
|
+
if source == "compact":
|
|
307
|
+
return render(client, "session-start", context=context)
|
|
308
|
+
summary = collect(manager, root, session, config["lang"])
|
|
309
|
+
text = reminder_text(summary, config)
|
|
310
|
+
state = load_state(host, env)
|
|
311
|
+
shown: list[str] = []
|
|
312
|
+
if text:
|
|
313
|
+
previous = state["reminders"].get(str(root), {})
|
|
314
|
+
if time.time() - previous.get("at", 0) >= config["reminder_repeat_hours"] * 3600:
|
|
315
|
+
state["reminders"][str(root)] = {"at": time.time()}
|
|
316
|
+
shown.append(text)
|
|
317
|
+
context = (context or "") + say("reminder_hint", config["lang"], text=text)
|
|
318
|
+
notice = exposure_notice(manager, root, state, config["lang"])
|
|
319
|
+
if notice:
|
|
320
|
+
shown.append(notice)
|
|
321
|
+
if client != "claude":
|
|
322
|
+
context = (context or "") + say("relay", config["lang"], notice=notice)
|
|
323
|
+
if shown:
|
|
324
|
+
save_state(host, env, state)
|
|
325
|
+
return render(client, "session-start", context=context, system="\n".join(shown) or None)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def exposure_notice(manager: Path, root: Path, state: dict, lang: str) -> str | None:
|
|
329
|
+
"""收件箱含用户原话:每个项目只在第一次提醒一次是否会随仓库或同步盘被他人看到。只告知,不替用户决定,不改任何文件。"""
|
|
330
|
+
if not (root / ".opinion" / "inbox.json").is_file() or str(root) in state["exposure_noted"]:
|
|
331
|
+
return None
|
|
332
|
+
result = run_manager(manager, "inbox", "exposure", "--project", str(root), lang=lang)
|
|
333
|
+
if not result or not result.get("needs_notice"):
|
|
334
|
+
return None
|
|
335
|
+
state["exposure_noted"][str(root)] = {"at": time.time(), "git": result["git"]}
|
|
336
|
+
return "[Opinion] " + result["message"]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def prompt_submit(host: Any, env: Any, payload: dict, client: str, root: Path, config: dict) -> dict | None:
|
|
340
|
+
prompt = payload.get("prompt")
|
|
341
|
+
if client == "cursor" or not isinstance(prompt, str) or not prompt.strip() or len(prompt) > MAX_PROMPT_CHARS:
|
|
342
|
+
return None
|
|
343
|
+
lowered = prompt.lower()
|
|
344
|
+
hits = [word for word in config["keywords"] if word in lowered]
|
|
345
|
+
if not hits:
|
|
346
|
+
return None
|
|
347
|
+
session = str(payload.get("session_id") or "")
|
|
348
|
+
state = load_state(host, env)
|
|
349
|
+
record = session_record(state, session)
|
|
350
|
+
if record["capture_hints"] >= config["capture_hints_per_session"]:
|
|
351
|
+
return None
|
|
352
|
+
record["capture_hints"] += 1
|
|
353
|
+
save_state(host, env, state)
|
|
354
|
+
manager = find_manager(host, env)
|
|
355
|
+
lang = config["lang"]
|
|
356
|
+
command = say("add_command", lang, manager=manager, root=root, lang=lang,
|
|
357
|
+
session=f" --session {session}" if session else "") if manager else say("add_command_fallback", lang)
|
|
358
|
+
context = say("prompt_hint", lang, hits=say("sep", lang).join(hits[:3]), command=command)
|
|
359
|
+
return render(client, "prompt-submit", context=context)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def stop(host: Any, env: Any, payload: dict, client: str, root: Path, config: dict) -> dict | None:
|
|
363
|
+
mode = config["stop_prompt"].get(client, "off")
|
|
364
|
+
if mode == "off" or payload.get("stop_hook_active") or payload.get("loop_count"):
|
|
365
|
+
return None
|
|
366
|
+
if client == "cursor" and payload.get("status") not in (None, "completed"):
|
|
367
|
+
return None
|
|
368
|
+
session = str(payload.get("session_id") or payload.get("conversation_id") or "")
|
|
369
|
+
inbox = root / ".opinion" / "inbox.json"
|
|
370
|
+
if not session or not inbox.is_file() or session not in inbox.read_text(encoding="utf-8", errors="replace"):
|
|
371
|
+
return None
|
|
372
|
+
state = load_state(host, env)
|
|
373
|
+
record = session_record(state, session)
|
|
374
|
+
if record["stop_prompts"] >= config["max_prompts_per_session"]:
|
|
375
|
+
return None
|
|
376
|
+
message = str(payload.get("last_assistant_message") or "").lower()
|
|
377
|
+
if mode == "on-completion" and client != "cursor" and not any(word in message for word in config["completion_keywords"]):
|
|
378
|
+
return None
|
|
379
|
+
manager = find_manager(host, env)
|
|
380
|
+
if manager is None:
|
|
381
|
+
return None
|
|
382
|
+
summary = collect(manager, root, session, config["lang"])
|
|
383
|
+
identifiers = summary["session_unpresented"]
|
|
384
|
+
if not identifiers:
|
|
385
|
+
return None
|
|
386
|
+
listing = run_manager(manager, "inbox", "list", "--project", str(root), "--session", session, "--unpresented",
|
|
387
|
+
"--status", "pending", "--output", "markdown", lang=config["lang"])
|
|
388
|
+
if not listing:
|
|
389
|
+
return None
|
|
390
|
+
run_manager(manager, "inbox", "present", *identifiers, "--project", str(root), lang=config["lang"])
|
|
391
|
+
record["stop_prompts"] += 1
|
|
392
|
+
save_state(host, env, state)
|
|
393
|
+
context = say("stop_request", config["lang"], count=len(identifiers), manager=manager, root=root, lang=config["lang"], listing=listing)
|
|
394
|
+
return render(client, "stop", context=context)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
HANDLERS = {"session-start": session_start, "prompt-submit": prompt_submit, "stop": stop}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def read_payload() -> dict:
|
|
401
|
+
if sys.stdin is None or sys.stdin.isatty():
|
|
402
|
+
return {}
|
|
403
|
+
try:
|
|
404
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
405
|
+
except ValueError:
|
|
406
|
+
return {}
|
|
407
|
+
return data if isinstance(data, dict) else {}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def run_hook(args: argparse.Namespace, host: Any, payload: dict | None = None) -> dict | None:
|
|
411
|
+
"""hook 入口:任何异常都吞掉,保证不影响客户端会话。"""
|
|
412
|
+
try:
|
|
413
|
+
env = host.current_env(args.home)
|
|
414
|
+
if env.get(ENV_DISABLE) not in (None, "0", "false"):
|
|
415
|
+
return None
|
|
416
|
+
payload = read_payload() if payload is None else payload
|
|
417
|
+
client = detect_client(args.client, payload, dict(os.environ))
|
|
418
|
+
root = find_project(host, payload_directory(payload, client, dict(os.environ)))
|
|
419
|
+
if root is None:
|
|
420
|
+
return None
|
|
421
|
+
config, _ = load_config(host, env, root)
|
|
422
|
+
if not config["enabled"]:
|
|
423
|
+
return None
|
|
424
|
+
return HANDLERS[args.event](host, env, payload, client, root, config)
|
|
425
|
+
except Exception: # noqa: BLE001 - a nudge must never break a session
|
|
426
|
+
if os.environ.get(ENV_DEBUG):
|
|
427
|
+
import traceback
|
|
428
|
+
traceback.print_exc(file=sys.stderr)
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# ---------------------------------------------------------------- status and hook installation
|
|
433
|
+
|
|
434
|
+
def status(args: argparse.Namespace, host: Any) -> dict:
|
|
435
|
+
env = host.current_env(args.home)
|
|
436
|
+
root = find_project(host, args.project or Path.cwd())
|
|
437
|
+
config, warnings = load_config(host, env, root)
|
|
438
|
+
result: dict[str, Any] = {"ok": True, "project": str(root) if root else None, "config": config, "warnings": warnings,
|
|
439
|
+
"disabled_by_environment": env.get(ENV_DISABLE) not in (None, "0", "false")}
|
|
440
|
+
if root is None:
|
|
441
|
+
result["note"] = say("note_no_project", config["lang"])
|
|
442
|
+
return result
|
|
443
|
+
manager = find_manager(host, env)
|
|
444
|
+
result["opinion_manager"] = str(manager) if manager else None
|
|
445
|
+
summary = collect(manager, root, "", config["lang"])
|
|
446
|
+
result["summary"] = {key: summary[key] for key in ("pending", "trial", "oldest_days")}
|
|
447
|
+
if manager is not None and (root / ".opinion" / "inbox.json").is_file():
|
|
448
|
+
result["exposure"] = run_manager(manager, "inbox", "exposure", "--project", str(root), lang=config["lang"])
|
|
449
|
+
result["reminder"] = reminder_text(summary, config)
|
|
450
|
+
return result
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def cursor_commands(runtime: Path) -> dict[str, dict]:
|
|
454
|
+
base = f'python3 "{runtime}" nudge hook --client cursor --event'
|
|
455
|
+
return {"sessionStart": {"command": f"{base} session-start", "timeout": 10},
|
|
456
|
+
"stop": {"command": f"{base} stop", "timeout": 10}}
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def merged_cursor_hooks(existing: dict, runtime: Path, remove: bool) -> dict:
|
|
460
|
+
data = json.loads(json.dumps(existing)) if existing else {}
|
|
461
|
+
data.setdefault("version", 1)
|
|
462
|
+
hooks = data.setdefault("hooks", {})
|
|
463
|
+
wanted = cursor_commands(runtime)
|
|
464
|
+
for event in list(hooks):
|
|
465
|
+
kept = [entry for entry in hooks[event] if not (isinstance(entry, dict) and OURS.search(str(entry.get("command", ""))))]
|
|
466
|
+
if kept:
|
|
467
|
+
hooks[event] = kept
|
|
468
|
+
else:
|
|
469
|
+
del hooks[event]
|
|
470
|
+
if not remove:
|
|
471
|
+
for event, entry in wanted.items():
|
|
472
|
+
hooks.setdefault(event, []).append(entry)
|
|
473
|
+
return data
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def install_hooks(args: argparse.Namespace, host: Any) -> dict:
|
|
477
|
+
env = host.current_env(args.home)
|
|
478
|
+
lang = load_config(host, env, None)[0]["lang"]
|
|
479
|
+
if args.scope == "project":
|
|
480
|
+
project = (args.project or Path.cwd()).expanduser().resolve()
|
|
481
|
+
target = project / ".cursor" / "hooks.json"
|
|
482
|
+
else:
|
|
483
|
+
target = env.home / ".cursor" / "hooks.json"
|
|
484
|
+
try:
|
|
485
|
+
existing = host.read_json(target) if target.is_file() else {}
|
|
486
|
+
except ValueError as exc:
|
|
487
|
+
raise host.CheckError(say("err_hooks_json", lang, target=target, error=exc)) from exc
|
|
488
|
+
if not isinstance(existing, dict):
|
|
489
|
+
raise host.CheckError(say("err_hooks_root", lang, target=target))
|
|
490
|
+
runtime = Path(__file__).resolve().with_name("agenthelm.py")
|
|
491
|
+
merged = merged_cursor_hooks(existing, runtime, args.remove)
|
|
492
|
+
changed = merged != existing
|
|
493
|
+
result = {"ok": True, "applied": False, "target": str(target), "changed": changed, "remove": args.remove,
|
|
494
|
+
"hooks": merged.get("hooks", {})}
|
|
495
|
+
if args.apply and changed:
|
|
496
|
+
if target.is_file():
|
|
497
|
+
backup = target.with_name(target.name + ".agenthelm-bak")
|
|
498
|
+
if not backup.exists():
|
|
499
|
+
backup.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
|
|
500
|
+
result["backup"] = str(backup)
|
|
501
|
+
host.write_json_atomic(target, merged)
|
|
502
|
+
result["applied"] = True
|
|
503
|
+
result["note"] = say("note_cursor", lang)
|
|
504
|
+
result["lang"] = lang
|
|
505
|
+
return result
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def parser() -> argparse.ArgumentParser:
|
|
509
|
+
result = argparse.ArgumentParser(prog="agenthelm nudge", description="agenthelm nudge: collect opinion candidates from client hooks and remind about unconfirmed items")
|
|
510
|
+
sub = result.add_subparsers(dest="action", required=True)
|
|
511
|
+
hook = sub.add_parser("hook", help="called by a client hook: the hook JSON arrives on stdin, always exits 0")
|
|
512
|
+
hook.add_argument("--event", choices=EVENTS, required=True)
|
|
513
|
+
hook.add_argument("--client", choices=("auto", *CLIENTS), default="auto")
|
|
514
|
+
hook.add_argument("--home", type=Path, help=argparse.SUPPRESS)
|
|
515
|
+
state = sub.add_parser("status", help="show the effective config, the current pending summary and whether a new session would remind")
|
|
516
|
+
state.add_argument("--project", type=Path)
|
|
517
|
+
state.add_argument("--output", choices=("text", "json"), default="text")
|
|
518
|
+
state.add_argument("--home", type=Path, help=argparse.SUPPRESS)
|
|
519
|
+
install = sub.add_parser("install-hooks", help="write the nudge hooks into Cursor's hooks.json (preview by default)")
|
|
520
|
+
install.add_argument("--client", choices=("cursor",), default="cursor")
|
|
521
|
+
install.add_argument("--scope", choices=("user", "project"), default="user")
|
|
522
|
+
install.add_argument("--project", type=Path)
|
|
523
|
+
install.add_argument("--remove", action="store_true", help="remove the entries agenthelm wrote")
|
|
524
|
+
install.add_argument("--apply", action="store_true")
|
|
525
|
+
install.add_argument("--output", choices=("text", "json"), default="text")
|
|
526
|
+
install.add_argument("--home", type=Path, help=argparse.SUPPRESS)
|
|
527
|
+
return result
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def main(argv: list[str], host: Any) -> int:
|
|
531
|
+
args = parser().parse_args(argv)
|
|
532
|
+
if args.action == "hook":
|
|
533
|
+
output = run_hook(args, host)
|
|
534
|
+
if output is not None:
|
|
535
|
+
print(json.dumps(output, ensure_ascii=False))
|
|
536
|
+
return 0
|
|
537
|
+
try:
|
|
538
|
+
result = status(args, host) if args.action == "status" else install_hooks(args, host)
|
|
539
|
+
except host.CheckError as exc:
|
|
540
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
541
|
+
return 2
|
|
542
|
+
if args.output == "json":
|
|
543
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
544
|
+
return 0
|
|
545
|
+
if args.action == "status":
|
|
546
|
+
config = result["config"]
|
|
547
|
+
lang = config["lang"]
|
|
548
|
+
if result["disabled_by_environment"]:
|
|
549
|
+
state = say("status_env_off", lang)
|
|
550
|
+
else:
|
|
551
|
+
state = say("status_on", lang) if config["enabled"] else say("status_off", lang)
|
|
552
|
+
lines = [say("status_project", lang, project=result["project"] or say("status_no_project", lang)),
|
|
553
|
+
say("status_switch", lang, state=state),
|
|
554
|
+
say("status_thresholds", lang, pending=config["pending_threshold"], stale=config["stale_days"],
|
|
555
|
+
prompts=config["max_prompts_per_session"], expire=config["trial_expire_days"],
|
|
556
|
+
stop=config["stop_prompt"], language=f"{config['language']} ({lang})")]
|
|
557
|
+
if result.get("summary"):
|
|
558
|
+
summary = result["summary"]
|
|
559
|
+
lines.append(say("status_current", lang, pending=summary["pending"], trial=summary["trial"], days=int(summary["oldest_days"])))
|
|
560
|
+
lines.append(result["reminder"] or say("status_quiet", lang))
|
|
561
|
+
if result.get("exposure"):
|
|
562
|
+
lines.append(say("status_exposure", lang, message=result["exposure"]["message"]))
|
|
563
|
+
lines += [say("status_warning", lang, item=item) for item in result["warnings"]]
|
|
564
|
+
if result.get("note"):
|
|
565
|
+
lines.append(result["note"])
|
|
566
|
+
print("\n".join(lines))
|
|
567
|
+
else:
|
|
568
|
+
lang = result["lang"]
|
|
569
|
+
verb = say("install_written" if result["applied"] else ("install_unchanged" if not result["changed"] else "install_preview"), lang)
|
|
570
|
+
print(say("install_line", lang, verb=verb, target=result["target"]))
|
|
571
|
+
print(json.dumps(result["hooks"], ensure_ascii=False, indent=2))
|
|
572
|
+
print(result["note"])
|
|
573
|
+
return 0
|