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.
Files changed (55) hide show
  1. package/README.md +79 -80
  2. package/bin/cli.js +257 -383
  3. package/package.json +28 -56
  4. package/CODEX_LANGFUSE_PLAN.md +0 -62
  5. package/bin/langfuse-cli.js +0 -718
  6. package/codex_langfuse_notify.py +0 -591
  7. package/langfuse_hook.py +0 -603
  8. package/opencode-ohai-report/.claude/commands/report-ai-issue.md +0 -60
  9. package/opencode-ohai-report/.opencode/commands/report-ai-issue.md +0 -30
  10. package/opencode-ohai-report/.opencode/plugins/oh-ai-report.ts +0 -569
  11. package/opencode-ohai-report/README.md +0 -45
  12. package/opencode-ohai-report/bin/cli.js +0 -421
  13. package/opencode-ohai-report/docs/opencode-ai-issue-collection-architecture.md +0 -313
  14. package/opencode-ohai-report/docs/opencode-ai-issue-collection-best-practices.md +0 -476
  15. package/opencode-ohai-report/docs/opencode-ai-issue-collection-phase1-summary.md +0 -405
  16. package/opencode-ohai-report/examples/issue_output.json +0 -4
  17. package/opencode-ohai-report/package.json +0 -40
  18. package/opencode-ohai-report/scripts/claude_report_hook.py +0 -257
  19. package/opencode-ohai-report/scripts/create_issue.py +0 -34
  20. package/opencode-ohai-report/scripts/install-claude-plugin.ps1 +0 -254
  21. package/opencode-ohai-report/scripts/install-opencode-plugin.ps1 +0 -264
  22. package/opencode-ohai-report/scripts/install-opencode-plugin.sh +0 -218
  23. package/opencode-ohai-report/scripts/merge-claude-settings.py +0 -99
  24. package/opencode-ohai-report/tools/ohai-report/README.md +0 -151
  25. package/opencode-ohai-report/tools/ohai-report/examples/issue-input.json +0 -26
  26. package/opencode-ohai-report/tools/ohai-report/ohai_report/__init__.py +0 -5
  27. package/opencode-ohai-report/tools/ohai-report/ohai_report/__main__.py +0 -9
  28. package/opencode-ohai-report/tools/ohai-report/ohai_report/cli.py +0 -319
  29. package/opencode-ohai-report/tools/ohai-report/ohai_report/git_context.py +0 -32
  30. package/opencode-ohai-report/tools/ohai-report/ohai_report/gitcode_defaults.py +0 -14
  31. package/opencode-ohai-report/tools/ohai-report/ohai_report/issue_markdown.py +0 -313
  32. package/opencode-ohai-report/tools/ohai-report/ohai_report/metadata.py +0 -360
  33. package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/__init__.py +0 -1
  34. package/opencode-ohai-report/tools/ohai-report/ohai_report/observability/langfuse.py +0 -38
  35. package/opencode-ohai-report/tools/ohai-report/ohai_report/payload.py +0 -64
  36. package/opencode-ohai-report/tools/ohai-report/ohai_report/schema.py +0 -80
  37. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/__init__.py +0 -1
  38. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/base.py +0 -15
  39. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/gitcode.py +0 -405
  40. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/local.py +0 -21
  41. package/opencode-ohai-report/tools/ohai-report/ohai_report/sinks/webhook.py +0 -354
  42. package/opencode-ohai-report/tools/ohai-report/ohai_report/webhook_defaults.py +0 -9
  43. package/opencode-ohai-report/tools/ohai-report/ohai_report/workspace.py +0 -61
  44. package/opencode-ohai-report/tools/ohai-report/ohai_report.py +0 -10
  45. package/opencode-ohai-report/tools/ohai-report/schemas/report_issue.schema.json +0 -166
  46. package/scripts/codex-langfuse-check.mjs +0 -101
  47. package/scripts/codex-langfuse-setup.mjs +0 -181
  48. package/scripts/langfuse-check.mjs +0 -90
  49. package/scripts/langfuse-setup.mjs +0 -278
  50. package/scripts/opencode-langfuse-check.mjs +0 -94
  51. package/scripts/opencode-langfuse-run.mjs +0 -96
  52. package/scripts/opencode-langfuse-setup.mjs +0 -478
  53. package/scripts/resolve-opencode-cli.mjs +0 -58
  54. package/setup-langfuse.bat +0 -163
  55. 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`。