oh-langfuse 0.1.78 → 0.1.80
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/SELF_VERIFY.md +25 -25
- package/bin/cli.js +23 -14
- package/codex_langfuse_notify.py +162 -2
- package/langfuse_hook.py +165 -2
- package/package.json +2 -1
- package/scripts/auto-update-runtime.mjs +14 -14
- package/scripts/cli-detection-utils.mjs +114 -114
- package/scripts/codex-langfuse-check.mjs +65 -65
- package/scripts/codex-langfuse-setup.mjs +127 -116
- package/scripts/json-utils.mjs +99 -99
- package/scripts/langfuse-check.mjs +50 -50
- package/scripts/langfuse-setup.mjs +139 -128
- package/scripts/opencode-langfuse-check.mjs +208 -199
- package/scripts/opencode-langfuse-run.mjs +12 -1
- package/scripts/opencode-langfuse-setup.mjs +88 -6
- package/scripts/opencode-skills-discover.mjs +125 -0
- package/scripts/real-self-verify.mjs +97 -85
- package/scripts/resolve-opencode-cli.mjs +53 -53
- package/scripts/update-langfuse-runtime.mjs +39 -22
- package/setup-langfuse.bat +19 -19
- package/setup-langfuse.sh +19 -19
package/SELF_VERIFY.md
CHANGED
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
# Real self verification
|
|
2
|
-
|
|
3
|
-
`npm run self:verify` is an end-to-end validation path. It does not only inspect environment variables or local config files.
|
|
4
|
-
|
|
5
|
-
The script:
|
|
6
|
-
|
|
7
|
-
1. Runs the real setup command for the selected target unless `--skip-install` is set.
|
|
8
|
-
2. Starts the real Claude Code / OpenCode / Codex CLI with a unique marker.
|
|
9
|
-
3. Polls Langfuse Public API with the configured public/secret key until that marker appears in traces or observations.
|
|
10
|
-
|
|
11
|
-
Examples:
|
|
12
|
-
|
|
13
|
-
```bash
|
|
14
|
-
npm run self:verify -- --targets=opencode --userId=h00613222
|
|
15
|
-
npm run self:verify -- --targets=claude,opencode,codex --userId=h00613222
|
|
16
|
-
```
|
|
17
|
-
|
|
18
|
-
Useful options:
|
|
19
|
-
|
|
20
|
-
- `--opencodeCmd=<path>`, `--claudeCmd=<path>`, `--codexCmd=<path>`: use a specific CLI binary.
|
|
21
|
-
- `--skip-install`: do not run setup before triggering the CLI.
|
|
22
|
-
- `--skip-trigger`: only poll Langfuse for an existing marker.
|
|
23
|
-
- `--timeoutMs=180000`: adjust Langfuse polling timeout.
|
|
24
|
-
|
|
25
|
-
This command writes real user-level tool configuration and sends a real validation trace to Langfuse.
|
|
1
|
+
# Real self verification
|
|
2
|
+
|
|
3
|
+
`npm run self:verify` is an end-to-end validation path. It does not only inspect environment variables or local config files.
|
|
4
|
+
|
|
5
|
+
The script:
|
|
6
|
+
|
|
7
|
+
1. Runs the real setup command for the selected target unless `--skip-install` is set.
|
|
8
|
+
2. Starts the real Claude Code / OpenCode / Codex CLI with a unique marker.
|
|
9
|
+
3. Polls Langfuse Public API with the configured public/secret key until that marker appears in traces or observations.
|
|
10
|
+
|
|
11
|
+
Examples:
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npm run self:verify -- --targets=opencode --userId=h00613222
|
|
15
|
+
npm run self:verify -- --targets=claude,opencode,codex --userId=h00613222
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Useful options:
|
|
19
|
+
|
|
20
|
+
- `--opencodeCmd=<path>`, `--claudeCmd=<path>`, `--codexCmd=<path>`: use a specific CLI binary.
|
|
21
|
+
- `--skip-install`: do not run setup before triggering the CLI.
|
|
22
|
+
- `--skip-trigger`: only poll Langfuse for an existing marker.
|
|
23
|
+
- `--timeoutMs=180000`: adjust Langfuse polling timeout.
|
|
24
|
+
|
|
25
|
+
This command writes real user-level tool configuration and sends a real validation trace to Langfuse.
|
package/bin/cli.js
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
|
-
import readline from "readline";
|
|
5
|
-
import { fileURLToPath } from "url";
|
|
6
|
-
import { spawnSync } from "child_process";
|
|
7
|
-
import { resolveOpencodeCli } from "../scripts/resolve-opencode-cli.mjs";
|
|
4
|
+
import readline from "readline";
|
|
5
|
+
import { fileURLToPath } from "url";
|
|
6
|
+
import { spawnSync } from "child_process";
|
|
7
|
+
import { resolveOpencodeCli } from "../scripts/resolve-opencode-cli.mjs";
|
|
8
8
|
|
|
9
9
|
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
10
|
const scriptsDir = path.join(rootDir, "scripts");
|
|
11
11
|
|
|
12
|
-
const DEFAULT_LANGFUSE_BASE_URL = "
|
|
12
|
+
const DEFAULT_LANGFUSE_BASE_URL = "https://metrics.openharmonyinsight.cn";
|
|
13
|
+
const LEGACY_LANGFUSE_BASE_URLS = new Set([
|
|
14
|
+
"http://120.46.221.227:3000",
|
|
15
|
+
"https://120.46.221.227:3000",
|
|
16
|
+
]);
|
|
13
17
|
const DEFAULT_LANGFUSE_PUBLIC_KEY = "pk-lf-da0c90a7-6e93-4eb7-bb86-c1047c8d187d";
|
|
14
18
|
const DEFAULT_LANGFUSE_SECRET_KEY = "sk-lf-0269b85d-bfdc-442c-bfa3-e737954e3315";
|
|
15
19
|
const USER_ID_PATTERN = /^[a-z](?:\d{8}|wx\d{7})$/;
|
|
@@ -119,6 +123,11 @@ function hasValue(v) {
|
|
|
119
123
|
return typeof v === "string" && v.trim().length > 0;
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
function normalizeLegacyBaseUrl(baseUrl) {
|
|
127
|
+
const value = String(baseUrl || "").trim().replace(/\/+$/, "");
|
|
128
|
+
return LEGACY_LANGFUSE_BASE_URLS.has(value) ? DEFAULT_LANGFUSE_BASE_URL : baseUrl;
|
|
129
|
+
}
|
|
130
|
+
|
|
122
131
|
function normalizeUserId(v) {
|
|
123
132
|
return String(v || "").trim();
|
|
124
133
|
}
|
|
@@ -160,13 +169,13 @@ function detectPython() {
|
|
|
160
169
|
return { ok: false, command: null, version: "", pipOk: false, pipVersion: "" };
|
|
161
170
|
}
|
|
162
171
|
|
|
163
|
-
function detectOpencode() {
|
|
164
|
-
const resolved = resolveOpencodeCli();
|
|
165
|
-
const candidates = [resolved, ...(process.platform === "win32" ? ["opencode.cmd", "opencode"] : ["opencode"])].filter(Boolean);
|
|
166
|
-
for (const cmd of candidates) {
|
|
167
|
-
const r = commandExists(cmd, ["--version"]);
|
|
168
|
-
if (r.ok) return { ok: true, command: cmd, version: r.stdout || r.stderr };
|
|
169
|
-
}
|
|
172
|
+
function detectOpencode() {
|
|
173
|
+
const resolved = resolveOpencodeCli();
|
|
174
|
+
const candidates = [resolved, ...(process.platform === "win32" ? ["opencode.cmd", "opencode"] : ["opencode"])].filter(Boolean);
|
|
175
|
+
for (const cmd of candidates) {
|
|
176
|
+
const r = commandExists(cmd, ["--version"]);
|
|
177
|
+
if (r.ok) return { ok: true, command: cmd, version: r.stdout || r.stderr };
|
|
178
|
+
}
|
|
170
179
|
return { ok: false, command: null, version: "" };
|
|
171
180
|
}
|
|
172
181
|
|
|
@@ -508,13 +517,13 @@ async function askMultiChoice(rl, label, choices, options = {}) {
|
|
|
508
517
|
|
|
509
518
|
function langfuseConfig(overrides = {}) {
|
|
510
519
|
const config = {
|
|
511
|
-
baseUrl: envDefault("LANGFUSE_BASEURL", envDefault("LANGFUSE_HOST", DEFAULT_LANGFUSE_BASE_URL)),
|
|
520
|
+
baseUrl: normalizeLegacyBaseUrl(envDefault("LANGFUSE_BASEURL", envDefault("LANGFUSE_HOST", DEFAULT_LANGFUSE_BASE_URL))),
|
|
512
521
|
publicKey: envDefault("LANGFUSE_PUBLIC_KEY", DEFAULT_LANGFUSE_PUBLIC_KEY),
|
|
513
522
|
secretKey: envDefault("LANGFUSE_SECRET_KEY", DEFAULT_LANGFUSE_SECRET_KEY),
|
|
514
523
|
userId: ""
|
|
515
524
|
};
|
|
516
525
|
for (const [key, value] of Object.entries(overrides)) {
|
|
517
|
-
if (hasValue(value)) config[key] = value;
|
|
526
|
+
if (hasValue(value)) config[key] = key === "baseUrl" ? normalizeLegacyBaseUrl(value) : value;
|
|
518
527
|
}
|
|
519
528
|
return config;
|
|
520
529
|
}
|
package/codex_langfuse_notify.py
CHANGED
|
@@ -745,6 +745,155 @@ def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
|
|
|
745
745
|
return names
|
|
746
746
|
|
|
747
747
|
|
|
748
|
+
def collect_skill_roots(cwd: Path) -> list:
|
|
749
|
+
"""Return absolute <dir>/skills paths that contain SKILL.md, found among
|
|
750
|
+
cwd itself, cwd's children, and cwd's siblings. Mirrors the OpenCode
|
|
751
|
+
opencode-skills-discover.mjs collectSkillRoots so skills cloned near the
|
|
752
|
+
working directory can be linked into the native skills dir."""
|
|
753
|
+
def _has_skill(d: Path) -> bool:
|
|
754
|
+
try:
|
|
755
|
+
if not d.is_dir():
|
|
756
|
+
return False
|
|
757
|
+
for entry in d.iterdir():
|
|
758
|
+
if entry.name == "SKILL.md" and entry.is_file():
|
|
759
|
+
return True
|
|
760
|
+
if entry.is_dir() and not entry.name.startswith(".") and (entry / "SKILL.md").exists():
|
|
761
|
+
return True
|
|
762
|
+
except Exception:
|
|
763
|
+
pass
|
|
764
|
+
return False
|
|
765
|
+
|
|
766
|
+
candidates = []
|
|
767
|
+
for sub in (Path("skills"), Path(".opencode") / "skills", Path(".opencode") / "skill"):
|
|
768
|
+
candidates.append(cwd / sub)
|
|
769
|
+
try:
|
|
770
|
+
for entry in cwd.iterdir():
|
|
771
|
+
if entry.is_dir() and not entry.name.startswith("."):
|
|
772
|
+
candidates.append(entry / "skills")
|
|
773
|
+
except Exception:
|
|
774
|
+
pass
|
|
775
|
+
parent = cwd.parent
|
|
776
|
+
if parent != cwd:
|
|
777
|
+
try:
|
|
778
|
+
for entry in parent.iterdir():
|
|
779
|
+
if entry.is_dir() and entry.name != cwd.name and not entry.name.startswith("."):
|
|
780
|
+
candidates.append(entry / "skills")
|
|
781
|
+
except Exception:
|
|
782
|
+
pass
|
|
783
|
+
|
|
784
|
+
roots = []
|
|
785
|
+
seen = set()
|
|
786
|
+
for d in candidates:
|
|
787
|
+
try:
|
|
788
|
+
if _has_skill(d):
|
|
789
|
+
key = str(d.resolve())
|
|
790
|
+
if key not in seen:
|
|
791
|
+
seen.add(key)
|
|
792
|
+
roots.append(d.resolve())
|
|
793
|
+
except Exception:
|
|
794
|
+
continue
|
|
795
|
+
return roots
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _skill_link_exists(link: Path) -> bool:
|
|
799
|
+
try:
|
|
800
|
+
return link.exists() or link.is_symlink()
|
|
801
|
+
except Exception:
|
|
802
|
+
return False
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _create_skill_link(target: Path, link: Path) -> bool:
|
|
806
|
+
"""Create a junction (Windows, no admin) or symlink (Unix) at link -> target.
|
|
807
|
+
Returns False if link already exists or creation failed.
|
|
808
|
+
|
|
809
|
+
On Windows we always use a directory junction (mklink /J): Codex follows
|
|
810
|
+
junctions but does not follow true symlinks for skill discovery, while
|
|
811
|
+
Claude Code follows both. Junctions do not require admin/developer mode."""
|
|
812
|
+
if _skill_link_exists(link):
|
|
813
|
+
return False
|
|
814
|
+
try:
|
|
815
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
816
|
+
except Exception:
|
|
817
|
+
pass
|
|
818
|
+
if sys.platform == "win32":
|
|
819
|
+
try:
|
|
820
|
+
r = subprocess.run(
|
|
821
|
+
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
|
822
|
+
capture_output=True,
|
|
823
|
+
)
|
|
824
|
+
return r.returncode == 0
|
|
825
|
+
except Exception:
|
|
826
|
+
return False
|
|
827
|
+
try:
|
|
828
|
+
os.symlink(str(target), str(link), target_is_directory=True)
|
|
829
|
+
return True
|
|
830
|
+
except OSError:
|
|
831
|
+
return False
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def _remove_skill_link(link: Path) -> None:
|
|
835
|
+
"""Remove a junction/symlink we created, without touching the target."""
|
|
836
|
+
try:
|
|
837
|
+
if sys.platform == "win32":
|
|
838
|
+
subprocess.run(["cmd", "/c", "rmdir", str(link)], capture_output=True)
|
|
839
|
+
else:
|
|
840
|
+
os.unlink(str(link))
|
|
841
|
+
except Exception:
|
|
842
|
+
pass
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def link_nearby_skills(cwd: Path, native_dir: Path, state_file: Path) -> int:
|
|
846
|
+
"""Symlink nearby repo skills into native_dir so the agent discovers them on
|
|
847
|
+
the next turn/session. Idempotent; skips names already present (native installs
|
|
848
|
+
or prior links). Cleans stale links we created whose target is gone. Failures
|
|
849
|
+
are swallowed so the metrics hook never breaks. Returns newly-linked count."""
|
|
850
|
+
try:
|
|
851
|
+
native_dir.mkdir(parents=True, exist_ok=True)
|
|
852
|
+
except Exception:
|
|
853
|
+
return 0
|
|
854
|
+
state = {}
|
|
855
|
+
try:
|
|
856
|
+
if state_file.exists():
|
|
857
|
+
state = json.loads(state_file.read_text("utf-8") or "{}")
|
|
858
|
+
except Exception:
|
|
859
|
+
state = {}
|
|
860
|
+
for link_str, target_str in list(state.items()):
|
|
861
|
+
link = Path(link_str)
|
|
862
|
+
target = Path(target_str)
|
|
863
|
+
try:
|
|
864
|
+
if _skill_link_exists(link):
|
|
865
|
+
if not target.exists():
|
|
866
|
+
_remove_skill_link(link)
|
|
867
|
+
state.pop(link_str, None)
|
|
868
|
+
else:
|
|
869
|
+
state.pop(link_str, None)
|
|
870
|
+
except Exception:
|
|
871
|
+
pass
|
|
872
|
+
linked = 0
|
|
873
|
+
for repo_skills_dir in collect_skill_roots(cwd):
|
|
874
|
+
try:
|
|
875
|
+
for skill_dir in repo_skills_dir.iterdir():
|
|
876
|
+
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
|
|
877
|
+
continue
|
|
878
|
+
if not (skill_dir / "SKILL.md").exists():
|
|
879
|
+
continue
|
|
880
|
+
try:
|
|
881
|
+
target = skill_dir.resolve()
|
|
882
|
+
except Exception:
|
|
883
|
+
continue
|
|
884
|
+
link_path = native_dir / skill_dir.name
|
|
885
|
+
if _create_skill_link(target, link_path):
|
|
886
|
+
state[str(link_path)] = str(target)
|
|
887
|
+
linked += 1
|
|
888
|
+
except Exception:
|
|
889
|
+
pass
|
|
890
|
+
try:
|
|
891
|
+
state_file.write_text(json.dumps(state, ensure_ascii=False), "utf-8")
|
|
892
|
+
except Exception:
|
|
893
|
+
pass
|
|
894
|
+
return linked
|
|
895
|
+
|
|
896
|
+
|
|
748
897
|
def _skill_namespace(name: str) -> str:
|
|
749
898
|
return name.split(":", 1)[0] if ":" in name else ""
|
|
750
899
|
|
|
@@ -1377,7 +1526,12 @@ def emit_codex_turn(
|
|
|
1377
1526
|
model = first_string(turn.model, meta.get("model"), meta.get("model_provider")) or "codex"
|
|
1378
1527
|
tool_calls = material.get("tool_calls") or []
|
|
1379
1528
|
tool_results = material.get("tool_results") or []
|
|
1380
|
-
|
|
1529
|
+
cwd = Path.cwd()
|
|
1530
|
+
try:
|
|
1531
|
+
known_skills = discover_known_skills(collect_skill_roots(cwd))
|
|
1532
|
+
except Exception:
|
|
1533
|
+
known_skills = discover_known_skills()
|
|
1534
|
+
skill_usages = detect_turn_skill_usages(material, known_skills)
|
|
1381
1535
|
interaction_id = build_interaction_id("codex", session_id, turn_num)
|
|
1382
1536
|
skill_use_events = build_skill_use_events(interaction_id, skill_usages)
|
|
1383
1537
|
interaction_meta = build_interaction_metadata(
|
|
@@ -1516,7 +1670,7 @@ def main() -> int:
|
|
|
1516
1670
|
or os.environ.get("LANGFUSE_BASEURL")
|
|
1517
1671
|
or os.environ.get("LANGFUSE_HOST")
|
|
1518
1672
|
or config.get("baseUrl")
|
|
1519
|
-
or "https://
|
|
1673
|
+
or "https://metrics.openharmonyinsight.cn"
|
|
1520
1674
|
)
|
|
1521
1675
|
user_id = (
|
|
1522
1676
|
find_value(payload, ("user_id", "userId", "username", "userName"))
|
|
@@ -1595,6 +1749,12 @@ def main() -> int:
|
|
|
1595
1749
|
langfuse.flush()
|
|
1596
1750
|
except Exception:
|
|
1597
1751
|
pass
|
|
1752
|
+
# Best-effort: link nearby repo skills into ~/.codex/skills. Runs
|
|
1753
|
+
# AFTER the Langfuse flush so it can never block or break metrics.
|
|
1754
|
+
try:
|
|
1755
|
+
link_nearby_skills(Path.cwd(), CODEX_DIR / "skills", CODEX_DIR / "skills" / ".oh-linked.json")
|
|
1756
|
+
except Exception:
|
|
1757
|
+
pass
|
|
1598
1758
|
log("INFO", f"Processed {uploaded} Codex turn(s), total {ss.turn_count} for {session_path}")
|
|
1599
1759
|
return 0
|
|
1600
1760
|
except Exception as e:
|
package/langfuse_hook.py
CHANGED
|
@@ -626,6 +626,156 @@ def discover_known_skills(extra_roots: Optional[List[Path]] = None) -> set:
|
|
|
626
626
|
continue
|
|
627
627
|
return names
|
|
628
628
|
|
|
629
|
+
|
|
630
|
+
def collect_skill_roots(cwd: Path) -> list:
|
|
631
|
+
"""Return absolute <dir>/skills paths that contain SKILL.md, found among
|
|
632
|
+
cwd itself, cwd's children, and cwd's siblings. Mirrors the OpenCode
|
|
633
|
+
opencode-skills-discover.mjs collectSkillRoots so skills cloned near the
|
|
634
|
+
working directory can be linked into the native skills dir."""
|
|
635
|
+
def _has_skill(d: Path) -> bool:
|
|
636
|
+
try:
|
|
637
|
+
if not d.is_dir():
|
|
638
|
+
return False
|
|
639
|
+
for entry in d.iterdir():
|
|
640
|
+
if entry.name == "SKILL.md" and entry.is_file():
|
|
641
|
+
return True
|
|
642
|
+
if entry.is_dir() and not entry.name.startswith(".") and (entry / "SKILL.md").exists():
|
|
643
|
+
return True
|
|
644
|
+
except Exception:
|
|
645
|
+
pass
|
|
646
|
+
return False
|
|
647
|
+
|
|
648
|
+
candidates = []
|
|
649
|
+
for sub in (Path("skills"), Path(".opencode") / "skills", Path(".opencode") / "skill"):
|
|
650
|
+
candidates.append(cwd / sub)
|
|
651
|
+
try:
|
|
652
|
+
for entry in cwd.iterdir():
|
|
653
|
+
if entry.is_dir() and not entry.name.startswith("."):
|
|
654
|
+
candidates.append(entry / "skills")
|
|
655
|
+
except Exception:
|
|
656
|
+
pass
|
|
657
|
+
parent = cwd.parent
|
|
658
|
+
if parent != cwd:
|
|
659
|
+
try:
|
|
660
|
+
for entry in parent.iterdir():
|
|
661
|
+
if entry.is_dir() and entry.name != cwd.name and not entry.name.startswith("."):
|
|
662
|
+
candidates.append(entry / "skills")
|
|
663
|
+
except Exception:
|
|
664
|
+
pass
|
|
665
|
+
|
|
666
|
+
roots = []
|
|
667
|
+
seen = set()
|
|
668
|
+
for d in candidates:
|
|
669
|
+
try:
|
|
670
|
+
if _has_skill(d):
|
|
671
|
+
key = str(d.resolve())
|
|
672
|
+
if key not in seen:
|
|
673
|
+
seen.add(key)
|
|
674
|
+
roots.append(d.resolve())
|
|
675
|
+
except Exception:
|
|
676
|
+
continue
|
|
677
|
+
return roots
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _skill_link_exists(link: Path) -> bool:
|
|
681
|
+
try:
|
|
682
|
+
return link.exists() or link.is_symlink()
|
|
683
|
+
except Exception:
|
|
684
|
+
return False
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _create_skill_link(target: Path, link: Path) -> bool:
|
|
688
|
+
"""Create a junction (Windows, no admin) or symlink (Unix) at link -> target.
|
|
689
|
+
Returns False if link already exists or creation failed.
|
|
690
|
+
|
|
691
|
+
On Windows we always use a directory junction (mklink /J): Codex follows
|
|
692
|
+
junctions but does not follow true symlinks for skill discovery, while
|
|
693
|
+
Claude Code follows both. Junctions do not require admin/developer mode."""
|
|
694
|
+
if _skill_link_exists(link):
|
|
695
|
+
return False
|
|
696
|
+
try:
|
|
697
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
698
|
+
except Exception:
|
|
699
|
+
pass
|
|
700
|
+
if sys.platform == "win32":
|
|
701
|
+
try:
|
|
702
|
+
r = subprocess.run(
|
|
703
|
+
["cmd", "/c", "mklink", "/J", str(link), str(target)],
|
|
704
|
+
capture_output=True,
|
|
705
|
+
)
|
|
706
|
+
return r.returncode == 0
|
|
707
|
+
except Exception:
|
|
708
|
+
return False
|
|
709
|
+
try:
|
|
710
|
+
os.symlink(str(target), str(link), target_is_directory=True)
|
|
711
|
+
return True
|
|
712
|
+
except OSError:
|
|
713
|
+
return False
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
def _remove_skill_link(link: Path) -> None:
|
|
717
|
+
"""Remove a junction/symlink we created, without touching the target."""
|
|
718
|
+
try:
|
|
719
|
+
if sys.platform == "win32":
|
|
720
|
+
subprocess.run(["cmd", "/c", "rmdir", str(link)], capture_output=True)
|
|
721
|
+
else:
|
|
722
|
+
os.unlink(str(link))
|
|
723
|
+
except Exception:
|
|
724
|
+
pass
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
def link_nearby_skills(cwd: Path, native_dir: Path, state_file: Path) -> int:
|
|
728
|
+
"""Symlink nearby repo skills into native_dir so the agent discovers them on
|
|
729
|
+
the next turn/session. Idempotent; skips names already present (native installs
|
|
730
|
+
or prior links). Cleans stale links we created whose target is gone. Failures
|
|
731
|
+
are swallowed so the metrics hook never breaks. Returns newly-linked count."""
|
|
732
|
+
try:
|
|
733
|
+
native_dir.mkdir(parents=True, exist_ok=True)
|
|
734
|
+
except Exception:
|
|
735
|
+
return 0
|
|
736
|
+
state = {}
|
|
737
|
+
try:
|
|
738
|
+
if state_file.exists():
|
|
739
|
+
state = json.loads(state_file.read_text("utf-8") or "{}")
|
|
740
|
+
except Exception:
|
|
741
|
+
state = {}
|
|
742
|
+
for link_str, target_str in list(state.items()):
|
|
743
|
+
link = Path(link_str)
|
|
744
|
+
target = Path(target_str)
|
|
745
|
+
try:
|
|
746
|
+
if _skill_link_exists(link):
|
|
747
|
+
if not target.exists():
|
|
748
|
+
_remove_skill_link(link)
|
|
749
|
+
state.pop(link_str, None)
|
|
750
|
+
else:
|
|
751
|
+
state.pop(link_str, None)
|
|
752
|
+
except Exception:
|
|
753
|
+
pass
|
|
754
|
+
linked = 0
|
|
755
|
+
for repo_skills_dir in collect_skill_roots(cwd):
|
|
756
|
+
try:
|
|
757
|
+
for skill_dir in repo_skills_dir.iterdir():
|
|
758
|
+
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
|
|
759
|
+
continue
|
|
760
|
+
if not (skill_dir / "SKILL.md").exists():
|
|
761
|
+
continue
|
|
762
|
+
try:
|
|
763
|
+
target = skill_dir.resolve()
|
|
764
|
+
except Exception:
|
|
765
|
+
continue
|
|
766
|
+
link_path = native_dir / skill_dir.name
|
|
767
|
+
if _create_skill_link(target, link_path):
|
|
768
|
+
state[str(link_path)] = str(target)
|
|
769
|
+
linked += 1
|
|
770
|
+
except Exception:
|
|
771
|
+
pass
|
|
772
|
+
try:
|
|
773
|
+
state_file.write_text(json.dumps(state, ensure_ascii=False), "utf-8")
|
|
774
|
+
except Exception:
|
|
775
|
+
pass
|
|
776
|
+
return linked
|
|
777
|
+
|
|
778
|
+
|
|
629
779
|
def _skill_namespace(name: str) -> str:
|
|
630
780
|
return name.split(":", 1)[0] if ":" in name else ""
|
|
631
781
|
|
|
@@ -1067,7 +1217,12 @@ def emit_turn(
|
|
|
1067
1217
|
usage_details = get_usage(last_assistant)
|
|
1068
1218
|
|
|
1069
1219
|
tool_calls = _tool_calls_from_assistants(turn.assistant_msgs)
|
|
1070
|
-
|
|
1220
|
+
cwd = Path.cwd()
|
|
1221
|
+
try:
|
|
1222
|
+
known_skills = discover_known_skills(collect_skill_roots(cwd))
|
|
1223
|
+
except Exception:
|
|
1224
|
+
known_skills = discover_known_skills()
|
|
1225
|
+
skill_usages = detect_turn_skill_usages(turn, tool_calls, known_skills)
|
|
1071
1226
|
interaction_id = build_interaction_id("claude", session_id, turn_num)
|
|
1072
1227
|
skill_use_events = build_skill_use_events(interaction_id, skill_usages)
|
|
1073
1228
|
interaction_meta = build_interaction_metadata(
|
|
@@ -1173,6 +1328,7 @@ def emit_turn(
|
|
|
1173
1328
|
|
|
1174
1329
|
trace_span.update(output={"role": "assistant", "content": assistant_text})
|
|
1175
1330
|
|
|
1331
|
+
|
|
1176
1332
|
# ----------------- Main -----------------
|
|
1177
1333
|
def main() -> int:
|
|
1178
1334
|
start = time.time()
|
|
@@ -1183,7 +1339,7 @@ def main() -> int:
|
|
|
1183
1339
|
|
|
1184
1340
|
public_key = os.environ.get("CC_LANGFUSE_PUBLIC_KEY") or os.environ.get("LANGFUSE_PUBLIC_KEY")
|
|
1185
1341
|
secret_key = os.environ.get("CC_LANGFUSE_SECRET_KEY") or os.environ.get("LANGFUSE_SECRET_KEY")
|
|
1186
|
-
host = os.environ.get("CC_LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_BASEURL") or "https://
|
|
1342
|
+
host = os.environ.get("CC_LANGFUSE_BASE_URL") or os.environ.get("LANGFUSE_BASEURL") or "https://metrics.openharmonyinsight.cn"
|
|
1187
1343
|
|
|
1188
1344
|
if not public_key or not secret_key:
|
|
1189
1345
|
warn("Missing Langfuse public/secret key in hook environment; exiting.")
|
|
@@ -1249,6 +1405,13 @@ def main() -> int:
|
|
|
1249
1405
|
warn(f"Langfuse flush failed: {e}")
|
|
1250
1406
|
pass
|
|
1251
1407
|
|
|
1408
|
+
# Best-effort: link nearby repo skills into ~/.claude/skills. Runs
|
|
1409
|
+
# AFTER the Langfuse flush so it can never block or break metrics.
|
|
1410
|
+
try:
|
|
1411
|
+
link_nearby_skills(Path.cwd(), Path.home() / ".claude" / "skills", Path.home() / ".claude" / "skills" / ".oh-linked.json")
|
|
1412
|
+
except Exception:
|
|
1413
|
+
pass
|
|
1414
|
+
|
|
1252
1415
|
dur = time.time() - start
|
|
1253
1416
|
info(f"Processed {emitted} turns in {dur:.2f}s (session={session_id})")
|
|
1254
1417
|
return 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "oh-langfuse",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.80",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Use npm scripts to configure Claude Code / OpenCode / Codex with Langfuse tracing.",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"scripts/opencode-langfuse-check.mjs",
|
|
24
24
|
"scripts/opencode-langfuse-run.mjs",
|
|
25
25
|
"scripts/opencode-langfuse-setup.mjs",
|
|
26
|
+
"scripts/opencode-skills-discover.mjs",
|
|
26
27
|
"scripts/resolve-opencode-cli.mjs",
|
|
27
28
|
"scripts/real-self-verify.mjs",
|
|
28
29
|
"scripts/log-filter-utils.mjs",
|
|
@@ -6,10 +6,10 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { extractVersionFromNpmMetadata, isNewerVersion } from "./update-utils.mjs";
|
|
7
7
|
import { getRuntimeInstallRecord } from "./runtime-state-utils.mjs";
|
|
8
8
|
|
|
9
|
-
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
-
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
11
|
-
const ALLOWED_TARGETS = new Set(["claude", "opencode", "codex"]);
|
|
12
|
-
const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
9
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
10
|
+
const packageJson = JSON.parse(fs.readFileSync(path.join(rootDir, "package.json"), "utf8"));
|
|
11
|
+
const ALLOWED_TARGETS = new Set(["claude", "opencode", "codex"]);
|
|
12
|
+
const OFFICIAL_NPM_REGISTRY = "https://registry.npmjs.org/";
|
|
13
13
|
|
|
14
14
|
const colorEnabled = process.stdout.isTTY && process.env.NO_COLOR !== "1";
|
|
15
15
|
const ansi = (code) => (colorEnabled ? `\x1b[${code}m` : "");
|
|
@@ -40,7 +40,7 @@ function parseArgs(argv) {
|
|
|
40
40
|
return args;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
async function latestVersion(packageName, registry = OFFICIAL_NPM_REGISTRY) {
|
|
43
|
+
async function latestVersion(packageName, registry = OFFICIAL_NPM_REGISTRY) {
|
|
44
44
|
const base = registry.replace(/\/+$/, "");
|
|
45
45
|
const response = await fetch(`${base}/${packageName}`, { headers: { accept: "application/json" } });
|
|
46
46
|
if (!response.ok) throw new Error(`npm registry ${response.status} ${response.statusText}`);
|
|
@@ -106,15 +106,15 @@ function printAlreadyCurrent(target, packageName, version) {
|
|
|
106
106
|
console.log(paint(`[OK] ${label} Langfuse \u5df2\u662f\u6700\u65b0\uff1a${packageName}@${version}`, t.bold, t.green));
|
|
107
107
|
}
|
|
108
108
|
|
|
109
|
-
function updateCommandForPackage(packageName, target) {
|
|
110
|
-
if (packageName === "oh-aicoding-tool") return `npx --registry=${OFFICIAL_NPM_REGISTRY} oh-aicoding-tool@latest langfuse update ${target}`;
|
|
111
|
-
return `npx --registry=${OFFICIAL_NPM_REGISTRY} ${packageName}@latest update ${target}`;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function updateArgsForPackage(packageName, target) {
|
|
115
|
-
if (packageName === "oh-aicoding-tool") return ["-y", `--registry=${OFFICIAL_NPM_REGISTRY}`, "oh-aicoding-tool@latest", "langfuse", "update", target];
|
|
116
|
-
return ["-y", `--registry=${OFFICIAL_NPM_REGISTRY}`, `${packageName}@latest`, "update", target];
|
|
117
|
-
}
|
|
109
|
+
function updateCommandForPackage(packageName, target) {
|
|
110
|
+
if (packageName === "oh-aicoding-tool") return `npx --registry=${OFFICIAL_NPM_REGISTRY} oh-aicoding-tool@latest langfuse update ${target}`;
|
|
111
|
+
return `npx --registry=${OFFICIAL_NPM_REGISTRY} ${packageName}@latest update ${target}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function updateArgsForPackage(packageName, target) {
|
|
115
|
+
if (packageName === "oh-aicoding-tool") return ["-y", `--registry=${OFFICIAL_NPM_REGISTRY}`, "oh-aicoding-tool@latest", "langfuse", "update", target];
|
|
116
|
+
return ["-y", `--registry=${OFFICIAL_NPM_REGISTRY}`, `${packageName}@latest`, "update", target];
|
|
117
|
+
}
|
|
118
118
|
|
|
119
119
|
function runUpdate(target, packageName, args) {
|
|
120
120
|
const updateArgs = updateArgsForPackage(packageName, target);
|