oh-aicoding-tool 0.1.1 → 0.1.4
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/README.md +79 -80
- package/bin/cli.js +257 -383
- package/package.json +28 -56
- package/CODEX_LANGFUSE_PLAN.md +0 -62
- package/bin/langfuse-cli.js +0 -718
- package/codex_langfuse_notify.py +0 -591
- package/langfuse_hook.py +0 -603
- package/opencode-ohai-report/.claude/commands/report-ai-issue.md +0 -60
- package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +0 -30
- package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +0 -569
- package/opencode-ohai-report/README.md +0 -45
- package/opencode-ohai-report/bin/cli.js +0 -421
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +0 -313
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +0 -476
- package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +0 -405
- package/opencode-ohai-report/examples/issue_output.json +0 -4
- package/opencode-ohai-report/package.json +0 -40
- package/opencode-ohai-report/scripts/claude_report_hook.py +0 -257
- package/opencode-ohai-report/scripts/create_issue.py +0 -34
- package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +0 -254
- package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +0 -264
- package/opencode-ohai-report/scripts/install-opencode-plugin.sh +0 -218
- package/opencode-ohai-report/scripts/merge-claude-settings.py +0 -99
- package/opencode-ohai-report/tools/ohai-report/README.md +0 -151
- package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +0 -26
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +0 -5
- package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +0 -9
- package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +0 -319
- package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +0 -32
- package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +0 -14
- package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +0 -313
- package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +0 -360
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +0 -1
- package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +0 -38
- package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +0 -64
- package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +0 -80
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +0 -1
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +0 -15
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +0 -405
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +0 -21
- package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +0 -354
- package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +0 -9
- package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +0 -61
- package/opencode-ohai-report/tools/ohai-report/ohai_report.py +0 -10
- package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +0 -166
- package/scripts/codex-langfuse-check.mjs +0 -101
- package/scripts/codex-langfuse-setup.mjs +0 -181
- package/scripts/langfuse-check.mjs +0 -90
- package/scripts/langfuse-setup.mjs +0 -278
- package/scripts/opencode-langfuse-check.mjs +0 -94
- package/scripts/opencode-langfuse-run.mjs +0 -96
- package/scripts/opencode-langfuse-setup.mjs +0 -478
- package/scripts/resolve-opencode-cli.mjs +0 -58
- package/setup-langfuse.bat +0 -163
- package/setup-langfuse.sh +0 -130
package/langfuse_hook.py
DELETED
|
@@ -1,603 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Claude Code -> Langfuse hook
|
|
4
|
-
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
import json
|
|
8
|
-
import os
|
|
9
|
-
import sys
|
|
10
|
-
import time
|
|
11
|
-
import hashlib
|
|
12
|
-
from dataclasses import dataclass
|
|
13
|
-
from datetime import datetime, timezone
|
|
14
|
-
from pathlib import Path
|
|
15
|
-
from typing import Any, Dict, List, Optional, Tuple
|
|
16
|
-
|
|
17
|
-
# --- Langfuse import (fail-open) ---
|
|
18
|
-
try:
|
|
19
|
-
from langfuse import Langfuse, propagate_attributes
|
|
20
|
-
except Exception:
|
|
21
|
-
sys.exit(0)
|
|
22
|
-
|
|
23
|
-
# --- Paths ---
|
|
24
|
-
STATE_DIR = Path.home() / ".claude" / "state"
|
|
25
|
-
LOG_FILE = STATE_DIR / "langfuse_hook.log"
|
|
26
|
-
STATE_FILE = STATE_DIR / "langfuse_state.json"
|
|
27
|
-
LOCK_FILE = STATE_DIR / "langfuse_state.lock"
|
|
28
|
-
|
|
29
|
-
DEBUG = os.environ.get("CC_LANGFUSE_DEBUG", "").lower() == "true"
|
|
30
|
-
MAX_CHARS = int(os.environ.get("CC_LANGFUSE_MAX_CHARS", "20000"))
|
|
31
|
-
|
|
32
|
-
# ----------------- Logging -----------------
|
|
33
|
-
def _log(level: str, message: str) -> None:
|
|
34
|
-
try:
|
|
35
|
-
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
36
|
-
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
37
|
-
with open(LOG_FILE, "a", encoding="utf-8") as f:
|
|
38
|
-
f.write(f"{ts} [{level}] {message}\n")
|
|
39
|
-
except Exception:
|
|
40
|
-
# Never block
|
|
41
|
-
pass
|
|
42
|
-
|
|
43
|
-
def debug(msg: str) -> None:
|
|
44
|
-
if DEBUG:
|
|
45
|
-
_log("DEBUG", msg)
|
|
46
|
-
|
|
47
|
-
def info(msg: str) -> None:
|
|
48
|
-
_log("INFO", msg)
|
|
49
|
-
|
|
50
|
-
def warn(msg: str) -> None:
|
|
51
|
-
_log("WARN", msg)
|
|
52
|
-
|
|
53
|
-
def error(msg: str) -> None:
|
|
54
|
-
_log("ERROR", msg)
|
|
55
|
-
|
|
56
|
-
# ----------------- State locking (best-effort) -----------------
|
|
57
|
-
class FileLock:
|
|
58
|
-
def __init__(self, path: Path, timeout_s: float = 2.0):
|
|
59
|
-
self.path = path
|
|
60
|
-
self.timeout_s = timeout_s
|
|
61
|
-
self._fh = None
|
|
62
|
-
|
|
63
|
-
def __enter__(self):
|
|
64
|
-
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
-
self._fh = open(self.path, "a+", encoding="utf-8")
|
|
66
|
-
try:
|
|
67
|
-
import fcntl # Unix only
|
|
68
|
-
deadline = time.time() + self.timeout_s
|
|
69
|
-
while True:
|
|
70
|
-
try:
|
|
71
|
-
fcntl.flock(self._fh.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
72
|
-
break
|
|
73
|
-
except BlockingIOError:
|
|
74
|
-
if time.time() > deadline:
|
|
75
|
-
break
|
|
76
|
-
time.sleep(0.05)
|
|
77
|
-
except Exception:
|
|
78
|
-
# If locking isn't available, proceed without it.
|
|
79
|
-
pass
|
|
80
|
-
return self
|
|
81
|
-
|
|
82
|
-
def __exit__(self, exc_type, exc, tb):
|
|
83
|
-
try:
|
|
84
|
-
import fcntl
|
|
85
|
-
fcntl.flock(self._fh.fileno(), fcntl.LOCK_UN)
|
|
86
|
-
except Exception:
|
|
87
|
-
pass
|
|
88
|
-
try:
|
|
89
|
-
self._fh.close()
|
|
90
|
-
except Exception:
|
|
91
|
-
pass
|
|
92
|
-
|
|
93
|
-
def load_state() -> Dict[str, Any]:
|
|
94
|
-
try:
|
|
95
|
-
if not STATE_FILE.exists():
|
|
96
|
-
return {}
|
|
97
|
-
return json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
98
|
-
except Exception:
|
|
99
|
-
return {}
|
|
100
|
-
|
|
101
|
-
def save_state(state: Dict[str, Any]) -> None:
|
|
102
|
-
try:
|
|
103
|
-
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
|
104
|
-
tmp = STATE_FILE.with_suffix(".tmp")
|
|
105
|
-
tmp.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
|
106
|
-
os.replace(tmp, STATE_FILE)
|
|
107
|
-
except Exception as e:
|
|
108
|
-
debug(f"save_state failed: {e}")
|
|
109
|
-
|
|
110
|
-
def state_key(session_id: str, transcript_path: str) -> str:
|
|
111
|
-
# stable key even if session_id collides
|
|
112
|
-
raw = f"{session_id}::{transcript_path}"
|
|
113
|
-
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
114
|
-
|
|
115
|
-
# ----------------- Hook payload -----------------
|
|
116
|
-
def read_hook_payload() -> Dict[str, Any]:
|
|
117
|
-
"""
|
|
118
|
-
Claude Code hooks pass a JSON payload on stdin.
|
|
119
|
-
This script tolerates missing/empty stdin by returning {}.
|
|
120
|
-
"""
|
|
121
|
-
try:
|
|
122
|
-
data = sys.stdin.read()
|
|
123
|
-
if not data.strip():
|
|
124
|
-
return {}
|
|
125
|
-
return json.loads(data)
|
|
126
|
-
except Exception:
|
|
127
|
-
return {}
|
|
128
|
-
|
|
129
|
-
def extract_session_transcript_and_user(payload: Dict[str, Any]) -> Tuple[Optional[str], Optional[Path], Optional[str]]:
|
|
130
|
-
"""
|
|
131
|
-
Tries a few plausible field names; exact keys can vary across hook types/versions.
|
|
132
|
-
Prefer structured values from stdin over heuristics.
|
|
133
|
-
"""
|
|
134
|
-
session_id = (
|
|
135
|
-
payload.get("sessionId")
|
|
136
|
-
or payload.get("session_id")
|
|
137
|
-
or payload.get("session", {}).get("id")
|
|
138
|
-
)
|
|
139
|
-
|
|
140
|
-
transcript = (
|
|
141
|
-
payload.get("transcriptPath")
|
|
142
|
-
or payload.get("transcript_path")
|
|
143
|
-
or payload.get("transcript", {}).get("path")
|
|
144
|
-
)
|
|
145
|
-
|
|
146
|
-
if transcript:
|
|
147
|
-
try:
|
|
148
|
-
transcript_path = Path(transcript).expanduser().resolve()
|
|
149
|
-
except Exception:
|
|
150
|
-
transcript_path = None
|
|
151
|
-
else:
|
|
152
|
-
transcript_path = None
|
|
153
|
-
|
|
154
|
-
user_id = (
|
|
155
|
-
payload.get("userId")
|
|
156
|
-
or payload.get("user_id")
|
|
157
|
-
or payload.get("user", {}).get("id")
|
|
158
|
-
or payload.get("username")
|
|
159
|
-
or payload.get("userName")
|
|
160
|
-
)
|
|
161
|
-
|
|
162
|
-
if not user_id:
|
|
163
|
-
# Best-effort fallback on local username for CLI sessions.
|
|
164
|
-
user_id = (
|
|
165
|
-
os.environ.get("CC_LANGFUSE_USER_ID")
|
|
166
|
-
or os.environ.get("CLAUDE_USER_ID")
|
|
167
|
-
or os.environ.get("CC_USER_ID")
|
|
168
|
-
or os.environ.get("LANGFUSE_USER_ID")
|
|
169
|
-
or os.environ.get("USERNAME")
|
|
170
|
-
)
|
|
171
|
-
|
|
172
|
-
return session_id, transcript_path, user_id
|
|
173
|
-
|
|
174
|
-
# ----------------- Transcript parsing helpers -----------------
|
|
175
|
-
def get_content(msg: Dict[str, Any]) -> Any:
|
|
176
|
-
if not isinstance(msg, dict):
|
|
177
|
-
return None
|
|
178
|
-
if "message" in msg and isinstance(msg.get("message"), dict):
|
|
179
|
-
return msg["message"].get("content")
|
|
180
|
-
return msg.get("content")
|
|
181
|
-
|
|
182
|
-
def get_role(msg: Dict[str, Any]) -> Optional[str]:
|
|
183
|
-
# Claude Code transcript lines commonly have type=user/assistant OR message.role
|
|
184
|
-
t = msg.get("type")
|
|
185
|
-
if t in ("user", "assistant"):
|
|
186
|
-
return t
|
|
187
|
-
m = msg.get("message")
|
|
188
|
-
if isinstance(m, dict):
|
|
189
|
-
r = m.get("role")
|
|
190
|
-
if r in ("user", "assistant"):
|
|
191
|
-
return r
|
|
192
|
-
return None
|
|
193
|
-
|
|
194
|
-
def is_tool_result(msg: Dict[str, Any]) -> bool:
|
|
195
|
-
role = get_role(msg)
|
|
196
|
-
if role != "user":
|
|
197
|
-
return False
|
|
198
|
-
content = get_content(msg)
|
|
199
|
-
if isinstance(content, list):
|
|
200
|
-
return any(isinstance(x, dict) and x.get("type") == "tool_result" for x in content)
|
|
201
|
-
return False
|
|
202
|
-
|
|
203
|
-
def iter_tool_results(content: Any) -> List[Dict[str, Any]]:
|
|
204
|
-
out: List[Dict[str, Any]] = []
|
|
205
|
-
if isinstance(content, list):
|
|
206
|
-
for x in content:
|
|
207
|
-
if isinstance(x, dict) and x.get("type") == "tool_result":
|
|
208
|
-
out.append(x)
|
|
209
|
-
return out
|
|
210
|
-
|
|
211
|
-
def iter_tool_uses(content: Any) -> List[Dict[str, Any]]:
|
|
212
|
-
out: List[Dict[str, Any]] = []
|
|
213
|
-
if isinstance(content, list):
|
|
214
|
-
for x in content:
|
|
215
|
-
if isinstance(x, dict) and x.get("type") == "tool_use":
|
|
216
|
-
out.append(x)
|
|
217
|
-
return out
|
|
218
|
-
|
|
219
|
-
def extract_text(content: Any) -> str:
|
|
220
|
-
if isinstance(content, str):
|
|
221
|
-
return content
|
|
222
|
-
if isinstance(content, list):
|
|
223
|
-
parts: List[str] = []
|
|
224
|
-
for x in content:
|
|
225
|
-
if isinstance(x, dict) and x.get("type") == "text":
|
|
226
|
-
parts.append(x.get("text", ""))
|
|
227
|
-
elif isinstance(x, str):
|
|
228
|
-
parts.append(x)
|
|
229
|
-
return "\n".join([p for p in parts if p])
|
|
230
|
-
return ""
|
|
231
|
-
|
|
232
|
-
def truncate_text(s: str, max_chars: int = MAX_CHARS) -> Tuple[str, Dict[str, Any]]:
|
|
233
|
-
if s is None:
|
|
234
|
-
return "", {"truncated": False, "orig_len": 0}
|
|
235
|
-
orig_len = len(s)
|
|
236
|
-
if orig_len <= max_chars:
|
|
237
|
-
return s, {"truncated": False, "orig_len": orig_len}
|
|
238
|
-
head = s[:max_chars]
|
|
239
|
-
return head, {"truncated": True, "orig_len": orig_len, "kept_len": len(head), "sha256": hashlib.sha256(s.encode("utf-8")).hexdigest()}
|
|
240
|
-
|
|
241
|
-
def get_model(msg: Dict[str, Any]) -> str:
|
|
242
|
-
m = msg.get("message")
|
|
243
|
-
if isinstance(m, dict):
|
|
244
|
-
return m.get("model") or "claude"
|
|
245
|
-
return "claude"
|
|
246
|
-
|
|
247
|
-
def get_usage(msg: Dict[str, Any]) -> Dict[str, int]:
|
|
248
|
-
"""
|
|
249
|
-
Extract token usage from Claude transcript message shape:
|
|
250
|
-
msg.message.usage.{input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens}
|
|
251
|
-
"""
|
|
252
|
-
m = msg.get("message")
|
|
253
|
-
usage = m.get("usage") if isinstance(m, dict) else None
|
|
254
|
-
if not isinstance(usage, dict):
|
|
255
|
-
return {}
|
|
256
|
-
|
|
257
|
-
out: Dict[str, int] = {}
|
|
258
|
-
field_map = {
|
|
259
|
-
"input_tokens": "input",
|
|
260
|
-
"output_tokens": "output",
|
|
261
|
-
"cache_read_input_tokens": "cache_read_input_tokens",
|
|
262
|
-
"cache_creation_input_tokens": "cache_creation_input_tokens",
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
for src_key, dst_key in field_map.items():
|
|
266
|
-
val = usage.get(src_key)
|
|
267
|
-
if isinstance(val, int) and val >= 0:
|
|
268
|
-
out[dst_key] = val
|
|
269
|
-
return out
|
|
270
|
-
|
|
271
|
-
def get_message_id(msg: Dict[str, Any]) -> Optional[str]:
|
|
272
|
-
m = msg.get("message")
|
|
273
|
-
if isinstance(m, dict):
|
|
274
|
-
mid = m.get("id")
|
|
275
|
-
if isinstance(mid, str) and mid:
|
|
276
|
-
return mid
|
|
277
|
-
return None
|
|
278
|
-
|
|
279
|
-
# ----------------- Incremental reader -----------------
|
|
280
|
-
@dataclass
|
|
281
|
-
class SessionState:
|
|
282
|
-
offset: int = 0
|
|
283
|
-
buffer: str = ""
|
|
284
|
-
turn_count: int = 0
|
|
285
|
-
|
|
286
|
-
def load_session_state(global_state: Dict[str, Any], key: str) -> SessionState:
|
|
287
|
-
s = global_state.get(key, {})
|
|
288
|
-
return SessionState(
|
|
289
|
-
offset=int(s.get("offset", 0)),
|
|
290
|
-
buffer=str(s.get("buffer", "")),
|
|
291
|
-
turn_count=int(s.get("turn_count", 0)),
|
|
292
|
-
)
|
|
293
|
-
|
|
294
|
-
def write_session_state(global_state: Dict[str, Any], key: str, ss: SessionState) -> None:
|
|
295
|
-
global_state[key] = {
|
|
296
|
-
"offset": ss.offset,
|
|
297
|
-
"buffer": ss.buffer,
|
|
298
|
-
"turn_count": ss.turn_count,
|
|
299
|
-
"updated": datetime.now(timezone.utc).isoformat(),
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
def read_new_jsonl(transcript_path: Path, ss: SessionState) -> Tuple[List[Dict[str, Any]], SessionState]:
|
|
303
|
-
"""
|
|
304
|
-
Reads only new bytes since ss.offset. Keeps ss.buffer for partial last line.
|
|
305
|
-
Returns parsed JSON lines (best-effort) and updated state.
|
|
306
|
-
"""
|
|
307
|
-
if not transcript_path.exists():
|
|
308
|
-
return [], ss
|
|
309
|
-
|
|
310
|
-
try:
|
|
311
|
-
with open(transcript_path, "rb") as f:
|
|
312
|
-
f.seek(ss.offset)
|
|
313
|
-
chunk = f.read()
|
|
314
|
-
new_offset = f.tell()
|
|
315
|
-
except Exception as e:
|
|
316
|
-
debug(f"read_new_jsonl failed: {e}")
|
|
317
|
-
return [], ss
|
|
318
|
-
|
|
319
|
-
if not chunk:
|
|
320
|
-
return [], ss
|
|
321
|
-
|
|
322
|
-
try:
|
|
323
|
-
text = chunk.decode("utf-8", errors="replace")
|
|
324
|
-
except Exception:
|
|
325
|
-
text = chunk.decode(errors="replace")
|
|
326
|
-
|
|
327
|
-
combined = ss.buffer + text
|
|
328
|
-
lines = combined.split("\n")
|
|
329
|
-
# last element may be incomplete
|
|
330
|
-
ss.buffer = lines[-1]
|
|
331
|
-
ss.offset = new_offset
|
|
332
|
-
|
|
333
|
-
msgs: List[Dict[str, Any]] = []
|
|
334
|
-
for line in lines[:-1]:
|
|
335
|
-
line = line.strip()
|
|
336
|
-
if not line:
|
|
337
|
-
continue
|
|
338
|
-
try:
|
|
339
|
-
msgs.append(json.loads(line))
|
|
340
|
-
except Exception:
|
|
341
|
-
continue
|
|
342
|
-
|
|
343
|
-
return msgs, ss
|
|
344
|
-
|
|
345
|
-
# ----------------- Turn assembly -----------------
|
|
346
|
-
@dataclass
|
|
347
|
-
class Turn:
|
|
348
|
-
user_msg: Dict[str, Any]
|
|
349
|
-
assistant_msgs: List[Dict[str, Any]]
|
|
350
|
-
tool_results_by_id: Dict[str, Any]
|
|
351
|
-
|
|
352
|
-
def build_turns(messages: List[Dict[str, Any]]) -> List[Turn]:
|
|
353
|
-
"""
|
|
354
|
-
Groups incremental transcript rows into turns:
|
|
355
|
-
user (non-tool-result) -> assistant messages -> (tool_result rows, possibly interleaved)
|
|
356
|
-
Uses:
|
|
357
|
-
- assistant message dedupe by message.id (latest row wins)
|
|
358
|
-
- tool results dedupe by tool_use_id (latest wins)
|
|
359
|
-
"""
|
|
360
|
-
turns: List[Turn] = []
|
|
361
|
-
current_user: Optional[Dict[str, Any]] = None
|
|
362
|
-
|
|
363
|
-
# assistant messages for current turn:
|
|
364
|
-
assistant_order: List[str] = [] # message ids in order of first appearance (or synthetic)
|
|
365
|
-
assistant_latest: Dict[str, Dict[str, Any]] = {} # id -> latest msg
|
|
366
|
-
|
|
367
|
-
tool_results_by_id: Dict[str, Any] = {} # tool_use_id -> content
|
|
368
|
-
|
|
369
|
-
def flush_turn():
|
|
370
|
-
nonlocal current_user, assistant_order, assistant_latest, tool_results_by_id, turns
|
|
371
|
-
if current_user is None:
|
|
372
|
-
return
|
|
373
|
-
if not assistant_latest:
|
|
374
|
-
return
|
|
375
|
-
assistants = [assistant_latest[mid] for mid in assistant_order if mid in assistant_latest]
|
|
376
|
-
turns.append(Turn(user_msg=current_user, assistant_msgs=assistants, tool_results_by_id=dict(tool_results_by_id)))
|
|
377
|
-
|
|
378
|
-
for msg in messages:
|
|
379
|
-
role = get_role(msg)
|
|
380
|
-
|
|
381
|
-
# tool_result rows show up as role=user with content blocks of type tool_result
|
|
382
|
-
if is_tool_result(msg):
|
|
383
|
-
for tr in iter_tool_results(get_content(msg)):
|
|
384
|
-
tid = tr.get("tool_use_id")
|
|
385
|
-
if tid:
|
|
386
|
-
tool_results_by_id[str(tid)] = tr.get("content")
|
|
387
|
-
continue
|
|
388
|
-
|
|
389
|
-
if role == "user":
|
|
390
|
-
# new user message -> finalize previous turn
|
|
391
|
-
flush_turn()
|
|
392
|
-
|
|
393
|
-
# start a new turn
|
|
394
|
-
current_user = msg
|
|
395
|
-
assistant_order = []
|
|
396
|
-
assistant_latest = {}
|
|
397
|
-
tool_results_by_id = {}
|
|
398
|
-
continue
|
|
399
|
-
|
|
400
|
-
if role == "assistant":
|
|
401
|
-
if current_user is None:
|
|
402
|
-
# ignore assistant rows until we see a user message
|
|
403
|
-
continue
|
|
404
|
-
|
|
405
|
-
mid = get_message_id(msg) or f"noid:{len(assistant_order)}"
|
|
406
|
-
if mid not in assistant_latest:
|
|
407
|
-
assistant_order.append(mid)
|
|
408
|
-
assistant_latest[mid] = msg
|
|
409
|
-
continue
|
|
410
|
-
|
|
411
|
-
# ignore unknown rows
|
|
412
|
-
|
|
413
|
-
# flush last
|
|
414
|
-
flush_turn()
|
|
415
|
-
return turns
|
|
416
|
-
|
|
417
|
-
# ----------------- Langfuse emit -----------------
|
|
418
|
-
def _tool_calls_from_assistants(assistant_msgs: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
419
|
-
calls: List[Dict[str, Any]] = []
|
|
420
|
-
for am in assistant_msgs:
|
|
421
|
-
for tu in iter_tool_uses(get_content(am)):
|
|
422
|
-
tid = tu.get("id") or ""
|
|
423
|
-
calls.append({
|
|
424
|
-
"id": str(tid),
|
|
425
|
-
"name": tu.get("name") or "unknown",
|
|
426
|
-
"input": tu.get("input") if isinstance(tu.get("input"), (dict, list, str, int, float, bool)) else {},
|
|
427
|
-
})
|
|
428
|
-
return calls
|
|
429
|
-
|
|
430
|
-
def emit_turn(
|
|
431
|
-
langfuse: Langfuse,
|
|
432
|
-
session_id: str,
|
|
433
|
-
user_id: Optional[str],
|
|
434
|
-
turn_num: int,
|
|
435
|
-
turn: Turn,
|
|
436
|
-
transcript_path: Path,
|
|
437
|
-
) -> None:
|
|
438
|
-
user_text_raw = extract_text(get_content(turn.user_msg))
|
|
439
|
-
user_text, user_text_meta = truncate_text(user_text_raw)
|
|
440
|
-
|
|
441
|
-
last_assistant = turn.assistant_msgs[-1]
|
|
442
|
-
assistant_text_raw = extract_text(get_content(last_assistant))
|
|
443
|
-
assistant_text, assistant_text_meta = truncate_text(assistant_text_raw)
|
|
444
|
-
|
|
445
|
-
model = get_model(turn.assistant_msgs[0])
|
|
446
|
-
usage_details = get_usage(last_assistant)
|
|
447
|
-
|
|
448
|
-
tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
|
|
449
|
-
|
|
450
|
-
# attach tool outputs
|
|
451
|
-
for c in tool_calls:
|
|
452
|
-
if c["id"] and c["id"] in turn.tool_results_by_id:
|
|
453
|
-
out_raw = turn.tool_results_by_id[c["id"]]
|
|
454
|
-
out_str = out_raw if isinstance(out_raw, str) else json.dumps(out_raw, ensure_ascii=False)
|
|
455
|
-
out_trunc, out_meta = truncate_text(out_str)
|
|
456
|
-
c["output"] = out_trunc
|
|
457
|
-
c["output_meta"] = out_meta
|
|
458
|
-
else:
|
|
459
|
-
c["output"] = None
|
|
460
|
-
|
|
461
|
-
with propagate_attributes(
|
|
462
|
-
user_id=user_id,
|
|
463
|
-
session_id=session_id,
|
|
464
|
-
trace_name=f"Claude Code - Turn {turn_num}",
|
|
465
|
-
tags=["claude-code"],
|
|
466
|
-
):
|
|
467
|
-
with langfuse.start_as_current_observation(
|
|
468
|
-
name=f"Claude Code - Turn {turn_num}",
|
|
469
|
-
input={"role": "user", "content": user_text},
|
|
470
|
-
metadata={
|
|
471
|
-
"source": "claude-code",
|
|
472
|
-
"session_id": session_id,
|
|
473
|
-
"turn_number": turn_num,
|
|
474
|
-
"transcript_path": str(transcript_path),
|
|
475
|
-
"user_text": user_text_meta,
|
|
476
|
-
},
|
|
477
|
-
) as trace_span:
|
|
478
|
-
# LLM generation
|
|
479
|
-
with langfuse.start_as_current_observation(
|
|
480
|
-
name="Claude Response",
|
|
481
|
-
as_type="generation",
|
|
482
|
-
model=model,
|
|
483
|
-
input={"role": "user", "content": user_text},
|
|
484
|
-
output={"role": "assistant", "content": assistant_text},
|
|
485
|
-
usage_details=usage_details or None,
|
|
486
|
-
metadata={
|
|
487
|
-
"assistant_text": assistant_text_meta,
|
|
488
|
-
"tool_count": len(tool_calls),
|
|
489
|
-
"usage_details": usage_details,
|
|
490
|
-
},
|
|
491
|
-
):
|
|
492
|
-
pass
|
|
493
|
-
|
|
494
|
-
# Tool observations
|
|
495
|
-
for tc in tool_calls:
|
|
496
|
-
in_obj = tc["input"]
|
|
497
|
-
# truncate tool input if it's a large string payload
|
|
498
|
-
if isinstance(in_obj, str):
|
|
499
|
-
in_obj, in_meta = truncate_text(in_obj)
|
|
500
|
-
else:
|
|
501
|
-
in_meta = None
|
|
502
|
-
|
|
503
|
-
with langfuse.start_as_current_observation(
|
|
504
|
-
name=f"Tool: {tc['name']}",
|
|
505
|
-
as_type="tool",
|
|
506
|
-
input=in_obj,
|
|
507
|
-
metadata={
|
|
508
|
-
"tool_name": tc["name"],
|
|
509
|
-
"tool_id": tc["id"],
|
|
510
|
-
"input_meta": in_meta,
|
|
511
|
-
"output_meta": tc.get("output_meta"),
|
|
512
|
-
},
|
|
513
|
-
) as tool_obs:
|
|
514
|
-
tool_obs.update(output=tc.get("output"))
|
|
515
|
-
|
|
516
|
-
trace_span.update(output={"role": "assistant", "content": assistant_text})
|
|
517
|
-
|
|
518
|
-
# ----------------- Main -----------------
|
|
519
|
-
def main() -> int:
|
|
520
|
-
start = time.time()
|
|
521
|
-
debug("Hook started")
|
|
522
|
-
|
|
523
|
-
if os.environ.get("TRACE_TO_LANGFUSE", "").lower() != "true":
|
|
524
|
-
return 0
|
|
525
|
-
|
|
526
|
-
public_key = os.environ.get("CC_LANGFUSE_PUBLIC_KEY") or os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
527
|
-
secret_key = os.environ.get("CC_LANGFUSE_SECRET_KEY") or os.environ.get("LANGFUSE_SECRET_KEY")
|
|
528
|
-
host = os.environ.get("CC_LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_BASEURL") or "https://cloud.langfuse.com"
|
|
529
|
-
|
|
530
|
-
if not public_key or not secret_key:
|
|
531
|
-
return 0
|
|
532
|
-
|
|
533
|
-
payload = read_hook_payload()
|
|
534
|
-
session_id, transcript_path, user_id = extract_session_transcript_and_user(payload)
|
|
535
|
-
|
|
536
|
-
if not session_id or not transcript_path:
|
|
537
|
-
# No structured payload; fail open (do not guess)
|
|
538
|
-
debug("Missing session_id or transcript_path from hook payload; exiting.")
|
|
539
|
-
return 0
|
|
540
|
-
|
|
541
|
-
if not transcript_path.exists():
|
|
542
|
-
debug(f"Transcript path does not exist: {transcript_path}")
|
|
543
|
-
return 0
|
|
544
|
-
|
|
545
|
-
try:
|
|
546
|
-
langfuse = Langfuse(public_key=public_key, secret_key=secret_key, host=host)
|
|
547
|
-
except Exception:
|
|
548
|
-
return 0
|
|
549
|
-
|
|
550
|
-
try:
|
|
551
|
-
with FileLock(LOCK_FILE):
|
|
552
|
-
state = load_state()
|
|
553
|
-
key = state_key(session_id, str(transcript_path))
|
|
554
|
-
ss = load_session_state(state, key)
|
|
555
|
-
|
|
556
|
-
msgs, ss = read_new_jsonl(transcript_path, ss)
|
|
557
|
-
if not msgs:
|
|
558
|
-
write_session_state(state, key, ss)
|
|
559
|
-
save_state(state)
|
|
560
|
-
return 0
|
|
561
|
-
|
|
562
|
-
turns = build_turns(msgs)
|
|
563
|
-
if not turns:
|
|
564
|
-
write_session_state(state, key, ss)
|
|
565
|
-
save_state(state)
|
|
566
|
-
return 0
|
|
567
|
-
|
|
568
|
-
# emit turns
|
|
569
|
-
emitted = 0
|
|
570
|
-
for t in turns:
|
|
571
|
-
emitted += 1
|
|
572
|
-
turn_num = ss.turn_count + emitted
|
|
573
|
-
try:
|
|
574
|
-
emit_turn(langfuse, session_id, user_id, turn_num, t, transcript_path)
|
|
575
|
-
except Exception as e:
|
|
576
|
-
debug(f"emit_turn failed: {e}")
|
|
577
|
-
# continue emitting other turns
|
|
578
|
-
|
|
579
|
-
ss.turn_count += emitted
|
|
580
|
-
write_session_state(state, key, ss)
|
|
581
|
-
save_state(state)
|
|
582
|
-
|
|
583
|
-
try:
|
|
584
|
-
langfuse.flush()
|
|
585
|
-
except Exception:
|
|
586
|
-
pass
|
|
587
|
-
|
|
588
|
-
dur = time.time() - start
|
|
589
|
-
info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})")
|
|
590
|
-
return 0
|
|
591
|
-
|
|
592
|
-
except Exception as e:
|
|
593
|
-
debug(f"Unexpected failure: {e}")
|
|
594
|
-
return 0
|
|
595
|
-
|
|
596
|
-
finally:
|
|
597
|
-
try:
|
|
598
|
-
langfuse.shutdown()
|
|
599
|
-
except Exception:
|
|
600
|
-
pass
|
|
601
|
-
|
|
602
|
-
if __name__ == "__main__":
|
|
603
|
-
sys.exit(main())
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: 上报 AI 辅助开发问题
|
|
3
|
-
agent: build
|
|
4
|
-
subtask: true
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
你是一个短事务问题上报 subagent。你的职责是使用当前 LLM 能力把用户描述整理为「AI 使用问题反馈」结构字段,然后调用上报 CLI 完成远端建单(**Webhook** 或 **GitCode/Gitee 直连**),并写入本地 `.ohai-report/issues/`。不要继续参与当前开发任务。
|
|
8
|
-
|
|
9
|
-
工作边界:
|
|
10
|
-
- LLM 负责:理解用户描述,生成模板字段(含可选扩展字段)。
|
|
11
|
-
- CLI 负责:校验、metadata、按所选 sink 建单、落盘、打印一行 JSON。
|
|
12
|
-
- Issue 正文不含完整日志;「日志信息」中的 **`user_email`**(公司邮箱)/ `session_id` / … 来自 `--metadata auto`:`session_id` 优先使用**当前进程环境**(如 `OHAI_SESSION_ID` / `LANGFUSE_SESSION_ID`),也可使用近期写入且同工作区的 `.ohai-report/metadata.json` 会话,避免复用过期旧值;trace/model 等其他 metadata 可从当前环境或 `.ohai-report/metadata.json` 补齐;邮箱还会读取独立 JSON:**`<仓库>/.ohai-report/user_email.json`** 或全局 **`~/.claude/ohai-report/email.json`**(安装脚本写入,可直接编辑改邮箱;兼容旧路径 `ohai-report-user.json`)。观测侧建议:`LANGFUSE_SESSION_ID`…;**公司邮箱**建议 `OHAI_USER_EMAIL`;Claude Code hook 可从当前会话注入 `OHAI_SESSION_ID`,也可通过 `metadata update …` 预先落盘 `message_id` / `claude_subagent` / `trace_id` / `observation_id` 等字段。
|
|
13
|
-
|
|
14
|
-
Webhook:`WEBHOOK_URL` / `OHAI_WEBHOOK_URL` 优先,否则使用 `tools/ohai-report/ohai_report/webhook_defaults.py` 中的默认地址。可选 `WEBHOOK_SECRET`。
|
|
15
|
-
|
|
16
|
-
**GitCode / Gitee 直连(`--sink gitcode`)**:在仓库 `openharmonyinsight/ai-dev-feedback` 等目标上由本工具直接调 OpenAPI;默认 owner/repo 见 `tools/ohai-report/ohai_report/gitcode_defaults.py`(可用 `OHAI_GITCODE_OWNER` / `OHAI_GITCODE_REPO` 覆盖)。**Token** 必须经环境变量 `OHAI_GITCODE_TOKEN` 或 `GITCODE_ACCESS_TOKEN` 提供,勿写入仓库。使用 **Gitee** 时务必设置 `OHAI_GITCODE_API_BASE=https://gitee.com/api/v5`。**维度标签**(`tool:` / `model:` / `level:` / `category:`)会在建单后通过 JSON 接口绑定到 Issue。机器人对仓库有 **Maintainer** 且允许自动创建仓库标签时,设置 `OHAI_GITCODE_LABEL_MAINTAINER=1`(或 CLI `--gitcode-label-maintainer`);否则仅绑定已存在的同名标签,缺失则报错。Claude Code 侧可设置 `OHAI_REPORT_SINK=gitcode` 与上述变量,执行 CLI 时显式传入 `--sink gitcode`。
|
|
17
|
-
|
|
18
|
-
严格要求:
|
|
19
|
-
1. 只基于用户输入生成字段,不读取完整日志、diff、源码、transcript。
|
|
20
|
-
2. 不编造具体日志、命令输出、文件名;不确定处写 `unknown` 或保守短句。
|
|
21
|
-
3. 不追问用户。
|
|
22
|
-
4. 不在当前会话中输出完整 Issue 正文。
|
|
23
|
-
5. 在仓库根目录执行 `create`(路径相对仓库根)。**必填**字段通过 CLI 传入;**可选**扩展字段一律用 `--field 键=值` 追加(值中勿含未转义的双引号;含空格时整条 `--field ...` 用引号包起来)。
|
|
24
|
-
|
|
25
|
-
**必填字段(与模板对应)**
|
|
26
|
-
- `title`:标题,≤80 字。
|
|
27
|
-
- `summary`:问题摘要,可较长(≤800 字),写清现象与上下文。
|
|
28
|
-
- `user_description`:原始描述(可与用户原话一致)。
|
|
29
|
-
- `expected_behavior` / `actual_behavior`:期望与实际(各 ≤400 字);信息不足时用保守概括。
|
|
30
|
-
- `category`:必须是以下之一——模型输出错误 / 工具调用失败 / Skill 缺陷 / 上下文缺失 / 环境问题 / 其他。
|
|
31
|
-
- `severity`:P0 / P1 / P2 / P3(默认倾向 P2,除非用户明确表达阻塞或重大损失)。
|
|
32
|
-
|
|
33
|
-
**可选字段(与「AI 使用问题反馈」模板对齐,尽量补齐;不知填 `unknown`)**
|
|
34
|
-
- `agent` / `model` / `level`:在「标签」中固定以 `agent:`、`model:`、`level:` 三行展示。Claude Code 上报时 `agent` 固定为 `claude`;`model` 为所用 Claude 模型名(如 `sonnet`、`opus`,不确定则填 `unknown`);`level` 为问题严重等级 `P0`–`P3`,不填时正文沿用 `severity`。
|
|
35
|
-
- `scenario` / `task_type` / `workflow_phase` / `affected_role`:使用场景(已不含「工具」行)。
|
|
36
|
-
- `user_email` / `session_id` / `message_id` / `claude_subagent` / `trace_id` / `observation_id`:正文「日志信息」展示 **user_email**(公司邮箱)与 **session_id**;`user_id` 仅作追踪侧 JSON 字段。可与 metadata 重叠时用 `--field` 覆盖;缺省时邮箱/会话为「(未采集)」类提示,勿编造。
|
|
37
|
-
- `primary_category`:展示用主分类(如 `model-capability`);不填则正文里用 `category` 枚举值代替。
|
|
38
|
-
- `sub_category`:次分类,默认 `none`。
|
|
39
|
-
- `classification_confidence`:只能是 `high` / `medium` / `low`。
|
|
40
|
-
- `classification_rationale`:判断依据(≤800 字)。
|
|
41
|
-
- `tags`:可选备注(正文「标签」小节固定为 agent/model/level,不再渲染自由标签列表)。
|
|
42
|
-
- `impact`:影响说明。
|
|
43
|
-
- `possible_causes`:可能原因,多行可用 `\n`,行首可加 `- `。
|
|
44
|
-
- `improvement_direction`:改进方向。
|
|
45
|
-
- `suggested_follow_up`:建议补充信息。
|
|
46
|
-
- `suggested_dispatch`:建议分派方向(如 `模型能力、产品体验、unknown`)。
|
|
47
|
-
|
|
48
|
-
然后调用已配置的上报 CLI 入口执行 `create`,不要自行拼接或猜测相对路径形式的 Python 脚本命令。执行参数语义如下(将 `<...>` 与 `--field` 替换为实际值;**无**可选字段时可省略所有 `--field`):`--source claude`、`--metadata auto`、`--json`、必填字段参数、可选扩展字段,以及 **二选一**的 `--sink webhook`(见下)或 `--sink gitcode`(见上文环境变量;维度标签由 CLI 自动处理,无需手写 `--gitcode-labels` 即可打 `tool/model/level/category`)。Webhook 侧 POST JSON 中 **`labels` 为逗号分隔的单个字符串**(如 `bug,critical`),非数组。Webhook 建单若需**额外**远端 Label:传 `--webhook-labels "标签1,标签2"`,或在 issue JSON / `--field labels=...` 中提供(与 `OHAI_WEBHOOK_LABELS` 合并;仅 `--webhook-labels` 时额外标签仅来自该串,维度仍由工具自动前置)。
|
|
49
|
-
|
|
50
|
-
若参数过长,可先在本仓库根目录写入临时 JSON(字段名与 `tools/ohai-report/schemas/report_issue.schema.json` 一致),再通过已配置的 CLI 入口以 `--issue-file <路径>` 提交。**禁止**在命令中使用 `tools/ohai-report/ohai_report.py`、`scripts/create_issue.py` 等相对路径入口,以免在错误工作目录下执行或误写 `.ohai-report`。
|
|
51
|
-
|
|
52
|
-
**上报入口**:必须使用安装器、Hook 或用户环境中已解析好的上报 CLI 入口;如果入口不可用,停止并报告“ohai-report CLI not found / 请设置 OHAI_REPORT_CLI”,不要尝试猜测仓库内相对路径。无论使用哪种 sink,**必须从命令标准输出的 JSON 解析** `issue_id` 与 `webhook_url` / `gitcode_url`(字段缺失或空则省略)。**禁止**在回复里写占位符 `<issue_id>` 或虚构链接。
|
|
53
|
-
|
|
54
|
-
6. 从标准输出取**最后一行完整 JSON**(整行可被 `json.loads` 解析),读取其中的真实字符串 `issue_id` 与 `webhook_url`(字段缺失或空则省略)。**只回复一行**(尖括号内为解析出的字面量,勿保留 angle brackets):
|
|
55
|
-
|
|
56
|
-
```text
|
|
57
|
-
已上报:<issue_id> <webhook_url>
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
无 `webhook_url` 时:`已上报:<issue_id> +用户描述:$ARGUMENTS`。
|